@open-mercato/core 0.4.5-develop-f4858e0ef3 → 0.4.5-develop-4849712ccb

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 (163) hide show
  1. package/dist/generated/entities/catalog_product/index.js +16 -0
  2. package/dist/generated/entities/catalog_product/index.js.map +2 -2
  3. package/dist/generated/entities/catalog_product_unit_conversion/index.js +27 -0
  4. package/dist/generated/entities/catalog_product_unit_conversion/index.js.map +7 -0
  5. package/dist/generated/entities/sales_credit_memo_line/index.js +7 -1
  6. package/dist/generated/entities/sales_credit_memo_line/index.js.map +2 -2
  7. package/dist/generated/entities/sales_invoice_line/index.js +7 -1
  8. package/dist/generated/entities/sales_invoice_line/index.js.map +2 -2
  9. package/dist/generated/entities/sales_order_line/index.js +6 -0
  10. package/dist/generated/entities/sales_order_line/index.js.map +2 -2
  11. package/dist/generated/entities/sales_quote_line/index.js +6 -0
  12. package/dist/generated/entities/sales_quote_line/index.js.map +2 -2
  13. package/dist/generated/entities.ids.generated.js +1 -0
  14. package/dist/generated/entities.ids.generated.js.map +2 -2
  15. package/dist/generated/entity-fields-registry.js +2 -0
  16. package/dist/generated/entity-fields-registry.js.map +2 -2
  17. package/dist/modules/catalog/api/prices/route.js +123 -8
  18. package/dist/modules/catalog/api/prices/route.js.map +2 -2
  19. package/dist/modules/catalog/api/product-unit-conversions/route.js +194 -0
  20. package/dist/modules/catalog/api/product-unit-conversions/route.js.map +7 -0
  21. package/dist/modules/catalog/api/products/route.js +351 -201
  22. package/dist/modules/catalog/api/products/route.js.map +2 -2
  23. package/dist/modules/catalog/backend/catalog/products/[id]/page.js +1267 -497
  24. package/dist/modules/catalog/backend/catalog/products/[id]/page.js.map +2 -2
  25. package/dist/modules/catalog/backend/catalog/products/create/page.js +733 -210
  26. package/dist/modules/catalog/backend/catalog/products/create/page.js.map +2 -2
  27. package/dist/modules/catalog/commands/index.js +1 -0
  28. package/dist/modules/catalog/commands/index.js.map +2 -2
  29. package/dist/modules/catalog/commands/productUnitConversions.js +503 -0
  30. package/dist/modules/catalog/commands/productUnitConversions.js.map +7 -0
  31. package/dist/modules/catalog/commands/products.js +355 -73
  32. package/dist/modules/catalog/commands/products.js.map +2 -2
  33. package/dist/modules/catalog/commands/shared.js +18 -4
  34. package/dist/modules/catalog/commands/shared.js.map +2 -2
  35. package/dist/modules/catalog/components/products/ProductUomSection.js +591 -0
  36. package/dist/modules/catalog/components/products/ProductUomSection.js.map +7 -0
  37. package/dist/modules/catalog/components/products/productForm.js +66 -5
  38. package/dist/modules/catalog/components/products/productForm.js.map +2 -2
  39. package/dist/modules/catalog/components/products/productFormUtils.js +68 -0
  40. package/dist/modules/catalog/components/products/productFormUtils.js.map +7 -0
  41. package/dist/modules/catalog/data/entities.js +86 -0
  42. package/dist/modules/catalog/data/entities.js.map +2 -2
  43. package/dist/modules/catalog/data/validators.js +65 -3
  44. package/dist/modules/catalog/data/validators.js.map +2 -2
  45. package/dist/modules/catalog/events.js +3 -0
  46. package/dist/modules/catalog/events.js.map +2 -2
  47. package/dist/modules/catalog/lib/unitCodes.js +7 -0
  48. package/dist/modules/catalog/lib/unitCodes.js.map +7 -0
  49. package/dist/modules/catalog/lib/unitResolution.js +53 -0
  50. package/dist/modules/catalog/lib/unitResolution.js.map +7 -0
  51. package/dist/modules/catalog/migrations/Migration20260218225422.js +19 -0
  52. package/dist/modules/catalog/migrations/Migration20260218225422.js.map +7 -0
  53. package/dist/modules/catalog/migrations/Migration20260219084500.js +27 -0
  54. package/dist/modules/catalog/migrations/Migration20260219084500.js.map +7 -0
  55. package/dist/modules/catalog/search.js +69 -1
  56. package/dist/modules/catalog/search.js.map +2 -2
  57. package/dist/modules/catalog/seed/examples.js +91 -42
  58. package/dist/modules/catalog/seed/examples.js.map +2 -2
  59. package/dist/modules/dashboards/seed/analytics.js +3 -0
  60. package/dist/modules/dashboards/seed/analytics.js.map +2 -2
  61. package/dist/modules/sales/api/order-lines/route.js +98 -15
  62. package/dist/modules/sales/api/order-lines/route.js.map +2 -2
  63. package/dist/modules/sales/api/quote-lines/route.js +101 -14
  64. package/dist/modules/sales/api/quote-lines/route.js.map +2 -2
  65. package/dist/modules/sales/api/quotes/public/[token]/route.js +87 -12
  66. package/dist/modules/sales/api/quotes/public/[token]/route.js.map +2 -2
  67. package/dist/modules/sales/commands/documents.js +1424 -260
  68. package/dist/modules/sales/commands/documents.js.map +3 -3
  69. package/dist/modules/sales/commands/shared.js +6 -2
  70. package/dist/modules/sales/commands/shared.js.map +2 -2
  71. package/dist/modules/sales/components/documents/ItemsSection.js +216 -86
  72. package/dist/modules/sales/components/documents/ItemsSection.js.map +2 -2
  73. package/dist/modules/sales/components/documents/LineItemDialog.js +913 -241
  74. package/dist/modules/sales/components/documents/LineItemDialog.js.map +3 -3
  75. package/dist/modules/sales/components/documents/ShipmentsSection.js +15 -3
  76. package/dist/modules/sales/components/documents/ShipmentsSection.js.map +2 -2
  77. package/dist/modules/sales/data/entities.js +59 -3
  78. package/dist/modules/sales/data/entities.js.map +2 -2
  79. package/dist/modules/sales/data/validators.js +35 -0
  80. package/dist/modules/sales/data/validators.js.map +2 -2
  81. package/dist/modules/sales/frontend/quote/[token]/page.js +15 -1
  82. package/dist/modules/sales/frontend/quote/[token]/page.js.map +2 -2
  83. package/dist/modules/sales/migrations/Migration20260218225423.js +31 -0
  84. package/dist/modules/sales/migrations/Migration20260218225423.js.map +7 -0
  85. package/dist/modules/sales/migrations/Migration20260219084501.js +71 -0
  86. package/dist/modules/sales/migrations/Migration20260219084501.js.map +7 -0
  87. package/dist/modules/sales/search.js +28 -0
  88. package/dist/modules/sales/search.js.map +2 -2
  89. package/dist/modules/sales/seed/examples.js +14 -1
  90. package/dist/modules/sales/seed/examples.js.map +2 -2
  91. package/dist/modules/sales/widgets/injection/document-history/widget.client.js +1 -1
  92. package/dist/modules/sales/widgets/injection/document-history/widget.client.js.map +2 -2
  93. package/generated/entities/catalog_product/index.ts +8 -0
  94. package/generated/entities/catalog_product_unit_conversion/index.ts +12 -0
  95. package/generated/entities/sales_credit_memo_line/index.ts +3 -0
  96. package/generated/entities/sales_invoice_line/index.ts +3 -0
  97. package/generated/entities/sales_order_line/index.ts +3 -0
  98. package/generated/entities/sales_quote_line/index.ts +3 -0
  99. package/generated/entities.ids.generated.ts +1 -0
  100. package/generated/entity-fields-registry.ts +2 -0
  101. package/package.json +2 -2
  102. package/src/modules/auth/i18n/de.json +1 -1
  103. package/src/modules/auth/i18n/en.json +1 -1
  104. package/src/modules/auth/i18n/es.json +1 -1
  105. package/src/modules/auth/i18n/pl.json +1 -1
  106. package/src/modules/catalog/api/prices/route.ts +213 -81
  107. package/src/modules/catalog/api/product-unit-conversions/route.ts +195 -0
  108. package/src/modules/catalog/api/products/route.ts +638 -402
  109. package/src/modules/catalog/backend/catalog/products/[id]/page.tsx +2085 -1072
  110. package/src/modules/catalog/backend/catalog/products/create/page.tsx +1288 -593
  111. package/src/modules/catalog/commands/index.ts +1 -0
  112. package/src/modules/catalog/commands/productUnitConversions.ts +626 -0
  113. package/src/modules/catalog/commands/products.ts +1151 -693
  114. package/src/modules/catalog/commands/shared.ts +19 -5
  115. package/src/modules/catalog/components/products/ProductUomSection.tsx +745 -0
  116. package/src/modules/catalog/components/products/productForm.ts +369 -256
  117. package/src/modules/catalog/components/products/productFormUtils.ts +82 -0
  118. package/src/modules/catalog/data/entities.ts +82 -1
  119. package/src/modules/catalog/data/validators.ts +118 -34
  120. package/src/modules/catalog/events.ts +3 -0
  121. package/src/modules/catalog/i18n/de.json +56 -0
  122. package/src/modules/catalog/i18n/en.json +56 -0
  123. package/src/modules/catalog/i18n/es.json +56 -0
  124. package/src/modules/catalog/i18n/pl.json +56 -0
  125. package/src/modules/catalog/lib/unitCodes.ts +1 -0
  126. package/src/modules/catalog/lib/unitResolution.ts +62 -0
  127. package/src/modules/catalog/migrations/.snapshot-open-mercato.json +245 -0
  128. package/src/modules/catalog/migrations/Migration20260218225422.ts +21 -0
  129. package/src/modules/catalog/migrations/Migration20260219084500.ts +26 -0
  130. package/src/modules/catalog/search.ts +73 -1
  131. package/src/modules/catalog/seed/examples.ts +552 -479
  132. package/src/modules/dashboards/i18n/de.json +1 -1
  133. package/src/modules/dashboards/i18n/en.json +1 -1
  134. package/src/modules/dashboards/i18n/es.json +1 -1
  135. package/src/modules/dashboards/i18n/pl.json +1 -1
  136. package/src/modules/dashboards/seed/analytics.ts +3 -0
  137. package/src/modules/sales/api/order-lines/route.ts +158 -68
  138. package/src/modules/sales/api/quote-lines/route.ts +161 -67
  139. package/src/modules/sales/api/quotes/public/[token]/route.ts +122 -36
  140. package/src/modules/sales/commands/documents.ts +4250 -2424
  141. package/src/modules/sales/commands/shared.ts +7 -2
  142. package/src/modules/sales/components/documents/ItemsSection.tsx +580 -310
  143. package/src/modules/sales/components/documents/LineItemDialog.tsx +1988 -833
  144. package/src/modules/sales/components/documents/ShipmentsSection.tsx +17 -3
  145. package/src/modules/sales/components/documents/lineItemTypes.ts +6 -0
  146. package/src/modules/sales/data/entities.ts +53 -0
  147. package/src/modules/sales/data/validators.ts +36 -0
  148. package/src/modules/sales/frontend/quote/[token]/page.tsx +25 -1
  149. package/src/modules/sales/i18n/de.json +23 -3
  150. package/src/modules/sales/i18n/en.json +23 -3
  151. package/src/modules/sales/i18n/es.json +23 -3
  152. package/src/modules/sales/i18n/pl.json +23 -3
  153. package/src/modules/sales/lib/types.ts +30 -0
  154. package/src/modules/sales/migrations/.snapshot-open-mercato.json +172 -0
  155. package/src/modules/sales/migrations/Migration20260218225423.ts +37 -0
  156. package/src/modules/sales/migrations/Migration20260219084501.ts +73 -0
  157. package/src/modules/sales/search.ts +28 -0
  158. package/src/modules/sales/seed/examples.ts +20 -1
  159. package/src/modules/sales/widgets/injection/document-history/widget.client.tsx +1 -1
  160. package/src/modules/workflows/i18n/de.json +4 -4
  161. package/src/modules/workflows/i18n/en.json +4 -4
  162. package/src/modules/workflows/i18n/es.json +4 -4
  163. package/src/modules/workflows/i18n/pl.json +4 -4
@@ -1,46 +1,58 @@
1
- import { randomUUID } from 'node:crypto'
2
- import { registerCommand } from '@open-mercato/shared/lib/commands'
3
- import type { CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
1
+ import { randomUUID } from "node:crypto";
2
+ import { registerCommand } from "@open-mercato/shared/lib/commands";
3
+ import type {
4
+ CommandHandler,
5
+ CommandRuntimeContext,
6
+ } from "@open-mercato/shared/lib/commands";
4
7
  import {
8
+ buildChanges,
5
9
  requireId,
6
10
  parseWithCustomFields,
7
11
  setCustomFieldsIfAny,
8
12
  emitCrudSideEffects,
9
13
  emitCrudUndoSideEffects,
10
- } from '@open-mercato/shared/lib/commands/helpers'
11
- import type { EntityManager } from '@mikro-orm/postgresql'
12
- import { UniqueConstraintViolationException } from '@mikro-orm/core'
13
- import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
14
- import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
15
- import type { CrudEventAction, CrudEventsConfig, CrudIndexerConfig } from '@open-mercato/shared/lib/crud/types'
16
- import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
17
- import { loadCustomFieldSnapshot, buildCustomFieldResetMap } from '@open-mercato/shared/lib/commands/customFieldSnapshots'
18
- import { E } from '#generated/entities.ids.generated'
19
- import { slugifyTagLabel } from '@open-mercato/shared/lib/utils'
20
- import { parseObjectLike } from '@open-mercato/shared/lib/json/parseObjectLike'
14
+ } from "@open-mercato/shared/lib/commands/helpers";
15
+ import type { EntityManager } from "@mikro-orm/postgresql";
16
+ import { UniqueConstraintViolationException } from "@mikro-orm/core";
17
+ import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
18
+ import { CrudHttpError } from "@open-mercato/shared/lib/crud/errors";
19
+ import type {
20
+ CrudEventAction,
21
+ CrudEventsConfig,
22
+ CrudIndexerConfig,
23
+ } from "@open-mercato/shared/lib/crud/types";
24
+ import type { DataEngine } from "@open-mercato/shared/lib/data/engine";
25
+ import {
26
+ loadCustomFieldSnapshot,
27
+ buildCustomFieldResetMap,
28
+ } from "@open-mercato/shared/lib/commands/customFieldSnapshots";
29
+ import { E } from "#generated/entities.ids.generated";
30
+ import { slugifyTagLabel } from "@open-mercato/shared/lib/utils";
31
+ import { parseObjectLike } from "@open-mercato/shared/lib/json/parseObjectLike";
21
32
  import {
22
33
  CatalogOffer,
23
34
  CatalogProduct,
24
35
  CatalogProductVariant,
25
36
  CatalogProductPrice,
37
+ CatalogProductUnitConversion,
26
38
  CatalogOptionSchemaTemplate,
27
39
  CatalogProductCategory,
28
40
  CatalogProductCategoryAssignment,
29
41
  CatalogProductTag,
30
42
  CatalogProductTagAssignment,
31
- } from '../data/entities'
32
- import { SalesTaxRate } from '@open-mercato/core/modules/sales/data/entities'
43
+ } from "../data/entities";
44
+ import { SalesTaxRate } from "@open-mercato/core/modules/sales/data/entities";
33
45
  import {
34
46
  productCreateSchema,
35
47
  productUpdateSchema,
36
48
  type OfferInput,
37
49
  type ProductCreateInput,
38
50
  type ProductUpdateInput,
39
- } from '../data/validators'
51
+ } from "../data/validators";
40
52
  import type {
41
53
  CatalogProductOptionSchema,
42
54
  CatalogProductType,
43
- } from '../data/types'
55
+ } from "../data/types";
44
56
  import {
45
57
  cloneJson,
46
58
  ensureOrganizationScope,
@@ -52,50 +64,171 @@ import {
52
64
  emitCatalogQueryIndexEvent,
53
65
  randomSuffix,
54
66
  toNumericString,
55
- } from './shared'
56
- import { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
67
+ getErrorConstraint,
68
+ getErrorMessage,
69
+ } from "./shared";
70
+ import {
71
+ findWithDecryption,
72
+ findOneWithDecryption,
73
+ } from "@open-mercato/shared/lib/encryption/find";
74
+ import { canonicalizeUnitCode } from "../lib/unitCodes";
75
+ import {
76
+ resolveCanonicalUnitCode,
77
+ } from "../lib/unitResolution";
57
78
 
58
79
  type ProductSnapshot = {
59
- id: string
60
- organizationId: string
61
- tenantId: string
62
- title: string
63
- subtitle: string | null
64
- description: string | null
65
- sku: string | null
66
- handle: string | null
67
- taxRateId: string | null
68
- taxRate: string | null
69
- productType: CatalogProductType
70
- statusEntryId: string | null
71
- primaryCurrencyCode: string | null
72
- defaultUnit: string | null
73
- defaultMediaId: string | null
74
- defaultMediaUrl: string | null
75
- weightValue: string | null
76
- weightUnit: string | null
77
- dimensions: Record<string, unknown> | null
78
- optionSchemaId: string | null
79
- customFieldsetCode: string | null
80
- metadata: Record<string, unknown> | null
81
- isConfigurable: boolean
82
- isActive: boolean
83
- createdAt: string
84
- updatedAt: string
85
- offers: OfferSnapshot[]
86
- tags: string[]
87
- categoryIds: string[]
88
- custom: Record<string, unknown> | null
80
+ id: string;
81
+ organizationId: string;
82
+ tenantId: string;
83
+ title: string;
84
+ subtitle: string | null;
85
+ description: string | null;
86
+ sku: string | null;
87
+ handle: string | null;
88
+ taxRateId: string | null;
89
+ taxRate: string | null;
90
+ productType: CatalogProductType;
91
+ statusEntryId: string | null;
92
+ primaryCurrencyCode: string | null;
93
+ defaultUnit: string | null;
94
+ defaultSalesUnit: string | null;
95
+ defaultSalesUnitQuantity: string;
96
+ uomRoundingScale: number;
97
+ uomRoundingMode: "half_up" | "down" | "up";
98
+ unitPriceEnabled: boolean;
99
+ unitPriceReferenceUnit: "kg" | "l" | "m2" | "m3" | "pc" | null;
100
+ unitPriceBaseQuantity: string | null;
101
+ defaultMediaId: string | null;
102
+ defaultMediaUrl: string | null;
103
+ weightValue: string | null;
104
+ weightUnit: string | null;
105
+ dimensions: Record<string, unknown> | null;
106
+ optionSchemaId: string | null;
107
+ customFieldsetCode: string | null;
108
+ metadata: Record<string, unknown> | null;
109
+ isConfigurable: boolean;
110
+ isActive: boolean;
111
+ createdAt: string;
112
+ updatedAt: string;
113
+ offers: OfferSnapshot[];
114
+ tags: string[];
115
+ categoryIds: string[];
116
+ custom: Record<string, unknown> | null;
117
+ };
118
+
119
+ async function resolveProductUnitDefaults(
120
+ em: EntityManager,
121
+ params: {
122
+ organizationId: string;
123
+ tenantId: string;
124
+ defaultUnit: string | null | undefined;
125
+ defaultSalesUnit: string | null | undefined;
126
+ },
127
+ ): Promise<{ defaultUnit: string | null; defaultSalesUnit: string | null }> {
128
+ const defaultUnitInput = canonicalizeUnitCode(params.defaultUnit);
129
+ const defaultSalesUnitInput = canonicalizeUnitCode(params.defaultSalesUnit);
130
+ if (!defaultUnitInput && defaultSalesUnitInput) {
131
+ throw new CrudHttpError(400, { error: "uom.default_unit_missing" });
132
+ }
133
+ const defaultUnit = defaultUnitInput
134
+ ? await resolveCanonicalUnitCode(em, {
135
+ organizationId: params.organizationId,
136
+ tenantId: params.tenantId,
137
+ unitCode: defaultUnitInput,
138
+ })
139
+ : null;
140
+ const defaultSalesUnit = defaultSalesUnitInput
141
+ ? await resolveCanonicalUnitCode(em, {
142
+ organizationId: params.organizationId,
143
+ tenantId: params.tenantId,
144
+ unitCode: defaultSalesUnitInput,
145
+ })
146
+ : null;
147
+ return { defaultUnit, defaultSalesUnit };
89
148
  }
90
149
 
91
- type ProductUndoPayload = {
92
- before?: ProductSnapshot | null
93
- after?: ProductSnapshot | null
150
+ async function ensureBaseUnitCanBeRemoved(
151
+ em: EntityManager,
152
+ params: {
153
+ productId: string;
154
+ organizationId: string;
155
+ tenantId: string;
156
+ defaultUnit: string | null;
157
+ defaultSalesUnit: string | null;
158
+ },
159
+ ): Promise<void> {
160
+ if (params.defaultUnit) return;
161
+ if (params.defaultSalesUnit) {
162
+ throw new CrudHttpError(400, { error: "uom.default_unit_missing" });
163
+ }
164
+ const activeConversionCount = await em.count(CatalogProductUnitConversion, {
165
+ product: params.productId,
166
+ organizationId: params.organizationId,
167
+ tenantId: params.tenantId,
168
+ deletedAt: null,
169
+ isActive: true,
170
+ });
171
+ if (activeConversionCount > 0) {
172
+ throw new CrudHttpError(400, { error: "uom.default_unit_missing" });
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Resolves unit-price configuration from product input.
178
+ * Supports two input paths:
179
+ * - Nested: `unitPrice.enabled`, `unitPrice.referenceUnit`, `unitPrice.baseQuantity` (preferred)
180
+ * - Flat: `unitPriceEnabled`, `unitPriceReferenceUnit`, `unitPriceBaseQuantity` (legacy compat)
181
+ * Nested values take precedence when both are provided.
182
+ */
183
+ function resolveUnitPriceInput(
184
+ parsed: ProductCreateInput | ProductUpdateInput,
185
+ ): {
186
+ enabled?: boolean;
187
+ referenceUnit?: "kg" | "l" | "m2" | "m3" | "pc" | null;
188
+ baseQuantity?: string | null;
189
+ enabledProvided: boolean;
190
+ referenceProvided: boolean;
191
+ baseProvided: boolean;
192
+ } {
193
+ const enabledFromNested = parsed.unitPrice?.enabled;
194
+ const enabledFromFlat = parsed.unitPriceEnabled;
195
+ const referenceFromNested = parsed.unitPrice?.referenceUnit;
196
+ const referenceFromFlat = parsed.unitPriceReferenceUnit;
197
+ const baseFromNested = parsed.unitPrice?.baseQuantity;
198
+ const baseFromFlat = parsed.unitPriceBaseQuantity;
199
+ const enabledProvided =
200
+ enabledFromNested !== undefined || enabledFromFlat !== undefined;
201
+ const referenceProvided =
202
+ referenceFromNested !== undefined || referenceFromFlat !== undefined;
203
+ const baseProvided =
204
+ baseFromNested !== undefined || baseFromFlat !== undefined;
205
+ const enabled = enabledFromNested ?? enabledFromFlat;
206
+ const referenceUnit = canonicalizeUnitCode(
207
+ referenceFromNested ?? referenceFromFlat ?? null,
208
+ ) as "kg" | "l" | "m2" | "m3" | "pc" | null | undefined;
209
+ const baseQuantitySource = baseFromNested ?? baseFromFlat;
210
+ const baseQuantity =
211
+ baseQuantitySource === undefined
212
+ ? undefined
213
+ : (toNumericString(baseQuantitySource) ?? null);
214
+ return {
215
+ enabled,
216
+ referenceUnit,
217
+ baseQuantity,
218
+ enabledProvided,
219
+ referenceProvided,
220
+ baseProvided,
221
+ };
94
222
  }
95
223
 
224
+ type ProductUndoPayload = {
225
+ before?: ProductSnapshot | null;
226
+ after?: ProductSnapshot | null;
227
+ };
228
+
96
229
  const productCrudEvents: CrudEventsConfig<CatalogProduct> = {
97
- module: 'catalog',
98
- entity: 'product',
230
+ module: "catalog",
231
+ entity: "product",
99
232
  persistent: true,
100
233
  buildPayload: (ctx) => ({
101
234
  id: ctx.identifiers.id,
@@ -105,7 +238,7 @@ const productCrudEvents: CrudEventsConfig<CatalogProduct> = {
105
238
  statusEntryId: ctx.entity.statusEntryId ?? null,
106
239
  isActive: ctx.entity.isActive,
107
240
  }),
108
- }
241
+ };
109
242
 
110
243
  const productCrudIndexer: CrudIndexerConfig<CatalogProduct> = {
111
244
  entityType: E.catalog.catalog_product,
@@ -121,22 +254,22 @@ const productCrudIndexer: CrudIndexerConfig<CatalogProduct> = {
121
254
  tenantId: ctx.identifiers.tenantId,
122
255
  organizationId: ctx.identifiers.organizationId,
123
256
  }),
124
- }
257
+ };
125
258
 
126
259
  function buildProductCrudIdentifiers(product: CatalogProduct) {
127
260
  return {
128
261
  id: product.id,
129
262
  organizationId: product.organizationId,
130
263
  tenantId: product.tenantId,
131
- }
264
+ };
132
265
  }
133
266
 
134
267
  async function emitProductCrudChange(opts: {
135
- dataEngine: DataEngine
136
- action: CrudEventAction
137
- product: CatalogProduct
268
+ dataEngine: DataEngine;
269
+ action: CrudEventAction;
270
+ product: CatalogProduct;
138
271
  }) {
139
- const { dataEngine, action, product } = opts
272
+ const { dataEngine, action, product } = opts;
140
273
  await emitCrudSideEffects({
141
274
  dataEngine,
142
275
  action,
@@ -144,15 +277,15 @@ async function emitProductCrudChange(opts: {
144
277
  identifiers: buildProductCrudIdentifiers(product),
145
278
  events: productCrudEvents,
146
279
  indexer: productCrudIndexer,
147
- })
280
+ });
148
281
  }
149
282
 
150
283
  async function emitProductCrudUndoChange(opts: {
151
- dataEngine: DataEngine
152
- action: CrudEventAction
153
- product: CatalogProduct
284
+ dataEngine: DataEngine;
285
+ action: CrudEventAction;
286
+ product: CatalogProduct;
154
287
  }) {
155
- const { dataEngine, action, product } = opts
288
+ const { dataEngine, action, product } = opts;
156
289
  await emitCrudUndoSideEffects({
157
290
  dataEngine,
158
291
  action,
@@ -160,277 +293,347 @@ async function emitProductCrudUndoChange(opts: {
160
293
  identifiers: buildProductCrudIdentifiers(product),
161
294
  events: productCrudEvents,
162
295
  indexer: productCrudIndexer,
163
- })
296
+ });
164
297
  }
165
298
 
166
299
  type OfferSnapshot = {
167
- id: string
168
- channelId: string
169
- title: string
170
- description: string | null
171
- defaultMediaId: string | null
172
- defaultMediaUrl: string | null
173
- metadata: Record<string, unknown> | null
174
- isActive: boolean
175
- }
300
+ id: string;
301
+ channelId: string;
302
+ title: string;
303
+ description: string | null;
304
+ defaultMediaId: string | null;
305
+ defaultMediaUrl: string | null;
306
+ metadata: Record<string, unknown> | null;
307
+ isActive: boolean;
308
+ };
176
309
 
177
310
  async function resolveScopedTaxRate(
178
311
  em: EntityManager,
179
312
  taxRateId: string | null | undefined,
180
313
  taxRateInput: number | string | null | undefined,
181
314
  organizationId: string,
182
- tenantId: string
315
+ tenantId: string,
183
316
  ): Promise<{ taxRateId: string | null; taxRate: string | null }> {
184
317
  const normalizedRate =
185
318
  taxRateInput === null || taxRateInput === undefined
186
319
  ? null
187
320
  : (() => {
188
- const numeric = typeof taxRateInput === 'string' ? Number(taxRateInput) : taxRateInput
189
- return Number.isFinite(numeric) ? toNumericString(numeric) : null
190
- })()
321
+ const numeric =
322
+ typeof taxRateInput === "string"
323
+ ? Number(taxRateInput)
324
+ : taxRateInput;
325
+ return Number.isFinite(numeric) ? toNumericString(numeric) : null;
326
+ })();
191
327
  if (!taxRateId) {
192
- return { taxRateId: null, taxRate: normalizedRate }
328
+ return { taxRateId: null, taxRate: normalizedRate };
193
329
  }
194
- const record = await em.findOne(SalesTaxRate, {
330
+ const record = await findOneWithDecryption(em, SalesTaxRate, {
195
331
  id: taxRateId,
196
332
  organizationId,
197
333
  tenantId,
198
334
  deletedAt: null,
199
- })
335
+ });
200
336
  if (!record) {
201
- throw new CrudHttpError(400, { error: 'Tax class not found' })
337
+ const { translate } = await resolveTranslations();
338
+ throw new CrudHttpError(400, {
339
+ error: translate(
340
+ "catalog.products.errors.taxClassNotFound",
341
+ "Tax class not found",
342
+ ),
343
+ });
202
344
  }
203
- return { taxRateId, taxRate: record.rate ?? normalizedRate }
345
+ return { taxRateId, taxRate: record.rate ?? normalizedRate };
204
346
  }
205
347
 
206
348
  function slugifyCode(input: string): string {
207
349
  return input
208
350
  .toLowerCase()
209
351
  .trim()
210
- .replace(/[^a-z0-9\-]+/g, '-')
211
- .replace(/^-+|-+$/g, '')
352
+ .replace(/[^a-z0-9\-]+/g, "-")
353
+ .replace(/^-+|-+$/g, "");
212
354
  }
213
355
 
214
356
  function normalizeCatalogOptionSchema(
215
- input?: CatalogProductOptionSchema | null
357
+ input?: CatalogProductOptionSchema | null,
216
358
  ): CatalogProductOptionSchema | null {
217
- if (!input || !Array.isArray(input.options) || !input.options.length) return null
359
+ if (!input || !Array.isArray(input.options) || !input.options.length)
360
+ return null;
218
361
  const options = input.options
219
362
  .map((option) => {
220
- if (!option) return null
363
+ if (!option) return null;
221
364
  const label =
222
- typeof option.label === 'string' && option.label.trim().length ? option.label.trim() : null
365
+ typeof option.label === "string" && option.label.trim().length
366
+ ? option.label.trim()
367
+ : null;
223
368
  const codeSource =
224
- typeof option.code === 'string' && option.code.trim().length ? option.code.trim() : label
225
- const code = slugifyCode(codeSource ?? '')
226
- if (!label && !code) return null
369
+ typeof option.code === "string" && option.code.trim().length
370
+ ? option.code.trim()
371
+ : label;
372
+ const code = slugifyCode(codeSource ?? "");
373
+ if (!label && !code) return null;
227
374
  const choices = Array.isArray(option.choices)
228
375
  ? option.choices
229
376
  .map((choice) => {
230
- if (!choice) return null
377
+ if (!choice) return null;
231
378
  const choiceLabel =
232
- typeof choice.label === 'string' && choice.label.trim().length ? choice.label.trim() : null
379
+ typeof choice.label === "string" && choice.label.trim().length
380
+ ? choice.label.trim()
381
+ : null;
233
382
  const choiceCodeSource =
234
- typeof choice.code === 'string' && choice.code.trim().length
383
+ typeof choice.code === "string" && choice.code.trim().length
235
384
  ? choice.code.trim()
236
- : choiceLabel
237
- const choiceCode = slugifyCode(choiceCodeSource ?? '')
238
- if (!choiceLabel && !choiceCode) return null
385
+ : choiceLabel;
386
+ const choiceCode = slugifyCode(choiceCodeSource ?? "");
387
+ if (!choiceLabel && !choiceCode) return null;
239
388
  return {
240
389
  code: choiceCode || `choice-${randomSuffix()}`,
241
- label: choiceLabel ?? (choiceCode || `Choice ${randomSuffix()}`),
242
- }
390
+ label:
391
+ choiceLabel ?? (choiceCode || `Choice ${randomSuffix()}`),
392
+ };
243
393
  })
244
394
  .filter(
245
395
  (entry): entry is { code: string; label: string } =>
246
- !!entry && entry.code.trim().length > 0 && entry.label.trim().length > 0
396
+ !!entry &&
397
+ entry.code.trim().length > 0 &&
398
+ entry.label.trim().length > 0,
247
399
  )
248
- : []
400
+ : [];
249
401
  return {
250
402
  code: code || `option-${randomSuffix()}`,
251
403
  label: label ?? (code || `Option ${randomSuffix()}`),
252
404
  description:
253
- typeof option.description === 'string' && option.description.trim().length
405
+ typeof option.description === "string" &&
406
+ option.description.trim().length
254
407
  ? option.description.trim()
255
408
  : null,
256
409
  inputType:
257
- option.inputType === 'text' ||
258
- option.inputType === 'textarea' ||
259
- option.inputType === 'number'
410
+ option.inputType === "text" ||
411
+ option.inputType === "textarea" ||
412
+ option.inputType === "number"
260
413
  ? option.inputType
261
- : 'select',
414
+ : "select",
262
415
  isRequired: option.isRequired ?? false,
263
416
  isMultiple: option.isMultiple ?? false,
264
417
  choices,
265
- }
418
+ };
266
419
  })
267
420
  .filter((entry) => !!entry && entry.code.trim().length > 0) as Array<
268
- CatalogProductOptionSchema['options'][number]
269
- >
270
- if (!options.length) return null
421
+ CatalogProductOptionSchema["options"][number]
422
+ >;
423
+ if (!options.length) return null;
271
424
  return {
272
- version: typeof input.version === 'number' && input.version > 0 ? input.version : 1,
273
- name: typeof input.name === 'string' && input.name.trim().length ? input.name.trim() : undefined,
425
+ version:
426
+ typeof input.version === "number" && input.version > 0
427
+ ? input.version
428
+ : 1,
429
+ name:
430
+ typeof input.name === "string" && input.name.trim().length
431
+ ? input.name.trim()
432
+ : undefined,
274
433
  description:
275
- typeof input.description === 'string' && input.description.trim().length
434
+ typeof input.description === "string" && input.description.trim().length
276
435
  ? input.description.trim()
277
436
  : undefined,
278
437
  options,
279
- }
438
+ };
280
439
  }
281
440
 
282
- function convertLegacyOptionSchema(raw: unknown): CatalogProductOptionSchema | null {
283
- if (!Array.isArray(raw)) return null
441
+ function convertLegacyOptionSchema(
442
+ raw: unknown,
443
+ ): CatalogProductOptionSchema | null {
444
+ if (!Array.isArray(raw)) return null;
284
445
  const options = raw
285
446
  .map((entry) => {
286
- if (!entry || typeof entry !== 'object') return null
287
- const source = entry as Record<string, unknown>
447
+ if (!entry || typeof entry !== "object") return null;
448
+ const source = entry as Record<string, unknown>;
288
449
  const title =
289
- typeof source['title'] === 'string' && (source['title'] as string).trim().length
290
- ? (source['title'] as string).trim()
291
- : null
292
- if (!title) return null
293
- const values = Array.isArray(source['values'])
294
- ? (source['values'] as unknown[])
295
- .map((value: any) => {
296
- if (!value || typeof value !== 'object') return null
450
+ typeof source["title"] === "string" &&
451
+ (source["title"] as string).trim().length
452
+ ? (source["title"] as string).trim()
453
+ : null;
454
+ if (!title) return null;
455
+ const values = Array.isArray(source["values"])
456
+ ? (source["values"] as unknown[])
457
+ .map((value: unknown) => {
458
+ if (!value || typeof value !== "object") return null;
459
+ const choice = value as Record<string, unknown>;
297
460
  const label =
298
- typeof value.label === 'string' && value.label.trim().length ? value.label.trim() : null
299
- if (!label) return null
300
- return { code: slugifyCode(label), label }
461
+ typeof choice.label === "string" && (choice.label as string).trim().length
462
+ ? (choice.label as string).trim()
463
+ : null;
464
+ if (!label) return null;
465
+ return { code: slugifyCode(label), label };
301
466
  })
302
- .filter((choice): choice is { code: string; label: string } => !!choice)
303
- : []
467
+ .filter(
468
+ (choice): choice is { code: string; label: string } => !!choice,
469
+ )
470
+ : [];
304
471
  return {
305
472
  code: slugifyCode(title),
306
473
  label: title,
307
- inputType: 'select' as const,
474
+ inputType: "select" as const,
308
475
  choices: values,
309
- }
476
+ };
310
477
  })
311
- .filter((option) => !!option) as CatalogProductOptionSchema['options']
312
- if (!options.length) return null
478
+ .filter((option) => !!option) as CatalogProductOptionSchema["options"];
479
+ if (!options.length) return null;
313
480
  return {
314
481
  version: 1,
315
482
  options,
316
- }
483
+ };
317
484
  }
318
485
 
319
486
  function extractOptionSchemaInput(source: {
320
- metadata?: Record<string, unknown> | null | undefined
321
- optionSchema?: CatalogProductOptionSchema | null | undefined
322
- }): { schema: CatalogProductOptionSchema | null; metadata: Record<string, unknown> | null } {
487
+ metadata?: Record<string, unknown> | null | undefined;
488
+ optionSchema?: CatalogProductOptionSchema | null | undefined;
489
+ }): {
490
+ schema: CatalogProductOptionSchema | null;
491
+ metadata: Record<string, unknown> | null;
492
+ } {
323
493
  const metadata =
324
- source.metadata && typeof source.metadata === 'object'
494
+ source.metadata && typeof source.metadata === "object"
325
495
  ? { ...(source.metadata as Record<string, unknown>) }
326
- : null
327
- let schema = normalizeCatalogOptionSchema(source.optionSchema)
496
+ : null;
497
+ let schema = normalizeCatalogOptionSchema(source.optionSchema);
328
498
  if (!schema && metadata) {
329
499
  const legacy = convertLegacyOptionSchema(
330
- (metadata as Record<string, unknown>)['optionSchema'] ??
331
- (metadata as Record<string, unknown>)['option_schema']
332
- )
333
- schema = normalizeCatalogOptionSchema(legacy)
500
+ (metadata as Record<string, unknown>)["optionSchema"] ??
501
+ (metadata as Record<string, unknown>)["option_schema"],
502
+ );
503
+ schema = normalizeCatalogOptionSchema(legacy);
334
504
  }
335
505
  if (metadata) {
336
- delete (metadata as Record<string, unknown>)['optionSchema']
337
- delete (metadata as Record<string, unknown>)['option_schema']
338
- delete (metadata as Record<string, unknown>)['dimensions']
339
- delete (metadata as Record<string, unknown>)['weight']
506
+ delete (metadata as Record<string, unknown>)["optionSchema"];
507
+ delete (metadata as Record<string, unknown>)["option_schema"];
508
+ delete (metadata as Record<string, unknown>)["dimensions"];
509
+ delete (metadata as Record<string, unknown>)["weight"];
340
510
  }
341
511
  return {
342
512
  schema,
343
513
  metadata: metadata && Object.keys(metadata).length ? metadata : null,
344
- }
514
+ };
345
515
  }
346
516
 
347
517
  function parseNumeric(value: unknown): number | null {
348
- const numeric = typeof value === 'number' ? value : Number(value)
349
- if (!Number.isFinite(numeric) || numeric < 0) return null
350
- return numeric
518
+ const numeric = typeof value === "number" ? value : Number(value);
519
+ if (!Number.isFinite(numeric) || numeric < 0) return null;
520
+ return numeric;
351
521
  }
352
522
 
353
523
  function normalizeDimensionsInput(raw: unknown): {
354
- width?: number
355
- height?: number
356
- depth?: number
357
- unit?: string
524
+ width?: number;
525
+ height?: number;
526
+ depth?: number;
527
+ unit?: string;
358
528
  } | null {
359
- const source = parseObjectLike(raw)
360
- if (!source) return null
361
- const clean: Record<string, unknown> = {}
362
- const width = parseNumeric(source.width)
363
- const height = parseNumeric(source.height)
364
- const depth = parseNumeric(source.depth)
365
- const unit = typeof source.unit === 'string' && source.unit.trim().length ? source.unit.trim() : null
366
- if (width !== null) clean.width = width
367
- if (height !== null) clean.height = height
368
- if (depth !== null) clean.depth = depth
369
- if (unit) clean.unit = unit
370
- return Object.keys(clean).length ? (clean as { width?: number; height?: number; depth?: number; unit?: string }) : null
529
+ const source = parseObjectLike(raw);
530
+ if (!source) return null;
531
+ const clean: Record<string, unknown> = {};
532
+ const width = parseNumeric(source.width);
533
+ const height = parseNumeric(source.height);
534
+ const depth = parseNumeric(source.depth);
535
+ const unit =
536
+ typeof source.unit === "string" && source.unit.trim().length
537
+ ? source.unit.trim()
538
+ : null;
539
+ if (width !== null) clean.width = width;
540
+ if (height !== null) clean.height = height;
541
+ if (depth !== null) clean.depth = depth;
542
+ if (unit) clean.unit = unit;
543
+ return Object.keys(clean).length
544
+ ? (clean as {
545
+ width?: number;
546
+ height?: number;
547
+ depth?: number;
548
+ unit?: string;
549
+ })
550
+ : null;
371
551
  }
372
552
 
373
- function normalizeWeightInput(raw: unknown): { value?: number; unit?: string } | null {
374
- const source = parseObjectLike(raw)
375
- if (!source) return null
376
- const value = parseNumeric(source.value)
377
- const unit = typeof source.unit === 'string' && source.unit.trim().length ? source.unit.trim() : null
378
- if (value === null && !unit) return null
379
- const clean: { value?: number; unit?: string } = {}
380
- if (value !== null) clean.value = value
381
- if (unit) clean.unit = unit
382
- return clean
553
+ function normalizeWeightInput(
554
+ raw: unknown,
555
+ ): { value?: number; unit?: string } | null {
556
+ const source = parseObjectLike(raw);
557
+ if (!source) return null;
558
+ const value = parseNumeric(source.value);
559
+ const unit =
560
+ typeof source.unit === "string" && source.unit.trim().length
561
+ ? source.unit.trim()
562
+ : null;
563
+ if (value === null && !unit) return null;
564
+ const clean: { value?: number; unit?: string } = {};
565
+ if (value !== null) clean.value = value;
566
+ if (unit) clean.unit = unit;
567
+ return clean;
383
568
  }
384
569
 
385
- function extractMeasurementsFromMetadata(metadata: Record<string, unknown> | null | undefined): {
386
- metadata: Record<string, unknown> | null
387
- dimensions: { width?: number; height?: number; depth?: number; unit?: string } | null
388
- weightValue: number | null
389
- weightUnit: string | null
570
+ function extractMeasurementsFromMetadata(
571
+ metadata: Record<string, unknown> | null | undefined,
572
+ ): {
573
+ metadata: Record<string, unknown> | null;
574
+ dimensions: {
575
+ width?: number;
576
+ height?: number;
577
+ depth?: number;
578
+ unit?: string;
579
+ } | null;
580
+ weightValue: number | null;
581
+ weightUnit: string | null;
390
582
  } {
391
- if (!metadata || typeof metadata !== 'object') {
392
- return { metadata: null, dimensions: null, weightValue: null, weightUnit: null }
583
+ if (!metadata || typeof metadata !== "object") {
584
+ return {
585
+ metadata: null,
586
+ dimensions: null,
587
+ weightValue: null,
588
+ weightUnit: null,
589
+ };
393
590
  }
394
- const clone = { ...(metadata as Record<string, unknown>) }
395
- const dimensions = normalizeDimensionsInput(clone.dimensions)
396
- const weight = normalizeWeightInput(clone.weight)
397
- delete clone.dimensions
398
- delete clone.weight
399
- const cleanedMetadata = Object.keys(clone).length ? clone : null
591
+ const clone = { ...(metadata as Record<string, unknown>) };
592
+ const dimensions = normalizeDimensionsInput(clone.dimensions);
593
+ const weight = normalizeWeightInput(clone.weight);
594
+ delete clone.dimensions;
595
+ delete clone.weight;
596
+ const cleanedMetadata = Object.keys(clone).length ? clone : null;
400
597
  return {
401
598
  metadata: cleanedMetadata,
402
599
  dimensions,
403
600
  weightValue: weight?.value ?? null,
404
601
  weightUnit: weight?.unit ?? null,
405
- }
602
+ };
406
603
  }
407
604
 
408
- function ensureSchemaName(name?: string | null, fallback?: string | null): string {
409
- if (name && name.trim().length) return name.trim()
410
- if (fallback && fallback.trim().length) return fallback.trim()
411
- return 'Product option schema'
605
+ function ensureSchemaName(
606
+ name?: string | null,
607
+ fallback?: string | null,
608
+ ): string {
609
+ if (name && name.trim().length) return name.trim();
610
+ if (fallback && fallback.trim().length) return fallback.trim();
611
+ return "Product option schema";
412
612
  }
413
613
 
414
614
  async function assignOptionSchemaTemplate(
415
615
  em: EntityManager,
416
616
  product: CatalogProduct,
417
617
  schema: CatalogProductOptionSchema,
418
- preferredName?: string | null
618
+ preferredName?: string | null,
419
619
  ): Promise<CatalogOptionSchemaTemplate> {
420
- const resolvedName = ensureSchemaName(schema.name, preferredName ?? product.title)
620
+ const resolvedName = ensureSchemaName(
621
+ schema.name,
622
+ preferredName ?? product.title,
623
+ );
421
624
  const templateCode = resolveOptionSchemaCode({
422
625
  name: schema.name ?? resolvedName,
423
626
  fallback: `${resolvedName}-${product.id}`,
424
627
  uniqueHint: product.id?.slice(0, 8),
425
- })
426
- let template = product.optionSchemaTemplate ?? null
628
+ });
629
+ let template = product.optionSchemaTemplate ?? null;
427
630
  if (!template) {
428
631
  template = await em.findOne(CatalogOptionSchemaTemplate, {
429
632
  organizationId: product.organizationId,
430
633
  tenantId: product.tenantId,
431
634
  code: templateCode,
432
635
  deletedAt: null,
433
- })
636
+ });
434
637
  }
435
638
  if (!template) {
436
639
  template = em.create(CatalogOptionSchemaTemplate, {
@@ -440,18 +643,18 @@ async function assignOptionSchemaTemplate(
440
643
  code: templateCode,
441
644
  description: schema.description ?? null,
442
645
  schema: cloneJson(schema),
443
- metadata: { source: 'product' },
646
+ metadata: { source: "product" },
444
647
  isActive: true,
445
- })
446
- em.persist(template)
648
+ });
649
+ em.persist(template);
447
650
  } else {
448
- template.code = templateCode
449
- template.name = resolvedName
450
- template.description = schema.description ?? template.description ?? null
451
- template.schema = cloneJson(schema)
651
+ template.code = templateCode;
652
+ template.name = resolvedName;
653
+ template.description = schema.description ?? template.description ?? null;
654
+ template.schema = cloneJson(schema);
452
655
  }
453
- product.optionSchemaTemplate = template
454
- return template
656
+ product.optionSchemaTemplate = template;
657
+ return template;
455
658
  }
456
659
 
457
660
  function serializeOffer(record: CatalogOffer): OfferSnapshot {
@@ -464,35 +667,38 @@ function serializeOffer(record: CatalogOffer): OfferSnapshot {
464
667
  defaultMediaUrl: record.defaultMediaUrl ?? null,
465
668
  metadata: record.metadata ? cloneJson(record.metadata) : null,
466
669
  isActive: record.isActive,
467
- }
670
+ };
468
671
  }
469
672
 
470
- async function loadOfferSnapshots(em: EntityManager, productId: string): Promise<OfferSnapshot[]> {
673
+ async function loadOfferSnapshots(
674
+ em: EntityManager,
675
+ productId: string,
676
+ ): Promise<OfferSnapshot[]> {
471
677
  const offerRecords = await em.find(
472
678
  CatalogOffer,
473
679
  { product: productId },
474
- { orderBy: { createdAt: 'asc' } }
475
- )
476
- return offerRecords.map((offer) => serializeOffer(offer))
680
+ { orderBy: { createdAt: "asc" } },
681
+ );
682
+ return offerRecords.map((offer) => serializeOffer(offer));
477
683
  }
478
684
 
479
685
  async function restoreOffersFromSnapshot(
480
686
  em: EntityManager,
481
687
  product: CatalogProduct,
482
- snapshot: OfferSnapshot[] | null | undefined
688
+ snapshot: OfferSnapshot[] | null | undefined,
483
689
  ): Promise<void> {
484
- const existing = await em.find(CatalogOffer, { product })
485
- const keepIds = new Set<string>()
486
- const list = Array.isArray(snapshot) ? snapshot : []
690
+ const existing = await em.find(CatalogOffer, { product });
691
+ const keepIds = new Set<string>();
692
+ const list = Array.isArray(snapshot) ? snapshot : [];
487
693
  for (const offer of existing) {
488
694
  if (!list.some((snap) => snap.id === offer.id)) {
489
- em.remove(offer)
695
+ em.remove(offer);
490
696
  } else {
491
- keepIds.add(offer.id)
697
+ keepIds.add(offer.id);
492
698
  }
493
699
  }
494
700
  for (const snap of list) {
495
- let target = existing.find((entry) => entry.id === snap.id)
701
+ let target = existing.find((entry) => entry.id === snap.id);
496
702
  if (!target) {
497
703
  target = em.create(CatalogOffer, {
498
704
  id: snap.id,
@@ -502,22 +708,22 @@ async function restoreOffersFromSnapshot(
502
708
  channelId: snap.channelId,
503
709
  title: snap.title,
504
710
  isActive: snap.isActive,
505
- })
506
- em.persist(target)
711
+ });
712
+ em.persist(target);
507
713
  }
508
- target.channelId = snap.channelId
509
- target.title = snap.title
510
- target.description = snap.description ?? null
511
- target.defaultMediaId = snap.defaultMediaId ?? null
512
- target.defaultMediaUrl = snap.defaultMediaUrl ?? null
513
- target.metadata = snap.metadata ? cloneJson(snap.metadata) : null
514
- target.isActive = snap.isActive
515
- keepIds.add(target.id)
714
+ target.channelId = snap.channelId;
715
+ target.title = snap.title;
716
+ target.description = snap.description ?? null;
717
+ target.defaultMediaId = snap.defaultMediaId ?? null;
718
+ target.defaultMediaUrl = snap.defaultMediaUrl ?? null;
719
+ target.metadata = snap.metadata ? cloneJson(snap.metadata) : null;
720
+ target.isActive = snap.isActive;
721
+ keepIds.add(target.id);
516
722
  }
517
- const toRemove = existing.filter((offer) => !keepIds.has(offer.id))
723
+ const toRemove = existing.filter((offer) => !keepIds.has(offer.id));
518
724
  if (toRemove.length) {
519
725
  for (const offer of toRemove) {
520
- em.remove(offer)
726
+ em.remove(offer);
521
727
  }
522
728
  }
523
729
  }
@@ -525,45 +731,46 @@ async function restoreOffersFromSnapshot(
525
731
  async function syncOffers(
526
732
  em: EntityManager,
527
733
  product: CatalogProduct,
528
- inputs: OfferInput[] | undefined
734
+ inputs: OfferInput[] | undefined,
529
735
  ): Promise<void> {
530
- if (!inputs) return
531
- const normalized = inputs
532
- .map((input) => ({
533
- ...input,
534
- title: input.title?.trim().length ? input.title.trim() : product.title,
535
- description:
536
- input.description != null && input.description.trim().length
537
- ? input.description.trim()
538
- : product.description ?? null,
539
- defaultMediaId:
540
- typeof input.defaultMediaId === 'string' && input.defaultMediaId.trim().length
541
- ? input.defaultMediaId.trim()
542
- : null,
543
- defaultMediaUrl:
544
- typeof input.defaultMediaUrl === 'string' && input.defaultMediaUrl.trim().length
545
- ? input.defaultMediaUrl.trim()
546
- : null,
547
- metadata: input.metadata ? cloneJson(input.metadata) : null,
548
- isActive: input.isActive !== false,
549
- }))
550
- const existing = await em.find(CatalogOffer, { product })
551
- const claimed = new Set<string>()
552
- const channelMap = new Map<string, CatalogOffer>()
736
+ if (!inputs) return;
737
+ const normalized = inputs.map((input) => ({
738
+ ...input,
739
+ title: input.title?.trim().length ? input.title.trim() : product.title,
740
+ description:
741
+ input.description != null && input.description.trim().length
742
+ ? input.description.trim()
743
+ : (product.description ?? null),
744
+ defaultMediaId:
745
+ typeof input.defaultMediaId === "string" &&
746
+ input.defaultMediaId.trim().length
747
+ ? input.defaultMediaId.trim()
748
+ : null,
749
+ defaultMediaUrl:
750
+ typeof input.defaultMediaUrl === "string" &&
751
+ input.defaultMediaUrl.trim().length
752
+ ? input.defaultMediaUrl.trim()
753
+ : null,
754
+ metadata: input.metadata ? cloneJson(input.metadata) : null,
755
+ isActive: input.isActive !== false,
756
+ }));
757
+ const existing = await em.find(CatalogOffer, { product });
758
+ const claimed = new Set<string>();
759
+ const channelMap = new Map<string, CatalogOffer>();
553
760
  for (const offer of existing) {
554
- channelMap.set(offer.channelId, offer)
761
+ channelMap.set(offer.channelId, offer);
555
762
  }
556
- const updates: CatalogOffer[] = []
763
+ const updates: CatalogOffer[] = [];
557
764
  for (const input of normalized) {
558
- if (!input.channelId) continue
559
- let target: CatalogOffer | undefined
765
+ if (!input.channelId) continue;
766
+ let target: CatalogOffer | undefined;
560
767
  if (input.id) {
561
- target = existing.find((item) => item.id === input.id)
768
+ target = existing.find((item) => item.id === input.id);
562
769
  }
563
770
  if (!target) {
564
- const existingByChannel = channelMap.get(input.channelId)
771
+ const existingByChannel = channelMap.get(input.channelId);
565
772
  if (existingByChannel && !claimed.has(existingByChannel.id)) {
566
- target = existingByChannel
773
+ target = existingByChannel;
567
774
  }
568
775
  }
569
776
  if (!target) {
@@ -574,65 +781,65 @@ async function syncOffers(
574
781
  channelId: input.channelId,
575
782
  title: input.title || product.title,
576
783
  isActive: input.isActive !== false,
577
- })
578
- em.persist(target)
579
- existing.push(target)
580
- channelMap.set(input.channelId, target)
784
+ });
785
+ em.persist(target);
786
+ existing.push(target);
787
+ channelMap.set(input.channelId, target);
581
788
  }
582
- target.channelId = input.channelId
583
- target.title = input.title || product.title
584
- target.description = input.description ?? null
585
- target.defaultMediaId = input.defaultMediaId ?? null
586
- target.defaultMediaUrl = input.defaultMediaUrl ?? null
587
- target.metadata = input.metadata ? cloneJson(input.metadata) : null
588
- target.isActive = input.isActive !== false
589
- claimed.add(target.id)
590
- updates.push(target)
789
+ target.channelId = input.channelId;
790
+ target.title = input.title || product.title;
791
+ target.description = input.description ?? null;
792
+ target.defaultMediaId = input.defaultMediaId ?? null;
793
+ target.defaultMediaUrl = input.defaultMediaUrl ?? null;
794
+ target.metadata = input.metadata ? cloneJson(input.metadata) : null;
795
+ target.isActive = input.isActive !== false;
796
+ claimed.add(target.id);
797
+ updates.push(target);
591
798
  }
592
- const toRemove = existing.filter((offer) => !claimed.has(offer.id))
799
+ const toRemove = existing.filter((offer) => !claimed.has(offer.id));
593
800
  for (const offer of toRemove) {
594
- em.remove(offer)
801
+ em.remove(offer);
595
802
  }
596
803
  }
597
804
 
598
805
  async function syncCategoryAssignments(
599
806
  em: EntityManager,
600
807
  product: CatalogProduct,
601
- categoryIds: string[] | undefined
808
+ categoryIds: string[] | undefined,
602
809
  ): Promise<void> {
603
810
  const normalized = Array.from(
604
811
  new Set(
605
812
  (Array.isArray(categoryIds) ? categoryIds : [])
606
- .map((id) => (typeof id === 'string' ? id.trim() : ''))
607
- .filter((id) => id.length)
608
- )
609
- )
610
- const existing = await em.find(CatalogProductCategoryAssignment, { product })
813
+ .map((id) => (typeof id === "string" ? id.trim() : ""))
814
+ .filter((id) => id.length),
815
+ ),
816
+ );
817
+ const existing = await em.find(CatalogProductCategoryAssignment, { product });
611
818
  if (!normalized.length) {
612
819
  if (existing.length) {
613
820
  for (const assignment of existing) {
614
- em.remove(assignment)
821
+ em.remove(assignment);
615
822
  }
616
823
  }
617
- return
824
+ return;
618
825
  }
619
- const categories = await em.find(
620
- CatalogProductCategory,
621
- {
622
- id: { $in: normalized },
623
- organizationId: product.organizationId,
624
- tenantId: product.tenantId,
625
- }
626
- )
627
- const categoryMap = new Map(categories.map((category) => [category.id, category]))
628
- const claimed = new Set<string>()
826
+ const categories = await em.find(CatalogProductCategory, {
827
+ id: { $in: normalized },
828
+ organizationId: product.organizationId,
829
+ tenantId: product.tenantId,
830
+ });
831
+ const categoryMap = new Map(
832
+ categories.map((category) => [category.id, category]),
833
+ );
834
+ const claimed = new Set<string>();
629
835
  normalized.forEach((categoryId, index) => {
630
- const category = categoryMap.get(categoryId)
631
- if (!category) return
836
+ const category = categoryMap.get(categoryId);
837
+ if (!category) return;
632
838
  let assignment = existing.find((item) => {
633
- const value = typeof item.category === 'string' ? item.category : item.category?.id
634
- return value === categoryId
635
- })
839
+ const value =
840
+ typeof item.category === "string" ? item.category : item.category?.id;
841
+ return value === categoryId;
842
+ });
636
843
  if (!assignment) {
637
844
  assignment = em.create(CatalogProductCategoryAssignment, {
638
845
  product,
@@ -640,16 +847,16 @@ async function syncCategoryAssignments(
640
847
  organizationId: product.organizationId,
641
848
  tenantId: product.tenantId,
642
849
  position: index,
643
- })
644
- em.persist(assignment)
645
- existing.push(assignment)
850
+ });
851
+ em.persist(assignment);
852
+ existing.push(assignment);
646
853
  }
647
- assignment.position = index
648
- claimed.add(assignment.id)
649
- })
854
+ assignment.position = index;
855
+ claimed.add(assignment.id);
856
+ });
650
857
  for (const assignment of existing) {
651
858
  if (!claimed.has(assignment.id)) {
652
- em.remove(assignment)
859
+ em.remove(assignment);
653
860
  }
654
861
  }
655
862
  }
@@ -657,102 +864,99 @@ async function syncCategoryAssignments(
657
864
  async function syncProductTags(
658
865
  em: EntityManager,
659
866
  product: CatalogProduct,
660
- tags: string[] | undefined
867
+ tags: string[] | undefined,
661
868
  ): Promise<void> {
662
- const labelMap = new Map<string, string>()
869
+ const labelMap = new Map<string, string>();
663
870
  if (Array.isArray(tags)) {
664
871
  tags.forEach((raw) => {
665
- const label = typeof raw === 'string' ? raw.trim() : ''
666
- if (!label) return
667
- const slug = slugifyTagLabel(label)
872
+ const label = typeof raw === "string" ? raw.trim() : "";
873
+ if (!label) return;
874
+ const slug = slugifyTagLabel(label);
668
875
  if (!labelMap.has(slug)) {
669
- labelMap.set(slug, label)
876
+ labelMap.set(slug, label);
670
877
  }
671
- })
878
+ });
672
879
  }
673
- const slugs = Array.from(labelMap.keys())
880
+ const slugs = Array.from(labelMap.keys());
674
881
  const existingAssignments = await findWithDecryption(
675
882
  em,
676
883
  CatalogProductTagAssignment,
677
884
  { product },
678
- { populate: ['tag'] },
885
+ { populate: ["tag"] },
679
886
  { tenantId: product.tenantId, organizationId: product.organizationId },
680
- )
887
+ );
681
888
  if (!slugs.length) {
682
889
  if (existingAssignments.length) {
683
890
  for (const assignment of existingAssignments) {
684
- em.remove(assignment)
891
+ em.remove(assignment);
685
892
  }
686
893
  }
687
- return
894
+ return;
688
895
  }
689
- const existingTags = await em.find(
690
- CatalogProductTag,
691
- {
692
- organizationId: product.organizationId,
693
- tenantId: product.tenantId,
694
- slug: { $in: slugs },
695
- }
696
- )
697
- const tagsBySlug = new Map(existingTags.map((tag) => [tag.slug, tag]))
896
+ const existingTags = await em.find(CatalogProductTag, {
897
+ organizationId: product.organizationId,
898
+ tenantId: product.tenantId,
899
+ slug: { $in: slugs },
900
+ });
901
+ const tagsBySlug = new Map(existingTags.map((tag) => [tag.slug, tag]));
698
902
  for (const slug of slugs) {
699
- if (tagsBySlug.has(slug)) continue
700
- const label = labelMap.get(slug) ?? slug
903
+ if (tagsBySlug.has(slug)) continue;
904
+ const label = labelMap.get(slug) ?? slug;
701
905
  const tag = em.create(CatalogProductTag, {
702
906
  organizationId: product.organizationId,
703
907
  tenantId: product.tenantId,
704
908
  slug,
705
909
  label,
706
- })
707
- em.persist(tag)
708
- tagsBySlug.set(slug, tag)
910
+ });
911
+ em.persist(tag);
912
+ tagsBySlug.set(slug, tag);
709
913
  }
710
914
  const assignmentByTagId = new Map(
711
915
  existingAssignments.map((assignment) => [
712
- typeof assignment.tag === 'string' ? assignment.tag : assignment.tag.id,
916
+ typeof assignment.tag === "string" ? assignment.tag : assignment.tag.id,
713
917
  assignment,
714
- ])
715
- )
716
- const keepIds = new Set<string>()
918
+ ]),
919
+ );
920
+ const keepIds = new Set<string>();
717
921
  for (const slug of slugs) {
718
- const tag = tagsBySlug.get(slug)
719
- if (!tag) continue
720
- const tagId = tag.id
721
- let assignment = assignmentByTagId.get(tagId)
922
+ const tag = tagsBySlug.get(slug);
923
+ if (!tag) continue;
924
+ const tagId = tag.id;
925
+ let assignment = assignmentByTagId.get(tagId);
722
926
  if (!assignment) {
723
927
  assignment = em.create(CatalogProductTagAssignment, {
724
928
  product,
725
929
  tag,
726
930
  organizationId: product.organizationId,
727
931
  tenantId: product.tenantId,
728
- })
729
- em.persist(assignment)
932
+ });
933
+ em.persist(assignment);
730
934
  }
731
- keepIds.add(assignment.id)
935
+ keepIds.add(assignment.id);
732
936
  }
733
937
  for (const assignment of existingAssignments) {
734
938
  if (!keepIds.has(assignment.id)) {
735
- em.remove(assignment)
939
+ em.remove(assignment);
736
940
  }
737
941
  }
738
942
  }
739
943
 
740
944
  type VariantCleanupSnapshot = {
741
- id: string
742
- organizationId: string
743
- tenantId: string
744
- custom: Record<string, unknown> | null
745
- }
945
+ id: string;
946
+ organizationId: string;
947
+ tenantId: string;
948
+ custom: Record<string, unknown> | null;
949
+ };
746
950
 
747
951
  async function deleteProductVariantsAndRelatedData(opts: {
748
- em: EntityManager
749
- product: CatalogProduct
750
- dataEngine: DataEngine
751
- ctx: CommandRuntimeContext
952
+ em: EntityManager;
953
+ product: CatalogProduct;
954
+ dataEngine: DataEngine;
955
+ ctx: CommandRuntimeContext;
752
956
  }): Promise<void> {
753
- const { em, product, dataEngine, ctx } = opts
754
- const variants = await em.find(CatalogProductVariant, { product })
755
- if (!variants.length) return
957
+ const { em, product, dataEngine, ctx } = opts;
958
+ const variants = await em.find(CatalogProductVariant, { product });
959
+ if (!variants.length) return;
756
960
  const cleanupEntries: VariantCleanupSnapshot[] = await Promise.all(
757
961
  variants.map(async (variant) => {
758
962
  const custom = await loadCustomFieldSnapshot(em, {
@@ -760,27 +964,29 @@ async function deleteProductVariantsAndRelatedData(opts: {
760
964
  recordId: variant.id,
761
965
  organizationId: variant.organizationId,
762
966
  tenantId: variant.tenantId,
763
- })
967
+ });
764
968
  return {
765
969
  id: variant.id,
766
970
  organizationId: variant.organizationId,
767
971
  tenantId: variant.tenantId,
768
972
  custom: Object.keys(custom).length ? custom : null,
769
- }
770
- })
771
- )
772
- const variantIds = variants.map((variant) => variant.id)
973
+ };
974
+ }),
975
+ );
976
+ const variantIds = variants.map((variant) => variant.id);
773
977
  if (variantIds.length) {
774
- await em.nativeDelete(CatalogProductPrice, { variant: { $in: variantIds } })
978
+ await em.nativeDelete(CatalogProductPrice, {
979
+ variant: { $in: variantIds },
980
+ });
775
981
  }
776
982
  for (const variant of variants) {
777
- em.remove(variant)
983
+ em.remove(variant);
778
984
  }
779
- await em.flush()
985
+ await em.flush();
780
986
  for (const cleanup of cleanupEntries) {
781
- if (!cleanup.custom) continue
782
- const resetValues = buildCustomFieldResetMap(cleanup.custom, undefined)
783
- if (!Object.keys(resetValues).length) continue
987
+ if (!cleanup.custom) continue;
988
+ const resetValues = buildCustomFieldResetMap(cleanup.custom, undefined);
989
+ if (!Object.keys(resetValues).length) continue;
784
990
  await setCustomFieldsIfAny({
785
991
  dataEngine,
786
992
  entityId: E.catalog.catalog_product_variant,
@@ -788,7 +994,7 @@ async function deleteProductVariantsAndRelatedData(opts: {
788
994
  organizationId: cleanup.organizationId,
789
995
  tenantId: cleanup.tenantId,
790
996
  values: resetValues,
791
- })
997
+ });
792
998
  }
793
999
  for (const cleanup of cleanupEntries) {
794
1000
  await emitCatalogQueryIndexEvent(ctx, {
@@ -796,100 +1002,112 @@ async function deleteProductVariantsAndRelatedData(opts: {
796
1002
  recordId: cleanup.id,
797
1003
  organizationId: cleanup.organizationId,
798
1004
  tenantId: cleanup.tenantId,
799
- action: 'deleted',
800
- })
1005
+ action: "deleted",
1006
+ });
801
1007
  }
802
1008
  }
803
1009
 
804
1010
  function isProductOwnedOptionSchemaTemplate(
805
- template: CatalogOptionSchemaTemplate | string | null | undefined
1011
+ template: CatalogOptionSchemaTemplate | string | null | undefined,
806
1012
  ): template is CatalogOptionSchemaTemplate {
807
- if (!template || typeof template === 'string') return false
808
- const metadata = template.metadata
809
- if (!metadata || typeof metadata !== 'object') return false
810
- const source = (metadata as Record<string, unknown>).source
811
- return source === 'product'
1013
+ if (!template || typeof template === "string") return false;
1014
+ const metadata = template.metadata;
1015
+ if (!metadata || typeof metadata !== "object") return false;
1016
+ const source = (metadata as Record<string, unknown>).source;
1017
+ return source === "product";
812
1018
  }
813
1019
 
814
1020
  async function resolveOptionSchemaTemplateForRemoval(
815
1021
  em: EntityManager,
816
- product: CatalogProduct
1022
+ product: CatalogProduct,
817
1023
  ): Promise<CatalogOptionSchemaTemplate | null> {
818
- const template = product.optionSchemaTemplate
1024
+ const template = product.optionSchemaTemplate;
819
1025
  if (!isProductOwnedOptionSchemaTemplate(template)) {
820
- return null
1026
+ return null;
821
1027
  }
822
1028
  const otherUsage = await em.count(CatalogProduct, {
823
1029
  optionSchemaTemplate: template,
824
1030
  id: { $ne: product.id },
825
1031
  deletedAt: null,
826
- })
827
- if (otherUsage > 0) return null
828
- return template
1032
+ });
1033
+ if (otherUsage > 0) return null;
1034
+ return template;
829
1035
  }
830
1036
 
831
1037
  async function loadProductSnapshot(
832
1038
  em: EntityManager,
833
- id: string
1039
+ id: string,
834
1040
  ): Promise<ProductSnapshot | null> {
835
1041
  const record = await findOneWithDecryption(
836
1042
  em,
837
1043
  CatalogProduct,
838
1044
  { id, deletedAt: null },
839
- { populate: ['optionSchemaTemplate'] },
840
- )
841
- if (!record) return null
1045
+ { populate: ["optionSchemaTemplate"] },
1046
+ );
1047
+ if (!record) return null;
842
1048
  const [offers, tagAssignments, categoryAssignments] = await Promise.all([
843
1049
  loadOfferSnapshots(em, record.id),
844
1050
  findWithDecryption(
845
1051
  em,
846
1052
  CatalogProductTagAssignment,
847
1053
  { product: record.id },
848
- { populate: ['tag'] },
1054
+ { populate: ["tag"] },
1055
+ { tenantId: record.tenantId, organizationId: record.organizationId },
1056
+ ),
1057
+ findWithDecryption(
1058
+ em,
1059
+ CatalogProductCategoryAssignment,
1060
+ { product: record.id },
1061
+ { populate: ["category"] },
849
1062
  { tenantId: record.tenantId, organizationId: record.organizationId },
850
1063
  ),
851
- em.find(CatalogProductCategoryAssignment, { product: record.id }, { populate: ['category'] }),
852
- ])
1064
+ ]);
853
1065
  const tags = tagAssignments
854
1066
  .map((assignment) => {
855
1067
  const tag =
856
- typeof assignment.tag === 'string' ? null : assignment.tag ?? null
857
- const label = tag?.label ?? null
858
- return typeof label === 'string' && label.trim().length ? label : null
1068
+ typeof assignment.tag === "string" ? null : (assignment.tag ?? null);
1069
+ const label = tag?.label ?? null;
1070
+ return typeof label === "string" && label.trim().length ? label : null;
859
1071
  })
860
1072
  .filter((label): label is string => !!label)
861
- .sort((a, b) => a.localeCompare(b))
1073
+ .sort((a, b) => a.localeCompare(b));
862
1074
  const categoryIds = categoryAssignments
863
1075
  .slice()
864
1076
  .sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
865
1077
  .map((assignment) => {
866
- if (typeof assignment.category === 'string') return assignment.category
867
- return assignment.category?.id ?? null
1078
+ if (typeof assignment.category === "string") return assignment.category;
1079
+ return assignment.category?.id ?? null;
868
1080
  })
869
- .filter((value): value is string => !!value)
1081
+ .filter((value): value is string => !!value);
870
1082
  const custom = await loadCustomFieldSnapshot(em, {
871
1083
  entityId: E.catalog.catalog_product,
872
1084
  recordId: record.id,
873
1085
  tenantId: record.tenantId,
874
1086
  organizationId: record.organizationId,
875
- })
876
- const optionSchemaTemplate = record.optionSchemaTemplate
1087
+ });
1088
+ const optionSchemaTemplate = record.optionSchemaTemplate;
877
1089
  const optionTemplateId =
878
- typeof optionSchemaTemplate === 'string'
1090
+ typeof optionSchemaTemplate === "string"
879
1091
  ? optionSchemaTemplate
880
- : optionSchemaTemplate?.id ?? null
881
- const measurements = extractMeasurementsFromMetadata(record.metadata ? cloneJson(record.metadata) : null)
1092
+ : (optionSchemaTemplate?.id ?? null);
1093
+ const measurements = extractMeasurementsFromMetadata(
1094
+ record.metadata ? cloneJson(record.metadata) : null,
1095
+ );
882
1096
  const dimensions =
883
1097
  record.dimensions && Object.keys(record.dimensions).length
884
1098
  ? cloneJson(record.dimensions)
885
1099
  : measurements.dimensions
886
1100
  ? cloneJson(measurements.dimensions)
887
- : null
1101
+ : null;
888
1102
  const weightValue =
889
1103
  record.weightValue ??
890
- (measurements.weightValue !== null ? toNumericString(measurements.weightValue) : null)
891
- const weightUnit = record.weightUnit ?? measurements.weightUnit ?? null
892
- const metadata = measurements.metadata ? cloneJson(measurements.metadata) : null
1104
+ (measurements.weightValue !== null
1105
+ ? toNumericString(measurements.weightValue)
1106
+ : null);
1107
+ const weightUnit = record.weightUnit ?? measurements.weightUnit ?? null;
1108
+ const metadata = measurements.metadata
1109
+ ? cloneJson(measurements.metadata)
1110
+ : null;
893
1111
  return {
894
1112
  id: record.id,
895
1113
  organizationId: record.organizationId,
@@ -905,6 +1123,13 @@ async function loadProductSnapshot(
905
1123
  statusEntryId: record.statusEntryId ?? null,
906
1124
  primaryCurrencyCode: record.primaryCurrencyCode ?? null,
907
1125
  defaultUnit: record.defaultUnit ?? null,
1126
+ defaultSalesUnit: record.defaultSalesUnit ?? null,
1127
+ defaultSalesUnitQuantity: record.defaultSalesUnitQuantity ?? "1",
1128
+ uomRoundingScale: record.uomRoundingScale ?? 4,
1129
+ uomRoundingMode: record.uomRoundingMode ?? "half_up",
1130
+ unitPriceEnabled: record.unitPriceEnabled ?? false,
1131
+ unitPriceReferenceUnit: record.unitPriceReferenceUnit ?? null,
1132
+ unitPriceBaseQuantity: record.unitPriceBaseQuantity ?? null,
908
1133
  defaultMediaId: record.defaultMediaId ?? null,
909
1134
  defaultMediaUrl: record.defaultMediaUrl ?? null,
910
1135
  weightValue,
@@ -921,71 +1146,101 @@ async function loadProductSnapshot(
921
1146
  tags,
922
1147
  categoryIds,
923
1148
  custom: Object.keys(custom).length ? custom : null,
924
- }
1149
+ };
925
1150
  }
926
1151
 
927
1152
  function applyProductSnapshot(
928
1153
  em: EntityManager,
929
1154
  record: CatalogProduct,
930
- snapshot: ProductSnapshot
1155
+ snapshot: ProductSnapshot,
931
1156
  ): void {
932
- record.organizationId = snapshot.organizationId
933
- record.tenantId = snapshot.tenantId
934
- record.title = snapshot.title
935
- record.subtitle = snapshot.subtitle ?? null
936
- record.description = snapshot.description ?? null
937
- record.sku = snapshot.sku ?? null
938
- record.handle = snapshot.handle ?? null
939
- record.taxRateId = snapshot.taxRateId ?? null
940
- record.taxRate = snapshot.taxRate ?? null
941
- record.productType = snapshot.productType
942
- record.statusEntryId = snapshot.statusEntryId ?? null
943
- record.primaryCurrencyCode = snapshot.primaryCurrencyCode ?? null
944
- record.defaultUnit = snapshot.defaultUnit ?? null
945
- record.defaultMediaId = snapshot.defaultMediaId ?? null
946
- record.defaultMediaUrl = snapshot.defaultMediaUrl ?? null
947
- record.weightValue = snapshot.weightValue ?? null
948
- record.weightUnit = snapshot.weightUnit ?? null
949
- record.dimensions = snapshot.dimensions ? cloneJson(snapshot.dimensions) : null
950
- record.metadata = snapshot.metadata ? cloneJson(snapshot.metadata) : null
951
- record.customFieldsetCode = snapshot.customFieldsetCode ?? null
1157
+ record.organizationId = snapshot.organizationId;
1158
+ record.tenantId = snapshot.tenantId;
1159
+ record.title = snapshot.title;
1160
+ record.subtitle = snapshot.subtitle ?? null;
1161
+ record.description = snapshot.description ?? null;
1162
+ record.sku = snapshot.sku ?? null;
1163
+ record.handle = snapshot.handle ?? null;
1164
+ record.taxRateId = snapshot.taxRateId ?? null;
1165
+ record.taxRate = snapshot.taxRate ?? null;
1166
+ record.productType = snapshot.productType;
1167
+ record.statusEntryId = snapshot.statusEntryId ?? null;
1168
+ record.primaryCurrencyCode = snapshot.primaryCurrencyCode ?? null;
1169
+ record.defaultUnit = snapshot.defaultUnit ?? null;
1170
+ record.defaultSalesUnit = snapshot.defaultSalesUnit ?? null;
1171
+ record.defaultSalesUnitQuantity = snapshot.defaultSalesUnitQuantity ?? "1";
1172
+ record.uomRoundingScale = snapshot.uomRoundingScale;
1173
+ record.uomRoundingMode = snapshot.uomRoundingMode;
1174
+ record.unitPriceEnabled = snapshot.unitPriceEnabled;
1175
+ record.unitPriceReferenceUnit = snapshot.unitPriceReferenceUnit ?? null;
1176
+ record.unitPriceBaseQuantity = snapshot.unitPriceBaseQuantity ?? null;
1177
+ record.defaultMediaId = snapshot.defaultMediaId ?? null;
1178
+ record.defaultMediaUrl = snapshot.defaultMediaUrl ?? null;
1179
+ record.weightValue = snapshot.weightValue ?? null;
1180
+ record.weightUnit = snapshot.weightUnit ?? null;
1181
+ record.dimensions = snapshot.dimensions
1182
+ ? cloneJson(snapshot.dimensions)
1183
+ : null;
1184
+ record.metadata = snapshot.metadata ? cloneJson(snapshot.metadata) : null;
1185
+ record.customFieldsetCode = snapshot.customFieldsetCode ?? null;
952
1186
  record.optionSchemaTemplate = snapshot.optionSchemaId
953
1187
  ? em.getReference(CatalogOptionSchemaTemplate, snapshot.optionSchemaId)
954
- : null
955
- record.isConfigurable = snapshot.isConfigurable
956
- record.isActive = snapshot.isActive
957
- record.createdAt = new Date(snapshot.createdAt)
958
- record.updatedAt = new Date(snapshot.updatedAt)
1188
+ : null;
1189
+ record.isConfigurable = snapshot.isConfigurable;
1190
+ record.isActive = snapshot.isActive;
1191
+ record.createdAt = new Date(snapshot.createdAt);
1192
+ record.updatedAt = new Date(snapshot.updatedAt);
959
1193
  }
960
1194
 
961
- const createProductCommand: CommandHandler<ProductCreateInput, { productId: string }> = {
962
- id: 'catalog.products.create',
1195
+ const createProductCommand: CommandHandler<
1196
+ ProductCreateInput,
1197
+ { productId: string }
1198
+ > = {
1199
+ id: "catalog.products.create",
963
1200
  async execute(rawInput, ctx) {
964
- const { parsed, custom } = parseWithCustomFields(productCreateSchema, rawInput)
965
- ensureTenantScope(ctx, parsed.tenantId)
966
- ensureOrganizationScope(ctx, parsed.organizationId)
967
- const em = (ctx.container.resolve('em') as EntityManager).fork()
968
- const now = new Date()
1201
+ const { parsed, custom } = parseWithCustomFields(
1202
+ productCreateSchema,
1203
+ rawInput,
1204
+ );
1205
+ ensureTenantScope(ctx, parsed.tenantId);
1206
+ ensureOrganizationScope(ctx, parsed.organizationId);
1207
+ const em = (ctx.container.resolve("em") as EntityManager).fork();
1208
+ const { translate } = await resolveTranslations();
1209
+ const now = new Date();
969
1210
  const { taxRateId, taxRate } = await resolveScopedTaxRate(
970
1211
  em,
971
1212
  parsed.taxRateId ?? null,
972
1213
  parsed.taxRate,
973
1214
  parsed.organizationId,
974
- parsed.tenantId
975
- )
976
- const { schema: optionSchemaDefinition, metadata: sanitizedMetadata } = extractOptionSchemaInput(parsed)
977
- const measurements = extractMeasurementsFromMetadata(sanitizedMetadata)
978
- const dimensions = normalizeDimensionsInput(parsed.dimensions) ?? measurements.dimensions
1215
+ parsed.tenantId,
1216
+ );
1217
+ const { schema: optionSchemaDefinition, metadata: sanitizedMetadata } =
1218
+ extractOptionSchemaInput(parsed);
1219
+ const measurements = extractMeasurementsFromMetadata(sanitizedMetadata);
1220
+ const dimensions =
1221
+ normalizeDimensionsInput(parsed.dimensions) ?? measurements.dimensions;
979
1222
  const weightValue =
980
1223
  parsed.weightValue !== undefined
981
1224
  ? toNumericString(parsed.weightValue)
982
1225
  : measurements.weightValue !== null
983
1226
  ? toNumericString(measurements.weightValue)
984
- : null
1227
+ : null;
985
1228
  const weightUnit =
986
- parsed.weightUnit !== undefined ? parsed.weightUnit ?? null : measurements.weightUnit ?? null
987
- const metadata = measurements.metadata ? cloneJson(measurements.metadata) : null
988
- const productId = randomUUID()
1229
+ parsed.weightUnit !== undefined
1230
+ ? (parsed.weightUnit ?? null)
1231
+ : (measurements.weightUnit ?? null);
1232
+ const metadata = measurements.metadata
1233
+ ? cloneJson(measurements.metadata)
1234
+ : null;
1235
+ const unitPriceInput = resolveUnitPriceInput(parsed);
1236
+ const unitPriceEnabled = unitPriceInput.enabled ?? false;
1237
+ const resolvedUnits = await resolveProductUnitDefaults(em, {
1238
+ organizationId: parsed.organizationId,
1239
+ tenantId: parsed.tenantId,
1240
+ defaultUnit: parsed.defaultUnit ?? null,
1241
+ defaultSalesUnit: parsed.defaultSalesUnit ?? parsed.defaultUnit ?? null,
1242
+ });
1243
+ const productId = randomUUID();
989
1244
  const record = em.create(CatalogProduct, {
990
1245
  id: productId,
991
1246
  organizationId: parsed.organizationId,
@@ -997,10 +1252,23 @@ const createProductCommand: CommandHandler<ProductCreateInput, { productId: stri
997
1252
  handle: parsed.handle ?? null,
998
1253
  taxRateId,
999
1254
  taxRate,
1000
- productType: parsed.productType ?? 'simple',
1255
+ productType: parsed.productType ?? "simple",
1001
1256
  statusEntryId: parsed.statusEntryId ?? null,
1002
1257
  primaryCurrencyCode: parsed.primaryCurrencyCode ?? null,
1003
- defaultUnit: parsed.defaultUnit ?? null,
1258
+ defaultUnit: resolvedUnits.defaultUnit,
1259
+ defaultSalesUnit:
1260
+ resolvedUnits.defaultSalesUnit ?? resolvedUnits.defaultUnit,
1261
+ defaultSalesUnitQuantity:
1262
+ toNumericString(parsed.defaultSalesUnitQuantity ?? 1) ?? "1",
1263
+ uomRoundingScale: parsed.uomRoundingScale ?? 4,
1264
+ uomRoundingMode: parsed.uomRoundingMode ?? "half_up",
1265
+ unitPriceEnabled,
1266
+ unitPriceReferenceUnit: unitPriceEnabled
1267
+ ? (unitPriceInput.referenceUnit ?? null)
1268
+ : null,
1269
+ unitPriceBaseQuantity: unitPriceEnabled
1270
+ ? (unitPriceInput.baseQuantity ?? null)
1271
+ : null,
1004
1272
  defaultMediaId: parsed.defaultMediaId ?? null,
1005
1273
  defaultMediaUrl: parsed.defaultMediaUrl ?? null,
1006
1274
  weightValue,
@@ -1012,39 +1280,43 @@ const createProductCommand: CommandHandler<ProductCreateInput, { productId: stri
1012
1280
  isActive: parsed.isActive ?? true,
1013
1281
  createdAt: now,
1014
1282
  updatedAt: now,
1015
- })
1016
- let optionSchemaTemplate: CatalogOptionSchemaTemplate | null = null
1283
+ });
1284
+ let optionSchemaTemplate: CatalogOptionSchemaTemplate | null = null;
1017
1285
  if (parsed.optionSchemaId) {
1018
1286
  optionSchemaTemplate = await requireOptionSchemaTemplate(
1019
1287
  em,
1020
1288
  parsed.optionSchemaId,
1021
- 'Option schema not found'
1022
- )
1023
- ensureSameScope(optionSchemaTemplate, parsed.organizationId, parsed.tenantId)
1024
- record.optionSchemaTemplate = optionSchemaTemplate
1289
+ translate("catalog.errors.optionSchemaNotFound", "Option schema not found"),
1290
+ );
1291
+ ensureSameScope(
1292
+ optionSchemaTemplate,
1293
+ parsed.organizationId,
1294
+ parsed.tenantId,
1295
+ );
1296
+ record.optionSchemaTemplate = optionSchemaTemplate;
1025
1297
  } else if (optionSchemaDefinition) {
1026
1298
  optionSchemaTemplate = await assignOptionSchemaTemplate(
1027
1299
  em,
1028
1300
  record,
1029
1301
  optionSchemaDefinition,
1030
- optionSchemaDefinition.name ?? parsed.title
1031
- )
1302
+ optionSchemaDefinition.name ?? parsed.title,
1303
+ );
1032
1304
  }
1033
- em.persist(record)
1305
+ em.persist(record);
1034
1306
  try {
1035
- await em.flush()
1307
+ await em.flush();
1036
1308
  } catch (error) {
1037
- await rethrowProductUniqueConstraint(error)
1309
+ await rethrowProductUniqueConstraint(error);
1038
1310
  }
1039
- await syncOffers(em, record, parsed.offers)
1040
- await syncCategoryAssignments(em, record, parsed.categoryIds)
1041
- await syncProductTags(em, record, parsed.tags)
1311
+ await syncOffers(em, record, parsed.offers);
1312
+ await syncCategoryAssignments(em, record, parsed.categoryIds);
1313
+ await syncProductTags(em, record, parsed.tags);
1042
1314
  try {
1043
- await em.flush()
1315
+ await em.flush();
1044
1316
  } catch (error) {
1045
- await rethrowProductUniqueConstraint(error)
1317
+ await rethrowProductUniqueConstraint(error);
1046
1318
  }
1047
- const dataEngine = ctx.container.resolve('dataEngine') as DataEngine
1319
+ const dataEngine = ctx.container.resolve("dataEngine") as DataEngine;
1048
1320
  await setCustomFieldsIfAny({
1049
1321
  dataEngine,
1050
1322
  entityId: E.catalog.catalog_product,
@@ -1052,25 +1324,28 @@ const createProductCommand: CommandHandler<ProductCreateInput, { productId: stri
1052
1324
  organizationId: record.organizationId,
1053
1325
  tenantId: record.tenantId,
1054
1326
  values: custom,
1055
- })
1327
+ });
1056
1328
  await emitProductCrudChange({
1057
1329
  dataEngine,
1058
- action: 'created',
1330
+ action: "created",
1059
1331
  product: record,
1060
- })
1061
- return { productId: record.id }
1332
+ });
1333
+ return { productId: record.id };
1062
1334
  },
1063
1335
  captureAfter: async (_input, result, ctx) => {
1064
- const em = (ctx.container.resolve('em') as EntityManager).fork()
1065
- return loadProductSnapshot(em, result.productId)
1336
+ const em = (ctx.container.resolve("em") as EntityManager).fork();
1337
+ return loadProductSnapshot(em, result.productId);
1066
1338
  },
1067
1339
  buildLog: async ({ result, snapshots }) => {
1068
- const after = snapshots.after as ProductSnapshot | undefined
1069
- if (!after) return null
1070
- const { translate } = await resolveTranslations()
1340
+ const after = snapshots.after as ProductSnapshot | undefined;
1341
+ if (!after) return null;
1342
+ const { translate } = await resolveTranslations();
1071
1343
  return {
1072
- actionLabel: translate('catalog.audit.products.create', 'Create catalog product'),
1073
- resourceKind: 'catalog.product',
1344
+ actionLabel: translate(
1345
+ "catalog.audit.products.create",
1346
+ "Create catalog product",
1347
+ ),
1348
+ resourceKind: "catalog.product",
1074
1349
  resourceId: result.productId,
1075
1350
  tenantId: after.tenantId,
1076
1351
  organizationId: after.organizationId,
@@ -1080,21 +1355,24 @@ const createProductCommand: CommandHandler<ProductCreateInput, { productId: stri
1080
1355
  after,
1081
1356
  } satisfies ProductUndoPayload,
1082
1357
  },
1083
- }
1358
+ };
1084
1359
  },
1085
1360
  undo: async ({ logEntry, ctx }) => {
1086
- const payload = extractUndoPayload<ProductUndoPayload>(logEntry)
1087
- const after = payload?.after
1088
- if (!after) return
1089
- const em = (ctx.container.resolve('em') as EntityManager).fork()
1090
- const record = await em.findOne(CatalogProduct, { id: after.id })
1091
- if (!record) return
1092
- ensureTenantScope(ctx, record.tenantId)
1093
- ensureOrganizationScope(ctx, record.organizationId)
1094
- em.remove(record)
1095
- await em.flush()
1096
- const dataEngine = ctx.container.resolve('dataEngine') as DataEngine
1097
- const resetValues = buildCustomFieldResetMap(undefined, after.custom ?? undefined)
1361
+ const payload = extractUndoPayload<ProductUndoPayload>(logEntry);
1362
+ const after = payload?.after;
1363
+ if (!after) return;
1364
+ const em = (ctx.container.resolve("em") as EntityManager).fork();
1365
+ const record = await findOneWithDecryption(em, CatalogProduct, { id: after.id });
1366
+ if (!record) return;
1367
+ ensureTenantScope(ctx, record.tenantId);
1368
+ ensureOrganizationScope(ctx, record.organizationId);
1369
+ em.remove(record);
1370
+ await em.flush();
1371
+ const dataEngine = ctx.container.resolve("dataEngine") as DataEngine;
1372
+ const resetValues = buildCustomFieldResetMap(
1373
+ undefined,
1374
+ after.custom ?? undefined,
1375
+ );
1098
1376
  if (Object.keys(resetValues).length) {
1099
1377
  await setCustomFieldsIfAny({
1100
1378
  dataEngine,
@@ -1103,73 +1381,176 @@ const createProductCommand: CommandHandler<ProductCreateInput, { productId: stri
1103
1381
  organizationId: after.organizationId,
1104
1382
  tenantId: after.tenantId,
1105
1383
  values: resetValues,
1106
- })
1384
+ });
1107
1385
  }
1108
1386
  await emitProductCrudUndoChange({
1109
1387
  dataEngine,
1110
- action: 'deleted',
1388
+ action: "deleted",
1111
1389
  product: record,
1112
- })
1390
+ });
1113
1391
  },
1114
- }
1392
+ };
1115
1393
 
1116
- const updateProductCommand: CommandHandler<ProductUpdateInput, { productId: string }> = {
1117
- id: 'catalog.products.update',
1394
+ const updateProductCommand: CommandHandler<
1395
+ ProductUpdateInput,
1396
+ { productId: string }
1397
+ > = {
1398
+ id: "catalog.products.update",
1118
1399
  async prepare(input, ctx) {
1119
- const id = requireId(input, 'Product id is required')
1120
- const em = (ctx.container.resolve('em') as EntityManager)
1121
- const snapshot = await loadProductSnapshot(em, id)
1400
+ const id = requireId(input, "Product id is required");
1401
+ const em = ctx.container.resolve("em") as EntityManager;
1402
+ const snapshot = await loadProductSnapshot(em, id);
1122
1403
  if (snapshot) {
1123
- ensureTenantScope(ctx, snapshot.tenantId)
1124
- ensureOrganizationScope(ctx, snapshot.organizationId)
1404
+ ensureTenantScope(ctx, snapshot.tenantId);
1405
+ ensureOrganizationScope(ctx, snapshot.organizationId);
1125
1406
  }
1126
- return snapshot ? { before: snapshot } : {}
1407
+ return snapshot ? { before: snapshot } : {};
1127
1408
  },
1128
1409
  async execute(rawInput, ctx) {
1129
- const { parsed, custom } = parseWithCustomFields(productUpdateSchema, rawInput)
1130
- const em = (ctx.container.resolve('em') as EntityManager).fork()
1131
- const record = await em.findOne(CatalogProduct, { id: parsed.id, deletedAt: null })
1132
- if (!record) throw new CrudHttpError(404, { error: 'Catalog product not found' })
1133
- const organizationId = parsed.organizationId ?? record.organizationId
1134
- const tenantId = parsed.tenantId ?? record.tenantId
1135
- ensureTenantScope(ctx, tenantId)
1136
- ensureOrganizationScope(ctx, organizationId)
1137
- ensureSameScope(record, organizationId, tenantId)
1138
- const dataEngine = ctx.container.resolve('dataEngine') as DataEngine
1139
- record.organizationId = organizationId
1140
- record.tenantId = tenantId
1141
- const taxRateProvided = parsed.taxRateId !== undefined || parsed.taxRate !== undefined
1410
+ const { parsed, custom } = parseWithCustomFields(
1411
+ productUpdateSchema,
1412
+ rawInput,
1413
+ );
1414
+ const rawPayload =
1415
+ rawInput && typeof rawInput === "object"
1416
+ ? (rawInput as Record<string, unknown>)
1417
+ : null;
1418
+ const hasDefaultUnit = Boolean(
1419
+ rawPayload &&
1420
+ Object.prototype.hasOwnProperty.call(rawPayload, "defaultUnit"),
1421
+ );
1422
+ const hasDefaultSalesUnit = Boolean(
1423
+ rawPayload &&
1424
+ Object.prototype.hasOwnProperty.call(rawPayload, "defaultSalesUnit"),
1425
+ );
1426
+ const requestedDefaultUnit = hasDefaultUnit
1427
+ ? rawPayload?.defaultUnit
1428
+ : parsed.defaultUnit;
1429
+ const requestedDefaultSalesUnit = hasDefaultSalesUnit
1430
+ ? rawPayload?.defaultSalesUnit
1431
+ : parsed.defaultSalesUnit;
1432
+ const em = (ctx.container.resolve("em") as EntityManager).fork();
1433
+ const { translate } = await resolveTranslations();
1434
+ const record = await findOneWithDecryption(em, CatalogProduct, {
1435
+ id: parsed.id,
1436
+ deletedAt: null,
1437
+ });
1438
+ if (!record)
1439
+ throw new CrudHttpError(404, {
1440
+ error: translate("catalog.errors.productNotFound", "Catalog product not found"),
1441
+ });
1442
+ const organizationId = parsed.organizationId ?? record.organizationId;
1443
+ const tenantId = parsed.tenantId ?? record.tenantId;
1444
+ ensureTenantScope(ctx, tenantId);
1445
+ ensureOrganizationScope(ctx, organizationId);
1446
+ ensureSameScope(record, organizationId, tenantId);
1447
+ const dataEngine = ctx.container.resolve("dataEngine") as DataEngine;
1448
+ const lookupEm = em.fork();
1449
+ const taxRateProvided =
1450
+ parsed.taxRateId !== undefined || parsed.taxRate !== undefined;
1142
1451
  const resolvedTaxRate = taxRateProvided
1143
- ? await resolveScopedTaxRate(em, parsed.taxRateId ?? null, parsed.taxRate, organizationId, tenantId)
1144
- : null
1452
+ ? await resolveScopedTaxRate(
1453
+ lookupEm,
1454
+ parsed.taxRateId ?? null,
1455
+ parsed.taxRate,
1456
+ organizationId,
1457
+ tenantId,
1458
+ )
1459
+ : null;
1460
+ record.organizationId = organizationId;
1461
+ record.tenantId = tenantId;
1145
1462
 
1146
- if (parsed.title !== undefined) record.title = parsed.title
1147
- if (parsed.subtitle !== undefined) record.subtitle = parsed.subtitle ?? null
1148
- if (parsed.description !== undefined) record.description = parsed.description ?? null
1149
- if (parsed.sku !== undefined) record.sku = parsed.sku ?? null
1150
- if (parsed.handle !== undefined) record.handle = parsed.handle ?? null
1463
+ if (parsed.title !== undefined) record.title = parsed.title;
1464
+ if (parsed.subtitle !== undefined)
1465
+ record.subtitle = parsed.subtitle ?? null;
1466
+ if (parsed.description !== undefined)
1467
+ record.description = parsed.description ?? null;
1468
+ if (parsed.sku !== undefined) record.sku = parsed.sku ?? null;
1469
+ if (parsed.handle !== undefined) record.handle = parsed.handle ?? null;
1151
1470
  if (taxRateProvided) {
1152
- record.taxRateId = resolvedTaxRate?.taxRateId ?? null
1153
- record.taxRate = resolvedTaxRate?.taxRate ?? null
1471
+ record.taxRateId = resolvedTaxRate?.taxRateId ?? null;
1472
+ record.taxRate = resolvedTaxRate?.taxRate ?? null;
1154
1473
  }
1155
- if (parsed.productType !== undefined) record.productType = parsed.productType
1156
- if (parsed.statusEntryId !== undefined) record.statusEntryId = parsed.statusEntryId ?? null
1474
+ if (parsed.productType !== undefined)
1475
+ record.productType = parsed.productType;
1476
+ if (parsed.statusEntryId !== undefined)
1477
+ record.statusEntryId = parsed.statusEntryId ?? null;
1157
1478
  if (parsed.primaryCurrencyCode !== undefined) {
1158
- record.primaryCurrencyCode = parsed.primaryCurrencyCode ?? null
1479
+ record.primaryCurrencyCode = parsed.primaryCurrencyCode ?? null;
1480
+ }
1481
+ const uomDefaultsTouched =
1482
+ hasDefaultUnit ||
1483
+ hasDefaultSalesUnit ||
1484
+ parsed.defaultUnit !== undefined ||
1485
+ parsed.defaultSalesUnit !== undefined ||
1486
+ parsed.organizationId !== undefined ||
1487
+ parsed.tenantId !== undefined;
1488
+ if (uomDefaultsTouched) {
1489
+ const resolvedUnits = await resolveProductUnitDefaults(lookupEm, {
1490
+ organizationId,
1491
+ tenantId,
1492
+ defaultUnit: hasDefaultUnit
1493
+ ? (requestedDefaultUnit as string | null | undefined)
1494
+ : parsed.defaultUnit !== undefined
1495
+ ? parsed.defaultUnit
1496
+ : record.defaultUnit,
1497
+ defaultSalesUnit: hasDefaultSalesUnit
1498
+ ? (requestedDefaultSalesUnit as string | null | undefined)
1499
+ : parsed.defaultSalesUnit !== undefined
1500
+ ? parsed.defaultSalesUnit
1501
+ : record.defaultSalesUnit,
1502
+ });
1503
+ await ensureBaseUnitCanBeRemoved(lookupEm, {
1504
+ productId: record.id,
1505
+ organizationId,
1506
+ tenantId,
1507
+ defaultUnit: resolvedUnits.defaultUnit,
1508
+ defaultSalesUnit: resolvedUnits.defaultSalesUnit,
1509
+ });
1510
+ record.defaultUnit = resolvedUnits.defaultUnit;
1511
+ record.defaultSalesUnit = resolvedUnits.defaultSalesUnit;
1512
+ }
1513
+ if (parsed.defaultSalesUnitQuantity !== undefined) {
1514
+ record.defaultSalesUnitQuantity =
1515
+ toNumericString(parsed.defaultSalesUnitQuantity) ?? "1";
1516
+ }
1517
+ if (parsed.uomRoundingScale !== undefined) {
1518
+ record.uomRoundingScale = parsed.uomRoundingScale;
1519
+ }
1520
+ if (parsed.uomRoundingMode !== undefined) {
1521
+ record.uomRoundingMode = parsed.uomRoundingMode;
1522
+ }
1523
+ const unitPriceInput = resolveUnitPriceInput(parsed);
1524
+ if (unitPriceInput.enabledProvided) {
1525
+ record.unitPriceEnabled = unitPriceInput.enabled ?? false;
1526
+ if (!record.unitPriceEnabled) {
1527
+ record.unitPriceReferenceUnit = null;
1528
+ record.unitPriceBaseQuantity = null;
1529
+ }
1530
+ }
1531
+ if (unitPriceInput.referenceProvided && record.unitPriceEnabled) {
1532
+ record.unitPriceReferenceUnit = unitPriceInput.referenceUnit ?? null;
1533
+ }
1534
+ if (unitPriceInput.baseProvided && record.unitPriceEnabled) {
1535
+ record.unitPriceBaseQuantity = unitPriceInput.baseQuantity ?? null;
1159
1536
  }
1160
- if (parsed.defaultUnit !== undefined) record.defaultUnit = parsed.defaultUnit ?? null
1161
1537
  if (parsed.defaultMediaId !== undefined) {
1162
- record.defaultMediaId = parsed.defaultMediaId ?? null
1538
+ record.defaultMediaId = parsed.defaultMediaId ?? null;
1163
1539
  }
1164
1540
  if (parsed.defaultMediaUrl !== undefined) {
1165
- record.defaultMediaUrl = parsed.defaultMediaUrl ?? null
1541
+ record.defaultMediaUrl = parsed.defaultMediaUrl ?? null;
1166
1542
  }
1167
1543
  const metadataProvided =
1168
- rawInput && typeof rawInput === 'object' && Object.prototype.hasOwnProperty.call(rawInput, 'metadata')
1169
- const { schema: optionSchemaDefinition, metadata: sanitizedMetadata } = extractOptionSchemaInput(parsed)
1170
- const measurements = extractMeasurementsFromMetadata(sanitizedMetadata)
1544
+ rawInput &&
1545
+ typeof rawInput === "object" &&
1546
+ Object.prototype.hasOwnProperty.call(rawInput, "metadata");
1547
+ const { schema: optionSchemaDefinition, metadata: sanitizedMetadata } =
1548
+ extractOptionSchemaInput(parsed);
1549
+ const measurements = extractMeasurementsFromMetadata(sanitizedMetadata);
1171
1550
  const normalizedDimensions =
1172
- parsed.dimensions !== undefined ? normalizeDimensionsInput(parsed.dimensions) : measurements.dimensions
1551
+ parsed.dimensions !== undefined
1552
+ ? normalizeDimensionsInput(parsed.dimensions)
1553
+ : measurements.dimensions;
1173
1554
  const weightValueFromInput =
1174
1555
  parsed.weightValue === null
1175
1556
  ? null
@@ -1177,35 +1558,41 @@ const updateProductCommand: CommandHandler<ProductUpdateInput, { productId: stri
1177
1558
  ? toNumericString(parsed.weightValue)
1178
1559
  : measurements.weightValue !== null
1179
1560
  ? toNumericString(measurements.weightValue)
1180
- : null
1561
+ : null;
1181
1562
  const weightUnitFromInput =
1182
- parsed.weightUnit !== undefined ? parsed.weightUnit ?? null : measurements.weightUnit ?? null
1563
+ parsed.weightUnit !== undefined
1564
+ ? (parsed.weightUnit ?? null)
1565
+ : (measurements.weightUnit ?? null);
1183
1566
  const weightProvided =
1184
1567
  parsed.weightValue !== undefined ||
1185
1568
  parsed.weightUnit !== undefined ||
1186
1569
  measurements.weightValue !== null ||
1187
- measurements.weightUnit !== null
1570
+ measurements.weightUnit !== null;
1188
1571
  if (normalizedDimensions !== null || parsed.dimensions !== undefined) {
1189
- record.dimensions = normalizedDimensions ? cloneJson(normalizedDimensions) : null
1572
+ record.dimensions = normalizedDimensions
1573
+ ? cloneJson(normalizedDimensions)
1574
+ : null;
1190
1575
  }
1191
1576
  if (weightProvided) {
1192
- record.weightValue = weightValueFromInput
1193
- record.weightUnit = weightUnitFromInput
1577
+ record.weightValue = weightValueFromInput;
1578
+ record.weightUnit = weightUnitFromInput;
1194
1579
  }
1195
1580
  if (metadataProvided) {
1196
- record.metadata = measurements.metadata ? cloneJson(measurements.metadata) : null
1581
+ record.metadata = measurements.metadata
1582
+ ? cloneJson(measurements.metadata)
1583
+ : null;
1197
1584
  }
1198
1585
  if (parsed.optionSchemaId !== undefined) {
1199
1586
  if (!parsed.optionSchemaId) {
1200
- record.optionSchemaTemplate = null
1587
+ record.optionSchemaTemplate = null;
1201
1588
  } else {
1202
1589
  const optionTemplate = await requireOptionSchemaTemplate(
1203
- em,
1590
+ lookupEm,
1204
1591
  parsed.optionSchemaId,
1205
- 'Option schema not found'
1206
- )
1207
- ensureSameScope(optionTemplate, organizationId, tenantId)
1208
- record.optionSchemaTemplate = optionTemplate
1592
+ translate("catalog.errors.optionSchemaNotFound", "Option schema not found"),
1593
+ );
1594
+ ensureSameScope(optionTemplate, organizationId, tenantId);
1595
+ record.optionSchemaTemplate = optionTemplate;
1209
1596
  }
1210
1597
  }
1211
1598
  if (optionSchemaDefinition) {
@@ -1213,26 +1600,27 @@ const updateProductCommand: CommandHandler<ProductUpdateInput, { productId: stri
1213
1600
  em,
1214
1601
  record,
1215
1602
  optionSchemaDefinition,
1216
- optionSchemaDefinition.name ?? parsed.title ?? record.title
1217
- )
1603
+ optionSchemaDefinition.name ?? parsed.title ?? record.title,
1604
+ );
1218
1605
  }
1219
1606
  if (parsed.customFieldsetCode !== undefined) {
1220
- record.customFieldsetCode = parsed.customFieldsetCode ?? null
1607
+ record.customFieldsetCode = parsed.customFieldsetCode ?? null;
1221
1608
  }
1222
- if (parsed.isConfigurable !== undefined) record.isConfigurable = parsed.isConfigurable
1223
- if (parsed.isActive !== undefined) record.isActive = parsed.isActive
1609
+ if (parsed.isConfigurable !== undefined)
1610
+ record.isConfigurable = parsed.isConfigurable;
1611
+ if (parsed.isActive !== undefined) record.isActive = parsed.isActive;
1224
1612
  try {
1225
- await em.flush()
1613
+ await em.flush();
1226
1614
  } catch (error) {
1227
- await rethrowProductUniqueConstraint(error)
1615
+ await rethrowProductUniqueConstraint(error);
1228
1616
  }
1229
- await syncOffers(em, record, parsed.offers)
1230
- await syncCategoryAssignments(em, record, parsed.categoryIds)
1231
- await syncProductTags(em, record, parsed.tags)
1617
+ await syncOffers(em, record, parsed.offers);
1618
+ await syncCategoryAssignments(em, record, parsed.categoryIds);
1619
+ await syncProductTags(em, record, parsed.tags);
1232
1620
  try {
1233
- await em.flush()
1621
+ await em.flush();
1234
1622
  } catch (error) {
1235
- await rethrowProductUniqueConstraint(error)
1623
+ await rethrowProductUniqueConstraint(error);
1236
1624
  }
1237
1625
  if (custom && Object.keys(custom).length) {
1238
1626
  await setCustomFieldsIfAny({
@@ -1242,30 +1630,47 @@ const updateProductCommand: CommandHandler<ProductUpdateInput, { productId: stri
1242
1630
  organizationId: record.organizationId,
1243
1631
  tenantId: record.tenantId,
1244
1632
  values: custom,
1245
- })
1633
+ });
1246
1634
  }
1247
1635
  await emitProductCrudChange({
1248
1636
  dataEngine,
1249
- action: 'updated',
1637
+ action: "updated",
1250
1638
  product: record,
1251
- })
1252
- return { productId: record.id }
1639
+ });
1640
+ return { productId: record.id };
1253
1641
  },
1254
1642
  captureAfter: async (_input, result, ctx) => {
1255
- const em = (ctx.container.resolve('em') as EntityManager).fork()
1256
- return loadProductSnapshot(em, result.productId)
1643
+ const em = (ctx.container.resolve("em") as EntityManager).fork();
1644
+ return loadProductSnapshot(em, result.productId);
1257
1645
  },
1258
1646
  buildLog: async ({ snapshots }) => {
1259
- const before = snapshots.before as ProductSnapshot | undefined
1260
- const after = snapshots.after as ProductSnapshot | undefined
1261
- if (!before || !after) return null
1262
- const { translate } = await resolveTranslations()
1647
+ const before = snapshots.before as ProductSnapshot | undefined;
1648
+ const after = snapshots.after as ProductSnapshot | undefined;
1649
+ if (!before || !after) return null;
1650
+ const { translate } = await resolveTranslations();
1263
1651
  return {
1264
- actionLabel: translate('catalog.audit.products.update', 'Update catalog product'),
1265
- resourceKind: 'catalog.product',
1652
+ actionLabel: translate(
1653
+ "catalog.audit.products.update",
1654
+ "Update catalog product",
1655
+ ),
1656
+ resourceKind: "catalog.product",
1266
1657
  resourceId: before.id,
1267
1658
  tenantId: before.tenantId,
1268
1659
  organizationId: before.organizationId,
1660
+ changes: buildChanges(before, after, [
1661
+ "title",
1662
+ "sku",
1663
+ "productType",
1664
+ "defaultUnit",
1665
+ "defaultSalesUnit",
1666
+ "defaultSalesUnitQuantity",
1667
+ "uomRoundingScale",
1668
+ "uomRoundingMode",
1669
+ "unitPriceEnabled",
1670
+ "unitPriceReferenceUnit",
1671
+ "unitPriceBaseQuantity",
1672
+ "isActive",
1673
+ ]),
1269
1674
  snapshotBefore: before,
1270
1675
  snapshotAfter: after,
1271
1676
  payload: {
@@ -1274,14 +1679,14 @@ const updateProductCommand: CommandHandler<ProductUpdateInput, { productId: stri
1274
1679
  after,
1275
1680
  } satisfies ProductUndoPayload,
1276
1681
  },
1277
- }
1682
+ };
1278
1683
  },
1279
1684
  undo: async ({ logEntry, ctx }) => {
1280
- const payload = extractUndoPayload<ProductUndoPayload>(logEntry)
1281
- const before = payload?.before
1282
- if (!before) return
1283
- const em = (ctx.container.resolve('em') as EntityManager).fork()
1284
- let record = await em.findOne(CatalogProduct, { id: before.id })
1685
+ const payload = extractUndoPayload<ProductUndoPayload>(logEntry);
1686
+ const before = payload?.before;
1687
+ if (!before) return;
1688
+ const em = (ctx.container.resolve("em") as EntityManager).fork();
1689
+ let record = await findOneWithDecryption(em, CatalogProduct, { id: before.id });
1285
1690
  if (!record) {
1286
1691
  record = em.create(CatalogProduct, {
1287
1692
  id: before.id,
@@ -1297,6 +1702,13 @@ const updateProductCommand: CommandHandler<ProductUpdateInput, { productId: stri
1297
1702
  statusEntryId: before.statusEntryId ?? null,
1298
1703
  primaryCurrencyCode: before.primaryCurrencyCode ?? null,
1299
1704
  defaultUnit: before.defaultUnit ?? null,
1705
+ defaultSalesUnit: before.defaultSalesUnit ?? null,
1706
+ defaultSalesUnitQuantity: before.defaultSalesUnitQuantity ?? "1",
1707
+ uomRoundingScale: before.uomRoundingScale,
1708
+ uomRoundingMode: before.uomRoundingMode,
1709
+ unitPriceEnabled: before.unitPriceEnabled,
1710
+ unitPriceReferenceUnit: before.unitPriceReferenceUnit ?? null,
1711
+ unitPriceBaseQuantity: before.unitPriceBaseQuantity ?? null,
1300
1712
  weightValue: before.weightValue ?? null,
1301
1713
  weightUnit: before.weightUnit ?? null,
1302
1714
  dimensions: before.dimensions ? cloneJson(before.dimensions) : null,
@@ -1305,25 +1717,32 @@ const updateProductCommand: CommandHandler<ProductUpdateInput, { productId: stri
1305
1717
  optionSchemaTemplate: before.optionSchemaId
1306
1718
  ? em.getReference(CatalogOptionSchemaTemplate, before.optionSchemaId)
1307
1719
  : null,
1308
- productType: before.productType ?? 'simple',
1720
+ productType: before.productType ?? "simple",
1309
1721
  isConfigurable: before.isConfigurable,
1310
1722
  isActive: before.isActive,
1311
1723
  createdAt: new Date(before.createdAt),
1312
1724
  updatedAt: new Date(before.updatedAt),
1313
- })
1314
- em.persist(record)
1725
+ });
1726
+ em.persist(record);
1315
1727
  }
1316
- ensureTenantScope(ctx, before.tenantId)
1317
- ensureOrganizationScope(ctx, before.organizationId)
1318
- applyProductSnapshot(em, record, before)
1319
- await em.flush()
1728
+ ensureTenantScope(ctx, before.tenantId);
1729
+ ensureOrganizationScope(ctx, before.organizationId);
1730
+ applyProductSnapshot(em, record, before);
1731
+ await em.flush();
1320
1732
 
1321
- await restoreOffersFromSnapshot(em, record, before.offers)
1322
- await syncCategoryAssignments(em, record, before.categoryIds)
1323
- await syncProductTags(em, record, before.tags)
1324
- await em.flush()
1325
- const dataEngine = ctx.container.resolve('dataEngine') as DataEngine
1326
- const resetValues = buildCustomFieldResetMap(before.custom ?? undefined, payload?.after?.custom ?? undefined)
1733
+ const relationEm = em.fork();
1734
+ const relationRecord = await findOneWithDecryption(relationEm, CatalogProduct, { id: before.id });
1735
+ if (relationRecord) {
1736
+ await restoreOffersFromSnapshot(relationEm, relationRecord, before.offers);
1737
+ await syncCategoryAssignments(relationEm, relationRecord, before.categoryIds);
1738
+ await syncProductTags(relationEm, relationRecord, before.tags);
1739
+ await relationEm.flush();
1740
+ }
1741
+ const dataEngine = ctx.container.resolve("dataEngine") as DataEngine;
1742
+ const resetValues = buildCustomFieldResetMap(
1743
+ before.custom ?? undefined,
1744
+ payload?.after?.custom ?? undefined,
1745
+ );
1327
1746
  if (Object.keys(resetValues).length) {
1328
1747
  await setCustomFieldsIfAny({
1329
1748
  dataEngine,
@@ -1332,57 +1751,73 @@ const updateProductCommand: CommandHandler<ProductUpdateInput, { productId: stri
1332
1751
  organizationId: before.organizationId,
1333
1752
  tenantId: before.tenantId,
1334
1753
  values: resetValues,
1335
- })
1754
+ });
1336
1755
  }
1337
1756
  await emitProductCrudUndoChange({
1338
1757
  dataEngine,
1339
- action: 'updated',
1758
+ action: "updated",
1340
1759
  product: record,
1341
- })
1760
+ });
1342
1761
  },
1343
- }
1762
+ };
1344
1763
 
1345
1764
  const deleteProductCommand: CommandHandler<
1346
1765
  { body?: Record<string, unknown>; query?: Record<string, unknown> },
1347
1766
  { productId: string }
1348
1767
  > = {
1349
- id: 'catalog.products.delete',
1768
+ id: "catalog.products.delete",
1350
1769
  async prepare(input, ctx) {
1351
- const id = requireId(input, 'Product id is required')
1352
- const em = (ctx.container.resolve('em') as EntityManager)
1353
- const snapshot = await loadProductSnapshot(em, id)
1770
+ const id = requireId(input, "Product id is required");
1771
+ const em = ctx.container.resolve("em") as EntityManager;
1772
+ const snapshot = await loadProductSnapshot(em, id);
1354
1773
  if (snapshot) {
1355
- ensureTenantScope(ctx, snapshot.tenantId)
1356
- ensureOrganizationScope(ctx, snapshot.organizationId)
1774
+ ensureTenantScope(ctx, snapshot.tenantId);
1775
+ ensureOrganizationScope(ctx, snapshot.organizationId);
1357
1776
  }
1358
- return snapshot ? { before: snapshot } : {}
1777
+ return snapshot ? { before: snapshot } : {};
1359
1778
  },
1360
1779
  async execute(input, ctx) {
1361
- const id = requireId(input, 'Product id is required')
1362
- const em = (ctx.container.resolve('em') as EntityManager).fork()
1780
+ const id = requireId(input, "Product id is required");
1781
+ const em = (ctx.container.resolve("em") as EntityManager).fork();
1363
1782
  const record = await findOneWithDecryption(
1364
1783
  em,
1365
1784
  CatalogProduct,
1366
1785
  { id },
1367
- { populate: ['optionSchemaTemplate'] },
1368
- )
1369
- if (!record) throw new CrudHttpError(404, { error: 'Catalog product not found' })
1370
- const baseEm = (ctx.container.resolve('em') as EntityManager)
1371
- const snapshot = await loadProductSnapshot(baseEm, id)
1372
- ensureTenantScope(ctx, record.tenantId)
1373
- ensureOrganizationScope(ctx, record.organizationId)
1374
- const dataEngine = ctx.container.resolve('dataEngine') as DataEngine
1375
- await deleteProductVariantsAndRelatedData({ em, product: record, dataEngine, ctx })
1376
- await em.nativeDelete(CatalogProductPrice, { product: record.id })
1377
- const templateToRemove = await resolveOptionSchemaTemplateForRemoval(em, record)
1786
+ { populate: ["optionSchemaTemplate"] },
1787
+ );
1788
+ if (!record) {
1789
+ const { translate } = await resolveTranslations();
1790
+ throw new CrudHttpError(404, {
1791
+ error: translate(
1792
+ "catalog.products.errors.notFound",
1793
+ "Catalog product not found",
1794
+ ),
1795
+ });
1796
+ }
1797
+ const baseEm = ctx.container.resolve("em") as EntityManager;
1798
+ const snapshot = await loadProductSnapshot(baseEm, id);
1799
+ ensureTenantScope(ctx, record.tenantId);
1800
+ ensureOrganizationScope(ctx, record.organizationId);
1801
+ const dataEngine = ctx.container.resolve("dataEngine") as DataEngine;
1802
+ await deleteProductVariantsAndRelatedData({
1803
+ em,
1804
+ product: record,
1805
+ dataEngine,
1806
+ ctx,
1807
+ });
1808
+ await em.nativeDelete(CatalogProductPrice, { product: record.id });
1809
+ const templateToRemove = await resolveOptionSchemaTemplateForRemoval(
1810
+ em,
1811
+ record,
1812
+ );
1378
1813
  if (templateToRemove) {
1379
- record.optionSchemaTemplate = null
1380
- em.remove(templateToRemove)
1814
+ record.optionSchemaTemplate = null;
1815
+ em.remove(templateToRemove);
1381
1816
  }
1382
- em.remove(record)
1383
- await em.flush()
1817
+ em.remove(record);
1818
+ await em.flush();
1384
1819
  if (snapshot?.custom && Object.keys(snapshot.custom).length) {
1385
- const resetValues = buildCustomFieldResetMap(snapshot.custom, undefined)
1820
+ const resetValues = buildCustomFieldResetMap(snapshot.custom, undefined);
1386
1821
  if (Object.keys(resetValues).length) {
1387
1822
  await setCustomFieldsIfAny({
1388
1823
  dataEngine,
@@ -1391,23 +1826,26 @@ const deleteProductCommand: CommandHandler<
1391
1826
  organizationId: record.organizationId,
1392
1827
  tenantId: record.tenantId,
1393
1828
  values: resetValues,
1394
- })
1829
+ });
1395
1830
  }
1396
1831
  }
1397
1832
  await emitProductCrudChange({
1398
1833
  dataEngine,
1399
- action: 'deleted',
1834
+ action: "deleted",
1400
1835
  product: record,
1401
- })
1402
- return { productId: id }
1836
+ });
1837
+ return { productId: id };
1403
1838
  },
1404
1839
  buildLog: async ({ snapshots }) => {
1405
- const before = snapshots.before as ProductSnapshot | undefined
1406
- if (!before) return null
1407
- const { translate } = await resolveTranslations()
1840
+ const before = snapshots.before as ProductSnapshot | undefined;
1841
+ if (!before) return null;
1842
+ const { translate } = await resolveTranslations();
1408
1843
  return {
1409
- actionLabel: translate('catalog.audit.products.delete', 'Delete catalog product'),
1410
- resourceKind: 'catalog.product',
1844
+ actionLabel: translate(
1845
+ "catalog.audit.products.delete",
1846
+ "Delete catalog product",
1847
+ ),
1848
+ resourceKind: "catalog.product",
1411
1849
  resourceId: before.id,
1412
1850
  tenantId: before.tenantId,
1413
1851
  organizationId: before.organizationId,
@@ -1417,14 +1855,14 @@ const deleteProductCommand: CommandHandler<
1417
1855
  before,
1418
1856
  } satisfies ProductUndoPayload,
1419
1857
  },
1420
- }
1858
+ };
1421
1859
  },
1422
1860
  undo: async ({ logEntry, ctx }) => {
1423
- const payload = extractUndoPayload<ProductUndoPayload>(logEntry)
1424
- const before = payload?.before
1425
- if (!before) return
1426
- const em = (ctx.container.resolve('em') as EntityManager).fork()
1427
- let record = await em.findOne(CatalogProduct, { id: before.id })
1861
+ const payload = extractUndoPayload<ProductUndoPayload>(logEntry);
1862
+ const before = payload?.before;
1863
+ if (!before) return;
1864
+ const em = (ctx.container.resolve("em") as EntityManager).fork();
1865
+ let record = await findOneWithDecryption(em, CatalogProduct, { id: before.id });
1428
1866
  if (!record) {
1429
1867
  record = em.create(CatalogProduct, {
1430
1868
  id: before.id,
@@ -1440,6 +1878,13 @@ const deleteProductCommand: CommandHandler<
1440
1878
  statusEntryId: before.statusEntryId ?? null,
1441
1879
  primaryCurrencyCode: before.primaryCurrencyCode ?? null,
1442
1880
  defaultUnit: before.defaultUnit ?? null,
1881
+ defaultSalesUnit: before.defaultSalesUnit ?? null,
1882
+ defaultSalesUnitQuantity: before.defaultSalesUnitQuantity ?? "1",
1883
+ uomRoundingScale: before.uomRoundingScale,
1884
+ uomRoundingMode: before.uomRoundingMode,
1885
+ unitPriceEnabled: before.unitPriceEnabled,
1886
+ unitPriceReferenceUnit: before.unitPriceReferenceUnit ?? null,
1887
+ unitPriceBaseQuantity: before.unitPriceBaseQuantity ?? null,
1443
1888
  weightValue: before.weightValue ?? null,
1444
1889
  weightUnit: before.weightUnit ?? null,
1445
1890
  dimensions: before.dimensions ? cloneJson(before.dimensions) : null,
@@ -1448,22 +1893,28 @@ const deleteProductCommand: CommandHandler<
1448
1893
  optionSchemaTemplate: before.optionSchemaId
1449
1894
  ? em.getReference(CatalogOptionSchemaTemplate, before.optionSchemaId)
1450
1895
  : null,
1451
- productType: before.productType ?? 'simple',
1896
+ productType: before.productType ?? "simple",
1452
1897
  isConfigurable: before.isConfigurable,
1453
1898
  isActive: before.isActive,
1454
1899
  createdAt: new Date(before.createdAt),
1455
1900
  updatedAt: new Date(before.updatedAt),
1456
- })
1457
- em.persist(record)
1901
+ });
1902
+ em.persist(record);
1903
+ }
1904
+ ensureTenantScope(ctx, before.tenantId);
1905
+ ensureOrganizationScope(ctx, before.organizationId);
1906
+ applyProductSnapshot(em, record, before);
1907
+ await em.flush();
1908
+
1909
+ const relationEm = em.fork();
1910
+ const relationRecord = await findOneWithDecryption(relationEm, CatalogProduct, { id: before.id });
1911
+ if (relationRecord) {
1912
+ await restoreOffersFromSnapshot(relationEm, relationRecord, before.offers);
1913
+ await syncCategoryAssignments(relationEm, relationRecord, before.categoryIds);
1914
+ await syncProductTags(relationEm, relationRecord, before.tags);
1915
+ await relationEm.flush();
1458
1916
  }
1459
- ensureTenantScope(ctx, before.tenantId)
1460
- ensureOrganizationScope(ctx, before.organizationId)
1461
- applyProductSnapshot(em, record, before)
1462
- await restoreOffersFromSnapshot(em, record, before.offers)
1463
- await syncCategoryAssignments(em, record, before.categoryIds)
1464
- await syncProductTags(em, record, before.tags)
1465
- await em.flush()
1466
- const dataEngine = ctx.container.resolve('dataEngine') as DataEngine
1917
+ const dataEngine = ctx.container.resolve("dataEngine") as DataEngine;
1467
1918
  if (before.custom && Object.keys(before.custom).length) {
1468
1919
  await setCustomFieldsIfAny({
1469
1920
  dataEngine,
@@ -1472,69 +1923,76 @@ const deleteProductCommand: CommandHandler<
1472
1923
  organizationId: before.organizationId,
1473
1924
  tenantId: before.tenantId,
1474
1925
  values: before.custom,
1475
- })
1926
+ });
1476
1927
  }
1477
1928
  await emitProductCrudUndoChange({
1478
1929
  dataEngine,
1479
- action: 'created',
1930
+ action: "created",
1480
1931
  product: record,
1481
- })
1932
+ });
1482
1933
  },
1483
- }
1934
+ };
1484
1935
 
1485
- registerCommand(createProductCommand)
1486
- registerCommand(updateProductCommand)
1487
- registerCommand(deleteProductCommand)
1936
+ registerCommand(createProductCommand);
1937
+ registerCommand(updateProductCommand);
1938
+ registerCommand(deleteProductCommand);
1488
1939
 
1489
- function resolveProductUniqueConstraint(error: unknown): 'handle' | 'sku' | null {
1490
- if (!(error instanceof UniqueConstraintViolationException)) return null
1491
- const constraint = typeof (error as { constraint?: string }).constraint === 'string'
1492
- ? (error as { constraint?: string }).constraint
1493
- : null
1494
- if (constraint === 'catalog_products_handle_scope_unique') return 'handle'
1495
- if (constraint === 'catalog_products_sku_scope_unique') return 'sku'
1496
- const message = typeof (error as { message?: string }).message === 'string'
1497
- ? (error as { message?: string }).message
1498
- : ''
1499
- const normalized = message ? message.toLowerCase() : ''
1940
+ function resolveProductUniqueConstraint(
1941
+ error: unknown,
1942
+ ): "handle" | "sku" | null {
1943
+ if (!(error instanceof UniqueConstraintViolationException)) return null;
1944
+ const constraint = getErrorConstraint(error);
1945
+ if (constraint === "catalog_products_handle_scope_unique") return "handle";
1946
+ if (constraint === "catalog_products_sku_scope_unique") return "sku";
1947
+ const message = getErrorMessage(error).toLowerCase();
1500
1948
  if (
1501
- normalized.includes('catalog_products_handle_scope_unique') ||
1502
- normalized.includes(' handle')
1949
+ message.includes("catalog_products_handle_scope_unique") ||
1950
+ message.includes(" handle")
1503
1951
  ) {
1504
- return 'handle'
1952
+ return "handle";
1505
1953
  }
1506
1954
  if (
1507
- normalized.includes('catalog_products_sku_scope_unique') ||
1508
- normalized.includes(' sku')
1955
+ message.includes("catalog_products_sku_scope_unique") ||
1956
+ message.includes(" sku")
1509
1957
  ) {
1510
- return 'sku'
1958
+ return "sku";
1511
1959
  }
1512
- return null
1960
+ return null;
1513
1961
  }
1514
1962
 
1515
1963
  async function rethrowProductUniqueConstraint(error: unknown): Promise<never> {
1516
- const target = resolveProductUniqueConstraint(error)
1517
- if (target === 'handle') await throwDuplicateHandleError()
1518
- if (target === 'sku') await throwDuplicateSkuError()
1519
- throw error
1964
+ const target = resolveProductUniqueConstraint(error);
1965
+ if (target === "handle") await throwDuplicateHandleError();
1966
+ if (target === "sku") await throwDuplicateSkuError();
1967
+ throw error;
1520
1968
  }
1521
1969
 
1522
1970
  async function throwDuplicateHandleError(): Promise<never> {
1523
- const { translate } = await resolveTranslations()
1524
- const message = translate('catalog.products.errors.handleExists', 'Handle already in use.')
1971
+ const { translate } = await resolveTranslations();
1972
+ const message = translate(
1973
+ "catalog.products.errors.handleExists",
1974
+ "Handle already in use.",
1975
+ );
1525
1976
  throw new CrudHttpError(400, {
1526
1977
  error: message,
1527
1978
  fieldErrors: { handle: message },
1528
- details: [{ path: ['handle'], message, code: 'duplicate', origin: 'validation' }],
1529
- })
1979
+ details: [
1980
+ { path: ["handle"], message, code: "duplicate", origin: "validation" },
1981
+ ],
1982
+ });
1530
1983
  }
1531
1984
 
1532
1985
  async function throwDuplicateSkuError(): Promise<never> {
1533
- const { translate } = await resolveTranslations()
1534
- const message = translate('catalog.products.errors.skuExists', 'SKU already in use.')
1986
+ const { translate } = await resolveTranslations();
1987
+ const message = translate(
1988
+ "catalog.products.errors.skuExists",
1989
+ "SKU already in use.",
1990
+ );
1535
1991
  throw new CrudHttpError(400, {
1536
1992
  error: message,
1537
1993
  fieldErrors: { sku: message },
1538
- details: [{ path: ['sku'], message, code: 'duplicate', origin: 'validation' }],
1539
- })
1994
+ details: [
1995
+ { path: ["sku"], message, code: "duplicate", origin: "validation" },
1996
+ ],
1997
+ });
1540
1998
  }