@open-mercato/core 0.10.1-develop.7773.1.a41b0f6c03 → 0.10.1-develop.7775.1.2a1f553826

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 (33) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/cart/lib/promotionContext.js +3 -2
  3. package/dist/modules/cart/lib/promotionContext.js.map +2 -2
  4. package/dist/modules/customers/ai-tools.js.map +2 -2
  5. package/dist/modules/promotions/lib/promotions-service.js +5 -1
  6. package/dist/modules/promotions/lib/promotions-service.js.map +2 -2
  7. package/dist/modules/promotions/services/promotion-cache.js +35 -2
  8. package/dist/modules/promotions/services/promotion-cache.js.map +3 -3
  9. package/dist/modules/sales/api/documents/factory.js +1 -1
  10. package/dist/modules/sales/api/documents/factory.js.map +2 -2
  11. package/dist/modules/sales/api/orders/route.js +5 -4
  12. package/dist/modules/sales/api/orders/route.js.map +2 -2
  13. package/dist/modules/sales/api/quotes/route.js +5 -4
  14. package/dist/modules/sales/api/quotes/route.js.map +2 -2
  15. package/dist/modules/sales/backend/sales/documents/[id]/page.js +23 -1
  16. package/dist/modules/sales/backend/sales/documents/[id]/page.js.map +2 -2
  17. package/dist/modules/sales/components/documents/SalesDocumentExtensionSpot.js +26 -0
  18. package/dist/modules/sales/components/documents/SalesDocumentExtensionSpot.js.map +7 -0
  19. package/dist/modules/sales/extension-points.js +26 -0
  20. package/dist/modules/sales/extension-points.js.map +2 -2
  21. package/package.json +7 -7
  22. package/src/modules/cart/lib/promotionContext.ts +3 -7
  23. package/src/modules/catalog/AGENTS.md +2 -2
  24. package/src/modules/customers/AGENTS.md +2 -2
  25. package/src/modules/customers/ai-tools.ts +6 -9
  26. package/src/modules/promotions/lib/promotions-service.ts +5 -3
  27. package/src/modules/promotions/services/promotion-cache.ts +41 -4
  28. package/src/modules/sales/api/documents/factory.ts +1 -1
  29. package/src/modules/sales/api/orders/route.ts +4 -3
  30. package/src/modules/sales/api/quotes/route.ts +4 -3
  31. package/src/modules/sales/backend/sales/documents/[id]/page.tsx +19 -1
  32. package/src/modules/sales/components/documents/SalesDocumentExtensionSpot.tsx +44 -0
  33. package/src/modules/sales/extension-points.ts +26 -0
@@ -0,0 +1,26 @@
1
+ "use client";
2
+ import { jsx } from "react/jsx-runtime";
3
+ import { extensionPoints } from "@open-mercato/core/modules/sales/extension-points";
4
+ import { resolveExtensionPointPattern } from "@open-mercato/shared/modules/widgets/extension-points";
5
+ import { InjectionSpot } from "@open-mercato/ui/backend/injection/InjectionSpot";
6
+ function SalesDocumentExtensionSpot({
7
+ surface,
8
+ context,
9
+ data,
10
+ onDataChange
11
+ }) {
12
+ const pattern = surface === "stage-bar" ? extensionPoints.hosts.documentStageBar.pattern : extensionPoints.hosts.documentClosure.pattern;
13
+ return /* @__PURE__ */ jsx(
14
+ InjectionSpot,
15
+ {
16
+ spotId: resolveExtensionPointPattern(pattern, { kind: context.kind }),
17
+ context,
18
+ data,
19
+ onDataChange
20
+ }
21
+ );
22
+ }
23
+ export {
24
+ SalesDocumentExtensionSpot
25
+ };
26
+ //# sourceMappingURL=SalesDocumentExtensionSpot.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../../src/modules/sales/components/documents/SalesDocumentExtensionSpot.tsx"],
4
+ "sourcesContent": ["'use client'\n\nimport { extensionPoints } from '@open-mercato/core/modules/sales/extension-points'\nimport { resolveExtensionPointPattern } from '@open-mercato/shared/modules/widgets/extension-points'\nimport { InjectionSpot } from '@open-mercato/ui/backend/injection/InjectionSpot'\n\nexport type SalesDocumentExtensionContext<TRecord extends { id: string }> = {\n kind: 'order' | 'quote'\n documentId: string | null\n record: TRecord | null\n formId: string\n resourceKind: 'sales.order' | 'sales.quote'\n resourceId?: string\n retryLastMutation: () => Promise<boolean>\n organizationId: string | null\n tenantId: string | null\n}\n\ntype SalesDocumentExtensionSpotProps<TRecord extends { id: string }> = {\n surface: 'stage-bar' | 'closure'\n context: SalesDocumentExtensionContext<TRecord>\n data: TRecord\n onDataChange: (data: TRecord) => void\n}\n\nexport function SalesDocumentExtensionSpot<TRecord extends { id: string }>({\n surface,\n context,\n data,\n onDataChange,\n}: SalesDocumentExtensionSpotProps<TRecord>) {\n const pattern = surface === 'stage-bar'\n ? extensionPoints.hosts.documentStageBar.pattern\n : extensionPoints.hosts.documentClosure.pattern\n\n return (\n <InjectionSpot\n spotId={resolveExtensionPointPattern(pattern, { kind: context.kind })}\n context={context}\n data={data}\n onDataChange={onDataChange}\n />\n )\n}\n"],
5
+ "mappings": ";AAoCI;AAlCJ,SAAS,uBAAuB;AAChC,SAAS,oCAAoC;AAC7C,SAAS,qBAAqB;AAqBvB,SAAS,2BAA2D;AAAA,EACzE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAA6C;AAC3C,QAAM,UAAU,YAAY,cACxB,gBAAgB,MAAM,iBAAiB,UACvC,gBAAgB,MAAM,gBAAgB;AAE1C,SACE;AAAA,IAAC;AAAA;AAAA,MACC,QAAQ,6BAA6B,SAAS,EAAE,MAAM,QAAQ,KAAK,CAAC;AAAA,MACpE;AAAA,MACA;AAAA,MACA;AAAA;AAAA,EACF;AAEJ;",
6
+ "names": []
7
+ }
@@ -17,6 +17,32 @@ const extensionPoints = defineModuleExtensionPoints({
17
17
  supported: ["render-widget"],
18
18
  source: "backend/sales/documents/[id]/page.tsx"
19
19
  }),
20
+ documentStageBar: injectionExtensionHost({
21
+ family: "detail",
22
+ pattern: "detail:sales.{kind}:stage-bar",
23
+ parameters: {
24
+ kind: { source: "SalesDocumentExtensionContext.kind", pattern: "^(order|quote)$" }
25
+ },
26
+ supported: ["render-widget"],
27
+ source: ["components/documents/SalesDocumentExtensionSpot.tsx", "backend/sales/documents/[id]/page.tsx"],
28
+ contextContract: "SalesDocumentExtensionContext: kind, documentId, record, formId, resourceKind, resourceId, retryLastMutation, organizationId, tenantId",
29
+ dataContract: "Current sales order or quote record; onDataChange updates the in-page record only",
30
+ scopeContract: "organizationId and tenantId from the loaded, server-scoped document; widget writes must enforce their own authorization",
31
+ runtimeContract: "Rendered below the document header; no native stage transition or mutation lifecycle is added"
32
+ }),
33
+ documentClosure: injectionExtensionHost({
34
+ family: "detail",
35
+ pattern: "detail:sales.{kind}:closure",
36
+ parameters: {
37
+ kind: { source: "SalesDocumentExtensionContext.kind", pattern: "^(order|quote)$" }
38
+ },
39
+ supported: ["render-widget"],
40
+ source: ["components/documents/SalesDocumentExtensionSpot.tsx", "backend/sales/documents/[id]/page.tsx"],
41
+ contextContract: "SalesDocumentExtensionContext: kind, documentId, record, formId, resourceKind, resourceId, retryLastMutation, organizationId, tenantId",
42
+ dataContract: "Current sales order or quote record; onDataChange updates the in-page record only",
43
+ scopeContract: "organizationId and tenantId from the loaded, server-scoped document; widget writes must enforce their own authorization",
44
+ runtimeContract: "Rendered below document details for closure extensions; no native closure dialog or mutation lifecycle is added"
45
+ }),
20
46
  documentDetail: injectionExtensionHost({
21
47
  family: "detail",
22
48
  pattern: "sales.document.detail.{kind}:{surface}",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/modules/sales/extension-points.ts"],
4
- "sourcesContent": ["import { dataTableExtensionHost, defineModuleExtensionPoints, injectionExtensionHost } from '@open-mercato/shared/modules/widgets/extension-points'\n\nexport const extensionPoints = defineModuleExtensionPoints({\n moduleId: 'sales',\n hosts: {\n ordersTable: dataTableExtensionHost({ tableId: 'sales.orders', source: 'components/documents/SalesDocumentsTable.tsx' }),\n quotesTable: dataTableExtensionHost({ tableId: 'sales.quotes', source: 'components/documents/SalesDocumentsTable.tsx' }),\n paymentsTable: dataTableExtensionHost({ tableId: 'sales.payments', source: 'components/documents/PaymentsSection.tsx' }),\n orderItemColumns: injectionExtensionHost({\n family: 'detail',\n spotId: 'data-table:sales.order.items:columns',\n supported: ['column-widget'],\n source: 'components/documents/ItemsSection.tsx',\n }),\n orderShipping: injectionExtensionHost({\n family: 'detail',\n spotId: 'detail:sales.order:shipping',\n supported: ['render-widget'],\n source: 'backend/sales/documents/[id]/page.tsx',\n }),\n documentDetail: injectionExtensionHost({\n family: 'detail',\n pattern: 'sales.document.detail.{kind}:{surface}',\n parameters: {\n kind: { source: 'SalesDocumentKind', pattern: '^(order|quote)$' },\n surface: { source: 'host declaration', pattern: '^(tabs|details)$' },\n },\n supported: ['render-widget'],\n source: 'backend/sales/documents/[id]/page.tsx',\n }),\n },\n})\n\nexport default extensionPoints\n"],
5
- "mappings": "AAAA,SAAS,wBAAwB,6BAA6B,8BAA8B;AAErF,MAAM,kBAAkB,4BAA4B;AAAA,EACzD,UAAU;AAAA,EACV,OAAO;AAAA,IACL,aAAa,uBAAuB,EAAE,SAAS,gBAAgB,QAAQ,+CAA+C,CAAC;AAAA,IACvH,aAAa,uBAAuB,EAAE,SAAS,gBAAgB,QAAQ,+CAA+C,CAAC;AAAA,IACvH,eAAe,uBAAuB,EAAE,SAAS,kBAAkB,QAAQ,2CAA2C,CAAC;AAAA,IACvH,kBAAkB,uBAAuB;AAAA,MACvC,QAAQ;AAAA,MACR,QAAQ;AAAA,MACR,WAAW,CAAC,eAAe;AAAA,MAC3B,QAAQ;AAAA,IACV,CAAC;AAAA,IACD,eAAe,uBAAuB;AAAA,MACpC,QAAQ;AAAA,MACR,QAAQ;AAAA,MACR,WAAW,CAAC,eAAe;AAAA,MAC3B,QAAQ;AAAA,IACV,CAAC;AAAA,IACD,gBAAgB,uBAAuB;AAAA,MACrC,QAAQ;AAAA,MACR,SAAS;AAAA,MACT,YAAY;AAAA,QACV,MAAM,EAAE,QAAQ,qBAAqB,SAAS,kBAAkB;AAAA,QAChE,SAAS,EAAE,QAAQ,oBAAoB,SAAS,mBAAmB;AAAA,MACrE;AAAA,MACA,WAAW,CAAC,eAAe;AAAA,MAC3B,QAAQ;AAAA,IACV,CAAC;AAAA,EACH;AACF,CAAC;AAED,IAAO,2BAAQ;",
4
+ "sourcesContent": ["import { dataTableExtensionHost, defineModuleExtensionPoints, injectionExtensionHost } from '@open-mercato/shared/modules/widgets/extension-points'\n\nexport const extensionPoints = defineModuleExtensionPoints({\n moduleId: 'sales',\n hosts: {\n ordersTable: dataTableExtensionHost({ tableId: 'sales.orders', source: 'components/documents/SalesDocumentsTable.tsx' }),\n quotesTable: dataTableExtensionHost({ tableId: 'sales.quotes', source: 'components/documents/SalesDocumentsTable.tsx' }),\n paymentsTable: dataTableExtensionHost({ tableId: 'sales.payments', source: 'components/documents/PaymentsSection.tsx' }),\n orderItemColumns: injectionExtensionHost({\n family: 'detail',\n spotId: 'data-table:sales.order.items:columns',\n supported: ['column-widget'],\n source: 'components/documents/ItemsSection.tsx',\n }),\n orderShipping: injectionExtensionHost({\n family: 'detail',\n spotId: 'detail:sales.order:shipping',\n supported: ['render-widget'],\n source: 'backend/sales/documents/[id]/page.tsx',\n }),\n documentStageBar: injectionExtensionHost({\n family: 'detail',\n pattern: 'detail:sales.{kind}:stage-bar',\n parameters: {\n kind: { source: 'SalesDocumentExtensionContext.kind', pattern: '^(order|quote)$' },\n },\n supported: ['render-widget'],\n source: ['components/documents/SalesDocumentExtensionSpot.tsx', 'backend/sales/documents/[id]/page.tsx'],\n contextContract: 'SalesDocumentExtensionContext: kind, documentId, record, formId, resourceKind, resourceId, retryLastMutation, organizationId, tenantId',\n dataContract: 'Current sales order or quote record; onDataChange updates the in-page record only',\n scopeContract: 'organizationId and tenantId from the loaded, server-scoped document; widget writes must enforce their own authorization',\n runtimeContract: 'Rendered below the document header; no native stage transition or mutation lifecycle is added',\n }),\n documentClosure: injectionExtensionHost({\n family: 'detail',\n pattern: 'detail:sales.{kind}:closure',\n parameters: {\n kind: { source: 'SalesDocumentExtensionContext.kind', pattern: '^(order|quote)$' },\n },\n supported: ['render-widget'],\n source: ['components/documents/SalesDocumentExtensionSpot.tsx', 'backend/sales/documents/[id]/page.tsx'],\n contextContract: 'SalesDocumentExtensionContext: kind, documentId, record, formId, resourceKind, resourceId, retryLastMutation, organizationId, tenantId',\n dataContract: 'Current sales order or quote record; onDataChange updates the in-page record only',\n scopeContract: 'organizationId and tenantId from the loaded, server-scoped document; widget writes must enforce their own authorization',\n runtimeContract: 'Rendered below document details for closure extensions; no native closure dialog or mutation lifecycle is added',\n }),\n documentDetail: injectionExtensionHost({\n family: 'detail',\n pattern: 'sales.document.detail.{kind}:{surface}',\n parameters: {\n kind: { source: 'SalesDocumentKind', pattern: '^(order|quote)$' },\n surface: { source: 'host declaration', pattern: '^(tabs|details)$' },\n },\n supported: ['render-widget'],\n source: 'backend/sales/documents/[id]/page.tsx',\n }),\n },\n})\n\nexport default extensionPoints\n"],
5
+ "mappings": "AAAA,SAAS,wBAAwB,6BAA6B,8BAA8B;AAErF,MAAM,kBAAkB,4BAA4B;AAAA,EACzD,UAAU;AAAA,EACV,OAAO;AAAA,IACL,aAAa,uBAAuB,EAAE,SAAS,gBAAgB,QAAQ,+CAA+C,CAAC;AAAA,IACvH,aAAa,uBAAuB,EAAE,SAAS,gBAAgB,QAAQ,+CAA+C,CAAC;AAAA,IACvH,eAAe,uBAAuB,EAAE,SAAS,kBAAkB,QAAQ,2CAA2C,CAAC;AAAA,IACvH,kBAAkB,uBAAuB;AAAA,MACvC,QAAQ;AAAA,MACR,QAAQ;AAAA,MACR,WAAW,CAAC,eAAe;AAAA,MAC3B,QAAQ;AAAA,IACV,CAAC;AAAA,IACD,eAAe,uBAAuB;AAAA,MACpC,QAAQ;AAAA,MACR,QAAQ;AAAA,MACR,WAAW,CAAC,eAAe;AAAA,MAC3B,QAAQ;AAAA,IACV,CAAC;AAAA,IACD,kBAAkB,uBAAuB;AAAA,MACvC,QAAQ;AAAA,MACR,SAAS;AAAA,MACT,YAAY;AAAA,QACV,MAAM,EAAE,QAAQ,sCAAsC,SAAS,kBAAkB;AAAA,MACnF;AAAA,MACA,WAAW,CAAC,eAAe;AAAA,MAC3B,QAAQ,CAAC,uDAAuD,uCAAuC;AAAA,MACvG,iBAAiB;AAAA,MACjB,cAAc;AAAA,MACd,eAAe;AAAA,MACf,iBAAiB;AAAA,IACnB,CAAC;AAAA,IACD,iBAAiB,uBAAuB;AAAA,MACtC,QAAQ;AAAA,MACR,SAAS;AAAA,MACT,YAAY;AAAA,QACV,MAAM,EAAE,QAAQ,sCAAsC,SAAS,kBAAkB;AAAA,MACnF;AAAA,MACA,WAAW,CAAC,eAAe;AAAA,MAC3B,QAAQ,CAAC,uDAAuD,uCAAuC;AAAA,MACvG,iBAAiB;AAAA,MACjB,cAAc;AAAA,MACd,eAAe;AAAA,MACf,iBAAiB;AAAA,IACnB,CAAC;AAAA,IACD,gBAAgB,uBAAuB;AAAA,MACrC,QAAQ;AAAA,MACR,SAAS;AAAA,MACT,YAAY;AAAA,QACV,MAAM,EAAE,QAAQ,qBAAqB,SAAS,kBAAkB;AAAA,QAChE,SAAS,EAAE,QAAQ,oBAAoB,SAAS,mBAAmB;AAAA,MACrE;AAAA,MACA,WAAW,CAAC,eAAe;AAAA,MAC3B,QAAQ;AAAA,IACV,CAAC;AAAA,EACH;AACF,CAAC;AAED,IAAO,2BAAQ;",
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.7773.1.a41b0f6c03",
3
+ "version": "0.10.1-develop.7775.1.2a1f553826",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -266,16 +266,16 @@
266
266
  "zod": "^4.4.3"
267
267
  },
268
268
  "peerDependencies": {
269
- "@open-mercato/ai-assistant": "0.10.1-develop.7773.1.a41b0f6c03",
270
- "@open-mercato/shared": "0.10.1-develop.7773.1.a41b0f6c03",
271
- "@open-mercato/ui": "0.10.1-develop.7773.1.a41b0f6c03",
269
+ "@open-mercato/ai-assistant": "0.10.1-develop.7775.1.2a1f553826",
270
+ "@open-mercato/shared": "0.10.1-develop.7775.1.2a1f553826",
271
+ "@open-mercato/ui": "0.10.1-develop.7775.1.2a1f553826",
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.7773.1.a41b0f6c03",
277
- "@open-mercato/shared": "0.10.1-develop.7773.1.a41b0f6c03",
278
- "@open-mercato/ui": "0.10.1-develop.7773.1.a41b0f6c03",
276
+ "@open-mercato/ai-assistant": "0.10.1-develop.7775.1.2a1f553826",
277
+ "@open-mercato/shared": "0.10.1-develop.7775.1.2a1f553826",
278
+ "@open-mercato/ui": "0.10.1-develop.7775.1.2a1f553826",
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",
@@ -1,3 +1,4 @@
1
+ import { TransactionContext } from '@mikro-orm/core'
1
2
  import type { EntityManager } from '@mikro-orm/postgresql'
2
3
  import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
3
4
  import { CatalogProduct, CatalogProductVariant } from '@open-mercato/core/modules/catalog/data/entities'
@@ -163,7 +164,7 @@ export type BuildPromotionsEffectsPhaseParams = {
163
164
  export function buildPromotionsEffectsPhase(params: BuildPromotionsEffectsPhaseParams): () => Promise<void> {
164
165
  const { em, container, cart, lines, assortmentScope, tenantScope, now, adjustments, warnings, createdGiftLines, removedGiftLines } = params
165
166
 
166
- return async () => {
167
+ return () => TransactionContext.create(em, async () => {
167
168
  const promotionsService = container.resolve<PromotionsService>('promotionsService')
168
169
  const salesCalculationService = container.resolve<SalesCalculationService>('salesCalculationService')
169
170
 
@@ -181,11 +182,6 @@ export function buildPromotionsEffectsPhase(params: BuildPromotionsEffectsPhaseP
181
182
  const codeIdsInUse = Array.from(
182
183
  new Set(existingApplications.map((application) => application.codeId).filter((id): id is string => !!id)),
183
184
  )
184
- // A code-id's reservation lives on `promotions`' own tables, not this cart's `em` — so
185
- // extending it and then re-checking it still exists is two round trips against a
186
- // DIFFERENT EntityManager (`extendReservation`'s own `resolvePromotionsEm` fork) plus a
187
- // plain cross-module read on THIS `em`, never a read-after-mutate hazard on this phase's
188
- // own pending changes (there are none yet at this point in the phase).
189
185
  const lapsedCodeIds = new Set<string>()
190
186
  for (const codeId of codeIdsInUse) {
191
187
  await promotionsService.extendReservation(codeId, cart.id)
@@ -566,5 +562,5 @@ export function buildPromotionsEffectsPhase(params: BuildPromotionsEffectsPhaseP
566
562
  // has the full `appliedPromotions` shape to read from, same "found and closed before
567
563
  // anyone needed it" style this run's earlier steps use for a half-wired field.
568
564
  cart.promotionEffects = result.appliedPromotions
569
- }
565
+ })
570
566
  }
@@ -112,6 +112,6 @@ Two typed AI agents ship from `ai-agents.ts`. See `/framework/ai-assistant/agent
112
112
  | Agent ID | Mode | Policy | Purpose |
113
113
  |----------|------|--------|---------|
114
114
  | `catalog.catalog_assistant` | chat | `read-only` | General operator explorer for products, categories, variants, prices, offers, product media, tags, option schemas, and unit conversions via the base catalog tool pack + general-purpose packs. |
115
- | `catalog.merchandising_assistant` | chat | `read-only` (mutation-capable via per-tenant override unlocking `catalog.update_product`, `catalog.bulk_update_products`, `catalog.apply_attribute_extraction`, `catalog.update_product_media_descriptions`) | D18 demo agent: proposes descriptions, attribute extractions, title variants, and price adjustments for the current selection on the products list page. |
115
+ | `catalog.merchandising_assistant` | chat | `confirm-required`; tenant overrides may restrict to `read-only`, never escalate | D18 agent: proposes descriptions, attribute extractions, title variants, and price adjustments; whitelisted product, batch, attribute, and media writes require approval. See `ai-agents.ts`. |
116
116
 
117
- The merchandising assistant is the Phase 2 D18 demo. `<AiChat agent="catalog.merchandising_assistant" />` is injected via `MerchandisingAssistantSheet.tsx` on `/backend/catalog/catalog/products` (see `packages/core/src/modules/catalog/backend/catalog/products/page.tsx`). Mutation-capable authoring tools route through `prepareMutation` + the approval-card contract; confirmed batches refresh the DataTable via `catalog.product.updated` events on the DOM event bridge.
117
+ The merchandising assistant is the D18 demo. `<AiChat agent="catalog.merchandising_assistant" />` is injected via `MerchandisingAssistantSheet.tsx` on `/backend/catalog/products` (see `packages/core/src/modules/catalog/backend/catalog/products/page.tsx`). Mutation tools route through `prepareMutation` + the approval-card contract; confirmed batches refresh the DataTable via `catalog.product.updated` events on the DOM event bridge.
@@ -125,8 +125,8 @@ This module is the reference implementation for the AI framework. Copy `ai-agent
125
125
 
126
126
  | Agent ID | Mode | Policy | Purpose |
127
127
  |----------|------|--------|---------|
128
- | `customers.account_assistant` | chat | read-only (mutation-capable via per-tenant override that unlocks `customers.update_deal_stage`; see `packages/core/src/modules/customers/ai-agents.ts` for the whitelist and prompt) | Operator-facing assistant that explores people, companies, deals, activities, tasks, addresses, tags, and settings through the customers tool pack. |
129
- | `customers.update_deal_stage` | tool (mutation) | `destructive-confirm-required` — goes through `prepareMutation` + the approval card | Moves a deal between stages / flips status between open, won, lost. Declared via `defineAiTool` in `ai-tools.ts` and exposed only when the tenant mutation-policy override promotes the agent above read-only. |
128
+ | `customers.account_assistant` | chat | `confirm-required`; tenant overrides may restrict to `read-only`, never escalate | Explores people, companies, deals, activities, tasks, addresses, tags, and settings; whitelisted stage, comment, and activity writes require approval. See `ai-agents.ts`. |
129
+ | `customers.update_deal_stage` | tool (mutation) | Agent mutation policy applies through `prepareMutation` + the approval card | Moves deals between stages or open/won/lost statuses. Declared in `ai-tools/deals-pack.ts`; blocked when the agent is read-only. |
130
130
  | `customers.deal_analyzer` | chat, `executionEngine: 'stream-text'` | `confirm-required` | Multi-step agentic demo that exercises loop primitives from PRs #1856..#1869. Calls `customers.analyze_deals`, ranks deals by health score, then calls `customers.update_deal_stage` for the highest-value stalled deal — loop stops immediately after (loop.stopWhen). `loop.prepareStep` scopes the active tool set by step without overriding the model. It intentionally leaves `defaultProvider` and `defaultModel` unset so runtime resolution uses `OM_AI_PROVIDER` / `OM_AI_MODEL` or tenant/runtime overrides. |
131
131
  | `customers.deal_analyzer_tool_loop` | chat, `executionEngine: 'tool-loop-agent'` | `confirm-required` | Sibling agent identical to `customers.deal_analyzer` but dispatched via `ToolLoopAgent`. Proves the mutation-approval gate holds for both execution engines (TC-AI-AGENT-LOOP-006). |
132
132
 
@@ -1,18 +1,15 @@
1
1
  /**
2
- * Module-root AI tool contribution for the customers module
3
- * (Phase 1 WS-C, Step 3.9 — read-only Phase 1 surface).
2
+ * Module-root AI tool contribution for the customers module.
4
3
  *
5
4
  * The generator walks every module for a top-level `ai-tools.ts` and takes
6
5
  * the default/`aiTools` export as the contribution. This file aggregates the
7
- * six customers packs (people, companies, deals, activities+tasks,
8
- * addresses+tags, settings) so they all flow through the existing
6
+ * customers packs (people, companies, deals, activities+tasks,
7
+ * addresses+tags, settings, deal analysis) so they all flow through the existing
9
8
  * `ai-tools.generated.ts` pipeline without any generator changes.
10
9
  *
11
- * Mutation tools are deferred to Step 5.13+ under the pending-action contract;
12
- * every tool here is read-only and enforces tenant + organization scoping via
13
- * the existing encryption helpers. See
14
- * `.ai/runs/2026-04-18-ai-framework-unification/step-3.9-checks.md` for the
15
- * matrix of required features and decisions.
10
+ * Read and mutation tools enforce tenant + organization scoping. Mutations
11
+ * use the pending-action approval contract and command-backed writes;
12
+ * read-only agents cannot expose them. See the framework AI agents guide.
16
13
  */
17
14
  import peopleAiTools from './ai-tools/people-pack'
18
15
  import companiesAiTools from './ai-tools/companies-pack'
@@ -1,3 +1,4 @@
1
+ import { TransactionContext } from '@mikro-orm/core'
1
2
  import type { EntityManager } from '@mikro-orm/postgresql'
2
3
  import { computeProductScopeKeys, PRODUCT_SCOPE_CATEGORY_PREFIX } from '@open-mercato/core/modules/catalog/lib/productScopeKeys'
3
4
  import { Currency } from '@open-mercato/core/modules/currencies/data/entities'
@@ -91,9 +92,10 @@ function resolvePromotionsServiceScope(container: PromotionsServiceContainer): P
91
92
  }
92
93
 
93
94
  function resolvePromotionsEm(container: PromotionsServiceContainer): EntityManager {
94
- // Same fork-on-resolve precedent as `services/promotion-cache.ts`'s
95
- // `resolvePromotionEm` / `commands/promotions.ts`'s command handlers.
96
- return container.resolve<EntityManager>('em').fork()
95
+ const registeredEm = container.resolve<EntityManager>('em')
96
+ const contextualEm = TransactionContext.getEntityManager(registeredEm.name) as EntityManager | undefined
97
+ const em = contextualEm && contextualEm.getDriver() === registeredEm.getDriver() ? contextualEm : registeredEm
98
+ return em.fork({ keepTransactionContext: true, disableContextResolution: Boolean(contextualEm && contextualEm !== em) })
97
99
  }
98
100
 
99
101
  // Step 6.2's five code-service methods need the full `auth` (not just tenant/org scope)
@@ -1,3 +1,4 @@
1
+ import { TransactionContext } from '@mikro-orm/core'
1
2
  import type { EntityManager } from '@mikro-orm/postgresql'
2
3
  import { runWithCacheTenant, type CacheStrategy } from '@open-mercato/cache'
3
4
  import { createLogger } from '@open-mercato/shared/lib/logger'
@@ -284,6 +285,7 @@ export type PromotionCacheContainer = {
284
285
  }
285
286
 
286
287
  function resolvePromotionCacheStrategy(container: PromotionCacheContainer): CacheStrategy | null {
288
+ if (resolvePromotionEm(container).isInTransaction()) return null
287
289
  try {
288
290
  return container.resolve<CacheStrategy>('cache') ?? null
289
291
  } catch {
@@ -292,15 +294,21 @@ function resolvePromotionCacheStrategy(container: PromotionCacheContainer): Cach
292
294
  }
293
295
 
294
296
  function resolvePromotionEm(container: PromotionCacheContainer): EntityManager {
295
- // Exact precedent: `commands/promotions.ts` resolves/forks the same way —
296
- // `(ctx.container.resolve('em') as EntityManager).fork()` — before any read/write.
297
- return (container.resolve<EntityManager>('em')).fork()
297
+ const registeredEm = container.resolve<EntityManager>('em')
298
+ const contextualEm = TransactionContext.getEntityManager(registeredEm.name) as EntityManager | undefined
299
+ const em = contextualEm && contextualEm.getDriver() === registeredEm.getDriver() ? contextualEm : registeredEm
300
+ return em.fork({ keepTransactionContext: true, disableContextResolution: Boolean(contextualEm && contextualEm !== em) })
298
301
  }
299
302
 
300
303
  export class PromotionCacheService {
301
304
  constructor(private readonly container: PromotionCacheContainer) {}
302
305
 
303
306
  async getActivePromotions(tenantId: string, organizationId: string): Promise<NormalizedPromotion[]> {
307
+ const em = resolvePromotionEm(this.container)
308
+ if (em.isInTransaction()) {
309
+ const promotions = await this.buildActivePromotionTrees(tenantId, organizationId)
310
+ return this.buildTransactionalUsageSnapshot(em, promotions, tenantId, organizationId)
311
+ }
304
312
  const cache = resolvePromotionCacheStrategy(this.container)
305
313
  const treeKey = buildPromotionTreeCacheKey(tenantId, organizationId)
306
314
 
@@ -317,6 +325,35 @@ export class PromotionCacheService {
317
325
  )
318
326
  }
319
327
 
328
+ private async buildTransactionalUsageSnapshot(
329
+ em: EntityManager,
330
+ promotions: NormalizedPromotion[],
331
+ tenantId: string,
332
+ organizationId: string,
333
+ ): Promise<NormalizedPromotion[]> {
334
+ const budgeted = promotions.filter((promotion) => promotion.maxBudget !== null)
335
+ const currencies = [...new Set(budgeted.map((promotion) => promotion.budgetCurrency).filter((currency): currency is string => !!currency))]
336
+ const totals = new Map<string, string>()
337
+ if (budgeted.length > 0 && currencies.length > 0) {
338
+ const promotionIds = budgeted.map((promotion) => promotion.id)
339
+ const promotionSlots = promotionIds.map(() => '?').join(', ')
340
+ const currencySlots = currencies.map(() => '?').join(', ')
341
+ const rows = await em.execute<Array<{ promotion_id: string; currency: string; total: string }>>(
342
+ `SELECT promotion_id, currency, COALESCE(SUM(total_discount_amount), 0)::text AS total
343
+ FROM promotion_usages
344
+ WHERE promotion_id IN (${promotionSlots}) AND currency IN (${currencySlots})
345
+ AND organization_id = ? AND tenant_id = ? AND reverted_at IS NULL
346
+ GROUP BY promotion_id, currency`,
347
+ [...promotionIds, ...currencies, organizationId, tenantId],
348
+ )
349
+ for (const row of rows) totals.set(`${row.promotion_id}:${row.currency}`, row.total)
350
+ }
351
+ return promotions.map((promotion) => promotion.maxBudget === null ? promotion : freezeOutsideProduction({
352
+ ...promotion,
353
+ totalDiscountGranted: totals.get(`${promotion.id}:${promotion.budgetCurrency}`) ?? '0',
354
+ }))
355
+ }
356
+
320
357
  // §Cache Build Contract item 4: "read only for budget-capped promotions" — an
321
358
  // uncapped promotion's `totalDiscountGranted` is never read back from
322
359
  // `promotion_usages`, it stays the inert '0' baked into the tree cache.
@@ -369,7 +406,7 @@ export class PromotionCacheService {
369
406
  ): Promise<string> {
370
407
  if (!budgetCurrency) return '0'
371
408
  const em = resolvePromotionEm(this.container)
372
- const rows = (await em.getConnection().execute(
409
+ const rows = (await em.execute(
373
410
  `SELECT COALESCE(SUM(total_discount_amount), 0)::text AS total
374
411
  FROM promotion_usages
375
412
  WHERE promotion_id = ? AND organization_id = ? AND tenant_id = ? AND currency = ? AND reverted_at IS NULL`,
@@ -473,7 +473,7 @@ export function buildDocumentCrudOptions(binding: DocumentBinding) {
473
473
  indexer: {
474
474
  entityType: binding.entityId,
475
475
  },
476
- enrichers: binding.kind === 'order' ? { entityId: binding.entityId } : undefined,
476
+ enrichers: { entityId: binding.entityId },
477
477
  list: {
478
478
  schema: listSchema,
479
479
  entityId: binding.entityId,
@@ -3,8 +3,8 @@ import { SalesOrder } from '../../data/entities'
3
3
  import { E } from '#generated/entities.ids.generated'
4
4
  import { buildDocumentCrudOptions, buildDocumentOpenApi } from '../documents/factory'
5
5
 
6
- const crud = makeCrudRoute(
7
- buildDocumentCrudOptions({
6
+ const crud = makeCrudRoute({
7
+ ...buildDocumentCrudOptions({
8
8
  kind: 'order',
9
9
  entity: SalesOrder,
10
10
  entityId: E.sales.sales_order,
@@ -15,7 +15,8 @@ const crud = makeCrudRoute(
15
15
  manageFeature: 'sales.orders.manage',
16
16
  viewFeature: 'sales.orders.view',
17
17
  }),
18
- )
18
+ enrichers: { entityId: E.sales.sales_order },
19
+ })
19
20
 
20
21
  export const metadata = crud.metadata
21
22
  export const GET = crud.GET
@@ -3,8 +3,8 @@ import { SalesQuote } from '../../data/entities'
3
3
  import { E } from '#generated/entities.ids.generated'
4
4
  import { buildDocumentCrudOptions, buildDocumentOpenApi } from '../documents/factory'
5
5
 
6
- const crud = makeCrudRoute(
7
- buildDocumentCrudOptions({
6
+ const crud = makeCrudRoute({
7
+ ...buildDocumentCrudOptions({
8
8
  kind: 'quote',
9
9
  entity: SalesQuote,
10
10
  entityId: E.sales.sales_quote,
@@ -15,7 +15,8 @@ const crud = makeCrudRoute(
15
15
  manageFeature: 'sales.quotes.manage',
16
16
  viewFeature: 'sales.quotes.view',
17
17
  }),
18
- )
18
+ enrichers: { entityId: E.sales.sales_quote },
19
+ })
19
20
 
20
21
  export const metadata = crud.metadata
21
22
  export const GET = crud.GET
@@ -52,6 +52,7 @@ import type { AdjustmentRowData } from '@open-mercato/core/modules/sales/compone
52
52
  import { SalesShipmentsSection } from '@open-mercato/core/modules/sales/components/documents/ShipmentsSection'
53
53
  import { SalesReturnsSection } from '@open-mercato/core/modules/sales/components/documents/ReturnsSection'
54
54
  import { DocumentTotals } from '@open-mercato/core/modules/sales/components/documents/DocumentTotals'
55
+ import { SalesDocumentExtensionSpot, type SalesDocumentExtensionContext } from '@open-mercato/core/modules/sales/components/documents/SalesDocumentExtensionSpot'
55
56
  import { E } from '#generated/entities.ids.generated'
56
57
  import type { DictionarySelectLabels } from '@open-mercato/core/modules/dictionaries/components/DictionaryEntrySelect'
57
58
  import { useCurrencyDictionary } from '@open-mercato/core/modules/customers/components/detail/hooks/useCurrencyDictionary'
@@ -862,6 +863,8 @@ type StatusOption = {
862
863
 
863
864
  type DocumentRecord = {
864
865
  id: string
866
+ organizationId?: string | null
867
+ tenantId?: string | null
865
868
  orderNumber?: string | null
866
869
  quoteNumber?: string | null
867
870
  status?: string | null
@@ -1992,14 +1995,17 @@ export default function SalesDocumentDetailPage({
1992
1995
  contextId: mutationContextId,
1993
1996
  blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
1994
1997
  })
1995
- const detailInjectionContext = React.useMemo(
1998
+ const detailInjectionContext = React.useMemo<SalesDocumentExtensionContext<DocumentRecord>>(
1996
1999
  () => ({
1997
2000
  kind,
2001
+ documentId: record?.id ?? null,
1998
2002
  record,
1999
2003
  formId: mutationContextId,
2000
2004
  resourceKind: `sales.${kind}`,
2001
2005
  resourceId: record?.id ?? undefined,
2002
2006
  retryLastMutation,
2007
+ organizationId: record?.organizationId ?? null,
2008
+ tenantId: record?.tenantId ?? null,
2003
2009
  }),
2004
2010
  [kind, mutationContextId, record, retryLastMutation],
2005
2011
  )
@@ -4779,6 +4785,12 @@ export default function SalesDocumentDetailPage({
4779
4785
  isDeleting={deleting}
4780
4786
  deleteLabel={t('sales.documents.detail.delete', 'Delete')}
4781
4787
  />
4788
+ <SalesDocumentExtensionSpot
4789
+ surface="stage-bar"
4790
+ context={detailInjectionContext}
4791
+ data={record}
4792
+ onDataChange={setRecord}
4793
+ />
4782
4794
  <div className="grid gap-4 md:grid-cols-4">
4783
4795
  <div className="md:col-span-3">
4784
4796
  <CustomerInlineEditor
@@ -5023,6 +5035,12 @@ export default function SalesDocumentDetailPage({
5023
5035
  />
5024
5036
  </div>
5025
5037
 
5038
+ <SalesDocumentExtensionSpot
5039
+ surface="closure"
5040
+ context={detailInjectionContext}
5041
+ data={record}
5042
+ onDataChange={setRecord}
5043
+ />
5026
5044
  <div className="space-y-4">
5027
5045
  <CustomDataSection
5028
5046
  title={t('sales.documents.detail.customData', 'Custom data')}
@@ -0,0 +1,44 @@
1
+ 'use client'
2
+
3
+ import { extensionPoints } from '@open-mercato/core/modules/sales/extension-points'
4
+ import { resolveExtensionPointPattern } from '@open-mercato/shared/modules/widgets/extension-points'
5
+ import { InjectionSpot } from '@open-mercato/ui/backend/injection/InjectionSpot'
6
+
7
+ export type SalesDocumentExtensionContext<TRecord extends { id: string }> = {
8
+ kind: 'order' | 'quote'
9
+ documentId: string | null
10
+ record: TRecord | null
11
+ formId: string
12
+ resourceKind: 'sales.order' | 'sales.quote'
13
+ resourceId?: string
14
+ retryLastMutation: () => Promise<boolean>
15
+ organizationId: string | null
16
+ tenantId: string | null
17
+ }
18
+
19
+ type SalesDocumentExtensionSpotProps<TRecord extends { id: string }> = {
20
+ surface: 'stage-bar' | 'closure'
21
+ context: SalesDocumentExtensionContext<TRecord>
22
+ data: TRecord
23
+ onDataChange: (data: TRecord) => void
24
+ }
25
+
26
+ export function SalesDocumentExtensionSpot<TRecord extends { id: string }>({
27
+ surface,
28
+ context,
29
+ data,
30
+ onDataChange,
31
+ }: SalesDocumentExtensionSpotProps<TRecord>) {
32
+ const pattern = surface === 'stage-bar'
33
+ ? extensionPoints.hosts.documentStageBar.pattern
34
+ : extensionPoints.hosts.documentClosure.pattern
35
+
36
+ return (
37
+ <InjectionSpot
38
+ spotId={resolveExtensionPointPattern(pattern, { kind: context.kind })}
39
+ context={context}
40
+ data={data}
41
+ onDataChange={onDataChange}
42
+ />
43
+ )
44
+ }
@@ -18,6 +18,32 @@ export const extensionPoints = defineModuleExtensionPoints({
18
18
  supported: ['render-widget'],
19
19
  source: 'backend/sales/documents/[id]/page.tsx',
20
20
  }),
21
+ documentStageBar: injectionExtensionHost({
22
+ family: 'detail',
23
+ pattern: 'detail:sales.{kind}:stage-bar',
24
+ parameters: {
25
+ kind: { source: 'SalesDocumentExtensionContext.kind', pattern: '^(order|quote)$' },
26
+ },
27
+ supported: ['render-widget'],
28
+ source: ['components/documents/SalesDocumentExtensionSpot.tsx', 'backend/sales/documents/[id]/page.tsx'],
29
+ contextContract: 'SalesDocumentExtensionContext: kind, documentId, record, formId, resourceKind, resourceId, retryLastMutation, organizationId, tenantId',
30
+ dataContract: 'Current sales order or quote record; onDataChange updates the in-page record only',
31
+ scopeContract: 'organizationId and tenantId from the loaded, server-scoped document; widget writes must enforce their own authorization',
32
+ runtimeContract: 'Rendered below the document header; no native stage transition or mutation lifecycle is added',
33
+ }),
34
+ documentClosure: injectionExtensionHost({
35
+ family: 'detail',
36
+ pattern: 'detail:sales.{kind}:closure',
37
+ parameters: {
38
+ kind: { source: 'SalesDocumentExtensionContext.kind', pattern: '^(order|quote)$' },
39
+ },
40
+ supported: ['render-widget'],
41
+ source: ['components/documents/SalesDocumentExtensionSpot.tsx', 'backend/sales/documents/[id]/page.tsx'],
42
+ contextContract: 'SalesDocumentExtensionContext: kind, documentId, record, formId, resourceKind, resourceId, retryLastMutation, organizationId, tenantId',
43
+ dataContract: 'Current sales order or quote record; onDataChange updates the in-page record only',
44
+ scopeContract: 'organizationId and tenantId from the loaded, server-scoped document; widget writes must enforce their own authorization',
45
+ runtimeContract: 'Rendered below document details for closure extensions; no native closure dialog or mutation lifecycle is added',
46
+ }),
21
47
  documentDetail: injectionExtensionHost({
22
48
  family: 'detail',
23
49
  pattern: 'sales.document.detail.{kind}:{surface}',