diff --git a/app/assets/utils/classes/StockManager.php b/app/assets/utils/classes/StockManager.php index acb11b3..6a32aa2 100644 --- a/app/assets/utils/classes/StockManager.php +++ b/app/assets/utils/classes/StockManager.php @@ -233,7 +233,12 @@ class StockManager { public function saveStockIn(array $data, array $logging, string $uuid): int { $id = (int)($data['id'] ?? 0); - $warehouse_id = (int)($data['warehouse'] ?? 0); + $warehouse_id = (int)($data["warehouse"] ?? 0); + $quantity = (float)($data['quantity'] ?? 0); + + if ($id === 0 && $quantity <= 0) { + throw new Exception("Quantity must be greater than zero."); + } $whMgmt = new WarehouseManager($this->pdo, $this->company_id); $ctx = $whMgmt->getStockContext($warehouse_id, $id); @@ -267,8 +272,8 @@ class StockManager { } else { - $lot_number = $data['lot_number'] ?: null; - $expiry_date = $data['expiry_date'] ?: null; + $lot_number = trim((string)($data["lot_number"] ?? "")); + $expiry_date = trim((string)($data["expiry_date"] ?? "")); // Upsert md_lot: preserve existing expiry_date if already recorded if ($lot_number && $expiry_date) { @@ -296,7 +301,7 @@ class StockManager { ':company_id' => $this->company_id, ':date' => date('Y-m-d H:i:s'), ':product_sku' => $data['product_sku'], - ':quantity' => $data['quantity'], + ':quantity' => $quantity, ':price' => (float)($data['price'] ?? 0), ':zone' => $data['zone'], ':aisle' => $data['aisle'], @@ -305,7 +310,7 @@ class StockManager { ':description' => $data['description'] ?? '', ':log' => json_encode($table_log), ':lot_number' => $lot_number, - ':serial_number' => ($data['serial_number'] ?? null) ?: null, + ':serial_number' => trim((string)($data["serial_number"] ?? "")), ]); $td_stock_id = (int)$this->pdo->lastInsertId(); @@ -788,6 +793,7 @@ class StockManager { $sth = $this->pdo->prepare( "SELECT * FROM `{$paired_table}` WHERE uuid = :uuid AND company_id = :company_id + AND `in` > 0 LIMIT 1" ); $sth->execute([':uuid' => $row['uuid'], ':company_id' => $this->company_id]); @@ -798,6 +804,7 @@ class StockManager { $sth = $this->pdo->prepare( "SELECT * FROM `{$from_table}` WHERE uuid = :uuid AND company_id = :company_id + AND `out` > 0 LIMIT 1" ); $sth->execute([':uuid' => $row['uuid'], ':company_id' => $this->company_id]); diff --git a/app/assets/utils/classes_ac/PostingWindowGuard.php b/app/assets/utils/classes_ac/PostingWindowGuard.php index 0f0d0cb..879e0cc 100644 --- a/app/assets/utils/classes_ac/PostingWindowGuard.php +++ b/app/assets/utils/classes_ac/PostingWindowGuard.php @@ -64,7 +64,11 @@ class PostingWindowGuard $date = "{$m[3]}-{$m[2]}-{$m[1]}"; } - if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { + if (preg_match("#^([0-9]{4}-[0-9]{2}-[0-9]{2})[ T]#", $date, $m)) { + $date = $m[1]; + } + + if (!preg_match("#^[0-9]{4}-[0-9]{2}-[0-9]{2}$#", $date)) { throw new Exception("Invalid posting date {$date}. Use YYYY-MM-DD."); } diff --git a/docs/tests/doc_flow_test.php b/docs/tests/doc_flow_test.php new file mode 100644 index 0000000..6fca4bb --- /dev/null +++ b/docs/tests/doc_flow_test.php @@ -0,0 +1,386 @@ + 501, 'dt' => date('Y-m-d H:i:s'), 'login' => date('Y-m-d H:i:s')]; +} + +// ── Reset ───────────────────────────────────────────────────────────────────── +echo PHP_EOL . "=== RESET ===" . PHP_EOL; +$p1 = $pdo1; +$p2 = $pdo2; + +// wms_codex_test +$p1->exec("DELETE FROM company_usage WHERE company_id = " . COMPANY_ID); + +// wms2_codex_test +$p2->exec("DELETE FROM td_invoice_item WHERE company_id = " . COMPANY_ID); +$p2->exec("DELETE FROM td_invoice WHERE company_id = " . COMPANY_ID); +$p2->exec("DELETE FROM td_order_item WHERE company_id = " . COMPANY_ID); +$p2->exec("DELETE FROM td_order WHERE company_id = " . COMPANY_ID); +$p2->exec("DELETE FROM td_purchase_order_item WHERE company_id = " . COMPANY_ID); +$p2->exec("DELETE FROM td_purchase_order WHERE company_id = " . COMPANY_ID); +$p2->exec("DELETE FROM td_quotation_item WHERE company_id = " . COMPANY_ID); +$p2->exec("DELETE FROM td_quotation WHERE company_id = " . COMPANY_ID); +$p2->exec("DELETE FROM td_stock_" . WH_ID . " WHERE company_id = " . COMPANY_ID); +$p2->exec("DELETE FROM warehouse_balance WHERE company_id = " . COMPANY_ID); +$p2->exec("UPDATE md_rack SET product_sku = NULL, td_stock_id = 0 WHERE company_id = " . COMPANY_ID); +echo "State reset complete." . PHP_EOL . PHP_EOL; + +// ── T19: Stock In — Create ──────────────────────────────────────────────────── +echo "=== T19: Stock In Create ===" . PHP_EOL; +$stock_in_id = 0; +try { + $data = [ + 'warehouse' => WH_ID, + 'product_sku' => SKU, + 'quantity' => 10, + 'price' => 5.00, + 'zone' => ZONE, + 'aisle' => AISLE, + 'rack' => RACK, + 'contact_id' => 0, + 'description' => 'Test stock in', + 'lot_number' => '', + 'serial_number' => '', + ]; + dbTransaction($p2, function($pdo) use ($data, &$stock_in_id) { + $stock = new StockManager($pdo, COMPANY_ID); + $stock_in_id = $stock->saveStockIn($data, logging(), bin2hex(random_bytes(16))); + }); + ok('T19a: stock_in_id > 0', $stock_in_id > 0, "id={$stock_in_id}"); + + // Verify rack occupied + $rack = $p2->prepare("SELECT product_sku, td_stock_id FROM md_rack WHERE company_id=? AND zone=? AND aisle=? AND rack=?"); + $rack->execute([COMPANY_ID, ZONE, AISLE, RACK]); + $rrow = $rack->fetch(PDO::FETCH_ASSOC); + ok('T19b: rack occupied with correct SKU', $rrow['product_sku'] === SKU, "sku={$rrow['product_sku']}"); + ok('T19c: rack td_stock_id matches', (int)$rrow['td_stock_id'] === $stock_in_id, "td_stock_id={$rrow['td_stock_id']}"); +} catch (Exception $e) { + ok('T19: Stock In', false, $e->getMessage()); +} + +// ── T20: Stock In — Approve ─────────────────────────────────────────────────── +echo PHP_EOL . "=== T20: Stock In Approve ===" . PHP_EOL; +try { + dbTransaction($p2, function($pdo) use ($stock_in_id) { + $stock = new StockManager($pdo, COMPANY_ID); + $whMgmt = new WarehouseManager($pdo, COMPANY_ID); + $stock->approveStock($stock_in_id, WH_ID, 'in', $whMgmt); + }); + + // Check warehouse_balance + $sth = $p2->prepare("SELECT total_in FROM warehouse_balance WHERE company_id=? AND warehouse_id=? AND product_sku=?"); + $sth->execute([COMPANY_ID, WH_ID, SKU]); + $bal = $sth->fetch(PDO::FETCH_ASSOC); + ok('T20a: warehouse_balance updated', !empty($bal), "total_in=" . ($bal['total_in'] ?? 'null')); + ok('T20b: total_in = 10', (float)($bal['total_in'] ?? 0) === 10.0, (string)($bal['total_in'] ?? 0)); + + // Check stock row status = 1 + $sth2 = $p2->prepare("SELECT status FROM td_stock_" . WH_ID . " WHERE id=? AND company_id=?"); + $sth2->execute([$stock_in_id, COMPANY_ID]); + $srow = $sth2->fetch(PDO::FETCH_ASSOC); + ok('T20c: stock status = 1 after approve', (int)$srow['status'] === 1, "status={$srow['status']}"); +} catch (Exception $e) { + ok('T20: Stock Approve', false, $e->getMessage()); +} + +// ── T21: Sales Order — Create Draft ────────────────────────────────────────── +echo PHP_EOL . "=== T21: Sales Order Create ===" . PHP_EOL; +$order_id = 0; +try { + $qty = 2.0; + $uprice = 10.0; + $total = $qty * $uprice; + $data = [ + 'contact_id' => 0, + 'department_id'=> 0, + 'order_date' => date('Y-m-d'), + 'discount' => 0, + 'tax_adjustment' => 0, + 'shipping_fee' => 0, + 'notes' => 'Test order', + 'items' => [[ + 'product_sku' => SKU, + 'product_name' => 'Test Product 001', + 'quantity' => $qty, + 'unit_price' => $uprice, + 'total_price' => $total, + 'tax_amount' => 0, + 'tax_rate' => 0, + 'warehouse_id' => WH_ID, + 'lot_number' => '', + ]], + ]; + $om = new OrderManager($p2, COMPANY_ID); + $order_id = $om->saveOrder($data, logging()); + ok('T21a: order_id > 0', $order_id > 0, "id={$order_id}"); + + $sth = $p2->prepare("SELECT status, grand_total FROM td_order WHERE id=? AND company_id=?"); + $sth->execute([$order_id, COMPANY_ID]); + $orow = $sth->fetch(PDO::FETCH_ASSOC); + ok('T21b: order status = 0 (draft)', (int)$orow['status'] === 0, "status={$orow['status']}"); + ok('T21c: grand_total = 20.00', (float)$orow['grand_total'] === 20.0, "total={$orow['grand_total']}"); + + $sth2 = $p2->prepare("SELECT COUNT(*) FROM td_order_item WHERE order_id=? AND company_id=?"); + $sth2->execute([$order_id, COMPANY_ID]); + ok('T21d: 1 order item created', (int)$sth2->fetchColumn() === 1); +} catch (Exception $e) { + ok('T21: Sales Order Create', false, $e->getMessage()); +} + +// ── T22: Sales Order — Confirm ─────────────────────────────────────────────── +echo PHP_EOL . "=== T22: Sales Order Confirm ===" . PHP_EOL; +try { + $uuid = bin2hex(random_bytes(16)); + dbTransaction($p2, function($pdo) use ($order_id, $uuid) { + $om = new OrderManager($pdo, COMPANY_ID); + $om->confirmOrder($order_id, $uuid, logging()); + }); + + $sth = $p2->prepare("SELECT status FROM td_order WHERE id=? AND company_id=?"); + $sth->execute([$order_id, COMPANY_ID]); + $orow = $sth->fetch(PDO::FETCH_ASSOC); + ok('T22a: order status = 1 (confirmed)', (int)$orow['status'] === 1, "status={$orow['status']}"); + + // Check a stock-out draft row was created + $sth2 = $p2->prepare("SELECT COUNT(*) FROM td_stock_" . WH_ID . " WHERE company_id=? AND source='order' AND source_id=? AND `out`>0"); + $sth2->execute([COMPANY_ID, $order_id]); + ok('T22b: draft stock-out row created', (int)$sth2->fetchColumn() === 1); +} catch (Exception $e) { + ok('T22: Sales Order Confirm', false, $e->getMessage()); +} + +// ── T23: Invoice — Create from Order ───────────────────────────────────────── +echo PHP_EOL . "=== T23: Invoice from Confirmed Order ===" . PHP_EOL; +$invoice_id = 0; +try { + $im = new InvoiceManager($p2, COMPANY_ID); + dbTransaction($p2, function($pdo) use ($order_id, &$invoice_id) { + $im2 = new InvoiceManager($pdo, COMPANY_ID); + $invoice_id = $im2->createFromOrder($order_id, logging()); + }); + ok('T23a: invoice_id > 0', $invoice_id > 0, "id={$invoice_id}"); + + $sth = $p2->prepare("SELECT status, doc_type, grand_total FROM td_invoice WHERE id=? AND company_id=?"); + $sth->execute([$invoice_id, COMPANY_ID]); + $inv = $sth->fetch(PDO::FETCH_ASSOC); + ok('T23b: doc_type = invoice', $inv['doc_type'] === 'invoice', "doc_type={$inv['doc_type']}"); + ok('T23c: invoice status = 0 (draft)', (int)$inv['status'] === 0, "status={$inv['status']}"); + ok('T23d: grand_total = 20.00', (float)$inv['grand_total'] === 20.0, "total={$inv['grand_total']}"); + + $sth2 = $p2->prepare("SELECT COUNT(*) FROM td_invoice_item WHERE invoice_id=? AND company_id=?"); + $sth2->execute([$invoice_id, COMPANY_ID]); + ok('T23e: invoice items copied', (int)$sth2->fetchColumn() >= 1); + + // Block duplicate invoice + $blocked = false; + try { + dbTransaction($p2, function($pdo) use ($order_id) { + (new InvoiceManager($pdo, COMPANY_ID))->createFromOrder($order_id, logging()); + }); + } catch (Exception $e2) { + $blocked = str_contains($e2->getMessage(), 'already exists'); + } + ok('T23f: duplicate invoice blocked', $blocked); +} catch (Exception $e) { + ok('T23: Invoice from Order', false, $e->getMessage()); +} + +// ── T24: Purchase Order — Create ───────────────────────────────────────────── +echo PHP_EOL . "=== T24: PO Create ===" . PHP_EOL; +$po_id = 0; +try { + $qty = 5.0; + $uprice = 4.50; + $total = $qty * $uprice; + $data = [ + 'contact_id' => 0, + 'department_id' => 0, + 'po_date' => date('Y-m-d'), + 'expected_date' => date('Y-m-d', strtotime('+7 days')), + 'warehouse_id' => WH_ID, + 'discount' => 0, + 'tax_adjustment'=> 0, + 'shipping_fee' => 0, + 'notes' => 'Test PO', + 'items' => [[ + 'product_sku' => SKU, + 'product_name' => 'Test Product 001', + 'quantity' => $qty, + 'unit_price' => $uprice, + 'total_price' => $total, + 'tax_amount' => 0, + 'tax_rate' => 0, + ]], + ]; + $pom = new PurchaseOrderManager($p2, COMPANY_ID); + $po_id = $pom->savePo($data, logging()); + ok('T24a: po_id > 0', $po_id > 0, "id={$po_id}"); + + $sth = $p2->prepare("SELECT status, grand_total, expected_date FROM td_purchase_order WHERE id=? AND company_id=?"); + $sth->execute([$po_id, COMPANY_ID]); + $porow = $sth->fetch(PDO::FETCH_ASSOC); + ok('T24b: PO status = 0 (draft)', (int)$porow['status'] === 0, "status={$porow['status']}"); + ok('T24c: grand_total = 22.50', (float)$porow['grand_total'] === 22.5, "total={$porow['grand_total']}"); + ok('T24d: expected_date saved', !empty($porow['expected_date']), "expected_date={$porow['expected_date']}"); + + $sth2 = $p2->prepare("SELECT COUNT(*) FROM td_purchase_order_item WHERE order_id=? AND company_id=?"); + $sth2->execute([$po_id, COMPANY_ID]); + ok('T24e: 1 PO item created', (int)$sth2->fetchColumn() === 1); +} catch (Exception $e) { + ok('T24: PO Create', false, $e->getMessage()); +} + +// ── T25: PO Confirm ────────────────────────────────────────────────────────── +echo PHP_EOL . "=== T25: PO Confirm ===" . PHP_EOL; +try { + $pom = new PurchaseOrderManager($p2, COMPANY_ID); + $pom->confirmPo($po_id, logging()); + + $sth = $p2->prepare("SELECT status FROM td_purchase_order WHERE id=? AND company_id=?"); + $sth->execute([$po_id, COMPANY_ID]); + $porow = $sth->fetch(PDO::FETCH_ASSOC); + ok('T25a: PO status = 1 (confirmed)', (int)$porow['status'] === 1, "status={$porow['status']}"); + + // Confirm again should throw + $blocked = false; + try { $pom->confirmPo($po_id, logging()); } catch (Exception $e2) { $blocked = true; } + ok('T25b: double-confirm blocked', $blocked); +} catch (Exception $e) { + ok('T25: PO Confirm', false, $e->getMessage()); +} + +// ── T26: Quotation Create ──────────────────────────────────────────────────── +echo PHP_EOL . "=== T26: Quotation Create ===" . PHP_EOL; +$quotation_id = 0; +try { + $qty = 3.0; + $uprice = 10.0; + $total = $qty * $uprice; + $data = [ + 'contact_id' => 0, + 'department_id' => 0, + 'quotation_date' => date('Y-m-d'), + 'valid_until' => date('Y-m-d', strtotime('+30 days')), + 'discount' => 0, + 'notes' => 'Test quotation', + 'items' => [[ + 'product_sku' => SKU, + 'product_name' => 'Test Product 001', + 'quantity' => $qty, + 'unit_price' => $uprice, + 'total_price' => $total, + 'tax_amount' => 0, + 'tax_rate' => 0, + ]], + ]; + $qm = new QuotationManager($p2, COMPANY_ID); + $quotation_id = $qm->save($data, logging()); + ok('T26a: quotation_id > 0', $quotation_id > 0, "id={$quotation_id}"); + + $sth = $p2->prepare("SELECT status, grand_total, quotation_date, valid_until FROM td_quotation WHERE id=? AND company_id=?"); + $sth->execute([$quotation_id, COMPANY_ID]); + $qrow = $sth->fetch(PDO::FETCH_ASSOC); + ok('T26b: quotation status = 0 (draft)', (int)$qrow['status'] === 0, "status={$qrow['status']}"); + ok('T26c: grand_total = 30.00', (float)$qrow['grand_total'] === 30.0, "total={$qrow['grand_total']}"); + ok('T26d: quotation_date saved', !empty($qrow['quotation_date']), "qdate={$qrow['quotation_date']}"); + ok('T26e: valid_until saved', !empty($qrow['valid_until']), "valid_until={$qrow['valid_until']}"); + + $sth2 = $p2->prepare("SELECT COUNT(*) FROM td_quotation_item WHERE quotation_id=? AND company_id=?"); + $sth2->execute([$quotation_id, COMPANY_ID]); + ok('T26f: 1 quotation item created', (int)$sth2->fetchColumn() === 1); +} catch (Exception $e) { + ok('T26: Quotation Create', false, $e->getMessage()); +} + +// ── T27: Usage increment grep check ────────────────────────────────────────── +echo PHP_EOL . "=== T27: Usage Increment Wiring ===" . PHP_EOL; +$increment_files = [ + 'ics/api/engine/manage_stock_in.php', + 'ics/api/engine/manage_stock_out.php', + 'ics/api/engine/manage_stock_transfer.php', + 'order/api/engine/manage_order.php', + 'order/api/engine/manage_invoice.php', + 'po/api/engine/manage_po.php', + 'revenue/api/engine/manage_order.php', + 'revenue/api/engine/manage_quotation.php', + 'finance/api/engine/manage_receipt.php', + 'finance/api/engine/manage_payment.php', +]; +$missing = []; +foreach ($increment_files as $rel) { + $path = APP_ROOT . '/' . $rel; + if (!file_exists($path)) { + $missing[] = $rel . ' (FILE MISSING)'; + continue; + } + $src = file_get_contents($path); + if (!str_contains($src, '->increment()')) { + $missing[] = $rel; + } +} +ok('T27: all 10 engine files have increment()', empty($missing), + empty($missing) ? '10/10 found' : 'Missing: ' . implode(', ', $missing)); + +// ── Summary ─────────────────────────────────────────────────────────────────── +echo PHP_EOL . "=== SUMMARY ===" . PHP_EOL; +$total = $pass + $fail; +echo "Passed: {$pass}/{$total}" . PHP_EOL; + +if ($fail > 0) { + echo PHP_EOL . "FAILED TESTS:" . PHP_EOL; + foreach ($results as [$s, $l, $d]) { + if ($s === 'FAIL') echo " - {$l}" . ($d ? ": {$d}" : '') . PHP_EOL; + } +} + +exit($fail === 0 ? 0 : 1);