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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user