@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.
Files changed (40) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/commands/users.js +27 -23
  3. package/dist/modules/auth/commands/users.js.map +2 -2
  4. package/dist/modules/customers/commands/companies.js +10 -1
  5. package/dist/modules/customers/commands/companies.js.map +2 -2
  6. package/dist/modules/customers/docs/issue-8675-ownership-assessment.md +30 -0
  7. package/dist/modules/promotions/backend/promotions/[id]/page.js +3 -2
  8. package/dist/modules/promotions/backend/promotions/[id]/page.js.map +2 -2
  9. package/dist/modules/promotions/backend/promotions/page.js +3 -7
  10. package/dist/modules/promotions/backend/promotions/page.js.map +2 -2
  11. package/dist/modules/promotions/components/promotionDates.js +19 -0
  12. package/dist/modules/promotions/components/promotionDates.js.map +7 -0
  13. package/dist/modules/promotions/data/validators.js +8 -1
  14. package/dist/modules/promotions/data/validators.js.map +2 -2
  15. package/dist/modules/wms/components/backend/CompletePutawayTaskDialog.js +2 -2
  16. package/dist/modules/wms/components/backend/CompletePutawayTaskDialog.js.map +2 -2
  17. package/dist/modules/wms/components/backend/CreateAsnDialog.js +5 -5
  18. package/dist/modules/wms/components/backend/CreateAsnDialog.js.map +2 -2
  19. package/dist/modules/wms/components/backend/ReceiveAsnLineDialog.js +1 -1
  20. package/dist/modules/wms/components/backend/ReceiveAsnLineDialog.js.map +2 -2
  21. package/package.json +7 -7
  22. package/src/modules/auth/commands/users.ts +28 -25
  23. package/src/modules/customers/commands/companies.ts +11 -1
  24. package/src/modules/customers/docs/issue-8675-ownership-assessment.md +30 -0
  25. package/src/modules/promotions/backend/promotions/[id]/page.tsx +3 -2
  26. package/src/modules/promotions/backend/promotions/page.tsx +3 -8
  27. package/src/modules/promotions/components/promotionDates.ts +19 -0
  28. package/src/modules/promotions/data/validators.ts +9 -1
  29. package/src/modules/promotions/i18n/de.json +6 -6
  30. package/src/modules/promotions/i18n/es.json +6 -6
  31. package/src/modules/promotions/i18n/ko.json +6 -6
  32. package/src/modules/promotions/i18n/pl.json +6 -6
  33. package/src/modules/wms/components/backend/CompletePutawayTaskDialog.tsx +2 -2
  34. package/src/modules/wms/components/backend/CreateAsnDialog.tsx +5 -5
  35. package/src/modules/wms/components/backend/ReceiveAsnLineDialog.tsx +1 -1
  36. package/src/modules/wms/i18n/de.json +173 -165
  37. package/src/modules/wms/i18n/en.json +8 -0
  38. package/src/modules/wms/i18n/es.json +175 -167
  39. package/src/modules/wms/i18n/ko.json +175 -167
  40. 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": "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",
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.7982.1.df20d84e85",
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.7982.1.df20d84e85",
270
- "@open-mercato/shared": "0.10.1-develop.7982.1.df20d84e85",
271
- "@open-mercato/ui": "0.10.1-develop.7982.1.df20d84e85",
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.7982.1.df20d84e85",
277
- "@open-mercato/shared": "0.10.1-develop.7982.1.df20d84e85",
278
- "@open-mercato/ui": "0.10.1-develop.7982.1.df20d84e85",
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: 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
- if (Array.isArray(parsed.roles) && parsed.roles.length) {
363
- await withAtomicFlush(em, [
364
- async () => syncUserRoles(em, user, parsed.roles ?? [], tenantId),
365
- ], { transaction: true, label: 'auth.users.create.roles' })
366
- assignedRoles = await loadUserRoleNames(em, String(user.id))
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 = formatDate(row.startsAt)
138
- const endsLabel = formatDate(row.endsAt)
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 decimalStringSchema = z.string().regex(/^\d+(\.\d+)?$/, 'Must be a non-negative decimal string')
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": "Currency is required when a max budget is set",
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": "Budget currency",
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": "Eligible currencies",
209
- "promotions.form.fields.eligibleCurrencies.description": "Leave empty to allow every currency. Exactly one currency is required once a rule or benefit depends on a fixed amount (D-R4-27).",
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": "Max budget",
216
- "promotions.form.fields.maxBudget.description": "Optional spend cap for this promotion. Setting it requires a budget currency.",
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": "Currency is required when a max budget is set",
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": "Budget currency",
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": "Eligible currencies",
209
- "promotions.form.fields.eligibleCurrencies.description": "Leave empty to allow every currency. Exactly one currency is required once a rule or benefit depends on a fixed amount (D-R4-27).",
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": "Max budget",
216
- "promotions.form.fields.maxBudget.description": "Optional spend cap for this promotion. Setting it requires a budget currency.",
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": "Currency is required when a max budget is set",
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": "Budget currency",
205
+ "promotions.form.fields.budgetCurrency": "예산 통화",
206
206
  "promotions.form.fields.cumulative": "중복 적용",
207
207
  "promotions.form.fields.description": "설명",
208
- "promotions.form.fields.eligibleCurrencies": "Eligible currencies",
209
- "promotions.form.fields.eligibleCurrencies.description": "Leave empty to allow every currency. Exactly one currency is required once a rule or benefit depends on a fixed amount (D-R4-27).",
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": "Max budget",
216
- "promotions.form.fields.maxBudget.description": "Optional spend cap for this promotion. Setting it requires a budget currency.",
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": "Currency is required when a max budget is set",
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": "Budget currency",
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": "Eligible currencies",
209
- "promotions.form.fields.eligibleCurrencies.description": "Leave empty to allow every currency. Exactly one currency is required once a rule or benefit depends on a fixed amount (D-R4-27).",
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": "Max budget",
216
- "promotions.form.fields.maxBudget.description": "Optional spend cap for this promotion. Setting it requires a budget currency.",
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(),