Blocks order-item edits when the operator saves below the 22% markup threshold and routes them through a manager-approval gate before the item can be processed. - new `order-item-margin-approval-service` saves the submitted draft, stamps `item_status='approval_needed'`, then fires the configured n8n webhook; webhook failure leaves the item blocked and returns `webhookFired=false` - new POST /api/v1/order-items/[id]/request-margin-approval browser endpoint gates the same flow with source='order_process' | 'order_sprint' - new `MarginApprovalDialog` shared by both order-process and order-sprint process surfaces — single source of truth for the submit/audit-note flow - new `approval_needed` / `approval_rejected` item statuses; the order-process save path now returns 'blocked' when an item is in approval_needed so the operator can't re-edit past the gate - order-sprint-process-modal now uses the same dialog so the below-threshold flow is consistent across both entry points Tests: 21 new (service + route + sprint modal), 1231 → 1242 passing. Co-authored-by: Orca <help@stably.ai>
139 lines
No EOL
4.8 KiB
TypeScript
139 lines
No EOL
4.8 KiB
TypeScript
// Full item_status enum — must match the `order_items.item_status`
|
|
// select field on the PB schema. The 2026-07-20-delivery-tracking
|
|
// migration added the last 3 (`with_driver`, `checked_in`,
|
|
// `completed`); the others (`ready_invoice`, `invoiced`,
|
|
// `stock_booked`) were already on the PB schema but missing from
|
|
// this whitelist, which silently rejected legitimate PATCHes —
|
|
// fixed in the same change so /deliveries + n8n flows don't
|
|
// trip a stale validator.
|
|
const ITEM_STATUS_VALUES = [
|
|
"pending",
|
|
"approval_needed",
|
|
"approval_rejected",
|
|
"processed",
|
|
"po_generated",
|
|
"collection_pool",
|
|
"assigned",
|
|
"collected",
|
|
"stock_verified",
|
|
"picking",
|
|
"ready_delivery",
|
|
"delivered",
|
|
"cancelled",
|
|
"ready_invoice",
|
|
"invoiced",
|
|
"stock_booked",
|
|
"with_driver",
|
|
"checked_in",
|
|
"completed",
|
|
] as const;
|
|
|
|
const DRIVER_STATUS_VALUES = [
|
|
"unassigned",
|
|
"assigned",
|
|
"en_route",
|
|
"collected",
|
|
"failed",
|
|
] as const;
|
|
|
|
export type ItemStatusValue = (typeof ITEM_STATUS_VALUES)[number];
|
|
export type DriverStatusValue = (typeof DRIVER_STATUS_VALUES)[number];
|
|
|
|
/**
|
|
* Convert a YYYY-MM-DD string to an ISO timestamp at the start of that
|
|
* day in the business's local timezone. Returns null on parse failure.
|
|
*
|
|
* The route layer normalizes YYYY-MM-DD dates from the operator-facing
|
|
* modal before they hit the normalizer. We don't bake a TZ into the
|
|
* server-side normalizer because the business timezone is a config
|
|
* concern (one day this should live in env or PB), and 00:00 in UTC is
|
|
* close enough for operator scheduling (PO dates are calendar days, not
|
|
* timestamps the driver app needs second-level precision on).
|
|
*/
|
|
export function assignedDateToIso(dateOnly: string): string | null {
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateOnly)) return null;
|
|
const d = new Date(dateOnly + "T00:00:00.000Z");
|
|
if (Number.isNaN(d.getTime())) return null;
|
|
return d.toISOString();
|
|
}
|
|
|
|
export function normalizeOrderItemStatusInput(input: {
|
|
item_status?: string;
|
|
driver_status?: string;
|
|
drv_status?: string;
|
|
notes?: string;
|
|
problem_notes?: string;
|
|
/**
|
|
* Driver user id (relation to `users`). Optional on every status
|
|
* update — the assignment page writes it; the driver app's
|
|
* "I'm starting" / "I'm collected" updates leave it unchanged.
|
|
* Empty string is treated as "clear the assignment" so the
|
|
* unassign path works without a separate codepath.
|
|
*/
|
|
drv_id?: string;
|
|
/**
|
|
* ISO timestamp of the most recent assignment. Same rationale as
|
|
* `drv_id` — only the assignment page writes it; other surfaces
|
|
* leave it untouched. Empty string clears the assignment time.
|
|
*
|
|
* `assignedDate` is the date-only convenience form (YYYY-MM-DD)
|
|
* used by the operator-facing assign modal. The route layer
|
|
* converts it to a full ISO at the local business timezone
|
|
* (00:00:00 UTC) so the operator's chosen day wins over an
|
|
* auto-stamp. Both fields are independent — sending only
|
|
* `assigned_at` keeps the legacy behaviour (stamp to now).
|
|
*/
|
|
assigned_at?: string;
|
|
/** YYYY-MM-DD date the operator picked from the date input. */
|
|
assignedDate?: string;
|
|
}) {
|
|
const patch: Record<string, unknown> = {};
|
|
|
|
if (input.item_status) {
|
|
if (!ITEM_STATUS_VALUES.includes(input.item_status as ItemStatusValue)) {
|
|
throw new Error(`Invalid item_status: ${input.item_status}`);
|
|
}
|
|
patch.item_status = input.item_status;
|
|
}
|
|
|
|
const resolvedDriverStatus = input.drv_status ?? input.driver_status;
|
|
if (resolvedDriverStatus) {
|
|
if (!DRIVER_STATUS_VALUES.includes(resolvedDriverStatus as DriverStatusValue)) {
|
|
throw new Error(`Invalid driver status: ${resolvedDriverStatus}`);
|
|
}
|
|
patch.drv_status = resolvedDriverStatus;
|
|
patch.driver_status = resolvedDriverStatus;
|
|
}
|
|
|
|
if (typeof input.notes === "string") {
|
|
patch.notes = input.notes;
|
|
}
|
|
|
|
if (typeof input.problem_notes === "string") {
|
|
patch.problem_notes = input.problem_notes;
|
|
}
|
|
|
|
// drv_id + assigned_at: assignment lifecycle fields. Either both
|
|
// can be omitted (no change), or either can be present alone —
|
|
// the route handler does not enforce coupling because the
|
|
// driver app's status-only writes must keep flowing. The
|
|
// assignment page is the only surface that writes both at once.
|
|
if (typeof input.drv_id === "string") {
|
|
patch.drv_id = input.drv_id;
|
|
}
|
|
|
|
// assignedDate takes precedence over assigned_at. If the operator
|
|
// picked a date, that wins; otherwise fall through to the legacy
|
|
// auto-stamp path (caller-supplied or now).
|
|
if (typeof input.assignedDate === "string") {
|
|
const iso = assignedDateToIso(input.assignedDate);
|
|
if (!iso) {
|
|
throw new Error(`Invalid assignedDate: ${input.assignedDate}`);
|
|
}
|
|
patch.assigned_at = iso;
|
|
} else if (typeof input.assigned_at === "string") {
|
|
patch.assigned_at = input.assigned_at;
|
|
}
|
|
|
|
return patch;
|
|
} |