@openlup/platform 0.22.0 → 0.24.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 +440 -158
- package/README.md +141 -589
- package/dist/fulfillment/adapterConformance.js +1 -1
- package/dist/fulfillment/bindings.js +1 -1
- package/dist/fulfillment/contracts/conformance.d.ts +3 -1
- package/dist/fulfillment/contracts/dispatch.d.ts +45 -3
- package/dist/fulfillment/contracts/index.d.ts +3 -3
- package/dist/fulfillment/contracts/observation.d.ts +21 -1
- package/dist/fulfillment/contracts/readback.d.ts +21 -0
- package/dist/fulfillment/contracts/store.d.ts +3 -0
- package/dist/fulfillment/contracts/sync.d.ts +58 -2
- package/dist/fulfillment/dispatchProjection.d.ts +20 -1
- package/dist/fulfillment/dispatchProjection.js +97 -6
- package/dist/fulfillment/index.d.ts +3 -2
- package/dist/fulfillment/index.js +2 -2
- package/dist/fulfillment/migrations.d.ts +5 -0
- package/dist/fulfillment/migrations.js +1 -0
- package/dist/fulfillment/observationProjection.d.ts +24 -3
- package/dist/fulfillment/observationProjection.js +65 -12
- package/dist/fulfillment/omnipack/client.d.ts +3 -1
- package/dist/fulfillment/omnipack/client.js +31 -13
- package/dist/fulfillment/omnipack/index.d.ts +4 -1
- package/dist/fulfillment/omnipack/index.js +107 -45
- package/dist/fulfillment/omnipack/mappers.d.ts +7 -1
- package/dist/fulfillment/omnipack/mappers.js +24 -2
- package/dist/fulfillment/omnipack/observation.d.ts +11 -4
- package/dist/fulfillment/omnipack/observation.js +44 -29
- package/dist/fulfillment/omnipack/reconciliation.d.ts +15 -0
- package/dist/fulfillment/omnipack/reconciliation.js +55 -0
- package/dist/fulfillment/postgresDispatchStore.js +1 -1
- package/dist/fulfillment/postgresShipmentSpine.d.ts +8 -1
- package/dist/fulfillment/postgresShipmentSpine.js +17 -3
- package/dist/fulfillment/postgresStockSyncPort.js +6 -3
- package/dist/fulfillment/providerDispatchAcceptance.d.ts +1 -0
- package/dist/fulfillment/providerDispatchAcceptance.js +8 -2
- package/dist/fulfillment/providerDispatchWorker.d.ts +51 -0
- package/dist/fulfillment/providerDispatchWorker.js +65 -20
- package/dist/fulfillment/providerReconciliationWorker.d.ts +27 -19
- package/dist/fulfillment/providerReconciliationWorker.js +75 -19
- package/dist/fulfillment/providerStockSyncWorker.d.ts +5 -1
- package/dist/fulfillment/providerStockSyncWorker.js +229 -120
- package/dist/fulfillment/providerTrackingReferences.d.ts +3 -2
- package/dist/fulfillment/verifiedIngress.d.ts +38 -11
- package/dist/fulfillment/verifiedIngress.js +36 -8
- package/dist/invoicing/accountingCanonicalProviderLines.d.ts +5 -0
- package/dist/invoicing/accountingCanonicalProviderLines.js +104 -0
- package/dist/invoicing/accountingLineAllocation.d.ts +2 -0
- package/dist/invoicing/accountingLineAllocation.js +69 -0
- package/dist/invoicing/adapterConformance.d.ts +42 -0
- package/dist/invoicing/adapterConformance.js +296 -0
- package/dist/invoicing/artifacts.d.ts +7 -0
- package/dist/invoicing/artifacts.js +20 -0
- package/dist/invoicing/bindings.d.ts +46 -0
- package/dist/invoicing/bindings.js +69 -0
- package/dist/invoicing/capabilities.d.ts +11 -0
- package/dist/invoicing/capabilities.js +10 -0
- package/dist/invoicing/channelPolicy.d.ts +28 -0
- package/dist/invoicing/channelPolicy.js +27 -0
- package/dist/invoicing/continuity.d.ts +12 -0
- package/dist/invoicing/continuity.js +18 -0
- package/dist/invoicing/contracts/capabilities.d.ts +40 -0
- package/dist/invoicing/contracts/capabilities.js +1 -0
- package/dist/invoicing/contracts/companyLookup.d.ts +49 -0
- package/dist/invoicing/contracts/companyLookup.js +1 -0
- package/dist/invoicing/contracts/delivery.d.ts +74 -0
- package/dist/invoicing/contracts/delivery.js +1 -0
- package/dist/invoicing/contracts/facts.d.ts +110 -0
- package/dist/invoicing/contracts/facts.js +1 -0
- package/dist/invoicing/contracts/host.d.ts +245 -0
- package/dist/invoicing/contracts/host.js +1 -0
- package/dist/invoicing/contracts/index.d.ts +16 -0
- package/dist/invoicing/contracts/index.js +1 -0
- package/dist/invoicing/contracts/provider.d.ts +126 -0
- package/dist/invoicing/contracts/provider.js +1 -0
- package/dist/invoicing/contracts/settlement.d.ts +66 -0
- package/dist/invoicing/contracts/settlement.js +1 -0
- package/dist/invoicing/contracts/store.d.ts +277 -0
- package/dist/invoicing/contracts/store.js +1 -0
- package/dist/invoicing/contribution.d.ts +21 -0
- package/dist/invoicing/contribution.js +19 -0
- package/dist/invoicing/correction.d.ts +42 -0
- package/dist/invoicing/correction.js +44 -0
- package/dist/invoicing/delivery.d.ts +85 -0
- package/dist/invoicing/delivery.js +79 -0
- package/dist/invoicing/documentHistory.d.ts +50 -0
- package/dist/invoicing/documentHistory.js +213 -0
- package/dist/invoicing/documentSync.d.ts +24 -0
- package/dist/invoicing/documentSync.js +26 -0
- package/dist/invoicing/dutyCollector.d.ts +15 -0
- package/dist/invoicing/dutyCollector.js +35 -0
- package/dist/invoicing/fulfillmentTrigger.d.ts +56 -0
- package/dist/invoicing/fulfillmentTrigger.js +90 -0
- package/dist/invoicing/handoff.d.ts +37 -0
- package/dist/invoicing/handoff.js +62 -0
- package/dist/invoicing/index.d.ts +39 -0
- package/dist/invoicing/index.js +37 -0
- package/dist/invoicing/issue.d.ts +39 -0
- package/dist/invoicing/issue.js +55 -0
- package/dist/invoicing/migrations.d.ts +48 -0
- package/dist/invoicing/migrations.js +25 -0
- package/dist/invoicing/nativeUnsupported.d.ts +9 -0
- package/dist/invoicing/nativeUnsupported.js +22 -0
- package/dist/invoicing/paymentReadback.d.ts +20 -0
- package/dist/invoicing/paymentReadback.js +47 -0
- package/dist/invoicing/postgresInvoiceStore.d.ts +55 -0
- package/dist/invoicing/postgresInvoiceStore.js +344 -0
- package/dist/invoicing/readModel.d.ts +74 -0
- package/dist/invoicing/readModel.js +90 -0
- package/dist/invoicing/readback.d.ts +4 -0
- package/dist/invoicing/readback.js +30 -0
- package/dist/invoicing/reversal.d.ts +37 -0
- package/dist/invoicing/reversal.js +28 -0
- package/dist/invoicing/sellerRevision.d.ts +19 -0
- package/dist/invoicing/sellerRevision.js +14 -0
- package/dist/invoicing/settlementSync.d.ts +18 -0
- package/dist/invoicing/settlementSync.js +32 -0
- package/dist/invoicing/snapshot.d.ts +52 -0
- package/dist/invoicing/snapshot.js +141 -0
- package/dist/subscriptions/automaticRenewal.d.ts +44 -0
- package/dist/subscriptions/automaticRenewal.js +81 -0
- package/dist/subscriptions/automaticRenewalExecution.d.ts +21 -0
- package/dist/subscriptions/automaticRenewalExecution.js +44 -0
- package/dist/subscriptions/contracts/commands.d.ts +115 -0
- package/dist/subscriptions/contracts/commands.js +1 -0
- package/dist/subscriptions/contracts/dunning.d.ts +133 -0
- package/dist/subscriptions/contracts/dunning.js +1 -0
- package/dist/subscriptions/contracts/index.d.ts +6 -0
- package/dist/subscriptions/contracts/index.js +1 -0
- package/dist/subscriptions/contracts/messages.d.ts +43 -0
- package/dist/subscriptions/contracts/messages.js +1 -0
- package/dist/subscriptions/contracts/renewal.d.ts +533 -0
- package/dist/subscriptions/contracts/renewal.js +1 -0
- package/dist/subscriptions/contracts/stockWait.d.ts +73 -0
- package/dist/subscriptions/contracts/stockWait.js +1 -0
- package/dist/subscriptions/index.d.ts +39 -0
- package/dist/subscriptions/index.js +17 -0
- package/dist/subscriptions/paymentOutcomes.d.ts +8 -0
- package/dist/subscriptions/paymentOutcomes.js +27 -0
- package/dist/subscriptions/postgresRenewalStore.d.ts +20 -0
- package/dist/subscriptions/postgresRenewalStore.js +61 -0
- package/dist/subscriptions/postgresRetryStore.d.ts +78 -0
- package/dist/subscriptions/postgresRetryStore.js +142 -0
- package/dist/subscriptions/renewalCycleFailureMechanism.d.ts +34 -0
- package/dist/subscriptions/renewalCycleFailureMechanism.js +79 -0
- package/dist/subscriptions/renewalCycleMechanism.d.ts +46 -0
- package/dist/subscriptions/renewalCycleMechanism.js +157 -0
- package/dist/subscriptions/renewalCycleRefusalMechanism.d.ts +41 -0
- package/dist/subscriptions/renewalCycleRefusalMechanism.js +160 -0
- package/dist/subscriptions/renewalIdentity.d.ts +3 -0
- package/dist/subscriptions/renewalIdentity.js +4 -0
- package/package.json +23 -3
- package/release-gates.json +74 -7
- package/sql/20260809210000_accounting_document_rail.sql +889 -0
- package/sql/20260811150000_accounting_payout_rail.sql +333 -0
- package/sql/20260812130000_accounting_document_lifecycle_rail.sql +460 -0
- package/sql/20261022160000_subscription_retry_lifecycle.sql +328 -0
- package/sql/20261022165000_fulfillment_stock_evidence_replay_reopen.sql +30 -0
- package/sql/20261022190000_invoicing_durable_duties.sql +494 -0
- package/sql/20261022191000_invoicing_document_effects.sql +249 -0
- package/sql/20261022192000_invoicing_payment_decoupling.sql +144 -0
- package/sql/20261022195000_invoicing_seller_revision.sql +65 -0
- package/src/fulfillment/adapterConformance.ts +1 -1
- package/src/fulfillment/bindings.ts +1 -1
- package/src/fulfillment/contracts/conformance.ts +3 -1
- package/src/fulfillment/contracts/dispatch.ts +24 -5
- package/src/fulfillment/contracts/index.ts +3 -3
- package/src/fulfillment/contracts/observation.ts +11 -3
- package/src/fulfillment/contracts/readback.ts +13 -0
- package/src/fulfillment/contracts/store.ts +1 -0
- package/src/fulfillment/contracts/sync.ts +13 -2
- package/src/fulfillment/dispatchProjection.ts +94 -7
- package/src/fulfillment/index.ts +3 -2
- package/src/fulfillment/migrations.ts +1 -0
- package/src/fulfillment/observationProjection.ts +64 -13
- package/src/fulfillment/omnipack/client.ts +32 -13
- package/src/fulfillment/omnipack/index.ts +99 -39
- package/src/fulfillment/omnipack/mappers.ts +23 -2
- package/src/fulfillment/omnipack/observation.ts +45 -27
- package/src/fulfillment/omnipack/reconciliation.ts +56 -0
- package/src/fulfillment/postgresDispatchStore.ts +1 -1
- package/src/fulfillment/postgresShipmentSpine.ts +21 -4
- package/src/fulfillment/postgresStockSyncPort.ts +6 -3
- package/src/fulfillment/providerDispatchAcceptance.ts +9 -3
- package/src/fulfillment/providerDispatchWorker.ts +61 -20
- package/src/fulfillment/providerReconciliationWorker.ts +81 -25
- package/src/fulfillment/providerStockSyncWorker.ts +222 -126
- package/src/fulfillment/providerTrackingReferences.ts +3 -2
- package/src/fulfillment/verifiedIngress.ts +44 -11
- package/src/invoicing/accountingCanonicalProviderLines.ts +134 -0
- package/src/invoicing/accountingLineAllocation.ts +69 -0
- package/src/invoicing/adapterConformance.ts +249 -0
- package/src/invoicing/artifacts.ts +19 -0
- package/src/invoicing/bindings.ts +80 -0
- package/src/invoicing/capabilities.ts +9 -0
- package/src/invoicing/channelPolicy.ts +50 -0
- package/src/invoicing/continuity.ts +15 -0
- package/src/invoicing/contracts/capabilities.ts +15 -0
- package/src/invoicing/contracts/companyLookup.ts +13 -0
- package/src/invoicing/contracts/delivery.ts +24 -0
- package/src/invoicing/contracts/facts.ts +71 -0
- package/src/invoicing/contracts/host.ts +84 -0
- package/src/invoicing/contracts/index.ts +16 -0
- package/src/invoicing/contracts/provider.ts +78 -0
- package/src/invoicing/contracts/settlement.ts +39 -0
- package/src/invoicing/contracts/store.ts +102 -0
- package/src/invoicing/contribution.ts +28 -0
- package/src/invoicing/correction.ts +56 -0
- package/src/invoicing/delivery.ts +122 -0
- package/src/invoicing/documentHistory.ts +301 -0
- package/src/invoicing/documentSync.ts +51 -0
- package/src/invoicing/dutyCollector.ts +31 -0
- package/src/invoicing/fulfillmentTrigger.ts +144 -0
- package/src/invoicing/handoff.ts +71 -0
- package/src/invoicing/index.ts +39 -0
- package/src/invoicing/issue.ts +64 -0
- package/src/invoicing/migrations.ts +25 -0
- package/src/invoicing/nativeUnsupported.ts +24 -0
- package/src/invoicing/paymentReadback.ts +70 -0
- package/src/invoicing/postgresInvoiceStore.ts +334 -0
- package/src/invoicing/readModel.ts +80 -0
- package/src/invoicing/readback.ts +26 -0
- package/src/invoicing/reversal.ts +34 -0
- package/src/invoicing/sellerRevision.ts +20 -0
- package/src/invoicing/settlementSync.ts +37 -0
- package/src/invoicing/snapshot.ts +192 -0
- package/src/subscriptions/automaticRenewal.ts +87 -0
- package/src/subscriptions/automaticRenewalExecution.ts +39 -0
- package/src/subscriptions/contracts/commands.ts +46 -0
- package/src/subscriptions/contracts/dunning.ts +48 -0
- package/src/subscriptions/contracts/index.ts +6 -0
- package/src/subscriptions/contracts/messages.ts +18 -0
- package/src/subscriptions/contracts/renewal.ts +325 -0
- package/src/subscriptions/contracts/stockWait.ts +34 -0
- package/src/subscriptions/index.ts +24 -0
- package/src/subscriptions/paymentOutcomes.ts +23 -0
- package/src/subscriptions/postgresRenewalStore.ts +76 -0
- package/src/subscriptions/postgresRetryStore.ts +174 -0
- package/src/subscriptions/renewalCycleFailureMechanism.ts +119 -0
- package/src/subscriptions/renewalCycleMechanism.ts +225 -0
- package/src/subscriptions/renewalCycleRefusalMechanism.ts +215 -0
- package/src/subscriptions/renewalIdentity.ts +5 -0
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import { durableStrings, localFailureReason, safeDiagnosticCode, operationError } from "./dispatchProjection.js";
|
|
1
2
|
import { applyFulfillmentObservationEffects } from "./fulfillmentObservationEffects.js";
|
|
2
|
-
import { observationStatusPolicy,
|
|
3
|
+
import { observationStatusPolicy, projectObservation, projectPollEvidence, projectTrackingReference } from "./observationProjection.js";
|
|
3
4
|
import { quarantineReconciliationStateConflict } from "./providerReconciliationError.js";
|
|
4
5
|
import { isOffTrackFulfillmentStatus } from "./providerReconciliationTerminalSkip.js";
|
|
5
6
|
import { normalizeReconciliationTimestamp } from "./providerReconciliationTimestamp.js";
|
|
6
7
|
import { enrichTrackingReference, trackingReferenceComplete, uniqueTrackingReferences } from "./providerTrackingReferences.js";
|
|
7
8
|
export async function reconcileFulfillmentObservations(input) {
|
|
9
|
+
const privacy = durableStrings();
|
|
10
|
+
const failure = (stage, error) => localFailureReason(stage, error, privacy);
|
|
8
11
|
const result = {
|
|
9
12
|
ok: true, checked: 0, updated: 0, trackingRefs: 0, quarantined: 0, stateConflicts: 0, exceptions: 0,
|
|
10
13
|
stale: 0, offTrackSkips: 0, replayed: 0, failures: 0, invoiceIssueFailures: 0, invoiceIssueRefused: 0,
|
|
@@ -12,9 +15,6 @@ export async function reconcileFulfillmentObservations(input) {
|
|
|
12
15
|
};
|
|
13
16
|
const page = await input.readPage({ page: input.page ?? 0, size: input.batchSize });
|
|
14
17
|
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
18
|
result.checked = page.length;
|
|
19
19
|
const quarantine = async (observation, reason) => {
|
|
20
20
|
const evidence = await input.lifecycle.recordQuarantine(observation, reason);
|
|
@@ -30,32 +30,39 @@ export async function reconcileFulfillmentObservations(input) {
|
|
|
30
30
|
const refusal = input.lifecycle.invoiceFailure(error);
|
|
31
31
|
if (refusal.permanent) {
|
|
32
32
|
result.invoiceIssueRefused += 1;
|
|
33
|
+
const identifier = privacy.token(refusal.identifier, safeDiagnosticCode), sqlstate = privacy.token(refusal.sqlstate, safeDiagnosticCode);
|
|
33
34
|
if (result.invoiceIssueRefusals.length < 10)
|
|
34
35
|
result.invoiceIssueRefusals.push({ fulfillmentRef: ref,
|
|
35
|
-
identifier
|
|
36
|
-
result.reason ??= "reconciliation_invoice_issue_refused";
|
|
36
|
+
identifier, sqlstate: sqlstate ?? "22023", benign: refusal.benign });
|
|
37
|
+
result.reason ??= failure("reconciliation_invoice_issue_refused", { causeMessage: identifier, causeCode: sqlstate, message: error instanceof Error ? error.message : undefined });
|
|
37
38
|
return;
|
|
38
39
|
}
|
|
39
40
|
result.ok = false;
|
|
40
41
|
result.failures += 1;
|
|
41
42
|
result.invoiceIssueFailures += 1;
|
|
42
|
-
result.reason ??= "reconciliation_invoice_issue_failed";
|
|
43
|
+
result.reason ??= failure("reconciliation_invoice_issue_failed", error);
|
|
43
44
|
}
|
|
44
45
|
};
|
|
45
46
|
for (const readback of page) {
|
|
46
47
|
let observation = null;
|
|
47
48
|
try {
|
|
49
|
+
if (readback.kind === "unknown") {
|
|
50
|
+
result.ok = false;
|
|
51
|
+
result.failures += 1;
|
|
52
|
+
result.reason ??= "fulfillment_reconciliation_provider_read_failed";
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
48
55
|
if (readback.kind === "unmapped") {
|
|
49
56
|
// An unknown primary status still correlates/ACKs, but an off-track row
|
|
50
57
|
// must never trade a benign skip for a quarantine alarm.
|
|
51
|
-
const evidence =
|
|
58
|
+
const evidence = projectPollEvidence(readback.evidence);
|
|
52
59
|
const ref = await input.lifecycle.findDispatchRef(evidence);
|
|
53
60
|
if (!ref) {
|
|
54
61
|
await quarantine(evidence, "missing_correlation");
|
|
55
62
|
continue;
|
|
56
63
|
}
|
|
57
|
-
const
|
|
58
|
-
if (isOffTrackFulfillmentStatus(
|
|
64
|
+
const localState = await input.localPersistence.readLocalFulfillmentStatus(ref.fulfillmentRef);
|
|
65
|
+
if (isOffTrackFulfillmentStatus(localState)) {
|
|
59
66
|
result.offTrackSkips += 1;
|
|
60
67
|
continue;
|
|
61
68
|
}
|
|
@@ -71,14 +78,17 @@ export async function reconcileFulfillmentObservations(input) {
|
|
|
71
78
|
// Neither unsupported nor a provider absence is a local transition.
|
|
72
79
|
continue;
|
|
73
80
|
}
|
|
74
|
-
observation = projectObservation(readback.observation);
|
|
81
|
+
observation = projectObservation(readback.observation, input.lifecycle.trackingCodecs.approveTrackingUrl, input.lifecycle.trackingCodecs);
|
|
82
|
+
if (observation.diagnostics?.redactedFieldCount) {
|
|
83
|
+
result.redactedFieldCount = (result.redactedFieldCount ?? 0) + observation.diagnostics.redactedFieldCount;
|
|
84
|
+
}
|
|
75
85
|
const ref = await input.lifecycle.findDispatchRef(observation);
|
|
76
86
|
if (!ref) {
|
|
77
87
|
await quarantine(observation, "missing_correlation");
|
|
78
88
|
continue;
|
|
79
89
|
}
|
|
80
|
-
const
|
|
81
|
-
const offTrack = isOffTrackFulfillmentStatus(
|
|
90
|
+
const localState = await input.localPersistence.readLocalFulfillmentStatus(ref.fulfillmentRef);
|
|
91
|
+
const offTrack = isOffTrackFulfillmentStatus(localState);
|
|
82
92
|
if (offTrack)
|
|
83
93
|
result.offTrackSkips += 1;
|
|
84
94
|
if (!offTrack) {
|
|
@@ -90,9 +100,10 @@ export async function reconcileFulfillmentObservations(input) {
|
|
|
90
100
|
}
|
|
91
101
|
const occurredAt = normalizeReconciliationTimestamp(readback.updatedAt);
|
|
92
102
|
const localStatus = observation.fact.canonicalStatus;
|
|
93
|
-
|
|
103
|
+
const latest = await input.localPersistence.latestStatusOccurredAt(ref.fulfillmentRef);
|
|
104
|
+
if (latest && occurredAt && Date.parse(occurredAt) < Date.parse(latest)) {
|
|
94
105
|
result.stale += 1;
|
|
95
|
-
if (!offTrack && input.lifecycle.issueAccountingInvoice &&
|
|
106
|
+
if (!offTrack && input.lifecycle.issueAccountingInvoice && await input.localPersistence.readHandedOverAt(ref.fulfillmentRef))
|
|
96
107
|
await invoice(ref.fulfillmentRef);
|
|
97
108
|
continue;
|
|
98
109
|
}
|
|
@@ -109,7 +120,7 @@ export async function reconcileFulfillmentObservations(input) {
|
|
|
109
120
|
if (exception.replayed)
|
|
110
121
|
result.replayed += 1;
|
|
111
122
|
result.exceptions += exception.replayed ? 0 : 1;
|
|
112
|
-
if (input.lifecycle.issueAccountingInvoice &&
|
|
123
|
+
if (input.lifecycle.issueAccountingInvoice && await input.localPersistence.readHandedOverAt(ref.fulfillmentRef))
|
|
113
124
|
await invoice(ref.fulfillmentRef);
|
|
114
125
|
continue;
|
|
115
126
|
}
|
|
@@ -119,7 +130,13 @@ export async function reconcileFulfillmentObservations(input) {
|
|
|
119
130
|
for (const tracking of uniqueTrackingReferences(observation.trackingRefs)) {
|
|
120
131
|
if (!trackingReferenceComplete(tracking) && carrier === undefined)
|
|
121
132
|
carrier = await input.lifecycle.readDeliveryCarrier(ref.fulfillmentRef);
|
|
122
|
-
const
|
|
133
|
+
const codecs = input.lifecycle.trackingCodecs;
|
|
134
|
+
const candidate = enrichTrackingReference(tracking, carrier ?? null, { ...codecs, publicUrl: () => null });
|
|
135
|
+
const enriched = projectTrackingReference(candidate, privacy, codecs.approveTrackingUrl, codecs);
|
|
136
|
+
if (!enriched)
|
|
137
|
+
continue;
|
|
138
|
+
// Host-built links use admitted parts and pass through unchanged, including queries.
|
|
139
|
+
enriched.publicUrl ??= codecs.publicUrl(enriched.carrierRef, enriched.trackingNumber);
|
|
123
140
|
trackingRefs.push({ trackingNumber: enriched.trackingNumber, trackingUrl: enriched.publicUrl, carrierKind: enriched.carrierRef, service: enriched.serviceRef });
|
|
124
141
|
}
|
|
125
142
|
}
|
|
@@ -144,8 +161,47 @@ export async function reconcileFulfillmentObservations(input) {
|
|
|
144
161
|
continue;
|
|
145
162
|
result.ok = false;
|
|
146
163
|
result.failures += 1;
|
|
147
|
-
result.reason ??= "reconciliation_local_operation_failed";
|
|
164
|
+
result.reason ??= failure("reconciliation_local_operation_failed", error);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { ...result, ...(privacy.redactedFieldCount ? { redactedFieldCount: (result.redactedFieldCount ?? 0) + privacy.redactedFieldCount } : {}) };
|
|
168
|
+
}
|
|
169
|
+
/** Host owns scheduling, HTTP and durable job-ledger writes. This operation only projects them. */
|
|
170
|
+
export async function runFulfillmentReconciliationPage(input) {
|
|
171
|
+
if (!Number.isSafeInteger(input.pageSize) || input.pageSize <= 0)
|
|
172
|
+
throw new Error("reconciliation_page_size_invalid");
|
|
173
|
+
const isPage = (value) => Number.isSafeInteger(value) && value >= 0;
|
|
174
|
+
const checkpoint = input.checkpoint;
|
|
175
|
+
const saved = checkpoint?.reconciliationPagination;
|
|
176
|
+
const page = saved?.version === 1 && saved.pageSize === input.pageSize && isPage(saved.page) && isPage(saved.nextPage)
|
|
177
|
+
&& (saved.nextPage === 0 || saved.nextPage === saved.page || saved.page < Number.MAX_SAFE_INTEGER && saved.nextPage === saved.page + 1) ? saved.nextPage : 0;
|
|
178
|
+
const privacy = durableStrings();
|
|
179
|
+
let result;
|
|
180
|
+
let pageRead = false;
|
|
181
|
+
const empty = () => ({ ok: true, checked: 0, updated: 0, trackingRefs: 0, quarantined: 0, stateConflicts: 0, exceptions: 0,
|
|
182
|
+
stale: 0, offTrackSkips: 0, replayed: 0, failures: 0, invoiceIssueFailures: 0, invoiceIssueRefused: 0, invoiceIssueRefusals: [], providerCalls: 1, readBacks: 0, skipped: false });
|
|
183
|
+
try {
|
|
184
|
+
const response = await input.provider.readPage({ page, size: input.pageSize });
|
|
185
|
+
if (response.kind === "page_exhausted")
|
|
186
|
+
result = { ...empty(), reason: "page_exhausted" };
|
|
187
|
+
else if (response.kind === "failed") {
|
|
188
|
+
const providerError = response.error ? operationError(response.error) : undefined;
|
|
189
|
+
result = { ...empty(), ok: false, failures: 1, ...(providerError ? { providerError } : {}),
|
|
190
|
+
reason: providerError?.code ?? privacy.token(response.refusal.reason) ?? "redacted" };
|
|
148
191
|
}
|
|
192
|
+
else {
|
|
193
|
+
pageRead = true;
|
|
194
|
+
result = await reconcileFulfillmentObservations({ ...input, batchSize: input.pageSize, page, readPage: async () => response.rows });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
result = { ...empty(), ok: false, failures: 1, reason: localFailureReason(pageRead ? "reconciliation_page_failed" : "reconciliation_provider_read_failed", error, privacy) };
|
|
149
199
|
}
|
|
150
|
-
|
|
200
|
+
if (privacy.redactedFieldCount)
|
|
201
|
+
result.redactedFieldCount = (result.redactedFieldCount ?? 0) + privacy.redactedFieldCount;
|
|
202
|
+
// A pre-traversal abort must not skip a page. Item failures return normally.
|
|
203
|
+
const nextPage = pageRead && result.checked === 0 && !result.ok ? page
|
|
204
|
+
: result.checked === input.pageSize && page < Number.MAX_SAFE_INTEGER ? page + 1 : 0;
|
|
205
|
+
return { status: result.ok ? 200 : 502, ledgerStatus: result.ok ? "succeeded" : "failed", result,
|
|
206
|
+
metadata: { reconciliationPagination: { version: 1, page, pageSize: input.pageSize, nextPage } } };
|
|
151
207
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FulfillmentStockSyncPort, FulfillmentStockSyncProvider } from "./contracts/index.js";
|
|
1
|
+
import type { FulfillmentStockSyncPort, FulfillmentStockSyncProvider, SyncCursor } from "./contracts/index.js";
|
|
2
2
|
type Threshold = Parameters<FulfillmentStockSyncPort["resolveLowStockEvidence"]>[0]["activeThresholdKinds"][number];
|
|
3
3
|
interface StockSyncResult {
|
|
4
4
|
ok: boolean;
|
|
@@ -17,6 +17,10 @@ interface StockSyncResult {
|
|
|
17
17
|
syncRunId: string;
|
|
18
18
|
lastMovementOccurredAt: string | null;
|
|
19
19
|
reason?: string;
|
|
20
|
+
rejected: number;
|
|
21
|
+
items: NonNullable<SyncCursor["items"]>[number][];
|
|
22
|
+
providerError?: NonNullable<SyncCursor["error"]>["providerError"];
|
|
23
|
+
refusal?: NonNullable<SyncCursor["refusal"]>;
|
|
20
24
|
}
|
|
21
25
|
export declare function runFulfillmentStockSyncWorker(input: {
|
|
22
26
|
port: FulfillmentStockSyncPort;
|
|
@@ -7,6 +7,8 @@ export async function runFulfillmentStockSyncWorker(input) {
|
|
|
7
7
|
const syncRunId = input.compatibility.syncRunId(now);
|
|
8
8
|
const result = {
|
|
9
9
|
ok: true,
|
|
10
|
+
rejected: 0,
|
|
11
|
+
items: [],
|
|
10
12
|
checked: 0,
|
|
11
13
|
updated: 0,
|
|
12
14
|
mismatches: 0,
|
|
@@ -22,143 +24,219 @@ export async function runFulfillmentStockSyncWorker(input) {
|
|
|
22
24
|
syncRunId,
|
|
23
25
|
lastMovementOccurredAt: null,
|
|
24
26
|
};
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (syncIsStale) {
|
|
33
|
-
await input.port.recordLowStockEvidence({
|
|
34
|
-
requestKey: input.compatibility.staleKey(previousCursor.lastStockSyncedAt),
|
|
35
|
-
skuRef: input.compatibility.staleEvidenceSku,
|
|
36
|
-
thresholdKind: "stale_sync",
|
|
37
|
-
severity: "warning",
|
|
38
|
-
providerForSaleQuantity: 0,
|
|
39
|
-
localAvailableQuantity: 0,
|
|
40
|
-
syncRunId,
|
|
41
|
-
});
|
|
42
|
-
}
|
|
43
|
-
await input.port.recordCursor({
|
|
44
|
-
status: "running",
|
|
45
|
-
lastStockSyncedAt: null,
|
|
46
|
-
lastMovementOccurredAt: null,
|
|
47
|
-
continuation: { nextRef: null, startLocalDate: null, endLocalDate: null },
|
|
48
|
-
coverageGapSkus: [], refusal: null,
|
|
49
|
-
});
|
|
50
|
-
const [localStock, providerStock] = await Promise.all([
|
|
51
|
-
input.port.readLocalInventoryStock(now.toISOString()),
|
|
52
|
-
input.provider.getStock(),
|
|
53
|
-
]);
|
|
54
|
-
result.providerCalls += 1;
|
|
55
|
-
const stockItems = providerStock.filter((item) => item.skuRef.trim());
|
|
56
|
-
const skus = [...new Set(stockItems.map((item) => item.skuRef))];
|
|
57
|
-
const [inventoryClasses, activeReservations] = await Promise.all([
|
|
58
|
-
input.port.readSkuInventoryClasses(skus),
|
|
59
|
-
input.port.readActiveReservations(skus, now.toISOString()),
|
|
60
|
-
]);
|
|
61
|
-
const movements = await input.provider.getStockMovements({ ...stockMovementWindow(previousCursor, now, input.compatibility.timeZone), size: input.movementBatchSize });
|
|
62
|
-
result.providerCalls += 1;
|
|
63
|
-
result.movements = movements.length;
|
|
64
|
-
result.lastMovementOccurredAt = latestOccurredAt(movements);
|
|
65
|
-
const localBySku = aggregateAtpCompatibleStock(localStock, now);
|
|
66
|
-
const coverageCandidates = [];
|
|
27
|
+
const alerts = [];
|
|
28
|
+
let stage = "cursor_read_failed";
|
|
29
|
+
let operationalReason;
|
|
30
|
+
let staleSync;
|
|
31
|
+
let recoverStaleSync = false;
|
|
32
|
+
let previousCursor = null;
|
|
33
|
+
let rejectedStockResponse = false;
|
|
67
34
|
const currentCoverageGapSkus = new Set();
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
requestKey: input.compatibility.
|
|
84
|
-
|
|
35
|
+
try {
|
|
36
|
+
previousCursor = await input.port.readCursor();
|
|
37
|
+
// Stale-sync alert: if the last successful sync aged past the threshold, open a stale_sync
|
|
38
|
+
// low-stock evidence row (evidence/warn — never blocks sales); a healthy run resolves it
|
|
39
|
+
// below. A never-synced state (no prior cursor) is not "stale".
|
|
40
|
+
const lastSyncedMs = previousCursor?.lastStockSyncedAt ? Date.parse(previousCursor.lastStockSyncedAt) : null;
|
|
41
|
+
const staleGapHours = lastSyncedMs !== null && !Number.isNaN(lastSyncedMs) ? (now.getTime() - lastSyncedMs) / 3_600_000 : null;
|
|
42
|
+
const syncIsStale = staleGapHours !== null && staleGapHours >= staleThresholdHours;
|
|
43
|
+
recoverStaleSync = !syncIsStale;
|
|
44
|
+
if (syncIsStale) {
|
|
45
|
+
staleSync = { gapHours: Math.round(staleGapHours * 10) / 10, thresholdHours: staleThresholdHours, detectedAt: now.toISOString() };
|
|
46
|
+
stage = "stock_evidence_write_failed";
|
|
47
|
+
alerts.push({ skuRef: input.compatibility.staleEvidenceSku, thresholdKind: "stale_sync", severity: "warning",
|
|
48
|
+
providerForSaleQuantity: 0, localAvailableQuantity: 0, ...staleSync });
|
|
49
|
+
await input.port.recordLowStockEvidence({
|
|
50
|
+
requestKey: input.compatibility.staleKey(previousCursor.lastStockSyncedAt),
|
|
51
|
+
skuRef: input.compatibility.staleEvidenceSku,
|
|
52
|
+
thresholdKind: "stale_sync",
|
|
53
|
+
severity: "warning",
|
|
54
|
+
providerForSaleQuantity: 0,
|
|
55
|
+
localAvailableQuantity: 0,
|
|
56
|
+
syncRunId,
|
|
85
57
|
});
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
58
|
+
}
|
|
59
|
+
stage = "cursor_write_failed";
|
|
60
|
+
await input.port.recordCursor({
|
|
61
|
+
syncRunId,
|
|
62
|
+
status: "running",
|
|
63
|
+
lastStockSyncedAt: null,
|
|
64
|
+
lastMovementOccurredAt: null,
|
|
65
|
+
continuation: { nextRef: null, startLocalDate: null, endLocalDate: null },
|
|
66
|
+
coverageGapSkus: previousCursor?.coverageGapSkus ?? [], refusal: null,
|
|
67
|
+
});
|
|
68
|
+
stage = "stock_read_failed";
|
|
69
|
+
result.providerCalls += 1;
|
|
70
|
+
const [localStock, providerStock] = await Promise.all([
|
|
71
|
+
input.port.readLocalInventoryStock(now.toISOString()),
|
|
72
|
+
input.provider.getStock(),
|
|
73
|
+
]);
|
|
74
|
+
const stockItems = [];
|
|
75
|
+
for (const [itemIndex, item] of providerStock.entries()) {
|
|
76
|
+
if ("kind" in item && item.kind === "rejected") {
|
|
77
|
+
result.rejected += 1;
|
|
78
|
+
result.failures += 1;
|
|
79
|
+
result.items.push({ kind: "stock", itemIndex: item.itemIndex, skuRef: null, status: "rejected", reason: item.reason });
|
|
80
|
+
}
|
|
81
|
+
else if ("skuRef" in item && item.skuRef.trim()) {
|
|
82
|
+
stockItems.push({ stock: item, itemIndex });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const skus = [...new Set(stockItems.map(({ stock }) => stock.skuRef))];
|
|
86
|
+
rejectedStockResponse = providerStock.length > 0 && stockItems.length === 0;
|
|
87
|
+
stage = "stock_inventory_read_failed";
|
|
88
|
+
const [inventoryClasses, activeReservations] = await Promise.all([
|
|
89
|
+
input.port.readSkuInventoryClasses(skus),
|
|
90
|
+
input.port.readActiveReservations(skus, now.toISOString()),
|
|
91
|
+
]);
|
|
92
|
+
stage = "stock_movements_read_failed";
|
|
93
|
+
result.providerCalls += 1;
|
|
94
|
+
const movementRows = await input.provider.getStockMovements({ ...stockMovementWindow(previousCursor, now, input.compatibility.timeZone), size: input.movementBatchSize });
|
|
95
|
+
const movements = [];
|
|
96
|
+
for (const item of movementRows) {
|
|
97
|
+
if ("kind" in item && item.kind === "rejected") {
|
|
98
|
+
result.rejected += 1;
|
|
99
|
+
result.failures += 1;
|
|
100
|
+
result.items.push({ kind: "movement", itemIndex: item.itemIndex, skuRef: null, status: "rejected", reason: item.reason });
|
|
101
|
+
}
|
|
102
|
+
else if ("skuRef" in item) {
|
|
103
|
+
movements.push(item);
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
|
-
|
|
106
|
+
if (rejectedStockResponse || (result.rejected > 0 && stockItems.length + movements.length === 0)) {
|
|
107
107
|
result.ok = false;
|
|
108
|
-
result.
|
|
109
|
-
result.reason = result.reason ?? "stock_sync_operation_failed";
|
|
108
|
+
result.reason = "invalid_sku_ref";
|
|
110
109
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
110
|
+
result.movements = movements.length;
|
|
111
|
+
result.lastMovementOccurredAt = latestOccurredAt(movements);
|
|
112
|
+
const localBySku = aggregateAtpCompatibleStock(localStock, now);
|
|
113
|
+
const coverageCandidates = [];
|
|
114
|
+
const packagingSkus = new Set();
|
|
115
|
+
const previousCoverageGapSkus = previousCursor?.status === "succeeded"
|
|
116
|
+
? readStringSet(previousCursor.coverageGapSkus)
|
|
117
|
+
: new Set();
|
|
118
|
+
for (const { stock, itemIndex } of stockItems) {
|
|
119
|
+
try {
|
|
120
|
+
const local = localBySku.get(stock.skuRef) ?? emptyLocal(stock.skuRef);
|
|
121
|
+
const inventoryClass = inventoryClasses.get(stock.skuRef) ?? null;
|
|
122
|
+
const activeReserved = activeReservations.get(stock.skuRef) ?? 0;
|
|
123
|
+
const staleAfter = new Date(now.getTime() + staleThresholdHours * 3_600_000).toISOString();
|
|
124
|
+
stage = "stock_current_write_failed";
|
|
125
|
+
const current = await input.port.recordProviderStockCurrent({
|
|
126
|
+
requestKey: input.compatibility.currentKey(syncRunId, stock.skuRef),
|
|
127
|
+
snapshot: stock, inventoryClass, lastSyncedAt: now.toISOString(), staleAfter,
|
|
128
|
+
});
|
|
129
|
+
stage = "snapshot_write_failed";
|
|
130
|
+
const snapshot = await input.port.recordStockSnapshot({
|
|
131
|
+
requestKey: input.compatibility.snapshotKey(syncRunId, stock.skuRef),
|
|
132
|
+
snapshot: stock, local, mismatchKind: "none", inventoryClass,
|
|
126
133
|
});
|
|
127
|
-
|
|
134
|
+
result.items.push({ itemIndex, skuRef: stock.skuRef, status: "recorded", activeReserved,
|
|
135
|
+
sellableNow: inventoryClass === "sellable" ? Math.max(0, stock.forSaleQuantity - activeReserved - local.safetyStock) : null,
|
|
136
|
+
coverageGapQuantity: Math.max(0, activeReserved - stock.forSaleQuantity) });
|
|
137
|
+
result.checked += 1;
|
|
138
|
+
result.updated += (snapshot.replayed ? 0 : 1) + (current.replayed ? 0 : 1);
|
|
139
|
+
if (snapshot.replayed)
|
|
140
|
+
result.replayed += 1;
|
|
141
|
+
if (current.replayed)
|
|
128
142
|
result.replayed += 1;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
143
|
+
if (snapshot.readBack)
|
|
144
|
+
result.readBacks += 1;
|
|
145
|
+
if (current.readBack)
|
|
146
|
+
result.readBacks += 1;
|
|
147
|
+
if (inventoryClass === null)
|
|
148
|
+
result.unclassified += 1;
|
|
149
|
+
if (inventoryClass === "packaging")
|
|
150
|
+
packagingSkus.add(stock.skuRef);
|
|
151
|
+
if (inventoryClass === "sellable") {
|
|
152
|
+
coverageCandidates.push({ stock, local, activeReserved });
|
|
153
|
+
if (activeReserved > stock.forSaleQuantity)
|
|
154
|
+
currentCoverageGapSkus.add(stock.skuRef);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
result.ok = false;
|
|
159
|
+
result.failures += 1;
|
|
160
|
+
const reason = safeReason(error, stage);
|
|
161
|
+
operationalReason ??= reason;
|
|
162
|
+
result.items.push({ itemIndex, skuRef: stock.skuRef, status: "failed", reason });
|
|
132
163
|
}
|
|
133
|
-
await input.port.resolveLowStockEvidence({
|
|
134
|
-
skuRef: stock.skuRef,
|
|
135
|
-
activeThresholdKinds,
|
|
136
|
-
syncRunId,
|
|
137
|
-
});
|
|
138
164
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
165
|
+
if (result.ok) {
|
|
166
|
+
for (const { stock, local, activeReserved } of coverageCandidates) {
|
|
167
|
+
const localAvailable = availableQuantity(local);
|
|
168
|
+
const confirmedCoverageGap = currentCoverageGapSkus.has(stock.skuRef) && previousCoverageGapSkus.has(stock.skuRef);
|
|
169
|
+
const lowStockSignals = lowStockSignalsFor(stock, local, activeReserved, confirmedCoverageGap);
|
|
170
|
+
const activeThresholdKinds = lowStockSignals.map((signal) => signal.thresholdKind);
|
|
171
|
+
if (currentCoverageGapSkus.has(stock.skuRef) && !activeThresholdKinds.includes("reservation_coverage")) {
|
|
172
|
+
activeThresholdKinds.push("reservation_coverage");
|
|
173
|
+
}
|
|
174
|
+
for (const signal of lowStockSignals) {
|
|
175
|
+
alerts.push({ skuRef: stock.skuRef, thresholdKind: signal.thresholdKind, severity: signal.severity,
|
|
176
|
+
providerForSaleQuantity: stock.forSaleQuantity, localAvailableQuantity: localAvailable, detectedAt: now.toISOString(),
|
|
177
|
+
activeReserved, coverageGapQuantity: Math.max(0, activeReserved - stock.forSaleQuantity) });
|
|
178
|
+
stage = "stock_evidence_write_failed";
|
|
179
|
+
const evidence = await input.port.recordLowStockEvidence({
|
|
180
|
+
requestKey: input.compatibility.lowStockKey(stock.skuRef, signal.thresholdKind),
|
|
181
|
+
skuRef: stock.skuRef, thresholdKind: signal.thresholdKind, severity: signal.severity,
|
|
182
|
+
providerForSaleQuantity: stock.forSaleQuantity, localAvailableQuantity: localAvailable, syncRunId,
|
|
183
|
+
});
|
|
184
|
+
if (evidence.replayed)
|
|
185
|
+
result.replayed += 1;
|
|
186
|
+
result.lowStock += evidence.replayed ? 0 : 1;
|
|
187
|
+
if (signal.thresholdKind === "reservation_coverage")
|
|
188
|
+
result.reservationCoverage += 1;
|
|
189
|
+
}
|
|
190
|
+
stage = "stock_evidence_resolve_failed";
|
|
191
|
+
await input.port.resolveLowStockEvidence({
|
|
192
|
+
skuRef: stock.skuRef,
|
|
193
|
+
activeThresholdKinds,
|
|
194
|
+
syncRunId,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
for (const sku of packagingSkus) {
|
|
198
|
+
stage = "stock_evidence_resolve_failed";
|
|
199
|
+
await input.port.resolveLowStockEvidence({
|
|
200
|
+
skuRef: sku,
|
|
201
|
+
activeThresholdKinds: [],
|
|
202
|
+
syncRunId,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
145
205
|
}
|
|
146
206
|
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
result.ok = false;
|
|
209
|
+
result.failures += 1;
|
|
210
|
+
result.providerError = providerDiagnostic(error);
|
|
211
|
+
if (stage === "stock_read_failed" || stage === "stock_movements_read_failed")
|
|
212
|
+
result.refusal = admissionRefusal(error);
|
|
213
|
+
operationalReason ??= result.providerError ? `provider_${result.providerError.operation}_failed:${result.providerError.code}` : result.refusal?.reason ?? safeReason(error, stage);
|
|
214
|
+
}
|
|
215
|
+
result.reason = operationalReason ?? result.reason;
|
|
216
|
+
if (!result.ok)
|
|
217
|
+
result.refusal ??= { reason: result.failures === result.rejected ? "malformed" : "io_failure", phase: "sync", retryable: result.providerError?.retryable ?? result.failures > result.rejected };
|
|
147
218
|
await input.port.recordCursor({
|
|
148
|
-
status: result.ok ? "succeeded" : "failed",
|
|
219
|
+
status: result.ok ? "succeeded" : "failed", syncRunId,
|
|
149
220
|
lastStockSyncedAt: result.ok ? now.toISOString() : null,
|
|
150
221
|
lastMovementOccurredAt: result.lastMovementOccurredAt,
|
|
151
222
|
continuation: { nextRef: null, startLocalDate: null, endLocalDate: null },
|
|
152
|
-
coverageGapSkus: result.ok ? [...currentCoverageGapSkus].sort() : [],
|
|
153
|
-
refusal: result.
|
|
223
|
+
coverageGapSkus: result.ok ? [...currentCoverageGapSkus].sort() : rejectedStockResponse ? previousCursor?.coverageGapSkus ?? [] : [],
|
|
224
|
+
refusal: result.refusal ?? null,
|
|
225
|
+
error: result.ok ? null : { reason: result.reason, ...(result.providerError ? { providerError: result.providerError } : {}) },
|
|
226
|
+
counts: { checked: result.checked, updated: result.updated, movements: result.movements, rejected: result.rejected,
|
|
227
|
+
failures: result.failures, providerCalls: result.providerCalls, readBacks: result.readBacks },
|
|
228
|
+
items: result.items, alerts, staleSync,
|
|
154
229
|
});
|
|
155
|
-
// Recovery
|
|
156
|
-
if (result.ok &&
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
230
|
+
// Recovery follows the durable succeeded cursor; a failed cleanup leaves the completed run intact.
|
|
231
|
+
if (result.ok && recoverStaleSync) {
|
|
232
|
+
try {
|
|
233
|
+
await input.port.resolveLowStockEvidence({
|
|
234
|
+
skuRef: input.compatibility.staleEvidenceSku,
|
|
235
|
+
activeThresholdKinds: [],
|
|
236
|
+
syncRunId,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
catch { /* Best-effort cleanup must not undo the completed run. */ }
|
|
162
240
|
}
|
|
163
241
|
return result;
|
|
164
242
|
}
|
|
@@ -184,3 +262,34 @@ function latestOccurredAt(movements) {
|
|
|
184
262
|
.filter((value) => Boolean(value))
|
|
185
263
|
.sort((a, b) => Date.parse(b) - Date.parse(a))[0] ?? null;
|
|
186
264
|
}
|
|
265
|
+
/** Keep the operation's closed identity and SQLSTATE only, never arbitrary exception prose. */
|
|
266
|
+
function safeReason(error, operation) {
|
|
267
|
+
const code = error && typeof error === "object" && "code" in error ? error.code : null;
|
|
268
|
+
if (typeof code === "string" && /^[0-9A-Z]{5}$/.test(code))
|
|
269
|
+
return `${operation}:${code}`;
|
|
270
|
+
const known = error instanceof Error ? error.message.match(new RegExp(`^${operation}:([0-9A-Z]{5})$`)) : null;
|
|
271
|
+
return known ? `${operation}:${known[1]}` : operation;
|
|
272
|
+
}
|
|
273
|
+
function providerDiagnostic(error) {
|
|
274
|
+
if (!error || typeof error !== "object" || !("providerError" in error))
|
|
275
|
+
return undefined;
|
|
276
|
+
const detail = error.providerError;
|
|
277
|
+
if (!detail || typeof detail !== "object" || !("operation" in detail) || !("code" in detail) || !("status" in detail) || !("retryable" in detail))
|
|
278
|
+
return undefined;
|
|
279
|
+
if (detail.operation !== "get_stock" && detail.operation !== "get_stock_movements")
|
|
280
|
+
return undefined;
|
|
281
|
+
if (typeof detail.code !== "string" || !/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(detail.code))
|
|
282
|
+
return undefined;
|
|
283
|
+
return { operation: detail.operation, code: detail.code,
|
|
284
|
+
status: typeof detail.status === "number" && Number.isInteger(detail.status) && detail.status >= 100 && detail.status <= 599 ? detail.status : null,
|
|
285
|
+
retryable: detail.retryable === true };
|
|
286
|
+
}
|
|
287
|
+
function admissionRefusal(error) {
|
|
288
|
+
if (!error || typeof error !== "object" || !("reason" in error) || !("phase" in error) || !("retryable" in error))
|
|
289
|
+
return undefined;
|
|
290
|
+
if (error.phase !== "sync" || typeof error.retryable !== "boolean")
|
|
291
|
+
return undefined;
|
|
292
|
+
if (error.reason !== "disabled" && error.reason !== "not_ready" && error.reason !== "malformed")
|
|
293
|
+
return undefined;
|
|
294
|
+
return { reason: error.reason, phase: error.phase, retryable: error.retryable };
|
|
295
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import type { FulfillmentTrackingReference } from "./contracts/index.js";
|
|
1
|
+
import type { FulfillmentTrackingReference, FulfillmentObservationCodecs } from "./contracts/index.js";
|
|
2
2
|
export declare function trackingReferenceComplete(ref: FulfillmentTrackingReference): boolean;
|
|
3
3
|
/** Provider evidence wins as a unit; private carrier/URL codecs stay injected. */
|
|
4
4
|
export declare function enrichTrackingReference(ref: FulfillmentTrackingReference, deliveryCarrier: {
|
|
5
5
|
carrierRef: string | null;
|
|
6
6
|
serviceRef: string | null;
|
|
7
|
-
} | null, codecs: {
|
|
7
|
+
} | null, codecs: FulfillmentObservationCodecs & {
|
|
8
|
+
approveTrackingUrl?(url: string): boolean;
|
|
8
9
|
normalizeCarrier(input: {
|
|
9
10
|
carrierRef: string | null;
|
|
10
11
|
serviceRef: string | null;
|