modify classed and comments

This commit is contained in:
Thanakorn S
2026-04-29 14:21:09 +07:00
parent 2061624641
commit f6dc9a3278
15 changed files with 4122 additions and 2638 deletions
+145 -36
View File
@@ -3,12 +3,24 @@
/**
* StockManager
*
* Encapsulates read operations for ICS stock transactions:
* - Stock list by type (in / out / transfer)
* - Single record retrieval for stock_in, stock_out, transfer
* Handles read and write operations for ICS stock transactions
* (stock in, stock out, stock transfer).
*
* Write operations (manage_stock_in, manage_stock_out, manage_stock_transfer)
* are handled in their engine files via WarehouseManager.
* Method order:
* Master file basis → (none — stock transactions are not master data)
* Transaction basis → getStockList, getStockInById, getStockOutById,
* getTransferById, saveStockIn, saveStockOut, saveStockTransfer
* Report basis → (none — reporting is handled by ReportManager)
*
* Write operations delegate rack and balance side-effects to WarehouseManager.
* Delete operations are handled directly in WarehouseManager (deleteStockIn, etc.).
*
* 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 derived from DB-sourced warehouse
* names sanitised with preg_replace('/[^a-zA-Z0-9_]/', '', ...) before interpolation.
*/
class StockManager {
@@ -25,8 +37,17 @@ class StockManager {
// ─────────────────────────────────────────────────────────────
/**
* Resolve warehouse name by ID and return the td_stock_<wh> table name.
* Throws if warehouse not found.
* 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
{
@@ -46,19 +67,26 @@ class StockManager {
}
// ─────────────────────────────────────────────────────────────
// Stock list queries
// TRANSACTION BASIS — Read
// ─────────────────────────────────────────────────────────────
/**
* List all stock records of a given type for a warehouse.
* type: 'in' | 'out' | 'transfer'
* Return all stock records of a given movement type for a warehouse.
*
* Used to populate the stock in / stock out / transfer listing pages.
* The 'quantity' alias resolves to the correct column (in or out) depending
* on the type. For transfers, only the outbound row is listed (out > 0).
*
* @param int $warehouse_id The md_warehouse.id to query.
* @param string $type Movement type: 'in' | 'out' | 'transfer'.
* @return array Stock rows ordered by date DESC, each with 'quantity' and 'product_name'.
*/
public function getStockList(int $warehouse_id, string $type): array
{
$table = $this->resolveTable($warehouse_id);
$column = $type === 'out' ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)';
// transfer list shows outbound side only (out > 0)
// 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(
@@ -79,13 +107,16 @@ class StockManager {
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
// ─────────────────────────────────────────────────────────────
// Single record retrieval
// ─────────────────────────────────────────────────────────────
/**
* Fetch a single stock_in record with product and contact name,
* plus lot expiry date if applicable.
* Fetch a single stock_in record with related product, contact, and lot data.
*
* Used to pre-fill the manage stock in form in edit mode and for the
* 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.
* @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
{
@@ -112,7 +143,15 @@ class StockManager {
}
/**
* Fetch a single stock_out record with product and contact name.
* Fetch a single stock_out record with related product and contact data.
*
* Used to pre-fill the manage stock out form in edit mode and for the
* 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.
* @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
{
@@ -134,9 +173,18 @@ class StockManager {
}
/**
* Fetch a transfer record pair — the outbound row plus its paired
* inbound row resolved via ref_warehouse + uuid.
* Returns the outbound row with a 'ref' key containing the inbound row.
* Fetch a transfer record pair — the outbound (from) row and its paired
* inbound (to) row — as a single structure.
*
* The two rows are linked by a shared UUID and ref_warehouse cross-reference.
* The inbound row is returned under the 'ref' key of the outbound row.
* This is used by the manage stock transfer form in edit mode and the
* 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.
* @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
{
@@ -180,16 +228,29 @@ class StockManager {
return $output;
}
// ─────────────────────────────────────────────────────────────
// Stock write operations
// TRANSACTION BASIS — Write
// ─────────────────────────────────────────────────────────────
/**
* Insert or update a stock_in record.
* On insert: upserts md_lot, inserts td_stock row, occupies rack, adjusts balance.
* On update: updates metadata (contact, description, log) only.
* Insert a new stock_in record or update metadata on an existing one.
*
* 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.
*
* Update flow (id > 0):
* - Updates contact_id, description, and log only.
* - Quantity, rack, lot, and serial are immutable after creation.
*
* Must be called inside dbTransaction() by the caller.
*
* @param array $data Keys: id, warehouse, product_sku, quantity, zone, aisle, rack,
* contact_id, description, lot_number, expiry_date, serial_number.
* @param array $logging Audit entry to append to the log column.
* @param string $uuid UUID for this transaction (shared across transfer pairs).
*/
public function saveStockIn(array $data, array $logging, string $uuid): void
{
@@ -205,6 +266,7 @@ class StockManager {
if ($id > 0) {
// Update: only metadata fields are editable after creation
$this->pdo->prepare(
"UPDATE `$table` SET
`contact_id` = :contact_id,
@@ -224,7 +286,7 @@ class StockManager {
$lot_number = $data['lot_number'] ?: null;
$expiry_date = $data['expiry_date'] ?: null;
// Upsert md_lot if lot + expiry provided
// Upsert md_lot: preserve existing expiry_date if already recorded
if ($lot_number && $expiry_date) {
$this->pdo->prepare(
"INSERT INTO md_lot (company_id, product_sku, lot_number, expiry_date)
@@ -263,6 +325,7 @@ class StockManager {
$td_stock_id = (int)$this->pdo->lastInsertId();
// Mark rack as occupied and link it to this stock row
$whMgmt->occupyRack(
$warehouse_id,
$data['zone'], $data['aisle'], $data['rack'],
@@ -270,6 +333,7 @@ class StockManager {
$td_stock_id
);
// Update running balance (+quantity in this warehouse)
$whMgmt->adjustBalance(
'in',
$warehouse_id,
@@ -281,10 +345,24 @@ class StockManager {
}
/**
* Insert or update a stock_out record.
* On insert: validates rack, inserts td_stock row, releases rack, adjusts balance.
* On update: updates metadata (contact, description, log) only.
* Insert a new stock_out record or update metadata on an existing one.
*
* 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.
* 3. Calls WarehouseManager::releaseRack() to free the rack slot.
* 4. Calls WarehouseManager::adjustBalance() to update warehouse_balance.
*
* Update flow (id > 0):
* - Updates contact_id, description, and log only.
*
* Must be called inside dbTransaction() by the caller.
*
* @param array $data Keys: id, warehouse, product_sku, zone, aisle, rack,
* contact_id, description, lot_number, serial_number.
* @param array $logging Audit entry to append to the log column.
* @param string $uuid UUID for this transaction.
* @throws Exception If the rack is empty, holds a different SKU/lot/serial.
*/
public function saveStockOut(array $data, array $logging, string $uuid): void
{
@@ -300,6 +378,7 @@ class StockManager {
if ($id > 0) {
// Update: only metadata fields are editable after creation
$this->pdo->prepare(
"UPDATE `$table` SET
`contact_id` = :contact_id,
@@ -316,6 +395,7 @@ class StockManager {
} else {
// Validate rack holds the expected product / lot / serial
$source_stock = $whMgmt->getRackStock(
$warehouse_id,
$data['zone'], $data['aisle'], $data['rack']
@@ -345,6 +425,7 @@ class StockManager {
);
}
// Quantity and identifiers come from the existing stock_in row (immutable)
$quantity = (int)$source_stock['in'];
$ref_id = (int)$source_stock['id'];
$lot_number = $source_stock['lot_number'] ?? null;
@@ -374,6 +455,7 @@ class StockManager {
':serial_number' => $serial_number,
]);
// Release the source rack and reverse balance
$whMgmt->releaseRack(
$warehouse_id,
$data['zone'], $data['aisle'], $data['rack']
@@ -390,10 +472,31 @@ class StockManager {
}
/**
* Insert or update a stock transfer record pair.
* On insert: validates source rack, inserts paired td_stock rows, moves rack state, adjusts both balances.
* On update: updates metadata (contact, description, log) on both rows only.
* Insert a new stock transfer pair or update metadata on an existing one.
*
* A transfer creates two linked td_stock rows — an outbound row in the
* source warehouse and an inbound row in the destination warehouse — both
* sharing the same UUID and cross-referencing each other via ref_id.
*
* Insert flow (id = 0):
* 1. Validates the source rack holds the correct SKU / lot / serial.
* 2. Inserts the outbound row in td_stock_<from>.
* 3. Inserts the inbound row in td_stock_<to> with ref_id pointing to from.
* 4. Back-fills ref_id on the from row so both point at each other.
* 5. Releases the source rack, occupies the destination rack.
* 6. Adjusts balance on both warehouses (out from source, in to dest).
*
* Update flow (id > 0):
* - Updates contact_id, description, and log on BOTH rows.
*
* Must be called inside dbTransaction() by the caller.
*
* @param array $data Keys: id, warehouse_from, warehouse_to, product_sku,
* zone_from, aisle_from, rack_from, zone_to, aisle_to, rack_to,
* contact_id, description, lot_number, serial_number.
* @param array $logging Audit entry to append to the log column on both rows.
* @param string $uuid UUID shared by both the from and to rows.
* @throws Exception If source rack validation fails or paired record is missing on update.
*/
public function saveStockTransfer(array $data, array $logging, string $uuid): void
{
@@ -405,6 +508,7 @@ class StockManager {
if ($id > 0) {
// Update: patch metadata on both the from and to rows
$from_warehouse = (int)($data['warehouse_from'] ?? 0);
$to_warehouse = (int)($data['warehouse_to'] ?? 0);
@@ -459,6 +563,7 @@ class StockManager {
return;
}
// Insert: validate source rack, then create paired rows
$from_warehouse = (int)$data['warehouse_from'];
$from_zone = $data['zone_from'];
$from_aisle = $data['aisle_from'];
@@ -494,6 +599,7 @@ class StockManager {
);
}
// Quantity and identifiers come from the source stock_in row (immutable)
$quantity = (int)$source_stock['in'];
$lot_number = $source_stock['lot_number'] ?? null;
$serial_number = $source_stock['serial_number'] ?? null;
@@ -502,6 +608,7 @@ class StockManager {
$to_table = $whMgmt->getStockContext($to_warehouse, 0)['table'];
$table_log = [$logging];
// Insert outbound row (from warehouse)
$this->pdo->prepare(
"INSERT INTO `$from_table`
(uuid, company_id, `date`, product_sku, `out`,
@@ -529,6 +636,7 @@ class StockManager {
]);
$from_stock_id = (int)$this->pdo->lastInsertId();
// Insert inbound row (to warehouse)
$this->pdo->prepare(
"INSERT INTO `$to_table`
(uuid, company_id, `date`, product_sku, `in`,
@@ -557,7 +665,7 @@ class StockManager {
]);
$to_stock_id = (int)$this->pdo->lastInsertId();
// Back-fill ref_id on from row so both rows point at each other
// Back-fill ref_id on the from row so both rows cross-reference each other
$this->pdo->prepare(
"UPDATE `$from_table` SET ref_id = :ref_id
WHERE id = :id AND company_id = :company_id"
@@ -567,12 +675,13 @@ class StockManager {
':company_id' => $this->company_id,
]);
// Rack state: release source, occupy destination
$whMgmt->releaseRack($from_warehouse, $from_zone, $from_aisle, $from_rack);
$whMgmt->occupyRack($to_warehouse, $to_zone, $to_aisle, $to_rack, $data['product_sku'], $to_stock_id);
// Balance: deduct from source, add to destination
$whMgmt->adjustBalance('out', $from_warehouse, $data['product_sku'], 0, $quantity);
$whMgmt->adjustBalance('in', $to_warehouse, $data['product_sku'], 0, $quantity);
}
}
?>
}