@open-mercato/core 0.6.7-develop.6669.1.40b669666b → 0.6.7-develop.6670.1.4efa7961c6
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/inventory_balance/index.js +21 -0
- package/dist/generated/entities/inventory_balance/index.js.map +7 -0
- package/dist/generated/entities/inventory_lot/index.js +19 -0
- package/dist/generated/entities/inventory_lot/index.js.map +7 -0
- package/dist/generated/entities/inventory_movement/index.js +35 -0
- package/dist/generated/entities/inventory_movement/index.js.map +7 -0
- package/dist/generated/entities/inventory_reservation/index.js +23 -0
- package/dist/generated/entities/inventory_reservation/index.js.map +7 -0
- package/dist/generated/entities/product_inventory_profile/index.js +21 -0
- package/dist/generated/entities/product_inventory_profile/index.js.map +7 -0
- package/dist/generated/entities/sales_order_warehouse_assignment/index.js +11 -0
- package/dist/generated/entities/sales_order_warehouse_assignment/index.js.map +7 -0
- package/dist/generated/entities/warehouse/index.js +25 -0
- package/dist/generated/entities/warehouse/index.js.map +7 -0
- package/dist/generated/entities/warehouse_location/index.js +21 -0
- package/dist/generated/entities/warehouse_location/index.js.map +7 -0
- package/dist/generated/entities/warehouse_zone/index.js +11 -0
- package/dist/generated/entities/warehouse_zone/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 +98 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/auth/backend/roles/page.js +12 -4
- package/dist/modules/auth/backend/roles/page.js.map +2 -2
- package/dist/modules/auth/lib/backendChrome.js +1 -0
- package/dist/modules/auth/lib/backendChrome.js.map +2 -2
- package/dist/modules/auth/services/rbacService.js +16 -17
- package/dist/modules/auth/services/rbacService.js.map +2 -2
- package/dist/modules/catalog/api/products/route.js +3 -0
- package/dist/modules/catalog/api/products/route.js.map +2 -2
- package/dist/modules/catalog/api/variants/route.js +3 -0
- package/dist/modules/catalog/api/variants/route.js.map +2 -2
- package/dist/modules/query_index/lib/engine.js +6 -6
- package/dist/modules/query_index/lib/engine.js.map +2 -2
- package/dist/modules/sales/api/documents/factory.js +1 -0
- package/dist/modules/sales/api/documents/factory.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +53 -0
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/components/documents/ItemsSection.js +51 -2
- package/dist/modules/sales/components/documents/ItemsSection.js.map +2 -2
- package/dist/modules/sales/events.js +2 -0
- package/dist/modules/sales/events.js.map +2 -2
- package/dist/modules/sales/widgets/injection/order-items-context.js +11 -0
- package/dist/modules/sales/widgets/injection/order-items-context.js.map +7 -0
- package/dist/modules/wms/acl.js +18 -0
- package/dist/modules/wms/acl.js.map +7 -0
- package/dist/modules/wms/analytics.js +11 -0
- package/dist/modules/wms/analytics.js.map +7 -0
- package/dist/modules/wms/api/dashboard/operational/route.js +80 -0
- package/dist/modules/wms/api/dashboard/operational/route.js.map +7 -0
- package/dist/modules/wms/api/interceptors.js +90 -0
- package/dist/modules/wms/api/interceptors.js.map +7 -0
- package/dist/modules/wms/api/inventory/adjust/route.js +51 -0
- package/dist/modules/wms/api/inventory/adjust/route.js.map +7 -0
- package/dist/modules/wms/api/inventory/allocate/route.js +51 -0
- package/dist/modules/wms/api/inventory/allocate/route.js.map +7 -0
- package/dist/modules/wms/api/inventory/balances/route.js +172 -0
- package/dist/modules/wms/api/inventory/balances/route.js.map +7 -0
- package/dist/modules/wms/api/inventory/cycle-count/route.js +52 -0
- package/dist/modules/wms/api/inventory/cycle-count/route.js.map +7 -0
- package/dist/modules/wms/api/inventory/helpers.js +113 -0
- package/dist/modules/wms/api/inventory/helpers.js.map +7 -0
- package/dist/modules/wms/api/inventory/import/apply/route.js +46 -0
- package/dist/modules/wms/api/inventory/import/apply/route.js.map +7 -0
- package/dist/modules/wms/api/inventory/import/helpers.js +195 -0
- package/dist/modules/wms/api/inventory/import/helpers.js.map +7 -0
- package/dist/modules/wms/api/inventory/import/template/route.js +32 -0
- package/dist/modules/wms/api/inventory/import/template/route.js.map +7 -0
- package/dist/modules/wms/api/inventory/import/validate/route.js +54 -0
- package/dist/modules/wms/api/inventory/import/validate/route.js.map +7 -0
- package/dist/modules/wms/api/inventory/move/route.js +51 -0
- package/dist/modules/wms/api/inventory/move/route.js.map +7 -0
- package/dist/modules/wms/api/inventory/movements/route.js +164 -0
- package/dist/modules/wms/api/inventory/movements/route.js.map +7 -0
- package/dist/modules/wms/api/inventory/receive/route.js +50 -0
- package/dist/modules/wms/api/inventory/receive/route.js.map +7 -0
- package/dist/modules/wms/api/inventory/release/route.js +44 -0
- package/dist/modules/wms/api/inventory/release/route.js.map +7 -0
- package/dist/modules/wms/api/inventory/reservations/route.js +128 -0
- package/dist/modules/wms/api/inventory/reservations/route.js.map +7 -0
- package/dist/modules/wms/api/inventory/reserve/route.js +59 -0
- package/dist/modules/wms/api/inventory/reserve/route.js.map +7 -0
- package/dist/modules/wms/api/inventory-profiles/route.js +178 -0
- package/dist/modules/wms/api/inventory-profiles/route.js.map +7 -0
- package/dist/modules/wms/api/listEnrichers.js +181 -0
- package/dist/modules/wms/api/listEnrichers.js.map +7 -0
- package/dist/modules/wms/api/listSearch.js +77 -0
- package/dist/modules/wms/api/listSearch.js.map +7 -0
- package/dist/modules/wms/api/locations/route.js +172 -0
- package/dist/modules/wms/api/locations/route.js.map +7 -0
- package/dist/modules/wms/api/lots/route.js +204 -0
- package/dist/modules/wms/api/lots/route.js.map +7 -0
- package/dist/modules/wms/api/openapi.js +27 -0
- package/dist/modules/wms/api/openapi.js.map +7 -0
- package/dist/modules/wms/api/sales-orders/[salesOrderId]/re-run-reservation/route.js +54 -0
- package/dist/modules/wms/api/sales-orders/[salesOrderId]/re-run-reservation/route.js.map +7 -0
- package/dist/modules/wms/api/sales-orders/[salesOrderId]/warehouse-assignment/route.js +279 -0
- package/dist/modules/wms/api/sales-orders/[salesOrderId]/warehouse-assignment/route.js.map +7 -0
- package/dist/modules/wms/api/sales-orders/assign-warehouse/route.js +56 -0
- package/dist/modules/wms/api/sales-orders/assign-warehouse/route.js.map +7 -0
- package/dist/modules/wms/api/sales-orders/unassign-warehouse/route.js +43 -0
- package/dist/modules/wms/api/sales-orders/unassign-warehouse/route.js.map +7 -0
- package/dist/modules/wms/api/warehouses/route.js +181 -0
- package/dist/modules/wms/api/warehouses/route.js.map +7 -0
- package/dist/modules/wms/api/zones/route.js +159 -0
- package/dist/modules/wms/api/zones/route.js.map +7 -0
- package/dist/modules/wms/backend/config/wms/page.js +9 -0
- package/dist/modules/wms/backend/config/wms/page.js.map +7 -0
- package/dist/modules/wms/backend/config/wms/page.meta.js +18 -0
- package/dist/modules/wms/backend/config/wms/page.meta.js.map +7 -0
- package/dist/modules/wms/backend/page.js +9 -0
- package/dist/modules/wms/backend/page.js.map +7 -0
- package/dist/modules/wms/backend/page.meta.js +16 -0
- package/dist/modules/wms/backend/page.meta.js.map +7 -0
- package/dist/modules/wms/backend/wms/inventory/page.js +9 -0
- package/dist/modules/wms/backend/wms/inventory/page.js.map +7 -0
- package/dist/modules/wms/backend/wms/inventory/page.meta.js +18 -0
- package/dist/modules/wms/backend/wms/inventory/page.meta.js.map +7 -0
- package/dist/modules/wms/backend/wms/location/[id]/page.js +9 -0
- package/dist/modules/wms/backend/wms/location/[id]/page.js.map +7 -0
- package/dist/modules/wms/backend/wms/location/[id]/page.meta.js +18 -0
- package/dist/modules/wms/backend/wms/location/[id]/page.meta.js.map +7 -0
- package/dist/modules/wms/backend/wms/locations/page.js +10 -0
- package/dist/modules/wms/backend/wms/locations/page.js.map +7 -0
- package/dist/modules/wms/backend/wms/locations/page.meta.js +18 -0
- package/dist/modules/wms/backend/wms/locations/page.meta.js.map +7 -0
- package/dist/modules/wms/backend/wms/lot/[id]/page.js +9 -0
- package/dist/modules/wms/backend/wms/lot/[id]/page.js.map +7 -0
- package/dist/modules/wms/backend/wms/lot/[id]/page.meta.js +18 -0
- package/dist/modules/wms/backend/wms/lot/[id]/page.meta.js.map +7 -0
- package/dist/modules/wms/backend/wms/lots/page.js +9 -0
- package/dist/modules/wms/backend/wms/lots/page.js.map +7 -0
- package/dist/modules/wms/backend/wms/lots/page.meta.js +18 -0
- package/dist/modules/wms/backend/wms/lots/page.meta.js.map +7 -0
- package/dist/modules/wms/backend/wms/movements/page.js +22 -0
- package/dist/modules/wms/backend/wms/movements/page.js.map +7 -0
- package/dist/modules/wms/backend/wms/movements/page.meta.js +18 -0
- package/dist/modules/wms/backend/wms/movements/page.meta.js.map +7 -0
- package/dist/modules/wms/backend/wms/reservations/page.js +23 -0
- package/dist/modules/wms/backend/wms/reservations/page.js.map +7 -0
- package/dist/modules/wms/backend/wms/reservations/page.meta.js +18 -0
- package/dist/modules/wms/backend/wms/reservations/page.meta.js.map +7 -0
- package/dist/modules/wms/backend/wms/sku/[id]/page.js +9 -0
- package/dist/modules/wms/backend/wms/sku/[id]/page.js.map +7 -0
- package/dist/modules/wms/backend/wms/sku/[id]/page.meta.js +18 -0
- package/dist/modules/wms/backend/wms/sku/[id]/page.meta.js.map +7 -0
- package/dist/modules/wms/backend/wms/warehouses/page.js +10 -0
- package/dist/modules/wms/backend/wms/warehouses/page.js.map +7 -0
- package/dist/modules/wms/backend/wms/warehouses/page.meta.js +18 -0
- package/dist/modules/wms/backend/wms/warehouses/page.meta.js.map +7 -0
- package/dist/modules/wms/backend/wms/zones/page.js +10 -0
- package/dist/modules/wms/backend/wms/zones/page.js.map +7 -0
- package/dist/modules/wms/backend/wms/zones/page.meta.js +18 -0
- package/dist/modules/wms/backend/wms/zones/page.meta.js.map +7 -0
- package/dist/modules/wms/ce.js +80 -0
- package/dist/modules/wms/ce.js.map +7 -0
- package/dist/modules/wms/cli.js +83 -0
- package/dist/modules/wms/cli.js.map +7 -0
- package/dist/modules/wms/commands/configuration.js +1519 -0
- package/dist/modules/wms/commands/configuration.js.map +7 -0
- package/dist/modules/wms/commands/index.js +4 -0
- package/dist/modules/wms/commands/index.js.map +7 -0
- package/dist/modules/wms/commands/inventory-actions.js +1539 -0
- package/dist/modules/wms/commands/inventory-actions.js.map +7 -0
- package/dist/modules/wms/commands/sales-order-assignment.js +277 -0
- package/dist/modules/wms/commands/sales-order-assignment.js.map +7 -0
- package/dist/modules/wms/commands/shared.js +82 -0
- package/dist/modules/wms/commands/shared.js.map +7 -0
- package/dist/modules/wms/components/backend/AdjustInventoryDialog.js +755 -0
- package/dist/modules/wms/components/backend/AdjustInventoryDialog.js.map +7 -0
- package/dist/modules/wms/components/backend/ChangeLotStatusDialog.js +269 -0
- package/dist/modules/wms/components/backend/ChangeLotStatusDialog.js.map +7 -0
- package/dist/modules/wms/components/backend/CycleCountWizardDialog.js +1783 -0
- package/dist/modules/wms/components/backend/CycleCountWizardDialog.js.map +7 -0
- package/dist/modules/wms/components/backend/ImportInventoryDialog.js +766 -0
- package/dist/modules/wms/components/backend/ImportInventoryDialog.js.map +7 -0
- package/dist/modules/wms/components/backend/InventoryOperationsSection.js +119 -0
- package/dist/modules/wms/components/backend/InventoryOperationsSection.js.map +7 -0
- package/dist/modules/wms/components/backend/LocationEditDialog.js +140 -0
- package/dist/modules/wms/components/backend/LocationEditDialog.js.map +7 -0
- package/dist/modules/wms/components/backend/MoveInventoryDialog.js +791 -0
- package/dist/modules/wms/components/backend/MoveInventoryDialog.js.map +7 -0
- package/dist/modules/wms/components/backend/ReceiveInventoryDialog.js +656 -0
- package/dist/modules/wms/components/backend/ReceiveInventoryDialog.js.map +7 -0
- package/dist/modules/wms/components/backend/ReleaseReservationDialog.js +344 -0
- package/dist/modules/wms/components/backend/ReleaseReservationDialog.js.map +7 -0
- package/dist/modules/wms/components/backend/ReserveInventoryDialog.js +475 -0
- package/dist/modules/wms/components/backend/ReserveInventoryDialog.js.map +7 -0
- package/dist/modules/wms/components/backend/WmsConfigurationPage.js +1112 -0
- package/dist/modules/wms/components/backend/WmsConfigurationPage.js.map +7 -0
- package/dist/modules/wms/components/backend/WmsInventoryConsolePage.js +908 -0
- package/dist/modules/wms/components/backend/WmsInventoryConsolePage.js.map +7 -0
- package/dist/modules/wms/components/backend/WmsLocationDetailPage.js +1015 -0
- package/dist/modules/wms/components/backend/WmsLocationDetailPage.js.map +7 -0
- package/dist/modules/wms/components/backend/WmsLotDetailPage.js +1049 -0
- package/dist/modules/wms/components/backend/WmsLotDetailPage.js.map +7 -0
- package/dist/modules/wms/components/backend/WmsLotsListPage.js +297 -0
- package/dist/modules/wms/components/backend/WmsLotsListPage.js.map +7 -0
- package/dist/modules/wms/components/backend/WmsOperationalDashboardPage.js +757 -0
- package/dist/modules/wms/components/backend/WmsOperationalDashboardPage.js.map +7 -0
- package/dist/modules/wms/components/backend/WmsSkuDetailPage.js +975 -0
- package/dist/modules/wms/components/backend/WmsSkuDetailPage.js.map +7 -0
- package/dist/modules/wms/components/backend/inventoryMutationLoaders.js +710 -0
- package/dist/modules/wms/components/backend/inventoryMutationLoaders.js.map +7 -0
- package/dist/modules/wms/components/backend/useWmsInventoryMutationAccess.js +79 -0
- package/dist/modules/wms/components/backend/useWmsInventoryMutationAccess.js.map +7 -0
- package/dist/modules/wms/components/backend/useWmsInventoryScopeFromSearchParams.js +24 -0
- package/dist/modules/wms/components/backend/useWmsInventoryScopeFromSearchParams.js.map +7 -0
- package/dist/modules/wms/components/backend/wmsLookupLoaders.js +55 -0
- package/dist/modules/wms/components/backend/wmsLookupLoaders.js.map +7 -0
- package/dist/modules/wms/data/enrichers.js +501 -0
- package/dist/modules/wms/data/enrichers.js.map +7 -0
- package/dist/modules/wms/data/entities.js +467 -0
- package/dist/modules/wms/data/entities.js.map +7 -0
- package/dist/modules/wms/data/validators.js +399 -0
- package/dist/modules/wms/data/validators.js.map +7 -0
- package/dist/modules/wms/di.js +27 -0
- package/dist/modules/wms/di.js.map +7 -0
- package/dist/modules/wms/events.js +42 -0
- package/dist/modules/wms/events.js.map +7 -0
- package/dist/modules/wms/index.js +17 -0
- package/dist/modules/wms/index.js.map +7 -0
- package/dist/modules/wms/lib/catalogInventoryProfileIntent.js +54 -0
- package/dist/modules/wms/lib/catalogInventoryProfileIntent.js.map +7 -0
- package/dist/modules/wms/lib/catalogInventoryProfileSync.js +144 -0
- package/dist/modules/wms/lib/catalogInventoryProfileSync.js.map +7 -0
- package/dist/modules/wms/lib/expiry.js +29 -0
- package/dist/modules/wms/lib/expiry.js.map +7 -0
- package/dist/modules/wms/lib/flashMutationError.js +51 -0
- package/dist/modules/wms/lib/flashMutationError.js.map +7 -0
- package/dist/modules/wms/lib/inventoryDisplayUi.js +188 -0
- package/dist/modules/wms/lib/inventoryDisplayUi.js.map +7 -0
- package/dist/modules/wms/lib/inventoryIdempotency.js +37 -0
- package/dist/modules/wms/lib/inventoryIdempotency.js.map +7 -0
- package/dist/modules/wms/lib/inventoryImportCsv.js +173 -0
- package/dist/modules/wms/lib/inventoryImportCsv.js.map +7 -0
- package/dist/modules/wms/lib/inventoryImportService.js +468 -0
- package/dist/modules/wms/lib/inventoryImportService.js.map +7 -0
- package/dist/modules/wms/lib/inventoryMutationUi.js +25 -0
- package/dist/modules/wms/lib/inventoryMutationUi.js.map +7 -0
- package/dist/modules/wms/lib/inventoryPolicy.js +61 -0
- package/dist/modules/wms/lib/inventoryPolicy.js.map +7 -0
- package/dist/modules/wms/lib/inventoryReconciliation.js +193 -0
- package/dist/modules/wms/lib/inventoryReconciliation.js.map +7 -0
- package/dist/modules/wms/lib/inventoryTrackingValidation.js +13 -0
- package/dist/modules/wms/lib/inventoryTrackingValidation.js.map +7 -0
- package/dist/modules/wms/lib/loadOperationalDashboard.js +580 -0
- package/dist/modules/wms/lib/loadOperationalDashboard.js.map +7 -0
- package/dist/modules/wms/lib/lowStockBalanceFilter.js +49 -0
- package/dist/modules/wms/lib/lowStockBalanceFilter.js.map +7 -0
- package/dist/modules/wms/lib/primaryWarehousePolicy.js +32 -0
- package/dist/modules/wms/lib/primaryWarehousePolicy.js.map +7 -0
- package/dist/modules/wms/lib/roleFeatures.js +30 -0
- package/dist/modules/wms/lib/roleFeatures.js.map +7 -0
- package/dist/modules/wms/lib/salesOrderInventoryAutomation.js +291 -0
- package/dist/modules/wms/lib/salesOrderInventoryAutomation.js.map +7 -0
- package/dist/modules/wms/lib/salesOrderWarehouseAssignment.js +54 -0
- package/dist/modules/wms/lib/salesOrderWarehouseAssignment.js.map +7 -0
- package/dist/modules/wms/lib/wmsIntegrationToggles.js +71 -0
- package/dist/modules/wms/lib/wmsIntegrationToggles.js.map +7 -0
- package/dist/modules/wms/migrations/Migration20260428110546.js +78 -0
- package/dist/modules/wms/migrations/Migration20260428110546.js.map +7 -0
- package/dist/modules/wms/migrations/Migration20260527120000.js +15 -0
- package/dist/modules/wms/migrations/Migration20260527120000.js.map +7 -0
- package/dist/modules/wms/migrations/Migration20260527140000.js +37 -0
- package/dist/modules/wms/migrations/Migration20260527140000.js.map +7 -0
- package/dist/modules/wms/migrations/Migration20260613120000.js +23 -0
- package/dist/modules/wms/migrations/Migration20260613120000.js.map +7 -0
- package/dist/modules/wms/migrations/Migration20260616090000.js +13 -0
- package/dist/modules/wms/migrations/Migration20260616090000.js.map +7 -0
- package/dist/modules/wms/migrations/Migration20260707180000.js +15 -0
- package/dist/modules/wms/migrations/Migration20260707180000.js.map +7 -0
- package/dist/modules/wms/notifications.client.js +18 -0
- package/dist/modules/wms/notifications.client.js.map +7 -0
- package/dist/modules/wms/notifications.js +53 -0
- package/dist/modules/wms/notifications.js.map +7 -0
- package/dist/modules/wms/search.js +196 -0
- package/dist/modules/wms/search.js.map +7 -0
- package/dist/modules/wms/setup.js +30 -0
- package/dist/modules/wms/setup.js.map +7 -0
- package/dist/modules/wms/subscribers/low-stock-notification.js +40 -0
- package/dist/modules/wms/subscribers/low-stock-notification.js.map +7 -0
- package/dist/modules/wms/subscribers/reservation-shortfall-notification.js +69 -0
- package/dist/modules/wms/subscribers/reservation-shortfall-notification.js.map +7 -0
- package/dist/modules/wms/subscribers/sales-order-cancelled-release.js +14 -0
- package/dist/modules/wms/subscribers/sales-order-cancelled-release.js.map +7 -0
- package/dist/modules/wms/subscribers/sales-order-confirmed-reserve.js +14 -0
- package/dist/modules/wms/subscribers/sales-order-confirmed-reserve.js.map +7 -0
- package/dist/modules/wms/translations.js +10 -0
- package/dist/modules/wms/translations.js.map +7 -0
- package/dist/modules/wms/vector.js +9 -0
- package/dist/modules/wms/vector.js.map +7 -0
- package/dist/modules/wms/widgets/injection/catalog-inventory-profile/widget.js +200 -0
- package/dist/modules/wms/widgets/injection/catalog-inventory-profile/widget.js.map +7 -0
- package/dist/modules/wms/widgets/injection/order-items-stock-column/widget.js +104 -0
- package/dist/modules/wms/widgets/injection/order-items-stock-column/widget.js.map +7 -0
- package/dist/modules/wms/widgets/injection/sales-order-stock-context/widget.client.js +389 -0
- package/dist/modules/wms/widgets/injection/sales-order-stock-context/widget.client.js.map +7 -0
- package/dist/modules/wms/widgets/injection/sales-order-stock-context/widget.js +16 -0
- package/dist/modules/wms/widgets/injection/sales-order-stock-context/widget.js.map +7 -0
- package/dist/modules/wms/widgets/injection-table.js +36 -0
- package/dist/modules/wms/widgets/injection-table.js.map +7 -0
- package/dist/modules/wms/widgets/notifications/WmsLowStockRenderer.js +150 -0
- package/dist/modules/wms/widgets/notifications/WmsLowStockRenderer.js.map +7 -0
- package/dist/modules/wms/widgets/notifications/WmsReservationShortfallRenderer.js +186 -0
- package/dist/modules/wms/widgets/notifications/WmsReservationShortfallRenderer.js.map +7 -0
- package/dist/modules/wms/widgets/notifications/index.js +7 -0
- package/dist/modules/wms/widgets/notifications/index.js.map +7 -0
- package/generated/entities/inventory_balance/index.ts +9 -0
- package/generated/entities/inventory_lot/index.ts +8 -0
- package/generated/entities/inventory_movement/index.ts +16 -0
- package/generated/entities/inventory_reservation/index.ts +10 -0
- package/generated/entities/product_inventory_profile/index.ts +9 -0
- package/generated/entities/sales_order_warehouse_assignment/index.ts +4 -0
- package/generated/entities/warehouse/index.ts +11 -0
- package/generated/entities/warehouse_location/index.ts +9 -0
- package/generated/entities/warehouse_zone/index.ts +4 -0
- package/generated/entities.ids.generated.ts +12 -0
- package/generated/entity-fields-registry.ts +98 -0
- package/package.json +7 -7
- package/src/modules/auth/backend/roles/page.tsx +13 -4
- package/src/modules/auth/i18n/de.json +2 -0
- package/src/modules/auth/i18n/en.json +2 -0
- package/src/modules/auth/i18n/es.json +2 -0
- package/src/modules/auth/i18n/pl.json +2 -0
- package/src/modules/auth/lib/backendChrome.tsx +1 -0
- package/src/modules/auth/services/rbacService.ts +25 -21
- package/src/modules/catalog/api/products/route.ts +3 -0
- package/src/modules/catalog/api/variants/route.ts +3 -0
- package/src/modules/query_index/lib/engine.ts +18 -6
- package/src/modules/sales/api/documents/factory.ts +1 -0
- package/src/modules/sales/commands/documents.ts +71 -0
- package/src/modules/sales/components/documents/ItemsSection.tsx +58 -0
- package/src/modules/sales/events.ts +2 -0
- package/src/modules/sales/widgets/injection/order-items-context.ts +15 -0
- package/src/modules/wms/acl.ts +14 -0
- package/src/modules/wms/analytics.ts +9 -0
- package/src/modules/wms/api/dashboard/operational/route.ts +83 -0
- package/src/modules/wms/api/interceptors.ts +108 -0
- package/src/modules/wms/api/inventory/adjust/route.ts +50 -0
- package/src/modules/wms/api/inventory/allocate/route.ts +50 -0
- package/src/modules/wms/api/inventory/balances/route.ts +178 -0
- package/src/modules/wms/api/inventory/cycle-count/route.ts +51 -0
- package/src/modules/wms/api/inventory/helpers.ts +128 -0
- package/src/modules/wms/api/inventory/import/apply/route.ts +47 -0
- package/src/modules/wms/api/inventory/import/helpers.ts +223 -0
- package/src/modules/wms/api/inventory/import/template/route.ts +31 -0
- package/src/modules/wms/api/inventory/import/validate/route.ts +56 -0
- package/src/modules/wms/api/inventory/move/route.ts +50 -0
- package/src/modules/wms/api/inventory/movements/route.ts +164 -0
- package/src/modules/wms/api/inventory/receive/route.ts +49 -0
- package/src/modules/wms/api/inventory/release/route.ts +43 -0
- package/src/modules/wms/api/inventory/reservations/route.ts +128 -0
- package/src/modules/wms/api/inventory/reserve/route.ts +59 -0
- package/src/modules/wms/api/inventory-profiles/route.ts +177 -0
- package/src/modules/wms/api/listEnrichers.ts +312 -0
- package/src/modules/wms/api/listSearch.ts +121 -0
- package/src/modules/wms/api/locations/route.ts +171 -0
- package/src/modules/wms/api/lots/route.ts +205 -0
- package/src/modules/wms/api/openapi.ts +28 -0
- package/src/modules/wms/api/sales-orders/[salesOrderId]/re-run-reservation/route.ts +58 -0
- package/src/modules/wms/api/sales-orders/[salesOrderId]/warehouse-assignment/route.ts +306 -0
- package/src/modules/wms/api/sales-orders/assign-warehouse/route.ts +57 -0
- package/src/modules/wms/api/sales-orders/unassign-warehouse/route.ts +43 -0
- package/src/modules/wms/api/warehouses/route.ts +181 -0
- package/src/modules/wms/api/zones/route.ts +158 -0
- package/src/modules/wms/backend/config/wms/page.meta.ts +14 -0
- package/src/modules/wms/backend/config/wms/page.tsx +5 -0
- package/src/modules/wms/backend/page.meta.ts +12 -0
- package/src/modules/wms/backend/page.tsx +5 -0
- package/src/modules/wms/backend/wms/inventory/page.meta.ts +14 -0
- package/src/modules/wms/backend/wms/inventory/page.tsx +5 -0
- package/src/modules/wms/backend/wms/location/[id]/page.meta.ts +14 -0
- package/src/modules/wms/backend/wms/location/[id]/page.tsx +5 -0
- package/src/modules/wms/backend/wms/locations/page.meta.ts +14 -0
- package/src/modules/wms/backend/wms/locations/page.tsx +12 -0
- package/src/modules/wms/backend/wms/lot/[id]/page.meta.ts +14 -0
- package/src/modules/wms/backend/wms/lot/[id]/page.tsx +5 -0
- package/src/modules/wms/backend/wms/lots/page.meta.ts +14 -0
- package/src/modules/wms/backend/wms/lots/page.tsx +5 -0
- package/src/modules/wms/backend/wms/movements/page.meta.ts +14 -0
- package/src/modules/wms/backend/wms/movements/page.tsx +23 -0
- package/src/modules/wms/backend/wms/reservations/page.meta.ts +14 -0
- package/src/modules/wms/backend/wms/reservations/page.tsx +24 -0
- package/src/modules/wms/backend/wms/sku/[id]/page.meta.ts +14 -0
- package/src/modules/wms/backend/wms/sku/[id]/page.tsx +5 -0
- package/src/modules/wms/backend/wms/warehouses/page.meta.ts +14 -0
- package/src/modules/wms/backend/wms/warehouses/page.tsx +12 -0
- package/src/modules/wms/backend/wms/zones/page.meta.ts +14 -0
- package/src/modules/wms/backend/wms/zones/page.tsx +12 -0
- package/src/modules/wms/ce.ts +76 -0
- package/src/modules/wms/cli.ts +96 -0
- package/src/modules/wms/commands/configuration.ts +1844 -0
- package/src/modules/wms/commands/index.ts +3 -0
- package/src/modules/wms/commands/inventory-actions.ts +1866 -0
- package/src/modules/wms/commands/sales-order-assignment.ts +346 -0
- package/src/modules/wms/commands/shared.ts +90 -0
- package/src/modules/wms/components/backend/AdjustInventoryDialog.tsx +820 -0
- package/src/modules/wms/components/backend/ChangeLotStatusDialog.tsx +312 -0
- package/src/modules/wms/components/backend/CycleCountWizardDialog.tsx +2007 -0
- package/src/modules/wms/components/backend/ImportInventoryDialog.tsx +1018 -0
- package/src/modules/wms/components/backend/InventoryOperationsSection.tsx +159 -0
- package/src/modules/wms/components/backend/LocationEditDialog.tsx +185 -0
- package/src/modules/wms/components/backend/MoveInventoryDialog.tsx +907 -0
- package/src/modules/wms/components/backend/ReceiveInventoryDialog.tsx +695 -0
- package/src/modules/wms/components/backend/ReleaseReservationDialog.tsx +424 -0
- package/src/modules/wms/components/backend/ReserveInventoryDialog.tsx +511 -0
- package/src/modules/wms/components/backend/WmsConfigurationPage.tsx +1340 -0
- package/src/modules/wms/components/backend/WmsInventoryConsolePage.tsx +1132 -0
- package/src/modules/wms/components/backend/WmsLocationDetailPage.tsx +1310 -0
- package/src/modules/wms/components/backend/WmsLotDetailPage.tsx +1355 -0
- package/src/modules/wms/components/backend/WmsLotsListPage.tsx +359 -0
- package/src/modules/wms/components/backend/WmsOperationalDashboardPage.tsx +958 -0
- package/src/modules/wms/components/backend/WmsSkuDetailPage.tsx +1245 -0
- package/src/modules/wms/components/backend/inventoryMutationLoaders.ts +960 -0
- package/src/modules/wms/components/backend/useWmsInventoryMutationAccess.ts +89 -0
- package/src/modules/wms/components/backend/useWmsInventoryScopeFromSearchParams.ts +32 -0
- package/src/modules/wms/components/backend/wmsLookupLoaders.ts +78 -0
- package/src/modules/wms/data/enrichers.ts +718 -0
- package/src/modules/wms/data/entities.ts +436 -0
- package/src/modules/wms/data/validators.ts +463 -0
- package/src/modules/wms/di.ts +25 -0
- package/src/modules/wms/events.ts +41 -0
- package/src/modules/wms/i18n/de.json +1051 -0
- package/src/modules/wms/i18n/en.json +1051 -0
- package/src/modules/wms/i18n/es.json +1051 -0
- package/src/modules/wms/i18n/pl.json +1051 -0
- package/src/modules/wms/index.ts +15 -0
- package/src/modules/wms/lib/catalogInventoryProfileIntent.ts +65 -0
- package/src/modules/wms/lib/catalogInventoryProfileSync.ts +199 -0
- package/src/modules/wms/lib/expiry.ts +30 -0
- package/src/modules/wms/lib/flashMutationError.ts +74 -0
- package/src/modules/wms/lib/inventoryDisplayUi.ts +249 -0
- package/src/modules/wms/lib/inventoryIdempotency.ts +54 -0
- package/src/modules/wms/lib/inventoryImportCsv.ts +212 -0
- package/src/modules/wms/lib/inventoryImportService.ts +629 -0
- package/src/modules/wms/lib/inventoryMutationUi.ts +21 -0
- package/src/modules/wms/lib/inventoryPolicy.ts +111 -0
- package/src/modules/wms/lib/inventoryReconciliation.ts +263 -0
- package/src/modules/wms/lib/inventoryTrackingValidation.ts +23 -0
- package/src/modules/wms/lib/loadOperationalDashboard.ts +776 -0
- package/src/modules/wms/lib/lowStockBalanceFilter.ts +74 -0
- package/src/modules/wms/lib/primaryWarehousePolicy.ts +46 -0
- package/src/modules/wms/lib/roleFeatures.ts +32 -0
- package/src/modules/wms/lib/salesOrderInventoryAutomation.ts +390 -0
- package/src/modules/wms/lib/salesOrderWarehouseAssignment.ts +78 -0
- package/src/modules/wms/lib/wmsIntegrationToggles.ts +81 -0
- package/src/modules/wms/migrations/.snapshot-open-mercato.json +2880 -0
- package/src/modules/wms/migrations/Migration20260428110546.ts +91 -0
- package/src/modules/wms/migrations/Migration20260527120000.ts +15 -0
- package/src/modules/wms/migrations/Migration20260527140000.ts +35 -0
- package/src/modules/wms/migrations/Migration20260613120000.ts +21 -0
- package/src/modules/wms/migrations/Migration20260616090000.ts +11 -0
- package/src/modules/wms/migrations/Migration20260707180000.ts +13 -0
- package/src/modules/wms/notifications.client.ts +18 -0
- package/src/modules/wms/notifications.ts +51 -0
- package/src/modules/wms/search.ts +230 -0
- package/src/modules/wms/setup.ts +29 -0
- package/src/modules/wms/subscribers/low-stock-notification.ts +54 -0
- package/src/modules/wms/subscribers/reservation-shortfall-notification.ts +98 -0
- package/src/modules/wms/subscribers/sales-order-cancelled-release.ts +21 -0
- package/src/modules/wms/subscribers/sales-order-confirmed-reserve.ts +21 -0
- package/src/modules/wms/translations.ts +6 -0
- package/src/modules/wms/vector.ts +7 -0
- package/src/modules/wms/widgets/injection/catalog-inventory-profile/widget.ts +238 -0
- package/src/modules/wms/widgets/injection/order-items-stock-column/widget.tsx +153 -0
- package/src/modules/wms/widgets/injection/sales-order-stock-context/widget.client.tsx +476 -0
- package/src/modules/wms/widgets/injection/sales-order-stock-context/widget.ts +15 -0
- package/src/modules/wms/widgets/injection-table.ts +34 -0
- package/src/modules/wms/widgets/notifications/WmsLowStockRenderer.tsx +153 -0
- package/src/modules/wms/widgets/notifications/WmsReservationShortfallRenderer.tsx +194 -0
- package/src/modules/wms/widgets/notifications/index.ts +2 -0
|
@@ -0,0 +1,1866 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// WMS Inventory Mutation Commands — Undo Policy
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// All inventory-mutation commands in this file are deliberately registered
|
|
6
|
+
// with `isUndoable: false` and therefore opt out of the generic command-bus
|
|
7
|
+
// undo flow. WMS inventory is modeled as an append-only ledger
|
|
8
|
+
// (`inventory_movements`) that drives live balances; subsequent reservations
|
|
9
|
+
// and movements may be made on top of any prior state, so a per-record
|
|
10
|
+
// "undo" cannot safely re-derive a point-in-time balance without a
|
|
11
|
+
// stop-the-world replay.
|
|
12
|
+
//
|
|
13
|
+
// Reversal is therefore exposed as an explicit, auditable counter-action in
|
|
14
|
+
// the domain model rather than as a generic undo verb:
|
|
15
|
+
// - reserve ↔ release
|
|
16
|
+
// - allocate ↔ release (cancels the allocation)
|
|
17
|
+
// - adjust(+N) ↔ adjust(-N)
|
|
18
|
+
// - receive ↔ adjust / RMA flow
|
|
19
|
+
// - move(A → B) ↔ move(B → A)
|
|
20
|
+
// - cycle count ↔ cycle count (re-recount)
|
|
21
|
+
//
|
|
22
|
+
// The audit log still captures the full before/after via `buildLog`, so any
|
|
23
|
+
// reversing counter-action is fully traceable.
|
|
24
|
+
// =============================================================================
|
|
25
|
+
import { LockMode } from '@mikro-orm/core'
|
|
26
|
+
import { randomUUID } from 'crypto'
|
|
27
|
+
import type { CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
|
|
28
|
+
import { registerCommand } from '@open-mercato/shared/lib/commands'
|
|
29
|
+
import { emitCrudSideEffects } from '@open-mercato/shared/lib/commands/helpers'
|
|
30
|
+
import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
|
|
31
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
32
|
+
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
33
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
34
|
+
import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
35
|
+
import { emitWmsEvent } from '../events'
|
|
36
|
+
import { E } from '#generated/entities.ids.generated'
|
|
37
|
+
import type { QueryEngine } from '@open-mercato/shared/lib/query/types'
|
|
38
|
+
import {
|
|
39
|
+
InventoryBalance,
|
|
40
|
+
InventoryLot,
|
|
41
|
+
InventoryMovement,
|
|
42
|
+
InventoryReservation,
|
|
43
|
+
ProductInventoryProfile,
|
|
44
|
+
Warehouse,
|
|
45
|
+
WarehouseLocation,
|
|
46
|
+
type InventoryStrategy,
|
|
47
|
+
} from '../data/entities'
|
|
48
|
+
import {
|
|
49
|
+
inventoryAdjustSchema,
|
|
50
|
+
inventoryCycleCountSchema,
|
|
51
|
+
inventoryMoveSchema,
|
|
52
|
+
inventoryReceiveSchema,
|
|
53
|
+
inventoryReservationAllocateSchema,
|
|
54
|
+
inventoryReservationCreateSchema,
|
|
55
|
+
inventoryReservationReleaseSchema,
|
|
56
|
+
type InventoryAdjustInput,
|
|
57
|
+
type InventoryCycleCountInput,
|
|
58
|
+
type InventoryMoveInput,
|
|
59
|
+
type InventoryReceiveInput,
|
|
60
|
+
type InventoryReservationAllocateInput,
|
|
61
|
+
type InventoryReservationCreateInput,
|
|
62
|
+
type InventoryReservationReleaseInput,
|
|
63
|
+
} from '../data/validators'
|
|
64
|
+
import {
|
|
65
|
+
evaluateLowStock,
|
|
66
|
+
isLotEligible,
|
|
67
|
+
resolveReservationStrategyFromProfile,
|
|
68
|
+
sortBucketsForStrategy,
|
|
69
|
+
type InventoryStrategyBucket,
|
|
70
|
+
} from '../lib/inventoryPolicy'
|
|
71
|
+
import { enforceInventoryTrackingRequirements } from '../lib/inventoryTrackingValidation'
|
|
72
|
+
import {
|
|
73
|
+
buildMovementIdempotencyKey,
|
|
74
|
+
buildReservationIdempotencyKey,
|
|
75
|
+
} from '../lib/inventoryIdempotency'
|
|
76
|
+
import {
|
|
77
|
+
ensureOrganizationScope,
|
|
78
|
+
ensureTenantScope,
|
|
79
|
+
inventoryBalanceCrudEvents,
|
|
80
|
+
inventoryBalanceCrudIndexer,
|
|
81
|
+
inventoryMovementCrudEvents,
|
|
82
|
+
inventoryMovementCrudIndexer,
|
|
83
|
+
inventoryReservationCrudEvents,
|
|
84
|
+
inventoryReservationCrudIndexer,
|
|
85
|
+
requireId,
|
|
86
|
+
toNumericString,
|
|
87
|
+
WMS_INVENTORY_BALANCE_RESOURCE,
|
|
88
|
+
WMS_INVENTORY_MOVEMENT_RESOURCE,
|
|
89
|
+
WMS_INVENTORY_RESERVATION_RESOURCE,
|
|
90
|
+
} from './shared'
|
|
91
|
+
|
|
92
|
+
type Scope = { tenantId: string; organizationId: string }
|
|
93
|
+
type AllocationBucket = {
|
|
94
|
+
balanceId: string
|
|
95
|
+
locationId: string
|
|
96
|
+
lotId: string | null
|
|
97
|
+
serialNumber: string | null
|
|
98
|
+
quantity: number
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
type ReservationMetadata = {
|
|
102
|
+
allocatedBuckets?: AllocationBucket[]
|
|
103
|
+
allocationState?: 'reserved' | 'allocated'
|
|
104
|
+
strategy?: InventoryStrategy
|
|
105
|
+
releasedReason?: string
|
|
106
|
+
releasedAt?: string
|
|
107
|
+
allocatedAt?: string
|
|
108
|
+
[key: string]: unknown
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
type ReservationCommandResult = {
|
|
112
|
+
reservationId: string
|
|
113
|
+
allocatedBuckets: Array<{ locationId: string; lotId: string | null; quantity: string }>
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
type MutationLogInput = {
|
|
117
|
+
actionKey: string
|
|
118
|
+
fallbackLabel: string
|
|
119
|
+
resourceKind: string
|
|
120
|
+
resourceId: string | null
|
|
121
|
+
parentResourceId?: string | null
|
|
122
|
+
tenantId: string | null
|
|
123
|
+
organizationId: string | null
|
|
124
|
+
cacheAliases?: string[]
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
type AffectedReservation = { entity: InventoryReservation; action: 'created' | 'updated' | 'deleted' }
|
|
128
|
+
type AffectedMovement = { entity: InventoryMovement; action: 'created' | 'updated' | 'deleted' }
|
|
129
|
+
type AffectedBalance = { entity: InventoryBalance; action: 'created' | 'updated' | 'deleted' }
|
|
130
|
+
|
|
131
|
+
type AffectedSideEffects = {
|
|
132
|
+
reservations?: AffectedReservation[]
|
|
133
|
+
movements?: AffectedMovement[]
|
|
134
|
+
balances?: AffectedBalance[]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function emitInventorySideEffects(
|
|
138
|
+
ctx: CommandRuntimeContext,
|
|
139
|
+
affected: AffectedSideEffects,
|
|
140
|
+
): Promise<void> {
|
|
141
|
+
let de: DataEngine | null = null
|
|
142
|
+
try {
|
|
143
|
+
de = ctx.container.resolve('dataEngine') as DataEngine
|
|
144
|
+
} catch {
|
|
145
|
+
de = null
|
|
146
|
+
}
|
|
147
|
+
if (!de) return
|
|
148
|
+
|
|
149
|
+
for (const { entity, action } of affected.reservations ?? []) {
|
|
150
|
+
await emitCrudSideEffects({
|
|
151
|
+
dataEngine: de,
|
|
152
|
+
action,
|
|
153
|
+
entity,
|
|
154
|
+
identifiers: {
|
|
155
|
+
id: entity.id,
|
|
156
|
+
organizationId: entity.organizationId,
|
|
157
|
+
tenantId: entity.tenantId,
|
|
158
|
+
},
|
|
159
|
+
indexer: inventoryReservationCrudIndexer,
|
|
160
|
+
events: inventoryReservationCrudEvents,
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
for (const { entity, action } of affected.movements ?? []) {
|
|
164
|
+
await emitCrudSideEffects({
|
|
165
|
+
dataEngine: de,
|
|
166
|
+
action,
|
|
167
|
+
entity,
|
|
168
|
+
identifiers: {
|
|
169
|
+
id: entity.id,
|
|
170
|
+
organizationId: entity.organizationId,
|
|
171
|
+
tenantId: entity.tenantId,
|
|
172
|
+
},
|
|
173
|
+
indexer: inventoryMovementCrudIndexer,
|
|
174
|
+
events: inventoryMovementCrudEvents,
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
for (const { entity, action } of affected.balances ?? []) {
|
|
178
|
+
await emitCrudSideEffects({
|
|
179
|
+
dataEngine: de,
|
|
180
|
+
action,
|
|
181
|
+
entity,
|
|
182
|
+
identifiers: {
|
|
183
|
+
id: entity.id,
|
|
184
|
+
organizationId: entity.organizationId,
|
|
185
|
+
tenantId: entity.tenantId,
|
|
186
|
+
},
|
|
187
|
+
indexer: inventoryBalanceCrudIndexer,
|
|
188
|
+
events: inventoryBalanceCrudEvents,
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function resolveScope(ctx: CommandRuntimeContext, fallback?: { tenantId?: string | null; organizationId?: string | null }): Scope {
|
|
194
|
+
const tenantId = fallback?.tenantId ?? ctx.auth?.tenantId ?? null
|
|
195
|
+
const organizationId = fallback?.organizationId ?? ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null
|
|
196
|
+
if (!tenantId || !organizationId) {
|
|
197
|
+
throw new CrudHttpError(400, { error: 'Tenant and organization scope are required.' })
|
|
198
|
+
}
|
|
199
|
+
return { tenantId, organizationId }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function resolveEm(ctx: CommandRuntimeContext): EntityManager {
|
|
203
|
+
return (ctx.container.resolve('em') as EntityManager).fork()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function runInTransaction<TResult>(
|
|
207
|
+
em: EntityManager,
|
|
208
|
+
operation: (trx: EntityManager) => Promise<TResult>,
|
|
209
|
+
): Promise<TResult> {
|
|
210
|
+
const transactionalEm = em as EntityManager & {
|
|
211
|
+
transactional?: (callback: (trx: EntityManager) => Promise<TResult>) => Promise<TResult>
|
|
212
|
+
}
|
|
213
|
+
if (typeof transactionalEm.transactional === 'function') {
|
|
214
|
+
return transactionalEm.transactional((trx) => operation(trx))
|
|
215
|
+
}
|
|
216
|
+
return operation(em)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function toNumber(value: unknown): number {
|
|
220
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
221
|
+
if (typeof value === 'string' && value.trim().length > 0) {
|
|
222
|
+
const parsed = Number(value)
|
|
223
|
+
if (Number.isFinite(parsed)) return parsed
|
|
224
|
+
}
|
|
225
|
+
return 0
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function isUniqueConstraintError(error: unknown): boolean {
|
|
229
|
+
if (!error || typeof error !== 'object') return false
|
|
230
|
+
const code = (error as { code?: string }).code
|
|
231
|
+
if (code === '23505') return true
|
|
232
|
+
const name = (error as { name?: string }).name
|
|
233
|
+
return name === 'UniqueConstraintViolationException'
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function findExistingMovementByIdempotencyKey(
|
|
237
|
+
em: EntityManager,
|
|
238
|
+
scope: Scope,
|
|
239
|
+
idempotencyKey: string,
|
|
240
|
+
): Promise<InventoryMovement | null> {
|
|
241
|
+
return findOneWithDecryption(
|
|
242
|
+
em,
|
|
243
|
+
InventoryMovement,
|
|
244
|
+
{
|
|
245
|
+
organizationId: scope.organizationId,
|
|
246
|
+
idempotencyKey,
|
|
247
|
+
deletedAt: null,
|
|
248
|
+
},
|
|
249
|
+
undefined,
|
|
250
|
+
scope,
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function findExistingActiveReservationByIdempotencyKey(
|
|
255
|
+
em: EntityManager,
|
|
256
|
+
scope: Scope,
|
|
257
|
+
idempotencyKey: string,
|
|
258
|
+
): Promise<InventoryReservation | null> {
|
|
259
|
+
return findOneWithDecryption(
|
|
260
|
+
em,
|
|
261
|
+
InventoryReservation,
|
|
262
|
+
{
|
|
263
|
+
organizationId: scope.organizationId,
|
|
264
|
+
idempotencyKey,
|
|
265
|
+
status: 'active',
|
|
266
|
+
deletedAt: null,
|
|
267
|
+
},
|
|
268
|
+
undefined,
|
|
269
|
+
scope,
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function resolveLotFromBalance(balance: InventoryBalance): InventoryLot | null {
|
|
274
|
+
const lotRaw = balance.lot ?? null
|
|
275
|
+
if (!lotRaw || typeof lotRaw === 'string') return null
|
|
276
|
+
return lotRaw
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function balanceHasEligibleLot(balance: InventoryBalance, now: Date): boolean {
|
|
280
|
+
const lot = resolveLotFromBalance(balance)
|
|
281
|
+
return isLotEligible(lot, now)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function setNumeric(target: { [key: string]: unknown }, key: string, value: number) {
|
|
285
|
+
target[key] = toNumericString(value)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function getAvailableQuantity(balance: InventoryBalance): number {
|
|
289
|
+
return toNumber(balance.quantityOnHand) - toNumber(balance.quantityReserved) - toNumber(balance.quantityAllocated)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function extractReservationMetadata(reservation: InventoryReservation): ReservationMetadata {
|
|
293
|
+
if (!reservation.metadata || typeof reservation.metadata !== 'object' || Array.isArray(reservation.metadata)) {
|
|
294
|
+
return {}
|
|
295
|
+
}
|
|
296
|
+
return { ...(reservation.metadata as Record<string, unknown>) }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function buildBucketKey(input: {
|
|
300
|
+
warehouseId: string
|
|
301
|
+
locationId: string
|
|
302
|
+
catalogVariantId: string
|
|
303
|
+
lotId: string | null
|
|
304
|
+
serialNumber: string | null
|
|
305
|
+
}): string {
|
|
306
|
+
return [
|
|
307
|
+
input.warehouseId,
|
|
308
|
+
input.locationId,
|
|
309
|
+
input.catalogVariantId,
|
|
310
|
+
input.lotId ?? '',
|
|
311
|
+
input.serialNumber ?? '',
|
|
312
|
+
].join('::')
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function serializeAllocationBuckets(buckets: AllocationBucket[]): Array<{ locationId: string; lotId: string | null; quantity: string }> {
|
|
316
|
+
return buckets.map((bucket) => ({
|
|
317
|
+
locationId: bucket.locationId,
|
|
318
|
+
lotId: bucket.lotId,
|
|
319
|
+
quantity: toNumericString(bucket.quantity),
|
|
320
|
+
}))
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function buildMutationLog(input: MutationLogInput) {
|
|
324
|
+
const { translate } = await resolveTranslations()
|
|
325
|
+
const aliases = Array.from(
|
|
326
|
+
new Set((input.cacheAliases ?? []).filter((alias) => typeof alias === 'string' && alias.length > 0)),
|
|
327
|
+
)
|
|
328
|
+
return {
|
|
329
|
+
actionLabel: translate(input.actionKey, input.fallbackLabel),
|
|
330
|
+
resourceKind: input.resourceKind,
|
|
331
|
+
resourceId: input.resourceId,
|
|
332
|
+
parentResourceKind: input.parentResourceId ? 'wms.inventory' : null,
|
|
333
|
+
parentResourceId: input.parentResourceId ?? null,
|
|
334
|
+
tenantId: input.tenantId,
|
|
335
|
+
organizationId: input.organizationId,
|
|
336
|
+
...(aliases.length ? { context: { cacheAliases: aliases } } : {}),
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function requireWarehouse(em: EntityManager, ctx: CommandRuntimeContext, warehouseId: string, scope?: Scope) {
|
|
341
|
+
const resolvedScope = scope ?? resolveScope(ctx)
|
|
342
|
+
const warehouse = await findOneWithDecryption(
|
|
343
|
+
em,
|
|
344
|
+
Warehouse,
|
|
345
|
+
{ id: warehouseId, deletedAt: null },
|
|
346
|
+
undefined,
|
|
347
|
+
resolvedScope,
|
|
348
|
+
)
|
|
349
|
+
if (!warehouse) throw new CrudHttpError(404, { error: 'Warehouse not found.' })
|
|
350
|
+
ensureTenantScope(ctx, warehouse.tenantId)
|
|
351
|
+
ensureOrganizationScope(ctx, warehouse.organizationId)
|
|
352
|
+
return warehouse
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function requireLocation(em: EntityManager, ctx: CommandRuntimeContext, locationId: string, scope?: Scope) {
|
|
356
|
+
const resolvedScope = scope ?? resolveScope(ctx)
|
|
357
|
+
const location = await findOneWithDecryption(
|
|
358
|
+
em,
|
|
359
|
+
WarehouseLocation,
|
|
360
|
+
{ id: locationId, deletedAt: null },
|
|
361
|
+
undefined,
|
|
362
|
+
resolvedScope,
|
|
363
|
+
)
|
|
364
|
+
if (!location) throw new CrudHttpError(404, { error: 'Warehouse location not found.' })
|
|
365
|
+
ensureTenantScope(ctx, location.tenantId)
|
|
366
|
+
ensureOrganizationScope(ctx, location.organizationId)
|
|
367
|
+
return location
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function requireReservation(em: EntityManager, ctx: CommandRuntimeContext, reservationId: string, scope?: Scope, lock = false) {
|
|
371
|
+
const resolvedScope = scope ?? resolveScope(ctx)
|
|
372
|
+
const reservation = await findOneWithDecryption(
|
|
373
|
+
em,
|
|
374
|
+
InventoryReservation,
|
|
375
|
+
{ id: reservationId, deletedAt: null },
|
|
376
|
+
lock ? { lockMode: LockMode.PESSIMISTIC_WRITE } : undefined,
|
|
377
|
+
resolvedScope,
|
|
378
|
+
)
|
|
379
|
+
if (!reservation) throw new CrudHttpError(404, { error: 'Inventory reservation not found.' })
|
|
380
|
+
ensureTenantScope(ctx, reservation.tenantId)
|
|
381
|
+
ensureOrganizationScope(ctx, reservation.organizationId)
|
|
382
|
+
return reservation
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function loadProfileForVariant(
|
|
386
|
+
em: EntityManager,
|
|
387
|
+
ctx: CommandRuntimeContext,
|
|
388
|
+
catalogVariantId: string,
|
|
389
|
+
scope?: Scope,
|
|
390
|
+
) {
|
|
391
|
+
const resolvedScope = scope ?? resolveScope(ctx)
|
|
392
|
+
const profile = await findOneWithDecryption(
|
|
393
|
+
em,
|
|
394
|
+
ProductInventoryProfile,
|
|
395
|
+
{ catalogVariantId, deletedAt: null },
|
|
396
|
+
undefined,
|
|
397
|
+
resolvedScope,
|
|
398
|
+
)
|
|
399
|
+
if (!profile) return null
|
|
400
|
+
ensureTenantScope(ctx, profile.tenantId)
|
|
401
|
+
ensureOrganizationScope(ctx, profile.organizationId)
|
|
402
|
+
return profile
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function resolveReceiveLotId(
|
|
406
|
+
em: EntityManager,
|
|
407
|
+
ctx: CommandRuntimeContext,
|
|
408
|
+
scope: Scope,
|
|
409
|
+
input: Pick<InventoryReceiveInput, 'catalogVariantId' | 'lotId' | 'lotNumber'>,
|
|
410
|
+
): Promise<string | undefined> {
|
|
411
|
+
if (input.lotId?.trim()) return input.lotId.trim()
|
|
412
|
+
const lotNumber = input.lotNumber?.trim()
|
|
413
|
+
if (!lotNumber) return undefined
|
|
414
|
+
|
|
415
|
+
const existing = await findOneWithDecryption(
|
|
416
|
+
em,
|
|
417
|
+
InventoryLot,
|
|
418
|
+
{
|
|
419
|
+
catalogVariantId: input.catalogVariantId,
|
|
420
|
+
lotNumber,
|
|
421
|
+
deletedAt: null,
|
|
422
|
+
},
|
|
423
|
+
undefined,
|
|
424
|
+
scope,
|
|
425
|
+
)
|
|
426
|
+
if (existing) return existing.id
|
|
427
|
+
|
|
428
|
+
const queryEngine = ctx.container.resolve<QueryEngine>('queryEngine')
|
|
429
|
+
const variantResult = await queryEngine.query(E.catalog.catalog_product_variant, {
|
|
430
|
+
tenantId: scope.tenantId,
|
|
431
|
+
organizationId: scope.organizationId,
|
|
432
|
+
filters: { id: { $eq: input.catalogVariantId } },
|
|
433
|
+
fields: ['id', 'sku'],
|
|
434
|
+
page: { page: 1, pageSize: 1 },
|
|
435
|
+
})
|
|
436
|
+
const firstItem: unknown = variantResult.items?.[0]
|
|
437
|
+
const sku =
|
|
438
|
+
typeof firstItem === 'object' && firstItem !== null
|
|
439
|
+
? String((firstItem as Record<string, unknown>).sku ?? '').trim()
|
|
440
|
+
: ''
|
|
441
|
+
if (!sku) {
|
|
442
|
+
throw new CrudHttpError(422, { error: 'variant_sku_missing' })
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Use a client-side UUID so the lot entity can be persisted into the
|
|
446
|
+
// surrounding transaction without a premature flush (defaultRaw would
|
|
447
|
+
// otherwise require a DB round-trip to obtain the generated id).
|
|
448
|
+
const lot = em.create(InventoryLot, {
|
|
449
|
+
id: randomUUID(),
|
|
450
|
+
organizationId: scope.organizationId,
|
|
451
|
+
tenantId: scope.tenantId,
|
|
452
|
+
catalogVariantId: input.catalogVariantId,
|
|
453
|
+
sku,
|
|
454
|
+
lotNumber,
|
|
455
|
+
status: 'available',
|
|
456
|
+
})
|
|
457
|
+
em.persist(lot)
|
|
458
|
+
return lot.id
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function listCandidateBalances(
|
|
462
|
+
em: EntityManager,
|
|
463
|
+
ctx: CommandRuntimeContext,
|
|
464
|
+
input: {
|
|
465
|
+
warehouseId: string
|
|
466
|
+
catalogVariantId: string
|
|
467
|
+
lotId?: string
|
|
468
|
+
serialNumber?: string
|
|
469
|
+
},
|
|
470
|
+
scope: Scope,
|
|
471
|
+
) {
|
|
472
|
+
const where: Record<string, unknown> = {
|
|
473
|
+
warehouse: input.warehouseId,
|
|
474
|
+
catalogVariantId: input.catalogVariantId,
|
|
475
|
+
deletedAt: null,
|
|
476
|
+
}
|
|
477
|
+
if (input.lotId) where.lot = input.lotId
|
|
478
|
+
if (input.serialNumber) where.serialNumber = input.serialNumber
|
|
479
|
+
const balances = await findWithDecryption(
|
|
480
|
+
em,
|
|
481
|
+
InventoryBalance,
|
|
482
|
+
where,
|
|
483
|
+
{
|
|
484
|
+
populate: ['lot', 'location'],
|
|
485
|
+
lockMode: LockMode.PESSIMISTIC_WRITE,
|
|
486
|
+
orderBy: { createdAt: 'asc', id: 'asc' },
|
|
487
|
+
},
|
|
488
|
+
scope,
|
|
489
|
+
)
|
|
490
|
+
for (const balance of balances) {
|
|
491
|
+
ensureTenantScope(ctx, balance.tenantId)
|
|
492
|
+
ensureOrganizationScope(ctx, balance.organizationId)
|
|
493
|
+
}
|
|
494
|
+
return balances
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async function listBalancesForVariant(
|
|
498
|
+
em: EntityManager,
|
|
499
|
+
ctx: CommandRuntimeContext,
|
|
500
|
+
catalogVariantId: string,
|
|
501
|
+
scope: Scope,
|
|
502
|
+
) {
|
|
503
|
+
const balances = await findWithDecryption(
|
|
504
|
+
em,
|
|
505
|
+
InventoryBalance,
|
|
506
|
+
{
|
|
507
|
+
catalogVariantId,
|
|
508
|
+
deletedAt: null,
|
|
509
|
+
},
|
|
510
|
+
undefined,
|
|
511
|
+
scope,
|
|
512
|
+
)
|
|
513
|
+
for (const balance of balances) {
|
|
514
|
+
ensureTenantScope(ctx, balance.tenantId)
|
|
515
|
+
ensureOrganizationScope(ctx, balance.organizationId)
|
|
516
|
+
}
|
|
517
|
+
return balances
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async function emitLowStockEventIfNeeded(
|
|
521
|
+
em: EntityManager,
|
|
522
|
+
ctx: CommandRuntimeContext,
|
|
523
|
+
scope: Scope,
|
|
524
|
+
catalogVariantId: string,
|
|
525
|
+
) {
|
|
526
|
+
const profile = await loadProfileForVariant(em, ctx, catalogVariantId, scope)
|
|
527
|
+
if (!profile) return
|
|
528
|
+
|
|
529
|
+
const balances = await listBalancesForVariant(em, ctx, catalogVariantId, scope)
|
|
530
|
+
const availableQuantity = balances.reduce((total, balance) => total + getAvailableQuantity(balance), 0)
|
|
531
|
+
const lowStock = evaluateLowStock(profile, availableQuantity)
|
|
532
|
+
if (!lowStock) return
|
|
533
|
+
|
|
534
|
+
await emitWmsEvent('wms.inventory.low_stock', {
|
|
535
|
+
id: catalogVariantId,
|
|
536
|
+
catalogVariantId,
|
|
537
|
+
availableQuantity: lowStock.availableQuantity,
|
|
538
|
+
reorderPoint: lowStock.reorderPoint,
|
|
539
|
+
safetyStock: lowStock.safetyStock,
|
|
540
|
+
state: lowStock.state,
|
|
541
|
+
tenantId: scope.tenantId,
|
|
542
|
+
organizationId: scope.organizationId,
|
|
543
|
+
})
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function loadReceivedAtByBucket(
|
|
547
|
+
em: EntityManager,
|
|
548
|
+
balances: InventoryBalance[],
|
|
549
|
+
scope: Scope,
|
|
550
|
+
): Promise<Map<string, Date>> {
|
|
551
|
+
if (balances.length === 0) return new Map()
|
|
552
|
+
const locationIds = Array.from(
|
|
553
|
+
new Set(
|
|
554
|
+
balances.map((balance) => (typeof balance.location === 'string' ? balance.location : balance.location.id)),
|
|
555
|
+
),
|
|
556
|
+
)
|
|
557
|
+
const warehouseId = typeof balances[0].warehouse === 'string' ? balances[0].warehouse : balances[0].warehouse.id
|
|
558
|
+
const catalogVariantId = balances[0].catalogVariantId
|
|
559
|
+
const movements = await findWithDecryption(
|
|
560
|
+
em,
|
|
561
|
+
InventoryMovement,
|
|
562
|
+
{
|
|
563
|
+
warehouse: warehouseId,
|
|
564
|
+
catalogVariantId,
|
|
565
|
+
deletedAt: null,
|
|
566
|
+
$or: [
|
|
567
|
+
{ locationFrom: { $in: locationIds } },
|
|
568
|
+
{ locationTo: { $in: locationIds } },
|
|
569
|
+
],
|
|
570
|
+
},
|
|
571
|
+
{
|
|
572
|
+
orderBy: { receivedAt: 'asc' },
|
|
573
|
+
},
|
|
574
|
+
scope,
|
|
575
|
+
)
|
|
576
|
+
const map = new Map<string, Date>()
|
|
577
|
+
for (const movement of movements) {
|
|
578
|
+
const locationIdRaw = movement.locationTo ?? movement.locationFrom ?? null
|
|
579
|
+
const locationId =
|
|
580
|
+
typeof locationIdRaw === 'string'
|
|
581
|
+
? locationIdRaw
|
|
582
|
+
: locationIdRaw?.id ?? null
|
|
583
|
+
if (!locationId) continue
|
|
584
|
+
const lotIdRaw = movement.lot ?? null
|
|
585
|
+
const lotId = typeof lotIdRaw === 'string' ? lotIdRaw : lotIdRaw?.id ?? null
|
|
586
|
+
const key = buildBucketKey({
|
|
587
|
+
warehouseId,
|
|
588
|
+
locationId,
|
|
589
|
+
catalogVariantId: movement.catalogVariantId,
|
|
590
|
+
lotId,
|
|
591
|
+
serialNumber: movement.serialNumber ?? null,
|
|
592
|
+
})
|
|
593
|
+
if (!map.has(key)) {
|
|
594
|
+
map.set(key, movement.receivedAt)
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return map
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
type BalanceSortRow = InventoryStrategyBucket & { balance: InventoryBalance }
|
|
601
|
+
|
|
602
|
+
function sortBalancesForStrategy(
|
|
603
|
+
balances: InventoryBalance[],
|
|
604
|
+
strategy: InventoryStrategy,
|
|
605
|
+
receiptMap: Map<string, Date>,
|
|
606
|
+
): InventoryBalance[] {
|
|
607
|
+
const rows: BalanceSortRow[] = balances.map((balance) => {
|
|
608
|
+
const warehouseId = typeof balance.warehouse === 'string' ? balance.warehouse : balance.warehouse.id
|
|
609
|
+
const locationId = typeof balance.location === 'string' ? balance.location : balance.location.id
|
|
610
|
+
const lotIdRaw = balance.lot ?? null
|
|
611
|
+
const lotId = typeof lotIdRaw === 'string' ? lotIdRaw : lotIdRaw?.id ?? null
|
|
612
|
+
const lot = typeof balance.lot === 'string' ? null : balance.lot
|
|
613
|
+
return {
|
|
614
|
+
balance,
|
|
615
|
+
warehouseId,
|
|
616
|
+
locationId,
|
|
617
|
+
catalogVariantId: balance.catalogVariantId,
|
|
618
|
+
createdAt: balance.createdAt,
|
|
619
|
+
lotId,
|
|
620
|
+
lotExpiresAt: lot?.expiresAt ?? null,
|
|
621
|
+
serialNumber: balance.serialNumber ?? null,
|
|
622
|
+
}
|
|
623
|
+
})
|
|
624
|
+
const sorted = sortBucketsForStrategy(rows, strategy, receiptMap)
|
|
625
|
+
return sorted.map((row) => row.balance)
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
async function resolveReservationStrategy(
|
|
629
|
+
em: EntityManager,
|
|
630
|
+
ctx: CommandRuntimeContext,
|
|
631
|
+
input: InventoryReservationCreateInput,
|
|
632
|
+
scope: Scope,
|
|
633
|
+
): Promise<InventoryStrategy> {
|
|
634
|
+
const profile = await loadProfileForVariant(em, ctx, input.catalogVariantId, scope)
|
|
635
|
+
return resolveReservationStrategyFromProfile(profile, input.strategy)
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function requireSufficientAvailability(remaining: number) {
|
|
639
|
+
if (remaining > 0.000001) {
|
|
640
|
+
throw new CrudHttpError(409, { error: 'insufficient_stock' })
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async function findExactBalanceForUpdate(
|
|
645
|
+
em: EntityManager,
|
|
646
|
+
scope: Scope,
|
|
647
|
+
input: {
|
|
648
|
+
warehouseId: string
|
|
649
|
+
locationId: string
|
|
650
|
+
catalogVariantId: string
|
|
651
|
+
lotId?: string
|
|
652
|
+
serialNumber?: string
|
|
653
|
+
},
|
|
654
|
+
) {
|
|
655
|
+
return findOneWithDecryption(
|
|
656
|
+
em,
|
|
657
|
+
InventoryBalance,
|
|
658
|
+
{
|
|
659
|
+
warehouse: input.warehouseId,
|
|
660
|
+
location: input.locationId,
|
|
661
|
+
catalogVariantId: input.catalogVariantId,
|
|
662
|
+
lot: input.lotId ?? null,
|
|
663
|
+
serialNumber: input.serialNumber ?? null,
|
|
664
|
+
deletedAt: null,
|
|
665
|
+
},
|
|
666
|
+
{ lockMode: LockMode.PESSIMISTIC_WRITE },
|
|
667
|
+
scope,
|
|
668
|
+
)
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
async function findReservationBucketBalance(
|
|
672
|
+
em: EntityManager,
|
|
673
|
+
scope: Scope,
|
|
674
|
+
reservation: InventoryReservation,
|
|
675
|
+
bucket: AllocationBucket,
|
|
676
|
+
) {
|
|
677
|
+
const warehouseId =
|
|
678
|
+
typeof reservation.warehouse === 'string'
|
|
679
|
+
? reservation.warehouse
|
|
680
|
+
: reservation.warehouse.id
|
|
681
|
+
|
|
682
|
+
if (typeof bucket.balanceId === 'string' && bucket.balanceId.length > 0) {
|
|
683
|
+
const balance = await findOneWithDecryption(
|
|
684
|
+
em,
|
|
685
|
+
InventoryBalance,
|
|
686
|
+
{ id: bucket.balanceId, deletedAt: null },
|
|
687
|
+
{ lockMode: LockMode.PESSIMISTIC_WRITE },
|
|
688
|
+
scope,
|
|
689
|
+
)
|
|
690
|
+
if (balance) return balance
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
return findExactBalanceForUpdate(em, scope, {
|
|
694
|
+
warehouseId,
|
|
695
|
+
locationId: bucket.locationId,
|
|
696
|
+
catalogVariantId: reservation.catalogVariantId,
|
|
697
|
+
lotId: bucket.lotId ?? undefined,
|
|
698
|
+
serialNumber: bucket.serialNumber ?? undefined,
|
|
699
|
+
})
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
async function upsertBalanceBucket(
|
|
703
|
+
em: EntityManager,
|
|
704
|
+
scope: Scope,
|
|
705
|
+
input: {
|
|
706
|
+
warehouseId: string
|
|
707
|
+
locationId: string
|
|
708
|
+
catalogVariantId: string
|
|
709
|
+
lotId?: string
|
|
710
|
+
serialNumber?: string
|
|
711
|
+
},
|
|
712
|
+
): Promise<{ balance: InventoryBalance; created: boolean }> {
|
|
713
|
+
const existing = await findExactBalanceForUpdate(em, scope, input)
|
|
714
|
+
if (existing) return { balance: existing, created: false }
|
|
715
|
+
const balance = em.create(InventoryBalance, {
|
|
716
|
+
organizationId: scope.organizationId,
|
|
717
|
+
tenantId: scope.tenantId,
|
|
718
|
+
warehouse: em.getReference(Warehouse, input.warehouseId),
|
|
719
|
+
location: em.getReference(WarehouseLocation, input.locationId),
|
|
720
|
+
catalogVariantId: input.catalogVariantId,
|
|
721
|
+
lot: input.lotId ? em.getReference(InventoryLot, input.lotId) : null,
|
|
722
|
+
serialNumber: input.serialNumber ?? null,
|
|
723
|
+
quantityOnHand: '0',
|
|
724
|
+
quantityReserved: '0',
|
|
725
|
+
quantityAllocated: '0',
|
|
726
|
+
metadata: null,
|
|
727
|
+
})
|
|
728
|
+
em.persist(balance)
|
|
729
|
+
await em.flush()
|
|
730
|
+
return { balance, created: true }
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
async function resolveReceivedAtForBalance(
|
|
734
|
+
em: EntityManager,
|
|
735
|
+
balance: InventoryBalance | null,
|
|
736
|
+
scope: Scope,
|
|
737
|
+
fallback: Date,
|
|
738
|
+
): Promise<Date> {
|
|
739
|
+
if (!balance) return fallback
|
|
740
|
+
const warehouseId = typeof balance.warehouse === 'string' ? balance.warehouse : balance.warehouse.id
|
|
741
|
+
const locationId = typeof balance.location === 'string' ? balance.location : balance.location.id
|
|
742
|
+
const lotIdRaw = balance.lot ?? null
|
|
743
|
+
const lotId = typeof lotIdRaw === 'string' ? lotIdRaw : lotIdRaw?.id ?? null
|
|
744
|
+
const movement = await findOneWithDecryption(
|
|
745
|
+
em,
|
|
746
|
+
InventoryMovement,
|
|
747
|
+
{
|
|
748
|
+
warehouse: warehouseId,
|
|
749
|
+
catalogVariantId: balance.catalogVariantId,
|
|
750
|
+
lot: lotId,
|
|
751
|
+
serialNumber: balance.serialNumber ?? null,
|
|
752
|
+
deletedAt: null,
|
|
753
|
+
$or: [
|
|
754
|
+
{ locationFrom: locationId },
|
|
755
|
+
{ locationTo: locationId },
|
|
756
|
+
],
|
|
757
|
+
},
|
|
758
|
+
{ orderBy: { receivedAt: 'asc' } },
|
|
759
|
+
scope,
|
|
760
|
+
)
|
|
761
|
+
return movement?.receivedAt ?? fallback
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
async function createMovement(
|
|
765
|
+
em: EntityManager,
|
|
766
|
+
scope: Scope,
|
|
767
|
+
input: {
|
|
768
|
+
warehouseId: string
|
|
769
|
+
locationFromId?: string | null
|
|
770
|
+
locationToId?: string | null
|
|
771
|
+
catalogVariantId: string
|
|
772
|
+
lotId?: string | null
|
|
773
|
+
serialNumber?: string | null
|
|
774
|
+
quantity: number
|
|
775
|
+
type: InventoryMovement['type']
|
|
776
|
+
referenceType: InventoryMovement['referenceType']
|
|
777
|
+
referenceId: string
|
|
778
|
+
performedBy: string
|
|
779
|
+
performedAt: Date
|
|
780
|
+
receivedAt: Date
|
|
781
|
+
reason?: string | null
|
|
782
|
+
reasonCode?: string | null
|
|
783
|
+
metadata?: Record<string, unknown> | null
|
|
784
|
+
idempotencyKey: string
|
|
785
|
+
},
|
|
786
|
+
) {
|
|
787
|
+
const movement = em.create(InventoryMovement, {
|
|
788
|
+
organizationId: scope.organizationId,
|
|
789
|
+
tenantId: scope.tenantId,
|
|
790
|
+
warehouse: em.getReference(Warehouse, input.warehouseId),
|
|
791
|
+
locationFrom: input.locationFromId ? em.getReference(WarehouseLocation, input.locationFromId) : null,
|
|
792
|
+
locationTo: input.locationToId ? em.getReference(WarehouseLocation, input.locationToId) : null,
|
|
793
|
+
catalogVariantId: input.catalogVariantId,
|
|
794
|
+
lot: input.lotId ? em.getReference(InventoryLot, input.lotId) : null,
|
|
795
|
+
serialNumber: input.serialNumber ?? null,
|
|
796
|
+
quantity: toNumericString(input.quantity),
|
|
797
|
+
type: input.type,
|
|
798
|
+
referenceType: input.referenceType,
|
|
799
|
+
referenceId: input.referenceId,
|
|
800
|
+
performedBy: input.performedBy,
|
|
801
|
+
performedAt: input.performedAt,
|
|
802
|
+
receivedAt: input.receivedAt,
|
|
803
|
+
reason: input.reason ?? null,
|
|
804
|
+
reasonCode: input.reasonCode ?? null,
|
|
805
|
+
metadata: input.metadata ?? null,
|
|
806
|
+
idempotencyKey: input.idempotencyKey,
|
|
807
|
+
})
|
|
808
|
+
em.persist(movement)
|
|
809
|
+
return movement
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
type MovementMutationInput = {
|
|
813
|
+
warehouseId: string
|
|
814
|
+
locationFromId?: string | null
|
|
815
|
+
locationToId?: string | null
|
|
816
|
+
catalogVariantId: string
|
|
817
|
+
lotId?: string | null
|
|
818
|
+
serialNumber?: string | null
|
|
819
|
+
quantity: number
|
|
820
|
+
type: InventoryMovement['type']
|
|
821
|
+
referenceType: InventoryMovement['referenceType']
|
|
822
|
+
referenceId: string
|
|
823
|
+
performedBy: string
|
|
824
|
+
performedAt: Date
|
|
825
|
+
receivedAt: Date
|
|
826
|
+
reason?: string | null
|
|
827
|
+
reasonCode?: string | null
|
|
828
|
+
metadata?: Record<string, unknown> | null
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
async function persistMovementWithIdempotency(
|
|
832
|
+
em: EntityManager,
|
|
833
|
+
scope: Scope,
|
|
834
|
+
input: MovementMutationInput,
|
|
835
|
+
): Promise<{ movement: InventoryMovement; idempotentReplay: boolean }> {
|
|
836
|
+
const idempotencyKey = buildMovementIdempotencyKey({
|
|
837
|
+
referenceType: input.referenceType,
|
|
838
|
+
referenceId: input.referenceId,
|
|
839
|
+
type: input.type,
|
|
840
|
+
warehouseId: input.warehouseId,
|
|
841
|
+
locationFromId: input.locationFromId,
|
|
842
|
+
locationToId: input.locationToId,
|
|
843
|
+
catalogVariantId: input.catalogVariantId,
|
|
844
|
+
lotId: input.lotId,
|
|
845
|
+
serialNumber: input.serialNumber,
|
|
846
|
+
quantity: input.quantity,
|
|
847
|
+
})
|
|
848
|
+
const existing = await findExistingMovementByIdempotencyKey(em, scope, idempotencyKey)
|
|
849
|
+
if (existing) {
|
|
850
|
+
return { movement: existing, idempotentReplay: true }
|
|
851
|
+
}
|
|
852
|
+
try {
|
|
853
|
+
const movement = await createMovement(em, scope, {
|
|
854
|
+
...input,
|
|
855
|
+
idempotencyKey,
|
|
856
|
+
})
|
|
857
|
+
return { movement, idempotentReplay: false }
|
|
858
|
+
} catch (error) {
|
|
859
|
+
if (!isUniqueConstraintError(error)) throw error
|
|
860
|
+
const raced = await findExistingMovementByIdempotencyKey(em, scope, idempotencyKey)
|
|
861
|
+
if (!raced) throw error
|
|
862
|
+
return { movement: raced, idempotentReplay: true }
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
async function emitBalanceDriftIfNeeded(
|
|
867
|
+
balance: InventoryBalance,
|
|
868
|
+
field: 'quantityReserved' | 'quantityAllocated',
|
|
869
|
+
attemptedValue: number,
|
|
870
|
+
scope: Scope,
|
|
871
|
+
reservationId: string,
|
|
872
|
+
) {
|
|
873
|
+
if (attemptedValue >= -0.000001) return
|
|
874
|
+
await emitWmsEvent('wms.inventory.balance_drift', {
|
|
875
|
+
id: balance.id,
|
|
876
|
+
balanceId: balance.id,
|
|
877
|
+
reservationId,
|
|
878
|
+
field,
|
|
879
|
+
attemptedValue: toNumericString(attemptedValue),
|
|
880
|
+
clampedValue: '0',
|
|
881
|
+
tenantId: scope.tenantId,
|
|
882
|
+
organizationId: scope.organizationId,
|
|
883
|
+
}).catch(() => undefined)
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function enforceNonNegativeBalance(
|
|
887
|
+
balance: InventoryBalance,
|
|
888
|
+
field: 'quantityReserved' | 'quantityAllocated',
|
|
889
|
+
nextValue: number,
|
|
890
|
+
reservationId: string,
|
|
891
|
+
scope: Scope,
|
|
892
|
+
) {
|
|
893
|
+
if (nextValue < -0.000001) {
|
|
894
|
+
void emitBalanceDriftIfNeeded(balance, field, nextValue, scope, reservationId)
|
|
895
|
+
throw new CrudHttpError(409, { error: 'balance_integrity_violation' })
|
|
896
|
+
}
|
|
897
|
+
setNumeric(balance as unknown as Record<string, unknown>, field, nextValue)
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const reserveInventoryCommand: CommandHandler<InventoryReservationCreateInput, ReservationCommandResult> = {
|
|
901
|
+
id: 'wms.inventory.reserve',
|
|
902
|
+
// See "WMS Inventory Mutation Commands — Undo Policy" at top of file.
|
|
903
|
+
isUndoable: false,
|
|
904
|
+
async execute(rawInput, ctx) {
|
|
905
|
+
const input = inventoryReservationCreateSchema.parse(rawInput ?? {})
|
|
906
|
+
ensureTenantScope(ctx, input.tenantId)
|
|
907
|
+
ensureOrganizationScope(ctx, input.organizationId)
|
|
908
|
+
const em = resolveEm(ctx)
|
|
909
|
+
const result = await runInTransaction(em, async (trx) => {
|
|
910
|
+
const scope = resolveScope(ctx, input)
|
|
911
|
+
await requireWarehouse(trx, ctx, input.warehouseId, scope)
|
|
912
|
+
const reservationIdempotencyKey = buildReservationIdempotencyKey({
|
|
913
|
+
sourceType: input.sourceType,
|
|
914
|
+
sourceId: input.sourceId,
|
|
915
|
+
catalogVariantId: input.catalogVariantId,
|
|
916
|
+
warehouseId: input.warehouseId,
|
|
917
|
+
quantity: input.quantity,
|
|
918
|
+
})
|
|
919
|
+
const existingReservation = await findExistingActiveReservationByIdempotencyKey(
|
|
920
|
+
trx,
|
|
921
|
+
scope,
|
|
922
|
+
reservationIdempotencyKey,
|
|
923
|
+
)
|
|
924
|
+
if (existingReservation) {
|
|
925
|
+
const metadata = extractReservationMetadata(existingReservation)
|
|
926
|
+
const buckets = Array.isArray(metadata.allocatedBuckets)
|
|
927
|
+
? (metadata.allocatedBuckets as AllocationBucket[])
|
|
928
|
+
: []
|
|
929
|
+
return {
|
|
930
|
+
reservationId: existingReservation.id,
|
|
931
|
+
allocatedBuckets: serializeAllocationBuckets(buckets),
|
|
932
|
+
tenantId: scope.tenantId,
|
|
933
|
+
organizationId: scope.organizationId,
|
|
934
|
+
warehouseId: input.warehouseId,
|
|
935
|
+
catalogVariantId: input.catalogVariantId,
|
|
936
|
+
quantity: input.quantity,
|
|
937
|
+
reservationEntity: existingReservation,
|
|
938
|
+
touchedBalances: [] as InventoryBalance[],
|
|
939
|
+
idempotentReplay: true,
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
const strategy = await resolveReservationStrategy(trx, ctx, input, scope)
|
|
943
|
+
const balances = await listCandidateBalances(trx, ctx, input, scope)
|
|
944
|
+
const receiptMap = await loadReceivedAtByBucket(trx, balances, scope)
|
|
945
|
+
const now = new Date()
|
|
946
|
+
const ordered = sortBalancesForStrategy(
|
|
947
|
+
balances.filter(
|
|
948
|
+
(balance) => balanceHasEligibleLot(balance, now) && getAvailableQuantity(balance) > 0,
|
|
949
|
+
),
|
|
950
|
+
strategy,
|
|
951
|
+
receiptMap,
|
|
952
|
+
)
|
|
953
|
+
let remaining = input.quantity
|
|
954
|
+
const buckets: AllocationBucket[] = []
|
|
955
|
+
const touchedBalances: InventoryBalance[] = []
|
|
956
|
+
for (const balance of ordered) {
|
|
957
|
+
if (remaining <= 0) break
|
|
958
|
+
const available = getAvailableQuantity(balance)
|
|
959
|
+
if (available <= 0) continue
|
|
960
|
+
const quantity = Math.min(available, remaining)
|
|
961
|
+
const locationId = typeof balance.location === 'string' ? balance.location : balance.location.id
|
|
962
|
+
const lotIdRaw = balance.lot ?? null
|
|
963
|
+
const lotId = typeof lotIdRaw === 'string' ? lotIdRaw : lotIdRaw?.id ?? null
|
|
964
|
+
const persistedBalance = await findExactBalanceForUpdate(trx, scope, {
|
|
965
|
+
warehouseId: input.warehouseId,
|
|
966
|
+
locationId,
|
|
967
|
+
catalogVariantId: input.catalogVariantId,
|
|
968
|
+
lotId: lotId ?? undefined,
|
|
969
|
+
serialNumber: balance.serialNumber ?? undefined,
|
|
970
|
+
})
|
|
971
|
+
if (!persistedBalance) {
|
|
972
|
+
throw new CrudHttpError(409, { error: 'invalid_tracking_state' })
|
|
973
|
+
}
|
|
974
|
+
setNumeric(
|
|
975
|
+
persistedBalance as unknown as Record<string, unknown>,
|
|
976
|
+
'quantityReserved',
|
|
977
|
+
toNumber(persistedBalance.quantityReserved) + quantity,
|
|
978
|
+
)
|
|
979
|
+
touchedBalances.push(persistedBalance)
|
|
980
|
+
buckets.push({
|
|
981
|
+
balanceId: persistedBalance.id,
|
|
982
|
+
locationId,
|
|
983
|
+
lotId,
|
|
984
|
+
serialNumber: balance.serialNumber ?? null,
|
|
985
|
+
quantity,
|
|
986
|
+
})
|
|
987
|
+
remaining -= quantity
|
|
988
|
+
}
|
|
989
|
+
requireSufficientAvailability(remaining)
|
|
990
|
+
let reservation: InventoryReservation
|
|
991
|
+
try {
|
|
992
|
+
reservation = trx.create(InventoryReservation, {
|
|
993
|
+
organizationId: scope.organizationId,
|
|
994
|
+
tenantId: scope.tenantId,
|
|
995
|
+
warehouse: trx.getReference(Warehouse, input.warehouseId),
|
|
996
|
+
catalogVariantId: input.catalogVariantId,
|
|
997
|
+
lot: buckets.length === 1 && buckets[0].lotId ? trx.getReference(InventoryLot, buckets[0].lotId) : null,
|
|
998
|
+
serialNumber: buckets.length === 1 ? buckets[0].serialNumber : null,
|
|
999
|
+
quantity: toNumericString(input.quantity),
|
|
1000
|
+
sourceType: input.sourceType,
|
|
1001
|
+
sourceId: input.sourceId,
|
|
1002
|
+
expiresAt: input.expiresAt ?? null,
|
|
1003
|
+
status: 'active',
|
|
1004
|
+
idempotencyKey: reservationIdempotencyKey,
|
|
1005
|
+
metadata: {
|
|
1006
|
+
...(input.metadata ?? {}),
|
|
1007
|
+
allocatedBuckets: buckets,
|
|
1008
|
+
allocationState: 'reserved',
|
|
1009
|
+
strategy,
|
|
1010
|
+
},
|
|
1011
|
+
})
|
|
1012
|
+
trx.persist(reservation)
|
|
1013
|
+
await trx.flush()
|
|
1014
|
+
} catch (error) {
|
|
1015
|
+
if (!isUniqueConstraintError(error)) throw error
|
|
1016
|
+
const raced = await findExistingActiveReservationByIdempotencyKey(
|
|
1017
|
+
trx,
|
|
1018
|
+
scope,
|
|
1019
|
+
reservationIdempotencyKey,
|
|
1020
|
+
)
|
|
1021
|
+
if (!raced) throw error
|
|
1022
|
+
const metadata = extractReservationMetadata(raced)
|
|
1023
|
+
const racedBuckets = Array.isArray(metadata.allocatedBuckets)
|
|
1024
|
+
? (metadata.allocatedBuckets as AllocationBucket[])
|
|
1025
|
+
: []
|
|
1026
|
+
return {
|
|
1027
|
+
reservationId: raced.id,
|
|
1028
|
+
allocatedBuckets: serializeAllocationBuckets(racedBuckets),
|
|
1029
|
+
tenantId: scope.tenantId,
|
|
1030
|
+
organizationId: scope.organizationId,
|
|
1031
|
+
warehouseId: input.warehouseId,
|
|
1032
|
+
catalogVariantId: input.catalogVariantId,
|
|
1033
|
+
quantity: input.quantity,
|
|
1034
|
+
reservationEntity: raced,
|
|
1035
|
+
touchedBalances: [] as InventoryBalance[],
|
|
1036
|
+
idempotentReplay: true,
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
return {
|
|
1040
|
+
reservationId: reservation.id,
|
|
1041
|
+
allocatedBuckets: serializeAllocationBuckets(buckets),
|
|
1042
|
+
tenantId: scope.tenantId,
|
|
1043
|
+
organizationId: scope.organizationId,
|
|
1044
|
+
warehouseId: input.warehouseId,
|
|
1045
|
+
catalogVariantId: input.catalogVariantId,
|
|
1046
|
+
quantity: input.quantity,
|
|
1047
|
+
reservationEntity: reservation,
|
|
1048
|
+
touchedBalances,
|
|
1049
|
+
idempotentReplay: false,
|
|
1050
|
+
}
|
|
1051
|
+
})
|
|
1052
|
+
if (!result.idempotentReplay) {
|
|
1053
|
+
await emitInventorySideEffects(ctx, {
|
|
1054
|
+
reservations: [{ entity: result.reservationEntity, action: 'created' }],
|
|
1055
|
+
balances: result.touchedBalances.map((entity) => ({ entity, action: 'updated' as const })),
|
|
1056
|
+
})
|
|
1057
|
+
void emitWmsEvent('wms.inventory.reserved', {
|
|
1058
|
+
id: result.reservationId,
|
|
1059
|
+
reservationId: result.reservationId,
|
|
1060
|
+
warehouseId: result.warehouseId,
|
|
1061
|
+
catalogVariantId: result.catalogVariantId,
|
|
1062
|
+
quantity: toNumericString(result.quantity),
|
|
1063
|
+
tenantId: result.tenantId,
|
|
1064
|
+
organizationId: result.organizationId,
|
|
1065
|
+
}).catch(() => undefined)
|
|
1066
|
+
void emitLowStockEventIfNeeded(
|
|
1067
|
+
resolveEm(ctx),
|
|
1068
|
+
ctx,
|
|
1069
|
+
{ tenantId: result.tenantId, organizationId: result.organizationId },
|
|
1070
|
+
result.catalogVariantId,
|
|
1071
|
+
).catch(() => undefined)
|
|
1072
|
+
}
|
|
1073
|
+
return {
|
|
1074
|
+
reservationId: result.reservationId,
|
|
1075
|
+
allocatedBuckets: result.allocatedBuckets,
|
|
1076
|
+
}
|
|
1077
|
+
},
|
|
1078
|
+
buildLog: async ({ input, result, ctx }) =>
|
|
1079
|
+
buildMutationLog({
|
|
1080
|
+
actionKey: 'wms.audit.inventory.reserve',
|
|
1081
|
+
fallbackLabel: 'Reserve inventory',
|
|
1082
|
+
resourceKind: WMS_INVENTORY_RESERVATION_RESOURCE,
|
|
1083
|
+
resourceId: result?.reservationId ?? null,
|
|
1084
|
+
parentResourceId:
|
|
1085
|
+
input?.warehouseId && input?.catalogVariantId
|
|
1086
|
+
? `${input.warehouseId}:${input.catalogVariantId}`
|
|
1087
|
+
: null,
|
|
1088
|
+
tenantId: input?.tenantId ?? ctx.auth?.tenantId ?? null,
|
|
1089
|
+
organizationId: input?.organizationId ?? ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
|
|
1090
|
+
cacheAliases: [WMS_INVENTORY_BALANCE_RESOURCE],
|
|
1091
|
+
}),
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
const releaseInventoryReservationCommand: CommandHandler<InventoryReservationReleaseInput, { reservationId: string }> = {
|
|
1095
|
+
id: 'wms.inventory.release',
|
|
1096
|
+
// See "WMS Inventory Mutation Commands — Undo Policy" at top of file.
|
|
1097
|
+
isUndoable: false,
|
|
1098
|
+
async execute(rawInput, ctx) {
|
|
1099
|
+
const input = inventoryReservationReleaseSchema.parse(rawInput ?? {})
|
|
1100
|
+
ensureTenantScope(ctx, input.tenantId)
|
|
1101
|
+
ensureOrganizationScope(ctx, input.organizationId)
|
|
1102
|
+
const em = resolveEm(ctx)
|
|
1103
|
+
const result = await runInTransaction(em, async (trx) => {
|
|
1104
|
+
const scope = resolveScope(ctx, input)
|
|
1105
|
+
const reservation = await requireReservation(trx, ctx, input.reservationId, scope, true)
|
|
1106
|
+
const metadata = extractReservationMetadata(reservation)
|
|
1107
|
+
const buckets = Array.isArray(metadata.allocatedBuckets) ? (metadata.allocatedBuckets as AllocationBucket[]) : []
|
|
1108
|
+
const allocationState = metadata.allocationState ?? 'reserved'
|
|
1109
|
+
const touchedBalances: InventoryBalance[] = []
|
|
1110
|
+
for (const bucket of buckets) {
|
|
1111
|
+
const balance = await findReservationBucketBalance(
|
|
1112
|
+
trx,
|
|
1113
|
+
scope,
|
|
1114
|
+
reservation,
|
|
1115
|
+
bucket,
|
|
1116
|
+
)
|
|
1117
|
+
if (!balance) continue
|
|
1118
|
+
if (allocationState === 'allocated') {
|
|
1119
|
+
enforceNonNegativeBalance(
|
|
1120
|
+
balance,
|
|
1121
|
+
'quantityAllocated',
|
|
1122
|
+
toNumber(balance.quantityAllocated) - bucket.quantity,
|
|
1123
|
+
reservation.id,
|
|
1124
|
+
scope,
|
|
1125
|
+
)
|
|
1126
|
+
} else {
|
|
1127
|
+
enforceNonNegativeBalance(
|
|
1128
|
+
balance,
|
|
1129
|
+
'quantityReserved',
|
|
1130
|
+
toNumber(balance.quantityReserved) - bucket.quantity,
|
|
1131
|
+
reservation.id,
|
|
1132
|
+
scope,
|
|
1133
|
+
)
|
|
1134
|
+
}
|
|
1135
|
+
touchedBalances.push(balance)
|
|
1136
|
+
}
|
|
1137
|
+
reservation.status = 'released'
|
|
1138
|
+
reservation.metadata = {
|
|
1139
|
+
...metadata,
|
|
1140
|
+
releasedReason: input.reason,
|
|
1141
|
+
releasedAt: new Date().toISOString(),
|
|
1142
|
+
}
|
|
1143
|
+
await trx.flush()
|
|
1144
|
+
return {
|
|
1145
|
+
reservationId: reservation.id,
|
|
1146
|
+
warehouseId: typeof reservation.warehouse === 'string' ? reservation.warehouse : reservation.warehouse.id,
|
|
1147
|
+
catalogVariantId: reservation.catalogVariantId,
|
|
1148
|
+
quantity: reservation.quantity,
|
|
1149
|
+
tenantId: reservation.tenantId,
|
|
1150
|
+
organizationId: reservation.organizationId,
|
|
1151
|
+
reservationEntity: reservation,
|
|
1152
|
+
touchedBalances,
|
|
1153
|
+
}
|
|
1154
|
+
})
|
|
1155
|
+
await emitInventorySideEffects(ctx, {
|
|
1156
|
+
reservations: [{ entity: result.reservationEntity, action: 'updated' }],
|
|
1157
|
+
balances: result.touchedBalances.map((entity) => ({ entity, action: 'updated' as const })),
|
|
1158
|
+
})
|
|
1159
|
+
void emitWmsEvent('wms.inventory.released', {
|
|
1160
|
+
id: result.reservationId,
|
|
1161
|
+
reservationId: result.reservationId,
|
|
1162
|
+
warehouseId: result.warehouseId,
|
|
1163
|
+
catalogVariantId: result.catalogVariantId,
|
|
1164
|
+
quantity: result.quantity,
|
|
1165
|
+
tenantId: result.tenantId,
|
|
1166
|
+
organizationId: result.organizationId,
|
|
1167
|
+
}).catch(() => undefined)
|
|
1168
|
+
void emitLowStockEventIfNeeded(
|
|
1169
|
+
resolveEm(ctx),
|
|
1170
|
+
ctx,
|
|
1171
|
+
{ tenantId: result.tenantId, organizationId: result.organizationId },
|
|
1172
|
+
result.catalogVariantId,
|
|
1173
|
+
).catch(() => undefined)
|
|
1174
|
+
return { reservationId: result.reservationId }
|
|
1175
|
+
},
|
|
1176
|
+
buildLog: async ({ input, result, ctx }) =>
|
|
1177
|
+
buildMutationLog({
|
|
1178
|
+
actionKey: 'wms.audit.inventory.release',
|
|
1179
|
+
fallbackLabel: 'Release inventory reservation',
|
|
1180
|
+
resourceKind: WMS_INVENTORY_RESERVATION_RESOURCE,
|
|
1181
|
+
resourceId: result?.reservationId ?? input?.reservationId ?? null,
|
|
1182
|
+
tenantId: input?.tenantId ?? ctx.auth?.tenantId ?? null,
|
|
1183
|
+
organizationId: input?.organizationId ?? ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
|
|
1184
|
+
cacheAliases: [WMS_INVENTORY_BALANCE_RESOURCE],
|
|
1185
|
+
}),
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
const allocateInventoryReservationCommand: CommandHandler<InventoryReservationAllocateInput, { reservationId: string; allocationState: 'allocated' }> = {
|
|
1189
|
+
id: 'wms.inventory.allocate',
|
|
1190
|
+
// See "WMS Inventory Mutation Commands — Undo Policy" at top of file.
|
|
1191
|
+
isUndoable: false,
|
|
1192
|
+
async execute(rawInput, ctx) {
|
|
1193
|
+
const input = inventoryReservationAllocateSchema.parse(rawInput ?? {})
|
|
1194
|
+
ensureTenantScope(ctx, input.tenantId)
|
|
1195
|
+
ensureOrganizationScope(ctx, input.organizationId)
|
|
1196
|
+
const em = resolveEm(ctx)
|
|
1197
|
+
const result = await runInTransaction(em, async (trx) => {
|
|
1198
|
+
const scope = resolveScope(ctx, input)
|
|
1199
|
+
const reservation = await requireReservation(trx, ctx, input.reservationId, scope, true)
|
|
1200
|
+
const metadata = extractReservationMetadata(reservation)
|
|
1201
|
+
if (metadata.allocationState === 'allocated') {
|
|
1202
|
+
return {
|
|
1203
|
+
reservationId: reservation.id,
|
|
1204
|
+
allocationState: 'allocated' as const,
|
|
1205
|
+
warehouseId: typeof reservation.warehouse === 'string' ? reservation.warehouse : reservation.warehouse.id,
|
|
1206
|
+
catalogVariantId: reservation.catalogVariantId,
|
|
1207
|
+
quantity: reservation.quantity,
|
|
1208
|
+
tenantId: reservation.tenantId,
|
|
1209
|
+
organizationId: reservation.organizationId,
|
|
1210
|
+
reservationEntity: reservation,
|
|
1211
|
+
touchedBalances: [] as InventoryBalance[],
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
const buckets = Array.isArray(metadata.allocatedBuckets) ? (metadata.allocatedBuckets as AllocationBucket[]) : []
|
|
1215
|
+
const touchedBalances: InventoryBalance[] = []
|
|
1216
|
+
for (const bucket of buckets) {
|
|
1217
|
+
const balance = await findReservationBucketBalance(
|
|
1218
|
+
trx,
|
|
1219
|
+
scope,
|
|
1220
|
+
reservation,
|
|
1221
|
+
bucket,
|
|
1222
|
+
)
|
|
1223
|
+
if (!balance) throw new CrudHttpError(409, { error: 'invalid_tracking_state' })
|
|
1224
|
+
if (toNumber(balance.quantityReserved) < bucket.quantity - 0.000001) {
|
|
1225
|
+
throw new CrudHttpError(409, { error: 'invalid_tracking_state' })
|
|
1226
|
+
}
|
|
1227
|
+
// Guard above ensures the subtraction is non-negative within numeric(16,4) precision.
|
|
1228
|
+
setNumeric(
|
|
1229
|
+
balance as unknown as Record<string, unknown>,
|
|
1230
|
+
'quantityReserved',
|
|
1231
|
+
toNumber(balance.quantityReserved) - bucket.quantity,
|
|
1232
|
+
)
|
|
1233
|
+
setNumeric(
|
|
1234
|
+
balance as unknown as Record<string, unknown>,
|
|
1235
|
+
'quantityAllocated',
|
|
1236
|
+
toNumber(balance.quantityAllocated) + bucket.quantity,
|
|
1237
|
+
)
|
|
1238
|
+
touchedBalances.push(balance)
|
|
1239
|
+
}
|
|
1240
|
+
reservation.metadata = {
|
|
1241
|
+
...metadata,
|
|
1242
|
+
allocationState: 'allocated',
|
|
1243
|
+
allocatedAt: new Date().toISOString(),
|
|
1244
|
+
}
|
|
1245
|
+
await trx.flush()
|
|
1246
|
+
return {
|
|
1247
|
+
reservationId: reservation.id,
|
|
1248
|
+
allocationState: 'allocated' as const,
|
|
1249
|
+
warehouseId: typeof reservation.warehouse === 'string' ? reservation.warehouse : reservation.warehouse.id,
|
|
1250
|
+
catalogVariantId: reservation.catalogVariantId,
|
|
1251
|
+
quantity: reservation.quantity,
|
|
1252
|
+
tenantId: reservation.tenantId,
|
|
1253
|
+
organizationId: reservation.organizationId,
|
|
1254
|
+
reservationEntity: reservation,
|
|
1255
|
+
touchedBalances,
|
|
1256
|
+
}
|
|
1257
|
+
})
|
|
1258
|
+
await emitInventorySideEffects(ctx, {
|
|
1259
|
+
reservations: [{ entity: result.reservationEntity, action: 'updated' }],
|
|
1260
|
+
balances: result.touchedBalances.map((entity) => ({ entity, action: 'updated' as const })),
|
|
1261
|
+
})
|
|
1262
|
+
void emitWmsEvent('wms.inventory.allocated', {
|
|
1263
|
+
id: result.reservationId,
|
|
1264
|
+
reservationId: result.reservationId,
|
|
1265
|
+
warehouseId: result.warehouseId,
|
|
1266
|
+
catalogVariantId: result.catalogVariantId,
|
|
1267
|
+
quantity: result.quantity,
|
|
1268
|
+
tenantId: result.tenantId,
|
|
1269
|
+
organizationId: result.organizationId,
|
|
1270
|
+
}).catch(() => undefined)
|
|
1271
|
+
return { reservationId: result.reservationId, allocationState: result.allocationState }
|
|
1272
|
+
},
|
|
1273
|
+
buildLog: async ({ input, result, ctx }) =>
|
|
1274
|
+
buildMutationLog({
|
|
1275
|
+
actionKey: 'wms.audit.inventory.allocate',
|
|
1276
|
+
fallbackLabel: 'Allocate inventory reservation',
|
|
1277
|
+
resourceKind: WMS_INVENTORY_RESERVATION_RESOURCE,
|
|
1278
|
+
resourceId: result?.reservationId ?? input?.reservationId ?? null,
|
|
1279
|
+
tenantId: input?.tenantId ?? ctx.auth?.tenantId ?? null,
|
|
1280
|
+
organizationId: input?.organizationId ?? ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
|
|
1281
|
+
cacheAliases: [WMS_INVENTORY_BALANCE_RESOURCE],
|
|
1282
|
+
}),
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
const adjustInventoryCommand: CommandHandler<InventoryAdjustInput, { movementId: string }> = {
|
|
1286
|
+
id: 'wms.inventory.adjust',
|
|
1287
|
+
// See "WMS Inventory Mutation Commands — Undo Policy" at top of file.
|
|
1288
|
+
isUndoable: false,
|
|
1289
|
+
async execute(rawInput, ctx) {
|
|
1290
|
+
const input = inventoryAdjustSchema.parse(rawInput ?? {})
|
|
1291
|
+
ensureTenantScope(ctx, input.tenantId)
|
|
1292
|
+
ensureOrganizationScope(ctx, input.organizationId)
|
|
1293
|
+
const em = resolveEm(ctx)
|
|
1294
|
+
const result = await runInTransaction(em, async (trx) => {
|
|
1295
|
+
const scope = resolveScope(ctx, input)
|
|
1296
|
+
await requireWarehouse(trx, ctx, input.warehouseId, scope)
|
|
1297
|
+
const location = await requireLocation(trx, ctx, input.locationId, scope)
|
|
1298
|
+
const locationWarehouseId = typeof location.warehouse === 'string' ? location.warehouse : location.warehouse.id
|
|
1299
|
+
if (locationWarehouseId !== input.warehouseId) {
|
|
1300
|
+
throw new CrudHttpError(422, { error: 'invalid_location' })
|
|
1301
|
+
}
|
|
1302
|
+
const { balance, created: balanceWasNew } = await upsertBalanceBucket(trx, scope, {
|
|
1303
|
+
warehouseId: input.warehouseId,
|
|
1304
|
+
locationId: input.locationId,
|
|
1305
|
+
catalogVariantId: input.catalogVariantId,
|
|
1306
|
+
lotId: input.lotId,
|
|
1307
|
+
serialNumber: input.serialNumber,
|
|
1308
|
+
})
|
|
1309
|
+
const delta = input.delta
|
|
1310
|
+
const performedAt = input.performedAt ?? new Date()
|
|
1311
|
+
const movementInput: MovementMutationInput = {
|
|
1312
|
+
warehouseId: input.warehouseId,
|
|
1313
|
+
locationToId: input.locationId,
|
|
1314
|
+
catalogVariantId: input.catalogVariantId,
|
|
1315
|
+
lotId: input.lotId ?? null,
|
|
1316
|
+
serialNumber: input.serialNumber ?? null,
|
|
1317
|
+
quantity: delta,
|
|
1318
|
+
type: 'adjust',
|
|
1319
|
+
referenceType: input.referenceType,
|
|
1320
|
+
referenceId: input.referenceId,
|
|
1321
|
+
performedBy: input.performedBy,
|
|
1322
|
+
performedAt,
|
|
1323
|
+
receivedAt: performedAt,
|
|
1324
|
+
reason: input.reason,
|
|
1325
|
+
reasonCode: input.reasonCode ?? null,
|
|
1326
|
+
metadata: input.metadata ?? null,
|
|
1327
|
+
}
|
|
1328
|
+
const idempotencyKey = buildMovementIdempotencyKey({
|
|
1329
|
+
referenceType: movementInput.referenceType,
|
|
1330
|
+
referenceId: movementInput.referenceId,
|
|
1331
|
+
type: movementInput.type,
|
|
1332
|
+
warehouseId: movementInput.warehouseId,
|
|
1333
|
+
locationFromId: movementInput.locationFromId,
|
|
1334
|
+
locationToId: movementInput.locationToId,
|
|
1335
|
+
catalogVariantId: movementInput.catalogVariantId,
|
|
1336
|
+
lotId: movementInput.lotId,
|
|
1337
|
+
serialNumber: movementInput.serialNumber,
|
|
1338
|
+
quantity: movementInput.quantity,
|
|
1339
|
+
})
|
|
1340
|
+
const existingMovement = await findExistingMovementByIdempotencyKey(trx, scope, idempotencyKey)
|
|
1341
|
+
if (existingMovement) {
|
|
1342
|
+
return {
|
|
1343
|
+
movementId: existingMovement.id,
|
|
1344
|
+
warehouseId: input.warehouseId,
|
|
1345
|
+
catalogVariantId: input.catalogVariantId,
|
|
1346
|
+
quantity: delta,
|
|
1347
|
+
tenantId: scope.tenantId,
|
|
1348
|
+
organizationId: scope.organizationId,
|
|
1349
|
+
movementEntity: existingMovement,
|
|
1350
|
+
balanceEntity: balance,
|
|
1351
|
+
balanceAction: ('updated' as const),
|
|
1352
|
+
idempotentReplay: true,
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
if (delta < 0 && getAvailableQuantity(balance) < Math.abs(delta) - 0.000001) {
|
|
1356
|
+
throw new CrudHttpError(409, { error: 'insufficient_stock' })
|
|
1357
|
+
}
|
|
1358
|
+
setNumeric(
|
|
1359
|
+
balance as unknown as Record<string, unknown>,
|
|
1360
|
+
'quantityOnHand',
|
|
1361
|
+
toNumber(balance.quantityOnHand) + delta,
|
|
1362
|
+
)
|
|
1363
|
+
const { movement } = await persistMovementWithIdempotency(trx, scope, movementInput)
|
|
1364
|
+
await trx.flush()
|
|
1365
|
+
return {
|
|
1366
|
+
movementId: movement.id,
|
|
1367
|
+
warehouseId: input.warehouseId,
|
|
1368
|
+
catalogVariantId: input.catalogVariantId,
|
|
1369
|
+
quantity: delta,
|
|
1370
|
+
tenantId: scope.tenantId,
|
|
1371
|
+
organizationId: scope.organizationId,
|
|
1372
|
+
movementEntity: movement,
|
|
1373
|
+
balanceEntity: balance,
|
|
1374
|
+
balanceAction: balanceWasNew ? ('created' as const) : ('updated' as const),
|
|
1375
|
+
idempotentReplay: false,
|
|
1376
|
+
}
|
|
1377
|
+
})
|
|
1378
|
+
if (!result.idempotentReplay) {
|
|
1379
|
+
await emitInventorySideEffects(ctx, {
|
|
1380
|
+
movements: [{ entity: result.movementEntity, action: 'created' }],
|
|
1381
|
+
balances: [{ entity: result.balanceEntity, action: result.balanceAction }],
|
|
1382
|
+
})
|
|
1383
|
+
void emitWmsEvent('wms.inventory.adjusted', {
|
|
1384
|
+
id: result.movementId,
|
|
1385
|
+
movementId: result.movementId,
|
|
1386
|
+
warehouseId: result.warehouseId,
|
|
1387
|
+
catalogVariantId: result.catalogVariantId,
|
|
1388
|
+
quantity: toNumericString(result.quantity),
|
|
1389
|
+
tenantId: result.tenantId,
|
|
1390
|
+
organizationId: result.organizationId,
|
|
1391
|
+
}).catch(() => undefined)
|
|
1392
|
+
void emitLowStockEventIfNeeded(
|
|
1393
|
+
resolveEm(ctx),
|
|
1394
|
+
ctx,
|
|
1395
|
+
{ tenantId: result.tenantId, organizationId: result.organizationId },
|
|
1396
|
+
result.catalogVariantId,
|
|
1397
|
+
).catch(() => undefined)
|
|
1398
|
+
}
|
|
1399
|
+
return { movementId: result.movementId }
|
|
1400
|
+
},
|
|
1401
|
+
buildLog: async ({ input, result, ctx }) =>
|
|
1402
|
+
buildMutationLog({
|
|
1403
|
+
actionKey: 'wms.audit.inventory.adjust',
|
|
1404
|
+
fallbackLabel: 'Adjust inventory',
|
|
1405
|
+
resourceKind: WMS_INVENTORY_MOVEMENT_RESOURCE,
|
|
1406
|
+
resourceId: result?.movementId ?? null,
|
|
1407
|
+
parentResourceId:
|
|
1408
|
+
input?.warehouseId && input?.catalogVariantId
|
|
1409
|
+
? `${input.warehouseId}:${input.catalogVariantId}`
|
|
1410
|
+
: null,
|
|
1411
|
+
tenantId: input?.tenantId ?? ctx.auth?.tenantId ?? null,
|
|
1412
|
+
organizationId: input?.organizationId ?? ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
|
|
1413
|
+
cacheAliases: [WMS_INVENTORY_BALANCE_RESOURCE],
|
|
1414
|
+
}),
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
const receiveInventoryCommand: CommandHandler<InventoryReceiveInput, { movementId: string }> = {
|
|
1418
|
+
id: 'wms.inventory.receive',
|
|
1419
|
+
// See "WMS Inventory Mutation Commands — Undo Policy" at top of file.
|
|
1420
|
+
isUndoable: false,
|
|
1421
|
+
async execute(rawInput, ctx) {
|
|
1422
|
+
const input = inventoryReceiveSchema.parse(rawInput ?? {})
|
|
1423
|
+
ensureTenantScope(ctx, input.tenantId)
|
|
1424
|
+
ensureOrganizationScope(ctx, input.organizationId)
|
|
1425
|
+
const em = resolveEm(ctx)
|
|
1426
|
+
const result = await runInTransaction(em, async (trx) => {
|
|
1427
|
+
const scope = resolveScope(ctx, input)
|
|
1428
|
+
await requireWarehouse(trx, ctx, input.warehouseId, scope)
|
|
1429
|
+
const location = await requireLocation(trx, ctx, input.locationId, scope)
|
|
1430
|
+
const locationWarehouseId = typeof location.warehouse === 'string' ? location.warehouse : location.warehouse.id
|
|
1431
|
+
if (locationWarehouseId !== input.warehouseId) {
|
|
1432
|
+
throw new CrudHttpError(422, { error: 'invalid_location' })
|
|
1433
|
+
}
|
|
1434
|
+
const profile = await loadProfileForVariant(trx, ctx, input.catalogVariantId, scope)
|
|
1435
|
+
const resolvedLotId = await resolveReceiveLotId(trx, ctx, scope, input)
|
|
1436
|
+
enforceInventoryTrackingRequirements(profile, {
|
|
1437
|
+
lotId: resolvedLotId ?? null,
|
|
1438
|
+
serialNumber: input.serialNumber ?? null,
|
|
1439
|
+
})
|
|
1440
|
+
const { balance, created: balanceWasNew } = await upsertBalanceBucket(trx, scope, {
|
|
1441
|
+
warehouseId: input.warehouseId,
|
|
1442
|
+
locationId: input.locationId,
|
|
1443
|
+
catalogVariantId: input.catalogVariantId,
|
|
1444
|
+
lotId: resolvedLotId,
|
|
1445
|
+
serialNumber: input.serialNumber,
|
|
1446
|
+
})
|
|
1447
|
+
const receivedAt = input.receivedAt ?? input.performedAt ?? new Date()
|
|
1448
|
+
const performedAt = input.performedAt ?? receivedAt
|
|
1449
|
+
const movementInput: MovementMutationInput = {
|
|
1450
|
+
warehouseId: input.warehouseId,
|
|
1451
|
+
locationToId: input.locationId,
|
|
1452
|
+
catalogVariantId: input.catalogVariantId,
|
|
1453
|
+
lotId: resolvedLotId ?? null,
|
|
1454
|
+
serialNumber: input.serialNumber ?? null,
|
|
1455
|
+
quantity: input.quantity,
|
|
1456
|
+
type: 'receipt',
|
|
1457
|
+
referenceType: input.referenceType,
|
|
1458
|
+
referenceId: input.referenceId,
|
|
1459
|
+
performedBy: input.performedBy,
|
|
1460
|
+
performedAt,
|
|
1461
|
+
receivedAt,
|
|
1462
|
+
reason: input.reason,
|
|
1463
|
+
metadata: input.metadata ?? null,
|
|
1464
|
+
}
|
|
1465
|
+
const idempotencyKey = buildMovementIdempotencyKey({
|
|
1466
|
+
referenceType: movementInput.referenceType,
|
|
1467
|
+
referenceId: movementInput.referenceId,
|
|
1468
|
+
type: movementInput.type,
|
|
1469
|
+
warehouseId: movementInput.warehouseId,
|
|
1470
|
+
locationFromId: movementInput.locationFromId,
|
|
1471
|
+
locationToId: movementInput.locationToId,
|
|
1472
|
+
catalogVariantId: movementInput.catalogVariantId,
|
|
1473
|
+
lotId: movementInput.lotId,
|
|
1474
|
+
serialNumber: movementInput.serialNumber,
|
|
1475
|
+
quantity: movementInput.quantity,
|
|
1476
|
+
})
|
|
1477
|
+
const existingMovement = await findExistingMovementByIdempotencyKey(trx, scope, idempotencyKey)
|
|
1478
|
+
if (existingMovement) {
|
|
1479
|
+
return {
|
|
1480
|
+
movementId: existingMovement.id,
|
|
1481
|
+
warehouseId: input.warehouseId,
|
|
1482
|
+
locationId: input.locationId,
|
|
1483
|
+
catalogVariantId: input.catalogVariantId,
|
|
1484
|
+
quantity: input.quantity,
|
|
1485
|
+
tenantId: scope.tenantId,
|
|
1486
|
+
organizationId: scope.organizationId,
|
|
1487
|
+
movementEntity: existingMovement,
|
|
1488
|
+
balanceEntity: balance,
|
|
1489
|
+
balanceAction: ('updated' as const),
|
|
1490
|
+
idempotentReplay: true,
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
setNumeric(
|
|
1494
|
+
balance as unknown as Record<string, unknown>,
|
|
1495
|
+
'quantityOnHand',
|
|
1496
|
+
toNumber(balance.quantityOnHand) + input.quantity,
|
|
1497
|
+
)
|
|
1498
|
+
const { movement } = await persistMovementWithIdempotency(trx, scope, movementInput)
|
|
1499
|
+
await trx.flush()
|
|
1500
|
+
return {
|
|
1501
|
+
movementId: movement.id,
|
|
1502
|
+
warehouseId: input.warehouseId,
|
|
1503
|
+
locationId: input.locationId,
|
|
1504
|
+
catalogVariantId: input.catalogVariantId,
|
|
1505
|
+
quantity: input.quantity,
|
|
1506
|
+
tenantId: scope.tenantId,
|
|
1507
|
+
organizationId: scope.organizationId,
|
|
1508
|
+
movementEntity: movement,
|
|
1509
|
+
balanceEntity: balance,
|
|
1510
|
+
balanceAction: balanceWasNew ? ('created' as const) : ('updated' as const),
|
|
1511
|
+
idempotentReplay: false,
|
|
1512
|
+
}
|
|
1513
|
+
})
|
|
1514
|
+
if (!result.idempotentReplay) {
|
|
1515
|
+
await emitInventorySideEffects(ctx, {
|
|
1516
|
+
movements: [{ entity: result.movementEntity, action: 'created' }],
|
|
1517
|
+
balances: [{ entity: result.balanceEntity, action: result.balanceAction }],
|
|
1518
|
+
})
|
|
1519
|
+
void emitWmsEvent('wms.inventory.received', {
|
|
1520
|
+
id: result.movementId,
|
|
1521
|
+
movementId: result.movementId,
|
|
1522
|
+
warehouseId: result.warehouseId,
|
|
1523
|
+
locationId: result.locationId,
|
|
1524
|
+
catalogVariantId: result.catalogVariantId,
|
|
1525
|
+
quantity: toNumericString(result.quantity),
|
|
1526
|
+
tenantId: result.tenantId,
|
|
1527
|
+
organizationId: result.organizationId,
|
|
1528
|
+
}).catch(() => undefined)
|
|
1529
|
+
void emitLowStockEventIfNeeded(
|
|
1530
|
+
resolveEm(ctx),
|
|
1531
|
+
ctx,
|
|
1532
|
+
{ tenantId: result.tenantId, organizationId: result.organizationId },
|
|
1533
|
+
result.catalogVariantId,
|
|
1534
|
+
).catch(() => undefined)
|
|
1535
|
+
}
|
|
1536
|
+
return { movementId: result.movementId }
|
|
1537
|
+
},
|
|
1538
|
+
buildLog: async ({ input, result, ctx }) =>
|
|
1539
|
+
buildMutationLog({
|
|
1540
|
+
actionKey: 'wms.audit.inventory.receive',
|
|
1541
|
+
fallbackLabel: 'Receive inventory',
|
|
1542
|
+
resourceKind: WMS_INVENTORY_MOVEMENT_RESOURCE,
|
|
1543
|
+
resourceId: result?.movementId ?? null,
|
|
1544
|
+
parentResourceId:
|
|
1545
|
+
input?.warehouseId && input?.catalogVariantId
|
|
1546
|
+
? `${input.warehouseId}:${input.catalogVariantId}`
|
|
1547
|
+
: null,
|
|
1548
|
+
tenantId: input?.tenantId ?? ctx.auth?.tenantId ?? null,
|
|
1549
|
+
organizationId: input?.organizationId ?? ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
|
|
1550
|
+
cacheAliases: [WMS_INVENTORY_BALANCE_RESOURCE],
|
|
1551
|
+
}),
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
const moveInventoryCommand: CommandHandler<InventoryMoveInput, { movementId: string }> = {
|
|
1555
|
+
id: 'wms.inventory.move',
|
|
1556
|
+
// See "WMS Inventory Mutation Commands — Undo Policy" at top of file.
|
|
1557
|
+
isUndoable: false,
|
|
1558
|
+
async execute(rawInput, ctx) {
|
|
1559
|
+
const input = inventoryMoveSchema.parse(rawInput ?? {})
|
|
1560
|
+
ensureTenantScope(ctx, input.tenantId)
|
|
1561
|
+
ensureOrganizationScope(ctx, input.organizationId)
|
|
1562
|
+
const em = resolveEm(ctx)
|
|
1563
|
+
const result = await runInTransaction(em, async (trx) => {
|
|
1564
|
+
const scope = resolveScope(ctx, input)
|
|
1565
|
+
await requireWarehouse(trx, ctx, input.warehouseId, scope)
|
|
1566
|
+
const sourceLocation = await requireLocation(trx, ctx, input.fromLocationId, scope)
|
|
1567
|
+
const targetLocation = await requireLocation(trx, ctx, input.toLocationId, scope)
|
|
1568
|
+
const sourceWarehouseId = typeof sourceLocation.warehouse === 'string' ? sourceLocation.warehouse : sourceLocation.warehouse.id
|
|
1569
|
+
const targetWarehouseId = typeof targetLocation.warehouse === 'string' ? targetLocation.warehouse : targetLocation.warehouse.id
|
|
1570
|
+
if (sourceWarehouseId !== input.warehouseId || targetWarehouseId !== input.warehouseId) {
|
|
1571
|
+
throw new CrudHttpError(422, { error: 'invalid_location' })
|
|
1572
|
+
}
|
|
1573
|
+
const sourceResult = await upsertBalanceBucket(trx, scope, {
|
|
1574
|
+
warehouseId: input.warehouseId,
|
|
1575
|
+
locationId: input.fromLocationId,
|
|
1576
|
+
catalogVariantId: input.catalogVariantId,
|
|
1577
|
+
lotId: input.lotId,
|
|
1578
|
+
serialNumber: input.serialNumber,
|
|
1579
|
+
})
|
|
1580
|
+
const sourceBalance = sourceResult.balance
|
|
1581
|
+
const sourceBalanceAction = sourceResult.created ? ('created' as const) : ('updated' as const)
|
|
1582
|
+
const targetResult = await upsertBalanceBucket(trx, scope, {
|
|
1583
|
+
warehouseId: input.warehouseId,
|
|
1584
|
+
locationId: input.toLocationId,
|
|
1585
|
+
catalogVariantId: input.catalogVariantId,
|
|
1586
|
+
lotId: input.lotId,
|
|
1587
|
+
serialNumber: input.serialNumber,
|
|
1588
|
+
})
|
|
1589
|
+
const targetBalance = targetResult.balance
|
|
1590
|
+
const targetBalanceAction = targetResult.created ? ('created' as const) : ('updated' as const)
|
|
1591
|
+
const performedAt = input.performedAt ?? new Date()
|
|
1592
|
+
const receivedAt = await resolveReceivedAtForBalance(trx, sourceBalance, scope, performedAt)
|
|
1593
|
+
const movementInput: MovementMutationInput = {
|
|
1594
|
+
warehouseId: input.warehouseId,
|
|
1595
|
+
locationFromId: input.fromLocationId,
|
|
1596
|
+
locationToId: input.toLocationId,
|
|
1597
|
+
catalogVariantId: input.catalogVariantId,
|
|
1598
|
+
lotId: input.lotId ?? null,
|
|
1599
|
+
serialNumber: input.serialNumber ?? null,
|
|
1600
|
+
quantity: input.quantity,
|
|
1601
|
+
type: input.type ?? 'transfer',
|
|
1602
|
+
referenceType: input.referenceType,
|
|
1603
|
+
referenceId: input.referenceId,
|
|
1604
|
+
performedBy: input.performedBy,
|
|
1605
|
+
performedAt,
|
|
1606
|
+
receivedAt,
|
|
1607
|
+
reason: input.reason,
|
|
1608
|
+
reasonCode: input.reasonCode ?? null,
|
|
1609
|
+
metadata: input.metadata ?? null,
|
|
1610
|
+
}
|
|
1611
|
+
const idempotencyKey = buildMovementIdempotencyKey({
|
|
1612
|
+
referenceType: movementInput.referenceType,
|
|
1613
|
+
referenceId: movementInput.referenceId,
|
|
1614
|
+
type: movementInput.type,
|
|
1615
|
+
warehouseId: movementInput.warehouseId,
|
|
1616
|
+
locationFromId: movementInput.locationFromId,
|
|
1617
|
+
locationToId: movementInput.locationToId,
|
|
1618
|
+
catalogVariantId: movementInput.catalogVariantId,
|
|
1619
|
+
lotId: movementInput.lotId,
|
|
1620
|
+
serialNumber: movementInput.serialNumber,
|
|
1621
|
+
quantity: movementInput.quantity,
|
|
1622
|
+
})
|
|
1623
|
+
const existingMovement = await findExistingMovementByIdempotencyKey(trx, scope, idempotencyKey)
|
|
1624
|
+
if (existingMovement) {
|
|
1625
|
+
return {
|
|
1626
|
+
movementId: existingMovement.id,
|
|
1627
|
+
warehouseId: input.warehouseId,
|
|
1628
|
+
catalogVariantId: input.catalogVariantId,
|
|
1629
|
+
quantity: input.quantity,
|
|
1630
|
+
tenantId: scope.tenantId,
|
|
1631
|
+
organizationId: scope.organizationId,
|
|
1632
|
+
movementEntity: existingMovement,
|
|
1633
|
+
balances: [
|
|
1634
|
+
{ entity: sourceBalance, action: sourceBalanceAction },
|
|
1635
|
+
{ entity: targetBalance, action: targetBalanceAction },
|
|
1636
|
+
] as AffectedBalance[],
|
|
1637
|
+
idempotentReplay: true,
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
if (getAvailableQuantity(sourceBalance) < input.quantity - 0.000001) {
|
|
1641
|
+
throw new CrudHttpError(409, { error: 'insufficient_stock' })
|
|
1642
|
+
}
|
|
1643
|
+
setNumeric(
|
|
1644
|
+
sourceBalance as unknown as Record<string, unknown>,
|
|
1645
|
+
'quantityOnHand',
|
|
1646
|
+
toNumber(sourceBalance.quantityOnHand) - input.quantity,
|
|
1647
|
+
)
|
|
1648
|
+
setNumeric(
|
|
1649
|
+
targetBalance as unknown as Record<string, unknown>,
|
|
1650
|
+
'quantityOnHand',
|
|
1651
|
+
toNumber(targetBalance.quantityOnHand) + input.quantity,
|
|
1652
|
+
)
|
|
1653
|
+
const { movement } = await persistMovementWithIdempotency(trx, scope, movementInput)
|
|
1654
|
+
await trx.flush()
|
|
1655
|
+
return {
|
|
1656
|
+
movementId: movement.id,
|
|
1657
|
+
warehouseId: input.warehouseId,
|
|
1658
|
+
catalogVariantId: input.catalogVariantId,
|
|
1659
|
+
quantity: input.quantity,
|
|
1660
|
+
tenantId: scope.tenantId,
|
|
1661
|
+
organizationId: scope.organizationId,
|
|
1662
|
+
movementEntity: movement,
|
|
1663
|
+
balances: [
|
|
1664
|
+
{ entity: sourceBalance, action: sourceBalanceAction },
|
|
1665
|
+
{ entity: targetBalance, action: targetBalanceAction },
|
|
1666
|
+
] as AffectedBalance[],
|
|
1667
|
+
idempotentReplay: false,
|
|
1668
|
+
}
|
|
1669
|
+
})
|
|
1670
|
+
if (!result.idempotentReplay) {
|
|
1671
|
+
await emitInventorySideEffects(ctx, {
|
|
1672
|
+
movements: [{ entity: result.movementEntity, action: 'created' }],
|
|
1673
|
+
balances: result.balances,
|
|
1674
|
+
})
|
|
1675
|
+
void emitWmsEvent('wms.inventory.moved', {
|
|
1676
|
+
id: result.movementId,
|
|
1677
|
+
movementId: result.movementId,
|
|
1678
|
+
warehouseId: result.warehouseId,
|
|
1679
|
+
catalogVariantId: result.catalogVariantId,
|
|
1680
|
+
quantity: toNumericString(result.quantity),
|
|
1681
|
+
tenantId: result.tenantId,
|
|
1682
|
+
organizationId: result.organizationId,
|
|
1683
|
+
}).catch(() => undefined)
|
|
1684
|
+
void emitLowStockEventIfNeeded(
|
|
1685
|
+
resolveEm(ctx),
|
|
1686
|
+
ctx,
|
|
1687
|
+
{ tenantId: result.tenantId, organizationId: result.organizationId },
|
|
1688
|
+
result.catalogVariantId,
|
|
1689
|
+
).catch(() => undefined)
|
|
1690
|
+
}
|
|
1691
|
+
return { movementId: result.movementId }
|
|
1692
|
+
},
|
|
1693
|
+
buildLog: async ({ input, result, ctx }) =>
|
|
1694
|
+
buildMutationLog({
|
|
1695
|
+
actionKey: 'wms.audit.inventory.move',
|
|
1696
|
+
fallbackLabel: 'Move inventory',
|
|
1697
|
+
resourceKind: WMS_INVENTORY_MOVEMENT_RESOURCE,
|
|
1698
|
+
resourceId: result?.movementId ?? null,
|
|
1699
|
+
parentResourceId:
|
|
1700
|
+
input?.warehouseId && input?.catalogVariantId
|
|
1701
|
+
? `${input.warehouseId}:${input.catalogVariantId}`
|
|
1702
|
+
: null,
|
|
1703
|
+
tenantId: input?.tenantId ?? ctx.auth?.tenantId ?? null,
|
|
1704
|
+
organizationId: input?.organizationId ?? ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
|
|
1705
|
+
cacheAliases: [WMS_INVENTORY_BALANCE_RESOURCE],
|
|
1706
|
+
}),
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
const cycleCountInventoryCommand: CommandHandler<InventoryCycleCountInput, { adjustmentDelta: string; movementId?: string | null }> = {
|
|
1710
|
+
id: 'wms.inventory.cycleCount',
|
|
1711
|
+
// See "WMS Inventory Mutation Commands — Undo Policy" at top of file.
|
|
1712
|
+
isUndoable: false,
|
|
1713
|
+
async execute(rawInput, ctx) {
|
|
1714
|
+
const input = inventoryCycleCountSchema.parse(rawInput ?? {})
|
|
1715
|
+
ensureTenantScope(ctx, input.tenantId)
|
|
1716
|
+
ensureOrganizationScope(ctx, input.organizationId)
|
|
1717
|
+
const em = resolveEm(ctx)
|
|
1718
|
+
const result = await runInTransaction(em, async (trx) => {
|
|
1719
|
+
const scope = resolveScope(ctx, input)
|
|
1720
|
+
await requireWarehouse(trx, ctx, input.warehouseId, scope)
|
|
1721
|
+
const location = await requireLocation(trx, ctx, input.locationId, scope)
|
|
1722
|
+
const locationWarehouseId = typeof location.warehouse === 'string' ? location.warehouse : location.warehouse.id
|
|
1723
|
+
if (locationWarehouseId !== input.warehouseId) {
|
|
1724
|
+
throw new CrudHttpError(422, { error: 'invalid_location' })
|
|
1725
|
+
}
|
|
1726
|
+
const { balance, created: balanceWasNew } = await upsertBalanceBucket(trx, scope, {
|
|
1727
|
+
warehouseId: input.warehouseId,
|
|
1728
|
+
locationId: input.locationId,
|
|
1729
|
+
catalogVariantId: input.catalogVariantId,
|
|
1730
|
+
lotId: input.lotId,
|
|
1731
|
+
serialNumber: input.serialNumber,
|
|
1732
|
+
})
|
|
1733
|
+
const currentOnHand = toNumber(balance.quantityOnHand)
|
|
1734
|
+
const delta = input.countedQuantity - currentOnHand
|
|
1735
|
+
if (delta === 0) {
|
|
1736
|
+
return {
|
|
1737
|
+
adjustmentDelta: '0',
|
|
1738
|
+
movementId: null as string | null,
|
|
1739
|
+
warehouseId: input.warehouseId,
|
|
1740
|
+
catalogVariantId: input.catalogVariantId,
|
|
1741
|
+
tenantId: scope.tenantId,
|
|
1742
|
+
organizationId: scope.organizationId,
|
|
1743
|
+
movementEntity: null as InventoryMovement | null,
|
|
1744
|
+
balanceEntity: balance,
|
|
1745
|
+
balanceAction: ('updated' as const),
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
if (!input.autoAdjust) {
|
|
1749
|
+
throw new CrudHttpError(422, { error: 'auto_adjust_required' })
|
|
1750
|
+
}
|
|
1751
|
+
const performedAt = input.performedAt ?? new Date()
|
|
1752
|
+
const receivedAt = await resolveReceivedAtForBalance(trx, balance, scope, performedAt)
|
|
1753
|
+
const movementInput: MovementMutationInput = {
|
|
1754
|
+
warehouseId: input.warehouseId,
|
|
1755
|
+
locationToId: input.locationId,
|
|
1756
|
+
catalogVariantId: input.catalogVariantId,
|
|
1757
|
+
lotId: input.lotId ?? null,
|
|
1758
|
+
serialNumber: input.serialNumber ?? null,
|
|
1759
|
+
quantity: delta,
|
|
1760
|
+
type: 'cycle_count',
|
|
1761
|
+
referenceType: 'manual',
|
|
1762
|
+
referenceId: input.referenceId,
|
|
1763
|
+
performedBy: input.performedBy,
|
|
1764
|
+
performedAt,
|
|
1765
|
+
receivedAt,
|
|
1766
|
+
reason: input.reason,
|
|
1767
|
+
metadata: input.metadata ?? null,
|
|
1768
|
+
}
|
|
1769
|
+
const idempotencyKey = buildMovementIdempotencyKey({
|
|
1770
|
+
referenceType: movementInput.referenceType,
|
|
1771
|
+
referenceId: movementInput.referenceId,
|
|
1772
|
+
type: movementInput.type,
|
|
1773
|
+
warehouseId: movementInput.warehouseId,
|
|
1774
|
+
locationFromId: movementInput.locationFromId,
|
|
1775
|
+
locationToId: movementInput.locationToId,
|
|
1776
|
+
catalogVariantId: movementInput.catalogVariantId,
|
|
1777
|
+
lotId: movementInput.lotId,
|
|
1778
|
+
serialNumber: movementInput.serialNumber,
|
|
1779
|
+
quantity: movementInput.quantity,
|
|
1780
|
+
})
|
|
1781
|
+
const existingMovement = await findExistingMovementByIdempotencyKey(trx, scope, idempotencyKey)
|
|
1782
|
+
if (existingMovement) {
|
|
1783
|
+
return {
|
|
1784
|
+
adjustmentDelta: toNumericString(delta),
|
|
1785
|
+
movementId: existingMovement.id,
|
|
1786
|
+
warehouseId: input.warehouseId,
|
|
1787
|
+
catalogVariantId: input.catalogVariantId,
|
|
1788
|
+
tenantId: scope.tenantId,
|
|
1789
|
+
organizationId: scope.organizationId,
|
|
1790
|
+
movementEntity: existingMovement,
|
|
1791
|
+
balanceEntity: balance,
|
|
1792
|
+
balanceAction: ('updated' as const),
|
|
1793
|
+
idempotentReplay: true,
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
setNumeric(
|
|
1797
|
+
balance as unknown as Record<string, unknown>,
|
|
1798
|
+
'quantityOnHand',
|
|
1799
|
+
input.countedQuantity,
|
|
1800
|
+
)
|
|
1801
|
+
const { movement } = await persistMovementWithIdempotency(trx, scope, movementInput)
|
|
1802
|
+
await trx.flush()
|
|
1803
|
+
return {
|
|
1804
|
+
adjustmentDelta: toNumericString(delta),
|
|
1805
|
+
movementId: movement.id as string | null,
|
|
1806
|
+
warehouseId: input.warehouseId,
|
|
1807
|
+
catalogVariantId: input.catalogVariantId,
|
|
1808
|
+
tenantId: scope.tenantId,
|
|
1809
|
+
organizationId: scope.organizationId,
|
|
1810
|
+
movementEntity: movement as InventoryMovement | null,
|
|
1811
|
+
balanceEntity: balance,
|
|
1812
|
+
balanceAction: balanceWasNew ? ('created' as const) : ('updated' as const),
|
|
1813
|
+
idempotentReplay: false,
|
|
1814
|
+
}
|
|
1815
|
+
})
|
|
1816
|
+
if (!result.idempotentReplay) {
|
|
1817
|
+
await emitInventorySideEffects(ctx, {
|
|
1818
|
+
movements: result.movementEntity ? [{ entity: result.movementEntity, action: 'created' }] : [],
|
|
1819
|
+
balances: [{ entity: result.balanceEntity, action: result.balanceAction }],
|
|
1820
|
+
})
|
|
1821
|
+
if (result.movementId) {
|
|
1822
|
+
void emitWmsEvent('wms.inventory.reconciled', {
|
|
1823
|
+
id: result.movementId,
|
|
1824
|
+
movementId: result.movementId,
|
|
1825
|
+
warehouseId: result.warehouseId,
|
|
1826
|
+
catalogVariantId: result.catalogVariantId,
|
|
1827
|
+
adjustmentDelta: result.adjustmentDelta,
|
|
1828
|
+
tenantId: result.tenantId,
|
|
1829
|
+
organizationId: result.organizationId,
|
|
1830
|
+
}).catch(() => undefined)
|
|
1831
|
+
void emitLowStockEventIfNeeded(
|
|
1832
|
+
resolveEm(ctx),
|
|
1833
|
+
ctx,
|
|
1834
|
+
{ tenantId: result.tenantId, organizationId: result.organizationId },
|
|
1835
|
+
result.catalogVariantId,
|
|
1836
|
+
).catch(() => undefined)
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
return {
|
|
1840
|
+
adjustmentDelta: result.adjustmentDelta,
|
|
1841
|
+
movementId: result.movementId ?? null,
|
|
1842
|
+
}
|
|
1843
|
+
},
|
|
1844
|
+
buildLog: async ({ input, result, ctx }) =>
|
|
1845
|
+
buildMutationLog({
|
|
1846
|
+
actionKey: 'wms.audit.inventory.cycleCount',
|
|
1847
|
+
fallbackLabel: 'Run cycle count reconciliation',
|
|
1848
|
+
resourceKind: WMS_INVENTORY_MOVEMENT_RESOURCE,
|
|
1849
|
+
resourceId: result?.movementId ?? null,
|
|
1850
|
+
parentResourceId:
|
|
1851
|
+
input?.warehouseId && input?.catalogVariantId
|
|
1852
|
+
? `${input.warehouseId}:${input.catalogVariantId}`
|
|
1853
|
+
: null,
|
|
1854
|
+
tenantId: input?.tenantId ?? ctx.auth?.tenantId ?? null,
|
|
1855
|
+
organizationId: input?.organizationId ?? ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
|
|
1856
|
+
cacheAliases: [WMS_INVENTORY_BALANCE_RESOURCE],
|
|
1857
|
+
}),
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
registerCommand(reserveInventoryCommand)
|
|
1861
|
+
registerCommand(releaseInventoryReservationCommand)
|
|
1862
|
+
registerCommand(allocateInventoryReservationCommand)
|
|
1863
|
+
registerCommand(adjustInventoryCommand)
|
|
1864
|
+
registerCommand(receiveInventoryCommand)
|
|
1865
|
+
registerCommand(moveInventoryCommand)
|
|
1866
|
+
registerCommand(cycleCountInventoryCommand)
|