@open-mercato/core 0.10.1-develop.7982.1.df20d84e85 → 0.10.1-develop.7990.1.82d0a183c5
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/.turbo/turbo-build.log +1 -1
- package/dist/modules/auth/commands/users.js +27 -23
- package/dist/modules/auth/commands/users.js.map +2 -2
- package/dist/modules/customers/commands/companies.js +10 -1
- package/dist/modules/customers/commands/companies.js.map +2 -2
- package/dist/modules/customers/docs/issue-8675-ownership-assessment.md +30 -0
- package/dist/modules/promotions/backend/promotions/[id]/page.js +3 -2
- package/dist/modules/promotions/backend/promotions/[id]/page.js.map +2 -2
- package/dist/modules/promotions/backend/promotions/page.js +3 -7
- package/dist/modules/promotions/backend/promotions/page.js.map +2 -2
- package/dist/modules/promotions/components/promotionDates.js +19 -0
- package/dist/modules/promotions/components/promotionDates.js.map +7 -0
- package/dist/modules/promotions/data/validators.js +8 -1
- package/dist/modules/promotions/data/validators.js.map +2 -2
- package/dist/modules/wms/components/backend/CompletePutawayTaskDialog.js +2 -2
- package/dist/modules/wms/components/backend/CompletePutawayTaskDialog.js.map +2 -2
- package/dist/modules/wms/components/backend/CreateAsnDialog.js +5 -5
- package/dist/modules/wms/components/backend/CreateAsnDialog.js.map +2 -2
- package/dist/modules/wms/components/backend/ReceiveAsnLineDialog.js +1 -1
- package/dist/modules/wms/components/backend/ReceiveAsnLineDialog.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/auth/commands/users.ts +28 -25
- package/src/modules/customers/commands/companies.ts +11 -1
- package/src/modules/customers/docs/issue-8675-ownership-assessment.md +30 -0
- package/src/modules/promotions/backend/promotions/[id]/page.tsx +3 -2
- package/src/modules/promotions/backend/promotions/page.tsx +3 -8
- package/src/modules/promotions/components/promotionDates.ts +19 -0
- package/src/modules/promotions/data/validators.ts +9 -1
- package/src/modules/promotions/i18n/de.json +6 -6
- package/src/modules/promotions/i18n/es.json +6 -6
- package/src/modules/promotions/i18n/ko.json +6 -6
- package/src/modules/promotions/i18n/pl.json +6 -6
- package/src/modules/wms/components/backend/CompletePutawayTaskDialog.tsx +2 -2
- package/src/modules/wms/components/backend/CreateAsnDialog.tsx +5 -5
- package/src/modules/wms/components/backend/ReceiveAsnLineDialog.tsx +1 -1
- package/src/modules/wms/i18n/de.json +173 -165
- package/src/modules/wms/i18n/en.json +8 -0
- package/src/modules/wms/i18n/es.json +175 -167
- package/src/modules/wms/i18n/ko.json +175 -167
- package/src/modules/wms/i18n/pl.json +172 -164
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/wms/components/backend/ReceiveAsnLineDialog.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { z } from 'zod'\nimport { useQueryClient } from '@tanstack/react-query'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { flashMutationError } from '../../lib/flashMutationError'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { ComboboxInput } from '@open-mercato/ui/backend/inputs/ComboboxInput'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport {\n buildOptimisticLockHeader,\n extractOptimisticLockConflict,\n} from '@open-mercato/ui/backend/utils/optimisticLock'\nimport { raiseCrudError } from '@open-mercato/ui/backend/utils/serverErrors'\nimport { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from '@open-mercato/ui/primitives/dialog'\nimport { FormField } from '@open-mercato/ui/primitives/form-field'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { KbdShortcut } from '@open-mercato/ui/primitives/kbd'\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '@open-mercato/ui/primitives/select'\nimport { Textarea } from '@open-mercato/ui/primitives/textarea'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport {\n loadStagingLocationOptions,\n resolveLocationLabel,\n} from './inventoryMutationLoaders'\nimport type { WmsInventoryMutationAccess } from './useWmsInventoryMutationAccess'\n\nexport type ReceiveAsnLineTarget = {\n lineId: string\n catalogVariantId: string\n expectedQty: number\n receivedQty: number\n lotNumber?: string | null\n serialNumbers?: string[] | null\n targetStagingLocationId?: string | null\n variantLabel?: string | null\n updatedAt?: string | null\n asnUpdatedAt?: string | null\n}\n\nexport function parseReceiveSerialNumbers(value: string): string[] {\n return value\n .split(/[\\n,]/)\n .map((part) => part.trim())\n .filter((part) => part.length > 0)\n}\n\ntype ReceiveAsnLineDialogProps = {\n open: boolean\n onOpenChange: (open: boolean) => void\n access: WmsInventoryMutationAccess\n asnId: string\n warehouseId: string\n line: ReceiveAsnLineTarget | null\n onSuccess?: (result?: { receivedQty: number; asnUpdatedAt?: string | null }) => void\n}\n\nconst formSchema = z\n .object({\n receivedQty: z.number().positive(),\n qcStatus: z.enum(['passed', 'failed']),\n targetStagingLocationId: z.string().uuid().optional(),\n lotNumber: z.string().trim().max(120).optional(),\n rejectionReason: z.string().trim().max(500).optional(),\n })\n .superRefine((value, ctx) => {\n if (value.qcStatus === 'passed' && !value.targetStagingLocationId) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: ['targetStagingLocationId'],\n message: 'wms.backend.asns.receive.errors.stagingRequired',\n })\n }\n if (value.qcStatus === 'failed' && !value.rejectionReason) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: ['rejectionReason'],\n message: 'wms.backend.asns.receive.errors.rejectionRequired',\n })\n }\n })\n\nexport function ReceiveAsnLineDialog({\n open,\n onOpenChange,\n access,\n asnId,\n warehouseId,\n line,\n onSuccess,\n}: ReceiveAsnLineDialogProps) {\n const t = useT()\n const queryClient = useQueryClient()\n const { runMutation, retryLastMutation } = useGuardedMutation<{\n retryLastMutation: () => Promise<boolean>\n }>({\n contextId: 'wms-asn-receive-line',\n })\n const mutationContext = React.useMemo(\n () => ({ retryLastMutation }),\n [retryLastMutation],\n )\n\n const remaining = Math.max(0, (line?.expectedQty ?? 0) - (line?.receivedQty ?? 0))\n const [baselineReceivedQty, setBaselineReceivedQty] = React.useState(line?.receivedQty ?? 0)\n const [receivedQty, setReceivedQty] = React.useState(1)\n const [qcStatus, setQcStatus] = React.useState<'passed' | 'failed'>('passed')\n const [targetStagingLocationId, setTargetStagingLocationId] = React.useState('')\n const [lotNumber, setLotNumber] = React.useState('')\n const [serialNumbersText, setSerialNumbersText] = React.useState('')\n const [rejectionReason, setRejectionReason] = React.useState('')\n const [fieldErrors, setFieldErrors] = React.useState<Record<string, string>>({})\n const [submitting, setSubmitting] = React.useState(false)\n const [lockUpdatedAt, setLockUpdatedAt] = React.useState<string | null>(null)\n /** Fresh If-Match after 409; ref so guarded retry reads latest without re-seed. */\n const lockUpdatedAtRef = React.useRef<string | null>(null)\n\n const closeDialog = React.useCallback(() => {\n if (submitting) return\n onOpenChange(false)\n }, [onOpenChange, submitting])\n\n React.useEffect(() => {\n if (!open || !line) return\n setBaselineReceivedQty(line.receivedQty)\n setReceivedQty(remaining > 0 ? remaining : 1)\n setQcStatus('passed')\n setTargetStagingLocationId(line.targetStagingLocationId?.trim() || '')\n setLotNumber(line.lotNumber?.trim() || '')\n const storedSerials = (line.serialNumbers ?? []).map((value) => value.trim()).filter(Boolean)\n // After a partial receipt the field is this batch only. Planned serials still\n // prefill the first receipt, when nothing has been received yet.\n setSerialNumbersText(line.receivedQty > 0 ? '' : storedSerials.join('\\n'))\n setRejectionReason('')\n setFieldErrors({})\n setSubmitting(false)\n const seed = line.asnUpdatedAt ?? line.updatedAt ?? null\n lockUpdatedAtRef.current = seed\n setLockUpdatedAt(seed)\n }, [line, open, remaining])\n\n const handleSubmit = React.useCallback(\n async (event?: React.FormEvent) => {\n event?.preventDefault()\n if (!line) return\n if (!access.scopeReady || !access.organizationId || !access.tenantId || !access.userId) {\n flash(\n t(\n 'wms.backend.inventory.mutations.errors.scope',\n 'Select an organization and sign in before posting inventory changes.',\n ),\n 'error',\n )\n return\n }\n\n const parsed = formSchema.safeParse({\n receivedQty,\n qcStatus,\n targetStagingLocationId: targetStagingLocationId.trim() || undefined,\n lotNumber: lotNumber.trim() || undefined,\n rejectionReason: rejectionReason.trim() || undefined,\n })\n const serialNumbers = parseReceiveSerialNumbers(serialNumbersText)\n const alreadyReceivedSerials =\n line.receivedQty > 0\n ? (line.serialNumbers ?? []).map((value) => value.trim()).filter(Boolean)\n : []\n const nextErrors: Record<string, string> = {}\n if (!parsed.success) {\n for (const issue of parsed.error.issues) {\n const key = String(issue.path[0] ?? 'form')\n if (!nextErrors[key]) {\n nextErrors[key] = t(issue.message, issue.message)\n }\n }\n }\n if (serialNumbers.length > 0) {\n const uniqueSerials = new Set(serialNumbers)\n const knownSerials = new Set(alreadyReceivedSerials)\n if (uniqueSerials.size !== serialNumbers.length || serialNumbers.some((value) => knownSerials.has(value))) {\n nextErrors.serialNumbers = t(\n 'wms.backend.asns.receive.errors.duplicateSerial',\n 'Duplicate serial numbers are not allowed.',\n )\n } else if (parsed.success && serialNumbers.length !== parsed.data.receivedQty) {\n nextErrors.serialNumbers = t(\n 'wms.backend.asns.receive.errors.serialCount',\n 'Serial count must match the received quantity.',\n )\n }\n }\n if (!parsed.success || nextErrors.serialNumbers) {\n setFieldErrors(nextErrors)\n return\n }\n\n setSubmitting(true)\n setFieldErrors({})\n const targetReceivedQty = baselineReceivedQty + parsed.data.receivedQty\n const payload: Record<string, unknown> = {\n organizationId: access.organizationId,\n tenantId: access.tenantId,\n lineId: line.lineId,\n receivedQty: parsed.data.receivedQty,\n targetReceivedQty,\n qcStatus: parsed.data.qcStatus,\n performedBy: access.userId,\n }\n if (parsed.data.targetStagingLocationId) {\n payload.targetStagingLocationId = parsed.data.targetStagingLocationId\n }\n if (parsed.data.lotNumber) payload.lotNumber = parsed.data.lotNumber\n if (serialNumbers.length > 0) payload.serialNumbers = serialNumbers\n if (parsed.data.rejectionReason) payload.rejectionReason = parsed.data.rejectionReason\n\n try {\n let conflictHandled = false\n let mutationResult: {\n ok?: boolean\n receivedQty?: number\n asnUpdatedAt?: string\n } = {}\n await runMutation({\n operation: async () => {\n const call = await apiCall<{\n ok?: boolean\n receivedQty?: number\n asnUpdatedAt?: string\n }>(\n `/api/wms/asns/${encodeURIComponent(asnId)}/receive`,\n {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n ...buildOptimisticLockHeader(lockUpdatedAtRef.current ?? undefined),\n },\n body: JSON.stringify(payload),\n },\n )\n if (!call.ok) {\n const refreshLockFromConflict = () => {\n const conflict = extractOptimisticLockConflict({\n status: call.status,\n body: call.result,\n })\n if (conflict?.currentUpdatedAt) {\n lockUpdatedAtRef.current = conflict.currentUpdatedAt\n setLockUpdatedAt(conflict.currentUpdatedAt)\n }\n void queryClient.invalidateQueries({ queryKey: ['wms-asn-detail'] })\n void queryClient.invalidateQueries({ queryKey: ['wms-asns-list'] })\n }\n if (\n surfaceRecordConflict({ status: call.status, body: call.result }, t, {\n onRefresh: refreshLockFromConflict,\n })\n ) {\n refreshLockFromConflict()\n conflictHandled = true\n return {}\n }\n await raiseCrudError(\n call.response,\n t('wms.backend.asns.receive.errors.submit', 'Failed to receive ASN line.'),\n )\n }\n mutationResult = call.result ?? {}\n return mutationResult\n },\n context: mutationContext,\n mutationPayload: payload,\n })\n\n if (conflictHandled) return\n\n const nextReceivedQty =\n typeof mutationResult.receivedQty === 'number'\n ? mutationResult.receivedQty\n : targetReceivedQty\n setBaselineReceivedQty(nextReceivedQty)\n if (mutationResult.asnUpdatedAt) {\n setLockUpdatedAt(mutationResult.asnUpdatedAt)\n }\n\n flash(\n parsed.data.qcStatus === 'failed'\n ? t('wms.backend.asns.receive.flash.qcFailed', 'ASN line recorded with QC fail')\n : t('wms.backend.asns.receive.flash.success', 'ASN line received'),\n 'success',\n )\n await Promise.all([\n queryClient.invalidateQueries({ queryKey: ['wms-asn-detail'] }),\n queryClient.invalidateQueries({ queryKey: ['wms-asns-list'] }),\n queryClient.invalidateQueries({ queryKey: ['wms-putaway-queue'] }),\n ])\n closeDialog()\n onSuccess?.({\n receivedQty: nextReceivedQty,\n asnUpdatedAt: mutationResult.asnUpdatedAt ?? lockUpdatedAt,\n })\n } catch (error) {\n flashMutationError(\n error,\n t('wms.backend.asns.receive.errors.submit', 'Failed to receive ASN line.'),\n t,\n )\n } finally {\n setSubmitting(false)\n }\n },\n [\n access,\n asnId,\n baselineReceivedQty,\n closeDialog,\n line,\n lockUpdatedAt,\n lotNumber,\n mutationContext,\n onSuccess,\n qcStatus,\n queryClient,\n receivedQty,\n rejectionReason,\n runMutation,\n serialNumbersText,\n t,\n targetStagingLocationId,\n ],\n )\n\n const handleKeyDown = React.useCallback(\n (event: React.KeyboardEvent<HTMLDivElement>) => {\n if (event.key === 'Escape') {\n event.preventDefault()\n closeDialog()\n return\n }\n if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {\n event.preventDefault()\n if (!submitting) void handleSubmit()\n }\n },\n [closeDialog, handleSubmit, submitting],\n )\n\n if (!line) return null\n\n return (\n <Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : closeDialog())}>\n <DialogContent className=\"max-w-lg gap-0 overflow-hidden p-0\" onKeyDown={handleKeyDown}>\n <div className=\"border-b px-6 py-4 pr-12\">\n <DialogHeader className=\"space-y-1 text-left\">\n <DialogTitle>{t('wms.backend.asns.receive.dialog.title', 'Receive ASN line')}</DialogTitle>\n <DialogDescription>\n {t(\n 'wms.backend.asns.receive.dialog.description',\n 'Record accepted or rejected quantity against the expected ASN line.',\n )}\n </DialogDescription>\n </DialogHeader>\n </div>\n\n <form onSubmit={handleSubmit} className=\"flex min-h-0 flex-1 flex-col\">\n <div className=\"flex flex-col gap-5 overflow-y-auto px-6 py-6\">\n <div className=\"rounded-md border bg-muted/30 px-3 py-2 text-sm\">\n <div className=\"font-medium\">\n {line.variantLabel?.trim() || line.catalogVariantId}\n </div>\n <div className=\"text-muted-foreground\">\n {t('wms.backend.asns.receive.summary.expected', 'Expected')}: {line.expectedQty}\n {' \u00B7 '}\n {t('wms.backend.asns.receive.summary.received', 'Received')}: {baselineReceivedQty}\n </div>\n {baselineReceivedQty > 0 && (line.serialNumbers ?? []).some((value) => value.trim()) ? (\n <div className=\"text-muted-foreground\" data-testid=\"receive-asn-known-serials\">\n {t('wms.backend.asns.receive.summary.alreadyReceivedSerials', 'Already received')}:{' '}\n {(line.serialNumbers ?? [])\n .map((value) => value.trim())\n .filter(Boolean)\n .join(', ')}\n </div>\n ) : null}\n </div>\n\n <FormField\n label={t('wms.backend.asns.receive.form.receivedQty', 'Received quantity')}\n required\n error={fieldErrors.receivedQty}\n >\n <Input\n type=\"number\"\n min={0.0001}\n step=\"any\"\n value={receivedQty}\n onChange={(event) => setReceivedQty(Number(event.target.value))}\n disabled={submitting}\n data-testid=\"receive-asn-qty\"\n />\n </FormField>\n\n <FormField\n label={t('wms.backend.asns.receive.form.qcStatus', 'QC outcome')}\n required\n error={fieldErrors.qcStatus}\n >\n <Select\n value={qcStatus}\n onValueChange={(value) => setQcStatus(value as 'passed' | 'failed')}\n disabled={submitting}\n >\n <SelectTrigger data-testid=\"receive-asn-qc\">\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n <SelectItem value=\"passed\">\n {t('wms.backend.asns.qc.passed', 'Passed')}\n </SelectItem>\n <SelectItem value=\"failed\">\n {t('wms.backend.asns.qc.failed', 'Failed')}\n </SelectItem>\n </SelectContent>\n </Select>\n </FormField>\n\n {qcStatus === 'passed' ? (\n <FormField\n label={t('wms.backend.asns.receive.form.staging', 'Staging location')}\n required\n error={fieldErrors.targetStagingLocationId}\n >\n <ComboboxInput\n value={targetStagingLocationId}\n onChange={setTargetStagingLocationId}\n loadSuggestions={async (query) =>\n warehouseId ? loadStagingLocationOptions(warehouseId, query) : []\n }\n resolveLabel={async (value) => (await resolveLocationLabel(value)) ?? value}\n placeholder={t(\n 'wms.backend.asns.receive.form.stagingPlaceholder',\n 'Select staging or dock',\n )}\n allowCustomValues={false}\n disabled={submitting || !warehouseId}\n />\n </FormField>\n ) : (\n <FormField\n label={t('wms.backend.asns.receive.form.rejectionReason', 'Rejection reason')}\n required\n error={fieldErrors.rejectionReason}\n >\n <Textarea\n value={rejectionReason}\n onChange={(event) => setRejectionReason(event.target.value)}\n disabled={submitting}\n rows={3}\n data-testid=\"receive-asn-rejection\"\n />\n </FormField>\n )}\n\n <FormField\n label={t('wms.backend.asns.receive.form.serialNumbers', 'Serial numbers')}\n error={fieldErrors.serialNumbers}\n description={t(\n 'wms.backend.asns.receive.form.serialNumbersHint',\n 'This batch only, one per line. Do not repeat serials already received. The count must match this quantity.',\n )}\n >\n <Textarea\n value={serialNumbersText}\n onChange={(event) => setSerialNumbersText(event.target.value)}\n disabled={submitting}\n rows={3}\n placeholder={t(\n 'wms.backend.asns.receive.form.serialNumbersPlaceholder',\n 'Enter one serial number per line',\n )}\n data-testid=\"receive-asn-serials\"\n />\n </FormField>\n\n <FormField\n label={t('wms.backend.asns.receive.form.lotNumber', 'Lot number')}\n error={fieldErrors.lotNumber}\n >\n <Input\n value={lotNumber}\n onChange={(event) => setLotNumber(event.target.value)}\n disabled={submitting}\n />\n </FormField>\n </div>\n\n <DialogFooter bordered={false} className=\"border-t px-6 py-4 sm:justify-between\">\n <span className=\"hidden text-xs text-muted-foreground sm:inline\">\n <KbdShortcut keys={['Ctrl/\u2318', 'Enter']} />{' '}\n {t('wms.backend.asns.receive.dialog.shortcutHint', 'to receive')}\n </span>\n <div className=\"flex gap-2\">\n <Button type=\"button\" variant=\"outline\" onClick={closeDialog} disabled={submitting}>\n {t('wms.backend.asns.receive.dialog.cancel', 'Cancel')}\n </Button>\n <Button\n type=\"submit\"\n disabled={submitting || access.loading || !access.scopeReady}\n data-testid=\"receive-asn-submit\"\n >\n {t('wms.backend.asns.receive.dialog.submit', 'Receive line')}\n </Button>\n </div>\n </DialogFooter>\n </form>\n </DialogContent>\n </Dialog>\n )\n}\n"],
|
|
5
|
-
"mappings": ";AAiXU,SACE,KADF;AA/WV,YAAY,WAAW;AACvB,SAAS,SAAS;AAClB,SAAS,sBAAsB;AAC/B,SAAS,aAAa;AACtB,SAAS,0BAA0B;AACnC,SAAS,0BAA0B;AACnC,SAAS,qBAAqB;AAC9B,SAAS,eAAe;AACxB;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,sBAAsB;AAC/B,SAAS,6BAA6B;AACtC,SAAS,cAAc;AACvB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,iBAAiB;AAC1B,SAAS,aAAa;AACtB,SAAS,mBAAmB;AAC5B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,gBAAgB;AACzB,SAAS,YAAY;AACrB;AAAA,EACE;AAAA,EACA;AAAA,OACK;AAgBA,SAAS,0BAA0B,OAAyB;AACjE,SAAO,MACJ,MAAM,OAAO,EACb,IAAI,CAAC,SAAS,KAAK,KAAK,CAAC,EACzB,OAAO,CAAC,SAAS,KAAK,SAAS,CAAC;AACrC;AAYA,MAAM,aAAa,EAChB,OAAO;AAAA,EACN,aAAa,EAAE,OAAO,EAAE,SAAS;AAAA,
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { z } from 'zod'\nimport { useQueryClient } from '@tanstack/react-query'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { flashMutationError } from '../../lib/flashMutationError'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { ComboboxInput } from '@open-mercato/ui/backend/inputs/ComboboxInput'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport {\n buildOptimisticLockHeader,\n extractOptimisticLockConflict,\n} from '@open-mercato/ui/backend/utils/optimisticLock'\nimport { raiseCrudError } from '@open-mercato/ui/backend/utils/serverErrors'\nimport { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from '@open-mercato/ui/primitives/dialog'\nimport { FormField } from '@open-mercato/ui/primitives/form-field'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { KbdShortcut } from '@open-mercato/ui/primitives/kbd'\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '@open-mercato/ui/primitives/select'\nimport { Textarea } from '@open-mercato/ui/primitives/textarea'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport {\n loadStagingLocationOptions,\n resolveLocationLabel,\n} from './inventoryMutationLoaders'\nimport type { WmsInventoryMutationAccess } from './useWmsInventoryMutationAccess'\n\nexport type ReceiveAsnLineTarget = {\n lineId: string\n catalogVariantId: string\n expectedQty: number\n receivedQty: number\n lotNumber?: string | null\n serialNumbers?: string[] | null\n targetStagingLocationId?: string | null\n variantLabel?: string | null\n updatedAt?: string | null\n asnUpdatedAt?: string | null\n}\n\nexport function parseReceiveSerialNumbers(value: string): string[] {\n return value\n .split(/[\\n,]/)\n .map((part) => part.trim())\n .filter((part) => part.length > 0)\n}\n\ntype ReceiveAsnLineDialogProps = {\n open: boolean\n onOpenChange: (open: boolean) => void\n access: WmsInventoryMutationAccess\n asnId: string\n warehouseId: string\n line: ReceiveAsnLineTarget | null\n onSuccess?: (result?: { receivedQty: number; asnUpdatedAt?: string | null }) => void\n}\n\nconst formSchema = z\n .object({\n receivedQty: z.number().positive('wms.backend.asns.receive.errors.quantityPositive'),\n qcStatus: z.enum(['passed', 'failed']),\n targetStagingLocationId: z.string().uuid().optional(),\n lotNumber: z.string().trim().max(120).optional(),\n rejectionReason: z.string().trim().max(500).optional(),\n })\n .superRefine((value, ctx) => {\n if (value.qcStatus === 'passed' && !value.targetStagingLocationId) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: ['targetStagingLocationId'],\n message: 'wms.backend.asns.receive.errors.stagingRequired',\n })\n }\n if (value.qcStatus === 'failed' && !value.rejectionReason) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: ['rejectionReason'],\n message: 'wms.backend.asns.receive.errors.rejectionRequired',\n })\n }\n })\n\nexport function ReceiveAsnLineDialog({\n open,\n onOpenChange,\n access,\n asnId,\n warehouseId,\n line,\n onSuccess,\n}: ReceiveAsnLineDialogProps) {\n const t = useT()\n const queryClient = useQueryClient()\n const { runMutation, retryLastMutation } = useGuardedMutation<{\n retryLastMutation: () => Promise<boolean>\n }>({\n contextId: 'wms-asn-receive-line',\n })\n const mutationContext = React.useMemo(\n () => ({ retryLastMutation }),\n [retryLastMutation],\n )\n\n const remaining = Math.max(0, (line?.expectedQty ?? 0) - (line?.receivedQty ?? 0))\n const [baselineReceivedQty, setBaselineReceivedQty] = React.useState(line?.receivedQty ?? 0)\n const [receivedQty, setReceivedQty] = React.useState(1)\n const [qcStatus, setQcStatus] = React.useState<'passed' | 'failed'>('passed')\n const [targetStagingLocationId, setTargetStagingLocationId] = React.useState('')\n const [lotNumber, setLotNumber] = React.useState('')\n const [serialNumbersText, setSerialNumbersText] = React.useState('')\n const [rejectionReason, setRejectionReason] = React.useState('')\n const [fieldErrors, setFieldErrors] = React.useState<Record<string, string>>({})\n const [submitting, setSubmitting] = React.useState(false)\n const [lockUpdatedAt, setLockUpdatedAt] = React.useState<string | null>(null)\n /** Fresh If-Match after 409; ref so guarded retry reads latest without re-seed. */\n const lockUpdatedAtRef = React.useRef<string | null>(null)\n\n const closeDialog = React.useCallback(() => {\n if (submitting) return\n onOpenChange(false)\n }, [onOpenChange, submitting])\n\n React.useEffect(() => {\n if (!open || !line) return\n setBaselineReceivedQty(line.receivedQty)\n setReceivedQty(remaining > 0 ? remaining : 1)\n setQcStatus('passed')\n setTargetStagingLocationId(line.targetStagingLocationId?.trim() || '')\n setLotNumber(line.lotNumber?.trim() || '')\n const storedSerials = (line.serialNumbers ?? []).map((value) => value.trim()).filter(Boolean)\n // After a partial receipt the field is this batch only. Planned serials still\n // prefill the first receipt, when nothing has been received yet.\n setSerialNumbersText(line.receivedQty > 0 ? '' : storedSerials.join('\\n'))\n setRejectionReason('')\n setFieldErrors({})\n setSubmitting(false)\n const seed = line.asnUpdatedAt ?? line.updatedAt ?? null\n lockUpdatedAtRef.current = seed\n setLockUpdatedAt(seed)\n }, [line, open, remaining])\n\n const handleSubmit = React.useCallback(\n async (event?: React.FormEvent) => {\n event?.preventDefault()\n if (!line) return\n if (!access.scopeReady || !access.organizationId || !access.tenantId || !access.userId) {\n flash(\n t(\n 'wms.backend.inventory.mutations.errors.scope',\n 'Select an organization and sign in before posting inventory changes.',\n ),\n 'error',\n )\n return\n }\n\n const parsed = formSchema.safeParse({\n receivedQty,\n qcStatus,\n targetStagingLocationId: targetStagingLocationId.trim() || undefined,\n lotNumber: lotNumber.trim() || undefined,\n rejectionReason: rejectionReason.trim() || undefined,\n })\n const serialNumbers = parseReceiveSerialNumbers(serialNumbersText)\n const alreadyReceivedSerials =\n line.receivedQty > 0\n ? (line.serialNumbers ?? []).map((value) => value.trim()).filter(Boolean)\n : []\n const nextErrors: Record<string, string> = {}\n if (!parsed.success) {\n for (const issue of parsed.error.issues) {\n const key = String(issue.path[0] ?? 'form')\n if (!nextErrors[key]) {\n nextErrors[key] = t(issue.message, issue.message)\n }\n }\n }\n if (serialNumbers.length > 0) {\n const uniqueSerials = new Set(serialNumbers)\n const knownSerials = new Set(alreadyReceivedSerials)\n if (uniqueSerials.size !== serialNumbers.length || serialNumbers.some((value) => knownSerials.has(value))) {\n nextErrors.serialNumbers = t(\n 'wms.backend.asns.receive.errors.duplicateSerial',\n 'Duplicate serial numbers are not allowed.',\n )\n } else if (parsed.success && serialNumbers.length !== parsed.data.receivedQty) {\n nextErrors.serialNumbers = t(\n 'wms.backend.asns.receive.errors.serialCount',\n 'Serial count must match the received quantity.',\n )\n }\n }\n if (!parsed.success || nextErrors.serialNumbers) {\n setFieldErrors(nextErrors)\n return\n }\n\n setSubmitting(true)\n setFieldErrors({})\n const targetReceivedQty = baselineReceivedQty + parsed.data.receivedQty\n const payload: Record<string, unknown> = {\n organizationId: access.organizationId,\n tenantId: access.tenantId,\n lineId: line.lineId,\n receivedQty: parsed.data.receivedQty,\n targetReceivedQty,\n qcStatus: parsed.data.qcStatus,\n performedBy: access.userId,\n }\n if (parsed.data.targetStagingLocationId) {\n payload.targetStagingLocationId = parsed.data.targetStagingLocationId\n }\n if (parsed.data.lotNumber) payload.lotNumber = parsed.data.lotNumber\n if (serialNumbers.length > 0) payload.serialNumbers = serialNumbers\n if (parsed.data.rejectionReason) payload.rejectionReason = parsed.data.rejectionReason\n\n try {\n let conflictHandled = false\n let mutationResult: {\n ok?: boolean\n receivedQty?: number\n asnUpdatedAt?: string\n } = {}\n await runMutation({\n operation: async () => {\n const call = await apiCall<{\n ok?: boolean\n receivedQty?: number\n asnUpdatedAt?: string\n }>(\n `/api/wms/asns/${encodeURIComponent(asnId)}/receive`,\n {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n ...buildOptimisticLockHeader(lockUpdatedAtRef.current ?? undefined),\n },\n body: JSON.stringify(payload),\n },\n )\n if (!call.ok) {\n const refreshLockFromConflict = () => {\n const conflict = extractOptimisticLockConflict({\n status: call.status,\n body: call.result,\n })\n if (conflict?.currentUpdatedAt) {\n lockUpdatedAtRef.current = conflict.currentUpdatedAt\n setLockUpdatedAt(conflict.currentUpdatedAt)\n }\n void queryClient.invalidateQueries({ queryKey: ['wms-asn-detail'] })\n void queryClient.invalidateQueries({ queryKey: ['wms-asns-list'] })\n }\n if (\n surfaceRecordConflict({ status: call.status, body: call.result }, t, {\n onRefresh: refreshLockFromConflict,\n })\n ) {\n refreshLockFromConflict()\n conflictHandled = true\n return {}\n }\n await raiseCrudError(\n call.response,\n t('wms.backend.asns.receive.errors.submit', 'Failed to receive ASN line.'),\n )\n }\n mutationResult = call.result ?? {}\n return mutationResult\n },\n context: mutationContext,\n mutationPayload: payload,\n })\n\n if (conflictHandled) return\n\n const nextReceivedQty =\n typeof mutationResult.receivedQty === 'number'\n ? mutationResult.receivedQty\n : targetReceivedQty\n setBaselineReceivedQty(nextReceivedQty)\n if (mutationResult.asnUpdatedAt) {\n setLockUpdatedAt(mutationResult.asnUpdatedAt)\n }\n\n flash(\n parsed.data.qcStatus === 'failed'\n ? t('wms.backend.asns.receive.flash.qcFailed', 'ASN line recorded with QC fail')\n : t('wms.backend.asns.receive.flash.success', 'ASN line received'),\n 'success',\n )\n await Promise.all([\n queryClient.invalidateQueries({ queryKey: ['wms-asn-detail'] }),\n queryClient.invalidateQueries({ queryKey: ['wms-asns-list'] }),\n queryClient.invalidateQueries({ queryKey: ['wms-putaway-queue'] }),\n ])\n closeDialog()\n onSuccess?.({\n receivedQty: nextReceivedQty,\n asnUpdatedAt: mutationResult.asnUpdatedAt ?? lockUpdatedAt,\n })\n } catch (error) {\n flashMutationError(\n error,\n t('wms.backend.asns.receive.errors.submit', 'Failed to receive ASN line.'),\n t,\n )\n } finally {\n setSubmitting(false)\n }\n },\n [\n access,\n asnId,\n baselineReceivedQty,\n closeDialog,\n line,\n lockUpdatedAt,\n lotNumber,\n mutationContext,\n onSuccess,\n qcStatus,\n queryClient,\n receivedQty,\n rejectionReason,\n runMutation,\n serialNumbersText,\n t,\n targetStagingLocationId,\n ],\n )\n\n const handleKeyDown = React.useCallback(\n (event: React.KeyboardEvent<HTMLDivElement>) => {\n if (event.key === 'Escape') {\n event.preventDefault()\n closeDialog()\n return\n }\n if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {\n event.preventDefault()\n if (!submitting) void handleSubmit()\n }\n },\n [closeDialog, handleSubmit, submitting],\n )\n\n if (!line) return null\n\n return (\n <Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : closeDialog())}>\n <DialogContent className=\"max-w-lg gap-0 overflow-hidden p-0\" onKeyDown={handleKeyDown}>\n <div className=\"border-b px-6 py-4 pr-12\">\n <DialogHeader className=\"space-y-1 text-left\">\n <DialogTitle>{t('wms.backend.asns.receive.dialog.title', 'Receive ASN line')}</DialogTitle>\n <DialogDescription>\n {t(\n 'wms.backend.asns.receive.dialog.description',\n 'Record accepted or rejected quantity against the expected ASN line.',\n )}\n </DialogDescription>\n </DialogHeader>\n </div>\n\n <form onSubmit={handleSubmit} className=\"flex min-h-0 flex-1 flex-col\">\n <div className=\"flex flex-col gap-5 overflow-y-auto px-6 py-6\">\n <div className=\"rounded-md border bg-muted/30 px-3 py-2 text-sm\">\n <div className=\"font-medium\">\n {line.variantLabel?.trim() || line.catalogVariantId}\n </div>\n <div className=\"text-muted-foreground\">\n {t('wms.backend.asns.receive.summary.expected', 'Expected')}: {line.expectedQty}\n {' \u00B7 '}\n {t('wms.backend.asns.receive.summary.received', 'Received')}: {baselineReceivedQty}\n </div>\n {baselineReceivedQty > 0 && (line.serialNumbers ?? []).some((value) => value.trim()) ? (\n <div className=\"text-muted-foreground\" data-testid=\"receive-asn-known-serials\">\n {t('wms.backend.asns.receive.summary.alreadyReceivedSerials', 'Already received')}:{' '}\n {(line.serialNumbers ?? [])\n .map((value) => value.trim())\n .filter(Boolean)\n .join(', ')}\n </div>\n ) : null}\n </div>\n\n <FormField\n label={t('wms.backend.asns.receive.form.receivedQty', 'Received quantity')}\n required\n error={fieldErrors.receivedQty}\n >\n <Input\n type=\"number\"\n min={0.0001}\n step=\"any\"\n value={receivedQty}\n onChange={(event) => setReceivedQty(Number(event.target.value))}\n disabled={submitting}\n data-testid=\"receive-asn-qty\"\n />\n </FormField>\n\n <FormField\n label={t('wms.backend.asns.receive.form.qcStatus', 'QC outcome')}\n required\n error={fieldErrors.qcStatus}\n >\n <Select\n value={qcStatus}\n onValueChange={(value) => setQcStatus(value as 'passed' | 'failed')}\n disabled={submitting}\n >\n <SelectTrigger data-testid=\"receive-asn-qc\">\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n <SelectItem value=\"passed\">\n {t('wms.backend.asns.qc.passed', 'Passed')}\n </SelectItem>\n <SelectItem value=\"failed\">\n {t('wms.backend.asns.qc.failed', 'Failed')}\n </SelectItem>\n </SelectContent>\n </Select>\n </FormField>\n\n {qcStatus === 'passed' ? (\n <FormField\n label={t('wms.backend.asns.receive.form.staging', 'Staging location')}\n required\n error={fieldErrors.targetStagingLocationId}\n >\n <ComboboxInput\n value={targetStagingLocationId}\n onChange={setTargetStagingLocationId}\n loadSuggestions={async (query) =>\n warehouseId ? loadStagingLocationOptions(warehouseId, query) : []\n }\n resolveLabel={async (value) => (await resolveLocationLabel(value)) ?? value}\n placeholder={t(\n 'wms.backend.asns.receive.form.stagingPlaceholder',\n 'Select staging or dock',\n )}\n allowCustomValues={false}\n disabled={submitting || !warehouseId}\n />\n </FormField>\n ) : (\n <FormField\n label={t('wms.backend.asns.receive.form.rejectionReason', 'Rejection reason')}\n required\n error={fieldErrors.rejectionReason}\n >\n <Textarea\n value={rejectionReason}\n onChange={(event) => setRejectionReason(event.target.value)}\n disabled={submitting}\n rows={3}\n data-testid=\"receive-asn-rejection\"\n />\n </FormField>\n )}\n\n <FormField\n label={t('wms.backend.asns.receive.form.serialNumbers', 'Serial numbers')}\n error={fieldErrors.serialNumbers}\n description={t(\n 'wms.backend.asns.receive.form.serialNumbersHint',\n 'This batch only, one per line. Do not repeat serials already received. The count must match this quantity.',\n )}\n >\n <Textarea\n value={serialNumbersText}\n onChange={(event) => setSerialNumbersText(event.target.value)}\n disabled={submitting}\n rows={3}\n placeholder={t(\n 'wms.backend.asns.receive.form.serialNumbersPlaceholder',\n 'Enter one serial number per line',\n )}\n data-testid=\"receive-asn-serials\"\n />\n </FormField>\n\n <FormField\n label={t('wms.backend.asns.receive.form.lotNumber', 'Lot number')}\n error={fieldErrors.lotNumber}\n >\n <Input\n value={lotNumber}\n onChange={(event) => setLotNumber(event.target.value)}\n disabled={submitting}\n />\n </FormField>\n </div>\n\n <DialogFooter bordered={false} className=\"border-t px-6 py-4 sm:justify-between\">\n <span className=\"hidden text-xs text-muted-foreground sm:inline\">\n <KbdShortcut keys={['Ctrl/\u2318', 'Enter']} />{' '}\n {t('wms.backend.asns.receive.dialog.shortcutHint', 'to receive')}\n </span>\n <div className=\"flex gap-2\">\n <Button type=\"button\" variant=\"outline\" onClick={closeDialog} disabled={submitting}>\n {t('wms.backend.asns.receive.dialog.cancel', 'Cancel')}\n </Button>\n <Button\n type=\"submit\"\n disabled={submitting || access.loading || !access.scopeReady}\n data-testid=\"receive-asn-submit\"\n >\n {t('wms.backend.asns.receive.dialog.submit', 'Receive line')}\n </Button>\n </div>\n </DialogFooter>\n </form>\n </DialogContent>\n </Dialog>\n )\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.10.1-develop.
|
|
3
|
+
"version": "0.10.1-develop.7990.1.82d0a183c5",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -266,16 +266,16 @@
|
|
|
266
266
|
"zod": "^4.6.6"
|
|
267
267
|
},
|
|
268
268
|
"peerDependencies": {
|
|
269
|
-
"@open-mercato/ai-assistant": "0.10.1-develop.
|
|
270
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
271
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
269
|
+
"@open-mercato/ai-assistant": "0.10.1-develop.7990.1.82d0a183c5",
|
|
270
|
+
"@open-mercato/shared": "0.10.1-develop.7990.1.82d0a183c5",
|
|
271
|
+
"@open-mercato/ui": "0.10.1-develop.7990.1.82d0a183c5",
|
|
272
272
|
"react": "^19.0.0",
|
|
273
273
|
"react-dom": "^19.0.0"
|
|
274
274
|
},
|
|
275
275
|
"devDependencies": {
|
|
276
|
-
"@open-mercato/ai-assistant": "0.10.1-develop.
|
|
277
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
278
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
276
|
+
"@open-mercato/ai-assistant": "0.10.1-develop.7990.1.82d0a183c5",
|
|
277
|
+
"@open-mercato/shared": "0.10.1-develop.7990.1.82d0a183c5",
|
|
278
|
+
"@open-mercato/ui": "0.10.1-develop.7990.1.82d0a183c5",
|
|
279
279
|
"@testing-library/dom": "^10.4.1",
|
|
280
280
|
"@testing-library/jest-dom": "^7.0.1",
|
|
281
281
|
"@testing-library/react": "^16.3.3",
|
|
@@ -339,32 +339,35 @@ const createUserCommand: CommandHandler<Record<string, unknown>, CreateUserResul
|
|
|
339
339
|
}
|
|
340
340
|
|
|
341
341
|
const de = (ctx.container.resolve('dataEngine') as DataEngine)
|
|
342
|
-
let user
|
|
343
|
-
try {
|
|
344
|
-
user = await de.createOrmEntity({
|
|
345
|
-
entity: User,
|
|
346
|
-
data: {
|
|
347
|
-
email: parsed.email,
|
|
348
|
-
name: parsed.name,
|
|
349
|
-
emailHash,
|
|
350
|
-
passwordHash,
|
|
351
|
-
isConfirmed: true,
|
|
352
|
-
organizationId: parsed.organizationId,
|
|
353
|
-
tenantId,
|
|
354
|
-
},
|
|
355
|
-
})
|
|
356
|
-
} catch (error) {
|
|
357
|
-
if (isUniqueViolation(error)) await throwDuplicateEmailError()
|
|
358
|
-
throw error
|
|
359
|
-
}
|
|
360
|
-
|
|
342
|
+
let user!: User
|
|
361
343
|
let assignedRoles: string[] = []
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
344
|
+
await withAtomicFlush(em, [
|
|
345
|
+
async () => {
|
|
346
|
+
try {
|
|
347
|
+
user = await de.createOrmEntity({
|
|
348
|
+
entity: User,
|
|
349
|
+
data: {
|
|
350
|
+
email: parsed.email,
|
|
351
|
+
name: parsed.name,
|
|
352
|
+
emailHash,
|
|
353
|
+
passwordHash,
|
|
354
|
+
isConfirmed: true,
|
|
355
|
+
organizationId: parsed.organizationId,
|
|
356
|
+
tenantId,
|
|
357
|
+
},
|
|
358
|
+
})
|
|
359
|
+
} catch (error) {
|
|
360
|
+
if (isUniqueViolation(error)) await throwDuplicateEmailError()
|
|
361
|
+
throw error
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
async () => {
|
|
365
|
+
if (Array.isArray(parsed.roles) && parsed.roles.length) {
|
|
366
|
+
await syncUserRoles(em, user, parsed.roles, tenantId)
|
|
367
|
+
assignedRoles = await loadUserRoleNames(em, String(user.id))
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
], { transaction: true, label: 'auth.users.create' })
|
|
368
371
|
|
|
369
372
|
await setCustomFieldsIfAny({
|
|
370
373
|
dataEngine: de,
|
|
@@ -12,7 +12,7 @@ import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
|
|
|
12
12
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
13
13
|
import { LockMode } from '@mikro-orm/core'
|
|
14
14
|
import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
|
|
15
|
-
import { CrudHttpError, notFound } from '@open-mercato/shared/lib/crud/errors'
|
|
15
|
+
import { CrudHttpError, forbidden, notFound } from '@open-mercato/shared/lib/crud/errors'
|
|
16
16
|
import {
|
|
17
17
|
CustomerAddress,
|
|
18
18
|
CustomerComment,
|
|
@@ -67,6 +67,7 @@ import { E } from '#generated/entities.ids.generated'
|
|
|
67
67
|
import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
68
68
|
import { CUSTOMER_ENTITY_ID, resolveCompanyCustomFieldRouting } from '../lib/customFieldRouting'
|
|
69
69
|
import { CustomFieldValue } from '@open-mercato/core/modules/entities/data/entities'
|
|
70
|
+
import { Organization } from '@open-mercato/core/modules/directory/data/entities'
|
|
70
71
|
import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
|
|
71
72
|
import { resolveRedoSnapshot } from '@open-mercato/shared/lib/commands/redo'
|
|
72
73
|
import { reviveSnapshotDates } from '@open-mercato/shared/lib/commands/undo'
|
|
@@ -510,6 +511,15 @@ const createCompanyCommand: CommandHandler<CompanyCreateInput, { entityId: strin
|
|
|
510
511
|
ensureOrganizationScope(ctx, parsed.organizationId)
|
|
511
512
|
|
|
512
513
|
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
514
|
+
const organization = await findOneWithDecryption(
|
|
515
|
+
em,
|
|
516
|
+
Organization,
|
|
517
|
+
{ id: parsed.organizationId, tenant: parsed.tenantId, deletedAt: null },
|
|
518
|
+
{ fields: ['id'] },
|
|
519
|
+
{ tenantId: parsed.tenantId, organizationId: parsed.organizationId },
|
|
520
|
+
)
|
|
521
|
+
if (!organization) throw forbidden()
|
|
522
|
+
|
|
513
523
|
const nextInteractionName = parsed.nextInteraction?.name ? parsed.nextInteraction.name.trim() : null
|
|
514
524
|
const nextInteractionRefId = normalizeOptionalString(parsed.nextInteraction?.refId)
|
|
515
525
|
const nextInteractionIcon = normalizeOptionalString(parsed.nextInteraction?.icon)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Company organization ownership (#8675)
|
|
2
|
+
|
|
3
|
+
Company creation now checks the final organization against the authorized tenant before creating either the customer entity or company profile. Missing, soft-deleted, and foreign organizations return the existing generic 403 denial. Organization ACL semantics, privileged tenant selection, system calls, and update/delete behavior remain unchanged. No schema, startup, or configuration changes are required.
|
|
4
|
+
|
|
5
|
+
Existing rows are not rewritten. An authorized operator can assess historical inconsistencies using a read-only connection in each tenant's approved maintenance context. Do not expose identifiers or results to tenant users or public issue comments. Bind `$1` to the authorized tenant ID through the SQL client and assess one tenant at a time. The following query returns identifiers only, including soft-deleted records, for investigation:
|
|
6
|
+
|
|
7
|
+
```sql
|
|
8
|
+
SELECT entity.id AS entity_id, profile.id AS profile_id,
|
|
9
|
+
entity.tenant_id AS entity_tenant_id,
|
|
10
|
+
entity.organization_id AS entity_organization_id,
|
|
11
|
+
profile.tenant_id AS profile_tenant_id,
|
|
12
|
+
profile.organization_id AS profile_organization_id
|
|
13
|
+
FROM customer_entities AS entity
|
|
14
|
+
LEFT JOIN customer_companies AS profile ON profile.entity_id = entity.id
|
|
15
|
+
LEFT JOIN organizations AS entity_org ON entity_org.id = entity.organization_id
|
|
16
|
+
LEFT JOIN organizations AS profile_org ON profile_org.id = profile.organization_id
|
|
17
|
+
WHERE entity.tenant_id = $1::uuid
|
|
18
|
+
AND entity.kind = 'company'
|
|
19
|
+
AND (entity_org.id IS NULL
|
|
20
|
+
OR entity_org.tenant_id IS DISTINCT FROM entity.tenant_id
|
|
21
|
+
OR profile.id IS NULL
|
|
22
|
+
OR profile_org.id IS NULL
|
|
23
|
+
OR profile_org.tenant_id IS DISTINCT FROM profile.tenant_id
|
|
24
|
+
OR profile.tenant_id IS DISTINCT FROM entity.tenant_id
|
|
25
|
+
OR profile.organization_id IS DISTINCT FROM entity.organization_id);
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Separately inspect profiles whose `entity_id` has no matching entity, and companies referencing a soft-deleted organization. Soft deletion after legitimate creation alone does not prove an ownership violation. Reconcile findings against trusted creation/audit history and the customer's intended tenant before planning any repair. Do not automatically reassign, delete, restore, or reindex suspect records. Any repair and downstream index/custom-field review requires a separately authorized maintenance plan, backup, and tenant-safe verification.
|
|
29
|
+
|
|
30
|
+
Regression coverage: registered command tests cover ownership, liveness, ACLs, hook-modified input, privileged selected tenant, system/worker compatibility and absence of writes/events/custom fields on denial. `TC-CRM-8675-company-organization-ownership.spec.ts` creates fresh tenants, organizations and callers, exercises the real POST endpoint, verifies both persisted scopes and rejection without company rows, successful audit operations or index rows, and checks existing restricted update/delete isolation.
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
type PromotionFormValues,
|
|
25
25
|
} from '../../../components/promotionFormFields'
|
|
26
26
|
import { RuleTreeBuilder } from '../../../components/RuleTreeBuilder'
|
|
27
|
+
import { promotionCalendarDate } from '../../../components/promotionDates'
|
|
27
28
|
|
|
28
29
|
type PromotionDetailResponse = {
|
|
29
30
|
id: string
|
|
@@ -122,8 +123,8 @@ export default function PromotionDetailPage({ params }: { params?: { id?: string
|
|
|
122
123
|
updatedAt: record.updatedAt,
|
|
123
124
|
name: record.name,
|
|
124
125
|
description: record.description,
|
|
125
|
-
startsAt: record.startsAt,
|
|
126
|
-
endsAt: record.endsAt,
|
|
126
|
+
startsAt: promotionCalendarDate(record.startsAt),
|
|
127
|
+
endsAt: promotionCalendarDate(record.endsAt),
|
|
127
128
|
active: record.active,
|
|
128
129
|
cumulative: record.cumulative,
|
|
129
130
|
tags: record.tags,
|
|
@@ -41,6 +41,7 @@ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuarde
|
|
|
41
41
|
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
42
42
|
import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
|
|
43
43
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
44
|
+
import { formatPromotionCalendarDate } from '../../components/promotionDates'
|
|
44
45
|
|
|
45
46
|
const PAGE_SIZE = 50
|
|
46
47
|
const RESOURCE_KIND = 'promotions.promotion'
|
|
@@ -80,12 +81,6 @@ function sameOrder(a: PromotionRow[], b: PromotionRow[]): boolean {
|
|
|
80
81
|
return a.every((row, index) => row.id === b[index]?.id)
|
|
81
82
|
}
|
|
82
83
|
|
|
83
|
-
function formatDate(value: string | null): string | null {
|
|
84
|
-
if (!value) return null
|
|
85
|
-
const date = new Date(value)
|
|
86
|
-
return Number.isNaN(date.getTime()) ? null : date.toLocaleDateString()
|
|
87
|
-
}
|
|
88
|
-
|
|
89
84
|
function isFilterValueActive(value: unknown): boolean {
|
|
90
85
|
if (value == null) return false
|
|
91
86
|
if (Array.isArray(value)) return value.length > 0
|
|
@@ -134,8 +129,8 @@ function PromotionCard({
|
|
|
134
129
|
transform: CSS.Transform.toString(transform),
|
|
135
130
|
transition,
|
|
136
131
|
}
|
|
137
|
-
const startsLabel =
|
|
138
|
-
const endsLabel =
|
|
132
|
+
const startsLabel = formatPromotionCalendarDate(row.startsAt)
|
|
133
|
+
const endsLabel = formatPromotionCalendarDate(row.endsAt)
|
|
139
134
|
const windowLabel = startsLabel || endsLabel
|
|
140
135
|
? [
|
|
141
136
|
startsLabel ? t('promotions.list.card.starts', 'Starts {date}', { date: startsLabel }) : null,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const PROMOTION_DATE_PATTERN = /^(\d{4}-\d{2}-\d{2})(?:T.*)?$/
|
|
2
|
+
|
|
3
|
+
export function promotionCalendarDate(value: string | null): string | null {
|
|
4
|
+
if (!value) return null
|
|
5
|
+
const dateOnly = PROMOTION_DATE_PATTERN.exec(value)?.[1]
|
|
6
|
+
if (!dateOnly) return null
|
|
7
|
+
const date = new Date(`${dateOnly}T00:00:00.000Z`)
|
|
8
|
+
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== dateOnly) return null
|
|
9
|
+
return dateOnly
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function formatPromotionCalendarDate(
|
|
13
|
+
value: string | null,
|
|
14
|
+
locales?: string | string[],
|
|
15
|
+
): string | null {
|
|
16
|
+
const dateOnly = promotionCalendarDate(value)
|
|
17
|
+
if (!dateOnly) return null
|
|
18
|
+
return new Date(`${dateOnly}T00:00:00.000Z`).toLocaleDateString(locales, { timeZone: 'UTC' })
|
|
19
|
+
}
|
|
@@ -84,7 +84,15 @@ const clearableUuidSchema = z.preprocess(emptyStringToNull, uuid().nullable().op
|
|
|
84
84
|
// this schema — rule thresholds, benefit discount values, cart/line amounts — is a
|
|
85
85
|
// non-negative magnitude; a resolved effect's own sign (a discount subtracts) is applied
|
|
86
86
|
// downstream in `lib/effect-resolvers.ts`, never carried in the input itself.
|
|
87
|
-
const
|
|
87
|
+
const normalizeDecimalSeparator = (value: unknown): unknown => {
|
|
88
|
+
if (typeof value !== 'string' || !/^\d+,\d+$/.test(value)) return value
|
|
89
|
+
return value.replace(',', '.')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const decimalStringSchema = z.preprocess(
|
|
93
|
+
normalizeDecimalSeparator,
|
|
94
|
+
z.string().regex(/^\d+(\.\d+)?$/, 'Must be a non-negative decimal string'),
|
|
95
|
+
)
|
|
88
96
|
|
|
89
97
|
const clearableDecimalStringSchema = z.preprocess(emptyStringToNull, decimalStringSchema.nullable().optional())
|
|
90
98
|
|
|
@@ -198,22 +198,22 @@
|
|
|
198
198
|
"promotions.fields.triggerQuantity": "Auslöser-Menge",
|
|
199
199
|
"promotions.fields.value": "Wert",
|
|
200
200
|
"promotions.fields.variantId": "Varianten-ID",
|
|
201
|
-
"promotions.form.errors.budgetCurrencyRequired": "
|
|
201
|
+
"promotions.form.errors.budgetCurrencyRequired": "Bei einem Höchstbudget ist eine Währung erforderlich",
|
|
202
202
|
"promotions.form.errors.endBeforeStart": "Das Enddatum muss am oder nach dem Startdatum liegen",
|
|
203
203
|
"promotions.form.errors.nameRequired": "Name ist erforderlich",
|
|
204
204
|
"promotions.form.fields.active": "Aktiv",
|
|
205
|
-
"promotions.form.fields.budgetCurrency": "
|
|
205
|
+
"promotions.form.fields.budgetCurrency": "Budgetwährung",
|
|
206
206
|
"promotions.form.fields.cumulative": "Kumulativ",
|
|
207
207
|
"promotions.form.fields.description": "Beschreibung",
|
|
208
|
-
"promotions.form.fields.eligibleCurrencies": "
|
|
209
|
-
"promotions.form.fields.eligibleCurrencies.description": "
|
|
208
|
+
"promotions.form.fields.eligibleCurrencies": "Zulässige Währungen",
|
|
209
|
+
"promotions.form.fields.eligibleCurrencies.description": "Leer lassen, um alle Währungen zuzulassen. Genau eine Währung ist erforderlich, sobald eine Regel oder ein Vorteil von einem festen Betrag abhängt (D-R4-27).",
|
|
210
210
|
"promotions.form.fields.endsAt": "Endet am",
|
|
211
211
|
"promotions.form.fields.excludePromotionalPrices": "Artikel mit bereits reduziertem Preis ausschließen",
|
|
212
212
|
"promotions.form.fields.excludePromotionalPrices.description": "Artikel überspringen, deren Einzelpreis bereits von einem Aktionspreis im Katalog stammt.",
|
|
213
213
|
"promotions.form.fields.excludedTags": "Ausgeschlossene Tags",
|
|
214
214
|
"promotions.form.fields.excludedTagsPlaceholder": "Ausgeschlossenen Tag hinzufügen...",
|
|
215
|
-
"promotions.form.fields.maxBudget": "
|
|
216
|
-
"promotions.form.fields.maxBudget.description": "
|
|
215
|
+
"promotions.form.fields.maxBudget": "Höchstbudget",
|
|
216
|
+
"promotions.form.fields.maxBudget.description": "Optionale Ausgabenobergrenze für diese Aktion. Beim Festlegen muss eine Budgetwährung ausgewählt werden.",
|
|
217
217
|
"promotions.form.fields.name": "Name",
|
|
218
218
|
"promotions.form.fields.startsAt": "Beginnt am",
|
|
219
219
|
"promotions.form.fields.tags": "Tags",
|
|
@@ -198,22 +198,22 @@
|
|
|
198
198
|
"promotions.fields.triggerQuantity": "Cantidad desencadenante",
|
|
199
199
|
"promotions.fields.value": "Valor",
|
|
200
200
|
"promotions.fields.variantId": "ID de variante",
|
|
201
|
-
"promotions.form.errors.budgetCurrencyRequired": "
|
|
201
|
+
"promotions.form.errors.budgetCurrencyRequired": "Se requiere una moneda al establecer un presupuesto máximo",
|
|
202
202
|
"promotions.form.errors.endBeforeStart": "La fecha de fin debe ser igual o posterior a la fecha de inicio",
|
|
203
203
|
"promotions.form.errors.nameRequired": "El nombre es obligatorio",
|
|
204
204
|
"promotions.form.fields.active": "Activa",
|
|
205
|
-
"promotions.form.fields.budgetCurrency": "
|
|
205
|
+
"promotions.form.fields.budgetCurrency": "Moneda del presupuesto",
|
|
206
206
|
"promotions.form.fields.cumulative": "Acumulable",
|
|
207
207
|
"promotions.form.fields.description": "Descripción",
|
|
208
|
-
"promotions.form.fields.eligibleCurrencies": "
|
|
209
|
-
"promotions.form.fields.eligibleCurrencies.description": "
|
|
208
|
+
"promotions.form.fields.eligibleCurrencies": "Monedas elegibles",
|
|
209
|
+
"promotions.form.fields.eligibleCurrencies.description": "Déjalo vacío para permitir todas las monedas. Se requiere exactamente una moneda cuando una regla o beneficio depende de un importe fijo (D-R4-27).",
|
|
210
210
|
"promotions.form.fields.endsAt": "Finaliza el",
|
|
211
211
|
"promotions.form.fields.excludePromotionalPrices": "Excluir artículos ya en precio promocional",
|
|
212
212
|
"promotions.form.fields.excludePromotionalPrices.description": "Omitir artículos cuyo precio unitario ya proviene de un precio promocional del catálogo.",
|
|
213
213
|
"promotions.form.fields.excludedTags": "Etiquetas excluidas",
|
|
214
214
|
"promotions.form.fields.excludedTagsPlaceholder": "Añadir etiqueta excluida...",
|
|
215
|
-
"promotions.form.fields.maxBudget": "
|
|
216
|
-
"promotions.form.fields.maxBudget.description": "
|
|
215
|
+
"promotions.form.fields.maxBudget": "Presupuesto máximo",
|
|
216
|
+
"promotions.form.fields.maxBudget.description": "Límite de gasto opcional para esta promoción. Al establecerlo, se debe seleccionar una moneda del presupuesto.",
|
|
217
217
|
"promotions.form.fields.name": "Nombre",
|
|
218
218
|
"promotions.form.fields.startsAt": "Comienza el",
|
|
219
219
|
"promotions.form.fields.tags": "Etiquetas",
|
|
@@ -198,22 +198,22 @@
|
|
|
198
198
|
"promotions.fields.triggerQuantity": "트리거 수량",
|
|
199
199
|
"promotions.fields.value": "값",
|
|
200
200
|
"promotions.fields.variantId": "옵션 ID",
|
|
201
|
-
"promotions.form.errors.budgetCurrencyRequired": "
|
|
201
|
+
"promotions.form.errors.budgetCurrencyRequired": "최대 예산을 설정하려면 통화가 필요합니다",
|
|
202
202
|
"promotions.form.errors.endBeforeStart": "종료일은 시작일 이후여야 합니다",
|
|
203
203
|
"promotions.form.errors.nameRequired": "이름은 필수입니다",
|
|
204
204
|
"promotions.form.fields.active": "활성",
|
|
205
|
-
"promotions.form.fields.budgetCurrency": "
|
|
205
|
+
"promotions.form.fields.budgetCurrency": "예산 통화",
|
|
206
206
|
"promotions.form.fields.cumulative": "중복 적용",
|
|
207
207
|
"promotions.form.fields.description": "설명",
|
|
208
|
-
"promotions.form.fields.eligibleCurrencies": "
|
|
209
|
-
"promotions.form.fields.eligibleCurrencies.description": "
|
|
208
|
+
"promotions.form.fields.eligibleCurrencies": "사용 가능 통화",
|
|
209
|
+
"promotions.form.fields.eligibleCurrencies.description": "모든 통화를 허용하려면 비워 두세요. 규칙이나 혜택이 고정 금액에 따라 달라지는 경우 통화를 정확히 하나 선택해야 합니다(D-R4-27).",
|
|
210
210
|
"promotions.form.fields.endsAt": "종료일",
|
|
211
211
|
"promotions.form.fields.excludePromotionalPrices": "이미 프로모션 가격이 적용된 상품 제외",
|
|
212
212
|
"promotions.form.fields.excludePromotionalPrices.description": "단가가 이미 카탈로그 프로모션 가격에서 산출된 상품은 건너뜁니다.",
|
|
213
213
|
"promotions.form.fields.excludedTags": "제외 태그",
|
|
214
214
|
"promotions.form.fields.excludedTagsPlaceholder": "제외 태그 추가...",
|
|
215
|
-
"promotions.form.fields.maxBudget": "
|
|
216
|
-
"promotions.form.fields.maxBudget.description": "
|
|
215
|
+
"promotions.form.fields.maxBudget": "최대 예산",
|
|
216
|
+
"promotions.form.fields.maxBudget.description": "이 프로모션의 선택적 지출 한도입니다. 설정하려면 예산 통화를 선택해야 합니다.",
|
|
217
217
|
"promotions.form.fields.name": "이름",
|
|
218
218
|
"promotions.form.fields.startsAt": "시작일",
|
|
219
219
|
"promotions.form.fields.tags": "태그",
|
|
@@ -198,22 +198,22 @@
|
|
|
198
198
|
"promotions.fields.triggerQuantity": "Ilość wyzwalająca",
|
|
199
199
|
"promotions.fields.value": "Wartość",
|
|
200
200
|
"promotions.fields.variantId": "ID wariantu",
|
|
201
|
-
"promotions.form.errors.budgetCurrencyRequired": "
|
|
201
|
+
"promotions.form.errors.budgetCurrencyRequired": "Waluta jest wymagana po ustawieniu maksymalnego budżetu",
|
|
202
202
|
"promotions.form.errors.endBeforeStart": "Data zakończenia musi być równa lub późniejsza niż data początkowa",
|
|
203
203
|
"promotions.form.errors.nameRequired": "Nazwa jest wymagana",
|
|
204
204
|
"promotions.form.fields.active": "Aktywna",
|
|
205
|
-
"promotions.form.fields.budgetCurrency": "
|
|
205
|
+
"promotions.form.fields.budgetCurrency": "Waluta budżetu",
|
|
206
206
|
"promotions.form.fields.cumulative": "Łączy się z innymi",
|
|
207
207
|
"promotions.form.fields.description": "Opis",
|
|
208
|
-
"promotions.form.fields.eligibleCurrencies": "
|
|
209
|
-
"promotions.form.fields.eligibleCurrencies.description": "
|
|
208
|
+
"promotions.form.fields.eligibleCurrencies": "Dopuszczalne waluty",
|
|
209
|
+
"promotions.form.fields.eligibleCurrencies.description": "Pozostaw puste, aby zezwolić na wszystkie waluty. Dokładnie jedna waluta jest wymagana, gdy reguła lub korzyść zależy od stałej kwoty (D-R4-27).",
|
|
210
210
|
"promotions.form.fields.endsAt": "Kończy się",
|
|
211
211
|
"promotions.form.fields.excludePromotionalPrices": "Wyklucz produkty już w cenie promocyjnej",
|
|
212
212
|
"promotions.form.fields.excludePromotionalPrices.description": "Pomiń pozycje, których cena jednostkowa już pochodzi z promocyjnej ceny katalogowej.",
|
|
213
213
|
"promotions.form.fields.excludedTags": "Wykluczone tagi",
|
|
214
214
|
"promotions.form.fields.excludedTagsPlaceholder": "Dodaj wykluczony tag...",
|
|
215
|
-
"promotions.form.fields.maxBudget": "
|
|
216
|
-
"promotions.form.fields.maxBudget.description": "
|
|
215
|
+
"promotions.form.fields.maxBudget": "Maksymalny budżet",
|
|
216
|
+
"promotions.form.fields.maxBudget.description": "Opcjonalny limit wydatków dla tej promocji. Jego ustawienie wymaga wybrania waluty budżetu.",
|
|
217
217
|
"promotions.form.fields.name": "Nazwa",
|
|
218
218
|
"promotions.form.fields.startsAt": "Zaczyna się",
|
|
219
219
|
"promotions.form.fields.tags": "Tagi",
|
|
@@ -53,8 +53,8 @@ type CompletePutawayTaskDialogProps = {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
const formSchema = z.object({
|
|
56
|
-
confirmedQuantity: z.number().positive(),
|
|
57
|
-
targetLocationId: z.string().uuid(),
|
|
56
|
+
confirmedQuantity: z.number().positive('wms.backend.putaway.complete.errors.quantityPositive'),
|
|
57
|
+
targetLocationId: z.string().uuid('wms.backend.putaway.complete.errors.targetRequired'),
|
|
58
58
|
})
|
|
59
59
|
|
|
60
60
|
export function CompletePutawayTaskDialog({
|
|
@@ -47,19 +47,19 @@ type LineDraft = {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
const formSchema = z.object({
|
|
50
|
-
warehouseId: z.string().uuid(),
|
|
51
|
-
expectedAt: z.string().min(1),
|
|
50
|
+
warehouseId: z.string().uuid('wms.backend.asns.create.errors.warehouseRequired'),
|
|
51
|
+
expectedAt: z.string().min(1, 'wms.backend.asns.create.errors.expectedAtRequired'),
|
|
52
52
|
referenceNumber: z.string().trim().max(120).optional(),
|
|
53
53
|
notes: z.string().trim().max(2000).optional(),
|
|
54
54
|
lines: z
|
|
55
55
|
.array(
|
|
56
56
|
z.object({
|
|
57
|
-
catalogVariantId: z.string().uuid(),
|
|
58
|
-
expectedQty: z.number().positive(),
|
|
57
|
+
catalogVariantId: z.string().uuid('wms.backend.asns.create.errors.variantRequired'),
|
|
58
|
+
expectedQty: z.number().positive('wms.backend.asns.create.errors.quantityPositive'),
|
|
59
59
|
targetStagingLocationId: z.string().uuid().optional(),
|
|
60
60
|
}),
|
|
61
61
|
)
|
|
62
|
-
.min(1),
|
|
62
|
+
.min(1, 'wms.backend.asns.create.errors.linesRequired'),
|
|
63
63
|
})
|
|
64
64
|
|
|
65
65
|
const EMPTY_LINE: LineDraft = {
|
|
@@ -73,7 +73,7 @@ type ReceiveAsnLineDialogProps = {
|
|
|
73
73
|
|
|
74
74
|
const formSchema = z
|
|
75
75
|
.object({
|
|
76
|
-
receivedQty: z.number().positive(),
|
|
76
|
+
receivedQty: z.number().positive('wms.backend.asns.receive.errors.quantityPositive'),
|
|
77
77
|
qcStatus: z.enum(['passed', 'failed']),
|
|
78
78
|
targetStagingLocationId: z.string().uuid().optional(),
|
|
79
79
|
lotNumber: z.string().trim().max(120).optional(),
|