From f70f226bd13294047ab8b27106b5a900f68feaf2 Mon Sep 17 00:00:00 2001 From: Thanakorn Date: Thu, 17 Sep 2026 09:00:15 +0700 Subject: [PATCH] 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. --- app/assets/js/custom.js | 57 ++++++- app/assets/utils/classes/InvoiceManager.php | 57 ++++++- .../utils/classes/PurchaseOrderManager.php | 22 ++- app/assets/utils/classes/StockManager.php | 142 +++++++++++++++--- app/assets/utils/timezone.php | 40 +++++ app/dbconn.php | 19 ++- app/ics/manage_stock_in.php | 36 +++++ app/ics/stock_in.php | 26 ++-- app/ics/stock_out.php | 25 +-- app/include_header.php | 5 + app/login/api/engine/login_confirm.php | 20 +-- app/po/manage_po.php | 28 +++- app/po/manage_purchase_invoice.php | 19 ++- app/revenue/manage_invoice.php | 28 +++- 14 files changed, 464 insertions(+), 60 deletions(-) create mode 100644 app/assets/utils/timezone.php diff --git a/app/assets/js/custom.js b/app/assets/js/custom.js index a86a945..12b9f39 100644 --- a/app/assets/js/custom.js +++ b/app/assets/js/custom.js @@ -772,6 +772,42 @@ function ajax_request(options) { // Override options.data with the full FormData object 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 --- @@ -1073,6 +1109,25 @@ function expand_exponential_number(value) { 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) { var n = Number(value); if (isNaN(n)) return '--'; @@ -1184,7 +1239,7 @@ function show_stock_rows(source, source_id, label) { ${escape_html(r.warehouse_name)} ${escape_html(location)} ${escape_html(r.lot_number || '—')} - ${format_number(r.quantity, 2)} + ${format_quantity(r.quantity)} ${status_badge} ${format_date(r.date)} `; diff --git a/app/assets/utils/classes/InvoiceManager.php b/app/assets/utils/classes/InvoiceManager.php index d8bcee7..6de171e 100644 --- a/app/assets/utils/classes/InvoiceManager.php +++ b/app/assets/utils/classes/InvoiceManager.php @@ -403,12 +403,47 @@ class InvoiceManager { * @param array $logging Audit entry. * @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 { $id = (int)($data['id'] ?? 0); $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" ); $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 ? (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 = [ - ':due_date' => $data['due_date'] ?: null, + ':due_date' => $due_date, ':notes' => $data['notes'] ?? '', ':formula_id' => $formula_id, ':log' => json_encode($log), @@ -525,6 +573,11 @@ class InvoiceManager { if (in_array($row['doc_type'], ['invoice', 'purchase_invoice']) && !$due_date) { 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']))); $log = json_decode($row['log'] ?? '[]', true) ?: []; diff --git a/app/assets/utils/classes/PurchaseOrderManager.php b/app/assets/utils/classes/PurchaseOrderManager.php index 43da110..159d822 100644 --- a/app/assets/utils/classes/PurchaseOrderManager.php +++ b/app/assets/utils/classes/PurchaseOrderManager.php @@ -1,6 +1,7 @@ 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'. - * @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 { - $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). - $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(); // Transfer list: show only the outbound side (out > 0) to avoid duplicate display $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_ 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_ 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( - "SELECT a.*, {$stock_ref} AS stock_reference, {$column} AS quantity, b.product_name, b.uom - FROM `{$table}` a - LEFT JOIN md_product b - ON a.company_id = b.company_id - AND a.product_sku = b.sku - WHERE a.company_id = :company_id - AND a.type = :type - {$extra_cond} - ORDER BY a.date DESC" + "SELECT w.id + FROM md_warehouse w + JOIN information_schema.tables t + ON t.table_schema = DATABASE() + AND t.table_name = CONCAT('td_stock_', w.id) + WHERE w.company_id = :company_id + ORDER BY w.id" ); - $sth->execute([ - ':company_id' => $this->company_id, - ':type' => $type, - ]); - return $sth->fetchAll(PDO::FETCH_ASSOC); + $sth->execute([':company_id' => $this->company_id]); + return array_map('intval', $sth->fetchAll(PDO::FETCH_COLUMN)); } /** @@ -258,8 +360,8 @@ class StockManager { $warehouse_id = (int)($data["warehouse"] ?? 0); $quantity = (float)($data['quantity'] ?? 0); - if ($id === 0 && $quantity <= 0) { - throw new Exception("Quantity must be greater than zero."); + if ($id === 0) { + $quantity = self::normaliseQuantity($quantity); } $whMgmt = new WarehouseManager($this->pdo, $this->company_id); diff --git a/app/assets/utils/timezone.php b/app/assets/utils/timezone.php new file mode 100644 index 0000000..5c18fad --- /dev/null +++ b/app/assets/utils/timezone.php @@ -0,0 +1,40 @@ +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) + )); +} diff --git a/app/dbconn.php b/app/dbconn.php index e698975..0a7a53b 100644 --- a/app/dbconn.php +++ b/app/dbconn.php @@ -1,5 +1,9 @@ setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); //..................... PDO2 .....................// $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); \ No newline at end of file +$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. + } +} \ No newline at end of file diff --git a/app/ics/manage_stock_in.php b/app/ics/manage_stock_in.php index c6043af..184d97d 100644 --- a/app/ics/manage_stock_in.php +++ b/app/ics/manage_stock_in.php @@ -257,6 +257,31 @@ var sku_val = $('#product_sku').attr('secondary') || $('#product_sku').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. + + 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(); + } + + // Mirror to hidden inputs for autoPrepare consistency (simple mode) if (!advanced) { $('#zone').val(bin_val); @@ -462,7 +487,18 @@ .val(data.expiry_date); if (expiryPicker && expiryPicker.altInput) { expiryPicker.altInput.disabled = true; } } 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(); } + $('#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, #quantity, #price').prop('disabled', true); diff --git a/app/ics/stock_in.php b/app/ics/stock_in.php index df0648d..ec55a47 100644 --- a/app/ics/stock_in.php +++ b/app/ics/stock_in.php @@ -32,7 +32,7 @@
- +
@@ -56,6 +56,7 @@ Date Reference + Warehouse Product Lot Number Serial Number @@ -210,18 +211,21 @@ var body = ``; - var warehouse = $(`select#warehouse`).val(); - $.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_ table. + var warehouse = item['warehouse_id']; + body += ` ${format_date(item["date"])} ${item["stock_reference"] || "—"} + ${escape_html(item['warehouse_name'] || '—')} ${item["product_sku"]}: ${item['product_name']} ${item['lot_number'] || '—'} ${item['serial_number'] || '—'}
- ${format_number(item['quantity'], 2)} + ${format_quantity(item['quantity'])} ${item['uom'] || ''}
@@ -234,7 +238,7 @@ + onclick="delete_stock_in($(this),${item['id']},${warehouse})"> @@ -255,9 +259,13 @@ checkRequired: 0, action: 'read', 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 = ``; $.each(res.output, function(key, item) { - option += ``; + 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({ url: "ics/api/engine/delete_stock_in.php", @@ -274,7 +282,7 @@ action: 'delete', data : { id: id, - wh: $(`select#warehouse`).val() + wh: warehouse_id }, onSuccess: function(res) { element.closest('tr').remove(); diff --git a/app/ics/stock_out.php b/app/ics/stock_out.php index 2371329..dd0af98 100644 --- a/app/ics/stock_out.php +++ b/app/ics/stock_out.php @@ -32,7 +32,7 @@
- +
@@ -56,6 +56,7 @@ Date Reference + Warehouse Product Lot Number Serial Number @@ -151,7 +152,7 @@ } var delete_btn = (!source) - ? ` + ? ` ` : ''; @@ -237,18 +238,21 @@ var body = ``; - var warehouse = $(`select#warehouse`).val(); - $.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_ table. + var warehouse = item['warehouse_id']; + body += ` ${format_date(item["date"])} ${item["stock_reference"] || "—"} + ${escape_html(item['warehouse_name'] || '—')} ${item["product_sku"]}: ${item['product_name']} ${item['lot_number'] || '—'} ${item['serial_number'] || '—'}
- ${format_number(item['quantity'], 2)} + ${format_quantity(item['quantity'])} ${item['uom'] || ''}
@@ -275,9 +279,12 @@ checkRequired: 0, action: 'read', 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 = ``; $.each(res.output, function(key, item) { - option += ``; + 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({ url: "ics/api/engine/delete_stock_out.php", @@ -294,7 +301,7 @@ action: 'delete', data : { id: id, - wh: $(`select#warehouse`).val() + wh: warehouse_id }, onSuccess: function(res) { element.closest('tr').remove(); diff --git a/app/include_header.php b/app/include_header.php index 65ed825..b20c91d 100644 --- a/app/include_header.php +++ b/app/include_header.php @@ -18,6 +18,11 @@ if (ob_get_level() === 0) { ini_set('display_errors', '0'); 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. header('X-Content-Type-Options: nosniff'); header('X-Frame-Options: SAMEORIGIN'); diff --git a/app/login/api/engine/login_confirm.php b/app/login/api/engine/login_confirm.php index e9c07fa..0166c76 100644 --- a/app/login/api/engine/login_confirm.php +++ b/app/login/api/engine/login_confirm.php @@ -134,15 +134,17 @@ if (empty($_SESSION['skip_otp'])) { // 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 -// own NOW()), not in PHP. session_last_seen is written with MySQL's NOW(), and -// the MySQL server here runs on Asia/Bangkok time while PHP's default timezone is -// 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 -// misreads that Bangkok wall-clock string as UTC — 7 hours in the future — which -// made idle_seconds permanently negative and this check block every login, -// regardless of window size. Comparing inside MySQL sidesteps the mismatch -// without touching PHP's global timezone (which would ripple into every other -// date()/time() call in the app). +// own NOW()), not in PHP, because session_last_seen is written with MySQL's +// NOW() and so is best compared against it. +// +// This originally worked around a timezone mismatch: config.php's $time_zone was +// never applied via date_default_timezone_set(), so PHP ran on UTC while the +// MySQL server ran on Asia/Bangkok. Pulling the timestamp into PHP and comparing +// with strtotime()/time() misread that Bangkok wall-clock string as UTC — 7 hours +// in the future — which made idle_seconds permanently negative and blocked every +// 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); $sth_active = $pdo1->prepare( diff --git a/app/po/manage_po.php b/app/po/manage_po.php index f241786..aa75577 100644 --- a/app/po/manage_po.php +++ b/app/po/manage_po.php @@ -527,9 +527,9 @@ ${item.product_name || ''}
- Ordered: ${format_number(item.ordered_qty, 0)} - Received: ${format_number(item.received_qty, 0)} - Remaining: ${format_number(item.remaining_qty, 0)} + Ordered: ${format_quantity(item.ordered_qty)} + Received: ${format_quantity(item.received_qty)} + Remaining: ${format_quantity(item.remaining_qty)}
@@ -546,7 +546,7 @@
+ value="${item.remaining_qty}" min="0.0001" max="${item.remaining_qty}" step="0.0001">
@@ -770,6 +770,15 @@ var qty = parseFloat($(`#recv_qty_${rowId}`).val()) || 0; 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 bin = $(`#recv_bin_${rowId}`).val() || ''; @@ -791,13 +800,22 @@ // flatpickr with altInput: the real (hidden) input holds the ISO value 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({ item_id: parseInt($card.data('item-id')), product_sku: $card.data('sku'), warehouse_id: wh_id, quantity: qty, - lot_number: $(`#recv_lot_${rowId}`).val().trim(), + lot_number: lot_val, expiry_date: expiry_val, serial_number: $(`#recv_serial_${rowId}`).val().trim(), zone: zone, diff --git a/app/po/manage_purchase_invoice.php b/app/po/manage_purchase_invoice.php index c33747f..548809c 100644 --- a/app/po/manage_purchase_invoice.php +++ b/app/po/manage_purchase_invoice.php @@ -160,6 +160,8 @@ var invoice_id = ; 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) { const map = { @@ -226,6 +228,11 @@ $('#display_contact').text(inv.contact_name || '—'); $('#display_department').text(get_dept_label(inv.department_id)); $('#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) : ''); $('#notes').val(inv.notes || ''); @@ -311,6 +318,12 @@ } 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({ url: 'order/api/engine/manage_invoice.php', autoPrepare: true, @@ -346,6 +359,10 @@ bootbox.alert('Please enter a due date before issuing this purchase invoice.'); 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'; bootbox.confirm({ message: is_dn ? 'Issue this supplier credit note?' : 'Issue this purchase invoice?', @@ -419,8 +436,8 @@ $(function() { load_dept_cache(); + due_picker = flatpickr('#due_date', { dateFormat: 'd/m/Y', allowInput: true }); if (invoice_id) retrieve_invoice(); - flatpickr('#due_date', { dateFormat: 'd/m/Y', allowInput: true }); }); diff --git a/app/revenue/manage_invoice.php b/app/revenue/manage_invoice.php index 113298d..40f3406 100644 --- a/app/revenue/manage_invoice.php +++ b/app/revenue/manage_invoice.php @@ -146,7 +146,9 @@