@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,2122 @@
1
+ import type { CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
2
+ import { registerCommand } from '@open-mercato/shared/lib/commands'
3
+ import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
4
+ import { emitCrudSideEffects } from '@open-mercato/shared/lib/commands/helpers'
5
+ import { extractUndoPayload } from '@open-mercato/shared/lib/commands/undo'
6
+ import { LockMode } from '@mikro-orm/core'
7
+ import type { EntityManager } from '@mikro-orm/postgresql'
8
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
9
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
10
+ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
11
+ import { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
12
+ import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
13
+ import type { JsonValue } from '@open-mercato/shared/lib/json'
14
+ import {
15
+ Asn,
16
+ InventoryBalance,
17
+ PutawayTask,
18
+ ReceivingLine,
19
+ Warehouse,
20
+ WarehouseLocation,
21
+ type AsnStatus,
22
+ type ReceivingLineQcStatus,
23
+ } from '../data/entities'
24
+ import { requireVendorIfPresent as requireVendorIfPresentLookup } from '../lib/asnVendor'
25
+ import {
26
+ asnCloseSchema,
27
+ asnCreateSchema,
28
+ asnReceiveLineSchema,
29
+ asnUpdateSchema,
30
+ receivingLineCreateSchema,
31
+ receivingLineUpdateSchema,
32
+ type AsnCloseInput,
33
+ type AsnCreateInput,
34
+ type AsnReceiveLineInput,
35
+ type AsnUpdateInput,
36
+ type InventoryReceiveInput,
37
+ type ReceivingLineCreateInput,
38
+ type ReceivingLineUpdateInput,
39
+ } from '../data/validators'
40
+ import { emitWmsEvent } from '../events'
41
+ import {
42
+ assertAsnQcTransition,
43
+ assertReceivingLineLifecycleFieldsForbidden,
44
+ buildAsnReceivePutawayKey,
45
+ buildAsnReceiveReferenceId,
46
+ hasAsnDeleteBlockingLineActivity,
47
+ isAsnCloseable,
48
+ resolveAsnReceiveAttempt,
49
+ resolvePutawayQuantityForAlreadyAtTargetRetry,
50
+ shouldEnsurePutawayOnAlreadyAtTarget,
51
+ shouldRecreatePutawayOnAlreadyAtTarget,
52
+ shouldWriteStockOnQcPass,
53
+ } from '../lib/asnReceiving'
54
+ import {
55
+ computeUncommittedPutawaySourceQuantity,
56
+ selectCoveringOpenPutawayTask,
57
+ } from '../lib/putaway'
58
+ import {
59
+ applyInventoryReceiveInTransaction,
60
+ emitInventoryReceiveSideEffects,
61
+ type InventoryReceiveMutationResult,
62
+ } from './inventory-actions'
63
+ import {
64
+ asnCrudEvents,
65
+ asnCrudIndexer,
66
+ ensureOrganizationScope,
67
+ ensureTenantScope,
68
+ normalizeOptionalString,
69
+ putawayTaskCrudEvents,
70
+ putawayTaskCrudIndexer,
71
+ receivingLineCrudEvents,
72
+ receivingLineCrudIndexer,
73
+ requireId,
74
+ toNumericString,
75
+ WMS_ASN_RESOURCE,
76
+ WMS_INVENTORY_BALANCE_RESOURCE,
77
+ WMS_RECEIVING_LINE_RESOURCE,
78
+ } from './shared'
79
+
80
+ type Scope = { tenantId: string; organizationId: string }
81
+
82
+ type AsnSnapshot = {
83
+ id: string
84
+ organizationId: string
85
+ tenantId: string
86
+ warehouseId: string
87
+ vendorId: string | null
88
+ status: AsnStatus
89
+ expectedAt: string
90
+ referenceNumber: string | null
91
+ notes: string | null
92
+ metadata: JsonValue | null
93
+ createdAt: string
94
+ updatedAt: string
95
+ }
96
+
97
+ type ReceivingLineSnapshot = {
98
+ id: string
99
+ organizationId: string
100
+ tenantId: string
101
+ asnId: string
102
+ catalogVariantId: string
103
+ expectedQty: string
104
+ receivedQty: string
105
+ lotNumber: string | null
106
+ serialNumbers: string[] | null
107
+ qcStatus: ReceivingLineQcStatus
108
+ targetStagingLocationId: string | null
109
+ rejectionReason: string | null
110
+ metadata: JsonValue | null
111
+ createdAt: string
112
+ updatedAt: string
113
+ }
114
+
115
+ function resolveScope(
116
+ ctx: CommandRuntimeContext,
117
+ fallback?: { tenantId?: string | null; organizationId?: string | null },
118
+ ): Scope {
119
+ const tenantId = fallback?.tenantId ?? ctx.auth?.tenantId ?? null
120
+ const organizationId = fallback?.organizationId ?? ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null
121
+ if (!tenantId || !organizationId) {
122
+ throw new CrudHttpError(400, { error: 'Tenant and organization scope are required.' })
123
+ }
124
+ return { tenantId, organizationId }
125
+ }
126
+
127
+ function resolveEm(ctx: CommandRuntimeContext): EntityManager {
128
+ return (ctx.container.resolve('em') as EntityManager).fork()
129
+ }
130
+
131
+ function toNumber(value: unknown): number {
132
+ if (typeof value === 'number' && Number.isFinite(value)) return value
133
+ if (typeof value === 'string' && value.trim().length > 0) {
134
+ const parsed = Number(value)
135
+ if (Number.isFinite(parsed)) return parsed
136
+ }
137
+ return 0
138
+ }
139
+
140
+ function toJsonValue(value: Record<string, unknown> | null | undefined): JsonValue | null | undefined {
141
+ if (value === undefined) return undefined
142
+ return (value ?? null) as JsonValue | null
143
+ }
144
+
145
+ function iso(value: Date | string): string {
146
+ if (value instanceof Date) return value.toISOString()
147
+ return new Date(value).toISOString()
148
+ }
149
+
150
+ function snapshotAsn(record: Asn): AsnSnapshot {
151
+ const warehouseId = typeof record.warehouse === 'string' ? record.warehouse : record.warehouse.id
152
+ return {
153
+ id: record.id,
154
+ organizationId: record.organizationId,
155
+ tenantId: record.tenantId,
156
+ warehouseId,
157
+ vendorId: record.vendorId ?? null,
158
+ status: record.status,
159
+ expectedAt: iso(record.expectedAt),
160
+ referenceNumber: record.referenceNumber ?? null,
161
+ notes: record.notes ?? null,
162
+ metadata: (record.metadata ?? null) as JsonValue | null,
163
+ createdAt: iso(record.createdAt),
164
+ updatedAt: iso(record.updatedAt),
165
+ }
166
+ }
167
+
168
+ function snapshotReceivingLine(record: ReceivingLine): ReceivingLineSnapshot {
169
+ const asnId = typeof record.asn === 'string' ? record.asn : record.asn.id
170
+ return {
171
+ id: record.id,
172
+ organizationId: record.organizationId,
173
+ tenantId: record.tenantId,
174
+ asnId,
175
+ catalogVariantId: record.catalogVariantId,
176
+ expectedQty: record.expectedQty,
177
+ receivedQty: record.receivedQty,
178
+ lotNumber: record.lotNumber ?? null,
179
+ serialNumbers: Array.isArray(record.serialNumbers) ? [...record.serialNumbers] : null,
180
+ qcStatus: record.qcStatus,
181
+ targetStagingLocationId: record.targetStagingLocationId ?? null,
182
+ rejectionReason: record.rejectionReason ?? null,
183
+ metadata: (record.metadata ?? null) as JsonValue | null,
184
+ createdAt: iso(record.createdAt),
185
+ updatedAt: iso(record.updatedAt),
186
+ }
187
+ }
188
+
189
+ async function loadAsn(em: EntityManager, ctx: CommandRuntimeContext, id: string): Promise<Asn> {
190
+ const asn = await findOneWithDecryption(em, Asn, { id, deletedAt: null }, undefined, resolveScope(ctx))
191
+ if (!asn) throw new CrudHttpError(404, { error: 'ASN not found.' })
192
+ ensureTenantScope(ctx, asn.tenantId)
193
+ ensureOrganizationScope(ctx, asn.organizationId)
194
+ return asn
195
+ }
196
+
197
+ /**
198
+ * Load ASN under PESSIMISTIC_WRITE inside an open TX, then run `fn`.
199
+ * Same serialization as receive-line: activity re-check + header mutate /
200
+ * soft-delete must hold the ASN row lock so concurrent receive cannot commit
201
+ * receipt/QC after the check but before flush under READ COMMITTED.
202
+ */
203
+ async function withLockedAsn<T>(
204
+ em: EntityManager,
205
+ ctx: CommandRuntimeContext,
206
+ id: string,
207
+ scope: Scope,
208
+ fn: (asn: Asn, trx: EntityManager) => Promise<T>,
209
+ ): Promise<T> {
210
+ return em.transactional(async (trx) => {
211
+ const asn = await findOneWithDecryption(
212
+ trx,
213
+ Asn,
214
+ { id, deletedAt: null },
215
+ { lockMode: LockMode.PESSIMISTIC_WRITE },
216
+ scope,
217
+ )
218
+ if (!asn) throw new CrudHttpError(404, { error: 'ASN not found.' })
219
+ ensureTenantScope(ctx, asn.tenantId)
220
+ ensureOrganizationScope(ctx, asn.organizationId)
221
+ return fn(asn, trx)
222
+ })
223
+ }
224
+
225
+ /**
226
+ * Lock ASN then receiving line (same order as receive / update / delete execute),
227
+ * then run `fn`. Used by receiving-line create/update/delete undo so concurrent
228
+ * receive cannot post receipt/QC between an unlocked activity check and flush.
229
+ * Returns null when the line is missing (or soft-deleted when `requireActiveLine`).
230
+ */
231
+ async function withLockedAsnReceivingLine<T>(
232
+ em: EntityManager,
233
+ ctx: CommandRuntimeContext,
234
+ lineId: string,
235
+ scope: Scope,
236
+ options: { requireActiveLine: boolean },
237
+ fn: (args: { asn: Asn; line: ReceivingLine; trx: EntityManager }) => Promise<T>,
238
+ ): Promise<T | null> {
239
+ return em.transactional(async (trx) => {
240
+ const unlocked = await findOneWithDecryption(trx, ReceivingLine, { id: lineId }, undefined, scope)
241
+ if (!unlocked) return null
242
+ if (options.requireActiveLine && unlocked.deletedAt) return null
243
+ ensureTenantScope(ctx, unlocked.tenantId)
244
+ ensureOrganizationScope(ctx, unlocked.organizationId)
245
+ const asnId = typeof unlocked.asn === 'string' ? unlocked.asn : unlocked.asn.id
246
+
247
+ const asn = await findOneWithDecryption(
248
+ trx,
249
+ Asn,
250
+ { id: asnId, deletedAt: null },
251
+ { lockMode: LockMode.PESSIMISTIC_WRITE },
252
+ scope,
253
+ )
254
+ if (!asn) throw new CrudHttpError(404, { error: 'ASN not found.' })
255
+ ensureTenantScope(ctx, asn.tenantId)
256
+ ensureOrganizationScope(ctx, asn.organizationId)
257
+
258
+ const locked = await findOneWithDecryption(
259
+ trx,
260
+ ReceivingLine,
261
+ { id: lineId },
262
+ { lockMode: LockMode.PESSIMISTIC_WRITE },
263
+ scope,
264
+ )
265
+ if (!locked) return null
266
+ if (options.requireActiveLine && locked.deletedAt) return null
267
+ ensureTenantScope(ctx, locked.tenantId)
268
+ ensureOrganizationScope(ctx, locked.organizationId)
269
+ return fn({ asn, line: locked, trx })
270
+ })
271
+ }
272
+
273
+ async function loadReceivingLine(
274
+ em: EntityManager,
275
+ ctx: CommandRuntimeContext,
276
+ id: string,
277
+ ): Promise<ReceivingLine> {
278
+ const line = await findOneWithDecryption(
279
+ em,
280
+ ReceivingLine,
281
+ { id, deletedAt: null },
282
+ undefined,
283
+ resolveScope(ctx),
284
+ )
285
+ if (!line) throw new CrudHttpError(404, { error: 'Receiving line not found.' })
286
+ ensureTenantScope(ctx, line.tenantId)
287
+ ensureOrganizationScope(ctx, line.organizationId)
288
+ return line
289
+ }
290
+
291
+ async function requireWarehouse(em: EntityManager, ctx: CommandRuntimeContext, warehouseId: string, scope: Scope) {
292
+ const warehouse = await findOneWithDecryption(
293
+ em,
294
+ Warehouse,
295
+ { id: warehouseId, deletedAt: null },
296
+ undefined,
297
+ scope,
298
+ )
299
+ if (!warehouse) throw new CrudHttpError(404, { error: 'Warehouse not found.' })
300
+ ensureTenantScope(ctx, warehouse.tenantId)
301
+ ensureOrganizationScope(ctx, warehouse.organizationId)
302
+ return warehouse
303
+ }
304
+
305
+ async function requireStagingLocation(
306
+ em: EntityManager,
307
+ ctx: CommandRuntimeContext,
308
+ locationId: string,
309
+ warehouseId: string,
310
+ scope: Scope,
311
+ ) {
312
+ const location = await findOneWithDecryption(
313
+ em,
314
+ WarehouseLocation,
315
+ { id: locationId, deletedAt: null },
316
+ undefined,
317
+ scope,
318
+ )
319
+ if (!location) throw new CrudHttpError(404, { error: 'Warehouse location not found.' })
320
+ ensureTenantScope(ctx, location.tenantId)
321
+ ensureOrganizationScope(ctx, location.organizationId)
322
+ const locationWarehouseId = typeof location.warehouse === 'string' ? location.warehouse : location.warehouse.id
323
+ if (locationWarehouseId !== warehouseId) {
324
+ throw new CrudHttpError(422, { error: 'invalid_location' })
325
+ }
326
+ if (location.type !== 'staging' && location.type !== 'dock') {
327
+ throw new CrudHttpError(422, { error: 'invalid_staging_location' })
328
+ }
329
+ if (!location.isActive) {
330
+ throw new CrudHttpError(422, { error: 'inactive_location' })
331
+ }
332
+ return location
333
+ }
334
+
335
+ async function requireVendorIfPresent(
336
+ ctx: CommandRuntimeContext,
337
+ vendorId: string | null | undefined,
338
+ scope: Scope,
339
+ ) {
340
+ await requireVendorIfPresentLookup(ctx.container, vendorId, scope)
341
+ }
342
+
343
+ function isUniqueConstraintError(error: unknown): boolean {
344
+ if (!error || typeof error !== 'object') return false
345
+ const code = (error as { code?: string }).code
346
+ if (code === '23505') return true
347
+ const name = (error as { name?: string }).name
348
+ return name === 'UniqueConstraintViolationException'
349
+ }
350
+
351
+ async function findExistingPutawayForReceipt(
352
+ em: EntityManager,
353
+ scope: Scope,
354
+ putawayKey: string,
355
+ ): Promise<PutawayTask | null> {
356
+ return findOneWithDecryption(
357
+ em,
358
+ PutawayTask,
359
+ {
360
+ organizationId: scope.organizationId,
361
+ tenantId: scope.tenantId,
362
+ putawayKey,
363
+ deletedAt: null,
364
+ status: { $in: ['open', 'in_progress', 'done'] },
365
+ },
366
+ undefined,
367
+ scope,
368
+ )
369
+ }
370
+
371
+ /** Cancel nulls putaway_key; recover qty/lot via metadata.putawayKey (not a capped scan). */
372
+ async function findCancelledPutawayForReceipt(
373
+ em: EntityManager,
374
+ scope: Scope,
375
+ putawayKey: string,
376
+ ): Promise<PutawayTask | null> {
377
+ const rows = await em.getConnection().execute<Array<{ id: string }>>(
378
+ `select id
379
+ from wms_putaway_tasks
380
+ where organization_id = ?
381
+ and tenant_id = ?
382
+ and deleted_at is null
383
+ and status = 'cancelled'
384
+ and metadata->>'putawayKey' = ?
385
+ order by updated_at desc
386
+ limit 1`,
387
+ [scope.organizationId, scope.tenantId, putawayKey],
388
+ )
389
+ const id = rows[0]?.id
390
+ if (!id) return null
391
+ return findOneWithDecryption(
392
+ em,
393
+ PutawayTask,
394
+ { id, deletedAt: null, status: 'cancelled' },
395
+ undefined,
396
+ scope,
397
+ )
398
+ }
399
+
400
+ /**
401
+ * Staging floor for already-at-target putaway recreate: on-hand minus
402
+ * reserved/allocated minus open/in_progress putaway commitments. Returns null
403
+ * when there is no balance row (nothing left to put away).
404
+ */
405
+ async function resolveUncommittedStagingPutawayQuantity(
406
+ em: EntityManager,
407
+ scope: Scope,
408
+ input: {
409
+ warehouseId: string
410
+ sourceLocationId: string
411
+ catalogVariantId: string
412
+ lotId: string | null
413
+ serialNumber?: string | null
414
+ },
415
+ options?: { lock?: boolean },
416
+ ): Promise<{ remaining: number; openTasks: PutawayTask[] } | null> {
417
+ const serialNumber = input.serialNumber?.trim() ? input.serialNumber.trim() : null
418
+ // Same row lock as requireAvailableBalanceQuantity(..., { lock: true }). Hold it
419
+ // through the caller's task insert — the lock lives until this transaction ends.
420
+ const balance = await findOneWithDecryption(
421
+ em,
422
+ InventoryBalance,
423
+ {
424
+ warehouse: input.warehouseId,
425
+ location: input.sourceLocationId,
426
+ catalogVariantId: input.catalogVariantId,
427
+ lot: input.lotId ?? null,
428
+ serialNumber,
429
+ deletedAt: null,
430
+ },
431
+ options?.lock ? { lockMode: LockMode.PESSIMISTIC_WRITE } : undefined,
432
+ scope,
433
+ )
434
+ if (!balance) return null
435
+ const openTasks = await findWithDecryption(
436
+ em,
437
+ PutawayTask,
438
+ {
439
+ warehouse: input.warehouseId,
440
+ sourceLocationId: input.sourceLocationId,
441
+ catalogVariantId: input.catalogVariantId,
442
+ lotId: input.lotId ?? null,
443
+ serialNumber,
444
+ status: { $in: ['open', 'in_progress'] },
445
+ deletedAt: null,
446
+ },
447
+ undefined,
448
+ scope,
449
+ )
450
+ return {
451
+ remaining: computeUncommittedPutawaySourceQuantity({
452
+ quantityOnHand: toNumber(balance.quantityOnHand),
453
+ quantityReserved: toNumber(balance.quantityReserved),
454
+ quantityAllocated: toNumber(balance.quantityAllocated),
455
+ openPutawayQuantities: openTasks.map((task) => toNumber(task.quantity)),
456
+ }),
457
+ openTasks,
458
+ }
459
+ }
460
+
461
+ async function findOrCreatePutawayForReceipt(
462
+ em: EntityManager,
463
+ scope: Scope,
464
+ putawayKey: string,
465
+ input: {
466
+ warehouseId: string
467
+ sourceLocationId: string
468
+ catalogVariantId: string
469
+ lotId: string | null
470
+ quantity: number
471
+ serialNumber?: string | null
472
+ asnId: string
473
+ lineId: string
474
+ metadata?: Record<string, unknown> | null
475
+ },
476
+ ): Promise<{ task: PutawayTask; created: boolean }> {
477
+ const existing = await findExistingPutawayForReceipt(em, scope, putawayKey)
478
+ if (existing) return { task: existing, created: false }
479
+ const task = em.create(PutawayTask, {
480
+ organizationId: scope.organizationId,
481
+ tenantId: scope.tenantId,
482
+ warehouse: em.getReference(Warehouse, input.warehouseId),
483
+ sourceLocationId: input.sourceLocationId,
484
+ targetLocationId: null,
485
+ catalogVariantId: input.catalogVariantId,
486
+ lotId: input.lotId,
487
+ serialNumber: input.serialNumber?.trim() ? input.serialNumber.trim() : null,
488
+ quantity: toNumericString(input.quantity),
489
+ status: 'open',
490
+ assignedTo: null,
491
+ priority: 5,
492
+ putawayKey,
493
+ metadata: {
494
+ ...(input.metadata ?? {}),
495
+ asnId: input.asnId,
496
+ receivingLineId: input.lineId,
497
+ putawayKey,
498
+ source: 'asn_receive',
499
+ ...(input.serialNumber?.trim() ? { serialNumber: input.serialNumber.trim() } : {}),
500
+ } as JsonValue,
501
+ })
502
+ try {
503
+ await em.persist(task).flush()
504
+ return { task, created: true }
505
+ } catch (error) {
506
+ if (!isUniqueConstraintError(error)) throw error
507
+ const raced = await findExistingPutawayForReceipt(em, scope, putawayKey)
508
+ if (raced) return { task: raced, created: false }
509
+ throw error
510
+ }
511
+ }
512
+
513
+ async function emitAsnReceivePutawayCreatedSideEffects(
514
+ ctx: CommandRuntimeContext,
515
+ scope: Scope,
516
+ putaway: PutawayTask,
517
+ warehouseId: string,
518
+ ): Promise<void> {
519
+ let de: DataEngine | null = null
520
+ try {
521
+ de = ctx.container.resolve('dataEngine') as DataEngine
522
+ } catch {
523
+ de = null
524
+ }
525
+ if (de) {
526
+ await emitCrudSideEffects({
527
+ dataEngine: de,
528
+ action: 'created',
529
+ entity: putaway,
530
+ identifiers: {
531
+ id: putaway.id,
532
+ organizationId: putaway.organizationId,
533
+ tenantId: putaway.tenantId,
534
+ },
535
+ indexer: putawayTaskCrudIndexer,
536
+ events: putawayTaskCrudEvents,
537
+ })
538
+ }
539
+ void emitWmsEvent('wms.putaway.created', {
540
+ id: putaway.id,
541
+ warehouseId,
542
+ sourceLocationId: putaway.sourceLocationId,
543
+ targetLocationId: putaway.targetLocationId ?? null,
544
+ catalogVariantId: putaway.catalogVariantId,
545
+ quantity: putaway.quantity,
546
+ tenantId: scope.tenantId,
547
+ organizationId: scope.organizationId,
548
+ }).catch(() => undefined)
549
+ }
550
+
551
+ async function buildCrudLog(
552
+ ctx: CommandRuntimeContext,
553
+ input: { tenantId?: string | null; organizationId?: string | null; id?: string | null } | undefined,
554
+ resultId: string | null,
555
+ actionKey: string,
556
+ fallbackLabel: string,
557
+ resourceKind: string,
558
+ ) {
559
+ const { translate } = await resolveTranslations()
560
+ return {
561
+ actionLabel: translate(actionKey, fallbackLabel),
562
+ resourceKind,
563
+ resourceId: resultId ?? input?.id ?? null,
564
+ tenantId: input?.tenantId ?? ctx.auth?.tenantId ?? null,
565
+ organizationId: input?.organizationId ?? ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
566
+ }
567
+ }
568
+
569
+ function assertAsnMutable(asn: Asn) {
570
+ if (asn.status === 'closed' || asn.status === 'received') {
571
+ throw new CrudHttpError(409, { error: 'invalid_receipt_state' })
572
+ }
573
+ }
574
+
575
+ /** Open/in-progress putaway linked to this ASN via metadata.asnId (ASN receive path). */
576
+ async function hasOpenPutawayForAsn(
577
+ em: EntityManager,
578
+ scope: Scope,
579
+ asnId: string,
580
+ ): Promise<boolean> {
581
+ const rows = await em.getConnection().execute<Array<{ hit: number }>>(
582
+ `select 1 as hit
583
+ from wms_putaway_tasks
584
+ where organization_id = ?
585
+ and tenant_id = ?
586
+ and deleted_at is null
587
+ and status in ('open', 'in_progress')
588
+ and metadata->>'asnId' = ?
589
+ limit 1`,
590
+ [scope.organizationId, scope.tenantId, asnId],
591
+ )
592
+ return rows.length > 0
593
+ }
594
+
595
+ function resolveSerialNumbers(input: AsnReceiveLineInput): string[] {
596
+ if (!input.serialNumbers?.length) return []
597
+ return input.serialNumbers.map((value) => value.trim()).filter((value) => value.length > 0)
598
+ }
599
+
600
+ function normalizeStoredSerials(values: string[] | null | undefined): string[] {
601
+ if (!values?.length) return []
602
+ return values.map((value) => value.trim()).filter((value) => value.length > 0)
603
+ }
604
+
605
+ /** Reject repeated serials after trim. Callers must not collapse the list and still advance qty. */
606
+ function assertUniqueSerialNumbers(serialNumbers: string[]): void {
607
+ const seen = new Set<string>()
608
+ for (const serialNumber of serialNumbers) {
609
+ if (seen.has(serialNumber)) {
610
+ throw new CrudHttpError(422, { error: 'duplicate_serial' })
611
+ }
612
+ seen.add(serialNumber)
613
+ }
614
+ }
615
+
616
+ /** A later batch must not receive a serial already stored on a partially received line. */
617
+ function assertIncomingSerialsAreNew(
618
+ incoming: string[],
619
+ stored: string[] | null | undefined,
620
+ ): void {
621
+ const known = new Set(normalizeStoredSerials(stored))
622
+ for (const serialNumber of incoming) {
623
+ if (known.has(serialNumber)) {
624
+ throw new CrudHttpError(422, { error: 'duplicate_serial' })
625
+ }
626
+ }
627
+ }
628
+
629
+ function mergeReceivedSerials(stored: string[] | null | undefined, incoming: string[]): string[] {
630
+ const next = normalizeStoredSerials(stored)
631
+ const seen = new Set(next)
632
+ for (const serialNumber of incoming) {
633
+ if (seen.has(serialNumber)) continue
634
+ seen.add(serialNumber)
635
+ next.push(serialNumber)
636
+ }
637
+ return next
638
+ }
639
+
640
+ function recordReceivedSerials(
641
+ line: ReceivingLine,
642
+ incoming: string[],
643
+ priorReceivedQty: number,
644
+ ): void {
645
+ if (incoming.length === 0) return
646
+ line.serialNumbers =
647
+ priorReceivedQty > 0.000001 ? mergeReceivedSerials(line.serialNumbers, incoming) : incoming
648
+ }
649
+
650
+ async function emitAsnSideEffects(
651
+ ctx: CommandRuntimeContext,
652
+ affected: {
653
+ asns?: Array<{ entity: Asn; action: 'created' | 'updated' | 'deleted' }>
654
+ lines?: Array<{ entity: ReceivingLine; action: 'created' | 'updated' | 'deleted' }>
655
+ },
656
+ ) {
657
+ let de: DataEngine | null = null
658
+ try {
659
+ de = ctx.container.resolve('dataEngine') as DataEngine
660
+ } catch {
661
+ de = null
662
+ }
663
+ if (!de) return
664
+ for (const { entity, action } of affected.asns ?? []) {
665
+ await emitCrudSideEffects({
666
+ dataEngine: de,
667
+ action,
668
+ entity,
669
+ identifiers: {
670
+ id: entity.id,
671
+ organizationId: entity.organizationId,
672
+ tenantId: entity.tenantId,
673
+ },
674
+ indexer: asnCrudIndexer,
675
+ events: asnCrudEvents,
676
+ })
677
+ }
678
+ for (const { entity, action } of affected.lines ?? []) {
679
+ await emitCrudSideEffects({
680
+ dataEngine: de,
681
+ action,
682
+ entity,
683
+ identifiers: {
684
+ id: entity.id,
685
+ organizationId: entity.organizationId,
686
+ tenantId: entity.tenantId,
687
+ },
688
+ indexer: receivingLineCrudIndexer,
689
+ events: receivingLineCrudEvents,
690
+ })
691
+ }
692
+ }
693
+
694
+ const createAsnCommand: CommandHandler<AsnCreateInput, { asnId: string; lineIds: string[] }> = {
695
+ id: 'wms.asns.create',
696
+ async execute(rawInput, ctx) {
697
+ const input = asnCreateSchema.parse(rawInput ?? {})
698
+ ensureTenantScope(ctx, input.tenantId)
699
+ ensureOrganizationScope(ctx, input.organizationId)
700
+ const em = resolveEm(ctx)
701
+ const scope = resolveScope(ctx, input)
702
+ await requireWarehouse(em, ctx, input.warehouseId, scope)
703
+ await requireVendorIfPresent(ctx, input.vendorId, scope)
704
+ // Terminal received/closed are rejected by asnWritableStatusSchema at parse time.
705
+ const status = input.status ?? 'draft'
706
+
707
+ const createdLines: ReceivingLine[] = []
708
+ let asn!: Asn
709
+ await withAtomicFlush(
710
+ em,
711
+ [
712
+ () => {
713
+ asn = em.create(Asn, {
714
+ organizationId: scope.organizationId,
715
+ tenantId: scope.tenantId,
716
+ warehouse: em.getReference(Warehouse, input.warehouseId),
717
+ vendorId: input.vendorId ?? null,
718
+ status,
719
+ expectedAt: input.expectedAt,
720
+ referenceNumber: normalizeOptionalString(input.referenceNumber ?? undefined),
721
+ // sourceKey is server/system-only (auth null). Authenticated HTTP creates ignore it.
722
+ sourceKey:
723
+ ctx.auth == null
724
+ ? normalizeOptionalString(input.sourceKey ?? undefined)
725
+ : null,
726
+ notes: normalizeOptionalString(input.notes ?? undefined),
727
+ metadata: toJsonValue(input.metadata ?? null) ?? null,
728
+ })
729
+ em.persist(asn)
730
+ },
731
+ () => {
732
+ for (const lineInput of input.lines ?? []) {
733
+ const line = em.create(ReceivingLine, {
734
+ organizationId: scope.organizationId,
735
+ tenantId: scope.tenantId,
736
+ asn,
737
+ catalogVariantId: lineInput.catalogVariantId,
738
+ expectedQty: toNumericString(lineInput.expectedQty),
739
+ receivedQty: '0',
740
+ lotNumber: normalizeOptionalString(lineInput.lotNumber ?? undefined),
741
+ serialNumbers: lineInput.serialNumbers ?? null,
742
+ qcStatus: 'pending',
743
+ targetStagingLocationId: lineInput.targetStagingLocationId ?? null,
744
+ metadata: toJsonValue(lineInput.metadata ?? null) ?? null,
745
+ })
746
+ em.persist(line)
747
+ createdLines.push(line)
748
+ }
749
+ },
750
+ ],
751
+ { transaction: true, label: 'wms.asns.create' },
752
+ )
753
+
754
+ await emitAsnSideEffects(ctx, {
755
+ asns: [{ entity: asn, action: 'created' }],
756
+ lines: createdLines.map((entity) => ({ entity, action: 'created' as const })),
757
+ })
758
+
759
+ void emitWmsEvent('wms.asn.created', {
760
+ id: asn.id,
761
+ warehouseId: input.warehouseId,
762
+ vendorId: input.vendorId ?? null,
763
+ status: asn.status,
764
+ tenantId: scope.tenantId,
765
+ organizationId: scope.organizationId,
766
+ }).catch(() => undefined)
767
+
768
+ return { asnId: asn.id, lineIds: createdLines.map((line) => line.id) }
769
+ },
770
+ captureAfter: async (_input, result, ctx) => {
771
+ if (!result?.asnId) return null
772
+ const em = resolveEm(ctx)
773
+ const asn = await loadAsn(em, ctx, result.asnId)
774
+ return snapshotAsn(asn)
775
+ },
776
+ undo: async ({ logEntry, ctx }) => {
777
+ const payload = extractUndoPayload<{
778
+ after?: AsnSnapshot | null
779
+ lineIds?: string[] | null
780
+ }>(logEntry)
781
+ const after = payload?.after
782
+ if (!after?.id) return
783
+ const em = resolveEm(ctx)
784
+ const scope = { tenantId: after.tenantId, organizationId: after.organizationId }
785
+ // Idempotent when already soft-deleted (unlocked probe); lock only the active path.
786
+ const existing = await findOneWithDecryption(em, Asn, { id: after.id }, undefined, scope)
787
+ if (!existing || existing.deletedAt) return
788
+ // Hold ASN row lock through activity re-check + soft-delete (same race as delete execute).
789
+ const locked = await withLockedAsn(em, ctx, after.id, scope, async (asn, trx) => {
790
+ // Refuse soft-delete undo after receipt/complete (same floor as delete execute).
791
+ assertAsnMutable(asn)
792
+ // Lines added after create share this ASN. The create log's lineIds are only
793
+ // the original set — guard and cascade every active line under the ASN lock.
794
+ const lines = await findWithDecryption(
795
+ trx,
796
+ ReceivingLine,
797
+ { asn: asn.id, deletedAt: null },
798
+ undefined,
799
+ scope,
800
+ )
801
+ if (hasAsnDeleteBlockingLineActivity(lines)) {
802
+ throw new CrudHttpError(409, { error: 'invalid_receipt_state' })
803
+ }
804
+ const deletedAt = new Date()
805
+ asn.deletedAt = deletedAt
806
+ for (const line of lines) {
807
+ line.deletedAt = deletedAt
808
+ }
809
+ await trx.flush()
810
+ return { asn, lines }
811
+ })
812
+ await emitAsnSideEffects(ctx, {
813
+ asns: [{ entity: locked.asn, action: 'deleted' }],
814
+ lines: locked.lines.map((entity) => ({ entity, action: 'deleted' as const })),
815
+ })
816
+ },
817
+ buildLog: async ({ input, result, ctx, snapshots }) => {
818
+ const base = await buildCrudLog(
819
+ ctx,
820
+ input,
821
+ result?.asnId ?? null,
822
+ 'wms.audit.asn.create',
823
+ 'Create ASN',
824
+ WMS_ASN_RESOURCE,
825
+ )
826
+ const after = snapshots?.after as AsnSnapshot | undefined
827
+ return {
828
+ ...base,
829
+ snapshotAfter: after,
830
+ payload: { undo: { after, lineIds: result?.lineIds ?? [] } },
831
+ }
832
+ },
833
+ }
834
+
835
+ const updateAsnCommand: CommandHandler<AsnUpdateInput, { asnId: string }> = {
836
+ id: 'wms.asns.update',
837
+ async prepare(rawInput, ctx) {
838
+ const id = requireId((rawInput as { id?: string } | null)?.id, 'ASN')
839
+ const em = resolveEm(ctx)
840
+ const asn = await loadAsn(em, ctx, id)
841
+ // Same floor as receive / line CRUD / delete — received/closed headers are immutable.
842
+ assertAsnMutable(asn)
843
+ return { before: snapshotAsn(asn) }
844
+ },
845
+ async execute(rawInput, ctx) {
846
+ const input = asnUpdateSchema.parse(rawInput ?? {})
847
+ ensureTenantScope(ctx, input.tenantId ?? ctx.auth?.tenantId ?? '')
848
+ if (input.organizationId) ensureOrganizationScope(ctx, input.organizationId)
849
+ const em = resolveEm(ctx)
850
+ // Lock ASN through activity re-check + header mutate so concurrent receive-line
851
+ // cannot post receipt/QC after the check but before flush.
852
+ const asn = await withLockedAsn(em, ctx, input.id, resolveScope(ctx), async (asn, trx) => {
853
+ // Refuse received/closed before any field write so PUT cannot demote status or
854
+ // move warehouseId after stock/lines have been posted.
855
+ assertAsnMutable(asn)
856
+ await enforceCommandOptimisticLockWithGuards(ctx.container, {
857
+ resourceKind: WMS_ASN_RESOURCE,
858
+ resourceId: asn.id,
859
+ current: asn.updatedAt,
860
+ request: ctx.request ?? null,
861
+ })
862
+ const scope = resolveScope(ctx, {
863
+ tenantId: asn.tenantId,
864
+ organizationId: asn.organizationId,
865
+ })
866
+ // Same floor as delete: after any line receipt/QC, refuse header edits
867
+ // (warehouseId would desync staged stock / later receives).
868
+ const lines = await findWithDecryption(
869
+ trx,
870
+ ReceivingLine,
871
+ { asn: asn.id, deletedAt: null },
872
+ undefined,
873
+ scope,
874
+ )
875
+ if (hasAsnDeleteBlockingLineActivity(lines)) {
876
+ throw new CrudHttpError(409, { error: 'invalid_receipt_state' })
877
+ }
878
+ if (input.warehouseId) {
879
+ await requireWarehouse(trx, ctx, input.warehouseId, scope)
880
+ asn.warehouse = trx.getReference(Warehouse, input.warehouseId)
881
+ }
882
+ if (input.vendorId !== undefined) {
883
+ await requireVendorIfPresent(ctx, input.vendorId, scope)
884
+ asn.vendorId = input.vendorId
885
+ }
886
+ if (input.status !== undefined) {
887
+ // Terminal received/closed are rejected by asnWritableStatusSchema at parse time.
888
+ asn.status = input.status
889
+ }
890
+ if (input.expectedAt !== undefined) asn.expectedAt = input.expectedAt
891
+ if (input.referenceNumber !== undefined) {
892
+ asn.referenceNumber = normalizeOptionalString(input.referenceNumber ?? undefined)
893
+ }
894
+ if (input.notes !== undefined) asn.notes = normalizeOptionalString(input.notes ?? undefined)
895
+ if (input.metadata !== undefined) asn.metadata = toJsonValue(input.metadata) ?? null
896
+ await trx.flush()
897
+ return asn
898
+ })
899
+ await emitAsnSideEffects(ctx, { asns: [{ entity: asn, action: 'updated' }] })
900
+ void emitWmsEvent('wms.asn.updated', {
901
+ id: asn.id,
902
+ status: asn.status,
903
+ tenantId: asn.tenantId,
904
+ organizationId: asn.organizationId,
905
+ }).catch(() => undefined)
906
+ return { asnId: asn.id }
907
+ },
908
+ captureAfter: async (_input, result, ctx) => {
909
+ if (!result?.asnId) return null
910
+ const em = resolveEm(ctx)
911
+ const asn = await loadAsn(em, ctx, result.asnId)
912
+ return snapshotAsn(asn)
913
+ },
914
+ undo: async ({ logEntry, ctx }) => {
915
+ const payload = extractUndoPayload<{ before?: AsnSnapshot | null }>(logEntry)
916
+ const before = payload?.before
917
+ if (!before?.id) return
918
+ const em = resolveEm(ctx)
919
+ const scope = { tenantId: before.tenantId, organizationId: before.organizationId }
920
+ // Hold ASN row lock through activity re-check + snapshot restore (same race as execute).
921
+ const asn = await withLockedAsn(em, ctx, before.id, scope, async (asn, trx) => {
922
+ // Refuse restoring header snapshot after receipt/complete — would demote
923
+ // received/closed → draft/in_transit and/or rewrite warehouseId past stock.
924
+ assertAsnMutable(asn)
925
+ const lines = await findWithDecryption(
926
+ trx,
927
+ ReceivingLine,
928
+ { asn: asn.id, deletedAt: null },
929
+ undefined,
930
+ scope,
931
+ )
932
+ if (hasAsnDeleteBlockingLineActivity(lines)) {
933
+ throw new CrudHttpError(409, { error: 'invalid_receipt_state' })
934
+ }
935
+ asn.warehouse = trx.getReference(Warehouse, before.warehouseId)
936
+ asn.vendorId = before.vendorId
937
+ asn.status = before.status
938
+ asn.expectedAt = new Date(before.expectedAt)
939
+ asn.referenceNumber = before.referenceNumber
940
+ asn.notes = before.notes
941
+ asn.metadata = before.metadata
942
+ asn.deletedAt = null
943
+ await trx.flush()
944
+ return asn
945
+ })
946
+ await emitAsnSideEffects(ctx, { asns: [{ entity: asn, action: 'updated' }] })
947
+ },
948
+ buildLog: async ({ input, result, ctx, snapshots }) => {
949
+ const base = await buildCrudLog(
950
+ ctx,
951
+ input,
952
+ result?.asnId ?? null,
953
+ 'wms.audit.asn.update',
954
+ 'Update ASN',
955
+ WMS_ASN_RESOURCE,
956
+ )
957
+ const before = snapshots?.before as AsnSnapshot | undefined
958
+ const after = snapshots?.after as AsnSnapshot | undefined
959
+ return {
960
+ ...base,
961
+ snapshotBefore: before,
962
+ snapshotAfter: after,
963
+ payload: { undo: { before, after } },
964
+ }
965
+ },
966
+ }
967
+
968
+ const deleteAsnCommand: CommandHandler<{ id: string }, { asnId: string; lineIds: string[] }> = {
969
+ id: 'wms.asns.delete',
970
+ async prepare(rawInput, ctx) {
971
+ const id = requireId((rawInput as { id?: string } | null)?.id, 'ASN')
972
+ const em = resolveEm(ctx)
973
+ const asn = await loadAsn(em, ctx, id)
974
+ const lines = await findWithDecryption(
975
+ em,
976
+ ReceivingLine,
977
+ { asn: asn.id, deletedAt: null },
978
+ undefined,
979
+ { tenantId: asn.tenantId, organizationId: asn.organizationId },
980
+ )
981
+ return {
982
+ before: snapshotAsn(asn),
983
+ lineIds: lines.map((line) => line.id),
984
+ }
985
+ },
986
+ async execute(rawInput, ctx) {
987
+ const id = requireId((rawInput as { id?: string } | null)?.id, 'ASN')
988
+ const em = resolveEm(ctx)
989
+ // Lock ASN through activity/putaway re-check + soft-delete so concurrent
990
+ // receive-line cannot stage stock after the check but before flush.
991
+ const locked = await withLockedAsn(em, ctx, id, resolveScope(ctx), async (asn, trx) => {
992
+ assertAsnMutable(asn)
993
+ await enforceCommandOptimisticLockWithGuards(ctx.container, {
994
+ resourceKind: WMS_ASN_RESOURCE,
995
+ resourceId: asn.id,
996
+ current: asn.updatedAt,
997
+ request: ctx.request ?? null,
998
+ })
999
+ const scope = { tenantId: asn.tenantId, organizationId: asn.organizationId }
1000
+ // Re-query active lines under the ASN lock (create also locks ASN) so a line
1001
+ // inserted after prepare cannot remain active on a soft-deleted ASN.
1002
+ const lines = await findWithDecryption(
1003
+ trx,
1004
+ ReceivingLine,
1005
+ { asn: asn.id, deletedAt: null },
1006
+ undefined,
1007
+ scope,
1008
+ )
1009
+ // Refuse when any line has receipt/QC activity (same floor as receiving-line delete)
1010
+ // so soft-delete cannot free source_key while staging stock/movements remain.
1011
+ if (hasAsnDeleteBlockingLineActivity(lines)) {
1012
+ throw new CrudHttpError(409, { error: 'invalid_receipt_state' })
1013
+ }
1014
+ if (await hasOpenPutawayForAsn(trx, scope, asn.id)) {
1015
+ throw new CrudHttpError(409, { error: 'asn_has_open_putaway' })
1016
+ }
1017
+ const deletedAt = new Date()
1018
+ asn.deletedAt = deletedAt
1019
+ for (const line of lines) {
1020
+ line.deletedAt = deletedAt
1021
+ }
1022
+ await trx.flush()
1023
+ return { asn, lines }
1024
+ })
1025
+ await emitAsnSideEffects(ctx, {
1026
+ asns: [{ entity: locked.asn, action: 'deleted' }],
1027
+ lines: locked.lines.map((entity) => ({ entity, action: 'deleted' as const })),
1028
+ })
1029
+ return { asnId: locked.asn.id, lineIds: locked.lines.map((line) => line.id) }
1030
+ },
1031
+ undo: async ({ logEntry, ctx }) => {
1032
+ const payload = extractUndoPayload<{
1033
+ before?: AsnSnapshot | null
1034
+ lineIds?: string[] | null
1035
+ }>(logEntry)
1036
+ const before = payload?.before
1037
+ if (!before?.id) return
1038
+ const em = resolveEm(ctx)
1039
+ const scope = { tenantId: before.tenantId, organizationId: before.organizationId }
1040
+ const asn = await findOneWithDecryption(em, Asn, { id: before.id }, undefined, scope)
1041
+ if (!asn) return
1042
+ // Soft-delete frees (organization_id, source_key); procurement may create a
1043
+ // replacement. Refuse undelete when another active ASN already holds the key.
1044
+ const sourceKey = normalizeOptionalString(asn.sourceKey ?? undefined)
1045
+ if (sourceKey) {
1046
+ const clash = await findOneWithDecryption(
1047
+ em,
1048
+ Asn,
1049
+ {
1050
+ organizationId: scope.organizationId,
1051
+ tenantId: scope.tenantId,
1052
+ sourceKey,
1053
+ deletedAt: null,
1054
+ id: { $ne: asn.id },
1055
+ },
1056
+ undefined,
1057
+ scope,
1058
+ )
1059
+ if (clash) {
1060
+ throw new CrudHttpError(409, { error: 'asn_source_key_conflict' })
1061
+ }
1062
+ }
1063
+ asn.deletedAt = null
1064
+ const lineIds = Array.isArray(payload?.lineIds) ? payload.lineIds : []
1065
+ const restoredLines: ReceivingLine[] = []
1066
+ if (lineIds.length > 0) {
1067
+ const lines = await findWithDecryption(
1068
+ em,
1069
+ ReceivingLine,
1070
+ { id: { $in: lineIds }, asn: asn.id },
1071
+ undefined,
1072
+ scope,
1073
+ )
1074
+ for (const line of lines) {
1075
+ line.deletedAt = null
1076
+ restoredLines.push(line)
1077
+ }
1078
+ }
1079
+ try {
1080
+ await em.flush()
1081
+ } catch (error) {
1082
+ if (sourceKey && isUniqueConstraintError(error)) {
1083
+ throw new CrudHttpError(409, { error: 'asn_source_key_conflict' })
1084
+ }
1085
+ throw error
1086
+ }
1087
+ await emitAsnSideEffects(ctx, {
1088
+ asns: [{ entity: asn, action: 'updated' }],
1089
+ lines: restoredLines.map((entity) => ({ entity, action: 'updated' as const })),
1090
+ })
1091
+ },
1092
+ buildLog: async ({ input, result, ctx, snapshots }) => {
1093
+ const base = await buildCrudLog(
1094
+ ctx,
1095
+ input as { id?: string },
1096
+ result?.asnId ?? null,
1097
+ 'wms.audit.asn.delete',
1098
+ 'Delete ASN',
1099
+ WMS_ASN_RESOURCE,
1100
+ )
1101
+ const before = snapshots?.before as AsnSnapshot | undefined
1102
+ // Execute re-queries under the ASN lock. Persist that set, not prepare's snapshot:
1103
+ // line create does not bump the ASN version, so a child inserted after prepare is
1104
+ // deleted here and must be the undo payload.
1105
+ const lineIds = result?.lineIds ?? []
1106
+ return {
1107
+ ...base,
1108
+ snapshotBefore: before,
1109
+ payload: { undo: { before, lineIds } },
1110
+ }
1111
+ },
1112
+ }
1113
+
1114
+ const createReceivingLineCommand: CommandHandler<ReceivingLineCreateInput, { lineId: string }> = {
1115
+ id: 'wms.receiving-lines.create',
1116
+ async execute(rawInput, ctx) {
1117
+ const input = receivingLineCreateSchema.parse(rawInput ?? {})
1118
+ ensureTenantScope(ctx, input.tenantId)
1119
+ ensureOrganizationScope(ctx, input.organizationId)
1120
+ const em = resolveEm(ctx)
1121
+ // Hold ASN row lock through mutable assert + insert so concurrent ASN delete
1122
+ // cannot soft-delete the header after an unlocked snapshot and leave an orphan line.
1123
+ const line = await withLockedAsn(em, ctx, input.asnId, resolveScope(ctx, input), async (asn, trx) => {
1124
+ assertAsnMutable(asn)
1125
+ const created = trx.create(ReceivingLine, {
1126
+ organizationId: asn.organizationId,
1127
+ tenantId: asn.tenantId,
1128
+ asn,
1129
+ catalogVariantId: input.catalogVariantId,
1130
+ expectedQty: toNumericString(input.expectedQty),
1131
+ receivedQty: '0',
1132
+ lotNumber: normalizeOptionalString(input.lotNumber ?? undefined),
1133
+ serialNumbers: input.serialNumbers ?? null,
1134
+ qcStatus: 'pending',
1135
+ targetStagingLocationId: input.targetStagingLocationId ?? null,
1136
+ metadata: toJsonValue(input.metadata ?? null) ?? null,
1137
+ })
1138
+ trx.persist(created)
1139
+ await trx.flush()
1140
+ return created
1141
+ })
1142
+ await emitAsnSideEffects(ctx, { lines: [{ entity: line, action: 'created' }] })
1143
+ return { lineId: line.id }
1144
+ },
1145
+ captureAfter: async (_input, result, ctx) => {
1146
+ if (!result?.lineId) return null
1147
+ const em = resolveEm(ctx)
1148
+ const line = await loadReceivingLine(em, ctx, result.lineId)
1149
+ return snapshotReceivingLine(line)
1150
+ },
1151
+ undo: async ({ logEntry, ctx }) => {
1152
+ const payload = extractUndoPayload<{ after?: ReceivingLineSnapshot | null }>(logEntry)
1153
+ const after = payload?.after
1154
+ if (!after?.id) return
1155
+ const em = resolveEm(ctx)
1156
+ const scope = { tenantId: after.tenantId, organizationId: after.organizationId }
1157
+ // Lock ASN then line (same order as delete execute) and re-check activity
1158
+ // under the locks so concurrent receive cannot post stock then lose the line.
1159
+ const line = await withLockedAsnReceivingLine(
1160
+ em,
1161
+ ctx,
1162
+ after.id,
1163
+ scope,
1164
+ { requireActiveLine: true },
1165
+ async ({ asn, line, trx }) => {
1166
+ assertAsnMutable(asn)
1167
+ // Same floor as line delete — refuse soft-delete after receipt/QC.
1168
+ if (hasAsnDeleteBlockingLineActivity([line])) {
1169
+ throw new CrudHttpError(409, { error: 'invalid_receipt_state' })
1170
+ }
1171
+ line.deletedAt = new Date()
1172
+ await trx.flush()
1173
+ return line
1174
+ },
1175
+ )
1176
+ if (!line) return
1177
+ await emitAsnSideEffects(ctx, { lines: [{ entity: line, action: 'deleted' }] })
1178
+ },
1179
+ buildLog: async ({ input, result, ctx, snapshots }) => {
1180
+ const base = await buildCrudLog(
1181
+ ctx,
1182
+ input,
1183
+ result?.lineId ?? null,
1184
+ 'wms.audit.receivingLine.create',
1185
+ 'Create receiving line',
1186
+ WMS_RECEIVING_LINE_RESOURCE,
1187
+ )
1188
+ const after = snapshots?.after as ReceivingLineSnapshot | undefined
1189
+ return { ...base, snapshotAfter: after, payload: { undo: { after } } }
1190
+ },
1191
+ }
1192
+
1193
+ const updateReceivingLineCommand: CommandHandler<ReceivingLineUpdateInput, { lineId: string }> = {
1194
+ id: 'wms.receiving-lines.update',
1195
+ async prepare(rawInput, ctx) {
1196
+ const id = requireId((rawInput as { id?: string } | null)?.id, 'Receiving line')
1197
+ const em = resolveEm(ctx)
1198
+ const line = await loadReceivingLine(em, ctx, id)
1199
+ return { before: snapshotReceivingLine(line) }
1200
+ },
1201
+ async execute(rawInput, ctx) {
1202
+ assertReceivingLineLifecycleFieldsForbidden(rawInput)
1203
+ const input = receivingLineUpdateSchema.parse(rawInput ?? {})
1204
+ const rootEm = resolveEm(ctx)
1205
+
1206
+ // Lock ASN then line (same order as receive) and re-check activity under
1207
+ // the locks so concurrent receive cannot post stock while we rewrite variant/qty/staging.
1208
+ const line = await rootEm.transactional(async (trx) => {
1209
+ const unlocked = await findOneWithDecryption(
1210
+ trx,
1211
+ ReceivingLine,
1212
+ { id: input.id, deletedAt: null },
1213
+ undefined,
1214
+ resolveScope(ctx),
1215
+ )
1216
+ if (!unlocked) throw new CrudHttpError(404, { error: 'Receiving line not found.' })
1217
+ ensureTenantScope(ctx, unlocked.tenantId)
1218
+ ensureOrganizationScope(ctx, unlocked.organizationId)
1219
+ const asnId = typeof unlocked.asn === 'string' ? unlocked.asn : unlocked.asn.id
1220
+ const scope = { tenantId: unlocked.tenantId, organizationId: unlocked.organizationId }
1221
+
1222
+ const asn = await findOneWithDecryption(
1223
+ trx,
1224
+ Asn,
1225
+ { id: asnId, deletedAt: null },
1226
+ { lockMode: LockMode.PESSIMISTIC_WRITE },
1227
+ scope,
1228
+ )
1229
+ if (!asn) throw new CrudHttpError(404, { error: 'ASN not found.' })
1230
+ ensureTenantScope(ctx, asn.tenantId)
1231
+ ensureOrganizationScope(ctx, asn.organizationId)
1232
+ assertAsnMutable(asn)
1233
+
1234
+ const locked = await findOneWithDecryption(
1235
+ trx,
1236
+ ReceivingLine,
1237
+ { id: input.id, deletedAt: null },
1238
+ { lockMode: LockMode.PESSIMISTIC_WRITE },
1239
+ scope,
1240
+ )
1241
+ if (!locked) throw new CrudHttpError(404, { error: 'Receiving line not found.' })
1242
+ ensureTenantScope(ctx, locked.tenantId)
1243
+ ensureOrganizationScope(ctx, locked.organizationId)
1244
+ await enforceCommandOptimisticLockWithGuards(ctx.container, {
1245
+ resourceKind: WMS_RECEIVING_LINE_RESOURCE,
1246
+ resourceId: locked.id,
1247
+ current: locked.updatedAt,
1248
+ request: ctx.request ?? null,
1249
+ })
1250
+ // Same floor as receiving-line delete — refuse mutating lines after receipt/QC.
1251
+ if (hasAsnDeleteBlockingLineActivity([locked])) {
1252
+ throw new CrudHttpError(409, { error: 'invalid_receipt_state' })
1253
+ }
1254
+ if (input.catalogVariantId !== undefined) locked.catalogVariantId = input.catalogVariantId
1255
+ if (input.expectedQty !== undefined) locked.expectedQty = toNumericString(input.expectedQty)
1256
+ if (input.lotNumber !== undefined) {
1257
+ locked.lotNumber = normalizeOptionalString(input.lotNumber ?? undefined)
1258
+ }
1259
+ if (input.serialNumbers !== undefined) locked.serialNumbers = input.serialNumbers
1260
+ if (input.targetStagingLocationId !== undefined) {
1261
+ locked.targetStagingLocationId = input.targetStagingLocationId
1262
+ }
1263
+ if (input.metadata !== undefined) locked.metadata = toJsonValue(input.metadata) ?? null
1264
+ await trx.flush()
1265
+ return locked
1266
+ })
1267
+
1268
+ await emitAsnSideEffects(ctx, { lines: [{ entity: line, action: 'updated' }] })
1269
+ return { lineId: line.id }
1270
+ },
1271
+ captureAfter: async (_input, result, ctx) => {
1272
+ if (!result?.lineId) return null
1273
+ const em = resolveEm(ctx)
1274
+ const line = await loadReceivingLine(em, ctx, result.lineId)
1275
+ return snapshotReceivingLine(line)
1276
+ },
1277
+ undo: async ({ logEntry, ctx }) => {
1278
+ const payload = extractUndoPayload<{ before?: ReceivingLineSnapshot | null }>(logEntry)
1279
+ const before = payload?.before
1280
+ if (!before?.id) return
1281
+ const em = resolveEm(ctx)
1282
+ const scope = { tenantId: before.tenantId, organizationId: before.organizationId }
1283
+ // Lock ASN then line (same order as execute/receive) and re-check activity
1284
+ // under the locks so concurrent receive cannot commit then undo demote qty/QC.
1285
+ const line = await withLockedAsnReceivingLine(
1286
+ em,
1287
+ ctx,
1288
+ before.id,
1289
+ scope,
1290
+ { requireActiveLine: false },
1291
+ async ({ asn, line, trx }) => {
1292
+ assertAsnMutable(asn)
1293
+ // Refuse restoring snapshot that would clear receipt/QC while stock remains
1294
+ // (same floor as receiving-line delete).
1295
+ if (hasAsnDeleteBlockingLineActivity([line])) {
1296
+ throw new CrudHttpError(409, { error: 'invalid_receipt_state' })
1297
+ }
1298
+ if (hasAsnDeleteBlockingLineActivity([before])) {
1299
+ throw new CrudHttpError(409, { error: 'invalid_receipt_state' })
1300
+ }
1301
+ line.catalogVariantId = before.catalogVariantId
1302
+ line.expectedQty = before.expectedQty
1303
+ line.receivedQty = before.receivedQty
1304
+ line.lotNumber = before.lotNumber
1305
+ line.serialNumbers = before.serialNumbers
1306
+ line.qcStatus = before.qcStatus
1307
+ line.targetStagingLocationId = before.targetStagingLocationId
1308
+ line.rejectionReason = before.rejectionReason
1309
+ line.metadata = before.metadata
1310
+ line.deletedAt = null
1311
+ await trx.flush()
1312
+ return line
1313
+ },
1314
+ )
1315
+ if (!line) return
1316
+ await emitAsnSideEffects(ctx, { lines: [{ entity: line, action: 'updated' }] })
1317
+ },
1318
+ buildLog: async ({ input, result, ctx, snapshots }) => {
1319
+ const base = await buildCrudLog(
1320
+ ctx,
1321
+ input,
1322
+ result?.lineId ?? null,
1323
+ 'wms.audit.receivingLine.update',
1324
+ 'Update receiving line',
1325
+ WMS_RECEIVING_LINE_RESOURCE,
1326
+ )
1327
+ const before = snapshots?.before as ReceivingLineSnapshot | undefined
1328
+ const after = snapshots?.after as ReceivingLineSnapshot | undefined
1329
+ return {
1330
+ ...base,
1331
+ snapshotBefore: before,
1332
+ snapshotAfter: after,
1333
+ payload: { undo: { before, after } },
1334
+ }
1335
+ },
1336
+ }
1337
+
1338
+ const deleteReceivingLineCommand: CommandHandler<{ id: string }, { lineId: string }> = {
1339
+ id: 'wms.receiving-lines.delete',
1340
+ async prepare(rawInput, ctx) {
1341
+ const id = requireId((rawInput as { id?: string } | null)?.id, 'Receiving line')
1342
+ const em = resolveEm(ctx)
1343
+ const line = await loadReceivingLine(em, ctx, id)
1344
+ return { before: snapshotReceivingLine(line) }
1345
+ },
1346
+ async execute(rawInput, ctx) {
1347
+ const id = requireId((rawInput as { id?: string } | null)?.id, 'Receiving line')
1348
+ const rootEm = resolveEm(ctx)
1349
+
1350
+ // Lock ASN then line (same order as update/receive) and re-check activity under
1351
+ // the locks so concurrent QC-pass cannot post stock between the guard and flush.
1352
+ const line = await rootEm.transactional(async (trx) => {
1353
+ const unlocked = await findOneWithDecryption(
1354
+ trx,
1355
+ ReceivingLine,
1356
+ { id, deletedAt: null },
1357
+ undefined,
1358
+ resolveScope(ctx),
1359
+ )
1360
+ if (!unlocked) throw new CrudHttpError(404, { error: 'Receiving line not found.' })
1361
+ ensureTenantScope(ctx, unlocked.tenantId)
1362
+ ensureOrganizationScope(ctx, unlocked.organizationId)
1363
+ const asnId = typeof unlocked.asn === 'string' ? unlocked.asn : unlocked.asn.id
1364
+ const scope = { tenantId: unlocked.tenantId, organizationId: unlocked.organizationId }
1365
+
1366
+ const asn = await findOneWithDecryption(
1367
+ trx,
1368
+ Asn,
1369
+ { id: asnId, deletedAt: null },
1370
+ { lockMode: LockMode.PESSIMISTIC_WRITE },
1371
+ scope,
1372
+ )
1373
+ if (!asn) throw new CrudHttpError(404, { error: 'ASN not found.' })
1374
+ ensureTenantScope(ctx, asn.tenantId)
1375
+ ensureOrganizationScope(ctx, asn.organizationId)
1376
+ assertAsnMutable(asn)
1377
+
1378
+ const locked = await findOneWithDecryption(
1379
+ trx,
1380
+ ReceivingLine,
1381
+ { id, deletedAt: null },
1382
+ { lockMode: LockMode.PESSIMISTIC_WRITE },
1383
+ scope,
1384
+ )
1385
+ if (!locked) throw new CrudHttpError(404, { error: 'Receiving line not found.' })
1386
+ ensureTenantScope(ctx, locked.tenantId)
1387
+ ensureOrganizationScope(ctx, locked.organizationId)
1388
+ await enforceCommandOptimisticLockWithGuards(ctx.container, {
1389
+ resourceKind: WMS_RECEIVING_LINE_RESOURCE,
1390
+ resourceId: locked.id,
1391
+ current: locked.updatedAt,
1392
+ request: ctx.request ?? null,
1393
+ })
1394
+ if (hasAsnDeleteBlockingLineActivity([locked])) {
1395
+ throw new CrudHttpError(409, { error: 'invalid_receipt_state' })
1396
+ }
1397
+ locked.deletedAt = new Date()
1398
+ await trx.flush()
1399
+ return locked
1400
+ })
1401
+
1402
+ await emitAsnSideEffects(ctx, { lines: [{ entity: line, action: 'deleted' }] })
1403
+ return { lineId: line.id }
1404
+ },
1405
+ undo: async ({ logEntry, ctx }) => {
1406
+ const payload = extractUndoPayload<{ before?: ReceivingLineSnapshot | null }>(logEntry)
1407
+ const before = payload?.before
1408
+ if (!before?.id) return
1409
+ const em = resolveEm(ctx)
1410
+ const scope = { tenantId: before.tenantId, organizationId: before.organizationId }
1411
+ // Lock ASN then line (same order as execute/create undo) through mutability
1412
+ // + undelete so concurrent ASN receive/complete cannot race undelete.
1413
+ const line = await withLockedAsnReceivingLine(
1414
+ em,
1415
+ ctx,
1416
+ before.id,
1417
+ scope,
1418
+ { requireActiveLine: false },
1419
+ async ({ asn, line, trx }) => {
1420
+ assertAsnMutable(asn)
1421
+ // Refuse undeleting onto a line that somehow gained receipt/QC activity.
1422
+ if (hasAsnDeleteBlockingLineActivity([line])) {
1423
+ throw new CrudHttpError(409, { error: 'invalid_receipt_state' })
1424
+ }
1425
+ if (hasAsnDeleteBlockingLineActivity([before])) {
1426
+ throw new CrudHttpError(409, { error: 'invalid_receipt_state' })
1427
+ }
1428
+ line.deletedAt = null
1429
+ await trx.flush()
1430
+ return line
1431
+ },
1432
+ )
1433
+ if (!line) return
1434
+ await emitAsnSideEffects(ctx, { lines: [{ entity: line, action: 'updated' }] })
1435
+ },
1436
+ buildLog: async ({ input, result, ctx, snapshots }) => {
1437
+ const base = await buildCrudLog(
1438
+ ctx,
1439
+ input as { id?: string },
1440
+ result?.lineId ?? null,
1441
+ 'wms.audit.receivingLine.delete',
1442
+ 'Delete receiving line',
1443
+ WMS_RECEIVING_LINE_RESOURCE,
1444
+ )
1445
+ const before = snapshots?.before as ReceivingLineSnapshot | undefined
1446
+ return { ...base, snapshotBefore: before, payload: { undo: { before } } }
1447
+ },
1448
+ }
1449
+
1450
+ type ReceiptPutawayLink = { task: PutawayTask; created: boolean }
1451
+
1452
+ async function ensureSerialReceiptPutaways(
1453
+ trx: EntityManager,
1454
+ scope: Scope,
1455
+ input: {
1456
+ attemptKey: string
1457
+ serialNumbers: string[]
1458
+ recreateWhenMissing: boolean
1459
+ warehouseId: string
1460
+ sourceLocationId: string
1461
+ catalogVariantId: string
1462
+ lotId: string | null
1463
+ asnId: string
1464
+ lineId: string
1465
+ metadata?: Record<string, unknown> | null
1466
+ },
1467
+ ): Promise<ReceiptPutawayLink[]> {
1468
+ const links: ReceiptPutawayLink[] = []
1469
+ for (const serialNumber of input.serialNumbers) {
1470
+ const putawayKey = buildAsnReceivePutawayKey({
1471
+ attemptKey: input.attemptKey,
1472
+ serialNumber,
1473
+ })
1474
+ const existing = await findExistingPutawayForReceipt(trx, scope, putawayKey)
1475
+ if (existing) {
1476
+ links.push({ task: existing, created: false })
1477
+ continue
1478
+ }
1479
+ let lotId = input.lotId
1480
+ if (input.recreateWhenMissing) {
1481
+ const cancelled = lotId == null ? await findCancelledPutawayForReceipt(trx, scope, putawayKey) : null
1482
+ if (lotId == null) lotId = cancelled?.lotId ?? null
1483
+ const availability = await resolveUncommittedStagingPutawayQuantity(
1484
+ trx,
1485
+ scope,
1486
+ {
1487
+ warehouseId: input.warehouseId,
1488
+ sourceLocationId: input.sourceLocationId,
1489
+ catalogVariantId: input.catalogVariantId,
1490
+ lotId,
1491
+ serialNumber,
1492
+ },
1493
+ { lock: true },
1494
+ )
1495
+ const canCreate =
1496
+ availability != null &&
1497
+ shouldRecreatePutawayOnAlreadyAtTarget({
1498
+ requestedQuantity: 1,
1499
+ remainingAvailable: availability.remaining,
1500
+ })
1501
+ if (!canCreate) {
1502
+ const covering = availability
1503
+ ? selectCoveringOpenPutawayTask(
1504
+ availability.openTasks.map((task) => ({
1505
+ task,
1506
+ quantity: toNumber(task.quantity),
1507
+ })),
1508
+ 1,
1509
+ )
1510
+ : null
1511
+ if (covering) links.push({ task: covering.task, created: false })
1512
+ continue
1513
+ }
1514
+ }
1515
+ links.push(
1516
+ await findOrCreatePutawayForReceipt(trx, scope, putawayKey, {
1517
+ warehouseId: input.warehouseId,
1518
+ sourceLocationId: input.sourceLocationId,
1519
+ catalogVariantId: input.catalogVariantId,
1520
+ lotId,
1521
+ quantity: 1,
1522
+ serialNumber,
1523
+ asnId: input.asnId,
1524
+ lineId: input.lineId,
1525
+ metadata: input.metadata ?? null,
1526
+ }),
1527
+ )
1528
+ }
1529
+ return links
1530
+ }
1531
+
1532
+ async function emitCreatedReceiptPutaways(
1533
+ ctx: CommandRuntimeContext,
1534
+ scope: Scope,
1535
+ putaways: ReceiptPutawayLink[],
1536
+ warehouseId: string,
1537
+ ): Promise<void> {
1538
+ for (const entry of putaways) {
1539
+ if (!entry.created) continue
1540
+ await emitAsnReceivePutawayCreatedSideEffects(ctx, scope, entry.task, warehouseId)
1541
+ }
1542
+ }
1543
+
1544
+ type ReceiveAsnLineResult = {
1545
+ movementIds: string[]
1546
+ putawayTaskIds: string[]
1547
+ lineId: string
1548
+ asnId: string
1549
+ receivedQty: number
1550
+ asnUpdatedAt: string
1551
+ }
1552
+
1553
+ const receiveAsnLineCommand: CommandHandler<AsnReceiveLineInput, ReceiveAsnLineResult> = {
1554
+ id: 'wms.asns.receive-line',
1555
+ // Stock-affecting on QC pass — align with Phase 1 inventory undo policy.
1556
+ isUndoable: false,
1557
+ async execute(rawInput, ctx) {
1558
+ const input = asnReceiveLineSchema.parse(rawInput ?? {})
1559
+ ensureTenantScope(ctx, input.tenantId)
1560
+ ensureOrganizationScope(ctx, input.organizationId)
1561
+ const rootEm = resolveEm(ctx)
1562
+ const scope = resolveScope(ctx, input)
1563
+ const serialNumbers = resolveSerialNumbers(input)
1564
+ assertUniqueSerialNumbers(serialNumbers)
1565
+
1566
+ // QC-fail: audit-only path (no stock). Still serialize on ASN/line row locks.
1567
+ if (!shouldWriteStockOnQcPass(input.qcStatus)) {
1568
+ const locked = await rootEm.transactional(async (trx) => {
1569
+ const asn = await findOneWithDecryption(
1570
+ trx,
1571
+ Asn,
1572
+ { id: input.asnId, deletedAt: null },
1573
+ { lockMode: LockMode.PESSIMISTIC_WRITE },
1574
+ scope,
1575
+ )
1576
+ if (!asn) throw new CrudHttpError(404, { error: 'ASN not found.' })
1577
+ ensureTenantScope(ctx, asn.tenantId)
1578
+ ensureOrganizationScope(ctx, asn.organizationId)
1579
+ assertAsnMutable(asn)
1580
+ await enforceCommandOptimisticLockWithGuards(ctx.container, {
1581
+ resourceKind: WMS_ASN_RESOURCE,
1582
+ resourceId: asn.id,
1583
+ current: asn.updatedAt,
1584
+ request: ctx.request ?? null,
1585
+ })
1586
+
1587
+ const line = await findOneWithDecryption(
1588
+ trx,
1589
+ ReceivingLine,
1590
+ { id: input.lineId, deletedAt: null },
1591
+ { lockMode: LockMode.PESSIMISTIC_WRITE },
1592
+ scope,
1593
+ )
1594
+ if (!line) throw new CrudHttpError(404, { error: 'Receiving line not found.' })
1595
+ ensureTenantScope(ctx, line.tenantId)
1596
+ ensureOrganizationScope(ctx, line.organizationId)
1597
+
1598
+ const lineAsnId = typeof line.asn === 'string' ? line.asn : line.asn.id
1599
+ if (lineAsnId !== asn.id) {
1600
+ throw new CrudHttpError(422, { error: 'line_asn_mismatch' })
1601
+ }
1602
+ assertAsnQcTransition(line.qcStatus, input.qcStatus)
1603
+
1604
+ const priorReceivedQty = toNumber(line.receivedQty)
1605
+ const attempt = resolveAsnReceiveAttempt({
1606
+ lineId: line.id,
1607
+ priorReceivedQty,
1608
+ receivedQty: input.receivedQty,
1609
+ targetReceivedQty: input.targetReceivedQty,
1610
+ idempotencyKey: input.idempotencyKey,
1611
+ })
1612
+ const warehouseId = typeof asn.warehouse === 'string' ? asn.warehouse : asn.warehouse.id
1613
+ const previousStatus = asn.status
1614
+ const receivingStarted = asn.status === 'draft' || asn.status === 'in_transit'
1615
+ let lineUpdated = false
1616
+ if (attempt.applyQty > 0.000001 && priorReceivedQty === toNumber(line.receivedQty)) {
1617
+ if (serialNumbers.length > 0 && priorReceivedQty > 0.000001) {
1618
+ assertIncomingSerialsAreNew(serialNumbers, line.serialNumbers)
1619
+ }
1620
+ line.receivedQty = toNumericString(attempt.targetReceivedQty)
1621
+ line.qcStatus = 'failed'
1622
+ line.rejectionReason =
1623
+ normalizeOptionalString(input.rejectionReason) ?? line.rejectionReason ?? null
1624
+ if (input.lotNumber !== undefined) {
1625
+ line.lotNumber = normalizeOptionalString(input.lotNumber) ?? line.lotNumber ?? null
1626
+ }
1627
+ recordReceivedSerials(line, serialNumbers, priorReceivedQty)
1628
+ if (asn.status === 'draft') asn.status = 'in_transit'
1629
+ // Bump ASN version only with the successful line write (not before).
1630
+ asn.updatedAt = new Date()
1631
+ line.updatedAt = new Date()
1632
+ await trx.flush()
1633
+ lineUpdated = true
1634
+ }
1635
+ return {
1636
+ asn,
1637
+ line,
1638
+ warehouseId,
1639
+ previousStatus,
1640
+ receivingStarted,
1641
+ lineUpdated,
1642
+ }
1643
+ })
1644
+
1645
+ if (locked.lineUpdated) {
1646
+ await emitAsnSideEffects(ctx, {
1647
+ asns: [{ entity: locked.asn, action: 'updated' }],
1648
+ lines: [{ entity: locked.line, action: 'updated' }],
1649
+ })
1650
+ }
1651
+ if (locked.lineUpdated && locked.receivingStarted && locked.previousStatus === 'draft') {
1652
+ void emitWmsEvent('wms.asn.receiving_started', {
1653
+ id: locked.asn.id,
1654
+ warehouseId: locked.warehouseId,
1655
+ tenantId: scope.tenantId,
1656
+ organizationId: scope.organizationId,
1657
+ }).catch(() => undefined)
1658
+ }
1659
+ // Identical absolute-target retry is a no-op (lineUpdated=false); do not re-emit.
1660
+ if (locked.lineUpdated) {
1661
+ void emitWmsEvent('wms.inventory.receipt_qc_failed', {
1662
+ id: locked.line.id,
1663
+ asnId: locked.asn.id,
1664
+ lineId: locked.line.id,
1665
+ catalogVariantId: locked.line.catalogVariantId,
1666
+ receivedQty: toNumericString(input.receivedQty),
1667
+ rejectionReason: locked.line.rejectionReason,
1668
+ tenantId: scope.tenantId,
1669
+ organizationId: scope.organizationId,
1670
+ }).catch(() => undefined)
1671
+ }
1672
+ return {
1673
+ movementIds: [],
1674
+ putawayTaskIds: [],
1675
+ lineId: locked.line.id,
1676
+ asnId: locked.asn.id,
1677
+ receivedQty: toNumber(locked.line.receivedQty),
1678
+ asnUpdatedAt: locked.asn.updatedAt.toISOString(),
1679
+ }
1680
+ }
1681
+
1682
+ // QC-pass: one transactional boundary holds ASN/line pessimistic locks through
1683
+ // inventory receive + line qty + putaway create. Nested commandBus receives
1684
+ // cannot share that TX; inlined applyInventoryReceiveInTransaction does.
1685
+ // Concurrent overlapping receives therefore cannot both observe prior=0 and
1686
+ // both post stock — the second waits on the row lock and then sees advanced
1687
+ // receivedQty (applyQty=0) or a higher absolute target.
1688
+ if (!input.targetStagingLocationId) {
1689
+ throw new CrudHttpError(422, { error: 'staging_location_required' })
1690
+ }
1691
+
1692
+ type QcPassOutcome = {
1693
+ movementResults: InventoryReceiveMutationResult[]
1694
+ putaways: ReceiptPutawayLink[]
1695
+ lineUpdated: boolean
1696
+ asn: Asn
1697
+ line: ReceivingLine
1698
+ warehouseId: string
1699
+ previousStatus: AsnStatus
1700
+ receivingStarted: boolean
1701
+ applyQty: number
1702
+ alreadyAtTarget: boolean
1703
+ currentReceivedQty: number
1704
+ }
1705
+
1706
+ const outcome = await rootEm.transactional(async (trx): Promise<QcPassOutcome> => {
1707
+ const asn = await findOneWithDecryption(
1708
+ trx,
1709
+ Asn,
1710
+ { id: input.asnId, deletedAt: null },
1711
+ { lockMode: LockMode.PESSIMISTIC_WRITE },
1712
+ scope,
1713
+ )
1714
+ if (!asn) throw new CrudHttpError(404, { error: 'ASN not found.' })
1715
+ ensureTenantScope(ctx, asn.tenantId)
1716
+ ensureOrganizationScope(ctx, asn.organizationId)
1717
+ assertAsnMutable(asn)
1718
+ await enforceCommandOptimisticLockWithGuards(ctx.container, {
1719
+ resourceKind: WMS_ASN_RESOURCE,
1720
+ resourceId: asn.id,
1721
+ current: asn.updatedAt,
1722
+ request: ctx.request ?? null,
1723
+ })
1724
+
1725
+ const line = await findOneWithDecryption(
1726
+ trx,
1727
+ ReceivingLine,
1728
+ { id: input.lineId, deletedAt: null },
1729
+ { lockMode: LockMode.PESSIMISTIC_WRITE },
1730
+ scope,
1731
+ )
1732
+ if (!line) throw new CrudHttpError(404, { error: 'Receiving line not found.' })
1733
+ ensureTenantScope(ctx, line.tenantId)
1734
+ ensureOrganizationScope(ctx, line.organizationId)
1735
+
1736
+ const lineAsnId = typeof line.asn === 'string' ? line.asn : line.asn.id
1737
+ if (lineAsnId !== asn.id) {
1738
+ throw new CrudHttpError(422, { error: 'line_asn_mismatch' })
1739
+ }
1740
+ assertAsnQcTransition(line.qcStatus, input.qcStatus)
1741
+
1742
+ const warehouseId = typeof asn.warehouse === 'string' ? asn.warehouse : asn.warehouse.id
1743
+ await requireStagingLocation(trx, ctx, input.targetStagingLocationId!, warehouseId, scope)
1744
+
1745
+ const priorReceivedQty = toNumber(line.receivedQty)
1746
+ const attempt = resolveAsnReceiveAttempt({
1747
+ lineId: line.id,
1748
+ priorReceivedQty,
1749
+ receivedQty: input.receivedQty,
1750
+ targetReceivedQty: input.targetReceivedQty,
1751
+ idempotencyKey: input.idempotencyKey,
1752
+ })
1753
+ const previousStatus = asn.status
1754
+ const receivingStarted = asn.status === 'draft' || asn.status === 'in_transit'
1755
+ const aggregatePutawayKey = buildAsnReceivePutawayKey({ attemptKey: attempt.attemptKey })
1756
+ const alreadyAtTarget = attempt.applyQty <= 0.000001
1757
+ const trackedSerials =
1758
+ serialNumbers.length > 0 ? serialNumbers : normalizeStoredSerials(line.serialNumbers)
1759
+ const putawayShared = {
1760
+ attemptKey: attempt.attemptKey,
1761
+ warehouseId,
1762
+ sourceLocationId: input.targetStagingLocationId!,
1763
+ catalogVariantId: line.catalogVariantId,
1764
+ asnId: asn.id,
1765
+ lineId: line.id,
1766
+ metadata: input.metadata ?? null,
1767
+ }
1768
+
1769
+ if (alreadyAtTarget) {
1770
+ // Cancel clears putaway_key; identical absolute-target retry must recreate
1771
+ // so staging stock is not left without a queue task — but only when
1772
+ // uncommitted staging qty remains. Lock the source balance before reading
1773
+ // commitments and hold it through insert (same lock as manual create).
1774
+ if (trackedSerials.length > 0) {
1775
+ const putaways = await ensureSerialReceiptPutaways(trx, scope, {
1776
+ ...putawayShared,
1777
+ serialNumbers: trackedSerials,
1778
+ recreateWhenMissing: true,
1779
+ lotId: input.lotId ?? null,
1780
+ })
1781
+ return {
1782
+ movementResults: [],
1783
+ putaways,
1784
+ lineUpdated: false,
1785
+ asn,
1786
+ line,
1787
+ warehouseId,
1788
+ previousStatus,
1789
+ receivingStarted,
1790
+ applyQty: 0,
1791
+ alreadyAtTarget: true,
1792
+ currentReceivedQty: toNumber(line.receivedQty),
1793
+ }
1794
+ }
1795
+ const existingPutaway = await findExistingPutawayForReceipt(trx, scope, aggregatePutawayKey)
1796
+ const putaways: ReceiptPutawayLink[] = []
1797
+ if (existingPutaway) {
1798
+ putaways.push({ task: existingPutaway, created: false })
1799
+ } else if (shouldEnsurePutawayOnAlreadyAtTarget(existingPutaway)) {
1800
+ const cancelled = await findCancelledPutawayForReceipt(trx, scope, aggregatePutawayKey)
1801
+ const lotId = input.lotId ?? cancelled?.lotId ?? null
1802
+ const quantity = resolvePutawayQuantityForAlreadyAtTargetRetry({
1803
+ cancelledTaskQuantity: cancelled ? toNumber(cancelled.quantity) : null,
1804
+ absoluteTargetQty: attempt.targetReceivedQty,
1805
+ })
1806
+ if (quantity > 0.000001) {
1807
+ const availability = await resolveUncommittedStagingPutawayQuantity(
1808
+ trx,
1809
+ scope,
1810
+ {
1811
+ warehouseId,
1812
+ sourceLocationId: input.targetStagingLocationId!,
1813
+ catalogVariantId: line.catalogVariantId,
1814
+ lotId,
1815
+ },
1816
+ { lock: true },
1817
+ )
1818
+ if (
1819
+ availability &&
1820
+ shouldRecreatePutawayOnAlreadyAtTarget({
1821
+ requestedQuantity: quantity,
1822
+ remainingAvailable: availability.remaining,
1823
+ })
1824
+ ) {
1825
+ putaways.push(
1826
+ await findOrCreatePutawayForReceipt(trx, scope, aggregatePutawayKey, {
1827
+ warehouseId,
1828
+ sourceLocationId: input.targetStagingLocationId!,
1829
+ catalogVariantId: line.catalogVariantId,
1830
+ lotId,
1831
+ quantity,
1832
+ asnId: asn.id,
1833
+ lineId: line.id,
1834
+ metadata: input.metadata ?? null,
1835
+ }),
1836
+ )
1837
+ } else if (availability) {
1838
+ const covering = selectCoveringOpenPutawayTask(
1839
+ availability.openTasks.map((task) => ({
1840
+ task,
1841
+ quantity: toNumber(task.quantity),
1842
+ })),
1843
+ quantity,
1844
+ )
1845
+ if (covering) putaways.push({ task: covering.task, created: false })
1846
+ }
1847
+ }
1848
+ }
1849
+ return {
1850
+ movementResults: [],
1851
+ putaways,
1852
+ lineUpdated: false,
1853
+ asn,
1854
+ line,
1855
+ warehouseId,
1856
+ previousStatus,
1857
+ receivingStarted,
1858
+ applyQty: 0,
1859
+ alreadyAtTarget: true,
1860
+ currentReceivedQty: toNumber(line.receivedQty),
1861
+ }
1862
+ }
1863
+
1864
+ if (serialNumbers.length > 0 && priorReceivedQty > 0.000001) {
1865
+ assertIncomingSerialsAreNew(serialNumbers, line.serialNumbers)
1866
+ }
1867
+ if (serialNumbers.length > 0 && serialNumbers.length !== attempt.applyQty) {
1868
+ throw new CrudHttpError(422, { error: 'tracking_required' })
1869
+ }
1870
+
1871
+ const movementResults: InventoryReceiveMutationResult[] = []
1872
+ const receiveOnce = async (quantity: number, serialNumber?: string) => {
1873
+ const movementPutawayKey = buildAsnReceivePutawayKey({
1874
+ attemptKey: attempt.attemptKey,
1875
+ serialNumber,
1876
+ })
1877
+ const receiveInput: InventoryReceiveInput = {
1878
+ organizationId: scope.organizationId,
1879
+ tenantId: scope.tenantId,
1880
+ warehouseId,
1881
+ locationId: input.targetStagingLocationId!,
1882
+ catalogVariantId: line.catalogVariantId,
1883
+ lotId: input.lotId,
1884
+ lotNumber: input.lotNumber ?? line.lotNumber ?? undefined,
1885
+ serialNumber,
1886
+ quantity,
1887
+ referenceType: 'po',
1888
+ referenceId: buildAsnReceiveReferenceId({
1889
+ attemptKey: attempt.attemptKey,
1890
+ serialNumber,
1891
+ }),
1892
+ performedBy: input.performedBy,
1893
+ receivedAt: input.receivedAt,
1894
+ reasonCode: 'asn_receive',
1895
+ metadata: {
1896
+ ...(input.metadata ?? {}),
1897
+ asnId: asn.id,
1898
+ asnReferenceNumber: asn.referenceNumber ?? null,
1899
+ receivingLineId: line.id,
1900
+ putawayKey: movementPutawayKey,
1901
+ source: 'asn_receive',
1902
+ },
1903
+ }
1904
+ movementResults.push(await applyInventoryReceiveInTransaction(trx, ctx, receiveInput))
1905
+ }
1906
+
1907
+ if (serialNumbers.length > 0) {
1908
+ for (const serialNumber of serialNumbers) {
1909
+ await receiveOnce(1, serialNumber)
1910
+ }
1911
+ } else {
1912
+ await receiveOnce(attempt.applyQty)
1913
+ }
1914
+
1915
+ const lotId =
1916
+ input.lotId ?? movementResults.find((result) => result.lotId)?.lotId ?? null
1917
+
1918
+ const putaways =
1919
+ serialNumbers.length > 0
1920
+ ? await ensureSerialReceiptPutaways(trx, scope, {
1921
+ ...putawayShared,
1922
+ serialNumbers,
1923
+ recreateWhenMissing: false,
1924
+ lotId,
1925
+ })
1926
+ : [
1927
+ await findOrCreatePutawayForReceipt(trx, scope, aggregatePutawayKey, {
1928
+ warehouseId,
1929
+ sourceLocationId: input.targetStagingLocationId!,
1930
+ catalogVariantId: line.catalogVariantId,
1931
+ lotId,
1932
+ quantity: attempt.applyQty,
1933
+ asnId: asn.id,
1934
+ lineId: line.id,
1935
+ metadata: input.metadata ?? null,
1936
+ }),
1937
+ ]
1938
+
1939
+ const currentReceived = toNumber(line.receivedQty)
1940
+ let lineUpdated = false
1941
+ if (currentReceived + 0.000001 < attempt.targetReceivedQty) {
1942
+ line.receivedQty = toNumericString(attempt.targetReceivedQty)
1943
+ line.qcStatus = 'passed'
1944
+ line.targetStagingLocationId = input.targetStagingLocationId
1945
+ line.rejectionReason = null
1946
+ if (input.lotNumber !== undefined) {
1947
+ line.lotNumber = normalizeOptionalString(input.lotNumber) ?? line.lotNumber ?? null
1948
+ }
1949
+ recordReceivedSerials(line, serialNumbers, priorReceivedQty)
1950
+ if (asn.status === 'draft') asn.status = 'in_transit'
1951
+ // Bump ASN version only after stock + line write succeed in this TX.
1952
+ asn.updatedAt = new Date()
1953
+ line.updatedAt = new Date()
1954
+ await trx.flush()
1955
+ lineUpdated = true
1956
+ }
1957
+
1958
+ return {
1959
+ movementResults,
1960
+ putaways,
1961
+ lineUpdated,
1962
+ asn,
1963
+ line,
1964
+ warehouseId,
1965
+ previousStatus,
1966
+ receivingStarted,
1967
+ applyQty: attempt.applyQty,
1968
+ alreadyAtTarget: false,
1969
+ currentReceivedQty: toNumber(line.receivedQty),
1970
+ }
1971
+ })
1972
+
1973
+ if (outcome.alreadyAtTarget) {
1974
+ await emitCreatedReceiptPutaways(ctx, scope, outcome.putaways, outcome.warehouseId)
1975
+ return {
1976
+ movementIds: [],
1977
+ putawayTaskIds: outcome.putaways.map((entry) => entry.task.id),
1978
+ lineId: outcome.line.id,
1979
+ asnId: outcome.asn.id,
1980
+ receivedQty: outcome.currentReceivedQty,
1981
+ asnUpdatedAt: outcome.asn.updatedAt.toISOString(),
1982
+ }
1983
+ }
1984
+
1985
+ for (const movementResult of outcome.movementResults) {
1986
+ await emitInventoryReceiveSideEffects(ctx, movementResult)
1987
+ }
1988
+
1989
+ await emitCreatedReceiptPutaways(ctx, scope, outcome.putaways, outcome.warehouseId)
1990
+
1991
+ if (outcome.lineUpdated) {
1992
+ await emitAsnSideEffects(ctx, {
1993
+ asns: [{ entity: outcome.asn, action: 'updated' }],
1994
+ lines: [{ entity: outcome.line, action: 'updated' }],
1995
+ })
1996
+
1997
+ if (outcome.receivingStarted && outcome.previousStatus === 'draft') {
1998
+ void emitWmsEvent('wms.asn.receiving_started', {
1999
+ id: outcome.asn.id,
2000
+ warehouseId: outcome.warehouseId,
2001
+ tenantId: scope.tenantId,
2002
+ organizationId: scope.organizationId,
2003
+ }).catch(() => undefined)
2004
+ }
2005
+ void emitWmsEvent('wms.asn.line_received', {
2006
+ id: outcome.line.id,
2007
+ asnId: outcome.asn.id,
2008
+ lineId: outcome.line.id,
2009
+ movementIds: outcome.movementResults.map((result) => result.movementId),
2010
+ catalogVariantId: outcome.line.catalogVariantId,
2011
+ receivedQty: toNumericString(outcome.applyQty),
2012
+ locationId: input.targetStagingLocationId,
2013
+ tenantId: scope.tenantId,
2014
+ organizationId: scope.organizationId,
2015
+ }).catch(() => undefined)
2016
+ }
2017
+
2018
+ if (outcome.putaways.length === 0) {
2019
+ throw new CrudHttpError(500, { error: '[internal] putaway missing after QC-pass receive' })
2020
+ }
2021
+
2022
+ return {
2023
+ movementIds: outcome.movementResults.map((result) => result.movementId),
2024
+ putawayTaskIds: outcome.putaways.map((entry) => entry.task.id),
2025
+ lineId: outcome.line.id,
2026
+ asnId: outcome.asn.id,
2027
+ receivedQty: toNumber(outcome.line.receivedQty),
2028
+ asnUpdatedAt: outcome.asn.updatedAt.toISOString(),
2029
+ }
2030
+ },
2031
+ buildLog: async ({ input, result, ctx }) => {
2032
+ const { translate } = await resolveTranslations()
2033
+ return {
2034
+ actionLabel: translate('wms.audit.asn.receiveLine', 'Receive ASN line'),
2035
+ resourceKind: WMS_ASN_RESOURCE,
2036
+ resourceId: result?.asnId ?? input?.asnId ?? null,
2037
+ parentResourceKind: WMS_RECEIVING_LINE_RESOURCE,
2038
+ parentResourceId: result?.lineId ?? input?.lineId ?? null,
2039
+ tenantId: input?.tenantId ?? ctx.auth?.tenantId ?? null,
2040
+ organizationId: input?.organizationId ?? ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
2041
+ context: { cacheAliases: [WMS_INVENTORY_BALANCE_RESOURCE, WMS_RECEIVING_LINE_RESOURCE] },
2042
+ }
2043
+ },
2044
+ }
2045
+
2046
+ const closeAsnCommand: CommandHandler<AsnCloseInput, { asnId: string; status: AsnStatus }> = {
2047
+ id: 'wms.asns.close',
2048
+ isUndoable: false,
2049
+ async execute(rawInput, ctx) {
2050
+ const input = asnCloseSchema.parse(rawInput ?? {})
2051
+ ensureTenantScope(ctx, input.tenantId)
2052
+ ensureOrganizationScope(ctx, input.organizationId)
2053
+ const em = resolveEm(ctx)
2054
+ const scope = resolveScope(ctx, input)
2055
+
2056
+ const locked = await em.transactional(async (trx) => {
2057
+ const asn = await findOneWithDecryption(
2058
+ trx,
2059
+ Asn,
2060
+ { id: input.id, deletedAt: null },
2061
+ { lockMode: LockMode.PESSIMISTIC_WRITE },
2062
+ scope,
2063
+ )
2064
+ if (!asn) throw new CrudHttpError(404, { error: 'ASN not found.' })
2065
+ ensureTenantScope(ctx, asn.tenantId)
2066
+ ensureOrganizationScope(ctx, asn.organizationId)
2067
+ await enforceCommandOptimisticLockWithGuards(ctx.container, {
2068
+ resourceKind: WMS_ASN_RESOURCE,
2069
+ resourceId: asn.id,
2070
+ current: asn.updatedAt,
2071
+ request: ctx.request ?? null,
2072
+ })
2073
+ if (asn.status === 'closed') {
2074
+ return { asn, transitioned: false as const, emitReceived: false as const }
2075
+ }
2076
+ if (asn.status === 'received') {
2077
+ asn.status = 'closed'
2078
+ await trx.flush()
2079
+ return { asn, transitioned: true as const, emitReceived: false as const }
2080
+ }
2081
+
2082
+ const lines = await findWithDecryption(
2083
+ trx,
2084
+ ReceivingLine,
2085
+ { asn: asn.id, deletedAt: null },
2086
+ undefined,
2087
+ { tenantId: asn.tenantId, organizationId: asn.organizationId },
2088
+ )
2089
+ if (!isAsnCloseable(lines, !!input.closeWhenShort)) {
2090
+ throw new CrudHttpError(409, { error: 'invalid_receipt_state' })
2091
+ }
2092
+ asn.status = 'received'
2093
+ await trx.flush()
2094
+ return { asn, transitioned: true as const, emitReceived: true as const }
2095
+ })
2096
+
2097
+ if (locked.transitioned) {
2098
+ await emitAsnSideEffects(ctx, { asns: [{ entity: locked.asn, action: 'updated' }] })
2099
+ }
2100
+ if (locked.emitReceived) {
2101
+ void emitWmsEvent('wms.asn.received', {
2102
+ id: locked.asn.id,
2103
+ status: locked.asn.status,
2104
+ closeWhenShort: !!input.closeWhenShort,
2105
+ tenantId: locked.asn.tenantId,
2106
+ organizationId: locked.asn.organizationId,
2107
+ }).catch(() => undefined)
2108
+ }
2109
+ return { asnId: locked.asn.id, status: locked.asn.status }
2110
+ },
2111
+ buildLog: async ({ input, result, ctx }) =>
2112
+ buildCrudLog(ctx, input, result?.asnId ?? null, 'wms.audit.asn.close', 'Complete ASN', WMS_ASN_RESOURCE),
2113
+ }
2114
+
2115
+ registerCommand(createAsnCommand)
2116
+ registerCommand(updateAsnCommand)
2117
+ registerCommand(deleteAsnCommand)
2118
+ registerCommand(createReceivingLineCommand)
2119
+ registerCommand(updateReceivingLineCommand)
2120
+ registerCommand(deleteReceivingLineCommand)
2121
+ registerCommand(receiveAsnLineCommand)
2122
+ registerCommand(closeAsnCommand)