generic API response + ICS: warehouse_balance md_rack update
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user