@open-mercato/core 0.6.6-develop.6305.1.4503070c9d → 0.6.6-develop.6313.1.618a49a6b4

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.
@@ -14,7 +14,9 @@ import type { SalesCalculationService } from '../services/salesCalculationServic
14
14
  import type { SalesAdjustmentDraft, SalesLineSnapshot, SalesDocumentCalculationResult } from '../lib/types'
15
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
- import { SalesOrder, SalesOrderAdjustment, SalesOrderLine, SalesReturn, SalesReturnLine } from '../data/entities'
17
+ import { SalesOrder, SalesOrderAdjustment, SalesOrderLine, SalesReturn, SalesReturnLine, SalesShipment, SalesShipmentItem } from '../data/entities'
18
+ import { coerceShipmentQuantity } from '../lib/shipments/snapshots'
19
+ import { computeAvailableReturnQuantity } from '../lib/returnQuantity'
18
20
  import {
19
21
  returnCreateSchema,
20
22
  returnUpdateSchema,
@@ -541,6 +543,36 @@ async function restoreReturnEffects(
541
543
  return createdLines
542
544
  }
543
545
 
546
+ async function loadShippedQuantityByLine(
547
+ em: EntityManager,
548
+ orderId: string,
549
+ scope: { tenantId: string; organizationId: string },
550
+ ): Promise<Map<string, number>> {
551
+ const shipments = await findWithDecryption(
552
+ em,
553
+ SalesShipment,
554
+ { order: orderId, deletedAt: null },
555
+ {},
556
+ scope,
557
+ )
558
+ const shippedByLine = new Map<string, number>()
559
+ if (!shipments.length) return shippedByLine
560
+ const items = await findWithDecryption(
561
+ em,
562
+ SalesShipmentItem,
563
+ { shipment: { $in: shipments.map((shipment) => shipment.id) } },
564
+ {},
565
+ scope,
566
+ )
567
+ items.forEach((item) => {
568
+ const orderLineId = typeof item.orderLine === 'string' ? item.orderLine : item.orderLine?.id ?? null
569
+ if (!orderLineId) return
570
+ const next = (shippedByLine.get(orderLineId) ?? 0) + coerceShipmentQuantity(item.quantity)
571
+ shippedByLine.set(orderLineId, next)
572
+ })
573
+ return shippedByLine
574
+ }
575
+
544
576
  function normalizeLinesInput(lines: ReturnCreateInput['lines']): ReturnLineInput[] {
545
577
  const seen = new Set<string>()
546
578
  const result: ReturnLineInput[] = []
@@ -594,14 +626,23 @@ const createReturnCommand: CommandHandler<ReturnCreateInput, { returnId: string
594
626
  )
595
627
  const lineMap = new Map(orderLines.map((line) => [line.id, line]))
596
628
 
629
+ const shippedByLine = await loadShippedQuantityByLine(tx, order.id, {
630
+ tenantId: input.tenantId,
631
+ organizationId: input.organizationId,
632
+ })
633
+
597
634
  requested.forEach(({ orderLineId, quantity }) => {
598
635
  const line = lineMap.get(orderLineId)
599
636
  if (!line) {
600
637
  throw new CrudHttpError(404, { error: translate('sales.returns.lineMissing', 'Order line not found.') })
601
638
  }
602
- const available = toNumeric(line.quantity) - toNumeric(line.returnedQuantity)
639
+ const available = computeAvailableReturnQuantity({
640
+ quantity: toNumeric(line.quantity),
641
+ returnedQuantity: toNumeric(line.returnedQuantity),
642
+ shippedQuantity: shippedByLine.get(orderLineId) ?? 0,
643
+ })
603
644
  if (quantity - 1e-6 > available) {
604
- throw new CrudHttpError(400, { error: translate('sales.returns.quantityExceeded', 'Cannot return more than the remaining quantity.') })
645
+ throw new CrudHttpError(400, { error: translate('sales.returns.quantityExceedsShipped', 'Cannot return more than the shipped quantity. Ship the items before recording a return.') })
605
646
  }
606
647
  })
607
648
 
@@ -22,6 +22,7 @@ export type ReturnOrderLine = {
22
22
  lineNumber: number | null
23
23
  quantity: number
24
24
  returnedQuantity: number
25
+ shippedQuantity?: number
25
26
  thumbnail?: string | null
26
27
  }
27
28
 
@@ -17,6 +17,7 @@ import {
17
17
  emitSalesDocumentTotalsRefresh,
18
18
  subscribeSalesDocumentTotalsRefresh,
19
19
  } from '@open-mercato/core/modules/sales/lib/frontend/documentTotalsEvents'
20
+ import { sumShippedQuantityByLine } from '@open-mercato/core/modules/sales/lib/returnQuantity'
20
21
  import { formatMoney, normalizeNumber } from './lineItemUtils'
21
22
  import { ReturnDialog, type ReturnOrderLine } from './ReturnDialog'
22
23
  import { ReturnEditDialog, type ReturnEditRecord } from './ReturnEditDialog'
@@ -60,14 +61,25 @@ export function SalesReturnsSection({ orderId, currencyCode, documentUpdatedAt }
60
61
 
61
62
  const loadLines = React.useCallback(async () => {
62
63
  const params = new URLSearchParams({ page: '1', pageSize: '100', orderId })
63
- const response = await apiCall<{ items?: Array<Record<string, unknown>> }>(
64
- `/api/sales/order-lines?${params.toString()}`,
65
- undefined,
66
- { fallback: { items: [] } },
64
+ const shipmentParams = new URLSearchParams({ page: '1', pageSize: '200', orderId })
65
+ const [response, shipmentsResponse] = await Promise.all([
66
+ apiCall<{ items?: Array<Record<string, unknown>> }>(
67
+ `/api/sales/order-lines?${params.toString()}`,
68
+ undefined,
69
+ { fallback: { items: [] } },
70
+ ),
71
+ apiCall<{ items?: Array<Record<string, unknown>> }>(
72
+ `/api/sales/shipments?${shipmentParams.toString()}`,
73
+ undefined,
74
+ { fallback: { items: [] } },
75
+ ),
76
+ ])
77
+ const shippedByLine = sumShippedQuantityByLine(
78
+ Array.isArray(shipmentsResponse.result?.items) ? shipmentsResponse.result?.items : [],
67
79
  )
68
80
  const items = Array.isArray(response.result?.items) ? response.result?.items ?? [] : []
69
81
  const mapped: ReturnOrderLine[] = items
70
- .map((item) => {
82
+ .map((item): ReturnOrderLine | null => {
71
83
  const map = item as Record<string, unknown>
72
84
  const id = typeof map.id === 'string' ? map.id : null
73
85
  if (!id) return null
@@ -110,6 +122,7 @@ export function SalesReturnsSection({ orderId, currencyCode, documentUpdatedAt }
110
122
  lineNumber,
111
123
  quantity: Number.isFinite(quantity) ? quantity : 0,
112
124
  returnedQuantity: Number.isFinite(returnedQuantity) ? returnedQuantity : 0,
125
+ shippedQuantity: shippedByLine.get(id) ?? 0,
113
126
  }
114
127
  })
115
128
  .filter((entry): entry is ReturnOrderLine => Boolean(entry?.id))
@@ -1326,6 +1326,7 @@
1326
1326
  "sales.returns.qty.increase": "Menge erhöhen",
1327
1327
  "sales.returns.quantity": "Menge",
1328
1328
  "sales.returns.quantityExceeded": "Es kann nicht mehr als die verbleibende Menge zurückgegeben werden.",
1329
+ "sales.returns.quantityExceedsShipped": "Es kann nicht mehr als die versandte Menge zurückgegeben werden. Versenden Sie die Artikel, bevor Sie eine Rücksendung erfassen.",
1329
1330
  "sales.returns.reason": "Grund",
1330
1331
  "sales.returns.reason.placeholder": "Optional",
1331
1332
  "sales.returns.returnNumber": "Rückgabe",
@@ -1326,6 +1326,7 @@
1326
1326
  "sales.returns.qty.increase": "Increase quantity",
1327
1327
  "sales.returns.quantity": "Quantity",
1328
1328
  "sales.returns.quantityExceeded": "Cannot return more than the remaining quantity.",
1329
+ "sales.returns.quantityExceedsShipped": "Cannot return more than the shipped quantity. Ship the items before recording a return.",
1329
1330
  "sales.returns.reason": "Reason",
1330
1331
  "sales.returns.reason.placeholder": "Optional",
1331
1332
  "sales.returns.returnNumber": "Return",
@@ -1326,6 +1326,7 @@
1326
1326
  "sales.returns.qty.increase": "Aumentar cantidad",
1327
1327
  "sales.returns.quantity": "Cantidad",
1328
1328
  "sales.returns.quantityExceeded": "No se puede devolver más que la cantidad restante.",
1329
+ "sales.returns.quantityExceedsShipped": "No se puede devolver más que la cantidad enviada. Envíe los artículos antes de registrar una devolución.",
1329
1330
  "sales.returns.reason": "Motivo",
1330
1331
  "sales.returns.reason.placeholder": "Opcional",
1331
1332
  "sales.returns.returnNumber": "Devolución",
@@ -1326,6 +1326,7 @@
1326
1326
  "sales.returns.qty.increase": "Zwiększ ilość",
1327
1327
  "sales.returns.quantity": "Ilość",
1328
1328
  "sales.returns.quantityExceeded": "Nie można zwrócić więcej niż pozostała ilość.",
1329
+ "sales.returns.quantityExceedsShipped": "Nie można zwrócić więcej niż wysłana ilość. Wyślij artykuły, zanim zarejestrujesz zwrot.",
1329
1330
  "sales.returns.reason": "Powód",
1330
1331
  "sales.returns.reason.placeholder": "Opcjonalny",
1331
1332
  "sales.returns.returnNumber": "Zwrot",
@@ -1,10 +1,57 @@
1
1
  export type ReturnAvailableInput = {
2
2
  quantity: number
3
3
  returnedQuantity: number
4
+ shippedQuantity?: number
4
5
  }
5
6
 
6
7
  export function computeAvailableReturnQuantity(line: ReturnAvailableInput): number {
7
- const raw = line.quantity - line.returnedQuantity
8
+ const cap =
9
+ typeof line.shippedQuantity === 'number' && Number.isFinite(line.shippedQuantity)
10
+ ? Math.min(line.quantity, line.shippedQuantity)
11
+ : line.quantity
12
+ const raw = cap - line.returnedQuantity
8
13
  if (!Number.isFinite(raw) || raw <= 0) return 0
9
14
  return Math.max(0, Math.floor(raw + 1e-6))
10
15
  }
16
+
17
+ function coerceQuantity(value: unknown): number {
18
+ if (typeof value === 'number' && Number.isFinite(value)) return value
19
+ if (typeof value === 'string' && value.trim().length) {
20
+ const parsed = Number(value)
21
+ if (Number.isFinite(parsed)) return parsed
22
+ }
23
+ return 0
24
+ }
25
+
26
+ /**
27
+ * Aggregate shipped quantity per order line from the `/api/sales/shipments`
28
+ * list payload. Mirrors the server-side `loadShippedQuantityByLine` cap so the
29
+ * Return dialog's "Available" figure matches what `sales.returns.create`
30
+ * enforces. Each shipment exposes its lines under `items` (either the
31
+ * persisted snapshot or DB-grouped rows), both carrying `orderLineId` and
32
+ * `quantity`.
33
+ */
34
+ export function sumShippedQuantityByLine(
35
+ shipments: ReadonlyArray<Record<string, unknown>> | null | undefined,
36
+ ): Map<string, number> {
37
+ const shippedByLine = new Map<string, number>()
38
+ if (!Array.isArray(shipments)) return shippedByLine
39
+ for (const shipment of shipments) {
40
+ if (!shipment || typeof shipment !== 'object') continue
41
+ const lineItems = (shipment as Record<string, unknown>).items
42
+ if (!Array.isArray(lineItems)) continue
43
+ for (const lineItem of lineItems) {
44
+ if (!lineItem || typeof lineItem !== 'object') continue
45
+ const record = lineItem as Record<string, unknown>
46
+ const orderLineId =
47
+ typeof record.orderLineId === 'string'
48
+ ? record.orderLineId
49
+ : typeof record.order_line_id === 'string'
50
+ ? record.order_line_id
51
+ : null
52
+ if (!orderLineId) continue
53
+ shippedByLine.set(orderLineId, (shippedByLine.get(orderLineId) ?? 0) + coerceQuantity(record.quantity))
54
+ }
55
+ }
56
+ return shippedByLine
57
+ }
@@ -123,6 +123,9 @@ function isSafeWorkflowRegexPattern(pattern: string): boolean {
123
123
  let lastClosedGroup: RegexGroupFrame | null = null
124
124
  let lastAtomWasQuantified = false
125
125
 
126
+ // Hand-written single-pass lexer: `index` is intentionally advanced inside the
127
+ // loop body (escape pairs, `(?:` prefixes, multi-char quantifiers, lazy `?`) on
128
+ // top of the `index += 1` update clause. Do not "simplify" these in-body writes.
126
129
  for (let index = 0; index < pattern.length; index += 1) {
127
130
  const char = pattern[index]
128
131