@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,23 @@
|
|
|
1
|
+
import type { FulfillmentCommerceReadPort, FulfillmentShipmentsOverviewPort } from './contracts/read.js';
|
|
2
|
+
import type { FulfillmentNativeUnsupported } from './contracts/nativeUnsupported.js';
|
|
3
|
+
import type { FulfillmentReplacementPort } from './contracts/replacement.js';
|
|
4
|
+
import type { FulfillmentOperatorMutationPort } from './contracts/operator.js';
|
|
5
|
+
import type { FulfillmentCustomerAlignmentRowsReader } from './contracts/customerAlignment.js';
|
|
6
|
+
export const FULFILLMENT_NATIVE_UNSUPPORTED: FulfillmentNativeUnsupported = Object.freeze({ kind: 'native_unsupported', reason: 'host_binding_required', retryable: false });
|
|
7
|
+
/** Deliberately has no executor: unsupported native parity can never make a partial write. */
|
|
8
|
+
export function createNativeUnsupportedFulfillmentPorts(): FulfillmentReplacementPort & FulfillmentOperatorMutationPort & FulfillmentCommerceReadPort & FulfillmentShipmentsOverviewPort & { readCustomerAlignmentRows: FulfillmentCustomerAlignmentRowsReader } {
|
|
9
|
+
const unsupported = async () => FULFILLMENT_NATIVE_UNSUPPORTED;
|
|
10
|
+
return {
|
|
11
|
+
requestReplacementShipment: unsupported,
|
|
12
|
+
createCommerceFulfillmentOrder: unsupported,
|
|
13
|
+
recordCommerceFulfillmentProviderAttempt: unsupported,
|
|
14
|
+
recordCommerceFulfillmentLabel: unsupported,
|
|
15
|
+
handOffCommerceFulfillmentOrder: unsupported,
|
|
16
|
+
recordCommerceFulfillmentTrackingEvent: unsupported,
|
|
17
|
+
cancelCommerceFulfillmentOrder: unsupported,
|
|
18
|
+
readCustomerAlignmentRows: unsupported,
|
|
19
|
+
listCommerceFulfillmentOrders: unsupported,
|
|
20
|
+
getCommerceFulfillmentOrderDetail: unsupported,
|
|
21
|
+
getShipmentsOverview: unsupported,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { isCanonicalLocalFulfillmentStatus, parseFulfillmentFact, parseFulfillmentTransitionConflict } from "@openlup/core/fulfillment";
|
|
2
|
+
export type { CanonicalNormalizedFulfillmentFact as NormalizedFulfillmentFact, CanonicalNormalizedFulfillmentFactV1 as NormalizedFulfillmentFactV1, CanonicalNormalizedFulfillmentFactV0 as NormalizedFulfillmentFactV0, CanonicalCompatibleNormalizedFulfillmentFact as CompatibleNormalizedFulfillmentFact, CanonicalFulfillmentTransitionSnapshot as FulfillmentTransitionSnapshot, CanonicalTransitionConflict as TransitionConflict } from "@openlup/core/fulfillment";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { CanonicalLocalFulfillmentStatus, FulfillmentObservation } from "./contracts/index.js";
|
|
2
|
+
import { parseFulfillmentFact } from "./normalizedFulfillmentFacts.js";
|
|
3
|
+
import { FULFILLMENT_STATUS_MAP } from "./statusMap.js";
|
|
4
|
+
|
|
5
|
+
export function projectCorrelation(value: FulfillmentObservation["correlation"]): FulfillmentObservation["correlation"] {
|
|
6
|
+
const { orderRef, fulfillmentRef, externalRef, orderNumber, providerFulfillmentRef, parcelRef } = value;
|
|
7
|
+
return { orderRef, fulfillmentRef, externalRef, orderNumber, providerFulfillmentRef, parcelRef };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** New boundary projection: arbitrary adapter properties are never durable. */
|
|
11
|
+
export function projectObservation(value: FulfillmentObservation): FulfillmentObservation {
|
|
12
|
+
const diagnostics: NonNullable<FulfillmentObservation["diagnostics"]> = {};
|
|
13
|
+
if (value.diagnostics?.refusal) {
|
|
14
|
+
const { reason, phase, retryable } = value.diagnostics.refusal;
|
|
15
|
+
diagnostics.refusal = { reason, phase, retryable };
|
|
16
|
+
}
|
|
17
|
+
if (value.diagnostics?.stockContention) diagnostics.stockContention = { sqlState: "55P03", phase: "provider_stock_consumed", retryable: true };
|
|
18
|
+
if (value.diagnostics?.annotation) diagnostics.annotation = value.diagnostics.annotation;
|
|
19
|
+
if (value.diagnostics?.recoveredPickedReceipt) {
|
|
20
|
+
const { receiptRef, closed } = value.diagnostics.recoveredPickedReceipt;
|
|
21
|
+
diagnostics.recoveredPickedReceipt = { receiptRef, closed };
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
fact: parseFulfillmentFact(value.fact), eventKind: value.eventKind, eventKey: value.eventKey,
|
|
25
|
+
firstReceivedAt: value.firstReceivedAt,
|
|
26
|
+
correlation: projectCorrelation(value.correlation),
|
|
27
|
+
trackingRefs: value.trackingRefs.map(({ trackingNumber, carrierRef, serviceRef, publicUrl }) => ({ trackingNumber, carrierRef, serviceRef, publicUrl })),
|
|
28
|
+
...(Object.keys(diagnostics).length ? { diagnostics } : {}),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const HANDOVER_STATUSES: ReadonlySet<string> = new Set(
|
|
33
|
+
FULFILLMENT_STATUS_MAP.stages.filter(stage => stage.effects.handover).flatMap(stage => stage.localStatus),
|
|
34
|
+
);
|
|
35
|
+
/** Donor finished-picking boundary is narrower than the coarse packing stage. */
|
|
36
|
+
export const observationStatusPolicy = {
|
|
37
|
+
consumesStock: (status: CanonicalLocalFulfillmentStatus) => ["packed", "in_transit", "delivered"].includes(status),
|
|
38
|
+
handsOver: (status: CanonicalLocalFulfillmentStatus) => HANDOVER_STATUSES.has(status),
|
|
39
|
+
suppressDispatched: (status: CanonicalLocalFulfillmentStatus) => status === "delivered",
|
|
40
|
+
trackingStatus: (status: CanonicalLocalFulfillmentStatus) => status as "in_transit" | "delivered",
|
|
41
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { FulfillmentCapabilityDescriptor } from "../contracts/index.js";
|
|
2
|
+
|
|
3
|
+
/** Offline protocol capabilities; selection and account readiness belong to the host. */
|
|
4
|
+
export const omnipackFulfillmentCapabilities: Readonly<FulfillmentCapabilityDescriptor> = Object.freeze<FulfillmentCapabilityDescriptor>({
|
|
5
|
+
providerRef: "omnipack", role: "warehouse", defaultOff: true, selected: false,
|
|
6
|
+
readiness: Object.freeze({ ready: false, refusal: Object.freeze({ reason: "not_ready", phase: "selection", retryable: false }) }),
|
|
7
|
+
operations: Object.freeze({
|
|
8
|
+
dispatch: Object.freeze({ supported: true }), readback: Object.freeze({ supported: true }),
|
|
9
|
+
exactAbsence: Object.freeze({ supported: false, reason: "not_exposed" }), verifyObservation: Object.freeze({ supported: true }),
|
|
10
|
+
syncStock: Object.freeze({ supported: true }), syncProducts: Object.freeze({ supported: true }),
|
|
11
|
+
labelCreate: Object.freeze({ supported: false, reason: "not_exposed" }), labelRead: Object.freeze({ supported: false, reason: "host_only" }),
|
|
12
|
+
labelVoid: Object.freeze({ supported: false, reason: "not_exposed" }), cancel: Object.freeze({ supported: false, reason: "not_exposed" }),
|
|
13
|
+
pickupPoint: Object.freeze({ supported: true }), courierPickup: Object.freeze({ supported: false, reason: "not_exposed" }),
|
|
14
|
+
}),
|
|
15
|
+
authentication: "token", freshness: "not_available", ownsDeliveredNotification: false,
|
|
16
|
+
stockAuthority: "external_stock_master_with_local_reservations", labelReadOwner: "host_storage",
|
|
17
|
+
actionPolicy: Object.freeze({ providerSpecificAdminActions: false, genericAdminActions: true, mutationSurface: "provider_adapter" }),
|
|
18
|
+
});
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mapOmnipackFulfilmentRequestsResponse,
|
|
3
|
+
mapOmnipackFulfilmentsResponse,
|
|
4
|
+
mapOmnipackOrderCreatedResponse,
|
|
5
|
+
mapOmnipackProductCreatedResponse,
|
|
6
|
+
mapOmnipackStockMovementsResponse,
|
|
7
|
+
mapOmnipackStockResponse,
|
|
8
|
+
sanitizeOmnipackPayload,
|
|
9
|
+
type OmnipackFulfilmentEvidence,
|
|
10
|
+
type OmnipackFulfilmentRequestEvidence,
|
|
11
|
+
type OmnipackOrderCreatedEvidence,
|
|
12
|
+
type OmnipackProductCreatedEvidence,
|
|
13
|
+
type OmnipackStockEvidence,
|
|
14
|
+
type OmnipackStockMovementEvidence,
|
|
15
|
+
} from "./mappers.js";
|
|
16
|
+
import {
|
|
17
|
+
mapOmnipackProductPackCreatedResponse,
|
|
18
|
+
mapOmnipackProductResponse,
|
|
19
|
+
type OmnipackProductEvidence,
|
|
20
|
+
type OmnipackProductPackCreatedEvidence,
|
|
21
|
+
} from "./productSyncMappers.js";
|
|
22
|
+
import { mayPostHaveSucceeded, OmnipackProviderError } from "./providerError.js";
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
OmnipackProviderError,
|
|
26
|
+
sanitizeOmnipackProviderError,
|
|
27
|
+
type SanitizedOmnipackProviderError,
|
|
28
|
+
} from "./providerError.js";
|
|
29
|
+
|
|
30
|
+
const PRODUCT_PACKS_SEGMENT = "packs";
|
|
31
|
+
|
|
32
|
+
export interface OmnipackClientConfig {
|
|
33
|
+
baseUrl: string;
|
|
34
|
+
credentials: () => { username: string; password: string } | Promise<{ username: string; password: string }>;
|
|
35
|
+
timeoutMs: number;
|
|
36
|
+
safeReadRetries: number;
|
|
37
|
+
productPath: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface OmnipackClient {
|
|
41
|
+
createOrder(body: string): Promise<OmnipackOrderCreatedEvidence>;
|
|
42
|
+
// Product sync. createProduct/createProductPack are POST, so
|
|
43
|
+
// requestJson does NOT retry them (mutations must not be silently re-sent). getProduct is a
|
|
44
|
+
// GET (retried) reading the SKU's packs[] — one SKU ↔ many EANs.
|
|
45
|
+
createProduct(payload: Record<string, unknown>, sku: string): Promise<OmnipackProductCreatedEvidence>;
|
|
46
|
+
getProduct(sku: string): Promise<OmnipackProductEvidence>;
|
|
47
|
+
createProductPack(sku: string, payload: Record<string, unknown>, ean: string): Promise<OmnipackProductPackCreatedEvidence>;
|
|
48
|
+
getStock(): Promise<OmnipackStockEvidence[]>;
|
|
49
|
+
getStockMovements(params?: OmnipackQueryParams): Promise<OmnipackStockMovementEvidence[]>;
|
|
50
|
+
getFulfilmentsPage(params?: OmnipackQueryParams): Promise<{ rows: OmnipackFulfilmentEvidence[]; complete: boolean }>;
|
|
51
|
+
getFulfilments(params?: OmnipackQueryParams): Promise<OmnipackFulfilmentEvidence[]>;
|
|
52
|
+
getFulfilmentRequests(params?: OmnipackQueryParams): Promise<OmnipackFulfilmentRequestEvidence[]>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface OmnipackQueryParams {
|
|
56
|
+
startLocalDate?: string;
|
|
57
|
+
endLocalDate?: string;
|
|
58
|
+
page?: number;
|
|
59
|
+
size?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createOmnipackClient(
|
|
63
|
+
config: OmnipackClientConfig,
|
|
64
|
+
fetchImpl: typeof fetch,
|
|
65
|
+
): OmnipackClient {
|
|
66
|
+
return {
|
|
67
|
+
async createOrder(body) {
|
|
68
|
+
return mapOmnipackOrderCreatedResponse(await requestJson(fetchImpl, config, "createOrder", "/orders", {
|
|
69
|
+
method: "POST",
|
|
70
|
+
body,
|
|
71
|
+
}));
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
async createProduct(payload, sku) {
|
|
75
|
+
const body = await requestJson(fetchImpl, config, "createProduct", config.productPath, post(payload));
|
|
76
|
+
return mapOmnipackProductCreatedResponse(body, sku);
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
async getProduct(sku) {
|
|
80
|
+
const path = productResourcePath(config.productPath, sku);
|
|
81
|
+
return mapOmnipackProductResponse(await requestJson(fetchImpl, config, "getProduct", path, { method: "GET" }), sku);
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
async createProductPack(sku, payload, ean) {
|
|
85
|
+
const path = `${productResourcePath(config.productPath, sku)}/${PRODUCT_PACKS_SEGMENT}`;
|
|
86
|
+
const body = await requestJson(fetchImpl, config, "createProductPack", path, post(payload));
|
|
87
|
+
return mapOmnipackProductPackCreatedResponse(body, sku, ean);
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
async getStock() {
|
|
91
|
+
return mapOmnipackStockResponse(await requestJson(fetchImpl, config, "getStock", "/stock", { method: "GET" }));
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
async getStockMovements(params) {
|
|
95
|
+
return mapOmnipackStockMovementsResponse(await requestJson(
|
|
96
|
+
fetchImpl,
|
|
97
|
+
config,
|
|
98
|
+
"getStockMovements",
|
|
99
|
+
withQuery("/stock-movements", params),
|
|
100
|
+
{ method: "GET" },
|
|
101
|
+
));
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
async getFulfilmentsPage(params) {
|
|
105
|
+
const body = await requestJson(fetchImpl, config, "getFulfilments", withQuery("/fulfilments", params), { method: "GET" });
|
|
106
|
+
const rows = mapOmnipackFulfilmentsResponse(body);
|
|
107
|
+
// Only explicit single-page totals establish whole-collection uniqueness.
|
|
108
|
+
const record = body as { page?: { number?: unknown; size?: unknown; totalPages?: unknown; totalElements?: unknown }; _links?: { next?: unknown }; incomplete?: unknown; partial?: unknown } | null;
|
|
109
|
+
const page = record?.page;
|
|
110
|
+
const complete = page?.number === 0 && page.totalPages === 1 && page.totalElements === rows.length
|
|
111
|
+
&& typeof page.size === "number" && Number.isSafeInteger(page.size) && page.size >= rows.length && page.size > 0
|
|
112
|
+
&& !record?._links?.next && !record?.incomplete && !record?.partial;
|
|
113
|
+
return { rows, complete };
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
async getFulfilments(params) {
|
|
117
|
+
return mapOmnipackFulfilmentsResponse(await requestJson(
|
|
118
|
+
fetchImpl,
|
|
119
|
+
config,
|
|
120
|
+
"getFulfilments",
|
|
121
|
+
withQuery("/fulfilments", params),
|
|
122
|
+
{ method: "GET" },
|
|
123
|
+
));
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
async getFulfilmentRequests(params) {
|
|
127
|
+
return mapOmnipackFulfilmentRequestsResponse(await requestJson(
|
|
128
|
+
fetchImpl,
|
|
129
|
+
config,
|
|
130
|
+
"getFulfilmentRequests",
|
|
131
|
+
withQuery("/fulfilment-requests", params),
|
|
132
|
+
{ method: "GET" },
|
|
133
|
+
));
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function requestJson(
|
|
139
|
+
fetchImpl: typeof fetch,
|
|
140
|
+
config: OmnipackClientConfig,
|
|
141
|
+
operation: string,
|
|
142
|
+
path: string,
|
|
143
|
+
init: RequestInit,
|
|
144
|
+
): Promise<unknown> {
|
|
145
|
+
const method = (init.method ?? "GET").toUpperCase();
|
|
146
|
+
const attempts = method === "GET" ? config.safeReadRetries + 1 : 1;
|
|
147
|
+
let lastError: unknown = null;
|
|
148
|
+
|
|
149
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
150
|
+
let submitted = false;
|
|
151
|
+
try {
|
|
152
|
+
const base = new URL(config.baseUrl);
|
|
153
|
+
const url = new URL(path, base);
|
|
154
|
+
if (url.origin !== base.origin || url.username || url.password || url.protocol !== "https:") throw new Error("Invalid fulfillment path");
|
|
155
|
+
const response = await fetchWithTimeout(fetchImpl, url, config, init, () => { submitted = true; });
|
|
156
|
+
if (response.ok) return await readJsonBody(response);
|
|
157
|
+
|
|
158
|
+
const retryable = method === "GET" && isRetryableStatus(response.status);
|
|
159
|
+
// A failed body read cannot erase the rejection status already received.
|
|
160
|
+
const body = await readJsonBody(response).catch(() => null);
|
|
161
|
+
lastError = new OmnipackProviderError({
|
|
162
|
+
operation,
|
|
163
|
+
status: response.status,
|
|
164
|
+
code: readProviderErrorCode(body) ?? `http_${response.status}`,
|
|
165
|
+
retryable,
|
|
166
|
+
mayHaveSucceeded: mayPostHaveSucceeded(method, response.status),
|
|
167
|
+
});
|
|
168
|
+
if (!retryable || attempt === attempts) throw lastError;
|
|
169
|
+
} catch (error) {
|
|
170
|
+
const retryable = method === "GET" && attempt < attempts;
|
|
171
|
+
lastError = submitted && error instanceof OmnipackProviderError
|
|
172
|
+
? error
|
|
173
|
+
: new OmnipackProviderError({
|
|
174
|
+
operation,
|
|
175
|
+
status: null,
|
|
176
|
+
code: "network_error",
|
|
177
|
+
retryable,
|
|
178
|
+
mayHaveSucceeded: submitted && mayPostHaveSucceeded(method, null),
|
|
179
|
+
});
|
|
180
|
+
if (!retryable) throw lastError;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
throw lastError;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function fetchWithTimeout(
|
|
188
|
+
fetchImpl: typeof fetch,
|
|
189
|
+
url: URL,
|
|
190
|
+
config: OmnipackClientConfig,
|
|
191
|
+
init: RequestInit,
|
|
192
|
+
onSubmit: () => void,
|
|
193
|
+
): Promise<Response> {
|
|
194
|
+
const controller = new AbortController();
|
|
195
|
+
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
|
|
196
|
+
try {
|
|
197
|
+
const credentials = await config.credentials();
|
|
198
|
+
const requestInit: RequestInit = {
|
|
199
|
+
...init,
|
|
200
|
+
signal: controller.signal,
|
|
201
|
+
redirect: "error",
|
|
202
|
+
headers: {
|
|
203
|
+
Accept: "application/json",
|
|
204
|
+
"Content-Type": "application/json",
|
|
205
|
+
Authorization: `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString("base64")}`,
|
|
206
|
+
...(init.headers ?? {}),
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
new Request(url, requestInit); // Validate construction before crossing the submission boundary.
|
|
210
|
+
controller.signal.throwIfAborted();
|
|
211
|
+
onSubmit();
|
|
212
|
+
return await fetchImpl(url, requestInit);
|
|
213
|
+
} finally {
|
|
214
|
+
clearTimeout(timeout);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function readJsonBody(response: Response): Promise<unknown> {
|
|
219
|
+
const text = await response.text();
|
|
220
|
+
if (!text) return {};
|
|
221
|
+
try {
|
|
222
|
+
return sanitizeOmnipackPayload(JSON.parse(text) as Record<string, unknown>);
|
|
223
|
+
} catch {
|
|
224
|
+
return {};
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// A non-retried JSON POST init (mutations must not be silently re-sent — requestJson keys retry
|
|
229
|
+
// off the GET method).
|
|
230
|
+
function post(payload: Record<string, unknown>): RequestInit {
|
|
231
|
+
return { method: "POST", body: JSON.stringify(payload) };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Per-product resource path (GET /products/{sku}, base for /packs). sku is percent-encoded; the
|
|
235
|
+
// product path is right-trimmed so a trailing-slash path override does not double the slash.
|
|
236
|
+
function productResourcePath(productPath: string, sku: string): string {
|
|
237
|
+
return `${productPath.replace(/\/+$/, "")}/${encodeURIComponent(sku)}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function withQuery(path: string, params: OmnipackQueryParams | undefined): string {
|
|
241
|
+
const url = new URL(path, "https://example.test");
|
|
242
|
+
if (params?.startLocalDate) url.searchParams.set("startLocalDate", params.startLocalDate);
|
|
243
|
+
if (params?.endLocalDate) url.searchParams.set("endLocalDate", params.endLocalDate);
|
|
244
|
+
if (typeof params?.page === "number") url.searchParams.set("page", String(params.page));
|
|
245
|
+
if (typeof params?.size === "number") url.searchParams.set("size", String(params.size));
|
|
246
|
+
return `${url.pathname}${url.search}`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function readProviderErrorCode(body: unknown): string | null {
|
|
250
|
+
return typeof body === "object" && body !== null && "code" in body && typeof body.code === "string"
|
|
251
|
+
&& /^[A-Z][A-Z0-9_]{0,63}$/.test(body.code)
|
|
252
|
+
? body.code
|
|
253
|
+
: null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function isRetryableStatus(status: number): boolean {
|
|
257
|
+
return status === 408 || status === 429 || status >= 500;
|
|
258
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import type { DispatchEvidence, FulfillmentCapabilityDescriptor, FulfillmentDispatchPort, FulfillmentDispatchRequest,
|
|
2
|
+
FulfillmentLabelPort, FulfillmentObservationPort, FulfillmentReadbackPort, FulfillmentStockSyncProvider, FulfillmentProductSyncProvider } from "../contracts/index.js";
|
|
3
|
+
import { createOmnipackClient, OmnipackProviderError } from "./client.js";
|
|
4
|
+
import { omnipackFulfillmentCapabilities } from "./capabilities.js";
|
|
5
|
+
import { buildOmnipackDispatchCommand, serializeOmnipackDispatchPayload, stableDispatchHash } from "./omnipackDispatchPayload.js";
|
|
6
|
+
import { mapOmnipackWebhookPayload } from "./mappers.js";
|
|
7
|
+
import { verifyOmnipackWebhookToken, type OmnipackWebhookRouteEvent } from "./omnipackWebhookPayload.js";
|
|
8
|
+
import { localStatusFor, normalizeStatus } from "./omnipackStatusVocabulary.js";
|
|
9
|
+
import { digest, eventKind, observation, reference, refusal, timestamp } from "./observation.js";
|
|
10
|
+
export { omnipackFulfillmentCapabilities } from "./capabilities.js";
|
|
11
|
+
|
|
12
|
+
/** Node-only, explicit construction. Secrets are supplied per call, never retained as values. */
|
|
13
|
+
export interface OmnipackFulfillmentAdapterConfig {
|
|
14
|
+
http: {
|
|
15
|
+
baseUrl: string; credentials: () => { username: string; password: string } | Promise<{ username: string; password: string }>;
|
|
16
|
+
fetchImpl: typeof fetch; timeoutMs: number; safeReadRetries: number; productPath: string;
|
|
17
|
+
};
|
|
18
|
+
selected?: boolean;
|
|
19
|
+
/** Per-instance retention target; default 10,000. Evicts oldest completed submissions, never in-flight. */
|
|
20
|
+
maxRetainedSubmissions?: number;
|
|
21
|
+
/** Host account evidence, independent of credentials. Omission remains not ready. */
|
|
22
|
+
readiness?: FulfillmentCapabilityDescriptor["readiness"];
|
|
23
|
+
/** Called in prepare and again for new dispatches; return identical metadata for the same request or dispatch refuses. */
|
|
24
|
+
resolveProduct: (line: FulfillmentDispatchRequest["lines"][number]) => Promise<{ lotNumber?: string; expirationDate?: string } | null>;
|
|
25
|
+
notification: { token: () => string | undefined | Promise<string | undefined>; expectedEvent: OmnipackWebhookRouteEvent };
|
|
26
|
+
clock: () => string;
|
|
27
|
+
mintEvidenceReference: () => `evidence:${string}`;
|
|
28
|
+
mintSyncRunId: () => string;
|
|
29
|
+
approveTrackingUrl: (url: string) => boolean;
|
|
30
|
+
/** Writes occur only through deliberate calls; scheduled composition must remain pull-only. */
|
|
31
|
+
product?: { resolvePushMetadata: (sku: string) => Promise<Record<string, unknown> | null> };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** No route, schedule, environment reader or default binding is installed. */
|
|
35
|
+
export function createOmnipackFulfillmentAdapter(config: OmnipackFulfillmentAdapterConfig): {
|
|
36
|
+
capabilities: FulfillmentCapabilityDescriptor;
|
|
37
|
+
dispatch: FulfillmentDispatchPort;
|
|
38
|
+
label: FulfillmentLabelPort;
|
|
39
|
+
observation: FulfillmentObservationPort;
|
|
40
|
+
readback: FulfillmentReadbackPort;
|
|
41
|
+
stockSync: FulfillmentStockSyncProvider;
|
|
42
|
+
productSync: FulfillmentProductSyncProvider;
|
|
43
|
+
} {
|
|
44
|
+
const maxRetainedSubmissions = config.maxRetainedSubmissions === undefined ? 10_000 : config.maxRetainedSubmissions;
|
|
45
|
+
const base = new URL(config.http.baseUrl);
|
|
46
|
+
if (base.protocol !== "https:" || base.username || base.password || base.search || base.hash
|
|
47
|
+
|| !Number.isSafeInteger(maxRetainedSubmissions) || maxRetainedSubmissions <= 0
|
|
48
|
+
|| typeof config.http.fetchImpl !== "function" || typeof config.http.credentials !== "function"
|
|
49
|
+
|| !Number.isSafeInteger(config.http.timeoutMs) || config.http.timeoutMs <= 0
|
|
50
|
+
|| !Number.isSafeInteger(config.http.safeReadRetries) || config.http.safeReadRetries < 0
|
|
51
|
+
|| !/^\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*\/?$/u.test(config.http.productPath)
|
|
52
|
+
|| !eventKind(config.notification.expectedEvent)) throw new Error("Invalid fulfillment configuration");
|
|
53
|
+
const client = createOmnipackClient({ ...config.http }, config.http.fetchImpl);
|
|
54
|
+
// Copy the profile so host selection never mutates the exported default-off descriptor.
|
|
55
|
+
const capabilities: FulfillmentCapabilityDescriptor = structuredClone(omnipackFulfillmentCapabilities);
|
|
56
|
+
capabilities.selected = config.selected === true;
|
|
57
|
+
capabilities.readiness = config.readiness?.ready === true ? { ready: true }
|
|
58
|
+
: { ready: false, refusal: refusal("not_ready", "selection") };
|
|
59
|
+
const submissions = new Map<string, { evidenceKey: string; commandDigest: string; completed: boolean; result: ReturnType<FulfillmentDispatchPort["dispatch"]> }>();
|
|
60
|
+
const evictCompleted = () => {
|
|
61
|
+
for (const [fingerprint, submission] of submissions) {
|
|
62
|
+
if (submissions.size <= maxRetainedSubmissions) break;
|
|
63
|
+
if (submission.completed) submissions.delete(fingerprint);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
const selectionFailure = (request: FulfillmentDispatchRequest) => {
|
|
67
|
+
if (!request.selection?.providerRef?.trim()) return refusal("selection_required", "selection");
|
|
68
|
+
if (request.selection.providerRef !== capabilities.providerRef) return refusal("provider_mismatch", "selection");
|
|
69
|
+
if (!reference(request.selection.carrierRef) || !reference(request.selection.serviceRef)) return refusal("carrier_service_required", "selection");
|
|
70
|
+
if (!capabilities.selected) return refusal("disabled", "selection");
|
|
71
|
+
if (!capabilities.readiness.ready) return refusal("not_ready", "selection");
|
|
72
|
+
return null;
|
|
73
|
+
};
|
|
74
|
+
const requireSync = () => {
|
|
75
|
+
if (!capabilities.selected || !capabilities.readiness.ready) throw refusal(capabilities.selected ? "not_ready" : "disabled", "sync");
|
|
76
|
+
};
|
|
77
|
+
const syncIdentity = () => {
|
|
78
|
+
const syncRunId = reference(config.mintSyncRunId());
|
|
79
|
+
const collectedAt = timestamp(config.clock());
|
|
80
|
+
if (!syncRunId || !collectedAt) throw refusal("malformed", "sync");
|
|
81
|
+
return { providerRef: capabilities.providerRef, syncRunId, collectedAt, sourceRevision: null, revisionSource: "collection" as const };
|
|
82
|
+
};
|
|
83
|
+
// Shared computation: host metadata and serialized bytes live only for this call.
|
|
84
|
+
const buildCommand = async (request: FulfillmentDispatchRequest) => {
|
|
85
|
+
const requestDigest = stableDispatchHash(request);
|
|
86
|
+
if (!Number.isSafeInteger(request.contactRevision.schemaVersion) || request.contactRevision.schemaVersion < 1
|
|
87
|
+
|| !reference(request.contactRevision.source) || !Number.isSafeInteger(request.contactRevision.revision)
|
|
88
|
+
|| request.contactRevision.revision < 0) throw Error();
|
|
89
|
+
const lines = [];
|
|
90
|
+
for (const line of request.lines) {
|
|
91
|
+
const metadata = await config.resolveProduct(line);
|
|
92
|
+
if (!metadata) throw Error();
|
|
93
|
+
lines.push({ sku: line.skuRef, quantity: line.quantity, productSnapshot: { lotNumber: metadata.lotNumber, expirationDate: metadata.expirationDate } });
|
|
94
|
+
}
|
|
95
|
+
if (stableDispatchHash(request) !== requestDigest) throw Error();
|
|
96
|
+
const command = buildOmnipackDispatchCommand({
|
|
97
|
+
orderId: request.orderRef, orderNumber: request.orderNumber,
|
|
98
|
+
deliveryContact: { revision: request.contactRevision.revision, recipientName: request.recipient.name,
|
|
99
|
+
contactEmail: request.recipient.email, contactPhone: request.recipient.phone, line1: request.recipient.line1,
|
|
100
|
+
city: request.recipient.city, postalCode: request.recipient.postalCode, country: request.recipient.country,
|
|
101
|
+
selectedDelivery: { providerKind: capabilities.providerRef, carrierKind: request.selection.carrierRef,
|
|
102
|
+
serviceCode: request.selection.serviceRef, deliveryKind: request.selection.deliveryKind, pickupPoint: request.selection.pickupPoint } }, lines,
|
|
103
|
+
});
|
|
104
|
+
const evidence: DispatchEvidence = { commandFingerprint: command.requestFingerprint,
|
|
105
|
+
contactRevision: { schemaVersion: request.contactRevision.schemaVersion, source: request.contactRevision.source, revision: request.contactRevision.revision },
|
|
106
|
+
providerRef: capabilities.providerRef, carrierRef: request.selection.carrierRef, serviceRef: request.selection.serviceRef };
|
|
107
|
+
return { body: command.body, evidence };
|
|
108
|
+
};
|
|
109
|
+
return {
|
|
110
|
+
capabilities,
|
|
111
|
+
dispatch: {
|
|
112
|
+
async prepare(request) {
|
|
113
|
+
const failed = selectionFailure(request);
|
|
114
|
+
if (failed) return { kind: "refused", refusal: failed };
|
|
115
|
+
try { return { kind: "prepared", evidence: (await buildCommand(request)).evidence }; }
|
|
116
|
+
catch { return { kind: "refused", refusal: refusal("not_dispatchable", "prepare") }; }
|
|
117
|
+
},
|
|
118
|
+
async dispatch(request, evidence) {
|
|
119
|
+
let commandRequest: FulfillmentDispatchRequest;
|
|
120
|
+
let fingerprint: string;
|
|
121
|
+
let evidenceKey: string;
|
|
122
|
+
let requestCommandDigest: string;
|
|
123
|
+
try {
|
|
124
|
+
// Validate the closed evidence and its request-bound values without consulting preparation state.
|
|
125
|
+
if (Object.keys(evidence).length !== 5 || Object.keys(evidence.contactRevision).length !== 3
|
|
126
|
+
|| typeof evidence.commandFingerprint !== "string" || !/^[a-f0-9]{64}$/u.test(evidence.commandFingerprint)
|
|
127
|
+
|| !reference(evidence.providerRef)
|
|
128
|
+
|| !reference(evidence.carrierRef) || !reference(evidence.serviceRef)
|
|
129
|
+
|| !Number.isSafeInteger(evidence.contactRevision.schemaVersion) || evidence.contactRevision.schemaVersion < 1
|
|
130
|
+
|| !reference(evidence.contactRevision.source) || !Number.isSafeInteger(evidence.contactRevision.revision)
|
|
131
|
+
|| evidence.contactRevision.revision < 0
|
|
132
|
+
|| evidence.contactRevision.schemaVersion !== request.contactRevision.schemaVersion
|
|
133
|
+
|| evidence.contactRevision.source !== request.contactRevision.source
|
|
134
|
+
|| evidence.contactRevision.revision !== request.contactRevision.revision
|
|
135
|
+
|| evidence.providerRef !== request.selection.providerRef
|
|
136
|
+
|| evidence.carrierRef !== request.selection.carrierRef || evidence.serviceRef !== request.selection.serviceRef) throw Error();
|
|
137
|
+
evidenceKey = serializeOmnipackDispatchPayload(evidence).body;
|
|
138
|
+
requestCommandDigest = commandDigest(request);
|
|
139
|
+
const submitted = submissions.get(evidence.commandFingerprint);
|
|
140
|
+
if (submitted) {
|
|
141
|
+
// Replay is bound to this submission's evidence and command, never replaceable preparation.
|
|
142
|
+
if (submitted.evidenceKey !== evidenceKey || submitted.commandDigest !== requestCommandDigest) throw Error();
|
|
143
|
+
return structuredClone(await submitted.result);
|
|
144
|
+
}
|
|
145
|
+
if (selectionFailure(request)) throw Error();
|
|
146
|
+
commandRequest = structuredClone(request);
|
|
147
|
+
fingerprint = evidence.commandFingerprint;
|
|
148
|
+
} catch { return { kind: "not_dispatched", effectMayHaveSucceeded: false, refusal: refusal("not_dispatchable", "dispatch") }; }
|
|
149
|
+
// Reserve before host resolution too, so concurrent dispatches join the rebuild.
|
|
150
|
+
const result = Promise.resolve().then(async () => {
|
|
151
|
+
let command;
|
|
152
|
+
try {
|
|
153
|
+
command = await buildCommand(commandRequest);
|
|
154
|
+
if (command.evidence.commandFingerprint !== fingerprint) throw Error();
|
|
155
|
+
} catch {
|
|
156
|
+
// A stale or unresolved command has never reached credentials or HTTP.
|
|
157
|
+
submissions.delete(fingerprint);
|
|
158
|
+
return { kind: "not_dispatched" as const, effectMayHaveSucceeded: false as const, refusal: refusal("not_dispatchable", "dispatch") };
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
const result = await client.createOrder(command.body);
|
|
162
|
+
const externalRef = reference(result.providerOrderId);
|
|
163
|
+
if (!externalRef) return { kind: "unknown" as const, effectMayHaveSucceeded: true as const, refusal: refusal("malformed", "acknowledge") };
|
|
164
|
+
// Opaque provider proof; the worker replaces these with authoritative host keys.
|
|
165
|
+
const proof = digest(JSON.stringify([externalRef, command.evidence.commandFingerprint]));
|
|
166
|
+
return { kind: "accepted" as const, externalRef, evidence: structuredClone(command.evidence),
|
|
167
|
+
providerAttemptKey: `attempt:${proof}`, labelKey: `label:${proof}` };
|
|
168
|
+
} catch (error) {
|
|
169
|
+
return error instanceof OmnipackProviderError && !error.mayHaveSucceeded
|
|
170
|
+
? { kind: "not_dispatched" as const, effectMayHaveSucceeded: false as const, refusal: refusal("io_failure", "dispatch") }
|
|
171
|
+
: { kind: "unknown" as const, effectMayHaveSucceeded: true as const, refusal: refusal("io_failure", "dispatch") };
|
|
172
|
+
} finally {
|
|
173
|
+
command.body = "";
|
|
174
|
+
submissions.get(fingerprint)!.completed = true;
|
|
175
|
+
evictCompleted();
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
submissions.set(fingerprint, { evidenceKey, commandDigest: requestCommandDigest, completed: false, result });
|
|
179
|
+
evictCompleted();
|
|
180
|
+
return structuredClone(await result);
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
label: {
|
|
184
|
+
async create() { return { kind: "refused", refusal: refusal("unsupported", "dispatch") }; },
|
|
185
|
+
async read() { return { kind: "refused", refusal: refusal("unsupported", "dispatch") }; },
|
|
186
|
+
async void() { return { providerDeletion: "unconfirmed", labelDeleted: false, canProceed: false, refusal: refusal("unsupported", "dispatch") }; },
|
|
187
|
+
},
|
|
188
|
+
observation: {
|
|
189
|
+
async verifyAndNormalize(input) {
|
|
190
|
+
// Token check precedes every read of rawBody and every parse.
|
|
191
|
+
try {
|
|
192
|
+
if (!verifyOmnipackWebhookToken({ headers: { ...input.headers } }, await config.notification.token()))
|
|
193
|
+
return { kind: "refused", refusal: refusal("untrusted", "verify") };
|
|
194
|
+
} catch { return { kind: "refused", refusal: refusal("untrusted", "verify") }; }
|
|
195
|
+
try {
|
|
196
|
+
const rawBody = Buffer.from(input.rawBody);
|
|
197
|
+
const payload: unknown = JSON.parse(rawBody.toString("utf8"));
|
|
198
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return { kind: "refused", refusal: refusal("malformed", "normalize") };
|
|
199
|
+
if ("event" in payload && (typeof payload.event !== "string" || !eventKind(payload.event)))
|
|
200
|
+
return { kind: "refused", refusal: refusal("unsupported_event", "normalize") };
|
|
201
|
+
const evidence = mapOmnipackWebhookPayload(payload, config.notification.expectedEvent);
|
|
202
|
+
const event = eventKind(evidence.event);
|
|
203
|
+
if (!event || event !== config.notification.expectedEvent) return { kind: "refused", refusal: refusal("unsupported_event", "normalize") };
|
|
204
|
+
if (![evidence.providerOrderId, evidence.orderNumber, evidence.fulfilmentNumber].some(value => reference(value)))
|
|
205
|
+
return { kind: "refused", refusal: refusal("missing_correlation", "normalize") };
|
|
206
|
+
return { kind: "verified", observation: observation({ evidence, event, receivedAt: input.receivedAt,
|
|
207
|
+
rawDigest: digest(rawBody), evidenceRef: config.mintEvidenceReference(), source: "provider_webhook", approveTrackingUrl: config.approveTrackingUrl }) };
|
|
208
|
+
} catch { return { kind: "refused", refusal: refusal("malformed", "normalize") }; }
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
readback: {
|
|
212
|
+
async read(input) {
|
|
213
|
+
if (input.providerRef !== capabilities.providerRef) return { kind: "unsupported", refusal: refusal("provider_mismatch", "readback") };
|
|
214
|
+
if (!capabilities.selected || !capabilities.readiness.ready) return { kind: "unsupported", refusal: refusal(capabilities.selected ? "not_ready" : "disabled", "readback") };
|
|
215
|
+
if (!reference(input.externalRef)) return { kind: "unsupported", refusal: refusal("unsupported", "readback") };
|
|
216
|
+
try {
|
|
217
|
+
const collection = await client.getFulfilmentsPage({ page: 0, size: 100 });
|
|
218
|
+
if (!collection.complete) return { kind: "unknown", refusal: refusal("local_missing", "readback") };
|
|
219
|
+
const matches = collection.rows.filter(row => input.mode === "parcel"
|
|
220
|
+
? row.fulfilmentNumber === input.externalRef : row.providerOrderId === input.externalRef);
|
|
221
|
+
if (matches.length !== 1) return { kind: "unknown", refusal: refusal("local_missing", "readback") };
|
|
222
|
+
const row = matches[0]!;
|
|
223
|
+
const normalized = normalizeStatus(row.status);
|
|
224
|
+
if (!normalized) return { kind: "unknown", refusal: refusal("unsupported_event", "readback") };
|
|
225
|
+
const status = localStatusFor(normalized) as "provider_received" | "picking" | "packed" | "in_transit" | "delivered" | "exception";
|
|
226
|
+
const event = ({ provider_received: "shipment.accepted", picking: "order.processing_started", packed: "order.picked", in_transit: "order.shipped", delivered: "order.delivered", exception: "parcel.exception" } as const)[status];
|
|
227
|
+
const evidence = { provider: "omnipack" as const, event, providerOrderId: row.providerOrderId, orderNumber: row.orderNumber,
|
|
228
|
+
fulfilmentNumber: row.fulfilmentNumber, occurredAt: row.updatedAt, trackingNumbers: row.trackingNumbers,
|
|
229
|
+
trackingReferences: row.trackingReferences, shippingMethods: [] };
|
|
230
|
+
return { kind: "found", externalRef: input.externalRef!, createdAt: timestamp(row.createdAt), updatedAt: timestamp(row.updatedAt),
|
|
231
|
+
observation: observation({ evidence, event, status, fulfillmentRef: input.fulfillmentRef, receivedAt: config.clock(),
|
|
232
|
+
rawDigest: digest(JSON.stringify(evidence)), evidenceRef: config.mintEvidenceReference(), source: "provider_reconciliation", approveTrackingUrl: config.approveTrackingUrl }) };
|
|
233
|
+
} catch { return { kind: "unknown", refusal: refusal("io_failure", "readback") }; }
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
stockSync: {
|
|
237
|
+
async getStock() {
|
|
238
|
+
requireSync();
|
|
239
|
+
try {
|
|
240
|
+
const identity = syncIdentity();
|
|
241
|
+
return (await client.getStock()).map(row => {
|
|
242
|
+
const skuRef = reference(row.sku);
|
|
243
|
+
if (!skuRef) throw refusal("malformed", "sync");
|
|
244
|
+
return { ...identity, skuRef, quantityKind: "snapshot" as const, totalQuantity: row.totalQuantity,
|
|
245
|
+
forSaleQuantity: row.forSaleQuantity, reservedOrUnavailableQuantity: row.reservedOrUnavailableQuantity };
|
|
246
|
+
});
|
|
247
|
+
} catch { throw refusal("io_failure", "sync"); }
|
|
248
|
+
},
|
|
249
|
+
async getStockMovements(input) {
|
|
250
|
+
requireSync();
|
|
251
|
+
try {
|
|
252
|
+
const identity = syncIdentity();
|
|
253
|
+
return (await client.getStockMovements(input)).map(row => {
|
|
254
|
+
const skuRef = reference(row.sku);
|
|
255
|
+
if (!skuRef) throw refusal("malformed", "sync");
|
|
256
|
+
return { ...identity, skuRef, quantityKind: "movement" as const, quantity: row.quantity, occurredAt: timestamp(row.occurredAt),
|
|
257
|
+
operationType: reference(row.operationType), documentRef: reference(row.warehouseDocumentNumber), lotRef: reference(row.lotNumber), expirationDate: /^\d{4}-\d\d-\d\d$/u.test(row.expirationDate ?? "") ? row.expirationDate : null };
|
|
258
|
+
});
|
|
259
|
+
} catch { throw refusal("io_failure", "sync"); }
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
productSync: {
|
|
263
|
+
async ensureProduct(sku) {
|
|
264
|
+
requireSync();
|
|
265
|
+
try {
|
|
266
|
+
const metadata = await config.product?.resolvePushMetadata(sku);
|
|
267
|
+
if (!metadata) return null;
|
|
268
|
+
await client.createProduct(metadata, sku);
|
|
269
|
+
return { replayed: false };
|
|
270
|
+
} catch { throw refusal("io_failure", "sync"); }
|
|
271
|
+
},
|
|
272
|
+
async getProductPacks(sku) {
|
|
273
|
+
requireSync();
|
|
274
|
+
try { return (await client.getProduct(sku)).packs.filter(pack => /^\d{8,14}$/u.test(pack.ean) && Number.isSafeInteger(pack.quantity) && pack.quantity > 0).map(pack => ({ ean: pack.ean, quantity: pack.quantity })); }
|
|
275
|
+
catch { throw refusal("io_failure", "sync"); }
|
|
276
|
+
},
|
|
277
|
+
async addProductPack(sku, pack) {
|
|
278
|
+
requireSync();
|
|
279
|
+
if (!/^\d{8,14}$/u.test(pack.ean) || !Number.isSafeInteger(pack.quantity) || pack.quantity <= 0) throw refusal("malformed", "sync");
|
|
280
|
+
try { await client.createProductPack(sku, { ean: pack.ean, quantity: pack.quantity }, pack.ean); }
|
|
281
|
+
catch { throw refusal("io_failure", "sync"); }
|
|
282
|
+
},
|
|
283
|
+
async listStockedSkus() {
|
|
284
|
+
requireSync();
|
|
285
|
+
try { return [...new Set((await client.getStock()).flatMap(row => reference(row.sku) ? [row.sku] : []))]; }
|
|
286
|
+
catch { throw refusal("io_failure", "sync"); }
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// buildOmnipackDispatchPayloadFromCandidate forwards only name/email/phone and
|
|
293
|
+
// line1/city/postalCode/country to buildOmnipackOutboundOrderPayload; neither
|
|
294
|
+
// codec reads line titles, recipient line2/deliveryInstructions/courierInstructions,
|
|
295
|
+
// or pickup-point provider/name/address (readPickUpPoint reads only its id).
|
|
296
|
+
// Exclude those descriptions; retain host request/order/fulfillment/parcel and
|
|
297
|
+
// product/pack identities as well as all wire/evidence-bearing request values.
|
|
298
|
+
// The submission retains only this SHA-256 hash in memory, never the raw values.
|
|
299
|
+
function commandDigest(request: FulfillmentDispatchRequest): string {
|
|
300
|
+
return stableDispatchHash({
|
|
301
|
+
requestKey: request.requestKey, fulfillmentRef: request.fulfillmentRef,
|
|
302
|
+
orderRef: request.orderRef, orderNumber: request.orderNumber, parcelRef: request.parcelRef,
|
|
303
|
+
contactRevision: { schemaVersion: request.contactRevision.schemaVersion,
|
|
304
|
+
source: request.contactRevision.source, revision: request.contactRevision.revision },
|
|
305
|
+
recipient: { name: request.recipient.name, email: request.recipient.email, phone: request.recipient.phone,
|
|
306
|
+
line1: request.recipient.line1, city: request.recipient.city, postalCode: request.recipient.postalCode, country: request.recipient.country },
|
|
307
|
+
lines: request.lines.map(line => ({ skuRef: line.skuRef, productRef: line.productRef, packRef: line.packRef, quantity: line.quantity })),
|
|
308
|
+
selection: { providerRef: request.selection.providerRef, carrierRef: request.selection.carrierRef,
|
|
309
|
+
serviceRef: request.selection.serviceRef, deliveryKind: request.selection.deliveryKind,
|
|
310
|
+
pickupPointId: request.selection.pickupPoint?.id ?? null },
|
|
311
|
+
});
|
|
312
|
+
}
|