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 { AccountingHooksService } from '../accounting-sync/accounting-hooks.service'; import { OrdersService } from '../orders/orders.service'; import { LedgerService } from '../accounting/ledger.service'; import { WageService } from '../pricing/wage.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 = { 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, private readonly accounting: AccountingHooksService, private readonly ledger: LedgerService, private readonly wage: WageService, ) {} /** Fields the XTable filter bar and sorter may reference. */ private static readonly ALLOWED = [ 'id', 'orderNumber', 'status', 'createdAt', 'updatedAt', 'totalGoldWeightGrams', 'wageWeightGrams', 'flatRialCharge', 'isSuspendedOverCreditLimit', 'customer.fullName', 'customer.mobile', 'branch.name', ]; /** * The grids send the column KEY as the sort field. Three of those keys are the * reference's names, not columns of this table, and used to answer a click on * their header with a 400 («جمع کل سفارش», «اجرت (گرم)/ریالی», «مشتری»). */ private static readonly SORT_ALIASES: Record = { input: 'totalGoldWeightGrams', wage: 'wageWeightGrams', rial_input: 'flatRialCharge', 'customer.name': 'customer.fullName', }; private withSortAlias(query: TableQueryDto): TableQueryDto { if (!query.order) return query; const field = query.order.replace(/^[+-]/, ''); const real = AdminOrdersService.SORT_ALIASES[field]; return real ? { ...query, order: `${query.order.startsWith('-') ? '-' : '+'}${real}` } : query; } /** * 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(this.withSortAlias(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, // The wholesale / sourcing grids bind the reference's order columns // (`input`, `wage`, `rial_input`, `discount`); with only the target's own // names in the row those cells rendered empty, so the order's total grams, // making-charge grams and rial making charge were not shown at all. input: o.totalGoldWeightGrams, w: o.totalGoldWeightGrams, wage: o.wageWeightGrams, rial_input: o.flatRialCharge, // Reference: array_sum of the lines' discount percents (setOrder). discount: o.items.reduce((s, it) => s.add(it.discountPercentSnapshot), new Prisma.Decimal(0)), 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, // The «مشتری» column of the order grids binds `customer.name`. name: o.customer.fullName, // 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.firstName ?? o.customer.fullName, lname: o.customer.lastName ?? '', 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, // Per unit — `qty` sits beside it. grams: it.computedWeightGrams, // What the line owes: unit × qty (the figures that sum to the order's). total_grams: it.computedWeightGrams.mul(it.quantity), rash: it.rashWeightGramsSnapshot, percent: it.wagePercentSnapshot.sub(it.discountPercentSnapshot), // Per unit, as the variant's fixed rial making charge (legacy rial_pay). flatRialCharge: it.flatRialCharge, rial_pay: it.flatRialCharge, total_rial: it.flatRialCharge.mul(it.quantity), })), }; } /** * 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(this.withSortAlias(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(async (tx) => { // D27 — a temporary document may already be in the books (the rule can // post it at placement); withdraw it like a cancellation. await this.accounting.orderCancelled(tx, id); await tx.orderStatusEvent.deleteMany({ where: { orderId: id } }); await tx.orderItem.deleteMany({ where: { orderId: id } }); await tx.order.delete({ where: { id } }); }); return { success: true }; } /** * Legacy `POST order-item/edit` — correct one line of an order: raw weight, * quantity and the net percent charged. The rules, and the totals and debt * that follow, live in OrdersService.editLine. */ async editItem(body: { id?: string; rash_weight?: unknown; qty?: unknown; percent?: unknown }) { if (!body?.id) throw new BadRequestException('ردیف سفارش مشخص نشده است'); const line = await this.prisma.orderItem.findUnique({ where: { id: String(body.id) }, select: { orderId: true } }); if (!line) throw new NotFoundException('ردیف سفارش یافت نشد'); await this.orders.editLine(line.orderId, String(body.id), { rashWeightGrams: body.rash_weight, quantity: body.qty, netPercent: body.percent, }); return this.findOne(line.orderId); } /** Legacy `DELETE major/order_item/{id}`. */ async removeItem(lineId: string) { const line = await this.prisma.orderItem.findUnique({ where: { id: lineId }, select: { orderId: true } }); if (!line) throw new NotFoundException('ردیف سفارش یافت نشد'); await this.orders.removeLine(line.orderId, lineId); return this.findOne(line.orderId); } /** 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: { actorAdmin: 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('سبد سفارش خالی است'); } // What the app would charge this customer for each variant: their own // percent plus the variant's, less the largest promotion. A line the // operator did not give a percent for is priced at that figure, so the same // order costs the same from the panel as from the app (the reference only // ever priced wholesale orders this way, in the cart). A percent typed // explicitly still wins — that is the operator's negotiated figure. const quotes = await this.wage.quote( payload.customer_id, payload.order_items.map((it) => it.vid), ); 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}`); const quote = quotes.get(it.vid); const typed = it.percent !== undefined && it.percent !== null; return { productVariantId: it.vid, quantity: Number(it.qty) || 1, wagePercent: typed ? Number(it.percent) : quote ? Number(quote.wagePercentSnapshot) : Number(variant.defaultWagePercent), discountPercent: typed || !quote ? Number(it.discount ?? 0) : Number(it.discount ?? quote.discountPercentSnapshot), // Omitted rial_input is left unset so createOrder copies variant.rialWage. // An explicit 0 stays 0: the operator cleared the charge. ...(it.rial_input != null ? { flatRialCharge: Number(it.rial_input) } : {}), }; }), ); // 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 = { 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 keeps the order's debt in line with its status * (syncOrderDebt). 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. * * Any status may be removed, as in legacy (MajorOrderController.php:180-203), * which stepped `sts` back and then RECOMPUTED the debt (updateRemain). The * rollback below does the same through syncOrderDebt, so taking an order * back below «تحویل به نماینده» takes its debt off, and nothing is left * behind. (This used to be refused, on the belief that legacy left the debt * in place; legacy recomputed it.) */ private async removeStatusEvent(orderId: string, status: OrderStatus) { const event = await this.prisma.orderStatusEvent.findFirst({ where: { orderId, toStatus: status }, orderBy: { createdAt: 'desc' }, }); if (!event) throw new NotFoundException('وضعیت مورد نظر یافت نشد'); // One transaction: the event, the status and the debt move together or not at all. return this.prisma.$transaction(async (tx) => { await tx.orderStatusEvent.delete({ where: { id: event.id } }); // Roll the order back to whatever status is now the latest recorded one. const latest = await tx.orderStatusEvent.findFirst({ where: { orderId }, orderBy: { createdAt: 'desc' }, }); const rolledBackTo = latest?.toStatus ?? OrderStatus.PLACED; const updated = await tx.order.update({ where: { id: orderId }, data: { status: rolledBackTo } }); // Legacy updateRemain after the step back: the debt follows the status. await this.ledger.syncOrderDebt(orderId, tx); // D27 — rolled back below the document rule: the پیش‌فاکتور is withdrawn. await this.accounting.orderStatusReached(tx, orderId, rolledBackTo); return updated; }); } }