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
+40 -39
View File
@@ -31,6 +31,40 @@ class db_statement extends PDOStatement {
$this->pdo = $pdo;
}
// double_encode is off so text that is loaded and saved again is not escaped
// a second time (" becoming "), and ENT_SUBSTITUTE keeps a value
// with a broken byte sequence instead of silently storing an empty string.
const ESCAPE_FLAGS = ENT_QUOTES | ENT_SUBSTITUTE;
private static function escapeString(string $value): string {
return htmlspecialchars($value, self::ESCAPE_FLAGS, 'UTF-8', false);
}
private static function escapeTree($node) {
if (is_string($node)) return self::escapeString($node);
if (!is_array($node)) return $node;
$out = [];
foreach ($node as $k => $v) {
$out[is_string($k) ? self::escapeString($k) : $k] = self::escapeTree($v);
}
return $out;
}
public static function escapeValue(string $item): string {
$first = $item[0] ?? '';
if ($first === '{' || $first === '[') {
$tree = json_decode($item, true);
if (is_array($tree)) {
$flags = JSON_PRESERVE_ZERO_FRACTION;
// An empty {} must not come back as [].
if ($tree === [] ) return $item;
$encoded = json_encode(self::escapeTree($tree), $flags);
if ($encoded !== false) return $encoded;
}
}
return self::escapeString($item);
}
// PDOStatement::execute() is declared ?array $params = null : bool. This
// override deliberately accepts a looser signature so callers may pass
// positional arguments (see func_get_args() below), so the tightened return
@@ -49,46 +83,13 @@ class db_statement extends PDOStatement {
// null is preserved as-is so PDO can bind NULL columns correctly.
$args = array_map(fn($v) => is_null($v) ? null : (string)$v, $args);
// escaping array
// prevent store XSS
// Escape on the way in, to prevent stored XSS. Values holding a JSON
// object/array are escaped string by string so they stay valid JSON.
foreach($args as &$item){
if (is_null($item)) continue;
// decode the JSON data
// set second parameter boolean TRUE for associative array output.
$result = json_decode($item);
if (json_last_error() === JSON_ERROR_NONE) {
// encode html for json
$tmp = json_decode($item,true);
foreach((array)$tmp as &$ii){
// Inner values may be arrays (nested JSON objects) — cast to string
if (!is_string($ii)) {
$ii = json_encode($ii);
continue;
}
$result = json_decode($ii);
if (json_last_error() === JSON_ERROR_NONE) {
// json inside json
$tmpp = json_decode($ii,true);
foreach ((array)$tmpp as &$iii) {
$iii = htmlspecialchars($ii, ENT_QUOTES, 'UTF-8');
}
$ii = json_encode($tmpp);
}else{
// string inside json
$ii = htmlspecialchars($ii, ENT_QUOTES, 'UTF-8');
}
}
$item = json_encode($tmp);
}else{
// encode html for string
$item = htmlspecialchars($item, ENT_QUOTES, 'UTF-8');
}
$item = self::escapeValue($item);
}
unset($item);
}
return parent::execute($args);
}
@@ -96,11 +97,11 @@ class db_statement extends PDOStatement {
}
//..................... PDO1 .....................//
$pdo1 = new database($db_type.':host='.$db_server.';dbname='.$db_database.';charset=utf8', $db_user, $db_pass);
$pdo1 = new database($db_type.':host='.$db_server.';dbname='.$db_database.';charset=utf8mb4', $db_user, $db_pass);
$pdo1->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
//..................... PDO2 .....................//
$pdo2 = new database($db_type2.':host='.$db_server2.';dbname='.$db_database2.';charset=utf8', $db_user2, $db_pass2);
$pdo2 = new database($db_type2.':host='.$db_server2.';dbname='.$db_database2.';charset=utf8mb4', $db_user2, $db_pass2);
$pdo2->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Pin both connections to the application timezone, so MySQL NOW() and PHP