Merge fix/feedback-16-09

This commit is contained in:
Thanakorn
2026-09-17 09:00:37 +07:00
14 changed files with 464 additions and 60 deletions
+56 -1
View File
@@ -772,6 +772,42 @@ function ajax_request(options) {
// Override options.data with the full FormData object // Override options.data with the full FormData object
options.data = options.formData; options.data = options.formData;
} }
} else if (
options.data && !(options.data instanceof FormData) &&
typeof options.data === 'object' && !('json' in options.data)
) {
// autoPrepare: false with a plain field map — e.g. the delete buttons'
// `data: { id: id }`. db_auth.php only accepts a `json` string or a
// FormData post carrying `otp`, so an unwrapped map was rejected outright
// with "Request denied: No valid JSON payload or Form Data detected.", and
// `options.action` was dropped because only the autoPrepare branch applied
// it. Wrap it the same way here, without touching callers that already
// pass a ready-made `{ json: ... }`.
const session_element = document.getElementById('session-context');
const payload = {};
if (session_element) {
payload['company_id'] = session_element.dataset.companyId;
payload['otp'] = session_element.dataset.otp;
}
Object.entries(options.data).forEach(([key, value]) => {
payload[key] = value;
});
if (options.action) {
payload['action'] = (options.action === 'manage')
? (payload['id'] ? 'update' : 'create')
: options.action;
}
options.data = { json: JSON.stringify(payload) };
if (options.debugMode) {
console.log("REQUEST DATA:", options.data);
}
} }
// --- START MODIFIED $.AJAX BLOCK --- // --- START MODIFIED $.AJAX BLOCK ---
@@ -1073,6 +1109,25 @@ function expand_exponential_number(value) {
return sign + digits.slice(0, point) + '.' + digits.slice(point); return sign + digits.slice(0, point) + '.' + digits.slice(point);
} }
/**
* Format a stock quantity without hiding real data.
*
* Quantity columns are decimal(18,4), so a genuine 0.0001 exists. Formatting
* every quantity at 2 dp printed such a value as "0.00", which reads as "no
* data" — the stock popups and list pages all showed an empty-looking QTY for
* a receipt that had in fact been made. Show 2 dp normally, and the stored
* 4 dp whenever rounding to 2 would lose something.
*/
function format_quantity(value) {
var n = Number(value);
if (isNaN(n)) return '--';
return (round_dp(n, 2) !== round_dp(n, 4))
? format_number(n, 4)
: format_number(n, 2);
}
function format_number(value, decimal) { function format_number(value, decimal) {
var n = Number(value); var n = Number(value);
if (isNaN(n)) return '--'; if (isNaN(n)) return '--';
@@ -1184,7 +1239,7 @@ function show_stock_rows(source, source_id, label) {
<td>${escape_html(r.warehouse_name)}</td> <td>${escape_html(r.warehouse_name)}</td>
<td><small>${escape_html(location)}</small></td> <td><small>${escape_html(location)}</small></td>
<td><small>${escape_html(r.lot_number || '—')}</small></td> <td><small>${escape_html(r.lot_number || '—')}</small></td>
<td class="text-end fw-semibold">${format_number(r.quantity, 2)}</td> <td class="text-end fw-semibold">${format_quantity(r.quantity)}</td>
<td>${status_badge}</td> <td>${status_badge}</td>
<td><small>${format_date(r.date)}</small></td> <td><small>${format_date(r.date)}</small></td>
</tr>`; </tr>`;
+55 -2
View File
@@ -403,12 +403,47 @@ class InvoiceManager {
* @param array $logging Audit entry. * @param array $logging Audit entry.
* @throws Exception If invoice not found or not in draft status. * @throws Exception If invoice not found or not in draft status.
*/ */
/**
* Normalise a client-supplied date to ISO YYYY-MM-DD and reject anything
* that is not a real calendar date.
*
* The date pickers display d/m/Y, and a page that forgets to convert before
* posting sends that text straight through to a MySQL DATE column, where it
* fails as a PDOException and surfaces to the user as the opaque
* "Database error, please try again." Accepting both spellings here keeps
* the failure mode a named, actionable message instead.
*
* @param string $value ISO or d/m/Y date; '' is treated as "not set".
* @param string $label Field name used in the error message.
* @return string|null ISO date, or null when nothing was supplied.
* @throws Exception When the value is not a valid date.
*/
private function normaliseDate(string $value, string $label): ?string
{
$value = trim($value);
if ($value === '') return null;
// Strip a time part, if the caller passed a datetime.
$value = explode(' ', $value)[0];
foreach (['Y-m-d', 'd/m/Y'] as $format) {
$parsed = DateTime::createFromFormat('!' . $format, $value);
// createFromFormat() accepts overflowing values such as 32/01/2026
// and rolls them over, so compare the round-trip to reject those.
if ($parsed && $parsed->format($format) === $value) {
return $parsed->format('Y-m-d');
}
}
throw new Exception("{$label} is not a valid date.");
}
public function saveInvoice(array $data, array $logging): void public function saveInvoice(array $data, array $logging): void
{ {
$id = (int)($data['id'] ?? 0); $id = (int)($data['id'] ?? 0);
$sth = $this->pdo->prepare( $sth = $this->pdo->prepare(
"SELECT status, doc_type, issued_date, `log` FROM td_invoice "SELECT status, doc_type, issued_date, due_date, `log` FROM td_invoice
WHERE company_id = :company_id AND id = :id" WHERE company_id = :company_id AND id = :id"
); );
$sth->execute([':company_id' => $this->company_id, ':id' => $id]); $sth->execute([':company_id' => $this->company_id, ':id' => $id]);
@@ -428,8 +463,21 @@ class InvoiceManager {
$formula_id = isset($data['formula_id']) && (int)$data['formula_id'] > 0 $formula_id = isset($data['formula_id']) && (int)$data['formula_id'] > 0
? (int)$data['formula_id'] : null; ? (int)$data['formula_id'] : null;
// Absent key means "not being edited" — keep what is stored rather than
// clearing it, so a caller that posts only tax_adjustment cannot wipe
// the agreed payment term.
$due_date = array_key_exists('due_date', $data)
? $this->normaliseDate((string)$data['due_date'], 'Due date')
: ($row['due_date'] ?: null);
$issued_date = $row['issued_date'] ?: null;
if ($due_date !== null && $issued_date !== null && $due_date < $issued_date) {
throw new Exception("The due date cannot be earlier than the issue date.");
}
$params = [ $params = [
':due_date' => $data['due_date'] ?: null, ':due_date' => $due_date,
':notes' => $data['notes'] ?? '', ':notes' => $data['notes'] ?? '',
':formula_id' => $formula_id, ':formula_id' => $formula_id,
':log' => json_encode($log), ':log' => json_encode($log),
@@ -525,6 +573,11 @@ class InvoiceManager {
if (in_array($row['doc_type'], ['invoice', 'purchase_invoice']) && !$due_date) { if (in_array($row['doc_type'], ['invoice', 'purchase_invoice']) && !$due_date) {
throw new Exception("Due date is required before issuing this document."); throw new Exception("Due date is required before issuing this document.");
} }
$due_date = $this->normaliseDate((string)($due_date ?? ''), 'Due date');
if ($due_date !== null && $due_date < $issued_date) {
throw new Exception("The due date cannot be earlier than the issue date.");
}
$this->assertPostingWindow($issued_date, ucfirst(str_replace('_', ' ', $row['doc_type']))); $this->assertPostingWindow($issued_date, ucfirst(str_replace('_', ' ', $row['doc_type'])));
$log = json_decode($row['log'] ?? '[]', true) ?: []; $log = json_decode($row['log'] ?? '[]', true) ?: [];
@@ -1,6 +1,7 @@
<?php <?php
require_once __DIR__ . '/DocumentNumberManager.php'; require_once __DIR__ . '/DocumentNumberManager.php';
require_once __DIR__ . '/WarehouseManager.php'; require_once __DIR__ . '/WarehouseManager.php';
require_once __DIR__ . '/StockManager.php';
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php'; require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
/** /**
@@ -575,7 +576,16 @@ class PurchaseOrderManager {
$warehouse_id = (int)($recv['warehouse_id'] ?? $po['warehouse_id']); $warehouse_id = (int)($recv['warehouse_id'] ?? $po['warehouse_id']);
$quantity = (float)($recv['quantity'] ?? 0); $quantity = (float)($recv['quantity'] ?? 0);
if ($quantity <= 0) continue; // A blank line is a line the user chose not to receive — skip it.
if ($quantity == 0) continue;
// Anything positive has to survive the decimal(18,4) columns it is
// about to be written to. Without this, 0.0000001 was accepted, was
// stored as 0.0000, produced a stock movement of nothing, and still
// advanced received_qty enough to leave the PO stuck on "Partial".
$name = $po_items[$po_items_by_id[$item_id] ?? -1]['product_name'] ?? $product_sku;
$quantity = StockManager::normaliseQuantity($quantity, "Receiving quantity for \"{$name}\"");
if (!$product_sku) throw new Exception("Item #{$j}: missing product_sku."); if (!$product_sku) throw new Exception("Item #{$j}: missing product_sku.");
if (!$warehouse_id) throw new Exception("Item #{$j}: missing warehouse_id."); if (!$warehouse_id) throw new Exception("Item #{$j}: missing warehouse_id.");
@@ -600,6 +610,16 @@ class PurchaseOrderManager {
$zone = $recv['zone'] ?? ''; $zone = $recv['zone'] ?? '';
$aisle = $recv['aisle'] ?? ''; $aisle = $recv['aisle'] ?? '';
// Expiry dates live on md_lot, keyed by lot number, so an expiry
// entered without one is silently dropped and the received stock
// shows no expiry at all. Say so instead of discarding it.
if (trim((string)($recv['expiry_date'] ?? '')) !== ''
&& trim((string)($recv['lot_number'] ?? '')) === '') {
throw new Exception(
"Enter a lot number for \"{$name}\" — an expiry date is recorded against its lot."
);
}
// Simple location mode: zone and aisle must mirror the bin value // Simple location mode: zone and aisle must mirror the bin value
// (same convention as manage_stock_in.php). // (same convention as manage_stock_in.php).
// occupyBin() looks up md_bin WHERE zone=:zone AND aisle=:aisle AND bin=:bin, // occupyBin() looks up md_bin WHERE zone=:zone AND aisle=:aisle AND bin=:bin,
+122 -20
View File
@@ -26,6 +26,18 @@ require_once __DIR__ . '/../notify_node.php';
*/ */
class StockManager { class StockManager {
/**
* Scale of every stock quantity column (`in`, `out`, td_*_item.quantity are
* all decimal(18,4)). Anything finer than this cannot be stored: MySQL
* rounds it on insert, so a quantity of 0.0000001 silently became 0.0000
* and produced a movement of nothing that still left the source document
* "partially received".
*/
public const QTY_SCALE = 4;
/** Smallest quantity the schema can represent — 0.0001. */
public const QTY_MIN = 0.0001;
private PDO $pdo; private PDO $pdo;
private int $company_id; private int $company_id;
@@ -56,6 +68,39 @@ class StockManager {
return 'td_stock_' . $warehouse_id; return 'td_stock_' . $warehouse_id;
} }
/**
* Round a quantity to the stored scale and reject values that cannot be
* represented.
*
* A positive input that rounds to zero is a mistake worth naming — the
* caller asked to move some stock and would otherwise get a zero-quantity
* movement that looks successful and reports as "0.00" everywhere.
*
* @param mixed $value Raw client input.
* @param string $label Field name used in the error message.
* @return float Quantity rounded to QTY_SCALE.
* @throws Exception When the value is not a usable quantity.
*/
public static function normaliseQuantity($value, string $label = 'Quantity'): float
{
$raw = (float)$value;
if ($raw <= 0) {
throw new Exception("{$label} must be greater than zero.");
}
$rounded = round($raw, self::QTY_SCALE);
if ($rounded < self::QTY_MIN) {
throw new Exception(
"{$label} of {$raw} is smaller than the minimum the system records (" .
rtrim(rtrim(number_format(self::QTY_MIN, self::QTY_SCALE), '0'), '.') . ")."
);
}
return $rounded;
}
private function stockReferenceSql(): string private function stockReferenceSql(): string
{ {
return "CONCAT(DATE_FORMAT(COALESCE(a.`date`, a.updated_at), '%Y%m%d%H%i%s'), '-', LPAD(a.id, 11, '0'))"; return "CONCAT(DATE_FORMAT(COALESCE(a.`date`, a.updated_at), '%Y%m%d%H%i%s'), '-', LPAD(a.id, 11, '0'))";
@@ -72,36 +117,93 @@ class StockManager {
* The 'quantity' alias resolves to the correct column (in or out) depending * The 'quantity' alias resolves to the correct column (in or out) depending
* on the type. For transfers, only the outbound row is listed (out > 0). * on the type. For transfers, only the outbound row is listed (out > 0).
* *
* @param int $warehouse_id The md_warehouse.id to query. * @param int $warehouse_id The md_warehouse.id to query, or 0 for every
* warehouse of this company.
* @param string $type Movement type: 'in' | 'out' | 'transfer'. * @param string $type Movement type: 'in' | 'out' | 'transfer'.
* @return array Stock rows ordered by date DESC, each with 'quantity' and 'product_name'. * @return array Stock rows ordered by date DESC, each with 'quantity',
* 'product_name', 'warehouse_id' and 'warehouse_name'.
*/ */
public function getStockList(int $warehouse_id, string $type): array public function getStockList(int $warehouse_id, string $type): array
{ {
$table = $this->stockTableNameFromWarehouseId($warehouse_id); // warehouse_id 0 = every warehouse. Stock lives in one table per
// warehouse, so a single-warehouse list hides the rest of a receipt
// that was split across warehouses — a 4-line PO received into two of
// them looked like only 3 lines had been received.
$warehouses = $warehouse_id > 0
? [$warehouse_id]
: $this->warehouseIdsWithStockTable();
// The transfer list shows the outbound row, whose quantity is in `out` (its `in` is always 0). // The transfer list shows the outbound row, whose quantity is in `out` (its `in` is always 0).
$column = in_array($type, ['out', 'transfer'], true) ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)'; // Quantity is NOT rounded for display here: rounding to 2 dp reports a
// small-but-real quantity as "0.00", which reads as missing data.
$column = in_array($type, ['out', 'transfer'], true) ? 'a.out' : 'a.in';
$stock_ref = $this->stockReferenceSql(); $stock_ref = $this->stockReferenceSql();
// Transfer list: show only the outbound side (out > 0) to avoid duplicate display // Transfer list: show only the outbound side (out > 0) to avoid duplicate display
$extra_cond = ($type === 'transfer') ? 'AND a.out > 0' : ''; $extra_cond = ($type === 'transfer') ? 'AND a.out > 0' : '';
$rows = [];
foreach ($warehouses as $wh_id) {
$table = $this->stockTableNameFromWarehouseId($wh_id);
$sth = $this->pdo->prepare(
"SELECT a.*, {$stock_ref} AS stock_reference, {$column} AS quantity,
b.product_name, b.uom,
w.warehouse_name
FROM `{$table}` a
LEFT JOIN md_product b
ON a.company_id = b.company_id
AND a.product_sku = b.sku
LEFT JOIN md_warehouse w
ON w.company_id = a.company_id
AND w.id = :warehouse_id
WHERE a.company_id = :company_id
AND a.type = :type
{$extra_cond}
ORDER BY a.date DESC"
);
$sth->execute([
':company_id' => $this->company_id,
':warehouse_id' => $wh_id,
':type' => $type,
]);
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
// The row's own warehouse, so the list can link each Action
// back to the right td_stock_<id> table when showing them all.
$row['warehouse_id'] = $wh_id;
$rows[] = $row;
}
}
// Re-sort across warehouses — each table was only ordered internally.
usort($rows, fn($x, $y) => strcmp((string)($y['date'] ?? ''), (string)($x['date'] ?? '')));
return $rows;
}
/**
* Warehouse ids of this company that actually have a stock table.
*
* td_stock_<id> tables are created lazily on first use, so a warehouse with
* no movements yet has none and must be skipped rather than queried.
*
* @return int[]
*/
private function warehouseIdsWithStockTable(): array
{
$sth = $this->pdo->prepare( $sth = $this->pdo->prepare(
"SELECT a.*, {$stock_ref} AS stock_reference, {$column} AS quantity, b.product_name, b.uom "SELECT w.id
FROM `{$table}` a FROM md_warehouse w
LEFT JOIN md_product b JOIN information_schema.tables t
ON a.company_id = b.company_id ON t.table_schema = DATABASE()
AND a.product_sku = b.sku AND t.table_name = CONCAT('td_stock_', w.id)
WHERE a.company_id = :company_id WHERE w.company_id = :company_id
AND a.type = :type ORDER BY w.id"
{$extra_cond}
ORDER BY a.date DESC"
); );
$sth->execute([ $sth->execute([':company_id' => $this->company_id]);
':company_id' => $this->company_id, return array_map('intval', $sth->fetchAll(PDO::FETCH_COLUMN));
':type' => $type,
]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
} }
/** /**
@@ -258,8 +360,8 @@ class StockManager {
$warehouse_id = (int)($data["warehouse"] ?? 0); $warehouse_id = (int)($data["warehouse"] ?? 0);
$quantity = (float)($data['quantity'] ?? 0); $quantity = (float)($data['quantity'] ?? 0);
if ($id === 0 && $quantity <= 0) { if ($id === 0) {
throw new Exception("Quantity must be greater than zero."); $quantity = self::normaliseQuantity($quantity);
} }
$whMgmt = new WarehouseManager($this->pdo, $this->company_id); $whMgmt = new WarehouseManager($this->pdo, $this->company_id);
+40
View File
@@ -0,0 +1,40 @@
<?php
// Applies the configured application timezone to PHP, and exposes the matching
// UTC offset so the database session can be pinned to the same zone.
//
// config.php has always defined $time_zone ("Asia/Bangkok"), but nothing ever
// called date_default_timezone_set() with it. PHP therefore ran on its ini
// default (UTC on this stack) while MySQL NOW() ran on the database server's
// zone (Bangkok). Every timestamp written from PHP — stock movement `date`
// above all — was stored 7 hours behind the real wall clock, so a stock-in
// created at 14:02 was listed as 07:02.
//
// Loaded from dbconn.php (covers every API engine, which is where writes
// happen) and from include_header.php (covers the rendered pages).
if (!defined('APP_TIMEZONE')) {
$app_tz = $GLOBALS['time_zone'] ?? 'Asia/Bangkok';
// An unknown identifier would leave PHP on UTC and silently reintroduce the
// skew, so fall back to the documented project zone instead.
try {
$tz = new DateTimeZone($app_tz);
} catch (Exception $e) {
$app_tz = 'Asia/Bangkok';
$tz = new DateTimeZone($app_tz);
}
date_default_timezone_set($app_tz);
define('APP_TIMEZONE', $app_tz);
// "+07:00" — the form MySQL accepts without its named-timezone tables
// having been loaded, which is the usual case on a stock install.
$offset_seconds = $tz->getOffset(new DateTime('now', $tz));
define('APP_TIMEZONE_OFFSET', sprintf(
'%s%02d:%02d',
$offset_seconds < 0 ? '-' : '+',
intdiv(abs($offset_seconds), 3600),
intdiv(abs($offset_seconds) % 3600, 60)
));
}
+18 -1
View File
@@ -1,5 +1,9 @@
<?php <?php
// Apply the configured application timezone before anything formats or stores a
// date. config.php (loaded by the caller) supplies $time_zone.
require_once __DIR__ . '/assets/utils/timezone.php';
// db connection // db connection
/** overide native PDO function */ /** overide native PDO function */
class database extends PDO { class database extends PDO {
@@ -97,4 +101,17 @@ $pdo1->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
//..................... PDO2 .....................// //..................... PDO2 .....................//
$pdo2 = new database($db_type2.':host='.$db_server2.';dbname='.$db_database2.';charset=utf8', $db_user2, $db_pass2); $pdo2 = new database($db_type2.':host='.$db_server2.';dbname='.$db_database2.';charset=utf8', $db_user2, $db_pass2);
$pdo2->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo2->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Pin both connections to the application timezone, so MySQL NOW() and PHP
// date() agree no matter how the database server itself is configured. Queries
// mix the two freely (rows written with NOW(), others with date()), and a
// mismatch shows up as timestamps hours away from the real clock.
foreach ([$pdo1, $pdo2] as $pdo_tz) {
try {
$pdo_tz->exec("SET time_zone = '" . APP_TIMEZONE_OFFSET . "'");
} catch (PDOException $e) {
// A server that refuses the offset keeps its own zone — no worse than
// before this call existed, and not a reason to fail the request.
}
}
+36
View File
@@ -257,6 +257,31 @@
var sku_val = $('#product_sku').attr('secondary') || $('#product_sku').val(); var sku_val = $('#product_sku').attr('secondary') || $('#product_sku').val();
var quantity_val = $('#quantity').val(); var quantity_val = $('#quantity').val();
// Quantities are stored as decimal(18,4). Anything finer is rounded away
// on insert, so 0.0000001 used to become a movement of 0.0000 that still
// looked like a successful stock-in. Reject it here with a message that
// names the limit; StockManager enforces the same rule server-side.
<?php if (empty($_GET["id"])) { ?>
var quantity_num = parseFloat(quantity_val);
if (!(quantity_num > 0)) {
bootbox.alert('Please enter a quantity greater than zero.');
return Promise.resolve();
}
if (round_dp(quantity_num, 4) < 0.0001) {
bootbox.alert('Quantity ' + quantity_num + ' is smaller than the minimum the system records (0.0001).');
return Promise.resolve();
}
quantity_val = round_dp(quantity_num, 4);
// Expiry dates are stored on the lot, so one entered without a lot number
// has nowhere to go and would be dropped without warning.
if ($('#expiry_date').val() && !$('#lot_number').val().trim()) {
bootbox.alert('Enter a lot number — expiry dates are recorded against a lot.');
return Promise.resolve();
}
<?php } ?>
// Mirror to hidden inputs for autoPrepare consistency (simple mode) // Mirror to hidden inputs for autoPrepare consistency (simple mode)
if (!advanced) { if (!advanced) {
$('#zone').val(bin_val); $('#zone').val(bin_val);
@@ -462,7 +487,18 @@
.val(data.expiry_date); .val(data.expiry_date);
if (expiryPicker && expiryPicker.altInput) { expiryPicker.altInput.disabled = true; } if (expiryPicker && expiryPicker.altInput) { expiryPicker.altInput.disabled = true; }
} else { } else {
// No lot number, so there is nothing for an expiry date to hang
// off: expiry lives on md_lot, keyed by lot. Leaving the field
// empty but editable invited entering one that would be silently
// discarded on update, so say why it is unavailable instead.
if (expiryPicker) { expiryPicker.clear(); } if (expiryPicker) { expiryPicker.clear(); }
$('#expiry_date').prop('disabled', true)
.attr('title', 'Expiry dates are recorded against a lot — this movement has no lot number')
.attr('placeholder', 'Not tracked — no lot number');
if (expiryPicker && expiryPicker.altInput) {
expiryPicker.altInput.disabled = true;
expiryPicker.altInput.placeholder = 'Not tracked — no lot number';
}
} }
$('#product_sku').attr('secondary', data.product_sku); $('#product_sku').attr('secondary', data.product_sku);
$('#product_sku, #quantity, #price').prop('disabled', true); $('#product_sku, #quantity, #price').prop('disabled', true);
+17 -9
View File
@@ -32,7 +32,7 @@
<input class="form-control" value='Warehouse' disabled> <input class="form-control" value='Warehouse' disabled>
</div> </div>
<div class="mb-3 col-lg-2"> <div class="mb-3 col-lg-2">
<select name="warehouse" id="warehouse" class="form-select" required></select> <select name="warehouse" id="warehouse" class="form-select"></select>
</div> </div>
<div class="mb-3 col-lg-2"> <div class="mb-3 col-lg-2">
<input class="form-control" value='Source' disabled> <input class="form-control" value='Source' disabled>
@@ -56,6 +56,7 @@
<tr> <tr>
<th>Date</th> <th>Date</th>
<th>Reference</th> <th>Reference</th>
<th>Warehouse</th>
<th>Product</th> <th>Product</th>
<th>Lot Number</th> <th>Lot Number</th>
<th>Serial Number</th> <th>Serial Number</th>
@@ -210,18 +211,21 @@
var body = ``; var body = ``;
var warehouse = $(`select#warehouse`).val();
$.each(page_data, function(key, item) { $.each(page_data, function(key, item) {
// The row's own warehouse, not the filter — with "All Warehouses" the
// filter has no value, and each row lives in its own td_stock_<id> table.
var warehouse = item['warehouse_id'];
body += `<tr> body += `<tr>
<td class="py-3">${format_date(item["date"])}</td> <td class="py-3">${format_date(item["date"])}</td>
<td class="py-3 fw-semibold">${item["stock_reference"] || "—"}</td> <td class="py-3 fw-semibold">${item["stock_reference"] || "—"}</td>
<td class="py-3">${escape_html(item['warehouse_name'] || '—')}</td>
<td class="py-3">${item["product_sku"]}: ${item['product_name']}</td> <td class="py-3">${item["product_sku"]}: ${item['product_name']}</td>
<td class="py-3">${item['lot_number'] || '<span class="text-muted">—</span>'}</td> <td class="py-3">${item['lot_number'] || '<span class="text-muted">—</span>'}</td>
<td class="py-3">${item['serial_number'] || '<span class="text-muted">—</span>'}</td> <td class="py-3">${item['serial_number'] || '<span class="text-muted">—</span>'}</td>
<td class="py-3"> <td class="py-3">
<div class="d-flex justify-content-end align-items-baseline gap-1"> <div class="d-flex justify-content-end align-items-baseline gap-1">
<span>${format_number(item['quantity'], 2)}</span> <span>${format_quantity(item['quantity'])}</span>
<span class="text-muted small" style="min-width:28px;">${item['uom'] || ''}</span> <span class="text-muted small" style="min-width:28px;">${item['uom'] || ''}</span>
</div> </div>
</td> </td>
@@ -234,7 +238,7 @@
<td class="py-3"> <td class="py-3">
<a href="<?php echo $server_url?>ics/manage_stock_in.php?id=${item['id']}&wh=${warehouse}" class=""><i class="ti ti-eye fs-5"></i></a> <a href="<?php echo $server_url?>ics/manage_stock_in.php?id=${item['id']}&wh=${warehouse}" class=""><i class="ti ti-eye fs-5"></i></a>
<a href="javascript:void(0);" class="link-danger" <a href="javascript:void(0);" class="link-danger"
onclick="delete_stock_in($(this),${item['id']})"> onclick="delete_stock_in($(this),${item['id']},${warehouse})">
<i class="ti ti-trash ms-2 fs-5"></i> <i class="ti ti-trash ms-2 fs-5"></i>
</a> </a>
</td> </td>
@@ -255,9 +259,13 @@
checkRequired: 0, checkRequired: 0,
action: 'read', action: 'read',
onSuccess: function(res) { onSuccess: function(res) {
var option = ``; // Default to every warehouse. Stock is stored one table per warehouse,
// so defaulting to a single one made a receipt that was split across
// warehouses look incomplete — a 4-line PO showed only the 3 lines that
// landed in the selected warehouse.
var option = `<option value=''>All Warehouses</option>`;
$.each(res.output, function(key, item) { $.each(res.output, function(key, item) {
option += `<option value='${item.id}'>${item.warehouse_name}</option>`; option += `<option value='${item.id}'>${escape_html(item.warehouse_name)}</option>`;
}) })
$(`select#warehouse`).html(option); $(`select#warehouse`).html(option);
} }
@@ -265,7 +273,7 @@
} }
function delete_stock_in(element, id) { function delete_stock_in(element, id, warehouse_id) {
return ajax_request({ return ajax_request({
url: "<?php echo $server_url?>ics/api/engine/delete_stock_in.php", url: "<?php echo $server_url?>ics/api/engine/delete_stock_in.php",
@@ -274,7 +282,7 @@
action: 'delete', action: 'delete',
data : { data : {
id: id, id: id,
wh: $(`select#warehouse`).val() wh: warehouse_id
}, },
onSuccess: function(res) { onSuccess: function(res) {
element.closest('tr').remove(); element.closest('tr').remove();
+16 -9
View File
@@ -32,7 +32,7 @@
<input class="form-control" value='Warehouse' disabled> <input class="form-control" value='Warehouse' disabled>
</div> </div>
<div class="mb-3 col-lg-2"> <div class="mb-3 col-lg-2">
<select name="warehouse" id="warehouse" class="form-select" required></select> <select name="warehouse" id="warehouse" class="form-select"></select>
</div> </div>
<div class="mb-3 col-lg-2"> <div class="mb-3 col-lg-2">
<input class="form-control" value='Source' disabled> <input class="form-control" value='Source' disabled>
@@ -56,6 +56,7 @@
<tr> <tr>
<th>Date</th> <th>Date</th>
<th>Reference</th> <th>Reference</th>
<th>Warehouse</th>
<th>Product</th> <th>Product</th>
<th>Lot Number</th> <th>Lot Number</th>
<th>Serial Number</th> <th>Serial Number</th>
@@ -151,7 +152,7 @@
} }
var delete_btn = (!source) var delete_btn = (!source)
? `<a href="javascript:void(0);" class="link-danger" onclick="delete_stock_out($(this),${item['id']})"> ? `<a href="javascript:void(0);" class="link-danger" onclick="delete_stock_out($(this),${item['id']},${warehouse})">
<i class="ti ti-trash ms-2 fs-5"></i> <i class="ti ti-trash ms-2 fs-5"></i>
</a>` </a>`
: ''; : '';
@@ -237,18 +238,21 @@
var body = ``; var body = ``;
var warehouse = $(`select#warehouse`).val();
$.each(page_data, function(key, item) { $.each(page_data, function(key, item) {
// The row's own warehouse, not the filter — with "All Warehouses" the
// filter has no value, and each row lives in its own td_stock_<id> table.
var warehouse = item['warehouse_id'];
body += `<tr> body += `<tr>
<td class="py-3">${format_date(item["date"])}</td> <td class="py-3">${format_date(item["date"])}</td>
<td class="py-3 fw-semibold">${item["stock_reference"] || "—"}</td> <td class="py-3 fw-semibold">${item["stock_reference"] || "—"}</td>
<td class="py-3">${escape_html(item['warehouse_name'] || '—')}</td>
<td class="py-3">${item["product_sku"]}: ${item['product_name']}</td> <td class="py-3">${item["product_sku"]}: ${item['product_name']}</td>
<td class="py-3">${item['lot_number'] || '<span class="text-muted">—</span>'}</td> <td class="py-3">${item['lot_number'] || '<span class="text-muted">—</span>'}</td>
<td class="py-3">${item['serial_number'] || '<span class="text-muted">—</span>'}</td> <td class="py-3">${item['serial_number'] || '<span class="text-muted">—</span>'}</td>
<td class="py-3"> <td class="py-3">
<div class="d-flex justify-content-end align-items-baseline gap-1"> <div class="d-flex justify-content-end align-items-baseline gap-1">
<span>${format_number(item['quantity'], 2)}</span> <span>${format_quantity(item['quantity'])}</span>
<span class="text-muted small" style="min-width:28px;">${item['uom'] || ''}</span> <span class="text-muted small" style="min-width:28px;">${item['uom'] || ''}</span>
</div> </div>
</td> </td>
@@ -275,9 +279,12 @@
checkRequired: 0, checkRequired: 0,
action: 'read', action: 'read',
onSuccess: function(res) { onSuccess: function(res) {
var option = ``; // Default to every warehouse — stock is stored one table per
// warehouse, so a single-warehouse default hides movements that went
// elsewhere and makes a document look only partly processed.
var option = `<option value=''>All Warehouses</option>`;
$.each(res.output, function(key, item) { $.each(res.output, function(key, item) {
option += `<option value='${item.id}'>${item.warehouse_name}</option>`; option += `<option value='${item.id}'>${escape_html(item.warehouse_name)}</option>`;
}) })
$(`select#warehouse`).html(option); $(`select#warehouse`).html(option);
} }
@@ -285,7 +292,7 @@
} }
function delete_stock_out(element, id) { function delete_stock_out(element, id, warehouse_id) {
return ajax_request({ return ajax_request({
url: "<?php echo $server_url?>ics/api/engine/delete_stock_out.php", url: "<?php echo $server_url?>ics/api/engine/delete_stock_out.php",
@@ -294,7 +301,7 @@
action: 'delete', action: 'delete',
data : { data : {
id: id, id: id,
wh: $(`select#warehouse`).val() wh: warehouse_id
}, },
onSuccess: function(res) { onSuccess: function(res) {
element.closest('tr').remove(); element.closest('tr').remove();
+5
View File
@@ -18,6 +18,11 @@ if (ob_get_level() === 0) {
ini_set('display_errors', '0'); ini_set('display_errors', '0');
ini_set('log_errors', '1'); ini_set('log_errors', '1');
// Apply the configured application timezone. Pages that only require config.php
// (no dbconn.php) still call date() for default values such as "today", so they
// need this too or they render a UTC date.
require_once __DIR__ . '/assets/utils/timezone.php';
// Security headers — emitted before any HTML output. // Security headers — emitted before any HTML output.
header('X-Content-Type-Options: nosniff'); header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: SAMEORIGIN'); header('X-Frame-Options: SAMEORIGIN');
+11 -9
View File
@@ -134,15 +134,17 @@ if (empty($_SESSION['skip_otp'])) {
// An explicit logout clears session_token to NULL, so back.php bypasses this. // An explicit logout clears session_token to NULL, so back.php bypasses this.
// //
// The staleness comparison is done entirely in SQL (session_last_seen vs MySQL's // The staleness comparison is done entirely in SQL (session_last_seen vs MySQL's
// own NOW()), not in PHP. session_last_seen is written with MySQL's NOW(), and // own NOW()), not in PHP, because session_last_seen is written with MySQL's
// the MySQL server here runs on Asia/Bangkok time while PHP's default timezone is // NOW() and so is best compared against it.
// UTC (config.php's $time_zone is never applied via date_default_timezone_set()). //
// Pulling the timestamp into PHP and comparing with strtotime()/time() silently // This originally worked around a timezone mismatch: config.php's $time_zone was
// misreads that Bangkok wall-clock string as UTC — 7 hours in the future — which // never applied via date_default_timezone_set(), so PHP ran on UTC while the
// made idle_seconds permanently negative and this check block every login, // MySQL server ran on Asia/Bangkok. Pulling the timestamp into PHP and comparing
// regardless of window size. Comparing inside MySQL sidesteps the mismatch // with strtotime()/time() misread that Bangkok wall-clock string as UTC — 7 hours
// without touching PHP's global timezone (which would ripple into every other // in the future — which made idle_seconds permanently negative and blocked every
// date()/time() call in the app). // login. assets/utils/timezone.php now applies $time_zone to PHP and pins both
// PDO connections to the same offset, so the mismatch is gone; comparing in SQL
// is kept because it is still the most direct way to read a NOW()-written column.
define('SESSION_ACTIVE_GRACE_SECONDS', 120); define('SESSION_ACTIVE_GRACE_SECONDS', 120);
$sth_active = $pdo1->prepare( $sth_active = $pdo1->prepare(
+23 -5
View File
@@ -527,9 +527,9 @@
<span class="text-muted ms-2 small">${item.product_name || ''}</span> <span class="text-muted ms-2 small">${item.product_name || ''}</span>
</div> </div>
<div class="d-flex gap-3 text-muted small"> <div class="d-flex gap-3 text-muted small">
<span>Ordered: <strong>${format_number(item.ordered_qty, 0)}</strong></span> <span>Ordered: <strong>${format_quantity(item.ordered_qty)}</strong></span>
<span>Received: <strong>${format_number(item.received_qty, 0)}</strong></span> <span>Received: <strong>${format_quantity(item.received_qty)}</strong></span>
<span>Remaining: <strong class="text-primary">${format_number(item.remaining_qty, 0)}</strong></span> <span>Remaining: <strong class="text-primary">${format_quantity(item.remaining_qty)}</strong></span>
</div> </div>
</div> </div>
@@ -546,7 +546,7 @@
<div class="col-lg-3"> <div class="col-lg-3">
<label class="form-label small text-muted">Receiving Qty <span class="text-danger">*</span></label> <label class="form-label small text-muted">Receiving Qty <span class="text-danger">*</span></label>
<input type="number" id="recv_qty_${rowId}" class="form-control form-control-sm" <input type="number" id="recv_qty_${rowId}" class="form-control form-control-sm"
value="${item.remaining_qty}" min="0.0001" max="${item.remaining_qty}" step="any"> value="${item.remaining_qty}" min="0.0001" max="${item.remaining_qty}" step="0.0001">
</div> </div>
<div class="col-lg-3"> <div class="col-lg-3">
@@ -770,6 +770,15 @@
var qty = parseFloat($(`#recv_qty_${rowId}`).val()) || 0; var qty = parseFloat($(`#recv_qty_${rowId}`).val()) || 0;
if (qty <= 0) return; if (qty <= 0) return;
// Received quantities are stored as decimal(18,4). A value finer than
// that is rounded away on insert, so 0.0000001 was accepted, created a
// stock movement of 0.0000, and still left the PO showing "Partial".
if (round_dp(qty, 4) < 0.0001) {
errors.push(`${$card.data('sku')}: receiving quantity ${qty} is smaller than the minimum the system records (0.0001).`);
return;
}
qty = round_dp(qty, 4);
var wh_id = parseInt($(`#recv_wh_${rowId}`).val()) || 0; var wh_id = parseInt($(`#recv_wh_${rowId}`).val()) || 0;
var bin = $(`#recv_bin_${rowId}`).val() || ''; var bin = $(`#recv_bin_${rowId}`).val() || '';
@@ -791,13 +800,22 @@
// flatpickr with altInput: the real (hidden) input holds the ISO value // flatpickr with altInput: the real (hidden) input holds the ISO value
var expiry_val = $(`#recv_expiry_${rowId}`).val() || ''; var expiry_val = $(`#recv_expiry_${rowId}`).val() || '';
var lot_val = $(`#recv_lot_${rowId}`).val().trim();
// Expiry dates are stored on md_lot, keyed by lot number — one entered
// without a lot has nowhere to go and was dropped without warning, so
// the received stock then showed no expiry at all.
if (expiry_val && !lot_val) {
errors.push(`${$card.data('sku')}: enter a lot number — expiry dates are recorded against a lot.`);
return;
}
receive_items.push({ receive_items.push({
item_id: parseInt($card.data('item-id')), item_id: parseInt($card.data('item-id')),
product_sku: $card.data('sku'), product_sku: $card.data('sku'),
warehouse_id: wh_id, warehouse_id: wh_id,
quantity: qty, quantity: qty,
lot_number: $(`#recv_lot_${rowId}`).val().trim(), lot_number: lot_val,
expiry_date: expiry_val, expiry_date: expiry_val,
serial_number: $(`#recv_serial_${rowId}`).val().trim(), serial_number: $(`#recv_serial_${rowId}`).val().trim(),
zone: zone, zone: zone,
+18 -1
View File
@@ -160,6 +160,8 @@
var invoice_id = <?php echo $invoice_id; ?>; var invoice_id = <?php echo $invoice_id; ?>;
var invoice_data = null; var invoice_data = null;
var issued_date = ''; // ISO issue date — lower bound for the due date
var due_picker = null; // flatpickr instance on #due_date
function doc_type_badge(doc_type) { function doc_type_badge(doc_type) {
const map = { const map = {
@@ -226,6 +228,11 @@
$('#display_contact').text(inv.contact_name || '—'); $('#display_contact').text(inv.contact_name || '—');
$('#display_department').text(get_dept_label(inv.department_id)); $('#display_department').text(get_dept_label(inv.department_id));
$('#display_issued').text(format_date(inv.issued_date) || '—'); $('#display_issued').text(format_date(inv.issued_date) || '—');
// A due date before the issue date is not a valid payment term, so
// stop the picker from offering one.
issued_date = inv.issued_date ? String(inv.issued_date).split(' ')[0] : '';
if (due_picker && issued_date) due_picker.set('minDate', issued_date);
$('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : ''); $('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : '');
$('#notes').val(inv.notes || ''); $('#notes').val(inv.notes || '');
@@ -311,6 +318,12 @@
} }
function save_invoice() { function save_invoice() {
var due_val = $('#due_date').val().trim();
if (due_val && issued_date && to_iso_date(due_val) < issued_date) {
bootbox.alert('The due date cannot be earlier than the issue date.');
return;
}
ajax_request({ ajax_request({
url: '<?php echo $server_url?>order/api/engine/manage_invoice.php', url: '<?php echo $server_url?>order/api/engine/manage_invoice.php',
autoPrepare: true, autoPrepare: true,
@@ -346,6 +359,10 @@
bootbox.alert('Please enter a due date before issuing this purchase invoice.'); bootbox.alert('Please enter a due date before issuing this purchase invoice.');
return; return;
} }
if (!is_dn && due_date && issued_date && to_iso_date(due_date) < issued_date) {
bootbox.alert('The due date cannot be earlier than the issue date.');
return;
}
var label = is_dn ? 'Issue Supplier Credit Note' : 'Issue Purchase Invoice'; var label = is_dn ? 'Issue Supplier Credit Note' : 'Issue Purchase Invoice';
bootbox.confirm({ bootbox.confirm({
message: is_dn ? 'Issue this supplier credit note?' : 'Issue this purchase invoice?', message: is_dn ? 'Issue this supplier credit note?' : 'Issue this purchase invoice?',
@@ -419,8 +436,8 @@
$(function() { $(function() {
load_dept_cache(); load_dept_cache();
due_picker = flatpickr('#due_date', { dateFormat: 'd/m/Y', allowInput: true });
if (invoice_id) retrieve_invoice(); if (invoice_id) retrieve_invoice();
flatpickr('#due_date', { dateFormat: 'd/m/Y', allowInput: true });
}); });
</script> </script>
+26 -2
View File
@@ -146,7 +146,9 @@
<?php require '../include_ending.php'; ?> <?php require '../include_ending.php'; ?>
<script> <script>
var invoice_id = <?php echo $invoice_id; ?>; var invoice_id = <?php echo $invoice_id; ?>;
var issued_date = ''; // ISO issue date — lower bound for the due date
var due_picker = null; // flatpickr instance on #due_date
function doc_type_badge(doc_type) { function doc_type_badge(doc_type) {
const map = { const map = {
@@ -208,6 +210,11 @@
$('#display_issued').html(inv.issued_date ? format_date(inv.issued_date) : '<span class="text-muted">—</span>'); $('#display_issued').html(inv.issued_date ? format_date(inv.issued_date) : '<span class="text-muted">—</span>');
$('#display_due').html(inv.due_date ? format_date(inv.due_date) : '<span class="text-muted">—</span>'); $('#display_due').html(inv.due_date ? format_date(inv.due_date) : '<span class="text-muted">—</span>');
$('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : ''); $('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : '');
// A due date before the issue date is not a valid payment term, so stop
// the picker from offering one.
issued_date = inv.issued_date ? String(inv.issued_date).split(' ')[0] : '';
if (due_picker && issued_date) due_picker.set('minDate', issued_date);
$('#display_notes').html(inv.notes ? escape_html(inv.notes).replace(/\n/g, '<br>') : '<span class="text-muted">—</span>'); $('#display_notes').html(inv.notes ? escape_html(inv.notes).replace(/\n/g, '<br>') : '<span class="text-muted">—</span>');
var rows = ''; var rows = '';
@@ -279,6 +286,18 @@
} }
function save_invoice() { function save_invoice() {
// autoPrepare sweeps every .form-control into the payload, so #due_date
// arrives as the picker's DD/MM/YYYY text. Sent unconverted it reaches a
// MySQL DATE column verbatim and the insert fails, which the engine
// reports as the opaque "Database error, please try again." Convert it
// here, the way the purchase-invoice page already does.
var due_date = $('#due_date').val().trim();
if (due_date && issued_date && to_iso_date(due_date) < issued_date) {
bootbox.alert('The due date cannot be earlier than the issue date.');
return;
}
ajax_request({ ajax_request({
url: '<?php echo $server_url?>order/api/engine/manage_invoice.php', url: '<?php echo $server_url?>order/api/engine/manage_invoice.php',
autoPrepare: true, autoPrepare: true,
@@ -286,6 +305,7 @@
action: 'update', action: 'update',
data: { data: {
id: invoice_id, id: invoice_id,
due_date: due_date ? to_iso_date(due_date) : '',
tax_adjustment: parseFloat($('#tax_adjustment').val()) || 0, tax_adjustment: parseFloat($('#tax_adjustment').val()) || 0,
}, },
onSuccess: function() { retrieve_invoice(); } onSuccess: function() { retrieve_invoice(); }
@@ -320,6 +340,10 @@
bootbox.alert('Please enter a due date before issuing this invoice.'); bootbox.alert('Please enter a due date before issuing this invoice.');
return; return;
} }
if (due_date && issued_date && to_iso_date(due_date) < issued_date) {
bootbox.alert('The due date cannot be earlier than the issue date.');
return;
}
bootbox.confirm({ bootbox.confirm({
message: 'Issue this invoice?', message: 'Issue this invoice?',
buttons: { buttons: {
@@ -390,8 +414,8 @@
} }
$(function() { $(function() {
due_picker = flatpickr('#due_date', { dateFormat: 'd/m/Y', allowInput: true });
if (invoice_id) retrieve_invoice(); if (invoice_id) retrieve_invoice();
flatpickr('#due_date', { dateFormat: 'd/m/Y', allowInput: true });
}); });
</script> </script>