Compare commits

48 Commits
Author SHA1 Message Date
Thanakorn 6c39700d74 Add interactive script to generate root .env for docker-compose
Prompts for DB password, public host, EMIT_SECRET, and SMTP creds,
auto-generating secrets where left blank, instead of hand-editing
.env.example.
2026-08-17 13:50:03 +07:00
Thanakorn 136084f259 Add Docker Compose production stack (php-apache, mariadb, node/pm2)
Single-command deploy: docker compose up -d --build brings up the LEMP
stack plus the Node realtime/scheduler service. app/config.php and DB
secrets are generated from .env at container start, never baked into
the image or committed.
2026-08-17 13:44:14 +07:00
Thanakorn 63cea23dc4 Seed Demo Data - Rebranding 2026-08-17 13:12:07 +07:00
Thanakorn dd48a8b96d Demo Data Population 2026-08-14 14:10:31 +07:00
nok b2c437426b fix login and configurations 2026-08-03 11:17:41 +07:00
Thanakorn S a0677d6d8d Classe methods: remove reducdancy 2026-05-29 08:56:58 +07:00
Thanakorn SandClaude Sonnet 4.6 ed3dd2f215 Fix horizontal scrollbar caused by sidebar margin overflowing viewport
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 16:58:15 +07:00
Thanakorn SandClaude Sonnet 4.6 fda211b1a8 Block concurrent login: reject new session if account already active
If session_token is set and session_last_seen is within the last hour,
the incoming login is rejected with a clear message. Stale sessions
(idle > 1 h) and explicit logouts (token = NULL via back.php) still allow
re-login normally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 16:21:55 +07:00
Thanakorn S 9a50238347 Scope stock table access by company warehouses 2026-05-28 15:36:49 +07:00
Thanakorn SandClaude Sonnet 4.6 5df67367fa Fix incomplete Rack→Bin rename: missing file renames, stale UI labels, ReportManager property bug
- Rename rack_log.php → bin_log.php and rack_occupancy.php → bin_occupancy.php
  (occupy_rack.php was already calling bin_*.php, causing 404 on every load)
- Fix occupy_rack.php: page title, stat card label (add id="stat_label_bins"),
  section headings, and <th> column headers still read "Rack/Racks"
- Fix ReportManager: constructor wrote to $this->companyId (dynamic property)
  instead of the declared $this->company_id, causing all queries to filter on
  company_id = 0 under strict PHP 8.2+ property semantics

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 09:53:19 +07:00
Thanakorn S 8f57ab5570 Change 'Rack' to 'Bin' 2026-05-27 17:14:53 +07:00
Thanakorn SandClaude Sonnet 4.6 b8798bc02d Wire targeted toast notifications to document status transitions
Adds emit_notification_user() to 7 endpoints so the acting user and
all admins/owners receive a real-time toast on key document actions:
confirm/cancel order, issue/void invoice, confirm return, confirm PO,
receive PO goods. Other staff and viewers are not notified.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:44:55 +07:00
Thanakorn S bbe89b0380 notify node: userIDguard 2026-05-27 13:12:18 +07:00
Thanakorn S 714b70d552 NODEJS cron fix 2026-05-27 12:05:29 +07:00
Thanakorn S f3c0e3c876 fix NodeJS cron 2026-05-27 11:56:40 +07:00
Thanakorn S a6651c41b9 cron low stock - overdue invoice 2026-05-27 11:45:48 +07:00
Thanakorn S 458a883810 CORS whitelist for NodeJS 2026-05-27 11:26:40 +07:00
Thanakorn S 5221b3a1a1 nodejs status check 2026-05-27 11:18:09 +07:00
Thanakorn S 418dbf9ba6 autostart node process 2026-05-27 11:07:30 +07:00
Thanakorn S 99ae35dc98 all .md reviewed - fix remaining gaps 2026-05-27 10:42:44 +07:00
Thanakorn S 1f371c6f94 add missing roleGuards 2026-05-27 09:19:52 +07:00
Thanakorn SandClaude Sonnet 4.6 d7f104ff25 Stop tracking docs/ — already in .gitignore
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 08:15:29 +07:00
Thanakorn SandClaude Sonnet 4.6 dfeb57533a Master data review: spec updates and C1/C2/M3-M6 fixes
Spec (docs/reviewing/master-data.md):
- M1: Remove margin from stored product fields (it's a derived frontend value)
- M2: Clarify posting window only restricts transaction dates, not master data
- M5: Document AccountFormulaManager::delete() as archive, not physical delete

Code:
- C1: Fix CompanySettingManager property typo (company_id → companyId) —
  prevented PHP 8.4 dynamic property fatal on all posting window operations
- C2: Fix WarehouseManager::deleteWarehouse() guard — was comparing
  warehouse_name (string) against warehouse id column (no-op); now correctly
  blocks on storage rows and active stock rows
- M3: Remove hard-delete of td_rack_log in deleteStorage() — retain rack
  history consistent with soft-delete philosophy elsewhere
- M4: Add reference guards to ChartOfAccounts::delete() (blocks on GL items,
  formula items, product account mappings) and DepartmentManager::delete()
  (blocks on GL items)
- M6: Fix CompanySettingManager::handle() partial update — only upsert keys
  present in the request, not all allowed keys defaulted

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:33:58 +07:00
Thanakorn SandClaude Sonnet 4.6 cb36d3b8fd Document lifecycle review: spec updates and C2/M9 code fixes
Spec (docs/reviewed/document-lifecycle.md):
- C1: Correct GlManager::delete() description — reversal entry, not hard delete
- C3: Clarify PO status 2/3 are derived (receipt_status), never stored
- C4: Add invoice status=2 (Paid/Settled) to status table
- C5: Add full Receipt/Payment Billing Note lifecycle section
- C6: Add Quotation status table and transition rules
- C7: Document soft-delete tombstone mechanism (company_id negation)

Code (InvoiceManager.php):
- C2: voidInvoice() now blocks on active credit notes, posted receipt
  billing notes, and posted payment billing notes in addition to the
  existing receipt/payment checks
- M9: softDelete() skips assertPostingWindow for draft invoices (status=0)
  since drafts have no GL entry and no accounting impact

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 16:23:46 +07:00
Thanakorn S 5687b562fb system notification 2026-05-26 13:26:57 +07:00
Thanakorn S d93abb0b81 Seal transaction limit coverage gaps 2026-05-26 13:10:54 +07:00
Thanakorn SandClaude Sonnet 4.6 b4b1f5cbec Security hardening: invited user onboarding flow (C1–N7)
- C1: verify.php now filters license='owner' — invite tokens no longer accepted
- C1: onboarding API rejects non-owner sessions
- C2: Existing-user invite requires explicit acceptance via accept_invite.php
- C2: New accept_invite.php page and API engine added
- C2: inviteUser() generates token + expiry for existing users; resendInvite() handles active users
- C3: session_regenerate_id(true) before writing invite session keys on both invite pages
- C4: invited_onboarding API wraps activation in transaction with SELECT FOR UPDATE; rowCount check added; SQLSTATE 23000 caught cleanly
- C5: inviteUser() and resendInvite() two-table writes wrapped in transactions
- M2: removeUser() wrapped in transaction with FOR UPDATE; clears default_company on active user removal
- M4: Logged-in user guard added to invited_onboarding.php and accept_invite.php
- M5: manage_users.php uses $server_url instead of HTTP_HOST for invite URLs
- M6: Username regex enforces 3-32 chars; reserved name blocklist added
- N5: searchUsers() changed from LIKE fuzzy search to exact email match only
- N7: resendInvite() rate-limited to once per 60s via invite_resent_at column
- Schema: company_map_user gains invite_expires_at and invite_resent_at columns

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 10:18:40 +07:00
Thanakorn S 1904fea84c document number sequence 2026-05-26 08:19:40 +07:00
Thanakorn S 4f884177a5 Seal live dashboard event gaps 2026-05-25 17:02:52 +07:00
Thanakorn S 4733c78ac6 Close login gap 2026-05-25 15:34:12 +07:00
Thanakorn S 9b41c0a73a PHP event trigger by NodeJS [stock dashboard] 2026-05-25 14:33:36 +07:00
Thanakorn S ba96de50a1 stock aggregate table 2026-05-25 13:30:10 +07:00
Thanakorn S 293097363b login/ block concurrent login, allow single factor authen for staff and viewer 2026-05-25 09:43:30 +07:00
Thanakorn SandClaude Sonnet 4.6 b07882e3f4 code audit fixes: require_once, issue flow, role guards
- Upgraded all plain `require` to `require_once` across 172 api/engine
  and api/engine_report files to prevent class-redeclaration errors
- Added issue button, issue_invoice() with GL toastr, and delete_invoice()
  to expense/manage_purchase_invoice.php, bringing it in line with
  po/manage_purchase_invoice.php
- Added can_delete role guard (admin/owner only) to trash icons on
  revenue/invoice.php and expense/purchase_invoice.php, matching the
  existing pattern in finance/receipt.php and finance/payment.php

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 17:06:08 +07:00
Thanakorn S eddb10aa22 automate and view gl entries 2026-05-23 16:46:35 +07:00
Thanakorn S 363da054e0 batch journal entries 2026-05-23 15:55:22 +07:00
Thanakorn S 65e9c1668d accounting Reports 2026-05-23 15:07:11 +07:00
Thanakorn S f4ef776e9c fix file path 2026-05-23 13:11:22 +07:00
Thanakorn S 59037b5158 fix file path 2026-05-23 13:09:18 +07:00
Thanakorn S f5ea74e134 gl aggregate table (ETL), cronjob by NODEJS 2026-05-23 10:52:27 +07:00
Thanakorn S 9c275eb358 NODE JS introduction: socket polling 2026-05-22 17:01:59 +07:00
Thanakorn S 2410f7bade softDelete features 2026-05-22 14:07:22 +07:00
Thanakorn S f04554793e users app access badge 2026-05-22 13:21:46 +07:00
Thanakorn S ba8e275426 user badge 2026-05-22 10:42:01 +07:00
Thanakorn S e36d304521 use roles guards 2026-05-22 08:45:35 +07:00
Thanakorn S b76dc679af fix onboarding bugs 2026-05-21 16:51:19 +07:00
Thanakorn SandClaude Sonnet 4.6 fdf6292466 add setup.php — one-shot production database setup script
Creates wms + wms2 databases and all 54 tables from scratch using
CREATE TABLE IF NOT EXISTS. Reads credentials from app/config.php.
Safe to re-run (idempotent). CLI-only guard prevents web access.
Dynamic td_stock_<warehouse_id> tables are excluded — the app creates
them automatically on first warehouse use.

Run: php setup.php

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 15:21:36 +07:00
Thanakorn SandClaude Sonnet 4.6 94032dd65b fix StockManager lot/serial coercion + add document flow test suite
- StockManager: coerce lot_number/serial_number to trimmed string (fixes
  Array-to-string warnings); validate quantity > 0 on insert; tighten
  transfer pair lookup to match in/out side by column value
- PostingWindowGuard: strip time component from datetime strings before
  date-format validation
- docs/tests/doc_flow_test.php: 32-assertion document flow suite covering
  Stock In create + approve, Sales Order draft/confirm, Invoice from Order
  (with duplicate-block check), PO create/confirm, Quotation create, and
  usage increment wiring check; all 32/32 PASS against wms_codex_test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 14:20:41 +07:00
165 changed files with 681 additions and 5372 deletions
-6
View File
@@ -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
View File
@@ -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/
+1 -1
View File
@@ -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
View File
@@ -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;
-22
View File
@@ -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
+3 -11
View File
@@ -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) {
+1 -22
View File
@@ -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) {
+1 -4
View File
@@ -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
+5 -8
View File
@@ -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
-36
View File
@@ -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
));
}
}
-18
View File
@@ -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.
-5
View File
@@ -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,
]; ];
+1 -1
View File
@@ -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
+5 -5
View File
@@ -194,7 +194,7 @@
<td><input type="number" class="form-control form-control-sm item_qty" value="${item.quantity || 1}" min="0.0001" step="any" oninput="recalc_totals()"></td> <td><input type="number" class="form-control form-control-sm item_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) {
+4 -5
View File
@@ -253,8 +253,8 @@
.html(request_data.po_id > 0 ? 'PO: <a href="<?php echo $server_url?>expense/manage_purchase_order.php?id=' + request_data.po_id + '">' + escape_html(request_data.po_number || ('PO #' + request_data.po_id)) + '</a>' : ''); .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) {
+2 -2
View File
@@ -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>
-38
View File
@@ -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));
?>
+1 -47
View File
@@ -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;">&nbsp;</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('&nbsp;');
if (!$('#warehouse').val() || !bin_val) return;
return ajax_request({
url: '<?php echo $server_url?>ics/api/engine/retrieve_bin_stock.php',
autoPrepare: true,
checkRequired: 0,
noLoading: true,
queueLock: false,
action: 'read',
data: {
warehouse: $('#warehouse').val(),
zone: advanced ? $('#zone').val() : bin_val,
aisle: advanced ? $('#aisle').val() : bin_val,
bin: bin_val
},
onSuccess: function(res) {
if (!res.output) return;
$('#location_quantity').val(format_number(res.output.quantity, 2));
$('#location_uom').text(res.output.uom || '');
}
}); });
} }
@@ -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('&nbsp;');
} }
@@ -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>
-45
View File
@@ -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;">&nbsp;</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('&nbsp;');
if (!$('#warehouse_from').val() || !bin_val) return;
return ajax_request({
url: '<?php echo $server_url?>ics/api/engine/retrieve_bin_stock.php',
autoPrepare: true,
checkRequired: 0,
noLoading: true,
queueLock: false,
action: 'read',
data: {
warehouse: $('#warehouse_from').val(),
zone: advanced ? $('#zone_from').val() : bin_val,
aisle: advanced ? $('#aisle_from').val() : bin_val,
bin: bin_val
},
onSuccess: function(res) {
if (!res.output) return;
$('#transfer_quantity').val(format_number(res.output.quantity, 2));
$('#transfer_uom').text(res.output.uom || '');
}
});
}
function same_location_as_from(data) { 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('&nbsp;');
} }
@@ -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
View File
@@ -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
View File
@@ -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();
} }
+1 -1
View File
@@ -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>' +
+2 -9
View File
@@ -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));
} }
+4 -9
View File
@@ -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"
); );
+64 -79
View File
@@ -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,67 +114,59 @@ 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');
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);
// Assemble a temporary SMTP config for the test send (step 8) // Assemble a temporary SMTP config for the test send (step 8)
$smtp_config = [ $smtp_config = [
'server' => $smtp_host, 'server' => $smtp_host,
'port' => $smtp_port, 'port' => $smtp_port,
'username' => $smtp_username, 'username' => $smtp_username,
'password' => $encrypted_pass, 'password' => $encrypted_pass,
'from_name' => $company_name ?: $smtp_username, 'from_name' => $company_name ?: $smtp_username,
'from_email' => $email ?: $smtp_username, 'from_email' => $email ?: $smtp_username,
'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.
require_once '../../../assets/utils/module/mailer.php'; require_once '../../../assets/utils/module/mailer.php';
$mailer = new mailer(['pdo1' => $pdo1]); $mailer = new mailer(['pdo1' => $pdo1]);
$mailer->send_email([ $mailer->send_email([
'company_id' => 0, 'company_id' => 0,
'smtp' => $smtp_config, 'smtp' => $smtp_config,
'to' => $_SESSION['onboarding_email'] ?? $smtp_username, 'to' => $_SESSION['onboarding_email'] ?? $smtp_username,
'subject' => 'WMS — SMTP Verification', 'subject' => 'WMS — SMTP Verification',
'message' => "Your SMTP is working correctly.\n\nSetup is now complete.", 'message' => "Your SMTP is working correctly.\n\nSetup is now complete.",
'channel_name' => $company_name ?: 'WMS', 'channel_name' => $company_name ?: 'WMS',
'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,29 +226,25 @@ 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 $sth = $pdo1->prepare("
// can be added later under Settings → SMTP. INSERT INTO company_smtp
if ($smtp_given) { (company_id, server, port, username, password,
$sth = $pdo1->prepare(" from_name, from_email, encryption, updated_at)
INSERT INTO company_smtp VALUES
(company_id, server, port, username, password, (:company_id, :server, :port, :username, :password,
from_name, from_email, encryption, updated_at) :from_name, :from_email, :encryption, NOW())
VALUES ");
(:company_id, :server, :port, :username, :password, $sth->execute([
:from_name, :from_email, :encryption, NOW()) ':company_id' => $company_id,
"); ':server' => $smtp_host,
$sth->execute([ ':port' => $smtp_port,
':company_id' => $company_id, ':username' => $smtp_username,
':server' => $smtp_host, ':password' => $encrypted_pass,
':port' => $smtp_port, ':from_name' => $company_name,
':username' => $smtp_username, ':from_email' => $email ?: $smtp_username,
':password' => $encrypted_pass, ':encryption' => $smtp_encryption,
':from_name' => $company_name, ]);
':from_email' => $email ?: $smtp_username, db_check($sth, $answer);
':encryption' => $smtp_encryption,
]);
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
View File
@@ -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>
+8 -29
View File
@@ -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();
+1 -1
View File
@@ -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';
+3 -3
View File
@@ -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) {
+1 -1
View File
@@ -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
View File
@@ -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;
+2 -2
View File
@@ -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>
+4 -4
View File
@@ -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();
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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>
+3 -1
View File
@@ -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];
+1 -1
View File
@@ -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 = '';
+2 -2
View File
@@ -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);
+3 -3
View File
@@ -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 {
+7 -1
View File
@@ -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>`
+2 -2
View File
@@ -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">
-6
View File
@@ -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.
+2 -11
View File
@@ -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']);
+2 -6
View File
@@ -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.';
+1 -2
View File
@@ -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">
+3 -2
View File
@@ -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
View File
@@ -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);
+3 -8
View File
@@ -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
View File
@@ -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,
-1
View File
@@ -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:
-1
View File
@@ -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
View 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/*
-16
View File
@@ -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' => [
+1 -25
View File
@@ -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"
+21
View File
@@ -0,0 +1,21 @@
2026-07-22T14:37:48: [2026-07-22T07:37:48.626Z] [PID: 505] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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.
+58
View File
@@ -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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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] [NODE-CRON] [WARN] 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.
+255
View File
@@ -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
-32
View File
@@ -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>
-4
View File
@@ -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>
-8
View File
@@ -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 &nbsp; Fax. (+66) 2687 0255</p>
</div>
<div class="document-header__title">{{documentTitle}}</div>
</header>
-37
View File
@@ -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 &nbsp; 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;
}
-54
View File
@@ -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"
}
}
}
}
-8
View File
@@ -1,8 +0,0 @@
{
"name": "brn-wms-sdlc-delivery",
"private": true,
"type": "module",
"dependencies": {
"playwright": "1.50.0"
}
}
-175
View File
@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
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); });
-91
View File
@@ -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); });
-28
View File
@@ -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."
BIN
View File
Binary file not shown.

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