[test] contact to setting modules
This commit is contained in:
@@ -1,3 +1,33 @@
|
||||
/** =========================
|
||||
* SIDEBAR ACTIVE STATE
|
||||
* Override: activate the parent listing page for manage_* sub-pages.
|
||||
* main.js (theme) handles exact-match pages; this covers second-depth pages.
|
||||
* ========================= */
|
||||
$(function () {
|
||||
var $links = $('#sidebar').find('a.nav-link');
|
||||
var path = window.location.pathname;
|
||||
|
||||
// Theme already handled it — an active link whose href matches the current path
|
||||
var alreadyActive = $links.filter('.active').toArray().some(function (a) {
|
||||
return a.pathname === path;
|
||||
});
|
||||
if (alreadyActive) return;
|
||||
|
||||
// For manage_* pages: strip "manage_" to derive the parent listing filename
|
||||
var filename = path.split('/').pop();
|
||||
if (!/^manage_/.test(filename)) return;
|
||||
|
||||
var dir = path.substring(0, path.lastIndexOf('/'));
|
||||
var parentPath = dir + '/' + filename.replace(/^manage_/, '');
|
||||
|
||||
var $parent = $links.filter(function () { return this.pathname === parentPath; });
|
||||
if ($parent.length) {
|
||||
$links.removeClass('active');
|
||||
$parent.addClass('active');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/** =========================
|
||||
* UTILITIES
|
||||
* ========================= */
|
||||
|
||||
@@ -234,14 +234,14 @@ class CompanySettingManager
|
||||
}
|
||||
|
||||
$sth = $this->transactionPdo->prepare(
|
||||
"SELECT warehouse_name FROM md_warehouse WHERE company_id = :company_id"
|
||||
"SELECT id FROM md_warehouse WHERE company_id = :company_id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
$warehouses = $sth->fetchAll(PDO::FETCH_COLUMN);
|
||||
$warehouse_ids = $sth->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
foreach ($warehouses as $warehouse_name) {
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', (string)$warehouse_name);
|
||||
if ($safe !== '' && $this->tableHasCompanyRows('td_stock_' . $safe)) {
|
||||
foreach ($warehouse_ids as $warehouse_id) {
|
||||
$warehouse_id = (int)$warehouse_id;
|
||||
if ($warehouse_id > 0 && $this->tableHasCompanyRows('td_stock_' . $warehouse_id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* OrderManager
|
||||
*
|
||||
* Handles all read and write operations for sales orders (td_order)
|
||||
* and their downstream stock-out effects on td_stock_<warehouse> tables.
|
||||
* and their downstream stock-out effects on td_stock_<warehouse_id> tables.
|
||||
*
|
||||
* Method order:
|
||||
* Transaction basis → getOrderList, getOrderById, generateOrderNumber,
|
||||
@@ -16,25 +16,24 @@
|
||||
* 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_<wh>.date ASC.
|
||||
* still rack-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.
|
||||
* - cancelOrder() sets td_order.status = -1 and soft-deletes ALL linked
|
||||
* stock-out rows (status → -1) across all td_stock_* tables.
|
||||
* Cancellation is blocked if any active invoice (status != -1) or
|
||||
* active return (status != -1) is linked to the order.
|
||||
* - Cancel logic is intentionally self-contained here. status=-1 is
|
||||
* an order-domain concept with no rack/balance side effects, so
|
||||
* WarehouseManager and StockManager are not involved.
|
||||
* stock-out rows (status → -1) across all td_stock_* tables. If linked
|
||||
* stock-out rows were already approved, their rack 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.
|
||||
*
|
||||
* Note: Write methods do NOT manage their own DB transactions.
|
||||
* Callers must wrap multi-step operations inside dbTransaction().
|
||||
*
|
||||
* Security: All SQL uses PDO prepared statements with bound parameters.
|
||||
* Dynamic table names (td_stock_<warehouse>) are sanitised with
|
||||
* preg_replace('/[^a-zA-Z0-9_]/', '', ...) before interpolation —
|
||||
* no user input ever reaches a table identifier directly.
|
||||
* Dynamic stock table names are derived only from md_warehouse.id.
|
||||
*/
|
||||
class OrderManager {
|
||||
|
||||
@@ -67,31 +66,13 @@ class OrderManager {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the td_stock_<warehouse> table name for a warehouse_id.
|
||||
*
|
||||
* Does not filter by status — allows reading inactive warehouses
|
||||
* for historical order lookups.
|
||||
*
|
||||
* @param int $warehouse_id
|
||||
* @return string Sanitised table name e.g. "td_stock_Main".
|
||||
* @throws Exception If warehouse not found.
|
||||
*/
|
||||
private function resolveStockTable(int $warehouse_id): string
|
||||
private function stockTableNameFromWarehouseId(int $warehouse_id): string
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT warehouse_name FROM md_warehouse
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]);
|
||||
$name = $sth->fetchColumn();
|
||||
|
||||
if (!$name) {
|
||||
throw new Exception("Warehouse ID {$warehouse_id} not found.");
|
||||
if ($warehouse_id <= 0) {
|
||||
throw new Exception("Invalid warehouse id.");
|
||||
}
|
||||
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
|
||||
return "td_stock_{$safe}";
|
||||
return 'td_stock_' . $warehouse_id;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,10 +89,18 @@ class OrderManager {
|
||||
*/
|
||||
private function pickFifoRack(int $warehouse_id, string $product_sku): ?array
|
||||
{
|
||||
$table = $this->resolveStockTable($warehouse_id);
|
||||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT s.*, r.zone, r.aisle, r.rack
|
||||
"SELECT s.*, r.zone, r.aisle, r.rack,
|
||||
(s.`in` - COALESCE((
|
||||
SELECT SUM(o.`out`)
|
||||
FROM `{$table}` o
|
||||
WHERE o.company_id = s.company_id
|
||||
AND o.ref_id = s.id
|
||||
AND o.`out` > 0
|
||||
AND o.status != -1
|
||||
), 0)) AS available_qty
|
||||
FROM `{$table}` s
|
||||
INNER JOIN md_rack r
|
||||
ON r.company_id = s.company_id
|
||||
@@ -120,9 +109,10 @@ class OrderManager {
|
||||
AND r.product_sku IS NOT NULL
|
||||
WHERE s.company_id = :company_id
|
||||
AND s.product_sku = :product_sku
|
||||
AND s.type = 'in'
|
||||
AND s.`in` > 0
|
||||
AND s.status = 1
|
||||
ORDER BY s.date ASC
|
||||
HAVING available_qty > 0
|
||||
ORDER BY s.date ASC, s.id ASC
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([
|
||||
@@ -354,7 +344,7 @@ class OrderManager {
|
||||
*
|
||||
* Flow per item:
|
||||
* 1. pickFifoRack() — find oldest rack-occupied stock-in row for the SKU.
|
||||
* 2. INSERT into td_stock_<wh> with type='out', status=0,
|
||||
* 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.
|
||||
*
|
||||
@@ -396,17 +386,20 @@ class OrderManager {
|
||||
throw new Exception("Cannot confirm an order with no items.");
|
||||
}
|
||||
|
||||
// ── Per-item: FIFO pick + insert stock-out row ────────────────────
|
||||
foreach ($items as $i => &$item) {
|
||||
|
||||
$warehouse_id = (int)($item['warehouse_id'] ?? 0);
|
||||
$product_sku = $item['product_sku'] ?? '';
|
||||
$quantity = (float)($item['quantity'] ?? 0);
|
||||
|
||||
if (!$warehouse_id || !$product_sku) {
|
||||
throw new Exception(
|
||||
"Item #{$i}: missing warehouse_id or product_sku."
|
||||
);
|
||||
}
|
||||
if ($quantity <= 0) {
|
||||
throw new Exception("Item #{$i}: quantity must be greater than zero.");
|
||||
}
|
||||
|
||||
$rack_stock = $this->pickFifoRack($warehouse_id, $product_sku);
|
||||
|
||||
@@ -417,7 +410,17 @@ class OrderManager {
|
||||
);
|
||||
}
|
||||
|
||||
$table = $this->resolveStockTable($warehouse_id);
|
||||
$available_qty = (float)($rack_stock['available_qty'] ?? $rack_stock['in'] ?? 0);
|
||||
if ($quantity - $available_qty > 0.000001) {
|
||||
$name = $item['product_name'] ?? $product_sku;
|
||||
throw new Exception(
|
||||
"FIFO rack {$rack_stock['zone']}-{$rack_stock['aisle']}-{$rack_stock['rack']} " .
|
||||
"for \"{$name}\" has only {$available_qty} available unit(s), " .
|
||||
"but the order requests {$quantity}."
|
||||
);
|
||||
}
|
||||
|
||||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||||
$item_uuid = $uuid . '_' . $i;
|
||||
$item_log = [array_merge($logging, ['action' => 'confirm_item'])];
|
||||
|
||||
@@ -439,7 +442,7 @@ class OrderManager {
|
||||
':company_id' => $this->company_id,
|
||||
':date' => date('Y-m-d H:i:s'),
|
||||
':product_sku' => $product_sku,
|
||||
':quantity' => (float)$item['quantity'],
|
||||
':quantity' => $quantity,
|
||||
':zone' => $rack_stock['zone'],
|
||||
':aisle' => $rack_stock['aisle'],
|
||||
':rack' => $rack_stock['rack'],
|
||||
@@ -493,17 +496,18 @@ class OrderManager {
|
||||
*
|
||||
* Business rule:
|
||||
* An order can be cancelled (Draft or Confirmed) as long as no active
|
||||
* downstream documents exist. Active means status != -1 (not voided /
|
||||
* not cancelled).
|
||||
* downstream documents exist. For invoices, active means not voided
|
||||
* (status != 4). For returns, active means not cancelled (status != -1).
|
||||
*
|
||||
* Downstream documents that block cancellation:
|
||||
* - td_invoice where order_id = $order_id AND status != -1
|
||||
* - td_invoice where order_id = $order_id AND status != 4
|
||||
* - td_return where order_id = $order_id AND status != -1
|
||||
*
|
||||
* Stock-out rows are children of the order — they follow the parent
|
||||
* and are never independently approved. On cancel, ALL stock-out rows
|
||||
* linked to this order (any status except already -1) are soft-deleted
|
||||
* (status → -1) across all td_stock_* tables.
|
||||
* Stock-out rows are children of the order — they follow the parent.
|
||||
* On cancel, approved rows first re-occupy the original source rack 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.
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
@@ -541,7 +545,7 @@ class OrderManager {
|
||||
"SELECT COUNT(*) FROM td_invoice
|
||||
WHERE company_id = :company_id
|
||||
AND order_id = :order_id
|
||||
AND status != -1"
|
||||
AND status != 4"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':order_id' => $order_id]);
|
||||
if ((int)$sth->fetchColumn() > 0) {
|
||||
@@ -566,7 +570,7 @@ class OrderManager {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Soft-delete all linked stock-out rows ─────────────────────────
|
||||
// ── Reverse approved stock-out side effects, then soft-delete rows ─
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
@@ -574,11 +578,54 @@ class OrderManager {
|
||||
);
|
||||
$sth->execute();
|
||||
$tables = $sth->fetchAll(PDO::FETCH_COLUMN);
|
||||
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
|
||||
|
||||
foreach ($tables as $table) {
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||
if (!preg_match('/^td_stock_(\d+)$/', (string)$table, $matches)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$warehouse_id = (int)$matches[1];
|
||||
|
||||
$row_sth = $this->pdo->prepare(
|
||||
"SELECT id, product_sku, `out`, zone, aisle, rack, ref_id, status
|
||||
FROM `{$table}`
|
||||
WHERE company_id = :company_id
|
||||
AND source = 'order'
|
||||
AND source_id = :order_id
|
||||
AND type = 'out'
|
||||
AND status != -1"
|
||||
);
|
||||
$row_sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':order_id' => $order_id,
|
||||
]);
|
||||
$rows = $row_sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if ((int)$row['status'] !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$whMgmt->occupyRack(
|
||||
$warehouse_id,
|
||||
(string)$row['zone'],
|
||||
(string)$row['aisle'],
|
||||
(string)$row['rack'],
|
||||
(string)$row['product_sku'],
|
||||
(int)$row['ref_id']
|
||||
);
|
||||
$whMgmt->adjustBalance(
|
||||
'out',
|
||||
$warehouse_id,
|
||||
(string)$row['product_sku'],
|
||||
(float)$row['out'],
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE `{$safe}` SET status = -1
|
||||
"UPDATE `{$table}` SET status = -1
|
||||
WHERE company_id = :company_id
|
||||
AND source = 'order'
|
||||
AND source_id = :order_id
|
||||
|
||||
@@ -358,6 +358,7 @@ class ProductManager {
|
||||
':company_id' => $this->company_id,
|
||||
':product_name' => $data['product_name'],
|
||||
':sku' => $data['sku'],
|
||||
':uom' => $data['uom'] ?? 'pcs',
|
||||
':price' => $data['price'],
|
||||
':min_stock' => $data['min_stock'],
|
||||
':reorder_point' => $data['reorder_point'],
|
||||
@@ -374,6 +375,7 @@ class ProductManager {
|
||||
"UPDATE md_product SET
|
||||
product_name = :product_name,
|
||||
sku = :sku,
|
||||
uom = :uom,
|
||||
price = :price,
|
||||
min_stock = :min_stock,
|
||||
reorder_point = :reorder_point,
|
||||
@@ -387,10 +389,10 @@ class ProductManager {
|
||||
} else {
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO md_product
|
||||
(company_id, product_name, sku, price, min_stock, reorder_point,
|
||||
(company_id, product_name, sku, uom, price, min_stock, reorder_point,
|
||||
category, product_image, `description`, `status`, `log`)
|
||||
VALUES
|
||||
(:company_id, :product_name, :sku, :price, :min_stock, :reorder_point,
|
||||
(:company_id, :product_name, :sku, :uom, :price, :min_stock, :reorder_point,
|
||||
:category, :product_image, :description, :status, :log)"
|
||||
)->execute($params);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,596 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* PurchaseOrderManager
|
||||
*
|
||||
* Handles all read and write operations for td_purchase_order
|
||||
* and the downstream stock-in effects on td_stock_<warehouse_id> tables.
|
||||
*
|
||||
* Method order:
|
||||
* Transaction basis → getPoList, getPoById, generatePoNumber,
|
||||
* savePo, confirmPo, receivePo, cancelPo
|
||||
*
|
||||
* Key design decisions:
|
||||
* - PO items are stored as a JSON array in td_purchase_order.items.
|
||||
* Same pattern as td_order. No separate child table.
|
||||
* - receivePo() calls StockManager::saveStockIn() for each received line
|
||||
* with source='po' and source_id=po_id. This means received goods appear
|
||||
* in the existing Stock In list automatically under source='po'.
|
||||
* - stock_in_id is written back into the items JSON after each receive call,
|
||||
* same pattern as stock_out_id in OrderManager::confirmOrder().
|
||||
* - Partial receipt is supported: receivePo() can be called multiple times
|
||||
* until all items are fully received, advancing status 1→2 (partial) or 1/2→3 (completed).
|
||||
* - cancelPo() is blocked if any linked stock-in rows have been approved (status=1),
|
||||
* because those have already modified rack and balance.
|
||||
* - 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 PurchaseOrderManager {
|
||||
|
||||
private $pdo;
|
||||
private $company_id;
|
||||
|
||||
public function __construct($pdo, int $company_id) {
|
||||
$this->pdo = $pdo;
|
||||
$this->company_id = $company_id;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
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,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate next sequential PO number in PO-YYYYMMDD-XXXX format.
|
||||
*/
|
||||
private function generatePoNumber(): string
|
||||
{
|
||||
$prefix = 'PO-' . date('Ymd') . '-';
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT po_number FROM td_purchase_order
|
||||
WHERE company_id = :company_id
|
||||
AND po_number LIKE :prefix
|
||||
ORDER BY po_number DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':prefix' => $prefix . '%',
|
||||
]);
|
||||
|
||||
$last = $sth->fetchColumn();
|
||||
$seq = $last ? ((int)substr($last, -4) + 1) : 1;
|
||||
|
||||
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
private function stockTableNameFromWarehouseId(int $warehouse_id): string
|
||||
{
|
||||
if ($warehouse_id <= 0) {
|
||||
throw new Exception("Invalid warehouse id.");
|
||||
}
|
||||
|
||||
return 'td_stock_' . $warehouse_id;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// TRANSACTION BASIS — Read
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return all POs for the company, ordered by created_at DESC.
|
||||
* Joins md_contact for supplier name display.
|
||||
*/
|
||||
public function getPoList(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT p.*,
|
||||
COALESCE(c.contact_name, '') AS contact_name
|
||||
FROM td_purchase_order p
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = p.company_id
|
||||
AND c.id = p.contact_id
|
||||
WHERE p.company_id = :company_id
|
||||
ORDER BY p.created_at DESC"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single PO by its primary key.
|
||||
* Returns the row with items decoded as a PHP array.
|
||||
*/
|
||||
public function getPoById(int $id): array|false
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT p.*,
|
||||
COALESCE(c.contact_name, '') AS contact_name
|
||||
FROM td_purchase_order p
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = p.company_id
|
||||
AND c.id = p.contact_id
|
||||
WHERE p.company_id = :company_id
|
||||
AND p.id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) return false;
|
||||
|
||||
$row['items'] = json_decode($row['items'] ?? '[]', true) ?: [];
|
||||
return $row;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// TRANSACTION BASIS — Write
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Insert a new PO (draft) or update metadata on an existing draft.
|
||||
*
|
||||
* Insert (id=0): generates po_number, sets status=0, payment_status=0.
|
||||
* Update (id>0): only allowed while status=0 (draft).
|
||||
*
|
||||
* @param array $data Keys: id, contact_id, po_date, expected_date,
|
||||
* warehouse_id, items (array), discount, tax,
|
||||
* shipping_fee, notes.
|
||||
* @param array $logging Audit entry.
|
||||
* @return int New td_purchase_order.id on insert, 0 on update.
|
||||
*/
|
||||
public function savePo(array $data, array $logging): int
|
||||
{
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
$items = $data['items'] ?? [];
|
||||
|
||||
$subtotal = array_reduce($items, fn($carry, $item) =>
|
||||
$carry + (float)($item['total_price'] ?? 0), 0.0
|
||||
);
|
||||
$discount = (float)($data['discount'] ?? 0);
|
||||
$tax = (float)($data['tax'] ?? 0);
|
||||
$shipping_fee = (float)($data['shipping_fee'] ?? 0);
|
||||
$grand_total = $subtotal - $discount + $tax + $shipping_fee;
|
||||
|
||||
if ($id > 0) {
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT status, `log` FROM td_purchase_order
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) throw new Exception("Purchase order not found.");
|
||||
if ((int)$row['status'] !== 0) throw new Exception("Only draft POs can be edited.");
|
||||
|
||||
$log = json_decode($row['log'] ?? '[]', true) ?: [];
|
||||
$log[] = $logging;
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_purchase_order SET
|
||||
contact_id = :contact_id,
|
||||
po_date = :po_date,
|
||||
expected_date = :expected_date,
|
||||
warehouse_id = :warehouse_id,
|
||||
items = :items,
|
||||
subtotal = :subtotal,
|
||||
discount = :discount,
|
||||
tax = :tax,
|
||||
shipping_fee = :shipping_fee,
|
||||
grand_total = :grand_total,
|
||||
notes = :notes,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':po_date' => $data['po_date'] ?? date('Y-m-d'),
|
||||
':expected_date' => $data['expected_date'] ?: null,
|
||||
':warehouse_id' => (int)($data['warehouse_id'] ?? 0),
|
||||
':items' => json_encode($items, JSON_UNESCAPED_UNICODE),
|
||||
':subtotal' => $subtotal,
|
||||
':discount' => $discount,
|
||||
':tax' => $tax,
|
||||
':shipping_fee' => $shipping_fee,
|
||||
':grand_total' => $grand_total,
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':log' => json_encode($log),
|
||||
':id' => $id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
|
||||
return 0;
|
||||
|
||||
} else {
|
||||
|
||||
$log = [$logging];
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO td_purchase_order
|
||||
(company_id, uuid, po_number, contact_id, po_date, expected_date,
|
||||
warehouse_id, status, payment_status, subtotal, discount, tax,
|
||||
shipping_fee, grand_total, items, notes, `log`, created_at)
|
||||
VALUES
|
||||
(:company_id, :uuid, :po_number, :contact_id, :po_date, :expected_date,
|
||||
:warehouse_id, 0, 0, :subtotal, :discount, :tax,
|
||||
:shipping_fee, :grand_total, :items, :notes, :log, :created_at)"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':po_number' => $this->generatePoNumber(),
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':po_date' => $data['po_date'] ?? date('Y-m-d'),
|
||||
':expected_date' => $data['expected_date'] ?: null,
|
||||
':warehouse_id' => (int)($data['warehouse_id'] ?? 0),
|
||||
':subtotal' => $subtotal,
|
||||
':discount' => $discount,
|
||||
':tax' => $tax,
|
||||
':shipping_fee' => $shipping_fee,
|
||||
':grand_total' => $grand_total,
|
||||
':items' => json_encode($items, JSON_UNESCAPED_UNICODE),
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':log' => json_encode($log),
|
||||
':created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
return (int)$this->pdo->lastInsertId();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a draft PO — advance status 0 → 1.
|
||||
* No stock rows are created at this stage; receipt happens via receivePo().
|
||||
*
|
||||
* @throws Exception If PO not found or not in draft.
|
||||
*/
|
||||
public function confirmPo(int $po_id, array $logging): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM td_purchase_order
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $po_id]);
|
||||
$po = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$po) throw new Exception("Purchase order not found.");
|
||||
if ((int)$po['status'] !== 0) throw new Exception("Only draft POs can be confirmed.");
|
||||
|
||||
$log = json_decode($po['log'] ?? '[]', true) ?: [];
|
||||
$log[] = array_merge($logging, ['action' => 'confirm']);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_purchase_order SET
|
||||
status = 1,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':log' => json_encode($log),
|
||||
':id' => $po_id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive goods against a confirmed PO — creates draft stock-in rows.
|
||||
*
|
||||
* Flow per received line:
|
||||
* 1. Calls StockManager::saveStockIn() with source='po', source_id=po_id.
|
||||
* 2. Writes stock_in_id back into the PO item object (same as stock_out_id in orders).
|
||||
* 3. Increments received_qty on the item.
|
||||
*
|
||||
* After all lines:
|
||||
* 4. If all items are fully received → status = 3 (completed).
|
||||
* 5. If partially received → status = 2 (partial).
|
||||
*
|
||||
* Can be called multiple times for partial deliveries.
|
||||
* If auto_approve=true (from company settings), approveStock() is also called.
|
||||
*
|
||||
* @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,
|
||||
* expiry_date, serial_number, contact_id }
|
||||
* @param string $uuid UUID prefix for stock-in rows.
|
||||
* @param array $logging Audit entry.
|
||||
* @param bool $auto_approve Auto-approve stock-in rows immediately.
|
||||
*/
|
||||
public function receivePo(
|
||||
int $po_id,
|
||||
array $receive_items,
|
||||
string $uuid,
|
||||
array $logging,
|
||||
bool $auto_approve = false
|
||||
): void {
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM td_purchase_order
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $po_id]);
|
||||
$po = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$po) throw new Exception("Purchase order not found.");
|
||||
|
||||
$status = (int)$po['status'];
|
||||
if ($status === -1) throw new Exception("Cannot receive against a cancelled PO.");
|
||||
if ($status === 0) throw new Exception("Confirm the PO before receiving goods.");
|
||||
if ($status === 3) throw new Exception("This PO is already fully received.");
|
||||
|
||||
if (empty($receive_items)) {
|
||||
throw new Exception("No items provided to receive.");
|
||||
}
|
||||
|
||||
$po_items = json_decode($po['items'] ?? '[]', true) ?: [];
|
||||
|
||||
// Index PO items by item_id for quick lookup
|
||||
$po_items_by_id = [];
|
||||
foreach ($po_items as $i => $item) {
|
||||
$po_items_by_id[(int)($item['item_id'] ?? $i)] = $i;
|
||||
}
|
||||
|
||||
$stock = new StockManager($this->pdo, $this->company_id);
|
||||
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
|
||||
|
||||
foreach ($receive_items as $j => $recv) {
|
||||
$item_id = (int)($recv['item_id'] ?? -1);
|
||||
$product_sku = $recv['product_sku'] ?? '';
|
||||
$warehouse_id = (int)($recv['warehouse_id'] ?? $po['warehouse_id']);
|
||||
$quantity = (float)($recv['quantity'] ?? 0);
|
||||
|
||||
if ($quantity <= 0) continue;
|
||||
if (!$product_sku) throw new Exception("Item #{$j}: missing product_sku.");
|
||||
if (!$warehouse_id) throw new Exception("Item #{$j}: missing warehouse_id.");
|
||||
|
||||
$item_uuid = $uuid . '_' . $j;
|
||||
$item_log = array_merge($logging, ['action' => 'receive_item']);
|
||||
|
||||
$rack = $recv['rack'] ?? '';
|
||||
$zone = $recv['zone'] ?? '';
|
||||
$aisle = $recv['aisle'] ?? '';
|
||||
|
||||
// Simple location mode: zone and aisle must mirror the rack 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;
|
||||
|
||||
$stock_data = [
|
||||
'id' => 0,
|
||||
'warehouse' => $warehouse_id,
|
||||
'product_sku' => $product_sku,
|
||||
'quantity' => $quantity,
|
||||
'zone' => $zone,
|
||||
'aisle' => $aisle,
|
||||
'rack' => $rack,
|
||||
'lot_number' => $recv['lot_number'] ?? '',
|
||||
'expiry_date' => $recv['expiry_date'] ?? '',
|
||||
'serial_number' => $recv['serial_number'] ?? '',
|
||||
'contact_id' => (int)($recv['contact_id'] ?? $po['contact_id'] ?? 0),
|
||||
'description' => $po['po_number'],
|
||||
'source' => 'po',
|
||||
'source_id' => $po_id,
|
||||
];
|
||||
|
||||
$new_stock_in_id = $stock->saveStockIn($stock_data, $item_log, $item_uuid);
|
||||
|
||||
if ($new_stock_in_id > 0) {
|
||||
// saveStockIn() does not write source/source_id — stamp them here.
|
||||
// This links the stock-in row back to this PO for cancellation guards
|
||||
// and makes it appear under the "PO" source tab on the Stock In list.
|
||||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||||
$this->pdo->prepare(
|
||||
"UPDATE `{$table}` SET source = 'po', source_id = :po_id
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':po_id' => $po_id,
|
||||
':id' => $new_stock_in_id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
|
||||
// approveStock() handles balance updates. saveStockIn() has
|
||||
// already reserved the rack so draft receipts cannot be reused.
|
||||
if ($auto_approve) {
|
||||
$stock->approveStock($new_stock_in_id, $warehouse_id, 'in', $whMgmt);
|
||||
}
|
||||
}
|
||||
|
||||
// Write stock_in_id + received qty back into PO item
|
||||
if ($item_id >= 0 && isset($po_items_by_id[$item_id])) {
|
||||
$idx = $po_items_by_id[$item_id];
|
||||
$already_received = (float)($po_items[$idx]['received_qty'] ?? 0);
|
||||
$po_items[$idx]['received_qty'] = $already_received + $quantity;
|
||||
$po_items[$idx]['stock_in_id'] = $new_stock_in_id;
|
||||
$po_items[$idx]['receive_wh'] = $warehouse_id;
|
||||
$po_items[$idx]['receive_zone'] = $zone;
|
||||
$po_items[$idx]['receive_aisle'] = $aisle;
|
||||
$po_items[$idx]['receive_rack'] = $rack;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine new PO status
|
||||
$all_received = true;
|
||||
foreach ($po_items as $item) {
|
||||
$ordered = (float)($item['quantity'] ?? 0);
|
||||
$received = (float)($item['received_qty'] ?? 0);
|
||||
if ($received < $ordered) {
|
||||
$all_received = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$new_status = $all_received ? 3 : 2;
|
||||
|
||||
$log = json_decode($po['log'] ?? '[]', true) ?: [];
|
||||
$log[] = array_merge($logging, ['action' => 'receive', 'new_status' => $new_status]);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_purchase_order SET
|
||||
status = :status,
|
||||
items = :items,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':status' => $new_status,
|
||||
':items' => json_encode($po_items, JSON_UNESCAPED_UNICODE),
|
||||
':log' => json_encode($log),
|
||||
':id' => $po_id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update payment status on a PO.
|
||||
* Allowed for status >= 1 (confirmed, partial, completed).
|
||||
*
|
||||
* @param int $po_id td_purchase_order.id
|
||||
* @param int $payment_status 0=unpaid, 1=paid, 2=partial
|
||||
* @param array $logging Audit entry.
|
||||
*/
|
||||
public function updatePaymentStatus(int $po_id, int $payment_status, array $logging): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT status, `log` FROM td_purchase_order
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $po_id]);
|
||||
$po = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$po) throw new Exception("Purchase order not found.");
|
||||
if ((int)$po['status'] < 1) throw new Exception("Confirm the PO before updating payment.");
|
||||
if ((int)$po['status'] === -1) throw new Exception("Cannot update a cancelled PO.");
|
||||
|
||||
if (!in_array($payment_status, [0, 1, 2], true)) {
|
||||
throw new Exception("Invalid payment status.");
|
||||
}
|
||||
|
||||
$log = json_decode($po['log'] ?? '[]', true) ?: [];
|
||||
$log[] = array_merge($logging, ['action' => 'update_payment', 'payment_status' => $payment_status]);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_purchase_order SET
|
||||
payment_status = :payment_status,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':payment_status' => $payment_status,
|
||||
':log' => json_encode($log),
|
||||
':id' => $po_id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a PO.
|
||||
*
|
||||
* Blocked if any linked stock-in rows have already been approved (status=1)
|
||||
* because those have modified rack occupancy and balance.
|
||||
*
|
||||
* For draft stock-in rows (status=0), they are soft-deleted (status=-1).
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function cancelPo(int $po_id, array $logging): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM td_purchase_order
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $po_id]);
|
||||
$po = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$po) throw new Exception("Purchase order not found.");
|
||||
|
||||
$status = (int)$po['status'];
|
||||
if ($status === -1) throw new Exception("PO is already cancelled.");
|
||||
if ($status === 3) throw new Exception("Cannot cancel a completed PO.");
|
||||
|
||||
// Guard: block if any approved stock-in rows exist for this PO
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name LIKE 'td_stock_%'"
|
||||
);
|
||||
$sth->execute();
|
||||
$tables = $sth->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
foreach ($tables as $table) {
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM `{$table}`
|
||||
WHERE company_id = :company_id
|
||||
AND source = 'po'
|
||||
AND source_id = :po_id
|
||||
AND status = 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':po_id' => $po_id]);
|
||||
if ((int)$sth->fetchColumn() > 0) {
|
||||
throw new Exception(
|
||||
"Cannot cancel — stock from this PO has already been approved and received. " .
|
||||
"Please adjust stock manually if needed."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
|
||||
|
||||
// Soft-delete draft stock-in rows and release their rack reservations.
|
||||
foreach ($tables as $table) {
|
||||
if (!preg_match('/^td_stock_(\d+)$/', $table, $m)) {
|
||||
continue;
|
||||
}
|
||||
$warehouse_id = (int)$m[1];
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, zone, aisle, rack
|
||||
FROM `{$table}`
|
||||
WHERE company_id = :company_id
|
||||
AND source = 'po'
|
||||
AND source_id = :po_id
|
||||
AND status = 0"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':po_id' => $po_id]);
|
||||
$draft_rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($draft_rows as $row) {
|
||||
$this->pdo->prepare(
|
||||
"UPDATE `{$table}` SET status = -1
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':id' => (int)$row['id'],
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
|
||||
$whMgmt->releaseRack(
|
||||
$warehouse_id,
|
||||
$row['zone'],
|
||||
$row['aisle'],
|
||||
$row['rack']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$log = json_decode($po['log'] ?? '[]', true) ?: [];
|
||||
$log[] = array_merge($logging, ['action' => 'cancel']);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_purchase_order SET
|
||||
status = -1,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':log' => json_encode($log),
|
||||
':id' => $po_id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -16,17 +16,7 @@
|
||||
* Balance summary → getWarehouseBalanceSummary
|
||||
*
|
||||
* Security: All SQL uses PDO prepared statements with bound parameters.
|
||||
* Dynamic table names (td_stock_<warehouse>) are derived from DB-sourced
|
||||
* warehouse names sanitised with preg_replace('/[^a-zA-Z0-9_]/', '', ...)
|
||||
* before interpolation. Integer parameters (warehouse_id, company_id, limit)
|
||||
* are explicitly cast to (int) before use in any SQL string fragment.
|
||||
*
|
||||
* Security fixes applied vs. previous version:
|
||||
* - getRecentActivity: warehouse_name in SQL now uses $safe (was unescaped)
|
||||
* - getExpiredStock: warehouse_id cast to (int) before SQL fragment injection;
|
||||
* warehouse_name in UNION now uses $safe (was unescaped)
|
||||
* - getRackLog: cross-DB join uses $mainDb injected at construction time
|
||||
* - getRackOccupancy: (already safe — no dynamic identifiers)
|
||||
* Dynamic stock table names are derived only from md_warehouse.id.
|
||||
*/
|
||||
class ReportManager
|
||||
{
|
||||
@@ -45,26 +35,26 @@ class ReportManager
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the td_stock_<wh> table name for a warehouse, requiring active status.
|
||||
*
|
||||
* Returns null if the warehouse is not found or inactive.
|
||||
* The warehouse_name is sanitised before use as a table suffix.
|
||||
*
|
||||
* @param int $warehouse_id The md_warehouse.id to resolve.
|
||||
* @return string|null Sanitised table name, or null if not active/found.
|
||||
*/
|
||||
private function stockTableNameFromWarehouseId(int $warehouse_id): string
|
||||
{
|
||||
if ($warehouse_id <= 0) {
|
||||
throw new Exception("Invalid warehouse id.");
|
||||
}
|
||||
|
||||
return 'td_stock_' . $warehouse_id;
|
||||
}
|
||||
|
||||
private function resolveWarehouseTable(int $warehouse_id): ?string
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT warehouse_name FROM md_warehouse
|
||||
"SELECT id FROM md_warehouse
|
||||
WHERE company_id = :company_id AND id = :id AND status = 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId, ':id' => $warehouse_id]);
|
||||
$name = $sth->fetchColumn();
|
||||
if (!$name) return null;
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
|
||||
return "td_stock_{$safe}";
|
||||
$id = $sth->fetchColumn();
|
||||
if (!$id) return null;
|
||||
|
||||
return $this->stockTableNameFromWarehouseId((int)$id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -569,9 +559,6 @@ class ReportManager
|
||||
* Builds a UNION ALL across all td_stock_* tables to produce a unified
|
||||
* activity feed ordered by date DESC. Used by the dashboard "recent activity" widget.
|
||||
*
|
||||
* Security fix: warehouse_name in UNION SQL now uses $safe (sanitised with
|
||||
* preg_replace) instead of the raw warehouse_name string.
|
||||
*
|
||||
* @param int $limit Maximum number of transactions to return (default 10).
|
||||
* @return array Activity rows with product_name, product_sku, warehouse_name,
|
||||
* direction ('in'|'out'), qty, type, date.
|
||||
@@ -587,18 +574,17 @@ class ReportManager
|
||||
|
||||
if (empty($warehouses)) return [];
|
||||
|
||||
// Security: use $safe (sanitised name) for both the table identifier
|
||||
// and the literal warehouse_name string in the SELECT — never raw $wh['warehouse_name'].
|
||||
$unions = implode("\nUNION ALL\n", array_map(
|
||||
function ($wh) {
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
|
||||
$cid = (int) $this->companyId;
|
||||
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
|
||||
$cid = (int) $this->companyId;
|
||||
$warehouse_name = $this->pdo->quote($wh['warehouse_name']);
|
||||
return "SELECT s.date, s.product_sku, s.type,
|
||||
ROUND(COALESCE(s.`in`, 0), 2) AS stock_in,
|
||||
ROUND(COALESCE(s.`out`, 0), 2) AS stock_out,
|
||||
p.product_name,
|
||||
'{$safe}' AS warehouse_name
|
||||
FROM `td_stock_{$safe}` s
|
||||
{$warehouse_name} AS warehouse_name
|
||||
FROM `{$table}` s
|
||||
LEFT JOIN md_product p
|
||||
ON p.company_id = s.company_id
|
||||
AND p.sku = s.product_sku
|
||||
@@ -721,7 +707,7 @@ class ReportManager
|
||||
/**
|
||||
* Return monthly stock_in and stock_out totals for a warehouse over the last 12 months.
|
||||
*
|
||||
* Queries the per-warehouse td_stock_<wh> table directly (not warehouse_balance)
|
||||
* Queries the per-warehouse td_stock_<warehouse_id> table directly (not warehouse_balance)
|
||||
* for per-warehouse granularity. Produces chart-ready arrays with month labels.
|
||||
*
|
||||
* @param int $warehouse_id The warehouse to query.
|
||||
@@ -899,8 +885,6 @@ class ReportManager
|
||||
*
|
||||
* Security fix: $warehouse_id is now cast to (int) and the WHERE condition
|
||||
* uses a bound parameter (:warehouse_id) instead of raw string interpolation.
|
||||
* warehouse_name in UNION now uses $safe variable (sanitised) not raw $wh['warehouse_name'].
|
||||
*
|
||||
* @param int $warehouse_id Warehouse filter (0 = all warehouses).
|
||||
* @return array Expiry-status stock items with product, lot, location, and days_remaining.
|
||||
*/
|
||||
@@ -928,10 +912,9 @@ class ReportManager
|
||||
|
||||
$cid = (int) $this->companyId; // cast before interpolation
|
||||
|
||||
// Security: use $safe (sanitised) for table identifier and literal warehouse name string
|
||||
$unions = implode("\nUNION ALL\n", array_map(
|
||||
function ($wh) use ($cid) {
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
|
||||
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
|
||||
return "SELECT
|
||||
s.id,
|
||||
s.product_sku,
|
||||
@@ -941,7 +924,7 @@ class ReportManager
|
||||
s.rack,
|
||||
ROUND(s.`in`, 2) AS quantity,
|
||||
{$wh['id']} AS warehouse_id
|
||||
FROM `td_stock_{$safe}` s
|
||||
FROM `{$table}` s
|
||||
WHERE s.company_id = {$cid}
|
||||
AND s.type = 'in'
|
||||
AND s.lot_number IS NOT NULL
|
||||
@@ -1044,8 +1027,8 @@ class ReportManager
|
||||
$rows = [];
|
||||
|
||||
foreach ($warehouses as $wh) {
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
|
||||
$table = "td_stock_{$safe}";
|
||||
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
|
||||
$warehouse_name = $this->pdo->quote($wh['warehouse_name']);
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT
|
||||
@@ -1059,7 +1042,7 @@ class ReportManager
|
||||
ROUND(COALESCE(s.`out`, 0), 2) AS stock_out,
|
||||
s.serial_number,
|
||||
s.description,
|
||||
'{$safe}' AS warehouse_name
|
||||
{$warehouse_name} AS warehouse_name
|
||||
FROM `{$table}` s
|
||||
WHERE s.company_id = :company_id
|
||||
AND s.product_sku = :product_sku
|
||||
@@ -1109,8 +1092,7 @@ class ReportManager
|
||||
$cid = (int) $this->companyId;
|
||||
|
||||
foreach ($wh_list as $wh) {
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
|
||||
$table = "td_stock_{$safe}";
|
||||
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
|
||||
|
||||
$sth = $this->pdo->query(
|
||||
"SELECT lot_number,
|
||||
@@ -1195,7 +1177,10 @@ class ReportManager
|
||||
{
|
||||
if (!$rack_id) return [];
|
||||
|
||||
$main_db = preg_replace('/[^a-zA-Z0-9_]/', '', $this->mainDb);
|
||||
$main_db = $this->mainDb;
|
||||
if ($main_db === '' || !ctype_alnum(str_replace('_', '', $main_db))) {
|
||||
throw new Exception("Invalid main database name.");
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT
|
||||
@@ -1287,7 +1272,7 @@ class ReportManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Return paginated raw stock movement transactions from td_stock_<warehouse>
|
||||
* Return paginated raw stock movement transactions from td_stock_<warehouse_id>
|
||||
* within a date range (by month), with a balance brought forward.
|
||||
*
|
||||
* Each row is one individual transaction — no grouping. Full datetime is
|
||||
@@ -1412,13 +1397,15 @@ class ReportManager
|
||||
$bf_out = (float)($bf['bf_out'] ?? 0);
|
||||
$bf_bal = round($bf_in - $bf_out, 2);
|
||||
|
||||
// Product name
|
||||
// Product name + UOM
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT product_name FROM md_product
|
||||
"SELECT product_name, uom FROM md_product
|
||||
WHERE company_id = :company_id AND sku = :sku LIMIT 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId, ':sku' => $product_sku]);
|
||||
$product_name = $sth->fetchColumn() ?: $product_sku;
|
||||
$product_row = $sth->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
$product_name = $product_row['product_name'] ?: $product_sku;
|
||||
$product_uom = $product_row['uom'] ?? '';
|
||||
|
||||
// All transactions for this SKU in range, sorted by date then id
|
||||
$sth = $this->pdo->prepare(
|
||||
@@ -1460,6 +1447,7 @@ class ReportManager
|
||||
return [
|
||||
'sku' => $product_sku,
|
||||
'product_name' => $product_name,
|
||||
'uom' => $product_uom,
|
||||
'brought_forward' => [
|
||||
'in' => $bf_in,
|
||||
'out' => $bf_out,
|
||||
@@ -1468,4 +1456,4 @@ class ReportManager
|
||||
'rows' => $rows,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* Key design decisions:
|
||||
* - status: -1=cancelled, 0=draft, 1=confirmed (consistent with td_order)
|
||||
* - confirmReturn() is the single restock path for all customer returns:
|
||||
* 1. INSERT td_stock_<wh> in rows (source='return', source_id=return_id,
|
||||
* 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.
|
||||
@@ -27,7 +27,7 @@
|
||||
* Callers must wrap multi-step operations inside dbTransaction().
|
||||
*
|
||||
* Security: All SQL uses PDO prepared statements with bound parameters.
|
||||
* Dynamic table names are sanitised before interpolation.
|
||||
* Dynamic stock table names are derived only from md_warehouse.id.
|
||||
*/
|
||||
class ReturnManager {
|
||||
|
||||
@@ -72,17 +72,13 @@ class ReturnManager {
|
||||
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
private function resolveStockTable(int $warehouse_id): string
|
||||
private function stockTableNameFromWarehouseId(int $warehouse_id): string
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT warehouse_name FROM md_warehouse
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]);
|
||||
$name = $sth->fetchColumn();
|
||||
if ($warehouse_id <= 0) {
|
||||
throw new Exception("Invalid warehouse id.");
|
||||
}
|
||||
|
||||
if (!$name) throw new Exception("Warehouse ID {$warehouse_id} not found.");
|
||||
return 'td_stock_' . preg_replace('/[^a-zA-Z0-9_]/', '', $name);
|
||||
return 'td_stock_' . $warehouse_id;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
@@ -254,9 +250,9 @@ class ReturnManager {
|
||||
* Confirm a return — restock items back to warehouse + optionally create credit note.
|
||||
*
|
||||
* Flow per item:
|
||||
* 1. INSERT td_stock_<wh> in row with type='in', status=1 (force confirmed),
|
||||
* 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 back into the original rack.
|
||||
* 2. occupyRack() — place returned stock into the user-selected empty rack.
|
||||
* 3. adjustBalance() — update warehouse_balance.
|
||||
*
|
||||
* After all items:
|
||||
@@ -304,13 +300,42 @@ class ReturnManager {
|
||||
foreach ($items as $i => $item) {
|
||||
|
||||
$warehouse_id = (int)($item['warehouse_id'] ?? 0);
|
||||
$stock_out_wh = (int)($item['stock_out_warehouse_id'] ?? $warehouse_id);
|
||||
$stock_out_id = (int)($item['stock_out_id'] ?? 0);
|
||||
$product_sku = $item['product_sku'] ?? '';
|
||||
|
||||
if (!$warehouse_id || !$product_sku) {
|
||||
throw new Exception("Item #{$i}: missing warehouse_id or product_sku.");
|
||||
}
|
||||
if (!$stock_out_wh || !$stock_out_id) {
|
||||
throw new Exception("Item #{$i}: missing linked stock-out record.");
|
||||
}
|
||||
|
||||
$table = $this->resolveStockTable($warehouse_id);
|
||||
$stock_out_table = $this->stockTableNameFromWarehouseId($stock_out_wh);
|
||||
$stock_out_sth = $this->pdo->prepare(
|
||||
"SELECT status, lot_number, serial_number
|
||||
FROM `{$stock_out_table}`
|
||||
WHERE company_id = :company_id
|
||||
AND id = :id
|
||||
AND product_sku = :product_sku
|
||||
AND type = 'out'
|
||||
LIMIT 1"
|
||||
);
|
||||
$stock_out_sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':id' => $stock_out_id,
|
||||
':product_sku' => $product_sku,
|
||||
]);
|
||||
$stock_out = $stock_out_sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$stock_out) {
|
||||
throw new Exception("Item #{$i}: linked stock-out record not found.");
|
||||
}
|
||||
if ((int)$stock_out['status'] !== 1) {
|
||||
throw new Exception("Item #{$i}: stock-out is still draft. Approve stock-out before confirming the return.");
|
||||
}
|
||||
|
||||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||||
$item_uuid = $uuid . '_ret_' . $i;
|
||||
$item_log = [array_merge($logging, ['action' => 'confirm_return_item'])];
|
||||
$quantity = (float)($item['quantity'] ?? 0);
|
||||
@@ -341,8 +366,8 @@ class ReturnManager {
|
||||
':contact_id' => (int)$return['contact_id'],
|
||||
':description' => $return['return_number'],
|
||||
':log' => json_encode($item_log),
|
||||
':lot_number' => $item['lot_number'] ?? '',
|
||||
':serial_number' => $item['serial_number'] ?? '',
|
||||
':lot_number' => $stock_out['lot_number'] ?? ($item['lot_number'] ?? ''),
|
||||
':serial_number' => $stock_out['serial_number'] ?? ($item['serial_number'] ?? ''),
|
||||
':source_id' => $return_id,
|
||||
':price' => (float)($item['price'] ?? 0),
|
||||
]);
|
||||
@@ -356,7 +381,8 @@ class ReturnManager {
|
||||
$item['aisle'] ?? '',
|
||||
$item['rack'] ?? '',
|
||||
$product_sku,
|
||||
$stock_in_id
|
||||
$stock_in_id,
|
||||
true
|
||||
);
|
||||
$whMgmt->adjustBalance('in', $warehouse_id, $product_sku, 0, $quantity);
|
||||
}
|
||||
@@ -465,12 +491,11 @@ class ReturnManager {
|
||||
|
||||
if (!$warehouse_id || !$product_sku) continue;
|
||||
|
||||
$table = $this->resolveStockTable($warehouse_id);
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||||
|
||||
// Find the stock-in row created by confirmReturn()
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, zone, aisle, rack FROM `{$safe}`
|
||||
"SELECT id, zone, aisle, rack FROM `{$table}`
|
||||
WHERE company_id = :company_id
|
||||
AND source = 'return'
|
||||
AND source_id = :return_id
|
||||
@@ -489,7 +514,7 @@ class ReturnManager {
|
||||
|
||||
// Soft-delete the stock-in row
|
||||
$this->pdo->prepare(
|
||||
"UPDATE `{$safe}` SET status = -1
|
||||
"UPDATE `{$table}` SET status = -1
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':id' => $stock_row['id'],
|
||||
@@ -501,9 +526,7 @@ class ReturnManager {
|
||||
$warehouse_id,
|
||||
$stock_row['zone'],
|
||||
$stock_row['aisle'],
|
||||
$stock_row['rack'],
|
||||
$product_sku,
|
||||
$stock_row['id']
|
||||
$stock_row['rack']
|
||||
);
|
||||
|
||||
// Reverse balance
|
||||
@@ -526,4 +549,4 @@ class ReturnManager {
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,7 @@
|
||||
* Callers must wrap multi-step operations inside dbTransaction().
|
||||
*
|
||||
* Security: All SQL uses PDO prepared statements with bound parameters.
|
||||
* Dynamic table names (td_stock_<warehouse>) are derived from DB-sourced warehouse
|
||||
* names sanitised with preg_replace('/[^a-zA-Z0-9_]/', '', ...) before interpolation.
|
||||
* Dynamic stock table names are derived only from md_warehouse.id.
|
||||
*/
|
||||
class StockManager {
|
||||
|
||||
@@ -36,34 +35,13 @@ class StockManager {
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the dynamic td_stock_<warehouse> table name for a warehouse_id.
|
||||
*
|
||||
* Looks up warehouse_name from md_warehouse (no status filter — unlike
|
||||
* WarehouseManager::resolveWarehouseTable, this serves read flows that may
|
||||
* need to access inactive warehouses for historical record retrieval).
|
||||
* The name is sanitised with preg_replace before being used as a table
|
||||
* identifier, preventing SQL injection via malicious warehouse names.
|
||||
*
|
||||
* @param int $warehouse_id The md_warehouse.id to resolve.
|
||||
* @return string The sanitised table name, e.g. "td_stock_Main".
|
||||
* @throws Exception If no warehouse is found for the given ID.
|
||||
*/
|
||||
private function resolveTable(int $warehouse_id): string
|
||||
private function stockTableNameFromWarehouseId(int $warehouse_id): string
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT warehouse_name FROM md_warehouse
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $warehouse_id]);
|
||||
$name = $sth->fetchColumn();
|
||||
|
||||
if (!$name) {
|
||||
throw new Exception("Warehouse not found.");
|
||||
if ($warehouse_id <= 0) {
|
||||
throw new Exception("Invalid warehouse id.");
|
||||
}
|
||||
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
|
||||
return "td_stock_{$safe}";
|
||||
return 'td_stock_' . $warehouse_id;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
@@ -83,14 +61,14 @@ class StockManager {
|
||||
*/
|
||||
public function getStockList(int $warehouse_id, string $type): array
|
||||
{
|
||||
$table = $this->resolveTable($warehouse_id);
|
||||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||||
$column = $type === 'out' ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)';
|
||||
|
||||
// Transfer list: show only the outbound side (out > 0) to avoid duplicate display
|
||||
$extra_cond = ($type === 'transfer') ? 'AND a.out > 0' : '';
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT a.*, {$column} AS quantity, b.product_name
|
||||
"SELECT a.*, {$column} AS quantity, b.product_name, b.uom
|
||||
FROM `{$table}` a
|
||||
LEFT JOIN md_product b
|
||||
ON a.company_id = b.company_id
|
||||
@@ -114,18 +92,18 @@ class StockManager {
|
||||
* stock in detail view. Joins md_lot to include lot expiry_date when available.
|
||||
*
|
||||
* @param int $warehouse_id The warehouse the stock_in belongs to.
|
||||
* @param int $id The td_stock_<wh>.id of the stock_in row.
|
||||
* @param int $id The td_stock_<warehouse_id>.id of the stock_in row.
|
||||
* @return array|false Full row with 'quantity', 'contact_name', 'product_name',
|
||||
* 'expiry_date', or false if not found.
|
||||
*/
|
||||
public function getStockInById(int $warehouse_id, int $id): array|false
|
||||
{
|
||||
$table = $this->resolveTable($warehouse_id);
|
||||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT a.*, a.in AS quantity,
|
||||
b.contact_name,
|
||||
c.product_name,
|
||||
c.product_name, c.uom,
|
||||
d.expiry_date
|
||||
FROM `{$table}` a
|
||||
LEFT JOIN md_contact b
|
||||
@@ -149,18 +127,18 @@ class StockManager {
|
||||
* stock out detail view.
|
||||
*
|
||||
* @param int $warehouse_id The warehouse the stock_out belongs to.
|
||||
* @param int $id The td_stock_<wh>.id of the stock_out row.
|
||||
* @param int $id The td_stock_<warehouse_id>.id of the stock_out row.
|
||||
* @return array|false Full row with 'quantity', 'contact_name', 'product_name',
|
||||
* or false if not found.
|
||||
*/
|
||||
public function getStockOutById(int $warehouse_id, int $id): array|false
|
||||
{
|
||||
$table = $this->resolveTable($warehouse_id);
|
||||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT a.*, a.out AS quantity,
|
||||
b.contact_name,
|
||||
c.product_name
|
||||
c.product_name, c.uom
|
||||
FROM `{$table}` a
|
||||
LEFT JOIN md_contact b
|
||||
ON a.company_id = b.company_id AND a.contact_id = b.id
|
||||
@@ -182,19 +160,19 @@ class StockManager {
|
||||
* transfer detail view.
|
||||
*
|
||||
* @param int $warehouse_id The warehouse holding the outbound (from) row.
|
||||
* @param int $id The td_stock_<wh>.id of the outbound transfer row.
|
||||
* @param int $id The td_stock_<warehouse_id>.id of the outbound transfer row.
|
||||
* @return array|false Outbound row with 'quantity', 'contact_name', 'product_name',
|
||||
* and a 'ref' key containing the inbound row, or false if not found.
|
||||
*/
|
||||
public function getTransferById(int $warehouse_id, int $id): array|false
|
||||
{
|
||||
$table = $this->resolveTable($warehouse_id);
|
||||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||||
|
||||
// Fetch the outbound (from) row
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT a.*, a.out AS quantity,
|
||||
b.contact_name,
|
||||
c.product_name
|
||||
c.product_name, c.uom
|
||||
FROM `{$table}` a
|
||||
LEFT JOIN md_contact b
|
||||
ON a.company_id = b.company_id AND a.contact_id = b.id
|
||||
@@ -209,12 +187,12 @@ class StockManager {
|
||||
|
||||
// Resolve the inbound (to) row via ref_warehouse + uuid
|
||||
$to_warehouse_id = (int)$output['ref_warehouse'];
|
||||
$to_table = $this->resolveTable($to_warehouse_id);
|
||||
$to_table = $this->stockTableNameFromWarehouseId($to_warehouse_id);
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT a.*, a.in AS quantity,
|
||||
b.contact_name,
|
||||
c.product_name
|
||||
c.product_name, c.uom
|
||||
FROM `{$to_table}` a
|
||||
LEFT JOIN md_contact b
|
||||
ON a.company_id = b.company_id AND a.contact_id = b.id
|
||||
@@ -237,9 +215,9 @@ class StockManager {
|
||||
*
|
||||
* Insert flow (id = 0):
|
||||
* 1. Upserts md_lot if lot_number + expiry_date are provided.
|
||||
* 2. Inserts the td_stock_<wh> row.
|
||||
* 3. Calls WarehouseManager::occupyRack() to mark the rack as taken.
|
||||
* 4. Calls WarehouseManager::adjustBalance() to update warehouse_balance.
|
||||
* 2. Inserts the td_stock_<warehouse_id> row.
|
||||
* 3. Calls WarehouseManager::occupyRack() to reserve the rack.
|
||||
* warehouse_balance is updated later by approveStock().
|
||||
*
|
||||
* Update flow (id > 0):
|
||||
* - Updates contact_id, description, and log only.
|
||||
@@ -328,7 +306,18 @@ class StockManager {
|
||||
|
||||
$td_stock_id = (int)$this->pdo->lastInsertId();
|
||||
|
||||
// occupyRack and adjustBalance deferred — called from approveStock() only.
|
||||
// Reserve the location immediately so draft stock-in rows cannot
|
||||
// leave the same rack available for another receipt. Balance still
|
||||
// changes only when approveStock() runs.
|
||||
$whMgmt->occupyRack(
|
||||
$warehouse_id,
|
||||
$data['zone'],
|
||||
$data['aisle'],
|
||||
$data['rack'],
|
||||
$data['product_sku'],
|
||||
$td_stock_id
|
||||
);
|
||||
|
||||
return $td_stock_id;
|
||||
}
|
||||
}
|
||||
@@ -338,7 +327,7 @@ class StockManager {
|
||||
*
|
||||
* Insert flow (id = 0):
|
||||
* 1. Validates the rack is occupied with the correct SKU / lot / serial.
|
||||
* 2. Inserts the td_stock_<wh> row, copying quantity and lot info from the rack.
|
||||
* 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.
|
||||
* 4. Calls WarehouseManager::adjustBalance() to update warehouse_balance.
|
||||
*
|
||||
@@ -609,7 +598,7 @@ class StockManager {
|
||||
':date' => date('Y-m-d H:i:s'),
|
||||
':product_sku' => $data['product_sku'],
|
||||
':quantity' => $quantity,
|
||||
':ref_warehouse' => $whMgmt->getWarehouseName($to_warehouse),
|
||||
':ref_warehouse' => $to_warehouse,
|
||||
':zone' => $from_zone,
|
||||
':aisle' => $from_aisle,
|
||||
':rack' => $from_rack,
|
||||
@@ -637,7 +626,7 @@ class StockManager {
|
||||
':date' => date('Y-m-d H:i:s'),
|
||||
':product_sku' => $data['product_sku'],
|
||||
':quantity' => $quantity,
|
||||
':ref_warehouse' => $whMgmt->getWarehouseName($from_warehouse),
|
||||
':ref_warehouse' => $from_warehouse,
|
||||
':ref_id' => $from_stock_id,
|
||||
':zone' => $to_zone,
|
||||
':aisle' => $to_aisle,
|
||||
@@ -686,7 +675,7 @@ class StockManager {
|
||||
*/
|
||||
public function approveStock(int $id, int $warehouse_id, string $type, WarehouseManager $whMgmt): void
|
||||
{
|
||||
$table = $this->resolveTable($warehouse_id);
|
||||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||||
|
||||
// Load the row and validate ownership / status
|
||||
$sth = $this->pdo->prepare(
|
||||
@@ -724,31 +713,52 @@ class StockManager {
|
||||
|
||||
} elseif ($type === 'out') {
|
||||
|
||||
// Release rack now that stock-out is approved
|
||||
$whMgmt->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']);
|
||||
$remaining_qty = 0.0;
|
||||
if ((int)($row['ref_id'] ?? 0) > 0) {
|
||||
$remaining_sth = $this->pdo->prepare(
|
||||
"SELECT src.`in` - COALESCE(SUM(out_rows.`out`), 0) AS remaining_qty
|
||||
FROM `{$table}` src
|
||||
LEFT JOIN `{$table}` out_rows
|
||||
ON out_rows.company_id = src.company_id
|
||||
AND out_rows.ref_id = src.id
|
||||
AND out_rows.`out` > 0
|
||||
AND out_rows.status != -1
|
||||
WHERE src.id = :ref_id
|
||||
AND src.company_id = :company_id
|
||||
GROUP BY src.id, src.`in`"
|
||||
);
|
||||
$remaining_sth->execute([
|
||||
':ref_id' => (int)$row['ref_id'],
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
$remaining_qty = (float)$remaining_sth->fetchColumn();
|
||||
}
|
||||
|
||||
// Release the rack only when the source batch is fully consumed.
|
||||
if ($remaining_qty <= 0.000001) {
|
||||
$whMgmt->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']);
|
||||
}
|
||||
$whMgmt->adjustBalance('out', $warehouse_id, $row['product_sku'], 0, (float)$row['out']);
|
||||
|
||||
} elseif ($type === 'transfer') {
|
||||
|
||||
// Transfer always has two rows in two different tables:
|
||||
// outbound row → td_stock_<from> (out > 0, ref_warehouse = to_name)
|
||||
// inbound row → td_stock_<to> (in > 0, ref_warehouse = from_name)
|
||||
// outbound row → td_stock_<from_id> (out > 0, ref_warehouse = to_id)
|
||||
// inbound row → td_stock_<to_id> (in > 0, ref_warehouse = from_id)
|
||||
// Both rows share the same uuid. We always approve both atomically.
|
||||
// Identify which side we were given and derive the other.
|
||||
|
||||
$is_outbound = (float)$row['out'] > 0;
|
||||
|
||||
// The outbound row lives in the from-warehouse table (already loaded as $row/$table).
|
||||
// The inbound row lives in td_stock_<ref_warehouse>.
|
||||
// The inbound row lives in td_stock_<ref_warehouse_id>.
|
||||
$from_row = $is_outbound ? $row : null;
|
||||
$from_table = $is_outbound ? $table : null;
|
||||
$from_wh_id = $is_outbound ? $warehouse_id : null;
|
||||
|
||||
// Resolve the paired table from ref_warehouse
|
||||
$paired_wh_name = $row['ref_warehouse'];
|
||||
$paired_safe = preg_replace('/[^a-zA-Z0-9_]/', '', $paired_wh_name);
|
||||
$paired_table = "td_stock_{$paired_safe}";
|
||||
$paired_wh_id = $whMgmt->getWarehouseIdByName($paired_wh_name);
|
||||
// Resolve the paired table from ref_warehouse id
|
||||
$paired_wh_id = (int)$row['ref_warehouse'];
|
||||
$paired_table = $this->stockTableNameFromWarehouseId($paired_wh_id);
|
||||
|
||||
// If we received the inbound side, swap so $from_* is always outbound
|
||||
if (!$is_outbound) {
|
||||
@@ -816,4 +826,4 @@ class StockManager {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,7 @@
|
||||
* Callers must wrap multi-step operations inside dbTransaction().
|
||||
*
|
||||
* Security: All SQL uses PDO prepared statements with bound parameters.
|
||||
* Dynamic table names (td_stock_<warehouse>) are derived exclusively from
|
||||
* DB-sourced warehouse names sanitised with preg_replace('/[^a-zA-Z0-9_]/', '', ...)
|
||||
* before interpolation — no user input ever reaches a table identifier directly.
|
||||
* Dynamic stock table names are derived only from md_warehouse.id.
|
||||
*/
|
||||
class WarehouseManager {
|
||||
|
||||
@@ -35,27 +33,26 @@ class WarehouseManager {
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the td_stock_<wh> table name for a warehouse, requiring active status.
|
||||
*
|
||||
* Returns null if the warehouse is not found or is inactive (status != 1).
|
||||
* Used by Zone/Aisle/Rack out-query methods where inactive warehouses
|
||||
* should not contribute available stock locations.
|
||||
*
|
||||
* @param int $warehouse_id The md_warehouse.id to resolve.
|
||||
* @return string|null Sanitised table name, or null if not active/found.
|
||||
*/
|
||||
private function stockTableNameFromWarehouseId(int $warehouse_id): string
|
||||
{
|
||||
if ($warehouse_id <= 0) {
|
||||
throw new Exception("Invalid warehouse id.");
|
||||
}
|
||||
|
||||
return 'td_stock_' . $warehouse_id;
|
||||
}
|
||||
|
||||
private function resolveWarehouseTable(int $warehouse_id): ?string
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT warehouse_name FROM md_warehouse
|
||||
"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]);
|
||||
$name = $sth->fetchColumn();
|
||||
if (!$name) return null;
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
|
||||
return "td_stock_{$safe}";
|
||||
$id = $sth->fetchColumn();
|
||||
if (!$id) return null;
|
||||
|
||||
return $this->stockTableNameFromWarehouseId((int)$id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -330,6 +327,90 @@ class WarehouseManager {
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete rack logs created by a stock movement and its reversal.
|
||||
*
|
||||
* md_rack_log follows the same soft-delete convention as other tenant rows:
|
||||
* negate company_id so normal company_id filters hide the records.
|
||||
*/
|
||||
private function softDeleteRackLogs(
|
||||
$warehouse_id,
|
||||
string $zone,
|
||||
string $aisle,
|
||||
string $rack,
|
||||
array $actions,
|
||||
string $product_sku,
|
||||
array $td_stock_ids = [],
|
||||
?string $since = null
|
||||
): void {
|
||||
if (empty($actions)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rack_sth = $this->pdo->prepare(
|
||||
"SELECT id FROM md_rack
|
||||
WHERE company_id = :company_id
|
||||
AND warehouse = :warehouse
|
||||
AND zone = :zone
|
||||
AND aisle = :aisle
|
||||
AND rack = :rack
|
||||
LIMIT 1"
|
||||
);
|
||||
$rack_sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':warehouse' => $warehouse_id,
|
||||
':zone' => $zone,
|
||||
':aisle' => $aisle,
|
||||
':rack' => $rack,
|
||||
]);
|
||||
$rack_id = (int)$rack_sth->fetchColumn();
|
||||
if (!$rack_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$params = [
|
||||
':company_id' => $this->company_id,
|
||||
':md_rack_id' => $rack_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 md_rack_log
|
||||
SET company_id = company_id * -1
|
||||
WHERE company_id = :company_id
|
||||
AND md_rack_id = :md_rack_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 racks under a storage_id all fall inside a new range.
|
||||
*
|
||||
@@ -340,15 +421,14 @@ class WarehouseManager {
|
||||
* @param array $range Keys: aisle_from, aisle_to, rack_from, rack_to.
|
||||
* @throws Exception If occupied racks would fall outside the new range.
|
||||
*/
|
||||
private function validateRangeChange(int $storage_id, array $range): void
|
||||
private function validateRangeChange(int $storage_id, array $range, bool $advanced_location): void
|
||||
{
|
||||
// Build the valid sets from the new range
|
||||
$valid_aisles = $this->rangeToArray($range['aisle_from'], $range['aisle_to']);
|
||||
$valid_racks = $this->rangeToArray($range['rack_from'], $range['rack_to']);
|
||||
|
||||
// Fetch all occupied racks under this storage_id
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT aisle, rack FROM md_rack
|
||||
"SELECT zone, aisle, rack FROM md_rack
|
||||
WHERE company_id = :company_id
|
||||
AND storage_id = :storage_id
|
||||
AND product_sku IS NOT NULL"
|
||||
@@ -359,10 +439,18 @@ class WarehouseManager {
|
||||
]);
|
||||
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$z = trim($row['zone']);
|
||||
$a = trim($row['aisle']);
|
||||
$r = trim($row['rack']);
|
||||
|
||||
if (!in_array($a, $valid_aisles, true) || !in_array($r, $valid_racks, true)) {
|
||||
if ($advanced_location) {
|
||||
if (in_array($a, $valid_aisles, true) && in_array($r, $valid_racks, true)) {
|
||||
continue;
|
||||
}
|
||||
throw new Exception("Cannot shrink range — some racks still have stock");
|
||||
}
|
||||
|
||||
if ($z !== $a || $a !== $r || !in_array($r, $valid_racks, true)) {
|
||||
throw new Exception("Cannot shrink range — some racks still have stock");
|
||||
}
|
||||
}
|
||||
@@ -377,7 +465,7 @@ class WarehouseManager {
|
||||
* @param int $storage_id The md_storage.id this range belongs to.
|
||||
* @param array $range Keys: warehouse, zone, aisle_from, aisle_to, rack_from, rack_to.
|
||||
*/
|
||||
private function insertRacksInRange(int $storage_id, array $range): void
|
||||
private function insertRacksInRange(int $storage_id, array $range, bool $advanced_location): void
|
||||
{
|
||||
$sql = "INSERT IGNORE INTO md_rack
|
||||
(company_id, warehouse, storage_id, zone, aisle, rack)
|
||||
@@ -388,6 +476,20 @@ class WarehouseManager {
|
||||
$aisles = $this->rangeToArray($range['aisle_from'], $range['aisle_to']);
|
||||
$racks = $this->rangeToArray($range['rack_from'], $range['rack_to']);
|
||||
|
||||
if (!$advanced_location) {
|
||||
foreach ($racks as $location) {
|
||||
$sth->execute([
|
||||
":company_id" => $this->company_id,
|
||||
":warehouse" => $range['warehouse'],
|
||||
":storage_id" => $storage_id,
|
||||
":zone" => $location,
|
||||
":aisle" => $location,
|
||||
":rack" => $location,
|
||||
]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($aisles as $a) {
|
||||
foreach ($racks as $r) {
|
||||
$sth->execute([
|
||||
@@ -481,41 +583,43 @@ class WarehouseManager {
|
||||
return $sth->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse lookup — return warehouse_id for a given warehouse_name.
|
||||
* Used by approveStock() to resolve the destination warehouse on transfers.
|
||||
*
|
||||
* @param string $name The warehouse_name stored in td_stock.ref_warehouse.
|
||||
* @return int|null The warehouse id, or null if not found.
|
||||
*/
|
||||
public function getWarehouseIdByName(string $name): ?int
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id FROM md_warehouse
|
||||
WHERE company_id = :company_id AND warehouse_name = :name
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':name' => $name]);
|
||||
$id = $sth->fetchColumn();
|
||||
return $id !== false ? (int)$id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a new warehouse or update an existing one.
|
||||
*
|
||||
* On insert, creates a new per-warehouse stock table (td_stock_<name>)
|
||||
* using the td_stock template via CREATE TABLE LIKE.
|
||||
* The warehouse_name is sanitised before use as a table name suffix.
|
||||
* 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): void
|
||||
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
|
||||
@@ -527,7 +631,7 @@ class WarehouseManager {
|
||||
|
||||
$params = [
|
||||
':company_id' => $this->company_id,
|
||||
':warehouse_name' => $data['warehouse_name'] ?? '',
|
||||
':warehouse_name' => $warehouse_name,
|
||||
':location' => $data['location'] ?? '',
|
||||
':manager' => (int)($data['manager'] ?? 0),
|
||||
':description' => $data['description'] ?? '',
|
||||
@@ -555,12 +659,16 @@ class WarehouseManager {
|
||||
(:company_id, :warehouse_name, :location, :manager, :description, :status, :log)"
|
||||
)->execute($params);
|
||||
|
||||
// Create the per-warehouse stock table using td_stock as template
|
||||
if (!empty($data['warehouse_name'])) {
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $data['warehouse_name']);
|
||||
$this->pdo->exec("CREATE TABLE `td_stock_{$safe}` LIKE `td_stock`");
|
||||
}
|
||||
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`");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -682,10 +790,43 @@ class WarehouseManager {
|
||||
* rack_from, rack_to, description, status.
|
||||
* @param array $logging Audit entry to append to the log column.
|
||||
*/
|
||||
public function saveStorage(array $data, array $logging): void
|
||||
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'] ?? ''));
|
||||
$rack_from = trim((string)($data['rack_from'] ?? ''));
|
||||
$rack_to = trim((string)($data['rack_to'] ?? ''));
|
||||
|
||||
if ($warehouse === '') {
|
||||
throw new Exception("Warehouse is required.");
|
||||
}
|
||||
|
||||
if ($advanced_location) {
|
||||
if ($zone === '' || $aisle_from === '' || $aisle_to === '' || $rack_from === '' || $rack_to === '') {
|
||||
throw new Exception("Zone, aisle range, and rack range are required.");
|
||||
}
|
||||
} else {
|
||||
$location_from = $rack_from !== '' ? $rack_from : $aisle_from;
|
||||
$location_to = $rack_to !== '' ? $rack_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;
|
||||
$rack_from = $location_from;
|
||||
$rack_to = $location_to;
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT `log` FROM md_storage
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
@@ -696,12 +837,12 @@ class WarehouseManager {
|
||||
|
||||
$params = [
|
||||
':company_id' => $this->company_id,
|
||||
':warehouse' => $data['warehouse'] ?? '',
|
||||
':zone' => $data['zone'] ?? '',
|
||||
':aisle_from' => $data['aisle_from'] ?? '',
|
||||
':aisle_to' => $data['aisle_to'] ?? '',
|
||||
':rack_from' => $data['rack_from'] ?? '',
|
||||
':rack_to' => $data['rack_to'] ?? '',
|
||||
':warehouse' => $warehouse,
|
||||
':zone' => $zone,
|
||||
':aisle_from' => $aisle_from,
|
||||
':aisle_to' => $aisle_to,
|
||||
':rack_from' => $rack_from,
|
||||
':rack_to' => $rack_to,
|
||||
':description' => $data['description'] ?? '',
|
||||
':status' => (int)($data['status'] ?? 1),
|
||||
':log' => json_encode($table_log, JSON_UNESCAPED_UNICODE),
|
||||
@@ -739,13 +880,13 @@ class WarehouseManager {
|
||||
|
||||
// Sync md_rack rows to exactly match the new aisle × rack range
|
||||
$this->syncRacks($storage_id, [
|
||||
'warehouse' => $data['warehouse'],
|
||||
'zone' => $data['zone'],
|
||||
'aisle_from' => $data['aisle_from'],
|
||||
'aisle_to' => $data['aisle_to'],
|
||||
'rack_from' => $data['rack_from'],
|
||||
'rack_to' => $data['rack_to'],
|
||||
]);
|
||||
'warehouse' => $warehouse,
|
||||
'zone' => $zone,
|
||||
'aisle_from' => $aisle_from,
|
||||
'aisle_to' => $aisle_to,
|
||||
'rack_from' => $rack_from,
|
||||
'rack_to' => $rack_to,
|
||||
], $advanced_location);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -938,7 +1079,7 @@ class WarehouseManager {
|
||||
// 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, warehouse_name FROM md_warehouse
|
||||
"SELECT id FROM md_warehouse
|
||||
WHERE company_id = :company_id AND status = 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
@@ -950,8 +1091,8 @@ class WarehouseManager {
|
||||
|
||||
// Build UNION EXISTS subquery across all td_stock_* tables
|
||||
$unions = implode(' UNION ALL ', array_map(function($wh) use ($cid) {
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
|
||||
return "SELECT lot_number FROM `td_stock_{$safe}`
|
||||
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
|
||||
return "SELECT lot_number FROM `{$table}`
|
||||
WHERE company_id = {$cid} AND lot_number IS NOT NULL";
|
||||
}, $warehouses));
|
||||
|
||||
@@ -987,7 +1128,7 @@ class WarehouseManager {
|
||||
public function getActiveLots(string $product_sku): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, warehouse_name FROM md_warehouse
|
||||
"SELECT id FROM md_warehouse
|
||||
WHERE company_id = :company_id AND status = 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
@@ -996,8 +1137,7 @@ class WarehouseManager {
|
||||
$active_lots = [];
|
||||
|
||||
foreach ($warehouses as $wh) {
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
|
||||
$table = "td_stock_{$safe}";
|
||||
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT DISTINCT s.lot_number, l.expiry_date
|
||||
@@ -1008,7 +1148,7 @@ class WarehouseManager {
|
||||
AND l.lot_number = s.lot_number
|
||||
WHERE s.company_id = :company_id
|
||||
AND s.product_sku = :product_sku
|
||||
AND s.type = 'in'
|
||||
AND s.`in` > 0
|
||||
AND s.status = 1
|
||||
AND s.lot_number IS NOT NULL
|
||||
AND EXISTS (
|
||||
@@ -1047,7 +1187,7 @@ class WarehouseManager {
|
||||
public function getActiveSerials(string $product_sku, string $lot_number = ''): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, warehouse_name FROM md_warehouse
|
||||
"SELECT id FROM md_warehouse
|
||||
WHERE company_id = :company_id AND status = 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
@@ -1056,15 +1196,14 @@ class WarehouseManager {
|
||||
$active_serials = [];
|
||||
|
||||
foreach ($warehouses as $wh) {
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
|
||||
$table = "td_stock_{$safe}";
|
||||
$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.type = 'in'
|
||||
AND s.`in` > 0
|
||||
AND s.status = 1
|
||||
AND s.serial_number IS NOT NULL
|
||||
AND EXISTS (
|
||||
@@ -1104,8 +1243,8 @@ class WarehouseManager {
|
||||
* 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> table name
|
||||
* - 'name': the raw warehouse_name string
|
||||
* - '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.
|
||||
@@ -1113,15 +1252,17 @@ class WarehouseManager {
|
||||
* historical rows that need reading.
|
||||
*
|
||||
* @param int|string $warehouse_id The md_warehouse.id.
|
||||
* @param int|string $id The td_stock_<wh>.id to fetch, or 0 for table-only.
|
||||
* @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.");
|
||||
}
|
||||
|
||||
// Sanitise table suffix to prevent SQL injection via warehouse names
|
||||
$safe_name = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
|
||||
$table = "td_stock_" . $safe_name;
|
||||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||||
|
||||
if (empty($id)) {
|
||||
return [
|
||||
@@ -1166,10 +1307,10 @@ class WarehouseManager {
|
||||
* @param array $range Keys: warehouse, zone, aisle_from, aisle_to, rack_from, rack_to.
|
||||
* @throws Exception If occupied racks would be removed by the new range.
|
||||
*/
|
||||
public function syncRacks(int $storage_id, array $range): void {
|
||||
public function syncRacks(int $storage_id, array $range, bool $advanced_location): void {
|
||||
|
||||
// Guard: refuse if any occupied rack under this storage_id falls outside the new range
|
||||
$this->validateRangeChange($storage_id, $range);
|
||||
$this->validateRangeChange($storage_id, $range, $advanced_location);
|
||||
|
||||
// Wipe all empty racks belonging to this storage_id
|
||||
$sql = "DELETE FROM md_rack
|
||||
@@ -1184,14 +1325,14 @@ class WarehouseManager {
|
||||
]);
|
||||
|
||||
// Reinsert the full range fresh (INSERT IGNORE skips any occupied racks already there)
|
||||
$this->insertRacksInRange($storage_id, $range);
|
||||
$this->insertRacksInRange($storage_id, $range, $advanced_location);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the stock record currently occupying a rack.
|
||||
*
|
||||
* Under the 1:1 rack model, each occupied rack's md_rack.td_stock_id points
|
||||
* to exactly one td_stock_<wh> row. This method resolves that pointer and
|
||||
* to exactly one td_stock_<warehouse_id> row. This method resolves that pointer and
|
||||
* returns the full stock row, or null if the rack is empty or the link is broken.
|
||||
*
|
||||
* Used by saveStockOut and saveStockTransfer to validate rack contents before
|
||||
@@ -1227,9 +1368,17 @@ class WarehouseManager {
|
||||
return null; // Rack is empty or doesn't exist
|
||||
}
|
||||
|
||||
// Fetch the full stock row from the per-warehouse table
|
||||
// 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 rack but are not available inventory yet.
|
||||
$context = $this->getStockContext($warehouse_id, $td_stock_id);
|
||||
return $context['row'] ?: null;
|
||||
$row = $context['row'] ?: null;
|
||||
|
||||
if (!$row || $row['type'] !== 'in' || (int)$row['status'] !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1274,20 +1423,24 @@ class WarehouseManager {
|
||||
if (empty($warehouses)) return;
|
||||
|
||||
foreach ($warehouses as $wh) {
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
|
||||
$table = "td_stock_{$safe}";
|
||||
$wh_id = (int)$wh['id'];
|
||||
$table = $this->stockTableNameFromWarehouseId($wh_id);
|
||||
|
||||
// Find any stock_in row for this SKU that is still rack-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 rack check to THIS warehouse only —
|
||||
// td_stock_id values are per-table integers, not globally unique, so without
|
||||
// this filter a rack 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.type = 'in'
|
||||
AND s.`in` > 0
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM md_rack 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
|
||||
)";
|
||||
@@ -1303,9 +1456,10 @@ class WarehouseManager {
|
||||
}
|
||||
|
||||
$params = [
|
||||
':company_id' => $cid,
|
||||
':company_id2' => $cid,
|
||||
':product_sku' => $product_sku,
|
||||
':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;
|
||||
@@ -1332,7 +1486,8 @@ class WarehouseManager {
|
||||
* Assign a product to an empty rack and link it to a td_stock row.
|
||||
*
|
||||
* Uses FOR UPDATE to lock the rack row and prevent concurrent occupancy.
|
||||
* Also calls validateStockUnique to enforce no-duplicate-serial rules.
|
||||
* Also calls validateStockUnique to enforce no-duplicate-serial rules unless
|
||||
* the caller is replaying a validated source transaction, such as a return.
|
||||
* Updates md_rack state (product_sku + td_stock_id) in a single UPDATE
|
||||
* and appends a rack log entry.
|
||||
*
|
||||
@@ -1343,12 +1498,12 @@ class WarehouseManager {
|
||||
* @param string $aisle Aisle identifier.
|
||||
* @param string $rack Rack identifier.
|
||||
* @param string $product_sku SKU to assign to this rack.
|
||||
* @param int $td_stock_id The td_stock_<wh>.id to link.
|
||||
* @param int $td_stock_id The td_stock_<warehouse_id>.id to link.
|
||||
* @throws Exception If the rack does not exist or is already occupied.
|
||||
*/
|
||||
public function occupyRack($warehouse_id, $zone, $aisle, $rack, $product_sku, $td_stock_id): void {
|
||||
public function occupyRack($warehouse_id, $zone, $aisle, $rack, $product_sku, $td_stock_id, bool $skip_unique_validation = false): void {
|
||||
|
||||
$sql = "SELECT id, product_sku FROM md_rack
|
||||
$sql = "SELECT id, product_sku, td_stock_id FROM md_rack
|
||||
WHERE company_id = :company_id
|
||||
AND warehouse = :warehouse
|
||||
AND zone = :zone
|
||||
@@ -1371,22 +1526,31 @@ class WarehouseManager {
|
||||
}
|
||||
|
||||
if ($current['product_sku'] !== null) {
|
||||
if (
|
||||
$current['product_sku'] === $product_sku
|
||||
&& (int)$current['td_stock_id'] === (int)$td_stock_id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Exception(
|
||||
"Rack {$zone}-{$aisle}-{$rack} is already occupied by {$current['product_sku']}"
|
||||
);
|
||||
}
|
||||
|
||||
// Validate sku + lot + serial uniqueness before linking the rack
|
||||
$ctx = $this->getStockContext($warehouse_id, $td_stock_id);
|
||||
$row = $ctx['row'] ?? null;
|
||||
if (!$skip_unique_validation) {
|
||||
// Validate sku + lot + serial uniqueness before linking the rack
|
||||
$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
|
||||
);
|
||||
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
|
||||
@@ -1674,7 +1838,8 @@ class WarehouseManager {
|
||||
SELECT s.id FROM `{$table}` s
|
||||
WHERE s.company_id = :company_id2
|
||||
AND s.product_sku = :product_sku2
|
||||
AND s.type = 'in'
|
||||
AND s.`in` > 0
|
||||
AND s.status = 1
|
||||
{$lot_cond}
|
||||
{$serial_cond}
|
||||
)
|
||||
@@ -1745,7 +1910,8 @@ class WarehouseManager {
|
||||
SELECT s.id FROM `{$table}` s
|
||||
WHERE s.company_id = :company_id2
|
||||
AND s.product_sku = :product_sku2
|
||||
AND s.type = 'in'
|
||||
AND s.`in` > 0
|
||||
AND s.status = 1
|
||||
{$lot_cond}
|
||||
{$serial_cond}
|
||||
)
|
||||
@@ -1841,7 +2007,8 @@ class WarehouseManager {
|
||||
SELECT s.id FROM `{$table}` s
|
||||
WHERE s.company_id = :company_id2
|
||||
AND s.product_sku = :product_sku2
|
||||
AND s.type = 'in'
|
||||
AND s.`in` > 0
|
||||
AND s.status = 1
|
||||
{$lot_cond}
|
||||
{$serial_cond}
|
||||
)
|
||||
@@ -1861,12 +2028,12 @@ class WarehouseManager {
|
||||
* 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 occupied rack back to empty.
|
||||
* 4. Reverses the balance (adjustBalance with new_qty = 0).
|
||||
* 3. Releases the reserved/occupied rack 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_<wh>.id of the row to delete.
|
||||
* @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.
|
||||
*/
|
||||
@@ -1892,9 +2059,19 @@ class WarehouseManager {
|
||||
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
|
||||
// Stock-in reserves the rack at creation time, even while draft.
|
||||
// Balance is only applied for approved rows.
|
||||
$this->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']);
|
||||
$this->softDeleteRackLogs(
|
||||
$warehouse_id,
|
||||
$row['zone'], $row['aisle'], $row['rack'],
|
||||
['occupy', 'release'],
|
||||
$product_sku,
|
||||
[$stock_id],
|
||||
$row['date']
|
||||
);
|
||||
|
||||
if ((int)$row['status'] === 1) {
|
||||
$this->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']);
|
||||
$this->adjustBalance('in', $warehouse_id, $product_sku, (int)$row['in'], 0);
|
||||
}
|
||||
}
|
||||
@@ -1910,7 +2087,7 @@ class WarehouseManager {
|
||||
*
|
||||
* Must be called inside a DB transaction by the caller.
|
||||
*
|
||||
* @param int $stock_id The td_stock_<wh>.id of the row to delete.
|
||||
* @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.
|
||||
*/
|
||||
@@ -1945,6 +2122,14 @@ class WarehouseManager {
|
||||
$product_sku,
|
||||
(int)$row['ref_id']
|
||||
);
|
||||
$this->softDeleteRackLogs(
|
||||
$warehouse_id,
|
||||
$row['zone'], $row['aisle'], $row['rack'],
|
||||
['release', 'occupy'],
|
||||
$product_sku,
|
||||
[(int)$row['ref_id']],
|
||||
$row['date']
|
||||
);
|
||||
$this->adjustBalance('out', $warehouse_id, $product_sku, (int)$row['out'], 0);
|
||||
}
|
||||
}
|
||||
@@ -2020,6 +2205,22 @@ class WarehouseManager {
|
||||
$product_sku,
|
||||
$from_stock_id
|
||||
);
|
||||
$this->softDeleteRackLogs(
|
||||
$to_warehouse,
|
||||
$to_row['zone'], $to_row['aisle'], $to_row['rack'],
|
||||
['occupy', 'release'],
|
||||
$product_sku,
|
||||
[$ref_id],
|
||||
$from_row['date']
|
||||
);
|
||||
$this->softDeleteRackLogs(
|
||||
$from_warehouse_id,
|
||||
$from_row['zone'], $from_row['aisle'], $from_row['rack'],
|
||||
['release', 'occupy'],
|
||||
$product_sku,
|
||||
[],
|
||||
$from_row['date']
|
||||
);
|
||||
|
||||
// Reverse balances on both sides
|
||||
$quantity = (int)$from_row['out'];
|
||||
@@ -2056,8 +2257,7 @@ class WarehouseManager {
|
||||
$cid = (int)$this->company_id;
|
||||
|
||||
foreach ($warehouses as $wh) {
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
|
||||
$table = "td_stock_{$safe}";
|
||||
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
|
||||
$wh_id = (int)$wh['id'];
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
@@ -2065,7 +2265,7 @@ class WarehouseManager {
|
||||
WHERE s.company_id = :company_id
|
||||
AND s.product_sku = :product_sku
|
||||
AND s.lot_number = :lot_number
|
||||
AND s.type = 'in'
|
||||
AND s.`in` > 0
|
||||
AND s.status = 1
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM md_rack r
|
||||
@@ -2108,6 +2308,52 @@ class WarehouseManager {
|
||||
*/
|
||||
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_rack 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];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user