pdo = $pdo; $this->company_id = $company_id; } // ───────────────────────────────────────────────────────────── // Private helpers // ───────────────────────────────────────────────────────────── private function generateNumber(array $data = []): string { return (new DocumentNumberManager($this->pdo, $this->company_id)) ->resolveNumber($data, 'purchase_request', 'td_purchase_request', 'request_number'); } /** * Derive document-level totals from line items. * tax = ROUND(SUM(items.tax_amount), 2) — never accepted from caller. */ private function computeTotals(array $items, float $discount, float $shipping_fee): array { $subtotal = array_sum(array_map(fn($i) => (float)($i['total_price'] ?? 0), $items)); $tax = round(array_sum(array_map(fn($i) => (float)($i['tax_amount'] ?? 0), $items)), 2); $grand = max(0, $subtotal - $discount + $tax + $shipping_fee); return [round($subtotal, 4), $tax, round($grand, 4)]; } /** DELETE + INSERT all line items. converted_qty is always reset to 0 on save. */ private function syncItems(int $request_id, array $items): void { $this->pdo->prepare( "DELETE FROM td_purchase_request_item WHERE request_id = :rid AND company_id = :cid" )->execute([':rid' => $request_id, ':cid' => $this->company_id]); $sth = $this->pdo->prepare( "INSERT INTO td_purchase_request_item (company_id, request_id, item_id, product_sku, product_name, quantity, unit_price, total_price, tax_amount, tax_rate, converted_qty) VALUES (:company_id, :request_id, :item_id, :product_sku, :product_name, :quantity, :unit_price, :total_price, :tax_amount, :tax_rate, 0)" ); foreach ($items as $pos => $item) { $sth->execute([ ':company_id' => $this->company_id, ':request_id' => $request_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), ]); } } // ───────────────────────────────────────────────────────────── // Read // ───────────────────────────────────────────────────────────── public function getList(): array { $sth = $this->pdo->prepare( "SELECT r.*, COALESCE(c.contact_name, '') AS contact_name, (SELECT COUNT(*) FROM td_purchase_order p WHERE p.company_id = r.company_id AND p.source = 'purchase_request' AND p.source_id = r.id AND p.status != -1) AS linked_po_count, (SELECT COUNT(*) FROM td_purchase_request_item i WHERE i.company_id = r.company_id AND i.request_id = r.id) AS item_count FROM td_purchase_request r LEFT JOIN md_contact c ON c.company_id = r.company_id AND c.id = r.contact_id WHERE r.company_id = :cid ORDER BY r.id DESC" ); $sth->execute([':cid' => $this->company_id]); return $sth->fetchAll(PDO::FETCH_ASSOC); } public function getById(int $id): array|false { $sth = $this->pdo->prepare( "SELECT r.*, COALESCE(c.contact_name, '') AS contact_name FROM td_purchase_request r LEFT JOIN md_contact c ON c.company_id = r.company_id AND c.id = r.contact_id WHERE r.company_id = :cid AND r.id = :id LIMIT 1" ); $sth->execute([':cid' => $this->company_id, ':id' => $id]); $row = $sth->fetch(PDO::FETCH_ASSOC); if (!$row) return false; $sth2 = $this->pdo->prepare( "SELECT *, (quantity - converted_qty) AS remaining_qty FROM td_purchase_request_item WHERE request_id = :rid AND company_id = :cid ORDER BY item_id" ); $sth2->execute([':rid' => $id, ':cid' => $this->company_id]); $row['items'] = $sth2->fetchAll(PDO::FETCH_ASSOC); $row['total_remaining'] = array_sum(array_column($row['items'], 'remaining_qty')); $sth3 = $this->pdo->prepare( "SELECT id, po_number, status, grand_total, created_at FROM td_purchase_order WHERE company_id = :cid AND source = 'purchase_request' AND source_id = :rid AND status != -1 ORDER BY id" ); $sth3->execute([':cid' => $this->company_id, ':rid' => $id]); $row['linked_pos'] = $sth3->fetchAll(PDO::FETCH_ASSOC); return $row; } // ───────────────────────────────────────────────────────────── // Write // ───────────────────────────────────────────────────────────── /** * Create (id=0) or update an existing draft purchase request. * * @param array $data Keys: id, contact_id, department_id, request_date, * required_date, items (array), discount, * shipping_fee, notes. * Each item must include tax_amount. * @return int The purchase request id (new or existing). */ public function save(array $data, array $logging): int { $id = (int)($data['id'] ?? 0); $items = DocumentValidator::normaliseLines($data['items'] ?? [], 'Purchase request'); $data['items'] = $items; $discount = DocumentValidator::discount($data['discount'] ?? 0, array_sum(array_column($items, 'total_price'))); $shipping_fee = DocumentValidator::amount($data['shipping_fee'] ?? 0, 'Shipping fee'); if ($id === 0 || array_key_exists('department_id', $data)) { $data['department_id'] = DocumentValidator::requireDepartment($this->pdo, $this->company_id, $data['department_id'] ?? 0); } if (empty($items)) throw new Exception('At least one item is required.'); $request_date = (string)($data['request_date'] ?? ''); $required_date = (string)($data['required_date'] ?? ''); if ($request_date !== '' && $required_date !== '' && $required_date < $request_date) { throw new Exception('Required date cannot be earlier than the request date.'); } [$subtotal, $tax, $grand] = $this->computeTotals($items, $discount, $shipping_fee); if ($id === 0) { $number = $this->generateNumber($data); $log = [array_merge($logging, ['action' => 'create_purchase_request'])]; $this->pdo->prepare( "INSERT INTO td_purchase_request (company_id, uuid, request_number, contact_id, department_id, request_date, required_date, status, subtotal, discount, tax, shipping_fee, grand_total, notes, `log`, created_at) VALUES (:cid, :uuid, :num, :contact_id, :department_id, :request_date, :required_date, 0, :sub, :disc, :tax, :ship, :grand, :notes, :log, NOW())" )->execute([ ':cid' => $this->company_id, ':uuid' => bin2hex(random_bytes(16)), ':num' => $number, ':contact_id' => (int)($data['contact_id'] ?? 0), ':department_id' => (int)($data['department_id'] ?? 0), ':request_date' => $data['request_date'] ?: null, ':required_date' => $data['required_date'] ?: null, ':sub' => $subtotal, ':disc' => $discount, ':tax' => $tax, ':ship' => $shipping_fee, ':grand' => $grand, ':notes' => $data['notes'] ?? '', ':log' => json_encode($log), ]); $id = (int)$this->pdo->lastInsertId(); $this->syncItems($id, $items); return $id; } // Update — only allowed on draft (status = 0) $sth = $this->pdo->prepare( "SELECT status, `log` FROM td_purchase_request WHERE company_id = :cid AND id = :id LIMIT 1" ); $sth->execute([':cid' => $this->company_id, ':id' => $id]); $row = $sth->fetch(PDO::FETCH_ASSOC); if (!$row) throw new Exception('Purchase request not found.'); if ((int)$row['status'] !== 0) throw new Exception('Only draft purchase requests can be edited.'); $log = json_decode($row['log'] ?: '[]', true); $log[] = array_merge($logging, ['action' => 'update_purchase_request']); $this->pdo->prepare( "UPDATE td_purchase_request SET contact_id = :contact_id, department_id = :department_id, request_date = :request_date, required_date = :required_date, subtotal = :sub, discount = :disc, tax = :tax, shipping_fee = :ship, grand_total = :grand, notes = :notes, `log` = :log WHERE company_id = :cid AND id = :id" )->execute([ ':contact_id' => (int)($data['contact_id'] ?? 0), ':department_id' => (int)($data['department_id'] ?? 0), ':request_date' => $data['request_date'] ?: null, ':required_date' => $data['required_date'] ?: null, ':sub' => $subtotal, ':disc' => $discount, ':tax' => $tax, ':ship' => $shipping_fee, ':grand' => $grand, ':notes' => $data['notes'] ?? '', ':log' => json_encode($log), ':cid' => $this->company_id, ':id' => $id, ]); $this->syncItems($id, $items); return $id; } /** * Transition purchase request status. * Valid actions: submit, approve, reject, reopen, cancel. */ public function updateStatus(int $id, string $action, array $logging): void { $transitions = [ 'submit' => ['from' => [0], 'to' => 1, 'label' => 'Submitted'], 'approve' => ['from' => [1], 'to' => 2, 'label' => 'Approved'], 'reject' => ['from' => [1], 'to' => 3, 'label' => 'Rejected'], 'reopen' => ['from' => [1, 2, 3], 'to' => 0, 'label' => 'Draft'], 'cancel' => ['from' => [0, 1], 'to' => -1, 'label' => 'Cancelled'], ]; if (!isset($transitions[$action])) throw new Exception('Invalid action.'); $sth = $this->pdo->prepare( "SELECT status, `log` FROM td_purchase_request WHERE company_id = :cid AND id = :id LIMIT 1" ); $sth->execute([':cid' => $this->company_id, ':id' => $id]); $row = $sth->fetch(PDO::FETCH_ASSOC); if (!$row) throw new Exception('Purchase request not found.'); $t = $transitions[$action]; if (!in_array((int)$row['status'], $t['from'], true)) { throw new Exception('Transition not allowed from current status.'); } if ($action === 'reopen') { $sth2 = $this->pdo->prepare( "SELECT COUNT(*) FROM td_purchase_request_item WHERE request_id = :rid AND company_id = :cid AND converted_qty > 0" ); $sth2->execute([':rid' => $id, ':cid' => $this->company_id]); if ((int)$sth2->fetchColumn() > 0) { throw new Exception('Cannot reopen — one or more items have already been converted to a purchase order.'); } } $log = json_decode($row['log'] ?: '[]', true); $log[] = array_merge($logging, ['action' => 'purchase_request_' . $action]); $this->pdo->prepare( "UPDATE td_purchase_request SET status = :status, `log` = :log WHERE company_id = :cid AND id = :id" )->execute([ ':status' => $t['to'], ':log' => json_encode($log), ':cid' => $this->company_id, ':id' => $id, ]); } /** * Increment converted_qty per item after a successful PO conversion. * Automatically sets PR status = 5 when all items are fully converted. * * @param array $validated Each entry: ['item_id' => int, 'quantity' => float] */ public function incrementConvertedQty(int $request_id, array $validated): void { $upd = $this->pdo->prepare( "UPDATE td_purchase_request_item SET converted_qty = converted_qty + :qty WHERE request_id = :rid AND item_id = :item_id AND company_id = :cid" ); foreach ($validated as $v) { $upd->execute([ ':qty' => $v['quantity'], ':rid' => $request_id, ':item_id' => $v['item_id'], ':cid' => $this->company_id, ]); } $sth = $this->pdo->prepare( "SELECT COUNT(*) FROM td_purchase_request_item WHERE request_id = :rid AND company_id = :cid AND converted_qty < quantity - 0.000001" ); $sth->execute([':rid' => $request_id, ':cid' => $this->company_id]); if ((int)$sth->fetchColumn() === 0) { $this->pdo->prepare( "UPDATE td_purchase_request SET status = 5 WHERE id = :id AND company_id = :cid" )->execute([':id' => $request_id, ':cid' => $this->company_id]); } } /** * Soft-delete a purchase request by negating company_id on the header and all items. * Blocked if any purchase order (not yet soft-deleted) was converted from this request. */ public function softDelete(int $id): void { $sth = $this->pdo->prepare( "SELECT id, request_date FROM td_purchase_request WHERE id = :id AND company_id = :cid LIMIT 1" ); $sth->execute([':id' => $id, ':cid' => $this->company_id]); $row = $sth->fetch(PDO::FETCH_ASSOC); if (!$row) throw new Exception('Purchase request not found.'); global $pdo1; if (isset($pdo1) && $pdo1 instanceof PDO) { $guard = new PostingWindowGuard($pdo1, $this->company_id); $guard->assertOpenDate($row['request_date'] ?: date('Y-m-d'), 'Purchase request deletion'); } $sth2 = $this->pdo->prepare( "SELECT COUNT(*) FROM td_purchase_order WHERE company_id = :cid AND source = 'purchase_request' AND source_id = :id" ); $sth2->execute([':cid' => $this->company_id, ':id' => $id]); if ((int)$sth2->fetchColumn() > 0) { throw new Exception('Cannot delete — this request has linked purchase orders. Delete the POs first.'); } $this->pdo->prepare( "UPDATE td_purchase_request_item SET company_id = company_id * -1 WHERE request_id = :id AND company_id = :cid" )->execute([':id' => $id, ':cid' => $this->company_id]); $this->pdo->prepare( "UPDATE td_purchase_request SET company_id = company_id * -1 WHERE id = :id AND company_id = :cid" )->execute([':id' => $id, ':cid' => $this->company_id]); } }