@open-mercato/core 0.4.5-develop-3ce83a8b24 → 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
@@ -5,3 +5,4 @@ import './optionSchemas'
5
5
  import './priceKinds'
6
6
  import './offers'
7
7
  import './categories'
8
+ import './productUnitConversions'
@@ -0,0 +1,626 @@
1
+ import { registerCommand } from "@open-mercato/shared/lib/commands";
2
+ import type { CommandHandler } from "@open-mercato/shared/lib/commands";
3
+ import {
4
+ buildChanges,
5
+ requireId,
6
+ emitCrudSideEffects,
7
+ emitCrudUndoSideEffects,
8
+ } from "@open-mercato/shared/lib/commands/helpers";
9
+ import type {
10
+ CrudEventAction,
11
+ CrudEventsConfig,
12
+ CrudIndexerConfig,
13
+ } from "@open-mercato/shared/lib/crud/types";
14
+ import type { EntityManager } from "@mikro-orm/postgresql";
15
+ import { UniqueConstraintViolationException } from "@mikro-orm/core";
16
+ import { CrudHttpError } from "@open-mercato/shared/lib/crud/errors";
17
+ import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
18
+ import type { DataEngine } from "@open-mercato/shared/lib/data/engine";
19
+ import { E } from "#generated/entities.ids.generated";
20
+ import {
21
+ findOneWithDecryption,
22
+ } from "@open-mercato/shared/lib/encryption/find";
23
+ import { CatalogProduct, CatalogProductUnitConversion } from "../data/entities";
24
+ import {
25
+ productUnitConversionCreateSchema,
26
+ productUnitConversionUpdateSchema,
27
+ productUnitConversionDeleteSchema,
28
+ type ProductUnitConversionCreateInput,
29
+ type ProductUnitConversionUpdateInput,
30
+ type ProductUnitConversionDeleteInput,
31
+ } from "../data/validators";
32
+ import {
33
+ ensureOrganizationScope,
34
+ ensureSameScope,
35
+ cloneJson,
36
+ ensureTenantScope,
37
+ extractUndoPayload,
38
+ requireProduct,
39
+ toNumericString,
40
+ getErrorConstraint,
41
+ getErrorMessage,
42
+ } from "./shared";
43
+ import { toUnitLookupKey } from "../lib/unitCodes";
44
+ import { resolveCanonicalUnitCode } from "../lib/unitResolution";
45
+
46
+ type ProductUnitConversionSnapshot = {
47
+ id: string;
48
+ productId: string;
49
+ organizationId: string;
50
+ tenantId: string;
51
+ unitCode: string;
52
+ toBaseFactor: string;
53
+ sortOrder: number;
54
+ isActive: boolean;
55
+ metadata: Record<string, unknown> | null;
56
+ createdAt: string;
57
+ updatedAt: string;
58
+ };
59
+
60
+ type ProductUnitConversionUndoPayload = {
61
+ before?: ProductUnitConversionSnapshot | null;
62
+ after?: ProductUnitConversionSnapshot | null;
63
+ };
64
+
65
+ const conversionCrudEvents: CrudEventsConfig<CatalogProductUnitConversion> = {
66
+ module: "catalog",
67
+ entity: "product_unit_conversion",
68
+ persistent: true,
69
+ buildPayload: (ctx) => ({
70
+ id: ctx.identifiers.id,
71
+ productId:
72
+ ctx.entity.product && typeof ctx.entity.product !== "string"
73
+ ? ctx.entity.product.id
74
+ : null,
75
+ unitCode: ctx.entity.unitCode,
76
+ tenantId: ctx.identifiers.tenantId,
77
+ organizationId: ctx.identifiers.organizationId,
78
+ }),
79
+ };
80
+
81
+ const conversionCrudIndexer: CrudIndexerConfig<CatalogProductUnitConversion> = {
82
+ entityType: E.catalog.catalog_product_unit_conversion,
83
+ buildUpsertPayload: (ctx) => ({
84
+ entityType: E.catalog.catalog_product_unit_conversion,
85
+ recordId: ctx.identifiers.id,
86
+ tenantId: ctx.identifiers.tenantId,
87
+ organizationId: ctx.identifiers.organizationId,
88
+ }),
89
+ buildDeletePayload: (ctx) => ({
90
+ entityType: E.catalog.catalog_product_unit_conversion,
91
+ recordId: ctx.identifiers.id,
92
+ tenantId: ctx.identifiers.tenantId,
93
+ organizationId: ctx.identifiers.organizationId,
94
+ }),
95
+ };
96
+
97
+ function buildIdentifiers(record: CatalogProductUnitConversion) {
98
+ return {
99
+ id: record.id,
100
+ organizationId: record.organizationId,
101
+ tenantId: record.tenantId,
102
+ };
103
+ }
104
+
105
+ async function emitConversionCrudChange(opts: {
106
+ dataEngine: DataEngine;
107
+ action: CrudEventAction;
108
+ conversion: CatalogProductUnitConversion;
109
+ }) {
110
+ const { dataEngine, action, conversion } = opts;
111
+ await emitCrudSideEffects({
112
+ dataEngine,
113
+ action,
114
+ entity: conversion,
115
+ identifiers: buildIdentifiers(conversion),
116
+ events: conversionCrudEvents,
117
+ indexer: conversionCrudIndexer,
118
+ });
119
+ }
120
+
121
+ async function emitConversionCrudUndoChange(opts: {
122
+ dataEngine: DataEngine;
123
+ action: CrudEventAction;
124
+ conversion: CatalogProductUnitConversion;
125
+ }) {
126
+ const { dataEngine, action, conversion } = opts;
127
+ await emitCrudUndoSideEffects({
128
+ dataEngine,
129
+ action,
130
+ entity: conversion,
131
+ identifiers: buildIdentifiers(conversion),
132
+ events: conversionCrudEvents,
133
+ indexer: conversionCrudIndexer,
134
+ });
135
+ }
136
+
137
+ async function loadConversionSnapshot(
138
+ em: EntityManager,
139
+ id: string,
140
+ ): Promise<ProductUnitConversionSnapshot | null> {
141
+ const record = await findOneWithDecryption(
142
+ em,
143
+ CatalogProductUnitConversion,
144
+ { id, deletedAt: null },
145
+ { populate: ["product"] },
146
+ );
147
+ if (!record) return null;
148
+ const productId =
149
+ typeof record.product === "string"
150
+ ? record.product
151
+ : (record.product?.id ?? null);
152
+ if (!productId) return null;
153
+ return {
154
+ id: record.id,
155
+ productId,
156
+ organizationId: record.organizationId,
157
+ tenantId: record.tenantId,
158
+ unitCode: record.unitCode,
159
+ toBaseFactor: record.toBaseFactor,
160
+ sortOrder: record.sortOrder,
161
+ isActive: record.isActive,
162
+ metadata: record.metadata
163
+ ? cloneJson(record.metadata)
164
+ : null,
165
+ createdAt: record.createdAt.toISOString(),
166
+ updatedAt: record.updatedAt.toISOString(),
167
+ };
168
+ }
169
+
170
+ function applyConversionSnapshot(
171
+ em: EntityManager,
172
+ record: CatalogProductUnitConversion,
173
+ snapshot: ProductUnitConversionSnapshot,
174
+ ): void {
175
+ record.organizationId = snapshot.organizationId;
176
+ record.tenantId = snapshot.tenantId;
177
+ record.product = em.getReference(CatalogProduct, snapshot.productId);
178
+ record.unitCode = snapshot.unitCode;
179
+ record.toBaseFactor = snapshot.toBaseFactor;
180
+ record.sortOrder = snapshot.sortOrder;
181
+ record.isActive = snapshot.isActive;
182
+ record.metadata = snapshot.metadata
183
+ ? cloneJson(snapshot.metadata)
184
+ : null;
185
+ record.createdAt = new Date(snapshot.createdAt);
186
+ record.updatedAt = new Date(snapshot.updatedAt);
187
+ }
188
+
189
+ async function ensureDefaultSalesUnitIsNotRemoved(
190
+ em: EntityManager,
191
+ record: CatalogProductUnitConversion,
192
+ nextIsActive: boolean,
193
+ ): Promise<void> {
194
+ if (nextIsActive) return;
195
+ const product =
196
+ typeof record.product === "string"
197
+ ? await findOneWithDecryption(em, CatalogProduct, {
198
+ id: record.product,
199
+ deletedAt: null,
200
+ })
201
+ : record.product;
202
+ if (!product) return;
203
+ const defaultSalesUnitKey = toUnitLookupKey(product.defaultSalesUnit);
204
+ const conversionUnitKey = toUnitLookupKey(record.unitCode);
205
+ if (
206
+ defaultSalesUnitKey &&
207
+ conversionUnitKey &&
208
+ defaultSalesUnitKey === conversionUnitKey
209
+ ) {
210
+ throw new CrudHttpError(409, {
211
+ error: "uom.default_sales_unit_conversion_required",
212
+ });
213
+ }
214
+ }
215
+
216
+ function resolveConversionUniqueConstraint(error: unknown): boolean {
217
+ if (error instanceof UniqueConstraintViolationException) {
218
+ const constraint = getErrorConstraint(error);
219
+ if (constraint === "catalog_product_unit_conversions_unique") return true;
220
+ const message = getErrorMessage(error);
221
+ return message.toLowerCase().includes("catalog_product_unit_conversions_unique");
222
+ }
223
+ return false;
224
+ }
225
+
226
+ function rethrowConversionUniqueConstraint(
227
+ error: unknown,
228
+ ): never {
229
+ if (resolveConversionUniqueConstraint(error)) {
230
+ throw new CrudHttpError(409, { error: "uom.duplicate_conversion" });
231
+ }
232
+ throw error;
233
+ }
234
+
235
+ const createProductUnitConversionCommand: CommandHandler<
236
+ ProductUnitConversionCreateInput,
237
+ { conversionId: string }
238
+ > = {
239
+ id: "catalog.product-unit-conversions.create",
240
+ async execute(rawInput, ctx) {
241
+ const parsed = productUnitConversionCreateSchema.parse(rawInput);
242
+ ensureTenantScope(ctx, parsed.tenantId);
243
+ ensureOrganizationScope(ctx, parsed.organizationId);
244
+
245
+ const em = (ctx.container.resolve("em") as EntityManager).fork();
246
+ const { translate } = await resolveTranslations();
247
+ const product = await requireProduct(
248
+ em,
249
+ parsed.productId,
250
+ translate("catalog.errors.productNotFound", "Catalog product not found"),
251
+ );
252
+ ensureSameScope(product, parsed.organizationId, parsed.tenantId);
253
+ const canonicalUnitCode = await resolveCanonicalUnitCode(em, {
254
+ organizationId: parsed.organizationId,
255
+ tenantId: parsed.tenantId,
256
+ unitCode: parsed.unitCode,
257
+ });
258
+
259
+ const toBaseFactorStr = toNumericString(parsed.toBaseFactor);
260
+ if (!toBaseFactorStr) {
261
+ throw new CrudHttpError(400, {
262
+ error: "uom.conversion_factor_required",
263
+ });
264
+ }
265
+
266
+ const conversion = em.create(CatalogProductUnitConversion, {
267
+ product,
268
+ organizationId: parsed.organizationId,
269
+ tenantId: parsed.tenantId,
270
+ unitCode: canonicalUnitCode,
271
+ toBaseFactor: toBaseFactorStr,
272
+ sortOrder: parsed.sortOrder ?? 0,
273
+ isActive: parsed.isActive !== false,
274
+ metadata: parsed.metadata
275
+ ? cloneJson(parsed.metadata)
276
+ : null,
277
+ createdAt: new Date(),
278
+ updatedAt: new Date(),
279
+ });
280
+ em.persist(conversion);
281
+ try {
282
+ await em.flush();
283
+ } catch (error) {
284
+ rethrowConversionUniqueConstraint(error);
285
+ }
286
+
287
+ const dataEngine = ctx.container.resolve("dataEngine") as DataEngine;
288
+ await emitConversionCrudChange({
289
+ dataEngine,
290
+ action: "created",
291
+ conversion,
292
+ });
293
+ return { conversionId: conversion.id };
294
+ },
295
+ captureAfter: async (_input, result, ctx) => {
296
+ const em = (ctx.container.resolve("em") as EntityManager).fork();
297
+ return loadConversionSnapshot(em, result.conversionId);
298
+ },
299
+ buildLog: async ({ snapshots }) => {
300
+ const after = snapshots.after as ProductUnitConversionSnapshot | undefined;
301
+ if (!after) return null;
302
+ const { translate } = await resolveTranslations();
303
+ return {
304
+ actionLabel: translate(
305
+ "catalog.audit.productUnitConversions.create",
306
+ "Create product unit conversion",
307
+ ),
308
+ resourceKind: "catalog.product_unit_conversion",
309
+ resourceId: after.id,
310
+ tenantId: after.tenantId,
311
+ organizationId: after.organizationId,
312
+ snapshotAfter: after,
313
+ payload: {
314
+ undo: {
315
+ after,
316
+ } satisfies ProductUnitConversionUndoPayload,
317
+ },
318
+ };
319
+ },
320
+ undo: async ({ logEntry, ctx }) => {
321
+ const payload =
322
+ extractUndoPayload<ProductUnitConversionUndoPayload>(logEntry);
323
+ const after = payload?.after;
324
+ if (!after) return;
325
+ const em = (ctx.container.resolve("em") as EntityManager).fork();
326
+ const record = await findOneWithDecryption(
327
+ em,
328
+ CatalogProductUnitConversion,
329
+ { id: after.id, deletedAt: null },
330
+ );
331
+ if (!record) return;
332
+ ensureTenantScope(ctx, record.tenantId);
333
+ ensureOrganizationScope(ctx, record.organizationId);
334
+ em.remove(record);
335
+ await em.flush();
336
+ const dataEngine = ctx.container.resolve("dataEngine") as DataEngine;
337
+ await emitConversionCrudUndoChange({
338
+ dataEngine,
339
+ action: "deleted",
340
+ conversion: record,
341
+ });
342
+ },
343
+ };
344
+
345
+ const updateProductUnitConversionCommand: CommandHandler<
346
+ ProductUnitConversionUpdateInput,
347
+ { conversionId: string }
348
+ > = {
349
+ id: "catalog.product-unit-conversions.update",
350
+ async prepare(input, ctx) {
351
+ const id = requireId(input, "Product unit conversion id is required");
352
+ const em = ctx.container.resolve("em") as EntityManager;
353
+ const snapshot = await loadConversionSnapshot(em, id);
354
+ if (snapshot) {
355
+ ensureTenantScope(ctx, snapshot.tenantId);
356
+ ensureOrganizationScope(ctx, snapshot.organizationId);
357
+ }
358
+ return snapshot ? { before: snapshot } : {};
359
+ },
360
+ async execute(rawInput, ctx) {
361
+ const parsed = productUnitConversionUpdateSchema.parse(rawInput);
362
+ const em = (ctx.container.resolve("em") as EntityManager).fork();
363
+ const { translate } = await resolveTranslations();
364
+ const record = await findOneWithDecryption(
365
+ em,
366
+ CatalogProductUnitConversion,
367
+ { id: parsed.id, deletedAt: null },
368
+ { populate: ["product"] },
369
+ );
370
+ if (!record)
371
+ throw new CrudHttpError(404, {
372
+ error: translate("catalog.errors.conversionNotFound", "Catalog product unit conversion not found"),
373
+ });
374
+ const product =
375
+ typeof record.product === "string"
376
+ ? await requireProduct(em, record.product, translate("catalog.errors.productNotFound", "Catalog product not found"))
377
+ : record.product;
378
+ ensureTenantScope(ctx, record.tenantId);
379
+ ensureOrganizationScope(ctx, record.organizationId);
380
+ ensureSameScope(product, record.organizationId, record.tenantId);
381
+
382
+ // Resolve all query-dependent values BEFORE applying scalar mutations
383
+ const resolvedUnitCode =
384
+ parsed.unitCode !== undefined
385
+ ? await resolveCanonicalUnitCode(em, {
386
+ organizationId: record.organizationId,
387
+ tenantId: record.tenantId,
388
+ unitCode: parsed.unitCode,
389
+ })
390
+ : undefined;
391
+ if (parsed.isActive !== undefined) {
392
+ await ensureDefaultSalesUnitIsNotRemoved(em, record, parsed.isActive);
393
+ }
394
+
395
+ // Apply all scalar mutations after queries are complete
396
+ if (resolvedUnitCode !== undefined) {
397
+ record.unitCode = resolvedUnitCode;
398
+ }
399
+ if (parsed.toBaseFactor !== undefined) {
400
+ record.toBaseFactor =
401
+ toNumericString(parsed.toBaseFactor) ?? record.toBaseFactor;
402
+ }
403
+ if (parsed.sortOrder !== undefined) {
404
+ record.sortOrder = parsed.sortOrder;
405
+ }
406
+ if (parsed.isActive !== undefined) {
407
+ record.isActive = parsed.isActive;
408
+ }
409
+ if (parsed.metadata !== undefined) {
410
+ record.metadata = parsed.metadata
411
+ ? cloneJson(parsed.metadata)
412
+ : null;
413
+ }
414
+ try {
415
+ await em.flush();
416
+ } catch (error) {
417
+ rethrowConversionUniqueConstraint(error);
418
+ }
419
+
420
+ const dataEngine = ctx.container.resolve("dataEngine") as DataEngine;
421
+ await emitConversionCrudChange({
422
+ dataEngine,
423
+ action: "updated",
424
+ conversion: record,
425
+ });
426
+ return { conversionId: record.id };
427
+ },
428
+ captureAfter: async (_input, result, ctx) => {
429
+ const em = (ctx.container.resolve("em") as EntityManager).fork();
430
+ return loadConversionSnapshot(em, result.conversionId);
431
+ },
432
+ buildLog: async ({ snapshots }) => {
433
+ const before = snapshots.before as
434
+ | ProductUnitConversionSnapshot
435
+ | undefined;
436
+ const after = snapshots.after as ProductUnitConversionSnapshot | undefined;
437
+ if (!before || !after) return null;
438
+ const { translate } = await resolveTranslations();
439
+ return {
440
+ actionLabel: translate(
441
+ "catalog.audit.productUnitConversions.update",
442
+ "Update product unit conversion",
443
+ ),
444
+ resourceKind: "catalog.product_unit_conversion",
445
+ resourceId: before.id,
446
+ tenantId: before.tenantId,
447
+ organizationId: before.organizationId,
448
+ changes: buildChanges(before, after, [
449
+ "unitCode",
450
+ "toBaseFactor",
451
+ "sortOrder",
452
+ "isActive",
453
+ "metadata",
454
+ ]),
455
+ snapshotBefore: before,
456
+ snapshotAfter: after,
457
+ payload: {
458
+ undo: {
459
+ before,
460
+ after,
461
+ } satisfies ProductUnitConversionUndoPayload,
462
+ },
463
+ };
464
+ },
465
+ undo: async ({ logEntry, ctx }) => {
466
+ const payload =
467
+ extractUndoPayload<ProductUnitConversionUndoPayload>(logEntry);
468
+ const before = payload?.before;
469
+ if (!before) return;
470
+ const em = (ctx.container.resolve("em") as EntityManager).fork();
471
+ let record = await findOneWithDecryption(
472
+ em,
473
+ CatalogProductUnitConversion,
474
+ { id: before.id, deletedAt: null },
475
+ );
476
+ let undoAction: "updated" | "created" = "updated";
477
+ if (!record) {
478
+ record = em.create(CatalogProductUnitConversion, {
479
+ id: before.id,
480
+ organizationId: before.organizationId,
481
+ tenantId: before.tenantId,
482
+ product: em.getReference(CatalogProduct, before.productId),
483
+ unitCode: before.unitCode,
484
+ toBaseFactor: before.toBaseFactor,
485
+ sortOrder: before.sortOrder,
486
+ isActive: before.isActive,
487
+ metadata: before.metadata
488
+ ? cloneJson(before.metadata)
489
+ : null,
490
+ createdAt: new Date(before.createdAt),
491
+ updatedAt: new Date(before.updatedAt),
492
+ });
493
+ em.persist(record);
494
+ undoAction = "created";
495
+ }
496
+ ensureTenantScope(ctx, before.tenantId);
497
+ ensureOrganizationScope(ctx, before.organizationId);
498
+ applyConversionSnapshot(em, record, before);
499
+ await em.transactional(async () => {
500
+ await em.flush();
501
+ });
502
+ const dataEngine = ctx.container.resolve("dataEngine") as DataEngine;
503
+ await emitConversionCrudUndoChange({
504
+ dataEngine,
505
+ action: undoAction,
506
+ conversion: record,
507
+ });
508
+ },
509
+ };
510
+
511
+ const deleteProductUnitConversionCommand: CommandHandler<
512
+ ProductUnitConversionDeleteInput,
513
+ { conversionId: string }
514
+ > = {
515
+ id: "catalog.product-unit-conversions.delete",
516
+ async prepare(input, ctx) {
517
+ const id = requireId(input, "Product unit conversion id is required");
518
+ const em = ctx.container.resolve("em") as EntityManager;
519
+ const snapshot = await loadConversionSnapshot(em, id);
520
+ if (snapshot) {
521
+ ensureTenantScope(ctx, snapshot.tenantId);
522
+ ensureOrganizationScope(ctx, snapshot.organizationId);
523
+ }
524
+ return snapshot ? { before: snapshot } : {};
525
+ },
526
+ async execute(rawInput, ctx) {
527
+ const parsed = productUnitConversionDeleteSchema.parse(rawInput);
528
+ const em = (ctx.container.resolve("em") as EntityManager).fork();
529
+ const { translate } = await resolveTranslations();
530
+ const record = await findOneWithDecryption(
531
+ em,
532
+ CatalogProductUnitConversion,
533
+ { id: parsed.id, deletedAt: null },
534
+ { populate: ["product"] },
535
+ );
536
+ if (!record)
537
+ throw new CrudHttpError(404, {
538
+ error: translate("catalog.errors.conversionNotFound", "Catalog product unit conversion not found"),
539
+ });
540
+ ensureTenantScope(ctx, record.tenantId);
541
+ ensureOrganizationScope(ctx, record.organizationId);
542
+ await ensureDefaultSalesUnitIsNotRemoved(em, record, false);
543
+
544
+ em.remove(record);
545
+ await em.transactional(async () => {
546
+ await em.flush();
547
+ });
548
+
549
+ const dataEngine = ctx.container.resolve("dataEngine") as DataEngine;
550
+ await emitConversionCrudChange({
551
+ dataEngine,
552
+ action: "deleted",
553
+ conversion: record,
554
+ });
555
+ return { conversionId: parsed.id };
556
+ },
557
+ buildLog: async ({ snapshots }) => {
558
+ const before = snapshots.before as
559
+ | ProductUnitConversionSnapshot
560
+ | undefined;
561
+ if (!before) return null;
562
+ const { translate } = await resolveTranslations();
563
+ return {
564
+ actionLabel: translate(
565
+ "catalog.audit.productUnitConversions.delete",
566
+ "Delete product unit conversion",
567
+ ),
568
+ resourceKind: "catalog.product_unit_conversion",
569
+ resourceId: before.id,
570
+ tenantId: before.tenantId,
571
+ organizationId: before.organizationId,
572
+ snapshotBefore: before,
573
+ payload: {
574
+ undo: {
575
+ before,
576
+ } satisfies ProductUnitConversionUndoPayload,
577
+ },
578
+ };
579
+ },
580
+ undo: async ({ logEntry, ctx }) => {
581
+ const payload =
582
+ extractUndoPayload<ProductUnitConversionUndoPayload>(logEntry);
583
+ const before = payload?.before;
584
+ if (!before) return;
585
+ const em = (ctx.container.resolve("em") as EntityManager).fork();
586
+ let record = await findOneWithDecryption(
587
+ em,
588
+ CatalogProductUnitConversion,
589
+ { id: before.id, deletedAt: null },
590
+ );
591
+ if (!record) {
592
+ record = em.create(CatalogProductUnitConversion, {
593
+ id: before.id,
594
+ organizationId: before.organizationId,
595
+ tenantId: before.tenantId,
596
+ product: em.getReference(CatalogProduct, before.productId),
597
+ unitCode: before.unitCode,
598
+ toBaseFactor: before.toBaseFactor,
599
+ sortOrder: before.sortOrder,
600
+ isActive: before.isActive,
601
+ metadata: before.metadata
602
+ ? cloneJson(before.metadata)
603
+ : null,
604
+ createdAt: new Date(before.createdAt),
605
+ updatedAt: new Date(before.updatedAt),
606
+ });
607
+ em.persist(record);
608
+ }
609
+ ensureTenantScope(ctx, before.tenantId);
610
+ ensureOrganizationScope(ctx, before.organizationId);
611
+ applyConversionSnapshot(em, record, before);
612
+ await em.transactional(async () => {
613
+ await em.flush();
614
+ });
615
+ const dataEngine = ctx.container.resolve("dataEngine") as DataEngine;
616
+ await emitConversionCrudUndoChange({
617
+ dataEngine,
618
+ action: "created",
619
+ conversion: record,
620
+ });
621
+ },
622
+ };
623
+
624
+ registerCommand(createProductUnitConversionCommand);
625
+ registerCommand(updateProductUnitConversionCommand);
626
+ registerCommand(deleteProductUnitConversionCommand);