Compare commits

36 Commits
Author SHA1 Message Date
Thanakorn 5cc43cff92 Merge branch 'fix/qa-review' 2026-09-19 11:00:59 +07:00
Thanakorn c89b28da4c Fix QA review findings: server-side validation, notes encoding, dashboard totals
Validate document lines on the server and recompute their totals, store notes with quotes/markup/emoji (utf8mb4, idempotent escaping, decode in form fields), exclude transfers from company-wide stock in/out, count revenue from confirmed orders only, one low-stock rule everywhere, list unapproved lots, natural bin sort, stable order/PO sort, status tiles that add up.
2026-09-19 10:58:42 +07:00
Thanakorn de760d02da Merge fix/scan2 2026-09-19 07:45:06 +07:00
Thanakorn 3668f22e55 Fix customer return confirm, auto credit note VAT and quotation link
Store the put-away location on return lines (new td_return_item columns, needs setup.php), split auto credit notes into net and VAT with the parent's department, drop the write to a td_quotation column that does not exist, and have the demo seed confirm its customer return.
2026-09-19 07:32:07 +07:00
Thanakorn 114cf73748 Merge fix/manual-journal 2026-09-19 06:56:27 +07:00
Thanakorn 9a8998796c Redirect finance detail pages to their list when opened without an id 2026-09-19 06:33:45 +07:00
Thanakorn 80c7eab0d2 Merge fix/manual-journal 2026-09-19 06:28:11 +07:00
Thanakorn 033846dece Fix stock-out boot, transfer 500, Clear buttons and uncaught engine errors
Return JSON from any uncaught engine exception, stop the empty stock-out warehouse list aborting page boot, reject non-transfer rows in the transfer lookup, define the missing reset_input helper, and remove a stale unreferenced copy of confirm_order.php.
2026-09-18 20:21:34 +07:00
Thanakorn c14822add6 Merge fix/manual-journal 2026-09-18 16:33:35 +07:00
Thanakorn c60b705d98 Fix GL journal save, edit, detail view and list filters
Send journal lines, gl_id and list filters inside the ajax data payload where ajax_request reads them, select period when replacing a manual journal, and show ledger amounts to two decimals.
2026-09-18 16:19:27 +07:00
Thanakorn d8363f6ca7 Merge fix/feedback-16-09 2026-09-17 09:00:37 +07:00
Thanakorn f70f226bd1 Fix timestamps, delete requests, invoice dates and GR quantities
Apply the configured timezone to PHP and both DB connections, wrap
unwrapped ajax payloads so delete buttons reach their engines, normalise
and validate invoice due dates, reject stock quantities below the stored
4dp scale, and list stock movements across all warehouses.
2026-09-17 09:00:15 +07:00
Thanakorn f14c850c70 Merge fix/accounting-feedback 2026-09-15 16:13:55 +07:00
Thanakorn c915379e2e Fix item counts, PR/QT conversions, validation and department loss 2026-09-15 16:11:47 +07:00
Thanakorn 78d69d81df Merge fix/stock-transfer-seed 2026-09-15 13:13:18 +07:00
Thanakorn 693c72f9ea Fix transfer quantity, unify date format, align demo seeds
Stock Transfer list showed 0.00 (read in instead of out); stock-out/transfer forms show the location quantity; dates display as YYYY-MM-DD HH:mm:ss. Demo seeds map product accounts and use product names and supplier batches.
2026-09-15 13:09:19 +07:00
Thanakorn 9512d440dd Merge fix/switch-branch 2026-09-14 17:19:02 +07:00
Thanakorn 45331948c1 Use absolute URLs in invitation emails 2026-09-14 17:18:42 +07:00
Thanakorn ba73456185 Send the chosen company when switching branch 2026-09-14 17:17:40 +07:00
Thanakorn 501c70050f Merge fix/untrack-node-logs 2026-09-14 17:06:16 +07:00
Thanakorn f6909b08eb Stop tracking PM2 log files 2026-09-14 17:05:57 +07:00
Thanakorn 5846c8b282 Merge fix/app-registry-default 2026-09-14 16:58:00 +07:00
Thanakorn 6a271c1f7d Default the app registry when config.php does not define it 2026-09-14 16:57:36 +07:00
Thanakorn 2ef2f32107 Merge fix/retrieve-bin-endpoint 2026-09-14 16:29:56 +07:00
Thanakorn 1f72633cb6 Install libpng, libjpeg and freetype for the gd extension 2026-09-14 16:29:38 +07:00
Thanakorn 4efab9f7c9 Rename retrieve_rack.php to retrieve_bin.php 2026-09-14 16:27:53 +07:00
Thanakorn 44c66c49a5 Merge feature/otp-off-by-default 2026-09-14 15:38:53 +07:00
Thanakorn 6b3a590aa9 Make email OTP login off by default 2026-09-14 15:38:36 +07:00
Thanakorn 21148bf50c Merge feature/onboarding-optional-smtp 2026-09-14 15:27:46 +07:00
Thanakorn cf8106771b Make onboarding SMTP optional when OTP is off 2026-09-14 15:27:01 +07:00
Thanakorn d7203583b7 Merge feature/otp-login-toggle 2026-09-14 15:11:45 +07:00
Thanakorn 9afcf072b0 Add OTP_REQUIRED switch for email OTP login 2026-09-14 15:03:16 +07:00
Thanakorn 2f290ddb26 Merge fix/api-json-notices 2026-09-14 13:33:59 +07:00
Thanakorn 5d021d7683 Accept uppercase channel names in onboarding and company settings 2026-09-14 13:31:26 +07:00
Thanakorn 5ee0c8d41b Keep PHP notices out of login API JSON responses 2026-09-14 12:46:13 +07:00
Thanakorn c3113bc70d Fix login redirect and hide PHP errors on pages 2026-09-14 12:46:13 +07:00
105 changed files with 2359 additions and 1325 deletions
+6
View File
@@ -13,5 +13,11 @@ EMIT_SECRET=
SMTP_USERNAME=
SMTP_PASSWORD=
# Email OTP on sign-in. Off by default; only the exact value "true" turns it on,
# and that needs working SMTP. While off, sign-in is password only (logged as
# OTP_BYPASSED, shown on the login page and top bar).
# Applied to app/config.php by the php container on every start.
OTP_REQUIRED=false
# Port to expose the web app on (default 80)
HTTP_PORT=80
+3
View File
@@ -6,6 +6,9 @@ app/uploads
node_modules/
nodejs/.env
# PM2 runtime logs (written by the node container; nodejs/logs/.gitkeep keeps the folder)
nodejs/logs/*.log
# Docker deploy secrets
/.env
+1 -1
View File
@@ -262,7 +262,7 @@
'<td>' + escape_html(r.doc_number) + '</td>' +
'<td>' + escape_html(r.contact_name || '—') + '</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>' + status_badge + '</td>' +
'</tr>';
+1 -1
View File
@@ -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 });
var bal_color = running >= 0 ? '' : 'text-danger';
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">' + (r.dept_code ? escape_html(r.dept_code) : '<span class="text-muted">—</span>') + '</td>' +
'<td class="small">' + escape_html(r.reference || '—') + '</td>' +
+2 -2
View File
@@ -281,7 +281,7 @@
'<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_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>' +
'<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>' +
@@ -509,7 +509,7 @@
var extra_fields = h.source_type === 'manual'
? '<div class="col-sm-4"><div class="text-muted small">Reference</div><div class="fw-semibold">' + escape_html(h.reference || ('MJE-' + h.id)) + '</div></div>' +
'<div class="col-sm-4"><div class="text-muted small">Journal Date</div><div>' + escape_html(h.journal_date_fmt || '—') + '</div></div>' +
'<div class="col-sm-4"><div class="text-muted small">Journal Date</div><div>' + escape_html(format_date(h.journal_date)) + '</div></div>' +
'<div class="col-sm-4"><div class="text-muted small">Description</div><div>' + escape_html(h.description || '—') + '</div></div>'
: '<div class="col-sm-4"><div class="text-muted small">Formula</div><div>' + escape_html(h.formula_name) + '</div></div>' +
'<div class="col-sm-4"><div class="text-muted small">Version</div><div>v' + h.current_version + (h.current_version > 1 ? ' <span class="text-muted small">(replaced)</span>' : '') + '</div></div>' +
+1 -1
View File
@@ -222,7 +222,7 @@
autoPrepare: true,
checkRequired: 0,
action: 'read',
data: { id: <?php echo (int)$_GET['id']; ?> },
data: { id: <?php echo (int)($_GET['id'] ?? 0); ?> },
onSuccess: function(res) {
var item = res.output;
$('#id').val(item.id);
+1 -1
View File
@@ -89,7 +89,7 @@
autoPrepare: true,
checkRequired: 0,
action: 'read',
data: { id: <?php echo (int)$_GET['id']; ?> },
data: { id: <?php echo (int)($_GET['id'] ?? 0); ?> },
onSuccess: function(res) {
var item = res.output;
$('#id').val(item.id);
+5 -4
View File
@@ -157,8 +157,10 @@
autoPrepare: true,
checkRequired: 0,
action: 'save',
open_from: from,
open_to: to,
// Inside `data`: as top-level options these were ignored, and the save
// only worked because autoPrepare happens to sweep the two inputs, whose
// ids match the field names.
data: { open_from: from, open_to: to },
onSuccess: function() {
render_display(from, to);
}
@@ -173,8 +175,7 @@
autoPrepare: true,
checkRequired: 0,
action: 'save',
open_from: '',
open_to: '',
data: { open_from: '', open_to: '' },
onSuccess: function() {
render_display('', '');
}
+1 -1
View File
@@ -210,7 +210,7 @@
var html = '';
rows.forEach(function(r) {
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"><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>' +
+138 -4
View File
@@ -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&quot; pipe &lt;spare&gt;. Field values
// are never parsed as HTML, so decoding them here is safe.
function decode_html(value) {
if (typeof value !== 'string' || value.indexOf('&') === -1) return value;
return value.replace(/&(quot|#0*39|#x0*27|apos|lt|gt|amp);/gi, function (m, name) {
name = name.toLowerCase();
if (name === 'quot') return '"';
if (name === 'lt') return '<';
if (name === 'gt') return '>';
if (name === 'amp') return '&';
return "'";
});
}
(function ($) {
if (!$ || !$.fn || $.fn.val.__decodes_html) return;
var original_val = $.fn.val;
$.fn.val = function (value) {
if (arguments.length && typeof value === 'string') {
// Only free-text fields; a <select> value must keep matching its option.
var text_fields = this.filter('input, textarea');
if (text_fields.length === this.length) return original_val.call(this, decode_html(value));
}
return original_val.apply(this, arguments);
};
$.fn.val.__decodes_html = true;
})(window.jQuery);
/** =========================
* SIDEBAR ACTIVE STATE
* Override: activate the parent listing page for manage_* sub-pages.
@@ -581,9 +611,19 @@ function load_formula_options(select_id, document_type, selected_id, onLoaded) {
});
}
// Line tax rate; derived from tax_amount / total_price when only the amount was stored
function line_tax_rate(item) {
var rate = parseFloat(item.tax_rate) || 0;
var amount = parseFloat(item.tax_amount) || 0;
var total = parseFloat(item.total_price) || 0;
if (rate === 0 && amount > 0 && total > 0) rate = round_dp(amount / total * 100, 2);
return rate;
}
// Returns the request promise so callers can await the options before selecting a value
function load_departments(select_id, selected_id) {
var ctx = document.getElementById('session-context');
ajax_request({
return ajax_request({
url: server_url + 'accounting/api/engine/department.php',
action: 'get',
queueLock: false,
@@ -762,6 +802,42 @@ function ajax_request(options) {
// Override options.data with the full FormData object
options.data = options.formData;
}
} else if (
options.data && !(options.data instanceof FormData) &&
typeof options.data === 'object' && !('json' in options.data)
) {
// autoPrepare: false with a plain field map — e.g. the delete buttons'
// `data: { id: id }`. db_auth.php only accepts a `json` string or a
// FormData post carrying `otp`, so an unwrapped map was rejected outright
// with "Request denied: No valid JSON payload or Form Data detected.", and
// `options.action` was dropped because only the autoPrepare branch applied
// it. Wrap it the same way here, without touching callers that already
// pass a ready-made `{ json: ... }`.
const session_element = document.getElementById('session-context');
const payload = {};
if (session_element) {
payload['company_id'] = session_element.dataset.companyId;
payload['otp'] = session_element.dataset.otp;
}
Object.entries(options.data).forEach(([key, value]) => {
payload[key] = value;
});
if (options.action) {
payload['action'] = (options.action === 'manage')
? (payload['id'] ? 'update' : 'create')
: options.action;
}
options.data = { json: JSON.stringify(payload) };
if (options.debugMode) {
console.log("REQUEST DATA:", options.data);
}
}
// --- START MODIFIED $.AJAX BLOCK ---
@@ -995,15 +1071,24 @@ document.addEventListener('DOMContentLoaded', () => {
/**
* Helper function to format date strings (assuming input is in ISO format)
*/
// Display format used across the app: YYYY-MM-DD, or YYYY-MM-DD HH:mm:ss when the value has a time.
function format_date(iso_string) {
if (!iso_string) return '—';
var split = iso_string.split(" ");
var split = String(iso_string).split(" ");
var datePart = split[0].split("-");
if (datePart.length !== 3) return iso_string;
var formatted = `${datePart[2]}/${datePart[1]}/${datePart[0]}`;
var formatted = `${datePart[0]}-${datePart[1]}-${datePart[2]}`;
return split.length === 2 ? `${formatted} ${split[1]}` : formatted;
}
// DD/MM/YYYY for filling date inputs (flatpickr dateFormat 'd/m/Y'); to_iso_date() reverses it.
function format_date_input(iso_string) {
if (!iso_string) return '';
var datePart = String(iso_string).split(" ")[0].split("-");
if (datePart.length !== 3) return iso_string;
return `${datePart[2]}/${datePart[1]}/${datePart[0]}`;
}
function to_iso_date(dateStr) {
if (!dateStr) return null;
@@ -1019,6 +1104,36 @@ function to_iso_date(dateStr) {
}
/**
* Clear every field inside a form or form-like container — the "Clear" buttons
* on the master-data pages. It was called by five pages but never defined, so
* each click threw a ReferenceError, and where the container is a <div> rather
* than a <form> (Chart of Accounts, Departments) the button did nothing at all.
* Disabled and hidden inputs are left alone: those carry the record id and
* locked values, not user input.
*/
function reset_input(selector) {
var $scope = $(selector);
if (!$scope.length) return;
$scope.find('input, textarea, select').each(function () {
if (this.disabled || this.type === 'hidden' || this.type === 'button' || this.type === 'submit') return;
if (this._flatpickr) {
this._flatpickr.clear();
} else if (this.type === 'checkbox' || this.type === 'radio') {
this.checked = this.defaultChecked;
} else if (this.tagName === 'SELECT') {
this.selectedIndex = 0;
} else {
this.value = '';
}
$(this).removeAttr('secondary').removeClass('is-invalid is-valid');
});
}
function round_dp(value, places) {
var factor = Math.pow(10, places);
return Math.round((Number(value) + Number.EPSILON) * factor) / factor;
@@ -1054,6 +1169,25 @@ function expand_exponential_number(value) {
return sign + digits.slice(0, point) + '.' + digits.slice(point);
}
/**
* Format a stock quantity without hiding real data.
*
* Quantity columns are decimal(18,4), so a genuine 0.0001 exists. Formatting
* every quantity at 2 dp printed such a value as "0.00", which reads as "no
* data" — the stock popups and list pages all showed an empty-looking QTY for
* a receipt that had in fact been made. Show 2 dp normally, and the stored
* 4 dp whenever rounding to 2 would lose something.
*/
function format_quantity(value) {
var n = Number(value);
if (isNaN(n)) return '--';
return (round_dp(n, 2) !== round_dp(n, 4))
? format_number(n, 4)
: format_number(n, 2);
}
function format_number(value, decimal) {
var n = Number(value);
if (isNaN(n)) return '--';
@@ -1165,7 +1299,7 @@ function show_stock_rows(source, source_id, label) {
<td>${escape_html(r.warehouse_name)}</td>
<td><small>${escape_html(location)}</small></td>
<td><small>${escape_html(r.lot_number || '—')}</small></td>
<td class="text-end fw-semibold">${format_number(r.quantity, 2)}</td>
<td class="text-end fw-semibold">${format_quantity(r.quantity)}</td>
<td>${status_badge}</td>
<td><small>${format_date(r.date)}</small></td>
</tr>`;
+22
View File
@@ -0,0 +1,22 @@
<?php
// app/assets/utils/app_registry.php
//
// The apps a user can be given access to (user.app_access and
// company_map_user.app_access), with the label, icon and badge colour the
// Users Access page shows for each.
//
// config.php may define its own $app_registry; this file only fills it in when
// it is missing or empty — which is every Docker-generated config.php written
// before the setting was documented. Without it the Add User dialog breaks
// (Object.entries(null) in setting/users.php) and inviting a user fails
// (array_keys(null) in setting/api/engine/manage_users.php).
//
// Keys must stay within the user.app_access enum: 'wms' and 'accounting'
// ('all' is implied and never listed here).
if (!isset($app_registry) || !is_array($app_registry) || !$app_registry) {
$app_registry = [
'wms' => ['label' => 'WMS', 'icon' => 'ti-box', 'color' => 'bg-label-primary'],
'accounting' => ['label' => 'Accounting', 'icon' => 'ti-calculator', 'color' => 'bg-label-success'],
];
}
@@ -99,7 +99,7 @@ class CompanyProfileManager
public function saveProfile(array $data, string $company_logo, string $company_seal): void
{
$channel = strtolower(preg_replace('/[^a-z0-9\-_]/', '', $data['channel_name'] ?? ''));
$channel = preg_replace('/[^a-z0-9\-_]/', '', strtolower(trim($data['channel_name'] ?? '')));
$sth = $this->pdo->prepare(
"UPDATE company_list SET
@@ -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;
}
}
+88 -7
View File
@@ -403,12 +403,47 @@ class InvoiceManager {
* @param array $logging Audit entry.
* @throws Exception If invoice not found or not in draft status.
*/
/**
* Normalise a client-supplied date to ISO YYYY-MM-DD and reject anything
* that is not a real calendar date.
*
* The date pickers display d/m/Y, and a page that forgets to convert before
* posting sends that text straight through to a MySQL DATE column, where it
* fails as a PDOException and surfaces to the user as the opaque
* "Database error, please try again." Accepting both spellings here keeps
* the failure mode a named, actionable message instead.
*
* @param string $value ISO or d/m/Y date; '' is treated as "not set".
* @param string $label Field name used in the error message.
* @return string|null ISO date, or null when nothing was supplied.
* @throws Exception When the value is not a valid date.
*/
private function normaliseDate(string $value, string $label): ?string
{
$value = trim($value);
if ($value === '') return null;
// Strip a time part, if the caller passed a datetime.
$value = explode(' ', $value)[0];
foreach (['Y-m-d', 'd/m/Y'] as $format) {
$parsed = DateTime::createFromFormat('!' . $format, $value);
// createFromFormat() accepts overflowing values such as 32/01/2026
// and rolls them over, so compare the round-trip to reject those.
if ($parsed && $parsed->format($format) === $value) {
return $parsed->format('Y-m-d');
}
}
throw new Exception("{$label} is not a valid date.");
}
public function saveInvoice(array $data, array $logging): void
{
$id = (int)($data['id'] ?? 0);
$sth = $this->pdo->prepare(
"SELECT status, doc_type, issued_date, `log` FROM td_invoice
"SELECT status, doc_type, issued_date, due_date, `log` FROM td_invoice
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
@@ -428,8 +463,21 @@ class InvoiceManager {
$formula_id = isset($data['formula_id']) && (int)$data['formula_id'] > 0
? (int)$data['formula_id'] : null;
// Absent key means "not being edited" — keep what is stored rather than
// clearing it, so a caller that posts only tax_adjustment cannot wipe
// the agreed payment term.
$due_date = array_key_exists('due_date', $data)
? $this->normaliseDate((string)$data['due_date'], 'Due date')
: ($row['due_date'] ?: null);
$issued_date = $row['issued_date'] ?: null;
if ($due_date !== null && $issued_date !== null && $due_date < $issued_date) {
throw new Exception("The due date cannot be earlier than the issue date.");
}
$params = [
':due_date' => $data['due_date'] ?: null,
':due_date' => $due_date,
':notes' => $data['notes'] ?? '',
':formula_id' => $formula_id,
':log' => json_encode($log),
@@ -525,6 +573,11 @@ class InvoiceManager {
if (in_array($row['doc_type'], ['invoice', 'purchase_invoice']) && !$due_date) {
throw new Exception("Due date is required before issuing this document.");
}
$due_date = $this->normaliseDate((string)($due_date ?? ''), 'Due date');
if ($due_date !== null && $due_date < $issued_date) {
throw new Exception("The due date cannot be earlier than the issue date.");
}
$this->assertPostingWindow($issued_date, ucfirst(str_replace('_', ' ', $row['doc_type'])));
$log = json_decode($row['log'] ?? '[]', true) ?: [];
@@ -852,18 +905,46 @@ class InvoiceManager {
$log = [array_merge($logging, ['action' => 'create_credit_note'])];
// Split the credited amount into net and VAT from the lines being
// credited. This used to store the whole VAT-inclusive amount as the
// subtotal with tax = 0, so the header disagreed with its own lines: the
// VAT report missed the output-tax reversal, and a GL formula posting
// from the header reversed revenue by the gross figure.
//
// The VAT is taken as "amount minus net" so the grand total still
// equals the caller's amount exactly, including any rounding adjustment
// the return carried; that adjustment is recorded as tax_adjustment.
// With no priced lines to split by, the amount is kept whole as before.
$net = round(array_reduce($items, fn($c, $i) => $c + (float)($i['total_price'] ?? 0), 0.0), 4);
$line_tax = round(array_reduce($items, fn($c, $i) => $c + (float)($i['tax_amount'] ?? 0), 0.0), 2);
if ($net > 0 && $net <= abs($amount) + 0.005) {
$subtotal = $net;
$tax = round(abs($amount) - $net, 4);
$tax_adj = round($tax - $line_tax, 2);
} else {
$subtotal = abs($amount);
$tax = 0.0;
$tax_adj = 0.0;
}
$this->pdo->prepare(
"INSERT INTO td_invoice
(company_id, uuid, source_id, `source`, doc_type, invoice_number, ref_invoice_id,
order_id, contact_id, issued_date, due_date,
subtotal, discount, tax, shipping_fee, grand_total,
order_id, contact_id, department_id, issued_date, due_date,
subtotal, discount, tax, tax_adjustment, shipping_fee, grand_total,
status, notes, `log`)
VALUES
(:company_id, :uuid, :source_id, :source, 'credit_note', :invoice_number, :ref_invoice_id,
:order_id, :contact_id, :issued_date, NULL,
:amount, 0, 0, 0, :grand_total,
:order_id, :contact_id, :department_id, :issued_date, NULL,
:amount, 0, :tax, :tax_adjustment, 0, :grand_total,
1, '', :log)"
)->execute([
// The credit note belongs to the same department as the invoice it
// corrects; it was left at 0 before.
':department_id' => (int)($parent['department_id'] ?? 0),
':tax' => $tax,
':tax_adjustment' => $tax_adj,
':company_id' => $this->company_id,
':uuid' => bin2hex(random_bytes(16)),
':source_id' => $source_id,
@@ -873,7 +954,7 @@ class InvoiceManager {
':order_id' => (int)$parent['order_id'],
':contact_id' => (int)$parent['contact_id'],
':issued_date' => $issued_date,
':amount' => $amount,
':amount' => $subtotal,
':grand_total' => -abs($amount), // negative for net-balance queries
':log' => json_encode($log),
]);
+34 -9
View File
@@ -1,5 +1,6 @@
<?php
require_once __DIR__ . '/DocumentNumberManager.php';
require_once __DIR__ . '/DocumentValidator.php';
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
/**
@@ -260,13 +261,16 @@ class OrderManager {
{
$sth = $this->pdo->prepare(
"SELECT o.*,
COALESCE(c.contact_name, '') AS contact_name
COALESCE(c.contact_name, '') AS contact_name,
(SELECT COUNT(*) FROM td_order_item i
WHERE i.company_id = o.company_id
AND i.order_id = o.id) AS item_count
FROM td_order o
LEFT JOIN md_contact c
ON c.company_id = o.company_id
AND c.id = o.contact_id
WHERE o.company_id = :company_id
ORDER BY o.created_at DESC"
ORDER BY o.order_date DESC, o.id DESC"
);
$sth->execute([':company_id' => $this->company_id]);
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
@@ -403,12 +407,23 @@ class OrderManager {
return $returnable;
}
/**
* Mark an accepted quotation as converted once an order has been created
* from it.
*
* The link itself lives on the order (td_order.source = 'quotation',
* source_id = quotation id), which saveOrder() has already written and
* QuotationManager::getById() joins on. This used to also write
* td_quotation.order_id — a column that has never existed — so saving an
* order with source=quotation failed with "Unknown column" and rolled the
* new order back with it.
*/
public function linkQuotationToOrder(int $quotation_id, int $order_id): void
{
$this->pdo->prepare(
"UPDATE td_quotation SET order_id = :order_id, status = 5
"UPDATE td_quotation SET status = 5
WHERE id = :id AND company_id = :cid AND status = 2"
)->execute([':order_id' => $order_id, ':id' => $quotation_id, ':cid' => $this->company_id]);
)->execute([':id' => $quotation_id, ':cid' => $this->company_id]);
}
public function assertRevenueOrderEditable(int $order_id): void
@@ -471,13 +486,18 @@ class OrderManager {
public function saveOrder(array $data, array $logging): int
{
$id = (int)($data['id'] ?? 0);
$items = $data['items'] ?? [];
$items = DocumentValidator::normaliseLines($data['items'] ?? [], 'Order');
$data['items'] = $items;
DocumentValidator::requireId($data['contact_id'] ?? 0, 'Contact is required.');
if ($id === 0 || array_key_exists('department_id', $data)) {
$data['department_id'] = DocumentValidator::requireDepartment($this->pdo, $this->company_id, $data['department_id'] ?? 0);
}
// Calculate totals from items
$subtotal = array_reduce($items, fn($carry, $item) =>
$carry + (float)($item['total_price'] ?? 0), 0.0
);
$discount = (float)($data['discount'] ?? 0);
$discount = DocumentValidator::discount($data['discount'] ?? 0, $subtotal);
$tax_adjustment = (float)($data['tax_adjustment'] ?? 0);
if (abs($tax_adjustment) > 0.30) {
throw new Exception("Tax adjustment cannot exceed ±0.30.");
@@ -485,7 +505,7 @@ class OrderManager {
$tax = round(array_reduce($items, fn($carry, $item) =>
$carry + (float)($item['tax_amount'] ?? 0), 0.0
), 2) + $tax_adjustment;
$shipping_fee = (float)($data['shipping_fee'] ?? 0);
$shipping_fee = DocumentValidator::amount($data['shipping_fee'] ?? 0, 'Shipping fee');
$tracking_no = trim((string)($data['shipping_tracking_number'] ?? ''));
$grand_total = $subtotal - $discount + $tax + $shipping_fee;
@@ -493,7 +513,7 @@ class OrderManager {
// Fetch existing row to check status and load log
$sth = $this->pdo->prepare(
"SELECT status, `log` FROM td_order
"SELECT status, department_id, `log` FROM td_order
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
@@ -502,6 +522,11 @@ class OrderManager {
if (!$row) {
throw new Exception("Order not found.");
}
// Pages without a department field (Revenue SO) must not wipe the stored one
$department_id = array_key_exists('department_id', $data)
? (int)$data['department_id']
: (int)$row['department_id'];
$cur_status = (int)$row['status'];
if ($cur_status !== 0 && $cur_status !== -2) {
throw new Exception("Only draft or pending orders can be edited.");
@@ -541,7 +566,7 @@ class OrderManager {
WHERE id = :id AND company_id = :company_id"
)->execute([
':contact_id' => (int)($data['contact_id'] ?? 0),
':department_id' => (int)($data['department_id'] ?? 0),
':department_id' => $department_id,
':order_date' => $data['order_date'] ?? date('Y-m-d'),
':subtotal' => $subtotal,
':discount' => $discount,
+3 -3
View File
@@ -569,9 +569,9 @@ class ProductManager {
ON p.company_id = r.company_id
AND p.sku = r.product_sku
WHERE r.company_id = :company_id
ORDER BY mw.warehouse_name, r.zone,
CAST(r.aisle AS UNSIGNED), r.aisle,
CAST(r.bin AS UNSIGNED), r.bin"
ORDER BY mw.warehouse_name, REGEXP_REPLACE(r.zone, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.zone, '[0-9]+$') AS UNSIGNED), r.zone,
REGEXP_REPLACE(r.aisle, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.aisle, '[0-9]+$') AS UNSIGNED), r.aisle,
REGEXP_REPLACE(r.bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.bin, '[0-9]+$') AS UNSIGNED), r.bin"
);
$sth->execute([':company_id' => $this->company_id]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
@@ -1,6 +1,8 @@
<?php
require_once __DIR__ . '/DocumentNumberManager.php';
require_once __DIR__ . '/DocumentValidator.php';
require_once __DIR__ . '/WarehouseManager.php';
require_once __DIR__ . '/StockManager.php';
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
/**
@@ -194,13 +196,16 @@ class PurchaseOrderManager {
{
$sth = $this->pdo->prepare(
"SELECT p.*,
COALESCE(c.contact_name, '') AS contact_name
COALESCE(c.contact_name, '') AS contact_name,
(SELECT COUNT(*) FROM td_purchase_order_item i
WHERE i.company_id = p.company_id
AND i.order_id = p.id) AS item_count
FROM td_purchase_order p
LEFT JOIN md_contact c
ON c.company_id = p.company_id
AND c.id = p.contact_id
WHERE p.company_id = :company_id
ORDER BY p.created_at DESC"
ORDER BY p.po_date DESC, p.id DESC"
);
$sth->execute([':company_id' => $this->company_id]);
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
@@ -321,7 +326,12 @@ class PurchaseOrderManager {
public function savePo(array $data, array $logging): int
{
$id = (int)($data['id'] ?? 0);
$items = $data['items'] ?? [];
$items = DocumentValidator::normaliseLines($data['items'] ?? [], 'Purchase order');
$data['items'] = $items;
DocumentValidator::requireId($data['contact_id'] ?? 0, 'Supplier is required.');
if ($id === 0 || array_key_exists('department_id', $data)) {
$data['department_id'] = DocumentValidator::requireDepartment($this->pdo, $this->company_id, $data['department_id'] ?? 0);
}
$skus = array_filter(array_column($items, 'product_sku'));
if (count($skus) !== count(array_unique($skus))) {
@@ -331,7 +341,7 @@ class PurchaseOrderManager {
$subtotal = array_reduce($items, fn($carry, $item) =>
$carry + (float)($item['total_price'] ?? 0), 0.0
);
$discount = (float)($data['discount'] ?? 0);
$discount = DocumentValidator::discount($data['discount'] ?? 0, $subtotal);
$tax_adjustment = (float)($data['tax_adjustment'] ?? 0);
if (abs($tax_adjustment) > 0.30) {
throw new Exception("Tax adjustment cannot exceed ±0.30.");
@@ -339,7 +349,7 @@ class PurchaseOrderManager {
$tax = round(array_reduce($items, fn($carry, $item) =>
$carry + (float)($item['tax_amount'] ?? 0), 0.0
), 2) + $tax_adjustment;
$shipping_fee = (float)($data['shipping_fee'] ?? 0);
$shipping_fee = DocumentValidator::amount($data['shipping_fee'] ?? 0, 'Shipping fee');
$grand_total = $subtotal - $discount + $tax + $shipping_fee;
if ($id > 0) {
@@ -572,7 +582,16 @@ class PurchaseOrderManager {
$warehouse_id = (int)($recv['warehouse_id'] ?? $po['warehouse_id']);
$quantity = (float)($recv['quantity'] ?? 0);
if ($quantity <= 0) continue;
// A blank line is a line the user chose not to receive — skip it.
if ($quantity == 0) continue;
// Anything positive has to survive the decimal(18,4) columns it is
// about to be written to. Without this, 0.0000001 was accepted, was
// stored as 0.0000, produced a stock movement of nothing, and still
// advanced received_qty enough to leave the PO stuck on "Partial".
$name = $po_items[$po_items_by_id[$item_id] ?? -1]['product_name'] ?? $product_sku;
$quantity = StockManager::normaliseQuantity($quantity, "Receiving quantity for \"{$name}\"");
if (!$product_sku) throw new Exception("Item #{$j}: missing product_sku.");
if (!$warehouse_id) throw new Exception("Item #{$j}: missing warehouse_id.");
@@ -597,6 +616,16 @@ class PurchaseOrderManager {
$zone = $recv['zone'] ?? '';
$aisle = $recv['aisle'] ?? '';
// Expiry dates live on md_lot, keyed by lot number, so an expiry
// entered without one is silently dropped and the received stock
// shows no expiry at all. Say so instead of discarding it.
if (trim((string)($recv['expiry_date'] ?? '')) !== ''
&& trim((string)($recv['lot_number'] ?? '')) === '') {
throw new Exception(
"Enter a lot number for \"{$name}\" — an expiry date is recorded against its lot."
);
}
// Simple location mode: zone and aisle must mirror the bin value
// (same convention as manage_stock_in.php).
// occupyBin() looks up md_bin WHERE zone=:zone AND aisle=:aisle AND bin=:bin,
@@ -1,5 +1,6 @@
<?php
require_once __DIR__ . '/DocumentNumberManager.php';
require_once __DIR__ . '/DocumentValidator.php';
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
/**
@@ -94,7 +95,10 @@ class PurchaseRequestManager
WHERE p.company_id = r.company_id
AND p.source = 'purchase_request'
AND p.source_id = r.id
AND p.status != -1) AS linked_po_count
AND p.status != -1) AS linked_po_count,
(SELECT COUNT(*) FROM td_purchase_request_item i
WHERE i.company_id = r.company_id
AND i.request_id = r.id) AS item_count
FROM td_purchase_request r
LEFT JOIN md_contact c
ON c.company_id = r.company_id AND c.id = r.contact_id
@@ -160,12 +164,22 @@ class PurchaseRequestManager
public function save(array $data, array $logging): int
{
$id = (int)($data['id'] ?? 0);
$items = $data['items'] ?? [];
$discount = (float)($data['discount'] ?? 0);
$shipping_fee = (float)($data['shipping_fee'] ?? 0);
$items = DocumentValidator::normaliseLines($data['items'] ?? [], 'Purchase request');
$data['items'] = $items;
$discount = DocumentValidator::discount($data['discount'] ?? 0, array_sum(array_column($items, 'total_price')));
$shipping_fee = DocumentValidator::amount($data['shipping_fee'] ?? 0, 'Shipping fee');
if ($id === 0 || array_key_exists('department_id', $data)) {
$data['department_id'] = DocumentValidator::requireDepartment($this->pdo, $this->company_id, $data['department_id'] ?? 0);
}
if (empty($items)) throw new Exception('At least one item is required.');
$request_date = (string)($data['request_date'] ?? '');
$required_date = (string)($data['required_date'] ?? '');
if ($request_date !== '' && $required_date !== '' && $required_date < $request_date) {
throw new Exception('Required date cannot be earlier than the request date.');
}
[$subtotal, $tax, $grand] = $this->computeTotals($items, $discount, $shipping_fee);
if ($id === 0) {
+26 -3
View File
@@ -1,5 +1,6 @@
<?php
require_once __DIR__ . '/DocumentNumberManager.php';
require_once __DIR__ . '/DocumentValidator.php';
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
/**
@@ -89,7 +90,10 @@ class QuotationManager
public function getList(): array
{
$sth = $this->pdo->prepare(
"SELECT q.*, COALESCE(c.contact_name, '') AS contact_name
"SELECT q.*, COALESCE(c.contact_name, '') AS contact_name,
(SELECT COUNT(*) FROM td_quotation_item i
WHERE i.company_id = q.company_id
AND i.quotation_id = q.id) AS item_count
FROM td_quotation q
LEFT JOIN md_contact c
ON c.id = q.contact_id AND c.company_id = q.company_id
@@ -170,8 +174,27 @@ class QuotationManager
public function save(array $data, array $logging): int
{
$id = (int)($data['id'] ?? 0);
$items = $data['items'] ?? [];
$discount = (float)($data['discount'] ?? 0);
$items = DocumentValidator::normaliseLines($data['items'] ?? [], 'Quotation');
$data['items'] = $items;
$discount = DocumentValidator::discount($data['discount'] ?? 0, array_sum(array_column($items, 'total_price')));
$quotation_date = (string)($data['quotation_date'] ?? '');
$valid_until = (string)($data['valid_until'] ?? '');
if ((int)($data['contact_id'] ?? 0) <= 0) {
throw new Exception('Contact is required.');
}
if ($quotation_date === '') {
throw new Exception('Quotation date is required.');
}
if ($valid_until !== '' && $valid_until < $quotation_date) {
throw new Exception('Valid until cannot be earlier than the quotation date.');
}
if ((int)($data['department_id'] ?? 0) <= 0) {
throw new Exception('Department is required.');
}
if (empty($items)) {
throw new Exception('At least one line item is required.');
}
[$subtotal, $tax, $grand] = $this->computeTotals($items, $discount);
+127 -51
View File
@@ -35,6 +35,8 @@ class ReportManager
// Private helpers
// ─────────────────────────────────────────────────────────────
private ?array $transfer_totals = null;
private function stockTableNameFromWarehouseId(int $warehouse_id): string
{
if ($warehouse_id <= 0) {
@@ -45,6 +47,61 @@ class ReportManager
}
/**
* Approved warehouse-to-warehouse transfer quantities, per month and SKU.
*
* A transfer is stored as an `out` row in the source warehouse and an `in`
* row in the destination, and both reach etl_stock_summary, which is right
* for each warehouse's balance. Company-wide "Stock In / Stock Out" figures
* must leave them out: the goods were already counted when first received,
* and moving them between warehouses is neither a receipt nor an issue.
*
* @return array [month => [sku => ['in' => float, 'out' => float]]]
*/
private function transferTotals(): array
{
if ($this->transfer_totals !== null) return $this->transfer_totals;
$totals = [];
$sth = $this->pdo->prepare("SELECT id FROM md_warehouse WHERE company_id = :company_id");
$sth->execute([':company_id' => $this->company_id]);
foreach ($sth->fetchAll(PDO::FETCH_COLUMN) as $wh_id) {
$table = $this->stockTableNameFromWarehouseId((int)$wh_id);
try {
$rows = $this->fetchAll(
"SELECT DATE_FORMAT(`date`, '%Y-%m') AS month, product_sku,
SUM(`in`) AS qty_in, SUM(`out`) AS qty_out
FROM `{$table}`
WHERE company_id = :company_id AND status = 1 AND type = 'transfer'
GROUP BY month, product_sku"
);
} catch (PDOException $e) {
continue; // warehouse without a stock table yet
}
foreach ($rows as $r) {
$slot = &$totals[$r['month']][$r['product_sku']];
$slot['in'] = ($slot['in'] ?? 0) + (float)$r['qty_in'];
$slot['out'] = ($slot['out'] ?? 0) + (float)$r['qty_out'];
unset($slot);
}
}
return $this->transfer_totals = $totals;
}
/** Transfer in/out summed over the given month (null = all months). */
private function transferSum(?string $month = null, ?string $sku = null): array
{
$in = 0.0; $out = 0.0;
foreach ($this->transferTotals() as $m => $by_sku) {
if ($month !== null && $m !== $month) continue;
foreach ($by_sku as $k => $t) {
if ($sku !== null && (string)$k !== $sku) continue;
$in += $t['in']; $out += $t['out'];
}
}
return ['in' => $in, 'out' => $out];
}
private function resolveWarehouseTable(int $warehouse_id): ?string
{
$sth = $this->pdo->prepare(
@@ -297,15 +354,7 @@ class ReportManager
*/
public function getLowStockCount(): int
{
$products = $this->getStockBalance();
$count = 0;
foreach ($products as $product) {
$balance = (float) $product["total_in"] - (float) $product["total_out"];
if ($balance < (float) $product["min_stock"]) {
$count++;
}
}
return $count;
return count($this->getLowStockItems());
}
public function getDashboardStockTotals(): array
@@ -318,13 +367,20 @@ class ReportManager
WHERE company_id = :company_id"
);
$sth->execute([':company_id' => $this->company_id]);
return $sth->fetch(PDO::FETCH_ASSOC) ?: ['total_in' => 0, 'total_out' => 0];
$row = $sth->fetch(PDO::FETCH_ASSOC) ?: ['total_in' => 0, 'total_out' => 0];
$transfers = $this->transferSum();
return [
'total_in' => round(max(0, (float)$row['total_in'] - $transfers['in']), 2),
'total_out' => round(max(0, (float)$row['total_out'] - $transfers['out']), 2),
];
}
public function getDashboardOrderStats(): array
{
$sth = $this->pdo->prepare(
"SELECT COUNT(*), COALESCE(SUM(subtotal), 0)
// Orders are counted unless cancelled; revenue only once confirmed —
// a draft or pending order is not a sale yet.
"SELECT COUNT(*), COALESCE(SUM(CASE WHEN status >= 1 THEN subtotal ELSE 0 END), 0)
FROM td_order
WHERE company_id = :company_id
AND status != -1"
@@ -400,6 +456,13 @@ class ReportManager
*
* @return array Low/critical stock items with warehouse_name, product_name, balance, status.
*/
/*
* The one definition of "low stock", shared by the dashboard tile, the Low
* Stock page, the warehouse overview tile and the daily alert: an active
* product in an active warehouse whose balance there is at or below its
* reorder point (or minimum stock, whichever is higher). Each screen used
* to apply its own threshold and grouping, so the counts never matched.
*/
public function getLowStockItems(): array
{
$sql = "SELECT
@@ -420,11 +483,13 @@ class ReportManager
ON wb.company_id = mw.company_id
AND wb.warehouse_id = mw.id
WHERE wb.company_id = :company_id
AND mp.reorder_point > 0
AND mp.status > 0
AND mw.status = 1
AND GREATEST(mp.reorder_point, mp.min_stock) > 0
GROUP BY
wb.warehouse_id, wb.product_sku, mw.warehouse_name,
mp.product_name, mp.min_stock, mp.reorder_point, mp.product_image, mp.cost_price
HAVING balance <= mp.reorder_point
HAVING balance <= GREATEST(mp.reorder_point, mp.min_stock)
ORDER BY mp.product_name ASC, mw.warehouse_name ASC";
$rows = $this->fetchAll($sql);
@@ -498,9 +563,13 @@ class ReportManager
AND month = :month"
);
$sth->execute([':company_id' => $this->company_id, ':month' => $month]);
return $sth->fetch(PDO::FETCH_ASSOC) ?: [
$row = $sth->fetch(PDO::FETCH_ASSOC) ?: [
'total_in' => 0, 'total_out' => 0, 'active_products' => 0
];
$transfers = $this->transferSum($month);
$row['total_in'] = round(max(0, (float)$row['total_in'] - $transfers['in']), 2);
$row['total_out'] = round(max(0, (float)$row['total_out'] - $transfers['out']), 2);
return $row;
}
/**
@@ -568,6 +637,9 @@ class ReportManager
$dataByMonth = [];
foreach ($rows as $row) {
$transfers = $this->transferSum($row['month']);
$row['stock_in'] = round(max(0, (float)$row['stock_in'] - $transfers['in']), 2);
$row['stock_out'] = round(max(0, (float)$row['stock_out'] - $transfers['out']), 2);
$dataByMonth[$row['month']] = $row;
}
@@ -611,15 +683,22 @@ class ReportManager
AND pc.id = p.category
WHERE wb.company_id = :company_id
AND wb.month = :month
GROUP BY wb.product_sku, p.product_name, pc.category
ORDER BY total_out DESC
LIMIT {$limit}"
GROUP BY wb.product_sku, p.product_name, pc.category"
);
$sth->execute([
':company_id' => $this->company_id,
':month' => $month,
]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as &$row) {
$transfers = $this->transferSum($month, (string)$row['product_sku']);
$row['total_in'] = round(max(0, (float)$row['total_in'] - $transfers['in']), 2);
$row['total_out'] = round(max(0, (float)$row['total_out'] - $transfers['out']), 2);
}
unset($row);
$rows = array_values(array_filter($rows, fn($r) => $r['total_in'] > 0 || $r['total_out'] > 0));
usort($rows, fn($a, $b) => $b['total_out'] <=> $a['total_out'] ?: $b['total_in'] <=> $a['total_in']);
return array_slice($rows, 0, $limit);
}
/**
@@ -668,8 +747,8 @@ class ReportManager
$cid = (int) $this->company_id;
$warehouse_name = $this->pdo->quote($wh['warehouse_name']);
return "SELECT s.date, s.product_sku, s.type,
ROUND(COALESCE(s.`in`, 0), 2) AS stock_in,
ROUND(COALESCE(s.`out`, 0), 2) AS stock_out,
COALESCE(s.`in`, 0) AS stock_in,
COALESCE(s.`out`, 0) AS stock_out,
p.product_name,
{$warehouse_name} AS warehouse_name
FROM `{$table}` s
@@ -677,7 +756,8 @@ class ReportManager
ON p.company_id = s.company_id
AND p.sku = s.product_sku
WHERE s.company_id = {$cid}
AND s.status = 1";
AND s.status = 1
AND (s.`in` > 0 OR s.`out` > 0)";
},
$warehouses
));
@@ -691,6 +771,8 @@ class ReportManager
$items = [];
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
// Decided on the unrounded quantity: a receipt of 0.004 used to round
// to 0.00, fall through to "out" and show as -0.
$is_in = (float)$row['stock_in'] > 0;
$items[] = [
'product_name' => $row['product_name'] ?: $row['product_sku'],
@@ -771,25 +853,10 @@ class ReportManager
*/
public function getWarehouseLowStockCount(int $warehouse_id): int
{
$sth = $this->pdo->prepare(
"SELECT wb.product_sku,
ROUND(SUM(wb.total_in) - SUM(wb.total_out), 2) AS balance,
mp.min_stock
FROM etl_stock_summary wb
INNER JOIN md_product mp
ON mp.company_id = wb.company_id
AND mp.sku = wb.product_sku
WHERE wb.company_id = :company_id
AND wb.warehouse_id = :warehouse_id
GROUP BY wb.product_sku, mp.min_stock"
);
$sth->execute([':company_id' => $this->company_id, ':warehouse_id' => $warehouse_id]);
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
$count = 0;
foreach ($rows as $row) {
if ((float)$row['balance'] < (float)$row['min_stock']) $count++;
}
return $count;
return count(array_filter(
$this->getLowStockItems(),
fn($item) => $item['warehouse_id'] === $warehouse_id
));
}
/**
@@ -1181,16 +1248,19 @@ class ReportManager
$table = $this->stockTableNameFromWarehouseId((int)$wh['id']);
$sth = $this->pdo->query(
"SELECT lot_number,
ROUND(SUM(COALESCE(`in`, 0)) - SUM(COALESCE(`out`, 0)), 2) AS lot_balance
// Every row of the lot makes it listable; only approved rows
// count towards the balance. A lot received but not yet
// approved used to vanish here while the lot master showed it.
// Keyed by SKU as well: two products may share a lot number.
"SELECT product_sku, lot_number,
ROUND(SUM(CASE WHEN status = 1 THEN COALESCE(`in`, 0) - COALESCE(`out`, 0) ELSE 0 END), 4) AS lot_balance
FROM `{$table}`
WHERE company_id = {$cid}
AND status = 1
AND lot_number IS NOT NULL
GROUP BY lot_number"
AND lot_number <> ''
GROUP BY product_sku, lot_number"
);
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $lb) {
$key = $lb['lot_number'];
$key = $lb['product_sku'] . "\0" . $lb['lot_number'];
$lot_balance[$key] = ($lot_balance[$key] ?? 0) + (float)$lb['lot_balance'];
}
}
@@ -1217,16 +1287,22 @@ class ReportManager
$active = $expired = $near = 0;
// Exclude lots that have no td_stock record at all (e.g. all rows were soft-deleted)
$rows = array_values(array_filter($rows, fn($r) => array_key_exists($r['lot_number'], $lot_balance)));
$lot_key = fn($r) => $r['product_sku'] . "\0" . $r['lot_number'];
$rows = array_values(array_filter($rows, fn($r) => array_key_exists($lot_key($r), $lot_balance)));
foreach ($rows as &$row) {
$days = (int)$row['days_remaining'];
$balance = round($lot_balance[$row['lot_number']] ?? 0, 2);
$balance = round($lot_balance[$lot_key($row)] ?? 0, 4);
$row['balance'] = $balance;
$row['is_active'] = $balance > 0 ? 1 : 0;
if ($balance > 0) $active++;
if ($row['expiry_date'] === null || $row['expiry_date'] === '') {
// No expiry recorded: not "expiring today"
$row['status'] = 'ok';
continue;
}
if ($days < 0) $expired++;
if ($days >= 0 && $days <= 30) $near++;
@@ -1324,9 +1400,9 @@ class ReportManager
ON p.company_id = r.company_id
AND p.sku = r.product_sku
WHERE r.company_id = :company_id
ORDER BY mw.warehouse_name, r.zone,
CAST(r.aisle AS UNSIGNED), r.aisle,
CAST(r.bin AS UNSIGNED), r.bin"
ORDER BY mw.warehouse_name, REGEXP_REPLACE(r.zone, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.zone, '[0-9]+$') AS UNSIGNED), r.zone,
REGEXP_REPLACE(r.aisle, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.aisle, '[0-9]+$') AS UNSIGNED), r.aisle,
REGEXP_REPLACE(r.bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.bin, '[0-9]+$') AS UNSIGNED), r.bin"
);
$sth->execute([':company_id' => $this->company_id]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
+31 -3
View File
@@ -122,13 +122,28 @@ class ReturnManager {
$sth = $this->pdo->prepare(
"INSERT INTO td_return_item
(company_id, return_id, item_id, product_sku, product_name,
quantity, unit_price, total_price, tax_amount, tax_rate, warehouse_id, stock_out_id, stock_out_warehouse_id)
quantity, unit_price, total_price, tax_amount, tax_rate, warehouse_id, stock_out_id, stock_out_warehouse_id,
zone, aisle, bin)
VALUES
(:company_id, :return_id, :item_id, :product_sku, :product_name,
:quantity, :unit_price, :total_price, :tax_amount, :tax_rate, :warehouse_id, :stock_out_id, :stock_out_warehouse_id)"
:quantity, :unit_price, :total_price, :tax_amount, :tax_rate, :warehouse_id, :stock_out_id, :stock_out_warehouse_id,
:zone, :aisle, :bin)"
);
foreach ($items as $pos => $item) {
// Put-away location chosen on the form. In simple location mode
// the page sends only the bin; zone and aisle mirror it, the same
// convention stock-in and goods receipt use, because md_bin is
// looked up on all three.
$bin = trim((string)($item['bin'] ?? ''));
$zone = trim((string)($item['zone'] ?? ''));
$aisle = trim((string)($item['aisle'] ?? ''));
if ($zone === '' && $bin !== '') $zone = $bin;
if ($aisle === '' && $bin !== '') $aisle = $bin;
$sth->execute([
':zone' => $zone,
':aisle' => $aisle,
':bin' => $bin,
':company_id' => $this->company_id,
':return_id' => $return_id,
':item_id' => $pos + 1,
@@ -169,7 +184,10 @@ class ReturnManager {
$sth = $this->pdo->prepare(
"SELECT r.*,
COALESCE(c.contact_name, '') AS contact_name,
o.order_number
o.order_number,
(SELECT COUNT(*) FROM td_return_item i
WHERE i.company_id = r.company_id
AND i.return_id = r.id) AS item_count
FROM td_return r
LEFT JOIN md_contact c
ON c.company_id = r.company_id
@@ -471,6 +489,16 @@ class ReturnManager {
throw new Exception("Item #{$i}: missing linked stock-out record.");
}
// Returned goods are put back into a specific bin. A return saved
// before the location columns existed has none recorded; name the
// fix rather than letting occupyBin() fail on an empty location.
if (trim((string)($item['bin'] ?? '')) === '') {
$name = $item['product_name'] ?: $product_sku;
throw new Exception(
"\"{$name}\" has no return location. Open the return, choose where it goes back to, save, then confirm."
);
}
$stock_out_table = $this->stockTableNameFromWarehouseId($stock_out_wh);
$stock_out_sth = $this->pdo->prepare(
"SELECT status, lot_number, serial_number, price
+133 -25
View File
@@ -26,6 +26,18 @@ require_once __DIR__ . '/../notify_node.php';
*/
class StockManager {
/**
* Scale of every stock quantity column (`in`, `out`, td_*_item.quantity are
* all decimal(18,4)). Anything finer than this cannot be stored: MySQL
* rounds it on insert, so a quantity of 0.0000001 silently became 0.0000
* and produced a movement of nothing that still left the source document
* "partially received".
*/
public const QTY_SCALE = 4;
/** Smallest quantity the schema can represent — 0.0001. */
public const QTY_MIN = 0.0001;
private PDO $pdo;
private int $company_id;
@@ -56,6 +68,39 @@ class StockManager {
return 'td_stock_' . $warehouse_id;
}
/**
* Round a quantity to the stored scale and reject values that cannot be
* represented.
*
* A positive input that rounds to zero is a mistake worth naming — the
* caller asked to move some stock and would otherwise get a zero-quantity
* movement that looks successful and reports as "0.00" everywhere.
*
* @param mixed $value Raw client input.
* @param string $label Field name used in the error message.
* @return float Quantity rounded to QTY_SCALE.
* @throws Exception When the value is not a usable quantity.
*/
public static function normaliseQuantity($value, string $label = 'Quantity'): float
{
$raw = (float)$value;
if ($raw <= 0) {
throw new Exception("{$label} must be greater than zero.");
}
$rounded = round($raw, self::QTY_SCALE);
if ($rounded < self::QTY_MIN) {
throw new Exception(
"{$label} of {$raw} is smaller than the minimum the system records (" .
rtrim(rtrim(number_format(self::QTY_MIN, self::QTY_SCALE), '0'), '.') . ")."
);
}
return $rounded;
}
private function stockReferenceSql(): string
{
return "CONCAT(DATE_FORMAT(COALESCE(a.`date`, a.updated_at), '%Y%m%d%H%i%s'), '-', LPAD(a.id, 11, '0'))";
@@ -72,35 +117,93 @@ class StockManager {
* The 'quantity' alias resolves to the correct column (in or out) depending
* on the type. For transfers, only the outbound row is listed (out > 0).
*
* @param int $warehouse_id The md_warehouse.id to query.
* @param int $warehouse_id The md_warehouse.id to query, or 0 for every
* warehouse of this company.
* @param string $type Movement type: 'in' | 'out' | 'transfer'.
* @return array Stock rows ordered by date DESC, each with 'quantity' and 'product_name'.
* @return array Stock rows ordered by date DESC, each with 'quantity',
* 'product_name', 'warehouse_id' and 'warehouse_name'.
*/
public function getStockList(int $warehouse_id, string $type): array
{
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
$column = $type === 'out' ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)';
// warehouse_id 0 = every warehouse. Stock lives in one table per
// warehouse, so a single-warehouse list hides the rest of a receipt
// that was split across warehouses — a 4-line PO received into two of
// them looked like only 3 lines had been received.
$warehouses = $warehouse_id > 0
? [$warehouse_id]
: $this->warehouseIdsWithStockTable();
// The transfer list shows the outbound row, whose quantity is in `out` (its `in` is always 0).
// Quantity is NOT rounded for display here: rounding to 2 dp reports a
// small-but-real quantity as "0.00", which reads as missing data.
$column = in_array($type, ['out', 'transfer'], true) ? 'a.out' : 'a.in';
$stock_ref = $this->stockReferenceSql();
// Transfer list: show only the outbound side (out > 0) to avoid duplicate display
$extra_cond = ($type === 'transfer') ? 'AND a.out > 0' : '';
$rows = [];
foreach ($warehouses as $wh_id) {
$table = $this->stockTableNameFromWarehouseId($wh_id);
$sth = $this->pdo->prepare(
"SELECT a.*, {$stock_ref} AS stock_reference, {$column} AS quantity,
b.product_name, b.uom,
w.warehouse_name
FROM `{$table}` a
LEFT JOIN md_product b
ON a.company_id = b.company_id
AND a.product_sku = b.sku
LEFT JOIN md_warehouse w
ON w.company_id = a.company_id
AND w.id = :warehouse_id
WHERE a.company_id = :company_id
AND a.type = :type
{$extra_cond}
ORDER BY a.date DESC"
);
$sth->execute([
':company_id' => $this->company_id,
':warehouse_id' => $wh_id,
':type' => $type,
]);
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
// The row's own warehouse, so the list can link each Action
// back to the right td_stock_<id> table when showing them all.
$row['warehouse_id'] = $wh_id;
$rows[] = $row;
}
}
// Re-sort across warehouses — each table was only ordered internally.
usort($rows, fn($x, $y) => strcmp((string)($y['date'] ?? ''), (string)($x['date'] ?? '')));
return $rows;
}
/**
* Warehouse ids of this company that actually have a stock table.
*
* td_stock_<id> tables are created lazily on first use, so a warehouse with
* no movements yet has none and must be skipped rather than queried.
*
* @return int[]
*/
private function warehouseIdsWithStockTable(): array
{
$sth = $this->pdo->prepare(
"SELECT a.*, {$stock_ref} AS stock_reference, {$column} AS quantity, b.product_name, b.uom
FROM `{$table}` a
LEFT JOIN md_product b
ON a.company_id = b.company_id
AND a.product_sku = b.sku
WHERE a.company_id = :company_id
AND a.type = :type
{$extra_cond}
ORDER BY a.date DESC"
"SELECT w.id
FROM md_warehouse w
JOIN information_schema.tables t
ON t.table_schema = DATABASE()
AND t.table_name = CONCAT('td_stock_', w.id)
WHERE w.company_id = :company_id
ORDER BY w.id"
);
$sth->execute([
':company_id' => $this->company_id,
':type' => $type,
]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
$sth->execute([':company_id' => $this->company_id]);
return array_map('intval', $sth->fetchAll(PDO::FETCH_COLUMN));
}
/**
@@ -204,7 +307,10 @@ class StockManager {
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
$output = $sth->fetch(PDO::FETCH_ASSOC);
if (!$output) return false;
// Only a transfer's outbound row names a destination. Any other row
// (a stock-in or stock-out reached through a stale or edited link) has
// no ref_warehouse, and resolving it threw "Invalid warehouse id."
if (!$output || $output['type'] !== 'transfer' || (int)$output['ref_warehouse'] <= 0) return false;
// Resolve the inbound (to) row via ref_warehouse + uuid
$to_warehouse_id = (int)$output['ref_warehouse'];
@@ -257,8 +363,8 @@ class StockManager {
$warehouse_id = (int)($data["warehouse"] ?? 0);
$quantity = (float)($data['quantity'] ?? 0);
if ($id === 0 && $quantity <= 0) {
throw new Exception("Quantity must be greater than zero.");
if ($id === 0) {
$quantity = self::normaliseQuantity($quantity);
}
$whMgmt = new WarehouseManager($this->pdo, $this->company_id);
@@ -439,8 +545,9 @@ class StockManager {
);
}
// Quantity and identifiers come from the existing stock_in row (immutable)
$quantity = (int)$source_stock['in'];
// Quantity and identifiers come from the existing stock_in row (immutable).
// `in` is decimal(18,4) — an int cast would drop fractional quantities.
$quantity = (float)$source_stock['in'];
$ref_id = (int)$source_stock['id'];
$lot_number = $source_stock['lot_number'] ?? null;
$serial_number = $source_stock['serial_number'] ?? null;
@@ -611,8 +718,9 @@ class StockManager {
);
}
// Quantity and identifiers come from the source stock_in row (immutable)
$quantity = (int)$source_stock['in'];
// Quantity and identifiers come from the source stock_in row (immutable).
// `in` is decimal(18,4) — an int cast would drop fractional quantities.
$quantity = (float)$source_stock['in'];
$lot_number = $source_stock['lot_number'] ?? null;
$serial_number = $source_stock['serial_number'] ?? null;
@@ -154,7 +154,10 @@ class SupplierReturnManager {
$sth = $this->pdo->prepare(
"SELECT r.*,
COALESCE(c.contact_name, '') AS contact_name,
p.po_number
p.po_number,
(SELECT COUNT(*) FROM td_supplier_return_item i
WHERE i.company_id = r.company_id
AND i.return_id = r.id) AS item_count
FROM td_supplier_return r
LEFT JOIN md_contact c
ON c.company_id = r.company_id
+11 -11
View File
@@ -1035,7 +1035,7 @@ class WarehouseManager {
$sth = $this->pdo->prepare(
"SELECT DISTINCT zone FROM md_bin
WHERE company_id = :company_id AND warehouse = :warehouse
ORDER BY CAST(zone AS UNSIGNED), zone"
ORDER BY REGEXP_REPLACE(zone, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(zone, '[0-9]+$') AS UNSIGNED), zone"
);
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
@@ -1055,7 +1055,7 @@ class WarehouseManager {
$sth = $this->pdo->prepare(
"SELECT DISTINCT aisle FROM md_bin
WHERE company_id = :company_id AND warehouse = :warehouse AND zone = :zone
ORDER BY CAST(aisle AS UNSIGNED), aisle"
ORDER BY REGEXP_REPLACE(aisle, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(aisle, '[0-9]+$') AS UNSIGNED), aisle"
);
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id, ':zone' => $zone]);
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle');
@@ -1077,7 +1077,7 @@ class WarehouseManager {
$sth = $this->pdo->prepare(
"SELECT DISTINCT bin FROM md_bin
WHERE company_id = :company_id AND warehouse = :warehouse
ORDER BY CAST(bin AS UNSIGNED), bin"
ORDER BY REGEXP_REPLACE(bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(bin, '[0-9]+$') AS UNSIGNED), bin"
);
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]);
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'bin');
@@ -1086,7 +1086,7 @@ class WarehouseManager {
$sth = $this->pdo->prepare(
"SELECT DISTINCT bin FROM md_bin
WHERE company_id = :company_id AND warehouse = :warehouse AND zone = :zone AND aisle = :aisle
ORDER BY CAST(bin AS UNSIGNED), bin"
ORDER BY REGEXP_REPLACE(bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(bin, '[0-9]+$') AS UNSIGNED), bin"
);
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id, ':zone' => $zone, ':aisle' => $aisle]);
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'bin');
@@ -1915,7 +1915,7 @@ class WarehouseManager {
WHERE company_id = :company_id
AND warehouse = :warehouse
AND product_sku IS NULL
ORDER BY CAST(zone AS UNSIGNED), zone"
ORDER BY REGEXP_REPLACE(zone, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(zone, '[0-9]+$') AS UNSIGNED), zone"
);
$sth->execute([':company_id' => $this->company_id, ':warehouse' => $warehouse_id]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
@@ -1961,7 +1961,7 @@ class WarehouseManager {
{$lot_cond}
{$serial_cond}
)
ORDER BY CAST(r.zone AS UNSIGNED), r.zone"
ORDER BY REGEXP_REPLACE(r.zone, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.zone, '[0-9]+$') AS UNSIGNED), r.zone"
);
$sth->execute($params);
return $sth->fetchAll(PDO::FETCH_ASSOC);
@@ -1982,7 +1982,7 @@ class WarehouseManager {
AND warehouse = :warehouse
AND zone = :zone
AND product_sku IS NULL
ORDER BY CAST(aisle AS UNSIGNED), aisle"
ORDER BY REGEXP_REPLACE(aisle, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(aisle, '[0-9]+$') AS UNSIGNED), aisle"
);
$sth->execute([
':company_id' => $this->company_id,
@@ -2033,7 +2033,7 @@ class WarehouseManager {
{$lot_cond}
{$serial_cond}
)
ORDER BY CAST(r.aisle AS UNSIGNED), r.aisle"
ORDER BY REGEXP_REPLACE(r.aisle, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.aisle, '[0-9]+$') AS UNSIGNED), r.aisle"
);
$sth->execute($params);
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'aisle');
@@ -2055,7 +2055,7 @@ class WarehouseManager {
WHERE company_id = :company_id
AND warehouse = :warehouse
AND product_sku IS NULL
ORDER BY CAST(bin AS UNSIGNED), bin"
ORDER BY REGEXP_REPLACE(bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(bin, '[0-9]+$') AS UNSIGNED), bin"
);
$sth->execute([
':company_id' => $this->company_id,
@@ -2071,7 +2071,7 @@ class WarehouseManager {
AND zone = :zone
AND aisle = :aisle
AND product_sku IS NULL
ORDER BY CAST(bin AS UNSIGNED), bin"
ORDER BY REGEXP_REPLACE(bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(bin, '[0-9]+$') AS UNSIGNED), bin"
);
$sth->execute([
':company_id' => $this->company_id,
@@ -2130,7 +2130,7 @@ class WarehouseManager {
{$lot_cond}
{$serial_cond}
)
ORDER BY CAST(r.bin AS UNSIGNED), r.bin"
ORDER BY REGEXP_REPLACE(r.bin, '[0-9]+$', ''), CAST(REGEXP_SUBSTR(r.bin, '[0-9]+$') AS UNSIGNED), r.bin"
);
$sth->execute($params);
return array_column($sth->fetchAll(PDO::FETCH_ASSOC), 'bin');
@@ -697,7 +697,7 @@ class FinancialReports
COALESCE(d.dept_code, '') AS dept_code,
COALESCE(d.dept_name, '') AS dept_name,
COALESCE(g.journal_date, DATE(g.created_at)) AS entry_date,
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
DATE_FORMAT(g.created_at, '%Y-%m-%d %H:%i:%s') AS posted_at,
i.debit,
i.credit,
COALESCE(i.description, '') AS line_description
+5 -1
View File
@@ -81,7 +81,11 @@ class GlManager
$now = date('Y-m-d H:i:s');
$sth = $this->pdo->prepare(
"SELECT id, current_version, formula_id, history
// `period` is read below as $old_period to reverse the old ETL
// totals. It was missing from this list, so it was always null and
// upsertEtl(string $period) threw a TypeError — every edit of a
// manual journal ended in HTTP 500.
"SELECT id, current_version, formula_id, history, period
FROM td_gl
WHERE company_id = :cid AND id = :gl_id AND source_type = 'manual'
FOR UPDATE"
@@ -75,8 +75,8 @@ class GlQueryManager
g.current_version,
g.formula_id,
COALESCE(f.formula_name, '') AS formula_name,
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
DATE_FORMAT(g.updated_at, '%d/%m/%Y %H:%i') AS updated_at,
DATE_FORMAT(g.created_at, '%Y-%m-%d %H:%i:%s') AS posted_at,
DATE_FORMAT(g.updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at,
CASE g.source_type
WHEN 'receipt' THEN r.receipt_number
WHEN 'payment' THEN p.payment_number
@@ -142,8 +142,8 @@ class GlQueryManager
$sth = $this->pdo->prepare(
"SELECT g.*,
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
DATE_FORMAT(g.updated_at, '%d/%m/%Y %H:%i') AS updated_at_fmt,
DATE_FORMAT(g.created_at, '%Y-%m-%d %H:%i:%s') AS posted_at,
DATE_FORMAT(g.updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at_fmt,
DATE_FORMAT(g.journal_date, '%d/%m/%Y') AS journal_date_fmt,
COALESCE(f.formula_name, '') AS formula_name
FROM td_gl g
+34
View File
@@ -7,6 +7,34 @@ ini_set('display_errors', 0);
ini_set('log_errors', 1);
header('Content-Type: application/json; charset=utf-8');
// Last-resort handler for exceptions an engine does not catch itself. Many
// engines call a manager with no try/catch, so any exception — including the
// managers' own deliberate validation messages — used to end as a PHP fatal
// with an empty 500 body, which the browser could only report as "Server
// error occurred." This mirrors the convention the catching engines already
// use: a manager's Exception carries a user-facing message (400); a database
// or engine fault stays generic (500) and goes to the server log.
set_exception_handler(function (Throwable $e) {
while (ob_get_level() > 0) ob_end_clean();
if (!headers_sent()) header('Content-Type: application/json; charset=utf-8');
if ($e instanceof PDOException) {
error_log('Uncaught PDOException: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
http_response_code(500);
$message = 'Database error, please try again.';
} elseif ($e instanceof Exception) {
http_response_code(400);
$message = $e->getMessage();
} else {
// Error / TypeError: a programming fault, not something to show users.
error_log('Uncaught ' . get_class($e) . ': ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
http_response_code(500);
$message = 'Server error occurred.';
}
echo json_encode(['success' => 0, 'message' => $message]);
});
require_once __DIR__."/../../config.php";
require_once __DIR__."/../../dbconn.php";
require_once __DIR__."/db_helpers.php";
@@ -113,6 +141,12 @@ $answer = array("success"=>0, "message"=>"");
if (isset($_POST['json'])) {
// Old Method: Data is wrapped in a JSON string
$data = json_decode($_POST['json'], true);
if (!is_array($data)) {
// An undecodable payload used to carry on as an empty request.
http_response_code(400);
$answer["message"] = "The request could not be read. Please reload the page and try again.";
exit(json_encode($answer));
}
} else if (isset($_POST['otp'])) {
// New Method: Data is sent directly (FormData)
// We check for 'otp' because every request should have one
+36
View File
@@ -0,0 +1,36 @@
<?php
// app/assets/utils/otp_policy.php
//
// Email OTP login policy, set by OTP_REQUIRED in config.php.
//
// OFF BY DEFAULT: the OTP step runs only when the constant is defined and is
// exactly the boolean true. A missing constant (any config.php written before
// this switch existed), 1, 'true' or a typo all leave it off, so sign-in is
// password only and no SMTP is needed to log in.
//
// While it is off, every sign-in that skips the OTP because of it is logged as
// OTP_BYPASSED, and the login page and top bar both say so on screen — a
// password-only sign-in must never be invisible to whoever is using it.
//
// Only the login OTP is affected. When it is on, the staff/viewer and no-SMTP
// skips in login_otp.php still apply; password-reset OTPs (PasswordResetManager)
// are a separate flow that stays on regardless.
if (!function_exists('otp_required')) {
function otp_required(): bool {
return defined('OTP_REQUIRED') && OTP_REQUIRED === true;
}
}
if (!function_exists('otp_log_bypass')) {
// There is no auth log table in this app, so bypasses go to the PHP error
// log (the container's Apache log) under a fixed, greppable tag.
function otp_log_bypass($user_id, string $where): void {
error_log(sprintf(
'[auth] OTP_BYPASSED user_id=%d ip=%s where=%s -- OTP_REQUIRED is not true in config.php',
(int)$user_id,
$_SERVER['REMOTE_ADDR'] ?? '-',
$where
));
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
// Applies the configured application timezone to PHP, and exposes the matching
// UTC offset so the database session can be pinned to the same zone.
//
// config.php has always defined $time_zone ("Asia/Bangkok"), but nothing ever
// called date_default_timezone_set() with it. PHP therefore ran on its ini
// default (UTC on this stack) while MySQL NOW() ran on the database server's
// zone (Bangkok). Every timestamp written from PHP — stock movement `date`
// above all — was stored 7 hours behind the real wall clock, so a stock-in
// created at 14:02 was listed as 07:02.
//
// Loaded from dbconn.php (covers every API engine, which is where writes
// happen) and from include_header.php (covers the rendered pages).
if (!defined('APP_TIMEZONE')) {
$app_tz = $GLOBALS['time_zone'] ?? 'Asia/Bangkok';
// An unknown identifier would leave PHP on UTC and silently reintroduce the
// skew, so fall back to the documented project zone instead.
try {
$tz = new DateTimeZone($app_tz);
} catch (Exception $e) {
$app_tz = 'Asia/Bangkok';
$tz = new DateTimeZone($app_tz);
}
date_default_timezone_set($app_tz);
define('APP_TIMEZONE', $app_tz);
// "+07:00" — the form MySQL accepts without its named-timezone tables
// having been loaded, which is the usual case on a stock install.
$offset_seconds = $tz->getOffset(new DateTime('now', $tz));
define('APP_TIMEZONE_OFFSET', sprintf(
'%s%02d:%02d',
$offset_seconds < 0 ? '-' : '+',
intdiv(abs($offset_seconds), 3600),
intdiv(abs($offset_seconds) % 3600, 60)
));
}
+18
View File
@@ -38,6 +38,24 @@ if (!defined('NODE_EMIT_SECRET')) {
define('NODE_EMIT_SECRET', 'YOUR_NODE_EMIT_SECRET'); // must match nodejs/.env EMIT_SECRET
}
// ── Login OTP ────────────────────────────────────────────────────────────────
// Email OTP on sign-in. OFF BY DEFAULT: only the boolean true turns it on —
// anything else, the constant being absent included, leaves sign-in password
// only (logged as OTP_BYPASSED, shown on the login page and top bar). Turn it
// on only with working SMTP. Password-reset OTPs are not affected.
if (!defined('OTP_REQUIRED')) {
define('OTP_REQUIRED', false);
}
// ── App registry ─────────────────────────────────────────────────────────────
// Apps a user can be given access to, as shown on Setting → Users Access. Keys
// must match the user.app_access enum ('wms', 'accounting'). If this is left
// out, assets/utils/app_registry.php supplies the same default.
$app_registry = [
'wms' => ['label' => 'WMS', 'icon' => 'ti-box', 'color' => 'bg-label-primary'],
'accounting' => ['label' => 'Accounting', 'icon' => 'ti-calculator', 'color' => 'bg-label-success'],
];
// ── Usage packages ───────────────────────────────────────────────────────────
// Keyed by company_list.package (defaults to 'starter'). Read by UsageGuard to
// enforce daily/weekly action limits and which features lock once exceeded.
+13 -8
View File
@@ -1,28 +1,33 @@
<?php
/**
* 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 }] }
*/
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(
"SELECT e.company_id, COUNT(*) AS count
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
WHERE company_id > 0
GROUP BY company_id, product_sku
GROUP BY company_id, warehouse_id, product_sku
) e
JOIN md_product p
ON p.sku = e.product_sku
AND p.company_id = e.company_id
AND p.status = 1
WHERE p.min_stock > 0
AND e.balance < p.min_stock
AND p.company_id = e.company_id
AND p.status > 0
JOIN md_warehouse w
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
HAVING count > 0"
);
$answer['success'] = 1;
@@ -14,8 +14,14 @@
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'] = [
'items' => $items,
'warehouses' => $sth->fetchAll(PDO::FETCH_ASSOC),
'total_low' => count($items),
'total_critical' => $critical,
'total_warning' => $warning,
+5 -5
View File
@@ -430,8 +430,8 @@
<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>
<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>`;
});
@@ -483,7 +483,7 @@
<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>
<span class="fw-bold text-${color}">${sign}${format_quantity(item.qty)}</span><br>
<small class="text-muted">${time_ago(item.date)}</small>
</div>
</li>`;
@@ -569,7 +569,7 @@
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="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>`;
});
}
@@ -608,7 +608,7 @@
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="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>`;
});
}
+305 -309
View File
@@ -1,316 +1,312 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
?>
<body>
<?php require '../include_topbar.php';?>
<?php require '../include_sidebar.php';?>
<!-- MAIN CONTENT -->
<main id="content" class="content py-15">
<div class="container-fluid">
<!-- Page header -->
<div class="row">
<div class="col-12">
<div class="mb-6 d-flex justify-content-between align-items-center">
<div>
<h1 class="fs-3 mb-1">Low Stock Products</h1>
<p class="mb-0 text-muted">Products whose current balance has fallen below minimum stock level</p>
</div>
</div>
</div>
</div>
<!-- Summary cards -->
<div class="row mb-5 g-5">
<div class="col-lg-4 col-12">
<div class="card border border-warning border-opacity-25">
<div class="card-body p-5">
<div class="d-flex justify-content-between align-items-center">
<span>Total Low Stock</span>
<span><i class="ti ti-alert-triangle fs-3 text-warning"></i></span>
</div>
<div class="d-flex flex-column mt-3">
<span class="fs-3 fw-bold mb-0 d-block text-warning" id="stat_total">—</span>
<small class="text-muted">Products need attention</small>
</div>
<div class="card-loader-overlay" id="loader_total">
<div class="spinner-border spinner-border-sm text-primary"></div>
</div>
</div>
</div>
</div>
<div class="col-lg-4 col-12">
<div class="card border border-danger border-opacity-25">
<div class="card-body p-5">
<div class="d-flex justify-content-between align-items-center">
<span>Critical Level</span>
<span><i class="ti ti-activity-heartbeat fs-3 text-danger"></i></span>
</div>
<div class="d-flex flex-column mt-3">
<span class="fs-3 fw-bold mb-0 d-block text-danger" id="stat_critical">—</span>
<small class="text-muted">Immediate restock needed</small>
</div>
<div class="card-loader-overlay" id="loader_critical">
<div class="spinner-border spinner-border-sm text-primary"></div>
</div>
</div>
</div>
</div>
<div class="col-lg-4 col-12">
<div class="card border border-secondary border-opacity-25">
<div class="card-body p-5">
<div class="d-flex justify-content-between align-items-center">
<span>Warning Level</span>
<span><i class="ti ti-box-seam fs-3 text-secondary"></i></span>
</div>
<div class="d-flex flex-column mt-3">
<span class="fs-3 fw-bold mb-0 d-block text-secondary" id="stat_warning">—</span>
<small class="text-muted">Restock soon</small>
</div>
<div class="card-loader-overlay" id="loader_warning">
<div class="spinner-border spinner-border-sm text-primary"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Filter bar -->
<div class="row mb-4 g-3 align-items-end">
<div class="col-md-4 col-12">
<label class="form-label mb-1">Filter by Status</label>
<select id="filter_status" class="form-select">
<option value="">All</option>
<option value="critical">Critical</option>
<option value="warning">Warning</option>
</select>
</div>
<div class="col-md-4 col-12">
<label class="form-label mb-1">Filter by Warehouse</label>
<select id="filter_warehouse" class="form-select">
<option value="">All Warehouses</option>
</select>
</div>
<div class="col-md-4 col-12">
<label class="form-label mb-1">Search Product</label>
<input type="text" id="filter_search" class="form-control" placeholder="Name or SKU…">
</div>
</div>
<!-- Table -->
<div class="row g-5">
<div class="col-12">
<div class="card">
<div class="card-body p-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="fs-5 mb-0">Low Stock Items</h2>
<small class="text-muted" id="result_count"></small>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0 table-centered" id="low_stock">
<thead class="table-light">
<tr>
<th>Product</th>
<th>SKU</th>
<th>Warehouse</th>
<th>Balance</th>
<th>Min Stock</th>
<th>Reorder Point</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="8" class="text-center py-5 text-muted">
<div class="spinner-border spinner-border-sm me-2"></div>Loading…
</td>
</tr>
</tbody>
<tfoot></tfoot>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<?php require "../include_ending.php";?>
<script>
// ── In-memory dataset ────────────────────────────────────────────────
var all_items = [];
// ── Fetch low-stock data from the report engine ──────────────────────
function retrieve_low_stock() {
return ajax_request({
url: "<?php echo $server_url?>dashboard/api/engine_report/low_stock.php",
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
var out = res.output || {};
all_items = out.items || [];
// Summary cards
$('#stat_total').text(out.total_low ?? 0);
$('#stat_critical').text(out.total_critical ?? 0);
$('#stat_warning').text(out.total_warning ?? 0);
$('#loader_total, #loader_critical, #loader_warning').hide();
// Warehouse filter options (derived from data)
var warehouses = {};
$.each(all_items, function(i, item) {
warehouses[item.warehouse_id] = item.warehouse_name;
});
var wh_options = '<option value="">All Warehouses</option>';
$.each(warehouses, function(id, name) {
wh_options += `<option value="${id}">${name}</option>`;
});
$('#filter_warehouse').html(wh_options);
// Register dataset in alasql for paginated rendering
alasql('CREATE TABLE IF NOT EXISTS low_stock');
alasql.tables.low_stock.data = all_items;
change_page_low_stock(1);
}
});
}
// ── Apply filters, then paginate ─────────────────────────────────────
function change_page_low_stock(page_num) {
var offset = (page_num - 1) * prop_limit;
var status = $('#filter_status').val().trim().toLowerCase();
var warehouse = $('#filter_warehouse').val();
var search = $('#filter_search').val().trim().toLowerCase();
// Filter first — we need the full filtered set for correct pagination
var filtered = all_items.filter(function(item) {
if (status && item.status !== status) return false;
if (warehouse && String(item.warehouse_id) !== String(warehouse)) return false;
if (search) {
var hay = (item.product_name + ' ' + item.product_sku).toLowerCase();
if (hay.indexOf(search) === -1) return false;
}
return true;
});
// Pagination footer reflects filtered count
var page = generate_pagination('low_stock', filtered.length);
$(`table#low_stock tfoot`).html(page);
$('#result_count').text(filtered.length + ' item(s)');
if (filtered.length === 0) {
$('table#low_stock > tbody').html(
`<tr><td colspan="8" class="text-center py-5 text-muted">
<i class="ti ti-mood-smile fs-3 d-block mb-2"></i>
No low-stock products found.
</td></tr>`
);
return;
}
// Render only the current page slice
var page_data = filtered.slice(offset, offset + prop_limit);
var body = '';
$.each(page_data, function(i, item) {
var pct = item.reorder_point > 0 ? Math.round((item.balance / item.reorder_point) * 100) : 0;
var bar_cls = item.status === 'critical' ? 'bg-danger' : 'bg-warning';
var badge = item.status === 'critical' ?
'<span class="badge bg-danger rounded-pill">Critical</span>' :
'<span class="badge bg-secondary bg-opacity-10 text-dark rounded-pill">Warning</span>';
var balance_cls = item.status === 'critical' ? 'text-danger fw-semibold' : 'text-warning fw-semibold';
body += `<tr>
<td class="py-3">
<div class="d-flex flex-column">
<span>${item.product_name}</span>
<div class="progress mt-1" style="height:4px;width:80px;" title="${pct}% of reorder point">
<div class="progress-bar ${bar_cls}" style="width:${Math.min(pct,100)}%"></div>
</div>
</div>
</td>
<td class="py-3 text-secondary">${item.product_sku}</td>
<td class="py-3">${item.warehouse_name}</td>
<?php
session_start();
require '../config.php';
require '../include_header.php';
?>
<body>
<?php require '../include_topbar.php';?>
<?php require '../include_sidebar.php';?>
<!-- MAIN CONTENT -->
<main id="content" class="content py-15">
<div class="container-fluid">
<!-- Page header -->
<div class="row">
<div class="col-12">
<div class="mb-6 d-flex justify-content-between align-items-center">
<div>
<h1 class="fs-3 mb-1">Low Stock Products</h1>
<p class="mb-0 text-muted">Products whose current balance has fallen below minimum stock level</p>
</div>
</div>
</div>
</div>
<!-- Summary cards -->
<div class="row mb-5 g-5">
<div class="col-lg-4 col-12">
<div class="card border border-warning border-opacity-25">
<div class="card-body p-5">
<div class="d-flex justify-content-between align-items-center">
<span>Total Low Stock</span>
<span><i class="ti ti-alert-triangle fs-3 text-warning"></i></span>
</div>
<div class="d-flex flex-column mt-3">
<span class="fs-3 fw-bold mb-0 d-block text-warning" id="stat_total">—</span>
<small class="text-muted">Products need attention</small>
</div>
<div class="card-loader-overlay" id="loader_total">
<div class="spinner-border spinner-border-sm text-primary"></div>
</div>
</div>
</div>
</div>
<div class="col-lg-4 col-12">
<div class="card border border-danger border-opacity-25">
<div class="card-body p-5">
<div class="d-flex justify-content-between align-items-center">
<span>Critical Level</span>
<span><i class="ti ti-activity-heartbeat fs-3 text-danger"></i></span>
</div>
<div class="d-flex flex-column mt-3">
<span class="fs-3 fw-bold mb-0 d-block text-danger" id="stat_critical">—</span>
<small class="text-muted">Immediate restock needed</small>
</div>
<div class="card-loader-overlay" id="loader_critical">
<div class="spinner-border spinner-border-sm text-primary"></div>
</div>
</div>
</div>
</div>
<div class="col-lg-4 col-12">
<div class="card border border-secondary border-opacity-25">
<div class="card-body p-5">
<div class="d-flex justify-content-between align-items-center">
<span>Warning Level</span>
<span><i class="ti ti-box-seam fs-3 text-secondary"></i></span>
</div>
<div class="d-flex flex-column mt-3">
<span class="fs-3 fw-bold mb-0 d-block text-secondary" id="stat_warning">—</span>
<small class="text-muted">Restock soon</small>
</div>
<div class="card-loader-overlay" id="loader_warning">
<div class="spinner-border spinner-border-sm text-primary"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Filter bar -->
<div class="row mb-4 g-3 align-items-end">
<div class="col-md-4 col-12">
<label class="form-label mb-1">Filter by Status</label>
<select id="filter_status" class="form-select">
<option value="">All</option>
<option value="critical">Critical</option>
<option value="warning">Warning</option>
</select>
</div>
<div class="col-md-4 col-12">
<label class="form-label mb-1">Filter by Warehouse</label>
<select id="filter_warehouse" class="form-select">
<option value="">All Warehouses</option>
</select>
</div>
<div class="col-md-4 col-12">
<label class="form-label mb-1">Search Product</label>
<input type="text" id="filter_search" class="form-control" placeholder="Name or SKU…">
</div>
</div>
<!-- Table -->
<div class="row g-5">
<div class="col-12">
<div class="card">
<div class="card-body p-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="fs-5 mb-0">Low Stock Items</h2>
<small class="text-muted" id="result_count"></small>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0 table-centered" id="low_stock">
<thead class="table-light">
<tr>
<th>Product</th>
<th>SKU</th>
<th>Warehouse</th>
<th>Balance</th>
<th>Min Stock</th>
<th>Reorder Point</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="8" class="text-center py-5 text-muted">
<div class="spinner-border spinner-border-sm me-2"></div>Loading…
</td>
</tr>
</tbody>
<tfoot></tfoot>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<?php require "../include_ending.php";?>
<script>
// ── In-memory dataset ────────────────────────────────────────────────
var all_items = [];
// ── Fetch low-stock data from the report engine ──────────────────────
function retrieve_low_stock() {
return ajax_request({
url: "<?php echo $server_url?>dashboard/api/engine_report/low_stock.php",
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
var out = res.output || {};
all_items = out.items || [];
// Summary cards
$('#stat_total').text(out.total_low ?? 0);
$('#stat_critical').text(out.total_critical ?? 0);
$('#stat_warning').text(out.total_warning ?? 0);
$('#loader_total, #loader_critical, #loader_warning').hide();
// Warehouse filter options: every active warehouse, not only those with low items
var wh_options = '<option value="">All Warehouses</option>';
$.each(out.warehouses || [], function(i, wh) {
wh_options += `<option value="${wh.id}">${wh.warehouse_name}</option>`;
});
$('#filter_warehouse').html(wh_options);
// Register dataset in alasql for paginated rendering
alasql('CREATE TABLE IF NOT EXISTS low_stock');
alasql.tables.low_stock.data = all_items;
change_page_low_stock(1);
}
});
}
// ── Apply filters, then paginate ─────────────────────────────────────
function change_page_low_stock(page_num) {
var offset = (page_num - 1) * prop_limit;
var status = $('#filter_status').val().trim().toLowerCase();
var warehouse = $('#filter_warehouse').val();
var search = $('#filter_search').val().trim().toLowerCase();
// Filter first — we need the full filtered set for correct pagination
var filtered = all_items.filter(function(item) {
if (status && item.status !== status) return false;
if (warehouse && String(item.warehouse_id) !== String(warehouse)) return false;
if (search) {
var hay = (item.product_name + ' ' + item.product_sku).toLowerCase();
if (hay.indexOf(search) === -1) return false;
}
return true;
});
// Pagination footer reflects filtered count
var page = generate_pagination('low_stock', filtered.length);
$(`table#low_stock tfoot`).html(page);
$('#result_count').text(filtered.length + ' item(s)');
if (filtered.length === 0) {
$('table#low_stock > tbody').html(
`<tr><td colspan="8" class="text-center py-5 text-muted">
<i class="ti ti-mood-smile fs-3 d-block mb-2"></i>
No low-stock products found.
</td></tr>`
);
return;
}
// Render only the current page slice
var page_data = filtered.slice(offset, offset + prop_limit);
var body = '';
$.each(page_data, function(i, item) {
var pct = item.reorder_point > 0 ? Math.round((item.balance / item.reorder_point) * 100) : 0;
var bar_cls = item.status === 'critical' ? 'bg-danger' : 'bg-warning';
var badge = item.status === 'critical' ?
'<span class="badge bg-danger rounded-pill">Critical</span>' :
'<span class="badge bg-secondary bg-opacity-10 text-dark rounded-pill">Warning</span>';
var balance_cls = item.status === 'critical' ? 'text-danger fw-semibold' : 'text-warning fw-semibold';
body += `<tr>
<td class="py-3">
<div class="d-flex flex-column">
<span>${item.product_name}</span>
<div class="progress mt-1" style="height:4px;width:80px;" title="${pct}% of reorder point">
<div class="progress-bar ${bar_cls}" style="width:${Math.min(pct,100)}%"></div>
</div>
</div>
</td>
<td class="py-3 text-secondary">${item.product_sku}</td>
<td class="py-3">${item.warehouse_name}</td>
<td class="py-3 ${balance_cls}">
${item.balance} <span class="text-muted fw-normal small">${item.uom || 'pcs'}</span>
${item.cost_price > 0
? `<div class="text-muted small fw-normal">Value: ${format_number(item.balance * item.cost_price, 2)}</div>`
: ''}
</td>
<td class="py-3">${item.min_stock}</td>
<td class="py-3">${item.reorder_point}</td>
<td class="py-3">${badge}</td>
<td class="py-3">
<a href="<?php echo $server_url?>ics/manage_stock_in.php?sku=${encodeURIComponent(item.product_sku)}&name=${encodeURIComponent(item.product_name)}&qty=${Math.max(0, item.reorder_point - item.balance)}&wh=${item.warehouse_id}"
class="btn btn-sm btn-outline-primary">
<i class="ti ti-plus me-1"></i>Restock
</a>
</td>
</tr>`;
});
$('table#low_stock > tbody').html(body);
}
// ── Filter listeners ─────────────────────────────────────────────────
function debounce(fn, ms) {
var t;
return function() {
var args = arguments,
ctx = this;
clearTimeout(t);
t = setTimeout(function() {
fn.apply(ctx, args);
}, ms);
};
}
$('#filter_status, #filter_warehouse').on('change', function() {
change_page_low_stock(1); // reset to page 1 on filter change
});
$('#filter_search').on('input', debounce(function() {
change_page_low_stock(1);
}, 200));
// ── Boot ─────────────────────────────────────────────────────────────
$(async function() {
try {
await retrieve_low_stock();
} catch (e) {
console.error(e);
$('table#low_stock > tbody').html(
'<tr><td colspan="8" class="text-center py-5 text-danger">Failed to load data.</td></tr>'
);
}
});
</script>
</body>
<td class="py-3">${item.min_stock}</td>
<td class="py-3">${item.reorder_point}</td>
<td class="py-3">${badge}</td>
<td class="py-3">
<a href="<?php echo $server_url?>ics/manage_stock_in.php?sku=${encodeURIComponent(item.product_sku)}&name=${encodeURIComponent(item.product_name)}&qty=${Math.max(0, item.reorder_point - item.balance)}&wh=${item.warehouse_id}"
class="btn btn-sm btn-outline-primary">
<i class="ti ti-plus me-1"></i>Restock
</a>
</td>
</tr>`;
});
$('table#low_stock > tbody').html(body);
}
// ── Filter listeners ─────────────────────────────────────────────────
function debounce(fn, ms) {
var t;
return function() {
var args = arguments,
ctx = this;
clearTimeout(t);
t = setTimeout(function() {
fn.apply(ctx, args);
}, ms);
};
}
$('#filter_status, #filter_warehouse').on('change', function() {
change_page_low_stock(1); // reset to page 1 on filter change
});
$('#filter_search').on('input', debounce(function() {
change_page_low_stock(1);
}, 200));
// ── Boot ─────────────────────────────────────────────────────────────
$(async function() {
try {
await retrieve_low_stock();
} catch (e) {
console.error(e);
$('table#low_stock > tbody').html(
'<tr><td colspan="8" class="text-center py-5 text-danger">Failed to load data.</td></tr>'
);
}
});
</script>
</body>
</html>
+63 -40
View File
@@ -1,5 +1,9 @@
<?php
// Apply the configured application timezone before anything formats or stores a
// date. config.php (loaded by the caller) supplies $time_zone.
require_once __DIR__ . '/assets/utils/timezone.php';
// db connection
/** overide native PDO function */
class database extends PDO {
@@ -27,6 +31,45 @@ class db_statement extends PDOStatement {
$this->pdo = $pdo;
}
// double_encode is off so text that is loaded and saved again is not escaped
// a second time (&quot; becoming &amp;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
// override deliberately accepts a looser signature so callers may pass
// positional arguments (see func_get_args() below), so the tightened return
// type is opted out of rather than the call sites being changed.
#[\ReturnTypeWillChange]
public function execute($args = null) {
// Perform logging here. PDO object is accessible
// from $this->pdo.
@@ -40,46 +83,13 @@ class db_statement extends PDOStatement {
// null is preserved as-is so PDO can bind NULL columns correctly.
$args = array_map(fn($v) => is_null($v) ? null : (string)$v, $args);
// escaping array
// prevent store XSS
// Escape on the way in, to prevent stored XSS. Values holding a JSON
// object/array are escaped string by string so they stay valid JSON.
foreach($args as &$item){
if (is_null($item)) continue;
// decode the JSON data
// set second parameter boolean TRUE for associative array output.
$result = json_decode($item);
if (json_last_error() === JSON_ERROR_NONE) {
// encode html for json
$tmp = json_decode($item,true);
foreach((array)$tmp as &$ii){
// Inner values may be arrays (nested JSON objects) — cast to string
if (!is_string($ii)) {
$ii = json_encode($ii);
continue;
}
$result = json_decode($ii);
if (json_last_error() === JSON_ERROR_NONE) {
// json inside json
$tmpp = json_decode($ii,true);
foreach ((array)$tmpp as &$iii) {
$iii = htmlspecialchars($ii, ENT_QUOTES, 'UTF-8');
}
$ii = json_encode($tmpp);
}else{
// string inside json
$ii = htmlspecialchars($ii, ENT_QUOTES, 'UTF-8');
}
}
$item = json_encode($tmp);
}else{
// encode html for string
$item = htmlspecialchars($item, ENT_QUOTES, 'UTF-8');
}
$item = self::escapeValue($item);
}
unset($item);
}
return parent::execute($args);
}
@@ -87,9 +97,22 @@ class db_statement extends PDOStatement {
}
//..................... PDO1 .....................//
$pdo1 = new database($db_type.':host='.$db_server.';dbname='.$db_database.';charset=utf8', $db_user, $db_pass);
$pdo1 = new database($db_type.':host='.$db_server.';dbname='.$db_database.';charset=utf8mb4', $db_user, $db_pass);
$pdo1->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
//..................... PDO2 .....................//
$pdo2 = new database($db_type2.':host='.$db_server2.';dbname='.$db_database2.';charset=utf8', $db_user2, $db_pass2);
$pdo2->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo2 = new database($db_type2.':host='.$db_server2.';dbname='.$db_database2.';charset=utf8mb4', $db_user2, $db_pass2);
$pdo2->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Pin both connections to the application timezone, so MySQL NOW() and PHP
// date() agree no matter how the database server itself is configured. Queries
// mix the two freely (rows written with NOW(), others with date()), and a
// mismatch shows up as timestamps hours away from the real clock.
foreach ([$pdo1, $pdo2] as $pdo_tz) {
try {
$pdo_tz->exec("SET time_zone = '" . APP_TIMEZONE_OFFSET . "'");
} catch (PDOException $e) {
// A server that refuses the offset keeps its own zone — no worse than
// before this call existed, and not a reason to fail the request.
}
}
@@ -15,10 +15,6 @@ if (!$request_id) {
$answer['message'] = 'Purchase request ID is required.';
exit(json_encode($answer));
}
if (!$contact_id) {
$answer['message'] = 'Supplier (contact_id) is required for the PO.';
exit(json_encode($answer));
}
$prm = new PurchaseRequestManager($pdo2, $company_id);
@@ -29,6 +25,15 @@ if (!$pr || (int)$pr['status'] !== 2) {
exit(json_encode($answer));
}
// Default to the PR's preferred supplier
if (!$contact_id) {
$contact_id = (int)($pr['contact_id'] ?? 0);
}
if (!$contact_id) {
$answer['message'] = 'Set a Preferred Supplier on this purchase request before converting it to a PO.';
exit(json_encode($answer));
}
$pr_items = $pr['items'];
if (empty($pr_items)) {
$answer['message'] = 'Purchase request has no items.';
@@ -92,6 +97,7 @@ foreach ($convert_items as $ci) {
'unit_price' => $unit_price,
'total_price' => $total_price,
'tax_amount' => $tax_amount,
'tax_rate' => (float)($pi['tax_rate'] ?? 0),
'received_qty' => 0,
'stock_in_id' => 0,
];
@@ -13,13 +13,14 @@ $prm = new PurchaseRequestManager($pdo2, $company_id);
try {
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['message'] = 'Purchase request created.';
$answer['new_id'] = $new_id;
(new UsageGuard($pdo1, $company_id, $packages))->increment();
} 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['message'] = 'Purchase request updated.';
} else {
+1 -1
View File
@@ -230,7 +230,7 @@
$('#badge_status').html(invoice_status_badge(inv.status, inv.due_date));
$('#display_contact').text(inv.contact_name || '—');
$('#display_issued').text(format_date(inv.issued_date) || '—');
$('#due_date').val(inv.due_date ? format_date(inv.due_date) : '');
$('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : '');
$('#notes').val(inv.notes || '');
// Source link
+5 -5
View File
@@ -194,7 +194,7 @@
<td><input type="number" class="form-control form-control-sm item_qty" value="${item.quantity || 1}" min="0.0001" step="any" oninput="recalc_totals()"></td>
<td><input type="number" class="form-control form-control-sm item_price" value="${item.unit_price || item.price || 0}" min="0" step="any" oninput="recalc_totals()"></td>
<td>
<input type="number" class="form-control form-control-sm item_tax_rate" value="${parseFloat(item.tax_rate || 0).toFixed(2)}" min="0" max="100" step="0.01" oninput="recalc_totals()">
<input type="number" class="form-control form-control-sm item_tax_rate" value="${line_tax_rate(item).toFixed(2)}" min="0" max="100" step="0.01" oninput="recalc_totals()">
<input type="hidden" class="item_tax_amount" value="${item.tax_amount || 0}">
</td>
<td class="item_total text-end">${format_number((item.quantity || 1) * (item.unit_price || item.price || 0), 2)}</td>
@@ -274,8 +274,8 @@
$('#po_number_display').text(po_data.po_number || '');
$('#contact').val(po_data.contact_name || '');
$('#contact_id').val(po_data.contact_id || '');
$('#po_date').val(po_data.po_date ? format_date(po_data.po_date) : '');
$('#expected_date').val(po_data.expected_date ? format_date(po_data.expected_date) : '');
$('#po_date').val(po_data.po_date ? format_date_input(po_data.po_date) : '');
$('#expected_date').val(po_data.expected_date ? format_date_input(po_data.expected_date) : '');
$('#department_id').val(po_data.department_id || 0);
$('#notes').val(po_data.notes || '');
$('#discount').val(po_data.discount || 0);
@@ -371,8 +371,8 @@
recalc_totals();
});
$(function() {
load_departments('department_id');
$(async function() {
await Promise.resolve(load_departments('department_id')).catch(function() {});
flatpickr('#po_date', { dateFormat: 'd/m/Y', allowInput: true });
flatpickr('#expected_date', { dateFormat: 'd/m/Y', allowInput: true });
if (po_id) {
+5 -4
View File
@@ -253,8 +253,8 @@
.html(request_data.po_id > 0 ? 'PO: <a href="<?php echo $server_url?>expense/manage_purchase_order.php?id=' + request_data.po_id + '">' + escape_html(request_data.po_number || ('PO #' + request_data.po_id)) + '</a>' : '');
$('#contact').val(request_data.contact_name || '');
$('#contact_id').val(request_data.contact_id || 0);
$('#request_date').val(request_data.request_date ? format_date(request_data.request_date) : '');
$('#required_date').val(request_data.required_date ? format_date(request_data.required_date) : '');
$('#request_date').val(request_data.request_date ? format_date_input(request_data.request_date) : '');
$('#required_date').val(request_data.required_date ? format_date_input(request_data.required_date) : '');
$('#department_id').val(request_data.department_id || 0);
$('#notes').val(request_data.notes || '');
$('#discount').val(request_data.discount || 0);
@@ -370,8 +370,9 @@
recalc_totals();
});
$(function() {
load_departments('department_id');
$(async function() {
// Options must exist before retrieve_request() selects the saved department
await Promise.resolve(load_departments('department_id')).catch(function() {});
flatpickr('#request_date', { dateFormat: 'd/m/Y', allowInput: true });
flatpickr('#required_date', { dateFormat: 'd/m/Y', allowInput: true });
if (request_id) {
+22 -6
View File
@@ -26,7 +26,7 @@
</div>
<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="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-primary bg-opacity-10 text-primary rounded-2">
@@ -39,7 +39,7 @@
</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-success bg-opacity-10 text-success rounded-2">
@@ -52,7 +52,7 @@
</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-warning bg-opacity-10 text-warning rounded-2">
@@ -65,7 +65,20 @@
</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="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-info bg-opacity-10 text-info rounded-2">
@@ -255,8 +268,11 @@
function update_stats() {
var pis = all_invoices.filter(i => i.doc_type === 'purchase_invoice');
$('#stat_invoice').text(format_number(pis.length));
$('#stat_paid').text(format_number(pis.filter(i => String(i.status) === '2').length));
$('#stat_open').text(format_number(pis.filter(i => ['0','1'].includes(String(i.status))).length));
// Same test as the row badge, so a tile never disagrees with the list below it
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));
}
+2 -2
View File
@@ -180,7 +180,7 @@
return;
}
$.each(rows, function(i, r) {
var items = typeof r.items === 'string' ? JSON.parse(r.items || '[]') : (r.items || []);
var item_count = parseInt(r.item_count) || 0;
var po_link = r.po_id > 0
? `<a href="<?php echo $server_url?>expense/manage_purchase_order.php?id=${r.po_id}">${escape_html(r.po_number || ('PO #' + r.po_id))}</a>`
: '<span class="text-muted">—</span>';
@@ -190,7 +190,7 @@
<td class="py-3">${r.required_date ? format_date(r.required_date) : '<span class="text-muted">—</span>'}</td>
<td class="py-3">${escape_html(r.contact_name || '—')}</td>
<td class="py-3">${get_dept_label(r.department_id)}</td>
<td class="py-3">${items.length} item${items.length !== 1 ? 's' : ''}</td>
<td class="py-3">${item_count} item${item_count !== 1 ? 's' : ''}</td>
<td class="py-3 fw-semibold">${format_number(r.grand_total, 2)}</td>
<td class="py-3">${status_badge[String(r.status)] || r.status}</td>
<td class="py-3">${po_link}</td>
+9 -1
View File
@@ -1,9 +1,17 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
// A detail-only page: payments are created from the Payments list, so with no
// id there is nothing to show. Send the user there instead of rendering an
// empty page that alerts "Payment id is required."
$payment_id = (int)($_GET['id'] ?? 0);
if (!$payment_id) {
header('Location: ' . $server_url . 'finance/payment.php');
exit;
}
require '../include_header.php';
?>
<body>
+9 -1
View File
@@ -1,9 +1,17 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
// A detail-only page: payment billings are created from their list, so with
// no id there is nothing to show. Send the user there instead of rendering an
// empty page that alerts "Payment billing id is required."
$billing_id = (int)($_GET['id'] ?? 0);
if (!$billing_id) {
header('Location: ' . $server_url . 'finance/payment_billing.php');
exit;
}
require '../include_header.php';
?>
<body>
+9 -1
View File
@@ -1,9 +1,17 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
// A detail-only page: receipts are created from the Receipts list, so with no
// id there is nothing to show. Send the user there instead of rendering an
// empty page that alerts "Receipt id is required."
$receipt_id = (int)($_GET['id'] ?? 0);
if (!$receipt_id) {
header('Location: ' . $server_url . 'finance/receipt.php');
exit;
}
require '../include_header.php';
?>
<body>
+9 -1
View File
@@ -1,9 +1,17 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
// A detail-only page: receipt billings are created from their list, so with
// no id there is nothing to show. Send the user there instead of rendering an
// empty page that alerts "Receipt billing id is required."
$billing_id = (int)($_GET['id'] ?? 0);
if (!$billing_id) {
header('Location: ' . $server_url . 'finance/receipt_billing.php');
exit;
}
require '../include_header.php';
?>
<body>
+38
View File
@@ -0,0 +1,38 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/WarehouseManager.php';
// What one location holds — the quantity a stock-out or transfer from it moves
// (StockManager takes the whole approved stock-in row in the bin).
try {
$whMgmt = new WarehouseManager($pdo2, $company_id);
$row = $whMgmt->getBinStock(
(int)($data['warehouse'] ?? 0),
$data['zone'] ?? '',
$data['aisle'] ?? '',
$data['bin'] ?? ''
);
$output = null;
if ($row) {
$sth = $pdo2->prepare("SELECT uom FROM md_product WHERE company_id = :c AND sku = :sku LIMIT 1");
$sth->execute([':c' => $company_id, ':sku' => $row['product_sku']]);
$output = [
'product_sku' => $row['product_sku'],
'lot_number' => $row['lot_number'],
'serial_number' => $row['serial_number'],
'quantity' => (float)$row['in'],
'uom' => (string)($sth->fetchColumn() ?: ''),
];
}
$answer['output'] = $output;
$answer['success'] = 1;
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
?>
+20 -4
View File
@@ -17,11 +17,27 @@
$answer['output'] = $wh->getWarehouseList($type, $sku, $id);
}
if (empty($answer['output'])) {
if (!empty($answer['output'])) {
$answer['success'] = 1;
} elseif ($type === 'from' && !$id && $sku === '') {
// A source ("from") list is filtered to warehouses holding the chosen
// product, so it is empty until a product has been picked. That is the
// normal starting state of a stock-out form, not a failure. Reporting
// it as one showed "create your first warehouse" on every fresh
// stock-out, and the rejected request aborted the page's boot sequence
// before the barcode scanner was initialised.
$answer['success'] = 1;
} elseif ($type === 'from' && $sku !== '') {
$answer['success'] = 0;
$answer['message'] = $lot
? 'No approved stock of this product and lot is available in any warehouse.'
: 'No approved stock of this product is available in any warehouse.';
} else {
$answer['success'] = 0;
$answer['message'] = 'No warehouse found. Please go to <b>Inventory → Warehouse</b> to create your first warehouse before using this page.';
} else {
$answer['success'] = 1;
}
exit(json_encode($answer));
?>
?>
+36
View File
@@ -257,6 +257,31 @@
var sku_val = $('#product_sku').attr('secondary') || $('#product_sku').val();
var quantity_val = $('#quantity').val();
// Quantities are stored as decimal(18,4). Anything finer is rounded away
// on insert, so 0.0000001 used to become a movement of 0.0000 that still
// looked like a successful stock-in. Reject it here with a message that
// names the limit; StockManager enforces the same rule server-side.
<?php if (empty($_GET["id"])) { ?>
var quantity_num = parseFloat(quantity_val);
if (!(quantity_num > 0)) {
bootbox.alert('Please enter a quantity greater than zero.');
return Promise.resolve();
}
if (round_dp(quantity_num, 4) < 0.0001) {
bootbox.alert('Quantity ' + quantity_num + ' is smaller than the minimum the system records (0.0001).');
return Promise.resolve();
}
quantity_val = round_dp(quantity_num, 4);
// Expiry dates are stored on the lot, so one entered without a lot number
// has nowhere to go and would be dropped without warning.
if ($('#expiry_date').val() && !$('#lot_number').val().trim()) {
bootbox.alert('Enter a lot number — expiry dates are recorded against a lot.');
return Promise.resolve();
}
<?php } ?>
// Mirror to hidden inputs for autoPrepare consistency (simple mode)
if (!advanced) {
$('#zone').val(bin_val);
@@ -462,7 +487,18 @@
.val(data.expiry_date);
if (expiryPicker && expiryPicker.altInput) { expiryPicker.altInput.disabled = true; }
} else {
// No lot number, so there is nothing for an expiry date to hang
// off: expiry lives on md_lot, keyed by lot. Leaving the field
// empty but editable invited entering one that would be silently
// discarded on update, so say why it is unavailable instead.
if (expiryPicker) { expiryPicker.clear(); }
$('#expiry_date').prop('disabled', true)
.attr('title', 'Expiry dates are recorded against a lot — this movement has no lot number')
.attr('placeholder', 'Not tracked — no lot number');
if (expiryPicker && expiryPicker.altInput) {
expiryPicker.altInput.disabled = true;
expiryPicker.altInput.placeholder = 'Not tracked — no lot number';
}
}
$('#product_sku').attr('secondary', data.product_sku);
$('#product_sku, #quantity, #price').prop('disabled', true);
+47 -1
View File
@@ -93,6 +93,16 @@
</select>
</div>
<!-- Quantity: a stock-out always takes everything in the location -->
<div class="mb-3 col-lg-6">
<label for="location_quantity" class="form-label">Quantity</label>
<div class="input-group">
<input type="text" id="location_quantity" class="form-control text-end" placeholder="—" disabled>
<span class="input-group-text" id="location_uom" style="min-width:60px;">&nbsp;</span>
</div>
<div class="form-text">The whole quantity in the selected location is taken out.</div>
</div>
<!-- Contact -->
<div class="mb-3 col-lg-6">
<label for="contact" class="form-label">Contact</label>
@@ -159,6 +169,35 @@
}
set_select_value('#bin', data.bin, data.bin);
scan_location_ready = true;
show_location_quantity();
});
}
// Quantity held in the chosen location — the amount this stock-out moves.
function show_location_quantity() {
var bin_val = $('#bin').val();
$('#location_quantity').val('');
$('#location_uom').html('&nbsp;');
if (!$('#warehouse').val() || !bin_val) return;
return ajax_request({
url: '<?php echo $server_url?>ics/api/engine/retrieve_bin_stock.php',
autoPrepare: true,
checkRequired: 0,
noLoading: true,
queueLock: false,
action: 'read',
data: {
warehouse: $('#warehouse').val(),
zone: advanced ? $('#zone').val() : bin_val,
aisle: advanced ? $('#aisle').val() : bin_val,
bin: bin_val
},
onSuccess: function(res) {
if (!res.output) return;
$('#location_quantity').val(format_number(res.output.quantity, 2));
$('#location_uom').text(res.output.uom || '');
}
});
}
@@ -483,6 +522,8 @@
$('#zone').html('<option value="">Please select ' + label_zone.toLowerCase() + '</option>').prop('disabled', true);
$('#aisle').html('<option value="">Please select ' + label_aisle.toLowerCase() + '</option>').prop('disabled', true);
$('#bin').html('<option value="">Please select ' + label_bin.toLowerCase() + '</option>').prop('disabled', true);
$('#location_quantity').val('');
$('#location_uom').html('&nbsp;');
}
@@ -509,7 +550,7 @@
$('#serial_number').html(`<option value="${data.serial_number || ''}">${data.serial_number || '—'}</option>`)
.val(data.serial_number || '').prop('disabled', true);
$('#warehouse').val('<?php echo $_GET["wh"];?>').prop('disabled', true);
$('#warehouse').val('<?php echo (int)($_GET["wh"] ?? 0);?>').prop('disabled', true);
function populate_fields() {
$.each(data, function(key, item) {
@@ -522,6 +563,8 @@
.attr('data-id', data.contact_id);
$('#product_name').val(data.product_name);
$('#product_sku').attr('secondary', data.product_sku).prop('disabled', true);
$('#location_quantity').val(format_number(data.quantity, 2));
$('#location_uom').text(data.uom || '');
$('button[type=submit]').text('Update');
$('button[type=reset]').hide();
if (data.status == 0) {
@@ -619,6 +662,9 @@
if (advanced) retrieve_bin();
});
// Bin selected → show how much it holds
$('#bin').on('change', show_location_quantity);
<?php } ?>
</script>
+51
View File
@@ -92,6 +92,15 @@
<option value="">Please select bin</option>
</select>
</div>
<!-- Quantity: a transfer always moves everything in the from-location -->
<div class="mb-3 col-lg-3">
<label for="transfer_quantity" class="form-label">Quantity</label>
<div class="input-group">
<input type="text" id="transfer_quantity" class="form-control text-end" placeholder="—" disabled>
<span class="input-group-text" id="transfer_uom" style="min-width:52px;">&nbsp;</span>
</div>
<div class="form-text">Whole location is moved.</div>
</div>
<div class="col-12"><hr class="my-2"></div>
@@ -204,12 +213,41 @@
if (role === 'from') {
scan_from_ready = true;
show_from_quantity();
} else {
scan_to_ready = true;
}
});
}
// Quantity held in the from-location — the amount this transfer moves.
function show_from_quantity() {
var bin_val = $('#bin_from').val();
$('#transfer_quantity').val('');
$('#transfer_uom').html('&nbsp;');
if (!$('#warehouse_from').val() || !bin_val) return;
return ajax_request({
url: '<?php echo $server_url?>ics/api/engine/retrieve_bin_stock.php',
autoPrepare: true,
checkRequired: 0,
noLoading: true,
queueLock: false,
action: 'read',
data: {
warehouse: $('#warehouse_from').val(),
zone: advanced ? $('#zone_from').val() : bin_val,
aisle: advanced ? $('#aisle_from').val() : bin_val,
bin: bin_val
},
onSuccess: function(res) {
if (!res.output) return;
$('#transfer_quantity').val(format_number(res.output.quantity, 2));
$('#transfer_uom').text(res.output.uom || '');
}
});
}
function same_location_as_from(data) {
return String($('#warehouse_from').val()) === String(data.warehouse_id)
&& String($('#zone_from').val()) === String(data.zone)
@@ -574,6 +612,8 @@
$('#zone_from').html('<option value="">Please select ' + label_zone.toLowerCase() + '</option>').prop('disabled', true);
$('#aisle_from').html('<option value="">Please select ' + label_aisle.toLowerCase() + '</option>').prop('disabled', true);
$('#bin_from').html('<option value="">Please select ' + label_bin.toLowerCase() + '</option>').prop('disabled', true);
$('#transfer_quantity').val('');
$('#transfer_uom').html('&nbsp;');
}
@@ -590,6 +630,12 @@
action: 'read',
onSuccess: function(res) {
var data = res.output;
if (!data || !data.ref) {
bootbox.alert('Stock transfer not found.', function() {
window.location.href = '<?php echo $server_url?>ics/stock_transfer.php';
});
return;
}
var from_wh = data.ref.ref_warehouse;
var to_wh = data.ref_warehouse;
@@ -640,6 +686,8 @@
$('#contact').val(data.contact_name)
.attr('secondary', data.contact_name)
.attr('data-id', data.contact_id);
$('#transfer_quantity').val(format_number(data.quantity, 2));
$('#transfer_uom').text(data.uom || '');
$('button[type=submit]').text('Update');
$('button[type=reset]').hide();
if (data.status == 0) {
@@ -732,6 +780,9 @@
if (advanced) retrieve_bin('from');
});
// From bin → show how much it holds
$('#bin_from').on('change', show_from_quantity);
// To warehouse → zone (advanced) or bin directly (simple)
$('select[name="warehouse"][role="to"]').on('change', function() {
if (advanced) { retrieve_zone('to'); } else { retrieve_bin('to'); }
+17 -9
View File
@@ -32,7 +32,7 @@
<input class="form-control" value='Warehouse' disabled>
</div>
<div class="mb-3 col-lg-2">
<select name="warehouse" id="warehouse" class="form-select" required></select>
<select name="warehouse" id="warehouse" class="form-select"></select>
</div>
<div class="mb-3 col-lg-2">
<input class="form-control" value='Source' disabled>
@@ -56,6 +56,7 @@
<tr>
<th>Date</th>
<th>Reference</th>
<th>Warehouse</th>
<th>Product</th>
<th>Lot Number</th>
<th>Serial Number</th>
@@ -210,18 +211,21 @@
var body = ``;
var warehouse = $(`select#warehouse`).val();
$.each(page_data, function(key, item) {
// The row's own warehouse, not the filter — with "All Warehouses" the
// filter has no value, and each row lives in its own td_stock_<id> table.
var warehouse = item['warehouse_id'];
body += `<tr>
<td class="py-3">${format_date(item["date"])}</td>
<td class="py-3 fw-semibold">${item["stock_reference"] || "—"}</td>
<td class="py-3">${escape_html(item['warehouse_name'] || '—')}</td>
<td class="py-3">${item["product_sku"]}: ${item['product_name']}</td>
<td class="py-3">${item['lot_number'] || '<span class="text-muted">—</span>'}</td>
<td class="py-3">${item['serial_number'] || '<span class="text-muted">—</span>'}</td>
<td class="py-3">
<div class="d-flex justify-content-end align-items-baseline gap-1">
<span>${format_number(item['quantity'], 2)}</span>
<span>${format_quantity(item['quantity'])}</span>
<span class="text-muted small" style="min-width:28px;">${item['uom'] || ''}</span>
</div>
</td>
@@ -234,7 +238,7 @@
<td class="py-3">
<a href="<?php echo $server_url?>ics/manage_stock_in.php?id=${item['id']}&wh=${warehouse}" class=""><i class="ti ti-eye fs-5"></i></a>
<a href="javascript:void(0);" class="link-danger"
onclick="delete_stock_in($(this),${item['id']})">
onclick="delete_stock_in($(this),${item['id']},${warehouse})">
<i class="ti ti-trash ms-2 fs-5"></i>
</a>
</td>
@@ -255,9 +259,13 @@
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
var option = ``;
// Default to every warehouse. Stock is stored one table per warehouse,
// so defaulting to a single one made a receipt that was split across
// warehouses look incomplete — a 4-line PO showed only the 3 lines that
// landed in the selected warehouse.
var option = `<option value=''>All Warehouses</option>`;
$.each(res.output, function(key, item) {
option += `<option value='${item.id}'>${item.warehouse_name}</option>`;
option += `<option value='${item.id}'>${escape_html(item.warehouse_name)}</option>`;
})
$(`select#warehouse`).html(option);
}
@@ -265,7 +273,7 @@
}
function delete_stock_in(element, id) {
function delete_stock_in(element, id, warehouse_id) {
return ajax_request({
url: "<?php echo $server_url?>ics/api/engine/delete_stock_in.php",
@@ -274,7 +282,7 @@
action: 'delete',
data : {
id: id,
wh: $(`select#warehouse`).val()
wh: warehouse_id
},
onSuccess: function(res) {
element.closest('tr').remove();
+16 -9
View File
@@ -32,7 +32,7 @@
<input class="form-control" value='Warehouse' disabled>
</div>
<div class="mb-3 col-lg-2">
<select name="warehouse" id="warehouse" class="form-select" required></select>
<select name="warehouse" id="warehouse" class="form-select"></select>
</div>
<div class="mb-3 col-lg-2">
<input class="form-control" value='Source' disabled>
@@ -56,6 +56,7 @@
<tr>
<th>Date</th>
<th>Reference</th>
<th>Warehouse</th>
<th>Product</th>
<th>Lot Number</th>
<th>Serial Number</th>
@@ -151,7 +152,7 @@
}
var delete_btn = (!source)
? `<a href="javascript:void(0);" class="link-danger" onclick="delete_stock_out($(this),${item['id']})">
? `<a href="javascript:void(0);" class="link-danger" onclick="delete_stock_out($(this),${item['id']},${warehouse})">
<i class="ti ti-trash ms-2 fs-5"></i>
</a>`
: '';
@@ -237,18 +238,21 @@
var body = ``;
var warehouse = $(`select#warehouse`).val();
$.each(page_data, function(key, item) {
// The row's own warehouse, not the filter — with "All Warehouses" the
// filter has no value, and each row lives in its own td_stock_<id> table.
var warehouse = item['warehouse_id'];
body += `<tr>
<td class="py-3">${format_date(item["date"])}</td>
<td class="py-3 fw-semibold">${item["stock_reference"] || "—"}</td>
<td class="py-3">${escape_html(item['warehouse_name'] || '—')}</td>
<td class="py-3">${item["product_sku"]}: ${item['product_name']}</td>
<td class="py-3">${item['lot_number'] || '<span class="text-muted">—</span>'}</td>
<td class="py-3">${item['serial_number'] || '<span class="text-muted">—</span>'}</td>
<td class="py-3">
<div class="d-flex justify-content-end align-items-baseline gap-1">
<span>${format_number(item['quantity'], 2)}</span>
<span>${format_quantity(item['quantity'])}</span>
<span class="text-muted small" style="min-width:28px;">${item['uom'] || ''}</span>
</div>
</td>
@@ -275,9 +279,12 @@
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
var option = ``;
// Default to every warehouse — stock is stored one table per
// warehouse, so a single-warehouse default hides movements that went
// elsewhere and makes a document look only partly processed.
var option = `<option value=''>All Warehouses</option>`;
$.each(res.output, function(key, item) {
option += `<option value='${item.id}'>${item.warehouse_name}</option>`;
option += `<option value='${item.id}'>${escape_html(item.warehouse_name)}</option>`;
})
$(`select#warehouse`).html(option);
}
@@ -285,7 +292,7 @@
}
function delete_stock_out(element, id) {
function delete_stock_out(element, id, warehouse_id) {
return ajax_request({
url: "<?php echo $server_url?>ics/api/engine/delete_stock_out.php",
@@ -294,7 +301,7 @@
action: 'delete',
data : {
id: id,
wh: $(`select#warehouse`).val()
wh: warehouse_id
},
onSuccess: function(res) {
element.closest('tr').remove();
+25 -1
View File
@@ -1,4 +1,28 @@
<?php
// Output buffering must be active before the first byte of HTML below, so that
// header() calls made later in the page still work — notably the
// not-logged-in redirect in include_topbar.php, which runs *after* this file
// has already emitted <!DOCTYPE html>. Without a buffer that redirect depends
// entirely on php.ini's output_buffering: it is on for the dev stack but off
// in production, where every protected page answered 200 with a half-rendered
// body instead of sending the browser to the login form. session.php starts a
// buffer for the same reason.
if (ob_get_level() === 0) {
ob_start();
}
// Never render PHP notices/warnings into the page: they leak absolute server
// paths to anonymous visitors and corrupt the markup. Errors still reach the
// server log. This mirrors the policy db_auth.php already applies to the JSON
// API routes, and keeps the app safe even where php.ini has display_errors on.
ini_set('display_errors', '0');
ini_set('log_errors', '1');
// Apply the configured application timezone. Pages that only require config.php
// (no dbconn.php) still call date() for default values such as "today", so they
// need this too or they render a UTC date.
require_once __DIR__ . '/assets/utils/timezone.php';
// Security headers — emitted before any HTML output.
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: SAMEORIGIN');
@@ -66,7 +90,7 @@ header('Referrer-Policy: strict-origin-when-cross-origin');
<link rel="stylesheet" href="<?php echo $server_url?>assets/css/main.css">
<script type="module" src="<?php echo $server_url?>assets/js/main.js"></script>
<link rel="stylesheet" href="<?php echo $server_url?>assets/css/custom.css">
<script src="<?php echo $server_url?>assets/js/custom.js"></script>
<script src="<?php echo $server_url?>assets/js/custom.js?v=<?php echo @filemtime(__DIR__ . '/assets/js/custom.js'); ?>"></script>
<script src="<?php echo $server_url?>assets/js/batch_overlay.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html5-qrcode/2.3.8/html5-qrcode.min.js"
+21 -1
View File
@@ -3,11 +3,17 @@
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/dbconn.php';
require_once __DIR__ . '/assets/utils/classes/UsageGuard.php';
require_once __DIR__ . '/assets/utils/otp_policy.php';
// Redirect to login if the user has not completed full authentication.
// login_company_id is only written by login_confirm.php after OTP is verified —
// using it (not "otp") ensures half-logged-in sessions are also redirected.
if(empty($_SESSION["login_company_id"])){
// Discard the markup include_header.php has already buffered so the browser
// receives a clean redirect rather than a partially rendered page body.
while (ob_get_level() > 0) {
ob_end_clean();
}
header('Location: '.$server_url.'login/index.php');
exit;
}
@@ -193,6 +199,18 @@ $_usage_full = $_usage_max_pct >= 100;
</li>
<?php endif; ?>
<!-- Email OTP off (the default): a password-only sign-in must never be invisible to whoever is using it -->
<?php if (!otp_required()): ?>
<li class="d-none d-md-block">
<span class="badge bg-warning text-dark d-flex align-items-center gap-1 px-2 py-1"
style="font-size:11px; cursor:default;"
title="OTP_REQUIRED is not true in config.php">
<i class="ti ti-shield-off"></i>
OTP off
</span>
</li>
<?php endif; ?>
<!-- Usage limit warning -->
<?php if ($_usage_full || $_usage_warn): ?>
<li>
@@ -402,7 +420,9 @@ function do_switch_branch(company_id) {
autoPrepare: true,
checkRequired: 0,
action: 'update',
company_id: company_id,
// Sent under its own key: prepare_form_data() always fills company_id with
// the CURRENT company, and only options.data reaches the payload.
data: { target_company_id: company_id },
onSuccess: function(res) {
window.location.reload();
}
+28 -10
View File
@@ -117,16 +117,32 @@
};
function load_listing() {
var period = document.getElementById('f_period').value;
var period = document.getElementById('f_period').value; // "YYYY-MM"
var source = document.getElementById('f_source').value;
// The engine filters on a date range, not a period, so turn the chosen
// month into its first and last day.
var date_from = '', date_to = '';
if (/^\d{4}-\d{2}$/.test(period)) {
var ym = period.split('-');
var last_day = new Date(parseInt(ym[0]), parseInt(ym[1]), 0).getDate();
date_from = period + '-01';
date_to = period + '-' + ('0' + last_day).slice(-2);
}
// Payload fields must go inside `data` — ajax_request() ignores unknown
// top-level options, so as siblings of `url` these were never sent and the
// month / source filters silently did nothing.
ajax_request({
url: server_url + 'accounting/api/engine/get_journal_listing.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
period: period,
source_type: source,
data: {
source_type: source,
date_from: date_from,
date_to: date_to
},
onSuccess: function(res) {
var rows = res.output || [];
document.getElementById('jl_badge').textContent = rows.length + ' entries';
@@ -146,9 +162,9 @@
'<td><span class="badge rounded-pill ' + cls + ' small">' + escape_html(label) + '</span></td>' +
'<td class="small text-muted">' + escape_html(r.contact_name || '—') + '</td>' +
'<td class="small">' + escape_html(r.period) + '</td>' +
'<td class="text-end small">' + format_number(r.total_debit) + '</td>' +
'<td class="text-end small">' + format_number(r.total_credit) + '</td>' +
'<td class="small text-muted">' + escape_html(r.posted_at) + '</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="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>' +
(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>' +
@@ -169,7 +185,9 @@
autoPrepare: true,
checkRequired: 0,
action: 'read',
gl_id: gl_id,
// Inside `data`, or it is never sent and the engine answers
// "gl_id is required." for every row.
data: { gl_id: gl_id },
onSuccess: function(res) {
var d = res.output;
var h = d.header;
@@ -180,14 +198,14 @@
'<td>' + escape_html(l.account_code) + '</td>' +
'<td>' + escape_html(l.account_name) + '</td>' +
'<td class="text-muted small">' + escape_html(l.description || '—') + '</td>' +
'<td class="text-end">' + (parseFloat(l.debit) ? format_number(l.debit) : '—') + '</td>' +
'<td class="text-end">' + (parseFloat(l.credit) ? format_number(l.credit) : '—') + '</td>' +
'<td class="text-end">' + (parseFloat(l.debit) ? format_number(l.debit, 2) : '—') + '</td>' +
'<td class="text-end">' + (parseFloat(l.credit) ? format_number(l.credit, 2) : '—') + '</td>' +
'</tr>';
}).join('');
document.getElementById('gl_detail_body').innerHTML =
'<div class="row g-3 mb-4">' +
'<div class="col-6"><p class="text-muted small mb-0">Date</p><strong>' + escape_html(h.journal_date_fmt || h.journal_date || '—') + '</strong></div>' +
'<div class="col-6"><p class="text-muted small mb-0">Date</p><strong>' + escape_html(format_date(h.journal_date)) + '</strong></div>' +
'<div class="col-6"><p class="text-muted small mb-0">Period</p><strong>' + escape_html(h.period) + '</strong></div>' +
'<div class="col-6"><p class="text-muted small mb-0">Reference</p><strong>' + escape_html(h.reference || '—') + '</strong></div>' +
'<div class="col-6"><p class="text-muted small mb-0">Source</p><strong>' + escape_html(src_labels[h.source_type] || h.source_type) + '</strong></div>' +
+12 -6
View File
@@ -216,11 +216,17 @@
autoPrepare: true,
checkRequired: 0,
action: 'manage',
gl_id: document.getElementById('gl_id').value || 0,
journal_date: jdate,
reference: document.getElementById('reference').value,
description: document.getElementById('description').value,
lines: JSON.stringify(lines),
// Payload fields must go inside `data` — ajax_request() ignores unknown
// top-level options. As siblings of `url`, `lines` was never sent (it is
// not a form field, so autoPrepare could not pick it up either) and every
// save was refused with "At least two journal lines are required."
data: {
gl_id: document.getElementById('gl_id').value || 0,
journal_date: jdate,
reference: document.getElementById('reference').value,
description: document.getElementById('description').value,
lines: JSON.stringify(lines)
},
onSuccess: function() {
window.location.href = server_url + 'journal/index.php';
}
@@ -238,7 +244,7 @@
autoPrepare: true,
checkRequired: 0,
action: 'read',
gl_id: <?php echo (int)$_GET['gl_id']; ?>,
data: { gl_id: <?php echo (int)($_GET['gl_id'] ?? 0); ?> },
onSuccess: function(res) {
var d = res.output;
var h = d.header;
+20 -11
View File
@@ -58,6 +58,7 @@ require_once '../../../config.php';
require_once '../../../preset.php';
define('UNAUTHENTICATED_ROUTE', true);
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/otp_policy.php';
// ── Step 1: Load session state written by login_otp.php ───────────────────────
$data["username"] = $_SESSION["login_data"]['username'];
@@ -100,9 +101,15 @@ $_SESSION["diff"] = $otp_diff_minutes;
// ── Step 4: Validate OTP value and expiry ─────────────────────────────────────
// Skipped for staff/viewer roles — login_otp.php sets skip_otp=true in session
// so they never receive or enter an OTP. Admin/owner always go through this check.
// so they never receive or enter an OTP. Admin/owner always go through this check,
// unless OTP_REQUIRED=false in config.php: that also covers a user who was already
// on the OTP screen when the switch was turned off.
if (empty($_SESSION['skip_otp'])) {
if ($data["otp"] != $otp || $otp_diff_minutes > 5) {
if (!otp_required()) {
if (!empty($user_id)) {
otp_log_bypass($user_id, 'login_confirm');
}
} elseif ($data["otp"] != $otp || $otp_diff_minutes > 5) {
$answer["message"] = "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)";
exit(json_encode($answer));
}
@@ -127,15 +134,17 @@ if (empty($_SESSION['skip_otp'])) {
// An explicit logout clears session_token to NULL, so back.php bypasses this.
//
// The staleness comparison is done entirely in SQL (session_last_seen vs MySQL's
// own NOW()), not in PHP. session_last_seen is written with MySQL's NOW(), and
// the MySQL server here runs on Asia/Bangkok time while PHP's default timezone is
// UTC (config.php's $time_zone is never applied via date_default_timezone_set()).
// Pulling the timestamp into PHP and comparing with strtotime()/time() silently
// misreads that Bangkok wall-clock string as UTC — 7 hours in the future — which
// made idle_seconds permanently negative and this check block every login,
// regardless of window size. Comparing inside MySQL sidesteps the mismatch
// without touching PHP's global timezone (which would ripple into every other
// date()/time() call in the app).
// own NOW()), not in PHP, because session_last_seen is written with MySQL's
// NOW() and so is best compared against it.
//
// This originally worked around a timezone mismatch: config.php's $time_zone was
// never applied via date_default_timezone_set(), so PHP ran on UTC while the
// MySQL server ran on Asia/Bangkok. Pulling the timestamp into PHP and comparing
// with strtotime()/time() misread that Bangkok wall-clock string as UTC — 7 hours
// in the future — which made idle_seconds permanently negative and blocked every
// login. assets/utils/timezone.php now applies $time_zone to PHP and pins both
// PDO connections to the same offset, so the mismatch is gone; comparing in SQL
// is kept because it is still the most direct way to read a NOW()-written column.
define('SESSION_ACTIVE_GRACE_SECONDS', 120);
$sth_active = $pdo1->prepare(
+9 -4
View File
@@ -62,6 +62,7 @@ require_once '../../../config.php';
require_once '../../../preset.php';
define('UNAUTHENTICATED_ROUTE', true);
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/otp_policy.php';
// ── Step 1: Resolve user_id from username or email (case-insensitive) ────────
$sth = $pdo1->prepare("select user_id from user where ? in (username,email) ");
@@ -253,11 +254,15 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
exit(json_encode($answer));
}
// ── Step 5g: Role check — staff/viewer skip OTP entirely ─────────────────
// Owners always require 2FA. Invited users (license='user') require 2FA only
// ── Step 5g: OTP policy, then role check — staff/viewer skip OTP entirely ─
// OTP_REQUIRED=false in config.php turns the email OTP off for everyone and
// logs the sign-in as a bypass (see assets/utils/otp_policy.php).
// Otherwise owners always require 2FA. Invited users (license='user') require 2FA only
// if their role in this company is admin or owner; staff/viewer go straight in.
$requires_otp = true;
if (($r['license'] ?? 'owner') !== 'owner') {
$requires_otp = otp_required();
if (!$requires_otp) {
otp_log_bypass($user_id, 'login_otp');
} elseif (($r['license'] ?? 'owner') !== 'owner') {
$sth_role = $pdo1->prepare(
"SELECT role FROM company_map_user WHERE company_id = :cid AND user_id = :uid LIMIT 1"
);
+79 -64
View File
@@ -19,8 +19,10 @@
* 2. CSRF check — rejects requests missing a valid X-CSRF-Token header.
* 3. Decode and sanitise input fields.
* 4. Required field validation — company_name and channel_name must be non-empty.
* 5. Required SMTP validation — smtp_host, smtp_username, smtp_password
* must all be provided (company SMTP is mandatory for WMS email delivery).
* 5. SMTP validation — smtp_host, smtp_username, smtp_password must all be
* provided while email OTP is on (company SMTP delivers the OTP). With
* OTP_REQUIRED=false they are optional but all-or-nothing: left blank,
* steps 6-8 and 13 are skipped and the company is created without SMTP.
* 6. Normalise smtp_port to one of ['25', '465', '587'] (default: 587).
* Normalise smtp_encryption to one of ['tls', 'ssl', 'none'] (default: tls).
* 7. Encrypt SMTP password with OpenSSL (same method/iv/key as rest of app).
@@ -52,6 +54,7 @@ require_once '../../../session.php';
require_once '../../../config.php';
require_once '../../../dbconn.php';
require_once '../../../assets/utils/db_helpers.php';
require_once '../../../assets/utils/otp_policy.php';
header('Content-Type: application/json; charset=utf-8');
@@ -97,9 +100,9 @@ try {
$company_name = trim($data['company_name'] ?? '');
$company_name2 = trim($data['company_name2'] ?? '');
// channel_name is the URL slug / identifier — strip everything except
// lowercase letters, digits, hyphens, and underscores.
$channel_name = strtolower(preg_replace('/[^a-z0-9\-_]/', '', $data['channel_name'] ?? ''));
// channel_name is the URL slug / identifier — lowercase first, then strip
// everything except lowercase letters, digits, hyphens, and underscores.
$channel_name = preg_replace('/[^a-z0-9\-_]/', '', strtolower(trim($data['channel_name'] ?? '')));
$branch = trim($data['branch'] ?? 'สำนักงานใหญ่');
$branch_no = trim($data['branch_no'] ?? '00000');
@@ -114,59 +117,67 @@ try {
}
// ── Step 5: SMTP field validation ────────────────────────────────────────
// SMTP is mandatory because the company needs to send OTP emails to users.
// An account without working SMTP would be unable to complete 2FA login.
// While email OTP is on, SMTP is mandatory: the company needs it to send OTP
// emails, and an account without working SMTP could not complete 2FA login.
// With OTP_REQUIRED=false in config.php it is optional — all three fields
// left blank means "no SMTP", and the test send (step 8) and the company_smtp
// row (step 13) are skipped. Partly filled is an error either way.
$smtp_host = trim($data['smtp_host'] ?? '');
$smtp_username = trim($data['smtp_username'] ?? '');
$smtp_password = $data['smtp_password'] ?? '';
$smtp_given = ($smtp_host !== '' || $smtp_username !== '' || $smtp_password !== '');
if (!$smtp_host || !$smtp_username || !$smtp_password) {
$answer['message'] = 'SMTP configuration is required. Please fill in all SMTP fields.';
if ((otp_required() || $smtp_given) && (!$smtp_host || !$smtp_username || !$smtp_password)) {
$answer['message'] = otp_required()
? 'SMTP configuration is required. Please fill in all SMTP fields.'
: 'Fill in SMTP host, username and password, or leave all three blank.';
http_response_code(422);
exit(json_encode($answer));
}
// ── Step 6: Normalise SMTP port and encryption ────────────────────────────
// Clamp to known-good values to prevent storing unsupported configuration.
$smtp_port = trim($data['smtp_port'] ?? '587');
$smtp_encryption = trim($data['smtp_encryption'] ?? 'tls');
if ($smtp_given) {
// ── Step 6: Normalise SMTP port and encryption ────────────────────────
// Clamp to known-good values to prevent storing unsupported configuration.
$smtp_port = trim($data['smtp_port'] ?? '587');
$smtp_encryption = trim($data['smtp_encryption'] ?? 'tls');
if (!in_array($smtp_port, ['25', '465', '587'], true)) $smtp_port = '587';
if (!in_array($smtp_encryption, ['tls', 'ssl', 'none'], true)) $smtp_encryption = 'tls';
if (!in_array($smtp_port, ['25', '465', '587'], true)) $smtp_port = '587';
if (!in_array($smtp_encryption, ['tls', 'ssl', 'none'], true)) $smtp_encryption = 'tls';
// ── Step 7: Encrypt SMTP password ────────────────────────────────────────
// Uses the same OpenSSL method/iv/key as the rest of the app (from config.php)
// so the stored password can be decrypted by the mailer module.
$encrypted_pass = openssl_encrypt($smtp_password, $method, $pinkey, 0, $iv);
// ── Step 7: Encrypt SMTP password ────────────────────────────────────
// Uses the same OpenSSL method/iv/key as the rest of the app (from config.php)
// so the stored password can be decrypted by the mailer module.
$encrypted_pass = openssl_encrypt($smtp_password, $method, $pinkey, 0, $iv);
// Assemble a temporary SMTP config for the test send (step 8)
$smtp_config = [
'server' => $smtp_host,
'port' => $smtp_port,
'username' => $smtp_username,
'password' => $encrypted_pass,
'from_name' => $company_name ?: $smtp_username,
'from_email' => $email ?: $smtp_username,
'encryption' => $smtp_encryption,
];
// Assemble a temporary SMTP config for the test send (step 8)
$smtp_config = [
'server' => $smtp_host,
'port' => $smtp_port,
'username' => $smtp_username,
'password' => $encrypted_pass,
'from_name' => $company_name ?: $smtp_username,
'from_email' => $email ?: $smtp_username,
'encryption' => $smtp_encryption,
];
// ── Step 8: Silent SMTP test — before any DB writes ──────────────────────
// Sends a test email to the onboarding user's registered address.
// If the mailer throws or exits, no DB records have been created yet,
// so the user can correct their SMTP settings and retry cleanly.
require_once '../../../assets/utils/module/mailer.php';
// ── Step 8: Silent SMTP test — before any DB writes ──────────────────
// Sends a test email to the onboarding user's registered address.
// If the mailer throws or exits, no DB records have been created yet,
// so the user can correct their SMTP settings and retry cleanly.
require_once '../../../assets/utils/module/mailer.php';
$mailer = new mailer(['pdo1' => $pdo1]);
$mailer->send_email([
'company_id' => 0,
'smtp' => $smtp_config,
'to' => $_SESSION['onboarding_email'] ?? $smtp_username,
'subject' => 'WMS — SMTP Verification',
'message' => "Your SMTP is working correctly.\n\nSetup is now complete.",
'channel_name' => $company_name ?: 'WMS',
'key' => $pinkey,
]);
// If mailer fails, it calls exit() internally — nothing below this line runs.
$mailer = new mailer(['pdo1' => $pdo1]);
$mailer->send_email([
'company_id' => 0,
'smtp' => $smtp_config,
'to' => $_SESSION['onboarding_email'] ?? $smtp_username,
'subject' => 'WMS — SMTP Verification',
'message' => "Your SMTP is working correctly.\n\nSetup is now complete.",
'channel_name' => $company_name ?: 'WMS',
'key' => $pinkey,
]);
// If mailer fails, it calls exit() internally — nothing below this line runs.
}
// ── Step 9: Duplicate channel_name check ─────────────────────────────────
// channel_name is the unique identifier used in URLs and API calls — must be globally unique.
@@ -226,25 +237,29 @@ try {
// ── Step 13: Save company SMTP settings ──────────────────────────────────
// Stored with the encrypted password so the mailer module can decrypt and
// use it for all outgoing email from this company (OTP, notifications, etc.).
$sth = $pdo1->prepare("
INSERT INTO company_smtp
(company_id, server, port, username, password,
from_name, from_email, encryption, updated_at)
VALUES
(:company_id, :server, :port, :username, :password,
:from_name, :from_email, :encryption, NOW())
");
$sth->execute([
':company_id' => $company_id,
':server' => $smtp_host,
':port' => $smtp_port,
':username' => $smtp_username,
':password' => $encrypted_pass,
':from_name' => $company_name,
':from_email' => $email ?: $smtp_username,
':encryption' => $smtp_encryption,
]);
db_check($sth, $answer);
// Skipped when no SMTP was given (only allowed with OTP_REQUIRED=false); it
// can be added later under Settings → SMTP.
if ($smtp_given) {
$sth = $pdo1->prepare("
INSERT INTO company_smtp
(company_id, server, port, username, password,
from_name, from_email, encryption, updated_at)
VALUES
(:company_id, :server, :port, :username, :password,
:from_name, :from_email, :encryption, NOW())
");
$sth->execute([
':company_id' => $company_id,
':server' => $smtp_host,
':port' => $smtp_port,
':username' => $smtp_username,
':password' => $encrypted_pass,
':from_name' => $company_name,
':from_email' => $email ?: $smtp_username,
':encryption' => $smtp_encryption,
]);
db_check($sth, $answer);
}
// ── Step 14: Clear onboarding session keys ───────────────────────────────
// These keys are no longer needed and should not persist into the
+18 -2
View File
@@ -1,6 +1,7 @@
<?php
require '../session.php';
require '../config.php';
require_once '../assets/utils/otp_policy.php';
require '../include_header.php';
// successful login — redirect based on app_access
if(!empty($_SESSION["login_status"])){
@@ -32,6 +33,13 @@
</div>
<form class="needs-validation mt-3" novalidate id="login-form">
<?php if (!otp_required()): ?>
<!-- OTP_REQUIRED is not true in config.php (the default): a password-only sign-in must never be invisible -->
<div class="alert alert-warning small py-2 mb-3" title="OTP_REQUIRED is not true in config.php">
<i class="ti ti-alert-triangle me-1"></i>
Email OTP is off — sign-in is password only.
</div>
<?php endif; ?>
<!-- first step login [OTP] -->
<?php if(!isset($_SESSION['login_data'])){?>
<div class="mb-3">
@@ -51,7 +59,7 @@
<div class="d-flex justify-content-between align-items-center mb-3">
<!-- "Remember me" is intentionally excluded.
This login uses 2FA (OTP via email) on every session.
This login uses 2FA (OTP via email) on every session when OTP_REQUIRED=true in config.php (off by default).
A persistent login would bypass the OTP step and undermine the security model.
Do not add this back. -->
</div>
@@ -62,6 +70,7 @@
</p>
<?php }else{ ?>
<!-- second step login -->
<?php if (otp_required()): ?>
<div class="alert alert-warning small py-2 mb-3">
<i class="ti ti-mail me-1"></i>
OTP is sent via your company's SMTP setting.
@@ -73,13 +82,20 @@
<span>One Time Password</span>
</label>
<input id="otp" type="otp" class="form-control"
placeholder="your otp for reference number <?php echo $_SESSION["reference"]?>" required minlength="6">
placeholder="your otp for reference number <?php echo $_SESSION["reference"] ?? ''?>" required minlength="6">
<div class="invalid-feedback">Please provide a otp (min 6 characters).</div>
</div>
<?php else: ?>
<!-- OTP_REQUIRED was switched off while this session sat on the OTP step:
login_confirm.php no longer checks the code, so there is nothing to type. -->
<input id="otp" type="hidden" value="">
<?php endif; ?>
<div class="mb-3">
<label for="password" class="form-label d-flex justify-content-between">
<a href="javascript:;" class="small link-primary" onclick="back()">Back</a>
<?php if (otp_required()): ?>
<a href="javascript:;" class="small link-primary" onclick="request_new_otp();">Request New OTP</a>
<?php endif; ?>
</label>
</div>
<button class="btn btn-primary w-100" onclick="login_confirm();">Sign in</button>
+29 -8
View File
@@ -1,6 +1,7 @@
<?php
require '../session.php';
require '../config.php';
require_once '../assets/utils/otp_policy.php';
// Must come from email verification
if (empty($_SESSION['onboarding_user_id'])) {
@@ -48,7 +49,8 @@
</div>
<div class="col-md-6">
<label class="form-label">Channel Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="channel_name" placeholder="e.g. my-shop">
<input type="text" class="form-control" id="channel_name" placeholder="e.g. my-shop"
oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9_-]/g,'')">
<div class="form-text">Unique identifier. Lowercase, no spaces.</div>
</div>
<div class="col-md-3">
@@ -77,11 +79,20 @@
<div class="d-flex justify-content-between align-items-start mb-1">
<h2 class="fs-5 mb-0"><i class="ti ti-mail-cog me-2"></i>SMTP / Email Setting</h2>
<?php if (otp_required()): ?>
<span class="badge bg-label-danger">Required</span>
<?php else: ?>
<span class="badge bg-label-secondary">Optional</span>
<?php endif; ?>
</div>
<p class="text-muted small mb-3">
<?php if (otp_required()): ?>
SMTP is required to send OTP during login.
A verification email will be sent when you finish setup.
<?php else: ?>
Email OTP is turned off, so SMTP is optional. Leave it blank to skip;
you can add it later under Settings → SMTP.
<?php endif; ?>
</p>
<!-- SMTP User Guide (collapsible) -->
@@ -174,11 +185,11 @@
<!-- SMTP Form -->
<div class="row g-3">
<div class="col-md-8">
<label class="form-label">SMTP Host <span class="text-danger">*</span></label>
<label class="form-label">SMTP Host <?php if (otp_required()): ?><span class="text-danger">*</span><?php endif; ?></label>
<input type="text" class="form-control" id="smtp_host" placeholder="e.g. smtp.gmail.com">
</div>
<div class="col-md-4">
<label class="form-label">Port <span class="text-danger">*</span></label>
<label class="form-label">Port <?php if (otp_required()): ?><span class="text-danger">*</span><?php endif; ?></label>
<select class="form-select" id="smtp_port">
<option value="587">587 — TLS</option>
<option value="465">465 — SSL</option>
@@ -186,11 +197,11 @@
</select>
</div>
<div class="col-md-6">
<label class="form-label">Username / Email <span class="text-danger">*</span></label>
<label class="form-label">Username / Email <?php if (otp_required()): ?><span class="text-danger">*</span><?php endif; ?></label>
<input type="text" class="form-control" id="smtp_username" placeholder="your@email.com">
</div>
<div class="col-md-6">
<label class="form-label">Password <span class="text-danger">*</span></label>
<label class="form-label">Password <?php if (otp_required()): ?><span class="text-danger">*</span><?php endif; ?></label>
<div class="input-group">
<input type="password" class="form-control" id="smtp_password" placeholder="SMTP password">
<button class="btn btn-outline-secondary toggle-pw" type="button" data-target="smtp_password">
@@ -251,13 +262,23 @@
return;
}
if (!$('#smtp_host').val().trim() || !$('#smtp_username').val().trim() || !$('#smtp_password').val()) {
bootbox.alert('SMTP host, username and password are required.');
// Mirrors api/engine/onboarding.php: SMTP is required while email OTP is on;
// with OTP_REQUIRED=false it is optional, but the three fields go together.
const smtp_required = <?php echo otp_required() ? 'true' : 'false'; ?>;
const smtp_host = $('#smtp_host').val().trim();
const smtp_user = $('#smtp_username').val().trim();
const smtp_pass = $('#smtp_password').val();
const smtp_given = !!(smtp_host || smtp_user || smtp_pass);
if ((smtp_required || smtp_given) && (!smtp_host || !smtp_user || !smtp_pass)) {
bootbox.alert(smtp_required
? 'SMTP host, username and password are required.'
: 'Fill in SMTP host, username and password, or leave all three blank.');
return;
}
const $btn = $('#btn_finish');
$btn.prop('disabled', true).html('<i class="ti ti-loader-2 me-1"></i>Verifying SMTP…');
$btn.prop('disabled', true).html('<i class="ti ti-loader-2 me-1"></i>' + (smtp_given ? 'Verifying SMTP…' : 'Setting up…'));
const encryption = $('input[name="smtp_encryption"]:checked').val();
-47
View File
@@ -1,47 +0,0 @@
<?php
session_start();
require '../../../assets/utils/db_auth.php';
require '../../../assets/utils/classes/OrderManager.php';
require '../../../assets/utils/classes/StockManager.php';
require '../../../assets/utils/classes/WarehouseManager.php';
require '../../../assets/utils/classes/InvoiceManager.php';
require_once '../../../assets/utils/classes/CompanySettingManager.php';
$id = (int)($data['id'] ?? 0);
if (!$id) {
$answer['message'] = 'Invalid order ID.';
http_response_code(400);
exit(json_encode($answer));
}
$csm = new CompanySettingManager($pdo1, $company_id);
$auto_approve = (int)$csm->get('default_stock_status') === 1;
$auto_invoice = (int)$csm->get('auto_invoice_and_credit_note') === 1;
try {
dbTransaction($pdo2, function($pdo) use ($id, $company_id, $logging, $uuid, $auto_approve, $auto_invoice) {
$order = new OrderManager($pdo, $company_id);
$order->confirmOrder($id, $uuid, $logging, $auto_approve);
if ($auto_invoice) {
$invMgmt = new InvoiceManager($pdo, $company_id);
$invMgmt->createFromOrder($id, $logging);
}
});
$answer['success'] = 1;
$answer['message'] = 'Order confirmed.';
$answer['auto_approved'] = $auto_approve;
} catch (PDOException $e) {
$answer['message'] = 'Database error, please try again.';
http_response_code(500);
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
?>
+33 -5
View File
@@ -23,7 +23,7 @@
</div>
<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="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-primary bg-opacity-10 text-primary rounded-2">
@@ -36,7 +36,7 @@
</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="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-warning bg-opacity-10 text-warning rounded-2">
@@ -49,7 +49,7 @@
</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="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-info bg-opacity-10 text-info rounded-2">
@@ -62,7 +62,7 @@
</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="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-danger bg-opacity-10 text-danger rounded-2">
@@ -75,7 +75,33 @@
</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>
<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="col-12">
@@ -224,6 +250,8 @@
$('#stat_total').text(format_number(all.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_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));
alasql('CREATE TABLE IF NOT EXISTS invoice_list');
+1 -1
View File
@@ -250,7 +250,7 @@
$('#display_contact').text(inv.contact_name || '—');
$('#display_department').text(get_dept_label(inv.department_id));
$('#display_issued').text(format_date(inv.issued_date) || '—');
$('#due_date').val(inv.due_date ? format_date(inv.due_date) : '');
$('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : '');
$('#notes').val(inv.notes || '');
var gl_type = inv.doc_type === 'credit_note' ? 'sales_credit_note' : 'sales_invoice';
+3 -3
View File
@@ -341,7 +341,7 @@
</td>
<td>
<input type="number" class="form-control form-control-sm item_tax_rate"
value="${parseFloat(item.tax_rate || 0).toFixed(2)}" min="0" max="100" step="0.01" oninput="recalc_totals()">
value="${line_tax_rate(item).toFixed(2)}" min="0" max="100" step="0.01" oninput="recalc_totals()">
<input type="hidden" class="item_tax_amount" value="${item.tax_amount || 0}">
</td>
<td class="item_total text-end">${format_number((item.quantity || 1) * (item.unit_price || item.price || 0), 2)}</td>
@@ -493,7 +493,7 @@
// Fields
$('#contact').val(o.contact_name || '');
$('#contact_id').val(o.contact_id);
$('#order_date').val(o.order_date ? format_date(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 || '');
$('#discount').val(o.discount || 0);
@@ -712,7 +712,7 @@
// ── Boot ─────────────────────────────────────────────────────────────────
$(async function() {
try {
load_departments('department_id');
await Promise.resolve(load_departments('department_id')).catch(function() {});
await retrieve_warehouses();
if (order_id) {
+1 -1
View File
@@ -795,7 +795,7 @@
$('#badge_status').html(return_status_badge(r.status));
$('#return_number_display').text(r.return_number);
$('#return_date').val(r.return_date ? format_date(r.return_date) : '');
$('#return_date').val(r.return_date ? format_date_input(r.return_date) : '');
$('#reason').val(r.reason || '');
$('#tax_adjustment').val(parseFloat(r.tax_adjustment || 0).toFixed(2));
$('#display_department').text(get_dept_label(r.department_id));
+19 -6
View File
@@ -30,7 +30,7 @@
<!-- Stat cards -->
<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="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-primary bg-opacity-10 text-primary rounded-2">
@@ -43,7 +43,7 @@
</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-warning bg-opacity-10 text-warning rounded-2">
@@ -56,7 +56,20 @@
</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="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-info bg-opacity-10 text-info rounded-2">
@@ -69,7 +82,7 @@
</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">
@@ -215,6 +228,7 @@
// Stat cards
$('#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_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));
@@ -241,8 +255,7 @@
var body = '';
$.each(page_data, function(i, o) {
var items = JSON.parse(o.items || '[]');
var item_count = items.length;
var item_count = parseInt(o.item_count) || 0;
var can_edit = parseInt(o.status) === 0;
var can_cancel = parseInt(o.status) >= 0 && parseInt(o.status) <= 1;
+2 -2
View File
@@ -158,7 +158,7 @@
var body = '';
$.each(page_data, function(i, r) {
var items = JSON.parse(r.items || '[]');
var item_count = parseInt(r.item_count) || 0;
var can_cancel = parseInt(r.status) >= 0 && parseInt(r.status) <= 1;
body += `<tr>
@@ -167,7 +167,7 @@
<td class="py-3">${r.contact_name || '<span class="text-muted">—</span>'}</td>
<td class="py-3">${get_dept_label(r.department_id)}</td>
<td class="py-3">${format_date(r.return_date)}</td>
<td class="py-3">${items.length} item${items.length !== 1 ? 's' : ''}</td>
<td class="py-3">${item_count} item${item_count !== 1 ? 's' : ''}</td>
<td class="py-3 fw-semibold">${format_number(r.refund_amount, 2)}</td>
<td class="py-3">${return_status_badge(r.status)}</td>
<td class="py-3">${receipt_status_badge(r.receipt_status)}</td>
+33 -5
View File
@@ -23,7 +23,7 @@
</div>
<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="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-primary bg-opacity-10 text-primary rounded-2">
@@ -36,7 +36,7 @@
</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="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-warning bg-opacity-10 text-warning rounded-2">
@@ -49,7 +49,7 @@
</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="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-info bg-opacity-10 text-info rounded-2">
@@ -62,7 +62,7 @@
</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="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-danger bg-opacity-10 text-danger rounded-2">
@@ -75,7 +75,33 @@
</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>
<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="col-12">
@@ -212,6 +238,8 @@
$('#stat_total').text(format_number(all.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_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 =>
String(i.status) === '1' && i.due_date && i.due_date < today
).length));
+27 -9
View File
@@ -326,7 +326,7 @@
</td>
<td>
<input type="number" class="form-control form-control-sm item_tax_rate"
value="${parseFloat(item.tax_rate || 0).toFixed(2)}" min="0" max="100" step="0.01" oninput="recalc_totals()">
value="${line_tax_rate(item).toFixed(2)}" min="0" max="100" step="0.01" oninput="recalc_totals()">
<input type="hidden" class="item_tax_amount" value="${item.tax_amount || 0}">
</td>
<td class="item_total text-end">${format_number((item.quantity || 1) * (item.unit_price || 0), 2)}</td>
@@ -527,9 +527,9 @@
<span class="text-muted ms-2 small">${item.product_name || ''}</span>
</div>
<div class="d-flex gap-3 text-muted small">
<span>Ordered: <strong>${format_number(item.ordered_qty, 0)}</strong></span>
<span>Received: <strong>${format_number(item.received_qty, 0)}</strong></span>
<span>Remaining: <strong class="text-primary">${format_number(item.remaining_qty, 0)}</strong></span>
<span>Ordered: <strong>${format_quantity(item.ordered_qty)}</strong></span>
<span>Received: <strong>${format_quantity(item.received_qty)}</strong></span>
<span>Remaining: <strong class="text-primary">${format_quantity(item.remaining_qty)}</strong></span>
</div>
</div>
@@ -546,7 +546,7 @@
<div class="col-lg-3">
<label class="form-label small text-muted">Receiving Qty <span class="text-danger">*</span></label>
<input type="number" id="recv_qty_${rowId}" class="form-control form-control-sm"
value="${item.remaining_qty}" min="0.0001" max="${item.remaining_qty}" step="any">
value="${item.remaining_qty}" min="0.0001" max="${item.remaining_qty}" step="0.0001">
</div>
<div class="col-lg-3">
@@ -682,8 +682,8 @@
$('#contact').val(o.contact_name || '');
$('#contact_id').val(o.contact_id || '');
$('#po_date').val(o.po_date ? format_date(o.po_date) : '');
$('#expected_date').val(o.expected_date ? format_date(o.expected_date) : '');
$('#po_date').val(o.po_date ? format_date_input(o.po_date) : '');
$('#expected_date').val(o.expected_date ? format_date_input(o.expected_date) : '');
$('#warehouse_id').val(o.warehouse_id || '');
$('#department_id').val(o.department_id || 0);
$('#notes').val(o.notes || '');
@@ -770,6 +770,15 @@
var qty = parseFloat($(`#recv_qty_${rowId}`).val()) || 0;
if (qty <= 0) return;
// Received quantities are stored as decimal(18,4). A value finer than
// that is rounded away on insert, so 0.0000001 was accepted, created a
// stock movement of 0.0000, and still left the PO showing "Partial".
if (round_dp(qty, 4) < 0.0001) {
errors.push(`${$card.data('sku')}: receiving quantity ${qty} is smaller than the minimum the system records (0.0001).`);
return;
}
qty = round_dp(qty, 4);
var wh_id = parseInt($(`#recv_wh_${rowId}`).val()) || 0;
var bin = $(`#recv_bin_${rowId}`).val() || '';
@@ -791,13 +800,22 @@
// flatpickr with altInput: the real (hidden) input holds the ISO value
var expiry_val = $(`#recv_expiry_${rowId}`).val() || '';
var lot_val = $(`#recv_lot_${rowId}`).val().trim();
// Expiry dates are stored on md_lot, keyed by lot number — one entered
// without a lot has nowhere to go and was dropped without warning, so
// the received stock then showed no expiry at all.
if (expiry_val && !lot_val) {
errors.push(`${$card.data('sku')}: enter a lot number — expiry dates are recorded against a lot.`);
return;
}
receive_items.push({
item_id: parseInt($card.data('item-id')),
product_sku: $card.data('sku'),
warehouse_id: wh_id,
quantity: qty,
lot_number: $(`#recv_lot_${rowId}`).val().trim(),
lot_number: lot_val,
expiry_date: expiry_val,
serial_number: $(`#recv_serial_${rowId}`).val().trim(),
zone: zone,
@@ -974,7 +992,7 @@
// ── Boot ─────────────────────────────────────────────────────────────────
$(async function() {
try {
load_departments('department_id');
await Promise.resolve(load_departments('department_id')).catch(function() {});
await load_location_config();
await retrieve_warehouses();
+19 -2
View File
@@ -160,6 +160,8 @@
var invoice_id = <?php echo $invoice_id; ?>;
var invoice_data = null;
var issued_date = ''; // ISO issue date — lower bound for the due date
var due_picker = null; // flatpickr instance on #due_date
function doc_type_badge(doc_type) {
const map = {
@@ -226,7 +228,12 @@
$('#display_contact').text(inv.contact_name || '—');
$('#display_department').text(get_dept_label(inv.department_id));
$('#display_issued').text(format_date(inv.issued_date) || '—');
$('#due_date').val(inv.due_date ? format_date(inv.due_date) : '');
// A due date before the issue date is not a valid payment term, so
// stop the picker from offering one.
issued_date = inv.issued_date ? String(inv.issued_date).split(' ')[0] : '';
if (due_picker && issued_date) due_picker.set('minDate', issued_date);
$('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : '');
$('#notes').val(inv.notes || '');
// Source link
@@ -311,6 +318,12 @@
}
function save_invoice() {
var due_val = $('#due_date').val().trim();
if (due_val && issued_date && to_iso_date(due_val) < issued_date) {
bootbox.alert('The due date cannot be earlier than the issue date.');
return;
}
ajax_request({
url: '<?php echo $server_url?>order/api/engine/manage_invoice.php',
autoPrepare: true,
@@ -346,6 +359,10 @@
bootbox.alert('Please enter a due date before issuing this purchase invoice.');
return;
}
if (!is_dn && due_date && issued_date && to_iso_date(due_date) < issued_date) {
bootbox.alert('The due date cannot be earlier than the issue date.');
return;
}
var label = is_dn ? 'Issue Supplier Credit Note' : 'Issue Purchase Invoice';
bootbox.confirm({
message: is_dn ? 'Issue this supplier credit note?' : 'Issue this purchase invoice?',
@@ -419,8 +436,8 @@
$(function() {
load_dept_cache();
due_picker = flatpickr('#due_date', { dateFormat: 'd/m/Y', allowInput: true });
if (invoice_id) retrieve_invoice();
flatpickr('#due_date', { dateFormat: 'd/m/Y', allowInput: true });
});
</script>
+1 -1
View File
@@ -432,7 +432,7 @@
$('#badge_status').html(return_status_badge(r.status));
$('#badge_fulfillment').html(fulfillment_status_badge(r.fulfillment_status));
$('#return_number_display').text(r.return_number);
$('#return_date').val(r.return_date ? format_date(r.return_date) : '');
$('#return_date').val(r.return_date ? format_date_input(r.return_date) : '');
$('#reason').val(r.reason || '');
$('#tax_adjustment').val(parseFloat(r.tax_adjustment || 0).toFixed(2));
$('#display_department').text(get_dept_label(r.department_id));
+1 -2
View File
@@ -226,8 +226,7 @@
var body = '';
$.each(page_data, function(i, o) {
var items = JSON.parse(o.items || '[]');
var item_count = items.length;
var item_count = parseInt(o.item_count) || 0;
var can_cancel = parseInt(o.status) >= -2 && parseInt(o.status) <= 1;
body += `<tr>
+1 -3
View File
@@ -218,9 +218,7 @@
}
$.each(rows, function(i, r) {
var items = [];
try { items = JSON.parse(r.items || '[]'); } catch(e) {}
var item_count = items.length;
var item_count = parseInt(r.item_count) || 0;
body += `<tr>
<td class="py-3 fw-semibold">${escape_html(r.return_number || '')}</td>
<td class="py-3">
+2 -2
View File
@@ -306,7 +306,7 @@
<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">${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">${item.quantity}</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.lot_number || '<span class="text-muted">—</span>'}</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">${item.quantity}</td>
<td class="py-3">${status_badge(item.status)}</td>
+1 -1
View File
@@ -533,7 +533,7 @@
}
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"><code>${row.product_sku}</code></td>
<td class="py-2">${row.lot_number || '<span class="text-muted">—</span>'}</td>
@@ -88,6 +88,7 @@ foreach ($convert_items as $ci) {
'unit_price' => $unit_price,
'total_price' => $total_price,
'tax_amount' => $tax_amount,
'tax_rate' => (float)($qi['tax_rate'] ?? 0),
'stock_out_id' => 0,
];
$validated[] = ['item_id' => $item_id, 'quantity' => $qty];
+3 -2
View File
@@ -13,14 +13,15 @@ $qm = new QuotationManager($pdo2, $company_id);
try {
if ($action === 'create') {
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['message'] = 'Quotation created.';
$answer['new_id'] = $new_id;
(new UsageGuard($pdo1, $company_id, $packages))->increment();
} elseif ($action === 'update') {
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['message'] = 'Quotation updated.';
} else {
+22 -6
View File
@@ -26,7 +26,7 @@
</div>
<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="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-primary bg-opacity-10 text-primary rounded-2">
@@ -39,7 +39,7 @@
</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-success bg-opacity-10 text-success rounded-2">
@@ -52,7 +52,7 @@
</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-warning bg-opacity-10 text-warning rounded-2">
@@ -65,7 +65,20 @@
</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="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-danger bg-opacity-10 text-danger rounded-2">
@@ -255,8 +268,11 @@
function update_stats() {
$('#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));
$('#stat_open').text(format_number(all_invoices.filter(i => i.doc_type === 'invoice' && ['0', '1'].includes(String(i.status))).length));
// Same test as the row badge, so a tile never disagrees with the list below it
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));
}
+27 -3
View File
@@ -146,7 +146,9 @@
<?php require '../include_ending.php'; ?>
<script>
var invoice_id = <?php echo $invoice_id; ?>;
var invoice_id = <?php echo $invoice_id; ?>;
var issued_date = ''; // ISO issue date — lower bound for the due date
var due_picker = null; // flatpickr instance on #due_date
function doc_type_badge(doc_type) {
const map = {
@@ -207,7 +209,12 @@
$('#display_contact').html(display_text(inv.contact_name));
$('#display_issued').html(inv.issued_date ? format_date(inv.issued_date) : '<span class="text-muted">—</span>');
$('#display_due').html(inv.due_date ? format_date(inv.due_date) : '<span class="text-muted">—</span>');
$('#due_date').val(inv.due_date ? format_date(inv.due_date) : '');
$('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : '');
// A due date before the issue date is not a valid payment term, so stop
// the picker from offering one.
issued_date = inv.issued_date ? String(inv.issued_date).split(' ')[0] : '';
if (due_picker && issued_date) due_picker.set('minDate', issued_date);
$('#display_notes').html(inv.notes ? escape_html(inv.notes).replace(/\n/g, '<br>') : '<span class="text-muted">—</span>');
var rows = '';
@@ -279,6 +286,18 @@
}
function save_invoice() {
// autoPrepare sweeps every .form-control into the payload, so #due_date
// arrives as the picker's DD/MM/YYYY text. Sent unconverted it reaches a
// MySQL DATE column verbatim and the insert fails, which the engine
// reports as the opaque "Database error, please try again." Convert it
// here, the way the purchase-invoice page already does.
var due_date = $('#due_date').val().trim();
if (due_date && issued_date && to_iso_date(due_date) < issued_date) {
bootbox.alert('The due date cannot be earlier than the issue date.');
return;
}
ajax_request({
url: '<?php echo $server_url?>order/api/engine/manage_invoice.php',
autoPrepare: true,
@@ -286,6 +305,7 @@
action: 'update',
data: {
id: invoice_id,
due_date: due_date ? to_iso_date(due_date) : '',
tax_adjustment: parseFloat($('#tax_adjustment').val()) || 0,
},
onSuccess: function() { retrieve_invoice(); }
@@ -320,6 +340,10 @@
bootbox.alert('Please enter a due date before issuing this invoice.');
return;
}
if (due_date && issued_date && to_iso_date(due_date) < issued_date) {
bootbox.alert('The due date cannot be earlier than the issue date.');
return;
}
bootbox.confirm({
message: 'Issue this invoice?',
buttons: {
@@ -390,8 +414,8 @@
}
$(function() {
due_picker = flatpickr('#due_date', { dateFormat: 'd/m/Y', allowInput: true });
if (invoice_id) retrieve_invoice();
flatpickr('#due_date', { dateFormat: 'd/m/Y', allowInput: true });
});
</script>
+14 -3
View File
@@ -50,6 +50,13 @@
placeholder="DD/MM/YYYY" autocomplete="off">
</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">
<label class="form-label">Notes</label>
<textarea id="notes" class="form-control" rows="2"
@@ -235,7 +242,7 @@
</td>
<td>
<input type="number" class="form-control form-control-sm item_tax_rate"
value="${parseFloat(item.tax_rate || 0).toFixed(2)}" min="0" max="100" step="0.01" oninput="recalc_totals()">
value="${line_tax_rate(item).toFixed(2)}" min="0" max="100" step="0.01" oninput="recalc_totals()">
<input type="hidden" class="item_tax_amount" value="${item.tax_amount || 0}">
</td>
<td class="item_total text-end align-middle">
@@ -293,7 +300,7 @@
has_active_invoice = has_active_invoice || false;
active_invoice_id = parseInt(active_invoice_id) || 0;
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);
$('.item_sku, .item_desc, .item_qty, .item_price, .item_tax_rate').prop('disabled', !editable);
$('.remove_item_btn').toggleClass('d-none', !editable);
@@ -356,7 +363,8 @@
$('#order_number_display').text(o.order_number);
$('#contact').val(o.contact_name || '');
$('#contact_id').val(o.contact_id || 0);
$('#order_date').val(o.order_date ? format_date(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 || '');
$('#discount').val(o.discount || 0);
$('#tax_adjustment').val(o.tax_adjustment || 0);
@@ -388,6 +396,7 @@
id: order_id,
contact_id: $('#contact_id').val() || 0,
order_date: to_iso_date($('#order_date').val()),
department_id: $('#department_id').val() || 0,
items: JSON.stringify(items),
discount: $('#discount').val() || 0,
tax_adjustment: $('#tax_adjustment').val() || 0,
@@ -490,6 +499,8 @@
$(async function() {
try {
// Options must exist before retrieve_order() selects the saved department
await Promise.resolve(load_departments('department_id')).catch(function() {});
if (order_id) {
await retrieve_order();
} else {
+3 -3
View File
@@ -379,8 +379,8 @@
$('#contact').val(q.contact_name || '');
$('#contact_id').val(q.contact_id || 0);
$('#quotation_date').val(q.quotation_date ? format_date(q.quotation_date) : '');
$('#valid_until').val(q.valid_until ? format_date(q.valid_until) : '');
$('#quotation_date').val(q.quotation_date ? format_date_input(q.quotation_date) : '');
$('#valid_until').val(q.valid_until ? format_date_input(q.valid_until) : '');
$('#department_id').val(q.department_id || 0);
$('#notes').val(q.notes || '');
$('#discount').val(parseFloat(q.discount) || 0);
@@ -515,7 +515,7 @@
// ── Boot ──────────────────────────────────────────────────────────────────
$(async function() {
try {
load_departments('department_id');
await Promise.resolve(load_departments('department_id')).catch(function() {});
if (quotation_id) {
await retrieve_quotation();
} else {
+20 -12
View File
@@ -26,7 +26,7 @@
</div>
<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="d-flex align-items-center gap-3">
<div class="icon-shape icon-md bg-primary bg-opacity-10 text-primary rounded-2">
@@ -39,7 +39,7 @@
</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">
@@ -52,7 +52,7 @@
</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-warning bg-opacity-10 text-warning rounded-2">
@@ -65,7 +65,7 @@
</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-success bg-opacity-10 text-success rounded-2">
@@ -78,7 +78,20 @@
</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>
<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="col-12">
@@ -165,12 +178,6 @@
return map[String(status)] || '<span class="badge bg-light text-dark">—</span>';
}
function order_items_count(items) {
if (Array.isArray(items)) return items.length;
try { return JSON.parse(items || '[]').length; }
catch(e) { return 0; }
}
function retrieve_orders() {
return ajax_request({
url: '<?php echo $server_url?>order/api/engine/retrieve_order.php',
@@ -203,6 +210,7 @@
$('#stat_total').text(format_number(all.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_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));
alasql('CREATE TABLE IF NOT EXISTS revenue_order_list');
@@ -226,7 +234,7 @@
}
$.each(rows, function(i, o) {
var item_count = order_items_count(o.items);
var item_count = parseInt(o.item_count) || 0;
var source = String(o.source || '');
var source_display = source === 'quotation' && parseInt(o.source_id) > 0
? `<a href="<?php echo $server_url?>revenue/manage_quotation.php?id=${o.source_id}">Quotation #${o.source_id}</a>`
+2 -2
View File
@@ -212,14 +212,14 @@
var body = '';
$.each(page_data, function(i, q) {
var items = typeof q.items === 'string' ? JSON.parse(q.items || '[]') : (q.items || []);
var item_count = parseInt(q.item_count) || 0;
body += `<tr>
<td class="py-3 fw-semibold">${escape_html(q.quotation_number)}</td>
<td class="py-3">${format_date(q.quotation_date)}</td>
<td class="py-3">${q.valid_until ? format_date(q.valid_until) : '<span class="text-muted">—</span>'}</td>
<td class="py-3">${escape_html(q.contact_name || '—')}</td>
<td class="py-3">${get_dept_label(q.department_id)}</td>
<td class="py-3">${items.length} item${items.length !== 1 ? 's' : ''}</td>
<td class="py-3">${item_count} item${item_count !== 1 ? 's' : ''}</td>
<td class="py-3 fw-semibold">${format_number(q.grand_total, 2)}</td>
<td class="py-3">${status_badge[String(q.status)] || q.status}</td>
<td class="py-3">
+6
View File
@@ -2,6 +2,12 @@
// app/session.php
ob_start(); // ensure output buffering is on regardless of php.ini — prevents stray output from corrupting JSON API responses
// The buffer is still flushed, so notices would still land in front of the JSON
// body and break the client's parse ("Server error occurred."). The login API
// engines load this file instead of db_auth.php, so apply the same policy here.
ini_set('display_errors', '0');
ini_set('log_errors', '1');
if (session_status() === PHP_SESSION_NONE) {
// Derive cookie path dynamically from the current script location.
+11 -2
View File
@@ -1,6 +1,7 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/app_registry.php';
require_once '../../../assets/utils/classes/UserManager.php';
if ($user_role !== 'owner') { http_response_code(403); exit(json_encode(['success' => 0, 'message' => 'Only the owner can modify this setting.'])); }
@@ -30,7 +31,11 @@
$result = $um->inviteUser($email, $role, $app_access);
// Both new and existing users require explicit acceptance via email
$invite_url = rtrim($server_url, '/') . ($result['new_user']
// Absolute URL: the link is opened from a mail client, where a bare
// /app/... path goes nowhere. Same construction as register.php.
$invite_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
. '://' . $_SERVER['HTTP_HOST']
. rtrim($server_url, '/') . ($result['new_user']
? '/login/invited_onboarding.php?token=' . $result['token']
: '/login/accept_invite.php?token=' . $result['token']);
@@ -88,7 +93,11 @@
$map_id = (int)($data['map_id'] ?? 0);
$result = $um->resendInvite($map_id);
$invite_url = rtrim($server_url, '/') . ($result['is_new_user']
// Absolute URL: the link is opened from a mail client, where a bare
// /app/... path goes nowhere. Same construction as register.php.
$invite_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
. '://' . $_SERVER['HTTP_HOST']
. rtrim($server_url, '/') . ($result['is_new_user']
? '/login/invited_onboarding.php?token=' . $result['token']
: '/login/accept_invite.php?token=' . $result['token']);
+6 -2
View File
@@ -3,7 +3,11 @@
* switch_branch.php — Switch the active company for the current session.
*
* action: 'read' → return list of companies the user belongs to
* action: 'update' → switch to the requested company_id
* action: 'update' → switch to target_company_id
*
* The target is read from target_company_id, not company_id: every request
* carries company_id = the CURRENT company (prepare_form_data in custom.js),
* so reading it made a switch silently re-select the company already active.
*/
session_start();
require_once '../../../assets/utils/db_auth.php';
@@ -20,7 +24,7 @@ if ($action === 'read') {
}
if ($action === 'update') {
$target_company_id = (int)($data['company_id'] ?? 0);
$target_company_id = (int)($data['target_company_id'] ?? 0);
if (!$target_company_id) {
$answer['message'] = 'Invalid company.';
+2 -1
View File
@@ -121,7 +121,8 @@
<div class="col-md-6 mb-3">
<label class="form-label">Nickname / Channel Name</label>
<input type="text" class="form-control" id="channel_name" placeholder="e.g. my-shop">
<input type="text" class="form-control" id="channel_name" placeholder="e.g. my-shop"
oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9_-]/g,'')">
<div class="form-text">Unique identifier. Lowercase, no spaces.</div>
</div>
<div class="col-md-3 mb-3">
+2 -3
View File
@@ -1,6 +1,7 @@
<?php
session_start();
require '../config.php';
require_once '../assets/utils/app_registry.php';
require '../include_header.php';
?>
@@ -256,9 +257,7 @@
const license_badge = license_html(u.license);
const access_badge = app_access_html(u.app_access);
const joined = u.created_at
? new Date(u.created_at).toLocaleDateString('en-GB', {day:'2-digit', month:'short', year:'numeric'})
: '—';
const joined = u.created_at ? format_date(String(u.created_at).split(' ')[0]) : '—';
const is_pending = u.status === 'pending' && u.is_pending_invite == 1;
+4 -2
View File
@@ -532,9 +532,11 @@ foreach ($stockout_defs as $so) {
$qty = $so['qty'];
$cost = $p['cost'];
$uom = $p['uom'];
// The batch is bought in from a supplier; only the stock-out goes to the customer.
$supplier_id = $supplier_ids[0];
dbTransaction($pdo2, function ($pdo2) use (
$stockMgmt, $whMgmt, $company_id, $main_wh_id, $sku, $qty, $cost, $customer_id,
$stockMgmt, $whMgmt, $company_id, $main_wh_id, $sku, $qty, $cost, $customer_id, $supplier_id,
$logging, $dispatch_in_marker, $dispatch_out_marker
) {
$bin = findFreeBin($pdo2, $company_id, $main_wh_id);
@@ -549,7 +551,7 @@ foreach ($stockout_defs as $so) {
'zone' => $bin,
'aisle' => $bin,
'bin' => $bin,
'contact_id' => $customer_id,
'contact_id' => $supplier_id,
'description' => $dispatch_in_marker,
], $logging, $in_uuid);
$stockMgmt->approveStock($stock_id, $main_wh_id, 'in', $whMgmt);
+8 -3
View File
@@ -39,7 +39,7 @@ function buildLineItem(array $products, string $sku, float $qty): array {
$tax_amount = round($total_price * $tax_rate / 100, 4);
return [
'product_sku' => $sku,
'product_name' => $sku,
'product_name' => $p['product_name'],
'quantity' => $qty,
'unit_price' => $unit_price,
'total_price' => $total_price,
@@ -69,11 +69,16 @@ $sth->execute([':c' => $company_id]);
$sales_dept_id = (int)$sth->fetchColumn();
if (!$sales_dept_id) { exit("ERROR: SALES department not found — run demo_seed_transactions.php first.\n"); }
$sth = $pdo2->prepare("SELECT sku, price FROM md_product WHERE company_id = :c");
$sth = $pdo2->prepare("SELECT sku, product_name, price FROM md_product WHERE company_id = :c");
$sth->execute([':c' => $company_id]);
$products = $sth->fetchAll(PDO::FETCH_ASSOC | PDO::FETCH_UNIQUE);
$sth = $pdo2->prepare("SELECT id, contact_name FROM md_contact WHERE company_id = :c AND contact_type = 1 ORDER BY id");
// contact_type holds an md_contact_type id — resolve 'Customer' by name, not by a fixed id.
$sth = $pdo2->prepare(
"SELECT c.id, c.contact_name FROM md_contact c
JOIN md_contact_type t ON t.company_id = c.company_id AND t.id = c.contact_type
WHERE c.company_id = :c AND t.contact_type = 'Customer' ORDER BY c.id"
);
$sth->execute([':c' => $company_id]);
$customers = $sth->fetchAll(PDO::FETCH_ASSOC);
$customer_ids = array_column($customers, 'id');
+82 -191
View File
@@ -5,31 +5,27 @@
*
* Extends the base demo data (demo_seed.php: company, warehouses, products,
* contacts, opening stock) with transactional data covering the rest of the
* app's features:
* app's features (restock / dispatch / transfer movements are seeded by demo_seed.php):
*
* 1. Additional stock-in replenishment (restock events)
* 2. Stock-out (direct dispatch) + stock transfer (Main -> Bangna)
* 3. Chart of accounts, departments, GL posting formulas
* 4. Sales cycle: quotation -> sales order -> invoice -> GL post
* 5. AR: receipt billing -> receipt -> GL post
* 6. Purchasing cycle: purchase request -> PO -> receive -> purchase invoice -> GL post
* 7. AP: payment billing -> payment -> GL post
* 8. Supplier return (confirmed, restocks reversed)
* 9. Customer return (draft only — see note below)
* 10. Barcode labels for a handful of products
* 4. Chart of accounts, departments, GL posting formulas, product account mapping
* 5. Sales cycle: quotation -> sales order -> invoice -> GL post
* 6. AR: receipt billing -> receipt -> GL post
* 7. Purchasing cycle: purchase request -> PO -> receive -> purchase invoice -> GL post
* 8. AP: payment billing -> payment -> GL post
* 9. Supplier return (confirmed, restocks reversed)
* 10. Customer return (draft only — see note below)
* 11. Barcode labels for a handful of products
*
* Every write goes through the same Manager classes + engine-file patterns
* the app itself uses (dbTransaction wrapping, GlManager posting exactly as
* order/api/engine/issue_invoice.php and finance/api/engine/manage_receipt.php
* do it), so the data matches what the real UI would have produced.
*
* NOTE — customer returns: ReturnManager::confirmReturn() reads zone/aisle/bin
* from td_return_item rows, but td_return_item has no zone/aisle/bin columns
* (see setup.php) and no engine file back-fills them before calling
* confirmReturn(). Confirming ANY customer return in this app currently
* throws "Location - - - does not exist" from WarehouseManager::occupyBin().
* This script creates one customer return and leaves it in draft status —
* confirming it is not possible until that's fixed.
* Customer returns: the return is saved with a put-away bin and then
* confirmed, the same two steps the Customer Return page performs. (It used to
* be left in draft: td_return_item had no zone/aisle/bin columns, so the
* location was lost on save and confirmReturn() could not restock. setup.php
* now adds them.)
*
* Safe to re-run: every insert is guarded by an existence check.
*/
@@ -46,6 +42,7 @@ require_once __DIR__ . '/app/dbconn.php';
require_once __DIR__ . '/app/assets/utils/db_helpers.php';
require_once __DIR__ . '/app/assets/utils/classes/WarehouseManager.php';
require_once __DIR__ . '/app/assets/utils/classes/StockManager.php';
require_once __DIR__ . '/app/assets/utils/classes/ProductManager.php';
require_once __DIR__ . '/app/assets/utils/classes/QuotationManager.php';
require_once __DIR__ . '/app/assets/utils/classes/OrderManager.php';
require_once __DIR__ . '/app/assets/utils/classes/InvoiceManager.php';
@@ -89,24 +86,29 @@ $sth->execute();
$owner_user_id = (int)($sth->fetchColumn() ?: 0);
if (!$owner_user_id) exit("ERROR: demo owner user not found — run demo_seed.php first.\n");
// Resolve by name: ids depend on what else exists in the database.
$sth = $pdo2->prepare("SELECT id, warehouse_name FROM md_warehouse WHERE company_id = :c ORDER BY id");
$sth->execute([':c' => $company_id]);
$warehouses = $sth->fetchAll(PDO::FETCH_KEY_PAIR); // id => name
if (count($warehouses) < 2) exit("ERROR: expected 2 warehouses — run demo_seed.php first.\n");
$wh_ids = array_keys($warehouses);
$main_wh = $wh_ids[0]; // Main Warehouse
$bangna_wh = $wh_ids[1]; // Bangna Distribution Center
$main_wh = (int)(array_search('Main Warehouse', $warehouses, true) ?: 0);
$bangna_wh = (int)(array_search('Bangna Distribution Center', $warehouses, true) ?: 0);
if (!$main_wh || !$bangna_wh) exit("ERROR: expected Main Warehouse and Bangna Distribution Center — run demo_seed.php first.\n");
$sth = $pdo2->prepare("SELECT id, contact_name, contact_type FROM md_contact WHERE company_id = :c ORDER BY id");
// md_contact.contact_type is an md_contact_type id, so match on the type name.
$sth = $pdo2->prepare(
"SELECT c.id, c.contact_name, t.contact_type AS type_name
FROM md_contact c
JOIN md_contact_type t ON t.company_id = c.company_id AND t.id = c.contact_type
WHERE c.company_id = :c
ORDER BY c.id"
);
$sth->execute([':c' => $company_id]);
$contacts = $sth->fetchAll(PDO::FETCH_ASSOC);
if (count($contacts) < 7) exit("ERROR: expected 7 contacts — run demo_seed.php first.\n");
$customer_ids = array_column(array_filter($contacts, fn($c) => (int)$c['contact_type'] === 1), 'id');
$supplier_ids = array_column(array_filter($contacts, fn($c) => (int)$c['contact_type'] === 2), 'id');
$customer_ids = array_values($customer_ids);
$supplier_ids = array_values($supplier_ids);
$customer_ids = array_values(array_column(array_filter($contacts, fn($c) => $c['type_name'] === 'Customer'), 'id'));
$supplier_ids = array_values(array_column(array_filter($contacts, fn($c) => $c['type_name'] === 'Supplier'), 'id'));
if (count($customer_ids) < 4 || count($supplier_ids) < 3) exit("ERROR: expected 4 customers and 3 suppliers — run demo_seed.php first.\n");
$sth = $pdo2->prepare("SELECT sku, uom, cost_price, price FROM md_product WHERE company_id = :c ORDER BY id");
$sth = $pdo2->prepare("SELECT sku, product_name, uom, cost_price, price FROM md_product WHERE company_id = :c ORDER BY id");
$sth->execute([':c' => $company_id]);
$products = $sth->fetchAll(PDO::FETCH_ASSOC | PDO::FETCH_UNIQUE);
if (count($products) < 14) exit("ERROR: expected 14 products — run demo_seed.php first.\n");
@@ -114,156 +116,6 @@ if (count($products) < 14) exit("ERROR: expected 14 products — run demo_seed.p
$logging = ['user_id' => $owner_user_id, 'dt' => date('Y-m-d H:i:s'), 'login' => null, 'action' => 'seed_transactions'];
$whMgmt = new WarehouseManager($pdo2, $company_id);
$stockMgmt = new StockManager($pdo2, $company_id);
/** Find the next unused simple-location bin label "A-N" for a warehouse. */
function nextFreeBin(PDO $pdo2, int $company_id, int $warehouse_id): string {
$sth = $pdo2->prepare(
"SELECT bin FROM md_bin WHERE company_id = :c AND warehouse = :w AND product_sku IS NULL
ORDER BY CAST(SUBSTRING(bin, 3) AS UNSIGNED) ASC LIMIT 1"
);
$sth->execute([':c' => $company_id, ':w' => $warehouse_id]);
$bin = $sth->fetchColumn();
if (!$bin) throw new Exception("No free bin available in warehouse {$warehouse_id}.");
return $bin;
}
// ─────────────────────────────────────────────────────────────────────────────
// 1. Additional stock-in replenishment (restock events)
// ─────────────────────────────────────────────────────────────────────────────
echo "--- Restock (additional stock-in) ---\n";
$restock_defs = [
['sku' => 'EL-001', 'warehouse' => $main_wh, 'qty' => 40, 'supplier_idx' => 0],
['sku' => 'OF-001', 'warehouse' => $main_wh, 'qty' => 60, 'supplier_idx' => 1],
['sku' => 'BV-002', 'warehouse' => $bangna_wh, 'qty' => 35, 'supplier_idx' => 2],
];
foreach ($restock_defs as $r) {
$sth = $pdo2->prepare(
"SELECT COUNT(*) FROM `td_stock_{$r['warehouse']}`
WHERE company_id = :c AND product_sku = :sku AND type = 'in' AND description = 'Restock (demo seed)'"
);
$sth->execute([':c' => $company_id, ':sku' => $r['sku']]);
if ((int)$sth->fetchColumn() > 0) {
skip("Restock for {$r['sku']} in warehouse {$r['warehouse']} already exists");
continue;
}
$bin = nextFreeBin($pdo2, $company_id, $r['warehouse']);
$supplier_id = $supplier_ids[$r['supplier_idx']];
$uuid = bin2hex(random_bytes(16));
$p = $products[$r['sku']];
dbTransaction($pdo2, function ($pdo2) use ($stockMgmt, $whMgmt, $r, $bin, $supplier_id, $logging, $uuid, $p) {
$stock_id = $stockMgmt->saveStockIn([
'id' => 0, 'warehouse' => $r['warehouse'], 'product_sku' => $r['sku'],
'quantity' => $r['qty'], 'price' => $p['cost_price'],
'zone' => $bin, 'aisle' => $bin, 'bin' => $bin,
'contact_id' => $supplier_id, 'description' => 'Restock (demo seed)',
], $logging, $uuid);
$stockMgmt->approveStock($stock_id, $r['warehouse'], 'in', $whMgmt);
});
ok("Restocked {$r['qty']} {$p['uom']} of {$r['sku']} into {$warehouses[$r['warehouse']]} bin {$bin}");
}
// ─────────────────────────────────────────────────────────────────────────────
// 2. Stock-out (direct dispatch) — dedicated batch in + full-bin out
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- Stock-out (direct dispatch) ---\n";
$dispatch_defs = [
['sku' => 'PK-003', 'warehouse' => $main_wh, 'qty' => 25, 'customer_idx' => 0],
['sku' => 'OF-003', 'warehouse' => $main_wh, 'qty' => 15, 'customer_idx' => 1],
];
foreach ($dispatch_defs as $d) {
$sth = $pdo2->prepare(
"SELECT COUNT(*) FROM `td_stock_{$d['warehouse']}`
WHERE company_id = :c AND product_sku = :sku AND type = 'out' AND description = 'Direct dispatch (demo seed)'"
);
$sth->execute([':c' => $company_id, ':sku' => $d['sku']]);
if ((int)$sth->fetchColumn() > 0) {
skip("Dispatch for {$d['sku']} already exists");
continue;
}
$bin = nextFreeBin($pdo2, $company_id, $d['warehouse']);
$customer_id = $customer_ids[$d['customer_idx']];
$p = $products[$d['sku']];
dbTransaction($pdo2, function ($pdo2) use ($stockMgmt, $whMgmt, $d, $bin, $customer_id, $logging, $p) {
// Receive a dedicated batch first (so we don't touch demo_seed.php's opening-stock bins)
$in_uuid = bin2hex(random_bytes(16));
$in_id = $stockMgmt->saveStockIn([
'id' => 0, 'warehouse' => $d['warehouse'], 'product_sku' => $d['sku'],
'quantity' => $d['qty'], 'price' => $p['cost_price'],
'zone' => $bin, 'aisle' => $bin, 'bin' => $bin,
'contact_id' => $customer_id, 'description' => 'Batch for dispatch (demo seed)',
], $logging, $in_uuid);
$stockMgmt->approveStock($in_id, $d['warehouse'], 'in', $whMgmt);
// Dispatch it straight out (saveStockOut takes the whole bin)
$out_uuid = bin2hex(random_bytes(16));
$out_id = $stockMgmt->saveStockOut([
'id' => 0, 'warehouse' => $d['warehouse'], 'product_sku' => $d['sku'],
'zone' => $bin, 'aisle' => $bin, 'bin' => $bin,
'contact_id' => $customer_id, 'description' => 'Direct dispatch (demo seed)',
], $logging, $out_uuid);
$stockMgmt->approveStock($out_id, $d['warehouse'], 'out', $whMgmt);
});
ok("Dispatched {$d['qty']} {$p['uom']} of {$d['sku']} from {$warehouses[$d['warehouse']]}");
}
// ─────────────────────────────────────────────────────────────────────────────
// 3. Stock transfer — Main Warehouse -> Bangna Distribution Center
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- Stock transfer (Main -> Bangna) ---\n";
$transfer_defs = [
['sku' => 'BV-001', 'qty' => 30],
['sku' => 'PK-002', 'qty' => 12],
];
foreach ($transfer_defs as $t) {
$sth = $pdo2->prepare(
"SELECT COUNT(*) FROM `td_stock_{$main_wh}`
WHERE company_id = :c AND product_sku = :sku AND type = 'transfer' AND description = 'Transfer to Bangna (demo seed)'"
);
$sth->execute([':c' => $company_id, ':sku' => $t['sku']]);
if ((int)$sth->fetchColumn() > 0) {
skip("Transfer for {$t['sku']} already exists");
continue;
}
// Bin allocation is independent of the transaction below — resolve both up front.
$from_bin = nextFreeBin($pdo2, $company_id, $main_wh);
$to_bin = nextFreeBin($pdo2, $company_id, $bangna_wh);
$p = $products[$t['sku']];
dbTransaction($pdo2, function ($pdo2) use ($stockMgmt, $whMgmt, $t, $from_bin, $to_bin, $main_wh, $bangna_wh, $logging, $p) {
// Receive a dedicated batch first (so we don't touch demo_seed.php's opening-stock bins)
$in_uuid = bin2hex(random_bytes(16));
$in_id = $stockMgmt->saveStockIn([
'id' => 0, 'warehouse' => $main_wh, 'product_sku' => $t['sku'],
'quantity' => $t['qty'], 'price' => $p['cost_price'],
'zone' => $from_bin, 'aisle' => $from_bin, 'bin' => $from_bin,
'contact_id' => 0, 'description' => 'Batch for transfer (demo seed)',
], $logging, $in_uuid);
$stockMgmt->approveStock($in_id, $main_wh, 'in', $whMgmt);
$tr_uuid = bin2hex(random_bytes(16));
$out_id = $stockMgmt->saveStockTransfer([
'id' => 0, 'warehouse_from' => $main_wh, 'warehouse_to' => $bangna_wh,
'product_sku' => $t['sku'],
'zone_from' => $from_bin, 'aisle_from' => $from_bin, 'bin_from' => $from_bin,
'zone_to' => $to_bin, 'aisle_to' => $to_bin, 'bin_to' => $to_bin,
'contact_id' => 0, 'description' => 'Transfer to Bangna (demo seed)',
], $logging, $tr_uuid);
$stockMgmt->approveStock($out_id, $main_wh, 'transfer', $whMgmt);
});
ok("Transferred {$t['qty']} {$p['uom']} of {$t['sku']}: {$warehouses[$main_wh]} bin {$from_bin} -> {$warehouses[$bangna_wh]} bin {$to_bin}");
}
// ─────────────────────────────────────────────────────────────────────────────
// 4. Chart of accounts + departments + GL posting formulas
@@ -379,6 +231,25 @@ foreach ($formula_defs as $doc_type => $def) {
$formula_ids[$doc_type] = $id;
}
echo "\n--- Product account mapping ---\n";
// The sales and purchase formulas split 'total' per product, so Batch GL Entries
// refuses to post until every product has sales/purchase accounts
// (Account Formulas > Product Accounts, accounting/api/engine/product_account_mapping.php).
$sth = $pdo2->prepare("SELECT id, sku, sales_account_code, purchase_account_code FROM md_product WHERE company_id = :c ORDER BY id");
$sth->execute([':c' => $company_id]);
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $prod) {
if ($prod['sales_account_code'] && $prod['purchase_account_code']) {
skip("Product {$prod['sku']} already mapped");
continue;
}
$sales_code = $prod['sales_account_code'] ?: '4000';
$purchase_code = $prod['purchase_account_code'] ?: '5000';
dbTransaction($pdo2, fn($pdo) => (new ProductManager($pdo, $company_id))
->updateAccountMapping((int)$prod['id'], $sales_code, $purchase_code, $logging));
ok("Mapped {$prod['sku']}: sales {$sales_code}, purchase {$purchase_code}");
}
/** Post (or replace) GL for a document, mirroring order/api/engine/issue_invoice.php exactly. */
function postGl(PDO $pdo1, PDO $pdo2, int $company_id, string $doc_type, int $doc_id, string $posting_class): array {
require_once __DIR__ . "/app/assets/utils/classes_ac/posting/{$posting_class}.php";
@@ -409,7 +280,7 @@ function buildLineItem(array $products, string $sku, float $qty): array {
$tax_amount = round($total_price * $tax_rate / 100, 4);
return [
'product_sku' => $sku,
'product_name' => $sku, // demo_seed.php products keyed by SKU; name not needed for GL/report correctness
'product_name' => $p['product_name'], // documents show the product name, as the UI's product search fills it
'quantity' => $qty,
'unit_price' => $unit_price,
'total_price' => $total_price,
@@ -427,12 +298,9 @@ $quotMgmt = new QuotationManager($pdo2, $company_id);
$orderMgmt = new OrderManager($pdo2, $company_id);
$invMgmt = new InvoiceManager($pdo2, $company_id);
// NOTE: OrderManager::linkQuotationToOrder() is broken — it writes to a
// td_quotation.order_id column that does not exist in the schema (see
// setup.php's td_quotation definition). We never call it. The real link
// between a quotation and its order is source='quotation'/source_id=$qid on
// td_order, which QuotationManager::getById() already joins on — that's what
// this script relies on too, both to link and to look the link back up.
// The link between a quotation and its order is source='quotation' /
// source_id=$qid on td_order, which QuotationManager::getById() joins on —
// that's what this script relies on, both to link and to look the link back up.
$sth = $pdo2->prepare("SELECT id FROM td_quotation WHERE company_id = :c AND notes = 'Demo seed sales cycle' LIMIT 1");
$sth->execute([':c' => $company_id]);
@@ -831,10 +699,9 @@ if ($supplier_return_id) {
}
// ─────────────────────────────────────────────────────────────────────────────
// 10. Customer return — draft only (see NOTE at top of this file: confirming
// customer returns is currently broken in the app itself).
// 10. Customer return — saved with a put-away bin, then confirmed (restocked).
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- Customer return (draft only — confirming is currently broken, see file header) ---\n";
echo "\n--- Customer return ---\n";
$returnMgmt = new ReturnManager($pdo2, $company_id);
@@ -857,7 +724,18 @@ if ($customer_return_id) {
$ret_total = round($ret_qty * (float)$order_line['unit_price'], 4);
$ret_tax = round($ret_total * (float)$order_line['tax_rate'] / 100, 4);
$customer_return_id = dbTransaction($pdo2, function ($pdo2) use ($returnMgmt, $customer_ids, $order_line, $ret_qty, $ret_total, $ret_tax, $sales_order_id, $sales_invoice_id, $logging) {
// Returned goods go back into a free bin of the warehouse they shipped from.
$sth = $pdo2->prepare(
"SELECT bin FROM md_bin WHERE company_id = :c AND warehouse = :w AND product_sku IS NULL
ORDER BY CAST(SUBSTRING(bin, 3) AS UNSIGNED) ASC LIMIT 1"
);
$sth->execute([':c' => $company_id, ':w' => (int)$order_line['warehouse_id']]);
$return_bin = (string)($sth->fetchColumn() ?: '');
if ($return_bin === '') {
throw new Exception("No free bin in warehouse #{$order_line['warehouse_id']} for the demo customer return.");
}
$customer_return_id = dbTransaction($pdo2, function ($pdo2) use ($returnMgmt, $customer_ids, $order_line, $ret_qty, $ret_total, $ret_tax, $sales_order_id, $sales_invoice_id, $logging, $return_bin) {
return $returnMgmt->saveReturn([
'id' => 0, 'order_id' => $sales_order_id, 'invoice_id' => $sales_invoice_id,
'contact_id' => $customer_ids[0], 'return_date' => date('Y-m-d'),
@@ -869,10 +747,23 @@ if ($customer_return_id) {
'total_price' => $ret_total, 'tax_amount' => $ret_tax, 'tax_rate' => $order_line['tax_rate'],
'warehouse_id' => $order_line['warehouse_id'], 'stock_out_id' => $order_line['stock_out_id'],
'stock_out_warehouse_id' => $order_line['warehouse_id'],
// simple location mode: zone and aisle mirror the bin
'zone' => $return_bin, 'aisle' => $return_bin, 'bin' => $return_bin,
]],
], $logging);
});
ok("Created draft customer return for {$ret_qty} x {$order_line['product_sku']} (id={$customer_return_id}) — left in draft, not confirmed");
// Confirm exactly as order/api/engine/confirm_return.php does: restock,
// auto-approve the stock-in, and raise the credit note.
dbTransaction($pdo2, function ($pdo2) use ($company_id, $customer_return_id, $logging) {
$ret = new ReturnManager($pdo2, $company_id);
$ret->confirmReturn(
$customer_return_id, bin2hex(random_bytes(16)), $logging,
new WarehouseManager($pdo2, $company_id), new InvoiceManager($pdo2, $company_id),
true, true
);
});
ok("Created + confirmed customer return for {$ret_qty} x {$order_line['product_sku']} into bin {$return_bin} (id={$customer_return_id})");
}
// ─────────────────────────────────────────────────────────────────────────────
+1
View File
@@ -22,6 +22,7 @@ services:
EMIT_SECRET: ${EMIT_SECRET}
SMTP_USERNAME: ${SMTP_USERNAME}
SMTP_PASSWORD: ${SMTP_PASSWORD}
OTP_REQUIRED: ${OTP_REQUIRED:-false}
volumes:
- .:/var/www/html/wms-app
ports:
+1
View File
@@ -55,6 +55,7 @@ PUBLIC_HOST=$public_host
EMIT_SECRET=$emit_secret
SMTP_USERNAME=$smtp_user
SMTP_PASSWORD=$smtp_pass
OTP_REQUIRED=false
HTTP_PORT=$http_port
EOF
chmod 600 "$ENV_FILE"
+2
View File
@@ -2,6 +2,8 @@ FROM php:8.3-apache
RUN apt-get update && apt-get install -y --no-install-recommends \
libzip-dev libicu-dev libonig-dev default-mysql-client gettext-base \
libpng-dev libjpeg-dev libfreetype6-dev \
&& docker-php-ext-configure gd --with-jpeg --with-freetype \
&& docker-php-ext-install pdo_mysql mysqli mbstring gd zip intl sockets exif opcache \
&& a2enmod rewrite \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
+16
View File
@@ -33,6 +33,22 @@ if (!defined('NODE_EMIT_SECRET')) {
define('NODE_EMIT_SECRET', '${EMIT_SECRET}');
}
// ── Login OTP ────────────────────────────────────────────────────────────────
// Set from OTP_REQUIRED in .env and reconciled by the entrypoint on every start.
// Off by default: anything other than the boolean true leaves the OTP step off.
if (!defined('OTP_REQUIRED')) {
define('OTP_REQUIRED', ${OTP_REQUIRED});
}
// ── App registry ─────────────────────────────────────────────────────────────
// Apps a user can be given access to (Setting → Users Access). Keys must match
// the user.app_access enum; assets/utils/app_registry.php supplies this default
// for configs that do not define it.
$app_registry = [
'wms' => ['label' => 'WMS', 'icon' => 'ti-box', 'color' => 'bg-label-primary'],
'accounting' => ['label' => 'Accounting', 'icon' => 'ti-calculator', 'color' => 'bg-label-success'],
];
// ── Usage packages ───────────────────────────────────────────────────────────
$packages = [
'starter' => [
+25 -1
View File
@@ -4,15 +4,39 @@ set -e
APP_DIR=/var/www/html/wms-app
CONFIG=$APP_DIR/app/config.php
# Email OTP on sign-in, OFF BY DEFAULT. Only the exact string "true" turns it
# on; a missing variable or anything else means false.
: "${OTP_REQUIRED:=false}"
[ "$OTP_REQUIRED" = "true" ] || OTP_REQUIRED=false
export OTP_REQUIRED
# Generate app/config.php from template on first run only.
# Restrict envsubst to known placeholders so it never touches the app's own
# $variable syntax (envsubst blanks out any $NAME it doesn't recognize).
if [ ! -f "$CONFIG" ]; then
echo "[entrypoint] generating app/config.php"
envsubst '${DB_ROOT_PASSWORD} ${PUBLIC_HOST} ${EMIT_SECRET} ${SMTP_USERNAME} ${SMTP_PASSWORD}' \
envsubst '${DB_ROOT_PASSWORD} ${PUBLIC_HOST} ${EMIT_SECRET} ${SMTP_USERNAME} ${SMTP_PASSWORD} ${OTP_REQUIRED}' \
< /usr/local/etc/wms/config.php.template > "$CONFIG"
fi
# config.php is never regenerated once it exists, so OTP_REQUIRED is the one
# line reconciled on every start: the .env value always wins, and a config.php
# written before this switch existed gets the line added.
if grep -q "define('OTP_REQUIRED'" "$CONFIG"; then
if ! grep -q "define('OTP_REQUIRED', ${OTP_REQUIRED});" "$CONFIG"; then
sed -i "s/define('OTP_REQUIRED', [A-Za-z]*);/define('OTP_REQUIRED', ${OTP_REQUIRED});/" "$CONFIG"
echo "[entrypoint] OTP_REQUIRED is now ${OTP_REQUIRED}"
fi
else
# Drop a closing ?> on the last line so the appended block stays inside PHP.
sed -i -e '${/^[[:space:]]*?>[[:space:]]*$/d}' "$CONFIG"
printf "\nif (!defined('OTP_REQUIRED')) {\n\tdefine('OTP_REQUIRED', %s);\n}\n" "$OTP_REQUIRED" >> "$CONFIG"
echo "[entrypoint] added OTP_REQUIRED = ${OTP_REQUIRED} to an existing config.php"
fi
if [ "$OTP_REQUIRED" = "false" ]; then
echo "[entrypoint] email OTP is off (OTP_REQUIRED=false); sign-in is password only."
fi
mkdir -p "$APP_DIR/app/uploads"
chown -R www-data:www-data "$APP_DIR/app/uploads"

Some files were not shown because too many files have changed in this diff Show More