@open-mercato/core 0.6.6-develop.5617.1.62538c48ca → 0.6.6-develop.5619.1.29f01e2c42

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 (30) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/sales/acl.js +6 -0
  3. package/dist/modules/sales/acl.js.map +2 -2
  4. package/dist/modules/sales/api/returns/route.js +43 -3
  5. package/dist/modules/sales/api/returns/route.js.map +2 -2
  6. package/dist/modules/sales/commands/returns.js +473 -213
  7. package/dist/modules/sales/commands/returns.js.map +2 -2
  8. package/dist/modules/sales/commands/shared.js +2 -0
  9. package/dist/modules/sales/commands/shared.js.map +2 -2
  10. package/dist/modules/sales/components/documents/ReturnEditDialog.js +125 -0
  11. package/dist/modules/sales/components/documents/ReturnEditDialog.js.map +7 -0
  12. package/dist/modules/sales/components/documents/ReturnsSection.js +102 -6
  13. package/dist/modules/sales/components/documents/ReturnsSection.js.map +2 -2
  14. package/dist/modules/sales/data/validators.js +13 -0
  15. package/dist/modules/sales/data/validators.js.map +2 -2
  16. package/dist/modules/sales/setup.js +1 -0
  17. package/dist/modules/sales/setup.js.map +2 -2
  18. package/package.json +7 -7
  19. package/src/modules/sales/acl.ts +6 -0
  20. package/src/modules/sales/api/returns/route.ts +41 -3
  21. package/src/modules/sales/commands/returns.ts +561 -229
  22. package/src/modules/sales/commands/shared.ts +1 -0
  23. package/src/modules/sales/components/documents/ReturnEditDialog.tsx +157 -0
  24. package/src/modules/sales/components/documents/ReturnsSection.tsx +105 -3
  25. package/src/modules/sales/data/validators.ts +15 -0
  26. package/src/modules/sales/i18n/de.json +11 -0
  27. package/src/modules/sales/i18n/en.json +11 -0
  28. package/src/modules/sales/i18n/es.json +11 -0
  29. package/src/modules/sales/i18n/pl.json +11 -0
  30. package/src/modules/sales/setup.ts +1 -0
@@ -12,10 +12,17 @@ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/
12
12
  import { SalesDocumentNumberGenerator } from '../services/salesDocumentNumberGenerator'
13
13
  import type { SalesCalculationService } from '../services/salesCalculationService'
14
14
  import type { SalesAdjustmentDraft, SalesLineSnapshot, SalesDocumentCalculationResult } from '../lib/types'
15
- import { cloneJson, ensureOrganizationScope, ensureSameScope, ensureTenantScope, extractUndoPayload, toNumericString, enforceSalesDocumentOptimisticLock, SALES_RESOURCE_KIND_ORDER } from './shared'
15
+ import { cloneJson, ensureOrganizationScope, ensureSameScope, ensureTenantScope, extractUndoPayload, toNumericString, enforceSalesDocumentOptimisticLock, SALES_RESOURCE_KIND_ORDER, SALES_RESOURCE_KIND_RETURN } from './shared'
16
16
  import { resolveRedoSnapshot } from '@open-mercato/shared/lib/commands/redo'
17
17
  import { SalesOrder, SalesOrderAdjustment, SalesOrderLine, SalesReturn, SalesReturnLine } from '../data/entities'
18
- import { returnCreateSchema, type ReturnCreateInput } from '../data/validators'
18
+ import {
19
+ returnCreateSchema,
20
+ returnUpdateSchema,
21
+ returnDeleteSchema,
22
+ type ReturnCreateInput,
23
+ type ReturnUpdateInput,
24
+ type ReturnDeleteInput,
25
+ } from '../data/validators'
19
26
  import { E } from '#generated/entities.ids.generated'
20
27
 
21
28
  type ReturnLineInput = { orderLineId: string; quantity: number }
@@ -245,6 +252,295 @@ export async function loadReturnSnapshot(em: EntityManager, id: string): Promise
245
252
  }
246
253
  }
247
254
 
255
+ type ReturnHeaderSnapshot = {
256
+ id: string
257
+ orderId: string
258
+ organizationId: string
259
+ tenantId: string
260
+ reason: string | null
261
+ notes: string | null
262
+ returnedAt: string | null
263
+ }
264
+
265
+ type ReturnHeaderUndoPayload = {
266
+ before?: ReturnHeaderSnapshot | null
267
+ after?: ReturnHeaderSnapshot | null
268
+ }
269
+
270
+ type ReturnDeleteUndoPayload = {
271
+ before?: ReturnSnapshot | null
272
+ }
273
+
274
+ async function loadReturnHeaderSnapshot(em: EntityManager, id: string): Promise<ReturnHeaderSnapshot | null> {
275
+ const header = await findOneWithDecryption(em, SalesReturn, { id, deletedAt: null }, { populate: ['order'] }, {})
276
+ if (!header || !header.order) return null
277
+ const orderId = typeof header.order === 'string' ? header.order : header.order.id
278
+ return {
279
+ id: header.id,
280
+ orderId,
281
+ organizationId: header.organizationId,
282
+ tenantId: header.tenantId,
283
+ reason: header.reason ?? null,
284
+ notes: header.notes ?? null,
285
+ returnedAt: header.returnedAt ? header.returnedAt.toISOString() : null,
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Reverse the order-level effects of a return: restore each order line's
291
+ * `returnedQuantity`, drop the return's line-scoped credit adjustments, remove
292
+ * the return header + lines, and recalculate the order totals. Shared by the
293
+ * create command's undo and the delete command's execute — both need the exact
294
+ * same teardown. No-op when the order is gone.
295
+ *
296
+ * The line reversals, adjustment/return removals, and the order-total recompute
297
+ * interleave queries on the same EntityManager with scalar mutations, so they
298
+ * run inside an atomic flush to avoid lost updates and partial commits
299
+ * (SPEC-018): the per-phase flush boundary persists the line `returnedQuantity`
300
+ * reversals before the adjustment/header/return-line lookups in the next phase
301
+ * run any query, which under MikroORM v7 would otherwise silently discard the
302
+ * pending scalar changes on the managed lines.
303
+ */
304
+ async function reverseReturnEffects(
305
+ em: EntityManager,
306
+ salesCalculationService: SalesCalculationService,
307
+ snapshot: ReturnSnapshot,
308
+ ): Promise<void> {
309
+ const order = await findOneWithDecryption(
310
+ em,
311
+ SalesOrder,
312
+ { id: snapshot.orderId, deletedAt: null },
313
+ {},
314
+ { tenantId: snapshot.tenantId, organizationId: snapshot.organizationId },
315
+ )
316
+ if (!order) return
317
+
318
+ let lines: SalesOrderLine[] = []
319
+ await withAtomicFlush(
320
+ em,
321
+ [
322
+ async () => {
323
+ lines = await findWithDecryption(
324
+ em,
325
+ SalesOrderLine,
326
+ { order: order.id, deletedAt: null },
327
+ {},
328
+ { tenantId: snapshot.tenantId, organizationId: snapshot.organizationId },
329
+ )
330
+ const lineMap = new Map(lines.map((line) => [line.id, line]))
331
+ snapshot.lines.forEach((entry) => {
332
+ const line = lineMap.get(entry.orderLineId)
333
+ if (!line) return
334
+ const next = Math.max(0, toNumeric(line.returnedQuantity) - entry.quantityReturned)
335
+ line.returnedQuantity = next.toString()
336
+ line.updatedAt = new Date()
337
+ em.persist(line)
338
+ })
339
+ },
340
+ async () => {
341
+ if (snapshot.adjustmentIds.length) {
342
+ const adjustments = await findWithDecryption(
343
+ em,
344
+ SalesOrderAdjustment,
345
+ { id: { $in: snapshot.adjustmentIds }, deletedAt: null },
346
+ {},
347
+ { tenantId: snapshot.tenantId, organizationId: snapshot.organizationId },
348
+ )
349
+ adjustments.forEach((adj) => em.remove(adj))
350
+ }
351
+
352
+ const header = await findOneWithDecryption(
353
+ em,
354
+ SalesReturn,
355
+ { id: snapshot.id, deletedAt: null },
356
+ {},
357
+ { tenantId: snapshot.tenantId, organizationId: snapshot.organizationId },
358
+ )
359
+ const returnLines = await findWithDecryption(
360
+ em,
361
+ SalesReturnLine,
362
+ { salesReturn: snapshot.id, deletedAt: null },
363
+ {},
364
+ { tenantId: snapshot.tenantId, organizationId: snapshot.organizationId },
365
+ )
366
+ returnLines.forEach((line) => em.remove(line))
367
+ if (header) em.remove(header)
368
+
369
+ const existingAdjustments = await findWithDecryption(
370
+ em,
371
+ SalesOrderAdjustment,
372
+ { order: order.id, deletedAt: null },
373
+ { orderBy: { position: 'asc' } },
374
+ { tenantId: snapshot.tenantId, organizationId: snapshot.organizationId },
375
+ )
376
+ const lineSnapshots: SalesLineSnapshot[] = lines.map(mapOrderLineEntityToSnapshot)
377
+ const adjustmentDrafts: SalesAdjustmentDraft[] = existingAdjustments.map(mapOrderAdjustmentToDraft)
378
+ const calculation = await salesCalculationService.calculateDocumentTotals({
379
+ documentKind: 'order',
380
+ lines: lineSnapshots,
381
+ adjustments: adjustmentDrafts,
382
+ context: buildCalculationContext(order),
383
+ })
384
+ applyOrderTotals(order, calculation.totals, calculation.lines.length)
385
+ order.updatedAt = new Date()
386
+ em.persist(order)
387
+ },
388
+ ],
389
+ { transaction: true },
390
+ )
391
+ }
392
+
393
+ /**
394
+ * Re-apply a return from a snapshot: recreate the return header + lines and the
395
+ * line-scoped credit adjustments, bump each order line's `returnedQuantity`, and
396
+ * recalculate the order totals. Shared by the create command's redo and the
397
+ * delete command's undo. Returns the recreated return lines so callers can emit
398
+ * index side effects. Throws a 404 when the order is gone.
399
+ */
400
+ async function restoreReturnEffects(
401
+ em: EntityManager,
402
+ salesCalculationService: SalesCalculationService,
403
+ snapshot: ReturnSnapshot,
404
+ ): Promise<SalesReturnLine[]> {
405
+ const returnId = snapshot.id
406
+ const createdLines: SalesReturnLine[] = []
407
+
408
+ await withAtomicFlush(
409
+ em,
410
+ [
411
+ async () => {
412
+ const order = await findOneWithDecryption(
413
+ em,
414
+ SalesOrder,
415
+ { id: snapshot.orderId, deletedAt: null },
416
+ {},
417
+ { tenantId: snapshot.tenantId, organizationId: snapshot.organizationId },
418
+ )
419
+ if (!order) {
420
+ throw new CrudHttpError(404, { error: 'sales.returns.orderMissing' })
421
+ }
422
+ ensureSameScope(order, snapshot.organizationId, snapshot.tenantId)
423
+
424
+ const orderLines = await findWithDecryption(
425
+ em,
426
+ SalesOrderLine,
427
+ { order: order.id, deletedAt: null },
428
+ { lockMode: LockMode.PESSIMISTIC_WRITE },
429
+ { tenantId: snapshot.tenantId, organizationId: snapshot.organizationId },
430
+ )
431
+ const lineMap = new Map(orderLines.map((line) => [line.id, line]))
432
+
433
+ const existingAdjustments = await findWithDecryption(
434
+ em,
435
+ SalesOrderAdjustment,
436
+ { order: order.id, deletedAt: null },
437
+ { orderBy: { position: 'asc' } },
438
+ { tenantId: snapshot.tenantId, organizationId: snapshot.organizationId },
439
+ )
440
+ const positionStart = existingAdjustments.reduce((acc, adj) => Math.max(acc, adj.position ?? 0), 0) + 1
441
+
442
+ const restoredHeader =
443
+ (await findOneWithDecryption(
444
+ em,
445
+ SalesReturn,
446
+ { id: snapshot.id },
447
+ {},
448
+ { tenantId: snapshot.tenantId, organizationId: snapshot.organizationId },
449
+ )) ??
450
+ em.create(SalesReturn, {
451
+ id: snapshot.id,
452
+ order,
453
+ organizationId: snapshot.organizationId,
454
+ tenantId: snapshot.tenantId,
455
+ returnNumber: snapshot.returnNumber,
456
+ reason: snapshot.reason ?? null,
457
+ notes: snapshot.notes ?? null,
458
+ returnedAt: snapshot.returnedAt ? new Date(snapshot.returnedAt) : new Date(),
459
+ createdAt: new Date(),
460
+ updatedAt: new Date(),
461
+ })
462
+ restoredHeader.order = order
463
+ restoredHeader.deletedAt = null
464
+ restoredHeader.organizationId = snapshot.organizationId
465
+ restoredHeader.tenantId = snapshot.tenantId
466
+ restoredHeader.returnNumber = snapshot.returnNumber
467
+ restoredHeader.reason = snapshot.reason ?? null
468
+ restoredHeader.notes = snapshot.notes ?? null
469
+ restoredHeader.returnedAt = snapshot.returnedAt ? new Date(snapshot.returnedAt) : new Date()
470
+ restoredHeader.updatedAt = new Date()
471
+ em.persist(restoredHeader)
472
+
473
+ const createdAdjustments: SalesOrderAdjustment[] = []
474
+ snapshot.lines.forEach((lineSnapshot, index) => {
475
+ const line = lineMap.get(lineSnapshot.orderLineId)
476
+ if (!line) return
477
+ const totalNet = lineSnapshot.totalNetAmount
478
+ const totalGross = lineSnapshot.totalGrossAmount
479
+ const adjustmentId = snapshot.adjustmentIds[index] ?? randomUUID()
480
+
481
+ const returnLine = em.create(SalesReturnLine, {
482
+ id: lineSnapshot.id,
483
+ salesReturn: restoredHeader,
484
+ orderLine: em.getReference(SalesOrderLine, line.id),
485
+ organizationId: snapshot.organizationId,
486
+ tenantId: snapshot.tenantId,
487
+ quantityReturned: lineSnapshot.quantityReturned.toString(),
488
+ unitPriceNet: lineSnapshot.unitPriceNet.toString(),
489
+ unitPriceGross: lineSnapshot.unitPriceGross.toString(),
490
+ totalNetAmount: totalNet.toString(),
491
+ totalGrossAmount: totalGross.toString(),
492
+ createdAt: new Date(),
493
+ updatedAt: new Date(),
494
+ })
495
+ createdLines.push(returnLine)
496
+ em.persist(returnLine)
497
+
498
+ const adjustment = em.create(SalesOrderAdjustment, {
499
+ id: adjustmentId,
500
+ order,
501
+ orderLine: em.getReference(SalesOrderLine, line.id),
502
+ organizationId: snapshot.organizationId,
503
+ tenantId: snapshot.tenantId,
504
+ scope: 'line',
505
+ kind: 'return',
506
+ rate: '0',
507
+ amountNet: totalNet.toString(),
508
+ amountGross: totalGross.toString(),
509
+ currencyCode: order.currencyCode,
510
+ metadata: { returnId, returnLineId: lineSnapshot.id },
511
+ position: positionStart + index,
512
+ createdAt: new Date(),
513
+ updatedAt: new Date(),
514
+ })
515
+ createdAdjustments.push(adjustment)
516
+ em.persist(adjustment)
517
+
518
+ line.returnedQuantity = (toNumeric(line.returnedQuantity) + lineSnapshot.quantityReturned).toString()
519
+ line.updatedAt = new Date()
520
+ em.persist(line)
521
+ })
522
+
523
+ const lineSnapshots: SalesLineSnapshot[] = orderLines.map(mapOrderLineEntityToSnapshot)
524
+ const adjustmentDrafts: SalesAdjustmentDraft[] = [...existingAdjustments, ...createdAdjustments].map(
525
+ mapOrderAdjustmentToDraft,
526
+ )
527
+ const calculation = await salesCalculationService.calculateDocumentTotals({
528
+ documentKind: 'order',
529
+ lines: lineSnapshots,
530
+ adjustments: adjustmentDrafts,
531
+ context: buildCalculationContext(order),
532
+ })
533
+ applyOrderTotals(order, calculation.totals, calculation.lines.length)
534
+ order.updatedAt = new Date()
535
+ em.persist(order)
536
+ },
537
+ ],
538
+ { transaction: true },
539
+ )
540
+
541
+ return createdLines
542
+ }
543
+
248
544
  function normalizeLinesInput(lines: ReturnCreateInput['lines']): ReturnLineInput[] {
249
545
  const seen = new Set<string>()
250
546
  const result: ReturnLineInput[] = []
@@ -464,254 +760,290 @@ const createReturnCommand: CommandHandler<ReturnCreateInput, { returnId: string
464
760
  const after = payload?.after
465
761
  if (!after) return
466
762
  const em = (ctx.container.resolve('em') as EntityManager).fork()
467
- const order = await findOneWithDecryption(
763
+ const salesCalculationService = ctx.container.resolve<SalesCalculationService>('salesCalculationService')
764
+ await reverseReturnEffects(em, salesCalculationService, after)
765
+ },
766
+ redo: async ({ ctx, logEntry }) => {
767
+ const after = resolveRedoSnapshot<ReturnSnapshot>(logEntry)
768
+ if (!after || !after.id) {
769
+ throw new CrudHttpError(400, { error: '[internal] redo snapshot unavailable for sales.returns.create' })
770
+ }
771
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
772
+ const salesCalculationService = ctx.container.resolve<SalesCalculationService>('salesCalculationService')
773
+
774
+ const createdLines = await restoreReturnEffects(em, salesCalculationService, after)
775
+
776
+ const header = await findOneWithDecryption(
468
777
  em,
469
- SalesOrder,
470
- { id: after.orderId, deletedAt: null },
778
+ SalesReturn,
779
+ { id: after.id, deletedAt: null },
471
780
  {},
472
781
  { tenantId: after.tenantId, organizationId: after.organizationId },
473
782
  )
474
- if (!order) return
783
+ if (!header) {
784
+ throw new CrudHttpError(404, { error: 'sales.returns.orderMissing' })
785
+ }
475
786
 
476
- const salesCalculationService = ctx.container.resolve<SalesCalculationService>('salesCalculationService')
787
+ const dataEngine = ctx.container.resolve('dataEngine') as DataEngine
788
+ await emitCrudSideEffects({
789
+ dataEngine,
790
+ action: 'created',
791
+ entity: header,
792
+ identifiers: { id: header.id, organizationId: header.organizationId, tenantId: header.tenantId },
793
+ indexer: { entityType: E.sales.sales_return },
794
+ events: returnCrudEvents,
795
+ })
477
796
 
478
- // Line reversals, adjustment/return removals, and the order-total recompute
479
- // interleave queries on the same EntityManager with scalar mutations, so they
480
- // must run inside an atomic flush to avoid lost updates and partial commits.
481
- let lines: SalesOrderLine[] = []
482
- await withAtomicFlush(
483
- em,
484
- [
485
- async () => {
486
- lines = await findWithDecryption(
487
- em,
488
- SalesOrderLine,
489
- { order: order.id, deletedAt: null },
490
- {},
491
- { tenantId: after.tenantId, organizationId: after.organizationId },
492
- )
493
- const lineMap = new Map(lines.map((line) => [line.id, line]))
494
- after.lines.forEach((entry) => {
495
- const line = lineMap.get(entry.orderLineId)
496
- if (!line) return
497
- const next = Math.max(0, toNumeric(line.returnedQuantity) - entry.quantityReturned)
498
- line.returnedQuantity = next.toString()
499
- line.updatedAt = new Date()
500
- em.persist(line)
501
- })
502
- },
503
- // The line returnedQuantity reversals above are persisted by
504
- // withAtomicFlush's per-phase flush boundary before the adjustment /
505
- // header / return-line lookups below run any query on this
506
- // EntityManager. MikroORM v7 would otherwise silently discard the pending
507
- // scalar changes on the managed `lines` when the next read resets the
508
- // changeset (see SPEC-018).
509
- async () => {
510
- if (after.adjustmentIds.length) {
511
- const adjustments = await findWithDecryption(
512
- em,
513
- SalesOrderAdjustment,
514
- { id: { $in: after.adjustmentIds }, deletedAt: null },
515
- {},
516
- { tenantId: after.tenantId, organizationId: after.organizationId },
517
- )
518
- adjustments.forEach((adj) => em.remove(adj))
519
- }
520
-
521
- const header = await findOneWithDecryption(
522
- em,
523
- SalesReturn,
524
- { id: after.id, deletedAt: null },
525
- {},
526
- { tenantId: after.tenantId, organizationId: after.organizationId },
527
- )
528
- const returnLines = await findWithDecryption(
529
- em,
530
- SalesReturnLine,
531
- { salesReturn: after.id, deletedAt: null },
532
- {},
533
- { tenantId: after.tenantId, organizationId: after.organizationId },
534
- )
535
- returnLines.forEach((line) => em.remove(line))
536
- if (header) em.remove(header)
797
+ if (createdLines.length) {
798
+ await Promise.all(
799
+ createdLines.map((line) =>
800
+ emitCrudSideEffects({
801
+ dataEngine,
802
+ action: 'created',
803
+ entity: line,
804
+ identifiers: { id: line.id, organizationId: line.organizationId, tenantId: line.tenantId },
805
+ indexer: { entityType: E.sales.sales_return_line },
806
+ }),
807
+ ),
808
+ )
809
+ }
537
810
 
538
- const existingAdjustments = await findWithDecryption(
539
- em,
540
- SalesOrderAdjustment,
541
- { order: order.id, deletedAt: null },
542
- { orderBy: { position: 'asc' } },
543
- { tenantId: after.tenantId, organizationId: after.organizationId },
544
- )
545
- const lineSnapshots: SalesLineSnapshot[] = lines.map(mapOrderLineEntityToSnapshot)
546
- const adjustmentDrafts: SalesAdjustmentDraft[] = existingAdjustments.map(mapOrderAdjustmentToDraft)
547
- const calculation = await salesCalculationService.calculateDocumentTotals({
548
- documentKind: 'order',
549
- lines: lineSnapshots,
550
- adjustments: adjustmentDrafts,
551
- context: buildCalculationContext(order),
552
- })
553
- applyOrderTotals(order, calculation.totals, calculation.lines.length)
554
- order.updatedAt = new Date()
555
- em.persist(order)
556
- },
557
- ],
558
- { transaction: true },
559
- )
811
+ return { returnId: header.id }
560
812
  },
561
- redo: async ({ ctx, logEntry }) => {
562
- const after = resolveRedoSnapshot<ReturnSnapshot>(logEntry)
563
- const returnId = after?.id ?? logEntry.resourceId ?? null
564
- if (!after || !returnId) {
565
- throw new CrudHttpError(400, { error: '[internal] redo snapshot unavailable for sales.returns.create' })
813
+ }
814
+
815
+ const updateReturnCommand: CommandHandler<ReturnUpdateInput, { returnId: string }> = {
816
+ id: 'sales.returns.update',
817
+ async prepare(rawInput, ctx) {
818
+ const parsed = returnUpdateSchema.parse(rawInput ?? {})
819
+ const em = ctx.container.resolve('em') as EntityManager
820
+ const snapshot = await loadReturnHeaderSnapshot(em, parsed.id)
821
+ if (snapshot) {
822
+ ensureTenantScope(ctx, snapshot.tenantId)
823
+ ensureOrganizationScope(ctx, snapshot.organizationId)
566
824
  }
825
+ return snapshot ? { before: snapshot } : {}
826
+ },
827
+ async execute(rawInput, ctx) {
828
+ const input = returnUpdateSchema.parse(rawInput ?? {})
829
+ ensureTenantScope(ctx, input.tenantId)
830
+ ensureOrganizationScope(ctx, input.organizationId)
831
+ const { translate } = await resolveTranslations()
567
832
  const em = (ctx.container.resolve('em') as EntityManager).fork()
568
- const salesCalculationService = ctx.container.resolve<SalesCalculationService>('salesCalculationService')
569
833
 
570
- const createdLines: SalesReturnLine[] = []
834
+ const header = await em.transactional(async (tx) => {
835
+ const entity = await findOneWithDecryption(
836
+ tx,
837
+ SalesReturn,
838
+ { id: input.id, deletedAt: null },
839
+ { populate: ['order'] },
840
+ { tenantId: input.tenantId, organizationId: input.organizationId },
841
+ )
842
+ if (!entity || !entity.order) {
843
+ throw new CrudHttpError(404, { error: translate('sales.returns.notFound', 'Return not found.') })
844
+ }
845
+ ensureSameScope(entity, input.organizationId, input.tenantId)
846
+ const orderId = typeof entity.order === 'string' ? entity.order : entity.order.id
847
+ if (input.orderId !== orderId) {
848
+ throw new CrudHttpError(400, { error: translate('sales.returns.orderMismatch', 'Return does not belong to this order.') })
849
+ }
850
+ // Lock on the return's own version — editing header fields (reason / notes /
851
+ // returnedAt) only touches the return, not the order totals.
852
+ enforceSalesDocumentOptimisticLock(ctx, entity, SALES_RESOURCE_KIND_RETURN)
571
853
 
572
- await withAtomicFlush(
573
- em,
574
- [
575
- async () => {
576
- const order = await findOneWithDecryption(
577
- em,
578
- SalesOrder,
579
- { id: after.orderId, deletedAt: null },
580
- {},
581
- { tenantId: after.tenantId, organizationId: after.organizationId },
582
- )
583
- if (!order) {
584
- throw new CrudHttpError(404, { error: 'sales.returns.orderMissing' })
585
- }
586
- ensureSameScope(order, after.organizationId, after.tenantId)
854
+ if (input.reason !== undefined) entity.reason = input.reason.length ? input.reason : null
855
+ if (input.notes !== undefined) entity.notes = input.notes.length ? input.notes : null
856
+ if (input.returnedAt !== undefined) entity.returnedAt = input.returnedAt ?? null
857
+ entity.updatedAt = new Date()
858
+ tx.persist(entity)
859
+ await tx.flush()
860
+ return entity
861
+ })
587
862
 
588
- const orderLines = await findWithDecryption(
589
- em,
590
- SalesOrderLine,
591
- { order: order.id, deletedAt: null },
592
- { lockMode: LockMode.PESSIMISTIC_WRITE },
593
- { tenantId: after.tenantId, organizationId: after.organizationId },
594
- )
595
- const lineMap = new Map(orderLines.map((line) => [line.id, line]))
863
+ const dataEngine = ctx.container.resolve('dataEngine') as DataEngine
864
+ await emitCrudSideEffects({
865
+ dataEngine,
866
+ action: 'updated',
867
+ entity: header,
868
+ identifiers: { id: header.id, organizationId: header.organizationId, tenantId: header.tenantId },
869
+ indexer: { entityType: E.sales.sales_return },
870
+ events: returnCrudEvents,
871
+ })
596
872
 
597
- const existingAdjustments = await findWithDecryption(
598
- em,
599
- SalesOrderAdjustment,
600
- { order: order.id, deletedAt: null },
601
- { orderBy: { position: 'asc' } },
602
- { tenantId: after.tenantId, organizationId: after.organizationId },
603
- )
604
- const positionStart = existingAdjustments.reduce((acc, adj) => Math.max(acc, adj.position ?? 0), 0) + 1
605
-
606
- const restoredHeader =
607
- (await findOneWithDecryption(
608
- em,
609
- SalesReturn,
610
- { id: after.id },
611
- {},
612
- { tenantId: after.tenantId, organizationId: after.organizationId },
613
- )) ??
614
- em.create(SalesReturn, {
615
- id: after.id,
616
- order,
617
- organizationId: after.organizationId,
618
- tenantId: after.tenantId,
619
- returnNumber: after.returnNumber,
620
- reason: after.reason ?? null,
621
- notes: after.notes ?? null,
622
- returnedAt: after.returnedAt ? new Date(after.returnedAt) : new Date(),
623
- createdAt: new Date(),
624
- updatedAt: new Date(),
625
- })
626
- restoredHeader.order = order
627
- restoredHeader.deletedAt = null
628
- restoredHeader.organizationId = after.organizationId
629
- restoredHeader.tenantId = after.tenantId
630
- restoredHeader.returnNumber = after.returnNumber
631
- restoredHeader.reason = after.reason ?? null
632
- restoredHeader.notes = after.notes ?? null
633
- restoredHeader.returnedAt = after.returnedAt ? new Date(after.returnedAt) : new Date()
634
- restoredHeader.updatedAt = new Date()
635
- em.persist(restoredHeader)
636
-
637
- const createdAdjustments: SalesOrderAdjustment[] = []
638
- after.lines.forEach((lineSnapshot, index) => {
639
- const line = lineMap.get(lineSnapshot.orderLineId)
640
- if (!line) return
641
- const totalNet = lineSnapshot.totalNetAmount
642
- const totalGross = lineSnapshot.totalGrossAmount
643
- const adjustmentId = after.adjustmentIds[index] ?? randomUUID()
644
-
645
- const returnLine = em.create(SalesReturnLine, {
646
- id: lineSnapshot.id,
647
- salesReturn: restoredHeader,
648
- orderLine: em.getReference(SalesOrderLine, line.id),
649
- organizationId: after.organizationId,
650
- tenantId: after.tenantId,
651
- quantityReturned: lineSnapshot.quantityReturned.toString(),
652
- unitPriceNet: lineSnapshot.unitPriceNet.toString(),
653
- unitPriceGross: lineSnapshot.unitPriceGross.toString(),
654
- totalNetAmount: totalNet.toString(),
655
- totalGrossAmount: totalGross.toString(),
656
- createdAt: new Date(),
657
- updatedAt: new Date(),
658
- })
659
- createdLines.push(returnLine)
660
- em.persist(returnLine)
661
-
662
- const adjustment = em.create(SalesOrderAdjustment, {
663
- id: adjustmentId,
664
- order,
665
- orderLine: em.getReference(SalesOrderLine, line.id),
666
- organizationId: after.organizationId,
667
- tenantId: after.tenantId,
668
- scope: 'line',
669
- kind: 'return',
670
- rate: '0',
671
- amountNet: totalNet.toString(),
672
- amountGross: totalGross.toString(),
673
- currencyCode: order.currencyCode,
674
- metadata: { returnId, returnLineId: lineSnapshot.id },
675
- position: positionStart + index,
676
- createdAt: new Date(),
677
- updatedAt: new Date(),
678
- })
679
- createdAdjustments.push(adjustment)
680
- em.persist(adjustment)
681
-
682
- line.returnedQuantity = (toNumeric(line.returnedQuantity) + lineSnapshot.quantityReturned).toString()
683
- line.updatedAt = new Date()
684
- em.persist(line)
685
- })
873
+ return { returnId: header.id }
874
+ },
875
+ captureAfter: async (_input, result, ctx) => {
876
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
877
+ return loadReturnHeaderSnapshot(em, result.returnId)
878
+ },
879
+ buildLog: async ({ snapshots, result }) => {
880
+ const { translate } = await resolveTranslations()
881
+ const before = snapshots.before as ReturnHeaderSnapshot | undefined
882
+ const after = snapshots.after as ReturnHeaderSnapshot | undefined
883
+ return {
884
+ actionLabel: translate('sales.audit.returns.update', 'Update return'),
885
+ resourceKind: 'sales.return',
886
+ resourceId: result.returnId,
887
+ parentResourceKind: 'sales.order',
888
+ parentResourceId: after?.orderId ?? before?.orderId ?? null,
889
+ tenantId: after?.tenantId ?? before?.tenantId ?? null,
890
+ organizationId: after?.organizationId ?? before?.organizationId ?? null,
891
+ snapshotBefore: before ?? null,
892
+ snapshotAfter: after ?? null,
893
+ payload: {
894
+ undo: { before, after } satisfies ReturnHeaderUndoPayload,
895
+ },
896
+ }
897
+ },
898
+ undo: async ({ logEntry, ctx }) => {
899
+ const payload = extractUndoPayload<ReturnHeaderUndoPayload>(logEntry)
900
+ const before = payload?.before
901
+ if (!before) return
902
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
903
+ await em.transactional(async (tx) => {
904
+ const entity = await findOneWithDecryption(
905
+ tx,
906
+ SalesReturn,
907
+ { id: before.id, deletedAt: null },
908
+ {},
909
+ { tenantId: before.tenantId, organizationId: before.organizationId },
910
+ )
911
+ if (!entity) return
912
+ entity.reason = before.reason
913
+ entity.notes = before.notes
914
+ entity.returnedAt = before.returnedAt ? new Date(before.returnedAt) : null
915
+ entity.updatedAt = new Date()
916
+ tx.persist(entity)
917
+ await tx.flush()
918
+ })
686
919
 
687
- const lineSnapshots: SalesLineSnapshot[] = orderLines.map(mapOrderLineEntityToSnapshot)
688
- const adjustmentDrafts: SalesAdjustmentDraft[] = [...existingAdjustments, ...createdAdjustments].map(
689
- mapOrderAdjustmentToDraft,
690
- )
691
- const calculation = await salesCalculationService.calculateDocumentTotals({
692
- documentKind: 'order',
693
- lines: lineSnapshots,
694
- adjustments: adjustmentDrafts,
695
- context: buildCalculationContext(order),
696
- })
697
- applyOrderTotals(order, calculation.totals, calculation.lines.length)
698
- order.updatedAt = new Date()
699
- em.persist(order)
700
- },
701
- ],
702
- { transaction: true },
920
+ const dataEngine = ctx.container.resolve('dataEngine') as DataEngine
921
+ const restored = await findOneWithDecryption(
922
+ em,
923
+ SalesReturn,
924
+ { id: before.id, deletedAt: null },
925
+ {},
926
+ { tenantId: before.tenantId, organizationId: before.organizationId },
703
927
  )
928
+ if (restored) {
929
+ await emitCrudSideEffects({
930
+ dataEngine,
931
+ action: 'updated',
932
+ entity: restored,
933
+ identifiers: { id: restored.id, organizationId: restored.organizationId, tenantId: restored.tenantId },
934
+ indexer: { entityType: E.sales.sales_return },
935
+ events: returnCrudEvents,
936
+ })
937
+ }
938
+ },
939
+ }
940
+
941
+ const deleteReturnCommand: CommandHandler<ReturnDeleteInput, { returnId: string }> = {
942
+ id: 'sales.returns.delete',
943
+ async prepare(rawInput, ctx) {
944
+ const parsed = returnDeleteSchema.parse(rawInput ?? {})
945
+ const em = ctx.container.resolve('em') as EntityManager
946
+ const snapshot = await loadReturnSnapshot(em, parsed.id)
947
+ if (snapshot) {
948
+ ensureTenantScope(ctx, snapshot.tenantId)
949
+ ensureOrganizationScope(ctx, snapshot.organizationId)
950
+ }
951
+ return snapshot ? { before: snapshot } : {}
952
+ },
953
+ async execute(rawInput, ctx) {
954
+ const input = returnDeleteSchema.parse(rawInput ?? {})
955
+ ensureTenantScope(ctx, input.tenantId)
956
+ ensureOrganizationScope(ctx, input.organizationId)
957
+ const { translate } = await resolveTranslations()
958
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
959
+ const salesCalculationService = ctx.container.resolve<SalesCalculationService>('salesCalculationService')
960
+
961
+ const snapshot = await loadReturnSnapshot(em, input.id)
962
+ if (!snapshot) {
963
+ throw new CrudHttpError(404, { error: translate('sales.returns.notFound', 'Return not found.') })
964
+ }
965
+ ensureSameScope(snapshot, input.organizationId, input.tenantId)
966
+ if (input.orderId !== snapshot.orderId) {
967
+ throw new CrudHttpError(400, { error: translate('sales.returns.orderMismatch', 'Return does not belong to this order.') })
968
+ }
704
969
 
705
970
  const header = await findOneWithDecryption(
706
971
  em,
707
972
  SalesReturn,
708
- { id: after.id, deletedAt: null },
973
+ { id: input.id, deletedAt: null },
709
974
  {},
710
- { tenantId: after.tenantId, organizationId: after.organizationId },
975
+ { tenantId: input.tenantId, organizationId: input.organizationId },
711
976
  )
712
977
  if (!header) {
713
- throw new CrudHttpError(404, { error: 'sales.returns.orderMissing' })
978
+ throw new CrudHttpError(404, { error: translate('sales.returns.notFound', 'Return not found.') })
979
+ }
980
+ ensureSameScope(header, input.organizationId, input.tenantId)
981
+ // Lock on the return's own version, captured before any mutation.
982
+ enforceSalesDocumentOptimisticLock(ctx, header, SALES_RESOURCE_KIND_RETURN)
983
+
984
+ await reverseReturnEffects(em, salesCalculationService, snapshot)
985
+
986
+ const dataEngine = ctx.container.resolve('dataEngine') as DataEngine
987
+ await emitCrudSideEffects({
988
+ dataEngine,
989
+ action: 'deleted',
990
+ entity: header,
991
+ identifiers: { id: snapshot.id, organizationId: snapshot.organizationId, tenantId: snapshot.tenantId },
992
+ indexer: { entityType: E.sales.sales_return },
993
+ events: returnCrudEvents,
994
+ })
995
+
996
+ if (snapshot.lines.length) {
997
+ await Promise.all(
998
+ snapshot.lines.map((line) =>
999
+ emitCrudSideEffects({
1000
+ dataEngine,
1001
+ action: 'deleted',
1002
+ entity: { id: line.id, organizationId: snapshot.organizationId, tenantId: snapshot.tenantId },
1003
+ identifiers: { id: line.id, organizationId: snapshot.organizationId, tenantId: snapshot.tenantId },
1004
+ indexer: { entityType: E.sales.sales_return_line },
1005
+ }),
1006
+ ),
1007
+ )
1008
+ }
1009
+
1010
+ return { returnId: snapshot.id }
1011
+ },
1012
+ buildLog: async ({ snapshots, result }) => {
1013
+ const before = snapshots.before as ReturnSnapshot | undefined
1014
+ if (!before) return null
1015
+ const { translate } = await resolveTranslations()
1016
+ return {
1017
+ actionLabel: translate('sales.audit.returns.delete', 'Delete return'),
1018
+ resourceKind: 'sales.return',
1019
+ resourceId: result.returnId,
1020
+ parentResourceKind: 'sales.order',
1021
+ parentResourceId: before.orderId ?? null,
1022
+ tenantId: before.tenantId,
1023
+ organizationId: before.organizationId,
1024
+ snapshotBefore: before,
1025
+ payload: {
1026
+ undo: { before } satisfies ReturnDeleteUndoPayload,
1027
+ },
714
1028
  }
1029
+ },
1030
+ undo: async ({ logEntry, ctx }) => {
1031
+ const payload = extractUndoPayload<ReturnDeleteUndoPayload>(logEntry)
1032
+ const before = payload?.before
1033
+ if (!before) return
1034
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
1035
+ const salesCalculationService = ctx.container.resolve<SalesCalculationService>('salesCalculationService')
1036
+
1037
+ const createdLines = await restoreReturnEffects(em, salesCalculationService, before)
1038
+
1039
+ const header = await findOneWithDecryption(
1040
+ em,
1041
+ SalesReturn,
1042
+ { id: before.id, deletedAt: null },
1043
+ {},
1044
+ { tenantId: before.tenantId, organizationId: before.organizationId },
1045
+ )
1046
+ if (!header) return
715
1047
 
716
1048
  const dataEngine = ctx.container.resolve('dataEngine') as DataEngine
717
1049
  await emitCrudSideEffects({
@@ -736,11 +1068,11 @@ const createReturnCommand: CommandHandler<ReturnCreateInput, { returnId: string
736
1068
  ),
737
1069
  )
738
1070
  }
739
-
740
- return { returnId: header.id }
741
1071
  },
742
1072
  }
743
1073
 
744
1074
  registerCommand(createReturnCommand)
1075
+ registerCommand(updateReturnCommand)
1076
+ registerCommand(deleteReturnCommand)
745
1077
 
746
- export const returnCommands = [createReturnCommand]
1078
+ export const returnCommands = [createReturnCommand, updateReturnCommand, deleteReturnCommand]