@open-mercato/core 0.7.1-develop.7136.1.0f76137a1d → 0.7.1-develop.7148.1.3076e5ccf7
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/auth/backend/users/page.js +4 -4
- package/dist/modules/auth/backend/users/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js +1 -0
- package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/[id]/page.js +1 -0
- package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +1 -0
- package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
- package/dist/modules/customers/components/detail/CompanyPeopleSection.js +17 -4
- package/dist/modules/customers/components/detail/CompanyPeopleSection.js.map +2 -2
- package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +25 -12
- package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
- package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js.map +2 -2
- package/dist/modules/customers/components/linking/adapters/personAdapter.js +1 -1
- package/dist/modules/customers/components/linking/adapters/personAdapter.js.map +2 -2
- package/dist/modules/customers/migrations/Migration20260901120000_reindex_pipeline_stage_colors.js +16 -0
- package/dist/modules/customers/migrations/Migration20260901120000_reindex_pipeline_stage_colors.js.map +7 -0
- package/dist/modules/dictionaries/migrations/Migration20260901120000_reindex_dictionary_entries.js +16 -0
- package/dist/modules/dictionaries/migrations/Migration20260901120000_reindex_dictionary_entries.js.map +7 -0
- package/dist/modules/integrations/backend/integrations/[id]/page.js +16 -3
- package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
- package/dist/modules/integrations/backend/integrations/useIntegrationCredentialsFeatureAccess.js +42 -0
- package/dist/modules/integrations/backend/integrations/useIntegrationCredentialsFeatureAccess.js.map +7 -0
- package/dist/modules/messages/api/route.js +2 -1
- package/dist/modules/messages/api/route.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +11 -1
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/lib/calculations.js +22 -0
- package/dist/modules/sales/lib/calculations.js.map +2 -2
- package/dist/modules/sales/lib/lineSnapshots.js +11 -1
- package/dist/modules/sales/lib/lineSnapshots.js.map +2 -2
- package/dist/modules/workflows/migrations/Migration20260901120000_reindex_workflow_definitions.js +16 -0
- package/dist/modules/workflows/migrations/Migration20260901120000_reindex_workflow_definitions.js.map +7 -0
- package/package.json +7 -7
- package/src/modules/auth/backend/users/page.tsx +4 -4
- package/src/modules/auth/i18n/de.json +4 -0
- package/src/modules/auth/i18n/en.json +4 -0
- package/src/modules/auth/i18n/es.json +4 -0
- package/src/modules/auth/i18n/ko.json +4 -0
- package/src/modules/auth/i18n/pl.json +4 -0
- package/src/modules/customers/backend/customers/companies-v2/[id]/page.tsx +1 -0
- package/src/modules/customers/backend/customers/deals/[id]/page.tsx +1 -0
- package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +1 -0
- package/src/modules/customers/components/detail/CompanyPeopleSection.tsx +17 -4
- package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +40 -14
- package/src/modules/customers/components/detail/schedule/useScheduleFormState.ts +2 -0
- package/src/modules/customers/components/linking/adapters/personAdapter.tsx +2 -1
- package/src/modules/customers/i18n/de.json +6 -1
- package/src/modules/customers/i18n/en.json +6 -1
- package/src/modules/customers/i18n/es.json +6 -1
- package/src/modules/customers/i18n/ko.json +6 -1
- package/src/modules/customers/i18n/pl.json +6 -1
- package/src/modules/customers/migrations/Migration20260901120000_reindex_pipeline_stage_colors.ts +22 -0
- package/src/modules/dictionaries/migrations/Migration20260901120000_reindex_dictionary_entries.ts +20 -0
- package/src/modules/integrations/backend/integrations/[id]/page.tsx +23 -2
- package/src/modules/integrations/backend/integrations/useIntegrationCredentialsFeatureAccess.ts +50 -0
- package/src/modules/integrations/i18n/de.json +1 -0
- package/src/modules/integrations/i18n/en.json +1 -0
- package/src/modules/integrations/i18n/es.json +1 -0
- package/src/modules/integrations/i18n/ko.json +1 -0
- package/src/modules/integrations/i18n/pl.json +1 -0
- package/src/modules/messages/api/route.ts +2 -1
- package/src/modules/sales/commands/documents.ts +14 -0
- package/src/modules/sales/lib/calculations.ts +41 -0
- package/src/modules/sales/lib/lineSnapshots.ts +23 -0
- package/src/modules/sales/lib/types.ts +9 -0
- package/src/modules/workflows/migrations/Migration20260901120000_reindex_workflow_definitions.ts +21 -0
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
2
|
+
const logger = createLogger("sales");
|
|
1
3
|
function toNumber(value, fallback = 0) {
|
|
2
4
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
3
5
|
if (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value))) {
|
|
@@ -8,6 +10,7 @@ function toNumber(value, fallback = 0) {
|
|
|
8
10
|
function round(value) {
|
|
9
11
|
return Math.round((value + Number.EPSILON) * 1e4) / 1e4;
|
|
10
12
|
}
|
|
13
|
+
const NET_RECONCILIATION_TOLERANCE = 5e-3;
|
|
11
14
|
function extractAdjustmentTaxRate(adjustment) {
|
|
12
15
|
const metadata = adjustment.metadata ?? {};
|
|
13
16
|
const candidate = metadata.taxRate ?? metadata?.tax_rate ?? metadata?.taxRateValue ?? metadata?.tax_rate_value ?? null;
|
|
@@ -69,6 +72,25 @@ function buildBaseLineResult(line) {
|
|
|
69
72
|
netSubtotalBeforeDiscount
|
|
70
73
|
);
|
|
71
74
|
const netSubtotal = Math.max(netSubtotalBeforeDiscount - discountTotal, 0);
|
|
75
|
+
if (line.totalsFromStoredRow !== true && line.totalNetAmount !== null && line.totalNetAmount !== void 0) {
|
|
76
|
+
const computedNetAmount = round(netSubtotal);
|
|
77
|
+
const suppliedNetAmount = toNumber(line.totalNetAmount, NaN);
|
|
78
|
+
if (!Number.isFinite(suppliedNetAmount)) {
|
|
79
|
+
logger.warn("Sales line totalNetAmount is not a finite number; the computed value is used", {
|
|
80
|
+
lineId: line.id ?? null,
|
|
81
|
+
productId: line.productId ?? null,
|
|
82
|
+
suppliedTotalNetAmount: line.totalNetAmount,
|
|
83
|
+
computedNetAmount
|
|
84
|
+
});
|
|
85
|
+
} else if (Math.abs(round(suppliedNetAmount) - computedNetAmount) > NET_RECONCILIATION_TOLERANCE) {
|
|
86
|
+
logger.warn("Sales line totalNetAmount does not match the computed net amount; the computed value is used", {
|
|
87
|
+
lineId: line.id ?? null,
|
|
88
|
+
productId: line.productId ?? null,
|
|
89
|
+
suppliedTotalNetAmount: round(suppliedNetAmount),
|
|
90
|
+
computedNetAmount
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
72
94
|
const explicitTaxAmount = line.taxAmount !== null && line.taxAmount !== void 0;
|
|
73
95
|
let taxAmount = explicitTaxAmount ? toNumber(line.taxAmount, 0) : round(netSubtotal * Math.max(taxRate, 0));
|
|
74
96
|
const grossSubtotal = line.totalGrossAmount !== null && line.totalGrossAmount !== void 0 ? toNumber(line.totalGrossAmount, 0) : round(netSubtotal + taxAmount);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/sales/lib/calculations.ts"],
|
|
4
|
-
"sourcesContent": ["import {\n type SalesAdjustmentDraft,\n type SalesCalculationContext,\n type CalculateDocumentOptions,\n type CalculateLineOptions,\n type SalesDocumentCalculationResult,\n type SalesDocumentKind,\n type SalesLineCalculationHook,\n type SalesLineCalculationResult,\n type SalesLineSnapshot,\n type SalesTotalsCalculationHook,\n} from './types'\n\nfunction toNumber(value: unknown, fallback = 0): number {\n if (typeof value === 'number' && Number.isFinite(value)) return value\n if (typeof value === 'string' && value.trim() !== '' && !Number.isNaN(Number(value))) {\n return Number(value)\n }\n return fallback\n}\n\nfunction round(value: number): number {\n return Math.round((value + Number.EPSILON) * 1e4) / 1e4\n}\n\nfunction extractAdjustmentTaxRate(adjustment: SalesAdjustmentDraft): number | null {\n const metadata = (adjustment.metadata ?? {}) as Record<string, unknown>\n const candidate =\n metadata.taxRate ??\n (metadata as any)?.tax_rate ??\n (metadata as any)?.taxRateValue ??\n (metadata as any)?.tax_rate_value ??\n null\n const parsed = toNumber(candidate, NaN)\n return Number.isFinite(parsed) ? parsed : null\n}\n\nfunction resolveAdjustmentAmounts(\n adjustments: SalesAdjustmentDraft[],\n baseNet: number,\n baseGross: number\n): SalesAdjustmentDraft[] {\n return adjustments.map((adj) => {\n const rate = toNumber(adj.rate, NaN)\n const taxRate = extractAdjustmentTaxRate(adj)\n const hasAmountNet = Number.isFinite(toNumber(adj.amountNet, NaN))\n const hasAmountGross = Number.isFinite(toNumber(adj.amountGross, NaN))\n const hasRate = Number.isFinite(rate) && !hasAmountNet && !hasAmountGross\n const hasTaxRate = taxRate !== null\n let amountNet = toNumber(adj.amountNet, NaN)\n let amountGross = toNumber(adj.amountGross, NaN)\n\n if (hasRate) {\n const multiplier = (rate as number) / 100\n amountNet = round(Math.max(baseNet, 0) * multiplier)\n if (adj.kind === 'tax') {\n amountGross = amountNet\n } else if (hasTaxRate) {\n amountGross = round(amountNet * (1 + (taxRate as number) / 100))\n } else {\n amountGross = round(Math.max(baseGross, 0) * multiplier)\n }\n } else {\n if (!Number.isFinite(amountNet) && Number.isFinite(amountGross) && hasTaxRate) {\n amountNet = round((amountGross as number) / (1 + (taxRate as number) / 100))\n }\n if (!Number.isFinite(amountGross) && Number.isFinite(amountNet) && hasTaxRate) {\n amountGross = round((amountNet as number) * (1 + (taxRate as number) / 100))\n }\n }\n\n return {\n ...adj,\n amountNet: Number.isFinite(amountNet) ? amountNet : adj.amountNet,\n amountGross: Number.isFinite(amountGross) ? amountGross : adj.amountGross,\n }\n })\n}\n\n// `discount_amount` stores the discount for the WHOLE line, while\n// `discount_percent` records the operator's intent. The percentage therefore\n// wins whenever it is set: a stored amount is only ever its cached result, and\n// because the column is NOT NULL DEFAULT '0' a stored 0 cannot be told apart\n// from \"no discount supplied\" \u2014 so it counts as absent rather than as a\n// suppressing value. That is what makes recalculation idempotent, and what lets\n// a row whose amount the old engine dropped or re-inflated heal itself on the\n// next pass. Spec: .ai/specs/2026-08-07-sales-line-discount-amount-contract.md.\nfunction resolveLineDiscountTotal(\n line: SalesLineSnapshot,\n netSubtotalBeforeDiscount: number,\n quantity: number,\n): number {\n const percent = toNumber(line.discountPercent, 0)\n if (line.discountPercent !== null && line.discountPercent !== undefined && percent !== 0) {\n return (percent / 100) * netSubtotalBeforeDiscount\n }\n\n const amount = toNumber(line.discountAmount, 0)\n if (line.discountAmount === null || line.discountAmount === undefined || amount === 0) return 0\n\n // A snapshot rebuilt from a persisted row already holds a line total, so it\n // is never multiplied out again. Anything else came from a caller and keeps\n // the per-unit meaning the API has always documented unless the caller says\n // otherwise.\n if (line.discountAmountFromStoredRow === true) return amount\n return line.discountAmountBasis === 'line' ? amount : amount * quantity\n}\n\nfunction buildBaseLineResult(line: SalesLineSnapshot): SalesLineCalculationResult {\n const quantity = Math.max(toNumber(line.quantity, 0), 0)\n const taxRate = toNumber(line.taxRate, 0) / 100\n const unitNet =\n line.unitPriceNet ??\n (line.unitPriceGross !== null && line.unitPriceGross !== undefined\n ? toNumber(line.unitPriceGross) / (1 + taxRate)\n : 0)\n const netSubtotalBeforeDiscount = toNumber(unitNet, 0) * quantity\n const discountTotal = Math.min(\n Math.max(resolveLineDiscountTotal(line, netSubtotalBeforeDiscount, quantity), 0),\n netSubtotalBeforeDiscount,\n )\n const netSubtotal = Math.max(netSubtotalBeforeDiscount - discountTotal, 0)\n const explicitTaxAmount = line.taxAmount !== null && line.taxAmount !== undefined\n let taxAmount = explicitTaxAmount\n ? toNumber(line.taxAmount, 0)\n : round(netSubtotal * Math.max(taxRate, 0))\n const grossSubtotal =\n line.totalGrossAmount !== null && line.totalGrossAmount !== undefined\n ? toNumber(line.totalGrossAmount, 0)\n : round(netSubtotal + taxAmount)\n // When tax was not supplied explicitly and the rate-derived tax is zero but\n // the gross total already embeds tax (gross > net) \u2014 e.g. a tax-class-priced\n // line whose resolved rate was not persisted \u2014 derive the tax from the\n // net/gross delta so the document-level tax total is not silently zeroed\n // while per-line net/gross stay correct (#2457).\n if (!explicitTaxAmount && taxAmount <= 0) {\n const grossNetDelta = round(grossSubtotal - netSubtotal)\n if (grossNetDelta > 0) taxAmount = grossNetDelta\n }\n\n return {\n line,\n netAmount: round(netSubtotal),\n grossAmount: round(grossSubtotal),\n taxAmount: round(taxAmount),\n discountAmount: round(discountTotal),\n adjustments: [],\n }\n}\n\nfunction buildBaseDocumentResult(params: {\n documentKind: SalesDocumentKind\n lines: SalesLineCalculationResult[]\n adjustments: SalesAdjustmentDraft[]\n currencyCode: string\n existingTotals?: { paidTotalAmount?: number | null; refundedTotalAmount?: number | null }\n}): SalesDocumentCalculationResult {\n const { documentKind, lines, adjustments, currencyCode } = params\n const orderedAdjustments = [...(adjustments ?? [])].sort(\n (a, b) => (a.position ?? 0) - (b.position ?? 0)\n )\n let baseSubtotalNet = 0\n let baseSubtotalGross = 0\n let subtotalNet = 0\n let subtotalGross = 0\n let discountTotal = 0\n let taxTotal = 0\n let shippingNet = 0\n let shippingGross = 0\n let surchargeTotal = 0\n\n for (const line of lines) {\n const net = toNumber(line.netAmount, 0)\n const gross = toNumber(line.grossAmount, 0)\n subtotalNet += net\n subtotalGross += gross\n baseSubtotalNet += net\n baseSubtotalGross += gross\n discountTotal += toNumber(line.discountAmount, 0)\n taxTotal += toNumber(line.taxAmount, 0)\n }\n\n const resolvedAdjustments = resolveAdjustmentAmounts(orderedAdjustments, baseSubtotalNet, baseSubtotalGross)\n const scopedAdjustments = resolvedAdjustments.filter(\n (adj) => !adj.scope || adj.scope === 'order'\n )\n\n for (const adj of scopedAdjustments) {\n const rawNet = toNumber(adj.amountNet, toNumber(adj.amountGross))\n const rawGross = toNumber(adj.amountGross, rawNet)\n // Each adjustment kind has an intrinsic sign convention. The API edge\n // (enforceAdjustmentSign) rejects values that would invert the kind's\n // semantic effect, but the calculation engine normalizes defensively so\n // direct DB writes or seeded data can't inflate the grand total either.\n // See #1905 (mirrors the existing return normalization a few lines below\n // introduced for #1705).\n const isNonNegativeKind =\n adj.kind === 'discount' ||\n adj.kind === 'surcharge' ||\n adj.kind === 'shipping' ||\n adj.kind === 'tax'\n const net = isNonNegativeKind ? Math.abs(rawNet) : rawNet\n const gross = isNonNegativeKind ? Math.abs(rawGross) : rawGross\n const taxRate = extractAdjustmentTaxRate(adj)\n const taxPortion = taxRate !== null ? round(gross - net) : 0\n switch (adj.kind) {\n case 'discount':\n discountTotal += net\n subtotalNet = Math.max(subtotalNet - net, 0)\n subtotalGross = Math.max(subtotalGross - gross, 0)\n if (taxPortion) {\n taxTotal = round(taxTotal - taxPortion)\n }\n break\n case 'tax':\n taxTotal += gross || net\n subtotalGross += gross || net\n break\n case 'shipping':\n shippingNet += net\n shippingGross += gross\n subtotalNet += net\n subtotalGross += gross\n if (taxPortion) {\n taxTotal += taxPortion\n }\n break\n case 'surcharge':\n surchargeTotal += net || gross\n subtotalNet += net || gross\n subtotalGross += gross || net\n if (taxPortion) {\n taxTotal += taxPortion\n }\n break\n default:\n // `return` (credit) adjustments are handled by the dedicated loop below;\n // skip them here so an order-scoped return is not counted twice.\n if (adj.kind === 'return') break\n // Custom / operator-defined kinds carry an operator-controlled sign\n // (positive adds, negative credits). Fold the raw signed amount into the\n // grand total so a persisted adjustment can never be silently dropped\n // from the headline total while still appearing in the itemized\n // breakdown (#4052). No abs()/clamp here: unlike the sign-constrained\n // kinds above, custom kinds are intentionally unconstrained\n // (see enforceAdjustmentSign).\n subtotalNet += net\n subtotalGross += gross\n if (taxPortion) {\n taxTotal += taxPortion\n }\n break\n }\n }\n\n // Line-scoped and any other return (credit) adjustments reduce grand total.\n // Sign is normalized to negative regardless of the stored sign so a positive\n // amountNet / amountGross can never inflate totals (issue #1705).\n for (const adj of resolvedAdjustments) {\n if (adj.kind !== 'return') continue\n const net = toNumber(adj.amountNet, toNumber(adj.amountGross))\n const gross = toNumber(adj.amountGross, net)\n const netDelta = -Math.abs(net)\n const grossDelta = -Math.abs(gross)\n subtotalNet = Math.max(subtotalNet + netDelta, 0)\n subtotalGross = Math.max(subtotalGross + grossDelta, 0)\n }\n\n const grandTotalNet = round(subtotalNet)\n const grandTotalGross = round(subtotalGross)\n const paidTotalAmount = Math.max(toNumber(params.existingTotals?.paidTotalAmount, 0), 0)\n const refundedTotalAmount = Math.max(toNumber(params.existingTotals?.refundedTotalAmount, 0), 0)\n const outstandingAmount = Math.max(grandTotalGross - paidTotalAmount + refundedTotalAmount, 0)\n\n return {\n kind: documentKind,\n currencyCode,\n lines,\n adjustments: resolvedAdjustments,\n metadata: {},\n totals: {\n subtotalNetAmount: round(subtotalNet),\n subtotalGrossAmount: round(subtotalGross),\n discountTotalAmount: round(discountTotal),\n taxTotalAmount: round(taxTotal),\n shippingNetAmount: round(shippingNet),\n shippingGrossAmount: round(shippingGross),\n surchargeTotalAmount: round(surchargeTotal),\n grandTotalNetAmount: grandTotalNet,\n grandTotalGrossAmount: grandTotalGross,\n paidTotalAmount,\n refundedTotalAmount,\n outstandingAmount,\n },\n }\n}\n\nclass SalesCalculationRegistry {\n private lineCalculators: SalesLineCalculationHook[] = []\n private totalsCalculators: SalesTotalsCalculationHook[] = []\n\n registerLineCalculator(hook: SalesLineCalculationHook, opts?: { prepend?: boolean }): () => void {\n if (opts?.prepend) this.lineCalculators.unshift(hook)\n else this.lineCalculators.push(hook)\n return () => {\n this.lineCalculators = this.lineCalculators.filter((item) => item !== hook)\n }\n }\n\n registerTotalsCalculator(hook: SalesTotalsCalculationHook, opts?: { prepend?: boolean }): () => void {\n if (opts?.prepend) this.totalsCalculators.unshift(hook)\n else this.totalsCalculators.push(hook)\n return () => {\n this.totalsCalculators = this.totalsCalculators.filter((item) => item !== hook)\n }\n }\n\n async calculateLine(opts: CalculateLineOptions): Promise<SalesLineCalculationResult> {\n const { documentKind, line, context, eventBus } = opts\n let current = buildBaseLineResult(line)\n\n if (eventBus) {\n await eventBus.emitEvent('sales.line.calculate.before', {\n documentKind,\n line,\n context,\n result: current,\n setResult(next: SalesLineCalculationResult) {\n current = next\n },\n })\n }\n\n for (const hook of this.lineCalculators) {\n const next = await hook({ documentKind, line, context, current })\n if (next) current = next\n }\n\n if (eventBus) {\n await eventBus.emitEvent('sales.line.calculate.after', {\n documentKind,\n line,\n context,\n result: current,\n setResult(next: SalesLineCalculationResult) {\n current = next\n },\n })\n }\n\n return current\n }\n\n async calculateDocument(opts: CalculateDocumentOptions): Promise<SalesDocumentCalculationResult> {\n const { documentKind, lines, adjustments = [], context, eventBus, existingTotals } = opts\n const resolvedLines: SalesLineCalculationResult[] = []\n\n for (const line of lines) {\n const result = await this.calculateLine({ documentKind, line, context, eventBus })\n resolvedLines.push(result)\n }\n\n let current = buildBaseDocumentResult({\n documentKind,\n lines: resolvedLines,\n adjustments,\n currencyCode: context.currencyCode,\n existingTotals,\n })\n\n if (eventBus) {\n await eventBus.emitEvent('sales.document.calculate.before', {\n documentKind,\n lines: resolvedLines,\n context,\n adjustments,\n result: current,\n setResult(next: SalesDocumentCalculationResult) {\n current = next\n },\n })\n }\n\n for (const hook of this.totalsCalculators) {\n const next = await hook({\n documentKind,\n lines: resolvedLines,\n existingAdjustments: adjustments,\n context,\n current,\n eventBus,\n })\n if (next) current = next\n }\n\n if (eventBus) {\n await eventBus.emitEvent('sales.document.calculate.after', {\n documentKind,\n lines: resolvedLines,\n context,\n adjustments,\n result: current,\n setResult(next: SalesDocumentCalculationResult) {\n current = next\n },\n })\n }\n\n // Payment totals (paid/refunded) are authoritative inputs, not derived from\n // lines or adjustments. Totals calculators rebuild the document result from\n // lines+adjustments and would otherwise reset paid/refunded to 0 (and\n // outstanding back to the full grand total), producing a stale paid/\n // outstanding display after a payment. Re-apply the input totals last and\n // recompute outstanding against the post-calculation grand total.\n if (existingTotals) {\n const paidTotalAmount = Math.max(toNumber(existingTotals.paidTotalAmount, 0), 0)\n const refundedTotalAmount = Math.max(toNumber(existingTotals.refundedTotalAmount, 0), 0)\n current.totals = {\n ...current.totals,\n paidTotalAmount,\n refundedTotalAmount,\n outstandingAmount: round(\n Math.max(current.totals.grandTotalGrossAmount - paidTotalAmount + refundedTotalAmount, 0)\n ),\n }\n }\n\n return current\n }\n}\n\nexport function createSalesCalculationRegistry(): SalesCalculationRegistry {\n return new SalesCalculationRegistry()\n}\n\nexport const salesCalculations = createSalesCalculationRegistry()\n\nexport async function calculateLine(\n opts: CalculateLineOptions\n): Promise<SalesLineCalculationResult> {\n return salesCalculations.calculateLine(opts)\n}\n\nexport async function calculateDocumentTotals(\n opts: CalculateDocumentOptions\n): Promise<SalesDocumentCalculationResult> {\n return salesCalculations.calculateDocument(opts)\n}\n\nexport function registerSalesLineCalculator(\n hook: SalesLineCalculationHook,\n opts?: { prepend?: boolean }\n): () => void {\n return salesCalculations.registerLineCalculator(hook, opts)\n}\n\nexport function registerSalesTotalsCalculator(\n hook: SalesTotalsCalculationHook,\n opts?: { prepend?: boolean }\n): () => void {\n return salesCalculations.registerTotalsCalculator(hook, opts)\n}\n\nexport function rebuildDocumentResult(params: {\n documentKind: SalesDocumentKind\n currencyCode: string\n lines: SalesLineCalculationResult[]\n adjustments: SalesAdjustmentDraft[]\n metadata?: Record<string, unknown>\n}): SalesDocumentCalculationResult {\n const result = buildBaseDocumentResult({\n documentKind: params.documentKind,\n lines: params.lines,\n adjustments: params.adjustments,\n currencyCode: params.currencyCode,\n })\n result.metadata = params.metadata ?? {}\n return result\n}\n"],
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport {\n type SalesAdjustmentDraft,\n type SalesCalculationContext,\n type CalculateDocumentOptions,\n type CalculateLineOptions,\n type SalesDocumentCalculationResult,\n type SalesDocumentKind,\n type SalesLineCalculationHook,\n type SalesLineCalculationResult,\n type SalesLineSnapshot,\n type SalesTotalsCalculationHook,\n} from './types'\n\nconst logger = createLogger('sales')\n\nfunction toNumber(value: unknown, fallback = 0): number {\n if (typeof value === 'number' && Number.isFinite(value)) return value\n if (typeof value === 'string' && value.trim() !== '' && !Number.isNaN(Number(value))) {\n return Number(value)\n }\n return fallback\n}\n\nfunction round(value: number): number {\n return Math.round((value + Number.EPSILON) * 1e4) / 1e4\n}\n\n// The engine rounds to the 4 decimals the numeric columns carry, but callers\n// work in money at 2, so an exact comparison would report half a cent of\n// honest rounding as a mismatch. Half a minor unit is the widest divergence\n// that cannot be a real discrepancy and the narrowest that silences that noise.\nconst NET_RECONCILIATION_TOLERANCE = 0.005\n\nfunction extractAdjustmentTaxRate(adjustment: SalesAdjustmentDraft): number | null {\n const metadata = (adjustment.metadata ?? {}) as Record<string, unknown>\n const candidate =\n metadata.taxRate ??\n (metadata as any)?.tax_rate ??\n (metadata as any)?.taxRateValue ??\n (metadata as any)?.tax_rate_value ??\n null\n const parsed = toNumber(candidate, NaN)\n return Number.isFinite(parsed) ? parsed : null\n}\n\nfunction resolveAdjustmentAmounts(\n adjustments: SalesAdjustmentDraft[],\n baseNet: number,\n baseGross: number\n): SalesAdjustmentDraft[] {\n return adjustments.map((adj) => {\n const rate = toNumber(adj.rate, NaN)\n const taxRate = extractAdjustmentTaxRate(adj)\n const hasAmountNet = Number.isFinite(toNumber(adj.amountNet, NaN))\n const hasAmountGross = Number.isFinite(toNumber(adj.amountGross, NaN))\n const hasRate = Number.isFinite(rate) && !hasAmountNet && !hasAmountGross\n const hasTaxRate = taxRate !== null\n let amountNet = toNumber(adj.amountNet, NaN)\n let amountGross = toNumber(adj.amountGross, NaN)\n\n if (hasRate) {\n const multiplier = (rate as number) / 100\n amountNet = round(Math.max(baseNet, 0) * multiplier)\n if (adj.kind === 'tax') {\n amountGross = amountNet\n } else if (hasTaxRate) {\n amountGross = round(amountNet * (1 + (taxRate as number) / 100))\n } else {\n amountGross = round(Math.max(baseGross, 0) * multiplier)\n }\n } else {\n if (!Number.isFinite(amountNet) && Number.isFinite(amountGross) && hasTaxRate) {\n amountNet = round((amountGross as number) / (1 + (taxRate as number) / 100))\n }\n if (!Number.isFinite(amountGross) && Number.isFinite(amountNet) && hasTaxRate) {\n amountGross = round((amountNet as number) * (1 + (taxRate as number) / 100))\n }\n }\n\n return {\n ...adj,\n amountNet: Number.isFinite(amountNet) ? amountNet : adj.amountNet,\n amountGross: Number.isFinite(amountGross) ? amountGross : adj.amountGross,\n }\n })\n}\n\n// `discount_amount` stores the discount for the WHOLE line, while\n// `discount_percent` records the operator's intent. The percentage therefore\n// wins whenever it is set: a stored amount is only ever its cached result, and\n// because the column is NOT NULL DEFAULT '0' a stored 0 cannot be told apart\n// from \"no discount supplied\" \u2014 so it counts as absent rather than as a\n// suppressing value. That is what makes recalculation idempotent, and what lets\n// a row whose amount the old engine dropped or re-inflated heal itself on the\n// next pass. Spec: .ai/specs/2026-08-07-sales-line-discount-amount-contract.md.\nfunction resolveLineDiscountTotal(\n line: SalesLineSnapshot,\n netSubtotalBeforeDiscount: number,\n quantity: number,\n): number {\n const percent = toNumber(line.discountPercent, 0)\n if (line.discountPercent !== null && line.discountPercent !== undefined && percent !== 0) {\n return (percent / 100) * netSubtotalBeforeDiscount\n }\n\n const amount = toNumber(line.discountAmount, 0)\n if (line.discountAmount === null || line.discountAmount === undefined || amount === 0) return 0\n\n // A snapshot rebuilt from a persisted row already holds a line total, so it\n // is never multiplied out again. Anything else came from a caller and keeps\n // the per-unit meaning the API has always documented unless the caller says\n // otherwise.\n if (line.discountAmountFromStoredRow === true) return amount\n return line.discountAmountBasis === 'line' ? amount : amount * quantity\n}\n\nfunction buildBaseLineResult(line: SalesLineSnapshot): SalesLineCalculationResult {\n const quantity = Math.max(toNumber(line.quantity, 0), 0)\n const taxRate = toNumber(line.taxRate, 0) / 100\n const unitNet =\n line.unitPriceNet ??\n (line.unitPriceGross !== null && line.unitPriceGross !== undefined\n ? toNumber(line.unitPriceGross) / (1 + taxRate)\n : 0)\n const netSubtotalBeforeDiscount = toNumber(unitNet, 0) * quantity\n const discountTotal = Math.min(\n Math.max(resolveLineDiscountTotal(line, netSubtotalBeforeDiscount, quantity), 0),\n netSubtotalBeforeDiscount,\n )\n const netSubtotal = Math.max(netSubtotalBeforeDiscount - discountTotal, 0)\n // Unlike totalGrossAmount below, a supplied totalNetAmount is never honoured\n // verbatim \u2014 net always comes from unitPriceNet/discount so it stays\n // internally consistent with them. A caller-supplied value is still\n // reconciled against the computed one so a divergence (e.g. a mis-read\n // discount) surfaces instead of being silently discarded (#5644).\n //\n // Only a caller's value is reconciled: a snapshot rebuilt from a persisted\n // row (`totalsFromStoredRow`) carries the engine's own previous output, and\n // on a row the discount contract still has to heal that value is *supposed*\n // to differ from the recomputed net. Warning about it would drown the caller\n // signal this exists for in one line per line per recalculation.\n if (line.totalsFromStoredRow !== true && line.totalNetAmount !== null && line.totalNetAmount !== undefined) {\n const computedNetAmount = round(netSubtotal)\n const suppliedNetAmount = toNumber(line.totalNetAmount, NaN)\n if (!Number.isFinite(suppliedNetAmount)) {\n // Falling back to the computed value here would compare equal and log\n // nothing \u2014 the same silent discard #5644 exists to end.\n logger.warn('Sales line totalNetAmount is not a finite number; the computed value is used', {\n lineId: line.id ?? null,\n productId: line.productId ?? null,\n suppliedTotalNetAmount: line.totalNetAmount,\n computedNetAmount,\n })\n } else if (Math.abs(round(suppliedNetAmount) - computedNetAmount) > NET_RECONCILIATION_TOLERANCE) {\n logger.warn('Sales line totalNetAmount does not match the computed net amount; the computed value is used', {\n lineId: line.id ?? null,\n productId: line.productId ?? null,\n suppliedTotalNetAmount: round(suppliedNetAmount),\n computedNetAmount,\n })\n }\n }\n const explicitTaxAmount = line.taxAmount !== null && line.taxAmount !== undefined\n let taxAmount = explicitTaxAmount\n ? toNumber(line.taxAmount, 0)\n : round(netSubtotal * Math.max(taxRate, 0))\n const grossSubtotal =\n line.totalGrossAmount !== null && line.totalGrossAmount !== undefined\n ? toNumber(line.totalGrossAmount, 0)\n : round(netSubtotal + taxAmount)\n // When tax was not supplied explicitly and the rate-derived tax is zero but\n // the gross total already embeds tax (gross > net) \u2014 e.g. a tax-class-priced\n // line whose resolved rate was not persisted \u2014 derive the tax from the\n // net/gross delta so the document-level tax total is not silently zeroed\n // while per-line net/gross stay correct (#2457).\n if (!explicitTaxAmount && taxAmount <= 0) {\n const grossNetDelta = round(grossSubtotal - netSubtotal)\n if (grossNetDelta > 0) taxAmount = grossNetDelta\n }\n\n return {\n line,\n netAmount: round(netSubtotal),\n grossAmount: round(grossSubtotal),\n taxAmount: round(taxAmount),\n discountAmount: round(discountTotal),\n adjustments: [],\n }\n}\n\nfunction buildBaseDocumentResult(params: {\n documentKind: SalesDocumentKind\n lines: SalesLineCalculationResult[]\n adjustments: SalesAdjustmentDraft[]\n currencyCode: string\n existingTotals?: { paidTotalAmount?: number | null; refundedTotalAmount?: number | null }\n}): SalesDocumentCalculationResult {\n const { documentKind, lines, adjustments, currencyCode } = params\n const orderedAdjustments = [...(adjustments ?? [])].sort(\n (a, b) => (a.position ?? 0) - (b.position ?? 0)\n )\n let baseSubtotalNet = 0\n let baseSubtotalGross = 0\n let subtotalNet = 0\n let subtotalGross = 0\n let discountTotal = 0\n let taxTotal = 0\n let shippingNet = 0\n let shippingGross = 0\n let surchargeTotal = 0\n\n for (const line of lines) {\n const net = toNumber(line.netAmount, 0)\n const gross = toNumber(line.grossAmount, 0)\n subtotalNet += net\n subtotalGross += gross\n baseSubtotalNet += net\n baseSubtotalGross += gross\n discountTotal += toNumber(line.discountAmount, 0)\n taxTotal += toNumber(line.taxAmount, 0)\n }\n\n const resolvedAdjustments = resolveAdjustmentAmounts(orderedAdjustments, baseSubtotalNet, baseSubtotalGross)\n const scopedAdjustments = resolvedAdjustments.filter(\n (adj) => !adj.scope || adj.scope === 'order'\n )\n\n for (const adj of scopedAdjustments) {\n const rawNet = toNumber(adj.amountNet, toNumber(adj.amountGross))\n const rawGross = toNumber(adj.amountGross, rawNet)\n // Each adjustment kind has an intrinsic sign convention. The API edge\n // (enforceAdjustmentSign) rejects values that would invert the kind's\n // semantic effect, but the calculation engine normalizes defensively so\n // direct DB writes or seeded data can't inflate the grand total either.\n // See #1905 (mirrors the existing return normalization a few lines below\n // introduced for #1705).\n const isNonNegativeKind =\n adj.kind === 'discount' ||\n adj.kind === 'surcharge' ||\n adj.kind === 'shipping' ||\n adj.kind === 'tax'\n const net = isNonNegativeKind ? Math.abs(rawNet) : rawNet\n const gross = isNonNegativeKind ? Math.abs(rawGross) : rawGross\n const taxRate = extractAdjustmentTaxRate(adj)\n const taxPortion = taxRate !== null ? round(gross - net) : 0\n switch (adj.kind) {\n case 'discount':\n discountTotal += net\n subtotalNet = Math.max(subtotalNet - net, 0)\n subtotalGross = Math.max(subtotalGross - gross, 0)\n if (taxPortion) {\n taxTotal = round(taxTotal - taxPortion)\n }\n break\n case 'tax':\n taxTotal += gross || net\n subtotalGross += gross || net\n break\n case 'shipping':\n shippingNet += net\n shippingGross += gross\n subtotalNet += net\n subtotalGross += gross\n if (taxPortion) {\n taxTotal += taxPortion\n }\n break\n case 'surcharge':\n surchargeTotal += net || gross\n subtotalNet += net || gross\n subtotalGross += gross || net\n if (taxPortion) {\n taxTotal += taxPortion\n }\n break\n default:\n // `return` (credit) adjustments are handled by the dedicated loop below;\n // skip them here so an order-scoped return is not counted twice.\n if (adj.kind === 'return') break\n // Custom / operator-defined kinds carry an operator-controlled sign\n // (positive adds, negative credits). Fold the raw signed amount into the\n // grand total so a persisted adjustment can never be silently dropped\n // from the headline total while still appearing in the itemized\n // breakdown (#4052). No abs()/clamp here: unlike the sign-constrained\n // kinds above, custom kinds are intentionally unconstrained\n // (see enforceAdjustmentSign).\n subtotalNet += net\n subtotalGross += gross\n if (taxPortion) {\n taxTotal += taxPortion\n }\n break\n }\n }\n\n // Line-scoped and any other return (credit) adjustments reduce grand total.\n // Sign is normalized to negative regardless of the stored sign so a positive\n // amountNet / amountGross can never inflate totals (issue #1705).\n for (const adj of resolvedAdjustments) {\n if (adj.kind !== 'return') continue\n const net = toNumber(adj.amountNet, toNumber(adj.amountGross))\n const gross = toNumber(adj.amountGross, net)\n const netDelta = -Math.abs(net)\n const grossDelta = -Math.abs(gross)\n subtotalNet = Math.max(subtotalNet + netDelta, 0)\n subtotalGross = Math.max(subtotalGross + grossDelta, 0)\n }\n\n const grandTotalNet = round(subtotalNet)\n const grandTotalGross = round(subtotalGross)\n const paidTotalAmount = Math.max(toNumber(params.existingTotals?.paidTotalAmount, 0), 0)\n const refundedTotalAmount = Math.max(toNumber(params.existingTotals?.refundedTotalAmount, 0), 0)\n const outstandingAmount = Math.max(grandTotalGross - paidTotalAmount + refundedTotalAmount, 0)\n\n return {\n kind: documentKind,\n currencyCode,\n lines,\n adjustments: resolvedAdjustments,\n metadata: {},\n totals: {\n subtotalNetAmount: round(subtotalNet),\n subtotalGrossAmount: round(subtotalGross),\n discountTotalAmount: round(discountTotal),\n taxTotalAmount: round(taxTotal),\n shippingNetAmount: round(shippingNet),\n shippingGrossAmount: round(shippingGross),\n surchargeTotalAmount: round(surchargeTotal),\n grandTotalNetAmount: grandTotalNet,\n grandTotalGrossAmount: grandTotalGross,\n paidTotalAmount,\n refundedTotalAmount,\n outstandingAmount,\n },\n }\n}\n\nclass SalesCalculationRegistry {\n private lineCalculators: SalesLineCalculationHook[] = []\n private totalsCalculators: SalesTotalsCalculationHook[] = []\n\n registerLineCalculator(hook: SalesLineCalculationHook, opts?: { prepend?: boolean }): () => void {\n if (opts?.prepend) this.lineCalculators.unshift(hook)\n else this.lineCalculators.push(hook)\n return () => {\n this.lineCalculators = this.lineCalculators.filter((item) => item !== hook)\n }\n }\n\n registerTotalsCalculator(hook: SalesTotalsCalculationHook, opts?: { prepend?: boolean }): () => void {\n if (opts?.prepend) this.totalsCalculators.unshift(hook)\n else this.totalsCalculators.push(hook)\n return () => {\n this.totalsCalculators = this.totalsCalculators.filter((item) => item !== hook)\n }\n }\n\n async calculateLine(opts: CalculateLineOptions): Promise<SalesLineCalculationResult> {\n const { documentKind, line, context, eventBus } = opts\n let current = buildBaseLineResult(line)\n\n if (eventBus) {\n await eventBus.emitEvent('sales.line.calculate.before', {\n documentKind,\n line,\n context,\n result: current,\n setResult(next: SalesLineCalculationResult) {\n current = next\n },\n })\n }\n\n for (const hook of this.lineCalculators) {\n const next = await hook({ documentKind, line, context, current })\n if (next) current = next\n }\n\n if (eventBus) {\n await eventBus.emitEvent('sales.line.calculate.after', {\n documentKind,\n line,\n context,\n result: current,\n setResult(next: SalesLineCalculationResult) {\n current = next\n },\n })\n }\n\n return current\n }\n\n async calculateDocument(opts: CalculateDocumentOptions): Promise<SalesDocumentCalculationResult> {\n const { documentKind, lines, adjustments = [], context, eventBus, existingTotals } = opts\n const resolvedLines: SalesLineCalculationResult[] = []\n\n for (const line of lines) {\n const result = await this.calculateLine({ documentKind, line, context, eventBus })\n resolvedLines.push(result)\n }\n\n let current = buildBaseDocumentResult({\n documentKind,\n lines: resolvedLines,\n adjustments,\n currencyCode: context.currencyCode,\n existingTotals,\n })\n\n if (eventBus) {\n await eventBus.emitEvent('sales.document.calculate.before', {\n documentKind,\n lines: resolvedLines,\n context,\n adjustments,\n result: current,\n setResult(next: SalesDocumentCalculationResult) {\n current = next\n },\n })\n }\n\n for (const hook of this.totalsCalculators) {\n const next = await hook({\n documentKind,\n lines: resolvedLines,\n existingAdjustments: adjustments,\n context,\n current,\n eventBus,\n })\n if (next) current = next\n }\n\n if (eventBus) {\n await eventBus.emitEvent('sales.document.calculate.after', {\n documentKind,\n lines: resolvedLines,\n context,\n adjustments,\n result: current,\n setResult(next: SalesDocumentCalculationResult) {\n current = next\n },\n })\n }\n\n // Payment totals (paid/refunded) are authoritative inputs, not derived from\n // lines or adjustments. Totals calculators rebuild the document result from\n // lines+adjustments and would otherwise reset paid/refunded to 0 (and\n // outstanding back to the full grand total), producing a stale paid/\n // outstanding display after a payment. Re-apply the input totals last and\n // recompute outstanding against the post-calculation grand total.\n if (existingTotals) {\n const paidTotalAmount = Math.max(toNumber(existingTotals.paidTotalAmount, 0), 0)\n const refundedTotalAmount = Math.max(toNumber(existingTotals.refundedTotalAmount, 0), 0)\n current.totals = {\n ...current.totals,\n paidTotalAmount,\n refundedTotalAmount,\n outstandingAmount: round(\n Math.max(current.totals.grandTotalGrossAmount - paidTotalAmount + refundedTotalAmount, 0)\n ),\n }\n }\n\n return current\n }\n}\n\nexport function createSalesCalculationRegistry(): SalesCalculationRegistry {\n return new SalesCalculationRegistry()\n}\n\nexport const salesCalculations = createSalesCalculationRegistry()\n\nexport async function calculateLine(\n opts: CalculateLineOptions\n): Promise<SalesLineCalculationResult> {\n return salesCalculations.calculateLine(opts)\n}\n\nexport async function calculateDocumentTotals(\n opts: CalculateDocumentOptions\n): Promise<SalesDocumentCalculationResult> {\n return salesCalculations.calculateDocument(opts)\n}\n\nexport function registerSalesLineCalculator(\n hook: SalesLineCalculationHook,\n opts?: { prepend?: boolean }\n): () => void {\n return salesCalculations.registerLineCalculator(hook, opts)\n}\n\nexport function registerSalesTotalsCalculator(\n hook: SalesTotalsCalculationHook,\n opts?: { prepend?: boolean }\n): () => void {\n return salesCalculations.registerTotalsCalculator(hook, opts)\n}\n\nexport function rebuildDocumentResult(params: {\n documentKind: SalesDocumentKind\n currencyCode: string\n lines: SalesLineCalculationResult[]\n adjustments: SalesAdjustmentDraft[]\n metadata?: Record<string, unknown>\n}): SalesDocumentCalculationResult {\n const result = buildBaseDocumentResult({\n documentKind: params.documentKind,\n lines: params.lines,\n adjustments: params.adjustments,\n currencyCode: params.currencyCode,\n })\n result.metadata = params.metadata ?? {}\n return result\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -35,6 +35,11 @@ function mapPersistedLine(line) {
|
|
|
35
35
|
discountPercent: toNumeric(line.discountPercent),
|
|
36
36
|
taxRate: toNumeric(line.taxRate),
|
|
37
37
|
taxAmount: toNumeric(line.taxAmount),
|
|
38
|
+
// The totals below are the engine's own previous output read back off the
|
|
39
|
+
// row, not something a caller asserted, so they are not reconciled against
|
|
40
|
+
// the recomputed net (#5644) — on a legacy row that divergence is the
|
|
41
|
+
// discount contract healing itself, not a caller mistake.
|
|
42
|
+
totalsFromStoredRow: true,
|
|
38
43
|
totalNetAmount: toNumeric(line.totalNetAmount),
|
|
39
44
|
totalGrossAmount: toNumeric(line.totalGrossAmount),
|
|
40
45
|
configuration: line.configuration ? cloneJson(line.configuration) : null,
|
|
@@ -58,9 +63,14 @@ function resolveUpsertDiscountFields(callerAmount, callerBasis, existingSnapshot
|
|
|
58
63
|
discountAmountFromStoredRow: existingSnapshot != null
|
|
59
64
|
};
|
|
60
65
|
}
|
|
66
|
+
function resolveUpsertTotalsOrigin(callerTotalNetAmount, existingSnapshot) {
|
|
67
|
+
if (callerTotalNetAmount !== null && callerTotalNetAmount !== void 0) return {};
|
|
68
|
+
return existingSnapshot != null ? { totalsFromStoredRow: true } : {};
|
|
69
|
+
}
|
|
61
70
|
export {
|
|
62
71
|
mapOrderLineEntityToSnapshot,
|
|
63
72
|
mapQuoteLineEntityToSnapshot,
|
|
64
|
-
resolveUpsertDiscountFields
|
|
73
|
+
resolveUpsertDiscountFields,
|
|
74
|
+
resolveUpsertTotalsOrigin
|
|
65
75
|
};
|
|
66
76
|
//# sourceMappingURL=lineSnapshots.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/sales/lib/lineSnapshots.ts"],
|
|
4
|
-
"sourcesContent": ["import type { SalesOrderLine, SalesQuoteLine } from '../data/entities'\nimport { cloneJson } from './json'\nimport type { SalesLineDiscountBasis, SalesLineSnapshot } from './types'\n\nfunction toNumeric(value: unknown): number {\n if (typeof value === 'number' && Number.isFinite(value)) return value\n if (typeof value === 'string' && value.trim().length) {\n const parsed = Number(value)\n if (Number.isFinite(parsed)) return parsed\n }\n return 0\n}\n\nfunction mapPersistedLine(line: SalesOrderLine | SalesQuoteLine): SalesLineSnapshot {\n return {\n id: line.id,\n lineNumber: line.lineNumber,\n kind: line.kind,\n productId: line.productId ?? null,\n productVariantId: line.productVariantId ?? null,\n name: line.name ?? null,\n description: line.description ?? null,\n comment: line.comment ?? null,\n quantity: toNumeric(line.quantity),\n quantityUnit: line.quantityUnit ?? null,\n normalizedQuantity: toNumeric(line.normalizedQuantity ?? line.quantity),\n normalizedUnit: line.normalizedUnit ?? line.quantityUnit ?? null,\n uomSnapshot: line.uomSnapshot ? cloneJson(line.uomSnapshot) : null,\n currencyCode: line.currencyCode,\n unitPriceNet: toNumeric(line.unitPriceNet),\n unitPriceGross: toNumeric(line.unitPriceGross),\n discountAmount: toNumeric(line.discountAmount),\n // The persisted column holds the discount for the whole line, so the\n // calculation engine must not multiply it out by quantity again. This flag\n // is the only thing standing between a round trip and a compounding\n // re-inflation, and it is why mappers must never set `discountAmountBasis`\n // instead \u2014 that one means \"a caller asserted this\".\n discountAmountFromStoredRow: true,\n discountPercent: toNumeric(line.discountPercent),\n taxRate: toNumeric(line.taxRate),\n taxAmount: toNumeric(line.taxAmount),\n totalNetAmount: toNumeric(line.totalNetAmount),\n totalGrossAmount: toNumeric(line.totalGrossAmount),\n configuration: line.configuration ? cloneJson(line.configuration) : null,\n promotionCode: line.promotionCode ?? null,\n metadata: line.metadata ? cloneJson(line.metadata) : null,\n customFieldSetId: line.customFieldSetId ?? null,\n }\n}\n\n/**\n * Rebuild a calculation snapshot from a persisted order line.\n *\n * Shared by `commands/documents.ts` and `commands/returns.ts` on purpose: the\n * two files used to carry byte-identical copies, and that duplication is why\n * the return flows kept the discount defect after the order flows were fixed.\n */\nexport function mapOrderLineEntityToSnapshot(line: SalesOrderLine): SalesLineSnapshot {\n return mapPersistedLine(line)\n}\n\nexport function mapQuoteLineEntityToSnapshot(line: SalesQuoteLine): SalesLineSnapshot {\n return mapPersistedLine(line)\n}\n\ntype UpsertDiscountFields = Pick<\n SalesLineSnapshot,\n 'discountAmount' | 'discountAmountBasis' | 'discountAmountFromStoredRow'\n>\n\n/**\n * Decide the discount fields of an upsert payload one operand at a time.\n *\n * A line upsert can source its amount from two places with opposite meanings: a\n * caller value, which is per unit unless the caller says otherwise, and the\n * stored row, which is always a line total. Collapsing them into a single\n * `caller ?? stored` expression means whichever origin the merged result is\n * tagged with is wrong half the time \u2014 and the half that breaks is the\n * upsert-existing path, which then re-inflates by a further factor of quantity\n * while looking perfectly correct.\n *\n * Keeping `null` rather than `0` for \"nothing supplied\" is deliberate too: the\n * column cannot represent that distinction, but the payload can, and the\n * calculation engine needs it to tell a suppressing zero from an absent value.\n */\nexport function resolveUpsertDiscountFields(\n callerAmount: number | null | undefined,\n callerBasis: SalesLineDiscountBasis | null | undefined,\n existingSnapshot: Pick<SalesLineSnapshot, 'discountAmount'> | null | undefined,\n): UpsertDiscountFields {\n if (callerAmount !== null && callerAmount !== undefined) {\n return { discountAmount: callerAmount, discountAmountBasis: callerBasis ?? 'unit' }\n }\n return {\n discountAmount: existingSnapshot?.discountAmount ?? null,\n discountAmountFromStoredRow: existingSnapshot != null,\n }\n}\n"],
|
|
5
|
-
"mappings": "AACA,SAAS,iBAAiB;AAG1B,SAAS,UAAU,OAAwB;AACzC,MAAI,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK,EAAG,QAAO;AAChE,MAAI,OAAO,UAAU,YAAY,MAAM,KAAK,EAAE,QAAQ;AACpD,UAAM,SAAS,OAAO,KAAK;AAC3B,QAAI,OAAO,SAAS,MAAM,EAAG,QAAO;AAAA,EACtC;AACA,SAAO;AACT;AAEA,SAAS,iBAAiB,MAA0D;AAClF,SAAO;AAAA,IACL,IAAI,KAAK;AAAA,IACT,YAAY,KAAK;AAAA,IACjB,MAAM,KAAK;AAAA,IACX,WAAW,KAAK,aAAa;AAAA,IAC7B,kBAAkB,KAAK,oBAAoB;AAAA,IAC3C,MAAM,KAAK,QAAQ;AAAA,IACnB,aAAa,KAAK,eAAe;AAAA,IACjC,SAAS,KAAK,WAAW;AAAA,IACzB,UAAU,UAAU,KAAK,QAAQ;AAAA,IACjC,cAAc,KAAK,gBAAgB;AAAA,IACnC,oBAAoB,UAAU,KAAK,sBAAsB,KAAK,QAAQ;AAAA,IACtE,gBAAgB,KAAK,kBAAkB,KAAK,gBAAgB;AAAA,IAC5D,aAAa,KAAK,cAAc,UAAU,KAAK,WAAW,IAAI;AAAA,IAC9D,cAAc,KAAK;AAAA,IACnB,cAAc,UAAU,KAAK,YAAY;AAAA,IACzC,gBAAgB,UAAU,KAAK,cAAc;AAAA,IAC7C,gBAAgB,UAAU,KAAK,cAAc;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAM7C,6BAA6B;AAAA,IAC7B,iBAAiB,UAAU,KAAK,eAAe;AAAA,IAC/C,SAAS,UAAU,KAAK,OAAO;AAAA,IAC/B,WAAW,UAAU,KAAK,SAAS;AAAA,
|
|
4
|
+
"sourcesContent": ["import type { SalesOrderLine, SalesQuoteLine } from '../data/entities'\nimport { cloneJson } from './json'\nimport type { SalesLineDiscountBasis, SalesLineSnapshot } from './types'\n\nfunction toNumeric(value: unknown): number {\n if (typeof value === 'number' && Number.isFinite(value)) return value\n if (typeof value === 'string' && value.trim().length) {\n const parsed = Number(value)\n if (Number.isFinite(parsed)) return parsed\n }\n return 0\n}\n\nfunction mapPersistedLine(line: SalesOrderLine | SalesQuoteLine): SalesLineSnapshot {\n return {\n id: line.id,\n lineNumber: line.lineNumber,\n kind: line.kind,\n productId: line.productId ?? null,\n productVariantId: line.productVariantId ?? null,\n name: line.name ?? null,\n description: line.description ?? null,\n comment: line.comment ?? null,\n quantity: toNumeric(line.quantity),\n quantityUnit: line.quantityUnit ?? null,\n normalizedQuantity: toNumeric(line.normalizedQuantity ?? line.quantity),\n normalizedUnit: line.normalizedUnit ?? line.quantityUnit ?? null,\n uomSnapshot: line.uomSnapshot ? cloneJson(line.uomSnapshot) : null,\n currencyCode: line.currencyCode,\n unitPriceNet: toNumeric(line.unitPriceNet),\n unitPriceGross: toNumeric(line.unitPriceGross),\n discountAmount: toNumeric(line.discountAmount),\n // The persisted column holds the discount for the whole line, so the\n // calculation engine must not multiply it out by quantity again. This flag\n // is the only thing standing between a round trip and a compounding\n // re-inflation, and it is why mappers must never set `discountAmountBasis`\n // instead \u2014 that one means \"a caller asserted this\".\n discountAmountFromStoredRow: true,\n discountPercent: toNumeric(line.discountPercent),\n taxRate: toNumeric(line.taxRate),\n taxAmount: toNumeric(line.taxAmount),\n // The totals below are the engine's own previous output read back off the\n // row, not something a caller asserted, so they are not reconciled against\n // the recomputed net (#5644) \u2014 on a legacy row that divergence is the\n // discount contract healing itself, not a caller mistake.\n totalsFromStoredRow: true,\n totalNetAmount: toNumeric(line.totalNetAmount),\n totalGrossAmount: toNumeric(line.totalGrossAmount),\n configuration: line.configuration ? cloneJson(line.configuration) : null,\n promotionCode: line.promotionCode ?? null,\n metadata: line.metadata ? cloneJson(line.metadata) : null,\n customFieldSetId: line.customFieldSetId ?? null,\n }\n}\n\n/**\n * Rebuild a calculation snapshot from a persisted order line.\n *\n * Shared by `commands/documents.ts` and `commands/returns.ts` on purpose: the\n * two files used to carry byte-identical copies, and that duplication is why\n * the return flows kept the discount defect after the order flows were fixed.\n */\nexport function mapOrderLineEntityToSnapshot(line: SalesOrderLine): SalesLineSnapshot {\n return mapPersistedLine(line)\n}\n\nexport function mapQuoteLineEntityToSnapshot(line: SalesQuoteLine): SalesLineSnapshot {\n return mapPersistedLine(line)\n}\n\ntype UpsertDiscountFields = Pick<\n SalesLineSnapshot,\n 'discountAmount' | 'discountAmountBasis' | 'discountAmountFromStoredRow'\n>\n\n/**\n * Decide the discount fields of an upsert payload one operand at a time.\n *\n * A line upsert can source its amount from two places with opposite meanings: a\n * caller value, which is per unit unless the caller says otherwise, and the\n * stored row, which is always a line total. Collapsing them into a single\n * `caller ?? stored` expression means whichever origin the merged result is\n * tagged with is wrong half the time \u2014 and the half that breaks is the\n * upsert-existing path, which then re-inflates by a further factor of quantity\n * while looking perfectly correct.\n *\n * Keeping `null` rather than `0` for \"nothing supplied\" is deliberate too: the\n * column cannot represent that distinction, but the payload can, and the\n * calculation engine needs it to tell a suppressing zero from an absent value.\n */\nexport function resolveUpsertDiscountFields(\n callerAmount: number | null | undefined,\n callerBasis: SalesLineDiscountBasis | null | undefined,\n existingSnapshot: Pick<SalesLineSnapshot, 'discountAmount'> | null | undefined,\n): UpsertDiscountFields {\n if (callerAmount !== null && callerAmount !== undefined) {\n return { discountAmount: callerAmount, discountAmountBasis: callerBasis ?? 'unit' }\n }\n return {\n discountAmount: existingSnapshot?.discountAmount ?? null,\n discountAmountFromStoredRow: existingSnapshot != null,\n }\n}\n\n/**\n * Decide whether the `totalNetAmount` an upsert payload ends up carrying is a\n * caller assertion or a value that came back off the stored row.\n *\n * The upsert merges caller input over the existing snapshot, so the merged\n * total has two possible origins and only the caller one is worth reconciling\n * against the recomputed net (#5644): a value read back off the row is what the\n * engine itself wrote last time, and on a legacy row it is exactly what\n * recalculation is supposed to heal.\n */\nexport function resolveUpsertTotalsOrigin(\n callerTotalNetAmount: number | null | undefined,\n existingSnapshot: Pick<SalesLineSnapshot, 'totalNetAmount'> | null | undefined,\n): Pick<SalesLineSnapshot, 'totalsFromStoredRow'> {\n if (callerTotalNetAmount !== null && callerTotalNetAmount !== undefined) return {}\n return existingSnapshot != null ? { totalsFromStoredRow: true } : {}\n}\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,iBAAiB;AAG1B,SAAS,UAAU,OAAwB;AACzC,MAAI,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK,EAAG,QAAO;AAChE,MAAI,OAAO,UAAU,YAAY,MAAM,KAAK,EAAE,QAAQ;AACpD,UAAM,SAAS,OAAO,KAAK;AAC3B,QAAI,OAAO,SAAS,MAAM,EAAG,QAAO;AAAA,EACtC;AACA,SAAO;AACT;AAEA,SAAS,iBAAiB,MAA0D;AAClF,SAAO;AAAA,IACL,IAAI,KAAK;AAAA,IACT,YAAY,KAAK;AAAA,IACjB,MAAM,KAAK;AAAA,IACX,WAAW,KAAK,aAAa;AAAA,IAC7B,kBAAkB,KAAK,oBAAoB;AAAA,IAC3C,MAAM,KAAK,QAAQ;AAAA,IACnB,aAAa,KAAK,eAAe;AAAA,IACjC,SAAS,KAAK,WAAW;AAAA,IACzB,UAAU,UAAU,KAAK,QAAQ;AAAA,IACjC,cAAc,KAAK,gBAAgB;AAAA,IACnC,oBAAoB,UAAU,KAAK,sBAAsB,KAAK,QAAQ;AAAA,IACtE,gBAAgB,KAAK,kBAAkB,KAAK,gBAAgB;AAAA,IAC5D,aAAa,KAAK,cAAc,UAAU,KAAK,WAAW,IAAI;AAAA,IAC9D,cAAc,KAAK;AAAA,IACnB,cAAc,UAAU,KAAK,YAAY;AAAA,IACzC,gBAAgB,UAAU,KAAK,cAAc;AAAA,IAC7C,gBAAgB,UAAU,KAAK,cAAc;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAM7C,6BAA6B;AAAA,IAC7B,iBAAiB,UAAU,KAAK,eAAe;AAAA,IAC/C,SAAS,UAAU,KAAK,OAAO;AAAA,IAC/B,WAAW,UAAU,KAAK,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA,IAKnC,qBAAqB;AAAA,IACrB,gBAAgB,UAAU,KAAK,cAAc;AAAA,IAC7C,kBAAkB,UAAU,KAAK,gBAAgB;AAAA,IACjD,eAAe,KAAK,gBAAgB,UAAU,KAAK,aAAa,IAAI;AAAA,IACpE,eAAe,KAAK,iBAAiB;AAAA,IACrC,UAAU,KAAK,WAAW,UAAU,KAAK,QAAQ,IAAI;AAAA,IACrD,kBAAkB,KAAK,oBAAoB;AAAA,EAC7C;AACF;AASO,SAAS,6BAA6B,MAAyC;AACpF,SAAO,iBAAiB,IAAI;AAC9B;AAEO,SAAS,6BAA6B,MAAyC;AACpF,SAAO,iBAAiB,IAAI;AAC9B;AAsBO,SAAS,4BACd,cACA,aACA,kBACsB;AACtB,MAAI,iBAAiB,QAAQ,iBAAiB,QAAW;AACvD,WAAO,EAAE,gBAAgB,cAAc,qBAAqB,eAAe,OAAO;AAAA,EACpF;AACA,SAAO;AAAA,IACL,gBAAgB,kBAAkB,kBAAkB;AAAA,IACpD,6BAA6B,oBAAoB;AAAA,EACnD;AACF;AAYO,SAAS,0BACd,sBACA,kBACgD;AAChD,MAAI,yBAAyB,QAAQ,yBAAyB,OAAW,QAAO,CAAC;AACjF,SAAO,oBAAoB,OAAO,EAAE,qBAAqB,KAAK,IAAI,CAAC;AACrE;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/modules/workflows/migrations/Migration20260901120000_reindex_workflow_definitions.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Migration } from "@mikro-orm/migrations";
|
|
2
|
+
import { declareQueryIndexReindex } from "@open-mercato/shared/lib/query/migration-reindex";
|
|
3
|
+
const queryIndexReindexEntityTypes = declareQueryIndexReindex([
|
|
4
|
+
"workflows:workflow_definition"
|
|
5
|
+
]);
|
|
6
|
+
class Migration20260901120000_reindex_workflow_definitions extends Migration {
|
|
7
|
+
up() {
|
|
8
|
+
}
|
|
9
|
+
down() {
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export {
|
|
13
|
+
Migration20260901120000_reindex_workflow_definitions,
|
|
14
|
+
queryIndexReindexEntityTypes
|
|
15
|
+
};
|
|
16
|
+
//# sourceMappingURL=Migration20260901120000_reindex_workflow_definitions.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/workflows/migrations/Migration20260901120000_reindex_workflow_definitions.ts"],
|
|
4
|
+
"sourcesContent": ["import { Migration } from '@mikro-orm/migrations';\nimport { declareQueryIndexReindex } from '@open-mercato/shared/lib/query/migration-reindex';\n\n/**\n * Catch-up reindex for `Migration20260428102318`, which renamed three seeded\n * `workflow_definitions.workflow_id` values in raw SQL and never notified the query index. On\n * every install that already applied it, `entity_indexes.doc` for `workflows:workflow_definition`\n * still resolves those records by the pre-rename identifier \u2014 `checkout_simple_v1` rather than\n * `workflows.checkout-demo`, and so on \u2014 which is exactly the identifier the rename removed.\n *\n * This migration executes no SQL \u2014 the declaration below is its entire payload.\n */\nexport const queryIndexReindexEntityTypes = declareQueryIndexReindex([\n 'workflows:workflow_definition',\n]);\n\nexport class Migration20260901120000_reindex_workflow_definitions extends Migration {\n override up(): void | Promise<void> {}\n\n override down(): void | Promise<void> {}\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,iBAAiB;AAC1B,SAAS,gCAAgC;AAWlC,MAAM,+BAA+B,yBAAyB;AAAA,EACnE;AACF,CAAC;AAEM,MAAM,6DAA6D,UAAU;AAAA,EACzE,KAA2B;AAAA,EAAC;AAAA,EAE5B,OAA6B;AAAA,EAAC;AACzC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
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.7148.1.3076e5ccf7",
|
|
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.7148.1.3076e5ccf7",
|
|
256
|
+
"@open-mercato/shared": "0.7.1-develop.7148.1.3076e5ccf7",
|
|
257
|
+
"@open-mercato/ui": "0.7.1-develop.7148.1.3076e5ccf7",
|
|
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.7148.1.3076e5ccf7",
|
|
263
|
+
"@open-mercato/shared": "0.7.1-develop.7148.1.3076e5ccf7",
|
|
264
|
+
"@open-mercato/ui": "0.7.1-develop.7148.1.3076e5ccf7",
|
|
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",
|
|
@@ -377,10 +377,10 @@ export default function UsersListPage() {
|
|
|
377
377
|
)
|
|
378
378
|
const columns = React.useMemo<ColumnDef<Row>[]>(() => {
|
|
379
379
|
const base: ColumnDef<Row>[] = [
|
|
380
|
-
{ accessorKey: 'email', header: 'Email' },
|
|
380
|
+
{ accessorKey: 'email', header: t('auth.users.list.columns.email', 'Email') },
|
|
381
381
|
{ accessorKey: 'name', header: t('auth.users.list.columns.name', 'Display name') },
|
|
382
|
-
{ accessorKey: 'organizationName', header: 'Organization' },
|
|
383
|
-
{ accessorKey: 'roles', header: 'Roles', cell: ({ row }) => (row.original.roles || []).join(', ') },
|
|
382
|
+
{ accessorKey: 'organizationName', header: t('auth.users.list.columns.organization', 'Organization') },
|
|
383
|
+
{ accessorKey: 'roles', header: t('auth.users.list.columns.roles', 'Roles'), cell: ({ row }) => (row.original.roles || []).join(', ') },
|
|
384
384
|
{
|
|
385
385
|
accessorKey: 'isConfirmed',
|
|
386
386
|
header: t('auth.users.list.columns.status', 'Status'),
|
|
@@ -392,7 +392,7 @@ export default function UsersListPage() {
|
|
|
392
392
|
},
|
|
393
393
|
]
|
|
394
394
|
if (showTenantColumn) {
|
|
395
|
-
base.splice(1, 0, { accessorKey: 'tenantName', header: 'Tenant' })
|
|
395
|
+
base.splice(1, 0, { accessorKey: 'tenantName', header: t('auth.users.list.columns.tenant', 'Tenant') })
|
|
396
396
|
}
|
|
397
397
|
return base
|
|
398
398
|
}, [showTenantColumn, t])
|
|
@@ -599,8 +599,12 @@
|
|
|
599
599
|
"auth.users.form.loading": "Benutzerdaten werden geladen...",
|
|
600
600
|
"auth.users.form.title.create": "Benutzer erstellen",
|
|
601
601
|
"auth.users.form.title.edit": "Benutzer bearbeiten",
|
|
602
|
+
"auth.users.list.columns.email": "E-Mail",
|
|
602
603
|
"auth.users.list.columns.name": "Anzeigename",
|
|
604
|
+
"auth.users.list.columns.organization": "Organisation",
|
|
605
|
+
"auth.users.list.columns.roles": "Rollen",
|
|
603
606
|
"auth.users.list.columns.status": "Status",
|
|
607
|
+
"auth.users.list.columns.tenant": "Mandant",
|
|
604
608
|
"auth.users.list.error.delete": "Benutzer konnte nicht gelöscht werden",
|
|
605
609
|
"auth.users.list.error.load": "Benutzer konnten nicht geladen werden",
|
|
606
610
|
"auth.users.list.filters.name": "Anzeigename",
|
|
@@ -599,8 +599,12 @@
|
|
|
599
599
|
"auth.users.form.loading": "Loading user data...",
|
|
600
600
|
"auth.users.form.title.create": "Create User",
|
|
601
601
|
"auth.users.form.title.edit": "Edit User",
|
|
602
|
+
"auth.users.list.columns.email": "Email",
|
|
602
603
|
"auth.users.list.columns.name": "Display name",
|
|
604
|
+
"auth.users.list.columns.organization": "Organization",
|
|
605
|
+
"auth.users.list.columns.roles": "Roles",
|
|
603
606
|
"auth.users.list.columns.status": "Status",
|
|
607
|
+
"auth.users.list.columns.tenant": "Tenant",
|
|
604
608
|
"auth.users.list.error.delete": "Failed to delete user",
|
|
605
609
|
"auth.users.list.error.load": "Failed to load users",
|
|
606
610
|
"auth.users.list.filters.name": "Display name",
|
|
@@ -599,8 +599,12 @@
|
|
|
599
599
|
"auth.users.form.loading": "Cargando datos del usuario...",
|
|
600
600
|
"auth.users.form.title.create": "Crear usuario",
|
|
601
601
|
"auth.users.form.title.edit": "Editar usuario",
|
|
602
|
+
"auth.users.list.columns.email": "Correo electrónico",
|
|
602
603
|
"auth.users.list.columns.name": "Nombre visible",
|
|
604
|
+
"auth.users.list.columns.organization": "Organización",
|
|
605
|
+
"auth.users.list.columns.roles": "Roles",
|
|
603
606
|
"auth.users.list.columns.status": "Estado",
|
|
607
|
+
"auth.users.list.columns.tenant": "Inquilino",
|
|
604
608
|
"auth.users.list.error.delete": "No se pudo eliminar el usuario",
|
|
605
609
|
"auth.users.list.error.load": "No se pudieron cargar los usuarios",
|
|
606
610
|
"auth.users.list.filters.name": "Nombre visible",
|
|
@@ -599,8 +599,12 @@
|
|
|
599
599
|
"auth.users.form.loading": "사용자 데이터를 불러오는 중...",
|
|
600
600
|
"auth.users.form.title.create": "사용자 생성",
|
|
601
601
|
"auth.users.form.title.edit": "사용자 편집",
|
|
602
|
+
"auth.users.list.columns.email": "이메일",
|
|
602
603
|
"auth.users.list.columns.name": "표시 이름",
|
|
604
|
+
"auth.users.list.columns.organization": "조직",
|
|
605
|
+
"auth.users.list.columns.roles": "역할",
|
|
603
606
|
"auth.users.list.columns.status": "상태",
|
|
607
|
+
"auth.users.list.columns.tenant": "테넌트",
|
|
604
608
|
"auth.users.list.error.delete": "사용자 삭제에 실패했습니다",
|
|
605
609
|
"auth.users.list.error.load": "사용자를 불러오지 못했습니다",
|
|
606
610
|
"auth.users.list.filters.name": "표시 이름",
|
|
@@ -599,8 +599,12 @@
|
|
|
599
599
|
"auth.users.form.loading": "Ładowanie danych użytkownika...",
|
|
600
600
|
"auth.users.form.title.create": "Utwórz użytkownika",
|
|
601
601
|
"auth.users.form.title.edit": "Edytuj użytkownika",
|
|
602
|
+
"auth.users.list.columns.email": "Email",
|
|
602
603
|
"auth.users.list.columns.name": "Nazwa wyświetlana",
|
|
604
|
+
"auth.users.list.columns.organization": "Organizacja",
|
|
605
|
+
"auth.users.list.columns.roles": "Role",
|
|
603
606
|
"auth.users.list.columns.status": "Status",
|
|
607
|
+
"auth.users.list.columns.tenant": "Najemca",
|
|
604
608
|
"auth.users.list.error.delete": "Nie udało się usunąć użytkownika",
|
|
605
609
|
"auth.users.list.error.load": "Nie udało się wczytać użytkowników",
|
|
606
610
|
"auth.users.list.filters.name": "Nazwa wyświetlana",
|
|
@@ -247,6 +247,7 @@ export default function CompanyDetailV2Page({ params }: { params?: { id?: string
|
|
|
247
247
|
scheduledAt: typeof activity.scheduledAt === 'string' ? activity.scheduledAt : null,
|
|
248
248
|
occurredAt: typeof activity.occurredAt === 'string' ? activity.occurredAt : null,
|
|
249
249
|
durationMinutes: durationValue,
|
|
250
|
+
priority: typeof raw.priority === 'number' ? raw.priority as number : null,
|
|
250
251
|
location: typeof raw.location === 'string' ? raw.location as string : null,
|
|
251
252
|
allDay: typeof raw.allDay === 'boolean' ? raw.allDay as boolean : null,
|
|
252
253
|
recurrenceRule: typeof raw.recurrenceRule === 'string' ? raw.recurrenceRule as string : null,
|
|
@@ -264,6 +264,7 @@ export default function DealDetailPage({ params }: { params?: { id?: string } })
|
|
|
264
264
|
scheduledAt: activity.scheduledAt ?? null,
|
|
265
265
|
occurredAt: activity.occurredAt ?? null,
|
|
266
266
|
durationMinutes: activity.duration ?? null,
|
|
267
|
+
priority: activity.priority ?? null,
|
|
267
268
|
location: activity.location ?? null,
|
|
268
269
|
allDay: activity.allDay ?? null,
|
|
269
270
|
recurrenceRule: activity.recurrenceRule ?? null,
|
|
@@ -276,6 +276,7 @@ export default function PersonDetailV2Page({ params }: { params?: { id?: string
|
|
|
276
276
|
scheduledAt: typeof activity.scheduledAt === 'string' ? activity.scheduledAt : null,
|
|
277
277
|
occurredAt: typeof activity.occurredAt === 'string' ? activity.occurredAt : null,
|
|
278
278
|
durationMinutes: durationValue,
|
|
279
|
+
priority: typeof raw.priority === 'number' ? raw.priority as number : null,
|
|
279
280
|
location: typeof raw.location === 'string' ? raw.location as string : null,
|
|
280
281
|
allDay: typeof raw.allDay === 'boolean' ? raw.allDay as boolean : null,
|
|
281
282
|
recurrenceRule: typeof raw.recurrenceRule === 'string' ? raw.recurrenceRule as string : null,
|
|
@@ -480,11 +480,24 @@ export function CompanyPeopleSection({
|
|
|
480
480
|
confirmButtonLabel: translate('customers.linking.person.confirmButton', 'Link person'),
|
|
481
481
|
showLinkSettings: true,
|
|
482
482
|
roleOptions: [
|
|
483
|
-
{
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
483
|
+
{
|
|
484
|
+
id: 'decision_maker',
|
|
485
|
+
label: translate('customers.linking.person.role.decisionMaker', 'Decision maker'),
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
id: 'budget_holder',
|
|
489
|
+
label: translate('customers.linking.person.role.budgetHolder', 'Budget holder'),
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
id: 'stakeholder',
|
|
493
|
+
label: translate('customers.linking.person.role.stakeholder', 'Stakeholder'),
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
id: 'contact',
|
|
497
|
+
label: translate('customers.linking.person.role.contact', 'Contact'),
|
|
498
|
+
},
|
|
487
499
|
],
|
|
500
|
+
allFilterLabel: translate('customers.linking.person.role.all', 'All'),
|
|
488
501
|
excludeLinkedCompanyId: companyId,
|
|
489
502
|
addNew: {
|
|
490
503
|
title: translate('customers.linking.person.addNew', 'Add new contact'),
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
LinkedEntitiesField,
|
|
30
30
|
} from './schedule'
|
|
31
31
|
import type { ActivityType, ScheduleActivityEditData } from './schedule'
|
|
32
|
+
import { PRIORITY_NUMBER, priorityFromNumber, type EditorPriority } from '../../lib/calendar/editorPayload'
|
|
32
33
|
|
|
33
34
|
const TYPE_TABS: Array<{ type: ActivityType; icon: React.ComponentType<{ className?: string }>; labelKey: string; fallback: string }> = [
|
|
34
35
|
{ type: 'meeting', icon: Users, labelKey: 'customers.schedule.types.meeting', fallback: 'Meeting' },
|
|
@@ -81,13 +82,34 @@ const CALL_OUTCOMES: Array<{ key: string; labelKey: string; labelFallback: strin
|
|
|
81
82
|
{ key: 'badnumber', labelKey: 'customers.schedule.call.outcome.badNumber', labelFallback: 'Bad number', dot: 'bg-status-error-icon' },
|
|
82
83
|
]
|
|
83
84
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
85
|
+
// Task priority is the interaction's own nullable `priority` column (0-100), not a
|
|
86
|
+
// custom field — the same scale `CalendarEventEditor` writes through
|
|
87
|
+
// `PRIORITY_NUMBER` / `priorityFromNumber`. `null` means "no priority set", which the
|
|
88
|
+
// column allows (#5943). Dot colours mirror the calendar editor's `PRIORITY_META` so
|
|
89
|
+
// both controls render the same value identically.
|
|
90
|
+
type TaskPriorityValue = EditorPriority | null
|
|
91
|
+
|
|
92
|
+
const TASK_PRIORITIES: Array<{ key: TaskPriorityValue; labelKey: string; labelFallback: string; dot: string }> = [
|
|
93
|
+
{ key: null, labelKey: 'customers.schedule.task.priority.none', labelFallback: 'None', dot: 'bg-muted' },
|
|
94
|
+
{ key: 'low', labelKey: 'customers.schedule.task.priority.low', labelFallback: 'Low', dot: 'bg-status-info-icon' },
|
|
95
|
+
{ key: 'medium', labelKey: 'customers.schedule.task.priority.medium', labelFallback: 'Medium', dot: 'bg-muted-foreground' },
|
|
96
|
+
{ key: 'high', labelKey: 'customers.schedule.task.priority.high', labelFallback: 'High', dot: 'bg-status-error-icon' },
|
|
89
97
|
]
|
|
90
98
|
|
|
99
|
+
function readTaskPriorityNumber(raw: { priority?: unknown } | null | undefined): number | null {
|
|
100
|
+
return typeof raw?.priority === 'number' && !Number.isNaN(raw.priority) ? raw.priority : null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// `TaskForm` writes any 0-100 value while these chips only offer three buckets, so a
|
|
104
|
+
// save that did not touch the control must keep the stored number rather than snap it
|
|
105
|
+
// to the bucket midpoint — `priority` is a sortable column, so rewriting 100 as 90
|
|
106
|
+
// would silently reorder tasks the user never edited.
|
|
107
|
+
function buildTaskPriorityPayload(level: TaskPriorityValue, seeded: number | null): number | null {
|
|
108
|
+
if (!level) return null
|
|
109
|
+
if (seeded !== null && priorityFromNumber(seeded) === level) return seeded
|
|
110
|
+
return PRIORITY_NUMBER[level]
|
|
111
|
+
}
|
|
112
|
+
|
|
91
113
|
interface ScheduleActivityDialogProps {
|
|
92
114
|
open: boolean
|
|
93
115
|
onClose: () => void
|
|
@@ -126,7 +148,8 @@ export function ScheduleActivityDialog({
|
|
|
126
148
|
const [callOutcome, setCallOutcome] = React.useState<string | null>(null)
|
|
127
149
|
const [callPhoneNumber, setCallPhoneNumber] = React.useState('')
|
|
128
150
|
const [callPhoneError, setCallPhoneError] = React.useState<string | null>(null)
|
|
129
|
-
const [taskPriority, setTaskPriority] = React.useState<
|
|
151
|
+
const [taskPriority, setTaskPriority] = React.useState<TaskPriorityValue>(null)
|
|
152
|
+
const [seededTaskPriority, setSeededTaskPriority] = React.useState<number | null>(null)
|
|
130
153
|
const callPhoneInvalidMessage = React.useMemo(
|
|
131
154
|
() =>
|
|
132
155
|
t(
|
|
@@ -145,7 +168,7 @@ export function ScheduleActivityDialog({
|
|
|
145
168
|
|
|
146
169
|
React.useEffect(() => {
|
|
147
170
|
if (!open) return
|
|
148
|
-
const raw = editData as (Record<string, unknown> & { customValues?: unknown; phoneNumber?: unknown }) | null | undefined
|
|
171
|
+
const raw = editData as (Record<string, unknown> & { customValues?: unknown; phoneNumber?: unknown; priority?: unknown }) | null | undefined
|
|
149
172
|
const cv = (raw?.customValues && typeof raw.customValues === 'object' ? raw.customValues : null) as Record<string, unknown> | null
|
|
150
173
|
setCallDirection(typeof cv?.callDirection === 'string' && cv.callDirection === 'inbound' ? 'inbound' : 'outbound')
|
|
151
174
|
setCallOutcome(typeof cv?.callOutcome === 'string' ? cv.callOutcome : null)
|
|
@@ -160,7 +183,9 @@ export function ScheduleActivityDialog({
|
|
|
160
183
|
: ''
|
|
161
184
|
setCallPhoneNumber(seededPhone)
|
|
162
185
|
setCallPhoneError(null)
|
|
163
|
-
|
|
186
|
+
const seededPriority = readTaskPriorityNumber(raw)
|
|
187
|
+
setSeededTaskPriority(seededPriority)
|
|
188
|
+
setTaskPriority(seededPriority === null ? null : priorityFromNumber(seededPriority))
|
|
164
189
|
}, [open, editData])
|
|
165
190
|
|
|
166
191
|
// Reset per-type chip state when the user switches activity type in create mode.
|
|
@@ -171,7 +196,8 @@ export function ScheduleActivityDialog({
|
|
|
171
196
|
setCallOutcome(null)
|
|
172
197
|
setCallPhoneNumber('')
|
|
173
198
|
setCallPhoneError(null)
|
|
174
|
-
setTaskPriority(
|
|
199
|
+
setTaskPriority(null)
|
|
200
|
+
setSeededTaskPriority(null)
|
|
175
201
|
}, [state.activityType, open, isEditing])
|
|
176
202
|
|
|
177
203
|
const handleCallPhoneChange = React.useCallback((next: string | undefined) => {
|
|
@@ -388,9 +414,6 @@ export function ScheduleActivityDialog({
|
|
|
388
414
|
if (callOutcome) customValues.callOutcome = callOutcome
|
|
389
415
|
if (phoneNumberForPayload) customValues.callPhoneNumber = phoneNumberForPayload
|
|
390
416
|
}
|
|
391
|
-
if (state.activityType === 'task') {
|
|
392
|
-
customValues.taskPriority = taskPriority
|
|
393
|
-
}
|
|
394
417
|
const payload = {
|
|
395
418
|
...(isSaveEdit ? { id: editData!.id } : {}),
|
|
396
419
|
entityId,
|
|
@@ -402,6 +425,9 @@ export function ScheduleActivityDialog({
|
|
|
402
425
|
date: trimmedDate,
|
|
403
426
|
time: state.allDay ? '00:00' : trimmedStartTime,
|
|
404
427
|
phoneNumber: state.activityType === 'call' ? phoneNumberForPayload : undefined,
|
|
428
|
+
// Only tasks expose the priority control, so other types leave the column
|
|
429
|
+
// untouched rather than clearing it on a type switch (#5943).
|
|
430
|
+
priority: state.activityType === 'task' ? buildTaskPriorityPayload(taskPriority, seededTaskPriority) : undefined,
|
|
405
431
|
scheduledAt,
|
|
406
432
|
durationMinutes: visibleFields.has('duration') && !state.allDay ? state.duration : null,
|
|
407
433
|
location: visibleFields.has('location') ? (state.location.trim() || null) : null,
|
|
@@ -469,7 +495,7 @@ export function ScheduleActivityDialog({
|
|
|
469
495
|
} finally {
|
|
470
496
|
state.setSaving(false)
|
|
471
497
|
}
|
|
472
|
-
}, [callDirection, callOutcome, callPhoneInvalidMessage, callPhoneNumber, isDateMissing, isTimeMissing, state.activityType, state.allDay, state.date, state.description, dealId, state.duration, editData, entityId, state.guestPermissions, state.linkedEntities, state.location, onActivityCreated, onClose, state.participants, state.recurrenceCount, state.recurrenceDays, state.recurrenceEnabled, state.recurrenceEndDate, state.recurrenceEndType, state.reminderMinutes, runGuardedMutation, state.startTime, t, taskPriority, state.title, translateErrorMessage, trimmedCallPhone, trimmedDate, trimmedStartTime, state.visibility, visibleFields]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
498
|
+
}, [callDirection, callOutcome, callPhoneInvalidMessage, callPhoneNumber, isDateMissing, isTimeMissing, state.activityType, state.allDay, state.date, state.description, dealId, state.duration, editData, entityId, state.guestPermissions, state.linkedEntities, state.location, onActivityCreated, onClose, state.participants, state.recurrenceCount, state.recurrenceDays, state.recurrenceEnabled, state.recurrenceEndDate, state.recurrenceEndType, state.reminderMinutes, runGuardedMutation, seededTaskPriority, state.startTime, t, taskPriority, state.title, translateErrorMessage, trimmedCallPhone, trimmedDate, trimmedStartTime, state.visibility, visibleFields]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
473
499
|
|
|
474
500
|
const handleKeyDown = useDialogKeyHandler({ onConfirm: handleSave })
|
|
475
501
|
|
|
@@ -658,7 +684,7 @@ export function ScheduleActivityDialog({
|
|
|
658
684
|
const isActive = taskPriority === opt.key
|
|
659
685
|
return (
|
|
660
686
|
<button
|
|
661
|
-
key={opt.key}
|
|
687
|
+
key={opt.key ?? 'none'}
|
|
662
688
|
type="button"
|
|
663
689
|
aria-pressed={isActive}
|
|
664
690
|
onClick={() => setTaskPriority(opt.key)}
|
|
@@ -32,6 +32,8 @@ export type ScheduleActivityEditData = {
|
|
|
32
32
|
*/
|
|
33
33
|
occurredAt?: string | null
|
|
34
34
|
durationMinutes?: number | null
|
|
35
|
+
/** Interaction priority column (0-100, nullable) backing the task priority control (#5943). */
|
|
36
|
+
priority?: number | null
|
|
35
37
|
location?: string | null
|
|
36
38
|
allDay?: boolean | null
|
|
37
39
|
recurrenceRule?: string | null
|
|
@@ -40,6 +40,7 @@ type PersonAdapterOptions = {
|
|
|
40
40
|
addNew?: LinkEntityAdapter<PersonDetails, PersonLinkSettings>['addNew']
|
|
41
41
|
showLinkSettings?: boolean
|
|
42
42
|
roleOptions?: Array<{ id: string; label: string }>
|
|
43
|
+
allFilterLabel?: string
|
|
43
44
|
headerIcon?: React.ReactNode
|
|
44
45
|
}
|
|
45
46
|
|
|
@@ -217,7 +218,7 @@ export function createPersonLinkAdapter(
|
|
|
217
218
|
roleOptions.length > 0
|
|
218
219
|
? {
|
|
219
220
|
options: [
|
|
220
|
-
{ id: 'all', label: 'All' },
|
|
221
|
+
{ id: 'all', label: options.allFilterLabel ?? 'All' },
|
|
221
222
|
...roleOptions.map((role) => ({ id: role.id, label: role.label })),
|
|
222
223
|
],
|
|
223
224
|
defaultId: 'all',
|