Files
tehran_taj/apps/api/src/modules/admin-orders/admin-orders.service.ts
T
s.esmaeiliandClaude Opus 5.5 fb25b2251b fix(ledger): an order's debt follows its status, as legacy recomputed it (ACC-03)
Legacy never posted order debt; updateRemain recomputed it as
SUM(orders.input) WHERE sts > 4. The port posted it once on entering
DELIVERED_TO_AGENT, guarded on deliveredAt, which:
- posted it twice when an order reached that status again
- posted nothing for an order sent straight to DELIVERED_TO_CUSTOMER
- kept it after a rollback or cancellation

syncOrderDebt compares what the order should owe with the confirmed
ORDER_STOCK entries on it and posts only the difference (append-only,
idempotent). Status removal runs it too, so removing «تحویل به نماینده»
is allowed again, as in legacy. Existing data: 0 orders out of line.

Owner approval: 2026-10-08.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:57:38 +03:30

422 lines
16 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 { 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<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,
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: { 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 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;
});
}
}