@open-mercato/core 0.10.1-develop.7827.1.5c631908e4 → 0.10.1-develop.7836.1.0ef0f453c6

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 (47) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/helpers/integration/appRuntime.js +17 -0
  3. package/dist/helpers/integration/appRuntime.js.map +7 -0
  4. package/dist/modules/cart/api/current/lines/bulk/route.js +1 -1
  5. package/dist/modules/cart/api/current/lines/bulk/route.js.map +2 -2
  6. package/dist/modules/cart/api/current/lines/route.js +1 -1
  7. package/dist/modules/cart/api/current/lines/route.js.map +2 -2
  8. package/dist/modules/cart/commands/lines.js +5 -10
  9. package/dist/modules/cart/commands/lines.js.map +2 -2
  10. package/dist/modules/cart/lib/currentCartRoute.js +8 -0
  11. package/dist/modules/cart/lib/currentCartRoute.js.map +3 -3
  12. package/dist/modules/cart/lib/lineMutationLock.js +52 -0
  13. package/dist/modules/cart/lib/lineMutationLock.js.map +7 -0
  14. package/dist/modules/sales/commands/documents.js +3 -3
  15. package/dist/modules/sales/commands/documents.js.map +2 -2
  16. package/dist/modules/sales/components/documents/ItemsSection.js +75 -21
  17. package/dist/modules/sales/components/documents/ItemsSection.js.map +2 -2
  18. package/dist/modules/sales/components/documents/lineItemUtils.js +2 -2
  19. package/dist/modules/sales/components/documents/lineItemUtils.js.map +2 -2
  20. package/dist/modules/warranty_claims/search.js +7 -6
  21. package/dist/modules/warranty_claims/search.js.map +2 -2
  22. package/package.json +7 -7
  23. package/src/helpers/integration/appRuntime.ts +26 -0
  24. package/src/modules/cart/api/current/lines/bulk/route.ts +1 -1
  25. package/src/modules/cart/api/current/lines/route.ts +1 -1
  26. package/src/modules/cart/commands/lines.ts +5 -10
  27. package/src/modules/cart/i18n/de.json +1 -0
  28. package/src/modules/cart/i18n/en.json +1 -0
  29. package/src/modules/cart/i18n/es.json +1 -0
  30. package/src/modules/cart/i18n/ko.json +1 -0
  31. package/src/modules/cart/i18n/pl.json +1 -0
  32. package/src/modules/cart/lib/currentCartRoute.ts +8 -0
  33. package/src/modules/cart/lib/lineMutationLock.ts +59 -0
  34. package/src/modules/sales/commands/documents.ts +3 -3
  35. package/src/modules/sales/components/documents/ItemsSection.tsx +82 -23
  36. package/src/modules/sales/components/documents/lineItemUtils.ts +2 -2
  37. package/src/modules/sales/i18n/de.json +1 -0
  38. package/src/modules/sales/i18n/en.json +1 -0
  39. package/src/modules/sales/i18n/es.json +1 -0
  40. package/src/modules/sales/i18n/ko.json +1 -0
  41. package/src/modules/sales/i18n/pl.json +1 -0
  42. package/src/modules/warranty_claims/i18n/de.json +1 -0
  43. package/src/modules/warranty_claims/i18n/en.json +1 -0
  44. package/src/modules/warranty_claims/i18n/es.json +1 -0
  45. package/src/modules/warranty_claims/i18n/ko.json +1 -0
  46. package/src/modules/warranty_claims/i18n/pl.json +1 -0
  47. package/src/modules/warranty_claims/search.ts +8 -5
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/modules/warranty_claims/search.ts"],
4
- "sourcesContent": ["import { SortDir, type QueryEngine } from '@open-mercato/shared/lib/query/types'\nimport type {\n SearchBuildContext,\n SearchIndexSource,\n SearchModuleConfig,\n SearchResultLink,\n SearchResultPresenter,\n} from '@open-mercato/shared/modules/search'\nimport { E } from '#generated/entities.ids.generated'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\n\nconst logger = createLogger('warranty_claims')\n\ntype SearchContext = SearchBuildContext & {\n tenantId: string\n queryEngine?: QueryEngine\n}\n\nfunction assertTenantContext(ctx: SearchBuildContext): asserts ctx is SearchContext {\n if (typeof ctx.tenantId !== 'string' || ctx.tenantId.length === 0) {\n throw new Error('[internal] [search.warranty_claims] Missing tenantId in search build context')\n }\n}\n\nfunction readString(record: Record<string, unknown>, snakeKey: string, camelKey?: string): string | null {\n const value = record[snakeKey] ?? (camelKey ? record[camelKey] : undefined)\n if (typeof value !== 'string') return null\n const trimmed = value.trim()\n return trimmed.length ? trimmed : null\n}\n\nfunction appendLine(lines: string[], label: string, value: unknown): void {\n if (typeof value !== 'string') return\n const trimmed = value.trim()\n if (!trimmed.length) return\n lines.push(`${label}: ${trimmed}`)\n}\n\nfunction resolveClaimId(record: Record<string, unknown>): string | null {\n return readString(record, 'id', 'id')\n}\n\nfunction resolvePresenter(record: Record<string, unknown>): SearchResultPresenter {\n const claimNumber = readString(record, 'claim_number', 'claimNumber') ?? 'Warranty claim'\n const customerName = readString(record, 'customer_name', 'customerName')\n const claimType = readString(record, 'claim_type', 'claimType')\n const status = readString(record, 'status', 'status')\n const subtitle = [customerName, claimType].filter((value): value is string => Boolean(value)).join(' \u2014 ')\n return {\n title: claimNumber,\n subtitle: subtitle || undefined,\n icon: 'shield-check',\n badge: status ?? undefined,\n }\n}\n\nasync function loadClaimLineRows(ctx: SearchContext, claimId: string): Promise<Array<Record<string, unknown>>> {\n if (!ctx.queryEngine) return []\n try {\n const rows: Array<Record<string, unknown>> = []\n const pageSize = 100\n for (let page = 1; ; page += 1) {\n const result = await ctx.queryEngine.query<Record<string, unknown>>(E.warranty_claims.warranty_claim_line, {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? undefined,\n filters: { claim_id: { $eq: claimId } },\n fields: [\n 'id',\n 'line_no',\n 'sku',\n 'product_name',\n 'serial_number',\n 'lot_number',\n 'fault_code',\n 'warranty_status',\n 'disposition',\n 'line_status',\n ],\n page: { page, pageSize },\n sort: [\n { field: 'line_no', dir: SortDir.Asc },\n { field: 'id', dir: SortDir.Asc },\n ],\n })\n rows.push(...result.items)\n if (result.items.length < pageSize || rows.length >= result.total) break\n }\n return rows\n } catch (err) {\n logger.warn('[search.warranty_claims] Failed to load claim lines', {\n claimId,\n error: err instanceof Error ? err.message : err,\n })\n return []\n }\n}\n\nfunction appendLineRows(lines: string[], lineRows: Array<Record<string, unknown>>): void {\n for (const row of lineRows) {\n appendLine(lines, 'Line SKU', readString(row, 'sku', 'sku') ?? '')\n appendLine(lines, 'Line product', readString(row, 'product_name', 'productName') ?? '')\n appendLine(lines, 'Serial number', readString(row, 'serial_number', 'serialNumber') ?? '')\n appendLine(lines, 'Lot number', readString(row, 'lot_number', 'lotNumber') ?? '')\n appendLine(lines, 'Fault code', readString(row, 'fault_code', 'faultCode') ?? '')\n appendLine(lines, 'Warranty status', readString(row, 'warranty_status', 'warrantyStatus') ?? '')\n appendLine(lines, 'Disposition', readString(row, 'disposition', 'disposition') ?? '')\n appendLine(lines, 'Line status', readString(row, 'line_status', 'lineStatus') ?? '')\n }\n}\n\nfunction buildClaimUrl(record: Record<string, unknown>): string | null {\n const claimId = resolveClaimId(record)\n return claimId ? `/backend/warranty_claims/${claimId}` : null\n}\n\nexport const searchConfig: SearchModuleConfig = {\n entities: [\n {\n entityId: E.warranty_claims.warranty_claim,\n enabled: true,\n priority: 20,\n\n buildSource: async (ctx: SearchBuildContext): Promise<SearchIndexSource | null> => {\n assertTenantContext(ctx)\n const record = ctx.record\n const lines: string[] = []\n appendLine(lines, 'Claim number', readString(record, 'claim_number', 'claimNumber') ?? '')\n appendLine(lines, 'Customer', readString(record, 'customer_name', 'customerName') ?? '')\n appendLine(lines, 'Order number', readString(record, 'order_number', 'orderNumber') ?? '')\n appendLine(lines, 'Claim type', readString(record, 'claim_type', 'claimType') ?? '')\n appendLine(lines, 'Status', readString(record, 'status', 'status') ?? '')\n appendLine(lines, 'Vendor', readString(record, 'vendor_name', 'vendorName') ?? '')\n appendLine(lines, 'Vendor reference', readString(record, 'vendor_ref', 'vendorRef') ?? '')\n\n const claimId = resolveClaimId(record)\n const lineRows = claimId ? await loadClaimLineRows(ctx, claimId) : []\n appendLineRows(lines, lineRows)\n if (!lines.length) return null\n\n const presenter = resolvePresenter(record)\n const url = buildClaimUrl(record)\n const links: SearchResultLink[] = url ? [{ href: url, label: presenter.title, kind: 'primary' }] : []\n return {\n text: lines,\n presenter,\n links,\n checksumSource: {\n record,\n customFields: ctx.customFields,\n lineRows,\n },\n }\n },\n\n formatResult: (ctx: SearchBuildContext): SearchResultPresenter | null => {\n assertTenantContext(ctx)\n return resolvePresenter(ctx.record)\n },\n\n resolveUrl: (ctx: SearchBuildContext): string | null => buildClaimUrl(ctx.record),\n\n resolveLinks: async (ctx: SearchBuildContext): Promise<SearchResultLink[] | null> => {\n const url = buildClaimUrl(ctx.record)\n if (!url) return null\n const { t } = await resolveTranslations()\n return [{ href: url, label: t('warranty_claims.search.openClaim', 'Open claim'), kind: 'secondary' }]\n },\n\n fieldPolicy: {\n searchable: [\n 'claim_number',\n 'claim_type',\n 'status',\n 'priority',\n 'customer_name',\n 'vendor_name',\n 'vendor_ref',\n ],\n hashOnly: ['customer_id', 'order_id', 'sales_return_id', 'replacement_order_id', 'source_claim_id'],\n excluded: [\n 'tenant_id',\n 'organization_id',\n 'assignee_user_id',\n 'deleted_at',\n 'notes',\n 'resolution_summary',\n 'contact_email',\n 'fault_description',\n 'inspection_notes',\n 'rejection_reason_code',\n ],\n },\n aclFeatures: ['warranty_claims.claim.view'],\n },\n ],\n}\n\nexport default searchConfig\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { SortDir, type QueryEngine } from '@open-mercato/shared/lib/query/types'\nimport type {\n SearchBuildContext,\n SearchIndexSource,\n SearchModuleConfig,\n SearchResultLink,\n SearchResultPresenter,\n} from '@open-mercato/shared/modules/search'\nimport { E } from '#generated/entities.ids.generated'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\n\nconst logger = createLogger('warranty_claims')\n\ntype SearchContext = SearchBuildContext & {\n tenantId: string\n queryEngine?: QueryEngine\n}\n\nfunction assertTenantContext(ctx: SearchBuildContext): asserts ctx is SearchContext {\n if (typeof ctx.tenantId !== 'string' || ctx.tenantId.length === 0) {\n throw new Error('[internal] [search.warranty_claims] Missing tenantId in search build context')\n }\n}\n\nfunction readString(record: Record<string, unknown>, snakeKey: string, camelKey?: string): string | null {\n const value = record[snakeKey] ?? (camelKey ? record[camelKey] : undefined)\n if (typeof value !== 'string') return null\n const trimmed = value.trim()\n return trimmed.length ? trimmed : null\n}\n\nfunction appendLine(lines: string[], label: string, value: unknown): void {\n if (typeof value !== 'string') return\n const trimmed = value.trim()\n if (!trimmed.length) return\n lines.push(`${label}: ${trimmed}`)\n}\n\nfunction resolveClaimId(record: Record<string, unknown>): string | null {\n return readString(record, 'id', 'id')\n}\n\nasync function resolvePresenter(record: Record<string, unknown>): Promise<SearchResultPresenter> {\n const { t } = await resolveTranslations()\n const claimNumber = readString(record, 'claim_number', 'claimNumber') ?? 'Warranty claim'\n const customerName = readString(record, 'customer_name', 'customerName')\n const claimType = readString(record, 'claim_type', 'claimType')\n const status = readString(record, 'status', 'status')\n const statusLabel = status ? t(`warranty_claims.status.${status}`, status) : null\n const subtitle = [customerName, claimType, statusLabel]\n .filter((value): value is string => Boolean(value))\n .join(' \u2014 ')\n return {\n title: claimNumber,\n subtitle: subtitle || undefined,\n icon: 'shield-check',\n }\n}\n\nasync function loadClaimLineRows(ctx: SearchContext, claimId: string): Promise<Array<Record<string, unknown>>> {\n if (!ctx.queryEngine) return []\n try {\n const rows: Array<Record<string, unknown>> = []\n const pageSize = 100\n for (let page = 1; ; page += 1) {\n const result = await ctx.queryEngine.query<Record<string, unknown>>(E.warranty_claims.warranty_claim_line, {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? undefined,\n filters: { claim_id: { $eq: claimId } },\n fields: [\n 'id',\n 'line_no',\n 'sku',\n 'product_name',\n 'serial_number',\n 'lot_number',\n 'fault_code',\n 'warranty_status',\n 'disposition',\n 'line_status',\n ],\n page: { page, pageSize },\n sort: [\n { field: 'line_no', dir: SortDir.Asc },\n { field: 'id', dir: SortDir.Asc },\n ],\n })\n rows.push(...result.items)\n if (result.items.length < pageSize || rows.length >= result.total) break\n }\n return rows\n } catch (err) {\n logger.warn('[search.warranty_claims] Failed to load claim lines', {\n claimId,\n error: err instanceof Error ? err.message : err,\n })\n return []\n }\n}\n\nfunction appendLineRows(lines: string[], lineRows: Array<Record<string, unknown>>): void {\n for (const row of lineRows) {\n appendLine(lines, 'Line SKU', readString(row, 'sku', 'sku') ?? '')\n appendLine(lines, 'Line product', readString(row, 'product_name', 'productName') ?? '')\n appendLine(lines, 'Serial number', readString(row, 'serial_number', 'serialNumber') ?? '')\n appendLine(lines, 'Lot number', readString(row, 'lot_number', 'lotNumber') ?? '')\n appendLine(lines, 'Fault code', readString(row, 'fault_code', 'faultCode') ?? '')\n appendLine(lines, 'Warranty status', readString(row, 'warranty_status', 'warrantyStatus') ?? '')\n appendLine(lines, 'Disposition', readString(row, 'disposition', 'disposition') ?? '')\n appendLine(lines, 'Line status', readString(row, 'line_status', 'lineStatus') ?? '')\n }\n}\n\nfunction buildClaimUrl(record: Record<string, unknown>): string | null {\n const claimId = resolveClaimId(record)\n return claimId ? `/backend/warranty_claims/${claimId}` : null\n}\n\nexport const searchConfig: SearchModuleConfig = {\n entities: [\n {\n entityId: E.warranty_claims.warranty_claim,\n enabled: true,\n priority: 20,\n\n buildSource: async (ctx: SearchBuildContext): Promise<SearchIndexSource | null> => {\n assertTenantContext(ctx)\n const record = ctx.record\n const lines: string[] = []\n appendLine(lines, 'Claim number', readString(record, 'claim_number', 'claimNumber') ?? '')\n appendLine(lines, 'Customer', readString(record, 'customer_name', 'customerName') ?? '')\n appendLine(lines, 'Order number', readString(record, 'order_number', 'orderNumber') ?? '')\n appendLine(lines, 'Claim type', readString(record, 'claim_type', 'claimType') ?? '')\n appendLine(lines, 'Status', readString(record, 'status', 'status') ?? '')\n appendLine(lines, 'Vendor', readString(record, 'vendor_name', 'vendorName') ?? '')\n appendLine(lines, 'Vendor reference', readString(record, 'vendor_ref', 'vendorRef') ?? '')\n\n const claimId = resolveClaimId(record)\n const lineRows = claimId ? await loadClaimLineRows(ctx, claimId) : []\n appendLineRows(lines, lineRows)\n if (!lines.length) return null\n\n const presenter = await resolvePresenter(record)\n const url = buildClaimUrl(record)\n const links: SearchResultLink[] = url ? [{ href: url, label: presenter.title, kind: 'primary' }] : []\n return {\n text: lines,\n presenter,\n links,\n checksumSource: {\n record,\n customFields: ctx.customFields,\n lineRows,\n },\n }\n },\n\n formatResult: async (ctx: SearchBuildContext): Promise<SearchResultPresenter | null> => {\n assertTenantContext(ctx)\n return resolvePresenter(ctx.record)\n },\n\n resolveUrl: (ctx: SearchBuildContext): string | null => buildClaimUrl(ctx.record),\n\n resolveLinks: async (ctx: SearchBuildContext): Promise<SearchResultLink[] | null> => {\n const url = buildClaimUrl(ctx.record)\n if (!url) return null\n const { t } = await resolveTranslations()\n return [{ href: url, label: t('warranty_claims.search.openClaim', 'Open claim'), kind: 'secondary' }]\n },\n\n fieldPolicy: {\n searchable: [\n 'claim_number',\n 'claim_type',\n 'status',\n 'priority',\n 'customer_name',\n 'vendor_name',\n 'vendor_ref',\n ],\n hashOnly: ['customer_id', 'order_id', 'sales_return_id', 'replacement_order_id', 'source_claim_id'],\n excluded: [\n 'tenant_id',\n 'organization_id',\n 'assignee_user_id',\n 'deleted_at',\n 'notes',\n 'resolution_summary',\n 'contact_email',\n 'fault_description',\n 'inspection_notes',\n 'rejection_reason_code',\n ],\n },\n aclFeatures: ['warranty_claims.claim.view'],\n },\n ],\n}\n\nexport default searchConfig\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.10.1-develop.7827.1.5c631908e4",
3
+ "version": "0.10.1-develop.7836.1.0ef0f453c6",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -266,16 +266,16 @@
266
266
  "zod": "^4.6.6"
267
267
  },
268
268
  "peerDependencies": {
269
- "@open-mercato/ai-assistant": "0.10.1-develop.7827.1.5c631908e4",
270
- "@open-mercato/shared": "0.10.1-develop.7827.1.5c631908e4",
271
- "@open-mercato/ui": "0.10.1-develop.7827.1.5c631908e4",
269
+ "@open-mercato/ai-assistant": "0.10.1-develop.7836.1.0ef0f453c6",
270
+ "@open-mercato/shared": "0.10.1-develop.7836.1.0ef0f453c6",
271
+ "@open-mercato/ui": "0.10.1-develop.7836.1.0ef0f453c6",
272
272
  "react": "^19.0.0",
273
273
  "react-dom": "^19.0.0"
274
274
  },
275
275
  "devDependencies": {
276
- "@open-mercato/ai-assistant": "0.10.1-develop.7827.1.5c631908e4",
277
- "@open-mercato/shared": "0.10.1-develop.7827.1.5c631908e4",
278
- "@open-mercato/ui": "0.10.1-develop.7827.1.5c631908e4",
276
+ "@open-mercato/ai-assistant": "0.10.1-develop.7836.1.0ef0f453c6",
277
+ "@open-mercato/shared": "0.10.1-develop.7836.1.0ef0f453c6",
278
+ "@open-mercato/ui": "0.10.1-develop.7836.1.0ef0f453c6",
279
279
  "@testing-library/dom": "^10.4.1",
280
280
  "@testing-library/jest-dom": "^7.0.1",
281
281
  "@testing-library/react": "^16.3.3",
@@ -0,0 +1,26 @@
1
+ import { createRequire } from 'node:module'
2
+ import path from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+
5
+ /**
6
+ * Imports a package module from the same copy the app under test runs on.
7
+ *
8
+ * A spec that bootstraps the app in-process (`bootstrapFromAppRoot`) and then calls into its
9
+ * runtime — the command bus, `withAtomicFlush`, transaction lifetime — must load every one of those
10
+ * modules from a single copy. Against a standalone app (`OM_TEST_APP_ROOT`) the app's generated
11
+ * registries import `node_modules/@open-mercato/*` inside the app, while the spec's own static
12
+ * imports resolve to the monorepo's packages, so module-local state such as the command registry
13
+ * splits in two: handlers register in one copy and the bus resolves them in the other.
14
+ *
15
+ * Without `OM_TEST_APP_ROOT` this is a plain dynamic import, so the monorepo lane is unchanged.
16
+ */
17
+ export async function importFromAppRuntime<TModule>(specifier: string): Promise<TModule> {
18
+ const resolvedPath = resolveAppRuntimeModulePath(specifier)
19
+ return (await import(resolvedPath ? pathToFileURL(resolvedPath).href : specifier)) as TModule
20
+ }
21
+
22
+ export function resolveAppRuntimeModulePath(specifier: string, appRoot = process.env.OM_TEST_APP_ROOT): string | null {
23
+ const standaloneRoot = appRoot?.trim()
24
+ if (!standaloneRoot) return null
25
+ return createRequire(path.join(path.resolve(standaloneRoot), 'package.json')).resolve(specifier)
26
+ }
@@ -184,7 +184,7 @@ export const openApi: OpenApiRouteDoc = {
184
184
  { status: 409, description: 'Optimistic-lock conflict' },
185
185
  { status: 422, description: 'Idempotency key mismatch' },
186
186
  { status: 429, description: 'Rate limited' },
187
- { status: 503, description: 'No store context resolvable for this request (§6a.1a)' },
187
+ { status: 503, description: 'No store context resolvable, or cart_busy after 15 seconds waiting for the line lock. The requested line add/bulk-add phases did not run; earlier activity/context/repricing may have persisted. Retry the same request after Retry-After: 1 second, preserving Idempotency-Key.' },
188
188
  ],
189
189
  },
190
190
  },
@@ -201,7 +201,7 @@ export const openApi: OpenApiRouteDoc = {
201
201
  { status: 409, description: 'Optimistic-lock conflict, or the cart is a `proposal` cart' },
202
202
  { status: 422, description: 'Quantity rule violation, idempotency key mismatch, or an unpriceable product' },
203
203
  { status: 429, description: 'Rate limited' },
204
- { status: 503, description: 'No store context resolvable for this request (§6a.1a)' },
204
+ { status: 503, description: 'No store context resolvable, or cart_busy after 15 seconds waiting for the line lock. The requested line add/bulk-add phases did not run; earlier activity/context/repricing may have persisted. Retry the same request after Retry-After: 1 second, preserving Idempotency-Key.' },
205
205
  ],
206
206
  },
207
207
  },
@@ -57,6 +57,7 @@ import {
57
57
  } from '../lib/visibility'
58
58
  import type { CartVisibilityLineRef, ResolvedAssortmentScope } from '../lib/visibility'
59
59
  import { shouldEnforceAssortmentScope } from '../lib/routeContext'
60
+ import { withCartLineMutationLock } from '../lib/lineMutationLock'
60
61
  import type { ResolvedCartRouteContext } from '../lib/routeContext'
61
62
  import { buildRecomputeCartTotalsPhase } from '../lib/reprice'
62
63
  import type { CartPriceChange } from '../lib/reprice'
@@ -938,10 +939,7 @@ const addLineCommand: CommandHandler<CartLinesAddInput, CartLinesAddOutcome> = {
938
939
  // `cart.lines.add`/`.bulkAdd` call against this cart instead, closing the race
939
940
  // `TC-CART-CONC-05` reproduced (a silently lost update, and a raw 500 off
940
941
  // `cart_lines_unique_line_key`).
941
- await withAtomicFlush(em, [
942
- async () => {
943
- await em.execute('select pg_advisory_xact_lock(hashtextextended(?::text, 0))', [`cart:lines:${cart.id}`])
944
- },
942
+ await withCartLineMutationLock(em, cart.id, [
945
943
  async () => {
946
944
  const existing = await findExistingCustomerLine(em, scope, cart.id, input.productId, variantId, input.configuration ?? null)
947
945
  const pricingContext = buildCartPricingRowFilterContext(cart)
@@ -1049,7 +1047,7 @@ const addLineCommand: CommandHandler<CartLinesAddInput, CartLinesAddOutcome> = {
1049
1047
  if (!shouldRecompute()) return
1050
1048
  await phase()
1051
1049
  }),
1052
- ], { transaction: true, label: 'cart.lines.add' })
1050
+ ], 'cart.lines.add')
1053
1051
 
1054
1052
  if (!outcome) throw new Error('[internal] cart.lines.add produced no outcome')
1055
1053
  // Captured into plain `const`s (never reassigned themselves) rather than read directly
@@ -1641,10 +1639,7 @@ const bulkAddLinesCommand: CommandHandler<CartLinesBulkAddInput, CartLinesBulkAd
1641
1639
  // decision is read-decide-write against rows that may not exist yet, so two concurrent
1642
1640
  // `bulkAdd`/`add` calls on this cart must serialize, not just this call's own entries
1643
1641
  // against each other.
1644
- await withAtomicFlush(em, [
1645
- async () => {
1646
- await em.execute('select pg_advisory_xact_lock(hashtextextended(?::text, 0))', [`cart:lines:${cart.id}`])
1647
- },
1642
+ await withCartLineMutationLock(em, cart.id, [
1648
1643
  ...phases,
1649
1644
  ...buildCartPromotionsAndTotalsPhases({
1650
1645
  em,
@@ -1657,7 +1652,7 @@ const bulkAddLinesCommand: CommandHandler<CartLinesBulkAddInput, CartLinesBulkAd
1657
1652
  removedGiftLines,
1658
1653
  promotionWarnings,
1659
1654
  }),
1660
- ], { transaction: true, label: 'cart.lines.bulkAdd' })
1655
+ ], 'cart.lines.bulkAdd')
1661
1656
  await emitVisibilityRejectedEvent(cart, visibilityRejectedCount, 'lines.bulkAdd')
1662
1657
 
1663
1658
  const dataEngine = ctx.container.resolve('dataEngine') as DataEngine
@@ -4,6 +4,7 @@
4
4
  "cart.audit.lines.bulkAdd": "Add cart lines (bulk)",
5
5
  "cart.audit.lines.remove": "Remove cart line",
6
6
  "cart.audit.merge": "Merge cart",
7
+ "cart.errors.busy": "Der Warenkorb ist ausgelastet. Bitte versuche es erneut.",
7
8
  "cart.errors.store_context_unavailable": "Cart is unavailable for this store right now",
8
9
  "cart.errors.unexpected": "Unexpected error",
9
10
  "cart.warnings.code_no_longer_valid": "A previously applied code is no longer valid",
@@ -4,6 +4,7 @@
4
4
  "cart.audit.lines.bulkAdd": "Add cart lines (bulk)",
5
5
  "cart.audit.lines.remove": "Remove cart line",
6
6
  "cart.audit.merge": "Merge cart",
7
+ "cart.errors.busy": "Cart is busy. Please try again.",
7
8
  "cart.errors.store_context_unavailable": "Cart is unavailable for this store right now",
8
9
  "cart.errors.unexpected": "Unexpected error",
9
10
  "cart.warnings.code_no_longer_valid": "A previously applied code is no longer valid",
@@ -4,6 +4,7 @@
4
4
  "cart.audit.lines.bulkAdd": "Add cart lines (bulk)",
5
5
  "cart.audit.lines.remove": "Remove cart line",
6
6
  "cart.audit.merge": "Merge cart",
7
+ "cart.errors.busy": "El carrito está ocupado. Inténtalo de nuevo.",
7
8
  "cart.errors.store_context_unavailable": "Cart is unavailable for this store right now",
8
9
  "cart.errors.unexpected": "Unexpected error",
9
10
  "cart.warnings.code_no_longer_valid": "A previously applied code is no longer valid",
@@ -4,6 +4,7 @@
4
4
  "cart.audit.lines.bulkAdd": "Add cart lines (bulk)",
5
5
  "cart.audit.lines.remove": "Remove cart line",
6
6
  "cart.audit.merge": "Merge cart",
7
+ "cart.errors.busy": "장바구니가 사용 중입니다. 다시 시도해 주세요.",
7
8
  "cart.errors.store_context_unavailable": "Cart is unavailable for this store right now",
8
9
  "cart.errors.unexpected": "Unexpected error",
9
10
  "cart.warnings.code_no_longer_valid": "A previously applied code is no longer valid",
@@ -4,6 +4,7 @@
4
4
  "cart.audit.lines.bulkAdd": "Add cart lines (bulk)",
5
5
  "cart.audit.lines.remove": "Remove cart line",
6
6
  "cart.audit.merge": "Merge cart",
7
+ "cart.errors.busy": "Koszyk jest zajęty. Spróbuj ponownie.",
7
8
  "cart.errors.store_context_unavailable": "Cart is unavailable for this store right now",
8
9
  "cart.errors.unexpected": "Unexpected error",
9
10
  "cart.warnings.code_no_longer_valid": "A previously applied code is no longer valid",
@@ -10,6 +10,7 @@ import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
10
10
  import { getCartCommand } from '../commands/cart'
11
11
  import type { CartGetByTokenInput, CartSummary } from '../commands/cart'
12
12
  import type { CartPriceChange } from './reprice'
13
+ import { isCartLineMutationLockTimeoutError } from './lineMutationLock'
13
14
  import { isCartStoreContextUnavailableError, resolveCartRouteContext } from './routeContext'
14
15
  import type { ResolveCartRouteContextOptions, ResolvedCartRouteContext } from './routeContext'
15
16
 
@@ -173,6 +174,13 @@ const logger = createLogger('cart').child({ component: 'api' })
173
174
  * unexpected 500, logged and reported.
174
175
  */
175
176
  export async function mapRouteErrorToResponse(error: unknown): Promise<NextResponse> {
177
+ if (isCartLineMutationLockTimeoutError(error)) {
178
+ const { translate } = await resolveTranslations()
179
+ return NextResponse.json(
180
+ { error: translate('cart.errors.busy', 'Cart is busy. Please try again.'), code: 'cart_busy' },
181
+ { status: 503, headers: { 'Retry-After': '1', 'Cache-Control': 'no-store' } },
182
+ )
183
+ }
176
184
  if (isCrudHttpError(error)) return NextResponse.json(error.body, { status: error.status })
177
185
  const interceptorRejection = getCommandInterceptorHttpRejection(error)
178
186
  if (interceptorRejection) return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status })
@@ -0,0 +1,59 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
3
+ import { calculateBackoffDelayMs } from '@open-mercato/shared/lib/delivery/retry'
4
+
5
+ const CART_LINE_LOCK_TIMEOUT = Symbol.for('@open-mercato/cart:LineMutationLockTimeout')
6
+
7
+ export class CartLineMutationLockTimeoutError extends Error {
8
+ readonly [CART_LINE_LOCK_TIMEOUT] = true
9
+
10
+ constructor() {
11
+ super('[internal] Cart line mutation lock wait deadline exceeded')
12
+ this.name = 'CartLineMutationLockTimeoutError'
13
+ }
14
+ }
15
+
16
+ export function isCartLineMutationLockTimeoutError(error: unknown): error is CartLineMutationLockTimeoutError {
17
+ return typeof error === 'object' && error !== null && Reflect.get(error, CART_LINE_LOCK_TIMEOUT) === true
18
+ }
19
+
20
+ export async function withCartLineMutationLock(
21
+ em: EntityManager,
22
+ cartId: string,
23
+ phases: Array<() => void | Promise<void>>,
24
+ label: string,
25
+ waitDeadlineMs = 15_000,
26
+ ): Promise<void> {
27
+ const key = `cart:lines:${cartId}`
28
+ const deadline = Date.now() + waitDeadlineMs
29
+ const busy = new Error('[internal] Cart line mutation lock is busy')
30
+ const joinsTransaction = em.isInTransaction()
31
+
32
+ for (let attempt = 1; ; attempt += 1) {
33
+ try {
34
+ await withAtomicFlush(em, [
35
+ async () => {
36
+ if (joinsTransaction) {
37
+ await em.execute('select pg_advisory_xact_lock(hashtextextended(?::text, 0))', [key])
38
+ return
39
+ }
40
+ const rows = await em.execute<Array<{ locked: boolean }>>(
41
+ 'select pg_try_advisory_xact_lock(hashtextextended(?::text, 0)) as locked',
42
+ [key],
43
+ )
44
+ if (!rows[0]?.locked) throw busy
45
+ },
46
+ ...phases,
47
+ ], { transaction: true, label })
48
+ return
49
+ } catch (error) {
50
+ if (error !== busy || em.isInTransaction()) throw error
51
+ }
52
+
53
+ const remainingMs = deadline - Date.now()
54
+ if (remainingMs <= 0) throw new CartLineMutationLockTimeoutError()
55
+ const delayMs = calculateBackoffDelayMs(Math.min(attempt, 5), { baseDelayMs: 50, maxJitterMs: 25 })
56
+ await new Promise((resolve) => setTimeout(resolve, Math.min(delayMs, remainingMs)))
57
+ if (Date.now() >= deadline) throw new CartLineMutationLockTimeoutError()
58
+ }
59
+ }
@@ -646,8 +646,9 @@ const documentUpdateFieldsSchema = z
646
646
  currencyCode: currencyCodeSchema.optional(),
647
647
  // Order-only columns — SalesQuote declares none of them. Declared here
648
648
  // because the create schema declares them and a caller reusing its create
649
- // payload on an update otherwise lost them silently; `applyDocumentUpdate`
650
- // rejects each with a 400 on a quote rather than dropping it.
649
+ // payload on an update otherwise lost them silently. `applyDocumentUpdate`
650
+ // rejects them on a quote, except `totalsMode`, which is ignored to preserve
651
+ // quote-create compatibility without enabling external quote amounts.
651
652
  exchangeRate: z.union([z.null(), exchangeRateSchema]).optional(),
652
653
  paymentStatusEntryId: z.string().uuid().nullable().optional(),
653
654
  fulfillmentStatusEntryId: z.string().uuid().nullable().optional(),
@@ -5894,7 +5895,6 @@ const updateQuoteCommand: CommandHandler<
5894
5895
  },
5895
5896
  async execute(rawInput, ctx) {
5896
5897
  const parsed = documentUpdateSchema.parse(rawInput ?? {});
5897
- await assertAmountsModeUnsupportedOnQuote([parsed]);
5898
5898
  documentEditSchema.parse(rawInput ?? {});
5899
5899
  const em = (ctx.container.resolve("em") as EntityManager).fork();
5900
5900
  let quote!: SalesQuote;
@@ -7,7 +7,8 @@ import { buildOptimisticLockHeader } from "@open-mercato/ui/backend/utils/optimi
7
7
  import { deleteCrud } from "@open-mercato/ui/backend/utils/crud";
8
8
  import { normalizeCrudServerError } from "@open-mercato/ui/backend/utils/serverErrors";
9
9
  import { ErrorMessage, LoadingMessage, TabEmptyState } from "@open-mercato/ui/backend/detail";
10
- import { Button } from "@open-mercato/ui/primitives/button";
10
+ import { IconButton } from "@open-mercato/ui/primitives/icon-button";
11
+ import { SimpleTooltip } from "@open-mercato/ui/primitives/tooltip";
11
12
  import { flash } from "@open-mercato/ui/backend/FlashMessages";
12
13
  import { Pencil, Trash2 } from "lucide-react";
13
14
  import {
@@ -41,6 +42,53 @@ import { createLogger } from '@open-mercato/shared/lib/logger'
41
42
 
42
43
  const logger = createLogger('sales')
43
44
 
45
+ function DisabledActionHint({
46
+ label,
47
+ reason,
48
+ children,
49
+ }: {
50
+ label: string
51
+ reason?: string
52
+ children: React.ReactNode
53
+ }) {
54
+ const [open, setOpen] = React.useState(false)
55
+ const touchOpenRef = React.useRef(false)
56
+ if (!reason) return <>{children}</>
57
+ return (
58
+ <SimpleTooltip
59
+ content={reason}
60
+ open={open}
61
+ onOpenChange={(nextOpen) => {
62
+ if (nextOpen || !touchOpenRef.current) setOpen(nextOpen)
63
+ }}
64
+ >
65
+ <span
66
+ className="inline-flex rounded-md focus-visible:outline-none focus-visible:shadow-focus"
67
+ tabIndex={0}
68
+ aria-label={`${label}: ${reason}`}
69
+ onClick={(event) => event.stopPropagation()}
70
+ onPointerDown={(event) => {
71
+ if (event.pointerType !== 'touch') return
72
+ touchOpenRef.current = true
73
+ setOpen(true)
74
+ event.currentTarget.focus()
75
+ }}
76
+ onBlur={() => {
77
+ touchOpenRef.current = false
78
+ setOpen(false)
79
+ }}
80
+ onKeyDown={(event) => {
81
+ if (event.key !== 'Escape') return
82
+ touchOpenRef.current = false
83
+ setOpen(false)
84
+ }}
85
+ >
86
+ {children}
87
+ </span>
88
+ </SimpleTooltip>
89
+ )
90
+ }
91
+
44
92
  type ResolvedUnitPriceReference = {
45
93
  grossPerReference: number;
46
94
  referenceUnitCode: string;
@@ -932,11 +980,13 @@ export function SalesDocumentItemsSection({
932
980
  {discount.amount !== null ? (
933
981
  <span className="font-mono text-sm">
934
982
  {t(
935
- "sales.documents.items.table.discountAmount",
936
- "−{{value}}",
983
+ discount.amount < 0
984
+ ? "sales.documents.items.table.markupAmount"
985
+ : "sales.documents.items.table.discountAmount",
986
+ discount.amount < 0 ? "+{{value}}" : "−{{value}}",
937
987
  {
938
988
  value: formatMoney(
939
- discount.amount,
989
+ Math.abs(discount.amount),
940
990
  item.currencyCode ??
941
991
  currencyCode ??
942
992
  undefined,
@@ -1005,30 +1055,39 @@ export function SalesDocumentItemsSection({
1005
1055
  })}
1006
1056
  <td className="px-3 py-3">
1007
1057
  <div className="flex items-center gap-2 justify-end">
1008
- <Button
1009
- size="icon"
1010
- variant="ghost"
1011
- className="h-8 w-8"
1012
- aria-label={t('ui.actions.edit', 'Edit')}
1013
- disabled={amountsReadOnly}
1014
- onClick={(event) => {
1015
- event.stopPropagation();
1016
- handleEdit(item);
1017
- }}
1058
+ <DisabledActionHint
1059
+ label={t('ui.actions.edit', 'Edit')}
1060
+ reason={amountsReadOnly
1061
+ ? t('sales.documents.amountsExternalHint', 'Totals on this document come from an external system and are stored as supplied.')
1062
+ : undefined}
1018
1063
  >
1019
- <Pencil className="h-4 w-4" />
1020
- </Button>
1021
- <span
1022
- title={amountsReadOnly
1064
+ <IconButton
1065
+ type="button"
1066
+ size="default"
1067
+ variant="ghost"
1068
+ aria-label={t('ui.actions.edit', 'Edit')}
1069
+ disabled={amountsReadOnly}
1070
+ onClick={(event) => {
1071
+ event.stopPropagation();
1072
+ handleEdit(item);
1073
+ }}
1074
+ >
1075
+ <Pencil className="h-4 w-4" />
1076
+ </IconButton>
1077
+ </DisabledActionHint>
1078
+ <DisabledActionHint
1079
+ label={t('ui.actions.delete', 'Delete')}
1080
+ reason={amountsReadOnly
1023
1081
  ? t('sales.documents.amountsExternalHint', 'Totals on this document come from an external system and are stored as supplied.')
1024
1082
  : kind === 'order' && items.length === 1
1025
1083
  ? t('sales.documents.items.errorDeleteLast', 'An order must contain at least one line item.')
1026
1084
  : undefined}
1027
1085
  >
1028
- <Button
1029
- size="icon"
1086
+ <IconButton
1087
+ type="button"
1088
+ size="default"
1030
1089
  variant="ghost"
1031
- className="h-8 w-8 text-destructive"
1090
+ className="text-destructive"
1032
1091
  aria-label={t('ui.actions.delete', 'Delete')}
1033
1092
  disabled={amountsReadOnly || (kind === 'order' && items.length === 1)}
1034
1093
  onClick={(event) => {
@@ -1037,8 +1096,8 @@ export function SalesDocumentItemsSection({
1037
1096
  }}
1038
1097
  >
1039
1098
  <Trash2 className="h-4 w-4" />
1040
- </Button>
1041
- </span>
1099
+ </IconButton>
1100
+ </DisabledActionHint>
1042
1101
  </div>
1043
1102
  </td>
1044
1103
  </tr>
@@ -73,10 +73,10 @@ export function resolveLineDiscountDisplay(
73
73
  ): LineDiscountDisplay | null {
74
74
  const amount = normalizeNumber(line.discountAmount, 0)
75
75
  const percent = normalizeNumber(line.discountPercent, 0)
76
- if (amount > 0) {
76
+ if (amount !== 0) {
77
77
  return {
78
78
  amount,
79
- percent: percentAccountsForAmount(line, percent, amount) ? percent : null,
79
+ percent: amount > 0 && percentAccountsForAmount(line, percent, amount) ? percent : null,
80
80
  }
81
81
  }
82
82
  if (percent > 0) return { amount: null, percent }
@@ -1091,6 +1091,7 @@
1091
1091
  "sales.documents.items.table.discountAmount": "−{{value}}",
1092
1092
  "sales.documents.items.table.discountPercent": "{{value}} %",
1093
1093
  "sales.documents.items.table.gross": "brutto",
1094
+ "sales.documents.items.table.markupAmount": "+{{value}}",
1094
1095
  "sales.documents.items.table.net": "netto",
1095
1096
  "sales.documents.items.table.product": "Produkt",
1096
1097
  "sales.documents.items.table.quantity": "Menge",
@@ -1091,6 +1091,7 @@
1091
1091
  "sales.documents.items.table.discountAmount": "−{{value}}",
1092
1092
  "sales.documents.items.table.discountPercent": "{{value}}%",
1093
1093
  "sales.documents.items.table.gross": "gross",
1094
+ "sales.documents.items.table.markupAmount": "+{{value}}",
1094
1095
  "sales.documents.items.table.net": "net",
1095
1096
  "sales.documents.items.table.product": "Product",
1096
1097
  "sales.documents.items.table.quantity": "Qty",
@@ -1091,6 +1091,7 @@
1091
1091
  "sales.documents.items.table.discountAmount": "−{{value}}",
1092
1092
  "sales.documents.items.table.discountPercent": "{{value}} %",
1093
1093
  "sales.documents.items.table.gross": "bruto",
1094
+ "sales.documents.items.table.markupAmount": "+{{value}}",
1094
1095
  "sales.documents.items.table.net": "neto",
1095
1096
  "sales.documents.items.table.product": "Producto",
1096
1097
  "sales.documents.items.table.quantity": "Cant.",
@@ -1091,6 +1091,7 @@
1091
1091
  "sales.documents.items.table.discountAmount": "−{{value}}",
1092
1092
  "sales.documents.items.table.discountPercent": "{{value}}%",
1093
1093
  "sales.documents.items.table.gross": "세금 포함",
1094
+ "sales.documents.items.table.markupAmount": "+{{value}}",
1094
1095
  "sales.documents.items.table.net": "세금 제외",
1095
1096
  "sales.documents.items.table.product": "상품",
1096
1097
  "sales.documents.items.table.quantity": "수량",
@@ -1091,6 +1091,7 @@
1091
1091
  "sales.documents.items.table.discountAmount": "−{{value}}",
1092
1092
  "sales.documents.items.table.discountPercent": "{{value}}%",
1093
1093
  "sales.documents.items.table.gross": "brutto",
1094
+ "sales.documents.items.table.markupAmount": "+{{value}}",
1094
1095
  "sales.documents.items.table.net": "netto",
1095
1096
  "sales.documents.items.table.product": "Produkt",
1096
1097
  "sales.documents.items.table.quantity": "Ilość",
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "notifications.categories.warranty_claims": "Garantieclaims",
3
+ "search.entityType.warranty_claims.warranty_claim": "Garantieclaim",
3
4
  "warranty_claims.ai.apply": "Anwenden",
4
5
  "warranty_claims.ai.assess.attachment": "Anhang",
5
6
  "warranty_claims.ai.assess.attachmentsError": "Positionsanhänge konnten nicht geladen werden.",
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "notifications.categories.warranty_claims": "Warranty claims",
3
+ "search.entityType.warranty_claims.warranty_claim": "Warranty Claim",
3
4
  "warranty_claims.ai.apply": "Apply",
4
5
  "warranty_claims.ai.assess.attachment": "Attachment",
5
6
  "warranty_claims.ai.assess.attachmentsError": "Failed to load line attachments.",
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "notifications.categories.warranty_claims": "Reclamos de garantía",
3
+ "search.entityType.warranty_claims.warranty_claim": "Reclamo de garantía",
3
4
  "warranty_claims.ai.apply": "Aplicar",
4
5
  "warranty_claims.ai.assess.attachment": "Adjunto",
5
6
  "warranty_claims.ai.assess.attachmentsError": "No se pudieron cargar los adjuntos de la línea.",
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "notifications.categories.warranty_claims": "보증 클레임",
3
+ "search.entityType.warranty_claims.warranty_claim": "보증 클레임",
3
4
  "warranty_claims.ai.apply": "적용",
4
5
  "warranty_claims.ai.assess.attachment": "첨부파일",
5
6
  "warranty_claims.ai.assess.attachmentsError": "라인 첨부파일을 불러오지 못했습니다.",
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "notifications.categories.warranty_claims": "Reklamacje gwarancyjne",
3
+ "search.entityType.warranty_claims.warranty_claim": "Reklamacja gwarancyjna",
3
4
  "warranty_claims.ai.apply": "Zastosuj",
4
5
  "warranty_claims.ai.assess.attachment": "Załącznik",
5
6
  "warranty_claims.ai.assess.attachmentsError": "Nie udało się załadować załączników pozycji.",