diff --git a/app/accounting/gl_entries.php b/app/accounting/gl_entries.php
index 9e7d7ca..c970b77 100644
--- a/app/accounting/gl_entries.php
+++ b/app/accounting/gl_entries.php
@@ -262,7 +262,7 @@
'
' + escape_html(r.doc_number) + ' ' +
'' + escape_html(r.contact_name || '—') + ' ' +
'' + format_number(r.grand_total, 2) + ' ' +
- '' + escape_html(r.doc_date || '—') + ' ' +
+ '' + escape_html(format_date(r.doc_date)) + ' ' +
'' + mapping_badge + ' ' +
'' + status_badge + ' ' +
'';
diff --git a/app/accounting/gl_movement.php b/app/accounting/gl_movement.php
index b51be2d..ae8a269 100644
--- a/app/accounting/gl_movement.php
+++ b/app/accounting/gl_movement.php
@@ -198,7 +198,7 @@
export_data.push({ date:r.entry_date||'', period:r.period||'', department:r.dept_code||'', reference:r.reference||'', description:r.line_description||r.gl_description||'', debit:dr||'', credit:cr||'', balance:running });
var bal_color = running >= 0 ? '' : 'text-danger';
html += '' +
- '' + escape_html(r.entry_date || '—') + ' ' +
+ '' + escape_html(format_date(r.entry_date)) + ' ' +
'' + escape_html(r.period) + ' ' +
'' + (r.dept_code ? escape_html(r.dept_code) : '— ') + ' ' +
'' + escape_html(r.reference || '—') + ' ' +
diff --git a/app/accounting/journal_listing.php b/app/accounting/journal_listing.php
index 224a59b..efed99a 100644
--- a/app/accounting/journal_listing.php
+++ b/app/accounting/journal_listing.php
@@ -281,7 +281,7 @@
'' + escape_html(r.formula_name || '—') + ' ' +
'' + format_number(r.total_debit, 2) + ' ' +
'' + format_number(r.total_credit, 2) + ' ' +
- '' + escape_html(r.posted_at) + ' ' +
+ '' + escape_html(format_date(r.posted_at)) + ' ' +
'' +
'' +
' ' +
diff --git a/app/accounting/vat_report.php b/app/accounting/vat_report.php
index c2f90b7..1aadad4 100644
--- a/app/accounting/vat_report.php
+++ b/app/accounting/vat_report.php
@@ -210,7 +210,7 @@
var html = '';
rows.forEach(function(r) {
html += ' ' +
- '' + escape_html(r.entry_date || '—') + ' ' +
+ '' + escape_html(format_date(r.entry_date)) + ' ' +
'' + escape_html(r.period) + ' ' +
'' + escape_html(src_labels[r.source_type] || r.source_type) + ' ' +
'' + (r.dept_code ? escape_html(r.dept_code) : '— ') + ' ' +
diff --git a/app/assets/js/custom.js b/app/assets/js/custom.js
index 5b5ad57..fe9c47e 100644
--- a/app/assets/js/custom.js
+++ b/app/assets/js/custom.js
@@ -4,6 +4,36 @@ function escape_html(value) {
});
}
+// Text is stored HTML-escaped (see db_statement in dbconn.php), which is right
+// for anything written into markup but wrong inside a form field: a note saved
+// as 5" pipe came back as 5" pipe <spare>. Field values
+// are never parsed as HTML, so decoding them here is safe.
+function decode_html(value) {
+ if (typeof value !== 'string' || value.indexOf('&') === -1) return value;
+ return value.replace(/&(quot|#0*39|#x0*27|apos|lt|gt|amp);/gi, function (m, name) {
+ name = name.toLowerCase();
+ if (name === 'quot') return '"';
+ if (name === 'lt') return '<';
+ if (name === 'gt') return '>';
+ if (name === 'amp') return '&';
+ return "'";
+ });
+}
+
+(function ($) {
+ if (!$ || !$.fn || $.fn.val.__decodes_html) return;
+ var original_val = $.fn.val;
+ $.fn.val = function (value) {
+ if (arguments.length && typeof value === 'string') {
+ // Only free-text fields; a value must keep matching its option.
+ var text_fields = this.filter('input, textarea');
+ if (text_fields.length === this.length) return original_val.call(this, decode_html(value));
+ }
+ return original_val.apply(this, arguments);
+ };
+ $.fn.val.__decodes_html = true;
+})(window.jQuery);
+
/** =========================
* SIDEBAR ACTIVE STATE
* Override: activate the parent listing page for manage_* sub-pages.
diff --git a/app/assets/utils/classes/DocumentValidator.php b/app/assets/utils/classes/DocumentValidator.php
new file mode 100644
index 0000000..b50a6d0
--- /dev/null
+++ b/app/assets/utils/classes/DocumentValidator.php
@@ -0,0 +1,131 @@
+ $item) {
+ if (!is_array($item)) {
+ throw new Exception("{$doc_label} line #" . ($i + 1) . " is not valid.");
+ }
+ $name = trim((string)($item['product_name'] ?? '')) ?: trim((string)($item['product_sku'] ?? ''));
+ $label = 'Line #' . ($i + 1) . ($name !== '' ? " ({$name})" : '');
+
+ $qty = self::number($item['quantity'] ?? 0, "{$label}: quantity");
+ $price = self::number($item['unit_price'] ?? $item['price'] ?? 0, "{$label}: unit price");
+ $rate = self::number($item['tax_rate'] ?? 0, "{$label}: tax rate");
+
+ $qty = round($qty, 4);
+ if ($qty < self::MIN_QUANTITY) {
+ throw new Exception("{$label}: quantity must be greater than zero.");
+ }
+ if ($qty > self::MAX_QUANTITY) {
+ throw new Exception("{$label}: quantity is too large.");
+ }
+ if ($price < 0) {
+ throw new Exception("{$label}: unit price cannot be negative.");
+ }
+ if ($price > self::MAX_UNIT_PRICE) {
+ throw new Exception("{$label}: unit price cannot exceed " . number_format(self::MAX_UNIT_PRICE, 2) . ".");
+ }
+ if ($rate < 0 || $rate > self::MAX_TAX_RATE) {
+ throw new Exception("{$label}: tax rate must be between 0 and " . self::MAX_TAX_RATE . "%.");
+ }
+
+ $total = round($qty * $price, 4);
+ $item['quantity'] = $qty;
+ $item['unit_price'] = round($price, 4);
+ $item['tax_rate'] = round($rate, 2);
+ $item['total_price'] = $total;
+ $item['tax_amount'] = round($total * $item['tax_rate'] / 100, 4);
+ $out[] = $item;
+ }
+ return $out;
+ }
+
+ /** Header amounts (discount, shipping fee): numeric and never negative. */
+ public static function amount($value, string $label): float
+ {
+ $n = self::number($value, $label);
+ if ($n < 0) {
+ throw new Exception("{$label} cannot be negative.");
+ }
+ if ($n > self::MAX_UNIT_PRICE * 1000) {
+ throw new Exception("{$label} is too large.");
+ }
+ return $n;
+ }
+
+ /** A discount larger than the goods would turn the document negative. */
+ public static function discount($value, float $subtotal): float
+ {
+ $discount = self::amount($value, 'Discount');
+ if ($discount > $subtotal + 0.00005) {
+ throw new Exception('Discount cannot exceed the subtotal.');
+ }
+ return $discount;
+ }
+
+ public static function requireId($value, string $message): int
+ {
+ $id = (int)$value;
+ if ($id <= 0) {
+ throw new Exception($message);
+ }
+ return $id;
+ }
+
+ /**
+ * A department is mandatory once the company uses departments. A company
+ * that has never defined one keeps saving with "No Department".
+ */
+ public static function requireDepartment(PDO $pdo, int $company_id, $value): int
+ {
+ $id = (int)$value;
+ if ($id > 0) {
+ $sth = $pdo->prepare("SELECT COUNT(*) FROM md_department WHERE company_id = :cid AND id = :id");
+ $sth->execute([':cid' => $company_id, ':id' => $id]);
+ if ((int)$sth->fetchColumn() === 0) {
+ throw new Exception('The selected department does not exist.');
+ }
+ return $id;
+ }
+ $sth = $pdo->prepare("SELECT COUNT(*) FROM md_department WHERE company_id = :cid AND status = 1");
+ $sth->execute([':cid' => $company_id]);
+ if ((int)$sth->fetchColumn() > 0) {
+ throw new Exception('Department is required.');
+ }
+ return 0;
+ }
+
+ private static function number($value, string $label): float
+ {
+ if ($value === '' || $value === null) return 0.0;
+ if (!is_numeric($value) || !is_finite((float)$value)) {
+ throw new Exception("{$label} must be a number.");
+ }
+ return (float)$value;
+ }
+}
diff --git a/app/assets/utils/classes/OrderManager.php b/app/assets/utils/classes/OrderManager.php
index 6aa24f7..87ae74e 100644
--- a/app/assets/utils/classes/OrderManager.php
+++ b/app/assets/utils/classes/OrderManager.php
@@ -1,5 +1,6 @@
execute([':company_id' => $this->company_id]);
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
@@ -485,13 +486,18 @@ class OrderManager {
public function saveOrder(array $data, array $logging): int
{
$id = (int)($data['id'] ?? 0);
- $items = $data['items'] ?? [];
+ $items = DocumentValidator::normaliseLines($data['items'] ?? [], 'Order');
+ $data['items'] = $items;
+ DocumentValidator::requireId($data['contact_id'] ?? 0, 'Contact is required.');
+ if ($id === 0 || array_key_exists('department_id', $data)) {
+ $data['department_id'] = DocumentValidator::requireDepartment($this->pdo, $this->company_id, $data['department_id'] ?? 0);
+ }
// Calculate totals from items
$subtotal = array_reduce($items, fn($carry, $item) =>
$carry + (float)($item['total_price'] ?? 0), 0.0
);
- $discount = (float)($data['discount'] ?? 0);
+ $discount = DocumentValidator::discount($data['discount'] ?? 0, $subtotal);
$tax_adjustment = (float)($data['tax_adjustment'] ?? 0);
if (abs($tax_adjustment) > 0.30) {
throw new Exception("Tax adjustment cannot exceed ±0.30.");
@@ -499,7 +505,7 @@ class OrderManager {
$tax = round(array_reduce($items, fn($carry, $item) =>
$carry + (float)($item['tax_amount'] ?? 0), 0.0
), 2) + $tax_adjustment;
- $shipping_fee = (float)($data['shipping_fee'] ?? 0);
+ $shipping_fee = DocumentValidator::amount($data['shipping_fee'] ?? 0, 'Shipping fee');
$tracking_no = trim((string)($data['shipping_tracking_number'] ?? ''));
$grand_total = $subtotal - $discount + $tax + $shipping_fee;
diff --git a/app/assets/utils/classes/ProductManager.php b/app/assets/utils/classes/ProductManager.php
index 30150dc..c8ef448 100644
--- a/app/assets/utils/classes/ProductManager.php
+++ b/app/assets/utils/classes/ProductManager.php
@@ -569,9 +569,9 @@ class ProductManager {
ON p.company_id = r.company_id
AND p.sku = r.product_sku
WHERE r.company_id = :company_id
- ORDER BY mw.warehouse_name, r.zone,
- CAST(r.aisle AS UNSIGNED), r.aisle,
- CAST(r.bin AS UNSIGNED), r.bin"
+ ORDER BY mw.warehouse_name, REGEXP_REPLACE(r.zone, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.zone, '[0-9]+$') AS UNSIGNED), r.zone,
+ REGEXP_REPLACE(r.aisle, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.aisle, '[0-9]+$') AS UNSIGNED), r.aisle,
+ REGEXP_REPLACE(r.bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.bin, '[0-9]+$') AS UNSIGNED), r.bin"
);
$sth->execute([':company_id' => $this->company_id]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
diff --git a/app/assets/utils/classes/PurchaseOrderManager.php b/app/assets/utils/classes/PurchaseOrderManager.php
index 159d822..616b114 100644
--- a/app/assets/utils/classes/PurchaseOrderManager.php
+++ b/app/assets/utils/classes/PurchaseOrderManager.php
@@ -1,5 +1,6 @@
execute([':company_id' => $this->company_id]);
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
@@ -325,7 +326,12 @@ class PurchaseOrderManager {
public function savePo(array $data, array $logging): int
{
$id = (int)($data['id'] ?? 0);
- $items = $data['items'] ?? [];
+ $items = DocumentValidator::normaliseLines($data['items'] ?? [], 'Purchase order');
+ $data['items'] = $items;
+ DocumentValidator::requireId($data['contact_id'] ?? 0, 'Supplier is required.');
+ if ($id === 0 || array_key_exists('department_id', $data)) {
+ $data['department_id'] = DocumentValidator::requireDepartment($this->pdo, $this->company_id, $data['department_id'] ?? 0);
+ }
$skus = array_filter(array_column($items, 'product_sku'));
if (count($skus) !== count(array_unique($skus))) {
@@ -335,7 +341,7 @@ class PurchaseOrderManager {
$subtotal = array_reduce($items, fn($carry, $item) =>
$carry + (float)($item['total_price'] ?? 0), 0.0
);
- $discount = (float)($data['discount'] ?? 0);
+ $discount = DocumentValidator::discount($data['discount'] ?? 0, $subtotal);
$tax_adjustment = (float)($data['tax_adjustment'] ?? 0);
if (abs($tax_adjustment) > 0.30) {
throw new Exception("Tax adjustment cannot exceed ±0.30.");
@@ -343,7 +349,7 @@ class PurchaseOrderManager {
$tax = round(array_reduce($items, fn($carry, $item) =>
$carry + (float)($item['tax_amount'] ?? 0), 0.0
), 2) + $tax_adjustment;
- $shipping_fee = (float)($data['shipping_fee'] ?? 0);
+ $shipping_fee = DocumentValidator::amount($data['shipping_fee'] ?? 0, 'Shipping fee');
$grand_total = $subtotal - $discount + $tax + $shipping_fee;
if ($id > 0) {
diff --git a/app/assets/utils/classes/PurchaseRequestManager.php b/app/assets/utils/classes/PurchaseRequestManager.php
index 13a0a30..da0be2d 100644
--- a/app/assets/utils/classes/PurchaseRequestManager.php
+++ b/app/assets/utils/classes/PurchaseRequestManager.php
@@ -1,5 +1,6 @@
pdo, $this->company_id, $data['department_id'] ?? 0);
+ }
if (empty($items)) throw new Exception('At least one item is required.');
diff --git a/app/assets/utils/classes/QuotationManager.php b/app/assets/utils/classes/QuotationManager.php
index 58e7dd6..f3138bf 100644
--- a/app/assets/utils/classes/QuotationManager.php
+++ b/app/assets/utils/classes/QuotationManager.php
@@ -1,5 +1,6 @@
[sku => ['in' => float, 'out' => float]]]
+ */
+ private function transferTotals(): array
+ {
+ if ($this->transfer_totals !== null) return $this->transfer_totals;
+
+ $totals = [];
+ $sth = $this->pdo->prepare("SELECT id FROM md_warehouse WHERE company_id = :company_id");
+ $sth->execute([':company_id' => $this->company_id]);
+ foreach ($sth->fetchAll(PDO::FETCH_COLUMN) as $wh_id) {
+ $table = $this->stockTableNameFromWarehouseId((int)$wh_id);
+ try {
+ $rows = $this->fetchAll(
+ "SELECT DATE_FORMAT(`date`, '%Y-%m') AS month, product_sku,
+ SUM(`in`) AS qty_in, SUM(`out`) AS qty_out
+ FROM `{$table}`
+ WHERE company_id = :company_id AND status = 1 AND type = 'transfer'
+ GROUP BY month, product_sku"
+ );
+ } catch (PDOException $e) {
+ continue; // warehouse without a stock table yet
+ }
+ foreach ($rows as $r) {
+ $slot = &$totals[$r['month']][$r['product_sku']];
+ $slot['in'] = ($slot['in'] ?? 0) + (float)$r['qty_in'];
+ $slot['out'] = ($slot['out'] ?? 0) + (float)$r['qty_out'];
+ unset($slot);
+ }
+ }
+ return $this->transfer_totals = $totals;
+ }
+
+ /** Transfer in/out summed over the given month (null = all months). */
+ private function transferSum(?string $month = null, ?string $sku = null): array
+ {
+ $in = 0.0; $out = 0.0;
+ foreach ($this->transferTotals() as $m => $by_sku) {
+ if ($month !== null && $m !== $month) continue;
+ foreach ($by_sku as $k => $t) {
+ if ($sku !== null && (string)$k !== $sku) continue;
+ $in += $t['in']; $out += $t['out'];
+ }
+ }
+ return ['in' => $in, 'out' => $out];
+ }
+
private function resolveWarehouseTable(int $warehouse_id): ?string
{
$sth = $this->pdo->prepare(
@@ -297,15 +354,7 @@ class ReportManager
*/
public function getLowStockCount(): int
{
- $products = $this->getStockBalance();
- $count = 0;
- foreach ($products as $product) {
- $balance = (float) $product["total_in"] - (float) $product["total_out"];
- if ($balance < (float) $product["min_stock"]) {
- $count++;
- }
- }
- return $count;
+ return count($this->getLowStockItems());
}
public function getDashboardStockTotals(): array
@@ -318,13 +367,20 @@ class ReportManager
WHERE company_id = :company_id"
);
$sth->execute([':company_id' => $this->company_id]);
- return $sth->fetch(PDO::FETCH_ASSOC) ?: ['total_in' => 0, 'total_out' => 0];
+ $row = $sth->fetch(PDO::FETCH_ASSOC) ?: ['total_in' => 0, 'total_out' => 0];
+ $transfers = $this->transferSum();
+ return [
+ 'total_in' => round(max(0, (float)$row['total_in'] - $transfers['in']), 2),
+ 'total_out' => round(max(0, (float)$row['total_out'] - $transfers['out']), 2),
+ ];
}
public function getDashboardOrderStats(): array
{
$sth = $this->pdo->prepare(
- "SELECT COUNT(*), COALESCE(SUM(subtotal), 0)
+ // Orders are counted unless cancelled; revenue only once confirmed —
+ // a draft or pending order is not a sale yet.
+ "SELECT COUNT(*), COALESCE(SUM(CASE WHEN status >= 1 THEN subtotal ELSE 0 END), 0)
FROM td_order
WHERE company_id = :company_id
AND status != -1"
@@ -400,6 +456,13 @@ class ReportManager
*
* @return array Low/critical stock items with warehouse_name, product_name, balance, status.
*/
+ /*
+ * The one definition of "low stock", shared by the dashboard tile, the Low
+ * Stock page, the warehouse overview tile and the daily alert: an active
+ * product in an active warehouse whose balance there is at or below its
+ * reorder point (or minimum stock, whichever is higher). Each screen used
+ * to apply its own threshold and grouping, so the counts never matched.
+ */
public function getLowStockItems(): array
{
$sql = "SELECT
@@ -420,11 +483,13 @@ class ReportManager
ON wb.company_id = mw.company_id
AND wb.warehouse_id = mw.id
WHERE wb.company_id = :company_id
- AND mp.reorder_point > 0
+ AND mp.status > 0
+ AND mw.status = 1
+ AND GREATEST(mp.reorder_point, mp.min_stock) > 0
GROUP BY
wb.warehouse_id, wb.product_sku, mw.warehouse_name,
mp.product_name, mp.min_stock, mp.reorder_point, mp.product_image, mp.cost_price
- HAVING balance <= mp.reorder_point
+ HAVING balance <= GREATEST(mp.reorder_point, mp.min_stock)
ORDER BY mp.product_name ASC, mw.warehouse_name ASC";
$rows = $this->fetchAll($sql);
@@ -498,9 +563,13 @@ class ReportManager
AND month = :month"
);
$sth->execute([':company_id' => $this->company_id, ':month' => $month]);
- return $sth->fetch(PDO::FETCH_ASSOC) ?: [
+ $row = $sth->fetch(PDO::FETCH_ASSOC) ?: [
'total_in' => 0, 'total_out' => 0, 'active_products' => 0
];
+ $transfers = $this->transferSum($month);
+ $row['total_in'] = round(max(0, (float)$row['total_in'] - $transfers['in']), 2);
+ $row['total_out'] = round(max(0, (float)$row['total_out'] - $transfers['out']), 2);
+ return $row;
}
/**
@@ -568,6 +637,9 @@ class ReportManager
$dataByMonth = [];
foreach ($rows as $row) {
+ $transfers = $this->transferSum($row['month']);
+ $row['stock_in'] = round(max(0, (float)$row['stock_in'] - $transfers['in']), 2);
+ $row['stock_out'] = round(max(0, (float)$row['stock_out'] - $transfers['out']), 2);
$dataByMonth[$row['month']] = $row;
}
@@ -611,15 +683,22 @@ class ReportManager
AND pc.id = p.category
WHERE wb.company_id = :company_id
AND wb.month = :month
- GROUP BY wb.product_sku, p.product_name, pc.category
- ORDER BY total_out DESC
- LIMIT {$limit}"
+ GROUP BY wb.product_sku, p.product_name, pc.category"
);
$sth->execute([
':company_id' => $this->company_id,
':month' => $month,
]);
- return $sth->fetchAll(PDO::FETCH_ASSOC);
+ $rows = $sth->fetchAll(PDO::FETCH_ASSOC);
+ foreach ($rows as &$row) {
+ $transfers = $this->transferSum($month, (string)$row['product_sku']);
+ $row['total_in'] = round(max(0, (float)$row['total_in'] - $transfers['in']), 2);
+ $row['total_out'] = round(max(0, (float)$row['total_out'] - $transfers['out']), 2);
+ }
+ unset($row);
+ $rows = array_values(array_filter($rows, fn($r) => $r['total_in'] > 0 || $r['total_out'] > 0));
+ usort($rows, fn($a, $b) => $b['total_out'] <=> $a['total_out'] ?: $b['total_in'] <=> $a['total_in']);
+ return array_slice($rows, 0, $limit);
}
/**
@@ -668,8 +747,8 @@ class ReportManager
$cid = (int) $this->company_id;
$warehouse_name = $this->pdo->quote($wh['warehouse_name']);
return "SELECT s.date, s.product_sku, s.type,
- ROUND(COALESCE(s.`in`, 0), 2) AS stock_in,
- ROUND(COALESCE(s.`out`, 0), 2) AS stock_out,
+ COALESCE(s.`in`, 0) AS stock_in,
+ COALESCE(s.`out`, 0) AS stock_out,
p.product_name,
{$warehouse_name} AS warehouse_name
FROM `{$table}` s
@@ -677,7 +756,8 @@ class ReportManager
ON p.company_id = s.company_id
AND p.sku = s.product_sku
WHERE s.company_id = {$cid}
- AND s.status = 1";
+ AND s.status = 1
+ AND (s.`in` > 0 OR s.`out` > 0)";
},
$warehouses
));
@@ -691,6 +771,8 @@ class ReportManager
$items = [];
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
+ // Decided on the unrounded quantity: a receipt of 0.004 used to round
+ // to 0.00, fall through to "out" and show as -0.
$is_in = (float)$row['stock_in'] > 0;
$items[] = [
'product_name' => $row['product_name'] ?: $row['product_sku'],
@@ -771,25 +853,10 @@ class ReportManager
*/
public function getWarehouseLowStockCount(int $warehouse_id): int
{
- $sth = $this->pdo->prepare(
- "SELECT wb.product_sku,
- ROUND(SUM(wb.total_in) - SUM(wb.total_out), 2) AS balance,
- mp.min_stock
- FROM etl_stock_summary wb
- INNER JOIN md_product mp
- ON mp.company_id = wb.company_id
- AND mp.sku = wb.product_sku
- WHERE wb.company_id = :company_id
- AND wb.warehouse_id = :warehouse_id
- GROUP BY wb.product_sku, mp.min_stock"
- );
- $sth->execute([':company_id' => $this->company_id, ':warehouse_id' => $warehouse_id]);
- $rows = $sth->fetchAll(PDO::FETCH_ASSOC);
- $count = 0;
- foreach ($rows as $row) {
- if ((float)$row['balance'] < (float)$row['min_stock']) $count++;
- }
- return $count;
+ return count(array_filter(
+ $this->getLowStockItems(),
+ fn($item) => $item['warehouse_id'] === $warehouse_id
+ ));
}
/**
@@ -1181,16 +1248,19 @@ class ReportManager
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
$sth = $this->pdo->query(
- "SELECT lot_number,
- ROUND(SUM(COALESCE(`in`, 0)) - SUM(COALESCE(`out`, 0)), 2) AS lot_balance
+ // Every row of the lot makes it listable; only approved rows
+ // count towards the balance. A lot received but not yet
+ // approved used to vanish here while the lot master showed it.
+ // Keyed by SKU as well: two products may share a lot number.
+ "SELECT product_sku, lot_number,
+ ROUND(SUM(CASE WHEN status = 1 THEN COALESCE(`in`, 0) - COALESCE(`out`, 0) ELSE 0 END), 4) AS lot_balance
FROM `{$table}`
WHERE company_id = {$cid}
- AND status = 1
- AND lot_number IS NOT NULL
- GROUP BY lot_number"
+ AND lot_number <> ''
+ GROUP BY product_sku, lot_number"
);
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $lb) {
- $key = $lb['lot_number'];
+ $key = $lb['product_sku'] . "\0" . $lb['lot_number'];
$lot_balance[$key] = ($lot_balance[$key] ?? 0) + (float)$lb['lot_balance'];
}
}
@@ -1217,16 +1287,22 @@ class ReportManager
$active = $expired = $near = 0;
// Exclude lots that have no td_stock record at all (e.g. all rows were soft-deleted)
- $rows = array_values(array_filter($rows, fn($r) => array_key_exists($r['lot_number'], $lot_balance)));
+ $lot_key = fn($r) => $r['product_sku'] . "\0" . $r['lot_number'];
+ $rows = array_values(array_filter($rows, fn($r) => array_key_exists($lot_key($r), $lot_balance)));
foreach ($rows as &$row) {
$days = (int)$row['days_remaining'];
- $balance = round($lot_balance[$row['lot_number']] ?? 0, 2);
+ $balance = round($lot_balance[$lot_key($row)] ?? 0, 4);
$row['balance'] = $balance;
$row['is_active'] = $balance > 0 ? 1 : 0;
if ($balance > 0) $active++;
+ if ($row['expiry_date'] === null || $row['expiry_date'] === '') {
+ // No expiry recorded: not "expiring today"
+ $row['status'] = 'ok';
+ continue;
+ }
if ($days < 0) $expired++;
if ($days >= 0 && $days <= 30) $near++;
@@ -1324,9 +1400,9 @@ class ReportManager
ON p.company_id = r.company_id
AND p.sku = r.product_sku
WHERE r.company_id = :company_id
- ORDER BY mw.warehouse_name, r.zone,
- CAST(r.aisle AS UNSIGNED), r.aisle,
- CAST(r.bin AS UNSIGNED), r.bin"
+ ORDER BY mw.warehouse_name, REGEXP_REPLACE(r.zone, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.zone, '[0-9]+$') AS UNSIGNED), r.zone,
+ REGEXP_REPLACE(r.aisle, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.aisle, '[0-9]+$') AS UNSIGNED), r.aisle,
+ REGEXP_REPLACE(r.bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.bin, '[0-9]+$') AS UNSIGNED), r.bin"
);
$sth->execute([':company_id' => $this->company_id]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
diff --git a/app/assets/utils/classes/WarehouseManager.php b/app/assets/utils/classes/WarehouseManager.php
index b775199..b074ca6 100644
--- a/app/assets/utils/classes/WarehouseManager.php
+++ b/app/assets/utils/classes/WarehouseManager.php
@@ -1035,7 +1035,7 @@ class WarehouseManager {
$sth = $this->pdo->prepare(
"SELECT DISTINCT zone FROM md_bin
WHERE company_id = :company_id AND warehouse = :warehouse
- ORDER BY CAST(zone AS UNSIGNED), zone"
+ ORDER BY REGEXP_REPLACE(zone, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(zone, '[0-9]+$') AS UNSIGNED), zone"
);
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
@@ -1055,7 +1055,7 @@ class WarehouseManager {
$sth = $this->pdo->prepare(
"SELECT DISTINCT aisle FROM md_bin
WHERE company_id = :company_id AND warehouse = :warehouse AND zone = :zone
- ORDER BY CAST(aisle AS UNSIGNED), aisle"
+ ORDER BY REGEXP_REPLACE(aisle, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(aisle, '[0-9]+$') AS UNSIGNED), aisle"
);
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id, ':zone' => $zone]);
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle');
@@ -1077,7 +1077,7 @@ class WarehouseManager {
$sth = $this->pdo->prepare(
"SELECT DISTINCT bin FROM md_bin
WHERE company_id = :company_id AND warehouse = :warehouse
- ORDER BY CAST(bin AS UNSIGNED), bin"
+ ORDER BY REGEXP_REPLACE(bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(bin, '[0-9]+$') AS UNSIGNED), bin"
);
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]);
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'bin');
@@ -1086,7 +1086,7 @@ class WarehouseManager {
$sth = $this->pdo->prepare(
"SELECT DISTINCT bin FROM md_bin
WHERE company_id = :company_id AND warehouse = :warehouse AND zone = :zone AND aisle = :aisle
- ORDER BY CAST(bin AS UNSIGNED), bin"
+ ORDER BY REGEXP_REPLACE(bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(bin, '[0-9]+$') AS UNSIGNED), bin"
);
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id, ':zone' => $zone, ':aisle' => $aisle]);
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'bin');
@@ -1915,7 +1915,7 @@ class WarehouseManager {
WHERE company_id = :company_id
AND warehouse = :warehouse
AND product_sku IS NULL
- ORDER BY CAST(zone AS UNSIGNED), zone"
+ ORDER BY REGEXP_REPLACE(zone, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(zone, '[0-9]+$') AS UNSIGNED), zone"
);
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
@@ -1961,7 +1961,7 @@ class WarehouseManager {
{$lot_cond}
{$serial_cond}
)
- ORDER BY CAST(r.zone AS UNSIGNED), r.zone"
+ ORDER BY REGEXP_REPLACE(r.zone, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.zone, '[0-9]+$') AS UNSIGNED), r.zone"
);
$sth->execute($params);
return $sth->fetchAll(PDO::FETCH_ASSOC);
@@ -1982,7 +1982,7 @@ class WarehouseManager {
AND warehouse = :warehouse
AND zone = :zone
AND product_sku IS NULL
- ORDER BY CAST(aisle AS UNSIGNED), aisle"
+ ORDER BY REGEXP_REPLACE(aisle, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(aisle, '[0-9]+$') AS UNSIGNED), aisle"
);
$sth->execute([
':company_id' => $this->company_id,
@@ -2033,7 +2033,7 @@ class WarehouseManager {
{$lot_cond}
{$serial_cond}
)
- ORDER BY CAST(r.aisle AS UNSIGNED), r.aisle"
+ ORDER BY REGEXP_REPLACE(r.aisle, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.aisle, '[0-9]+$') AS UNSIGNED), r.aisle"
);
$sth->execute($params);
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle');
@@ -2055,7 +2055,7 @@ class WarehouseManager {
WHERE company_id = :company_id
AND warehouse = :warehouse
AND product_sku IS NULL
- ORDER BY CAST(bin AS UNSIGNED), bin"
+ ORDER BY REGEXP_REPLACE(bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(bin, '[0-9]+$') AS UNSIGNED), bin"
);
$sth->execute([
':company_id' => $this->company_id,
@@ -2071,7 +2071,7 @@ class WarehouseManager {
AND zone = :zone
AND aisle = :aisle
AND product_sku IS NULL
- ORDER BY CAST(bin AS UNSIGNED), bin"
+ ORDER BY REGEXP_REPLACE(bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(bin, '[0-9]+$') AS UNSIGNED), bin"
);
$sth->execute([
':company_id' => $this->company_id,
@@ -2130,7 +2130,7 @@ class WarehouseManager {
{$lot_cond}
{$serial_cond}
)
- ORDER BY CAST(r.bin AS UNSIGNED), r.bin"
+ ORDER BY REGEXP_REPLACE(r.bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.bin, '[0-9]+$') AS UNSIGNED), r.bin"
);
$sth->execute($params);
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'bin');
diff --git a/app/assets/utils/db_auth.php b/app/assets/utils/db_auth.php
index 7c16490..ec8e17e 100644
--- a/app/assets/utils/db_auth.php
+++ b/app/assets/utils/db_auth.php
@@ -141,6 +141,12 @@ $answer = array("success"=>0, "message"=>"");
if (isset($_POST['json'])) {
// Old Method: Data is wrapped in a JSON string
$data = json_decode($_POST['json'], true);
+if (!is_array($data)) {
+ // An undecodable payload used to carry on as an empty request.
+ http_response_code(400);
+ $answer["message"] = "The request could not be read. Please reload the page and try again.";
+ exit(json_encode($answer));
+}
} else if (isset($_POST['otp'])) {
// New Method: Data is sent directly (FormData)
// We check for 'otp' because every request should have one
diff --git a/app/cron/alert_low_stock.php b/app/cron/alert_low_stock.php
index 7fb099d..7b05685 100644
--- a/app/cron/alert_low_stock.php
+++ b/app/cron/alert_low_stock.php
@@ -1,28 +1,33 @@
query(
"SELECT e.company_id, COUNT(*) AS count
FROM (
- SELECT company_id, product_sku, SUM(total_in - total_out) AS balance
+ SELECT company_id, warehouse_id, product_sku, SUM(total_in - total_out) AS balance
FROM etl_stock_summary
WHERE company_id > 0
- GROUP BY company_id, product_sku
+ GROUP BY company_id, warehouse_id, product_sku
) e
JOIN md_product p
ON p.sku = e.product_sku
- AND p.company_id = e.company_id
- AND p.status = 1
- WHERE p.min_stock > 0
- AND e.balance < p.min_stock
+ AND p.company_id = e.company_id
+ AND p.status > 0
+ JOIN md_warehouse w
+ ON w.id = e.warehouse_id
+ AND w.company_id = e.company_id
+ AND w.status = 1
+ WHERE GREATEST(p.reorder_point, p.min_stock) > 0
+ AND e.balance <= GREATEST(p.reorder_point, p.min_stock)
GROUP BY e.company_id
HAVING count > 0"
-
);
$answer['success'] = 1;
diff --git a/app/dashboard/api/engine_report/low_stock.php b/app/dashboard/api/engine_report/low_stock.php
index 2026e19..6702e75 100644
--- a/app/dashboard/api/engine_report/low_stock.php
+++ b/app/dashboard/api/engine_report/low_stock.php
@@ -14,8 +14,14 @@
if ($item['status'] === 'critical') { $critical++; } else { $warning++; }
}
+ // Every active warehouse, so one with healthy stock still appears in the
+ // filter (and reads as "nothing low here") instead of looking left out.
+ $sth = $pdo2->prepare("SELECT id, warehouse_name FROM md_warehouse WHERE company_id = :cid AND status = 1 ORDER BY warehouse_name");
+ $sth->execute([':cid' => $company_id]);
+
$answer['output'] = [
'items' => $items,
+ 'warehouses' => $sth->fetchAll(PDO::FETCH_ASSOC),
'total_low' => count($items),
'total_critical' => $critical,
'total_warning' => $warning,
diff --git a/app/dashboard/index.php b/app/dashboard/index.php
index 3f4db9e..52b1567 100644
--- a/app/dashboard/index.php
+++ b/app/dashboard/index.php
@@ -430,8 +430,8 @@
${item.product_sku}
- -${Number(item.total_out).toLocaleString()}
- +${Number(item.total_in).toLocaleString()}
+ Out ${format_quantity(item.total_out)}
+ In ${format_quantity(item.total_in)}
`;
});
@@ -483,7 +483,7 @@
${item.warehouse_name}
- ${sign}${Number(item.qty).toLocaleString()}
+ ${sign}${format_quantity(item.qty)}
${time_ago(item.date)}
`;
@@ -569,7 +569,7 @@
moved_html += `
${item.product_name || item.product_sku}
${item.product_sku}
- -${Number(item.total_out).toLocaleString()} +${Number(item.total_in).toLocaleString()}
+ Out ${format_quantity(item.total_out)} In ${format_quantity(item.total_in)}
`;
});
}
@@ -608,7 +608,7 @@
act_html += `
${item.product_name}
${item.warehouse_name}
- ${sign}${Number(item.qty).toLocaleString()} ${time_ago(item.date)}
+ ${sign}${format_quantity(item.qty)} ${time_ago(item.date)}
`;
});
}
diff --git a/app/dashboard/low_stock_products.php b/app/dashboard/low_stock_products.php
index bb71a24..b7b580b 100644
--- a/app/dashboard/low_stock_products.php
+++ b/app/dashboard/low_stock_products.php
@@ -1,316 +1,312 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Low Stock Products
-
Products whose current balance has fallen below minimum stock level
-
-
-
-
-
-
-
-
-
-
-
- Total Low Stock
-
-
-
- —
- Products need attention
-
-
-
-
-
-
-
-
-
-
- Critical Level
-
-
-
- —
- Immediate restock needed
-
-
-
-
-
-
-
-
-
-
- Warning Level
-
-
-
- —
- Restock soon
-
-
-
-
-
-
-
-
-
-
- Filter by Status
-
- All
- Critical
- Warning
-
-
-
- Filter by Warehouse
-
- All Warehouses
-
-
-
- Search Product
-
-
-
-
-
-
-
-
-
-
-
Low Stock Items
-
-
-
-
-
-
- Product
- SKU
- Warehouse
- Balance
- Min Stock
- Reorder Point
- Status
- Action
-
-
-
-
-
-
Loading…
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ ${item.min_stock}
+ ${item.reorder_point}
+ ${badge}
+
+
+ Restock
+
+
+ `;
+ });
+
+ $('table#low_stock > tbody').html(body);
+ }
+
+
+ // ── Filter listeners ─────────────────────────────────────────────────
+ function debounce(fn, ms) {
+ var t;
+ return function() {
+ var args = arguments,
+ ctx = this;
+ clearTimeout(t);
+ t = setTimeout(function() {
+ fn.apply(ctx, args);
+ }, ms);
+ };
+ }
+
+ $('#filter_status, #filter_warehouse').on('change', function() {
+ change_page_low_stock(1); // reset to page 1 on filter change
+ });
+
+ $('#filter_search').on('input', debounce(function() {
+ change_page_low_stock(1);
+ }, 200));
+
+
+ // ── Boot ─────────────────────────────────────────────────────────────
+ $(async function() {
+ try {
+ await retrieve_low_stock();
+ } catch (e) {
+ console.error(e);
+ $('table#low_stock > tbody').html(
+ 'Failed to load data. '
+ );
+ }
+ });
+
+
+