Compare commits
48
Commits
f14c850c70
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c39700d74 | ||
|
|
136084f259 | ||
|
|
63cea23dc4 | ||
|
|
dd48a8b96d | ||
|
|
b2c437426b | ||
|
|
a0677d6d8d | ||
|
|
ed3dd2f215 | ||
|
|
fda211b1a8 | ||
|
|
9a50238347 | ||
|
|
5df67367fa | ||
|
|
8f57ab5570 | ||
|
|
b8798bc02d | ||
|
|
bbe89b0380 | ||
|
|
714b70d552 | ||
|
|
f3c0e3c876 | ||
|
|
a6651c41b9 | ||
|
|
458a883810 | ||
|
|
5221b3a1a1 | ||
|
|
418dbf9ba6 | ||
|
|
99ae35dc98 | ||
|
|
1f371c6f94 | ||
|
|
d7f104ff25 | ||
|
|
dfeb57533a | ||
|
|
cb36d3b8fd | ||
|
|
5687b562fb | ||
|
|
d93abb0b81 | ||
|
|
b4b1f5cbec | ||
|
|
1904fea84c | ||
|
|
4f884177a5 | ||
|
|
4733c78ac6 | ||
|
|
9b41c0a73a | ||
|
|
ba96de50a1 | ||
|
|
293097363b | ||
|
|
b07882e3f4 | ||
|
|
eddb10aa22 | ||
|
|
363da054e0 | ||
|
|
65e9c1668d | ||
|
|
f4ef776e9c | ||
|
|
59037b5158 | ||
|
|
f5ea74e134 | ||
|
|
9c275eb358 | ||
|
|
2410f7bade | ||
|
|
f04554793e | ||
|
|
ba8e275426 | ||
|
|
e36d304521 | ||
|
|
b76dc679af | ||
|
|
fdf6292466 | ||
|
|
94032dd65b |
@@ -13,11 +13,5 @@ EMIT_SECRET=
|
|||||||
SMTP_USERNAME=
|
SMTP_USERNAME=
|
||||||
SMTP_PASSWORD=
|
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)
|
# Port to expose the web app on (default 80)
|
||||||
HTTP_PORT=80
|
HTTP_PORT=80
|
||||||
|
|||||||
+2
-3
@@ -6,9 +6,6 @@ app/uploads
|
|||||||
node_modules/
|
node_modules/
|
||||||
nodejs/.env
|
nodejs/.env
|
||||||
|
|
||||||
# PM2 runtime logs (written by the node container; nodejs/logs/.gitkeep keeps the folder)
|
|
||||||
nodejs/logs/*.log
|
|
||||||
|
|
||||||
# Docker deploy secrets
|
# Docker deploy secrets
|
||||||
/.env
|
/.env
|
||||||
|
|
||||||
@@ -18,4 +15,6 @@ lib/zxcvbn-php-master/vendor/sebastian/
|
|||||||
# custom files
|
# custom files
|
||||||
notes/
|
notes/
|
||||||
docs/
|
docs/
|
||||||
|
.claude/
|
||||||
SESSION.php
|
SESSION.php
|
||||||
|
sdlc/
|
||||||
|
|||||||
@@ -509,7 +509,7 @@
|
|||||||
|
|
||||||
var extra_fields = h.source_type === 'manual'
|
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">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(format_date(h.journal_date)) + '</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">Description</div><div>' + escape_html(h.description || '—') + '</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">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>' +
|
'<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>' +
|
||||||
|
|||||||
+3
-22
@@ -581,19 +581,9 @@ 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) {
|
function load_departments(select_id, selected_id) {
|
||||||
var ctx = document.getElementById('session-context');
|
var ctx = document.getElementById('session-context');
|
||||||
return ajax_request({
|
ajax_request({
|
||||||
url: server_url + 'accounting/api/engine/department.php',
|
url: server_url + 'accounting/api/engine/department.php',
|
||||||
action: 'get',
|
action: 'get',
|
||||||
queueLock: false,
|
queueLock: false,
|
||||||
@@ -1005,24 +995,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
/**
|
/**
|
||||||
* Helper function to format date strings (assuming input is in ISO format)
|
* 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) {
|
function format_date(iso_string) {
|
||||||
if (!iso_string) return '—';
|
if (!iso_string) return '—';
|
||||||
var split = String(iso_string).split(" ");
|
var split = iso_string.split(" ");
|
||||||
var datePart = split[0].split("-");
|
var datePart = split[0].split("-");
|
||||||
if (datePart.length !== 3) return iso_string;
|
if (datePart.length !== 3) return iso_string;
|
||||||
var formatted = `${datePart[0]}-${datePart[1]}-${datePart[2]}`;
|
var formatted = `${datePart[2]}/${datePart[1]}/${datePart[0]}`;
|
||||||
return split.length === 2 ? `${formatted} ${split[1]}` : formatted;
|
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) {
|
function to_iso_date(dateStr) {
|
||||||
if (!dateStr) return null;
|
if (!dateStr) return null;
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
<?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
|
public function saveProfile(array $data, string $company_logo, string $company_seal): void
|
||||||
{
|
{
|
||||||
$channel = preg_replace('/[^a-z0-9\-_]/', '', strtolower(trim($data['channel_name'] ?? '')));
|
$channel = strtolower(preg_replace('/[^a-z0-9\-_]/', '', $data['channel_name'] ?? ''));
|
||||||
|
|
||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"UPDATE company_list SET
|
"UPDATE company_list SET
|
||||||
|
|||||||
@@ -260,10 +260,7 @@ class OrderManager {
|
|||||||
{
|
{
|
||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT o.*,
|
"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
|
FROM td_order o
|
||||||
LEFT JOIN md_contact c
|
LEFT JOIN md_contact c
|
||||||
ON c.company_id = o.company_id
|
ON c.company_id = o.company_id
|
||||||
@@ -496,7 +493,7 @@ class OrderManager {
|
|||||||
|
|
||||||
// Fetch existing row to check status and load log
|
// Fetch existing row to check status and load log
|
||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT status, department_id, `log` FROM td_order
|
"SELECT status, `log` FROM td_order
|
||||||
WHERE company_id = :company_id AND id = :id"
|
WHERE company_id = :company_id AND id = :id"
|
||||||
);
|
);
|
||||||
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
||||||
@@ -505,11 +502,6 @@ class OrderManager {
|
|||||||
if (!$row) {
|
if (!$row) {
|
||||||
throw new Exception("Order not found.");
|
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'];
|
$cur_status = (int)$row['status'];
|
||||||
if ($cur_status !== 0 && $cur_status !== -2) {
|
if ($cur_status !== 0 && $cur_status !== -2) {
|
||||||
throw new Exception("Only draft or pending orders can be edited.");
|
throw new Exception("Only draft or pending orders can be edited.");
|
||||||
@@ -549,7 +541,7 @@ class OrderManager {
|
|||||||
WHERE id = :id AND company_id = :company_id"
|
WHERE id = :id AND company_id = :company_id"
|
||||||
)->execute([
|
)->execute([
|
||||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||||
':department_id' => $department_id,
|
':department_id' => (int)($data['department_id'] ?? 0),
|
||||||
':order_date' => $data['order_date'] ?? date('Y-m-d'),
|
':order_date' => $data['order_date'] ?? date('Y-m-d'),
|
||||||
':subtotal' => $subtotal,
|
':subtotal' => $subtotal,
|
||||||
':discount' => $discount,
|
':discount' => $discount,
|
||||||
|
|||||||
@@ -194,10 +194,7 @@ class PurchaseOrderManager {
|
|||||||
{
|
{
|
||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT p.*,
|
"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
|
FROM td_purchase_order p
|
||||||
LEFT JOIN md_contact c
|
LEFT JOIN md_contact c
|
||||||
ON c.company_id = p.company_id
|
ON c.company_id = p.company_id
|
||||||
|
|||||||
@@ -94,10 +94,7 @@ class PurchaseRequestManager
|
|||||||
WHERE p.company_id = r.company_id
|
WHERE p.company_id = r.company_id
|
||||||
AND p.source = 'purchase_request'
|
AND p.source = 'purchase_request'
|
||||||
AND p.source_id = r.id
|
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
|
FROM td_purchase_request r
|
||||||
LEFT JOIN md_contact c
|
LEFT JOIN md_contact c
|
||||||
ON c.company_id = r.company_id AND c.id = r.contact_id
|
ON c.company_id = r.company_id AND c.id = r.contact_id
|
||||||
@@ -169,12 +166,6 @@ class PurchaseRequestManager
|
|||||||
|
|
||||||
if (empty($items)) throw new Exception('At least one item is required.');
|
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);
|
[$subtotal, $tax, $grand] = $this->computeTotals($items, $discount, $shipping_fee);
|
||||||
|
|
||||||
if ($id === 0) {
|
if ($id === 0) {
|
||||||
|
|||||||
@@ -89,10 +89,7 @@ class QuotationManager
|
|||||||
public function getList(): array
|
public function getList(): array
|
||||||
{
|
{
|
||||||
$sth = $this->pdo->prepare(
|
$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
|
FROM td_quotation q
|
||||||
LEFT JOIN md_contact c
|
LEFT JOIN md_contact c
|
||||||
ON c.id = q.contact_id AND c.company_id = q.company_id
|
ON c.id = q.contact_id AND c.company_id = q.company_id
|
||||||
@@ -176,24 +173,6 @@ class QuotationManager
|
|||||||
$items = $data['items'] ?? [];
|
$items = $data['items'] ?? [];
|
||||||
$discount = (float)($data['discount'] ?? 0);
|
$discount = (float)($data['discount'] ?? 0);
|
||||||
|
|
||||||
$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);
|
[$subtotal, $tax, $grand] = $this->computeTotals($items, $discount);
|
||||||
|
|
||||||
if ($id === 0) {
|
if ($id === 0) {
|
||||||
|
|||||||
@@ -169,10 +169,7 @@ class ReturnManager {
|
|||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT r.*,
|
"SELECT r.*,
|
||||||
COALESCE(c.contact_name, '') AS contact_name,
|
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
|
FROM td_return r
|
||||||
LEFT JOIN md_contact c
|
LEFT JOIN md_contact c
|
||||||
ON c.company_id = r.company_id
|
ON c.company_id = r.company_id
|
||||||
|
|||||||
@@ -79,8 +79,7 @@ class StockManager {
|
|||||||
public function getStockList(int $warehouse_id, string $type): array
|
public function getStockList(int $warehouse_id, string $type): array
|
||||||
{
|
{
|
||||||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||||||
// The transfer list shows the outbound row, whose quantity is in `out` (its `in` is always 0).
|
$column = $type === 'out' ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)';
|
||||||
$column = in_array($type, ['out', 'transfer'], true) ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)';
|
|
||||||
$stock_ref = $this->stockReferenceSql();
|
$stock_ref = $this->stockReferenceSql();
|
||||||
|
|
||||||
// Transfer list: show only the outbound side (out > 0) to avoid duplicate display
|
// Transfer list: show only the outbound side (out > 0) to avoid duplicate display
|
||||||
@@ -440,9 +439,8 @@ class StockManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quantity and identifiers come from the existing stock_in row (immutable).
|
// Quantity and identifiers come from the existing stock_in row (immutable)
|
||||||
// `in` is decimal(18,4) — an int cast would drop fractional quantities.
|
$quantity = (int)$source_stock['in'];
|
||||||
$quantity = (float)$source_stock['in'];
|
|
||||||
$ref_id = (int)$source_stock['id'];
|
$ref_id = (int)$source_stock['id'];
|
||||||
$lot_number = $source_stock['lot_number'] ?? null;
|
$lot_number = $source_stock['lot_number'] ?? null;
|
||||||
$serial_number = $source_stock['serial_number'] ?? null;
|
$serial_number = $source_stock['serial_number'] ?? null;
|
||||||
@@ -613,9 +611,8 @@ class StockManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quantity and identifiers come from the source stock_in row (immutable).
|
// Quantity and identifiers come from the source stock_in row (immutable)
|
||||||
// `in` is decimal(18,4) — an int cast would drop fractional quantities.
|
$quantity = (int)$source_stock['in'];
|
||||||
$quantity = (float)$source_stock['in'];
|
|
||||||
$lot_number = $source_stock['lot_number'] ?? null;
|
$lot_number = $source_stock['lot_number'] ?? null;
|
||||||
$serial_number = $source_stock['serial_number'] ?? null;
|
$serial_number = $source_stock['serial_number'] ?? null;
|
||||||
|
|
||||||
|
|||||||
@@ -154,10 +154,7 @@ class SupplierReturnManager {
|
|||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT r.*,
|
"SELECT r.*,
|
||||||
COALESCE(c.contact_name, '') AS contact_name,
|
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
|
FROM td_supplier_return r
|
||||||
LEFT JOIN md_contact c
|
LEFT JOIN md_contact c
|
||||||
ON c.company_id = r.company_id
|
ON c.company_id = r.company_id
|
||||||
|
|||||||
@@ -697,7 +697,7 @@ class FinancialReports
|
|||||||
COALESCE(d.dept_code, '') AS dept_code,
|
COALESCE(d.dept_code, '') AS dept_code,
|
||||||
COALESCE(d.dept_name, '') AS dept_name,
|
COALESCE(d.dept_name, '') AS dept_name,
|
||||||
COALESCE(g.journal_date, DATE(g.created_at)) AS entry_date,
|
COALESCE(g.journal_date, DATE(g.created_at)) AS entry_date,
|
||||||
DATE_FORMAT(g.created_at, '%Y-%m-%d %H:%i:%s') AS posted_at,
|
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
|
||||||
i.debit,
|
i.debit,
|
||||||
i.credit,
|
i.credit,
|
||||||
COALESCE(i.description, '') AS line_description
|
COALESCE(i.description, '') AS line_description
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ class GlQueryManager
|
|||||||
g.current_version,
|
g.current_version,
|
||||||
g.formula_id,
|
g.formula_id,
|
||||||
COALESCE(f.formula_name, '') AS formula_name,
|
COALESCE(f.formula_name, '') AS formula_name,
|
||||||
DATE_FORMAT(g.created_at, '%Y-%m-%d %H:%i:%s') AS posted_at,
|
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
|
||||||
DATE_FORMAT(g.updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at,
|
DATE_FORMAT(g.updated_at, '%d/%m/%Y %H:%i') AS updated_at,
|
||||||
CASE g.source_type
|
CASE g.source_type
|
||||||
WHEN 'receipt' THEN r.receipt_number
|
WHEN 'receipt' THEN r.receipt_number
|
||||||
WHEN 'payment' THEN p.payment_number
|
WHEN 'payment' THEN p.payment_number
|
||||||
@@ -142,8 +142,8 @@ class GlQueryManager
|
|||||||
|
|
||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT g.*,
|
"SELECT g.*,
|
||||||
DATE_FORMAT(g.created_at, '%Y-%m-%d %H:%i:%s') AS posted_at,
|
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
|
||||||
DATE_FORMAT(g.updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at_fmt,
|
DATE_FORMAT(g.updated_at, '%d/%m/%Y %H:%i') AS updated_at_fmt,
|
||||||
DATE_FORMAT(g.journal_date, '%d/%m/%Y') AS journal_date_fmt,
|
DATE_FORMAT(g.journal_date, '%d/%m/%Y') AS journal_date_fmt,
|
||||||
COALESCE(f.formula_name, '') AS formula_name
|
COALESCE(f.formula_name, '') AS formula_name
|
||||||
FROM td_gl g
|
FROM td_gl g
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
<?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
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -38,24 +38,6 @@ if (!defined('NODE_EMIT_SECRET')) {
|
|||||||
define('NODE_EMIT_SECRET', 'YOUR_NODE_EMIT_SECRET'); // must match nodejs/.env 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 ───────────────────────────────────────────────────────────
|
// ── Usage packages ───────────────────────────────────────────────────────────
|
||||||
// Keyed by company_list.package (defaults to 'starter'). Read by UsageGuard to
|
// Keyed by company_list.package (defaults to 'starter'). Read by UsageGuard to
|
||||||
// enforce daily/weekly action limits and which features lock once exceeded.
|
// enforce daily/weekly action limits and which features lock once exceeded.
|
||||||
|
|||||||
@@ -27,11 +27,6 @@ class db_statement extends PDOStatement {
|
|||||||
$this->pdo = $pdo;
|
$this->pdo = $pdo;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
public function execute($args = null) {
|
||||||
// Perform logging here. PDO object is accessible
|
// Perform logging here. PDO object is accessible
|
||||||
// from $this->pdo.
|
// from $this->pdo.
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ if (!$request_id) {
|
|||||||
$answer['message'] = 'Purchase request ID is required.';
|
$answer['message'] = 'Purchase request ID is required.';
|
||||||
exit(json_encode($answer));
|
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);
|
$prm = new PurchaseRequestManager($pdo2, $company_id);
|
||||||
|
|
||||||
@@ -25,15 +29,6 @@ if (!$pr || (int)$pr['status'] !== 2) {
|
|||||||
exit(json_encode($answer));
|
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'];
|
$pr_items = $pr['items'];
|
||||||
if (empty($pr_items)) {
|
if (empty($pr_items)) {
|
||||||
$answer['message'] = 'Purchase request has no items.';
|
$answer['message'] = 'Purchase request has no items.';
|
||||||
@@ -97,7 +92,6 @@ foreach ($convert_items as $ci) {
|
|||||||
'unit_price' => $unit_price,
|
'unit_price' => $unit_price,
|
||||||
'total_price' => $total_price,
|
'total_price' => $total_price,
|
||||||
'tax_amount' => $tax_amount,
|
'tax_amount' => $tax_amount,
|
||||||
'tax_rate' => (float)($pi['tax_rate'] ?? 0),
|
|
||||||
'received_qty' => 0,
|
'received_qty' => 0,
|
||||||
'stock_in_id' => 0,
|
'stock_in_id' => 0,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -230,7 +230,7 @@
|
|||||||
$('#badge_status').html(invoice_status_badge(inv.status, inv.due_date));
|
$('#badge_status').html(invoice_status_badge(inv.status, inv.due_date));
|
||||||
$('#display_contact').text(inv.contact_name || '—');
|
$('#display_contact').text(inv.contact_name || '—');
|
||||||
$('#display_issued').text(format_date(inv.issued_date) || '—');
|
$('#display_issued').text(format_date(inv.issued_date) || '—');
|
||||||
$('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : '');
|
$('#due_date').val(inv.due_date ? format_date(inv.due_date) : '');
|
||||||
$('#notes').val(inv.notes || '');
|
$('#notes').val(inv.notes || '');
|
||||||
|
|
||||||
// Source link
|
// Source link
|
||||||
|
|||||||
@@ -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_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_price" value="${item.unit_price || item.price || 0}" min="0" step="any" oninput="recalc_totals()"></td>
|
||||||
<td>
|
<td>
|
||||||
<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="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="hidden" class="item_tax_amount" value="${item.tax_amount || 0}">
|
<input type="hidden" class="item_tax_amount" value="${item.tax_amount || 0}">
|
||||||
</td>
|
</td>
|
||||||
<td class="item_total text-end">${format_number((item.quantity || 1) * (item.unit_price || item.price || 0), 2)}</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 || '');
|
$('#po_number_display').text(po_data.po_number || '');
|
||||||
$('#contact').val(po_data.contact_name || '');
|
$('#contact').val(po_data.contact_name || '');
|
||||||
$('#contact_id').val(po_data.contact_id || '');
|
$('#contact_id').val(po_data.contact_id || '');
|
||||||
$('#po_date').val(po_data.po_date ? format_date_input(po_data.po_date) : '');
|
$('#po_date').val(po_data.po_date ? format_date(po_data.po_date) : '');
|
||||||
$('#expected_date').val(po_data.expected_date ? format_date_input(po_data.expected_date) : '');
|
$('#expected_date').val(po_data.expected_date ? format_date(po_data.expected_date) : '');
|
||||||
$('#department_id').val(po_data.department_id || 0);
|
$('#department_id').val(po_data.department_id || 0);
|
||||||
$('#notes').val(po_data.notes || '');
|
$('#notes').val(po_data.notes || '');
|
||||||
$('#discount').val(po_data.discount || 0);
|
$('#discount').val(po_data.discount || 0);
|
||||||
@@ -371,8 +371,8 @@
|
|||||||
recalc_totals();
|
recalc_totals();
|
||||||
});
|
});
|
||||||
|
|
||||||
$(async function() {
|
$(function() {
|
||||||
await Promise.resolve(load_departments('department_id')).catch(function() {});
|
load_departments('department_id');
|
||||||
flatpickr('#po_date', { dateFormat: 'd/m/Y', allowInput: true });
|
flatpickr('#po_date', { dateFormat: 'd/m/Y', allowInput: true });
|
||||||
flatpickr('#expected_date', { dateFormat: 'd/m/Y', allowInput: true });
|
flatpickr('#expected_date', { dateFormat: 'd/m/Y', allowInput: true });
|
||||||
if (po_id) {
|
if (po_id) {
|
||||||
|
|||||||
@@ -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>' : '');
|
.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').val(request_data.contact_name || '');
|
||||||
$('#contact_id').val(request_data.contact_id || 0);
|
$('#contact_id').val(request_data.contact_id || 0);
|
||||||
$('#request_date').val(request_data.request_date ? format_date_input(request_data.request_date) : '');
|
$('#request_date').val(request_data.request_date ? format_date(request_data.request_date) : '');
|
||||||
$('#required_date').val(request_data.required_date ? format_date_input(request_data.required_date) : '');
|
$('#required_date').val(request_data.required_date ? format_date(request_data.required_date) : '');
|
||||||
$('#department_id').val(request_data.department_id || 0);
|
$('#department_id').val(request_data.department_id || 0);
|
||||||
$('#notes').val(request_data.notes || '');
|
$('#notes').val(request_data.notes || '');
|
||||||
$('#discount').val(request_data.discount || 0);
|
$('#discount').val(request_data.discount || 0);
|
||||||
@@ -370,9 +370,8 @@
|
|||||||
recalc_totals();
|
recalc_totals();
|
||||||
});
|
});
|
||||||
|
|
||||||
$(async function() {
|
$(function() {
|
||||||
// Options must exist before retrieve_request() selects the saved department
|
load_departments('department_id');
|
||||||
await Promise.resolve(load_departments('department_id')).catch(function() {});
|
|
||||||
flatpickr('#request_date', { dateFormat: 'd/m/Y', allowInput: true });
|
flatpickr('#request_date', { dateFormat: 'd/m/Y', allowInput: true });
|
||||||
flatpickr('#required_date', { dateFormat: 'd/m/Y', allowInput: true });
|
flatpickr('#required_date', { dateFormat: 'd/m/Y', allowInput: true });
|
||||||
if (request_id) {
|
if (request_id) {
|
||||||
|
|||||||
@@ -180,7 +180,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$.each(rows, function(i, r) {
|
$.each(rows, function(i, r) {
|
||||||
var item_count = parseInt(r.item_count) || 0;
|
var items = typeof r.items === 'string' ? JSON.parse(r.items || '[]') : (r.items || []);
|
||||||
var po_link = r.po_id > 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>`
|
? `<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>';
|
: '<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">${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">${escape_html(r.contact_name || '—')}</td>
|
||||||
<td class="py-3">${get_dept_label(r.department_id)}</td>
|
<td class="py-3">${get_dept_label(r.department_id)}</td>
|
||||||
<td class="py-3">${item_count} item${item_count !== 1 ? 's' : ''}</td>
|
<td class="py-3">${items.length} item${items.length !== 1 ? 's' : ''}</td>
|
||||||
<td class="py-3 fw-semibold">${format_number(r.grand_total, 2)}</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">${status_badge[String(r.status)] || r.status}</td>
|
||||||
<td class="py-3">${po_link}</td>
|
<td class="py-3">${po_link}</td>
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
<?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));
|
|
||||||
?>
|
|
||||||
@@ -93,16 +93,6 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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;"> </span>
|
|
||||||
</div>
|
|
||||||
<div class="form-text">The whole quantity in the selected location is taken out.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Contact -->
|
<!-- Contact -->
|
||||||
<div class="mb-3 col-lg-6">
|
<div class="mb-3 col-lg-6">
|
||||||
<label for="contact" class="form-label">Contact</label>
|
<label for="contact" class="form-label">Contact</label>
|
||||||
@@ -169,35 +159,6 @@
|
|||||||
}
|
}
|
||||||
set_select_value('#bin', data.bin, data.bin);
|
set_select_value('#bin', data.bin, data.bin);
|
||||||
scan_location_ready = true;
|
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(' ');
|
|
||||||
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 || '');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,8 +483,6 @@
|
|||||||
$('#zone').html('<option value="">Please select ' + label_zone.toLowerCase() + '</option>').prop('disabled', true);
|
$('#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);
|
$('#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);
|
$('#bin').html('<option value="">Please select ' + label_bin.toLowerCase() + '</option>').prop('disabled', true);
|
||||||
$('#location_quantity').val('');
|
|
||||||
$('#location_uom').html(' ');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -550,7 +509,7 @@
|
|||||||
$('#serial_number').html(`<option value="${data.serial_number || ''}">${data.serial_number || '—'}</option>`)
|
$('#serial_number').html(`<option value="${data.serial_number || ''}">${data.serial_number || '—'}</option>`)
|
||||||
.val(data.serial_number || '').prop('disabled', true);
|
.val(data.serial_number || '').prop('disabled', true);
|
||||||
|
|
||||||
$('#warehouse').val('<?php echo (int)($_GET["wh"] ?? 0);?>').prop('disabled', true);
|
$('#warehouse').val('<?php echo $_GET["wh"];?>').prop('disabled', true);
|
||||||
|
|
||||||
function populate_fields() {
|
function populate_fields() {
|
||||||
$.each(data, function(key, item) {
|
$.each(data, function(key, item) {
|
||||||
@@ -563,8 +522,6 @@
|
|||||||
.attr('data-id', data.contact_id);
|
.attr('data-id', data.contact_id);
|
||||||
$('#product_name').val(data.product_name);
|
$('#product_name').val(data.product_name);
|
||||||
$('#product_sku').attr('secondary', data.product_sku).prop('disabled', true);
|
$('#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=submit]').text('Update');
|
||||||
$('button[type=reset]').hide();
|
$('button[type=reset]').hide();
|
||||||
if (data.status == 0) {
|
if (data.status == 0) {
|
||||||
@@ -662,9 +619,6 @@
|
|||||||
if (advanced) retrieve_bin();
|
if (advanced) retrieve_bin();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bin selected → show how much it holds
|
|
||||||
$('#bin').on('change', show_location_quantity);
|
|
||||||
|
|
||||||
<?php } ?>
|
<?php } ?>
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -92,15 +92,6 @@
|
|||||||
<option value="">Please select bin</option>
|
<option value="">Please select bin</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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;"> </span>
|
|
||||||
</div>
|
|
||||||
<div class="form-text">Whole location is moved.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12"><hr class="my-2"></div>
|
<div class="col-12"><hr class="my-2"></div>
|
||||||
|
|
||||||
@@ -213,41 +204,12 @@
|
|||||||
|
|
||||||
if (role === 'from') {
|
if (role === 'from') {
|
||||||
scan_from_ready = true;
|
scan_from_ready = true;
|
||||||
show_from_quantity();
|
|
||||||
} else {
|
} else {
|
||||||
scan_to_ready = true;
|
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(' ');
|
|
||||||
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) {
|
function same_location_as_from(data) {
|
||||||
return String($('#warehouse_from').val()) === String(data.warehouse_id)
|
return String($('#warehouse_from').val()) === String(data.warehouse_id)
|
||||||
&& String($('#zone_from').val()) === String(data.zone)
|
&& String($('#zone_from').val()) === String(data.zone)
|
||||||
@@ -612,8 +574,6 @@
|
|||||||
$('#zone_from').html('<option value="">Please select ' + label_zone.toLowerCase() + '</option>').prop('disabled', true);
|
$('#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);
|
$('#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);
|
$('#bin_from').html('<option value="">Please select ' + label_bin.toLowerCase() + '</option>').prop('disabled', true);
|
||||||
$('#transfer_quantity').val('');
|
|
||||||
$('#transfer_uom').html(' ');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -680,8 +640,6 @@
|
|||||||
$('#contact').val(data.contact_name)
|
$('#contact').val(data.contact_name)
|
||||||
.attr('secondary', data.contact_name)
|
.attr('secondary', data.contact_name)
|
||||||
.attr('data-id', data.contact_id);
|
.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=submit]').text('Update');
|
||||||
$('button[type=reset]').hide();
|
$('button[type=reset]').hide();
|
||||||
if (data.status == 0) {
|
if (data.status == 0) {
|
||||||
@@ -774,9 +732,6 @@
|
|||||||
if (advanced) retrieve_bin('from');
|
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)
|
// To warehouse → zone (advanced) or bin directly (simple)
|
||||||
$('select[name="warehouse"][role="to"]').on('change', function() {
|
$('select[name="warehouse"][role="to"]').on('change', function() {
|
||||||
if (advanced) { retrieve_zone('to'); } else { retrieve_bin('to'); }
|
if (advanced) { retrieve_zone('to'); } else { retrieve_bin('to'); }
|
||||||
|
|||||||
+1
-20
@@ -1,23 +1,4 @@
|
|||||||
<?php
|
<?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');
|
|
||||||
|
|
||||||
// Security headers — emitted before any HTML output.
|
// Security headers — emitted before any HTML output.
|
||||||
header('X-Content-Type-Options: nosniff');
|
header('X-Content-Type-Options: nosniff');
|
||||||
header('X-Frame-Options: SAMEORIGIN');
|
header('X-Frame-Options: SAMEORIGIN');
|
||||||
@@ -85,7 +66,7 @@ header('Referrer-Policy: strict-origin-when-cross-origin');
|
|||||||
<link rel="stylesheet" href="<?php echo $server_url?>assets/css/main.css">
|
<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>
|
<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">
|
<link rel="stylesheet" href="<?php echo $server_url?>assets/css/custom.css">
|
||||||
<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/custom.js"></script>
|
||||||
<script src="<?php echo $server_url?>assets/js/batch_overlay.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"
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/html5-qrcode/2.3.8/html5-qrcode.min.js"
|
||||||
|
|||||||
+1
-21
@@ -3,17 +3,11 @@
|
|||||||
require_once __DIR__ . '/config.php';
|
require_once __DIR__ . '/config.php';
|
||||||
require_once __DIR__ . '/dbconn.php';
|
require_once __DIR__ . '/dbconn.php';
|
||||||
require_once __DIR__ . '/assets/utils/classes/UsageGuard.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.
|
// 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 —
|
// 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.
|
// using it (not "otp") ensures half-logged-in sessions are also redirected.
|
||||||
if(empty($_SESSION["login_company_id"])){
|
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');
|
header('Location: '.$server_url.'login/index.php');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -199,18 +193,6 @@ $_usage_full = $_usage_max_pct >= 100;
|
|||||||
</li>
|
</li>
|
||||||
<?php endif; ?>
|
<?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 -->
|
<!-- Usage limit warning -->
|
||||||
<?php if ($_usage_full || $_usage_warn): ?>
|
<?php if ($_usage_full || $_usage_warn): ?>
|
||||||
<li>
|
<li>
|
||||||
@@ -420,9 +402,7 @@ function do_switch_branch(company_id) {
|
|||||||
autoPrepare: true,
|
autoPrepare: true,
|
||||||
checkRequired: 0,
|
checkRequired: 0,
|
||||||
action: 'update',
|
action: 'update',
|
||||||
// Sent under its own key: prepare_form_data() always fills company_id with
|
company_id: company_id,
|
||||||
// the CURRENT company, and only options.data reaches the payload.
|
|
||||||
data: { target_company_id: company_id },
|
|
||||||
onSuccess: function(res) {
|
onSuccess: function(res) {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,7 +187,7 @@
|
|||||||
|
|
||||||
document.getElementById('gl_detail_body').innerHTML =
|
document.getElementById('gl_detail_body').innerHTML =
|
||||||
'<div class="row g-3 mb-4">' +
|
'<div class="row g-3 mb-4">' +
|
||||||
'<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">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">Period</p><strong>' + escape_html(h.period) + '</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">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>' +
|
'<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>' +
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ require_once '../../../config.php';
|
|||||||
require_once '../../../preset.php';
|
require_once '../../../preset.php';
|
||||||
define('UNAUTHENTICATED_ROUTE', true);
|
define('UNAUTHENTICATED_ROUTE', true);
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/otp_policy.php';
|
|
||||||
|
|
||||||
// ── Step 1: Load session state written by login_otp.php ───────────────────────
|
// ── Step 1: Load session state written by login_otp.php ───────────────────────
|
||||||
$data["username"] = $_SESSION["login_data"]['username'];
|
$data["username"] = $_SESSION["login_data"]['username'];
|
||||||
@@ -101,15 +100,9 @@ $_SESSION["diff"] = $otp_diff_minutes;
|
|||||||
|
|
||||||
// ── Step 4: Validate OTP value and expiry ─────────────────────────────────────
|
// ── Step 4: Validate OTP value and expiry ─────────────────────────────────────
|
||||||
// Skipped for staff/viewer roles — login_otp.php sets skip_otp=true in session
|
// 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 (empty($_SESSION['skip_otp'])) {
|
||||||
if (!otp_required()) {
|
if ($data["otp"] != $otp || $otp_diff_minutes > 5) {
|
||||||
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)";
|
$answer["message"] = "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)";
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ require_once '../../../config.php';
|
|||||||
require_once '../../../preset.php';
|
require_once '../../../preset.php';
|
||||||
define('UNAUTHENTICATED_ROUTE', true);
|
define('UNAUTHENTICATED_ROUTE', true);
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
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) ────────
|
// ── Step 1: Resolve user_id from username or email (case-insensitive) ────────
|
||||||
$sth = $pdo1->prepare("select user_id from user where ? in (username,email) ");
|
$sth = $pdo1->prepare("select user_id from user where ? in (username,email) ");
|
||||||
@@ -254,15 +253,11 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
|
|||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 5g: OTP policy, then role check — staff/viewer skip OTP entirely ─
|
// ── Step 5g: Role check — staff/viewer skip OTP entirely ─────────────────
|
||||||
// OTP_REQUIRED=false in config.php turns the email OTP off for everyone and
|
// Owners always require 2FA. Invited users (license='user') require 2FA only
|
||||||
// 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.
|
// if their role in this company is admin or owner; staff/viewer go straight in.
|
||||||
$requires_otp = otp_required();
|
$requires_otp = true;
|
||||||
if (!$requires_otp) {
|
if (($r['license'] ?? 'owner') !== 'owner') {
|
||||||
otp_log_bypass($user_id, 'login_otp');
|
|
||||||
} elseif (($r['license'] ?? 'owner') !== 'owner') {
|
|
||||||
$sth_role = $pdo1->prepare(
|
$sth_role = $pdo1->prepare(
|
||||||
"SELECT role FROM company_map_user WHERE company_id = :cid AND user_id = :uid LIMIT 1"
|
"SELECT role FROM company_map_user WHERE company_id = :cid AND user_id = :uid LIMIT 1"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,10 +19,8 @@
|
|||||||
* 2. CSRF check — rejects requests missing a valid X-CSRF-Token header.
|
* 2. CSRF check — rejects requests missing a valid X-CSRF-Token header.
|
||||||
* 3. Decode and sanitise input fields.
|
* 3. Decode and sanitise input fields.
|
||||||
* 4. Required field validation — company_name and channel_name must be non-empty.
|
* 4. Required field validation — company_name and channel_name must be non-empty.
|
||||||
* 5. SMTP validation — smtp_host, smtp_username, smtp_password must all be
|
* 5. Required SMTP validation — smtp_host, smtp_username, smtp_password
|
||||||
* provided while email OTP is on (company SMTP delivers the OTP). With
|
* must all be provided (company SMTP is mandatory for WMS email delivery).
|
||||||
* 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).
|
* 6. Normalise smtp_port to one of ['25', '465', '587'] (default: 587).
|
||||||
* Normalise smtp_encryption to one of ['tls', 'ssl', 'none'] (default: tls).
|
* 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).
|
* 7. Encrypt SMTP password with OpenSSL (same method/iv/key as rest of app).
|
||||||
@@ -54,7 +52,6 @@ require_once '../../../session.php';
|
|||||||
require_once '../../../config.php';
|
require_once '../../../config.php';
|
||||||
require_once '../../../dbconn.php';
|
require_once '../../../dbconn.php';
|
||||||
require_once '../../../assets/utils/db_helpers.php';
|
require_once '../../../assets/utils/db_helpers.php';
|
||||||
require_once '../../../assets/utils/otp_policy.php';
|
|
||||||
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
@@ -100,9 +97,9 @@ try {
|
|||||||
$company_name = trim($data['company_name'] ?? '');
|
$company_name = trim($data['company_name'] ?? '');
|
||||||
$company_name2 = trim($data['company_name2'] ?? '');
|
$company_name2 = trim($data['company_name2'] ?? '');
|
||||||
|
|
||||||
// channel_name is the URL slug / identifier — lowercase first, then strip
|
// channel_name is the URL slug / identifier — strip everything except
|
||||||
// everything except lowercase letters, digits, hyphens, and underscores.
|
// lowercase letters, digits, hyphens, and underscores.
|
||||||
$channel_name = preg_replace('/[^a-z0-9\-_]/', '', strtolower(trim($data['channel_name'] ?? '')));
|
$channel_name = strtolower(preg_replace('/[^a-z0-9\-_]/', '', $data['channel_name'] ?? ''));
|
||||||
|
|
||||||
$branch = trim($data['branch'] ?? 'สำนักงานใหญ่');
|
$branch = trim($data['branch'] ?? 'สำนักงานใหญ่');
|
||||||
$branch_no = trim($data['branch_no'] ?? '00000');
|
$branch_no = trim($data['branch_no'] ?? '00000');
|
||||||
@@ -117,26 +114,19 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 5: SMTP field validation ────────────────────────────────────────
|
// ── Step 5: SMTP field validation ────────────────────────────────────────
|
||||||
// While email OTP is on, SMTP is mandatory: the company needs it to send OTP
|
// SMTP is mandatory because the company needs to send OTP emails to users.
|
||||||
// emails, and an account without working SMTP could not complete 2FA login.
|
// An account without working SMTP would be unable to 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_host = trim($data['smtp_host'] ?? '');
|
||||||
$smtp_username = trim($data['smtp_username'] ?? '');
|
$smtp_username = trim($data['smtp_username'] ?? '');
|
||||||
$smtp_password = $data['smtp_password'] ?? '';
|
$smtp_password = $data['smtp_password'] ?? '';
|
||||||
$smtp_given = ($smtp_host !== '' || $smtp_username !== '' || $smtp_password !== '');
|
|
||||||
|
|
||||||
if ((otp_required() || $smtp_given) && (!$smtp_host || !$smtp_username || !$smtp_password)) {
|
if (!$smtp_host || !$smtp_username || !$smtp_password) {
|
||||||
$answer['message'] = otp_required()
|
$answer['message'] = 'SMTP configuration is required. Please fill in all SMTP fields.';
|
||||||
? '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);
|
http_response_code(422);
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($smtp_given) {
|
// ── Step 6: Normalise SMTP port and encryption ────────────────────────────
|
||||||
// ── Step 6: Normalise SMTP port and encryption ────────────────────────
|
|
||||||
// Clamp to known-good values to prevent storing unsupported configuration.
|
// Clamp to known-good values to prevent storing unsupported configuration.
|
||||||
$smtp_port = trim($data['smtp_port'] ?? '587');
|
$smtp_port = trim($data['smtp_port'] ?? '587');
|
||||||
$smtp_encryption = trim($data['smtp_encryption'] ?? 'tls');
|
$smtp_encryption = trim($data['smtp_encryption'] ?? 'tls');
|
||||||
@@ -144,7 +134,7 @@ try {
|
|||||||
if (!in_array($smtp_port, ['25', '465', '587'], true)) $smtp_port = '587';
|
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_encryption, ['tls', 'ssl', 'none'], true)) $smtp_encryption = 'tls';
|
||||||
|
|
||||||
// ── Step 7: Encrypt SMTP password ────────────────────────────────────
|
// ── Step 7: Encrypt SMTP password ────────────────────────────────────────
|
||||||
// Uses the same OpenSSL method/iv/key as the rest of the app (from config.php)
|
// 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.
|
// so the stored password can be decrypted by the mailer module.
|
||||||
$encrypted_pass = openssl_encrypt($smtp_password, $method, $pinkey, 0, $iv);
|
$encrypted_pass = openssl_encrypt($smtp_password, $method, $pinkey, 0, $iv);
|
||||||
@@ -160,7 +150,7 @@ try {
|
|||||||
'encryption' => $smtp_encryption,
|
'encryption' => $smtp_encryption,
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Step 8: Silent SMTP test — before any DB writes ──────────────────
|
// ── Step 8: Silent SMTP test — before any DB writes ──────────────────────
|
||||||
// Sends a test email to the onboarding user's registered address.
|
// Sends a test email to the onboarding user's registered address.
|
||||||
// If the mailer throws or exits, no DB records have been created yet,
|
// If the mailer throws or exits, no DB records have been created yet,
|
||||||
// so the user can correct their SMTP settings and retry cleanly.
|
// so the user can correct their SMTP settings and retry cleanly.
|
||||||
@@ -177,7 +167,6 @@ try {
|
|||||||
'key' => $pinkey,
|
'key' => $pinkey,
|
||||||
]);
|
]);
|
||||||
// If mailer fails, it calls exit() internally — nothing below this line runs.
|
// If mailer fails, it calls exit() internally — nothing below this line runs.
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step 9: Duplicate channel_name check ─────────────────────────────────
|
// ── Step 9: Duplicate channel_name check ─────────────────────────────────
|
||||||
// channel_name is the unique identifier used in URLs and API calls — must be globally unique.
|
// channel_name is the unique identifier used in URLs and API calls — must be globally unique.
|
||||||
@@ -237,9 +226,6 @@ try {
|
|||||||
// ── Step 13: Save company SMTP settings ──────────────────────────────────
|
// ── Step 13: Save company SMTP settings ──────────────────────────────────
|
||||||
// Stored with the encrypted password so the mailer module can decrypt and
|
// Stored with the encrypted password so the mailer module can decrypt and
|
||||||
// use it for all outgoing email from this company (OTP, notifications, etc.).
|
// use it for all outgoing email from this company (OTP, notifications, etc.).
|
||||||
// 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("
|
$sth = $pdo1->prepare("
|
||||||
INSERT INTO company_smtp
|
INSERT INTO company_smtp
|
||||||
(company_id, server, port, username, password,
|
(company_id, server, port, username, password,
|
||||||
@@ -259,7 +245,6 @@ try {
|
|||||||
':encryption' => $smtp_encryption,
|
':encryption' => $smtp_encryption,
|
||||||
]);
|
]);
|
||||||
db_check($sth, $answer);
|
db_check($sth, $answer);
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step 14: Clear onboarding session keys ───────────────────────────────
|
// ── Step 14: Clear onboarding session keys ───────────────────────────────
|
||||||
// These keys are no longer needed and should not persist into the
|
// These keys are no longer needed and should not persist into the
|
||||||
|
|||||||
+2
-18
@@ -1,7 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
require '../session.php';
|
require '../session.php';
|
||||||
require '../config.php';
|
require '../config.php';
|
||||||
require_once '../assets/utils/otp_policy.php';
|
|
||||||
require '../include_header.php';
|
require '../include_header.php';
|
||||||
// successful login — redirect based on app_access
|
// successful login — redirect based on app_access
|
||||||
if(!empty($_SESSION["login_status"])){
|
if(!empty($_SESSION["login_status"])){
|
||||||
@@ -33,13 +32,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="needs-validation mt-3" novalidate id="login-form">
|
<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] -->
|
<!-- first step login [OTP] -->
|
||||||
<?php if(!isset($_SESSION['login_data'])){?>
|
<?php if(!isset($_SESSION['login_data'])){?>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -59,7 +51,7 @@
|
|||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<!-- "Remember me" is intentionally excluded.
|
<!-- "Remember me" is intentionally excluded.
|
||||||
This login uses 2FA (OTP via email) on every session when OTP_REQUIRED=true in config.php (off by default).
|
This login uses 2FA (OTP via email) on every session.
|
||||||
A persistent login would bypass the OTP step and undermine the security model.
|
A persistent login would bypass the OTP step and undermine the security model.
|
||||||
Do not add this back. -->
|
Do not add this back. -->
|
||||||
</div>
|
</div>
|
||||||
@@ -70,7 +62,6 @@
|
|||||||
</p>
|
</p>
|
||||||
<?php }else{ ?>
|
<?php }else{ ?>
|
||||||
<!-- second step login -->
|
<!-- second step login -->
|
||||||
<?php if (otp_required()): ?>
|
|
||||||
<div class="alert alert-warning small py-2 mb-3">
|
<div class="alert alert-warning small py-2 mb-3">
|
||||||
<i class="ti ti-mail me-1"></i>
|
<i class="ti ti-mail me-1"></i>
|
||||||
OTP is sent via your company's SMTP setting.
|
OTP is sent via your company's SMTP setting.
|
||||||
@@ -82,20 +73,13 @@
|
|||||||
<span>One Time Password</span>
|
<span>One Time Password</span>
|
||||||
</label>
|
</label>
|
||||||
<input id="otp" type="otp" class="form-control"
|
<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 class="invalid-feedback">Please provide a otp (min 6 characters).</div>
|
||||||
</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">
|
<div class="mb-3">
|
||||||
<label for="password" class="form-label d-flex justify-content-between">
|
<label for="password" class="form-label d-flex justify-content-between">
|
||||||
<a href="javascript:;" class="small link-primary" onclick="back()">Back</a>
|
<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>
|
<a href="javascript:;" class="small link-primary" onclick="request_new_otp();">Request New OTP</a>
|
||||||
<?php endif; ?>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary w-100" onclick="login_confirm();">Sign in</button>
|
<button class="btn btn-primary w-100" onclick="login_confirm();">Sign in</button>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
require '../session.php';
|
require '../session.php';
|
||||||
require '../config.php';
|
require '../config.php';
|
||||||
require_once '../assets/utils/otp_policy.php';
|
|
||||||
|
|
||||||
// Must come from email verification
|
// Must come from email verification
|
||||||
if (empty($_SESSION['onboarding_user_id'])) {
|
if (empty($_SESSION['onboarding_user_id'])) {
|
||||||
@@ -49,8 +48,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Channel Name <span class="text-danger">*</span></label>
|
<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 class="form-text">Unique identifier. Lowercase, no spaces.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
@@ -79,20 +77,11 @@
|
|||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
<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>
|
<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>
|
<span class="badge bg-label-danger">Required</span>
|
||||||
<?php else: ?>
|
|
||||||
<span class="badge bg-label-secondary">Optional</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted small mb-3">
|
<p class="text-muted small mb-3">
|
||||||
<?php if (otp_required()): ?>
|
|
||||||
SMTP is required to send OTP during login.
|
SMTP is required to send OTP during login.
|
||||||
A verification email will be sent when you finish setup.
|
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>
|
</p>
|
||||||
|
|
||||||
<!-- SMTP User Guide (collapsible) -->
|
<!-- SMTP User Guide (collapsible) -->
|
||||||
@@ -185,11 +174,11 @@
|
|||||||
<!-- SMTP Form -->
|
<!-- SMTP Form -->
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<label class="form-label">SMTP Host <?php if (otp_required()): ?><span class="text-danger">*</span><?php endif; ?></label>
|
<label class="form-label">SMTP Host <span class="text-danger">*</span></label>
|
||||||
<input type="text" class="form-control" id="smtp_host" placeholder="e.g. smtp.gmail.com">
|
<input type="text" class="form-control" id="smtp_host" placeholder="e.g. smtp.gmail.com">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label">Port <?php if (otp_required()): ?><span class="text-danger">*</span><?php endif; ?></label>
|
<label class="form-label">Port <span class="text-danger">*</span></label>
|
||||||
<select class="form-select" id="smtp_port">
|
<select class="form-select" id="smtp_port">
|
||||||
<option value="587">587 — TLS</option>
|
<option value="587">587 — TLS</option>
|
||||||
<option value="465">465 — SSL</option>
|
<option value="465">465 — SSL</option>
|
||||||
@@ -197,11 +186,11 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Username / Email <?php if (otp_required()): ?><span class="text-danger">*</span><?php endif; ?></label>
|
<label class="form-label">Username / Email <span class="text-danger">*</span></label>
|
||||||
<input type="text" class="form-control" id="smtp_username" placeholder="your@email.com">
|
<input type="text" class="form-control" id="smtp_username" placeholder="your@email.com">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Password <?php if (otp_required()): ?><span class="text-danger">*</span><?php endif; ?></label>
|
<label class="form-label">Password <span class="text-danger">*</span></label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="password" class="form-control" id="smtp_password" placeholder="SMTP password">
|
<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">
|
<button class="btn btn-outline-secondary toggle-pw" type="button" data-target="smtp_password">
|
||||||
@@ -262,23 +251,13 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors api/engine/onboarding.php: SMTP is required while email OTP is on;
|
if (!$('#smtp_host').val().trim() || !$('#smtp_username').val().trim() || !$('#smtp_password').val()) {
|
||||||
// with OTP_REQUIRED=false it is optional, but the three fields go together.
|
bootbox.alert('SMTP host, username and password are required.');
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const $btn = $('#btn_finish');
|
const $btn = $('#btn_finish');
|
||||||
$btn.prop('disabled', true).html('<i class="ti ti-loader-2 me-1"></i>' + (smtp_given ? 'Verifying SMTP…' : 'Setting up…'));
|
$btn.prop('disabled', true).html('<i class="ti ti-loader-2 me-1"></i>Verifying SMTP…');
|
||||||
|
|
||||||
const encryption = $('input[name="smtp_encryption"]:checked').val();
|
const encryption = $('input[name="smtp_encryption"]:checked').val();
|
||||||
|
|
||||||
|
|||||||
@@ -250,7 +250,7 @@
|
|||||||
$('#display_contact').text(inv.contact_name || '—');
|
$('#display_contact').text(inv.contact_name || '—');
|
||||||
$('#display_department').text(get_dept_label(inv.department_id));
|
$('#display_department').text(get_dept_label(inv.department_id));
|
||||||
$('#display_issued').text(format_date(inv.issued_date) || '—');
|
$('#display_issued').text(format_date(inv.issued_date) || '—');
|
||||||
$('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : '');
|
$('#due_date').val(inv.due_date ? format_date(inv.due_date) : '');
|
||||||
$('#notes').val(inv.notes || '');
|
$('#notes').val(inv.notes || '');
|
||||||
|
|
||||||
var gl_type = inv.doc_type === 'credit_note' ? 'sales_credit_note' : 'sales_invoice';
|
var gl_type = inv.doc_type === 'credit_note' ? 'sales_credit_note' : 'sales_invoice';
|
||||||
|
|||||||
@@ -341,7 +341,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input type="number" class="form-control form-control-sm item_tax_rate"
|
<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()">
|
value="${parseFloat(item.tax_rate || 0).toFixed(2)}" min="0" max="100" step="0.01" oninput="recalc_totals()">
|
||||||
<input type="hidden" class="item_tax_amount" value="${item.tax_amount || 0}">
|
<input type="hidden" class="item_tax_amount" value="${item.tax_amount || 0}">
|
||||||
</td>
|
</td>
|
||||||
<td class="item_total text-end">${format_number((item.quantity || 1) * (item.unit_price || item.price || 0), 2)}</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
|
// Fields
|
||||||
$('#contact').val(o.contact_name || '');
|
$('#contact').val(o.contact_name || '');
|
||||||
$('#contact_id').val(o.contact_id);
|
$('#contact_id').val(o.contact_id);
|
||||||
$('#order_date').val(o.order_date ? format_date_input(o.order_date) : '');
|
$('#order_date').val(o.order_date ? format_date(o.order_date) : '');
|
||||||
$('#department_id').val(o.department_id || 0);
|
$('#department_id').val(o.department_id || 0);
|
||||||
$('#notes').val(o.notes || '');
|
$('#notes').val(o.notes || '');
|
||||||
$('#discount').val(o.discount || 0);
|
$('#discount').val(o.discount || 0);
|
||||||
@@ -712,7 +712,7 @@
|
|||||||
// ── Boot ─────────────────────────────────────────────────────────────────
|
// ── Boot ─────────────────────────────────────────────────────────────────
|
||||||
$(async function() {
|
$(async function() {
|
||||||
try {
|
try {
|
||||||
await Promise.resolve(load_departments('department_id')).catch(function() {});
|
load_departments('department_id');
|
||||||
await retrieve_warehouses();
|
await retrieve_warehouses();
|
||||||
|
|
||||||
if (order_id) {
|
if (order_id) {
|
||||||
|
|||||||
@@ -795,7 +795,7 @@
|
|||||||
$('#badge_status').html(return_status_badge(r.status));
|
$('#badge_status').html(return_status_badge(r.status));
|
||||||
$('#return_number_display').text(r.return_number);
|
$('#return_number_display').text(r.return_number);
|
||||||
|
|
||||||
$('#return_date').val(r.return_date ? format_date_input(r.return_date) : '');
|
$('#return_date').val(r.return_date ? format_date(r.return_date) : '');
|
||||||
$('#reason').val(r.reason || '');
|
$('#reason').val(r.reason || '');
|
||||||
$('#tax_adjustment').val(parseFloat(r.tax_adjustment || 0).toFixed(2));
|
$('#tax_adjustment').val(parseFloat(r.tax_adjustment || 0).toFixed(2));
|
||||||
$('#display_department').text(get_dept_label(r.department_id));
|
$('#display_department').text(get_dept_label(r.department_id));
|
||||||
|
|||||||
+2
-1
@@ -241,7 +241,8 @@
|
|||||||
var body = '';
|
var body = '';
|
||||||
|
|
||||||
$.each(page_data, function(i, o) {
|
$.each(page_data, function(i, o) {
|
||||||
var item_count = parseInt(o.item_count) || 0;
|
var items = JSON.parse(o.items || '[]');
|
||||||
|
var item_count = items.length;
|
||||||
var can_edit = parseInt(o.status) === 0;
|
var can_edit = parseInt(o.status) === 0;
|
||||||
var can_cancel = parseInt(o.status) >= 0 && parseInt(o.status) <= 1;
|
var can_cancel = parseInt(o.status) >= 0 && parseInt(o.status) <= 1;
|
||||||
|
|
||||||
|
|||||||
@@ -158,7 +158,7 @@
|
|||||||
var body = '';
|
var body = '';
|
||||||
|
|
||||||
$.each(page_data, function(i, r) {
|
$.each(page_data, function(i, r) {
|
||||||
var item_count = parseInt(r.item_count) || 0;
|
var items = JSON.parse(r.items || '[]');
|
||||||
var can_cancel = parseInt(r.status) >= 0 && parseInt(r.status) <= 1;
|
var can_cancel = parseInt(r.status) >= 0 && parseInt(r.status) <= 1;
|
||||||
|
|
||||||
body += `<tr>
|
body += `<tr>
|
||||||
@@ -167,7 +167,7 @@
|
|||||||
<td class="py-3">${r.contact_name || '<span class="text-muted">—</span>'}</td>
|
<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">${get_dept_label(r.department_id)}</td>
|
||||||
<td class="py-3">${format_date(r.return_date)}</td>
|
<td class="py-3">${format_date(r.return_date)}</td>
|
||||||
<td class="py-3">${item_count} item${item_count !== 1 ? 's' : ''}</td>
|
<td class="py-3">${items.length} item${items.length !== 1 ? 's' : ''}</td>
|
||||||
<td class="py-3 fw-semibold">${format_number(r.refund_amount, 2)}</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">${return_status_badge(r.status)}</td>
|
||||||
<td class="py-3">${receipt_status_badge(r.receipt_status)}</td>
|
<td class="py-3">${receipt_status_badge(r.receipt_status)}</td>
|
||||||
|
|||||||
@@ -326,7 +326,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input type="number" class="form-control form-control-sm item_tax_rate"
|
<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()">
|
value="${parseFloat(item.tax_rate || 0).toFixed(2)}" min="0" max="100" step="0.01" oninput="recalc_totals()">
|
||||||
<input type="hidden" class="item_tax_amount" value="${item.tax_amount || 0}">
|
<input type="hidden" class="item_tax_amount" value="${item.tax_amount || 0}">
|
||||||
</td>
|
</td>
|
||||||
<td class="item_total text-end">${format_number((item.quantity || 1) * (item.unit_price || 0), 2)}</td>
|
<td class="item_total text-end">${format_number((item.quantity || 1) * (item.unit_price || 0), 2)}</td>
|
||||||
@@ -682,8 +682,8 @@
|
|||||||
|
|
||||||
$('#contact').val(o.contact_name || '');
|
$('#contact').val(o.contact_name || '');
|
||||||
$('#contact_id').val(o.contact_id || '');
|
$('#contact_id').val(o.contact_id || '');
|
||||||
$('#po_date').val(o.po_date ? format_date_input(o.po_date) : '');
|
$('#po_date').val(o.po_date ? format_date(o.po_date) : '');
|
||||||
$('#expected_date').val(o.expected_date ? format_date_input(o.expected_date) : '');
|
$('#expected_date').val(o.expected_date ? format_date(o.expected_date) : '');
|
||||||
$('#warehouse_id').val(o.warehouse_id || '');
|
$('#warehouse_id').val(o.warehouse_id || '');
|
||||||
$('#department_id').val(o.department_id || 0);
|
$('#department_id').val(o.department_id || 0);
|
||||||
$('#notes').val(o.notes || '');
|
$('#notes').val(o.notes || '');
|
||||||
@@ -974,7 +974,7 @@
|
|||||||
// ── Boot ─────────────────────────────────────────────────────────────────
|
// ── Boot ─────────────────────────────────────────────────────────────────
|
||||||
$(async function() {
|
$(async function() {
|
||||||
try {
|
try {
|
||||||
await Promise.resolve(load_departments('department_id')).catch(function() {});
|
load_departments('department_id');
|
||||||
await load_location_config();
|
await load_location_config();
|
||||||
await retrieve_warehouses();
|
await retrieve_warehouses();
|
||||||
|
|
||||||
|
|||||||
@@ -226,7 +226,7 @@
|
|||||||
$('#display_contact').text(inv.contact_name || '—');
|
$('#display_contact').text(inv.contact_name || '—');
|
||||||
$('#display_department').text(get_dept_label(inv.department_id));
|
$('#display_department').text(get_dept_label(inv.department_id));
|
||||||
$('#display_issued').text(format_date(inv.issued_date) || '—');
|
$('#display_issued').text(format_date(inv.issued_date) || '—');
|
||||||
$('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : '');
|
$('#due_date').val(inv.due_date ? format_date(inv.due_date) : '');
|
||||||
$('#notes').val(inv.notes || '');
|
$('#notes').val(inv.notes || '');
|
||||||
|
|
||||||
// Source link
|
// Source link
|
||||||
|
|||||||
@@ -432,7 +432,7 @@
|
|||||||
$('#badge_status').html(return_status_badge(r.status));
|
$('#badge_status').html(return_status_badge(r.status));
|
||||||
$('#badge_fulfillment').html(fulfillment_status_badge(r.fulfillment_status));
|
$('#badge_fulfillment').html(fulfillment_status_badge(r.fulfillment_status));
|
||||||
$('#return_number_display').text(r.return_number);
|
$('#return_number_display').text(r.return_number);
|
||||||
$('#return_date').val(r.return_date ? format_date_input(r.return_date) : '');
|
$('#return_date').val(r.return_date ? format_date(r.return_date) : '');
|
||||||
$('#reason').val(r.reason || '');
|
$('#reason').val(r.reason || '');
|
||||||
$('#tax_adjustment').val(parseFloat(r.tax_adjustment || 0).toFixed(2));
|
$('#tax_adjustment').val(parseFloat(r.tax_adjustment || 0).toFixed(2));
|
||||||
$('#display_department').text(get_dept_label(r.department_id));
|
$('#display_department').text(get_dept_label(r.department_id));
|
||||||
|
|||||||
+2
-1
@@ -226,7 +226,8 @@
|
|||||||
var body = '';
|
var body = '';
|
||||||
|
|
||||||
$.each(page_data, function(i, o) {
|
$.each(page_data, function(i, o) {
|
||||||
var item_count = parseInt(o.item_count) || 0;
|
var items = JSON.parse(o.items || '[]');
|
||||||
|
var item_count = items.length;
|
||||||
var can_cancel = parseInt(o.status) >= -2 && parseInt(o.status) <= 1;
|
var can_cancel = parseInt(o.status) >= -2 && parseInt(o.status) <= 1;
|
||||||
|
|
||||||
body += `<tr>
|
body += `<tr>
|
||||||
|
|||||||
@@ -218,7 +218,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$.each(rows, function(i, r) {
|
$.each(rows, function(i, r) {
|
||||||
var item_count = parseInt(r.item_count) || 0;
|
var items = [];
|
||||||
|
try { items = JSON.parse(r.items || '[]'); } catch(e) {}
|
||||||
|
var item_count = items.length;
|
||||||
body += `<tr>
|
body += `<tr>
|
||||||
<td class="py-3 fw-semibold">${escape_html(r.return_number || '')}</td>
|
<td class="py-3 fw-semibold">${escape_html(r.return_number || '')}</td>
|
||||||
<td class="py-3">
|
<td class="py-3">
|
||||||
|
|||||||
@@ -88,7 +88,6 @@ foreach ($convert_items as $ci) {
|
|||||||
'unit_price' => $unit_price,
|
'unit_price' => $unit_price,
|
||||||
'total_price' => $total_price,
|
'total_price' => $total_price,
|
||||||
'tax_amount' => $tax_amount,
|
'tax_amount' => $tax_amount,
|
||||||
'tax_rate' => (float)($qi['tax_rate'] ?? 0),
|
|
||||||
'stock_out_id' => 0,
|
'stock_out_id' => 0,
|
||||||
];
|
];
|
||||||
$validated[] = ['item_id' => $item_id, 'quantity' => $qty];
|
$validated[] = ['item_id' => $item_id, 'quantity' => $qty];
|
||||||
|
|||||||
@@ -207,7 +207,7 @@
|
|||||||
$('#display_contact').html(display_text(inv.contact_name));
|
$('#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_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>');
|
$('#display_due').html(inv.due_date ? format_date(inv.due_date) : '<span class="text-muted">—</span>');
|
||||||
$('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : '');
|
$('#due_date').val(inv.due_date ? format_date(inv.due_date) : '');
|
||||||
$('#display_notes').html(inv.notes ? escape_html(inv.notes).replace(/\n/g, '<br>') : '<span class="text-muted">—</span>');
|
$('#display_notes').html(inv.notes ? escape_html(inv.notes).replace(/\n/g, '<br>') : '<span class="text-muted">—</span>');
|
||||||
|
|
||||||
var rows = '';
|
var rows = '';
|
||||||
|
|||||||
@@ -235,7 +235,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input type="number" class="form-control form-control-sm item_tax_rate"
|
<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()">
|
value="${parseFloat(item.tax_rate || 0).toFixed(2)}" min="0" max="100" step="0.01" oninput="recalc_totals()">
|
||||||
<input type="hidden" class="item_tax_amount" value="${item.tax_amount || 0}">
|
<input type="hidden" class="item_tax_amount" value="${item.tax_amount || 0}">
|
||||||
</td>
|
</td>
|
||||||
<td class="item_total text-end align-middle">
|
<td class="item_total text-end align-middle">
|
||||||
@@ -356,7 +356,7 @@
|
|||||||
$('#order_number_display').text(o.order_number);
|
$('#order_number_display').text(o.order_number);
|
||||||
$('#contact').val(o.contact_name || '');
|
$('#contact').val(o.contact_name || '');
|
||||||
$('#contact_id').val(o.contact_id || 0);
|
$('#contact_id').val(o.contact_id || 0);
|
||||||
$('#order_date').val(o.order_date ? format_date_input(o.order_date) : '');
|
$('#order_date').val(o.order_date ? format_date(o.order_date) : '');
|
||||||
$('#notes').val(o.notes || '');
|
$('#notes').val(o.notes || '');
|
||||||
$('#discount').val(o.discount || 0);
|
$('#discount').val(o.discount || 0);
|
||||||
$('#tax_adjustment').val(o.tax_adjustment || 0);
|
$('#tax_adjustment').val(o.tax_adjustment || 0);
|
||||||
|
|||||||
@@ -379,8 +379,8 @@
|
|||||||
|
|
||||||
$('#contact').val(q.contact_name || '');
|
$('#contact').val(q.contact_name || '');
|
||||||
$('#contact_id').val(q.contact_id || 0);
|
$('#contact_id').val(q.contact_id || 0);
|
||||||
$('#quotation_date').val(q.quotation_date ? format_date_input(q.quotation_date) : '');
|
$('#quotation_date').val(q.quotation_date ? format_date(q.quotation_date) : '');
|
||||||
$('#valid_until').val(q.valid_until ? format_date_input(q.valid_until) : '');
|
$('#valid_until').val(q.valid_until ? format_date(q.valid_until) : '');
|
||||||
$('#department_id').val(q.department_id || 0);
|
$('#department_id').val(q.department_id || 0);
|
||||||
$('#notes').val(q.notes || '');
|
$('#notes').val(q.notes || '');
|
||||||
$('#discount').val(parseFloat(q.discount) || 0);
|
$('#discount').val(parseFloat(q.discount) || 0);
|
||||||
@@ -515,7 +515,7 @@
|
|||||||
// ── Boot ──────────────────────────────────────────────────────────────────
|
// ── Boot ──────────────────────────────────────────────────────────────────
|
||||||
$(async function() {
|
$(async function() {
|
||||||
try {
|
try {
|
||||||
await Promise.resolve(load_departments('department_id')).catch(function() {});
|
load_departments('department_id');
|
||||||
if (quotation_id) {
|
if (quotation_id) {
|
||||||
await retrieve_quotation();
|
await retrieve_quotation();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -165,6 +165,12 @@
|
|||||||
return map[String(status)] || '<span class="badge bg-light text-dark">—</span>';
|
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() {
|
function retrieve_orders() {
|
||||||
return ajax_request({
|
return ajax_request({
|
||||||
url: '<?php echo $server_url?>order/api/engine/retrieve_order.php',
|
url: '<?php echo $server_url?>order/api/engine/retrieve_order.php',
|
||||||
@@ -220,7 +226,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$.each(rows, function(i, o) {
|
$.each(rows, function(i, o) {
|
||||||
var item_count = parseInt(o.item_count) || 0;
|
var item_count = order_items_count(o.items);
|
||||||
var source = String(o.source || '');
|
var source = String(o.source || '');
|
||||||
var source_display = source === 'quotation' && parseInt(o.source_id) > 0
|
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>`
|
? `<a href="<?php echo $server_url?>revenue/manage_quotation.php?id=${o.source_id}">Quotation #${o.source_id}</a>`
|
||||||
|
|||||||
@@ -212,14 +212,14 @@
|
|||||||
var body = '';
|
var body = '';
|
||||||
|
|
||||||
$.each(page_data, function(i, q) {
|
$.each(page_data, function(i, q) {
|
||||||
var item_count = parseInt(q.item_count) || 0;
|
var items = typeof q.items === 'string' ? JSON.parse(q.items || '[]') : (q.items || []);
|
||||||
body += `<tr>
|
body += `<tr>
|
||||||
<td class="py-3 fw-semibold">${escape_html(q.quotation_number)}</td>
|
<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">${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">${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">${escape_html(q.contact_name || '—')}</td>
|
||||||
<td class="py-3">${get_dept_label(q.department_id)}</td>
|
<td class="py-3">${get_dept_label(q.department_id)}</td>
|
||||||
<td class="py-3">${item_count} item${item_count !== 1 ? 's' : ''}</td>
|
<td class="py-3">${items.length} item${items.length !== 1 ? 's' : ''}</td>
|
||||||
<td class="py-3 fw-semibold">${format_number(q.grand_total, 2)}</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">${status_badge[String(q.status)] || q.status}</td>
|
||||||
<td class="py-3">
|
<td class="py-3">
|
||||||
|
|||||||
@@ -2,12 +2,6 @@
|
|||||||
// app/session.php
|
// app/session.php
|
||||||
ob_start(); // ensure output buffering is on regardless of php.ini — prevents stray output from corrupting JSON API responses
|
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) {
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
|
||||||
// Derive cookie path dynamically from the current script location.
|
// Derive cookie path dynamically from the current script location.
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
session_start();
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
require_once '../../../assets/utils/app_registry.php';
|
|
||||||
require_once '../../../assets/utils/classes/UserManager.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.'])); }
|
if ($user_role !== 'owner') { http_response_code(403); exit(json_encode(['success' => 0, 'message' => 'Only the owner can modify this setting.'])); }
|
||||||
@@ -31,11 +30,7 @@
|
|||||||
$result = $um->inviteUser($email, $role, $app_access);
|
$result = $um->inviteUser($email, $role, $app_access);
|
||||||
|
|
||||||
// Both new and existing users require explicit acceptance via email
|
// Both new and existing users require explicit acceptance via email
|
||||||
// Absolute URL: the link is opened from a mail client, where a bare
|
$invite_url = rtrim($server_url, '/') . ($result['new_user']
|
||||||
// /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/invited_onboarding.php?token=' . $result['token']
|
||||||
: '/login/accept_invite.php?token=' . $result['token']);
|
: '/login/accept_invite.php?token=' . $result['token']);
|
||||||
|
|
||||||
@@ -93,11 +88,7 @@
|
|||||||
$map_id = (int)($data['map_id'] ?? 0);
|
$map_id = (int)($data['map_id'] ?? 0);
|
||||||
$result = $um->resendInvite($map_id);
|
$result = $um->resendInvite($map_id);
|
||||||
|
|
||||||
// Absolute URL: the link is opened from a mail client, where a bare
|
$invite_url = rtrim($server_url, '/') . ($result['is_new_user']
|
||||||
// /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/invited_onboarding.php?token=' . $result['token']
|
||||||
: '/login/accept_invite.php?token=' . $result['token']);
|
: '/login/accept_invite.php?token=' . $result['token']);
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,7 @@
|
|||||||
* switch_branch.php — Switch the active company for the current session.
|
* switch_branch.php — Switch the active company for the current session.
|
||||||
*
|
*
|
||||||
* action: 'read' → return list of companies the user belongs to
|
* action: 'read' → return list of companies the user belongs to
|
||||||
* action: 'update' → switch to target_company_id
|
* action: 'update' → switch to the requested 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();
|
session_start();
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
@@ -24,7 +20,7 @@ if ($action === 'read') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($action === 'update') {
|
if ($action === 'update') {
|
||||||
$target_company_id = (int)($data['target_company_id'] ?? 0);
|
$target_company_id = (int)($data['company_id'] ?? 0);
|
||||||
|
|
||||||
if (!$target_company_id) {
|
if (!$target_company_id) {
|
||||||
$answer['message'] = 'Invalid company.';
|
$answer['message'] = 'Invalid company.';
|
||||||
|
|||||||
@@ -121,8 +121,7 @@
|
|||||||
|
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label class="form-label">Nickname / Channel Name</label>
|
<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 class="form-text">Unique identifier. Lowercase, no spaces.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3 mb-3">
|
<div class="col-md-3 mb-3">
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
session_start();
|
||||||
require '../config.php';
|
require '../config.php';
|
||||||
require_once '../assets/utils/app_registry.php';
|
|
||||||
require '../include_header.php';
|
require '../include_header.php';
|
||||||
?>
|
?>
|
||||||
|
|
||||||
@@ -257,7 +256,9 @@
|
|||||||
const license_badge = license_html(u.license);
|
const license_badge = license_html(u.license);
|
||||||
const access_badge = app_access_html(u.app_access);
|
const access_badge = app_access_html(u.app_access);
|
||||||
|
|
||||||
const joined = u.created_at ? format_date(String(u.created_at).split(' ')[0]) : '—';
|
const joined = u.created_at
|
||||||
|
? new Date(u.created_at).toLocaleDateString('en-GB', {day:'2-digit', month:'short', year:'numeric'})
|
||||||
|
: '—';
|
||||||
|
|
||||||
const is_pending = u.status === 'pending' && u.is_pending_invite == 1;
|
const is_pending = u.status === 'pending' && u.is_pending_invite == 1;
|
||||||
|
|
||||||
|
|||||||
+2
-4
@@ -532,11 +532,9 @@ foreach ($stockout_defs as $so) {
|
|||||||
$qty = $so['qty'];
|
$qty = $so['qty'];
|
||||||
$cost = $p['cost'];
|
$cost = $p['cost'];
|
||||||
$uom = $p['uom'];
|
$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 (
|
dbTransaction($pdo2, function ($pdo2) use (
|
||||||
$stockMgmt, $whMgmt, $company_id, $main_wh_id, $sku, $qty, $cost, $customer_id, $supplier_id,
|
$stockMgmt, $whMgmt, $company_id, $main_wh_id, $sku, $qty, $cost, $customer_id,
|
||||||
$logging, $dispatch_in_marker, $dispatch_out_marker
|
$logging, $dispatch_in_marker, $dispatch_out_marker
|
||||||
) {
|
) {
|
||||||
$bin = findFreeBin($pdo2, $company_id, $main_wh_id);
|
$bin = findFreeBin($pdo2, $company_id, $main_wh_id);
|
||||||
@@ -551,7 +549,7 @@ foreach ($stockout_defs as $so) {
|
|||||||
'zone' => $bin,
|
'zone' => $bin,
|
||||||
'aisle' => $bin,
|
'aisle' => $bin,
|
||||||
'bin' => $bin,
|
'bin' => $bin,
|
||||||
'contact_id' => $supplier_id,
|
'contact_id' => $customer_id,
|
||||||
'description' => $dispatch_in_marker,
|
'description' => $dispatch_in_marker,
|
||||||
], $logging, $in_uuid);
|
], $logging, $in_uuid);
|
||||||
$stockMgmt->approveStock($stock_id, $main_wh_id, 'in', $whMgmt);
|
$stockMgmt->approveStock($stock_id, $main_wh_id, 'in', $whMgmt);
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function buildLineItem(array $products, string $sku, float $qty): array {
|
|||||||
$tax_amount = round($total_price * $tax_rate / 100, 4);
|
$tax_amount = round($total_price * $tax_rate / 100, 4);
|
||||||
return [
|
return [
|
||||||
'product_sku' => $sku,
|
'product_sku' => $sku,
|
||||||
'product_name' => $p['product_name'],
|
'product_name' => $sku,
|
||||||
'quantity' => $qty,
|
'quantity' => $qty,
|
||||||
'unit_price' => $unit_price,
|
'unit_price' => $unit_price,
|
||||||
'total_price' => $total_price,
|
'total_price' => $total_price,
|
||||||
@@ -69,16 +69,11 @@ $sth->execute([':c' => $company_id]);
|
|||||||
$sales_dept_id = (int)$sth->fetchColumn();
|
$sales_dept_id = (int)$sth->fetchColumn();
|
||||||
if (!$sales_dept_id) { exit("ERROR: SALES department not found — run demo_seed_transactions.php first.\n"); }
|
if (!$sales_dept_id) { exit("ERROR: SALES department not found — run demo_seed_transactions.php first.\n"); }
|
||||||
|
|
||||||
$sth = $pdo2->prepare("SELECT sku, product_name, price FROM md_product WHERE company_id = :c");
|
$sth = $pdo2->prepare("SELECT sku, price FROM md_product WHERE company_id = :c");
|
||||||
$sth->execute([':c' => $company_id]);
|
$sth->execute([':c' => $company_id]);
|
||||||
$products = $sth->fetchAll(PDO::FETCH_ASSOC | PDO::FETCH_UNIQUE);
|
$products = $sth->fetchAll(PDO::FETCH_ASSOC | PDO::FETCH_UNIQUE);
|
||||||
|
|
||||||
// contact_type holds an md_contact_type id — resolve 'Customer' by name, not by a fixed id.
|
$sth = $pdo2->prepare("SELECT id, contact_name FROM md_contact WHERE company_id = :c AND contact_type = 1 ORDER BY 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]);
|
$sth->execute([':c' => $company_id]);
|
||||||
$customers = $sth->fetchAll(PDO::FETCH_ASSOC);
|
$customers = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||||
$customer_ids = array_column($customers, 'id');
|
$customer_ids = array_column($customers, 'id');
|
||||||
|
|||||||
+173
-46
@@ -5,16 +5,18 @@
|
|||||||
*
|
*
|
||||||
* Extends the base demo data (demo_seed.php: company, warehouses, products,
|
* Extends the base demo data (demo_seed.php: company, warehouses, products,
|
||||||
* contacts, opening stock) with transactional data covering the rest of the
|
* contacts, opening stock) with transactional data covering the rest of the
|
||||||
* app's features (restock / dispatch / transfer movements are seeded by demo_seed.php):
|
* app's features:
|
||||||
*
|
*
|
||||||
* 4. Chart of accounts, departments, GL posting formulas, product account mapping
|
* 1. Additional stock-in replenishment (restock events)
|
||||||
* 5. Sales cycle: quotation -> sales order -> invoice -> GL post
|
* 2. Stock-out (direct dispatch) + stock transfer (Main -> Bangna)
|
||||||
* 6. AR: receipt billing -> receipt -> GL post
|
* 3. Chart of accounts, departments, GL posting formulas
|
||||||
* 7. Purchasing cycle: purchase request -> PO -> receive -> purchase invoice -> GL post
|
* 4. Sales cycle: quotation -> sales order -> invoice -> GL post
|
||||||
* 8. AP: payment billing -> payment -> GL post
|
* 5. AR: receipt billing -> receipt -> GL post
|
||||||
* 9. Supplier return (confirmed, restocks reversed)
|
* 6. Purchasing cycle: purchase request -> PO -> receive -> purchase invoice -> GL post
|
||||||
* 10. Customer return (draft only — see note below)
|
* 7. AP: payment billing -> payment -> GL post
|
||||||
* 11. Barcode labels for a handful of products
|
* 8. Supplier return (confirmed, restocks reversed)
|
||||||
|
* 9. Customer return (draft only — see note below)
|
||||||
|
* 10. Barcode labels for a handful of products
|
||||||
*
|
*
|
||||||
* Every write goes through the same Manager classes + engine-file patterns
|
* Every write goes through the same Manager classes + engine-file patterns
|
||||||
* the app itself uses (dbTransaction wrapping, GlManager posting exactly as
|
* the app itself uses (dbTransaction wrapping, GlManager posting exactly as
|
||||||
@@ -44,7 +46,6 @@ require_once __DIR__ . '/app/dbconn.php';
|
|||||||
require_once __DIR__ . '/app/assets/utils/db_helpers.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/WarehouseManager.php';
|
||||||
require_once __DIR__ . '/app/assets/utils/classes/StockManager.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/QuotationManager.php';
|
||||||
require_once __DIR__ . '/app/assets/utils/classes/OrderManager.php';
|
require_once __DIR__ . '/app/assets/utils/classes/OrderManager.php';
|
||||||
require_once __DIR__ . '/app/assets/utils/classes/InvoiceManager.php';
|
require_once __DIR__ . '/app/assets/utils/classes/InvoiceManager.php';
|
||||||
@@ -88,29 +89,24 @@ $sth->execute();
|
|||||||
$owner_user_id = (int)($sth->fetchColumn() ?: 0);
|
$owner_user_id = (int)($sth->fetchColumn() ?: 0);
|
||||||
if (!$owner_user_id) exit("ERROR: demo owner user not found — run demo_seed.php first.\n");
|
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 = $pdo2->prepare("SELECT id, warehouse_name FROM md_warehouse WHERE company_id = :c ORDER BY id");
|
||||||
$sth->execute([':c' => $company_id]);
|
$sth->execute([':c' => $company_id]);
|
||||||
$warehouses = $sth->fetchAll(PDO::FETCH_KEY_PAIR); // id => name
|
$warehouses = $sth->fetchAll(PDO::FETCH_KEY_PAIR); // id => name
|
||||||
$main_wh = (int)(array_search('Main Warehouse', $warehouses, true) ?: 0);
|
if (count($warehouses) < 2) exit("ERROR: expected 2 warehouses — run demo_seed.php first.\n");
|
||||||
$bangna_wh = (int)(array_search('Bangna Distribution Center', $warehouses, true) ?: 0);
|
$wh_ids = array_keys($warehouses);
|
||||||
if (!$main_wh || !$bangna_wh) exit("ERROR: expected Main Warehouse and Bangna Distribution Center — run demo_seed.php first.\n");
|
$main_wh = $wh_ids[0]; // Main Warehouse
|
||||||
|
$bangna_wh = $wh_ids[1]; // Bangna Distribution Center
|
||||||
|
|
||||||
// md_contact.contact_type is an md_contact_type id, so match on the type name.
|
$sth = $pdo2->prepare("SELECT id, contact_name, contact_type FROM md_contact WHERE company_id = :c ORDER BY id");
|
||||||
$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]);
|
$sth->execute([':c' => $company_id]);
|
||||||
$contacts = $sth->fetchAll(PDO::FETCH_ASSOC);
|
$contacts = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||||
$customer_ids = array_values(array_column(array_filter($contacts, fn($c) => $c['type_name'] === 'Customer'), 'id'));
|
if (count($contacts) < 7) exit("ERROR: expected 7 contacts — run demo_seed.php first.\n");
|
||||||
$supplier_ids = array_values(array_column(array_filter($contacts, fn($c) => $c['type_name'] === 'Supplier'), 'id'));
|
$customer_ids = array_column(array_filter($contacts, fn($c) => (int)$c['contact_type'] === 1), 'id');
|
||||||
if (count($customer_ids) < 4 || count($supplier_ids) < 3) exit("ERROR: expected 4 customers and 3 suppliers — run demo_seed.php first.\n");
|
$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);
|
||||||
|
|
||||||
$sth = $pdo2->prepare("SELECT sku, product_name, uom, cost_price, price FROM md_product WHERE company_id = :c ORDER BY id");
|
$sth = $pdo2->prepare("SELECT sku, uom, cost_price, price FROM md_product WHERE company_id = :c ORDER BY id");
|
||||||
$sth->execute([':c' => $company_id]);
|
$sth->execute([':c' => $company_id]);
|
||||||
$products = $sth->fetchAll(PDO::FETCH_ASSOC | PDO::FETCH_UNIQUE);
|
$products = $sth->fetchAll(PDO::FETCH_ASSOC | PDO::FETCH_UNIQUE);
|
||||||
if (count($products) < 14) exit("ERROR: expected 14 products — run demo_seed.php first.\n");
|
if (count($products) < 14) exit("ERROR: expected 14 products — run demo_seed.php first.\n");
|
||||||
@@ -118,6 +114,156 @@ 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'];
|
$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);
|
$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
|
// 4. Chart of accounts + departments + GL posting formulas
|
||||||
@@ -233,25 +379,6 @@ foreach ($formula_defs as $doc_type => $def) {
|
|||||||
$formula_ids[$doc_type] = $id;
|
$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. */
|
/** 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 {
|
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";
|
require_once __DIR__ . "/app/assets/utils/classes_ac/posting/{$posting_class}.php";
|
||||||
@@ -282,7 +409,7 @@ function buildLineItem(array $products, string $sku, float $qty): array {
|
|||||||
$tax_amount = round($total_price * $tax_rate / 100, 4);
|
$tax_amount = round($total_price * $tax_rate / 100, 4);
|
||||||
return [
|
return [
|
||||||
'product_sku' => $sku,
|
'product_sku' => $sku,
|
||||||
'product_name' => $p['product_name'], // documents show the product name, as the UI's product search fills it
|
'product_name' => $sku, // demo_seed.php products keyed by SKU; name not needed for GL/report correctness
|
||||||
'quantity' => $qty,
|
'quantity' => $qty,
|
||||||
'unit_price' => $unit_price,
|
'unit_price' => $unit_price,
|
||||||
'total_price' => $total_price,
|
'total_price' => $total_price,
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ services:
|
|||||||
EMIT_SECRET: ${EMIT_SECRET}
|
EMIT_SECRET: ${EMIT_SECRET}
|
||||||
SMTP_USERNAME: ${SMTP_USERNAME}
|
SMTP_USERNAME: ${SMTP_USERNAME}
|
||||||
SMTP_PASSWORD: ${SMTP_PASSWORD}
|
SMTP_PASSWORD: ${SMTP_PASSWORD}
|
||||||
OTP_REQUIRED: ${OTP_REQUIRED:-false}
|
|
||||||
volumes:
|
volumes:
|
||||||
- .:/var/www/html/wms-app
|
- .:/var/www/html/wms-app
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ PUBLIC_HOST=$public_host
|
|||||||
EMIT_SECRET=$emit_secret
|
EMIT_SECRET=$emit_secret
|
||||||
SMTP_USERNAME=$smtp_user
|
SMTP_USERNAME=$smtp_user
|
||||||
SMTP_PASSWORD=$smtp_pass
|
SMTP_PASSWORD=$smtp_pass
|
||||||
OTP_REQUIRED=false
|
|
||||||
HTTP_PORT=$http_port
|
HTTP_PORT=$http_port
|
||||||
EOF
|
EOF
|
||||||
chmod 600 "$ENV_FILE"
|
chmod 600 "$ENV_FILE"
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ FROM php:8.3-apache
|
|||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libzip-dev libicu-dev libonig-dev default-mysql-client gettext-base \
|
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 \
|
&& docker-php-ext-install pdo_mysql mysqli mbstring gd zip intl sockets exif opcache \
|
||||||
&& a2enmod rewrite \
|
&& a2enmod rewrite \
|
||||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|||||||
@@ -33,22 +33,6 @@ if (!defined('NODE_EMIT_SECRET')) {
|
|||||||
define('NODE_EMIT_SECRET', '${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 ───────────────────────────────────────────────────────────
|
// ── Usage packages ───────────────────────────────────────────────────────────
|
||||||
$packages = [
|
$packages = [
|
||||||
'starter' => [
|
'starter' => [
|
||||||
|
|||||||
@@ -4,39 +4,15 @@ set -e
|
|||||||
APP_DIR=/var/www/html/wms-app
|
APP_DIR=/var/www/html/wms-app
|
||||||
CONFIG=$APP_DIR/app/config.php
|
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.
|
# Generate app/config.php from template on first run only.
|
||||||
# Restrict envsubst to known placeholders so it never touches the app's own
|
# Restrict envsubst to known placeholders so it never touches the app's own
|
||||||
# $variable syntax (envsubst blanks out any $NAME it doesn't recognize).
|
# $variable syntax (envsubst blanks out any $NAME it doesn't recognize).
|
||||||
if [ ! -f "$CONFIG" ]; then
|
if [ ! -f "$CONFIG" ]; then
|
||||||
echo "[entrypoint] generating app/config.php"
|
echo "[entrypoint] generating app/config.php"
|
||||||
envsubst '${DB_ROOT_PASSWORD} ${PUBLIC_HOST} ${EMIT_SECRET} ${SMTP_USERNAME} ${SMTP_PASSWORD} ${OTP_REQUIRED}' \
|
envsubst '${DB_ROOT_PASSWORD} ${PUBLIC_HOST} ${EMIT_SECRET} ${SMTP_USERNAME} ${SMTP_PASSWORD}' \
|
||||||
< /usr/local/etc/wms/config.php.template > "$CONFIG"
|
< /usr/local/etc/wms/config.php.template > "$CONFIG"
|
||||||
fi
|
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"
|
mkdir -p "$APP_DIR/app/uploads"
|
||||||
chown -R www-data:www-data "$APP_DIR/app/uploads"
|
chown -R www-data:www-data "$APP_DIR/app/uploads"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
2026-07-22T14:37:48: [2026-07-22T07:37:48.626Z] [PID: 505] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Wed Jul 22 2026 14:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-07-23T15:14:43: [2026-07-23T08:14:43.767Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 15:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-07-23T20:39:04: [2026-07-23T13:39:04.943Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 17:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-07-23T20:39:04: [2026-07-23T13:39:04.953Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 18:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-07-23T20:39:04: [2026-07-23T13:39:04.959Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 19:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-07-23T20:39:04: [2026-07-23T13:39:04.965Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 20:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-07-23T21:02:17: [2026-07-23T14:02:17.033Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 21:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-07-23T21:11:21: [2026-07-23T14:11:21.977Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 17:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-07-23T21:11:21: [2026-07-23T14:11:21.982Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 18:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-07-23T21:11:21: [2026-07-23T14:11:21.985Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 19:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-07-23T21:11:21: [2026-07-23T14:11:21.987Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 20:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-07-25T14:44:32: [2026-07-25T07:44:32.121Z] [PID: 476] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Sat Jul 25 2026 13:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-07-25T14:44:32: [2026-07-25T07:44:32.127Z] [PID: 476] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Sat Jul 25 2026 14:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-07-25T15:24:00: [2026-07-25T08:24:00.029Z] [PID: 476] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Sat Jul 25 2026 14:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-07-25T15:24:00: [2026-07-25T08:24:00.032Z] [PID: 476] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Sat Jul 25 2026 15:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-08-03T10:11:12: [2026-08-03T03:11:12.241Z] [PID: 477] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Mon Aug 03 2026 10:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-08-03T10:31:46: [2026-08-03T03:31:46.573Z] [PID: 477] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Mon Aug 03 2026 10:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-08-03T11:08:26: [2026-08-03T04:08:26.686Z] [PID: 477] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Mon Aug 03 2026 11:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-08-04T15:02:31: [2026-08-04T08:02:31.243Z] [PID: 78443] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Tue Aug 04 2026 15:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-08-04T15:32:15: [2026-08-04T08:32:15.561Z] [PID: 78443] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Tue Aug 04 2026 15:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-08-04T17:01:03: [2026-08-04T10:01:03.525Z] [PID: 78443] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Tue Aug 04 2026 17:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
2026-07-20T17:35:51: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
|
||||||
|
2026-07-20T21:00:00: [scheduler] etl_gl slot=21
|
||||||
|
2026-07-20T21:00:00: [scheduler] etl_gl slot=21 — no companies
|
||||||
|
2026-07-21T16:06:58: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
|
||||||
|
2026-07-22T13:08:58: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
|
||||||
|
2026-07-22T14:37:48: [2026-07-22T07:37:48.626Z] [PID: 505] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Wed Jul 22 2026 14:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-07-22T15:00:00: [scheduler] etl_gl slot=15
|
||||||
|
2026-07-22T15:00:00: [scheduler] etl_gl slot=15 — no companies
|
||||||
|
2026-07-22T15:30:00: [scheduler] etl_stock slot=15
|
||||||
|
2026-07-22T15:30:00: [scheduler] etl_stock slot=15 — no companies
|
||||||
|
2026-07-22T16:00:00: [scheduler] etl_gl slot=16
|
||||||
|
2026-07-22T16:00:00: [scheduler] etl_gl slot=16 — no companies
|
||||||
|
2026-07-23T12:11:51: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
|
||||||
|
2026-07-23T12:30:00: [scheduler] etl_stock slot=12
|
||||||
|
2026-07-23T12:30:00: [scheduler] etl_stock slot=12 — no companies
|
||||||
|
2026-07-23T13:36:04: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
|
||||||
|
2026-07-23T15:14:43: [2026-07-23T08:14:43.767Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 15:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-07-23T15:30:00: [scheduler] etl_stock slot=15
|
||||||
|
2026-07-23T15:30:00: [scheduler] etl_stock slot=15 — no companies
|
||||||
|
2026-07-23T16:00:00: [scheduler] etl_gl slot=16
|
||||||
|
2026-07-23T16:00:00: [scheduler] etl_gl slot=16 — no companies
|
||||||
|
2026-07-23T16:30:00: [scheduler] etl_stock slot=16
|
||||||
|
2026-07-23T16:30:00: [scheduler] etl_stock slot=16 — no companies
|
||||||
|
2026-07-23T20:39:04: [2026-07-23T13:39:04.943Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 17:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-07-23T20:39:04: [2026-07-23T13:39:04.953Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 18:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-07-23T20:39:04: [2026-07-23T13:39:04.959Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 19:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-07-23T20:39:04: [2026-07-23T13:39:04.965Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 20:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-07-23T21:02:17: [2026-07-23T14:02:17.033Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 21:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-07-23T21:11:21: [2026-07-23T14:11:21.977Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 17:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-07-23T21:11:21: [2026-07-23T14:11:21.982Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 18:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-07-23T21:11:21: [2026-07-23T14:11:21.985Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 19:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-07-23T21:11:21: [2026-07-23T14:11:21.987Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 20:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-07-23T21:30:00: [scheduler] etl_stock slot=21
|
||||||
|
2026-07-23T21:30:00: [scheduler] etl_stock slot=21 — no companies
|
||||||
|
2026-07-25T12:19:55: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
|
||||||
|
2026-07-25T12:30:00: [scheduler] etl_stock slot=12
|
||||||
|
2026-07-25T12:30:00: [scheduler] etl_stock slot=12 — no companies
|
||||||
|
2026-07-25T13:00:00: [scheduler] etl_gl slot=13
|
||||||
|
2026-07-25T13:00:00: [scheduler] etl_gl slot=13 — no companies
|
||||||
|
2026-07-25T14:44:32: [2026-07-25T07:44:32.121Z] [PID: 476] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Sat Jul 25 2026 13:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-07-25T14:44:32: [2026-07-25T07:44:32.127Z] [PID: 476] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Sat Jul 25 2026 14:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-07-25T15:24:00: [2026-07-25T08:24:00.029Z] [PID: 476] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Sat Jul 25 2026 14:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-07-25T15:24:00: [2026-07-25T08:24:00.032Z] [PID: 476] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Sat Jul 25 2026 15:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-08-03T08:52:15: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
|
||||||
|
2026-08-03T09:00:00: [scheduler] etl_gl slot=9
|
||||||
|
2026-08-03T09:00:00: [scheduler] alert_overdue_invoices running
|
||||||
|
2026-08-03T09:00:00: [scheduler] etl_gl slot=9 — no companies
|
||||||
|
2026-08-03T10:11:12: [2026-08-03T03:11:12.241Z] [PID: 477] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Mon Aug 03 2026 10:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-08-03T10:31:46: [2026-08-03T03:31:46.573Z] [PID: 477] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Mon Aug 03 2026 10:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-08-03T11:08:26: [2026-08-03T04:08:26.686Z] [PID: 477] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Mon Aug 03 2026 11:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-08-04T13:35:14: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
|
||||||
|
2026-08-04T15:02:31: [2026-08-04T08:02:31.243Z] [PID: 78443] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Tue Aug 04 2026 15:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-08-04T15:32:15: [2026-08-04T08:32:15.561Z] [PID: 78443] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Tue Aug 04 2026 15:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
|
2026-08-04T16:00:00: [scheduler] etl_gl slot=16
|
||||||
|
2026-08-04T16:00:00: [scheduler] etl_gl slot=16 — no companies
|
||||||
|
2026-08-04T16:30:00: [scheduler] etl_stock slot=16
|
||||||
|
2026-08-04T16:30:00: [scheduler] etl_stock slot=16 — no companies
|
||||||
|
2026-08-04T17:01:03: [2026-08-04T10:01:03.525Z] [PID: 78443] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Tue Aug 04 2026 17:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
2026-07-20T17:35:51: Node.js real-time server running on port 3000
|
||||||
|
2026-07-21T16:06:58: Node.js real-time server running on port 3000
|
||||||
|
2026-07-22T13:08:58: Node.js real-time server running on port 3000
|
||||||
|
2026-07-22T13:10:29: [connect] socket=0-LquhazY9eKKN7LAAAB company=1 user=1 role=owner
|
||||||
|
2026-07-22T13:15:09: [disconnect] socket=0-LquhazY9eKKN7LAAAB
|
||||||
|
2026-07-22T13:15:12: [connect] socket=P4rxlPGuHWMqT35XAAAD company=1 user=1 role=owner
|
||||||
|
2026-07-22T13:26:21: [disconnect] socket=P4rxlPGuHWMqT35XAAAD
|
||||||
|
2026-07-22T13:26:23: [connect] socket=EBxi3tj8fNMUmyoyAAAF company=1 user=1 role=owner
|
||||||
|
2026-07-22T13:30:24: [disconnect] socket=EBxi3tj8fNMUmyoyAAAF
|
||||||
|
2026-07-22T13:30:25: [connect] socket=SJtRh1L6usnHrWebAAAH company=1 user=1 role=owner
|
||||||
|
2026-07-22T13:31:59: [disconnect] socket=SJtRh1L6usnHrWebAAAH
|
||||||
|
2026-07-22T13:31:59: [connect] socket=xvRdZhqqg-soDWr7AAAJ company=1 user=1 role=owner
|
||||||
|
2026-07-22T13:41:17: [disconnect] socket=xvRdZhqqg-soDWr7AAAJ
|
||||||
|
2026-07-22T13:41:17: [connect] socket=QZkAkh2pHOGltp-0AAAL company=1 user=1 role=owner
|
||||||
|
2026-07-22T13:42:28: [disconnect] socket=QZkAkh2pHOGltp-0AAAL
|
||||||
|
2026-07-22T13:42:28: [connect] socket=0Vb5YTMHDs1gOC47AAAN company=1 user=1 role=owner
|
||||||
|
2026-07-22T13:42:36: [disconnect] socket=0Vb5YTMHDs1gOC47AAAN
|
||||||
|
2026-07-22T13:42:36: [connect] socket=idQO9l1OGLiXPyEoAAAP company=1 user=1 role=owner
|
||||||
|
2026-07-22T13:42:43: [disconnect] socket=idQO9l1OGLiXPyEoAAAP
|
||||||
|
2026-07-22T13:42:43: [connect] socket=nvfS_4EF_3kF5PGsAAAR company=1 user=1 role=owner
|
||||||
|
2026-07-22T13:47:25: [disconnect] socket=nvfS_4EF_3kF5PGsAAAR
|
||||||
|
2026-07-22T13:47:27: [connect] socket=yytX3fzh594f9phBAAAT company=1 user=1 role=owner
|
||||||
|
2026-07-22T13:50:19: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||||
|
2026-07-22T13:50:19: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||||
|
2026-07-22T13:50:19: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||||
|
2026-07-22T13:50:19: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||||
|
2026-07-22T13:50:19: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||||
|
2026-07-22T13:59:42: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||||
|
2026-07-22T13:59:42: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||||
|
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||||
|
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||||
|
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||||
|
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||||
|
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||||
|
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||||
|
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||||
|
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||||
|
2026-07-22T14:03:26: [disconnect] socket=yytX3fzh594f9phBAAAT
|
||||||
|
2026-07-22T14:03:28: [connect] socket=waEM4mo4V099RHhAAAAV company=1 user=1 role=owner
|
||||||
|
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||||
|
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||||
|
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||||
|
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||||
|
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||||
|
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||||
|
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||||
|
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||||
|
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||||
|
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||||
|
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
|
||||||
|
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
|
||||||
|
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
|
||||||
|
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
|
||||||
|
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
|
||||||
|
2026-07-22T14:11:10: [disconnect] socket=waEM4mo4V099RHhAAAAV
|
||||||
|
2026-07-22T14:35:14: [connect] socket=ZSmIs38dJh1u4If4AAAX company=1 user=1 role=owner
|
||||||
|
2026-07-22T14:35:49: [disconnect] socket=ZSmIs38dJh1u4If4AAAX
|
||||||
|
2026-07-22T14:35:49: [connect] socket=1bcHdQOC7cBTftxyAAAZ company=1 user=1 role=owner
|
||||||
|
2026-07-22T14:36:37: [disconnect] socket=1bcHdQOC7cBTftxyAAAZ
|
||||||
|
2026-07-22T14:36:38: [connect] socket=fMazKVGWKhaTm7OUAAAb company=1 user=1 role=owner
|
||||||
|
2026-07-22T14:36:46: [disconnect] socket=fMazKVGWKhaTm7OUAAAb
|
||||||
|
2026-07-22T14:36:46: [connect] socket=VfqVaEiOI3NTCA7fAAAd company=1 user=1 role=owner
|
||||||
|
2026-07-22T14:36:48: [disconnect] socket=VfqVaEiOI3NTCA7fAAAd
|
||||||
|
2026-07-22T14:36:48: [connect] socket=DAww8irzEgS7j7JLAAAf company=1 user=1 role=owner
|
||||||
|
2026-07-22T14:36:49: [disconnect] socket=DAww8irzEgS7j7JLAAAf
|
||||||
|
2026-07-22T14:36:49: [connect] socket=WgF3HArg1rthWkktAAAh company=1 user=1 role=owner
|
||||||
|
2026-07-22T14:37:05: [disconnect] socket=WgF3HArg1rthWkktAAAh
|
||||||
|
2026-07-22T14:37:05: [connect] socket=pfMLLNR0x-qoq485AAAj company=1 user=1 role=owner
|
||||||
|
2026-07-22T14:37:08: [disconnect] socket=pfMLLNR0x-qoq485AAAj
|
||||||
|
2026-07-22T14:37:09: [connect] socket=7ZtkCaJZqcYXBSZWAAAl company=1 user=1 role=owner
|
||||||
|
2026-07-22T14:37:14: [disconnect] socket=7ZtkCaJZqcYXBSZWAAAl
|
||||||
|
2026-07-22T14:37:14: [connect] socket=F6_OvPrmc-vv_KoqAAAn company=1 user=1 role=owner
|
||||||
|
2026-07-22T14:37:18: [disconnect] socket=F6_OvPrmc-vv_KoqAAAn
|
||||||
|
2026-07-22T14:37:18: [connect] socket=gdlZxPbkkcdkdE2AAAAp company=1 user=1 role=owner
|
||||||
|
2026-07-22T14:40:08: [disconnect] socket=gdlZxPbkkcdkdE2AAAAp
|
||||||
|
2026-07-22T14:40:09: [connect] socket=c8j8ybp-e7MPpa6cAAAr company=1 user=1 role=owner
|
||||||
|
2026-07-22T14:40:13: [disconnect] socket=c8j8ybp-e7MPpa6cAAAr
|
||||||
|
2026-07-22T14:40:13: [connect] socket=D36DmJgraENmU5xsAAAt company=1 user=1 role=owner
|
||||||
|
2026-07-22T14:40:20: [disconnect] socket=D36DmJgraENmU5xsAAAt
|
||||||
|
2026-07-22T14:40:21: [connect] socket=saY0q6gBioHWgq7RAAAv company=1 user=1 role=owner
|
||||||
|
2026-07-22T14:40:25: [disconnect] socket=saY0q6gBioHWgq7RAAAv
|
||||||
|
2026-07-22T14:40:25: [connect] socket=o9h4_9i-KOjDfVmrAAAx company=1 user=1 role=owner
|
||||||
|
2026-07-22T14:40:27: [disconnect] socket=o9h4_9i-KOjDfVmrAAAx
|
||||||
|
2026-07-22T14:40:27: [connect] socket=aIM89idl78lyhTbNAAAz company=1 user=1 role=owner
|
||||||
|
2026-07-22T14:56:05: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||||
|
2026-07-22T14:56:05: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||||
|
2026-07-22T14:56:06: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
|
||||||
|
2026-07-22T14:56:06: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
|
||||||
|
2026-07-22T15:21:05: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'transfer' }
|
||||||
|
2026-07-22T15:28:23: [disconnect] socket=aIM89idl78lyhTbNAAAz
|
||||||
|
2026-07-22T15:43:35: [connect] socket=q1N4BECKfiomOEfyAAA1 company=1 user=1 role=owner
|
||||||
|
2026-07-22T15:43:38: [disconnect] socket=q1N4BECKfiomOEfyAAA1
|
||||||
|
2026-07-22T15:43:38: [connect] socket=yM_0j72uX0V2b-GuAAA3 company=1 user=1 role=owner
|
||||||
|
2026-07-22T15:43:43: [disconnect] socket=yM_0j72uX0V2b-GuAAA3
|
||||||
|
2026-07-22T15:43:43: [connect] socket=dvzUKq0p7lXQMuSLAAA5 company=1 user=1 role=owner
|
||||||
|
2026-07-22T15:43:45: [disconnect] socket=dvzUKq0p7lXQMuSLAAA5
|
||||||
|
2026-07-22T15:43:45: [connect] socket=TYvNpQgcOHR5-V1RAAA7 company=1 user=1 role=owner
|
||||||
|
2026-07-22T15:43:46: [disconnect] socket=TYvNpQgcOHR5-V1RAAA7
|
||||||
|
2026-07-22T15:43:46: [connect] socket=waNTeqU5sAu8W2jqAAA9 company=1 user=1 role=owner
|
||||||
|
2026-07-22T16:05:01: [disconnect] socket=waNTeqU5sAu8W2jqAAA9
|
||||||
|
2026-07-23T12:11:51: Node.js real-time server running on port 3000
|
||||||
|
2026-07-23T12:13:27: [connect] socket=NYO9Tg4V6Cqp3e4cAAAB company=1 user=1 role=owner
|
||||||
|
2026-07-23T12:33:05: [disconnect] socket=NYO9Tg4V6Cqp3e4cAAAB
|
||||||
|
2026-07-23T12:33:06: [connect] socket=pEDe8Dms2dU7gdhCAAAD company=1 user=1 role=owner
|
||||||
|
2026-07-23T12:34:13: [disconnect] socket=pEDe8Dms2dU7gdhCAAAD
|
||||||
|
2026-07-23T12:34:17: [connect] socket=vaGUT12vPXkqH_7kAAAF company=1 user=1 role=owner
|
||||||
|
2026-07-23T12:55:49: [disconnect] socket=vaGUT12vPXkqH_7kAAAF
|
||||||
|
2026-07-23T13:36:04: Node.js real-time server running on port 3000
|
||||||
|
2026-07-23T13:36:55: [connect] socket=nVGY71aXPas0zYS_AAAB company=1 user=1 role=owner
|
||||||
|
2026-07-23T13:51:36: [disconnect] socket=nVGY71aXPas0zYS_AAAB
|
||||||
|
2026-07-23T13:51:36: [connect] socket=5peGIWbSaRCxnlYBAAAD company=1 user=1 role=owner
|
||||||
|
2026-07-23T13:51:38: [disconnect] socket=5peGIWbSaRCxnlYBAAAD
|
||||||
|
2026-07-23T13:51:38: [connect] socket=fGwRmZaYGzedlqqRAAAF company=1 user=1 role=owner
|
||||||
|
2026-07-23T13:51:40: [disconnect] socket=fGwRmZaYGzedlqqRAAAF
|
||||||
|
2026-07-23T13:51:40: [connect] socket=YZk7xLKHpmi29ykIAAAH company=1 user=1 role=owner
|
||||||
|
2026-07-23T13:51:41: [disconnect] socket=YZk7xLKHpmi29ykIAAAH
|
||||||
|
2026-07-23T13:51:41: [connect] socket=f078x01mtX2eGd2HAAAJ company=1 user=1 role=owner
|
||||||
|
2026-07-23T13:57:26: [disconnect] socket=f078x01mtX2eGd2HAAAJ
|
||||||
|
2026-07-23T13:57:28: [connect] socket=vToy_ZVIAk6w9099AAAL company=1 user=1 role=owner
|
||||||
|
2026-07-23T14:17:58: [disconnect] socket=vToy_ZVIAk6w9099AAAL
|
||||||
|
2026-07-23T14:18:00: [connect] socket=7yHLdkKxBAAO_nF4AAAN company=1 user=1 role=owner
|
||||||
|
2026-07-23T16:25:41: [disconnect] socket=7yHLdkKxBAAO_nF4AAAN
|
||||||
|
2026-07-25T12:19:55: Node.js real-time server running on port 3000
|
||||||
|
2026-08-03T08:52:15: Node.js real-time server running on port 3000
|
||||||
|
2026-08-03T10:17:41: [connect] socket=kNyFq-T_JVC3-_WLAAAB company=1 user=1 role=owner
|
||||||
|
2026-08-03T10:18:50: [disconnect] socket=kNyFq-T_JVC3-_WLAAAB
|
||||||
|
2026-08-03T10:18:50: [connect] socket=efcou9_hk0YUTnvQAAAD company=1 user=1 role=owner
|
||||||
|
2026-08-03T10:18:59: [disconnect] socket=efcou9_hk0YUTnvQAAAD
|
||||||
|
2026-08-03T10:18:59: [connect] socket=PmKuy_3CCIDze4P3AAAF company=1 user=1 role=owner
|
||||||
|
2026-08-03T10:32:14: [disconnect] socket=PmKuy_3CCIDze4P3AAAF
|
||||||
|
2026-08-03T10:32:18: [connect] socket=-ZlIPBBmpRazD30gAAAH company=1 user=1 role=owner
|
||||||
|
2026-08-03T10:49:23: [disconnect] socket=-ZlIPBBmpRazD30gAAAH
|
||||||
|
2026-08-03T10:49:26: [connect] socket=Azbj9ipTPqb3aOW3AAAJ company=1 user=1 role=owner
|
||||||
|
2026-08-03T10:54:19: [disconnect] socket=Azbj9ipTPqb3aOW3AAAJ
|
||||||
|
2026-08-03T10:54:19: [connect] socket=FMnx-fmSk96rxIfwAAAL company=1 user=1 role=owner
|
||||||
|
2026-08-03T11:13:57: [disconnect] socket=FMnx-fmSk96rxIfwAAAL
|
||||||
|
2026-08-03T11:13:59: [connect] socket=LgVxBoN_6JuJtxHyAAAN company=1 user=1 role=owner
|
||||||
|
2026-08-04T13:35:14: Node.js real-time server running on port 3000
|
||||||
|
2026-08-04T13:36:21: [connect] socket=BOvxSU23giBsnIXWAAAB company=1 user=1 role=owner
|
||||||
|
2026-08-04T13:36:25: [disconnect] socket=BOvxSU23giBsnIXWAAAB
|
||||||
|
2026-08-04T13:36:25: [connect] socket=pJXUXD7HNX_V9peAAAAD company=1 user=1 role=owner
|
||||||
|
2026-08-04T13:36:27: [disconnect] socket=pJXUXD7HNX_V9peAAAAD
|
||||||
|
2026-08-04T13:36:27: [connect] socket=St7RkiRumWpwc47xAAAF company=1 user=1 role=owner
|
||||||
|
2026-08-04T13:36:28: [disconnect] socket=St7RkiRumWpwc47xAAAF
|
||||||
|
2026-08-04T13:36:28: [connect] socket=a9NsNFmUpIL1vW8uAAAH company=1 user=1 role=owner
|
||||||
|
2026-08-04T13:40:28: [disconnect] socket=a9NsNFmUpIL1vW8uAAAH
|
||||||
|
2026-08-04T13:40:28: [connect] socket=uYLFddlhCkOxrcJNAAAJ company=1 user=1 role=owner
|
||||||
|
2026-08-04T13:48:10: [disconnect] socket=uYLFddlhCkOxrcJNAAAJ
|
||||||
|
2026-08-04T13:48:11: [connect] socket=VekC6UabiJABBbjhAAAL company=1 user=1 role=owner
|
||||||
|
2026-08-04T13:50:28: [disconnect] socket=VekC6UabiJABBbjhAAAL
|
||||||
|
2026-08-04T13:50:29: [connect] socket=gj-glld-ZsxWbGQuAAAN company=1 user=1 role=owner
|
||||||
|
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||||
|
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||||
|
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||||
|
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||||
|
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||||
|
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||||
|
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||||
|
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||||
|
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
|
||||||
|
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||||
|
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
|
||||||
|
2026-08-04T14:11:36: [disconnect] socket=gj-glld-ZsxWbGQuAAAN
|
||||||
|
2026-08-04T14:11:36: [connect] socket=v97BofsQmYBAEJMmAAAP company=1 user=1 role=owner
|
||||||
|
2026-08-04T14:13:54: [disconnect] socket=v97BofsQmYBAEJMmAAAP
|
||||||
|
2026-08-04T14:13:54: [connect] socket=WYJSdI9xVDaTML9xAAAR company=1 user=1 role=owner
|
||||||
|
2026-08-04T14:17:32: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||||
|
2026-08-04T14:17:32: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||||
|
2026-08-04T14:21:12: [disconnect] socket=WYJSdI9xVDaTML9xAAAR
|
||||||
|
2026-08-04T14:21:12: [connect] socket=UhIyCllsOjal4UwCAAAT company=1 user=1 role=owner
|
||||||
|
2026-08-04T14:21:13: [disconnect] socket=UhIyCllsOjal4UwCAAAT
|
||||||
|
2026-08-04T14:21:13: [connect] socket=6hZ-_fAyDgWzwsgvAAAV company=1 user=1 role=owner
|
||||||
|
2026-08-04T14:21:38: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||||
|
2026-08-04T14:21:38: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||||
|
2026-08-04T14:25:22: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||||
|
2026-08-04T14:25:22: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||||
|
2026-08-04T14:25:22: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||||
|
2026-08-04T14:25:22: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||||
|
2026-08-04T14:29:14: [disconnect] socket=6hZ-_fAyDgWzwsgvAAAV
|
||||||
|
2026-08-04T14:29:14: [connect] socket=7ocTMfufQlyO36XrAAAX company=1 user=1 role=owner
|
||||||
|
2026-08-04T14:29:19: [disconnect] socket=7ocTMfufQlyO36XrAAAX
|
||||||
|
2026-08-04T14:36:58: [connect] socket=kY4WNaECxPvGVj2JAAAZ company=1 user=1 role=owner
|
||||||
|
2026-08-04T14:37:12: [disconnect] socket=kY4WNaECxPvGVj2JAAAZ
|
||||||
|
2026-08-04T14:37:12: [connect] socket=OabEymZu5SehwNWnAAAb company=1 user=1 role=owner
|
||||||
|
2026-08-04T14:37:40: [disconnect] socket=OabEymZu5SehwNWnAAAb
|
||||||
|
2026-08-04T14:41:55: [connect] socket=kAlZOJl54kd8RXKAAAAd company=1 user=1 role=owner
|
||||||
|
2026-08-04T14:42:42: [disconnect] socket=kAlZOJl54kd8RXKAAAAd
|
||||||
|
2026-08-04T14:42:42: [connect] socket=DBSytTfd-o12DxhfAAAf company=1 user=1 role=owner
|
||||||
|
2026-08-04T14:49:38: [disconnect] socket=DBSytTfd-o12DxhfAAAf
|
||||||
|
2026-08-04T14:49:39: [connect] socket=sAr1qebAYGyIq8zrAAAh company=1 user=1 role=owner
|
||||||
|
2026-08-04T14:58:10: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||||
|
2026-08-04T14:58:10: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||||
|
2026-08-04T14:58:10: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||||
|
2026-08-04T14:58:10: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||||
|
2026-08-04T14:58:10: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
|
||||||
|
2026-08-04T14:59:17: [disconnect] socket=sAr1qebAYGyIq8zrAAAh
|
||||||
|
2026-08-04T14:59:17: [connect] socket=a264uVnqws4cIAy-AAAj company=1 user=1 role=owner
|
||||||
|
2026-08-04T15:21:19: [disconnect] socket=a264uVnqws4cIAy-AAAj
|
||||||
|
2026-08-04T15:21:19: [connect] socket=JL7kcUwnQI_NExMkAAAl company=1 user=1 role=owner
|
||||||
|
2026-08-04T15:21:22: [disconnect] socket=JL7kcUwnQI_NExMkAAAl
|
||||||
|
2026-08-04T15:27:09: [connect] socket=VcbOCpKEShqcqqM0AAAn company=1 user=1 role=owner
|
||||||
|
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||||
|
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||||
|
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||||
|
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||||
|
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||||
|
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||||
|
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||||
|
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||||
|
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||||
|
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||||
|
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
|
||||||
|
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||||
|
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
|
||||||
|
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||||
|
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
|
||||||
|
2026-08-04T15:30:09: [disconnect] socket=VcbOCpKEShqcqqM0AAAn
|
||||||
|
2026-08-04T15:30:09: [connect] socket=ecBAVshG1Eng4jh5AAAp company=1 user=1 role=owner
|
||||||
|
2026-08-04T15:33:32: [disconnect] socket=ecBAVshG1Eng4jh5AAAp
|
||||||
|
2026-08-04T15:33:32: [connect] socket=M43MyEQ7wipYOgd5AAAr company=1 user=1 role=owner
|
||||||
|
2026-08-04T15:34:32: [disconnect] socket=M43MyEQ7wipYOgd5AAAr
|
||||||
|
2026-08-04T15:34:32: [connect] socket=TiGDT6OMJsYlixlkAAAt company=1 user=1 role=owner
|
||||||
|
2026-08-04T15:35:36: [disconnect] socket=TiGDT6OMJsYlixlkAAAt
|
||||||
|
2026-08-04T15:35:36: [connect] socket=uHRGhZU0TJVvvh_aAAAv company=1 user=1 role=owner
|
||||||
|
2026-08-04T15:36:24: [disconnect] socket=uHRGhZU0TJVvvh_aAAAv
|
||||||
|
2026-08-04T15:36:24: [connect] socket=Hsui_73WGENhGdRIAAAx company=1 user=1 role=owner
|
||||||
|
2026-08-04T15:38:27: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||||
|
2026-08-04T15:38:27: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||||
|
2026-08-04T15:38:27: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||||
|
2026-08-04T15:38:27: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||||
|
2026-08-04T15:39:55: [disconnect] socket=Hsui_73WGENhGdRIAAAx
|
||||||
|
2026-08-04T15:39:55: [connect] socket=xSl0-9raxlwU2K9rAAAz company=1 user=1 role=owner
|
||||||
|
2026-08-04T15:52:53: [disconnect] socket=xSl0-9raxlwU2K9rAAAz
|
||||||
|
2026-08-04T15:52:53: [connect] socket=n_l9kHYTF1AXNNRYAAA1 company=1 user=1 role=owner
|
||||||
|
2026-08-04T15:58:37: [disconnect] socket=n_l9kHYTF1AXNNRYAAA1
|
||||||
|
2026-08-04T15:58:37: [connect] socket=T9mpzRMT3I1qjj0BAAA3 company=1 user=1 role=owner
|
||||||
|
2026-08-04T15:58:39: [disconnect] socket=T9mpzRMT3I1qjj0BAAA3
|
||||||
|
2026-08-04T15:58:39: [connect] socket=Q1jGtGwwFc49KKxDAAA5 company=1 user=1 role=owner
|
||||||
|
2026-08-04T15:58:40: [disconnect] socket=Q1jGtGwwFc49KKxDAAA5
|
||||||
|
2026-08-04T15:58:41: [connect] socket=Fk7l8SLr2IIRFFHbAAA7 company=1 user=1 role=owner
|
||||||
|
2026-08-04T15:58:42: [disconnect] socket=Fk7l8SLr2IIRFFHbAAA7
|
||||||
|
2026-08-04T15:58:42: [connect] socket=pAuTSmpDDC86EZwXAAA9 company=1 user=1 role=owner
|
||||||
|
2026-08-04T16:41:11: [disconnect] socket=pAuTSmpDDC86EZwXAAA9
|
||||||
|
2026-08-04T16:41:13: [connect] socket=XmFaoUIej-g8203TAAA_ company=1 user=1 role=owner
|
||||||
|
2026-08-04T16:41:25: [disconnect] socket=XmFaoUIej-g8203TAAA_
|
||||||
|
2026-08-04T16:41:28: [connect] socket=TxwzTsUHzqDLHpODAABB company=1 user=1 role=owner
|
||||||
|
2026-08-04T16:43:43: [disconnect] socket=TxwzTsUHzqDLHpODAABB
|
||||||
|
2026-08-04T16:44:26: [connect] socket=TOhs2YrBWQkiGDZDAABD company=1 user=1 role=owner
|
||||||
|
2026-08-04T16:58:05: [disconnect] socket=TOhs2YrBWQkiGDZDAABD
|
||||||
|
2026-08-04T16:59:15: [connect] socket=p1xNFoGJFKox8FaOAABF company=1 user=1 role=owner
|
||||||
|
2026-08-04T17:25:03: [disconnect] socket=p1xNFoGJFKox8FaOAABF
|
||||||
|
2026-08-04T17:25:03: [connect] socket=j3s6wvwe_BxVzCA_AABH company=1 user=1 role=owner
|
||||||
|
2026-08-04T17:25:05: [disconnect] socket=j3s6wvwe_BxVzCA_AABH
|
||||||
|
2026-08-04T17:25:05: [connect] socket=4wkwmOWCAvQSicL3AABJ company=1 user=1 role=owner
|
||||||
|
2026-08-04T17:26:40: [disconnect] socket=4wkwmOWCAvQSicL3AABJ
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
ROOT_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
|
|
||||||
TOOL_DIR="$SCRIPT_DIR/sdlc-delivery"
|
|
||||||
SOURCE_DIR="$ROOT_DIR/sdlc"
|
|
||||||
DELIVERY_ROOT="$ROOT_DIR/sdlc-delivery"
|
|
||||||
STAGING_DIR="$ROOT_DIR/.sdlc-delivery.staging"
|
|
||||||
|
|
||||||
if [[ ! -d "$TOOL_DIR/node_modules/playwright" ]]; then
|
|
||||||
echo "Installing the local PDF renderer..."
|
|
||||||
npm install --prefix "$TOOL_DIR"
|
|
||||||
npx --prefix "$TOOL_DIR" playwright install chromium
|
|
||||||
fi
|
|
||||||
|
|
||||||
rm -rf "$STAGING_DIR"
|
|
||||||
mkdir -p "$STAGING_DIR"
|
|
||||||
|
|
||||||
while IFS= read -r -d '' source; do
|
|
||||||
relative="${source#"$ROOT_DIR/sdlc/"}"
|
|
||||||
destination="$STAGING_DIR/${relative%.md}.pdf"
|
|
||||||
echo "Rendering ${relative%.md}.pdf"
|
|
||||||
node "$TOOL_DIR/render-sdlc.mjs" "$source" "$destination"
|
|
||||||
done < <(find "$SOURCE_DIR" -type f -name '*.md' -print0 | sort -z)
|
|
||||||
|
|
||||||
echo "Verifying staged delivery package..."
|
|
||||||
"$SCRIPT_DIR/verify-sdlc-delivery.sh" "$STAGING_DIR"
|
|
||||||
|
|
||||||
rm -rf "$DELIVERY_ROOT"
|
|
||||||
mv "$STAGING_DIR" "$DELIVERY_ROOT"
|
|
||||||
echo "Delivery and verification passed: $DELIVERY_ROOT"
|
|
||||||
Binary file not shown.
@@ -1,7 +0,0 @@
|
|||||||
<table class="document-control">
|
|
||||||
<tbody>
|
|
||||||
<tr><th>Document</th><td>{{documentType}}</td><th>Release</th><td>{{release}}</td></tr>
|
|
||||||
<tr><th>Project</th><td>{{projectName}}</td><th>Project code</th><td>{{projectCode}}</td></tr>
|
|
||||||
<tr><th>Project period</th><td colspan="3">{{projectPeriod}}</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<div style="border-top:1.5pt solid #1f2933; color:#52606d; display:flex; font-family:'BRN Thai',Arial,sans-serif; font-size:8pt; justify-content:space-between; margin:0 16mm; padding-top:2.5mm; width:calc(100% - 32mm);">
|
|
||||||
<span>ISO/IEC 29110-4-1:2018</span>
|
|
||||||
<span>{{projectCode}}</span>
|
|
||||||
</div>
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<header class="document-header">
|
|
||||||
<img class="document-header__logo" src="{{logoPath}}" alt="B.R.N. Enterprise Co., Ltd.">
|
|
||||||
<div class="document-header__company">
|
|
||||||
<p class="document-header__company-name">B.R.N. ENTERPRISE CO., LTD.</p>
|
|
||||||
<p class="document-header__address">1011 Supalai Grand Tower, 7th Floor, Unit 6-7, Rama 3 Road, Chongnonsi, Yannawa, Bangkok 10120<br>Tel. (+66) 2687 0250 Fax. (+66) 2687 0255</p>
|
|
||||||
</div>
|
|
||||||
<div class="document-header__title">{{documentTitle}}</div>
|
|
||||||
</header>
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>SDLC delivery theme preview</title>
|
|
||||||
<link rel="stylesheet" href="sdlc-delivery.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main class="delivery-document">
|
|
||||||
<header class="document-header">
|
|
||||||
<img class="document-header__logo" src="../../../app/assets/images/logo.png" alt="B.R.N. Enterprise Co., Ltd.">
|
|
||||||
<div class="document-header__company">
|
|
||||||
<p class="document-header__company-name">B.R.N. ENTERPRISE CO., LTD.</p>
|
|
||||||
<p class="document-header__address">1011 Supalai Grand Tower, 7th Floor, Unit 6-7, Rama 3 Road, Chongnonsi, Yannawa, Bangkok 10120<br>Tel. (+66) 2687 0250 Fax. (+66) 2687 0255</p>
|
|
||||||
</div>
|
|
||||||
<div class="document-header__title">Software Project Plan</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<table class="document-control">
|
|
||||||
<tbody>
|
|
||||||
<tr><th>Document</th><td>Software Project Plan</td><th>Release</th><td>05/01/26 V1.0 Final</td></tr>
|
|
||||||
<tr><th>Project</th><td>BRN WMS</td><th>Project code</th><td>200-WMS-26-001-00</td></tr>
|
|
||||||
<tr><th>Project period</th><td colspan="3">05/01/26–24/08/26</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h2>Project overview</h2>
|
|
||||||
<p>This preview demonstrates the shared A4 header, document-control table, headings, and standard data tables used by every generated delivery PDF.</p>
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Phase</th><th>Period</th><th>Primary output</th></tr></thead>
|
|
||||||
<tbody><tr><td>Planning</td><td>12/01/26–18/02/26</td><td>Project Plan</td></tr></tbody>
|
|
||||||
</table>
|
|
||||||
</main>
|
|
||||||
<footer class="document-footer"><span>ISO/IEC 29110-4-1:2018</span><span>200-WMS-26-001-00 · 1 / 1</span></footer>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
@page {
|
|
||||||
size: A4;
|
|
||||||
margin: 18mm 16mm 28mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--brn-blue: #0789c9;
|
|
||||||
--brn-red: #bd0e2d;
|
|
||||||
--brn-ink: #1f2933;
|
|
||||||
--brn-muted: #52606d;
|
|
||||||
--brn-line: #aab7c4;
|
|
||||||
--brn-pale-blue: #eaf6fc;
|
|
||||||
}
|
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
|
|
||||||
html, body {
|
|
||||||
color: var(--brn-ink);
|
|
||||||
font-family: "BRN Thai", Arial, sans-serif;
|
|
||||||
font-size: 10.5pt;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
body { margin: 0; }
|
|
||||||
|
|
||||||
.delivery-document { width: 100%; }
|
|
||||||
|
|
||||||
.document-header {
|
|
||||||
align-items: center;
|
|
||||||
border-bottom: 1.5pt solid var(--brn-blue);
|
|
||||||
display: flex;
|
|
||||||
gap: 9mm;
|
|
||||||
margin: 0 0 7mm;
|
|
||||||
min-height: 22mm;
|
|
||||||
padding: 0 0 3mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-header__logo {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
height: 16mm;
|
|
||||||
object-fit: contain;
|
|
||||||
width: 16mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-header__company { flex: 1; min-width: 0; }
|
|
||||||
|
|
||||||
.document-header__company-name {
|
|
||||||
font-size: 13pt;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: .03em;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-header__address {
|
|
||||||
color: var(--brn-muted);
|
|
||||||
font-size: 7.5pt;
|
|
||||||
line-height: 1.35;
|
|
||||||
margin: 1mm 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-header__title {
|
|
||||||
background: var(--brn-blue);
|
|
||||||
color: #fff;
|
|
||||||
font-size: 15pt;
|
|
||||||
font-weight: 500;
|
|
||||||
flex: 0 1 72mm;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
min-width: 52mm;
|
|
||||||
padding: 4mm 6mm;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-control {
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin: 0 0 7mm;
|
|
||||||
page-break-inside: avoid;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-control th,
|
|
||||||
.document-control td {
|
|
||||||
border: .5pt solid var(--brn-line);
|
|
||||||
padding: 2.2mm 3mm;
|
|
||||||
text-align: left;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-control th {
|
|
||||||
background: var(--brn-pale-blue);
|
|
||||||
font-weight: 700;
|
|
||||||
width: 26%;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, h2, h3 { break-after: avoid; color: var(--brn-ink); }
|
|
||||||
h1 { font-size: 18pt; margin: 0 0 5mm; }
|
|
||||||
h2 { border-left: 4pt solid var(--brn-blue); font-size: 14pt; margin: 9mm 0 4mm; padding-left: 3mm; }
|
|
||||||
h3 { color: var(--brn-blue); font-size: 11.5pt; margin: 6mm 0 3mm; }
|
|
||||||
|
|
||||||
p { margin: 0 0 3.5mm; }
|
|
||||||
|
|
||||||
table:not(.document-control) {
|
|
||||||
border-collapse: collapse;
|
|
||||||
border: .75pt solid #718096;
|
|
||||||
font-size: 9pt;
|
|
||||||
margin: 0 0 5mm;
|
|
||||||
table-layout: fixed;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
table:not(.document-control) th,
|
|
||||||
table:not(.document-control) td {
|
|
||||||
border: .5pt solid var(--brn-line);
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
padding: 2mm;
|
|
||||||
vertical-align: top;
|
|
||||||
word-break: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
table:not(.document-control) th { background: var(--brn-pale-blue); font-weight: 700; }
|
|
||||||
thead { display: table-header-group; }
|
|
||||||
tr { break-inside: avoid; }
|
|
||||||
|
|
||||||
.approval-block { break-inside: avoid; margin-top: 9mm; }
|
|
||||||
.approval-block__person { margin: 5mm 0; min-height: 21mm; }
|
|
||||||
.approval-block__line { border-bottom: .5pt solid var(--brn-ink); display: inline-block; min-width: 70mm; }
|
|
||||||
.approval-fields {
|
|
||||||
line-height: 1.62;
|
|
||||||
margin: 0 0 8mm;
|
|
||||||
padding: 2.5mm 0 4mm;
|
|
||||||
}
|
|
||||||
Generated
-54
@@ -1,54 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "brn-wms-sdlc-delivery",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "brn-wms-sdlc-delivery",
|
|
||||||
"dependencies": {
|
|
||||||
"playwright": "1.50.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fsevents": {
|
|
||||||
"version": "2.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
|
||||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/playwright": {
|
|
||||||
"version": "1.50.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.50.0.tgz",
|
|
||||||
"integrity": "sha512-+GinGfGTrd2IfX1TA4N2gNmeIksSb+IAe589ZH+FlmpV3MYTx6+buChGIuDLQwrGNCw2lWibqV50fU510N7S+w==",
|
|
||||||
"dependencies": {
|
|
||||||
"playwright-core": "1.50.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"playwright": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"fsevents": "2.3.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/playwright-core": {
|
|
||||||
"version": "1.50.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.50.0.tgz",
|
|
||||||
"integrity": "sha512-CXkSSlr4JaZs2tZHI40DsZUN/NIwgaUPsyLuOAaIZp2CyF2sN5MM5NJsyB188lFSSozFxQ5fPT4qM+f0tH/6wQ==",
|
|
||||||
"bin": {
|
|
||||||
"playwright-core": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "brn-wms-sdlc-delivery",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"dependencies": {
|
|
||||||
"playwright": "1.50.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
import fs from 'node:fs/promises';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { chromium } from 'playwright';
|
|
||||||
|
|
||||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const root = path.resolve(here, '../..');
|
|
||||||
const assets = path.join(here, 'assets');
|
|
||||||
|
|
||||||
const escapeHtml = (value) => value
|
|
||||||
.replaceAll('&', '&')
|
|
||||||
.replaceAll('<', '<')
|
|
||||||
.replaceAll('>', '>');
|
|
||||||
|
|
||||||
const renderInline = (value) => escapeHtml(value)
|
|
||||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
||||||
.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
||||||
|
|
||||||
const applyTemplate = (template, values) => template.replace(/{{(\w+)}}/g, (_, key) => values[key] ?? '');
|
|
||||||
|
|
||||||
function isTableLine(line) {
|
|
||||||
return /^\|.*\|\s*$/.test(line.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
function tableCells(line) {
|
|
||||||
return line.trim().slice(1, -1).split('|').map((cell) => cell.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSeparator(cells) {
|
|
||||||
return cells.every((cell) => /^:?-{3,}:?$/.test(cell));
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTable(lines, className = '') {
|
|
||||||
const rows = lines.map(tableCells);
|
|
||||||
const header = rows[0];
|
|
||||||
const data = isSeparator(rows[1] ?? []) ? rows.slice(2) : rows.slice(1);
|
|
||||||
const cells = (tag, row) => row.map((cell) => `<${tag}>${renderInline(cell)}</${tag}>`).join('');
|
|
||||||
return `<table${className ? ` class="${className}"` : ''}><thead><tr>${cells('th', header)}</tr></thead><tbody>${data.map((row) => `<tr>${cells('td', row)}</tr>`).join('')}</tbody></table>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderParagraph(lines) {
|
|
||||||
return lines.map((line, index) => {
|
|
||||||
const trimmed = line.trimEnd();
|
|
||||||
const hasHardBreak = /\s{2,}$/.test(line);
|
|
||||||
const isApprovalField = /^(Name|Role|Signature|Date|Position|Company|Project roles|Decision):/.test(trimmed);
|
|
||||||
const separator = hasHardBreak || isApprovalField ? '<br>' : (index < lines.length - 1 ? ' ' : '');
|
|
||||||
return `${renderInline(trimmed)}${separator}`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function markdownToHtml(markdown) {
|
|
||||||
const lines = markdown.replaceAll('\r\n', '\n').split('\n');
|
|
||||||
const output = [];
|
|
||||||
let index = 0;
|
|
||||||
|
|
||||||
while (index < lines.length) {
|
|
||||||
const line = lines[index];
|
|
||||||
if (!line.trim()) { index += 1; continue; }
|
|
||||||
|
|
||||||
if (isTableLine(line)) {
|
|
||||||
const table = [];
|
|
||||||
while (index < lines.length && isTableLine(lines[index])) table.push(lines[index++]);
|
|
||||||
output.push(renderTable(table));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const heading = line.match(/^(#{1,3})\s+(.+)$/);
|
|
||||||
if (heading) {
|
|
||||||
const level = heading[1].length;
|
|
||||||
output.push(`<h${level}>${renderInline(heading[2])}</h${level}>`);
|
|
||||||
index += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/^[-*]\s+/.test(line)) {
|
|
||||||
const items = [];
|
|
||||||
while (index < lines.length && /^[-*]\s+/.test(lines[index])) items.push(`<li>${renderInline(lines[index++].replace(/^[-*]\s+/, ''))}</li>`);
|
|
||||||
output.push(`<ul>${items.join('')}</ul>`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const paragraph = [];
|
|
||||||
while (index < lines.length && lines[index].trim() && !isTableLine(lines[index]) && !/^(#{1,3})\s+/.test(lines[index]) && !/^[-*]\s+/.test(lines[index])) paragraph.push(lines[index++]);
|
|
||||||
const approvalClass = paragraph.some((line) => /^(Name|Role|Signature|Date|Position|Company|Project roles|Decision):/.test(line.trimEnd())) ? ' class="approval-fields"' : '';
|
|
||||||
output.push(`<p${approvalClass}>${renderParagraph(paragraph)}</p>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return output.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractDocument(markdown) {
|
|
||||||
const titleMatch = markdown.match(/^#\s+(.+)\n+/m);
|
|
||||||
const title = titleMatch?.[1] ?? 'BRN WMS';
|
|
||||||
const afterTitle = markdown.slice((titleMatch?.index ?? 0) + (titleMatch?.[0].length ?? 0));
|
|
||||||
const lines = afterTitle.split('\n');
|
|
||||||
const metadata = {};
|
|
||||||
let bodyStart = 0;
|
|
||||||
|
|
||||||
for (let index = 0; index < lines.length; index += 1) {
|
|
||||||
if (!isTableLine(lines[index])) continue;
|
|
||||||
|
|
||||||
const table = [];
|
|
||||||
while (index < lines.length && isTableLine(lines[index])) table.push(lines[index++]);
|
|
||||||
const candidate = {};
|
|
||||||
const dataStart = isSeparator(tableCells(table[1] ?? '')) ? 2 : 1;
|
|
||||||
for (const row of table.slice(dataStart).map(tableCells)) {
|
|
||||||
if (row.length >= 2) candidate[row[0]] = row[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (candidate.Document && candidate.Project) {
|
|
||||||
Object.assign(metadata, candidate);
|
|
||||||
bodyStart = index;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
index -= 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { title, metadata, body: lines.slice(bodyStart).join('\n') };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const [source, destination] = process.argv.slice(2);
|
|
||||||
if (!source || !destination) throw new Error('Usage: render-sdlc.mjs SOURCE.md DESTINATION.pdf');
|
|
||||||
|
|
||||||
const [markdown, css, headerTemplate, footerTemplate, logo, thaiFont] = await Promise.all([
|
|
||||||
fs.readFile(source, 'utf8'),
|
|
||||||
fs.readFile(path.join(assets, 'sdlc-delivery.css'), 'utf8'),
|
|
||||||
fs.readFile(path.join(assets, 'header.html'), 'utf8'),
|
|
||||||
fs.readFile(path.join(assets, 'footer.html'), 'utf8'),
|
|
||||||
fs.readFile(path.join(root, 'app/assets/images/logo.svg')),
|
|
||||||
fs.readFile(path.join(assets, 'LeelawadeeUI.ttf'))
|
|
||||||
]);
|
|
||||||
const { title, metadata, body } = extractDocument(markdown);
|
|
||||||
const hasWideTable = markdown.split('\n').some((line) => isTableLine(line) && tableCells(line).length >= 8);
|
|
||||||
const isLandscape = metadata.Document === 'Work Schedule' || title === 'Work Schedule' || hasWideTable;
|
|
||||||
const values = {
|
|
||||||
logoPath: `data:image/svg+xml;base64,${logo.toString('base64')}`,
|
|
||||||
documentTitle: title,
|
|
||||||
documentType: metadata.Document ?? title,
|
|
||||||
release: metadata.Release ?? '',
|
|
||||||
projectName: metadata.Project ?? 'BRN WMS',
|
|
||||||
projectCode: metadata['Project code'] ?? '200-WMS-26-001-00',
|
|
||||||
projectPeriod: metadata['Project period'] ?? '05/01/26–24/08/26',
|
|
||||||
pageNumber: '<span class="pageNumber"></span>',
|
|
||||||
totalPages: '<span class="totalPages"></span>'
|
|
||||||
};
|
|
||||||
const control = await fs.readFile(path.join(assets, 'document-control.html'), 'utf8');
|
|
||||||
const pdfFooter = applyTemplate(footerTemplate, values);
|
|
||||||
const fontFace = `@font-face { font-family: "BRN Thai"; src: url(data:font/ttf;base64,${thaiFont.toString('base64')}) format("truetype"); font-weight: 400 700; }`;
|
|
||||||
const pageLayout = isLandscape
|
|
||||||
? '@page { size: A4 landscape; margin: 18mm 16mm 28mm; }'
|
|
||||||
: '';
|
|
||||||
const html = `<!doctype html><html><head><meta charset="utf-8"><style>${fontFace}${css}${pageLayout}</style></head><body><main class="delivery-document">${applyTemplate(headerTemplate, values)}${applyTemplate(control, values)}${markdownToHtml(body)}</main></body></html>`;
|
|
||||||
|
|
||||||
await fs.mkdir(path.dirname(destination), { recursive: true });
|
|
||||||
const browser = await chromium.launch({ headless: true });
|
|
||||||
try {
|
|
||||||
const page = await browser.newPage();
|
|
||||||
await page.setContent(html, { waitUntil: 'networkidle' });
|
|
||||||
await page.pdf({
|
|
||||||
path: destination,
|
|
||||||
format: 'A4',
|
|
||||||
printBackground: true,
|
|
||||||
preferCSSPageSize: true,
|
|
||||||
displayHeaderFooter: true,
|
|
||||||
headerTemplate: '<div></div>',
|
|
||||||
footerTemplate: pdfFooter
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((error) => { console.error(error); process.exit(1); });
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { execFile } from 'node:child_process';
|
|
||||||
import fs from 'node:fs/promises';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { promisify } from 'node:util';
|
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
|
||||||
|
|
||||||
function isTableLine(line) {
|
|
||||||
return /^\|.*\|\s*$/.test(line.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
function tableCells(line) {
|
|
||||||
return line.trim().slice(1, -1).split('|').map((cell) => cell.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSeparator(cells) {
|
|
||||||
return cells.every((cell) => /^:?-{3,}:?$/.test(cell));
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractDocumentContent(markdown) {
|
|
||||||
const titleMatch = markdown.match(/^#\s+.+\n+/m);
|
|
||||||
const afterTitle = markdown.slice((titleMatch?.index ?? 0) + (titleMatch?.[0].length ?? 0));
|
|
||||||
const lines = afterTitle.split('\n');
|
|
||||||
|
|
||||||
for (let index = 0; index < lines.length; index += 1) {
|
|
||||||
if (!isTableLine(lines[index])) continue;
|
|
||||||
const table = [];
|
|
||||||
while (index < lines.length && isTableLine(lines[index])) table.push(lines[index++]);
|
|
||||||
const dataStart = isSeparator(tableCells(table[1] ?? '')) ? 2 : 1;
|
|
||||||
const fields = Object.fromEntries(table.slice(dataStart).map(tableCells).filter((row) => row.length >= 2));
|
|
||||||
if (fields.Document && fields.Project) return { body: lines.slice(index).join('\n'), fields };
|
|
||||||
index -= 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { body: afterTitle, fields: {} };
|
|
||||||
}
|
|
||||||
|
|
||||||
function words(text) {
|
|
||||||
return new Set((text.normalize('NFC').toLocaleLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []));
|
|
||||||
}
|
|
||||||
|
|
||||||
function compact(text) {
|
|
||||||
return text.normalize('NFC').toLocaleLowerCase().replace(/[^\p{L}\p{N}]/gu, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function markdownFiles(directory) {
|
|
||||||
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
||||||
const results = await Promise.all(entries.map(async (entry) => {
|
|
||||||
const target = path.join(directory, entry.name);
|
|
||||||
if (entry.isDirectory()) return markdownFiles(target);
|
|
||||||
return entry.isFile() && entry.name.endsWith('.md') ? [target] : [];
|
|
||||||
}));
|
|
||||||
return results.flat();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pdfText(pdf) {
|
|
||||||
const { stdout } = await execFileAsync('pdftotext', [pdf, '-'], { maxBuffer: 32 * 1024 * 1024 });
|
|
||||||
return stdout;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const [sourceRoot, deliveryRoot] = process.argv.slice(2);
|
|
||||||
if (!sourceRoot || !deliveryRoot) throw new Error('Usage: verify-content.mjs SOURCE_DIRECTORY DELIVERY_DIRECTORY');
|
|
||||||
|
|
||||||
const sources = (await markdownFiles(sourceRoot)).sort();
|
|
||||||
const failures = [];
|
|
||||||
|
|
||||||
for (const source of sources) {
|
|
||||||
const relative = path.relative(sourceRoot, source);
|
|
||||||
const pdf = path.join(deliveryRoot, relative.replace(/\.md$/, '.pdf'));
|
|
||||||
const [markdown, extracted] = await Promise.all([fs.readFile(source, 'utf8'), pdfText(pdf)]);
|
|
||||||
const { body, fields } = extractDocumentContent(markdown);
|
|
||||||
const controlText = ['Document', 'Project', 'Project code', 'Project period', 'Release']
|
|
||||||
.map((field) => fields[field] ?? '')
|
|
||||||
.join('\n');
|
|
||||||
const expected = words(`${body}\n${controlText}`);
|
|
||||||
const actual = compact(extracted);
|
|
||||||
const missing = [...expected].filter((word) => !actual.includes(word));
|
|
||||||
if (missing.length) failures.push(`${relative}: missing ${missing.slice(0, 12).join(', ')}${missing.length > 12 ? ', …' : ''}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failures.length) {
|
|
||||||
console.error(`Content coverage failed for ${failures.length}/${sources.length} PDFs:`);
|
|
||||||
failures.forEach((failure) => console.error(`- ${failure}`));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Content coverage passed: ${sources.length}/${sources.length} PDFs.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((error) => { console.error(error); process.exit(1); });
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
ROOT_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
|
|
||||||
SOURCE_DIR="$ROOT_DIR/sdlc"
|
|
||||||
TOOL_DIR="$SCRIPT_DIR/sdlc-delivery"
|
|
||||||
PACKAGE_DIR="$(realpath -m "${1:-$ROOT_DIR/sdlc-delivery}")"
|
|
||||||
|
|
||||||
[[ -d "$PACKAGE_DIR" ]] || { echo "Delivery package not found: $PACKAGE_DIR" >&2; exit 2; }
|
|
||||||
|
|
||||||
expected="$(find "$SOURCE_DIR" -type f -name '*.md' | wc -l | tr -d ' ')"
|
|
||||||
produced="$(find "$PACKAGE_DIR" -type f -name '*.pdf' | wc -l | tr -d ' ')"
|
|
||||||
[[ "$expected" == "$produced" ]] || { echo "Expected $expected PDFs, found $produced." >&2; exit 1; }
|
|
||||||
|
|
||||||
node "$TOOL_DIR/verify-content.mjs" "$SOURCE_DIR" "$PACKAGE_DIR"
|
|
||||||
|
|
||||||
format_errors=0
|
|
||||||
while IFS= read -r -d '' pdf; do
|
|
||||||
page_size="$(pdfinfo "$pdf" | awk -F': *' '/^Page size/ {print $2}')"
|
|
||||||
case "$page_size" in
|
|
||||||
'594.96 x 841.92 pts (A4)'|'841.92 x 594.96 pts (A4)') ;;
|
|
||||||
*) echo "Non-A4 PDF: $pdf ($page_size)" >&2; format_errors=$((format_errors + 1));;
|
|
||||||
esac
|
|
||||||
done < <(find "$PACKAGE_DIR" -type f -name '*.pdf' -print0 | sort -z)
|
|
||||||
|
|
||||||
[[ "$format_errors" == 0 ]] || exit 1
|
|
||||||
echo "Format verification passed: $produced PDFs use A4 portrait or landscape."
|
|
||||||
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user