- Rial wage and gram-debt arithmetic matched to the reference; order corrections keep debt in sync - Document confirm/un-confirm is a compare-and-swap (no double-applied cache under concurrency) - Settlement document and expert inputs validated (400 instead of 500) - Expert: edit / activate-deactivate own customers, adjust raw weights only - Made-to-order flow in the app, PreOrder.quantity migration - Order grids: customer column, sort keys; panel select search; pre-order quantity save - Rebuilt release artifacts Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
506 lines
20 KiB
TypeScript
506 lines
20 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 { 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<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,
|
||
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<string, string> = {
|
||
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<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;
|
||
});
|
||
}
|
||
}
|