# Generalisation plan: yunyi-management

Scope: the whole flow from a document arriving to money received, plus stock, master data, roles and corrections. Names in `code font` written as `Model.field` are in `prisma/schema.prisma` today unless the text says "new".

## 1. The shape of the general model

Six rules produce every change in this plan.

1. **A link row carries the quantity or the amount.** Wherever two things are joined today by a single foreign key or a counter, they are joined by a row that says how much. Example: `PurchaseOrderLine.deliveredQty` is a counter. It becomes shipment lines, each with its own quantity, date and lot. One row is the usual case. Five rows is the same code path.
2. **Each real-world event is a document with a header and lines.** A receipt, a shipment, a return, a count, an invoice and a payment each get a row with a number, a business date, a person and a status. A counter cannot be signed, printed, disputed or reversed. A document can.
3. **Quantities and balances come from ledgers.** Stock on hand is the sum of `StockMovement`. Money owed is invoice total minus allocations. Any stored total (`BranchStock.qty`, `PurchaseOrder.paidAmount`) is a cache that a nightly check compares with the ledger.
4. **A posted row is never edited or deleted.** A correction is a reversal row plus a new row. Drafts are free to edit.
5. **State is one field per question.** Approval, fulfilment and payment are three questions. Fulfilment and payment are computed from the documents, so they cannot disagree with them.
6. **The usual case is the default, filled in by the system.** The general model always exists underneath. The system creates the single child row itself and the user sees it only when the case is unusual.

Policy that differs by customer, supplier or branch is a column on that master row. Example: Ranch Market prints "Partial? No" on its POs. That is `Customer.allowPartialDelivery = false` (new), not an `if` in the delivery code.

## 2. The flow, stage by stage

Each table row is one assumption of "exactly one", "always" or a fixed order.

### 2.1 Document intake

| Today | Cases that break it | General model |
|---|---|---|
| One email is one document. `InboundDocument.sourceRef` is the Gmail message id and is unique. `scripts/ingest.ts` takes the first PDF only. | Diamond or Gelael attach three store POs to one mail. A WhatsApp order arrives as four photos of one PO. A mail has a PO plus a price memo. | New `InboundMessage` (one per mail, upload or chat message, unique on `source + sourceRef`) has many `InboundDocument` (one per file or text body). A group of documents may be marked as pages of one order. |
| One document is one PO. `PurchaseOrder.documentId` is unique and `InboundDocument.purchaseOrder` is singular. | A portal export lists 40 store POs in one PDF. A rekap sheet covers a week. | The extractor returns a list of orders per document. New `PoRevision` joins document and PO, many to many: one document can feed many POs and one PO can have many documents. |
| A document is a PO or it is nothing. `EmailStatus` has `PO`, `NOT_PO`, `DUPLICATE`, `FAILED`. | A cancellation mail. A revised PO. A remittance advice ("Advice from Standard Chartered Bank") that lists paid invoices. A customer's goods-received note. A return note or debit note. A tukar faktur receipt. A sales report from the Cikokol outlet. | New `InboundDocument.kind`: `PO`, `PO_REVISION`, `PO_CANCELLATION`, `REMITTANCE_ADVICE`, `CUSTOMER_RECEIPT`, `RETURN_NOTE`, `DEBIT_NOTE`, `INVOICE_RECEIPT`, `OTHER`, `UNKNOWN`. Each kind is routed to a queue: order desk, receivables desk, or archive. Nothing is dropped. |
| A second document with the same `PurchaseOrder.customerPoNumber` is discarded as `DUPLICATE` (`@@unique([customerId, customerPoNumber])`). | A customer revises quantity the day after sending. Astro's terms give us one working day to confirm or revise, so changes in both directions are routine. The customer resends the same PDF. The customer reuses a number in a new year. Super Indo issues two number formats. | A document with a known number becomes a `PoRevision` with status `PROPOSED`. If its content equals the current revision it is closed as `IDENTICAL` with no human step. If it differs, an admin sees the difference line by line and applies or rejects it. A reused number on a far-apart PO date is offered as a new PO. New `PurchaseOrder.seriesKey` (default empty) joins the unique key with customer and number, so the admin can keep it as a second PO. |
| Extraction happens once. A `FAILED` row is never retried and has no screen. | Meridian is down for an hour. The model misreads a scanned fax. A better model ships. A password-protected PDF arrives. | New `Extraction` rows, many per document: attempt number, model, result, error. A failed document stays in a queue with retry and "key in by hand" as exits. The file hash detects the same file sent twice through two channels. |
| Intake is Gmail with a PDF. Mail without a PDF is not listed. | An order typed in the mail body. An Excel or image attachment. A portal that needs a daily login (Lotte, AEON). A phone order. | Adapters stay as they are: they fetch and call one function. Bodies and non-PDF files become `InboundDocument` rows too. A phone order is a manual PO with `PoSource.MANUAL` and an optional note document. |
| The sender tells us nothing certain. `Customer.senderEmails` is a JSON list and is only appended. | One Gmail address orders for two legal entities. NetSuite sends every AlloFresh mail from a shared domain. A person moves to another retailer. | New `CustomerContact` (customer, channel, address, role, active). One address may belong to several customers. It is a hint for matching, never the key. |
| Nothing reports when a source stops. | The Gmail token expired on 8 October 2026 and no PO was read until someone looked. A portal customer has no adapter and nobody uploads. | New `IntakeChannel` (source, address, expected silence in hours, enabled) and `IntakeRun` (channel, started, finished, seen, created, failed). The dashboard shows each channel whose silence has passed its limit. |

### 2.2 Master data

| Today | Cases that break it | General model |
|---|---|---|
| A customer is one row matched on `Customer.npwp`, then exact `Customer.legalName`. No merge. | "PT Fajar Mitra Indah" and "FAJAR MITRA INDAH, PT". A PO with no NPWP. A retailer that buys through two legal entities and pays from one. An NPWP that changes to the 16-digit form. | `Customer` stays the legal entity. New `Customer.parentId` groups entities under one banner. New `Customer.mergedIntoId` records a merge: the merged row stays, all its children move, and matching follows the pointer. New `CustomerName` holds every spelling seen, so the next PO matches without a person. New `CustomerTaxId` (customer, kind `NPWP15`, `NPWP16` or `NITKU`, value, unique) replaces `Customer.npwp`. New `Customer.isPkp`. |
| Ship-to is free text on the PO (`PurchaseOrder.shipToName`, `shipToAddress`, `shipToLat`, `shipToLng`). | FamilyMart has hundreds of stores and their addresses do not geocode. A store moves. A DC has receiving hours (AlloFresh: Mon–Sat 08.00–17.00). The same store appears under three spellings. | New `CustomerSite` (customer, the customer's own site code, name, address, coordinates, type store or DC, receiving window, default branch). The PO points to a site. The branch suggestion reads the site's default branch first and geocodes only for an unknown site. The printed text stays on the revision as evidence. |
| One product has one barcode (`Product.barcode` unique), one unit (`Product.unit`), one producer as text (`Product.producer`). | A repack gets a new EAN while old stock still sells. A product moves from Tahu Yun-Yi's registration to CV Kims Pangan Jaya's with a new BPOM number. A customer orders by carton. Two suppliers can make the same item. | New `ProductBarcode` (many per product, with dates). New `ProductUnit` (product, unit name, factor to the base unit). New `Supplier` and `SupplierProduct`. New `Product.shelfLifeDays`, `Product.status` (`ACTIVE`, `PHASE_OUT`, `DISCONTINUED`), `Product.replacedById` and `Product.mergedIntoId`. New `ProductDocument` (product, kind BPOM or halal, number, valid to, attachment), with a list of documents that expire within 90 days. |
| The customer's item code maps to one product forever (`CustomerProductAlias`, unique on customer and code). | The customer reuses a PLU for a new pack size. The customer's unit for that code is a carton of 12. | The alias gains `unitName`, `factorToBase`, `validFrom`, `validTo` (all new). The alias is where the carton-versus-pack fact lives, because it is a fact about how that customer orders. |
| No price list. The PO's printed price is the price. | A customer prints last month's price. A promo price runs for one week. A price differs by store class. | New `PriceAgreement` (customer, product, unit, price, valid from, valid to, optional site). It is a reference for flags and for invoices, not a replacement for the printed price. |
| Payment terms are text (`Customer.paymentTerms`, `PurchaseOrder.paymentTerms`). The due date reads the PO's copy with a regular expression for the first number. FamilyMart's code "V021" is read as 21 days, and nothing confirms that it means 21. | Due date counted from invoice date, from tukar faktur date, or from delivery. A customer that pays only on Thursdays. | New structured term on `Customer`: `termDays`, `termBasis` (`DELIVERY`, `INVOICE`, `INVOICE_RECEIPT`), `payDayRule`. The printed text stays on the PO. The invoice stores the due date it computed. |
| One bank account, known only by `BankTransaction.accountNumber`. | A second BCA account. A Mandiri account for one customer. Cash handed to a driver. A giro that bounces. | New `BankAccount`. Cash and giro are `Payment` rows (see 2.10). |
| No calendar. | Lebaran week. A national holiday on the delivery date. A branch closed on Sunday. | New `CalendarDay` (date, optional branch, optional supplier, optional customer, closed flag). Used by flags, by the supplier order draft and by due dates. |
| Any product can appear on any customer's PO. | A Day2Day line, which is Ranch Market's private label, is read on an Astro PO. A retailer orders a product it has not listed. | The alias is the listing. A product with no alias for that customer raises a flag the first time. New `Product.exclusiveCustomerId` for private label makes that flag blocking. |
| One tax amount on the PO (`PurchaseOrder.tax`) with no rule behind it. | PPN moves from 11% to 12%. The tax base is 11/12 of the price ("DPP nilai lain", printed by Ranch Market as "DPP Oth.Value"). A customer that is not PKP. | New `TaxRule` (valid from, rate, base factor). The invoice computes base and tax from the rule valid on its date. The PO keeps what was printed. |
| The seller is implied. | An invoice must print the seller's legal name, NPWP and address. A second selling entity later. | New `SellerEntity` with one row. `Invoice`, `BankAccount` and `NumberSeries` point to it. |
| A branch is a name, an address and `Branch.active`. It can be switched off while it holds stock. | A branch closed for Lebaran. A branch at capacity. A branch that closes for good. | New `Branch.cutoffTime`, `workingDays`, `dailyCapacity`. The branch suggestion reads them along with distance and stock. Deactivation is refused while stock or open allocations remain. |
| Policy is constants in code (`TOLERANCE` in `src/lib/payments.ts`, the 3-day Gmail window, the 16:00 habit). | The owner wants another tolerance. One branch must block negative stock. | New `Setting` (key, scope global, branch, customer or supplier, value). A column on a master row overrides the global row. |

### 2.3 The order and its approval

| Today | Cases that break it | General model |
|---|---|---|
| One enum, `PurchaseOrder.status` (`PoStatus`), holds approval, fulfilment and payment. | A PO that is approved, half delivered and has one invoice paid. A PO cancelled after approval. A PO short-closed. | `PurchaseOrder.approvalStatus` (new): `NEEDS_REVIEW`, `PENDING`, `APPROVED`, `REJECTED`, `CANCELLED`. Fulfilment state and payment state are computed from allocations, shipments, invoices and payments and cached for lists. |
| Approval is two columns (`PurchaseOrder.verifiedById`, `verifiedAt`) that are wiped when verification is cancelled. | Approve, cancel, approve again by another admin. Approval of revision 2 after revision 1 was approved. An admin who must answer for a 2000-pack order. | New `PoApproval`: one row per decision, with the revision it decided, the person, the time, the decision and a note. Approval applies to a revision. A later revision needs its own decision. Only the changed lines return to pending. A `Setting` can require a second approver above an amount and is off by default. |
| Anomalies are a text note (`PurchaseOrder.reviewNote`). | Quantity ten times the customer's usual. Price differs from the agreement. Unknown site. Delivery date before today or on a closed day. Same content under a new number. PO past its `PurchaseOrder.expiryDate`. New unit text. Totals that do not add up. | New `PoFlag` (PO, revision, code, severity, detail, acknowledged by, acknowledged at). Approval is refused while a blocking flag is neither fixed nor acknowledged with a note. The note is the accountability record. The system flags and the admin decides, as owner requirement 1 says. |
| Approval does not need mapped lines. | A verified PO whose unmapped line never reaches the factory order. | An unmapped line is a blocking flag. The admin maps it, or closes that line with a reason. |
| A line has one quantity in an unknown unit (`PurchaseOrderLine.qty`, `unit`). `PurchaseOrderLine.freeQty` is stored and ignored by stock. | AlloFresh orders packs and prints free goods in pieces. Gelael prints "PCK/1" with pieces in brackets. A carton line. | The line keeps what was printed and gains `baseQty` and `baseFreeQty` (new) in the product's base unit, computed through the alias factor. Stock demand is `baseQty + baseFreeQty`. Money is `qty × unitPrice`. |
| The line is the unit of fulfilment: one PO has one `PurchaseOrder.branchId`, one ship-to, one `PurchaseOrder.deliveryDate`. | The nearest branch has half the stock, so a second branch sends the rest. A consolidated PO lists four stores. A weekly PO with a delivery every two days. The remainder of a partly delivered PO moves to another branch. | New `OrderAllocation` (PO line, branch, site, planned date, quantity). It is the unit of commitment and delivery. The sum of a line's allocations plus its closed quantity equals the line quantity. `PurchaseOrder.branchId` and `deliveryDate` stay as the defaults that fill the single allocation. |
| The customer orders, we deliver all of it. | We can supply 60 of 100 and tell the customer (Astro requires a reply within one working day, AlloFresh's subject asks for confirmation of availability). The customer then revises or accepts. | `OrderAllocation.quantity` may be less than ordered at approval. The gap is an open remainder or a closed quantity with a reason. A `PoConfirmation` event (new) records what was told to the customer and when. |
| When supply is short, whoever ships first gets the goods. | The producer confirms 300 of 500 packs and six POs want them. | A shortage screen lists the open allocations of that product by planned date and by new `Customer.priority`. A person cuts quantities. Each cut is a closed quantity with a reason and a `PoConfirmation` to the customer. The system proposes and never cuts. |
| A short delivery never closes the PO. | The customer will not take the rest of yesterday's tofu order. The PO expires (Gelael and Diamond print an expiry). | A line can be closed short: `PurchaseOrderLine.closedQty` and `closeReason` (new), set by a person. A daily job flags open remainders past the expiry date or the delivery date. It never closes them itself. New `Customer.backorderPolicy` (`CANCEL_BALANCE`, `CARRY`) pre-selects the proposed action. |
| An edit deletes and recreates every line (`updatePoAction`). `StockMovement.poLineId` then points at nothing or blocks the delete. | Any edit after a partial delivery. | A line has a stable identity for its whole life. An edit is a new `PoRevision` applied as a difference: lines are changed, added or closed, never recreated. A revision may not reduce a line below what has shipped. |
| Every order is a customer PO with a number. Intake invents "(tanpa nomor) …" when there is none. | A phone order. A sample for a buyer. A replacement for returned goods. A top-up the store manager asks the driver for. | `PurchaseOrder.customerPoNumber` becomes optional. New `PurchaseOrder.orderNo` is our own number, always present. New `PurchaseOrder.kind`: `CUSTOMER_PO`, `VERBAL`, `REPLACEMENT`, `SAMPLE`. All kinds pass through approval. New `OrderSchedule` (customer, site, weekdays, lines) drafts standing orders into the same queue. |
| Totals are recomputed as quantity times price on edit (`PurchaseOrder.subtotal`, `tax`, `total`). | Astro prints tax rate 0% and applies 11%. Gelael has two discount columns. Ranch Market has "PAY Allowance". A delivery fee. | The revision keeps the printed totals. The line gains `discountAmount`, `taxRate`, `taxAmount` (new). Printed and computed totals are both stored. A difference is a flag, and the invoice uses computed values from accepted quantities. |

### 2.4 Ordering from suppliers

| Today | Cases that break it | General model |
|---|---|---|
| One producer. `FactoryOrder` has no supplier. | Three registrants in the BPOM catalogue: tofu from CV Kims Pangan Jaya, tempe from Rumah Tempe Indonesia, the retail line from PT Selalu Bermanfaat Selamanya. A supplier that cannot produce today. | The model is renamed `SupplierOrder` in Prisma and keeps its table (`@@map("FactoryOrder")`, the pattern `InboundDocument` already uses). It gains `supplierId`. The draft step makes one order per supplier that has a shortfall. |
| One order per branch per day (`@@unique([branchId, orderDate])` on `FactoryOrder`). | A large PO approved at 17:00, after the 16:00 request. A morning top-up. A weekend order placed on Friday for two days. | The unique key goes. An order has a sequence number within branch, supplier and date. A later draft computes shortfall net of what earlier orders already asked for. |
| The order is fixed once sent (`FactoryOrderStatus`: `DRAFT`, `SENT`, `RECEIVED`). | The supplier confirms 80 of 100. The branch cancels a line. The supplier substitutes a size. | Line gains `confirmedQty` and `closedQty` (new). Status gains `CONFIRMED`, `CLOSED`, `CANCELLED`. A change after sending is a logged amendment, not an edit. |
| Shortfall is `committed + buffer − on hand` (`BranchStock.bufferQty`), with no dates and no inbound. | Stock already ordered and arriving tonight is ordered again. A PO due next Monday is ordered today and expires in the cold room. Stock on hand that will be expired by the delivery date is counted as usable. | Shortfall per product and need date: allocations due within the supplier's lead time, plus buffer, minus usable on-hand stock, minus open inbound. The open-inbound term is required from the moment the unique key goes, or a second draft orders the same shortfall twice. Buffer comes from new `BufferRule` (branch, product, optional weekday, quantity, valid from, valid to), which replaces `BranchStock.bufferQty`. Usable means not expired by the planned date and above the customer's minimum remaining shelf life. Lead time and order multiple (crate size) come from `SupplierProduct`. |
| The branch orders for itself. | Pusat places one order for all branches. The supplier's truck drops one branch's goods at another. The supplier delivers straight to the customer's DC. | The order has a requesting branch and each line has a receiving branch, which defaults to the same one. A direct delivery is a receipt and a shipment posted together at the responsible branch, so stock and money rules stay the same. A supplier order line may name the allocation it is for (`SupplierOrderLine.allocationId`, new and optional), so a direct delivery can be planned as well as recorded. |

### 2.5 Receiving

| Today | Cases that break it | General model |
|---|---|---|
| Receipt is a counter on the order line (`FactoryOrderLine.receivedQty`). A second receipt overwrites it and posts the difference. | The owner's example: several batches in a day. A truck at 05:00 and another at 13:00. Goods for yesterday's order arriving with today's. | New `GoodsReceipt` and `GoodsReceiptLine`. Each arrival is one receipt with its own time, supplier delivery-note number, vehicle and receiver. The order line's received quantity is the sum of receipt lines. |
| Goods can be received only against a factory order. | The supplier sends 20 extra packs. The supplier sends an item nobody ordered. The branch forgot to send the order and the goods came anyway. | `GoodsReceiptLine.supplierOrderLineId` is optional. An unordered line is accepted into stock and flagged for pusat. |
| Everything received is good. | Crushed packs. A warm truck. Goods made three days ago. Short weight. | The receipt line has `qtyAccepted` and `qtyRejected` with a reason. Rejected goods either never enter stock or enter as condition `HOLD` until a `SupplierReturn` (new) or a write-off. |
| A receipt has no production or expiry date. | Two batches on hand made two days apart. A recall of one production date. | Each accepted receipt line creates or joins a `StockLot` (see 2.6). |
| "Complete" is a button on the order (`FactoryOrder.receivedAt`). | The rest will come tomorrow. The rest will never come. | The order closes itself when every line is received in full. Otherwise a person closes the remainder short, and until then it counts as open inbound only up to its expected date. |

### 2.6 Stock

| Today | Cases that break it | General model |
|---|---|---|
| One number per branch per product (`BranchStock.qty`). | Old stock ships after new stock and expires. A customer demands five days of remaining life. A recall. | New `StockLot` (product, supplier, production date, expiry date, source receipt line). New `StockBalance` keyed by branch, product, lot and condition. Picking is first-expired-first-out, chosen by the system. `BranchStock` goes away once `BufferRule` holds the buffer. |
| All stock is sellable. | Returned goods waiting for a decision. Damaged packs waiting for the supplier's driver. Goods on the truck. | `condition` on balance and movement: `GOOD`, `HOLD`, `DAMAGED`. Goods on a truck are in a dispatched shipment or transfer, which is a known quantity outside every branch. |
| Five movement types (`MovementType`) and three optional links (`StockMovement.poLineId`, `factoryOrderId`, `opnameId`). A movement may have no source at all (`WASTE` has only `StockMovement.note`). | An auditor asks why 40 packs left on Tuesday. | Every movement points to exactly one posted document line: receipt, shipment, transfer, customer return, supplier return, adjustment or count. `MovementType` grows to match. `poLineId` and `factoryOrderId` are replaced by the document-line links. |
| A movement has one time (`StockMovement.at`), the time it was typed. | The 05:00 truck is entered at 09:00, after the 06:00 deliveries were entered. A count done Saturday is typed on Monday. | `occurredAt` (when it happened, chosen by the user, defaulting to now) and `recordedAt` (when it was typed, set by the system). Stock as of a time uses `occurredAt`. |
| Delivery does not check stock. Stock can go negative without notice. | Goods ship before their receipt is typed. A real shortage is hidden the same way. | Default: a shipment may post against stock not yet received. It draws on a "lot not yet known" balance that is flagged on the branch's screen. The receipt that arrives in the system later clears it. A per-branch switch makes it a hard block instead. |
| No transfer between branches. | One branch is short and another has spare. A branch closes and its stock moves. | New `StockTransfer` with lines: sent quantity at dispatch, received quantity at arrival, lot kept. A difference is posted as a transit loss by a person. |
| Waste is a movement with a note. | Expired, damaged, sample to a buyer, staff purchase, theft. Each has a different owner and a different cost. Large write-offs need a second person. | New `StockAdjustment` document with a reason code per line and an approval step above a branch-level threshold. A daily job lists lots past expiry in `GOOD` condition. A person posts the write-off. Expired lots are excluded from picking at once. |
| Stock changes one product at a time. | The chiller fails overnight and every pack in the branch is in doubt. | A `StockAdjustment` of kind hold moves every `GOOD` balance of the branch to `HOLD` in one posting, with the time of failure and of recovery. A person then releases or writes off each lot. |
| Opname posts at once, one line per product (`StockOpnameLine.systemQty`, `countedQty`, unique on opname and product), compared with stock at the moment of typing. | A count of chilled goods only. Two counters. A count typed two days late with deliveries in between. A typing error of 300 for 30. | `StockOpname` gains a status (`DRAFT`, `SUBMITTED`, `APPROVED`, `VOID`), a count time and a scope. Lines are per product and lot. System quantity is stock as of the count time by `occurredAt`. The count is blind: the system quantity is hidden while counting. A variance under the branch's threshold posts on submit. A larger one waits for a second person. A recount is a new opname that voids the first. |

### 2.7 Delivery

| Today | Cases that break it | General model |
|---|---|---|
| Delivery is a counter (`PurchaseOrderLine.deliveredQty`) and a movement. There is no delivery document. | The store asks for the surat jalan number. Finance needs the signed copy to invoice. Gelael wants the original PO sheet with the goods. | New `Shipment` (branch, site, number, business date, driver, vehicle, status) and `ShipmentLine` (allocation, product, lot, quantity). Scans are `Attachment` rows (new). |
| One delivery is for one PO. | FamilyMart sends two POs for one store on one day and one truck carries both. One PO takes three trips. | A shipment line points to an allocation, so one shipment can carry lines from many POs for one site, and one allocation can be served by many shipments. |
| What left the branch is what the customer took. `PurchaseOrder.deliveredAt` is the time the form was saved. | The DC rejects 12 packs for short shelf life. The store is closed. The truck arrives after the receiving window. The customer's receipt shows 98 where we loaded 100. | The shipment line has `qtyShipped`, `qtyAccepted`, `qtyRejected` with a reason, and `qtyShort` for a count difference with no goods to bring back. New `Customer.confirmAcceptance`: when false, accepted equals shipped at dispatch. When true, the invoice waits for the customer's count. Status runs `DRAFT`, `DISPATCHED`, `DELIVERED`, `FAILED`, `VOID`. The customer's own receipt number (GRN, BPB or LPB) is a field. Rejected goods come back through a customer return posted with the proof of delivery. |
| Stock leaves when the form is saved. | The driver leaves at 04:00 and proof of delivery is typed at 15:00. | Stock leaves the branch at dispatch. Revenue quantity is fixed at proof of delivery. Between the two the goods are in transit to the customer. |
| The product delivered is the product ordered. | White tofu sent for yellow with the buyer's consent. A 320 g pack for a 640 g pack. | The shipment line's product may differ from the allocation's product when the line is marked as a substitution with a reason. Price follows the PO line unless the invoice line is edited before posting. |
| Quantity delivered cannot exceed the order (`deliverAction` refuses it). | The store asks the driver for ten more. | Over-delivery is allowed only when `Customer.allowOverDelivery` (new) is true or a pusat admin approves it. It is flagged because the customer may pay only the PO quantity. |
| Partial delivery is always allowed. | Ranch Market: "Partial? No". | `Customer.allowPartialDelivery` (new). When false, a shipment that would leave a remainder needs an override. |
| One truck, one drop. | One driver does twelve stores in a morning. A third-party courier. Customer pick-up at the branch. | New optional `DeliveryRun` (branch, date, driver, vehicle) groups shipments. `Shipment.method`: own truck, courier, pick-up. A shipment without a run is valid. |
| A delivery has a date and nothing more. | AlloFresh and Ranch Market DCs receive by appointment with a queue number. A missed slot sends the truck back. | New `Shipment.appointmentAt` and `appointmentRef`. A site with new `CustomerSite.needsAppointment` cannot be dispatched to without one. |

### 2.8 Customer returns

| Today | Cases that break it | General model |
|---|---|---|
| `MovementType.RETURN` exists with no document and no screen. | Rejection at the dock. Unsold goods collected a week later. Swap of old goods for fresh ones on the next drop. A return note from the customer with its own number. | New `CustomerReturn` and lines: customer, site, source shipment line where known, quantity, reason, condition received, disposal (`RESTOCK`, `HOLD`, `WRITE_OFF`, `NOT_COLLECTED`). |
| A return has no money effect. | The customer deducts the return from the next payment. The customer wants replacement goods, not money. | Each return line ends in exactly one of: a credit note line, a replacement order line (`PurchaseOrder.kind = REPLACEMENT`), or a recorded decision that no credit is due. |

### 2.9 Invoice and receivable

| Today | Cases that break it | General model |
|---|---|---|
| Receivable is the PO total once `PoStatus.DELIVERED` (`PurchaseOrder.total`, `paidAmount`). | A PO delivered 90 of 100 is never a receivable, so 90 packs are never billed. A customer pays on its received quantity. | New `Invoice` and `InvoiceLine`. An invoice line bills accepted quantity from one shipment line at the PO line's price. Every accepted, billable unit is invoiced once. |
| One PO, one receivable. | One invoice per delivery. One invoice per week per customer. One invoice per store per month. A faktur pajak number per invoice. | Invoice to shipment line is one to many, so an invoice can cover many shipments and many POs, and a PO can appear on many invoices. `Customer.invoiceGrouping` (new): per shipment, per PO, or periodic. |
| Due date is delivery date plus the number found in the term text. | Tukar faktur: the term starts when the customer's finance desk stamps the invoice. Payment runs on fixed days. | New `InvoiceSubmission` (customer, date, the customer's receipt number, promised pay date) joined to many invoices. `Invoice.dueDate` is computed from the customer's term basis and stored. New `Customer.submissionDocs` lists what that customer's desk requires (invoice, faktur pajak, signed surat jalan, PO copy). A submission cannot be recorded while a listed attachment is missing. |
| No credit note and no deduction. | Price difference claimed by the customer. Return. Rebate, listing fee, promotion charge. Penalty for a late or short delivery. Tax withheld with a bukti potong. | New `CreditNote` with a reason code and lines, applied to invoices through `CreditApplication` rows. Deductions with no credit note are an allocation kind (see 2.10). |
| An unpaid invoice is only late. | The customer rejects an invoice over a price or quantity difference and holds payment. | New `InvoiceDispute` (invoice, amount, reason, owner, opened, resolved as credit, paid or upheld). Ageing shows disputed amounts apart. |
| Free goods have no place in money. | AlloFresh's free pieces. Samples. Replacements. | They appear on the shipment and on the invoice at zero price, so the customer's receipt and our invoice list the same goods. |
| Invoice and tax number do not exist. | The customer refuses an invoice without the right number format. A voided invoice must keep its number. | New `NumberSeries` per document type and year, gap-free. A void keeps the number and the row. New `Invoice.replacesId` names the invoice that a new one replaces. New `Invoice.taxInvoiceNo` and `taxInvoiceStatus` (`NONE`, `UPLOADED`, `APPROVED`, `REJECTED`, `REPLACED`), because a faktur pajak is valid only after the tax office approves the upload. |

### 2.10 Money received

| Today | Cases that break it | General model |
|---|---|---|
| Money in is a Moota mutation (`BankTransaction.mutationId` unique and required). | Cash to the driver. A giro that clears in a week or bounces. A transfer to a second account. A day when Moota misses a mutation. The webhook and the polling job both deliver the same mutation. | `BankTransaction` stays a mirror of the bank feed. It gains `bankAccountId` and `feed` (`MOOTA`, `POLL`, `STATEMENT_IMPORT`) and stays idempotent on the mutation id. New `Payment` (payer customer, method `TRANSFER`, `CASH`, `GIRO` or `OFFSET`, date, amount, optional bank transaction, reference, status). Cash and an uncleared giro are payments with no bank row. A giro has a clearing status. A payment with no bank row needs a proof attachment. |
| An allocation joins a transfer to a PO (`PaymentAllocation.purchaseOrderId`, unique on transfer and PO). | One transfer pays 30 invoices from a tukar faktur batch. One invoice is paid in two transfers. A transfer pays invoices of two legal entities in one group. | `PaymentAllocation` joins a payment to an invoice. The unique key goes, because a reversal and a re-allocation of the same pair must both stay on record. |
| The whole transfer must be explained by POs, with a tolerance of Rp 5,000 and a free-text note for the rest. | The customer deducts a return, a rebate or a bank fee. The customer overpays. The customer pays in advance. Nobody knows who sent it. | `PaymentAllocation.kind` (new): `INVOICE`, `DEDUCTION`, `WITHHOLDING`, `BANK_FEE`, `REFUND`, `ROUNDING`, `WRITE_OFF`. A deduction carries a reason code and may point to a credit note. A withholding carries the bukti potong number. What a payment has not allocated is the customer's on-account credit and can be applied later. Allocations of a payment sum to its amount or less. |
| Unmatching deletes the allocations (`unmatchAction`). | A match is undone a month later and the history of who was paid when is gone. | An allocation is reversed by a contra row with `reversesId`. `BankTxStatus` is computed: unallocated, part allocated, allocated, ignored. |
| The payer is the customer (`BankTransaction.customerId`). | A parent company pays for its subsidiary. A purchasing agent pays. | One bank transaction backs one or more payments, each with its own payer, whose amounts sum to the transaction or less. A group treasury's transfer for two entities is two payments. An allocation to another customer's invoice is allowed inside one customer group without a note, and outside it with one. |
| A sender text belongs to one customer (`CustomerBankIdentity.pattern`). | Two customers bank through the same payment service and show the same sender text. A customer changes bank. | The identity stays a hint. Several customers may share a pattern, and then the suggestion step ranks them by which one's open invoices add up. Remittance advice documents from intake (2.1) give exact invoice lists when present. |
| Debits are stored and ignored. | A refund to a customer. A bank reversal of a credit. | A debit backs a payment with a negative amount: a refund against the customer's on-account credit, or the reversal of the credit it names. |

### 2.11 Roles and access

| Today | Cases that break it | General model |
|---|---|---|
| Three fixed roles (`Role`: `ROOT`, `ADMIN`, `BRANCH`) on `User.role`. | A finance clerk who matches payments and must not approve POs. A branch head who approves counts, and a warehouse clerk who only receives and ships. A driver who records proof of delivery. | New `RoleDef` (a named set of permissions) and `RolePermission`. Permissions are verbs on documents: `po.approve`, `receipt.post`, `opname.approve`, `payment.allocate`, and so on. The three current roles are seeded as three role definitions with today's rights. |
| A user has one branch (`User.branchId`). Null means pusat. | A supervisor of two branches. A pusat admin who covers a branch during leave. A clerk moved to another branch. | New `UserRole` (user, role, branch or all branches, valid from, valid to). Kantor pusat still has no row: a pusat assignment is one with no branch. |
| Any admin can do every admin act alone. | The person who typed a manual PO approves it. The counter approves the count. The person who entered a manual cash receipt allocates it. | Four separation rules, each a switch: creator and approver of a manual PO, counter and approver of an opname, requester and approver of a write-off above threshold, and enterer and allocator of a manual receipt. |
| Approval stops when the approver is away. | Lebaran. Sick leave. 200 POs a day and one admin. | Any number of users may hold `po.approve`. A dated `UserRole` row is a delegation. |

### 2.12 Corrections and audit

| Today | Cases that break it | General model |
|---|---|---|
| Fixing a mistake means editing or deleting: PO lines are recreated, allocations deleted, received quantity overwritten. Cancelling verification is allowed after partial delivery. | Any mistake found after a later step used the row. | One rule for every document: `DRAFT` is editable, `POSTED` is frozen, `VOID` is a posted row cancelled by a reversal that points to it. A reversal is refused while a later document depends on the row. Example: a shipment cannot be voided while its invoice is posted. Void the invoice first. |
| `AuditLog` has `action`, `entity`, `entityId`, `before`, `after`. System actions have a null user. | "Who approved this, and what did the PO say at that moment?" "Which job created this row?" | Every posting goes through one command wrapper. It opens the transaction, checks the permission and the idempotency key, writes the change and writes the `AuditLog` rows in that same transaction. Today `audit()` runs after the transaction, so a crash between the two loses the log, and intake writes no audit row at all. `AuditLog` gains `actorKind` (user, job, webhook), `reason`, `requestId` and `txId` (new). Decisions that matter are also first-class rows (`PoApproval`, flags, reversals), so they can be queried without reading JSON. |
| There is no closed period. | A back-dated receipt changes last month's stock after the owner has read the report. | New `PeriodLock` (branch or all, locked through date). Back-dating before the lock is refused. A lock is set by a person, by default after an approved opname. |
| Two people can act on one row at once. | Two branch users ship the same PO. The Moota webhook retries. | Every posting action checks a version number on the header and runs in one transaction with the ledger rows. Every inbound call is idempotent on its external id. Every posting command from a screen carries a `clientRequestId`, unique per document type, so a double click returns the first result. |
| Nothing tells a person that work is waiting. | A PO assigned to a branch. A failed document. A flagged negative balance. | New `Outbox` (kind, target, payload, sent at). Dashboard queue counts read it first. An email or WhatsApp sender can read the same table later. |

## 3. Data model after the change

### 3.1 Entities and cardinality

Read `A 1—n B` as "one A has many B". Models marked (kept) exist today.

**Intake**
- `InboundMessage` 1—n `InboundDocument` (kept)
- `InboundDocument` 1—n `Extraction`
- `InboundDocument` n—n `PurchaseOrder` (kept), through `PoRevision`

**Parties and catalogue**
- `Customer` (kept) 1—n `Customer` (group, by `parentId`), 1—n `CustomerName`, 1—n `CustomerContact`, 1—n `CustomerSite`, 1—n `CustomerProductAlias` (kept), 1—n `PriceAgreement`, 1—n `CustomerTaxId`, 1—n `CustomerBankIdentity` (kept)
- `Supplier` n—n `Product` (kept), through `SupplierProduct`
- `Product` 1—n `ProductBarcode`, 1—n `ProductUnit`, 1—n `ProductDocument`, 1—n `StockLot`
- `Branch` (kept) 1—n `CustomerSite` (as default branch), 1—n `BufferRule`

**Order**
- `Customer` 1—n `PurchaseOrder` 1—n `PurchaseOrderLine` (kept) 1—n `OrderAllocation`
- `PurchaseOrder` 1—n `PoRevision`, 1—n `PoApproval`, 1—n `PoFlag`, 1—n `PoConfirmation`
- `OrderAllocation` n—1 `Branch`, n—1 `CustomerSite`

**Supply**
- `Branch` 1—n `SupplierOrder` (today `FactoryOrder`) n—1 `Supplier`
- `SupplierOrder` 1—n `SupplierOrderLine` (today `FactoryOrderLine`) 1—n `GoodsReceiptLine` (optional on the receipt side)
- `GoodsReceipt` 1—n `GoodsReceiptLine` n—1 `StockLot`

**Stock**
- `StockBalance` is one row per branch, product, lot and condition
- `StockMovement` (kept) n—1 exactly one of: `GoodsReceiptLine`, `ShipmentLine`, `StockTransferLine`, `CustomerReturnLine`, `SupplierReturnLine`, `StockAdjustmentLine`, `StockOpnameLine` (kept)
- `StockTransfer` 1—n `StockTransferLine`. `StockAdjustment` 1—n `StockAdjustmentLine`. `StockOpname` (kept) 1—n `StockOpnameLine`

**Delivery and returns**
- `DeliveryRun` 1—n `Shipment` (optional) n—1 `CustomerSite`
- `Shipment` 1—n `ShipmentLine` n—1 `OrderAllocation`
- `CustomerReturn` 1—n `CustomerReturnLine` n—1 `ShipmentLine` (optional)

**Money**
- `Invoice` 1—n `InvoiceLine` n—1 `ShipmentLine`
- `InvoiceSubmission` n—n `Invoice`
- `CreditNote` 1—n `CreditNoteLine` (optional link to `CustomerReturnLine` or `InvoiceLine`). `CreditNote` n—n `Invoice` through `CreditApplication`
- `BankAccount` 1—n `BankTransaction` (kept) 1—n `Payment` 1—n `PaymentAllocation` (kept) n—1 `Invoice` (when kind is `INVOICE`). A `Payment` may have no `BankTransaction`
- `Invoice` 1—n `InvoiceDispute`. `Invoice` 0..1—1 `Invoice` (by `replacesId`). `SellerEntity` 1—n `Invoice`

**Access and cross-cutting**
- `User` (kept) n—n `RoleDef` through `UserRole` (with optional branch). `RoleDef` 1—n `RolePermission`
- `Attachment`, `NumberSeries`, `PeriodLock`, `CalendarDay`, `TaxRule`, `Setting`, `Outbox`, `AuditLog` (kept)
- `IntakeChannel` 1—n `IntakeRun`

### 3.2 Sketches

Only the fields that carry the design are shown. Every document header also has `number`, `businessDate`, `status`, `version`, `createdById`, `postedById`, `postedAt`, `voidedById`, `voidedAt`, `voidReason`, `reversesId`, `clientRequestId`.

```prisma
model PoRevision {
  id              Int
  purchaseOrderId Int
  revisionNo      Int
  documentId      Int?          // null for a manual edit
  orderIndex      Int?          // position of this order inside the document
  kind            RevisionKind  // ORIGINAL | REVISION | CANCELLATION | MANUAL_EDIT
  status          RevisionStatus // PROPOSED | APPLIED | REJECTED | IDENTICAL
  snapshot        Json          // header and lines as printed
  decidedById     Int?
  decidedAt       DateTime?
  @@unique([purchaseOrderId, revisionNo])
}

model OrderAllocation {
  id          Int
  poLineId    Int
  branchId    Int
  siteId      Int
  plannedDate DateTime @db.Date
  qty         Decimal   // base unit, billable plus free
  status      AllocStatus // OPEN | CLOSED
  closeReason String?
}

model StockLot {
  id             Int
  productId      Int
  supplierId     Int?
  productionDate DateTime? @db.Date
  expiryDate     DateTime? @db.Date
  unknown        Boolean   // the one catch-all lot per product
}

model StockBalance {
  branchId  Int
  productId Int
  lotId     Int
  condition StockCondition // GOOD | HOLD | DAMAGED
  qty       Decimal
  @@unique([branchId, productId, lotId, condition])
}

model StockMovement {       // kept, extended
  branchId   Int
  productId  Int
  lotId      Int
  condition  StockCondition
  type       MovementType
  qty        Decimal        // signed
  occurredAt DateTime
  recordedAt DateTime
  reversesId Int?
  // exactly one of the document-line ids below is set
  goodsReceiptLineId Int?
  shipmentLineId     Int?
  transferLineId     Int?
  customerReturnLineId Int?
  supplierReturnLineId Int?
  adjustmentLineId   Int?
  opnameLineId       Int?
}

model ShipmentLine {
  id           Int
  shipmentId   Int
  allocationId Int
  productId    Int        // differs from the PO line only for a substitution
  lotId        Int
  qtyShipped   Decimal
  qtyAccepted  Decimal?
  qtyRejected  Decimal?
  rejectReason String?
  qtyShort     Decimal?   // count difference with no goods to return
  billable     Boolean
}

model InvoiceLine {
  id             Int
  invoiceId      Int
  shipmentLineId Int
  qty            Decimal
  unitPrice      Decimal
  discountAmount Decimal
  taxRate        Decimal
  taxAmount      Decimal
  lineTotal      Decimal
}

model Payment {
  id                Int
  payerCustomerId   Int
  method            PaymentMethod  // TRANSFER | CASH | GIRO | OFFSET
  amount            Decimal        // negative for a refund
  businessDate      DateTime @db.Date
  bankTransactionId Int?           // several payments may share one transaction
  reference         String?        // giro number, cash receipt number
  status            PaymentStatus  // POSTED | PENDING_CLEARING | BOUNCED | VOID
}

model PaymentAllocation {   // kept, retargeted
  paymentId         Int
  kind              AllocationKind // INVOICE | DEDUCTION | WITHHOLDING | BANK_FEE | REFUND | ROUNDING | WRITE_OFF
  customerId        Int
  invoiceId         Int?
  creditNoteId      Int?
  reasonCode        String?
  amount            Decimal
  reversesId        Int?
  createdById       Int
  createdAt         DateTime
}

model UserRole {
  userId    Int
  roleId    Int
  branchId  Int?      // null means every branch, which is kantor pusat
  validFrom DateTime
  validTo   DateTime?
}
```

### 3.3 Rules that must always hold

A nightly job checks every rule and lists each row that breaks one. Posting code enforces the rule first. The job is the second line.

**Stock**

- S1. For every branch, product, lot and condition, `StockBalance.qty` equals the sum of its movements.
- S2. Every movement points to exactly one posted document line. A document line and its movements post in one transaction.
- S3. Movements are never updated or deleted. A reversal is a movement of opposite sign with `reversesId` set, and a movement is reversed at most once.
- S4. For each PO line: sum of allocation quantities plus closed quantity equals `baseQty + baseFreeQty`.
- S5. For each allocation: shipped quantity net of failed shipments does not exceed its quantity, unless an over-delivery was approved.
- S6. For each shipment line at proof of delivery: accepted plus rejected plus short equals shipped. Each rejected unit is on a customer return line. Each short unit is posted as a transit loss by a person.
- S7. For each transfer line: received plus recorded transit loss equals sent, once the transfer is closed.
- S8. No shipment line draws on a lot past its expiry date. A lot below the customer's minimum remaining shelf life needs an override.
- S9. Demand exists only for allocations of a PO whose current revision is approved. A proposed revision changes nothing until applied and approved.
- S10. A balance in `GOOD` condition is negative only in the catch-all lot, and each such balance is listed for the branch until cleared.
- S11. No movement has `occurredAt` inside a locked period.

**Money**

- M1. Invoice total equals the sum of its lines after discount plus tax, rounded by one stated rule to whole rupiah.
- M2. For each shipment line: invoiced quantity on posted invoices does not exceed accepted quantity. A delivered, billable line with nothing invoiced is listed as "delivered, not invoiced".
- M3. Open amount of an invoice equals total minus payment allocations minus credit applications, and is never below zero.
- M4. Payments of a bank transaction sum to its amount or less. Allocations of a payment, net of reversals, sum to its amount or less. Each remainder is shown: money not yet assigned to a payer, and on-account credit. Nothing is left implied by a note.
- M5. Credit applications of a credit note do not exceed its total.
- M6. Each customer return line has exactly one outcome: credit note line, replacement order line, or a recorded no-credit decision.
- M7. A receivable exists only as a posted invoice. The receivables figure and the ageing report read invoices and nothing else.
- M8. Posted invoices, credit notes and allocations are never edited. Their numbers are gap-free within a series.
- M9. A customer's on-account credit equals its payments minus their allocations, net of reversals and refunds, and is never negative.
- M10. Stock and money agree: for each customer and period, accepted quantity on shipments equals invoiced quantity plus non-billable quantity plus the "delivered, not invoiced" list.

**Audit**

- A1. Every posted change has its `AuditLog` rows in the same transaction, under one `txId`. No log, no change.
- A2. A command whose `clientRequestId` has been seen returns the earlier result and writes nothing.

## 4. The usual case stays as short as today

The test for each phase is a count of user actions on the usual path, taken before the phase and after it. A phase is not done if the count rose.

| Step | Usual case today | How it stays that short |
|---|---|---|
| Intake | Nothing to do. | Unchanged. One mail, one PDF, one PO creates one message, one document, one extraction and revision 1 without a person. A resend of the same file closes itself as identical. |
| Approve a PO | Check, pick branch, verify. | The site is known after its first PO, so the branch is pre-filled from `CustomerSite` and geocoding misses stop costing time. Approval creates one allocation per line by itself. A PO with no flags can be approved from the list. Flags appear only when present. |
| Order from the supplier | Open the day's draft, send. | One draft per supplier is prepared from the shortfall. While each branch buys from one supplier it is one draft, as today. |
| Receive goods | Type received quantities, complete. | "Received as ordered" posts one receipt for all open lines. The lot is created from the receipt date and the product's shelf life, so nobody types a lot. Reject quantities and a second receipt are optional parts of the same form. |
| Deliver | Type quantities per PO line, save. | The same form creates a shipment for the remaining quantity, picks lots first-expired-first-out, and posts dispatch and proof of delivery together when the user confirms the customer took everything. Rejections, substitution and a run are optional. |
| Invoice | Does not exist. | A draft invoice is created at proof of delivery under the customer's grouping rule. Finance issues the day's drafts in one action, because an issued invoice is a tax document with a gap-free number. |
| Match a payment | Confirm the suggested POs. | The same suggestion, now over invoices. Remittance advice documents make more suggestions exact. Deduction kinds appear only when the amounts differ. |
| Count stock | Type counts, save. | The counter does the same. A variance under the threshold posts at once, as today. Only a larger one, such as 300 typed for 30, waits for a second person. |
| Waste | Quantity and a reason. | The same, with the reason picked from a list. Approval applies only above the branch's threshold. |

Three techniques produce this:

- **Auto-created single child.** Allocation, lot, shipment and invoice are created by the system from defaults. The user edits them only to split or change.
- **Master data that learns.** Sites, aliases with unit factors, customer names and bank identities are saved from each human decision, so the same question is not asked twice.
- **Exception fields are optional parts of the usual form.** There is one receiving form and one delivery form. There is no separate "advanced" flow to maintain.

## 5. Moving existing rows

The design notes in the mdgraph are dated 1–3 October 2026, so the system is days old. Inferred from that, not counted: tables are small. They grow with each day of use, so the structural phases are cheapest now.

Method for every phase: add new tables and columns, backfill with an idempotent script that has a dry-run mode and prints counts, switch reads, switch writes, then drop old columns one phase later. Each backfill has a reconciliation query that must return zero differences before the switch. Before the first backfill, dump the database and record the reconciliation numbers: PO count by status, stock per branch and product, receivable total, paid total, movement count by type. After each backfill the same numbers must match, with every difference on a listed exception. The owner signs off before old columns are dropped. Until that drop, rollback is a redeploy of the previous code. After it, rollback is the dump.

| Existing rows | Move to | Check |
|---|---|---|
| `InboundDocument` (table `InboundEmail`) | One `InboundMessage` per row, taking `sourceRef`, sender, subject and received time. The row stays as the document. `parsedJson` becomes `Extraction` attempt 1. | Row counts equal. |
| `InboundDocument.status` | `PO` rows link through revision 1. `DUPLICATE` rows become revisions: `IDENTICAL` when `parsedJson` matches the PO, else `PROPOSED` for an admin. `FAILED` rows enter the retry queue. `NOT_PO` rows get kind `UNKNOWN` and are re-classified by a batch run. | Every document has a kind and a queue. |
| `PurchaseOrder` and `PurchaseOrderLine` | Revision 1 with a snapshot of the current row. `orderNo` assigned in id order. Lines keep their ids. `baseQty = qty`, and `baseFreeQty = freeQty` only on POs not yet delivered. | Line count and totals equal. |
| `PurchaseOrder.status` | `PENDING` and `NEEDS_REVIEW` keep their names in `approvalStatus`. `VERIFIED` and `DELIVERED` become `APPROVED`. `REJECTED` stays. `PurchaseOrder.reviewNote` text is kept and split into `PoFlag` rows where it matches the intake problem strings. | Count per status maps one to one. |
| `PurchaseOrder.verifiedById`, `verifiedAt` | One `PoApproval` row per verified PO. Earlier verify and cancel events are rebuilt from `AuditLog` actions `po.verified` and `po.pending`. | One current approval per approved PO. |
| `PurchaseOrder.shipToName`, `shipToAddress`, `shipToLat`, `shipToLng` | `CustomerSite` rows, de-duplicated per customer on normalised name and address. Default branch is the branch most often assigned. Near-duplicates are listed for an admin to merge. | Every PO has a site. |
| `PurchaseOrder.branchId`, `deliveryDate` | One `OrderAllocation` per line of each approved PO. | Committed stock per branch and product equals the figure `branchStock()` gives before the switch. |
| `PurchaseOrderLine.deliveredQty` and `DELIVERY_OUT` movements | One `Shipment` per `AuditLog` row with action `po.delivered` or `po.deliver_partial`, with lines from the movements of that event. Accepted equals shipped. Marked `legacy`. A `DELIVERY_OUT` movement with no `poLineId` is listed for hand review and never guessed. | Sum of shipment lines per PO line equals `deliveredQty`. |
| Delivered POs | One `Invoice` per PO from its legacy shipments, total taken from `PurchaseOrder.total`, date from `deliveredAt`, due date by today's formula. Marked `legacy`, with a number from a legacy series. A difference between line sum and printed total becomes a rounding line, so M1 holds. An approved PO with partial delivery gets a draft invoice for the delivered quantity, left for finance to issue or discard. | Sum of open invoice amounts equals the sum from `openReceivables()` before the switch. |
| `PaymentAllocation.purchaseOrderId` | One `Payment` per matched `BankTransaction`, method `TRANSFER`, payer from the transaction. Each allocation moves to that payment and to the `invoiceId` of its PO's legacy invoice, kind `INVOICE`. | Sum per invoice equals `PurchaseOrder.paidAmount`. Then `paidAmount` is dropped. |
| `BankTransaction` | `feed = MOOTA`. `mutationId` stays the idempotency key. `accountNumber` values become `BankAccount` rows. | Row counts equal. |
| `BranchStock.qty` | `StockBalance` in the product's catch-all lot, condition `GOOD`. | S1 holds for every branch and product. |
| `BranchStock.bufferQty` | One `BufferRule` with no weekday. | Shortfall per branch and product is unchanged. |
| `Customer.npwp` | One `CustomerTaxId` row, kind by digit count. | Lookup by NPWP returns the same customer. |
| `StockMovement` | `lotId` is the catch-all lot. `occurredAt = recordedAt = at`. `FACTORY_IN` rows get a legacy `GoodsReceipt` per `AuditLog` receive event. `WASTE` rows get a legacy `StockAdjustment` with reason `OTHER` and the note. `ADJUSTMENT` rows link to their `StockOpnameLine`. | Every movement has exactly one document line (S2). |
| `StockOpname` | Status `APPROVED`, approver empty, marked `legacy`. | None needed. |
| `FactoryOrder`, `FactoryOrderLine` | `Supplier` rows from the distinct values of `Product.producer`. Each order is split by the supplier of its lines, so every order has one supplier. `receivedQty` is checked against the legacy receipts and then dropped. | Sum of receipt lines equals `receivedQty` per line. |
| `Product.barcode`, `Product.unit` | One `ProductBarcode` row. One `ProductUnit` row with factor 1. | Barcode lookups return the same product. |
| `Customer.senderEmails`, `Customer.paymentTerms` | `CustomerContact` rows. `termDays` from the first number, `termBasis = DELIVERY`, so due dates do not change. Terms with no plain day count (for example "V021") are listed for an admin. | Due dates before and after are equal. |
| `User.role`, `User.branchId` | Three seeded `RoleDef` rows and one `UserRole` per user. | Each user passes and fails the same permission checks as before. |
| `AuditLog` | Unchanged. New columns are null on old rows. | None needed. |

Old stock with no lot stays in the catch-all lot and is used first by picking. It runs out within days because the goods are fresh, so there is no need to guess lots for it. The first approved opname after the stock phase is the clean starting point.

## 6. Phases and dependencies

```
P0 ──► P1 ──► P2 ──► P3 ──┐
 │      │                 ├──► P5 ──► P6 ──► P7 ──► P8
 │      └────► P4 ────────┘
 └──────────► P4
```

| Phase | Content | Needs | Why here |
|---|---|---|---|
| P0. Ground rules | Database backups. Stop-loss fixes on today's code: no cancelling of verification and no line delete once anything has shipped, mapped lines required at verification, every attachment read, a screen for failed documents, the delivery race closed, an unmatch that marks and does not delete. The command wrapper with audit in the same transaction. `Setting`, `Outbox`. Document lifecycle (draft, posted, void, reversal). `occurredAt` and `recordedAt`. Version check. `NumberSeries`, `Attachment`. `AuditLog` columns. The nightly rule check with today's rules (S1 and the paid-amount sum). A single `can(user, permission, branch)` function in front of today's three roles. | None | Every later table uses these. The rule check must exist before any backfill so that it can prove the backfill. |
| P1. Master data | `CustomerSite`, `CustomerName`, `CustomerContact`, merge, group. `Supplier`, `SupplierProduct`. `ProductBarcode`, `ProductUnit`, shelf life. Alias unit factor. Structured terms. `PriceAgreement`. `CalendarDay`. `SellerEntity`, `TaxRule`, `ProductDocument`, branch working days and capacity. | P0 | P2 to P7 each point to these rows. This is also where the owner's staff interviews land, as data and not as code. |
| P2. Intake | `InboundMessage`, many documents per message, many orders per document, `Extraction` attempts and retry queue, document kinds, `PoRevision`, cancellation and revision handling, `IntakeChannel` and `IntakeRun`. | P0, P1 | Fixes the losses that happen today without anyone seeing them: dropped revisions, dropped cancellations, second attachments. |
| P3. Order | `approvalStatus`, `PoApproval`, `PoFlag`, stable lines, base quantities and free goods, `OrderAllocation`, close short, cancel, order kinds, confirmation event, `OrderSchedule`, the shortage screen. | P1, P2 | Allocations are what stock and delivery commit to. |
| P4. Stock and supply | `StockLot`, `StockBalance`, condition, movement links. `SupplierOrder` with supplier and many per day. `GoodsReceipt`. `StockAdjustment`. Opname with approval. `StockTransfer`. `SupplierReturn`. Dated shortfall with `BufferRule`. Branch-wide hold. | P0, P1 | Does not need P2 or P3, so it can be built alongside them. It covers the owner's own example. |
| P5. Delivery and returns | `Shipment`, proof of delivery, rejection, substitution, `DeliveryRun`, `CustomerReturn`. | P3, P4 | Needs allocations to ship against and lots to ship from. |
| P6. Receivables | `Invoice`, grouping rule, due-date basis, `InvoiceSubmission` with its document set, `CreditNote`, `CreditApplication`, `InvoiceDispute`, tax invoice status. | P5 | An invoice bills accepted shipment lines. |
| P7. Money | `BankAccount`, `Payment`, allocation kinds, on-account credit, reversal rows, refunds, remittance advice matching, Moota polling as a second feed. | P6 | Allocations point to invoices. |
| P8. Access and control | `RoleDef`, `UserRole`, separation switches, delegation, `PeriodLock`, the full S and M rule check with a report for the owner. | P0 for the seam, all others for the permission list | The permission list is complete only when every document exists. The seam from P0 means no earlier phase hard-codes a role. |

Order inside a phase is always the same: tables, backfill, reconciliation, read switch, write switch, usual-case action count, drop old columns in the next phase.

P2 and P4 are the two to start first after P1. P2 stops silent data loss. P4 is the owner's stated example and has no dependency on the order work.

## 7. Decisions made here, and questions for the business

### 7.1 Decisions made in this plan

These are technical. They do not need the business.

1. Link rows with quantities or amounts replace counters and single foreign keys everywhere (rule 1).
2. One table per real document type, sharing one header convention. No generic "document" table.
3. Ledgers are the truth. Stored totals are caches checked each night.
4. Posted rows are frozen. Corrections are reversals. Reversal is refused while a later document depends on the row.
5. Approval, fulfilment and payment are separate. Only approval is stored as a decision.
6. `PurchaseOrder`, `PurchaseOrderLine`, `InboundDocument`, `StockMovement`, `BankTransaction`, `PaymentAllocation`, `StockOpname` and `AuditLog` keep their names and ids. `FactoryOrder` is renamed in Prisma only, through `@@map`.
7. A PO line keeps one identity for life. Revisions are applied as differences.
8. `OrderAllocation` is the unit of commitment, not the PO and not the line.
9. Every stock row has a lot. A catch-all lot per product stands in when the lot is not known, so no code path handles "no lot".
10. Two times on every stock event: when it happened and when it was typed.
11. Stock leaves at dispatch. The billable quantity is fixed at proof of delivery.
12. A receivable is an invoice. The system drafts it and a person issues it.
13. `BankTransaction` mirrors the bank feed and nothing else. `Payment` is the money-in row for every method.
14. Permissions are checked through one function from P0 on. Role tables come last.
15. Per-customer, per-supplier and per-branch behaviour is a column on the master row. The global default is a `Setting` row.
16. The system never approves, closes, writes off or allocates by itself. It flags, drafts and suggests. This extends owner requirement 1 from POs to every decision that changes stock or money.
17. Every posting goes through one command wrapper, and its audit rows commit with it.
18. Branch users see a PO through its allocations. `PurchaseOrder.branchId` is only the default that fills them.
19. Date-only columns follow the UTC rule already in `src/lib/format.ts`.

### 7.2 Questions only the business can answer

Each has the default this plan assumes. The model is the same whatever the answer. The answer sets a default value or a switch.

| # | Question | Default assumed |
|---|---|---|
| 1 | How many suppliers are there, and which product comes from which? | Three, as in the BPOM registry. Each product has one preferred supplier. |
| 2 | Does a supplier deliver an order in full, or over several trips or days? | Any number of receipts per order. A remainder stays open until a person closes it. |
| 3 | What is the shelf life of each product, and does any customer require a minimum remaining life on arrival? | `shelfLifeDays` empty. Lots are recorded and picked oldest first. No expiry block until the number is given. |
| 4 | What happens to expired or damaged goods? Does the supplier take them back or credit them? | Written off at the branch with a reason. No claim on the supplier. |
| 5 | When a customer returns goods, is it a credit, a deduction from payment, or a swap for fresh goods? | Credit note. Returned goods go to `HOLD` and are then written off, never resold. |
| 6 | When a store rejects part of a delivery, do we bill the accepted quantity? | Yes. |
| 7 | Does the business issue its own invoice and faktur pajak? Per delivery, per PO or per period? Which customers require tukar faktur? | One invoice per shipment, drafted by the system. Tukar faktur off for every customer until switched on. |
| 8 | From which date does each customer count the payment term? | Delivery date, as the code does today. |
| 9 | If the PO price differs from the agreed price, which one do we bill? | The PO's printed price. The difference is a flag at approval. |
| 10 | What does each customer's unit mean in packs (PAC, PCK/1, EA, carton)? | Factor 1. A unit text not seen before for that customer and item is a flag. |
| 11 | Are free goods on a PO shipped from stock at zero price? | Yes. |
| 12 | After a partial delivery, is the rest delivered later or dropped? | It stays open and is flagged after the PO's expiry date, or one day after the delivery date. A person closes it. |
| 13 | Are customers' paying bank accounts fixed? | No. Sender text is a hint and a customer may have several. |
| 14 | Does money arrive any other way: cash, giro, another account? | BCA through Moota only. Manual entry exists and needs a proof attachment. |
| 15 | Which deductions do customers take from payments (rebate, listing fee, penalty, withheld tax)? | Any, recorded with a reason code. Above Rp 5,000 a note is required, matching today's tolerance. |
| 16 | Do branches move stock between each other? | Rarely. Transfers exist with two steps. |
| 17 | May a branch ship goods before the receipt is typed in? | Yes, with a flag. A per-branch switch can forbid it. |
| 18 | Who approves a stock count and a write-off, and above what size? | A second user at the branch or pusat, for a count variance above 5 packs per product. Write-offs above a threshold set per branch, starting at zero so that all need approval. |
| 19 | May an admin approve many flag-free POs in one action? | No. The owner asked for the admin's discretion on every PO, so each is opened and approved singly. The switch exists and is off. |
| 20 | What jobs exist at a cabang (head, warehouse, driver, admin)? | One branch role with today's rights. |
| 21 | Must the person who types a manual PO be different from the approver? | No. The switch exists and is off. |
| 22 | How far back may a user date a stock event, and who locks a period? | Back to the last lock. A pusat admin locks after each approved weekly count. |
| 23 | Do Lotte and AEON change a PO on the portal after posting it? | Unknown. A re-upload of the same number becomes a revision. |
| 24 | Do we reply to customers to confirm or revise a PO (Astro asks within one working day)? | The reply is sent by hand outside the system and recorded as a confirmation event. |
| 25 | What is a Ranch Market "PURCHASE ORDER RTV"? In retail, RTV usually means return to vendor. | Treated as a PO, as intake does today. |
| 26 | Does a supplier ever deliver straight to a customer's DC? | Rarely. Recorded as a receipt and a shipment at the responsible branch. |
| 27 | Which customers are billed with PPN, and at what rate? | The invoice copies the tax the PO prints per line. |
| 28 | What is the Cikokol outlet that sends sales reports: an own shop or goods on consignment? | Outside this plan. Its mails are stored with kind `OTHER`. |
| 29 | Does the group sell through more than one legal entity? | One: CV Sumber Sehat Pangan. |
| 30 | Does a customer ever send one PO for several stores or several delivery dates? | Rarely. Allocations cover it. The usual PO has one site and one date. |
| 31 | Who orders from the producer: each cabang, or kantor pusat for all? | Each cabang. Pusat can see and edit every order. |
| 32 | Does the producer deliver to each branch, or do branches collect? Can one truck serve two branches? | The producer delivers to each branch. |
| 33 | What is each supplier's lead time, and how are weekends and holidays covered? | One day. Friday's order covers Saturday and Sunday. |
| 34 | When supply is short, which customer is served first? | By planned date. A person decides each cut. |
| 35 | Do crates or buckets stay at the customer and come back later? | No. Packaging is not tracked. |
| 36 | Which customer DCs need a delivery appointment? | None. |
| 37 | What documents does each customer's tukar faktur desk require? | Invoice and signed surat jalan. |
| 38 | Who uploads the faktur pajak, and does every invoice use the 11/12 tax base? | Finance uploads by hand and types the number. The tax rule valid on the invoice date applies to all. |
| 39 | Is there a trading-terms contract with rebates or fixed fees per customer? | None recorded. Deductions carry a reason code only. |
| 40 | Does any customer need temperature evidence or a certificate with a delivery? | No. |

## 8. Cases covered by a seam and not built

Each of these has a place to attach later without changing a table already described. None is built until its trigger appears.

| Case | Trigger | Where it attaches |
|---|---|---|
| Consignment stock at a customer | Answer to question 28 is "consignment" | A site of type consignment that holds `StockBalance`. Shipment becomes a transfer to it. A sales report becomes the billable event. |
| Several selling legal entities | Answer to question 29 is "more than one" | A second `SellerEntity` row, and a seller on `PurchaseOrder`. |
| Supplier bills and money paid out | The owner asks for payables or margin | `GoodsReceiptLine` is what a supplier bill matches. `BankTransaction` debits already arrive. |
| Product kits and promo bundles | A customer orders one code that ships as two products | A component list on `Product`. `OrderAllocation` is created per component. |
| Storage places inside a branch | A branch gets a second cold room or a van stock | A `locationId` added to the `StockBalance` key, defaulting to one location per branch. |
| Route planning and driver app | Volume makes hand-built runs slow | `DeliveryRun` and `Shipment` status already carry what it needs. |
| Virtual account per customer | The owner revisits the Xendit or Midtrans option | A new `feed` on `BankTransaction` whose external id names the customer exactly. |
| Returnable crates and buckets | Answer to question 35 is "yes" | A `PackagingMovement` ledger per customer site, written from `ShipmentLine` and from a return. A crate count on the surat jalan. |
| Rebate accrual against trading terms | Answer to question 39 names a contract | A `TradingTerm` row per customer and period. A deduction allocation is checked against what the term expects. |
