Change 'Rack' to 'Bin'

This commit is contained in:
Thanakorn S
2026-05-27 17:14:53 +07:00
parent b8798bc02d
commit 8f57ab5570
41 changed files with 931 additions and 933 deletions
+26 -26
View File
@@ -121,17 +121,17 @@ class BarcodeManager {
}
/**
* Confirm a rack row exists for the given warehouse + zone + aisle + rack.
* Confirm a bin row exists for the given warehouse + zone + aisle + bin.
* Throws if not found.
*/
private function validateLocation(int $warehouse_id, string $zone, string $aisle, string $rack): void {
private function validateLocation(int $warehouse_id, string $zone, string $aisle, string $bin): void {
$sth = $this->pdo->prepare(
"SELECT 1 FROM md_rack
"SELECT 1 FROM md_bin
WHERE company_id = :company_id
AND warehouse = :warehouse
AND COALESCE(zone, '') = :zone
AND COALESCE(aisle,'') = :aisle
AND rack = :rack
AND bin = :bin
LIMIT 1"
);
$sth->execute([
@@ -139,7 +139,7 @@ class BarcodeManager {
':warehouse' => $warehouse_id,
':zone' => $zone,
':aisle' => $aisle,
':rack' => $rack,
':bin' => $bin,
]);
if (!$sth->fetchColumn()) throw new Exception('Location not found.');
}
@@ -343,7 +343,7 @@ class BarcodeManager {
*/
public function listLocationLabels(): array {
$sth = $this->pdo->prepare(
"SELECT barcode, warehouse_id, zone, aisle, rack, status, print_count, last_printed_dt
"SELECT barcode, warehouse_id, zone, aisle, bin, status, print_count, last_printed_dt
FROM md_barcode
WHERE company_id = :company_id
AND barcode_type = 'loc'"
@@ -355,30 +355,30 @@ class BarcodeManager {
/**
* Create a new location barcode label (or re-activate a previously disabled one).
*
* Validates that the rack exists in md_rack before inserting.
* Validates that the bin exists in md_bin before inserting.
*
* @param string $barcode Full barcode string (e.g. "LOC|1|A|1|R1").
* @param int $warehouse_id Warehouse ID.
* @param string $zone Zone code.
* @param string $aisle Aisle code.
* @param string $rack Rack code.
* @return array Keys: barcode, warehouse_id, zone, aisle, rack.
* @throws Exception If the location is not found in md_rack.
* @param string $bin Bin code.
* @return array Keys: barcode, warehouse_id, zone, aisle, bin.
* @throws Exception If the location is not found in md_bin.
*/
public function createLocationLabel(string $barcode, int $warehouse_id, string $zone, string $aisle, string $rack): array {
$this->validateLocation($warehouse_id, $zone, $aisle, $rack);
public function createLocationLabel(string $barcode, int $warehouse_id, string $zone, string $aisle, string $bin): array {
$this->validateLocation($warehouse_id, $zone, $aisle, $bin);
$this->pdo->prepare(
"INSERT INTO md_barcode
(company_id, barcode_type, barcode, warehouse_id, zone, aisle, rack)
(company_id, barcode_type, barcode, warehouse_id, zone, aisle, bin)
VALUES
(:company_id, 'loc', :barcode, :warehouse_id, :zone, :aisle, :rack)
(:company_id, 'loc', :barcode, :warehouse_id, :zone, :aisle, :bin)
ON DUPLICATE KEY UPDATE
barcode_type = VALUES(barcode_type),
warehouse_id = VALUES(warehouse_id),
zone = VALUES(zone),
aisle = VALUES(aisle),
rack = VALUES(rack),
bin = VALUES(bin),
status = 1"
)->execute([
':company_id' => $this->company_id,
@@ -386,7 +386,7 @@ class BarcodeManager {
':warehouse_id' => $warehouse_id,
':zone' => $zone,
':aisle' => $aisle,
':rack' => $rack,
':bin' => $bin,
]);
return [
@@ -394,7 +394,7 @@ class BarcodeManager {
'warehouse_id' => $warehouse_id,
'zone' => $zone,
'aisle' => $aisle,
'rack' => $rack,
'bin' => $bin,
];
}
@@ -465,7 +465,7 @@ class BarcodeManager {
* resolve it from the caller via CompanySettingManager::get('advanced_location').
*
* @param string $barcode Full barcode string to resolve.
* @param bool $advancedLocation True = zone/aisle/rack parts; false = rack only.
* @param bool $advancedLocation True = zone/aisle/bin parts; false = bin only.
* @return array
* @throws Exception On parse or validation failure.
*/
@@ -515,13 +515,13 @@ class BarcodeManager {
if ($advancedLocation) {
$zone = trim($parts[2]);
$aisle = trim($parts[3]);
$rack = trim($parts[4]);
$bin = trim($parts[4]);
} else {
$rack = trim($parts[2]);
$zone = $rack;
$aisle = $rack;
$bin = trim($parts[2]);
$zone = $bin;
$aisle = $bin;
}
if (!$warehouse_id || $zone === '' || $aisle === '' || $rack === '') {
if (!$warehouse_id || $zone === '' || $aisle === '' || $bin === '') {
throw new Exception('Location label is incomplete.');
}
@@ -531,19 +531,19 @@ class BarcodeManager {
(int)$label['warehouse_id'] !== $warehouse_id ||
$label['zone'] !== $zone ||
$label['aisle'] !== $aisle ||
$label['rack'] !== $rack
$label['bin'] !== $bin
) {
throw new Exception('Location barcode label does not match its registry.');
}
$this->validateLocation($warehouse_id, $zone, $aisle, $rack);
$this->validateLocation($warehouse_id, $zone, $aisle, $bin);
return [
'type' => 'loc',
'warehouse_id' => $warehouse_id,
'zone' => $zone,
'aisle' => $aisle,
'rack' => $rack,
'bin' => $bin,
];
}
@@ -8,8 +8,8 @@
* default_stock_status int 0 = draft, 1 = auto-approve on save
* auto_invoice_and_credit_note int 0 = manual, 1 = auto-generate
* auto_complete_on_ship int 0 = manual, 1 = auto-complete on shipped
* advanced_location int 0 = simple (single location), 1 = zone/aisle/rack
* location_label_rack string label for rack / single location field
* advanced_location int 0 = simple (single location), 1 = zone/aisle/bin
* location_label_bin string label for bin / single location field
* location_label_zone string label for zone (advanced only)
* location_label_aisle string label for aisle (advanced only)
*
@@ -27,7 +27,7 @@ class CompanySettingManager
'auto_invoice_and_credit_note' => 0, // manual by default
'auto_complete_on_ship' => 1, // auto-complete by default
'advanced_location' => 0, // simple mode by default
'location_label_rack' => 'Location', // single label default
'location_label_bin' => 'Location', // single label default
'location_label_zone' => 'Zone', // advanced level 1 default
'location_label_aisle' => 'Aisle', // advanced level 2 default
'posting_open_from' => '', // YYYY-MM-DD; empty = no lower bound
+43 -43
View File
@@ -17,19 +17,19 @@ require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
* - Order items are stored as a JSON array in td_order.items.
* No separate child table exists. SKU-level reporting is served by
* td_stock_* rows (source='order') rather than querying items JSON.
* - confirmOrder() picks racks via FIFO — oldest approved stock-in row
* still rack-occupied, ordered by td_stock_<warehouse_id>.date ASC.
* - confirmOrder() picks bins via FIFO — oldest approved stock-in row
* still bin-occupied, ordered by td_stock_<warehouse_id>.date ASC.
* - confirmOrder() creates stock-out rows with status=0 (draft).
* Warehouse staff approve them via the existing approve_stock.php
* engine, which handles rack release and balance adjustment.
* engine, which handles bin release and balance adjustment.
* - cancelOrder() sets td_order.status = -1 and soft-deletes ALL linked
* stock-out rows (status → -1) across all td_stock_* tables. If linked
* stock-out rows were already approved, their rack and balance effects are
* stock-out rows were already approved, their bin and balance effects are
* reversed before the rows are cancelled.
* Cancellation is blocked if any active invoice (status != 4 / void) or
* active return (status != -1 / cancelled) is linked to the order.
* - Cancel logic stays in the order domain, but uses WarehouseManager for
* the same rack and balance reversal rules as manual stock-out deletion.
* the same bin and balance reversal rules as manual stock-out deletion.
*
* Note: Write methods do NOT manage their own DB transactions.
* Callers must wrap multi-step operations inside dbTransaction().
@@ -78,15 +78,15 @@ class OrderManager {
}
/**
* FIFO rack pick — find the oldest approved stock-in row for a SKU
* in a given warehouse that is still rack-occupied.
* FIFO bin pick — find the oldest approved stock-in row for a SKU
* in a given warehouse that is still bin-occupied.
*
* "Oldest" = smallest date value among status=1 stock-in rows
* that have an md_rack row pointing back to them (td_stock_id IS set).
* that have an md_bin row pointing back to them (td_stock_id IS set).
*
* @param int $warehouse_id
* @param string $product_sku
* @return array|null Full td_stock row + zone/aisle/rack from md_rack,
* @return array|null Full td_stock row + zone/aisle/bin from md_bin,
* or null if no available stock in this warehouse.
*/
private function pickFifoRack(int $warehouse_id, string $product_sku): ?array
@@ -94,7 +94,7 @@ class OrderManager {
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
$sth = $this->pdo->prepare(
"SELECT s.*, r.zone, r.aisle, r.rack, l.expiry_date,
"SELECT s.*, r.zone, r.aisle, r.bin, l.expiry_date,
(s.`in` - COALESCE((
SELECT SUM(o.`out`)
FROM `{$table}` o
@@ -104,7 +104,7 @@ class OrderManager {
AND o.status != -1
), 0)) AS available_qty
FROM `{$table}` s
INNER JOIN md_rack r
INNER JOIN md_bin r
ON r.company_id = s.company_id
AND r.warehouse = :warehouse_id
AND r.td_stock_id = s.id
@@ -358,7 +358,7 @@ class OrderManager {
if ($stock_out_warehouse_id > 0 && $stock_out_id > 0) {
$table = $this->stockTableNameFromWarehouseId($stock_out_warehouse_id);
$sth = $this->pdo->prepare(
"SELECT id, status, zone, aisle, rack, lot_number, serial_number
"SELECT id, status, zone, aisle, bin, lot_number, serial_number
FROM `{$table}`
WHERE company_id = :company_id
AND id = :id
@@ -374,7 +374,7 @@ class OrderManager {
if ($stock_out) {
$item['zone'] = $stock_out['zone'] ?? ($item['zone'] ?? '');
$item['aisle'] = $stock_out['aisle'] ?? ($item['aisle'] ?? '');
$item['rack'] = $stock_out['rack'] ?? ($item['rack'] ?? '');
$item['bin'] = $stock_out['bin'] ?? ($item['bin'] ?? '');
$item['lot_number'] = $stock_out['lot_number'] ?? ($item['lot_number'] ?? '');
$item['serial_number'] = $stock_out['serial_number'] ?? ($item['serial_number'] ?? '');
$item['stock_out_status'] = (int)$stock_out['status'];
@@ -599,10 +599,10 @@ class OrderManager {
/**
* Confirm a draft order — create stock-out rows (status=0) for each item
* using FIFO rack selection, then advance the order to status=1.
* using FIFO bin selection, then advance the order to status=1.
*
* Flow per item:
* 1. pickFifoRack() — find oldest rack-occupied stock-in row for the SKU.
* 1. pickFifoRack() — find oldest bin-occupied stock-in row for the SKU.
* 2. INSERT into td_stock_<warehouse_id> with type='out', status=0,
* source='order', source_id=order_id.
* 3. Write back stock_out_id into the item object in td_order.items.
@@ -611,7 +611,7 @@ class OrderManager {
* 4. UPDATE td_order.items with stock_out_id values written back.
* 5. UPDATE td_order.status = 1 (confirmed).
*
* Rack release and balance adjustment are intentionally deferred —
* Bin release and balance adjustment are intentionally deferred —
* they happen when warehouse staff approve the stock-out rows via
* the existing approve_stock.php engine (StockManager::approveStock).
*
@@ -621,7 +621,7 @@ class OrderManager {
* @param string $uuid UUID prefix — each stock-out row gets uuid_{$i}.
* @param array $logging Audit entry appended to td_order.log.
* @throws Exception If order not found, not in draft status, or any item
* has no available FIFO rack in its assigned warehouse.
* has no available FIFO bin in its assigned warehouse.
*/
public function confirmOrder(int $order_id, string $uuid, array $logging, bool $auto_approve = false): void
{
@@ -668,22 +668,22 @@ class OrderManager {
continue;
}
$rack_stock = $this->pickFifoRack($warehouse_id, $product_sku);
if (!$rack_stock) {
$bin_stock = $this->pickFifoRack($warehouse_id, $product_sku);
if (!$bin_stock) {
$preflight_errors[] = "No available stock for \"{$name}\" in the selected warehouse.";
continue;
}
$available_qty = (float)($rack_stock['available_qty'] ?? $rack_stock['in'] ?? 0);
$available_qty = (float)($bin_stock['available_qty'] ?? $bin_stock['in'] ?? 0);
if ($quantity - $available_qty > 0.000001) {
$preflight_errors[] =
"Insufficient stock for \"{$name}\": " .
"{$available_qty} available, {$quantity} requested.";
}
$expiry = $rack_stock['expiry_date'] ?? null;
$expiry = $bin_stock['expiry_date'] ?? null;
if ($expiry && strtotime($expiry) < strtotime(date('Y-m-d'))) {
$lot = $rack_stock['lot_number'] ?? '';
$lot = $bin_stock['lot_number'] ?? '';
$preflight_errors[] =
"Lot {$lot} for \"{$name}\" expired on {$expiry} and cannot be picked.";
}
@@ -699,8 +699,8 @@ class OrderManager {
$product_sku = $item['product_sku'] ?? '';
$quantity = (float)($item['quantity'] ?? 0);
$rack_stock = $this->pickFifoRack($warehouse_id, $product_sku);
$available_qty = (float)($rack_stock['available_qty'] ?? $rack_stock['in'] ?? 0);
$bin_stock = $this->pickFifoRack($warehouse_id, $product_sku);
$available_qty = (float)($bin_stock['available_qty'] ?? $bin_stock['in'] ?? 0);
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
$item_uuid = $uuid . '_' . $i;
@@ -709,13 +709,13 @@ class OrderManager {
$this->pdo->prepare(
"INSERT INTO `{$table}`
(uuid, company_id, `date`, product_sku, `out`,
zone, aisle, rack,
zone, aisle, bin,
contact_id, `description`, `log`, `type`,
ref_id, lot_number, serial_number,
source, source_id, price, status)
VALUES
(:uuid, :company_id, :date, :product_sku, :quantity,
:zone, :aisle, :rack,
:zone, :aisle, :bin,
:contact_id, :description, :log, 'out',
:ref_id, :lot_number, :serial_number,
'order', :source_id, :price, 0)"
@@ -725,28 +725,28 @@ class OrderManager {
':date' => date('Y-m-d H:i:s'),
':product_sku' => $product_sku,
':quantity' => $quantity,
':zone' => $rack_stock['zone'],
':aisle' => $rack_stock['aisle'],
':rack' => $rack_stock['rack'],
':zone' => $bin_stock['zone'],
':aisle' => $bin_stock['aisle'],
':bin' => $bin_stock['bin'],
':contact_id' => (int)$order['contact_id'],
':description' => $order['order_number'],
':log' => json_encode($item_log),
':ref_id' => (int)$rack_stock['id'],
':lot_number' => $rack_stock['lot_number'] ?? '',
':serial_number' => $rack_stock['serial_number'] ?? '',
':ref_id' => (int)$bin_stock['id'],
':lot_number' => $bin_stock['lot_number'] ?? '',
':serial_number' => $bin_stock['serial_number'] ?? '',
':source_id' => $order_id,
':price' => (float)($item['unit_price'] ?? 0),
]);
// Write rack context + stock_out_id back into the item object
$item['zone'] = $rack_stock['zone'];
$item['aisle'] = $rack_stock['aisle'];
$item['rack'] = $rack_stock['rack'];
$item['lot_number'] = $rack_stock['lot_number'] ?? '';
$item['serial_number'] = $rack_stock['serial_number'] ?? '';
// Write bin context + stock_out_id back into the item object
$item['zone'] = $bin_stock['zone'];
$item['aisle'] = $bin_stock['aisle'];
$item['bin'] = $bin_stock['bin'];
$item['lot_number'] = $bin_stock['lot_number'] ?? '';
$item['serial_number'] = $bin_stock['serial_number'] ?? '';
$item['stock_out_id'] = (int)$this->pdo->lastInsertId();
// Auto-approve: immediately release rack + adjust balance
// Auto-approve: immediately release bin + adjust balance
if ($auto_approve) {
$stock = new StockManager($this->pdo, $this->company_id);
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
@@ -802,7 +802,7 @@ class OrderManager {
* - td_return where order_id = $order_id AND status != -1
*
* Stock-out rows are children of the order — they follow the parent.
* On cancel, approved rows first re-occupy the original source rack and
* On cancel, approved rows first re-occupy the original source bin and
* reverse the stock-out balance. Then ALL stock-out rows linked to this
* order (any status except already -1) are soft-deleted (status → -1)
* across all td_stock_* tables.
@@ -882,7 +882,7 @@ class OrderManager {
$warehouse_id = (int)$matches[1];
$row_sth = $this->pdo->prepare(
"SELECT id, product_sku, `out`, zone, aisle, rack, ref_id, status, `date`
"SELECT id, product_sku, `out`, zone, aisle, bin, ref_id, status, `date`
FROM `{$table}`
WHERE company_id = :company_id
AND source = 'order'
@@ -903,11 +903,11 @@ class OrderManager {
$whMgmt->assertStockMovementWindow($row['date'] ?? null, 'Order stock cancellation');
$whMgmt->occupyRack(
$whMgmt->occupyBin(
$warehouse_id,
(string)$row['zone'],
(string)$row['aisle'],
(string)$row['rack'],
(string)$row['bin'],
(string)$row['product_sku'],
(int)$row['ref_id']
);
+7 -7
View File
@@ -534,18 +534,18 @@ class ProductManager {
// ─────────────────────────────────────────────────────────────
/**
* Return all rack slots across all warehouses with occupancy status,
* Return all bin slots across all warehouses with occupancy status,
* warehouse name, and product name.
*
* Used by the rack occupancy dashboard to visualise which racks are
* Used by the bin occupancy dashboard to visualise which bins are
* empty vs. occupied, and which product is in each slot.
* Results are ordered by warehouse → zone → aisle → rack, with
* Results are ordered by warehouse → zone → aisle → bin, with
* numeric-first sorting via CAST so e.g. "2" sorts before "10".
*
* Security fix: was previously using $this->companyId (undefined property),
* corrected to $this->company_id.
*
* @return array All md_rack rows joined to warehouse and product, with 'status' field.
* @return array All md_bin rows joined to warehouse and product, with 'status' field.
*/
public function getRackOccupancy(): array
{
@@ -554,14 +554,14 @@ class ProductManager {
r.id,
r.zone,
r.aisle,
r.rack,
r.bin,
r.product_sku,
r.td_stock_id,
mw.warehouse_name,
mw.id AS warehouse_id,
p.product_name,
CASE WHEN r.product_sku IS NOT NULL THEN 'occupied' ELSE 'empty' END AS status
FROM md_rack r
FROM md_bin r
INNER JOIN md_warehouse mw
ON mw.company_id = r.company_id
AND mw.id = r.warehouse
@@ -571,7 +571,7 @@ class ProductManager {
WHERE r.company_id = :company_id
ORDER BY mw.warehouse_name, r.zone,
CAST(r.aisle AS UNSIGNED), r.aisle,
CAST(r.rack AS UNSIGNED), r.rack"
CAST(r.bin AS UNSIGNED), r.bin"
);
$sth->execute([':company_id' => $this->company_id]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
@@ -25,7 +25,7 @@ require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
* Receipt progress (pending/partial/received) is derived via deriveReceiptStatus(),
* same pattern as fulfillment_status on SO. Never stored in td_purchase_order.
* - cancelPo() is blocked if any linked stock-in rows have been approved (status=1),
* because those have already modified rack and balance.
* because those have already modified bin and balance.
* - Write methods do NOT manage their own DB transactions.
* Callers must wrap multi-step operations inside dbTransaction().
*
@@ -274,7 +274,7 @@ class PurchaseOrderManager {
'warehouse_id' => (int)($item['receive_wh'] ?? 0),
'zone' => $item['receive_zone'] ?? '',
'aisle' => $item['receive_aisle'] ?? '',
'rack' => $item['receive_rack'] ?? '',
'bin' => $item['receive_bin'] ?? '',
]);
}
}
@@ -492,7 +492,7 @@ class PurchaseOrderManager {
*
* @param int $po_id td_purchase_order.id
* @param array $receive_items Each item: { item_id, product_sku, warehouse_id,
* quantity, zone, aisle, rack, lot_number,
* quantity, zone, aisle, bin, lot_number,
* expiry_date, serial_number, contact_id }
* @param string $uuid UUID prefix for stock-in rows.
* @param array $logging Audit entry.
@@ -573,16 +573,16 @@ class PurchaseOrderManager {
$item_uuid = $uuid . '_' . $j;
$item_log = array_merge($logging, ['action' => 'receive_item']);
$rack = $recv['rack'] ?? '';
$bin = $recv['bin'] ?? '';
$zone = $recv['zone'] ?? '';
$aisle = $recv['aisle'] ?? '';
// Simple location mode: zone and aisle must mirror the rack value
// Simple location mode: zone and aisle must mirror the bin value
// (same convention as manage_stock_in.php).
// occupyRack() looks up md_rack WHERE zone=:zone AND aisle=:aisle AND rack=:rack,
// so all three must match — a blank zone/aisle produces "Rack --b does not exist".
if ($zone === '' && $rack !== '') $zone = $rack;
if ($aisle === '' && $rack !== '') $aisle = $rack;
// occupyBin() looks up md_bin WHERE zone=:zone AND aisle=:aisle AND bin=:bin,
// so all three must match — a blank zone/aisle produces "Bin --b does not exist".
if ($zone === '' && $bin !== '') $zone = $bin;
if ($aisle === '' && $bin !== '') $aisle = $bin;
// Resolve unit_price: prefer the receive-time override, fall back to PO item price
$po_item_price = 0;
@@ -599,7 +599,7 @@ class PurchaseOrderManager {
'price' => $unit_price,
'zone' => $zone,
'aisle' => $aisle,
'rack' => $rack,
'bin' => $bin,
'lot_number' => $recv['lot_number'] ?? '',
'expiry_date' => $recv['expiry_date'] ?? '',
'serial_number' => $recv['serial_number'] ?? '',
@@ -626,7 +626,7 @@ class PurchaseOrderManager {
]);
// approveStock() handles balance updates. saveStockIn() has
// already reserved the rack so draft receipts cannot be reused.
// already reserved the bin so draft receipts cannot be reused.
if ($auto_approve) {
$stock->approveStock($new_stock_in_id, $warehouse_id, 'in', $whMgmt);
}
@@ -708,7 +708,7 @@ class PurchaseOrderManager {
* Cancel a PO.
*
* Blocked if any linked stock-in rows have already been approved (status=1)
* because those have modified rack occupancy and balance.
* because those have modified bin occupancy and balance.
*
* For draft stock-in rows (status=0), they are soft-deleted (status=-1).
*
@@ -756,7 +756,7 @@ class PurchaseOrderManager {
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
// Soft-delete draft stock-in rows and release their rack reservations.
// Soft-delete draft stock-in rows and release their bin reservations.
foreach ($tables as $table) {
if (!preg_match('/^td_stock_(\d+)$/', $table, $m)) {
continue;
@@ -764,7 +764,7 @@ class PurchaseOrderManager {
$warehouse_id = (int)$m[1];
$sth = $this->pdo->prepare(
"SELECT id, zone, aisle, rack
"SELECT id, zone, aisle, bin
FROM `{$table}`
WHERE company_id = :company_id
AND source = 'po'
@@ -783,11 +783,11 @@ class PurchaseOrderManager {
':company_id' => $this->company_id,
]);
$whMgmt->releaseRack(
$whMgmt->releaseBin(
$warehouse_id,
$row['zone'],
$row['aisle'],
$row['rack']
$row['bin']
);
}
}
@@ -894,21 +894,21 @@ class PurchaseOrderManager {
}
}
// Release racks from draft stock-in rows and negate them
// Release bins from draft stock-in rows and negate them
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
foreach ($tables as $table) {
if (!preg_match('/^td_stock_(\d+)$/', (string)$table, $matches)) continue;
$warehouse_id = (int)$matches[1];
$row_sth = $this->pdo->prepare(
"SELECT id, zone, aisle, rack FROM `{$table}`
"SELECT id, zone, aisle, bin FROM `{$table}`
WHERE company_id = :cid AND source = 'po' AND source_id = :id AND status = 0"
);
$row_sth->execute([':cid' => $this->company_id, ':id' => $po_id]);
$draft_rows = $row_sth->fetchAll(PDO::FETCH_ASSOC);
foreach ($draft_rows as $row) {
$whMgmt->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']);
$whMgmt->releaseBin($warehouse_id, $row['zone'], $row['aisle'], $row['bin']);
}
$this->pdo->prepare(
+35 -35
View File
@@ -12,7 +12,7 @@
* Dashboard reports → stats, movement charts, most moved, recent activity
* Warehouse detail → capacity, space used, balance, movement, trend, activity
* Expiry reports → expired / near-expiry stock
* Lot / Rack reports → lot stock log, product lots, rack log, rack occupancy
* Lot / Bin reports → lot stock log, product lots, bin log, bin occupancy
* Balance summary → getWarehouseBalanceSummary
*
* Security: All SQL uses PDO prepared statements with bound parameters.
@@ -159,25 +159,25 @@ class ReportManager
}
/**
* Total rack capacity (all racks across all warehouses) for this company.
* Total bin capacity (all bins across all warehouses) for this company.
* Used by the reports_stats dashboard tile.
*/
public function getTotalCapacity(): int
{
$sql = "SELECT COUNT(*)
FROM md_rack
FROM md_bin
WHERE company_id = :company_id";
return (int) $this->fetchScalar($sql);
}
/**
* Number of occupied racks (product_sku IS NOT NULL) for this company.
* Number of occupied bins (product_sku IS NOT NULL) for this company.
* Used by the reports_stats dashboard tile.
*/
public function getSpaceUsed(): int
{
$sql = "SELECT COUNT(*)
FROM md_rack
FROM md_bin
WHERE company_id = :company_id
AND product_sku IS NOT NULL";
return (int) $this->fetchScalar($sql);
@@ -710,15 +710,15 @@ class ReportManager
// ─────────────────────────────────────────────────────────────
/**
* Total rack capacity for a specific warehouse (all racks regardless of occupancy).
* Total bin capacity for a specific warehouse (all bins regardless of occupancy).
*
* @param int $warehouse_id The warehouse to query.
* @return int Total rack count.
* @return int Total bin count.
*/
public function getWarehouseCapacity(int $warehouse_id): int
{
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM md_rack
"SELECT COUNT(*) FROM md_bin
WHERE company_id = :company_id AND warehouse = :warehouse_id"
);
$sth->execute([':company_id' => $this->companyId, ':warehouse_id' => $warehouse_id]);
@@ -726,15 +726,15 @@ class ReportManager
}
/**
* Number of occupied racks (product_sku IS NOT NULL) for a specific warehouse.
* Number of occupied bins (product_sku IS NOT NULL) for a specific warehouse.
*
* @param int $warehouse_id The warehouse to query.
* @return int Occupied rack count.
* @return int Occupied bin count.
*/
public function getWarehouseSpaceUsed(int $warehouse_id): int
{
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM md_rack
"SELECT COUNT(*) FROM md_bin
WHERE company_id = :company_id AND warehouse = :warehouse_id
AND product_sku IS NOT NULL"
);
@@ -1008,14 +1008,14 @@ class ReportManager
s.lot_number,
s.zone,
s.aisle,
s.rack,
s.bin,
ROUND(SUM(s.`in`) - SUM(s.`out`), 2) AS quantity,
{$wh['id']} AS warehouse_id
FROM `{$table}` s
WHERE s.company_id = {$cid}
AND s.lot_number IS NOT NULL
AND s.status = 1
GROUP BY s.product_sku, s.lot_number, s.zone, s.aisle, s.rack
GROUP BY s.product_sku, s.lot_number, s.zone, s.aisle, s.bin
HAVING quantity > 0";
},
$warehouses
@@ -1026,7 +1026,7 @@ class ReportManager
stock.lot_number,
stock.zone,
stock.aisle,
stock.rack,
stock.bin,
stock.quantity,
l.expiry_date,
DATEDIFF(l.expiry_date, CURDATE()) AS days_remaining,
@@ -1073,7 +1073,7 @@ class ReportManager
'lot_number' => $row['lot_number'],
'zone' => $row['zone'],
'aisle' => $row['aisle'],
'rack' => $row['rack'],
'bin' => $row['bin'],
'quantity' => (float) $row['quantity'],
'expiry_date' => $row['expiry_date'],
'days_remaining' => $days,
@@ -1085,7 +1085,7 @@ class ReportManager
}
// ─────────────────────────────────────────────────────────────
// REPORT BASIS — Lot / Rack reports
// REPORT BASIS — Lot / Bin reports
// ─────────────────────────────────────────────────────────────
/**
@@ -1097,7 +1097,7 @@ class ReportManager
*
* @param string $product_sku SKU to filter by.
* @param string $lot_number Lot number to filter by.
* @return array Stock rows with date, type, zone/aisle/rack, in/out amounts, warehouse_name.
* @return array Stock rows with date, type, zone/aisle/bin, in/out amounts, warehouse_name.
*/
public function getLotStockLog(string $product_sku, string $lot_number): array
{
@@ -1123,7 +1123,7 @@ class ReportManager
s.type,
s.zone,
s.aisle,
s.rack,
s.bin,
ROUND(COALESCE(s.`in`, 0), 2) AS stock_in,
ROUND(COALESCE(s.`out`, 0), 2) AS stock_out,
s.serial_number,
@@ -1247,21 +1247,21 @@ class ReportManager
}
/**
* Return the action log for a specific rack, with user name.
* Return the action log for a specific bin, with user name.
*
* Used by the rack log dashboard report page.
* Used by the bin log dashboard report page.
* Joins the wms.user table for the acting user's name.
*
* Security fix: the database name is now fetched once via a bound query
* and stored as $db_name (string), then used as a backtick-quoted identifier
* rather than being embedded in the JOIN without escaping.
*
* @param int $rack_id The md_rack.id to fetch logs for (0 returns empty array).
* @param int $bin_id The md_bin.id to fetch logs for (0 returns empty array).
* @return array Log rows with dt, action, product_sku, td_stock_id, login, user_name.
*/
public function getRackLog(int $rack_id): array
public function getBinLog(int $bin_id): array
{
if (!$rack_id) return [];
if (!$bin_id) return [];
$main_db = $this->mainDb;
if ($main_db === '' || !ctype_alnum(str_replace('_', '', $main_db))) {
@@ -1277,30 +1277,30 @@ class ReportManager
rl.td_stock_id,
rl.login,
u.name AS user_name
FROM td_rack_log rl
FROM td_bin_log rl
LEFT JOIN `{$main_db}`.user u
ON u.user_id = rl.user_id
WHERE rl.company_id = :company_id
AND rl.md_rack_id = :rack_id
AND rl.md_bin_id = :bin_id
ORDER BY rl.dt DESC"
);
$sth->execute([
':company_id' => $this->companyId,
':rack_id' => $rack_id,
':bin_id' => $bin_id,
]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Return all rack slots across all warehouses with occupancy status,
* Return all bin slots across all warehouses with occupancy status,
* warehouse name, and product name.
*
* Used by the rack occupancy dashboard report page to visualise which
* racks are empty vs occupied and which product is in each slot.
* Results are ordered by warehouse → zone → aisle → rack with
* Used by the bin occupancy dashboard report page to visualise which
* bins are empty vs occupied and which product is in each slot.
* Results are ordered by warehouse → zone → aisle → bin with
* numeric-first sorting via CAST.
*
* @return array All md_rack rows joined to warehouse and product with 'status' field.
* @return array All md_bin rows joined to warehouse and product with 'status' field.
*/
public function getRackOccupancy(): array
{
@@ -1309,14 +1309,14 @@ class ReportManager
r.id,
r.zone,
r.aisle,
r.rack,
r.bin,
r.product_sku,
r.td_stock_id,
mw.warehouse_name,
mw.id AS warehouse_id,
p.product_name,
CASE WHEN r.product_sku IS NOT NULL THEN 'occupied' ELSE 'empty' END AS status
FROM md_rack r
FROM md_bin r
INNER JOIN md_warehouse mw
ON mw.company_id = r.company_id
AND mw.id = r.warehouse
@@ -1326,7 +1326,7 @@ class ReportManager
WHERE r.company_id = :company_id
ORDER BY mw.warehouse_name, r.zone,
CAST(r.aisle AS UNSIGNED), r.aisle,
CAST(r.rack AS UNSIGNED), r.rack"
CAST(r.bin AS UNSIGNED), r.bin"
);
$sth->execute([':company_id' => $this->companyId]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
@@ -1510,7 +1510,7 @@ class ReportManager
s.serial_number,
s.zone,
s.aisle,
s.rack,
s.bin,
ROUND(COALESCE(s.`in`, 0), 2) AS stock_in,
ROUND(COALESCE(s.`out`, 0), 2) AS stock_out,
CASE
+17 -17
View File
@@ -17,13 +17,13 @@ require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
* 1. INSERT td_stock_<warehouse_id> in rows (source='return', source_id=return_id,
* status=1 ALWAYS — confirming a return is a final business decision,
* no separate warehouse approval step needed).
* 2. occupyRack() + adjustBalance() via WarehouseManager.
* 2. occupyBin() + adjustBalance() via WarehouseManager.
* 3. If auto_invoice_and_credit_note=1 → InvoiceManager::createCreditNote()
* auto-generates a CN linked to the original invoice (status=1 issued).
* If =0 → user creates CN manually from the return detail page.
* - Return items stored as JSON in td_return.items (same pattern as td_order).
* - Each item references stock_out_id from the original order so the correct
* warehouse/rack/lot context is known for restock.
* warehouse/bin/lot context is known for restock.
*
* Note: Write methods do NOT manage their own DB transactions.
* Callers must wrap multi-step operations inside dbTransaction().
@@ -242,7 +242,7 @@ class ReturnManager {
*
* items JSON array — each element mirrors td_order.items with stock_out_id
* referencing the original stock-out row so confirmReturn() knows which
* rack/warehouse/lot to restock.
* bin/warehouse/lot to restock.
*
* Must be called inside dbTransaction() by the caller.
*
@@ -389,7 +389,7 @@ class ReturnManager {
* Flow per item:
* 1. INSERT td_stock_<warehouse_id> in row with type='in', status=1 (force confirmed),
* source='return', source_id=return_id.
* 2. occupyRack() — place returned stock into the user-selected empty rack.
* 2. occupyBin() — place returned stock into the user-selected empty bin.
* 3. adjustBalance() — update etl_stock_summary.
*
* After all items:
@@ -400,7 +400,7 @@ class ReturnManager {
*
* Stock-in rows respect $auto_approve (mirrors default_stock_status config).
* When false, rows are created as draft (status=0) and warehouse staff approve
* them via approve_stock.php; rack occupation and balance are deferred.
* them via approve_stock.php; bin occupation and balance are deferred.
*
* Must be called inside dbTransaction() by the caller.
*
@@ -495,13 +495,13 @@ class ReturnManager {
$this->pdo->prepare(
"INSERT INTO `{$table}`
(uuid, company_id, `date`, product_sku, `in`,
zone, aisle, rack,
zone, aisle, bin,
contact_id, `description`, `log`, `type`,
lot_number, serial_number,
source, source_id, ref_id, price, status)
VALUES
(:uuid, :company_id, :date, :product_sku, :quantity,
:zone, :aisle, :rack,
:zone, :aisle, :bin,
:contact_id, :description, :log, 'in',
:lot_number, :serial_number,
'return', :source_id, :ref_id, :price, :status)"
@@ -513,7 +513,7 @@ class ReturnManager {
':quantity' => $quantity,
':zone' => $item['zone'] ?? '',
':aisle' => $item['aisle'] ?? '',
':rack' => $item['rack'] ?? '',
':bin' => $item['bin'] ?? '',
':contact_id' => (int)$return['contact_id'],
':description' => $return['return_number'],
':log' => json_encode($item_log),
@@ -528,11 +528,11 @@ class ReturnManager {
$stock_in_id = (int)$this->pdo->lastInsertId();
if ($auto_approve) {
$whMgmt->occupyRack(
$whMgmt->occupyBin(
$warehouse_id,
$item['zone'] ?? '',
$item['aisle'] ?? '',
$item['rack'] ?? '',
$item['bin'] ?? '',
$product_sku,
$stock_in_id,
true
@@ -583,9 +583,9 @@ class ReturnManager {
* and doc_type.
*
* If the return was confirmed, stock-in rows (source='return', source_id=$return_id)
* were created with status=1 and rack/balance were already adjusted.
* were created with status=1 and bin/balance were already adjusted.
* On cancel we soft-delete those rows (status → -1) and reverse the
* rack occupation and balance via WarehouseManager.
* bin occupation and balance via WarehouseManager.
*
* If the return was still draft, no stock rows exist — simple status flip.
*
@@ -636,7 +636,7 @@ class ReturnManager {
}
}
// ── If confirmed: reverse stock-in rows + rack/balance ────────────
// ── If confirmed: reverse stock-in rows + bin/balance ────────────
if ($status === 1) {
$sth2 = $this->pdo->prepare(
"SELECT * FROM td_return_item
@@ -657,7 +657,7 @@ class ReturnManager {
// Fetch ALL non-cancelled stock-in rows (status=1 approved or status=0 draft)
$sth = $this->pdo->prepare(
"SELECT id, status, zone, aisle, rack, `date` FROM `{$table}`
"SELECT id, status, zone, aisle, bin, `date` FROM `{$table}`
WHERE company_id = :company_id
AND source = 'return'
AND source_id = :return_id
@@ -679,11 +679,11 @@ class ReturnManager {
if ((int)$row['status'] === 1) {
$whMgmt->assertStockMovementWindow($row['date'] ?? null, 'Return stock cancellation');
// Approved rows: reverse rack occupation + balance
$whMgmt->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']);
// Approved rows: reverse bin occupation + balance
$whMgmt->releaseBin($warehouse_id, $row['zone'], $row['aisle'], $row['bin']);
$whMgmt->adjustBalance('out', $warehouse_id, $product_sku, 0, $quantity);
}
// Draft (status=0) rows: just soft-delete — rack/balance were never applied
// Draft (status=0) rows: just soft-delete — bin/balance were never applied
}
}
}
+57 -57
View File
@@ -15,7 +15,7 @@ require_once __DIR__ . '/../notify_node.php';
* getTransferById, saveStockIn, saveStockOut, saveStockTransfer
* Report basis → (none — reporting is handled by ReportManager)
*
* Write operations delegate rack and balance side-effects to WarehouseManager.
* Write operations delegate bin and balance side-effects to WarehouseManager.
* Delete operations are handled directly in WarehouseManager (deleteStockIn, etc.).
*
* Note: Write methods do NOT manage their own DB transactions.
@@ -228,16 +228,16 @@ class StockManager {
* Insert flow (id = 0):
* 1. Upserts md_lot if lot_number + expiry_date are provided.
* 2. Inserts the td_stock_<warehouse_id> row.
* 3. Calls WarehouseManager::occupyRack() to reserve the rack.
* 3. Calls WarehouseManager::occupyBin() to reserve the bin.
* etl_stock_summary is updated later by approveStock().
*
* Update flow (id > 0):
* - Updates contact_id, description, and log only.
* - Quantity, rack, lot, and serial are immutable after creation.
* - Quantity, bin, lot, and serial are immutable after creation.
*
* Must be called inside dbTransaction() by the caller.
*
* @param array $data Keys: id, warehouse, product_sku, quantity, zone, aisle, rack,
* @param array $data Keys: id, warehouse, product_sku, quantity, zone, aisle, bin,
* contact_id, description, lot_number, expiry_date, serial_number.
* @param array $logging Audit entry to append to the log column.
* @param string $uuid UUID for this transaction (shared across transfer pairs).
@@ -303,10 +303,10 @@ class StockManager {
$this->pdo->prepare(
"INSERT INTO `$table`
(uuid, company_id, `date`, product_sku, `in`, price, zone, aisle, rack,
(uuid, company_id, `date`, product_sku, `in`, price, zone, aisle, bin,
contact_id, `description`, `log`, `type`, lot_number, serial_number, status, updated_at)
VALUES
(:uuid, :company_id, :date, :product_sku, :quantity, :price, :zone, :aisle, :rack,
(:uuid, :company_id, :date, :product_sku, :quantity, :price, :zone, :aisle, :bin,
:contact_id, :description, :log, 'in', :lot_number, :serial_number, 0, NOW())"
)->execute([
':uuid' => $uuid,
@@ -317,7 +317,7 @@ class StockManager {
':price' => (float)($data['price'] ?? 0),
':zone' => $data['zone'],
':aisle' => $data['aisle'],
':rack' => $data['rack'],
':bin' => $data['bin'],
':contact_id' => (int)($data['contact_id'] ?? 0),
':description' => $data['description'] ?? '',
':log' => json_encode($table_log),
@@ -328,13 +328,13 @@ class StockManager {
$td_stock_id = (int)$this->pdo->lastInsertId();
// Reserve the location immediately so draft stock-in rows cannot
// leave the same rack available for another receipt. Balance still
// leave the same bin available for another receipt. Balance still
// changes only when approveStock() runs.
$whMgmt->occupyRack(
$whMgmt->occupyBin(
$warehouse_id,
$data['zone'],
$data['aisle'],
$data['rack'],
$data['bin'],
$data['product_sku'],
$td_stock_id
);
@@ -347,9 +347,9 @@ class StockManager {
* Insert a new stock_out record or update metadata on an existing one.
*
* Insert flow (id = 0):
* 1. Validates the rack is occupied with the correct SKU / lot / serial.
* 2. Inserts the td_stock_<warehouse_id> row, copying quantity and lot info from the rack.
* 3. Calls WarehouseManager::releaseRack() to free the rack slot.
* 1. Validates the bin is occupied with the correct SKU / lot / serial.
* 2. Inserts the td_stock_<warehouse_id> row, copying quantity and lot info from the bin.
* 3. Calls WarehouseManager::releaseBin() to free the bin slot.
* 4. Calls WarehouseManager::adjustBalance() to update etl_stock_summary.
*
* Update flow (id > 0):
@@ -357,11 +357,11 @@ class StockManager {
*
* Must be called inside dbTransaction() by the caller.
*
* @param array $data Keys: id, warehouse, product_sku, zone, aisle, rack,
* @param array $data Keys: id, warehouse, product_sku, zone, aisle, bin,
* contact_id, description, lot_number, serial_number.
* @param array $logging Audit entry to append to the log column.
* @param string $uuid UUID for this transaction.
* @throws Exception If the rack is empty, holds a different SKU/lot/serial.
* @throws Exception If the bin is empty, holds a different SKU/lot/serial.
*/
public function saveStockOut(array $data, array $logging, string $uuid): int
{
@@ -400,33 +400,33 @@ class StockManager {
} else {
// Validate rack holds the expected product / lot / serial
$source_stock = $whMgmt->getRackStock(
// Validate bin holds the expected product / lot / serial
$source_stock = $whMgmt->getBinStock(
$warehouse_id,
$data['zone'], $data['aisle'], $data['rack']
$data['zone'], $data['aisle'], $data['bin']
);
if (!$source_stock) {
throw new Exception(
"Rack {$data['zone']}-{$data['aisle']}-{$data['rack']} is empty — nothing to take out."
"Bin {$data['zone']}-{$data['aisle']}-{$data['bin']} 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']}."
"Bin holds {$source_stock['product_sku']}, not {$data['product_sku']}."
);
}
if (!empty($data['lot_number']) && $source_stock['lot_number'] !== $data['lot_number']) {
throw new Exception(
"Rack holds lot '{$source_stock['lot_number']}', not '{$data['lot_number']}'."
"Bin holds lot '{$source_stock['lot_number']}', not '{$data['lot_number']}'."
);
}
if (!empty($data['serial_number']) && $source_stock['serial_number'] !== $data['serial_number']) {
throw new Exception(
"Rack holds serial '{$source_stock['serial_number']}', not '{$data['serial_number']}'."
"Bin holds serial '{$source_stock['serial_number']}', not '{$data['serial_number']}'."
);
}
@@ -438,10 +438,10 @@ class StockManager {
$this->pdo->prepare(
"INSERT INTO `$table`
(uuid, company_id, `date`, product_sku, `out`, zone, aisle, rack,
(uuid, company_id, `date`, product_sku, `out`, zone, aisle, bin,
contact_id, `description`, `log`, `type`, ref_id, lot_number, serial_number, status, updated_at)
VALUES
(:uuid, :company_id, :date, :product_sku, :quantity, :zone, :aisle, :rack,
(:uuid, :company_id, :date, :product_sku, :quantity, :zone, :aisle, :bin,
:contact_id, :description, :log, 'out', :ref_id, :lot_number, :serial_number, 0, NOW())"
)->execute([
':uuid' => $uuid,
@@ -451,7 +451,7 @@ class StockManager {
':quantity' => $quantity,
':zone' => $data['zone'],
':aisle' => $data['aisle'],
':rack' => $data['rack'],
':bin' => $data['bin'],
':contact_id' => (int)($data['contact_id'] ?? 0),
':description' => $data['description'] ?? '',
':log' => json_encode($table_log),
@@ -462,7 +462,7 @@ class StockManager {
$out_stock_id = (int)$this->pdo->lastInsertId();
// releaseRack and adjustBalance deferred — called from approveStock() only.
// releaseBin and adjustBalance deferred — called from approveStock() only.
return $out_stock_id;
}
}
@@ -475,11 +475,11 @@ class StockManager {
* 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.
* 1. Validates the source bin holds the correct SKU / lot / serial.
* 2. Inserts the outbound row in td_stock_<from>.
* 3. Inserts the inbound row in td_stock_<to> with ref_id pointing to from.
* 4. Back-fills ref_id on the from row so both point at each other.
* 5. Releases the source rack, occupies the destination rack.
* 5. Releases the source bin, occupies the destination bin.
* 6. Adjusts balance on both warehouses (out from source, in to dest).
*
* Update flow (id > 0):
@@ -488,11 +488,11 @@ class StockManager {
* 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,
* zone_from, aisle_from, bin_from, zone_to, aisle_to, bin_to,
* contact_id, description, lot_number, serial_number.
* @param array $logging Audit entry to append to the log column on both rows.
* @param string $uuid UUID shared by both the from and to rows.
* @throws Exception If source rack validation fails or paired record is missing on update.
* @throws Exception If source bin validation fails or paired record is missing on update.
*/
public function saveStockTransfer(array $data, array $logging, string $uuid): int
{
@@ -566,39 +566,39 @@ class StockManager {
return 0; // update — no new row
}
// Insert: validate source rack, then create paired rows
// Insert: validate source bin, then create paired rows
$from_warehouse = (int)$data['warehouse_from'];
$from_zone = $data['zone_from'];
$from_aisle = $data['aisle_from'];
$from_rack = $data['rack_from'];
$from_bin = $data['bin_from'];
$to_warehouse = (int)$data['warehouse_to'];
$to_zone = $data['zone_to'];
$to_aisle = $data['aisle_to'];
$to_rack = $data['rack_to'];
$to_bin = $data['bin_to'];
$source_stock = $whMgmt->getRackStock(
$from_warehouse, $from_zone, $from_aisle, $from_rack
$source_stock = $whMgmt->getBinStock(
$from_warehouse, $from_zone, $from_aisle, $from_bin
);
if (!$source_stock) {
throw new Exception("Source rack {$from_zone}-{$from_aisle}-{$from_rack} is empty.");
throw new Exception("Source bin {$from_zone}-{$from_aisle}-{$from_bin} is empty.");
}
if ($source_stock['product_sku'] !== $data['product_sku']) {
throw new Exception(
"Source rack holds {$source_stock['product_sku']}, not {$data['product_sku']}."
"Source bin holds {$source_stock['product_sku']}, not {$data['product_sku']}."
);
}
if (!empty($data['lot_number']) && $source_stock['lot_number'] !== $data['lot_number']) {
throw new Exception(
"Source rack holds lot '{$source_stock['lot_number']}', not '{$data['lot_number']}'."
"Source bin holds lot '{$source_stock['lot_number']}', not '{$data['lot_number']}'."
);
}
if (!empty($data['serial_number']) && $source_stock['serial_number'] !== $data['serial_number']) {
throw new Exception(
"Source rack holds serial '{$source_stock['serial_number']}', not '{$data['serial_number']}'."
"Source bin holds serial '{$source_stock['serial_number']}', not '{$data['serial_number']}'."
);
}
@@ -615,11 +615,11 @@ class StockManager {
$this->pdo->prepare(
"INSERT INTO `$from_table`
(uuid, company_id, `date`, product_sku, `out`,
ref_warehouse, zone, aisle, rack,
ref_warehouse, zone, aisle, bin,
contact_id, `description`, `log`, `type`, lot_number, serial_number, status, updated_at)
VALUES
(:uuid, :company_id, :date, :product_sku, :quantity,
:ref_warehouse, :zone, :aisle, :rack,
:ref_warehouse, :zone, :aisle, :bin,
:contact_id, :description, :log, 'transfer', :lot_number, :serial_number, 0, NOW())"
)->execute([
':uuid' => $uuid,
@@ -630,7 +630,7 @@ class StockManager {
':ref_warehouse' => $to_warehouse,
':zone' => $from_zone,
':aisle' => $from_aisle,
':rack' => $from_rack,
':bin' => $from_bin,
':contact_id' => $contact_id,
':description' => $description,
':log' => json_encode($table_log),
@@ -643,11 +643,11 @@ class StockManager {
$this->pdo->prepare(
"INSERT INTO `$to_table`
(uuid, company_id, `date`, product_sku, `in`,
ref_warehouse, ref_id, zone, aisle, rack,
ref_warehouse, ref_id, zone, aisle, bin,
contact_id, `description`, `log`, `type`, lot_number, serial_number, status, updated_at)
VALUES
(:uuid, :company_id, :date, :product_sku, :quantity,
:ref_warehouse, :ref_id, :zone, :aisle, :rack,
:ref_warehouse, :ref_id, :zone, :aisle, :bin,
:contact_id, :description, :log, 'transfer', :lot_number, :serial_number, 0, NOW())"
)->execute([
':uuid' => $uuid,
@@ -659,7 +659,7 @@ class StockManager {
':ref_id' => $from_stock_id,
':zone' => $to_zone,
':aisle' => $to_aisle,
':rack' => $to_rack,
':bin' => $to_bin,
':contact_id' => $contact_id,
':description' => $description,
':log' => json_encode($table_log),
@@ -678,7 +678,7 @@ class StockManager {
':company_id' => $this->company_id,
]);
// releaseRack, occupyRack and adjustBalance deferred — called from approveStock() only.
// releaseBin, occupyBin and adjustBalance deferred — called from approveStock() only.
return $from_stock_id;
}
@@ -730,13 +730,13 @@ class StockManager {
"UPDATE `{$table}` SET status = 1, updated_at = NOW() WHERE id = :id AND company_id = :company_id"
)->execute([':id' => $id, ':company_id' => $this->company_id]);
// ── Rack state + balance ──────────────────────────────────────────
// ── Bin state + balance ──────────────────────────────────────────
if ($type === 'in') {
// Occupy rack now that stock is approved
$whMgmt->occupyRack(
// Occupy bin now that stock is approved
$whMgmt->occupyBin(
$warehouse_id,
$row['zone'], $row['aisle'], $row['rack'],
$row['zone'], $row['aisle'], $row['bin'],
$row['product_sku'],
$id
);
@@ -766,9 +766,9 @@ class StockManager {
$remaining_qty = (float)$remaining_sth->fetchColumn();
}
// Release the rack only when the source batch is fully consumed.
// Release the bin only when the source batch is fully consumed.
if ($remaining_qty <= 0.000001) {
$whMgmt->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']);
$whMgmt->releaseBin($warehouse_id, $row['zone'], $row['aisle'], $row['bin']);
}
$whMgmt->adjustBalance('out', $warehouse_id, $row['product_sku'], 0, (float)$row['out'],
$id, $row['source'] ?? '', (int)($row['source_id'] ?? 0), $row['date'] ?? '');
@@ -848,19 +848,19 @@ class StockManager {
)->execute([':id' => $inbound_row['id'], ':company_id' => $this->company_id]);
}
// Rack state: release source, occupy destination
// Bin state: release source, occupy destination
if ($from_row && $from_wh_id) {
$whMgmt->releaseRack(
$whMgmt->releaseBin(
$from_wh_id,
$from_row['zone'], $from_row['aisle'], $from_row['rack']
$from_row['zone'], $from_row['aisle'], $from_row['bin']
);
$whMgmt->adjustBalance('out', $from_wh_id, $from_row['product_sku'], 0, (float)$from_row['out'],
(int)$from_row['id'], $from_row['source'] ?? '', (int)($from_row['source_id'] ?? 0), $from_row['date'] ?? '');
}
if ($inbound_row && $paired_wh_id) {
$whMgmt->occupyRack(
$whMgmt->occupyBin(
$paired_wh_id,
$inbound_row['zone'], $inbound_row['aisle'], $inbound_row['rack'],
$inbound_row['zone'], $inbound_row['aisle'], $inbound_row['bin'],
$inbound_row['product_sku'],
$inbound_row['id']
);
@@ -26,7 +26,7 @@ class StockSourceManager
$warehouse_id = (int)$match[1];
$sth = $this->pdo->prepare(
"SELECT s.id, s.type, s.product_sku, s.`in`, s.`out`,
s.zone, s.aisle, s.rack, s.lot_number, s.serial_number,
s.zone, s.aisle, s.bin, s.lot_number, s.serial_number,
s.status, s.date, s.price,
p.product_name
FROM `{$table}` s
@@ -104,10 +104,10 @@ class SupplierReturnManager {
$sth = $this->pdo->prepare(
"INSERT INTO td_supplier_return_item
(company_id, return_id, item_id, product_sku, product_name,
quantity, unit_price, total_price, tax_amount, tax_rate, warehouse_id, stock_in_id, zone, aisle, rack)
quantity, unit_price, total_price, tax_amount, tax_rate, warehouse_id, stock_in_id, zone, aisle, bin)
VALUES
(:company_id, :return_id, :item_id, :product_sku, :product_name,
:quantity, :unit_price, :total_price, :tax_amount, :tax_rate, :warehouse_id, :stock_in_id, :zone, :aisle, :rack)"
:quantity, :unit_price, :total_price, :tax_amount, :tax_rate, :warehouse_id, :stock_in_id, :zone, :aisle, :bin)"
);
foreach ($items as $pos => $item) {
$sth->execute([
@@ -125,7 +125,7 @@ class SupplierReturnManager {
':stock_in_id' => (int)($item['stock_in_id'] ?? 0),
':zone' => $item['zone'] ?? '',
':aisle' => $item['aisle'] ?? '',
':rack' => $item['rack'] ?? '',
':bin' => $item['bin'] ?? '',
]);
}
}
@@ -348,7 +348,7 @@ class SupplierReturnManager {
* Flow per item:
* 1. Validate stock-in row (from PO receive) is approved.
* 2. INSERT td_stock_<warehouse_id> out row, status respects $auto_approve.
* 3. If $auto_approve: releaseRack() + adjustBalance('out').
* 3. If $auto_approve: releaseBin() + adjustBalance('out').
* After all items:
* 4. UPDATE td_supplier_return.status = 1.
*
@@ -433,9 +433,9 @@ class SupplierReturnManager {
$zone = $item['zone'] ?? '';
$aisle = $item['aisle'] ?? '';
$rack = $item['rack'] ?? '';
if ($zone === '' && $rack !== '') $zone = $rack;
if ($aisle === '' && $rack !== '') $aisle = $rack;
$bin = $item['bin'] ?? '';
if ($zone === '' && $bin !== '') $zone = $bin;
if ($aisle === '' && $bin !== '') $aisle = $bin;
$item_uuid = $uuid . '_srn_' . $i;
$item_log = [array_merge($logging, ['action' => 'confirm_return_item'])];
@@ -443,13 +443,13 @@ class SupplierReturnManager {
$this->pdo->prepare(
"INSERT INTO `{$table}`
(uuid, company_id, `date`, product_sku, `out`,
zone, aisle, rack,
zone, aisle, bin,
contact_id, `description`, `log`, `type`,
lot_number, serial_number,
source, source_id, ref_id, price, status)
VALUES
(:uuid, :company_id, :date, :product_sku, :quantity,
:zone, :aisle, :rack,
:zone, :aisle, :bin,
:contact_id, :description, :log, 'out',
:lot_number, :serial_number,
'supplier_return', :source_id, :ref_id, :price, :status)"
@@ -461,7 +461,7 @@ class SupplierReturnManager {
':quantity' => $quantity,
':zone' => $zone,
':aisle' => $aisle,
':rack' => $rack,
':bin' => $bin,
':contact_id' => (int)$return['contact_id'],
':description' => $return['return_number'],
':log' => json_encode($item_log),
@@ -475,7 +475,7 @@ class SupplierReturnManager {
if ($auto_approve) {
$stock_out_id = (int)$this->pdo->lastInsertId();
$whMgmt->releaseRack($warehouse_id, $zone, $aisle, $rack);
$whMgmt->releaseBin($warehouse_id, $zone, $aisle, $bin);
$whMgmt->adjustBalance('out', $warehouse_id, $product_sku, 0, $quantity);
}
}
@@ -560,7 +560,7 @@ class SupplierReturnManager {
// Fetch ALL non-cancelled stock-out rows (status=1 approved or status=0 draft)
$sth = $this->pdo->prepare(
"SELECT id, status, zone, aisle, rack, ref_id, `date` FROM `{$table}`
"SELECT id, status, zone, aisle, bin, ref_id, `date` FROM `{$table}`
WHERE company_id = :company_id
AND source = 'supplier_return'
AND source_id = :return_id
@@ -582,14 +582,14 @@ class SupplierReturnManager {
if ((int)$row['status'] === 1) {
$whMgmt->assertStockMovementWindow($row['date'] ?? null, 'Supplier return stock cancellation');
// Approved rows: re-occupy rack (reverse the release) + reverse balance
// Approved rows: re-occupy bin (reverse the release) + reverse balance
$ref_id = (int)$row['ref_id'];
if ($ref_id > 0) {
$whMgmt->occupyRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack'], $product_sku, $ref_id, false);
$whMgmt->occupyBin($warehouse_id, $row['zone'], $row['aisle'], $row['bin'], $product_sku, $ref_id, false);
}
$whMgmt->adjustBalance('in', $warehouse_id, $product_sku, 0, $quantity);
}
// Draft (status=0) rows: just soft-delete — rack/balance were never applied
// Draft (status=0) rows: just soft-delete — bin/balance were never applied
}
}
}
File diff suppressed because it is too large Load Diff