[test] contact to setting modules

This commit is contained in:
Thanakorn S
2026-05-06 08:58:09 +07:00
parent 414f1cd81d
commit 6501d326ca
43 changed files with 4295 additions and 581 deletions
+99 -52
View File
@@ -4,7 +4,7 @@
* OrderManager
*
* Handles all read and write operations for sales orders (td_order)
* and their downstream stock-out effects on td_stock_<warehouse> tables.
* and their downstream stock-out effects on td_stock_<warehouse_id> tables.
*
* Method order:
* Transaction basis → getOrderList, getOrderById, generateOrderNumber,
@@ -16,25 +16,24 @@
* No separate child table exists. SKU-level reporting is served by
* td_stock_* rows (source='order') rather than querying items JSON.
* - confirmOrder() picks racks via FIFO — oldest approved stock-in row
* still rack-occupied, ordered by td_stock_<wh>.date ASC.
* still rack-occupied, ordered by td_stock_<warehouse_id>.date ASC.
* - confirmOrder() creates stock-out rows with status=0 (draft).
* Warehouse staff approve them via the existing approve_stock.php
* engine, which handles rack release and balance adjustment.
* - cancelOrder() sets td_order.status = -1 and soft-deletes ALL linked
* stock-out rows (status → -1) across all td_stock_* tables.
* Cancellation is blocked if any active invoice (status != -1) or
* active return (status != -1) is linked to the order.
* - Cancel logic is intentionally self-contained here. status=-1 is
* an order-domain concept with no rack/balance side effects, so
* WarehouseManager and StockManager are not involved.
* stock-out rows (status → -1) across all td_stock_* tables. If linked
* stock-out rows were already approved, their rack and balance effects are
* reversed before the rows are cancelled.
* Cancellation is blocked if any active invoice (status != 4 / void) or
* active return (status != -1 / cancelled) is linked to the order.
* - Cancel logic stays in the order domain, but uses WarehouseManager for
* the same rack and balance reversal rules as manual stock-out deletion.
*
* Note: Write methods do NOT manage their own DB transactions.
* Callers must wrap multi-step operations inside dbTransaction().
*
* Security: All SQL uses PDO prepared statements with bound parameters.
* Dynamic table names (td_stock_<warehouse>) are sanitised with
* preg_replace('/[^a-zA-Z0-9_]/', '', ...) before interpolation —
* no user input ever reaches a table identifier directly.
* Dynamic stock table names are derived only from md_warehouse.id.
*/
class OrderManager {
@@ -67,31 +66,13 @@ class OrderManager {
];
}
/**
* Resolve the td_stock_<warehouse> table name for a warehouse_id.
*
* Does not filter by status — allows reading inactive warehouses
* for historical order lookups.
*
* @param int $warehouse_id
* @return string Sanitised table name e.g. "td_stock_Main".
* @throws Exception If warehouse not found.
*/
private function resolveStockTable(int $warehouse_id): string
private function stockTableNameFromWarehouseId(int $warehouse_id): string
{
$sth = $this->pdo->prepare(
"SELECT warehouse_name FROM md_warehouse
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]);
$name = $sth->fetchColumn();
if (!$name) {
throw new Exception("Warehouse ID {$warehouse_id} not found.");
if ($warehouse_id <= 0) {
throw new Exception("Invalid warehouse id.");
}
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
return "td_stock_{$safe}";
return 'td_stock_' . $warehouse_id;
}
/**
@@ -108,10 +89,18 @@ class OrderManager {
*/
private function pickFifoRack(int $warehouse_id, string $product_sku): ?array
{
$table = $this->resolveStockTable($warehouse_id);
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
$sth = $this->pdo->prepare(
"SELECT s.*, r.zone, r.aisle, r.rack
"SELECT s.*, r.zone, r.aisle, r.rack,
(s.`in` - COALESCE((
SELECT SUM(o.`out`)
FROM `{$table}` o
WHERE o.company_id = s.company_id
AND o.ref_id = s.id
AND o.`out` > 0
AND o.status != -1
), 0)) AS available_qty
FROM `{$table}` s
INNER JOIN md_rack r
ON r.company_id = s.company_id
@@ -120,9 +109,10 @@ class OrderManager {
AND r.product_sku IS NOT NULL
WHERE s.company_id = :company_id
AND s.product_sku = :product_sku
AND s.type = 'in'
AND s.`in` > 0
AND s.status = 1
ORDER BY s.date ASC
HAVING available_qty > 0
ORDER BY s.date ASC, s.id ASC
LIMIT 1"
);
$sth->execute([
@@ -354,7 +344,7 @@ class OrderManager {
*
* Flow per item:
* 1. pickFifoRack() — find oldest rack-occupied stock-in row for the SKU.
* 2. INSERT into td_stock_<wh> with type='out', status=0,
* 2. INSERT into td_stock_<warehouse_id> with type='out', status=0,
* source='order', source_id=order_id.
* 3. Write back stock_out_id into the item object in td_order.items.
*
@@ -396,17 +386,20 @@ class OrderManager {
throw new Exception("Cannot confirm an order with no items.");
}
// ── Per-item: FIFO pick + insert stock-out row ────────────────────
foreach ($items as $i => &$item) {
$warehouse_id = (int)($item['warehouse_id'] ?? 0);
$product_sku = $item['product_sku'] ?? '';
$quantity = (float)($item['quantity'] ?? 0);
if (!$warehouse_id || !$product_sku) {
throw new Exception(
"Item #{$i}: missing warehouse_id or product_sku."
);
}
if ($quantity <= 0) {
throw new Exception("Item #{$i}: quantity must be greater than zero.");
}
$rack_stock = $this->pickFifoRack($warehouse_id, $product_sku);
@@ -417,7 +410,17 @@ class OrderManager {
);
}
$table = $this->resolveStockTable($warehouse_id);
$available_qty = (float)($rack_stock['available_qty'] ?? $rack_stock['in'] ?? 0);
if ($quantity - $available_qty > 0.000001) {
$name = $item['product_name'] ?? $product_sku;
throw new Exception(
"FIFO rack {$rack_stock['zone']}-{$rack_stock['aisle']}-{$rack_stock['rack']} " .
"for \"{$name}\" has only {$available_qty} available unit(s), " .
"but the order requests {$quantity}."
);
}
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
$item_uuid = $uuid . '_' . $i;
$item_log = [array_merge($logging, ['action' => 'confirm_item'])];
@@ -439,7 +442,7 @@ class OrderManager {
':company_id' => $this->company_id,
':date' => date('Y-m-d H:i:s'),
':product_sku' => $product_sku,
':quantity' => (float)$item['quantity'],
':quantity' => $quantity,
':zone' => $rack_stock['zone'],
':aisle' => $rack_stock['aisle'],
':rack' => $rack_stock['rack'],
@@ -493,17 +496,18 @@ class OrderManager {
*
* Business rule:
* An order can be cancelled (Draft or Confirmed) as long as no active
* downstream documents exist. Active means status != -1 (not voided /
* not cancelled).
* downstream documents exist. For invoices, active means not voided
* (status != 4). For returns, active means not cancelled (status != -1).
*
* Downstream documents that block cancellation:
* - td_invoice where order_id = $order_id AND status != -1
* - td_invoice where order_id = $order_id AND status != 4
* - td_return where order_id = $order_id AND status != -1
*
* Stock-out rows are children of the order — they follow the parent
* and are never independently approved. On cancel, ALL stock-out rows
* linked to this order (any status except already -1) are soft-deleted
* (status → -1) across all td_stock_* tables.
* Stock-out rows are children of the order — they follow the parent.
* On cancel, approved rows first re-occupy the original source rack and
* reverse the stock-out balance. Then ALL stock-out rows linked to this
* order (any status except already -1) are soft-deleted (status → -1)
* across all td_stock_* tables.
*
* Must be called inside dbTransaction() by the caller.
*
@@ -541,7 +545,7 @@ class OrderManager {
"SELECT COUNT(*) FROM td_invoice
WHERE company_id = :company_id
AND order_id = :order_id
AND status != -1"
AND status != 4"
);
$sth->execute([':company_id' => $this->company_id, ':order_id' => $order_id]);
if ((int)$sth->fetchColumn() > 0) {
@@ -566,7 +570,7 @@ class OrderManager {
);
}
// ── Soft-delete all linked stock-out rows ─────────────────────────
// ── Reverse approved stock-out side effects, then soft-delete rows ─
$sth = $this->pdo->prepare(
"SELECT table_name FROM information_schema.tables
WHERE table_schema = DATABASE()
@@ -574,11 +578,54 @@ class OrderManager {
);
$sth->execute();
$tables = $sth->fetchAll(PDO::FETCH_COLUMN);
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
foreach ($tables as $table) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
if (!preg_match('/^td_stock_(\d+)$/', (string)$table, $matches)) {
continue;
}
$warehouse_id = (int)$matches[1];
$row_sth = $this->pdo->prepare(
"SELECT id, product_sku, `out`, zone, aisle, rack, ref_id, status
FROM `{$table}`
WHERE company_id = :company_id
AND source = 'order'
AND source_id = :order_id
AND type = 'out'
AND status != -1"
);
$row_sth->execute([
':company_id' => $this->company_id,
':order_id' => $order_id,
]);
$rows = $row_sth->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $row) {
if ((int)$row['status'] !== 1) {
continue;
}
$whMgmt->occupyRack(
$warehouse_id,
(string)$row['zone'],
(string)$row['aisle'],
(string)$row['rack'],
(string)$row['product_sku'],
(int)$row['ref_id']
);
$whMgmt->adjustBalance(
'out',
$warehouse_id,
(string)$row['product_sku'],
(float)$row['out'],
0
);
}
$this->pdo->prepare(
"UPDATE `{$safe}` SET status = -1
"UPDATE `{$table}` SET status = -1
WHERE company_id = :company_id
AND source = 'order'
AND source_id = :order_id