Fix QA review findings: server-side validation, notes encoding, dashboard totals

Validate document lines on the server and recompute their totals, store notes with quotes/markup/emoji (utf8mb4, idempotent escaping, decode in form fields), exclude transfers from company-wide stock in/out, count revenue from confirmed orders only, one low-stock rule everywhere, list unapproved lots, natural bin sort, stable order/PO sort, status tiles that add up.
This commit is contained in:
Thanakorn
2026-09-19 10:58:42 +07:00
parent de760d02da
commit c89b28da4c
32 changed files with 924 additions and 483 deletions
+127 -51
View File
@@ -35,6 +35,8 @@ class ReportManager
// Private helpers
// ─────────────────────────────────────────────────────────────
private ?array $transfer_totals = null;
private function stockTableNameFromWarehouseId(int $warehouse_id): string
{
if ($warehouse_id <= 0) {
@@ -45,6 +47,61 @@ class ReportManager
}
/**
* Approved warehouse-to-warehouse transfer quantities, per month and SKU.
*
* A transfer is stored as an `out` row in the source warehouse and an `in`
* row in the destination, and both reach etl_stock_summary, which is right
* for each warehouse's balance. Company-wide "Stock In / Stock Out" figures
* must leave them out: the goods were already counted when first received,
* and moving them between warehouses is neither a receipt nor an issue.
*
* @return array [month => [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);