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
+36 -4
View File
@@ -401,12 +401,34 @@ function ajax_request(options) {
isAjaxProcessing = false;
$.LoadingOverlay("hide");
// Errors re-thrown from .then() — pass through
if (xhr instanceof Error) {
throw xhr;
}
console.error("AJAX Error:", xhr?.responseText);
bootbox.alert("Server error occurred.");
// Extract server's error message from JSON response
let serverMessage = xhr?.responseJSON?.message;
// Fallback: parse responseText if responseJSON isn't set
if (!serverMessage && xhr?.responseText) {
try {
serverMessage = JSON.parse(xhr.responseText)?.message;
} catch (e) {
// Response wasn't JSON — real server crash or HTML error page
}
}
if (serverMessage) {
// Known server-reported error (HTTP 4xx/5xx with JSON body)
bootbox.alert(serverMessage);
options.onError?.(xhr, serverMessage);
} else {
// Unknown error — network failure, PHP fatal, HTML error page
console.error("AJAX Error:", xhr?.status, xhr?.responseText);
bootbox.alert("Server error occurred.");
options.onError?.(xhr, null);
}
throw xhr;
});
}
@@ -422,6 +444,7 @@ function generate_pagination(table_id, total_records, records_per_page, current_
// 1. Find the table and thead
const table = document.getElementById(table_id);
let theme_class = '';
let col_count = 1;
if (table) {
const thead = table.querySelector('thead');
@@ -433,16 +456,25 @@ function generate_pagination(table_id, total_records, records_per_page, current_
// 3. Convert "table-secondary" to "page-item-secondary"
theme_class = bootstrap_theme.replace('table-', 'page-item-');
}
// 4. Count <th> in the first row of thead
const header_row = thead.querySelector('tr');
if (header_row) {
col_count = header_row.querySelectorAll('th').length || 1;
}
}
}
const left_span = Math.floor(col_count / 2);
const right_span = Math.ceil(col_count / 2);
let pagination_html = `
<tr>
<td class="border-bottom-0">
<td colspan="${left_span}" class="border-bottom-0">
<span class="text-muted">Showing ${records_per_page} per page</span><br>
<strong>Total: ${total_records}</strong>
</td>
<td colspan="9" class="border-bottom-0">
<td colspan="${right_span}" class="border-bottom-0">
<nav aria-label="Page navigation" class="d-flex justify-content-end">
<ul class="pagination mb-0">`;
+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