fix financials
Tests / PHPUnit (push) Failing after 1m21s

This commit is contained in:
root
2026-07-18 22:57:40 -04:00
parent 068e739408
commit a30c1398a1
61 changed files with 10908 additions and 1775 deletions
+305 -50
View File
@@ -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));
}
}