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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/dist/generated/entities/catalog_product/index.js +16 -0
  2. package/dist/generated/entities/catalog_product/index.js.map +2 -2
  3. package/dist/generated/entities/catalog_product_unit_conversion/index.js +27 -0
  4. package/dist/generated/entities/catalog_product_unit_conversion/index.js.map +7 -0
  5. package/dist/generated/entities/sales_credit_memo_line/index.js +7 -1
  6. package/dist/generated/entities/sales_credit_memo_line/index.js.map +2 -2
  7. package/dist/generated/entities/sales_invoice_line/index.js +7 -1
  8. package/dist/generated/entities/sales_invoice_line/index.js.map +2 -2
  9. package/dist/generated/entities/sales_order_line/index.js +6 -0
  10. package/dist/generated/entities/sales_order_line/index.js.map +2 -2
  11. package/dist/generated/entities/sales_quote_line/index.js +6 -0
  12. package/dist/generated/entities/sales_quote_line/index.js.map +2 -2
  13. package/dist/generated/entities.ids.generated.js +1 -0
  14. package/dist/generated/entities.ids.generated.js.map +2 -2
  15. package/dist/generated/entity-fields-registry.js +2 -0
  16. package/dist/generated/entity-fields-registry.js.map +2 -2
  17. package/dist/modules/catalog/api/prices/route.js +123 -8
  18. package/dist/modules/catalog/api/prices/route.js.map +2 -2
  19. package/dist/modules/catalog/api/product-unit-conversions/route.js +194 -0
  20. package/dist/modules/catalog/api/product-unit-conversions/route.js.map +7 -0
  21. package/dist/modules/catalog/api/products/route.js +351 -201
  22. package/dist/modules/catalog/api/products/route.js.map +2 -2
  23. package/dist/modules/catalog/backend/catalog/products/[id]/page.js +1267 -497
  24. package/dist/modules/catalog/backend/catalog/products/[id]/page.js.map +2 -2
  25. package/dist/modules/catalog/backend/catalog/products/create/page.js +733 -210
  26. package/dist/modules/catalog/backend/catalog/products/create/page.js.map +2 -2
  27. package/dist/modules/catalog/commands/index.js +1 -0
  28. package/dist/modules/catalog/commands/index.js.map +2 -2
  29. package/dist/modules/catalog/commands/productUnitConversions.js +503 -0
  30. package/dist/modules/catalog/commands/productUnitConversions.js.map +7 -0
  31. package/dist/modules/catalog/commands/products.js +355 -73
  32. package/dist/modules/catalog/commands/products.js.map +2 -2
  33. package/dist/modules/catalog/commands/shared.js +18 -4
  34. package/dist/modules/catalog/commands/shared.js.map +2 -2
  35. package/dist/modules/catalog/components/products/ProductUomSection.js +591 -0
  36. package/dist/modules/catalog/components/products/ProductUomSection.js.map +7 -0
  37. package/dist/modules/catalog/components/products/productForm.js +66 -5
  38. package/dist/modules/catalog/components/products/productForm.js.map +2 -2
  39. package/dist/modules/catalog/components/products/productFormUtils.js +68 -0
  40. package/dist/modules/catalog/components/products/productFormUtils.js.map +7 -0
  41. package/dist/modules/catalog/data/entities.js +86 -0
  42. package/dist/modules/catalog/data/entities.js.map +2 -2
  43. package/dist/modules/catalog/data/validators.js +65 -3
  44. package/dist/modules/catalog/data/validators.js.map +2 -2
  45. package/dist/modules/catalog/events.js +3 -0
  46. package/dist/modules/catalog/events.js.map +2 -2
  47. package/dist/modules/catalog/lib/unitCodes.js +7 -0
  48. package/dist/modules/catalog/lib/unitCodes.js.map +7 -0
  49. package/dist/modules/catalog/lib/unitResolution.js +53 -0
  50. package/dist/modules/catalog/lib/unitResolution.js.map +7 -0
  51. package/dist/modules/catalog/migrations/Migration20260218225422.js +19 -0
  52. package/dist/modules/catalog/migrations/Migration20260218225422.js.map +7 -0
  53. package/dist/modules/catalog/migrations/Migration20260219084500.js +27 -0
  54. package/dist/modules/catalog/migrations/Migration20260219084500.js.map +7 -0
  55. package/dist/modules/catalog/search.js +69 -1
  56. package/dist/modules/catalog/search.js.map +2 -2
  57. package/dist/modules/catalog/seed/examples.js +91 -42
  58. package/dist/modules/catalog/seed/examples.js.map +2 -2
  59. package/dist/modules/dashboards/seed/analytics.js +3 -0
  60. package/dist/modules/dashboards/seed/analytics.js.map +2 -2
  61. package/dist/modules/sales/api/order-lines/route.js +98 -15
  62. package/dist/modules/sales/api/order-lines/route.js.map +2 -2
  63. package/dist/modules/sales/api/quote-lines/route.js +101 -14
  64. package/dist/modules/sales/api/quote-lines/route.js.map +2 -2
  65. package/dist/modules/sales/api/quotes/public/[token]/route.js +87 -12
  66. package/dist/modules/sales/api/quotes/public/[token]/route.js.map +2 -2
  67. package/dist/modules/sales/commands/documents.js +1424 -260
  68. package/dist/modules/sales/commands/documents.js.map +3 -3
  69. package/dist/modules/sales/commands/shared.js +6 -2
  70. package/dist/modules/sales/commands/shared.js.map +2 -2
  71. package/dist/modules/sales/components/documents/ItemsSection.js +216 -86
  72. package/dist/modules/sales/components/documents/ItemsSection.js.map +2 -2
  73. package/dist/modules/sales/components/documents/LineItemDialog.js +913 -241
  74. package/dist/modules/sales/components/documents/LineItemDialog.js.map +3 -3
  75. package/dist/modules/sales/components/documents/ShipmentsSection.js +15 -3
  76. package/dist/modules/sales/components/documents/ShipmentsSection.js.map +2 -2
  77. package/dist/modules/sales/data/entities.js +59 -3
  78. package/dist/modules/sales/data/entities.js.map +2 -2
  79. package/dist/modules/sales/data/validators.js +35 -0
  80. package/dist/modules/sales/data/validators.js.map +2 -2
  81. package/dist/modules/sales/frontend/quote/[token]/page.js +15 -1
  82. package/dist/modules/sales/frontend/quote/[token]/page.js.map +2 -2
  83. package/dist/modules/sales/migrations/Migration20260218225423.js +31 -0
  84. package/dist/modules/sales/migrations/Migration20260218225423.js.map +7 -0
  85. package/dist/modules/sales/migrations/Migration20260219084501.js +71 -0
  86. package/dist/modules/sales/migrations/Migration20260219084501.js.map +7 -0
  87. package/dist/modules/sales/search.js +28 -0
  88. package/dist/modules/sales/search.js.map +2 -2
  89. package/dist/modules/sales/seed/examples.js +14 -1
  90. package/dist/modules/sales/seed/examples.js.map +2 -2
  91. package/dist/modules/sales/widgets/injection/document-history/widget.client.js +1 -1
  92. package/dist/modules/sales/widgets/injection/document-history/widget.client.js.map +2 -2
  93. package/generated/entities/catalog_product/index.ts +8 -0
  94. package/generated/entities/catalog_product_unit_conversion/index.ts +12 -0
  95. package/generated/entities/sales_credit_memo_line/index.ts +3 -0
  96. package/generated/entities/sales_invoice_line/index.ts +3 -0
  97. package/generated/entities/sales_order_line/index.ts +3 -0
  98. package/generated/entities/sales_quote_line/index.ts +3 -0
  99. package/generated/entities.ids.generated.ts +1 -0
  100. package/generated/entity-fields-registry.ts +2 -0
  101. package/package.json +2 -2
  102. package/src/modules/auth/i18n/de.json +1 -1
  103. package/src/modules/auth/i18n/en.json +1 -1
  104. package/src/modules/auth/i18n/es.json +1 -1
  105. package/src/modules/auth/i18n/pl.json +1 -1
  106. package/src/modules/catalog/api/prices/route.ts +213 -81
  107. package/src/modules/catalog/api/product-unit-conversions/route.ts +195 -0
  108. package/src/modules/catalog/api/products/route.ts +638 -402
  109. package/src/modules/catalog/backend/catalog/products/[id]/page.tsx +2085 -1072
  110. package/src/modules/catalog/backend/catalog/products/create/page.tsx +1288 -593
  111. package/src/modules/catalog/commands/index.ts +1 -0
  112. package/src/modules/catalog/commands/productUnitConversions.ts +626 -0
  113. package/src/modules/catalog/commands/products.ts +1151 -693
  114. package/src/modules/catalog/commands/shared.ts +19 -5
  115. package/src/modules/catalog/components/products/ProductUomSection.tsx +745 -0
  116. package/src/modules/catalog/components/products/productForm.ts +369 -256
  117. package/src/modules/catalog/components/products/productFormUtils.ts +82 -0
  118. package/src/modules/catalog/data/entities.ts +82 -1
  119. package/src/modules/catalog/data/validators.ts +118 -34
  120. package/src/modules/catalog/events.ts +3 -0
  121. package/src/modules/catalog/i18n/de.json +56 -0
  122. package/src/modules/catalog/i18n/en.json +56 -0
  123. package/src/modules/catalog/i18n/es.json +56 -0
  124. package/src/modules/catalog/i18n/pl.json +56 -0
  125. package/src/modules/catalog/lib/unitCodes.ts +1 -0
  126. package/src/modules/catalog/lib/unitResolution.ts +62 -0
  127. package/src/modules/catalog/migrations/.snapshot-open-mercato.json +245 -0
  128. package/src/modules/catalog/migrations/Migration20260218225422.ts +21 -0
  129. package/src/modules/catalog/migrations/Migration20260219084500.ts +26 -0
  130. package/src/modules/catalog/search.ts +73 -1
  131. package/src/modules/catalog/seed/examples.ts +552 -479
  132. package/src/modules/dashboards/i18n/de.json +1 -1
  133. package/src/modules/dashboards/i18n/en.json +1 -1
  134. package/src/modules/dashboards/i18n/es.json +1 -1
  135. package/src/modules/dashboards/i18n/pl.json +1 -1
  136. package/src/modules/dashboards/seed/analytics.ts +3 -0
  137. package/src/modules/sales/api/order-lines/route.ts +158 -68
  138. package/src/modules/sales/api/quote-lines/route.ts +161 -67
  139. package/src/modules/sales/api/quotes/public/[token]/route.ts +122 -36
  140. package/src/modules/sales/commands/documents.ts +4250 -2424
  141. package/src/modules/sales/commands/shared.ts +7 -2
  142. package/src/modules/sales/components/documents/ItemsSection.tsx +580 -310
  143. package/src/modules/sales/components/documents/LineItemDialog.tsx +1988 -833
  144. package/src/modules/sales/components/documents/ShipmentsSection.tsx +17 -3
  145. package/src/modules/sales/components/documents/lineItemTypes.ts +6 -0
  146. package/src/modules/sales/data/entities.ts +53 -0
  147. package/src/modules/sales/data/validators.ts +36 -0
  148. package/src/modules/sales/frontend/quote/[token]/page.tsx +25 -1
  149. package/src/modules/sales/i18n/de.json +23 -3
  150. package/src/modules/sales/i18n/en.json +23 -3
  151. package/src/modules/sales/i18n/es.json +23 -3
  152. package/src/modules/sales/i18n/pl.json +23 -3
  153. package/src/modules/sales/lib/types.ts +30 -0
  154. package/src/modules/sales/migrations/.snapshot-open-mercato.json +172 -0
  155. package/src/modules/sales/migrations/Migration20260218225423.ts +37 -0
  156. package/src/modules/sales/migrations/Migration20260219084501.ts +73 -0
  157. package/src/modules/sales/search.ts +28 -0
  158. package/src/modules/sales/seed/examples.ts +20 -1
  159. package/src/modules/sales/widgets/injection/document-history/widget.client.tsx +1 -1
  160. package/src/modules/workflows/i18n/de.json +4 -4
  161. package/src/modules/workflows/i18n/en.json +4 -4
  162. package/src/modules/workflows/i18n/es.json +4 -4
  163. package/src/modules/workflows/i18n/pl.json +4 -4
@@ -1,57 +1,79 @@
1
- import { z } from 'zod'
2
- import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
3
- import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
4
- import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
5
- import { buildCustomFieldFiltersFromQuery, extractAllCustomFieldEntries } from '@open-mercato/shared/lib/crud/custom-fields'
6
- import { SalesQuoteLine } from '../../data/entities'
7
- import { quoteLineCreateSchema } from '../../data/validators'
8
- import { createPagedListResponseSchema, createSalesCrudOpenApi, defaultOkResponseSchema } from '../openapi'
9
- import { withScopedPayload } from '../utils'
10
- import { E } from '#generated/entities.ids.generated'
11
- import * as F from '#generated/entities/sales_quote_line'
1
+ import { z } from "zod";
2
+ import { makeCrudRoute } from "@open-mercato/shared/lib/crud/factory";
3
+ import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
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 { SalesQuoteLine } from "../../data/entities";
10
+ import { quoteLineCreateSchema } from "../../data/validators";
11
+ import {
12
+ createPagedListResponseSchema,
13
+ createSalesCrudOpenApi,
14
+ defaultOkResponseSchema,
15
+ } from "../openapi";
16
+ import { withScopedPayload } from "../utils";
17
+ import { E } from "#generated/entities.ids.generated";
18
+ import * as F from "#generated/entities/sales_quote_line";
19
+ import { canonicalizeUnitCode, REFERENCE_UNIT_CODES } from "@open-mercato/shared/lib/units/unitCodes";
12
20
 
13
- const rawBodySchema = z.object({}).passthrough()
14
- const resolveRawBody = (raw: unknown) => (raw && typeof raw === 'object' && 'body' in raw ? (raw as any).body : raw)
21
+ const rawBodySchema = z.object({}).passthrough();
22
+ const resolveRawBody = (raw: unknown): Record<string, unknown> => {
23
+ if (!raw || typeof raw !== "object") return {};
24
+ if ("body" in raw) {
25
+ const payload = raw as { body?: unknown };
26
+ if (payload.body && typeof payload.body === "object") {
27
+ return payload.body as Record<string, unknown>;
28
+ }
29
+ }
30
+ return raw as Record<string, unknown>;
31
+ };
15
32
 
16
33
  const listSchema = z
17
34
  .object({
18
35
  page: z.coerce.number().min(1).default(1),
19
- pageSize: z.coerce.number().min(1).max(200).default(50),
36
+ pageSize: z.coerce.number().min(1).max(100).default(50),
20
37
  id: z.string().uuid().optional(),
21
38
  quoteId: z.string().uuid().optional(),
22
39
  sortField: z.string().optional(),
23
- sortDir: z.enum(['asc', 'desc']).optional(),
40
+ sortDir: z.enum(["asc", "desc"]).optional(),
24
41
  })
25
- .passthrough()
42
+ .passthrough();
26
43
 
27
44
  const routeMetadata = {
28
- GET: { requireAuth: true, requireFeatures: ['sales.quotes.view'] },
29
- POST: { requireAuth: true, requireFeatures: ['sales.quotes.manage'] },
30
- PUT: { requireAuth: true, requireFeatures: ['sales.quotes.manage'] },
31
- DELETE: { requireAuth: true, requireFeatures: ['sales.quotes.manage'] },
32
- }
45
+ GET: { requireAuth: true, requireFeatures: ["sales.quotes.view"] },
46
+ POST: { requireAuth: true, requireFeatures: ["sales.quotes.manage"] },
47
+ PUT: { requireAuth: true, requireFeatures: ["sales.quotes.manage"] },
48
+ DELETE: { requireAuth: true, requireFeatures: ["sales.quotes.manage"] },
49
+ };
33
50
 
34
- const upsertSchema = quoteLineCreateSchema.extend({ id: z.string().uuid().optional() })
51
+ const upsertSchema = quoteLineCreateSchema.extend({
52
+ id: z.string().uuid().optional(),
53
+ });
35
54
  const deleteSchema = z.object({
36
55
  id: z.string().uuid(),
37
56
  quoteId: z.string().uuid(),
38
- })
57
+ });
39
58
 
40
59
  const crud = makeCrudRoute({
41
60
  metadata: routeMetadata,
42
61
  orm: {
43
62
  entity: SalesQuoteLine,
44
- idField: 'id',
45
- orgField: 'organizationId',
46
- tenantField: 'tenantId',
47
- softDeleteField: 'deletedAt',
63
+ idField: "id",
64
+ orgField: "organizationId",
65
+ tenantField: "tenantId",
66
+ softDeleteField: "deletedAt",
67
+ },
68
+ indexer: {
69
+ entityType: E.sales.sales_quote_line,
48
70
  },
49
71
  list: {
50
72
  schema: listSchema,
51
73
  entityId: E.sales.sales_quote_line,
52
74
  fields: [
53
75
  F.id,
54
- 'quote_id',
76
+ "quote_id",
55
77
  F.line_number,
56
78
  F.kind,
57
79
  F.status_entry_id,
@@ -64,6 +86,9 @@ const crud = makeCrudRoute({
64
86
  F.comment,
65
87
  F.quantity,
66
88
  F.quantity_unit,
89
+ F.normalized_quantity,
90
+ F.normalized_unit,
91
+ F.uom_snapshot,
67
92
  F.currency_code,
68
93
  F.unit_price_net,
69
94
  F.unit_price_gross,
@@ -87,74 +112,134 @@ const crud = makeCrudRoute({
87
112
  lineNumber: F.line_number,
88
113
  },
89
114
  buildFilters: async (query, ctx) => {
90
- const filters: Record<string, unknown> = {}
91
- if (query.id) filters.id = { $eq: query.id }
92
- if (query.quoteId) filters.quote_id = { $eq: query.quoteId }
115
+ const filters: Record<string, unknown> = {};
116
+ if (query.id) filters.id = { $eq: query.id };
117
+ if (query.quoteId) filters.quote_id = { $eq: query.quoteId };
93
118
  try {
94
- const em = ctx.container.resolve('em')
119
+ const em = ctx.container.resolve("em");
95
120
  const cfFilters = await buildCustomFieldFiltersFromQuery({
96
121
  entityId: E.sales.sales_quote_line,
97
122
  query,
98
123
  em,
99
124
  tenantId: ctx.auth?.tenantId ?? null,
100
- })
101
- Object.assign(filters, cfFilters)
125
+ });
126
+ Object.assign(filters, cfFilters);
102
127
  } catch {
103
128
  // ignore
104
129
  }
105
- return filters
130
+ return filters;
106
131
  },
107
- transformItem: (item: any) => {
108
- if (!item) return item
109
- const normalized = { ...item }
110
- const cfEntries = extractAllCustomFieldEntries(item)
132
+ transformItem: (item: Record<string, unknown> | null | undefined) => {
133
+ if (!item) return item;
134
+ const normalized = { ...item };
135
+ const cfEntries = extractAllCustomFieldEntries(item);
111
136
  for (const key of Object.keys(normalized)) {
112
- if (key.startsWith('cf:')) delete normalized[key]
137
+ if (key.startsWith("cf:")) delete normalized[key];
113
138
  }
114
- return { ...normalized, ...cfEntries }
139
+ const quantityUnit = canonicalizeUnitCode(
140
+ normalized["quantity_unit"] ?? normalized["quantityUnit"],
141
+ );
142
+ const normalizedUnit =
143
+ canonicalizeUnitCode(
144
+ normalized["normalized_unit"] ?? normalized["normalizedUnit"],
145
+ ) ?? quantityUnit;
146
+ return {
147
+ ...normalized,
148
+ quantity_unit: quantityUnit,
149
+ normalized_unit: normalizedUnit,
150
+ ...cfEntries,
151
+ };
115
152
  },
116
153
  },
117
154
  actions: {
118
155
  create: {
119
- commandId: 'sales.quotes.lines.upsert',
156
+ commandId: "sales.quotes.lines.upsert",
120
157
  schema: rawBodySchema,
121
158
  mapInput: async ({ raw, ctx }) => {
122
- const { translate } = await resolveTranslations()
123
- const payload = upsertSchema.parse(withScopedPayload(resolveRawBody(raw) ?? {}, ctx, translate))
124
- return { body: payload }
159
+ const { translate } = await resolveTranslations();
160
+ const payload = upsertSchema.parse(
161
+ withScopedPayload(resolveRawBody(raw) ?? {}, ctx, translate),
162
+ );
163
+ return { body: payload };
125
164
  },
126
- response: ({ result }) => ({ id: result?.lineId ?? null, quoteId: result?.quoteId ?? null }),
165
+ response: ({ result }) => ({
166
+ id: result?.lineId ?? null,
167
+ quoteId: result?.quoteId ?? null,
168
+ }),
127
169
  status: 201,
128
170
  },
129
171
  update: {
130
- commandId: 'sales.quotes.lines.upsert',
172
+ commandId: "sales.quotes.lines.upsert",
131
173
  schema: rawBodySchema,
132
174
  mapInput: async ({ raw, ctx }) => {
133
- const { translate } = await resolveTranslations()
134
- const payload = upsertSchema.parse(withScopedPayload(resolveRawBody(raw) ?? {}, ctx, translate))
135
- return { body: payload }
175
+ const { translate } = await resolveTranslations();
176
+ const payload = upsertSchema.parse(
177
+ withScopedPayload(resolveRawBody(raw) ?? {}, ctx, translate),
178
+ );
179
+ return { body: payload };
136
180
  },
137
- response: ({ result }) => ({ id: result?.lineId ?? null, quoteId: result?.quoteId ?? null }),
181
+ response: ({ result }) => ({
182
+ id: result?.lineId ?? null,
183
+ quoteId: result?.quoteId ?? null,
184
+ }),
138
185
  },
139
186
  delete: {
140
- commandId: 'sales.quotes.lines.delete',
187
+ commandId: "sales.quotes.lines.delete",
141
188
  schema: rawBodySchema,
142
189
  mapInput: async ({ raw, ctx }) => {
143
- const { translate } = await resolveTranslations()
144
- const payload = deleteSchema.parse(withScopedPayload(resolveRawBody(raw) ?? {}, ctx, translate))
190
+ const { translate } = await resolveTranslations();
191
+ const payload = deleteSchema.parse(
192
+ withScopedPayload(resolveRawBody(raw) ?? {}, ctx, translate),
193
+ );
145
194
  if (!payload.id || !payload.quoteId) {
146
- throw new CrudHttpError(400, { error: translate('sales.documents.detail.error', 'Document not found or inaccessible.') })
195
+ throw new CrudHttpError(400, {
196
+ error: translate(
197
+ "sales.documents.detail.error",
198
+ "Document not found or inaccessible.",
199
+ ),
200
+ });
147
201
  }
148
- return { body: payload }
202
+ return { body: payload };
149
203
  },
150
204
  response: () => ({ ok: true }),
151
205
  },
152
206
  },
153
- })
207
+ });
208
+
209
+ const { GET, POST, PUT, DELETE } = crud;
154
210
 
155
- const { GET, POST, PUT, DELETE } = crud
211
+ export { GET, POST, PUT, DELETE };
156
212
 
157
- export { GET, POST, PUT, DELETE }
213
+ const uomSnapshotOpenApiSchema = z
214
+ .object({
215
+ version: z.literal(1),
216
+ productId: z.string().nullable(),
217
+ productVariantId: z.string().nullable(),
218
+ baseUnitCode: z.string().nullable(),
219
+ enteredUnitCode: z.string().nullable(),
220
+ enteredQuantity: z.string(),
221
+ toBaseFactor: z.string(),
222
+ normalizedQuantity: z.string(),
223
+ rounding: z.object({
224
+ mode: z.enum(["half_up", "down", "up"]),
225
+ scale: z.number().int(),
226
+ }),
227
+ source: z.object({
228
+ conversionId: z.string().nullable(),
229
+ resolvedAt: z.string(),
230
+ }),
231
+ unitPriceReference: z
232
+ .object({
233
+ enabled: z.boolean(),
234
+ referenceUnitCode: z.enum(REFERENCE_UNIT_CODES).nullable(),
235
+ baseQuantity: z.string().nullable(),
236
+ grossPerReference: z.string().nullable().optional(),
237
+ netPerReference: z.string().nullable().optional(),
238
+ })
239
+ .optional(),
240
+ })
241
+ .nullable()
242
+ .optional();
158
243
 
159
244
  const quoteLineSchema = z.object({
160
245
  id: z.string().uuid(),
@@ -171,6 +256,9 @@ const quoteLineSchema = z.object({
171
256
  comment: z.string().nullable().optional(),
172
257
  quantity: z.number(),
173
258
  quantity_unit: z.string().nullable().optional(),
259
+ normalized_quantity: z.number(),
260
+ normalized_unit: z.string().nullable().optional(),
261
+ uom_snapshot: uomSnapshotOpenApiSchema,
174
262
  currency_code: z.string(),
175
263
  unit_price_net: z.number(),
176
264
  unit_price_gross: z.number(),
@@ -187,25 +275,31 @@ const quoteLineSchema = z.object({
187
275
  custom_field_set_id: z.string().uuid().nullable().optional(),
188
276
  created_at: z.string(),
189
277
  updated_at: z.string(),
190
- })
278
+ });
191
279
 
192
280
  export const openApi = createSalesCrudOpenApi({
193
- resourceName: 'Quote line',
281
+ resourceName: "Quote line",
194
282
  querySchema: listSchema,
195
283
  listResponseSchema: createPagedListResponseSchema(quoteLineSchema),
196
284
  create: {
197
285
  schema: upsertSchema,
198
- responseSchema: z.object({ id: z.string().uuid().nullable(), quoteId: z.string().uuid().nullable() }),
199
- description: 'Creates a quote line and recalculates totals.',
286
+ responseSchema: z.object({
287
+ id: z.string().uuid().nullable(),
288
+ quoteId: z.string().uuid().nullable(),
289
+ }),
290
+ description: "Creates a quote line and recalculates totals.",
200
291
  },
201
292
  update: {
202
293
  schema: upsertSchema,
203
- responseSchema: z.object({ id: z.string().uuid().nullable(), quoteId: z.string().uuid().nullable() }),
204
- description: 'Updates a quote line and recalculates totals.',
294
+ responseSchema: z.object({
295
+ id: z.string().uuid().nullable(),
296
+ quoteId: z.string().uuid().nullable(),
297
+ }),
298
+ description: "Updates a quote line and recalculates totals.",
205
299
  },
206
300
  del: {
207
301
  schema: deleteSchema,
208
302
  responseSchema: defaultOkResponseSchema,
209
- description: 'Deletes a quote line and recalculates totals.',
303
+ description: "Deletes a quote line and recalculates totals.",
210
304
  },
211
- })
305
+ });
@@ -1,38 +1,60 @@
1
- import { NextResponse } from 'next/server'
2
- import { z } from 'zod'
3
- import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
- import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
5
- import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
6
- import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
7
- import type { EntityManager } from '@mikro-orm/postgresql'
8
- import { SalesQuote, SalesQuoteLine, SalesQuoteAdjustment } from '../../../../data/entities'
1
+ import { NextResponse } from "next/server";
2
+ import { z } from "zod";
3
+ import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
4
+ import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
5
+ import { CrudHttpError } from "@open-mercato/shared/lib/crud/errors";
6
+ import type { OpenApiRouteDoc } from "@open-mercato/shared/lib/openapi";
7
+ import type { EntityManager } from "@mikro-orm/postgresql";
8
+ import { findOneWithDecryption, findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
9
+ import {
10
+ SalesQuote,
11
+ SalesQuoteLine,
12
+ SalesQuoteAdjustment,
13
+ } from "../../../../data/entities";
14
+ import { canonicalizeUnitCode } from "@open-mercato/shared/lib/units/unitCodes";
9
15
 
10
16
  const paramsSchema = z.object({
11
17
  token: z.string().uuid(),
12
- })
18
+ });
13
19
 
14
20
  export const metadata = {
15
21
  GET: { requireAuth: false },
16
- }
22
+ };
17
23
 
18
24
  export async function GET(_req: Request, ctx: { params: { token: string } }) {
19
25
  try {
20
- const { token } = paramsSchema.parse(ctx.params ?? {})
21
- const container = await createRequestContainer()
22
- const em = container.resolve('em') as EntityManager
23
- const quote = await em.findOne(SalesQuote, { acceptanceToken: token, deletedAt: null })
24
- const { translate } = await resolveTranslations()
26
+ const { token } = paramsSchema.parse(ctx.params ?? {});
27
+ const container = await createRequestContainer();
28
+ const em = container.resolve("em") as EntityManager;
29
+ const quote = await findOneWithDecryption(em, SalesQuote, {
30
+ acceptanceToken: token,
31
+ deletedAt: null,
32
+ });
33
+ const { translate } = await resolveTranslations();
25
34
  if (!quote) {
26
- throw new CrudHttpError(404, { error: translate('sales.quotes.public.notFound', 'Quote not found.') })
35
+ throw new CrudHttpError(404, {
36
+ error: translate("sales.quotes.public.notFound", "Quote not found."),
37
+ });
27
38
  }
28
39
 
29
- const now = new Date()
30
- const isExpired = !!quote.validUntil && quote.validUntil.getTime() < now.getTime()
40
+ const now = new Date();
41
+ const isExpired =
42
+ !!quote.validUntil && quote.validUntil.getTime() < now.getTime();
31
43
 
32
44
  const [lines, adjustments] = await Promise.all([
33
- em.find(SalesQuoteLine, { quote: quote.id, deletedAt: null }, { orderBy: { lineNumber: 'asc' } }),
34
- em.find(SalesQuoteAdjustment, { quote: quote.id }, { orderBy: { position: 'asc' } }),
35
- ])
45
+ findWithDecryption(
46
+ em,
47
+ SalesQuoteLine,
48
+ { quote: quote.id, organizationId: quote.organizationId, tenantId: quote.tenantId, deletedAt: null },
49
+ { orderBy: { lineNumber: "asc" } },
50
+ ),
51
+ findWithDecryption(
52
+ em,
53
+ SalesQuoteAdjustment,
54
+ { quote: quote.id, organizationId: quote.organizationId, tenantId: quote.tenantId },
55
+ { orderBy: { position: "asc" } },
56
+ ),
57
+ ]);
36
58
 
37
59
  return NextResponse.json({
38
60
  quote: {
@@ -54,7 +76,17 @@ export async function GET(_req: Request, ctx: { params: { token: string } }) {
54
76
  name: line.name ?? null,
55
77
  description: line.description ?? null,
56
78
  quantity: line.quantity,
57
- quantityUnit: line.quantityUnit ?? null,
79
+ quantityUnit: canonicalizeUnitCode(line.quantityUnit) ?? null,
80
+ normalizedQuantity: line.normalizedQuantity ?? line.quantity,
81
+ normalizedUnit:
82
+ canonicalizeUnitCode(line.normalizedUnit ?? line.quantityUnit) ??
83
+ null,
84
+ uomSnapshot: line.uomSnapshot
85
+ ? {
86
+ baseUnitCode: line.uomSnapshot.baseUnitCode ?? null,
87
+ enteredUnitCode: line.uomSnapshot.enteredUnitCode ?? null,
88
+ }
89
+ : null,
58
90
  currencyCode: line.currencyCode,
59
91
  unitPriceNet: line.unitPriceNet,
60
92
  unitPriceGross: line.unitPriceGross,
@@ -64,6 +96,18 @@ export async function GET(_req: Request, ctx: { params: { token: string } }) {
64
96
  taxAmount: line.taxAmount,
65
97
  totalNetAmount: line.totalNetAmount,
66
98
  totalGrossAmount: line.totalGrossAmount,
99
+ unitPriceReference: (() => {
100
+ if (!line.uomSnapshot) return null;
101
+ const ref = line.uomSnapshot.unitPriceReference;
102
+ if (!ref) return null;
103
+ return {
104
+ enabled: ref.enabled ?? null,
105
+ referenceUnitCode: ref.referenceUnitCode ?? null,
106
+ baseQuantity: ref.baseQuantity ?? null,
107
+ grossPerReference: ref.grossPerReference ?? null,
108
+ netPerReference: ref.netPerReference ?? null,
109
+ };
110
+ })(),
67
111
  })),
68
112
  adjustments: adjustments.map((adj) => ({
69
113
  scope: adj.scope,
@@ -77,14 +121,19 @@ export async function GET(_req: Request, ctx: { params: { token: string } }) {
77
121
  quoteLineId: adj.quoteLine?.id ?? null,
78
122
  })),
79
123
  isExpired,
80
- })
124
+ });
81
125
  } catch (err) {
82
126
  if (err instanceof CrudHttpError) {
83
- return NextResponse.json(err.body, { status: err.status })
127
+ return NextResponse.json(err.body, { status: err.status });
84
128
  }
85
- const { translate } = await resolveTranslations()
86
- console.error('sales.quotes.public failed', err)
87
- return NextResponse.json({ error: translate('sales.quotes.public.failed', 'Failed to load quote.') }, { status: 400 })
129
+ const { translate } = await resolveTranslations();
130
+ console.error("sales.quotes.public failed", err);
131
+ return NextResponse.json(
132
+ {
133
+ error: translate("sales.quotes.public.failed", "Failed to load quote."),
134
+ },
135
+ { status: 400 },
136
+ );
88
137
  }
89
138
  }
90
139
 
@@ -110,9 +159,35 @@ const publicQuoteResponseSchema = z.object({
110
159
  description: z.string().nullable(),
111
160
  quantity: z.string(),
112
161
  quantityUnit: z.string().nullable(),
162
+ normalizedQuantity: z.string(),
163
+ normalizedUnit: z.string().nullable(),
164
+ uomSnapshot: z
165
+ .object({
166
+ baseUnitCode: z.string().nullable(),
167
+ enteredUnitCode: z.string().nullable(),
168
+ })
169
+ .nullable()
170
+ .optional(),
113
171
  currencyCode: z.string(),
172
+ unitPriceNet: z.string(),
173
+ unitPriceGross: z.string(),
174
+ discountAmount: z.string(),
175
+ discountPercent: z.string(),
176
+ taxRate: z.string(),
177
+ taxAmount: z.string(),
178
+ totalNetAmount: z.string(),
114
179
  totalGrossAmount: z.string(),
115
- })
180
+ unitPriceReference: z
181
+ .object({
182
+ enabled: z.boolean().nullable().optional(),
183
+ referenceUnitCode: z.string().nullable().optional(),
184
+ baseQuantity: z.string().nullable().optional(),
185
+ grossPerReference: z.string().nullable().optional(),
186
+ netPerReference: z.string().nullable().optional(),
187
+ })
188
+ .nullable()
189
+ .optional(),
190
+ }),
116
191
  ),
117
192
  adjustments: z.array(
118
193
  z.object({
@@ -122,22 +197,33 @@ const publicQuoteResponseSchema = z.object({
122
197
  rate: z.string().nullable(),
123
198
  amountNet: z.string().nullable(),
124
199
  amountGross: z.string().nullable(),
125
- })
200
+ currencyCode: z.string().nullable(),
201
+ position: z.number().nullable(),
202
+ quoteLineId: z.string().uuid().nullable(),
203
+ }),
126
204
  ),
127
205
  isExpired: z.boolean(),
128
- })
206
+ });
129
207
 
130
208
  export const openApi: OpenApiRouteDoc = {
131
- tag: 'Sales',
132
- summary: 'View a quote (public)',
209
+ tag: "Sales",
210
+ summary: "View a quote (public)",
133
211
  pathParams: z.object({ token: z.string().uuid() }),
134
212
  methods: {
135
213
  GET: {
136
- summary: 'Get quote details by acceptance token',
214
+ summary: "Get quote details by acceptance token",
137
215
  responses: [
138
- { status: 200, description: 'Quote details', schema: publicQuoteResponseSchema },
139
- { status: 404, description: 'Quote not found', schema: z.object({ error: z.string() }) },
216
+ {
217
+ status: 200,
218
+ description: "Quote details",
219
+ schema: publicQuoteResponseSchema,
220
+ },
221
+ {
222
+ status: 404,
223
+ description: "Quote not found",
224
+ schema: z.object({ error: z.string() }),
225
+ },
140
226
  ],
141
227
  },
142
228
  },
143
- }
229
+ };