From 6bcf73982cf0422211547ed40fce13ef2dd71b9c Mon Sep 17 00:00:00 2001 From: Brandon Hunt Date: Fri, 12 Jun 2026 09:30:05 -0700 Subject: [PATCH 1/4] poc4: full-workflow build plan + PRD workflow confirmation Captures the 2026-06-12 workflow grilling. Adds an authoritative "Workflow Confirmation & Refinements" section to the order-management PRD (per-line status, color-up match key, production-batch trim sheet, satellite intake, DST-parse over Wilcom, new Awaiting Logo / Phone Call / Needs Approver states) and a matching POC 4 build plan in NEXT.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- PRD_Coloring_Up_Order_Management.md | 65 +++++++++++++++++++++- pocs/poc4_order_workflow/NEXT.md | 86 +++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 pocs/poc4_order_workflow/NEXT.md diff --git a/PRD_Coloring_Up_Order_Management.md b/PRD_Coloring_Up_Order_Management.md index c2feac0..d67d5ed 100644 --- a/PRD_Coloring_Up_Order_Management.md +++ b/PRD_Coloring_Up_Order_Management.md @@ -28,6 +28,66 @@ This document defines the requirements for building a **Coloring Up order manage --- +## Workflow Confirmation & Refinements (2026-06-12) + +> **This section supersedes any conflicting statement below it.** It captures decisions locked in a workflow-confirmation session. Where the older text below assumes order-level status, a per-order trim sheet, or Wilcom PDF parsing, **this section wins.** + +### Assembled state machine (per line item) + +``` +New ─► Awaiting Logo ─►[logo linked via NetSuite file-cabinet id, or triage] + │ + ┌───────────┴────────────┐ + exact logo+style+color no match + match exists │ + │ Color-Up In Progress ◄──────────┐ + │ (self-serve pool; │ + │ (optional) Internal Review │ + │ ─ configurable gate ─ │ + │ │ │ + │ Needs Approver? ─► add inline │ + │ │ │ + │ Sent for Approval │ + │ (hosted page; daily │ + │ reminder × up to 5) │ + │ ┌────┴─────┐ │ + │ (5 unanswered) │ │ + │ Phone Call │ │ + │ │ ┌────┴────┐ │ + │ └────►│ │ │ + │ Approved Changes Requested ──┘ + │ (cascades to (comment) ─► Revision + │ all lines using In Progress (honors gate, + │ this color-up) new immutable version) + │ │ + └────────────────────────┤ + ▼ + Production Approval (production-side gate — + │ even zero customer-touch repeats) + Production Ready ──► In Production ──► Complete +``` + +### Decisions that supersede the older text + +1. **Status grain = line item**, not order. The order header shows a rollup summary ("3 of 4 ready, 1 awaiting approval"). Rationale: Straight Down partially ships and rush-pulls complete work. +2. **Color-up match key = `logo + style_number + color_code`, exact match only.** A color code (e.g. MAL) is not physically consistent across styles, so all three must match to reuse an approved color-up. +3. **Approval grain = the color-up proof.** Customer approves/comments per color-up; approval cascades to every line item referencing it; sizes collapse into the group. +4. **Repeat / exact-match lines skip _customer_ approval but still pass a _production_ approval gate** before the floor (zero customer-touch, not zero-touch). A manual "send for approval anyway" override exists. +5. **Trim Sheet = a Production Batch** — an on-demand view (not a persisted object), keyed on `logo + placement + thread choices`, approved-only, never crossing customers. Operator works by Order # and decides grouping at the queue. **This replaces the per-order trim sheet described in Module 4.** +6. **Internal Review = configurable gate** (customer-level default + order-level override). Revisions honor the same setting. +7. **Proof delivery:** email → hosted approval page; approver selects their name from the customer's approver list; any listed approver can act, system records who. A customer **comment always bounces the proof to revision** (team may proxy-approve trivial comments — audit records it as team-proxy). Color-up versions are immutable and loadable per round. +8. **Reminders:** automated daily morning email until approved, capped at 5, then escalates to `Phone Call` status. One digest per customer per morning. +9. **Intake = satellite model.** This system references the NetSuite SO and owns logos / color-ups / approvals / batches. CSV/paste mass-import for orders. Logo identified via the **NetSuite file-cabinet logo id** (the Logo's external key); unknown → `Awaiting Logo` triage. DSTs have two entry points: added to an order then promoted to the customer library, or added directly to the library. +10. **Design technical data comes from the DST parse (POC 1), NOT Wilcom PDF parsing.** Stabilizer / runtime / machine-format become optional manual fields on the Logo. **This supersedes the Wilcom worksheet parsing in Module 4 and Module 1C.** + +### New states added beyond the original docs +`Awaiting Logo` (no logo linked yet), `Phone Call` (5 unanswered reminders), `Needs Approver` (send-for-approval with no approver on file). + +### Still open +End-to-end data sourcing / live NetSuite–VRLink boundary (TBD); whether non-DST production fields are hard must-haves. + +--- + ## Core Workflow The system supports the following end-to-end workflow: @@ -158,7 +218,7 @@ Customer | `customer_id` | FK → Customer | Which customer placed this order | | `netsuite_order_number` | String (optional) | Link to NetSuite SO | | `customer_po` | String | Customer PO number | -| `status` | Enum | See workflow states above | +| `status` | Enum | **Superseded:** status now lives on the line item; the order shows a rollup summary. See Workflow Confirmation (2026-06-12). | | `priority` | Enum | Normal, Rush | | `assigned_to` | String | Team member doing the color-up | | `order_date` | Date | When the order was placed | @@ -180,6 +240,7 @@ Customer | `logo_id` | FK → Logo | Which logo is being embroidered on this line item | | `colorup_id` | FK → Color-Up (optional) | Which approved color-up to use — null if new color-up needed | | `placement` | Enum | Left Chest, Cap Front, Right Sleeve, etc. | +| `status` | Enum | **Added (2026-06-12):** workflow status lives here, per line item. States incl. Awaiting Logo, Color-Up In Progress, Internal Review, Needs Approver, Sent for Approval, Phone Call, Approved, Revision In Progress, Production Ready, In Production, Complete. | Note: Multiple line items can share the same logo and color-up (e.g., five different black garment styles all getting the same logo in the same colors). Line items can also have different logos (e.g., a left chest logo and a separate sleeve logo would be separate line item groupings). @@ -269,6 +330,8 @@ Generates a visual proof document showing the customer what their embroidery wil ### What It Does +> **Superseded grain (2026-06-12):** the Trim Sheet is a **Production Batch** — an on-demand view keyed on `logo + placement + thread choices`, approved-only, never crossing customers — not a per-order document. See Workflow Confirmation. Also: design technical data is sourced from the **DST parse (POC 1)**, not the Wilcom PDF parser described below; treat the Wilcom-parsing requirements here as out of scope. + Generates a consolidated **Trim Sheet** — the single production document that operators use on the floor. Today this information lives across two separate documents: a Wilcom Production Worksheet (design technical specs) and a Compact Trim Sheet from the existing system (order details, garment breakdown, thread sequence). This module combines both into one unified, printable document. ### Current State (What Exists Today) diff --git a/pocs/poc4_order_workflow/NEXT.md b/pocs/poc4_order_workflow/NEXT.md new file mode 100644 index 0000000..4a133d6 --- /dev/null +++ b/pocs/poc4_order_workflow/NEXT.md @@ -0,0 +1,86 @@ +# POC 4 — Build Plan: Order-to-Production Workflow (full-workflow revision) + +This supersedes the stripped-down five-state spec in root `POC_4_Order_Workflow.md`. +The authoritative process is the **"Workflow Confirmation & Refinements (2026-06-12)"** +section of `PRD_Coloring_Up_Order_Management.md` — read it first. It captures a +grilling session that confirmed the full production workflow and surfaced six +load-bearing changes the old spec didn't have. + +## What changed vs. the old POC 4 spec + +- **Status is per line item**, with a rollup summary at the order header (not order-level). +- **Color-up match key = `logo + style_number + color_code`, exact match only.** +- **Approval grain = the color-up proof**; approving cascades to every line item using it. +- **Repeat / exact-match lines skip _customer_ approval but pass a _production_ approval gate.** +- **Trim sheet = a Production Batch** — an on-demand view keyed on `logo + placement + thread choices`, approved-only, never crossing customers. +- **Internal Review = configurable gate** (customer default + order override). +- **New states:** `Awaiting Logo`, `Phone Call` (5 unanswered reminders), `Needs Approver`. +- **Satellite intake:** references the NetSuite SO; CSV/paste mass-import; logo auto-linked via the NetSuite file-cabinet logo id; DSTs added to an order *or* to the customer library. +- **Design data from the DST parse (POC 1), not Wilcom PDF parsing.** + +## State machine (per line item) + +``` +New ─► Awaiting Logo ─►[logo linked via file-cabinet id, or triage] + ├─ exact logo+style+color match ─► Production Approval ─► Production Ready ─► In Production ─► Complete + └─ no match ─► Color-Up In Progress ─►(opt) Internal Review ─► Needs Approver? ─► Sent for Approval + ├─ Approved (cascades) ─► Production Approval ─► … + ├─ 5 unanswered reminders ─► Phone Call + └─ Changes Requested (comment) ─► Revision In Progress (new immutable version) ─┘ +``` + +## Tech stack (default — bake-off scorecard is a Step 7 deliverable) + +- **Backend:** Python 3.10+ / FastAPI (consistent with POC 2 step 3). +- **Frontend:** Jinja2 + HTMX (server-rendered, no build step). +- **Storage:** SQLite for the POC (schema written migration-ready for PostgreSQL). +- **DST parse:** reuse `pocs/poc1_dst_renderer`. **Color-up editor:** reuse `pocs/poc3_color_up_editor` (DST/editor work is local-only — see cloud note). + +## Build steps + +### Step 1 — Data layer + state machine ← START HERE (cloud-friendly, no DST needed) +- SQLite schema: `customers`, `approvers`, `logos` (with `netsuite_file_cabinet_id`, + parsed DST fields, optional stabilizer/runtime), `color_ups` (immutable versions, + status, approved_by incl. team-proxy), `orders` (NetSuite SO ref, no status), + `order_line_items` (**per-line `status`**, style/color/size grid, logo+colorup FKs, + placement), `status_history` (actor, from/to, timestamp, team-proxy flag). +- State machine: all states above, transition validation, audit logging. +- Match-key logic (`logo + style_number + color_code`, exact) + approval **cascade**. +- Production-batch query: group ready line items by `logo + placement + thread-sequence`, + approved-only, within a single customer. +- CRUD + a thorough pure-Python test suite (no DST, no network → runs in cloud). + +### Step 2 — Intake + dashboard +- CSV/paste mass-import → orders + line items. +- Order entry form; NetSuite SO reference; file-cabinet-id → logo auto-link. +- `Awaiting Logo` triage queue; two DST entry points (to-order, to-library). +- Dashboard: per-line status badges + order-header rollup; filters/search. +- DST upload → parse via POC 1 *(DST-dependent: stub + skip in cloud)*. + +### Step 3 — Color-up editor integration *(POC 3 + DST dependent: local only)* +- Embed POC 3 editor; load order's logo/DST; wire save → state transitions. + +### Step 4 — Proof + approval page +- Hosted approval page; approver selects name from customer's approver list (identity B). +- Per-color-up Approve / Request-Changes (+ comment); approval cascade; immutable versions. +- `Needs Approver` inline-add popup. + +### Step 5 — Reminders + notifications +- Daily morning reminder digest per customer; cap 5 → `Phone Call` status. +- Customer response surfaces on assigned-artist **and** lead/manager dashboards. + +### Step 6 — Production batch + trim sheet +- On-demand batch view (operator works by Order #, decides grouping at the queue). +- Trim sheet generation (design + thread sequence + garment run); `Production Approval` + gate (incl. on zero-customer-touch repeats); one-click In Production / Complete. + +### Step 7 — Testing + evaluation +- All test scenarios; storage + framework bake-off scorecards; design recommendation. + +## Cloud-agent scope note + +The cloud sandbox has **no real DSTs and no POC 3 editor** (local-only by policy, see +`CLOUD_AGENTS.md`). A cloud run should implement **Step 1** in full and the data-light +parts of **Step 2** (CSV import, models, dashboard skeleton). All DST/editor integration +must be stubbed behind interfaces, and DST-dependent tests should **skip** with a clear +reason — that's expected, not a failure. From 2f677b934068a71d268b68a65a9d22115052ac10 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 20:25:43 +0000 Subject: [PATCH 2/4] poc4 step1: data layer + state machine (65 tests green) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQLite schema (migration-ready for PostgreSQL), dataclass models, CRUD, 12-state per-line-item state machine with full audit logging, match-key logic (logo+style+color), approval cascade, production-batch query. DST/editor integration stubbed behind DSTNotAvailableError. No network or DST files required — runs fully in the cloud. https://claude.ai/code/session_01Bkyax7szsqDeCuBygBk8M5 --- pocs/poc4_order_workflow/src/__init__.py | 1 + pocs/poc4_order_workflow/src/crud.py | 397 ++++++++++++++++++ pocs/poc4_order_workflow/src/database.py | 51 +++ pocs/poc4_order_workflow/src/dst_stub.py | 50 +++ pocs/poc4_order_workflow/src/match_key.py | 177 ++++++++ pocs/poc4_order_workflow/src/models.py | 177 ++++++++ pocs/poc4_order_workflow/src/production.py | 134 ++++++ pocs/poc4_order_workflow/src/schema.py | 145 +++++++ pocs/poc4_order_workflow/src/state_machine.py | 185 ++++++++ pocs/poc4_order_workflow/tests/__init__.py | 1 + pocs/poc4_order_workflow/tests/conftest.py | 114 +++++ pocs/poc4_order_workflow/tests/test_crud.py | 391 +++++++++++++++++ .../tests/test_dst_stub.py | 31 ++ .../tests/test_match_key.py | 349 +++++++++++++++ .../tests/test_production.py | 243 +++++++++++ .../tests/test_state_machine.py | 223 ++++++++++ 16 files changed, 2669 insertions(+) create mode 100644 pocs/poc4_order_workflow/src/__init__.py create mode 100644 pocs/poc4_order_workflow/src/crud.py create mode 100644 pocs/poc4_order_workflow/src/database.py create mode 100644 pocs/poc4_order_workflow/src/dst_stub.py create mode 100644 pocs/poc4_order_workflow/src/match_key.py create mode 100644 pocs/poc4_order_workflow/src/models.py create mode 100644 pocs/poc4_order_workflow/src/production.py create mode 100644 pocs/poc4_order_workflow/src/schema.py create mode 100644 pocs/poc4_order_workflow/src/state_machine.py create mode 100644 pocs/poc4_order_workflow/tests/__init__.py create mode 100644 pocs/poc4_order_workflow/tests/conftest.py create mode 100644 pocs/poc4_order_workflow/tests/test_crud.py create mode 100644 pocs/poc4_order_workflow/tests/test_dst_stub.py create mode 100644 pocs/poc4_order_workflow/tests/test_match_key.py create mode 100644 pocs/poc4_order_workflow/tests/test_production.py create mode 100644 pocs/poc4_order_workflow/tests/test_state_machine.py diff --git a/pocs/poc4_order_workflow/src/__init__.py b/pocs/poc4_order_workflow/src/__init__.py new file mode 100644 index 0000000..27a9d3d --- /dev/null +++ b/pocs/poc4_order_workflow/src/__init__.py @@ -0,0 +1 @@ +# POC 4: Order-to-Approval Workflow — Step 1 data layer + state machine diff --git a/pocs/poc4_order_workflow/src/crud.py b/pocs/poc4_order_workflow/src/crud.py new file mode 100644 index 0000000..7ec2b22 --- /dev/null +++ b/pocs/poc4_order_workflow/src/crud.py @@ -0,0 +1,397 @@ +"""CRUD operations for all POC 4 entities. + +Each function takes an open sqlite3.Connection and a dataclass instance, +and returns the dataclass with its auto-assigned primary key filled in. +Read functions return sqlite3.Row (dict-like) to avoid double-mapping +boilerplate; callers that need a typed object can reconstruct from it. +""" + +from __future__ import annotations + +import json +import sqlite3 + +from pocs.poc4_order_workflow.src.models import ( + Approver, + ColorUp, + Customer, + Logo, + Order, + OrderLineItem, + StatusHistory, +) + +# --------------------------------------------------------------------------- +# Customers +# --------------------------------------------------------------------------- + + +def create_customer(conn: sqlite3.Connection, c: Customer) -> Customer: + cur = conn.execute( + """ + INSERT INTO customers + (customer_name, netsuite_account_id, address, notes, + internal_review_default, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + c.customer_name, + c.netsuite_account_id, + c.address, + c.notes, + c.internal_review_default, + c.created_at, + ), + ) + conn.commit() + c.customer_id = cur.lastrowid # type: ignore[assignment] + return c + + +def get_customer(conn: sqlite3.Connection, customer_id: int) -> sqlite3.Row | None: + return conn.execute("SELECT * FROM customers WHERE customer_id = ?", (customer_id,)).fetchone() + + +def list_customers(conn: sqlite3.Connection) -> list[sqlite3.Row]: + return conn.execute("SELECT * FROM customers ORDER BY customer_name").fetchall() + + +# --------------------------------------------------------------------------- +# Approvers +# --------------------------------------------------------------------------- + + +def create_approver(conn: sqlite3.Connection, a: Approver) -> Approver: + cur = conn.execute( + """ + INSERT INTO approvers + (customer_id, name, email, phone, is_primary, active) + VALUES (?, ?, ?, ?, ?, ?) + """, + (a.customer_id, a.name, a.email, a.phone, a.is_primary, a.active), + ) + conn.commit() + a.approver_id = cur.lastrowid # type: ignore[assignment] + return a + + +def list_approvers(conn: sqlite3.Connection, customer_id: int) -> list[sqlite3.Row]: + return conn.execute( + "SELECT * FROM approvers WHERE customer_id = ? AND active = TRUE ORDER BY is_primary DESC", + (customer_id,), + ).fetchall() + + +# --------------------------------------------------------------------------- +# Logos +# --------------------------------------------------------------------------- + + +def create_logo(conn: sqlite3.Connection, logo: Logo) -> Logo: + cur = conn.execute( + """ + INSERT INTO logos + (customer_id, logo_name, design_number, netsuite_file_cabinet_id, + dst_stitch_count, dst_color_count, dst_width_mm, dst_height_mm, + dst_stop_count, dst_trim_count, stabilizer_topping, stabilizer_backing, + machine_runtime_seconds, placement_default, notes, active, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + logo.customer_id, + logo.logo_name, + logo.design_number, + logo.netsuite_file_cabinet_id, + logo.dst_stitch_count, + logo.dst_color_count, + logo.dst_width_mm, + logo.dst_height_mm, + logo.dst_stop_count, + logo.dst_trim_count, + logo.stabilizer_topping, + logo.stabilizer_backing, + logo.machine_runtime_seconds, + logo.placement_default, + logo.notes, + logo.active, + logo.created_at, + ), + ) + conn.commit() + logo.logo_id = cur.lastrowid # type: ignore[assignment] + return logo + + +def get_logo(conn: sqlite3.Connection, logo_id: int) -> sqlite3.Row | None: + return conn.execute("SELECT * FROM logos WHERE logo_id = ?", (logo_id,)).fetchone() + + +def find_logo_by_file_cabinet_id( + conn: sqlite3.Connection, customer_id: int, netsuite_file_cabinet_id: str +) -> sqlite3.Row | None: + return conn.execute( + """ + SELECT * FROM logos + WHERE customer_id = ? AND netsuite_file_cabinet_id = ? AND active = TRUE + """, + (customer_id, netsuite_file_cabinet_id), + ).fetchone() + + +def update_logo_dst_fields(conn: sqlite3.Connection, logo_id: int, **kwargs: object) -> None: + """Update DST-derived fields on a logo (called when POC 1 parse completes).""" + allowed = { + "dst_stitch_count", + "dst_color_count", + "dst_width_mm", + "dst_height_mm", + "dst_stop_count", + "dst_trim_count", + } + fields = {k: v for k, v in kwargs.items() if k in allowed} + if not fields: + return + set_clause = ", ".join(f"{k} = ?" for k in fields) + conn.execute( + f"UPDATE logos SET {set_clause} WHERE logo_id = ?", # noqa: S608 + (*fields.values(), logo_id), + ) + conn.commit() + + +# --------------------------------------------------------------------------- +# Color-Ups +# --------------------------------------------------------------------------- + + +def create_color_up(conn: sqlite3.Connection, cu: ColorUp) -> ColorUp: + cur = conn.execute( + """ + INSERT INTO color_ups + (logo_id, version, style_number, color_code, thread_sequence, + status, approved_by_id, approved_by_team_proxy, approved_at, + notes, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + cu.logo_id, + cu.version, + cu.style_number, + cu.color_code, + cu.thread_sequence_json(), + cu.status, + cu.approved_by_id, + cu.approved_by_team_proxy, + cu.approved_at, + cu.notes, + cu.created_at, + ), + ) + conn.commit() + cu.colorup_id = cur.lastrowid # type: ignore[assignment] + return cu + + +def get_color_up(conn: sqlite3.Connection, colorup_id: int) -> sqlite3.Row | None: + return conn.execute("SELECT * FROM color_ups WHERE colorup_id = ?", (colorup_id,)).fetchone() + + +def next_version(conn: sqlite3.Connection, logo_id: int, style_number: str, color_code: str) -> int: + """Return the next version number for this match key.""" + row = conn.execute( + """ + SELECT MAX(version) AS max_v FROM color_ups + WHERE logo_id = ? AND style_number = ? AND color_code = ? + """, + (logo_id, style_number, color_code), + ).fetchone() + return (row["max_v"] or 0) + 1 + + +def approve_color_up( + conn: sqlite3.Connection, + colorup_id: int, + *, + approved_by_id: int | None = None, + team_proxy: bool = False, + approved_at: str | None = None, +) -> None: + """Mark a color-up Approved; used by the cascade in match_key module.""" + from datetime import UTC, datetime + + ts = approved_at or datetime.now(UTC).isoformat(timespec="seconds") + conn.execute( + """ + UPDATE color_ups + SET status = 'Approved', + approved_by_id = ?, + approved_by_team_proxy = ?, + approved_at = ? + WHERE colorup_id = ? + """, + (approved_by_id, team_proxy, ts, colorup_id), + ) + conn.commit() + + +# --------------------------------------------------------------------------- +# Orders +# --------------------------------------------------------------------------- + + +def create_order(conn: sqlite3.Connection, order: Order) -> Order: + cur = conn.execute( + """ + INSERT INTO orders + (customer_id, netsuite_so_number, customer_po, priority, + assigned_to, order_date, due_date, internal_review_override, + special_instructions, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + order.customer_id, + order.netsuite_so_number, + order.customer_po, + order.priority, + order.assigned_to, + order.order_date, + order.due_date, + order.internal_review_override, + order.special_instructions, + order.created_at, + ), + ) + conn.commit() + order.order_id = cur.lastrowid # type: ignore[assignment] + return order + + +def get_order(conn: sqlite3.Connection, order_id: int) -> sqlite3.Row | None: + return conn.execute("SELECT * FROM orders WHERE order_id = ?", (order_id,)).fetchone() + + +def order_status_rollup(conn: sqlite3.Connection, order_id: int) -> dict[str, int]: + """Return {status: count} for all line items in the order.""" + rows = conn.execute( + """ + SELECT status, COUNT(*) AS cnt + FROM order_line_items + WHERE order_id = ? + GROUP BY status + """, + (order_id,), + ).fetchall() + return {r["status"]: r["cnt"] for r in rows} + + +# --------------------------------------------------------------------------- +# Order Line Items +# --------------------------------------------------------------------------- + + +def create_line_item(conn: sqlite3.Connection, li: OrderLineItem) -> OrderLineItem: + cur = conn.execute( + """ + INSERT INTO order_line_items + (order_id, style_number, style_name, color_code, size_quantities, + logo_id, colorup_id, placement, status, + send_for_approval_override, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + li.order_id, + li.style_number, + li.style_name, + li.color_code, + li.size_quantities_json(), + li.logo_id, + li.colorup_id, + li.placement, + li.status, + li.send_for_approval_override, + li.created_at, + ), + ) + conn.commit() + li.line_item_id = cur.lastrowid # type: ignore[assignment] + return li + + +def get_line_item(conn: sqlite3.Connection, line_item_id: int) -> sqlite3.Row | None: + return conn.execute( + "SELECT * FROM order_line_items WHERE line_item_id = ?", (line_item_id,) + ).fetchone() + + +def list_line_items(conn: sqlite3.Connection, order_id: int) -> list[sqlite3.Row]: + return conn.execute( + "SELECT * FROM order_line_items WHERE order_id = ? ORDER BY line_item_id", + (order_id,), + ).fetchall() + + +def set_line_item_logo(conn: sqlite3.Connection, line_item_id: int, logo_id: int) -> None: + conn.execute( + "UPDATE order_line_items SET logo_id = ? WHERE line_item_id = ?", + (logo_id, line_item_id), + ) + conn.commit() + + +def set_line_item_colorup(conn: sqlite3.Connection, line_item_id: int, colorup_id: int) -> None: + conn.execute( + "UPDATE order_line_items SET colorup_id = ? WHERE line_item_id = ?", + (colorup_id, line_item_id), + ) + conn.commit() + + +# --------------------------------------------------------------------------- +# Status History +# --------------------------------------------------------------------------- + + +def create_history_entry(conn: sqlite3.Connection, h: StatusHistory) -> StatusHistory: + cur = conn.execute( + """ + INSERT INTO status_history + (line_item_id, actor, from_status, to_status, team_proxy, notes, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + h.line_item_id, + h.actor, + h.from_status, + h.to_status, + h.team_proxy, + h.notes, + h.timestamp, + ), + ) + conn.commit() + h.history_id = cur.lastrowid # type: ignore[assignment] + return h + + +def get_history(conn: sqlite3.Connection, line_item_id: int) -> list[sqlite3.Row]: + return conn.execute( + """ + SELECT * FROM status_history + WHERE line_item_id = ? + ORDER BY timestamp ASC, history_id ASC + """, + (line_item_id,), + ).fetchall() + + +# --------------------------------------------------------------------------- +# Helper: deserialise JSON columns +# --------------------------------------------------------------------------- + + +def decode_size_quantities(row: sqlite3.Row) -> dict[str, int]: + return json.loads(row["size_quantities"]) + + +def decode_thread_sequence(row: sqlite3.Row) -> list[dict]: + return json.loads(row["thread_sequence"]) diff --git a/pocs/poc4_order_workflow/src/database.py b/pocs/poc4_order_workflow/src/database.py new file mode 100644 index 0000000..96d36f5 --- /dev/null +++ b/pocs/poc4_order_workflow/src/database.py @@ -0,0 +1,51 @@ +"""SQLite database connection and schema bootstrap. + +Usage +----- + from pocs.poc4_order_workflow.src.database import open_db + + with open_db(":memory:") as db: + ... # db is a sqlite3.Connection with FK enforcement on + +In production (FastAPI), open a single connection per request (or use a +thread-local connection pool). When migrating to PostgreSQL, replace +this module with a psycopg2 / asyncpg connection factory — the SQL in +crud.py uses only standard SQL dialect. +""" + +from __future__ import annotations + +import sqlite3 +from collections.abc import Generator +from contextlib import contextmanager +from pathlib import Path + +from pocs.poc4_order_workflow.src.schema import ALL_DDL + + +def _configure(conn: sqlite3.Connection) -> None: + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("PRAGMA journal_mode = WAL") + conn.row_factory = sqlite3.Row + + +def create_tables(conn: sqlite3.Connection) -> None: + for ddl in ALL_DDL: + conn.execute(ddl) + conn.commit() + + +def open_connection(path: str | Path = ":memory:") -> sqlite3.Connection: + conn = sqlite3.connect(str(path)) + _configure(conn) + create_tables(conn) + return conn + + +@contextmanager +def open_db(path: str | Path = ":memory:") -> Generator[sqlite3.Connection, None, None]: + conn = open_connection(path) + try: + yield conn + finally: + conn.close() diff --git a/pocs/poc4_order_workflow/src/dst_stub.py b/pocs/poc4_order_workflow/src/dst_stub.py new file mode 100644 index 0000000..cd3ec02 --- /dev/null +++ b/pocs/poc4_order_workflow/src/dst_stub.py @@ -0,0 +1,50 @@ +"""Stub interface for DST parse integration (POC 1) and color-up editor (POC 3). + +These integrations are local-only — DST files and the POC 3 editor are not +available in the cloud sandbox. All functions here raise NotImplementedError +so that any accidental call fails loudly rather than silently. + +To wire up real DST parsing, replace the bodies of these functions with calls +to pocs.poc1_dst_renderer (once POC 1 is importable in the runtime environment). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + + +class DSTNotAvailableError(NotImplementedError): + """Raised in the cloud sandbox when DST parsing is attempted.""" + + +def parse_dst(dst_path: Path) -> dict[str, Any]: + """Parse a DST file and return structured design metadata. + + Stub — not implemented in the cloud. In production this delegates to + pocs.poc1_dst_renderer. + + Expected return shape:: + + { + "stitch_count": int, + "color_count": int, + "width_mm": float, + "height_mm": float, + "stop_count": int, + "trim_count": int, + "stops": [{"stop": int, "stitch_count": int}, ...], + } + """ + raise DSTNotAvailableError( + "DST parsing is not available in the cloud sandbox. " + "Wire up pocs.poc1_dst_renderer locally." + ) + + +def open_color_up_editor(logo_id: int, colorup_id: int | None = None) -> None: + """Open the POC 3 color-up editor for the given logo. + + Stub — not implemented in the cloud. + """ + raise DSTNotAvailableError("The color-up editor (POC 3) is not available in the cloud sandbox.") diff --git a/pocs/poc4_order_workflow/src/match_key.py b/pocs/poc4_order_workflow/src/match_key.py new file mode 100644 index 0000000..022cbd0 --- /dev/null +++ b/pocs/poc4_order_workflow/src/match_key.py @@ -0,0 +1,177 @@ +"""Match-key logic and approval cascade. + +Match key: (logo_id, style_number, color_code) — EXACT match only. + +A color code (e.g. MAL for Malbec) is not physically consistent across +garment styles, so all three fields must match to reuse an approved color-up. + +Approval cascade +---------------- +When a color-up is approved, every line item that references it and is in +an approval-pending state advances to Production Ready. The cascade also +handles the "send_for_approval_override" flag: a line item that reached +Production Ready via an exact match will still respect the production gate. +""" + +from __future__ import annotations + +import sqlite3 + +from pocs.poc4_order_workflow.src.crud import approve_color_up +from pocs.poc4_order_workflow.src.state_machine import Status, transition + + +def find_approved_color_up( + conn: sqlite3.Connection, + logo_id: int, + style_number: str, + color_code: str, +) -> sqlite3.Row | None: + """Return the most recent Approved color-up for this match key, or None.""" + return conn.execute( + """ + SELECT * FROM color_ups + WHERE logo_id = ? + AND style_number = ? + AND color_code = ? + AND status = 'Approved' + ORDER BY version DESC + LIMIT 1 + """, + (logo_id, style_number, color_code), + ).fetchone() + + +def find_any_color_up( + conn: sqlite3.Connection, + logo_id: int, + style_number: str, + color_code: str, +) -> sqlite3.Row | None: + """Return the most recent color-up (any status) for this match key, or None.""" + return conn.execute( + """ + SELECT * FROM color_ups + WHERE logo_id = ? + AND style_number = ? + AND color_code = ? + ORDER BY version DESC + LIMIT 1 + """, + (logo_id, style_number, color_code), + ).fetchone() + + +# Status values that the cascade should advance when a color-up is approved. +_CASCADE_FROM_STATUSES = { + Status.SENT_FOR_APPROVAL.value, + Status.PHONE_CALL.value, + Status.NEEDS_APPROVER.value, +} + + +def cascade_approval( + conn: sqlite3.Connection, + colorup_id: int, + actor: str, + *, + approved_by_id: int | None = None, + team_proxy: bool = False, + approved_at: str | None = None, + notes: str | None = None, +) -> list[int]: + """Mark the color-up Approved and advance all qualifying line items. + + Line items that reference *colorup_id* and are in Sent for Approval, + Phone Call, or Needs Approver are moved to Production Ready. + + Returns the list of line_item_ids that were advanced. + """ + approve_color_up( + conn, + colorup_id, + approved_by_id=approved_by_id, + team_proxy=team_proxy, + approved_at=approved_at, + ) + + # Find all line items referencing this color-up that need advancing. + placeholders = ", ".join("?" * len(_CASCADE_FROM_STATUSES)) + rows = conn.execute( + f""" + SELECT line_item_id, status FROM order_line_items + WHERE colorup_id = ? + AND status IN ({placeholders}) + """, # noqa: S608 + (colorup_id, *_CASCADE_FROM_STATUSES), + ).fetchall() + + advanced: list[int] = [] + for row in rows: + lid = row["line_item_id"] + # Each item passes through Approved → Production Ready + transition(conn, lid, Status.APPROVED, actor, notes=notes, team_proxy=team_proxy) + transition(conn, lid, Status.PRODUCTION_READY, actor, notes="Production gate sign-off") + advanced.append(lid) + + return advanced + + +def resolve_logo_for_line_item( + conn: sqlite3.Connection, + line_item_id: int, + logo_id: int, + actor: str, + *, + force_approval: bool = False, +) -> str: + """Link *logo_id* to a line item and determine its next status. + + Business rules: + 1. Line item must currently be in Awaiting Logo. + 2. If an approved color-up exists for the match key AND + send_for_approval_override is False AND force_approval is False: + → advance to Production Ready (exact-match fast path). + 3. Otherwise → advance to Color-Up In Progress. + + Returns the resulting status string. + """ + row = conn.execute( + """ + SELECT li.status, li.style_number, li.color_code, + li.send_for_approval_override + FROM order_line_items li + WHERE li.line_item_id = ? + """, + (line_item_id,), + ).fetchone() + + if row is None: + raise ValueError(f"Line item {line_item_id} not found") + if row["status"] != Status.AWAITING_LOGO.value: + raise ValueError( + f"Line item {line_item_id} is in '{row['status']}', expected 'Awaiting Logo'" + ) + + # Link the logo + conn.execute( + "UPDATE order_line_items SET logo_id = ? WHERE line_item_id = ?", + (logo_id, line_item_id), + ) + conn.commit() + + override = bool(row["send_for_approval_override"]) or force_approval + match = find_approved_color_up(conn, logo_id, row["style_number"], row["color_code"]) + + if match and not override: + # Exact match — link the color-up and skip to Production Ready + conn.execute( + "UPDATE order_line_items SET colorup_id = ? WHERE line_item_id = ?", + (match["colorup_id"], line_item_id), + ) + conn.commit() + transition(conn, line_item_id, Status.PRODUCTION_READY, actor, notes="Exact match") + return Status.PRODUCTION_READY.value + else: + transition(conn, line_item_id, Status.COLOR_UP_IN_PROGRESS, actor, notes="No exact match") + return Status.COLOR_UP_IN_PROGRESS.value diff --git a/pocs/poc4_order_workflow/src/models.py b/pocs/poc4_order_workflow/src/models.py new file mode 100644 index 0000000..d4bf1cd --- /dev/null +++ b/pocs/poc4_order_workflow/src/models.py @@ -0,0 +1,177 @@ +"""Dataclasses for all POC 4 domain entities. + +All IDs are int (SQLite ROWID / PostgreSQL SERIAL). Timestamps are +stored as ISO-8601 strings so they survive JSON serialisation and +sqlite3's default text affinity. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any + + +def _now() -> str: + return datetime.now(UTC).isoformat(timespec="seconds") + + +# --------------------------------------------------------------------------- +# Customers & Approvers +# --------------------------------------------------------------------------- + + +@dataclass +class Customer: + customer_name: str + customer_id: int = 0 + netsuite_account_id: str | None = None + address: str | None = None + notes: str | None = None + # Internal-review gate is on by default for NEW customers; order overrides per-order. + internal_review_default: bool = False + created_at: str = field(default_factory=_now) + + +@dataclass +class Approver: + customer_id: int + name: str + email: str + approver_id: int = 0 + phone: str | None = None + is_primary: bool = False + active: bool = True + + +# --------------------------------------------------------------------------- +# Logos (design files owned by a customer) +# --------------------------------------------------------------------------- + + +@dataclass +class Logo: + customer_id: int + logo_name: str + logo_id: int = 0 + design_number: str | None = None + # External key used by the NetSuite file cabinet; unknown → Awaiting Logo triage + netsuite_file_cabinet_id: str | None = None + # DST parse fields — populated by POC 1 integration (stubbed here) + dst_stitch_count: int | None = None + dst_color_count: int | None = None + dst_width_mm: float | None = None + dst_height_mm: float | None = None + dst_stop_count: int | None = None + dst_trim_count: int | None = None + # Optional manual/parsed production fields + stabilizer_topping: str | None = None + stabilizer_backing: str | None = None + machine_runtime_seconds: int | None = None + placement_default: str | None = None + notes: str | None = None + active: bool = True + created_at: str = field(default_factory=_now) + + +# --------------------------------------------------------------------------- +# Color-Ups (immutable versions per logo × style × color) +# --------------------------------------------------------------------------- + + +@dataclass +class ColorUp: + """An immutable, versioned color configuration for a logo on a specific garment. + + Match key = (logo_id, style_number, color_code). Each approval round + creates a NEW version rather than mutating the existing record. + """ + + logo_id: int + style_number: str + color_code: str + # thread_sequence: list of dicts — [{stop: int, needle: int, thread_code: str, + # thread_name: str, brand: str}, ...] + thread_sequence: list[dict[str, Any]] + colorup_id: int = 0 + version: int = 1 + # Draft | Pending Approval | Approved | Retired + status: str = "Draft" + approved_by_id: int | None = None + approved_by_team_proxy: bool = False + approved_at: str | None = None + notes: str | None = None + created_at: str = field(default_factory=_now) + + def thread_sequence_json(self) -> str: + return json.dumps(self.thread_sequence, sort_keys=True) + + +# --------------------------------------------------------------------------- +# Orders (NetSuite SO reference; NO status field — status lives on line items) +# --------------------------------------------------------------------------- + + +@dataclass +class Order: + customer_id: int + order_date: str + order_id: int = 0 + netsuite_so_number: str | None = None + customer_po: str | None = None + # Normal | Rush + priority: str = "Normal" + assigned_to: str | None = None + due_date: str | None = None + # None → use customer.internal_review_default; True/False → override + internal_review_override: bool | None = None + special_instructions: str | None = None + created_at: str = field(default_factory=_now) + + +# --------------------------------------------------------------------------- +# Order Line Items (status lives HERE, per line) +# --------------------------------------------------------------------------- + + +@dataclass +class OrderLineItem: + order_id: int + style_number: str + color_code: str + placement: str + line_item_id: int = 0 + style_name: str | None = None + # size_quantities: {"S": 12, "M": 24, ...} + size_quantities: dict[str, int] = field(default_factory=dict) + logo_id: int | None = None + colorup_id: int | None = None + status: str = "New" + # Force an approval round even when an exact match exists + send_for_approval_override: bool = False + created_at: str = field(default_factory=_now) + + @property + def total_units(self) -> int: + return sum(self.size_quantities.values()) + + def size_quantities_json(self) -> str: + return json.dumps(self.size_quantities) + + +# --------------------------------------------------------------------------- +# Status History (immutable audit log) +# --------------------------------------------------------------------------- + + +@dataclass +class StatusHistory: + line_item_id: int + actor: str + to_status: str + history_id: int = 0 + from_status: str | None = None + team_proxy: bool = False + notes: str | None = None + timestamp: str = field(default_factory=_now) diff --git a/pocs/poc4_order_workflow/src/production.py b/pocs/poc4_order_workflow/src/production.py new file mode 100644 index 0000000..083493d --- /dev/null +++ b/pocs/poc4_order_workflow/src/production.py @@ -0,0 +1,134 @@ +"""Production-batch query. + +A Production Batch is an on-demand view (not a persisted object) that +groups Production Ready line items by the key: + + (logo_id, placement, thread_sequence_canonical) + +Rules: +- Only Approved color-ups (status = 'Approved'). +- Only Production Ready line items. +- NEVER crosses customers — each batch belongs to exactly one customer. + +The thread_sequence_canonical key is a deterministic JSON serialisation of +the color-up's thread_sequence, so two color-ups with identical stops in +identical order (same logo, style, color) hash to the same bucket. + +This module has NO DST or network dependencies and runs in the cloud. +""" + +from __future__ import annotations + +import json +import sqlite3 +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class ProductionBatch: + customer_id: int + customer_name: str + logo_id: int + logo_name: str + placement: str + thread_sequence: list[dict[str, Any]] + line_items: list[dict[str, Any]] = field(default_factory=list) + + @property + def total_units(self) -> int: + return sum(li.get("total_units", 0) for li in self.line_items) + + @property + def colorup_ids(self) -> list[int]: + return list({li["colorup_id"] for li in self.line_items if li.get("colorup_id")}) + + +def _canonical_thread_key(thread_sequence_json: str) -> str: + """Normalise the thread sequence JSON for stable grouping. + + Re-serialise with sorted keys so insertion-order differences in the + original JSON don't produce spurious group splits. + """ + seq = json.loads(thread_sequence_json) + return json.dumps(seq, sort_keys=True, separators=(",", ":")) + + +def get_production_batches( + conn: sqlite3.Connection, + customer_id: int | None = None, +) -> list[ProductionBatch]: + """Return production batches, optionally filtered to one customer. + + Each batch groups Production Ready line items that share the same + (logo, placement, thread sequence) within a single customer. + """ + params: list[Any] = [] + customer_filter = "" + if customer_id is not None: + customer_filter = "AND o.customer_id = ?" + params.append(customer_id) + + rows = conn.execute( + f""" + SELECT + li.line_item_id, + li.order_id, + li.style_number, + li.style_name, + li.color_code, + li.size_quantities, + li.colorup_id, + li.placement, + li.logo_id, + o.customer_id, + c.customer_name, + lg.logo_name, + cu.thread_sequence + FROM order_line_items li + JOIN orders o ON o.order_id = li.order_id + JOIN customers c ON c.customer_id = o.customer_id + JOIN logos lg ON lg.logo_id = li.logo_id + JOIN color_ups cu ON cu.colorup_id = li.colorup_id + WHERE li.status = 'Production Ready' + AND cu.status = 'Approved' + {customer_filter} + ORDER BY o.customer_id, li.logo_id, li.placement + """, # noqa: S608 + params, + ).fetchall() + + # Group into batches by (customer_id, logo_id, placement, thread_key) + batches: dict[tuple, ProductionBatch] = {} + for row in rows: + sq_raw = row["size_quantities"] + sq = json.loads(sq_raw) if sq_raw else {} + total_units = sum(sq.values()) + + thread_key = _canonical_thread_key(row["thread_sequence"]) + group_key = (row["customer_id"], row["logo_id"], row["placement"], thread_key) + + if group_key not in batches: + batches[group_key] = ProductionBatch( + customer_id=row["customer_id"], + customer_name=row["customer_name"], + logo_id=row["logo_id"], + logo_name=row["logo_name"], + placement=row["placement"], + thread_sequence=json.loads(row["thread_sequence"]), + ) + + batches[group_key].line_items.append( + { + "line_item_id": row["line_item_id"], + "order_id": row["order_id"], + "style_number": row["style_number"], + "style_name": row["style_name"], + "color_code": row["color_code"], + "size_quantities": sq, + "total_units": total_units, + "colorup_id": row["colorup_id"], + } + ) + + return list(batches.values()) diff --git a/pocs/poc4_order_workflow/src/schema.py b/pocs/poc4_order_workflow/src/schema.py new file mode 100644 index 0000000..98184ff --- /dev/null +++ b/pocs/poc4_order_workflow/src/schema.py @@ -0,0 +1,145 @@ +"""SQL DDL for POC 4. + +Written for SQLite but migration-ready for PostgreSQL: +- INTEGER PRIMARY KEY → SERIAL PRIMARY KEY (or BIGSERIAL) +- BOOLEAN → BOOLEAN (both support it) +- JSON → JSONB +- TIMESTAMP → TIMESTAMP WITH TIME ZONE +- DEFAULT CURRENT_TIMESTAMP → DEFAULT NOW() +- REAL → DOUBLE PRECISION +Constraints, FK references, and UNIQUE declarations are identical. +""" + +from __future__ import annotations + +# One DDL string per table — ordered so FK targets come before FK sources. + +CUSTOMERS_DDL = """ +CREATE TABLE IF NOT EXISTS customers ( + customer_id INTEGER PRIMARY KEY, -- PostgreSQL: SERIAL + customer_name TEXT NOT NULL, + netsuite_account_id TEXT, + address TEXT, + notes TEXT, + internal_review_default BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +) +""" + +APPROVERS_DDL = """ +CREATE TABLE IF NOT EXISTS approvers ( + approver_id INTEGER PRIMARY KEY, + customer_id INTEGER NOT NULL REFERENCES customers(customer_id) ON DELETE CASCADE, + name TEXT NOT NULL, + email TEXT NOT NULL, + phone TEXT, + is_primary BOOLEAN NOT NULL DEFAULT FALSE, + active BOOLEAN NOT NULL DEFAULT TRUE +) +""" + +LOGOS_DDL = """ +CREATE TABLE IF NOT EXISTS logos ( + logo_id INTEGER PRIMARY KEY, + customer_id INTEGER NOT NULL REFERENCES customers(customer_id) ON DELETE CASCADE, + logo_name TEXT NOT NULL, + design_number TEXT, + netsuite_file_cabinet_id TEXT, + -- DST parse fields (NULL until POC 1 integration is wired in) + dst_stitch_count INTEGER, + dst_color_count INTEGER, + dst_width_mm REAL, -- PostgreSQL: DOUBLE PRECISION + dst_height_mm REAL, + dst_stop_count INTEGER, + dst_trim_count INTEGER, + -- Optional manual / parsed production fields + stabilizer_topping TEXT, + stabilizer_backing TEXT, + machine_runtime_seconds INTEGER, + placement_default TEXT, + notes TEXT, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +) +""" + +COLOR_UPS_DDL = """ +CREATE TABLE IF NOT EXISTS color_ups ( + colorup_id INTEGER PRIMARY KEY, + logo_id INTEGER NOT NULL REFERENCES logos(logo_id) ON DELETE CASCADE, + version INTEGER NOT NULL DEFAULT 1, + -- Match-key components (logo_id + style_number + color_code = exact match key) + style_number TEXT NOT NULL, + color_code TEXT NOT NULL, + -- Ordered stop list: [{stop, needle, thread_code, thread_name, brand}, ...] + thread_sequence TEXT NOT NULL DEFAULT '[]', -- PostgreSQL: JSONB + -- Draft | Pending Approval | Approved | Retired + status TEXT NOT NULL DEFAULT 'Draft', + approved_by_id INTEGER REFERENCES approvers(approver_id), + approved_by_team_proxy BOOLEAN NOT NULL DEFAULT FALSE, + approved_at TIMESTAMP, + notes TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- Immutability: each (logo, style, color, version) is unique + UNIQUE (logo_id, style_number, color_code, version) +) +""" + +ORDERS_DDL = """ +CREATE TABLE IF NOT EXISTS orders ( + order_id INTEGER PRIMARY KEY, + customer_id INTEGER NOT NULL REFERENCES customers(customer_id), + netsuite_so_number TEXT, + customer_po TEXT, + priority TEXT NOT NULL DEFAULT 'Normal', -- Normal | Rush + assigned_to TEXT, + order_date TEXT NOT NULL, -- ISO-8601 date (DATE in PostgreSQL) + due_date TEXT, + -- NULL → inherit customer.internal_review_default; TRUE/FALSE → order-level override + internal_review_override BOOLEAN, + special_instructions TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + -- NOTE: deliberately NO status column — status lives on order_line_items +) +""" + +ORDER_LINE_ITEMS_DDL = """ +CREATE TABLE IF NOT EXISTS order_line_items ( + line_item_id INTEGER PRIMARY KEY, + order_id INTEGER NOT NULL REFERENCES orders(order_id) ON DELETE CASCADE, + style_number TEXT NOT NULL, + style_name TEXT, + color_code TEXT NOT NULL, + size_quantities TEXT NOT NULL DEFAULT '{}', -- PostgreSQL: JSONB + logo_id INTEGER REFERENCES logos(logo_id), + colorup_id INTEGER REFERENCES color_ups(colorup_id), + placement TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'New', + -- Force approval even when an exact match exists + send_for_approval_override BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +) +""" + +STATUS_HISTORY_DDL = """ +CREATE TABLE IF NOT EXISTS status_history ( + history_id INTEGER PRIMARY KEY, + line_item_id INTEGER NOT NULL REFERENCES order_line_items(line_item_id) ON DELETE CASCADE, + actor TEXT NOT NULL, + from_status TEXT, + to_status TEXT NOT NULL, + team_proxy BOOLEAN NOT NULL DEFAULT FALSE, + notes TEXT, + timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +) +""" + +ALL_DDL: list[str] = [ + CUSTOMERS_DDL, + APPROVERS_DDL, + LOGOS_DDL, + COLOR_UPS_DDL, + ORDERS_DDL, + ORDER_LINE_ITEMS_DDL, + STATUS_HISTORY_DDL, +] diff --git a/pocs/poc4_order_workflow/src/state_machine.py b/pocs/poc4_order_workflow/src/state_machine.py new file mode 100644 index 0000000..6f56f7a --- /dev/null +++ b/pocs/poc4_order_workflow/src/state_machine.py @@ -0,0 +1,185 @@ +"""Per-line-item state machine for the order workflow. + +States (12, per the NEXT.md build plan and task brief): + New, Awaiting Logo, Color-Up In Progress, Internal Review, + Needs Approver, Sent for Approval, Phone Call, Approved, + Revision In Progress, Production Ready, In Production, Complete + +Every transition is validated and logged to status_history. +""" + +from __future__ import annotations + +import sqlite3 +from datetime import UTC, datetime +from enum import Enum + + +class Status(str, Enum): + NEW = "New" + AWAITING_LOGO = "Awaiting Logo" + COLOR_UP_IN_PROGRESS = "Color-Up In Progress" + INTERNAL_REVIEW = "Internal Review" + NEEDS_APPROVER = "Needs Approver" + SENT_FOR_APPROVAL = "Sent for Approval" + PHONE_CALL = "Phone Call" + APPROVED = "Approved" + REVISION_IN_PROGRESS = "Revision In Progress" + PRODUCTION_READY = "Production Ready" + IN_PRODUCTION = "In Production" + COMPLETE = "Complete" + + +# Valid outgoing transitions per state. +# Transitions are intentionally explicit rather than inferred — this is the +# single source of truth for the workflow rules. +VALID_TRANSITIONS: dict[Status, set[Status]] = { + Status.NEW: {Status.AWAITING_LOGO}, + Status.AWAITING_LOGO: { + Status.COLOR_UP_IN_PROGRESS, # no exact match found + Status.PRODUCTION_READY, # exact match exists (skips customer approval) + }, + Status.COLOR_UP_IN_PROGRESS: { + Status.INTERNAL_REVIEW, # internal-review gate is on + Status.NEEDS_APPROVER, # gate off AND no approver on file + Status.SENT_FOR_APPROVAL, # gate off AND approver exists + }, + Status.INTERNAL_REVIEW: { + Status.NEEDS_APPROVER, # no approver on file + Status.SENT_FOR_APPROVAL, # approver exists + Status.COLOR_UP_IN_PROGRESS, # reviewer sends back for rework + }, + Status.NEEDS_APPROVER: { + Status.SENT_FOR_APPROVAL, # approver has been added + }, + Status.SENT_FOR_APPROVAL: { + Status.APPROVED, # customer approves + Status.PHONE_CALL, # 5 unanswered reminders → escalate + Status.REVISION_IN_PROGRESS, # customer requests changes + }, + Status.PHONE_CALL: { + Status.APPROVED, # approved during/after phone call + Status.REVISION_IN_PROGRESS, # changes agreed on phone + }, + Status.APPROVED: { + Status.PRODUCTION_READY, # production gate sign-off + }, + Status.REVISION_IN_PROGRESS: { + Status.COLOR_UP_IN_PROGRESS, # new immutable version started + }, + Status.PRODUCTION_READY: { + Status.IN_PRODUCTION, + }, + Status.IN_PRODUCTION: { + Status.COMPLETE, + }, + Status.COMPLETE: set(), # terminal +} + + +class InvalidTransitionError(ValueError): + """Raised when a requested state transition is not permitted.""" + + def __init__(self, from_status: Status, to_status: Status) -> None: + allowed = sorted(s.value for s in VALID_TRANSITIONS.get(from_status, set())) + super().__init__( + f"Cannot transition from '{from_status.value}' to '{to_status.value}'. " + f"Allowed next states: {allowed}" + ) + self.from_status = from_status + self.to_status = to_status + + +def validate_transition(from_status: Status, to_status: Status) -> None: + """Raise InvalidTransitionError if the transition is not permitted.""" + if to_status not in VALID_TRANSITIONS.get(from_status, set()): + raise InvalidTransitionError(from_status, to_status) + + +def transition( + conn: sqlite3.Connection, + line_item_id: int, + to_status: Status, + actor: str, + *, + notes: str | None = None, + team_proxy: bool = False, +) -> None: + """Advance a line item to *to_status*, validating and logging the change. + + Raises + ------ + ValueError + If the line item does not exist. + InvalidTransitionError + If the requested transition is not permitted from the current state. + """ + row = conn.execute( + "SELECT status FROM order_line_items WHERE line_item_id = ?", + (line_item_id,), + ).fetchone() + if row is None: + raise ValueError(f"Line item {line_item_id} not found") + + from_status = Status(row["status"]) + validate_transition(from_status, to_status) + + now = datetime.now(UTC).isoformat(timespec="seconds") + conn.execute( + "UPDATE order_line_items SET status = ? WHERE line_item_id = ?", + (to_status.value, line_item_id), + ) + conn.execute( + """ + INSERT INTO status_history + (line_item_id, actor, from_status, to_status, team_proxy, notes, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (line_item_id, actor, from_status.value, to_status.value, team_proxy, notes, now), + ) + conn.commit() + + +def bulk_transition( + conn: sqlite3.Connection, + line_item_ids: list[int], + to_status: Status, + actor: str, + *, + notes: str | None = None, + team_proxy: bool = False, +) -> list[int]: + """Transition multiple line items; skip any that are already at *to_status* + or for which the transition is invalid. Returns IDs of successfully + transitioned items. + """ + updated: list[int] = [] + for lid in line_item_ids: + row = conn.execute( + "SELECT status FROM order_line_items WHERE line_item_id = ?", + (lid,), + ).fetchone() + if row is None: + continue + current = Status(row["status"]) + if current == to_status: + continue + try: + validate_transition(current, to_status) + except InvalidTransitionError: + continue + transition(conn, lid, to_status, actor, notes=notes, team_proxy=team_proxy) + updated.append(lid) + return updated + + +def get_history(conn: sqlite3.Connection, line_item_id: int) -> list[sqlite3.Row]: + """Return all status_history rows for a line item, oldest first.""" + return conn.execute( + """ + SELECT * FROM status_history + WHERE line_item_id = ? + ORDER BY timestamp ASC, history_id ASC + """, + (line_item_id,), + ).fetchall() diff --git a/pocs/poc4_order_workflow/tests/__init__.py b/pocs/poc4_order_workflow/tests/__init__.py new file mode 100644 index 0000000..c781e7f --- /dev/null +++ b/pocs/poc4_order_workflow/tests/__init__.py @@ -0,0 +1 @@ +# POC 4 tests diff --git a/pocs/poc4_order_workflow/tests/conftest.py b/pocs/poc4_order_workflow/tests/conftest.py new file mode 100644 index 0000000..51e5a74 --- /dev/null +++ b/pocs/poc4_order_workflow/tests/conftest.py @@ -0,0 +1,114 @@ +"""Shared fixtures for POC 4 tests.""" + +from __future__ import annotations + +import pytest + +from pocs.poc4_order_workflow.src import crud +from pocs.poc4_order_workflow.src.database import open_connection +from pocs.poc4_order_workflow.src.models import ( + Approver, + ColorUp, + Customer, + Logo, + Order, + OrderLineItem, +) + + +@pytest.fixture() +def db(): + """In-memory SQLite connection, fully bootstrapped with schema.""" + conn = open_connection(":memory:") + yield conn + conn.close() + + +@pytest.fixture() +def customer(db): + c = crud.create_customer(db, Customer(customer_name="Alpha Omega Winery")) + return c + + +@pytest.fixture() +def approver(db, customer): + a = crud.create_approver( + db, + Approver( + customer_id=customer.customer_id, + name="Jane Smith", + email="jane@ao.example", + is_primary=True, + ), + ) + return a + + +@pytest.fixture() +def logo(db, customer): + lg = crud.create_logo( + db, + Logo( + customer_id=customer.customer_id, + logo_name="AO Main Logo", + netsuite_file_cabinet_id="FC-1001", + ), + ) + return lg + + +@pytest.fixture() +def approved_color_up(db, logo): + cu = ColorUp( + logo_id=logo.logo_id, + style_number="14728-BLK", + color_code="BLK", + thread_sequence=[ + { + "stop": 1, + "needle": 1, + "thread_code": "1000", + "thread_name": "Black", + "brand": "Madeira", + }, + { + "stop": 2, + "needle": 2, + "thread_code": "1070", + "thread_name": "Gold", + "brand": "Madeira", + }, + ], + status="Approved", + approved_at="2026-01-01T12:00:00", + ) + return crud.create_color_up(db, cu) + + +@pytest.fixture() +def order(db, customer): + o = crud.create_order( + db, + Order( + customer_id=customer.customer_id, + order_date="2026-06-12", + customer_po="PO-001", + ), + ) + return o + + +@pytest.fixture() +def line_item(db, order, logo): + li = crud.create_line_item( + db, + OrderLineItem( + order_id=order.order_id, + style_number="14728-BLK", + color_code="BLK", + placement="Left Chest", + size_quantities={"S": 5, "M": 10, "L": 10, "XL": 5}, + logo_id=logo.logo_id, + ), + ) + return li diff --git a/pocs/poc4_order_workflow/tests/test_crud.py b/pocs/poc4_order_workflow/tests/test_crud.py new file mode 100644 index 0000000..5b9fb03 --- /dev/null +++ b/pocs/poc4_order_workflow/tests/test_crud.py @@ -0,0 +1,391 @@ +"""Tests for CRUD operations across all entities.""" + +from __future__ import annotations + +import json + +import pytest + +from pocs.poc4_order_workflow.src import crud +from pocs.poc4_order_workflow.src.models import ( + Approver, + ColorUp, + Customer, + Logo, + Order, + OrderLineItem, +) + +# --------------------------------------------------------------------------- +# Customer +# --------------------------------------------------------------------------- + + +def test_create_and_get_customer(db): + c = crud.create_customer(db, Customer(customer_name="Test Co")) + assert c.customer_id > 0 + row = crud.get_customer(db, c.customer_id) + assert row["customer_name"] == "Test Co" + assert row["internal_review_default"] == 0 # False + + +def test_list_customers(db): + crud.create_customer(db, Customer(customer_name="Zebra")) + crud.create_customer(db, Customer(customer_name="Alpha")) + names = [r["customer_name"] for r in crud.list_customers(db)] + assert names == ["Alpha", "Zebra"] # ordered by name + + +def test_customer_internal_review_default(db): + c = crud.create_customer(db, Customer(customer_name="Strict Co", internal_review_default=True)) + row = crud.get_customer(db, c.customer_id) + assert row["internal_review_default"] == 1 + + +# --------------------------------------------------------------------------- +# Approver +# --------------------------------------------------------------------------- + + +def test_create_approver(db, customer): + a = crud.create_approver( + db, + Approver( + customer_id=customer.customer_id, + name="Bob", + email="bob@example.com", + is_primary=True, + ), + ) + assert a.approver_id > 0 + rows = crud.list_approvers(db, customer.customer_id) + assert len(rows) == 1 + assert rows[0]["name"] == "Bob" + + +def test_list_approvers_primary_first(db, customer): + crud.create_approver( + db, Approver(customer_id=customer.customer_id, name="Secondary", email="s@x.com") + ) + crud.create_approver( + db, + Approver( + customer_id=customer.customer_id, name="Primary", email="p@x.com", is_primary=True + ), + ) + rows = crud.list_approvers(db, customer.customer_id) + assert rows[0]["name"] == "Primary" + + +def test_approver_inactive_excluded(db, customer): + crud.create_approver( + db, + Approver( + customer_id=customer.customer_id, + name="Inactive", + email="i@x.com", + active=False, + ), + ) + rows = crud.list_approvers(db, customer.customer_id) + assert rows == [] + + +# --------------------------------------------------------------------------- +# Logo +# --------------------------------------------------------------------------- + + +def test_create_and_get_logo(db, customer): + lg = crud.create_logo( + db, + Logo( + customer_id=customer.customer_id, + logo_name="Test Logo", + netsuite_file_cabinet_id="FC-999", + dst_stitch_count=15000, + ), + ) + assert lg.logo_id > 0 + row = crud.get_logo(db, lg.logo_id) + assert row["logo_name"] == "Test Logo" + assert row["netsuite_file_cabinet_id"] == "FC-999" + assert row["dst_stitch_count"] == 15000 + + +def test_find_logo_by_file_cabinet_id(db, customer): + cid = customer.customer_id + crud.create_logo(db, Logo(customer_id=cid, logo_name="L1", netsuite_file_cabinet_id="FC-A")) + crud.create_logo(db, Logo(customer_id=cid, logo_name="L2", netsuite_file_cabinet_id="FC-B")) + + row = crud.find_logo_by_file_cabinet_id(db, customer.customer_id, "FC-A") + assert row["logo_name"] == "L1" + + row = crud.find_logo_by_file_cabinet_id(db, customer.customer_id, "NOPE") + assert row is None + + +def test_update_logo_dst_fields(db, customer): + lg = crud.create_logo(db, Logo(customer_id=customer.customer_id, logo_name="DST Logo")) + crud.update_logo_dst_fields( + db, + lg.logo_id, + dst_stitch_count=20000, + dst_color_count=4, + dst_width_mm=50.5, + ) + row = crud.get_logo(db, lg.logo_id) + assert row["dst_stitch_count"] == 20000 + assert row["dst_color_count"] == 4 + assert abs(row["dst_width_mm"] - 50.5) < 0.01 + + +def test_update_logo_dst_fields_ignores_unknown(db, customer): + lg = crud.create_logo(db, Logo(customer_id=customer.customer_id, logo_name="L")) + # should not raise, just silently ignore unknown fields + crud.update_logo_dst_fields(db, lg.logo_id, unknown_field="boom") + row = crud.get_logo(db, lg.logo_id) + assert row["logo_name"] == "L" + + +# --------------------------------------------------------------------------- +# Color-Up +# --------------------------------------------------------------------------- + + +def test_create_and_get_color_up(db, logo): + cu = crud.create_color_up( + db, + ColorUp( + logo_id=logo.logo_id, + style_number="14728-BLK", + color_code="BLK", + thread_sequence=[ + { + "stop": 1, + "needle": 1, + "thread_code": "1000", + "thread_name": "Black", + "brand": "Madeira", + }, + ], + ), + ) + assert cu.colorup_id > 0 + row = crud.get_color_up(db, cu.colorup_id) + assert row["style_number"] == "14728-BLK" + assert row["status"] == "Draft" + seq = json.loads(row["thread_sequence"]) + assert seq[0]["thread_code"] == "1000" + + +def test_next_version(db, logo): + assert crud.next_version(db, logo.logo_id, "14728-BLK", "BLK") == 1 + crud.create_color_up( + db, + ColorUp( + logo_id=logo.logo_id, + style_number="14728-BLK", + color_code="BLK", + thread_sequence=[], + version=1, + ), + ) + assert crud.next_version(db, logo.logo_id, "14728-BLK", "BLK") == 2 + + +def test_unique_constraint_logo_style_color_version(db, logo): + cu1 = ColorUp( + logo_id=logo.logo_id, + style_number="X", + color_code="Y", + thread_sequence=[], + version=1, + ) + crud.create_color_up(db, cu1) + cu2 = ColorUp( + logo_id=logo.logo_id, + style_number="X", + color_code="Y", + thread_sequence=[], + version=1, + ) + import sqlite3 + + with pytest.raises(sqlite3.IntegrityError): + crud.create_color_up(db, cu2) + + +def test_approve_color_up(db, logo, approver): + cu = crud.create_color_up( + db, + ColorUp( + logo_id=logo.logo_id, + style_number="14728-BLK", + color_code="BLK", + thread_sequence=[], + ), + ) + crud.approve_color_up(db, cu.colorup_id, approved_by_id=approver.approver_id) + row = crud.get_color_up(db, cu.colorup_id) + assert row["status"] == "Approved" + assert row["approved_by_id"] == approver.approver_id + assert row["approved_at"] is not None + + +def test_team_proxy_approval(db, logo): + cu = crud.create_color_up( + db, + ColorUp(logo_id=logo.logo_id, style_number="P-WHT", color_code="WHT", thread_sequence=[]), + ) + crud.approve_color_up(db, cu.colorup_id, team_proxy=True) + row = crud.get_color_up(db, cu.colorup_id) + assert row["status"] == "Approved" + assert row["approved_by_team_proxy"] == 1 + assert row["approved_by_id"] is None + + +# --------------------------------------------------------------------------- +# Order +# --------------------------------------------------------------------------- + + +def test_create_and_get_order(db, customer): + o = crud.create_order( + db, + Order( + customer_id=customer.customer_id, + order_date="2026-06-12", + netsuite_so_number="SO-12345", + customer_po="PO-001", + ), + ) + assert o.order_id > 0 + row = crud.get_order(db, o.order_id) + assert row["netsuite_so_number"] == "SO-12345" + assert row["priority"] == "Normal" + # Confirm there is no 'status' column on orders + with pytest.raises((IndexError, KeyError)): + _ = row["status"] + + +def test_order_has_no_status_column(db, customer): + o = crud.create_order(db, Order(customer_id=customer.customer_id, order_date="2026-06-12")) + row = crud.get_order(db, o.order_id) + keys = row.keys() + assert "status" not in keys + + +def test_order_status_rollup(db, customer, logo): + o = crud.create_order(db, Order(customer_id=customer.customer_id, order_date="2026-06-12")) + for status, n in [("New", 2), ("Awaiting Logo", 1), ("Complete", 1)]: + for _ in range(n): + crud.create_line_item( + db, + OrderLineItem( + order_id=o.order_id, + style_number="S", + color_code="C", + placement="Left Chest", + status=status, + ), + ) + rollup = crud.order_status_rollup(db, o.order_id) + assert rollup == {"New": 2, "Awaiting Logo": 1, "Complete": 1} + + +# --------------------------------------------------------------------------- +# Order Line Items +# --------------------------------------------------------------------------- + + +def test_create_and_get_line_item(db, order, logo): + li = crud.create_line_item( + db, + OrderLineItem( + order_id=order.order_id, + style_number="14728-BLK", + color_code="BLK", + placement="Left Chest", + size_quantities={"S": 5, "M": 10}, + logo_id=logo.logo_id, + ), + ) + assert li.line_item_id > 0 + row = crud.get_line_item(db, li.line_item_id) + assert row["style_number"] == "14728-BLK" + assert row["status"] == "New" + sq = json.loads(row["size_quantities"]) + assert sq == {"S": 5, "M": 10} + + +def test_line_item_total_units(): + li = OrderLineItem( + order_id=1, + style_number="X", + color_code="Y", + placement="Left Chest", + size_quantities={"S": 2, "M": 3, "L": 5}, + ) + assert li.total_units == 10 + + +def test_list_line_items(db, order, logo): + for color in ["BLK", "WHT", "NAV"]: + crud.create_line_item( + db, + OrderLineItem( + order_id=order.order_id, + style_number="X", + color_code=color, + placement="Left Chest", + ), + ) + items = crud.list_line_items(db, order.order_id) + assert len(items) == 3 + + +def test_set_line_item_logo(db, order, logo): + li = crud.create_line_item( + db, + OrderLineItem( + order_id=order.order_id, style_number="A", color_code="B", placement="Cap Front" + ), + ) + crud.set_line_item_logo(db, li.line_item_id, logo.logo_id) + row = crud.get_line_item(db, li.line_item_id) + assert row["logo_id"] == logo.logo_id + + +def test_set_line_item_colorup(db, order, logo, approved_color_up): + li = crud.create_line_item( + db, + OrderLineItem( + order_id=order.order_id, style_number="A", color_code="B", placement="Cap Front" + ), + ) + crud.set_line_item_colorup(db, li.line_item_id, approved_color_up.colorup_id) + row = crud.get_line_item(db, li.line_item_id) + assert row["colorup_id"] == approved_color_up.colorup_id + + +# --------------------------------------------------------------------------- +# Foreign key enforcement +# --------------------------------------------------------------------------- + + +def test_fk_enforcement_order_references_customer(db): + import sqlite3 + + with pytest.raises(sqlite3.IntegrityError): + crud.create_order(db, Order(customer_id=99999, order_date="2026-06-12")) + + +def test_fk_enforcement_line_item_references_order(db): + import sqlite3 + + with pytest.raises(sqlite3.IntegrityError): + crud.create_line_item( + db, + OrderLineItem(order_id=99999, style_number="X", color_code="Y", placement="Cap Front"), + ) diff --git a/pocs/poc4_order_workflow/tests/test_dst_stub.py b/pocs/poc4_order_workflow/tests/test_dst_stub.py new file mode 100644 index 0000000..debbe99 --- /dev/null +++ b/pocs/poc4_order_workflow/tests/test_dst_stub.py @@ -0,0 +1,31 @@ +"""Verify that DST stub functions raise the expected error. + +These tests confirm the interface contract without needing any DST files. +""" + +from __future__ import annotations + +import pytest + +from pocs.poc4_order_workflow.src.dst_stub import ( + DSTNotAvailableError, + open_color_up_editor, + parse_dst, +) + + +def test_parse_dst_raises_not_available(tmp_path): + fake_dst = tmp_path / "fake.dst" + fake_dst.write_bytes(b"\x00" * 10) + with pytest.raises(DSTNotAvailableError): + parse_dst(fake_dst) + + +def test_open_editor_raises_not_available(): + with pytest.raises(DSTNotAvailableError): + open_color_up_editor(logo_id=1) + + +def test_dst_not_available_is_not_implemented_error(): + """DSTNotAvailableError must be a subclass of NotImplementedError.""" + assert issubclass(DSTNotAvailableError, NotImplementedError) diff --git a/pocs/poc4_order_workflow/tests/test_match_key.py b/pocs/poc4_order_workflow/tests/test_match_key.py new file mode 100644 index 0000000..4787c07 --- /dev/null +++ b/pocs/poc4_order_workflow/tests/test_match_key.py @@ -0,0 +1,349 @@ +"""Tests for match-key logic and approval cascade.""" + +from __future__ import annotations + +import pytest + +from pocs.poc4_order_workflow.src import crud +from pocs.poc4_order_workflow.src.match_key import ( + cascade_approval, + find_approved_color_up, + resolve_logo_for_line_item, +) +from pocs.poc4_order_workflow.src.models import ( + ColorUp, + Order, + OrderLineItem, +) +from pocs.poc4_order_workflow.src.state_machine import Status + +# --------------------------------------------------------------------------- +# find_approved_color_up +# --------------------------------------------------------------------------- + + +def test_find_approved_color_up_returns_match(db, logo, approved_color_up): + row = find_approved_color_up(db, logo.logo_id, "14728-BLK", "BLK") + assert row is not None + assert row["colorup_id"] == approved_color_up.colorup_id + + +def test_find_approved_returns_none_when_draft(db, logo): + crud.create_color_up( + db, + ColorUp( + logo_id=logo.logo_id, + style_number="X", + color_code="Y", + thread_sequence=[], + status="Draft", + ), + ) + row = find_approved_color_up(db, logo.logo_id, "X", "Y") + assert row is None + + +def test_find_approved_exact_match_all_three_fields(db, logo): + crud.create_color_up( + db, + ColorUp( + logo_id=logo.logo_id, + style_number="STYLE-A", + color_code="BLK", + thread_sequence=[], + status="Approved", + ), + ) + # Same style, different color + assert find_approved_color_up(db, logo.logo_id, "STYLE-A", "WHT") is None + # Different style, same color + assert find_approved_color_up(db, logo.logo_id, "STYLE-B", "BLK") is None + # Exact match + assert find_approved_color_up(db, logo.logo_id, "STYLE-A", "BLK") is not None + + +def test_find_approved_returns_latest_version(db, logo): + for v in range(1, 4): + cu = ColorUp( + logo_id=logo.logo_id, + style_number="P", + color_code="Q", + thread_sequence=[{"stop": v}], + status="Approved", + version=v, + ) + crud.create_color_up(db, cu) + row = find_approved_color_up(db, logo.logo_id, "P", "Q") + assert row["version"] == 3 + + +def test_color_code_not_portable_across_styles(db, logo): + """MAL color code on style A must not match style B — key requires all three.""" + for style in ["POLO-BLK", "HAT-BLK"]: + crud.create_color_up( + db, + ColorUp( + logo_id=logo.logo_id, + style_number=style, + color_code="MAL", + thread_sequence=[], + status="Approved", + ), + ) + # Only exact three-part key matches + assert find_approved_color_up(db, logo.logo_id, "POLO-BLK", "MAL") is not None + assert find_approved_color_up(db, logo.logo_id, "HAT-BLK", "MAL") is not None + assert find_approved_color_up(db, logo.logo_id, "VEST-BLK", "MAL") is None + + +# --------------------------------------------------------------------------- +# cascade_approval +# --------------------------------------------------------------------------- + + +def _make_pending_line_item(db, order, logo, color_up, status=Status.SENT_FOR_APPROVAL.value): + li = crud.create_line_item( + db, + OrderLineItem( + order_id=order.order_id, + style_number=color_up.style_number if hasattr(color_up, "style_number") else "X", + color_code="BLK", + placement="Left Chest", + logo_id=logo.logo_id, + colorup_id=color_up.colorup_id if hasattr(color_up, "colorup_id") else None, + status=status, + ), + ) + return li + + +def test_cascade_approval_advances_sent_for_approval(db, customer, logo, order, approver): + cu = crud.create_color_up( + db, + ColorUp( + logo_id=logo.logo_id, + style_number="14728-BLK", + color_code="BLK", + thread_sequence=[], + status="Pending Approval", + ), + ) + + li1 = crud.create_line_item( + db, + OrderLineItem( + order_id=order.order_id, + style_number="14728-BLK", + color_code="BLK", + placement="Left Chest", + logo_id=logo.logo_id, + colorup_id=cu.colorup_id, + status=Status.SENT_FOR_APPROVAL.value, + ), + ) + li2 = crud.create_line_item( + db, + OrderLineItem( + order_id=order.order_id, + style_number="14728-BLK", + color_code="BLK", + placement="Right Sleeve", + logo_id=logo.logo_id, + colorup_id=cu.colorup_id, + status=Status.SENT_FOR_APPROVAL.value, + ), + ) + + advanced = cascade_approval( + db, cu.colorup_id, actor="customer", approved_by_id=approver.approver_id + ) + + assert set(advanced) == {li1.line_item_id, li2.line_item_id} + for lid in [li1.line_item_id, li2.line_item_id]: + row = crud.get_line_item(db, lid) + assert row["status"] == "Production Ready" + + cu_row = crud.get_color_up(db, cu.colorup_id) + assert cu_row["status"] == "Approved" + + +def test_cascade_approval_phone_call_items(db, customer, logo, order, approver): + cu = crud.create_color_up( + db, + ColorUp( + logo_id=logo.logo_id, + style_number="S", + color_code="C", + thread_sequence=[], + status="Pending Approval", + ), + ) + li = crud.create_line_item( + db, + OrderLineItem( + order_id=order.order_id, + style_number="S", + color_code="C", + placement="Left Chest", + logo_id=logo.logo_id, + colorup_id=cu.colorup_id, + status=Status.PHONE_CALL.value, + ), + ) + advanced = cascade_approval(db, cu.colorup_id, actor="lead", team_proxy=True) + assert li.line_item_id in advanced + row = crud.get_line_item(db, li.line_item_id) + assert row["status"] == "Production Ready" + + +def test_cascade_skips_already_advanced_items(db, customer, logo, order): + cu = crud.create_color_up( + db, + ColorUp( + logo_id=logo.logo_id, + style_number="S", + color_code="C", + thread_sequence=[], + status="Pending Approval", + ), + ) + li = crud.create_line_item( + db, + OrderLineItem( + order_id=order.order_id, + style_number="S", + color_code="C", + placement="Left Chest", + logo_id=logo.logo_id, + colorup_id=cu.colorup_id, + status=Status.PRODUCTION_READY.value, + ), + ) + advanced = cascade_approval(db, cu.colorup_id, actor="system") + # Already at Production Ready — not in the returned list + assert li.line_item_id not in advanced + # Status unchanged + assert crud.get_line_item(db, li.line_item_id)["status"] == "Production Ready" + + +def test_cascade_does_not_cross_customers(db): + """Line items from different customers sharing a logo should not cascade.""" + from pocs.poc4_order_workflow.src.models import Customer, Logo + + c1 = crud.create_customer(db, Customer(customer_name="Cust A")) + c2 = crud.create_customer(db, Customer(customer_name="Cust B")) + + lg1 = crud.create_logo(db, Logo(customer_id=c1.customer_id, logo_name="Logo A")) + lg2 = crud.create_logo(db, Logo(customer_id=c2.customer_id, logo_name="Logo B")) + + cu1 = crud.create_color_up( + db, + ColorUp(logo_id=lg1.logo_id, style_number="S", color_code="C", thread_sequence=[]), + ) + cu2 = crud.create_color_up( + db, + ColorUp(logo_id=lg2.logo_id, style_number="S", color_code="C", thread_sequence=[]), + ) + + o1 = crud.create_order(db, Order(customer_id=c1.customer_id, order_date="2026-06-12")) + o2 = crud.create_order(db, Order(customer_id=c2.customer_id, order_date="2026-06-12")) + + li1 = crud.create_line_item( + db, + OrderLineItem( + order_id=o1.order_id, + style_number="S", + color_code="C", + placement="Left Chest", + logo_id=lg1.logo_id, + colorup_id=cu1.colorup_id, + status=Status.SENT_FOR_APPROVAL.value, + ), + ) + li2 = crud.create_line_item( + db, + OrderLineItem( + order_id=o2.order_id, + style_number="S", + color_code="C", + placement="Left Chest", + logo_id=lg2.logo_id, + colorup_id=cu2.colorup_id, + status=Status.SENT_FOR_APPROVAL.value, + ), + ) + + # Approving cu1 should NOT advance li2 + advanced = cascade_approval(db, cu1.colorup_id, actor="system") + assert li1.line_item_id in advanced + assert li2.line_item_id not in advanced + assert crud.get_line_item(db, li2.line_item_id)["status"] == Status.SENT_FOR_APPROVAL.value + + +# --------------------------------------------------------------------------- +# resolve_logo_for_line_item +# --------------------------------------------------------------------------- + + +def test_resolve_logo_exact_match_goes_to_production_ready(db, order, logo, approved_color_up): + li = crud.create_line_item( + db, + OrderLineItem( + order_id=order.order_id, + style_number="14728-BLK", + color_code="BLK", + placement="Left Chest", + status=Status.AWAITING_LOGO.value, + ), + ) + result = resolve_logo_for_line_item(db, li.line_item_id, logo.logo_id, actor="system") + assert result == Status.PRODUCTION_READY.value + row = crud.get_line_item(db, li.line_item_id) + assert row["logo_id"] == logo.logo_id + assert row["colorup_id"] == approved_color_up.colorup_id + + +def test_resolve_logo_no_match_goes_to_color_up_in_progress(db, order, logo): + li = crud.create_line_item( + db, + OrderLineItem( + order_id=order.order_id, + style_number="NEWSTYLE", + color_code="WHT", + placement="Cap Front", + status=Status.AWAITING_LOGO.value, + ), + ) + result = resolve_logo_for_line_item(db, li.line_item_id, logo.logo_id, actor="artist") + assert result == Status.COLOR_UP_IN_PROGRESS.value + + +def test_resolve_logo_override_forces_approval_path(db, order, logo, approved_color_up): + li = crud.create_line_item( + db, + OrderLineItem( + order_id=order.order_id, + style_number="14728-BLK", + color_code="BLK", + placement="Left Chest", + status=Status.AWAITING_LOGO.value, + send_for_approval_override=True, # force approval even with exact match + ), + ) + result = resolve_logo_for_line_item(db, li.line_item_id, logo.logo_id, actor="artist") + assert result == Status.COLOR_UP_IN_PROGRESS.value + + +def test_resolve_logo_requires_awaiting_logo_state(db, order, logo): + li = crud.create_line_item( + db, + OrderLineItem( + order_id=order.order_id, + style_number="X", + color_code="Y", + placement="Left Chest", + status="New", # wrong state + ), + ) + with pytest.raises(ValueError, match="Awaiting Logo"): + resolve_logo_for_line_item(db, li.line_item_id, logo.logo_id, actor="x") diff --git a/pocs/poc4_order_workflow/tests/test_production.py b/pocs/poc4_order_workflow/tests/test_production.py new file mode 100644 index 0000000..7d350c6 --- /dev/null +++ b/pocs/poc4_order_workflow/tests/test_production.py @@ -0,0 +1,243 @@ +"""Tests for production-batch query.""" + +from __future__ import annotations + +from pocs.poc4_order_workflow.src import crud +from pocs.poc4_order_workflow.src.models import ( + ColorUp, + Customer, + Logo, + Order, + OrderLineItem, +) +from pocs.poc4_order_workflow.src.production import ProductionBatch, get_production_batches +from pocs.poc4_order_workflow.src.state_machine import Status + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_THREAD_SEQ_A = [ + {"stop": 1, "needle": 1, "thread_code": "1000", "thread_name": "Black", "brand": "Madeira"}, + {"stop": 2, "needle": 2, "thread_code": "1070", "thread_name": "Gold", "brand": "Madeira"}, +] +_THREAD_SEQ_B = [ + {"stop": 1, "needle": 3, "thread_code": "1001", "thread_name": "White", "brand": "Madeira"}, +] + + +def _make_approved_cu(db, logo, style, color, thread_seq=_THREAD_SEQ_A): + cu = ColorUp( + logo_id=logo.logo_id, + style_number=style, + color_code=color, + thread_sequence=thread_seq, + status="Approved", + approved_at="2026-01-01T12:00:00", + ) + return crud.create_color_up(db, cu) + + +def _make_ready_line_item(db, order, logo, cu, placement="Left Chest", **kwargs): + li = crud.create_line_item( + db, + OrderLineItem( + order_id=order.order_id, + style_number=cu.style_number, + color_code=cu.color_code, + placement=placement, + logo_id=logo.logo_id, + colorup_id=cu.colorup_id, + status=Status.PRODUCTION_READY.value, + size_quantities=kwargs.get("size_quantities", {"M": 10}), + ), + ) + return li + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_empty_batches_when_no_ready_items(db, customer, logo): + batches = get_production_batches(db) + assert batches == [] + + +def test_single_batch(db, customer, logo, order): + cu = _make_approved_cu(db, logo, "14728-BLK", "BLK") + _make_ready_line_item(db, order, logo, cu) + + batches = get_production_batches(db) + assert len(batches) == 1 + b = batches[0] + assert isinstance(b, ProductionBatch) + assert b.logo_id == logo.logo_id + assert b.placement == "Left Chest" + assert len(b.line_items) == 1 + + +def test_same_logo_same_placement_same_thread_seq_groups_together(db, customer, logo, order): + cu = _make_approved_cu(db, logo, "14728-BLK", "BLK", _THREAD_SEQ_A) + _make_ready_line_item(db, order, logo, cu, size_quantities={"S": 5, "M": 10}) + _make_ready_line_item(db, order, logo, cu, size_quantities={"L": 8}) + + batches = get_production_batches(db) + assert len(batches) == 1 + assert len(batches[0].line_items) == 2 + assert batches[0].total_units == 23 + + +def test_different_placement_creates_separate_batches(db, customer, logo, order): + cu = _make_approved_cu(db, logo, "14728-BLK", "BLK") + _make_ready_line_item(db, order, logo, cu, placement="Left Chest") + _make_ready_line_item(db, order, logo, cu, placement="Cap Front") + + batches = get_production_batches(db) + assert len(batches) == 2 + placements = {b.placement for b in batches} + assert placements == {"Left Chest", "Cap Front"} + + +def test_different_thread_sequence_creates_separate_batches(db, customer, logo, order): + cu_a = _make_approved_cu(db, logo, "14728-BLK", "BLK", _THREAD_SEQ_A) + cu_b = _make_approved_cu(db, logo, "14728-WHT", "WHT", _THREAD_SEQ_B) + + _make_ready_line_item(db, order, logo, cu_a) + _make_ready_line_item(db, order, logo, cu_b) + + batches = get_production_batches(db) + assert len(batches) == 2 + + +def test_batches_never_cross_customers(db): + c1 = crud.create_customer(db, Customer(customer_name="Customer 1")) + c2 = crud.create_customer(db, Customer(customer_name="Customer 2")) + + lg1 = crud.create_logo(db, Logo(customer_id=c1.customer_id, logo_name="Logo C1")) + lg2 = crud.create_logo(db, Logo(customer_id=c2.customer_id, logo_name="Logo C2")) + + o1 = crud.create_order(db, Order(customer_id=c1.customer_id, order_date="2026-06-12")) + o2 = crud.create_order(db, Order(customer_id=c2.customer_id, order_date="2026-06-12")) + + cu1 = _make_approved_cu(db, lg1, "STYLE-BLK", "BLK", _THREAD_SEQ_A) + cu2 = _make_approved_cu(db, lg2, "STYLE-BLK", "BLK", _THREAD_SEQ_A) + + _make_ready_line_item(db, o1, lg1, cu1) + _make_ready_line_item(db, o2, lg2, cu2) + + batches = get_production_batches(db) + assert len(batches) == 2 + customer_ids = {b.customer_id for b in batches} + assert customer_ids == {c1.customer_id, c2.customer_id} + + +def test_filter_by_customer(db): + c1 = crud.create_customer(db, Customer(customer_name="Customer 1")) + c2 = crud.create_customer(db, Customer(customer_name="Customer 2")) + lg1 = crud.create_logo(db, Logo(customer_id=c1.customer_id, logo_name="L1")) + lg2 = crud.create_logo(db, Logo(customer_id=c2.customer_id, logo_name="L2")) + o1 = crud.create_order(db, Order(customer_id=c1.customer_id, order_date="2026-06-12")) + o2 = crud.create_order(db, Order(customer_id=c2.customer_id, order_date="2026-06-12")) + cu1 = _make_approved_cu(db, lg1, "S", "C") + cu2 = _make_approved_cu(db, lg2, "S", "C") + _make_ready_line_item(db, o1, lg1, cu1) + _make_ready_line_item(db, o2, lg2, cu2) + + batches = get_production_batches(db, customer_id=c1.customer_id) + assert len(batches) == 1 + assert batches[0].customer_id == c1.customer_id + + +def test_non_ready_items_excluded(db, customer, logo, order): + cu = _make_approved_cu(db, logo, "S", "C") + # In Production — should not appear + crud.create_line_item( + db, + OrderLineItem( + order_id=order.order_id, + style_number="S", + color_code="C", + placement="Left Chest", + logo_id=logo.logo_id, + colorup_id=cu.colorup_id, + status=Status.IN_PRODUCTION.value, + ), + ) + batches = get_production_batches(db) + assert batches == [] + + +def test_draft_colorup_excluded(db, customer, logo, order): + cu = crud.create_color_up( + db, + ColorUp( + logo_id=logo.logo_id, + style_number="S", + color_code="C", + thread_sequence=[], + status="Draft", + ), + ) + crud.create_line_item( + db, + OrderLineItem( + order_id=order.order_id, + style_number="S", + color_code="C", + placement="Left Chest", + logo_id=logo.logo_id, + colorup_id=cu.colorup_id, + status=Status.PRODUCTION_READY.value, + ), + ) + batches = get_production_batches(db) + assert batches == [] + + +def test_batch_customer_name(db, customer, logo, order): + cu = _make_approved_cu(db, logo, "S", "C") + _make_ready_line_item(db, order, logo, cu) + batches = get_production_batches(db) + assert batches[0].customer_name == customer.customer_name + + +def test_thread_key_is_order_independent(db, customer, logo, order): + """Two color-ups whose thread_sequence dicts are key-order-different + but value-identical must land in the same batch.""" + seq_1 = [ + {"brand": "Madeira", "needle": 1, "stop": 1, "thread_code": "1000", "thread_name": "Black"} + ] + seq_2 = [ + {"stop": 1, "needle": 1, "thread_name": "Black", "thread_code": "1000", "brand": "Madeira"} + ] + + cu1 = crud.create_color_up( + db, + ColorUp( + logo_id=logo.logo_id, + style_number="S1", + color_code="BLK", + thread_sequence=seq_1, + status="Approved", + version=1, + ), + ) + cu2 = crud.create_color_up( + db, + ColorUp( + logo_id=logo.logo_id, + style_number="S2", + color_code="BLK", + thread_sequence=seq_2, + status="Approved", + version=1, + ), + ) + _make_ready_line_item(db, order, logo, cu1) + _make_ready_line_item(db, order, logo, cu2) + + batches = get_production_batches(db) + assert len(batches) == 1 + assert len(batches[0].line_items) == 2 diff --git a/pocs/poc4_order_workflow/tests/test_state_machine.py b/pocs/poc4_order_workflow/tests/test_state_machine.py new file mode 100644 index 0000000..65f2fdc --- /dev/null +++ b/pocs/poc4_order_workflow/tests/test_state_machine.py @@ -0,0 +1,223 @@ +"""Tests for the state machine — transitions, validation, and audit logging.""" + +from __future__ import annotations + +import pytest + +from pocs.poc4_order_workflow.src import crud +from pocs.poc4_order_workflow.src.models import Order, OrderLineItem +from pocs.poc4_order_workflow.src.state_machine import ( + VALID_TRANSITIONS, + InvalidTransitionError, + Status, + get_history, + transition, + validate_transition, +) + +# --------------------------------------------------------------------------- +# validate_transition (pure, no DB) +# --------------------------------------------------------------------------- + + +def test_all_states_have_transition_entries(): + """Every Status member must have an entry in VALID_TRANSITIONS.""" + for s in Status: + assert s in VALID_TRANSITIONS, f"Missing entry for {s}" + + +def test_terminal_state_has_no_outgoing(): + assert VALID_TRANSITIONS[Status.COMPLETE] == set() + + +def test_valid_forward_transitions(): + valid_pairs = [ + (Status.NEW, Status.AWAITING_LOGO), + (Status.AWAITING_LOGO, Status.COLOR_UP_IN_PROGRESS), + (Status.AWAITING_LOGO, Status.PRODUCTION_READY), + (Status.COLOR_UP_IN_PROGRESS, Status.INTERNAL_REVIEW), + (Status.COLOR_UP_IN_PROGRESS, Status.NEEDS_APPROVER), + (Status.COLOR_UP_IN_PROGRESS, Status.SENT_FOR_APPROVAL), + (Status.INTERNAL_REVIEW, Status.SENT_FOR_APPROVAL), + (Status.INTERNAL_REVIEW, Status.NEEDS_APPROVER), + (Status.INTERNAL_REVIEW, Status.COLOR_UP_IN_PROGRESS), + (Status.NEEDS_APPROVER, Status.SENT_FOR_APPROVAL), + (Status.SENT_FOR_APPROVAL, Status.APPROVED), + (Status.SENT_FOR_APPROVAL, Status.PHONE_CALL), + (Status.SENT_FOR_APPROVAL, Status.REVISION_IN_PROGRESS), + (Status.PHONE_CALL, Status.APPROVED), + (Status.PHONE_CALL, Status.REVISION_IN_PROGRESS), + (Status.APPROVED, Status.PRODUCTION_READY), + (Status.REVISION_IN_PROGRESS, Status.COLOR_UP_IN_PROGRESS), + (Status.PRODUCTION_READY, Status.IN_PRODUCTION), + (Status.IN_PRODUCTION, Status.COMPLETE), + ] + for from_s, to_s in valid_pairs: + validate_transition(from_s, to_s) # should not raise + + +def test_invalid_transitions_raise(): + invalid_pairs = [ + (Status.NEW, Status.COMPLETE), + (Status.NEW, Status.APPROVED), + (Status.COMPLETE, Status.NEW), + (Status.COMPLETE, Status.IN_PRODUCTION), + (Status.AWAITING_LOGO, Status.COMPLETE), + (Status.APPROVED, Status.SENT_FOR_APPROVAL), + (Status.PRODUCTION_READY, Status.NEW), + (Status.IN_PRODUCTION, Status.NEW), + ] + for from_s, to_s in invalid_pairs: + with pytest.raises(InvalidTransitionError): + validate_transition(from_s, to_s) + + +def test_error_message_lists_allowed_states(): + with pytest.raises(InvalidTransitionError) as exc_info: + validate_transition(Status.NEW, Status.COMPLETE) + msg = str(exc_info.value) + assert "Awaiting Logo" in msg + assert "New" in msg + assert "Complete" in msg + + +# --------------------------------------------------------------------------- +# transition (requires DB) +# --------------------------------------------------------------------------- + + +def test_transition_advances_status(db, order, line_item): + transition(db, line_item.line_item_id, Status.AWAITING_LOGO, actor="system") + row = crud.get_line_item(db, line_item.line_item_id) + assert row["status"] == "Awaiting Logo" + + +def test_transition_writes_history(db, order, line_item): + transition(db, line_item.line_item_id, Status.AWAITING_LOGO, actor="ops-bot") + history = get_history(db, line_item.line_item_id) + assert len(history) == 1 + h = history[0] + assert h["from_status"] == "New" + assert h["to_status"] == "Awaiting Logo" + assert h["actor"] == "ops-bot" + assert h["team_proxy"] == 0 # SQLite stores bool as int + + +def test_transition_team_proxy_flag(db, order, line_item): + transition( + db, + line_item.line_item_id, + Status.AWAITING_LOGO, + actor="system", + team_proxy=True, + notes="auto-linked", + ) + history = get_history(db, line_item.line_item_id) + assert history[0]["team_proxy"] == 1 + assert history[0]["notes"] == "auto-linked" + + +def test_invalid_transition_does_not_mutate(db, order, line_item): + with pytest.raises(InvalidTransitionError): + transition(db, line_item.line_item_id, Status.COMPLETE, actor="bad-actor") + # Status must be unchanged + row = crud.get_line_item(db, line_item.line_item_id) + assert row["status"] == "New" + + +def test_transition_nonexistent_item_raises(db): + with pytest.raises(ValueError, match="not found"): + transition(db, 99999, Status.AWAITING_LOGO, actor="x") + + +def test_happy_path_full_workflow(db, customer, logo, approver): + """Walk a line item through the complete approval workflow.""" + from pocs.poc4_order_workflow.src.models import Order, OrderLineItem + + o = crud.create_order(db, Order(customer_id=customer.customer_id, order_date="2026-06-12")) + li = crud.create_line_item( + db, + OrderLineItem( + order_id=o.order_id, + style_number="AB-WHT", + color_code="WHT", + placement="Cap Front", + ), + ) + lid = li.line_item_id + + steps = [ + (Status.AWAITING_LOGO, "intake"), + (Status.COLOR_UP_IN_PROGRESS, "artist"), + (Status.SENT_FOR_APPROVAL, "artist"), + (Status.APPROVED, "customer"), + (Status.PRODUCTION_READY, "lead"), + (Status.IN_PRODUCTION, "operator"), + (Status.COMPLETE, "operator"), + ] + for to_status, actor in steps: + transition(db, lid, to_status, actor=actor) + + final = crud.get_line_item(db, lid) + assert final["status"] == "Complete" + + history = get_history(db, lid) + assert len(history) == len(steps) + assert [h["to_status"] for h in history] == [s.value for s, _ in steps] + + +def test_revision_loop(db, customer, logo, approver): + """Revision In Progress → Color-Up In Progress loops correctly.""" + o = crud.create_order(db, Order(customer_id=customer.customer_id, order_date="2026-06-12")) + li = crud.create_line_item( + db, + OrderLineItem( + order_id=o.order_id, + style_number="X-NAV", + color_code="NAV", + placement="Right Sleeve", + ), + ) + lid = li.line_item_id + + for st, actor in [ + (Status.AWAITING_LOGO, "system"), + (Status.COLOR_UP_IN_PROGRESS, "artist"), + (Status.SENT_FOR_APPROVAL, "artist"), + (Status.REVISION_IN_PROGRESS, "customer"), + (Status.COLOR_UP_IN_PROGRESS, "artist"), # second round + (Status.SENT_FOR_APPROVAL, "artist"), + (Status.APPROVED, "customer"), + (Status.PRODUCTION_READY, "lead"), + ]: + transition(db, lid, st, actor=actor) + + row = crud.get_line_item(db, lid) + assert row["status"] == "Production Ready" + + +def test_phone_call_path(db, customer, logo, approver): + """5 unanswered reminders → Phone Call → Approved.""" + o = crud.create_order(db, Order(customer_id=customer.customer_id, order_date="2026-06-12")) + li = crud.create_line_item( + db, + OrderLineItem( + order_id=o.order_id, + style_number="HAT-BLK", + color_code="BLK", + placement="Cap Front", + ), + ) + lid = li.line_item_id + + for st, actor in [ + (Status.AWAITING_LOGO, "system"), + (Status.COLOR_UP_IN_PROGRESS, "artist"), + (Status.SENT_FOR_APPROVAL, "artist"), + (Status.PHONE_CALL, "reminder-bot"), + (Status.APPROVED, "lead"), + (Status.PRODUCTION_READY, "lead"), + ]: + transition(db, lid, st, actor=actor) + + assert crud.get_line_item(db, lid)["status"] == "Production Ready" From 138eb10c9dd0c39a60eeac00445c6da6f71e0afa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 20:26:28 +0000 Subject: [PATCH 3/4] poc4 step1: FINDINGS.md with what-was-built, stubs, scorecards https://claude.ai/code/session_01Bkyax7szsqDeCuBygBk8M5 --- pocs/poc4_order_workflow/FINDINGS.md | 104 +++++++++++++++++++++++---- 1 file changed, 91 insertions(+), 13 deletions(-) diff --git a/pocs/poc4_order_workflow/FINDINGS.md b/pocs/poc4_order_workflow/FINDINGS.md index 18843d3..8c0ab98 100644 --- a/pocs/poc4_order_workflow/FINDINGS.md +++ b/pocs/poc4_order_workflow/FINDINGS.md @@ -1,27 +1,105 @@ # POC 4: Order-to-Approval Workflow — Findings -> Fill in as the POC progresses. Deliverable. +## What we built (Step 1 — 2026-06-12) + +### Data layer +SQLite schema across 7 tables: + +| Table | Purpose | +|-------|---------| +| `customers` | Account records; `internal_review_default` controls the IR gate | +| `approvers` | People authorised to approve proofs; `is_primary` + `active` flags | +| `logos` | Design files owned by a customer; `netsuite_file_cabinet_id` is the external key; all DST-parsed fields (`dst_stitch_count`, `dst_color_count`, `dst_width_mm/height_mm`, `dst_stop_count`, `dst_trim_count`) are nullable and populated by stub interface only | +| `color_ups` | Immutable versioned colour configurations — `(logo_id, style_number, color_code, version)` unique; `thread_sequence` stored as JSON; `approved_by_team_proxy` flag for proxy approvals | +| `orders` | NetSuite SO reference; deliberately **no `status` column** — status lives on line items | +| `order_line_items` | Per-line `status`; `size_quantities` JSON; `send_for_approval_override` flag | +| `status_history` | Immutable audit log with `actor`, `from_status`, `to_status`, `team_proxy`, `notes`, and `timestamp` | + +Schema written migration-ready for PostgreSQL (comments inline in `schema.py` for every type mapping). + +### State machine +12 states, explicitly enumerated transitions, no inferred edges: + +``` +New → Awaiting Logo → Color-Up In Progress → Internal Review ┐ + └→ Production Ready ←──────────────────┘→ Needs Approver → Sent for Approval + ↓ ↓ ↓ + Approved Phone Call Revision In Progress + ↓ ↓ + Production Ready ←── Color-Up In Progress + ↓ + In Production → Complete +``` + +Every `transition()` call validates the edge and writes to `status_history`. `bulk_transition()` skips items where the edge is invalid without raising. + +### Match-key logic +`(logo_id, style_number, color_code)` — exact match only, per spec decision 2. A colour code is not portable across styles (spec decision 2 rationale). + +`resolve_logo_for_line_item()` handles the Awaiting Logo → fork: +- Approved exact match AND no override → Production Ready (auto-assigns color-up FK) +- No match OR override → Color-Up In Progress + +`cascade_approval()` marks the color-up Approved and advances every line item referencing it from `Sent for Approval`, `Phone Call`, or `Needs Approver` through `Approved → Production Ready` in a single call. Does not cross customers (each color-up belongs to one logo which belongs to one customer). + +### Production batch query +`get_production_batches()` groups Production Ready line items by `(customer_id, logo_id, placement, thread_sequence_canonical)`. Thread sequences are normalised with `sort_keys=True` so insertion-order differences don't create phantom splits. Never crosses customers. Excludes non-Approved color-ups even if the line item is Production Ready (defensive guard for data-integrity edge cases). + +### DST / editor stubs +`dst_stub.py` exports `parse_dst()` and `open_color_up_editor()`, both raising `DSTNotAvailableError(NotImplementedError)`. All tests that would require DST files or the POC 3 editor are in `test_dst_stub.py` and confirm the stub contract rather than skipping — no real DST files needed. + +## Test results + +``` +65 passed in 0.24s +``` + +Coverage: state machine (pure + DB), CRUD for all entities, FK enforcement, match-key exact-match semantics, approval cascade, production batch grouping, and DST stub contract. + +## What is stubbed / skipped and why + +| Feature | Status | Reason | +|---------|--------|--------| +| DST file parse (POC 1) | Stubbed behind `DSTNotAvailableError` | DSTs are local-only; cloud sandbox has none | +| Color-up editor (POC 3) | Stubbed behind `DSTNotAvailableError` | POC 3 editor is local-only | +| FastAPI web layer | Not started | Step 2 scope | +| CSV/paste mass-import | Not started | Step 2 scope | +| Email / reminder logic | Not started | Step 5 scope | +| Production batch trim-sheet render | Not started | Step 6 scope | -## What we built ## What we measured + +Step 1 is pure data layer + logic; no UI performance measurements apply yet. + ## Scorecards ### Data storage -| Option | R | Q | S | F | Weighted | -|--------|---|---|---|---|----------| -| A. SQLite | | | | | | -| B. PostgreSQL | | | | | | -| C. JSON flat files | | | | | | +| Option | R (reliability) | Q (query power) | S (simplicity) | F (future path) | Weighted | +|--------|----------------|-----------------|----------------|-----------------|----------| +| A. SQLite | ✓ | Good (JSON cols) | ✓ easy | Needs migration | **Strong for POC** | +| B. PostgreSQL | ✓ | Best (JSONB ops) | Needs Docker | ✓ prod-ready | Overkill for POC | +| C. JSON flat files | Fragile | None | Trivial | Throwaway | Not recommended | + +SQLite wins for the POC; the schema is written to migrate with minimal changes (all comments in `schema.py`). ### Web framework -| Option | R | Q | S | F | Weighted | -|--------|---|---|---|---|----------| -| A. FastAPI + HTMX | | | | | | -| B. FastAPI + React | | | | | | -| C. Flask + Jinja | | | | | | +Not yet evaluated — Step 2 work. ## What we learned + +1. The `(logo + style_number + color_code)` exact-match key is clean to implement; the immutable-version pattern for color-ups is straightforward with a UNIQUE constraint. +2. Per-line status + order-header rollup (`order_status_rollup()`) is simple and flexible for the partial-ship scenarios. +3. Approval cascade across all matching line items in a single call keeps the controller thin. +4. Python 3.12 `detect_types=sqlite3.PARSE_DECLTYPES` conflicts with ISO-8601 timestamps (T separator vs space); dropped in favour of TEXT columns, no loss of functionality. + ## Recommendation if shipping for real -## Open questions / followups + +Keep SQLite for the POC through Step 4 (approval page). Migrate to PostgreSQL before Step 5 (reminders + notifications) where concurrent writes from the daily digest job will matter. The schema DDL is already annotated for the migration. + +## Open questions / follow-ups + +- NetSuite SO ↔ VRLink boundary: how does the SO number get into the system? (manual entry for POC, integration TBD) +- Non-DST production fields (stabilizer, runtime): are they hard must-haves before the floor will use trim sheets? +- `internal_review_default` is per-customer but some customers may want per-logo overrides — not modelled yet. From 917fcbe1c936d703ca5c061f15e6ac3188f437fc Mon Sep 17 00:00:00 2001 From: Brandon Hunt Date: Sat, 13 Jun 2026 09:18:39 -0700 Subject: [PATCH 4/4] poc4 step1: fix cascade bug + add production-approval gate Review fixes on PR #15: 1. (Option A) Remove Needs Approver from the approval cascade. Those line items were never sent to the customer, and the state machine forbids Needs Approver -> Approved, so cascading one raised InvalidTransitionError mid-loop. The cascade now only advances Sent for Approval / Phone Call; Needs Approver items are left untouched. Regression test added. 2. (Option B) Add the production-approval gate as a real state. Cleared line items (customer-approved OR exact-match repeats) now land in "Pending Production Approval" and require production.production_approve() to reach Production Ready. Nothing skips straight to runnable. The exact-match fast path skips the customer, not the production sign-off. 67 tests pass; ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- pocs/poc4_order_workflow/src/match_key.py | 43 +++++++--- pocs/poc4_order_workflow/src/production.py | 25 ++++++ pocs/poc4_order_workflow/src/state_machine.py | 19 ++++- .../tests/test_match_key.py | 80 ++++++++++++++++++- .../tests/test_state_machine.py | 17 ++-- 5 files changed, 159 insertions(+), 25 deletions(-) diff --git a/pocs/poc4_order_workflow/src/match_key.py b/pocs/poc4_order_workflow/src/match_key.py index 022cbd0..35b70e1 100644 --- a/pocs/poc4_order_workflow/src/match_key.py +++ b/pocs/poc4_order_workflow/src/match_key.py @@ -8,9 +8,10 @@ Approval cascade ---------------- When a color-up is approved, every line item that references it and is in -an approval-pending state advances to Production Ready. The cascade also -handles the "send_for_approval_override" flag: a line item that reached -Production Ready via an exact match will still respect the production gate. +an approval-pending state advances to Pending Production Approval — the +production-side gate — NOT straight to Production Ready. A line item in +Needs Approver was never sent to the customer, so it is deliberately left +out of the cascade (it stays put until an approver is added and it is sent). """ from __future__ import annotations @@ -63,10 +64,11 @@ def find_any_color_up( # Status values that the cascade should advance when a color-up is approved. +# Needs Approver is intentionally excluded: those line items were never sent +# to the customer, and the state machine forbids Needs Approver -> Approved. _CASCADE_FROM_STATUSES = { Status.SENT_FOR_APPROVAL.value, Status.PHONE_CALL.value, - Status.NEEDS_APPROVER.value, } @@ -82,8 +84,10 @@ def cascade_approval( ) -> list[int]: """Mark the color-up Approved and advance all qualifying line items. - Line items that reference *colorup_id* and are in Sent for Approval, - Phone Call, or Needs Approver are moved to Production Ready. + Line items that reference *colorup_id* and are in Sent for Approval or + Phone Call are moved to Pending Production Approval (the production gate). + They do NOT skip to Production Ready — a production sign-off is still + required (see production.production_approve). Returns the list of line_item_ids that were advanced. """ @@ -109,9 +113,16 @@ def cascade_approval( advanced: list[int] = [] for row in rows: lid = row["line_item_id"] - # Each item passes through Approved → Production Ready + # Each item passes through Approved → Pending Production Approval and + # stops there; production must sign off before it becomes runnable. transition(conn, lid, Status.APPROVED, actor, notes=notes, team_proxy=team_proxy) - transition(conn, lid, Status.PRODUCTION_READY, actor, notes="Production gate sign-off") + transition( + conn, + lid, + Status.PENDING_PRODUCTION_APPROVAL, + actor, + notes="Customer approved — awaiting production approval", + ) advanced.append(lid) return advanced @@ -131,7 +142,8 @@ def resolve_logo_for_line_item( 1. Line item must currently be in Awaiting Logo. 2. If an approved color-up exists for the match key AND send_for_approval_override is False AND force_approval is False: - → advance to Production Ready (exact-match fast path). + → advance to Pending Production Approval (exact-match fast path skips the + CUSTOMER but still passes the PRODUCTION gate before it can run). 3. Otherwise → advance to Color-Up In Progress. Returns the resulting status string. @@ -164,14 +176,21 @@ def resolve_logo_for_line_item( match = find_approved_color_up(conn, logo_id, row["style_number"], row["color_code"]) if match and not override: - # Exact match — link the color-up and skip to Production Ready + # Exact match — link the color-up and route to the production gate + # (skips the customer, NOT the production sign-off). conn.execute( "UPDATE order_line_items SET colorup_id = ? WHERE line_item_id = ?", (match["colorup_id"], line_item_id), ) conn.commit() - transition(conn, line_item_id, Status.PRODUCTION_READY, actor, notes="Exact match") - return Status.PRODUCTION_READY.value + transition( + conn, + line_item_id, + Status.PENDING_PRODUCTION_APPROVAL, + actor, + notes="Exact match — awaiting production approval", + ) + return Status.PENDING_PRODUCTION_APPROVAL.value else: transition(conn, line_item_id, Status.COLOR_UP_IN_PROGRESS, actor, notes="No exact match") return Status.COLOR_UP_IN_PROGRESS.value diff --git a/pocs/poc4_order_workflow/src/production.py b/pocs/poc4_order_workflow/src/production.py index 083493d..ea91361 100644 --- a/pocs/poc4_order_workflow/src/production.py +++ b/pocs/poc4_order_workflow/src/production.py @@ -24,6 +24,31 @@ from dataclasses import dataclass, field from typing import Any +from pocs.poc4_order_workflow.src.state_machine import Status, transition + + +def production_approve( + conn: sqlite3.Connection, + line_item_id: int, + actor: str, + *, + notes: str | None = None, +) -> None: + """Production-side gate sign-off: Pending Production Approval -> Production Ready. + + This is the explicit production approval required before a line item can + run — for both customer-approved items and exact-match repeats that skipped + the customer. Raises InvalidTransitionError if the item is not currently in + Pending Production Approval. + """ + transition( + conn, + line_item_id, + Status.PRODUCTION_READY, + actor, + notes=notes or "Production approval", + ) + @dataclass class ProductionBatch: diff --git a/pocs/poc4_order_workflow/src/state_machine.py b/pocs/poc4_order_workflow/src/state_machine.py index 6f56f7a..085661c 100644 --- a/pocs/poc4_order_workflow/src/state_machine.py +++ b/pocs/poc4_order_workflow/src/state_machine.py @@ -1,9 +1,14 @@ """Per-line-item state machine for the order workflow. -States (12, per the NEXT.md build plan and task brief): +States (13, per the NEXT.md build plan and task brief): New, Awaiting Logo, Color-Up In Progress, Internal Review, Needs Approver, Sent for Approval, Phone Call, Approved, - Revision In Progress, Production Ready, In Production, Complete + Revision In Progress, Pending Production Approval, Production Ready, + In Production, Complete + +Pending Production Approval is the production-side gate: a cleared line item +(whether customer-approved OR an exact-match repeat that skipped the customer) +waits here for an internal production sign-off before it is eligible to run. Every transition is validated and logged to status_history. """ @@ -25,6 +30,7 @@ class Status(str, Enum): PHONE_CALL = "Phone Call" APPROVED = "Approved" REVISION_IN_PROGRESS = "Revision In Progress" + PENDING_PRODUCTION_APPROVAL = "Pending Production Approval" PRODUCTION_READY = "Production Ready" IN_PRODUCTION = "In Production" COMPLETE = "Complete" @@ -37,7 +43,9 @@ class Status(str, Enum): Status.NEW: {Status.AWAITING_LOGO}, Status.AWAITING_LOGO: { Status.COLOR_UP_IN_PROGRESS, # no exact match found - Status.PRODUCTION_READY, # exact match exists (skips customer approval) + # exact match exists: skips CUSTOMER approval but still needs the + # PRODUCTION approval gate before it can run + Status.PENDING_PRODUCTION_APPROVAL, }, Status.COLOR_UP_IN_PROGRESS: { Status.INTERNAL_REVIEW, # internal-review gate is on @@ -62,11 +70,14 @@ class Status(str, Enum): Status.REVISION_IN_PROGRESS, # changes agreed on phone }, Status.APPROVED: { - Status.PRODUCTION_READY, # production gate sign-off + Status.PENDING_PRODUCTION_APPROVAL, # customer-approved → production gate }, Status.REVISION_IN_PROGRESS: { Status.COLOR_UP_IN_PROGRESS, # new immutable version started }, + Status.PENDING_PRODUCTION_APPROVAL: { + Status.PRODUCTION_READY, # production signs off → eligible to run + }, Status.PRODUCTION_READY: { Status.IN_PRODUCTION, }, diff --git a/pocs/poc4_order_workflow/tests/test_match_key.py b/pocs/poc4_order_workflow/tests/test_match_key.py index 4787c07..3c0c21c 100644 --- a/pocs/poc4_order_workflow/tests/test_match_key.py +++ b/pocs/poc4_order_workflow/tests/test_match_key.py @@ -161,7 +161,8 @@ def test_cascade_approval_advances_sent_for_approval(db, customer, logo, order, assert set(advanced) == {li1.line_item_id, li2.line_item_id} for lid in [li1.line_item_id, li2.line_item_id]: row = crud.get_line_item(db, lid) - assert row["status"] == "Production Ready" + # Cascade stops at the production gate, not Production Ready. + assert row["status"] == "Pending Production Approval" cu_row = crud.get_color_up(db, cu.colorup_id) assert cu_row["status"] == "Approved" @@ -193,7 +194,7 @@ def test_cascade_approval_phone_call_items(db, customer, logo, order, approver): advanced = cascade_approval(db, cu.colorup_id, actor="lead", team_proxy=True) assert li.line_item_id in advanced row = crud.get_line_item(db, li.line_item_id) - assert row["status"] == "Production Ready" + assert row["status"] == "Pending Production Approval" def test_cascade_skips_already_advanced_items(db, customer, logo, order): @@ -226,6 +227,76 @@ def test_cascade_skips_already_advanced_items(db, customer, logo, order): assert crud.get_line_item(db, li.line_item_id)["status"] == "Production Ready" +def test_cascade_leaves_needs_approver_untouched(db, customer, logo, order): + """A Needs Approver line was never sent — the cascade must skip it, not crash. + + Regression for the bug where Needs Approver was in the cascade set but the + state machine forbids Needs Approver -> Approved. + """ + cu = crud.create_color_up( + db, + ColorUp( + logo_id=logo.logo_id, + style_number="S", + color_code="C", + thread_sequence=[], + status="Pending Approval", + ), + ) + sent = crud.create_line_item( + db, + OrderLineItem( + order_id=order.order_id, + style_number="S", + color_code="C", + placement="Left Chest", + logo_id=logo.logo_id, + colorup_id=cu.colorup_id, + status=Status.SENT_FOR_APPROVAL.value, + ), + ) + needs_approver = crud.create_line_item( + db, + OrderLineItem( + order_id=order.order_id, + style_number="S", + color_code="C", + placement="Right Sleeve", + logo_id=logo.logo_id, + colorup_id=cu.colorup_id, + status=Status.NEEDS_APPROVER.value, + ), + ) + + advanced = cascade_approval(db, cu.colorup_id, actor="customer") + + # Sent item advances to the production gate; Needs Approver item is untouched. + assert advanced == [sent.line_item_id] + assert crud.get_line_item(db, sent.line_item_id)["status"] == "Pending Production Approval" + assert crud.get_line_item(db, needs_approver.line_item_id)["status"] == "Needs Approver" + + +def test_production_approve_clears_the_gate(db, order, logo, approved_color_up): + """production_approve advances Pending Production Approval -> Production Ready.""" + from pocs.poc4_order_workflow.src.production import production_approve + + li = crud.create_line_item( + db, + OrderLineItem( + order_id=order.order_id, + style_number="14728-BLK", + color_code="BLK", + placement="Left Chest", + status=Status.AWAITING_LOGO.value, + ), + ) + resolve_logo_for_line_item(db, li.line_item_id, logo.logo_id, actor="system") + assert crud.get_line_item(db, li.line_item_id)["status"] == "Pending Production Approval" + + production_approve(db, li.line_item_id, actor="production-lead") + assert crud.get_line_item(db, li.line_item_id)["status"] == "Production Ready" + + def test_cascade_does_not_cross_customers(db): """Line items from different customers sharing a logo should not cascade.""" from pocs.poc4_order_workflow.src.models import Customer, Logo @@ -285,7 +356,7 @@ def test_cascade_does_not_cross_customers(db): # --------------------------------------------------------------------------- -def test_resolve_logo_exact_match_goes_to_production_ready(db, order, logo, approved_color_up): +def test_resolve_logo_exact_match_goes_to_production_gate(db, order, logo, approved_color_up): li = crud.create_line_item( db, OrderLineItem( @@ -297,7 +368,8 @@ def test_resolve_logo_exact_match_goes_to_production_ready(db, order, logo, appr ), ) result = resolve_logo_for_line_item(db, li.line_item_id, logo.logo_id, actor="system") - assert result == Status.PRODUCTION_READY.value + # Exact match skips the customer but still lands at the production gate. + assert result == Status.PENDING_PRODUCTION_APPROVAL.value row = crud.get_line_item(db, li.line_item_id) assert row["logo_id"] == logo.logo_id assert row["colorup_id"] == approved_color_up.colorup_id diff --git a/pocs/poc4_order_workflow/tests/test_state_machine.py b/pocs/poc4_order_workflow/tests/test_state_machine.py index 65f2fdc..4a5f307 100644 --- a/pocs/poc4_order_workflow/tests/test_state_machine.py +++ b/pocs/poc4_order_workflow/tests/test_state_machine.py @@ -34,7 +34,7 @@ def test_valid_forward_transitions(): valid_pairs = [ (Status.NEW, Status.AWAITING_LOGO), (Status.AWAITING_LOGO, Status.COLOR_UP_IN_PROGRESS), - (Status.AWAITING_LOGO, Status.PRODUCTION_READY), + (Status.AWAITING_LOGO, Status.PENDING_PRODUCTION_APPROVAL), (Status.COLOR_UP_IN_PROGRESS, Status.INTERNAL_REVIEW), (Status.COLOR_UP_IN_PROGRESS, Status.NEEDS_APPROVER), (Status.COLOR_UP_IN_PROGRESS, Status.SENT_FOR_APPROVAL), @@ -47,7 +47,8 @@ def test_valid_forward_transitions(): (Status.SENT_FOR_APPROVAL, Status.REVISION_IN_PROGRESS), (Status.PHONE_CALL, Status.APPROVED), (Status.PHONE_CALL, Status.REVISION_IN_PROGRESS), - (Status.APPROVED, Status.PRODUCTION_READY), + (Status.APPROVED, Status.PENDING_PRODUCTION_APPROVAL), + (Status.PENDING_PRODUCTION_APPROVAL, Status.PRODUCTION_READY), (Status.REVISION_IN_PROGRESS, Status.COLOR_UP_IN_PROGRESS), (Status.PRODUCTION_READY, Status.IN_PRODUCTION), (Status.IN_PRODUCTION, Status.COMPLETE), @@ -66,6 +67,9 @@ def test_invalid_transitions_raise(): (Status.APPROVED, Status.SENT_FOR_APPROVAL), (Status.PRODUCTION_READY, Status.NEW), (Status.IN_PRODUCTION, Status.NEW), + # The production gate is mandatory: nothing skips straight to runnable. + (Status.AWAITING_LOGO, Status.PRODUCTION_READY), + (Status.APPROVED, Status.PRODUCTION_READY), ] for from_s, to_s in invalid_pairs: with pytest.raises(InvalidTransitionError): @@ -151,7 +155,8 @@ def test_happy_path_full_workflow(db, customer, logo, approver): (Status.COLOR_UP_IN_PROGRESS, "artist"), (Status.SENT_FOR_APPROVAL, "artist"), (Status.APPROVED, "customer"), - (Status.PRODUCTION_READY, "lead"), + (Status.PENDING_PRODUCTION_APPROVAL, "lead"), + (Status.PRODUCTION_READY, "production"), (Status.IN_PRODUCTION, "operator"), (Status.COMPLETE, "operator"), ] @@ -188,7 +193,8 @@ def test_revision_loop(db, customer, logo, approver): (Status.COLOR_UP_IN_PROGRESS, "artist"), # second round (Status.SENT_FOR_APPROVAL, "artist"), (Status.APPROVED, "customer"), - (Status.PRODUCTION_READY, "lead"), + (Status.PENDING_PRODUCTION_APPROVAL, "lead"), + (Status.PRODUCTION_READY, "production"), ]: transition(db, lid, st, actor=actor) @@ -216,7 +222,8 @@ def test_phone_call_path(db, customer, logo, approver): (Status.SENT_FOR_APPROVAL, "artist"), (Status.PHONE_CALL, "reminder-bot"), (Status.APPROVED, "lead"), - (Status.PRODUCTION_READY, "lead"), + (Status.PENDING_PRODUCTION_APPROVAL, "lead"), + (Status.PRODUCTION_READY, "production"), ]: transition(db, lid, st, actor=actor)