fix(money,orders): wage/gram-debt parity, atomic document confirm, expert edit, made-to-order, QA fixes

- 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>
This commit is contained in:
mahmood
2026-10-29 18:09:02 +03:30
co-authored by Claude Sonnet 5.5
parent 959a272227
commit 6514b05d18
321 changed files with 6984 additions and 2170 deletions
@@ -6,6 +6,7 @@ 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';
/**
@@ -44,6 +45,7 @@ export class AdminOrdersService {
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. */
@@ -54,6 +56,7 @@ export class AdminOrdersService {
'createdAt',
'updatedAt',
'totalGoldWeightGrams',
'wageWeightGrams',
'flatRialCharge',
'isSuspendedOverCreditLimit',
'customer.fullName',
@@ -61,6 +64,25 @@ export class AdminOrdersService {
'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.
*
@@ -70,7 +92,7 @@ export class AdminOrdersService {
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, {
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' },
@@ -125,12 +147,24 @@ export class AdminOrdersService {
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,
@@ -143,9 +177,16 @@ export class AdminOrdersService {
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),
})),
};
}
@@ -176,7 +217,7 @@ export class AdminOrdersService {
async listTamin(query: TableQueryDto, branchId?: string) {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(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' },
@@ -236,6 +277,31 @@ export class AdminOrdersService {
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({
@@ -312,17 +378,32 @@ export class AdminOrdersService {
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,
// 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),
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) } : {}),