@open-mercato/core 0.6.8-develop.6998.1.d2bc46c56c → 0.6.8-develop.7001.1.03608ab252
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 +2 -2
- package/dist/generated/entities/warranty_claim/index.js +105 -0
- package/dist/generated/entities/warranty_claim/index.js.map +7 -0
- package/dist/generated/entities/warranty_claim_event/index.js +25 -0
- package/dist/generated/entities/warranty_claim_event/index.js.map +7 -0
- package/dist/generated/entities/warranty_claim_line/index.js +77 -0
- package/dist/generated/entities/warranty_claim_line/index.js.map +7 -0
- package/dist/generated/entities/warranty_claim_registration/index.js +43 -0
- package/dist/generated/entities/warranty_claim_registration/index.js.map +7 -0
- package/dist/generated/entities/warranty_claim_sequence/index.js +17 -0
- package/dist/generated/entities/warranty_claim_sequence/index.js.map +7 -0
- package/dist/generated/entities/warranty_claim_settings/index.js +43 -0
- package/dist/generated/entities/warranty_claim_settings/index.js.map +7 -0
- package/dist/generated/entities/warranty_claim_sla_signal/index.js +25 -0
- package/dist/generated/entities/warranty_claim_sla_signal/index.js.map +7 -0
- package/dist/generated/entities/warranty_troubleshooting_guide/index.js +25 -0
- package/dist/generated/entities/warranty_troubleshooting_guide/index.js.map +7 -0
- package/dist/generated/entities/warranty_vendor_policy/index.js +31 -0
- package/dist/generated/entities/warranty_vendor_policy/index.js.map +7 -0
- package/dist/generated/entities.ids.generated.js +12 -0
- package/dist/generated/entities.ids.generated.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +200 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/helpers/integration/api.js +3 -2
- package/dist/helpers/integration/api.js.map +2 -2
- package/dist/helpers/integration/salesFixtures.js +13 -6
- package/dist/helpers/integration/salesFixtures.js.map +2 -2
- package/dist/modules/attachments/di.js +18 -0
- package/dist/modules/attachments/di.js.map +2 -2
- package/dist/modules/attachments/fields/attachment.js +186 -26
- package/dist/modules/attachments/fields/attachment.js.map +2 -2
- package/dist/modules/attachments/lib/scoped-upload-service.js +202 -0
- package/dist/modules/attachments/lib/scoped-upload-service.js.map +7 -0
- package/dist/modules/attachments/lib/target-access-service.js +39 -0
- package/dist/modules/attachments/lib/target-access-service.js.map +7 -0
- package/dist/modules/auth/api/logout.js +37 -10
- package/dist/modules/auth/api/logout.js.map +2 -2
- package/dist/modules/auth/api/reset/confirm.js +7 -0
- package/dist/modules/auth/api/reset/confirm.js.map +2 -2
- package/dist/modules/auth/api/reset.js +7 -0
- package/dist/modules/auth/api/reset.js.map +2 -2
- package/dist/modules/auth/commands/users.js +13 -0
- package/dist/modules/auth/commands/users.js.map +2 -2
- package/dist/modules/auth/events.js +15 -0
- package/dist/modules/auth/events.js.map +2 -2
- package/dist/modules/customers/api/companies/route.js +1 -0
- package/dist/modules/customers/api/companies/route.js.map +2 -2
- package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js +2 -2
- package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +2 -2
- package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
- package/dist/modules/customers/components/detail/assignableStaff.js +6 -69
- package/dist/modules/customers/components/detail/assignableStaff.js.map +2 -2
- package/dist/modules/customers/lib/assignableStaff.js +76 -0
- package/dist/modules/customers/lib/assignableStaff.js.map +7 -0
- package/dist/modules/data_sync/lib/batch-stream.js +56 -0
- package/dist/modules/data_sync/lib/batch-stream.js.map +7 -0
- package/dist/modules/data_sync/lib/sync-engine.js +165 -103
- package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
- package/dist/modules/inbox_ops/api/emails/[id]/reprocess/route.js +4 -0
- package/dist/modules/inbox_ops/api/emails/[id]/reprocess/route.js.map +2 -2
- package/dist/modules/inbox_ops/api/extract/route.js +4 -0
- package/dist/modules/inbox_ops/api/extract/route.js.map +2 -2
- package/dist/modules/inbox_ops/api/webhook/inbound.js +4 -0
- package/dist/modules/inbox_ops/api/webhook/inbound.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +8 -8
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/warranty_claims/acl.js +75 -0
- package/dist/modules/warranty_claims/acl.js.map +7 -0
- package/dist/modules/warranty_claims/ai-agents.js +161 -0
- package/dist/modules/warranty_claims/ai-agents.js.map +7 -0
- package/dist/modules/warranty_claims/ai-tools.js +591 -0
- package/dist/modules/warranty_claims/ai-tools.js.map +7 -0
- package/dist/modules/warranty_claims/api/ai/assess/route.js +304 -0
- package/dist/modules/warranty_claims/api/ai/assess/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/ai/draft-reply/route.js +127 -0
- package/dist/modules/warranty_claims/api/ai/draft-reply/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/ai/suggest/route.js +177 -0
- package/dist/modules/warranty_claims/api/ai/suggest/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/assign/route.js +124 -0
- package/dist/modules/warranty_claims/api/assign/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/assignees/route.js +97 -0
- package/dist/modules/warranty_claims/api/assignees/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/credit-memo/route.js +152 -0
- package/dist/modules/warranty_claims/api/credit-memo/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/entitlement/route.js +145 -0
- package/dist/modules/warranty_claims/api/entitlement/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/events/route.js +199 -0
- package/dist/modules/warranty_claims/api/events/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/external/claims/route.js +446 -0
- package/dist/modules/warranty_claims/api/external/claims/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/lines/route.js +258 -0
- package/dist/modules/warranty_claims/api/lines/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/openapi.js +27 -0
- package/dist/modules/warranty_claims/api/openapi.js.map +7 -0
- package/dist/modules/warranty_claims/api/portal/attachments/route.js +490 -0
- package/dist/modules/warranty_claims/api/portal/attachments/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/portal/claims/[id]/route.js +178 -0
- package/dist/modules/warranty_claims/api/portal/claims/[id]/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/portal/claims/[id]/shared.js +101 -0
- package/dist/modules/warranty_claims/api/portal/claims/[id]/shared.js.map +7 -0
- package/dist/modules/warranty_claims/api/portal/claims/[id]/submit/route.js +78 -0
- package/dist/modules/warranty_claims/api/portal/claims/[id]/submit/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/portal/claims/[id]/withdraw/route.js +81 -0
- package/dist/modules/warranty_claims/api/portal/claims/[id]/withdraw/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/portal/claims/route.js +462 -0
- package/dist/modules/warranty_claims/api/portal/claims/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/portal/events/route.js +228 -0
- package/dist/modules/warranty_claims/api/portal/events/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/portal/options/route.js +91 -0
- package/dist/modules/warranty_claims/api/portal/options/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/portal/orders/lines/route.js +172 -0
- package/dist/modules/warranty_claims/api/portal/orders/lines/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/portal/orders/route.js +151 -0
- package/dist/modules/warranty_claims/api/portal/orders/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/portal/troubleshooting/route.js +121 -0
- package/dist/modules/warranty_claims/api/portal/troubleshooting/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/receiving/route.js +176 -0
- package/dist/modules/warranty_claims/api/receiving/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/registrations/route.js +382 -0
- package/dist/modules/warranty_claims/api/registrations/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/replacement-order/route.js +150 -0
- package/dist/modules/warranty_claims/api/replacement-order/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/return-label/route.js +235 -0
- package/dist/modules/warranty_claims/api/return-label/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/risk/route.js +101 -0
- package/dist/modules/warranty_claims/api/risk/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/route.js +482 -0
- package/dist/modules/warranty_claims/api/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/sales-return/route.js +151 -0
- package/dist/modules/warranty_claims/api/sales-return/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/settings-general/route.js +195 -0
- package/dist/modules/warranty_claims/api/settings-general/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/stats/route.js +199 -0
- package/dist/modules/warranty_claims/api/stats/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/submit/route.js +120 -0
- package/dist/modules/warranty_claims/api/submit/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/transition/route.js +126 -0
- package/dist/modules/warranty_claims/api/transition/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/troubleshooting-guides/route.js +261 -0
- package/dist/modules/warranty_claims/api/troubleshooting-guides/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/vendor-policies/route.js +311 -0
- package/dist/modules/warranty_claims/api/vendor-policies/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/vendor-recovery/route.js +130 -0
- package/dist/modules/warranty_claims/api/vendor-recovery/route.js.map +7 -0
- package/dist/modules/warranty_claims/api/vendor-recovery-suggestions/route.js +153 -0
- package/dist/modules/warranty_claims/api/vendor-recovery-suggestions/route.js.map +7 -0
- package/dist/modules/warranty_claims/backend/components/AiAssessButtons.js +268 -0
- package/dist/modules/warranty_claims/backend/components/AiAssessButtons.js.map +7 -0
- package/dist/modules/warranty_claims/backend/components/ClaimStageProgress.js +54 -0
- package/dist/modules/warranty_claims/backend/components/ClaimStageProgress.js.map +7 -0
- package/dist/modules/warranty_claims/backend/components/ClaimStatusBadge.js +87 -0
- package/dist/modules/warranty_claims/backend/components/ClaimStatusBadge.js.map +7 -0
- package/dist/modules/warranty_claims/backend/components/ClaimsKpiStrip.js +120 -0
- package/dist/modules/warranty_claims/backend/components/ClaimsKpiStrip.js.map +7 -0
- package/dist/modules/warranty_claims/backend/components/EntitlementLookupBadge.js +135 -0
- package/dist/modules/warranty_claims/backend/components/EntitlementLookupBadge.js.map +7 -0
- package/dist/modules/warranty_claims/backend/components/ReceivingPanel.js +218 -0
- package/dist/modules/warranty_claims/backend/components/ReceivingPanel.js.map +7 -0
- package/dist/modules/warranty_claims/backend/components/ReturnLabelPanel.js +231 -0
- package/dist/modules/warranty_claims/backend/components/ReturnLabelPanel.js.map +7 -0
- package/dist/modules/warranty_claims/backend/components/TroubleshootingWalker.js +82 -0
- package/dist/modules/warranty_claims/backend/components/TroubleshootingWalker.js.map +7 -0
- package/dist/modules/warranty_claims/backend/components/VendorRecoverySuggestionsPanel.js +136 -0
- package/dist/modules/warranty_claims/backend/components/VendorRecoverySuggestionsPanel.js.map +7 -0
- package/dist/modules/warranty_claims/backend/components/WarrantyWorkspace.js +41 -0
- package/dist/modules/warranty_claims/backend/components/WarrantyWorkspace.js.map +7 -0
- package/dist/modules/warranty_claims/backend/components/claimReasonOptions.js +45 -0
- package/dist/modules/warranty_claims/backend/components/claimReasonOptions.js.map +7 -0
- package/dist/modules/warranty_claims/backend/components/claimSla.js +92 -0
- package/dist/modules/warranty_claims/backend/components/claimSla.js.map +7 -0
- package/dist/modules/warranty_claims/backend/components/orderLookup.js +81 -0
- package/dist/modules/warranty_claims/backend/components/orderLookup.js.map +7 -0
- package/dist/modules/warranty_claims/backend/components/productLookup.js +317 -0
- package/dist/modules/warranty_claims/backend/components/productLookup.js.map +7 -0
- package/dist/modules/warranty_claims/backend/components/useUserDisplayNames.js +61 -0
- package/dist/modules/warranty_claims/backend/components/useUserDisplayNames.js.map +7 -0
- package/dist/modules/warranty_claims/backend/page.js +1088 -0
- package/dist/modules/warranty_claims/backend/page.js.map +7 -0
- package/dist/modules/warranty_claims/backend/page.meta.js +16 -0
- package/dist/modules/warranty_claims/backend/page.meta.js.map +7 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/[id]/edit/page.js +427 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/[id]/edit/page.js.map +7 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/[id]/edit/page.meta.js +16 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/[id]/edit/page.meta.js.map +7 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/[id]/page.js +2101 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/[id]/page.js.map +7 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/[id]/page.meta.js +16 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/[id]/page.meta.js.map +7 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/create/page.js +1249 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/create/page.js.map +7 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/create/page.meta.js +16 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/create/page.meta.js.map +7 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/registrations/[id]/edit/page.js +110 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/registrations/[id]/edit/page.js.map +7 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/registrations/[id]/edit/page.meta.js +17 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/registrations/[id]/edit/page.meta.js.map +7 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/registrations/create/page.js +49 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/registrations/create/page.js.map +7 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/registrations/create/page.meta.js +17 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/registrations/create/page.meta.js.map +7 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/registrations/page.js +485 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/registrations/page.js.map +7 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/registrations/page.meta.js +19 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/registrations/page.meta.js.map +7 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/registrations/registrationForm.js +253 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/registrations/registrationForm.js.map +7 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/settings/page.js +1565 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/settings/page.js.map +7 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/settings/page.meta.js +19 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/settings/page.meta.js.map +7 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/TroubleshootingTreeBuilder.js +472 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/TroubleshootingTreeBuilder.js.map +7 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/[id]/edit/page.js +115 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/[id]/edit/page.js.map +7 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/[id]/edit/page.meta.js +19 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/[id]/edit/page.meta.js.map +7 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/create/page.js +54 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/create/page.js.map +7 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/create/page.meta.js +19 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/create/page.meta.js.map +7 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/page.js +414 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/page.js.map +7 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/page.meta.js +21 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/page.meta.js.map +7 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/troubleshootingGuideForm.js +171 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/troubleshootingGuideForm.js.map +7 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/[id]/edit/page.js +111 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/[id]/edit/page.js.map +7 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/[id]/edit/page.meta.js +19 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/[id]/edit/page.meta.js.map +7 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/create/page.js +48 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/create/page.js.map +7 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/create/page.meta.js +19 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/create/page.meta.js.map +7 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/page.js +429 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/page.js.map +7 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/page.meta.js +21 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/page.meta.js.map +7 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/vendorPolicyForm.js +182 -0
- package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/vendorPolicyForm.js.map +7 -0
- package/dist/modules/warranty_claims/ce.js +48 -0
- package/dist/modules/warranty_claims/ce.js.map +7 -0
- package/dist/modules/warranty_claims/commands/claim-lines.js +911 -0
- package/dist/modules/warranty_claims/commands/claim-lines.js.map +7 -0
- package/dist/modules/warranty_claims/commands/claims.js +2577 -0
- package/dist/modules/warranty_claims/commands/claims.js.map +7 -0
- package/dist/modules/warranty_claims/commands/index.js +4 -0
- package/dist/modules/warranty_claims/commands/index.js.map +7 -0
- package/dist/modules/warranty_claims/commands/settings.js +169 -0
- package/dist/modules/warranty_claims/commands/settings.js.map +7 -0
- package/dist/modules/warranty_claims/commands/shared.js +162 -0
- package/dist/modules/warranty_claims/commands/shared.js.map +7 -0
- package/dist/modules/warranty_claims/data/constants.js +35 -0
- package/dist/modules/warranty_claims/data/constants.js.map +7 -0
- package/dist/modules/warranty_claims/data/enrichers.js +97 -0
- package/dist/modules/warranty_claims/data/enrichers.js.map +7 -0
- package/dist/modules/warranty_claims/data/entities.js +731 -0
- package/dist/modules/warranty_claims/data/entities.js.map +7 -0
- package/dist/modules/warranty_claims/data/validators.js +488 -0
- package/dist/modules/warranty_claims/data/validators.js.map +7 -0
- package/dist/modules/warranty_claims/di.js +29 -0
- package/dist/modules/warranty_claims/di.js.map +7 -0
- package/dist/modules/warranty_claims/encryption.js +28 -0
- package/dist/modules/warranty_claims/encryption.js.map +7 -0
- package/dist/modules/warranty_claims/events.js +81 -0
- package/dist/modules/warranty_claims/events.js.map +7 -0
- package/dist/modules/warranty_claims/extension-points.js +19 -0
- package/dist/modules/warranty_claims/extension-points.js.map +7 -0
- package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.js +825 -0
- package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.js.map +7 -0
- package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.meta.js +11 -0
- package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.meta.js.map +7 -0
- package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/new/page.js +1139 -0
- package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/new/page.js.map +7 -0
- package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/new/page.meta.js +11 -0
- package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/new/page.meta.js.map +7 -0
- package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/page.js +253 -0
- package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/page.js.map +7 -0
- package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/page.meta.js +18 -0
- package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/page.meta.js.map +7 -0
- package/dist/modules/warranty_claims/index.js +15 -0
- package/dist/modules/warranty_claims/index.js.map +7 -0
- package/dist/modules/warranty_claims/lib/aiAssist.js +496 -0
- package/dist/modules/warranty_claims/lib/aiAssist.js.map +7 -0
- package/dist/modules/warranty_claims/lib/apiIdsFilter.js +11 -0
- package/dist/modules/warranty_claims/lib/apiIdsFilter.js.map +7 -0
- package/dist/modules/warranty_claims/lib/assigneeNames.js +153 -0
- package/dist/modules/warranty_claims/lib/assigneeNames.js.map +7 -0
- package/dist/modules/warranty_claims/lib/attachmentVisibility.js +9 -0
- package/dist/modules/warranty_claims/lib/attachmentVisibility.js.map +7 -0
- package/dist/modules/warranty_claims/lib/bulkFeedback.js +8 -0
- package/dist/modules/warranty_claims/lib/bulkFeedback.js.map +7 -0
- package/dist/modules/warranty_claims/lib/businessHours.js +250 -0
- package/dist/modules/warranty_claims/lib/businessHours.js.map +7 -0
- package/dist/modules/warranty_claims/lib/businessHoursForm.js +146 -0
- package/dist/modules/warranty_claims/lib/businessHoursForm.js.map +7 -0
- package/dist/modules/warranty_claims/lib/claimTypeConfig.js +39 -0
- package/dist/modules/warranty_claims/lib/claimTypeConfig.js.map +7 -0
- package/dist/modules/warranty_claims/lib/decryptionSafety.js +17 -0
- package/dist/modules/warranty_claims/lib/decryptionSafety.js.map +7 -0
- package/dist/modules/warranty_claims/lib/deskFilters.js +88 -0
- package/dist/modules/warranty_claims/lib/deskFilters.js.map +7 -0
- package/dist/modules/warranty_claims/lib/dictionaries.js +157 -0
- package/dist/modules/warranty_claims/lib/dictionaries.js.map +7 -0
- package/dist/modules/warranty_claims/lib/dictionaryLabels.js +12 -0
- package/dist/modules/warranty_claims/lib/dictionaryLabels.js.map +7 -0
- package/dist/modules/warranty_claims/lib/emailIntake.js +87 -0
- package/dist/modules/warranty_claims/lib/emailIntake.js.map +7 -0
- package/dist/modules/warranty_claims/lib/escalation.js +58 -0
- package/dist/modules/warranty_claims/lib/escalation.js.map +7 -0
- package/dist/modules/warranty_claims/lib/externalIntake.js +233 -0
- package/dist/modules/warranty_claims/lib/externalIntake.js.map +7 -0
- package/dist/modules/warranty_claims/lib/grading.js +21 -0
- package/dist/modules/warranty_claims/lib/grading.js.map +7 -0
- package/dist/modules/warranty_claims/lib/listSegments.js +18 -0
- package/dist/modules/warranty_claims/lib/listSegments.js.map +7 -0
- package/dist/modules/warranty_claims/lib/orderOwnership.js +25 -0
- package/dist/modules/warranty_claims/lib/orderOwnership.js.map +7 -0
- package/dist/modules/warranty_claims/lib/portalAttachmentUpload.js +11 -0
- package/dist/modules/warranty_claims/lib/portalAttachmentUpload.js.map +7 -0
- package/dist/modules/warranty_claims/lib/portalAttachmentValidation.js +108 -0
- package/dist/modules/warranty_claims/lib/portalAttachmentValidation.js.map +7 -0
- package/dist/modules/warranty_claims/lib/portalClaimAccess.js +26 -0
- package/dist/modules/warranty_claims/lib/portalClaimAccess.js.map +7 -0
- package/dist/modules/warranty_claims/lib/quantity.js +19 -0
- package/dist/modules/warranty_claims/lib/quantity.js.map +7 -0
- package/dist/modules/warranty_claims/lib/risk.js +204 -0
- package/dist/modules/warranty_claims/lib/risk.js.map +7 -0
- package/dist/modules/warranty_claims/lib/settings.js +55 -0
- package/dist/modules/warranty_claims/lib/settings.js.map +7 -0
- package/dist/modules/warranty_claims/lib/stateMachine.js +88 -0
- package/dist/modules/warranty_claims/lib/stateMachine.js.map +7 -0
- package/dist/modules/warranty_claims/lib/timelineFilters.js +16 -0
- package/dist/modules/warranty_claims/lib/timelineFilters.js.map +7 -0
- package/dist/modules/warranty_claims/lib/triage.js +241 -0
- package/dist/modules/warranty_claims/lib/triage.js.map +7 -0
- package/dist/modules/warranty_claims/lib/troubleshooting.js +111 -0
- package/dist/modules/warranty_claims/lib/troubleshooting.js.map +7 -0
- package/dist/modules/warranty_claims/lib/tryResolve.js +11 -0
- package/dist/modules/warranty_claims/lib/tryResolve.js.map +7 -0
- package/dist/modules/warranty_claims/lib/vendorPolicyRecovery.js +101 -0
- package/dist/modules/warranty_claims/lib/vendorPolicyRecovery.js.map +7 -0
- package/dist/modules/warranty_claims/lib/warrantyPreview.js +18 -0
- package/dist/modules/warranty_claims/lib/warrantyPreview.js.map +7 -0
- package/dist/modules/warranty_claims/migrations/Migration20260703191537_warranty_claims.js +32 -0
- package/dist/modules/warranty_claims/migrations/Migration20260703191537_warranty_claims.js.map +7 -0
- package/dist/modules/warranty_claims/migrations/Migration20260704115605_warranty_claims.js +16 -0
- package/dist/modules/warranty_claims/migrations/Migration20260704115605_warranty_claims.js.map +7 -0
- package/dist/modules/warranty_claims/migrations/Migration20260704230000_warranty_claims.js +17 -0
- package/dist/modules/warranty_claims/migrations/Migration20260704230000_warranty_claims.js.map +7 -0
- package/dist/modules/warranty_claims/migrations/Migration20260705121601_warranty_claims.js +29 -0
- package/dist/modules/warranty_claims/migrations/Migration20260705121601_warranty_claims.js.map +7 -0
- package/dist/modules/warranty_claims/migrations/Migration20260709120000_warranty_claims.js +25 -0
- package/dist/modules/warranty_claims/migrations/Migration20260709120000_warranty_claims.js.map +7 -0
- package/dist/modules/warranty_claims/migrations/Migration20260710023607_warranty_claims.js +13 -0
- package/dist/modules/warranty_claims/migrations/Migration20260710023607_warranty_claims.js.map +7 -0
- package/dist/modules/warranty_claims/migrations/Migration20260716203936_warranty_claims.js +15 -0
- package/dist/modules/warranty_claims/migrations/Migration20260716203936_warranty_claims.js.map +7 -0
- package/dist/modules/warranty_claims/migrations/Migration20260717150000_warranty_claims.js +13 -0
- package/dist/modules/warranty_claims/migrations/Migration20260717150000_warranty_claims.js.map +7 -0
- package/dist/modules/warranty_claims/migrations/Migration20260719120000_warranty_claims.js +13 -0
- package/dist/modules/warranty_claims/migrations/Migration20260719120000_warranty_claims.js.map +7 -0
- package/dist/modules/warranty_claims/migrations/Migration20260813103053_warranty_claims.js +12 -0
- package/dist/modules/warranty_claims/migrations/Migration20260813103053_warranty_claims.js.map +7 -0
- package/dist/modules/warranty_claims/notifications.client.js +67 -0
- package/dist/modules/warranty_claims/notifications.client.js.map +7 -0
- package/dist/modules/warranty_claims/notifications.js +103 -0
- package/dist/modules/warranty_claims/notifications.js.map +7 -0
- package/dist/modules/warranty_claims/search.js +174 -0
- package/dist/modules/warranty_claims/search.js.map +7 -0
- package/dist/modules/warranty_claims/services/adjudicationEvaluator.js +202 -0
- package/dist/modules/warranty_claims/services/adjudicationEvaluator.js.map +7 -0
- package/dist/modules/warranty_claims/services/claimNumberGenerator.js +58 -0
- package/dist/modules/warranty_claims/services/claimNumberGenerator.js.map +7 -0
- package/dist/modules/warranty_claims/services/entitlementResolver.js +69 -0
- package/dist/modules/warranty_claims/services/entitlementResolver.js.map +7 -0
- package/dist/modules/warranty_claims/services/returnLabelProvider.js +14 -0
- package/dist/modules/warranty_claims/services/returnLabelProvider.js.map +7 -0
- package/dist/modules/warranty_claims/setup.js +121 -0
- package/dist/modules/warranty_claims/setup.js.map +7 -0
- package/dist/modules/warranty_claims/subscribers/auto-vendor-recovery.js +119 -0
- package/dist/modules/warranty_claims/subscribers/auto-vendor-recovery.js.map +7 -0
- package/dist/modules/warranty_claims/subscribers/claim-assigned-notification.js +47 -0
- package/dist/modules/warranty_claims/subscribers/claim-assigned-notification.js.map +7 -0
- package/dist/modules/warranty_claims/subscribers/claim-customer-reply-notification.js +60 -0
- package/dist/modules/warranty_claims/subscribers/claim-customer-reply-notification.js.map +7 -0
- package/dist/modules/warranty_claims/subscribers/claim-status-notification.js +83 -0
- package/dist/modules/warranty_claims/subscribers/claim-status-notification.js.map +7 -0
- package/dist/modules/warranty_claims/subscribers/claim-submitted-notification.js +46 -0
- package/dist/modules/warranty_claims/subscribers/claim-submitted-notification.js.map +7 -0
- package/dist/modules/warranty_claims/subscribers/email-to-claim.js +135 -0
- package/dist/modules/warranty_claims/subscribers/email-to-claim.js.map +7 -0
- package/dist/modules/warranty_claims/subscribers/return-shipment-tracking.js +164 -0
- package/dist/modules/warranty_claims/subscribers/return-shipment-tracking.js.map +7 -0
- package/dist/modules/warranty_claims/subscribers/vendor-recovery-reconciliation-undo.js +24 -0
- package/dist/modules/warranty_claims/subscribers/vendor-recovery-reconciliation-undo.js.map +7 -0
- package/dist/modules/warranty_claims/subscribers/vendor-recovery-reconciliation.js +26 -0
- package/dist/modules/warranty_claims/subscribers/vendor-recovery-reconciliation.js.map +7 -0
- package/dist/modules/warranty_claims/widgets/dashboard/claims-queue/config.js +15 -0
- package/dist/modules/warranty_claims/widgets/dashboard/claims-queue/config.js.map +7 -0
- package/dist/modules/warranty_claims/widgets/dashboard/claims-queue/widget.client.js +197 -0
- package/dist/modules/warranty_claims/widgets/dashboard/claims-queue/widget.client.js.map +7 -0
- package/dist/modules/warranty_claims/widgets/dashboard/claims-queue/widget.js +28 -0
- package/dist/modules/warranty_claims/widgets/dashboard/claims-queue/widget.js.map +7 -0
- package/dist/modules/warranty_claims/widgets/injection/claims-ai-trigger/widget.client.js +418 -0
- package/dist/modules/warranty_claims/widgets/injection/claims-ai-trigger/widget.client.js.map +7 -0
- package/dist/modules/warranty_claims/widgets/injection/claims-ai-trigger/widget.js +18 -0
- package/dist/modules/warranty_claims/widgets/injection/claims-ai-trigger/widget.js.map +7 -0
- package/dist/modules/warranty_claims/widgets/injection/customer-claims-tab/widget.js +17 -0
- package/dist/modules/warranty_claims/widgets/injection/customer-claims-tab/widget.js.map +7 -0
- package/dist/modules/warranty_claims/widgets/injection/customer-claims-tab.js +146 -0
- package/dist/modules/warranty_claims/widgets/injection/customer-claims-tab.js.map +7 -0
- package/dist/modules/warranty_claims/widgets/injection/order-claims-tab/widget.js +17 -0
- package/dist/modules/warranty_claims/widgets/injection/order-claims-tab/widget.js.map +7 -0
- package/dist/modules/warranty_claims/widgets/injection/order-claims-tab.js +147 -0
- package/dist/modules/warranty_claims/widgets/injection/order-claims-tab.js.map +7 -0
- package/dist/modules/warranty_claims/widgets/injection-table.js +38 -0
- package/dist/modules/warranty_claims/widgets/injection-table.js.map +7 -0
- package/dist/modules/warranty_claims/widgets/notifications/WarrantyClaimNotificationRenderer.js +223 -0
- package/dist/modules/warranty_claims/widgets/notifications/WarrantyClaimNotificationRenderer.js.map +7 -0
- package/dist/modules/warranty_claims/widgets/notifications/index.js +5 -0
- package/dist/modules/warranty_claims/widgets/notifications/index.js.map +7 -0
- package/dist/modules/warranty_claims/workers/sla-escalation-sweep.js +334 -0
- package/dist/modules/warranty_claims/workers/sla-escalation-sweep.js.map +7 -0
- package/generated/entities/warranty_claim/index.ts +51 -0
- package/generated/entities/warranty_claim_event/index.ts +11 -0
- package/generated/entities/warranty_claim_line/index.ts +37 -0
- package/generated/entities/warranty_claim_registration/index.ts +20 -0
- package/generated/entities/warranty_claim_sequence/index.ts +7 -0
- package/generated/entities/warranty_claim_settings/index.ts +20 -0
- package/generated/entities/warranty_claim_sla_signal/index.ts +11 -0
- package/generated/entities/warranty_troubleshooting_guide/index.ts +11 -0
- package/generated/entities/warranty_vendor_policy/index.ts +14 -0
- package/generated/entities.ids.generated.ts +12 -0
- package/generated/entity-fields-registry.ts +200 -0
- package/package.json +7 -7
- package/src/helpers/integration/api.ts +10 -3
- package/src/helpers/integration/salesFixtures.ts +17 -7
- package/src/modules/attachments/di.ts +30 -1
- package/src/modules/attachments/fields/attachment.tsx +215 -23
- package/src/modules/attachments/i18n/de.json +5 -0
- package/src/modules/attachments/i18n/en.json +5 -0
- package/src/modules/attachments/i18n/es.json +5 -0
- package/src/modules/attachments/i18n/ko.json +5 -0
- package/src/modules/attachments/i18n/pl.json +5 -0
- package/src/modules/attachments/lib/scoped-upload-service.ts +257 -0
- package/src/modules/attachments/lib/target-access-service.ts +57 -0
- package/src/modules/auth/api/logout.ts +46 -10
- package/src/modules/auth/api/reset/confirm.ts +7 -0
- package/src/modules/auth/api/reset.ts +7 -0
- package/src/modules/auth/commands/users.ts +22 -0
- package/src/modules/auth/events.ts +15 -0
- package/src/modules/customers/api/companies/route.ts +1 -0
- package/src/modules/customers/backend/customers/companies-v2/[id]/page.tsx +4 -2
- package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +4 -2
- package/src/modules/customers/components/detail/assignableStaff.ts +14 -177
- package/src/modules/customers/lib/assignableStaff.ts +177 -0
- package/src/modules/data_sync/AGENTS.md +3 -0
- package/src/modules/data_sync/lib/batch-stream.ts +117 -0
- package/src/modules/data_sync/lib/sync-engine.ts +190 -115
- package/src/modules/inbox_ops/api/emails/[id]/reprocess/route.ts +4 -0
- package/src/modules/inbox_ops/api/extract/route.ts +4 -0
- package/src/modules/inbox_ops/api/webhook/inbound.ts +4 -0
- package/src/modules/sales/commands/documents.ts +16 -8
- package/src/modules/warranty_claims/AGENTS.md +49 -0
- package/src/modules/warranty_claims/acl.ts +71 -0
- package/src/modules/warranty_claims/ai-agents.ts +189 -0
- package/src/modules/warranty_claims/ai-tools.ts +711 -0
- package/src/modules/warranty_claims/api/ai/assess/route.ts +371 -0
- package/src/modules/warranty_claims/api/ai/draft-reply/route.ts +149 -0
- package/src/modules/warranty_claims/api/ai/suggest/route.ts +190 -0
- package/src/modules/warranty_claims/api/assign/route.ts +142 -0
- package/src/modules/warranty_claims/api/assignees/route.ts +102 -0
- package/src/modules/warranty_claims/api/credit-memo/route.ts +191 -0
- package/src/modules/warranty_claims/api/entitlement/route.ts +224 -0
- package/src/modules/warranty_claims/api/events/route.ts +226 -0
- package/src/modules/warranty_claims/api/external/claims/route.ts +528 -0
- package/src/modules/warranty_claims/api/lines/route.ts +275 -0
- package/src/modules/warranty_claims/api/openapi.ts +28 -0
- package/src/modules/warranty_claims/api/portal/attachments/route.ts +547 -0
- package/src/modules/warranty_claims/api/portal/claims/[id]/route.ts +195 -0
- package/src/modules/warranty_claims/api/portal/claims/[id]/shared.ts +125 -0
- package/src/modules/warranty_claims/api/portal/claims/[id]/submit/route.ts +82 -0
- package/src/modules/warranty_claims/api/portal/claims/[id]/withdraw/route.ts +87 -0
- package/src/modules/warranty_claims/api/portal/claims/route.ts +579 -0
- package/src/modules/warranty_claims/api/portal/events/route.ts +260 -0
- package/src/modules/warranty_claims/api/portal/options/route.ts +113 -0
- package/src/modules/warranty_claims/api/portal/orders/lines/route.ts +258 -0
- package/src/modules/warranty_claims/api/portal/orders/route.ts +206 -0
- package/src/modules/warranty_claims/api/portal/troubleshooting/route.ts +151 -0
- package/src/modules/warranty_claims/api/receiving/route.ts +215 -0
- package/src/modules/warranty_claims/api/registrations/route.ts +417 -0
- package/src/modules/warranty_claims/api/replacement-order/route.ts +188 -0
- package/src/modules/warranty_claims/api/return-label/route.ts +291 -0
- package/src/modules/warranty_claims/api/risk/route.ts +112 -0
- package/src/modules/warranty_claims/api/route.ts +543 -0
- package/src/modules/warranty_claims/api/sales-return/route.ts +183 -0
- package/src/modules/warranty_claims/api/settings-general/route.ts +230 -0
- package/src/modules/warranty_claims/api/stats/route.ts +306 -0
- package/src/modules/warranty_claims/api/submit/route.ts +146 -0
- package/src/modules/warranty_claims/api/transition/route.ts +144 -0
- package/src/modules/warranty_claims/api/troubleshooting-guides/route.ts +295 -0
- package/src/modules/warranty_claims/api/vendor-policies/route.ts +351 -0
- package/src/modules/warranty_claims/api/vendor-recovery/route.ts +148 -0
- package/src/modules/warranty_claims/api/vendor-recovery-suggestions/route.ts +167 -0
- package/src/modules/warranty_claims/backend/components/AiAssessButtons.tsx +389 -0
- package/src/modules/warranty_claims/backend/components/ClaimStageProgress.tsx +65 -0
- package/src/modules/warranty_claims/backend/components/ClaimStatusBadge.tsx +137 -0
- package/src/modules/warranty_claims/backend/components/ClaimsKpiStrip.tsx +169 -0
- package/src/modules/warranty_claims/backend/components/EntitlementLookupBadge.tsx +203 -0
- package/src/modules/warranty_claims/backend/components/ReceivingPanel.tsx +300 -0
- package/src/modules/warranty_claims/backend/components/ReturnLabelPanel.tsx +292 -0
- package/src/modules/warranty_claims/backend/components/TroubleshootingWalker.tsx +125 -0
- package/src/modules/warranty_claims/backend/components/VendorRecoverySuggestionsPanel.tsx +217 -0
- package/src/modules/warranty_claims/backend/components/WarrantyWorkspace.tsx +66 -0
- package/src/modules/warranty_claims/backend/components/claimReasonOptions.ts +57 -0
- package/src/modules/warranty_claims/backend/components/claimSla.tsx +127 -0
- package/src/modules/warranty_claims/backend/components/orderLookup.ts +109 -0
- package/src/modules/warranty_claims/backend/components/productLookup.tsx +433 -0
- package/src/modules/warranty_claims/backend/components/useUserDisplayNames.ts +66 -0
- package/src/modules/warranty_claims/backend/page.meta.ts +12 -0
- package/src/modules/warranty_claims/backend/page.tsx +1302 -0
- package/src/modules/warranty_claims/backend/warranty_claims/[id]/edit/page.meta.ts +12 -0
- package/src/modules/warranty_claims/backend/warranty_claims/[id]/edit/page.tsx +531 -0
- package/src/modules/warranty_claims/backend/warranty_claims/[id]/page.meta.ts +12 -0
- package/src/modules/warranty_claims/backend/warranty_claims/[id]/page.tsx +2599 -0
- package/src/modules/warranty_claims/backend/warranty_claims/create/page.meta.ts +12 -0
- package/src/modules/warranty_claims/backend/warranty_claims/create/page.tsx +1533 -0
- package/src/modules/warranty_claims/backend/warranty_claims/registrations/[id]/edit/page.meta.ts +13 -0
- package/src/modules/warranty_claims/backend/warranty_claims/registrations/[id]/edit/page.tsx +136 -0
- package/src/modules/warranty_claims/backend/warranty_claims/registrations/create/page.meta.ts +13 -0
- package/src/modules/warranty_claims/backend/warranty_claims/registrations/create/page.tsx +52 -0
- package/src/modules/warranty_claims/backend/warranty_claims/registrations/page.meta.ts +15 -0
- package/src/modules/warranty_claims/backend/warranty_claims/registrations/page.tsx +555 -0
- package/src/modules/warranty_claims/backend/warranty_claims/registrations/registrationForm.tsx +295 -0
- package/src/modules/warranty_claims/backend/warranty_claims/settings/page.meta.ts +15 -0
- package/src/modules/warranty_claims/backend/warranty_claims/settings/page.tsx +1903 -0
- package/src/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/TroubleshootingTreeBuilder.tsx +550 -0
- package/src/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/[id]/edit/page.meta.ts +15 -0
- package/src/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/[id]/edit/page.tsx +141 -0
- package/src/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/create/page.meta.ts +15 -0
- package/src/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/create/page.tsx +57 -0
- package/src/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/page.meta.ts +17 -0
- package/src/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/page.tsx +477 -0
- package/src/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/troubleshootingGuideForm.tsx +214 -0
- package/src/modules/warranty_claims/backend/warranty_claims/vendor-policies/[id]/edit/page.meta.ts +15 -0
- package/src/modules/warranty_claims/backend/warranty_claims/vendor-policies/[id]/edit/page.tsx +137 -0
- package/src/modules/warranty_claims/backend/warranty_claims/vendor-policies/create/page.meta.ts +15 -0
- package/src/modules/warranty_claims/backend/warranty_claims/vendor-policies/create/page.tsx +51 -0
- package/src/modules/warranty_claims/backend/warranty_claims/vendor-policies/page.meta.ts +17 -0
- package/src/modules/warranty_claims/backend/warranty_claims/vendor-policies/page.tsx +506 -0
- package/src/modules/warranty_claims/backend/warranty_claims/vendor-policies/vendorPolicyForm.tsx +220 -0
- package/src/modules/warranty_claims/ce.ts +44 -0
- package/src/modules/warranty_claims/commands/claim-lines.ts +1082 -0
- package/src/modules/warranty_claims/commands/claims.ts +3364 -0
- package/src/modules/warranty_claims/commands/index.ts +3 -0
- package/src/modules/warranty_claims/commands/settings.ts +198 -0
- package/src/modules/warranty_claims/commands/shared.ts +212 -0
- package/src/modules/warranty_claims/data/constants.ts +37 -0
- package/src/modules/warranty_claims/data/enrichers.ts +165 -0
- package/src/modules/warranty_claims/data/entities.ts +663 -0
- package/src/modules/warranty_claims/data/validators.ts +634 -0
- package/src/modules/warranty_claims/di.ts +34 -0
- package/src/modules/warranty_claims/encryption.ts +26 -0
- package/src/modules/warranty_claims/events.ts +81 -0
- package/src/modules/warranty_claims/extension-points.ts +16 -0
- package/src/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.meta.ts +9 -0
- package/src/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.tsx +1037 -0
- package/src/modules/warranty_claims/frontend/[orgSlug]/portal/claims/new/page.meta.ts +9 -0
- package/src/modules/warranty_claims/frontend/[orgSlug]/portal/claims/new/page.tsx +1409 -0
- package/src/modules/warranty_claims/frontend/[orgSlug]/portal/claims/page.meta.ts +16 -0
- package/src/modules/warranty_claims/frontend/[orgSlug]/portal/claims/page.tsx +304 -0
- package/src/modules/warranty_claims/i18n/de.json +1270 -0
- package/src/modules/warranty_claims/i18n/en.json +1270 -0
- package/src/modules/warranty_claims/i18n/es.json +1270 -0
- package/src/modules/warranty_claims/i18n/ko.json +1270 -0
- package/src/modules/warranty_claims/i18n/pl.json +1270 -0
- package/src/modules/warranty_claims/index.ts +13 -0
- package/src/modules/warranty_claims/lib/aiAssist.ts +677 -0
- package/src/modules/warranty_claims/lib/apiIdsFilter.ts +12 -0
- package/src/modules/warranty_claims/lib/assigneeNames.ts +184 -0
- package/src/modules/warranty_claims/lib/attachmentVisibility.ts +5 -0
- package/src/modules/warranty_claims/lib/bulkFeedback.ts +6 -0
- package/src/modules/warranty_claims/lib/businessHours.ts +333 -0
- package/src/modules/warranty_claims/lib/businessHoursForm.ts +206 -0
- package/src/modules/warranty_claims/lib/claimTypeConfig.ts +49 -0
- package/src/modules/warranty_claims/lib/decryptionSafety.ts +19 -0
- package/src/modules/warranty_claims/lib/deskFilters.ts +131 -0
- package/src/modules/warranty_claims/lib/dictionaries.ts +212 -0
- package/src/modules/warranty_claims/lib/dictionaryLabels.ts +16 -0
- package/src/modules/warranty_claims/lib/emailIntake.ts +123 -0
- package/src/modules/warranty_claims/lib/escalation.ts +83 -0
- package/src/modules/warranty_claims/lib/externalIntake.ts +362 -0
- package/src/modules/warranty_claims/lib/grading.ts +23 -0
- package/src/modules/warranty_claims/lib/listSegments.ts +19 -0
- package/src/modules/warranty_claims/lib/orderOwnership.ts +58 -0
- package/src/modules/warranty_claims/lib/portalAttachmentUpload.ts +11 -0
- package/src/modules/warranty_claims/lib/portalAttachmentValidation.ts +115 -0
- package/src/modules/warranty_claims/lib/portalClaimAccess.ts +37 -0
- package/src/modules/warranty_claims/lib/quantity.ts +15 -0
- package/src/modules/warranty_claims/lib/risk.ts +371 -0
- package/src/modules/warranty_claims/lib/settings.ts +76 -0
- package/src/modules/warranty_claims/lib/stateMachine.ts +130 -0
- package/src/modules/warranty_claims/lib/timelineFilters.ts +22 -0
- package/src/modules/warranty_claims/lib/triage.ts +366 -0
- package/src/modules/warranty_claims/lib/troubleshooting.ts +157 -0
- package/src/modules/warranty_claims/lib/tryResolve.ts +7 -0
- package/src/modules/warranty_claims/lib/vendorPolicyRecovery.ts +182 -0
- package/src/modules/warranty_claims/lib/warrantyPreview.ts +20 -0
- package/src/modules/warranty_claims/migrations/.snapshot-open-mercato.json +3525 -0
- package/src/modules/warranty_claims/migrations/Migration20260703191537_warranty_claims.ts +38 -0
- package/src/modules/warranty_claims/migrations/Migration20260704115605_warranty_claims.ts +17 -0
- package/src/modules/warranty_claims/migrations/Migration20260704230000_warranty_claims.ts +17 -0
- package/src/modules/warranty_claims/migrations/Migration20260705121601_warranty_claims.ts +37 -0
- package/src/modules/warranty_claims/migrations/Migration20260709120000_warranty_claims.ts +25 -0
- package/src/modules/warranty_claims/migrations/Migration20260710023607_warranty_claims.ts +13 -0
- package/src/modules/warranty_claims/migrations/Migration20260716203936_warranty_claims.ts +15 -0
- package/src/modules/warranty_claims/migrations/Migration20260717150000_warranty_claims.ts +13 -0
- package/src/modules/warranty_claims/migrations/Migration20260719120000_warranty_claims.ts +13 -0
- package/src/modules/warranty_claims/migrations/Migration20260813103053_warranty_claims.ts +11 -0
- package/src/modules/warranty_claims/notifications.client.ts +78 -0
- package/src/modules/warranty_claims/notifications.ts +101 -0
- package/src/modules/warranty_claims/search.ts +199 -0
- package/src/modules/warranty_claims/services/adjudicationEvaluator.ts +344 -0
- package/src/modules/warranty_claims/services/claimNumberGenerator.ts +69 -0
- package/src/modules/warranty_claims/services/entitlementResolver.ts +102 -0
- package/src/modules/warranty_claims/services/returnLabelProvider.ts +30 -0
- package/src/modules/warranty_claims/setup.ts +154 -0
- package/src/modules/warranty_claims/subscribers/auto-vendor-recovery.ts +140 -0
- package/src/modules/warranty_claims/subscribers/claim-assigned-notification.ts +54 -0
- package/src/modules/warranty_claims/subscribers/claim-customer-reply-notification.ts +68 -0
- package/src/modules/warranty_claims/subscribers/claim-status-notification.ts +100 -0
- package/src/modules/warranty_claims/subscribers/claim-submitted-notification.ts +53 -0
- package/src/modules/warranty_claims/subscribers/email-to-claim.ts +175 -0
- package/src/modules/warranty_claims/subscribers/return-shipment-tracking.ts +226 -0
- package/src/modules/warranty_claims/subscribers/vendor-recovery-reconciliation-undo.ts +28 -0
- package/src/modules/warranty_claims/subscribers/vendor-recovery-reconciliation.ts +31 -0
- package/src/modules/warranty_claims/widgets/dashboard/claims-queue/config.ts +15 -0
- package/src/modules/warranty_claims/widgets/dashboard/claims-queue/widget.client.tsx +237 -0
- package/src/modules/warranty_claims/widgets/dashboard/claims-queue/widget.ts +27 -0
- package/src/modules/warranty_claims/widgets/injection/claims-ai-trigger/widget.client.tsx +527 -0
- package/src/modules/warranty_claims/widgets/injection/claims-ai-trigger/widget.ts +37 -0
- package/src/modules/warranty_claims/widgets/injection/customer-claims-tab/widget.ts +21 -0
- package/src/modules/warranty_claims/widgets/injection/customer-claims-tab.tsx +210 -0
- package/src/modules/warranty_claims/widgets/injection/order-claims-tab/widget.ts +21 -0
- package/src/modules/warranty_claims/widgets/injection/order-claims-tab.tsx +210 -0
- package/src/modules/warranty_claims/widgets/injection-table.ts +36 -0
- package/src/modules/warranty_claims/widgets/notifications/WarrantyClaimNotificationRenderer.tsx +253 -0
- package/src/modules/warranty_claims/widgets/notifications/index.ts +1 -0
- package/src/modules/warranty_claims/workers/sla-escalation-sweep.ts +450 -0
|
@@ -0,0 +1,3364 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto'
|
|
2
|
+
import { LockMode } from '@mikro-orm/core'
|
|
3
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
import { registerCommand, type CommandBus, type CommandHandler, type CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
|
|
6
|
+
import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
|
|
7
|
+
import { emitCrudSideEffects, emitCrudUndoSideEffects } from '@open-mercato/shared/lib/commands/helpers'
|
|
8
|
+
import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
|
|
9
|
+
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
10
|
+
import { invalidateCrudCache } from '@open-mercato/shared/lib/crud/cache'
|
|
11
|
+
import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
12
|
+
import { readSafeDecryptedString } from '../lib/decryptionSafety'
|
|
13
|
+
import type { EntityId } from '@open-mercato/shared/modules/entities'
|
|
14
|
+
import type { CrudEventsConfig } from '@open-mercato/shared/lib/crud/types'
|
|
15
|
+
import { E } from '#generated/entities.ids.generated'
|
|
16
|
+
import {
|
|
17
|
+
WarrantyClaim,
|
|
18
|
+
WarrantyClaimLine,
|
|
19
|
+
} from '../data/entities'
|
|
20
|
+
import {
|
|
21
|
+
CLAIM_FULFILLMENT_UPDATE_FIELDS,
|
|
22
|
+
CLAIM_INTAKE_UPDATE_FIELDS,
|
|
23
|
+
claimCreateSchema,
|
|
24
|
+
claimUpdateSchema,
|
|
25
|
+
assignClaimInputSchema,
|
|
26
|
+
claimCreateCreditMemoSchema,
|
|
27
|
+
claimCreateReplacementOrderSchema,
|
|
28
|
+
claimCreateSalesReturnSchema,
|
|
29
|
+
claimSetReturnLabelSchema,
|
|
30
|
+
commentClaimInputSchema,
|
|
31
|
+
transitionClaimInputSchema,
|
|
32
|
+
vendorRecoveryInputSchema,
|
|
33
|
+
type ClaimCreateInput,
|
|
34
|
+
type ClaimInitialLineCreateInput,
|
|
35
|
+
type ClaimUpdateInput,
|
|
36
|
+
type AssignClaimInput,
|
|
37
|
+
type ClaimCreateCreditMemoInput,
|
|
38
|
+
type ClaimCreateReplacementOrderInput,
|
|
39
|
+
type ClaimCreateSalesReturnInput,
|
|
40
|
+
type ClaimSetReturnLabelInput,
|
|
41
|
+
type CommentClaimInput,
|
|
42
|
+
type TransitionClaimInput,
|
|
43
|
+
type VendorRecoveryInput,
|
|
44
|
+
type WarrantyClaimChannel,
|
|
45
|
+
type WarrantyClaimLineStatus,
|
|
46
|
+
type WarrantyClaimPriority,
|
|
47
|
+
type WarrantyClaimDisposition,
|
|
48
|
+
type WarrantyClaimStatus,
|
|
49
|
+
type WarrantyClaimType,
|
|
50
|
+
type WarrantyClaimWarrantyStatus,
|
|
51
|
+
} from '../data/validators'
|
|
52
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
53
|
+
import { isAssignableStaffUser } from '../lib/assigneeNames'
|
|
54
|
+
import { WarrantyClaimNumberGenerator } from '../services/claimNumberGenerator'
|
|
55
|
+
import { emitWarrantyClaimsEvent } from '../events'
|
|
56
|
+
import { assertTransition, canResolveWithLineStatuses, computeHeaderRollups } from '../lib/stateMachine'
|
|
57
|
+
import { assertOrderBelongsToCustomer } from '../lib/orderOwnership'
|
|
58
|
+
import { addBusinessMillis, businessMillisBetween } from '../lib/businessHours'
|
|
59
|
+
import { resolveEffectiveWarrantyClaimSettings, type WarrantyClaimEffectiveSettings } from '../lib/settings'
|
|
60
|
+
import { evaluateClaimRisk } from '../lib/risk'
|
|
61
|
+
import type { WarrantyAdjudicationEvaluator } from '../services/adjudicationEvaluator'
|
|
62
|
+
import type { WarrantyEntitlementInput, WarrantyEntitlementResolver } from '../services/entitlementResolver'
|
|
63
|
+
import {
|
|
64
|
+
WARRANTY_CLAIM_RESOURCE_KIND,
|
|
65
|
+
appendClaimEvent,
|
|
66
|
+
enforceWarrantyClaimOptimisticLock,
|
|
67
|
+
ensureOrganizationScope,
|
|
68
|
+
ensureTenantScope,
|
|
69
|
+
extractUndoPayload,
|
|
70
|
+
requireScopedClaim,
|
|
71
|
+
type WarrantyClaimScope,
|
|
72
|
+
} from './shared'
|
|
73
|
+
|
|
74
|
+
const claimCrudEvents: CrudEventsConfig = {
|
|
75
|
+
module: 'warranty_claims',
|
|
76
|
+
entity: 'claim',
|
|
77
|
+
persistent: true,
|
|
78
|
+
buildPayload: (ctx) => {
|
|
79
|
+
const claim = ctx.entity as WarrantyClaim | null
|
|
80
|
+
return {
|
|
81
|
+
id: ctx.identifiers.id,
|
|
82
|
+
organizationId: ctx.identifiers.organizationId,
|
|
83
|
+
tenantId: ctx.identifiers.tenantId,
|
|
84
|
+
claimType: claim?.claimType ?? null,
|
|
85
|
+
status: claim?.status ?? null,
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const claimDeleteSchema = z
|
|
91
|
+
.object({
|
|
92
|
+
id: z.string().uuid(),
|
|
93
|
+
organizationId: z.string().uuid().optional(),
|
|
94
|
+
tenantId: z.string().uuid().optional(),
|
|
95
|
+
})
|
|
96
|
+
.strict()
|
|
97
|
+
|
|
98
|
+
const submitClaimSchema = claimDeleteSchema.extend({
|
|
99
|
+
actorCustomerId: z.string().uuid().optional(),
|
|
100
|
+
})
|
|
101
|
+
const escalateClaimInputSchema = z
|
|
102
|
+
.object({
|
|
103
|
+
id: z.string().uuid(),
|
|
104
|
+
organizationId: z.string().uuid(),
|
|
105
|
+
tenantId: z.string().uuid(),
|
|
106
|
+
toLevel: z.coerce.number().int().min(1).max(1000),
|
|
107
|
+
reassignToUserId: z.string().uuid().optional(),
|
|
108
|
+
})
|
|
109
|
+
.strict()
|
|
110
|
+
|
|
111
|
+
type ClaimDeleteInput = z.infer<typeof claimDeleteSchema>
|
|
112
|
+
type SubmitClaimInput = z.infer<typeof submitClaimSchema>
|
|
113
|
+
type EscalateClaimInput = z.infer<typeof escalateClaimInputSchema>
|
|
114
|
+
type EscalateClaimResult = { claimId: string; escalationLevel: number; escalated: boolean }
|
|
115
|
+
type ClaimSetReturnLabelResult = {
|
|
116
|
+
claimId: string
|
|
117
|
+
labelUrl: string | null
|
|
118
|
+
trackingNumber: string | null
|
|
119
|
+
carrier: string | null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
type ScopeInput = {
|
|
123
|
+
organizationId?: string | null
|
|
124
|
+
tenantId?: string | null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
type ClaimUpdateField =
|
|
128
|
+
| (typeof CLAIM_INTAKE_UPDATE_FIELDS)[number]
|
|
129
|
+
| (typeof CLAIM_FULFILLMENT_UPDATE_FIELDS)[number]
|
|
130
|
+
|
|
131
|
+
type ClaimLineSnapshot = {
|
|
132
|
+
id: string
|
|
133
|
+
lineNo: number
|
|
134
|
+
productId: string | null
|
|
135
|
+
variantId: string | null
|
|
136
|
+
sku: string | null
|
|
137
|
+
productName: string | null
|
|
138
|
+
orderLineId: string | null
|
|
139
|
+
serialNumber: string | null
|
|
140
|
+
lotNumber: string | null
|
|
141
|
+
purchaseDate: string | null
|
|
142
|
+
warrantyMonths: number | null
|
|
143
|
+
warrantyExpiresAt: string | null
|
|
144
|
+
warrantyStatus: WarrantyClaimWarrantyStatus
|
|
145
|
+
faultCode: string | null
|
|
146
|
+
faultDescription: string | null
|
|
147
|
+
qtyClaimed: string
|
|
148
|
+
qtyApproved: string | null
|
|
149
|
+
qtyReceived: string | null
|
|
150
|
+
conditionOnReceipt: string | null
|
|
151
|
+
conditionGrade: string | null
|
|
152
|
+
quarantineStatus: string
|
|
153
|
+
inspectionNotes: string | null
|
|
154
|
+
assessmentPayload: Record<string, unknown> | null
|
|
155
|
+
disposition: WarrantyClaimDisposition | null
|
|
156
|
+
lineStatus: WarrantyClaimLineStatus
|
|
157
|
+
creditAmount: string | null
|
|
158
|
+
restockingFee: string | null
|
|
159
|
+
coreChargeAmount: string | null
|
|
160
|
+
coreCreditAmount: string | null
|
|
161
|
+
vendorClaimLineId: string | null
|
|
162
|
+
vendorName: string | null
|
|
163
|
+
deletedAt: string | null
|
|
164
|
+
updatedAt: string | null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
type ClaimSnapshot = {
|
|
168
|
+
id: string
|
|
169
|
+
organizationId: string
|
|
170
|
+
tenantId: string
|
|
171
|
+
claimNumber: string
|
|
172
|
+
claimType: WarrantyClaimType
|
|
173
|
+
status: WarrantyClaimStatus
|
|
174
|
+
channel: WarrantyClaimChannel
|
|
175
|
+
priority: WarrantyClaimPriority
|
|
176
|
+
customerId: string | null
|
|
177
|
+
customerName: string | null
|
|
178
|
+
externalRef: string | null
|
|
179
|
+
intakeMessageRef: string | null
|
|
180
|
+
entitlementSource: string | null
|
|
181
|
+
contactEmail: string | null
|
|
182
|
+
returnLabelUrl: string | null
|
|
183
|
+
returnTrackingNumber: string | null
|
|
184
|
+
returnCarrier: string | null
|
|
185
|
+
vendorName: string | null
|
|
186
|
+
vendorRef: string | null
|
|
187
|
+
orderId: string | null
|
|
188
|
+
orderNumber: string | null
|
|
189
|
+
awaitingStaffReply: boolean
|
|
190
|
+
salesReturnId: string | null
|
|
191
|
+
replacementOrderId: string | null
|
|
192
|
+
creditMemoId: string | null
|
|
193
|
+
sourceClaimId: string | null
|
|
194
|
+
advanceReplacement: boolean
|
|
195
|
+
advanceShippedAt: string | null
|
|
196
|
+
reasonCode: string | null
|
|
197
|
+
rejectionReasonCode: string | null
|
|
198
|
+
resolutionSummary: string | null
|
|
199
|
+
notes: string | null
|
|
200
|
+
currencyCode: string | null
|
|
201
|
+
totalClaimedAmount: string | null
|
|
202
|
+
totalApprovedAmount: string | null
|
|
203
|
+
totalRecoveredAmount: string | null
|
|
204
|
+
escalationLevel: number
|
|
205
|
+
escalatedAt: string | null
|
|
206
|
+
slaDueAt: string | null
|
|
207
|
+
slaPausedAt: string | null
|
|
208
|
+
slaAtRiskNotifiedAt: string | null
|
|
209
|
+
slaBreachedNotifiedAt: string | null
|
|
210
|
+
submittedAt: string | null
|
|
211
|
+
resolvedAt: string | null
|
|
212
|
+
closedAt: string | null
|
|
213
|
+
assigneeUserId: string | null
|
|
214
|
+
deletedAt: string | null
|
|
215
|
+
updatedAt: string | null
|
|
216
|
+
lines: ClaimLineSnapshot[]
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
type ClaimUndoPayload = {
|
|
220
|
+
before?: ClaimSnapshot | null
|
|
221
|
+
after?: ClaimSnapshot | null
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
type ClaimEventPayload = {
|
|
225
|
+
id: string
|
|
226
|
+
claimId: string
|
|
227
|
+
claimNumber: string
|
|
228
|
+
externalRef: string | null
|
|
229
|
+
claimType: WarrantyClaimType
|
|
230
|
+
status: WarrantyClaimStatus
|
|
231
|
+
customerId: string | null
|
|
232
|
+
organizationId: string
|
|
233
|
+
tenantId: string
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
type ReferenceLookupResult = 'missing' | 'unknown'
|
|
237
|
+
|
|
238
|
+
type ReferenceValidationInput = {
|
|
239
|
+
orderId?: string | null
|
|
240
|
+
salesReturnId?: string | null
|
|
241
|
+
replacementOrderId?: string | null
|
|
242
|
+
creditMemoId?: string | null
|
|
243
|
+
lineOrderRefs?: Array<{ orderLineId: string; orderId: string | null }>
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const intakeStatuses = new Set<WarrantyClaimStatus>(['draft', 'submitted', 'in_review', 'info_requested'])
|
|
247
|
+
const fulfillmentStatuses = new Set<WarrantyClaimStatus>(['approved', 'awaiting_return', 'received', 'inspecting'])
|
|
248
|
+
const deletableStatuses = new Set<WarrantyClaimStatus>(['draft', 'cancelled'])
|
|
249
|
+
const preReceivedStatuses = new Set<WarrantyClaimStatus>([
|
|
250
|
+
'draft',
|
|
251
|
+
'submitted',
|
|
252
|
+
'in_review',
|
|
253
|
+
'info_requested',
|
|
254
|
+
'approved',
|
|
255
|
+
'awaiting_return',
|
|
256
|
+
])
|
|
257
|
+
|
|
258
|
+
function parseCommandInput<T>(schema: z.ZodType<T>, rawInput: unknown): T {
|
|
259
|
+
const result = schema.safeParse(rawInput ?? {})
|
|
260
|
+
if (!result.success) {
|
|
261
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.invalidInput' })
|
|
262
|
+
}
|
|
263
|
+
return result.data
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function hasOwn(input: object, key: string): boolean {
|
|
267
|
+
return Object.prototype.hasOwnProperty.call(input, key)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function resolveScope(ctx: CommandRuntimeContext, input: ScopeInput): WarrantyClaimScope {
|
|
271
|
+
const tenantId = input.tenantId ?? ctx.auth?.tenantId ?? null
|
|
272
|
+
const organizationId = input.organizationId ?? ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null
|
|
273
|
+
if (!tenantId) throw new CrudHttpError(400, { error: '[internal] tenant scope required for warranty claim command' })
|
|
274
|
+
if (!organizationId) throw new CrudHttpError(400, { error: '[internal] organization scope required for warranty claim command' })
|
|
275
|
+
ensureTenantScope(ctx, tenantId)
|
|
276
|
+
ensureOrganizationScope(ctx, organizationId)
|
|
277
|
+
return { tenantId, organizationId }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function toIso(value: Date | string | null | undefined): string | null {
|
|
281
|
+
if (!value) return null
|
|
282
|
+
const date = value instanceof Date ? value : new Date(value)
|
|
283
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString()
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function toDate(value: string | null): Date | null {
|
|
287
|
+
if (!value) return null
|
|
288
|
+
const date = new Date(value)
|
|
289
|
+
return Number.isNaN(date.getTime()) ? null : date
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function toDateOnlyIso(value: Date | string | null | undefined): string | null {
|
|
293
|
+
const iso = toIso(value)
|
|
294
|
+
return iso ? iso.slice(0, 10) : null
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function toDateOnly(value: string | null): Date | null {
|
|
298
|
+
if (!value) return null
|
|
299
|
+
const date = new Date(`${value}T00:00:00.000Z`)
|
|
300
|
+
return Number.isNaN(date.getTime()) ? null : date
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function amountString(value: number | string | null | undefined, fallback = '0'): string | null {
|
|
304
|
+
if (value === null) return null
|
|
305
|
+
if (value === undefined) return fallback
|
|
306
|
+
const parsed = typeof value === 'number' ? value : Number(value)
|
|
307
|
+
if (!Number.isFinite(parsed)) return fallback
|
|
308
|
+
return String(parsed)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function nullableAmountString(value: number | string | null | undefined): string | null {
|
|
312
|
+
if (value === undefined || value === null) return null
|
|
313
|
+
return amountString(value, '0')
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function addMonths(date: Date, months: number): Date {
|
|
317
|
+
const target = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, 1))
|
|
318
|
+
const lastDay = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate()
|
|
319
|
+
const copy = new Date(date.getTime())
|
|
320
|
+
copy.setUTCDate(1)
|
|
321
|
+
copy.setUTCFullYear(target.getUTCFullYear(), target.getUTCMonth(), Math.min(date.getUTCDate(), lastDay))
|
|
322
|
+
return copy
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function computeWarrantyDates(
|
|
326
|
+
purchaseDate: Date | null | undefined,
|
|
327
|
+
warrantyMonths: number | null | undefined,
|
|
328
|
+
): { warrantyExpiresAt: Date | null; warrantyStatus: WarrantyClaimWarrantyStatus } {
|
|
329
|
+
if (!purchaseDate || warrantyMonths === null || warrantyMonths === undefined) {
|
|
330
|
+
return { warrantyExpiresAt: null, warrantyStatus: 'unknown' }
|
|
331
|
+
}
|
|
332
|
+
const warrantyExpiresAt = addMonths(purchaseDate, warrantyMonths)
|
|
333
|
+
const warrantyStatus = warrantyExpiresAt.getTime() >= Date.now() ? 'in_warranty' : 'out_of_warranty'
|
|
334
|
+
return { warrantyExpiresAt, warrantyStatus }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function buildCreateEntitlementInput(input: ClaimCreateInput): WarrantyEntitlementInput | null {
|
|
338
|
+
const lines = input.lines ?? []
|
|
339
|
+
const sourceLine = lines.find((line) => typeof line.serialNumber === 'string' && line.serialNumber.trim().length > 0)
|
|
340
|
+
?? lines.find((line) => Boolean(line.productId))
|
|
341
|
+
?? lines.find((line) => typeof line.sku === 'string' && line.sku.trim().length > 0)
|
|
342
|
+
?? (input.orderId ? lines.find((line) => Boolean(line.purchaseDate)) : null)
|
|
343
|
+
?? null
|
|
344
|
+
|
|
345
|
+
const serialNumber = sourceLine?.serialNumber?.trim() || null
|
|
346
|
+
const orderId = input.orderId ?? null
|
|
347
|
+
const productId = sourceLine?.productId ?? null
|
|
348
|
+
const sku = sourceLine?.sku?.trim() || null
|
|
349
|
+
|
|
350
|
+
if (!serialNumber && !orderId && !productId && !sku) return null
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
serialNumber,
|
|
354
|
+
orderId,
|
|
355
|
+
productId,
|
|
356
|
+
sku,
|
|
357
|
+
purchaseDate: toDateOnlyIso(sourceLine?.purchaseDate),
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function stampEntitlementSourceIfResolvable(
|
|
362
|
+
ctx: CommandRuntimeContext,
|
|
363
|
+
em: EntityManager,
|
|
364
|
+
claim: WarrantyClaim,
|
|
365
|
+
input: ClaimCreateInput,
|
|
366
|
+
scope: WarrantyClaimScope,
|
|
367
|
+
): Promise<void> {
|
|
368
|
+
const entitlementInput = buildCreateEntitlementInput(input)
|
|
369
|
+
if (!entitlementInput) return
|
|
370
|
+
|
|
371
|
+
try {
|
|
372
|
+
const resolver = ctx.container.resolve<WarrantyEntitlementResolver>('warrantyEntitlementResolver')
|
|
373
|
+
const result = await resolver.resolveEntitlement(entitlementInput, scope, em)
|
|
374
|
+
claim.entitlementSource = result.source ?? null
|
|
375
|
+
} catch {
|
|
376
|
+
claim.entitlementSource = null
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function buildInitialLineData(
|
|
381
|
+
claim: WarrantyClaim,
|
|
382
|
+
input: ClaimInitialLineCreateInput,
|
|
383
|
+
index: number,
|
|
384
|
+
): Partial<WarrantyClaimLine> & {
|
|
385
|
+
id: string
|
|
386
|
+
claim: WarrantyClaim
|
|
387
|
+
organizationId: string
|
|
388
|
+
tenantId: string
|
|
389
|
+
lineNo: number
|
|
390
|
+
qtyClaimed: string
|
|
391
|
+
lineStatus: WarrantyClaimLineStatus
|
|
392
|
+
warrantyStatus: WarrantyClaimWarrantyStatus
|
|
393
|
+
createdAt: Date
|
|
394
|
+
updatedAt: Date
|
|
395
|
+
} {
|
|
396
|
+
const purchaseDate = input.purchaseDate ?? null
|
|
397
|
+
const warrantyMonths = input.warrantyMonths ?? null
|
|
398
|
+
const computedWarranty = computeWarrantyDates(purchaseDate, warrantyMonths)
|
|
399
|
+
return {
|
|
400
|
+
id: randomUUID(),
|
|
401
|
+
claim,
|
|
402
|
+
organizationId: claim.organizationId,
|
|
403
|
+
tenantId: claim.tenantId,
|
|
404
|
+
lineNo: input.lineNo ?? index + 1,
|
|
405
|
+
productId: input.productId ?? null,
|
|
406
|
+
variantId: input.variantId ?? null,
|
|
407
|
+
sku: input.sku ?? null,
|
|
408
|
+
productName: input.productName ?? null,
|
|
409
|
+
orderLineId: input.orderLineId ?? null,
|
|
410
|
+
serialNumber: input.serialNumber ?? null,
|
|
411
|
+
lotNumber: input.lotNumber ?? null,
|
|
412
|
+
purchaseDate,
|
|
413
|
+
warrantyMonths,
|
|
414
|
+
warrantyExpiresAt: input.warrantyExpiresAt ?? computedWarranty.warrantyExpiresAt,
|
|
415
|
+
warrantyStatus: input.warrantyStatus ?? computedWarranty.warrantyStatus,
|
|
416
|
+
faultCode: input.faultCode ?? null,
|
|
417
|
+
faultDescription: input.faultDescription ?? null,
|
|
418
|
+
qtyClaimed: amountString(input.qtyClaimed, '1') ?? '1',
|
|
419
|
+
qtyApproved: nullableAmountString(input.qtyApproved),
|
|
420
|
+
qtyReceived: nullableAmountString(input.qtyReceived),
|
|
421
|
+
conditionOnReceipt: input.conditionOnReceipt ?? null,
|
|
422
|
+
inspectionNotes: input.inspectionNotes ?? null,
|
|
423
|
+
disposition: input.disposition ?? null,
|
|
424
|
+
vendorName: input.vendorName ?? null,
|
|
425
|
+
lineStatus: 'pending',
|
|
426
|
+
creditAmount: nullableAmountString(input.creditAmount),
|
|
427
|
+
restockingFee: nullableAmountString(input.restockingFee),
|
|
428
|
+
coreChargeAmount: nullableAmountString(input.coreChargeAmount),
|
|
429
|
+
coreCreditAmount: nullableAmountString(input.coreCreditAmount),
|
|
430
|
+
createdAt: new Date(),
|
|
431
|
+
updatedAt: new Date(),
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function snapshotLine(line: WarrantyClaimLine): ClaimLineSnapshot {
|
|
436
|
+
return {
|
|
437
|
+
id: line.id,
|
|
438
|
+
lineNo: line.lineNo,
|
|
439
|
+
productId: line.productId ?? null,
|
|
440
|
+
variantId: line.variantId ?? null,
|
|
441
|
+
sku: line.sku ?? null,
|
|
442
|
+
productName: line.productName ?? null,
|
|
443
|
+
orderLineId: line.orderLineId ?? null,
|
|
444
|
+
serialNumber: line.serialNumber ?? null,
|
|
445
|
+
lotNumber: line.lotNumber ?? null,
|
|
446
|
+
purchaseDate: toDateOnlyIso(line.purchaseDate),
|
|
447
|
+
warrantyMonths: line.warrantyMonths ?? null,
|
|
448
|
+
warrantyExpiresAt: toDateOnlyIso(line.warrantyExpiresAt),
|
|
449
|
+
warrantyStatus: line.warrantyStatus,
|
|
450
|
+
faultCode: line.faultCode ?? null,
|
|
451
|
+
faultDescription: line.faultDescription ?? null,
|
|
452
|
+
qtyClaimed: line.qtyClaimed,
|
|
453
|
+
qtyApproved: line.qtyApproved ?? null,
|
|
454
|
+
qtyReceived: line.qtyReceived ?? null,
|
|
455
|
+
conditionOnReceipt: line.conditionOnReceipt ?? null,
|
|
456
|
+
conditionGrade: line.conditionGrade ?? null,
|
|
457
|
+
quarantineStatus: line.quarantineStatus,
|
|
458
|
+
inspectionNotes: line.inspectionNotes ?? null,
|
|
459
|
+
assessmentPayload: line.assessmentPayload ?? null,
|
|
460
|
+
disposition: line.disposition ?? null,
|
|
461
|
+
lineStatus: line.lineStatus,
|
|
462
|
+
creditAmount: line.creditAmount ?? null,
|
|
463
|
+
restockingFee: line.restockingFee ?? null,
|
|
464
|
+
coreChargeAmount: line.coreChargeAmount ?? null,
|
|
465
|
+
coreCreditAmount: line.coreCreditAmount ?? null,
|
|
466
|
+
vendorClaimLineId: line.vendorClaimLineId ?? null,
|
|
467
|
+
vendorName: line.vendorName ?? null,
|
|
468
|
+
deletedAt: toIso(line.deletedAt),
|
|
469
|
+
updatedAt: toIso(line.updatedAt),
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function snapshotClaim(claim: WarrantyClaim, lines: readonly WarrantyClaimLine[]): ClaimSnapshot {
|
|
474
|
+
return {
|
|
475
|
+
id: claim.id,
|
|
476
|
+
organizationId: claim.organizationId,
|
|
477
|
+
tenantId: claim.tenantId,
|
|
478
|
+
claimNumber: claim.claimNumber,
|
|
479
|
+
claimType: claim.claimType,
|
|
480
|
+
status: claim.status,
|
|
481
|
+
channel: claim.channel,
|
|
482
|
+
priority: claim.priority,
|
|
483
|
+
customerId: claim.customerId ?? null,
|
|
484
|
+
customerName: claim.customerName ?? null,
|
|
485
|
+
externalRef: claim.externalRef ?? null,
|
|
486
|
+
intakeMessageRef: claim.intakeMessageRef ?? null,
|
|
487
|
+
entitlementSource: claim.entitlementSource ?? null,
|
|
488
|
+
contactEmail: claim.contactEmail ?? null,
|
|
489
|
+
returnLabelUrl: claim.returnLabelUrl ?? null,
|
|
490
|
+
returnTrackingNumber: claim.returnTrackingNumber ?? null,
|
|
491
|
+
returnCarrier: claim.returnCarrier ?? null,
|
|
492
|
+
vendorName: claim.vendorName ?? null,
|
|
493
|
+
vendorRef: claim.vendorRef ?? null,
|
|
494
|
+
orderId: claim.orderId ?? null,
|
|
495
|
+
orderNumber: claim.orderNumber ?? null,
|
|
496
|
+
awaitingStaffReply: claim.awaitingStaffReply === true,
|
|
497
|
+
salesReturnId: claim.salesReturnId ?? null,
|
|
498
|
+
replacementOrderId: claim.replacementOrderId ?? null,
|
|
499
|
+
creditMemoId: claim.creditMemoId ?? null,
|
|
500
|
+
sourceClaimId: claim.sourceClaimId ?? null,
|
|
501
|
+
advanceReplacement: claim.advanceReplacement,
|
|
502
|
+
advanceShippedAt: toIso(claim.advanceShippedAt),
|
|
503
|
+
reasonCode: claim.reasonCode ?? null,
|
|
504
|
+
rejectionReasonCode: claim.rejectionReasonCode ?? null,
|
|
505
|
+
resolutionSummary: claim.resolutionSummary ?? null,
|
|
506
|
+
notes: claim.notes ?? null,
|
|
507
|
+
currencyCode: claim.currencyCode ?? null,
|
|
508
|
+
totalClaimedAmount: claim.totalClaimedAmount ?? null,
|
|
509
|
+
totalApprovedAmount: claim.totalApprovedAmount ?? null,
|
|
510
|
+
totalRecoveredAmount: claim.totalRecoveredAmount ?? null,
|
|
511
|
+
escalationLevel: claim.escalationLevel ?? 0,
|
|
512
|
+
escalatedAt: toIso(claim.escalatedAt),
|
|
513
|
+
slaDueAt: toIso(claim.slaDueAt),
|
|
514
|
+
slaPausedAt: toIso(claim.slaPausedAt),
|
|
515
|
+
slaAtRiskNotifiedAt: toIso(claim.slaAtRiskNotifiedAt),
|
|
516
|
+
slaBreachedNotifiedAt: toIso(claim.slaBreachedNotifiedAt),
|
|
517
|
+
submittedAt: toIso(claim.submittedAt),
|
|
518
|
+
resolvedAt: toIso(claim.resolvedAt),
|
|
519
|
+
closedAt: toIso(claim.closedAt),
|
|
520
|
+
assigneeUserId: claim.assigneeUserId ?? null,
|
|
521
|
+
deletedAt: toIso(claim.deletedAt),
|
|
522
|
+
updatedAt: toIso(claim.updatedAt),
|
|
523
|
+
lines: lines.map(snapshotLine),
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function loadClaimSnapshot(
|
|
528
|
+
em: EntityManager,
|
|
529
|
+
claimId: string,
|
|
530
|
+
scope: WarrantyClaimScope,
|
|
531
|
+
): Promise<ClaimSnapshot | null> {
|
|
532
|
+
const claim = await findOneWithDecryption(em, WarrantyClaim, { id: claimId, tenantId: scope.tenantId, organizationId: scope.organizationId }, {}, scope)
|
|
533
|
+
if (!claim) return null
|
|
534
|
+
const lines = await findWithDecryption(
|
|
535
|
+
em,
|
|
536
|
+
WarrantyClaimLine,
|
|
537
|
+
{ claim: claim.id, tenantId: scope.tenantId, organizationId: scope.organizationId },
|
|
538
|
+
{},
|
|
539
|
+
scope,
|
|
540
|
+
)
|
|
541
|
+
return snapshotClaim(claim, lines)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function snapshotValueChanged(before: unknown, after: unknown): boolean {
|
|
545
|
+
return JSON.stringify(before) !== JSON.stringify(after)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function restoreClaimDelta(claim: WarrantyClaim, before: ClaimSnapshot, after: ClaimSnapshot): void {
|
|
549
|
+
if (before.claimNumber !== after.claimNumber) claim.claimNumber = before.claimNumber
|
|
550
|
+
if (before.claimType !== after.claimType) claim.claimType = before.claimType
|
|
551
|
+
if (before.status !== after.status) claim.status = before.status
|
|
552
|
+
if (before.channel !== after.channel) claim.channel = before.channel
|
|
553
|
+
if (before.priority !== after.priority) claim.priority = before.priority
|
|
554
|
+
if (before.customerId !== after.customerId) claim.customerId = before.customerId
|
|
555
|
+
if (before.customerName !== after.customerName) claim.customerName = before.customerName
|
|
556
|
+
if (before.externalRef !== after.externalRef) claim.externalRef = before.externalRef
|
|
557
|
+
if (before.intakeMessageRef !== after.intakeMessageRef) claim.intakeMessageRef = before.intakeMessageRef
|
|
558
|
+
if (before.entitlementSource !== after.entitlementSource) claim.entitlementSource = before.entitlementSource
|
|
559
|
+
if (before.contactEmail !== after.contactEmail) claim.contactEmail = before.contactEmail
|
|
560
|
+
if (before.returnLabelUrl !== after.returnLabelUrl) claim.returnLabelUrl = before.returnLabelUrl
|
|
561
|
+
if (before.returnTrackingNumber !== after.returnTrackingNumber) claim.returnTrackingNumber = before.returnTrackingNumber
|
|
562
|
+
if (before.returnCarrier !== after.returnCarrier) claim.returnCarrier = before.returnCarrier
|
|
563
|
+
if (before.vendorName !== after.vendorName) claim.vendorName = before.vendorName
|
|
564
|
+
if (before.vendorRef !== after.vendorRef) claim.vendorRef = before.vendorRef
|
|
565
|
+
if (before.orderId !== after.orderId) claim.orderId = before.orderId
|
|
566
|
+
if (before.orderNumber !== after.orderNumber) claim.orderNumber = before.orderNumber
|
|
567
|
+
if (before.awaitingStaffReply !== after.awaitingStaffReply) claim.awaitingStaffReply = before.awaitingStaffReply === true
|
|
568
|
+
if (before.salesReturnId !== after.salesReturnId) claim.salesReturnId = before.salesReturnId
|
|
569
|
+
if (before.replacementOrderId !== after.replacementOrderId) claim.replacementOrderId = before.replacementOrderId
|
|
570
|
+
if (before.creditMemoId !== after.creditMemoId) claim.creditMemoId = before.creditMemoId
|
|
571
|
+
if (before.sourceClaimId !== after.sourceClaimId) claim.sourceClaimId = before.sourceClaimId
|
|
572
|
+
if (before.advanceReplacement !== after.advanceReplacement) claim.advanceReplacement = before.advanceReplacement
|
|
573
|
+
if (before.advanceShippedAt !== after.advanceShippedAt) claim.advanceShippedAt = toDate(before.advanceShippedAt)
|
|
574
|
+
if (before.reasonCode !== after.reasonCode) claim.reasonCode = before.reasonCode
|
|
575
|
+
if (before.rejectionReasonCode !== after.rejectionReasonCode) claim.rejectionReasonCode = before.rejectionReasonCode
|
|
576
|
+
if (before.resolutionSummary !== after.resolutionSummary) claim.resolutionSummary = before.resolutionSummary
|
|
577
|
+
if (before.notes !== after.notes) claim.notes = before.notes
|
|
578
|
+
if (before.currencyCode !== after.currencyCode) claim.currencyCode = before.currencyCode
|
|
579
|
+
if (before.totalClaimedAmount !== after.totalClaimedAmount) claim.totalClaimedAmount = before.totalClaimedAmount
|
|
580
|
+
if (before.totalApprovedAmount !== after.totalApprovedAmount) claim.totalApprovedAmount = before.totalApprovedAmount
|
|
581
|
+
if (before.totalRecoveredAmount !== after.totalRecoveredAmount) claim.totalRecoveredAmount = before.totalRecoveredAmount
|
|
582
|
+
if (before.escalationLevel !== after.escalationLevel) claim.escalationLevel = before.escalationLevel
|
|
583
|
+
if (before.escalatedAt !== after.escalatedAt) claim.escalatedAt = toDate(before.escalatedAt)
|
|
584
|
+
if (before.slaDueAt !== after.slaDueAt) claim.slaDueAt = toDate(before.slaDueAt)
|
|
585
|
+
if (before.slaPausedAt !== after.slaPausedAt) claim.slaPausedAt = toDate(before.slaPausedAt)
|
|
586
|
+
if (before.slaAtRiskNotifiedAt !== after.slaAtRiskNotifiedAt) claim.slaAtRiskNotifiedAt = toDate(before.slaAtRiskNotifiedAt)
|
|
587
|
+
if (before.slaBreachedNotifiedAt !== after.slaBreachedNotifiedAt) claim.slaBreachedNotifiedAt = toDate(before.slaBreachedNotifiedAt)
|
|
588
|
+
if (before.submittedAt !== after.submittedAt) claim.submittedAt = toDate(before.submittedAt)
|
|
589
|
+
if (before.resolvedAt !== after.resolvedAt) claim.resolvedAt = toDate(before.resolvedAt)
|
|
590
|
+
if (before.closedAt !== after.closedAt) claim.closedAt = toDate(before.closedAt)
|
|
591
|
+
if (before.assigneeUserId !== after.assigneeUserId) claim.assigneeUserId = before.assigneeUserId
|
|
592
|
+
if (before.deletedAt !== after.deletedAt) claim.deletedAt = toDate(before.deletedAt)
|
|
593
|
+
claim.updatedAt = new Date()
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function restoreLineDelta(line: WarrantyClaimLine, before: ClaimLineSnapshot, after: ClaimLineSnapshot): void {
|
|
597
|
+
if (before.lineNo !== after.lineNo) line.lineNo = before.lineNo
|
|
598
|
+
if (before.productId !== after.productId) line.productId = before.productId
|
|
599
|
+
if (before.variantId !== after.variantId) line.variantId = before.variantId
|
|
600
|
+
if (before.sku !== after.sku) line.sku = before.sku
|
|
601
|
+
if (before.productName !== after.productName) line.productName = before.productName
|
|
602
|
+
if (before.orderLineId !== after.orderLineId) line.orderLineId = before.orderLineId
|
|
603
|
+
if (before.serialNumber !== after.serialNumber) line.serialNumber = before.serialNumber
|
|
604
|
+
if (before.lotNumber !== after.lotNumber) line.lotNumber = before.lotNumber
|
|
605
|
+
if (before.purchaseDate !== after.purchaseDate) line.purchaseDate = toDateOnly(before.purchaseDate)
|
|
606
|
+
if (before.warrantyMonths !== after.warrantyMonths) line.warrantyMonths = before.warrantyMonths
|
|
607
|
+
if (before.warrantyExpiresAt !== after.warrantyExpiresAt) line.warrantyExpiresAt = toDateOnly(before.warrantyExpiresAt)
|
|
608
|
+
if (before.warrantyStatus !== after.warrantyStatus) line.warrantyStatus = before.warrantyStatus
|
|
609
|
+
if (before.faultCode !== after.faultCode) line.faultCode = before.faultCode
|
|
610
|
+
if (before.faultDescription !== after.faultDescription) line.faultDescription = before.faultDescription
|
|
611
|
+
if (before.qtyClaimed !== after.qtyClaimed) line.qtyClaimed = before.qtyClaimed
|
|
612
|
+
if (before.qtyApproved !== after.qtyApproved) line.qtyApproved = before.qtyApproved
|
|
613
|
+
if (before.qtyReceived !== after.qtyReceived) line.qtyReceived = before.qtyReceived
|
|
614
|
+
if (before.conditionOnReceipt !== after.conditionOnReceipt) line.conditionOnReceipt = before.conditionOnReceipt
|
|
615
|
+
if (before.conditionGrade !== after.conditionGrade) line.conditionGrade = before.conditionGrade
|
|
616
|
+
if (before.quarantineStatus !== after.quarantineStatus) line.quarantineStatus = before.quarantineStatus
|
|
617
|
+
if (before.inspectionNotes !== after.inspectionNotes) line.inspectionNotes = before.inspectionNotes
|
|
618
|
+
if (snapshotValueChanged(before.assessmentPayload, after.assessmentPayload)) line.assessmentPayload = before.assessmentPayload
|
|
619
|
+
if (before.disposition !== after.disposition) line.disposition = before.disposition
|
|
620
|
+
if (before.lineStatus !== after.lineStatus) line.lineStatus = before.lineStatus
|
|
621
|
+
if (before.creditAmount !== after.creditAmount) line.creditAmount = before.creditAmount
|
|
622
|
+
if (before.restockingFee !== after.restockingFee) line.restockingFee = before.restockingFee
|
|
623
|
+
if (before.coreChargeAmount !== after.coreChargeAmount) line.coreChargeAmount = before.coreChargeAmount
|
|
624
|
+
if (before.coreCreditAmount !== after.coreCreditAmount) line.coreCreditAmount = before.coreCreditAmount
|
|
625
|
+
if (before.vendorClaimLineId !== after.vendorClaimLineId) line.vendorClaimLineId = before.vendorClaimLineId
|
|
626
|
+
if (before.vendorName !== after.vendorName) line.vendorName = before.vendorName
|
|
627
|
+
if (before.deletedAt !== after.deletedAt) line.deletedAt = toDate(before.deletedAt)
|
|
628
|
+
line.updatedAt = new Date()
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function throwClaimUndoConflict(): never {
|
|
632
|
+
throw new CrudHttpError(409, { error: 'warranty_claims.errors.conflict' })
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async function requireCurrentUndoSnapshot(
|
|
636
|
+
em: EntityManager,
|
|
637
|
+
after: ClaimSnapshot,
|
|
638
|
+
): Promise<{ claim: WarrantyClaim; lines: WarrantyClaimLine[] }> {
|
|
639
|
+
const scope = { tenantId: after.tenantId, organizationId: after.organizationId }
|
|
640
|
+
const claim = await findOneWithDecryption(
|
|
641
|
+
em,
|
|
642
|
+
WarrantyClaim,
|
|
643
|
+
{ id: after.id, tenantId: scope.tenantId, organizationId: scope.organizationId },
|
|
644
|
+
{ lockMode: LockMode.PESSIMISTIC_WRITE },
|
|
645
|
+
scope,
|
|
646
|
+
)
|
|
647
|
+
const expectedClaimUpdatedAt = toIso(after.updatedAt)
|
|
648
|
+
if (!claim || !expectedClaimUpdatedAt || toIso(claim.updatedAt) !== expectedClaimUpdatedAt) throwClaimUndoConflict()
|
|
649
|
+
const lines = await findWithDecryption(
|
|
650
|
+
em,
|
|
651
|
+
WarrantyClaimLine,
|
|
652
|
+
{ claim: after.id, tenantId: scope.tenantId, organizationId: scope.organizationId },
|
|
653
|
+
{ lockMode: LockMode.PESSIMISTIC_WRITE },
|
|
654
|
+
scope,
|
|
655
|
+
)
|
|
656
|
+
const afterLines = Array.isArray(after.lines) ? after.lines : []
|
|
657
|
+
if (lines.length !== afterLines.length) throwClaimUndoConflict()
|
|
658
|
+
const afterLinesById = new Map(afterLines.map((line) => [line.id, line]))
|
|
659
|
+
for (const line of lines) {
|
|
660
|
+
const expected = afterLinesById.get(line.id)
|
|
661
|
+
const expectedLineUpdatedAt = toIso(expected?.updatedAt)
|
|
662
|
+
if (!expectedLineUpdatedAt || toIso(line.updatedAt) !== expectedLineUpdatedAt) throwClaimUndoConflict()
|
|
663
|
+
}
|
|
664
|
+
return { claim, lines }
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async function assertUndoSnapshotCurrent(ctx: CommandRuntimeContext, after: ClaimSnapshot): Promise<void> {
|
|
668
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
669
|
+
await em.transactional(async (tx) => {
|
|
670
|
+
await requireCurrentUndoSnapshot(tx, after)
|
|
671
|
+
})
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
async function restoreSnapshot(em: EntityManager, before: ClaimSnapshot, after: ClaimSnapshot): Promise<WarrantyClaim> {
|
|
675
|
+
const { claim, lines } = await requireCurrentUndoSnapshot(em, after)
|
|
676
|
+
restoreClaimDelta(claim, before, after)
|
|
677
|
+
const beforeLinesById = new Map((Array.isArray(before.lines) ? before.lines : []).map((line) => [line.id, line]))
|
|
678
|
+
const afterLinesById = new Map((Array.isArray(after.lines) ? after.lines : []).map((line) => [line.id, line]))
|
|
679
|
+
for (const line of lines) {
|
|
680
|
+
const beforeLine = beforeLinesById.get(line.id)
|
|
681
|
+
const afterLine = afterLinesById.get(line.id)
|
|
682
|
+
if (!beforeLine || !afterLine) throwClaimUndoConflict()
|
|
683
|
+
if (snapshotValueChanged(beforeLine, afterLine)) restoreLineDelta(line, beforeLine, afterLine)
|
|
684
|
+
}
|
|
685
|
+
return claim
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function claimEventPayload(claim: WarrantyClaim): ClaimEventPayload {
|
|
689
|
+
return {
|
|
690
|
+
id: claim.id,
|
|
691
|
+
claimId: claim.id,
|
|
692
|
+
claimNumber: claim.claimNumber,
|
|
693
|
+
externalRef: claim.externalRef ?? null,
|
|
694
|
+
claimType: claim.claimType,
|
|
695
|
+
status: claim.status,
|
|
696
|
+
customerId: claim.customerId ?? null,
|
|
697
|
+
organizationId: claim.organizationId,
|
|
698
|
+
tenantId: claim.tenantId,
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function claimPersistentEmitOptions(claim: WarrantyClaim) {
|
|
703
|
+
return {
|
|
704
|
+
persistent: true,
|
|
705
|
+
tenantId: claim.tenantId,
|
|
706
|
+
organizationId: claim.organizationId,
|
|
707
|
+
} as const
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
async function emitClaimCrud(
|
|
711
|
+
ctx: CommandRuntimeContext,
|
|
712
|
+
action: 'created' | 'updated' | 'deleted',
|
|
713
|
+
claim: WarrantyClaim,
|
|
714
|
+
): Promise<void> {
|
|
715
|
+
const dataEngine = ctx.container.resolve('dataEngine') as DataEngine
|
|
716
|
+
await emitCrudSideEffects({
|
|
717
|
+
dataEngine,
|
|
718
|
+
action,
|
|
719
|
+
entity: claim,
|
|
720
|
+
identifiers: { id: claim.id, organizationId: claim.organizationId, tenantId: claim.tenantId },
|
|
721
|
+
indexer: { entityType: E.warranty_claims.warranty_claim },
|
|
722
|
+
events: claimCrudEvents,
|
|
723
|
+
})
|
|
724
|
+
await invalidateCrudCache(
|
|
725
|
+
ctx.container,
|
|
726
|
+
'warranty_claims.claim',
|
|
727
|
+
{ id: claim.id, organizationId: claim.organizationId, tenantId: claim.tenantId },
|
|
728
|
+
ctx.auth?.tenantId ?? null,
|
|
729
|
+
`warranty_claims.claim.${action}`,
|
|
730
|
+
)
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
async function emitClaimUndoCrud(
|
|
734
|
+
ctx: CommandRuntimeContext,
|
|
735
|
+
action: 'created' | 'updated' | 'deleted',
|
|
736
|
+
claim: WarrantyClaim,
|
|
737
|
+
): Promise<void> {
|
|
738
|
+
const dataEngine = ctx.container.resolve('dataEngine') as DataEngine
|
|
739
|
+
await emitCrudUndoSideEffects({
|
|
740
|
+
dataEngine,
|
|
741
|
+
action,
|
|
742
|
+
entity: claim,
|
|
743
|
+
identifiers: { id: claim.id, organizationId: claim.organizationId, tenantId: claim.tenantId },
|
|
744
|
+
indexer: { entityType: E.warranty_claims.warranty_claim },
|
|
745
|
+
events: claimCrudEvents,
|
|
746
|
+
})
|
|
747
|
+
await invalidateCrudCache(
|
|
748
|
+
ctx.container,
|
|
749
|
+
'warranty_claims.claim',
|
|
750
|
+
{ id: claim.id, organizationId: claim.organizationId, tenantId: claim.tenantId },
|
|
751
|
+
ctx.auth?.tenantId ?? null,
|
|
752
|
+
`warranty_claims.claim.undo.${action}`,
|
|
753
|
+
)
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
async function emitLineCrud(
|
|
757
|
+
ctx: CommandRuntimeContext,
|
|
758
|
+
action: 'created' | 'updated' | 'deleted',
|
|
759
|
+
line: WarrantyClaimLine,
|
|
760
|
+
): Promise<void> {
|
|
761
|
+
const dataEngine = ctx.container.resolve('dataEngine') as DataEngine
|
|
762
|
+
await emitCrudSideEffects({
|
|
763
|
+
dataEngine,
|
|
764
|
+
action,
|
|
765
|
+
entity: line,
|
|
766
|
+
identifiers: { id: line.id, organizationId: line.organizationId, tenantId: line.tenantId },
|
|
767
|
+
indexer: { entityType: E.warranty_claims.warranty_claim_line },
|
|
768
|
+
})
|
|
769
|
+
await invalidateCrudCache(
|
|
770
|
+
ctx.container,
|
|
771
|
+
'warranty_claims.claim_line',
|
|
772
|
+
{ id: line.id, organizationId: line.organizationId, tenantId: line.tenantId },
|
|
773
|
+
ctx.auth?.tenantId ?? null,
|
|
774
|
+
`warranty_claims.claim_line.${action}`,
|
|
775
|
+
)
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
async function emitLineUndoCrud(
|
|
779
|
+
ctx: CommandRuntimeContext,
|
|
780
|
+
action: 'created' | 'updated' | 'deleted',
|
|
781
|
+
line: WarrantyClaimLine,
|
|
782
|
+
): Promise<void> {
|
|
783
|
+
const dataEngine = ctx.container.resolve('dataEngine') as DataEngine
|
|
784
|
+
await emitCrudUndoSideEffects({
|
|
785
|
+
dataEngine,
|
|
786
|
+
action,
|
|
787
|
+
entity: line,
|
|
788
|
+
identifiers: { id: line.id, organizationId: line.organizationId, tenantId: line.tenantId },
|
|
789
|
+
indexer: { entityType: E.warranty_claims.warranty_claim_line },
|
|
790
|
+
})
|
|
791
|
+
await invalidateCrudCache(
|
|
792
|
+
ctx.container,
|
|
793
|
+
'warranty_claims.claim_line',
|
|
794
|
+
{ id: line.id, organizationId: line.organizationId, tenantId: line.tenantId },
|
|
795
|
+
ctx.auth?.tenantId ?? null,
|
|
796
|
+
`warranty_claims.claim_line.undo.${action}`,
|
|
797
|
+
)
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function resolveClaimNumberGenerator(ctx: CommandRuntimeContext, em: EntityManager): WarrantyClaimNumberGenerator {
|
|
801
|
+
try {
|
|
802
|
+
return ctx.container.resolve('warrantyClaimNumberGenerator') as WarrantyClaimNumberGenerator
|
|
803
|
+
} catch {
|
|
804
|
+
return new WarrantyClaimNumberGenerator(em)
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function readCustomerName(row: Record<string, unknown>): string | null {
|
|
809
|
+
const candidates = [row.display_name, row.displayName, row.name, row.label]
|
|
810
|
+
for (const candidate of candidates) {
|
|
811
|
+
if (typeof candidate === 'string' && candidate.trim().length > 0) return candidate.trim()
|
|
812
|
+
}
|
|
813
|
+
return null
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function readString(row: Record<string, unknown>, key: string): string | null {
|
|
817
|
+
const value = row[key]
|
|
818
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
type SalesReferenceTable = 'sales_orders' | 'sales_returns' | 'sales_credit_memos' | 'sales_order_lines'
|
|
822
|
+
|
|
823
|
+
type SalesReferenceDb = {
|
|
824
|
+
sales_orders: {
|
|
825
|
+
id: string
|
|
826
|
+
currency_code: string
|
|
827
|
+
customer_entity_id: string | null
|
|
828
|
+
customer_contact_id: string | null
|
|
829
|
+
billing_address_id: string | null
|
|
830
|
+
shipping_address_id: string | null
|
|
831
|
+
channel_id: string | null
|
|
832
|
+
updated_at: Date | string | null
|
|
833
|
+
tenant_id: string | null
|
|
834
|
+
organization_id: string | null
|
|
835
|
+
deleted_at: Date | null
|
|
836
|
+
}
|
|
837
|
+
sales_returns: {
|
|
838
|
+
id: string
|
|
839
|
+
updated_at: Date | string | null
|
|
840
|
+
tenant_id: string | null
|
|
841
|
+
organization_id: string | null
|
|
842
|
+
deleted_at: Date | null
|
|
843
|
+
}
|
|
844
|
+
sales_credit_memos: {
|
|
845
|
+
id: string
|
|
846
|
+
updated_at: Date | string | null
|
|
847
|
+
tenant_id: string | null
|
|
848
|
+
organization_id: string | null
|
|
849
|
+
deleted_at: Date | null
|
|
850
|
+
}
|
|
851
|
+
sales_order_lines: {
|
|
852
|
+
id: string
|
|
853
|
+
order_id: string
|
|
854
|
+
product_id: string | null
|
|
855
|
+
product_variant_id: string | null
|
|
856
|
+
name: string | null
|
|
857
|
+
kind: 'product' | 'service' | 'shipping' | 'discount' | 'adjustment'
|
|
858
|
+
currency_code: string
|
|
859
|
+
quantity: string | null
|
|
860
|
+
unit_price_net: string
|
|
861
|
+
unit_price_gross: string
|
|
862
|
+
tax_rate: string
|
|
863
|
+
total_net_amount: string
|
|
864
|
+
total_gross_amount: string
|
|
865
|
+
tenant_id: string | null
|
|
866
|
+
organization_id: string | null
|
|
867
|
+
deleted_at: Date | null
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
type ClaimedQuantityDb = SalesReferenceDb & {
|
|
872
|
+
warranty_claims: {
|
|
873
|
+
id: string
|
|
874
|
+
status: string
|
|
875
|
+
tenant_id: string
|
|
876
|
+
organization_id: string
|
|
877
|
+
deleted_at: Date | null
|
|
878
|
+
}
|
|
879
|
+
warranty_claim_lines: {
|
|
880
|
+
id: string
|
|
881
|
+
claim_id: string
|
|
882
|
+
order_line_id: string | null
|
|
883
|
+
qty_claimed: string | null
|
|
884
|
+
line_status: string
|
|
885
|
+
tenant_id: string
|
|
886
|
+
organization_id: string
|
|
887
|
+
deleted_at: Date | null
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
export type ClaimedQuantityLine = {
|
|
892
|
+
id?: string | null
|
|
893
|
+
orderLineId?: string | null
|
|
894
|
+
qtyClaimed: string | number
|
|
895
|
+
deletedAt?: Date | string | null
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function isMissingReferenceTableError(err: unknown): boolean {
|
|
899
|
+
if (typeof err !== 'object' || err === null) return false
|
|
900
|
+
const candidate = err as { code?: unknown; message?: unknown }
|
|
901
|
+
return candidate.code === '42P01'
|
|
902
|
+
|| (typeof candidate.message === 'string' && candidate.message.includes('does not exist'))
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
async function querySalesReferenceRow(
|
|
906
|
+
ctx: CommandRuntimeContext,
|
|
907
|
+
table: SalesReferenceTable,
|
|
908
|
+
scope: WarrantyClaimScope,
|
|
909
|
+
id: string,
|
|
910
|
+
select: string[],
|
|
911
|
+
): Promise<Record<string, unknown> | null | undefined> {
|
|
912
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
913
|
+
const db = em.getKysely<SalesReferenceDb>()
|
|
914
|
+
try {
|
|
915
|
+
const row = await db
|
|
916
|
+
.selectFrom(table)
|
|
917
|
+
.select(select as never[])
|
|
918
|
+
.where('id' as never, '=', id as never)
|
|
919
|
+
.where('tenant_id' as never, '=', scope.tenantId as never)
|
|
920
|
+
.where('organization_id' as never, '=', scope.organizationId as never)
|
|
921
|
+
.where('deleted_at' as never, 'is', null)
|
|
922
|
+
.executeTakeFirst()
|
|
923
|
+
return (row as Record<string, unknown> | undefined) ?? null
|
|
924
|
+
} catch (err) {
|
|
925
|
+
if (isMissingReferenceTableError(err)) return undefined
|
|
926
|
+
throw err
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
async function querySalesOrderLineReferences(
|
|
931
|
+
ctx: CommandRuntimeContext,
|
|
932
|
+
scope: WarrantyClaimScope,
|
|
933
|
+
ids: string[],
|
|
934
|
+
select: string[] = ['id', 'order_id'],
|
|
935
|
+
): Promise<Record<string, unknown>[] | undefined> {
|
|
936
|
+
if (ids.length === 0) return []
|
|
937
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
938
|
+
const db = em.getKysely<SalesReferenceDb>()
|
|
939
|
+
try {
|
|
940
|
+
const rows = await db
|
|
941
|
+
.selectFrom('sales_order_lines')
|
|
942
|
+
.select(select as never[])
|
|
943
|
+
.where('id', 'in', ids)
|
|
944
|
+
.where('tenant_id', '=', scope.tenantId)
|
|
945
|
+
.where('organization_id', '=', scope.organizationId)
|
|
946
|
+
.where('deleted_at', 'is', null)
|
|
947
|
+
.execute()
|
|
948
|
+
return rows as Record<string, unknown>[]
|
|
949
|
+
} catch (err) {
|
|
950
|
+
if (isMissingReferenceTableError(err)) return undefined
|
|
951
|
+
throw err
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const CLAIMED_QUANTITY_SCALE = 10_000n
|
|
956
|
+
const TAX_RATE_FORMULA_SCALE = 1_000_000n
|
|
957
|
+
|
|
958
|
+
function claimedQuantityUnits(value: string | number | null | undefined): bigint | null {
|
|
959
|
+
if (value === null || value === undefined) return null
|
|
960
|
+
let normalized = String(value).trim()
|
|
961
|
+
if (/e/i.test(normalized)) {
|
|
962
|
+
const numeric = Number(normalized)
|
|
963
|
+
if (!Number.isFinite(numeric)) return null
|
|
964
|
+
normalized = numeric.toFixed(4)
|
|
965
|
+
}
|
|
966
|
+
const match = /^([+-]?)(\d+)(?:\.(\d+))?$/.exec(normalized)
|
|
967
|
+
if (!match) return null
|
|
968
|
+
const [, sign, whole, fraction = ''] = match
|
|
969
|
+
const scaledFraction = fraction.slice(0, 4).padEnd(4, '0')
|
|
970
|
+
let units = BigInt(whole) * CLAIMED_QUANTITY_SCALE + BigInt(scaledFraction)
|
|
971
|
+
if (fraction.length > 4 && fraction[4] >= '5') units += 1n
|
|
972
|
+
return sign === '-' ? -units : units
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function roundHalfUpDivision(numerator: bigint, denominator: bigint): bigint {
|
|
976
|
+
if (denominator <= 0n) throw new Error('[internal] Positive denominator required')
|
|
977
|
+
if (numerator < 0n) return -roundHalfUpDivision(-numerator, denominator)
|
|
978
|
+
return (numerator + denominator / 2n) / denominator
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function formatScaledUnits(units: bigint): string {
|
|
982
|
+
const sign = units < 0n ? '-' : ''
|
|
983
|
+
const absolute = units < 0n ? -units : units
|
|
984
|
+
const whole = absolute / CLAIMED_QUANTITY_SCALE
|
|
985
|
+
const fraction = String(absolute % CLAIMED_QUANTITY_SCALE).padStart(4, '0')
|
|
986
|
+
return `${sign}${whole}.${fraction}`
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function netUnitsFromGross(grossUnits: bigint, taxRateUnits: bigint): bigint {
|
|
990
|
+
const denominator = TAX_RATE_FORMULA_SCALE + taxRateUnits
|
|
991
|
+
return roundHalfUpDivision(grossUnits * TAX_RATE_FORMULA_SCALE, denominator)
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
export async function assertClaimedQtyWithinSold(
|
|
995
|
+
ctx: CommandRuntimeContext,
|
|
996
|
+
scope: WarrantyClaimScope,
|
|
997
|
+
claimIdOrPendingLines: string | readonly ClaimedQuantityLine[],
|
|
998
|
+
candidateLine: ClaimedQuantityLine,
|
|
999
|
+
): Promise<void> {
|
|
1000
|
+
const orderLineId = candidateLine.orderLineId ?? null
|
|
1001
|
+
if (!orderLineId || !resolveOptionalEntityId('sales', 'sales_order_line')) return
|
|
1002
|
+
|
|
1003
|
+
const salesLine = await querySalesReferenceRow(ctx, 'sales_order_lines', scope, orderLineId, ['id', 'quantity'])
|
|
1004
|
+
const soldQuantity = claimedQuantityUnits(salesLine?.quantity as string | number | null | undefined)
|
|
1005
|
+
if (salesLine === undefined || salesLine === null || soldQuantity === null) return
|
|
1006
|
+
|
|
1007
|
+
let lines: readonly ClaimedQuantityLine[]
|
|
1008
|
+
if (typeof claimIdOrPendingLines === 'string') {
|
|
1009
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
1010
|
+
lines = await findWithDecryption(
|
|
1011
|
+
em,
|
|
1012
|
+
WarrantyClaimLine,
|
|
1013
|
+
{
|
|
1014
|
+
claim: claimIdOrPendingLines,
|
|
1015
|
+
orderLineId,
|
|
1016
|
+
tenantId: scope.tenantId,
|
|
1017
|
+
organizationId: scope.organizationId,
|
|
1018
|
+
deletedAt: null,
|
|
1019
|
+
},
|
|
1020
|
+
{},
|
|
1021
|
+
scope,
|
|
1022
|
+
)
|
|
1023
|
+
} else {
|
|
1024
|
+
lines = claimIdOrPendingLines
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
let claimedQuantity = claimedQuantityUnits(candidateLine.qtyClaimed) ?? 0n
|
|
1028
|
+
for (const line of lines) {
|
|
1029
|
+
if (line.deletedAt || (line.orderLineId ?? null) !== orderLineId) continue
|
|
1030
|
+
if (candidateLine.id != null && line.id === candidateLine.id) continue
|
|
1031
|
+
claimedQuantity += claimedQuantityUnits(line.qtyClaimed) ?? 0n
|
|
1032
|
+
}
|
|
1033
|
+
if (claimedQuantity > soldQuantity) {
|
|
1034
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.qtyExceedsOrdered' })
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
export async function assertPendingClaimQuantitiesWithinSold(
|
|
1039
|
+
em: EntityManager,
|
|
1040
|
+
scope: WarrantyClaimScope,
|
|
1041
|
+
pendingLinesByOrderLine: ReadonlyMap<string, readonly ClaimedQuantityLine[]>,
|
|
1042
|
+
options: { currentClaimId: string; excludedLineIds?: ReadonlySet<string> },
|
|
1043
|
+
): Promise<void> {
|
|
1044
|
+
const db = em.getKysely<ClaimedQuantityDb>()
|
|
1045
|
+
const orderLineIds = Array.from(pendingLinesByOrderLine.keys()).sort((left, right) => left.localeCompare(right))
|
|
1046
|
+
for (const orderLineId of orderLineIds) {
|
|
1047
|
+
let soldRow: { id: string; quantity: string | null } | undefined
|
|
1048
|
+
try {
|
|
1049
|
+
soldRow = await db
|
|
1050
|
+
.selectFrom('sales_order_lines')
|
|
1051
|
+
.select(['id', 'quantity'])
|
|
1052
|
+
.where('id', '=', orderLineId)
|
|
1053
|
+
.where('tenant_id', '=', scope.tenantId)
|
|
1054
|
+
.where('organization_id', '=', scope.organizationId)
|
|
1055
|
+
.where('deleted_at', 'is', null)
|
|
1056
|
+
.forUpdate()
|
|
1057
|
+
.executeTakeFirst()
|
|
1058
|
+
} catch (err) {
|
|
1059
|
+
if (isMissingReferenceTableError(err)) return
|
|
1060
|
+
throw err
|
|
1061
|
+
}
|
|
1062
|
+
const soldQuantity = claimedQuantityUnits(soldRow?.quantity)
|
|
1063
|
+
if (!soldRow || soldQuantity === null) continue
|
|
1064
|
+
|
|
1065
|
+
const persistedRows = await db
|
|
1066
|
+
.selectFrom('warranty_claim_lines')
|
|
1067
|
+
.innerJoin('warranty_claims', 'warranty_claims.id', 'warranty_claim_lines.claim_id')
|
|
1068
|
+
.select(['warranty_claim_lines.id', 'warranty_claim_lines.qty_claimed'])
|
|
1069
|
+
.where('warranty_claim_lines.order_line_id', '=', orderLineId)
|
|
1070
|
+
.where('warranty_claim_lines.tenant_id', '=', scope.tenantId)
|
|
1071
|
+
.where('warranty_claim_lines.organization_id', '=', scope.organizationId)
|
|
1072
|
+
.where('warranty_claim_lines.deleted_at', 'is', null)
|
|
1073
|
+
.where('warranty_claim_lines.line_status', '!=', 'rejected')
|
|
1074
|
+
.where('warranty_claims.tenant_id', '=', scope.tenantId)
|
|
1075
|
+
.where('warranty_claims.organization_id', '=', scope.organizationId)
|
|
1076
|
+
.where('warranty_claims.id', '=', options.currentClaimId)
|
|
1077
|
+
.where('warranty_claims.deleted_at', 'is', null)
|
|
1078
|
+
.where('warranty_claims.status', '!=', 'cancelled')
|
|
1079
|
+
.execute()
|
|
1080
|
+
|
|
1081
|
+
let claimedQuantity = 0n
|
|
1082
|
+
for (const row of persistedRows) {
|
|
1083
|
+
if (options.excludedLineIds?.has(row.id)) continue
|
|
1084
|
+
claimedQuantity += claimedQuantityUnits(row.qty_claimed) ?? 0n
|
|
1085
|
+
}
|
|
1086
|
+
for (const line of pendingLinesByOrderLine.get(orderLineId) ?? []) {
|
|
1087
|
+
claimedQuantity += claimedQuantityUnits(line.qtyClaimed) ?? 0n
|
|
1088
|
+
}
|
|
1089
|
+
if (claimedQuantity > soldQuantity) {
|
|
1090
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.qtyExceedsOrdered' })
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
type CustomerEntitiesDb = {
|
|
1096
|
+
customer_entities: {
|
|
1097
|
+
id: string
|
|
1098
|
+
display_name: string | null
|
|
1099
|
+
tenant_id: string | null
|
|
1100
|
+
organization_id: string | null
|
|
1101
|
+
deleted_at: Date | null
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function isMissingCustomerTableError(err: unknown): boolean {
|
|
1106
|
+
if (typeof err !== 'object' || err === null) return false
|
|
1107
|
+
const candidate = err as { code?: unknown; message?: unknown }
|
|
1108
|
+
return candidate.code === '42P01'
|
|
1109
|
+
|| (typeof candidate.message === 'string'
|
|
1110
|
+
&& candidate.message.includes('customer_entities')
|
|
1111
|
+
&& candidate.message.includes('does not exist'))
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
async function assertCustomerExists(
|
|
1115
|
+
ctx: CommandRuntimeContext,
|
|
1116
|
+
customerId: string,
|
|
1117
|
+
scope: WarrantyClaimScope,
|
|
1118
|
+
): Promise<void> {
|
|
1119
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
1120
|
+
const db = em.getKysely<CustomerEntitiesDb>()
|
|
1121
|
+
let row: { id: string } | undefined
|
|
1122
|
+
try {
|
|
1123
|
+
row = await db
|
|
1124
|
+
.selectFrom('customer_entities')
|
|
1125
|
+
.select('id')
|
|
1126
|
+
.where('id', '=', customerId)
|
|
1127
|
+
.where('tenant_id', '=', scope.tenantId)
|
|
1128
|
+
.where('organization_id', '=', scope.organizationId)
|
|
1129
|
+
.where('deleted_at', 'is', null)
|
|
1130
|
+
.executeTakeFirst()
|
|
1131
|
+
} catch (err) {
|
|
1132
|
+
if (isMissingCustomerTableError(err)) return
|
|
1133
|
+
throw err
|
|
1134
|
+
}
|
|
1135
|
+
if (!row) throw new CrudHttpError(400, { error: 'warranty_claims.errors.invalidInput' })
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
async function resolveCustomerName(
|
|
1139
|
+
ctx: CommandRuntimeContext,
|
|
1140
|
+
customerId: string | null | undefined,
|
|
1141
|
+
scope: WarrantyClaimScope,
|
|
1142
|
+
fallback: string | null | undefined,
|
|
1143
|
+
options: { strict: boolean } = { strict: false },
|
|
1144
|
+
): Promise<string | null> {
|
|
1145
|
+
if (!customerId) return fallback ?? null
|
|
1146
|
+
if (options.strict) await assertCustomerExists(ctx, customerId, scope)
|
|
1147
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
1148
|
+
const db = em.getKysely<CustomerEntitiesDb>()
|
|
1149
|
+
let customer: Record<string, unknown> | undefined
|
|
1150
|
+
try {
|
|
1151
|
+
customer = await db
|
|
1152
|
+
.selectFrom('customer_entities')
|
|
1153
|
+
.select(['id', 'display_name'] as never[])
|
|
1154
|
+
.where('id' as never, '=', customerId as never)
|
|
1155
|
+
.where('tenant_id' as never, '=', scope.tenantId as never)
|
|
1156
|
+
.where('organization_id' as never, '=', scope.organizationId as never)
|
|
1157
|
+
.where('deleted_at' as never, 'is', null)
|
|
1158
|
+
.executeTakeFirst() as Record<string, unknown> | undefined
|
|
1159
|
+
} catch (err) {
|
|
1160
|
+
if (!isMissingCustomerTableError(err)) throw err
|
|
1161
|
+
return fallback ?? null
|
|
1162
|
+
}
|
|
1163
|
+
if (!customer) return fallback ?? null
|
|
1164
|
+
let encryption: {
|
|
1165
|
+
decryptEntityPayload: (entityId: string, payload: Record<string, unknown>, tenantId: string | null, organizationId?: string | null) => Promise<Record<string, unknown>>
|
|
1166
|
+
} | null = null
|
|
1167
|
+
try {
|
|
1168
|
+
encryption = ctx.container.resolve('tenantEncryptionService')
|
|
1169
|
+
} catch {
|
|
1170
|
+
encryption = null
|
|
1171
|
+
}
|
|
1172
|
+
if (encryption) {
|
|
1173
|
+
try {
|
|
1174
|
+
customer = await encryption.decryptEntityPayload('customers:customer_entity', customer, scope.tenantId, scope.organizationId)
|
|
1175
|
+
} catch {
|
|
1176
|
+
return fallback ?? null
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
return readSafeDecryptedString(readCustomerName(customer)) ?? fallback ?? null
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
async function resolveOrderNumber(
|
|
1183
|
+
ctx: CommandRuntimeContext,
|
|
1184
|
+
orderId: string | null | undefined,
|
|
1185
|
+
scope: WarrantyClaimScope,
|
|
1186
|
+
fallback: string | null | undefined,
|
|
1187
|
+
): Promise<string | null> {
|
|
1188
|
+
if (!orderId) return null
|
|
1189
|
+
const row = await querySalesReferenceRow(ctx, 'sales_orders', scope, orderId, ['id', 'order_number'])
|
|
1190
|
+
if (!row) return fallback ?? null
|
|
1191
|
+
return readString(row, 'order_number') ?? fallback ?? null
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
type CustomerUsersDb = {
|
|
1195
|
+
customer_users: {
|
|
1196
|
+
id: string
|
|
1197
|
+
tenant_id: string | null
|
|
1198
|
+
organization_id: string | null
|
|
1199
|
+
customer_entity_id: string | null
|
|
1200
|
+
is_active: boolean
|
|
1201
|
+
deleted_at: Date | null
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
async function resolvePortalRecipientUserIds(
|
|
1206
|
+
ctx: CommandRuntimeContext,
|
|
1207
|
+
claim: WarrantyClaim,
|
|
1208
|
+
): Promise<string[]> {
|
|
1209
|
+
if (!claim.customerId) return []
|
|
1210
|
+
try {
|
|
1211
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
1212
|
+
const db = em.getKysely<CustomerUsersDb>()
|
|
1213
|
+
const rows = await db
|
|
1214
|
+
.selectFrom('customer_users')
|
|
1215
|
+
.select('id')
|
|
1216
|
+
.where('tenant_id', '=', claim.tenantId)
|
|
1217
|
+
.where('organization_id', '=', claim.organizationId)
|
|
1218
|
+
.where('customer_entity_id', '=', claim.customerId)
|
|
1219
|
+
.where('is_active', '=', true)
|
|
1220
|
+
.where('deleted_at', 'is', null)
|
|
1221
|
+
.limit(100)
|
|
1222
|
+
.execute()
|
|
1223
|
+
return rows.map((row) => row.id)
|
|
1224
|
+
} catch {
|
|
1225
|
+
return []
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
async function emitClaimStatusChanged(
|
|
1230
|
+
ctx: CommandRuntimeContext,
|
|
1231
|
+
claim: WarrantyClaim,
|
|
1232
|
+
fromStatus: WarrantyClaimStatus,
|
|
1233
|
+
toStatus: WarrantyClaimStatus,
|
|
1234
|
+
): Promise<void> {
|
|
1235
|
+
const payload = {
|
|
1236
|
+
...claimEventPayload(claim),
|
|
1237
|
+
fromStatus,
|
|
1238
|
+
toStatus,
|
|
1239
|
+
}
|
|
1240
|
+
await emitWarrantyClaimsEvent('warranty_claims.claim.status_changed', payload, claimPersistentEmitOptions(claim))
|
|
1241
|
+
const recipientUserIds = await resolvePortalRecipientUserIds(ctx, claim)
|
|
1242
|
+
if (recipientUserIds.length === 0) return
|
|
1243
|
+
await emitWarrantyClaimsEvent('warranty_claims.claim.portal_status_changed', {
|
|
1244
|
+
...payload,
|
|
1245
|
+
recipientUserIds,
|
|
1246
|
+
}, claimPersistentEmitOptions(claim))
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
async function emitCustomerVisibleCommentAdded(
|
|
1250
|
+
ctx: CommandRuntimeContext,
|
|
1251
|
+
claim: WarrantyClaim,
|
|
1252
|
+
input: CommentClaimInput,
|
|
1253
|
+
): Promise<void> {
|
|
1254
|
+
const recipientUserIds = await resolvePortalRecipientUserIds(ctx, claim)
|
|
1255
|
+
if (recipientUserIds.length === 0) return
|
|
1256
|
+
await emitWarrantyClaimsEvent('warranty_claims.claim.comment_added', {
|
|
1257
|
+
...claimEventPayload(claim),
|
|
1258
|
+
visibility: input.visibility,
|
|
1259
|
+
actorCustomerId: input.actorCustomerId ?? null,
|
|
1260
|
+
recipientUserIds,
|
|
1261
|
+
}, claimPersistentEmitOptions(claim))
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function applySlaPause(
|
|
1265
|
+
em: EntityManager,
|
|
1266
|
+
claim: WarrantyClaim,
|
|
1267
|
+
fromStatus: WarrantyClaimStatus,
|
|
1268
|
+
toStatus: WarrantyClaimStatus,
|
|
1269
|
+
now: Date,
|
|
1270
|
+
effectiveSettings: WarrantyClaimEffectiveSettings,
|
|
1271
|
+
): void {
|
|
1272
|
+
if (fromStatus === toStatus || toStatus !== 'info_requested' || !effectiveSettings.slaPauseOnInfoRequested) return
|
|
1273
|
+
claim.slaPausedAt = now
|
|
1274
|
+
appendClaimEvent(em, claim, 'system', {
|
|
1275
|
+
visibility: 'customer',
|
|
1276
|
+
payload: { action: 'sla_paused' },
|
|
1277
|
+
actorUserId: null,
|
|
1278
|
+
})
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
function applySlaResume(
|
|
1282
|
+
em: EntityManager,
|
|
1283
|
+
claim: WarrantyClaim,
|
|
1284
|
+
fromStatus: WarrantyClaimStatus,
|
|
1285
|
+
toStatus: WarrantyClaimStatus,
|
|
1286
|
+
now: Date,
|
|
1287
|
+
effectiveSettings: WarrantyClaimEffectiveSettings,
|
|
1288
|
+
): void {
|
|
1289
|
+
if (fromStatus !== 'info_requested' || toStatus === 'info_requested' || !claim.slaPausedAt) return
|
|
1290
|
+
// Preserve the business time that was still remaining when the clock stopped;
|
|
1291
|
+
// a claim already past due when paused stays past due.
|
|
1292
|
+
if (claim.slaDueAt && claim.slaDueAt.getTime() > claim.slaPausedAt.getTime()) {
|
|
1293
|
+
const remainingBusinessMillis = businessMillisBetween(claim.slaPausedAt, claim.slaDueAt, effectiveSettings.businessHours)
|
|
1294
|
+
claim.slaDueAt = addBusinessMillis(now, remainingBusinessMillis, effectiveSettings.businessHours)
|
|
1295
|
+
}
|
|
1296
|
+
claim.slaPausedAt = null
|
|
1297
|
+
claim.slaAtRiskNotifiedAt = null
|
|
1298
|
+
claim.slaBreachedNotifiedAt = null
|
|
1299
|
+
appendClaimEvent(em, claim, 'system', {
|
|
1300
|
+
visibility: 'customer',
|
|
1301
|
+
payload: { action: 'sla_resumed' },
|
|
1302
|
+
actorUserId: null,
|
|
1303
|
+
})
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
function resolveOptionalEntityId(moduleId: string, entity: string): EntityId | null {
|
|
1307
|
+
const registry = E as unknown as Record<string, Record<string, string> | undefined>
|
|
1308
|
+
const value = registry[moduleId]?.[entity]
|
|
1309
|
+
return typeof value === 'string' ? (value as EntityId) : null
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
async function lookupSalesReference(
|
|
1313
|
+
ctx: CommandRuntimeContext,
|
|
1314
|
+
entityId: EntityId | null,
|
|
1315
|
+
table: SalesReferenceTable,
|
|
1316
|
+
scope: WarrantyClaimScope,
|
|
1317
|
+
id: string,
|
|
1318
|
+
select: string[],
|
|
1319
|
+
): Promise<Record<string, unknown> | ReferenceLookupResult> {
|
|
1320
|
+
if (!entityId) return 'unknown'
|
|
1321
|
+
const row = await querySalesReferenceRow(ctx, table, scope, id, select)
|
|
1322
|
+
if (row === undefined) return 'unknown'
|
|
1323
|
+
return row ?? 'missing'
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
function assertValidSalesReference(result: Record<string, unknown> | ReferenceLookupResult): asserts result is Record<string, unknown> | 'unknown' {
|
|
1327
|
+
if (result === 'missing') {
|
|
1328
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.invalidReference' })
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
export async function validateClaimReferences(
|
|
1333
|
+
ctx: CommandRuntimeContext,
|
|
1334
|
+
scope: WarrantyClaimScope,
|
|
1335
|
+
input: ReferenceValidationInput,
|
|
1336
|
+
): Promise<void> {
|
|
1337
|
+
if (input.orderId) {
|
|
1338
|
+
const result = await lookupSalesReference(ctx, resolveOptionalEntityId('sales', 'sales_order'), 'sales_orders', scope, input.orderId, ['id'])
|
|
1339
|
+
assertValidSalesReference(result)
|
|
1340
|
+
}
|
|
1341
|
+
if (input.replacementOrderId) {
|
|
1342
|
+
const result = await lookupSalesReference(ctx, resolveOptionalEntityId('sales', 'sales_order'), 'sales_orders', scope, input.replacementOrderId, ['id'])
|
|
1343
|
+
assertValidSalesReference(result)
|
|
1344
|
+
}
|
|
1345
|
+
if (input.salesReturnId) {
|
|
1346
|
+
const result = await lookupSalesReference(ctx, resolveOptionalEntityId('sales', 'sales_return'), 'sales_returns', scope, input.salesReturnId, ['id'])
|
|
1347
|
+
assertValidSalesReference(result)
|
|
1348
|
+
}
|
|
1349
|
+
if (input.creditMemoId) {
|
|
1350
|
+
const result = await lookupSalesReference(ctx, resolveOptionalEntityId('sales', 'sales_credit_memo'), 'sales_credit_memos', scope, input.creditMemoId, ['id'])
|
|
1351
|
+
assertValidSalesReference(result)
|
|
1352
|
+
}
|
|
1353
|
+
const lineRefs = input.lineOrderRefs ?? []
|
|
1354
|
+
if (lineRefs.length === 0 || !resolveOptionalEntityId('sales', 'sales_order_line')) return
|
|
1355
|
+
const lineRows = await querySalesOrderLineReferences(
|
|
1356
|
+
ctx,
|
|
1357
|
+
scope,
|
|
1358
|
+
Array.from(new Set(lineRefs.map((lineRef) => lineRef.orderLineId))),
|
|
1359
|
+
)
|
|
1360
|
+
if (lineRows === undefined) return
|
|
1361
|
+
const lineRowsById = new Map(lineRows.map((row) => [readString(row, 'id'), row]))
|
|
1362
|
+
for (const lineRef of lineRefs) {
|
|
1363
|
+
const result = lineRowsById.get(lineRef.orderLineId)
|
|
1364
|
+
if (!result) {
|
|
1365
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.invalidReference' })
|
|
1366
|
+
}
|
|
1367
|
+
if (lineRef.orderId && readString(result, 'order_id') !== lineRef.orderId) {
|
|
1368
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.invalidReference' })
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
async function validateAssigneeUser(
|
|
1374
|
+
ctx: CommandRuntimeContext,
|
|
1375
|
+
scope: WarrantyClaimScope,
|
|
1376
|
+
assigneeUserId: string | null | undefined,
|
|
1377
|
+
): Promise<void> {
|
|
1378
|
+
if (!assigneeUserId) return
|
|
1379
|
+
const assignable = await isAssignableStaffUser({
|
|
1380
|
+
container: ctx.container,
|
|
1381
|
+
tenantId: scope.tenantId,
|
|
1382
|
+
organizationId: scope.organizationId,
|
|
1383
|
+
}, assigneeUserId)
|
|
1384
|
+
if (!assignable) throw new CrudHttpError(400, { error: 'warranty_claims.errors.invalidAssignee' })
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
function applyClaimAssignment(
|
|
1388
|
+
em: EntityManager,
|
|
1389
|
+
claim: WarrantyClaim,
|
|
1390
|
+
assigneeUserId: string | null,
|
|
1391
|
+
actorUserId: string | null,
|
|
1392
|
+
now: Date,
|
|
1393
|
+
): void {
|
|
1394
|
+
claim.assigneeUserId = assigneeUserId
|
|
1395
|
+
claim.updatedAt = now
|
|
1396
|
+
appendClaimEvent(em, claim, 'assignment', {
|
|
1397
|
+
visibility: 'internal',
|
|
1398
|
+
payload: { assigneeUserId: claim.assigneeUserId },
|
|
1399
|
+
actorUserId,
|
|
1400
|
+
})
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
function allowedUpdateFields(status: WarrantyClaimStatus): Set<ClaimUpdateField> {
|
|
1404
|
+
if (intakeStatuses.has(status)) return new Set(CLAIM_INTAKE_UPDATE_FIELDS)
|
|
1405
|
+
if (fulfillmentStatuses.has(status)) return new Set(CLAIM_FULFILLMENT_UPDATE_FIELDS)
|
|
1406
|
+
return new Set()
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function assertClaimUpdateFields(claim: WarrantyClaim, input: ClaimUpdateInput): void {
|
|
1410
|
+
const keys = [...CLAIM_INTAKE_UPDATE_FIELDS, ...CLAIM_FULFILLMENT_UPDATE_FIELDS]
|
|
1411
|
+
const requested = keys.filter((key) => hasOwn(input, key))
|
|
1412
|
+
if (!requested.length) return
|
|
1413
|
+
const allowed = allowedUpdateFields(claim.status)
|
|
1414
|
+
const disallowed = requested.filter((key) => !allowed.has(key))
|
|
1415
|
+
if (disallowed.length > 0) {
|
|
1416
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.fieldLocked' })
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
function applyClaimUpdate(claim: WarrantyClaim, input: ClaimUpdateInput, customerName: string | null, orderNumber: string | null): void {
|
|
1421
|
+
if (hasOwn(input, 'customerId')) claim.customerId = input.customerId ?? null
|
|
1422
|
+
if (hasOwn(input, 'customerName')) claim.customerName = customerName
|
|
1423
|
+
if (hasOwn(input, 'orderId')) {
|
|
1424
|
+
claim.orderId = input.orderId ?? null
|
|
1425
|
+
claim.orderNumber = orderNumber
|
|
1426
|
+
}
|
|
1427
|
+
if (hasOwn(input, 'reasonCode')) claim.reasonCode = input.reasonCode ?? null
|
|
1428
|
+
if (hasOwn(input, 'priority') && input.priority) claim.priority = input.priority
|
|
1429
|
+
if (hasOwn(input, 'notes')) claim.notes = input.notes ?? null
|
|
1430
|
+
if (hasOwn(input, 'advanceReplacement')) claim.advanceReplacement = input.advanceReplacement ?? false
|
|
1431
|
+
if (hasOwn(input, 'replacementOrderId')) claim.replacementOrderId = input.replacementOrderId ?? null
|
|
1432
|
+
if (hasOwn(input, 'advanceShippedAt')) claim.advanceShippedAt = input.advanceShippedAt ?? null
|
|
1433
|
+
if (hasOwn(input, 'salesReturnId')) claim.salesReturnId = input.salesReturnId ?? null
|
|
1434
|
+
if (hasOwn(input, 'creditMemoId')) claim.creditMemoId = input.creditMemoId ?? null
|
|
1435
|
+
if (hasOwn(input, 'vendorName')) claim.vendorName = input.vendorName ?? null
|
|
1436
|
+
if (hasOwn(input, 'vendorRef')) claim.vendorRef = input.vendorRef ?? null
|
|
1437
|
+
if (hasOwn(input, 'resolutionSummary')) claim.resolutionSummary = input.resolutionSummary ?? null
|
|
1438
|
+
claim.updatedAt = new Date()
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
async function recomputeClaimRollups(em: EntityManager, claim: WarrantyClaim): Promise<void> {
|
|
1442
|
+
const lines = await findWithDecryption(
|
|
1443
|
+
em,
|
|
1444
|
+
WarrantyClaimLine,
|
|
1445
|
+
{
|
|
1446
|
+
claim: claim.id,
|
|
1447
|
+
tenantId: claim.tenantId,
|
|
1448
|
+
organizationId: claim.organizationId,
|
|
1449
|
+
deletedAt: null,
|
|
1450
|
+
},
|
|
1451
|
+
{},
|
|
1452
|
+
{ tenantId: claim.tenantId, organizationId: claim.organizationId },
|
|
1453
|
+
)
|
|
1454
|
+
const totals = computeHeaderRollups(lines, { claimType: claim.claimType })
|
|
1455
|
+
claim.totalClaimedAmount = String(totals.totalClaimedAmount)
|
|
1456
|
+
claim.totalApprovedAmount = String(totals.totalApprovedAmount)
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
function assertCanResolve(lines: readonly WarrantyClaimLine[]): void {
|
|
1460
|
+
if (!canResolveWithLineStatuses(lines)) {
|
|
1461
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.invalidTransition' })
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
function assertVendorRecoveryLines(lines: readonly WarrantyClaimLine[], requestedLineIds: readonly string[]): void {
|
|
1466
|
+
if (lines.length !== requestedLineIds.length) {
|
|
1467
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.vendorRecoveryNeedsResolvedLines' })
|
|
1468
|
+
}
|
|
1469
|
+
for (const line of lines) {
|
|
1470
|
+
if (line.lineStatus !== 'resolved' || line.vendorClaimLineId) {
|
|
1471
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.vendorRecoveryNeedsResolvedLines' })
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
function buildClaimLog(
|
|
1477
|
+
actionLabel: string,
|
|
1478
|
+
resourceId: string,
|
|
1479
|
+
snapshots: { before?: unknown; after?: unknown },
|
|
1480
|
+
): {
|
|
1481
|
+
actionLabel: string
|
|
1482
|
+
resourceKind: string
|
|
1483
|
+
resourceId: string
|
|
1484
|
+
tenantId: string | null
|
|
1485
|
+
organizationId: string | null
|
|
1486
|
+
snapshotBefore: unknown
|
|
1487
|
+
snapshotAfter: unknown
|
|
1488
|
+
payload: { undo: ClaimUndoPayload }
|
|
1489
|
+
} {
|
|
1490
|
+
const after = snapshots.after as ClaimSnapshot | null | undefined
|
|
1491
|
+
const before = snapshots.before as ClaimSnapshot | null | undefined
|
|
1492
|
+
return {
|
|
1493
|
+
actionLabel,
|
|
1494
|
+
resourceKind: WARRANTY_CLAIM_RESOURCE_KIND,
|
|
1495
|
+
resourceId,
|
|
1496
|
+
tenantId: after?.tenantId ?? before?.tenantId ?? null,
|
|
1497
|
+
organizationId: after?.organizationId ?? before?.organizationId ?? null,
|
|
1498
|
+
snapshotBefore: before ?? null,
|
|
1499
|
+
snapshotAfter: after ?? null,
|
|
1500
|
+
payload: { undo: { before: before ?? null, after: after ?? null } },
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
const createClaimCommand: CommandHandler<ClaimCreateInput, { claimId: string }> = {
|
|
1505
|
+
id: 'warranty_claims.claim.create',
|
|
1506
|
+
isUndoable: true,
|
|
1507
|
+
async execute(rawInput, ctx) {
|
|
1508
|
+
const input = parseCommandInput(claimCreateSchema, rawInput)
|
|
1509
|
+
const scope = resolveScope(ctx, input)
|
|
1510
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
1511
|
+
await validateClaimReferences(ctx, scope, {
|
|
1512
|
+
orderId: input.orderId ?? null,
|
|
1513
|
+
salesReturnId: input.salesReturnId ?? null,
|
|
1514
|
+
replacementOrderId: input.replacementOrderId ?? null,
|
|
1515
|
+
creditMemoId: null,
|
|
1516
|
+
lineOrderRefs: (input.lines ?? [])
|
|
1517
|
+
.filter((line): line is ClaimInitialLineCreateInput & { orderLineId: string } => typeof line.orderLineId === 'string')
|
|
1518
|
+
.map((line) => ({ orderLineId: line.orderLineId, orderId: input.orderId ?? null })),
|
|
1519
|
+
})
|
|
1520
|
+
await assertOrderBelongsToCustomer(em, scope, input.orderId ?? null, input.customerId ?? null)
|
|
1521
|
+
const pendingLines = (input.lines ?? []).map((line) => ({
|
|
1522
|
+
orderLineId: line.orderLineId ?? null,
|
|
1523
|
+
qtyClaimed: amountString(line.qtyClaimed, '1') ?? '1',
|
|
1524
|
+
}))
|
|
1525
|
+
const pendingLinesByOrderLine = new Map<string, ClaimedQuantityLine[]>()
|
|
1526
|
+
for (const line of pendingLines) {
|
|
1527
|
+
if (!line.orderLineId) continue
|
|
1528
|
+
const grouped = pendingLinesByOrderLine.get(line.orderLineId) ?? []
|
|
1529
|
+
grouped.push(line)
|
|
1530
|
+
pendingLinesByOrderLine.set(line.orderLineId, grouped)
|
|
1531
|
+
}
|
|
1532
|
+
const numberGenerator = resolveClaimNumberGenerator(ctx, em)
|
|
1533
|
+
const generated = await numberGenerator.generate({
|
|
1534
|
+
claimType: input.claimType,
|
|
1535
|
+
tenantId: scope.tenantId,
|
|
1536
|
+
organizationId: scope.organizationId,
|
|
1537
|
+
})
|
|
1538
|
+
const customerName = await resolveCustomerName(ctx, input.customerId, scope, input.customerName, { strict: true })
|
|
1539
|
+
// A linked order derives its number from the order itself (falling back to any supplied
|
|
1540
|
+
// reference); without a linked order, persist the free-text reference as the display number
|
|
1541
|
+
// so the portal "my order isn't listed" path is captured (WQA-002).
|
|
1542
|
+
const orderNumber = input.orderId
|
|
1543
|
+
? await resolveOrderNumber(ctx, input.orderId, scope, input.orderNumber ?? null)
|
|
1544
|
+
: (input.orderNumber ?? null)
|
|
1545
|
+
const claimId = randomUUID()
|
|
1546
|
+
let claim!: WarrantyClaim
|
|
1547
|
+
let createdLines: WarrantyClaimLine[] = []
|
|
1548
|
+
|
|
1549
|
+
await withAtomicFlush(em, [
|
|
1550
|
+
async () => {
|
|
1551
|
+
await assertPendingClaimQuantitiesWithinSold(em, scope, pendingLinesByOrderLine, { currentClaimId: claimId })
|
|
1552
|
+
claim = em.create(WarrantyClaim, {
|
|
1553
|
+
id: claimId,
|
|
1554
|
+
organizationId: scope.organizationId,
|
|
1555
|
+
tenantId: scope.tenantId,
|
|
1556
|
+
claimNumber: generated.number,
|
|
1557
|
+
claimType: input.claimType,
|
|
1558
|
+
status: 'draft',
|
|
1559
|
+
channel: input.channel ?? 'staff',
|
|
1560
|
+
priority: input.priority ?? 'normal',
|
|
1561
|
+
customerId: input.customerId ?? null,
|
|
1562
|
+
customerName,
|
|
1563
|
+
externalRef: input.externalRef ?? null,
|
|
1564
|
+
intakeMessageRef: input.intakeMessageRef ?? null,
|
|
1565
|
+
contactEmail: input.contactEmail ?? null,
|
|
1566
|
+
vendorName: input.vendorName ?? null,
|
|
1567
|
+
vendorRef: input.vendorRef ?? null,
|
|
1568
|
+
orderId: input.orderId ?? null,
|
|
1569
|
+
orderNumber,
|
|
1570
|
+
salesReturnId: input.salesReturnId ?? null,
|
|
1571
|
+
replacementOrderId: input.replacementOrderId ?? null,
|
|
1572
|
+
creditMemoId: null,
|
|
1573
|
+
sourceClaimId: null,
|
|
1574
|
+
advanceReplacement: input.advanceReplacement ?? false,
|
|
1575
|
+
advanceShippedAt: input.advanceShippedAt ?? null,
|
|
1576
|
+
reasonCode: input.reasonCode ?? null,
|
|
1577
|
+
rejectionReasonCode: input.rejectionReasonCode ?? null,
|
|
1578
|
+
resolutionSummary: input.resolutionSummary ?? null,
|
|
1579
|
+
notes: input.notes ?? null,
|
|
1580
|
+
currencyCode: input.currencyCode ?? null,
|
|
1581
|
+
totalClaimedAmount: '0',
|
|
1582
|
+
totalApprovedAmount: '0',
|
|
1583
|
+
totalRecoveredAmount: '0',
|
|
1584
|
+
createdAt: new Date(),
|
|
1585
|
+
updatedAt: new Date(),
|
|
1586
|
+
})
|
|
1587
|
+
em.persist(claim)
|
|
1588
|
+
createdLines = (input.lines ?? []).map((lineInput, index) => {
|
|
1589
|
+
const line = em.create(WarrantyClaimLine, buildInitialLineData(claim, lineInput, index))
|
|
1590
|
+
em.persist(line)
|
|
1591
|
+
return line
|
|
1592
|
+
})
|
|
1593
|
+
const totals = computeHeaderRollups(createdLines, { claimType: claim.claimType })
|
|
1594
|
+
claim.totalClaimedAmount = String(totals.totalClaimedAmount)
|
|
1595
|
+
claim.totalApprovedAmount = String(totals.totalApprovedAmount)
|
|
1596
|
+
appendClaimEvent(em, claim, 'system', {
|
|
1597
|
+
visibility: 'internal',
|
|
1598
|
+
payload: { action: 'created' },
|
|
1599
|
+
actorUserId: ctx.auth?.sub ?? null,
|
|
1600
|
+
})
|
|
1601
|
+
},
|
|
1602
|
+
() => stampEntitlementSourceIfResolvable(ctx, em, claim, input, scope),
|
|
1603
|
+
], { transaction: true, label: 'warranty_claims.claim.create' })
|
|
1604
|
+
|
|
1605
|
+
await emitClaimCrud(ctx, 'created', claim)
|
|
1606
|
+
await Promise.all(createdLines.map((line) => emitLineCrud(ctx, 'created', line)))
|
|
1607
|
+
return { claimId: claim.id }
|
|
1608
|
+
},
|
|
1609
|
+
captureAfter: async (rawInput, result, ctx) => {
|
|
1610
|
+
const input = parseCommandInput(claimCreateSchema, rawInput)
|
|
1611
|
+
const scope = resolveScope(ctx, input)
|
|
1612
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
1613
|
+
return loadClaimSnapshot(em, result.claimId, scope)
|
|
1614
|
+
},
|
|
1615
|
+
buildLog: async ({ result, snapshots }) => buildClaimLog('warranty_claims.audit.claim.create', result.claimId, snapshots),
|
|
1616
|
+
undo: async ({ logEntry, ctx }) => {
|
|
1617
|
+
const payload = extractUndoPayload<ClaimUndoPayload>(logEntry)
|
|
1618
|
+
const after = payload?.after
|
|
1619
|
+
if (!after) return
|
|
1620
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
1621
|
+
let claim!: WarrantyClaim
|
|
1622
|
+
let lines: WarrantyClaimLine[] = []
|
|
1623
|
+
await withAtomicFlush(em, [
|
|
1624
|
+
async () => {
|
|
1625
|
+
const current = await requireCurrentUndoSnapshot(em, after)
|
|
1626
|
+
claim = current.claim
|
|
1627
|
+
lines = current.lines.filter((line) => !line.deletedAt)
|
|
1628
|
+
const now = new Date()
|
|
1629
|
+
claim.deletedAt = now
|
|
1630
|
+
claim.updatedAt = now
|
|
1631
|
+
for (const line of lines) {
|
|
1632
|
+
line.deletedAt = now
|
|
1633
|
+
line.updatedAt = now
|
|
1634
|
+
}
|
|
1635
|
+
},
|
|
1636
|
+
], { transaction: true, label: 'warranty_claims.claim.create.undo' })
|
|
1637
|
+
await emitClaimUndoCrud(ctx, 'deleted', claim)
|
|
1638
|
+
await Promise.all(lines.map((line) => emitLineUndoCrud(ctx, 'deleted', line)))
|
|
1639
|
+
},
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
const updateClaimCommand: CommandHandler<ClaimUpdateInput, { claimId: string }> = {
|
|
1643
|
+
id: 'warranty_claims.claim.update',
|
|
1644
|
+
isUndoable: true,
|
|
1645
|
+
prepare: async (rawInput, ctx) => {
|
|
1646
|
+
const input = parseCommandInput(claimUpdateSchema, rawInput)
|
|
1647
|
+
const scope = resolveScope(ctx, input)
|
|
1648
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
1649
|
+
return { before: await loadClaimSnapshot(em, input.id, scope) }
|
|
1650
|
+
},
|
|
1651
|
+
async execute(rawInput, ctx) {
|
|
1652
|
+
const input = parseCommandInput(claimUpdateSchema, rawInput)
|
|
1653
|
+
const scope = resolveScope(ctx, input)
|
|
1654
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
1655
|
+
const claim = await requireScopedClaim(em, input.id, scope)
|
|
1656
|
+
await enforceWarrantyClaimOptimisticLock(ctx, claim)
|
|
1657
|
+
assertClaimUpdateFields(claim, input)
|
|
1658
|
+
await validateClaimReferences(ctx, scope, {
|
|
1659
|
+
orderId: hasOwn(input, 'orderId') && (input.orderId ?? null) !== (claim.orderId ?? null) ? input.orderId ?? null : null,
|
|
1660
|
+
salesReturnId: hasOwn(input, 'salesReturnId') && (input.salesReturnId ?? null) !== (claim.salesReturnId ?? null) ? input.salesReturnId ?? null : null,
|
|
1661
|
+
replacementOrderId: hasOwn(input, 'replacementOrderId') && (input.replacementOrderId ?? null) !== (claim.replacementOrderId ?? null) ? input.replacementOrderId ?? null : null,
|
|
1662
|
+
creditMemoId: hasOwn(input, 'creditMemoId') && (input.creditMemoId ?? null) !== (claim.creditMemoId ?? null) ? input.creditMemoId ?? null : null,
|
|
1663
|
+
})
|
|
1664
|
+
const effectiveOrderId = hasOwn(input, 'orderId') ? (input.orderId ?? null) : (claim.orderId ?? null)
|
|
1665
|
+
const effectiveCustomerId = hasOwn(input, 'customerId') ? (input.customerId ?? null) : (claim.customerId ?? null)
|
|
1666
|
+
await assertOrderBelongsToCustomer(em, scope, effectiveOrderId, effectiveCustomerId)
|
|
1667
|
+
const customerChanged = hasOwn(input, 'customerId') && (input.customerId ?? null) !== (claim.customerId ?? null)
|
|
1668
|
+
const shouldRefreshCustomerName = hasOwn(input, 'customerId') || hasOwn(input, 'customerName')
|
|
1669
|
+
const customerName = shouldRefreshCustomerName
|
|
1670
|
+
? await resolveCustomerName(ctx, input.customerId ?? claim.customerId, scope, input.customerName ?? claim.customerName, { strict: customerChanged })
|
|
1671
|
+
: claim.customerName ?? null
|
|
1672
|
+
const orderNumber = hasOwn(input, 'orderId')
|
|
1673
|
+
? await resolveOrderNumber(ctx, input.orderId, scope, (input.orderId ?? null) === (claim.orderId ?? null) ? claim.orderNumber : null)
|
|
1674
|
+
: claim.orderNumber ?? null
|
|
1675
|
+
await withAtomicFlush(em, [
|
|
1676
|
+
() => applyClaimUpdate(claim, input, customerName, orderNumber),
|
|
1677
|
+
], { transaction: true, label: 'warranty_claims.claim.update' })
|
|
1678
|
+
await emitClaimCrud(ctx, 'updated', claim)
|
|
1679
|
+
return { claimId: claim.id }
|
|
1680
|
+
},
|
|
1681
|
+
captureAfter: async (rawInput, result, ctx) => {
|
|
1682
|
+
const input = parseCommandInput(claimUpdateSchema, rawInput)
|
|
1683
|
+
const scope = resolveScope(ctx, input)
|
|
1684
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
1685
|
+
return loadClaimSnapshot(em, result.claimId, scope)
|
|
1686
|
+
},
|
|
1687
|
+
buildLog: async ({ result, snapshots }) => buildClaimLog('warranty_claims.audit.claim.update', result.claimId, snapshots),
|
|
1688
|
+
undo: async ({ logEntry, ctx }) => {
|
|
1689
|
+
const payload = extractUndoPayload<ClaimUndoPayload>(logEntry)
|
|
1690
|
+
const before = payload?.before
|
|
1691
|
+
if (!before) return
|
|
1692
|
+
const after = payload?.after
|
|
1693
|
+
if (!after) throwClaimUndoConflict()
|
|
1694
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
1695
|
+
let claim!: WarrantyClaim
|
|
1696
|
+
await withAtomicFlush(em, [
|
|
1697
|
+
async () => {
|
|
1698
|
+
claim = await restoreSnapshot(em, before, after)
|
|
1699
|
+
},
|
|
1700
|
+
], { transaction: true, label: 'warranty_claims.claim.update.undo' })
|
|
1701
|
+
await emitClaimUndoCrud(ctx, 'updated', claim)
|
|
1702
|
+
},
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
const deleteClaimCommand: CommandHandler<ClaimDeleteInput, { claimId: string }> = {
|
|
1706
|
+
id: 'warranty_claims.claim.delete',
|
|
1707
|
+
isUndoable: true,
|
|
1708
|
+
prepare: async (rawInput, ctx) => {
|
|
1709
|
+
const input = parseCommandInput(claimDeleteSchema, rawInput)
|
|
1710
|
+
const scope = resolveScope(ctx, input)
|
|
1711
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
1712
|
+
return { before: await loadClaimSnapshot(em, input.id, scope) }
|
|
1713
|
+
},
|
|
1714
|
+
async execute(rawInput, ctx) {
|
|
1715
|
+
const input = parseCommandInput(claimDeleteSchema, rawInput)
|
|
1716
|
+
const scope = resolveScope(ctx, input)
|
|
1717
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
1718
|
+
const claim = await requireScopedClaim(em, input.id, scope)
|
|
1719
|
+
await enforceWarrantyClaimOptimisticLock(ctx, claim)
|
|
1720
|
+
if (!deletableStatuses.has(claim.status)) {
|
|
1721
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.deleteNotAllowed' })
|
|
1722
|
+
}
|
|
1723
|
+
const lines = await findWithDecryption(
|
|
1724
|
+
em,
|
|
1725
|
+
WarrantyClaimLine,
|
|
1726
|
+
{ claim: claim.id, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },
|
|
1727
|
+
{},
|
|
1728
|
+
scope,
|
|
1729
|
+
)
|
|
1730
|
+
await withAtomicFlush(em, [
|
|
1731
|
+
() => {
|
|
1732
|
+
const now = new Date()
|
|
1733
|
+
claim.deletedAt = now
|
|
1734
|
+
claim.updatedAt = now
|
|
1735
|
+
for (const line of lines) {
|
|
1736
|
+
line.deletedAt = now
|
|
1737
|
+
line.updatedAt = now
|
|
1738
|
+
}
|
|
1739
|
+
},
|
|
1740
|
+
], { transaction: true, label: 'warranty_claims.claim.delete' })
|
|
1741
|
+
await emitClaimCrud(ctx, 'deleted', claim)
|
|
1742
|
+
await Promise.all(lines.map((line) => emitLineCrud(ctx, 'deleted', line)))
|
|
1743
|
+
return { claimId: claim.id }
|
|
1744
|
+
},
|
|
1745
|
+
captureAfter: async (rawInput, result, ctx) => {
|
|
1746
|
+
const input = parseCommandInput(claimDeleteSchema, rawInput)
|
|
1747
|
+
const scope = resolveScope(ctx, input)
|
|
1748
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
1749
|
+
return loadClaimSnapshot(em, result.claimId, scope)
|
|
1750
|
+
},
|
|
1751
|
+
buildLog: async ({ result, snapshots }) => buildClaimLog('warranty_claims.audit.claim.delete', result.claimId, snapshots),
|
|
1752
|
+
undo: async ({ logEntry, ctx }) => {
|
|
1753
|
+
const payload = extractUndoPayload<ClaimUndoPayload>(logEntry)
|
|
1754
|
+
const before = payload?.before
|
|
1755
|
+
if (!before) return
|
|
1756
|
+
const after = payload?.after
|
|
1757
|
+
if (!after) throwClaimUndoConflict()
|
|
1758
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
1759
|
+
let claim!: WarrantyClaim
|
|
1760
|
+
await withAtomicFlush(em, [
|
|
1761
|
+
async () => {
|
|
1762
|
+
claim = await restoreSnapshot(em, before, after)
|
|
1763
|
+
},
|
|
1764
|
+
], { transaction: true, label: 'warranty_claims.claim.delete.undo' })
|
|
1765
|
+
await emitClaimUndoCrud(ctx, 'created', claim)
|
|
1766
|
+
const restoredLines = await findWithDecryption(
|
|
1767
|
+
em,
|
|
1768
|
+
WarrantyClaimLine,
|
|
1769
|
+
{ claim: claim.id, tenantId: before.tenantId, organizationId: before.organizationId, deletedAt: null },
|
|
1770
|
+
{},
|
|
1771
|
+
{ tenantId: before.tenantId, organizationId: before.organizationId },
|
|
1772
|
+
)
|
|
1773
|
+
await Promise.all(restoredLines.map((line) => emitLineCrud(ctx, 'created', line)))
|
|
1774
|
+
},
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
const submitClaimCommand: CommandHandler<SubmitClaimInput, { claimId: string }> = {
|
|
1778
|
+
id: 'warranty_claims.claim.submit',
|
|
1779
|
+
async execute(rawInput, ctx) {
|
|
1780
|
+
const input = parseCommandInput(submitClaimSchema, rawInput)
|
|
1781
|
+
const scope = resolveScope(ctx, input)
|
|
1782
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
1783
|
+
const claim = await requireScopedClaim(em, input.id, scope)
|
|
1784
|
+
await enforceWarrantyClaimOptimisticLock(ctx, claim)
|
|
1785
|
+
if (claim.status !== 'draft') {
|
|
1786
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.invalidTransition' })
|
|
1787
|
+
}
|
|
1788
|
+
const fromStatus = claim.status
|
|
1789
|
+
let autoApproved = false
|
|
1790
|
+
let effectiveSettings: WarrantyClaimEffectiveSettings | null = null
|
|
1791
|
+
await withAtomicFlush(em, [
|
|
1792
|
+
async () => {
|
|
1793
|
+
effectiveSettings = await resolveEffectiveWarrantyClaimSettings(em, scope)
|
|
1794
|
+
const submittedAt = new Date()
|
|
1795
|
+
claim.status = 'submitted'
|
|
1796
|
+
claim.submittedAt = submittedAt
|
|
1797
|
+
claim.slaDueAt = addBusinessMillis(submittedAt, effectiveSettings.slaHours * 60 * 60 * 1000, effectiveSettings.businessHours)
|
|
1798
|
+
claim.slaAtRiskNotifiedAt = null
|
|
1799
|
+
claim.slaBreachedNotifiedAt = null
|
|
1800
|
+
claim.updatedAt = submittedAt
|
|
1801
|
+
appendClaimEvent(em, claim, 'status_changed', {
|
|
1802
|
+
visibility: 'customer',
|
|
1803
|
+
payload: { from: fromStatus, to: 'submitted' },
|
|
1804
|
+
actorUserId: input.actorCustomerId ? null : (ctx.auth?.sub ?? null),
|
|
1805
|
+
actorCustomerId: input.actorCustomerId ?? null,
|
|
1806
|
+
})
|
|
1807
|
+
},
|
|
1808
|
+
async () => {
|
|
1809
|
+
const lines = await findWithDecryption(
|
|
1810
|
+
em,
|
|
1811
|
+
WarrantyClaimLine,
|
|
1812
|
+
{
|
|
1813
|
+
claim: claim.id,
|
|
1814
|
+
tenantId: claim.tenantId,
|
|
1815
|
+
organizationId: claim.organizationId,
|
|
1816
|
+
deletedAt: null,
|
|
1817
|
+
},
|
|
1818
|
+
{},
|
|
1819
|
+
scope,
|
|
1820
|
+
)
|
|
1821
|
+
const risk = await evaluateClaimRisk(em, claim, lines)
|
|
1822
|
+
const settings = effectiveSettings ?? await resolveEffectiveWarrantyClaimSettings(em, scope)
|
|
1823
|
+
const evaluator = ctx.container.resolve<WarrantyAdjudicationEvaluator>('warrantyAdjudicationEvaluator')
|
|
1824
|
+
const { decision } = await evaluator.evaluate({
|
|
1825
|
+
claim,
|
|
1826
|
+
lines,
|
|
1827
|
+
settings,
|
|
1828
|
+
risk,
|
|
1829
|
+
container: ctx.container,
|
|
1830
|
+
em,
|
|
1831
|
+
scope,
|
|
1832
|
+
})
|
|
1833
|
+
if (decision === 'auto_approve') {
|
|
1834
|
+
claim.status = 'approved'
|
|
1835
|
+
claim.updatedAt = new Date()
|
|
1836
|
+
autoApproved = true
|
|
1837
|
+
appendClaimEvent(em, claim, 'status_changed', {
|
|
1838
|
+
visibility: 'customer',
|
|
1839
|
+
payload: { from: 'submitted', to: 'approved' },
|
|
1840
|
+
actorUserId: null,
|
|
1841
|
+
})
|
|
1842
|
+
appendClaimEvent(em, claim, 'system', {
|
|
1843
|
+
visibility: 'internal',
|
|
1844
|
+
payload: {
|
|
1845
|
+
action: 'auto_approved',
|
|
1846
|
+
maxAmount: settings.autoApproveMaxAmount,
|
|
1847
|
+
currencyCode: settings.autoApproveCurrencyCode,
|
|
1848
|
+
},
|
|
1849
|
+
actorUserId: null,
|
|
1850
|
+
})
|
|
1851
|
+
}
|
|
1852
|
+
},
|
|
1853
|
+
], { transaction: true, label: 'warranty_claims.claim.submit' })
|
|
1854
|
+
await emitClaimCrud(ctx, 'updated', claim)
|
|
1855
|
+
const payload = claimEventPayload(claim)
|
|
1856
|
+
await emitWarrantyClaimsEvent('warranty_claims.claim.submitted', payload, claimPersistentEmitOptions(claim))
|
|
1857
|
+
await emitClaimStatusChanged(ctx, claim, autoApproved ? 'submitted' : fromStatus, autoApproved ? 'approved' : 'submitted')
|
|
1858
|
+
return { claimId: claim.id }
|
|
1859
|
+
},
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
const transitionClaimCommand: CommandHandler<TransitionClaimInput, { claimId: string }> = {
|
|
1863
|
+
id: 'warranty_claims.claim.transition',
|
|
1864
|
+
isUndoable: true,
|
|
1865
|
+
prepare: async (rawInput, ctx) => {
|
|
1866
|
+
const input = parseCommandInput(transitionClaimInputSchema, rawInput)
|
|
1867
|
+
const scope = resolveScope(ctx, {})
|
|
1868
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
1869
|
+
return { before: await loadClaimSnapshot(em, input.id, scope) }
|
|
1870
|
+
},
|
|
1871
|
+
async execute(rawInput, ctx) {
|
|
1872
|
+
const input = parseCommandInput(transitionClaimInputSchema, rawInput)
|
|
1873
|
+
const scope = resolveScope(ctx, {})
|
|
1874
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
1875
|
+
const claim = await requireScopedClaim(em, input.id, scope)
|
|
1876
|
+
await enforceWarrantyClaimOptimisticLock(ctx, claim)
|
|
1877
|
+
const fromStatus = claim.status
|
|
1878
|
+
assertTransition(fromStatus, input.toStatus)
|
|
1879
|
+
if (input.toStatus === 'cancelled' && !preReceivedStatuses.has(fromStatus)) {
|
|
1880
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.invalidTransition' })
|
|
1881
|
+
}
|
|
1882
|
+
if (input.toStatus === 'rejected' && !input.rejectionReasonCode) {
|
|
1883
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.invalidTransition' })
|
|
1884
|
+
}
|
|
1885
|
+
const lines = input.toStatus === 'resolved'
|
|
1886
|
+
? await findWithDecryption(
|
|
1887
|
+
em,
|
|
1888
|
+
WarrantyClaimLine,
|
|
1889
|
+
{
|
|
1890
|
+
claim: claim.id,
|
|
1891
|
+
tenantId: claim.tenantId,
|
|
1892
|
+
organizationId: claim.organizationId,
|
|
1893
|
+
deletedAt: null,
|
|
1894
|
+
},
|
|
1895
|
+
{},
|
|
1896
|
+
{ tenantId: claim.tenantId, organizationId: claim.organizationId },
|
|
1897
|
+
)
|
|
1898
|
+
: []
|
|
1899
|
+
if (input.toStatus === 'resolved') assertCanResolve(lines)
|
|
1900
|
+
await withAtomicFlush(em, [
|
|
1901
|
+
async () => {
|
|
1902
|
+
const effectiveSettings = await resolveEffectiveWarrantyClaimSettings(em, scope)
|
|
1903
|
+
const now = new Date()
|
|
1904
|
+
claim.status = input.toStatus
|
|
1905
|
+
if (input.toStatus === 'rejected') claim.rejectionReasonCode = input.rejectionReasonCode ?? null
|
|
1906
|
+
if (input.toStatus === 'resolved') {
|
|
1907
|
+
claim.resolvedAt = now
|
|
1908
|
+
if (hasOwn(input, 'resolutionSummary')) claim.resolutionSummary = input.resolutionSummary ?? null
|
|
1909
|
+
}
|
|
1910
|
+
if (input.toStatus === 'closed') claim.closedAt = now
|
|
1911
|
+
applySlaResume(em, claim, fromStatus, input.toStatus, now, effectiveSettings)
|
|
1912
|
+
applySlaPause(em, claim, fromStatus, input.toStatus, now, effectiveSettings)
|
|
1913
|
+
claim.awaitingStaffReply = false
|
|
1914
|
+
claim.updatedAt = now
|
|
1915
|
+
appendClaimEvent(em, claim, 'status_changed', {
|
|
1916
|
+
visibility: 'customer',
|
|
1917
|
+
payload: {
|
|
1918
|
+
from: fromStatus,
|
|
1919
|
+
to: input.toStatus,
|
|
1920
|
+
...(input.systemNote ? { systemNote: input.systemNote } : {}),
|
|
1921
|
+
},
|
|
1922
|
+
actorUserId: input.actorCustomerId ? null : (ctx.auth?.sub ?? null),
|
|
1923
|
+
actorCustomerId: input.actorCustomerId ?? null,
|
|
1924
|
+
})
|
|
1925
|
+
},
|
|
1926
|
+
], { transaction: true, label: 'warranty_claims.claim.transition' })
|
|
1927
|
+
await emitClaimCrud(ctx, 'updated', claim)
|
|
1928
|
+
await emitClaimStatusChanged(ctx, claim, fromStatus, input.toStatus)
|
|
1929
|
+
return { claimId: claim.id }
|
|
1930
|
+
},
|
|
1931
|
+
captureAfter: async (_rawInput, result, ctx) => {
|
|
1932
|
+
const scope = resolveScope(ctx, {})
|
|
1933
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
1934
|
+
return loadClaimSnapshot(em, result.claimId, scope)
|
|
1935
|
+
},
|
|
1936
|
+
buildLog: async ({ result, snapshots }) => buildClaimLog('warranty_claims.audit.claim.transition', result.claimId, snapshots),
|
|
1937
|
+
undo: async ({ logEntry, ctx }) => {
|
|
1938
|
+
const payload = extractUndoPayload<ClaimUndoPayload>(logEntry)
|
|
1939
|
+
const before = payload?.before
|
|
1940
|
+
if (!before) return
|
|
1941
|
+
const after = payload?.after
|
|
1942
|
+
if (!after) throwClaimUndoConflict()
|
|
1943
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
1944
|
+
let claim!: WarrantyClaim
|
|
1945
|
+
await withAtomicFlush(em, [
|
|
1946
|
+
async () => {
|
|
1947
|
+
claim = await restoreSnapshot(em, before, after)
|
|
1948
|
+
appendClaimEvent(em, claim, 'system', {
|
|
1949
|
+
visibility: 'internal',
|
|
1950
|
+
payload: { action: 'undo_status_transition' },
|
|
1951
|
+
actorUserId: ctx.auth?.sub ?? null,
|
|
1952
|
+
})
|
|
1953
|
+
},
|
|
1954
|
+
], { transaction: true, label: 'warranty_claims.claim.transition.undo' })
|
|
1955
|
+
await emitClaimUndoCrud(ctx, 'updated', claim)
|
|
1956
|
+
},
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
const assignClaimCommand: CommandHandler<AssignClaimInput, { claimId: string }> = {
|
|
1960
|
+
id: 'warranty_claims.claim.assign',
|
|
1961
|
+
async execute(rawInput, ctx) {
|
|
1962
|
+
const input = parseCommandInput(assignClaimInputSchema, rawInput)
|
|
1963
|
+
const scope = resolveScope(ctx, {})
|
|
1964
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
1965
|
+
const claim = await requireScopedClaim(em, input.id, scope)
|
|
1966
|
+
await enforceWarrantyClaimOptimisticLock(ctx, claim)
|
|
1967
|
+
await validateAssigneeUser(ctx, scope, input.assigneeUserId ?? null)
|
|
1968
|
+
await withAtomicFlush(em, [
|
|
1969
|
+
() => {
|
|
1970
|
+
applyClaimAssignment(em, claim, input.assigneeUserId ?? null, ctx.auth?.sub ?? null, new Date())
|
|
1971
|
+
},
|
|
1972
|
+
], { transaction: true, label: 'warranty_claims.claim.assign' })
|
|
1973
|
+
await emitClaimCrud(ctx, 'updated', claim)
|
|
1974
|
+
await emitWarrantyClaimsEvent('warranty_claims.claim.assigned', {
|
|
1975
|
+
...claimEventPayload(claim),
|
|
1976
|
+
assigneeUserId: claim.assigneeUserId,
|
|
1977
|
+
}, claimPersistentEmitOptions(claim))
|
|
1978
|
+
return { claimId: claim.id }
|
|
1979
|
+
},
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
const setReturnLabelCommand: CommandHandler<ClaimSetReturnLabelInput, ClaimSetReturnLabelResult> = {
|
|
1983
|
+
id: 'warranty_claims.claim.set_return_label',
|
|
1984
|
+
isUndoable: true,
|
|
1985
|
+
prepare: async (rawInput, ctx) => {
|
|
1986
|
+
const input = parseCommandInput(claimSetReturnLabelSchema, rawInput)
|
|
1987
|
+
const scope = resolveScope(ctx, input)
|
|
1988
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
1989
|
+
return { before: await loadClaimSnapshot(em, input.id, scope) }
|
|
1990
|
+
},
|
|
1991
|
+
async execute(rawInput, ctx) {
|
|
1992
|
+
const input = parseCommandInput(claimSetReturnLabelSchema, rawInput)
|
|
1993
|
+
const scope = resolveScope(ctx, input)
|
|
1994
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
1995
|
+
const claim = await requireScopedClaim(em, input.id, scope)
|
|
1996
|
+
await enforceWarrantyClaimOptimisticLock(ctx, claim, WARRANTY_CLAIM_RESOURCE_KIND, input.updatedAt ?? undefined)
|
|
1997
|
+
|
|
1998
|
+
await withAtomicFlush(em, [
|
|
1999
|
+
() => {
|
|
2000
|
+
if (input.labelUrl !== undefined) claim.returnLabelUrl = input.labelUrl
|
|
2001
|
+
if (input.trackingNumber !== undefined) claim.returnTrackingNumber = input.trackingNumber
|
|
2002
|
+
if (input.carrier !== undefined) claim.returnCarrier = input.carrier
|
|
2003
|
+
claim.updatedAt = new Date()
|
|
2004
|
+
appendClaimEvent(em, claim, 'system', {
|
|
2005
|
+
visibility: 'internal',
|
|
2006
|
+
payload: {
|
|
2007
|
+
action: 'return_label_created',
|
|
2008
|
+
carrier: claim.returnCarrier ?? null,
|
|
2009
|
+
trackingNumber: claim.returnTrackingNumber ?? null,
|
|
2010
|
+
},
|
|
2011
|
+
actorUserId: ctx.auth?.sub ?? null,
|
|
2012
|
+
})
|
|
2013
|
+
},
|
|
2014
|
+
], { transaction: true, label: 'warranty_claims.claim.set_return_label' })
|
|
2015
|
+
|
|
2016
|
+
await emitClaimCrud(ctx, 'updated', claim)
|
|
2017
|
+
await emitWarrantyClaimsEvent('warranty_claims.claim.return_label_created', {
|
|
2018
|
+
id: claim.id,
|
|
2019
|
+
claimId: claim.id,
|
|
2020
|
+
carrier: claim.returnCarrier ?? null,
|
|
2021
|
+
trackingNumber: claim.returnTrackingNumber ?? null,
|
|
2022
|
+
scope,
|
|
2023
|
+
organizationId: scope.organizationId,
|
|
2024
|
+
tenantId: scope.tenantId,
|
|
2025
|
+
}, claimPersistentEmitOptions(claim))
|
|
2026
|
+
|
|
2027
|
+
return {
|
|
2028
|
+
claimId: claim.id,
|
|
2029
|
+
labelUrl: claim.returnLabelUrl ?? null,
|
|
2030
|
+
trackingNumber: claim.returnTrackingNumber ?? null,
|
|
2031
|
+
carrier: claim.returnCarrier ?? null,
|
|
2032
|
+
}
|
|
2033
|
+
},
|
|
2034
|
+
captureAfter: async (rawInput, result, ctx) => {
|
|
2035
|
+
const input = parseCommandInput(claimSetReturnLabelSchema, rawInput)
|
|
2036
|
+
const scope = resolveScope(ctx, input)
|
|
2037
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
2038
|
+
return loadClaimSnapshot(em, result.claimId, scope)
|
|
2039
|
+
},
|
|
2040
|
+
buildLog: async ({ result, snapshots }) => buildClaimLog('warranty_claims.audit.claim.set_return_label', result.claimId, snapshots),
|
|
2041
|
+
undo: async ({ logEntry, ctx }) => {
|
|
2042
|
+
const payload = extractUndoPayload<ClaimUndoPayload>(logEntry)
|
|
2043
|
+
const before = payload?.before
|
|
2044
|
+
if (!before) return
|
|
2045
|
+
const after = payload?.after
|
|
2046
|
+
if (!after) throwClaimUndoConflict()
|
|
2047
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
2048
|
+
let claim!: WarrantyClaim
|
|
2049
|
+
await withAtomicFlush(em, [
|
|
2050
|
+
async () => {
|
|
2051
|
+
claim = await restoreSnapshot(em, before, after)
|
|
2052
|
+
appendClaimEvent(em, claim, 'system', {
|
|
2053
|
+
visibility: 'internal',
|
|
2054
|
+
payload: { action: 'undo_return_label' },
|
|
2055
|
+
actorUserId: ctx.auth?.sub ?? null,
|
|
2056
|
+
})
|
|
2057
|
+
},
|
|
2058
|
+
], { transaction: true, label: 'warranty_claims.claim.set_return_label.undo' })
|
|
2059
|
+
await emitClaimUndoCrud(ctx, 'updated', claim)
|
|
2060
|
+
},
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
const escalateClaimCommand: CommandHandler<EscalateClaimInput, EscalateClaimResult> = {
|
|
2064
|
+
id: 'warranty_claims.claim.escalate',
|
|
2065
|
+
async execute(rawInput, ctx) {
|
|
2066
|
+
const input = parseCommandInput(escalateClaimInputSchema, rawInput)
|
|
2067
|
+
const scope = resolveScope(ctx, input)
|
|
2068
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
2069
|
+
if (input.reassignToUserId) {
|
|
2070
|
+
await validateAssigneeUser(ctx, scope, input.reassignToUserId)
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
let claim: WarrantyClaim | null = null
|
|
2074
|
+
let escalated = false
|
|
2075
|
+
await withAtomicFlush(em, [
|
|
2076
|
+
async () => {
|
|
2077
|
+
claim = await requireScopedClaim(em, input.id, scope, { lockMode: LockMode.PESSIMISTIC_WRITE })
|
|
2078
|
+
if ((claim.escalationLevel ?? 0) >= input.toLevel) return
|
|
2079
|
+
|
|
2080
|
+
const now = new Date()
|
|
2081
|
+
claim.escalationLevel = input.toLevel
|
|
2082
|
+
claim.escalatedAt = now
|
|
2083
|
+
claim.updatedAt = now
|
|
2084
|
+
appendClaimEvent(em, claim, 'system', {
|
|
2085
|
+
visibility: 'internal',
|
|
2086
|
+
payload: {
|
|
2087
|
+
action: 'sla_escalated',
|
|
2088
|
+
level: input.toLevel,
|
|
2089
|
+
reassignToUserId: input.reassignToUserId ?? null,
|
|
2090
|
+
},
|
|
2091
|
+
actorUserId: ctx.auth?.sub ?? null,
|
|
2092
|
+
})
|
|
2093
|
+
if (input.reassignToUserId) {
|
|
2094
|
+
applyClaimAssignment(em, claim, input.reassignToUserId, ctx.auth?.sub ?? null, now)
|
|
2095
|
+
}
|
|
2096
|
+
escalated = true
|
|
2097
|
+
},
|
|
2098
|
+
], { transaction: true, label: 'warranty_claims.claim.escalate' })
|
|
2099
|
+
|
|
2100
|
+
const escalatedClaim = claim as WarrantyClaim | null
|
|
2101
|
+
if (!escalatedClaim) {
|
|
2102
|
+
throw new CrudHttpError(404, { error: 'warranty_claims.errors.notFound' })
|
|
2103
|
+
}
|
|
2104
|
+
if (!escalated) {
|
|
2105
|
+
return { claimId: escalatedClaim.id, escalationLevel: escalatedClaim.escalationLevel ?? 0, escalated: false }
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
await emitClaimCrud(ctx, 'updated', escalatedClaim)
|
|
2109
|
+
if (input.reassignToUserId) {
|
|
2110
|
+
await emitWarrantyClaimsEvent('warranty_claims.claim.assigned', {
|
|
2111
|
+
...claimEventPayload(escalatedClaim),
|
|
2112
|
+
assigneeUserId: escalatedClaim.assigneeUserId ?? null,
|
|
2113
|
+
}, claimPersistentEmitOptions(escalatedClaim))
|
|
2114
|
+
}
|
|
2115
|
+
await emitWarrantyClaimsEvent('warranty_claims.claim.escalated', {
|
|
2116
|
+
...claimEventPayload(escalatedClaim),
|
|
2117
|
+
level: escalatedClaim.escalationLevel,
|
|
2118
|
+
escalationLevel: escalatedClaim.escalationLevel,
|
|
2119
|
+
reassignToUserId: input.reassignToUserId ?? null,
|
|
2120
|
+
}, claimPersistentEmitOptions(escalatedClaim))
|
|
2121
|
+
|
|
2122
|
+
return { claimId: escalatedClaim.id, escalationLevel: escalatedClaim.escalationLevel, escalated: true }
|
|
2123
|
+
},
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
const commentClaimCommand: CommandHandler<CommentClaimInput, { claimId: string }> = {
|
|
2127
|
+
id: 'warranty_claims.claim.comment',
|
|
2128
|
+
async execute(rawInput, ctx) {
|
|
2129
|
+
const input = parseCommandInput(commentClaimInputSchema, rawInput)
|
|
2130
|
+
const scope = resolveScope(ctx, {})
|
|
2131
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
2132
|
+
const claim = await requireScopedClaim(em, input.claimId, scope)
|
|
2133
|
+
await enforceWarrantyClaimOptimisticLock(ctx, claim)
|
|
2134
|
+
let autoResumed = false
|
|
2135
|
+
await withAtomicFlush(em, [
|
|
2136
|
+
async () => {
|
|
2137
|
+
const fromStatus = claim.status
|
|
2138
|
+
appendClaimEvent(em, claim, 'comment', {
|
|
2139
|
+
visibility: input.visibility,
|
|
2140
|
+
body: input.body,
|
|
2141
|
+
actorUserId: input.actorCustomerId ? null : (ctx.auth?.sub ?? null),
|
|
2142
|
+
actorCustomerId: input.actorCustomerId ?? null,
|
|
2143
|
+
})
|
|
2144
|
+
claim.awaitingStaffReply = Boolean(input.actorCustomerId)
|
|
2145
|
+
claim.updatedAt = new Date()
|
|
2146
|
+
if (input.actorCustomerId && fromStatus === 'info_requested') {
|
|
2147
|
+
const now = new Date()
|
|
2148
|
+
const effectiveSettings = await resolveEffectiveWarrantyClaimSettings(em, scope)
|
|
2149
|
+
claim.status = 'in_review'
|
|
2150
|
+
applySlaResume(em, claim, fromStatus, 'in_review', now, effectiveSettings)
|
|
2151
|
+
claim.updatedAt = now
|
|
2152
|
+
autoResumed = true
|
|
2153
|
+
appendClaimEvent(em, claim, 'status_changed', {
|
|
2154
|
+
visibility: 'customer',
|
|
2155
|
+
payload: { from: fromStatus, to: 'in_review' },
|
|
2156
|
+
actorUserId: null,
|
|
2157
|
+
})
|
|
2158
|
+
}
|
|
2159
|
+
},
|
|
2160
|
+
], { transaction: true, label: 'warranty_claims.claim.comment' })
|
|
2161
|
+
if (input.visibility === 'customer') {
|
|
2162
|
+
await emitCustomerVisibleCommentAdded(ctx, claim, input)
|
|
2163
|
+
}
|
|
2164
|
+
await emitClaimCrud(ctx, 'updated', claim)
|
|
2165
|
+
if (autoResumed) {
|
|
2166
|
+
await emitClaimStatusChanged(ctx, claim, 'info_requested', 'in_review')
|
|
2167
|
+
}
|
|
2168
|
+
return { claimId: claim.id }
|
|
2169
|
+
},
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
const createVendorRecoveryCommand: CommandHandler<VendorRecoveryInput, { claimId: string }> = {
|
|
2173
|
+
id: 'warranty_claims.claim.create_vendor_recovery',
|
|
2174
|
+
async execute(rawInput, ctx) {
|
|
2175
|
+
const input = parseCommandInput(vendorRecoveryInputSchema, rawInput)
|
|
2176
|
+
const scope = resolveScope(ctx, input)
|
|
2177
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
2178
|
+
const sourceClaim = await requireScopedClaim(em, input.claimId, scope)
|
|
2179
|
+
await enforceWarrantyClaimOptimisticLock(ctx, sourceClaim)
|
|
2180
|
+
const preflightLines = await findWithDecryption(
|
|
2181
|
+
em,
|
|
2182
|
+
WarrantyClaimLine,
|
|
2183
|
+
{
|
|
2184
|
+
id: { $in: input.lineIds },
|
|
2185
|
+
claim: sourceClaim.id,
|
|
2186
|
+
tenantId: scope.tenantId,
|
|
2187
|
+
organizationId: scope.organizationId,
|
|
2188
|
+
deletedAt: null,
|
|
2189
|
+
},
|
|
2190
|
+
{},
|
|
2191
|
+
scope,
|
|
2192
|
+
)
|
|
2193
|
+
assertVendorRecoveryLines(preflightLines, input.lineIds)
|
|
2194
|
+
const numberGenerator = resolveClaimNumberGenerator(ctx, em)
|
|
2195
|
+
const generated = await numberGenerator.generate({
|
|
2196
|
+
claimType: 'vendor_recovery',
|
|
2197
|
+
tenantId: scope.tenantId,
|
|
2198
|
+
organizationId: scope.organizationId,
|
|
2199
|
+
})
|
|
2200
|
+
let recoveryClaim!: WarrantyClaim
|
|
2201
|
+
let copiedLines: WarrantyClaimLine[] = []
|
|
2202
|
+
let updatedSourceLines: WarrantyClaimLine[] = []
|
|
2203
|
+
|
|
2204
|
+
await em.transactional(async (tx) => {
|
|
2205
|
+
const lockedSource = await requireScopedClaim(tx, sourceClaim.id, scope, { lockMode: LockMode.PESSIMISTIC_WRITE })
|
|
2206
|
+
const lockedLines = await findWithDecryption(
|
|
2207
|
+
tx,
|
|
2208
|
+
WarrantyClaimLine,
|
|
2209
|
+
{
|
|
2210
|
+
id: { $in: input.lineIds },
|
|
2211
|
+
claim: lockedSource.id,
|
|
2212
|
+
tenantId: scope.tenantId,
|
|
2213
|
+
organizationId: scope.organizationId,
|
|
2214
|
+
deletedAt: null,
|
|
2215
|
+
},
|
|
2216
|
+
{ lockMode: LockMode.PESSIMISTIC_WRITE },
|
|
2217
|
+
scope,
|
|
2218
|
+
)
|
|
2219
|
+
assertVendorRecoveryLines(lockedLines, input.lineIds)
|
|
2220
|
+
updatedSourceLines = lockedLines
|
|
2221
|
+
recoveryClaim = tx.create(WarrantyClaim, {
|
|
2222
|
+
id: randomUUID(),
|
|
2223
|
+
organizationId: scope.organizationId,
|
|
2224
|
+
tenantId: scope.tenantId,
|
|
2225
|
+
claimNumber: generated.number,
|
|
2226
|
+
claimType: 'vendor_recovery',
|
|
2227
|
+
status: 'draft',
|
|
2228
|
+
channel: 'staff',
|
|
2229
|
+
priority: lockedSource.priority,
|
|
2230
|
+
customerId: lockedSource.customerId ?? null,
|
|
2231
|
+
customerName: lockedSource.customerName ?? null,
|
|
2232
|
+
vendorName: input.vendorName,
|
|
2233
|
+
vendorRef: input.vendorRef ?? null,
|
|
2234
|
+
sourceClaimId: lockedSource.id,
|
|
2235
|
+
advanceReplacement: false,
|
|
2236
|
+
currencyCode: lockedSource.currencyCode ?? null,
|
|
2237
|
+
totalClaimedAmount: '0',
|
|
2238
|
+
totalApprovedAmount: '0',
|
|
2239
|
+
totalRecoveredAmount: '0',
|
|
2240
|
+
createdAt: new Date(),
|
|
2241
|
+
updatedAt: new Date(),
|
|
2242
|
+
})
|
|
2243
|
+
tx.persist(recoveryClaim)
|
|
2244
|
+
const lineOrder = new Map(input.lineIds.map((id, index) => [id, index]))
|
|
2245
|
+
copiedLines = [...lockedLines]
|
|
2246
|
+
.sort((left, right) => (lineOrder.get(left.id) ?? 0) - (lineOrder.get(right.id) ?? 0))
|
|
2247
|
+
.map((sourceLine, index) => {
|
|
2248
|
+
const copiedLine = tx.create(WarrantyClaimLine, {
|
|
2249
|
+
id: randomUUID(),
|
|
2250
|
+
claim: recoveryClaim,
|
|
2251
|
+
organizationId: scope.organizationId,
|
|
2252
|
+
tenantId: scope.tenantId,
|
|
2253
|
+
lineNo: index + 1,
|
|
2254
|
+
productId: sourceLine.productId ?? null,
|
|
2255
|
+
variantId: sourceLine.variantId ?? null,
|
|
2256
|
+
sku: sourceLine.sku ?? null,
|
|
2257
|
+
productName: sourceLine.productName ?? null,
|
|
2258
|
+
orderLineId: sourceLine.orderLineId ?? null,
|
|
2259
|
+
serialNumber: sourceLine.serialNumber ?? null,
|
|
2260
|
+
lotNumber: sourceLine.lotNumber ?? null,
|
|
2261
|
+
purchaseDate: sourceLine.purchaseDate ?? null,
|
|
2262
|
+
warrantyMonths: sourceLine.warrantyMonths ?? null,
|
|
2263
|
+
warrantyExpiresAt: sourceLine.warrantyExpiresAt ?? null,
|
|
2264
|
+
warrantyStatus: sourceLine.warrantyStatus,
|
|
2265
|
+
faultCode: sourceLine.faultCode ?? null,
|
|
2266
|
+
faultDescription: sourceLine.faultDescription ?? null,
|
|
2267
|
+
qtyClaimed: sourceLine.qtyApproved ?? sourceLine.qtyClaimed,
|
|
2268
|
+
qtyApproved: sourceLine.qtyApproved ?? null,
|
|
2269
|
+
conditionOnReceipt: sourceLine.conditionOnReceipt ?? null,
|
|
2270
|
+
inspectionNotes: sourceLine.inspectionNotes ?? null,
|
|
2271
|
+
disposition: sourceLine.disposition ?? null,
|
|
2272
|
+
lineStatus: 'pending',
|
|
2273
|
+
creditAmount: sourceLine.creditAmount ?? null,
|
|
2274
|
+
restockingFee: sourceLine.restockingFee ?? null,
|
|
2275
|
+
coreChargeAmount: sourceLine.coreChargeAmount ?? null,
|
|
2276
|
+
coreCreditAmount: sourceLine.coreCreditAmount ?? null,
|
|
2277
|
+
createdAt: new Date(),
|
|
2278
|
+
updatedAt: new Date(),
|
|
2279
|
+
})
|
|
2280
|
+
sourceLine.vendorClaimLineId = copiedLine.id
|
|
2281
|
+
sourceLine.updatedAt = new Date()
|
|
2282
|
+
tx.persist(copiedLine)
|
|
2283
|
+
return copiedLine
|
|
2284
|
+
})
|
|
2285
|
+
const totals = computeHeaderRollups(copiedLines)
|
|
2286
|
+
recoveryClaim.totalClaimedAmount = String(totals.totalClaimedAmount)
|
|
2287
|
+
recoveryClaim.totalApprovedAmount = String(totals.totalApprovedAmount)
|
|
2288
|
+
appendClaimEvent(tx, lockedSource, 'system', {
|
|
2289
|
+
visibility: 'internal',
|
|
2290
|
+
payload: { action: 'vendor_recovery_created', recoveryClaimId: recoveryClaim.id },
|
|
2291
|
+
actorUserId: ctx.auth?.sub ?? null,
|
|
2292
|
+
})
|
|
2293
|
+
appendClaimEvent(tx, recoveryClaim, 'system', {
|
|
2294
|
+
visibility: 'internal',
|
|
2295
|
+
payload: { action: 'created_from_source_claim', sourceClaimId: lockedSource.id },
|
|
2296
|
+
actorUserId: ctx.auth?.sub ?? null,
|
|
2297
|
+
})
|
|
2298
|
+
await tx.flush()
|
|
2299
|
+
})
|
|
2300
|
+
|
|
2301
|
+
await emitClaimCrud(ctx, 'created', recoveryClaim)
|
|
2302
|
+
await Promise.all(copiedLines.map((line) => emitLineCrud(ctx, 'created', line)))
|
|
2303
|
+
await Promise.all(updatedSourceLines.map((line) => emitLineCrud(ctx, 'updated', line)))
|
|
2304
|
+
await Promise.all(input.lineIds.map((sourceLineId) => invalidateCrudCache(
|
|
2305
|
+
ctx.container,
|
|
2306
|
+
'warranty_claims.claim_line',
|
|
2307
|
+
{ id: sourceLineId, organizationId: sourceClaim.organizationId, tenantId: sourceClaim.tenantId },
|
|
2308
|
+
ctx.auth?.tenantId ?? null,
|
|
2309
|
+
'warranty_claims.vendor_recovery.source_lines',
|
|
2310
|
+
)))
|
|
2311
|
+
return { claimId: recoveryClaim.id }
|
|
2312
|
+
},
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
const salesReturnBridgeLogger = createLogger('warranty_claims')
|
|
2316
|
+
|
|
2317
|
+
const SALES_RETURN_ELIGIBLE_CLAIM_STATUSES = new Set<WarrantyClaimStatus>(['approved', 'awaiting_return', 'received', 'inspecting'])
|
|
2318
|
+
const SALES_RETURN_ELIGIBLE_LINE_STATUSES = new Set<WarrantyClaimLineStatus>(['approved', 'received', 'inspected', 'resolved'])
|
|
2319
|
+
|
|
2320
|
+
type ClaimSalesReturnUndoPayload = ClaimUndoPayload & {
|
|
2321
|
+
salesReturn?: { id: string; orderId: string; updatedAt: string | null } | null
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
type ClaimCreateSalesReturnResult = {
|
|
2325
|
+
claimId: string
|
|
2326
|
+
salesReturnId: string
|
|
2327
|
+
salesReturnUpdatedAt: string | null
|
|
2328
|
+
skippedLineIds: string[]
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
function scrubbedDispatchContext(ctx: CommandRuntimeContext): CommandRuntimeContext {
|
|
2332
|
+
return { ...ctx, request: undefined }
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
function isSalesQuantityRejection(err: unknown): boolean {
|
|
2336
|
+
if (!(err instanceof CrudHttpError) || err.status !== 400) return false
|
|
2337
|
+
const body = err.body as { error?: unknown } | null
|
|
2338
|
+
const message = typeof body?.error === 'string' ? body.error : ''
|
|
2339
|
+
return message.includes('quantityExceedsShipped') || message.includes('Cannot return more than the shipped quantity')
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
async function dispatchSalesReturnDelete(
|
|
2343
|
+
ctx: CommandRuntimeContext,
|
|
2344
|
+
scope: WarrantyClaimScope,
|
|
2345
|
+
salesReturnId: string,
|
|
2346
|
+
orderId: string,
|
|
2347
|
+
): Promise<void> {
|
|
2348
|
+
const commandBus = ctx.container.resolve('commandBus') as CommandBus
|
|
2349
|
+
await commandBus.execute(
|
|
2350
|
+
'sales.returns.delete',
|
|
2351
|
+
{
|
|
2352
|
+
input: { id: salesReturnId, orderId, tenantId: scope.tenantId, organizationId: scope.organizationId },
|
|
2353
|
+
ctx: scrubbedDispatchContext(ctx),
|
|
2354
|
+
},
|
|
2355
|
+
)
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
const createSalesReturnCommand: CommandHandler<ClaimCreateSalesReturnInput, ClaimCreateSalesReturnResult> = {
|
|
2359
|
+
id: 'warranty_claims.claim.create_sales_return',
|
|
2360
|
+
isUndoable: true,
|
|
2361
|
+
prepare: async (rawInput, ctx) => {
|
|
2362
|
+
const input = parseCommandInput(claimCreateSalesReturnSchema, rawInput)
|
|
2363
|
+
const scope = resolveScope(ctx, input)
|
|
2364
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
2365
|
+
return { before: await loadClaimSnapshot(em, input.id, scope) }
|
|
2366
|
+
},
|
|
2367
|
+
async execute(rawInput, ctx) {
|
|
2368
|
+
const input = parseCommandInput(claimCreateSalesReturnSchema, rawInput)
|
|
2369
|
+
const scope = resolveScope(ctx, input)
|
|
2370
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
2371
|
+
const claim = await requireScopedClaim(em, input.id, scope)
|
|
2372
|
+
await enforceWarrantyClaimOptimisticLock(ctx, claim, WARRANTY_CLAIM_RESOURCE_KIND, input.updatedAt ?? undefined)
|
|
2373
|
+
if (!claim.orderId) {
|
|
2374
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.salesReturnRequiresOrder' })
|
|
2375
|
+
}
|
|
2376
|
+
if (!SALES_RETURN_ELIGIBLE_CLAIM_STATUSES.has(claim.status as WarrantyClaimStatus)) {
|
|
2377
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.salesReturnStatusNotEligible' })
|
|
2378
|
+
}
|
|
2379
|
+
if (claim.salesReturnId) {
|
|
2380
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.salesReturnAlreadyLinked' })
|
|
2381
|
+
}
|
|
2382
|
+
if (!resolveOptionalEntityId('sales', 'sales_return')) {
|
|
2383
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.salesUnavailable' })
|
|
2384
|
+
}
|
|
2385
|
+
const lines = await findWithDecryption(
|
|
2386
|
+
em,
|
|
2387
|
+
WarrantyClaimLine,
|
|
2388
|
+
{ claim: claim.id, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },
|
|
2389
|
+
{},
|
|
2390
|
+
scope,
|
|
2391
|
+
)
|
|
2392
|
+
const eligibleLines: Array<{ orderLineId: string; quantity: string }> = []
|
|
2393
|
+
const skippedLineIds: string[] = []
|
|
2394
|
+
for (const line of lines) {
|
|
2395
|
+
const effectiveQty = line.qtyApproved ?? line.qtyClaimed
|
|
2396
|
+
const quantityUnits = claimedQuantityUnits(effectiveQty) ?? 0n
|
|
2397
|
+
const eligible = Boolean(line.orderLineId)
|
|
2398
|
+
&& SALES_RETURN_ELIGIBLE_LINE_STATUSES.has(line.lineStatus as WarrantyClaimLineStatus)
|
|
2399
|
+
&& quantityUnits > 0n
|
|
2400
|
+
&& quantityUnits % CLAIMED_QUANTITY_SCALE === 0n
|
|
2401
|
+
if (eligible && line.orderLineId) {
|
|
2402
|
+
eligibleLines.push({ orderLineId: line.orderLineId, quantity: String(effectiveQty) })
|
|
2403
|
+
} else {
|
|
2404
|
+
skippedLineIds.push(line.id)
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
if (eligibleLines.length === 0) {
|
|
2408
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.salesReturnNoEligibleLines' })
|
|
2409
|
+
}
|
|
2410
|
+
const commandBus = ctx.container.resolve('commandBus') as CommandBus
|
|
2411
|
+
let salesReturnId: string
|
|
2412
|
+
try {
|
|
2413
|
+
const { result: dispatchedResult } = await commandBus.execute<
|
|
2414
|
+
{ orderId: string; reason?: string; tenantId: string; organizationId: string; lines: Array<{ orderLineId: string; quantity: string }> },
|
|
2415
|
+
{ returnId: string }
|
|
2416
|
+
>(
|
|
2417
|
+
'sales.returns.create',
|
|
2418
|
+
{
|
|
2419
|
+
input: {
|
|
2420
|
+
orderId: claim.orderId,
|
|
2421
|
+
reason: claim.claimNumber,
|
|
2422
|
+
tenantId: scope.tenantId,
|
|
2423
|
+
organizationId: scope.organizationId,
|
|
2424
|
+
lines: eligibleLines,
|
|
2425
|
+
},
|
|
2426
|
+
ctx: scrubbedDispatchContext(ctx),
|
|
2427
|
+
},
|
|
2428
|
+
)
|
|
2429
|
+
if (!dispatchedResult?.returnId) {
|
|
2430
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.save_failed' })
|
|
2431
|
+
}
|
|
2432
|
+
salesReturnId = dispatchedResult.returnId
|
|
2433
|
+
} catch (err) {
|
|
2434
|
+
if (isSalesQuantityRejection(err)) {
|
|
2435
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.salesReturnQuantityRejected' })
|
|
2436
|
+
}
|
|
2437
|
+
throw err
|
|
2438
|
+
}
|
|
2439
|
+
let salesReturnUpdatedAt: string | null = null
|
|
2440
|
+
try {
|
|
2441
|
+
const createdReturnRow = await querySalesReferenceRow(ctx, 'sales_returns', scope, salesReturnId, ['id', 'updated_at'])
|
|
2442
|
+
salesReturnUpdatedAt = createdReturnRow?.updated_at
|
|
2443
|
+
? new Date(createdReturnRow.updated_at as string | Date).toISOString()
|
|
2444
|
+
: null
|
|
2445
|
+
await em.transactional(async (tx) => {
|
|
2446
|
+
const lockedClaim = await requireScopedClaim(tx, claim.id, scope, { lockMode: LockMode.PESSIMISTIC_WRITE })
|
|
2447
|
+
if (lockedClaim.salesReturnId) {
|
|
2448
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.salesReturnAlreadyLinked' })
|
|
2449
|
+
}
|
|
2450
|
+
lockedClaim.salesReturnId = salesReturnId
|
|
2451
|
+
lockedClaim.updatedAt = new Date()
|
|
2452
|
+
appendClaimEvent(tx, lockedClaim, 'system', {
|
|
2453
|
+
visibility: 'internal',
|
|
2454
|
+
payload: { action: 'sales_return_created', salesReturnId },
|
|
2455
|
+
actorUserId: ctx.auth?.sub ?? null,
|
|
2456
|
+
})
|
|
2457
|
+
claim.salesReturnId = salesReturnId
|
|
2458
|
+
claim.updatedAt = lockedClaim.updatedAt
|
|
2459
|
+
await tx.flush()
|
|
2460
|
+
})
|
|
2461
|
+
} catch (err) {
|
|
2462
|
+
try {
|
|
2463
|
+
await dispatchSalesReturnDelete(ctx, scope, salesReturnId, claim.orderId)
|
|
2464
|
+
} catch (compensationErr) {
|
|
2465
|
+
salesReturnBridgeLogger.error('warranty_claims.claim.create_sales_return compensation failed — orphaned sales return', {
|
|
2466
|
+
err: compensationErr,
|
|
2467
|
+
claimId: claim.id,
|
|
2468
|
+
salesReturnId,
|
|
2469
|
+
})
|
|
2470
|
+
const orphanEm = (ctx.container.resolve('em') as EntityManager).fork()
|
|
2471
|
+
const orphanClaim = await findOneWithDecryption(
|
|
2472
|
+
orphanEm,
|
|
2473
|
+
WarrantyClaim,
|
|
2474
|
+
{ id: claim.id, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },
|
|
2475
|
+
{},
|
|
2476
|
+
scope,
|
|
2477
|
+
)
|
|
2478
|
+
if (orphanClaim) {
|
|
2479
|
+
try {
|
|
2480
|
+
appendClaimEvent(orphanEm, orphanClaim, 'system', {
|
|
2481
|
+
visibility: 'internal',
|
|
2482
|
+
payload: { action: 'sales_return_orphaned', salesReturnId },
|
|
2483
|
+
actorUserId: ctx.auth?.sub ?? null,
|
|
2484
|
+
})
|
|
2485
|
+
await orphanEm.flush()
|
|
2486
|
+
} catch (orphanEventErr) {
|
|
2487
|
+
salesReturnBridgeLogger.error('warranty_claims.claim.create_sales_return orphan timeline write failed', {
|
|
2488
|
+
err: orphanEventErr,
|
|
2489
|
+
claimId: claim.id,
|
|
2490
|
+
salesReturnId,
|
|
2491
|
+
})
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
throw new CrudHttpError(500, { error: 'warranty_claims.errors.save_failed' })
|
|
2495
|
+
}
|
|
2496
|
+
throw err
|
|
2497
|
+
}
|
|
2498
|
+
await emitClaimCrud(ctx, 'updated', claim)
|
|
2499
|
+
return { claimId: claim.id, salesReturnId, salesReturnUpdatedAt, skippedLineIds }
|
|
2500
|
+
},
|
|
2501
|
+
captureAfter: async (rawInput, result, ctx) => {
|
|
2502
|
+
const input = parseCommandInput(claimCreateSalesReturnSchema, rawInput)
|
|
2503
|
+
const scope = resolveScope(ctx, input)
|
|
2504
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
2505
|
+
return loadClaimSnapshot(em, result.claimId, scope)
|
|
2506
|
+
},
|
|
2507
|
+
buildLog: async ({ result, snapshots }) => {
|
|
2508
|
+
const base = buildClaimLog('warranty_claims.audit.claim.create_sales_return', result.claimId, snapshots)
|
|
2509
|
+
const after = snapshots.after as { orderId?: string | null } | null | undefined
|
|
2510
|
+
const undoPayload: ClaimSalesReturnUndoPayload = {
|
|
2511
|
+
...(base.payload.undo as ClaimUndoPayload),
|
|
2512
|
+
salesReturn: {
|
|
2513
|
+
id: result.salesReturnId,
|
|
2514
|
+
orderId: after?.orderId ?? '',
|
|
2515
|
+
updatedAt: result.salesReturnUpdatedAt,
|
|
2516
|
+
},
|
|
2517
|
+
}
|
|
2518
|
+
return { ...base, payload: { undo: undoPayload } }
|
|
2519
|
+
},
|
|
2520
|
+
undo: async ({ logEntry, ctx }) => {
|
|
2521
|
+
const payload = extractUndoPayload<ClaimSalesReturnUndoPayload>(logEntry)
|
|
2522
|
+
const before = payload?.before
|
|
2523
|
+
if (!before) return
|
|
2524
|
+
const after = payload?.after
|
|
2525
|
+
if (!after) throwClaimUndoConflict()
|
|
2526
|
+
await assertUndoSnapshotCurrent(ctx, after)
|
|
2527
|
+
const scope: WarrantyClaimScope = { tenantId: before.tenantId, organizationId: before.organizationId }
|
|
2528
|
+
const salesReturn = payload?.salesReturn ?? null
|
|
2529
|
+
if (salesReturn?.id && salesReturn.orderId) {
|
|
2530
|
+
const currentRow = await querySalesReferenceRow(ctx, 'sales_returns', scope, salesReturn.id, ['id', 'updated_at'])
|
|
2531
|
+
if (currentRow) {
|
|
2532
|
+
const currentUpdatedAt = currentRow.updated_at
|
|
2533
|
+
? new Date(currentRow.updated_at as string | Date).toISOString()
|
|
2534
|
+
: null
|
|
2535
|
+
if (!salesReturn.updatedAt || (currentUpdatedAt && currentUpdatedAt !== salesReturn.updatedAt)) {
|
|
2536
|
+
throw new CrudHttpError(409, { error: 'warranty_claims.errors.salesReturnChangedUndoAborted' })
|
|
2537
|
+
}
|
|
2538
|
+
await dispatchSalesReturnDelete(ctx, scope, salesReturn.id, salesReturn.orderId)
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
2542
|
+
let claim!: WarrantyClaim
|
|
2543
|
+
await withAtomicFlush(em, [
|
|
2544
|
+
async () => {
|
|
2545
|
+
claim = await restoreSnapshot(em, before, after)
|
|
2546
|
+
appendClaimEvent(em, claim, 'system', {
|
|
2547
|
+
visibility: 'internal',
|
|
2548
|
+
payload: { action: 'undo_sales_return_created' },
|
|
2549
|
+
actorUserId: ctx.auth?.sub ?? null,
|
|
2550
|
+
})
|
|
2551
|
+
},
|
|
2552
|
+
], { transaction: true, label: 'warranty_claims.claim.create_sales_return.undo' })
|
|
2553
|
+
await emitClaimUndoCrud(ctx, 'updated', claim)
|
|
2554
|
+
},
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
const creditMemoBridgeLogger = createLogger('warranty_claims')
|
|
2558
|
+
|
|
2559
|
+
const CREDIT_MEMO_ELIGIBLE_CLAIM_STATUSES = new Set<WarrantyClaimStatus>(['received', 'inspecting', 'resolved'])
|
|
2560
|
+
const CREDIT_MEMO_ELIGIBLE_LINE_STATUSES = new Set<WarrantyClaimLineStatus>(['received', 'inspected', 'resolved'])
|
|
2561
|
+
const CREDIT_MEMO_ELIGIBLE_DISPOSITIONS = new Set<WarrantyClaimDisposition>(['credit', 'refund'])
|
|
2562
|
+
|
|
2563
|
+
type CreditMemoLineInput = {
|
|
2564
|
+
orderLineId: string
|
|
2565
|
+
quantity: string
|
|
2566
|
+
currencyCode: string
|
|
2567
|
+
unitPriceNet: string
|
|
2568
|
+
unitPriceGross: string
|
|
2569
|
+
taxRate: string
|
|
2570
|
+
taxAmount: string
|
|
2571
|
+
totalNetAmount: string
|
|
2572
|
+
totalGrossAmount: string
|
|
2573
|
+
metadata: { warrantyClaimLineId: string }
|
|
2574
|
+
name?: string
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
type CreditMemoCreateInput = {
|
|
2578
|
+
organizationId: string
|
|
2579
|
+
tenantId: string
|
|
2580
|
+
orderId: string
|
|
2581
|
+
currencyCode: string
|
|
2582
|
+
reason: string
|
|
2583
|
+
metadata: { warrantyClaimId: string; warrantyClaimNumber: string }
|
|
2584
|
+
lines: CreditMemoLineInput[]
|
|
2585
|
+
subtotalNetAmount: string
|
|
2586
|
+
subtotalGrossAmount: string
|
|
2587
|
+
taxTotalAmount: string
|
|
2588
|
+
grandTotalNetAmount: string
|
|
2589
|
+
grandTotalGrossAmount: string
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
type PreparedCreditMemoLine = {
|
|
2593
|
+
input: CreditMemoLineInput
|
|
2594
|
+
netUnits: bigint
|
|
2595
|
+
grossUnits: bigint
|
|
2596
|
+
taxUnits: bigint
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
type ClaimCreditMemoUndoPayload = ClaimUndoPayload & {
|
|
2600
|
+
creditMemo?: { id: string; updatedAt: string | null } | null
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
type ClaimCreateCreditMemoResult = {
|
|
2604
|
+
claimId: string
|
|
2605
|
+
creditMemoId: string
|
|
2606
|
+
creditMemoUpdatedAt: string | null
|
|
2607
|
+
skippedLineIds: string[]
|
|
2608
|
+
grandTotalGrossAmount: string
|
|
2609
|
+
currencyCode: string
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
function minimumUnits(left: bigint, right: bigint): bigint {
|
|
2613
|
+
return left < right ? left : right
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
function prepareCreditMemoLine(
|
|
2617
|
+
claimLine: WarrantyClaimLine,
|
|
2618
|
+
sourceLine: Record<string, unknown>,
|
|
2619
|
+
creditedQuantityUnits: bigint,
|
|
2620
|
+
currencyCode: string,
|
|
2621
|
+
): PreparedCreditMemoLine | null {
|
|
2622
|
+
const orderQuantityUnits = claimedQuantityUnits(sourceLine.quantity as string | number | null | undefined)
|
|
2623
|
+
const sourceGrossUnits = claimedQuantityUnits(sourceLine.total_gross_amount as string | number | null | undefined)
|
|
2624
|
+
const sourceNetUnits = claimedQuantityUnits(sourceLine.total_net_amount as string | number | null | undefined)
|
|
2625
|
+
const taxRateUnits = claimedQuantityUnits(sourceLine.tax_rate as string | number | null | undefined)
|
|
2626
|
+
if (
|
|
2627
|
+
orderQuantityUnits === null
|
|
2628
|
+
|| orderQuantityUnits <= 0n
|
|
2629
|
+
|| creditedQuantityUnits > orderQuantityUnits
|
|
2630
|
+
|| sourceGrossUnits === null
|
|
2631
|
+
|| sourceNetUnits === null
|
|
2632
|
+
|| taxRateUnits === null
|
|
2633
|
+
) {
|
|
2634
|
+
return null
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2637
|
+
let netBasisUnits = sourceNetUnits
|
|
2638
|
+
if (sourceGrossUnits > 0n && netBasisUnits <= 0n) {
|
|
2639
|
+
netBasisUnits = netUnitsFromGross(sourceGrossUnits, taxRateUnits)
|
|
2640
|
+
}
|
|
2641
|
+
const proratedGrossUnits = roundHalfUpDivision(sourceGrossUnits * creditedQuantityUnits, orderQuantityUnits)
|
|
2642
|
+
const proratedNetUnits = roundHalfUpDivision(netBasisUnits * creditedQuantityUnits, orderQuantityUnits)
|
|
2643
|
+
const creditAmountUnits = claimedQuantityUnits(claimLine.creditAmount)
|
|
2644
|
+
const restockingFeeUnits = claimedQuantityUnits(claimLine.restockingFee) ?? 0n
|
|
2645
|
+
const coreCreditUnits = claimedQuantityUnits(claimLine.coreCreditAmount) ?? 0n
|
|
2646
|
+
const baseGrossUnits = creditAmountUnits ?? proratedGrossUnits
|
|
2647
|
+
const adjustedGrossUnits = baseGrossUnits - restockingFeeUnits + coreCreditUnits
|
|
2648
|
+
const grossUnits = adjustedGrossUnits > 0n ? adjustedGrossUnits : 0n
|
|
2649
|
+
let netUnits = grossUnits === proratedGrossUnits
|
|
2650
|
+
? proratedNetUnits
|
|
2651
|
+
: netUnitsFromGross(grossUnits, taxRateUnits)
|
|
2652
|
+
if (grossUnits > 0n && netUnits <= 0n) {
|
|
2653
|
+
netUnits = netUnitsFromGross(grossUnits, taxRateUnits)
|
|
2654
|
+
}
|
|
2655
|
+
const taxUnits = grossUnits - netUnits
|
|
2656
|
+
const input: CreditMemoLineInput = {
|
|
2657
|
+
orderLineId: claimLine.orderLineId as string,
|
|
2658
|
+
quantity: formatScaledUnits(creditedQuantityUnits),
|
|
2659
|
+
currencyCode,
|
|
2660
|
+
unitPriceNet: formatScaledUnits(roundHalfUpDivision(netUnits * CLAIMED_QUANTITY_SCALE, creditedQuantityUnits)),
|
|
2661
|
+
unitPriceGross: formatScaledUnits(roundHalfUpDivision(grossUnits * CLAIMED_QUANTITY_SCALE, creditedQuantityUnits)),
|
|
2662
|
+
taxRate: formatScaledUnits(taxRateUnits),
|
|
2663
|
+
taxAmount: formatScaledUnits(taxUnits),
|
|
2664
|
+
totalNetAmount: formatScaledUnits(netUnits),
|
|
2665
|
+
totalGrossAmount: formatScaledUnits(grossUnits),
|
|
2666
|
+
metadata: { warrantyClaimLineId: claimLine.id },
|
|
2667
|
+
}
|
|
2668
|
+
const name = readString(sourceLine, 'name')
|
|
2669
|
+
if (name) input.name = name
|
|
2670
|
+
return { input, netUnits, grossUnits, taxUnits }
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
async function dispatchCreditMemoDelete(
|
|
2674
|
+
ctx: CommandRuntimeContext,
|
|
2675
|
+
scope: WarrantyClaimScope,
|
|
2676
|
+
creditMemoId: string,
|
|
2677
|
+
): Promise<void> {
|
|
2678
|
+
const commandBus = ctx.container.resolve('commandBus') as CommandBus
|
|
2679
|
+
await commandBus.execute(
|
|
2680
|
+
'sales.credit_memos.delete',
|
|
2681
|
+
{
|
|
2682
|
+
input: { id: creditMemoId, organizationId: scope.organizationId, tenantId: scope.tenantId },
|
|
2683
|
+
ctx: scrubbedDispatchContext(ctx),
|
|
2684
|
+
},
|
|
2685
|
+
)
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
const createCreditMemoCommand: CommandHandler<ClaimCreateCreditMemoInput, ClaimCreateCreditMemoResult> = {
|
|
2689
|
+
id: 'warranty_claims.claim.create_credit_memo',
|
|
2690
|
+
isUndoable: true,
|
|
2691
|
+
prepare: async (rawInput, ctx) => {
|
|
2692
|
+
const input = parseCommandInput(claimCreateCreditMemoSchema, rawInput)
|
|
2693
|
+
const scope = resolveScope(ctx, input)
|
|
2694
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
2695
|
+
return { before: await loadClaimSnapshot(em, input.id, scope) }
|
|
2696
|
+
},
|
|
2697
|
+
async execute(rawInput, ctx) {
|
|
2698
|
+
const input = parseCommandInput(claimCreateCreditMemoSchema, rawInput)
|
|
2699
|
+
const scope = resolveScope(ctx, input)
|
|
2700
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
2701
|
+
const claim = await requireScopedClaim(em, input.id, scope)
|
|
2702
|
+
await enforceWarrantyClaimOptimisticLock(ctx, claim, WARRANTY_CLAIM_RESOURCE_KIND, input.updatedAt ?? undefined)
|
|
2703
|
+
if (!claim.orderId) {
|
|
2704
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.creditMemoRequiresOrder' })
|
|
2705
|
+
}
|
|
2706
|
+
if (!CREDIT_MEMO_ELIGIBLE_CLAIM_STATUSES.has(claim.status as WarrantyClaimStatus)) {
|
|
2707
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.creditMemoInvalidStatus' })
|
|
2708
|
+
}
|
|
2709
|
+
if (claim.creditMemoId) {
|
|
2710
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.creditMemoAlreadyLinked' })
|
|
2711
|
+
}
|
|
2712
|
+
if (!resolveOptionalEntityId('sales', 'sales_credit_memo')) {
|
|
2713
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.creditMemoSalesUnavailable' })
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
const sourceOrder = await querySalesReferenceRow(
|
|
2717
|
+
ctx,
|
|
2718
|
+
'sales_orders',
|
|
2719
|
+
scope,
|
|
2720
|
+
claim.orderId,
|
|
2721
|
+
['id', 'currency_code'],
|
|
2722
|
+
)
|
|
2723
|
+
const orderCurrencyCode = sourceOrder ? readString(sourceOrder, 'currency_code') : null
|
|
2724
|
+
if (!sourceOrder || !orderCurrencyCode) {
|
|
2725
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.creditMemoRequiresOrder' })
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
const claimLines = await findWithDecryption(
|
|
2729
|
+
em,
|
|
2730
|
+
WarrantyClaimLine,
|
|
2731
|
+
{ claim: claim.id, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },
|
|
2732
|
+
{},
|
|
2733
|
+
scope,
|
|
2734
|
+
)
|
|
2735
|
+
const sourceLineRows = await querySalesOrderLineReferences(
|
|
2736
|
+
ctx,
|
|
2737
|
+
scope,
|
|
2738
|
+
Array.from(new Set(claimLines.flatMap((line) => line.orderLineId ? [line.orderLineId] : []))),
|
|
2739
|
+
['id', 'order_id', 'name', 'currency_code', 'quantity', 'total_net_amount', 'total_gross_amount', 'tax_rate'],
|
|
2740
|
+
)
|
|
2741
|
+
const sourceLinesById = new Map((sourceLineRows ?? []).map((row) => [readString(row, 'id'), row]))
|
|
2742
|
+
const preparedLines: PreparedCreditMemoLine[] = []
|
|
2743
|
+
const skippedLineIds: string[] = []
|
|
2744
|
+
for (const claimLine of claimLines) {
|
|
2745
|
+
const approvedQuantityUnits = claimedQuantityUnits(claimLine.qtyApproved ?? claimLine.qtyClaimed) ?? 0n
|
|
2746
|
+
const receivedQuantityUnits = claimedQuantityUnits(claimLine.qtyReceived) ?? 0n
|
|
2747
|
+
const creditedQuantityUnits = minimumUnits(approvedQuantityUnits, receivedQuantityUnits)
|
|
2748
|
+
const initiallyEligible = Boolean(claimLine.orderLineId)
|
|
2749
|
+
&& CREDIT_MEMO_ELIGIBLE_DISPOSITIONS.has(claimLine.disposition as WarrantyClaimDisposition)
|
|
2750
|
+
&& CREDIT_MEMO_ELIGIBLE_LINE_STATUSES.has(claimLine.lineStatus as WarrantyClaimLineStatus)
|
|
2751
|
+
&& creditedQuantityUnits > 0n
|
|
2752
|
+
if (!initiallyEligible || !claimLine.orderLineId) {
|
|
2753
|
+
skippedLineIds.push(claimLine.id)
|
|
2754
|
+
continue
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
const sourceLine = sourceLinesById.get(claimLine.orderLineId) ?? null
|
|
2758
|
+
const sourceCurrencyCode = sourceLine ? readString(sourceLine, 'currency_code') : null
|
|
2759
|
+
if (
|
|
2760
|
+
!sourceLine
|
|
2761
|
+
|| sourceLine.order_id !== claim.orderId
|
|
2762
|
+
|| sourceCurrencyCode !== orderCurrencyCode
|
|
2763
|
+
) {
|
|
2764
|
+
skippedLineIds.push(claimLine.id)
|
|
2765
|
+
continue
|
|
2766
|
+
}
|
|
2767
|
+
const preparedLine = prepareCreditMemoLine(
|
|
2768
|
+
claimLine,
|
|
2769
|
+
sourceLine,
|
|
2770
|
+
creditedQuantityUnits,
|
|
2771
|
+
orderCurrencyCode,
|
|
2772
|
+
)
|
|
2773
|
+
if (!preparedLine) {
|
|
2774
|
+
skippedLineIds.push(claimLine.id)
|
|
2775
|
+
continue
|
|
2776
|
+
}
|
|
2777
|
+
preparedLines.push(preparedLine)
|
|
2778
|
+
}
|
|
2779
|
+
if (preparedLines.length === 0) {
|
|
2780
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.creditMemoNoEligibleLines' })
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
const totals = preparedLines.reduce(
|
|
2784
|
+
(result, line) => ({
|
|
2785
|
+
netUnits: result.netUnits + line.netUnits,
|
|
2786
|
+
grossUnits: result.grossUnits + line.grossUnits,
|
|
2787
|
+
taxUnits: result.taxUnits + line.taxUnits,
|
|
2788
|
+
}),
|
|
2789
|
+
{ netUnits: 0n, grossUnits: 0n, taxUnits: 0n },
|
|
2790
|
+
)
|
|
2791
|
+
const grandTotalNetAmount = formatScaledUnits(totals.netUnits)
|
|
2792
|
+
const grandTotalGrossAmount = formatScaledUnits(totals.grossUnits)
|
|
2793
|
+
const creditMemoInput: CreditMemoCreateInput = {
|
|
2794
|
+
organizationId: scope.organizationId,
|
|
2795
|
+
tenantId: scope.tenantId,
|
|
2796
|
+
orderId: claim.orderId,
|
|
2797
|
+
currencyCode: orderCurrencyCode,
|
|
2798
|
+
reason: claim.claimNumber,
|
|
2799
|
+
metadata: { warrantyClaimId: claim.id, warrantyClaimNumber: claim.claimNumber },
|
|
2800
|
+
lines: preparedLines.map((line) => line.input),
|
|
2801
|
+
subtotalNetAmount: grandTotalNetAmount,
|
|
2802
|
+
subtotalGrossAmount: grandTotalGrossAmount,
|
|
2803
|
+
taxTotalAmount: formatScaledUnits(totals.taxUnits),
|
|
2804
|
+
grandTotalNetAmount,
|
|
2805
|
+
grandTotalGrossAmount,
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2808
|
+
const commandBus = ctx.container.resolve('commandBus') as CommandBus
|
|
2809
|
+
let creditMemoId: string | null = null
|
|
2810
|
+
let creditMemoUpdatedAt: string | null = null
|
|
2811
|
+
try {
|
|
2812
|
+
const { result: dispatchedResult } = await commandBus.execute<CreditMemoCreateInput, { creditMemoId: string }>(
|
|
2813
|
+
'sales.credit_memos.create',
|
|
2814
|
+
{ input: creditMemoInput, ctx: scrubbedDispatchContext(ctx) },
|
|
2815
|
+
)
|
|
2816
|
+
if (!dispatchedResult?.creditMemoId) {
|
|
2817
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.save_failed' })
|
|
2818
|
+
}
|
|
2819
|
+
creditMemoId = dispatchedResult.creditMemoId
|
|
2820
|
+
const createdCreditMemoRow = await querySalesReferenceRow(
|
|
2821
|
+
ctx,
|
|
2822
|
+
'sales_credit_memos',
|
|
2823
|
+
scope,
|
|
2824
|
+
creditMemoId,
|
|
2825
|
+
['id', 'updated_at'],
|
|
2826
|
+
)
|
|
2827
|
+
creditMemoUpdatedAt = createdCreditMemoRow?.updated_at
|
|
2828
|
+
? new Date(createdCreditMemoRow.updated_at as string | Date).toISOString()
|
|
2829
|
+
: null
|
|
2830
|
+
await em.transactional(async (tx) => {
|
|
2831
|
+
const lockedClaim = await requireScopedClaim(tx, claim.id, scope, { lockMode: LockMode.PESSIMISTIC_WRITE })
|
|
2832
|
+
if (lockedClaim.creditMemoId) {
|
|
2833
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.creditMemoAlreadyLinked' })
|
|
2834
|
+
}
|
|
2835
|
+
lockedClaim.creditMemoId = creditMemoId
|
|
2836
|
+
lockedClaim.updatedAt = new Date()
|
|
2837
|
+
appendClaimEvent(tx, lockedClaim, 'system', {
|
|
2838
|
+
visibility: 'internal',
|
|
2839
|
+
payload: { action: 'credit_memo_created', creditMemoId, grandTotalGrossAmount, currencyCode: orderCurrencyCode },
|
|
2840
|
+
actorUserId: ctx.auth?.sub ?? null,
|
|
2841
|
+
})
|
|
2842
|
+
claim.creditMemoId = creditMemoId
|
|
2843
|
+
claim.updatedAt = lockedClaim.updatedAt
|
|
2844
|
+
await tx.flush()
|
|
2845
|
+
})
|
|
2846
|
+
} catch (err) {
|
|
2847
|
+
if (!creditMemoId) throw err
|
|
2848
|
+
try {
|
|
2849
|
+
await dispatchCreditMemoDelete(ctx, scope, creditMemoId)
|
|
2850
|
+
} catch (compensationErr) {
|
|
2851
|
+
creditMemoBridgeLogger.error('warranty_claims.claim.create_credit_memo compensation failed — orphaned credit memo', {
|
|
2852
|
+
err: compensationErr,
|
|
2853
|
+
claimId: claim.id,
|
|
2854
|
+
creditMemoId,
|
|
2855
|
+
})
|
|
2856
|
+
try {
|
|
2857
|
+
const orphanEm = (ctx.container.resolve('em') as EntityManager).fork()
|
|
2858
|
+
const orphanClaim = await findOneWithDecryption(
|
|
2859
|
+
orphanEm,
|
|
2860
|
+
WarrantyClaim,
|
|
2861
|
+
{ id: claim.id, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },
|
|
2862
|
+
{},
|
|
2863
|
+
scope,
|
|
2864
|
+
)
|
|
2865
|
+
if (orphanClaim) {
|
|
2866
|
+
appendClaimEvent(orphanEm, orphanClaim, 'system', {
|
|
2867
|
+
visibility: 'internal',
|
|
2868
|
+
payload: { action: 'credit_memo_orphaned', creditMemoId },
|
|
2869
|
+
actorUserId: ctx.auth?.sub ?? null,
|
|
2870
|
+
})
|
|
2871
|
+
await orphanEm.flush()
|
|
2872
|
+
}
|
|
2873
|
+
} catch (orphanEventErr) {
|
|
2874
|
+
creditMemoBridgeLogger.error('warranty_claims.claim.create_credit_memo orphan timeline write failed', {
|
|
2875
|
+
err: orphanEventErr,
|
|
2876
|
+
claimId: claim.id,
|
|
2877
|
+
creditMemoId,
|
|
2878
|
+
})
|
|
2879
|
+
}
|
|
2880
|
+
throw new CrudHttpError(500, { error: 'warranty_claims.errors.save_failed' })
|
|
2881
|
+
}
|
|
2882
|
+
throw err
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
await emitClaimCrud(ctx, 'updated', claim)
|
|
2886
|
+
return {
|
|
2887
|
+
claimId: claim.id,
|
|
2888
|
+
creditMemoId,
|
|
2889
|
+
creditMemoUpdatedAt,
|
|
2890
|
+
skippedLineIds,
|
|
2891
|
+
grandTotalGrossAmount,
|
|
2892
|
+
currencyCode: orderCurrencyCode,
|
|
2893
|
+
}
|
|
2894
|
+
},
|
|
2895
|
+
captureAfter: async (rawInput, result, ctx) => {
|
|
2896
|
+
const input = parseCommandInput(claimCreateCreditMemoSchema, rawInput)
|
|
2897
|
+
const scope = resolveScope(ctx, input)
|
|
2898
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
2899
|
+
return loadClaimSnapshot(em, result.claimId, scope)
|
|
2900
|
+
},
|
|
2901
|
+
buildLog: async ({ result, snapshots }) => {
|
|
2902
|
+
const base = buildClaimLog('warranty_claims.audit.claim.create_credit_memo', result.claimId, snapshots)
|
|
2903
|
+
const undoPayload: ClaimCreditMemoUndoPayload = {
|
|
2904
|
+
...(base.payload.undo as ClaimUndoPayload),
|
|
2905
|
+
creditMemo: { id: result.creditMemoId, updatedAt: result.creditMemoUpdatedAt },
|
|
2906
|
+
}
|
|
2907
|
+
return { ...base, payload: { undo: undoPayload } }
|
|
2908
|
+
},
|
|
2909
|
+
undo: async ({ logEntry, ctx }) => {
|
|
2910
|
+
const payload = extractUndoPayload<ClaimCreditMemoUndoPayload>(logEntry)
|
|
2911
|
+
const before = payload?.before
|
|
2912
|
+
if (!before) return
|
|
2913
|
+
const after = payload?.after
|
|
2914
|
+
if (!after) throwClaimUndoConflict()
|
|
2915
|
+
await assertUndoSnapshotCurrent(ctx, after)
|
|
2916
|
+
const scope: WarrantyClaimScope = { tenantId: before.tenantId, organizationId: before.organizationId }
|
|
2917
|
+
const creditMemo = payload?.creditMemo ?? null
|
|
2918
|
+
if (creditMemo?.id) {
|
|
2919
|
+
const currentRow = await querySalesReferenceRow(ctx, 'sales_credit_memos', scope, creditMemo.id, ['id', 'updated_at'])
|
|
2920
|
+
if (currentRow === undefined) {
|
|
2921
|
+
throw new CrudHttpError(409, { error: 'warranty_claims.errors.creditMemoChangedUndoAborted' })
|
|
2922
|
+
}
|
|
2923
|
+
if (currentRow) {
|
|
2924
|
+
const currentUpdatedAt = currentRow.updated_at
|
|
2925
|
+
? new Date(currentRow.updated_at as string | Date).toISOString()
|
|
2926
|
+
: null
|
|
2927
|
+
if (!creditMemo.updatedAt || !currentUpdatedAt || currentUpdatedAt !== creditMemo.updatedAt) {
|
|
2928
|
+
throw new CrudHttpError(409, { error: 'warranty_claims.errors.creditMemoChangedUndoAborted' })
|
|
2929
|
+
}
|
|
2930
|
+
await dispatchCreditMemoDelete(ctx, scope, creditMemo.id)
|
|
2931
|
+
} else {
|
|
2932
|
+
creditMemoBridgeLogger.info('warranty_claims.claim.create_credit_memo undo found credit memo already absent', {
|
|
2933
|
+
claimId: before.id,
|
|
2934
|
+
creditMemoId: creditMemo.id,
|
|
2935
|
+
})
|
|
2936
|
+
}
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
2940
|
+
let claim!: WarrantyClaim
|
|
2941
|
+
await withAtomicFlush(em, [
|
|
2942
|
+
async () => {
|
|
2943
|
+
claim = await restoreSnapshot(em, before, after)
|
|
2944
|
+
appendClaimEvent(em, claim, 'system', {
|
|
2945
|
+
visibility: 'internal',
|
|
2946
|
+
payload: { action: 'undo_credit_memo_created' },
|
|
2947
|
+
actorUserId: ctx.auth?.sub ?? null,
|
|
2948
|
+
})
|
|
2949
|
+
},
|
|
2950
|
+
], { transaction: true, label: 'warranty_claims.claim.create_credit_memo.undo' })
|
|
2951
|
+
await emitClaimUndoCrud(ctx, 'updated', claim)
|
|
2952
|
+
},
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
const replacementOrderBridgeLogger = createLogger('warranty_claims')
|
|
2956
|
+
|
|
2957
|
+
const REPLACEMENT_ELIGIBLE_CLAIM_STATUSES = new Set<WarrantyClaimStatus>([
|
|
2958
|
+
'approved',
|
|
2959
|
+
'awaiting_return',
|
|
2960
|
+
'received',
|
|
2961
|
+
'inspecting',
|
|
2962
|
+
'resolved',
|
|
2963
|
+
])
|
|
2964
|
+
const REPLACEMENT_ELIGIBLE_LINE_STATUSES = new Set<WarrantyClaimLineStatus>(['approved', 'received', 'inspected', 'resolved'])
|
|
2965
|
+
const ADVANCE_REPLACEMENT_CLAIM_STATUSES = new Set<WarrantyClaimStatus>(['approved', 'awaiting_return'])
|
|
2966
|
+
const SALES_ORDER_LINE_KINDS = new Set(['product', 'service', 'shipping', 'discount', 'adjustment'] as const)
|
|
2967
|
+
|
|
2968
|
+
type SalesOrderLineKind = 'product' | 'service' | 'shipping' | 'discount' | 'adjustment'
|
|
2969
|
+
|
|
2970
|
+
type ReplacementOrderLineInput = {
|
|
2971
|
+
kind: SalesOrderLineKind
|
|
2972
|
+
currencyCode: string
|
|
2973
|
+
quantity: string
|
|
2974
|
+
unitPriceNet: string
|
|
2975
|
+
unitPriceGross: string
|
|
2976
|
+
productId?: string
|
|
2977
|
+
productVariantId?: string
|
|
2978
|
+
name?: string
|
|
2979
|
+
taxRate?: string
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2982
|
+
type ReplacementOrderCreateInput = {
|
|
2983
|
+
organizationId: string
|
|
2984
|
+
tenantId: string
|
|
2985
|
+
currencyCode: string
|
|
2986
|
+
metadata: { warrantyClaimId: string; warrantyClaimNumber: string }
|
|
2987
|
+
lines: ReplacementOrderLineInput[]
|
|
2988
|
+
customerEntityId?: string
|
|
2989
|
+
customerContactId?: string
|
|
2990
|
+
billingAddressId?: string
|
|
2991
|
+
shippingAddressId?: string
|
|
2992
|
+
channelId?: string
|
|
2993
|
+
}
|
|
2994
|
+
|
|
2995
|
+
type ClaimReplacementOrderUndoPayload = ClaimUndoPayload & {
|
|
2996
|
+
replacementOrder?: { id: string; updatedAt: string | null } | null
|
|
2997
|
+
}
|
|
2998
|
+
|
|
2999
|
+
type ClaimCreateReplacementOrderResult = {
|
|
3000
|
+
claimId: string
|
|
3001
|
+
replacementOrderId: string
|
|
3002
|
+
replacementOrderUpdatedAt: string | null
|
|
3003
|
+
skippedLineIds: string[]
|
|
3004
|
+
pricing: 'zero' | 'original'
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
function readSalesOrderLineKind(value: unknown): SalesOrderLineKind | null {
|
|
3008
|
+
return typeof value === 'string' && SALES_ORDER_LINE_KINDS.has(value as SalesOrderLineKind)
|
|
3009
|
+
? value as SalesOrderLineKind
|
|
3010
|
+
: null
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
async function dispatchSalesOrderDelete(
|
|
3014
|
+
ctx: CommandRuntimeContext,
|
|
3015
|
+
scope: WarrantyClaimScope,
|
|
3016
|
+
replacementOrderId: string,
|
|
3017
|
+
): Promise<void> {
|
|
3018
|
+
const commandBus = ctx.container.resolve('commandBus') as CommandBus
|
|
3019
|
+
await commandBus.execute(
|
|
3020
|
+
'sales.orders.delete',
|
|
3021
|
+
{
|
|
3022
|
+
input: { id: replacementOrderId, tenantId: scope.tenantId, organizationId: scope.organizationId },
|
|
3023
|
+
ctx: scrubbedDispatchContext(ctx),
|
|
3024
|
+
},
|
|
3025
|
+
)
|
|
3026
|
+
}
|
|
3027
|
+
|
|
3028
|
+
const createReplacementOrderCommand: CommandHandler<ClaimCreateReplacementOrderInput, ClaimCreateReplacementOrderResult> = {
|
|
3029
|
+
id: 'warranty_claims.claim.create_replacement_order',
|
|
3030
|
+
isUndoable: true,
|
|
3031
|
+
prepare: async (rawInput, ctx) => {
|
|
3032
|
+
const input = parseCommandInput(claimCreateReplacementOrderSchema, rawInput)
|
|
3033
|
+
const scope = resolveScope(ctx, input)
|
|
3034
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
3035
|
+
return { before: await loadClaimSnapshot(em, input.id, scope) }
|
|
3036
|
+
},
|
|
3037
|
+
async execute(rawInput, ctx) {
|
|
3038
|
+
const input = parseCommandInput(claimCreateReplacementOrderSchema, rawInput)
|
|
3039
|
+
const scope = resolveScope(ctx, input)
|
|
3040
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
3041
|
+
const claim = await requireScopedClaim(em, input.id, scope)
|
|
3042
|
+
await enforceWarrantyClaimOptimisticLock(ctx, claim, WARRANTY_CLAIM_RESOURCE_KIND, input.updatedAt ?? undefined)
|
|
3043
|
+
if (!claim.orderId) {
|
|
3044
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.replacementOrderRequiresOrder' })
|
|
3045
|
+
}
|
|
3046
|
+
if (!REPLACEMENT_ELIGIBLE_CLAIM_STATUSES.has(claim.status as WarrantyClaimStatus)) {
|
|
3047
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.replacementInvalidStatus' })
|
|
3048
|
+
}
|
|
3049
|
+
if (claim.replacementOrderId) {
|
|
3050
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.replacementAlreadyLinked' })
|
|
3051
|
+
}
|
|
3052
|
+
if (!resolveOptionalEntityId('sales', 'sales_order')) {
|
|
3053
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.replacementSalesUnavailable' })
|
|
3054
|
+
}
|
|
3055
|
+
|
|
3056
|
+
const sourceOrder = await querySalesReferenceRow(
|
|
3057
|
+
ctx,
|
|
3058
|
+
'sales_orders',
|
|
3059
|
+
scope,
|
|
3060
|
+
claim.orderId,
|
|
3061
|
+
[
|
|
3062
|
+
'id',
|
|
3063
|
+
'currency_code',
|
|
3064
|
+
'customer_entity_id',
|
|
3065
|
+
'customer_contact_id',
|
|
3066
|
+
'billing_address_id',
|
|
3067
|
+
'shipping_address_id',
|
|
3068
|
+
'channel_id',
|
|
3069
|
+
],
|
|
3070
|
+
)
|
|
3071
|
+
const orderCurrencyCode = sourceOrder ? readString(sourceOrder, 'currency_code') : null
|
|
3072
|
+
if (!sourceOrder || !orderCurrencyCode) {
|
|
3073
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.replacementOrderRequiresOrder' })
|
|
3074
|
+
}
|
|
3075
|
+
|
|
3076
|
+
const lines = await findWithDecryption(
|
|
3077
|
+
em,
|
|
3078
|
+
WarrantyClaimLine,
|
|
3079
|
+
{ claim: claim.id, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },
|
|
3080
|
+
{},
|
|
3081
|
+
scope,
|
|
3082
|
+
)
|
|
3083
|
+
const sourceLineRows = await querySalesOrderLineReferences(
|
|
3084
|
+
ctx,
|
|
3085
|
+
scope,
|
|
3086
|
+
Array.from(new Set(lines.flatMap((line) => line.orderLineId ? [line.orderLineId] : []))),
|
|
3087
|
+
[
|
|
3088
|
+
'id',
|
|
3089
|
+
'order_id',
|
|
3090
|
+
'product_id',
|
|
3091
|
+
'product_variant_id',
|
|
3092
|
+
'name',
|
|
3093
|
+
'kind',
|
|
3094
|
+
'currency_code',
|
|
3095
|
+
'unit_price_net',
|
|
3096
|
+
'unit_price_gross',
|
|
3097
|
+
'tax_rate',
|
|
3098
|
+
],
|
|
3099
|
+
)
|
|
3100
|
+
const sourceLinesById = new Map((sourceLineRows ?? []).map((row) => [readString(row, 'id'), row]))
|
|
3101
|
+
const replacementLines: ReplacementOrderLineInput[] = []
|
|
3102
|
+
const skippedLineIds: string[] = []
|
|
3103
|
+
for (const line of lines) {
|
|
3104
|
+
const effectiveQty = line.qtyApproved ?? line.qtyClaimed
|
|
3105
|
+
const quantityUnits = claimedQuantityUnits(effectiveQty) ?? 0n
|
|
3106
|
+
const initiallyEligible = line.disposition === 'replace'
|
|
3107
|
+
&& Boolean(line.orderLineId)
|
|
3108
|
+
&& REPLACEMENT_ELIGIBLE_LINE_STATUSES.has(line.lineStatus as WarrantyClaimLineStatus)
|
|
3109
|
+
&& quantityUnits > 0n
|
|
3110
|
+
&& quantityUnits % CLAIMED_QUANTITY_SCALE === 0n
|
|
3111
|
+
if (!initiallyEligible || !line.orderLineId) {
|
|
3112
|
+
skippedLineIds.push(line.id)
|
|
3113
|
+
continue
|
|
3114
|
+
}
|
|
3115
|
+
|
|
3116
|
+
const sourceLine = sourceLinesById.get(line.orderLineId) ?? null
|
|
3117
|
+
const productId = sourceLine ? readString(sourceLine, 'product_id') : null
|
|
3118
|
+
const productVariantId = sourceLine ? readString(sourceLine, 'product_variant_id') : null
|
|
3119
|
+
const name = sourceLine ? readString(sourceLine, 'name') : null
|
|
3120
|
+
const kind = sourceLine ? readSalesOrderLineKind(sourceLine.kind) : null
|
|
3121
|
+
const currencyCode = sourceLine ? readString(sourceLine, 'currency_code') : null
|
|
3122
|
+
const unitPriceNet = sourceLine ? readString(sourceLine, 'unit_price_net') : null
|
|
3123
|
+
const unitPriceGross = sourceLine ? readString(sourceLine, 'unit_price_gross') : null
|
|
3124
|
+
const taxRate = sourceLine ? readString(sourceLine, 'tax_rate') : null
|
|
3125
|
+
const originalPricingIncomplete = input.pricing === 'original'
|
|
3126
|
+
&& (unitPriceNet === null || unitPriceGross === null || taxRate === null)
|
|
3127
|
+
if (
|
|
3128
|
+
!sourceLine
|
|
3129
|
+
|| sourceLine.order_id !== claim.orderId
|
|
3130
|
+
|| (!productId && !productVariantId && !name)
|
|
3131
|
+
|| !kind
|
|
3132
|
+
|| !currencyCode
|
|
3133
|
+
|| originalPricingIncomplete
|
|
3134
|
+
) {
|
|
3135
|
+
skippedLineIds.push(line.id)
|
|
3136
|
+
continue
|
|
3137
|
+
}
|
|
3138
|
+
|
|
3139
|
+
const replacementLine: ReplacementOrderLineInput = {
|
|
3140
|
+
kind,
|
|
3141
|
+
currencyCode,
|
|
3142
|
+
quantity: String(effectiveQty),
|
|
3143
|
+
unitPriceNet: input.pricing === 'zero' ? '0' : unitPriceNet as string,
|
|
3144
|
+
unitPriceGross: input.pricing === 'zero' ? '0' : unitPriceGross as string,
|
|
3145
|
+
}
|
|
3146
|
+
if (productId) replacementLine.productId = productId
|
|
3147
|
+
if (productVariantId) replacementLine.productVariantId = productVariantId
|
|
3148
|
+
if (name) replacementLine.name = name
|
|
3149
|
+
if (input.pricing === 'original') replacementLine.taxRate = taxRate as string
|
|
3150
|
+
replacementLines.push(replacementLine)
|
|
3151
|
+
}
|
|
3152
|
+
if (replacementLines.length === 0) {
|
|
3153
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.replacementNoEligibleLines' })
|
|
3154
|
+
}
|
|
3155
|
+
|
|
3156
|
+
const orderInput: ReplacementOrderCreateInput = {
|
|
3157
|
+
organizationId: scope.organizationId,
|
|
3158
|
+
tenantId: scope.tenantId,
|
|
3159
|
+
currencyCode: orderCurrencyCode,
|
|
3160
|
+
metadata: { warrantyClaimId: claim.id, warrantyClaimNumber: claim.claimNumber },
|
|
3161
|
+
lines: replacementLines,
|
|
3162
|
+
}
|
|
3163
|
+
const customerEntityId = readString(sourceOrder, 'customer_entity_id')
|
|
3164
|
+
const customerContactId = readString(sourceOrder, 'customer_contact_id')
|
|
3165
|
+
const billingAddressId = readString(sourceOrder, 'billing_address_id')
|
|
3166
|
+
const shippingAddressId = readString(sourceOrder, 'shipping_address_id')
|
|
3167
|
+
const channelId = readString(sourceOrder, 'channel_id')
|
|
3168
|
+
if (customerEntityId) orderInput.customerEntityId = customerEntityId
|
|
3169
|
+
if (customerContactId) orderInput.customerContactId = customerContactId
|
|
3170
|
+
if (billingAddressId) orderInput.billingAddressId = billingAddressId
|
|
3171
|
+
if (shippingAddressId) orderInput.shippingAddressId = shippingAddressId
|
|
3172
|
+
if (channelId) orderInput.channelId = channelId
|
|
3173
|
+
|
|
3174
|
+
const commandBus = ctx.container.resolve('commandBus') as CommandBus
|
|
3175
|
+
const { result: dispatchedResult } = await commandBus.execute<ReplacementOrderCreateInput, { orderId: string }>(
|
|
3176
|
+
'sales.orders.create',
|
|
3177
|
+
{ input: orderInput, ctx: scrubbedDispatchContext(ctx) },
|
|
3178
|
+
)
|
|
3179
|
+
if (!dispatchedResult?.orderId) {
|
|
3180
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.save_failed' })
|
|
3181
|
+
}
|
|
3182
|
+
const replacementOrderId = dispatchedResult.orderId
|
|
3183
|
+
let replacementOrderUpdatedAt: string | null = null
|
|
3184
|
+
try {
|
|
3185
|
+
const createdOrderRow = await querySalesReferenceRow(ctx, 'sales_orders', scope, replacementOrderId, ['id', 'updated_at'])
|
|
3186
|
+
replacementOrderUpdatedAt = createdOrderRow?.updated_at
|
|
3187
|
+
? new Date(createdOrderRow.updated_at as string | Date).toISOString()
|
|
3188
|
+
: null
|
|
3189
|
+
await em.transactional(async (tx) => {
|
|
3190
|
+
const lockedClaim = await requireScopedClaim(tx, claim.id, scope, { lockMode: LockMode.PESSIMISTIC_WRITE })
|
|
3191
|
+
if (lockedClaim.replacementOrderId) {
|
|
3192
|
+
throw new CrudHttpError(400, { error: 'warranty_claims.errors.replacementAlreadyLinked' })
|
|
3193
|
+
}
|
|
3194
|
+
lockedClaim.replacementOrderId = replacementOrderId
|
|
3195
|
+
if (ADVANCE_REPLACEMENT_CLAIM_STATUSES.has(lockedClaim.status as WarrantyClaimStatus)) {
|
|
3196
|
+
lockedClaim.advanceReplacement = true
|
|
3197
|
+
}
|
|
3198
|
+
lockedClaim.updatedAt = new Date()
|
|
3199
|
+
appendClaimEvent(tx, lockedClaim, 'system', {
|
|
3200
|
+
visibility: 'internal',
|
|
3201
|
+
payload: { action: 'replacement_order_created', replacementOrderId, pricing: input.pricing },
|
|
3202
|
+
actorUserId: ctx.auth?.sub ?? null,
|
|
3203
|
+
})
|
|
3204
|
+
claim.replacementOrderId = replacementOrderId
|
|
3205
|
+
claim.advanceReplacement = lockedClaim.advanceReplacement
|
|
3206
|
+
claim.updatedAt = lockedClaim.updatedAt
|
|
3207
|
+
await tx.flush()
|
|
3208
|
+
})
|
|
3209
|
+
} catch (err) {
|
|
3210
|
+
try {
|
|
3211
|
+
await dispatchSalesOrderDelete(ctx, scope, replacementOrderId)
|
|
3212
|
+
} catch (compensationErr) {
|
|
3213
|
+
replacementOrderBridgeLogger.error('warranty_claims.claim.create_replacement_order compensation failed — orphaned sales order', {
|
|
3214
|
+
err: compensationErr,
|
|
3215
|
+
claimId: claim.id,
|
|
3216
|
+
replacementOrderId,
|
|
3217
|
+
})
|
|
3218
|
+
try {
|
|
3219
|
+
const orphanEm = (ctx.container.resolve('em') as EntityManager).fork()
|
|
3220
|
+
const orphanClaim = await findOneWithDecryption(
|
|
3221
|
+
orphanEm,
|
|
3222
|
+
WarrantyClaim,
|
|
3223
|
+
{ id: claim.id, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },
|
|
3224
|
+
{},
|
|
3225
|
+
scope,
|
|
3226
|
+
)
|
|
3227
|
+
if (orphanClaim) {
|
|
3228
|
+
appendClaimEvent(orphanEm, orphanClaim, 'system', {
|
|
3229
|
+
visibility: 'internal',
|
|
3230
|
+
payload: { action: 'replacement_order_orphaned', replacementOrderId },
|
|
3231
|
+
actorUserId: ctx.auth?.sub ?? null,
|
|
3232
|
+
})
|
|
3233
|
+
await orphanEm.flush()
|
|
3234
|
+
}
|
|
3235
|
+
} catch (orphanEventErr) {
|
|
3236
|
+
replacementOrderBridgeLogger.error('warranty_claims.claim.create_replacement_order orphan timeline write failed', {
|
|
3237
|
+
err: orphanEventErr,
|
|
3238
|
+
claimId: claim.id,
|
|
3239
|
+
replacementOrderId,
|
|
3240
|
+
})
|
|
3241
|
+
}
|
|
3242
|
+
throw new CrudHttpError(500, { error: 'warranty_claims.errors.save_failed' })
|
|
3243
|
+
}
|
|
3244
|
+
throw err
|
|
3245
|
+
}
|
|
3246
|
+
|
|
3247
|
+
await emitClaimCrud(ctx, 'updated', claim)
|
|
3248
|
+
return {
|
|
3249
|
+
claimId: claim.id,
|
|
3250
|
+
replacementOrderId,
|
|
3251
|
+
replacementOrderUpdatedAt,
|
|
3252
|
+
skippedLineIds,
|
|
3253
|
+
pricing: input.pricing,
|
|
3254
|
+
}
|
|
3255
|
+
},
|
|
3256
|
+
captureAfter: async (rawInput, result, ctx) => {
|
|
3257
|
+
const input = parseCommandInput(claimCreateReplacementOrderSchema, rawInput)
|
|
3258
|
+
const scope = resolveScope(ctx, input)
|
|
3259
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
3260
|
+
return loadClaimSnapshot(em, result.claimId, scope)
|
|
3261
|
+
},
|
|
3262
|
+
buildLog: async ({ result, snapshots }) => {
|
|
3263
|
+
const base = buildClaimLog('warranty_claims.audit.claim.create_replacement_order', result.claimId, snapshots)
|
|
3264
|
+
const undoPayload: ClaimReplacementOrderUndoPayload = {
|
|
3265
|
+
...(base.payload.undo as ClaimUndoPayload),
|
|
3266
|
+
replacementOrder: {
|
|
3267
|
+
id: result.replacementOrderId,
|
|
3268
|
+
updatedAt: result.replacementOrderUpdatedAt,
|
|
3269
|
+
},
|
|
3270
|
+
}
|
|
3271
|
+
return { ...base, payload: { undo: undoPayload } }
|
|
3272
|
+
},
|
|
3273
|
+
undo: async ({ logEntry, ctx }) => {
|
|
3274
|
+
const payload = extractUndoPayload<ClaimReplacementOrderUndoPayload>(logEntry)
|
|
3275
|
+
const before = payload?.before
|
|
3276
|
+
if (!before) return
|
|
3277
|
+
const after = payload?.after
|
|
3278
|
+
if (!after) throwClaimUndoConflict()
|
|
3279
|
+
await assertUndoSnapshotCurrent(ctx, after)
|
|
3280
|
+
const scope: WarrantyClaimScope = { tenantId: before.tenantId, organizationId: before.organizationId }
|
|
3281
|
+
const replacementOrder = payload?.replacementOrder ?? null
|
|
3282
|
+
if (replacementOrder?.id) {
|
|
3283
|
+
const currentRow = await querySalesReferenceRow(ctx, 'sales_orders', scope, replacementOrder.id, ['id', 'updated_at'])
|
|
3284
|
+
if (currentRow === undefined) {
|
|
3285
|
+
throw new CrudHttpError(409, { error: 'warranty_claims.errors.replacementOrderChangedUndoAborted' })
|
|
3286
|
+
}
|
|
3287
|
+
if (currentRow) {
|
|
3288
|
+
const currentUpdatedAt = currentRow.updated_at
|
|
3289
|
+
? new Date(currentRow.updated_at as string | Date).toISOString()
|
|
3290
|
+
: null
|
|
3291
|
+
if (!replacementOrder.updatedAt || !currentUpdatedAt || currentUpdatedAt !== replacementOrder.updatedAt) {
|
|
3292
|
+
throw new CrudHttpError(409, { error: 'warranty_claims.errors.replacementOrderChangedUndoAborted' })
|
|
3293
|
+
}
|
|
3294
|
+
await dispatchSalesOrderDelete(ctx, scope, replacementOrder.id)
|
|
3295
|
+
} else {
|
|
3296
|
+
replacementOrderBridgeLogger.info('warranty_claims.claim.create_replacement_order undo found replacement order already absent', {
|
|
3297
|
+
claimId: before.id,
|
|
3298
|
+
replacementOrderId: replacementOrder.id,
|
|
3299
|
+
})
|
|
3300
|
+
}
|
|
3301
|
+
}
|
|
3302
|
+
|
|
3303
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
3304
|
+
let claim!: WarrantyClaim
|
|
3305
|
+
await withAtomicFlush(em, [
|
|
3306
|
+
async () => {
|
|
3307
|
+
claim = await restoreSnapshot(em, before, after)
|
|
3308
|
+
appendClaimEvent(em, claim, 'system', {
|
|
3309
|
+
visibility: 'internal',
|
|
3310
|
+
payload: { action: 'undo_replacement_order_created' },
|
|
3311
|
+
actorUserId: ctx.auth?.sub ?? null,
|
|
3312
|
+
})
|
|
3313
|
+
},
|
|
3314
|
+
], { transaction: true, label: 'warranty_claims.claim.create_replacement_order.undo' })
|
|
3315
|
+
await emitClaimUndoCrud(ctx, 'updated', claim)
|
|
3316
|
+
},
|
|
3317
|
+
}
|
|
3318
|
+
|
|
3319
|
+
registerCommand(createClaimCommand)
|
|
3320
|
+
registerCommand(updateClaimCommand)
|
|
3321
|
+
registerCommand(deleteClaimCommand)
|
|
3322
|
+
registerCommand(submitClaimCommand)
|
|
3323
|
+
registerCommand(transitionClaimCommand)
|
|
3324
|
+
registerCommand(assignClaimCommand)
|
|
3325
|
+
registerCommand(setReturnLabelCommand)
|
|
3326
|
+
registerCommand(escalateClaimCommand)
|
|
3327
|
+
registerCommand(commentClaimCommand)
|
|
3328
|
+
registerCommand(createVendorRecoveryCommand)
|
|
3329
|
+
registerCommand(createSalesReturnCommand)
|
|
3330
|
+
registerCommand(createCreditMemoCommand)
|
|
3331
|
+
registerCommand(createReplacementOrderCommand)
|
|
3332
|
+
|
|
3333
|
+
export const claimCommands = [
|
|
3334
|
+
createClaimCommand,
|
|
3335
|
+
updateClaimCommand,
|
|
3336
|
+
deleteClaimCommand,
|
|
3337
|
+
submitClaimCommand,
|
|
3338
|
+
transitionClaimCommand,
|
|
3339
|
+
assignClaimCommand,
|
|
3340
|
+
setReturnLabelCommand,
|
|
3341
|
+
escalateClaimCommand,
|
|
3342
|
+
commentClaimCommand,
|
|
3343
|
+
createVendorRecoveryCommand,
|
|
3344
|
+
createSalesReturnCommand,
|
|
3345
|
+
createCreditMemoCommand,
|
|
3346
|
+
createReplacementOrderCommand,
|
|
3347
|
+
]
|
|
3348
|
+
|
|
3349
|
+
export {
|
|
3350
|
+
claimCrudEvents,
|
|
3351
|
+
createClaimCommand,
|
|
3352
|
+
updateClaimCommand,
|
|
3353
|
+
deleteClaimCommand,
|
|
3354
|
+
submitClaimCommand,
|
|
3355
|
+
transitionClaimCommand,
|
|
3356
|
+
assignClaimCommand,
|
|
3357
|
+
setReturnLabelCommand,
|
|
3358
|
+
escalateClaimCommand,
|
|
3359
|
+
commentClaimCommand,
|
|
3360
|
+
createVendorRecoveryCommand,
|
|
3361
|
+
createSalesReturnCommand,
|
|
3362
|
+
createCreditMemoCommand,
|
|
3363
|
+
createReplacementOrderCommand,
|
|
3364
|
+
}
|