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

578 lines
23 KiB
PHP

<?php
/**
* StockManager
*
* Encapsulates read operations for ICS stock transactions:
* - Stock list by type (in / out / transfer)
* - Single record retrieval for stock_in, stock_out, transfer
*
* Write operations (manage_stock_in, manage_stock_out, manage_stock_transfer)
* are handled in their engine files via WarehouseManager.
*/
class StockManager {
private $pdo;
private $company_id;
public function __construct($pdo, $company_id) {
$this->pdo = $pdo;
$this->company_id = $company_id;
}
// ─────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────
/**
* Resolve warehouse name by ID and return the td_stock_<wh> table name.
* Throws if warehouse not found.
*/
private function resolveTable(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.");
}
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
return "td_stock_{$safe}";
}
// ─────────────────────────────────────────────────────────────
// Stock list queries
// ─────────────────────────────────────────────────────────────
/**
* List all stock records of a given type for a warehouse.
* type: 'in' | 'out' | 'transfer'
*/
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)
$extra_cond = ($type === 'transfer') ? 'AND a.out > 0' : '';
$sth = $this->pdo->prepare(
"SELECT a.*, {$column} AS quantity, b.product_name
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);
}
// ─────────────────────────────────────────────────────────────
// Single record retrieval
// ─────────────────────────────────────────────────────────────
/**
* Fetch a single stock_in record with product and contact name,
* plus lot expiry date if applicable.
*/
public function getStockInById(int $warehouse_id, int $id): array|false
{
$table = $this->resolveTable($warehouse_id);
$sth = $this->pdo->prepare(
"SELECT a.*, a.in AS quantity,
b.contact_name,
c.product_name,
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 product and contact name.
*/
public function getStockOutById(int $warehouse_id, int $id): array|false
{
$table = $this->resolveTable($warehouse_id);
$sth = $this->pdo->prepare(
"SELECT a.*, a.out AS quantity,
b.contact_name,
c.product_name
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 row plus its paired
* inbound row resolved via ref_warehouse + uuid.
* Returns the outbound row with a 'ref' key containing the inbound row.
*/
public function getTransferById(int $warehouse_id, int $id): array|false
{
$table = $this->resolveTable($warehouse_id);
// Fetch the outbound (from) row
$sth = $this->pdo->prepare(
"SELECT a.*, a.out AS quantity,
b.contact_name,
c.product_name
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->resolveTable($to_warehouse_id);
$sth = $this->pdo->prepare(
"SELECT a.*, a.in AS quantity,
b.contact_name,
c.product_name
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;
}
// ─────────────────────────────────────────────────────────────
// Stock write operations
// ─────────────────────────────────────────────────────────────
/**
* 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.
* Must be called inside dbTransaction() by the caller.
*/
public function saveStockIn(array $data, array $logging, string $uuid): void
{
$id = (int)$data['id'];
$warehouse_id = (int)$data['warehouse'];
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
$ctx = $whMgmt->getStockContext($warehouse_id, $id);
$row = $ctx['row'] ?? [];
$table = $ctx['table'];
$table_log = json_decode($row['log'] ?? '[]', true) ?: [];
$table_log[] = $logging;
if ($id > 0) {
$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'],
':description' => $data['description'],
':log' => json_encode($table_log),
]);
} else {
$lot_number = $data['lot_number'] ?: null;
$expiry_date = $data['expiry_date'] ?: null;
// Upsert md_lot if lot + expiry provided
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`, zone, aisle, rack,
contact_id, `description`, `log`, `type`, lot_number, serial_number)
VALUES
(:uuid, :company_id, :date, :product_sku, :quantity, :zone, :aisle, :rack,
:contact_id, :description, :log, 'in', :lot_number, :serial_number)"
)->execute([
':uuid' => $uuid,
':company_id' => $this->company_id,
':date' => date('Y-m-d H:i:s'),
':product_sku' => $data['product_sku'],
':quantity' => $data['quantity'],
':zone' => $data['zone'],
':aisle' => $data['aisle'],
':rack' => $data['rack'],
':contact_id' => (int)$data['contact_id'],
':description' => $data['description'],
':log' => json_encode($table_log),
':lot_number' => $lot_number,
':serial_number' => $data['serial_number'] ?: null,
]);
$td_stock_id = (int)$this->pdo->lastInsertId();
$whMgmt->occupyRack(
$warehouse_id,
$data['zone'], $data['aisle'], $data['rack'],
$data['product_sku'],
$td_stock_id
);
$whMgmt->adjustBalance(
'in',
$warehouse_id,
$data['product_sku'],
$row['in'] ?? 0,
$data['quantity']
);
}
}
/**
* 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.
* Must be called inside dbTransaction() by the caller.
*/
public function saveStockOut(array $data, array $logging, string $uuid): void
{
$id = (int)$data['id'];
$warehouse_id = (int)$data['warehouse'];
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
$ctx = $whMgmt->getStockContext($warehouse_id, $id);
$row = $ctx['row'] ?? [];
$table = $ctx['table'];
$table_log = json_decode($row['log'] ?? '[]', true) ?: [];
$table_log[] = $logging;
if ($id > 0) {
$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'],
':description' => $data['description'],
':log' => json_encode($table_log),
]);
} else {
$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 = (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)
VALUES
(:uuid, :company_id, :date, :product_sku, :quantity, :zone, :aisle, :rack,
:contact_id, :description, :log, 'out', :ref_id, :lot_number, :serial_number)"
)->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'],
':description' => $data['description'],
':log' => json_encode($table_log),
':ref_id' => $ref_id,
':lot_number' => $lot_number,
':serial_number' => $serial_number,
]);
$whMgmt->releaseRack(
$warehouse_id,
$data['zone'], $data['aisle'], $data['rack']
);
$whMgmt->adjustBalance(
'out',
$warehouse_id,
$data['product_sku'],
$row['out'] ?? 0,
$quantity
);
}
}
/**
* 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.
* Must be called inside dbTransaction() by the caller.
*/
public function saveStockTransfer(array $data, array $logging, string $uuid): void
{
$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) {
$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.");
}
$from_log = json_decode($from_row['log'] ?? '[]', true) ?: [];
$from_log[] = $logging;
$to_log = json_decode($to_row['log'] ?? '[]', 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;
}
$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 = (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];
$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)
VALUES
(:uuid, :company_id, :date, :product_sku, :quantity,
:ref_warehouse, :zone, :aisle, :rack,
:contact_id, :description, :log, 'transfer', :lot_number, :serial_number)"
)->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();
$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)
VALUES
(:uuid, :company_id, :date, :product_sku, :quantity,
:ref_warehouse, :ref_id, :zone, :aisle, :rack,
:contact_id, :description, :log, 'transfer', :lot_number, :serial_number)"
)->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 from row so both rows point at 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,
]);
$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);
$whMgmt->adjustBalance('out', $from_warehouse, $data['product_sku'], 0, $quantity);
$whMgmt->adjustBalance('in', $to_warehouse, $data['product_sku'], 0, $quantity);
}
}
?>