@openlup/core 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 +20 -343
- package/README.md +9 -1
- package/dist/fulfillment/currentFulfillmentParcel.d.ts +16 -0
- package/dist/fulfillment/currentFulfillmentParcel.js +70 -0
- package/dist/fulfillment/index.d.ts +16 -0
- package/dist/fulfillment/index.js +8 -0
- package/dist/fulfillment/normalizedFulfillmentFacts.d.ts +14 -0
- package/dist/fulfillment/normalizedFulfillmentFacts.js +18 -0
- package/dist/fulfillment/statusMap.d.ts +220 -0
- package/dist/fulfillment/statusMap.js +214 -0
- package/dist/fulfillment/trackingProgress.d.ts +4 -0
- package/dist/fulfillment/trackingProgress.js +42 -0
- package/dist/fulfillment/types.d.ts +15 -0
- package/dist/fulfillment/types.js +1 -0
- package/package.json +1 -1
- package/release-gates.json +7 -3
- package/src/fulfillment/currentFulfillmentParcel.ts +94 -0
- package/src/fulfillment/index.ts +20 -0
- package/src/fulfillment/normalizedFulfillmentFacts.ts +52 -0
- package/src/fulfillment/statusMap.ts +253 -0
- package/src/fulfillment/trackingProgress.ts +49 -0
- package/src/fulfillment/types.ts +12 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import type { CustomerFulfillmentStep } from "./types.js";
|
|
2
|
+
export interface FulfillmentStatusStage {
|
|
3
|
+
/** Layer C — the provider-neutral customer step. */
|
|
4
|
+
customerStep: CustomerFulfillmentStep;
|
|
5
|
+
/** Precedence when several signals disagree — highest rank wins. */
|
|
6
|
+
rank: number;
|
|
7
|
+
/** Linear 5-step bar position (paid0 → delivered4); off-track states pinned. */
|
|
8
|
+
phaseIndex: number;
|
|
9
|
+
/** Whether this step sits on the normal paid→delivered track. */
|
|
10
|
+
onTrack: boolean;
|
|
11
|
+
/** i18n key for the short label (progress bar + cards). */
|
|
12
|
+
stepLabelKey: string;
|
|
13
|
+
/** Polish milestone label emitted server-side into the order-detail timeline. */
|
|
14
|
+
timelineLabel: string;
|
|
15
|
+
/** Key under `account:dashboard.panels.orders.trackingPhase.*` (start tile). */
|
|
16
|
+
trackingPhaseKey: string;
|
|
17
|
+
/** Short display label contributed with the status map. */
|
|
18
|
+
label: {
|
|
19
|
+
pl: string;
|
|
20
|
+
en: string;
|
|
21
|
+
};
|
|
22
|
+
/** Layer B `local_status` / durable FSM / order-level statuses resolving here. */
|
|
23
|
+
localStatus: readonly string[];
|
|
24
|
+
omsFulfillmentStatus: string[];
|
|
25
|
+
orderStatus: string[];
|
|
26
|
+
/** Extra provider/synonym tokens that also resolve here (for token→step). */
|
|
27
|
+
aliasTokens: string[];
|
|
28
|
+
/** Contributed effect metadata, pinned by the package test's projection digest. */
|
|
29
|
+
effects: {
|
|
30
|
+
stock: string;
|
|
31
|
+
handover: boolean;
|
|
32
|
+
customerEmail: string | false;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export interface FulfillmentStatusMap {
|
|
36
|
+
version: number;
|
|
37
|
+
comment: string;
|
|
38
|
+
stages: readonly FulfillmentStatusStage[];
|
|
39
|
+
}
|
|
40
|
+
export declare const FULFILLMENT_STATUS_MAP: {
|
|
41
|
+
readonly version: 1;
|
|
42
|
+
readonly comment: "SINGLE SOURCE OF TRUTH for fulfillment status across all layers. Layer C (customerStep) is the spine. Providers (Layer A) map ONLY to local_status (Layer B) in their own dictionary; the UI consumes only Layer C. statusMap.test.ts keeps every layer consistent.";
|
|
43
|
+
readonly stages: readonly [{
|
|
44
|
+
readonly customerStep: "paid";
|
|
45
|
+
readonly rank: 1;
|
|
46
|
+
readonly phaseIndex: 0;
|
|
47
|
+
readonly onTrack: true;
|
|
48
|
+
readonly stepLabelKey: "account:dashboard.ordersV2.steps.paid";
|
|
49
|
+
readonly timelineLabel: "Oplacone";
|
|
50
|
+
readonly trackingPhaseKey: "paid";
|
|
51
|
+
readonly label: {
|
|
52
|
+
readonly pl: "Opłacone";
|
|
53
|
+
readonly en: "Paid";
|
|
54
|
+
};
|
|
55
|
+
readonly localStatus: readonly [];
|
|
56
|
+
readonly omsFulfillmentStatus: ["created"];
|
|
57
|
+
readonly orderStatus: ["paid", "fulfillment_pending"];
|
|
58
|
+
readonly aliasTokens: [];
|
|
59
|
+
readonly effects: {
|
|
60
|
+
readonly stock: "reserved";
|
|
61
|
+
readonly handover: false;
|
|
62
|
+
readonly customerEmail: false;
|
|
63
|
+
};
|
|
64
|
+
}, {
|
|
65
|
+
readonly customerStep: "accepted";
|
|
66
|
+
readonly rank: 2;
|
|
67
|
+
readonly phaseIndex: 1;
|
|
68
|
+
readonly onTrack: true;
|
|
69
|
+
readonly stepLabelKey: "account:dashboard.ordersV2.steps.accepted";
|
|
70
|
+
readonly timelineLabel: "Przyjete do realizacji";
|
|
71
|
+
readonly trackingPhaseKey: "accepted";
|
|
72
|
+
readonly label: {
|
|
73
|
+
readonly pl: "Przyjęte";
|
|
74
|
+
readonly en: "Accepted";
|
|
75
|
+
};
|
|
76
|
+
readonly localStatus: readonly ["provider_received"];
|
|
77
|
+
readonly omsFulfillmentStatus: ["label_pending", "label_created"];
|
|
78
|
+
readonly orderStatus: [];
|
|
79
|
+
readonly aliasTokens: ["new", "accepted", "processing", "provider_attempt_recorded"];
|
|
80
|
+
readonly effects: {
|
|
81
|
+
readonly stock: "reserved";
|
|
82
|
+
readonly handover: false;
|
|
83
|
+
readonly customerEmail: false;
|
|
84
|
+
};
|
|
85
|
+
}, {
|
|
86
|
+
readonly customerStep: "packing";
|
|
87
|
+
readonly rank: 3;
|
|
88
|
+
readonly phaseIndex: 2;
|
|
89
|
+
readonly onTrack: true;
|
|
90
|
+
readonly stepLabelKey: "account:dashboard.ordersV2.steps.packing";
|
|
91
|
+
readonly timelineLabel: "Przygotowane do wysylki";
|
|
92
|
+
readonly trackingPhaseKey: "preparing";
|
|
93
|
+
readonly label: {
|
|
94
|
+
readonly pl: "Przygotowanie";
|
|
95
|
+
readonly en: "Preparing";
|
|
96
|
+
};
|
|
97
|
+
readonly localStatus: readonly ["picking", "packed"];
|
|
98
|
+
readonly omsFulfillmentStatus: ["packed"];
|
|
99
|
+
readonly orderStatus: [];
|
|
100
|
+
readonly aliasTokens: ["picked", "in_fulfillment", "ready_for_packing", "awaiting_courier", "ready_for_pickup"];
|
|
101
|
+
readonly effects: {
|
|
102
|
+
readonly stock: "consumed";
|
|
103
|
+
readonly handover: false;
|
|
104
|
+
readonly customerEmail: false;
|
|
105
|
+
};
|
|
106
|
+
}, {
|
|
107
|
+
readonly customerStep: "transit";
|
|
108
|
+
readonly rank: 4;
|
|
109
|
+
readonly phaseIndex: 3;
|
|
110
|
+
readonly onTrack: true;
|
|
111
|
+
readonly stepLabelKey: "account:dashboard.ordersV2.steps.transit";
|
|
112
|
+
readonly timelineLabel: "W drodze";
|
|
113
|
+
readonly trackingPhaseKey: "in_transit";
|
|
114
|
+
readonly label: {
|
|
115
|
+
readonly pl: "W drodze";
|
|
116
|
+
readonly en: "In transit";
|
|
117
|
+
};
|
|
118
|
+
readonly localStatus: readonly ["in_transit"];
|
|
119
|
+
readonly omsFulfillmentStatus: ["handed_over", "in_transit"];
|
|
120
|
+
readonly orderStatus: [];
|
|
121
|
+
readonly aliasTokens: ["shipped", "shipping", "out_for_delivery", "dispatched"];
|
|
122
|
+
readonly effects: {
|
|
123
|
+
readonly stock: "consumed";
|
|
124
|
+
readonly handover: true;
|
|
125
|
+
readonly customerEmail: "shipment.dispatched";
|
|
126
|
+
};
|
|
127
|
+
}, {
|
|
128
|
+
readonly customerStep: "delivered";
|
|
129
|
+
readonly rank: 7;
|
|
130
|
+
readonly phaseIndex: 4;
|
|
131
|
+
readonly onTrack: true;
|
|
132
|
+
readonly stepLabelKey: "account:dashboard.ordersV2.steps.delivered";
|
|
133
|
+
readonly timelineLabel: "Dostarczono";
|
|
134
|
+
readonly trackingPhaseKey: "delivered";
|
|
135
|
+
readonly label: {
|
|
136
|
+
readonly pl: "Dostarczone";
|
|
137
|
+
readonly en: "Delivered";
|
|
138
|
+
};
|
|
139
|
+
readonly localStatus: readonly ["delivered"];
|
|
140
|
+
readonly omsFulfillmentStatus: ["delivered"];
|
|
141
|
+
readonly orderStatus: ["fulfilled"];
|
|
142
|
+
readonly aliasTokens: ["delivery_delivered"];
|
|
143
|
+
readonly effects: {
|
|
144
|
+
readonly stock: "consumed";
|
|
145
|
+
readonly handover: true;
|
|
146
|
+
readonly customerEmail: false;
|
|
147
|
+
};
|
|
148
|
+
}, {
|
|
149
|
+
readonly customerStep: "exception";
|
|
150
|
+
readonly rank: 5;
|
|
151
|
+
readonly phaseIndex: 1;
|
|
152
|
+
readonly onTrack: false;
|
|
153
|
+
readonly stepLabelKey: "account:dashboard.ordersV2.status.exception";
|
|
154
|
+
readonly timelineLabel: "Wymaga sprawdzenia";
|
|
155
|
+
readonly trackingPhaseKey: "exception";
|
|
156
|
+
readonly label: {
|
|
157
|
+
readonly pl: "Wymaga sprawdzenia";
|
|
158
|
+
readonly en: "Needs review";
|
|
159
|
+
};
|
|
160
|
+
readonly localStatus: readonly ["exception"];
|
|
161
|
+
readonly omsFulfillmentStatus: ["exception"];
|
|
162
|
+
readonly orderStatus: [];
|
|
163
|
+
readonly aliasTokens: ["shipping_failed", "failed", "suspended", "returned_to_sender"];
|
|
164
|
+
readonly effects: {
|
|
165
|
+
readonly stock: "held";
|
|
166
|
+
readonly handover: false;
|
|
167
|
+
readonly customerEmail: "shipment.exception (provider exception before handoff)";
|
|
168
|
+
};
|
|
169
|
+
}, {
|
|
170
|
+
readonly customerStep: "cancelled";
|
|
171
|
+
readonly rank: 6;
|
|
172
|
+
readonly phaseIndex: 0;
|
|
173
|
+
readonly onTrack: false;
|
|
174
|
+
readonly stepLabelKey: "account:dashboard.ordersV2.status.cancelled";
|
|
175
|
+
readonly timelineLabel: "Anulowano";
|
|
176
|
+
readonly trackingPhaseKey: "cancelled";
|
|
177
|
+
readonly label: {
|
|
178
|
+
readonly pl: "Anulowano";
|
|
179
|
+
readonly en: "Cancelled";
|
|
180
|
+
};
|
|
181
|
+
readonly localStatus: readonly ["cancelled"];
|
|
182
|
+
readonly omsFulfillmentStatus: ["cancelled"];
|
|
183
|
+
readonly orderStatus: ["cancelled", "refunded"];
|
|
184
|
+
readonly aliasTokens: ["canceled"];
|
|
185
|
+
readonly effects: {
|
|
186
|
+
readonly stock: "released";
|
|
187
|
+
readonly handover: false;
|
|
188
|
+
readonly customerEmail: false;
|
|
189
|
+
};
|
|
190
|
+
}];
|
|
191
|
+
};
|
|
192
|
+
type CanonicalLocalFulfillmentStatus = import("./types.js").CanonicalLocalFulfillmentStatus;
|
|
193
|
+
export declare const CANONICAL_LOCAL_FULFILLMENT_STATUSES: readonly CanonicalLocalFulfillmentStatus[];
|
|
194
|
+
export declare const CUSTOMER_STEP_SEQUENCE: CustomerFulfillmentStep[];
|
|
195
|
+
export declare function canonicalStatusToken(value: string | null | undefined): string | null;
|
|
196
|
+
/**
|
|
197
|
+
* Resolve a single raw token (a Layer B `local_status`, an FSM value, an order
|
|
198
|
+
* status, or a provider/synonym) to a customer step. Exact map lookup first, then
|
|
199
|
+
* a conservative substring fallback so an unknown FUTURE token never regresses to
|
|
200
|
+
* a misleading default — it returns null and the caller decides (generic label /
|
|
201
|
+
* "paid" floor). Never guesses a more-advanced state than the token implies.
|
|
202
|
+
*/
|
|
203
|
+
export declare function stepForToken(token: string | null | undefined): CustomerFulfillmentStep | null;
|
|
204
|
+
/**
|
|
205
|
+
* The single customer step for an order, from all its real signals (order status,
|
|
206
|
+
* durable fulfillment status, latest provider evidence). The most-advanced real
|
|
207
|
+
* signal wins by rank — so the internal `label_created` (set at OUR dispatch) can
|
|
208
|
+
* never overstate a box the provider has not started, and a real `picking`/
|
|
209
|
+
* `in_transit` is never held back by a stale internal state. Floors at "paid".
|
|
210
|
+
*/
|
|
211
|
+
export declare function customerStepFromSignals(tokens: Array<string | null | undefined>): CustomerFulfillmentStep;
|
|
212
|
+
export declare function phaseIndexForStep(step: CustomerFulfillmentStep): number;
|
|
213
|
+
/** i18n key for a step's short label (progress bar + cards). */
|
|
214
|
+
export declare function stepLabelKey(step: CustomerFulfillmentStep): string;
|
|
215
|
+
/** Server-emitted Polish milestone label for the order-detail timeline. */
|
|
216
|
+
export declare function timelineLabelForStep(step: CustomerFulfillmentStep): string;
|
|
217
|
+
/** `panels.orders.trackingPhase.*` key for a step (start-page tile). */
|
|
218
|
+
export declare function trackingPhaseKeyForStep(step: CustomerFulfillmentStep): string;
|
|
219
|
+
export declare function stepUsesTruckIcon(step: CustomerFulfillmentStep): boolean;
|
|
220
|
+
export {};
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
export const FULFILLMENT_STATUS_MAP = {
|
|
2
|
+
version: 1,
|
|
3
|
+
// Contributed metadata below is retained verbatim. The package-local test pins
|
|
4
|
+
// the projection digest, token round-trips and helpers; it does not check host
|
|
5
|
+
// provider dictionaries or i18n resources.
|
|
6
|
+
comment: "SINGLE SOURCE OF TRUTH for fulfillment status across all layers. Layer C (customerStep) is the spine. Providers (Layer A) map ONLY to local_status (Layer B) in their own dictionary; the UI consumes only Layer C. statusMap.test.ts keeps every layer consistent.",
|
|
7
|
+
stages: [
|
|
8
|
+
{
|
|
9
|
+
customerStep: "paid",
|
|
10
|
+
rank: 1,
|
|
11
|
+
phaseIndex: 0,
|
|
12
|
+
onTrack: true,
|
|
13
|
+
stepLabelKey: "account:dashboard.ordersV2.steps.paid",
|
|
14
|
+
timelineLabel: "Oplacone",
|
|
15
|
+
trackingPhaseKey: "paid",
|
|
16
|
+
label: { pl: "Opłacone", en: "Paid" },
|
|
17
|
+
localStatus: [],
|
|
18
|
+
omsFulfillmentStatus: ["created"],
|
|
19
|
+
orderStatus: ["paid", "fulfillment_pending"],
|
|
20
|
+
aliasTokens: [],
|
|
21
|
+
effects: { stock: "reserved", handover: false, customerEmail: false },
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
customerStep: "accepted",
|
|
25
|
+
rank: 2,
|
|
26
|
+
phaseIndex: 1,
|
|
27
|
+
onTrack: true,
|
|
28
|
+
stepLabelKey: "account:dashboard.ordersV2.steps.accepted",
|
|
29
|
+
timelineLabel: "Przyjete do realizacji",
|
|
30
|
+
trackingPhaseKey: "accepted",
|
|
31
|
+
label: { pl: "Przyjęte", en: "Accepted" },
|
|
32
|
+
localStatus: ["provider_received"],
|
|
33
|
+
omsFulfillmentStatus: ["label_pending", "label_created"],
|
|
34
|
+
orderStatus: [],
|
|
35
|
+
aliasTokens: ["new", "accepted", "processing", "provider_attempt_recorded"],
|
|
36
|
+
effects: { stock: "reserved", handover: false, customerEmail: false },
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
customerStep: "packing",
|
|
40
|
+
rank: 3,
|
|
41
|
+
phaseIndex: 2,
|
|
42
|
+
onTrack: true,
|
|
43
|
+
stepLabelKey: "account:dashboard.ordersV2.steps.packing",
|
|
44
|
+
timelineLabel: "Przygotowane do wysylki",
|
|
45
|
+
trackingPhaseKey: "preparing",
|
|
46
|
+
label: { pl: "Przygotowanie", en: "Preparing" },
|
|
47
|
+
localStatus: ["picking", "packed"],
|
|
48
|
+
omsFulfillmentStatus: ["packed"],
|
|
49
|
+
orderStatus: [],
|
|
50
|
+
aliasTokens: ["picked", "in_fulfillment", "ready_for_packing", "awaiting_courier", "ready_for_pickup"],
|
|
51
|
+
effects: { stock: "consumed", handover: false, customerEmail: false },
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
customerStep: "transit",
|
|
55
|
+
rank: 4,
|
|
56
|
+
phaseIndex: 3,
|
|
57
|
+
onTrack: true,
|
|
58
|
+
stepLabelKey: "account:dashboard.ordersV2.steps.transit",
|
|
59
|
+
timelineLabel: "W drodze",
|
|
60
|
+
trackingPhaseKey: "in_transit",
|
|
61
|
+
label: { pl: "W drodze", en: "In transit" },
|
|
62
|
+
localStatus: ["in_transit"],
|
|
63
|
+
omsFulfillmentStatus: ["handed_over", "in_transit"],
|
|
64
|
+
orderStatus: [],
|
|
65
|
+
aliasTokens: ["shipped", "shipping", "out_for_delivery", "dispatched"],
|
|
66
|
+
effects: { stock: "consumed", handover: true, customerEmail: "shipment.dispatched" },
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
customerStep: "delivered",
|
|
70
|
+
rank: 7,
|
|
71
|
+
phaseIndex: 4,
|
|
72
|
+
onTrack: true,
|
|
73
|
+
stepLabelKey: "account:dashboard.ordersV2.steps.delivered",
|
|
74
|
+
timelineLabel: "Dostarczono",
|
|
75
|
+
trackingPhaseKey: "delivered",
|
|
76
|
+
label: { pl: "Dostarczone", en: "Delivered" },
|
|
77
|
+
localStatus: ["delivered"],
|
|
78
|
+
omsFulfillmentStatus: ["delivered"],
|
|
79
|
+
orderStatus: ["fulfilled"],
|
|
80
|
+
aliasTokens: ["delivery_delivered"],
|
|
81
|
+
effects: { stock: "consumed", handover: true, customerEmail: false },
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
customerStep: "exception",
|
|
85
|
+
rank: 5,
|
|
86
|
+
phaseIndex: 1,
|
|
87
|
+
onTrack: false,
|
|
88
|
+
stepLabelKey: "account:dashboard.ordersV2.status.exception",
|
|
89
|
+
timelineLabel: "Wymaga sprawdzenia",
|
|
90
|
+
trackingPhaseKey: "exception",
|
|
91
|
+
label: { pl: "Wymaga sprawdzenia", en: "Needs review" },
|
|
92
|
+
localStatus: ["exception"],
|
|
93
|
+
omsFulfillmentStatus: ["exception"],
|
|
94
|
+
orderStatus: [],
|
|
95
|
+
aliasTokens: ["shipping_failed", "failed", "suspended", "returned_to_sender"],
|
|
96
|
+
effects: { stock: "held", handover: false, customerEmail: "shipment.exception (provider exception before handoff)" },
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
customerStep: "cancelled",
|
|
100
|
+
rank: 6,
|
|
101
|
+
phaseIndex: 0,
|
|
102
|
+
onTrack: false,
|
|
103
|
+
stepLabelKey: "account:dashboard.ordersV2.status.cancelled",
|
|
104
|
+
timelineLabel: "Anulowano",
|
|
105
|
+
trackingPhaseKey: "cancelled",
|
|
106
|
+
label: { pl: "Anulowano", en: "Cancelled" },
|
|
107
|
+
localStatus: ["cancelled"],
|
|
108
|
+
omsFulfillmentStatus: ["cancelled"],
|
|
109
|
+
orderStatus: ["cancelled", "refunded"],
|
|
110
|
+
aliasTokens: ["canceled"],
|
|
111
|
+
effects: { stock: "released", handover: false, customerEmail: false },
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
};
|
|
115
|
+
export const CANONICAL_LOCAL_FULFILLMENT_STATUSES = FULFILLMENT_STATUS_MAP.stages.flatMap((stage) => stage.localStatus);
|
|
116
|
+
// Off-track steps render separately and are absent from the progress-bar spine.
|
|
117
|
+
export const CUSTOMER_STEP_SEQUENCE = ["paid", "accepted", "packing", "transit", "delivered"];
|
|
118
|
+
// Provider strings arrive in varied spelling ("IN_FULFILLMENT", "in-fulfillment",
|
|
119
|
+
// "Label Created") — compare on a canonical token so casing/separator drift never
|
|
120
|
+
// silently changes the mapping.
|
|
121
|
+
export function canonicalStatusToken(value) {
|
|
122
|
+
const token = value?.trim().toLowerCase().replace(/[\s_-]+/g, "_");
|
|
123
|
+
return token || null;
|
|
124
|
+
}
|
|
125
|
+
const STAGE_BY_STEP = Object.fromEntries(FULFILLMENT_STATUS_MAP.stages.map((stage) => [stage.customerStep, stage]));
|
|
126
|
+
// token → customerStep, derived from the map (localStatus + FSM + orderStatus +
|
|
127
|
+
// aliasTokens). The package-local fulfillment/statusMap.test.ts witness checks
|
|
128
|
+
// that each contributed token round-trips to its declared customer step.
|
|
129
|
+
const STEP_LOOKUP = (() => {
|
|
130
|
+
const table = {};
|
|
131
|
+
for (const stage of FULFILLMENT_STATUS_MAP.stages) {
|
|
132
|
+
for (const token of [
|
|
133
|
+
...stage.localStatus,
|
|
134
|
+
...stage.omsFulfillmentStatus,
|
|
135
|
+
...stage.orderStatus,
|
|
136
|
+
...stage.aliasTokens,
|
|
137
|
+
]) {
|
|
138
|
+
const canon = canonicalStatusToken(token);
|
|
139
|
+
if (canon)
|
|
140
|
+
table[canon] = stage.customerStep;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return table;
|
|
144
|
+
})();
|
|
145
|
+
/**
|
|
146
|
+
* Resolve a single raw token (a Layer B `local_status`, an FSM value, an order
|
|
147
|
+
* status, or a provider/synonym) to a customer step. Exact map lookup first, then
|
|
148
|
+
* a conservative substring fallback so an unknown FUTURE token never regresses to
|
|
149
|
+
* a misleading default — it returns null and the caller decides (generic label /
|
|
150
|
+
* "paid" floor). Never guesses a more-advanced state than the token implies.
|
|
151
|
+
*/
|
|
152
|
+
export function stepForToken(token) {
|
|
153
|
+
const canon = canonicalStatusToken(token);
|
|
154
|
+
if (!canon)
|
|
155
|
+
return null;
|
|
156
|
+
const exact = STEP_LOOKUP[canon];
|
|
157
|
+
if (exact)
|
|
158
|
+
return exact;
|
|
159
|
+
if (/deliver/.test(canon))
|
|
160
|
+
return "delivered";
|
|
161
|
+
if (/cancel/.test(canon))
|
|
162
|
+
return "cancelled";
|
|
163
|
+
if (/exception|fail/.test(canon))
|
|
164
|
+
return "exception";
|
|
165
|
+
if (/transit|dispatch|shipp|out_for/.test(canon))
|
|
166
|
+
return "transit";
|
|
167
|
+
if (/pack|pick|fulfil/.test(canon))
|
|
168
|
+
return "packing";
|
|
169
|
+
if (/label|accept|received|processing/.test(canon) || canon === "new")
|
|
170
|
+
return "accepted";
|
|
171
|
+
if (/paid|created/.test(canon))
|
|
172
|
+
return "paid";
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* The single customer step for an order, from all its real signals (order status,
|
|
177
|
+
* durable fulfillment status, latest provider evidence). The most-advanced real
|
|
178
|
+
* signal wins by rank — so the internal `label_created` (set at OUR dispatch) can
|
|
179
|
+
* never overstate a box the provider has not started, and a real `picking`/
|
|
180
|
+
* `in_transit` is never held back by a stale internal state. Floors at "paid".
|
|
181
|
+
*/
|
|
182
|
+
export function customerStepFromSignals(tokens) {
|
|
183
|
+
let best = null;
|
|
184
|
+
let bestRank = -1;
|
|
185
|
+
for (const token of tokens) {
|
|
186
|
+
const step = stepForToken(token);
|
|
187
|
+
if (!step)
|
|
188
|
+
continue;
|
|
189
|
+
const rank = STAGE_BY_STEP[step].rank;
|
|
190
|
+
if (rank > bestRank) {
|
|
191
|
+
bestRank = rank;
|
|
192
|
+
best = step;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return best ?? "paid";
|
|
196
|
+
}
|
|
197
|
+
export function phaseIndexForStep(step) {
|
|
198
|
+
return STAGE_BY_STEP[step].phaseIndex;
|
|
199
|
+
}
|
|
200
|
+
/** i18n key for a step's short label (progress bar + cards). */
|
|
201
|
+
export function stepLabelKey(step) {
|
|
202
|
+
return STAGE_BY_STEP[step].stepLabelKey;
|
|
203
|
+
}
|
|
204
|
+
/** Server-emitted Polish milestone label for the order-detail timeline. */
|
|
205
|
+
export function timelineLabelForStep(step) {
|
|
206
|
+
return STAGE_BY_STEP[step].timelineLabel;
|
|
207
|
+
}
|
|
208
|
+
/** `panels.orders.trackingPhase.*` key for a step (start-page tile). */
|
|
209
|
+
export function trackingPhaseKeyForStep(step) {
|
|
210
|
+
return STAGE_BY_STEP[step].trackingPhaseKey;
|
|
211
|
+
}
|
|
212
|
+
export function stepUsesTruckIcon(step) {
|
|
213
|
+
return step === "transit" || step === "delivered";
|
|
214
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { FulfillmentProgressInput, FulfillmentProgressPresentation } from './types.js';
|
|
2
|
+
import type { CustomerFulfillmentStep } from './types.js';
|
|
3
|
+
/** The host resolves its existing step; only actual recorded milestones complete. */
|
|
4
|
+
export declare function fulfillmentProgressPresentation(order: FulfillmentProgressInput, step: CustomerFulfillmentStep): FulfillmentProgressPresentation | null;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { CUSTOMER_STEP_SEQUENCE, phaseIndexForStep, stepForToken } from './statusMap.js';
|
|
2
|
+
/** The host resolves its existing step; only actual recorded milestones complete. */
|
|
3
|
+
export function fulfillmentProgressPresentation(order, step) {
|
|
4
|
+
const status = order.status.toLowerCase();
|
|
5
|
+
const rawPayment = order.paymentStatus?.toLowerCase() ?? "";
|
|
6
|
+
const payment = rawPayment === "paid" ? "succeeded" : rawPayment;
|
|
7
|
+
const paid = payment === "succeeded";
|
|
8
|
+
const terminal = status === "cancelled" || status === "refunded";
|
|
9
|
+
const orderMilestone = stepForToken(status);
|
|
10
|
+
const recordedOrderMilestone = orderMilestone !== null && orderMilestone !== "paid"
|
|
11
|
+
&& CUSTOMER_STEP_SEQUENCE.includes(orderMilestone);
|
|
12
|
+
const parcelEvidence = recordedOrderMilestone || Boolean(order.fulfillmentStatus)
|
|
13
|
+
|| Boolean(order.customerFulfillmentStep && step !== "paid" && !["draft", "failed", "expired"].includes(status))
|
|
14
|
+
|| order.trackingTimeline.some((event) => {
|
|
15
|
+
const observed = stepForToken(event.eventType);
|
|
16
|
+
return observed !== null && observed !== "paid";
|
|
17
|
+
});
|
|
18
|
+
const advancedParcel = !terminal && parcelEvidence && step !== "paid";
|
|
19
|
+
return progressPresentation(order, step, paid, terminal, advancedParcel);
|
|
20
|
+
}
|
|
21
|
+
function progressPresentation(order, step, paid, terminal, advancedParcel) {
|
|
22
|
+
if (terminal || ["draft", "expired"].includes(order.status.toLowerCase())
|
|
23
|
+
|| step === "exception" || step === "cancelled" || !paid && !advancedParcel)
|
|
24
|
+
return null;
|
|
25
|
+
const completed = new Set(paid ? ["paid"] : []);
|
|
26
|
+
const record = (token) => {
|
|
27
|
+
const observed = stepForToken(token);
|
|
28
|
+
// No historical inference from an index, and no payment proof from parcel tokens.
|
|
29
|
+
if (observed && observed !== "paid" && CUSTOMER_STEP_SEQUENCE.includes(observed)
|
|
30
|
+
&& phaseIndexForStep(observed) <= phaseIndexForStep(step))
|
|
31
|
+
completed.add(observed);
|
|
32
|
+
};
|
|
33
|
+
if (advancedParcel) {
|
|
34
|
+
record(order.fulfillmentStatus);
|
|
35
|
+
record(step);
|
|
36
|
+
order.trackingTimeline.forEach((event) => record(event.eventType));
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
completedSteps: [...completed],
|
|
40
|
+
currentStep: advancedParcel && step !== "delivered" ? step : null,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type CanonicalLocalFulfillmentStatus = "provider_received" | "picking" | "packed" | "in_transit" | "delivered" | "exception" | "cancelled";
|
|
2
|
+
export type CustomerFulfillmentStep = "paid" | "accepted" | "packing" | "transit" | "delivered" | "exception" | "cancelled";
|
|
3
|
+
export interface FulfillmentProgressInput {
|
|
4
|
+
status: string;
|
|
5
|
+
paymentStatus?: string | null;
|
|
6
|
+
fulfillmentStatus?: string | null;
|
|
7
|
+
customerFulfillmentStep?: CustomerFulfillmentStep | null;
|
|
8
|
+
trackingTimeline: {
|
|
9
|
+
eventType: string;
|
|
10
|
+
}[];
|
|
11
|
+
}
|
|
12
|
+
export interface FulfillmentProgressPresentation {
|
|
13
|
+
completedSteps: CustomerFulfillmentStep[];
|
|
14
|
+
currentStep: CustomerFulfillmentStep | null;
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
package/release-gates.json
CHANGED
|
@@ -75,7 +75,11 @@
|
|
|
75
75
|
"maturity": "experimental",
|
|
76
76
|
"compatibilityPolicy": "internal-candidate",
|
|
77
77
|
"packageSmokeEvidence": [
|
|
78
|
-
"smoke/fulfillmentStandalone.test.ts"
|
|
78
|
+
"smoke/fulfillmentStandalone.test.ts",
|
|
79
|
+
"test/fulfillmentStatusMap.test.ts",
|
|
80
|
+
"test/fulfillmentTrackingProgress.test.ts",
|
|
81
|
+
"test/fulfillmentCurrentParcel.test.ts",
|
|
82
|
+
"test/fulfillmentNormalizedFacts.test.ts"
|
|
79
83
|
],
|
|
80
84
|
"entrypoint": "dist/fulfillment/index.d.ts",
|
|
81
85
|
"snapshot": "api/fulfillment.api.md"
|
|
@@ -286,8 +290,8 @@
|
|
|
286
290
|
"release-gates.json"
|
|
287
291
|
],
|
|
288
292
|
"baseline": {
|
|
289
|
-
"version": "0.
|
|
290
|
-
"packedBytes":
|
|
293
|
+
"version": "0.21.0",
|
|
294
|
+
"packedBytes": 161706
|
|
291
295
|
},
|
|
292
296
|
"browserEntries": {}
|
|
293
297
|
},
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// An order may hold more than one fulfilment row: the original at `sequence_no` 0 and a
|
|
2
|
+
// replacement parcel at 1, 2, ... Several reads still have to answer with exactly one, and
|
|
3
|
+
// they must all answer the same way, or the operator, the customer and the support agent
|
|
4
|
+
// each see a different parcel for the same order.
|
|
5
|
+
//
|
|
6
|
+
// The rule, fixed once here: the parcel that *currently represents the order* is the highest
|
|
7
|
+
// `sequence_no`, tie-broken by `id` descending. While every order has exactly one row - which
|
|
8
|
+
// is every order until an operator can request a replacement - each function below returns
|
|
9
|
+
// that row, so the reads calling them are unchanged.
|
|
10
|
+
//
|
|
11
|
+
// This module is the single owner of that rule. It was four near-copies across two adapters
|
|
12
|
+
// and two read models before they were merged; keep it one.
|
|
13
|
+
|
|
14
|
+
export type FulfillmentParcelRow = { id?: unknown; order_id?: unknown; sequence_no?: unknown };
|
|
15
|
+
|
|
16
|
+
// A row read without the column, or by a query that does not select it, is the original
|
|
17
|
+
// parcel - `sequence_no` carries a database default of 0 for exactly the same reason.
|
|
18
|
+
export function parcelSequenceNo(row: FulfillmentParcelRow): number {
|
|
19
|
+
return typeof row.sequence_no === "number" ? row.sequence_no : 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Negative when `left` is the more current parcel, so this sorts the current parcel first.
|
|
23
|
+
// Plain codepoint order on `id`, never `localeCompare`: this has to agree with SQL's
|
|
24
|
+
// `ORDER BY id DESC` over a uuid, and a collation that folds hyphens or case would not.
|
|
25
|
+
export function compareParcelPrecedence(left: FulfillmentParcelRow, right: FulfillmentParcelRow): number {
|
|
26
|
+
const bySequence = parcelSequenceNo(right) - parcelSequenceNo(left);
|
|
27
|
+
if (bySequence !== 0) return bySequence;
|
|
28
|
+
const leftId = String(left.id ?? "");
|
|
29
|
+
const rightId = String(right.id ?? "");
|
|
30
|
+
if (leftId === rightId) return 0;
|
|
31
|
+
return leftId < rightId ? 1 : -1;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function currentParcel<Row extends FulfillmentParcelRow>(rows: readonly Row[]): Row | null {
|
|
35
|
+
return rows.reduce<Row | null>(
|
|
36
|
+
(best, row) => (best === null || compareParcelPrecedence(row, best) < 0 ? row : best),
|
|
37
|
+
null,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function currentParcelFor<Row extends FulfillmentParcelRow>(
|
|
42
|
+
rows: readonly Row[],
|
|
43
|
+
orderId: unknown,
|
|
44
|
+
): Row | null {
|
|
45
|
+
return currentParcel(rows.filter((row) => row.order_id === orderId));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function currentParcelPerOrder<Row extends FulfillmentParcelRow>(
|
|
49
|
+
rows: readonly Row[],
|
|
50
|
+
): Map<string, Row> {
|
|
51
|
+
const byOrder = new Map<string, Row>();
|
|
52
|
+
for (const row of rows) {
|
|
53
|
+
const orderId = String(row.order_id ?? "");
|
|
54
|
+
const incumbent = byOrder.get(orderId);
|
|
55
|
+
if (!incumbent || compareParcelPrecedence(row, incumbent) < 0) byOrder.set(orderId, row);
|
|
56
|
+
}
|
|
57
|
+
return byOrder;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// For consumers that take `[0]` of an order's rows after filtering by `order_id`. Grouping
|
|
61
|
+
// preserves each order's first-appearance position, so a result that already holds one row
|
|
62
|
+
// per order comes back in exactly the order it arrived.
|
|
63
|
+
export function currentParcelFirst<Row extends FulfillmentParcelRow>(rows: readonly Row[]): Row[] {
|
|
64
|
+
const byOrder = new Map<string, Row[]>();
|
|
65
|
+
for (const row of rows) {
|
|
66
|
+
const key = String(row.order_id ?? "");
|
|
67
|
+
const group = byOrder.get(key);
|
|
68
|
+
if (group) group.push(row);
|
|
69
|
+
else byOrder.set(key, [row]);
|
|
70
|
+
}
|
|
71
|
+
return [...byOrder.values()].flatMap((group) => (group.length > 1 ? [...group].sort(compareParcelPrecedence) : group));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A companion row - a tracking ref, an operation, an evidence row - names the parcel it was
|
|
75
|
+
// written for. Attribution is the rule the OMS summary already applies to every other companion;
|
|
76
|
+
// tracking refs only joined it once `shipment_external_refs.fulfillment_order_id` existed.
|
|
77
|
+
//
|
|
78
|
+
// A row whose attribution is absent belongs to whichever parcel is asking. That is exactly the
|
|
79
|
+
// answer every reader gave before the column existed, so no ref a writer left unattributed can
|
|
80
|
+
// vanish from a customer's page - the narrowing can only ever remove a *sibling parcel's* ref.
|
|
81
|
+
export type ParcelAttributedRow = { fulfillment_order_id?: unknown };
|
|
82
|
+
|
|
83
|
+
export function belongsToParcel(row: ParcelAttributedRow, parcelId: unknown): boolean {
|
|
84
|
+
const attributed = row.fulfillment_order_id;
|
|
85
|
+
if (typeof attributed !== "string" || !attributed) return true;
|
|
86
|
+
return typeof parcelId === "string" && parcelId ? attributed === parcelId : true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function rowsForParcel<Row extends ParcelAttributedRow>(
|
|
90
|
+
rows: readonly Row[],
|
|
91
|
+
parcelId: unknown,
|
|
92
|
+
): Row[] {
|
|
93
|
+
return rows.filter((row) => belongsToParcel(row, parcelId));
|
|
94
|
+
}
|
package/src/fulfillment/index.ts
CHANGED
|
@@ -32,3 +32,23 @@ export type {
|
|
|
32
32
|
FulfillmentTransitionSnapshot,
|
|
33
33
|
TransitionConflictReason,
|
|
34
34
|
} from "./facts.js";
|
|
35
|
+
|
|
36
|
+
/** @beta */
|
|
37
|
+
export { FULFILLMENT_STATUS_MAP, CANONICAL_LOCAL_FULFILLMENT_STATUSES, CUSTOMER_STEP_SEQUENCE, canonicalStatusToken, stepForToken, customerStepFromSignals, phaseIndexForStep, stepLabelKey, timelineLabelForStep, trackingPhaseKeyForStep, stepUsesTruckIcon } from "./statusMap.js";
|
|
38
|
+
/** @beta */
|
|
39
|
+
export type { FulfillmentStatusStage, FulfillmentStatusMap } from "./statusMap.js";
|
|
40
|
+
|
|
41
|
+
/** @beta */
|
|
42
|
+
export { fulfillmentProgressPresentation } from "./trackingProgress.js";
|
|
43
|
+
|
|
44
|
+
/** @beta */
|
|
45
|
+
export { parcelSequenceNo, compareParcelPrecedence, currentParcel, currentParcelFor, currentParcelPerOrder, currentParcelFirst, belongsToParcel, rowsForParcel } from "./currentFulfillmentParcel.js";
|
|
46
|
+
/** @beta */
|
|
47
|
+
export type { FulfillmentParcelRow, ParcelAttributedRow } from "./currentFulfillmentParcel.js";
|
|
48
|
+
|
|
49
|
+
/** @beta */
|
|
50
|
+
export { isCanonicalLocalFulfillmentStatus, parseFulfillmentFact, parseFulfillmentTransitionConflict } from "./normalizedFulfillmentFacts.js";
|
|
51
|
+
/** @beta */
|
|
52
|
+
export type { CanonicalNormalizedFulfillmentFact, CanonicalNormalizedFulfillmentFactV1, CanonicalNormalizedFulfillmentFactV0, CanonicalCompatibleNormalizedFulfillmentFact, CanonicalFulfillmentTransitionSnapshot, CanonicalTransitionConflict } from "./normalizedFulfillmentFacts.js";
|
|
53
|
+
/** @beta */
|
|
54
|
+
export type { CanonicalLocalFulfillmentStatus, CustomerFulfillmentStep, FulfillmentProgressInput, FulfillmentProgressPresentation } from "./types.js";
|