2502 lines
98 KiB
PHP
2502 lines
98 KiB
PHP
<?php
|
||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||
|
||
/**
|
||
* WarehouseManager
|
||
*
|
||
* Handles all warehouse, storage, bin, lot/serial, and stock deletion operations.
|
||
*
|
||
* Method order:
|
||
* Master file basis → Warehouse (get/save/delete), Storage (get/save/delete),
|
||
* Zone/Aisle/Bin resolution (getAll variants for admin views),
|
||
* Lot/Serial queries
|
||
* Transaction basis → Stock context resolution, bin lifecycle (occupy/release/transfer),
|
||
* balance adjustment, delete operations (stock in/out/transfer)
|
||
* Report basis → Warehouse list queries (getWarehouseList, getWarehouseListAll)
|
||
*
|
||
* 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 WarehouseManager {
|
||
|
||
private PDO $pdo;
|
||
private int $company_id;
|
||
|
||
public function __construct(PDO $pdo, int $company_id, $logging = null) {
|
||
$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;
|
||
}
|
||
|
||
public function assertStockMovementWindow(?string $date, string $context = 'Stock movement'): void
|
||
{
|
||
global $pdo1;
|
||
|
||
if (!isset($pdo1) || !($pdo1 instanceof PDO)) {
|
||
throw new Exception("Posting-window validation is unavailable.");
|
||
}
|
||
|
||
$guard = new PostingWindowGuard($pdo1, $this->company_id);
|
||
$guard->assertOpenDate($date ?: date('Y-m-d'), $context);
|
||
}
|
||
|
||
private function resolveWarehouseTable(int $warehouse_id): ?string
|
||
{
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT id FROM md_warehouse
|
||
WHERE company_id = :company_id AND id = :id AND status = 1"
|
||
);
|
||
$sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]);
|
||
$id = $sth->fetchColumn();
|
||
if (!$id) return null;
|
||
|
||
return $this->stockTableNameFromWarehouseId((int)$id);
|
||
}
|
||
|
||
/**
|
||
* Convert a from/to range string pair into a flat array of string values.
|
||
*
|
||
* Supports two range types:
|
||
* - Numeric: "1" → "10" expands to ["1", "2", ..., "10"]
|
||
* - Single-letter alpha: "A" → "D" expands to ["A", "B", "C", "D"]
|
||
*
|
||
* Used when syncing md_bin rows against an md_storage aisle/bin range.
|
||
*
|
||
* @param string $from Start of range (e.g. "1" or "A").
|
||
* @param string $to End of range (e.g. "10" or "Z").
|
||
* @return array Flat array of string values in range (inclusive).
|
||
* @throws Exception If values are not pure numeric or single alpha characters,
|
||
* or if start > end.
|
||
*/
|
||
private function rangeToArray(string $from, string $to): array
|
||
{
|
||
$from = trim($from);
|
||
$to = trim($to);
|
||
|
||
// Same value — single location, any format allowed (e.g. "Shelf-A", "Bin-3")
|
||
if ($from === $to) {
|
||
return [$from];
|
||
}
|
||
|
||
// Numeric range e.g. 1 → 10
|
||
if (is_numeric($from) && is_numeric($to)) {
|
||
$f = (int) $from;
|
||
$t = (int) $to;
|
||
if ($f > $t) throw new Exception("Range start ($from) must be <= end ($to)");
|
||
return array_map('strval', range($f, $t));
|
||
}
|
||
|
||
// Single-letter alpha range e.g. A → Z
|
||
if (ctype_alpha($from) && ctype_alpha($to)
|
||
&& strlen($from) === 1 && strlen($to) === 1) {
|
||
if (ord(strtoupper($from)) > ord(strtoupper($to))) {
|
||
throw new Exception("Range start ($from) must be <= end ($to)");
|
||
}
|
||
return range(strtoupper($from), strtoupper($to));
|
||
}
|
||
|
||
// Prefix + suffix range e.g. Shelf-A → Shelf-E or Bin-1 → Bin-5
|
||
// Split on the last sequence of digits or single letter at the end
|
||
$pattern = '/^(.*?)([A-Za-z]|[0-9]+)$/';
|
||
if (preg_match($pattern, $from, $mf) && preg_match($pattern, $to, $mt)) {
|
||
$prefix_f = $mf[1];
|
||
$prefix_t = $mt[1];
|
||
$suffix_f = $mf[2];
|
||
$suffix_t = $mt[2];
|
||
|
||
// Prefixes must match
|
||
if ($prefix_f !== $prefix_t) {
|
||
throw new Exception(
|
||
"Location prefix mismatch: '$prefix_f' vs '$prefix_t'. " .
|
||
"From and To must share the same prefix (e.g. Shelf-A → Shelf-E)."
|
||
);
|
||
}
|
||
|
||
$prefix = $prefix_f;
|
||
|
||
// Suffix: numeric range
|
||
if (is_numeric($suffix_f) && is_numeric($suffix_t)) {
|
||
$f = (int) $suffix_f;
|
||
$t = (int) $suffix_t;
|
||
if ($f > $t) throw new Exception("Range start ($from) must be <= end ($to)");
|
||
return array_map(fn($n) => $prefix . $n, range($f, $t));
|
||
}
|
||
|
||
// Suffix: single-letter range
|
||
if (ctype_alpha($suffix_f) && ctype_alpha($suffix_t)
|
||
&& strlen($suffix_f) === 1 && strlen($suffix_t) === 1) {
|
||
$sf = strtoupper($suffix_f);
|
||
$st = strtoupper($suffix_t);
|
||
if (ord($sf) > ord($st)) {
|
||
throw new Exception("Range start ($from) must be <= end ($to)");
|
||
}
|
||
return array_map(fn($c) => $prefix . $c, range($sf, $st));
|
||
}
|
||
|
||
throw new Exception(
|
||
"Location suffix must be a number (e.g. 1–99) or single letter (e.g. A–Z). " .
|
||
"Got: '$suffix_f' → '$suffix_t'"
|
||
);
|
||
}
|
||
|
||
throw new Exception(
|
||
"Invalid location range: '$from' → '$to'. " .
|
||
"Use formats like: 1–10, A–Z, Shelf-1–Shelf-10, or Shelf-A–Shelf-Z."
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Build the WHERE conditions and bound params for lot/serial filtering
|
||
* used across getZonesOut, getAislesOut, getBinsOut.
|
||
*
|
||
* Conditionally appends lot_number and/or serial_number filters only
|
||
* when those values are non-empty, preventing unnecessary join conditions.
|
||
*
|
||
* @param int $warehouse_id Warehouse to scope the query to.
|
||
* @param string $product_sku SKU being queried.
|
||
* @param string|null $lot_number Optional lot filter.
|
||
* @param string|null $serial_number Optional serial filter.
|
||
* @return array Three-element array: [$lot_cond string, $serial_cond string, $params array].
|
||
*/
|
||
private function buildLotSerialCondition(
|
||
int $warehouse_id,
|
||
string $product_sku,
|
||
?string $lot_number = null,
|
||
?string $serial_number = null
|
||
): array {
|
||
$lot_cond = !empty($lot_number) ? "AND s.lot_number = :lot_number" : "";
|
||
$serial_cond = !empty($serial_number) ? "AND s.serial_number = :serial_number" : "";
|
||
|
||
$params = [
|
||
':company_id' => $this->company_id,
|
||
':warehouse' => $warehouse_id,
|
||
':product_sku' => $product_sku,
|
||
':company_id2' => $this->company_id,
|
||
':product_sku2' => $product_sku,
|
||
];
|
||
if (!empty($lot_number)) $params[':lot_number'] = $lot_number;
|
||
if (!empty($serial_number)) $params[':serial_number'] = $serial_number;
|
||
|
||
return [$lot_cond, $serial_cond, $params];
|
||
}
|
||
|
||
/**
|
||
* Build a standard audit log entry array for append-to-JSON log columns.
|
||
*
|
||
* Captures the acting user_id, current datetime, session login time,
|
||
* and a short action label (e.g. 'delete', 'update').
|
||
*
|
||
* Usage:
|
||
* $log[] = $this->buildLogEntry('delete');
|
||
* $params[':log'] = json_encode($log);
|
||
*
|
||
* @param string $action Short label describing the operation.
|
||
* @return array Associative array ready to be appended to a log array.
|
||
*/
|
||
private function buildLogEntry(string $action): array {
|
||
return [
|
||
'user_id' => $_SESSION['login_user_id'] ?? null,
|
||
'dt' => date('Y-m-d H:i:s'),
|
||
'login' => isset($_SESSION['otpTime'])
|
||
? date('Y-m-d H:i:s', $_SESSION['otpTime'])
|
||
: null,
|
||
'action' => $action,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Resolve a product's display name from md_product for use in error messages.
|
||
*
|
||
* Falls back to the SKU itself if the product record is not found.
|
||
*
|
||
* @param string $product_sku The SKU to look up.
|
||
* @return string The product_name, or $product_sku if not found.
|
||
*/
|
||
private function getProductName(string $product_sku): string {
|
||
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT product_name FROM md_product
|
||
WHERE company_id = :company_id AND sku = :sku"
|
||
);
|
||
$sth->execute([
|
||
':company_id' => $this->company_id,
|
||
':sku' => $product_sku,
|
||
]);
|
||
return $sth->fetchColumn() ?: $product_sku;
|
||
}
|
||
|
||
/**
|
||
* Validate that the given row is the globally latest transaction for its SKU.
|
||
*
|
||
* Scans this company's td_stock_* tables via UNION ALL to find the single most recent
|
||
* transaction date across all warehouses for the SKU. If a newer row exists,
|
||
* deletion is blocked to enforce LIFO (last-in-first-out) reversal order.
|
||
*
|
||
* Table names are sourced from information_schema and backtick-quoted;
|
||
* no user input reaches the identifier.
|
||
*
|
||
* @param string $product_sku SKU of the row being deleted.
|
||
* @param string $row_date The 'date' column value of the row being deleted.
|
||
* @param string $product_name Human-readable name for the error message.
|
||
* @throws Exception If a newer transaction for the same SKU exists anywhere.
|
||
*/
|
||
private function validateLatestTransaction(
|
||
string $product_sku,
|
||
string $row_date,
|
||
string $product_name
|
||
): void {
|
||
|
||
// Discover stock tables only for warehouses owned by this company.
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT t.table_name
|
||
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"
|
||
);
|
||
$sth->execute([':company_id' => $this->company_id]);
|
||
$tables = $sth->fetchAll(PDO::FETCH_COLUMN);
|
||
|
||
if (empty($tables)) {
|
||
return;
|
||
}
|
||
|
||
// Build UNION across this company's td_stock_* tables to find the global latest date
|
||
$unions = implode(' UNION ALL ', array_map(
|
||
fn($t) => "SELECT `type`, `date` FROM `$t`
|
||
WHERE company_id = :company_id
|
||
AND product_sku = :product_sku
|
||
AND status = 1",
|
||
$tables
|
||
));
|
||
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT `type`, `date` FROM ($unions) AS all_stock
|
||
ORDER BY `date` DESC
|
||
LIMIT 1"
|
||
);
|
||
$sth->execute([
|
||
':company_id' => $this->company_id,
|
||
':product_sku' => $product_sku,
|
||
]);
|
||
$latest = $sth->fetch(PDO::FETCH_ASSOC);
|
||
|
||
if (!$latest || $row_date === $latest['date']) {
|
||
return; // This is the latest globally — allow deletion
|
||
}
|
||
|
||
$type = strtoupper($latest['type']);
|
||
$date = $latest['date'];
|
||
|
||
throw new Exception(
|
||
"Cannot delete — \"{$product_name}\" has a newer " .
|
||
"{$type} transaction on {$date} that must be deleted first."
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Insert a bin log entry for audit tracking of occupy/release/transfer events.
|
||
*
|
||
* Called internally by occupyBin, releaseBin, and transferBin.
|
||
* $extra may contain 'product_sku' and 'td_stock_id' to record what
|
||
* was in the bin at the time of the action.
|
||
*
|
||
* @param int $bin_id The md_bin.id being acted on.
|
||
* @param string $action Event label: 'occupy' | 'release' | 'transfer_in' | 'transfer_out'.
|
||
* @param array $extra Optional keys: product_sku, td_stock_id.
|
||
*/
|
||
private function insertBinLog(int $bin_id, string $action, array $extra = []): void {
|
||
|
||
$sql = "INSERT INTO td_bin_log
|
||
(company_id, md_bin_id, user_id, dt, login, action, product_sku, td_stock_id)
|
||
VALUES
|
||
(:company_id, :md_bin_id, :user_id, :dt, :login, :action, :product_sku, :td_stock_id)";
|
||
|
||
$sth = $this->pdo->prepare($sql);
|
||
$sth->execute([
|
||
':company_id' => $this->company_id,
|
||
':md_bin_id' => $bin_id,
|
||
':user_id' => $_SESSION['login_user_id'] ?? null,
|
||
':dt' => date('Y-m-d H:i:s'),
|
||
':login' => isset($_SESSION['otpTime'])
|
||
? date('Y-m-d H:i:s', $_SESSION['otpTime'])
|
||
: null,
|
||
':action' => $action,
|
||
':product_sku' => $extra['product_sku'] ?? null,
|
||
':td_stock_id' => $extra['td_stock_id'] ?? null,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Soft-delete bin logs created by a stock movement and its reversal.
|
||
*
|
||
* td_bin_log follows the same soft-delete convention as other tenant rows:
|
||
* negate company_id so normal company_id filters hide the records.
|
||
*/
|
||
private function softDeleteBinLogs(
|
||
$warehouse_id,
|
||
string $zone,
|
||
string $aisle,
|
||
string $bin,
|
||
array $actions,
|
||
string $product_sku,
|
||
array $td_stock_ids = [],
|
||
?string $since = null
|
||
): void {
|
||
if (empty($actions)) {
|
||
return;
|
||
}
|
||
|
||
$bin_sth = $this->pdo->prepare(
|
||
"SELECT id FROM md_bin
|
||
WHERE company_id = :company_id
|
||
AND warehouse = :warehouse
|
||
AND zone = :zone
|
||
AND aisle = :aisle
|
||
AND bin = :bin
|
||
LIMIT 1"
|
||
);
|
||
$bin_sth->execute([
|
||
':company_id' => $this->company_id,
|
||
':warehouse' => $warehouse_id,
|
||
':zone' => $zone,
|
||
':aisle' => $aisle,
|
||
':bin' => $bin,
|
||
]);
|
||
$bin_id = (int)$bin_sth->fetchColumn();
|
||
if (!$bin_id) {
|
||
return;
|
||
}
|
||
|
||
$params = [
|
||
':company_id' => $this->company_id,
|
||
':md_bin_id' => $bin_id,
|
||
':product_sku' => $product_sku,
|
||
];
|
||
|
||
$action_placeholders = [];
|
||
foreach (array_values($actions) as $idx => $action) {
|
||
$key = ':action_' . $idx;
|
||
$action_placeholders[] = $key;
|
||
$params[$key] = $action;
|
||
}
|
||
|
||
$td_stock_ids = array_values(array_filter(array_map('intval', $td_stock_ids)));
|
||
$td_stock_cond = '';
|
||
if (!empty($td_stock_ids)) {
|
||
$td_placeholders = [];
|
||
foreach ($td_stock_ids as $idx => $td_stock_id) {
|
||
$key = ':td_stock_id_' . $idx;
|
||
$td_placeholders[] = $key;
|
||
$params[$key] = $td_stock_id;
|
||
}
|
||
$td_stock_cond = 'AND td_stock_id IN (' . implode(', ', $td_placeholders) . ')';
|
||
}
|
||
|
||
$since_cond = '';
|
||
if ($since !== null && $since !== '') {
|
||
$since_cond = 'AND dt >= :since';
|
||
$params[':since'] = $since;
|
||
}
|
||
|
||
$sql = "UPDATE td_bin_log
|
||
SET company_id = company_id * -1
|
||
WHERE company_id = :company_id
|
||
AND md_bin_id = :md_bin_id
|
||
AND product_sku = :product_sku
|
||
AND action IN (" . implode(', ', $action_placeholders) . ")
|
||
{$td_stock_cond}
|
||
{$since_cond}";
|
||
|
||
$this->pdo->prepare($sql)->execute($params);
|
||
}
|
||
|
||
/**
|
||
* Validate that the occupied bins under a storage_id all fall inside a new range.
|
||
*
|
||
* Called before syncBins to prevent shrinking a range that still has occupied bins.
|
||
* If any occupied bin has an aisle or bin value outside the proposed range, throws.
|
||
*
|
||
* @param int $storage_id The md_storage.id whose range is being changed.
|
||
* @param array $range Keys: aisle_from, aisle_to, bin_from, bin_to.
|
||
* @throws Exception If occupied bins would fall outside the new range.
|
||
*/
|
||
private function validateRangeChange(int $storage_id, array $range, bool $advanced_location): void
|
||
{
|
||
$valid_aisles = $this->rangeToArray($range['aisle_from'], $range['aisle_to']);
|
||
$valid_bins = $this->rangeToArray($range['bin_from'], $range['bin_to']);
|
||
|
||
// Fetch all occupied bins under this storage_id
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT zone, aisle, bin FROM md_bin
|
||
WHERE company_id = :company_id
|
||
AND storage_id = :storage_id
|
||
AND product_sku IS NOT NULL"
|
||
);
|
||
$sth->execute([
|
||
':company_id' => $this->company_id,
|
||
':storage_id' => $storage_id,
|
||
]);
|
||
|
||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||
$z = trim($row['zone']);
|
||
$a = trim($row['aisle']);
|
||
$r = trim($row['bin']);
|
||
|
||
if ($advanced_location) {
|
||
if (in_array($a, $valid_aisles, true) && in_array($r, $valid_bins, true)) {
|
||
continue;
|
||
}
|
||
throw new Exception("Cannot shrink range — some bins still have stock");
|
||
}
|
||
|
||
if ($z !== $a || $a !== $r || !in_array($r, $valid_bins, true)) {
|
||
throw new Exception("Cannot shrink range — some bins still have stock");
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Insert bin rows for the full aisle × bin range (INSERT IGNORE skips duplicates).
|
||
*
|
||
* Called by syncBins after cleaning up out-of-range empty bins.
|
||
* Supports both numeric (1-10) and alpha (A-Z) aisle/bin values.
|
||
*
|
||
* @param int $storage_id The md_storage.id this range belongs to.
|
||
* @param array $range Keys: warehouse, zone, aisle_from, aisle_to, bin_from, bin_to.
|
||
*/
|
||
private function insertBinsInRange(int $storage_id, array $range, bool $advanced_location): void
|
||
{
|
||
$sql = "INSERT IGNORE INTO md_bin
|
||
(company_id, warehouse, storage_id, zone, aisle, bin)
|
||
VALUES
|
||
(:company_id, :warehouse, :storage_id, :zone, :aisle, :bin)";
|
||
$sth = $this->pdo->prepare($sql);
|
||
|
||
$aisles = $this->rangeToArray($range['aisle_from'], $range['aisle_to']);
|
||
$bins = $this->rangeToArray($range['bin_from'], $range['bin_to']);
|
||
|
||
if (!$advanced_location) {
|
||
foreach ($bins as $location) {
|
||
$sth->execute([
|
||
":company_id" => $this->company_id,
|
||
":warehouse" => $range['warehouse'],
|
||
":storage_id" => $storage_id,
|
||
":zone" => $location,
|
||
":aisle" => $location,
|
||
":bin" => $location,
|
||
]);
|
||
}
|
||
return;
|
||
}
|
||
|
||
foreach ($aisles as $a) {
|
||
foreach ($bins as $r) {
|
||
$sth->execute([
|
||
":company_id" => $this->company_id,
|
||
":warehouse" => $range['warehouse'],
|
||
":storage_id" => $storage_id,
|
||
":zone" => $range['zone'],
|
||
":aisle" => $a,
|
||
":bin" => $r,
|
||
]);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// MASTER FILE BASIS — Warehouse
|
||
// ─────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Return all warehouses with bin statistics and manager name.
|
||
*
|
||
* Used by the warehouse listing page (/inventory/warehouse.php).
|
||
* Aggregates total_bins, occupied_bins, and unique_product per warehouse
|
||
* from md_bin, and joins the wms.user table for the manager name.
|
||
*
|
||
* @return array All md_warehouse rows for this company with capacity/occupancy fields.
|
||
*/
|
||
public function getWarehouseListAll(): array
|
||
{
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT
|
||
a.*,
|
||
COALESCE(r.total_bins, 0) AS capacity,
|
||
COALESCE(r.occupied_bins, 0) AS space_used,
|
||
COALESCE(r.unique_product, 0) AS unique_product,
|
||
c.name AS manager_name,
|
||
c.surname AS manager_surname
|
||
FROM md_warehouse a
|
||
LEFT JOIN (
|
||
SELECT
|
||
company_id, warehouse,
|
||
COUNT(*) AS total_bins,
|
||
COUNT(product_sku) AS occupied_bins,
|
||
COUNT(DISTINCT product_sku) AS unique_product
|
||
FROM md_bin
|
||
GROUP BY company_id, warehouse
|
||
) r ON a.company_id = r.company_id AND a.id = r.warehouse
|
||
LEFT JOIN wms.user c ON a.manager = c.user_id
|
||
WHERE a.company_id = :company_id"
|
||
);
|
||
$sth->execute([':company_id' => $this->company_id]);
|
||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||
}
|
||
|
||
/**
|
||
* Fetch a single warehouse row by its primary key.
|
||
*
|
||
* Used to pre-fill the edit form on the manage warehouse page.
|
||
*
|
||
* @param int $id The md_warehouse.id to fetch.
|
||
* @return array|false Associative row, or false if not found.
|
||
*/
|
||
public function getWarehouseById(int $id): array|false
|
||
{
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT * FROM md_warehouse
|
||
WHERE company_id = :company_id AND id = :id"
|
||
);
|
||
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
||
return $sth->fetch(PDO::FETCH_ASSOC);
|
||
}
|
||
|
||
/**
|
||
* Fetch a warehouse's name string by its ID.
|
||
*
|
||
* Lightweight helper used internally and by engine files that only need
|
||
* the name (e.g. for table name resolution or display).
|
||
*
|
||
* @param int|string $warehouse_id The md_warehouse.id to look up.
|
||
* @return string|false The warehouse_name, or false if not found.
|
||
*/
|
||
public function getWarehouseName($warehouse_id) {
|
||
$sql = "SELECT warehouse_name FROM md_warehouse
|
||
WHERE company_id = :company_id AND id = :warehouse_id";
|
||
|
||
$sth = $this->pdo->prepare($sql);
|
||
$sth->execute([
|
||
":company_id" => $this->company_id,
|
||
":warehouse_id" => $warehouse_id
|
||
]);
|
||
return $sth->fetchColumn();
|
||
}
|
||
|
||
/**
|
||
* Insert a new warehouse or update an existing one.
|
||
*
|
||
* On insert, returns the new warehouse id so the caller can create the
|
||
* per-warehouse stock table outside the DB transaction.
|
||
*
|
||
* Pass $data['id'] = 0 to insert; pass $data['id'] > 0 to update.
|
||
* Must be called inside dbTransaction() by the caller.
|
||
*
|
||
* @param array $data Keys: id, warehouse_name, location, manager, description, status.
|
||
* @param array $logging Audit entry to append to the log column.
|
||
* @return int|null New warehouse id on insert, null on update.
|
||
*/
|
||
public function saveWarehouse(array $data, array $logging): ?int
|
||
{
|
||
$id = (int)($data['id'] ?? 0);
|
||
$warehouse_name = trim((string)($data['warehouse_name'] ?? ''));
|
||
|
||
if ($warehouse_name === '') {
|
||
throw new Exception("Warehouse name is required.");
|
||
}
|
||
|
||
$duplicate = $this->pdo->prepare(
|
||
"SELECT COUNT(*) FROM md_warehouse
|
||
WHERE company_id = :company_id
|
||
AND warehouse_name = :warehouse_name
|
||
AND id != :id"
|
||
);
|
||
$duplicate->execute([
|
||
':company_id' => $this->company_id,
|
||
':warehouse_name' => $warehouse_name,
|
||
':id' => $id,
|
||
]);
|
||
|
||
if ((int)$duplicate->fetchColumn() > 0) {
|
||
throw new Exception("Warehouse name already exists. Please use a different warehouse name.");
|
||
}
|
||
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT `log` FROM md_warehouse
|
||
WHERE company_id = :company_id AND id = :id"
|
||
);
|
||
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
||
$table_log = json_decode($sth->fetchColumn() ?: '[]', true) ?: [];
|
||
$table_log[] = $logging;
|
||
|
||
$params = [
|
||
':company_id' => $this->company_id,
|
||
':warehouse_name' => $warehouse_name,
|
||
':location' => $data['location'] ?? '',
|
||
':manager' => (int)($data['manager'] ?? 0),
|
||
':description' => $data['description'] ?? '',
|
||
':status' => (int)($data['status'] ?? 1),
|
||
':log' => json_encode($table_log, JSON_UNESCAPED_UNICODE),
|
||
];
|
||
|
||
if ($id > 0) {
|
||
$params[':id'] = $id;
|
||
$this->pdo->prepare(
|
||
"UPDATE md_warehouse SET
|
||
`warehouse_name` = :warehouse_name,
|
||
`location` = :location,
|
||
`manager` = :manager,
|
||
`description` = :description,
|
||
`status` = :status,
|
||
`log` = :log
|
||
WHERE id = :id AND company_id = :company_id"
|
||
)->execute($params);
|
||
} else {
|
||
$this->pdo->prepare(
|
||
"INSERT INTO md_warehouse
|
||
(company_id, warehouse_name, location, manager, description, status, `log`)
|
||
VALUES
|
||
(:company_id, :warehouse_name, :location, :manager, :description, :status, :log)"
|
||
)->execute($params);
|
||
|
||
return (int)$this->pdo->lastInsertId();
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
public function createStockTableForWarehouse(int $warehouse_id): void
|
||
{
|
||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||
$this->pdo->exec("CREATE TABLE IF NOT EXISTS `{$table}` LIKE `td_stock`");
|
||
}
|
||
|
||
/**
|
||
* Soft-delete a warehouse by negating its company_id.
|
||
*
|
||
* Blocks deletion if any md_storage record references this warehouse by name,
|
||
* ensuring no orphaned storage locations remain.
|
||
*
|
||
* Must be called inside dbTransaction() by the caller.
|
||
*
|
||
* @param int $warehouse_id The md_warehouse.id to delete.
|
||
* @throws Exception If the warehouse is not found or has storage locations.
|
||
*/
|
||
public function deleteWarehouse(int $warehouse_id): void {
|
||
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT id, warehouse_name, `log` FROM md_warehouse
|
||
WHERE company_id = :company_id AND id = :id"
|
||
);
|
||
$sth->execute([
|
||
':company_id' => $this->company_id,
|
||
':id' => $warehouse_id,
|
||
]);
|
||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||
|
||
if (!$row) {
|
||
throw new Exception("Warehouse not found.");
|
||
}
|
||
|
||
// Block if any md_storage references this warehouse
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT COUNT(*) FROM md_storage
|
||
WHERE company_id = :company_id
|
||
AND warehouse = :warehouse_id"
|
||
);
|
||
$sth->execute([
|
||
':company_id' => $this->company_id,
|
||
':warehouse_id' => $warehouse_id,
|
||
]);
|
||
if ((int)$sth->fetchColumn() > 0) {
|
||
throw new Exception(
|
||
"Cannot delete — warehouse \"{$row['warehouse_name']}\" " .
|
||
"still has storage locations assigned to it."
|
||
);
|
||
}
|
||
|
||
// Block if the warehouse's stock table has any rows
|
||
$stock_table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT COUNT(*) FROM `{$stock_table}`
|
||
WHERE company_id = :company_id"
|
||
);
|
||
$sth->execute([':company_id' => $this->company_id]);
|
||
if ((int)$sth->fetchColumn() > 0) {
|
||
throw new Exception(
|
||
"Cannot delete — warehouse \"{$row['warehouse_name']}\" " .
|
||
"still has stock records. Clear the stock first."
|
||
);
|
||
}
|
||
|
||
// Append delete event to log
|
||
$log = json_decode($row['log'] ?? '[]', true) ?: [];
|
||
$log[] = $this->buildLogEntry('delete');
|
||
|
||
// Soft-delete: negate company_id so row is hidden but recoverable
|
||
$this->pdo->prepare(
|
||
"UPDATE md_warehouse
|
||
SET company_id = company_id * -1,
|
||
`log` = :log
|
||
WHERE id = :id AND company_id = :company_id"
|
||
)->execute([
|
||
':log' => json_encode($log),
|
||
':id' => $warehouse_id,
|
||
':company_id' => $this->company_id,
|
||
]);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// MASTER FILE BASIS — Storage
|
||
// ─────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Return all storage records with their associated warehouse name.
|
||
*
|
||
* Used to populate the storage listing page (/inventory/manage_storage.php).
|
||
*
|
||
* @return array All md_storage rows for this company with 'warehouse_name'.
|
||
*/
|
||
public function getStorageList(): array
|
||
{
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT a.*, b.warehouse_name
|
||
FROM md_storage a
|
||
LEFT JOIN md_warehouse b
|
||
ON a.company_id = b.company_id
|
||
AND a.warehouse = b.id
|
||
WHERE a.company_id = :company_id"
|
||
);
|
||
$sth->execute([':company_id' => $this->company_id]);
|
||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||
}
|
||
|
||
/**
|
||
* Fetch a single storage row by its primary key.
|
||
*
|
||
* Used to pre-fill the edit form on the manage storage page.
|
||
*
|
||
* @param int $id The md_storage.id to fetch.
|
||
* @return array|false Associative row, or false if not found.
|
||
*/
|
||
public function getStorageById(int $id): array|false
|
||
{
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT * FROM md_storage
|
||
WHERE company_id = :company_id AND id = :id"
|
||
);
|
||
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
||
return $sth->fetch(PDO::FETCH_ASSOC);
|
||
}
|
||
|
||
/**
|
||
* Insert a new storage record or update an existing one, then sync md_bin rows.
|
||
*
|
||
* After saving md_storage, calls syncBins() to ensure the md_bin table
|
||
* exactly matches the new aisle × bin range. On update, existing occupied
|
||
* bins that fall outside the new range will block the operation (via syncBins).
|
||
*
|
||
* Pass $data['id'] = 0 to insert; pass $data['id'] > 0 to update.
|
||
* Must be called inside dbTransaction() by the caller.
|
||
*
|
||
* @param array $data Keys: id, warehouse, zone, aisle_from, aisle_to,
|
||
* bin_from, bin_to, description, status.
|
||
* @param array $logging Audit entry to append to the log column.
|
||
*/
|
||
public function saveStorage(array $data, array $logging, bool $advanced_location = false): void
|
||
{
|
||
$id = (int)($data['id'] ?? 0);
|
||
|
||
$warehouse = trim((string)($data['warehouse'] ?? ''));
|
||
$zone = trim((string)($data['zone'] ?? ''));
|
||
$aisle_from = trim((string)($data['aisle_from'] ?? ''));
|
||
$aisle_to = trim((string)($data['aisle_to'] ?? ''));
|
||
$bin_from = trim((string)($data['bin_from'] ?? ''));
|
||
$bin_to = trim((string)($data['bin_to'] ?? ''));
|
||
|
||
if ($warehouse === '') {
|
||
throw new Exception("Warehouse is required.");
|
||
}
|
||
|
||
if ($advanced_location) {
|
||
if ($zone === '' || $aisle_from === '' || $aisle_to === '' || $bin_from === '' || $bin_to === '') {
|
||
throw new Exception("Zone, aisle range, and bin range are required.");
|
||
}
|
||
} else {
|
||
$location_from = $bin_from !== '' ? $bin_from : $aisle_from;
|
||
$location_to = $bin_to !== '' ? $bin_to : $aisle_to;
|
||
|
||
if ($location_from === '') {
|
||
throw new Exception("Location From is required.");
|
||
}
|
||
if ($location_to === '') {
|
||
$location_to = $location_from;
|
||
}
|
||
|
||
$zone = $location_from;
|
||
$aisle_from = $location_from;
|
||
$aisle_to = $location_to;
|
||
$bin_from = $location_from;
|
||
$bin_to = $location_to;
|
||
}
|
||
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT `log` FROM md_storage
|
||
WHERE company_id = :company_id AND id = :id"
|
||
);
|
||
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
||
$table_log = json_decode($sth->fetchColumn() ?: '[]', true) ?: [];
|
||
$table_log[] = $logging;
|
||
|
||
$params = [
|
||
':company_id' => $this->company_id,
|
||
':warehouse' => $warehouse,
|
||
':zone' => $zone,
|
||
':aisle_from' => $aisle_from,
|
||
':aisle_to' => $aisle_to,
|
||
':bin_from' => $bin_from,
|
||
':bin_to' => $bin_to,
|
||
':description' => $data['description'] ?? '',
|
||
':status' => (int)($data['status'] ?? 1),
|
||
':log' => json_encode($table_log, JSON_UNESCAPED_UNICODE),
|
||
];
|
||
|
||
if ($id > 0) {
|
||
$params[':id'] = $id;
|
||
$this->pdo->prepare(
|
||
"UPDATE md_storage SET
|
||
warehouse = :warehouse,
|
||
zone = :zone,
|
||
aisle_from = :aisle_from,
|
||
aisle_to = :aisle_to,
|
||
bin_from = :bin_from,
|
||
bin_to = :bin_to,
|
||
description = :description,
|
||
status = :status,
|
||
`log` = :log
|
||
WHERE id = :id AND company_id = :company_id"
|
||
)->execute($params);
|
||
|
||
$storage_id = $id;
|
||
} else {
|
||
$this->pdo->prepare(
|
||
"INSERT INTO md_storage
|
||
(company_id, warehouse, zone, aisle_from, aisle_to,
|
||
bin_from, bin_to, description, status, `log`)
|
||
VALUES
|
||
(:company_id, :warehouse, :zone, :aisle_from, :aisle_to,
|
||
:bin_from, :bin_to, :description, :status, :log)"
|
||
)->execute($params);
|
||
|
||
$storage_id = (int)$this->pdo->lastInsertId();
|
||
}
|
||
|
||
// Sync md_bin rows to exactly match the new aisle × bin range
|
||
$this->syncBins($storage_id, [
|
||
'warehouse' => $warehouse,
|
||
'zone' => $zone,
|
||
'aisle_from' => $aisle_from,
|
||
'aisle_to' => $aisle_to,
|
||
'bin_from' => $bin_from,
|
||
'bin_to' => $bin_to,
|
||
], $advanced_location);
|
||
}
|
||
|
||
/**
|
||
* Soft-delete a storage record and its associated bin rows.
|
||
*
|
||
* Blocks deletion if any bin under this storage_id is occupied.
|
||
* Soft-deletes md_bin rows (company_id negation) and retains td_bin_log
|
||
* for audit purposes. Then soft-deletes md_storage.
|
||
*
|
||
* Must be called inside dbTransaction() by the caller.
|
||
*
|
||
* @param int $storage_id The md_storage.id to delete.
|
||
* @throws Exception If storage is not found or has occupied bins.
|
||
*/
|
||
public function deleteStorage(int $storage_id): void {
|
||
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT id, zone, `log` FROM md_storage
|
||
WHERE company_id = :company_id AND id = :id"
|
||
);
|
||
$sth->execute([
|
||
':company_id' => $this->company_id,
|
||
':id' => $storage_id,
|
||
]);
|
||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||
|
||
if (!$row) {
|
||
throw new Exception("Storage not found.");
|
||
}
|
||
|
||
// Block if any bin under this storage is occupied
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT COUNT(*) FROM md_bin
|
||
WHERE company_id = :company_id
|
||
AND storage_id = :storage_id
|
||
AND product_sku IS NOT NULL"
|
||
);
|
||
$sth->execute([
|
||
':company_id' => $this->company_id,
|
||
':storage_id' => $storage_id,
|
||
]);
|
||
|
||
if ($sth->fetchColumn() > 0) {
|
||
throw new Exception(
|
||
"Cannot delete — storage zone \"{$row['zone']}\" " .
|
||
"still has occupied bins with stock."
|
||
);
|
||
}
|
||
|
||
// Soft-delete all md_bin rows under this storage
|
||
$this->pdo->prepare(
|
||
"UPDATE md_bin
|
||
SET company_id = company_id * -1
|
||
WHERE company_id = :company_id
|
||
AND storage_id = :storage_id"
|
||
)->execute([
|
||
':company_id' => $this->company_id,
|
||
':storage_id' => $storage_id,
|
||
]);
|
||
|
||
// Append delete event to log
|
||
$log = json_decode($row['log'] ?? '[]', true) ?: [];
|
||
$log[] = $this->buildLogEntry('delete');
|
||
|
||
// Soft-delete: negate company_id so row is hidden but recoverable
|
||
$this->pdo->prepare(
|
||
"UPDATE md_storage
|
||
SET company_id = company_id * -1,
|
||
`log` = :log
|
||
WHERE id = :id AND company_id = :company_id"
|
||
)->execute([
|
||
':log' => json_encode($log),
|
||
':id' => $storage_id,
|
||
':company_id' => $this->company_id,
|
||
]);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// MASTER FILE BASIS — Zone / Aisle / Bin (admin read-only views)
|
||
// ─────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Return all distinct zones in a warehouse — for admin/read-only listing.
|
||
*
|
||
* Returns every zone regardless of occupancy. Used on warehouse detail
|
||
* and storage admin pages where all zones must be shown.
|
||
* Results are sorted numerically first, then alphabetically.
|
||
*
|
||
* @param int $warehouse_id The md_warehouse.id to query.
|
||
* @return array Rows with 'zone' key.
|
||
*/
|
||
public function getZonesAll(int $warehouse_id): array
|
||
{
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT DISTINCT zone FROM md_bin
|
||
WHERE company_id = :company_id AND warehouse = :warehouse
|
||
ORDER BY CAST(zone AS UNSIGNED), zone"
|
||
);
|
||
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]);
|
||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||
}
|
||
|
||
/**
|
||
* Return all distinct aisles in a zone — for admin/read-only listing.
|
||
*
|
||
* Returns every aisle regardless of occupancy. Used on storage admin pages.
|
||
*
|
||
* @param int $warehouse_id The md_warehouse.id to query.
|
||
* @param string $zone The zone to filter by.
|
||
* @return array Flat array of aisle values (strings).
|
||
*/
|
||
public function getAislesAll(int $warehouse_id, string $zone): array
|
||
{
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT DISTINCT aisle FROM md_bin
|
||
WHERE company_id = :company_id AND warehouse = :warehouse AND zone = :zone
|
||
ORDER BY CAST(aisle AS UNSIGNED), aisle"
|
||
);
|
||
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id, ':zone' => $zone]);
|
||
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle');
|
||
}
|
||
|
||
/**
|
||
* Return all distinct bins in an aisle — for admin/read-only listing.
|
||
*
|
||
* Returns every bin regardless of occupancy. Used on storage admin pages.
|
||
*
|
||
* @param int $warehouse_id The md_warehouse.id to query.
|
||
* @param string $zone The zone to filter by.
|
||
* @param string $aisle The aisle to filter by.
|
||
* @return array Flat array of bin values (strings).
|
||
*/
|
||
public function getBinsAll(int $warehouse_id, string $zone, string $aisle): array
|
||
{
|
||
if ($zone === '' || $aisle === '') {
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT DISTINCT bin FROM md_bin
|
||
WHERE company_id = :company_id AND warehouse = :warehouse
|
||
ORDER BY CAST(bin AS UNSIGNED), bin"
|
||
);
|
||
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]);
|
||
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'bin');
|
||
}
|
||
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT DISTINCT bin FROM md_bin
|
||
WHERE company_id = :company_id AND warehouse = :warehouse AND zone = :zone AND aisle = :aisle
|
||
ORDER BY CAST(bin AS UNSIGNED), bin"
|
||
);
|
||
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id, ':zone' => $zone, ':aisle' => $aisle]);
|
||
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'bin');
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// MASTER FILE BASIS — Lot / Serial queries
|
||
// ─────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Search md_lot by product_sku and lot_number keyword — for lot autocomplete.
|
||
*
|
||
* Returns up to 50 matches ordered by lot_number ASC. The keyword is safely
|
||
* bound as a LIKE parameter.
|
||
*
|
||
* @param string $product_sku The SKU to scope the search to.
|
||
* @param string $keyword Partial lot number to match.
|
||
* @return array Matching md_lot rows.
|
||
*/
|
||
public function getLotList(string $product_sku, string $keyword): array
|
||
{
|
||
// Only return lots that have at least one active td_stock row.
|
||
// Lots whose stock was fully soft-deleted (company_id negated) are excluded.
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT id FROM md_warehouse
|
||
WHERE company_id = :company_id AND status = 1"
|
||
);
|
||
$sth->execute([':company_id' => $this->company_id]);
|
||
$warehouses = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||
|
||
if (empty($warehouses)) return [];
|
||
|
||
$cid = (int)$this->company_id;
|
||
|
||
// Build UNION EXISTS subquery across all td_stock_* tables
|
||
$unions = implode(' UNION ALL ', array_map(function($wh) use ($cid) {
|
||
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
|
||
return "SELECT lot_number FROM `{$table}`
|
||
WHERE company_id = {$cid} AND lot_number IS NOT NULL";
|
||
}, $warehouses));
|
||
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT *
|
||
FROM md_lot
|
||
WHERE company_id = :company_id
|
||
AND product_sku = :product_sku
|
||
AND lot_number LIKE :keyword
|
||
AND lot_number IN (SELECT lot_number FROM ({$unions}) AS all_lots)
|
||
ORDER BY lot_number ASC
|
||
LIMIT 50"
|
||
);
|
||
$sth->execute([
|
||
':company_id' => $this->company_id,
|
||
':product_sku' => $product_sku,
|
||
':keyword' => '%' . $keyword . '%',
|
||
]);
|
||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||
}
|
||
|
||
/**
|
||
* Return distinct active lots for a product_sku across all warehouses.
|
||
*
|
||
* "Active" means the bin linked to the stock_in row is still occupied
|
||
* (md_bin.product_sku IS NOT NULL). Includes expiry_date from md_lot.
|
||
* Deduplicates across warehouses so each lot_number appears once.
|
||
* Used to populate the lot dropdown on stock_out forms.
|
||
*
|
||
* @param string $product_sku The SKU to find active lots for.
|
||
* @return array Rows with lot_number and expiry_date, deduplicated.
|
||
*/
|
||
public function getActiveLots(string $product_sku): array
|
||
{
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT id FROM md_warehouse
|
||
WHERE company_id = :company_id AND status = 1"
|
||
);
|
||
$sth->execute([':company_id' => $this->company_id]);
|
||
$warehouses = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||
|
||
$active_lots = [];
|
||
|
||
foreach ($warehouses as $wh) {
|
||
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
|
||
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT DISTINCT s.lot_number, l.expiry_date
|
||
FROM `{$table}` s
|
||
LEFT JOIN md_lot l
|
||
ON l.company_id = s.company_id
|
||
AND l.product_sku = s.product_sku
|
||
AND l.lot_number = s.lot_number
|
||
WHERE s.company_id = :company_id
|
||
AND s.product_sku = :product_sku
|
||
AND s.`in` > 0
|
||
AND s.status = 1
|
||
AND s.lot_number IS NOT NULL
|
||
AND EXISTS (
|
||
SELECT 1 FROM md_bin r
|
||
WHERE r.company_id = s.company_id
|
||
AND r.warehouse = :warehouse_id
|
||
AND r.td_stock_id = s.id
|
||
AND r.product_sku IS NOT NULL
|
||
)"
|
||
);
|
||
$sth->execute([
|
||
':company_id' => $this->company_id,
|
||
':product_sku' => $product_sku,
|
||
':warehouse_id' => $wh['id'],
|
||
]);
|
||
|
||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||
$active_lots[$row['lot_number']] ??= $row;
|
||
}
|
||
}
|
||
|
||
return array_values($active_lots);
|
||
}
|
||
|
||
/**
|
||
* Return distinct active serial numbers for a product_sku (and optional lot)
|
||
* across all warehouses.
|
||
*
|
||
* "Active" means the bin linked to the stock_in row is still occupied.
|
||
* Used to populate the serial dropdown on stock_out forms.
|
||
*
|
||
* @param string $product_sku The SKU to find active serials for.
|
||
* @param string $lot_number Optional lot filter — pass empty string to skip.
|
||
* @return array Flat array of active serial number strings.
|
||
*/
|
||
public function getActiveSerials(string $product_sku, string $lot_number = ''): array
|
||
{
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT id FROM md_warehouse
|
||
WHERE company_id = :company_id AND status = 1"
|
||
);
|
||
$sth->execute([':company_id' => $this->company_id]);
|
||
$warehouses = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||
|
||
$active_serials = [];
|
||
|
||
foreach ($warehouses as $wh) {
|
||
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
|
||
|
||
$wh_id = $wh['id'];
|
||
$sql = "SELECT DISTINCT s.serial_number
|
||
FROM `{$table}` s
|
||
WHERE s.company_id = :company_id
|
||
AND s.product_sku = :product_sku
|
||
AND s.`in` > 0
|
||
AND s.status = 1
|
||
AND s.serial_number IS NOT NULL
|
||
AND EXISTS (
|
||
SELECT 1 FROM md_bin r
|
||
WHERE r.company_id = s.company_id
|
||
AND r.warehouse = {$wh_id}
|
||
AND r.td_stock_id = s.id
|
||
AND r.product_sku IS NOT NULL
|
||
)";
|
||
|
||
$params = [
|
||
':company_id' => $this->company_id,
|
||
':product_sku' => $product_sku,
|
||
];
|
||
|
||
if (!empty($lot_number)) {
|
||
$sql .= ' AND s.lot_number = :lot_number';
|
||
$params[':lot_number'] = $lot_number;
|
||
}
|
||
|
||
$sth = $this->pdo->prepare($sql);
|
||
$sth->execute($params);
|
||
|
||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||
$active_serials[$row['serial_number']] = $row['serial_number'];
|
||
}
|
||
}
|
||
|
||
return array_values($active_serials);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// TRANSACTION BASIS — Stock context resolution
|
||
// ─────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Resolve the per-warehouse stock table name and optionally fetch a specific row.
|
||
*
|
||
* Core helper used throughout engine files and StockManager to get:
|
||
* - 'table': the td_stock_<warehouse_id> table name
|
||
* - 'name': the raw warehouse_name string for display
|
||
* - 'row': the specific stock row (or empty array if $id = 0)
|
||
*
|
||
* Pass $id = 0 to get just the table name without fetching a row.
|
||
* Note: does NOT filter by status — inactive warehouses can still have
|
||
* historical rows that need reading.
|
||
*
|
||
* @param int|string $warehouse_id The md_warehouse.id.
|
||
* @param int|string $id The td_stock_<warehouse_id>.id to fetch, or 0 for table-only.
|
||
* @return array Keys: 'table' (string), 'name' (string), 'row' (array|[]).
|
||
*/
|
||
public function getStockContext($warehouse_id, $id) {
|
||
$warehouse_id = (int)$warehouse_id;
|
||
$name = $this->getWarehouseName($warehouse_id);
|
||
if (!$name) {
|
||
throw new Exception("Warehouse ID {$warehouse_id} not found.");
|
||
}
|
||
|
||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||
|
||
if (empty($id)) {
|
||
return [
|
||
'table' => $table,
|
||
'name' => $name,
|
||
'row' => []
|
||
];
|
||
}
|
||
|
||
$sql = "SELECT * FROM `$table`
|
||
WHERE company_id = :company_id AND id = :id";
|
||
|
||
$sth = $this->pdo->prepare($sql);
|
||
$sth->execute([
|
||
":company_id" => $this->company_id,
|
||
":id" => $id
|
||
]);
|
||
|
||
return [
|
||
'table' => $table,
|
||
'name' => $name,
|
||
'row' => $sth->fetch(PDO::FETCH_ASSOC) ?: []
|
||
];
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// TRANSACTION BASIS — Bin lifecycle
|
||
// ─────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Sync md_bin rows for a storage record to exactly match a new aisle × bin range.
|
||
*
|
||
* Steps:
|
||
* 1. Validates that no occupied bins fall outside the new range (blocks if so).
|
||
* 2. Hard-deletes all empty bins belonging to this storage_id.
|
||
* 3. Re-inserts the full set of bins for the new range (INSERT IGNORE skips existing occupied bins).
|
||
*
|
||
* Called by saveStorage after every insert or update.
|
||
* Must be called inside a DB transaction by the caller.
|
||
*
|
||
* @param int $storage_id The md_storage.id this range belongs to.
|
||
* @param array $range Keys: warehouse, zone, aisle_from, aisle_to, bin_from, bin_to.
|
||
* @throws Exception If occupied bins would be removed by the new range.
|
||
*/
|
||
public function syncBins(int $storage_id, array $range, bool $advanced_location): void {
|
||
|
||
// Guard: refuse if any occupied bin under this storage_id falls outside the new range
|
||
$this->validateRangeChange($storage_id, $range, $advanced_location);
|
||
|
||
// Wipe all empty bins belonging to this storage_id
|
||
$sql = "DELETE FROM md_bin
|
||
WHERE company_id = :company_id
|
||
AND storage_id = :storage_id
|
||
AND product_sku IS NULL";
|
||
|
||
$sth = $this->pdo->prepare($sql);
|
||
$sth->execute([
|
||
':company_id' => $this->company_id,
|
||
':storage_id' => $storage_id,
|
||
]);
|
||
|
||
// Reinsert the full range fresh (INSERT IGNORE skips any occupied bins already there)
|
||
$this->insertBinsInRange($storage_id, $range, $advanced_location);
|
||
}
|
||
|
||
private function getBinRow(int $warehouse_id, string $zone, string $aisle, string $bin): array|false
|
||
{
|
||
$sql = "SELECT id, product_sku, td_stock_id
|
||
FROM md_bin
|
||
WHERE company_id = :company_id
|
||
AND warehouse = :warehouse
|
||
AND zone = :zone
|
||
AND aisle = :aisle
|
||
AND bin = :bin
|
||
LIMIT 1";
|
||
|
||
$sth = $this->pdo->prepare($sql);
|
||
$sth->execute([
|
||
':company_id' => $this->company_id,
|
||
':warehouse' => $warehouse_id,
|
||
':zone' => $zone,
|
||
':aisle' => $aisle,
|
||
':bin' => $bin,
|
||
]);
|
||
|
||
return $sth->fetch(PDO::FETCH_ASSOC);
|
||
}
|
||
|
||
public function validateScanLocation(
|
||
string $mode,
|
||
int $warehouse_id,
|
||
string $zone,
|
||
string $aisle,
|
||
string $bin,
|
||
string $product_sku = '',
|
||
string $lot_number = '',
|
||
string $serial_number = ''
|
||
): void {
|
||
if (!$warehouse_id || $zone === '' || $aisle === '' || $bin === '') {
|
||
throw new Exception('Location label is incomplete.');
|
||
}
|
||
|
||
$bin_row = $this->getBinRow($warehouse_id, $zone, $aisle, $bin);
|
||
if (!$bin_row) {
|
||
throw new Exception("Location {$zone}-{$aisle}-{$bin} does not exist.");
|
||
}
|
||
|
||
if ($mode === 'in' || $mode === 'to') {
|
||
if ($bin_row['product_sku'] !== null) {
|
||
throw new Exception("Location {$zone}-{$aisle}-{$bin} is already occupied by {$bin_row['product_sku']}.");
|
||
}
|
||
|
||
if ($mode === 'in' && $product_sku !== '') {
|
||
$this->validateStockUnique($product_sku, $lot_number ?: null, $serial_number ?: null);
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
if ($mode === 'out' || $mode === 'from') {
|
||
if ($product_sku === '') {
|
||
throw new Exception('Scan the SKU label first.');
|
||
}
|
||
|
||
$stock = $this->getBinStock($warehouse_id, $zone, $aisle, $bin);
|
||
if (!$stock) {
|
||
throw new Exception("Location {$zone}-{$aisle}-{$bin} has no approved stock to move out.");
|
||
}
|
||
|
||
if ($stock['product_sku'] !== $product_sku) {
|
||
throw new Exception("Location holds {$stock['product_sku']}, not {$product_sku}.");
|
||
}
|
||
|
||
if ($lot_number !== '' && (string)$stock['lot_number'] !== $lot_number) {
|
||
throw new Exception("Location holds lot '{$stock['lot_number']}', not '{$lot_number}'.");
|
||
}
|
||
|
||
if ($serial_number !== '' && (string)$stock['serial_number'] !== $serial_number) {
|
||
throw new Exception("Location holds serial '{$stock['serial_number']}', not '{$serial_number}'.");
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
throw new Exception('Invalid scan validation mode.');
|
||
}
|
||
|
||
/**
|
||
* Fetch the stock record currently occupying a bin.
|
||
*
|
||
* Under the 1:1 bin model, each occupied bin's md_bin.td_stock_id points
|
||
* to exactly one td_stock_<warehouse_id> row. This method resolves that pointer and
|
||
* returns the full stock row, or null if the bin is empty or the link is broken.
|
||
*
|
||
* Used by saveStockOut and saveStockTransfer to validate bin contents before
|
||
* allowing a movement.
|
||
*
|
||
* @param int|string $warehouse_id The warehouse the bin belongs to.
|
||
* @param string $zone Zone identifier.
|
||
* @param string $aisle Aisle identifier.
|
||
* @param string $bin Bin identifier.
|
||
* @return array|null The full td_stock row linked to the bin, or null if empty.
|
||
*/
|
||
public function getBinStock($warehouse_id, $zone, $aisle, $bin): ?array {
|
||
|
||
// Get the stock pointer from md_bin
|
||
$sql = "SELECT td_stock_id FROM md_bin
|
||
WHERE company_id = :company_id
|
||
AND warehouse = :warehouse
|
||
AND zone = :zone
|
||
AND aisle = :aisle
|
||
AND bin = :bin";
|
||
|
||
$sth = $this->pdo->prepare($sql);
|
||
$sth->execute([
|
||
":company_id" => $this->company_id,
|
||
":warehouse" => $warehouse_id,
|
||
":zone" => $zone,
|
||
":aisle" => $aisle,
|
||
":bin" => $bin,
|
||
]);
|
||
$td_stock_id = $sth->fetchColumn();
|
||
|
||
if (!$td_stock_id) {
|
||
return null; // Bin is empty or doesn't exist
|
||
}
|
||
|
||
// Fetch the full stock row from the per-warehouse table. Source
|
||
// movements can only consume approved stock-in rows; draft stock-in
|
||
// rows may reserve a bin but are not available inventory yet.
|
||
$context = $this->getStockContext($warehouse_id, $td_stock_id);
|
||
$row = $context['row'] ?: null;
|
||
|
||
if (!$row || $row['type'] !== 'in' || (int)$row['status'] !== 1) {
|
||
return null;
|
||
}
|
||
|
||
return $row;
|
||
}
|
||
|
||
/**
|
||
* Validate that a sku + lot + serial combination is not already active in any bin.
|
||
*
|
||
* "Active" means the td_stock row is currently linked to an occupied md_bin slot.
|
||
* Enforces uniqueness rules:
|
||
* - SKU alone → allowed in multiple bins (normal stocking)
|
||
* - SKU + lot → allowed in multiple bins (lot spread across locations)
|
||
* - SKU + serial → must be unique (one physical unit = one location)
|
||
* - SKU + lot + serial → must be unique
|
||
*
|
||
* Called from occupyBin to prevent double-stocking the same serialised unit.
|
||
* The $exclude_stock_id parameter skips the row just inserted (prevents self-conflict
|
||
* in insert flows where the row exists but md_bin has not yet been updated).
|
||
*
|
||
* @param string $product_sku SKU to validate.
|
||
* @param string|null $lot_number Lot number to check (or null to skip).
|
||
* @param string|null $serial_number Serial number to check (or null to skip).
|
||
* @param int $exclude_stock_id Stock row ID to exclude from the check.
|
||
* @throws Exception If an active duplicate is found in any warehouse.
|
||
*/
|
||
public function validateStockUnique(
|
||
string $product_sku,
|
||
?string $lot_number = null,
|
||
?string $serial_number = null,
|
||
int $exclude_stock_id = 0
|
||
): void {
|
||
// Nothing to validate if neither lot nor serial was provided
|
||
if (empty($lot_number) && empty($serial_number)) return;
|
||
|
||
$cid = $this->company_id;
|
||
|
||
// Discover all active warehouses
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT id, warehouse_name FROM md_warehouse
|
||
WHERE company_id = :company_id AND status = 1"
|
||
);
|
||
$sth->execute([':company_id' => $cid]);
|
||
$warehouses = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||
|
||
if (empty($warehouses)) return;
|
||
|
||
foreach ($warehouses as $wh) {
|
||
$wh_id = (int)$wh['id'];
|
||
$table = $this->stockTableNameFromWarehouseId($wh_id);
|
||
|
||
// Find any stock_in row for this SKU that is still bin-linked (active)
|
||
// and matches the lot/serial combination being validated.
|
||
// The exclude_id skips the just-inserted row to avoid false self-conflict.
|
||
// r.warehouse = :warehouse_id scopes the bin check to THIS warehouse only —
|
||
// td_stock_id values are per-table integers, not globally unique, so without
|
||
// this filter a bin in a different warehouse could cause a false positive.
|
||
$sql = "SELECT s.id
|
||
FROM `{$table}` s
|
||
WHERE s.company_id = :company_id
|
||
AND s.product_sku = :product_sku
|
||
AND s.`in` > 0
|
||
AND EXISTS (
|
||
SELECT 1 FROM md_bin r
|
||
WHERE r.company_id = :company_id2
|
||
AND r.warehouse = :warehouse_id
|
||
AND r.td_stock_id = s.id
|
||
AND r.product_sku IS NOT NULL
|
||
)";
|
||
|
||
if (!empty($lot_number)) {
|
||
$sql .= " AND s.lot_number = :lot_number";
|
||
}
|
||
if (!empty($serial_number)) {
|
||
$sql .= " AND s.serial_number = :serial_number";
|
||
}
|
||
if ($exclude_stock_id > 0) {
|
||
$sql .= " AND s.id != :exclude_id";
|
||
}
|
||
|
||
$params = [
|
||
':company_id' => $cid,
|
||
':company_id2' => $cid,
|
||
':warehouse_id' => $wh_id,
|
||
':product_sku' => $product_sku,
|
||
];
|
||
if (!empty($lot_number)) $params[':lot_number'] = $lot_number;
|
||
if (!empty($serial_number)) $params[':serial_number'] = $serial_number;
|
||
if ($exclude_stock_id > 0) $params[':exclude_id'] = $exclude_stock_id;
|
||
|
||
$sth = $this->pdo->prepare($sql);
|
||
$sth->execute($params);
|
||
|
||
if ($sth->fetchColumn()) {
|
||
$combo = implode(' / ', array_filter([
|
||
$lot_number ? "lot: {$lot_number}" : null,
|
||
$serial_number ? "serial: {$serial_number}" : null,
|
||
]));
|
||
throw new Exception(
|
||
"Active stock already exists for product '{$product_sku}' [{$combo}] "
|
||
. "in warehouse '{$wh['warehouse_name']}'. "
|
||
. "Stock must be moved out before it can be received again."
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Assign a product to an empty bin and link it to a td_stock row.
|
||
*
|
||
* Uses FOR UPDATE to lock the bin row and prevent concurrent occupancy.
|
||
* Also calls validateStockUnique to enforce no-duplicate-serial rules unless
|
||
* the caller is replaying a validated source transaction, such as a return.
|
||
* Updates md_bin state (product_sku + td_stock_id) in a single UPDATE
|
||
* and appends a bin log entry.
|
||
*
|
||
* Must be called inside a DB transaction by the caller.
|
||
*
|
||
* @param int|string $warehouse_id Warehouse the bin belongs to.
|
||
* @param string $zone Zone identifier.
|
||
* @param string $aisle Aisle identifier.
|
||
* @param string $bin Bin identifier.
|
||
* @param string $product_sku SKU to assign to this bin.
|
||
* @param int $td_stock_id The td_stock_<warehouse_id>.id to link.
|
||
* @throws Exception If the bin does not exist or is already occupied.
|
||
*/
|
||
public function occupyBin($warehouse_id, $zone, $aisle, $bin, $product_sku, $td_stock_id, bool $skip_unique_validation = false): void {
|
||
|
||
$sql = "SELECT id, product_sku, td_stock_id FROM md_bin
|
||
WHERE company_id = :company_id
|
||
AND warehouse = :warehouse
|
||
AND zone = :zone
|
||
AND aisle = :aisle
|
||
AND bin = :bin
|
||
FOR UPDATE";
|
||
|
||
$sth = $this->pdo->prepare($sql);
|
||
$sth->execute([
|
||
":company_id" => $this->company_id,
|
||
":warehouse" => $warehouse_id,
|
||
":zone" => $zone,
|
||
":aisle" => $aisle,
|
||
":bin" => $bin,
|
||
]);
|
||
$current = $sth->fetch(PDO::FETCH_ASSOC);
|
||
|
||
if (!$current) {
|
||
throw new Exception("Bin {$zone}-{$aisle}-{$bin} does not exist");
|
||
}
|
||
|
||
if ($current['product_sku'] !== null) {
|
||
if (
|
||
$current['product_sku'] === $product_sku
|
||
&& (int)$current['td_stock_id'] === (int)$td_stock_id
|
||
) {
|
||
return;
|
||
}
|
||
|
||
throw new Exception(
|
||
"Bin {$zone}-{$aisle}-{$bin} is already occupied by {$current['product_sku']}"
|
||
);
|
||
}
|
||
|
||
if (!$skip_unique_validation) {
|
||
// Validate sku + lot + serial uniqueness before linking the bin
|
||
$ctx = $this->getStockContext($warehouse_id, $td_stock_id);
|
||
$row = $ctx['row'] ?? null;
|
||
|
||
if ($row) {
|
||
$this->validateStockUnique(
|
||
$product_sku,
|
||
$row['lot_number'] ?? null,
|
||
$row['serial_number'] ?? null,
|
||
$td_stock_id // exclude self so insert flows don't self-conflict
|
||
);
|
||
}
|
||
}
|
||
|
||
// Atomic UPDATE: set state and link in one statement
|
||
$sql = "UPDATE md_bin
|
||
SET product_sku = :product_sku,
|
||
td_stock_id = :td_stock_id
|
||
WHERE id = :id";
|
||
|
||
$sth = $this->pdo->prepare($sql);
|
||
$sth->execute([
|
||
":product_sku" => $product_sku,
|
||
":td_stock_id" => $td_stock_id,
|
||
":id" => $current['id'],
|
||
]);
|
||
|
||
$this->insertBinLog($current['id'], 'occupy', [
|
||
'product_sku' => $product_sku,
|
||
'td_stock_id' => $td_stock_id,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Release a bin — clear its product_sku and td_stock_id link.
|
||
*
|
||
* Uses FOR UPDATE to lock the bin row. If the bin is already empty,
|
||
* exits silently (idempotent: safe to call on an already-empty bin).
|
||
* Appends a bin log entry on successful release.
|
||
*
|
||
* Must be called inside a DB transaction by the caller.
|
||
*
|
||
* @param int|string $warehouse_id Warehouse the bin belongs to.
|
||
* @param string $zone Zone identifier.
|
||
* @param string $aisle Aisle identifier.
|
||
* @param string $bin Bin identifier.
|
||
*/
|
||
public function releaseBin($warehouse_id, $zone, $aisle, $bin): void {
|
||
|
||
$sql = "SELECT id, product_sku, td_stock_id FROM md_bin
|
||
WHERE company_id = :company_id
|
||
AND warehouse = :warehouse
|
||
AND zone = :zone
|
||
AND aisle = :aisle
|
||
AND bin = :bin
|
||
FOR UPDATE";
|
||
|
||
$sth = $this->pdo->prepare($sql);
|
||
$sth->execute([
|
||
":company_id" => $this->company_id,
|
||
":warehouse" => $warehouse_id,
|
||
":zone" => $zone,
|
||
":aisle" => $aisle,
|
||
":bin" => $bin,
|
||
]);
|
||
$current = $sth->fetch(PDO::FETCH_ASSOC);
|
||
|
||
// Nothing to release — exit silently (idempotent)
|
||
if (!$current || $current['product_sku'] === null) {
|
||
return;
|
||
}
|
||
|
||
// Clear both state fields in a single UPDATE
|
||
$sql = "UPDATE md_bin
|
||
SET product_sku = NULL,
|
||
td_stock_id = NULL
|
||
WHERE id = :id";
|
||
|
||
$sth = $this->pdo->prepare($sql);
|
||
$sth->execute([":id" => $current['id']]);
|
||
|
||
$this->insertBinLog($current['id'], 'release', [
|
||
'product_sku' => $current['product_sku'],
|
||
'td_stock_id' => $current['td_stock_id'],
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Move a bin assignment from one location to another.
|
||
*
|
||
* Transfers both product_sku and td_stock_id together from the source
|
||
* bin to the destination bin. The source must be occupied; the destination
|
||
* must be empty. Both bins are locked in id-ascending order to prevent
|
||
* deadlocks under concurrent transfers.
|
||
*
|
||
* Must be called inside a DB transaction by the caller.
|
||
*
|
||
* @param int|string $from_warehouse Source warehouse.
|
||
* @param string $from_zone Source zone.
|
||
* @param string $from_aisle Source aisle.
|
||
* @param string $from_bin Source bin.
|
||
* @param int|string $to_warehouse Destination warehouse.
|
||
* @param string $to_zone Destination zone.
|
||
* @param string $to_aisle Destination aisle.
|
||
* @param string $to_bin Destination bin.
|
||
* @throws Exception If source/destination bins are not found, source is empty,
|
||
* or destination is already occupied.
|
||
*/
|
||
public function transferBin(
|
||
$from_warehouse, $from_zone, $from_aisle, $from_bin,
|
||
$to_warehouse, $to_zone, $to_aisle, $to_bin
|
||
): void {
|
||
|
||
// Lock both bins ordered by id to prevent deadlocks on concurrent transfers
|
||
$sql = "SELECT id, warehouse, zone, aisle, bin, product_sku, td_stock_id
|
||
FROM md_bin
|
||
WHERE company_id = :company_id
|
||
AND (
|
||
(warehouse = :from_wh AND zone = :from_zone
|
||
AND aisle = :from_aisle AND bin = :from_bin)
|
||
OR
|
||
(warehouse = :to_wh AND zone = :to_zone
|
||
AND aisle = :to_aisle AND bin = :to_bin)
|
||
)
|
||
ORDER BY id
|
||
FOR UPDATE";
|
||
|
||
$sth = $this->pdo->prepare($sql);
|
||
$sth->execute([
|
||
":company_id" => $this->company_id,
|
||
":from_wh" => $from_warehouse,
|
||
":from_zone" => $from_zone,
|
||
":from_aisle" => $from_aisle,
|
||
":from_bin" => $from_bin,
|
||
":to_wh" => $to_warehouse,
|
||
":to_zone" => $to_zone,
|
||
":to_aisle" => $to_aisle,
|
||
":to_bin" => $to_bin,
|
||
]);
|
||
|
||
$bins = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||
|
||
// Identify source and destination from the fetched rows
|
||
$from = null;
|
||
$to = null;
|
||
foreach ($bins as $r) {
|
||
if ($r['warehouse'] == $from_warehouse && $r['zone'] == $from_zone
|
||
&& $r['aisle'] == $from_aisle && $r['bin'] == $from_bin) {
|
||
$from = $r;
|
||
}
|
||
if ($r['warehouse'] == $to_warehouse && $r['zone'] == $to_zone
|
||
&& $r['aisle'] == $to_aisle && $r['bin'] == $to_bin) {
|
||
$to = $r;
|
||
}
|
||
}
|
||
|
||
if (!$from) {
|
||
throw new Exception("Source bin not found");
|
||
}
|
||
if (!$to) {
|
||
throw new Exception("Destination bin not found");
|
||
}
|
||
if ($from['product_sku'] === null) {
|
||
throw new Exception("Source bin is empty");
|
||
}
|
||
if ($to['product_sku'] !== null) {
|
||
throw new Exception("Destination bin is already occupied");
|
||
}
|
||
|
||
// Carry both SKU and stock reference from source to destination
|
||
$sku = $from['product_sku'];
|
||
$td_stock_id = $from['td_stock_id'];
|
||
|
||
// Clear source
|
||
$this->pdo->prepare("UPDATE md_bin SET product_sku = NULL, td_stock_id = NULL WHERE id = :id")
|
||
->execute([":id" => $from['id']]);
|
||
|
||
$this->insertBinLog($from['id'], 'transfer_out', [
|
||
'product_sku' => $sku,
|
||
'td_stock_id' => $td_stock_id,
|
||
]);
|
||
|
||
// Populate destination
|
||
$this->pdo->prepare("UPDATE md_bin SET product_sku = :sku, td_stock_id = :td_stock_id WHERE id = :id")
|
||
->execute([":sku" => $sku, ":td_stock_id" => $td_stock_id, ":id" => $to['id']]);
|
||
|
||
$this->insertBinLog($to['id'], 'transfer_in', [
|
||
'product_sku' => $sku,
|
||
'td_stock_id' => $td_stock_id,
|
||
]);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// TRANSACTION BASIS — Warehouse balance
|
||
// ─────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Adjust the running balance for a (warehouse, product_sku) pair in etl_stock_summary.
|
||
*
|
||
* Uses an INSERT ... ON DUPLICATE KEY UPDATE to atomically upsert the balance row,
|
||
* partitioned by month (YYYY-MM) for efficient monthly reporting queries.
|
||
*
|
||
* The delta formula ($new_qty - $old_qty) supports both create and edit flows:
|
||
* - On insert: old_qty = 0, delta = new_qty (adds the full amount)
|
||
* - On delete: new_qty = 0, delta = -old_qty (reverses the full amount)
|
||
* - On edit: delta = new_qty - old_qty (adjusts for the difference)
|
||
*
|
||
* Called by StockManager and deleteStock* methods; never called directly by engine files.
|
||
*
|
||
* @param string $type 'in' or 'out' — which balance column to update.
|
||
* @param int|string $warehouse_id Warehouse to adjust balance for.
|
||
* @param string $product_sku SKU to adjust.
|
||
* @param int|float $old_qty Previous quantity (0 for new records).
|
||
* @param int|float $new_qty New quantity (0 to reverse/delete).
|
||
*/
|
||
public function adjustBalance(
|
||
$type, $warehouse_id, $product_sku, $old_qty, $new_qty,
|
||
int $stock_id = 0, string $source = '', int $source_id = 0,
|
||
string $stock_date = ''
|
||
): void {
|
||
$column = $type === 'in' ? 'total_in' : 'total_out';
|
||
$delta = $new_qty - $old_qty;
|
||
$month = $stock_date ? date('Y-m', strtotime($stock_date)) : date('Y-m');
|
||
|
||
$this->pdo->prepare(
|
||
"INSERT INTO etl_stock_summary
|
||
(company_id, warehouse_id, product_sku, month, `{$column}`, source_updated_at)
|
||
VALUES
|
||
(:company_id, :warehouse_id, :product_sku, :month, :delta, NOW())
|
||
ON DUPLICATE KEY UPDATE
|
||
`{$column}` = `{$column}` + VALUES(`{$column}`),
|
||
source_updated_at = NOW()"
|
||
)->execute([
|
||
':company_id' => $this->company_id,
|
||
':warehouse_id' => $warehouse_id,
|
||
':product_sku' => $product_sku,
|
||
':month' => $month,
|
||
':delta' => $delta,
|
||
]);
|
||
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// TRANSACTION BASIS — Zone / Aisle / Bin (stock movement selectors)
|
||
// ─────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Return distinct zones with at least one empty bin — for stock_in destination selector.
|
||
*
|
||
* Only zones that have at least one available (empty) bin slot are returned,
|
||
* so the user cannot direct stock to a fully occupied zone.
|
||
*
|
||
* @param int $warehouse_id The warehouse to query.
|
||
* @return array Rows with 'zone' key.
|
||
*/
|
||
public function getZonesIn(int $warehouse_id): array
|
||
{
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT DISTINCT zone FROM md_bin
|
||
WHERE company_id = :company_id
|
||
AND warehouse = :warehouse
|
||
AND product_sku IS NULL
|
||
ORDER BY CAST(zone AS UNSIGNED), zone"
|
||
);
|
||
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]);
|
||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||
}
|
||
|
||
/**
|
||
* Return distinct zones holding active stock for a sku/lot/serial — for stock_out source selector.
|
||
*
|
||
* Only zones that currently have at least one occupied bin with the matching
|
||
* product/lot/serial combination are returned.
|
||
*
|
||
* @param int $warehouse_id The warehouse to query.
|
||
* @param string $product_sku SKU to filter by.
|
||
* @param string|null $lot_number Optional lot filter.
|
||
* @param string|null $serial_number Optional serial filter.
|
||
* @return array Rows with 'zone' key.
|
||
*/
|
||
public function getZonesOut(
|
||
int $warehouse_id,
|
||
string $product_sku,
|
||
?string $lot_number = null,
|
||
?string $serial_number = null
|
||
): array {
|
||
$table = $this->resolveWarehouseTable($warehouse_id);
|
||
if (!$table) return [];
|
||
|
||
[$lot_cond, $serial_cond, $params] = $this->buildLotSerialCondition(
|
||
$warehouse_id, $product_sku, $lot_number, $serial_number
|
||
);
|
||
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT DISTINCT r.zone
|
||
FROM md_bin r
|
||
WHERE r.company_id = :company_id
|
||
AND r.warehouse = :warehouse
|
||
AND r.product_sku = :product_sku
|
||
AND r.td_stock_id IN (
|
||
SELECT s.id FROM `{$table}` s
|
||
WHERE s.company_id = :company_id2
|
||
AND s.product_sku = :product_sku2
|
||
AND s.`in` > 0
|
||
AND s.status = 1
|
||
{$lot_cond}
|
||
{$serial_cond}
|
||
)
|
||
ORDER BY CAST(r.zone AS UNSIGNED), r.zone"
|
||
);
|
||
$sth->execute($params);
|
||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||
}
|
||
|
||
/**
|
||
* Return distinct aisles with at least one empty bin in a zone — for stock_in destination.
|
||
*
|
||
* @param int $warehouse_id The warehouse to query.
|
||
* @param string $zone Zone to filter by.
|
||
* @return array Flat array of aisle values (strings).
|
||
*/
|
||
public function getAislesIn(int $warehouse_id, string $zone): array
|
||
{
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT DISTINCT aisle FROM md_bin
|
||
WHERE company_id = :company_id
|
||
AND warehouse = :warehouse
|
||
AND zone = :zone
|
||
AND product_sku IS NULL
|
||
ORDER BY CAST(aisle AS UNSIGNED), aisle"
|
||
);
|
||
$sth->execute([
|
||
':company_id' => $this->company_id,
|
||
':warehouse' => $warehouse_id,
|
||
':zone' => $zone,
|
||
]);
|
||
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle');
|
||
}
|
||
|
||
/**
|
||
* Return distinct aisles holding active stock for a sku/lot/serial in a zone — for stock_out source.
|
||
*
|
||
* @param int $warehouse_id The warehouse to query.
|
||
* @param string $zone Zone to filter by.
|
||
* @param string $product_sku SKU to filter by.
|
||
* @param string|null $lot_number Optional lot filter.
|
||
* @param string|null $serial_number Optional serial filter.
|
||
* @return array Flat array of aisle values (strings).
|
||
*/
|
||
public function getAislesOut(
|
||
int $warehouse_id,
|
||
string $zone,
|
||
string $product_sku,
|
||
?string $lot_number = null,
|
||
?string $serial_number = null
|
||
): array {
|
||
$table = $this->resolveWarehouseTable($warehouse_id);
|
||
if (!$table) return [];
|
||
|
||
[$lot_cond, $serial_cond, $params] = $this->buildLotSerialCondition(
|
||
$warehouse_id, $product_sku, $lot_number, $serial_number
|
||
);
|
||
$params[':zone'] = $zone;
|
||
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT DISTINCT r.aisle
|
||
FROM md_bin r
|
||
WHERE r.company_id = :company_id
|
||
AND r.warehouse = :warehouse
|
||
AND r.zone = :zone
|
||
AND r.product_sku = :product_sku
|
||
AND r.td_stock_id IN (
|
||
SELECT s.id FROM `{$table}` s
|
||
WHERE s.company_id = :company_id2
|
||
AND s.product_sku = :product_sku2
|
||
AND s.`in` > 0
|
||
AND s.status = 1
|
||
{$lot_cond}
|
||
{$serial_cond}
|
||
)
|
||
ORDER BY CAST(r.aisle AS UNSIGNED), r.aisle"
|
||
);
|
||
$sth->execute($params);
|
||
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle');
|
||
}
|
||
|
||
/**
|
||
* Return distinct empty bins in an aisle — for stock_in destination selector.
|
||
*
|
||
* @param int $warehouse_id The warehouse to query.
|
||
* @param string $zone Zone to filter by.
|
||
* @param string $aisle Aisle to filter by.
|
||
* @return array Flat array of bin values (strings).
|
||
*/
|
||
public function getBinsIn(int $warehouse_id, string $zone, string $aisle): array
|
||
{
|
||
if ($zone === '' || $aisle === '') {
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT DISTINCT bin FROM md_bin
|
||
WHERE company_id = :company_id
|
||
AND warehouse = :warehouse
|
||
AND product_sku IS NULL
|
||
ORDER BY CAST(bin AS UNSIGNED), bin"
|
||
);
|
||
$sth->execute([
|
||
':company_id' => $this->company_id,
|
||
':warehouse' => $warehouse_id,
|
||
]);
|
||
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'bin');
|
||
}
|
||
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT DISTINCT bin FROM md_bin
|
||
WHERE company_id = :company_id
|
||
AND warehouse = :warehouse
|
||
AND zone = :zone
|
||
AND aisle = :aisle
|
||
AND product_sku IS NULL
|
||
ORDER BY CAST(bin AS UNSIGNED), bin"
|
||
);
|
||
$sth->execute([
|
||
':company_id' => $this->company_id,
|
||
':warehouse' => $warehouse_id,
|
||
':zone' => $zone,
|
||
':aisle' => $aisle,
|
||
]);
|
||
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'bin');
|
||
}
|
||
|
||
/**
|
||
* Return distinct bins holding active stock for a sku/lot/serial in an aisle — for stock_out source.
|
||
*
|
||
* @param int $warehouse_id The warehouse to query.
|
||
* @param string $zone Zone to filter by.
|
||
* @param string $aisle Aisle to filter by.
|
||
* @param string $product_sku SKU to filter by.
|
||
* @param string|null $lot_number Optional lot filter.
|
||
* @param string|null $serial_number Optional serial filter.
|
||
* @return array Flat array of bin values (strings).
|
||
*/
|
||
public function getBinsOut(
|
||
int $warehouse_id,
|
||
string $zone,
|
||
string $aisle,
|
||
string $product_sku,
|
||
?string $lot_number = null,
|
||
?string $serial_number = null
|
||
): array {
|
||
$table = $this->resolveWarehouseTable($warehouse_id);
|
||
if (!$table) return [];
|
||
|
||
[$lot_cond, $serial_cond, $params] = $this->buildLotSerialCondition(
|
||
$warehouse_id, $product_sku, $lot_number, $serial_number
|
||
);
|
||
$location_cond = '';
|
||
if ($zone !== '' && $aisle !== '') {
|
||
$location_cond = "AND r.zone = :zone AND r.aisle = :aisle";
|
||
$params[':zone'] = $zone;
|
||
$params[':aisle'] = $aisle;
|
||
}
|
||
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT DISTINCT r.bin
|
||
FROM md_bin r
|
||
WHERE r.company_id = :company_id
|
||
AND r.warehouse = :warehouse
|
||
AND r.product_sku = :product_sku
|
||
{$location_cond}
|
||
AND r.td_stock_id IN (
|
||
SELECT s.id FROM `{$table}` s
|
||
WHERE s.company_id = :company_id2
|
||
AND s.product_sku = :product_sku2
|
||
AND s.`in` > 0
|
||
AND s.status = 1
|
||
{$lot_cond}
|
||
{$serial_cond}
|
||
)
|
||
ORDER BY CAST(r.bin AS UNSIGNED), r.bin"
|
||
);
|
||
$sth->execute($params);
|
||
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'bin');
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// TRANSACTION BASIS — Stock deletion (reversal)
|
||
// ─────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Soft-delete a stock_in record and reverse all its side effects.
|
||
*
|
||
* Steps:
|
||
* 1. Validates this is the globally latest transaction for the SKU.
|
||
* 2. Soft-deletes the td_stock row (negates company_id).
|
||
* 3. Releases the reserved/occupied bin back to empty.
|
||
* 4. Reverses the balance only if the row was approved.
|
||
*
|
||
* Must be called inside a DB transaction by the caller.
|
||
*
|
||
* @param int $stock_id The td_stock_<warehouse_id>.id of the row to delete.
|
||
* @param int $warehouse_id The warehouse the row belongs to.
|
||
* @throws Exception If not the latest transaction or record not found.
|
||
*/
|
||
public function deleteStockIn(int $stock_id, int $warehouse_id): void {
|
||
|
||
$ctx = $this->getStockContext($warehouse_id, $stock_id);
|
||
$table = $ctx['table'];
|
||
$row = $ctx['row'];
|
||
|
||
if (!$row) {
|
||
throw new Exception("Stock in record not found.");
|
||
}
|
||
|
||
$product_sku = $row['product_sku'];
|
||
$product_name = $this->getProductName($product_sku);
|
||
|
||
if ((int)$row['status'] === 1) {
|
||
$this->assertStockMovementWindow($row['date'] ?? null, 'Stock-in deletion');
|
||
}
|
||
|
||
$this->validateLatestTransaction($product_sku, $row['date'], $product_name);
|
||
|
||
// Soft-delete: negate company_id so row is hidden but recoverable
|
||
$this->pdo->prepare(
|
||
"UPDATE `$table`
|
||
SET company_id = company_id * -1
|
||
WHERE id = :id AND company_id = :company_id"
|
||
)->execute([':id' => $stock_id, ':company_id' => $this->company_id]);
|
||
|
||
// Stock-in reserves the bin at creation time, even while draft.
|
||
// Balance is only applied for approved rows.
|
||
$this->releaseBin($warehouse_id, $row['zone'], $row['aisle'], $row['bin']);
|
||
$this->softDeleteBinLogs(
|
||
$warehouse_id,
|
||
$row['zone'], $row['aisle'], $row['bin'],
|
||
['occupy', 'release'],
|
||
$product_sku,
|
||
[$stock_id],
|
||
$row['date']
|
||
);
|
||
|
||
if ((int)$row['status'] === 1) {
|
||
$this->adjustBalance('in', $warehouse_id, $product_sku, (int)$row['in'], 0, 0, '', 0, $row['date'] ?? '');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Soft-delete a stock_out record and reverse all its side effects.
|
||
*
|
||
* Steps:
|
||
* 1. Validates this is the globally latest transaction for the SKU.
|
||
* 2. Soft-deletes the td_stock row (negates company_id).
|
||
* 3. Re-occupies the bin with the original stock_in batch (via ref_id).
|
||
* 4. Reverses the balance (adjustBalance with new_qty = 0).
|
||
*
|
||
* Must be called inside a DB transaction by the caller.
|
||
*
|
||
* @param int $stock_id The td_stock_<warehouse_id>.id of the row to delete.
|
||
* @param int $warehouse_id The warehouse the row belongs to.
|
||
* @throws Exception If not the latest transaction or record not found.
|
||
*/
|
||
public function deleteStockOut(int $stock_id, int $warehouse_id): void {
|
||
|
||
$ctx = $this->getStockContext($warehouse_id, $stock_id);
|
||
$table = $ctx['table'];
|
||
$row = $ctx['row'];
|
||
|
||
if (!$row) {
|
||
throw new Exception("Stock out record not found.");
|
||
}
|
||
|
||
$product_sku = $row['product_sku'];
|
||
$product_name = $this->getProductName($product_sku);
|
||
|
||
if ((int)$row['status'] === 1) {
|
||
$this->assertStockMovementWindow($row['date'] ?? null, 'Stock-out deletion');
|
||
}
|
||
|
||
$this->validateLatestTransaction($product_sku, $row['date'], $product_name);
|
||
|
||
// Soft-delete: negate company_id so row is hidden but recoverable
|
||
$this->pdo->prepare(
|
||
"UPDATE `$table`
|
||
SET company_id = company_id * -1
|
||
WHERE id = :id AND company_id = :company_id"
|
||
)->execute([':id' => $stock_id, ':company_id' => $this->company_id]);
|
||
|
||
// Only reverse side effects if the row was approved — draft rows never had them applied
|
||
if ((int)$row['status'] === 1) {
|
||
// Re-occupy the bin with the original stock_in batch that was consumed
|
||
$this->occupyBin(
|
||
$warehouse_id,
|
||
$row['zone'], $row['aisle'], $row['bin'],
|
||
$product_sku,
|
||
(int)$row['ref_id']
|
||
);
|
||
$this->softDeleteBinLogs(
|
||
$warehouse_id,
|
||
$row['zone'], $row['aisle'], $row['bin'],
|
||
['release', 'occupy'],
|
||
$product_sku,
|
||
[(int)$row['ref_id']],
|
||
$row['date']
|
||
);
|
||
$this->adjustBalance('out', $warehouse_id, $product_sku, (int)$row['out'], 0, 0, '', 0, $row['date'] ?? '');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Soft-delete a transfer record pair and reverse all side effects on both warehouses.
|
||
*
|
||
* Steps:
|
||
* 1. Validates this is the globally latest transaction for the SKU.
|
||
* 2. Soft-deletes both the from and to rows (negates company_id on each).
|
||
* 3. Releases the destination bin (which was occupied by the transfer_in).
|
||
* 4. Re-occupies the source bin with the original from_stock_id batch.
|
||
* 5. Reverses balances on both warehouses.
|
||
*
|
||
* Must be called inside a DB transaction by the caller.
|
||
*
|
||
* @param int $from_stock_id The td_stock_<from>.id of the outbound transfer row.
|
||
* @param int $from_warehouse_id The source warehouse the outbound row belongs to.
|
||
* @throws Exception If not the latest transaction, or if either row is missing.
|
||
*/
|
||
public function deleteTransfer(int $from_stock_id, int $from_warehouse_id): void {
|
||
|
||
// Fetch the outbound (from) row
|
||
$from_ctx = $this->getStockContext($from_warehouse_id, $from_stock_id);
|
||
$from_table = $from_ctx['table'];
|
||
$from_row = $from_ctx['row'];
|
||
|
||
if (!$from_row || $from_row['type'] !== 'transfer') {
|
||
throw new Exception("Transfer record not found.");
|
||
}
|
||
|
||
$product_sku = $from_row['product_sku'];
|
||
$product_name = $this->getProductName($product_sku);
|
||
$to_warehouse = (int)$from_row['ref_warehouse'];
|
||
$ref_id = (int)$from_row['ref_id'];
|
||
|
||
// Fetch the inbound (to) row
|
||
$to_ctx = $this->getStockContext($to_warehouse, $ref_id);
|
||
$to_row = $to_ctx['row'];
|
||
|
||
if (!$to_row) {
|
||
throw new Exception("Paired destination record missing — data integrity issue.");
|
||
}
|
||
|
||
if ((int)$from_row['status'] === 1 || (int)$to_row['status'] === 1) {
|
||
$this->assertStockMovementWindow($from_row['date'] ?? null, 'Stock transfer cancellation');
|
||
$this->assertStockMovementWindow($to_row['date'] ?? null, 'Stock transfer cancellation');
|
||
}
|
||
|
||
// Single global validation covers both warehouses (same SKU, same date)
|
||
$this->validateLatestTransaction($product_sku, $from_row['date'], $product_name);
|
||
|
||
// Soft-delete both rows
|
||
$this->pdo->prepare(
|
||
"UPDATE `$from_table`
|
||
SET company_id = company_id * -1
|
||
WHERE id = :id AND company_id = :company_id"
|
||
)->execute([':id' => $from_stock_id, ':company_id' => $this->company_id]);
|
||
|
||
$this->pdo->prepare(
|
||
"UPDATE `{$to_ctx['table']}`
|
||
SET company_id = company_id * -1
|
||
WHERE id = :id AND company_id = :company_id"
|
||
)->execute([':id' => $ref_id, ':company_id' => $this->company_id]);
|
||
|
||
// Only reverse side effects if approved — draft rows never had them applied
|
||
if ((int)$from_row['status'] === 1) {
|
||
// Destination bin was occupied by transfer_in — release it
|
||
$this->releaseBin(
|
||
$to_warehouse,
|
||
$to_row['zone'], $to_row['aisle'], $to_row['bin']
|
||
);
|
||
|
||
// Source bin was released by transfer_out — re-occupy with original batch
|
||
$this->occupyBin(
|
||
$from_warehouse_id,
|
||
$from_row['zone'], $from_row['aisle'], $from_row['bin'],
|
||
$product_sku,
|
||
$from_stock_id
|
||
);
|
||
$this->softDeleteBinLogs(
|
||
$to_warehouse,
|
||
$to_row['zone'], $to_row['aisle'], $to_row['bin'],
|
||
['occupy', 'release'],
|
||
$product_sku,
|
||
[$ref_id],
|
||
$from_row['date']
|
||
);
|
||
$this->softDeleteBinLogs(
|
||
$from_warehouse_id,
|
||
$from_row['zone'], $from_row['aisle'], $from_row['bin'],
|
||
['release', 'occupy'],
|
||
$product_sku,
|
||
[],
|
||
$from_row['date']
|
||
);
|
||
|
||
// Reverse balances on both sides
|
||
$quantity = (int)$from_row['out'];
|
||
$this->adjustBalance('out', $from_warehouse_id, $product_sku, $quantity, 0, 0, '', 0, $from_row['date'] ?? '');
|
||
$this->adjustBalance('in', $to_warehouse, $product_sku, $quantity, 0, 0, '', 0, $from_row['date'] ?? '');
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// REPORT BASIS — Warehouse list queries
|
||
// ─────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Return warehouse ids that currently hold an approved, bin-occupied stock-in
|
||
* row for a given product_sku + lot_number combination.
|
||
*
|
||
* Used to filter the warehouse dropdown in stock-out/transfer when a lot is selected,
|
||
* so only warehouses actually holding that lot are shown.
|
||
*
|
||
* @param string $product_sku
|
||
* @param string $lot_number
|
||
* @return int[] Array of warehouse ids.
|
||
*/
|
||
public function getWarehousesByLot(string $product_sku, string $lot_number): array
|
||
{
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT id, warehouse_name FROM md_warehouse
|
||
WHERE company_id = :company_id AND status = 1"
|
||
);
|
||
$sth->execute([':company_id' => $this->company_id]);
|
||
$warehouses = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||
|
||
$result = [];
|
||
$cid = (int)$this->company_id;
|
||
|
||
foreach ($warehouses as $wh) {
|
||
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
|
||
$wh_id = (int)$wh['id'];
|
||
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT 1 FROM `{$table}` s
|
||
WHERE s.company_id = :company_id
|
||
AND s.product_sku = :product_sku
|
||
AND s.lot_number = :lot_number
|
||
AND s.`in` > 0
|
||
AND s.status = 1
|
||
AND EXISTS (
|
||
SELECT 1 FROM md_bin r
|
||
WHERE r.company_id = s.company_id
|
||
AND r.warehouse = :warehouse_id
|
||
AND r.td_stock_id = s.id
|
||
AND r.product_sku IS NOT NULL
|
||
)
|
||
LIMIT 1"
|
||
);
|
||
$sth->execute([
|
||
':company_id' => $cid,
|
||
':product_sku' => $product_sku,
|
||
':lot_number' => $lot_number,
|
||
':warehouse_id' => $wh_id,
|
||
]);
|
||
|
||
if ($sth->fetchColumn() !== false) {
|
||
$result[] = ['id' => $wh_id, 'warehouse_name' => $wh['warehouse_name']];
|
||
}
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* Return warehouses filtered by bin availability — for stock movement warehouse selectors.
|
||
*
|
||
* Used on stock_in / stock_out / transfer forms to present only valid warehouse options:
|
||
* type = 'to' → warehouses with at least one empty bin (valid destination)
|
||
* type = 'from' → warehouses holding the given product_sku (valid source)
|
||
* other → all warehouses with any bins
|
||
*
|
||
* Passing $id > 0 bypasses the occupancy filter (edit mode — warehouse is already set).
|
||
*
|
||
* @param string $type 'to', 'from', or any other string for unfiltered.
|
||
* @param string $product_sku SKU filter used when type = 'from'.
|
||
* @param int $id If > 0, skips the filter (edit mode).
|
||
* @return array md_warehouse rows (id, warehouse_name) ordered by name.
|
||
*/
|
||
public function getWarehouseList(string $type, string $product_sku = '', int $id = 0): array
|
||
{
|
||
if (!$id && $type === 'from') {
|
||
if ($product_sku === '') {
|
||
return [];
|
||
}
|
||
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT id, warehouse_name FROM md_warehouse
|
||
WHERE company_id = :company_id AND status = 1
|
||
ORDER BY warehouse_name"
|
||
);
|
||
$sth->execute([':company_id' => $this->company_id]);
|
||
$warehouses = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||
|
||
$result = [];
|
||
foreach ($warehouses as $wh) {
|
||
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
|
||
|
||
$stock = $this->pdo->prepare(
|
||
"SELECT 1
|
||
FROM md_bin r
|
||
INNER JOIN `{$table}` s
|
||
ON s.company_id = r.company_id
|
||
AND s.id = r.td_stock_id
|
||
WHERE r.company_id = :company_id
|
||
AND r.warehouse = :warehouse
|
||
AND r.product_sku = :product_sku
|
||
AND s.product_sku = :product_sku2
|
||
AND s.`in` > 0
|
||
AND s.status = 1
|
||
LIMIT 1"
|
||
);
|
||
$stock->execute([
|
||
':company_id' => $this->company_id,
|
||
':warehouse' => (int)$wh['id'],
|
||
':product_sku' => $product_sku,
|
||
':product_sku2' => $product_sku,
|
||
]);
|
||
|
||
if ($stock->fetchColumn() !== false) {
|
||
$result[] = $wh;
|
||
}
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
$product_sku_filter = '';
|
||
$params = [':company_id' => $this->company_id];
|
||
|
||
if (!$id) {
|
||
if ($type === 'to') {
|
||
$product_sku_filter = 'AND r.product_sku IS NULL';
|
||
} elseif ($type === 'from') {
|
||
$product_sku_filter = 'AND r.product_sku = :product_sku';
|
||
$params[':product_sku'] = $product_sku;
|
||
}
|
||
}
|
||
|
||
$sth = $this->pdo->prepare(
|
||
"SELECT w.id, w.warehouse_name
|
||
FROM md_warehouse w
|
||
WHERE w.company_id = :company_id
|
||
AND EXISTS (
|
||
SELECT 1 FROM md_bin r
|
||
WHERE r.company_id = w.company_id
|
||
AND r.warehouse = w.id
|
||
$product_sku_filter
|
||
)
|
||
ORDER BY w.warehouse_name"
|
||
);
|
||
$sth->execute($params);
|
||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||
}
|
||
}
|