@open-mercato/core 0.7.1-develop.7120.1.a0154ac412 → 0.7.1-develop.7121.1.734c263bda
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/sales/commands/documents.js +26 -62
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/commands/returns.js +1 -30
- package/dist/modules/sales/commands/returns.js.map +2 -2
- package/dist/modules/sales/commands/shared.js +1 -4
- package/dist/modules/sales/commands/shared.js.map +2 -2
- package/dist/modules/sales/components/documents/SalesOrderDraftLines.js +2 -1
- package/dist/modules/sales/components/documents/SalesOrderDraftLines.js.map +2 -2
- package/dist/modules/sales/data/validators.js +3 -0
- package/dist/modules/sales/data/validators.js.map +2 -2
- package/dist/modules/sales/lib/calculations.js +14 -2
- package/dist/modules/sales/lib/calculations.js.map +2 -2
- package/dist/modules/sales/lib/json.js +8 -0
- package/dist/modules/sales/lib/json.js.map +7 -0
- package/dist/modules/sales/lib/lineSnapshots.js +66 -0
- package/dist/modules/sales/lib/lineSnapshots.js.map +7 -0
- package/package.json +7 -7
- package/src/modules/sales/commands/documents.ts +32 -66
- package/src/modules/sales/commands/returns.ts +1 -31
- package/src/modules/sales/commands/shared.ts +1 -4
- package/src/modules/sales/components/documents/SalesOrderDraftLines.tsx +12 -1
- package/src/modules/sales/data/validators.ts +3 -0
- package/src/modules/sales/lib/calculations.ts +33 -7
- package/src/modules/sales/lib/json.ts +4 -0
- package/src/modules/sales/lib/lineSnapshots.ts +98 -0
- package/src/modules/sales/lib/types.ts +20 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.7.1-develop.
|
|
3
|
+
"version": "0.7.1-develop.7121.1.734c263bda",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -252,16 +252,16 @@
|
|
|
252
252
|
"zod": "^4.4.3"
|
|
253
253
|
},
|
|
254
254
|
"peerDependencies": {
|
|
255
|
-
"@open-mercato/ai-assistant": "0.7.1-develop.
|
|
256
|
-
"@open-mercato/shared": "0.7.1-develop.
|
|
257
|
-
"@open-mercato/ui": "0.7.1-develop.
|
|
255
|
+
"@open-mercato/ai-assistant": "0.7.1-develop.7121.1.734c263bda",
|
|
256
|
+
"@open-mercato/shared": "0.7.1-develop.7121.1.734c263bda",
|
|
257
|
+
"@open-mercato/ui": "0.7.1-develop.7121.1.734c263bda",
|
|
258
258
|
"react": "^19.0.0",
|
|
259
259
|
"react-dom": "^19.0.0"
|
|
260
260
|
},
|
|
261
261
|
"devDependencies": {
|
|
262
|
-
"@open-mercato/ai-assistant": "0.7.1-develop.
|
|
263
|
-
"@open-mercato/shared": "0.7.1-develop.
|
|
264
|
-
"@open-mercato/ui": "0.7.1-develop.
|
|
262
|
+
"@open-mercato/ai-assistant": "0.7.1-develop.7121.1.734c263bda",
|
|
263
|
+
"@open-mercato/shared": "0.7.1-develop.7121.1.734c263bda",
|
|
264
|
+
"@open-mercato/ui": "0.7.1-develop.7121.1.734c263bda",
|
|
265
265
|
"@testing-library/dom": "^10.4.1",
|
|
266
266
|
"@testing-library/jest-dom": "^7.0.0",
|
|
267
267
|
"@testing-library/react": "^16.3.1",
|
|
@@ -130,6 +130,11 @@ import {
|
|
|
130
130
|
type SalesLineCalculationResult,
|
|
131
131
|
type SalesDocumentCalculationResult,
|
|
132
132
|
} from "../lib/types";
|
|
133
|
+
import {
|
|
134
|
+
mapOrderLineEntityToSnapshot,
|
|
135
|
+
mapQuoteLineEntityToSnapshot,
|
|
136
|
+
resolveUpsertDiscountFields,
|
|
137
|
+
} from "../lib/lineSnapshots";
|
|
133
138
|
import { loadShippedQuantityByLine } from "../lib/shipments/snapshots";
|
|
134
139
|
import { resolveDictionaryEntryValue, resolveCachedDictionaryEntryValue } from "../lib/dictionaries";
|
|
135
140
|
import type { CacheStrategy } from "@open-mercato/cache";
|
|
@@ -2970,68 +2975,6 @@ function buildCalculationContext(params: {
|
|
|
2970
2975
|
};
|
|
2971
2976
|
}
|
|
2972
2977
|
|
|
2973
|
-
function mapOrderLineEntityToSnapshot(line: SalesOrderLine): SalesLineSnapshot {
|
|
2974
|
-
return {
|
|
2975
|
-
id: line.id,
|
|
2976
|
-
lineNumber: line.lineNumber,
|
|
2977
|
-
kind: line.kind,
|
|
2978
|
-
productId: line.productId ?? null,
|
|
2979
|
-
productVariantId: line.productVariantId ?? null,
|
|
2980
|
-
name: line.name ?? null,
|
|
2981
|
-
description: line.description ?? null,
|
|
2982
|
-
comment: line.comment ?? null,
|
|
2983
|
-
quantity: toNumeric(line.quantity),
|
|
2984
|
-
quantityUnit: line.quantityUnit ?? null,
|
|
2985
|
-
normalizedQuantity: toNumeric(line.normalizedQuantity ?? line.quantity),
|
|
2986
|
-
normalizedUnit: line.normalizedUnit ?? line.quantityUnit ?? null,
|
|
2987
|
-
uomSnapshot: line.uomSnapshot ? cloneJson(line.uomSnapshot) : null,
|
|
2988
|
-
currencyCode: line.currencyCode,
|
|
2989
|
-
unitPriceNet: toNumeric(line.unitPriceNet),
|
|
2990
|
-
unitPriceGross: toNumeric(line.unitPriceGross),
|
|
2991
|
-
discountAmount: toNumeric(line.discountAmount),
|
|
2992
|
-
discountPercent: toNumeric(line.discountPercent),
|
|
2993
|
-
taxRate: toNumeric(line.taxRate),
|
|
2994
|
-
taxAmount: toNumeric(line.taxAmount),
|
|
2995
|
-
totalNetAmount: toNumeric(line.totalNetAmount),
|
|
2996
|
-
totalGrossAmount: toNumeric(line.totalGrossAmount),
|
|
2997
|
-
configuration: line.configuration ? cloneJson(line.configuration) : null,
|
|
2998
|
-
promotionCode: line.promotionCode ?? null,
|
|
2999
|
-
metadata: line.metadata ? cloneJson(line.metadata) : null,
|
|
3000
|
-
customFieldSetId: line.customFieldSetId ?? null,
|
|
3001
|
-
};
|
|
3002
|
-
}
|
|
3003
|
-
|
|
3004
|
-
function mapQuoteLineEntityToSnapshot(line: SalesQuoteLine): SalesLineSnapshot {
|
|
3005
|
-
return {
|
|
3006
|
-
id: line.id,
|
|
3007
|
-
lineNumber: line.lineNumber,
|
|
3008
|
-
kind: line.kind,
|
|
3009
|
-
productId: line.productId ?? null,
|
|
3010
|
-
productVariantId: line.productVariantId ?? null,
|
|
3011
|
-
name: line.name ?? null,
|
|
3012
|
-
description: line.description ?? null,
|
|
3013
|
-
comment: line.comment ?? null,
|
|
3014
|
-
quantity: toNumeric(line.quantity),
|
|
3015
|
-
quantityUnit: line.quantityUnit ?? null,
|
|
3016
|
-
normalizedQuantity: toNumeric(line.normalizedQuantity ?? line.quantity),
|
|
3017
|
-
normalizedUnit: line.normalizedUnit ?? line.quantityUnit ?? null,
|
|
3018
|
-
uomSnapshot: line.uomSnapshot ? cloneJson(line.uomSnapshot) : null,
|
|
3019
|
-
currencyCode: line.currencyCode,
|
|
3020
|
-
unitPriceNet: toNumeric(line.unitPriceNet),
|
|
3021
|
-
unitPriceGross: toNumeric(line.unitPriceGross),
|
|
3022
|
-
discountAmount: toNumeric(line.discountAmount),
|
|
3023
|
-
discountPercent: toNumeric(line.discountPercent),
|
|
3024
|
-
taxRate: toNumeric(line.taxRate),
|
|
3025
|
-
taxAmount: toNumeric(line.taxAmount),
|
|
3026
|
-
totalNetAmount: toNumeric(line.totalNetAmount),
|
|
3027
|
-
totalGrossAmount: toNumeric(line.totalGrossAmount),
|
|
3028
|
-
configuration: line.configuration ? cloneJson(line.configuration) : null,
|
|
3029
|
-
promotionCode: line.promotionCode ?? null,
|
|
3030
|
-
metadata: line.metadata ? cloneJson(line.metadata) : null,
|
|
3031
|
-
customFieldSetId: line.customFieldSetId ?? null,
|
|
3032
|
-
};
|
|
3033
|
-
}
|
|
3034
|
-
|
|
3035
2978
|
function mapOrderAdjustmentToDraft(
|
|
3036
2979
|
adjustment: SalesOrderAdjustment,
|
|
3037
2980
|
): SalesAdjustmentDraft {
|
|
@@ -3088,6 +3031,13 @@ async function emitTotalsCalculated(
|
|
|
3088
3031
|
await eventBus.emitEvent("sales.document.totals.calculated", payload);
|
|
3089
3032
|
}
|
|
3090
3033
|
|
|
3034
|
+
function isStoredRowSourcedLine(line: DocumentLineCreateInput): boolean {
|
|
3035
|
+
return (
|
|
3036
|
+
(line as Pick<SalesLineSnapshot, "discountAmountFromStoredRow">)
|
|
3037
|
+
.discountAmountFromStoredRow === true
|
|
3038
|
+
);
|
|
3039
|
+
}
|
|
3040
|
+
|
|
3091
3041
|
function createLineSnapshotFromInput(
|
|
3092
3042
|
line: DocumentLineCreateInput,
|
|
3093
3043
|
lineNumber: number,
|
|
@@ -3119,6 +3069,16 @@ function createLineSnapshotFromInput(
|
|
|
3119
3069
|
unitPriceNet: line.unitPriceNet ?? null,
|
|
3120
3070
|
unitPriceGross: line.unitPriceGross ?? null,
|
|
3121
3071
|
discountAmount: line.discountAmount ?? null,
|
|
3072
|
+
// Several callers re-run an already-mapped snapshot through here — the line
|
|
3073
|
+
// upsert and delete paths rebuild every line of the document, not just the
|
|
3074
|
+
// one being edited. Those inputs already carry a line total from a stored
|
|
3075
|
+
// row, so their origin must survive rather than be overwritten with the
|
|
3076
|
+
// caller default, or the untouched lines get re-inflated by quantity on
|
|
3077
|
+
// every write. Exactly one of the two origin fields is ever set, which is
|
|
3078
|
+
// the invariant the calculation engine relies on.
|
|
3079
|
+
...(isStoredRowSourcedLine(line)
|
|
3080
|
+
? { discountAmountFromStoredRow: true }
|
|
3081
|
+
: { discountAmountBasis: line.discountAmountBasis ?? "unit" }),
|
|
3122
3082
|
discountPercent: line.discountPercent ?? null,
|
|
3123
3083
|
taxRate: line.taxRate ?? null,
|
|
3124
3084
|
taxAmount: line.taxAmount ?? null,
|
|
@@ -7267,8 +7227,11 @@ const orderLineUpsertCommand: CommandHandler<
|
|
|
7267
7227
|
order.currencyCode,
|
|
7268
7228
|
unitPriceNet: unitPriceNet ?? 0,
|
|
7269
7229
|
unitPriceGross: unitPriceGross ?? unitPriceNet ?? 0,
|
|
7270
|
-
|
|
7271
|
-
parsed.discountAmount
|
|
7230
|
+
...resolveUpsertDiscountFields(
|
|
7231
|
+
parsed.discountAmount,
|
|
7232
|
+
parsed.discountAmountBasis,
|
|
7233
|
+
existingSnapshot,
|
|
7234
|
+
),
|
|
7272
7235
|
discountPercent:
|
|
7273
7236
|
parsed.discountPercent ?? existingSnapshot?.discountPercent ?? 0,
|
|
7274
7237
|
taxRate: taxRate ?? 0,
|
|
@@ -7761,8 +7724,11 @@ const quoteLineUpsertCommand: CommandHandler<
|
|
|
7761
7724
|
quote.currencyCode,
|
|
7762
7725
|
unitPriceNet: unitPriceNet ?? 0,
|
|
7763
7726
|
unitPriceGross: unitPriceGross ?? unitPriceNet ?? 0,
|
|
7764
|
-
|
|
7765
|
-
parsed.discountAmount
|
|
7727
|
+
...resolveUpsertDiscountFields(
|
|
7728
|
+
parsed.discountAmount,
|
|
7729
|
+
parsed.discountAmountBasis,
|
|
7730
|
+
existingSnapshot,
|
|
7731
|
+
),
|
|
7766
7732
|
discountPercent:
|
|
7767
7733
|
parsed.discountPercent ?? existingSnapshot?.discountPercent ?? 0,
|
|
7768
7734
|
taxRate: taxRate ?? 0,
|
|
@@ -16,6 +16,7 @@ import type { SalesAdjustmentDraft, SalesLineSnapshot, SalesDocumentCalculationR
|
|
|
16
16
|
import { cloneJson, deriveLineNetFromGross, ensureOrganizationScope, ensureSameScope, ensureTenantScope, extractUndoPayload, toNumericString, enforceSalesDocumentOptimisticLock, SALES_RESOURCE_KIND_ORDER, SALES_RESOURCE_KIND_RETURN } from './shared'
|
|
17
17
|
import { resolveRedoSnapshot } from '@open-mercato/shared/lib/commands/redo'
|
|
18
18
|
import { SalesOrder, SalesOrderAdjustment, SalesOrderLine, SalesReturn, SalesReturnLine } from '../data/entities'
|
|
19
|
+
import { mapOrderLineEntityToSnapshot } from '../lib/lineSnapshots'
|
|
19
20
|
import { loadShippedQuantityByLine } from '../lib/shipments/snapshots'
|
|
20
21
|
import { computeAvailableReturnQuantity } from '../lib/returnQuantity'
|
|
21
22
|
import {
|
|
@@ -134,37 +135,6 @@ function applyOrderTotals(order: SalesOrder, totals: SalesDocumentCalculationRes
|
|
|
134
135
|
order.lineItemCount = lineCount
|
|
135
136
|
}
|
|
136
137
|
|
|
137
|
-
function mapOrderLineEntityToSnapshot(line: SalesOrderLine): SalesLineSnapshot {
|
|
138
|
-
return {
|
|
139
|
-
id: line.id,
|
|
140
|
-
lineNumber: line.lineNumber,
|
|
141
|
-
kind: line.kind,
|
|
142
|
-
productId: line.productId ?? null,
|
|
143
|
-
productVariantId: line.productVariantId ?? null,
|
|
144
|
-
name: line.name ?? null,
|
|
145
|
-
description: line.description ?? null,
|
|
146
|
-
comment: line.comment ?? null,
|
|
147
|
-
quantity: toNumeric(line.quantity),
|
|
148
|
-
quantityUnit: line.quantityUnit ?? null,
|
|
149
|
-
normalizedQuantity: toNumeric(line.normalizedQuantity ?? line.quantity),
|
|
150
|
-
normalizedUnit: line.normalizedUnit ?? line.quantityUnit ?? null,
|
|
151
|
-
uomSnapshot: line.uomSnapshot ? cloneJson(line.uomSnapshot) : null,
|
|
152
|
-
currencyCode: line.currencyCode,
|
|
153
|
-
unitPriceNet: toNumeric(line.unitPriceNet),
|
|
154
|
-
unitPriceGross: toNumeric(line.unitPriceGross),
|
|
155
|
-
discountAmount: toNumeric(line.discountAmount),
|
|
156
|
-
discountPercent: toNumeric(line.discountPercent),
|
|
157
|
-
taxRate: toNumeric(line.taxRate),
|
|
158
|
-
taxAmount: toNumeric(line.taxAmount),
|
|
159
|
-
totalNetAmount: toNumeric(line.totalNetAmount),
|
|
160
|
-
totalGrossAmount: toNumeric(line.totalGrossAmount),
|
|
161
|
-
configuration: line.configuration ? cloneJson(line.configuration) : null,
|
|
162
|
-
promotionCode: line.promotionCode ?? null,
|
|
163
|
-
metadata: line.metadata ? cloneJson(line.metadata) : null,
|
|
164
|
-
customFieldSetId: line.customFieldSetId ?? null,
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
138
|
function mapOrderAdjustmentToDraft(adjustment: SalesOrderAdjustment): SalesAdjustmentDraft {
|
|
169
139
|
return {
|
|
170
140
|
id: adjustment.id,
|
|
@@ -49,10 +49,7 @@ export async function enforceSalesDocumentOptimisticLock(
|
|
|
49
49
|
})
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
export
|
|
53
|
-
if (value === null || value === undefined) return value
|
|
54
|
-
return JSON.parse(JSON.stringify(value)) as T
|
|
55
|
-
}
|
|
52
|
+
export { cloneJson } from '../lib/json'
|
|
56
53
|
|
|
57
54
|
export function toNumericString(value: number | null | undefined): string | null {
|
|
58
55
|
if (value === undefined || value === null) return null
|
|
@@ -49,6 +49,17 @@ export function createSalesOrderLineDraft(
|
|
|
49
49
|
const catalogSnapshot = payload.catalogSnapshot && typeof payload.catalogSnapshot === 'object'
|
|
50
50
|
? payload.catalogSnapshot as Record<string, unknown>
|
|
51
51
|
: null
|
|
52
|
+
// `payload` is forwarded verbatim to the create API, where a supplied
|
|
53
|
+
// `discountAmount` is read per unit unless the caller says otherwise, while
|
|
54
|
+
// `record` below drives the grid, which shows the discount for the whole
|
|
55
|
+
// line. The multiplication is therefore the conversion between the two, not a
|
|
56
|
+
// second copy of the per-unit misreading the calculation engine used to hold
|
|
57
|
+
// — but it has to stop when the caller has explicitly said the amount is
|
|
58
|
+
// already a line total, or the grid would show the discount quantity times
|
|
59
|
+
// over while the persisted order shows it once.
|
|
60
|
+
const draftDiscountAmount = payload.discountAmountBasis === 'line'
|
|
61
|
+
? normalizeNumber(payload.discountAmount, 0)
|
|
62
|
+
: normalizeNumber(payload.discountAmount, 0) * quantity
|
|
52
63
|
|
|
53
64
|
return {
|
|
54
65
|
id,
|
|
@@ -65,7 +76,7 @@ export function createSalesOrderLineDraft(
|
|
|
65
76
|
currencyCode: typeof payload.currencyCode === 'string' ? payload.currencyCode : null,
|
|
66
77
|
unitPriceNet,
|
|
67
78
|
unitPriceGross,
|
|
68
|
-
discountAmount:
|
|
79
|
+
discountAmount: draftDiscountAmount,
|
|
69
80
|
discountPercent: normalizeNumber(payload.discountPercent, 0),
|
|
70
81
|
taxRate,
|
|
71
82
|
totalNet,
|
|
@@ -340,6 +340,9 @@ const linePricingSchema = z.object({
|
|
|
340
340
|
priceMode: z.enum(['net', 'gross']).optional(),
|
|
341
341
|
taxRateId: uuid().optional(),
|
|
342
342
|
discountAmount: decimal({ min: 0 }).optional(),
|
|
343
|
+
// How to read a supplied `discountAmount`. Omitting it reproduces the meaning
|
|
344
|
+
// the API has always documented — per unit — so no existing caller changes.
|
|
345
|
+
discountAmountBasis: z.enum(['unit', 'line']).optional(),
|
|
343
346
|
discountPercent: percentage().optional(),
|
|
344
347
|
taxRate: percentage().optional(),
|
|
345
348
|
taxAmount: decimal({ min: 0 }).optional(),
|
|
@@ -77,6 +77,35 @@ function resolveAdjustmentAmounts(
|
|
|
77
77
|
})
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// `discount_amount` stores the discount for the WHOLE line, while
|
|
81
|
+
// `discount_percent` records the operator's intent. The percentage therefore
|
|
82
|
+
// wins whenever it is set: a stored amount is only ever its cached result, and
|
|
83
|
+
// because the column is NOT NULL DEFAULT '0' a stored 0 cannot be told apart
|
|
84
|
+
// from "no discount supplied" — so it counts as absent rather than as a
|
|
85
|
+
// suppressing value. That is what makes recalculation idempotent, and what lets
|
|
86
|
+
// a row whose amount the old engine dropped or re-inflated heal itself on the
|
|
87
|
+
// next pass. Spec: .ai/specs/2026-08-07-sales-line-discount-amount-contract.md.
|
|
88
|
+
function resolveLineDiscountTotal(
|
|
89
|
+
line: SalesLineSnapshot,
|
|
90
|
+
netSubtotalBeforeDiscount: number,
|
|
91
|
+
quantity: number,
|
|
92
|
+
): number {
|
|
93
|
+
const percent = toNumber(line.discountPercent, 0)
|
|
94
|
+
if (line.discountPercent !== null && line.discountPercent !== undefined && percent !== 0) {
|
|
95
|
+
return (percent / 100) * netSubtotalBeforeDiscount
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const amount = toNumber(line.discountAmount, 0)
|
|
99
|
+
if (line.discountAmount === null || line.discountAmount === undefined || amount === 0) return 0
|
|
100
|
+
|
|
101
|
+
// A snapshot rebuilt from a persisted row already holds a line total, so it
|
|
102
|
+
// is never multiplied out again. Anything else came from a caller and keeps
|
|
103
|
+
// the per-unit meaning the API has always documented unless the caller says
|
|
104
|
+
// otherwise.
|
|
105
|
+
if (line.discountAmountFromStoredRow === true) return amount
|
|
106
|
+
return line.discountAmountBasis === 'line' ? amount : amount * quantity
|
|
107
|
+
}
|
|
108
|
+
|
|
80
109
|
function buildBaseLineResult(line: SalesLineSnapshot): SalesLineCalculationResult {
|
|
81
110
|
const quantity = Math.max(toNumber(line.quantity, 0), 0)
|
|
82
111
|
const taxRate = toNumber(line.taxRate, 0) / 100
|
|
@@ -85,14 +114,11 @@ function buildBaseLineResult(line: SalesLineSnapshot): SalesLineCalculationResul
|
|
|
85
114
|
(line.unitPriceGross !== null && line.unitPriceGross !== undefined
|
|
86
115
|
? toNumber(line.unitPriceGross) / (1 + taxRate)
|
|
87
116
|
: 0)
|
|
88
|
-
const discountPerUnit =
|
|
89
|
-
line.discountAmount ??
|
|
90
|
-
(line.discountPercent !== null && line.discountPercent !== undefined
|
|
91
|
-
? toNumber(line.discountPercent, 0) / 100 * toNumber(unitNet, 0)
|
|
92
|
-
: 0)
|
|
93
|
-
|
|
94
117
|
const netSubtotalBeforeDiscount = toNumber(unitNet, 0) * quantity
|
|
95
|
-
const discountTotal = Math.min(
|
|
118
|
+
const discountTotal = Math.min(
|
|
119
|
+
Math.max(resolveLineDiscountTotal(line, netSubtotalBeforeDiscount, quantity), 0),
|
|
120
|
+
netSubtotalBeforeDiscount,
|
|
121
|
+
)
|
|
96
122
|
const netSubtotal = Math.max(netSubtotalBeforeDiscount - discountTotal, 0)
|
|
97
123
|
const explicitTaxAmount = line.taxAmount !== null && line.taxAmount !== undefined
|
|
98
124
|
let taxAmount = explicitTaxAmount
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { SalesOrderLine, SalesQuoteLine } from '../data/entities'
|
|
2
|
+
import { cloneJson } from './json'
|
|
3
|
+
import type { SalesLineDiscountBasis, SalesLineSnapshot } from './types'
|
|
4
|
+
|
|
5
|
+
function toNumeric(value: unknown): number {
|
|
6
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
7
|
+
if (typeof value === 'string' && value.trim().length) {
|
|
8
|
+
const parsed = Number(value)
|
|
9
|
+
if (Number.isFinite(parsed)) return parsed
|
|
10
|
+
}
|
|
11
|
+
return 0
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function mapPersistedLine(line: SalesOrderLine | SalesQuoteLine): SalesLineSnapshot {
|
|
15
|
+
return {
|
|
16
|
+
id: line.id,
|
|
17
|
+
lineNumber: line.lineNumber,
|
|
18
|
+
kind: line.kind,
|
|
19
|
+
productId: line.productId ?? null,
|
|
20
|
+
productVariantId: line.productVariantId ?? null,
|
|
21
|
+
name: line.name ?? null,
|
|
22
|
+
description: line.description ?? null,
|
|
23
|
+
comment: line.comment ?? null,
|
|
24
|
+
quantity: toNumeric(line.quantity),
|
|
25
|
+
quantityUnit: line.quantityUnit ?? null,
|
|
26
|
+
normalizedQuantity: toNumeric(line.normalizedQuantity ?? line.quantity),
|
|
27
|
+
normalizedUnit: line.normalizedUnit ?? line.quantityUnit ?? null,
|
|
28
|
+
uomSnapshot: line.uomSnapshot ? cloneJson(line.uomSnapshot) : null,
|
|
29
|
+
currencyCode: line.currencyCode,
|
|
30
|
+
unitPriceNet: toNumeric(line.unitPriceNet),
|
|
31
|
+
unitPriceGross: toNumeric(line.unitPriceGross),
|
|
32
|
+
discountAmount: toNumeric(line.discountAmount),
|
|
33
|
+
// The persisted column holds the discount for the whole line, so the
|
|
34
|
+
// calculation engine must not multiply it out by quantity again. This flag
|
|
35
|
+
// is the only thing standing between a round trip and a compounding
|
|
36
|
+
// re-inflation, and it is why mappers must never set `discountAmountBasis`
|
|
37
|
+
// instead — that one means "a caller asserted this".
|
|
38
|
+
discountAmountFromStoredRow: true,
|
|
39
|
+
discountPercent: toNumeric(line.discountPercent),
|
|
40
|
+
taxRate: toNumeric(line.taxRate),
|
|
41
|
+
taxAmount: toNumeric(line.taxAmount),
|
|
42
|
+
totalNetAmount: toNumeric(line.totalNetAmount),
|
|
43
|
+
totalGrossAmount: toNumeric(line.totalGrossAmount),
|
|
44
|
+
configuration: line.configuration ? cloneJson(line.configuration) : null,
|
|
45
|
+
promotionCode: line.promotionCode ?? null,
|
|
46
|
+
metadata: line.metadata ? cloneJson(line.metadata) : null,
|
|
47
|
+
customFieldSetId: line.customFieldSetId ?? null,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Rebuild a calculation snapshot from a persisted order line.
|
|
53
|
+
*
|
|
54
|
+
* Shared by `commands/documents.ts` and `commands/returns.ts` on purpose: the
|
|
55
|
+
* two files used to carry byte-identical copies, and that duplication is why
|
|
56
|
+
* the return flows kept the discount defect after the order flows were fixed.
|
|
57
|
+
*/
|
|
58
|
+
export function mapOrderLineEntityToSnapshot(line: SalesOrderLine): SalesLineSnapshot {
|
|
59
|
+
return mapPersistedLine(line)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function mapQuoteLineEntityToSnapshot(line: SalesQuoteLine): SalesLineSnapshot {
|
|
63
|
+
return mapPersistedLine(line)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type UpsertDiscountFields = Pick<
|
|
67
|
+
SalesLineSnapshot,
|
|
68
|
+
'discountAmount' | 'discountAmountBasis' | 'discountAmountFromStoredRow'
|
|
69
|
+
>
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Decide the discount fields of an upsert payload one operand at a time.
|
|
73
|
+
*
|
|
74
|
+
* A line upsert can source its amount from two places with opposite meanings: a
|
|
75
|
+
* caller value, which is per unit unless the caller says otherwise, and the
|
|
76
|
+
* stored row, which is always a line total. Collapsing them into a single
|
|
77
|
+
* `caller ?? stored` expression means whichever origin the merged result is
|
|
78
|
+
* tagged with is wrong half the time — and the half that breaks is the
|
|
79
|
+
* upsert-existing path, which then re-inflates by a further factor of quantity
|
|
80
|
+
* while looking perfectly correct.
|
|
81
|
+
*
|
|
82
|
+
* Keeping `null` rather than `0` for "nothing supplied" is deliberate too: the
|
|
83
|
+
* column cannot represent that distinction, but the payload can, and the
|
|
84
|
+
* calculation engine needs it to tell a suppressing zero from an absent value.
|
|
85
|
+
*/
|
|
86
|
+
export function resolveUpsertDiscountFields(
|
|
87
|
+
callerAmount: number | null | undefined,
|
|
88
|
+
callerBasis: SalesLineDiscountBasis | null | undefined,
|
|
89
|
+
existingSnapshot: Pick<SalesLineSnapshot, 'discountAmount'> | null | undefined,
|
|
90
|
+
): UpsertDiscountFields {
|
|
91
|
+
if (callerAmount !== null && callerAmount !== undefined) {
|
|
92
|
+
return { discountAmount: callerAmount, discountAmountBasis: callerBasis ?? 'unit' }
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
discountAmount: existingSnapshot?.discountAmount ?? null,
|
|
96
|
+
discountAmountFromStoredRow: existingSnapshot != null,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -32,6 +32,13 @@ export type SalesLineUomSnapshot = {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* How a caller-supplied `discountAmount` should be read: as a rate per unit, or
|
|
37
|
+
* as the total for the whole line. Persisted rows always hold a line total —
|
|
38
|
+
* see `discountAmountFromStoredRow`, which is a separate signal on purpose.
|
|
39
|
+
*/
|
|
40
|
+
export type SalesLineDiscountBasis = 'unit' | 'line'
|
|
41
|
+
|
|
35
42
|
export type SalesLineSnapshot = {
|
|
36
43
|
id?: string
|
|
37
44
|
lineNumber?: number
|
|
@@ -50,6 +57,19 @@ export type SalesLineSnapshot = {
|
|
|
50
57
|
unitPriceNet?: number | null
|
|
51
58
|
unitPriceGross?: number | null
|
|
52
59
|
discountAmount?: number | null
|
|
60
|
+
/**
|
|
61
|
+
* Caller-supplied ONLY. How to interpret a supplied `discountAmount`.
|
|
62
|
+
* Omitted means 'unit', which is the meaning the API has always documented.
|
|
63
|
+
* Entity-to-snapshot mappers MUST NOT set this: a populated value is what
|
|
64
|
+
* makes this a reliable *caller* signal.
|
|
65
|
+
*/
|
|
66
|
+
discountAmountBasis?: SalesLineDiscountBasis | null
|
|
67
|
+
/**
|
|
68
|
+
* Set by entity-to-snapshot mappers ONLY. Marks `discountAmount` as
|
|
69
|
+
* reconstructed from a persisted row, so it is a line total and is NOT a
|
|
70
|
+
* caller assertion. Never persisted; never accepted from a request.
|
|
71
|
+
*/
|
|
72
|
+
discountAmountFromStoredRow?: boolean
|
|
53
73
|
discountPercent?: number | null
|
|
54
74
|
taxRate?: number | null
|
|
55
75
|
taxAmount?: number | null
|