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

- Rial wage and gram-debt arithmetic matched to the reference; order corrections keep debt in sync
- Document confirm/un-confirm is a compare-and-swap (no double-applied cache under concurrency)
- Settlement document and expert inputs validated (400 instead of 500)
- Expert: edit / activate-deactivate own customers, adjust raw weights only
- Made-to-order flow in the app, PreOrder.quantity migration
- Order grids: customer column, sort keys; panel select search; pre-order quantity save
- Rebuilt release artifacts

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mahmood
2026-10-29 18:09:02 +03:30
co-authored by Claude Sonnet 5.5
parent 959a272227
commit 6514b05d18
321 changed files with 6984 additions and 2170 deletions
@@ -0,0 +1,3 @@
-- Pieces asked for on a made-to-order request (legacy custom_orders.sum). Existing
-- rows were all treated as one piece by the admin grid, so they keep 1.
ALTER TABLE "PreOrder" ADD COLUMN "quantity" INTEGER NOT NULL DEFAULT 1;
+3
View File
@@ -901,6 +901,9 @@ model PreOrder {
sizeSpec String?
stoneColor String?
minaColor String?
/// Legacy custom_orders.sum — how many pieces the customer asked for. The first
/// schema had no field for it and the admin grid showed a hard-coded 1.
quantity Int @default(1)
estimatedWeightGrams Decimal? @db.Decimal(14, 4)
estimatedPriceRial Decimal? @db.Decimal(20, 2)
description String?
+2604 -1738
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,3 @@
-- Pieces asked for on a made-to-order request (legacy custom_orders.sum). Existing
-- rows were all treated as one piece by the admin grid, so they keep 1.
ALTER TABLE "PreOrder" ADD COLUMN "quantity" INTEGER NOT NULL DEFAULT 1;
File diff suppressed because one or more lines are too long
@@ -399,6 +399,7 @@ exports.Prisma.PreOrderScalarFieldEnum = {
sizeSpec: 'sizeSpec',
stoneColor: 'stoneColor',
minaColor: 'minaColor',
quantity: 'quantity',
estimatedWeightGrams: 'estimatedWeightGrams',
estimatedPriceRial: 'estimatedPriceRial',
description: 'description',
+65
View File
@@ -29083,11 +29083,13 @@ export namespace Prisma {
}
export type PreOrderAvgAggregateOutputType = {
quantity: number | null
estimatedWeightGrams: Decimal | null
estimatedPriceRial: Decimal | null
}
export type PreOrderSumAggregateOutputType = {
quantity: number | null
estimatedWeightGrams: Decimal | null
estimatedPriceRial: Decimal | null
}
@@ -29103,6 +29105,7 @@ export namespace Prisma {
sizeSpec: string | null
stoneColor: string | null
minaColor: string | null
quantity: number | null
estimatedWeightGrams: Decimal | null
estimatedPriceRial: Decimal | null
description: string | null
@@ -29122,6 +29125,7 @@ export namespace Prisma {
sizeSpec: string | null
stoneColor: string | null
minaColor: string | null
quantity: number | null
estimatedWeightGrams: Decimal | null
estimatedPriceRial: Decimal | null
description: string | null
@@ -29141,6 +29145,7 @@ export namespace Prisma {
sizeSpec: number
stoneColor: number
minaColor: number
quantity: number
estimatedWeightGrams: number
estimatedPriceRial: number
description: number
@@ -29152,11 +29157,13 @@ export namespace Prisma {
export type PreOrderAvgAggregateInputType = {
quantity?: true
estimatedWeightGrams?: true
estimatedPriceRial?: true
}
export type PreOrderSumAggregateInputType = {
quantity?: true
estimatedWeightGrams?: true
estimatedPriceRial?: true
}
@@ -29172,6 +29179,7 @@ export namespace Prisma {
sizeSpec?: true
stoneColor?: true
minaColor?: true
quantity?: true
estimatedWeightGrams?: true
estimatedPriceRial?: true
description?: true
@@ -29191,6 +29199,7 @@ export namespace Prisma {
sizeSpec?: true
stoneColor?: true
minaColor?: true
quantity?: true
estimatedWeightGrams?: true
estimatedPriceRial?: true
description?: true
@@ -29210,6 +29219,7 @@ export namespace Prisma {
sizeSpec?: true
stoneColor?: true
minaColor?: true
quantity?: true
estimatedWeightGrams?: true
estimatedPriceRial?: true
description?: true
@@ -29316,6 +29326,7 @@ export namespace Prisma {
sizeSpec: string | null
stoneColor: string | null
minaColor: string | null
quantity: number
estimatedWeightGrams: Decimal | null
estimatedPriceRial: Decimal | null
description: string | null
@@ -29354,6 +29365,7 @@ export namespace Prisma {
sizeSpec?: boolean
stoneColor?: boolean
minaColor?: boolean
quantity?: boolean
estimatedWeightGrams?: boolean
estimatedPriceRial?: boolean
description?: boolean
@@ -29379,6 +29391,7 @@ export namespace Prisma {
sizeSpec?: boolean
stoneColor?: boolean
minaColor?: boolean
quantity?: boolean
estimatedWeightGrams?: boolean
estimatedPriceRial?: boolean
description?: boolean
@@ -29402,6 +29415,7 @@ export namespace Prisma {
sizeSpec?: boolean
stoneColor?: boolean
minaColor?: boolean
quantity?: boolean
estimatedWeightGrams?: boolean
estimatedPriceRial?: boolean
description?: boolean
@@ -29445,6 +29459,11 @@ export namespace Prisma {
sizeSpec: string | null
stoneColor: string | null
minaColor: string | null
/**
* Legacy custom_orders.sum — how many pieces the customer asked for. The first
* schema had no field for it and the admin grid showed a hard-coded 1.
*/
quantity: number
estimatedWeightGrams: Prisma.Decimal | null
estimatedPriceRial: Prisma.Decimal | null
description: string | null
@@ -29862,6 +29881,7 @@ export namespace Prisma {
readonly sizeSpec: FieldRef<"PreOrder", 'String'>
readonly stoneColor: FieldRef<"PreOrder", 'String'>
readonly minaColor: FieldRef<"PreOrder", 'String'>
readonly quantity: FieldRef<"PreOrder", 'Int'>
readonly estimatedWeightGrams: FieldRef<"PreOrder", 'Decimal'>
readonly estimatedPriceRial: FieldRef<"PreOrder", 'Decimal'>
readonly description: FieldRef<"PreOrder", 'String'>
@@ -52380,6 +52400,7 @@ export namespace Prisma {
sizeSpec: 'sizeSpec',
stoneColor: 'stoneColor',
minaColor: 'minaColor',
quantity: 'quantity',
estimatedWeightGrams: 'estimatedWeightGrams',
estimatedPriceRial: 'estimatedPriceRial',
description: 'description',
@@ -54957,6 +54978,7 @@ export namespace Prisma {
sizeSpec?: StringNullableFilter<"PreOrder"> | string | null
stoneColor?: StringNullableFilter<"PreOrder"> | string | null
minaColor?: StringNullableFilter<"PreOrder"> | string | null
quantity?: IntFilter<"PreOrder"> | number
estimatedWeightGrams?: DecimalNullableFilter<"PreOrder"> | Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: DecimalNullableFilter<"PreOrder"> | Decimal | DecimalJsLike | number | string | null
description?: StringNullableFilter<"PreOrder"> | string | null
@@ -54981,6 +55003,7 @@ export namespace Prisma {
sizeSpec?: SortOrderInput | SortOrder
stoneColor?: SortOrderInput | SortOrder
minaColor?: SortOrderInput | SortOrder
quantity?: SortOrder
estimatedWeightGrams?: SortOrderInput | SortOrder
estimatedPriceRial?: SortOrderInput | SortOrder
description?: SortOrderInput | SortOrder
@@ -55009,6 +55032,7 @@ export namespace Prisma {
sizeSpec?: StringNullableFilter<"PreOrder"> | string | null
stoneColor?: StringNullableFilter<"PreOrder"> | string | null
minaColor?: StringNullableFilter<"PreOrder"> | string | null
quantity?: IntFilter<"PreOrder"> | number
estimatedWeightGrams?: DecimalNullableFilter<"PreOrder"> | Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: DecimalNullableFilter<"PreOrder"> | Decimal | DecimalJsLike | number | string | null
description?: StringNullableFilter<"PreOrder"> | string | null
@@ -55032,6 +55056,7 @@ export namespace Prisma {
sizeSpec?: SortOrderInput | SortOrder
stoneColor?: SortOrderInput | SortOrder
minaColor?: SortOrderInput | SortOrder
quantity?: SortOrder
estimatedWeightGrams?: SortOrderInput | SortOrder
estimatedPriceRial?: SortOrderInput | SortOrder
description?: SortOrderInput | SortOrder
@@ -55059,6 +55084,7 @@ export namespace Prisma {
sizeSpec?: StringNullableWithAggregatesFilter<"PreOrder"> | string | null
stoneColor?: StringNullableWithAggregatesFilter<"PreOrder"> | string | null
minaColor?: StringNullableWithAggregatesFilter<"PreOrder"> | string | null
quantity?: IntWithAggregatesFilter<"PreOrder"> | number
estimatedWeightGrams?: DecimalNullableWithAggregatesFilter<"PreOrder"> | Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: DecimalNullableWithAggregatesFilter<"PreOrder"> | Decimal | DecimalJsLike | number | string | null
description?: StringNullableWithAggregatesFilter<"PreOrder"> | string | null
@@ -58677,6 +58703,7 @@ export namespace Prisma {
sizeSpec?: string | null
stoneColor?: string | null
minaColor?: string | null
quantity?: number
estimatedWeightGrams?: Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: Decimal | DecimalJsLike | number | string | null
description?: string | null
@@ -58700,6 +58727,7 @@ export namespace Prisma {
sizeSpec?: string | null
stoneColor?: string | null
minaColor?: string | null
quantity?: number
estimatedWeightGrams?: Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: Decimal | DecimalJsLike | number | string | null
description?: string | null
@@ -58717,6 +58745,7 @@ export namespace Prisma {
sizeSpec?: NullableStringFieldUpdateOperationsInput | string | null
stoneColor?: NullableStringFieldUpdateOperationsInput | string | null
minaColor?: NullableStringFieldUpdateOperationsInput | string | null
quantity?: IntFieldUpdateOperationsInput | number
estimatedWeightGrams?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
description?: NullableStringFieldUpdateOperationsInput | string | null
@@ -58740,6 +58769,7 @@ export namespace Prisma {
sizeSpec?: NullableStringFieldUpdateOperationsInput | string | null
stoneColor?: NullableStringFieldUpdateOperationsInput | string | null
minaColor?: NullableStringFieldUpdateOperationsInput | string | null
quantity?: IntFieldUpdateOperationsInput | number
estimatedWeightGrams?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
description?: NullableStringFieldUpdateOperationsInput | string | null
@@ -58760,6 +58790,7 @@ export namespace Prisma {
sizeSpec?: string | null
stoneColor?: string | null
minaColor?: string | null
quantity?: number
estimatedWeightGrams?: Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: Decimal | DecimalJsLike | number | string | null
description?: string | null
@@ -58776,6 +58807,7 @@ export namespace Prisma {
sizeSpec?: NullableStringFieldUpdateOperationsInput | string | null
stoneColor?: NullableStringFieldUpdateOperationsInput | string | null
minaColor?: NullableStringFieldUpdateOperationsInput | string | null
quantity?: IntFieldUpdateOperationsInput | number
estimatedWeightGrams?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
description?: NullableStringFieldUpdateOperationsInput | string | null
@@ -58794,6 +58826,7 @@ export namespace Prisma {
sizeSpec?: NullableStringFieldUpdateOperationsInput | string | null
stoneColor?: NullableStringFieldUpdateOperationsInput | string | null
minaColor?: NullableStringFieldUpdateOperationsInput | string | null
quantity?: IntFieldUpdateOperationsInput | number
estimatedWeightGrams?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
description?: NullableStringFieldUpdateOperationsInput | string | null
@@ -62173,6 +62206,7 @@ export namespace Prisma {
sizeSpec?: SortOrder
stoneColor?: SortOrder
minaColor?: SortOrder
quantity?: SortOrder
estimatedWeightGrams?: SortOrder
estimatedPriceRial?: SortOrder
description?: SortOrder
@@ -62182,6 +62216,7 @@ export namespace Prisma {
}
export type PreOrderAvgOrderByAggregateInput = {
quantity?: SortOrder
estimatedWeightGrams?: SortOrder
estimatedPriceRial?: SortOrder
}
@@ -62197,6 +62232,7 @@ export namespace Prisma {
sizeSpec?: SortOrder
stoneColor?: SortOrder
minaColor?: SortOrder
quantity?: SortOrder
estimatedWeightGrams?: SortOrder
estimatedPriceRial?: SortOrder
description?: SortOrder
@@ -62216,6 +62252,7 @@ export namespace Prisma {
sizeSpec?: SortOrder
stoneColor?: SortOrder
minaColor?: SortOrder
quantity?: SortOrder
estimatedWeightGrams?: SortOrder
estimatedPriceRial?: SortOrder
description?: SortOrder
@@ -62225,6 +62262,7 @@ export namespace Prisma {
}
export type PreOrderSumOrderByAggregateInput = {
quantity?: SortOrder
estimatedWeightGrams?: SortOrder
estimatedPriceRial?: SortOrder
}
@@ -67980,6 +68018,7 @@ export namespace Prisma {
sizeSpec?: string | null
stoneColor?: string | null
minaColor?: string | null
quantity?: number
estimatedWeightGrams?: Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: Decimal | DecimalJsLike | number | string | null
description?: string | null
@@ -68001,6 +68040,7 @@ export namespace Prisma {
sizeSpec?: string | null
stoneColor?: string | null
minaColor?: string | null
quantity?: number
estimatedWeightGrams?: Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: Decimal | DecimalJsLike | number | string | null
description?: string | null
@@ -68260,6 +68300,7 @@ export namespace Prisma {
sizeSpec?: StringNullableFilter<"PreOrder"> | string | null
stoneColor?: StringNullableFilter<"PreOrder"> | string | null
minaColor?: StringNullableFilter<"PreOrder"> | string | null
quantity?: IntFilter<"PreOrder"> | number
estimatedWeightGrams?: DecimalNullableFilter<"PreOrder"> | Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: DecimalNullableFilter<"PreOrder"> | Decimal | DecimalJsLike | number | string | null
description?: StringNullableFilter<"PreOrder"> | string | null
@@ -70217,6 +70258,7 @@ export namespace Prisma {
sizeSpec?: string | null
stoneColor?: string | null
minaColor?: string | null
quantity?: number
estimatedWeightGrams?: Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: Decimal | DecimalJsLike | number | string | null
description?: string | null
@@ -70238,6 +70280,7 @@ export namespace Prisma {
sizeSpec?: string | null
stoneColor?: string | null
minaColor?: string | null
quantity?: number
estimatedWeightGrams?: Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: Decimal | DecimalJsLike | number | string | null
description?: string | null
@@ -71999,6 +72042,7 @@ export namespace Prisma {
sizeSpec?: string | null
stoneColor?: string | null
minaColor?: string | null
quantity?: number
estimatedWeightGrams?: Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: Decimal | DecimalJsLike | number | string | null
description?: string | null
@@ -72020,6 +72064,7 @@ export namespace Prisma {
sizeSpec?: string | null
stoneColor?: string | null
minaColor?: string | null
quantity?: number
estimatedWeightGrams?: Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: Decimal | DecimalJsLike | number | string | null
description?: string | null
@@ -73970,6 +74015,7 @@ export namespace Prisma {
sizeSpec?: string | null
stoneColor?: string | null
minaColor?: string | null
quantity?: number
estimatedWeightGrams?: Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: Decimal | DecimalJsLike | number | string | null
description?: string | null
@@ -73992,6 +74038,7 @@ export namespace Prisma {
sizeSpec?: string | null
stoneColor?: string | null
minaColor?: string | null
quantity?: number
estimatedWeightGrams?: Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: Decimal | DecimalJsLike | number | string | null
description?: string | null
@@ -74392,6 +74439,7 @@ export namespace Prisma {
sizeSpec?: NullableStringFieldUpdateOperationsInput | string | null
stoneColor?: NullableStringFieldUpdateOperationsInput | string | null
minaColor?: NullableStringFieldUpdateOperationsInput | string | null
quantity?: IntFieldUpdateOperationsInput | number
estimatedWeightGrams?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
description?: NullableStringFieldUpdateOperationsInput | string | null
@@ -74414,6 +74462,7 @@ export namespace Prisma {
sizeSpec?: NullableStringFieldUpdateOperationsInput | string | null
stoneColor?: NullableStringFieldUpdateOperationsInput | string | null
minaColor?: NullableStringFieldUpdateOperationsInput | string | null
quantity?: IntFieldUpdateOperationsInput | number
estimatedWeightGrams?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
description?: NullableStringFieldUpdateOperationsInput | string | null
@@ -75304,6 +75353,7 @@ export namespace Prisma {
sizeSpec?: string | null
stoneColor?: string | null
minaColor?: string | null
quantity?: number
estimatedWeightGrams?: Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: Decimal | DecimalJsLike | number | string | null
description?: string | null
@@ -75326,6 +75376,7 @@ export namespace Prisma {
sizeSpec?: string | null
stoneColor?: string | null
minaColor?: string | null
quantity?: number
estimatedWeightGrams?: Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: Decimal | DecimalJsLike | number | string | null
description?: string | null
@@ -75407,6 +75458,7 @@ export namespace Prisma {
sizeSpec?: NullableStringFieldUpdateOperationsInput | string | null
stoneColor?: NullableStringFieldUpdateOperationsInput | string | null
minaColor?: NullableStringFieldUpdateOperationsInput | string | null
quantity?: IntFieldUpdateOperationsInput | number
estimatedWeightGrams?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
description?: NullableStringFieldUpdateOperationsInput | string | null
@@ -75429,6 +75481,7 @@ export namespace Prisma {
sizeSpec?: NullableStringFieldUpdateOperationsInput | string | null
stoneColor?: NullableStringFieldUpdateOperationsInput | string | null
minaColor?: NullableStringFieldUpdateOperationsInput | string | null
quantity?: IntFieldUpdateOperationsInput | number
estimatedWeightGrams?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
description?: NullableStringFieldUpdateOperationsInput | string | null
@@ -78941,6 +78994,7 @@ export namespace Prisma {
sizeSpec?: string | null
stoneColor?: string | null
minaColor?: string | null
quantity?: number
estimatedWeightGrams?: Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: Decimal | DecimalJsLike | number | string | null
description?: string | null
@@ -79197,6 +79251,7 @@ export namespace Prisma {
sizeSpec?: NullableStringFieldUpdateOperationsInput | string | null
stoneColor?: NullableStringFieldUpdateOperationsInput | string | null
minaColor?: NullableStringFieldUpdateOperationsInput | string | null
quantity?: IntFieldUpdateOperationsInput | number
estimatedWeightGrams?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
description?: NullableStringFieldUpdateOperationsInput | string | null
@@ -79218,6 +79273,7 @@ export namespace Prisma {
sizeSpec?: NullableStringFieldUpdateOperationsInput | string | null
stoneColor?: NullableStringFieldUpdateOperationsInput | string | null
minaColor?: NullableStringFieldUpdateOperationsInput | string | null
quantity?: IntFieldUpdateOperationsInput | number
estimatedWeightGrams?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
description?: NullableStringFieldUpdateOperationsInput | string | null
@@ -79237,6 +79293,7 @@ export namespace Prisma {
sizeSpec?: NullableStringFieldUpdateOperationsInput | string | null
stoneColor?: NullableStringFieldUpdateOperationsInput | string | null
minaColor?: NullableStringFieldUpdateOperationsInput | string | null
quantity?: IntFieldUpdateOperationsInput | number
estimatedWeightGrams?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
description?: NullableStringFieldUpdateOperationsInput | string | null
@@ -79985,6 +80042,7 @@ export namespace Prisma {
sizeSpec?: string | null
stoneColor?: string | null
minaColor?: string | null
quantity?: number
estimatedWeightGrams?: Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: Decimal | DecimalJsLike | number | string | null
description?: string | null
@@ -80231,6 +80289,7 @@ export namespace Prisma {
sizeSpec?: NullableStringFieldUpdateOperationsInput | string | null
stoneColor?: NullableStringFieldUpdateOperationsInput | string | null
minaColor?: NullableStringFieldUpdateOperationsInput | string | null
quantity?: IntFieldUpdateOperationsInput | number
estimatedWeightGrams?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
description?: NullableStringFieldUpdateOperationsInput | string | null
@@ -80252,6 +80311,7 @@ export namespace Prisma {
sizeSpec?: NullableStringFieldUpdateOperationsInput | string | null
stoneColor?: NullableStringFieldUpdateOperationsInput | string | null
minaColor?: NullableStringFieldUpdateOperationsInput | string | null
quantity?: IntFieldUpdateOperationsInput | number
estimatedWeightGrams?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
description?: NullableStringFieldUpdateOperationsInput | string | null
@@ -80271,6 +80331,7 @@ export namespace Prisma {
sizeSpec?: NullableStringFieldUpdateOperationsInput | string | null
stoneColor?: NullableStringFieldUpdateOperationsInput | string | null
minaColor?: NullableStringFieldUpdateOperationsInput | string | null
quantity?: IntFieldUpdateOperationsInput | number
estimatedWeightGrams?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
description?: NullableStringFieldUpdateOperationsInput | string | null
@@ -80822,6 +80883,7 @@ export namespace Prisma {
sizeSpec?: string | null
stoneColor?: string | null
minaColor?: string | null
quantity?: number
estimatedWeightGrams?: Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: Decimal | DecimalJsLike | number | string | null
description?: string | null
@@ -80838,6 +80900,7 @@ export namespace Prisma {
sizeSpec?: NullableStringFieldUpdateOperationsInput | string | null
stoneColor?: NullableStringFieldUpdateOperationsInput | string | null
minaColor?: NullableStringFieldUpdateOperationsInput | string | null
quantity?: IntFieldUpdateOperationsInput | number
estimatedWeightGrams?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
description?: NullableStringFieldUpdateOperationsInput | string | null
@@ -80859,6 +80922,7 @@ export namespace Prisma {
sizeSpec?: NullableStringFieldUpdateOperationsInput | string | null
stoneColor?: NullableStringFieldUpdateOperationsInput | string | null
minaColor?: NullableStringFieldUpdateOperationsInput | string | null
quantity?: IntFieldUpdateOperationsInput | number
estimatedWeightGrams?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
description?: NullableStringFieldUpdateOperationsInput | string | null
@@ -80878,6 +80942,7 @@ export namespace Prisma {
sizeSpec?: NullableStringFieldUpdateOperationsInput | string | null
stoneColor?: NullableStringFieldUpdateOperationsInput | string | null
minaColor?: NullableStringFieldUpdateOperationsInput | string | null
quantity?: IntFieldUpdateOperationsInput | number
estimatedWeightGrams?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
estimatedPriceRial?: NullableDecimalFieldUpdateOperationsInput | Decimal | DecimalJsLike | number | string | null
description?: NullableStringFieldUpdateOperationsInput | string | null
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "prisma-client-d98bddf3599c950eb3fcd511efa7f226c4ef4c5c3cbc261683fb1118a763a924",
"name": "prisma-client-116b04ea3171f5cde20e1cb240deadd7003ef9332c0f79ef1b0e851c763aeb43",
"main": "index.js",
"types": "index.d.ts",
"browser": "index-browser.js",
+3
View File
@@ -901,6 +901,9 @@ model PreOrder {
sizeSpec String?
stoneColor String?
minaColor String?
/// Legacy custom_orders.sum — how many pieces the customer asked for. The first
/// schema had no field for it and the admin grid showed a hard-coded 1.
quantity Int @default(1)
estimatedWeightGrams Decimal? @db.Decimal(14, 4)
estimatedPriceRial Decimal? @db.Decimal(20, 2)
description String?
+1
View File
@@ -399,6 +399,7 @@ exports.Prisma.PreOrderScalarFieldEnum = {
sizeSpec: 'sizeSpec',
stoneColor: 'stoneColor',
minaColor: 'minaColor',
quantity: 'quantity',
estimatedWeightGrams: 'estimatedWeightGrams',
estimatedPriceRial: 'estimatedPriceRial',
description: 'description',
+2
View File
@@ -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,
+32
View File
@@ -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('سند حسابداری');
});
});
+210 -27
View File
@@ -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);
});
});
+26 -6
View File
@@ -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,