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