Compare commits
2
Commits
de760d02da
...
5cc43cff92
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cc43cff92 | ||
|
|
c89b28da4c |
@@ -262,7 +262,7 @@
|
|||||||
'<td>' + escape_html(r.doc_number) + '</td>' +
|
'<td>' + escape_html(r.doc_number) + '</td>' +
|
||||||
'<td>' + escape_html(r.contact_name || '—') + '</td>' +
|
'<td>' + escape_html(r.contact_name || '—') + '</td>' +
|
||||||
'<td class="text-end">' + format_number(r.grand_total, 2) + '</td>' +
|
'<td class="text-end">' + format_number(r.grand_total, 2) + '</td>' +
|
||||||
'<td>' + escape_html(r.doc_date || '—') + '</td>' +
|
'<td>' + escape_html(format_date(r.doc_date)) + '</td>' +
|
||||||
'<td>' + mapping_badge + '</td>' +
|
'<td>' + mapping_badge + '</td>' +
|
||||||
'<td>' + status_badge + '</td>' +
|
'<td>' + status_badge + '</td>' +
|
||||||
'</tr>';
|
'</tr>';
|
||||||
|
|||||||
@@ -198,7 +198,7 @@
|
|||||||
export_data.push({ date:r.entry_date||'', period:r.period||'', department:r.dept_code||'', reference:r.reference||'', description:r.line_description||r.gl_description||'', debit:dr||'', credit:cr||'', balance:running });
|
export_data.push({ date:r.entry_date||'', period:r.period||'', department:r.dept_code||'', reference:r.reference||'', description:r.line_description||r.gl_description||'', debit:dr||'', credit:cr||'', balance:running });
|
||||||
var bal_color = running >= 0 ? '' : 'text-danger';
|
var bal_color = running >= 0 ? '' : 'text-danger';
|
||||||
html += '<tr>' +
|
html += '<tr>' +
|
||||||
'<td class="small">' + escape_html(r.entry_date || '—') + '</td>' +
|
'<td class="small">' + escape_html(format_date(r.entry_date)) + '</td>' +
|
||||||
'<td class="small">' + escape_html(r.period) + '</td>' +
|
'<td class="small">' + escape_html(r.period) + '</td>' +
|
||||||
'<td class="small">' + (r.dept_code ? escape_html(r.dept_code) : '<span class="text-muted">—</span>') + '</td>' +
|
'<td class="small">' + (r.dept_code ? escape_html(r.dept_code) : '<span class="text-muted">—</span>') + '</td>' +
|
||||||
'<td class="small">' + escape_html(r.reference || '—') + '</td>' +
|
'<td class="small">' + escape_html(r.reference || '—') + '</td>' +
|
||||||
|
|||||||
@@ -281,7 +281,7 @@
|
|||||||
'<td class="text-muted small">' + escape_html(r.formula_name || '—') + '</td>' +
|
'<td class="text-muted small">' + escape_html(r.formula_name || '—') + '</td>' +
|
||||||
'<td class="text-end">' + format_number(r.total_debit, 2) + '</td>' +
|
'<td class="text-end">' + format_number(r.total_debit, 2) + '</td>' +
|
||||||
'<td class="text-end">' + format_number(r.total_credit, 2) + '</td>' +
|
'<td class="text-end">' + format_number(r.total_credit, 2) + '</td>' +
|
||||||
'<td class="text-muted small">' + escape_html(r.posted_at) + '</td>' +
|
'<td class="text-muted small">' + escape_html(format_date(r.posted_at)) + '</td>' +
|
||||||
'<td>' +
|
'<td>' +
|
||||||
'<a href="javascript:;" onclick="show_journal_detail(' + r.id + ',\'' + escape_html(r.doc_number || '') + '\')" title="View lines">' +
|
'<a href="javascript:;" onclick="show_journal_detail(' + r.id + ',\'' + escape_html(r.doc_number || '') + '\')" title="View lines">' +
|
||||||
'<i class="ti ti-eye fs-5"></i></a>' +
|
'<i class="ti ti-eye fs-5"></i></a>' +
|
||||||
|
|||||||
@@ -210,7 +210,7 @@
|
|||||||
var html = '';
|
var html = '';
|
||||||
rows.forEach(function(r) {
|
rows.forEach(function(r) {
|
||||||
html += '<tr>' +
|
html += '<tr>' +
|
||||||
'<td class="small">' + escape_html(r.entry_date || '—') + '</td>' +
|
'<td class="small">' + escape_html(format_date(r.entry_date)) + '</td>' +
|
||||||
'<td class="small">' + escape_html(r.period) + '</td>' +
|
'<td class="small">' + escape_html(r.period) + '</td>' +
|
||||||
'<td class="small"><span class="badge bg-secondary bg-opacity-10 text-secondary">' + escape_html(src_labels[r.source_type] || r.source_type) + '</span></td>' +
|
'<td class="small"><span class="badge bg-secondary bg-opacity-10 text-secondary">' + escape_html(src_labels[r.source_type] || r.source_type) + '</span></td>' +
|
||||||
'<td class="small">' + (r.dept_code ? escape_html(r.dept_code) : '<span class="text-muted">—</span>') + '</td>' +
|
'<td class="small">' + (r.dept_code ? escape_html(r.dept_code) : '<span class="text-muted">—</span>') + '</td>' +
|
||||||
|
|||||||
@@ -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
|
* SIDEBAR ACTIVE STATE
|
||||||
* Override: activate the parent listing page for manage_* sub-pages.
|
* 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
|
<?php
|
||||||
require_once __DIR__ . '/DocumentNumberManager.php';
|
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||||
|
require_once __DIR__ . '/DocumentValidator.php';
|
||||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -269,7 +270,7 @@ class OrderManager {
|
|||||||
ON c.company_id = o.company_id
|
ON c.company_id = o.company_id
|
||||||
AND c.id = o.contact_id
|
AND c.id = o.contact_id
|
||||||
WHERE o.company_id = :company_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]);
|
$sth->execute([':company_id' => $this->company_id]);
|
||||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||||
@@ -485,13 +486,18 @@ class OrderManager {
|
|||||||
public function saveOrder(array $data, array $logging): int
|
public function saveOrder(array $data, array $logging): int
|
||||||
{
|
{
|
||||||
$id = (int)($data['id'] ?? 0);
|
$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
|
// Calculate totals from items
|
||||||
$subtotal = array_reduce($items, fn($carry, $item) =>
|
$subtotal = array_reduce($items, fn($carry, $item) =>
|
||||||
$carry + (float)($item['total_price'] ?? 0), 0.0
|
$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);
|
$tax_adjustment = (float)($data['tax_adjustment'] ?? 0);
|
||||||
if (abs($tax_adjustment) > 0.30) {
|
if (abs($tax_adjustment) > 0.30) {
|
||||||
throw new Exception("Tax adjustment cannot exceed ±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) =>
|
$tax = round(array_reduce($items, fn($carry, $item) =>
|
||||||
$carry + (float)($item['tax_amount'] ?? 0), 0.0
|
$carry + (float)($item['tax_amount'] ?? 0), 0.0
|
||||||
), 2) + $tax_adjustment;
|
), 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'] ?? ''));
|
$tracking_no = trim((string)($data['shipping_tracking_number'] ?? ''));
|
||||||
$grand_total = $subtotal - $discount + $tax + $shipping_fee;
|
$grand_total = $subtotal - $discount + $tax + $shipping_fee;
|
||||||
|
|
||||||
|
|||||||
@@ -569,9 +569,9 @@ class ProductManager {
|
|||||||
ON p.company_id = r.company_id
|
ON p.company_id = r.company_id
|
||||||
AND p.sku = r.product_sku
|
AND p.sku = r.product_sku
|
||||||
WHERE r.company_id = :company_id
|
WHERE r.company_id = :company_id
|
||||||
ORDER BY mw.warehouse_name, r.zone,
|
ORDER BY mw.warehouse_name, REGEXP_REPLACE(r.zone, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.zone, '[0-9]+$') AS UNSIGNED), r.zone,
|
||||||
CAST(r.aisle AS UNSIGNED), r.aisle,
|
REGEXP_REPLACE(r.aisle, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.aisle, '[0-9]+$') AS UNSIGNED), r.aisle,
|
||||||
CAST(r.bin AS UNSIGNED), r.bin"
|
REGEXP_REPLACE(r.bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.bin, '[0-9]+$') AS UNSIGNED), r.bin"
|
||||||
);
|
);
|
||||||
$sth->execute([':company_id' => $this->company_id]);
|
$sth->execute([':company_id' => $this->company_id]);
|
||||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once __DIR__ . '/DocumentNumberManager.php';
|
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||||
|
require_once __DIR__ . '/DocumentValidator.php';
|
||||||
require_once __DIR__ . '/WarehouseManager.php';
|
require_once __DIR__ . '/WarehouseManager.php';
|
||||||
require_once __DIR__ . '/StockManager.php';
|
require_once __DIR__ . '/StockManager.php';
|
||||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||||
@@ -204,7 +205,7 @@ class PurchaseOrderManager {
|
|||||||
ON c.company_id = p.company_id
|
ON c.company_id = p.company_id
|
||||||
AND c.id = p.contact_id
|
AND c.id = p.contact_id
|
||||||
WHERE p.company_id = :company_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]);
|
$sth->execute([':company_id' => $this->company_id]);
|
||||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||||
@@ -325,7 +326,12 @@ class PurchaseOrderManager {
|
|||||||
public function savePo(array $data, array $logging): int
|
public function savePo(array $data, array $logging): int
|
||||||
{
|
{
|
||||||
$id = (int)($data['id'] ?? 0);
|
$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'));
|
$skus = array_filter(array_column($items, 'product_sku'));
|
||||||
if (count($skus) !== count(array_unique($skus))) {
|
if (count($skus) !== count(array_unique($skus))) {
|
||||||
@@ -335,7 +341,7 @@ class PurchaseOrderManager {
|
|||||||
$subtotal = array_reduce($items, fn($carry, $item) =>
|
$subtotal = array_reduce($items, fn($carry, $item) =>
|
||||||
$carry + (float)($item['total_price'] ?? 0), 0.0
|
$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);
|
$tax_adjustment = (float)($data['tax_adjustment'] ?? 0);
|
||||||
if (abs($tax_adjustment) > 0.30) {
|
if (abs($tax_adjustment) > 0.30) {
|
||||||
throw new Exception("Tax adjustment cannot exceed ±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) =>
|
$tax = round(array_reduce($items, fn($carry, $item) =>
|
||||||
$carry + (float)($item['tax_amount'] ?? 0), 0.0
|
$carry + (float)($item['tax_amount'] ?? 0), 0.0
|
||||||
), 2) + $tax_adjustment;
|
), 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;
|
$grand_total = $subtotal - $discount + $tax + $shipping_fee;
|
||||||
|
|
||||||
if ($id > 0) {
|
if ($id > 0) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once __DIR__ . '/DocumentNumberManager.php';
|
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||||
|
require_once __DIR__ . '/DocumentValidator.php';
|
||||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -163,9 +164,13 @@ class PurchaseRequestManager
|
|||||||
public function save(array $data, array $logging): int
|
public function save(array $data, array $logging): int
|
||||||
{
|
{
|
||||||
$id = (int)($data['id'] ?? 0);
|
$id = (int)($data['id'] ?? 0);
|
||||||
$items = $data['items'] ?? [];
|
$items = DocumentValidator::normaliseLines($data['items'] ?? [], 'Purchase request');
|
||||||
$discount = (float)($data['discount'] ?? 0);
|
$data['items'] = $items;
|
||||||
$shipping_fee = (float)($data['shipping_fee'] ?? 0);
|
$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.');
|
if (empty($items)) throw new Exception('At least one item is required.');
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once __DIR__ . '/DocumentNumberManager.php';
|
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||||
|
require_once __DIR__ . '/DocumentValidator.php';
|
||||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -173,8 +174,9 @@ class QuotationManager
|
|||||||
public function save(array $data, array $logging): int
|
public function save(array $data, array $logging): int
|
||||||
{
|
{
|
||||||
$id = (int)($data['id'] ?? 0);
|
$id = (int)($data['id'] ?? 0);
|
||||||
$items = $data['items'] ?? [];
|
$items = DocumentValidator::normaliseLines($data['items'] ?? [], 'Quotation');
|
||||||
$discount = (float)($data['discount'] ?? 0);
|
$data['items'] = $items;
|
||||||
|
$discount = DocumentValidator::discount($data['discount'] ?? 0, array_sum(array_column($items, 'total_price')));
|
||||||
|
|
||||||
$quotation_date = (string)($data['quotation_date'] ?? '');
|
$quotation_date = (string)($data['quotation_date'] ?? '');
|
||||||
$valid_until = (string)($data['valid_until'] ?? '');
|
$valid_until = (string)($data['valid_until'] ?? '');
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ class ReportManager
|
|||||||
// Private helpers
|
// Private helpers
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private ?array $transfer_totals = null;
|
||||||
|
|
||||||
private function stockTableNameFromWarehouseId(int $warehouse_id): string
|
private function stockTableNameFromWarehouseId(int $warehouse_id): string
|
||||||
{
|
{
|
||||||
if ($warehouse_id <= 0) {
|
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
|
private function resolveWarehouseTable(int $warehouse_id): ?string
|
||||||
{
|
{
|
||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
@@ -297,15 +354,7 @@ class ReportManager
|
|||||||
*/
|
*/
|
||||||
public function getLowStockCount(): int
|
public function getLowStockCount(): int
|
||||||
{
|
{
|
||||||
$products = $this->getStockBalance();
|
return count($this->getLowStockItems());
|
||||||
$count = 0;
|
|
||||||
foreach ($products as $product) {
|
|
||||||
$balance = (float) $product["total_in"] - (float) $product["total_out"];
|
|
||||||
if ($balance < (float) $product["min_stock"]) {
|
|
||||||
$count++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $count;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getDashboardStockTotals(): array
|
public function getDashboardStockTotals(): array
|
||||||
@@ -318,13 +367,20 @@ class ReportManager
|
|||||||
WHERE company_id = :company_id"
|
WHERE company_id = :company_id"
|
||||||
);
|
);
|
||||||
$sth->execute([':company_id' => $this->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
|
public function getDashboardOrderStats(): array
|
||||||
{
|
{
|
||||||
$sth = $this->pdo->prepare(
|
$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
|
FROM td_order
|
||||||
WHERE company_id = :company_id
|
WHERE company_id = :company_id
|
||||||
AND status != -1"
|
AND status != -1"
|
||||||
@@ -400,6 +456,13 @@ class ReportManager
|
|||||||
*
|
*
|
||||||
* @return array Low/critical stock items with warehouse_name, product_name, balance, status.
|
* @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
|
public function getLowStockItems(): array
|
||||||
{
|
{
|
||||||
$sql = "SELECT
|
$sql = "SELECT
|
||||||
@@ -420,11 +483,13 @@ class ReportManager
|
|||||||
ON wb.company_id = mw.company_id
|
ON wb.company_id = mw.company_id
|
||||||
AND wb.warehouse_id = mw.id
|
AND wb.warehouse_id = mw.id
|
||||||
WHERE wb.company_id = :company_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
|
GROUP BY
|
||||||
wb.warehouse_id, wb.product_sku, mw.warehouse_name,
|
wb.warehouse_id, wb.product_sku, mw.warehouse_name,
|
||||||
mp.product_name, mp.min_stock, mp.reorder_point, mp.product_image, mp.cost_price
|
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";
|
ORDER BY mp.product_name ASC, mw.warehouse_name ASC";
|
||||||
|
|
||||||
$rows = $this->fetchAll($sql);
|
$rows = $this->fetchAll($sql);
|
||||||
@@ -498,9 +563,13 @@ class ReportManager
|
|||||||
AND month = :month"
|
AND month = :month"
|
||||||
);
|
);
|
||||||
$sth->execute([':company_id' => $this->company_id, ':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
|
'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 = [];
|
$dataByMonth = [];
|
||||||
foreach ($rows as $row) {
|
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;
|
$dataByMonth[$row['month']] = $row;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -611,15 +683,22 @@ class ReportManager
|
|||||||
AND pc.id = p.category
|
AND pc.id = p.category
|
||||||
WHERE wb.company_id = :company_id
|
WHERE wb.company_id = :company_id
|
||||||
AND wb.month = :month
|
AND wb.month = :month
|
||||||
GROUP BY wb.product_sku, p.product_name, pc.category
|
GROUP BY wb.product_sku, p.product_name, pc.category"
|
||||||
ORDER BY total_out DESC
|
|
||||||
LIMIT {$limit}"
|
|
||||||
);
|
);
|
||||||
$sth->execute([
|
$sth->execute([
|
||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
':month' => $month,
|
':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;
|
$cid = (int) $this->company_id;
|
||||||
$warehouse_name = $this->pdo->quote($wh['warehouse_name']);
|
$warehouse_name = $this->pdo->quote($wh['warehouse_name']);
|
||||||
return "SELECT s.date, s.product_sku, s.type,
|
return "SELECT s.date, s.product_sku, s.type,
|
||||||
ROUND(COALESCE(s.`in`, 0), 2) AS stock_in,
|
COALESCE(s.`in`, 0) AS stock_in,
|
||||||
ROUND(COALESCE(s.`out`, 0), 2) AS stock_out,
|
COALESCE(s.`out`, 0) AS stock_out,
|
||||||
p.product_name,
|
p.product_name,
|
||||||
{$warehouse_name} AS warehouse_name
|
{$warehouse_name} AS warehouse_name
|
||||||
FROM `{$table}` s
|
FROM `{$table}` s
|
||||||
@@ -677,7 +756,8 @@ class ReportManager
|
|||||||
ON p.company_id = s.company_id
|
ON p.company_id = s.company_id
|
||||||
AND p.sku = s.product_sku
|
AND p.sku = s.product_sku
|
||||||
WHERE s.company_id = {$cid}
|
WHERE s.company_id = {$cid}
|
||||||
AND s.status = 1";
|
AND s.status = 1
|
||||||
|
AND (s.`in` > 0 OR s.`out` > 0)";
|
||||||
},
|
},
|
||||||
$warehouses
|
$warehouses
|
||||||
));
|
));
|
||||||
@@ -691,6 +771,8 @@ class ReportManager
|
|||||||
|
|
||||||
$items = [];
|
$items = [];
|
||||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
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;
|
$is_in = (float)$row['stock_in'] > 0;
|
||||||
$items[] = [
|
$items[] = [
|
||||||
'product_name' => $row['product_name'] ?: $row['product_sku'],
|
'product_name' => $row['product_name'] ?: $row['product_sku'],
|
||||||
@@ -771,25 +853,10 @@ class ReportManager
|
|||||||
*/
|
*/
|
||||||
public function getWarehouseLowStockCount(int $warehouse_id): int
|
public function getWarehouseLowStockCount(int $warehouse_id): int
|
||||||
{
|
{
|
||||||
$sth = $this->pdo->prepare(
|
return count(array_filter(
|
||||||
"SELECT wb.product_sku,
|
$this->getLowStockItems(),
|
||||||
ROUND(SUM(wb.total_in) - SUM(wb.total_out), 2) AS balance,
|
fn($item) => $item['warehouse_id'] === $warehouse_id
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1181,16 +1248,19 @@ class ReportManager
|
|||||||
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
|
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
|
||||||
|
|
||||||
$sth = $this->pdo->query(
|
$sth = $this->pdo->query(
|
||||||
"SELECT lot_number,
|
// Every row of the lot makes it listable; only approved rows
|
||||||
ROUND(SUM(COALESCE(`in`, 0)) - SUM(COALESCE(`out`, 0)), 2) AS lot_balance
|
// 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}`
|
FROM `{$table}`
|
||||||
WHERE company_id = {$cid}
|
WHERE company_id = {$cid}
|
||||||
AND status = 1
|
AND lot_number <> ''
|
||||||
AND lot_number IS NOT NULL
|
GROUP BY product_sku, lot_number"
|
||||||
GROUP BY lot_number"
|
|
||||||
);
|
);
|
||||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $lb) {
|
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'];
|
$lot_balance[$key] = ($lot_balance[$key] ?? 0) + (float)$lb['lot_balance'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1217,16 +1287,22 @@ class ReportManager
|
|||||||
$active = $expired = $near = 0;
|
$active = $expired = $near = 0;
|
||||||
|
|
||||||
// Exclude lots that have no td_stock record at all (e.g. all rows were soft-deleted)
|
// 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) {
|
foreach ($rows as &$row) {
|
||||||
$days = (int)$row['days_remaining'];
|
$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['balance'] = $balance;
|
||||||
$row['is_active'] = $balance > 0 ? 1 : 0;
|
$row['is_active'] = $balance > 0 ? 1 : 0;
|
||||||
|
|
||||||
if ($balance > 0) $active++;
|
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) $expired++;
|
||||||
if ($days >= 0 && $days <= 30) $near++;
|
if ($days >= 0 && $days <= 30) $near++;
|
||||||
|
|
||||||
@@ -1324,9 +1400,9 @@ class ReportManager
|
|||||||
ON p.company_id = r.company_id
|
ON p.company_id = r.company_id
|
||||||
AND p.sku = r.product_sku
|
AND p.sku = r.product_sku
|
||||||
WHERE r.company_id = :company_id
|
WHERE r.company_id = :company_id
|
||||||
ORDER BY mw.warehouse_name, r.zone,
|
ORDER BY mw.warehouse_name, REGEXP_REPLACE(r.zone, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.zone, '[0-9]+$') AS UNSIGNED), r.zone,
|
||||||
CAST(r.aisle AS UNSIGNED), r.aisle,
|
REGEXP_REPLACE(r.aisle, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.aisle, '[0-9]+$') AS UNSIGNED), r.aisle,
|
||||||
CAST(r.bin AS UNSIGNED), r.bin"
|
REGEXP_REPLACE(r.bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.bin, '[0-9]+$') AS UNSIGNED), r.bin"
|
||||||
);
|
);
|
||||||
$sth->execute([':company_id' => $this->company_id]);
|
$sth->execute([':company_id' => $this->company_id]);
|
||||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|||||||
@@ -1035,7 +1035,7 @@ class WarehouseManager {
|
|||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT DISTINCT zone FROM md_bin
|
"SELECT DISTINCT zone FROM md_bin
|
||||||
WHERE company_id = :company_id AND warehouse = :warehouse
|
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]);
|
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]);
|
||||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||||
@@ -1055,7 +1055,7 @@ class WarehouseManager {
|
|||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT DISTINCT aisle FROM md_bin
|
"SELECT DISTINCT aisle FROM md_bin
|
||||||
WHERE company_id = :company_id AND warehouse = :warehouse AND zone = :zone
|
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]);
|
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id, ':zone' => $zone]);
|
||||||
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle');
|
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle');
|
||||||
@@ -1077,7 +1077,7 @@ class WarehouseManager {
|
|||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT DISTINCT bin FROM md_bin
|
"SELECT DISTINCT bin FROM md_bin
|
||||||
WHERE company_id = :company_id AND warehouse = :warehouse
|
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]);
|
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]);
|
||||||
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'bin');
|
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'bin');
|
||||||
@@ -1086,7 +1086,7 @@ class WarehouseManager {
|
|||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT DISTINCT bin FROM md_bin
|
"SELECT DISTINCT bin FROM md_bin
|
||||||
WHERE company_id = :company_id AND warehouse = :warehouse AND zone = :zone AND aisle = :aisle
|
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]);
|
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id, ':zone' => $zone, ':aisle' => $aisle]);
|
||||||
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'bin');
|
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'bin');
|
||||||
@@ -1915,7 +1915,7 @@ class WarehouseManager {
|
|||||||
WHERE company_id = :company_id
|
WHERE company_id = :company_id
|
||||||
AND warehouse = :warehouse
|
AND warehouse = :warehouse
|
||||||
AND product_sku IS NULL
|
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]);
|
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]);
|
||||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||||
@@ -1961,7 +1961,7 @@ class WarehouseManager {
|
|||||||
{$lot_cond}
|
{$lot_cond}
|
||||||
{$serial_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);
|
$sth->execute($params);
|
||||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||||
@@ -1982,7 +1982,7 @@ class WarehouseManager {
|
|||||||
AND warehouse = :warehouse
|
AND warehouse = :warehouse
|
||||||
AND zone = :zone
|
AND zone = :zone
|
||||||
AND product_sku IS NULL
|
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([
|
$sth->execute([
|
||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
@@ -2033,7 +2033,7 @@ class WarehouseManager {
|
|||||||
{$lot_cond}
|
{$lot_cond}
|
||||||
{$serial_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);
|
$sth->execute($params);
|
||||||
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle');
|
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle');
|
||||||
@@ -2055,7 +2055,7 @@ class WarehouseManager {
|
|||||||
WHERE company_id = :company_id
|
WHERE company_id = :company_id
|
||||||
AND warehouse = :warehouse
|
AND warehouse = :warehouse
|
||||||
AND product_sku IS NULL
|
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([
|
$sth->execute([
|
||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
@@ -2071,7 +2071,7 @@ class WarehouseManager {
|
|||||||
AND zone = :zone
|
AND zone = :zone
|
||||||
AND aisle = :aisle
|
AND aisle = :aisle
|
||||||
AND product_sku IS NULL
|
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([
|
$sth->execute([
|
||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
@@ -2130,7 +2130,7 @@ class WarehouseManager {
|
|||||||
{$lot_cond}
|
{$lot_cond}
|
||||||
{$serial_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);
|
$sth->execute($params);
|
||||||
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'bin');
|
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'bin');
|
||||||
|
|||||||
@@ -141,6 +141,12 @@ $answer = array("success"=>0, "message"=>"");
|
|||||||
if (isset($_POST['json'])) {
|
if (isset($_POST['json'])) {
|
||||||
// Old Method: Data is wrapped in a JSON string
|
// Old Method: Data is wrapped in a JSON string
|
||||||
$data = json_decode($_POST['json'], true);
|
$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'])) {
|
} else if (isset($_POST['otp'])) {
|
||||||
// New Method: Data is sent directly (FormData)
|
// New Method: Data is sent directly (FormData)
|
||||||
// We check for 'otp' because every request should have one
|
// We check for 'otp' because every request should have one
|
||||||
|
|||||||
@@ -1,28 +1,33 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* alert_low_stock.php — Called by Node.js scheduler daily at 8am.
|
* alert_low_stock.php — Called by Node.js scheduler daily at 8am.
|
||||||
* Returns companies that have one or more SKUs below their min_stock threshold.
|
* Returns companies that have one or more low-stock products.
|
||||||
* Response: { success: 1, alerts: [{ company_id, count }] }
|
* Response: { success: 1, alerts: [{ company_id, count }] }
|
||||||
*/
|
*/
|
||||||
require __DIR__ . '/../assets/utils/cron_auth.php';
|
require __DIR__ . '/../assets/utils/cron_auth.php';
|
||||||
|
|
||||||
|
// Same rule as ReportManager::getLowStockItems(): counted per warehouse, at or
|
||||||
|
// below the higher of reorder point and minimum stock.
|
||||||
$sth = $pdo2->query(
|
$sth = $pdo2->query(
|
||||||
"SELECT e.company_id, COUNT(*) AS count
|
"SELECT e.company_id, COUNT(*) AS count
|
||||||
FROM (
|
FROM (
|
||||||
SELECT company_id, product_sku, SUM(total_in - total_out) AS balance
|
SELECT company_id, warehouse_id, product_sku, SUM(total_in - total_out) AS balance
|
||||||
FROM etl_stock_summary
|
FROM etl_stock_summary
|
||||||
WHERE company_id > 0
|
WHERE company_id > 0
|
||||||
GROUP BY company_id, product_sku
|
GROUP BY company_id, warehouse_id, product_sku
|
||||||
) e
|
) e
|
||||||
JOIN md_product p
|
JOIN md_product p
|
||||||
ON p.sku = e.product_sku
|
ON p.sku = e.product_sku
|
||||||
AND p.company_id = e.company_id
|
AND p.company_id = e.company_id
|
||||||
AND p.status = 1
|
AND p.status > 0
|
||||||
WHERE p.min_stock > 0
|
JOIN md_warehouse w
|
||||||
AND e.balance < p.min_stock
|
ON w.id = e.warehouse_id
|
||||||
|
AND w.company_id = e.company_id
|
||||||
|
AND w.status = 1
|
||||||
|
WHERE GREATEST(p.reorder_point, p.min_stock) > 0
|
||||||
|
AND e.balance <= GREATEST(p.reorder_point, p.min_stock)
|
||||||
GROUP BY e.company_id
|
GROUP BY e.company_id
|
||||||
HAVING count > 0"
|
HAVING count > 0"
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$answer['success'] = 1;
|
$answer['success'] = 1;
|
||||||
|
|||||||
@@ -14,8 +14,14 @@
|
|||||||
if ($item['status'] === 'critical') { $critical++; } else { $warning++; }
|
if ($item['status'] === 'critical') { $critical++; } else { $warning++; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every active warehouse, so one with healthy stock still appears in the
|
||||||
|
// filter (and reads as "nothing low here") instead of looking left out.
|
||||||
|
$sth = $pdo2->prepare("SELECT id, warehouse_name FROM md_warehouse WHERE company_id = :cid AND status = 1 ORDER BY warehouse_name");
|
||||||
|
$sth->execute([':cid' => $company_id]);
|
||||||
|
|
||||||
$answer['output'] = [
|
$answer['output'] = [
|
||||||
'items' => $items,
|
'items' => $items,
|
||||||
|
'warehouses' => $sth->fetchAll(PDO::FETCH_ASSOC),
|
||||||
'total_low' => count($items),
|
'total_low' => count($items),
|
||||||
'total_critical' => $critical,
|
'total_critical' => $critical,
|
||||||
'total_warning' => $warning,
|
'total_warning' => $warning,
|
||||||
|
|||||||
@@ -430,8 +430,8 @@
|
|||||||
<small class="text-muted">${item.product_sku}</small>
|
<small class="text-muted">${item.product_sku}</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-end">
|
<div class="text-end">
|
||||||
<span class="fw-bold text-danger">-${Number(item.total_out).toLocaleString()}</span><br>
|
<span class="fw-bold text-danger">Out ${format_quantity(item.total_out)}</span><br>
|
||||||
<small class="text-success">+${Number(item.total_in).toLocaleString()}</small>
|
<small class="text-success">In ${format_quantity(item.total_in)}</small>
|
||||||
</div>
|
</div>
|
||||||
</li>`;
|
</li>`;
|
||||||
});
|
});
|
||||||
@@ -483,7 +483,7 @@
|
|||||||
<small class="text-muted">${item.warehouse_name}</small>
|
<small class="text-muted">${item.warehouse_name}</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-end">
|
<div class="text-end">
|
||||||
<span class="fw-bold text-${color}">${sign}${Number(item.qty).toLocaleString()}</span><br>
|
<span class="fw-bold text-${color}">${sign}${format_quantity(item.qty)}</span><br>
|
||||||
<small class="text-muted">${time_ago(item.date)}</small>
|
<small class="text-muted">${time_ago(item.date)}</small>
|
||||||
</div>
|
</div>
|
||||||
</li>`;
|
</li>`;
|
||||||
@@ -569,7 +569,7 @@
|
|||||||
moved_html += `<li class="list-group-item d-flex align-items-center gap-3 py-3">
|
moved_html += `<li class="list-group-item d-flex align-items-center gap-3 py-3">
|
||||||
<div class="icon-shape icon-sm bg-danger bg-opacity-10 text-danger rounded-2 flex-shrink-0"><i class="ti ti-trending-down"></i></div>
|
<div class="icon-shape icon-sm bg-danger bg-opacity-10 text-danger rounded-2 flex-shrink-0"><i class="ti ti-trending-down"></i></div>
|
||||||
<div class="flex-grow-1"><p class="mb-0 fw-semibold">${item.product_name || item.product_sku}</p><small class="text-muted">${item.product_sku}</small></div>
|
<div class="flex-grow-1"><p class="mb-0 fw-semibold">${item.product_name || item.product_sku}</p><small class="text-muted">${item.product_sku}</small></div>
|
||||||
<div class="text-end"><span class="fw-bold text-danger">-${Number(item.total_out).toLocaleString()}</span><br><small class="text-success">+${Number(item.total_in).toLocaleString()}</small></div>
|
<div class="text-end"><span class="fw-bold text-danger">Out ${format_quantity(item.total_out)}</span><br><small class="text-success">In ${format_quantity(item.total_in)}</small></div>
|
||||||
</li>`;
|
</li>`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -608,7 +608,7 @@
|
|||||||
act_html += `<li class="list-group-item d-flex align-items-center gap-3 py-3">
|
act_html += `<li class="list-group-item d-flex align-items-center gap-3 py-3">
|
||||||
<div class="icon-shape icon-sm bg-${color} bg-opacity-10 text-${color} rounded-2 flex-shrink-0"><i class="ti ${icon}"></i></div>
|
<div class="icon-shape icon-sm bg-${color} bg-opacity-10 text-${color} rounded-2 flex-shrink-0"><i class="ti ${icon}"></i></div>
|
||||||
<div class="flex-grow-1"><p class="mb-0 fw-semibold">${item.product_name}</p><small class="text-muted">${item.warehouse_name}</small></div>
|
<div class="flex-grow-1"><p class="mb-0 fw-semibold">${item.product_name}</p><small class="text-muted">${item.warehouse_name}</small></div>
|
||||||
<div class="text-end"><span class="fw-bold text-${color}">${sign}${Number(item.qty).toLocaleString()}</span><br><small class="text-muted">${time_ago(item.date)}</small></div>
|
<div class="text-end"><span class="fw-bold text-${color}">${sign}${format_quantity(item.qty)}</span><br><small class="text-muted">${time_ago(item.date)}</small></div>
|
||||||
</li>`;
|
</li>`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,14 +170,10 @@
|
|||||||
$('#stat_warning').text(out.total_warning ?? 0);
|
$('#stat_warning').text(out.total_warning ?? 0);
|
||||||
$('#loader_total, #loader_critical, #loader_warning').hide();
|
$('#loader_total, #loader_critical, #loader_warning').hide();
|
||||||
|
|
||||||
// Warehouse filter options (derived from data)
|
// Warehouse filter options: every active warehouse, not only those with low items
|
||||||
var warehouses = {};
|
|
||||||
$.each(all_items, function(i, item) {
|
|
||||||
warehouses[item.warehouse_id] = item.warehouse_name;
|
|
||||||
});
|
|
||||||
var wh_options = '<option value="">All Warehouses</option>';
|
var wh_options = '<option value="">All Warehouses</option>';
|
||||||
$.each(warehouses, function(id, name) {
|
$.each(out.warehouses || [], function(i, wh) {
|
||||||
wh_options += `<option value="${id}">${name}</option>`;
|
wh_options += `<option value="${wh.id}">${wh.warehouse_name}</option>`;
|
||||||
});
|
});
|
||||||
$('#filter_warehouse').html(wh_options);
|
$('#filter_warehouse').html(wh_options);
|
||||||
|
|
||||||
|
|||||||
+40
-39
@@ -31,6 +31,40 @@ class db_statement extends PDOStatement {
|
|||||||
$this->pdo = $pdo;
|
$this->pdo = $pdo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// double_encode is off so text that is loaded and saved again is not escaped
|
||||||
|
// a second time (" becoming &quot;), 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
|
// PDOStatement::execute() is declared ?array $params = null : bool. This
|
||||||
// override deliberately accepts a looser signature so callers may pass
|
// override deliberately accepts a looser signature so callers may pass
|
||||||
// positional arguments (see func_get_args() below), so the tightened return
|
// 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.
|
// null is preserved as-is so PDO can bind NULL columns correctly.
|
||||||
$args = array_map(fn($v) => is_null($v) ? null : (string)$v, $args);
|
$args = array_map(fn($v) => is_null($v) ? null : (string)$v, $args);
|
||||||
|
|
||||||
// escaping array
|
// Escape on the way in, to prevent stored XSS. Values holding a JSON
|
||||||
// prevent store XSS
|
// object/array are escaped string by string so they stay valid JSON.
|
||||||
foreach($args as &$item){
|
foreach($args as &$item){
|
||||||
|
|
||||||
if (is_null($item)) continue;
|
if (is_null($item)) continue;
|
||||||
|
$item = self::escapeValue($item);
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
unset($item);
|
||||||
}
|
}
|
||||||
return parent::execute($args);
|
return parent::execute($args);
|
||||||
}
|
}
|
||||||
@@ -96,11 +97,11 @@ class db_statement extends PDOStatement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
//..................... PDO1 .....................//
|
//..................... 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);
|
$pdo1->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
|
||||||
//..................... PDO2 .....................//
|
//..................... 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);
|
$pdo2->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
|
||||||
// Pin both connections to the application timezone, so MySQL NOW() and PHP
|
// Pin both connections to the application timezone, so MySQL NOW() and PHP
|
||||||
|
|||||||
@@ -13,13 +13,14 @@ $prm = new PurchaseRequestManager($pdo2, $company_id);
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if ($action === 'create') {
|
if ($action === 'create') {
|
||||||
$new_id = $prm->save(array_merge($data, ['id' => 0, 'items' => $items]), $logging);
|
// One transaction: a failure while writing the lines must not leave the header behind.
|
||||||
|
$new_id = dbTransaction($pdo2, fn() => $prm->save(array_merge($data, ['id' => 0, 'items' => $items]), $logging));
|
||||||
$answer['success'] = 1;
|
$answer['success'] = 1;
|
||||||
$answer['message'] = 'Purchase request created.';
|
$answer['message'] = 'Purchase request created.';
|
||||||
$answer['new_id'] = $new_id;
|
$answer['new_id'] = $new_id;
|
||||||
(new UsageGuard($pdo1, $company_id, $packages))->increment();
|
(new UsageGuard($pdo1, $company_id, $packages))->increment();
|
||||||
} elseif ($action === 'update') {
|
} elseif ($action === 'update') {
|
||||||
$prm->save(array_merge($data, ['id' => $id, 'items' => $items]), $logging);
|
dbTransaction($pdo2, fn() => $prm->save(array_merge($data, ['id' => $id, 'items' => $items]), $logging));
|
||||||
$answer['success'] = 1;
|
$answer['success'] = 1;
|
||||||
$answer['message'] = 'Purchase request updated.';
|
$answer['message'] = 'Purchase request updated.';
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-5 mb-5">
|
<div class="row g-5 mb-5">
|
||||||
<div class="col-lg-3 col-sm-6 col-12">
|
<div class="col-lg col-sm-6 col-12">
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-3">
|
||||||
<div class="icon-shape icon-md bg-primary bg-opacity-10 text-primary rounded-2">
|
<div class="icon-shape icon-md bg-primary bg-opacity-10 text-primary rounded-2">
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-3 col-sm-6 col-12">
|
<div class="col-lg col-sm-6 col-12">
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-3">
|
||||||
<div class="icon-shape icon-md bg-success bg-opacity-10 text-success rounded-2">
|
<div class="icon-shape icon-md bg-success bg-opacity-10 text-success rounded-2">
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-3 col-sm-6 col-12">
|
<div class="col-lg col-sm-6 col-12">
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-3">
|
||||||
<div class="icon-shape icon-md bg-warning bg-opacity-10 text-warning rounded-2">
|
<div class="icon-shape icon-md bg-warning bg-opacity-10 text-warning rounded-2">
|
||||||
@@ -65,7 +65,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-3 col-sm-6 col-12">
|
<div class="col-lg col-sm-6 col-12">
|
||||||
|
<div class="card p-4">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<div class="icon-shape icon-md bg-secondary bg-opacity-10 text-secondary rounded-2">
|
||||||
|
<i class="ti ti-ban fs-4"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="mb-0 text-muted small">Void</p>
|
||||||
|
<h3 class="fw-bold mb-0" id="stat_void">—</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg col-sm-6 col-12">
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-3">
|
||||||
<div class="icon-shape icon-md bg-info bg-opacity-10 text-info rounded-2">
|
<div class="icon-shape icon-md bg-info bg-opacity-10 text-info rounded-2">
|
||||||
@@ -255,8 +268,11 @@
|
|||||||
function update_stats() {
|
function update_stats() {
|
||||||
var pis = all_invoices.filter(i => i.doc_type === 'purchase_invoice');
|
var pis = all_invoices.filter(i => i.doc_type === 'purchase_invoice');
|
||||||
$('#stat_invoice').text(format_number(pis.length));
|
$('#stat_invoice').text(format_number(pis.length));
|
||||||
$('#stat_paid').text(format_number(pis.filter(i => String(i.status) === '2').length));
|
// Same test as the row badge, so a tile never disagrees with the list below it
|
||||||
$('#stat_open').text(format_number(pis.filter(i => ['0','1'].includes(String(i.status))).length));
|
var is_paid = i => String(i.status) === '2' || i.payment_state === 'paid';
|
||||||
|
$('#stat_paid').text(format_number(pis.filter(i => String(i.status) !== '4' && is_paid(i)).length));
|
||||||
|
$('#stat_open').text(format_number(pis.filter(i => ['0','1'].includes(String(i.status)) && !is_paid(i)).length));
|
||||||
|
$('#stat_void').text(format_number(pis.filter(i => String(i.status) === '4').length));
|
||||||
$('#stat_scn').text(format_number(all_invoices.filter(i => i.doc_type === 'supplier_credit_note').length));
|
$('#stat_scn').text(format_number(all_invoices.filter(i => i.doc_type === 'supplier_credit_note').length));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -164,7 +164,7 @@
|
|||||||
'<td class="small">' + escape_html(r.period) + '</td>' +
|
'<td class="small">' + escape_html(r.period) + '</td>' +
|
||||||
'<td class="text-end small">' + format_number(r.total_debit, 2) + '</td>' +
|
'<td class="text-end small">' + format_number(r.total_debit, 2) + '</td>' +
|
||||||
'<td class="text-end small">' + format_number(r.total_credit, 2) + '</td>' +
|
'<td class="text-end small">' + format_number(r.total_credit, 2) + '</td>' +
|
||||||
'<td class="small text-muted">' + escape_html(r.posted_at) + '</td>' +
|
'<td class="small text-muted">' + escape_html(format_date(r.posted_at)) + '</td>' +
|
||||||
'<td><a href="javascript:;" onclick="view_detail(' + r.id + ')"><i class="ti ti-eye fs-5"></i></a>' +
|
'<td><a href="javascript:;" onclick="view_detail(' + r.id + ')"><i class="ti ti-eye fs-5"></i></a>' +
|
||||||
(r.source_type === 'manual' ? ' <a href="' + server_url + 'journal/new.php?gl_id=' + r.id + '" class="ms-2"><i class="ti ti-edit fs-5"></i></a>' : '') +
|
(r.source_type === 'manual' ? ' <a href="' + server_url + 'journal/new.php?gl_id=' + r.id + '" class="ms-2"><i class="ti ti-edit fs-5"></i></a>' : '') +
|
||||||
'</td>' +
|
'</td>' +
|
||||||
|
|||||||
+32
-4
@@ -23,7 +23,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-5 mb-5">
|
<div class="row g-5 mb-5">
|
||||||
<div class="col-lg-3 col-sm-6 col-12">
|
<div class="col-xl-2 col-lg-4 col-sm-6 col-12">
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-3">
|
||||||
<div class="icon-shape icon-md bg-primary bg-opacity-10 text-primary rounded-2">
|
<div class="icon-shape icon-md bg-primary bg-opacity-10 text-primary rounded-2">
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-3 col-sm-6 col-12">
|
<div class="col-xl-2 col-lg-4 col-sm-6 col-12">
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-3">
|
||||||
<div class="icon-shape icon-md bg-warning bg-opacity-10 text-warning rounded-2">
|
<div class="icon-shape icon-md bg-warning bg-opacity-10 text-warning rounded-2">
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-3 col-sm-6 col-12">
|
<div class="col-xl-2 col-lg-4 col-sm-6 col-12">
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-3">
|
||||||
<div class="icon-shape icon-md bg-info bg-opacity-10 text-info rounded-2">
|
<div class="icon-shape icon-md bg-info bg-opacity-10 text-info rounded-2">
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-3 col-sm-6 col-12">
|
<div class="col-xl-2 col-lg-4 col-sm-6 col-12">
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-3">
|
||||||
<div class="icon-shape icon-md bg-danger bg-opacity-10 text-danger rounded-2">
|
<div class="icon-shape icon-md bg-danger bg-opacity-10 text-danger rounded-2">
|
||||||
@@ -75,7 +75,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-xl-2 col-lg-4 col-sm-6 col-12">
|
||||||
|
<div class="card p-4">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<div class="icon-shape icon-md bg-success bg-opacity-10 text-success rounded-2">
|
||||||
|
<i class="ti ti-circle-check fs-4"></i>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="mb-0 text-muted small">Paid</p>
|
||||||
|
<h3 class="fw-bold mb-0" id="stat_paid">—</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-2 col-lg-4 col-sm-6 col-12">
|
||||||
|
<div class="card p-4">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<div class="icon-shape icon-md bg-secondary bg-opacity-10 text-secondary rounded-2">
|
||||||
|
<i class="ti ti-ban fs-4"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="mb-0 text-muted small">Void</p>
|
||||||
|
<h3 class="fw-bold mb-0" id="stat_void">—</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row g-5">
|
<div class="row g-5">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
@@ -224,6 +250,8 @@
|
|||||||
$('#stat_total').text(format_number(all.length));
|
$('#stat_total').text(format_number(all.length));
|
||||||
$('#stat_draft').text(format_number(all.filter(i => String(i.status) === '0').length));
|
$('#stat_draft').text(format_number(all.filter(i => String(i.status) === '0').length));
|
||||||
$('#stat_issued').text(format_number(all.filter(i => String(i.status) === '1').length));
|
$('#stat_issued').text(format_number(all.filter(i => String(i.status) === '1').length));
|
||||||
|
$('#stat_paid').text(format_number(all.filter(i => String(i.status) === '2').length));
|
||||||
|
$('#stat_void').text(format_number(all.filter(i => String(i.status) === '4').length));
|
||||||
$('#stat_overdue').text(format_number(all.filter(i => is_overdue(i)).length));
|
$('#stat_overdue').text(format_number(all.filter(i => is_overdue(i)).length));
|
||||||
|
|
||||||
alasql('CREATE TABLE IF NOT EXISTS invoice_list');
|
alasql('CREATE TABLE IF NOT EXISTS invoice_list');
|
||||||
|
|||||||
+18
-4
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
<!-- Stat cards -->
|
<!-- Stat cards -->
|
||||||
<div class="row g-5 mb-5">
|
<div class="row g-5 mb-5">
|
||||||
<div class="col-lg-3 col-sm-6 col-12">
|
<div class="col-lg col-sm-6 col-12">
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-3">
|
||||||
<div class="icon-shape icon-md bg-primary bg-opacity-10 text-primary rounded-2">
|
<div class="icon-shape icon-md bg-primary bg-opacity-10 text-primary rounded-2">
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-3 col-sm-6 col-12">
|
<div class="col-lg col-sm-6 col-12">
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-3">
|
||||||
<div class="icon-shape icon-md bg-warning bg-opacity-10 text-warning rounded-2">
|
<div class="icon-shape icon-md bg-warning bg-opacity-10 text-warning rounded-2">
|
||||||
@@ -56,7 +56,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-3 col-sm-6 col-12">
|
<div class="col-lg col-sm-6 col-12">
|
||||||
|
<div class="card p-4">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<div class="icon-shape icon-md bg-danger bg-opacity-10 text-danger rounded-2">
|
||||||
|
<i class="ti ti-building-warehouse fs-4"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="mb-0 text-muted small">Pending Warehouse</p>
|
||||||
|
<h3 class="fw-bold mb-0" id="stat_pending">—</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg col-sm-6 col-12">
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-3">
|
||||||
<div class="icon-shape icon-md bg-info bg-opacity-10 text-info rounded-2">
|
<div class="icon-shape icon-md bg-info bg-opacity-10 text-info rounded-2">
|
||||||
@@ -69,7 +82,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-3 col-sm-6 col-12">
|
<div class="col-lg col-sm-6 col-12">
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-3">
|
||||||
<div class="icon-shape icon-md bg-secondary bg-opacity-10 text-secondary rounded-2">
|
<div class="icon-shape icon-md bg-secondary bg-opacity-10 text-secondary rounded-2">
|
||||||
@@ -215,6 +228,7 @@
|
|||||||
|
|
||||||
// Stat cards
|
// Stat cards
|
||||||
$('#stat_total').text(format_number(all.length));
|
$('#stat_total').text(format_number(all.length));
|
||||||
|
$('#stat_pending').text(format_number(all.filter(o => parseInt(o.status) === -2).length));
|
||||||
$('#stat_draft').text(format_number(all.filter(o => parseInt(o.status) === 0).length));
|
$('#stat_draft').text(format_number(all.filter(o => parseInt(o.status) === 0).length));
|
||||||
$('#stat_confirmed').text(format_number(all.filter(o => parseInt(o.status) === 1).length));
|
$('#stat_confirmed').text(format_number(all.filter(o => parseInt(o.status) === 1).length));
|
||||||
$('#stat_completed').text(format_number(all.filter(o => parseInt(o.status) === -1).length));
|
$('#stat_completed').text(format_number(all.filter(o => parseInt(o.status) === -1).length));
|
||||||
|
|||||||
+32
-4
@@ -23,7 +23,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-5 mb-5">
|
<div class="row g-5 mb-5">
|
||||||
<div class="col-lg-3 col-sm-6 col-12">
|
<div class="col-xl-2 col-lg-4 col-sm-6 col-12">
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-3">
|
||||||
<div class="icon-shape icon-md bg-primary bg-opacity-10 text-primary rounded-2">
|
<div class="icon-shape icon-md bg-primary bg-opacity-10 text-primary rounded-2">
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-3 col-sm-6 col-12">
|
<div class="col-xl-2 col-lg-4 col-sm-6 col-12">
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-3">
|
||||||
<div class="icon-shape icon-md bg-warning bg-opacity-10 text-warning rounded-2">
|
<div class="icon-shape icon-md bg-warning bg-opacity-10 text-warning rounded-2">
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-3 col-sm-6 col-12">
|
<div class="col-xl-2 col-lg-4 col-sm-6 col-12">
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-3">
|
||||||
<div class="icon-shape icon-md bg-info bg-opacity-10 text-info rounded-2">
|
<div class="icon-shape icon-md bg-info bg-opacity-10 text-info rounded-2">
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-3 col-sm-6 col-12">
|
<div class="col-xl-2 col-lg-4 col-sm-6 col-12">
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-3">
|
||||||
<div class="icon-shape icon-md bg-danger bg-opacity-10 text-danger rounded-2">
|
<div class="icon-shape icon-md bg-danger bg-opacity-10 text-danger rounded-2">
|
||||||
@@ -75,7 +75,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-xl-2 col-lg-4 col-sm-6 col-12">
|
||||||
|
<div class="card p-4">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<div class="icon-shape icon-md bg-success bg-opacity-10 text-success rounded-2">
|
||||||
|
<i class="ti ti-circle-check fs-4"></i>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="mb-0 text-muted small">Paid</p>
|
||||||
|
<h3 class="fw-bold mb-0" id="stat_paid">—</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-2 col-lg-4 col-sm-6 col-12">
|
||||||
|
<div class="card p-4">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<div class="icon-shape icon-md bg-secondary bg-opacity-10 text-secondary rounded-2">
|
||||||
|
<i class="ti ti-ban fs-4"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="mb-0 text-muted small">Void</p>
|
||||||
|
<h3 class="fw-bold mb-0" id="stat_void">—</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row g-5">
|
<div class="row g-5">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
@@ -212,6 +238,8 @@
|
|||||||
$('#stat_total').text(format_number(all.length));
|
$('#stat_total').text(format_number(all.length));
|
||||||
$('#stat_draft').text(format_number(all.filter(i => String(i.status) === '0').length));
|
$('#stat_draft').text(format_number(all.filter(i => String(i.status) === '0').length));
|
||||||
$('#stat_issued').text(format_number(all.filter(i => String(i.status) === '1').length));
|
$('#stat_issued').text(format_number(all.filter(i => String(i.status) === '1').length));
|
||||||
|
$('#stat_paid').text(format_number(all.filter(i => String(i.status) === '2').length));
|
||||||
|
$('#stat_void').text(format_number(all.filter(i => String(i.status) === '4').length));
|
||||||
$('#stat_overdue').text(format_number(all.filter(i =>
|
$('#stat_overdue').text(format_number(all.filter(i =>
|
||||||
String(i.status) === '1' && i.due_date && i.due_date < today
|
String(i.status) === '1' && i.due_date && i.due_date < today
|
||||||
).length));
|
).length));
|
||||||
|
|||||||
@@ -306,7 +306,7 @@
|
|||||||
<td class="py-3">${item.product_sku}</td>
|
<td class="py-3">${item.product_sku}</td>
|
||||||
<td class="py-3">${item.lot_number || '<span class="text-muted">—</span>'}</td>
|
<td class="py-3">${item.lot_number || '<span class="text-muted">—</span>'}</td>
|
||||||
<td class="py-3">${format_location(item)}</td>
|
<td class="py-3">${format_location(item)}</td>
|
||||||
<td class="py-3">${item.expiry_date}</td>
|
<td class="py-3">${format_date(item.expiry_date)}</td>
|
||||||
<td class="py-3 text-danger fw-semibold">${Math.abs(item.days_remaining)} days</td>
|
<td class="py-3 text-danger fw-semibold">${Math.abs(item.days_remaining)} days</td>
|
||||||
<td class="py-3">${item.quantity}</td>
|
<td class="py-3">${item.quantity}</td>
|
||||||
<td class="py-3">${status_badge(item.status)}</td>
|
<td class="py-3">${status_badge(item.status)}</td>
|
||||||
@@ -330,7 +330,7 @@
|
|||||||
<td class="py-3">${item.product_sku}</td>
|
<td class="py-3">${item.product_sku}</td>
|
||||||
<td class="py-3">${item.lot_number || '<span class="text-muted">—</span>'}</td>
|
<td class="py-3">${item.lot_number || '<span class="text-muted">—</span>'}</td>
|
||||||
<td class="py-3">${format_location(item)}</td>
|
<td class="py-3">${format_location(item)}</td>
|
||||||
<td class="py-3">${item.expiry_date}</td>
|
<td class="py-3">${format_date(item.expiry_date)}</td>
|
||||||
<td class="py-3 text-warning fw-semibold">${item.days_remaining} days</td>
|
<td class="py-3 text-warning fw-semibold">${item.days_remaining} days</td>
|
||||||
<td class="py-3">${item.quantity}</td>
|
<td class="py-3">${item.quantity}</td>
|
||||||
<td class="py-3">${status_badge(item.status)}</td>
|
<td class="py-3">${status_badge(item.status)}</td>
|
||||||
|
|||||||
@@ -533,7 +533,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body += `<tr>
|
body += `<tr>
|
||||||
<td class="py-2">${row.date}</td>
|
<td class="py-2">${format_date(row.date)}</td>
|
||||||
<td class="py-2">${type_badge[row.type] || row.type}</td>
|
<td class="py-2">${type_badge[row.type] || row.type}</td>
|
||||||
<td class="py-2"><code>${row.product_sku}</code></td>
|
<td class="py-2"><code>${row.product_sku}</code></td>
|
||||||
<td class="py-2">${row.lot_number || '<span class="text-muted">—</span>'}</td>
|
<td class="py-2">${row.lot_number || '<span class="text-muted">—</span>'}</td>
|
||||||
|
|||||||
@@ -13,14 +13,15 @@ $qm = new QuotationManager($pdo2, $company_id);
|
|||||||
try {
|
try {
|
||||||
if ($action === 'create') {
|
if ($action === 'create') {
|
||||||
require_role($user_role, ['owner', 'admin', 'staff']);
|
require_role($user_role, ['owner', 'admin', 'staff']);
|
||||||
$new_id = $qm->save(array_merge($data, ['id' => 0, 'items' => $items]), $logging);
|
// One transaction: a failure while writing the lines must not leave the header behind.
|
||||||
|
$new_id = dbTransaction($pdo2, fn() => $qm->save(array_merge($data, ['id' => 0, 'items' => $items]), $logging));
|
||||||
$answer['success'] = 1;
|
$answer['success'] = 1;
|
||||||
$answer['message'] = 'Quotation created.';
|
$answer['message'] = 'Quotation created.';
|
||||||
$answer['new_id'] = $new_id;
|
$answer['new_id'] = $new_id;
|
||||||
(new UsageGuard($pdo1, $company_id, $packages))->increment();
|
(new UsageGuard($pdo1, $company_id, $packages))->increment();
|
||||||
} elseif ($action === 'update') {
|
} elseif ($action === 'update') {
|
||||||
require_role($user_role, ['owner', 'admin', 'staff']);
|
require_role($user_role, ['owner', 'admin', 'staff']);
|
||||||
$qm->save(array_merge($data, ['id' => $id, 'items' => $items]), $logging);
|
dbTransaction($pdo2, fn() => $qm->save(array_merge($data, ['id' => $id, 'items' => $items]), $logging));
|
||||||
$answer['success'] = 1;
|
$answer['success'] = 1;
|
||||||
$answer['message'] = 'Quotation updated.';
|
$answer['message'] = 'Quotation updated.';
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+22
-6
@@ -26,7 +26,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-5 mb-5">
|
<div class="row g-5 mb-5">
|
||||||
<div class="col-lg-3 col-sm-6 col-12">
|
<div class="col-lg col-sm-6 col-12">
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-3">
|
||||||
<div class="icon-shape icon-md bg-primary bg-opacity-10 text-primary rounded-2">
|
<div class="icon-shape icon-md bg-primary bg-opacity-10 text-primary rounded-2">
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-3 col-sm-6 col-12">
|
<div class="col-lg col-sm-6 col-12">
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-3">
|
||||||
<div class="icon-shape icon-md bg-success bg-opacity-10 text-success rounded-2">
|
<div class="icon-shape icon-md bg-success bg-opacity-10 text-success rounded-2">
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-3 col-sm-6 col-12">
|
<div class="col-lg col-sm-6 col-12">
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-3">
|
||||||
<div class="icon-shape icon-md bg-warning bg-opacity-10 text-warning rounded-2">
|
<div class="icon-shape icon-md bg-warning bg-opacity-10 text-warning rounded-2">
|
||||||
@@ -65,7 +65,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-3 col-sm-6 col-12">
|
<div class="col-lg col-sm-6 col-12">
|
||||||
|
<div class="card p-4">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<div class="icon-shape icon-md bg-secondary bg-opacity-10 text-secondary rounded-2">
|
||||||
|
<i class="ti ti-ban fs-4"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="mb-0 text-muted small">Void</p>
|
||||||
|
<h3 class="fw-bold mb-0" id="stat_void">—</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg col-sm-6 col-12">
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-3">
|
||||||
<div class="icon-shape icon-md bg-danger bg-opacity-10 text-danger rounded-2">
|
<div class="icon-shape icon-md bg-danger bg-opacity-10 text-danger rounded-2">
|
||||||
@@ -255,8 +268,11 @@
|
|||||||
|
|
||||||
function update_stats() {
|
function update_stats() {
|
||||||
$('#stat_invoice').text(format_number(all_invoices.filter(i => i.doc_type === 'invoice').length));
|
$('#stat_invoice').text(format_number(all_invoices.filter(i => i.doc_type === 'invoice').length));
|
||||||
$('#stat_paid').text(format_number(all_invoices.filter(i => i.doc_type === 'invoice' && String(i.status) === '2').length));
|
// Same test as the row badge, so a tile never disagrees with the list below it
|
||||||
$('#stat_open').text(format_number(all_invoices.filter(i => i.doc_type === 'invoice' && ['0', '1'].includes(String(i.status))).length));
|
var is_paid = i => String(i.status) === '2' || i.payment_state === 'paid';
|
||||||
|
$('#stat_paid').text(format_number(all_invoices.filter(i => i.doc_type === 'invoice' && String(i.status) !== '4' && is_paid(i)).length));
|
||||||
|
$('#stat_open').text(format_number(all_invoices.filter(i => i.doc_type === 'invoice' && ['0', '1'].includes(String(i.status)) && !is_paid(i)).length));
|
||||||
|
$('#stat_void').text(format_number(all_invoices.filter(i => i.doc_type === 'invoice' && String(i.status) === '4').length));
|
||||||
$('#stat_cn').text(format_number(all_invoices.filter(i => i.doc_type === 'credit_note').length));
|
$('#stat_cn').text(format_number(all_invoices.filter(i => i.doc_type === 'credit_note').length));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,13 @@
|
|||||||
placeholder="DD/MM/YYYY" autocomplete="off">
|
placeholder="DD/MM/YYYY" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3 col-lg-6">
|
||||||
|
<label class="form-label">Department</label>
|
||||||
|
<select id="department_id" class="form-select">
|
||||||
|
<option value="0">— No Department —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mb-3 col-lg-12">
|
<div class="mb-3 col-lg-12">
|
||||||
<label class="form-label">Notes</label>
|
<label class="form-label">Notes</label>
|
||||||
<textarea id="notes" class="form-control" rows="2"
|
<textarea id="notes" class="form-control" rows="2"
|
||||||
@@ -293,7 +300,7 @@
|
|||||||
has_active_invoice = has_active_invoice || false;
|
has_active_invoice = has_active_invoice || false;
|
||||||
active_invoice_id = parseInt(active_invoice_id) || 0;
|
active_invoice_id = parseInt(active_invoice_id) || 0;
|
||||||
var editable = status === -2;
|
var editable = status === -2;
|
||||||
$('#contact, #order_date, #notes, #discount, #tax_adjustment, #shipping_fee, #btn_add_item')
|
$('#contact, #order_date, #department_id, #notes, #discount, #tax_adjustment, #shipping_fee, #btn_add_item')
|
||||||
.prop('disabled', !editable);
|
.prop('disabled', !editable);
|
||||||
$('.item_sku, .item_desc, .item_qty, .item_price, .item_tax_rate').prop('disabled', !editable);
|
$('.item_sku, .item_desc, .item_qty, .item_price, .item_tax_rate').prop('disabled', !editable);
|
||||||
$('.remove_item_btn').toggleClass('d-none', !editable);
|
$('.remove_item_btn').toggleClass('d-none', !editable);
|
||||||
@@ -357,6 +364,7 @@
|
|||||||
$('#contact').val(o.contact_name || '');
|
$('#contact').val(o.contact_name || '');
|
||||||
$('#contact_id').val(o.contact_id || 0);
|
$('#contact_id').val(o.contact_id || 0);
|
||||||
$('#order_date').val(o.order_date ? format_date_input(o.order_date) : '');
|
$('#order_date').val(o.order_date ? format_date_input(o.order_date) : '');
|
||||||
|
$('#department_id').val(o.department_id || 0);
|
||||||
$('#notes').val(o.notes || '');
|
$('#notes').val(o.notes || '');
|
||||||
$('#discount').val(o.discount || 0);
|
$('#discount').val(o.discount || 0);
|
||||||
$('#tax_adjustment').val(o.tax_adjustment || 0);
|
$('#tax_adjustment').val(o.tax_adjustment || 0);
|
||||||
@@ -388,6 +396,7 @@
|
|||||||
id: order_id,
|
id: order_id,
|
||||||
contact_id: $('#contact_id').val() || 0,
|
contact_id: $('#contact_id').val() || 0,
|
||||||
order_date: to_iso_date($('#order_date').val()),
|
order_date: to_iso_date($('#order_date').val()),
|
||||||
|
department_id: $('#department_id').val() || 0,
|
||||||
items: JSON.stringify(items),
|
items: JSON.stringify(items),
|
||||||
discount: $('#discount').val() || 0,
|
discount: $('#discount').val() || 0,
|
||||||
tax_adjustment: $('#tax_adjustment').val() || 0,
|
tax_adjustment: $('#tax_adjustment').val() || 0,
|
||||||
@@ -490,6 +499,8 @@
|
|||||||
|
|
||||||
$(async function() {
|
$(async function() {
|
||||||
try {
|
try {
|
||||||
|
// Options must exist before retrieve_order() selects the saved department
|
||||||
|
await Promise.resolve(load_departments('department_id')).catch(function() {});
|
||||||
if (order_id) {
|
if (order_id) {
|
||||||
await retrieve_order();
|
await retrieve_order();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+18
-4
@@ -26,7 +26,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-5 mb-5">
|
<div class="row g-5 mb-5">
|
||||||
<div class="col-lg-3 col-sm-6 col-12">
|
<div class="col-lg col-sm-6 col-12">
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-3">
|
||||||
<div class="icon-shape icon-md bg-primary bg-opacity-10 text-primary rounded-2">
|
<div class="icon-shape icon-md bg-primary bg-opacity-10 text-primary rounded-2">
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-3 col-sm-6 col-12">
|
<div class="col-lg col-sm-6 col-12">
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-3">
|
||||||
<div class="icon-shape icon-md bg-danger bg-opacity-10 text-danger rounded-2">
|
<div class="icon-shape icon-md bg-danger bg-opacity-10 text-danger rounded-2">
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-3 col-sm-6 col-12">
|
<div class="col-lg col-sm-6 col-12">
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-3">
|
||||||
<div class="icon-shape icon-md bg-warning bg-opacity-10 text-warning rounded-2">
|
<div class="icon-shape icon-md bg-warning bg-opacity-10 text-warning rounded-2">
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-3 col-sm-6 col-12">
|
<div class="col-lg col-sm-6 col-12">
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-3">
|
||||||
<div class="icon-shape icon-md bg-success bg-opacity-10 text-success rounded-2">
|
<div class="icon-shape icon-md bg-success bg-opacity-10 text-success rounded-2">
|
||||||
@@ -78,7 +78,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-lg col-sm-6 col-12">
|
||||||
|
<div class="card p-4">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<div class="icon-shape icon-md bg-secondary bg-opacity-10 text-secondary rounded-2">
|
||||||
|
<i class="ti ti-x fs-4"></i>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="mb-0 text-muted small">Cancelled</p>
|
||||||
|
<h3 class="fw-bold mb-0" id="stat_cancelled">—</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row g-5">
|
<div class="row g-5">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
@@ -197,6 +210,7 @@
|
|||||||
$('#stat_total').text(format_number(all.length));
|
$('#stat_total').text(format_number(all.length));
|
||||||
$('#stat_pending').text(format_number(all.filter(o => String(o.status) === '-2').length));
|
$('#stat_pending').text(format_number(all.filter(o => String(o.status) === '-2').length));
|
||||||
$('#stat_draft').text(format_number(all.filter(o => String(o.status) === '0').length));
|
$('#stat_draft').text(format_number(all.filter(o => String(o.status) === '0').length));
|
||||||
|
$('#stat_cancelled').text(format_number(all.filter(o => String(o.status) === '-1').length));
|
||||||
$('#stat_confirmed').text(format_number(all.filter(o => parseInt(o.status) >= 1).length));
|
$('#stat_confirmed').text(format_number(all.filter(o => parseInt(o.status) >= 1).length));
|
||||||
|
|
||||||
alasql('CREATE TABLE IF NOT EXISTS revenue_order_list');
|
alasql('CREATE TABLE IF NOT EXISTS revenue_order_list');
|
||||||
|
|||||||
@@ -1229,6 +1229,48 @@ CREATE TABLE IF NOT EXISTS `document_number_sequences` (
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
|
||||||
", 'document_number_sequences');
|
", 'document_number_sequences');
|
||||||
|
|
||||||
|
// ── utf8mb4 ───────────────────────────────────────────────────────────────────
|
||||||
|
// Older tables were created as utf8mb3, which cannot hold 4-byte characters
|
||||||
|
// (emoji, some CJK). With the connection on utf8mb4 a note containing one was
|
||||||
|
// rejected by the server and the whole save rolled back. Convert whatever is
|
||||||
|
// still utf8mb3, including the per-warehouse td_stock_<id> tables, to the
|
||||||
|
// collation the existing utf8mb4 tables already use so joins keep working.
|
||||||
|
foreach ([$db1, $db2] as $db_name) {
|
||||||
|
try {
|
||||||
|
$st = $pdo->prepare("SELECT TABLE_COLLATION FROM information_schema.TABLES
|
||||||
|
WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE' AND TABLE_COLLATION LIKE 'utf8mb4\\_%'
|
||||||
|
GROUP BY TABLE_COLLATION ORDER BY COUNT(*) DESC LIMIT 1");
|
||||||
|
$st->execute([$db2]);
|
||||||
|
$target = $st->fetchColumn();
|
||||||
|
if (!$target) {
|
||||||
|
$target = $pdo->query("SELECT COLLATION_NAME FROM information_schema.COLLATIONS
|
||||||
|
WHERE CHARACTER_SET_NAME = 'utf8mb4' AND IS_DEFAULT = 'Yes' LIMIT 1")->fetchColumn() ?: 'utf8mb4_general_ci';
|
||||||
|
}
|
||||||
|
if (!preg_match('/^utf8mb4_[a-z0-9_]+$/', $target)) $target = 'utf8mb4_general_ci';
|
||||||
|
|
||||||
|
$st = $pdo->prepare("SELECT TABLE_NAME FROM information_schema.TABLES
|
||||||
|
WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE'
|
||||||
|
AND (TABLE_COLLATION LIKE 'utf8mb3\\_%' OR TABLE_COLLATION LIKE 'utf8\\_%')
|
||||||
|
ORDER BY TABLE_NAME");
|
||||||
|
$st->execute([$db_name]);
|
||||||
|
$legacy = $st->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
if (!$legacy) { skip("utf8mb4: `{$db_name}` (already converted)"); }
|
||||||
|
foreach ($legacy as $tbl) {
|
||||||
|
run($pdo, "ALTER TABLE `{$db_name}`.`{$tbl}` CONVERT TO CHARACTER SET utf8mb4 COLLATE {$target}", "utf8mb4: {$db_name}.{$tbl}");
|
||||||
|
}
|
||||||
|
run($pdo, "ALTER DATABASE `{$db_name}` CHARACTER SET utf8mb4 COLLATE {$target}", "utf8mb4: database `{$db_name}` default");
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
fail("utf8mb4 conversion of `{$db_name}`: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$pdo->exec("USE `{$db2}`");
|
||||||
|
|
||||||
|
// td_purchase_order totals were decimal(15,2) while its lines are decimal(18,4);
|
||||||
|
// match the other document headers.
|
||||||
|
foreach (['subtotal', 'discount', 'tax', 'shipping_fee', 'grand_total'] as $col) {
|
||||||
|
run($pdo, "ALTER TABLE `{$db2}`.`td_purchase_order` MODIFY `{$col}` decimal(18,4) NOT NULL DEFAULT 0.0000", "td_purchase_order.{$col} decimal(18,4)");
|
||||||
|
}
|
||||||
|
|
||||||
// ── Summary ───────────────────────────────────────────────────────────────────
|
// ── Summary ───────────────────────────────────────────────────────────────────
|
||||||
$total = $ok_count + $skip_count + $err_count;
|
$total = $ok_count + $skip_count + $err_count;
|
||||||
echo "\n\033[1m=== Done ===\033[0m\n";
|
echo "\n\033[1m=== Done ===\033[0m\n";
|
||||||
|
|||||||
Reference in New Issue
Block a user