Files
0pt/lib/order-flow.ts
admin c3582838aa
CI (SIAX Cloud) / integration (push) Skipped
CI (SIAX Cloud) / sonar (push) Skipped
masterplan-lock / check (push) Successful in 1m3s
CI (SIAX Cloud) / security (push) Successful in 1m4s
Security Scan (SIAX Cloud) / security (push) Successful in 1m7s
CI (SIAX Cloud) / quality (push) Successful in 1m52s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 6s
CI (SIAX Cloud) / security (pull_request) Successful in 18s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 19s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m20s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m58s
feat(orders): kanonisk statusmaskin + guardad DB-constraint (ADR-0002, C3)
2026-09-29 21:15:35 +02:00

103 lines
4.0 KiB
TypeScript

import { createInboxItem, type InboxItem } from "@siax/contracts/inbox";
import { resolveAction, type ResolvedAction } from "@siax/contracts/authz-ux";
/**
* Kanonisk orderstatus (ADR-0002, C3 2026-09-29): kodnivå-union som är den
* ENDA sanningskällan för vilka statusar som existerar. DB-kolumnen är fortfarande
* `text` (sql/001_init.sql line ~92) men guardas av sql/015_order_status_check.sql
* (CHECK ... NOT VALID + VALIDATE i två steg, körs EJ automatiskt mot prod).
* 'cancelled' ingår: tests/order-flow.test.ts behandlar den som terminal status.
*/
export type OrderStatus =
| "draft"
| "quoted"
| "in_production"
| "review"
| "accepted"
| "delivered"
| "cancelled";
/** Uttömmande, kanonisk lista — SQL-constrainten i sql/015 MÅSTE spegla denna. */
export const ORDER_STATUSES = [
"draft",
"quoted",
"in_production",
"review",
"accepted",
"delivered",
"cancelled",
] as const satisfies readonly OrderStatus[];
/** Typ-vakt: fri text ur DB/payload klassas mot unionen (fail-closed mot read_only). */
export function isKnownOrderStatus(status: string): status is OrderStatus {
return (ORDER_STATUSES as readonly string[]).includes(status);
}
/** Statusar som kan flyttas fram; delivered/cancelled är terminala. */
type AdvanceableStatus = Exclude<OrderStatus, "delivered" | "cancelled">;
/** Kanoniskt flöde — servern äger resolutionen (ADR-0001), UI renderar. */
export const NEXT_STATUS: Record<AdvanceableStatus, OrderStatus> = {
draft: "quoted",
quoted: "in_production",
in_production: "review",
review: "accepted",
accepted: "delivered",
};
/** Guardad nästa-status: okänd/terminal status → null (aldrig påhittat steg). */
export function nextOrderStatus(status: string): OrderStatus | null {
if (!isKnownOrderStatus(status)) return null;
return NEXT_STATUS[status as AdvanceableStatus] ?? null;
}
/**
* Server-renderad action-resolution för advance-steget (ADR-0001):
* ägarskapenforces redan av requireOrderOwner på GET/PATCH; tillståndet
* avgör read_only. Innan Session/roller finns i 0pt är permission alltid
* granted för autentiserade ägare — hårdkoda aldrig denna resolve i klienten.
* Okänd status (fri text ur DB innan sql/015-valideringen körts) → read_only.
*/
export function resolveAdvanceAction(status: string): ResolvedAction {
if (nextOrderStatus(status)) {
return resolveAction({ permission: "granted", entitlement: "active", approval: "not_required", readOnly: false });
}
return resolveAction({ permission: "granted", entitlement: "active", approval: "not_required", readOnly: true });
}
/** Orderstatus som utgör en inkorgspost (våg 3.3: orders kö = inbox). */
const INBOX_ELIGIBLE: Partial<Record<OrderStatus, { kind: "task" | "approval"; priority: "normal" | "high" }>> = {
draft: { kind: "task", priority: "normal" },
quoted: { kind: "task", priority: "normal" },
in_production: { kind: "task", priority: "normal" },
review: { kind: "approval", priority: "high" },
};
/**
* Server-renderad inkorgspost (ADR-0001-mönstret): en order som väntar på
* nästa steg projiceras på universal-inbox-konvolverna. Terminala statusar
* (delivered/cancelled) och okända statusar ger ingen post — inboxen är
* öppna arbetsobjekt.
*/
export function buildOrderInboxItem(order: {
order_id: string;
status: string;
flow: string;
total_ore?: number | null;
}): InboxItem | null {
const next = nextOrderStatus(order.status);
const eligible = isKnownOrderStatus(order.status) ? INBOX_ELIGIBLE[order.status] : undefined;
if (!eligible || !next) return null;
return createInboxItem({
id: `order:${order.order_id}`,
kind: eligible.kind,
tenantId: "0pt",
subject: `Order ${order.order_id.slice(0, 8)} — ${order.status} → ${next}`,
priority: eligible.priority,
createdAt: new Date().toISOString(),
source: { system: "0pt", channel: "order-queue" },
entity: { type: "order", id: order.order_id },
actions: [{ id: "advance", label: `→ ${next}` }],
});
}