Merge fix/feedback-16-09
This commit is contained in:
+56
-1
@@ -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>`;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user