@open-mercato/core 0.4.5-develop-3ce83a8b24 → 0.4.5-develop-539cff4960

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 (184) 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/dist/modules/staff/backend/staff/team-members/[id]/page.js +28 -15
  94. package/dist/modules/staff/backend/staff/team-members/[id]/page.js.map +2 -2
  95. package/dist/modules/staff/translations.js +9 -0
  96. package/dist/modules/staff/translations.js.map +7 -0
  97. package/dist/modules/translations/components/TranslationDrawerAction.js +97 -0
  98. package/dist/modules/translations/components/TranslationDrawerAction.js.map +7 -0
  99. package/dist/modules/translations/lib/extract-record-id.js +31 -2
  100. package/dist/modules/translations/lib/extract-record-id.js.map +2 -2
  101. package/dist/modules/translations/lib/resolve-field-list.js +3 -0
  102. package/dist/modules/translations/lib/resolve-field-list.js.map +2 -2
  103. package/dist/modules/translations/widgets/injection/translation-manager/widget.client.js +105 -36
  104. package/dist/modules/translations/widgets/injection/translation-manager/widget.client.js.map +2 -2
  105. package/dist/modules/translations/widgets/injection-table.js +18 -29
  106. package/dist/modules/translations/widgets/injection-table.js.map +2 -2
  107. package/generated/entities/catalog_product/index.ts +8 -0
  108. package/generated/entities/catalog_product_unit_conversion/index.ts +12 -0
  109. package/generated/entities/sales_credit_memo_line/index.ts +3 -0
  110. package/generated/entities/sales_invoice_line/index.ts +3 -0
  111. package/generated/entities/sales_order_line/index.ts +3 -0
  112. package/generated/entities/sales_quote_line/index.ts +3 -0
  113. package/generated/entities.ids.generated.ts +1 -0
  114. package/generated/entity-fields-registry.ts +2 -0
  115. package/package.json +2 -2
  116. package/src/modules/auth/i18n/de.json +1 -1
  117. package/src/modules/auth/i18n/en.json +1 -1
  118. package/src/modules/auth/i18n/es.json +1 -1
  119. package/src/modules/auth/i18n/pl.json +1 -1
  120. package/src/modules/catalog/api/prices/route.ts +213 -81
  121. package/src/modules/catalog/api/product-unit-conversions/route.ts +195 -0
  122. package/src/modules/catalog/api/products/route.ts +638 -402
  123. package/src/modules/catalog/backend/catalog/products/[id]/page.tsx +2085 -1072
  124. package/src/modules/catalog/backend/catalog/products/create/page.tsx +1288 -593
  125. package/src/modules/catalog/commands/index.ts +1 -0
  126. package/src/modules/catalog/commands/productUnitConversions.ts +626 -0
  127. package/src/modules/catalog/commands/products.ts +1151 -693
  128. package/src/modules/catalog/commands/shared.ts +19 -5
  129. package/src/modules/catalog/components/products/ProductUomSection.tsx +745 -0
  130. package/src/modules/catalog/components/products/productForm.ts +369 -256
  131. package/src/modules/catalog/components/products/productFormUtils.ts +82 -0
  132. package/src/modules/catalog/data/entities.ts +82 -1
  133. package/src/modules/catalog/data/validators.ts +118 -34
  134. package/src/modules/catalog/events.ts +3 -0
  135. package/src/modules/catalog/i18n/de.json +56 -0
  136. package/src/modules/catalog/i18n/en.json +56 -0
  137. package/src/modules/catalog/i18n/es.json +56 -0
  138. package/src/modules/catalog/i18n/pl.json +56 -0
  139. package/src/modules/catalog/lib/unitCodes.ts +1 -0
  140. package/src/modules/catalog/lib/unitResolution.ts +62 -0
  141. package/src/modules/catalog/migrations/.snapshot-open-mercato.json +245 -0
  142. package/src/modules/catalog/migrations/Migration20260218225422.ts +21 -0
  143. package/src/modules/catalog/migrations/Migration20260219084500.ts +26 -0
  144. package/src/modules/catalog/search.ts +73 -1
  145. package/src/modules/catalog/seed/examples.ts +552 -479
  146. package/src/modules/dashboards/i18n/de.json +1 -1
  147. package/src/modules/dashboards/i18n/en.json +1 -1
  148. package/src/modules/dashboards/i18n/es.json +1 -1
  149. package/src/modules/dashboards/i18n/pl.json +1 -1
  150. package/src/modules/dashboards/seed/analytics.ts +3 -0
  151. package/src/modules/sales/api/order-lines/route.ts +158 -68
  152. package/src/modules/sales/api/quote-lines/route.ts +161 -67
  153. package/src/modules/sales/api/quotes/public/[token]/route.ts +122 -36
  154. package/src/modules/sales/commands/documents.ts +4250 -2424
  155. package/src/modules/sales/commands/shared.ts +7 -2
  156. package/src/modules/sales/components/documents/ItemsSection.tsx +580 -310
  157. package/src/modules/sales/components/documents/LineItemDialog.tsx +1988 -833
  158. package/src/modules/sales/components/documents/ShipmentsSection.tsx +17 -3
  159. package/src/modules/sales/components/documents/lineItemTypes.ts +6 -0
  160. package/src/modules/sales/data/entities.ts +53 -0
  161. package/src/modules/sales/data/validators.ts +36 -0
  162. package/src/modules/sales/frontend/quote/[token]/page.tsx +25 -1
  163. package/src/modules/sales/i18n/de.json +23 -3
  164. package/src/modules/sales/i18n/en.json +23 -3
  165. package/src/modules/sales/i18n/es.json +23 -3
  166. package/src/modules/sales/i18n/pl.json +23 -3
  167. package/src/modules/sales/lib/types.ts +30 -0
  168. package/src/modules/sales/migrations/.snapshot-open-mercato.json +172 -0
  169. package/src/modules/sales/migrations/Migration20260218225423.ts +37 -0
  170. package/src/modules/sales/migrations/Migration20260219084501.ts +73 -0
  171. package/src/modules/sales/search.ts +28 -0
  172. package/src/modules/sales/seed/examples.ts +20 -1
  173. package/src/modules/sales/widgets/injection/document-history/widget.client.tsx +1 -1
  174. package/src/modules/staff/backend/staff/team-members/[id]/page.tsx +8 -0
  175. package/src/modules/staff/translations.ts +5 -0
  176. package/src/modules/translations/components/TranslationDrawerAction.tsx +107 -0
  177. package/src/modules/translations/lib/extract-record-id.ts +47 -3
  178. package/src/modules/translations/lib/resolve-field-list.ts +4 -0
  179. package/src/modules/translations/widgets/injection/translation-manager/widget.client.tsx +108 -36
  180. package/src/modules/translations/widgets/injection-table.ts +19 -33
  181. package/src/modules/workflows/i18n/de.json +4 -4
  182. package/src/modules/workflows/i18n/en.json +4 -4
  183. package/src/modules/workflows/i18n/es.json +4 -4
  184. package/src/modules/workflows/i18n/pl.json +4 -4
@@ -1,7 +1,10 @@
1
1
  import { z } from "zod";
2
2
  import { makeCrudRoute } from "@open-mercato/shared/lib/crud/factory";
3
3
  import { CrudHttpError } from "@open-mercato/shared/lib/crud/errors";
4
- import { buildCustomFieldFiltersFromQuery, extractAllCustomFieldEntries } from "@open-mercato/shared/lib/crud/custom-fields";
4
+ import {
5
+ buildCustomFieldFiltersFromQuery,
6
+ extractAllCustomFieldEntries
7
+ } from "@open-mercato/shared/lib/crud/custom-fields";
5
8
  import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
6
9
  import {
7
10
  CatalogOffer,
@@ -9,11 +12,15 @@ import {
9
12
  CatalogProductCategory,
10
13
  CatalogProductCategoryAssignment,
11
14
  CatalogProductPrice,
15
+ CatalogProductUnitConversion,
12
16
  CatalogProductVariant,
13
17
  CatalogProductTagAssignment
14
18
  } from "../../data/entities.js";
15
19
  import { CATALOG_PRODUCT_TYPES } from "../../data/types.js";
16
- import { productCreateSchema, productUpdateSchema } from "../../data/validators.js";
20
+ import {
21
+ productCreateSchema,
22
+ productUpdateSchema
23
+ } from "../../data/validators.js";
17
24
  import { parseScopedCommandInput, resolveCrudRecordId } from "../utils.js";
18
25
  import { splitCustomFieldPayload } from "@open-mercato/shared/lib/crud/custom-fields";
19
26
  import { E } from "../../../../generated/entities.ids.generated.js";
@@ -35,6 +42,7 @@ import {
35
42
  defaultOkResponseSchema
36
43
  } from "../openapi.js";
37
44
  import { findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
45
+ import { canonicalizeUnitCode, toUnitLookupKey } from "../../lib/unitCodes.js";
38
46
  const rawBodySchema = z.object({}).passthrough();
39
47
  const UUID_REGEX = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/;
40
48
  const listSchema = z.object({
@@ -56,6 +64,7 @@ const listSchema = z.object({
56
64
  customerId: z.string().uuid().optional(),
57
65
  customerGroupId: z.string().uuid().optional(),
58
66
  quantity: z.coerce.number().min(1).max(1e5).optional(),
67
+ quantityUnit: z.string().trim().max(50).optional(),
59
68
  priceDate: z.string().optional(),
60
69
  sortField: z.string().optional(),
61
70
  sortDir: z.enum(["asc", "desc"]).optional(),
@@ -148,25 +157,36 @@ async function buildProductFilters(query, ctx) {
148
157
  if (query.productType) {
149
158
  filters.product_type = { $eq: query.productType };
150
159
  }
160
+ const scope = {
161
+ organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
162
+ tenantId: ctx.auth?.tenantId ?? null
163
+ };
151
164
  const channelFilterIds = parseIdList(query.channelIds);
152
165
  if (channelFilterIds.length) {
153
- const offerRows = await em.find(
166
+ const offerRows = await findWithDecryption(
167
+ em,
154
168
  CatalogOffer,
155
169
  {
156
170
  channelId: { $in: channelFilterIds },
157
- deletedAt: null
171
+ deletedAt: null,
172
+ ...scope
158
173
  },
159
- { fields: ["id", "product"] }
174
+ { fields: ["id", "product"] },
175
+ scope
160
176
  );
161
- const productIds = offerRows.map((offer) => typeof offer.product === "string" ? offer.product : offer.product?.id ?? null).filter((id) => !!id);
177
+ const productIds = offerRows.map(
178
+ (offer) => typeof offer.product === "string" ? offer.product : offer.product?.id ?? null
179
+ ).filter((id) => !!id);
162
180
  intersectProductIds(productIds);
163
181
  }
164
182
  const categoryFilterIds = parseIdList(query.categoryIds);
165
183
  if (categoryFilterIds.length) {
166
- const assignments = await em.find(
184
+ const assignments = await findWithDecryption(
185
+ em,
167
186
  CatalogProductCategoryAssignment,
168
- { category: { $in: categoryFilterIds } },
169
- { fields: ["id", "product"] }
187
+ { category: { $in: categoryFilterIds }, ...scope },
188
+ { fields: ["id", "product"] },
189
+ scope
170
190
  );
171
191
  const productIds = assignments.map(
172
192
  (assignment) => typeof assignment.product === "string" ? assignment.product : assignment.product?.id ?? null
@@ -175,10 +195,12 @@ async function buildProductFilters(query, ctx) {
175
195
  }
176
196
  const tagFilterIds = parseIdList(query.tagIds);
177
197
  if (tagFilterIds.length) {
178
- const assignments = await em.find(
198
+ const assignments = await findWithDecryption(
199
+ em,
179
200
  CatalogProductTagAssignment,
180
- { tag: { $in: tagFilterIds } },
181
- { fields: ["id", "product"] }
201
+ { tag: { $in: tagFilterIds }, ...scope },
202
+ { fields: ["id", "product"] },
203
+ scope
182
204
  );
183
205
  const productIds = assignments.map(
184
206
  (assignment) => typeof assignment.product === "string" ? assignment.product : assignment.product?.id ?? null
@@ -197,7 +219,8 @@ async function buildProductFilters(query, ctx) {
197
219
  fieldset: customFieldset ?? void 0
198
220
  });
199
221
  Object.assign(filters, cfFilters);
200
- } catch {
222
+ } catch (err) {
223
+ if (process.env.NODE_ENV === "development") console.warn("[catalog:products] custom field filter error", err);
201
224
  }
202
225
  applyRestrictedProducts();
203
226
  return filters;
@@ -222,198 +245,274 @@ async function decorateProductsAfterList(payload, ctx) {
222
245
  if (!items.length) return;
223
246
  const productIds = items.map((item) => typeof item.id === "string" ? item.id : null).filter((id) => !!id);
224
247
  if (!productIds.length) return;
225
- const em = ctx.container.resolve("em").fork();
226
- const offers = await em.find(
227
- CatalogOffer,
228
- { product: { $in: productIds }, deletedAt: null },
229
- { orderBy: { createdAt: "asc" } }
230
- );
231
- const channelIds = Array.from(
232
- new Set(
233
- offers.map((offer) => offer.channelId).filter((id) => typeof id === "string" && id.length > 0)
234
- )
235
- );
236
- const channelLookup = /* @__PURE__ */ new Map();
237
- if (channelIds.length) {
238
- const scopedChannelsWhere = buildScopedWhere(
239
- { id: { $in: channelIds } },
248
+ try {
249
+ const em = ctx.container.resolve("em").fork();
250
+ const scope = {
251
+ organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
252
+ tenantId: ctx.auth?.tenantId ?? null
253
+ };
254
+ const offers = await findWithDecryption(
255
+ em,
256
+ CatalogOffer,
257
+ { product: { $in: productIds }, deletedAt: null, ...scope },
258
+ { orderBy: { createdAt: "asc" } },
259
+ scope
260
+ );
261
+ const channelIds = Array.from(
262
+ new Set(
263
+ offers.map((offer) => offer.channelId).filter(
264
+ (id) => typeof id === "string" && id.length > 0
265
+ )
266
+ )
267
+ );
268
+ const channelLookup = /* @__PURE__ */ new Map();
269
+ if (channelIds.length) {
270
+ const scopedChannelsWhere = buildScopedWhere(
271
+ { id: { $in: channelIds } },
272
+ {
273
+ organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
274
+ organizationIds: Array.isArray(ctx.organizationIds) ? ctx.organizationIds : void 0,
275
+ tenantId: ctx.auth?.tenantId ?? null
276
+ }
277
+ );
278
+ const channels = await findWithDecryption(em, SalesChannel, scopedChannelsWhere, {
279
+ fields: ["id", "name", "code"]
280
+ });
281
+ for (const channel of channels) {
282
+ channelLookup.set(channel.id, {
283
+ name: channel.name,
284
+ code: channel.code ?? null
285
+ });
286
+ }
287
+ }
288
+ const offersByProduct = /* @__PURE__ */ new Map();
289
+ for (const offer of offers) {
290
+ const productId = typeof offer.product === "string" ? offer.product : offer.product?.id ?? null;
291
+ if (!productId) continue;
292
+ const channelInfo = channelLookup.get(offer.channelId);
293
+ const entry = offersByProduct.get(productId) ?? [];
294
+ entry.push({
295
+ id: offer.id,
296
+ channelId: offer.channelId,
297
+ channelName: channelInfo?.name ?? null,
298
+ channelCode: channelInfo?.code ?? null,
299
+ title: offer.title,
300
+ description: offer.description ?? null,
301
+ isActive: offer.isActive,
302
+ defaultMediaId: offer.defaultMediaId ?? null,
303
+ defaultMediaUrl: offer.defaultMediaUrl ?? null,
304
+ metadata: offer.metadata ?? null
305
+ });
306
+ offersByProduct.set(productId, entry);
307
+ }
308
+ const categoryAssignments = await findWithDecryption(
309
+ em,
310
+ CatalogProductCategoryAssignment,
311
+ { product: { $in: productIds }, ...scope },
312
+ { populate: ["category"], orderBy: { position: "asc" } },
313
+ scope
314
+ );
315
+ const parentIds = /* @__PURE__ */ new Set();
316
+ for (const assignment of categoryAssignments) {
317
+ const category = typeof assignment.category === "string" ? null : assignment.category ?? null;
318
+ if (!category) continue;
319
+ const parentId = category.parentId ?? null;
320
+ if (parentId) parentIds.add(parentId);
321
+ }
322
+ const parentCategories = parentIds.size ? await findWithDecryption(
323
+ em,
324
+ CatalogProductCategory,
325
+ { id: { $in: Array.from(parentIds) }, ...scope },
326
+ { fields: ["id", "name"] },
327
+ scope
328
+ ) : [];
329
+ const parentNameById = /* @__PURE__ */ new Map();
330
+ for (const parent of parentCategories) {
331
+ parentNameById.set(parent.id, parent.name ?? null);
332
+ }
333
+ const categoriesByProduct = /* @__PURE__ */ new Map();
334
+ for (const assignment of categoryAssignments) {
335
+ const productId = typeof assignment.product === "string" ? assignment.product : assignment.product?.id ?? null;
336
+ if (!productId) continue;
337
+ const category = typeof assignment.category === "string" ? null : assignment.category ?? null;
338
+ if (!category) continue;
339
+ const parentId = category.parentId ?? null;
340
+ const parentName = parentId ? parentNameById.get(parentId) ?? null : null;
341
+ const bucket = categoriesByProduct.get(productId) ?? [];
342
+ bucket.push({
343
+ id: category.id,
344
+ name: category.name ?? null,
345
+ treePath: category.treePath ?? null,
346
+ parentId,
347
+ parentName
348
+ });
349
+ categoriesByProduct.set(productId, bucket);
350
+ }
351
+ const tagAssignments = await findWithDecryption(
352
+ em,
353
+ CatalogProductTagAssignment,
354
+ { product: { $in: productIds } },
355
+ { populate: ["tag"] },
240
356
  {
241
- organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
242
- organizationIds: Array.isArray(ctx.organizationIds) ? ctx.organizationIds : void 0,
243
- tenantId: ctx.auth?.tenantId ?? null
357
+ tenantId: ctx.auth?.tenantId ?? null,
358
+ organizationId: ctx.auth?.orgId ?? null
244
359
  }
245
360
  );
246
- const channels = await em.find(
247
- SalesChannel,
248
- scopedChannelsWhere,
249
- { fields: ["id", "name", "code"] }
361
+ const tagsByProduct = /* @__PURE__ */ new Map();
362
+ for (const assignment of tagAssignments) {
363
+ const productId = typeof assignment.product === "string" ? assignment.product : assignment.product?.id ?? null;
364
+ if (!productId) continue;
365
+ const tag = typeof assignment.tag === "string" ? null : assignment.tag ?? null;
366
+ if (!tag) continue;
367
+ const label = typeof tag.label === "string" && tag.label.trim().length ? tag.label : null;
368
+ if (!label) continue;
369
+ const bucket = tagsByProduct.get(productId) ?? [];
370
+ bucket.push(label);
371
+ tagsByProduct.set(productId, bucket);
372
+ }
373
+ const variants = await findWithDecryption(
374
+ em,
375
+ CatalogProductVariant,
376
+ { product: { $in: productIds }, deletedAt: null, ...scope },
377
+ { fields: ["id", "product"] },
378
+ scope
379
+ );
380
+ const variantToProduct = /* @__PURE__ */ new Map();
381
+ for (const variant of variants) {
382
+ const productId = typeof variant.product === "string" ? variant.product : variant.product?.id ?? null;
383
+ if (!productId) continue;
384
+ variantToProduct.set(variant.id, productId);
385
+ }
386
+ const variantIds = Array.from(variantToProduct.keys());
387
+ const priceWhere = variantIds.length > 0 ? {
388
+ $or: [
389
+ { product: { $in: productIds } },
390
+ { variant: { $in: variantIds } }
391
+ ]
392
+ } : { product: { $in: productIds } };
393
+ const priceRows = await findWithDecryption(
394
+ em,
395
+ CatalogProductPrice,
396
+ { ...priceWhere, ...scope },
397
+ { populate: ["offer", "variant", "product", "priceKind"] },
398
+ scope
250
399
  );
251
- for (const channel of channels) {
252
- channelLookup.set(channel.id, {
253
- name: channel.name,
254
- code: channel.code ?? null
255
- });
400
+ const pricesByProduct = /* @__PURE__ */ new Map();
401
+ for (const price of priceRows) {
402
+ let productId = null;
403
+ if (price.product) {
404
+ productId = typeof price.product === "string" ? price.product : price.product?.id ?? null;
405
+ } else if (price.variant) {
406
+ const variantId = typeof price.variant === "string" ? price.variant : price.variant.id;
407
+ productId = variantToProduct.get(variantId) ?? null;
408
+ }
409
+ if (!productId) continue;
410
+ const entry = pricesByProduct.get(productId) ?? [];
411
+ entry.push(price);
412
+ pricesByProduct.set(productId, entry);
256
413
  }
257
- }
258
- const offersByProduct = /* @__PURE__ */ new Map();
259
- for (const offer of offers) {
260
- const productId = typeof offer.product === "string" ? offer.product : offer.product?.id ?? null;
261
- if (!productId) continue;
262
- const channelInfo = channelLookup.get(offer.channelId);
263
- const entry = offersByProduct.get(productId) ?? [];
264
- entry.push({
265
- id: offer.id,
266
- channelId: offer.channelId,
267
- channelName: channelInfo?.name ?? null,
268
- channelCode: channelInfo?.code ?? null,
269
- title: offer.title,
270
- description: offer.description ?? null,
271
- isActive: offer.isActive,
272
- defaultMediaId: offer.defaultMediaId ?? null,
273
- defaultMediaUrl: offer.defaultMediaUrl ?? null,
274
- metadata: offer.metadata ?? null
275
- });
276
- offersByProduct.set(productId, entry);
277
- }
278
- const categoryAssignments = await em.find(
279
- CatalogProductCategoryAssignment,
280
- { product: { $in: productIds } },
281
- { populate: ["category"], orderBy: { position: "asc" } }
282
- );
283
- const parentIds = /* @__PURE__ */ new Set();
284
- for (const assignment of categoryAssignments) {
285
- const category = typeof assignment.category === "string" ? null : assignment.category ?? null;
286
- if (!category) continue;
287
- const parentId = category.parentId ?? null;
288
- if (parentId) parentIds.add(parentId);
289
- }
290
- const parentCategories = parentIds.size ? await em.find(
291
- CatalogProductCategory,
292
- { id: { $in: Array.from(parentIds) } },
293
- { fields: ["id", "name"] }
294
- ) : [];
295
- const parentNameById = /* @__PURE__ */ new Map();
296
- for (const parent of parentCategories) {
297
- parentNameById.set(parent.id, parent.name ?? null);
298
- }
299
- const categoriesByProduct = /* @__PURE__ */ new Map();
300
- for (const assignment of categoryAssignments) {
301
- const productId = typeof assignment.product === "string" ? assignment.product : assignment.product?.id ?? null;
302
- if (!productId) continue;
303
- const category = typeof assignment.category === "string" ? null : assignment.category ?? null;
304
- if (!category) continue;
305
- const parentId = category.parentId ?? null;
306
- const parentName = parentId ? parentNameById.get(parentId) ?? null : null;
307
- const bucket = categoriesByProduct.get(productId) ?? [];
308
- bucket.push({
309
- id: category.id,
310
- name: category.name ?? null,
311
- treePath: category.treePath ?? null,
312
- parentId,
313
- parentName
314
- });
315
- categoriesByProduct.set(productId, bucket);
316
- }
317
- const tagAssignments = await findWithDecryption(
318
- em,
319
- CatalogProductTagAssignment,
320
- { product: { $in: productIds } },
321
- { populate: ["tag"] },
322
- { tenantId: ctx.auth?.tenantId ?? null, organizationId: ctx.auth?.orgId ?? null }
323
- );
324
- const tagsByProduct = /* @__PURE__ */ new Map();
325
- for (const assignment of tagAssignments) {
326
- const productId = typeof assignment.product === "string" ? assignment.product : assignment.product?.id ?? null;
327
- if (!productId) continue;
328
- const tag = typeof assignment.tag === "string" ? null : assignment.tag ?? null;
329
- if (!tag) continue;
330
- const label = typeof tag.label === "string" && tag.label.trim().length ? tag.label : null;
331
- if (!label) continue;
332
- const bucket = tagsByProduct.get(productId) ?? [];
333
- bucket.push(label);
334
- tagsByProduct.set(productId, bucket);
335
- }
336
- const variants = await em.find(
337
- CatalogProductVariant,
338
- { product: { $in: productIds }, deletedAt: null },
339
- { fields: ["id", "product"] }
340
- );
341
- const variantToProduct = /* @__PURE__ */ new Map();
342
- for (const variant of variants) {
343
- const productId = typeof variant.product === "string" ? variant.product : variant.product?.id ?? null;
344
- if (!productId) continue;
345
- variantToProduct.set(variant.id, productId);
346
- }
347
- const variantIds = Array.from(variantToProduct.keys());
348
- const priceWhere = variantIds.length > 0 ? {
349
- $or: [{ product: { $in: productIds } }, { variant: { $in: variantIds } }]
350
- } : { product: { $in: productIds } };
351
- const priceRows = await em.find(
352
- CatalogProductPrice,
353
- priceWhere,
354
- { populate: ["offer", "variant", "product", "priceKind"] }
355
- );
356
- const pricesByProduct = /* @__PURE__ */ new Map();
357
- for (const price of priceRows) {
358
- let productId = null;
359
- if (price.product) {
360
- productId = typeof price.product === "string" ? price.product : price.product?.id ?? null;
361
- } else if (price.variant) {
362
- const variantId = typeof price.variant === "string" ? price.variant : price.variant.id;
363
- productId = variantToProduct.get(variantId) ?? null;
414
+ const requestQuantityUnitKey = toUnitLookupKey(
415
+ ctx.query.quantityUnit
416
+ );
417
+ const conversionsByProduct = /* @__PURE__ */ new Map();
418
+ const conversionOrganizationId = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null;
419
+ const conversionTenantId = ctx.auth?.tenantId ?? null;
420
+ if (requestQuantityUnitKey && productIds.length && conversionOrganizationId && conversionTenantId) {
421
+ const conversionRows = await findWithDecryption(
422
+ em,
423
+ CatalogProductUnitConversion,
424
+ {
425
+ product: { $in: productIds },
426
+ organizationId: conversionOrganizationId,
427
+ tenantId: conversionTenantId,
428
+ deletedAt: null,
429
+ isActive: true
430
+ },
431
+ { fields: ["id", "product", "unitCode", "toBaseFactor"] },
432
+ { organizationId: conversionOrganizationId, tenantId: conversionTenantId }
433
+ );
434
+ for (const row of conversionRows) {
435
+ const productId = typeof row.product === "string" ? row.product : row.product?.id ?? null;
436
+ const unitKey = toUnitLookupKey(row.unitCode);
437
+ const factor = Number(row.toBaseFactor);
438
+ if (!productId || !unitKey || !Number.isFinite(factor) || factor <= 0)
439
+ continue;
440
+ const bucket = conversionsByProduct.get(productId) ?? /* @__PURE__ */ new Map();
441
+ bucket.set(unitKey, factor);
442
+ conversionsByProduct.set(productId, bucket);
443
+ }
364
444
  }
365
- if (!productId) continue;
366
- const entry = pricesByProduct.get(productId) ?? [];
367
- entry.push(price);
368
- pricesByProduct.set(productId, entry);
369
- }
370
- const channelFilterIds = parseIdList(ctx.query.channelIds);
371
- const channelContext = ctx.query.channelId ?? (channelFilterIds.length === 1 ? channelFilterIds[0] : null);
372
- const pricingContext = buildPricingContext(ctx.query, channelContext);
373
- const pricingService = ctx.container.resolve("catalogPricingService");
374
- for (const item of items) {
375
- const id = typeof item.id === "string" ? item.id : null;
376
- if (!id) continue;
377
- const offerEntries = offersByProduct.get(id) ?? [];
378
- item.offers = offerEntries;
379
- const channelIds2 = Array.from(
380
- new Set(
381
- offerEntries.map((offer) => typeof offer.channelId === "string" ? offer.channelId : null).filter((channelId) => !!channelId)
382
- )
445
+ const channelFilterIds = parseIdList(ctx.query.channelIds);
446
+ const channelContext = ctx.query.channelId ?? (channelFilterIds.length === 1 ? channelFilterIds[0] : null);
447
+ const pricingContext = buildPricingContext(ctx.query, channelContext);
448
+ const pricingService = ctx.container.resolve(
449
+ "catalogPricingService"
383
450
  );
384
- item.channelIds = channelIds2;
385
- const categories = categoriesByProduct.get(id) ?? [];
386
- item.categories = categories;
387
- item.categoryIds = categories.map((category) => category.id);
388
- item.tags = tagsByProduct.get(id) ?? [];
389
- const priceCandidates = pricesByProduct.get(id) ?? [];
390
- const channelScopedContext = pricingContext.channelId || channelIds2.length !== 1 ? pricingContext : { ...pricingContext, channelId: channelIds2[0] };
391
- const best = await pricingService.resolvePrice(priceCandidates, channelScopedContext);
392
- if (best) {
393
- item.pricing = {
394
- kind: resolvePriceKindCode(best),
395
- price_kind_id: typeof best.priceKind === "string" ? best.priceKind : best.priceKind?.id ?? null,
396
- price_kind_code: resolvePriceKindCode(best),
397
- currency_code: best.currencyCode,
398
- unit_price_net: best.unitPriceNet,
399
- unit_price_gross: best.unitPriceGross,
400
- min_quantity: best.minQuantity,
401
- max_quantity: best.maxQuantity ?? null,
402
- tax_rate: best.taxRate ?? null,
403
- tax_amount: best.taxAmount ?? null,
404
- scope: {
405
- variant_id: resolvePriceVariantId(best),
406
- offer_id: resolvePriceOfferId(best),
407
- channel_id: resolvePriceChannelId(best),
408
- user_id: best.userId ?? null,
409
- user_group_id: best.userGroupId ?? null,
410
- customer_id: best.customerId ?? null,
411
- customer_group_id: best.customerGroupId ?? null
451
+ for (const item of items) {
452
+ const id = typeof item.id === "string" ? item.id : null;
453
+ if (!id) continue;
454
+ const offerEntries = offersByProduct.get(id) ?? [];
455
+ item.offers = offerEntries;
456
+ const channelIds2 = Array.from(
457
+ new Set(
458
+ offerEntries.map(
459
+ (offer) => typeof offer.channelId === "string" ? offer.channelId : null
460
+ ).filter((channelId) => !!channelId)
461
+ )
462
+ );
463
+ item.channelIds = channelIds2;
464
+ const categories = categoriesByProduct.get(id) ?? [];
465
+ item.categories = categories;
466
+ item.categoryIds = categories.map((category) => category.id);
467
+ item.tags = tagsByProduct.get(id) ?? [];
468
+ const priceCandidates = pricesByProduct.get(id) ?? [];
469
+ const normalizedQuantityForPricing = (() => {
470
+ if (!requestQuantityUnitKey) return pricingContext.quantity;
471
+ const baseUnit = toUnitLookupKey(item.default_unit);
472
+ if (!baseUnit || requestQuantityUnitKey === baseUnit)
473
+ return pricingContext.quantity;
474
+ const productConversions = conversionsByProduct.get(id);
475
+ const factor = productConversions?.get(requestQuantityUnitKey) ?? null;
476
+ if (!factor || !Number.isFinite(factor) || factor <= 0) {
477
+ if (process.env.NODE_ENV === "development") console.warn(`[catalog.products] Invalid conversion factor for product=${id} unit=${requestQuantityUnitKey} factor=${factor}`);
478
+ return pricingContext.quantity;
412
479
  }
413
- };
414
- } else {
415
- item.pricing = null;
480
+ const normalized = pricingContext.quantity * factor;
481
+ return Number.isFinite(normalized) && normalized > 0 ? normalized : pricingContext.quantity;
482
+ })();
483
+ const channelScopedContext = pricingContext.channelId || channelIds2.length !== 1 ? pricingContext : { ...pricingContext, channelId: channelIds2[0] };
484
+ const best = await pricingService.resolvePrice(priceCandidates, {
485
+ ...channelScopedContext,
486
+ quantity: normalizedQuantityForPricing
487
+ });
488
+ if (best) {
489
+ item.pricing = {
490
+ kind: resolvePriceKindCode(best),
491
+ price_kind_id: typeof best.priceKind === "string" ? best.priceKind : best.priceKind?.id ?? null,
492
+ price_kind_code: resolvePriceKindCode(best),
493
+ currency_code: best.currencyCode,
494
+ unit_price_net: best.unitPriceNet,
495
+ unit_price_gross: best.unitPriceGross,
496
+ min_quantity: best.minQuantity,
497
+ max_quantity: best.maxQuantity ?? null,
498
+ tax_rate: best.taxRate ?? null,
499
+ tax_amount: best.taxAmount ?? null,
500
+ scope: {
501
+ variant_id: resolvePriceVariantId(best),
502
+ offer_id: resolvePriceOfferId(best),
503
+ channel_id: resolvePriceChannelId(best),
504
+ user_id: best.userId ?? null,
505
+ user_group_id: best.userGroupId ?? null,
506
+ customer_id: best.customerId ?? null,
507
+ customer_group_id: best.customerGroupId ?? null
508
+ }
509
+ };
510
+ } else {
511
+ item.pricing = null;
512
+ }
416
513
  }
514
+ } catch (error) {
515
+ console.error("[decorateProductsAfterList] Failed to load unit conversions", error);
417
516
  }
418
517
  }
419
518
  const crud = makeCrudRoute({
@@ -444,6 +543,13 @@ const crud = makeCrudRoute({
444
543
  F.status_entry_id,
445
544
  F.primary_currency_code,
446
545
  F.default_unit,
546
+ "default_sales_unit",
547
+ "default_sales_unit_quantity",
548
+ "uom_rounding_scale",
549
+ "uom_rounding_mode",
550
+ "unit_price_enabled",
551
+ "unit_price_reference_unit",
552
+ "unit_price_base_quantity",
447
553
  F.default_media_id,
448
554
  F.default_media_url,
449
555
  F.weight_value,
@@ -474,7 +580,21 @@ const crud = makeCrudRoute({
474
580
  delete normalized[key];
475
581
  }
476
582
  }
477
- return { ...normalized, ...cfEntries };
583
+ const defaultUnit = canonicalizeUnitCode(normalized.default_unit) ?? null;
584
+ const defaultSalesUnit = canonicalizeUnitCode(normalized.default_sales_unit) ?? null;
585
+ const unitPriceReferenceUnit = canonicalizeUnitCode(normalized.unit_price_reference_unit) ?? null;
586
+ return {
587
+ ...normalized,
588
+ default_unit: defaultUnit,
589
+ default_sales_unit: defaultSalesUnit,
590
+ unit_price_reference_unit: unitPriceReferenceUnit,
591
+ ...cfEntries,
592
+ unit_price: {
593
+ enabled: Boolean(normalized.unit_price_enabled),
594
+ reference_unit: unitPriceReferenceUnit,
595
+ base_quantity: normalized.unit_price_base_quantity ?? null
596
+ }
597
+ };
478
598
  }
479
599
  },
480
600
  hooks: {
@@ -486,11 +606,18 @@ const crud = makeCrudRoute({
486
606
  schema: rawBodySchema,
487
607
  mapInput: async ({ raw, ctx }) => {
488
608
  const { translate } = await resolveTranslations();
489
- const parsed = parseScopedCommandInput(productCreateSchema, raw ?? {}, ctx, translate);
609
+ const parsed = parseScopedCommandInput(
610
+ productCreateSchema,
611
+ raw ?? {},
612
+ ctx,
613
+ translate
614
+ );
490
615
  const { base, custom } = splitCustomFieldPayload(parsed);
491
616
  return Object.keys(custom).length ? { ...base, customFields: custom } : base;
492
617
  },
493
- response: ({ result }) => ({ id: result?.productId ?? result?.id ?? null }),
618
+ response: ({ result }) => ({
619
+ id: result?.productId ?? result?.id ?? null
620
+ }),
494
621
  status: 201
495
622
  },
496
623
  update: {
@@ -498,7 +625,12 @@ const crud = makeCrudRoute({
498
625
  schema: rawBodySchema,
499
626
  mapInput: async ({ raw, ctx }) => {
500
627
  const { translate } = await resolveTranslations();
501
- const parsed = parseScopedCommandInput(productUpdateSchema, raw ?? {}, ctx, translate);
628
+ const parsed = parseScopedCommandInput(
629
+ productUpdateSchema,
630
+ raw ?? {},
631
+ ctx,
632
+ translate
633
+ );
502
634
  const { base, custom } = splitCustomFieldPayload(parsed);
503
635
  return Object.keys(custom).length ? { ...base, customFields: custom } : base;
504
636
  },
@@ -510,7 +642,13 @@ const crud = makeCrudRoute({
510
642
  mapInput: async ({ parsed, ctx }) => {
511
643
  const { translate } = await resolveTranslations();
512
644
  const id = resolveCrudRecordId(parsed, ctx, translate);
513
- if (!id) throw new CrudHttpError(400, { error: translate("catalog.errors.id_required", "Product id is required.") });
645
+ if (!id)
646
+ throw new CrudHttpError(400, {
647
+ error: translate(
648
+ "catalog.errors.id_required",
649
+ "Product id is required."
650
+ )
651
+ });
514
652
  return { id };
515
653
  },
516
654
  response: () => ({ ok: true })
@@ -532,6 +670,18 @@ const productListItemSchema = z.object({
532
670
  status_entry_id: z.string().uuid().nullable().optional(),
533
671
  primary_currency_code: z.string().nullable().optional(),
534
672
  default_unit: z.string().nullable().optional(),
673
+ default_sales_unit: z.string().nullable().optional(),
674
+ default_sales_unit_quantity: z.number().nullable().optional(),
675
+ uom_rounding_scale: z.number().nullable().optional(),
676
+ uom_rounding_mode: z.enum(["half_up", "down", "up"]).nullable().optional(),
677
+ unit_price_enabled: z.boolean().nullable().optional(),
678
+ unit_price_reference_unit: z.enum(["kg", "l", "m2", "m3", "pc"]).nullable().optional(),
679
+ unit_price_base_quantity: z.number().nullable().optional(),
680
+ unit_price: z.object({
681
+ enabled: z.boolean(),
682
+ reference_unit: z.enum(["kg", "l", "m2", "m3", "pc"]).nullable(),
683
+ base_quantity: z.number().nullable()
684
+ }).optional(),
535
685
  default_media_id: z.string().uuid().nullable().optional(),
536
686
  default_media_url: z.string().nullable().optional(),
537
687
  weight_value: z.number().nullable().optional(),