From 2029ae7083e3e7ed5167328cf48d8ed25f0b0324 Mon Sep 17 00:00:00 2001 From: Hardik Date: Sun, 21 Jun 2026 12:23:09 +0530 Subject: [PATCH] docs: PO cancellation + supersede (#53), inventory-reversal tech debt (#55) - PO-Lifecycle: CANCELLED status + Cancellation & supersede section - Roles: cancel_po (MANAGER + SUPERUSER) - Purchase-Orders: cancelled POs exportable with CANCELLED watermark - Data-Model: cancelledAt/cancellationReason/supersededById - Tech-Debt: TD-3 inventory increments not reversed on cancel (#55) Co-Authored-By: Claude Opus 4.8 --- Data-Model.md | 2 ++ PO-Lifecycle.md | 23 +++++++++++++++++++++++ Purchase-Orders.md | 10 +++++++--- Roles-and-Permissions.md | 1 + Tech-Debt.md | 20 ++++++++++++++++++++ 5 files changed, 53 insertions(+), 3 deletions(-) diff --git a/Data-Model.md b/Data-Model.md index 24e7468..220232d 100644 --- a/Data-Model.md +++ b/Data-Model.md @@ -75,6 +75,8 @@ The central entity. Key fields: | `piQuotationNo/Date?`, `requisitionNo/Date?`, `placeOfDelivery?` | quotation/requisition metadata | | `tcDelivery / tcDispatch / tcInspection / tcTransitInsurance / tcPaymentTerms / tcOthers` | Terms & Conditions text | | `submittedAt / approvedAt / paidAt / closedAt / createdAt / updatedAt` | lifecycle timestamps | +| `cancelledAt? / cancellationReason?` | set when a manager/superuser cancels the PO | +| `supersededById?` | self-relation → the existing PO that replaces this cancelled one (reciprocal `supersedes`) | Required FKs: `submitterId → User`, `vesselId → Vessel` (**cost centre**), `accountId → Account` (**accounting code**). Optional FKs: `companyId`, diff --git a/PO-Lifecycle.md b/PO-Lifecycle.md index 3877e80..c154ad0 100644 --- a/PO-Lifecycle.md +++ b/PO-Lifecycle.md @@ -99,3 +99,26 @@ Each status renders a colour-coded pill (`components/po/po-status-badge.tsx`): | PARTIALLY_CLOSED | green-adjacent | | CLOSED | green/success | | REJECTED | red/danger | +| CANCELLED | red/danger (greyed row in history) | + +## Cancellation & supersede + +Orthogonal to the lifecycle above: a **MANAGER** or **SUPERUSER** can **cancel** a PO +from **any** state (permission `cancel_po`; `lib/po-state-machine.ts` → `canCancel`). The +UI requires typing the word `cancel` and a **mandatory reason** in a confirmation modal +(`components/po/cancel-po-controls.tsx`); the action (`cancelPo`) sets a terminal +`CANCELLED` status with `cancelledAt`/`cancellationReason`, logs a `CANCELLED` audit row, +and notifies the submitter + Accounts. + +A cancelled PO's value **drops out of every spend tracker/graph** automatically — those +filter on `POST_APPROVAL_STATUSES` or explicit whitelists, none of which include +`CANCELLED`. Cancelled POs stay visible (greyed in history) and **exportable**, but the +PDF/XLSX export carries a diagonal **CANCELLED** watermark. + +A cancelled PO may optionally be **superseded** by the existing PO that replaces it +(`supersedePo`, by PO number; self-referential `supersededById`). No vessel/account/vendor +match is enforced, the link can be added any time, and the replacement shows the +reciprocal "supersedes" link. + +> Approval-time inventory increments are **not** reversed on cancel yet — deferred (see +> [Tech Debt](Tech-Debt); inventory is feature-flagged off). diff --git a/Purchase-Orders.md b/Purchase-Orders.md index 628dc6e..23eb388 100644 --- a/Purchase-Orders.md +++ b/Purchase-Orders.md @@ -102,9 +102,13 @@ approved, not creation). ## Export (PDF / XLSX) `/api/po/[id]/export?format=pdf|xlsx` returns the PO as a document. It is -**gated to `MGR_APPROVED` and later** (a DRAFT export returns HTTP 403). The -approver's name (and uploaded signature) appears as signatory; company details -populate the header; optional line-item descriptions are included. +**gated to `MGR_APPROVED` and later, plus `CANCELLED`** (a DRAFT export returns HTTP +403). The approver's name (and uploaded signature) appears as signatory; company +details populate the header; optional line-item descriptions are included. + +**Cancelled POs** are exportable too, but carry a diagonal **CANCELLED** watermark +(CSS overlay in the PDF, an embedded image in the XLSX). See +[Cancellation & supersede](PO-Lifecycle#cancellation--supersede). **Company branding** (set per company in Admin → Companies → Edit → Branding) also renders on the export: the uploaded **logo** floats top-left of the header, diff --git a/Roles-and-Permissions.md b/Roles-and-Permissions.md index 760bbf1..626d3d2 100644 --- a/Roles-and-Permissions.md +++ b/Roles-and-Permissions.md @@ -35,6 +35,7 @@ The exact `ROLE_PERMISSIONS` map in `lib/permissions.ts`. ✓ = granted. | `view_all_pos` | | | ✓ | ✓ | ✓ | ✓ | ✓ | | `approve_po` | | | | ✓ | ✓ | | | | `reject_po` | | | | ✓ | ✓ | | | +| `cancel_po` | | | | ✓ | ✓ | | | | `request_edits` | | | | ✓ | ✓ | | | | `request_vendor_id` | | | | ✓ | ✓ | | | | `process_payment` | | | ✓ | ✓ | ✓ | | | diff --git a/Tech-Debt.md b/Tech-Debt.md index 59c9883..ea1d73b 100644 --- a/Tech-Debt.md +++ b/Tech-Debt.md @@ -10,6 +10,26 @@ goal is visibility: each item records **what**, **why it matters**, and a ## Open +### TD-3 · Inventory increments are not reversed when a PO is cancelled + +**What.** Approving a PO with a `siteId` increments `ItemInventory` +(`approvals/[id]/actions.ts`). The cancel feature (#53) intentionally does **not** +reverse that increment, so a cancelled approved PO can leave stale stock behind. + +**Why it matters.** Low impact today — inventory writes almost never fire (see TD-1) +and the surface is feature-flagged off — but it becomes a correctness bug the moment +inventory-on-approval is brought live. + +**Suggested direction.** When inventory goes live, `cancelPo` should decrement the same +`ItemInventory` quantities for line items with a `productId`, guarding against +double-reversal and negative stock. Tracked as Forgejo issue **#55**; deferred per the +#53 answers. + +**Touch points.** `app/(portal)/po/[id]/actions.ts` (`cancelPo`), +`app/(portal)/approvals/[id]/actions.ts` (the increment), `lib/feature-flags.ts`. + +--- + ### TD-1 · Inventory-on-approval is dormant in production **What.** Approving a PO is meant to add its ordered items to the delivery