@openlup/platform 0.22.0 → 0.23.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 +222 -99
- package/README.md +59 -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 +41 -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 +95 -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 +17 -7
- package/dist/fulfillment/omnipack/index.d.ts +4 -1
- package/dist/fulfillment/omnipack/index.js +104 -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 +13 -0
- package/dist/fulfillment/providerDispatchWorker.js +47 -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/capabilities.d.ts +11 -0
- package/dist/invoicing/capabilities.js +10 -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 +100 -0
- package/dist/invoicing/contracts/facts.js +1 -0
- package/dist/invoicing/contracts/host.d.ts +246 -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 +267 -0
- package/dist/invoicing/contracts/store.js +1 -0
- package/dist/invoicing/index.d.ts +1 -0
- package/dist/invoicing/index.js +1 -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 +22 -2
- package/release-gates.json +56 -3
- package/sql/20261022160000_subscription_retry_lifecycle.sql +328 -0
- package/sql/20261022165000_fulfillment_stock_evidence_replay_reopen.sql +30 -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 +20 -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 +92 -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 +19 -7
- package/src/fulfillment/omnipack/index.ts +96 -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 +48 -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/capabilities.ts +9 -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 +60 -0
- package/src/invoicing/contracts/host.ts +85 -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 +98 -0
- package/src/invoicing/index.ts +1 -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,6 +1,7 @@
|
|
|
1
|
-
import type { FulfillmentObservation,
|
|
1
|
+
import type { FulfillmentObservation, FulfillmentObservationPagePort, FulfillmentReconciliationObservation, FulfillmentOperationError } from "./contracts/index.js";
|
|
2
|
+
import { durableStrings, localFailureReason, safeDiagnosticCode, operationError } from "./dispatchProjection.js";
|
|
2
3
|
import { applyFulfillmentObservationEffects } from "./fulfillmentObservationEffects.js";
|
|
3
|
-
import { observationStatusPolicy,
|
|
4
|
+
import { observationStatusPolicy, projectObservation, projectPollEvidence, projectTrackingReference } from "./observationProjection.js";
|
|
4
5
|
import { quarantineReconciliationStateConflict } from "./providerReconciliationError.js";
|
|
5
6
|
import { isOffTrackFulfillmentStatus } from "./providerReconciliationTerminalSkip.js";
|
|
6
7
|
import { normalizeReconciliationTimestamp } from "./providerReconciliationTimestamp.js";
|
|
@@ -8,22 +9,24 @@ import { enrichTrackingReference, trackingReferenceComplete, uniqueTrackingRefer
|
|
|
8
9
|
|
|
9
10
|
type Ingress = Parameters<typeof import("./verifiedIngress.js").verifyAndNormalizeFulfillmentObservation>[0];
|
|
10
11
|
type DispatchRef = NonNullable<Awaited<ReturnType<Ingress["lifecycle"]["findDispatchRef"]>>>;
|
|
11
|
-
type PollEvidence = FulfillmentObservation |
|
|
12
|
-
interface ReconciliationResult {
|
|
12
|
+
type PollEvidence = Pick<FulfillmentObservation, "correlation" | "eventKey" | "providerStatus" | "diagnostics"> | FulfillmentObservation;
|
|
13
|
+
export interface ReconciliationResult {
|
|
13
14
|
ok: boolean; checked: number; updated: number; trackingRefs: number; quarantined: number;
|
|
14
15
|
stateConflicts: number; exceptions: number; stale: number; offTrackSkips: number; replayed: number;
|
|
15
|
-
failures: number; invoiceIssueFailures: number; invoiceIssueRefused: number;
|
|
16
|
+
redactedFieldCount?: number; failures: number; invoiceIssueFailures: number; invoiceIssueRefused: number;
|
|
16
17
|
invoiceIssueRefusals: { fulfillmentRef: string; identifier: string | null; sqlstate: string; benign: boolean }[];
|
|
17
|
-
providerCalls: number; readBacks: number; skipped: boolean; reason?: string;
|
|
18
|
+
providerCalls: number; readBacks: number; skipped: boolean; reason?: string; providerError?: FulfillmentOperationError;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export async function reconcileFulfillmentObservations(input: {
|
|
21
22
|
/** Adapter/host maps the private page into typed readback, preserving unknowns. */
|
|
22
|
-
readPage(input: { page: number; size: number }): Promise<readonly
|
|
23
|
+
readPage(input: { page: number; size: number }): Promise<readonly FulfillmentReconciliationObservation[]>;
|
|
23
24
|
batchSize: number; page?: number;
|
|
24
25
|
localPersistence: {
|
|
25
26
|
/** G17: local persisted chronology, never inferred from dispatch or provider state. */
|
|
26
|
-
|
|
27
|
+
readLocalFulfillmentStatus(ref: string): Promise<string | null>;
|
|
28
|
+
latestStatusOccurredAt(ref: string): Promise<string | null>;
|
|
29
|
+
readHandedOverAt(ref: string): Promise<string | null>;
|
|
27
30
|
};
|
|
28
31
|
lifecycle: Pick<Ingress["lifecycle"], "recordStatusEvidence" | "effects" | "keys"> & {
|
|
29
32
|
findDispatchRef(evidence: PollEvidence): Promise<DispatchRef | null>;
|
|
@@ -38,6 +41,8 @@ export async function reconcileFulfillmentObservations(input: {
|
|
|
38
41
|
classifyStateConflict(error: unknown): "fulfillment_not_found" | "stock_consumed_requires_label" | null;
|
|
39
42
|
};
|
|
40
43
|
}): Promise<ReconciliationResult> {
|
|
44
|
+
const privacy = durableStrings();
|
|
45
|
+
const failure = (stage: string, error: unknown) => localFailureReason(stage, error, privacy);
|
|
41
46
|
const result: ReconciliationResult = {
|
|
42
47
|
ok: true, checked: 0, updated: 0, trackingRefs: 0, quarantined: 0, stateConflicts: 0, exceptions: 0,
|
|
43
48
|
stale: 0, offTrackSkips: 0, replayed: 0, failures: 0, invoiceIssueFailures: 0, invoiceIssueRefused: 0,
|
|
@@ -45,8 +50,6 @@ export async function reconcileFulfillmentObservations(input: {
|
|
|
45
50
|
};
|
|
46
51
|
const page = await input.readPage({ page: input.page ?? 0, size: input.batchSize });
|
|
47
52
|
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
53
|
result.checked = page.length;
|
|
51
54
|
const quarantine = async (observation: PollEvidence | null, reason: Parameters<typeof input.lifecycle.recordQuarantine>[1]) => {
|
|
52
55
|
const evidence = await input.lifecycle.recordQuarantine(observation, reason);
|
|
@@ -59,26 +62,31 @@ export async function reconcileFulfillmentObservations(input: {
|
|
|
59
62
|
const refusal = input.lifecycle.invoiceFailure(error);
|
|
60
63
|
if (refusal.permanent) {
|
|
61
64
|
result.invoiceIssueRefused += 1;
|
|
65
|
+
const identifier = privacy.token(refusal.identifier, safeDiagnosticCode), sqlstate = privacy.token(refusal.sqlstate, safeDiagnosticCode);
|
|
62
66
|
if (result.invoiceIssueRefusals.length < 10) result.invoiceIssueRefusals.push({ fulfillmentRef: ref,
|
|
63
|
-
identifier
|
|
64
|
-
result.reason ??= "reconciliation_invoice_issue_refused";
|
|
67
|
+
identifier, sqlstate: sqlstate ?? "22023", benign: refusal.benign });
|
|
68
|
+
result.reason ??= failure("reconciliation_invoice_issue_refused", { causeMessage: identifier, causeCode: sqlstate, message: error instanceof Error ? error.message : undefined });
|
|
65
69
|
return;
|
|
66
70
|
}
|
|
67
71
|
result.ok = false; result.failures += 1; result.invoiceIssueFailures += 1;
|
|
68
|
-
result.reason ??= "reconciliation_invoice_issue_failed";
|
|
72
|
+
result.reason ??= failure("reconciliation_invoice_issue_failed", error);
|
|
69
73
|
}
|
|
70
74
|
};
|
|
71
75
|
for (const readback of page) {
|
|
72
76
|
let observation: FulfillmentObservation | null = null;
|
|
73
77
|
try {
|
|
78
|
+
if (readback.kind === "unknown") {
|
|
79
|
+
result.ok = false; result.failures += 1; result.reason ??= "fulfillment_reconciliation_provider_read_failed";
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
74
82
|
if (readback.kind === "unmapped") {
|
|
75
83
|
// An unknown primary status still correlates/ACKs, but an off-track row
|
|
76
84
|
// must never trade a benign skip for a quarantine alarm.
|
|
77
|
-
const evidence =
|
|
85
|
+
const evidence = projectPollEvidence(readback.evidence);
|
|
78
86
|
const ref = await input.lifecycle.findDispatchRef(evidence);
|
|
79
87
|
if (!ref) { await quarantine(evidence, "missing_correlation"); continue; }
|
|
80
|
-
const
|
|
81
|
-
if (isOffTrackFulfillmentStatus(
|
|
88
|
+
const localState = await input.localPersistence.readLocalFulfillmentStatus(ref.fulfillmentRef);
|
|
89
|
+
if (isOffTrackFulfillmentStatus(localState)) { result.offTrackSkips += 1; continue; }
|
|
82
90
|
if (!(readback.externalRef?.trim() || ref.externalRef?.trim())) { await quarantine(evidence, "missing_external_ref"); continue; }
|
|
83
91
|
await input.lifecycle.acknowledgeDispatchAcceptance(ref, evidence);
|
|
84
92
|
await quarantine(evidence, "unknown_status");
|
|
@@ -88,11 +96,14 @@ export async function reconcileFulfillmentObservations(input: {
|
|
|
88
96
|
// Neither unsupported nor a provider absence is a local transition.
|
|
89
97
|
continue;
|
|
90
98
|
}
|
|
91
|
-
observation = projectObservation(readback.observation);
|
|
99
|
+
observation = projectObservation(readback.observation, input.lifecycle.trackingCodecs.approveTrackingUrl, input.lifecycle.trackingCodecs);
|
|
100
|
+
if (observation.diagnostics?.redactedFieldCount) {
|
|
101
|
+
result.redactedFieldCount = (result.redactedFieldCount ?? 0) + observation.diagnostics.redactedFieldCount;
|
|
102
|
+
}
|
|
92
103
|
const ref = await input.lifecycle.findDispatchRef(observation);
|
|
93
104
|
if (!ref) { await quarantine(observation, "missing_correlation"); continue; }
|
|
94
|
-
const
|
|
95
|
-
const offTrack = isOffTrackFulfillmentStatus(
|
|
105
|
+
const localState = await input.localPersistence.readLocalFulfillmentStatus(ref.fulfillmentRef);
|
|
106
|
+
const offTrack = isOffTrackFulfillmentStatus(localState);
|
|
96
107
|
if (offTrack) result.offTrackSkips += 1;
|
|
97
108
|
if (!offTrack) {
|
|
98
109
|
if (!(readback.externalRef.trim() || ref.externalRef?.trim())) { await quarantine(observation, "missing_external_ref"); continue; }
|
|
@@ -100,9 +111,10 @@ export async function reconcileFulfillmentObservations(input: {
|
|
|
100
111
|
}
|
|
101
112
|
const occurredAt = normalizeReconciliationTimestamp(readback.updatedAt);
|
|
102
113
|
const localStatus = observation.fact.canonicalStatus;
|
|
103
|
-
|
|
114
|
+
const latest = await input.localPersistence.latestStatusOccurredAt(ref.fulfillmentRef);
|
|
115
|
+
if (latest && occurredAt && Date.parse(occurredAt) < Date.parse(latest)) {
|
|
104
116
|
result.stale += 1;
|
|
105
|
-
if (!offTrack && input.lifecycle.issueAccountingInvoice &&
|
|
117
|
+
if (!offTrack && input.lifecycle.issueAccountingInvoice && await input.localPersistence.readHandedOverAt(ref.fulfillmentRef)) await invoice(ref.fulfillmentRef);
|
|
106
118
|
continue;
|
|
107
119
|
}
|
|
108
120
|
// The normalized fact carries the same nullable occurrence used by dedupe.
|
|
@@ -115,7 +127,7 @@ export async function reconcileFulfillmentObservations(input: {
|
|
|
115
127
|
const exception = await input.lifecycle.recordProviderException(ref, observation);
|
|
116
128
|
if (exception.replayed) result.replayed += 1;
|
|
117
129
|
result.exceptions += exception.replayed ? 0 : 1;
|
|
118
|
-
if (input.lifecycle.issueAccountingInvoice &&
|
|
130
|
+
if (input.lifecycle.issueAccountingInvoice && await input.localPersistence.readHandedOverAt(ref.fulfillmentRef)) await invoice(ref.fulfillmentRef);
|
|
119
131
|
continue;
|
|
120
132
|
}
|
|
121
133
|
const trackingRefs = [];
|
|
@@ -123,7 +135,12 @@ export async function reconcileFulfillmentObservations(input: {
|
|
|
123
135
|
let carrier: Awaited<ReturnType<typeof input.lifecycle.readDeliveryCarrier>> | undefined;
|
|
124
136
|
for (const tracking of uniqueTrackingReferences(observation.trackingRefs)) {
|
|
125
137
|
if (!trackingReferenceComplete(tracking) && carrier === undefined) carrier = await input.lifecycle.readDeliveryCarrier(ref.fulfillmentRef);
|
|
126
|
-
const
|
|
138
|
+
const codecs = input.lifecycle.trackingCodecs;
|
|
139
|
+
const candidate = enrichTrackingReference(tracking, carrier ?? null, { ...codecs, publicUrl: () => null });
|
|
140
|
+
const enriched = projectTrackingReference(candidate, privacy, codecs.approveTrackingUrl, codecs);
|
|
141
|
+
if (!enriched) continue;
|
|
142
|
+
// Host-built links use admitted parts and pass through unchanged, including queries.
|
|
143
|
+
enriched.publicUrl ??= codecs.publicUrl(enriched.carrierRef, enriched.trackingNumber);
|
|
127
144
|
trackingRefs.push({ trackingNumber: enriched.trackingNumber, trackingUrl: enriched.publicUrl, carrierKind: enriched.carrierRef, service: enriched.serviceRef });
|
|
128
145
|
}
|
|
129
146
|
}
|
|
@@ -143,8 +160,47 @@ export async function reconcileFulfillmentObservations(input: {
|
|
|
143
160
|
const error = observation ? await quarantineReconciliationStateConflict(caught, observation,
|
|
144
161
|
input.lifecycle.classifyStateConflict, input.lifecycle.recordQuarantine, result) : caught;
|
|
145
162
|
if (error === null) continue;
|
|
146
|
-
result.ok = false; result.failures += 1; result.reason ??= "reconciliation_local_operation_failed";
|
|
163
|
+
result.ok = false; result.failures += 1; result.reason ??= failure("reconciliation_local_operation_failed", error);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { ...result, ...(privacy.redactedFieldCount ? { redactedFieldCount: (result.redactedFieldCount ?? 0) + privacy.redactedFieldCount } : {}) };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
/** Host owns scheduling, HTTP and durable job-ledger writes. This operation only projects them. */
|
|
171
|
+
export async function runFulfillmentReconciliationPage(input: Omit<Parameters<typeof reconcileFulfillmentObservations>[0], "readPage" | "batchSize" | "page"> & {
|
|
172
|
+
provider: FulfillmentObservationPagePort; pageSize: number; checkpoint: unknown;
|
|
173
|
+
}) {
|
|
174
|
+
if (!Number.isSafeInteger(input.pageSize) || input.pageSize <= 0) throw new Error("reconciliation_page_size_invalid");
|
|
175
|
+
const isPage = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0;
|
|
176
|
+
const checkpoint = input.checkpoint as { reconciliationPagination?: { version?: unknown; pageSize?: unknown; page?: unknown; nextPage?: unknown } } | null;
|
|
177
|
+
const saved = checkpoint?.reconciliationPagination;
|
|
178
|
+
const page = saved?.version === 1 && saved.pageSize === input.pageSize && isPage(saved.page) && isPage(saved.nextPage)
|
|
179
|
+
&& (saved.nextPage === 0 || saved.nextPage === saved.page || saved.page < Number.MAX_SAFE_INTEGER && saved.nextPage === saved.page + 1) ? saved.nextPage : 0;
|
|
180
|
+
const privacy = durableStrings();
|
|
181
|
+
let result: ReconciliationResult;
|
|
182
|
+
let pageRead = false;
|
|
183
|
+
const empty = (): ReconciliationResult => ({ ok: true, checked: 0, updated: 0, trackingRefs: 0, quarantined: 0, stateConflicts: 0, exceptions: 0,
|
|
184
|
+
stale: 0, offTrackSkips: 0, replayed: 0, failures: 0, invoiceIssueFailures: 0, invoiceIssueRefused: 0, invoiceIssueRefusals: [], providerCalls: 1, readBacks: 0, skipped: false });
|
|
185
|
+
try {
|
|
186
|
+
const response = await input.provider.readPage({ page, size: input.pageSize });
|
|
187
|
+
if (response.kind === "page_exhausted") result = { ...empty(), reason: "page_exhausted" };
|
|
188
|
+
else if (response.kind === "failed") {
|
|
189
|
+
const providerError = response.error ? operationError(response.error) : undefined;
|
|
190
|
+
result = { ...empty(), ok: false, failures: 1, ...(providerError ? { providerError } : {}),
|
|
191
|
+
reason: providerError?.code ?? privacy.token(response.refusal.reason) ?? "redacted" };
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
pageRead = true;
|
|
195
|
+
result = await reconcileFulfillmentObservations({ ...input, batchSize: input.pageSize, page, readPage: async () => response.rows });
|
|
147
196
|
}
|
|
197
|
+
} catch (error) {
|
|
198
|
+
result = { ...empty(), ok: false, failures: 1, reason: localFailureReason(pageRead ? "reconciliation_page_failed" : "reconciliation_provider_read_failed", error, privacy) };
|
|
148
199
|
}
|
|
149
|
-
|
|
200
|
+
if (privacy.redactedFieldCount) result.redactedFieldCount = (result.redactedFieldCount ?? 0) + privacy.redactedFieldCount;
|
|
201
|
+
// A pre-traversal abort must not skip a page. Item failures return normally.
|
|
202
|
+
const nextPage = pageRead && result.checked === 0 && !result.ok ? page
|
|
203
|
+
: result.checked === input.pageSize && page < Number.MAX_SAFE_INTEGER ? page + 1 : 0;
|
|
204
|
+
return { status: result.ok ? 200 as const : 502 as const, ledgerStatus: result.ok ? "succeeded" as const : "failed" as const, result,
|
|
205
|
+
metadata: { reconciliationPagination: { version: 1, page, pageSize: input.pageSize, nextPage } } };
|
|
150
206
|
}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { aggregateAtpCompatibleStock, emptyLocal, stockMovementWindow } from "./providerStockAvailability.js";
|
|
2
|
-
import type { FulfillmentStockSyncPort, FulfillmentStockSyncProvider, LocalStock, StockSnapshot, StockMovement } from "./contracts/index.js";
|
|
2
|
+
import type { FulfillmentStockSyncPort, FulfillmentStockSyncProvider, LocalStock, StockSnapshot, StockMovement, SyncCursor } from "./contracts/index.js";
|
|
3
3
|
type Threshold = Parameters<FulfillmentStockSyncPort["resolveLowStockEvidence"]>[0]["activeThresholdKinds"][number];
|
|
4
4
|
interface StockSyncResult {
|
|
5
5
|
ok: boolean; checked: number; updated: number; mismatches: number; reservationCoverage: number;
|
|
6
6
|
unclassified: number; lowStock: number; movements: number; replayed: number; failures: number;
|
|
7
7
|
providerCalls: number; readBacks: number; skipped: boolean; syncRunId: string;
|
|
8
8
|
lastMovementOccurredAt: string | null; reason?: string;
|
|
9
|
+
rejected: number; items: NonNullable<SyncCursor["items"]>[number][];
|
|
10
|
+
providerError?: NonNullable<SyncCursor["error"]>["providerError"];
|
|
11
|
+
refusal?: NonNullable<SyncCursor["refusal"]>;
|
|
9
12
|
}
|
|
10
13
|
export async function runFulfillmentStockSyncWorker(input: {
|
|
11
14
|
port: FulfillmentStockSyncPort;
|
|
@@ -26,6 +29,8 @@ export async function runFulfillmentStockSyncWorker(input: {
|
|
|
26
29
|
const syncRunId = input.compatibility.syncRunId(now);
|
|
27
30
|
const result: StockSyncResult = {
|
|
28
31
|
ok: true,
|
|
32
|
+
rejected: 0,
|
|
33
|
+
items: [],
|
|
29
34
|
checked: 0,
|
|
30
35
|
updated: 0,
|
|
31
36
|
mismatches: 0,
|
|
@@ -42,152 +47,218 @@ export async function runFulfillmentStockSyncWorker(input: {
|
|
|
42
47
|
lastMovementOccurredAt: null,
|
|
43
48
|
};
|
|
44
49
|
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
50
|
+
const alerts: NonNullable<SyncCursor["alerts"]>[number][] = [];
|
|
51
|
+
let stage = "cursor_read_failed";
|
|
52
|
+
let operationalReason: string | undefined;
|
|
53
|
+
let staleSync: SyncCursor["staleSync"];
|
|
54
|
+
let recoverStaleSync = false;
|
|
55
|
+
let previousCursor: SyncCursor | null = null;
|
|
56
|
+
let rejectedStockResponse = false;
|
|
57
|
+
const currentCoverageGapSkus = new Set<string>();
|
|
58
|
+
try {
|
|
59
|
+
previousCursor = await input.port.readCursor();
|
|
60
|
+
|
|
61
|
+
// Stale-sync alert: if the last successful sync aged past the threshold, open a stale_sync
|
|
62
|
+
// low-stock evidence row (evidence/warn — never blocks sales); a healthy run resolves it
|
|
63
|
+
// below. A never-synced state (no prior cursor) is not "stale".
|
|
64
|
+
const lastSyncedMs = previousCursor?.lastStockSyncedAt ? Date.parse(previousCursor.lastStockSyncedAt) : null;
|
|
65
|
+
const staleGapHours =
|
|
66
|
+
lastSyncedMs !== null && !Number.isNaN(lastSyncedMs) ? (now.getTime() - lastSyncedMs) / 3_600_000 : null;
|
|
67
|
+
const syncIsStale = staleGapHours !== null && staleGapHours >= staleThresholdHours;
|
|
68
|
+
recoverStaleSync = !syncIsStale;
|
|
69
|
+
if (syncIsStale) {
|
|
70
|
+
staleSync = { gapHours: Math.round(staleGapHours! * 10) / 10, thresholdHours: staleThresholdHours, detectedAt: now.toISOString() };
|
|
71
|
+
stage = "stock_evidence_write_failed";
|
|
72
|
+
alerts.push({ skuRef: input.compatibility.staleEvidenceSku, thresholdKind: "stale_sync", severity: "warning",
|
|
73
|
+
providerForSaleQuantity: 0, localAvailableQuantity: 0, ...staleSync });
|
|
74
|
+
await input.port.recordLowStockEvidence({
|
|
75
|
+
requestKey: input.compatibility.staleKey(previousCursor!.lastStockSyncedAt!),
|
|
76
|
+
skuRef: input.compatibility.staleEvidenceSku,
|
|
77
|
+
thresholdKind: "stale_sync",
|
|
78
|
+
severity: "warning",
|
|
79
|
+
providerForSaleQuantity: 0,
|
|
80
|
+
localAvailableQuantity: 0,
|
|
81
|
+
syncRunId,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
stage = "cursor_write_failed";
|
|
86
|
+
await input.port.recordCursor({
|
|
62
87
|
syncRunId,
|
|
88
|
+
status: "running",
|
|
89
|
+
lastStockSyncedAt: null,
|
|
90
|
+
lastMovementOccurredAt: null,
|
|
91
|
+
continuation: { nextRef: null, startLocalDate: null, endLocalDate: null },
|
|
92
|
+
coverageGapSkus: previousCursor?.coverageGapSkus ?? [], refusal: null,
|
|
63
93
|
});
|
|
64
|
-
}
|
|
65
94
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
95
|
+
stage = "stock_read_failed";
|
|
96
|
+
result.providerCalls += 1;
|
|
97
|
+
const [localStock, providerStock] = await Promise.all([
|
|
98
|
+
input.port.readLocalInventoryStock(now.toISOString()),
|
|
99
|
+
input.provider.getStock(),
|
|
100
|
+
]);
|
|
101
|
+
const stockItems: { stock: StockSnapshot; itemIndex: number }[] = [];
|
|
102
|
+
for (const [itemIndex, item] of providerStock.entries()) {
|
|
103
|
+
if ("kind" in item && item.kind === "rejected") {
|
|
104
|
+
result.rejected += 1;
|
|
105
|
+
result.failures += 1;
|
|
106
|
+
result.items.push({ kind: "stock", itemIndex: item.itemIndex, skuRef: null, status: "rejected", reason: item.reason });
|
|
107
|
+
} else if ("skuRef" in item && item.skuRef.trim()) {
|
|
108
|
+
stockItems.push({ stock: item, itemIndex });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const skus = [...new Set(stockItems.map(({ stock }) => stock.skuRef))];
|
|
112
|
+
rejectedStockResponse = providerStock.length > 0 && stockItems.length === 0;
|
|
113
|
+
stage = "stock_inventory_read_failed";
|
|
114
|
+
const [inventoryClasses, activeReservations] = await Promise.all([
|
|
115
|
+
input.port.readSkuInventoryClasses(skus),
|
|
116
|
+
input.port.readActiveReservations(skus, now.toISOString()),
|
|
117
|
+
]);
|
|
73
118
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
input.provider.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const coverageCandidates: Array<{
|
|
94
|
-
stock: StockSnapshot;
|
|
95
|
-
local: LocalStock;
|
|
96
|
-
activeReserved: number;
|
|
97
|
-
}> = [];
|
|
98
|
-
const currentCoverageGapSkus = new Set<string>();
|
|
99
|
-
const packagingSkus = new Set<string>();
|
|
100
|
-
const previousCoverageGapSkus = previousCursor?.status === "succeeded"
|
|
101
|
-
? readStringSet(previousCursor.coverageGapSkus)
|
|
102
|
-
: new Set<string>();
|
|
119
|
+
stage = "stock_movements_read_failed";
|
|
120
|
+
result.providerCalls += 1;
|
|
121
|
+
const movementRows = await input.provider.getStockMovements({ ...stockMovementWindow(previousCursor, now, input.compatibility.timeZone), size: input.movementBatchSize });
|
|
122
|
+
const movements: StockMovement[] = [];
|
|
123
|
+
for (const item of movementRows) {
|
|
124
|
+
if ("kind" in item && item.kind === "rejected") {
|
|
125
|
+
result.rejected += 1;
|
|
126
|
+
result.failures += 1;
|
|
127
|
+
result.items.push({ kind: "movement", itemIndex: item.itemIndex, skuRef: null, status: "rejected", reason: item.reason });
|
|
128
|
+
} else if ("skuRef" in item) {
|
|
129
|
+
movements.push(item);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (rejectedStockResponse || (result.rejected > 0 && stockItems.length + movements.length === 0)) {
|
|
133
|
+
result.ok = false;
|
|
134
|
+
result.reason = "invalid_sku_ref";
|
|
135
|
+
}
|
|
136
|
+
result.movements = movements.length;
|
|
137
|
+
result.lastMovementOccurredAt = latestOccurredAt(movements);
|
|
103
138
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
139
|
+
const localBySku = aggregateAtpCompatibleStock(localStock, now);
|
|
140
|
+
const coverageCandidates: Array<{
|
|
141
|
+
stock: StockSnapshot;
|
|
142
|
+
local: LocalStock;
|
|
143
|
+
activeReserved: number;
|
|
144
|
+
}> = [];
|
|
145
|
+
const packagingSkus = new Set<string>();
|
|
146
|
+
const previousCoverageGapSkus = previousCursor?.status === "succeeded"
|
|
147
|
+
? readStringSet(previousCursor.coverageGapSkus)
|
|
148
|
+
: new Set<string>();
|
|
149
|
+
|
|
150
|
+
for (const { stock, itemIndex } of stockItems) {
|
|
151
|
+
try {
|
|
152
|
+
const local = localBySku.get(stock.skuRef) ?? emptyLocal(stock.skuRef);
|
|
153
|
+
const inventoryClass = inventoryClasses.get(stock.skuRef) ?? null;
|
|
154
|
+
const activeReserved = activeReservations.get(stock.skuRef) ?? 0;
|
|
155
|
+
const staleAfter = new Date(now.getTime() + staleThresholdHours * 3_600_000).toISOString();
|
|
156
|
+
stage = "stock_current_write_failed";
|
|
157
|
+
const current = await input.port.recordProviderStockCurrent({
|
|
158
|
+
requestKey: input.compatibility.currentKey(syncRunId, stock.skuRef),
|
|
159
|
+
snapshot: stock, inventoryClass, lastSyncedAt: now.toISOString(), staleAfter,
|
|
160
|
+
});
|
|
161
|
+
stage = "snapshot_write_failed";
|
|
162
|
+
const snapshot = await input.port.recordStockSnapshot({
|
|
163
|
+
requestKey: input.compatibility.snapshotKey(syncRunId, stock.skuRef),
|
|
164
|
+
snapshot: stock, local, mismatchKind: "none", inventoryClass,
|
|
165
|
+
});
|
|
118
166
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (
|
|
167
|
+
result.items.push({ itemIndex, skuRef: stock.skuRef, status: "recorded", activeReserved,
|
|
168
|
+
sellableNow: inventoryClass === "sellable" ? Math.max(0, stock.forSaleQuantity - activeReserved - local.safetyStock) : null,
|
|
169
|
+
coverageGapQuantity: Math.max(0, activeReserved - stock.forSaleQuantity) });
|
|
170
|
+
result.checked += 1;
|
|
171
|
+
result.updated += (snapshot.replayed ? 0 : 1) + (current.replayed ? 0 : 1);
|
|
172
|
+
if (snapshot.replayed) result.replayed += 1;
|
|
173
|
+
if (current.replayed) result.replayed += 1;
|
|
174
|
+
if (snapshot.readBack) result.readBacks += 1;
|
|
175
|
+
if (current.readBack) result.readBacks += 1;
|
|
176
|
+
if (inventoryClass === null) result.unclassified += 1;
|
|
177
|
+
if (inventoryClass === "packaging") packagingSkus.add(stock.skuRef);
|
|
178
|
+
if (inventoryClass === "sellable") {
|
|
179
|
+
coverageCandidates.push({ stock, local, activeReserved });
|
|
180
|
+
if (activeReserved > stock.forSaleQuantity) currentCoverageGapSkus.add(stock.skuRef);
|
|
181
|
+
}
|
|
182
|
+
} catch (error) {
|
|
183
|
+
result.ok = false;
|
|
184
|
+
result.failures += 1;
|
|
185
|
+
const reason = safeReason(error, stage);
|
|
186
|
+
operationalReason ??= reason;
|
|
187
|
+
result.items.push({ itemIndex, skuRef: stock.skuRef, status: "failed", reason });
|
|
130
188
|
}
|
|
131
|
-
} catch (error) {
|
|
132
|
-
result.ok = false;
|
|
133
|
-
result.failures += 1;
|
|
134
|
-
result.reason = result.reason ?? "stock_sync_operation_failed";
|
|
135
189
|
}
|
|
136
|
-
}
|
|
137
190
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
191
|
+
if (result.ok) {
|
|
192
|
+
for (const { stock, local, activeReserved } of coverageCandidates) {
|
|
193
|
+
const localAvailable = availableQuantity(local);
|
|
194
|
+
const confirmedCoverageGap = currentCoverageGapSkus.has(stock.skuRef) && previousCoverageGapSkus.has(stock.skuRef);
|
|
195
|
+
const lowStockSignals = lowStockSignalsFor(stock, local, activeReserved, confirmedCoverageGap);
|
|
196
|
+
const activeThresholdKinds: Threshold[] = lowStockSignals.map((signal) => signal.thresholdKind);
|
|
197
|
+
if (currentCoverageGapSkus.has(stock.skuRef) && !activeThresholdKinds.includes("reservation_coverage")) {
|
|
198
|
+
activeThresholdKinds.push("reservation_coverage");
|
|
199
|
+
}
|
|
200
|
+
for (const signal of lowStockSignals) {
|
|
201
|
+
alerts.push({ skuRef: stock.skuRef, thresholdKind: signal.thresholdKind, severity: signal.severity,
|
|
202
|
+
providerForSaleQuantity: stock.forSaleQuantity, localAvailableQuantity: localAvailable, detectedAt: now.toISOString(),
|
|
203
|
+
activeReserved, coverageGapQuantity: Math.max(0, activeReserved - stock.forSaleQuantity) });
|
|
204
|
+
stage = "stock_evidence_write_failed";
|
|
205
|
+
const evidence = await input.port.recordLowStockEvidence({
|
|
206
|
+
requestKey: input.compatibility.lowStockKey(stock.skuRef, signal.thresholdKind),
|
|
207
|
+
skuRef: stock.skuRef, thresholdKind: signal.thresholdKind, severity: signal.severity,
|
|
208
|
+
providerForSaleQuantity: stock.forSaleQuantity, localAvailableQuantity: localAvailable, syncRunId,
|
|
209
|
+
});
|
|
210
|
+
if (evidence.replayed) result.replayed += 1;
|
|
211
|
+
result.lowStock += evidence.replayed ? 0 : 1;
|
|
212
|
+
if (signal.thresholdKind === "reservation_coverage") result.reservationCoverage += 1;
|
|
213
|
+
}
|
|
214
|
+
stage = "stock_evidence_resolve_failed";
|
|
215
|
+
await input.port.resolveLowStockEvidence({
|
|
216
|
+
skuRef: stock.skuRef,
|
|
217
|
+
activeThresholdKinds,
|
|
218
|
+
syncRunId,
|
|
219
|
+
});
|
|
146
220
|
}
|
|
147
|
-
for (const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
skuRef:
|
|
151
|
-
|
|
221
|
+
for (const sku of packagingSkus) {
|
|
222
|
+
stage = "stock_evidence_resolve_failed";
|
|
223
|
+
await input.port.resolveLowStockEvidence({
|
|
224
|
+
skuRef: sku,
|
|
225
|
+
activeThresholdKinds: [],
|
|
226
|
+
syncRunId,
|
|
152
227
|
});
|
|
153
|
-
if (evidence.replayed) result.replayed += 1;
|
|
154
|
-
result.lowStock += evidence.replayed ? 0 : 1;
|
|
155
|
-
if (signal.thresholdKind === "reservation_coverage") result.reservationCoverage += 1;
|
|
156
228
|
}
|
|
157
|
-
await input.port.resolveLowStockEvidence({
|
|
158
|
-
skuRef: stock.skuRef,
|
|
159
|
-
activeThresholdKinds,
|
|
160
|
-
syncRunId,
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
for (const sku of packagingSkus) {
|
|
164
|
-
await input.port.resolveLowStockEvidence({
|
|
165
|
-
skuRef: sku,
|
|
166
|
-
activeThresholdKinds: [],
|
|
167
|
-
syncRunId,
|
|
168
|
-
});
|
|
169
229
|
}
|
|
230
|
+
} catch (error) {
|
|
231
|
+
result.ok = false;
|
|
232
|
+
result.failures += 1;
|
|
233
|
+
result.providerError = providerDiagnostic(error);
|
|
234
|
+
if (stage === "stock_read_failed" || stage === "stock_movements_read_failed") result.refusal = admissionRefusal(error);
|
|
235
|
+
operationalReason ??= result.providerError ? `provider_${result.providerError.operation}_failed:${result.providerError.code}` : result.refusal?.reason ?? safeReason(error, stage);
|
|
170
236
|
}
|
|
171
237
|
|
|
238
|
+
result.reason = operationalReason ?? result.reason;
|
|
239
|
+
if (!result.ok) result.refusal ??= { reason: result.failures === result.rejected ? "malformed" : "io_failure", phase: "sync", retryable: result.providerError?.retryable ?? result.failures > result.rejected };
|
|
172
240
|
await input.port.recordCursor({
|
|
173
|
-
status: result.ok ? "succeeded" : "failed",
|
|
241
|
+
status: result.ok ? "succeeded" : "failed", syncRunId,
|
|
174
242
|
lastStockSyncedAt: result.ok ? now.toISOString() : null,
|
|
175
243
|
lastMovementOccurredAt: result.lastMovementOccurredAt,
|
|
176
244
|
continuation: { nextRef: null, startLocalDate: null, endLocalDate: null },
|
|
177
|
-
coverageGapSkus: result.ok ? [...currentCoverageGapSkus].sort() : [],
|
|
178
|
-
refusal: result.
|
|
245
|
+
coverageGapSkus: result.ok ? [...currentCoverageGapSkus].sort() : rejectedStockResponse ? previousCursor?.coverageGapSkus ?? [] : [],
|
|
246
|
+
refusal: result.refusal ?? null,
|
|
247
|
+
error: result.ok ? null : { reason: result.reason!, ...(result.providerError ? { providerError: result.providerError } : {}) },
|
|
248
|
+
counts: { checked: result.checked, updated: result.updated, movements: result.movements, rejected: result.rejected,
|
|
249
|
+
failures: result.failures, providerCalls: result.providerCalls, readBacks: result.readBacks },
|
|
250
|
+
items: result.items, alerts, staleSync,
|
|
179
251
|
});
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
252
|
+
// Recovery follows the durable succeeded cursor; a failed cleanup leaves the completed run intact.
|
|
253
|
+
if (result.ok && recoverStaleSync) {
|
|
254
|
+
try {
|
|
255
|
+
await input.port.resolveLowStockEvidence({
|
|
256
|
+
skuRef: input.compatibility.staleEvidenceSku,
|
|
257
|
+
activeThresholdKinds: [],
|
|
258
|
+
syncRunId,
|
|
259
|
+
});
|
|
260
|
+
} catch { /* Best-effort cleanup must not undo the completed run. */ }
|
|
189
261
|
}
|
|
190
|
-
|
|
191
262
|
return result;
|
|
192
263
|
}
|
|
193
264
|
|
|
@@ -220,3 +291,28 @@ function latestOccurredAt(movements: StockMovement[]): string | null {
|
|
|
220
291
|
.filter((value): value is string => Boolean(value))
|
|
221
292
|
.sort((a, b) => Date.parse(b) - Date.parse(a))[0] ?? null;
|
|
222
293
|
}
|
|
294
|
+
|
|
295
|
+
/** Keep the operation's closed identity and SQLSTATE only, never arbitrary exception prose. */
|
|
296
|
+
function safeReason(error: unknown, operation: string): string {
|
|
297
|
+
const code = error && typeof error === "object" && "code" in error ? error.code : null;
|
|
298
|
+
if (typeof code === "string" && /^[0-9A-Z]{5}$/.test(code)) return `${operation}:${code}`;
|
|
299
|
+
const known = error instanceof Error ? error.message.match(new RegExp(`^${operation}:([0-9A-Z]{5})$`)) : null;
|
|
300
|
+
return known ? `${operation}:${known[1]}` : operation;
|
|
301
|
+
}
|
|
302
|
+
function providerDiagnostic(error: unknown): StockSyncResult["providerError"] {
|
|
303
|
+
if (!error || typeof error !== "object" || !("providerError" in error)) return undefined;
|
|
304
|
+
const detail = error.providerError;
|
|
305
|
+
if (!detail || typeof detail !== "object" || !("operation" in detail) || !("code" in detail) || !("status" in detail) || !("retryable" in detail)) return undefined;
|
|
306
|
+
if (detail.operation !== "get_stock" && detail.operation !== "get_stock_movements") return undefined;
|
|
307
|
+
if (typeof detail.code !== "string" || !/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(detail.code)) return undefined;
|
|
308
|
+
return { operation: detail.operation, code: detail.code,
|
|
309
|
+
status: typeof detail.status === "number" && Number.isInteger(detail.status) && detail.status >= 100 && detail.status <= 599 ? detail.status : null,
|
|
310
|
+
retryable: detail.retryable === true };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function admissionRefusal(error: unknown): StockSyncResult["refusal"] {
|
|
314
|
+
if (!error || typeof error !== "object" || !("reason" in error) || !("phase" in error) || !("retryable" in error)) return undefined;
|
|
315
|
+
if (error.phase !== "sync" || typeof error.retryable !== "boolean") return undefined;
|
|
316
|
+
if (error.reason !== "disabled" && error.reason !== "not_ready" && error.reason !== "malformed") return undefined;
|
|
317
|
+
return { reason: error.reason, phase: error.phase, retryable: error.retryable };
|
|
318
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FulfillmentTrackingReference } from "./contracts/index.js";
|
|
1
|
+
import type { FulfillmentTrackingReference, FulfillmentObservationCodecs } from "./contracts/index.js";
|
|
2
2
|
|
|
3
3
|
export function trackingReferenceComplete(ref: FulfillmentTrackingReference): boolean {
|
|
4
4
|
return Boolean(ref.carrierRef && ref.publicUrl && ref.serviceRef);
|
|
@@ -7,7 +7,8 @@ export function trackingReferenceComplete(ref: FulfillmentTrackingReference): bo
|
|
|
7
7
|
export function enrichTrackingReference(
|
|
8
8
|
ref: FulfillmentTrackingReference,
|
|
9
9
|
deliveryCarrier: { carrierRef: string | null; serviceRef: string | null } | null,
|
|
10
|
-
codecs: {
|
|
10
|
+
codecs: FulfillmentObservationCodecs & {
|
|
11
|
+
approveTrackingUrl?(url: string): boolean;
|
|
11
12
|
normalizeCarrier(input: { carrierRef: string | null; serviceRef: string | null }): string | null;
|
|
12
13
|
publicUrl(carrierRef: string | null, trackingNumber: string): string | null;
|
|
13
14
|
},
|