[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
+69 -59
View File
@@ -19,8 +19,7 @@
* 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 derived from DB-sourced warehouse
* names sanitised with preg_replace('/[^a-zA-Z0-9_]/', '', ...) before interpolation.
* Dynamic stock table names are derived only from md_warehouse.id.
*/
class StockManager {
@@ -36,34 +35,13 @@ class StockManager {
// Private helpers
// ─────────────────────────────────────────────────────────────
/**
* Resolve the dynamic td_stock_<warehouse> table name for a warehouse_id.
*
* Looks up warehouse_name from md_warehouse (no status filter — unlike
* WarehouseManager::resolveWarehouseTable, this serves read flows that may
* need to access inactive warehouses for historical record retrieval).
* The name is sanitised with preg_replace before being used as a table
* identifier, preventing SQL injection via malicious warehouse names.
*
* @param int $warehouse_id The md_warehouse.id to resolve.
* @return string The sanitised table name, e.g. "td_stock_Main".
* @throws Exception If no warehouse is found for the given ID.
*/
private function resolveTable(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 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;
}
// ─────────────────────────────────────────────────────────────
@@ -83,14 +61,14 @@ class StockManager {
*/
public function getStockList(int $warehouse_id, string $type): array
{
$table = $this->resolveTable($warehouse_id);
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
$column = $type === 'out' ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)';
// Transfer list: show only the outbound side (out > 0) to avoid duplicate display
$extra_cond = ($type === 'transfer') ? 'AND a.out > 0' : '';
$sth = $this->pdo->prepare(
"SELECT a.*, {$column} AS quantity, b.product_name
"SELECT a.*, {$column} AS quantity, b.product_name, b.uom
FROM `{$table}` a
LEFT JOIN md_product b
ON a.company_id = b.company_id
@@ -114,18 +92,18 @@ class StockManager {
* stock in detail view. Joins md_lot to include lot expiry_date when available.
*
* @param int $warehouse_id The warehouse the stock_in belongs to.
* @param int $id The td_stock_<wh>.id of the stock_in row.
* @param int $id The td_stock_<warehouse_id>.id of the stock_in row.
* @return array|false Full row with 'quantity', 'contact_name', 'product_name',
* 'expiry_date', or false if not found.
*/
public function getStockInById(int $warehouse_id, int $id): array|false
{
$table = $this->resolveTable($warehouse_id);
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
$sth = $this->pdo->prepare(
"SELECT a.*, a.in AS quantity,
b.contact_name,
c.product_name,
c.product_name, c.uom,
d.expiry_date
FROM `{$table}` a
LEFT JOIN md_contact b
@@ -149,18 +127,18 @@ class StockManager {
* stock out detail view.
*
* @param int $warehouse_id The warehouse the stock_out belongs to.
* @param int $id The td_stock_<wh>.id of the stock_out row.
* @param int $id The td_stock_<warehouse_id>.id of the stock_out row.
* @return array|false Full row with 'quantity', 'contact_name', 'product_name',
* or false if not found.
*/
public function getStockOutById(int $warehouse_id, int $id): array|false
{
$table = $this->resolveTable($warehouse_id);
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
$sth = $this->pdo->prepare(
"SELECT a.*, a.out AS quantity,
b.contact_name,
c.product_name
c.product_name, c.uom
FROM `{$table}` a
LEFT JOIN md_contact b
ON a.company_id = b.company_id AND a.contact_id = b.id
@@ -182,19 +160,19 @@ class StockManager {
* transfer detail view.
*
* @param int $warehouse_id The warehouse holding the outbound (from) row.
* @param int $id The td_stock_<wh>.id of the outbound transfer row.
* @param int $id The td_stock_<warehouse_id>.id of the outbound transfer row.
* @return array|false Outbound row with 'quantity', 'contact_name', 'product_name',
* and a 'ref' key containing the inbound row, or false if not found.
*/
public function getTransferById(int $warehouse_id, int $id): array|false
{
$table = $this->resolveTable($warehouse_id);
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
// Fetch the outbound (from) row
$sth = $this->pdo->prepare(
"SELECT a.*, a.out AS quantity,
b.contact_name,
c.product_name
c.product_name, c.uom
FROM `{$table}` a
LEFT JOIN md_contact b
ON a.company_id = b.company_id AND a.contact_id = b.id
@@ -209,12 +187,12 @@ class StockManager {
// Resolve the inbound (to) row via ref_warehouse + uuid
$to_warehouse_id = (int)$output['ref_warehouse'];
$to_table = $this->resolveTable($to_warehouse_id);
$to_table = $this->stockTableNameFromWarehouseId($to_warehouse_id);
$sth = $this->pdo->prepare(
"SELECT a.*, a.in AS quantity,
b.contact_name,
c.product_name
c.product_name, c.uom
FROM `{$to_table}` a
LEFT JOIN md_contact b
ON a.company_id = b.company_id AND a.contact_id = b.id
@@ -237,9 +215,9 @@ class StockManager {
*
* Insert flow (id = 0):
* 1. Upserts md_lot if lot_number + expiry_date are provided.
* 2. Inserts the td_stock_<wh> row.
* 3. Calls WarehouseManager::occupyRack() to mark the rack as taken.
* 4. Calls WarehouseManager::adjustBalance() to update warehouse_balance.
* 2. Inserts the td_stock_<warehouse_id> row.
* 3. Calls WarehouseManager::occupyRack() to reserve the rack.
* warehouse_balance is updated later by approveStock().
*
* Update flow (id > 0):
* - Updates contact_id, description, and log only.
@@ -328,7 +306,18 @@ class StockManager {
$td_stock_id = (int)$this->pdo->lastInsertId();
// occupyRack and adjustBalance deferred — called from approveStock() only.
// Reserve the location immediately so draft stock-in rows cannot
// leave the same rack available for another receipt. Balance still
// changes only when approveStock() runs.
$whMgmt->occupyRack(
$warehouse_id,
$data['zone'],
$data['aisle'],
$data['rack'],
$data['product_sku'],
$td_stock_id
);
return $td_stock_id;
}
}
@@ -338,7 +327,7 @@ class StockManager {
*
* Insert flow (id = 0):
* 1. Validates the rack is occupied with the correct SKU / lot / serial.
* 2. Inserts the td_stock_<wh> row, copying quantity and lot info from the rack.
* 2. Inserts the td_stock_<warehouse_id> row, copying quantity and lot info from the rack.
* 3. Calls WarehouseManager::releaseRack() to free the rack slot.
* 4. Calls WarehouseManager::adjustBalance() to update warehouse_balance.
*
@@ -609,7 +598,7 @@ class StockManager {
':date' => date('Y-m-d H:i:s'),
':product_sku' => $data['product_sku'],
':quantity' => $quantity,
':ref_warehouse' => $whMgmt->getWarehouseName($to_warehouse),
':ref_warehouse' => $to_warehouse,
':zone' => $from_zone,
':aisle' => $from_aisle,
':rack' => $from_rack,
@@ -637,7 +626,7 @@ class StockManager {
':date' => date('Y-m-d H:i:s'),
':product_sku' => $data['product_sku'],
':quantity' => $quantity,
':ref_warehouse' => $whMgmt->getWarehouseName($from_warehouse),
':ref_warehouse' => $from_warehouse,
':ref_id' => $from_stock_id,
':zone' => $to_zone,
':aisle' => $to_aisle,
@@ -686,7 +675,7 @@ class StockManager {
*/
public function approveStock(int $id, int $warehouse_id, string $type, WarehouseManager $whMgmt): void
{
$table = $this->resolveTable($warehouse_id);
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
// Load the row and validate ownership / status
$sth = $this->pdo->prepare(
@@ -724,31 +713,52 @@ class StockManager {
} elseif ($type === 'out') {
// Release rack now that stock-out is approved
$whMgmt->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']);
$remaining_qty = 0.0;
if ((int)($row['ref_id'] ?? 0) > 0) {
$remaining_sth = $this->pdo->prepare(
"SELECT src.`in` - COALESCE(SUM(out_rows.`out`), 0) AS remaining_qty
FROM `{$table}` src
LEFT JOIN `{$table}` out_rows
ON out_rows.company_id = src.company_id
AND out_rows.ref_id = src.id
AND out_rows.`out` > 0
AND out_rows.status != -1
WHERE src.id = :ref_id
AND src.company_id = :company_id
GROUP BY src.id, src.`in`"
);
$remaining_sth->execute([
':ref_id' => (int)$row['ref_id'],
':company_id' => $this->company_id,
]);
$remaining_qty = (float)$remaining_sth->fetchColumn();
}
// Release the rack only when the source batch is fully consumed.
if ($remaining_qty <= 0.000001) {
$whMgmt->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']);
}
$whMgmt->adjustBalance('out', $warehouse_id, $row['product_sku'], 0, (float)$row['out']);
} elseif ($type === 'transfer') {
// Transfer always has two rows in two different tables:
// outbound row → td_stock_<from> (out > 0, ref_warehouse = to_name)
// inbound row → td_stock_<to> (in > 0, ref_warehouse = from_name)
// outbound row → td_stock_<from_id> (out > 0, ref_warehouse = to_id)
// inbound row → td_stock_<to_id> (in > 0, ref_warehouse = from_id)
// Both rows share the same uuid. We always approve both atomically.
// Identify which side we were given and derive the other.
$is_outbound = (float)$row['out'] > 0;
// The outbound row lives in the from-warehouse table (already loaded as $row/$table).
// The inbound row lives in td_stock_<ref_warehouse>.
// The inbound row lives in td_stock_<ref_warehouse_id>.
$from_row = $is_outbound ? $row : null;
$from_table = $is_outbound ? $table : null;
$from_wh_id = $is_outbound ? $warehouse_id : null;
// Resolve the paired table from ref_warehouse
$paired_wh_name = $row['ref_warehouse'];
$paired_safe = preg_replace('/[^a-zA-Z0-9_]/', '', $paired_wh_name);
$paired_table = "td_stock_{$paired_safe}";
$paired_wh_id = $whMgmt->getWarehouseIdByName($paired_wh_name);
// Resolve the paired table from ref_warehouse id
$paired_wh_id = (int)$row['ref_warehouse'];
$paired_table = $this->stockTableNameFromWarehouseId($paired_wh_id);
// If we received the inbound side, swap so $from_* is always outbound
if (!$is_outbound) {
@@ -816,4 +826,4 @@ class StockManager {
}
}
}
}