Files
tehran_taj/apps/api/src/modules/admin-orders/admin-orders.service.ts
T
s.esmaeiliandClaude Opus 5 024e3dd045 parity(api,apps): close SEC-02 - orders take the customer from the token
The user declined to defer this to Phase 2, correctly: a public POST /orders
with customerId in the body let anyone place an order on any customer's
account, and an order lands on that customer's gold debt and ledger.

POST /orders is now behind CustomerAuth. OrdersService.createOrder takes the
customer id as a separate argument that the controller fills from the token;
the body's customerId is ignored and a mismatch is logged. The field stays
optional in the DTO and in shared-types only so both apps keep validating
while they still send it -- they drop it in Phase 2. The admin builder is
unaffected: it legitimately places orders for a chosen customer and passes
that id explicitly, behind the admin guard.

GET /orders/:id is scoped to the owner. A foreign or nonexistent id both
return 404, never 403, so ids stay unenumerable. Operators read through the
new GET /orders/admin/:id behind rid order.list, declared before the :id
route because Nest matches in declaration order.

Both apps' CartView now gates on session.isLoggedIn rather than a remembered
customerId, which is no longer a credential.

OTP audit (requested): no master code, no bypass, no hardcoded code anywhere;
one code path issues a token and only after bcrypt.compare. No SMS provider is
wired up -- in development the code is logged once, guarded by
NODE_ENV !== 'production'.

Fixed while auditing (SEC-11): deliver() persisted the real message text, code
included, into SmsMessage.body, which the admin SMS grid lists and full-text
searches. Any operator with the sms rid could read live codes, defeating the
hashing of codeHash. The stored row is now a redacted audit line.

Also logged MON-08: wagePercent/discountPercent/flatRialCharge are still taken
from the client on order creation. The reference is worse -- it takes
rash_weight from the client too -- so this is already a partial improvement;
logged rather than changed, per D4.

Tests: auth-smoke.sh now 53 passed, 0 failed, including a spoofed body
customerId being ignored in favour of the token. Typecheck clean on api,
user-app, user-app-v2 and shared-types.

[ref: henza_server ApiMobileController.php:533,536-545,615-632]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 07:09:33 +03:30

414 lines
15 KiB
TypeScript

import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { OrderChannel, OrderStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
import { OrdersService } from '../orders/orders.service';
import type { UpdateOrderStatusDto } from './update-status.dto';
/**
* Farsi status labels, matching legacy's `sts_str` column.
*
* Legacy stored status as an integer `sts` and the server derived the string via
* Order::$SELECT_STS_STR. The new schema uses an enum, so the mapping is explicit.
* Debt is recognised at sts>4 (DELIVERED_TO_AGENT) — see the schema notes.
*/
const STATUS_LABELS: Record<OrderStatus, string> = {
SUSPENDED: 'معلق (سقف اعتبار)',
PLACED: 'ثبت شده',
ADMIN_APPROVED: 'تایید مدیر',
SOURCING: 'در حال تامین',
PREPARING: 'در حال آماده‌سازی',
DELIVERED_TO_AGENT: 'تحویل به نماینده',
DELIVERED_TO_CUSTOMER: 'تحویل به مشتری',
CANCELLED: 'لغو شده',
};
/** Order of the status timeline shown in the legacy "وضعیت سفارش" modal. */
const STATUS_FLOW: OrderStatus[] = [
OrderStatus.PLACED,
OrderStatus.ADMIN_APPROVED,
OrderStatus.SOURCING,
OrderStatus.PREPARING,
OrderStatus.DELIVERED_TO_AGENT,
OrderStatus.DELIVERED_TO_CUSTOMER,
];
@Injectable()
export class AdminOrdersService {
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
private readonly orders: OrdersService,
) {}
/** Fields the XTable filter bar and sorter may reference. */
private static readonly ALLOWED = [
'id',
'orderNumber',
'status',
'createdAt',
'updatedAt',
'totalGoldWeightGrams',
'flatRialCharge',
'isSuspendedOverCreditLimit',
'customer.fullName',
'customer.mobile',
'branch.name',
];
/**
* Legacy `POST single-order` — the order list grid.
*
* `branchId` narrows the result to one branch, which is how the branch-mode
* build of the panel scopes its list.
*/
async list(query: TableQueryDto, branchId?: string, channel: OrderChannel = OrderChannel.RETAIL) {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['orderNumber', 'customer.fullName', 'customer.mobile'],
allowed: AdminOrdersService.ALLOWED,
defaultOrderBy: { createdAt: 'desc' },
});
const scoped: Prisma.OrderWhereInput = {
AND: [
where as Prisma.OrderWhereInput,
{ channel },
...(branchId ? [{ branchId }] : []),
],
};
const [rows, total] = await Promise.all([
this.prisma.order.findMany({
where: scoped,
orderBy: orderBy as Prisma.OrderOrderByWithRelationInput,
skip,
take,
include: {
customer: true,
branch: true,
items: { include: { productVariant: { include: { product: true } } } },
},
}),
this.prisma.order.count({ where: scoped }),
]);
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
}
/**
* Shapes a row the way the ported list view reads it.
*
* Legacy field names are preserved (`sts_str`, `caj`, `total_price`, `items`)
* so the ported template bindings keep working unchanged.
*/
private toRow(o: Prisma.OrderGetPayload<{
include: {
customer: true;
branch: true;
items: { include: { productVariant: { include: { product: true } } } };
};
}>) {
return {
id: o.id,
orderNumber: o.orderNumber,
status: o.status,
sts_str: STATUS_LABELS[o.status],
caj: o.createdAt,
uaj: o.updatedAt,
total_price: o.flatRialCharge,
totalGoldWeightGrams: o.totalGoldWeightGrams,
wageWeightGrams: o.wageWeightGrams,
isSuspendedOverCreditLimit: o.isSuspendedOverCreditLimit,
deliveredAt: o.deliveredAt,
branch_id: o.branchId,
branch: o.branch ? { id: o.branch.id, name: o.branch.name } : null,
customer: {
id: o.customer.id,
// Legacy split names into fname/lname; the new schema has one fullName.
// Both are exposed so ported templates binding either keep working.
fname: o.customer.fullName,
lname: '',
fullName: o.customer.fullName,
phone: o.customer.mobile,
},
items: o.items.map((it) => ({
id: it.id,
title: it.productVariant.product.title,
variant_title: it.productVariant.title,
qty: it.quantity,
grams: it.computedWeightGrams,
rash: it.rashWeightGramsSnapshot,
})),
};
}
/**
* Legacy `POST major/order/table` — the wholesale (عمده) order grid.
*
* Legacy kept the two sales channels in SEPARATE tables: `orders_orders`
* (SingleCustomerOrders, served by AdminController::getSingleOrders) and
* `orders` (Order, served by MajorOrderController::index). Neither controller
* applied a type filter because the table itself was the discriminator, so the
* two lists never overlapped.
*
* The redesigned schema has one Order model, so `Order.channel` carries that
* distinction instead and each list filters on it.
*/
async listWholesale(query: TableQueryDto, branchId?: string) {
return this.list(query, branchId, OrderChannel.WHOLESALE);
}
/**
* Legacy `POST order-tamin` — MajorOrderController::getTaminOrders.
*
* Legacy narrowed to orders whose activity contained Order::$STS[3], i.e. those
* that have reached the sourcing stage. The equivalent here is a status event
* recording a transition into SOURCING.
*/
async listTamin(query: TableQueryDto, branchId?: string) {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
searchable: ['orderNumber', 'customer.fullName', 'customer.mobile'],
allowed: AdminOrdersService.ALLOWED,
defaultOrderBy: { createdAt: 'desc' },
});
const conditions: Prisma.OrderWhereInput[] = [
where as Prisma.OrderWhereInput,
{ statusEvents: { some: { toStatus: OrderStatus.SOURCING } } },
];
if (branchId) conditions.push({ branchId });
const scoped: Prisma.OrderWhereInput = { AND: conditions };
const [rows, total] = await Promise.all([
this.prisma.order.findMany({
where: scoped,
orderBy: orderBy as Prisma.OrderOrderByWithRelationInput,
skip,
take,
include: {
customer: true,
branch: true,
items: { include: { productVariant: { include: { product: true } } } },
},
}),
this.prisma.order.count({ where: scoped }),
]);
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
}
/** Legacy `POST delete-order` — remove a wholesale order. */
async remove(id: string) {
const order = await this.prisma.order.findUnique({
where: { id },
include: { ledgerEntries: true },
});
if (!order) throw new NotFoundException('سفارش یافت نشد');
// Refuses once debt has been posted: deleting the order would orphan the
// ledger entries and leave the customer permanently in debt. Legacy had no
// such guard. Recorded in docs/ADMIN-PORT-PLAN.md.
if (order.ledgerEntries.length > 0) {
throw new BadRequestException(
'حذف این سفارش ممکن نیست، چون برای آن سند حسابداری ثبت شده است.',
);
}
await this.prisma.$transaction([
this.prisma.orderStatusEvent.deleteMany({ where: { orderId: id } }),
this.prisma.orderItem.deleteMany({ where: { orderId: id } }),
this.prisma.order.delete({ where: { id } }),
]);
return { success: true };
}
/** Legacy `GET order/:id` detail, including the status timeline. */
async findOne(id: string) {
const order = await this.prisma.order.findUnique({
where: { id },
include: {
customer: true,
branch: true,
items: { include: { productVariant: { include: { product: true } } } },
statusEvents: { include: { actorAdminUser: true }, orderBy: { createdAt: 'asc' } },
},
});
if (!order) throw new NotFoundException('سفارش یافت نشد');
const reached = new Set(order.statusEvents.map((e) => e.toStatus));
return {
...this.toRow(order),
// legacy `activity`: one entry per step, `proceed` marking those reached
activity: STATUS_FLOW.map((s) => {
const event = order.statusEvents.find((e) => e.toStatus === s);
return {
status: s,
title: STATUS_LABELS[s],
proceed: reached.has(s),
uaj: event?.createdAt ?? null,
note: event?.note ?? '',
};
}),
};
}
/** Farsi labels for the status dropdowns. */
statusOptions() {
return Object.entries(STATUS_LABELS).map(([value, title]) => ({ value, title }));
}
/**
* Legacy `GET order/type` — the "نوع فروش" dropdown.
* Legacy Order::$TYPES = [1 => 'ثبت سفارش توسط مسئول فروش', 2 => 'ثبت مشتری'].
*/
typeOptions() {
return { 1: 'ثبت سفارش توسط مسئول فروش', 2: 'ثبت مشتری' };
}
/**
* Legacy `POST /order` from views/order/create.vue — the admin order builder.
*
* Delegates to OrdersService.createOrder, which owns the weight formula
* w = rash * (1 + (wagePercent - discountPercent)/100)
* and the credit-limit gate that suspends rather than rejects an over-limit
* order. Reimplementing either here would let the two paths drift.
*
* Legacy also posted total_price / total_weight / gold_price_original computed
* in the BROWSER. Those are ignored: the server recomputes them from the
* variant snapshots, so a tampered or stale client total cannot alter a debt.
*/
async createFromAdmin(
payload: {
customer_id: string;
order_items: {
vid: string;
qty: number;
percent?: number;
discount?: number;
rial_input?: number;
}[];
sts?: number | string;
note?: string;
},
actorAdminUserId: string,
) {
if (!payload.customer_id) throw new BadRequestException('مشتری انتخاب نشده است');
if (!Array.isArray(payload.order_items) || payload.order_items.length === 0) {
throw new BadRequestException('سبد سفارش خالی است');
}
const items = await Promise.all(
payload.order_items.map(async (it) => {
const variant = await this.prisma.productVariant.findUnique({ where: { id: it.vid } });
if (!variant) throw new NotFoundException(`تنوع محصول یافت نشد: ${it.vid}`);
return {
productVariantId: it.vid,
quantity: Number(it.qty) || 1,
// Fall back to the variant's own default wage when the builder did not
// negotiate a different one.
wagePercent: Number(it.percent ?? variant.defaultWagePercent),
discountPercent: Number(it.discount ?? 0),
flatRialCharge: Number(it.rial_input ?? 0),
};
}),
);
// The customer id is now a separate argument rather than a body field, so
// the customer-facing route can take it from the caller's token (SEC-02).
// This path is different: an operator legitimately places an order FOR a
// customer, so it passes the one the builder selected. The route itself is
// admin-guarded, which is what makes that safe.
const order = await this.orders.createOrder(
{
items,
// The admin builder serves the omde (major/*) flow, so orders it creates
// belong to the wholesale channel — legacy wrote them to the `orders`
// table, which MajorOrderController::index listed.
channel: OrderChannel.WHOLESALE,
},
payload.customer_id,
);
// legacy `sts`: advance the new order to the chosen delivery status.
const target = this.statusFromLegacyCode(payload.sts);
if (target && target !== OrderStatus.PLACED) {
await this.orders.updateStatus(order.id, target, actorAdminUserId, payload.note);
}
return this.findOne(order.id);
}
/**
* Legacy delivery-status codes from create.vue's `sts` map:
* 1 ثبت سفارش | 2 تایید مدیر سیستم | 3 در حال تامین
* 4 در حال آماده سازی | 5 ارسال | 6 تحویل مشتری
*/
private statusFromLegacyCode(code?: number | string): OrderStatus | undefined {
const map: Record<number, OrderStatus> = {
1: OrderStatus.PLACED,
2: OrderStatus.ADMIN_APPROVED,
3: OrderStatus.SOURCING,
4: OrderStatus.PREPARING,
5: OrderStatus.DELIVERED_TO_AGENT,
6: OrderStatus.DELIVERED_TO_CUSTOMER,
};
return code === undefined ? undefined : map[Number(code)];
}
/**
* Legacy `POST single/order-sts/edit` — advance or remove a status.
*
* Setting a status delegates to OrdersService.updateStatus rather than writing
* the row here, because that method also posts the gold debt to the ledger at
* DELIVERED_TO_AGENT. Duplicating the update would silently skip that.
*/
async setStatus(dto: UpdateOrderStatusDto, actorAdminUserId: string) {
if (dto.remove) {
return this.removeStatusEvent(dto.orderId, dto.status);
}
return this.orders.updateStatus(dto.orderId, dto.status, actorAdminUserId, dto.note);
}
/**
* Legacy's `remove: true` branch: drop a recorded status event.
*
* Deliberately refuses to remove DELIVERED_TO_AGENT. In legacy, deleting that
* row left the posted debt ledger entries behind, so the customer stayed in
* debt for an order no longer marked delivered. Rather than reproduce a
* data-corrupting path, this rejects and explains — recorded in
* docs/ADMIN-PORT-PLAN.md.
*/
private async removeStatusEvent(orderId: string, status: OrderStatus) {
if (status === OrderStatus.DELIVERED_TO_AGENT) {
throw new BadRequestException(
'حذف وضعیت «تحویل به نماینده» ممکن نیست، چون بدهی طلا در همین مرحله ثبت شده است.',
);
}
const event = await this.prisma.orderStatusEvent.findFirst({
where: { orderId, toStatus: status },
orderBy: { createdAt: 'desc' },
});
if (!event) throw new NotFoundException('وضعیت مورد نظر یافت نشد');
await this.prisma.orderStatusEvent.delete({ where: { id: event.id } });
// Roll the order back to whatever status is now the latest recorded one.
const latest = await this.prisma.orderStatusEvent.findFirst({
where: { orderId },
orderBy: { createdAt: 'desc' },
});
return this.prisma.order.update({
where: { id: orderId },
data: { status: latest?.toStatus ?? OrderStatus.PLACED },
});
}
}