@open-mercato/core 0.10.1-develop.7728.1.6b52eb2849 → 0.10.1-develop.7736.1.88e07906bf

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.
@@ -15,7 +15,7 @@ import type { SalesCalculationService } from '../services/salesCalculationServic
15
15
  import type { SalesAdjustmentDraft, SalesLineSnapshot, SalesDocumentCalculationResult } from '../lib/types'
16
16
  import { isExternalAmountsMode } from '../lib/calculations'
17
17
  import { readPersistedHeaderTotals } from '../lib/externalAmounts'
18
- import { cloneJson, deriveLineNetFromGross, ensureOrganizationScope, ensureSameScope, ensureTenantScope, extractUndoPayload, toNumericString, enforceSalesDocumentOptimisticLock, SALES_RESOURCE_KIND_ORDER, SALES_RESOURCE_KIND_RETURN } from './shared'
18
+ import { cloneJson, deriveLineNetFromGross, ensureOrganizationScope, ensureSameScope, ensureTenantScope, extractUndoPayload, toNumericString, enforceSalesDocumentOptimisticLock, lockSalesDocumentRow, SALES_RESOURCE_KIND_ORDER, SALES_RESOURCE_KIND_RETURN } from './shared'
19
19
  import { resolveRedoSnapshot } from '@open-mercato/shared/lib/commands/redo'
20
20
  import { SalesOrder, SalesOrderAdjustment, SalesOrderLine, SalesReturn, SalesReturnLine } from '../data/entities'
21
21
  import { mapOrderLineEntityToSnapshot } from '../lib/lineSnapshots'
@@ -317,6 +317,21 @@ async function loadReturnHeaderSnapshot(em: EntityManager, id: string): Promise<
317
317
  }
318
318
  }
319
319
 
320
+ async function lockReturnOrder(
321
+ em: EntityManager,
322
+ orderId: string,
323
+ scope: { tenantId: string; organizationId: string },
324
+ ): Promise<SalesOrder | null> {
325
+ if (!(await lockSalesDocumentRow(em, 'sales_orders', orderId, scope))) return null
326
+ return findOneWithDecryption(
327
+ em,
328
+ SalesOrder,
329
+ { id: orderId, deletedAt: null, ...scope },
330
+ { refresh: true },
331
+ scope,
332
+ )
333
+ }
334
+
320
335
  /**
321
336
  * Reverse the order-level effects of a return: restore each order line's
322
337
  * `returnedQuantity`, drop the return's line-scoped credit adjustments, remove
@@ -337,20 +352,17 @@ async function reverseReturnEffects(
337
352
  salesCalculationService: SalesCalculationService,
338
353
  snapshot: ReturnSnapshot,
339
354
  ): Promise<void> {
340
- const order = await findOneWithDecryption(
341
- em,
342
- SalesOrder,
343
- { id: snapshot.orderId, deletedAt: null },
344
- {},
345
- { tenantId: snapshot.tenantId, organizationId: snapshot.organizationId },
346
- )
347
- if (!order) return
348
-
355
+ let order: SalesOrder | null = null
349
356
  let lines: SalesOrderLine[] = []
350
357
  await withAtomicFlush(
351
358
  em,
352
359
  [
353
360
  async () => {
361
+ order = await lockReturnOrder(em, snapshot.orderId, {
362
+ tenantId: snapshot.tenantId,
363
+ organizationId: snapshot.organizationId,
364
+ })
365
+ if (!order) return
354
366
  lines = await findWithDecryption(
355
367
  em,
356
368
  SalesOrderLine,
@@ -369,6 +381,7 @@ async function reverseReturnEffects(
369
381
  })
370
382
  },
371
383
  async () => {
384
+ if (!order) return
372
385
  if (snapshot.adjustmentIds.length) {
373
386
  const adjustments = await findWithDecryption(
374
387
  em,
@@ -450,17 +463,24 @@ async function restoreReturnEffects(
450
463
  em,
451
464
  [
452
465
  async () => {
453
- const order = await findOneWithDecryption(
466
+ const existingOrder = await findOneWithDecryption(
454
467
  em,
455
468
  SalesOrder,
456
469
  { id: snapshot.orderId, deletedAt: null },
457
470
  {},
458
471
  { tenantId: snapshot.tenantId, organizationId: snapshot.organizationId },
459
472
  )
473
+ if (!existingOrder) {
474
+ throw notFound(translate('sales.returns.orderMissing', 'Order not found.'))
475
+ }
476
+ ensureSameScope(existingOrder, snapshot.organizationId, snapshot.tenantId)
477
+ const order = await lockReturnOrder(em, existingOrder.id, {
478
+ tenantId: snapshot.tenantId,
479
+ organizationId: snapshot.organizationId,
480
+ })
460
481
  if (!order) {
461
482
  throw notFound(translate('sales.returns.orderMissing', 'Order not found.'))
462
483
  }
463
- ensureSameScope(order, snapshot.organizationId, snapshot.tenantId)
464
484
 
465
485
  const orderLines = await findWithDecryption(
466
486
  em,
@@ -643,17 +663,24 @@ const createReturnCommand: CommandHandler<ReturnCreateInput, { returnId: string
643
663
 
644
664
  const salesCalculationService = ctx.container.resolve<SalesCalculationService>('salesCalculationService')
645
665
  const { header, createdLines, order } = await em.transactional(async (tx) => {
646
- const order = await findOneWithDecryption(
666
+ const existingOrder = await findOneWithDecryption(
647
667
  tx,
648
668
  SalesOrder,
649
669
  { id: input.orderId, deletedAt: null },
650
670
  {},
651
671
  { tenantId: input.tenantId, organizationId: input.organizationId },
652
672
  )
673
+ if (!existingOrder) {
674
+ throw notFound(translate('sales.returns.orderMissing', 'Order not found.'))
675
+ }
676
+ ensureSameScope(existingOrder, input.organizationId, input.tenantId)
677
+ const order = await lockReturnOrder(tx, existingOrder.id, {
678
+ tenantId: input.tenantId,
679
+ organizationId: input.organizationId,
680
+ })
653
681
  if (!order) {
654
682
  throw notFound(translate('sales.returns.orderMissing', 'Order not found.'))
655
683
  }
656
- ensureSameScope(order, input.organizationId, input.tenantId)
657
684
  await enforceSalesDocumentOptimisticLock(ctx, order, SALES_RESOURCE_KIND_ORDER)
658
685
 
659
686
  const orderLines = await findWithDecryption(
@@ -49,6 +49,46 @@ export async function enforceSalesDocumentOptimisticLock(
49
49
  })
50
50
  }
51
51
 
52
+ type SalesDocumentLockTable = 'sales_orders' | 'sales_quotes'
53
+ type SalesDocumentLockRow = {
54
+ id: string
55
+ tenant_id: string
56
+ organization_id: string
57
+ deleted_at: Date | null
58
+ }
59
+ type SalesDocumentLockDatabase = Record<SalesDocumentLockTable, SalesDocumentLockRow>
60
+
61
+ /**
62
+ * Take the parent-document row lock that serializes every write deriving the
63
+ * order/quote header (totals, line count, paid amounts) or the document's child
64
+ * rows (lines, adjustments, returned/fulfilled quantities) inside the caller's
65
+ * transaction. Acquire it BEFORE reading the child rows the header is
66
+ * recalculated from, and before locking those child rows, so all writers use the
67
+ * same parent-then-children lock order (#6463).
68
+ *
69
+ * `FOR NO KEY UPDATE` conflicts with other writers of the same document, including
70
+ * the `FOR UPDATE` locks taken by payments and undo, while still allowing the
71
+ * `KEY SHARE` locks that foreign-key inserts (for example shipments) take on the
72
+ * parent row. Returns false when no live row matches the scoped id.
73
+ */
74
+ export async function lockSalesDocumentRow(
75
+ em: EntityManager,
76
+ table: SalesDocumentLockTable,
77
+ id: string,
78
+ scope: { tenantId: string; organizationId: string },
79
+ ): Promise<boolean> {
80
+ const locked = await em.getKysely<SalesDocumentLockDatabase>()
81
+ .selectFrom(table)
82
+ .select('id')
83
+ .where('id', '=', id)
84
+ .where('tenant_id', '=', scope.tenantId)
85
+ .where('organization_id', '=', scope.organizationId)
86
+ .where('deleted_at', 'is', null)
87
+ .forNoKeyUpdate()
88
+ .executeTakeFirst()
89
+ return Boolean(locked)
90
+ }
91
+
52
92
  export { cloneJson } from '../lib/json'
53
93
 
54
94
  export function toNumericString(value: number | null | undefined): string | null {
@@ -31,6 +31,7 @@ import {
31
31
  ensureTenantScope,
32
32
  extractUndoPayload,
33
33
  enforceSalesDocumentOptimisticLock,
34
+ lockSalesDocumentRow,
34
35
  SALES_RESOURCE_KIND_ORDER,
35
36
  } from './shared'
36
37
  import { resolveDictionaryEntryValue } from '../lib/dictionaries'
@@ -312,8 +313,16 @@ async function deleteShipmentWithItems(em: EntityManager, shipment: SalesShipmen
312
313
  await em.flush()
313
314
  }
314
315
 
316
+ async function lockOrderBeforeLines(em: EntityManager, order: SalesOrder): Promise<void> {
317
+ await lockSalesDocumentRow(em, 'sales_orders', order.id, {
318
+ tenantId: order.tenantId,
319
+ organizationId: order.organizationId,
320
+ })
321
+ }
322
+
315
323
  async function recomputeFulfilledQuantities(em: EntityManager, order: SalesOrder): Promise<void> {
316
324
  const scope = { tenantId: order.tenantId, organizationId: order.organizationId }
325
+ await lockOrderBeforeLines(em, order)
317
326
  const shipments = await findWithDecryption(em, SalesShipment, { order, deletedAt: null }, {}, scope)
318
327
  const shipmentIds = shipments.map((entry) => entry.id)
319
328
  const shipmentItems = shipmentIds.length
@@ -386,6 +395,7 @@ async function validateShipmentItems(params: {
386
395
  throw new CrudHttpError(400, { error: translate('sales.shipments.items_required', 'Add at least one line to ship.') })
387
396
  }
388
397
  const scope = { tenantId: order.tenantId, organizationId: order.organizationId }
398
+ if (lockOrderLines) await lockOrderBeforeLines(em, order)
389
399
  const orderLines = await findWithDecryption(
390
400
  em,
391
401
  SalesOrderLine,
@@ -379,9 +379,13 @@ const crud = makeCrudRoute({
379
379
  .filter((value) => value.length > 0)
380
380
  }
381
381
  if (parseBooleanFlag(query.mine) && ctx.auth) {
382
+ const tenantId = ctx.auth.tenantId ?? null
383
+ const organizationId = ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null
384
+ if (!tenantId || !organizationId) {
385
+ filters[F.id] = { $in: [IMPOSSIBLE_PROJECT_ID] }
386
+ return filters
387
+ }
382
388
  const em = ctx.container.resolve('em') as EntityManager
383
- const tenantId = ctx.auth.tenantId ?? ''
384
- const organizationId = ctx.selectedOrganizationId ?? ctx.auth.orgId ?? ''
385
389
  const assigned = await resolveCallerAssignedProjectIds(
386
390
  em,
387
391
  ctx.auth.sub,