@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,29 +1,36 @@
1
- import { z } from 'zod'
2
- import type { EntityManager } from '@mikro-orm/postgresql'
3
- import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
4
- import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
5
- import { buildCustomFieldFiltersFromQuery, extractAllCustomFieldEntries } from '@open-mercato/shared/lib/crud/custom-fields'
6
- import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
1
+ import { z } from "zod";
2
+ import type { EntityManager } from "@mikro-orm/postgresql";
3
+ import { makeCrudRoute } from "@open-mercato/shared/lib/crud/factory";
4
+ import { CrudHttpError } from "@open-mercato/shared/lib/crud/errors";
5
+ import {
6
+ buildCustomFieldFiltersFromQuery,
7
+ extractAllCustomFieldEntries,
8
+ } from "@open-mercato/shared/lib/crud/custom-fields";
9
+ import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
7
10
  import {
8
11
  CatalogOffer,
9
12
  CatalogProduct,
10
13
  CatalogProductCategory,
11
14
  CatalogProductCategoryAssignment,
12
15
  CatalogProductPrice,
16
+ CatalogProductUnitConversion,
13
17
  CatalogProductVariant,
14
18
  CatalogProductTagAssignment,
15
- } from '../../data/entities'
16
- import { CATALOG_PRODUCT_TYPES } from '../../data/types'
17
- import type { CatalogProductType } from '../../data/types'
18
- import { productCreateSchema, productUpdateSchema } from '../../data/validators'
19
- import { parseScopedCommandInput, resolveCrudRecordId } from '../utils'
20
- import { splitCustomFieldPayload } from '@open-mercato/shared/lib/crud/custom-fields'
21
- import { E } from '#generated/entities.ids.generated'
22
- import * as F from '#generated/entities/catalog_product'
23
- import { parseBooleanFlag, sanitizeSearchTerm } from '../helpers'
24
- import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
25
- import type { CrudCtx } from '@open-mercato/shared/lib/crud/factory'
26
- import { buildScopedWhere } from '@open-mercato/shared/lib/api/crud'
19
+ } from "../../data/entities";
20
+ import { CATALOG_PRODUCT_TYPES } from "../../data/types";
21
+ import type { CatalogProductType } from "../../data/types";
22
+ import {
23
+ productCreateSchema,
24
+ productUpdateSchema,
25
+ } from "../../data/validators";
26
+ import { parseScopedCommandInput, resolveCrudRecordId } from "../utils";
27
+ import { splitCustomFieldPayload } from "@open-mercato/shared/lib/crud/custom-fields";
28
+ import { E } from "#generated/entities.ids.generated";
29
+ import * as F from "#generated/entities/catalog_product";
30
+ import { parseBooleanFlag, sanitizeSearchTerm } from "../helpers";
31
+ import { escapeLikePattern } from "@open-mercato/shared/lib/db/escapeLikePattern";
32
+ import type { CrudCtx } from "@open-mercato/shared/lib/crud/factory";
33
+ import { buildScopedWhere } from "@open-mercato/shared/lib/api/crud";
27
34
  import {
28
35
  resolvePriceChannelId,
29
36
  resolvePriceOfferId,
@@ -31,19 +38,21 @@ import {
31
38
  resolvePriceKindCode,
32
39
  type PricingContext,
33
40
  type PriceRow,
34
- } from '../../lib/pricing'
35
- import type { CatalogPricingService } from '../../services/catalogPricingService'
36
- import { fieldsetCodeRegex } from '@open-mercato/core/modules/entities/data/validators'
37
- import { SalesChannel } from '@open-mercato/core/modules/sales/data/entities'
41
+ } from "../../lib/pricing";
42
+ import type { CatalogPricingService } from "../../services/catalogPricingService";
43
+ import { fieldsetCodeRegex } from "@open-mercato/core/modules/entities/data/validators";
44
+ import { SalesChannel } from "@open-mercato/core/modules/sales/data/entities";
38
45
  import {
39
46
  createCatalogCrudOpenApi,
40
47
  createPagedListResponseSchema,
41
48
  defaultOkResponseSchema,
42
- } from '../openapi'
43
- import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
44
- const rawBodySchema = z.object({}).passthrough()
49
+ } from "../openapi";
50
+ import { findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
51
+ import { canonicalizeUnitCode, toUnitLookupKey } from "../../lib/unitCodes";
52
+ const rawBodySchema = z.object({}).passthrough();
45
53
 
46
- 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}$/
54
+ const UUID_REGEX =
55
+ /^[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}$/;
47
56
 
48
57
  const listSchema = z
49
58
  .object({
@@ -65,186 +74,221 @@ const listSchema = z
65
74
  customerId: z.string().uuid().optional(),
66
75
  customerGroupId: z.string().uuid().optional(),
67
76
  quantity: z.coerce.number().min(1).max(100000).optional(),
77
+ quantityUnit: z.string().trim().max(50).optional(),
68
78
  priceDate: z.string().optional(),
69
79
  sortField: z.string().optional(),
70
- sortDir: z.enum(['asc', 'desc']).optional(),
80
+ sortDir: z.enum(["asc", "desc"]).optional(),
71
81
  withDeleted: z.coerce.boolean().optional(),
72
82
  customFieldset: z.string().regex(fieldsetCodeRegex).optional(),
73
83
  })
74
- .passthrough()
84
+ .passthrough();
75
85
 
76
- type ProductsQuery = z.infer<typeof listSchema>
86
+ type ProductsQuery = z.infer<typeof listSchema>;
77
87
 
78
88
  const routeMetadata = {
79
- GET: { requireAuth: true, requireFeatures: ['catalog.products.view'] },
80
- POST: { requireAuth: true, requireFeatures: ['catalog.products.manage'] },
81
- PUT: { requireAuth: true, requireFeatures: ['catalog.products.manage'] },
82
- DELETE: { requireAuth: true, requireFeatures: ['catalog.products.manage'] },
83
- }
89
+ GET: { requireAuth: true, requireFeatures: ["catalog.products.view"] },
90
+ POST: { requireAuth: true, requireFeatures: ["catalog.products.manage"] },
91
+ PUT: { requireAuth: true, requireFeatures: ["catalog.products.manage"] },
92
+ DELETE: { requireAuth: true, requireFeatures: ["catalog.products.manage"] },
93
+ };
84
94
 
85
- export const metadata = routeMetadata
95
+ export const metadata = routeMetadata;
86
96
 
87
97
  export function parseIdList(raw?: string): string[] {
88
- if (!raw) return []
98
+ if (!raw) return [];
89
99
  return raw
90
- .split(',')
100
+ .split(",")
91
101
  .map((value) => value.trim())
92
- .filter((value) => UUID_REGEX.test(value))
102
+ .filter((value) => UUID_REGEX.test(value));
93
103
  }
94
104
 
95
105
  export async function buildProductFilters(
96
106
  query: ProductsQuery,
97
- ctx: CrudCtx
107
+ ctx: CrudCtx,
98
108
  ): Promise<Record<string, unknown>> {
99
- const filters: Record<string, unknown> = {}
100
- const em = (ctx.container.resolve('em') as EntityManager).fork()
101
- const restrictedProductIds: { value: Set<string> | null } = { value: null }
109
+ const filters: Record<string, unknown> = {};
110
+ const em = (ctx.container.resolve("em") as EntityManager).fork();
111
+ const restrictedProductIds: { value: Set<string> | null } = { value: null };
102
112
 
103
113
  const intersectProductIds = (ids: string[]) => {
104
114
  const normalized = ids.filter(
105
- (id): id is string => typeof id === 'string' && id.trim().length > 0
106
- )
107
- const current = new Set(normalized)
115
+ (id): id is string => typeof id === "string" && id.trim().length > 0,
116
+ );
117
+ const current = new Set(normalized);
108
118
  if (!current.size) {
109
- restrictedProductIds.value = new Set()
110
- return
119
+ restrictedProductIds.value = new Set();
120
+ return;
111
121
  }
112
122
  if (!restrictedProductIds.value) {
113
- restrictedProductIds.value = current
114
- return
123
+ restrictedProductIds.value = current;
124
+ return;
115
125
  }
116
126
  restrictedProductIds.value = new Set(
117
- Array.from(restrictedProductIds.value).filter((id) => current.has(id))
118
- )
119
- }
127
+ Array.from(restrictedProductIds.value).filter((id) => current.has(id)),
128
+ );
129
+ };
120
130
 
121
131
  const applyRestrictedProducts = () => {
122
- if (!restrictedProductIds.value) return
132
+ if (!restrictedProductIds.value) return;
123
133
  if (restrictedProductIds.value.size === 0) {
124
- filters.id = { $eq: '00000000-0000-0000-0000-000000000000' }
125
- return
134
+ filters.id = { $eq: "00000000-0000-0000-0000-000000000000" };
135
+ return;
126
136
  }
127
- const ids = Array.from(restrictedProductIds.value)
128
- const existing = filters.id as Record<string, unknown> | undefined
129
- if (existing && typeof existing === 'object') {
130
- if ('$eq' in existing && typeof (existing as { $eq?: unknown }).$eq === 'string') {
131
- const target = (existing as { $eq: string }).$eq
137
+ const ids = Array.from(restrictedProductIds.value);
138
+ const existing = filters.id as Record<string, unknown> | undefined;
139
+ if (existing && typeof existing === "object") {
140
+ if (
141
+ "$eq" in existing &&
142
+ typeof (existing as { $eq?: unknown }).$eq === "string"
143
+ ) {
144
+ const target = (existing as { $eq: string }).$eq;
132
145
  if (!restrictedProductIds.value.has(target)) {
133
- filters.id = { $eq: '00000000-0000-0000-0000-000000000000' }
146
+ filters.id = { $eq: "00000000-0000-0000-0000-000000000000" };
134
147
  }
135
- return
148
+ return;
136
149
  }
137
- if ('$in' in existing && Array.isArray((existing as { $in?: unknown }).$in)) {
138
- const subset = ((existing as { $in: string[] }).$in).filter((id) =>
139
- restrictedProductIds.value!.has(id)
140
- )
150
+ if (
151
+ "$in" in existing &&
152
+ Array.isArray((existing as { $in?: unknown }).$in)
153
+ ) {
154
+ const subset = (existing as { $in: string[] }).$in.filter((id) =>
155
+ restrictedProductIds.value!.has(id),
156
+ );
141
157
  filters.id = subset.length
142
158
  ? { $in: subset }
143
- : { $eq: '00000000-0000-0000-0000-000000000000' }
144
- return
159
+ : { $eq: "00000000-0000-0000-0000-000000000000" };
160
+ return;
145
161
  }
146
162
  }
147
- filters.id = ids.length === 1 ? { $eq: ids[0] } : { $in: ids }
148
- }
163
+ filters.id = ids.length === 1 ? { $eq: ids[0] } : { $in: ids };
164
+ };
149
165
  if (query.id) {
150
- filters.id = { $eq: query.id }
166
+ filters.id = { $eq: query.id };
151
167
  }
152
- const term = sanitizeSearchTerm(query.search)
168
+ const term = sanitizeSearchTerm(query.search);
153
169
  if (term) {
154
- const like = `%${escapeLikePattern(term)}%`
170
+ const like = `%${escapeLikePattern(term)}%`;
155
171
  filters.$or = [
156
172
  { title: { $ilike: like } },
157
173
  { subtitle: { $ilike: like } },
158
174
  { sku: { $ilike: like } },
159
175
  { handle: { $ilike: like } },
160
176
  { description: { $ilike: like } },
161
- ]
177
+ ];
162
178
  }
163
179
  if (query.status && query.status.trim()) {
164
- filters.status_entry_id = { $eq: query.status.trim() }
180
+ filters.status_entry_id = { $eq: query.status.trim() };
165
181
  }
166
- const active = parseBooleanFlag(query.isActive)
182
+ const active = parseBooleanFlag(query.isActive);
167
183
  if (active !== undefined) {
168
- filters.is_active = active
184
+ filters.is_active = active;
169
185
  }
170
- const configurable = parseBooleanFlag(query.configurable)
186
+ const configurable = parseBooleanFlag(query.configurable);
171
187
  if (configurable !== undefined) {
172
- filters.is_configurable = configurable
188
+ filters.is_configurable = configurable;
173
189
  }
174
190
  if (query.productType) {
175
- filters.product_type = { $eq: query.productType }
191
+ filters.product_type = { $eq: query.productType };
176
192
  }
177
- const channelFilterIds = parseIdList(query.channelIds)
193
+ const scope = {
194
+ organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
195
+ tenantId: ctx.auth?.tenantId ?? null,
196
+ };
197
+
198
+ const channelFilterIds = parseIdList(query.channelIds);
178
199
  if (channelFilterIds.length) {
179
- const offerRows = await em.find(
200
+ const offerRows = await findWithDecryption(
201
+ em,
180
202
  CatalogOffer,
181
203
  {
182
204
  channelId: { $in: channelFilterIds },
183
205
  deletedAt: null,
206
+ ...scope,
184
207
  },
185
- { fields: ['id', 'product'] }
186
- )
208
+ { fields: ["id", "product"] },
209
+ scope,
210
+ );
187
211
  const productIds = offerRows
188
- .map((offer) => (typeof offer.product === 'string' ? offer.product : offer.product?.id ?? null))
189
- .filter((id): id is string => !!id)
190
- intersectProductIds(productIds)
212
+ .map((offer) =>
213
+ typeof offer.product === "string"
214
+ ? offer.product
215
+ : (offer.product?.id ?? null),
216
+ )
217
+ .filter((id): id is string => !!id);
218
+ intersectProductIds(productIds);
191
219
  }
192
220
 
193
- const categoryFilterIds = parseIdList(query.categoryIds)
221
+ const categoryFilterIds = parseIdList(query.categoryIds);
194
222
  if (categoryFilterIds.length) {
195
- const assignments = await em.find(
223
+ const assignments = await findWithDecryption(
224
+ em,
196
225
  CatalogProductCategoryAssignment,
197
- { category: { $in: categoryFilterIds } },
198
- { fields: ['id', 'product'] }
199
- )
226
+ { category: { $in: categoryFilterIds }, ...scope },
227
+ { fields: ["id", "product"] },
228
+ scope,
229
+ );
200
230
  const productIds = assignments
201
231
  .map((assignment) =>
202
- typeof assignment.product === 'string' ? assignment.product : assignment.product?.id ?? null
232
+ typeof assignment.product === "string"
233
+ ? assignment.product
234
+ : (assignment.product?.id ?? null),
203
235
  )
204
- .filter((id): id is string => !!id)
205
- intersectProductIds(productIds)
236
+ .filter((id): id is string => !!id);
237
+ intersectProductIds(productIds);
206
238
  }
207
239
 
208
- const tagFilterIds = parseIdList(query.tagIds)
240
+ const tagFilterIds = parseIdList(query.tagIds);
209
241
  if (tagFilterIds.length) {
210
- const assignments = await em.find(
242
+ const assignments = await findWithDecryption(
243
+ em,
211
244
  CatalogProductTagAssignment,
212
- { tag: { $in: tagFilterIds } },
213
- { fields: ['id', 'product'] }
214
- )
245
+ { tag: { $in: tagFilterIds }, ...scope },
246
+ { fields: ["id", "product"] },
247
+ scope,
248
+ );
215
249
  const productIds = assignments
216
250
  .map((assignment) =>
217
- typeof assignment.product === 'string' ? assignment.product : assignment.product?.id ?? null
251
+ typeof assignment.product === "string"
252
+ ? assignment.product
253
+ : (assignment.product?.id ?? null),
218
254
  )
219
- .filter((id): id is string => !!id)
220
- intersectProductIds(productIds)
255
+ .filter((id): id is string => !!id);
256
+ intersectProductIds(productIds);
221
257
  }
222
258
  const customFieldset =
223
- typeof query.customFieldset === 'string' && query.customFieldset.trim().length
259
+ typeof query.customFieldset === "string" &&
260
+ query.customFieldset.trim().length
224
261
  ? query.customFieldset.trim()
225
- : null
226
- const tenantId = ctx.auth?.tenantId ?? null
262
+ : null;
263
+ const tenantId = ctx.auth?.tenantId ?? null;
227
264
  try {
228
- const scopedEm = ctx.container.resolve('em') as EntityManager
265
+ const scopedEm = ctx.container.resolve("em") as EntityManager;
229
266
  const cfFilters = await buildCustomFieldFiltersFromQuery({
230
267
  entityIds: [E.catalog.catalog_product],
231
268
  query,
232
269
  em: scopedEm,
233
270
  tenantId,
234
271
  fieldset: customFieldset ?? undefined,
235
- })
236
- Object.assign(filters, cfFilters)
237
- } catch {
238
- // ignore custom field filter errors; fall back to base filters
272
+ });
273
+ Object.assign(filters, cfFilters);
274
+ } catch (err) {
275
+ // Custom field filter parsing may fail for non-existent or misconfigured fields.
276
+ // Fall back to base filters to avoid blocking the product listing.
277
+ if (process.env.NODE_ENV === 'development') console.warn('[catalog:products] custom field filter error', err);
239
278
  }
240
- applyRestrictedProducts()
241
- return filters
279
+ applyRestrictedProducts();
280
+ return filters;
242
281
  }
243
282
 
244
- export function buildPricingContext(query: ProductsQuery, channelFallback: string | null): PricingContext {
245
- const quantity = Number.isFinite(Number(query.quantity)) ? Number(query.quantity) : 1
246
- const parsedDate = query.priceDate ? new Date(query.priceDate) : new Date()
247
- const channelId = query.channelId ?? channelFallback ?? null
283
+ export function buildPricingContext(
284
+ query: ProductsQuery,
285
+ channelFallback: string | null,
286
+ ): PricingContext {
287
+ const quantity = Number.isFinite(Number(query.quantity))
288
+ ? Number(query.quantity)
289
+ : 1;
290
+ const parsedDate = query.priceDate ? new Date(query.priceDate) : new Date();
291
+ const channelId = query.channelId ?? channelFallback ?? null;
248
292
  return {
249
293
  channelId,
250
294
  offerId: query.offerId ?? null,
@@ -254,267 +298,397 @@ export function buildPricingContext(query: ProductsQuery, channelFallback: strin
254
298
  customerGroupId: query.customerGroupId ?? null,
255
299
  quantity: Number.isFinite(quantity) && quantity > 0 ? quantity : 1,
256
300
  date: Number.isNaN(parsedDate.getTime()) ? new Date() : parsedDate,
257
- }
301
+ };
258
302
  }
259
303
 
260
-
261
304
  type ProductListItem = Record<string, unknown> & {
262
- id?: string
263
- title?: string | null
264
- subtitle?: string | null
265
- description?: string | null
266
- sku?: string | null
267
- handle?: string | null
268
- product_type?: CatalogProductType | null
269
- primary_currency_code?: string | null
270
- default_unit?: string | null
271
- default_media_id?: string | null
272
- default_media_url?: string | null
273
- weight_value?: string | null
274
- weightValue?: string | null
275
- weight_unit?: string | null
276
- weightUnit?: string | null
277
- dimensions?: Record<string, unknown> | null
278
- custom_fieldset_code?: string | null
279
- option_schema_id?: string | null
280
- offers?: Array<Record<string, unknown>>
281
- channelIds?: string[]
282
- categories?: Array<Record<string, unknown>>
283
- categoryIds?: string[]
284
- tags?: string[]
285
- }
305
+ id?: string;
306
+ title?: string | null;
307
+ subtitle?: string | null;
308
+ description?: string | null;
309
+ sku?: string | null;
310
+ handle?: string | null;
311
+ product_type?: CatalogProductType | null;
312
+ primary_currency_code?: string | null;
313
+ default_unit?: string | null;
314
+ default_sales_unit?: string | null;
315
+ default_sales_unit_quantity?: number | null;
316
+ uom_rounding_scale?: number | null;
317
+ uom_rounding_mode?: "half_up" | "down" | "up" | null;
318
+ unit_price_enabled?: boolean | null;
319
+ unit_price_reference_unit?: "kg" | "l" | "m2" | "m3" | "pc" | null;
320
+ unit_price_base_quantity?: number | null;
321
+ default_media_id?: string | null;
322
+ default_media_url?: string | null;
323
+ weight_value?: string | null;
324
+ weightValue?: string | null;
325
+ weight_unit?: string | null;
326
+ weightUnit?: string | null;
327
+ dimensions?: Record<string, unknown> | null;
328
+ custom_fieldset_code?: string | null;
329
+ option_schema_id?: string | null;
330
+ offers?: Array<Record<string, unknown>>;
331
+ channelIds?: string[];
332
+ categories?: Array<Record<string, unknown>>;
333
+ categoryIds?: string[];
334
+ tags?: string[];
335
+ };
286
336
 
287
337
  async function decorateProductsAfterList(
288
338
  payload: { items?: ProductListItem[] },
289
- ctx: CrudCtx & { query: ProductsQuery }
339
+ ctx: CrudCtx & { query: ProductsQuery },
290
340
  ): Promise<void> {
291
- const items = Array.isArray(payload?.items) ? payload.items : []
292
- if (!items.length) return
341
+ const items = Array.isArray(payload?.items) ? payload.items : [];
342
+ if (!items.length) return;
293
343
  const productIds = items
294
- .map((item) => (typeof item.id === 'string' ? item.id : null))
295
- .filter((id): id is string => !!id)
296
- if (!productIds.length) return
297
- const em = (ctx.container.resolve('em') as EntityManager).fork()
298
- const offers = await em.find(
299
- CatalogOffer,
300
- { product: { $in: productIds }, deletedAt: null },
301
- { orderBy: { createdAt: 'asc' } }
302
- )
303
- const channelIds = Array.from(
304
- new Set(
305
- offers
306
- .map((offer) => offer.channelId)
307
- .filter((id): id is string => typeof id === 'string' && id.length > 0)
308
- )
309
- )
310
- const channelLookup = new Map<string, { name?: string | null; code?: string | null }>()
311
- if (channelIds.length) {
312
- const scopedChannelsWhere = buildScopedWhere(
313
- { id: { $in: channelIds } },
314
- {
315
- organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
316
- organizationIds: Array.isArray(ctx.organizationIds) ? ctx.organizationIds : undefined,
317
- tenantId: ctx.auth?.tenantId ?? null,
344
+ .map((item) => (typeof item.id === "string" ? item.id : null))
345
+ .filter((id): id is string => !!id);
346
+ if (!productIds.length) return;
347
+ try {
348
+ const em = (ctx.container.resolve("em") as EntityManager).fork();
349
+ const scope = {
350
+ organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
351
+ tenantId: ctx.auth?.tenantId ?? null,
352
+ };
353
+ const offers = await findWithDecryption(
354
+ em,
355
+ CatalogOffer,
356
+ { product: { $in: productIds }, deletedAt: null, ...scope },
357
+ { orderBy: { createdAt: "asc" } },
358
+ scope,
359
+ );
360
+ const channelIds = Array.from(
361
+ new Set(
362
+ offers
363
+ .map((offer) => offer.channelId)
364
+ .filter(
365
+ (id): id is string => typeof id === "string" && id.length > 0,
366
+ ),
367
+ ),
368
+ );
369
+ const channelLookup = new Map<
370
+ string,
371
+ { name?: string | null; code?: string | null }
372
+ >();
373
+ if (channelIds.length) {
374
+ const scopedChannelsWhere = buildScopedWhere(
375
+ { id: { $in: channelIds } },
376
+ {
377
+ organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
378
+ organizationIds: Array.isArray(ctx.organizationIds)
379
+ ? ctx.organizationIds
380
+ : undefined,
381
+ tenantId: ctx.auth?.tenantId ?? null,
382
+ },
383
+ );
384
+ const channels = await findWithDecryption(em, SalesChannel, scopedChannelsWhere, {
385
+ fields: ["id", "name", "code"],
386
+ });
387
+ for (const channel of channels) {
388
+ channelLookup.set(channel.id, {
389
+ name: channel.name,
390
+ code: channel.code ?? null,
391
+ });
318
392
  }
319
- )
320
- const channels = await em.find(
321
- SalesChannel,
322
- scopedChannelsWhere,
323
- { fields: ['id', 'name', 'code'] }
324
- )
325
- for (const channel of channels) {
326
- channelLookup.set(channel.id, {
327
- name: channel.name,
328
- code: channel.code ?? null,
329
- })
330
393
  }
331
- }
332
- const offersByProduct = new Map<string, Array<Record<string, unknown>>>()
333
- for (const offer of offers) {
334
- const productId =
335
- typeof offer.product === 'string' ? offer.product : offer.product?.id ?? null
336
- if (!productId) continue
337
- const channelInfo = channelLookup.get(offer.channelId)
338
- const entry = offersByProduct.get(productId) ?? []
339
- entry.push({
340
- id: offer.id,
341
- channelId: offer.channelId,
342
- channelName: channelInfo?.name ?? null,
343
- channelCode: channelInfo?.code ?? null,
344
- title: offer.title,
345
- description: offer.description ?? null,
346
- isActive: offer.isActive,
347
- defaultMediaId: offer.defaultMediaId ?? null,
348
- defaultMediaUrl: offer.defaultMediaUrl ?? null,
349
- metadata: offer.metadata ?? null,
350
- })
351
- offersByProduct.set(productId, entry)
352
- }
353
-
354
- const categoryAssignments = await em.find(
355
- CatalogProductCategoryAssignment,
356
- { product: { $in: productIds } },
357
- { populate: ['category'], orderBy: { position: 'asc' } }
358
- )
359
- const parentIds = new Set<string>()
360
- for (const assignment of categoryAssignments) {
361
- const category =
362
- typeof assignment.category === 'string' ? null : assignment.category ?? null
363
- if (!category) continue
364
- const parentId = category.parentId ?? null
365
- if (parentId) parentIds.add(parentId)
366
- }
367
- const parentCategories = parentIds.size
368
- ? await em.find(
369
- CatalogProductCategory,
370
- { id: { $in: Array.from(parentIds) } },
371
- { fields: ['id', 'name'] }
372
- )
373
- : []
374
- const parentNameById = new Map<string, string | null>()
375
- for (const parent of parentCategories) {
376
- parentNameById.set(parent.id, parent.name ?? null)
377
- }
378
- const categoriesByProduct = new Map<
379
- string,
380
- Array<{ id: string; name: string | null; treePath: string | null; parentId: string | null; parentName: string | null }>
381
- >()
382
- for (const assignment of categoryAssignments) {
383
- const productId =
384
- typeof assignment.product === 'string' ? assignment.product : assignment.product?.id ?? null
385
- if (!productId) continue
386
- const category =
387
- typeof assignment.category === 'string' ? null : assignment.category ?? null
388
- if (!category) continue
389
- const parentId = category.parentId ?? null
390
- const parentName = parentId ? parentNameById.get(parentId) ?? null : null
391
- const bucket = categoriesByProduct.get(productId) ?? []
392
- bucket.push({
393
- id: category.id,
394
- name: category.name ?? null,
395
- treePath: category.treePath ?? null,
396
- parentId,
397
- parentName,
398
- })
399
- categoriesByProduct.set(productId, bucket)
400
- }
394
+ const offersByProduct = new Map<string, Array<Record<string, unknown>>>();
395
+ for (const offer of offers) {
396
+ const productId =
397
+ typeof offer.product === "string"
398
+ ? offer.product
399
+ : (offer.product?.id ?? null);
400
+ if (!productId) continue;
401
+ const channelInfo = channelLookup.get(offer.channelId);
402
+ const entry = offersByProduct.get(productId) ?? [];
403
+ entry.push({
404
+ id: offer.id,
405
+ channelId: offer.channelId,
406
+ channelName: channelInfo?.name ?? null,
407
+ channelCode: channelInfo?.code ?? null,
408
+ title: offer.title,
409
+ description: offer.description ?? null,
410
+ isActive: offer.isActive,
411
+ defaultMediaId: offer.defaultMediaId ?? null,
412
+ defaultMediaUrl: offer.defaultMediaUrl ?? null,
413
+ metadata: offer.metadata ?? null,
414
+ });
415
+ offersByProduct.set(productId, entry);
416
+ }
401
417
 
402
- const tagAssignments = await findWithDecryption(
403
- em,
404
- CatalogProductTagAssignment,
405
- { product: { $in: productIds } },
406
- { populate: ['tag'] },
407
- { tenantId: ctx.auth?.tenantId ?? null, organizationId: ctx.auth?.orgId ?? null },
408
- )
409
- const tagsByProduct = new Map<string, string[]>()
410
- for (const assignment of tagAssignments) {
411
- const productId =
412
- typeof assignment.product === 'string' ? assignment.product : assignment.product?.id ?? null
413
- if (!productId) continue
414
- const tag =
415
- typeof assignment.tag === 'string' ? null : assignment.tag ?? null
416
- if (!tag) continue
417
- const label = typeof tag.label === 'string' && tag.label.trim().length ? tag.label : null
418
- if (!label) continue
419
- const bucket = tagsByProduct.get(productId) ?? []
420
- bucket.push(label)
421
- tagsByProduct.set(productId, bucket)
422
- }
418
+ const categoryAssignments = await findWithDecryption(
419
+ em,
420
+ CatalogProductCategoryAssignment,
421
+ { product: { $in: productIds }, ...scope },
422
+ { populate: ["category"], orderBy: { position: "asc" } },
423
+ scope,
424
+ );
425
+ const parentIds = new Set<string>();
426
+ for (const assignment of categoryAssignments) {
427
+ const category =
428
+ typeof assignment.category === "string"
429
+ ? null
430
+ : (assignment.category ?? null);
431
+ if (!category) continue;
432
+ const parentId = category.parentId ?? null;
433
+ if (parentId) parentIds.add(parentId);
434
+ }
435
+ const parentCategories = parentIds.size
436
+ ? await findWithDecryption(
437
+ em,
438
+ CatalogProductCategory,
439
+ { id: { $in: Array.from(parentIds) }, ...scope },
440
+ { fields: ["id", "name"] },
441
+ scope,
442
+ )
443
+ : [];
444
+ const parentNameById = new Map<string, string | null>();
445
+ for (const parent of parentCategories) {
446
+ parentNameById.set(parent.id, parent.name ?? null);
447
+ }
448
+ const categoriesByProduct = new Map<
449
+ string,
450
+ Array<{
451
+ id: string;
452
+ name: string | null;
453
+ treePath: string | null;
454
+ parentId: string | null;
455
+ parentName: string | null;
456
+ }>
457
+ >();
458
+ for (const assignment of categoryAssignments) {
459
+ const productId =
460
+ typeof assignment.product === "string"
461
+ ? assignment.product
462
+ : (assignment.product?.id ?? null);
463
+ if (!productId) continue;
464
+ const category =
465
+ typeof assignment.category === "string"
466
+ ? null
467
+ : (assignment.category ?? null);
468
+ if (!category) continue;
469
+ const parentId = category.parentId ?? null;
470
+ const parentName = parentId
471
+ ? (parentNameById.get(parentId) ?? null)
472
+ : null;
473
+ const bucket = categoriesByProduct.get(productId) ?? [];
474
+ bucket.push({
475
+ id: category.id,
476
+ name: category.name ?? null,
477
+ treePath: category.treePath ?? null,
478
+ parentId,
479
+ parentName,
480
+ });
481
+ categoriesByProduct.set(productId, bucket);
482
+ }
423
483
 
424
- const variants = await em.find(
425
- CatalogProductVariant,
426
- { product: { $in: productIds }, deletedAt: null },
427
- { fields: ['id', 'product'] }
428
- )
429
- const variantToProduct = new Map<string, string>()
430
- for (const variant of variants) {
431
- const productId =
432
- typeof variant.product === 'string' ? variant.product : variant.product?.id ?? null
433
- if (!productId) continue
434
- variantToProduct.set(variant.id, productId)
435
- }
436
- const variantIds = Array.from(variantToProduct.keys())
437
- const priceWhere =
438
- variantIds.length > 0
439
- ? {
440
- $or: [{ product: { $in: productIds } }, { variant: { $in: variantIds } }],
441
- }
442
- : { product: { $in: productIds } }
443
- const priceRows = await em.find(
444
- CatalogProductPrice,
445
- priceWhere,
446
- { populate: ['offer', 'variant', 'product', 'priceKind'] }
447
- )
448
- const pricesByProduct = new Map<string, PriceRow[]>()
449
- for (const price of priceRows) {
450
- let productId: string | null = null
451
- if (price.product) {
452
- productId =
453
- typeof price.product === 'string' ? price.product : price.product?.id ?? null
454
- } else if (price.variant) {
455
- const variantId = typeof price.variant === 'string' ? price.variant : price.variant.id
456
- productId = variantToProduct.get(variantId) ?? null
484
+ const tagAssignments = await findWithDecryption(
485
+ em,
486
+ CatalogProductTagAssignment,
487
+ { product: { $in: productIds } },
488
+ { populate: ["tag"] },
489
+ {
490
+ tenantId: ctx.auth?.tenantId ?? null,
491
+ organizationId: ctx.auth?.orgId ?? null,
492
+ },
493
+ );
494
+ const tagsByProduct = new Map<string, string[]>();
495
+ for (const assignment of tagAssignments) {
496
+ const productId =
497
+ typeof assignment.product === "string"
498
+ ? assignment.product
499
+ : (assignment.product?.id ?? null);
500
+ if (!productId) continue;
501
+ const tag =
502
+ typeof assignment.tag === "string" ? null : (assignment.tag ?? null);
503
+ if (!tag) continue;
504
+ const label =
505
+ typeof tag.label === "string" && tag.label.trim().length
506
+ ? tag.label
507
+ : null;
508
+ if (!label) continue;
509
+ const bucket = tagsByProduct.get(productId) ?? [];
510
+ bucket.push(label);
511
+ tagsByProduct.set(productId, bucket);
457
512
  }
458
- if (!productId) continue
459
- const entry = pricesByProduct.get(productId) ?? []
460
- entry.push(price)
461
- pricesByProduct.set(productId, entry)
462
- }
463
513
 
464
- const channelFilterIds = parseIdList(ctx.query.channelIds)
465
- const channelContext =
466
- ctx.query.channelId ?? (channelFilterIds.length === 1 ? channelFilterIds[0] : null)
467
- const pricingContext = buildPricingContext(ctx.query, channelContext)
468
- const pricingService = ctx.container.resolve<CatalogPricingService>('catalogPricingService')
514
+ const variants = await findWithDecryption(
515
+ em,
516
+ CatalogProductVariant,
517
+ { product: { $in: productIds }, deletedAt: null, ...scope },
518
+ { fields: ["id", "product"] },
519
+ scope,
520
+ );
521
+ const variantToProduct = new Map<string, string>();
522
+ for (const variant of variants) {
523
+ const productId =
524
+ typeof variant.product === "string"
525
+ ? variant.product
526
+ : (variant.product?.id ?? null);
527
+ if (!productId) continue;
528
+ variantToProduct.set(variant.id, productId);
529
+ }
530
+ const variantIds = Array.from(variantToProduct.keys());
531
+ const priceWhere =
532
+ variantIds.length > 0
533
+ ? {
534
+ $or: [
535
+ { product: { $in: productIds } },
536
+ { variant: { $in: variantIds } },
537
+ ],
538
+ }
539
+ : { product: { $in: productIds } };
540
+ const priceRows = await findWithDecryption(
541
+ em,
542
+ CatalogProductPrice,
543
+ { ...priceWhere, ...scope },
544
+ { populate: ["offer", "variant", "product", "priceKind"] },
545
+ scope,
546
+ );
547
+ const pricesByProduct = new Map<string, PriceRow[]>();
548
+ for (const price of priceRows) {
549
+ let productId: string | null = null;
550
+ if (price.product) {
551
+ productId =
552
+ typeof price.product === "string"
553
+ ? price.product
554
+ : (price.product?.id ?? null);
555
+ } else if (price.variant) {
556
+ const variantId =
557
+ typeof price.variant === "string" ? price.variant : price.variant.id;
558
+ productId = variantToProduct.get(variantId) ?? null;
559
+ }
560
+ if (!productId) continue;
561
+ const entry = pricesByProduct.get(productId) ?? [];
562
+ entry.push(price);
563
+ pricesByProduct.set(productId, entry);
564
+ }
469
565
 
470
- for (const item of items) {
471
- const id = typeof item.id === 'string' ? item.id : null
472
- if (!id) continue
473
- const offerEntries = offersByProduct.get(id) ?? []
474
- item.offers = offerEntries
475
- const channelIds = Array.from(
476
- new Set(
477
- offerEntries
478
- .map((offer) => (typeof offer.channelId === 'string' ? offer.channelId : null))
479
- .filter((channelId): channelId is string => !!channelId)
480
- )
481
- )
482
- item.channelIds = channelIds
483
- const categories = categoriesByProduct.get(id) ?? []
484
- item.categories = categories
485
- item.categoryIds = categories.map((category) => category.id)
486
- item.tags = tagsByProduct.get(id) ?? []
487
- const priceCandidates = pricesByProduct.get(id) ?? []
488
- const channelScopedContext =
489
- pricingContext.channelId || channelIds.length !== 1
490
- ? pricingContext
491
- : { ...pricingContext, channelId: channelIds[0] }
492
- const best = await pricingService.resolvePrice(priceCandidates, channelScopedContext)
493
- if (best) {
494
- item.pricing = {
495
- kind: resolvePriceKindCode(best),
496
- price_kind_id: typeof best.priceKind === 'string' ? best.priceKind : best.priceKind?.id ?? null,
497
- price_kind_code: resolvePriceKindCode(best),
498
- currency_code: best.currencyCode,
499
- unit_price_net: best.unitPriceNet,
500
- unit_price_gross: best.unitPriceGross,
501
- min_quantity: best.minQuantity,
502
- max_quantity: best.maxQuantity ?? null,
503
- tax_rate: best.taxRate ?? null,
504
- tax_amount: best.taxAmount ?? null,
505
- scope: {
506
- variant_id: resolvePriceVariantId(best),
507
- offer_id: resolvePriceOfferId(best),
508
- channel_id: resolvePriceChannelId(best),
509
- user_id: best.userId ?? null,
510
- user_group_id: best.userGroupId ?? null,
511
- customer_id: best.customerId ?? null,
512
- customer_group_id: best.customerGroupId ?? null,
566
+ const requestQuantityUnitKey = toUnitLookupKey(
567
+ ctx.query.quantityUnit,
568
+ );
569
+ const conversionsByProduct = new Map<string, Map<string, number>>();
570
+ const conversionOrganizationId =
571
+ ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null;
572
+ const conversionTenantId = ctx.auth?.tenantId ?? null;
573
+ if (
574
+ requestQuantityUnitKey &&
575
+ productIds.length &&
576
+ conversionOrganizationId &&
577
+ conversionTenantId
578
+ ) {
579
+ const conversionRows = await findWithDecryption(
580
+ em,
581
+ CatalogProductUnitConversion,
582
+ {
583
+ product: { $in: productIds },
584
+ organizationId: conversionOrganizationId,
585
+ tenantId: conversionTenantId,
586
+ deletedAt: null,
587
+ isActive: true,
513
588
  },
589
+ { fields: ["id", "product", "unitCode", "toBaseFactor"] },
590
+ { organizationId: conversionOrganizationId, tenantId: conversionTenantId },
591
+ );
592
+ for (const row of conversionRows) {
593
+ const productId =
594
+ typeof row.product === "string"
595
+ ? row.product
596
+ : (row.product?.id ?? null);
597
+ const unitKey = toUnitLookupKey(row.unitCode);
598
+ const factor = Number(row.toBaseFactor);
599
+ if (!productId || !unitKey || !Number.isFinite(factor) || factor <= 0)
600
+ continue;
601
+ const bucket =
602
+ conversionsByProduct.get(productId) ?? new Map<string, number>();
603
+ bucket.set(unitKey, factor);
604
+ conversionsByProduct.set(productId, bucket);
514
605
  }
515
- } else {
516
- item.pricing = null
517
606
  }
607
+
608
+ const channelFilterIds = parseIdList(ctx.query.channelIds);
609
+ const channelContext =
610
+ ctx.query.channelId ??
611
+ (channelFilterIds.length === 1 ? channelFilterIds[0] : null);
612
+ const pricingContext = buildPricingContext(ctx.query, channelContext);
613
+ const pricingService = ctx.container.resolve<CatalogPricingService>(
614
+ "catalogPricingService",
615
+ );
616
+
617
+ for (const item of items) {
618
+ const id = typeof item.id === "string" ? item.id : null;
619
+ if (!id) continue;
620
+ const offerEntries = offersByProduct.get(id) ?? [];
621
+ item.offers = offerEntries;
622
+ const channelIds = Array.from(
623
+ new Set(
624
+ offerEntries
625
+ .map((offer) =>
626
+ typeof offer.channelId === "string" ? offer.channelId : null,
627
+ )
628
+ .filter((channelId): channelId is string => !!channelId),
629
+ ),
630
+ );
631
+ item.channelIds = channelIds;
632
+ const categories = categoriesByProduct.get(id) ?? [];
633
+ item.categories = categories;
634
+ item.categoryIds = categories.map((category) => category.id);
635
+ item.tags = tagsByProduct.get(id) ?? [];
636
+ const priceCandidates = pricesByProduct.get(id) ?? [];
637
+ const normalizedQuantityForPricing = (() => {
638
+ if (!requestQuantityUnitKey) return pricingContext.quantity;
639
+ const baseUnit = toUnitLookupKey(item.default_unit);
640
+ if (!baseUnit || requestQuantityUnitKey === baseUnit)
641
+ return pricingContext.quantity;
642
+ const productConversions = conversionsByProduct.get(id);
643
+ const factor = productConversions?.get(requestQuantityUnitKey) ?? null;
644
+ if (!factor || !Number.isFinite(factor) || factor <= 0) {
645
+ if (process.env.NODE_ENV === 'development') console.warn(`[catalog.products] Invalid conversion factor for product=${id} unit=${requestQuantityUnitKey} factor=${factor}`);
646
+ return pricingContext.quantity;
647
+ }
648
+ const normalized = pricingContext.quantity * factor;
649
+ return Number.isFinite(normalized) && normalized > 0
650
+ ? normalized
651
+ : pricingContext.quantity;
652
+ })();
653
+ const channelScopedContext =
654
+ pricingContext.channelId || channelIds.length !== 1
655
+ ? pricingContext
656
+ : { ...pricingContext, channelId: channelIds[0] };
657
+ const best = await pricingService.resolvePrice(priceCandidates, {
658
+ ...channelScopedContext,
659
+ quantity: normalizedQuantityForPricing,
660
+ });
661
+ if (best) {
662
+ item.pricing = {
663
+ kind: resolvePriceKindCode(best),
664
+ price_kind_id:
665
+ typeof best.priceKind === "string"
666
+ ? best.priceKind
667
+ : (best.priceKind?.id ?? null),
668
+ price_kind_code: resolvePriceKindCode(best),
669
+ currency_code: best.currencyCode,
670
+ unit_price_net: best.unitPriceNet,
671
+ unit_price_gross: best.unitPriceGross,
672
+ min_quantity: best.minQuantity,
673
+ max_quantity: best.maxQuantity ?? null,
674
+ tax_rate: best.taxRate ?? null,
675
+ tax_amount: best.taxAmount ?? null,
676
+ scope: {
677
+ variant_id: resolvePriceVariantId(best),
678
+ offer_id: resolvePriceOfferId(best),
679
+ channel_id: resolvePriceChannelId(best),
680
+ user_id: best.userId ?? null,
681
+ user_group_id: best.userGroupId ?? null,
682
+ customer_id: best.customerId ?? null,
683
+ customer_group_id: best.customerGroupId ?? null,
684
+ },
685
+ };
686
+ } else {
687
+ item.pricing = null;
688
+ }
689
+ }
690
+ } catch (error) {
691
+ console.error("[decorateProductsAfterList] Failed to load unit conversions", error);
518
692
  }
519
693
  }
520
694
 
@@ -522,10 +696,10 @@ const crud = makeCrudRoute({
522
696
  metadata: routeMetadata,
523
697
  orm: {
524
698
  entity: CatalogProduct,
525
- idField: 'id',
526
- orgField: 'organizationId',
527
- tenantField: 'tenantId',
528
- softDeleteField: 'deletedAt',
699
+ idField: "id",
700
+ orgField: "organizationId",
701
+ tenantField: "tenantId",
702
+ softDeleteField: "deletedAt",
529
703
  },
530
704
  indexer: {
531
705
  entityType: E.catalog.catalog_product,
@@ -540,12 +714,19 @@ const crud = makeCrudRoute({
540
714
  F.description,
541
715
  F.sku,
542
716
  F.handle,
543
- 'tax_rate_id',
544
- 'tax_rate',
717
+ "tax_rate_id",
718
+ "tax_rate",
545
719
  F.product_type,
546
720
  F.status_entry_id,
547
721
  F.primary_currency_code,
548
722
  F.default_unit,
723
+ "default_sales_unit",
724
+ "default_sales_unit_quantity",
725
+ "uom_rounding_scale",
726
+ "uom_rounding_mode",
727
+ "unit_price_enabled",
728
+ "unit_price_reference_unit",
729
+ "unit_price_base_quantity",
549
730
  F.default_media_id,
550
731
  F.default_media_url,
551
732
  F.weight_value,
@@ -554,8 +735,8 @@ const crud = makeCrudRoute({
554
735
  F.is_configurable,
555
736
  F.is_active,
556
737
  F.metadata,
557
- 'custom_fieldset_code',
558
- 'option_schema_id',
738
+ "custom_fieldset_code",
739
+ "option_schema_id",
559
740
  F.created_at,
560
741
  F.updated_at,
561
742
  ],
@@ -568,15 +749,31 @@ const crud = makeCrudRoute({
568
749
  },
569
750
  buildFilters: buildProductFilters,
570
751
  transformItem: (item: ProductListItem | null | undefined) => {
571
- if (!item) return item
572
- const normalized = { ...item }
573
- const cfEntries = extractAllCustomFieldEntries(item)
752
+ if (!item) return item;
753
+ const normalized = { ...item };
754
+ const cfEntries = extractAllCustomFieldEntries(item);
574
755
  for (const key of Object.keys(normalized)) {
575
- if (key.startsWith('cf:')) {
576
- delete normalized[key]
756
+ if (key.startsWith("cf:")) {
757
+ delete normalized[key];
577
758
  }
578
759
  }
579
- return { ...normalized, ...cfEntries }
760
+ const defaultUnit = canonicalizeUnitCode(normalized.default_unit) ?? null;
761
+ const defaultSalesUnit =
762
+ canonicalizeUnitCode(normalized.default_sales_unit) ?? null;
763
+ const unitPriceReferenceUnit =
764
+ canonicalizeUnitCode(normalized.unit_price_reference_unit) ?? null;
765
+ return {
766
+ ...normalized,
767
+ default_unit: defaultUnit,
768
+ default_sales_unit: defaultSalesUnit,
769
+ unit_price_reference_unit: unitPriceReferenceUnit,
770
+ ...cfEntries,
771
+ unit_price: {
772
+ enabled: Boolean(normalized.unit_price_enabled),
773
+ reference_unit: unitPriceReferenceUnit,
774
+ base_quantity: normalized.unit_price_base_quantity ?? null,
775
+ },
776
+ };
580
777
  },
581
778
  },
582
779
  hooks: {
@@ -584,46 +781,68 @@ const crud = makeCrudRoute({
584
781
  },
585
782
  actions: {
586
783
  create: {
587
- commandId: 'catalog.products.create',
784
+ commandId: "catalog.products.create",
588
785
  schema: rawBodySchema,
589
786
  mapInput: async ({ raw, ctx }) => {
590
- const { translate } = await resolveTranslations()
591
- const parsed = parseScopedCommandInput(productCreateSchema, raw ?? {}, ctx, translate)
592
- const { base, custom } = splitCustomFieldPayload(parsed)
593
- return Object.keys(custom).length ? { ...base, customFields: custom } : base
787
+ const { translate } = await resolveTranslations();
788
+ const parsed = parseScopedCommandInput(
789
+ productCreateSchema,
790
+ raw ?? {},
791
+ ctx,
792
+ translate,
793
+ );
794
+ const { base, custom } = splitCustomFieldPayload(parsed);
795
+ return Object.keys(custom).length
796
+ ? { ...base, customFields: custom }
797
+ : base;
594
798
  },
595
- response: ({ result }) => ({ id: result?.productId ?? result?.id ?? null }),
799
+ response: ({ result }) => ({
800
+ id: result?.productId ?? result?.id ?? null,
801
+ }),
596
802
  status: 201,
597
803
  },
598
804
  update: {
599
- commandId: 'catalog.products.update',
805
+ commandId: "catalog.products.update",
600
806
  schema: rawBodySchema,
601
807
  mapInput: async ({ raw, ctx }) => {
602
- const { translate } = await resolveTranslations()
603
- const parsed = parseScopedCommandInput(productUpdateSchema, raw ?? {}, ctx, translate)
604
- const { base, custom } = splitCustomFieldPayload(parsed)
605
- return Object.keys(custom).length ? { ...base, customFields: custom } : base
808
+ const { translate } = await resolveTranslations();
809
+ const parsed = parseScopedCommandInput(
810
+ productUpdateSchema,
811
+ raw ?? {},
812
+ ctx,
813
+ translate,
814
+ );
815
+ const { base, custom } = splitCustomFieldPayload(parsed);
816
+ return Object.keys(custom).length
817
+ ? { ...base, customFields: custom }
818
+ : base;
606
819
  },
607
820
  response: () => ({ ok: true }),
608
821
  },
609
822
  delete: {
610
- commandId: 'catalog.products.delete',
823
+ commandId: "catalog.products.delete",
611
824
  schema: rawBodySchema,
612
825
  mapInput: async ({ parsed, ctx }) => {
613
- const { translate } = await resolveTranslations()
614
- const id = resolveCrudRecordId(parsed, ctx, translate)
615
- if (!id) throw new CrudHttpError(400, { error: translate('catalog.errors.id_required', 'Product id is required.') })
616
- return { id }
826
+ const { translate } = await resolveTranslations();
827
+ const id = resolveCrudRecordId(parsed, ctx, translate);
828
+ if (!id)
829
+ throw new CrudHttpError(400, {
830
+ error: translate(
831
+ "catalog.errors.id_required",
832
+ "Product id is required.",
833
+ ),
834
+ });
835
+ return { id };
617
836
  },
618
837
  response: () => ({ ok: true }),
619
838
  },
620
839
  },
621
- })
840
+ });
622
841
 
623
- export const GET = crud.GET
624
- export const POST = crud.POST
625
- export const PUT = crud.PUT
626
- export const DELETE = crud.DELETE
842
+ export const GET = crud.GET;
843
+ export const POST = crud.POST;
844
+ export const PUT = crud.PUT;
845
+ export const DELETE = crud.DELETE;
627
846
 
628
847
  const productListItemSchema = z.object({
629
848
  id: z.string().uuid(),
@@ -636,6 +855,23 @@ const productListItemSchema = z.object({
636
855
  status_entry_id: z.string().uuid().nullable().optional(),
637
856
  primary_currency_code: z.string().nullable().optional(),
638
857
  default_unit: z.string().nullable().optional(),
858
+ default_sales_unit: z.string().nullable().optional(),
859
+ default_sales_unit_quantity: z.number().nullable().optional(),
860
+ uom_rounding_scale: z.number().nullable().optional(),
861
+ uom_rounding_mode: z.enum(["half_up", "down", "up"]).nullable().optional(),
862
+ unit_price_enabled: z.boolean().nullable().optional(),
863
+ unit_price_reference_unit: z
864
+ .enum(["kg", "l", "m2", "m3", "pc"])
865
+ .nullable()
866
+ .optional(),
867
+ unit_price_base_quantity: z.number().nullable().optional(),
868
+ unit_price: z
869
+ .object({
870
+ enabled: z.boolean(),
871
+ reference_unit: z.enum(["kg", "l", "m2", "m3", "pc"]).nullable(),
872
+ base_quantity: z.number().nullable(),
873
+ })
874
+ .optional(),
639
875
  default_media_id: z.string().uuid().nullable().optional(),
640
876
  default_media_url: z.string().nullable().optional(),
641
877
  weight_value: z.number().nullable().optional(),
@@ -654,25 +890,25 @@ const productListItemSchema = z.object({
654
890
  categoryIds: z.array(z.string()).optional(),
655
891
  tags: z.array(z.string()).optional(),
656
892
  pricing: z.record(z.string(), z.unknown()).nullable().optional(),
657
- })
893
+ });
658
894
 
659
895
  export const openApi = createCatalogCrudOpenApi({
660
- resourceName: 'Product',
661
- pluralName: 'Products',
896
+ resourceName: "Product",
897
+ pluralName: "Products",
662
898
  querySchema: listSchema,
663
899
  listResponseSchema: createPagedListResponseSchema(productListItemSchema),
664
900
  create: {
665
901
  schema: productCreateSchema,
666
- description: 'Creates a new product in the catalog.',
902
+ description: "Creates a new product in the catalog.",
667
903
  },
668
904
  update: {
669
905
  schema: productUpdateSchema,
670
906
  responseSchema: defaultOkResponseSchema,
671
- description: 'Updates an existing product by id.',
907
+ description: "Updates an existing product by id.",
672
908
  },
673
909
  del: {
674
910
  schema: z.object({ id: z.string().uuid() }),
675
911
  responseSchema: defaultOkResponseSchema,
676
- description: 'Deletes a product by id.',
912
+ description: "Deletes a product by id.",
677
913
  },
678
- })
914
+ });