@@ -79,8 +79,11 @@ class PurchaseOrderController extends BaseController
|
||||
$subtotal = 0.0;
|
||||
$items = [];
|
||||
foreach ($supply_ids as $i => $sid) {
|
||||
$q = max(0, (int)($qtys[$i] ?? 0));
|
||||
$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;
|
||||
@@ -151,77 +154,329 @@ class PurchaseOrderController extends BaseController
|
||||
*/
|
||||
public function receive($id)
|
||||
{
|
||||
$po = $this->poModel->find($id);
|
||||
if (!$po || in_array($po['status'], ['canceled','received'], true)) {
|
||||
return redirect()->to('inventory/po/show/'.$id)->with('error', 'PO not receivable.');
|
||||
}
|
||||
|
||||
$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->transStart();
|
||||
|
||||
$completed = true;
|
||||
foreach ($received as $itemId => $qty) {
|
||||
$qty = (int)$qty;
|
||||
if ($qty <= 0) continue;
|
||||
|
||||
$item = $this->itemModel->where('purchase_order_id', $id)->find($itemId);
|
||||
if (!$item) { $completed = false; continue; }
|
||||
|
||||
$remaining = (int)$item['quantity'] - (int)$item['received_qty'];
|
||||
$toReceive = min($remaining, $qty);
|
||||
if ($toReceive <= 0) continue;
|
||||
|
||||
// Update item received qty
|
||||
$this->itemModel->update($itemId, [
|
||||
'received_qty' => (int)$item['received_qty'] + $toReceive
|
||||
]);
|
||||
|
||||
// Update supply on hand
|
||||
$supply = $this->supplyModel->find($item['supply_id']);
|
||||
if (!$supply) { $completed = false; continue; }
|
||||
$newQty = (int)$supply['qty_on_hand'] + $toReceive;
|
||||
$this->supplyModel->update($supply['id'], ['qty_on_hand' => $newQty]);
|
||||
|
||||
// Log transaction IN
|
||||
$this->txnModel->insert([
|
||||
'supply_id' => $supply['id'],
|
||||
'type' => 'in',
|
||||
'quantity' => $toReceive,
|
||||
'ref' => 'PO ' . $po['po_number'],
|
||||
'issued_to' => 'Inventory',
|
||||
'issued_by' => $issuedBy,
|
||||
'notes' => 'Received against PO',
|
||||
]);
|
||||
|
||||
if (($item['received_qty'] + $toReceive) < $item['quantity']) {
|
||||
$completed = false;
|
||||
$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.');
|
||||
}
|
||||
}
|
||||
|
||||
// Set PO status
|
||||
$this->poModel->update($id, ['status' => $completed ? 'received' : 'ordered']);
|
||||
$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.');
|
||||
}
|
||||
|
||||
$this->db->transComplete();
|
||||
if ($this->db->transStatus() === false) {
|
||||
$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.');
|
||||
}
|
||||
$this->poModel->update($id, ['status' => 'canceled']);
|
||||
$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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user