Files
wms-app/app/assets/utils/classes/StockManager.php
T

862 lines
38 KiB
PHP

<?php
/**
* StockManager
*
* Handles read and write operations for ICS stock transactions
* (stock in, stock out, stock transfer).
*
* 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 stock table names are derived only from md_warehouse.id.
*/
class StockManager {
private PDO $pdo;
private int $company_id;
public function __construct(PDO $pdo, int $company_id) {
$this->pdo = $pdo;
$this->company_id = $company_id;
}
// ─────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────
private function stockTableNameFromWarehouseId(int $warehouse_id): string
{
if ($warehouse_id <= 0) {
throw new Exception("Invalid warehouse id.");
}
return 'td_stock_' . $warehouse_id;
}
// ─────────────────────────────────────────────────────────────
// TRANSACTION BASIS — Read
// ─────────────────────────────────────────────────────────────
/**
* 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->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, b.uom
FROM `{$table}` a
LEFT JOIN md_product b
ON a.company_id = b.company_id
AND a.product_sku = b.sku
WHERE a.company_id = :company_id
AND a.type = :type
{$extra_cond}
ORDER BY a.date DESC"
);
$sth->execute([
':company_id' => $this->company_id,
':type' => $type,
]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
/**
* 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_<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->stockTableNameFromWarehouseId($warehouse_id);
$sth = $this->pdo->prepare(
"SELECT a.*, a.in AS quantity,
b.contact_name,
c.product_name, c.uom,
d.expiry_date
FROM `{$table}` a
LEFT JOIN md_contact b
ON a.company_id = b.company_id AND a.contact_id = b.id
LEFT JOIN md_product c
ON a.company_id = c.company_id AND a.product_sku = c.sku
LEFT JOIN md_lot d
ON a.company_id = d.company_id
AND a.product_sku = d.product_sku
AND a.lot_number = d.lot_number
WHERE a.company_id = :company_id AND a.id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
return $sth->fetch(PDO::FETCH_ASSOC);
}
/**
* 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_<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->stockTableNameFromWarehouseId($warehouse_id);
$sth = $this->pdo->prepare(
"SELECT a.*, a.out AS quantity,
b.contact_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
LEFT JOIN md_product c
ON a.company_id = c.company_id AND a.product_sku = c.sku
WHERE a.company_id = :company_id AND a.id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
return $sth->fetch(PDO::FETCH_ASSOC);
}
/**
* 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_<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->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.uom
FROM `{$table}` a
LEFT JOIN md_contact b
ON a.company_id = b.company_id AND a.contact_id = b.id
LEFT JOIN md_product c
ON a.company_id = c.company_id AND a.product_sku = c.sku
WHERE a.company_id = :company_id AND a.id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
$output = $sth->fetch(PDO::FETCH_ASSOC);
if (!$output) return false;
// Resolve the inbound (to) row via ref_warehouse + uuid
$to_warehouse_id = (int)$output['ref_warehouse'];
$to_table = $this->stockTableNameFromWarehouseId($to_warehouse_id);
$sth = $this->pdo->prepare(
"SELECT a.*, a.in AS quantity,
b.contact_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
LEFT JOIN md_product c
ON a.company_id = b.company_id AND a.product_sku = c.sku
WHERE a.company_id = :company_id AND a.uuid = :uuid"
);
$sth->execute([':company_id' => $this->company_id, ':uuid' => $output['uuid']]);
$output['ref'] = $sth->fetch(PDO::FETCH_ASSOC);
return $output;
}
// ─────────────────────────────────────────────────────────────
// TRANSACTION BASIS — Write
// ─────────────────────────────────────────────────────────────
/**
* 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_<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.
* - 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): int
{
$id = (int)($data['id'] ?? 0);
$warehouse_id = (int)($data["warehouse"] ?? 0);
$quantity = (float)($data['quantity'] ?? 0);
if ($id === 0 && $quantity <= 0) {
throw new Exception("Quantity must be greater than zero.");
}
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
$ctx = $whMgmt->getStockContext($warehouse_id, $id);
$row = $ctx['row'] ?? [];
$table = $ctx['table'];
$raw_log = $row['log'] ?? [];
$table_log = is_array($raw_log) ? $raw_log : (json_decode($raw_log, true) ?: []);
$table_log[] = $logging;
if ($id > 0) {
if ((int)($row['status'] ?? 0) === 1) {
$whMgmt->assertStockMovementWindow($row['date'] ?? null, 'Stock-in edit');
}
// Update: only metadata fields are editable after creation
$this->pdo->prepare(
"UPDATE `$table` SET
`contact_id` = :contact_id,
`description` = :description,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':id' => $id,
':company_id' => $this->company_id,
':contact_id' => (int)($data['contact_id'] ?? 0),
':description' => $data['description'] ?? '',
':log' => json_encode($table_log),
]);
return 0; // update — no new row
} else {
$lot_number = trim((string)($data["lot_number"] ?? ""));
$expiry_date = trim((string)($data["expiry_date"] ?? ""));
// 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)
VALUES (:company_id, :product_sku, :lot_number, :expiry_date)
ON DUPLICATE KEY UPDATE expiry_date = expiry_date"
)->execute([
':company_id' => $this->company_id,
':product_sku' => $data['product_sku'],
':lot_number' => $lot_number,
':expiry_date' => $expiry_date,
]);
}
$this->pdo->prepare(
"INSERT INTO `$table`
(uuid, company_id, `date`, product_sku, `in`, price, zone, aisle, rack,
contact_id, `description`, `log`, `type`, lot_number, serial_number, status, updated_at)
VALUES
(:uuid, :company_id, :date, :product_sku, :quantity, :price, :zone, :aisle, :rack,
:contact_id, :description, :log, 'in', :lot_number, :serial_number, 0, NOW())"
)->execute([
':uuid' => $uuid,
':company_id' => $this->company_id,
':date' => date('Y-m-d H:i:s'),
':product_sku' => $data['product_sku'],
':quantity' => $quantity,
':price' => (float)($data['price'] ?? 0),
':zone' => $data['zone'],
':aisle' => $data['aisle'],
':rack' => $data['rack'],
':contact_id' => (int)($data['contact_id'] ?? 0),
':description' => $data['description'] ?? '',
':log' => json_encode($table_log),
':lot_number' => $lot_number,
':serial_number' => trim((string)($data["serial_number"] ?? "")),
]);
$td_stock_id = (int)$this->pdo->lastInsertId();
// 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;
}
}
/**
* 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_<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.
*
* 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): int
{
$id = (int)($data['id'] ?? 0);
$warehouse_id = (int)($data['warehouse'] ?? 0);
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
$ctx = $whMgmt->getStockContext($warehouse_id, $id);
$row = $ctx['row'] ?? [];
$table = $ctx['table'];
$raw_log = $row['log'] ?? [];
$table_log = is_array($raw_log) ? $raw_log : (json_decode($raw_log, true) ?: []);
$table_log[] = $logging;
if ($id > 0) {
if ((int)($row['status'] ?? 0) === 1) {
$whMgmt->assertStockMovementWindow($row['date'] ?? null, 'Stock-out edit');
}
// Update: only metadata fields are editable after creation
$this->pdo->prepare(
"UPDATE `$table` SET
`contact_id` = :contact_id,
`description` = :description,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':id' => $id,
':company_id' => $this->company_id,
':contact_id' => (int)($data['contact_id'] ?? 0),
':description' => $data['description'] ?? '',
':log' => json_encode($table_log),
]);
return 0; // update — no new row
} else {
// Validate rack holds the expected product / lot / serial
$source_stock = $whMgmt->getRackStock(
$warehouse_id,
$data['zone'], $data['aisle'], $data['rack']
);
if (!$source_stock) {
throw new Exception(
"Rack {$data['zone']}-{$data['aisle']}-{$data['rack']} is empty — nothing to take out."
);
}
if ($source_stock['product_sku'] !== $data['product_sku']) {
throw new Exception(
"Rack holds {$source_stock['product_sku']}, not {$data['product_sku']}."
);
}
if (!empty($data['lot_number']) && $source_stock['lot_number'] !== $data['lot_number']) {
throw new Exception(
"Rack holds lot '{$source_stock['lot_number']}', not '{$data['lot_number']}'."
);
}
if (!empty($data['serial_number']) && $source_stock['serial_number'] !== $data['serial_number']) {
throw new Exception(
"Rack holds serial '{$source_stock['serial_number']}', not '{$data['serial_number']}'."
);
}
// 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;
$serial_number = $source_stock['serial_number'] ?? null;
$this->pdo->prepare(
"INSERT INTO `$table`
(uuid, company_id, `date`, product_sku, `out`, zone, aisle, rack,
contact_id, `description`, `log`, `type`, ref_id, lot_number, serial_number, status, updated_at)
VALUES
(:uuid, :company_id, :date, :product_sku, :quantity, :zone, :aisle, :rack,
:contact_id, :description, :log, 'out', :ref_id, :lot_number, :serial_number, 0, NOW())"
)->execute([
':uuid' => $uuid,
':company_id' => $this->company_id,
':date' => date('Y-m-d H:i:s'),
':product_sku' => $data['product_sku'],
':quantity' => $quantity,
':zone' => $data['zone'],
':aisle' => $data['aisle'],
':rack' => $data['rack'],
':contact_id' => (int)($data['contact_id'] ?? 0),
':description' => $data['description'] ?? '',
':log' => json_encode($table_log),
':ref_id' => $ref_id,
':lot_number' => $lot_number,
':serial_number' => $serial_number,
]);
$out_stock_id = (int)$this->pdo->lastInsertId();
// releaseRack and adjustBalance deferred — called from approveStock() only.
return $out_stock_id;
}
}
/**
* 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): int
{
$id = (int)($data['id'] ?? 0);
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
$contact_id = (int)($data['contact_id'] ?? 0);
$description = ($data['description'] ?? '' ?? '');
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);
$from_ctx = $whMgmt->getStockContext($from_warehouse, $id);
$from_row = $from_ctx['row'];
if (!$from_row) {
throw new Exception("Transfer record not found.");
}
$to_id = (int)$from_row['ref_id'];
$to_ctx = $whMgmt->getStockContext($to_warehouse, $to_id);
$to_row = $to_ctx['row'];
if (!$to_row) {
throw new Exception("Paired destination record missing — data integrity issue.");
}
if ((int)($from_row['status'] ?? 0) === 1 || (int)($to_row['status'] ?? 0) === 1) {
$whMgmt->assertStockMovementWindow($from_row['date'] ?? null, 'Stock transfer edit');
$whMgmt->assertStockMovementWindow($to_row['date'] ?? null, 'Stock transfer edit');
}
$raw_from = $from_row['log'] ?? [];
$from_log = is_array($raw_from) ? $raw_from : (json_decode($raw_from, true) ?: []);
$from_log[] = $logging;
$raw_to = $to_row['log'] ?? [];
$to_log = is_array($raw_to) ? $raw_to : (json_decode($raw_to, true) ?: []);
$to_log[] = $logging;
$this->pdo->prepare(
"UPDATE `{$from_ctx['table']}` SET
contact_id = :contact_id,
description = :description,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':contact_id' => $contact_id,
':description' => $description,
':log' => json_encode($from_log),
':id' => $id,
':company_id' => $this->company_id,
]);
$this->pdo->prepare(
"UPDATE `{$to_ctx['table']}` SET
contact_id = :contact_id,
description = :description,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':contact_id' => $contact_id,
':description' => $description,
':log' => json_encode($to_log),
':id' => $to_id,
':company_id' => $this->company_id,
]);
return 0; // update — no new row
}
// Insert: validate source rack, then create paired rows
$from_warehouse = (int)$data['warehouse_from'];
$from_zone = $data['zone_from'];
$from_aisle = $data['aisle_from'];
$from_rack = $data['rack_from'];
$to_warehouse = (int)$data['warehouse_to'];
$to_zone = $data['zone_to'];
$to_aisle = $data['aisle_to'];
$to_rack = $data['rack_to'];
$source_stock = $whMgmt->getRackStock(
$from_warehouse, $from_zone, $from_aisle, $from_rack
);
if (!$source_stock) {
throw new Exception("Source rack {$from_zone}-{$from_aisle}-{$from_rack} is empty.");
}
if ($source_stock['product_sku'] !== $data['product_sku']) {
throw new Exception(
"Source rack holds {$source_stock['product_sku']}, not {$data['product_sku']}."
);
}
if (!empty($data['lot_number']) && $source_stock['lot_number'] !== $data['lot_number']) {
throw new Exception(
"Source rack holds lot '{$source_stock['lot_number']}', not '{$data['lot_number']}'."
);
}
if (!empty($data['serial_number']) && $source_stock['serial_number'] !== $data['serial_number']) {
throw new Exception(
"Source rack holds serial '{$source_stock['serial_number']}', not '{$data['serial_number']}'."
);
}
// 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;
$from_table = $whMgmt->getStockContext($from_warehouse, 0)['table'];
$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`,
ref_warehouse, zone, aisle, rack,
contact_id, `description`, `log`, `type`, lot_number, serial_number, status, updated_at)
VALUES
(:uuid, :company_id, :date, :product_sku, :quantity,
:ref_warehouse, :zone, :aisle, :rack,
:contact_id, :description, :log, 'transfer', :lot_number, :serial_number, 0, NOW())"
)->execute([
':uuid' => $uuid,
':company_id' => $this->company_id,
':date' => date('Y-m-d H:i:s'),
':product_sku' => $data['product_sku'],
':quantity' => $quantity,
':ref_warehouse' => $to_warehouse,
':zone' => $from_zone,
':aisle' => $from_aisle,
':rack' => $from_rack,
':contact_id' => $contact_id,
':description' => $description,
':log' => json_encode($table_log),
':lot_number' => $lot_number,
':serial_number' => $serial_number,
]);
$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`,
ref_warehouse, ref_id, zone, aisle, rack,
contact_id, `description`, `log`, `type`, lot_number, serial_number, status, updated_at)
VALUES
(:uuid, :company_id, :date, :product_sku, :quantity,
:ref_warehouse, :ref_id, :zone, :aisle, :rack,
:contact_id, :description, :log, 'transfer', :lot_number, :serial_number, 0, NOW())"
)->execute([
':uuid' => $uuid,
':company_id' => $this->company_id,
':date' => date('Y-m-d H:i:s'),
':product_sku' => $data['product_sku'],
':quantity' => $quantity,
':ref_warehouse' => $from_warehouse,
':ref_id' => $from_stock_id,
':zone' => $to_zone,
':aisle' => $to_aisle,
':rack' => $to_rack,
':contact_id' => $contact_id,
':description' => $description,
':log' => json_encode($table_log),
':lot_number' => $lot_number,
':serial_number' => $serial_number,
]);
$to_stock_id = (int)$this->pdo->lastInsertId();
// 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"
)->execute([
':ref_id' => $to_stock_id,
':id' => $from_stock_id,
':company_id' => $this->company_id,
]);
// releaseRack, occupyRack and adjustBalance deferred — called from approveStock() only.
return $from_stock_id;
}
// ─────────────────────────────────────────────────────────────
// Approve
// ─────────────────────────────────────────────────────────────
/**
* Approve a draft td_stock row (status 0 → 1) and update warehouse_balance.
*
* This is the single entry point for balance updates — both auto-approve
* (called immediately after save) and manual approve go through here.
* adjustBalance() is never called from anywhere else.
*
* For transfer rows, both the outbound (from) and inbound (to) rows share
* the same uuid. We approve both in one call so the pair is always consistent.
*
* @param int $id The td_stock row id.
* @param int $warehouse_id The warehouse the row belongs to.
* @param string $type 'in' | 'out' | 'transfer'
* @param WarehouseManager $whMgmt Injected to keep balance logic centralised.
* @throws Exception If the row is not found, already approved, or wrong company.
*/
public function approveStock(int $id, int $warehouse_id, string $type, WarehouseManager $whMgmt): void
{
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
// Load the row and validate ownership / status
$sth = $this->pdo->prepare(
"SELECT * FROM `{$table}`
WHERE id = :id AND company_id = :company_id
LIMIT 1"
);
$sth->execute([':id' => $id, ':company_id' => $this->company_id]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) {
throw new Exception('Stock record not found.');
}
if ((int)$row['status'] === 1) {
throw new Exception('Already approved.');
}
$whMgmt->assertStockMovementWindow($row['date'] ?? null, 'Stock approval');
// ── Approve this row ──────────────────────────────────────────────
$this->pdo->prepare(
"UPDATE `{$table}` SET status = 1, updated_at = NOW() WHERE id = :id AND company_id = :company_id"
)->execute([':id' => $id, ':company_id' => $this->company_id]);
// ── Rack state + balance ──────────────────────────────────────────
if ($type === 'in') {
// Occupy rack now that stock is approved
$whMgmt->occupyRack(
$warehouse_id,
$row['zone'], $row['aisle'], $row['rack'],
$row['product_sku'],
$id
);
$whMgmt->adjustBalance('in', $warehouse_id, $row['product_sku'], 0, (float)$row['in'],
$id, $row['source'] ?? '', (int)($row['source_id'] ?? 0));
} elseif ($type === 'out') {
$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'],
$id, $row['source'] ?? '', (int)($row['source_id'] ?? 0));
} elseif ($type === 'transfer') {
// Transfer always has two rows in two different tables:
// 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_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 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) {
$from_table = $paired_table;
$from_wh_id = $paired_wh_id;
$paired_table = $table;
$paired_wh_id = $warehouse_id;
}
// Load the inbound row from the paired table using uuid
$sth = $this->pdo->prepare(
"SELECT * FROM `{$paired_table}`
WHERE uuid = :uuid AND company_id = :company_id
AND `in` > 0
LIMIT 1"
);
$sth->execute([':uuid' => $row['uuid'], ':company_id' => $this->company_id]);
$inbound_row = $sth->fetch(PDO::FETCH_ASSOC);
// Load outbound row if we were given the inbound side
if (!$is_outbound) {
$sth = $this->pdo->prepare(
"SELECT * FROM `{$from_table}`
WHERE uuid = :uuid AND company_id = :company_id
AND `out` > 0
LIMIT 1"
);
$sth->execute([':uuid' => $row['uuid'], ':company_id' => $this->company_id]);
$from_row = $sth->fetch(PDO::FETCH_ASSOC);
} else {
$from_row = $row;
}
if ($from_row) {
$whMgmt->assertStockMovementWindow($from_row['date'] ?? null, 'Stock transfer approval');
}
if ($inbound_row) {
$whMgmt->assertStockMovementWindow($inbound_row['date'] ?? null, 'Stock transfer approval');
}
// Approve outbound row
if ($from_row && (int)$from_row['status'] === 0) {
$this->pdo->prepare(
"UPDATE `{$from_table}` SET status = 1, updated_at = NOW()
WHERE id = :id AND company_id = :company_id"
)->execute([':id' => $from_row['id'], ':company_id' => $this->company_id]);
}
// Approve inbound row
if ($inbound_row && (int)$inbound_row['status'] === 0) {
$this->pdo->prepare(
"UPDATE `{$paired_table}` SET status = 1, updated_at = NOW()
WHERE id = :id AND company_id = :company_id"
)->execute([':id' => $inbound_row['id'], ':company_id' => $this->company_id]);
}
// Rack state: release source, occupy destination
if ($from_row && $from_wh_id) {
$whMgmt->releaseRack(
$from_wh_id,
$from_row['zone'], $from_row['aisle'], $from_row['rack']
);
$whMgmt->adjustBalance('out', $from_wh_id, $from_row['product_sku'], 0, (float)$from_row['out'],
(int)$from_row['id'], $from_row['source'] ?? '', (int)($from_row['source_id'] ?? 0));
}
if ($inbound_row && $paired_wh_id) {
$whMgmt->occupyRack(
$paired_wh_id,
$inbound_row['zone'], $inbound_row['aisle'], $inbound_row['rack'],
$inbound_row['product_sku'],
$inbound_row['id']
);
$whMgmt->adjustBalance('in', $paired_wh_id, $inbound_row['product_sku'], 0, (float)$inbound_row['in'],
(int)$inbound_row['id'], $inbound_row['source'] ?? '', (int)($inbound_row['source_id'] ?? 0));
}
}
}
}