fix StockManager lot/serial coercion + add document flow test suite

- StockManager: coerce lot_number/serial_number to trimmed string (fixes
  Array-to-string warnings); validate quantity > 0 on insert; tighten
  transfer pair lookup to match in/out side by column value
- PostingWindowGuard: strip time component from datetime strings before
  date-format validation
- docs/tests/doc_flow_test.php: 32-assertion document flow suite covering
  Stock In create + approve, Sales Order draft/confirm, Invoice from Order
  (with duplicate-block check), PO create/confirm, Quotation create, and
  usage increment wiring check; all 32/32 PASS against wms_codex_test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thanakorn S
2026-05-21 14:20:41 +07:00
co-authored by Claude Sonnet 4.6
parent 6eeebfeacb
commit 94032dd65b
3 changed files with 403 additions and 6 deletions
+386
View File
@@ -0,0 +1,386 @@
<?php
/**
* Document Flow Test Suite — T19 to T27
* Run: php tests/doc_flow_test.php
* DB: wms_codex_test / wms2_codex_test
*/
// ── Bootstrap ────────────────────────────────────────────────────────────────
define('APP_ROOT', __DIR__ . '/../app');
// Load config for helper classes (packages array etc.) but override DB names
require APP_ROOT . '/config.php';
$db_database = 'wms_codex_test';
$db_database2 = 'wms2_codex_test';
require APP_ROOT . '/dbconn.php';
require APP_ROOT . '/assets/utils/db_helpers.php';
require APP_ROOT . '/assets/utils/classes/CompanySettingManager.php';
require APP_ROOT . '/assets/utils/classes_ac/PostingWindowGuard.php';
require APP_ROOT . '/assets/utils/classes/WarehouseManager.php';
require APP_ROOT . '/assets/utils/classes/StockManager.php';
require APP_ROOT . '/assets/utils/classes/OrderManager.php';
require APP_ROOT . '/assets/utils/classes/PurchaseOrderManager.php';
require APP_ROOT . '/assets/utils/classes/QuotationManager.php';
require APP_ROOT . '/assets/utils/classes/InvoiceManager.php';
// Fake session so logging arrays don't fail
$_SESSION['login_user_id'] = 501;
$_SESSION['otpTime'] = time();
// Test constants
const COMPANY_ID = 1001;
const WH_ID = 4;
const ZONE = '99';
const AISLE = '99';
const RACK = '99';
const SKU = 'SKU-CODEX-001';
// ── Helpers ──────────────────────────────────────────────────────────────────
$pass = 0;
$fail = 0;
$results = [];
function ok(string $label, bool $condition, string $detail = ''): void {
global $pass, $fail, $results;
$status = $condition ? 'PASS' : 'FAIL';
$condition ? $pass++ : $fail++;
$results[] = [$status, $label, $detail];
echo ($condition ? "\033[32m[PASS]\033[0m" : "\033[31m[FAIL]\033[0m")
. " {$label}" . ($detail ? " — {$detail}" : '') . PHP_EOL;
}
function logging(): array {
return ['user_id' => 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);