@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
@@ -63,6 +63,7 @@ import {
63
63
  } from '../data/validators'
64
64
  import {
65
65
  evaluateLowStock,
66
+ isBalanceLocationReservable,
66
67
  isLotEligible,
67
68
  resolveReservationStrategyFromProfile,
68
69
  sortBucketsForStrategy,
@@ -522,11 +523,16 @@ async function listCandidateBalances(
522
523
  },
523
524
  scope,
524
525
  )
526
+ const reservable: InventoryBalance[] = []
525
527
  for (const balance of balances) {
526
528
  ensureTenantScope(ctx, balance.tenantId)
527
529
  ensureOrganizationScope(ctx, balance.organizationId)
530
+ // Staging/dock are inbound holding locations — not pick/reserve candidates
531
+ // (Phase 2: ASN receive must not reserve staging stock and block putaway).
532
+ if (!isBalanceLocationReservable(balance)) continue
533
+ reservable.push(balance)
528
534
  }
529
- return balances
535
+ return reservable
530
536
  }
531
537
 
532
538
  async function listBalancesForVariant(
@@ -1411,12 +1417,28 @@ const adjustInventoryCommand: CommandHandler<InventoryAdjustInput, { movementId:
1411
1417
  if (delta < 0 && getAvailableQuantity(balance) < Math.abs(delta) - 0.000001) {
1412
1418
  throw new CrudHttpError(409, { error: 'insufficient_stock' })
1413
1419
  }
1420
+ // Persist movement before bumping balance so a unique-constraint replay cannot
1421
+ // leave a skewed on-hand and still report idempotentReplay: false.
1422
+ const { movement, idempotentReplay } = await persistMovementWithIdempotency(trx, scope, movementInput)
1423
+ if (idempotentReplay) {
1424
+ return {
1425
+ movementId: movement.id,
1426
+ warehouseId: input.warehouseId,
1427
+ catalogVariantId: input.catalogVariantId,
1428
+ quantity: delta,
1429
+ tenantId: scope.tenantId,
1430
+ organizationId: scope.organizationId,
1431
+ movementEntity: movement,
1432
+ balanceEntity: balance,
1433
+ balanceAction: ('updated' as const),
1434
+ idempotentReplay: true,
1435
+ }
1436
+ }
1414
1437
  setNumeric(
1415
1438
  balance as unknown as Record<string, unknown>,
1416
1439
  'quantityOnHand',
1417
1440
  toNumber(balance.quantityOnHand) + delta,
1418
1441
  )
1419
- const { movement } = await persistMovementWithIdempotency(trx, scope, movementInput)
1420
1442
  await trx.flush()
1421
1443
  return {
1422
1444
  movementId: movement.id,
@@ -1470,6 +1492,171 @@ const adjustInventoryCommand: CommandHandler<InventoryAdjustInput, { movementId:
1470
1492
  }),
1471
1493
  }
1472
1494
 
1495
+ /**
1496
+ * Core receipt mutation for an already-open transaction.
1497
+ * Used by `wms.inventory.receive` and by ASN QC-pass receive so stock + line
1498
+ * qty + putaway can share one transactional boundary (nested commandBus
1499
+ * receives cannot participate in the ASN row lock).
1500
+ */
1501
+ export type InventoryReceiveMutationResult = {
1502
+ movementId: string
1503
+ warehouseId: string
1504
+ locationId: string
1505
+ catalogVariantId: string
1506
+ quantity: number
1507
+ lotId: string | null
1508
+ tenantId: string
1509
+ organizationId: string
1510
+ movementEntity: InventoryMovement
1511
+ balanceEntity: InventoryBalance
1512
+ balanceAction: 'created' | 'updated'
1513
+ idempotentReplay: boolean
1514
+ }
1515
+
1516
+ export async function applyInventoryReceiveInTransaction(
1517
+ trx: EntityManager,
1518
+ ctx: CommandRuntimeContext,
1519
+ input: InventoryReceiveInput,
1520
+ ): Promise<InventoryReceiveMutationResult> {
1521
+ const scope = resolveScope(ctx, input)
1522
+ await requireWarehouse(trx, ctx, input.warehouseId, scope)
1523
+ const location = await requireLocation(trx, ctx, input.locationId, scope)
1524
+ const locationWarehouseId = typeof location.warehouse === 'string' ? location.warehouse : location.warehouse.id
1525
+ if (locationWarehouseId !== input.warehouseId) {
1526
+ throw new CrudHttpError(422, { error: 'invalid_location' })
1527
+ }
1528
+ const profile = await loadProfileForVariant(trx, ctx, input.catalogVariantId, scope)
1529
+ const resolvedLotId = await resolveReceiveLotId(trx, ctx, scope, input)
1530
+ enforceInventoryTrackingRequirements(profile, {
1531
+ lotId: resolvedLotId ?? null,
1532
+ serialNumber: input.serialNumber ?? null,
1533
+ })
1534
+ const { balance, created: balanceWasNew } = await upsertBalanceBucket(trx, scope, {
1535
+ warehouseId: input.warehouseId,
1536
+ locationId: input.locationId,
1537
+ catalogVariantId: input.catalogVariantId,
1538
+ lotId: resolvedLotId,
1539
+ serialNumber: input.serialNumber,
1540
+ })
1541
+ const receivedAt = input.receivedAt ?? input.performedAt ?? new Date()
1542
+ const performedAt = input.performedAt ?? receivedAt
1543
+ const movementInput: MovementMutationInput = {
1544
+ warehouseId: input.warehouseId,
1545
+ locationToId: input.locationId,
1546
+ catalogVariantId: input.catalogVariantId,
1547
+ lotId: resolvedLotId ?? null,
1548
+ serialNumber: input.serialNumber ?? null,
1549
+ quantity: input.quantity,
1550
+ type: 'receipt',
1551
+ referenceType: input.referenceType,
1552
+ referenceId: input.referenceId,
1553
+ performedBy: input.performedBy,
1554
+ performedAt,
1555
+ receivedAt,
1556
+ reason: input.reason,
1557
+ reasonCode: input.reasonCode ?? null,
1558
+ metadata: input.metadata ?? null,
1559
+ }
1560
+ const idempotencyKey = buildMovementIdempotencyKey({
1561
+ referenceType: movementInput.referenceType,
1562
+ referenceId: movementInput.referenceId,
1563
+ type: movementInput.type,
1564
+ warehouseId: movementInput.warehouseId,
1565
+ locationFromId: movementInput.locationFromId,
1566
+ locationToId: movementInput.locationToId,
1567
+ catalogVariantId: movementInput.catalogVariantId,
1568
+ lotId: movementInput.lotId,
1569
+ serialNumber: movementInput.serialNumber,
1570
+ quantity: movementInput.quantity,
1571
+ })
1572
+ const existingMovement = await findExistingMovementByIdempotencyKey(trx, scope, idempotencyKey)
1573
+ if (existingMovement) {
1574
+ const existingLotRaw = existingMovement.lot ?? null
1575
+ const existingLotId =
1576
+ typeof existingLotRaw === 'string' ? existingLotRaw : existingLotRaw?.id ?? resolvedLotId ?? null
1577
+ return {
1578
+ movementId: existingMovement.id,
1579
+ warehouseId: input.warehouseId,
1580
+ locationId: input.locationId,
1581
+ catalogVariantId: input.catalogVariantId,
1582
+ quantity: input.quantity,
1583
+ lotId: existingLotId,
1584
+ tenantId: scope.tenantId,
1585
+ organizationId: scope.organizationId,
1586
+ movementEntity: existingMovement,
1587
+ balanceEntity: balance,
1588
+ balanceAction: 'updated' as const,
1589
+ idempotentReplay: true,
1590
+ }
1591
+ }
1592
+ // Persist movement before bumping balance so a unique-constraint replay cannot
1593
+ // leave an inflated on-hand and still report idempotentReplay: false.
1594
+ const { movement, idempotentReplay } = await persistMovementWithIdempotency(trx, scope, movementInput)
1595
+ if (idempotentReplay) {
1596
+ return {
1597
+ movementId: movement.id,
1598
+ warehouseId: input.warehouseId,
1599
+ locationId: input.locationId,
1600
+ catalogVariantId: input.catalogVariantId,
1601
+ quantity: input.quantity,
1602
+ lotId: resolvedLotId ?? null,
1603
+ tenantId: scope.tenantId,
1604
+ organizationId: scope.organizationId,
1605
+ movementEntity: movement,
1606
+ balanceEntity: balance,
1607
+ balanceAction: 'updated' as const,
1608
+ idempotentReplay: true,
1609
+ }
1610
+ }
1611
+ setNumeric(
1612
+ balance as unknown as Record<string, unknown>,
1613
+ 'quantityOnHand',
1614
+ toNumber(balance.quantityOnHand) + input.quantity,
1615
+ )
1616
+ await trx.flush()
1617
+ return {
1618
+ movementId: movement.id,
1619
+ warehouseId: input.warehouseId,
1620
+ locationId: input.locationId,
1621
+ catalogVariantId: input.catalogVariantId,
1622
+ quantity: input.quantity,
1623
+ lotId: resolvedLotId ?? null,
1624
+ tenantId: scope.tenantId,
1625
+ organizationId: scope.organizationId,
1626
+ movementEntity: movement,
1627
+ balanceEntity: balance,
1628
+ balanceAction: balanceWasNew ? ('created' as const) : ('updated' as const),
1629
+ idempotentReplay: false,
1630
+ }
1631
+ }
1632
+
1633
+ export async function emitInventoryReceiveSideEffects(
1634
+ ctx: CommandRuntimeContext,
1635
+ result: InventoryReceiveMutationResult,
1636
+ ): Promise<void> {
1637
+ if (result.idempotentReplay) return
1638
+ await emitInventorySideEffects(ctx, {
1639
+ movements: [{ entity: result.movementEntity, action: 'created' }],
1640
+ balances: [{ entity: result.balanceEntity, action: result.balanceAction }],
1641
+ })
1642
+ void emitWmsEvent('wms.inventory.received', {
1643
+ id: result.movementId,
1644
+ movementId: result.movementId,
1645
+ warehouseId: result.warehouseId,
1646
+ locationId: result.locationId,
1647
+ catalogVariantId: result.catalogVariantId,
1648
+ quantity: toNumericString(result.quantity),
1649
+ tenantId: result.tenantId,
1650
+ organizationId: result.organizationId,
1651
+ }).catch(() => undefined)
1652
+ void emitLowStockEventIfNeeded(
1653
+ resolveEm(ctx),
1654
+ ctx,
1655
+ { tenantId: result.tenantId, organizationId: result.organizationId },
1656
+ result.catalogVariantId,
1657
+ ).catch(() => undefined)
1658
+ }
1659
+
1473
1660
  const receiveInventoryCommand: CommandHandler<InventoryReceiveInput, { movementId: string }> = {
1474
1661
  id: 'wms.inventory.receive',
1475
1662
  // See "WMS Inventory Mutation Commands — Undo Policy" at top of file.
@@ -1479,116 +1666,10 @@ const receiveInventoryCommand: CommandHandler<InventoryReceiveInput, { movementI
1479
1666
  ensureTenantScope(ctx, input.tenantId)
1480
1667
  ensureOrganizationScope(ctx, input.organizationId)
1481
1668
  const em = resolveEm(ctx)
1482
- const result = await runInTransaction(em, async (trx) => {
1483
- const scope = resolveScope(ctx, input)
1484
- await requireWarehouse(trx, ctx, input.warehouseId, scope)
1485
- const location = await requireLocation(trx, ctx, input.locationId, scope)
1486
- const locationWarehouseId = typeof location.warehouse === 'string' ? location.warehouse : location.warehouse.id
1487
- if (locationWarehouseId !== input.warehouseId) {
1488
- throw new CrudHttpError(422, { error: 'invalid_location' })
1489
- }
1490
- const profile = await loadProfileForVariant(trx, ctx, input.catalogVariantId, scope)
1491
- const resolvedLotId = await resolveReceiveLotId(trx, ctx, scope, input)
1492
- enforceInventoryTrackingRequirements(profile, {
1493
- lotId: resolvedLotId ?? null,
1494
- serialNumber: input.serialNumber ?? null,
1495
- })
1496
- const { balance, created: balanceWasNew } = await upsertBalanceBucket(trx, scope, {
1497
- warehouseId: input.warehouseId,
1498
- locationId: input.locationId,
1499
- catalogVariantId: input.catalogVariantId,
1500
- lotId: resolvedLotId,
1501
- serialNumber: input.serialNumber,
1502
- })
1503
- const receivedAt = input.receivedAt ?? input.performedAt ?? new Date()
1504
- const performedAt = input.performedAt ?? receivedAt
1505
- const movementInput: MovementMutationInput = {
1506
- warehouseId: input.warehouseId,
1507
- locationToId: input.locationId,
1508
- catalogVariantId: input.catalogVariantId,
1509
- lotId: resolvedLotId ?? null,
1510
- serialNumber: input.serialNumber ?? null,
1511
- quantity: input.quantity,
1512
- type: 'receipt',
1513
- referenceType: input.referenceType,
1514
- referenceId: input.referenceId,
1515
- performedBy: input.performedBy,
1516
- performedAt,
1517
- receivedAt,
1518
- reason: input.reason,
1519
- metadata: input.metadata ?? null,
1520
- }
1521
- const idempotencyKey = buildMovementIdempotencyKey({
1522
- referenceType: movementInput.referenceType,
1523
- referenceId: movementInput.referenceId,
1524
- type: movementInput.type,
1525
- warehouseId: movementInput.warehouseId,
1526
- locationFromId: movementInput.locationFromId,
1527
- locationToId: movementInput.locationToId,
1528
- catalogVariantId: movementInput.catalogVariantId,
1529
- lotId: movementInput.lotId,
1530
- serialNumber: movementInput.serialNumber,
1531
- quantity: movementInput.quantity,
1532
- })
1533
- const existingMovement = await findExistingMovementByIdempotencyKey(trx, scope, idempotencyKey)
1534
- if (existingMovement) {
1535
- return {
1536
- movementId: existingMovement.id,
1537
- warehouseId: input.warehouseId,
1538
- locationId: input.locationId,
1539
- catalogVariantId: input.catalogVariantId,
1540
- quantity: input.quantity,
1541
- tenantId: scope.tenantId,
1542
- organizationId: scope.organizationId,
1543
- movementEntity: existingMovement,
1544
- balanceEntity: balance,
1545
- balanceAction: ('updated' as const),
1546
- idempotentReplay: true,
1547
- }
1548
- }
1549
- setNumeric(
1550
- balance as unknown as Record<string, unknown>,
1551
- 'quantityOnHand',
1552
- toNumber(balance.quantityOnHand) + input.quantity,
1553
- )
1554
- const { movement } = await persistMovementWithIdempotency(trx, scope, movementInput)
1555
- await trx.flush()
1556
- return {
1557
- movementId: movement.id,
1558
- warehouseId: input.warehouseId,
1559
- locationId: input.locationId,
1560
- catalogVariantId: input.catalogVariantId,
1561
- quantity: input.quantity,
1562
- tenantId: scope.tenantId,
1563
- organizationId: scope.organizationId,
1564
- movementEntity: movement,
1565
- balanceEntity: balance,
1566
- balanceAction: balanceWasNew ? ('created' as const) : ('updated' as const),
1567
- idempotentReplay: false,
1568
- }
1569
- })
1570
- if (!result.idempotentReplay) {
1571
- await emitInventorySideEffects(ctx, {
1572
- movements: [{ entity: result.movementEntity, action: 'created' }],
1573
- balances: [{ entity: result.balanceEntity, action: result.balanceAction }],
1574
- })
1575
- void emitWmsEvent('wms.inventory.received', {
1576
- id: result.movementId,
1577
- movementId: result.movementId,
1578
- warehouseId: result.warehouseId,
1579
- locationId: result.locationId,
1580
- catalogVariantId: result.catalogVariantId,
1581
- quantity: toNumericString(result.quantity),
1582
- tenantId: result.tenantId,
1583
- organizationId: result.organizationId,
1584
- }).catch(() => undefined)
1585
- void emitLowStockEventIfNeeded(
1586
- resolveEm(ctx),
1587
- ctx,
1588
- { tenantId: result.tenantId, organizationId: result.organizationId },
1589
- result.catalogVariantId,
1590
- ).catch(() => undefined)
1591
- }
1669
+ const result = await runInTransaction(em, async (trx) =>
1670
+ applyInventoryReceiveInTransaction(trx, ctx, input),
1671
+ )
1672
+ await emitInventoryReceiveSideEffects(ctx, result)
1592
1673
  return { movementId: result.movementId }
1593
1674
  },
1594
1675
  buildLog: async ({ input, result, ctx }) =>
@@ -1607,6 +1688,205 @@ const receiveInventoryCommand: CommandHandler<InventoryReceiveInput, { movementI
1607
1688
  }),
1608
1689
  }
1609
1690
 
1691
+ /**
1692
+ * Core move mutation for an already-open transaction.
1693
+ * Used by `wms.inventory.move` and by putaway complete so stock move + task
1694
+ * status share one transactional boundary (nested commandBus moves cannot).
1695
+ */
1696
+ export type InventoryMoveMutationResult = {
1697
+ movementId: string
1698
+ warehouseId: string
1699
+ catalogVariantId: string
1700
+ quantity: number
1701
+ tenantId: string
1702
+ organizationId: string
1703
+ movementEntity: InventoryMovement
1704
+ balances: AffectedBalance[]
1705
+ idempotentReplay: boolean
1706
+ }
1707
+
1708
+ export async function applyInventoryMoveInTransaction(
1709
+ trx: EntityManager,
1710
+ ctx: CommandRuntimeContext,
1711
+ input: InventoryMoveInput,
1712
+ ): Promise<InventoryMoveMutationResult> {
1713
+ const scope = resolveScope(ctx, input)
1714
+ await requireWarehouse(trx, ctx, input.warehouseId, scope)
1715
+ const sourceLocation = await requireLocation(trx, ctx, input.fromLocationId, scope)
1716
+ const targetLocation = await requireLocation(trx, ctx, input.toLocationId, scope)
1717
+ const sourceWarehouseId =
1718
+ typeof sourceLocation.warehouse === 'string' ? sourceLocation.warehouse : sourceLocation.warehouse.id
1719
+ const targetWarehouseId =
1720
+ typeof targetLocation.warehouse === 'string' ? targetLocation.warehouse : targetLocation.warehouse.id
1721
+ if (sourceWarehouseId !== input.warehouseId || targetWarehouseId !== input.warehouseId) {
1722
+ throw new CrudHttpError(422, { error: 'invalid_location' })
1723
+ }
1724
+ const sourceBalance = await findExactBalanceForUpdate(trx, scope, {
1725
+ warehouseId: input.warehouseId,
1726
+ locationId: input.fromLocationId,
1727
+ catalogVariantId: input.catalogVariantId,
1728
+ lotId: input.lotId,
1729
+ serialNumber: input.serialNumber,
1730
+ })
1731
+ if (!sourceBalance) {
1732
+ throw new CrudHttpError(409, { error: 'insufficient_stock' })
1733
+ }
1734
+ const sourceBalanceAction = 'updated' as const
1735
+ const targetResult = await upsertBalanceBucket(trx, scope, {
1736
+ warehouseId: input.warehouseId,
1737
+ locationId: input.toLocationId,
1738
+ catalogVariantId: input.catalogVariantId,
1739
+ lotId: input.lotId,
1740
+ serialNumber: input.serialNumber,
1741
+ })
1742
+ const targetBalance = targetResult.balance
1743
+ const targetBalanceAction = targetResult.created ? ('created' as const) : ('updated' as const)
1744
+ const performedAt = input.performedAt ?? new Date()
1745
+ const receivedAt = await resolveReceivedAtForBalance(trx, sourceBalance, scope, performedAt)
1746
+ const movementInput: MovementMutationInput = {
1747
+ warehouseId: input.warehouseId,
1748
+ locationFromId: input.fromLocationId,
1749
+ locationToId: input.toLocationId,
1750
+ catalogVariantId: input.catalogVariantId,
1751
+ lotId: input.lotId ?? null,
1752
+ serialNumber: input.serialNumber ?? null,
1753
+ quantity: input.quantity,
1754
+ type: input.type ?? 'transfer',
1755
+ referenceType: input.referenceType,
1756
+ referenceId: input.referenceId,
1757
+ performedBy: input.performedBy,
1758
+ performedAt,
1759
+ receivedAt,
1760
+ reason: input.reason,
1761
+ reasonCode: input.reasonCode ?? null,
1762
+ metadata: input.metadata ?? null,
1763
+ }
1764
+ const idempotencyKey = buildMovementIdempotencyKey({
1765
+ referenceType: movementInput.referenceType,
1766
+ referenceId: movementInput.referenceId,
1767
+ type: movementInput.type,
1768
+ warehouseId: movementInput.warehouseId,
1769
+ locationFromId: movementInput.locationFromId,
1770
+ locationToId: movementInput.locationToId,
1771
+ catalogVariantId: movementInput.catalogVariantId,
1772
+ lotId: movementInput.lotId,
1773
+ serialNumber: movementInput.serialNumber,
1774
+ quantity: movementInput.quantity,
1775
+ })
1776
+ const existingMovement = await findExistingMovementByIdempotencyKey(trx, scope, idempotencyKey)
1777
+ if (existingMovement) {
1778
+ return {
1779
+ movementId: existingMovement.id,
1780
+ warehouseId: input.warehouseId,
1781
+ catalogVariantId: input.catalogVariantId,
1782
+ quantity: input.quantity,
1783
+ tenantId: scope.tenantId,
1784
+ organizationId: scope.organizationId,
1785
+ movementEntity: existingMovement,
1786
+ balances: [
1787
+ { entity: sourceBalance, action: sourceBalanceAction },
1788
+ { entity: targetBalance, action: targetBalanceAction },
1789
+ ],
1790
+ idempotentReplay: true,
1791
+ }
1792
+ }
1793
+ // Persist movement before mutating balances so unique-constraint replay cannot
1794
+ // leave skewed source/target on-hand and still report a fresh write.
1795
+ const { movement, idempotentReplay } = await persistMovementWithIdempotency(trx, scope, movementInput)
1796
+ if (idempotentReplay) {
1797
+ return {
1798
+ movementId: movement.id,
1799
+ warehouseId: input.warehouseId,
1800
+ catalogVariantId: input.catalogVariantId,
1801
+ quantity: input.quantity,
1802
+ tenantId: scope.tenantId,
1803
+ organizationId: scope.organizationId,
1804
+ movementEntity: movement,
1805
+ balances: [
1806
+ { entity: sourceBalance, action: sourceBalanceAction },
1807
+ { entity: targetBalance, action: targetBalanceAction },
1808
+ ],
1809
+ idempotentReplay: true,
1810
+ }
1811
+ }
1812
+ if (getAvailableQuantity(sourceBalance) < input.quantity - 0.000001) {
1813
+ throw new CrudHttpError(409, { error: 'insufficient_stock' })
1814
+ }
1815
+ setNumeric(
1816
+ sourceBalance as unknown as Record<string, unknown>,
1817
+ 'quantityOnHand',
1818
+ toNumber(sourceBalance.quantityOnHand) - input.quantity,
1819
+ )
1820
+ setNumeric(
1821
+ targetBalance as unknown as Record<string, unknown>,
1822
+ 'quantityOnHand',
1823
+ toNumber(targetBalance.quantityOnHand) + input.quantity,
1824
+ )
1825
+ await trx.flush()
1826
+ return {
1827
+ movementId: movement.id,
1828
+ warehouseId: input.warehouseId,
1829
+ catalogVariantId: input.catalogVariantId,
1830
+ quantity: input.quantity,
1831
+ tenantId: scope.tenantId,
1832
+ organizationId: scope.organizationId,
1833
+ movementEntity: movement,
1834
+ balances: [
1835
+ { entity: sourceBalance, action: sourceBalanceAction },
1836
+ { entity: targetBalance, action: targetBalanceAction },
1837
+ ],
1838
+ idempotentReplay: false,
1839
+ }
1840
+ }
1841
+
1842
+ export async function emitInventoryMoveSideEffects(
1843
+ ctx: CommandRuntimeContext,
1844
+ result: InventoryMoveMutationResult,
1845
+ ): Promise<void> {
1846
+ if (result.idempotentReplay) return
1847
+ await emitInventorySideEffects(ctx, {
1848
+ movements: [{ entity: result.movementEntity, action: 'created' }],
1849
+ balances: result.balances,
1850
+ })
1851
+ void emitWmsEvent('wms.inventory.moved', {
1852
+ id: result.movementId,
1853
+ movementId: result.movementId,
1854
+ warehouseId: result.warehouseId,
1855
+ catalogVariantId: result.catalogVariantId,
1856
+ quantity: toNumericString(result.quantity),
1857
+ tenantId: result.tenantId,
1858
+ organizationId: result.organizationId,
1859
+ }).catch(() => undefined)
1860
+ void emitLowStockEventIfNeeded(
1861
+ resolveEm(ctx),
1862
+ ctx,
1863
+ { tenantId: result.tenantId, organizationId: result.organizationId },
1864
+ result.catalogVariantId,
1865
+ ).catch(() => undefined)
1866
+ }
1867
+
1868
+ /** Find a putaway-complete movement by stable task reference (qty-independent). */
1869
+ export async function findPutawayCompleteMovementByReference(
1870
+ em: EntityManager,
1871
+ scope: { tenantId: string; organizationId: string },
1872
+ referenceId: string,
1873
+ ): Promise<InventoryMovement | null> {
1874
+ return findOneWithDecryption(
1875
+ em,
1876
+ InventoryMovement,
1877
+ {
1878
+ organizationId: scope.organizationId,
1879
+ tenantId: scope.tenantId,
1880
+ referenceType: 'manual',
1881
+ referenceId,
1882
+ type: 'putaway',
1883
+ deletedAt: null,
1884
+ },
1885
+ undefined,
1886
+ scope,
1887
+ )
1888
+ }
1889
+
1610
1890
  const moveInventoryCommand: CommandHandler<InventoryMoveInput, { movementId: string }> = {
1611
1891
  id: 'wms.inventory.move',
1612
1892
  // See "WMS Inventory Mutation Commands — Undo Policy" at top of file.
@@ -1616,136 +1896,10 @@ const moveInventoryCommand: CommandHandler<InventoryMoveInput, { movementId: str
1616
1896
  ensureTenantScope(ctx, input.tenantId)
1617
1897
  ensureOrganizationScope(ctx, input.organizationId)
1618
1898
  const em = resolveEm(ctx)
1619
- const result = await runInTransaction(em, async (trx) => {
1620
- const scope = resolveScope(ctx, input)
1621
- await requireWarehouse(trx, ctx, input.warehouseId, scope)
1622
- const sourceLocation = await requireLocation(trx, ctx, input.fromLocationId, scope)
1623
- const targetLocation = await requireLocation(trx, ctx, input.toLocationId, scope)
1624
- const sourceWarehouseId = typeof sourceLocation.warehouse === 'string' ? sourceLocation.warehouse : sourceLocation.warehouse.id
1625
- const targetWarehouseId = typeof targetLocation.warehouse === 'string' ? targetLocation.warehouse : targetLocation.warehouse.id
1626
- if (sourceWarehouseId !== input.warehouseId || targetWarehouseId !== input.warehouseId) {
1627
- throw new CrudHttpError(422, { error: 'invalid_location' })
1628
- }
1629
- const sourceBalance = await findExactBalanceForUpdate(trx, scope, {
1630
- warehouseId: input.warehouseId,
1631
- locationId: input.fromLocationId,
1632
- catalogVariantId: input.catalogVariantId,
1633
- lotId: input.lotId,
1634
- serialNumber: input.serialNumber,
1635
- })
1636
- if (!sourceBalance) {
1637
- throw new CrudHttpError(409, { error: 'insufficient_stock' })
1638
- }
1639
- const sourceBalanceAction = 'updated' as const
1640
- const targetResult = await upsertBalanceBucket(trx, scope, {
1641
- warehouseId: input.warehouseId,
1642
- locationId: input.toLocationId,
1643
- catalogVariantId: input.catalogVariantId,
1644
- lotId: input.lotId,
1645
- serialNumber: input.serialNumber,
1646
- })
1647
- const targetBalance = targetResult.balance
1648
- const targetBalanceAction = targetResult.created ? ('created' as const) : ('updated' as const)
1649
- const performedAt = input.performedAt ?? new Date()
1650
- const receivedAt = await resolveReceivedAtForBalance(trx, sourceBalance, scope, performedAt)
1651
- const movementInput: MovementMutationInput = {
1652
- warehouseId: input.warehouseId,
1653
- locationFromId: input.fromLocationId,
1654
- locationToId: input.toLocationId,
1655
- catalogVariantId: input.catalogVariantId,
1656
- lotId: input.lotId ?? null,
1657
- serialNumber: input.serialNumber ?? null,
1658
- quantity: input.quantity,
1659
- type: input.type ?? 'transfer',
1660
- referenceType: input.referenceType,
1661
- referenceId: input.referenceId,
1662
- performedBy: input.performedBy,
1663
- performedAt,
1664
- receivedAt,
1665
- reason: input.reason,
1666
- reasonCode: input.reasonCode ?? null,
1667
- metadata: input.metadata ?? null,
1668
- }
1669
- const idempotencyKey = buildMovementIdempotencyKey({
1670
- referenceType: movementInput.referenceType,
1671
- referenceId: movementInput.referenceId,
1672
- type: movementInput.type,
1673
- warehouseId: movementInput.warehouseId,
1674
- locationFromId: movementInput.locationFromId,
1675
- locationToId: movementInput.locationToId,
1676
- catalogVariantId: movementInput.catalogVariantId,
1677
- lotId: movementInput.lotId,
1678
- serialNumber: movementInput.serialNumber,
1679
- quantity: movementInput.quantity,
1680
- })
1681
- const existingMovement = await findExistingMovementByIdempotencyKey(trx, scope, idempotencyKey)
1682
- if (existingMovement) {
1683
- return {
1684
- movementId: existingMovement.id,
1685
- warehouseId: input.warehouseId,
1686
- catalogVariantId: input.catalogVariantId,
1687
- quantity: input.quantity,
1688
- tenantId: scope.tenantId,
1689
- organizationId: scope.organizationId,
1690
- movementEntity: existingMovement,
1691
- balances: [
1692
- { entity: sourceBalance, action: sourceBalanceAction },
1693
- { entity: targetBalance, action: targetBalanceAction },
1694
- ] as AffectedBalance[],
1695
- idempotentReplay: true,
1696
- }
1697
- }
1698
- if (getAvailableQuantity(sourceBalance) < input.quantity - 0.000001) {
1699
- throw new CrudHttpError(409, { error: 'insufficient_stock' })
1700
- }
1701
- setNumeric(
1702
- sourceBalance as unknown as Record<string, unknown>,
1703
- 'quantityOnHand',
1704
- toNumber(sourceBalance.quantityOnHand) - input.quantity,
1705
- )
1706
- setNumeric(
1707
- targetBalance as unknown as Record<string, unknown>,
1708
- 'quantityOnHand',
1709
- toNumber(targetBalance.quantityOnHand) + input.quantity,
1710
- )
1711
- const { movement } = await persistMovementWithIdempotency(trx, scope, movementInput)
1712
- await trx.flush()
1713
- return {
1714
- movementId: movement.id,
1715
- warehouseId: input.warehouseId,
1716
- catalogVariantId: input.catalogVariantId,
1717
- quantity: input.quantity,
1718
- tenantId: scope.tenantId,
1719
- organizationId: scope.organizationId,
1720
- movementEntity: movement,
1721
- balances: [
1722
- { entity: sourceBalance, action: sourceBalanceAction },
1723
- { entity: targetBalance, action: targetBalanceAction },
1724
- ] as AffectedBalance[],
1725
- idempotentReplay: false,
1726
- }
1727
- })
1728
- if (!result.idempotentReplay) {
1729
- await emitInventorySideEffects(ctx, {
1730
- movements: [{ entity: result.movementEntity, action: 'created' }],
1731
- balances: result.balances,
1732
- })
1733
- void emitWmsEvent('wms.inventory.moved', {
1734
- id: result.movementId,
1735
- movementId: result.movementId,
1736
- warehouseId: result.warehouseId,
1737
- catalogVariantId: result.catalogVariantId,
1738
- quantity: toNumericString(result.quantity),
1739
- tenantId: result.tenantId,
1740
- organizationId: result.organizationId,
1741
- }).catch(() => undefined)
1742
- void emitLowStockEventIfNeeded(
1743
- resolveEm(ctx),
1744
- ctx,
1745
- { tenantId: result.tenantId, organizationId: result.organizationId },
1746
- result.catalogVariantId,
1747
- ).catch(() => undefined)
1748
- }
1899
+ const result = await runInTransaction(em, async (trx) =>
1900
+ applyInventoryMoveInTransaction(trx, ctx, input),
1901
+ )
1902
+ await emitInventoryMoveSideEffects(ctx, result)
1749
1903
  return { movementId: result.movementId }
1750
1904
  },
1751
1905
  buildLog: async ({ input, result, ctx }) =>
@@ -1860,12 +2014,28 @@ const cycleCountInventoryCommand: CommandHandler<InventoryCycleCountInput, { adj
1860
2014
  ),
1861
2015
  })
1862
2016
  }
2017
+ // Persist movement before setting on-hand so a unique-constraint replay cannot
2018
+ // skew the balance and still report idempotentReplay: false.
2019
+ const { movement, idempotentReplay } = await persistMovementWithIdempotency(trx, scope, movementInput)
2020
+ if (idempotentReplay) {
2021
+ return {
2022
+ adjustmentDelta: toNumericString(delta),
2023
+ movementId: movement.id,
2024
+ warehouseId: input.warehouseId,
2025
+ catalogVariantId: input.catalogVariantId,
2026
+ tenantId: scope.tenantId,
2027
+ organizationId: scope.organizationId,
2028
+ movementEntity: movement,
2029
+ balanceEntity: balance,
2030
+ balanceAction: ('updated' as const),
2031
+ idempotentReplay: true,
2032
+ }
2033
+ }
1863
2034
  setNumeric(
1864
2035
  balance as unknown as Record<string, unknown>,
1865
2036
  'quantityOnHand',
1866
2037
  input.countedQuantity,
1867
2038
  )
1868
- const { movement } = await persistMovementWithIdempotency(trx, scope, movementInput)
1869
2039
  await trx.flush()
1870
2040
  return {
1871
2041
  adjustmentDelta: toNumericString(delta),