@open-mercato/core 0.4.8-develop-d16e2f51dc → 0.4.8-develop-4b58cde65d

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 (85) hide show
  1. package/dist/generated/entities/sales_return/index.js +31 -0
  2. package/dist/generated/entities/sales_return/index.js.map +7 -0
  3. package/dist/generated/entities/sales_return_line/index.js +29 -0
  4. package/dist/generated/entities/sales_return_line/index.js.map +7 -0
  5. package/dist/generated/entities.ids.generated.js +2 -0
  6. package/dist/generated/entities.ids.generated.js.map +2 -2
  7. package/dist/generated/entity-fields-registry.js +4 -0
  8. package/dist/generated/entity-fields-registry.js.map +2 -2
  9. package/dist/modules/sales/acl.js +2 -0
  10. package/dist/modules/sales/acl.js.map +2 -2
  11. package/dist/modules/sales/api/document-history/route.js +20 -2
  12. package/dist/modules/sales/api/document-history/route.js.map +2 -2
  13. package/dist/modules/sales/api/documents/factory.js +34 -0
  14. package/dist/modules/sales/api/documents/factory.js.map +2 -2
  15. package/dist/modules/sales/api/returns/[id]/route.js +147 -0
  16. package/dist/modules/sales/api/returns/[id]/route.js.map +7 -0
  17. package/dist/modules/sales/api/returns/route.js +158 -0
  18. package/dist/modules/sales/api/returns/route.js.map +7 -0
  19. package/dist/modules/sales/backend/sales/documents/[id]/page.js +20 -1
  20. package/dist/modules/sales/backend/sales/documents/[id]/page.js.map +2 -2
  21. package/dist/modules/sales/commands/index.js +1 -0
  22. package/dist/modules/sales/commands/index.js.map +2 -2
  23. package/dist/modules/sales/commands/returns.js +467 -0
  24. package/dist/modules/sales/commands/returns.js.map +7 -0
  25. package/dist/modules/sales/components/documents/ReturnDialog.js +176 -0
  26. package/dist/modules/sales/components/documents/ReturnDialog.js.map +7 -0
  27. package/dist/modules/sales/components/documents/ReturnsSection.js +188 -0
  28. package/dist/modules/sales/components/documents/ReturnsSection.js.map +7 -0
  29. package/dist/modules/sales/data/entities.js +115 -1
  30. package/dist/modules/sales/data/entities.js.map +2 -2
  31. package/dist/modules/sales/data/validators.js +13 -0
  32. package/dist/modules/sales/data/validators.js.map +2 -2
  33. package/dist/modules/sales/events.js +4 -0
  34. package/dist/modules/sales/events.js.map +2 -2
  35. package/dist/modules/sales/lib/calculations.js +7 -0
  36. package/dist/modules/sales/lib/calculations.js.map +2 -2
  37. package/dist/modules/sales/lib/dictionaries.js +1 -0
  38. package/dist/modules/sales/lib/dictionaries.js.map +2 -2
  39. package/dist/modules/sales/lib/documentNumberTokens.js +2 -0
  40. package/dist/modules/sales/lib/documentNumberTokens.js.map +2 -2
  41. package/dist/modules/sales/lib/historyHelpers.js +15 -7
  42. package/dist/modules/sales/lib/historyHelpers.js.map +2 -2
  43. package/dist/modules/sales/lib/makeSalesLineRoute.js +42 -37
  44. package/dist/modules/sales/lib/makeSalesLineRoute.js.map +2 -2
  45. package/dist/modules/sales/migrations/Migration20260309073310.js +23 -0
  46. package/dist/modules/sales/migrations/Migration20260309073310.js.map +7 -0
  47. package/dist/modules/sales/services/salesDocumentNumberGenerator.js +8 -6
  48. package/dist/modules/sales/services/salesDocumentNumberGenerator.js.map +2 -2
  49. package/dist/modules/sales/setup.js +1 -1
  50. package/dist/modules/sales/setup.js.map +2 -2
  51. package/dist/modules/sales/widgets/injection/document-history/widget.client.js +25 -16
  52. package/dist/modules/sales/widgets/injection/document-history/widget.client.js.map +2 -2
  53. package/generated/entities/sales_return/index.ts +14 -0
  54. package/generated/entities/sales_return_line/index.ts +13 -0
  55. package/generated/entities.ids.generated.ts +2 -0
  56. package/generated/entity-fields-registry.ts +4 -0
  57. package/package.json +3 -3
  58. package/src/modules/sales/AGENTS.md +1 -0
  59. package/src/modules/sales/acl.ts +2 -0
  60. package/src/modules/sales/api/document-history/route.ts +25 -1
  61. package/src/modules/sales/api/documents/factory.ts +35 -0
  62. package/src/modules/sales/api/returns/[id]/route.ts +156 -0
  63. package/src/modules/sales/api/returns/route.ts +171 -0
  64. package/src/modules/sales/backend/sales/documents/[id]/page.tsx +18 -0
  65. package/src/modules/sales/commands/index.ts +1 -0
  66. package/src/modules/sales/commands/returns.ts +540 -0
  67. package/src/modules/sales/components/documents/ReturnDialog.tsx +216 -0
  68. package/src/modules/sales/components/documents/ReturnsSection.tsx +270 -0
  69. package/src/modules/sales/data/entities.ts +99 -3
  70. package/src/modules/sales/data/validators.ts +16 -0
  71. package/src/modules/sales/events.ts +5 -0
  72. package/src/modules/sales/i18n/de.json +32 -0
  73. package/src/modules/sales/i18n/en.json +32 -0
  74. package/src/modules/sales/i18n/es.json +32 -0
  75. package/src/modules/sales/i18n/pl.json +32 -0
  76. package/src/modules/sales/lib/calculations.ts +9 -0
  77. package/src/modules/sales/lib/dictionaries.ts +1 -0
  78. package/src/modules/sales/lib/documentNumberTokens.ts +2 -1
  79. package/src/modules/sales/lib/historyHelpers.ts +20 -9
  80. package/src/modules/sales/lib/makeSalesLineRoute.ts +42 -37
  81. package/src/modules/sales/migrations/.snapshot-open-mercato.json +398 -0
  82. package/src/modules/sales/migrations/Migration20260309073310.ts +26 -0
  83. package/src/modules/sales/services/salesDocumentNumberGenerator.ts +15 -4
  84. package/src/modules/sales/setup.ts +1 -1
  85. package/src/modules/sales/widgets/injection/document-history/widget.client.tsx +26 -17
@@ -40,6 +40,7 @@ import { SalesDocumentPaymentsSection } from '@open-mercato/core/modules/sales/c
40
40
  import { SalesDocumentAdjustmentsSection } from '@open-mercato/core/modules/sales/components/documents/AdjustmentsSection'
41
41
  import type { AdjustmentRowData } from '@open-mercato/core/modules/sales/components/documents/AdjustmentDialog'
42
42
  import { SalesShipmentsSection } from '@open-mercato/core/modules/sales/components/documents/ShipmentsSection'
43
+ import { SalesReturnsSection } from '@open-mercato/core/modules/sales/components/documents/ReturnsSection'
43
44
  import { DocumentTotals } from '@open-mercato/core/modules/sales/components/documents/DocumentTotals'
44
45
  import { E } from '#generated/entities.ids.generated'
45
46
  import type { DictionarySelectLabels } from '@open-mercato/core/modules/dictionaries/components/DictionaryEntrySelect'
@@ -3945,6 +3946,7 @@ export default function SalesDocumentDetailPage({
3945
3946
  tabs.push(
3946
3947
  { id: 'shipments', label: t('sales.documents.detail.tabs.shipments', 'Shipments') },
3947
3948
  { id: 'payments', label: t('sales.documents.detail.tabs.payments', 'Payments') },
3949
+ { id: 'returns', label: t('sales.documents.detail.tabs.returns', 'Returns') },
3948
3950
  )
3949
3951
  }
3950
3952
  tabs.push({ id: 'adjustments', label: t('sales.documents.detail.tabs.adjustments', 'Adjustments') })
@@ -4083,6 +4085,10 @@ export default function SalesDocumentDetailPage({
4083
4085
  title: t('sales.documents.detail.empty.payments.title', 'No payments yet.'),
4084
4086
  description: t('sales.documents.detail.empty.payments.description', 'Payments are work in progress.'),
4085
4087
  },
4088
+ returns: {
4089
+ title: t('sales.returns.empty.title', 'No returns yet.'),
4090
+ description: t('sales.returns.empty.description', 'Create a return to generate credit adjustments for returned items.'),
4091
+ },
4086
4092
  adjustments: {
4087
4093
  title: t('sales.documents.detail.empty.adjustments.title', 'No adjustments yet.'),
4088
4094
  description: t(
@@ -4173,6 +4179,18 @@ export default function SalesDocumentDetailPage({
4173
4179
  />
4174
4180
  )
4175
4181
  }
4182
+ if (activeTab === 'returns') {
4183
+ if (kind !== 'order') {
4184
+ const placeholder = tabEmptyStates.returns
4185
+ return <TabEmptyState title={placeholder.title} description={placeholder.description} />
4186
+ }
4187
+ return (
4188
+ <SalesReturnsSection
4189
+ orderId={record.id}
4190
+ currencyCode={record.currencyCode ?? null}
4191
+ />
4192
+ )
4193
+ }
4176
4194
  if (activeTab === 'adjustments') {
4177
4195
  return (
4178
4196
  <SalesDocumentAdjustmentsSection
@@ -7,3 +7,4 @@ import './documentAddresses'
7
7
  import './shipments'
8
8
  import './payments'
9
9
  import './notes'
10
+ import './returns'
@@ -0,0 +1,540 @@
1
+ import { randomUUID } from 'crypto'
2
+ import { registerCommand, type CommandHandler } from '@open-mercato/shared/lib/commands'
3
+ import type { EntityManager } from '@mikro-orm/postgresql'
4
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
5
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
6
+ import { emitCrudSideEffects } from '@open-mercato/shared/lib/commands/helpers'
7
+ import type { CrudEventsConfig } from '@open-mercato/shared/lib/crud/types'
8
+ import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
9
+ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
10
+ import { SalesDocumentNumberGenerator } from '../services/salesDocumentNumberGenerator'
11
+ import type { SalesCalculationService } from '../services/salesCalculationService'
12
+ import type { SalesAdjustmentDraft, SalesLineSnapshot, SalesDocumentCalculationResult } from '../lib/types'
13
+ import { cloneJson, ensureOrganizationScope, ensureSameScope, ensureTenantScope, extractUndoPayload, toNumericString } from './shared'
14
+ import { SalesOrder, SalesOrderAdjustment, SalesOrderLine, SalesReturn, SalesReturnLine } from '../data/entities'
15
+ import { returnCreateSchema, type ReturnCreateInput } from '../data/validators'
16
+ import { E } from '#generated/entities.ids.generated'
17
+
18
+ type ReturnLineInput = { orderLineId: string; quantity: number }
19
+
20
+ type ReturnSnapshot = {
21
+ id: string
22
+ orderId: string
23
+ organizationId: string
24
+ tenantId: string
25
+ returnNumber: string
26
+ returnedAt: string | null
27
+ reason: string | null
28
+ notes: string | null
29
+ lines: Array<{
30
+ id: string
31
+ orderLineId: string
32
+ quantityReturned: number
33
+ unitPriceNet: number
34
+ unitPriceGross: number
35
+ totalNetAmount: number
36
+ totalGrossAmount: number
37
+ }>
38
+ adjustmentIds: string[]
39
+ }
40
+
41
+ type ReturnUndoPayload = {
42
+ after?: ReturnSnapshot | null
43
+ }
44
+
45
+ const returnCrudEvents: CrudEventsConfig = {
46
+ module: 'sales',
47
+ entity: 'return',
48
+ persistent: true,
49
+ buildPayload: (ctx) => ({
50
+ id: ctx.identifiers.id,
51
+ organizationId: ctx.identifiers.organizationId,
52
+ tenantId: ctx.identifiers.tenantId,
53
+ }),
54
+ }
55
+
56
+ function toNumeric(value: unknown): number {
57
+ if (typeof value === 'number' && Number.isFinite(value)) return value
58
+ if (typeof value === 'string' && value.trim().length) {
59
+ const parsed = Number(value)
60
+ if (Number.isFinite(parsed)) return parsed
61
+ }
62
+ return 0
63
+ }
64
+
65
+ function round(value: number): number {
66
+ return Math.round((value + Number.EPSILON) * 1e4) / 1e4
67
+ }
68
+
69
+ function applyOrderTotals(order: SalesOrder, totals: SalesDocumentCalculationResult['totals'], lineCount: number): void {
70
+ order.subtotalNetAmount = toNumericString(totals.subtotalNetAmount) ?? '0'
71
+ order.subtotalGrossAmount = toNumericString(totals.subtotalGrossAmount) ?? '0'
72
+ order.discountTotalAmount = toNumericString(totals.discountTotalAmount) ?? '0'
73
+ order.taxTotalAmount = toNumericString(totals.taxTotalAmount) ?? '0'
74
+ order.shippingNetAmount = toNumericString(totals.shippingNetAmount) ?? '0'
75
+ order.shippingGrossAmount = toNumericString(totals.shippingGrossAmount) ?? '0'
76
+ order.surchargeTotalAmount = toNumericString(totals.surchargeTotalAmount) ?? '0'
77
+ order.grandTotalNetAmount = toNumericString(totals.grandTotalNetAmount) ?? '0'
78
+ order.grandTotalGrossAmount = toNumericString(totals.grandTotalGrossAmount) ?? '0'
79
+ order.paidTotalAmount = toNumericString(totals.paidTotalAmount) ?? '0'
80
+ order.refundedTotalAmount = toNumericString(totals.refundedTotalAmount) ?? '0'
81
+ order.outstandingAmount = toNumericString(totals.outstandingAmount) ?? '0'
82
+ order.totalsSnapshot = cloneJson(totals)
83
+ order.lineItemCount = lineCount
84
+ }
85
+
86
+ function mapOrderLineEntityToSnapshot(line: SalesOrderLine): SalesLineSnapshot {
87
+ return {
88
+ id: line.id,
89
+ lineNumber: line.lineNumber,
90
+ kind: line.kind,
91
+ productId: line.productId ?? null,
92
+ productVariantId: line.productVariantId ?? null,
93
+ name: line.name ?? null,
94
+ description: line.description ?? null,
95
+ comment: line.comment ?? null,
96
+ quantity: toNumeric(line.quantity),
97
+ quantityUnit: line.quantityUnit ?? null,
98
+ normalizedQuantity: toNumeric(line.normalizedQuantity ?? line.quantity),
99
+ normalizedUnit: line.normalizedUnit ?? line.quantityUnit ?? null,
100
+ uomSnapshot: line.uomSnapshot ? cloneJson(line.uomSnapshot) : null,
101
+ currencyCode: line.currencyCode,
102
+ unitPriceNet: toNumeric(line.unitPriceNet),
103
+ unitPriceGross: toNumeric(line.unitPriceGross),
104
+ discountAmount: toNumeric(line.discountAmount),
105
+ discountPercent: toNumeric(line.discountPercent),
106
+ taxRate: toNumeric(line.taxRate),
107
+ taxAmount: toNumeric(line.taxAmount),
108
+ totalNetAmount: toNumeric(line.totalNetAmount),
109
+ totalGrossAmount: toNumeric(line.totalGrossAmount),
110
+ configuration: line.configuration ? cloneJson(line.configuration) : null,
111
+ promotionCode: line.promotionCode ?? null,
112
+ metadata: line.metadata ? cloneJson(line.metadata) : null,
113
+ customFieldSetId: line.customFieldSetId ?? null,
114
+ }
115
+ }
116
+
117
+ function mapOrderAdjustmentToDraft(adjustment: SalesOrderAdjustment): SalesAdjustmentDraft {
118
+ return {
119
+ id: adjustment.id,
120
+ scope: adjustment.scope ?? 'order',
121
+ kind: adjustment.kind,
122
+ code: adjustment.code ?? null,
123
+ label: adjustment.label ?? null,
124
+ calculatorKey: adjustment.calculatorKey ?? null,
125
+ promotionId: adjustment.promotionId ?? null,
126
+ rate: toNumeric(adjustment.rate),
127
+ amountNet: toNumeric(adjustment.amountNet),
128
+ amountGross: toNumeric(adjustment.amountGross),
129
+ currencyCode: adjustment.currencyCode ?? null,
130
+ metadata: adjustment.metadata ? cloneJson(adjustment.metadata) : null,
131
+ position: adjustment.position ?? 0,
132
+ }
133
+ }
134
+
135
+ function buildCalculationContext(order: SalesOrder) {
136
+ return {
137
+ tenantId: order.tenantId,
138
+ organizationId: order.organizationId,
139
+ currencyCode: order.currencyCode,
140
+ metadata: {
141
+ shippingMethod: order.shippingMethodSnapshot
142
+ ? cloneJson(order.shippingMethodSnapshot as Record<string, unknown>)
143
+ : null,
144
+ paymentMethod: order.paymentMethodSnapshot ? cloneJson(order.paymentMethodSnapshot as Record<string, unknown>) : null,
145
+ },
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Recalculates order totals (including line-scoped return adjustments) for display.
151
+ * Returns the totals object to merge into an order API response, or null if order not found.
152
+ */
153
+ export async function recalculateOrderTotalsForDisplay(
154
+ em: EntityManager,
155
+ container: { resolve: (key: string) => unknown },
156
+ orderId: string,
157
+ scope: { tenantId: string; organizationId: string },
158
+ ): Promise<SalesDocumentCalculationResult['totals'] | null> {
159
+ const order = await findOneWithDecryption(
160
+ em,
161
+ SalesOrder,
162
+ { id: orderId, deletedAt: null },
163
+ {},
164
+ scope,
165
+ )
166
+ if (!order) return null
167
+ const [orderLines, adjustments] = await Promise.all([
168
+ findWithDecryption(em, SalesOrderLine, { order: order.id, deletedAt: null }, {}, scope),
169
+ findWithDecryption(
170
+ em,
171
+ SalesOrderAdjustment,
172
+ { order: order.id, deletedAt: null },
173
+ { orderBy: { position: 'asc' } },
174
+ scope,
175
+ ),
176
+ ])
177
+ const lineSnapshots: SalesLineSnapshot[] = orderLines.map(mapOrderLineEntityToSnapshot)
178
+ const adjustmentDrafts: SalesAdjustmentDraft[] = adjustments.map(mapOrderAdjustmentToDraft)
179
+ const salesCalculationService = container.resolve('salesCalculationService') as SalesCalculationService
180
+ const calculation = await salesCalculationService.calculateDocumentTotals({
181
+ documentKind: 'order',
182
+ lines: lineSnapshots,
183
+ adjustments: adjustmentDrafts,
184
+ context: buildCalculationContext(order),
185
+ existingTotals: {
186
+ paidTotalAmount: toNumeric(order.paidTotalAmount),
187
+ refundedTotalAmount: toNumeric(order.refundedTotalAmount),
188
+ },
189
+ })
190
+ return calculation.totals
191
+ }
192
+
193
+ export async function loadReturnSnapshot(em: EntityManager, id: string): Promise<ReturnSnapshot | null> {
194
+ const header = await findOneWithDecryption(
195
+ em,
196
+ SalesReturn,
197
+ { id, deletedAt: null },
198
+ { populate: ['order'] },
199
+ {},
200
+ )
201
+ if (!header || !header.order) return null
202
+ const orderId = typeof header.order === 'string' ? header.order : header.order.id
203
+ const lines = await findWithDecryption(
204
+ em,
205
+ SalesReturnLine,
206
+ { salesReturn: header.id, deletedAt: null },
207
+ { populate: ['orderLine'] },
208
+ { tenantId: header.tenantId, organizationId: header.organizationId },
209
+ )
210
+ const adjustmentIds: string[] = []
211
+ const adjustments = await findWithDecryption(
212
+ em,
213
+ SalesOrderAdjustment,
214
+ { order: orderId, kind: 'return', deletedAt: null },
215
+ {},
216
+ { tenantId: header.tenantId, organizationId: header.organizationId },
217
+ )
218
+ adjustments.forEach((adj) => {
219
+ const meta = adj.metadata as Record<string, unknown> | null | undefined
220
+ if (meta && meta.returnId === header.id) adjustmentIds.push(adj.id)
221
+ })
222
+
223
+ return {
224
+ id: header.id,
225
+ orderId,
226
+ organizationId: header.organizationId,
227
+ tenantId: header.tenantId,
228
+ returnNumber: header.returnNumber,
229
+ returnedAt: header.returnedAt ? header.returnedAt.toISOString() : null,
230
+ reason: header.reason ?? null,
231
+ notes: header.notes ?? null,
232
+ lines: lines.map((line) => ({
233
+ id: line.id,
234
+ orderLineId: typeof line.orderLine === 'string' ? line.orderLine : line.orderLine?.id ?? null,
235
+ quantityReturned: toNumeric(line.quantityReturned),
236
+ unitPriceNet: toNumeric(line.unitPriceNet),
237
+ unitPriceGross: toNumeric(line.unitPriceGross),
238
+ totalNetAmount: toNumeric(line.totalNetAmount),
239
+ totalGrossAmount: toNumeric(line.totalGrossAmount),
240
+ })),
241
+ adjustmentIds,
242
+ }
243
+ }
244
+
245
+ function normalizeLinesInput(lines: ReturnCreateInput['lines']): ReturnLineInput[] {
246
+ const seen = new Set<string>()
247
+ const result: ReturnLineInput[] = []
248
+ for (const line of lines) {
249
+ const orderLineId = line.orderLineId
250
+ if (!orderLineId || seen.has(orderLineId)) continue
251
+ const quantity = toNumeric(line.quantity)
252
+ if (!Number.isFinite(quantity) || quantity <= 0) continue
253
+ seen.add(orderLineId)
254
+ result.push({ orderLineId, quantity })
255
+ }
256
+ return result
257
+ }
258
+
259
+ const createReturnCommand: CommandHandler<ReturnCreateInput, { returnId: string }> = {
260
+ id: 'sales.returns.create',
261
+ async execute(rawInput, ctx) {
262
+ const input = returnCreateSchema.parse(rawInput ?? {})
263
+ ensureTenantScope(ctx, input.tenantId)
264
+ ensureOrganizationScope(ctx, input.organizationId)
265
+
266
+ const { translate } = await resolveTranslations()
267
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
268
+
269
+ const order = await findOneWithDecryption(
270
+ em,
271
+ SalesOrder,
272
+ { id: input.orderId, deletedAt: null },
273
+ {},
274
+ { tenantId: input.tenantId, organizationId: input.organizationId },
275
+ )
276
+ if (!order) {
277
+ throw new CrudHttpError(404, { error: translate('sales.returns.orderMissing', 'Order not found.') })
278
+ }
279
+ ensureSameScope(order, input.organizationId, input.tenantId)
280
+
281
+ const requested = normalizeLinesInput(input.lines)
282
+ if (!requested.length) {
283
+ throw new CrudHttpError(400, { error: translate('sales.returns.linesRequired', 'Select at least one line to return.') })
284
+ }
285
+
286
+ const orderLines = await findWithDecryption(
287
+ em,
288
+ SalesOrderLine,
289
+ { order: order.id, deletedAt: null },
290
+ {},
291
+ { tenantId: input.tenantId, organizationId: input.organizationId },
292
+ )
293
+ const lineMap = new Map(orderLines.map((line) => [line.id, line]))
294
+
295
+ requested.forEach(({ orderLineId, quantity }) => {
296
+ const line = lineMap.get(orderLineId)
297
+ if (!line) {
298
+ throw new CrudHttpError(404, { error: translate('sales.returns.lineMissing', 'Order line not found.') })
299
+ }
300
+ const available = toNumeric(line.quantity) - toNumeric(line.returnedQuantity)
301
+ if (quantity - 1e-6 > available) {
302
+ throw new CrudHttpError(400, { error: translate('sales.returns.quantityExceeded', 'Cannot return more than the remaining quantity.') })
303
+ }
304
+ })
305
+
306
+ const existingAdjustments = await findWithDecryption(
307
+ em,
308
+ SalesOrderAdjustment,
309
+ { order: order.id, deletedAt: null },
310
+ { orderBy: { position: 'asc' } },
311
+ { tenantId: input.tenantId, organizationId: input.organizationId },
312
+ )
313
+ const positionStart = existingAdjustments.reduce((acc, adj) => Math.max(acc, adj.position ?? 0), 0) + 1
314
+
315
+ const numberGenerator = new SalesDocumentNumberGenerator(em)
316
+ const generated = await numberGenerator.generate({
317
+ kind: 'return',
318
+ tenantId: input.tenantId,
319
+ organizationId: input.organizationId,
320
+ })
321
+ const returnId = randomUUID()
322
+ const header = em.create(SalesReturn, {
323
+ id: returnId,
324
+ order,
325
+ organizationId: input.organizationId,
326
+ tenantId: input.tenantId,
327
+ returnNumber: generated.number,
328
+ reason: input.reason ?? null,
329
+ notes: input.notes ?? null,
330
+ returnedAt: input.returnedAt ?? new Date(),
331
+ createdAt: new Date(),
332
+ updatedAt: new Date(),
333
+ })
334
+ em.persist(header)
335
+
336
+ const createdAdjustments: SalesOrderAdjustment[] = []
337
+ const createdLines: SalesReturnLine[] = []
338
+ requested.forEach((lineInput, index) => {
339
+ const line = lineMap.get(lineInput.orderLineId)
340
+ if (!line) return
341
+ const quantity = lineInput.quantity
342
+ const lineQuantity = Math.max(toNumeric(line.quantity), 0)
343
+ const unitNet = lineQuantity > 0 ? toNumeric(line.totalNetAmount) / lineQuantity : toNumeric(line.unitPriceNet)
344
+ const unitGross = lineQuantity > 0 ? toNumeric(line.totalGrossAmount) / lineQuantity : toNumeric(line.unitPriceGross)
345
+ const totalNet = -round(Math.max(unitNet, 0) * quantity)
346
+ const totalGross = -round(Math.max(unitGross, 0) * quantity)
347
+
348
+ const returnLineId = randomUUID()
349
+ const returnLine = em.create(SalesReturnLine, {
350
+ id: returnLineId,
351
+ salesReturn: header,
352
+ orderLine: em.getReference(SalesOrderLine, line.id),
353
+ organizationId: input.organizationId,
354
+ tenantId: input.tenantId,
355
+ quantityReturned: quantity.toString(),
356
+ unitPriceNet: round(unitNet).toString(),
357
+ unitPriceGross: round(unitGross).toString(),
358
+ totalNetAmount: totalNet.toString(),
359
+ totalGrossAmount: totalGross.toString(),
360
+ createdAt: new Date(),
361
+ updatedAt: new Date(),
362
+ })
363
+ createdLines.push(returnLine)
364
+ em.persist(returnLine)
365
+
366
+ const adjustment = em.create(SalesOrderAdjustment, {
367
+ id: randomUUID(),
368
+ order,
369
+ orderLine: em.getReference(SalesOrderLine, line.id),
370
+ organizationId: input.organizationId,
371
+ tenantId: input.tenantId,
372
+ scope: 'line',
373
+ kind: 'return',
374
+ rate: '0',
375
+ amountNet: totalNet.toString(),
376
+ amountGross: totalGross.toString(),
377
+ currencyCode: order.currencyCode,
378
+ metadata: { returnId, returnLineId },
379
+ position: positionStart + index,
380
+ createdAt: new Date(),
381
+ updatedAt: new Date(),
382
+ })
383
+ createdAdjustments.push(adjustment)
384
+ em.persist(adjustment)
385
+
386
+ line.returnedQuantity = (toNumeric(line.returnedQuantity) + quantity).toString()
387
+ line.updatedAt = new Date()
388
+ em.persist(line)
389
+ })
390
+
391
+ const salesCalculationService = ctx.container.resolve<SalesCalculationService>('salesCalculationService')
392
+ const lineSnapshots: SalesLineSnapshot[] = orderLines.map(mapOrderLineEntityToSnapshot)
393
+ const adjustmentDrafts: SalesAdjustmentDraft[] = [...existingAdjustments, ...createdAdjustments].map(mapOrderAdjustmentToDraft)
394
+ const calculation = await salesCalculationService.calculateDocumentTotals({
395
+ documentKind: 'order',
396
+ lines: lineSnapshots,
397
+ adjustments: adjustmentDrafts,
398
+ context: buildCalculationContext(order),
399
+ })
400
+ applyOrderTotals(order, calculation.totals, calculation.lines.length)
401
+ order.updatedAt = new Date()
402
+ em.persist(order)
403
+
404
+ await em.flush()
405
+
406
+ const dataEngine = ctx.container.resolve('dataEngine') as DataEngine
407
+ await emitCrudSideEffects({
408
+ dataEngine,
409
+ action: 'created',
410
+ entity: header,
411
+ identifiers: { id: header.id, organizationId: header.organizationId, tenantId: header.tenantId },
412
+ indexer: { entityType: E.sales.sales_return },
413
+ events: returnCrudEvents,
414
+ })
415
+
416
+ if (createdLines.length) {
417
+ await Promise.all(
418
+ createdLines.map((line) =>
419
+ emitCrudSideEffects({
420
+ dataEngine,
421
+ action: 'created',
422
+ entity: line,
423
+ identifiers: { id: line.id, organizationId: line.organizationId, tenantId: line.tenantId },
424
+ indexer: { entityType: E.sales.sales_return_line },
425
+ }),
426
+ ),
427
+ )
428
+ }
429
+
430
+ return { returnId }
431
+ },
432
+ captureAfter: async (_input, result, ctx) => {
433
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
434
+ return loadReturnSnapshot(em, result.returnId)
435
+ },
436
+ buildLog: async ({ result, snapshots }) => {
437
+ const after = snapshots.after as ReturnSnapshot | undefined
438
+ if (!after) return null
439
+ const { translate } = await resolveTranslations()
440
+ return {
441
+ actionLabel: translate('sales.audit.returns.create', 'Create return'),
442
+ resourceKind: 'sales.return',
443
+ resourceId: result.returnId,
444
+ parentResourceKind: 'sales.order',
445
+ parentResourceId: after.orderId ?? null,
446
+ tenantId: after.tenantId,
447
+ organizationId: after.organizationId,
448
+ snapshotAfter: after,
449
+ payload: {
450
+ undo: { after } satisfies ReturnUndoPayload,
451
+ },
452
+ }
453
+ },
454
+ undo: async ({ logEntry, ctx }) => {
455
+ const payload = extractUndoPayload<ReturnUndoPayload>(logEntry)
456
+ const after = payload?.after
457
+ if (!after) return
458
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
459
+ const order = await findOneWithDecryption(
460
+ em,
461
+ SalesOrder,
462
+ { id: after.orderId, deletedAt: null },
463
+ {},
464
+ { tenantId: after.tenantId, organizationId: after.organizationId },
465
+ )
466
+ if (!order) return
467
+
468
+ const lines = await findWithDecryption(
469
+ em,
470
+ SalesOrderLine,
471
+ { order: order.id, deletedAt: null },
472
+ {},
473
+ { tenantId: after.tenantId, organizationId: after.organizationId },
474
+ )
475
+ const lineMap = new Map(lines.map((line) => [line.id, line]))
476
+ after.lines.forEach((entry) => {
477
+ const line = lineMap.get(entry.orderLineId)
478
+ if (!line) return
479
+ const next = Math.max(0, toNumeric(line.returnedQuantity) - entry.quantityReturned)
480
+ line.returnedQuantity = next.toString()
481
+ line.updatedAt = new Date()
482
+ em.persist(line)
483
+ })
484
+
485
+ if (after.adjustmentIds.length) {
486
+ const adjustments = await findWithDecryption(
487
+ em,
488
+ SalesOrderAdjustment,
489
+ { id: { $in: after.adjustmentIds }, deletedAt: null },
490
+ {},
491
+ { tenantId: after.tenantId, organizationId: after.organizationId },
492
+ )
493
+ adjustments.forEach((adj) => em.remove(adj))
494
+ }
495
+
496
+ const header = await findOneWithDecryption(
497
+ em,
498
+ SalesReturn,
499
+ { id: after.id, deletedAt: null },
500
+ {},
501
+ { tenantId: after.tenantId, organizationId: after.organizationId },
502
+ )
503
+ const returnLines = await findWithDecryption(
504
+ em,
505
+ SalesReturnLine,
506
+ { salesReturn: after.id, deletedAt: null },
507
+ {},
508
+ { tenantId: after.tenantId, organizationId: after.organizationId },
509
+ )
510
+ returnLines.forEach((line) => em.remove(line))
511
+ if (header) em.remove(header)
512
+
513
+ const existingAdjustments = await findWithDecryption(
514
+ em,
515
+ SalesOrderAdjustment,
516
+ { order: order.id, deletedAt: null },
517
+ { orderBy: { position: 'asc' } },
518
+ { tenantId: after.tenantId, organizationId: after.organizationId },
519
+ )
520
+ const salesCalculationService = ctx.container.resolve<SalesCalculationService>('salesCalculationService')
521
+ const lineSnapshots: SalesLineSnapshot[] = lines.map(mapOrderLineEntityToSnapshot)
522
+ const adjustmentDrafts: SalesAdjustmentDraft[] = existingAdjustments.map(mapOrderAdjustmentToDraft)
523
+ const calculation = await salesCalculationService.calculateDocumentTotals({
524
+ documentKind: 'order',
525
+ lines: lineSnapshots,
526
+ adjustments: adjustmentDrafts,
527
+ context: buildCalculationContext(order),
528
+ })
529
+ applyOrderTotals(order, calculation.totals, calculation.lines.length)
530
+ order.updatedAt = new Date()
531
+ em.persist(order)
532
+
533
+ await em.flush()
534
+ },
535
+ }
536
+
537
+ registerCommand(createReturnCommand)
538
+
539
+ export const returnCommands = [createReturnCommand]
540
+