Files
root a30c1398a1
Tests / PHPUnit (push) Failing after 1m21s
fix financials
2026-07-18 22:57:40 -04:00

483 lines
20 KiB
PHP

<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\PurchaseOrderModel;
use App\Models\PurchaseOrderItemModel;
use App\Models\SupplierModel;
use App\Models\SupplyModel;
use App\Models\SupplyTransactionModel;
class PurchaseOrderController extends BaseController
{
protected $poModel;
protected $itemModel;
protected $supplierModel;
protected $supplyModel;
protected $txnModel;
protected $db;
public function __construct()
{
$this->poModel = new PurchaseOrderModel();
$this->itemModel = new PurchaseOrderItemModel();
$this->supplierModel = new SupplierModel();
$this->supplyModel = new SupplyModel();
$this->txnModel = new SupplyTransactionModel();
$this->db = \Config\Database::connect();
}
public function index()
{
$q = trim($this->request->getGet('q') ?? '');
$builder = $this->poModel->select('purchase_orders.*, suppliers.name AS supplier_name')
->join('suppliers', 'suppliers.id = purchase_orders.supplier_id', 'left');
if ($q !== '') {
$builder->groupStart()
->like('po_number', $q)
->orLike('suppliers.name', $q)
->groupEnd();
}
$orders = $builder->orderBy('purchase_orders.created_at', 'DESC')->paginate(20);
return view('inventory/po_index', [
'orders' => $orders,
'pager' => $this->poModel->pager,
'q' => $q,
]);
}
public function create()
{
return view('inventory/po_form', [
'title' => 'Create Purchase Order',
'action' => site_url('inventory/po/store'),
'suppliers' => $this->supplierModel->orderBy('name')->findAll(),
'supplies' => $this->supplyModel->orderBy('name')->findAll(),
]);
}
public function store()
{
$po = $this->request->getPost([
'po_number','supplier_id','order_date','expected_date','notes'
]);
$status = $this->request->getPost('status') ?? 'ordered';
$supply_ids = $this->request->getPost('item_supply_id') ?? [];
$descs = $this->request->getPost('item_description') ?? [];
$qtys = $this->request->getPost('item_quantity') ?? [];
$unit_costs = $this->request->getPost('item_unit_cost') ?? [];
if (empty($supply_ids)) {
return redirect()->back()->withInput()->with('error', 'Add at least one line item.');
}
// compute totals
$subtotal = 0.0;
$items = [];
foreach ($supply_ids as $i => $sid) {
$q = (int)($qtys[$i] ?? 0);
$uc = (float)($unit_costs[$i] ?? 0);
if ($sid && ($q <= 0 || $uc < 0)) {
return redirect()->back()->withInput()->with('error', 'Quantity must be greater than zero and unit cost cannot be negative.');
}
if ($sid && $q > 0) {
$line = $q * $uc;
$subtotal += $line;
$items[] = [
'supply_id' => (int)$sid,
'description' => trim($descs[$i] ?? ''),
'quantity' => $q,
'unit_cost' => $uc,
'received_qty'=> 0,
];
}
}
if (!$items) {
return redirect()->back()->withInput()->with('error', 'Valid line items required.');
}
$tax = 0.00;
$total = $subtotal + $tax;
$this->db->transStart();
$po['status'] = in_array($status, ['draft','ordered'], true) ? $status : 'ordered';
$po['subtotal'] = $subtotal;
$po['tax'] = $tax;
$po['total'] = $total;
if (!$this->poModel->save($po)) {
$this->db->transRollback();
return redirect()->back()->withInput()->with('error', implode("\n", $this->poModel->errors()));
}
$poId = $this->poModel->getInsertID();
foreach ($items as $it) {
$it['purchase_order_id'] = $poId;
if (!$this->itemModel->insert($it)) {
$this->db->transRollback();
return redirect()->back()->withInput()->with('error', 'Failed to save line items.');
}
}
$this->db->transComplete();
if ($this->db->transStatus() === false) {
return redirect()->back()->withInput()->with('error', 'Failed to save purchase order.');
}
return redirect()->to(site_url('inventory/po/show/'.$poId))->with('success', 'PO created.');
}
public function show($id)
{
$po = $this->poModel->select('purchase_orders.*, suppliers.name AS supplier_name')
->join('suppliers', 'suppliers.id = purchase_orders.supplier_id', 'left')
->find($id);
if (!$po) return redirect()->to('inventory/po')->with('error', 'PO not found.');
$items = $this->itemModel->select('purchase_order_items.*, supplies.name AS supply_name, supplies.unit as supply_unit')
->join('supplies', 'supplies.id = purchase_order_items.supply_id', 'left')
->where('purchase_order_id', $id)->findAll();
return view('inventory/po_show', [
'po' => $po,
'items' => $items,
]);
}
/**
* Receive items (partial or full)
* POST body: received[item_id] = qty_to_receive
*/
public function receive($id)
{
$received = $this->request->getPost('received') ?? []; // [itemId => qty]
if (!$received) {
return redirect()->to('inventory/po/show/'.$id)->with('error', 'No items to receive.');
}
$idempotencyKey = trim((string)($this->request->getPost('idempotency_key') ?? ''));
if ($idempotencyKey === '') {
$idempotencyKey = bin2hex(random_bytes(16));
}
$fingerprint = $this->buildReceiptFingerprint((int)$id, $received);
$issuedBy = (string) (session('user.email') ?? session('user.username') ?? 'system');
$this->db->transBegin();
try {
$existingOperation = $this->db->query(
'SELECT * FROM inventory_receipt_operations WHERE idempotency_key = ? FOR UPDATE',
[$idempotencyKey]
)->getRowArray();
if ($existingOperation) {
if ((int)$existingOperation['purchase_order_id'] !== (int)$id || (string)$existingOperation['request_fingerprint_hash'] !== $fingerprint) {
$this->db->transCommit();
return redirect()->to('inventory/po/show/'.$id)->with('error', 'Receipt idempotency key conflicts with a different request.');
}
$this->db->transCommit();
return redirect()->to('inventory/po/show/'.$id)->with('success', 'Receipt already recorded.');
}
$po = $this->db->query('SELECT * FROM purchase_orders WHERE id = ? FOR UPDATE', [(int) $id])->getRowArray();
if (!$po || in_array($po['status'], ['canceled','received'], true)) {
throw new \RuntimeException('PO not receivable.');
}
$items = $this->db->query(
'SELECT * FROM purchase_order_items WHERE purchase_order_id = ? FOR UPDATE',
[(int) $id]
)->getResultArray();
if ($items === []) {
throw new \RuntimeException('PO has no receivable lines.');
}
$operationInserted = $this->db->table('inventory_receipt_operations')->insert([
'idempotency_key' => $idempotencyKey,
'purchase_order_id' => (int)$id,
'request_fingerprint_hash' => $fingerprint,
'status' => 'processing',
'actor_id' => (int)(session()->get('user_id') ?? 0) ?: null,
'created_at' => utc_now(),
'updated_at' => utc_now(),
]);
$operationId = (int)$this->db->insertID();
if (!$operationInserted) {
$operationId = 0;
}
if ($operationId <= 0) {
throw new \RuntimeException('Failed to create receipt operation.');
}
$itemsById = [];
foreach ($items as $item) {
$ordered = (int) ($item['quantity'] ?? 0);
$alreadyReceived = (int) ($item['received_qty'] ?? 0);
if ($ordered <= 0 || $alreadyReceived < 0 || $alreadyReceived > $ordered) {
throw new \RuntimeException('PO contains invalid received quantities.');
}
$itemsById[(int) $item['id']] = $item;
}
foreach ($received as $itemId => $qty) {
$itemId = (int) $itemId;
$qty = (int)$qty;
if ($qty <= 0) {
continue;
}
if (!isset($itemsById[$itemId])) {
throw new \RuntimeException('Submitted item does not belong to this PO.');
}
$item = $itemsById[$itemId];
$remaining = (int)$item['quantity'] - (int)$item['received_qty'];
if ($qty > $remaining) {
throw new \RuntimeException('Received quantity exceeds ordered quantity.');
}
$supply = $this->db->query('SELECT * FROM supplies WHERE id = ? FOR UPDATE', [(int) $item['supply_id']])->getRowArray();
if (!$supply) {
throw new \RuntimeException('Supply not found for PO line.');
}
if (!$this->db->table('supplies')
->where('id', (int) $supply['id'])
->set('qty_on_hand', 'qty_on_hand + ' . $qty, false)
->update()) {
throw new \RuntimeException('Failed to update supply quantity.');
}
$movementId = $this->txnModel->insert([
'supply_id' => (int) $supply['id'],
'type' => 'in',
'quantity' => $qty,
'ref' => 'PO ' . $po['po_number'],
'issued_to' => 'Inventory',
'issued_by' => $issuedBy,
'notes' => 'Received against PO',
]);
if (!$movementId) {
throw new \RuntimeException('Failed to record inventory transaction.');
}
if (!$this->db->table('inventory_receipt_lines')->insert([
'operation_id' => $operationId,
'purchase_order_item_id' => $itemId,
'quantity' => $qty,
'movement_id' => (int)$movementId,
'reversed_quantity' => 0,
'created_at' => utc_now(),
])) {
throw new \RuntimeException('Failed to record receipt line.');
}
if (!$this->itemModel->update($itemId, [
'received_qty' => (int)$item['received_qty'] + $qty
])) {
throw new \RuntimeException('Failed to update PO line quantity.');
}
$itemsById[$itemId]['received_qty'] = (int)$item['received_qty'] + $qty;
}
$completed = true;
foreach ($itemsById as $item) {
if ((int) $item['received_qty'] < (int) $item['quantity']) {
$completed = false;
break;
}
}
if (!$this->poModel->update($id, ['status' => $completed ? 'received' : 'ordered'])) {
throw new \RuntimeException('Failed to update PO status.');
}
$this->db->table('inventory_receipt_operations')
->where('id', $operationId)
->update(['status' => 'completed', 'updated_at' => utc_now()]);
if (!$this->db->transStatus()) {
throw new \RuntimeException('PO receive transaction failed.');
}
$this->db->transCommit();
} catch (\Throwable $e) {
$this->db->transRollback();
log_message('error', 'Failed to receive PO #{po}: {msg}', ['po' => $id, 'msg' => $e->getMessage()]);
return redirect()->to('inventory/po/show/'.$id)->with('error', 'Failed to receive items.');
}
return redirect()->to('inventory/po/show/'.$id)->with('success', $completed ? 'PO fully received.' : 'PO partially received.');
}
public function reverseReceipt(int $operationId)
{
$reason = trim((string)($this->request->getPost('reason') ?? ''));
if ($reason === '') {
return redirect()->back()->with('error', 'Receipt reversal reason is required.');
}
$issuedBy = (string) (session('user.email') ?? session('user.username') ?? 'system');
$poId = 0;
$this->db->transBegin();
try {
$operation = $this->db->query(
'SELECT * FROM inventory_receipt_operations WHERE id = ? FOR UPDATE',
[$operationId]
)->getRowArray();
if (!$operation || (string)($operation['status'] ?? '') !== 'completed') {
throw new \RuntimeException('Receipt operation is not reversible.');
}
$poId = (int)$operation['purchase_order_id'];
$po = $this->db->query('SELECT * FROM purchase_orders WHERE id = ? FOR UPDATE', [$poId])->getRowArray();
if (!$po) {
throw new \RuntimeException('Purchase order not found.');
}
$lines = $this->db->query(
'SELECT rl.*, poi.supply_id, poi.received_qty, poi.quantity AS ordered_quantity
FROM inventory_receipt_lines rl
JOIN purchase_order_items poi ON poi.id = rl.purchase_order_item_id
WHERE rl.operation_id = ?
FOR UPDATE',
[$operationId]
)->getResultArray();
if ($lines === []) {
throw new \RuntimeException('Receipt operation has no lines.');
}
$reversedAny = false;
foreach ($lines as $line) {
$remaining = (int)$line['quantity'] - (int)$line['reversed_quantity'];
if ($remaining <= 0) {
continue;
}
$supply = $this->db->query('SELECT * FROM supplies WHERE id = ? FOR UPDATE', [(int)$line['supply_id']])->getRowArray();
if (!$supply) {
throw new \RuntimeException('Supply not found for receipt line.');
}
if ((int)($supply['qty_on_hand'] ?? 0) < $remaining) {
throw new \RuntimeException('Insufficient inventory for receipt reversal.');
}
if ((int)$line['received_qty'] < $remaining) {
throw new \RuntimeException('PO line received quantity cannot cover reversal.');
}
if (!$this->db->table('supplies')
->where('id', (int)$supply['id'])
->set('qty_on_hand', 'qty_on_hand - ' . $remaining, false)
->update()) {
throw new \RuntimeException('Failed to update supply quantity.');
}
$movementId = $this->txnModel->insert([
'supply_id' => (int)$supply['id'],
'type' => 'out',
'quantity' => $remaining,
'ref' => 'PO ' . ($po['po_number'] ?? $poId),
'issued_to' => 'Inventory',
'issued_by' => $issuedBy,
'notes' => 'Reversal of receipt operation #' . $operationId . ': ' . $reason,
]);
if (!$movementId) {
throw new \RuntimeException('Failed to record inventory reversal transaction.');
}
if (!$this->db->table('purchase_order_items')
->where('id', (int)$line['purchase_order_item_id'])
->set('received_qty', 'received_qty - ' . $remaining, false)
->update()) {
throw new \RuntimeException('Failed to update PO line received quantity.');
}
if (!$this->db->table('inventory_receipt_lines')
->where('id', (int)$line['id'])
->update([
'reversed_quantity' => (int)$line['reversed_quantity'] + $remaining,
'reversal_movement_id' => (int)$movementId,
])) {
throw new \RuntimeException('Failed to mark receipt line reversed.');
}
$reversedAny = true;
}
if (!$reversedAny) {
throw new \RuntimeException('Receipt operation is already fully reversed.');
}
$items = $this->db->query(
'SELECT quantity, received_qty FROM purchase_order_items WHERE purchase_order_id = ? FOR UPDATE',
[$poId]
)->getResultArray();
$allReceived = $items !== [];
foreach ($items as $item) {
$allReceived = $allReceived && (int)($item['received_qty'] ?? 0) >= (int)($item['quantity'] ?? 0);
}
if (!$this->poModel->update($poId, ['status' => $allReceived ? 'received' : 'ordered'])) {
throw new \RuntimeException('Failed to update PO status.');
}
$this->db->table('inventory_receipt_operations')
->where('id', $operationId)
->update(['status' => 'reversed', 'updated_at' => utc_now()]);
if (!$this->db->transStatus()) {
throw new \RuntimeException('Receipt reversal transaction failed.');
}
$this->db->transCommit();
} catch (\Throwable $e) {
$this->db->transRollback();
log_message('error', 'Failed to reverse receipt operation #{operation}: {msg}', [
'operation' => $operationId,
'msg' => $e->getMessage(),
]);
return redirect()->to($poId > 0 ? 'inventory/po/show/' . $poId : 'inventory/po')->with('error', 'Failed to reverse receipt.');
}
return redirect()->to('inventory/po/show/' . $poId)->with('success', 'Receipt reversed.');
}
public function cancel($id)
{
$po = $this->poModel->find($id);
if (!$po || $po['status'] === 'received') {
return redirect()->to('inventory/po/show/'.$id)->with('error', 'Cannot cancel this PO.');
}
$received = $this->itemModel
->where('purchase_order_id', (int) $id)
->where('received_qty >', 0)
->countAllResults();
if ($received > 0) {
return redirect()->to('inventory/po/show/'.$id)->with('error', 'Cannot cancel a partially received PO without inventory reversal.');
}
if (!$this->poModel->update($id, ['status' => 'canceled'])) {
return redirect()->to('inventory/po/show/'.$id)->with('error', 'Failed to cancel PO.');
}
return redirect()->to('inventory/po/show/'.$id)->with('success', 'PO canceled.');
}
private function buildReceiptFingerprint(int $purchaseOrderId, array $received): string
{
$normalized = [];
foreach ($received as $itemId => $qty) {
$qty = (int)$qty;
if ($qty > 0) {
$normalized[(int)$itemId] = $qty;
}
}
ksort($normalized);
return hash('sha256', json_encode([
'operation_type' => 'inventory_receipt',
'purchase_order_id' => $purchaseOrderId,
'received' => $normalized,
], JSON_UNESCAPED_SLASHES));
}
}