generic API response + ICS: warehouse_balance md_rack update

This commit is contained in:
Thanakorn S
2026-04-23 16:06:19 +07:00
parent ed23c269ea
commit a75e180686
25 changed files with 934 additions and 342 deletions
+9
View File
@@ -97,6 +97,15 @@ class Reports
return (int) $this->fetchScalar($sql);
}
public function getTotalProductInStock(): int
{
$sql = "SELECT COUNT(DISTINCT product_sku)
FROM warehouse_balance
WHERE company_id = :company_id
AND (total_in - total_out) > 0";
return (int) $this->fetchScalar($sql);
}
public function getLowStockCount(): int
{
$products = $this->getStockBalance();
+144 -29
View File
@@ -15,9 +15,8 @@ class WarehouseManager {
private $pdo;
private $company_id;
public function __construct($pdo, $company_id) {
$this->pdo = $pdo;
public function __construct($pdo, $company_id, $logging = null) {
$this->pdo = $pdo;
$this->company_id = $company_id;
}
@@ -218,20 +217,87 @@ class WarehouseManager {
}
}
/**
* Fetch the stock record currently linked to a rack.
*
* Under the 1:1 model, each occupied rack points to exactly one
* td_stock_<wh> row via md_rack.td_stock_id. This method resolves
* that pointer — returning the full stock row, or null if the rack
* is empty or the link is broken.
*
* The td_stock table name is derived from the warehouse (via
* getStockContext), so callers don't need to know the naming convention.
*/
public function getRackStock($warehouse_id, $zone, $aisle, $rack): ?array {
// Get the stock pointer from md_rack
$sql = "SELECT td_stock_id FROM md_rack
WHERE company_id = :company_id
AND warehouse = :warehouse
AND zone = :zone
AND aisle = :aisle
AND rack = :rack";
$sth = $this->pdo->prepare($sql);
$sth->execute([
":company_id" => $this->company_id,
":warehouse" => $warehouse_id,
":zone" => $zone,
":aisle" => $aisle,
":rack" => $rack,
]);
$td_stock_id = $sth->fetchColumn();
// Rack is empty or doesn't exist
if (!$td_stock_id) {
return null;
}
// Resolve the td_stock_<wh> table from the warehouse name
// and fetch the full row by ID.
$context = $this->getStockContext($warehouse_id, $td_stock_id);
return $context['row'] ?: null;
}
// ─────────────────────────────────────────────────────────────
// Rack occupancy (md_rack.product_sku state)
// Rack occupancy (md_rack.product_sku + md_rack.td_stock_id state)
// ─────────────────────────────────────────────────────────────
/**
* Assign a product SKU to an empty rack.
*
* @throws Exception if the rack is already occupied by a different SKU
* or if the rack doesn't exist.
* Build a log JSON by appending an action entry to an existing md_rack.log.
* Entry captures user_id, dt (now), and login (session start) for audit context,
* plus any action-specific fields passed via $extra.
*/
public function occupyRack($warehouse_id, $zone, $aisle, $rack, $product_sku): void {
private function buildRackLog(?string $existing_log, string $action, array $extra = []): string {
// Lock and check the rack's current state
$sql = "SELECT id, product_sku FROM md_rack
$log = json_decode($existing_log ?: '[]', true) ?: [];
$log[] = array_merge([
'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,
], $extra);
return json_encode($log, JSON_UNESCAPED_UNICODE);
}
/**
* Assign a product SKU to an empty rack and link it to its stock record.
*
* The td_stock_<warehouse> table is derived from the warehouse,
* so only the row ID needs to be passed here.
*
* @throws Exception if the rack is already occupied or doesn't exist.
*/
public function occupyRack($warehouse_id, $zone, $aisle, $rack, $product_sku, $td_stock_id): void {
// Lock and fetch current state (including log for in-place append)
$sql = "SELECT id, product_sku, `log` FROM md_rack
WHERE company_id = :company_id
AND warehouse = :warehouse
AND zone = :zone
@@ -253,37 +319,44 @@ class WarehouseManager {
throw new Exception("Rack {$zone}-{$aisle}-{$rack} does not exist");
}
if ($current['product_sku'] !== null
&& $current['product_sku'] !== $product_sku) {
if ($current['product_sku'] !== null) {
throw new Exception(
"Rack {$zone}-{$aisle}-{$rack} is already occupied by {$current['product_sku']}"
);
}
// Assign the SKU
// Single atomic UPDATE: state + log together
$sql = "UPDATE md_rack
SET product_sku = :product_sku
SET product_sku = :product_sku,
td_stock_id = :td_stock_id,
`log` = :log
WHERE id = :id";
$sth = $this->pdo->prepare($sql);
$sth->execute([
":product_sku" => $product_sku,
":td_stock_id" => $td_stock_id,
":log" => $this->buildRackLog($current['log'], 'occupy', [
'product_sku' => $product_sku,
'td_stock_id' => $td_stock_id,
]),
":id" => $current['id'],
]);
}
/**
* Release a rack (set product_sku to NULL).
* Release a rack (clear both SKU and stock reference together).
*/
public function releaseRack($warehouse_id, $zone, $aisle, $rack): void {
$sql = "UPDATE md_rack
SET product_sku = NULL
// Fetch current state + log (needed to append release event)
$sql = "SELECT id, product_sku, td_stock_id, `log` FROM md_rack
WHERE company_id = :company_id
AND warehouse = :warehouse
AND zone = :zone
AND aisle = :aisle
AND rack = :rack";
AND rack = :rack
FOR UPDATE";
$sth = $this->pdo->prepare($sql);
$sth->execute([
@@ -293,12 +366,35 @@ class WarehouseManager {
":aisle" => $aisle,
":rack" => $rack,
]);
$current = $sth->fetch(PDO::FETCH_ASSOC);
// Nothing to release — exit silently (idempotent behavior)
if (!$current || $current['product_sku'] === null) {
return;
}
// Clear state + record log in one UPDATE
$sql = "UPDATE md_rack
SET product_sku = NULL,
td_stock_id = NULL,
`log` = :log
WHERE id = :id";
$sth = $this->pdo->prepare($sql);
$sth->execute([
":log" => $this->buildRackLog($current['log'], 'release', [
'product_sku' => $current['product_sku'],
'td_stock_id' => $current['td_stock_id'],
]),
":id" => $current['id'],
]);
}
/**
* Move a rack assignment from one location to another.
*
* Source rack must be occupied, destination rack must be empty.
* Both product_sku and td_stock_id travel together to the destination.
* Locks both racks (lower ID first) to avoid deadlocks.
*/
public function transferRack(
@@ -307,7 +403,7 @@ class WarehouseManager {
): void {
// Fetch both racks with FOR UPDATE, ordered by id to prevent deadlocks
$sql = "SELECT id, warehouse, zone, aisle, rack, product_sku
$sql = "SELECT id, warehouse, zone, aisle, rack, product_sku, td_stock_id, `log`
FROM md_rack
WHERE company_id = :company_id
AND (
@@ -362,17 +458,36 @@ class WarehouseManager {
throw new Exception("Destination rack is already occupied");
}
$sku = $from['product_sku'];
// Carry both SKU and stock reference across
$sku = $from['product_sku'];
$td_stock_id = $from['td_stock_id'];
// Clear source
$sql = "UPDATE md_rack SET product_sku = NULL WHERE id = :id";
$this->pdo->prepare($sql)->execute([":id" => $from['id']]);
// Populate destination
$sql = "UPDATE md_rack SET product_sku = :sku WHERE id = :id";
// Clear source — state + log in one UPDATE
$sql = "UPDATE md_rack
SET product_sku = NULL, td_stock_id = NULL, `log` = :log
WHERE id = :id";
$this->pdo->prepare($sql)->execute([
":sku" => $sku,
":id" => $to['id'],
":log" => $this->buildRackLog($from['log'], 'transfer_out', [
'product_sku' => $sku,
'td_stock_id' => $td_stock_id,
'to_rack_id' => $to['id'],
]),
":id" => $from['id'],
]);
// Populate destination — state + log in one UPDATE
$sql = "UPDATE md_rack
SET product_sku = :sku, td_stock_id = :td_stock_id, `log` = :log
WHERE id = :id";
$this->pdo->prepare($sql)->execute([
":sku" => $sku,
":td_stock_id" => $td_stock_id,
":log" => $this->buildRackLog($to['log'], 'transfer_in', [
'product_sku' => $sku,
'td_stock_id' => $td_stock_id,
'from_rack_id' => $from['id'],
]),
":id" => $to['id'],
]);
}
}
+5
View File
@@ -4,6 +4,11 @@ require_once __DIR__."/../../config.php";
require_once __DIR__."/../../dbconn.php";
require_once __DIR__."/db_helpers.php";
// Tell the browser (and jQuery) the response body is JSON.
// Without this, responses default to text/html, which prevents
// xhr.responseJSON from being populated automatically in ajax_request().
header('Content-Type: application/json; charset=utf-8');
if(!empty($_SESSION["login_company_id"])){
// CSRF Validation — add right at the top of the logged-in block