Book 3, Chapter 12 — Purchases & Requisitions¶
Module boundary (START HERE):
bizwiz/resources/views/admin/includes/sidebar_includes/purchases.blade.phpPrimary routes:bizwiz/routes/web.php(approx lines 3480–3830),bizwiz/routes/modules/accounts_payables.phpPrimary controllers (all inbizwiz/app/Http/Controllers/Admin/):PurchaseOrderController,ExternalRequisitionController,ApproveExternalRequisitionController,ResolveRequisitionToLpoController,ApproveLpoController,PurchaseReportsController,PurchaseDiscountController
1. Purpose¶
Plain language. This is the module a retail/wholesale business uses to buy goods from suppliers. It covers two connected flows:
- Branch (external) requisitions — a shop/branch says "we're running low on these items, please buy them." A branch initiates a requisition, it goes up an approval chain, and once approved it is resolved into a Local Purchase Order (LPO) — the actual order document sent to a supplier.
- Purchase Orders / LPOs — the formal order to a supplier. Buyers can create an LPO directly (skipping the requisition step), route it for approval, send it to the supplier, and track it through to completion.
An LPO ("Local Purchase Order") is the central document. Its life is: draft → routed for approval → approved → sent to supplier → goods received → completed. Once goods are received against it (Book 2 Ch9, Goods Receiving / GRN) and the supplier invoices, the LPO becomes a payable/bill in Accounts Payable (Ch13).
Who cares: branch managers (raise requisitions), central procurement/buyers (create and manage LPOs, pick suppliers, negotiate pricing), and approvers/finance (authorize spend before an order goes out).
The module also carries reports (purchases by store, family group, supplier; LPO status & lead-time), suggested orders (system-generated buy suggestions from sales velocity, out-of-stock, and reorder levels), and self-collection suppliers (suppliers whose goods the buyer collects themselves rather than having delivered).
2. Users & roles¶
Access is gated three ways throughout the module:
role_id == 1— the super-admin / administrator bypass. Present on nearly every nav item and controller check. (One nav item usesconfig('app.allowed_role')instead — the Self Collection Suppliers link,purchases.blade.php:141.)$my_permissions['module___action']— granular per-module permission strings (double-underscore separates module from action). Checked in Blade nav viaisset(...)and in controllers via$this->mypermissionsforAModule()returning an array (or the literal string'superadmin').can($action, $module)helper — defined atapp/helpers.php:4182(function can(string $action, string $module, ?User $user = null): bool). Used byPurchaseOrderController(e.g.PurchaseOrderController.php:76if (!can('view', $this->model))).
Permission strings that gate this module (from purchases.blade.php + controllers)¶
| Nav item / action | Permission string | Source |
|---|---|---|
| Whole "Purchases" menu | purchases___view |
purchases.blade.php:1 |
| Branch Requisitions submenu | purchase_requisitions___view |
purchases.blade.php:33 |
| Initiate Requisitions | external-requisitions___view |
purchases.blade.php:45 |
| Archived Branch Requisitions | external-requisitions___archived-requisition |
purchases.blade.php:51 |
| Approve Branch Requisition | approve-external-requisitions___view |
purchases.blade.php:56 |
| Resolve Requisition to LPO | resolve-requisition-to-lpo___view |
purchases.blade.php:62 |
| Suggested Orders | suggested-orders___view |
purchases.blade.php:70 |
| Status Report (requisitions) | external-requisitions___external-requisition-report |
purchases.blade.php:78 |
| Purchase Orders submenu | purchase_orders_module___view |
purchases.blade.php:89 |
| New Purchase Order | purchase-orders___view |
purchases.blade.php:103 |
| Approve LPOs | approve-lpo___view |
purchases.blade.php:109 |
| Declined LPOs | declined-lpo___view |
purchases.blade.php:115 |
| Archived LPOs | archived-lpo___view |
purchases.blade.php:121 |
| Completed LPOs | completed-lpo___view |
purchases.blade.php:126 |
| Status Report (LPO) | purchase-order-status___view |
purchases.blade.php:131 |
| Self Collection Suppliers | self_collection_suppliers___manage |
purchases.blade.php:141 |
| Reports | purchases-reports___view |
purchases.blade.php:149 |
Additional action-level strings enforced inside controllers (not just nav view):
- external-requisitions___view / ___view-all / ___add / ___archived-requisition (ExternalRequisitionController — approx lines 72, 134, 627)
- approve-external-requisitions___view (ApproveExternalRequisitionController.php:41)
- resolve-requisition-to-lpo___view / ___view-all / ___add (ResolveRequisitionToLpoController — approx lines 50, 81, 102)
- approve-lpo___edit — required to actually approve/decline (ApproveLpoController.php:86,120), while ___view gates the list (ApproveLpoController.php:38)
- Completed-LPO edit gate: PurchaseOrderController.php:900 — if ($order->status == 'COMPLETED' && !auth()->user()->isAdministrator() && !can('edit', 'completed-lpo'))
Approval-authority levels (multi-level sign-off)¶
Approval is not a single yes/no — it is a level-based chain:
- LPOs: wa_purchase_order_permissions rows (relation getRelatedAuthorizationPermissions, ordered by approve_level asc — WaPurchaseOrder.php:146). A user's purchase_order_authorization_level (on the users row, selected at ApproveLpoController.php:44) determines whether they can clear a given level.
- Requisitions: wa_external_req_permissions (model WaExternalReqPermission), and the user's external_authorization_level. Below-level approvals set status to PROCESSING and route to the next approver; the top level sets APPROVED.
(INFERENCE on exact role_id numbers: the requisition sub-agent reported role_id == 152 = branch manager and role_id == 154 = supplier user used for filtering. Treat the specific numbers as inference until confirmed against a roles seed/table.)
3. Processes¶
3.1 Branch (External) Requisition → Approval → Resolve to LPO¶
stateDiagram-v2
[*] --> UNAPPROVED: Branch initiates requisition (store)
UNAPPROVED --> PENDING: sendRequisitionRequest()
PENDING --> PROCESSING: approver clears a level (more levels remain)
PROCESSING --> PROCESSING: next-level approver
PROCESSING --> APPROVED: final level cleared
PENDING --> APPROVED: single-level approve
PENDING --> DECLINED: approver declines
PROCESSING --> DECLINED: approver declines
APPROVED --> RESOLVED: ResolveRequisitionToLpo (all items resolved)
DECLINED --> [*]
RESOLVED --> [*]: LPO (WaPurchaseOrder) created, status PENDING
Trigger: A branch user opens Branch Requisitions → Initiate Requisitions (external-requisitions.index, ExternalRequisitionController@index). They pick items — either manually, from out-of-stock suggestions (getOutOfStockItems), or from sales-based suggestions (getSalesBasedSuggestions).
States & routes:
| Step | Route / name | Controller method | Status effect |
|---|---|---|---|
| Initiate / save | external-requisitions.store |
ExternalRequisitionController@store |
requisition created (PENDING on send) |
| Non-stock variant | external-requisitions.store_non_stock |
store_non_stock |
same |
| Send for approval | external-requisitions.sendRequisitionRequest |
sendRequisitionRequest |
→ PENDING |
| Approve queue | approve-external-requisitions.index |
ApproveExternalRequisitionController@index |
lists to approve |
| Approve/decline | approve-external-requisitions.* (resource update) |
update |
PROCESSING/APPROVED/DECLINED |
| Resolve list | resolve-requisition-to-lpo.index |
ResolveRequisitionToLpoController@index |
shows APPROVED reqs |
| Resolve → LPO | resolve-requisition-to-lpo (resource store/update) |
update |
creates LPO; req → RESOLVED |
| Merge LPOs | resolve-requisition-to-lpo.merge / merge-lpo |
showAvailableLposMerge / mergeLpos |
combine reqs onto one LPO |
| Transfer | resolve-requisition-to-lpo.transfer |
transferRequisitions |
move reqs between branches |
Approval chain (ApproveExternalRequisitionController@update): uses WaExternalReqPermission rows keyed by approve_level. If a level above the current approver's external_authorization_level still exists, status → PROCESSING and it routes on; when the last level clears, status → APPROVED. Decline sets DECLINED and removes higher-level permissions.
Resolve to LPO (ResolveRequisitionToLpoController@update, approx lines 218–308):
1. (If supplier-portal/trade-agreement enforcement is on) validates a trade agreement exists for the supplier.
2. Creates a new WaPurchaseOrder — copies restaurant_id, wa_department_id, wa_supplier_id, store location, date from the requisition; generates purchase_no from the number series 'PURCHASE ORDERS'; sets LPO status PENDING (ResolveRequisitionToLpoController.php:250). Note: a different resolve path sets PRELPO (ResolveRequisitionToLpoController.php:136) — see §6.
3. Creates a WaPurchaseOrderItem per requisition line (qty, note, cost, VAT copied).
4. Marks each source wa_external_requisition_items.is_resolved = '1' (:290); when all a requisition's items are resolved, sets that wa_external_requisitions.status = 'RESOLVED' (:295).
5. Calls addPurchaseOrderPermissions(...) to seed the new LPO's approval levels.
Outcome: an approved branch demand becomes a real LPO in PENDING, ready to route for LPO approval and dispatch to the supplier.
3.2 Direct LPO / Purchase Order lifecycle¶
stateDiagram-v2
[*] --> DRAFT: PurchaseOrderController@store (new LPO)
DRAFT --> PENDING: sendRequisitionRequest() [from UNAPPROVED]
PENDING --> APPROVED: ApproveLpoController@update (approve)
PENDING --> DECLINED: ApproveLpoController@update (decline)
APPROVED --> sent: sent_to_supplier=true (dispatch)
sent --> COMPLETED: goods received (GRN) + closed
APPROVED --> COMPLETED
note right of PENDING: needs_approval = true → shows in Approve LPO queue
note right of DECLINED: LpoDeclined mail to creator
note right of APPROVED: event LpoApproved fired
DRAFT --> archived: is_hide = 'Yes' (Archived LPOs)
Trigger: buyer opens Purchase Orders → New Purchase Order (purchase-orders.index, PurchaseOrderController@index, gated by can('view','purchase-orders')).
Create (PurchaseOrderController@store, approx 350–650):
- Reserves the PO number before the DB transaction via NumberSeriesGeneratorService::getNextSeriesNumber('PURCHASE ORDERS') (:460) — uses a Redis/cache lock to prevent duplicate numbers across concurrent branches (comment at :456).
- Builds a WaPurchaseOrder (:466): restaurant/branch from the store location, department & UOM from the logged-in user, supplier (wa_supplier_id), delivery mode (supplier_own: OwnCollection / third-party transporter / supplier-delivered — :485), discounts (transport_rebate_discount*, invoice_discount*), consignment flag, invoice_payment_grace_period (default 30). Sets status = 'DRAFT' (:498).
- Loops items → WaPurchaseOrderItem rows with cost, VAT, rounding, discounts (discount_settings JSON), UOM conversion (InventoryItemConversionUnit), and stock-status lookups (wa_inventory_location_stock_status for max_stock, re_order_level).
Route for approval (sendRequisitionRequest, :795): finds the LPO where status == 'UNAPPROVED' and flips it to PENDING (:802). (See open questions — code sets DRAFT on create but sendRequisitionRequest looks for UNAPPROVED.)
Approve/decline (ApproveLpoController@update, :117): requires approve-lpo___edit.
- Queue is WaPurchaseOrder::where('needs_approval', true) (ApproveLpoController.php:40).
- Approve (:139): status='APPROVED', needs_approval=false, approved_by, approved_at=now(), then event(new LpoApproved($row)) (:146). NOTE: there are two similarly-named artifacts — the event App\Events\Purchases\LpoApproved (fired here, ApproveLpoController.php:17) and a separate mailable App\Mail\LpoApproved (imported by PurchaseOrderController.php:10, used on the send-to-supplier path). Don't conflate them.
- Decline (:150): status='DECLINED', same audit fields; emails the creator via Mail::to(...)->send(new LpoDeclined($row)) (:160).
- Approvers can also edit line prices/qty pre-approval (updatePurchaseItem, :302) — recalculates cost, discount, VAT, round-off. Items already in goods-receiving (WaReceivePurchaseOrderItem) cannot be deleted (:209).
Status filters (PurchaseOrderController@orders, :131):
- pending: is_hide='No' AND status NOT IN ['PRELPO','COMPLETED','DECLINED'] AND doesntHave('grns') (no goods yet received — a partially/fully received LPO drops off the pending list)
- archived: is_hide='Yes'
- completed: status='COMPLETED'
- declined: status='DECLINED'
- The orders() DataTables feed also filters where('needs_approval', false) (:124) — i.e. the main list excludes items still in the approval queue.
Send to supplier / dispatch: sent_to_supplier, supplier_accepted, goods_released, slot_booked columns on wa_purchase_orders track supplier hand-off and delivery-slot booking (selected in orders() at :115–120).
Completion: happens when goods are received against the LPO (GRN, §5) and the order is closed → status='COMPLETED'. Archive is a soft-hide (is_hide='Yes', set at :203/:218), which also archives the linked inbound delivery (InboundDelivery::STATUS_ARCHIVED, :207).
3.3 Suggested Orders¶
Two engines feed buy suggestions (from ExternalRequisitionController):
- Out-of-stock (getOutOfStockItems): flags items where max_stock − stock-on-hand > 0 (UOM-based) or round(total_sales/3 − available_stock) > 0 (SKU-based); factors reserved qty, qty-on-order, pending sales orders.
- Sales-based (getSalesBasedSuggestions): items sold in a date window with sales > 0, rolling pack sales down to base units via conversion factors.
- Reorder levels: wa_inventory_location_stock_status.re_order_level.
- Reports: branch-requisitions.suggested-orders → ReportsController@suggested_order_report_for_purchases; reports.suggested_order_report.
UOM-based vs SKU-based branch is driven by AdministrationSetting name INVENTORY_SETUP (= 'UOM BASED'). See §6.
4. Tables touched & key data¶
wa_purchase_orders (the LPO header) — database/migrations/2023_09_08_134414_create_wa_purchase_orders_table.php¶
Original enum (:28): **status ENUM('UNAPPROVED','PENDING','PROCESSING','APPROVED','DECLINED','PRELPO','COMPLETED') DEFAULT 'UNAPPROVED'**.
Key columns:purchase_no,slug,user_id,restaurant_id,wa_department_id,wa_supplier_id,wa_location_and_store_id,purchase_date,is_hideENUM('Yes','No'),return_status,invoicedENUM('Yes','No'),vehicle_reg_no,receive_note_doc_no,supplier_archived.
Later-added columns (grep migrations):needs_approval(bool,2026_03_01_082709_add_needs_approval_wa_purchase_orders.php), LPO approval audit columnsapproved_by/approved_at/approval_reason(2026_03_13_150000_add_lpo_approval_audit_columns.php), pluslpo_type,sent_to_supplier,supplier_accepted,goods_released,slot_booked,mother_lpo,advance_payment,lpo_is_consignment,invoice_payment_grace_period, delivery/transport fields (supplier_own,vehicle_id,employee_id,wa_transporter_supplier_id`), discount fields.
QUIRK — status enum vs code: the migration enum does not include DRAFT, yet PurchaseOrderController@store writes status='DRAFT' (:498) and ApproveLpoController writes status='APPROVED' (:139, in enum) and ResolveRequisitionToLpoController writes PRELPO (:136). MySQL ENUM will coerce an out-of-range value (DRAFT) to '' (empty string) unless a later migration widened the enum. This is a real data-integrity smell — see §7. (No migration adding DRAFT to the enum was found by grep.)
wa_purchase_order_items — ..._create_wa_purchase_order_items_table.php¶
Line items: wa_purchase_order_id, wa_inventory_item_id, quantity / supplier_quantity, order_price, standard_cost, total_cost, discount_percentage, discount_amount, discount_settings (JSON), vat_rate, vat_amount, round_off, total_cost_with_vat, other_discounts_total, note.
wa_external_requisitions — 2023_09_08_134414_create_wa_external_requisitions_table.php¶
status ENUM('UNAPPROVED','PENDING','PROCESSING','APPROVED','DECLINED','RESOLVED') DEFAULT 'UNAPPROVED' (:25); purchase_no, slug, user_id, requisition_date, restaurant_id, wa_department_id, is_hide. Later: merge_no (2024_05_27...), and external_requisition_no was added to wa_internal_requisitions (2026_04_16_130000...) — a cross-link.
wa_external_requisition_items¶
wa_external_requisition_id, wa_inventory_item_id, quantity, standard_cost, total_cost, vat_rate, vat_amount, total_cost_with_vat, note, and is_resolved bool (added 2023_11_18_230348_add_is_resolved_to_wa_external_requisition_items.php) — the "converted to LPO" flag.
wa_internal_requisitions — status ENUM was widened to include PAID, DELIVERED (2023_09_22_111306_add_additional_status_options_to_wa_internal_requisitions.php:17). Internal requisitions are the store-issue/inter-department flow (distinct from external/supplier requisitions); PurchaseOrderController imports WaInternalRequisitionItem (used for "unsold item" checks and stock reconciliation).¶
Supporting/permission tables¶
wa_purchase_order_permissions(LPO approval levels),wa_external_req_permissions(requisition approval levels).wa_supplier_distributors(WaSupplierDistributor,distributorsFK +status) — used bycheckSupplierType(distributor vs direct supplier).wa_user_suppliers(WaUserSupplier) — which suppliers a user may buy from.own_collection_suppliers(OwnCollectionSupplier) — self-collection suppliers.trade_agreements(TradeAgreement) — supplier trade agreements validated byapp/Rules/PurchaseOrder/TradeAgreementValidator.php.
Verbatim misspellings (kept as-is in code — grep before changing): method getDapartments() / route external-requisitions.get-departments; getInventryItemDetails / getInventryItemPricing / route names purchase-orders.getInventryItemDetails ("Inventry"); hidepurchaseorder; unarchieved-lpo route (purchase-orders.unarchive_lpo); DB column qauntity in wa_stock_moves (referenced in ResolveRequisitionToLpoController subqueries).
5. Interactions with other modules¶
flowchart LR
REQ[External Requisition] -->|resolve| LPO[WaPurchaseOrder / LPO]
LPO -->|goods arrive| GRN[GRN / Goods Receiving\nBook2 Ch9]
GRN -->|receipt posted| STOCK[Inventory stock moves]
GRN -->|supplier invoice| AP[Accounts Payable / Bill\nCh13]
LPO -.->|advance payment| AP
Seam BACK to Book 2 Ch9 (Goods Receiving / GRN):
- WaPurchaseOrder relates to receipts via getRelatedGrn / getRelatedGrns (hasMany WaGrn on wa_purchase_order_id, WaPurchaseOrder.php:151–161) and getReceiveOrder (hasOne WaReceivePurchaseOrder, :37).
- Receiving controllers live alongside PO routes: ReceivePurchasedOrderController (receive-purchase-order.*, incl. a direct-GRN path), ConfirmedReceiveOrderController, ProcessReceiveOrderController, CompletedGrnController, WeighbridgeTicketController (weighbridge → generate GRN) — all in routes/web.php ~3730–3775.
- getRelatedItem_with_grn joins wa_grns to know how much of each PO line was received (WaPurchaseOrder.php:45). Received qty is what drives an LPO toward COMPLETED. Approve-LPO refuses to delete a line if a WaReceivePurchaseOrderItem already exists for it (ApproveLpoController.php:209).
- Stock impact: getRelatedStockMoves (WaStockMove on wa_purchase_order_id, :169).
Seam FORWARD to Ch13 Accounts Payable (a received PO becomes a payable/bill):
- WaPurchaseOrder → getSuppTran (hasOne/hasMany WaSuppTran on wa_purchase_order_id, WaPurchaseOrder.php:174–181). WaSuppTran belongsTo WaPurchaseOrder (WaSuppTran.php:40) and to WaSupplier by supplier_code — this is the supplier ledger transaction (the payable).
- getRelatedGlTran (WaGlTran on wa_purchase_order_id, :164) — GL postings from the purchase.
- getAdvancePayment (AdvancePayment hasOne, :186) — advance/prepayment on the LPO (lpo_type = 'Advanced', advance_payment flag), a pre-receipt AP obligation.
- AP-side artifacts (SupplierBill, SupplierBillItem, OpeningBalanceInvoice, VAT/statutory/GL payments, return-to-supplier demands) are wired in routes/modules/accounts_payables.php — the received LPO/GRN feeds the supplier bill there. WaSuppTran::creditNoteAllocatableBalance() (WaSuppTran.php:129) shows the payable/credit-note allocation lives on this transaction.
Other seams:
- Inventory (Book 2): item search/pricing/stock-status (wa_inventory_location_stock_status, WaInventoryItem, WaStockMove, InventoryItemConversionUnit); reorder levels & max-stock drive suggested orders.
- Suppliers (this book, other chapters): WaSupplier, WaSupplierDistributor, WaUserSupplier, OwnCollectionSupplier, TradeAgreement, supplier portal (routes/modules/supplier_portal.php).
- Match Purchase Orders (MatchPurchaseOrderController, routes/web.php:3183) — "mother LPO" consolidation (mother_lpo self-relation, WaPurchaseOrder.php:191–196); attach/detach child LPOs, update GRNs and delivery notes.
- Fleet / Tyres (routes/modules/fleet.php): parallel TyrePurchaseOrderController / TyreApproveLpoController — a separate PO pipeline for tyres (out of scope here but shares patterns).
- Non-stock POs (NonStockPurchaseOrderController), Bulk POs (BulkPurchasesController, bulk-purchases.create-lpos), Consumable/Petty-cash/Project-centre POs (own models in app/Models).
6. Alternatives & variants¶
Two resolve paths / two LPO seed statuses. ResolveRequisitionToLpoController has one path that creates the LPO with status='PRELPO' (:136) and another (the main update) that creates it with status='PENDING' (:250). PRELPO is a valid enum value and is explicitly excluded from the pending list filter (PurchaseOrderController@orders:133). INFERENCE: PRELPO = a pre-LPO staging state before the buyer finalizes/prices it; PENDING = ready for approval routing. Both branches documented; exact trigger for each needs confirming (likely single-requisition resolve vs merge/bulk resolve).
UOM-based vs SKU-based inventory. Suggested-order math and item costing branch on AdministrationSetting INVENTORY_SETUP == 'UOM BASED'. UOM-based uses max_stock and UOM conversion factors (InventoryItemConversionUnit); SKU-based uses the sales/3 − available_stock reorder heuristic. Both branches exist in getOutOfStockItems/getSalesBasedSuggestions.
Price/free-stock editing on PO. Guarded by setting('ALLOW_PRICE_AND_FREE_STOCK_EDIT_ON_PURCHASE_ORDER', false) (PurchaseOrderController@store:511). When on and a line is priced exclusive, the code grosses the cost up by the line VAT rate before storing. When off, that branch is skipped.
Delivery mode branch (supplier_own, :483): OwnCollection (buyer's own vehicle+driver), ThirdPartyTransporter (LpoDeliveryModes enum → sets wa_transporter_supplier_id), or supplier-delivered (all transport FKs null). Self-Collection Suppliers menu (own-collection-suppliers.index) manages the OwnCollection supplier list.
Advance-payment LPOs. advance_payment boolean forces lpo_type='Advanced' (:474) and links an AdvancePayment (AP prepayment) — a variant that creates an AP obligation before any goods are received.
Trade-agreement / supplier-portal enforcement. When the supplier portal / trade-agreement feature is active, ResolveRequisitionToLpo and PO creation validate via app/Rules/PurchaseOrder/TradeAgreementValidator.php, PriceListValidator.php, ContactsValidation.php, MaxStockValidator.php. When inactive, these validators are not applied. Specifics:
- PriceListValidator — order price must be ≥ the price-list cost; prefers a branch-specific WaInventoryItemPrice (item + store_location), else the item's default price_list_cost. Only applied when ALLOW_PRICE_AND_FREE_STOCK_EDIT_ON_PURCHASE_ORDER is false (i.e. when buyers can't freely edit price).
- TradeAgreementValidator — only fires when config('app.enable_supplier_portal'); requires a TradeAgreement for the supplier that is is_locked and linked_to_portal.
- MaxStockValidator — skipped for lpo_type='Bulk' and for advance-payment LPOs; otherwise enforces (order_qty + qty-on-order + qty-on-hand) ≤ max_stock from wa_inventory_location_stock_status. Zero max_stock is allowed when the identify-sales-orders-from-normal-orders feature is on.
Legacy vs live requisition tables. wa_internal_requisitions (with ..._demos sibling tables and status widened to include PAID/DELIVERED) is a separate, older flow from wa_external_requisitions. The 2026 migration adding external_requisition_no to wa_internal_requisitions suggests an ongoing bridge/consolidation between the two. INFERENCE: external requisitions are the current supplier-buy flow; internal requisitions are store-issue/inter-department and partially legacy.
Direct GRN (skip formal LPO receiving). receive-purchase-order/direct-grn (ReceivePurchasedOrderController@directCreate/directStore) allows receiving without the standard PO-item flow — an alternative receiving entry point.
7. Open questions to confirm¶
CODE-PROVEN:
- LPO header table is wa_purchase_orders; items wa_purchase_order_items. Original status enum = UNAPPROVED, PENDING, PROCESSING, APPROVED, DECLINED, PRELPO, COMPLETED (migration :28). (proven)
- Approve-LPO queue = needs_approval = true; approve sets APPROVED+needs_approval=false+approved_by/approved_at and fires LpoApproved event; decline sets DECLINED and mails LpoDeclined to creator. (ApproveLpoController.php:40,139,146,150,160 — proven)
- External requisition table wa_external_requisitions, enum incl. RESOLVED; items carry is_resolved; resolve creates a WaPurchaseOrder and marks the requisition RESOLVED when all items resolved. (proven)
- GRN seam: WaGrn / WaReceivePurchaseOrder on wa_purchase_order_id. AP seam: WaSuppTran / WaGlTran / AdvancePayment on wa_purchase_order_id. (WaPurchaseOrder.php relations — proven)
- PO number series key = 'PURCHASE ORDERS' via NumberSeriesGeneratorService. (PurchaseOrderController.php:460 — proven)
INFERENCE / TO CONFIRM:
1. DRAFT status quirk. Code writes status='DRAFT' (store:498) but the enum has no DRAFT, and sendRequisitionRequest looks for status='UNAPPROVED' (:800). Either a migration widens the enum (not found by grep) or MySQL silently coerces DRAFT→''. Confirm the live column definition and whether newly-created LPOs actually persist as DRAFT, UNAPPROVED, or ''. This determines whether sendRequisitionRequest (matching UNAPPROVED) ever fires for freshly-created LPOs. (HIGH-PRIORITY / biggest open question)
2. PRELPO vs PENDING on resolve — which resolve branch fires when, and whether PRELPO requires a separate finalize step before approval.
3. Exact role_id numbers for branch manager (reported 152) and supplier user (154) — verify against roles seed.
4. Where COMPLETED is set. Filters read COMPLETED but the setter (likely in a receiving/GRN-completion controller, not the PO controller) wasn't pinned to a line here — confirm in CompletedGrnController / ConfirmedReceiveOrderController.
5. Requisition store initial status. Sub-agent reported store sets PENDING directly (ExternalRequisitionController:281) while the enum defaults UNAPPROVED — confirm whether "initiate" and "send for approval" are one step or two in practice.
6. config('app.allowed_role') value for the Self Collection Suppliers gate (purchases.blade.php:141) — is it 1 or a different role?
Not yet traced (out of chapter scope but flagged): Tyre PO pipeline, non-stock/bulk/consumable/project-centre PO variants, weighbridge→GRN, and the full AP bill-creation posting (Ch13).
8. Source references¶
Nav / boundary
- bizwiz/resources/views/admin/includes/sidebar_includes/purchases.blade.php:1,33,45,51,56,62,70,78,89,103,109,115,121,126,131,141,149
Routes
- bizwiz/routes/web.php:3482–3506 (external requisitions), 3509–3512 (approve external req), 3693–3729 (PO items/status filters), 3729 (status_report), 3745 (purchase-orders resource), 3795–3830 (approve-lpo, resolve-requisition-to-lpo, reports), 1813 (externalRequisitionReport), 3183–3191 (match-purchase-orders)
- bizwiz/routes/modules/accounts_payables.php (AP seam: GRNs, supplier-bills, return demands)
Controllers
- PurchaseOrderController.php:59 (class), :67 (ctor), :74 (index/can), :93–172 (orders + status filters), :203–222 (archive/hide), :284 (distributor check), :460 (number series), :466–502 (store header + DRAFT), :511 (price-edit setting), :795–803 (sendRequisitionRequest → PENDING), :900 (completed-lpo edit gate), :729 (status_report)
- ApproveLpoController.php:38 (view gate), :40 (needs_approval queue), :86,120 (edit gate), :117–182 (approve/decline), :139,146 (APPROVED + LpoApproved event), :150,160 (DECLINED + LpoDeclined mail), :209 (block delete if receiving started), :302–384 (updatePurchaseItem)
- ExternalRequisitionController.php:8–31 (models incl. WaExternalRequisition), index/store/sendRequisitionRequest/getOutOfStockItems/getSalesBasedSuggestions
- ApproveExternalRequisitionController.php:41 (view gate + queue), update (PROCESSING/APPROVED/DECLINED), externalRequisitionReport
- ResolveRequisitionToLpoController.php:51 (APPROVED reqs list), :128,136 (PRELPO path), :241–258 (create LPO, status PENDING), :262–290 (items + is_resolved), :292–295 (req → RESOLVED), :351–377 (transfer)
- app/helpers.php:4182 (can() helper)
Models
- app/Model/WaPurchaseOrder.php:30 (items), :37 (getReceiveOrder), :45 (items_with_grn), :104,109 (supplier), :146 (auth-permission levels), :151–161 (GRN), :164 (GL), :169 (stock moves), :174–181 (WaSuppTran/AP), :186 (advance payment), :191–196 (mother_lpo), :222 (approvedBy)
- app/Model/WaSuppTran.php:40 (belongsTo PO), :129 (creditNoteAllocatableBalance)
Migrations
- 2023_09_08_134414_create_wa_purchase_orders_table.php:28 (status enum, no DRAFT)
- 2023_09_08_134414_create_wa_purchase_order_items_table.php
- 2023_09_08_134414_create_wa_external_requisitions_table.php:25 (status enum incl RESOLVED)
- 2023_09_08_134414_create_wa_external_requisition_items_table.php
- 2023_11_18_230348_add_is_resolved_to_wa_external_requisition_items.php:15
- 2023_09_22_111306_add_additional_status_options_to_wa_internal_requisitions.php:17 (adds PAID, DELIVERED)
- 2026_03_01_082709_add_needs_approval_wa_purchase_orders.php (needs_approval)
- 2026_03_13_150000_add_lpo_approval_audit_columns.php (approved_by/at, approval_reason)
- 2026_04_16_130000_add_external_requisition_no_to_wa_internal_requisitions_table.php (cross-link)