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 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, ) {} /** 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.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, 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(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 `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('سبد سفارش خالی است'); } 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 = { 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; }); } }