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
@@ -0,0 +1,131 @@
<?php
/**
* Server-side rules shared by the documents that carry priced lines: sales
* orders, purchase orders, quotations and purchase requests.
*
* The pages enforce the same limits, but only in JavaScript, so a request sent
* straight to the engine could store a 150% tax rate, a negative price or a
* line total that does not match quantity × price. Everything here throws a
* plain Exception, which the engines already report back as the alert text.
*/
class DocumentValidator
{
const MAX_TAX_RATE = 100;
// Far above any real unit price, low enough to stop a slipped keystroke
// (or a crafted request) from booking billions.
const MAX_UNIT_PRICE = 999999999.99;
const MAX_QUANTITY = 999999999.9999;
const MIN_QUANTITY = 0.0001;
/**
* Validate the lines and return them with total_price and tax_amount
* recomputed, using the same formula as the pages:
* total = quantity × unit_price, tax = total × tax_rate / 100 (4 dp).
*/
public static function normaliseLines(array $items, string $doc_label = 'Document'): array
{
$out = [];
foreach (array_values($items) as $i => $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;
}
}