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:
@@ -4,6 +4,36 @@ function escape_html(value) {
|
||||
});
|
||||
}
|
||||
|
||||
// Text is stored HTML-escaped (see db_statement in dbconn.php), which is right
|
||||
// for anything written into markup but wrong inside a form field: a note saved
|
||||
// as 5" pipe <spare> came back as 5" pipe <spare>. Field values
|
||||
// are never parsed as HTML, so decoding them here is safe.
|
||||
function decode_html(value) {
|
||||
if (typeof value !== 'string' || value.indexOf('&') === -1) return value;
|
||||
return value.replace(/&(quot|#0*39|#x0*27|apos|lt|gt|amp);/gi, function (m, name) {
|
||||
name = name.toLowerCase();
|
||||
if (name === 'quot') return '"';
|
||||
if (name === 'lt') return '<';
|
||||
if (name === 'gt') return '>';
|
||||
if (name === 'amp') return '&';
|
||||
return "'";
|
||||
});
|
||||
}
|
||||
|
||||
(function ($) {
|
||||
if (!$ || !$.fn || $.fn.val.__decodes_html) return;
|
||||
var original_val = $.fn.val;
|
||||
$.fn.val = function (value) {
|
||||
if (arguments.length && typeof value === 'string') {
|
||||
// Only free-text fields; a <select> value must keep matching its option.
|
||||
var text_fields = this.filter('input, textarea');
|
||||
if (text_fields.length === this.length) return original_val.call(this, decode_html(value));
|
||||
}
|
||||
return original_val.apply(this, arguments);
|
||||
};
|
||||
$.fn.val.__decodes_html = true;
|
||||
})(window.jQuery);
|
||||
|
||||
/** =========================
|
||||
* SIDEBAR ACTIVE STATE
|
||||
* Override: activate the parent listing page for manage_* sub-pages.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||
require_once __DIR__ . '/DocumentValidator.php';
|
||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||
|
||||
/**
|
||||
@@ -269,7 +270,7 @@ class OrderManager {
|
||||
ON c.company_id = o.company_id
|
||||
AND c.id = o.contact_id
|
||||
WHERE o.company_id = :company_id
|
||||
ORDER BY o.created_at DESC"
|
||||
ORDER BY o.order_date DESC, o.id DESC"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
@@ -485,13 +486,18 @@ class OrderManager {
|
||||
public function saveOrder(array $data, array $logging): int
|
||||
{
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
$items = $data['items'] ?? [];
|
||||
$items = DocumentValidator::normaliseLines($data['items'] ?? [], 'Order');
|
||||
$data['items'] = $items;
|
||||
DocumentValidator::requireId($data['contact_id'] ?? 0, 'Contact is required.');
|
||||
if ($id === 0 || array_key_exists('department_id', $data)) {
|
||||
$data['department_id'] = DocumentValidator::requireDepartment($this->pdo, $this->company_id, $data['department_id'] ?? 0);
|
||||
}
|
||||
|
||||
// Calculate totals from items
|
||||
$subtotal = array_reduce($items, fn($carry, $item) =>
|
||||
$carry + (float)($item['total_price'] ?? 0), 0.0
|
||||
);
|
||||
$discount = (float)($data['discount'] ?? 0);
|
||||
$discount = DocumentValidator::discount($data['discount'] ?? 0, $subtotal);
|
||||
$tax_adjustment = (float)($data['tax_adjustment'] ?? 0);
|
||||
if (abs($tax_adjustment) > 0.30) {
|
||||
throw new Exception("Tax adjustment cannot exceed ±0.30.");
|
||||
@@ -499,7 +505,7 @@ class OrderManager {
|
||||
$tax = round(array_reduce($items, fn($carry, $item) =>
|
||||
$carry + (float)($item['tax_amount'] ?? 0), 0.0
|
||||
), 2) + $tax_adjustment;
|
||||
$shipping_fee = (float)($data['shipping_fee'] ?? 0);
|
||||
$shipping_fee = DocumentValidator::amount($data['shipping_fee'] ?? 0, 'Shipping fee');
|
||||
$tracking_no = trim((string)($data['shipping_tracking_number'] ?? ''));
|
||||
$grand_total = $subtotal - $discount + $tax + $shipping_fee;
|
||||
|
||||
|
||||
@@ -569,9 +569,9 @@ class ProductManager {
|
||||
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);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||
require_once __DIR__ . '/DocumentValidator.php';
|
||||
require_once __DIR__ . '/WarehouseManager.php';
|
||||
require_once __DIR__ . '/StockManager.php';
|
||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||
@@ -204,7 +205,7 @@ class PurchaseOrderManager {
|
||||
ON c.company_id = p.company_id
|
||||
AND c.id = p.contact_id
|
||||
WHERE p.company_id = :company_id
|
||||
ORDER BY p.created_at DESC"
|
||||
ORDER BY p.po_date DESC, p.id DESC"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
@@ -325,7 +326,12 @@ class PurchaseOrderManager {
|
||||
public function savePo(array $data, array $logging): int
|
||||
{
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
$items = $data['items'] ?? [];
|
||||
$items = DocumentValidator::normaliseLines($data['items'] ?? [], 'Purchase order');
|
||||
$data['items'] = $items;
|
||||
DocumentValidator::requireId($data['contact_id'] ?? 0, 'Supplier is required.');
|
||||
if ($id === 0 || array_key_exists('department_id', $data)) {
|
||||
$data['department_id'] = DocumentValidator::requireDepartment($this->pdo, $this->company_id, $data['department_id'] ?? 0);
|
||||
}
|
||||
|
||||
$skus = array_filter(array_column($items, 'product_sku'));
|
||||
if (count($skus) !== count(array_unique($skus))) {
|
||||
@@ -335,7 +341,7 @@ class PurchaseOrderManager {
|
||||
$subtotal = array_reduce($items, fn($carry, $item) =>
|
||||
$carry + (float)($item['total_price'] ?? 0), 0.0
|
||||
);
|
||||
$discount = (float)($data['discount'] ?? 0);
|
||||
$discount = DocumentValidator::discount($data['discount'] ?? 0, $subtotal);
|
||||
$tax_adjustment = (float)($data['tax_adjustment'] ?? 0);
|
||||
if (abs($tax_adjustment) > 0.30) {
|
||||
throw new Exception("Tax adjustment cannot exceed ±0.30.");
|
||||
@@ -343,7 +349,7 @@ class PurchaseOrderManager {
|
||||
$tax = round(array_reduce($items, fn($carry, $item) =>
|
||||
$carry + (float)($item['tax_amount'] ?? 0), 0.0
|
||||
), 2) + $tax_adjustment;
|
||||
$shipping_fee = (float)($data['shipping_fee'] ?? 0);
|
||||
$shipping_fee = DocumentValidator::amount($data['shipping_fee'] ?? 0, 'Shipping fee');
|
||||
$grand_total = $subtotal - $discount + $tax + $shipping_fee;
|
||||
|
||||
if ($id > 0) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||
require_once __DIR__ . '/DocumentValidator.php';
|
||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||
|
||||
/**
|
||||
@@ -163,9 +164,13 @@ class PurchaseRequestManager
|
||||
public function save(array $data, array $logging): int
|
||||
{
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
$items = $data['items'] ?? [];
|
||||
$discount = (float)($data['discount'] ?? 0);
|
||||
$shipping_fee = (float)($data['shipping_fee'] ?? 0);
|
||||
$items = DocumentValidator::normaliseLines($data['items'] ?? [], 'Purchase request');
|
||||
$data['items'] = $items;
|
||||
$discount = DocumentValidator::discount($data['discount'] ?? 0, array_sum(array_column($items, 'total_price')));
|
||||
$shipping_fee = DocumentValidator::amount($data['shipping_fee'] ?? 0, 'Shipping fee');
|
||||
if ($id === 0 || array_key_exists('department_id', $data)) {
|
||||
$data['department_id'] = DocumentValidator::requireDepartment($this->pdo, $this->company_id, $data['department_id'] ?? 0);
|
||||
}
|
||||
|
||||
if (empty($items)) throw new Exception('At least one item is required.');
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||
require_once __DIR__ . '/DocumentValidator.php';
|
||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||
|
||||
/**
|
||||
@@ -173,8 +174,9 @@ class QuotationManager
|
||||
public function save(array $data, array $logging): int
|
||||
{
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
$items = $data['items'] ?? [];
|
||||
$discount = (float)($data['discount'] ?? 0);
|
||||
$items = DocumentValidator::normaliseLines($data['items'] ?? [], 'Quotation');
|
||||
$data['items'] = $items;
|
||||
$discount = DocumentValidator::discount($data['discount'] ?? 0, array_sum(array_column($items, 'total_price')));
|
||||
|
||||
$quotation_date = (string)($data['quotation_date'] ?? '');
|
||||
$valid_until = (string)($data['valid_until'] ?? '');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1035,7 +1035,7 @@ class WarehouseManager {
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT DISTINCT zone FROM md_bin
|
||||
WHERE company_id = :company_id AND warehouse = :warehouse
|
||||
ORDER BY CAST(zone AS UNSIGNED), zone"
|
||||
ORDER BY REGEXP_REPLACE(zone, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(zone, '[0-9]+$') AS UNSIGNED), zone"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
@@ -1055,7 +1055,7 @@ class WarehouseManager {
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT DISTINCT aisle FROM md_bin
|
||||
WHERE company_id = :company_id AND warehouse = :warehouse AND zone = :zone
|
||||
ORDER BY CAST(aisle AS UNSIGNED), aisle"
|
||||
ORDER BY REGEXP_REPLACE(aisle, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(aisle, '[0-9]+$') AS UNSIGNED), aisle"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id, ':zone' => $zone]);
|
||||
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle');
|
||||
@@ -1077,7 +1077,7 @@ class WarehouseManager {
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT DISTINCT bin FROM md_bin
|
||||
WHERE company_id = :company_id AND warehouse = :warehouse
|
||||
ORDER BY CAST(bin AS UNSIGNED), bin"
|
||||
ORDER BY REGEXP_REPLACE(bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(bin, '[0-9]+$') AS UNSIGNED), bin"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]);
|
||||
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'bin');
|
||||
@@ -1086,7 +1086,7 @@ class WarehouseManager {
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT DISTINCT bin FROM md_bin
|
||||
WHERE company_id = :company_id AND warehouse = :warehouse AND zone = :zone AND aisle = :aisle
|
||||
ORDER BY CAST(bin AS UNSIGNED), bin"
|
||||
ORDER BY REGEXP_REPLACE(bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(bin, '[0-9]+$') AS UNSIGNED), bin"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id, ':zone' => $zone, ':aisle' => $aisle]);
|
||||
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'bin');
|
||||
@@ -1915,7 +1915,7 @@ class WarehouseManager {
|
||||
WHERE company_id = :company_id
|
||||
AND warehouse = :warehouse
|
||||
AND product_sku IS NULL
|
||||
ORDER BY CAST(zone AS UNSIGNED), zone"
|
||||
ORDER BY REGEXP_REPLACE(zone, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(zone, '[0-9]+$') AS UNSIGNED), zone"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
@@ -1961,7 +1961,7 @@ class WarehouseManager {
|
||||
{$lot_cond}
|
||||
{$serial_cond}
|
||||
)
|
||||
ORDER BY CAST(r.zone AS UNSIGNED), r.zone"
|
||||
ORDER BY REGEXP_REPLACE(r.zone, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.zone, '[0-9]+$') AS UNSIGNED), r.zone"
|
||||
);
|
||||
$sth->execute($params);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
@@ -1982,7 +1982,7 @@ class WarehouseManager {
|
||||
AND warehouse = :warehouse
|
||||
AND zone = :zone
|
||||
AND product_sku IS NULL
|
||||
ORDER BY CAST(aisle AS UNSIGNED), aisle"
|
||||
ORDER BY REGEXP_REPLACE(aisle, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(aisle, '[0-9]+$') AS UNSIGNED), aisle"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
@@ -2033,7 +2033,7 @@ class WarehouseManager {
|
||||
{$lot_cond}
|
||||
{$serial_cond}
|
||||
)
|
||||
ORDER BY CAST(r.aisle AS UNSIGNED), r.aisle"
|
||||
ORDER BY REGEXP_REPLACE(r.aisle, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.aisle, '[0-9]+$') AS UNSIGNED), r.aisle"
|
||||
);
|
||||
$sth->execute($params);
|
||||
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle');
|
||||
@@ -2055,7 +2055,7 @@ class WarehouseManager {
|
||||
WHERE company_id = :company_id
|
||||
AND warehouse = :warehouse
|
||||
AND product_sku IS NULL
|
||||
ORDER BY CAST(bin AS UNSIGNED), bin"
|
||||
ORDER BY REGEXP_REPLACE(bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(bin, '[0-9]+$') AS UNSIGNED), bin"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
@@ -2071,7 +2071,7 @@ class WarehouseManager {
|
||||
AND zone = :zone
|
||||
AND aisle = :aisle
|
||||
AND product_sku IS NULL
|
||||
ORDER BY CAST(bin AS UNSIGNED), bin"
|
||||
ORDER BY REGEXP_REPLACE(bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(bin, '[0-9]+$') AS UNSIGNED), bin"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
@@ -2130,7 +2130,7 @@ class WarehouseManager {
|
||||
{$lot_cond}
|
||||
{$serial_cond}
|
||||
)
|
||||
ORDER BY CAST(r.bin AS UNSIGNED), r.bin"
|
||||
ORDER BY REGEXP_REPLACE(r.bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.bin, '[0-9]+$') AS UNSIGNED), r.bin"
|
||||
);
|
||||
$sth->execute($params);
|
||||
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'bin');
|
||||
|
||||
@@ -141,6 +141,12 @@ $answer = array("success"=>0, "message"=>"");
|
||||
if (isset($_POST['json'])) {
|
||||
// Old Method: Data is wrapped in a JSON string
|
||||
$data = json_decode($_POST['json'], true);
|
||||
if (!is_array($data)) {
|
||||
// An undecodable payload used to carry on as an empty request.
|
||||
http_response_code(400);
|
||||
$answer["message"] = "The request could not be read. Please reload the page and try again.";
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
} else if (isset($_POST['otp'])) {
|
||||
// New Method: Data is sent directly (FormData)
|
||||
// We check for 'otp' because every request should have one
|
||||
|
||||
Reference in New Issue
Block a user