tables. * * Method order: * Transaction basis → getPoList, getPoById, generatePoNumber, * savePo, confirmPo, receivePo, cancelPo * * Key design decisions: * - PO items are stored as a JSON array in td_purchase_order.items. * Same pattern as td_order. No separate child table. * - receivePo() calls StockManager::saveStockIn() for each received line * with source='po' and source_id=po_id. This means received goods appear * in the existing Stock In list automatically under source='po'. * - stock_in_id is written back into the items JSON after each receive call, * same pattern as stock_out_id in OrderManager::confirmOrder(). * - Partial receipt is supported: receivePo() can be called multiple times. * - PO stored statuses: -2 (pending warehouse), -1 (cancelled), 0 (draft), 1 (confirmed). * Receipt progress (pending/partial/received) is derived via deriveReceiptStatus(), * same pattern as fulfillment_status on SO. Never stored in td_purchase_order. * - cancelPo() is blocked if any linked stock-in rows have been approved (status=1), * because those have already modified bin and balance. * - Write methods do NOT manage their own DB transactions. * Callers must wrap multi-step operations inside dbTransaction(). * * Security: All SQL uses PDO prepared statements with bound parameters. * Dynamic stock table names are derived only from md_warehouse.id. */ class PurchaseOrderManager { private PDO $pdo; private int $company_id; public function __construct(PDO $pdo, int $company_id) { $this->pdo = $pdo; $this->company_id = $company_id; } // ───────────────────────────────────────────────────────────── // Private helpers // ───────────────────────────────────────────────────────────── private function buildLogEntry(string $action): array { return [ 'user_id' => $_SESSION['login_user_id'] ?? null, 'dt' => date('Y-m-d H:i:s'), 'login' => isset($_SESSION['otpTime']) ? date('Y-m-d H:i:s', $_SESSION['otpTime']) : null, 'action' => $action, ]; } /** * Generate next sequential PO number in PO-YYYYMMDD-XXXX format. */ private function generatePoNumber(array $data = []): string { return (new DocumentNumberManager($this->pdo, $this->company_id)) ->resolveNumber($data, 'purchase_order', 'td_purchase_order', 'po_number'); } /** * Derive receipt status from linked stock-in rows — never stored in DB. * * 0 = no stock-in rows linked yet * 1 = pending (stock-in rows exist but none approved) * 2 = partial (some approved, quantities not fully received) * 3 = received (all approved, all quantities received) */ private function deriveReceiptStatus(array $po): int { if ((int)($po['status'] ?? 0) < 1) return 0; $po_id = (int)($po['id'] ?? 0); // Fetch items from normalized table $sth = $this->pdo->prepare( "SELECT item_id, quantity, received_qty, stock_in_id FROM td_purchase_order_item WHERE order_id = :po_id AND company_id = :company_id" ); $sth->execute([':po_id' => $po_id, ':company_id' => $this->company_id]); $items = $sth->fetchAll(PDO::FETCH_ASSOC); $has_any_stock_in = false; foreach ($items as $item) { if ((int)($item['stock_in_id'] ?? 0) > 0) { $has_any_stock_in = true; break; } } if (!$has_any_stock_in) return 0; $tables = (new WarehouseManager($this->pdo, $this->company_id))->getStockTables(); $total = 0; $pending = 0; foreach ($tables as $table) { $sth = $this->pdo->prepare( "SELECT status FROM `{$table}` WHERE company_id = :company_id AND source = 'po' AND source_id = :po_id AND type = 'in' AND status != -1" ); $sth->execute([':company_id' => $this->company_id, ':po_id' => $po_id]); foreach ($sth->fetchAll(PDO::FETCH_COLUMN) as $s) { $total++; if ((int)$s === 0) $pending++; } } if ($total === 0) return 0; if ($pending > 0) return 1; $all_received = true; foreach ($items as $item) { if ((float)($item['received_qty'] ?? 0) < (float)($item['quantity'] ?? 0)) { $all_received = false; break; } } return $all_received ? 3 : 2; } private function stockTableNameFromWarehouseId(int $warehouse_id): string { if ($warehouse_id <= 0) { throw new Exception("Invalid warehouse id."); } $sth = $this->pdo->prepare( "SELECT id FROM md_warehouse WHERE company_id = :company_id AND id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]); if (!$sth->fetchColumn()) { throw new Exception("Warehouse ID {$warehouse_id} not found."); } return 'td_stock_' . $warehouse_id; } private function syncPoItems(int $po_id, array $items): void { $this->pdo->prepare( "DELETE FROM td_purchase_order_item WHERE order_id = :po_id AND company_id = :company_id" )->execute([':po_id' => $po_id, ':company_id' => $this->company_id]); $sth = $this->pdo->prepare( "INSERT INTO td_purchase_order_item (company_id, order_id, item_id, product_sku, product_name, quantity, unit_price, total_price, tax_amount, tax_rate, received_qty, stock_in_id, invoiced_qty) VALUES (:company_id, :order_id, :item_id, :product_sku, :product_name, :quantity, :unit_price, :total_price, :tax_amount, :tax_rate, :received_qty, :stock_in_id, 0)" ); foreach ($items as $pos => $item) { $sth->execute([ ':company_id' => $this->company_id, ':order_id' => $po_id, ':item_id' => $pos + 1, ':product_sku' => $item['product_sku'] ?? '', ':product_name' => $item['product_name'] ?? '', ':quantity' => (float)($item['quantity'] ?? 0), ':unit_price' => (float)($item['unit_price'] ?? 0), ':total_price' => (float)($item['total_price'] ?? 0), ':tax_amount' => (float)($item['tax_amount'] ?? 0), ':tax_rate' => (float)($item['tax_rate'] ?? 0), ':received_qty' => (float)($item['received_qty'] ?? 0), ':stock_in_id' => (int)($item['stock_in_id'] ?? 0), ]); } } // ───────────────────────────────────────────────────────────── // TRANSACTION BASIS — Read // ───────────────────────────────────────────────────────────── /** * Return all POs for the company, ordered by created_at DESC. * Joins md_contact for supplier name display. */ public function getPoList(): array { $sth = $this->pdo->prepare( "SELECT p.*, COALESCE(c.contact_name, '') AS contact_name, (SELECT COUNT(*) FROM td_purchase_order_item i WHERE i.company_id = p.company_id AND i.order_id = p.id) AS item_count FROM td_purchase_order p LEFT JOIN md_contact c ON c.company_id = p.company_id AND c.id = p.contact_id WHERE p.company_id = :company_id ORDER BY p.po_date DESC, p.id DESC" ); $sth->execute([':company_id' => $this->company_id]); $rows = $sth->fetchAll(PDO::FETCH_ASSOC); foreach ($rows as &$row) { $row['receipt_status'] = $this->deriveReceiptStatus($row); } return $rows; } /** * Fetch a single PO by its primary key. * Returns the row with items decoded as a PHP array. */ public function getPoById(int $id): array|false { $sth = $this->pdo->prepare( "SELECT p.*, COALESCE(c.contact_name, '') AS contact_name FROM td_purchase_order p LEFT JOIN md_contact c ON c.company_id = p.company_id AND c.id = p.contact_id WHERE p.company_id = :company_id AND p.id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $id]); $row = $sth->fetch(PDO::FETCH_ASSOC); if (!$row) return false; $sth2 = $this->pdo->prepare( "SELECT * FROM td_purchase_order_item WHERE order_id = :order_id AND company_id = :company_id ORDER BY item_id" ); $sth2->execute([':order_id' => $id, ':company_id' => $this->company_id]); $row['items'] = $sth2->fetchAll(PDO::FETCH_ASSOC); $row['receipt_status'] = $this->deriveReceiptStatus($row); return $row; } public function getReceivableItems(array $po): array { $receivable = []; foreach (($po['items'] ?? []) as $i => $item) { $ordered = (float)($item['quantity'] ?? 0); $received = (float)($item['received_qty'] ?? 0); $remaining = $ordered - $received; if ($remaining > 0) { $receivable[] = array_merge($item, [ 'item_id' => (int)($item['item_id'] ?? $i), 'ordered_qty' => $ordered, 'received_qty' => $received, 'remaining_qty' => $remaining, ]); } } return $receivable; } public function getReturnableItems(array $po): array { $returnable = []; foreach (($po['items'] ?? []) as $i => $item) { $received = (float)($item['received_qty'] ?? 0); if ($received > 0) { $ordered = (float)($item['quantity'] ?? 0); $returnable[] = array_merge($item, [ 'item_id' => (int)($item['item_id'] ?? $i), 'ordered_qty' => $ordered, 'received_qty' => $received, 'returnable_qty' => $received, 'warehouse_id' => (int)($item['receive_wh'] ?? 0), 'zone' => $item['receive_zone'] ?? '', 'aisle' => $item['receive_aisle'] ?? '', 'bin' => $item['receive_bin'] ?? '', ]); } } return $returnable; } public function getActivePurchaseInvoiceId(int $po_id): int { $sth = $this->pdo->prepare( "SELECT id FROM td_invoice WHERE company_id = :company_id AND source = 'po' AND source_id = :po_id AND doc_type = 'purchase_invoice' AND status != 4 LIMIT 1" ); $sth->execute([':company_id' => $this->company_id, ':po_id' => $po_id]); return (int)$sth->fetchColumn(); } // ───────────────────────────────────────────────────────────── // TRANSACTION BASIS — Write // ───────────────────────────────────────────────────────────── /** * Insert a new PO or update metadata on an existing pending/draft PO. * * Insert (id=0): generates po_number. Accounting-sourced POs start * as pending warehouse (-2), direct WMS POs start as draft (0). * Update (id>0): only allowed while status=-2 (pending) or 0 (draft). * * @param array $data Keys: id, contact_id, po_date, expected_date, * warehouse_id, items (array), discount, tax, * shipping_fee, notes. * @param array $logging Audit entry. * @return int New td_purchase_order.id on insert, 0 on update. */ public function savePo(array $data, array $logging): int { $id = (int)($data['id'] ?? 0); $items = DocumentValidator::normaliseLines($data['items'] ?? [], 'Purchase order'); $data['items'] = $items; DocumentValidator::requireId($data['contact_id'] ?? 0, 'Supplier is required.'); if ($id === 0 || array_key_exists('department_id', $data)) { $data['department_id'] = DocumentValidator::requireDepartment($this->pdo, $this->company_id, $data['department_id'] ?? 0); } $skus = array_filter(array_column($items, 'product_sku')); if (count($skus) !== count(array_unique($skus))) { throw new Exception("Duplicate products found — each product may only appear once per PO."); } $subtotal = array_reduce($items, fn($carry, $item) => $carry + (float)($item['total_price'] ?? 0), 0.0 ); $discount = DocumentValidator::discount($data['discount'] ?? 0, $subtotal); $tax_adjustment = (float)($data['tax_adjustment'] ?? 0); if (abs($tax_adjustment) > 0.30) { throw new Exception("Tax adjustment cannot exceed ±0.30."); } $tax = round(array_reduce($items, fn($carry, $item) => $carry + (float)($item['tax_amount'] ?? 0), 0.0 ), 2) + $tax_adjustment; $shipping_fee = DocumentValidator::amount($data['shipping_fee'] ?? 0, 'Shipping fee'); $grand_total = $subtotal - $discount + $tax + $shipping_fee; if ($id > 0) { $sth = $this->pdo->prepare( "SELECT status, `log` FROM td_purchase_order WHERE company_id = :company_id AND id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $id]); $row = $sth->fetch(PDO::FETCH_ASSOC); if (!$row) throw new Exception("Purchase order not found."); $cur_status = (int)$row['status']; if (!in_array($cur_status, [-2, 0], true)) { throw new Exception("Only pending or draft POs can be edited."); } $warehouse_id = (int)($data['warehouse_id'] ?? 0); $new_status = ($cur_status === -2 && $warehouse_id > 0) ? 0 : $cur_status; $log = json_decode($row['log'] ?? '[]', true) ?: []; $log[] = $logging; $this->pdo->prepare( "UPDATE td_purchase_order SET contact_id = :contact_id, department_id = :department_id, po_date = :po_date, expected_date = :expected_date, warehouse_id = :warehouse_id, subtotal = :subtotal, discount = :discount, tax = :tax, tax_adjustment = :tax_adjustment, shipping_fee = :shipping_fee, grand_total = :grand_total, notes = :notes, status = :status, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ ':contact_id' => (int)($data['contact_id'] ?? 0), ':department_id' => (int)($data['department_id'] ?? 0), ':po_date' => $data['po_date'] ?? date('Y-m-d'), ':expected_date' => $data['expected_date'] ?: null, ':warehouse_id' => $warehouse_id, ':subtotal' => $subtotal, ':discount' => $discount, ':tax' => $tax, ':tax_adjustment' => $tax_adjustment, ':shipping_fee' => $shipping_fee, ':grand_total' => $grand_total, ':notes' => $data['notes'] ?? '', ':status' => $new_status, ':log' => json_encode($log), ':id' => $id, ':company_id' => $this->company_id, ]); $this->syncPoItems($id, $items); return 0; } else { $log = [$logging]; $source = trim((string)($data['source'] ?? '')); $source_id = (int)($data['source_id'] ?? 0); $init_status = $source !== '' ? -2 : 0; $this->pdo->prepare( "INSERT INTO td_purchase_order (company_id, uuid, source_id, `source`, po_number, contact_id, department_id, po_date, expected_date, warehouse_id, status, subtotal, discount, tax, tax_adjustment, shipping_fee, grand_total, notes, `log`, created_at) VALUES (:company_id, :uuid, :source_id, :source, :po_number, :contact_id, :department_id, :po_date, :expected_date, :warehouse_id, :status, :subtotal, :discount, :tax, :tax_adjustment, :shipping_fee, :grand_total, :notes, :log, :created_at)" )->execute([ ':company_id' => $this->company_id, ':uuid' => bin2hex(random_bytes(16)), ':source_id' => $source_id, ':source' => $source, ':po_number' => $this->generatePoNumber($data), ':contact_id' => (int)($data['contact_id'] ?? 0), ':department_id' => (int)($data['department_id'] ?? 0), ':po_date' => $data['po_date'] ?? date('Y-m-d'), ':expected_date' => $data['expected_date'] ?: null, ':warehouse_id' => (int)($data['warehouse_id'] ?? 0), ':status' => $init_status, ':subtotal' => $subtotal, ':discount' => $discount, ':tax' => $tax, ':tax_adjustment' => $tax_adjustment, ':shipping_fee' => $shipping_fee, ':grand_total' => $grand_total, ':notes' => $data['notes'] ?? '', ':log' => json_encode($log), ':created_at' => date('Y-m-d H:i:s'), ]); $new_id = (int)$this->pdo->lastInsertId(); $this->syncPoItems($new_id, $items); return $new_id; } } /** * Confirm a draft PO — advance status 0 → 1. * No stock rows are created at this stage; receipt happens via receivePo(). * * @throws Exception If PO not found or not in draft. */ public function confirmPo(int $po_id, array $logging): void { $sth = $this->pdo->prepare( "SELECT * FROM td_purchase_order WHERE company_id = :company_id AND id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $po_id]); $po = $sth->fetch(PDO::FETCH_ASSOC); if (!$po) throw new Exception("Purchase order not found."); if ((int)$po['status'] !== 0) throw new Exception("Only draft POs can be confirmed."); $log = json_decode($po['log'] ?? '[]', true) ?: []; $log[] = array_merge($logging, ['action' => 'confirm']); $this->pdo->prepare( "UPDATE td_purchase_order SET status = 1, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ ':log' => json_encode($log), ':id' => $po_id, ':company_id' => $this->company_id, ]); } /** * Receive goods against a confirmed PO — creates draft stock-in rows. * * Flow per received line: * 1. Calls StockManager::saveStockIn() with source='po', source_id=po_id. * 2. Writes stock_in_id back into the PO item object (same as stock_out_id in orders). * 3. Increments received_qty on the item. * * After all lines: * 4. If all items are fully received → status = 3 (completed). * 5. If partially received → status = 2 (partial). * * Can be called multiple times for partial deliveries. * If auto_approve=true (from company settings), approveStock() is also called. * * @param int $po_id td_purchase_order.id * @param array $receive_items Each item: { item_id, product_sku, warehouse_id, * quantity, zone, aisle, bin, lot_number, * expiry_date, serial_number, contact_id } * @param string $uuid UUID prefix for stock-in rows. * @param array $logging Audit entry. * @param bool $auto_approve Auto-approve stock-in rows immediately. */ public function receivePo( int $po_id, array $receive_items, string $uuid, array $logging, bool $auto_approve = false ): void { $sth = $this->pdo->prepare( "SELECT * FROM td_purchase_order WHERE company_id = :company_id AND id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $po_id]); $po = $sth->fetch(PDO::FETCH_ASSOC); if (!$po) throw new Exception("Purchase order not found."); $status = (int)$po['status']; if ($status === -1) throw new Exception("Cannot receive against a cancelled PO."); if ($status === 0) throw new Exception("Confirm the PO before receiving goods."); if (!empty($po['closed_at'])) throw new Exception("This PO has been closed — no further receipts are allowed."); // Load PO items from normalized table $sth2 = $this->pdo->prepare( "SELECT * FROM td_purchase_order_item WHERE order_id = :po_id AND company_id = :company_id ORDER BY item_id" ); $sth2->execute([':po_id' => $po_id, ':company_id' => $this->company_id]); $po_items = $sth2->fetchAll(PDO::FETCH_ASSOC); $all_done = !empty($po_items) && array_reduce($po_items, fn($ok, $item) => $ok && (float)($item['received_qty'] ?? 0) >= (float)($item['quantity'] ?? 0), true ); if ($all_done) throw new Exception("This PO is already fully received."); if (empty($receive_items)) { throw new Exception("No items provided to receive."); } // Index PO items by item_id for quick lookup $po_items_by_id = []; foreach ($po_items as $i => $item) { $po_items_by_id[(int)($item['item_id'] ?? $i)] = $i; } $stock = new StockManager($this->pdo, $this->company_id); $whMgmt = new WarehouseManager($this->pdo, $this->company_id); // Validate all warehouse IDs in one query before entering the item loop. $recvWarehouseIds = array_values(array_unique(array_filter( array_map(fn($recv) => (int)($recv['warehouse_id'] ?? $po['warehouse_id']), $receive_items), fn($id) => $id > 0 ))); if (!empty($recvWarehouseIds)) { $placeholders = implode(',', array_fill(0, count($recvWarehouseIds), '?')); $sth = $this->pdo->prepare( "SELECT id FROM md_warehouse WHERE company_id = ? AND id IN ($placeholders)" ); $sth->execute([$this->company_id, ...$recvWarehouseIds]); $validWarehouseIds = array_flip($sth->fetchAll(PDO::FETCH_COLUMN)); } else { $validWarehouseIds = []; } foreach ($receive_items as $j => $recv) { $item_id = (int)($recv['item_id'] ?? -1); $product_sku = $recv['product_sku'] ?? ''; $warehouse_id = (int)($recv['warehouse_id'] ?? $po['warehouse_id']); $quantity = (float)($recv['quantity'] ?? 0); // A blank line is a line the user chose not to receive — skip it. if ($quantity == 0) continue; // Anything positive has to survive the decimal(18,4) columns it is // about to be written to. Without this, 0.0000001 was accepted, was // stored as 0.0000, produced a stock movement of nothing, and still // advanced received_qty enough to leave the PO stuck on "Partial". $name = $po_items[$po_items_by_id[$item_id] ?? -1]['product_name'] ?? $product_sku; $quantity = StockManager::normaliseQuantity($quantity, "Receiving quantity for \"{$name}\""); if (!$product_sku) throw new Exception("Item #{$j}: missing product_sku."); if (!$warehouse_id) throw new Exception("Item #{$j}: missing warehouse_id."); // Cap against PO quantity — prevent over-receiving if ($item_id >= 0 && isset($po_items_by_id[$item_id])) { $idx = $po_items_by_id[$item_id]; $already_received = (float)($po_items[$idx]['received_qty'] ?? 0); $max_receivable = (float)($po_items[$idx]['quantity'] ?? 0) - $already_received; if ($quantity - $max_receivable > 0.000001) { $name = $po_items[$idx]['product_name'] ?? $product_sku; throw new Exception( "Cannot receive {$quantity} for \"{$name}\": " . "only {$max_receivable} unit(s) remaining on this PO." ); } } $item_uuid = $uuid . '_' . $j; $item_log = array_merge($logging, ['action' => 'receive_item']); $bin = $recv['bin'] ?? ''; $zone = $recv['zone'] ?? ''; $aisle = $recv['aisle'] ?? ''; // Expiry dates live on md_lot, keyed by lot number, so an expiry // entered without one is silently dropped and the received stock // shows no expiry at all. Say so instead of discarding it. if (trim((string)($recv['expiry_date'] ?? '')) !== '' && trim((string)($recv['lot_number'] ?? '')) === '') { throw new Exception( "Enter a lot number for \"{$name}\" — an expiry date is recorded against its lot." ); } // Simple location mode: zone and aisle must mirror the bin value // (same convention as manage_stock_in.php). // occupyBin() looks up md_bin WHERE zone=:zone AND aisle=:aisle AND bin=:bin, // so all three must match — a blank zone/aisle produces "Bin --b does not exist". if ($zone === '' && $bin !== '') $zone = $bin; if ($aisle === '' && $bin !== '') $aisle = $bin; // Resolve unit_price: prefer the receive-time override, fall back to PO item price $po_item_price = 0; if ($item_id >= 0 && isset($po_items_by_id[$item_id])) { $po_item_price = (float)($po_items[$po_items_by_id[$item_id]]['unit_price'] ?? 0); } $unit_price = (float)($recv['unit_price'] ?? $po_item_price); $stock_data = [ 'id' => 0, 'warehouse' => $warehouse_id, 'product_sku' => $product_sku, 'quantity' => $quantity, 'price' => $unit_price, 'zone' => $zone, 'aisle' => $aisle, 'bin' => $bin, 'lot_number' => $recv['lot_number'] ?? '', 'expiry_date' => $recv['expiry_date'] ?? '', 'serial_number' => $recv['serial_number'] ?? '', 'contact_id' => (int)($recv['contact_id'] ?? $po['contact_id'] ?? 0), 'description' => $po['po_number'], 'source' => 'po', 'source_id' => $po_id, ]; $new_stock_in_id = $stock->saveStockIn($stock_data, $item_log, $item_uuid); if ($new_stock_in_id > 0) { // saveStockIn() does not write source/source_id — stamp them here. // This links the stock-in row back to this PO for cancellation guards // and makes it appear under the "PO" source tab on the Stock In list. if (!isset($validWarehouseIds[$warehouse_id])) { throw new Exception("Warehouse ID {$warehouse_id} not found."); } $table = 'td_stock_' . $warehouse_id; $this->pdo->prepare( "UPDATE `{$table}` SET source = 'po', source_id = :po_id WHERE id = :id AND company_id = :company_id" )->execute([ ':po_id' => $po_id, ':id' => $new_stock_in_id, ':company_id' => $this->company_id, ]); // approveStock() handles balance updates. saveStockIn() has // already reserved the bin so draft receipts cannot be reused. if ($auto_approve) { $stock->approveStock($new_stock_in_id, $warehouse_id, 'in', $whMgmt); } } // Write stock_in_id + received qty back into td_purchase_order_item if ($item_id >= 0 && isset($po_items_by_id[$item_id])) { $idx = $po_items_by_id[$item_id]; $already_received = (float)($po_items[$idx]['received_qty'] ?? 0); $new_received_qty = $already_received + $quantity; $this->pdo->prepare( "UPDATE td_purchase_order_item SET stock_in_id = :stock_in_id, received_qty = :received_qty WHERE order_id = :po_id AND item_id = :item_id AND company_id = :company_id" )->execute([ ':stock_in_id' => $new_stock_in_id, ':received_qty' => $new_received_qty, ':po_id' => $po_id, ':item_id' => $item_id, ':company_id' => $this->company_id, ]); } } $log = json_decode($po['log'] ?? '[]', true) ?: []; $log[] = array_merge($logging, ['action' => 'receive']); $this->pdo->prepare( "UPDATE td_purchase_order SET `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ ':log' => json_encode($log), ':id' => $po_id, ':company_id' => $this->company_id, ]); } /** * Close a confirmed PO — sets closed_at to prevent further receipts. * * Use when the supplier will send no more goods (short-shipped, agreement closed). * Does not affect receipt_status — partial receipt is preserved. * Blocked if PO is cancelled or not yet confirmed. * * @throws Exception */ public function closePo(int $po_id, array $logging): void { $sth = $this->pdo->prepare( "SELECT status, closed_at, `log` FROM td_purchase_order WHERE company_id = :company_id AND id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $po_id]); $po = $sth->fetch(PDO::FETCH_ASSOC); if (!$po) throw new Exception("Purchase order not found."); if ((int)$po['status'] === -1) throw new Exception("Cannot close a cancelled PO."); if ((int)$po['status'] === 0) throw new Exception("Confirm the PO before closing it."); if (!empty($po['closed_at'])) throw new Exception("This PO is already closed."); $log = json_decode($po['log'] ?? '[]', true) ?: []; $log[] = array_merge($logging, ['action' => 'close']); $this->pdo->prepare( "UPDATE td_purchase_order SET closed_at = NOW(), `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ ':log' => json_encode($log), ':id' => $po_id, ':company_id' => $this->company_id, ]); } /** * Cancel a PO. * * Blocked if any linked stock-in rows have already been approved (status=1) * because those have modified bin occupancy and balance. * * For draft stock-in rows (status=0), they are soft-deleted (status=-1). * * @throws Exception */ public function cancelPo(int $po_id, array $logging): void { $sth = $this->pdo->prepare( "SELECT * FROM td_purchase_order WHERE company_id = :company_id AND id = :id" ); $sth->execute([':company_id' => $this->company_id, ':id' => $po_id]); $po = $sth->fetch(PDO::FETCH_ASSOC); if (!$po) throw new Exception("Purchase order not found."); $status = (int)$po['status']; if ($status === -1) throw new Exception("PO is already cancelled."); // Guard: block if any approved stock-in rows exist for this PO $whMgmt = new WarehouseManager($this->pdo, $this->company_id); $tables = $whMgmt->getStockTables(); foreach ($tables as $table) { $sth = $this->pdo->prepare( "SELECT COUNT(*) FROM `{$table}` WHERE company_id = :company_id AND source = 'po' AND source_id = :po_id AND status = 1" ); $sth->execute([':company_id' => $this->company_id, ':po_id' => $po_id]); if ((int)$sth->fetchColumn() > 0) { throw new Exception( "Cannot cancel — stock from this PO has already been approved and received. " . "Please adjust stock manually if needed." ); } } // Soft-delete draft stock-in rows and release their bin reservations. foreach ($tables as $table) { if (!preg_match('/^td_stock_(\d+)$/', $table, $m)) { continue; } $warehouse_id = (int)$m[1]; $sth = $this->pdo->prepare( "SELECT id, zone, aisle, bin FROM `{$table}` WHERE company_id = :company_id AND source = 'po' AND source_id = :po_id AND status = 0" ); $sth->execute([':company_id' => $this->company_id, ':po_id' => $po_id]); $draft_rows = $sth->fetchAll(PDO::FETCH_ASSOC); foreach ($draft_rows as $row) { $this->pdo->prepare( "UPDATE `{$table}` SET status = -1 WHERE id = :id AND company_id = :company_id" )->execute([ ':id' => (int)$row['id'], ':company_id' => $this->company_id, ]); $whMgmt->releaseBin( $warehouse_id, $row['zone'], $row['aisle'], $row['bin'] ); } } $log = json_decode($po['log'] ?? '[]', true) ?: []; $log[] = array_merge($logging, ['action' => 'cancel']); $this->pdo->prepare( "UPDATE td_purchase_order SET status = -1, `log` = :log WHERE id = :id AND company_id = :company_id" )->execute([ ':log' => json_encode($log), ':id' => $po_id, ':company_id' => $this->company_id, ]); // Release converted_qty on the originating purchase request items $source = (string)($po['source'] ?? ''); $source_id = (int)($po['source_id'] ?? 0); if ($source === 'purchase_request' && $source_id > 0) { $sth2 = $this->pdo->prepare( "SELECT item_id, quantity FROM td_purchase_order_item WHERE order_id = :po_id AND company_id = :company_id ORDER BY item_id" ); $sth2->execute([':po_id' => $po_id, ':company_id' => $this->company_id]); $po_items = $sth2->fetchAll(PDO::FETCH_ASSOC); $dec = $this->pdo->prepare( "UPDATE td_purchase_request_item SET converted_qty = GREATEST(0, converted_qty - :qty) WHERE request_id = :rid AND item_id = :item_id AND company_id = :cid" ); foreach ($po_items as $item) { $dec->execute([ ':qty' => (float)($item['quantity'] ?? 0), ':rid' => $source_id, ':item_id' => (int)($item['item_id'] ?? 0), ':cid' => $this->company_id, ]); } // Revert PR from fully-converted (status=5) back to Approved (status=2) $this->pdo->prepare( "UPDATE td_purchase_request SET status = 2 WHERE id = :id AND company_id = :cid AND status = 5" )->execute([':id' => $source_id, ':cid' => $this->company_id]); } } /** * Soft-delete a purchase order by negating company_id on the header, items, and all * linked draft stock-in rows. Blocked if any purchase invoice, supplier return, or * approved (received) stock-in rows exist. */ public function softDelete(int $po_id): void { $sth = $this->pdo->prepare( "SELECT * FROM td_purchase_order WHERE company_id = :cid AND id = :id" ); $sth->execute([':cid' => $this->company_id, ':id' => $po_id]); $po = $sth->fetch(PDO::FETCH_ASSOC); if (!$po) throw new Exception('Purchase order not found.'); global $pdo1; if (isset($pdo1) && $pdo1 instanceof PDO) { $guard = new PostingWindowGuard($pdo1, $this->company_id); $guard->assertOpenDate($po['po_date'] ?: date('Y-m-d'), 'Purchase order deletion'); } $sth2 = $this->pdo->prepare( "SELECT COUNT(*) FROM td_invoice WHERE company_id = :cid AND source = 'po' AND source_id = :id AND doc_type = 'purchase_invoice'" ); $sth2->execute([':cid' => $this->company_id, ':id' => $po_id]); if ((int)$sth2->fetchColumn() > 0) { throw new Exception('Cannot delete — this PO has linked purchase invoices. Delete the invoices first.'); } $sth3 = $this->pdo->prepare( "SELECT COUNT(*) FROM td_supplier_return WHERE company_id = :cid AND po_id = :id AND status != -1" ); $sth3->execute([':cid' => $this->company_id, ':id' => $po_id]); if ((int)$sth3->fetchColumn() > 0) { throw new Exception('Cannot delete — this PO has linked supplier returns. Delete or cancel the returns first.'); } $whMgmt = new WarehouseManager($this->pdo, $this->company_id); $tables = $whMgmt->getStockTables(); foreach ($tables as $table) { $sth5 = $this->pdo->prepare( "SELECT COUNT(*) FROM `{$table}` WHERE company_id = :cid AND source = 'po' AND source_id = :id AND status = 1" ); $sth5->execute([':cid' => $this->company_id, ':id' => $po_id]); if ((int)$sth5->fetchColumn() > 0) { throw new Exception('Cannot delete — received stock from this PO must be reversed via ICS first.'); } } // Release bins from draft stock-in rows and negate them foreach ($tables as $table) { if (!preg_match('/^td_stock_(\d+)$/', (string)$table, $matches)) continue; $warehouse_id = (int)$matches[1]; $row_sth = $this->pdo->prepare( "SELECT id, zone, aisle, bin FROM `{$table}` WHERE company_id = :cid AND source = 'po' AND source_id = :id AND status = 0" ); $row_sth->execute([':cid' => $this->company_id, ':id' => $po_id]); $draft_rows = $row_sth->fetchAll(PDO::FETCH_ASSOC); foreach ($draft_rows as $row) { $whMgmt->releaseBin($warehouse_id, $row['zone'], $row['aisle'], $row['bin']); } $this->pdo->prepare( "UPDATE `{$table}` SET company_id = company_id * -1 WHERE company_id = :cid AND source = 'po' AND source_id = :id" )->execute([':cid' => $this->company_id, ':id' => $po_id]); } // Release converted_qty on source purchase request $source = (string)($po['source'] ?? ''); $source_id = (int)($po['source_id'] ?? 0); if ($source === 'purchase_request' && $source_id > 0) { $sth6 = $this->pdo->prepare( "SELECT item_id, quantity FROM td_purchase_order_item WHERE order_id = :id AND company_id = :cid ORDER BY item_id" ); $sth6->execute([':id' => $po_id, ':cid' => $this->company_id]); $po_items = $sth6->fetchAll(PDO::FETCH_ASSOC); $dec = $this->pdo->prepare( "UPDATE td_purchase_request_item SET converted_qty = GREATEST(0, converted_qty - :qty) WHERE request_id = :rid AND item_id = :item_id AND company_id = :cid" ); foreach ($po_items as $item) { $dec->execute([ ':qty' => (float)($item['quantity'] ?? 0), ':rid' => $source_id, ':item_id' => (int)($item['item_id'] ?? 0), ':cid' => $this->company_id, ]); } $this->pdo->prepare( "UPDATE td_purchase_request SET status = 2 WHERE id = :id AND company_id = :cid AND status = 5" )->execute([':id' => $source_id, ':cid' => $this->company_id]); } $this->pdo->prepare( "UPDATE td_purchase_order_item SET company_id = company_id * -1 WHERE order_id = :id AND company_id = :cid" )->execute([':id' => $po_id, ':cid' => $this->company_id]); $this->pdo->prepare( "UPDATE td_purchase_order SET company_id = company_id * -1 WHERE id = :id AND company_id = :cid" )->execute([':id' => $po_id, ':cid' => $this->company_id]); } }