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:
co-authored by
Claude Sonnet 5.5
parent
959a272227
commit
6514b05d18
@@ -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) } : {}),
|
||||
|
||||
Reference in New Issue
Block a user