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
@@ -1,4 +1,4 @@
import { Body, Controller, Get, HttpCode, Param, Post } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, Param, Post } from '@nestjs/common';
import { AdminOrdersService } from './admin-orders.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { UpdateOrderStatusDto } from './update-status.dto';
@@ -73,7 +73,7 @@ export class AdminOrdersController {
@Body()
body: {
customer_id: string;
order_items: { vid: string; qty: number; percent?: number; discount?: number }[];
order_items: { vid: string; qty: number; percent?: number; discount?: number; rial_input?: number }[];
sts?: number | string;
note?: string;
},
@@ -82,6 +82,21 @@ export class AdminOrdersController {
return this.orders.createFromAdmin(body, admin.id);
}
/** Legacy POST order-item/edit — correct one line of a wholesale order. */
@Post('item/edit')
@HttpCode(200)
@RequirePermission('order.omde.view.edit')
editItem(@Body() body: { id?: string; rash_weight?: unknown; qty?: unknown; percent?: unknown }) {
return this.orders.editItem(body);
}
/** Legacy DELETE major/order_item/{id}. */
@Delete('item/:id')
@RequirePermission('order.omde.view.edit')
removeItem(@Param('id') id: string) {
return this.orders.removeItem(id);
}
/** Legacy GET order/type. */
@Get('type-options')
@SuperAdminOnly()
@@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { OrdersModule } from '../orders/orders.module';
import { AccountingModule } from '../accounting/accounting.module';
import { PricingModule } from '../pricing/pricing.module';
import { AdminOrdersController } from './admin-orders.controller';
import { AdminOrdersService } from './admin-orders.service';
@Module({
imports: [PrismaModule, OrdersModule, AccountingModule],
imports: [PrismaModule, OrdersModule, AccountingModule, PricingModule],
controllers: [AdminOrdersController],
providers: [AdminOrdersService],
})
@@ -0,0 +1,214 @@
import { OrderChannel, OrderStatus, Prisma } from '@prisma/client';
import { AdminOrdersService } from './admin-orders.service';
/**
* The wholesale and sourcing grids bind the reference's order columns
* (`input`, `wage`, `rial_input`, `discount`) and the modal binds per-line
* figures. The row the API returns has to carry them, with the line figures
* adding up to the order's.
*/
describe('AdminOrdersService.findOne — the row the grids bind', () => {
const D = (v: Prisma.Decimal.Value) => new Prisma.Decimal(v);
const order = {
id: 'o1',
orderNumber: '100',
status: OrderStatus.DELIVERED_TO_AGENT,
channel: OrderChannel.WHOLESALE,
createdAt: new Date('2026-10-01'),
updatedAt: new Date('2026-10-02'),
branchId: null,
branch: null,
deliveredAt: null,
isSuspendedOverCreditLimit: false,
// setOrder: allGram, allGram − Σ rash×qty, Σ rial_pay×qty
totalGoldWeightGrams: D('59.9'),
wageWeightGrams: D('5.5'),
flatRialCharge: D('1250000'),
customer: { id: 'c1', firstName: 'علی', lastName: 'رضایی', fullName: 'علی رضایی', mobile: '0912' },
statusEvents: [],
items: [
{
id: 'i1',
quantity: 3,
computedWeightGrams: D('6.3'),
rashWeightGramsSnapshot: D('5.7856'),
wagePercentSnapshot: D('10'),
discountPercentSnapshot: D('2'),
flatRialCharge: D('150000'),
productVariant: { title: '5 گرم', product: { title: 'النگو' } },
},
{
id: 'i2',
quantity: 10,
computedWeightGrams: D('4.1'),
rashWeightGramsSnapshot: D('2.5'),
wagePercentSnapshot: D('12'),
discountPercentSnapshot: D('3'),
flatRialCharge: D('80000'),
productVariant: { title: '2.5 گرم', product: { title: 'گوشواره' } },
},
],
};
const service = new AdminOrdersService(
{ order: { findUnique: jest.fn(async () => order) } } as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
it('carries the legacy order columns the wholesale and sourcing grids bind', async () => {
const row = (await service.findOne('o1')) as Record<string, any>;
expect(row.input.toString()).toBe('59.9'); // جمع کل سفارش
expect(row.wage.toString()).toBe('5.5'); // اجرت (گرم)
expect(row.rial_input.toString()).toBe('1250000'); // اجرت ریالی
expect(row.discount.toString()).toBe('5'); // Σ the lines' discount percents (array_sum in setOrder)
});
it('names the customer under the key the «مشتری» column binds (customer.name)', async () => {
const row = (await service.findOne('o1')) as Record<string, any>;
expect(row.customer.name).toBe('علی رضایی');
});
it('gives each line its per-unit figures and what the line owes', async () => {
const row = (await service.findOne('o1')) as Record<string, any>;
const [a, b] = row.items;
expect(a.grams.toString()).toBe('6.3');
expect(a.total_grams.toString()).toBe('18.9'); // 6.3 × 3
expect(a.percent.toString()).toBe('8'); // 10 − 2: the percent actually charged
expect(a.rial_pay.toString()).toBe('150000');
expect(a.total_rial.toString()).toBe('450000'); // × qty
expect(b.total_rial.toString()).toBe('800000');
});
it("the lines' rial totals add up to the order's rial making charge", async () => {
const row = (await service.findOne('o1')) as Record<string, any>;
const sum = row.items.reduce((s: Prisma.Decimal, it: any) => s.add(it.total_rial), D(0));
expect(sum.toString()).toBe(row.rial_input.toString());
});
});
/**
* The panel's order builder: a line the operator gave no percent for is priced
* at what the app would charge this customer; a percent typed on purpose wins.
*/
describe('AdminOrdersService.createFromAdmin — the percent a line is placed at', () => {
const D = (v: Prisma.Decimal.Value) => new Prisma.Decimal(v);
function build() {
const created: any[] = [];
const prisma = {
productVariant: { findUnique: jest.fn(async ({ where }: any) => ({ id: where.id, defaultWagePercent: D(7) })) },
order: { findUnique: jest.fn(async () => ({ ...baseOrder })) },
};
const orders = {
createOrder: jest.fn(async (dto: any, customerId: string) => {
created.push({ dto, customerId });
return { id: 'o1' };
}),
updateStatus: jest.fn(),
};
// 7 (variant) + 3 (customer) − 2 (largest promotion) = 8 net, stored as 10 charge / 2 discount.
const wage = {
quote: jest.fn(
async (_c: string, ids: string[]) =>
new Map(ids.map((id) => [id, { wagePercentSnapshot: '10', discountPercentSnapshot: '2', finalPercent: '8' }])),
),
};
const service = new AdminOrdersService(prisma as never, {} as never, orders as never, {} as never, {} as never, wage as never);
return { service, created, wage };
}
// A minimal order for the findOne that closes createFromAdmin.
const baseOrder = {
id: 'o1',
orderNumber: '1',
status: OrderStatus.PLACED,
channel: OrderChannel.WHOLESALE,
createdAt: new Date(),
updatedAt: new Date(),
branchId: null,
branch: null,
deliveredAt: null,
isSuspendedOverCreditLimit: false,
totalGoldWeightGrams: D(0),
wageWeightGrams: D(0),
flatRialCharge: D(0),
customer: { id: 'c1', firstName: 'a', lastName: 'b', fullName: 'a b', mobile: '0' },
statusEvents: [],
items: [],
};
it("places a line with no percent at the customer's quoted figure (net 8%), like the app", async () => {
const { service, created, wage } = build();
await service.createFromAdmin({ customer_id: 'c1', order_items: [{ vid: 'v1', qty: 2 }] }, 'admin1');
expect(wage.quote).toHaveBeenCalledWith('c1', ['v1']);
const line = created[0].dto.items[0];
expect(line.wagePercent - line.discountPercent).toBe(8);
expect(created[0].customerId).toBe('c1');
});
it('a percent the operator typed is used as typed', async () => {
const { service, created } = build();
await service.createFromAdmin({ customer_id: 'c1', order_items: [{ vid: 'v1', qty: 1, percent: 4.5 }] }, 'admin1');
const line = created[0].dto.items[0];
expect(line.wagePercent).toBe(4.5);
expect(line.discountPercent).toBe(0);
});
it('a rial charge the operator typed (even 0) is passed on; an omitted one is left for the variant', async () => {
const { service, created } = build();
await service.createFromAdmin(
{ customer_id: 'c1', order_items: [{ vid: 'v1', qty: 1, rial_input: 0 }, { vid: 'v2', qty: 1 }] },
'admin1',
);
const [a, b] = created[0].dto.items;
expect(a.flatRialCharge).toBe(0);
expect('flatRialCharge' in b).toBe(false);
});
});
/**
* The grid sends the COLUMN key as the sort field (`order: '-input'`), which is
* not always a column of the table: «جمع کل سفارش» (`input`), «اجرت ریالی»
* (`rial_input`) and «مشتری» (`customer.name`) answered a click on their header
* with a 400 "Field not filterable".
*/
describe('AdminOrdersService — sorting by the grid column keys', () => {
const D = (v: Prisma.Decimal.Value) => new Prisma.Decimal(v);
const { TableService } = jest.requireActual('../admin-table/table.service') as typeof import('../admin-table/table.service');
function build() {
const findMany = jest.fn(async () => []);
const prisma = { order: { findMany, count: jest.fn(async () => 0) } };
const service = new AdminOrdersService(prisma as never, new TableService() as never, {} as never, {} as never, {} as never, {} as never);
return { service, findMany };
}
const cases: [string, string, unknown][] = [
['-input', 'wholesale', { totalGoldWeightGrams: 'desc' }],
['+rial_input', 'wholesale', { flatRialCharge: 'asc' }],
['-customer.name', 'wholesale', { customer: { fullName: 'desc' } }],
['-input', 'tamin', { totalGoldWeightGrams: 'desc' }],
['+rial_input', 'tamin', { flatRialCharge: 'asc' }],
['+customer.name', 'tamin', { customer: { fullName: 'asc' } }],
];
it.each(cases)('order %s (%s grid) sorts by the real field', async (order, grid, expected) => {
const { service, findMany } = build();
const run = grid === 'tamin' ? service.listTamin.bind(service) : service.listWholesale.bind(service);
await run({ page: 1, limit: 10, order } as never);
expect((findMany.mock.calls[0] as any)[0].orderBy).toEqual(expected);
});
it('a field that is really not sortable keeps the default order (no error, nothing extra exposed)', async () => {
const { service, findMany } = build();
await service.listWholesale({ page: 1, limit: 10, order: '-passwordHash' } as never);
expect((findMany.mock.calls[0] as any)[0].orderBy).toEqual({ createdAt: 'desc' });
});
void D;
});
@@ -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) } : {}),