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.
132 lines
5.1 KiB
PHP
132 lines
5.1 KiB
PHP
<?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;
|
||
}
|
||
}
|