687 lines
30 KiB
PHP
687 lines
30 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 table names (td_stock_<warehouse>) are derived from DB-sourced warehouse
|
|
* names sanitised with preg_replace('/[^a-zA-Z0-9_]/', '', ...) before interpolation.
|
|
*/
|
|
class StockManager {
|
|
|
|
private $pdo;
|
|
private $company_id;
|
|
|
|
public function __construct($pdo, $company_id) {
|
|
$this->pdo = $pdo;
|
|
$this->company_id = $company_id;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// Private helpers
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Resolve the dynamic td_stock_<warehouse> table name for a warehouse_id.
|
|
*
|
|
* Looks up warehouse_name from md_warehouse (no status filter — unlike
|
|
* WarehouseManager::resolveWarehouseTable, this serves read flows that may
|
|
* need to access inactive warehouses for historical record retrieval).
|
|
* The name is sanitised with preg_replace before being used as a table
|
|
* identifier, preventing SQL injection via malicious warehouse names.
|
|
*
|
|
* @param int $warehouse_id The md_warehouse.id to resolve.
|
|
* @return string The sanitised table name, e.g. "td_stock_Main".
|
|
* @throws Exception If no warehouse is found for the given ID.
|
|
*/
|
|
private function resolveTable(int $warehouse_id): string
|
|
{
|
|
$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}";
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// 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->resolveTable($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
|
|
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_<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
|
|
{
|
|
$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 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
|
|
{
|
|
$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 (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
|
|
{
|
|
$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;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// 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_<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
|
|
{
|
|
$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) {
|
|
|
|
// 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'],
|
|
':description' => $data['description'],
|
|
':log' => json_encode($table_log),
|
|
]);
|
|
|
|
} else {
|
|
|
|
$lot_number = $data['lot_number'] ?: null;
|
|
$expiry_date = $data['expiry_date'] ?: null;
|
|
|
|
// 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`, 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();
|
|
|
|
// Mark rack as occupied and link it to this stock row
|
|
$whMgmt->occupyRack(
|
|
$warehouse_id,
|
|
$data['zone'], $data['aisle'], $data['rack'],
|
|
$data['product_sku'],
|
|
$td_stock_id
|
|
);
|
|
|
|
// Update running balance (+quantity in this warehouse)
|
|
$whMgmt->adjustBalance(
|
|
'in',
|
|
$warehouse_id,
|
|
$data['product_sku'],
|
|
$row['in'] ?? 0,
|
|
$data['quantity']
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
{
|
|
$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) {
|
|
|
|
// 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'],
|
|
':description' => $data['description'],
|
|
':log' => json_encode($table_log),
|
|
]);
|
|
|
|
} 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)
|
|
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,
|
|
]);
|
|
|
|
// Release the source rack and reverse balance
|
|
$whMgmt->releaseRack(
|
|
$warehouse_id,
|
|
$data['zone'], $data['aisle'], $data['rack']
|
|
);
|
|
|
|
$whMgmt->adjustBalance(
|
|
'out',
|
|
$warehouse_id,
|
|
$data['product_sku'],
|
|
$row['out'] ?? 0,
|
|
$quantity
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
{
|
|
$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.");
|
|
}
|
|
|
|
$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;
|
|
}
|
|
|
|
// 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)
|
|
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();
|
|
|
|
// 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)
|
|
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 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,
|
|
]);
|
|
|
|
// 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);
|
|
}
|
|
|
|
} |