Compare commits

...
2 Commits
Author SHA1 Message Date
Thanakorn d8363f6ca7 Merge fix/feedback-16-09 2026-09-17 09:00:37 +07:00
Thanakorn f70f226bd1 Fix timestamps, delete requests, invoice dates and GR quantities
Apply the configured timezone to PHP and both DB connections, wrap
unwrapped ajax payloads so delete buttons reach their engines, normalise
and validate invoice due dates, reject stock quantities below the stored
4dp scale, and list stock movements across all warehouses.
2026-09-17 09:00:15 +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>