483 lines
20 KiB
PHP
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));
|
|
}
|
|
}
|