generic API response + ICS: warehouse_balance md_rack update
This commit is contained in:
+36
-4
@@ -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">`;
|
||||
|
||||
|
||||
@@ -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