- 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>
387 lines
17 KiB
PHP
387 lines
17 KiB
PHP
<?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);
|