@open-mercato/core 0.10.1-develop.7709.1.046124599c → 0.10.1-develop.7714.1.9dbb8200d8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (276) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/generated/entities/asn/index.js +19 -0
  3. package/dist/generated/entities/asn/index.js.map +7 -0
  4. package/dist/generated/entities/putaway_task/index.js +25 -0
  5. package/dist/generated/entities/putaway_task/index.js.map +7 -0
  6. package/dist/generated/entities/receiving_line/index.js +21 -0
  7. package/dist/generated/entities/receiving_line/index.js.map +7 -0
  8. package/dist/generated/entities.ids.generated.js +4 -1
  9. package/dist/generated/entities.ids.generated.js.map +2 -2
  10. package/dist/generated/entity-fields-registry.js +34 -0
  11. package/dist/generated/entity-fields-registry.js.map +2 -2
  12. package/dist/modules/communication_channels/lib/sanitize-channel-html.js +22 -3
  13. package/dist/modules/communication_channels/lib/sanitize-channel-html.js.map +2 -2
  14. package/dist/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.client.js +40 -11
  15. package/dist/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.client.js.map +2 -2
  16. package/dist/modules/customers/components/calendar/CalendarEventEditor.js +13 -2
  17. package/dist/modules/customers/components/calendar/CalendarEventEditor.js.map +2 -2
  18. package/dist/modules/wms/acl.js +2 -0
  19. package/dist/modules/wms/acl.js.map +2 -2
  20. package/dist/modules/wms/api/asns/[id]/complete/route.js +61 -0
  21. package/dist/modules/wms/api/asns/[id]/complete/route.js.map +7 -0
  22. package/dist/modules/wms/api/asns/[id]/receive/route.js +68 -0
  23. package/dist/modules/wms/api/asns/[id]/receive/route.js.map +7 -0
  24. package/dist/modules/wms/api/asns/route.js +197 -0
  25. package/dist/modules/wms/api/asns/route.js.map +7 -0
  26. package/dist/modules/wms/api/inventory/helpers.js +42 -34
  27. package/dist/modules/wms/api/inventory/helpers.js.map +2 -2
  28. package/dist/modules/wms/api/inventory/receive/route.js +1 -1
  29. package/dist/modules/wms/api/inventory/receive/route.js.map +2 -2
  30. package/dist/modules/wms/api/listEnrichers.js +23 -0
  31. package/dist/modules/wms/api/listEnrichers.js.map +2 -2
  32. package/dist/modules/wms/api/putaway-tasks/[id]/assign/route.js +56 -0
  33. package/dist/modules/wms/api/putaway-tasks/[id]/assign/route.js.map +7 -0
  34. package/dist/modules/wms/api/putaway-tasks/[id]/cancel/route.js +56 -0
  35. package/dist/modules/wms/api/putaway-tasks/[id]/cancel/route.js.map +7 -0
  36. package/dist/modules/wms/api/putaway-tasks/[id]/complete/route.js +65 -0
  37. package/dist/modules/wms/api/putaway-tasks/[id]/complete/route.js.map +7 -0
  38. package/dist/modules/wms/api/putaway-tasks/[id]/start/route.js +56 -0
  39. package/dist/modules/wms/api/putaway-tasks/[id]/start/route.js.map +7 -0
  40. package/dist/modules/wms/api/putaway-tasks/create-from-balance/route.js +51 -0
  41. package/dist/modules/wms/api/putaway-tasks/create-from-balance/route.js.map +7 -0
  42. package/dist/modules/wms/api/putaway-tasks/route.js +192 -0
  43. package/dist/modules/wms/api/putaway-tasks/route.js.map +7 -0
  44. package/dist/modules/wms/api/receiving-lines/route.js +180 -0
  45. package/dist/modules/wms/api/receiving-lines/route.js.map +7 -0
  46. package/dist/modules/wms/api/scan/putaway/route.js +104 -0
  47. package/dist/modules/wms/api/scan/putaway/route.js.map +7 -0
  48. package/dist/modules/wms/api/scan/receive/route.js +109 -0
  49. package/dist/modules/wms/api/scan/receive/route.js.map +7 -0
  50. package/dist/modules/wms/api/scan/resolve-location/route.js +89 -0
  51. package/dist/modules/wms/api/scan/resolve-location/route.js.map +7 -0
  52. package/dist/modules/wms/api/scan/resolve-lot/route.js +89 -0
  53. package/dist/modules/wms/api/scan/resolve-lot/route.js.map +7 -0
  54. package/dist/modules/wms/backend/wms/asns/[id]/page.js +9 -0
  55. package/dist/modules/wms/backend/wms/asns/[id]/page.js.map +7 -0
  56. package/dist/modules/wms/backend/wms/asns/[id]/page.meta.js +18 -0
  57. package/dist/modules/wms/backend/wms/asns/[id]/page.meta.js.map +7 -0
  58. package/dist/modules/wms/backend/wms/asns/page.js +9 -0
  59. package/dist/modules/wms/backend/wms/asns/page.js.map +7 -0
  60. package/dist/modules/wms/backend/wms/asns/page.meta.js +18 -0
  61. package/dist/modules/wms/backend/wms/asns/page.meta.js.map +7 -0
  62. package/dist/modules/wms/backend/wms/putaway/page.js +9 -0
  63. package/dist/modules/wms/backend/wms/putaway/page.js.map +7 -0
  64. package/dist/modules/wms/backend/wms/putaway/page.meta.js +18 -0
  65. package/dist/modules/wms/backend/wms/putaway/page.meta.js.map +7 -0
  66. package/dist/modules/wms/backend/wms/receiving/page.js +8 -0
  67. package/dist/modules/wms/backend/wms/receiving/page.js.map +7 -0
  68. package/dist/modules/wms/backend/wms/receiving/page.meta.js +18 -0
  69. package/dist/modules/wms/backend/wms/receiving/page.meta.js.map +7 -0
  70. package/dist/modules/wms/ce.js +27 -0
  71. package/dist/modules/wms/ce.js.map +2 -2
  72. package/dist/modules/wms/commands/asn.js +1746 -0
  73. package/dist/modules/wms/commands/asn.js.map +7 -0
  74. package/dist/modules/wms/commands/index.js +2 -0
  75. package/dist/modules/wms/commands/index.js.map +2 -2
  76. package/dist/modules/wms/commands/inventory-actions.js +347 -243
  77. package/dist/modules/wms/commands/inventory-actions.js.map +2 -2
  78. package/dist/modules/wms/commands/putaway.js +945 -0
  79. package/dist/modules/wms/commands/putaway.js.map +7 -0
  80. package/dist/modules/wms/commands/shared.js +51 -0
  81. package/dist/modules/wms/commands/shared.js.map +2 -2
  82. package/dist/modules/wms/components/backend/AssignPutawayTaskDialog.js +278 -0
  83. package/dist/modules/wms/components/backend/AssignPutawayTaskDialog.js.map +7 -0
  84. package/dist/modules/wms/components/backend/CompletePutawayTaskDialog.js +327 -0
  85. package/dist/modules/wms/components/backend/CompletePutawayTaskDialog.js.map +7 -0
  86. package/dist/modules/wms/components/backend/CreateAsnDialog.js +404 -0
  87. package/dist/modules/wms/components/backend/CreateAsnDialog.js.map +7 -0
  88. package/dist/modules/wms/components/backend/InventoryOperationsSection.js +16 -3
  89. package/dist/modules/wms/components/backend/InventoryOperationsSection.js.map +2 -2
  90. package/dist/modules/wms/components/backend/ReceiveAsnLineDialog.js +476 -0
  91. package/dist/modules/wms/components/backend/ReceiveAsnLineDialog.js.map +7 -0
  92. package/dist/modules/wms/components/backend/ReceiveInventoryDialog.js +2 -2
  93. package/dist/modules/wms/components/backend/ReceiveInventoryDialog.js.map +2 -2
  94. package/dist/modules/wms/components/backend/WmsAsnDetailPage.js +524 -0
  95. package/dist/modules/wms/components/backend/WmsAsnDetailPage.js.map +7 -0
  96. package/dist/modules/wms/components/backend/WmsAsnsListPage.js +339 -0
  97. package/dist/modules/wms/components/backend/WmsAsnsListPage.js.map +7 -0
  98. package/dist/modules/wms/components/backend/WmsLocationDetailPage.js +5 -6
  99. package/dist/modules/wms/components/backend/WmsLocationDetailPage.js.map +2 -2
  100. package/dist/modules/wms/components/backend/WmsLotDetailPage.js +5 -6
  101. package/dist/modules/wms/components/backend/WmsLotDetailPage.js.map +2 -2
  102. package/dist/modules/wms/components/backend/WmsOperationalDashboardPage.js +14 -10
  103. package/dist/modules/wms/components/backend/WmsOperationalDashboardPage.js.map +2 -2
  104. package/dist/modules/wms/components/backend/WmsPutawayQueuePage.js +598 -0
  105. package/dist/modules/wms/components/backend/WmsPutawayQueuePage.js.map +7 -0
  106. package/dist/modules/wms/components/backend/WmsSkuDetailPage.js +5 -6
  107. package/dist/modules/wms/components/backend/WmsSkuDetailPage.js.map +2 -2
  108. package/dist/modules/wms/components/backend/asnCompleteOptimisticLock.js +32 -0
  109. package/dist/modules/wms/components/backend/asnCompleteOptimisticLock.js.map +7 -0
  110. package/dist/modules/wms/components/backend/asnReceivingLinesLoader.js +36 -0
  111. package/dist/modules/wms/components/backend/asnReceivingLinesLoader.js.map +7 -0
  112. package/dist/modules/wms/components/backend/inboundStatusUi.js +106 -0
  113. package/dist/modules/wms/components/backend/inboundStatusUi.js.map +7 -0
  114. package/dist/modules/wms/components/backend/inventoryMutationLoaders.js +17 -0
  115. package/dist/modules/wms/components/backend/inventoryMutationLoaders.js.map +2 -2
  116. package/dist/modules/wms/components/backend/putawayQueueOptimisticLock.js +14 -0
  117. package/dist/modules/wms/components/backend/putawayQueueOptimisticLock.js.map +7 -0
  118. package/dist/modules/wms/components/backend/useWmsInventoryMutationAccess.js +4 -0
  119. package/dist/modules/wms/components/backend/useWmsInventoryMutationAccess.js.map +2 -2
  120. package/dist/modules/wms/data/enrichers.js +117 -12
  121. package/dist/modules/wms/data/enrichers.js.map +2 -2
  122. package/dist/modules/wms/data/entities.js +150 -1
  123. package/dist/modules/wms/data/entities.js.map +2 -2
  124. package/dist/modules/wms/data/validators.js +214 -0
  125. package/dist/modules/wms/data/validators.js.map +2 -2
  126. package/dist/modules/wms/events.js +12 -1
  127. package/dist/modules/wms/events.js.map +2 -2
  128. package/dist/modules/wms/extension-points.js +3 -1
  129. package/dist/modules/wms/extension-points.js.map +2 -2
  130. package/dist/modules/wms/lib/asnReceiving.js +124 -0
  131. package/dist/modules/wms/lib/asnReceiving.js.map +7 -0
  132. package/dist/modules/wms/lib/asnVendor.js +52 -0
  133. package/dist/modules/wms/lib/asnVendor.js.map +7 -0
  134. package/dist/modules/wms/lib/flashMutationError.js +4 -0
  135. package/dist/modules/wms/lib/flashMutationError.js.map +2 -2
  136. package/dist/modules/wms/lib/inventoryDisplayUi.js +24 -0
  137. package/dist/modules/wms/lib/inventoryDisplayUi.js.map +2 -2
  138. package/dist/modules/wms/lib/inventoryPolicy.js +17 -0
  139. package/dist/modules/wms/lib/inventoryPolicy.js.map +2 -2
  140. package/dist/modules/wms/lib/loadOperationalDashboard.js +67 -1
  141. package/dist/modules/wms/lib/loadOperationalDashboard.js.map +2 -2
  142. package/dist/modules/wms/lib/putaway.js +141 -0
  143. package/dist/modules/wms/lib/putaway.js.map +7 -0
  144. package/dist/modules/wms/lib/roleFeatures.js +3 -1
  145. package/dist/modules/wms/lib/roleFeatures.js.map +2 -2
  146. package/dist/modules/wms/lib/salesOrderInventoryAutomation.js +99 -1
  147. package/dist/modules/wms/lib/salesOrderInventoryAutomation.js.map +2 -2
  148. package/dist/modules/wms/lib/scanResolve.js +147 -0
  149. package/dist/modules/wms/lib/scanResolve.js.map +7 -0
  150. package/dist/modules/wms/lib/wmsIntegrationToggles.js +1 -1
  151. package/dist/modules/wms/lib/wmsIntegrationToggles.js.map +2 -2
  152. package/dist/modules/wms/migrations/Migration20260812120000.js +43 -0
  153. package/dist/modules/wms/migrations/Migration20260812120000.js.map +7 -0
  154. package/dist/modules/wms/migrations/Migration20260812130000.js +30 -0
  155. package/dist/modules/wms/migrations/Migration20260812130000.js.map +7 -0
  156. package/dist/modules/wms/migrations/Migration20260822120000.js +17 -0
  157. package/dist/modules/wms/migrations/Migration20260822120000.js.map +7 -0
  158. package/dist/modules/wms/migrations/Migration20260822130000.js +17 -0
  159. package/dist/modules/wms/migrations/Migration20260822130000.js.map +7 -0
  160. package/dist/modules/wms/migrations/Migration20261029120000.js +13 -0
  161. package/dist/modules/wms/migrations/Migration20261029120000.js.map +7 -0
  162. package/dist/modules/wms/search.js +85 -0
  163. package/dist/modules/wms/search.js.map +2 -2
  164. package/dist/modules/wms/subscribers/asn-line-received-reevaluate-reservations.js +14 -0
  165. package/dist/modules/wms/subscribers/asn-line-received-reevaluate-reservations.js.map +7 -0
  166. package/dist/modules/wms/subscribers/invalidate-enricher-cache-asn.js +12 -0
  167. package/dist/modules/wms/subscribers/invalidate-enricher-cache-asn.js.map +7 -0
  168. package/dist/modules/wms/subscribers/procurement-goods-receipt-created.js +163 -0
  169. package/dist/modules/wms/subscribers/procurement-goods-receipt-created.js.map +7 -0
  170. package/dist/modules/wms/subscribers/putaway-completed-reevaluate-reservations.js +14 -0
  171. package/dist/modules/wms/subscribers/putaway-completed-reevaluate-reservations.js.map +7 -0
  172. package/dist/modules/wms/widgets/injection/sales-order-stock-context/widget.client.js +37 -8
  173. package/dist/modules/wms/widgets/injection/sales-order-stock-context/widget.client.js.map +2 -2
  174. package/generated/entities/asn/index.ts +8 -0
  175. package/generated/entities/putaway_task/index.ts +11 -0
  176. package/generated/entities/receiving_line/index.ts +9 -0
  177. package/generated/entities.ids.generated.ts +4 -1
  178. package/generated/entity-fields-registry.ts +34 -0
  179. package/package.json +7 -7
  180. package/src/modules/auth/i18n/de.json +2 -0
  181. package/src/modules/auth/i18n/en.json +2 -0
  182. package/src/modules/auth/i18n/es.json +2 -0
  183. package/src/modules/auth/i18n/ko.json +2 -0
  184. package/src/modules/auth/i18n/pl.json +2 -0
  185. package/src/modules/communication_channels/i18n/de.json +2 -0
  186. package/src/modules/communication_channels/i18n/en.json +2 -0
  187. package/src/modules/communication_channels/i18n/es.json +2 -0
  188. package/src/modules/communication_channels/i18n/ko.json +2 -0
  189. package/src/modules/communication_channels/i18n/pl.json +2 -0
  190. package/src/modules/communication_channels/lib/sanitize-channel-html.ts +23 -3
  191. package/src/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.client.tsx +44 -12
  192. package/src/modules/customers/components/calendar/CalendarEventEditor.tsx +16 -2
  193. package/src/modules/wms/acl.ts +2 -0
  194. package/src/modules/wms/api/asns/[id]/complete/route.ts +62 -0
  195. package/src/modules/wms/api/asns/[id]/receive/route.ts +74 -0
  196. package/src/modules/wms/api/asns/route.ts +200 -0
  197. package/src/modules/wms/api/inventory/helpers.ts +57 -34
  198. package/src/modules/wms/api/inventory/receive/route.ts +2 -1
  199. package/src/modules/wms/api/listEnrichers.ts +38 -0
  200. package/src/modules/wms/api/putaway-tasks/[id]/assign/route.ts +56 -0
  201. package/src/modules/wms/api/putaway-tasks/[id]/cancel/route.ts +56 -0
  202. package/src/modules/wms/api/putaway-tasks/[id]/complete/route.ts +66 -0
  203. package/src/modules/wms/api/putaway-tasks/[id]/start/route.ts +56 -0
  204. package/src/modules/wms/api/putaway-tasks/create-from-balance/route.ts +50 -0
  205. package/src/modules/wms/api/putaway-tasks/route.ts +199 -0
  206. package/src/modules/wms/api/receiving-lines/route.ts +180 -0
  207. package/src/modules/wms/api/scan/putaway/route.ts +108 -0
  208. package/src/modules/wms/api/scan/receive/route.ts +118 -0
  209. package/src/modules/wms/api/scan/resolve-location/route.ts +92 -0
  210. package/src/modules/wms/api/scan/resolve-lot/route.ts +92 -0
  211. package/src/modules/wms/backend/wms/asns/[id]/page.meta.ts +14 -0
  212. package/src/modules/wms/backend/wms/asns/[id]/page.tsx +5 -0
  213. package/src/modules/wms/backend/wms/asns/page.meta.ts +14 -0
  214. package/src/modules/wms/backend/wms/asns/page.tsx +5 -0
  215. package/src/modules/wms/backend/wms/putaway/page.meta.ts +14 -0
  216. package/src/modules/wms/backend/wms/putaway/page.tsx +5 -0
  217. package/src/modules/wms/backend/wms/receiving/page.meta.ts +14 -0
  218. package/src/modules/wms/backend/wms/receiving/page.tsx +5 -0
  219. package/src/modules/wms/ce.ts +27 -0
  220. package/src/modules/wms/commands/asn.ts +2122 -0
  221. package/src/modules/wms/commands/index.ts +2 -0
  222. package/src/modules/wms/commands/inventory-actions.ts +413 -243
  223. package/src/modules/wms/commands/putaway.ts +1169 -0
  224. package/src/modules/wms/commands/shared.ts +51 -0
  225. package/src/modules/wms/components/backend/AssignPutawayTaskDialog.tsx +328 -0
  226. package/src/modules/wms/components/backend/CompletePutawayTaskDialog.tsx +366 -0
  227. package/src/modules/wms/components/backend/CreateAsnDialog.tsx +427 -0
  228. package/src/modules/wms/components/backend/InventoryOperationsSection.tsx +20 -2
  229. package/src/modules/wms/components/backend/ReceiveAsnLineDialog.tsx +535 -0
  230. package/src/modules/wms/components/backend/ReceiveInventoryDialog.tsx +2 -2
  231. package/src/modules/wms/components/backend/WmsAsnDetailPage.tsx +647 -0
  232. package/src/modules/wms/components/backend/WmsAsnsListPage.tsx +407 -0
  233. package/src/modules/wms/components/backend/WmsLocationDetailPage.tsx +4 -5
  234. package/src/modules/wms/components/backend/WmsLotDetailPage.tsx +4 -5
  235. package/src/modules/wms/components/backend/WmsOperationalDashboardPage.tsx +12 -8
  236. package/src/modules/wms/components/backend/WmsPutawayQueuePage.tsx +677 -0
  237. package/src/modules/wms/components/backend/WmsSkuDetailPage.tsx +4 -5
  238. package/src/modules/wms/components/backend/asnCompleteOptimisticLock.ts +52 -0
  239. package/src/modules/wms/components/backend/asnReceivingLinesLoader.ts +83 -0
  240. package/src/modules/wms/components/backend/inboundStatusUi.ts +154 -0
  241. package/src/modules/wms/components/backend/inventoryMutationLoaders.ts +20 -0
  242. package/src/modules/wms/components/backend/putawayQueueOptimisticLock.ts +20 -0
  243. package/src/modules/wms/components/backend/useWmsInventoryMutationAccess.ts +4 -0
  244. package/src/modules/wms/data/enrichers.ts +175 -13
  245. package/src/modules/wms/data/entities.ts +155 -0
  246. package/src/modules/wms/data/validators.ts +256 -0
  247. package/src/modules/wms/events.ts +11 -0
  248. package/src/modules/wms/extension-points.ts +2 -0
  249. package/src/modules/wms/i18n/de.json +205 -3
  250. package/src/modules/wms/i18n/en.json +205 -3
  251. package/src/modules/wms/i18n/es.json +205 -3
  252. package/src/modules/wms/i18n/ko.json +205 -3
  253. package/src/modules/wms/i18n/pl.json +205 -3
  254. package/src/modules/wms/lib/asnReceiving.ts +226 -0
  255. package/src/modules/wms/lib/asnVendor.ts +80 -0
  256. package/src/modules/wms/lib/flashMutationError.ts +4 -0
  257. package/src/modules/wms/lib/inventoryDisplayUi.ts +46 -0
  258. package/src/modules/wms/lib/inventoryPolicy.ts +31 -1
  259. package/src/modules/wms/lib/loadOperationalDashboard.ts +89 -1
  260. package/src/modules/wms/lib/putaway.ts +210 -0
  261. package/src/modules/wms/lib/roleFeatures.ts +2 -0
  262. package/src/modules/wms/lib/salesOrderInventoryAutomation.ts +150 -1
  263. package/src/modules/wms/lib/scanResolve.ts +185 -0
  264. package/src/modules/wms/lib/wmsIntegrationToggles.ts +2 -1
  265. package/src/modules/wms/migrations/.snapshot-open-mercato.json +1000 -0
  266. package/src/modules/wms/migrations/Migration20260812120000.ts +43 -0
  267. package/src/modules/wms/migrations/Migration20260812130000.ts +28 -0
  268. package/src/modules/wms/migrations/Migration20260822120000.ts +17 -0
  269. package/src/modules/wms/migrations/Migration20260822130000.ts +15 -0
  270. package/src/modules/wms/migrations/Migration20261029120000.ts +11 -0
  271. package/src/modules/wms/search.ts +95 -0
  272. package/src/modules/wms/subscribers/asn-line-received-reevaluate-reservations.ts +21 -0
  273. package/src/modules/wms/subscribers/invalidate-enricher-cache-asn.ts +12 -0
  274. package/src/modules/wms/subscribers/procurement-goods-receipt-created.ts +238 -0
  275. package/src/modules/wms/subscribers/putaway-completed-reevaluate-reservations.ts +21 -0
  276. package/src/modules/wms/widgets/injection/sales-order-stock-context/widget.client.tsx +63 -13
@@ -0,0 +1,1169 @@
1
+ import type { CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
2
+ import { registerCommand } from '@open-mercato/shared/lib/commands'
3
+ import { emitCrudSideEffects } from '@open-mercato/shared/lib/commands/helpers'
4
+ import { extractUndoPayload } from '@open-mercato/shared/lib/commands/undo'
5
+ import { LockMode } from '@mikro-orm/core'
6
+ import type { EntityManager } from '@mikro-orm/postgresql'
7
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
8
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
9
+ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
10
+ import { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
11
+ import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
12
+ import type { JsonValue } from '@open-mercato/shared/lib/json'
13
+ import {
14
+ InventoryBalance,
15
+ PutawayTask,
16
+ Warehouse,
17
+ WarehouseLocation,
18
+ type PutawayTaskStatus,
19
+ } from '../data/entities'
20
+ import {
21
+ putawayTaskAssignSchema,
22
+ putawayTaskCancelSchema,
23
+ putawayTaskCompleteSchema,
24
+ putawayTaskCreateFromBalanceSchema,
25
+ putawayTaskCreateSchema,
26
+ putawayTaskStartSchema,
27
+ putawayTaskUpdateSchema,
28
+ type InventoryMoveInput,
29
+ type PutawayTaskAssignInput,
30
+ type PutawayTaskCancelInput,
31
+ type PutawayTaskCompleteInput,
32
+ type PutawayTaskCreateFromBalanceInput,
33
+ type PutawayTaskCreateInput,
34
+ type PutawayTaskStartInput,
35
+ type PutawayTaskUpdateInput,
36
+ } from '../data/validators'
37
+ import { emitWmsEvent } from '../events'
38
+ import {
39
+ assertPutawayAssignable,
40
+ assertPutawayCancellable,
41
+ computeUncommittedPutawaySourceQuantity,
42
+ hasUncommittedPutawaySourceQuantity,
43
+ assertPutawayCompleteAuthorized,
44
+ assertPutawayCompletable,
45
+ assertPutawayConfirmedQuantity,
46
+ assertPutawayDeletable,
47
+ assertPutawayLifecycleFieldsForbidden,
48
+ assertPutawayStartable,
49
+ assertPutawayTargetLocationType,
50
+ buildPutawayCompleteReferenceId,
51
+ putawayResidualQuantity,
52
+ } from '../lib/putaway'
53
+ import {
54
+ applyInventoryMoveInTransaction,
55
+ emitInventoryMoveSideEffects,
56
+ findPutawayCompleteMovementByReference,
57
+ type InventoryMoveMutationResult,
58
+ } from './inventory-actions'
59
+ import {
60
+ ensureOrganizationScope,
61
+ ensureTenantScope,
62
+ putawayTaskCrudEvents,
63
+ putawayTaskCrudIndexer,
64
+ requireId,
65
+ toNumericString,
66
+ WMS_INVENTORY_BALANCE_RESOURCE,
67
+ WMS_PUTAWAY_TASK_RESOURCE,
68
+ } from './shared'
69
+
70
+ type Scope = { tenantId: string; organizationId: string }
71
+
72
+ type PutawayTaskSnapshot = {
73
+ id: string
74
+ organizationId: string
75
+ tenantId: string
76
+ warehouseId: string
77
+ sourceLocationId: string
78
+ targetLocationId: string | null
79
+ catalogVariantId: string
80
+ lotId: string | null
81
+ quantity: string
82
+ status: PutawayTaskStatus
83
+ assignedTo: string | null
84
+ priority: number
85
+ metadata: JsonValue | null
86
+ createdAt: string
87
+ updatedAt: string
88
+ }
89
+
90
+ function resolveScope(
91
+ ctx: CommandRuntimeContext,
92
+ fallback?: { tenantId?: string | null; organizationId?: string | null },
93
+ ): Scope {
94
+ const tenantId = fallback?.tenantId ?? ctx.auth?.tenantId ?? null
95
+ const organizationId = fallback?.organizationId ?? ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null
96
+ if (!tenantId || !organizationId) {
97
+ throw new CrudHttpError(400, { error: 'Tenant and organization scope are required.' })
98
+ }
99
+ return { tenantId, organizationId }
100
+ }
101
+
102
+ function resolveEm(ctx: CommandRuntimeContext): EntityManager {
103
+ return (ctx.container.resolve('em') as EntityManager).fork()
104
+ }
105
+
106
+ type RbacServiceLike = {
107
+ userHasAllFeatures?: (
108
+ userId: string,
109
+ features: string[],
110
+ scope: { tenantId: string | null; organizationId: string | null },
111
+ ) => Promise<boolean>
112
+ }
113
+
114
+ async function resolvePutawayCompleteAuthorization(
115
+ ctx: CommandRuntimeContext,
116
+ scope: Scope,
117
+ assignedTo: string | null | undefined,
118
+ ): Promise<void> {
119
+ const actorUserId = typeof ctx.auth?.sub === 'string' ? ctx.auth.sub : null
120
+ let canManagePutaway = false
121
+ let canAdjustInventory = false
122
+ if (actorUserId) {
123
+ try {
124
+ const rbacService = ctx.container.resolve('rbacService') as RbacServiceLike
125
+ if (typeof rbacService.userHasAllFeatures === 'function') {
126
+ ;[canManagePutaway, canAdjustInventory] = await Promise.all([
127
+ rbacService.userHasAllFeatures(actorUserId, ['wms.manage_putaway'], scope),
128
+ rbacService.userHasAllFeatures(actorUserId, ['wms.adjust_inventory'], scope),
129
+ ])
130
+ }
131
+ } catch {
132
+ // Fail closed when rbac is unavailable — do not fall back to raw auth feature tokens.
133
+ }
134
+ }
135
+ assertPutawayCompleteAuthorized({
136
+ canManagePutaway,
137
+ canAdjustInventory,
138
+ actorUserId,
139
+ assignedTo,
140
+ })
141
+ }
142
+
143
+ function toJsonValue(value: Record<string, unknown> | null | undefined): JsonValue | null | undefined {
144
+ if (value === undefined) return undefined
145
+ return (value ?? null) as JsonValue | null
146
+ }
147
+
148
+ function iso(value: Date | string): string {
149
+ if (value instanceof Date) return value.toISOString()
150
+ return new Date(value).toISOString()
151
+ }
152
+
153
+ function snapshotPutawayTask(record: PutawayTask): PutawayTaskSnapshot {
154
+ const warehouseId = typeof record.warehouse === 'string' ? record.warehouse : record.warehouse.id
155
+ return {
156
+ id: record.id,
157
+ organizationId: record.organizationId,
158
+ tenantId: record.tenantId,
159
+ warehouseId,
160
+ sourceLocationId: record.sourceLocationId,
161
+ targetLocationId: record.targetLocationId ?? null,
162
+ catalogVariantId: record.catalogVariantId,
163
+ lotId: record.lotId ?? null,
164
+ quantity: record.quantity,
165
+ status: record.status,
166
+ assignedTo: record.assignedTo ?? null,
167
+ priority: Number(record.priority),
168
+ metadata: (record.metadata ?? null) as JsonValue | null,
169
+ createdAt: iso(record.createdAt),
170
+ updatedAt: iso(record.updatedAt),
171
+ }
172
+ }
173
+
174
+ async function loadPutawayTask(
175
+ em: EntityManager,
176
+ ctx: CommandRuntimeContext,
177
+ id: string,
178
+ ): Promise<PutawayTask> {
179
+ const task = await findOneWithDecryption(
180
+ em,
181
+ PutawayTask,
182
+ { id, deletedAt: null },
183
+ undefined,
184
+ resolveScope(ctx),
185
+ )
186
+ if (!task) throw new CrudHttpError(404, { error: 'Putaway task not found.' })
187
+ ensureTenantScope(ctx, task.tenantId)
188
+ ensureOrganizationScope(ctx, task.organizationId)
189
+ return task
190
+ }
191
+
192
+ /**
193
+ * Load putaway task under PESSIMISTIC_WRITE inside an open TX, then run `fn`.
194
+ * Re-assert lifecycle status inside `fn` after the lock — unlocked read → mutate
195
+ * races with complete under READ COMMITTED (cancel can overwrite `done` and clear
196
+ * putawayKey, letting ASN recreate putaway for already-moved stock).
197
+ */
198
+ async function withLockedPutawayTask<T>(
199
+ em: EntityManager,
200
+ ctx: CommandRuntimeContext,
201
+ id: string,
202
+ scope: Scope,
203
+ fn: (task: PutawayTask, trx: EntityManager) => Promise<T>,
204
+ ): Promise<T> {
205
+ return em.transactional(async (trx) => {
206
+ const task = await findOneWithDecryption(
207
+ trx,
208
+ PutawayTask,
209
+ { id, deletedAt: null },
210
+ { lockMode: LockMode.PESSIMISTIC_WRITE },
211
+ scope,
212
+ )
213
+ if (!task) throw new CrudHttpError(404, { error: 'Putaway task not found.' })
214
+ ensureTenantScope(ctx, task.tenantId)
215
+ ensureOrganizationScope(ctx, task.organizationId)
216
+ await enforceCommandOptimisticLockWithGuards(ctx.container, {
217
+ resourceKind: WMS_PUTAWAY_TASK_RESOURCE,
218
+ resourceId: task.id,
219
+ current: task.updatedAt,
220
+ request: ctx.request ?? null,
221
+ })
222
+ return fn(task, trx)
223
+ })
224
+ }
225
+
226
+ async function requireWarehouse(em: EntityManager, ctx: CommandRuntimeContext, warehouseId: string, scope: Scope) {
227
+ const warehouse = await findOneWithDecryption(
228
+ em,
229
+ Warehouse,
230
+ { id: warehouseId, deletedAt: null },
231
+ undefined,
232
+ scope,
233
+ )
234
+ if (!warehouse) throw new CrudHttpError(404, { error: 'Warehouse not found.' })
235
+ ensureTenantScope(ctx, warehouse.tenantId)
236
+ ensureOrganizationScope(ctx, warehouse.organizationId)
237
+ return warehouse
238
+ }
239
+
240
+ async function requireActiveLocation(
241
+ em: EntityManager,
242
+ ctx: CommandRuntimeContext,
243
+ locationId: string,
244
+ warehouseId: string,
245
+ scope: Scope,
246
+ ) {
247
+ const location = await findOneWithDecryption(
248
+ em,
249
+ WarehouseLocation,
250
+ { id: locationId, deletedAt: null },
251
+ undefined,
252
+ scope,
253
+ )
254
+ if (!location) throw new CrudHttpError(404, { error: 'Warehouse location not found.' })
255
+ ensureTenantScope(ctx, location.tenantId)
256
+ ensureOrganizationScope(ctx, location.organizationId)
257
+ const locationWarehouseId = typeof location.warehouse === 'string' ? location.warehouse : location.warehouse.id
258
+ if (locationWarehouseId !== warehouseId) {
259
+ throw new CrudHttpError(422, { error: 'invalid_location' })
260
+ }
261
+ if (!location.isActive) {
262
+ throw new CrudHttpError(422, { error: 'inactive_location' })
263
+ }
264
+ return location
265
+ }
266
+
267
+ function toNumber(value: unknown): number {
268
+ if (typeof value === 'number' && Number.isFinite(value)) return value
269
+ if (typeof value === 'string' && value.trim().length > 0) {
270
+ const parsed = Number(value)
271
+ if (Number.isFinite(parsed)) return parsed
272
+ }
273
+ return 0
274
+ }
275
+
276
+ async function requireAvailableBalanceQuantity(
277
+ em: EntityManager,
278
+ ctx: CommandRuntimeContext,
279
+ input: {
280
+ warehouseId: string
281
+ sourceLocationId: string
282
+ catalogVariantId: string
283
+ lotId?: string | null
284
+ serialNumber?: string | null
285
+ quantity: number
286
+ },
287
+ scope: Scope,
288
+ options?: { lock?: boolean; excludeTaskId?: string },
289
+ ) {
290
+ const serialNumber = input.serialNumber?.trim() ? input.serialNumber.trim() : null
291
+ const balance = await findOneWithDecryption(
292
+ em,
293
+ InventoryBalance,
294
+ {
295
+ warehouse: input.warehouseId,
296
+ location: input.sourceLocationId,
297
+ catalogVariantId: input.catalogVariantId,
298
+ lot: input.lotId ?? null,
299
+ serialNumber,
300
+ deletedAt: null,
301
+ },
302
+ options?.lock ? { lockMode: LockMode.PESSIMISTIC_WRITE } : undefined,
303
+ scope,
304
+ )
305
+ if (!balance) {
306
+ throw new CrudHttpError(409, { error: 'insufficient_stock' })
307
+ }
308
+ ensureTenantScope(ctx, balance.tenantId)
309
+ ensureOrganizationScope(ctx, balance.organizationId)
310
+
311
+ // Subtract open/in-progress putaway commitments so concurrent create-from-balance
312
+ // cannot oversubscribe the same staging bucket (TOCTOU). When updating an
313
+ // existing task, exclude it so its new quantity is compared against peers only.
314
+ const openTasks = await findWithDecryption(
315
+ em,
316
+ PutawayTask,
317
+ {
318
+ warehouse: input.warehouseId,
319
+ sourceLocationId: input.sourceLocationId,
320
+ catalogVariantId: input.catalogVariantId,
321
+ lotId: input.lotId ?? null,
322
+ serialNumber,
323
+ status: { $in: ['open', 'in_progress'] },
324
+ deletedAt: null,
325
+ },
326
+ undefined,
327
+ scope,
328
+ )
329
+ const remaining = computeUncommittedPutawaySourceQuantity({
330
+ quantityOnHand: toNumber(balance.quantityOnHand),
331
+ quantityReserved: toNumber(balance.quantityReserved),
332
+ quantityAllocated: toNumber(balance.quantityAllocated),
333
+ openPutawayQuantities: openTasks
334
+ .filter((task) => !(options?.excludeTaskId && task.id === options.excludeTaskId))
335
+ .map((task) => toNumber(task.quantity)),
336
+ })
337
+ if (!hasUncommittedPutawaySourceQuantity(remaining, input.quantity)) {
338
+ throw new CrudHttpError(409, { error: 'insufficient_stock' })
339
+ }
340
+ return balance
341
+ }
342
+
343
+ async function emitPutawaySideEffects(
344
+ ctx: CommandRuntimeContext,
345
+ tasks: Array<{ entity: PutawayTask; action: 'created' | 'updated' | 'deleted' }>,
346
+ ) {
347
+ const dataEngine = ctx.container.resolve('dataEngine') as DataEngine
348
+ for (const entry of tasks) {
349
+ await emitCrudSideEffects({
350
+ dataEngine,
351
+ action: entry.action,
352
+ entity: entry.entity,
353
+ identifiers: {
354
+ id: entry.entity.id,
355
+ organizationId: entry.entity.organizationId,
356
+ tenantId: entry.entity.tenantId,
357
+ },
358
+ events: putawayTaskCrudEvents,
359
+ indexer: putawayTaskCrudIndexer,
360
+ })
361
+ }
362
+ }
363
+
364
+ async function buildCrudLog(
365
+ ctx: CommandRuntimeContext,
366
+ input: { tenantId?: string | null; organizationId?: string | null; id?: string | null } | undefined,
367
+ resultId: string | null,
368
+ actionKey: string,
369
+ fallbackLabel: string,
370
+ ) {
371
+ const { translate } = await resolveTranslations()
372
+ return {
373
+ actionLabel: translate(actionKey, fallbackLabel),
374
+ resourceKind: WMS_PUTAWAY_TASK_RESOURCE,
375
+ resourceId: resultId ?? input?.id ?? null,
376
+ tenantId: input?.tenantId ?? ctx.auth?.tenantId ?? null,
377
+ organizationId: input?.organizationId ?? ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
378
+ }
379
+ }
380
+
381
+ type PutawaySourceBucket = {
382
+ warehouseId: string
383
+ sourceLocationId: string
384
+ catalogVariantId: string
385
+ lotId: string | null
386
+ serialNumber: string | null
387
+ }
388
+
389
+ function readPutawaySourceBucket(task: PutawayTask): PutawaySourceBucket {
390
+ const warehouseId = typeof task.warehouse === 'string' ? task.warehouse : task.warehouse.id
391
+ const serialNumber = task.serialNumber?.trim() ?? ''
392
+ return {
393
+ warehouseId,
394
+ sourceLocationId: task.sourceLocationId,
395
+ catalogVariantId: task.catalogVariantId,
396
+ lotId: task.lotId ?? null,
397
+ serialNumber: serialNumber.length > 0 ? serialNumber : null,
398
+ }
399
+ }
400
+
401
+ function samePutawaySourceBucket(left: PutawaySourceBucket, right: PutawaySourceBucket): boolean {
402
+ return (
403
+ left.warehouseId === right.warehouseId &&
404
+ left.sourceLocationId === right.sourceLocationId &&
405
+ left.catalogVariantId === right.catalogVariantId &&
406
+ (left.lotId ?? null) === (right.lotId ?? null) &&
407
+ (left.serialNumber ?? null) === (right.serialNumber ?? null)
408
+ )
409
+ }
410
+
411
+ async function assertResultingPutawayCapacity(
412
+ em: EntityManager,
413
+ ctx: CommandRuntimeContext,
414
+ scope: Scope,
415
+ input: {
416
+ previous: PutawaySourceBucket
417
+ next: PutawaySourceBucket
418
+ previousQuantity: number
419
+ nextQuantity: number
420
+ excludeTaskId: string
421
+ },
422
+ ): Promise<void> {
423
+ const bucketChanged = !samePutawaySourceBucket(input.previous, input.next)
424
+ const quantityIncreased = input.nextQuantity > input.previousQuantity + 0.000001
425
+ if (!bucketChanged && !quantityIncreased) return
426
+ await requireAvailableBalanceQuantity(
427
+ em,
428
+ ctx,
429
+ {
430
+ warehouseId: input.next.warehouseId,
431
+ sourceLocationId: input.next.sourceLocationId,
432
+ catalogVariantId: input.next.catalogVariantId,
433
+ lotId: input.next.lotId,
434
+ serialNumber: input.next.serialNumber,
435
+ quantity: input.nextQuantity,
436
+ },
437
+ scope,
438
+ { lock: true, excludeTaskId: input.excludeTaskId },
439
+ )
440
+ }
441
+
442
+ async function createOpenPutawayTask(
443
+ em: EntityManager,
444
+ ctx: CommandRuntimeContext,
445
+ input: PutawayTaskCreateInput & { putawayKey?: string | null; serialNumber?: string | null },
446
+ ): Promise<PutawayTask> {
447
+ const scope = resolveScope(ctx, input)
448
+ await requireWarehouse(em, ctx, input.warehouseId, scope)
449
+ await requireActiveLocation(em, ctx, input.sourceLocationId, input.warehouseId, scope)
450
+ if (input.targetLocationId) {
451
+ await requireActiveLocation(em, ctx, input.targetLocationId, input.warehouseId, scope)
452
+ }
453
+ const task = em.create(PutawayTask, {
454
+ organizationId: scope.organizationId,
455
+ tenantId: scope.tenantId,
456
+ warehouse: em.getReference(Warehouse, input.warehouseId),
457
+ sourceLocationId: input.sourceLocationId,
458
+ targetLocationId: input.targetLocationId ?? null,
459
+ catalogVariantId: input.catalogVariantId,
460
+ lotId: input.lotId ?? null,
461
+ serialNumber: input.serialNumber?.trim() ? input.serialNumber.trim() : null,
462
+ quantity: toNumericString(input.quantity),
463
+ status: 'open',
464
+ assignedTo: input.assignedTo ?? null,
465
+ priority: input.priority ?? 5,
466
+ putawayKey: input.putawayKey ?? null,
467
+ metadata: toJsonValue(input.metadata) ?? null,
468
+ })
469
+ await em.persist(task).flush()
470
+ return task
471
+ }
472
+
473
+ async function ensureResidualPutawayTask(
474
+ em: EntityManager,
475
+ ctx: CommandRuntimeContext,
476
+ input: {
477
+ scope: Scope
478
+ warehouseId: string
479
+ parent: PutawayTask
480
+ residual: number
481
+ },
482
+ ): Promise<void> {
483
+ const residualKey = `wms:putaway-residual:${input.parent.id}`
484
+ const existing = await findOneWithDecryption(
485
+ em,
486
+ PutawayTask,
487
+ {
488
+ organizationId: input.scope.organizationId,
489
+ tenantId: input.scope.tenantId,
490
+ putawayKey: residualKey,
491
+ deletedAt: null,
492
+ status: { $in: ['open', 'in_progress', 'done'] },
493
+ },
494
+ undefined,
495
+ input.scope,
496
+ )
497
+ if (existing) return
498
+ try {
499
+ await createOpenPutawayTask(em, ctx, {
500
+ organizationId: input.scope.organizationId,
501
+ tenantId: input.scope.tenantId,
502
+ warehouseId: input.warehouseId,
503
+ sourceLocationId: input.parent.sourceLocationId,
504
+ targetLocationId: null,
505
+ catalogVariantId: input.parent.catalogVariantId,
506
+ lotId: input.parent.lotId,
507
+ serialNumber: input.parent.serialNumber ?? null,
508
+ quantity: input.residual,
509
+ assignedTo: input.parent.assignedTo,
510
+ priority: input.parent.priority,
511
+ putawayKey: residualKey,
512
+ metadata: {
513
+ source: 'putaway_residual',
514
+ parentPutawayTaskId: input.parent.id,
515
+ },
516
+ })
517
+ } catch (error) {
518
+ const code = error && typeof error === 'object' ? (error as { code?: string }).code : undefined
519
+ const name = error && typeof error === 'object' ? (error as { name?: string }).name : undefined
520
+ if (code !== '23505' && name !== 'UniqueConstraintViolationException') throw error
521
+ }
522
+ }
523
+
524
+ const createPutawayTaskCommand: CommandHandler<PutawayTaskCreateInput, { taskId: string }> = {
525
+ id: 'wms.putaway-tasks.create',
526
+ async execute(rawInput, ctx) {
527
+ const input = putawayTaskCreateSchema.parse(rawInput ?? {})
528
+ ensureTenantScope(ctx, input.tenantId)
529
+ ensureOrganizationScope(ctx, input.organizationId)
530
+ const em = resolveEm(ctx)
531
+ const scope = resolveScope(ctx, input)
532
+ // Same staging availability floor as create-from-balance: lock balance and
533
+ // subtract open/in_progress putaway commitments before inserting.
534
+ const task = await em.transactional(async (trx) => {
535
+ await requireAvailableBalanceQuantity(
536
+ trx,
537
+ ctx,
538
+ {
539
+ warehouseId: input.warehouseId,
540
+ sourceLocationId: input.sourceLocationId,
541
+ catalogVariantId: input.catalogVariantId,
542
+ lotId: input.lotId,
543
+ quantity: input.quantity,
544
+ },
545
+ scope,
546
+ { lock: true },
547
+ )
548
+ return createOpenPutawayTask(trx, ctx, input)
549
+ })
550
+ await emitPutawaySideEffects(ctx, [{ entity: task, action: 'created' }])
551
+ void emitWmsEvent('wms.putaway.created', {
552
+ id: task.id,
553
+ warehouseId: input.warehouseId,
554
+ sourceLocationId: task.sourceLocationId,
555
+ targetLocationId: task.targetLocationId ?? null,
556
+ catalogVariantId: task.catalogVariantId,
557
+ quantity: task.quantity,
558
+ tenantId: task.tenantId,
559
+ organizationId: task.organizationId,
560
+ }).catch(() => undefined)
561
+ return { taskId: task.id }
562
+ },
563
+ captureAfter: async (_input, result, ctx) => {
564
+ if (!result?.taskId) return null
565
+ const task = await loadPutawayTask(resolveEm(ctx), ctx, result.taskId)
566
+ return snapshotPutawayTask(task)
567
+ },
568
+ undo: async ({ logEntry, ctx }) => {
569
+ const payload = extractUndoPayload<{ after?: PutawayTaskSnapshot | null }>(logEntry)
570
+ const after = payload?.after
571
+ if (!after?.id) return
572
+ const em = resolveEm(ctx)
573
+ const scope = { tenantId: after.tenantId, organizationId: after.organizationId }
574
+ // Only reverse pristine open creates — soft-deleting done frees putaway_key
575
+ // uniqueness while stock already moved; in_progress/cancelled need lifecycle cmds.
576
+ const task = await withLockedPutawayTask(em, ctx, after.id, scope, async (locked, trx) => {
577
+ if (locked.status !== 'open') {
578
+ throw new CrudHttpError(409, { error: 'invalid_putaway_state' })
579
+ }
580
+ locked.deletedAt = new Date()
581
+ await trx.flush()
582
+ return locked
583
+ })
584
+ await emitPutawaySideEffects(ctx, [{ entity: task, action: 'deleted' }])
585
+ },
586
+ buildLog: async ({ snapshots, input, result, ctx }) => {
587
+ const after = snapshots?.after as PutawayTaskSnapshot | undefined
588
+ return {
589
+ ...(await buildCrudLog(ctx, input, result?.taskId ?? null, 'wms.audit.putaway.create', 'Create putaway task')),
590
+ snapshotAfter: after,
591
+ payload: { undo: { after } },
592
+ }
593
+ },
594
+ }
595
+
596
+ const updatePutawayTaskCommand: CommandHandler<PutawayTaskUpdateInput, { taskId: string }> = {
597
+ id: 'wms.putaway-tasks.update',
598
+ async prepare(rawInput, ctx) {
599
+ const id = requireId((rawInput as { id?: string } | null)?.id, 'Putaway task')
600
+ const task = await loadPutawayTask(resolveEm(ctx), ctx, id)
601
+ return { before: snapshotPutawayTask(task) }
602
+ },
603
+ async execute(rawInput, ctx) {
604
+ assertPutawayLifecycleFieldsForbidden(rawInput)
605
+ const input = putawayTaskUpdateSchema.parse(rawInput ?? {})
606
+ const em = resolveEm(ctx)
607
+ const scope = resolveScope(ctx)
608
+ // Same lock + re-assert as lifecycle cmds — unlocked load races with complete
609
+ // and can rewrite quantity/locations/metadata on a done task.
610
+ const task = await withLockedPutawayTask(em, ctx, input.id, scope, async (locked, trx) => {
611
+ if (locked.status === 'done' || locked.status === 'cancelled') {
612
+ throw new CrudHttpError(409, { error: 'invalid_putaway_state' })
613
+ }
614
+ const previousBucket = readPutawaySourceBucket(locked)
615
+ const nextBucket: PutawaySourceBucket = {
616
+ warehouseId: input.warehouseId ?? previousBucket.warehouseId,
617
+ sourceLocationId: input.sourceLocationId ?? previousBucket.sourceLocationId,
618
+ catalogVariantId: input.catalogVariantId ?? previousBucket.catalogVariantId,
619
+ lotId: input.lotId !== undefined ? input.lotId : previousBucket.lotId,
620
+ serialNumber: previousBucket.serialNumber,
621
+ }
622
+ const previousQty = toNumber(locked.quantity)
623
+ const nextQty = input.quantity !== undefined ? Number(input.quantity) : previousQty
624
+ const taskScope = resolveScope(ctx, {
625
+ tenantId: locked.tenantId,
626
+ organizationId: locked.organizationId,
627
+ })
628
+ if (input.warehouseId) {
629
+ await requireWarehouse(trx, ctx, input.warehouseId, taskScope)
630
+ }
631
+ if (input.sourceLocationId) {
632
+ await requireActiveLocation(trx, ctx, input.sourceLocationId, nextBucket.warehouseId, taskScope)
633
+ }
634
+ if (input.targetLocationId) {
635
+ await requireActiveLocation(trx, ctx, input.targetLocationId, nextBucket.warehouseId, taskScope)
636
+ }
637
+ // Bucket moves oversubscribe even when quantity is omitted or unchanged.
638
+ // Validate before assigning so a rejection leaves the locked row untouched.
639
+ await assertResultingPutawayCapacity(trx, ctx, taskScope, {
640
+ previous: previousBucket,
641
+ next: nextBucket,
642
+ previousQuantity: previousQty,
643
+ nextQuantity: nextQty,
644
+ excludeTaskId: locked.id,
645
+ })
646
+ if (input.warehouseId) {
647
+ locked.warehouse = trx.getReference(Warehouse, input.warehouseId)
648
+ }
649
+ if (input.sourceLocationId) locked.sourceLocationId = input.sourceLocationId
650
+ if (input.targetLocationId !== undefined) locked.targetLocationId = input.targetLocationId
651
+ if (input.catalogVariantId !== undefined) locked.catalogVariantId = input.catalogVariantId
652
+ if (input.lotId !== undefined) locked.lotId = input.lotId
653
+ if (input.quantity !== undefined) locked.quantity = toNumericString(input.quantity)
654
+ if (input.priority !== undefined) locked.priority = input.priority
655
+ if (input.metadata !== undefined) locked.metadata = toJsonValue(input.metadata) ?? null
656
+ await trx.flush()
657
+ return locked
658
+ })
659
+ await emitPutawaySideEffects(ctx, [{ entity: task, action: 'updated' }])
660
+ return { taskId: task.id }
661
+ },
662
+ captureAfter: async (_input, result, ctx) => {
663
+ if (!result?.taskId) return null
664
+ const task = await loadPutawayTask(resolveEm(ctx), ctx, result.taskId)
665
+ return snapshotPutawayTask(task)
666
+ },
667
+ undo: async ({ logEntry, ctx }) => {
668
+ const payload = extractUndoPayload<{ before?: PutawayTaskSnapshot | null }>(logEntry)
669
+ const before = payload?.before
670
+ if (!before?.id) return
671
+ const em = resolveEm(ctx)
672
+ const scope = { tenantId: before.tenantId, organizationId: before.organizationId }
673
+ // Lock + re-assert: refuse reopening done/cancelled (movement already posted).
674
+ const task = await withLockedPutawayTask(em, ctx, before.id, scope, async (locked, trx) => {
675
+ if (locked.status === 'done' || locked.status === 'cancelled') {
676
+ throw new CrudHttpError(409, { error: 'invalid_putaway_state' })
677
+ }
678
+ if (before.status === 'done' || before.status === 'cancelled') {
679
+ throw new CrudHttpError(409, { error: 'invalid_putaway_state' })
680
+ }
681
+ const previousBucket = readPutawaySourceBucket(locked)
682
+ const restoredBucket: PutawaySourceBucket = {
683
+ warehouseId: before.warehouseId,
684
+ sourceLocationId: before.sourceLocationId,
685
+ catalogVariantId: before.catalogVariantId,
686
+ lotId: before.lotId ?? null,
687
+ serialNumber: previousBucket.serialNumber,
688
+ }
689
+ await assertResultingPutawayCapacity(trx, ctx, scope, {
690
+ previous: previousBucket,
691
+ next: restoredBucket,
692
+ previousQuantity: toNumber(locked.quantity),
693
+ nextQuantity: toNumber(before.quantity),
694
+ excludeTaskId: locked.id,
695
+ })
696
+ locked.warehouse = trx.getReference(Warehouse, before.warehouseId)
697
+ locked.sourceLocationId = before.sourceLocationId
698
+ locked.targetLocationId = before.targetLocationId
699
+ locked.catalogVariantId = before.catalogVariantId
700
+ locked.lotId = before.lotId
701
+ locked.quantity = before.quantity
702
+ locked.status = before.status
703
+ locked.assignedTo = before.assignedTo
704
+ locked.priority = before.priority
705
+ locked.metadata = before.metadata
706
+ locked.deletedAt = null
707
+ await trx.flush()
708
+ return locked
709
+ })
710
+ await emitPutawaySideEffects(ctx, [{ entity: task, action: 'updated' }])
711
+ },
712
+ buildLog: async ({ snapshots, input, result, ctx }) => {
713
+ const before = snapshots?.before as PutawayTaskSnapshot | undefined
714
+ const after = snapshots?.after as PutawayTaskSnapshot | undefined
715
+ return {
716
+ ...(await buildCrudLog(ctx, input, result?.taskId ?? null, 'wms.audit.putaway.update', 'Update putaway task')),
717
+ snapshotBefore: before,
718
+ snapshotAfter: after,
719
+ payload: { undo: { before, after } },
720
+ }
721
+ },
722
+ }
723
+
724
+ const deletePutawayTaskCommand: CommandHandler<{ id: string }, { taskId: string }> = {
725
+ id: 'wms.putaway-tasks.delete',
726
+ async prepare(rawInput, ctx) {
727
+ const id = requireId((rawInput as { id?: string } | null)?.id, 'Putaway task')
728
+ const task = await loadPutawayTask(resolveEm(ctx), ctx, id)
729
+ return { before: snapshotPutawayTask(task) }
730
+ },
731
+ async execute(rawInput, ctx) {
732
+ const id = requireId((rawInput as { id?: string } | null)?.id, 'Putaway task')
733
+ const em = resolveEm(ctx)
734
+ const scope = resolveScope(ctx)
735
+ const task = await withLockedPutawayTask(em, ctx, id, scope, async (locked, trx) => {
736
+ // Re-assert after lock — only cancelled is deletable. Done is permanent
737
+ // history (soft-delete would free putaway_key uniqueness and allow ASN
738
+ // receive to recreate an open putaway for already-moved stock).
739
+ assertPutawayDeletable(locked.status)
740
+ locked.deletedAt = new Date()
741
+ await trx.flush()
742
+ return locked
743
+ })
744
+ await emitPutawaySideEffects(ctx, [{ entity: task, action: 'deleted' }])
745
+ return { taskId: task.id }
746
+ },
747
+ undo: async ({ logEntry, ctx }) => {
748
+ const payload = extractUndoPayload<{ before?: PutawayTaskSnapshot | null }>(logEntry)
749
+ const before = payload?.before
750
+ if (!before?.id) return
751
+ const em = resolveEm(ctx)
752
+ const scope = { tenantId: before.tenantId, organizationId: before.organizationId }
753
+ // Soft-deleted rows are excluded from withLockedPutawayTask; lock the
754
+ // deleted row directly and re-assert cancelled before undelete.
755
+ const task = await em.transactional(async (trx) => {
756
+ const locked = await findOneWithDecryption(
757
+ trx,
758
+ PutawayTask,
759
+ { id: before.id },
760
+ { lockMode: LockMode.PESSIMISTIC_WRITE },
761
+ scope,
762
+ )
763
+ if (!locked) return null
764
+ ensureTenantScope(ctx, locked.tenantId)
765
+ ensureOrganizationScope(ctx, locked.organizationId)
766
+ // Only cancelled tasks may be soft-deleted; refuse undelete if status drifted.
767
+ if (locked.status !== 'cancelled') {
768
+ throw new CrudHttpError(409, { error: 'invalid_putaway_state' })
769
+ }
770
+ if (!locked.deletedAt) return locked
771
+ locked.deletedAt = null
772
+ await trx.flush()
773
+ return locked
774
+ })
775
+ if (!task) return
776
+ await emitPutawaySideEffects(ctx, [{ entity: task, action: 'updated' }])
777
+ },
778
+ buildLog: async ({ snapshots, input, result, ctx }) => {
779
+ const before = snapshots?.before as PutawayTaskSnapshot | undefined
780
+ return {
781
+ ...(await buildCrudLog(ctx, input, result?.taskId ?? null, 'wms.audit.putaway.delete', 'Delete putaway task')),
782
+ snapshotBefore: before,
783
+ payload: { undo: { before } },
784
+ }
785
+ },
786
+ }
787
+
788
+ const createPutawayTaskFromBalanceCommand: CommandHandler<
789
+ PutawayTaskCreateFromBalanceInput,
790
+ { taskId: string }
791
+ > = {
792
+ id: 'wms.putaway-tasks.create-from-balance',
793
+ async execute(rawInput, ctx) {
794
+ const input = putawayTaskCreateFromBalanceSchema.parse(rawInput ?? {})
795
+ ensureTenantScope(ctx, input.tenantId)
796
+ ensureOrganizationScope(ctx, input.organizationId)
797
+ const em = resolveEm(ctx)
798
+ const scope = resolveScope(ctx, input)
799
+ // One TX: lock balance row, re-check available minus open putaway commitments,
800
+ // then create — closes the TOCTOU oversubscribe window.
801
+ const task = await em.transactional(async (trx) => {
802
+ await requireAvailableBalanceQuantity(
803
+ trx,
804
+ ctx,
805
+ {
806
+ warehouseId: input.warehouseId,
807
+ sourceLocationId: input.sourceLocationId,
808
+ catalogVariantId: input.catalogVariantId,
809
+ lotId: input.lotId,
810
+ quantity: input.quantity,
811
+ },
812
+ scope,
813
+ { lock: true },
814
+ )
815
+ return createOpenPutawayTask(trx, ctx, {
816
+ organizationId: input.organizationId,
817
+ tenantId: input.tenantId,
818
+ warehouseId: input.warehouseId,
819
+ sourceLocationId: input.sourceLocationId,
820
+ targetLocationId: input.targetLocationId,
821
+ catalogVariantId: input.catalogVariantId,
822
+ lotId: input.lotId,
823
+ quantity: input.quantity,
824
+ priority: input.priority,
825
+ assignedTo: input.assignedTo,
826
+ metadata: {
827
+ ...(input.metadata ?? {}),
828
+ source: 'create_from_balance',
829
+ },
830
+ })
831
+ })
832
+ await emitPutawaySideEffects(ctx, [{ entity: task, action: 'created' }])
833
+ void emitWmsEvent('wms.putaway.created', {
834
+ id: task.id,
835
+ warehouseId: input.warehouseId,
836
+ sourceLocationId: task.sourceLocationId,
837
+ targetLocationId: task.targetLocationId ?? null,
838
+ catalogVariantId: task.catalogVariantId,
839
+ quantity: task.quantity,
840
+ tenantId: task.tenantId,
841
+ organizationId: task.organizationId,
842
+ }).catch(() => undefined)
843
+ return { taskId: task.id }
844
+ },
845
+ buildLog: async ({ input, result, ctx }) =>
846
+ buildCrudLog(
847
+ ctx,
848
+ input,
849
+ result?.taskId ?? null,
850
+ 'wms.audit.putaway.createFromBalance',
851
+ 'Create putaway task from balance',
852
+ ),
853
+ }
854
+
855
+ const assignPutawayTaskCommand: CommandHandler<PutawayTaskAssignInput, { taskId: string }> = {
856
+ id: 'wms.putaway-tasks.assign',
857
+ isUndoable: false,
858
+ async execute(rawInput, ctx) {
859
+ const input = putawayTaskAssignSchema.parse(rawInput ?? {})
860
+ ensureTenantScope(ctx, input.tenantId)
861
+ ensureOrganizationScope(ctx, input.organizationId)
862
+ const em = resolveEm(ctx)
863
+ const scope = resolveScope(ctx, input)
864
+ const task = await withLockedPutawayTask(em, ctx, input.id, scope, async (locked, trx) => {
865
+ assertPutawayAssignable(locked.status)
866
+ locked.assignedTo = input.assignedTo
867
+ await trx.flush()
868
+ return locked
869
+ })
870
+ await emitPutawaySideEffects(ctx, [{ entity: task, action: 'updated' }])
871
+ void emitWmsEvent('wms.putaway.assigned', {
872
+ id: task.id,
873
+ assignedTo: task.assignedTo,
874
+ tenantId: task.tenantId,
875
+ organizationId: task.organizationId,
876
+ }).catch(() => undefined)
877
+ return { taskId: task.id }
878
+ },
879
+ buildLog: async ({ input, result, ctx }) =>
880
+ buildCrudLog(ctx, input, result?.taskId ?? null, 'wms.audit.putaway.assign', 'Assign putaway task'),
881
+ }
882
+
883
+ const startPutawayTaskCommand: CommandHandler<PutawayTaskStartInput, { taskId: string }> = {
884
+ id: 'wms.putaway-tasks.start',
885
+ isUndoable: false,
886
+ async execute(rawInput, ctx) {
887
+ const input = putawayTaskStartSchema.parse(rawInput ?? {})
888
+ ensureTenantScope(ctx, input.tenantId)
889
+ ensureOrganizationScope(ctx, input.organizationId)
890
+ const em = resolveEm(ctx)
891
+ const scope = resolveScope(ctx, input)
892
+ const task = await withLockedPutawayTask(em, ctx, input.id, scope, async (locked, trx) => {
893
+ assertPutawayStartable(locked.status)
894
+ locked.status = 'in_progress'
895
+ await trx.flush()
896
+ return locked
897
+ })
898
+ await emitPutawaySideEffects(ctx, [{ entity: task, action: 'updated' }])
899
+ void emitWmsEvent('wms.putaway.started', {
900
+ id: task.id,
901
+ tenantId: task.tenantId,
902
+ organizationId: task.organizationId,
903
+ }).catch(() => undefined)
904
+ return { taskId: task.id }
905
+ },
906
+ buildLog: async ({ input, result, ctx }) =>
907
+ buildCrudLog(ctx, input, result?.taskId ?? null, 'wms.audit.putaway.start', 'Start putaway task'),
908
+ }
909
+
910
+ const completePutawayTaskCommand: CommandHandler<
911
+ PutawayTaskCompleteInput,
912
+ { taskId: string; movementId: string }
913
+ > = {
914
+ id: 'wms.putaway-tasks.complete',
915
+ // Stock-affecting — align with Phase 1 inventory undo policy.
916
+ isUndoable: false,
917
+ async execute(rawInput, ctx) {
918
+ const input = putawayTaskCompleteSchema.parse(rawInput ?? {})
919
+ ensureTenantScope(ctx, input.tenantId)
920
+ ensureOrganizationScope(ctx, input.organizationId)
921
+ const rootEm = resolveEm(ctx)
922
+ const scope = resolveScope(ctx, input)
923
+ const referenceId = buildPutawayCompleteReferenceId(input.id)
924
+
925
+ type CompleteOutcome = {
926
+ task: PutawayTask
927
+ warehouseId: string
928
+ taskQuantity: number
929
+ moveResult: InventoryMoveMutationResult
930
+ residual: number
931
+ alreadyDone: boolean
932
+ /** False when movement already existed and only status was finalized (retry-safe). */
933
+ emitCompletedEvent: boolean
934
+ }
935
+
936
+ // One TX holds PESSIMISTIC_WRITE on the putaway task through move + status
937
+ // update — nested unlocked commandBus move cannot share that lock.
938
+ const outcome = await rootEm.transactional(async (trx): Promise<CompleteOutcome> => {
939
+ const task = await findOneWithDecryption(
940
+ trx,
941
+ PutawayTask,
942
+ { id: input.id, deletedAt: null },
943
+ { lockMode: LockMode.PESSIMISTIC_WRITE },
944
+ scope,
945
+ )
946
+ if (!task) throw new CrudHttpError(404, { error: 'Putaway task not found.' })
947
+ ensureTenantScope(ctx, task.tenantId)
948
+ ensureOrganizationScope(ctx, task.organizationId)
949
+ await resolvePutawayCompleteAuthorization(ctx, scope, task.assignedTo)
950
+ await enforceCommandOptimisticLockWithGuards(ctx.container, {
951
+ resourceKind: WMS_PUTAWAY_TASK_RESOURCE,
952
+ resourceId: task.id,
953
+ current: task.updatedAt,
954
+ request: ctx.request ?? null,
955
+ })
956
+
957
+ const warehouseId = typeof task.warehouse === 'string' ? task.warehouse : task.warehouse.id
958
+ const taskQuantity = toNumber(task.quantity)
959
+ const existingByRef = await findPutawayCompleteMovementByReference(trx, scope, referenceId)
960
+
961
+ if (task.status === 'done') {
962
+ if (!existingByRef) {
963
+ throw new CrudHttpError(409, { error: 'invalid_putaway_state' })
964
+ }
965
+ const existingQty = toNumber(existingByRef.quantity)
966
+ if (Math.abs(existingQty - input.confirmedQuantity) > 0.000001) {
967
+ throw new CrudHttpError(409, { error: 'putaway_complete_quantity_conflict' })
968
+ }
969
+ return {
970
+ task,
971
+ warehouseId,
972
+ taskQuantity,
973
+ moveResult: {
974
+ movementId: existingByRef.id,
975
+ warehouseId,
976
+ catalogVariantId: task.catalogVariantId,
977
+ quantity: existingQty,
978
+ tenantId: scope.tenantId,
979
+ organizationId: scope.organizationId,
980
+ movementEntity: existingByRef,
981
+ balances: [],
982
+ idempotentReplay: true,
983
+ },
984
+ residual: 0,
985
+ alreadyDone: true,
986
+ emitCompletedEvent: false,
987
+ }
988
+ }
989
+
990
+ assertPutawayCompletable(task.status)
991
+ assertPutawayConfirmedQuantity(taskQuantity, input.confirmedQuantity)
992
+
993
+ if (existingByRef) {
994
+ const existingQty = toNumber(existingByRef.quantity)
995
+ if (Math.abs(existingQty - input.confirmedQuantity) > 0.000001) {
996
+ // Move already posted for this task with a different qty — refuse silent
997
+ // double-move under a qty-dependent idempotency key.
998
+ throw new CrudHttpError(409, { error: 'putaway_complete_quantity_conflict' })
999
+ }
1000
+ // Move succeeded previously; finish status update on this retry.
1001
+ // Suppress wms.putaway.completed — movement already existed; only status is finalized.
1002
+ task.targetLocationId = input.targetLocationId
1003
+ task.status = 'done'
1004
+ task.quantity = toNumericString(input.confirmedQuantity)
1005
+ if (input.lotId) task.lotId = input.lotId
1006
+ await trx.flush()
1007
+ const residual = putawayResidualQuantity(taskQuantity, input.confirmedQuantity)
1008
+ if (residual > 0) {
1009
+ await ensureResidualPutawayTask(trx, ctx, {
1010
+ scope,
1011
+ warehouseId,
1012
+ parent: task,
1013
+ residual,
1014
+ })
1015
+ }
1016
+ return {
1017
+ task,
1018
+ warehouseId,
1019
+ taskQuantity,
1020
+ moveResult: {
1021
+ movementId: existingByRef.id,
1022
+ warehouseId,
1023
+ catalogVariantId: task.catalogVariantId,
1024
+ quantity: existingQty,
1025
+ tenantId: scope.tenantId,
1026
+ organizationId: scope.organizationId,
1027
+ movementEntity: existingByRef,
1028
+ balances: [],
1029
+ idempotentReplay: true,
1030
+ },
1031
+ residual,
1032
+ alreadyDone: false,
1033
+ emitCompletedEvent: false,
1034
+ }
1035
+ }
1036
+
1037
+ await requireActiveLocation(trx, ctx, task.sourceLocationId, warehouseId, scope)
1038
+ const targetLocation = await requireActiveLocation(
1039
+ trx,
1040
+ ctx,
1041
+ input.targetLocationId,
1042
+ warehouseId,
1043
+ scope,
1044
+ )
1045
+ assertPutawayTargetLocationType(targetLocation.type)
1046
+
1047
+ const moveInput: InventoryMoveInput = {
1048
+ organizationId: scope.organizationId,
1049
+ tenantId: scope.tenantId,
1050
+ warehouseId,
1051
+ fromLocationId: task.sourceLocationId,
1052
+ toLocationId: input.targetLocationId,
1053
+ catalogVariantId: task.catalogVariantId,
1054
+ lotId: input.lotId ?? task.lotId ?? undefined,
1055
+ serialNumber: task.serialNumber?.trim() || undefined,
1056
+ quantity: input.confirmedQuantity,
1057
+ type: 'putaway',
1058
+ reason: input.reason ?? 'Putaway task completion',
1059
+ referenceType: 'manual',
1060
+ referenceId,
1061
+ performedBy: input.performedBy,
1062
+ performedAt: input.performedAt,
1063
+ metadata: {
1064
+ ...(input.metadata ?? {}),
1065
+ putawayTaskId: task.id,
1066
+ source: 'putaway_task_complete',
1067
+ },
1068
+ }
1069
+ const moveResult = await applyInventoryMoveInTransaction(trx, ctx, moveInput)
1070
+
1071
+ task.targetLocationId = input.targetLocationId
1072
+ task.status = 'done'
1073
+ task.quantity = toNumericString(input.confirmedQuantity)
1074
+ if (input.lotId) task.lotId = input.lotId
1075
+ await trx.flush()
1076
+
1077
+ const residual = putawayResidualQuantity(taskQuantity, input.confirmedQuantity)
1078
+ if (residual > 0) {
1079
+ await ensureResidualPutawayTask(trx, ctx, {
1080
+ scope,
1081
+ warehouseId,
1082
+ parent: task,
1083
+ residual,
1084
+ })
1085
+ }
1086
+
1087
+ return {
1088
+ task,
1089
+ warehouseId,
1090
+ taskQuantity,
1091
+ moveResult,
1092
+ residual,
1093
+ alreadyDone: false,
1094
+ emitCompletedEvent: true,
1095
+ }
1096
+ })
1097
+
1098
+ if (!outcome.alreadyDone) {
1099
+ await emitPutawaySideEffects(ctx, [{ entity: outcome.task, action: 'updated' }])
1100
+ }
1101
+ await emitInventoryMoveSideEffects(ctx, outcome.moveResult)
1102
+
1103
+ if (outcome.emitCompletedEvent) {
1104
+ void emitWmsEvent('wms.putaway.completed', {
1105
+ id: outcome.task.id,
1106
+ movementId: outcome.moveResult.movementId,
1107
+ warehouseId: outcome.warehouseId,
1108
+ sourceLocationId: outcome.task.sourceLocationId,
1109
+ targetLocationId: outcome.task.targetLocationId,
1110
+ catalogVariantId: outcome.task.catalogVariantId,
1111
+ quantity: toNumericString(input.confirmedQuantity),
1112
+ tenantId: outcome.task.tenantId,
1113
+ organizationId: outcome.task.organizationId,
1114
+ }).catch(() => undefined)
1115
+ }
1116
+
1117
+ return { taskId: outcome.task.id, movementId: outcome.moveResult.movementId }
1118
+ },
1119
+ buildLog: async ({ input, result, ctx }) => ({
1120
+ ...(await buildCrudLog(
1121
+ ctx,
1122
+ input,
1123
+ result?.taskId ?? null,
1124
+ 'wms.audit.putaway.complete',
1125
+ 'Complete putaway task',
1126
+ )),
1127
+ context: { cacheAliases: [WMS_INVENTORY_BALANCE_RESOURCE, WMS_PUTAWAY_TASK_RESOURCE] },
1128
+ }),
1129
+ }
1130
+
1131
+ const cancelPutawayTaskCommand: CommandHandler<PutawayTaskCancelInput, { taskId: string }> = {
1132
+ id: 'wms.putaway-tasks.cancel',
1133
+ isUndoable: false,
1134
+ async execute(rawInput, ctx) {
1135
+ const input = putawayTaskCancelSchema.parse(rawInput ?? {})
1136
+ ensureTenantScope(ctx, input.tenantId)
1137
+ ensureOrganizationScope(ctx, input.organizationId)
1138
+ const em = resolveEm(ctx)
1139
+ const scope = resolveScope(ctx, input)
1140
+ const task = await withLockedPutawayTask(em, ctx, input.id, scope, async (locked, trx) => {
1141
+ // Re-assert after PESSIMISTIC_WRITE — concurrent complete may have set `done`.
1142
+ assertPutawayCancellable(locked.status)
1143
+ locked.status = 'cancelled'
1144
+ // Free the unique putaway_key so ASN receive can recreate for the same attempt
1145
+ // without unique violation / stranded staging stock.
1146
+ locked.putawayKey = null
1147
+ await trx.flush()
1148
+ return locked
1149
+ })
1150
+ await emitPutawaySideEffects(ctx, [{ entity: task, action: 'updated' }])
1151
+ void emitWmsEvent('wms.putaway.cancelled', {
1152
+ id: task.id,
1153
+ tenantId: task.tenantId,
1154
+ organizationId: task.organizationId,
1155
+ }).catch(() => undefined)
1156
+ return { taskId: task.id }
1157
+ },
1158
+ buildLog: async ({ input, result, ctx }) =>
1159
+ buildCrudLog(ctx, input, result?.taskId ?? null, 'wms.audit.putaway.cancel', 'Cancel putaway task'),
1160
+ }
1161
+
1162
+ registerCommand(createPutawayTaskCommand)
1163
+ registerCommand(updatePutawayTaskCommand)
1164
+ registerCommand(deletePutawayTaskCommand)
1165
+ registerCommand(createPutawayTaskFromBalanceCommand)
1166
+ registerCommand(assignPutawayTaskCommand)
1167
+ registerCommand(startPutawayTaskCommand)
1168
+ registerCommand(completePutawayTaskCommand)
1169
+ registerCommand(cancelPutawayTaskCommand)