@openlup/platform 0.21.0 → 0.22.0

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 (263) hide show
  1. package/CHANGELOG.md +210 -47
  2. package/README.md +27 -4
  3. package/dist/composition/composeBundle.d.ts +7 -0
  4. package/dist/composition/composeBundle.js +2 -0
  5. package/dist/composition/fulfillmentContribution.d.ts +21 -0
  6. package/dist/composition/fulfillmentContribution.js +19 -0
  7. package/dist/composition/index.d.ts +1 -0
  8. package/dist/composition/index.js +1 -0
  9. package/dist/fulfillment/adapterConformance.d.ts +3 -0
  10. package/dist/fulfillment/adapterConformance.js +223 -0
  11. package/dist/fulfillment/bindings.d.ts +3 -0
  12. package/dist/fulfillment/bindings.js +29 -0
  13. package/dist/fulfillment/commerceFulfillmentHandlers.d.ts +29 -0
  14. package/dist/fulfillment/commerceFulfillmentHandlers.js +184 -0
  15. package/dist/fulfillment/commerceFulfillmentReadModel.d.ts +86 -0
  16. package/dist/fulfillment/commerceFulfillmentReadModel.js +131 -0
  17. package/dist/fulfillment/contracts/alignment.d.ts +25 -0
  18. package/dist/fulfillment/contracts/alignment.js +1 -0
  19. package/dist/fulfillment/contracts/bindings.d.ts +51 -0
  20. package/dist/fulfillment/contracts/bindings.js +1 -0
  21. package/dist/fulfillment/contracts/capabilities.d.ts +29 -0
  22. package/dist/fulfillment/contracts/capabilities.js +1 -0
  23. package/dist/fulfillment/contracts/conformance.d.ts +93 -0
  24. package/dist/fulfillment/contracts/conformance.js +1 -0
  25. package/dist/fulfillment/contracts/customerAlignment.d.ts +6 -0
  26. package/dist/fulfillment/contracts/customerAlignment.js +1 -0
  27. package/dist/fulfillment/contracts/dispatch.d.ts +105 -0
  28. package/dist/fulfillment/contracts/dispatch.js +1 -0
  29. package/dist/fulfillment/contracts/index.d.ts +18 -0
  30. package/dist/fulfillment/contracts/index.js +1 -0
  31. package/dist/fulfillment/contracts/label.d.ts +35 -0
  32. package/dist/fulfillment/contracts/label.js +1 -0
  33. package/dist/fulfillment/contracts/nativeUnsupported.d.ts +6 -0
  34. package/dist/fulfillment/contracts/nativeUnsupported.js +1 -0
  35. package/dist/fulfillment/contracts/observation.d.ts +52 -0
  36. package/dist/fulfillment/contracts/observation.js +1 -0
  37. package/dist/fulfillment/contracts/operator.d.ts +64 -0
  38. package/dist/fulfillment/contracts/operator.js +1 -0
  39. package/dist/fulfillment/contracts/read.d.ts +110 -0
  40. package/dist/fulfillment/contracts/read.js +1 -0
  41. package/dist/fulfillment/contracts/readback.d.ts +38 -0
  42. package/dist/fulfillment/contracts/readback.js +1 -0
  43. package/dist/fulfillment/contracts/replacement.d.ts +34 -0
  44. package/dist/fulfillment/contracts/replacement.js +1 -0
  45. package/dist/fulfillment/contracts/store.d.ts +86 -0
  46. package/dist/fulfillment/contracts/store.js +1 -0
  47. package/dist/fulfillment/contracts/sync.d.ts +170 -0
  48. package/dist/fulfillment/contracts/sync.js +1 -0
  49. package/dist/fulfillment/currentFulfillmentParcel.d.ts +2 -0
  50. package/dist/fulfillment/currentFulfillmentParcel.js +1 -0
  51. package/dist/fulfillment/delayReadModel.d.ts +11 -0
  52. package/dist/fulfillment/delayReadModel.js +45 -0
  53. package/dist/fulfillment/deliveryAlignmentReadModel.d.ts +4 -0
  54. package/dist/fulfillment/deliveryAlignmentReadModel.js +12 -0
  55. package/dist/fulfillment/dispatchProjection.d.ts +7 -0
  56. package/dist/fulfillment/dispatchProjection.js +18 -0
  57. package/dist/fulfillment/fulfillmentObservationEffects.d.ts +66 -0
  58. package/dist/fulfillment/fulfillmentObservationEffects.js +36 -0
  59. package/dist/fulfillment/handlerSchemas.d.ts +647 -0
  60. package/dist/fulfillment/handlerSchemas.js +213 -0
  61. package/dist/fulfillment/handlerSupport.d.ts +33 -0
  62. package/dist/fulfillment/handlerSupport.js +25 -0
  63. package/dist/fulfillment/index.d.ts +37 -0
  64. package/dist/fulfillment/index.js +37 -0
  65. package/dist/fulfillment/labelLifecycle.d.ts +24 -0
  66. package/dist/fulfillment/labelLifecycle.js +9 -0
  67. package/dist/fulfillment/lowStockEvidenceHandler.d.ts +36 -0
  68. package/dist/fulfillment/lowStockEvidenceHandler.js +46 -0
  69. package/dist/fulfillment/migrations.d.ts +27 -0
  70. package/dist/fulfillment/migrations.js +8 -0
  71. package/dist/fulfillment/nativeUnsupported.d.ts +10 -0
  72. package/dist/fulfillment/nativeUnsupported.js +18 -0
  73. package/dist/fulfillment/normalizedFulfillmentFacts.d.ts +2 -0
  74. package/dist/fulfillment/normalizedFulfillmentFacts.js +1 -0
  75. package/dist/fulfillment/observationProjection.d.ts +11 -0
  76. package/dist/fulfillment/observationProjection.js +37 -0
  77. package/dist/fulfillment/omnipack/capabilities.d.ts +3 -0
  78. package/dist/fulfillment/omnipack/capabilities.js +16 -0
  79. package/dist/fulfillment/omnipack/client.d.ts +37 -0
  80. package/dist/fulfillment/omnipack/client.js +162 -0
  81. package/dist/fulfillment/omnipack/index.d.ts +52 -0
  82. package/dist/fulfillment/omnipack/index.js +349 -0
  83. package/dist/fulfillment/omnipack/mappers.d.ts +73 -0
  84. package/dist/fulfillment/omnipack/mappers.js +177 -0
  85. package/dist/fulfillment/omnipack/observation.d.ts +21 -0
  86. package/dist/fulfillment/omnipack/observation.js +57 -0
  87. package/dist/fulfillment/omnipack/omnipackDispatchPayload.d.ts +36 -0
  88. package/dist/fulfillment/omnipack/omnipackDispatchPayload.js +63 -0
  89. package/dist/fulfillment/omnipack/omnipackOutboundOrderPayload.d.ts +83 -0
  90. package/dist/fulfillment/omnipack/omnipackOutboundOrderPayload.js +113 -0
  91. package/dist/fulfillment/omnipack/omnipackProductPackPayload.d.ts +11 -0
  92. package/dist/fulfillment/omnipack/omnipackProductPackPayload.js +38 -0
  93. package/dist/fulfillment/omnipack/omnipackProductPayload.d.ts +18 -0
  94. package/dist/fulfillment/omnipack/omnipackProductPayload.js +51 -0
  95. package/dist/fulfillment/omnipack/omnipackReconciliationPayload.d.ts +1 -0
  96. package/dist/fulfillment/omnipack/omnipackReconciliationPayload.js +3 -0
  97. package/dist/fulfillment/omnipack/omnipackStatusVocabulary.d.ts +4 -0
  98. package/dist/fulfillment/omnipack/omnipackStatusVocabulary.js +87 -0
  99. package/dist/fulfillment/omnipack/omnipackWebhookPayload.d.ts +9 -0
  100. package/dist/fulfillment/omnipack/omnipackWebhookPayload.js +76 -0
  101. package/dist/fulfillment/omnipack/productSyncMappers.d.ts +18 -0
  102. package/dist/fulfillment/omnipack/productSyncMappers.js +43 -0
  103. package/dist/fulfillment/omnipack/providerError.d.ts +25 -0
  104. package/dist/fulfillment/omnipack/providerError.js +47 -0
  105. package/dist/fulfillment/omnipack/trackingReferences.d.ts +16 -0
  106. package/dist/fulfillment/omnipack/trackingReferences.js +52 -0
  107. package/dist/fulfillment/paidFulfillmentRecovery.d.ts +67 -0
  108. package/dist/fulfillment/paidFulfillmentRecovery.js +154 -0
  109. package/dist/fulfillment/paidHandoff.d.ts +27 -0
  110. package/dist/fulfillment/paidHandoff.js +86 -0
  111. package/dist/fulfillment/paidHandoffPorts.d.ts +24 -0
  112. package/dist/fulfillment/paidHandoffPorts.js +3 -0
  113. package/dist/fulfillment/postgresDeliveryAlignment.d.ts +4 -0
  114. package/dist/fulfillment/postgresDeliveryAlignment.js +12 -0
  115. package/dist/fulfillment/postgresDispatchStore.d.ts +16 -0
  116. package/dist/fulfillment/postgresDispatchStore.js +44 -0
  117. package/dist/fulfillment/postgresEvidenceReadPort.d.ts +25 -0
  118. package/dist/fulfillment/postgresEvidenceReadPort.js +45 -0
  119. package/dist/fulfillment/postgresPaidFulfillmentRecovery.d.ts +25 -0
  120. package/dist/fulfillment/postgresPaidFulfillmentRecovery.js +26 -0
  121. package/dist/fulfillment/postgresPaidHandoffStore.d.ts +4 -0
  122. package/dist/fulfillment/postgresPaidHandoffStore.js +10 -0
  123. package/dist/fulfillment/postgresShipmentSpine.d.ts +63 -0
  124. package/dist/fulfillment/postgresShipmentSpine.js +232 -0
  125. package/dist/fulfillment/postgresStockSyncPort.d.ts +8 -0
  126. package/dist/fulfillment/postgresStockSyncPort.js +63 -0
  127. package/dist/fulfillment/productReconciliationEvidenceHandler.d.ts +36 -0
  128. package/dist/fulfillment/productReconciliationEvidenceHandler.js +50 -0
  129. package/dist/fulfillment/providerCapabilities.d.ts +9 -0
  130. package/dist/fulfillment/providerCapabilities.js +17 -0
  131. package/dist/fulfillment/providerCommandLedger.d.ts +143 -0
  132. package/dist/fulfillment/providerCommandLedger.js +14 -0
  133. package/dist/fulfillment/providerDispatchAcceptance.d.ts +26 -0
  134. package/dist/fulfillment/providerDispatchAcceptance.js +23 -0
  135. package/dist/fulfillment/providerDispatchError.d.ts +6 -0
  136. package/dist/fulfillment/providerDispatchError.js +5 -0
  137. package/dist/fulfillment/providerDispatchWorker.d.ts +31 -0
  138. package/dist/fulfillment/providerDispatchWorker.js +172 -0
  139. package/dist/fulfillment/providerProductSyncWorker.d.ts +28 -0
  140. package/dist/fulfillment/providerProductSyncWorker.js +122 -0
  141. package/dist/fulfillment/providerReconciliationError.d.ts +9 -0
  142. package/dist/fulfillment/providerReconciliationError.js +17 -0
  143. package/dist/fulfillment/providerReconciliationTerminalSkip.d.ts +2 -0
  144. package/dist/fulfillment/providerReconciliationTerminalSkip.js +6 -0
  145. package/dist/fulfillment/providerReconciliationTimestamp.d.ts +1 -0
  146. package/dist/fulfillment/providerReconciliationTimestamp.js +27 -0
  147. package/dist/fulfillment/providerReconciliationWorker.d.ts +82 -0
  148. package/dist/fulfillment/providerReconciliationWorker.js +151 -0
  149. package/dist/fulfillment/providerStockAvailability.d.ts +13 -0
  150. package/dist/fulfillment/providerStockAvailability.js +51 -0
  151. package/dist/fulfillment/providerStockSyncWorker.d.ts +37 -0
  152. package/dist/fulfillment/providerStockSyncWorker.js +186 -0
  153. package/dist/fulfillment/providerTrackingReferences.d.ts +15 -0
  154. package/dist/fulfillment/providerTrackingReferences.js +23 -0
  155. package/dist/fulfillment/replacementRefusals.d.ts +5 -0
  156. package/dist/fulfillment/replacementRefusals.js +38 -0
  157. package/dist/fulfillment/replacementShipmentHandler.d.ts +5 -0
  158. package/dist/fulfillment/replacementShipmentHandler.js +46 -0
  159. package/dist/fulfillment/schemaCapabilities.d.ts +13 -0
  160. package/dist/fulfillment/schemaCapabilities.js +7 -0
  161. package/dist/fulfillment/shipmentsOverviewHandler.d.ts +16 -0
  162. package/dist/fulfillment/shipmentsOverviewHandler.js +54 -0
  163. package/dist/fulfillment/statusMap.d.ts +2 -0
  164. package/dist/fulfillment/statusMap.js +1 -0
  165. package/dist/fulfillment/trackingProgress.d.ts +1 -0
  166. package/dist/fulfillment/trackingProgress.js +1 -0
  167. package/dist/fulfillment/verifiedIngress.d.ts +52 -0
  168. package/dist/fulfillment/verifiedIngress.js +66 -0
  169. package/dist/payments/stripe/index.d.ts +25 -0
  170. package/dist/payments/stripe/index.js +25 -0
  171. package/dist/payments/tpay/index.d.ts +30 -0
  172. package/dist/payments/tpay/index.js +30 -0
  173. package/package.json +17 -2
  174. package/release-gates.json +44 -3
  175. package/sql/20260808210000_fulfillment_shipment_rail.sql +810 -0
  176. package/sql/20260819150000_fulfillment_shipment_sequence.sql +98 -0
  177. package/sql/20261022080000_fulfillment_command_ledger.sql +786 -0
  178. package/sql/20261022090000_fulfillment_delivery_alignment.sql +1106 -0
  179. package/src/composition/composeBundle.ts +4 -0
  180. package/src/composition/fulfillmentContribution.ts +28 -0
  181. package/src/composition/index.ts +1 -0
  182. package/src/fulfillment/adapterConformance.ts +195 -0
  183. package/src/fulfillment/bindings.ts +26 -0
  184. package/src/fulfillment/commerceFulfillmentHandlers.ts +263 -0
  185. package/src/fulfillment/commerceFulfillmentReadModel.ts +257 -0
  186. package/src/fulfillment/contracts/alignment.ts +13 -0
  187. package/src/fulfillment/contracts/bindings.ts +30 -0
  188. package/src/fulfillment/contracts/capabilities.ts +15 -0
  189. package/src/fulfillment/contracts/conformance.ts +61 -0
  190. package/src/fulfillment/contracts/customerAlignment.ts +4 -0
  191. package/src/fulfillment/contracts/dispatch.ts +58 -0
  192. package/src/fulfillment/contracts/index.ts +18 -0
  193. package/src/fulfillment/contracts/label.ts +9 -0
  194. package/src/fulfillment/contracts/nativeUnsupported.ts +6 -0
  195. package/src/fulfillment/contracts/observation.ts +18 -0
  196. package/src/fulfillment/contracts/operator.ts +31 -0
  197. package/src/fulfillment/contracts/read.ts +42 -0
  198. package/src/fulfillment/contracts/readback.ts +11 -0
  199. package/src/fulfillment/contracts/replacement.ts +15 -0
  200. package/src/fulfillment/contracts/store.ts +26 -0
  201. package/src/fulfillment/contracts/sync.ts +61 -0
  202. package/src/fulfillment/currentFulfillmentParcel.ts +2 -0
  203. package/src/fulfillment/delayReadModel.ts +60 -0
  204. package/src/fulfillment/deliveryAlignmentReadModel.ts +15 -0
  205. package/src/fulfillment/dispatchProjection.ts +20 -0
  206. package/src/fulfillment/fulfillmentObservationEffects.ts +86 -0
  207. package/src/fulfillment/handlerSchemas.ts +255 -0
  208. package/src/fulfillment/handlerSupport.ts +25 -0
  209. package/src/fulfillment/index.ts +38 -0
  210. package/src/fulfillment/labelLifecycle.ts +11 -0
  211. package/src/fulfillment/lowStockEvidenceHandler.ts +60 -0
  212. package/src/fulfillment/migrations.ts +8 -0
  213. package/src/fulfillment/nativeUnsupported.ts +23 -0
  214. package/src/fulfillment/normalizedFulfillmentFacts.ts +2 -0
  215. package/src/fulfillment/observationProjection.ts +41 -0
  216. package/src/fulfillment/omnipack/capabilities.ts +18 -0
  217. package/src/fulfillment/omnipack/client.ts +258 -0
  218. package/src/fulfillment/omnipack/index.ts +312 -0
  219. package/src/fulfillment/omnipack/mappers.ts +261 -0
  220. package/src/fulfillment/omnipack/observation.ts +61 -0
  221. package/src/fulfillment/omnipack/omnipackDispatchPayload.ts +93 -0
  222. package/src/fulfillment/omnipack/omnipackOutboundOrderPayload.ts +194 -0
  223. package/src/fulfillment/omnipack/omnipackProductPackPayload.ts +47 -0
  224. package/src/fulfillment/omnipack/omnipackProductPayload.ts +67 -0
  225. package/src/fulfillment/omnipack/omnipackReconciliationPayload.ts +7 -0
  226. package/src/fulfillment/omnipack/omnipackStatusVocabulary.ts +103 -0
  227. package/src/fulfillment/omnipack/omnipackWebhookPayload.ts +73 -0
  228. package/src/fulfillment/omnipack/productSyncMappers.ts +74 -0
  229. package/src/fulfillment/omnipack/providerError.ts +64 -0
  230. package/src/fulfillment/omnipack/trackingReferences.ts +77 -0
  231. package/src/fulfillment/paidFulfillmentRecovery.ts +252 -0
  232. package/src/fulfillment/paidHandoff.ts +122 -0
  233. package/src/fulfillment/paidHandoffPorts.ts +21 -0
  234. package/src/fulfillment/postgresDeliveryAlignment.ts +14 -0
  235. package/src/fulfillment/postgresDispatchStore.ts +55 -0
  236. package/src/fulfillment/postgresEvidenceReadPort.ts +59 -0
  237. package/src/fulfillment/postgresPaidFulfillmentRecovery.ts +29 -0
  238. package/src/fulfillment/postgresPaidHandoffStore.ts +11 -0
  239. package/src/fulfillment/postgresShipmentSpine.ts +321 -0
  240. package/src/fulfillment/postgresStockSyncPort.ts +65 -0
  241. package/src/fulfillment/productReconciliationEvidenceHandler.ts +64 -0
  242. package/src/fulfillment/providerCapabilities.ts +18 -0
  243. package/src/fulfillment/providerCommandLedger.ts +53 -0
  244. package/src/fulfillment/providerDispatchAcceptance.ts +31 -0
  245. package/src/fulfillment/providerDispatchError.ts +7 -0
  246. package/src/fulfillment/providerDispatchWorker.ts +131 -0
  247. package/src/fulfillment/providerProductSyncWorker.ts +154 -0
  248. package/src/fulfillment/providerReconciliationError.ts +20 -0
  249. package/src/fulfillment/providerReconciliationTerminalSkip.ts +8 -0
  250. package/src/fulfillment/providerReconciliationTimestamp.ts +34 -0
  251. package/src/fulfillment/providerReconciliationWorker.ts +150 -0
  252. package/src/fulfillment/providerStockAvailability.ts +60 -0
  253. package/src/fulfillment/providerStockSyncWorker.ts +222 -0
  254. package/src/fulfillment/providerTrackingReferences.ts +31 -0
  255. package/src/fulfillment/replacementRefusals.ts +43 -0
  256. package/src/fulfillment/replacementShipmentHandler.ts +47 -0
  257. package/src/fulfillment/schemaCapabilities.ts +7 -0
  258. package/src/fulfillment/shipmentsOverviewHandler.ts +73 -0
  259. package/src/fulfillment/statusMap.ts +2 -0
  260. package/src/fulfillment/trackingProgress.ts +1 -0
  261. package/src/fulfillment/verifiedIngress.ts +80 -0
  262. package/src/payments/stripe/index.ts +30 -0
  263. package/src/payments/tpay/index.ts +36 -0
@@ -0,0 +1,131 @@
1
+ import type { DispatchRecord, FulfillmentDispatchPort, FulfillmentDispatchRequest, FulfillmentStorePort } from "./contracts/index.js";
2
+ import { acceptedProviderProof, dispatchEvidence, dispatchResult } from "./dispatchProjection.js";
3
+ import { acknowledgeAcceptedDispatch, recoverDispatchAcceptanceWrite } from "./providerDispatchAcceptance.js";
4
+ import { unknownDispatchFailure } from "./providerDispatchError.js";
5
+
6
+ export async function runFulfillmentDispatchWorker(input: {
7
+ store: FulfillmentStorePort;
8
+ provider: FulfillmentDispatchPort | null;
9
+ listCandidates(limit: number): Promise<readonly FulfillmentDispatchRequest[]>;
10
+ /** Return the original host key bytes, without a new package prefix. */
11
+ keys: {
12
+ request(candidate: FulfillmentDispatchRequest): string;
13
+ mappedAttempt(requestKey: string, mappedAttemptIndex: number): string;
14
+ acceptance(fulfillmentRef: string): { providerAttemptKey: string; labelKey: string };
15
+ };
16
+ config: { mode: DispatchRecord["mode"]; batchLimit: number };
17
+ now?: () => Date;
18
+ }) {
19
+ const result = { ok: true, skipped: false, checked: 0, updated: 0, failures: 0,
20
+ mode: input.config.mode, replayed: 0, providerCalls: 0, readBacks: 0, reason: undefined as string | undefined };
21
+ if (input.config.mode !== "shadow") {
22
+ try { await input.store.markStaleSubmissionsUncertain(new Date((input.now?.() ?? new Date()).getTime() - 5 * 60_000).toISOString()); }
23
+ catch { return { ...result, ok: false, failures: 1, reason: "dispatch_stale_submission_sweep_failed" }; }
24
+ }
25
+ const candidates = await input.listCandidates(input.config.batchLimit);
26
+ result.checked = candidates.length;
27
+ candidateLoop: for (const candidate of candidates) {
28
+ const requestKey = input.keys.request(candidate);
29
+ try {
30
+ let readBack = await input.store.read(requestKey);
31
+ if (readBack) result.readBacks += 1;
32
+ if (acceptedProviderProof(readBack)) {
33
+ const ack = await acknowledgeAcceptedDispatch({ store: input.store, record: readBack, keys: input.keys.acceptance(candidate.fulfillmentRef) });
34
+ if (ack.replayed) result.replayed += 1;
35
+ result.updated += ack.replayed ? 0 : 1;
36
+ continue;
37
+ }
38
+ const preparation = await input.store.prepare(candidate.fulfillmentRef);
39
+ if (preparation.disposition === "withheld") continue;
40
+ const preparedCandidate = await input.store.readCandidate(candidate.fulfillmentRef);
41
+ if (!preparedCandidate) throw new Error("dispatch_prepared_candidate_missing");
42
+ if (input.config.mode !== "shadow" && !input.provider) {
43
+ result.ok = false; result.failures += 1; result.reason ??= "provider_not_configured"; continue;
44
+ }
45
+ // Shadow needs command preparation too, but never calls dispatch.
46
+ if (!input.provider) throw new Error("provider_command_preparation_not_configured");
47
+ let mappedCandidate = preparedCandidate;
48
+ let dispatchRef: DispatchRecord;
49
+ let evidence: DispatchRecord["evidence"];
50
+ let mappedAttemptCount = 0;
51
+ for (let remapCount = 0; ; remapCount += 1) {
52
+ const request = { ...mappedCandidate, requestKey };
53
+ const prepared = await input.provider.prepare(request);
54
+ if (prepared.kind === "refused") {
55
+ result.ok = false; result.failures += 1; result.reason ??= prepared.refusal.reason; continue candidateLoop;
56
+ }
57
+ evidence = dispatchEvidence(prepared.evidence);
58
+ const claim = await input.store.claim({ requestKey, fulfillmentRef: mappedCandidate.fulfillmentRef,
59
+ orderRef: mappedCandidate.orderRef, parcelRef: mappedCandidate.parcelRef, mode: input.config.mode, evidence });
60
+ if (claim.kind === "refused") {
61
+ if (remapCount === 0 && claim.refusal.reason === "contact_stale") {
62
+ const refreshed = await input.store.readCandidate(candidate.fulfillmentRef);
63
+ if (!refreshed) throw new Error("dispatch_contact_remap_candidate_missing");
64
+ mappedCandidate = refreshed; continue;
65
+ }
66
+ result.ok = false; result.failures += 1; result.reason ??= claim.refusal.reason; continue candidateLoop;
67
+ }
68
+ dispatchRef = claim.record;
69
+ if (dispatchRef.replayed) result.replayed += 1;
70
+ // Re-claim may refresh a correctable draft atomically; readback is authoritative.
71
+ readBack = await input.store.read(requestKey);
72
+ if (readBack) result.readBacks += 1;
73
+ if (!readBack || readBack.fulfillmentRef !== mappedCandidate.fulfillmentRef) throw new Error("dispatch_readback_failed");
74
+ await input.store.recordProviderAttempt(input.keys.mappedAttempt(requestKey, mappedAttemptCount),
75
+ mappedCandidate.fulfillmentRef, evidence, input.config.mode, "recorded");
76
+ mappedAttemptCount += 1;
77
+ if (input.config.mode === "shadow") {
78
+ result.updated += dispatchRef.replayed ? 0 : 1; continue candidateLoop;
79
+ }
80
+ if (readBack.status !== "draft") {
81
+ result.ok = false; result.failures += 1; result.reason ??= `dispatch_not_safe_to_submit:${readBack.status}`; continue candidateLoop;
82
+ }
83
+ const submission = await input.store.begin({ dispatchRef: dispatchRef.dispatchRef, commandFingerprint: evidence.commandFingerprint });
84
+ if (submission.kind === "refused") {
85
+ if (remapCount === 0 && submission.refusal.reason === "contact_stale") {
86
+ const refreshed = await input.store.readCandidate(candidate.fulfillmentRef);
87
+ if (!refreshed) throw new Error("dispatch_contact_remap_candidate_missing");
88
+ mappedCandidate = refreshed; continue;
89
+ }
90
+ result.ok = false; result.failures += 1; result.reason ??= submission.refusal.reason; continue candidateLoop;
91
+ }
92
+ if (!submission.begun) {
93
+ const concurrent = await input.store.read(requestKey);
94
+ if (concurrent) result.readBacks += 1;
95
+ if (acceptedProviderProof(concurrent)) {
96
+ const ack = await acknowledgeAcceptedDispatch({ store: input.store, record: concurrent, keys: input.keys.acceptance(mappedCandidate.fulfillmentRef) });
97
+ if (ack.replayed) result.replayed += 1;
98
+ result.updated += ack.replayed ? 0 : 1;
99
+ } else { result.replayed += 1; }
100
+ continue candidateLoop;
101
+ }
102
+ if (submission.record.status !== "submitting") throw new Error("dispatch_submission_state_invalid");
103
+ break;
104
+ }
105
+ result.providerCalls += 1;
106
+ let response;
107
+ try { response = dispatchResult(await input.provider.dispatch({ ...mappedCandidate, requestKey }, evidence)); }
108
+ catch { response = unknownDispatchFailure(); }
109
+ if (response.kind !== "accepted") {
110
+ await input.store.finalize({ dispatchRef: dispatchRef.dispatchRef, result: response });
111
+ result.ok = false; result.failures += 1; result.reason ??= response.refusal.reason; continue;
112
+ }
113
+ // All ACK paths use the original host keys, ignoring adapter key fields.
114
+ const acceptance = { ...response, ...input.keys.acceptance(mappedCandidate.fulfillmentRef) };
115
+ try {
116
+ const ack = await input.store.acknowledge({ dispatchRef: dispatchRef.dispatchRef, acceptance });
117
+ if (ack.replayed) result.replayed += 1;
118
+ } catch {
119
+ const recovery = await recoverDispatchAcceptanceWrite({ store: input.store, requestKey, dispatchRef: dispatchRef.dispatchRef, acceptance });
120
+ result.readBacks += recovery.readBacks;
121
+ if (recovery.committed) { result.updated += 1; continue; }
122
+ result.ok = false; result.failures += 1; result.reason ??= "dispatch_proof_write_failed_after_provider_acceptance"; break;
123
+ }
124
+ result.updated += 1;
125
+ } catch {
126
+ // Exception messages may contain raw provider bodies or credentials.
127
+ result.ok = false; result.failures += 1; result.reason ??= "dispatch_local_operation_failed";
128
+ }
129
+ }
130
+ return result;
131
+ }
@@ -0,0 +1,154 @@
1
+ import type { FulfillmentProductSyncPort, FulfillmentProductSyncProvider } from "./contracts/index.js";
2
+ type PackKind = "unit" | "collective";
3
+ type PackUpsertConflict = Awaited<ReturnType<FulfillmentProductSyncPort["upsertPulledPack"]>>["conflict"];
4
+ type ReconciliationConflict = Parameters<FulfillmentProductSyncPort["recordReconciliationEvidence"]>[0]["conflictKind"];
5
+ type SyncIdentity = Parameters<FulfillmentProductSyncPort["recordReconciliationEvidence"]>[0]["identity"];
6
+ interface ProductSyncResult {
7
+ ok: boolean;
8
+ pushedProducts: number;
9
+ pushedPacks: number;
10
+ skippedProducts: number;
11
+ pulledSkus: number;
12
+ ingestedPacks: number;
13
+ reconciliations: number;
14
+ replayed: number;
15
+ failures: number;
16
+ providerCalls: number;
17
+ syncRunId: string;
18
+ reason?: string;
19
+ }
20
+
21
+ function packKindForQuantity(quantity: number): PackKind {
22
+ return quantity > 1 ? "collective" : "unit";
23
+ }
24
+
25
+ function reconciliationKindFor(conflict: PackUpsertConflict): ReconciliationConflict | null {
26
+ switch (conflict) {
27
+ case "sku_not_found":
28
+ return "unknown_sku";
29
+ case "ean_owned_by_other_sku":
30
+ return "ean_conflict";
31
+ case "local_locked_mismatch":
32
+ return "kind_quantity_conflict";
33
+ default:
34
+ return null;
35
+ }
36
+ }
37
+
38
+ export async function runFulfillmentProductSyncWorker(input: {
39
+ port: FulfillmentProductSyncPort;
40
+ provider: FulfillmentProductSyncProvider;
41
+ mode?: "push" | "pull" | "both";
42
+ now?: () => Date;
43
+ compatibility: { identity(now: Date): SyncIdentity; evidenceKey(sku: string, ean: string, kind: ReconciliationConflict): string };
44
+ }): Promise<ProductSyncResult> {
45
+ const now = input.now?.() ?? new Date();
46
+ const mode = input.mode ?? "both";
47
+ const identity = input.compatibility.identity(now);
48
+ const syncRunId = identity.syncRunId;
49
+ const result: ProductSyncResult = {
50
+ ok: true,
51
+ pushedProducts: 0,
52
+ pushedPacks: 0,
53
+ skippedProducts: 0,
54
+ pulledSkus: 0,
55
+ ingestedPacks: 0,
56
+ reconciliations: 0,
57
+ replayed: 0,
58
+ failures: 0,
59
+ providerCalls: 0,
60
+ syncRunId,
61
+ };
62
+
63
+ if (mode === "push" || mode === "both") await runPush(input, result);
64
+ if (mode === "pull" || mode === "both") await runPull(input, result, identity);
65
+
66
+ return result;
67
+ }
68
+
69
+ async function runPush(
70
+ input: { port: FulfillmentProductSyncPort; provider: FulfillmentProductSyncProvider },
71
+ result: ProductSyncResult,
72
+ ): Promise<void> {
73
+ const localProducts = await input.port.listLocalProductPacks();
74
+ for (const product of localProducts) {
75
+ try {
76
+ const ensured = await input.provider.ensureProduct(product.sku);
77
+ result.providerCalls += 1;
78
+ if (ensured === null) {
79
+ result.skippedProducts += 1;
80
+ continue;
81
+ }
82
+ result.pushedProducts += ensured.replayed ? 0 : 1;
83
+ if (ensured.replayed) result.replayed += 1;
84
+
85
+ const existingEans = new Set((await input.provider.getProductPacks(product.sku)).map((pack) => pack.ean));
86
+ result.providerCalls += 1;
87
+ for (const pack of product.packs) {
88
+ if (existingEans.has(pack.ean)) continue;
89
+ await input.provider.addProductPack(product.sku, { ean: pack.ean, quantity: pack.quantity });
90
+ result.providerCalls += 1;
91
+ await input.port.markPackSynced({ sku: product.sku, ean: pack.ean });
92
+ result.pushedPacks += 1;
93
+ }
94
+ } catch (error) {
95
+ result.ok = false;
96
+ result.failures += 1;
97
+ result.reason = result.reason ?? "product_sync_operation_failed";
98
+ }
99
+ }
100
+ }
101
+
102
+ async function runPull(
103
+ input: Pick<Parameters<typeof runFulfillmentProductSyncWorker>[0], "port" | "provider" | "compatibility">,
104
+ result: ProductSyncResult,
105
+ identity: SyncIdentity,
106
+ ): Promise<void> {
107
+ const syncRunId = identity.syncRunId;
108
+ const skus = await input.provider.listStockedSkus();
109
+ result.providerCalls += 1;
110
+ for (const sku of skus.filter((value) => value.trim())) {
111
+ try {
112
+ const providerPacks = await input.provider.getProductPacks(sku);
113
+ result.providerCalls += 1;
114
+ result.pulledSkus += 1;
115
+ const activeConflicts = new Set<ReconciliationConflict>();
116
+
117
+ for (const pack of providerPacks.filter((entry) => entry.ean.trim())) {
118
+ const upsert = await input.port.upsertPulledPack({
119
+ sku,
120
+ ean: pack.ean,
121
+ quantity: pack.quantity,
122
+ kind: packKindForQuantity(pack.quantity),
123
+ });
124
+ const conflictKind = reconciliationKindFor(upsert.conflict);
125
+ if (conflictKind === null) {
126
+ if (!upsert.replayed) result.ingestedPacks += 1;
127
+ else result.replayed += 1;
128
+ continue;
129
+ }
130
+ activeConflicts.add(conflictKind);
131
+ const evidence = await input.port.recordReconciliationEvidence({
132
+ requestKey: input.compatibility.evidenceKey(sku, pack.ean, conflictKind),
133
+ sku,
134
+ ean: pack.ean,
135
+ conflictKind,
136
+ providerQuantity: pack.quantity,
137
+ identity, refusal: null,
138
+ });
139
+ if (evidence.replayed) result.replayed += 1;
140
+ else result.reconciliations += 1;
141
+ }
142
+
143
+ await input.port.resolveReconciliationEvidence({
144
+ sku,
145
+ activeConflictKinds: [...activeConflicts],
146
+ syncRunId,
147
+ });
148
+ } catch (error) {
149
+ result.ok = false;
150
+ result.failures += 1;
151
+ result.reason = result.reason ?? "product_sync_operation_failed";
152
+ }
153
+ }
154
+ }
@@ -0,0 +1,20 @@
1
+ import type { FulfillmentObservation } from "./contracts/index.js";
2
+
3
+ /** Host decoding supplies known domain reasons, never a raw provider error message. */
4
+ export async function quarantineReconciliationStateConflict(
5
+ error: unknown,
6
+ observation: FulfillmentObservation,
7
+ classify: (error: unknown) => "fulfillment_not_found" | "stock_consumed_requires_label" | null,
8
+ record: (observation: FulfillmentObservation, reason: "state_conflict", detail: "fulfillment_not_found" | "stock_consumed_requires_label") => Promise<{ replayed: boolean }>,
9
+ result: { quarantined: number; replayed: number; stateConflicts: number },
10
+ ): Promise<unknown | null> {
11
+ const reason = classify(error);
12
+ if (!reason) return error;
13
+ try {
14
+ const quarantine = await record(observation, "state_conflict", reason);
15
+ if (quarantine.replayed) result.replayed += 1;
16
+ result.quarantined += quarantine.replayed ? 0 : 1;
17
+ result.stateConflicts += 1;
18
+ return null;
19
+ } catch (quarantineError) { return quarantineError; }
20
+ }
@@ -0,0 +1,8 @@
1
+ import { FULFILLMENT_STATUS_MAP } from "./statusMap.js";
2
+ const OFF_TRACK_FULFILLMENT_STATUSES: ReadonlySet<string> = new Set(
3
+ FULFILLMENT_STATUS_MAP.stages.filter(stage => !stage.onTrack).flatMap(stage => stage.omsFulfillmentStatus),
4
+ );
5
+ /** Off-track rows still write evidence; acceptance and all effects must skip. */
6
+ export function isOffTrackFulfillmentStatus(status: string | null | undefined): boolean {
7
+ return status !== null && status !== undefined && OFF_TRACK_FULFILLMENT_STATUSES.has(status);
8
+ }
@@ -0,0 +1,34 @@
1
+ const ISO_TIMESTAMP_WITH_EXPLICIT_ZONE =
2
+ /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):?(\d{2}))$/;
3
+
4
+ export function normalizeReconciliationTimestamp(
5
+ value: string | null | undefined,
6
+ ): string | null {
7
+ const occurredAt = value?.trim();
8
+ if (!occurredAt) return null;
9
+
10
+ const match = ISO_TIMESTAMP_WITH_EXPLICIT_ZONE.exec(occurredAt);
11
+ if (!match || !Number.isFinite(Date.parse(occurredAt))) return null;
12
+
13
+ const year = Number(match[1]);
14
+ const month = Number(match[2]);
15
+ const day = Number(match[3]);
16
+ const hour = Number(match[4]);
17
+ const minute = Number(match[5]);
18
+ const second = Number(match[6]);
19
+ const offsetHour = match[7] === undefined ? 0 : Number(match[7]);
20
+ const offsetMinute = match[8] === undefined ? 0 : Number(match[8]);
21
+ const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
22
+ const daysInMonth = month === 2
23
+ ? leapYear ? 29 : 28
24
+ : [4, 6, 9, 11].includes(month) ? 30 : 31;
25
+
26
+ if (
27
+ month < 1 || month > 12
28
+ || day < 1 || day > daysInMonth
29
+ || hour > 23 || minute > 59 || second > 59
30
+ || offsetHour > 23 || offsetMinute > 59
31
+ ) return null;
32
+
33
+ return occurredAt;
34
+ }
@@ -0,0 +1,150 @@
1
+ import type { FulfillmentObservation, FulfillmentReadbackResult } from "./contracts/index.js";
2
+ import { applyFulfillmentObservationEffects } from "./fulfillmentObservationEffects.js";
3
+ import { observationStatusPolicy, projectCorrelation, projectObservation } from "./observationProjection.js";
4
+ import { quarantineReconciliationStateConflict } from "./providerReconciliationError.js";
5
+ import { isOffTrackFulfillmentStatus } from "./providerReconciliationTerminalSkip.js";
6
+ import { normalizeReconciliationTimestamp } from "./providerReconciliationTimestamp.js";
7
+ import { enrichTrackingReference, trackingReferenceComplete, uniqueTrackingReferences } from "./providerTrackingReferences.js";
8
+
9
+ type Ingress = Parameters<typeof import("./verifiedIngress.js").verifyAndNormalizeFulfillmentObservation>[0];
10
+ type DispatchRef = NonNullable<Awaited<ReturnType<Ingress["lifecycle"]["findDispatchRef"]>>>;
11
+ type PollEvidence = FulfillmentObservation | { correlation: FulfillmentObservation["correlation"]; eventKey: string };
12
+ interface ReconciliationResult {
13
+ ok: boolean; checked: number; updated: number; trackingRefs: number; quarantined: number;
14
+ stateConflicts: number; exceptions: number; stale: number; offTrackSkips: number; replayed: number;
15
+ failures: number; invoiceIssueFailures: number; invoiceIssueRefused: number;
16
+ invoiceIssueRefusals: { fulfillmentRef: string; identifier: string | null; sqlstate: string; benign: boolean }[];
17
+ providerCalls: number; readBacks: number; skipped: boolean; reason?: string;
18
+ }
19
+
20
+ export async function reconcileFulfillmentObservations(input: {
21
+ /** Adapter/host maps the private page into typed readback, preserving unknowns. */
22
+ readPage(input: { page: number; size: number }): Promise<readonly (FulfillmentReadbackResult | { kind: "unmapped"; evidence: { correlation: FulfillmentObservation["correlation"]; eventKey: string }; externalRef: string | null })[]>;
23
+ batchSize: number; page?: number;
24
+ localPersistence: {
25
+ /** G17: local persisted chronology, never inferred from dispatch or provider state. */
26
+ readLocalFulfillmentState(ref: string): Promise<{ status: string | null; latestStatusOccurredAt: string | null; handedOverAt: string | null }>;
27
+ };
28
+ lifecycle: Pick<Ingress["lifecycle"], "recordStatusEvidence" | "effects" | "keys"> & {
29
+ findDispatchRef(evidence: PollEvidence): Promise<DispatchRef | null>;
30
+ acknowledgeDispatchAcceptance(ref: DispatchRef, evidence: PollEvidence): Promise<void>;
31
+ recordQuarantine(observation: PollEvidence | null, reason: "missing_correlation" | "missing_external_ref" | "unknown_status" | "state_conflict", detail?: "fulfillment_not_found" | "stock_consumed_requires_label"): Promise<{ replayed: boolean }>;
32
+ recordProviderException(ref: DispatchRef, observation: FulfillmentObservation): Promise<{ replayed: boolean }>;
33
+ readDeliveryCarrier(ref: string): Promise<{ carrierRef: string | null; serviceRef: string | null } | null>;
34
+ trackingCodecs: Parameters<typeof enrichTrackingReference>[2];
35
+ issueAccountingInvoice?(ref: string): Promise<void>;
36
+ /** Private RPC decoding retains permanent/benign distinction and safe refusal identity. */
37
+ invoiceFailure(error: unknown): { permanent: boolean; identifier: string | null; sqlstate: string | null; benign: boolean };
38
+ classifyStateConflict(error: unknown): "fulfillment_not_found" | "stock_consumed_requires_label" | null;
39
+ };
40
+ }): Promise<ReconciliationResult> {
41
+ const result: ReconciliationResult = {
42
+ ok: true, checked: 0, updated: 0, trackingRefs: 0, quarantined: 0, stateConflicts: 0, exceptions: 0,
43
+ stale: 0, offTrackSkips: 0, replayed: 0, failures: 0, invoiceIssueFailures: 0, invoiceIssueRefused: 0,
44
+ invoiceIssueRefusals: [], providerCalls: 0, readBacks: 0, skipped: false,
45
+ };
46
+ const page = await input.readPage({ page: input.page ?? 0, size: input.batchSize });
47
+ result.providerCalls += 1;
48
+ // A failed provider read cannot become a successful unknown-status quarantine.
49
+ if (page.some(readback => readback.kind === "unknown")) throw new Error("fulfillment_reconciliation_provider_read_failed");
50
+ result.checked = page.length;
51
+ const quarantine = async (observation: PollEvidence | null, reason: Parameters<typeof input.lifecycle.recordQuarantine>[1]) => {
52
+ const evidence = await input.lifecycle.recordQuarantine(observation, reason);
53
+ if (evidence.replayed) result.replayed += 1;
54
+ result.quarantined += evidence.replayed ? 0 : 1;
55
+ };
56
+ const invoice = async (ref: string) => {
57
+ try { await input.lifecycle.issueAccountingInvoice?.(ref); }
58
+ catch (error) {
59
+ const refusal = input.lifecycle.invoiceFailure(error);
60
+ if (refusal.permanent) {
61
+ result.invoiceIssueRefused += 1;
62
+ if (result.invoiceIssueRefusals.length < 10) result.invoiceIssueRefusals.push({ fulfillmentRef: ref,
63
+ identifier: refusal.identifier, sqlstate: refusal.sqlstate ?? "22023", benign: refusal.benign });
64
+ result.reason ??= "reconciliation_invoice_issue_refused";
65
+ return;
66
+ }
67
+ result.ok = false; result.failures += 1; result.invoiceIssueFailures += 1;
68
+ result.reason ??= "reconciliation_invoice_issue_failed";
69
+ }
70
+ };
71
+ for (const readback of page) {
72
+ let observation: FulfillmentObservation | null = null;
73
+ try {
74
+ if (readback.kind === "unmapped") {
75
+ // An unknown primary status still correlates/ACKs, but an off-track row
76
+ // must never trade a benign skip for a quarantine alarm.
77
+ const evidence = { eventKey: readback.evidence.eventKey, correlation: projectCorrelation(readback.evidence.correlation) };
78
+ const ref = await input.lifecycle.findDispatchRef(evidence);
79
+ if (!ref) { await quarantine(evidence, "missing_correlation"); continue; }
80
+ const state = await input.localPersistence.readLocalFulfillmentState(ref.fulfillmentRef);
81
+ if (isOffTrackFulfillmentStatus(state.status)) { result.offTrackSkips += 1; continue; }
82
+ if (!(readback.externalRef?.trim() || ref.externalRef?.trim())) { await quarantine(evidence, "missing_external_ref"); continue; }
83
+ await input.lifecycle.acknowledgeDispatchAcceptance(ref, evidence);
84
+ await quarantine(evidence, "unknown_status");
85
+ continue;
86
+ }
87
+ if (readback.kind !== "found") {
88
+ // Neither unsupported nor a provider absence is a local transition.
89
+ continue;
90
+ }
91
+ observation = projectObservation(readback.observation);
92
+ const ref = await input.lifecycle.findDispatchRef(observation);
93
+ if (!ref) { await quarantine(observation, "missing_correlation"); continue; }
94
+ const state = await input.localPersistence.readLocalFulfillmentState(ref.fulfillmentRef);
95
+ const offTrack = isOffTrackFulfillmentStatus(state.status);
96
+ if (offTrack) result.offTrackSkips += 1;
97
+ if (!offTrack) {
98
+ if (!(readback.externalRef.trim() || ref.externalRef?.trim())) { await quarantine(observation, "missing_external_ref"); continue; }
99
+ await input.lifecycle.acknowledgeDispatchAcceptance(ref, observation);
100
+ }
101
+ const occurredAt = normalizeReconciliationTimestamp(readback.updatedAt);
102
+ const localStatus = observation.fact.canonicalStatus;
103
+ if (state.latestStatusOccurredAt && occurredAt && Date.parse(occurredAt) < Date.parse(state.latestStatusOccurredAt)) {
104
+ result.stale += 1;
105
+ if (!offTrack && input.lifecycle.issueAccountingInvoice && state.handedOverAt) await invoice(ref.fulfillmentRef);
106
+ continue;
107
+ }
108
+ // The normalized fact carries the same nullable occurrence used by dedupe.
109
+ observation = { ...observation, fact: { ...observation.fact, occurredAt } };
110
+ const status = await input.lifecycle.recordStatusEvidence(ref, observation);
111
+ if (status.replayed) result.replayed += 1;
112
+ if (offTrack) continue;
113
+ result.updated += status.replayed ? 0 : 1;
114
+ if (localStatus === "exception") {
115
+ const exception = await input.lifecycle.recordProviderException(ref, observation);
116
+ if (exception.replayed) result.replayed += 1;
117
+ result.exceptions += exception.replayed ? 0 : 1;
118
+ if (input.lifecycle.issueAccountingInvoice && state.handedOverAt) await invoice(ref.fulfillmentRef);
119
+ continue;
120
+ }
121
+ const trackingRefs = [];
122
+ if (observationStatusPolicy.handsOver(localStatus)) {
123
+ let carrier: Awaited<ReturnType<typeof input.lifecycle.readDeliveryCarrier>> | undefined;
124
+ for (const tracking of uniqueTrackingReferences(observation.trackingRefs)) {
125
+ if (!trackingReferenceComplete(tracking) && carrier === undefined) carrier = await input.lifecycle.readDeliveryCarrier(ref.fulfillmentRef);
126
+ const enriched = enrichTrackingReference(tracking, carrier ?? null, input.lifecycle.trackingCodecs);
127
+ trackingRefs.push({ trackingNumber: enriched.trackingNumber, trackingUrl: enriched.publicUrl, carrierKind: enriched.carrierRef, service: enriched.serviceRef });
128
+ }
129
+ }
130
+ await applyFulfillmentObservationEffects({
131
+ port: input.lifecycle.effects, localStatus, ids: { orderId: ref.orderRef, fulfillmentOrderId: ref.fulfillmentRef }, observation,
132
+ trackingRefs, stockIdempotencyKey: input.lifecycle.keys.stock(ref.fulfillmentRef), handoffIdempotencyKey: input.lifecycle.keys.handoff(ref.fulfillmentRef),
133
+ trackingIdempotencyKey: number => input.lifecycle.keys.tracking(observation!.eventKey, number),
134
+ issueAccountingInvoice: input.lifecycle.issueAccountingInvoice ? () => invoice(ref.fulfillmentRef) : undefined,
135
+ onReplayed: () => { result.replayed += 1; },
136
+ onTrackingRecorded: tracking => {
137
+ if (tracking.replayed) result.replayed += 1;
138
+ if (tracking.readBack) result.readBacks += 1;
139
+ result.trackingRefs += tracking.replayed ? 0 : 1;
140
+ },
141
+ }, observationStatusPolicy);
142
+ } catch (caught) {
143
+ const error = observation ? await quarantineReconciliationStateConflict(caught, observation,
144
+ input.lifecycle.classifyStateConflict, input.lifecycle.recordQuarantine, result) : caught;
145
+ if (error === null) continue;
146
+ result.ok = false; result.failures += 1; result.reason ??= "reconciliation_local_operation_failed";
147
+ }
148
+ }
149
+ return result;
150
+ }
@@ -0,0 +1,60 @@
1
+ import type { FulfillmentStockSyncPort, LocalStock } from "./contracts/index.js";
2
+ type StockLine = Awaited<ReturnType<FulfillmentStockSyncPort["readLocalInventoryStock"]>>[number];
3
+ export function aggregateAtpCompatibleStock(lines: readonly StockLine[], now: Date): Map<string, LocalStock> {
4
+ const requestedAt = now.toISOString();
5
+ const balances = new Map<string, LocalStock>();
6
+ for (const line of lines.filter((item) => isAtpUsable(item, requestedAt))) {
7
+ const current = balances.get(line.skuRef) ?? emptyLocal(line.skuRef);
8
+ current.onHand += line.quantity.onHand;
9
+ current.reserved += line.quantity.reserved;
10
+ current.unavailable += line.quantity.unavailable;
11
+ current.safetyStock += line.quantity.safetyStock;
12
+ balances.set(line.skuRef, current);
13
+ }
14
+ return balances;
15
+ }
16
+
17
+ /** Cap the host-local movement window at UTC today, preserving donor overlap. */
18
+ export function stockMovementWindow(
19
+ cursor: { lastMovementOccurredAt: string | null; lastStockSyncedAt: string | null } | null,
20
+ now: Date,
21
+ timeZone: string,
22
+ ): { startLocalDate: string; endLocalDate: string } {
23
+ const startAt = new Date(cursor?.lastMovementOccurredAt ?? cursor?.lastStockSyncedAt ?? now.toISOString());
24
+ const safeStartAt = Number.isNaN(startAt.getTime()) ? now : startAt;
25
+ const endLocalDate = minDate(localDate(now, timeZone), utcDate(now));
26
+ return {
27
+ startLocalDate: minDate(localDate(safeStartAt, timeZone), endLocalDate),
28
+ endLocalDate,
29
+ };
30
+ }
31
+
32
+ function utcDate(date: Date): string {
33
+ return date.toISOString().slice(0, 10);
34
+ }
35
+
36
+ function minDate(a: string, b: string): string {
37
+ return a <= b ? a : b;
38
+ }
39
+
40
+ function isAtpUsable(line: StockLine, requestedAt: string): boolean {
41
+ if (!line.fulfillable || line.locationStatus !== "active") return false;
42
+ if (line.lotStatus && line.lotStatus !== "available") return false;
43
+ if (line.expiresAt && Date.parse(line.expiresAt) <= Date.parse(requestedAt)) return false;
44
+ return true;
45
+ }
46
+
47
+ export function emptyLocal(sku: string): LocalStock {
48
+ return { skuRef: sku, onHand: 0, reserved: 0, unavailable: 0, safetyStock: 0 };
49
+ }
50
+
51
+ function localDate(date: Date, timeZone: string): string {
52
+ const parts = new Intl.DateTimeFormat("en", {
53
+ timeZone,
54
+ year: "numeric",
55
+ month: "2-digit",
56
+ day: "2-digit",
57
+ }).formatToParts(date);
58
+ const byType = new Map(parts.map((part) => [part.type, part.value]));
59
+ return `${byType.get("year")}-${byType.get("month")}-${byType.get("day")}`;
60
+ }