@open-mercato/core 0.6.6-develop.6246.1.85fd30c705 → 0.6.6-develop.6255.1.a6ee4a57c1
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/customers/components/calendar/TimeGrid.js +3 -2
- package/dist/modules/customers/components/calendar/TimeGrid.js.map +2 -2
- package/dist/modules/customers/components/detail/DecisionMakersFooter.js +3 -3
- package/dist/modules/customers/components/detail/DecisionMakersFooter.js.map +2 -2
- package/dist/modules/customers/lib/calendar/grid.js +5 -2
- package/dist/modules/customers/lib/calendar/grid.js.map +2 -2
- package/dist/modules/query_index/lib/engine.js +5 -1
- package/dist/modules/query_index/lib/engine.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +25 -9
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/commands/shared.js +33 -0
- package/dist/modules/sales/commands/shared.js.map +2 -2
- package/dist/modules/sales/migrations/Migration20260624120000_backfill_line_net_from_gross.js +27 -0
- package/dist/modules/sales/migrations/Migration20260624120000_backfill_line_net_from_gross.js.map +7 -0
- package/package.json +7 -7
- package/src/modules/customers/components/calendar/TimeGrid.tsx +3 -2
- package/src/modules/customers/components/detail/DecisionMakersFooter.tsx +14 -12
- package/src/modules/customers/lib/calendar/grid.ts +10 -2
- package/src/modules/query_index/lib/engine.ts +5 -1
- package/src/modules/sales/commands/documents.ts +25 -9
- package/src/modules/sales/commands/shared.ts +68 -0
- package/src/modules/sales/migrations/Migration20260624120000_backfill_line_net_from_gross.ts +48 -0
|
@@ -24,6 +24,37 @@ function toNumericString(value) {
|
|
|
24
24
|
if (value === void 0 || value === null) return null;
|
|
25
25
|
return value.toString();
|
|
26
26
|
}
|
|
27
|
+
const LINE_AMOUNT_SCALE = 4;
|
|
28
|
+
function parseLineAmount(value) {
|
|
29
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : 0;
|
|
30
|
+
if (typeof value === "string" && value.trim().length) {
|
|
31
|
+
const parsed = Number(value);
|
|
32
|
+
if (!Number.isNaN(parsed)) return parsed;
|
|
33
|
+
}
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
function roundLineAmount(value) {
|
|
37
|
+
const factor = 10 ** LINE_AMOUNT_SCALE;
|
|
38
|
+
return Math.round((value + Number.EPSILON) * factor) / factor;
|
|
39
|
+
}
|
|
40
|
+
function deriveLineNetFromGross(net, gross, taxRate) {
|
|
41
|
+
const netValue = parseLineAmount(net);
|
|
42
|
+
const grossValue = parseLineAmount(gross);
|
|
43
|
+
if (grossValue > 0 && netValue <= 0) {
|
|
44
|
+
const rate = parseLineAmount(taxRate);
|
|
45
|
+
const fraction = rate > 0 ? rate / 100 : 0;
|
|
46
|
+
return roundLineAmount(fraction > 0 ? grossValue / (1 + fraction) : grossValue);
|
|
47
|
+
}
|
|
48
|
+
return netValue;
|
|
49
|
+
}
|
|
50
|
+
function reconcileLinePersistedTotals(payload) {
|
|
51
|
+
const gross = parseLineAmount(payload.totalGrossAmount);
|
|
52
|
+
const net = parseLineAmount(payload.totalNetAmount);
|
|
53
|
+
if (gross <= 0 || net > 0) return payload;
|
|
54
|
+
const derivedNet = toNumericString(deriveLineNetFromGross(net, gross, payload.taxRate));
|
|
55
|
+
if (derivedNet == null) return payload;
|
|
56
|
+
return { ...payload, totalNetAmount: derivedNet };
|
|
57
|
+
}
|
|
27
58
|
async function requireScopedEntity(em, entityClass, id, message, scope = { organizationId: null, tenantId: null }) {
|
|
28
59
|
const where = { id, deletedAt: null };
|
|
29
60
|
if (scope.organizationId) where.organizationId = scope.organizationId;
|
|
@@ -38,11 +69,13 @@ export {
|
|
|
38
69
|
SALES_RESOURCE_KIND_RETURN,
|
|
39
70
|
assertFound,
|
|
40
71
|
cloneJson,
|
|
72
|
+
deriveLineNetFromGross,
|
|
41
73
|
enforceSalesDocumentOptimisticLock,
|
|
42
74
|
ensureOrganizationScope,
|
|
43
75
|
ensureSameScope,
|
|
44
76
|
ensureTenantScope,
|
|
45
77
|
extractUndoPayload,
|
|
78
|
+
reconcileLinePersistedTotals,
|
|
46
79
|
requireScopedEntity,
|
|
47
80
|
toNumericString
|
|
48
81
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/sales/commands/shared.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nexport { assertFound } from '@open-mercato/shared/lib/crud/errors'\nexport { ensureOrganizationScope, ensureSameScope, ensureTenantScope } from '@open-mercato/shared/lib/commands/scope'\nexport { extractUndoPayload } from '@open-mercato/shared/lib/commands/undo'\n\n/** Resource kinds used by the document-aggregate optimistic-lock check. */\nexport const SALES_RESOURCE_KIND_ORDER = 'sales.order'\nexport const SALES_RESOURCE_KIND_QUOTE = 'sales.quote'\nexport const SALES_RESOURCE_KIND_RETURN = 'sales.return'\n\n/**\n * Enforce the document-aggregate OSS optimistic lock for a sales sub-resource\n * command (lines, adjustments, shipments, payments, returns, quote\n * conversion). The client sends the parent order/quote's expected `updated_at`\n * via the optimistic-lock extension header; this compares it against the\n * already-loaded document and throws the structured 409 on mismatch.\n *\n * The parent document is the consistency boundary: sub-resource mutations\n * recalculate document totals (or transition the document), which dirties the\n * parent so its `updated_at` advances on flush \u2014 meaning concurrent sub-edits\n * observe each other and conflict. Call this AFTER loading + scope-checking the\n * document and BEFORE mutating, so `document.updatedAt` is the pre-mutation\n * version.\n *\n * Strictly additive: when the client sends no header the check is a no-op, so\n * existing API consumers are unaffected. Respects `OM_OPTIMISTIC_LOCK`.\n */\nexport function enforceSalesDocumentOptimisticLock(\n ctx: CommandRuntimeContext,\n document: { id: string; updatedAt?: Date | string | null } | null | undefined,\n resourceKind: string,\n): void {\n if (!document) return\n enforceCommandOptimisticLock({\n resourceKind,\n resourceId: document.id,\n current: document.updatedAt ?? null,\n request: ctx.request ?? null,\n })\n}\n\nexport function cloneJson<T>(value: T): T {\n if (value === null || value === undefined) return value\n return JSON.parse(JSON.stringify(value)) as T\n}\n\nexport function toNumericString(value: number | null | undefined): string | null {\n if (value === undefined || value === null) return null\n return value.toString()\n}\n\nexport async function requireScopedEntity<T extends { id: string; deletedAt?: Date | null }>(\n em: EntityManager,\n entityClass: { new (): T },\n id: string,\n message: string,\n scope: { organizationId: string | null; tenantId: string | null } = { organizationId: null, tenantId: null },\n): Promise<T> {\n const where: Record<string, unknown> = { id, deletedAt: null }\n if (scope.organizationId) where.organizationId = scope.organizationId\n if (scope.tenantId) where.tenantId = scope.tenantId\n const entity = await findOneWithDecryption(em, entityClass, where, {}, scope)\n if (!entity) throw new CrudHttpError(404, { error: message })\n return entity\n}\n"],
|
|
5
|
-
"mappings": "AACA,SAAS,qBAAqB;AAC9B,SAAS,6BAA6B;AAEtC,SAAS,oCAAoC;AAC7C,SAAS,mBAAmB;AAC5B,SAAS,yBAAyB,iBAAiB,yBAAyB;AAC5E,SAAS,0BAA0B;AAG5B,MAAM,4BAA4B;AAClC,MAAM,4BAA4B;AAClC,MAAM,6BAA6B;AAmBnC,SAAS,mCACd,KACA,UACA,cACM;AACN,MAAI,CAAC,SAAU;AACf,+BAA6B;AAAA,IAC3B;AAAA,IACA,YAAY,SAAS;AAAA,IACrB,SAAS,SAAS,aAAa;AAAA,IAC/B,SAAS,IAAI,WAAW;AAAA,EAC1B,CAAC;AACH;AAEO,SAAS,UAAa,OAAa;AACxC,MAAI,UAAU,QAAQ,UAAU,OAAW,QAAO;AAClD,SAAO,KAAK,MAAM,KAAK,UAAU,KAAK,CAAC;AACzC;AAEO,SAAS,gBAAgB,OAAiD;AAC/E,MAAI,UAAU,UAAa,UAAU,KAAM,QAAO;AAClD,SAAO,MAAM,SAAS;AACxB;AAEA,eAAsB,oBACpB,IACA,aACA,IACA,SACA,QAAoE,EAAE,gBAAgB,MAAM,UAAU,KAAK,GAC/F;AACZ,QAAM,QAAiC,EAAE,IAAI,WAAW,KAAK;AAC7D,MAAI,MAAM,eAAgB,OAAM,iBAAiB,MAAM;AACvD,MAAI,MAAM,SAAU,OAAM,WAAW,MAAM;AAC3C,QAAM,SAAS,MAAM,sBAAsB,IAAI,aAAa,OAAO,CAAC,GAAG,KAAK;AAC5E,MAAI,CAAC,OAAQ,OAAM,IAAI,cAAc,KAAK,EAAE,OAAO,QAAQ,CAAC;AAC5D,SAAO;AACT;",
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nexport { assertFound } from '@open-mercato/shared/lib/crud/errors'\nexport { ensureOrganizationScope, ensureSameScope, ensureTenantScope } from '@open-mercato/shared/lib/commands/scope'\nexport { extractUndoPayload } from '@open-mercato/shared/lib/commands/undo'\n\n/** Resource kinds used by the document-aggregate optimistic-lock check. */\nexport const SALES_RESOURCE_KIND_ORDER = 'sales.order'\nexport const SALES_RESOURCE_KIND_QUOTE = 'sales.quote'\nexport const SALES_RESOURCE_KIND_RETURN = 'sales.return'\n\n/**\n * Enforce the document-aggregate OSS optimistic lock for a sales sub-resource\n * command (lines, adjustments, shipments, payments, returns, quote\n * conversion). The client sends the parent order/quote's expected `updated_at`\n * via the optimistic-lock extension header; this compares it against the\n * already-loaded document and throws the structured 409 on mismatch.\n *\n * The parent document is the consistency boundary: sub-resource mutations\n * recalculate document totals (or transition the document), which dirties the\n * parent so its `updated_at` advances on flush \u2014 meaning concurrent sub-edits\n * observe each other and conflict. Call this AFTER loading + scope-checking the\n * document and BEFORE mutating, so `document.updatedAt` is the pre-mutation\n * version.\n *\n * Strictly additive: when the client sends no header the check is a no-op, so\n * existing API consumers are unaffected. Respects `OM_OPTIMISTIC_LOCK`.\n */\nexport function enforceSalesDocumentOptimisticLock(\n ctx: CommandRuntimeContext,\n document: { id: string; updatedAt?: Date | string | null } | null | undefined,\n resourceKind: string,\n): void {\n if (!document) return\n enforceCommandOptimisticLock({\n resourceKind,\n resourceId: document.id,\n current: document.updatedAt ?? null,\n request: ctx.request ?? null,\n })\n}\n\nexport function cloneJson<T>(value: T): T {\n if (value === null || value === undefined) return value\n return JSON.parse(JSON.stringify(value)) as T\n}\n\nexport function toNumericString(value: number | null | undefined): string | null {\n if (value === undefined || value === null) return null\n return value.toString()\n}\n\n/** Numeric scale of the `total_net_amount` / `total_gross_amount` line columns. */\nconst LINE_AMOUNT_SCALE = 4\n\nfunction parseLineAmount(value: number | string | null | undefined): number {\n if (typeof value === 'number') return Number.isFinite(value) ? value : 0\n if (typeof value === 'string' && value.trim().length) {\n const parsed = Number(value)\n if (!Number.isNaN(parsed)) return parsed\n }\n return 0\n}\n\nfunction roundLineAmount(value: number): number {\n const factor = 10 ** LINE_AMOUNT_SCALE\n return Math.round((value + Number.EPSILON) * factor) / factor\n}\n\n/**\n * Derive a sales line's net total from its gross total and tax rate.\n *\n * `total_net_amount = 0` while `total_gross_amount > 0` is not a representable\n * priced state: `gross = net * (1 + taxRate)`, so `net = 0 \u21D2 gross = 0`. When a\n * line carries a positive gross but a missing/zero net (legacy rows, optional\n * pass-through inputs, or invoice/credit-memo copy of a zeroed order line), the\n * net is reconstructed from gross and the line's tax rate. `taxRate` is a\n * percentage (e.g. `23` \u21D2 `0.23` fraction), matching the stored column and\n * `taxCalculationService`. Returns the existing net unchanged when the\n * invariant already holds. See issues #3521 / #3036.\n */\nexport function deriveLineNetFromGross(\n net: number | string | null | undefined,\n gross: number | string | null | undefined,\n taxRate: number | string | null | undefined,\n): number {\n const netValue = parseLineAmount(net)\n const grossValue = parseLineAmount(gross)\n if (grossValue > 0 && netValue <= 0) {\n const rate = parseLineAmount(taxRate)\n const fraction = rate > 0 ? rate / 100 : 0\n return roundLineAmount(fraction > 0 ? grossValue / (1 + fraction) : grossValue)\n }\n return netValue\n}\n\ntype LinePersistedTotals = {\n totalNetAmount?: number | string | null\n totalGrossAmount?: number | string | null\n taxRate?: number | string | null\n}\n\n/**\n * Enforce the `gross > 0 \u21D2 net > 0` invariant on a line-entity create payload\n * right before persistence. Returns the payload unchanged when the net total is\n * already positive or gross is non-positive; otherwise fills the net total in\n * from gross / taxRate via {@link deriveLineNetFromGross}. Applied at every\n * sales line persistence site so the skew that froze return net totals (#3036)\n * cannot be stored at the source. Idempotent and non-destructive: it only ever\n * raises a zero/missing net to its derived value.\n */\nexport function reconcileLinePersistedTotals<T extends LinePersistedTotals>(payload: T): T {\n const gross = parseLineAmount(payload.totalGrossAmount)\n const net = parseLineAmount(payload.totalNetAmount)\n if (gross <= 0 || net > 0) return payload\n const derivedNet = toNumericString(deriveLineNetFromGross(net, gross, payload.taxRate))\n if (derivedNet == null) return payload\n return { ...payload, totalNetAmount: derivedNet } as T\n}\n\nexport async function requireScopedEntity<T extends { id: string; deletedAt?: Date | null }>(\n em: EntityManager,\n entityClass: { new (): T },\n id: string,\n message: string,\n scope: { organizationId: string | null; tenantId: string | null } = { organizationId: null, tenantId: null },\n): Promise<T> {\n const where: Record<string, unknown> = { id, deletedAt: null }\n if (scope.organizationId) where.organizationId = scope.organizationId\n if (scope.tenantId) where.tenantId = scope.tenantId\n const entity = await findOneWithDecryption(em, entityClass, where, {}, scope)\n if (!entity) throw new CrudHttpError(404, { error: message })\n return entity\n}\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,qBAAqB;AAC9B,SAAS,6BAA6B;AAEtC,SAAS,oCAAoC;AAC7C,SAAS,mBAAmB;AAC5B,SAAS,yBAAyB,iBAAiB,yBAAyB;AAC5E,SAAS,0BAA0B;AAG5B,MAAM,4BAA4B;AAClC,MAAM,4BAA4B;AAClC,MAAM,6BAA6B;AAmBnC,SAAS,mCACd,KACA,UACA,cACM;AACN,MAAI,CAAC,SAAU;AACf,+BAA6B;AAAA,IAC3B;AAAA,IACA,YAAY,SAAS;AAAA,IACrB,SAAS,SAAS,aAAa;AAAA,IAC/B,SAAS,IAAI,WAAW;AAAA,EAC1B,CAAC;AACH;AAEO,SAAS,UAAa,OAAa;AACxC,MAAI,UAAU,QAAQ,UAAU,OAAW,QAAO;AAClD,SAAO,KAAK,MAAM,KAAK,UAAU,KAAK,CAAC;AACzC;AAEO,SAAS,gBAAgB,OAAiD;AAC/E,MAAI,UAAU,UAAa,UAAU,KAAM,QAAO;AAClD,SAAO,MAAM,SAAS;AACxB;AAGA,MAAM,oBAAoB;AAE1B,SAAS,gBAAgB,OAAmD;AAC1E,MAAI,OAAO,UAAU,SAAU,QAAO,OAAO,SAAS,KAAK,IAAI,QAAQ;AACvE,MAAI,OAAO,UAAU,YAAY,MAAM,KAAK,EAAE,QAAQ;AACpD,UAAM,SAAS,OAAO,KAAK;AAC3B,QAAI,CAAC,OAAO,MAAM,MAAM,EAAG,QAAO;AAAA,EACpC;AACA,SAAO;AACT;AAEA,SAAS,gBAAgB,OAAuB;AAC9C,QAAM,SAAS,MAAM;AACrB,SAAO,KAAK,OAAO,QAAQ,OAAO,WAAW,MAAM,IAAI;AACzD;AAcO,SAAS,uBACd,KACA,OACA,SACQ;AACR,QAAM,WAAW,gBAAgB,GAAG;AACpC,QAAM,aAAa,gBAAgB,KAAK;AACxC,MAAI,aAAa,KAAK,YAAY,GAAG;AACnC,UAAM,OAAO,gBAAgB,OAAO;AACpC,UAAM,WAAW,OAAO,IAAI,OAAO,MAAM;AACzC,WAAO,gBAAgB,WAAW,IAAI,cAAc,IAAI,YAAY,UAAU;AAAA,EAChF;AACA,SAAO;AACT;AAiBO,SAAS,6BAA4D,SAAe;AACzF,QAAM,QAAQ,gBAAgB,QAAQ,gBAAgB;AACtD,QAAM,MAAM,gBAAgB,QAAQ,cAAc;AAClD,MAAI,SAAS,KAAK,MAAM,EAAG,QAAO;AAClC,QAAM,aAAa,gBAAgB,uBAAuB,KAAK,OAAO,QAAQ,OAAO,CAAC;AACtF,MAAI,cAAc,KAAM,QAAO;AAC/B,SAAO,EAAE,GAAG,SAAS,gBAAgB,WAAW;AAClD;AAEA,eAAsB,oBACpB,IACA,aACA,IACA,SACA,QAAoE,EAAE,gBAAgB,MAAM,UAAU,KAAK,GAC/F;AACZ,QAAM,QAAiC,EAAE,IAAI,WAAW,KAAK;AAC7D,MAAI,MAAM,eAAgB,OAAM,iBAAiB,MAAM;AACvD,MAAI,MAAM,SAAU,OAAM,WAAW,MAAM;AAC3C,QAAM,SAAS,MAAM,sBAAsB,IAAI,aAAa,OAAO,CAAC,GAAG,KAAK;AAC5E,MAAI,CAAC,OAAQ,OAAM,IAAI,cAAc,KAAK,EAAE,OAAO,QAAQ,CAAC;AAC5D,SAAO;AACT;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Migration } from "@mikro-orm/migrations";
|
|
2
|
+
class Migration20260624120000_backfill_line_net_from_gross extends Migration {
|
|
3
|
+
up() {
|
|
4
|
+
for (const table of [
|
|
5
|
+
"sales_order_lines",
|
|
6
|
+
"sales_quote_lines",
|
|
7
|
+
"sales_invoice_lines",
|
|
8
|
+
"sales_credit_memo_lines"
|
|
9
|
+
]) {
|
|
10
|
+
this.addSql(`
|
|
11
|
+
update "${table}"
|
|
12
|
+
set "total_net_amount" = round(
|
|
13
|
+
"total_gross_amount" / (1 + coalesce("tax_rate", 0) / 100),
|
|
14
|
+
4
|
|
15
|
+
)
|
|
16
|
+
where "total_gross_amount" > 0
|
|
17
|
+
and ("total_net_amount" is null or "total_net_amount" <= 0);
|
|
18
|
+
`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
down() {
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export {
|
|
25
|
+
Migration20260624120000_backfill_line_net_from_gross
|
|
26
|
+
};
|
|
27
|
+
//# sourceMappingURL=Migration20260624120000_backfill_line_net_from_gross.js.map
|
package/dist/modules/sales/migrations/Migration20260624120000_backfill_line_net_from_gross.js.map
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/sales/migrations/Migration20260624120000_backfill_line_net_from_gross.ts"],
|
|
4
|
+
"sourcesContent": ["import { Migration } from '@mikro-orm/migrations';\n\n/**\n * Backfill sales line rows that violate the `gross > 0 \u21D2 net > 0` invariant\n * (issue #3521, root cause of #3036).\n *\n * `total_net_amount = 0` while `total_gross_amount > 0` is not a representable\n * priced state (`gross = net * (1 + taxRate)`, so `net = 0 \u21D2 gross = 0`). Such\n * rows froze the order net grand total on a subsequent return and skewed any\n * invoice / credit-memo line that copied the order line's net. The persistence\n * paths are sealed at the source in `commands/documents.ts` via\n * `reconcileLinePersistedTotals` / `deriveLineNetFromGross`; this migration\n * repairs rows that were already stored in the inconsistent state (including the\n * seeded demo order `SO-DEMO-2001` on tenants provisioned before the fix).\n *\n * The repaired net is reconstructed from the stored gross and tax rate exactly\n * as the runtime helper does: `net = gross / (1 + taxRate/100)`, rounded to the\n * column scale (4). When the tax rate is zero/null the net equals the gross.\n *\n * Forward-only: the original (corrupt) zero net carried no information to\n * restore, so reverting is a no-op.\n */\nexport class Migration20260624120000_backfill_line_net_from_gross extends Migration {\n override up(): void | Promise<void> {\n for (const table of [\n 'sales_order_lines',\n 'sales_quote_lines',\n 'sales_invoice_lines',\n 'sales_credit_memo_lines',\n ]) {\n this.addSql(`\n update \"${table}\"\n set \"total_net_amount\" = round(\n \"total_gross_amount\" / (1 + coalesce(\"tax_rate\", 0) / 100),\n 4\n )\n where \"total_gross_amount\" > 0\n and (\"total_net_amount\" is null or \"total_net_amount\" <= 0);\n `);\n }\n }\n\n override down(): void | Promise<void> {\n // Forward-only data repair. The pre-migration zero/missing net carried no\n // information to restore, and reintroducing the `net = 0, gross > 0` skew\n // would re-open #3036. No-op.\n }\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,iBAAiB;AAsBnB,MAAM,6DAA6D,UAAU;AAAA,EACzE,KAA2B;AAClC,eAAW,SAAS;AAAA,MAClB;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF,GAAG;AACD,WAAK,OAAO;AAAA,kBACA,KAAK;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,OAOhB;AAAA,IACH;AAAA,EACF;AAAA,EAES,OAA6B;AAAA,EAItC;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.6255.1.a6ee4a57c1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -246,16 +246,16 @@
|
|
|
246
246
|
"zod": "^4.4.3"
|
|
247
247
|
},
|
|
248
248
|
"peerDependencies": {
|
|
249
|
-
"@open-mercato/ai-assistant": "0.6.6-develop.
|
|
250
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
251
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
249
|
+
"@open-mercato/ai-assistant": "0.6.6-develop.6255.1.a6ee4a57c1",
|
|
250
|
+
"@open-mercato/shared": "0.6.6-develop.6255.1.a6ee4a57c1",
|
|
251
|
+
"@open-mercato/ui": "0.6.6-develop.6255.1.a6ee4a57c1",
|
|
252
252
|
"react": "^19.0.0",
|
|
253
253
|
"react-dom": "^19.0.0"
|
|
254
254
|
},
|
|
255
255
|
"devDependencies": {
|
|
256
|
-
"@open-mercato/ai-assistant": "0.6.6-develop.
|
|
257
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
258
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
256
|
+
"@open-mercato/ai-assistant": "0.6.6-develop.6255.1.a6ee4a57c1",
|
|
257
|
+
"@open-mercato/shared": "0.6.6-develop.6255.1.a6ee4a57c1",
|
|
258
|
+
"@open-mercato/ui": "0.6.6-develop.6255.1.a6ee4a57c1",
|
|
259
259
|
"@testing-library/dom": "^10.4.1",
|
|
260
260
|
"@testing-library/jest-dom": "^6.9.1",
|
|
261
261
|
"@testing-library/react": "^16.3.1",
|
|
@@ -172,6 +172,7 @@ export function TimeGrid({
|
|
|
172
172
|
const scrollRef = React.useRef<HTMLDivElement | null>(null)
|
|
173
173
|
const nowMs = Date.now()
|
|
174
174
|
const today = startOfDay(new Date())
|
|
175
|
+
const todayMs = today.getTime()
|
|
175
176
|
const anchorMs = anchor.getTime()
|
|
176
177
|
const [selectedId, setSelectedId] = React.useState<string | null>(null)
|
|
177
178
|
const [drag, setDrag] = React.useState<DragState | null>(null)
|
|
@@ -179,8 +180,8 @@ export function TimeGrid({
|
|
|
179
180
|
const dayStarts = React.useMemo(() => {
|
|
180
181
|
const rangeStart = getVisibleRange(days === 7 ? 'week' : 'day', new Date(anchorMs), 0).from
|
|
181
182
|
const all = Array.from({ length: days }, (_, index) => addDays(rangeStart, index))
|
|
182
|
-
return days === 7 ? applyWeekendVisibility(all, showWeekends) : all
|
|
183
|
-
}, [days, anchorMs, showWeekends])
|
|
183
|
+
return days === 7 ? applyWeekendVisibility(all, showWeekends, new Date(todayMs)) : all
|
|
184
|
+
}, [days, anchorMs, showWeekends, todayMs])
|
|
184
185
|
|
|
185
186
|
const dayColumns = React.useMemo(
|
|
186
187
|
() => dayStarts.map((dayStart) => buildDayColumn(dayStart, items)),
|
|
@@ -4,7 +4,7 @@ import * as React from 'react'
|
|
|
4
4
|
import { Lightbulb, Send } from 'lucide-react'
|
|
5
5
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
6
|
import { Button } from '@open-mercato/ui/primitives/button'
|
|
7
|
-
import { Tooltip, TooltipContent, TooltipTrigger } from '@open-mercato/ui/primitives/tooltip'
|
|
7
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@open-mercato/ui/primitives/tooltip'
|
|
8
8
|
|
|
9
9
|
interface DecisionMakersFooterProps {
|
|
10
10
|
names: string[]
|
|
@@ -39,17 +39,19 @@ export function DecisionMakersFooter({ names, suggestion, onSendInvitation }: De
|
|
|
39
39
|
</div>
|
|
40
40
|
</div>
|
|
41
41
|
{onSendInvitation && (
|
|
42
|
-
<
|
|
43
|
-
<
|
|
44
|
-
<
|
|
45
|
-
<
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
42
|
+
<TooltipProvider delayDuration={300}>
|
|
43
|
+
<Tooltip>
|
|
44
|
+
<TooltipTrigger asChild>
|
|
45
|
+
<span className="w-full shrink-0 sm:w-auto">
|
|
46
|
+
<Button type="button" size="sm" disabled className="pointer-events-none w-full sm:w-auto">
|
|
47
|
+
<Send className="mr-1.5 size-3.5" />
|
|
48
|
+
{t('customers.people.decisionMakers.sendInvitation', 'Send invitation')}
|
|
49
|
+
</Button>
|
|
50
|
+
</span>
|
|
51
|
+
</TooltipTrigger>
|
|
52
|
+
<TooltipContent>{t('customers.ai.comingSoon', 'Coming soon')}</TooltipContent>
|
|
53
|
+
</Tooltip>
|
|
54
|
+
</TooltipProvider>
|
|
53
55
|
)}
|
|
54
56
|
</div>
|
|
55
57
|
)
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isSameDay } from 'date-fns/isSameDay'
|
|
2
|
+
|
|
1
3
|
export const DRAG_SNAP_MINUTES = 15
|
|
2
4
|
export const MIN_DRAG_DURATION_MINUTES = 30
|
|
3
5
|
const MINUTES_PER_DAY = 24 * 60
|
|
@@ -7,9 +9,15 @@ export function isWeekendDay(date: Date): boolean {
|
|
|
7
9
|
return weekday === 0 || weekday === 6
|
|
8
10
|
}
|
|
9
11
|
|
|
10
|
-
export function applyWeekendVisibility(
|
|
12
|
+
export function applyWeekendVisibility(
|
|
13
|
+
days: Date[],
|
|
14
|
+
showWeekends: boolean,
|
|
15
|
+
keepWeekendDate?: Date | null,
|
|
16
|
+
): Date[] {
|
|
11
17
|
if (showWeekends) return days
|
|
12
|
-
const workingDays = days.filter(
|
|
18
|
+
const workingDays = days.filter(
|
|
19
|
+
(day) => !isWeekendDay(day) || (keepWeekendDate != null && isSameDay(day, keepWeekendDate)),
|
|
20
|
+
)
|
|
13
21
|
return workingDays.length > 0 ? workingDays : days
|
|
14
22
|
}
|
|
15
23
|
|
|
@@ -935,7 +935,11 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
935
935
|
const cap = resolveEncryptedSortMaxRows()
|
|
936
936
|
const phase1Root = db.selectFrom(`${baseTable} as b` as any)
|
|
937
937
|
let phase1 = await applyQueryShape(phase1Root)
|
|
938
|
-
const
|
|
938
|
+
const scopeFieldNames = [
|
|
939
|
+
hasTenantColumn ? 'tenant_id' : null,
|
|
940
|
+
hasOrganizationColumn ? 'organization_id' : null,
|
|
941
|
+
].filter((field): field is string => field !== null)
|
|
942
|
+
const sortFieldNames = Array.from(new Set(['id', ...scopeFieldNames, ...resolvedSorts.map((s) => String(s.field))]))
|
|
939
943
|
phase1 = applySelection(phase1, sortFieldNames)
|
|
940
944
|
if (cap !== null) {
|
|
941
945
|
phase1 = phase1.limit(cap).orderBy(qualify('id'), 'asc' as any)
|
|
@@ -96,6 +96,8 @@ import {
|
|
|
96
96
|
ensureTenantScope,
|
|
97
97
|
extractUndoPayload,
|
|
98
98
|
toNumericString,
|
|
99
|
+
reconcileLinePersistedTotals,
|
|
100
|
+
deriveLineNetFromGross,
|
|
99
101
|
enforceSalesDocumentOptimisticLock,
|
|
100
102
|
SALES_RESOURCE_KIND_ORDER,
|
|
101
103
|
SALES_RESOURCE_KIND_QUOTE,
|
|
@@ -2937,7 +2939,7 @@ function convertLineCalculationToEntityInput(
|
|
|
2937
2939
|
index: number,
|
|
2938
2940
|
) {
|
|
2939
2941
|
const line = lineResult.line;
|
|
2940
|
-
return {
|
|
2942
|
+
return reconcileLinePersistedTotals({
|
|
2941
2943
|
lineNumber: line.lineNumber ?? index + 1,
|
|
2942
2944
|
kind: line.kind ?? "product",
|
|
2943
2945
|
statusEntryId: sourceLine.statusEntryId ?? null,
|
|
@@ -2984,7 +2986,7 @@ function convertLineCalculationToEntityInput(
|
|
|
2984
2986
|
customFieldSetId: sourceLine.customFieldSetId ?? null,
|
|
2985
2987
|
organizationId: document.organizationId,
|
|
2986
2988
|
tenantId: document.tenantId,
|
|
2987
|
-
};
|
|
2989
|
+
});
|
|
2988
2990
|
}
|
|
2989
2991
|
|
|
2990
2992
|
function convertAdjustmentResultToEntityInput(
|
|
@@ -3941,7 +3943,9 @@ async function restoreQuoteGraph(
|
|
|
3941
3943
|
discountPercent: line.discountPercent,
|
|
3942
3944
|
taxRate: line.taxRate,
|
|
3943
3945
|
taxAmount: line.taxAmount,
|
|
3944
|
-
totalNetAmount:
|
|
3946
|
+
totalNetAmount: toNumericString(
|
|
3947
|
+
deriveLineNetFromGross(line.totalNetAmount, line.totalGrossAmount, line.taxRate),
|
|
3948
|
+
),
|
|
3945
3949
|
totalGrossAmount: line.totalGrossAmount,
|
|
3946
3950
|
configuration: line.configuration ? cloneJson(line.configuration) : null,
|
|
3947
3951
|
promotionCode: line.promotionCode ?? null,
|
|
@@ -4269,7 +4273,9 @@ async function restoreOrderGraph(
|
|
|
4269
4273
|
discountPercent: line.discountPercent,
|
|
4270
4274
|
taxRate: line.taxRate,
|
|
4271
4275
|
taxAmount: line.taxAmount,
|
|
4272
|
-
totalNetAmount:
|
|
4276
|
+
totalNetAmount: toNumericString(
|
|
4277
|
+
deriveLineNetFromGross(line.totalNetAmount, line.totalGrossAmount, line.taxRate),
|
|
4278
|
+
),
|
|
4273
4279
|
totalGrossAmount: line.totalGrossAmount,
|
|
4274
4280
|
configuration: line.configuration ? cloneJson(line.configuration) : null,
|
|
4275
4281
|
promotionCode: line.promotionCode ?? null,
|
|
@@ -6190,7 +6196,9 @@ const convertQuoteToOrderCommand: CommandHandler<
|
|
|
6190
6196
|
discountPercent: line.discountPercent,
|
|
6191
6197
|
taxRate: line.taxRate,
|
|
6192
6198
|
taxAmount: line.taxAmount,
|
|
6193
|
-
totalNetAmount:
|
|
6199
|
+
totalNetAmount: toNumericString(
|
|
6200
|
+
deriveLineNetFromGross(line.totalNetAmount, line.totalGrossAmount, line.taxRate),
|
|
6201
|
+
),
|
|
6194
6202
|
totalGrossAmount: line.totalGrossAmount,
|
|
6195
6203
|
configuration: line.configuration
|
|
6196
6204
|
? cloneJson(line.configuration)
|
|
@@ -8444,7 +8452,9 @@ const createInvoiceCommand: CommandHandler<
|
|
|
8444
8452
|
discountPercent: toNumericString(line.discountPercent ?? 0),
|
|
8445
8453
|
taxRate: toNumericString(line.taxRate ?? 0),
|
|
8446
8454
|
taxAmount: toNumericString(line.taxAmount ?? 0),
|
|
8447
|
-
totalNetAmount: toNumericString(
|
|
8455
|
+
totalNetAmount: toNumericString(
|
|
8456
|
+
deriveLineNetFromGross(line.totalNetAmount ?? 0, line.totalGrossAmount ?? 0, line.taxRate ?? 0),
|
|
8457
|
+
),
|
|
8448
8458
|
totalGrossAmount: toNumericString(line.totalGrossAmount ?? 0),
|
|
8449
8459
|
metadata: line.metadata ?? null,
|
|
8450
8460
|
}),
|
|
@@ -8796,7 +8806,9 @@ const deleteInvoiceCommand: CommandHandler<
|
|
|
8796
8806
|
discountPercent: line.discountPercent,
|
|
8797
8807
|
taxRate: line.taxRate,
|
|
8798
8808
|
taxAmount: line.taxAmount,
|
|
8799
|
-
totalNetAmount:
|
|
8809
|
+
totalNetAmount: toNumericString(
|
|
8810
|
+
deriveLineNetFromGross(line.totalNetAmount, line.totalGrossAmount, line.taxRate),
|
|
8811
|
+
),
|
|
8800
8812
|
totalGrossAmount: line.totalGrossAmount,
|
|
8801
8813
|
metadata: line.metadata,
|
|
8802
8814
|
}));
|
|
@@ -8938,7 +8950,9 @@ const createCreditMemoCommand: CommandHandler<
|
|
|
8938
8950
|
unitPriceGross: toNumericString(line.unitPriceGross ?? 0),
|
|
8939
8951
|
taxRate: toNumericString(line.taxRate ?? 0),
|
|
8940
8952
|
taxAmount: toNumericString(line.taxAmount ?? 0),
|
|
8941
|
-
totalNetAmount: toNumericString(
|
|
8953
|
+
totalNetAmount: toNumericString(
|
|
8954
|
+
deriveLineNetFromGross(line.totalNetAmount ?? 0, line.totalGrossAmount ?? 0, line.taxRate ?? 0),
|
|
8955
|
+
),
|
|
8942
8956
|
totalGrossAmount: toNumericString(line.totalGrossAmount ?? 0),
|
|
8943
8957
|
metadata: line.metadata ?? null,
|
|
8944
8958
|
}),
|
|
@@ -9280,7 +9294,9 @@ const deleteCreditMemoCommand: CommandHandler<
|
|
|
9280
9294
|
unitPriceGross: line.unitPriceGross,
|
|
9281
9295
|
taxRate: line.taxRate,
|
|
9282
9296
|
taxAmount: line.taxAmount,
|
|
9283
|
-
totalNetAmount:
|
|
9297
|
+
totalNetAmount: toNumericString(
|
|
9298
|
+
deriveLineNetFromGross(line.totalNetAmount, line.totalGrossAmount, line.taxRate),
|
|
9299
|
+
),
|
|
9284
9300
|
totalGrossAmount: line.totalGrossAmount,
|
|
9285
9301
|
metadata: line.metadata,
|
|
9286
9302
|
}));
|
|
@@ -53,6 +53,74 @@ export function toNumericString(value: number | null | undefined): string | null
|
|
|
53
53
|
return value.toString()
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/** Numeric scale of the `total_net_amount` / `total_gross_amount` line columns. */
|
|
57
|
+
const LINE_AMOUNT_SCALE = 4
|
|
58
|
+
|
|
59
|
+
function parseLineAmount(value: number | string | null | undefined): number {
|
|
60
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : 0
|
|
61
|
+
if (typeof value === 'string' && value.trim().length) {
|
|
62
|
+
const parsed = Number(value)
|
|
63
|
+
if (!Number.isNaN(parsed)) return parsed
|
|
64
|
+
}
|
|
65
|
+
return 0
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function roundLineAmount(value: number): number {
|
|
69
|
+
const factor = 10 ** LINE_AMOUNT_SCALE
|
|
70
|
+
return Math.round((value + Number.EPSILON) * factor) / factor
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Derive a sales line's net total from its gross total and tax rate.
|
|
75
|
+
*
|
|
76
|
+
* `total_net_amount = 0` while `total_gross_amount > 0` is not a representable
|
|
77
|
+
* priced state: `gross = net * (1 + taxRate)`, so `net = 0 ⇒ gross = 0`. When a
|
|
78
|
+
* line carries a positive gross but a missing/zero net (legacy rows, optional
|
|
79
|
+
* pass-through inputs, or invoice/credit-memo copy of a zeroed order line), the
|
|
80
|
+
* net is reconstructed from gross and the line's tax rate. `taxRate` is a
|
|
81
|
+
* percentage (e.g. `23` ⇒ `0.23` fraction), matching the stored column and
|
|
82
|
+
* `taxCalculationService`. Returns the existing net unchanged when the
|
|
83
|
+
* invariant already holds. See issues #3521 / #3036.
|
|
84
|
+
*/
|
|
85
|
+
export function deriveLineNetFromGross(
|
|
86
|
+
net: number | string | null | undefined,
|
|
87
|
+
gross: number | string | null | undefined,
|
|
88
|
+
taxRate: number | string | null | undefined,
|
|
89
|
+
): number {
|
|
90
|
+
const netValue = parseLineAmount(net)
|
|
91
|
+
const grossValue = parseLineAmount(gross)
|
|
92
|
+
if (grossValue > 0 && netValue <= 0) {
|
|
93
|
+
const rate = parseLineAmount(taxRate)
|
|
94
|
+
const fraction = rate > 0 ? rate / 100 : 0
|
|
95
|
+
return roundLineAmount(fraction > 0 ? grossValue / (1 + fraction) : grossValue)
|
|
96
|
+
}
|
|
97
|
+
return netValue
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
type LinePersistedTotals = {
|
|
101
|
+
totalNetAmount?: number | string | null
|
|
102
|
+
totalGrossAmount?: number | string | null
|
|
103
|
+
taxRate?: number | string | null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Enforce the `gross > 0 ⇒ net > 0` invariant on a line-entity create payload
|
|
108
|
+
* right before persistence. Returns the payload unchanged when the net total is
|
|
109
|
+
* already positive or gross is non-positive; otherwise fills the net total in
|
|
110
|
+
* from gross / taxRate via {@link deriveLineNetFromGross}. Applied at every
|
|
111
|
+
* sales line persistence site so the skew that froze return net totals (#3036)
|
|
112
|
+
* cannot be stored at the source. Idempotent and non-destructive: it only ever
|
|
113
|
+
* raises a zero/missing net to its derived value.
|
|
114
|
+
*/
|
|
115
|
+
export function reconcileLinePersistedTotals<T extends LinePersistedTotals>(payload: T): T {
|
|
116
|
+
const gross = parseLineAmount(payload.totalGrossAmount)
|
|
117
|
+
const net = parseLineAmount(payload.totalNetAmount)
|
|
118
|
+
if (gross <= 0 || net > 0) return payload
|
|
119
|
+
const derivedNet = toNumericString(deriveLineNetFromGross(net, gross, payload.taxRate))
|
|
120
|
+
if (derivedNet == null) return payload
|
|
121
|
+
return { ...payload, totalNetAmount: derivedNet } as T
|
|
122
|
+
}
|
|
123
|
+
|
|
56
124
|
export async function requireScopedEntity<T extends { id: string; deletedAt?: Date | null }>(
|
|
57
125
|
em: EntityManager,
|
|
58
126
|
entityClass: { new (): T },
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Backfill sales line rows that violate the `gross > 0 ⇒ net > 0` invariant
|
|
5
|
+
* (issue #3521, root cause of #3036).
|
|
6
|
+
*
|
|
7
|
+
* `total_net_amount = 0` while `total_gross_amount > 0` is not a representable
|
|
8
|
+
* priced state (`gross = net * (1 + taxRate)`, so `net = 0 ⇒ gross = 0`). Such
|
|
9
|
+
* rows froze the order net grand total on a subsequent return and skewed any
|
|
10
|
+
* invoice / credit-memo line that copied the order line's net. The persistence
|
|
11
|
+
* paths are sealed at the source in `commands/documents.ts` via
|
|
12
|
+
* `reconcileLinePersistedTotals` / `deriveLineNetFromGross`; this migration
|
|
13
|
+
* repairs rows that were already stored in the inconsistent state (including the
|
|
14
|
+
* seeded demo order `SO-DEMO-2001` on tenants provisioned before the fix).
|
|
15
|
+
*
|
|
16
|
+
* The repaired net is reconstructed from the stored gross and tax rate exactly
|
|
17
|
+
* as the runtime helper does: `net = gross / (1 + taxRate/100)`, rounded to the
|
|
18
|
+
* column scale (4). When the tax rate is zero/null the net equals the gross.
|
|
19
|
+
*
|
|
20
|
+
* Forward-only: the original (corrupt) zero net carried no information to
|
|
21
|
+
* restore, so reverting is a no-op.
|
|
22
|
+
*/
|
|
23
|
+
export class Migration20260624120000_backfill_line_net_from_gross extends Migration {
|
|
24
|
+
override up(): void | Promise<void> {
|
|
25
|
+
for (const table of [
|
|
26
|
+
'sales_order_lines',
|
|
27
|
+
'sales_quote_lines',
|
|
28
|
+
'sales_invoice_lines',
|
|
29
|
+
'sales_credit_memo_lines',
|
|
30
|
+
]) {
|
|
31
|
+
this.addSql(`
|
|
32
|
+
update "${table}"
|
|
33
|
+
set "total_net_amount" = round(
|
|
34
|
+
"total_gross_amount" / (1 + coalesce("tax_rate", 0) / 100),
|
|
35
|
+
4
|
|
36
|
+
)
|
|
37
|
+
where "total_gross_amount" > 0
|
|
38
|
+
and ("total_net_amount" is null or "total_net_amount" <= 0);
|
|
39
|
+
`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
override down(): void | Promise<void> {
|
|
44
|
+
// Forward-only data repair. The pre-migration zero/missing net carried no
|
|
45
|
+
// information to restore, and reintroducing the `net = 0, gross > 0` skew
|
|
46
|
+
// would re-open #3036. No-op.
|
|
47
|
+
}
|
|
48
|
+
}
|