kitchen/docs/archive/PURCHASE-ORDER-PLAN.md
jtricerolph 8d688b459d Initial kitchen scaffold — Phase 1 kitchen port (build-verified 2026-07-11)
FastAPI backend (Python 3.11, MSSQL ODBC for SambaPOS, Azure DI OCR),
kitchen_db on central PG. React/TS/Vite frontend with navy sidebar layout.

Backend: auth.py (APP_SLUG=kitchen, SimpleNamespace — archive routes use
.kitchen_id/.is_admin without modification), main.py (51 migrations, scheduler,
internal router for KDS bookings feed), api/internal.py, full archive API
(31 routers: invoices, recipes, menus, sambapos, resos, newbook, disputes,
purchase_orders, etc.), models, migrations, OCR pipeline.
kitchen_id pinned to 1 (B1 — single hotel).

Frontend: AuthGate (app=kitchen, token shim for archive compat — B5b pending),
Layout (navy sidebar, 6 sections, Lucide icons, teal --app-primary),
App.tsx (Outlet pattern, UploadApp outside Layout), index.css (full :root block).
strict: false — archive components have type issues; build clean.

Note: 45 archive components call fetch('/api/...') without /kitchen/ prefix
(B5b). Runtime 404s; deferred until after initial testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 12:15:39 +00:00

424 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Purchase Order System
## Context
Users want to pre-allocate budget for known upcoming orders on the Spend Budget page. Clicking on a budget table cell (supplier × date) opens a PO creation modal. POs appear on the budget table in blue/italic (distinct from green invoices). When a real invoice arrives, it can be linked to the PO, which replaces the PO value in the budget. This gives visibility into planned spend before invoices arrive.
## Phased Plan
### Phase 1: Core PO System (DB, API, Modal, Budget Integration, List Page)
### Phase 2: Supplier & Kitchen Settings (order_email, account_number, kitchen details)
### Phase 3: Preview & Email (print view, Save & Email using existing SMTP)
### Phase 4: Invoice Matching (auto-suggest, banner, linking)
---
## Phase 1: Core PO System
### 1.1 Database Tables
**`purchase_orders`** table:
```sql
CREATE TABLE IF NOT EXISTS purchase_orders (
id SERIAL PRIMARY KEY,
kitchen_id INTEGER NOT NULL REFERENCES kitchens(id),
supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
order_date DATE NOT NULL, -- budget date this PO sits on
order_type VARCHAR(20) NOT NULL, -- 'itemised' or 'single_value'
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', -- DRAFT, PENDING, LINKED, CLOSED, CANCELLED
total_amount NUMERIC(12,2), -- for single_value orders
order_reference VARCHAR(200), -- external order number
notes TEXT,
attachment_path VARCHAR(500), -- uploaded photo/file
attachment_original_name VARCHAR(255),
linked_invoice_id INTEGER REFERENCES invoices(id) ON DELETE SET NULL,
created_by INTEGER NOT NULL REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW(),
updated_by INTEGER REFERENCES users(id),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_po_kitchen_date ON purchase_orders(kitchen_id, order_date);
CREATE INDEX IF NOT EXISTS idx_po_kitchen_supplier ON purchase_orders(kitchen_id, supplier_id);
CREATE INDEX IF NOT EXISTS idx_po_kitchen_status ON purchase_orders(kitchen_id, status);
```
**`purchase_order_line_items`** table:
```sql
CREATE TABLE IF NOT EXISTS purchase_order_line_items (
id SERIAL PRIMARY KEY,
purchase_order_id INTEGER NOT NULL REFERENCES purchase_orders(id) ON DELETE CASCADE,
kitchen_id INTEGER NOT NULL REFERENCES kitchens(id),
product_id INTEGER, -- nullable for manual entries
product_code VARCHAR(100),
description VARCHAR(500) NOT NULL,
unit VARCHAR(50),
unit_price NUMERIC(12,4) NOT NULL,
quantity NUMERIC(10,3) NOT NULL,
total NUMERIC(12,2) NOT NULL,
line_number INTEGER DEFAULT 0,
source VARCHAR(20) DEFAULT 'manual', -- 'search' or 'manual'
created_at TIMESTAMP DEFAULT NOW()
);
```
### 1.2 Files to Create
| File | Purpose |
|------|---------|
| `backend/models/purchase_order.py` | SQLAlchemy models: PurchaseOrder + PurchaseOrderLineItem |
| `backend/migrations/add_purchase_orders.py` | Migration creating both tables + indexes |
| `backend/api/purchase_orders.py` | Full CRUD API router |
| `frontend/src/components/PurchaseOrderModal.tsx` | Create/edit PO modal |
| `frontend/src/components/PurchaseOrderList.tsx` | PO list page with filters |
### 1.3 Files to Modify
| File | Changes |
|------|---------|
| `backend/models/__init__.py` | Import + register PurchaseOrder, PurchaseOrderLineItem |
| `backend/models/supplier.py` | Add `purchase_orders` relationship |
| `backend/main.py` | Register router + migration |
| `backend/api/budget.py` | Include POs in SupplierBudgetRow, add `purchase_orders_by_date` |
| `frontend/src/App.tsx` | Add route `/purchase-orders` + nav item in Invoices dropdown |
| `frontend/src/components/Budget.tsx` | Render POs in cells, add cell click → PO modal, PO styles |
### 1.4 API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/purchase-orders/` | Create PO (with line items) |
| `GET` | `/api/purchase-orders/` | List POs (query: status, supplier_id, date_from, date_to, limit, offset) |
| `GET` | `/api/purchase-orders/{po_id}` | Get PO detail with line items |
| `PUT` | `/api/purchase-orders/{po_id}` | Update PO (full replacement of line items) |
| `DELETE` | `/api/purchase-orders/{po_id}` | Delete PO (only DRAFT/CANCELLED) |
| `PUT` | `/api/purchase-orders/{po_id}/status` | Update status only (close, cancel) |
| `POST` | `/api/purchase-orders/{po_id}/attachment` | Upload attachment (multipart) |
| `DELETE` | `/api/purchase-orders/{po_id}/attachment` | Remove attachment |
| `GET` | `/api/purchase-orders/products/search` | Search products filtered by supplier_id |
| `GET` | `/api/purchase-orders/by-date` | POs for budget table (week_start, week_end) → `{supplier_id: {date: [PO]}}` |
### 1.5 PO Modal Structure (PurchaseOrderModal.tsx)
Follows WastageLogbook `CreateEntryModal` pattern (same modal overlay, header, CSS-in-JS):
```
┌──────────────────────────────────────────────────┐
│ Purchase Order [× Close]│
├──────────────────────────────────────────────────┤
│ Date: [2026-02-12] │ Notes: [optional textarea] │
│ Supplier: [name ▾] │ │
├──────────────────────────────────────────────────┤
│ [Itemised Order] | [Single Value] ← tab btns │
├──────────────────────────────────────────────────┤
│ IF Itemised: │
│ Line Items (3) │
│ ┌──────────────┬───────┬─────┬────────┬──┐ │
│ │ Product │ Price │ Qty │ Total │ ×│ │
│ ├──────────────┼───────┼─────┼────────┼──┤ │
│ │ Chicken 1kg │ 5.50 │ 10 │ 55.00 │ ×│ │
│ │ [manual] │ [inp] │[inp]│ [calc] │ ×│ │
│ └──────────────┴───────┴─────┴────────┴──┘ │
│ [+ Add Manual Item] │
│ │
│ Search Products (filtered to supplier): │
│ [🔍 Search by name or code...] │
│ ┌──────┬────────────┬──────┬───────┬─────┐ │
│ │ Code │ Product │ Unit │ Price │ Add │ │
│ └──────┴────────────┴──────┴───────┴─────┘ │
│ │
│ IF Single Value: │
│ Order Value: [£ ___.__] │
│ Order Ref: [optional] │
│ Attachment: [Upload] or [preview / remove] │
│ │
│ Total: £XX.XX │
├──────────────────────────────────────────────────┤
│ [Cancel] [Save Draft] [Save & Submit]│
└──────────────────────────────────────────────────┘
```
Key behaviors:
- Opened from budget cell click: supplier_id + order_date pre-populated
- Opened from PO list or budget PO button: loads existing PO for editing
- Product search filtered by supplier via `/api/purchase-orders/products/search?query=X&supplier_id=Y`
- Unit Price column before Qty (as requested)
- Auto-calc: `total = unit_price × quantity`
- Search result items show product_code when defined
- File upload via FormData to `/api/purchase-orders/{id}/attachment`
### 1.6 Budget Table Integration
**Backend** (`budget.py`): Add to `get_weekly_budget()`:
- Query `purchase_orders` where kitchen_id matches, status IN ('DRAFT','PENDING'), order_date in week range
- Group by supplier_id + order_date
- Add `purchase_orders_by_date` field to `SupplierBudgetRow`
- PO totals are shown visually but **not** added to `actual_spent` (they're planned, not actual)
- Ensure suppliers with POs but no invoices still appear in the table
**Frontend** (`Budget.tsx`): In the supplier row cell rendering (lines 952-981):
- After rendering invoices, also render POs from `supplier.purchase_orders_by_date[d]`
- PO buttons styled differently: blue text, dashed border, italic, "PO" suffix
- Empty future cells become clickable → open PO modal with that supplier+date
- Clicking existing PO button → open PO modal in edit mode
**PO button style** (distinct from invoiceBtn):
```typescript
poBtn: {
padding: '0.25rem 0.5rem',
background: '#e3f2fd',
border: '1px dashed #42a5f5',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '0.8rem',
fontStyle: 'italic',
color: '#1565c0',
whiteSpace: 'nowrap',
}
```
**Invoice buttons changed to green** (user requested invoices = green, POs = blue):
```typescript
invoiceBtn: {
...existing,
background: '#d4edda', // was #e3f2fd (blue)
border: '1px solid #28a745', // was #90caf9
color: '#155724',
}
```
### 1.6b Budget Table Columns Update
Current columns: Supplier | days... | Budget | Spent | Remaining | Status
New columns: Supplier | days... | Budget | Spent | **Ordered** | Remaining | Status
- **Spent** = actual invoices only (unchanged)
- **Ordered** (NEW) = sum of pending PO totals (DRAFT + PENDING) for this supplier this week
- **Remaining** = Budget Spent **Ordered** (POs count as committed spend)
- Remove "OVER" text label from remaining column — red negative value is clear enough
- Status badge logic unchanged (uses remaining value which now factors in POs)
Backend `SupplierBudgetRow` additions:
```python
po_ordered: Decimal # sum of PO totals for this supplier this week (DRAFT + PENDING)
# remaining recalculated: allocated_budget - actual_spent - po_ordered
```
### 1.7 PO List Page (PurchaseOrderList.tsx)
Route: `/purchase-orders` (added to Invoices dropdown after "Disputes")
Layout similar to Disputes.tsx:
- Header: "Purchase Orders" + [+ New PO] button
- Filter bar: Status tabs (All | Draft | Pending | Linked | Closed), Supplier dropdown, Date range
- Default filter: Draft + Pending (open POs)
- Table: Date | Supplier | Type | Reference | Total | Status | Created
- Status badges: DRAFT=grey, PENDING=blue, LINKED=green, CLOSED=dark grey, CANCELLED=red
- Row click → open PO in edit modal
### 1.8 Verification (Phase 1)
1. Click empty future cell on budget table → PO modal opens with correct supplier+date
2. Create itemised PO with search items + manual items → appears on budget table in blue/italic/dashed
3. Create single-value PO with attachment → appears correctly
4. Click PO on budget table → edit modal opens with all data
5. PO list page shows all POs with working status filters
6. Edit PO, change line items → total recalculates
7. Delete PO (DRAFT only) → disappears from budget + list
8. Close PO → status changes, no longer on budget table, removed from "Ordered"
9. "Ordered" column shows PO totals; "Remaining" = Budget Spent Ordered
10. Invoice buttons now green, PO buttons blue/dashed/italic
11. No "OVER" text on remaining column — red negative value is sufficient
---
## Phase 2: Supplier & Kitchen Settings
### 2.1 Supplier Model Additions
Add to `backend/models/supplier.py`:
```python
order_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
account_number: Mapped[str | None] = mapped_column(String(100), nullable=True)
```
**Migration**: `backend/migrations/add_supplier_po_fields.py`
- `ALTER TABLE suppliers ADD COLUMN order_email VARCHAR(255)`
- `ALTER TABLE suppliers ADD COLUMN account_number VARCHAR(100)`
**Modify**: `backend/api/suppliers.py` - add fields to create/update schemas
**Modify**: `frontend/src/components/Suppliers.tsx` - add form fields for order_email + account_number
### 2.2 Kitchen Details Settings Tab
Add to `backend/models/settings.py`:
```python
kitchen_display_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
kitchen_address_line1: Mapped[str | None] = mapped_column(String(255), nullable=True)
kitchen_address_line2: Mapped[str | None] = mapped_column(String(255), nullable=True)
kitchen_city: Mapped[str | None] = mapped_column(String(100), nullable=True)
kitchen_postcode: Mapped[str | None] = mapped_column(String(20), nullable=True)
kitchen_phone: Mapped[str | None] = mapped_column(String(50), nullable=True)
kitchen_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
```
**Migration**: `backend/migrations/add_kitchen_details.py`
**Modify**: `backend/api/settings.py` - add GET/PUT for kitchen details
**Modify**: `frontend/src/pages/Settings.tsx` - new "Kitchen Details" tab with form fields
### 2.3 SMTP Already Exists
SMTP settings already in `KitchenSettings` (lines 96-102). Email service at `backend/services/email_service.py` with `send_email()`. Settings UI already exposes SMTP fields. Test endpoint at `POST /api/settings/test-smtp`. **No new work needed for SMTP infrastructure.**
### 2.4 Verification (Phase 2)
1. Add order_email + account_number to a supplier → verify saved/displayed
2. Fill in Kitchen Details in Settings → verify persisted
3. Verify SMTP test still works from Settings
---
## Phase 3: Preview & Email
### 3.1 PO Preview/Print View
**Add endpoint**: `GET /api/purchase-orders/{po_id}/preview`
- Returns clean HTML page with:
- Kitchen letterhead (name, address, phone, email from KitchenSettings)
- "PURCHASE ORDER" title + PO number (PO-{id})
- Date, Supplier name, Supplier account number
- Items table (Code | Description | Unit | Price | Qty | Total) or single value
- Total
- Notes
- Print-friendly CSS (`@media print` styles)
**Frontend**: Add buttons to PO modal footer:
- "Save & Preview" → saves PO, opens `/api/purchase-orders/{id}/preview` in new tab
- "Preview" (when no unsaved changes) → opens preview directly
### 3.2 PO Email Sending
**Add endpoint**: `POST /api/purchase-orders/{po_id}/send-email`
- Loads PO + supplier → checks supplier.order_email exists
- Checks SMTP configured in settings
- Generates PO HTML (reuse preview template)
- Sends via existing `EmailService.send_email()` from `backend/services/email_service.py`
- Updates PO status to PENDING if currently DRAFT
**Frontend**: Add "Save & Email" button to PO modal (shown only when supplier has order_email AND SMTP configured)
- Saves PO, calls send-email endpoint, shows success/error message
### 3.3 Verification (Phase 3)
1. Click "Save & Preview" → new tab with clean formatted PO
2. Print PO from preview → verify layout
3. Set supplier order_email + SMTP config → "Save & Email" button appears
4. Send PO email → verify received with correct content
5. PO status changes to PENDING after email sent
---
## Phase 4: Invoice Matching
### 4.1 PO Matching Service
**File**: `backend/services/po_matching.py` (NEW)
```python
class POMatchingService:
async def find_matching_pos(db, kitchen_id, supplier_id, invoice_date=None):
"""Find pending POs for supplier, ordered by date proximity"""
# status IN ('DRAFT', 'PENDING'), order_date within ±7 days
def calculate_match_confidence(po, invoice) -> float:
"""Score 0-1: supplier match (+0.4), date proximity (+0.3), amount similarity (+0.3)"""
async def link_po_to_invoice(db, po_id, invoice_id, user_id):
"""Set PO status=LINKED, linked_invoice_id=invoice_id"""
async def unlink_po(db, po_id, user_id):
"""Reset PO status=PENDING, linked_invoice_id=None"""
```
### 4.2 API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/purchase-orders/matching?invoice_id={id}` | Find POs matching an invoice |
| `POST` | `/api/purchase-orders/{po_id}/link` | Link PO to invoice `{invoice_id}` |
| `POST` | `/api/purchase-orders/{po_id}/unlink` | Unlink PO from invoice |
### 4.3 Invoice Detail — PO Banner & Linked PO Display
**Modify**: `frontend/src/components/Review.tsx`
**A) Already-linked PO indicator** (shown when invoice has a linked PO):
- At top of invoice detail, show a compact info bar: "Linked to PO-42 (£125.00, Mon 10 Feb)" with a [View PO] button
- Clicking [View PO] opens the PurchaseOrderModal in read/edit mode for that PO
- Also show [Unlink] button to remove the link (returns PO to PENDING)
**B) Matching PO banner** (shown when invoice has NO linked PO but supplier has pending POs):
- Fetch matching POs via `/api/purchase-orders/matching?invoice_id={id}`
- Show info banner: "This supplier has N pending Purchase Orders"
- List each PO: `PO-42: £125.00 (Mon 10 Feb) [Link to this Invoice]`
- High-confidence matches (>0.8) highlighted with "Suggested match" label
- "Link" button calls POST link endpoint → PO becomes LINKED, banner switches to linked indicator (A)
### 4.4 Budget Table Behavior When Linked
When PO status = LINKED:
- PO **no longer** appears in `purchase_orders_by_date` on budget table
- The linked invoice naturally appears in `invoices_by_date` (it's a real invoice)
- Budget transitions seamlessly from showing planned PO → actual invoice
### 4.5 Verification (Phase 4)
1. Create PO for supplier + date, then upload invoice from same supplier
2. Invoice review page shows matching PO banner with pending POs
3. Link PO → status changes to LINKED, disappears from budget, invoice takes its place
4. Invoice review page now shows "Linked to PO-42" indicator with [View PO] button
5. Click [View PO] → PO modal opens with correct data
6. Unlink PO → returns to PENDING, reappears on budget, banner switches back to matching list
7. Auto-suggest works for high-confidence match (same supplier + close date + similar amount)
8. Banner always shows when supplier has any pending POs
---
## Key Existing Code to Reuse
| Existing Code | File | Reuse For |
|---------------|------|-----------|
| Modal overlay + header pattern | `WastageLogbook.tsx:541-997` | PO modal structure |
| Line item builder (add/update/remove) | `WastageLogbook.tsx` state handlers | PO itemised line items |
| Product search (debounced, deduped) | `WastageLogbook.tsx` + `/api/logbook/products/search` | PO product search (add supplier filter) |
| File upload (FormData + UUID naming) | `invoices.py` upload handler | PO attachment upload |
| Email sending (SMTP) | `backend/services/email_service.py` | PO email (Phase 3) |
| Invoice cell rendering on budget | `Budget.tsx:958-975` | PO cell rendering (same pattern, different style) |
| Dispute status badges | `Disputes.tsx` | PO status badges |
| Settings tab pattern | `Settings.tsx` | Kitchen Details tab (Phase 2) |
| Supplier form | `Suppliers.tsx` | Add order_email/account_number fields (Phase 2) |
## Complete File List (All Phases)
### New Files
1. `backend/models/purchase_order.py` — PO + line item models (Phase 1)
2. `backend/migrations/add_purchase_orders.py` — Create tables (Phase 1)
3. `backend/api/purchase_orders.py` — Full PO API (Phase 1, extended Phase 3-4)
4. `frontend/src/components/PurchaseOrderModal.tsx` — Create/edit modal (Phase 1)
5. `frontend/src/components/PurchaseOrderList.tsx` — PO list page (Phase 1)
6. `backend/migrations/add_supplier_po_fields.py` — Supplier order_email + account_number (Phase 2)
7. `backend/migrations/add_kitchen_details.py` — Kitchen detail columns (Phase 2)
8. `backend/services/po_matching.py` — PO-invoice matching service (Phase 4)
### Modified Files
1. `backend/models/__init__.py` — Register PO models (Phase 1)
2. `backend/models/supplier.py` — Add purchase_orders relationship + order_email + account_number (Phase 1+2)
3. `backend/main.py` — Register router + migrations (Phase 1+2)
4. `backend/api/budget.py` — Include POs in budget response (Phase 1)
5. `frontend/src/App.tsx` — Route + nav item (Phase 1)
6. `frontend/src/components/Budget.tsx` — PO cells + click handler + invoice color change (Phase 1)
7. `backend/api/suppliers.py` — Add new fields to schemas (Phase 2)
8. `frontend/src/components/Suppliers.tsx` — Add form fields (Phase 2)
9. `backend/models/settings.py` — Kitchen detail columns (Phase 2)
10. `backend/api/settings.py` — Kitchen details endpoints (Phase 2)
11. `frontend/src/pages/Settings.tsx` — Kitchen Details tab (Phase 2)
12. `frontend/src/components/Review.tsx` — PO matching banner (Phase 4)