@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.
Files changed (23) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/customers/components/calendar/TimeGrid.js +3 -2
  3. package/dist/modules/customers/components/calendar/TimeGrid.js.map +2 -2
  4. package/dist/modules/customers/components/detail/DecisionMakersFooter.js +3 -3
  5. package/dist/modules/customers/components/detail/DecisionMakersFooter.js.map +2 -2
  6. package/dist/modules/customers/lib/calendar/grid.js +5 -2
  7. package/dist/modules/customers/lib/calendar/grid.js.map +2 -2
  8. package/dist/modules/query_index/lib/engine.js +5 -1
  9. package/dist/modules/query_index/lib/engine.js.map +2 -2
  10. package/dist/modules/sales/commands/documents.js +25 -9
  11. package/dist/modules/sales/commands/documents.js.map +2 -2
  12. package/dist/modules/sales/commands/shared.js +33 -0
  13. package/dist/modules/sales/commands/shared.js.map +2 -2
  14. package/dist/modules/sales/migrations/Migration20260624120000_backfill_line_net_from_gross.js +27 -0
  15. package/dist/modules/sales/migrations/Migration20260624120000_backfill_line_net_from_gross.js.map +7 -0
  16. package/package.json +7 -7
  17. package/src/modules/customers/components/calendar/TimeGrid.tsx +3 -2
  18. package/src/modules/customers/components/detail/DecisionMakersFooter.tsx +14 -12
  19. package/src/modules/customers/lib/calendar/grid.ts +10 -2
  20. package/src/modules/query_index/lib/engine.ts +5 -1
  21. package/src/modules/sales/commands/documents.ts +25 -9
  22. package/src/modules/sales/commands/shared.ts +68 -0
  23. 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
@@ -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.6246.1.85fd30c705",
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.6246.1.85fd30c705",
250
- "@open-mercato/shared": "0.6.6-develop.6246.1.85fd30c705",
251
- "@open-mercato/ui": "0.6.6-develop.6246.1.85fd30c705",
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.6246.1.85fd30c705",
257
- "@open-mercato/shared": "0.6.6-develop.6246.1.85fd30c705",
258
- "@open-mercato/ui": "0.6.6-develop.6246.1.85fd30c705",
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
- <Tooltip>
43
- <TooltipTrigger asChild>
44
- <span className="w-full shrink-0 sm:w-auto">
45
- <Button type="button" size="sm" disabled className="pointer-events-none w-full sm:w-auto">
46
- <Send className="mr-1.5 size-3.5" />
47
- {t('customers.people.decisionMakers.sendInvitation', 'Send invitation')}
48
- </Button>
49
- </span>
50
- </TooltipTrigger>
51
- <TooltipContent>{t('customers.ai.comingSoon', 'Coming soon')}</TooltipContent>
52
- </Tooltip>
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(days: Date[], showWeekends: boolean): Date[] {
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((day) => !isWeekendDay(day))
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 sortFieldNames = Array.from(new Set(['id', ...resolvedSorts.map((s) => String(s.field))]))
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: line.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: line.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: line.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(line.totalNetAmount ?? 0),
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: line.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(line.totalNetAmount ?? 0),
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: line.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
+ }