@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.
- package/dist/helpers/integration/salesFixtures.js +10 -0
- package/dist/helpers/integration/salesFixtures.js.map +2 -2
- package/dist/modules/customers/data/guards.js +10 -1
- package/dist/modules/customers/data/guards.js.map +2 -2
- package/dist/modules/sales/commands/returns.js +38 -3
- package/dist/modules/sales/commands/returns.js.map +2 -2
- package/dist/modules/sales/components/documents/ReturnDialog.js.map +2 -2
- package/dist/modules/sales/components/documents/ReturnsSection.js +18 -5
- package/dist/modules/sales/components/documents/ReturnsSection.js.map +2 -2
- package/dist/modules/sales/lib/returnQuantity.js +29 -2
- package/dist/modules/sales/lib/returnQuantity.js.map +2 -2
- package/dist/modules/workflows/lib/event-trigger-service.js.map +2 -2
- package/package.json +7 -7
- package/src/helpers/integration/salesFixtures.ts +21 -0
- package/src/modules/customers/data/guards.ts +29 -0
- package/src/modules/sales/commands/returns.ts +44 -3
- package/src/modules/sales/components/documents/ReturnDialog.tsx +1 -0
- package/src/modules/sales/components/documents/ReturnsSection.tsx +18 -5
- package/src/modules/sales/i18n/de.json +1 -0
- package/src/modules/sales/i18n/en.json +1 -0
- package/src/modules/sales/i18n/es.json +1 -0
- package/src/modules/sales/i18n/pl.json +1 -0
- package/src/modules/sales/lib/returnQuantity.ts +48 -1
- package/src/modules/workflows/lib/event-trigger-service.ts +3 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/sales/components/documents/ReturnDialog.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Dialog, DialogContent, DialogHeader, DialogTitle } from '@open-mercato/ui/primitives/dialog'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { CounterInput } from '@open-mercato/ui/primitives/counter-input'\nimport { Textarea } from '@open-mercato/ui/primitives/textarea'\nimport { Label } from '@open-mercato/ui/primitives/label'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { apiCallOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'\nimport { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { normalizeCrudServerError } from '@open-mercato/ui/backend/utils/serverErrors'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { computeAvailableReturnQuantity } from '@open-mercato/core/modules/sales/lib/returnQuantity'\nimport { handleSectionMutationError } from './optimisticLock'\n\nexport type ReturnOrderLine = {\n id: string\n title: string\n lineNumber: number | null\n quantity: number\n returnedQuantity: number\n thumbnail?: string | null\n}\n\ntype ReturnDialogProps = {\n open: boolean\n orderId: string\n lines: ReturnOrderLine[]\n documentUpdatedAt?: string | null\n onClose: () => void\n onSaved: () => Promise<void>\n}\n\nconst normalizeNumber = (value: unknown): number => {\n if (typeof value === 'number' && Number.isFinite(value)) return value\n if (typeof value === 'string' && value.trim().length) {\n const parsed = Number(value)\n if (Number.isFinite(parsed)) return parsed\n }\n return 0\n}\n\nexport function ReturnDialog({ open, orderId, lines, documentUpdatedAt, onClose, onSaved }: ReturnDialogProps) {\n const t = useT()\n const { runMutation } = useGuardedMutation({ contextId: `sales-returns-${orderId}` })\n const [reason, setReason] = React.useState('')\n const [notes, setNotes] = React.useState('')\n const [quantities, setQuantities] = React.useState<Record<string, string>>({})\n const [saving, setSaving] = React.useState(false)\n\n const availableLines = React.useMemo(() => {\n return lines\n .map((line) => {\n const available = computeAvailableReturnQuantity(line)\n return { ...line, available }\n })\n .filter((line) => line.available > 0)\n }, [lines])\n\n React.useEffect(() => {\n if (!open) return\n setReason('')\n setNotes('')\n setQuantities({})\n }, [open])\n\n const submit = React.useCallback(async () => {\n if (saving) return\n let hasInvalidQuantity = false\n let hasNonInteger = false\n const linesForRequest: Array<{ orderLineId: string; quantity: string }> = []\n availableLines.forEach((line) => {\n const raw = quantities[line.id]\n const qty = normalizeNumber(raw)\n if (!Number.isFinite(qty) || qty <= 0) return\n if (!Number.isInteger(qty)) {\n hasNonInteger = true\n return\n }\n if (qty > line.available) {\n hasInvalidQuantity = true\n return\n }\n linesForRequest.push({ orderLineId: line.id, quantity: qty.toString() })\n })\n\n if (hasNonInteger) {\n flash(t('sales.returns.errors.quantityNotInteger', 'Return quantity must be a whole number.'), 'error')\n return\n }\n\n if (hasInvalidQuantity) {\n flash(t('sales.returns.errors.quantityExceeded', 'Cannot return more than available quantity.'), 'error')\n return\n }\n\n if (!linesForRequest.length) {\n flash(t('sales.returns.errors.linesRequired', 'Select at least one line to return.'), 'error')\n return\n }\n\n setSaving(true)\n try {\n await runMutation({\n context: { kind: 'order', record: { id: orderId } },\n mutationPayload: { orderId, lines: linesForRequest, reason, notes },\n operation: async () => {\n const response = await withScopedApiRequestHeaders(\n buildOptimisticLockHeader(documentUpdatedAt),\n () =>\n apiCallOrThrow<{ id: string | null }>(\n '/api/sales/returns',\n {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n orderId,\n lines: linesForRequest,\n ...(reason.trim().length ? { reason: reason.trim() } : {}),\n ...(notes.trim().length ? { notes: notes.trim() } : {}),\n }),\n },\n { errorMessage: t('sales.returns.errors.create', 'Failed to create return.') },\n ),\n )\n return response.result?.id ?? null\n },\n })\n flash(t('sales.returns.created', 'Return created.'), 'success')\n onClose()\n await onSaved()\n } catch (err) {\n if (handleSectionMutationError(err, t, () => void onSaved())) {\n onClose()\n return\n }\n const normalized = normalizeCrudServerError(err)\n const fallback = t('sales.returns.errors.create', 'Failed to create return.')\n flash(normalized.message || fallback, 'error')\n } finally {\n setSaving(false)\n }\n }, [availableLines, documentUpdatedAt, notes, onClose, onSaved, orderId, quantities, reason, runMutation, saving, t])\n\n const onKeyDown = React.useCallback(\n (e: React.KeyboardEvent) => {\n if (e.key === 'Escape') onClose()\n if ((e.key === 'Enter' || e.key === 'NumpadEnter') && (e.metaKey || e.ctrlKey)) {\n e.preventDefault()\n submit()\n }\n },\n [onClose, submit],\n )\n\n return (\n <Dialog open={open} onOpenChange={(next) => (!next ? onClose() : undefined)}>\n <DialogContent onKeyDown={onKeyDown} className=\"max-w-2xl\">\n <DialogHeader>\n <DialogTitle>{t('sales.returns.create.title', 'Create return')}</DialogTitle>\n </DialogHeader>\n\n <div className=\"space-y-4\">\n <div className=\"space-y-2\">\n <div className=\"text-sm font-medium\">{t('sales.returns.create.lines', 'Lines')}</div>\n {!availableLines.length ? (\n <div className=\"text-sm text-muted-foreground\">{t('sales.returns.empty.available', 'No items available to return.')}</div>\n ) : (\n <div className=\"max-h-[280px] overflow-auto rounded-md border\">\n <div className=\"divide-y\">\n {availableLines.map((line) => {\n const value = quantities[line.id] ?? ''\n return (\n <div key={line.id} className=\"flex items-center gap-3 p-3\">\n <div className=\"min-w-0 flex-1\">\n <div className=\"truncate text-sm font-medium\">\n {line.lineNumber ? `#${line.lineNumber} \u00B7 ` : ''}\n {line.title}\n </div>\n <div className=\"text-xs text-muted-foreground\">\n {t('sales.returns.available', 'Available')}: {line.available}\n </div>\n </div>\n <div className=\"w-32\">\n <Label className=\"sr-only\" htmlFor={`return-qty-${line.id}`}>\n {t('sales.returns.quantity', 'Quantity')}\n </Label>\n <CounterInput\n id={`return-qty-${line.id}`}\n min={1}\n max={line.available}\n step={1}\n placeholder=\"0\"\n value={value === '' ? null : Number(value)}\n onChange={(next) => {\n setQuantities((prev) => ({\n ...prev,\n [line.id]: next === null ? '' : String(next),\n }))\n }}\n decrementAriaLabel={t('sales.returns.qty.decrease', 'Decrease quantity')}\n incrementAriaLabel={t('sales.returns.qty.increase', 'Increase quantity')}\n />\n </div>\n </div>\n )\n })}\n </div>\n </div>\n )}\n </div>\n\n <div className=\"grid grid-cols-1 gap-4 md:grid-cols-2\">\n <div className=\"space-y-1.5\">\n <Label htmlFor=\"return-reason\">{t('sales.returns.reason', 'Reason')}</Label>\n <Input\n id=\"return-reason\"\n value={reason}\n onChange={(e) => setReason(e.target.value)}\n placeholder={t('sales.returns.reason.placeholder', 'Optional')}\n />\n </div>\n <div className=\"space-y-1.5 md:col-span-2\">\n <Label htmlFor=\"return-notes\">{t('sales.returns.notes', 'Notes')}</Label>\n <Textarea\n id=\"return-notes\"\n value={notes}\n onChange={(e) => setNotes(e.target.value)}\n placeholder={t('sales.returns.notes.placeholder', 'Optional')}\n rows={3}\n />\n </div>\n </div>\n\n <div className=\"flex items-center justify-end gap-2\">\n <Button type=\"button\" variant=\"outline\" onClick={onClose} disabled={saving}>\n {t('common.cancel', 'Cancel')}\n </Button>\n <Button type=\"button\" onClick={submit} disabled={saving || !availableLines.length}>\n {saving ? t('common.saving', 'Saving\u2026') : t('sales.returns.create.submit', 'Create return')}\n </Button>\n </div>\n </div>\n </DialogContent>\n </Dialog>\n )\n}\n\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Dialog, DialogContent, DialogHeader, DialogTitle } from '@open-mercato/ui/primitives/dialog'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { CounterInput } from '@open-mercato/ui/primitives/counter-input'\nimport { Textarea } from '@open-mercato/ui/primitives/textarea'\nimport { Label } from '@open-mercato/ui/primitives/label'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { apiCallOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'\nimport { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { normalizeCrudServerError } from '@open-mercato/ui/backend/utils/serverErrors'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { computeAvailableReturnQuantity } from '@open-mercato/core/modules/sales/lib/returnQuantity'\nimport { handleSectionMutationError } from './optimisticLock'\n\nexport type ReturnOrderLine = {\n id: string\n title: string\n lineNumber: number | null\n quantity: number\n returnedQuantity: number\n shippedQuantity?: number\n thumbnail?: string | null\n}\n\ntype ReturnDialogProps = {\n open: boolean\n orderId: string\n lines: ReturnOrderLine[]\n documentUpdatedAt?: string | null\n onClose: () => void\n onSaved: () => Promise<void>\n}\n\nconst normalizeNumber = (value: unknown): number => {\n if (typeof value === 'number' && Number.isFinite(value)) return value\n if (typeof value === 'string' && value.trim().length) {\n const parsed = Number(value)\n if (Number.isFinite(parsed)) return parsed\n }\n return 0\n}\n\nexport function ReturnDialog({ open, orderId, lines, documentUpdatedAt, onClose, onSaved }: ReturnDialogProps) {\n const t = useT()\n const { runMutation } = useGuardedMutation({ contextId: `sales-returns-${orderId}` })\n const [reason, setReason] = React.useState('')\n const [notes, setNotes] = React.useState('')\n const [quantities, setQuantities] = React.useState<Record<string, string>>({})\n const [saving, setSaving] = React.useState(false)\n\n const availableLines = React.useMemo(() => {\n return lines\n .map((line) => {\n const available = computeAvailableReturnQuantity(line)\n return { ...line, available }\n })\n .filter((line) => line.available > 0)\n }, [lines])\n\n React.useEffect(() => {\n if (!open) return\n setReason('')\n setNotes('')\n setQuantities({})\n }, [open])\n\n const submit = React.useCallback(async () => {\n if (saving) return\n let hasInvalidQuantity = false\n let hasNonInteger = false\n const linesForRequest: Array<{ orderLineId: string; quantity: string }> = []\n availableLines.forEach((line) => {\n const raw = quantities[line.id]\n const qty = normalizeNumber(raw)\n if (!Number.isFinite(qty) || qty <= 0) return\n if (!Number.isInteger(qty)) {\n hasNonInteger = true\n return\n }\n if (qty > line.available) {\n hasInvalidQuantity = true\n return\n }\n linesForRequest.push({ orderLineId: line.id, quantity: qty.toString() })\n })\n\n if (hasNonInteger) {\n flash(t('sales.returns.errors.quantityNotInteger', 'Return quantity must be a whole number.'), 'error')\n return\n }\n\n if (hasInvalidQuantity) {\n flash(t('sales.returns.errors.quantityExceeded', 'Cannot return more than available quantity.'), 'error')\n return\n }\n\n if (!linesForRequest.length) {\n flash(t('sales.returns.errors.linesRequired', 'Select at least one line to return.'), 'error')\n return\n }\n\n setSaving(true)\n try {\n await runMutation({\n context: { kind: 'order', record: { id: orderId } },\n mutationPayload: { orderId, lines: linesForRequest, reason, notes },\n operation: async () => {\n const response = await withScopedApiRequestHeaders(\n buildOptimisticLockHeader(documentUpdatedAt),\n () =>\n apiCallOrThrow<{ id: string | null }>(\n '/api/sales/returns',\n {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n orderId,\n lines: linesForRequest,\n ...(reason.trim().length ? { reason: reason.trim() } : {}),\n ...(notes.trim().length ? { notes: notes.trim() } : {}),\n }),\n },\n { errorMessage: t('sales.returns.errors.create', 'Failed to create return.') },\n ),\n )\n return response.result?.id ?? null\n },\n })\n flash(t('sales.returns.created', 'Return created.'), 'success')\n onClose()\n await onSaved()\n } catch (err) {\n if (handleSectionMutationError(err, t, () => void onSaved())) {\n onClose()\n return\n }\n const normalized = normalizeCrudServerError(err)\n const fallback = t('sales.returns.errors.create', 'Failed to create return.')\n flash(normalized.message || fallback, 'error')\n } finally {\n setSaving(false)\n }\n }, [availableLines, documentUpdatedAt, notes, onClose, onSaved, orderId, quantities, reason, runMutation, saving, t])\n\n const onKeyDown = React.useCallback(\n (e: React.KeyboardEvent) => {\n if (e.key === 'Escape') onClose()\n if ((e.key === 'Enter' || e.key === 'NumpadEnter') && (e.metaKey || e.ctrlKey)) {\n e.preventDefault()\n submit()\n }\n },\n [onClose, submit],\n )\n\n return (\n <Dialog open={open} onOpenChange={(next) => (!next ? onClose() : undefined)}>\n <DialogContent onKeyDown={onKeyDown} className=\"max-w-2xl\">\n <DialogHeader>\n <DialogTitle>{t('sales.returns.create.title', 'Create return')}</DialogTitle>\n </DialogHeader>\n\n <div className=\"space-y-4\">\n <div className=\"space-y-2\">\n <div className=\"text-sm font-medium\">{t('sales.returns.create.lines', 'Lines')}</div>\n {!availableLines.length ? (\n <div className=\"text-sm text-muted-foreground\">{t('sales.returns.empty.available', 'No items available to return.')}</div>\n ) : (\n <div className=\"max-h-[280px] overflow-auto rounded-md border\">\n <div className=\"divide-y\">\n {availableLines.map((line) => {\n const value = quantities[line.id] ?? ''\n return (\n <div key={line.id} className=\"flex items-center gap-3 p-3\">\n <div className=\"min-w-0 flex-1\">\n <div className=\"truncate text-sm font-medium\">\n {line.lineNumber ? `#${line.lineNumber} \u00B7 ` : ''}\n {line.title}\n </div>\n <div className=\"text-xs text-muted-foreground\">\n {t('sales.returns.available', 'Available')}: {line.available}\n </div>\n </div>\n <div className=\"w-32\">\n <Label className=\"sr-only\" htmlFor={`return-qty-${line.id}`}>\n {t('sales.returns.quantity', 'Quantity')}\n </Label>\n <CounterInput\n id={`return-qty-${line.id}`}\n min={1}\n max={line.available}\n step={1}\n placeholder=\"0\"\n value={value === '' ? null : Number(value)}\n onChange={(next) => {\n setQuantities((prev) => ({\n ...prev,\n [line.id]: next === null ? '' : String(next),\n }))\n }}\n decrementAriaLabel={t('sales.returns.qty.decrease', 'Decrease quantity')}\n incrementAriaLabel={t('sales.returns.qty.increase', 'Increase quantity')}\n />\n </div>\n </div>\n )\n })}\n </div>\n </div>\n )}\n </div>\n\n <div className=\"grid grid-cols-1 gap-4 md:grid-cols-2\">\n <div className=\"space-y-1.5\">\n <Label htmlFor=\"return-reason\">{t('sales.returns.reason', 'Reason')}</Label>\n <Input\n id=\"return-reason\"\n value={reason}\n onChange={(e) => setReason(e.target.value)}\n placeholder={t('sales.returns.reason.placeholder', 'Optional')}\n />\n </div>\n <div className=\"space-y-1.5 md:col-span-2\">\n <Label htmlFor=\"return-notes\">{t('sales.returns.notes', 'Notes')}</Label>\n <Textarea\n id=\"return-notes\"\n value={notes}\n onChange={(e) => setNotes(e.target.value)}\n placeholder={t('sales.returns.notes.placeholder', 'Optional')}\n rows={3}\n />\n </div>\n </div>\n\n <div className=\"flex items-center justify-end gap-2\">\n <Button type=\"button\" variant=\"outline\" onClick={onClose} disabled={saving}>\n {t('common.cancel', 'Cancel')}\n </Button>\n <Button type=\"button\" onClick={submit} disabled={saving || !availableLines.length}>\n {saving ? t('common.saving', 'Saving\u2026') : t('sales.returns.create.submit', 'Create return')}\n </Button>\n </div>\n </div>\n </DialogContent>\n </Dialog>\n )\n}\n\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -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.js";
|
|
21
22
|
import { ReturnDialog } from "./ReturnDialog.js";
|
|
22
23
|
import { ReturnEditDialog } from "./ReturnEditDialog.js";
|
|
@@ -39,10 +40,21 @@ function SalesReturnsSection({ orderId, currencyCode, documentUpdatedAt }) {
|
|
|
39
40
|
const [editRecord, setEditRecord] = React.useState(null);
|
|
40
41
|
const loadLines = React.useCallback(async () => {
|
|
41
42
|
const params = new URLSearchParams({ page: "1", pageSize: "100", orderId });
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
const shipmentParams = new URLSearchParams({ page: "1", pageSize: "200", orderId });
|
|
44
|
+
const [response, shipmentsResponse] = await Promise.all([
|
|
45
|
+
apiCall(
|
|
46
|
+
`/api/sales/order-lines?${params.toString()}`,
|
|
47
|
+
void 0,
|
|
48
|
+
{ fallback: { items: [] } }
|
|
49
|
+
),
|
|
50
|
+
apiCall(
|
|
51
|
+
`/api/sales/shipments?${shipmentParams.toString()}`,
|
|
52
|
+
void 0,
|
|
53
|
+
{ fallback: { items: [] } }
|
|
54
|
+
)
|
|
55
|
+
]);
|
|
56
|
+
const shippedByLine = sumShippedQuantityByLine(
|
|
57
|
+
Array.isArray(shipmentsResponse.result?.items) ? shipmentsResponse.result?.items : []
|
|
46
58
|
);
|
|
47
59
|
const items = Array.isArray(response.result?.items) ? response.result?.items ?? [] : [];
|
|
48
60
|
const mapped = items.map((item) => {
|
|
@@ -60,7 +72,8 @@ function SalesReturnsSection({ orderId, currencyCode, documentUpdatedAt }) {
|
|
|
60
72
|
title: name ?? id,
|
|
61
73
|
lineNumber,
|
|
62
74
|
quantity: Number.isFinite(quantity) ? quantity : 0,
|
|
63
|
-
returnedQuantity: Number.isFinite(returnedQuantity) ? returnedQuantity : 0
|
|
75
|
+
returnedQuantity: Number.isFinite(returnedQuantity) ? returnedQuantity : 0,
|
|
76
|
+
shippedQuantity: shippedByLine.get(id) ?? 0
|
|
64
77
|
};
|
|
65
78
|
}).filter((entry) => Boolean(entry?.id));
|
|
66
79
|
setLines(mapped);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/sales/components/documents/ReturnsSection.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Undo2, Plus } from 'lucide-react'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { Badge } from '@open-mercato/ui/primitives/badge'\nimport { ErrorMessage, LoadingMessage, TabEmptyState } from '@open-mercato/ui/backend/detail'\nimport { apiCall, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'\nimport { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'\nimport { deleteCrud } from '@open-mercato/ui/backend/utils/crud'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { RowActions } from '@open-mercato/ui/backend/RowActions'\nimport { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'\nimport { useOrganizationScopeDetail } from '@open-mercato/shared/lib/frontend/useOrganizationScope'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport {\n emitSalesDocumentTotalsRefresh,\n subscribeSalesDocumentTotalsRefresh,\n} from '@open-mercato/core/modules/sales/lib/frontend/documentTotalsEvents'\nimport { formatMoney, normalizeNumber } from './lineItemUtils'\nimport { ReturnDialog, type ReturnOrderLine } from './ReturnDialog'\nimport { ReturnEditDialog, type ReturnEditRecord } from './ReturnEditDialog'\nimport { handleSectionMutationError, readRowUpdatedAt, rowOptimisticVersion } from './optimisticLock'\n\ntype ReturnRow = {\n id: string\n returnNumber: string\n status: string | null\n reason: string | null\n notes: string | null\n returnedAt: string | null\n updatedAt: string | null\n totalNetAmount: number | null\n totalGrossAmount: number | null\n}\n\ntype SalesReturnsSectionProps = {\n orderId: string\n currencyCode?: string | null\n documentUpdatedAt?: string | null\n}\n\nfunction formatDisplayDate(value: string | null | undefined): string | null {\n if (!value) return null\n const date = new Date(value)\n if (Number.isNaN(date.getTime())) return null\n return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(date)\n}\n\nexport function SalesReturnsSection({ orderId, currencyCode, documentUpdatedAt }: SalesReturnsSectionProps) {\n const t = useT()\n const { organizationId, tenantId } = useOrganizationScopeDetail()\n const { confirm, ConfirmDialogElement } = useConfirmDialog()\n const [returns, setReturns] = React.useState<ReturnRow[]>([])\n const [lines, setLines] = React.useState<ReturnOrderLine[]>([])\n const [loading, setLoading] = React.useState(false)\n const [error, setError] = React.useState<string | null>(null)\n const [dialogOpen, setDialogOpen] = React.useState(false)\n const [editRecord, setEditRecord] = React.useState<ReturnEditRecord | null>(null)\n\n const loadLines = React.useCallback(async () => {\n const params = new URLSearchParams({ page: '1', pageSize: '100', orderId })\n const response = await apiCall<{ items?: Array<Record<string, unknown>> }>(\n `/api/sales/order-lines?${params.toString()}`,\n undefined,\n { fallback: { items: [] } },\n )\n const items = Array.isArray(response.result?.items) ? response.result?.items ?? [] : []\n const mapped: ReturnOrderLine[] = items\n .map((item) => {\n const map = item as Record<string, unknown>\n const id = typeof map.id === 'string' ? map.id : null\n if (!id) return null\n const snapshot = map['catalog_snapshot']\n const snapshotName =\n snapshot && typeof snapshot === 'object' && !Array.isArray(snapshot)\n ? (snapshot as Record<string, unknown>)['name']\n : null\n const name =\n typeof map.name === 'string'\n ? map.name\n : typeof snapshotName === 'string'\n ? snapshotName\n : null\n const lineNumber =\n typeof map['line_number'] === 'number'\n ? (map['line_number'] as number)\n : typeof map.lineNumber === 'number'\n ? (map.lineNumber as number)\n : null\n const quantity =\n typeof map.quantity === 'number'\n ? (map.quantity as number)\n : typeof map.quantity === 'string'\n ? Number(map.quantity)\n : 0\n const returnedQuantity =\n typeof map['returned_quantity'] === 'number'\n ? (map['returned_quantity'] as number)\n : typeof map.returnedQuantity === 'number'\n ? (map.returnedQuantity as number)\n : typeof map['returned_quantity'] === 'string'\n ? Number(map['returned_quantity'])\n : typeof map.returnedQuantity === 'string'\n ? Number(map.returnedQuantity)\n : 0\n return {\n id,\n title: name ?? id,\n lineNumber,\n quantity: Number.isFinite(quantity) ? quantity : 0,\n returnedQuantity: Number.isFinite(returnedQuantity) ? returnedQuantity : 0,\n }\n })\n .filter((entry): entry is ReturnOrderLine => Boolean(entry?.id))\n setLines(mapped)\n }, [orderId])\n\n const loadReturns = React.useCallback(async () => {\n setLoading(true)\n setError(null)\n try {\n const params = new URLSearchParams({ page: '1', pageSize: '100', orderId })\n const response = await apiCall<{ items?: Array<Record<string, unknown>> }>(\n `/api/sales/returns?${params.toString()}`,\n undefined,\n { fallback: { items: [] } },\n )\n const items = Array.isArray(response.result?.items) ? response.result?.items ?? [] : []\n const mapped: ReturnRow[] = items\n .map((item) => {\n const map = item as Record<string, unknown>\n const id = typeof map.id === 'string' ? map.id : null\n if (!id) return null\n const returnNumber =\n typeof map['return_number'] === 'string'\n ? (map['return_number'] as string)\n : typeof map.returnNumber === 'string'\n ? (map.returnNumber as string)\n : id\n const returnedAt =\n typeof map['returned_at'] === 'string'\n ? (map['returned_at'] as string)\n : typeof map.returnedAt === 'string'\n ? (map.returnedAt as string)\n : null\n const totalNetAmount =\n typeof map['total_net_amount'] === 'number'\n ? (map['total_net_amount'] as number)\n : typeof map.totalNetAmount === 'number'\n ? (map.totalNetAmount as number)\n : null\n const totalGrossAmount =\n typeof map['total_gross_amount'] === 'number'\n ? (map['total_gross_amount'] as number)\n : typeof map.totalGrossAmount === 'number'\n ? (map.totalGrossAmount as number)\n : null\n const reason =\n typeof map.reason === 'string' ? (map.reason as string) : null\n const notes =\n typeof map.notes === 'string' ? (map.notes as string) : null\n return {\n id,\n returnNumber,\n status: typeof map.status === 'string' ? (map.status as string) : null,\n reason,\n notes,\n returnedAt,\n updatedAt: readRowUpdatedAt(map),\n totalNetAmount,\n totalGrossAmount,\n }\n })\n .filter((entry): entry is ReturnRow => Boolean(entry?.id))\n setReturns(mapped)\n await loadLines()\n } catch {\n setError(t('sales.returns.errors.load', 'Failed to load returns.'))\n } finally {\n setLoading(false)\n }\n }, [loadLines, orderId, t])\n\n React.useEffect(() => {\n loadReturns()\n }, [loadReturns])\n\n React.useEffect(() => {\n return subscribeSalesDocumentTotalsRefresh((detail) => {\n if (detail.documentId !== orderId) return\n loadReturns()\n })\n }, [loadReturns, orderId])\n\n const emptyState = React.useMemo(\n () => ({\n title: t('sales.returns.empty.title', 'No returns yet.'),\n description: t('sales.returns.empty.description', 'Create a return to generate credit adjustments for returned items.'),\n }),\n [t],\n )\n\n const rows = React.useMemo(() => {\n return returns.map((ret) => {\n const total = normalizeNumber(ret.totalGrossAmount ?? ret.totalNetAmount ?? 0, 0)\n return {\n ...ret,\n total,\n }\n })\n }, [returns])\n\n const handleEdit = React.useCallback((row: ReturnRow) => {\n setEditRecord({\n id: row.id,\n reason: row.reason,\n notes: row.notes,\n returnedAt: row.returnedAt,\n updatedAt: row.updatedAt,\n })\n }, [])\n\n const handleDelete = React.useCallback(\n async (row: ReturnRow) => {\n const confirmed = await confirm({\n title: t('sales.returns.confirmDelete', 'Delete this return?'),\n description: t(\n 'sales.returns.confirmDelete.description',\n 'This reverses the returned quantities and the related credit adjustments.',\n ),\n variant: 'destructive',\n })\n if (!confirmed) return\n try {\n const result = await withScopedApiRequestHeaders(\n buildOptimisticLockHeader(rowOptimisticVersion(row)),\n () =>\n deleteCrud('sales/returns', {\n body: {\n id: row.id,\n orderId,\n ...(organizationId ? { organizationId } : {}),\n ...(tenantId ? { tenantId } : {}),\n },\n errorMessage: t('sales.returns.errors.delete', 'Failed to delete return.'),\n }),\n )\n if (result.ok) {\n emitSalesDocumentTotalsRefresh({ documentId: orderId, kind: 'order' })\n await loadReturns()\n }\n } catch (err) {\n if (handleSectionMutationError(err, t, () => void loadReturns())) return\n flash(t('sales.returns.errors.delete', 'Failed to delete return.'), 'error')\n }\n },\n [confirm, loadReturns, orderId, organizationId, tenantId, t],\n )\n\n if (loading) return <LoadingMessage label={t('sales.returns.loading', 'Loading returns\u2026')} />\n if (error) return <ErrorMessage label={error} />\n\n if (!rows.length) {\n return (\n <div className=\"space-y-4\">\n <TabEmptyState\n title={emptyState.title}\n description={emptyState.description}\n action={{\n label: t('sales.returns.create', 'Create return'),\n icon: <Plus className=\"mr-2 h-4 w-4\" aria-hidden />,\n onClick: () => setDialogOpen(true),\n }}\n />\n <ReturnDialog\n open={dialogOpen}\n orderId={orderId}\n lines={lines}\n documentUpdatedAt={documentUpdatedAt}\n onClose={() => setDialogOpen(false)}\n onSaved={async () => {\n emitSalesDocumentTotalsRefresh({ documentId: orderId, kind: 'order' })\n await loadReturns()\n }}\n />\n </div>\n )\n }\n\n return (\n <div className=\"space-y-4\">\n <div className=\"flex items-center justify-end\">\n <Button type=\"button\" onClick={() => setDialogOpen(true)}>\n <Plus className=\"mr-2 h-4 w-4\" aria-hidden />\n {t('sales.returns.create', 'Create return')}\n </Button>\n </div>\n\n <div className=\"overflow-hidden rounded-md border\">\n <div className=\"grid grid-cols-[1fr_auto_auto_auto] gap-3 border-b bg-muted/30 px-4 py-2 text-xs font-medium text-muted-foreground\">\n <div>{t('sales.returns.returnNumber', 'Return')}</div>\n <div className=\"text-right\">{t('sales.returns.returnedAt', 'Returned at')}</div>\n <div className=\"text-right\">{t('sales.returns.total', 'Total')}</div>\n <div className=\"sr-only\">{t('sales.returns.actions', 'Actions')}</div>\n </div>\n <div className=\"divide-y\">\n {rows.map((ret) => (\n <div key={ret.id} className=\"grid grid-cols-[1fr_auto_auto_auto] items-center gap-3 px-4 py-3\">\n <div className=\"min-w-0\">\n <div className=\"flex items-center gap-2\">\n <Undo2 className=\"h-4 w-4 text-muted-foreground\" aria-hidden />\n <div className=\"truncate text-sm font-medium\">{ret.returnNumber}</div>\n {ret.status ? <Badge variant=\"secondary\">{ret.status}</Badge> : null}\n </div>\n </div>\n <div className=\"whitespace-nowrap text-right text-sm text-muted-foreground\">\n {formatDisplayDate(ret.returnedAt) ?? t('sales.returns.notSet', 'Not set')}\n </div>\n <div className=\"whitespace-nowrap text-right text-sm font-medium\">\n {formatMoney(ret.total, currencyCode ?? null)}\n </div>\n <div className=\"flex justify-end\">\n <RowActions\n items={[\n {\n id: 'edit',\n label: t('ui.actions.edit', 'Edit'),\n onSelect: () => handleEdit(ret),\n },\n {\n id: 'delete',\n label: t('ui.actions.delete', 'Delete'),\n destructive: true,\n onSelect: () => void handleDelete(ret),\n },\n ]}\n />\n </div>\n </div>\n ))}\n </div>\n </div>\n\n <ReturnDialog\n open={dialogOpen}\n orderId={orderId}\n lines={lines}\n documentUpdatedAt={documentUpdatedAt}\n onClose={() => setDialogOpen(false)}\n onSaved={async () => {\n emitSalesDocumentTotalsRefresh({ documentId: orderId, kind: 'order' })\n await loadReturns()\n }}\n />\n\n <ReturnEditDialog\n open={editRecord !== null}\n returnRecord={editRecord}\n orderId={orderId}\n organizationId={organizationId ?? null}\n tenantId={tenantId ?? null}\n onClose={() => setEditRecord(null)}\n onSaved={async () => {\n setEditRecord(null)\n emitSalesDocumentTotalsRefresh({ documentId: orderId, kind: 'order' })\n await loadReturns()\n }}\n />\n\n {ConfirmDialogElement}\n </div>\n )\n}\n\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Undo2, Plus } from 'lucide-react'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { Badge } from '@open-mercato/ui/primitives/badge'\nimport { ErrorMessage, LoadingMessage, TabEmptyState } from '@open-mercato/ui/backend/detail'\nimport { apiCall, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'\nimport { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'\nimport { deleteCrud } from '@open-mercato/ui/backend/utils/crud'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { RowActions } from '@open-mercato/ui/backend/RowActions'\nimport { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'\nimport { useOrganizationScopeDetail } from '@open-mercato/shared/lib/frontend/useOrganizationScope'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport {\n emitSalesDocumentTotalsRefresh,\n subscribeSalesDocumentTotalsRefresh,\n} from '@open-mercato/core/modules/sales/lib/frontend/documentTotalsEvents'\nimport { sumShippedQuantityByLine } from '@open-mercato/core/modules/sales/lib/returnQuantity'\nimport { formatMoney, normalizeNumber } from './lineItemUtils'\nimport { ReturnDialog, type ReturnOrderLine } from './ReturnDialog'\nimport { ReturnEditDialog, type ReturnEditRecord } from './ReturnEditDialog'\nimport { handleSectionMutationError, readRowUpdatedAt, rowOptimisticVersion } from './optimisticLock'\n\ntype ReturnRow = {\n id: string\n returnNumber: string\n status: string | null\n reason: string | null\n notes: string | null\n returnedAt: string | null\n updatedAt: string | null\n totalNetAmount: number | null\n totalGrossAmount: number | null\n}\n\ntype SalesReturnsSectionProps = {\n orderId: string\n currencyCode?: string | null\n documentUpdatedAt?: string | null\n}\n\nfunction formatDisplayDate(value: string | null | undefined): string | null {\n if (!value) return null\n const date = new Date(value)\n if (Number.isNaN(date.getTime())) return null\n return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(date)\n}\n\nexport function SalesReturnsSection({ orderId, currencyCode, documentUpdatedAt }: SalesReturnsSectionProps) {\n const t = useT()\n const { organizationId, tenantId } = useOrganizationScopeDetail()\n const { confirm, ConfirmDialogElement } = useConfirmDialog()\n const [returns, setReturns] = React.useState<ReturnRow[]>([])\n const [lines, setLines] = React.useState<ReturnOrderLine[]>([])\n const [loading, setLoading] = React.useState(false)\n const [error, setError] = React.useState<string | null>(null)\n const [dialogOpen, setDialogOpen] = React.useState(false)\n const [editRecord, setEditRecord] = React.useState<ReturnEditRecord | null>(null)\n\n const loadLines = React.useCallback(async () => {\n const params = new URLSearchParams({ page: '1', pageSize: '100', orderId })\n const shipmentParams = new URLSearchParams({ page: '1', pageSize: '200', orderId })\n const [response, shipmentsResponse] = await Promise.all([\n apiCall<{ items?: Array<Record<string, unknown>> }>(\n `/api/sales/order-lines?${params.toString()}`,\n undefined,\n { fallback: { items: [] } },\n ),\n apiCall<{ items?: Array<Record<string, unknown>> }>(\n `/api/sales/shipments?${shipmentParams.toString()}`,\n undefined,\n { fallback: { items: [] } },\n ),\n ])\n const shippedByLine = sumShippedQuantityByLine(\n Array.isArray(shipmentsResponse.result?.items) ? shipmentsResponse.result?.items : [],\n )\n const items = Array.isArray(response.result?.items) ? response.result?.items ?? [] : []\n const mapped: ReturnOrderLine[] = items\n .map((item): ReturnOrderLine | null => {\n const map = item as Record<string, unknown>\n const id = typeof map.id === 'string' ? map.id : null\n if (!id) return null\n const snapshot = map['catalog_snapshot']\n const snapshotName =\n snapshot && typeof snapshot === 'object' && !Array.isArray(snapshot)\n ? (snapshot as Record<string, unknown>)['name']\n : null\n const name =\n typeof map.name === 'string'\n ? map.name\n : typeof snapshotName === 'string'\n ? snapshotName\n : null\n const lineNumber =\n typeof map['line_number'] === 'number'\n ? (map['line_number'] as number)\n : typeof map.lineNumber === 'number'\n ? (map.lineNumber as number)\n : null\n const quantity =\n typeof map.quantity === 'number'\n ? (map.quantity as number)\n : typeof map.quantity === 'string'\n ? Number(map.quantity)\n : 0\n const returnedQuantity =\n typeof map['returned_quantity'] === 'number'\n ? (map['returned_quantity'] as number)\n : typeof map.returnedQuantity === 'number'\n ? (map.returnedQuantity as number)\n : typeof map['returned_quantity'] === 'string'\n ? Number(map['returned_quantity'])\n : typeof map.returnedQuantity === 'string'\n ? Number(map.returnedQuantity)\n : 0\n return {\n id,\n title: name ?? id,\n lineNumber,\n quantity: Number.isFinite(quantity) ? quantity : 0,\n returnedQuantity: Number.isFinite(returnedQuantity) ? returnedQuantity : 0,\n shippedQuantity: shippedByLine.get(id) ?? 0,\n }\n })\n .filter((entry): entry is ReturnOrderLine => Boolean(entry?.id))\n setLines(mapped)\n }, [orderId])\n\n const loadReturns = React.useCallback(async () => {\n setLoading(true)\n setError(null)\n try {\n const params = new URLSearchParams({ page: '1', pageSize: '100', orderId })\n const response = await apiCall<{ items?: Array<Record<string, unknown>> }>(\n `/api/sales/returns?${params.toString()}`,\n undefined,\n { fallback: { items: [] } },\n )\n const items = Array.isArray(response.result?.items) ? response.result?.items ?? [] : []\n const mapped: ReturnRow[] = items\n .map((item) => {\n const map = item as Record<string, unknown>\n const id = typeof map.id === 'string' ? map.id : null\n if (!id) return null\n const returnNumber =\n typeof map['return_number'] === 'string'\n ? (map['return_number'] as string)\n : typeof map.returnNumber === 'string'\n ? (map.returnNumber as string)\n : id\n const returnedAt =\n typeof map['returned_at'] === 'string'\n ? (map['returned_at'] as string)\n : typeof map.returnedAt === 'string'\n ? (map.returnedAt as string)\n : null\n const totalNetAmount =\n typeof map['total_net_amount'] === 'number'\n ? (map['total_net_amount'] as number)\n : typeof map.totalNetAmount === 'number'\n ? (map.totalNetAmount as number)\n : null\n const totalGrossAmount =\n typeof map['total_gross_amount'] === 'number'\n ? (map['total_gross_amount'] as number)\n : typeof map.totalGrossAmount === 'number'\n ? (map.totalGrossAmount as number)\n : null\n const reason =\n typeof map.reason === 'string' ? (map.reason as string) : null\n const notes =\n typeof map.notes === 'string' ? (map.notes as string) : null\n return {\n id,\n returnNumber,\n status: typeof map.status === 'string' ? (map.status as string) : null,\n reason,\n notes,\n returnedAt,\n updatedAt: readRowUpdatedAt(map),\n totalNetAmount,\n totalGrossAmount,\n }\n })\n .filter((entry): entry is ReturnRow => Boolean(entry?.id))\n setReturns(mapped)\n await loadLines()\n } catch {\n setError(t('sales.returns.errors.load', 'Failed to load returns.'))\n } finally {\n setLoading(false)\n }\n }, [loadLines, orderId, t])\n\n React.useEffect(() => {\n loadReturns()\n }, [loadReturns])\n\n React.useEffect(() => {\n return subscribeSalesDocumentTotalsRefresh((detail) => {\n if (detail.documentId !== orderId) return\n loadReturns()\n })\n }, [loadReturns, orderId])\n\n const emptyState = React.useMemo(\n () => ({\n title: t('sales.returns.empty.title', 'No returns yet.'),\n description: t('sales.returns.empty.description', 'Create a return to generate credit adjustments for returned items.'),\n }),\n [t],\n )\n\n const rows = React.useMemo(() => {\n return returns.map((ret) => {\n const total = normalizeNumber(ret.totalGrossAmount ?? ret.totalNetAmount ?? 0, 0)\n return {\n ...ret,\n total,\n }\n })\n }, [returns])\n\n const handleEdit = React.useCallback((row: ReturnRow) => {\n setEditRecord({\n id: row.id,\n reason: row.reason,\n notes: row.notes,\n returnedAt: row.returnedAt,\n updatedAt: row.updatedAt,\n })\n }, [])\n\n const handleDelete = React.useCallback(\n async (row: ReturnRow) => {\n const confirmed = await confirm({\n title: t('sales.returns.confirmDelete', 'Delete this return?'),\n description: t(\n 'sales.returns.confirmDelete.description',\n 'This reverses the returned quantities and the related credit adjustments.',\n ),\n variant: 'destructive',\n })\n if (!confirmed) return\n try {\n const result = await withScopedApiRequestHeaders(\n buildOptimisticLockHeader(rowOptimisticVersion(row)),\n () =>\n deleteCrud('sales/returns', {\n body: {\n id: row.id,\n orderId,\n ...(organizationId ? { organizationId } : {}),\n ...(tenantId ? { tenantId } : {}),\n },\n errorMessage: t('sales.returns.errors.delete', 'Failed to delete return.'),\n }),\n )\n if (result.ok) {\n emitSalesDocumentTotalsRefresh({ documentId: orderId, kind: 'order' })\n await loadReturns()\n }\n } catch (err) {\n if (handleSectionMutationError(err, t, () => void loadReturns())) return\n flash(t('sales.returns.errors.delete', 'Failed to delete return.'), 'error')\n }\n },\n [confirm, loadReturns, orderId, organizationId, tenantId, t],\n )\n\n if (loading) return <LoadingMessage label={t('sales.returns.loading', 'Loading returns\u2026')} />\n if (error) return <ErrorMessage label={error} />\n\n if (!rows.length) {\n return (\n <div className=\"space-y-4\">\n <TabEmptyState\n title={emptyState.title}\n description={emptyState.description}\n action={{\n label: t('sales.returns.create', 'Create return'),\n icon: <Plus className=\"mr-2 h-4 w-4\" aria-hidden />,\n onClick: () => setDialogOpen(true),\n }}\n />\n <ReturnDialog\n open={dialogOpen}\n orderId={orderId}\n lines={lines}\n documentUpdatedAt={documentUpdatedAt}\n onClose={() => setDialogOpen(false)}\n onSaved={async () => {\n emitSalesDocumentTotalsRefresh({ documentId: orderId, kind: 'order' })\n await loadReturns()\n }}\n />\n </div>\n )\n }\n\n return (\n <div className=\"space-y-4\">\n <div className=\"flex items-center justify-end\">\n <Button type=\"button\" onClick={() => setDialogOpen(true)}>\n <Plus className=\"mr-2 h-4 w-4\" aria-hidden />\n {t('sales.returns.create', 'Create return')}\n </Button>\n </div>\n\n <div className=\"overflow-hidden rounded-md border\">\n <div className=\"grid grid-cols-[1fr_auto_auto_auto] gap-3 border-b bg-muted/30 px-4 py-2 text-xs font-medium text-muted-foreground\">\n <div>{t('sales.returns.returnNumber', 'Return')}</div>\n <div className=\"text-right\">{t('sales.returns.returnedAt', 'Returned at')}</div>\n <div className=\"text-right\">{t('sales.returns.total', 'Total')}</div>\n <div className=\"sr-only\">{t('sales.returns.actions', 'Actions')}</div>\n </div>\n <div className=\"divide-y\">\n {rows.map((ret) => (\n <div key={ret.id} className=\"grid grid-cols-[1fr_auto_auto_auto] items-center gap-3 px-4 py-3\">\n <div className=\"min-w-0\">\n <div className=\"flex items-center gap-2\">\n <Undo2 className=\"h-4 w-4 text-muted-foreground\" aria-hidden />\n <div className=\"truncate text-sm font-medium\">{ret.returnNumber}</div>\n {ret.status ? <Badge variant=\"secondary\">{ret.status}</Badge> : null}\n </div>\n </div>\n <div className=\"whitespace-nowrap text-right text-sm text-muted-foreground\">\n {formatDisplayDate(ret.returnedAt) ?? t('sales.returns.notSet', 'Not set')}\n </div>\n <div className=\"whitespace-nowrap text-right text-sm font-medium\">\n {formatMoney(ret.total, currencyCode ?? null)}\n </div>\n <div className=\"flex justify-end\">\n <RowActions\n items={[\n {\n id: 'edit',\n label: t('ui.actions.edit', 'Edit'),\n onSelect: () => handleEdit(ret),\n },\n {\n id: 'delete',\n label: t('ui.actions.delete', 'Delete'),\n destructive: true,\n onSelect: () => void handleDelete(ret),\n },\n ]}\n />\n </div>\n </div>\n ))}\n </div>\n </div>\n\n <ReturnDialog\n open={dialogOpen}\n orderId={orderId}\n lines={lines}\n documentUpdatedAt={documentUpdatedAt}\n onClose={() => setDialogOpen(false)}\n onSaved={async () => {\n emitSalesDocumentTotalsRefresh({ documentId: orderId, kind: 'order' })\n await loadReturns()\n }}\n />\n\n <ReturnEditDialog\n open={editRecord !== null}\n returnRecord={editRecord}\n orderId={orderId}\n organizationId={organizationId ?? null}\n tenantId={tenantId ?? null}\n onClose={() => setEditRecord(null)}\n onSaved={async () => {\n setEditRecord(null)\n emitSalesDocumentTotalsRefresh({ documentId: orderId, kind: 'order' })\n await loadReturns()\n }}\n />\n\n {ConfirmDialogElement}\n </div>\n )\n}\n\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,9 +1,36 @@
|
|
|
1
1
|
function computeAvailableReturnQuantity(line) {
|
|
2
|
-
const
|
|
2
|
+
const cap = typeof line.shippedQuantity === "number" && Number.isFinite(line.shippedQuantity) ? Math.min(line.quantity, line.shippedQuantity) : line.quantity;
|
|
3
|
+
const raw = cap - line.returnedQuantity;
|
|
3
4
|
if (!Number.isFinite(raw) || raw <= 0) return 0;
|
|
4
5
|
return Math.max(0, Math.floor(raw + 1e-6));
|
|
5
6
|
}
|
|
7
|
+
function coerceQuantity(value) {
|
|
8
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
9
|
+
if (typeof value === "string" && value.trim().length) {
|
|
10
|
+
const parsed = Number(value);
|
|
11
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
12
|
+
}
|
|
13
|
+
return 0;
|
|
14
|
+
}
|
|
15
|
+
function sumShippedQuantityByLine(shipments) {
|
|
16
|
+
const shippedByLine = /* @__PURE__ */ new Map();
|
|
17
|
+
if (!Array.isArray(shipments)) return shippedByLine;
|
|
18
|
+
for (const shipment of shipments) {
|
|
19
|
+
if (!shipment || typeof shipment !== "object") continue;
|
|
20
|
+
const lineItems = shipment.items;
|
|
21
|
+
if (!Array.isArray(lineItems)) continue;
|
|
22
|
+
for (const lineItem of lineItems) {
|
|
23
|
+
if (!lineItem || typeof lineItem !== "object") continue;
|
|
24
|
+
const record = lineItem;
|
|
25
|
+
const orderLineId = typeof record.orderLineId === "string" ? record.orderLineId : typeof record.order_line_id === "string" ? record.order_line_id : null;
|
|
26
|
+
if (!orderLineId) continue;
|
|
27
|
+
shippedByLine.set(orderLineId, (shippedByLine.get(orderLineId) ?? 0) + coerceQuantity(record.quantity));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return shippedByLine;
|
|
31
|
+
}
|
|
6
32
|
export {
|
|
7
|
-
computeAvailableReturnQuantity
|
|
33
|
+
computeAvailableReturnQuantity,
|
|
34
|
+
sumShippedQuantityByLine
|
|
8
35
|
};
|
|
9
36
|
//# sourceMappingURL=returnQuantity.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/sales/lib/returnQuantity.ts"],
|
|
4
|
-
"sourcesContent": ["export type ReturnAvailableInput = {\n quantity: number\n returnedQuantity: number\n}\n\nexport function computeAvailableReturnQuantity(line: ReturnAvailableInput): number {\n const
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["export type ReturnAvailableInput = {\n quantity: number\n returnedQuantity: number\n shippedQuantity?: number\n}\n\nexport function computeAvailableReturnQuantity(line: ReturnAvailableInput): number {\n const cap =\n typeof line.shippedQuantity === 'number' && Number.isFinite(line.shippedQuantity)\n ? Math.min(line.quantity, line.shippedQuantity)\n : line.quantity\n const raw = cap - line.returnedQuantity\n if (!Number.isFinite(raw) || raw <= 0) return 0\n return Math.max(0, Math.floor(raw + 1e-6))\n}\n\nfunction coerceQuantity(value: unknown): number {\n if (typeof value === 'number' && Number.isFinite(value)) return value\n if (typeof value === 'string' && value.trim().length) {\n const parsed = Number(value)\n if (Number.isFinite(parsed)) return parsed\n }\n return 0\n}\n\n/**\n * Aggregate shipped quantity per order line from the `/api/sales/shipments`\n * list payload. Mirrors the server-side `loadShippedQuantityByLine` cap so the\n * Return dialog's \"Available\" figure matches what `sales.returns.create`\n * enforces. Each shipment exposes its lines under `items` (either the\n * persisted snapshot or DB-grouped rows), both carrying `orderLineId` and\n * `quantity`.\n */\nexport function sumShippedQuantityByLine(\n shipments: ReadonlyArray<Record<string, unknown>> | null | undefined,\n): Map<string, number> {\n const shippedByLine = new Map<string, number>()\n if (!Array.isArray(shipments)) return shippedByLine\n for (const shipment of shipments) {\n if (!shipment || typeof shipment !== 'object') continue\n const lineItems = (shipment as Record<string, unknown>).items\n if (!Array.isArray(lineItems)) continue\n for (const lineItem of lineItems) {\n if (!lineItem || typeof lineItem !== 'object') continue\n const record = lineItem as Record<string, unknown>\n const orderLineId =\n typeof record.orderLineId === 'string'\n ? record.orderLineId\n : typeof record.order_line_id === 'string'\n ? record.order_line_id\n : null\n if (!orderLineId) continue\n shippedByLine.set(orderLineId, (shippedByLine.get(orderLineId) ?? 0) + coerceQuantity(record.quantity))\n }\n }\n return shippedByLine\n}\n"],
|
|
5
|
+
"mappings": "AAMO,SAAS,+BAA+B,MAAoC;AACjF,QAAM,MACJ,OAAO,KAAK,oBAAoB,YAAY,OAAO,SAAS,KAAK,eAAe,IAC5E,KAAK,IAAI,KAAK,UAAU,KAAK,eAAe,IAC5C,KAAK;AACX,QAAM,MAAM,MAAM,KAAK;AACvB,MAAI,CAAC,OAAO,SAAS,GAAG,KAAK,OAAO,EAAG,QAAO;AAC9C,SAAO,KAAK,IAAI,GAAG,KAAK,MAAM,MAAM,IAAI,CAAC;AAC3C;AAEA,SAAS,eAAe,OAAwB;AAC9C,MAAI,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK,EAAG,QAAO;AAChE,MAAI,OAAO,UAAU,YAAY,MAAM,KAAK,EAAE,QAAQ;AACpD,UAAM,SAAS,OAAO,KAAK;AAC3B,QAAI,OAAO,SAAS,MAAM,EAAG,QAAO;AAAA,EACtC;AACA,SAAO;AACT;AAUO,SAAS,yBACd,WACqB;AACrB,QAAM,gBAAgB,oBAAI,IAAoB;AAC9C,MAAI,CAAC,MAAM,QAAQ,SAAS,EAAG,QAAO;AACtC,aAAW,YAAY,WAAW;AAChC,QAAI,CAAC,YAAY,OAAO,aAAa,SAAU;AAC/C,UAAM,YAAa,SAAqC;AACxD,QAAI,CAAC,MAAM,QAAQ,SAAS,EAAG;AAC/B,eAAW,YAAY,WAAW;AAChC,UAAI,CAAC,YAAY,OAAO,aAAa,SAAU;AAC/C,YAAM,SAAS;AACf,YAAM,cACJ,OAAO,OAAO,gBAAgB,WAC1B,OAAO,cACP,OAAO,OAAO,kBAAkB,WAC9B,OAAO,gBACP;AACR,UAAI,CAAC,YAAa;AAClB,oBAAc,IAAI,cAAc,cAAc,IAAI,WAAW,KAAK,KAAK,eAAe,OAAO,QAAQ,CAAC;AAAA,IACxG;AAAA,EACF;AACA,SAAO;AACT;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/workflows/lib/event-trigger-service.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Workflows Module - Event Trigger Service\n *\n * Core service for evaluating and processing workflow event triggers.\n * Handles pattern matching, filter evaluation, context mapping, and workflow starting.\n */\n\nimport type { EntityManager } from '@mikro-orm/core'\nimport type { EntityManager as PostgreSqlEntityManager } from '@mikro-orm/postgresql'\nimport type { AwilixContainer } from 'awilix'\nimport type { CacheService } from '@open-mercato/cache'\nimport { matchEventPattern } from '@open-mercato/shared/lib/events/patterns'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { testLinearRegex } from '@open-mercato/shared/lib/regex/linear'\nimport {\n WorkflowEventTrigger,\n WorkflowDefinition,\n WorkflowInstance,\n type TriggerFilterCondition,\n type TriggerContextMapping,\n type WorkflowEventTriggerConfig,\n type WorkflowDefinitionTrigger,\n} from '../data/entities'\nimport { startWorkflow, executeWorkflow } from './workflow-executor'\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface EventTriggerContext {\n eventName: string\n payload: Record<string, unknown>\n tenantId: string\n organizationId: string\n}\n\nexport interface ProcessTriggersResult {\n triggered: number\n skipped: number\n errors: Array<{ triggerId: string; error: string }>\n instances: Array<{ triggerId: string; instanceId: string }>\n}\n\n/**\n * Unified trigger interface for both legacy (entity) and embedded (definition) triggers\n */\nexport interface UnifiedTrigger {\n id: string // For legacy: entity ID, for embedded: `${definitionId}:${triggerId}`\n triggerId: string\n name: string\n description?: string | null\n eventPattern: string\n config: WorkflowEventTriggerConfig | null\n enabled: boolean\n priority: number\n workflowDefinitionId: string\n workflowId: string\n workflowVersion: number\n source: 'legacy' | 'embedded'\n tenantId: string\n organizationId: string\n}\n\ninterface CachedTriggers {\n triggers: UnifiedTrigger[]\n cachedAt: number\n}\n\n// Cache TTL: 5 minutes\nconst TRIGGER_CACHE_TTL = 5 * 60 * 1000\n\n// ============================================================================\n// Pattern Matching\n// ============================================================================\n\n// ============================================================================\n// Filter Evaluation\n// ============================================================================\n\nconst MAX_WORKFLOW_REGEX_PATTERN_LENGTH = 200\nconst MAX_WORKFLOW_REGEX_INPUT_LENGTH = 10_000\n\n/**\n * Get a nested value from an object using dot notation.\n */\nfunction getNestedValue(obj: unknown, path: string): unknown {\n if (obj === null || obj === undefined) return undefined\n\n const parts = path.split('.')\n let current: unknown = obj\n\n for (const part of parts) {\n if (current === null || current === undefined) return undefined\n if (typeof current !== 'object') return undefined\n current = (current as Record<string, unknown>)[part]\n }\n\n return current\n}\n\nfunction getQuantifierEnd(pattern: string, index: number): number | null {\n const char = pattern[index]\n if (char === '*' || char === '+' || char === '?') return index\n if (char !== '{') return null\n\n const closeIndex = pattern.indexOf('}', index + 1)\n if (closeIndex === -1) return null\n\n const body = pattern.slice(index + 1, closeIndex)\n return /^[0-9]+(?:,[0-9]*)?$/.test(body) ? closeIndex : null\n}\n\ninterface RegexGroupFrame {\n hasAlternation: boolean\n hasQuantifier: boolean\n}\n\nfunction isSafeWorkflowRegexPattern(pattern: string): boolean {\n if (pattern.length > MAX_WORKFLOW_REGEX_PATTERN_LENGTH) return false\n\n const groupStack: RegexGroupFrame[] = [{ hasAlternation: false, hasQuantifier: false }]\n let inCharClass = false\n let lastClosedGroup: RegexGroupFrame | null = null\n let lastAtomWasQuantified = false\n\n for (let index = 0; index < pattern.length; index += 1) {\n const char = pattern[index]\n\n if (char === '\\\\') {\n const next = pattern[index + 1]\n if (!inCharClass && (/[1-9]/.test(next ?? '') || (next === 'k' && pattern[index + 2] === '<'))) {\n return false\n }\n index += 1\n lastClosedGroup = null\n lastAtomWasQuantified = false\n continue\n }\n\n if (inCharClass) {\n if (char === ']') {\n inCharClass = false\n lastAtomWasQuantified = false\n }\n lastClosedGroup = null\n continue\n }\n\n if (char === '[') {\n inCharClass = true\n lastClosedGroup = null\n lastAtomWasQuantified = false\n continue\n }\n\n if (char === '(') {\n if (pattern[index + 1] === '?') {\n if (pattern[index + 2] !== ':') return false\n index += 2\n }\n\n groupStack.push({ hasAlternation: false, hasQuantifier: false })\n lastClosedGroup = null\n lastAtomWasQuantified = false\n continue\n }\n\n if (char === ')') {\n if (groupStack.length === 1) return false\n lastClosedGroup = groupStack.pop()!\n lastAtomWasQuantified = false\n continue\n }\n\n if (char === '|') {\n groupStack[groupStack.length - 1].hasAlternation = true\n lastClosedGroup = null\n lastAtomWasQuantified = false\n continue\n }\n\n const quantifierEnd = getQuantifierEnd(pattern, index)\n if (quantifierEnd !== null) {\n if (lastAtomWasQuantified) return false\n if (lastClosedGroup?.hasAlternation || lastClosedGroup?.hasQuantifier) return false\n\n groupStack[groupStack.length - 1].hasQuantifier = true\n lastClosedGroup = null\n lastAtomWasQuantified = true\n index = quantifierEnd\n\n if (pattern[index + 1] === '?') {\n index += 1\n }\n\n continue\n }\n\n lastClosedGroup = null\n lastAtomWasQuantified = false\n }\n\n return groupStack.length === 1 && !inCharClass\n}\n\n/**\n * Evaluate a single filter condition against the event payload.\n */\nfunction evaluateCondition(condition: TriggerFilterCondition, payload: Record<string, unknown>): boolean {\n const value = getNestedValue(payload, condition.field)\n const expected = condition.value\n\n switch (condition.operator) {\n case 'eq':\n return value === expected\n\n case 'neq':\n return value !== expected\n\n case 'gt':\n return typeof value === 'number' && typeof expected === 'number' && value > expected\n\n case 'gte':\n return typeof value === 'number' && typeof expected === 'number' && value >= expected\n\n case 'lt':\n return typeof value === 'number' && typeof expected === 'number' && value < expected\n\n case 'lte':\n return typeof value === 'number' && typeof expected === 'number' && value <= expected\n\n case 'contains':\n if (typeof value === 'string' && typeof expected === 'string') {\n return value.includes(expected)\n }\n if (Array.isArray(value)) {\n return value.includes(expected)\n }\n return false\n\n case 'startsWith':\n return typeof value === 'string' && typeof expected === 'string' && value.startsWith(expected)\n\n case 'endsWith':\n return typeof value === 'string' && typeof expected === 'string' && value.endsWith(expected)\n\n case 'in':\n return Array.isArray(expected) && expected.includes(value)\n\n case 'notIn':\n return Array.isArray(expected) && !expected.includes(value)\n\n case 'exists':\n return value !== undefined && value !== null\n\n case 'notExists':\n return value === undefined || value === null\n\n case 'regex':\n if (typeof value !== 'string' || typeof expected !== 'string') return false\n if (value.length > MAX_WORKFLOW_REGEX_INPUT_LENGTH) return false\n if (!isSafeWorkflowRegexPattern(expected)) return false\n {\n const result = testLinearRegex(expected, value, {\n maxPatternLength: MAX_WORKFLOW_REGEX_PATTERN_LENGTH,\n maxInputLength: MAX_WORKFLOW_REGEX_INPUT_LENGTH,\n })\n return result.ok ? result.matched : false\n }\n\n default:\n return false\n }\n}\n\n/**\n * Evaluate all filter conditions against the event payload.\n * All conditions must pass (AND logic).\n */\nexport function evaluateFilterConditions(\n conditions: TriggerFilterCondition[] | undefined,\n payload: Record<string, unknown>\n): boolean {\n if (!conditions || conditions.length === 0) return true\n\n return conditions.every(condition => evaluateCondition(condition, payload))\n}\n\n// ============================================================================\n// Context Mapping\n// ============================================================================\n\n/**\n * Map event payload to workflow initial context.\n */\nexport function mapEventToContext(\n mapping: TriggerContextMapping[] | undefined,\n payload: Record<string, unknown>\n): Record<string, unknown> {\n const context: Record<string, unknown> = {}\n\n if (!mapping || mapping.length === 0) return context\n\n for (const item of mapping) {\n const value = getNestedValue(payload, item.sourceExpression)\n context[item.targetKey] = value !== undefined ? value : item.defaultValue\n }\n\n return context\n}\n\n// ============================================================================\n// Trigger Loading with Caching\n// ============================================================================\n\n// In-memory cache for triggers (per tenant/org).\n// Park the Map on globalThis so the same compiled module loaded under two\n// paths (a Next.js server chunk vs. a worker resolving the file through a\n// different import root) shares one cache. Without this,\n// `invalidateTriggerCache(...)` called from the PUT /api/workflows/definitions\n// route clears its own copy while the wildcard event-trigger subscriber keeps\n// reading a stale copy populated before the trigger was added \u2014 newly added\n// triggers stay invisible for up to TRIGGER_CACHE_TTL. Mirrors the same\n// workaround used by the modules registry and `getDiRegistrars()`.\nconst GLOBAL_TRIGGER_CACHE_KEY = '__openMercatoWorkflowTriggerCache__'\n\nfunction getTriggerCache(): Map<string, CachedTriggers> {\n const existing = (globalThis as any)[GLOBAL_TRIGGER_CACHE_KEY] as\n | Map<string, CachedTriggers>\n | undefined\n if (existing) return existing\n const created = new Map<string, CachedTriggers>()\n ;(globalThis as any)[GLOBAL_TRIGGER_CACHE_KEY] = created\n return created\n}\n\nfunction getCacheKey(tenantId: string, organizationId: string): string {\n return `${tenantId}:${organizationId}`\n}\n\n/**\n * Load legacy triggers from WorkflowEventTrigger entity table.\n * For backward compatibility with existing triggers.\n */\nasync function loadLegacyTriggers(\n em: EntityManager,\n tenantId: string,\n organizationId: string\n): Promise<UnifiedTrigger[]> {\n const postgresEm = em as unknown as PostgreSqlEntityManager\n const legacyTriggers = await findWithDecryption(\n postgresEm,\n WorkflowEventTrigger,\n {\n tenantId,\n organizationId,\n enabled: true,\n deletedAt: null,\n },\n {\n orderBy: { priority: 'DESC', createdAt: 'ASC' },\n },\n { tenantId, organizationId },\n )\n\n // Get definitions for these triggers to get workflowId\n const definitionIds = [...new Set(legacyTriggers.map(t => t.workflowDefinitionId))]\n const definitions = definitionIds.length > 0 ? await findWithDecryption(postgresEm, WorkflowDefinition, {\n id: { $in: definitionIds },\n tenantId,\n organizationId,\n enabled: true,\n deletedAt: null,\n }, {}, { tenantId, organizationId }) : []\n const definitionMap = new Map(definitions.map(d => [d.id, d]))\n\n return legacyTriggers\n .filter(t => definitionMap.has(t.workflowDefinitionId))\n .map(t => {\n const def = definitionMap.get(t.workflowDefinitionId)!\n return {\n id: t.id,\n triggerId: t.id,\n name: t.name,\n description: t.description,\n eventPattern: t.eventPattern,\n config: t.config ?? null,\n enabled: t.enabled,\n priority: t.priority,\n workflowDefinitionId: t.workflowDefinitionId,\n workflowId: def.workflowId,\n workflowVersion: def.version,\n source: 'legacy' as const,\n tenantId: t.tenantId,\n organizationId: t.organizationId,\n }\n })\n}\n\n/**\n * Load embedded triggers from workflow definitions.\n * New triggers are embedded directly in the definition JSONB.\n */\nasync function loadEmbeddedTriggers(\n em: EntityManager,\n tenantId: string,\n organizationId: string\n): Promise<UnifiedTrigger[]> {\n const postgresEm = em as unknown as PostgreSqlEntityManager\n // Load all enabled definitions that may have triggers\n const definitions = await findWithDecryption(\n postgresEm,\n WorkflowDefinition,\n {\n tenantId,\n organizationId,\n enabled: true,\n deletedAt: null,\n },\n {},\n { tenantId, organizationId },\n )\n\n const triggers: UnifiedTrigger[] = []\n\n for (const def of definitions) {\n const embeddedTriggers = def.definition?.triggers as WorkflowDefinitionTrigger[] | undefined\n if (!embeddedTriggers || embeddedTriggers.length === 0) continue\n\n for (const trigger of embeddedTriggers) {\n if (!trigger.enabled) continue\n\n triggers.push({\n id: `${def.id}:${trigger.triggerId}`,\n triggerId: trigger.triggerId,\n name: trigger.name,\n description: trigger.description ?? null,\n eventPattern: trigger.eventPattern,\n config: trigger.config ?? null,\n enabled: trigger.enabled,\n priority: trigger.priority,\n workflowDefinitionId: def.id,\n workflowId: def.workflowId,\n workflowVersion: def.version,\n source: 'embedded' as const,\n tenantId,\n organizationId,\n })\n }\n }\n\n return triggers\n}\n\n/**\n * Load all enabled triggers for a tenant/organization with caching.\n * Merges both legacy (entity) triggers and embedded (definition) triggers.\n */\nexport async function loadTriggersForTenant(\n em: EntityManager,\n tenantId: string,\n organizationId: string,\n cacheService?: CacheService\n): Promise<UnifiedTrigger[]> {\n const cacheKey = getCacheKey(tenantId, organizationId)\n const cache = getTriggerCache()\n\n // Check in-memory cache\n const cached = cache.get(cacheKey)\n if (cached && Date.now() - cached.cachedAt < TRIGGER_CACHE_TTL) {\n return cached.triggers\n }\n\n // Load from both sources\n const [legacyTriggers, embeddedTriggers] = await Promise.all([\n loadLegacyTriggers(em, tenantId, organizationId),\n loadEmbeddedTriggers(em, tenantId, organizationId),\n ])\n\n // Merge and sort by priority (higher first)\n const allTriggers = [...legacyTriggers, ...embeddedTriggers]\n .sort((a, b) => b.priority - a.priority)\n\n // Update cache\n cache.set(cacheKey, {\n triggers: allTriggers,\n cachedAt: Date.now(),\n })\n\n return allTriggers\n}\n\n/**\n * Invalidate trigger cache for a tenant/organization.\n * Call this when:\n * - Legacy triggers are created/updated/deleted\n * - Workflow definitions with embedded triggers are created/updated/deleted\n */\nexport function invalidateTriggerCache(tenantId: string, organizationId?: string): void {\n const cache = getTriggerCache()\n if (organizationId) {\n // Invalidate specific org\n const cacheKey = getCacheKey(tenantId, organizationId)\n cache.delete(cacheKey)\n } else {\n // Invalidate all orgs for tenant\n for (const key of cache.keys()) {\n if (key.startsWith(`${tenantId}:`)) {\n cache.delete(key)\n }\n }\n }\n}\n\n// ============================================================================\n// Trigger Matching\n// ============================================================================\n\n/**\n * Find all triggers that match an event.\n */\nexport async function findMatchingTriggers(\n em: EntityManager,\n context: EventTriggerContext\n): Promise<UnifiedTrigger[]> {\n const triggers = await loadTriggersForTenant(\n em,\n context.tenantId,\n context.organizationId\n )\n\n return triggers.filter(trigger => {\n // Check event pattern\n if (!matchEventPattern(context.eventName, trigger.eventPattern)) {\n return false\n }\n\n // Check filter conditions\n if (!evaluateFilterConditions(trigger.config?.filterConditions, context.payload)) {\n return false\n }\n\n return true\n })\n}\n\n// ============================================================================\n// Trigger Processing\n// ============================================================================\n\n/**\n * Check if max concurrent instances limit is reached.\n */\nasync function checkConcurrencyLimit(\n em: EntityManager,\n trigger: UnifiedTrigger\n): Promise<boolean> {\n const maxInstances = trigger.config?.maxConcurrentInstances\n\n if (!maxInstances) return true // No limit\n\n // Count running instances for this trigger's workflow definition\n const runningCount = await em.count(WorkflowInstance, {\n definitionId: trigger.workflowDefinitionId,\n status: { $in: ['RUNNING', 'WAITING_FOR_ACTIVITIES'] },\n tenantId: trigger.tenantId,\n organizationId: trigger.organizationId,\n deletedAt: null,\n })\n\n return runningCount < maxInstances\n}\n\n/**\n * Process all matching triggers for an event and start workflows.\n */\nexport async function processEventTriggers(\n em: EntityManager,\n container: AwilixContainer,\n context: EventTriggerContext\n): Promise<ProcessTriggersResult> {\n const result: ProcessTriggersResult = {\n triggered: 0,\n skipped: 0,\n errors: [],\n instances: [],\n }\n\n // Find matching triggers\n const triggers = await findMatchingTriggers(em, context)\n\n if (triggers.length === 0) {\n return result\n }\n\n // Process each trigger (definitions already validated during loading)\n for (const trigger of triggers) {\n try {\n // Check concurrency limit\n const canStart = await checkConcurrencyLimit(em, trigger)\n if (!canStart) {\n console.log(`[workflow-trigger] Skipping trigger \"${trigger.name}\": max concurrent instances reached`)\n result.skipped++\n continue\n }\n\n // Map event payload to workflow context\n const mappedContext = mapEventToContext(trigger.config?.contextMapping, context.payload)\n\n // Extract entity info from payload for metadata\n const payloadId = context.payload?.id as string | undefined\n const payloadEntityType = context.payload?.entityType as string | undefined\n\n // Include event metadata and payload in context\n const initialContext = {\n // Include raw event payload fields (e.g., id, organizationId, tenantId)\n ...context.payload,\n // Override with explicit mappings if provided\n ...mappedContext,\n __trigger: {\n triggerId: trigger.id,\n triggerName: trigger.name,\n eventName: context.eventName,\n eventPayload: context.payload,\n triggeredAt: new Date().toISOString(),\n source: trigger.source,\n },\n }\n\n // Start workflow\n const instance = await startWorkflow(em, {\n workflowId: trigger.workflowId,\n version: trigger.workflowVersion,\n initialContext,\n metadata: {\n initiatedBy: `trigger:${trigger.id}`,\n // Include entityId and entityType for widget discovery\n entityId: payloadId,\n entityType: payloadEntityType || trigger.config?.entityType,\n labels: {\n trigger_id: trigger.id,\n trigger_name: trigger.name,\n event_name: context.eventName,\n trigger_source: trigger.source,\n },\n },\n tenantId: context.tenantId,\n organizationId: context.organizationId,\n })\n\n result.triggered++\n result.instances.push({\n triggerId: trigger.id,\n instanceId: instance.id,\n })\n\n // Execute workflow asynchronously (don't wait)\n executeWorkflow(em.fork(), container, instance.id).catch(err => {\n console.error(`[workflow-trigger] Error executing workflow ${instance.id}:`, err)\n })\n\n } catch (error) {\n const errorMessage = error instanceof Error ? error.message : String(error)\n console.error(`[workflow-trigger] Error processing trigger \"${trigger.name}\":`, error)\n result.errors.push({\n triggerId: trigger.id,\n error: errorMessage,\n })\n }\n }\n\n return result\n}\n"],
|
|
5
|
-
"mappings": "AAWA,SAAS,yBAAyB;AAClC,SAAS,0BAA0B;AACnC,SAAS,uBAAuB;AAChC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OAKK;AACP,SAAS,eAAe,uBAAuB;AA8C/C,MAAM,oBAAoB,IAAI,KAAK;AAUnC,MAAM,oCAAoC;AAC1C,MAAM,kCAAkC;AAKxC,SAAS,eAAe,KAAc,MAAuB;AAC3D,MAAI,QAAQ,QAAQ,QAAQ,OAAW,QAAO;AAE9C,QAAM,QAAQ,KAAK,MAAM,GAAG;AAC5B,MAAI,UAAmB;AAEvB,aAAW,QAAQ,OAAO;AACxB,QAAI,YAAY,QAAQ,YAAY,OAAW,QAAO;AACtD,QAAI,OAAO,YAAY,SAAU,QAAO;AACxC,cAAW,QAAoC,IAAI;AAAA,EACrD;AAEA,SAAO;AACT;AAEA,SAAS,iBAAiB,SAAiB,OAA8B;AACvE,QAAM,OAAO,QAAQ,KAAK;AAC1B,MAAI,SAAS,OAAO,SAAS,OAAO,SAAS,IAAK,QAAO;AACzD,MAAI,SAAS,IAAK,QAAO;AAEzB,QAAM,aAAa,QAAQ,QAAQ,KAAK,QAAQ,CAAC;AACjD,MAAI,eAAe,GAAI,QAAO;AAE9B,QAAM,OAAO,QAAQ,MAAM,QAAQ,GAAG,UAAU;AAChD,SAAO,uBAAuB,KAAK,IAAI,IAAI,aAAa;AAC1D;AAOA,SAAS,2BAA2B,SAA0B;AAC5D,MAAI,QAAQ,SAAS,kCAAmC,QAAO;AAE/D,QAAM,aAAgC,CAAC,EAAE,gBAAgB,OAAO,eAAe,MAAM,CAAC;AACtF,MAAI,cAAc;AAClB,MAAI,kBAA0C;AAC9C,MAAI,wBAAwB;
|
|
4
|
+
"sourcesContent": ["/**\n * Workflows Module - Event Trigger Service\n *\n * Core service for evaluating and processing workflow event triggers.\n * Handles pattern matching, filter evaluation, context mapping, and workflow starting.\n */\n\nimport type { EntityManager } from '@mikro-orm/core'\nimport type { EntityManager as PostgreSqlEntityManager } from '@mikro-orm/postgresql'\nimport type { AwilixContainer } from 'awilix'\nimport type { CacheService } from '@open-mercato/cache'\nimport { matchEventPattern } from '@open-mercato/shared/lib/events/patterns'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { testLinearRegex } from '@open-mercato/shared/lib/regex/linear'\nimport {\n WorkflowEventTrigger,\n WorkflowDefinition,\n WorkflowInstance,\n type TriggerFilterCondition,\n type TriggerContextMapping,\n type WorkflowEventTriggerConfig,\n type WorkflowDefinitionTrigger,\n} from '../data/entities'\nimport { startWorkflow, executeWorkflow } from './workflow-executor'\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface EventTriggerContext {\n eventName: string\n payload: Record<string, unknown>\n tenantId: string\n organizationId: string\n}\n\nexport interface ProcessTriggersResult {\n triggered: number\n skipped: number\n errors: Array<{ triggerId: string; error: string }>\n instances: Array<{ triggerId: string; instanceId: string }>\n}\n\n/**\n * Unified trigger interface for both legacy (entity) and embedded (definition) triggers\n */\nexport interface UnifiedTrigger {\n id: string // For legacy: entity ID, for embedded: `${definitionId}:${triggerId}`\n triggerId: string\n name: string\n description?: string | null\n eventPattern: string\n config: WorkflowEventTriggerConfig | null\n enabled: boolean\n priority: number\n workflowDefinitionId: string\n workflowId: string\n workflowVersion: number\n source: 'legacy' | 'embedded'\n tenantId: string\n organizationId: string\n}\n\ninterface CachedTriggers {\n triggers: UnifiedTrigger[]\n cachedAt: number\n}\n\n// Cache TTL: 5 minutes\nconst TRIGGER_CACHE_TTL = 5 * 60 * 1000\n\n// ============================================================================\n// Pattern Matching\n// ============================================================================\n\n// ============================================================================\n// Filter Evaluation\n// ============================================================================\n\nconst MAX_WORKFLOW_REGEX_PATTERN_LENGTH = 200\nconst MAX_WORKFLOW_REGEX_INPUT_LENGTH = 10_000\n\n/**\n * Get a nested value from an object using dot notation.\n */\nfunction getNestedValue(obj: unknown, path: string): unknown {\n if (obj === null || obj === undefined) return undefined\n\n const parts = path.split('.')\n let current: unknown = obj\n\n for (const part of parts) {\n if (current === null || current === undefined) return undefined\n if (typeof current !== 'object') return undefined\n current = (current as Record<string, unknown>)[part]\n }\n\n return current\n}\n\nfunction getQuantifierEnd(pattern: string, index: number): number | null {\n const char = pattern[index]\n if (char === '*' || char === '+' || char === '?') return index\n if (char !== '{') return null\n\n const closeIndex = pattern.indexOf('}', index + 1)\n if (closeIndex === -1) return null\n\n const body = pattern.slice(index + 1, closeIndex)\n return /^[0-9]+(?:,[0-9]*)?$/.test(body) ? closeIndex : null\n}\n\ninterface RegexGroupFrame {\n hasAlternation: boolean\n hasQuantifier: boolean\n}\n\nfunction isSafeWorkflowRegexPattern(pattern: string): boolean {\n if (pattern.length > MAX_WORKFLOW_REGEX_PATTERN_LENGTH) return false\n\n const groupStack: RegexGroupFrame[] = [{ hasAlternation: false, hasQuantifier: false }]\n let inCharClass = false\n let lastClosedGroup: RegexGroupFrame | null = null\n let lastAtomWasQuantified = false\n\n // Hand-written single-pass lexer: `index` is intentionally advanced inside the\n // loop body (escape pairs, `(?:` prefixes, multi-char quantifiers, lazy `?`) on\n // top of the `index += 1` update clause. Do not \"simplify\" these in-body writes.\n for (let index = 0; index < pattern.length; index += 1) {\n const char = pattern[index]\n\n if (char === '\\\\') {\n const next = pattern[index + 1]\n if (!inCharClass && (/[1-9]/.test(next ?? '') || (next === 'k' && pattern[index + 2] === '<'))) {\n return false\n }\n index += 1\n lastClosedGroup = null\n lastAtomWasQuantified = false\n continue\n }\n\n if (inCharClass) {\n if (char === ']') {\n inCharClass = false\n lastAtomWasQuantified = false\n }\n lastClosedGroup = null\n continue\n }\n\n if (char === '[') {\n inCharClass = true\n lastClosedGroup = null\n lastAtomWasQuantified = false\n continue\n }\n\n if (char === '(') {\n if (pattern[index + 1] === '?') {\n if (pattern[index + 2] !== ':') return false\n index += 2\n }\n\n groupStack.push({ hasAlternation: false, hasQuantifier: false })\n lastClosedGroup = null\n lastAtomWasQuantified = false\n continue\n }\n\n if (char === ')') {\n if (groupStack.length === 1) return false\n lastClosedGroup = groupStack.pop()!\n lastAtomWasQuantified = false\n continue\n }\n\n if (char === '|') {\n groupStack[groupStack.length - 1].hasAlternation = true\n lastClosedGroup = null\n lastAtomWasQuantified = false\n continue\n }\n\n const quantifierEnd = getQuantifierEnd(pattern, index)\n if (quantifierEnd !== null) {\n if (lastAtomWasQuantified) return false\n if (lastClosedGroup?.hasAlternation || lastClosedGroup?.hasQuantifier) return false\n\n groupStack[groupStack.length - 1].hasQuantifier = true\n lastClosedGroup = null\n lastAtomWasQuantified = true\n index = quantifierEnd\n\n if (pattern[index + 1] === '?') {\n index += 1\n }\n\n continue\n }\n\n lastClosedGroup = null\n lastAtomWasQuantified = false\n }\n\n return groupStack.length === 1 && !inCharClass\n}\n\n/**\n * Evaluate a single filter condition against the event payload.\n */\nfunction evaluateCondition(condition: TriggerFilterCondition, payload: Record<string, unknown>): boolean {\n const value = getNestedValue(payload, condition.field)\n const expected = condition.value\n\n switch (condition.operator) {\n case 'eq':\n return value === expected\n\n case 'neq':\n return value !== expected\n\n case 'gt':\n return typeof value === 'number' && typeof expected === 'number' && value > expected\n\n case 'gte':\n return typeof value === 'number' && typeof expected === 'number' && value >= expected\n\n case 'lt':\n return typeof value === 'number' && typeof expected === 'number' && value < expected\n\n case 'lte':\n return typeof value === 'number' && typeof expected === 'number' && value <= expected\n\n case 'contains':\n if (typeof value === 'string' && typeof expected === 'string') {\n return value.includes(expected)\n }\n if (Array.isArray(value)) {\n return value.includes(expected)\n }\n return false\n\n case 'startsWith':\n return typeof value === 'string' && typeof expected === 'string' && value.startsWith(expected)\n\n case 'endsWith':\n return typeof value === 'string' && typeof expected === 'string' && value.endsWith(expected)\n\n case 'in':\n return Array.isArray(expected) && expected.includes(value)\n\n case 'notIn':\n return Array.isArray(expected) && !expected.includes(value)\n\n case 'exists':\n return value !== undefined && value !== null\n\n case 'notExists':\n return value === undefined || value === null\n\n case 'regex':\n if (typeof value !== 'string' || typeof expected !== 'string') return false\n if (value.length > MAX_WORKFLOW_REGEX_INPUT_LENGTH) return false\n if (!isSafeWorkflowRegexPattern(expected)) return false\n {\n const result = testLinearRegex(expected, value, {\n maxPatternLength: MAX_WORKFLOW_REGEX_PATTERN_LENGTH,\n maxInputLength: MAX_WORKFLOW_REGEX_INPUT_LENGTH,\n })\n return result.ok ? result.matched : false\n }\n\n default:\n return false\n }\n}\n\n/**\n * Evaluate all filter conditions against the event payload.\n * All conditions must pass (AND logic).\n */\nexport function evaluateFilterConditions(\n conditions: TriggerFilterCondition[] | undefined,\n payload: Record<string, unknown>\n): boolean {\n if (!conditions || conditions.length === 0) return true\n\n return conditions.every(condition => evaluateCondition(condition, payload))\n}\n\n// ============================================================================\n// Context Mapping\n// ============================================================================\n\n/**\n * Map event payload to workflow initial context.\n */\nexport function mapEventToContext(\n mapping: TriggerContextMapping[] | undefined,\n payload: Record<string, unknown>\n): Record<string, unknown> {\n const context: Record<string, unknown> = {}\n\n if (!mapping || mapping.length === 0) return context\n\n for (const item of mapping) {\n const value = getNestedValue(payload, item.sourceExpression)\n context[item.targetKey] = value !== undefined ? value : item.defaultValue\n }\n\n return context\n}\n\n// ============================================================================\n// Trigger Loading with Caching\n// ============================================================================\n\n// In-memory cache for triggers (per tenant/org).\n// Park the Map on globalThis so the same compiled module loaded under two\n// paths (a Next.js server chunk vs. a worker resolving the file through a\n// different import root) shares one cache. Without this,\n// `invalidateTriggerCache(...)` called from the PUT /api/workflows/definitions\n// route clears its own copy while the wildcard event-trigger subscriber keeps\n// reading a stale copy populated before the trigger was added \u2014 newly added\n// triggers stay invisible for up to TRIGGER_CACHE_TTL. Mirrors the same\n// workaround used by the modules registry and `getDiRegistrars()`.\nconst GLOBAL_TRIGGER_CACHE_KEY = '__openMercatoWorkflowTriggerCache__'\n\nfunction getTriggerCache(): Map<string, CachedTriggers> {\n const existing = (globalThis as any)[GLOBAL_TRIGGER_CACHE_KEY] as\n | Map<string, CachedTriggers>\n | undefined\n if (existing) return existing\n const created = new Map<string, CachedTriggers>()\n ;(globalThis as any)[GLOBAL_TRIGGER_CACHE_KEY] = created\n return created\n}\n\nfunction getCacheKey(tenantId: string, organizationId: string): string {\n return `${tenantId}:${organizationId}`\n}\n\n/**\n * Load legacy triggers from WorkflowEventTrigger entity table.\n * For backward compatibility with existing triggers.\n */\nasync function loadLegacyTriggers(\n em: EntityManager,\n tenantId: string,\n organizationId: string\n): Promise<UnifiedTrigger[]> {\n const postgresEm = em as unknown as PostgreSqlEntityManager\n const legacyTriggers = await findWithDecryption(\n postgresEm,\n WorkflowEventTrigger,\n {\n tenantId,\n organizationId,\n enabled: true,\n deletedAt: null,\n },\n {\n orderBy: { priority: 'DESC', createdAt: 'ASC' },\n },\n { tenantId, organizationId },\n )\n\n // Get definitions for these triggers to get workflowId\n const definitionIds = [...new Set(legacyTriggers.map(t => t.workflowDefinitionId))]\n const definitions = definitionIds.length > 0 ? await findWithDecryption(postgresEm, WorkflowDefinition, {\n id: { $in: definitionIds },\n tenantId,\n organizationId,\n enabled: true,\n deletedAt: null,\n }, {}, { tenantId, organizationId }) : []\n const definitionMap = new Map(definitions.map(d => [d.id, d]))\n\n return legacyTriggers\n .filter(t => definitionMap.has(t.workflowDefinitionId))\n .map(t => {\n const def = definitionMap.get(t.workflowDefinitionId)!\n return {\n id: t.id,\n triggerId: t.id,\n name: t.name,\n description: t.description,\n eventPattern: t.eventPattern,\n config: t.config ?? null,\n enabled: t.enabled,\n priority: t.priority,\n workflowDefinitionId: t.workflowDefinitionId,\n workflowId: def.workflowId,\n workflowVersion: def.version,\n source: 'legacy' as const,\n tenantId: t.tenantId,\n organizationId: t.organizationId,\n }\n })\n}\n\n/**\n * Load embedded triggers from workflow definitions.\n * New triggers are embedded directly in the definition JSONB.\n */\nasync function loadEmbeddedTriggers(\n em: EntityManager,\n tenantId: string,\n organizationId: string\n): Promise<UnifiedTrigger[]> {\n const postgresEm = em as unknown as PostgreSqlEntityManager\n // Load all enabled definitions that may have triggers\n const definitions = await findWithDecryption(\n postgresEm,\n WorkflowDefinition,\n {\n tenantId,\n organizationId,\n enabled: true,\n deletedAt: null,\n },\n {},\n { tenantId, organizationId },\n )\n\n const triggers: UnifiedTrigger[] = []\n\n for (const def of definitions) {\n const embeddedTriggers = def.definition?.triggers as WorkflowDefinitionTrigger[] | undefined\n if (!embeddedTriggers || embeddedTriggers.length === 0) continue\n\n for (const trigger of embeddedTriggers) {\n if (!trigger.enabled) continue\n\n triggers.push({\n id: `${def.id}:${trigger.triggerId}`,\n triggerId: trigger.triggerId,\n name: trigger.name,\n description: trigger.description ?? null,\n eventPattern: trigger.eventPattern,\n config: trigger.config ?? null,\n enabled: trigger.enabled,\n priority: trigger.priority,\n workflowDefinitionId: def.id,\n workflowId: def.workflowId,\n workflowVersion: def.version,\n source: 'embedded' as const,\n tenantId,\n organizationId,\n })\n }\n }\n\n return triggers\n}\n\n/**\n * Load all enabled triggers for a tenant/organization with caching.\n * Merges both legacy (entity) triggers and embedded (definition) triggers.\n */\nexport async function loadTriggersForTenant(\n em: EntityManager,\n tenantId: string,\n organizationId: string,\n cacheService?: CacheService\n): Promise<UnifiedTrigger[]> {\n const cacheKey = getCacheKey(tenantId, organizationId)\n const cache = getTriggerCache()\n\n // Check in-memory cache\n const cached = cache.get(cacheKey)\n if (cached && Date.now() - cached.cachedAt < TRIGGER_CACHE_TTL) {\n return cached.triggers\n }\n\n // Load from both sources\n const [legacyTriggers, embeddedTriggers] = await Promise.all([\n loadLegacyTriggers(em, tenantId, organizationId),\n loadEmbeddedTriggers(em, tenantId, organizationId),\n ])\n\n // Merge and sort by priority (higher first)\n const allTriggers = [...legacyTriggers, ...embeddedTriggers]\n .sort((a, b) => b.priority - a.priority)\n\n // Update cache\n cache.set(cacheKey, {\n triggers: allTriggers,\n cachedAt: Date.now(),\n })\n\n return allTriggers\n}\n\n/**\n * Invalidate trigger cache for a tenant/organization.\n * Call this when:\n * - Legacy triggers are created/updated/deleted\n * - Workflow definitions with embedded triggers are created/updated/deleted\n */\nexport function invalidateTriggerCache(tenantId: string, organizationId?: string): void {\n const cache = getTriggerCache()\n if (organizationId) {\n // Invalidate specific org\n const cacheKey = getCacheKey(tenantId, organizationId)\n cache.delete(cacheKey)\n } else {\n // Invalidate all orgs for tenant\n for (const key of cache.keys()) {\n if (key.startsWith(`${tenantId}:`)) {\n cache.delete(key)\n }\n }\n }\n}\n\n// ============================================================================\n// Trigger Matching\n// ============================================================================\n\n/**\n * Find all triggers that match an event.\n */\nexport async function findMatchingTriggers(\n em: EntityManager,\n context: EventTriggerContext\n): Promise<UnifiedTrigger[]> {\n const triggers = await loadTriggersForTenant(\n em,\n context.tenantId,\n context.organizationId\n )\n\n return triggers.filter(trigger => {\n // Check event pattern\n if (!matchEventPattern(context.eventName, trigger.eventPattern)) {\n return false\n }\n\n // Check filter conditions\n if (!evaluateFilterConditions(trigger.config?.filterConditions, context.payload)) {\n return false\n }\n\n return true\n })\n}\n\n// ============================================================================\n// Trigger Processing\n// ============================================================================\n\n/**\n * Check if max concurrent instances limit is reached.\n */\nasync function checkConcurrencyLimit(\n em: EntityManager,\n trigger: UnifiedTrigger\n): Promise<boolean> {\n const maxInstances = trigger.config?.maxConcurrentInstances\n\n if (!maxInstances) return true // No limit\n\n // Count running instances for this trigger's workflow definition\n const runningCount = await em.count(WorkflowInstance, {\n definitionId: trigger.workflowDefinitionId,\n status: { $in: ['RUNNING', 'WAITING_FOR_ACTIVITIES'] },\n tenantId: trigger.tenantId,\n organizationId: trigger.organizationId,\n deletedAt: null,\n })\n\n return runningCount < maxInstances\n}\n\n/**\n * Process all matching triggers for an event and start workflows.\n */\nexport async function processEventTriggers(\n em: EntityManager,\n container: AwilixContainer,\n context: EventTriggerContext\n): Promise<ProcessTriggersResult> {\n const result: ProcessTriggersResult = {\n triggered: 0,\n skipped: 0,\n errors: [],\n instances: [],\n }\n\n // Find matching triggers\n const triggers = await findMatchingTriggers(em, context)\n\n if (triggers.length === 0) {\n return result\n }\n\n // Process each trigger (definitions already validated during loading)\n for (const trigger of triggers) {\n try {\n // Check concurrency limit\n const canStart = await checkConcurrencyLimit(em, trigger)\n if (!canStart) {\n console.log(`[workflow-trigger] Skipping trigger \"${trigger.name}\": max concurrent instances reached`)\n result.skipped++\n continue\n }\n\n // Map event payload to workflow context\n const mappedContext = mapEventToContext(trigger.config?.contextMapping, context.payload)\n\n // Extract entity info from payload for metadata\n const payloadId = context.payload?.id as string | undefined\n const payloadEntityType = context.payload?.entityType as string | undefined\n\n // Include event metadata and payload in context\n const initialContext = {\n // Include raw event payload fields (e.g., id, organizationId, tenantId)\n ...context.payload,\n // Override with explicit mappings if provided\n ...mappedContext,\n __trigger: {\n triggerId: trigger.id,\n triggerName: trigger.name,\n eventName: context.eventName,\n eventPayload: context.payload,\n triggeredAt: new Date().toISOString(),\n source: trigger.source,\n },\n }\n\n // Start workflow\n const instance = await startWorkflow(em, {\n workflowId: trigger.workflowId,\n version: trigger.workflowVersion,\n initialContext,\n metadata: {\n initiatedBy: `trigger:${trigger.id}`,\n // Include entityId and entityType for widget discovery\n entityId: payloadId,\n entityType: payloadEntityType || trigger.config?.entityType,\n labels: {\n trigger_id: trigger.id,\n trigger_name: trigger.name,\n event_name: context.eventName,\n trigger_source: trigger.source,\n },\n },\n tenantId: context.tenantId,\n organizationId: context.organizationId,\n })\n\n result.triggered++\n result.instances.push({\n triggerId: trigger.id,\n instanceId: instance.id,\n })\n\n // Execute workflow asynchronously (don't wait)\n executeWorkflow(em.fork(), container, instance.id).catch(err => {\n console.error(`[workflow-trigger] Error executing workflow ${instance.id}:`, err)\n })\n\n } catch (error) {\n const errorMessage = error instanceof Error ? error.message : String(error)\n console.error(`[workflow-trigger] Error processing trigger \"${trigger.name}\":`, error)\n result.errors.push({\n triggerId: trigger.id,\n error: errorMessage,\n })\n }\n }\n\n return result\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.6313.1.618a49a6b4",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -246,16 +246,16 @@
|
|
|
246
246
|
"zod": "^4.4.3"
|
|
247
247
|
},
|
|
248
248
|
"peerDependencies": {
|
|
249
|
-
"@open-mercato/ai-assistant": "0.6.6-develop.
|
|
250
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
251
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
249
|
+
"@open-mercato/ai-assistant": "0.6.6-develop.6313.1.618a49a6b4",
|
|
250
|
+
"@open-mercato/shared": "0.6.6-develop.6313.1.618a49a6b4",
|
|
251
|
+
"@open-mercato/ui": "0.6.6-develop.6313.1.618a49a6b4",
|
|
252
252
|
"react": "^19.0.0",
|
|
253
253
|
"react-dom": "^19.0.0"
|
|
254
254
|
},
|
|
255
255
|
"devDependencies": {
|
|
256
|
-
"@open-mercato/ai-assistant": "0.6.6-develop.
|
|
257
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
258
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
256
|
+
"@open-mercato/ai-assistant": "0.6.6-develop.6313.1.618a49a6b4",
|
|
257
|
+
"@open-mercato/shared": "0.6.6-develop.6313.1.618a49a6b4",
|
|
258
|
+
"@open-mercato/ui": "0.6.6-develop.6313.1.618a49a6b4",
|
|
259
259
|
"@testing-library/dom": "^10.4.1",
|
|
260
260
|
"@testing-library/jest-dom": "^6.9.1",
|
|
261
261
|
"@testing-library/react": "^16.3.1",
|
|
@@ -73,6 +73,27 @@ export async function createOrderLineFixture(
|
|
|
73
73
|
);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Ship one or more order lines so a subsequent return passes the
|
|
78
|
+
* shipped-quantity guard (issue #3034). A return can only be created for
|
|
79
|
+
* quantities that were physically shipped, so any spec that creates a return
|
|
80
|
+
* must ship the relevant line(s) first. Returns the created shipment id.
|
|
81
|
+
*/
|
|
82
|
+
export async function createShipmentFixture(
|
|
83
|
+
request: APIRequestContext,
|
|
84
|
+
token: string,
|
|
85
|
+
orderId: string,
|
|
86
|
+
items: Array<{ orderLineId: string; quantity: number }>,
|
|
87
|
+
): Promise<string> {
|
|
88
|
+
return createEntity(
|
|
89
|
+
request,
|
|
90
|
+
token,
|
|
91
|
+
'/api/sales/shipments',
|
|
92
|
+
{ orderId, items },
|
|
93
|
+
['id', 'shipmentId'],
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
76
97
|
/**
|
|
77
98
|
* Probe whether the authenticated principal can create a sales order on the
|
|
78
99
|
* current tenant (i.e. holds `sales.orders.manage`). Sales-write integration
|
|
@@ -16,6 +16,32 @@ function normalizeIsoToken(raw: string): string | null {
|
|
|
16
16
|
return new Date(ms).toISOString()
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* HTTP header carrying the enterprise `record_locks` conflict resolution the
|
|
21
|
+
* client chose in the merge dialog. This guard only reads the header value off
|
|
22
|
+
* the request (no enterprise import) — it is the integration contract between
|
|
23
|
+
* the record-lock layer and this universal optimistic-lock floor.
|
|
24
|
+
*/
|
|
25
|
+
const RECORD_LOCK_RESOLUTION_HEADER_NAME = 'x-om-record-lock-resolution'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolutions that express an explicit, privileged intent to OVERWRITE the
|
|
29
|
+
* incoming concurrent write ("Keep mine" / merged). When the request asserts
|
|
30
|
+
* one of these, the record-lock guard (which runs first, at priority 0) has
|
|
31
|
+
* already authorized the override against `canOverrideIncoming`, so the
|
|
32
|
+
* stale-`updated_at` floor MUST defer to it — otherwise it rejects exactly the
|
|
33
|
+
* overwrite the user just authorized, looping the merge dialog on a fresh 409
|
|
34
|
+
* (issue #3601). `accept_incoming` is intentionally NOT here: that path reloads
|
|
35
|
+
* with a fresh `updated_at` and must still pass through the floor normally.
|
|
36
|
+
*/
|
|
37
|
+
const RECORD_LOCK_OVERRIDE_RESOLUTIONS: ReadonlySet<string> = new Set(['accept_mine', 'merged'])
|
|
38
|
+
|
|
39
|
+
export function isAuthorizedRecordLockOverride(headers: Headers): boolean {
|
|
40
|
+
const raw = headers.get(RECORD_LOCK_RESOLUTION_HEADER_NAME)
|
|
41
|
+
if (typeof raw !== 'string') return false
|
|
42
|
+
return RECORD_LOCK_OVERRIDE_RESOLUTIONS.has(raw.trim().toLowerCase())
|
|
43
|
+
}
|
|
44
|
+
|
|
19
45
|
const optimisticLockGuard: MutationGuard = {
|
|
20
46
|
id: 'customers.optimistic-lock',
|
|
21
47
|
targetEntity: '*',
|
|
@@ -24,6 +50,9 @@ const optimisticLockGuard: MutationGuard = {
|
|
|
24
50
|
async validate(input) {
|
|
25
51
|
const config = parseOptimisticLockEnv(process.env[OPTIMISTIC_LOCK_ENV_VAR])
|
|
26
52
|
if (config.mode === 'off') return { ok: true }
|
|
53
|
+
// A privileged record-lock "Keep mine" override deliberately overwrites the
|
|
54
|
+
// concurrent write; the floor must not block it on the now-stale timestamp.
|
|
55
|
+
if (isAuthorizedRecordLockOverride(input.requestHeaders)) return { ok: true }
|
|
27
56
|
const enabled = config.mode === 'all' || config.entities.has(input.resourceKind.toLowerCase())
|
|
28
57
|
if (!enabled) return { ok: true }
|
|
29
58
|
const readers = getAllOptimisticLockReaders()
|