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
@@ -20,6 +20,7 @@ import { AdminTableModule } from './modules/admin-table/admin-table.module';
|
||||
import { AdminOrdersModule } from './modules/admin-orders/admin-orders.module';
|
||||
import { AdminCitiesModule } from './modules/admin-cities/admin-cities.module';
|
||||
import { AdminPreOrdersModule } from './modules/admin-preorders/admin-preorders.module';
|
||||
import { CustomerPreOrdersModule } from './modules/customer-preorders/customer-preorders.module';
|
||||
import { AdminWorkshopsModule } from './modules/admin-workshops/admin-workshops.module';
|
||||
import { AdminCustomersModule } from './modules/admin-customers/admin-customers.module';
|
||||
import { AdminCommentsModule } from './modules/admin-comments/admin-comments.module';
|
||||
@@ -48,6 +49,7 @@ import { SalesAgentModule } from './modules/sales-agent/sales-agent.module';
|
||||
AdminOrdersModule,
|
||||
AdminCitiesModule,
|
||||
AdminPreOrdersModule,
|
||||
CustomerPreOrdersModule,
|
||||
AdminWorkshopsModule,
|
||||
AdminCustomersModule,
|
||||
AdminCommentsModule,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { faToEn } from '../modules/customer-auth/digits';
|
||||
|
||||
/**
|
||||
* PERS-03: the birth date both customer forms collect (legacy bulkCustomer
|
||||
* `birth_date`, singleCustomer `birth_day`). DateInput sends the Gregorian
|
||||
* 'YYYY-MM-DD' of the Jalali date picked; stored at UTC midnight, as the
|
||||
* import stores legacy's. undefined = not sent (leave as is), '' = cleared.
|
||||
*
|
||||
* Shared by the admin customer forms and the sales expert's «ویرایش مشتری» in
|
||||
* the app, so both read a date the same way.
|
||||
*/
|
||||
export function birthDateOrNull(raw: unknown): Date | null | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
if (raw === null || String(raw).trim() === '') return null;
|
||||
const m = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(String(raw).trim());
|
||||
const d = m ? new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])) : null;
|
||||
if (!d || d.getUTCMonth() !== +m![2] - 1 || d.getUTCFullYear() < 1900 || d > new Date()) {
|
||||
throw new BadRequestException('تاریخ تولد معتبر نیست');
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* A national code as the operator typed it, digits normalised; empty -> null.
|
||||
* The column is optional and unique (2026-10-07): an absent code is NULL,
|
||||
* never the mobile or '' — two customers without one must not collide, and
|
||||
* the column must only ever hold a national code.
|
||||
*/
|
||||
export function nationalCodeOrNull(raw: unknown): string | null {
|
||||
return faToEn(String(raw ?? '').trim()) || null;
|
||||
}
|
||||
@@ -104,3 +104,58 @@ describe('document rule (پیشفاکتور at a chosen status)', () => {
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a corrected order and its document (lines or weights edited after placing)', () => {
|
||||
const row = (status: string, kind = 'ORDER_DOCUMENT', provider = 'tahesab'): Row => ({ id: String(Math.random()), provider, kind, entityId: 'o1', status });
|
||||
|
||||
it('a document still waiting is left alone: it is built from the order when sent', async () => {
|
||||
const rows = [row('PENDING')];
|
||||
await hooks('PLACED').orderEdited(fakeTx(rows) as any, 'o1');
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].status).toBe('PENDING');
|
||||
});
|
||||
|
||||
it('a document already sent is withdrawn and a corrected one queued behind the delete', async () => {
|
||||
const rows = [row('SENT')];
|
||||
await hooks('PLACED').orderEdited(fakeTx(rows) as any, 'o1');
|
||||
expect(docs(rows, 'ORDER_DOCUMENT_DELETE')).toHaveLength(1);
|
||||
expect(docs(rows).filter((r) => r.status === 'PENDING')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('works for Zarrin too (its delete runs first, then the corrected document posts)', async () => {
|
||||
const rows = [row('SENT', 'ORDER_DOCUMENT', 'zarrin')];
|
||||
await hooks('PLACED', 'zarrin').orderEdited(fakeTx(rows) as any, 'o1');
|
||||
expect(docs(rows, 'ORDER_DOCUMENT_DELETE')).toHaveLength(1);
|
||||
expect(docs(rows).filter((r) => r.status === 'PENDING')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a second correction before the first has gone out queues nothing more', async () => {
|
||||
const rows = [row('SENT')];
|
||||
const h = hooks('PLACED');
|
||||
await h.orderEdited(fakeTx(rows) as any, 'o1');
|
||||
await h.orderEdited(fakeTx(rows) as any, 'o1');
|
||||
expect(docs(rows, 'ORDER_DOCUMENT_DELETE')).toHaveLength(1);
|
||||
expect(docs(rows).filter((r) => r.status === 'PENDING')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('an order with no document yet needs nothing', async () => {
|
||||
const rows: Row[] = [];
|
||||
await hooks('PLACED').orderEdited(fakeTx(rows) as any, 'o1');
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does nothing while the document feature is off', async () => {
|
||||
const rows = [row('SENT')];
|
||||
const h = hooks('PLACED');
|
||||
((await (h as any).config.get()) as AccountingConfig).features.documents = false;
|
||||
await h.orderEdited(fakeTx(rows) as any, 'o1');
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a document being sent, or waiting for review, blocks the correction', async () => {
|
||||
expect(await hooks('PLACED').orderDocumentBlocksEdit(fakeTx([row('SENDING')]) as any, 'o1')).toContain('سند حسابداری');
|
||||
expect(await hooks('PLACED').orderDocumentBlocksEdit(fakeTx([row('NEEDS_REVIEW')]) as any, 'o1')).toContain('سند حسابداری');
|
||||
expect(await hooks('PLACED').orderDocumentBlocksEdit(fakeTx([row('SENT')]) as any, 'o1')).toBeNull();
|
||||
expect(await hooks('PLACED').orderDocumentBlocksEdit(fakeTx([]) as any, 'o1')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,6 +108,52 @@ export class AccountingHooksService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* F3 — may this order's lines or weights be corrected now? A document that is
|
||||
* being sent, or is waiting for a person to look at it, cannot be reconciled
|
||||
* with a change automatically, so the correction is refused until it is
|
||||
* settled. Returns the reason, or null when the correction may go ahead.
|
||||
*/
|
||||
async orderDocumentBlocksEdit(tx: Tx, orderId: string): Promise<string | null> {
|
||||
const c = await this.config.get();
|
||||
if (c.provider === 'none' || !c.features.documents) return null;
|
||||
const stuck = await tx.accountingOutbox.findFirst({
|
||||
where: { provider: c.provider, kind: 'ORDER_DOCUMENT', entityId: orderId, status: { in: ['SENDING', 'NEEDS_REVIEW'] } },
|
||||
select: { id: true },
|
||||
});
|
||||
return stuck
|
||||
? 'سند حسابداری این سفارش در حال ارسال است یا منتظر بررسی؛ پس از روشن شدن وضعیتش دوباره تلاش کنید.'
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* F3 — the order's lines or weights were corrected after it was placed.
|
||||
*
|
||||
* A document still waiting (or failed and due for a retry) is built from the
|
||||
* order at the moment it is sent, so it picks the correction up by itself.
|
||||
* One already in the books no longer matches: it is withdrawn and a corrected
|
||||
* one is queued behind the delete (the worker runs the older job first).
|
||||
*/
|
||||
async orderEdited(tx: Tx, orderId: string) {
|
||||
const c = await this.config.get();
|
||||
if (c.provider === 'none' || !c.features.documents) return;
|
||||
const sent = await tx.accountingOutbox.findFirst({
|
||||
where: { provider: c.provider, kind: 'ORDER_DOCUMENT', entityId: orderId, status: { in: ['SENT'] } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!sent) return;
|
||||
// Already withdrawn and re-queued by an earlier correction: that new job
|
||||
// will read the latest figures too.
|
||||
const requeued = await tx.accountingOutbox.findFirst({
|
||||
where: { provider: c.provider, kind: 'ORDER_DOCUMENT', entityId: orderId, status: { in: ['PENDING'] } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (requeued) return;
|
||||
await this.orderCancelled(tx, orderId);
|
||||
await tx.accountingOutbox.create({ data: { provider: c.provider, kind: 'ORDER_DOCUMENT', entityId: orderId } });
|
||||
this.worker.kickSoon();
|
||||
}
|
||||
|
||||
/** F1 — an admin marked the customer VERIFIED. */
|
||||
async customerVerified(tx: Tx, customerId: string) {
|
||||
await this.enqueue(tx, 'customers', 'CUSTOMER_PUSH', customerId);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash';
|
||||
import { ActiveProvider, AccountingConfigService } from './accounting-config.service';
|
||||
import { AccountingConfig } from './accounting-config';
|
||||
import { JalaliDate, parseJalali, toJalali } from './jalali';
|
||||
import { documentWeightTolerance } from './document-check';
|
||||
import {
|
||||
AccountingProviderError,
|
||||
CustomerPayload,
|
||||
@@ -844,7 +845,7 @@ export class AccountingSyncService {
|
||||
new Prisma.Decimal(0),
|
||||
);
|
||||
const mismatch = docGold.sub(order.totalGoldWeightGrams).abs();
|
||||
if (mismatch.gt('0.01')) {
|
||||
if (mismatch.gt(documentWeightTolerance(order.items.reduce((s, i) => s + i.quantity, 0)))) {
|
||||
throw new NeedsReviewError(
|
||||
`جمع وزن سند (${docGold.toFixed(3)}) با وزن سفارش (${new Prisma.Decimal(order.totalGoldWeightGrams).toFixed(3)}) نمیخواند`,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { documentWeightTolerance } from './document-check';
|
||||
|
||||
/**
|
||||
* The document's gold is rebuilt from lines whose unit weight is stored to four
|
||||
* decimals; the order's total is not. The allowance has to cover that rounding
|
||||
* for every piece, or a large order is held for review for nothing.
|
||||
*/
|
||||
describe('documentWeightTolerance', () => {
|
||||
const r4 = (x: number) => Math.round(x * 1e4) / 1e4;
|
||||
|
||||
it('is 0.01 g plus 0.0001 g per piece', () => {
|
||||
expect(documentWeightTolerance(0).toString()).toBe('0.01');
|
||||
expect(documentWeightTolerance(10).toString()).toBe('0.011');
|
||||
expect(documentWeightTolerance(200).toString()).toBe('0.03');
|
||||
});
|
||||
|
||||
it('never goes below the flat 0.01 g', () => {
|
||||
expect(documentWeightTolerance(-5).toString()).toBe('0.01');
|
||||
});
|
||||
|
||||
it('covers the worst rounding of a big order (the case a flat 0.01 g rejected)', () => {
|
||||
// 5 lines × 200 pieces; 7.00003 × 1.085 = 7.5950326, stored as 7.5950, so every
|
||||
// piece is short by the same 0.0000326 g and the errors add up instead of cancelling.
|
||||
const lines = Array.from({ length: 5 }, () => ({ rash: 7.00003, pct: 8.5, qty: 200 }));
|
||||
let total = 0;
|
||||
let stored = 0;
|
||||
for (const l of lines) {
|
||||
const unit = l.rash * (1 + l.pct / 100);
|
||||
total += unit * l.qty;
|
||||
stored += r4(unit) * l.qty;
|
||||
}
|
||||
const drift = new Prisma.Decimal(Math.abs(r4(total) - stored));
|
||||
const pieces = lines.reduce((s, l) => s + l.qty, 0);
|
||||
expect(drift.gt('0.01')).toBe(true); // a flat 0.01 g would have held this order
|
||||
expect(drift.lte(documentWeightTolerance(pieces))).toBe(true);
|
||||
});
|
||||
|
||||
it('still catches a real disagreement', () => {
|
||||
// 1 g missing from a 1000-piece order is far beyond rounding.
|
||||
expect(new Prisma.Decimal(1).gt(documentWeightTolerance(1000))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* How far an order document's gold may differ from the order's own total
|
||||
* before the document is held for review (F3 acceptance rule).
|
||||
*
|
||||
* The order total comes from the unrounded sum of its lines, but each line
|
||||
* stores its UNIT weight to four decimals, and the document is built from the
|
||||
* stored lines. A unit can therefore be off by up to 0.00005 g, and a line of
|
||||
* `qty` units by `qty` times that — a flat 0.01 g allowance is crossed by a
|
||||
* big order (about 200 pieces in all) through rounding alone, with nothing
|
||||
* wrong in the books. The allowance grows with the pieces: 0.01 g plus twice
|
||||
* the worst per-unit rounding for every piece.
|
||||
*/
|
||||
export function documentWeightTolerance(totalQuantity: number): Prisma.Decimal {
|
||||
return new Prisma.Decimal('0.01').add(new Prisma.Decimal('0.0001').mul(Math.max(0, totalQuantity)));
|
||||
}
|
||||
@@ -1,5 +1,35 @@
|
||||
import { LedgerCurrency, LedgerDirection, OrderStatus, Prisma } from '@prisma/client';
|
||||
import { LedgerService } from './ledger.service';
|
||||
import { LedgerService, repaymentToGrams } from './ledger.service';
|
||||
|
||||
/**
|
||||
* The repayment conversions, against the reference's CashController::store
|
||||
* worked by hand:
|
||||
* type 1 value = original_value × ayar / 750
|
||||
* type 4 value = price / (price_per_gram / 4.331802)
|
||||
*/
|
||||
describe('repaymentToGrams', () => {
|
||||
it('melted gold: 20 g at 900 is 24 g of 750', () => {
|
||||
expect(repaymentToGrams('MELTED_GOLD', '20', 900).toString()).toBe('24');
|
||||
expect(repaymentToGrams('MELTED_GOLD', '10', 750).toString()).toBe('10');
|
||||
expect(repaymentToGrams('MELTED_GOLD', '12.5', 916).toDecimalPlaces(4).toString()).toBe('15.2667');
|
||||
});
|
||||
|
||||
it('cash: 50,000,000 at 20,000,000 per mesghal is 10.8295 g', () => {
|
||||
// 50e6 × 4.331802 / 20e6
|
||||
expect(repaymentToGrams('CASH', '50000000', undefined, '20000000').toDecimalPlaces(6).toString()).toBe('10.829505');
|
||||
});
|
||||
|
||||
it('transfer and cheque are the grams as typed', () => {
|
||||
expect(repaymentToGrams('TRANSFER', '7.25').toString()).toBe('7.25');
|
||||
expect(repaymentToGrams('CHEQUE', '3').toString()).toBe('3');
|
||||
});
|
||||
|
||||
it('refuses a melted-gold figure without a karat and a cash figure without a price', () => {
|
||||
expect(() => repaymentToGrams('MELTED_GOLD', '20')).toThrow('karatPerMille');
|
||||
expect(() => repaymentToGrams('CASH', '100')).toThrow('pricePerGramRial');
|
||||
expect(() => repaymentToGrams('CASH', '100', undefined, '0')).toThrow('pricePerGramRial');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* ACC-03 / MON-04 — an order's debt follows its status, exactly as legacy's
|
||||
@@ -92,3 +122,129 @@ describe('LedgerService.syncOrderDebt', () => {
|
||||
expect(t.entries).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `confirmed` on a repayment: an admin's own document counts at once (the
|
||||
* reference's store sets confirmed = 1); the default stays pending.
|
||||
*/
|
||||
describe('LedgerService.postGoldRepayment — confirmed', () => {
|
||||
function build() {
|
||||
const balance = { gold: new Prisma.Decimal(10) };
|
||||
const created: any[] = [];
|
||||
const tx = {
|
||||
ledgerEntry: {
|
||||
create: jest.fn(async ({ data }: any) => {
|
||||
created.push(data);
|
||||
return { id: 'e1', ...data };
|
||||
}),
|
||||
},
|
||||
customer: {
|
||||
update: jest.fn(async ({ data }: any) => {
|
||||
balance.gold = balance.gold.add(data.goldDebtGrams.increment);
|
||||
}),
|
||||
},
|
||||
};
|
||||
return { service: new LedgerService(tx as never), balance, created, tx };
|
||||
}
|
||||
|
||||
it('confirmed: the debt drops at once by the converted grams', async () => {
|
||||
const t = build();
|
||||
await t.service.postGoldRepayment(
|
||||
{ customerId: 'c1', instrument: 'MELTED_GOLD', rawValue: '20', karatPerMille: 900, confirmed: true },
|
||||
t.tx as never,
|
||||
);
|
||||
expect(t.balance.gold.toString()).toBe('-14'); // 10 − 24
|
||||
expect(t.created[0]).toMatchObject({ confirmed: true });
|
||||
});
|
||||
|
||||
it('by default: pending, the debt does not move', async () => {
|
||||
const t = build();
|
||||
await t.service.postGoldRepayment({ customerId: 'c1', instrument: 'TRANSFER', rawValue: '3' }, t.tx as never);
|
||||
expect(t.balance.gold.toString()).toBe('10');
|
||||
expect(t.created[0]).toMatchObject({ confirmed: false });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Confirming / un-confirming is a flag flip plus a move of the cached balance.
|
||||
* Check-then-update let N simultaneous requests (a double-click, two admins) all
|
||||
* pass the check and all move the balance, leaving the cache N times off the book
|
||||
* (found by firing 10 toggles at one document: book 8.04 g, cache 71.44 g).
|
||||
* The flip is now a compare-and-swap and only its winner moves the balance.
|
||||
*/
|
||||
describe('LedgerService — simultaneous confirm / un-confirm', () => {
|
||||
const D = (v: Prisma.Decimal.Value) => new Prisma.Decimal(v);
|
||||
const tick = () => new Promise<void>((r) => setImmediate(r));
|
||||
|
||||
function book(confirmed: boolean) {
|
||||
const row: Record<string, any> = {
|
||||
id: 'e1',
|
||||
customerId: 'c1',
|
||||
currency: LedgerCurrency.GOLD_GRAM,
|
||||
direction: LedgerDirection.CREDIT,
|
||||
amount: D(7),
|
||||
confirmed,
|
||||
confirmedAt: null,
|
||||
postedByAdminId: null,
|
||||
};
|
||||
const balance = { gold: D(confirmed ? -7 : 0) };
|
||||
// Each call yields first, so concurrent callers interleave exactly as separate requests do.
|
||||
// `updateMany` checks and sets with nothing awaited between them: the row lock of a real UPDATE ... WHERE.
|
||||
const db: any = {
|
||||
ledgerEntry: {
|
||||
findUnique: jest.fn(async () => (await tick(), { ...row })),
|
||||
update: jest.fn(async ({ data }: any) => (await tick(), Object.assign(row, data), { ...row })),
|
||||
updateMany: jest.fn(async ({ where, data }: any) => {
|
||||
await tick();
|
||||
if (row.id !== where.id || row.confirmed !== where.confirmed) return { count: 0 };
|
||||
Object.assign(row, data);
|
||||
return { count: 1 };
|
||||
}),
|
||||
},
|
||||
customer: {
|
||||
update: jest.fn(async ({ data }: any) => {
|
||||
await tick();
|
||||
balance.gold = balance.gold.add(data.goldDebtGrams.increment);
|
||||
return {};
|
||||
}),
|
||||
},
|
||||
};
|
||||
db.$transaction = jest.fn(async (fn: any) => fn({ ...db })); // a real transaction client is not the root client
|
||||
return { service: new LedgerService(db as never), row, balance, db };
|
||||
}
|
||||
|
||||
it('ten simultaneous confirms of a pending credit take 7 g off the debt once', async () => {
|
||||
const { service, balance, row } = book(false);
|
||||
await Promise.all(Array.from({ length: 10 }, () => service.confirmEntry('e1', 'admin1')));
|
||||
expect(row.confirmed).toBe(true);
|
||||
expect(balance.gold.toString()).toBe('-7');
|
||||
});
|
||||
|
||||
it('ten simultaneous un-confirms of a confirmed credit put the 7 g back once', async () => {
|
||||
const { service, balance, row } = book(true);
|
||||
const results = await Promise.all(Array.from({ length: 10 }, () => service.setConfirmed('e1', false)));
|
||||
expect(row.confirmed).toBe(false);
|
||||
expect(balance.gold.toString()).toBe('0');
|
||||
expect(results.filter((r) => r.changed)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('confirming something already confirmed changes nothing', async () => {
|
||||
const { service, balance } = book(true);
|
||||
const r = await service.setConfirmed('e1', true);
|
||||
expect(r.changed).toBe(false);
|
||||
expect(balance.gold.toString()).toBe('-7');
|
||||
});
|
||||
|
||||
it('a confirm then an un-confirm leaves the balance exactly where it started', async () => {
|
||||
const { service, balance } = book(false);
|
||||
await service.setConfirmed('e1', true);
|
||||
await service.setConfirmed('e1', false);
|
||||
expect(balance.gold.toString()).toBe('0');
|
||||
});
|
||||
|
||||
it('an unknown entry is still a 404', async () => {
|
||||
const { service, db } = book(false);
|
||||
db.ledgerEntry.findUnique.mockResolvedValueOnce(null);
|
||||
await expect(service.confirmEntry('nope')).rejects.toThrow('not found');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,43 @@ const ZERO = new Prisma.Decimal(0);
|
||||
/** Legacy `sts > 4`: delivered to the expert (5) or to the customer (6). */
|
||||
const OWING_STATUSES: OrderStatus[] = [OrderStatus.DELIVERED_TO_AGENT, OrderStatus.DELIVERED_TO_CUSTOMER];
|
||||
|
||||
export type RepaymentInstrument = 'MELTED_GOLD' | 'TRANSFER' | 'CHEQUE' | 'CASH';
|
||||
|
||||
/**
|
||||
* The raw figure of a repayment document → the gold grams it takes off the
|
||||
* debt. One place, used both when the document is filed and when an unconfirmed
|
||||
* one is edited, so the two can never disagree (legacy CashController::store
|
||||
* and ::update).
|
||||
*
|
||||
* MELTED_GOLD value = original_value × ayar / 750
|
||||
* CASH value = price / (price_per_gram / 4.331802)
|
||||
* TRANSFER / CHEQUE the grams as typed
|
||||
*/
|
||||
export function repaymentToGrams(
|
||||
instrument: RepaymentInstrument,
|
||||
rawValue: Prisma.Decimal.Value,
|
||||
karatPerMille?: number | null,
|
||||
pricePerGramRial?: Prisma.Decimal.Value | null,
|
||||
): Prisma.Decimal {
|
||||
const raw = new Prisma.Decimal(rawValue);
|
||||
switch (instrument) {
|
||||
case 'MELTED_GOLD':
|
||||
if (!karatPerMille) throw new BadRequestException('karatPerMille is required for a melted-gold repayment');
|
||||
return raw.mul(karatPerMille).div(STANDARD_KARAT_PER_MILLE);
|
||||
case 'CASH': {
|
||||
if (!pricePerGramRial || new Prisma.Decimal(pricePerGramRial).isZero()) {
|
||||
throw new BadRequestException('pricePerGramRial is required for a cash repayment');
|
||||
}
|
||||
// legacy: value = price / (price_per_gram / 4.331802) — preserved verbatim;
|
||||
// flagged in migration analysis as a formula worth stakeholder review.
|
||||
return raw.div(new Prisma.Decimal(pricePerGramRial).div(GRAMS_PER_MESGHAL));
|
||||
}
|
||||
case 'TRANSFER':
|
||||
case 'CHEQUE':
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
export interface PostEntryInput {
|
||||
customerId: string;
|
||||
currency: LedgerCurrency;
|
||||
@@ -77,17 +114,50 @@ export class LedgerService {
|
||||
* change from legacy, not a faithful mirror of it.
|
||||
*/
|
||||
async confirmEntry(entryId: string, adminUserId?: string, tx: Tx = this.prisma) {
|
||||
return (await this.setConfirmed(entryId, true, adminUserId, tx)).entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an entry's confirmed flag and moves the cached balance by exactly that
|
||||
* change, once. Un-confirming reverses the entry: the same amount, the other way.
|
||||
*
|
||||
* The flip is a compare-and-swap (`updateMany` while the flag still has its old
|
||||
* value) and only the request that wins it touches the balance. Reading the flag
|
||||
* and then updating let N simultaneous requests all pass the read and all move
|
||||
* the balance, leaving the cache N times off the book. The flip and the balance
|
||||
* move commit together.
|
||||
*
|
||||
* `changed` is false when someone else already put the entry in that state.
|
||||
*/
|
||||
async setConfirmed(
|
||||
entryId: string,
|
||||
confirmed: boolean,
|
||||
adminUserId?: string,
|
||||
tx: Tx = this.prisma,
|
||||
): Promise<{ entry: Prisma.LedgerEntryGetPayload<object>; changed: boolean }> {
|
||||
if (tx === this.prisma) {
|
||||
return this.prisma.$transaction((inner) => this.setConfirmed(entryId, confirmed, adminUserId, inner));
|
||||
}
|
||||
|
||||
const entry = await tx.ledgerEntry.findUnique({ where: { id: entryId } });
|
||||
if (!entry) throw new NotFoundException('Ledger entry not found');
|
||||
if (entry.confirmed) return entry;
|
||||
|
||||
const updated = await tx.ledgerEntry.update({
|
||||
where: { id: entryId },
|
||||
data: { confirmed: true, confirmedAt: new Date(), postedByAdminId: adminUserId ?? entry.postedByAdminId },
|
||||
const claimed = await tx.ledgerEntry.updateMany({
|
||||
where: { id: entryId, confirmed: !confirmed },
|
||||
data: confirmed
|
||||
? { confirmed: true, confirmedAt: new Date(), postedByAdminId: adminUserId ?? entry.postedByAdminId }
|
||||
: { confirmed: false, confirmedAt: null },
|
||||
});
|
||||
const current = (await tx.ledgerEntry.findUnique({ where: { id: entryId } })) ?? entry;
|
||||
if (claimed.count === 0) return { entry: current, changed: false };
|
||||
|
||||
await this.applyToBalance(entry.customerId, entry.currency, entry.direction, entry.amount, tx);
|
||||
return updated;
|
||||
const direction = confirmed
|
||||
? entry.direction
|
||||
: entry.direction === LedgerDirection.DEBIT
|
||||
? LedgerDirection.CREDIT
|
||||
: LedgerDirection.DEBIT;
|
||||
await this.applyToBalance(entry.customerId, entry.currency, direction, entry.amount, tx);
|
||||
return { entry: current, changed: true };
|
||||
}
|
||||
|
||||
private async applyToBalance(
|
||||
@@ -166,58 +236,40 @@ export class LedgerService {
|
||||
|
||||
/**
|
||||
* Normalizes a physical repayment instrument into gold grams and posts a
|
||||
* pending (unconfirmed) CREDIT entry against the customer's gold debt.
|
||||
* Formulas preserved verbatim from legacy CashController::store. Note:
|
||||
* legacy auto-confirms (confirmed=1) every Cash row at creation — posting
|
||||
* this as pending instead is an intentional design change, not a mirror of
|
||||
* legacy's default.
|
||||
* CREDIT entry against the customer's gold debt. Formulas preserved verbatim
|
||||
* from legacy CashController::store.
|
||||
*
|
||||
* `confirmed` follows the reference: a document an ADMIN files
|
||||
* (CashController::store, `confirmed = 1`) counts at once; one a customer
|
||||
* submits from the app (ApiMobileController::setFindoc, `confirmed = 0`) waits
|
||||
* for an admin. The default stays pending — the safe state for a caller that
|
||||
* does not say who is filing — and the admin documents screen passes true.
|
||||
*/
|
||||
async postGoldRepayment(
|
||||
params: {
|
||||
customerId: string;
|
||||
instrument: 'MELTED_GOLD' | 'TRANSFER' | 'CHEQUE' | 'CASH';
|
||||
instrument: RepaymentInstrument;
|
||||
rawValue: Prisma.Decimal.Value;
|
||||
karatPerMille?: number;
|
||||
pricePerGramRial?: Prisma.Decimal.Value;
|
||||
note?: string;
|
||||
postedByAdminId?: string;
|
||||
confirmed?: boolean;
|
||||
},
|
||||
tx: Tx = this.prisma,
|
||||
) {
|
||||
const raw = new Prisma.Decimal(params.rawValue);
|
||||
let goldGramsValue: Prisma.Decimal;
|
||||
let sourceType: LedgerSourceType;
|
||||
|
||||
switch (params.instrument) {
|
||||
case 'MELTED_GOLD': {
|
||||
if (!params.karatPerMille) {
|
||||
throw new BadRequestException('karatPerMille is required for a melted-gold repayment');
|
||||
}
|
||||
// legacy: value = original_value * ayar / 750
|
||||
goldGramsValue = raw.mul(params.karatPerMille).div(STANDARD_KARAT_PER_MILLE);
|
||||
sourceType = LedgerSourceType.REPAYMENT_MELTED_GOLD;
|
||||
break;
|
||||
}
|
||||
case 'CASH': {
|
||||
if (!params.pricePerGramRial) {
|
||||
throw new BadRequestException('pricePerGramRial is required for a cash repayment');
|
||||
}
|
||||
// legacy: value = price / (price_per_gram / 4.331802) — preserved verbatim;
|
||||
// flagged in migration analysis as a formula worth stakeholder review.
|
||||
const pricePerGram = new Prisma.Decimal(params.pricePerGramRial);
|
||||
goldGramsValue = raw.div(pricePerGram.div(GRAMS_PER_MESGHAL));
|
||||
sourceType = LedgerSourceType.REPAYMENT_CASH;
|
||||
break;
|
||||
}
|
||||
case 'TRANSFER':
|
||||
goldGramsValue = raw;
|
||||
sourceType = LedgerSourceType.REPAYMENT_TRANSFER;
|
||||
break;
|
||||
case 'CHEQUE':
|
||||
goldGramsValue = raw;
|
||||
sourceType = LedgerSourceType.REPAYMENT_CHEQUE;
|
||||
break;
|
||||
}
|
||||
const goldGramsValue = repaymentToGrams(
|
||||
params.instrument,
|
||||
params.rawValue,
|
||||
params.karatPerMille,
|
||||
params.pricePerGramRial,
|
||||
);
|
||||
const sourceType = {
|
||||
MELTED_GOLD: LedgerSourceType.REPAYMENT_MELTED_GOLD,
|
||||
CASH: LedgerSourceType.REPAYMENT_CASH,
|
||||
TRANSFER: LedgerSourceType.REPAYMENT_TRANSFER,
|
||||
CHEQUE: LedgerSourceType.REPAYMENT_CHEQUE,
|
||||
}[params.instrument];
|
||||
|
||||
return this.postEntry(
|
||||
{
|
||||
@@ -229,7 +281,7 @@ export class LedgerService {
|
||||
karatPerMille: params.karatPerMille,
|
||||
pricePerGramRialAtEntry: params.pricePerGramRial,
|
||||
note: params.note,
|
||||
confirmed: false, // pending until an admin confirms — intentional deviation from legacy, which auto-confirms on creation
|
||||
confirmed: params.confirmed ?? false,
|
||||
postedByAdminId: params.postedByAdminId,
|
||||
},
|
||||
tx,
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import { LedgerCurrency, LedgerDirection, OrderStatus, Prisma } from '@prisma/client';
|
||||
import { LedgerService } from './ledger.service';
|
||||
import { OrdersService } from '../orders/orders.service';
|
||||
import { WageService } from '../pricing/wage.service';
|
||||
|
||||
/**
|
||||
* End-to-end parity with the reference (henza_server / henza_pwa): the numbers
|
||||
* an order, its debt and its repayments produce here must equal what the PHP
|
||||
* computed. The reference arithmetic is re-typed below line for line from
|
||||
*
|
||||
* ApiMobileController::setOrder (:556-566, :615-632)
|
||||
* CustomerController::updateRemain (:42-51)
|
||||
* Adm\CashController::store (:55-73)
|
||||
*
|
||||
* in plain JS floats, exactly as PHP did, and the real WageService,
|
||||
* OrdersService and LedgerService are run against an in-memory database.
|
||||
*/
|
||||
|
||||
/* ───────────── the reference, typed out ───────────── */
|
||||
|
||||
interface RefItem {
|
||||
rash_weight: number;
|
||||
percent_pay: number; // PWA getWage(): customer.percent + variant.percentage_pay
|
||||
discount: number; // PWA getPromotion(): the largest promotion
|
||||
qty: number;
|
||||
rial_pay: number;
|
||||
}
|
||||
|
||||
function refSetOrder(items: RefItem[]) {
|
||||
const allGram = items.reduce((s, i) => {
|
||||
const percent = i.percent_pay - i.discount;
|
||||
const gram = i.rash_weight + i.rash_weight * (percent / 100);
|
||||
return s + gram * i.qty;
|
||||
}, 0);
|
||||
const wage = items.reduce((s, i) => s + i.rash_weight * i.qty, 0);
|
||||
const rial_input = items.reduce((s, i) => s + i.rial_pay * i.qty, 0);
|
||||
return { input: allGram, wage: allGram - wage, rial_input };
|
||||
}
|
||||
|
||||
/** remain = Σ orders.input (sts>4) − Σ cashes.value (confirmed, type 1-4); rial_remain = Σ orders.rial_input (sts>4) */
|
||||
function refUpdateRemain(
|
||||
orders: { input: number; rial_input: number; sts: number }[],
|
||||
cashes: { value: number; confirmed: boolean }[],
|
||||
) {
|
||||
const delivered = orders.filter((o) => o.sts > 4);
|
||||
return {
|
||||
remain: delivered.reduce((s, o) => s + o.input, 0) - cashes.filter((c) => c.confirmed).reduce((s, c) => s + c.value, 0),
|
||||
rial_remain: delivered.reduce((s, o) => s + o.rial_input, 0),
|
||||
};
|
||||
}
|
||||
|
||||
const refMelted = (originalValue: number, ayar: number) => (originalValue * ayar) / 750;
|
||||
const refCash = (price: number, pricePerGram: number) => price / (pricePerGram / 4.331802);
|
||||
|
||||
/* ───────────── the in-memory database ───────────── */
|
||||
|
||||
const D = (v: Prisma.Decimal.Value) => new Prisma.Decimal(v);
|
||||
|
||||
interface Variant {
|
||||
id: string;
|
||||
rashWeightGrams: string;
|
||||
defaultWagePercent: string;
|
||||
rialWage: string | null;
|
||||
product: { id: string; categoryId: string | null; brandId: string | null };
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
id: string;
|
||||
customerId: string;
|
||||
orderId?: string;
|
||||
currency: LedgerCurrency;
|
||||
direction: LedgerDirection;
|
||||
sourceType: string;
|
||||
amount: Prisma.Decimal;
|
||||
confirmed: boolean;
|
||||
}
|
||||
|
||||
function world(opts: { customerPercent: string; variants: Variant[]; promotions?: unknown[] }) {
|
||||
const entries: Entry[] = [];
|
||||
const customer = {
|
||||
id: 'c1',
|
||||
canPlaceOrders: true,
|
||||
referredByAgentId: null,
|
||||
makingChargeOffsetPercent: D(opts.customerPercent),
|
||||
goldDebtGrams: D(0),
|
||||
fiatDebtRial: D(0),
|
||||
goldCreditLimitGrams: D(100000),
|
||||
};
|
||||
const orders: Record<string, unknown>[] = [];
|
||||
let seq = 0;
|
||||
|
||||
// eslint-disable-next-line prefer-const
|
||||
let db: Record<string, any>;
|
||||
db = {
|
||||
customer: {
|
||||
findUnique: jest.fn(async () => customer),
|
||||
update: jest.fn(async ({ data }: { data: { goldDebtGrams?: { increment: Prisma.Decimal }; fiatDebtRial?: { increment: Prisma.Decimal } } }) => {
|
||||
if (data.goldDebtGrams) customer.goldDebtGrams = customer.goldDebtGrams.add(data.goldDebtGrams.increment);
|
||||
if (data.fiatDebtRial) customer.fiatDebtRial = customer.fiatDebtRial.add(data.fiatDebtRial.increment);
|
||||
return customer;
|
||||
}),
|
||||
},
|
||||
productVariant: {
|
||||
findMany: jest.fn(async ({ where }: { where: { id: { in: string[] } } }) => opts.variants.filter((v) => where.id.in.includes(v.id))),
|
||||
},
|
||||
promotion: { findMany: jest.fn(async () => opts.promotions ?? []) },
|
||||
promotionExclusion: { findMany: jest.fn(async () => []) },
|
||||
setting: { findUnique: jest.fn(async () => null) }, // → legacy formula
|
||||
order: {
|
||||
create: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
|
||||
const row = { id: `o${++seq}`, customerId: 'c1', ...data };
|
||||
orders.push(row);
|
||||
return row;
|
||||
}),
|
||||
findUnique: jest.fn(async ({ where }: { where: { id: string } }) => orders.find((o) => o.id === where.id)),
|
||||
},
|
||||
ledgerEntry: {
|
||||
create: jest.fn(async ({ data }: { data: Omit<Entry, 'id'> }) => {
|
||||
const e = { id: `e${entries.length + 1}`, ...data } as Entry;
|
||||
entries.push(e);
|
||||
return e;
|
||||
}),
|
||||
findUnique: jest.fn(async ({ where }: { where: { id: string } }) => entries.find((e) => e.id === where.id) ?? null),
|
||||
update: jest.fn(async ({ where, data }: { where: { id: string }; data: Partial<Entry> }) => {
|
||||
const e = entries.find((x) => x.id === where.id)!;
|
||||
Object.assign(e, data);
|
||||
return e;
|
||||
}),
|
||||
updateMany: jest.fn(async ({ where, data }: { where: { id: string; confirmed: boolean }; data: Partial<Entry> }) => {
|
||||
const e = entries.find((x) => x.id === where.id && x.confirmed === where.confirmed);
|
||||
if (!e) return { count: 0 };
|
||||
Object.assign(e, data);
|
||||
return { count: 1 };
|
||||
}),
|
||||
groupBy: jest.fn(async ({ where }: { where: { orderId: string; currency: LedgerCurrency } }) => {
|
||||
const out: { direction: LedgerDirection; _sum: { amount: Prisma.Decimal } }[] = [];
|
||||
for (const d of [LedgerDirection.DEBIT, LedgerDirection.CREDIT]) {
|
||||
const hit = entries.filter(
|
||||
(e) => e.orderId === where.orderId && e.currency === where.currency && e.direction === d && e.confirmed && e.sourceType === 'ORDER_STOCK',
|
||||
);
|
||||
if (hit.length) out.push({ direction: d, _sum: { amount: hit.reduce((s, e) => s.add(e.amount), D(0)) } });
|
||||
}
|
||||
return out;
|
||||
}),
|
||||
},
|
||||
$queryRaw: jest.fn(async () => [{ n: 100n }]),
|
||||
$transaction: jest.fn(async (fn: (t: unknown) => unknown): Promise<unknown> => fn({ ...db })),
|
||||
};
|
||||
|
||||
const catalog = {
|
||||
getVariantOrThrow: jest.fn(async (id: string) => {
|
||||
const v = opts.variants.find((x) => x.id === id)!;
|
||||
return { id: v.id, rashWeightGrams: v.rashWeightGrams, rialWage: v.rialWage };
|
||||
}),
|
||||
};
|
||||
|
||||
const ledger = new LedgerService(db as never);
|
||||
const wage = new WageService(db as never);
|
||||
const orderService = new OrdersService(
|
||||
db as never,
|
||||
catalog as never,
|
||||
ledger,
|
||||
{ orderStatusReached: jest.fn(), orderCancelled: jest.fn() } as never,
|
||||
wage,
|
||||
{ send: jest.fn(async () => ({ ok: true })) } as never,
|
||||
);
|
||||
|
||||
return { db, customer, entries, orders, ledger, orderService };
|
||||
}
|
||||
|
||||
/* ───────────── the scenario ───────────── */
|
||||
|
||||
const variants: Variant[] = [
|
||||
{ id: 'vA', rashWeightGrams: '5.7856', defaultWagePercent: '7', rialWage: '150000', product: { id: 'pA', categoryId: null, brandId: null } },
|
||||
{ id: 'vB', rashWeightGrams: '12.3', defaultWagePercent: '5.5', rialWage: null, product: { id: 'pB', categoryId: null, brandId: null } },
|
||||
{ id: 'vC', rashWeightGrams: '2.5', defaultWagePercent: '9', rialWage: '80000', product: { id: 'pC', categoryId: null, brandId: null } },
|
||||
];
|
||||
|
||||
// Two active product promotions on A (2% and 1%: the reference takes the largest),
|
||||
// one on C (3%), none on B. The customer's own percent is 3.
|
||||
const promotions = [
|
||||
{ scope: 'PRODUCT', productId: 'pA', categoryId: null, brandId: null, discountPercent: D(2) },
|
||||
{ scope: 'PRODUCT', productId: 'pA', categoryId: null, brandId: null, discountPercent: D(1) },
|
||||
{ scope: 'PRODUCT', productId: 'pC', categoryId: null, brandId: null, discountPercent: D(3) },
|
||||
];
|
||||
|
||||
const refItems: RefItem[] = [
|
||||
{ rash_weight: 5.7856, percent_pay: 3 + 7, discount: 2, qty: 3, rial_pay: 150000 },
|
||||
{ rash_weight: 12.3, percent_pay: 3 + 5.5, discount: 0, qty: 1, rial_pay: 0 },
|
||||
{ rash_weight: 2.5, percent_pay: 3 + 9, discount: 3, qty: 10, rial_pay: 80000 },
|
||||
];
|
||||
|
||||
const near = (actual: Prisma.Decimal.Value, expected: number, eps = 1e-3) =>
|
||||
expect(Math.abs(Number(actual) - expected)).toBeLessThan(eps);
|
||||
|
||||
describe('parity with the reference: order → debt → repayment', () => {
|
||||
async function place() {
|
||||
const w = world({ customerPercent: '3', variants, promotions });
|
||||
const order = (await w.orderService.createOrder(
|
||||
{
|
||||
items: [
|
||||
{ productVariantId: 'vA', quantity: 3, wagePercent: 0 },
|
||||
{ productVariantId: 'vB', quantity: 1, wagePercent: 0 },
|
||||
{ productVariantId: 'vC', quantity: 10, wagePercent: 0 },
|
||||
],
|
||||
} as never,
|
||||
'c1',
|
||||
{ placedBy: 'customer' },
|
||||
)) as unknown as { id: string; totalGoldWeightGrams: Prisma.Decimal; wageWeightGrams: Prisma.Decimal; flatRialCharge: Prisma.Decimal };
|
||||
return { w, order };
|
||||
}
|
||||
|
||||
it('prices the order like setOrder: grams, making-charge grams and rial making charge', async () => {
|
||||
const { order } = await place();
|
||||
const ref = refSetOrder(refItems);
|
||||
near(order.totalGoldWeightGrams, ref.input); // order.input / order.w
|
||||
near(order.wageWeightGrams, ref.wage); // order.wage = allGram − Σ rash×qty
|
||||
near(order.flatRialCharge, ref.rial_input, 0.5); // order.rial_input = Σ rial_pay×qty
|
||||
expect(order.flatRialCharge.toString()).toBe('1250000'); // 150000×3 + 0 + 80000×10
|
||||
});
|
||||
|
||||
it('owes nothing until the order is delivered, then exactly input and rial_input (updateRemain)', async () => {
|
||||
const { w, order } = await place();
|
||||
const ref = refSetOrder(refItems);
|
||||
|
||||
await w.ledger.syncOrderDebt(order.id, w.db as never);
|
||||
expect(w.customer.goldDebtGrams.toString()).toBe('0'); // sts 1
|
||||
expect(w.customer.fiatDebtRial.toString()).toBe('0');
|
||||
|
||||
(w.orders[0] as { status: OrderStatus }).status = OrderStatus.DELIVERED_TO_AGENT; // sts 5
|
||||
await w.ledger.syncOrderDebt(order.id, w.db as never);
|
||||
const legacy = refUpdateRemain([{ ...ref, sts: 5 }], []);
|
||||
near(w.customer.goldDebtGrams, legacy.remain);
|
||||
near(w.customer.fiatDebtRial, legacy.rial_remain, 0.5);
|
||||
});
|
||||
|
||||
it('subtracts a melted-gold and a cash repayment the way CashController does, only once confirmed', async () => {
|
||||
const { w, order } = await place();
|
||||
(w.orders[0] as { status: OrderStatus }).status = OrderStatus.DELIVERED_TO_CUSTOMER;
|
||||
await w.ledger.syncOrderDebt(order.id, w.db as never);
|
||||
const ref = refSetOrder(refItems);
|
||||
|
||||
const melted = await w.ledger.postGoldRepayment({ customerId: 'c1', instrument: 'MELTED_GOLD', rawValue: '20', karatPerMille: 900 }, w.db as never);
|
||||
const cash = await w.ledger.postGoldRepayment(
|
||||
{ customerId: 'c1', instrument: 'CASH', rawValue: '50000000', pricePerGramRial: '20000000' },
|
||||
w.db as never,
|
||||
);
|
||||
// Pending documents have not moved the balance.
|
||||
near(w.customer.goldDebtGrams, refUpdateRemain([{ ...ref, sts: 6 }], []).remain);
|
||||
|
||||
await w.ledger.confirmEntry(melted.id, undefined, w.db as never);
|
||||
await w.ledger.confirmEntry(cash.id, undefined, w.db as never);
|
||||
|
||||
const legacy = refUpdateRemain(
|
||||
[{ ...ref, sts: 6 }],
|
||||
[
|
||||
{ value: refMelted(20, 900), confirmed: true }, // 24 g
|
||||
{ value: refCash(50_000_000, 20_000_000), confirmed: true }, // 10.829505 g
|
||||
],
|
||||
);
|
||||
near(melted.amount, 24);
|
||||
near(cash.amount, 10.829505, 1e-6);
|
||||
near(w.customer.goldDebtGrams, legacy.remain);
|
||||
// A cash payment lowers the GOLD debt only — rial debt is untouched (reference quirk, kept).
|
||||
near(w.customer.fiatDebtRial, legacy.rial_remain, 0.5);
|
||||
});
|
||||
|
||||
it('puts the order back to zero debt when it is cancelled after delivery', async () => {
|
||||
const { w, order } = await place();
|
||||
(w.orders[0] as { status: OrderStatus }).status = OrderStatus.DELIVERED_TO_AGENT;
|
||||
await w.ledger.syncOrderDebt(order.id, w.db as never);
|
||||
(w.orders[0] as { status: OrderStatus }).status = OrderStatus.CANCELLED;
|
||||
await w.ledger.syncOrderDebt(order.id, w.db as never);
|
||||
expect(w.customer.goldDebtGrams.toString()).toBe('0');
|
||||
expect(w.customer.fiatDebtRial.toString()).toBe('0');
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { faToEn } from '../customer-auth/digits';
|
||||
import { birthDateOrNull, nationalCodeOrNull } from '../../common/customer-input';
|
||||
import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash';
|
||||
import { agentSalesWhere, customerSalesWhere } from '../sales-agent/agent-orders';
|
||||
import { SmsService } from '../sms/sms.service';
|
||||
@@ -72,33 +73,6 @@ export type OmdeScope = 'all' | 'banker' | 'shopkeeper' | 'non_verified' | 'arch
|
||||
* `sts_verify_step_one !== 2`, i.e. only an ACCEPTED first step unlocks them.
|
||||
* The new schema models one ordered enum, so these numbers are derived from it.
|
||||
*/
|
||||
/**
|
||||
* PERS-03: the birth date both customer forms collect (legacy bulkCustomer
|
||||
* `birth_date`, singleCustomer `birth_day`). DateInput sends the Gregorian
|
||||
* 'YYYY-MM-DD' of the Jalali date picked; stored at UTC midnight, as the
|
||||
* import stores legacy's. undefined = not sent (leave as is), '' = cleared.
|
||||
*/
|
||||
function birthDateOrNull(raw: unknown): Date | null | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
if (raw === null || String(raw).trim() === '') return null;
|
||||
const m = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(String(raw).trim());
|
||||
const d = m ? new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])) : null;
|
||||
if (!d || d.getUTCMonth() !== +m![2] - 1 || d.getUTCFullYear() < 1900 || d > new Date()) {
|
||||
throw new BadRequestException('تاریخ تولد معتبر نیست');
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* A national code as the operator typed it, digits normalised; empty -> null.
|
||||
* The column is optional and unique (2026-10-07): an absent code is NULL,
|
||||
* never the mobile or '' — two customers without one must not collide, and
|
||||
* the column must only ever hold a national code.
|
||||
*/
|
||||
function nationalCodeOrNull(raw: unknown): string | null {
|
||||
return faToEn(String(raw ?? '').trim()) || null;
|
||||
}
|
||||
|
||||
function verifyStepOne(step: VerificationStep): number {
|
||||
switch (step) {
|
||||
case VerificationStep.UNVERIFIED:
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { LedgerCurrency, LedgerDirection, LedgerSourceType, Prisma } from '@prisma/client';
|
||||
import { AdminDocumentsService } from './admin-documents.service';
|
||||
|
||||
/**
|
||||
* Editing a repayment document (legacy CashController::update).
|
||||
*
|
||||
* The edit form posts the WHOLE form, `value` included — the value as it was
|
||||
* loaded. These tests post exactly that and check that the gold taken off the
|
||||
* debt follows the raw figures the operator corrected.
|
||||
*/
|
||||
describe('AdminDocumentsService.update', () => {
|
||||
const D = (v: Prisma.Decimal.Value) => new Prisma.Decimal(v);
|
||||
|
||||
function build(existing: Record<string, unknown>) {
|
||||
const row: Record<string, any> = {
|
||||
id: 'd1',
|
||||
customerId: 'c1',
|
||||
currency: LedgerCurrency.GOLD_GRAM,
|
||||
direction: LedgerDirection.CREDIT,
|
||||
confirmed: false,
|
||||
confirmedAt: null,
|
||||
karatPerMille: null,
|
||||
originalValue: null,
|
||||
priceRial: null,
|
||||
pricePerGramRialAtEntry: null,
|
||||
customer: { id: 'c1', fullName: 'x', firstName: 'x', lastName: 'y', mobile: '0' },
|
||||
postedByAdmin: null,
|
||||
createdAt: new Date(),
|
||||
...existing,
|
||||
};
|
||||
const prisma = {
|
||||
ledgerEntry: {
|
||||
findUnique: jest.fn(async () => row),
|
||||
update: jest.fn(async ({ data }: { data: Record<string, any> }) => {
|
||||
Object.assign(row, data);
|
||||
return row;
|
||||
}),
|
||||
},
|
||||
};
|
||||
const service = new AdminDocumentsService(prisma as never, {} as never, {} as never, {} as never);
|
||||
return { service, row, prisma };
|
||||
}
|
||||
|
||||
const melted = { sourceType: LedgerSourceType.REPAYMENT_MELTED_GOLD, amount: D('15'), originalValue: D('16.6667'), karatPerMille: 675 };
|
||||
|
||||
it('recomputes the credited gold when the karat of an unconfirmed melted-gold document is corrected', async () => {
|
||||
// 16.6667 g @ 675 = 15.00003 g was filed; the lab said 900.
|
||||
const { service, row } = build({ ...melted, amount: D('15.0000'), originalValue: D('16.6667'), karatPerMille: 675 });
|
||||
await service.update('d1', { type: 1, value: 15, original_value: 16.6667, ayar: 900 });
|
||||
expect(row.karatPerMille).toBe(900);
|
||||
expect(row.amount.toString()).toBe(D(16.6667).mul(900).div(750).toString()); // 20.00004
|
||||
});
|
||||
|
||||
it('recomputes the credited gold when the raw weight is corrected', async () => {
|
||||
const { service, row } = build({ ...melted, amount: D('15'), originalValue: D('16.6667'), karatPerMille: 900 });
|
||||
await service.update('d1', { type: 1, value: 15, original_value: 20, ayar: 900 });
|
||||
expect(row.originalValue.toString()).toBe('20');
|
||||
expect(row.amount.toString()).toBe('24'); // 20 × 900 / 750
|
||||
});
|
||||
|
||||
it('refuses to move the amount of a CONFIRMED document (the debt already counts it)', async () => {
|
||||
const { service, row } = build({ ...melted, confirmed: true, amount: D('15'), originalValue: D('16.6667'), karatPerMille: 900 });
|
||||
await expect(service.update('d1', { type: 1, value: 15, original_value: 20, ayar: 900 })).rejects.toThrow(BadRequestException);
|
||||
expect(row.amount.toString()).toBe('15');
|
||||
});
|
||||
|
||||
it('lets a confirmed document be re-saved untouched (the form posts the same figures back)', async () => {
|
||||
// Stored to 4 places; the live conversion has more digits. Must not read as a change.
|
||||
const { service, row } = build({ ...melted, confirmed: true, amount: D('20.0000'), originalValue: D('16.6667'), karatPerMille: 900 });
|
||||
await expect(service.update('d1', { type: 1, value: 20, original_value: 16.6667, ayar: 900, note: 'checked' })).resolves.toBeDefined();
|
||||
expect(row.note).toBe('checked');
|
||||
expect(row.amount.toString()).toBe('20');
|
||||
});
|
||||
|
||||
it('a confirmed imported document whose stored value is not original×ayar/750 can still have its note edited', async () => {
|
||||
const { service } = build({ ...melted, confirmed: true, amount: D('48.34'), originalValue: D('50'), karatPerMille: 750 });
|
||||
await expect(service.update('d1', { type: 1, value: 48.34, original_value: 50, ayar: 750, note: 'n' })).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('recomputes a cash document from price and price per gram, and stores the price per gram', async () => {
|
||||
const { service, row } = build({
|
||||
sourceType: LedgerSourceType.REPAYMENT_CASH,
|
||||
amount: D('10.8295'),
|
||||
priceRial: D('50000000'),
|
||||
pricePerGramRialAtEntry: D('20000000'),
|
||||
});
|
||||
await service.update('d1', { type: 4, value: 10.8295, price: 50_000_000, price_per_gram: 25_000_000 });
|
||||
expect(row.pricePerGramRialAtEntry.toString()).toBe('25000000');
|
||||
expect(row.amount.toDecimalPlaces(4).toString()).toBe('8.6636'); // 5e7 / (2.5e7 / 4.331802)
|
||||
});
|
||||
|
||||
it('takes the typed grams of a transfer as they are', async () => {
|
||||
const { service, row } = build({ sourceType: LedgerSourceType.REPAYMENT_TRANSFER, amount: D('5') });
|
||||
await service.update('d1', { type: 2, value: 7.5 });
|
||||
expect(row.amount.toString()).toBe('7.5');
|
||||
});
|
||||
|
||||
it('leaves a rial repayment alone: it has no conversion', async () => {
|
||||
const { service, row } = build({
|
||||
sourceType: LedgerSourceType.REPAYMENT_CASH,
|
||||
currency: LedgerCurrency.RIAL,
|
||||
amount: D('1000000'),
|
||||
});
|
||||
await service.update('d1', { type: 4, note: 'rial', price_per_gram: 99 });
|
||||
expect(row.amount.toString()).toBe('1000000');
|
||||
expect(row.note).toBe('rial');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Reference CashController::store sets `confirmed = 1`: a document an admin
|
||||
* files counts at once and the gold debt drops then. Only a customer's own
|
||||
* submission (set-findoc) waits for confirmation.
|
||||
*/
|
||||
describe('AdminDocumentsService.create — an admin-filed document counts at once', () => {
|
||||
it('asks the ledger for a confirmed credit: melted gold 20 g at 900 takes 24 g off the debt at once', async () => {
|
||||
const posted: any[] = [];
|
||||
const ledger = {
|
||||
postGoldRepayment: jest.fn(async (p: any) => {
|
||||
posted.push(p);
|
||||
return { id: 'e1' };
|
||||
}),
|
||||
};
|
||||
const row = {
|
||||
id: 'e1',
|
||||
sourceType: LedgerSourceType.REPAYMENT_MELTED_GOLD,
|
||||
currency: LedgerCurrency.GOLD_GRAM,
|
||||
direction: LedgerDirection.CREDIT,
|
||||
amount: new Prisma.Decimal(24),
|
||||
confirmed: true,
|
||||
customer: { id: 'c1', fullName: 'x', firstName: 'x', lastName: 'y', mobile: '0' },
|
||||
postedByAdmin: null,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
const prisma = { ledgerEntry: { update: jest.fn(async () => row), findUnique: jest.fn(async () => row) } };
|
||||
const service = new AdminDocumentsService(prisma as never, {} as never, ledger as never, {} as never);
|
||||
await service.create({ type: 1, customer_id: 'c1', original_value: 20, ayar: 900 }, 'admin1');
|
||||
expect(posted[0]).toMatchObject({ customerId: 'c1', instrument: 'MELTED_GOLD', rawValue: '20', karatPerMille: 900, confirmed: true });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The route takes a loose body (no DTO). Malformed or absurd figures used to
|
||||
* reach `new Prisma.Decimal` / the database and come back as a 500; each is now
|
||||
* a 400 and nothing is posted.
|
||||
*/
|
||||
describe('AdminDocumentsService.create — figures it must refuse', () => {
|
||||
function build() {
|
||||
const ledger = { postGoldRepayment: jest.fn(async () => ({ id: 'e1' })) };
|
||||
const prisma = { ledgerEntry: { update: jest.fn(), findUnique: jest.fn() } };
|
||||
const service = new AdminDocumentsService(prisma as never, {} as never, ledger as never, {} as never);
|
||||
return { service, ledger };
|
||||
}
|
||||
const base = { customer_id: 'c1' };
|
||||
|
||||
const bad: [string, Record<string, unknown>][] = [
|
||||
['melted weight "abc"', { type: 1, original_value: 'abc', ayar: 750 }],
|
||||
['melted weight 1e30', { type: 1, original_value: 1e30, ayar: 750 }],
|
||||
['melted weight beyond the column', { type: 1, original_value: '99999999999', ayar: 750 }],
|
||||
['melted weight 0', { type: 1, original_value: 0, ayar: 750 }],
|
||||
['melted weight negative', { type: 1, original_value: -5, ayar: 750 }],
|
||||
['karat 5 000 000 000 (an Int column)', { type: 1, original_value: 5, ayar: 5000000000 }],
|
||||
['karat 916.5 (an Int column)', { type: 1, original_value: 5, ayar: 916.5 }],
|
||||
['karat 0', { type: 1, original_value: 5, ayar: 0 }],
|
||||
['cash price 1e30', { type: 4, price: 1e30, price_per_gram: 61500000 }],
|
||||
['cash price "abc"', { type: 4, price: 'abc', price_per_gram: 61500000 }],
|
||||
['cash price per gram "abc"', { type: 4, price: 1000000, price_per_gram: 'abc' }],
|
||||
['cash price per gram 0', { type: 4, price: 1000000, price_per_gram: 0 }],
|
||||
['cash price so small it converts to 0.0000 g', { type: 4, price: 0.0001, price_per_gram: 61500000 }],
|
||||
['cash price large enough to overflow the gram column once converted', { type: 4, price: '999999999999999', price_per_gram: '0.01' }],
|
||||
['transfer weight an object', { type: 2, value: { a: 1 } }],
|
||||
['transfer weight 0', { type: 2, value: 0 }],
|
||||
];
|
||||
|
||||
it.each(bad)('%s → 400, nothing posted', async (_name, body) => {
|
||||
const { service, ledger } = build();
|
||||
await expect(service.create({ ...base, ...body }, 'admin1')).rejects.toThrow(BadRequestException);
|
||||
expect(ledger.postGoldRepayment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still accepts the ordinary cases (melted 10 g @ 900, cash 100 000 000 @ 61 500 000)', async () => {
|
||||
const a = build();
|
||||
a.service.findOne = jest.fn(async () => ({}) as never);
|
||||
await a.service.create({ ...base, type: 1, original_value: 10, ayar: 900 }, 'admin1');
|
||||
expect(a.ledger.postGoldRepayment).toHaveBeenCalledTimes(1);
|
||||
const b = build();
|
||||
b.service.findOne = jest.fn(async () => ({}) as never);
|
||||
await b.service.create({ ...base, type: 4, price: 100000000, price_per_gram: 61500000 }, 'admin1');
|
||||
expect(b.ledger.postGoldRepayment).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
|
||||
import { LedgerCurrency, LedgerDirection, LedgerSourceType, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import { LedgerService } from '../accounting/ledger.service';
|
||||
import { LedgerService, repaymentToGrams } from '../accounting/ledger.service';
|
||||
import { SmsService } from '../sms/sms.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { brandName } from '../../common/brand';
|
||||
@@ -58,6 +58,52 @@ const TYPE_LABELS: Record<LedgerSourceType, string> = {
|
||||
ADJUSTMENT: 'اصلاح',
|
||||
};
|
||||
|
||||
/** LedgerEntry.originalValue is Decimal(14,4); the other figures have more room, so this is the tight bound. */
|
||||
const MAX_WEIGHT_GRAMS = new Prisma.Decimal('9999999999.9999');
|
||||
const MAX_RIAL = new Prisma.Decimal('999999999999999');
|
||||
|
||||
/**
|
||||
* A request figure as a Decimal. This route takes a loose body (no DTO), so
|
||||
* "abc", "1e400" or an object used to reach `new Prisma.Decimal` and come back as
|
||||
* a 500; it is a 400 naming the field instead.
|
||||
*/
|
||||
function decimalOf(value: unknown, label: string): Prisma.Decimal {
|
||||
const text = String(value ?? '').trim();
|
||||
if (!/^-?\d+(\.\d+)?$/.test(text)) throw new BadRequestException(`${label} نامعتبر است`);
|
||||
return new Prisma.Decimal(text);
|
||||
}
|
||||
|
||||
/** A figure that is not negative and fits its column. */
|
||||
function boundedFigure(value: unknown, label: string, max: Prisma.Decimal): Prisma.Decimal {
|
||||
const d = decimalOf(value, label);
|
||||
if (d.isNegative()) throw new BadRequestException(`${label} نمیتواند منفی باشد`);
|
||||
if (d.gt(max)) throw new BadRequestException(`${label} بیش از حد مجاز است`);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** A figure that is strictly positive and fits its column. */
|
||||
function positiveFigure(value: unknown, label: string, max: Prisma.Decimal): Prisma.Decimal {
|
||||
const d = boundedFigure(value, label, max);
|
||||
if (d.isZero()) throw new BadRequestException(`${label} باید بزرگتر از صفر باشد`);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Karat in thousandths (750 = 18k): the column is an integer. */
|
||||
function karatOf(value: unknown): number {
|
||||
const n = Number(value);
|
||||
if (!Number.isInteger(n) || n < 1 || n > 1000) throw new BadRequestException('عیار باید عدد صحیح بین ۱ تا ۱۰۰۰ باشد');
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Grams a document carries must survive the four-decimal rounding as something, and fit the column. */
|
||||
function checkedGrams(grams: Prisma.Decimal): Prisma.Decimal {
|
||||
const rounded = grams.toDecimalPlaces(4);
|
||||
if (rounded.lte(0) || rounded.gt(MAX_WEIGHT_GRAMS)) {
|
||||
throw new BadRequestException('مقدار سند پس از تبدیل به گرم صفر یا بیش از حد مجاز است');
|
||||
}
|
||||
return grams;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminDocumentsService {
|
||||
constructor(
|
||||
@@ -234,14 +280,26 @@ export class AdminDocumentsService {
|
||||
? String(data.original_value ?? data.value ?? 0)
|
||||
: String(data.value ?? 0);
|
||||
|
||||
// Every figure is checked here, before anything is posted: a bad one must
|
||||
// not reach the database (a 500) and a document must not carry zero grams.
|
||||
const isCash = instrument === 'CASH';
|
||||
const raw = positiveFigure(rawValue, isCash ? 'مبلغ' : 'وزن', isCash ? MAX_RIAL : MAX_WEIGHT_GRAMS);
|
||||
const karatPerMille = data.ayar ? karatOf(data.ayar) : undefined;
|
||||
const pricePerGramRial = data.price_per_gram ? positiveFigure(data.price_per_gram, 'مظنه', MAX_RIAL) : undefined;
|
||||
checkedGrams(repaymentToGrams(instrument, raw, karatPerMille, pricePerGramRial));
|
||||
|
||||
const entry = await this.ledger.postGoldRepayment({
|
||||
customerId,
|
||||
instrument,
|
||||
rawValue,
|
||||
karatPerMille: data.ayar ? Number(data.ayar) : undefined,
|
||||
pricePerGramRial: data.price_per_gram ? String(data.price_per_gram) : undefined,
|
||||
rawValue: raw.toString(),
|
||||
karatPerMille,
|
||||
pricePerGramRial: pricePerGramRial?.toString(),
|
||||
note: (data.note as string) ?? undefined,
|
||||
postedByAdminId: actorAdminUserId,
|
||||
// Reference CashController::store sets `confirmed = 1`: a document an admin
|
||||
// files counts at once. Only the customer's own submission (set-findoc,
|
||||
// not built in this app) waits for confirmation. «تایید/رد» still toggles.
|
||||
confirmed: true,
|
||||
});
|
||||
|
||||
// Attach the instrument metadata the ledger service does not model.
|
||||
@@ -261,10 +319,10 @@ export class AdminDocumentsService {
|
||||
if (data.bank_name !== undefined) patch.bankName = (data.bank_name as string) || null;
|
||||
if (data.to !== undefined) patch.transferTo = (data.to as string) || null;
|
||||
if (data.original_value !== undefined && data.original_value !== null) {
|
||||
patch.originalValue = new Prisma.Decimal(String(data.original_value));
|
||||
patch.originalValue = boundedFigure(data.original_value, 'وزن', MAX_WEIGHT_GRAMS);
|
||||
}
|
||||
if (data.price !== undefined && data.price !== null) {
|
||||
patch.priceRial = new Prisma.Decimal(String(data.price));
|
||||
patch.priceRial = boundedFigure(data.price, 'مبلغ', MAX_RIAL);
|
||||
}
|
||||
if (data.recive_date) patch.receivedAt = new Date(String(data.recive_date));
|
||||
return patch;
|
||||
@@ -282,25 +340,81 @@ export class AdminDocumentsService {
|
||||
const existing = await this.prisma.ledgerEntry.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('سند یافت نشد');
|
||||
|
||||
const amountChanged =
|
||||
data.value !== undefined && !new Prisma.Decimal(String(data.value)).equals(existing.amount);
|
||||
const patch = this.metadataPatch(data);
|
||||
if (data.note !== undefined) patch.note = (data.note as string) || null;
|
||||
|
||||
const newAmount = this.amountAfterEdit(existing, data, patch);
|
||||
const amountChanged = newAmount !== null && !newAmount.toDecimalPlaces(4).equals(existing.amount.toDecimalPlaces(4));
|
||||
|
||||
if (existing.confirmed && amountChanged) {
|
||||
throw new BadRequestException(
|
||||
'مبلغ سند تاییدشده قابل ویرایش نیست، چون بدهی مشتری بر اساس آن محاسبه شده است. برای اصلاح، سند جدید ثبت کنید.',
|
||||
);
|
||||
}
|
||||
|
||||
const patch = this.metadataPatch(data);
|
||||
if (data.note !== undefined) patch.note = (data.note as string) || null;
|
||||
if (!existing.confirmed && amountChanged) {
|
||||
patch.amount = new Prisma.Decimal(String(data.value));
|
||||
}
|
||||
if (!existing.confirmed && amountChanged) patch.amount = checkedGrams(newAmount!);
|
||||
|
||||
await this.prisma.ledgerEntry.update({ where: { id }, data: patch });
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* The gram amount a document should carry after an edit, or null when the
|
||||
* edit does not touch it.
|
||||
*
|
||||
* Legacy CashController::update recomputed `value = original_value × ayar / 750`
|
||||
* on every edit of a melted-gold document. The edit form posts the whole form,
|
||||
* including the OLD `value`, so trusting `data.value` (as this method used to)
|
||||
* left the credited gold unchanged whenever the operator corrected the weight
|
||||
* or the karat — and `ayar` was not even stored. The figure is therefore
|
||||
* derived from the raw inputs whenever one of those changed; `data.value`
|
||||
* only counts for the instruments that have no raw input (transfer, cheque).
|
||||
*/
|
||||
private amountAfterEdit(
|
||||
existing: Prisma.LedgerEntryGetPayload<object>,
|
||||
data: Record<string, unknown>,
|
||||
patch: Prisma.LedgerEntryUpdateInput,
|
||||
): Prisma.Decimal | null {
|
||||
const instrument = INSTRUMENT_BY_TYPE[existing.sourceType];
|
||||
// Rial repayments (postRialRepayment) share REPAYMENT_CASH but have no conversion.
|
||||
if (!instrument || existing.currency !== LedgerCurrency.GOLD_GRAM) {
|
||||
return data.value !== undefined && data.value !== null && data.value !== ''
|
||||
? decimalOf(data.value, 'مقدار')
|
||||
: null;
|
||||
}
|
||||
|
||||
const present = (v: unknown) => v !== undefined && v !== null && v !== '';
|
||||
const differs = (next: unknown, current: Prisma.Decimal.Value | null | undefined) =>
|
||||
present(next) && (current === null || current === undefined || !decimalOf(next, 'مقدار').equals(current));
|
||||
|
||||
if (instrument === 'MELTED_GOLD') {
|
||||
const karat = present(data.ayar) ? karatOf(data.ayar) : existing.karatPerMille;
|
||||
const raw = present(data.original_value) ? data.original_value : existing.originalValue;
|
||||
if (differs(data.ayar, existing.karatPerMille)) patch.karatPerMille = karat;
|
||||
if (
|
||||
(differs(data.ayar, existing.karatPerMille) || differs(data.original_value, existing.originalValue)) &&
|
||||
karat &&
|
||||
present(raw)
|
||||
) {
|
||||
return repaymentToGrams('MELTED_GOLD', String(raw), karat);
|
||||
}
|
||||
} else if (instrument === 'CASH') {
|
||||
const perGram = present(data.price_per_gram) ? data.price_per_gram : existing.pricePerGramRialAtEntry;
|
||||
const price = present(data.price) ? data.price : existing.priceRial;
|
||||
if (differs(data.price_per_gram, existing.pricePerGramRialAtEntry)) {
|
||||
patch.pricePerGramRialAtEntry = positiveFigure(data.price_per_gram, 'مظنه', MAX_RIAL);
|
||||
}
|
||||
if (
|
||||
(differs(data.price_per_gram, existing.pricePerGramRialAtEntry) || differs(data.price, existing.priceRial)) &&
|
||||
present(price) &&
|
||||
present(perGram)
|
||||
) {
|
||||
return repaymentToGrams('CASH', String(price), undefined, String(perGram));
|
||||
}
|
||||
}
|
||||
|
||||
return present(data.value) ? decimalOf(data.value, 'مقدار') : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST cash/confirem/{id}` (legacy's own spelling) — a TOGGLE: the grid
|
||||
* shows a tick on an unconfirmed row and a cross on a confirmed one, both
|
||||
@@ -314,32 +428,11 @@ export class AdminDocumentsService {
|
||||
const e = await this.prisma.ledgerEntry.findUnique({ where: { id } });
|
||||
if (!e) throw new NotFoundException('سند یافت نشد');
|
||||
|
||||
if (!e.confirmed) {
|
||||
await this.ledger.confirmEntry(id, adminUserId);
|
||||
this.notifyDocumentConfirm(e.customerId, true);
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
// Un-confirm: undo this entry's effect on the cached balance, then clear the
|
||||
// flag. A repayment is a CREDIT against gold debt, so reversing it adds the
|
||||
// amount back.
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const delta =
|
||||
e.direction === LedgerDirection.CREDIT ? e.amount : e.amount.negated();
|
||||
const field =
|
||||
e.currency === LedgerCurrency.GOLD_GRAM ? 'goldDebtGrams' : 'fiatDebtRial';
|
||||
|
||||
await tx.customer.update({
|
||||
where: { id: e.customerId },
|
||||
data: { [field]: { increment: delta } },
|
||||
});
|
||||
await tx.ledgerEntry.update({
|
||||
where: { id },
|
||||
data: { confirmed: false, confirmedAt: null },
|
||||
});
|
||||
});
|
||||
|
||||
this.notifyDocumentConfirm(e.customerId, false);
|
||||
// Confirm or un-confirm (a repayment is a CREDIT, so undoing it adds the amount
|
||||
// back to the debt). The ledger flips the flag and moves the cached balance in
|
||||
// one atomic step, so a double-click or two admins at once cannot apply it twice.
|
||||
const { changed } = await this.ledger.setConfirmed(id, !e.confirmed, adminUserId);
|
||||
if (changed) this.notifyDocumentConfirm(e.customerId, !e.confirmed);
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) } : {}),
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { PreOrderStatus } from '@prisma/client';
|
||||
import { AdminPreOrdersService } from './admin-preorders.service';
|
||||
|
||||
/** The quantity of a made-to-order request (legacy `sum`) is stored and shown, not a hard-coded 1. */
|
||||
describe('AdminPreOrdersService — quantity', () => {
|
||||
const row = {
|
||||
id: 'p1',
|
||||
status: PreOrderStatus.PLACED,
|
||||
colorName: 'طلایی',
|
||||
sizeSpec: '18',
|
||||
minaColor: null,
|
||||
stoneColor: null,
|
||||
quantity: 7,
|
||||
description: null,
|
||||
designImageUrl: null,
|
||||
estimatedWeightGrams: null,
|
||||
estimatedPriceRial: null,
|
||||
convertedOrderId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
city: null,
|
||||
workshop: null,
|
||||
customer: { id: 'c1', firstName: 'علی', lastName: 'رضایی', fullName: 'علی رضایی', mobile: '0912' },
|
||||
};
|
||||
|
||||
function build() {
|
||||
const updates: any[] = [];
|
||||
const prisma = {
|
||||
preOrder: {
|
||||
findUnique: jest.fn(async () => row),
|
||||
update: jest.fn(async ({ data }: any) => (updates.push(data), { ...row, ...data })),
|
||||
},
|
||||
};
|
||||
return { service: new AdminPreOrdersService(prisma as never, {} as never), updates };
|
||||
}
|
||||
|
||||
it("shows the request's own quantity as `sum`", async () => {
|
||||
const { service } = build();
|
||||
expect(((await service.findOne('p1')) as any).sum).toBe(7);
|
||||
});
|
||||
|
||||
it('lets the panel correct the quantity', async () => {
|
||||
const { service, updates } = build();
|
||||
await service.update('p1', { sum: 12 });
|
||||
expect(updates[0].quantity).toBe(12);
|
||||
});
|
||||
|
||||
it('refuses a quantity that is not a whole number of at least one', async () => {
|
||||
const { service, updates } = build();
|
||||
for (const sum of [0, -2, 1.5, 'abc']) {
|
||||
await expect(service.update('p1', { sum })).rejects.toThrow(BadRequestException);
|
||||
}
|
||||
expect(updates).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminPreOrdersService.list — sorting the «تعداد» column', () => {
|
||||
const { TableService } = jest.requireActual('../admin-table/table.service') as typeof import('../admin-table/table.service');
|
||||
|
||||
it('sorts on the stored quantity when the grid asks for its legacy key `sum`', async () => {
|
||||
const findMany = jest.fn(async () => []);
|
||||
const prisma = { preOrder: { findMany, count: jest.fn(async () => 0) } };
|
||||
const service = new AdminPreOrdersService(prisma as never, new TableService() as never);
|
||||
await service.list({ page: 1, limit: 10, order: '-sum' } as never);
|
||||
expect((findMany.mock.calls[0] as any)[0].orderBy).toEqual({ quantity: 'desc' });
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,7 @@ export class AdminPreOrdersService {
|
||||
'sizeSpec',
|
||||
'stoneColor',
|
||||
'minaColor',
|
||||
'quantity',
|
||||
'estimatedWeightGrams',
|
||||
'estimatedPriceRial',
|
||||
'customer.fullName',
|
||||
@@ -47,7 +48,10 @@ export class AdminPreOrdersService {
|
||||
async list(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
||||
// The «تعداد» column is keyed `sum` (legacy name); the stored field is `quantity`.
|
||||
const sorted = query.order && /^[+-]?sum$/.test(query.order) ? { ...query, order: query.order.replace('sum', 'quantity') } : query;
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(sorted, {
|
||||
searchable: ['customer.fullName', 'customer.mobile'],
|
||||
allowed: AdminPreOrdersService.ALLOWED,
|
||||
defaultOrderBy: { createdAt: 'desc' },
|
||||
@@ -79,8 +83,8 @@ export class AdminPreOrdersService {
|
||||
size: p.sizeSpec,
|
||||
color_mina: p.minaColor,
|
||||
color_stone: p.stoneColor,
|
||||
// legacy 'sum' (quantity); a PreOrder models a single design request
|
||||
sum: 1,
|
||||
// legacy 'sum' — the pieces asked for
|
||||
sum: p.quantity,
|
||||
description: p.description,
|
||||
designImageUrl: p.designImageUrl,
|
||||
estimatedWeightGrams: p.estimatedWeightGrams,
|
||||
@@ -123,6 +127,11 @@ export class AdminPreOrdersService {
|
||||
if (data.color_mina !== undefined) patch.minaColor = data.color_mina as string;
|
||||
if (data.color_stone !== undefined) patch.stoneColor = data.color_stone as string;
|
||||
if (data.description !== undefined) patch.description = data.description as string;
|
||||
if (data.sum !== undefined) {
|
||||
const sum = Number(data.sum);
|
||||
if (!Number.isInteger(sum) || sum < 1) throw new BadRequestException('تعداد باید عدد صحیح و حداقل ۱ باشد');
|
||||
patch.quantity = sum;
|
||||
}
|
||||
if (data.estimatedWeightGrams !== undefined) {
|
||||
patch.estimatedWeightGrams = new Prisma.Decimal(data.estimatedWeightGrams as string);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { TableService } from './table.service';
|
||||
|
||||
describe('TableService.build — sort and filter fields', () => {
|
||||
const table = new TableService();
|
||||
const allowed = ['createdAt', 'customer.fullName'];
|
||||
const opts = { allowed, defaultOrderBy: { createdAt: 'desc' as const } };
|
||||
|
||||
it('sorts by an allowed field, nested paths included', () => {
|
||||
expect(table.build({ order: '+createdAt' } as never, opts).orderBy).toEqual({ createdAt: 'asc' });
|
||||
expect(table.build({ order: '-customer.fullName' } as never, opts).orderBy).toEqual({ customer: { fullName: 'desc' } });
|
||||
});
|
||||
|
||||
it('a sort field that maps to nothing keeps the default order instead of failing the request', () => {
|
||||
expect(table.build({ order: '-input' } as never, opts).orderBy).toEqual({ createdAt: 'desc' });
|
||||
expect(table.build({ order: '+passwordHash' } as never, opts).orderBy).toEqual({ createdAt: 'desc' });
|
||||
});
|
||||
|
||||
it('with no allow-list the field is used as given (callers that never listed one)', () => {
|
||||
expect(table.build({ order: '+anything' } as never, { defaultOrderBy: { createdAt: 'desc' as const } }).orderBy).toEqual({ anything: 'asc' });
|
||||
});
|
||||
|
||||
it('a FILTER on a field that is not allowed is still refused', () => {
|
||||
expect(() => table.build({ filters: [{ field: 'passwordHash', op: '=', value: 'x' }] } as never, opts)).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -117,8 +117,11 @@ export class TableService {
|
||||
if (query.order) {
|
||||
const dir = query.order.startsWith('-') ? 'desc' : 'asc';
|
||||
const field = query.order.replace(/^[+-]/, '');
|
||||
if (field) {
|
||||
this.assertAllowed(field, opts.allowed);
|
||||
// A sort field the grid cannot map to a stored field (a column key that is
|
||||
// only the reference's name for something) keeps the default order. Failing
|
||||
// the whole request over it left the grid with an error for a header click;
|
||||
// unlike a FILTER, an order cannot widen what the caller may see.
|
||||
if (field && (!opts.allowed || opts.allowed.includes(field))) {
|
||||
orderBy = this.nest(field, dir) as Record<string, 'asc' | 'desc'>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Body, Controller, Get, HttpCode, Post } from '@nestjs/common';
|
||||
import { CustomerPreOrdersService, type CreatePreOrderInput } from './customer-preorders.service';
|
||||
import { CurrentCustomer, CustomerAuth } from '../customer-auth/customer.guards';
|
||||
import type { AuthenticatedCustomer } from '../customer-auth/customer-jwt.strategy';
|
||||
|
||||
/**
|
||||
* «سفارش ساخت» in the apps.
|
||||
*
|
||||
* POST customer/me/pre-orders -> legacy POST /create-custom-order
|
||||
* GET customer/me/pre-orders -> legacy GET /get-custom-orders
|
||||
*
|
||||
* The customer is the token's (`me.id` is the customer a sales expert is acting
|
||||
* as, when they are); no id is accepted from the client.
|
||||
*/
|
||||
@Controller('customer/me/pre-orders')
|
||||
export class CustomerPreOrdersController {
|
||||
constructor(private readonly preOrders: CustomerPreOrdersService) {}
|
||||
|
||||
@CustomerAuth()
|
||||
@Post()
|
||||
@HttpCode(201)
|
||||
create(@Body() body: CreatePreOrderInput, @CurrentCustomer() me: AuthenticatedCustomer) {
|
||||
return this.preOrders.create(me.id, body ?? {});
|
||||
}
|
||||
|
||||
@CustomerAuth()
|
||||
@Get()
|
||||
list(@CurrentCustomer() me: AuthenticatedCustomer) {
|
||||
return this.preOrders.listMine(me.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { CustomerPreOrdersController } from './customer-preorders.controller';
|
||||
import { CustomerPreOrdersService } from './customer-preorders.service';
|
||||
|
||||
/** «سفارش ساخت» — the customer's side of made-to-order requests. */
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [CustomerPreOrdersController],
|
||||
providers: [CustomerPreOrdersService],
|
||||
})
|
||||
export class CustomerPreOrdersModule {}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { PreOrderStatus } from '@prisma/client';
|
||||
import { CustomerPreOrdersService, PREORDER_DESIGN_PURPOSE } from './customer-preorders.service';
|
||||
|
||||
/** «سفارش ساخت» — a customer's made-to-order requests. */
|
||||
describe('CustomerPreOrdersService', () => {
|
||||
function build(files: { id: string; purpose: string | null; url: string }[] = []) {
|
||||
const created: any[] = [];
|
||||
const stored: any[] = [];
|
||||
const prisma: any = {
|
||||
uploadedFile: {
|
||||
findFirst: jest.fn(async ({ where }: any) => files.find((f) => f.id === where.id && f.purpose === where.purpose) ?? null),
|
||||
},
|
||||
preOrder: {
|
||||
create: jest.fn(async ({ data }: any) => {
|
||||
created.push(data);
|
||||
const row = { id: 'p1', createdAt: new Date('2026-10-29'), convertedOrderId: null, ...data };
|
||||
stored.push(row);
|
||||
return row;
|
||||
}),
|
||||
findMany: jest.fn(async ({ where }: any) => stored.filter((r) => r.customerId === where.customerId)),
|
||||
},
|
||||
};
|
||||
return { service: new CustomerPreOrdersService(prisma), created, prisma, stored };
|
||||
}
|
||||
|
||||
const form = { color: 'طلایی', size: '18', color_mina: 'آبی', color_stone: 'قرمز', sum: 3, description: 'مطابق عکس' };
|
||||
|
||||
it('places a request for the signed-in customer, in the first status, with the quantity', async () => {
|
||||
const t = build();
|
||||
const row = await t.service.create('c1', form);
|
||||
expect(t.created[0]).toMatchObject({ customerId: 'c1', status: PreOrderStatus.PLACED, colorName: 'طلایی', sizeSpec: '18', minaColor: 'آبی', stoneColor: 'قرمز', quantity: 3, description: 'مطابق عکس' });
|
||||
expect(row).toMatchObject({ sts_str_fa: 'ثبت سفارش', step: 1, sum: 3, color: 'طلایی' });
|
||||
});
|
||||
|
||||
it('keeps the uploaded design photo by its stored URL', async () => {
|
||||
const t = build([{ id: 'f1', purpose: PREORDER_DESIGN_PURPOSE, url: '/uploads/abc.jpg' }]);
|
||||
const row = await t.service.create('c1', { ...form, file_id: 'f1' });
|
||||
expect(t.created[0].designImageUrl).toBe('/uploads/abc.jpg');
|
||||
expect(row.image).toBe('/uploads/abc.jpg');
|
||||
});
|
||||
|
||||
it('refuses a file id that is not a design photo (another purpose, or no such file)', async () => {
|
||||
const t = build([{ id: 'f2', purpose: 'customer-document', url: '/uploads/id-card.jpg' }]);
|
||||
await expect(t.service.create('c1', { ...form, file_id: 'f2' })).rejects.toThrow('تصویر');
|
||||
await expect(t.service.create('c1', { ...form, file_id: 'nope' })).rejects.toThrow(BadRequestException);
|
||||
expect(t.created).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('needs a colour and a whole number of pieces', async () => {
|
||||
const t = build();
|
||||
await expect(t.service.create('c1', { ...form, color: ' ' })).rejects.toThrow('رنگ محصول');
|
||||
for (const sum of [0, -1, 1.5, 'abc', undefined, 100001]) {
|
||||
await expect(t.service.create('c1', { ...form, sum })).rejects.toThrow('تعداد');
|
||||
}
|
||||
expect(t.created).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('bounds the free text', async () => {
|
||||
const t = build();
|
||||
await expect(t.service.create('c1', { ...form, description: 'x'.repeat(1001) })).rejects.toThrow('توضیحات');
|
||||
await expect(t.service.create('c1', { ...form, size: 'x'.repeat(101) })).rejects.toThrow('سایز');
|
||||
});
|
||||
|
||||
it('takes the customer from the argument only: the body cannot name one', async () => {
|
||||
const t = build();
|
||||
await t.service.create('c1', { ...form, customerId: 'victim', customer_id: 'victim' } as never);
|
||||
expect(t.created[0].customerId).toBe('c1');
|
||||
});
|
||||
|
||||
it("lists only the customer's own requests", async () => {
|
||||
const t = build();
|
||||
await t.service.create('c1', form);
|
||||
await t.service.create('c2', form);
|
||||
const { rows } = await t.service.listMine('c1');
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(t.prisma.preOrder.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { customerId: 'c1' } }));
|
||||
});
|
||||
|
||||
it('puts each status on the five-step tracker the reference drew; cancelled is off it', async () => {
|
||||
const t = build();
|
||||
const svc = t.service as any;
|
||||
const step = (status: PreOrderStatus) => svc.toRow({ id: 'x', status, colorName: 'a', sizeSpec: null, minaColor: null, stoneColor: null, quantity: 1, description: null, designImageUrl: null, createdAt: new Date() }).step;
|
||||
expect([PreOrderStatus.PLACED, PreOrderStatus.ADMIN_APPROVED, PreOrderStatus.WORKSHOP_APPROVED, PreOrderStatus.READY_TO_SHIP, PreOrderStatus.DELIVERED].map(step)).toEqual([1, 2, 3, 4, 5]);
|
||||
expect(step(PreOrderStatus.CANCELLED)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { PreOrderStatus, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
/** The `purpose` the app tags a design photo with when it uploads it. */
|
||||
export const PREORDER_DESIGN_PURPOSE = 'preorder-design';
|
||||
|
||||
/**
|
||||
* Where a request stands on the tracker the reference drew on «وضعیت سفارش»
|
||||
* (makeOrderStatus.vue getOrderStatus): ثبت سفارش → تایید ادمین → تایید کارگاه →
|
||||
* ارسال → تحویل شده. 0 = cancelled (off the tracker).
|
||||
*/
|
||||
const STEP: Record<PreOrderStatus, number> = {
|
||||
PLACED: 1,
|
||||
ADMIN_APPROVED: 2,
|
||||
WORKSHOP_APPROVED: 3,
|
||||
READY_TO_SHIP: 4,
|
||||
DELIVERED: 5,
|
||||
CANCELLED: 0,
|
||||
};
|
||||
|
||||
/** The reference's labels (CustomOrder::sts_str_fa). */
|
||||
const LABEL: Record<PreOrderStatus, string> = {
|
||||
PLACED: 'ثبت سفارش',
|
||||
ADMIN_APPROVED: 'تایید ادمین',
|
||||
WORKSHOP_APPROVED: 'تایید کارگاه',
|
||||
READY_TO_SHIP: 'ساخته شده آماده ارسال',
|
||||
DELIVERED: 'تحویل شده',
|
||||
CANCELLED: 'لغو شده',
|
||||
};
|
||||
|
||||
export interface CreatePreOrderInput {
|
||||
color?: unknown;
|
||||
size?: unknown;
|
||||
color_mina?: unknown;
|
||||
color_stone?: unknown;
|
||||
sum?: unknown;
|
||||
file_id?: unknown;
|
||||
description?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* «سفارش ساخت» — the customer's side of made-to-order requests (reference
|
||||
* CustomOrderController::creatCustomOrder / getCustomOrders, `create-custom-order`
|
||||
* and `get-custom-orders`).
|
||||
*
|
||||
* The request is the customer's own: the subject always comes from the token,
|
||||
* never from the body, and a list only ever holds that customer's requests.
|
||||
* What happens after it is placed — approval, the workshop, pricing, turning it
|
||||
* into a real order — stays in the admin screens (admin-preorders); the reference
|
||||
* app's own «تایید سفارش / ارسال سفارش» buttons for workshop users both posted
|
||||
* the same status and let anyone move anyone's request, so they are not carried over.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CustomerPreOrdersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
private text(value: unknown, label: string, max: number, required = false): string | null {
|
||||
const s = value === undefined || value === null ? '' : String(value).trim();
|
||||
if (!s) {
|
||||
if (required) throw new BadRequestException(`${label} الزامی است`);
|
||||
return null;
|
||||
}
|
||||
if (s.length > max) throw new BadRequestException(`${label} نباید بیشتر از ${max} نویسه باشد`);
|
||||
return s;
|
||||
}
|
||||
|
||||
async create(customerId: string, input: CreatePreOrderInput) {
|
||||
const color = this.text(input.color, 'رنگ محصول', 100, true);
|
||||
const sum = Number(input.sum);
|
||||
if (!Number.isInteger(sum) || sum < 1 || sum > 100000) {
|
||||
throw new BadRequestException('تعداد باید عدد صحیح بین ۱ و ۱۰۰۰۰۰ باشد');
|
||||
}
|
||||
|
||||
// The photo is one the app uploaded for this purpose; its stored URL is what the
|
||||
// request keeps. An id that is not such a file is refused rather than ignored.
|
||||
let designImageUrl: string | null = null;
|
||||
if (input.file_id !== undefined && input.file_id !== null && String(input.file_id) !== '') {
|
||||
const file = await this.prisma.uploadedFile.findFirst({
|
||||
where: { id: String(input.file_id), purpose: PREORDER_DESIGN_PURPOSE },
|
||||
select: { url: true },
|
||||
});
|
||||
if (!file) throw new BadRequestException('تصویر سفارش معتبر نیست؛ دوباره بارگذاری کنید');
|
||||
designImageUrl = file.url;
|
||||
}
|
||||
|
||||
const created = await this.prisma.preOrder.create({
|
||||
data: {
|
||||
customerId,
|
||||
status: PreOrderStatus.PLACED,
|
||||
colorName: color,
|
||||
sizeSpec: this.text(input.size, 'سایز محصول', 100),
|
||||
minaColor: this.text(input.color_mina, 'رنگ مینا', 100),
|
||||
stoneColor: this.text(input.color_stone, 'رنگ سنگ', 100),
|
||||
quantity: sum,
|
||||
description: this.text(input.description, 'توضیحات', 1000),
|
||||
designImageUrl,
|
||||
},
|
||||
});
|
||||
return this.toRow(created);
|
||||
}
|
||||
|
||||
/** Newest first, the customer's own only. */
|
||||
async listMine(customerId: string) {
|
||||
const rows = await this.prisma.preOrder.findMany({
|
||||
where: { customerId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 200,
|
||||
});
|
||||
return { rows: rows.map((r) => this.toRow(r)) };
|
||||
}
|
||||
|
||||
private toRow(p: Prisma.PreOrderGetPayload<object>) {
|
||||
return {
|
||||
id: p.id,
|
||||
status: p.status,
|
||||
sts_str_fa: LABEL[p.status],
|
||||
step: STEP[p.status],
|
||||
color: p.colorName,
|
||||
size: p.sizeSpec,
|
||||
color_mina: p.minaColor,
|
||||
color_stone: p.stoneColor,
|
||||
sum: p.quantity,
|
||||
description: p.description,
|
||||
image: p.designImageUrl,
|
||||
createdAt: p.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { OrderChannel } from '@prisma/client';
|
||||
import { ArrayMinSize, IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsPositive, IsString, Min, ValidateNested } from 'class-validator';
|
||||
import { ArrayMinSize, IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsPositive, IsString, Max, Min, ValidateNested } from 'class-validator';
|
||||
|
||||
export class CreateOrderItemInputDto {
|
||||
@IsString()
|
||||
@@ -8,6 +8,8 @@ export class CreateOrderItemInputDto {
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
/** Same ceiling as the panel's line edit; beyond it the weights overflow their columns (a 500). */
|
||||
@Max(100000)
|
||||
quantity!: number;
|
||||
|
||||
/** Making-charge percentage negotiated for this order (may differ from the variant default). */
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { LedgerCurrency, LedgerDirection, OrderStatus, Prisma } from '@prisma/client';
|
||||
import { LedgerService } from '../accounting/ledger.service';
|
||||
import { OrdersService } from './orders.service';
|
||||
|
||||
/**
|
||||
* Correcting a placed order — the panel's «ویرایش ردیف» / «حذف ردیف»
|
||||
* (reference order-item/edit, major/order_item/{id}) and the sales expert's
|
||||
* weight entry + «ثبت نهایی» (reference PUT order/child/{id}).
|
||||
*
|
||||
* The real OrdersService and LedgerService run against an in-memory database
|
||||
* that rounds the way the columns do (unit weight 4 places, percent 3), so
|
||||
* what is asserted is what would be stored — and the customer's debt is read
|
||||
* back from the ledger after every correction.
|
||||
*/
|
||||
|
||||
const D = (v: Prisma.Decimal.Value) => new Prisma.Decimal(v);
|
||||
|
||||
type Row = Record<string, any>;
|
||||
|
||||
function world(opts: { status: OrderStatus; salesAgentId?: string | null; blocked?: string | null }) {
|
||||
const order: Row = {
|
||||
id: 'o1',
|
||||
orderNumber: '100',
|
||||
customerId: 'c1',
|
||||
status: opts.status,
|
||||
salesAgentId: opts.salesAgentId ?? 'agent1',
|
||||
deliveredAt: null,
|
||||
totalGoldWeightGrams: D(0),
|
||||
wageWeightGrams: D(0),
|
||||
flatRialCharge: D(0),
|
||||
};
|
||||
// Two lines placed at net 8% (10% charge − 2% promotion) and 12%.
|
||||
const items: Row[] = [
|
||||
{ id: 'i1', orderId: 'o1', quantity: 3, rashWeightGramsSnapshot: D('5.7856'), wagePercentSnapshot: D(10), discountPercentSnapshot: D(2), computedWeightGrams: D('6.2484'), flatRialCharge: D(150000) },
|
||||
{ id: 'i2', orderId: 'o1', quantity: 10, rashWeightGramsSnapshot: D('2.5'), wagePercentSnapshot: D(12), discountPercentSnapshot: D(0), computedWeightGrams: D('2.8'), flatRialCharge: D(80000) },
|
||||
];
|
||||
const sum = (f: (i: Row) => Prisma.Decimal) => items.reduce((s, i) => s.add(f(i)), D(0));
|
||||
order.totalGoldWeightGrams = sum((i) => i.computedWeightGrams.mul(i.quantity)); // 18.7452 + 28
|
||||
order.wageWeightGrams = order.totalGoldWeightGrams.sub(sum((i) => i.rashWeightGramsSnapshot.mul(i.quantity)));
|
||||
order.flatRialCharge = sum((i) => i.flatRialCharge.mul(i.quantity));
|
||||
|
||||
// What the columns store.
|
||||
const round = (data: Row) => {
|
||||
const out = { ...data };
|
||||
for (const k of ['computedWeightGrams', 'rashWeightGramsSnapshot', 'totalGoldWeightGrams', 'wageWeightGrams']) {
|
||||
if (out[k] !== undefined) out[k] = D(out[k]).toDecimalPlaces(4);
|
||||
}
|
||||
if (out.wagePercentSnapshot !== undefined) out.wagePercentSnapshot = D(out.wagePercentSnapshot).toDecimalPlaces(3);
|
||||
return out;
|
||||
};
|
||||
|
||||
const entries: Row[] = [];
|
||||
const customer = { goldDebtGrams: D(0), fiatDebtRial: D(0) };
|
||||
const events: Row[] = [];
|
||||
|
||||
const tx: Record<string, any> = {
|
||||
order: {
|
||||
findUnique: jest.fn(async ({ include }: { include?: { items?: boolean } }) => (include?.items ? { ...order, items: [...items] } : { ...order })),
|
||||
findUniqueOrThrow: jest.fn(async () => ({ ...order })),
|
||||
update: jest.fn(async ({ data }: { data: Row }) => {
|
||||
Object.assign(order, round(data));
|
||||
return { ...order };
|
||||
}),
|
||||
},
|
||||
orderItem: {
|
||||
findMany: jest.fn(async () => items.map((i) => ({ ...i }))),
|
||||
findFirst: jest.fn(async ({ where }: { where: { id: string } }) => items.find((i) => i.id === where.id) ?? null),
|
||||
update: jest.fn(async ({ where, data }: { where: { id: string }; data: Row }) => {
|
||||
Object.assign(items.find((i) => i.id === where.id)!, round(data));
|
||||
}),
|
||||
delete: jest.fn(async ({ where }: { where: { id: string } }) => {
|
||||
items.splice(items.findIndex((i) => i.id === where.id), 1);
|
||||
}),
|
||||
},
|
||||
orderStatusEvent: { create: jest.fn(async ({ data }: { data: Row }) => void events.push(data)) },
|
||||
ledgerEntry: {
|
||||
create: jest.fn(async ({ data }: { data: Row }) => {
|
||||
entries.push(data);
|
||||
return data;
|
||||
}),
|
||||
groupBy: jest.fn(async ({ where }: { where: Row }) => {
|
||||
const out: Row[] = [];
|
||||
for (const d of [LedgerDirection.DEBIT, LedgerDirection.CREDIT]) {
|
||||
const hit = entries.filter((e) => e.currency === where.currency && e.direction === d && e.confirmed && e.sourceType === 'ORDER_STOCK');
|
||||
if (hit.length) out.push({ direction: d, _sum: { amount: hit.reduce((s, e) => s.add(e.amount), D(0)) } });
|
||||
}
|
||||
return out;
|
||||
}),
|
||||
},
|
||||
customer: {
|
||||
update: jest.fn(async ({ data }: { data: Row }) => {
|
||||
if (data.goldDebtGrams) customer.goldDebtGrams = customer.goldDebtGrams.add(data.goldDebtGrams.increment);
|
||||
if (data.fiatDebtRial) customer.fiatDebtRial = customer.fiatDebtRial.add(data.fiatDebtRial.increment);
|
||||
}),
|
||||
},
|
||||
};
|
||||
const prisma = { $transaction: jest.fn(async (fn: (t: unknown) => unknown) => fn(tx)) };
|
||||
const accounting = {
|
||||
orderEdited: jest.fn(async () => undefined),
|
||||
orderDocumentBlocksEdit: jest.fn(async () => opts.blocked ?? null),
|
||||
orderStatusReached: jest.fn(async () => undefined),
|
||||
orderCancelled: jest.fn(async () => undefined),
|
||||
};
|
||||
const ledger = new LedgerService(prisma as never);
|
||||
const service = new OrdersService(prisma as never, {} as never, ledger, accounting as never, {} as never, { send: jest.fn() } as never);
|
||||
|
||||
/** The debt a delivered order carries right now (what updateRemain would give). */
|
||||
const owed = () => ({ gold: customer.goldDebtGrams, rial: customer.fiatDebtRial });
|
||||
return { service, order, items, entries, accounting, owed, events, ledger, tx };
|
||||
}
|
||||
|
||||
/** The reference's per-unit weight: rash + rash × percent / 100 (percent = net). */
|
||||
const refUnit = (rash: number, netPercent: number) => rash + rash * (netPercent / 100);
|
||||
const near = (actual: Prisma.Decimal.Value, expected: number, eps = 1e-3) => expect(Math.abs(Number(actual) - expected)).toBeLessThan(eps);
|
||||
|
||||
describe('editing one line of a placed order (order-item/edit)', () => {
|
||||
/** A delivered order, with its debt posted the way delivery posts it. */
|
||||
async function delivered() {
|
||||
const w = world({ status: OrderStatus.DELIVERED_TO_AGENT });
|
||||
await w.ledger.syncOrderDebt('o1', w.tx as never);
|
||||
return w;
|
||||
}
|
||||
|
||||
it('starts from a consistent state: debt equals the order', async () => {
|
||||
const w = await delivered();
|
||||
near(w.owed().gold, 18.7452 + 28);
|
||||
expect(w.owed().rial.toString()).toBe('1250000');
|
||||
});
|
||||
|
||||
it("recomputes the line, the order's totals and the debt after a quantity and weight correction", async () => {
|
||||
const w = await delivered();
|
||||
await w.service.editLine('o1', 'i1', { rashWeightGrams: '6', quantity: 5 });
|
||||
const unit = refUnit(6, 8); // net 10 − 2 stays
|
||||
near(w.items[0].computedWeightGrams, unit, 1e-4);
|
||||
expect(w.items[0].quantity).toBe(5);
|
||||
const total = unit * 5 + 2.8 * 10;
|
||||
near(w.order.totalGoldWeightGrams, total);
|
||||
near(w.order.wageWeightGrams, total - (6 * 5 + 2.5 * 10));
|
||||
// The rial making charge follows the quantity too (the reference left it stale).
|
||||
expect(w.order.flatRialCharge.toString()).toBe(String(150000 * 5 + 80000 * 10));
|
||||
near(w.owed().gold, total);
|
||||
expect(w.owed().rial.toString()).toBe(String(150000 * 5 + 80000 * 10));
|
||||
});
|
||||
|
||||
it('posts only the difference, as a correction on the same order', async () => {
|
||||
const w = await delivered();
|
||||
const before = w.entries.length;
|
||||
await w.service.editLine('o1', 'i1', { quantity: 4 });
|
||||
expect(w.entries.length).toBeGreaterThan(before);
|
||||
const fresh = w.entries.slice(before);
|
||||
expect(fresh.every((e) => e.orderId === 'o1' && e.sourceType === 'ORDER_STOCK' && e.confirmed)).toBe(true);
|
||||
expect(fresh.find((e) => e.currency === LedgerCurrency.GOLD_GRAM)!.direction).toBe(LedgerDirection.DEBIT);
|
||||
});
|
||||
|
||||
it('a corrected net percent keeps the line\'s own promotion: charge = net + discount', async () => {
|
||||
const w = await delivered();
|
||||
await w.service.editLine('o1', 'i1', { netPercent: 9 });
|
||||
expect(w.items[0].wagePercentSnapshot.toString()).toBe('11'); // 9 net + 2 discount
|
||||
near(w.items[0].computedWeightGrams, refUnit(5.7856, 9), 1e-4);
|
||||
});
|
||||
|
||||
it('an order not yet delivered owes nothing, before or after the correction', async () => {
|
||||
const w = world({ status: OrderStatus.PLACED });
|
||||
await w.service.editLine('o1', 'i2', { quantity: 20 });
|
||||
expect(w.owed().gold.toString()).toBe('0');
|
||||
expect(w.entries).toHaveLength(0);
|
||||
near(w.order.totalGoldWeightGrams, 6.2484 * 3 + 2.8 * 20);
|
||||
});
|
||||
|
||||
it('tells the accounting document the order changed', async () => {
|
||||
const w = await delivered();
|
||||
await w.service.editLine('o1', 'i2', { quantity: 11 });
|
||||
expect(w.accounting.orderEdited).toHaveBeenCalledWith(expect.anything(), 'o1');
|
||||
});
|
||||
|
||||
it('refuses nonsense and changes nothing', async () => {
|
||||
const w = await delivered();
|
||||
const total = w.order.totalGoldWeightGrams.toString();
|
||||
await expect(w.service.editLine('o1', 'i1', { rashWeightGrams: 0 })).rejects.toThrow(BadRequestException);
|
||||
await expect(w.service.editLine('o1', 'i1', { rashWeightGrams: -3 })).rejects.toThrow(BadRequestException);
|
||||
await expect(w.service.editLine('o1', 'i1', { quantity: 1.5 })).rejects.toThrow('صحیح');
|
||||
await expect(w.service.editLine('o1', 'i1', { quantity: 0 })).rejects.toThrow(BadRequestException);
|
||||
await expect(w.service.editLine('o1', 'i1', { netPercent: 'abc' })).rejects.toThrow(BadRequestException);
|
||||
await expect(w.service.editLine('o1', 'nope', { quantity: 2 })).rejects.toThrow(NotFoundException);
|
||||
expect(w.order.totalGoldWeightGrams.toString()).toBe(total);
|
||||
});
|
||||
|
||||
it('refuses a cancelled order', async () => {
|
||||
const w = world({ status: OrderStatus.CANCELLED });
|
||||
await expect(w.service.editLine('o1', 'i1', { quantity: 2 })).rejects.toThrow('لغو');
|
||||
});
|
||||
|
||||
it('refuses while the accounting document is being sent or waits for review', async () => {
|
||||
const w = world({ status: OrderStatus.DELIVERED_TO_AGENT, blocked: 'سند حسابداری این سفارش در حال ارسال است' });
|
||||
await expect(w.service.editLine('o1', 'i1', { quantity: 2 })).rejects.toThrow('سند حسابداری');
|
||||
expect(w.items[0].quantity).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removing a line of a placed order (major/order_item/{id})', () => {
|
||||
it('drops the line from the order and from the debt', async () => {
|
||||
const w = world({ status: OrderStatus.DELIVERED_TO_CUSTOMER });
|
||||
await w.ledger.syncOrderDebt('o1', w.tx as never);
|
||||
await w.service.removeLine('o1', 'i1');
|
||||
expect(w.items.map((i) => i.id)).toEqual(['i2']);
|
||||
near(w.order.totalGoldWeightGrams, 28);
|
||||
expect(w.order.flatRialCharge.toString()).toBe('800000');
|
||||
near(w.owed().gold, 28);
|
||||
expect(w.owed().rial.toString()).toBe('800000');
|
||||
});
|
||||
|
||||
it('will not remove the last line (an order of nothing owes nothing and shows nothing)', async () => {
|
||||
const w = world({ status: OrderStatus.PLACED });
|
||||
await w.service.removeLine('o1', 'i1');
|
||||
await expect(w.service.removeLine('o1', 'i2')).rejects.toThrow('آخرین ردیف');
|
||||
expect(w.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('an unknown line is not found', async () => {
|
||||
const w = world({ status: OrderStatus.PLACED });
|
||||
await expect(w.service.removeLine('o1', 'zzz')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the sales expert's weights and «ثبت نهایی» (PUT order/child/{id})", () => {
|
||||
async function inHand() {
|
||||
const w = world({ status: OrderStatus.DELIVERED_TO_AGENT, salesAgentId: 'agent1' });
|
||||
await w.ledger.syncOrderDebt('o1', w.tx as never);
|
||||
return w;
|
||||
}
|
||||
|
||||
it('keeps the making charge: the new raw weight × (1 + net percent), not the bare weight', async () => {
|
||||
const w = await inHand();
|
||||
const res = await w.service.expertAdjust('o1', 'agent1', [{ id: 'i1', rashWeightGrams: '5.9' }, { id: 'i2', rashWeightGrams: '2.4' }], false);
|
||||
near(w.items[0].computedWeightGrams, refUnit(5.9, 8), 1e-4); // net 8, as placed
|
||||
near(w.items[1].computedWeightGrams, refUnit(2.4, 12), 1e-4);
|
||||
const total = refUnit(5.9, 8) * 3 + refUnit(2.4, 12) * 10;
|
||||
near(w.order.totalGoldWeightGrams, total);
|
||||
near(w.owed().gold, total);
|
||||
expect(res).toMatchObject({ id: '100', status: OrderStatus.DELIVERED_TO_AGENT });
|
||||
});
|
||||
|
||||
it('submitting as final hands the order to the customer and leaves the debt as corrected', async () => {
|
||||
const w = await inHand();
|
||||
const res = await w.service.expertAdjust('o1', 'agent1', [{ id: 'i1', rashWeightGrams: '5.9' }], true);
|
||||
expect(w.order.status).toBe(OrderStatus.DELIVERED_TO_CUSTOMER);
|
||||
expect(w.order.deliveredAt).toBeInstanceOf(Date);
|
||||
expect(res.status).toBe(OrderStatus.DELIVERED_TO_CUSTOMER);
|
||||
near(w.owed().gold, refUnit(5.9, 8) * 3 + 28);
|
||||
expect(w.accounting.orderStatusReached).toHaveBeenCalledWith(expect.anything(), 'o1', OrderStatus.DELIVERED_TO_CUSTOMER);
|
||||
});
|
||||
|
||||
it("is only for the expert's own order — anyone else sees it as not found", async () => {
|
||||
const w = await inHand();
|
||||
await expect(w.service.expertAdjust('o1', 'someone-else', [{ id: 'i1', rashWeightGrams: '1' }], false)).rejects.toThrow(NotFoundException);
|
||||
expect(w.items[0].rashWeightGramsSnapshot.toString()).toBe('5.7856');
|
||||
});
|
||||
|
||||
it('only while the order is in their hands', async () => {
|
||||
const w = world({ status: OrderStatus.PREPARING, salesAgentId: 'agent1' });
|
||||
await expect(w.service.expertAdjust('o1', 'agent1', [{ id: 'i1', rashWeightGrams: '1' }], false)).rejects.toThrow('تحویل مسئول فروش');
|
||||
const done = world({ status: OrderStatus.DELIVERED_TO_CUSTOMER, salesAgentId: 'agent1' });
|
||||
await expect(done.service.expertAdjust('o1', 'agent1', [{ id: 'i1', rashWeightGrams: '1' }], true)).rejects.toThrow('تحویل مسئول فروش');
|
||||
});
|
||||
|
||||
it('refuses a missing, zero or negative weight and a line of another order', async () => {
|
||||
const w = await inHand();
|
||||
await expect(w.service.expertAdjust('o1', 'agent1', [{ id: 'i1', rashWeightGrams: 0 }], false)).rejects.toThrow(BadRequestException);
|
||||
await expect(w.service.expertAdjust('o1', 'agent1', [{ id: 'i1', rashWeightGrams: -2 }], false)).rejects.toThrow(BadRequestException);
|
||||
await expect(w.service.expertAdjust('o1', 'agent1', [{ id: 'i1', rashWeightGrams: undefined }], false)).rejects.toThrow(BadRequestException);
|
||||
await expect(w.service.expertAdjust('o1', 'agent1', [{ id: 'other-order-line', rashWeightGrams: 3 }], false)).rejects.toThrow(BadRequestException);
|
||||
expect(w.items[0].rashWeightGramsSnapshot.toString()).toBe('5.7856');
|
||||
});
|
||||
|
||||
it('refuses while the accounting document is being sent or waits for review', async () => {
|
||||
const w = world({ status: OrderStatus.DELIVERED_TO_AGENT, salesAgentId: 'agent1', blocked: 'سند حسابداری در حال ارسال' });
|
||||
await expect(w.service.expertAdjust('o1', 'agent1', [{ id: 'i1', rashWeightGrams: '5' }], false)).rejects.toThrow('سند حسابداری');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { OrderChannel, OrderStatus, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CatalogService } from '../catalog/catalog.service';
|
||||
@@ -329,39 +329,222 @@ export class OrdersService {
|
||||
}
|
||||
|
||||
async updateStatus(orderId: string, newStatus: OrderStatus, actorAdminUserId?: string, note?: string) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const order = await tx.order.findUnique({ where: { id: orderId } });
|
||||
if (!order) throw new NotFoundException(`Order ${orderId} not found`);
|
||||
return this.prisma.$transaction((tx) => this.moveStatus(tx, orderId, newStatus, actorAdminUserId, note));
|
||||
}
|
||||
|
||||
const updated = await tx.order.update({
|
||||
where: { id: orderId },
|
||||
/** The status change itself, inside the caller's transaction. */
|
||||
private async moveStatus(
|
||||
tx: Prisma.TransactionClient,
|
||||
orderId: string,
|
||||
newStatus: OrderStatus,
|
||||
actorAdminUserId?: string,
|
||||
note?: string,
|
||||
) {
|
||||
const order = await tx.order.findUnique({ where: { id: orderId } });
|
||||
if (!order) throw new NotFoundException(`Order ${orderId} not found`);
|
||||
|
||||
const updated = await tx.order.update({
|
||||
where: { id: orderId },
|
||||
data: {
|
||||
status: newStatus,
|
||||
deliveredAt: newStatus === OrderStatus.DELIVERED_TO_CUSTOMER ? new Date() : order.deliveredAt,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.orderStatusEvent.create({
|
||||
data: { orderId, fromStatus: order.status, toStatus: newStatus, note, actorAdminId: actorAdminUserId },
|
||||
});
|
||||
|
||||
// Debt follows the status, as legacy's recomputed `sts > 4` did: owed
|
||||
// while delivered (to the expert or the customer), not otherwise, never
|
||||
// twice. See LedgerService.syncOrderDebt (ACC-03 / MON-04).
|
||||
await this.ledger.syncOrderDebt(orderId, tx);
|
||||
|
||||
// D27 — the accounting document (پیشفاکتور) follows the owner's rule on
|
||||
// which status posts it, queued in this same transaction.
|
||||
if (newStatus === OrderStatus.CANCELLED && order.status !== OrderStatus.CANCELLED) {
|
||||
await this.accounting.orderCancelled(tx, orderId);
|
||||
} else if (newStatus !== order.status) {
|
||||
await this.accounting.orderStatusReached(tx, orderId, newStatus);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/* ───────────── correcting a placed order ───────────── */
|
||||
|
||||
/**
|
||||
* An order's totals from its stored lines — the same three sums createOrder
|
||||
* takes, so a corrected order and a freshly placed one agree: grams owed,
|
||||
* the making-charge part of them, and the rial making charge (unit × qty).
|
||||
*/
|
||||
private async recalcOrder(tx: Prisma.TransactionClient, orderId: string) {
|
||||
const items = await tx.orderItem.findMany({ where: { orderId } });
|
||||
const zero = new Prisma.Decimal(0);
|
||||
const total = items.reduce((s, i) => s.add(new Prisma.Decimal(i.computedWeightGrams).mul(i.quantity)), zero);
|
||||
const rash = items.reduce((s, i) => s.add(new Prisma.Decimal(i.rashWeightGramsSnapshot).mul(i.quantity)), zero);
|
||||
const rial = items.reduce((s, i) => s.add(new Prisma.Decimal(i.flatRialCharge).mul(i.quantity)), zero);
|
||||
return tx.order.update({
|
||||
where: { id: orderId },
|
||||
data: { totalGoldWeightGrams: total, wageWeightGrams: total.sub(rash), flatRialCharge: rial },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* After a line changed: totals, then the debt (a delivered order owes the new
|
||||
* figure — only the difference is posted, see LedgerService.syncOrderDebt),
|
||||
* then the accounting document, which no longer matches if it was already sent.
|
||||
*/
|
||||
private async afterCorrection(tx: Prisma.TransactionClient, orderId: string) {
|
||||
await this.recalcOrder(tx, orderId);
|
||||
await this.ledger.syncOrderDebt(orderId, tx);
|
||||
await this.accounting.orderEdited(tx, orderId);
|
||||
}
|
||||
|
||||
private async assertCorrectable(tx: Prisma.TransactionClient, orderId: string) {
|
||||
const order = await tx.order.findUnique({ where: { id: orderId } });
|
||||
if (!order) throw new NotFoundException('سفارش یافت نشد');
|
||||
if (order.status === OrderStatus.CANCELLED) throw new BadRequestException('سفارش لغوشده قابل ویرایش نیست');
|
||||
const blocked = await this.accounting.orderDocumentBlocksEdit(tx, orderId);
|
||||
if (blocked) throw new BadRequestException(blocked);
|
||||
return order;
|
||||
}
|
||||
|
||||
/** A figure from a form: a finite number inside [min, max], or a Persian 400. */
|
||||
private figure(value: unknown, label: string, min: number, max: number, minExclusive = false): Prisma.Decimal {
|
||||
let d: Prisma.Decimal;
|
||||
try {
|
||||
d = new Prisma.Decimal(String(value).trim());
|
||||
} catch {
|
||||
throw new BadRequestException(`${label}: عدد معتبر نیست`);
|
||||
}
|
||||
if (!d.isFinite() || (minExclusive ? d.lte(min) : d.lt(min)) || d.gt(max)) {
|
||||
throw new BadRequestException(`${label}: باید ${minExclusive ? 'بیشتر از' : 'حداقل'} ${min} و حداکثر ${max} باشد`);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST order-item/edit` (MajorOrderController::orderItemEdit): the
|
||||
* panel corrects one line of a wholesale order — its raw weight, its quantity
|
||||
* and the percent actually charged.
|
||||
*
|
||||
* w = rash × (1 + percent/100) per unit, as the reference computed it
|
||||
*
|
||||
* then the order's totals and the customer's debt follow (the reference ran
|
||||
* updateRemain). Not copied from the reference: its order `wage` came out as
|
||||
* the whole weight (it summed a column that does not exist), and the order's
|
||||
* rial charge stayed at the old quantity — here both follow the lines.
|
||||
* The line's own discount is kept; the percent given is the NET one.
|
||||
*/
|
||||
async editLine(
|
||||
orderId: string,
|
||||
lineId: string,
|
||||
input: { rashWeightGrams?: unknown; quantity?: unknown; netPercent?: unknown },
|
||||
) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await this.assertCorrectable(tx, orderId);
|
||||
const line = await tx.orderItem.findFirst({ where: { id: lineId, orderId } });
|
||||
if (!line) throw new NotFoundException('ردیف سفارش یافت نشد');
|
||||
|
||||
const has = (v: unknown) => v !== undefined && v !== null && v !== '';
|
||||
const rash = has(input.rashWeightGrams)
|
||||
? this.figure(input.rashWeightGrams, 'وزن خام', 0, 100000, true)
|
||||
: new Prisma.Decimal(line.rashWeightGramsSnapshot);
|
||||
const quantity = has(input.quantity) ? this.figure(input.quantity, 'تعداد', 1, 100000) : new Prisma.Decimal(line.quantity);
|
||||
if (!quantity.isInteger()) throw new BadRequestException('تعداد: باید عدد صحیح باشد');
|
||||
const discount = new Prisma.Decimal(line.discountPercentSnapshot);
|
||||
const net = has(input.netPercent)
|
||||
? this.figure(input.netPercent, 'درصد اجرت', -100, 1000)
|
||||
: new Prisma.Decimal(line.wagePercentSnapshot).sub(discount);
|
||||
|
||||
await tx.orderItem.update({
|
||||
where: { id: lineId },
|
||||
data: {
|
||||
status: newStatus,
|
||||
deliveredAt: newStatus === OrderStatus.DELIVERED_TO_CUSTOMER ? new Date() : order.deliveredAt,
|
||||
rashWeightGramsSnapshot: rash,
|
||||
quantity: quantity.toNumber(),
|
||||
wagePercentSnapshot: net.add(discount),
|
||||
computedWeightGrams: rash.mul(net.div(100).add(1)),
|
||||
},
|
||||
});
|
||||
|
||||
await tx.orderStatusEvent.create({
|
||||
data: { orderId, fromStatus: order.status, toStatus: newStatus, note, actorAdminId: actorAdminUserId },
|
||||
});
|
||||
|
||||
// Debt follows the status, as legacy's recomputed `sts > 4` did: owed
|
||||
// while delivered (to the expert or the customer), not otherwise, never
|
||||
// twice. See LedgerService.syncOrderDebt (ACC-03 / MON-04).
|
||||
await this.ledger.syncOrderDebt(orderId, tx);
|
||||
|
||||
// D27 — the accounting document (پیشفاکتور) follows the owner's rule on
|
||||
// which status posts it, queued in this same transaction.
|
||||
if (newStatus === OrderStatus.CANCELLED && order.status !== OrderStatus.CANCELLED) {
|
||||
await this.accounting.orderCancelled(tx, orderId);
|
||||
} else if (newStatus !== order.status) {
|
||||
await this.accounting.orderStatusReached(tx, orderId, newStatus);
|
||||
}
|
||||
|
||||
return updated;
|
||||
await this.afterCorrection(tx, orderId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `DELETE major/order_item/{id}` (MajorOrderController::deleteOrderItem).
|
||||
* The last line cannot go: an order with no lines owes nothing and shows
|
||||
* nothing — delete the order instead.
|
||||
*/
|
||||
async removeLine(orderId: string, lineId: string) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await this.assertCorrectable(tx, orderId);
|
||||
const lines = await tx.orderItem.findMany({ where: { orderId }, select: { id: true } });
|
||||
if (!lines.some((l) => l.id === lineId)) throw new NotFoundException('ردیف سفارش یافت نشد');
|
||||
if (lines.length < 2) {
|
||||
throw new BadRequestException('آخرین ردیف سفارش را نمیتوان حذف کرد؛ کل سفارش را حذف یا لغو کنید.');
|
||||
}
|
||||
await tx.orderItem.delete({ where: { id: lineId } });
|
||||
await this.afterCorrection(tx, orderId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `PUT order/child/{id}` (ApiMobileController::updateChildOrder): the
|
||||
* sales expert, with the order in hand («تحویل مسئول فروش»), enters the real
|
||||
* weight of each piece and may then submit it for good («ثبت نهایی»), which
|
||||
* hands it to the customer.
|
||||
*
|
||||
* What the screen asks for is the corrected RAW weight per piece; the final
|
||||
* weight is that weight with the making charge the order was placed at. The
|
||||
* reference computed it from a `percent_pay` its own order lines do not
|
||||
* carry, so the charge silently dropped out and the order shrank to raw
|
||||
* weight — the intent is kept here, the breakage is not. It also let anyone
|
||||
* edit any order; here it has to be this expert's own, still with them.
|
||||
*/
|
||||
async expertAdjust(
|
||||
orderId: string,
|
||||
expertAdminUserId: string,
|
||||
lines: { id: unknown; rashWeightGrams: unknown }[],
|
||||
finalSubmit: boolean,
|
||||
) {
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
const order = await tx.order.findUnique({ where: { id: orderId }, include: { items: true } });
|
||||
// Someone else's order is "not found", not "forbidden" (no enumeration).
|
||||
if (!order || order.salesAgentId !== expertAdminUserId) throw new NotFoundException('سفارش یافت نشد');
|
||||
if (order.status !== OrderStatus.DELIVERED_TO_AGENT) {
|
||||
throw new BadRequestException('این سفارش در وضعیت «تحویل مسئول فروش» نیست');
|
||||
}
|
||||
const blocked = await this.accounting.orderDocumentBlocksEdit(tx, orderId);
|
||||
if (blocked) throw new BadRequestException(blocked);
|
||||
if (!Array.isArray(lines)) throw new BadRequestException('ردیفهای سفارش ارسال نشده است');
|
||||
|
||||
const before = new Prisma.Decimal(order.totalGoldWeightGrams);
|
||||
for (const l of lines) {
|
||||
const line = order.items.find((i) => i.id === String(l.id));
|
||||
if (!line) throw new BadRequestException('ردیف سفارش پیدا نشد');
|
||||
const rash = this.figure(l.rashWeightGrams, 'وزن', 0, 100000, true);
|
||||
const net = new Prisma.Decimal(line.wagePercentSnapshot).sub(line.discountPercentSnapshot);
|
||||
await tx.orderItem.update({
|
||||
where: { id: line.id },
|
||||
data: { rashWeightGramsSnapshot: rash, computedWeightGrams: rash.mul(net.div(100).add(1)) },
|
||||
});
|
||||
}
|
||||
await this.afterCorrection(tx, orderId);
|
||||
if (finalSubmit) await this.moveStatus(tx, orderId, OrderStatus.DELIVERED_TO_CUSTOMER, undefined, 'ثبت نهایی توسط کارشناس فروش');
|
||||
|
||||
const after = await tx.order.findUniqueOrThrow({ where: { id: orderId } });
|
||||
return { orderNumber: after.orderNumber, status: after.status, before, after: new Prisma.Decimal(after.totalGoldWeightGrams) };
|
||||
});
|
||||
|
||||
// The ledger shows the difference as a correction entry; this is the plain-text trail.
|
||||
this.logger.log(
|
||||
`Order ${result.orderNumber} weights corrected by sales expert ${expertAdminUserId}: ` +
|
||||
`${result.before.toString()} g → ${result.after.toString()} g${finalSubmit ? ' (submitted as final)' : ''}.`,
|
||||
);
|
||||
return { id: result.orderNumber, status: result.status };
|
||||
}
|
||||
|
||||
/** Unscoped read. Only reachable from the admin-guarded route. */
|
||||
getById(orderId: string) {
|
||||
return this.prisma.order.findUnique({
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { WageService } from './wage.service';
|
||||
|
||||
/**
|
||||
* WageService.quote against the reference's promotion rules (henza_pwa
|
||||
* Mixins/product: getCandidPromotions / checkIgnoreList / getPromotion).
|
||||
*/
|
||||
describe('WageService.quote — promotions and the exclusion list', () => {
|
||||
const D = (v: Prisma.Decimal.Value) => new Prisma.Decimal(v);
|
||||
|
||||
function build(exclusions: { type: number; itemId: string }[]) {
|
||||
const variants = [
|
||||
{ id: 'v1', defaultWagePercent: D(7), product: { id: 'p1', categoryId: 'cat1', brandId: 'b1' } },
|
||||
{ id: 'v2', defaultWagePercent: D(7), product: { id: 'p2', categoryId: 'cat2', brandId: 'b2' } },
|
||||
];
|
||||
const promotions = [
|
||||
{ scope: 'PRODUCT', productId: 'p1', categoryId: null, brandId: null, discountPercent: D(2) },
|
||||
{ scope: 'CATEGORY', productId: null, categoryId: 'cat1', brandId: null, discountPercent: D(1) },
|
||||
{ scope: 'PRODUCT', productId: 'p2', categoryId: null, brandId: null, discountPercent: D(4) },
|
||||
];
|
||||
const prisma = {
|
||||
customer: { findUnique: jest.fn(async () => ({ makingChargeOffsetPercent: D(3) })) },
|
||||
productVariant: { findMany: jest.fn(async () => variants) },
|
||||
promotion: { findMany: jest.fn(async () => promotions) },
|
||||
promotionExclusion: { findMany: jest.fn(async () => exclusions) },
|
||||
setting: { findUnique: jest.fn(async () => null) }, // legacy formula
|
||||
};
|
||||
return new WageService(prisma as never);
|
||||
}
|
||||
|
||||
const final = async (svc: WageService, id: string) => (await svc.quote('c1', ['v1', 'v2'])).get(id)!.finalPercent;
|
||||
|
||||
it('with no exclusions: variant + customer − the largest promotion', async () => {
|
||||
const svc = build([]);
|
||||
expect(await final(svc, 'v1')).toBe('8'); // 7 + 3 − max(2, 1)
|
||||
expect(await final(svc, 'v2')).toBe('6'); // 7 + 3 − 4
|
||||
});
|
||||
|
||||
it('a product on the exclusion list gets no promotion at all', async () => {
|
||||
const svc = build([{ type: 1, itemId: 'p1' }]);
|
||||
expect(await final(svc, 'v1')).toBe('10'); // 7 + 3, nothing taken off
|
||||
expect(await final(svc, 'v2')).toBe('6'); // untouched
|
||||
});
|
||||
|
||||
it('an excluded category switches off every promotion of its products', async () => {
|
||||
const svc = build([{ type: 2, itemId: 'cat1' }]);
|
||||
expect(await final(svc, 'v1')).toBe('10');
|
||||
expect(await final(svc, 'v2')).toBe('6');
|
||||
});
|
||||
|
||||
it('an excluded brand switches off its products', async () => {
|
||||
const svc = build([{ type: 4, itemId: 'b2' }]);
|
||||
expect(await final(svc, 'v1')).toBe('8');
|
||||
expect(await final(svc, 'v2')).toBe('10');
|
||||
});
|
||||
|
||||
it('an excluded customer gets no promotion on anything', async () => {
|
||||
const svc = build([{ type: 3, itemId: 'c1' }]);
|
||||
expect(await final(svc, 'v1')).toBe('10');
|
||||
expect(await final(svc, 'v2')).toBe('10');
|
||||
});
|
||||
|
||||
it('an exclusion of another type with the same id is not a match', async () => {
|
||||
// type 3 (customer) "p1" must not switch off product p1.
|
||||
const svc = build([{ type: 3, itemId: 'p1' }]);
|
||||
expect(await final(svc, 'v1')).toBe('8');
|
||||
});
|
||||
|
||||
it('the stored snapshot still satisfies wage − discount = final', async () => {
|
||||
const svc = build([]);
|
||||
const q = (await svc.quote('c1', ['v1'])).get('v1')!;
|
||||
expect(D(q.wagePercentSnapshot).sub(q.discountPercentSnapshot).toString()).toBe(q.finalPercent);
|
||||
});
|
||||
});
|
||||
@@ -113,6 +113,13 @@ export class WageService {
|
||||
* and what an app order is charged. The promotions considered are legacy's:
|
||||
* active, inside their dates, scoped to the product, its category or its
|
||||
* brand (customer-scoped promotions were switched off in the legacy app).
|
||||
*
|
||||
* The «استثنا» list (views/offersIgnore) switches promotions off: legacy's
|
||||
* `checkIgnoreList` (henza_pwa Mixins/product) returned no promotion at all
|
||||
* for a product, category, brand or customer on it. The list was stored and
|
||||
* edited in the panel but nothing here read it, so an excluded product still
|
||||
* got the discount taken off its making charge — fewer grams charged than the
|
||||
* reference charges.
|
||||
*/
|
||||
async quote(customerId: string, variantIds: string[], tx: Tx = this.prisma): Promise<Map<string, WageQuote>> {
|
||||
const customer = await tx.customer.findUnique({
|
||||
@@ -127,6 +134,12 @@ export class WageService {
|
||||
select: { id: true, defaultWagePercent: true, product: { select: { id: true, categoryId: true, brandId: true } } },
|
||||
});
|
||||
|
||||
// Type codes are the promotion scope codes: 1 product | 2 category | 3 customer | 4 brand.
|
||||
const exclusions = await tx.promotionExclusion.findMany({ select: { type: true, itemId: true } });
|
||||
const excluded = (type: number, itemId: string | null | undefined) =>
|
||||
!!itemId && exclusions.some((e) => e.type === type && e.itemId === itemId);
|
||||
const customerExcluded = excluded(3, customerId);
|
||||
|
||||
const now = new Date();
|
||||
const promotions = await tx.promotion.findMany({
|
||||
where: {
|
||||
@@ -148,12 +161,19 @@ export class WageService {
|
||||
const formula = await this.formula(tx);
|
||||
const out = new Map<string, WageQuote>();
|
||||
for (const v of variants) {
|
||||
const applying = promotions.filter(
|
||||
(p) =>
|
||||
(p.scope === PromotionScope.PRODUCT && p.productId === v.product.id) ||
|
||||
(p.scope === PromotionScope.CATEGORY && !!v.product.categoryId && p.categoryId === v.product.categoryId) ||
|
||||
(p.scope === PromotionScope.BRAND && !!v.product.brandId && p.brandId === v.product.brandId),
|
||||
);
|
||||
const ignored =
|
||||
customerExcluded ||
|
||||
excluded(1, v.product.id) ||
|
||||
excluded(2, v.product.categoryId) ||
|
||||
excluded(4, v.product.brandId);
|
||||
const applying = ignored
|
||||
? []
|
||||
: promotions.filter(
|
||||
(p) =>
|
||||
(p.scope === PromotionScope.PRODUCT && p.productId === v.product.id) ||
|
||||
(p.scope === PromotionScope.CATEGORY && !!v.product.categoryId && p.categoryId === v.product.categoryId) ||
|
||||
(p.scope === PromotionScope.BRAND && !!v.product.brandId && p.brandId === v.product.brandId),
|
||||
);
|
||||
const inputs = {
|
||||
variantPercent: v.defaultWagePercent,
|
||||
customerPercent: customer.makingChargeOffsetPercent,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { agentSalesWhere, customerSalesWhere } from './agent-orders';
|
||||
|
||||
/**
|
||||
* A cancelled order is a removed one (the reference deleted it, and with it its
|
||||
* debt and its share of «میزان فروش»); both sales sums must leave it out while
|
||||
* still counting every live status.
|
||||
*/
|
||||
describe('sales sums leave cancelled orders out', () => {
|
||||
it("an expert's sales: both arms, and never a cancelled order", () => {
|
||||
expect(agentSalesWhere('a1')).toEqual({
|
||||
AND: [
|
||||
{ OR: [{ salesAgentId: 'a1' }, { customer: { salesExpertTradingFor: { adminUserId: 'a1' } } }] },
|
||||
{ status: { not: 'CANCELLED' } },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("a customer's sales: their own orders and the ones that named them, never a cancelled order", () => {
|
||||
expect(customerSalesWhere('c1')).toEqual({
|
||||
AND: [{ OR: [{ customerId: 'c1' }, { referrerCustomerId: 'c1' }] }, { status: { not: 'CANCELLED' } }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,22 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { OrderStatus, type Prisma } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* A cancelled order is a removed order. The reference had no cancelled state:
|
||||
* removing an order (`Order::safeDelete`) deleted it, and with it its debt and
|
||||
* its share of «میزان فروش» (updateGramSell summed what was left). The target
|
||||
* keeps the row and marks it CANCELLED, so every sales sum has to leave it out
|
||||
* or a cancelled order would keep counting as sold.
|
||||
*/
|
||||
const NOT_CANCELLED: Prisma.OrderWhereInput = { status: { not: OrderStatus.CANCELLED } };
|
||||
|
||||
/**
|
||||
* The orders that count as a sales expert's sales («میزان فروش»).
|
||||
*
|
||||
* Legacy `CustomerController::updateGramSell`:
|
||||
* SUM(orders.input) WHERE customer_id = X OR reagent_id = X
|
||||
* (soft-deleted orders excluded by the model, no status filter).
|
||||
* (soft-deleted orders excluded by the model, no status filter — the only
|
||||
* statuses that existed were the live ones; cancelled ones are left out here
|
||||
* for the reason above).
|
||||
*
|
||||
* - `reagent_id = X` is Order.salesAgentId: the expert recorded on the order
|
||||
* when it was placed — NOT the customer's current referrer, so a customer
|
||||
@@ -15,7 +26,10 @@ import type { Prisma } from '@prisma/client';
|
||||
*/
|
||||
export function agentSalesWhere(agentId: string): Prisma.OrderWhereInput {
|
||||
return {
|
||||
OR: [{ salesAgentId: agentId }, { customer: { salesExpertTradingFor: { adminUserId: agentId } } }],
|
||||
AND: [
|
||||
{ OR: [{ salesAgentId: agentId }, { customer: { salesExpertTradingFor: { adminUserId: agentId } } }] },
|
||||
NOT_CANCELLED,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,5 +39,5 @@ export function agentSalesWhere(agentId: string): Prisma.OrderWhereInput {
|
||||
* order that named them as its referrer (Order.referrerCustomerId).
|
||||
*/
|
||||
export function customerSalesWhere(customerId: string): Prisma.OrderWhereInput {
|
||||
return { OR: [{ customerId }, { referrerCustomerId: customerId }] };
|
||||
return { AND: [{ OR: [{ customerId }, { referrerCustomerId: customerId }] }, NOT_CANCELLED] };
|
||||
}
|
||||
|
||||
@@ -52,7 +52,8 @@ export class SalesAgentService {
|
||||
* Two deliberate fidelity notes:
|
||||
* - The `OR customer_id = X` arm is the expert's trading account (D21): an
|
||||
* AdminUser cannot own an Order, so their own orders sit on that Customer.
|
||||
* - No status filter, matching legacy. The admin panel disagrees with itself
|
||||
* - No status filter, matching legacy, except that a cancelled order is a
|
||||
* removed one (legacy deleted it) and is left out. The admin panel disagrees with itself
|
||||
* here — `AdminCustomersService.listReagents` aggregates unfiltered while
|
||||
* `reagentDetail` filters on the SOLD statuses — so this matches
|
||||
* listReagents (the grid figure) and legacy. If those three are ever
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, HttpCode, Post, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Get, HttpCode, Param, Post, Put, Query } from '@nestjs/common';
|
||||
import { SalesExpertAppService } from './sales-expert-app.service';
|
||||
import { CurrentCustomer, CustomerAuth } from '../customer-auth/customer.guards';
|
||||
import type { AuthenticatedCustomer } from '../customer-auth/app-principal';
|
||||
@@ -7,6 +7,9 @@ import type { AuthenticatedCustomer } from '../customer-auth/app-principal';
|
||||
* The sales expert's own screens in the customer apps.
|
||||
*
|
||||
* POST expert/child-orders -> legacy POST /order/child (ApiMobileController:759)
|
||||
* PUT expert/child-orders/:id -> legacy PUT /order/child/{id} (updateChildOrder)
|
||||
* POST expert/customers/:id/toggle-active -> legacy POST customer/toggle-active
|
||||
* PUT expert/customers/:id -> legacy POST customer/update
|
||||
* POST expert/my-customers -> legacy POST /my-child (ApiMobileController:184)
|
||||
* GET expert/me -> the expert's profile strip
|
||||
*
|
||||
@@ -32,6 +35,38 @@ export class SalesExpertAppController {
|
||||
return this.expert.childOrders(me.adminUserId, body ?? {});
|
||||
}
|
||||
|
||||
/** Legacy PUT /order/child/{id} — correct the weights, or submit as final. */
|
||||
@CustomerAuth()
|
||||
@Put('child-orders/:orderId')
|
||||
@HttpCode(200)
|
||||
adjustChildOrder(
|
||||
@Param('orderId') orderId: string,
|
||||
@Body() body: { items?: { id?: unknown; rash_weight?: unknown }[]; final_submit?: unknown },
|
||||
@CurrentCustomer() me: AuthenticatedCustomer,
|
||||
) {
|
||||
return this.expert.adjustChildOrder(me.adminUserId, orderId, body ?? {});
|
||||
}
|
||||
|
||||
/** Legacy POST customer/toggle-active — switch one of the expert's customers off or on. */
|
||||
@CustomerAuth()
|
||||
@Post('customers/:customerId/toggle-active')
|
||||
@HttpCode(200)
|
||||
toggleCustomerActive(@Param('customerId') customerId: string, @CurrentCustomer() me: AuthenticatedCustomer) {
|
||||
return this.expert.toggleCustomerActive(me.adminUserId, customerId);
|
||||
}
|
||||
|
||||
/** Legacy POST customer/update — correct the contact details of one of the expert's customers. */
|
||||
@CustomerAuth()
|
||||
@Put('customers/:customerId')
|
||||
@HttpCode(200)
|
||||
updateCustomer(
|
||||
@Param('customerId') customerId: string,
|
||||
@Body() body: Record<string, unknown>,
|
||||
@CurrentCustomer() me: AuthenticatedCustomer,
|
||||
) {
|
||||
return this.expert.updateCustomer(me.adminUserId, customerId, body ?? {});
|
||||
}
|
||||
|
||||
/** Legacy POST /my-child. */
|
||||
@CustomerAuth()
|
||||
@Post('my-customers')
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { AdminRole } from '@prisma/client';
|
||||
import { SalesExpertAppService } from './sales-expert-app.service';
|
||||
|
||||
/**
|
||||
* «مشتریان من» in the app — switching a customer on/off and editing their
|
||||
* contact details (reference customer/toggle-active and customer/update).
|
||||
*/
|
||||
describe('SalesExpertAppService — my customers: toggle and edit', () => {
|
||||
const EXPERT = 'expert1';
|
||||
const OWN = { id: 'c1', fullName: 'علی رضایی', firstName: 'علی', lastName: 'رضایی', mobile: '09121111111', isActive: true, referredByAgentId: EXPERT };
|
||||
|
||||
function build(opts: { customer?: any | null; expertHoldsMobile?: boolean; otherCustomerHoldsMobile?: boolean } = {}) {
|
||||
const customer = opts.customer === undefined ? { ...OWN } : opts.customer;
|
||||
const updates: any[] = [];
|
||||
const prisma: any = {
|
||||
customer: {
|
||||
// Ownership is part of the query: another expert's customer is simply not found.
|
||||
findFirst: jest.fn(async ({ where }: any) => {
|
||||
if (where.referredByAgentId !== undefined) {
|
||||
return customer && where.id === customer.id && where.referredByAgentId === customer.referredByAgentId ? customer : null;
|
||||
}
|
||||
return opts.otherCustomerHoldsMobile ? { id: 'someone-else' } : null; // the mobile lookup
|
||||
}),
|
||||
update: jest.fn(async ({ data }: any) => {
|
||||
updates.push(data);
|
||||
return { ...customer, ...Object.fromEntries(Object.entries(data).filter(([, v]) => typeof v !== 'object' || v === null)) };
|
||||
}),
|
||||
},
|
||||
adminUser: { findFirst: jest.fn(async () => (opts.expertHoldsMobile ? { id: 'ex' } : null)) },
|
||||
};
|
||||
prisma.$transaction = jest.fn(async (fn: any) => fn(prisma));
|
||||
const sms = { send: jest.fn(async () => ({ ok: true })) };
|
||||
const accounting = { customerEdited: jest.fn(async () => undefined) };
|
||||
const orders = {};
|
||||
const service = new SalesExpertAppService(prisma, orders as never, sms as never, accounting as never);
|
||||
return { service, prisma, sms, accounting, updates };
|
||||
}
|
||||
|
||||
describe('toggle active', () => {
|
||||
it("switches an own customer off, and says so without texting them", async () => {
|
||||
const t = build();
|
||||
const res = await t.service.toggleCustomerActive(EXPERT, 'c1');
|
||||
expect(res).toEqual({ id: 'c1', active: -1 });
|
||||
expect(t.updates[0]).toEqual({ isActive: false });
|
||||
expect(t.sms.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('switches an inactive one back on and texts them, as the reference did', async () => {
|
||||
const t = build({ customer: { ...OWN, isActive: false } });
|
||||
const res = await t.service.toggleCustomerActive(EXPERT, 'c1');
|
||||
expect(res).toEqual({ id: 'c1', active: 1 });
|
||||
expect(t.sms.send).toHaveBeenCalledWith('09121111111', expect.stringContaining('فعال شد'));
|
||||
});
|
||||
|
||||
it("another expert's customer is not found — and nothing is changed", async () => {
|
||||
const t = build();
|
||||
await expect(t.service.toggleCustomerActive('expert2', 'c1')).rejects.toThrow(NotFoundException);
|
||||
expect(t.updates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refuses anyone who is not a sales expert', async () => {
|
||||
const t = build();
|
||||
await expect(t.service.toggleCustomerActive(undefined, 'c1')).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit', () => {
|
||||
it('saves the contact details the form carries', async () => {
|
||||
const t = build();
|
||||
await t.service.updateCustomer(EXPERT, 'c1', {
|
||||
fname: 'محمد',
|
||||
lname: 'کریمی',
|
||||
phone: '02122223333',
|
||||
address: 'بازار، پاساژ طلا',
|
||||
store_name: 'گالری کریمی',
|
||||
pasazh_name: 'پاساژ طلا',
|
||||
zone: '12',
|
||||
national_code: '۰۰۱۲۳۴۵۶۷۸',
|
||||
birth_date: '1990-05-20',
|
||||
city_id: 'city1',
|
||||
});
|
||||
const d = t.updates[0];
|
||||
expect(d).toMatchObject({ firstName: 'محمد', lastName: 'کریمی', phone: '02122223333', storeName: 'گالری کریمی', pasazhName: 'پاساژ طلا', zone: '12', nationalCode: '0012345678' });
|
||||
expect(d.city).toEqual({ connect: { id: 'city1' } });
|
||||
expect(d.birthDate).toEqual(new Date(Date.UTC(1990, 4, 20)));
|
||||
});
|
||||
|
||||
it('can NEVER change the percent, credit, kind, expert or the account switches — whatever the request carries', async () => {
|
||||
const t = build();
|
||||
await t.service.updateCustomer(EXPERT, 'c1', {
|
||||
fname: 'علی',
|
||||
lname: 'رضایی',
|
||||
percent: 0,
|
||||
makingChargeOffsetPercent: 0,
|
||||
gram_level: 99999,
|
||||
goldCreditLimitGrams: 99999,
|
||||
type: 2,
|
||||
kind: 'SALES_AGENT',
|
||||
reagent_id: 'expert2',
|
||||
referredByAgentId: 'expert2',
|
||||
isActive: false,
|
||||
active: -1,
|
||||
canPlaceOrders: false,
|
||||
sts: -1,
|
||||
mcode: 'X1',
|
||||
password: 'hacked',
|
||||
goldDebtGrams: 0,
|
||||
fiatDebtRial: 0,
|
||||
});
|
||||
expect(Object.keys(t.updates[0]).sort()).toEqual(['firstName', 'lastName']);
|
||||
});
|
||||
|
||||
it("another expert's customer is not found", async () => {
|
||||
const t = build();
|
||||
await expect(t.service.updateCustomer('expert2', 'c1', { fname: 'a', lname: 'b' })).rejects.toThrow(NotFoundException);
|
||||
expect(t.updates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('needs both names', async () => {
|
||||
const t = build({ customer: { ...OWN, firstName: null, lastName: null } });
|
||||
await expect(t.service.updateCustomer(EXPERT, 'c1', { fname: 'علی' })).rejects.toThrow('نام و نام خانوادگی');
|
||||
await expect(t.service.updateCustomer(EXPERT, 'c1', { fname: ' ', lname: 'x' })).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('a changed mobile must be a mobile, not held by an active expert, not held by another customer', async () => {
|
||||
await expect(build().service.updateCustomer(EXPERT, 'c1', { fname: 'a', lname: 'b', mobile: '12345' })).rejects.toThrow('معتبر');
|
||||
await expect(build({ expertHoldsMobile: true }).service.updateCustomer(EXPERT, 'c1', { fname: 'a', lname: 'b', mobile: '09125556666' })).rejects.toThrow('کارشناس فروش');
|
||||
await expect(build({ otherCustomerHoldsMobile: true }).service.updateCustomer(EXPERT, 'c1', { fname: 'a', lname: 'b', mobile: '09125556666' })).rejects.toThrow('مشتری دیگری');
|
||||
});
|
||||
|
||||
it('saves a new mobile written in Persian digits as ASCII, and leaves an unchanged one alone', async () => {
|
||||
const a = build();
|
||||
await a.service.updateCustomer(EXPERT, 'c1', { fname: 'a', lname: 'b', mobile: '۰۹۱۲۵۵۵۶۶۶۶' });
|
||||
expect(a.updates[0].mobile).toBe('09125556666');
|
||||
const b = build();
|
||||
await b.service.updateCustomer(EXPERT, 'c1', { fname: 'a', lname: 'b', mobile: '09121111111' });
|
||||
expect('mobile' in b.updates[0]).toBe(false);
|
||||
});
|
||||
|
||||
it('tells the accounting account only when a field it carries changed', async () => {
|
||||
const a = build();
|
||||
await a.service.updateCustomer(EXPERT, 'c1', { fname: 'a', lname: 'b', phone: '021' });
|
||||
expect(a.accounting.customerEdited).toHaveBeenCalledWith(expect.anything(), 'c1');
|
||||
const b = build();
|
||||
await b.service.updateCustomer(EXPERT, 'c1', { store_name: 'x' });
|
||||
expect(b.accounting.customerEdited).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a name or text field that is an object or a list is refused, not stored as [object Object]', async () => {
|
||||
const { service, updates } = build();
|
||||
await expect(service.updateCustomer(EXPERT, 'c1', { fname: { a: 1 }, lname: 'x' })).rejects.toThrow(BadRequestException);
|
||||
await expect(service.updateCustomer(EXPERT, 'c1', { fname: 'a', lname: ['x'] })).rejects.toThrow(BadRequestException);
|
||||
await expect(service.updateCustomer(EXPERT, 'c1', { fname: 'a', lname: 'b', address: { x: 1 } })).rejects.toThrow(BadRequestException);
|
||||
expect(updates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refuses an impossible birth date', async () => {
|
||||
await expect(build().service.updateCustomer(EXPERT, 'c1', { fname: 'a', lname: 'b', birth_date: '2999-01-01' })).rejects.toThrow('تاریخ تولد');
|
||||
});
|
||||
});
|
||||
|
||||
it('the expert-role helper exists for the only role that can use these routes', () => {
|
||||
expect(AdminRole.SALES_EXPERT).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* «ثبت نهایی» and the weight entry: the expert cannot touch the percent. The
|
||||
* route reads only `id` and `rash_weight` from each line, so a percent sent
|
||||
* along is not even passed on.
|
||||
*/
|
||||
describe('SalesExpertAppService.adjustChildOrder — no percent', () => {
|
||||
it('passes on only the line ids and raw weights', async () => {
|
||||
const orders = { expertAdjust: jest.fn(async () => ({ id: '100', status: 'DELIVERED_TO_AGENT' })) };
|
||||
const service = new SalesExpertAppService({} as never, orders as never, {} as never, {} as never);
|
||||
await service.adjustChildOrder('expert1', 'o1', {
|
||||
items: [{ id: 'i1', rash_weight: 5.9, percent: 0, percent_pay: 0, discount: 50, wage: 0 } as never],
|
||||
final_submit: 'true',
|
||||
});
|
||||
expect(orders.expertAdjust).toHaveBeenCalledWith('o1', 'expert1', [{ id: 'i1', rashWeightGrams: 5.9 }], true);
|
||||
});
|
||||
|
||||
it('refuses a customer token', async () => {
|
||||
const service = new SalesExpertAppService({} as never, {} as never, {} as never, {} as never);
|
||||
await expect(service.adjustChildOrder(undefined, 'o1', { items: [] })).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('items that are not a list are a 400 (it used to be a TypeError, a 500)', async () => {
|
||||
const orders = { expertAdjust: jest.fn() };
|
||||
const service = new SalesExpertAppService({} as never, orders as never, {} as never, {} as never);
|
||||
for (const items of ['x', 5, { a: 1 }, true]) {
|
||||
await expect(service.adjustChildOrder('expert1', 'o1', { items } as never)).rejects.toThrow(BadRequestException);
|
||||
}
|
||||
expect(orders.expertAdjust).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { OrdersModule } from '../orders/orders.module';
|
||||
import { SalesExpertAppController } from './sales-expert-app.controller';
|
||||
import { SalesExpertAppService } from './sales-expert-app.service';
|
||||
|
||||
/** The sales expert's own screens in the customer apps (D21). */
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
imports: [PrismaModule, OrdersModule],
|
||||
controllers: [SalesExpertAppController],
|
||||
providers: [SalesExpertAppService],
|
||||
exports: [SalesExpertAppService],
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { OrderStatus, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { OrdersService } from '../orders/orders.service';
|
||||
import { SmsService } from '../sms/sms.service';
|
||||
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
|
||||
import { faToEn } from '../customer-auth/digits';
|
||||
import { brandName, brandSiteUrl } from '../../common/brand';
|
||||
import { birthDateOrNull, nationalCodeOrNull } from '../../common/customer-input';
|
||||
import { activeExpertHoldsMobile, EXPERT_HOLDS_MOBILE } from '../../common/mobile-owner';
|
||||
|
||||
/**
|
||||
* The two screens a sales expert has in the app beyond what every customer
|
||||
@@ -16,7 +23,161 @@ import { PrismaService } from '../../prisma/prisma.service';
|
||||
*/
|
||||
@Injectable()
|
||||
export class SalesExpertAppService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
private readonly logger = new Logger(SalesExpertAppService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly orders: OrdersService,
|
||||
private readonly sms: SmsService,
|
||||
private readonly accounting: AccountingHooksService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The customer, but only if it is one of THIS expert's. Anyone else's is "not
|
||||
* found" — never "forbidden" — so ids cannot be probed. (The reference's
|
||||
* `customer/toggle-active` checked nothing at all: any signed-in customer
|
||||
* could switch any account off; its `customer/update` did check ownership.)
|
||||
*/
|
||||
private async ownCustomer(agentId: string, customerId: string) {
|
||||
const c = await this.prisma.customer.findFirst({
|
||||
where: { id: customerId, referredByAgentId: agentId },
|
||||
});
|
||||
if (!c) throw new NotFoundException('مشتری یافت نشد');
|
||||
return c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Port of `POST customer/toggle-active` (AdminController::customerToggleActive)
|
||||
* for «مشتریان من»: the expert switches one of their customers' accounts off
|
||||
* or on. Off, the customer's token stops working on the next request (the
|
||||
* customer guard reads `isActive` every time); on, the customer is told by SMS,
|
||||
* as the reference told them.
|
||||
*/
|
||||
async toggleCustomerActive(adminUserId: string | undefined, customerId: string) {
|
||||
const agentId = this.assertExpert(adminUserId);
|
||||
const c = await this.ownCustomer(agentId, customerId);
|
||||
const updated = await this.prisma.customer.update({
|
||||
where: { id: c.id },
|
||||
data: { isActive: !c.isActive },
|
||||
});
|
||||
if (updated.isActive) {
|
||||
const text =
|
||||
`${updated.fullName} عزیز\n` +
|
||||
'حساب کاربری شما در اپلیکیشن فعال شد.\n' +
|
||||
'هماکنون میتوانید محصولات جدید را بررسی و ثبت سفارش کنید.\n' +
|
||||
'با مراجعه به لینک زیر میتوانید وارد پروفایل کاربری خود شوید.\n' +
|
||||
`${brandSiteUrl()}\n${brandName()}`;
|
||||
void this.sms.send(updated.mobile, text).catch((e: unknown) => {
|
||||
this.logger.error(`Activation SMS to ${updated.mobile} failed: ${String(e)}`);
|
||||
});
|
||||
}
|
||||
return { id: updated.id, active: updated.isActive ? 1 : -1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Port of `POST customer/update` (AdminController::updateCustomer) for
|
||||
* «ویرایش مشتری»: the expert corrects the contact details of one of their own
|
||||
* customers.
|
||||
*
|
||||
* Only contact details can be changed here. The customer's making-charge
|
||||
* percent, credit limit, kind, expert, account switches and codes are money
|
||||
* and administration — the admin panel's, not the app's — so they are not
|
||||
* read from the request at all, whatever it carries. (The reference form
|
||||
* could also change the customer's `type`; that is not carried over.)
|
||||
*/
|
||||
async updateCustomer(adminUserId: string | undefined, customerId: string, body: Record<string, unknown>) {
|
||||
const agentId = this.assertExpert(adminUserId);
|
||||
const existing = await this.ownCustomer(agentId, customerId);
|
||||
|
||||
const patch: Prisma.CustomerUpdateInput = {};
|
||||
|
||||
// A value that is not text (an object, a list) would be stored as "[object Object]".
|
||||
const text = (key: string): unknown => {
|
||||
const v = body[key];
|
||||
if (v !== undefined && v !== null && typeof v !== 'string' && typeof v !== 'number') {
|
||||
throw new BadRequestException('مقدار وارد شده معتبر نیست');
|
||||
}
|
||||
return v;
|
||||
};
|
||||
|
||||
// The reference required both names.
|
||||
const firstName = String(text('fname') ?? existing.firstName ?? '').trim();
|
||||
const lastName = String(text('lname') ?? existing.lastName ?? '').trim();
|
||||
if (!firstName || !lastName) throw new BadRequestException('نام و نام خانوادگی الزامی است');
|
||||
patch.firstName = firstName;
|
||||
patch.lastName = lastName;
|
||||
|
||||
if (body.mobile !== undefined && body.mobile !== null && String(body.mobile).trim() !== '') {
|
||||
const mobile = faToEn(String(body.mobile).trim());
|
||||
if (!/^09\d{9}$/.test(mobile)) throw new BadRequestException('شماره موبایل معتبر نیست');
|
||||
if (mobile !== existing.mobile) {
|
||||
// One mobile, one person: another customer (the unique index) or an active
|
||||
// expert (they sign in with it too) may already hold it.
|
||||
if (await activeExpertHoldsMobile(this.prisma, mobile)) throw new BadRequestException(EXPERT_HOLDS_MOBILE);
|
||||
if (await this.prisma.customer.findFirst({ where: { mobile, NOT: { id: existing.id } }, select: { id: true } })) {
|
||||
throw new BadRequestException('این شماره موبایل قبلاً برای مشتری دیگری ثبت شده است');
|
||||
}
|
||||
patch.mobile = mobile;
|
||||
}
|
||||
}
|
||||
|
||||
const optional = (key: string, max: number) => {
|
||||
if (body[key] === undefined) return undefined;
|
||||
const s = String(text(key) ?? '').trim();
|
||||
if (s.length > max) throw new BadRequestException('طول مقدار وارد شده بیش از حد مجاز است');
|
||||
return s || null;
|
||||
};
|
||||
const phone = optional('phone', 30);
|
||||
if (phone !== undefined) patch.phone = phone;
|
||||
const address = optional('address', 500);
|
||||
if (address !== undefined) patch.address = address;
|
||||
const storeName = optional('store_name', 150);
|
||||
if (storeName !== undefined) patch.storeName = storeName;
|
||||
const pasazhName = optional('pasazh_name', 150);
|
||||
if (pasazhName !== undefined) patch.pasazhName = pasazhName;
|
||||
const zone = optional('zone', 20);
|
||||
if (zone !== undefined) patch.zone = zone;
|
||||
|
||||
if (body.city_id !== undefined) {
|
||||
patch.city = body.city_id ? { connect: { id: String(body.city_id) } } : { disconnect: true };
|
||||
}
|
||||
if (body.national_code !== undefined) patch.nationalCode = nationalCodeOrNull(body.national_code);
|
||||
const birthDate = birthDateOrNull(body.birth_date);
|
||||
if (birthDate !== undefined) patch.birthDate = birthDate;
|
||||
|
||||
// D27 — the accounting account carries name, mobile, national code, phone, address.
|
||||
const contactChanged = ['fname', 'lname', 'mobile', 'national_code', 'phone', 'address'].some((k) => body[k] !== undefined);
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
const row = await tx.customer.update({ where: { id: existing.id }, data: patch });
|
||||
if (contactChanged) await this.accounting.customerEdited(tx, existing.id);
|
||||
return row;
|
||||
});
|
||||
return { id: updated.id, name: updated.fullName, mobile: updated.mobile };
|
||||
}
|
||||
|
||||
/**
|
||||
* Port of `updateChildOrder` (ApiMobileController.php, `PUT order/child/{id}`):
|
||||
* the expert enters the real raw weight of each piece of an order that is in
|
||||
* their hands, and may submit it as final, which hands it to the customer.
|
||||
* Scope is the expert's own orders only; the rules and the totals, debt and
|
||||
* accounting that follow are OrdersService.expertAdjust.
|
||||
*/
|
||||
async adjustChildOrder(
|
||||
adminUserId: string | undefined,
|
||||
orderId: string,
|
||||
body: { items?: { id?: unknown; rash_weight?: unknown }[]; final_submit?: unknown },
|
||||
) {
|
||||
const agentId = this.assertExpert(adminUserId);
|
||||
const finalSubmit = body?.final_submit === true || body?.final_submit === 'true';
|
||||
const items = body?.items ?? [];
|
||||
if (!Array.isArray(items)) throw new BadRequestException('items باید فهرست ردیفها باشد');
|
||||
return this.orders.expertAdjust(
|
||||
orderId,
|
||||
agentId,
|
||||
items.map((i) => ({ id: i?.id, rashWeightGrams: i?.rash_weight })),
|
||||
finalSubmit,
|
||||
);
|
||||
}
|
||||
|
||||
/** Refuses any principal that is not a sales expert. */
|
||||
private assertExpert(adminUserId: string | undefined): string {
|
||||
@@ -80,7 +241,15 @@ export class SalesExpertAppService {
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
include: {
|
||||
items: true,
|
||||
// The product and variant titles label each line where the expert
|
||||
// enters the real weights.
|
||||
items: {
|
||||
include: {
|
||||
productVariant: {
|
||||
select: { id: true, title: true, product: { select: { id: true, title: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
customer: { select: { id: true, fullName: true, mobile: true } },
|
||||
},
|
||||
}),
|
||||
@@ -150,8 +319,15 @@ export class SalesExpertAppService {
|
||||
select: {
|
||||
id: true,
|
||||
fullName: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
mobile: true,
|
||||
phone: true,
|
||||
address: true,
|
||||
zone: true,
|
||||
cityId: true,
|
||||
nationalCode: true,
|
||||
birthDate: true,
|
||||
storeName: true,
|
||||
pasazhName: true,
|
||||
goldCreditLimitGrams: true,
|
||||
@@ -167,8 +343,16 @@ export class SalesExpertAppService {
|
||||
// legacy key, which the ported card binds to
|
||||
name: c.fullName,
|
||||
fullName: c.fullName,
|
||||
// What «ویرایش مشتری» opens with.
|
||||
fname: c.firstName ?? c.fullName,
|
||||
lname: c.lastName ?? '',
|
||||
mobile: c.mobile,
|
||||
phone: c.phone,
|
||||
address: c.address,
|
||||
zone: c.zone,
|
||||
city_id: c.cityId,
|
||||
national_code: c.nationalCode,
|
||||
birth_date: c.birthDate ? c.birthDate.toISOString().slice(0, 10) : null,
|
||||
store_name: c.storeName,
|
||||
pasazh_name: c.pasazhName,
|
||||
gram_level: c.goldCreditLimitGrams,
|
||||
|
||||
Reference in New Issue
Block a user