Files
wms-app/app/assets/utils/classes/StockManager.php
T
Thanakorn f70f226bd1 Fix timestamps, delete requests, invoice dates and GR quantities
Apply the configured timezone to PHP and both DB connections, wrap
unwrapped ajax payloads so delete buttons reach their engines, normalise
and validate invoice due dates, reject stock quantities below the stored
4dp scale, and list stock movements across all warehouses.
2026-09-17 09:00:15 +07:00

993 lines
44 KiB
PHP

<?php
require_once __DIR__ . '/../db_helpers.php';
require_once __DIR__ . '/../notify_node.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 bin 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 {
/**
* Scale of every stock quantity column (`in`, `out`, td_*_item.quantity are
* all decimal(18,4)). Anything finer than this cannot be stored: MySQL
* rounds it on insert, so a quantity of 0.0000001 silently became 0.0000
* and produced a movement of nothing that still left the source document
* "partially received".
*/
public const QTY_SCALE = 4;
/** Smallest quantity the schema can represent — 0.0001. */
public const QTY_MIN = 0.0001;
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.");
}
$sth = $this->pdo->prepare(
"SELECT id FROM md_warehouse
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]);
if (!$sth->fetchColumn()) {
throw new Exception("Warehouse ID {$warehouse_id} not found.");
}
return 'td_stock_' . $warehouse_id;
}
/**
* Round a quantity to the stored scale and reject values that cannot be
* represented.
*
* A positive input that rounds to zero is a mistake worth naming — the
* caller asked to move some stock and would otherwise get a zero-quantity
* movement that looks successful and reports as "0.00" everywhere.
*
* @param mixed $value Raw client input.
* @param string $label Field name used in the error message.
* @return float Quantity rounded to QTY_SCALE.
* @throws Exception When the value is not a usable quantity.
*/
public static function normaliseQuantity($value, string $label = 'Quantity'): float
{
$raw = (float)$value;
if ($raw <= 0) {
throw new Exception("{$label} must be greater than zero.");
}
$rounded = round($raw, self::QTY_SCALE);
if ($rounded < self::QTY_MIN) {
throw new Exception(
"{$label} of {$raw} is smaller than the minimum the system records (" .
rtrim(rtrim(number_format(self::QTY_MIN, self::QTY_SCALE), '0'), '.') . ")."
);
}
return $rounded;
}
private function stockReferenceSql(): string
{
return "CONCAT(DATE_FORMAT(COALESCE(a.`date`, a.updated_at), '%Y%m%d%H%i%s'), '-', LPAD(a.id, 11, '0'))";
}
// ─────────────────────────────────────────────────────────────
// 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, or 0 for every
* warehouse of this company.
* @param string $type Movement type: 'in' | 'out' | 'transfer'.
* @return array Stock rows ordered by date DESC, each with 'quantity',
* 'product_name', 'warehouse_id' and 'warehouse_name'.
*/
public function getStockList(int $warehouse_id, string $type): array
{
// warehouse_id 0 = every warehouse. Stock lives in one table per
// warehouse, so a single-warehouse list hides the rest of a receipt
// that was split across warehouses — a 4-line PO received into two of
// them looked like only 3 lines had been received.
$warehouses = $warehouse_id > 0
? [$warehouse_id]
: $this->warehouseIdsWithStockTable();
// The transfer list shows the outbound row, whose quantity is in `out` (its `in` is always 0).
// Quantity is NOT rounded for display here: rounding to 2 dp reports a
// small-but-real quantity as "0.00", which reads as missing data.
$column = in_array($type, ['out', 'transfer'], true) ? 'a.out' : 'a.in';
$stock_ref = $this->stockReferenceSql();
// Transfer list: show only the outbound side (out > 0) to avoid duplicate display
$extra_cond = ($type === 'transfer') ? 'AND a.out > 0' : '';
$rows = [];
foreach ($warehouses as $wh_id) {
$table = $this->stockTableNameFromWarehouseId($wh_id);
$sth = $this->pdo->prepare(
"SELECT a.*, {$stock_ref} AS stock_reference, {$column} AS quantity,
b.product_name, b.uom,
w.warehouse_name
FROM `{$table}` a
LEFT JOIN md_product b
ON a.company_id = b.company_id
AND a.product_sku = b.sku
LEFT JOIN md_warehouse w
ON w.company_id = a.company_id
AND w.id = :warehouse_id
WHERE a.company_id = :company_id
AND a.type = :type
{$extra_cond}
ORDER BY a.date DESC"
);
$sth->execute([
':company_id' => $this->company_id,
':warehouse_id' => $wh_id,
':type' => $type,
]);
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
// The row's own warehouse, so the list can link each Action
// back to the right td_stock_<id> table when showing them all.
$row['warehouse_id'] = $wh_id;
$rows[] = $row;
}
}
// Re-sort across warehouses — each table was only ordered internally.
usort($rows, fn($x, $y) => strcmp((string)($y['date'] ?? ''), (string)($x['date'] ?? '')));
return $rows;
}
/**
* Warehouse ids of this company that actually have a stock table.
*
* td_stock_<id> tables are created lazily on first use, so a warehouse with
* no movements yet has none and must be skipped rather than queried.
*
* @return int[]
*/
private function warehouseIdsWithStockTable(): array
{
$sth = $this->pdo->prepare(
"SELECT w.id
FROM md_warehouse w
JOIN information_schema.tables t
ON t.table_schema = DATABASE()
AND t.table_name = CONCAT('td_stock_', w.id)
WHERE w.company_id = :company_id
ORDER BY w.id"
);
$sth->execute([':company_id' => $this->company_id]);
return array_map('intval', $sth->fetchAll(PDO::FETCH_COLUMN));
}
/**
* 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);
$stock_ref = $this->stockReferenceSql();
$sth = $this->pdo->prepare(
"SELECT a.*, {$stock_ref} AS stock_reference, 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);
$stock_ref = $this->stockReferenceSql();
$sth = $this->pdo->prepare(
"SELECT a.*, {$stock_ref} AS stock_reference, 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);
$stock_ref = $this->stockReferenceSql();
// Fetch the outbound (from) row
$sth = $this->pdo->prepare(
"SELECT a.*, {$stock_ref} AS stock_reference, 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.*, {$stock_ref} AS stock_reference, 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::occupyBin() to reserve the bin.
* etl_stock_summary is updated later by approveStock().
*
* Update flow (id > 0):
* - Updates contact_id, description, and log only.
* - Quantity, bin, 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, bin,
* 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 = self::normaliseQuantity($quantity);
}
$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, bin,
contact_id, `description`, `log`, `type`, lot_number, serial_number, status, updated_at)
VALUES
(:uuid, :company_id, :date, :product_sku, :quantity, :price, :zone, :aisle, :bin,
: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'],
':bin' => $data['bin'],
':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 bin available for another receipt. Balance still
// changes only when approveStock() runs.
$whMgmt->occupyBin(
$warehouse_id,
$data['zone'],
$data['aisle'],
$data['bin'],
$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 bin is occupied with the correct SKU / lot / serial.
* 2. Inserts the td_stock_<warehouse_id> row, copying quantity and lot info from the bin.
* 3. Calls WarehouseManager::releaseBin() to free the bin slot.
* 4. Calls WarehouseManager::adjustBalance() to update etl_stock_summary.
*
* 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, bin,
* 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 bin 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 bin holds the expected product / lot / serial
$source_stock = $whMgmt->getBinStock(
$warehouse_id,
$data['zone'], $data['aisle'], $data['bin']
);
if (!$source_stock) {
throw new Exception(
"Bin {$data['zone']}-{$data['aisle']}-{$data['bin']} is empty — nothing to take out."
);
}
if ($source_stock['product_sku'] !== $data['product_sku']) {
throw new Exception(
"Bin holds {$source_stock['product_sku']}, not {$data['product_sku']}."
);
}
if (!empty($data['lot_number']) && $source_stock['lot_number'] !== $data['lot_number']) {
throw new Exception(
"Bin 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(
"Bin holds serial '{$source_stock['serial_number']}', not '{$data['serial_number']}'."
);
}
// Quantity and identifiers come from the existing stock_in row (immutable).
// `in` is decimal(18,4) — an int cast would drop fractional quantities.
$quantity = (float)$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, bin,
contact_id, `description`, `log`, `type`, ref_id, lot_number, serial_number, status, updated_at)
VALUES
(:uuid, :company_id, :date, :product_sku, :quantity, :zone, :aisle, :bin,
: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'],
':bin' => $data['bin'],
':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();
// releaseBin 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 bin 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 bin, occupies the destination bin.
* 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, bin_from, zone_to, aisle_to, bin_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 bin 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 bin, then create paired rows
$from_warehouse = (int)$data['warehouse_from'];
$from_zone = $data['zone_from'];
$from_aisle = $data['aisle_from'];
$from_bin = $data['bin_from'];
$to_warehouse = (int)$data['warehouse_to'];
$to_zone = $data['zone_to'];
$to_aisle = $data['aisle_to'];
$to_bin = $data['bin_to'];
$source_stock = $whMgmt->getBinStock(
$from_warehouse, $from_zone, $from_aisle, $from_bin
);
if (!$source_stock) {
throw new Exception("Source bin {$from_zone}-{$from_aisle}-{$from_bin} is empty.");
}
if ($source_stock['product_sku'] !== $data['product_sku']) {
throw new Exception(
"Source bin 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 bin 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 bin holds serial '{$source_stock['serial_number']}', not '{$data['serial_number']}'."
);
}
// Quantity and identifiers come from the source stock_in row (immutable).
// `in` is decimal(18,4) — an int cast would drop fractional quantities.
$quantity = (float)$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, bin,
contact_id, `description`, `log`, `type`, lot_number, serial_number, status, updated_at)
VALUES
(:uuid, :company_id, :date, :product_sku, :quantity,
:ref_warehouse, :zone, :aisle, :bin,
: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,
':bin' => $from_bin,
':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, bin,
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, :bin,
: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,
':bin' => $to_bin,
':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,
]);
// releaseBin, occupyBin and adjustBalance deferred — called from approveStock() only.
return $from_stock_id;
}
// ─────────────────────────────────────────────────────────────
// Approve
// ─────────────────────────────────────────────────────────────
/**
* Approve a draft td_stock row (status 0 → 1) and update etl_stock_summary.
*
* 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]);
// ── Bin state + balance ──────────────────────────────────────────
if ($type === 'in') {
// Occupy bin now that stock is approved
$whMgmt->occupyBin(
$warehouse_id,
$row['zone'], $row['aisle'], $row['bin'],
$row['product_sku'],
$id
);
$whMgmt->adjustBalance('in', $warehouse_id, $row['product_sku'], 0, (float)$row['in'],
$id, $row['source'] ?? '', (int)($row['source_id'] ?? 0), $row['date'] ?? '');
} 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 bin only when the source batch is fully consumed.
if ($remaining_qty <= 0.000001) {
$whMgmt->releaseBin($warehouse_id, $row['zone'], $row['aisle'], $row['bin']);
}
$whMgmt->adjustBalance('out', $warehouse_id, $row['product_sku'], 0, (float)$row['out'],
$id, $row['source'] ?? '', (int)($row['source_id'] ?? 0), $row['date'] ?? '');
} 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]);
}
// Bin state: release source, occupy destination
if ($from_row && $from_wh_id) {
$whMgmt->releaseBin(
$from_wh_id,
$from_row['zone'], $from_row['aisle'], $from_row['bin']
);
$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), $from_row['date'] ?? '');
}
if ($inbound_row && $paired_wh_id) {
$whMgmt->occupyBin(
$paired_wh_id,
$inbound_row['zone'], $inbound_row['aisle'], $inbound_row['bin'],
$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), $inbound_row['date'] ?? '');
}
}
$notify_company_id = $this->company_id;
db_after_commit(function() use ($warehouse_id, $type, $notify_company_id) {
notify_node("stock_updated", ["warehouse_id" => $warehouse_id, "type" => $type], $notify_company_id);
});
}
}