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
188 changed files with 750 additions and 9945 deletions
-6
View File
@@ -13,11 +13,5 @@ EMIT_SECRET=
SMTP_USERNAME=
SMTP_PASSWORD=
# Email OTP on sign-in. Off by default; only the exact value "true" turns it on,
# and that needs working SMTP. While off, sign-in is password only (logged as
# OTP_BYPASSED, shown on the login page and top bar).
# Applied to app/config.php by the php container on every start.
OTP_REQUIRED=false
# Port to expose the web app on (default 80)
HTTP_PORT=80
+2 -3
View File
@@ -6,9 +6,6 @@ app/uploads
node_modules/
nodejs/.env
# PM2 runtime logs (written by the node container; nodejs/logs/.gitkeep keeps the folder)
nodejs/logs/*.log
# Docker deploy secrets
/.env
@@ -18,4 +15,6 @@ lib/zxcvbn-php-master/vendor/sebastian/
# custom files
notes/
docs/
.claude/
SESSION.php
sdlc/
+2 -10
View File
@@ -199,11 +199,7 @@
supplier_credit_note: 'Supplier Credit Note',
receipt: 'Receipt',
payment: 'Payment',
manual: 'Manual',
asset_capitalization: 'Asset Mgmt · Capitalization',
asset_depreciation: 'Asset Mgmt · Depreciation',
asset_amortization: 'Asset Mgmt · Amortization',
asset_disposal: 'Asset Mgmt · Disposal'
manual: 'Manual'
};
var source_badges = {
@@ -213,11 +209,7 @@
supplier_credit_note: 'bg-secondary-subtle text-secondary',
receipt: 'bg-info-subtle text-info',
payment: 'bg-danger-subtle text-danger',
manual: 'bg-dark-subtle text-dark',
asset_capitalization: 'bg-success text-white',
asset_depreciation: 'bg-success text-white',
asset_amortization: 'bg-success text-white',
asset_disposal: 'bg-success text-white'
manual: 'bg-dark-subtle text-dark'
};
function fmt_num(n) {
+5 -18
View File
@@ -11,7 +11,6 @@ require_once '../../../assets/utils/classes_ac/posting/SupplierCreditNotePosting
require_once '../../../assets/utils/classes_ac/posting/ReceiptPosting.php';
require_once '../../../assets/utils/classes_ac/posting/PaymentPosting.php';
require_once '../../../assets/utils/classes_ac/posting/PurchasePosting.php';
require_once '../../../assets/utils/classes_as/AssetPosting.php';
require_role($user_role, ['owner', 'admin']);
require_once '../../../assets/utils/notify_node.php';
@@ -30,31 +29,19 @@ $posting_map = [
'purchase_order' => PurchasePosting::class,
];
$is_asset_source = AssetPosting::isAssetSource($doc_type);
if ((!isset($posting_map[$doc_type]) && !$is_asset_source) || $id <= 0) {
if (!isset($posting_map[$doc_type]) || $id <= 0) {
$answer['message'] = 'Invalid doc_type or id.';
exit(json_encode($answer));
}
try {
if ($is_asset_source) {
// Asset Management entries carry their own reference and "[Asset Mgmt]" description.
$built = (new AssetPosting($pdo2, $company_id))->build($doc_type, $id);
$meta = [
'journal_date' => $built['doc_date'],
'reference' => $built['reference'],
'description' => $built['description'],
];
} else {
$posting_class = $posting_map[$doc_type];
$posting = new $posting_class($pdo2, $company_id);
$built = $posting->build($id, $formula_id);
$meta = ['journal_date' => $built['doc_date'] ?? null];
}
$posting_class = $posting_map[$doc_type];
$posting = new $posting_class($pdo2, $company_id);
$built = $posting->build($id, $formula_id);
$guard = new PostingWindowGuard($pdo1, $company_id);
$gl = new GlManager($pdo2, $company_id, $guard);
$meta = ['journal_date' => $built['doc_date'] ?? null];
$pdo2->beginTransaction();
+18 -82
View File
@@ -39,22 +39,6 @@
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#tab-payment">Payment</a>
</li>
<!-- Entries prepared in Asset Management -->
<li class="nav-item d-flex align-items-center ms-lg-3 px-2">
<span class="badge bg-success text-white"><i class="ti ti-building-warehouse me-1"></i>Asset Management</span>
</li>
<li class="nav-item">
<a class="nav-link text-success" data-bs-toggle="tab" href="#tab-asset-capitalization">Capitalization</a>
</li>
<li class="nav-item">
<a class="nav-link text-success" data-bs-toggle="tab" href="#tab-asset-depreciation">Depreciation</a>
</li>
<li class="nav-item">
<a class="nav-link text-success" data-bs-toggle="tab" href="#tab-asset-amortization">Amortization</a>
</li>
<li class="nav-item">
<a class="nav-link text-success" data-bs-toggle="tab" href="#tab-asset-disposal">Disposal</a>
</li>
</ul>
<div class="tab-content">
@@ -76,18 +60,6 @@
<div class="tab-pane fade" id="tab-payment">
<?php echo gl_tab_html('payment', 'Payment'); ?>
</div>
<div class="tab-pane fade" id="tab-asset-capitalization">
<?php echo gl_tab_html('asset_capitalization', 'Asset Capitalization', true); ?>
</div>
<div class="tab-pane fade" id="tab-asset-depreciation">
<?php echo gl_tab_html('asset_depreciation', 'Asset Depreciation', true); ?>
</div>
<div class="tab-pane fade" id="tab-asset-amortization">
<?php echo gl_tab_html('asset_amortization', 'Asset Amortization', true); ?>
</div>
<div class="tab-pane fade" id="tab-asset-disposal">
<?php echo gl_tab_html('asset_disposal', 'Asset Disposal', true); ?>
</div>
</div>
</div>
</div>
@@ -104,17 +76,9 @@
'purchase_invoice': 'purchase_invoice',
'supplier_credit_note': 'supplier_credit_note',
'receipt': 'receipt',
'payment': 'payment',
'asset_capitalization': 'asset_capitalization',
'asset_depreciation': 'asset_depreciation',
'asset_amortization': 'asset_amortization',
'asset_disposal': 'asset_disposal'
'payment': 'payment'
};
// Prepared in Asset Management: no GL formula, accounts come from the asset category.
var ASSET_TYPES = ['asset_capitalization', 'asset_depreciation', 'asset_amortization', 'asset_disposal'];
function is_asset_type(doc_type) { return ASSET_TYPES.indexOf(doc_type) !== -1; }
var _gl_loaded = {}; // doc_type → [ids] (flat, used by single-formula run)
var _gl_rows = {}; // doc_type → [rows]
var _gl_failed = {}; // doc_type → failed items from last run
@@ -127,13 +91,11 @@
if (_inited[doc_type]) return;
_inited[doc_type] = true;
if (!is_asset_type(doc_type)) {
load_formula_options('gl-formula-' + doc_type, doc_type, null, function(formulas) {
var $sel = $('#gl-formula-' + doc_type);
$sel.prepend('<option value="all">— All Formulas (' + formulas.length + ') —</option>');
$sel.val('all');
});
}
load_formula_options('gl-formula-' + doc_type, doc_type, null, function(formulas) {
var $sel = $('#gl-formula-' + doc_type);
$sel.prepend('<option value="all">— All Formulas (' + formulas.length + ') —</option>');
$sel.val('all');
});
var today = new Date();
var firstDay = new Date(today.getFullYear(), today.getMonth(), 1);
@@ -155,7 +117,7 @@
// ── load documents ────────────────────────────────────────────────────────
function load_gl_tab(doc_type) {
var formula_id = is_asset_type(doc_type) ? '0' : $('#gl-formula-' + doc_type).val();
var formula_id = $('#gl-formula-' + doc_type).val();
if (!formula_id) { bootbox.alert('Please select a GL formula first.'); return; }
if (formula_id === 'all') {
@@ -294,9 +256,7 @@
var status_badge = r.gl_status == 1
? '<span class="badge bg-success-subtle text-success">Posted</span>'
: '<span class="badge bg-danger-subtle text-danger">Unposted</span>';
var mapping_badge = is_asset_type(doc_type)
? '<span class="badge bg-success-subtle text-success">Asset category</span>'
: product_mapping_badge(r);
var mapping_badge = product_mapping_badge(r);
html +=
'<tr>' +
'<td>' + escape_html(r.doc_number) + '</td>' +
@@ -342,15 +302,11 @@
purchase_invoice: 'Purchase Invoice',
supplier_credit_note: 'Supplier Credit Note',
receipt: 'Receipt',
payment: 'Payment',
asset_capitalization: '[Asset Mgmt] Capitalization',
asset_depreciation: '[Asset Mgmt] Depreciation',
asset_amortization: '[Asset Mgmt] Amortization',
asset_disposal: '[Asset Mgmt] Disposal'
payment: 'Payment'
};
function run_gl_batch(doc_type) {
var formula_id = is_asset_type(doc_type) ? '0' : $('#gl-formula-' + doc_type).val();
var formula_id = $('#gl-formula-' + doc_type).val();
if (formula_id === 'all') {
run_all_formula_batches(doc_type);
@@ -437,7 +393,7 @@
// ── core batch processor ──────────────────────────────────────────────────
function _start_gl_batch(doc_type, ids, onComplete, formula_id_override) {
var formula_id = formula_id_override || (is_asset_type(doc_type) ? 0 : $('#gl-formula-' + doc_type).val());
var formula_id = formula_id_override || $('#gl-formula-' + doc_type).val();
var $msg = $('#gl-msg-' + doc_type);
set_btn_state('#gl-btn-load-' + doc_type, false);
@@ -560,34 +516,10 @@
</html>
<?php
function gl_tab_html(string $doc_type, string $label, bool $from_asset = false): string {
// Asset Management entries have no GL formula: say where they come from instead.
$source_field = $from_asset
? <<<HTML
<div class="col-auto">
<label class="form-label mb-1 small">Source</label>
<div><span class="badge bg-success text-white py-2 px-3"><i class="ti ti-building-warehouse me-1"></i>From Asset Management</span></div>
</div>
HTML
: <<<HTML
<div class="col-auto">
<label class="form-label mb-1 small">GL Formula <span class="text-danger">*</span></label>
<select id="gl-formula-{$doc_type}" class="form-select form-select-sm" style="width:220px;">
</select>
</div>
HTML;
$source_note = $from_asset
? '<div class="alert alert-success small py-2 mb-3"><i class="ti ti-info-circle me-1"></i>'
. 'Prepared in <strong>Asset Management</strong>. Accounts come from each asset category; '
. 'entries are labelled <strong>[Asset Mgmt]</strong> in the GL Journal.</div>'
: '';
$accounts_header = $from_asset ? 'Accounts' : 'Product Accounts';
function gl_tab_html(string $doc_type, string $label): string {
return <<<HTML
<div class="card p-4">
{$source_note}
<!-- Filters + action buttons -->
<div class="row g-2 align-items-end mb-3">
<div class="col-auto">
@@ -600,7 +532,11 @@ HTML;
<input type="text" id="gl-date-to-{$doc_type}" class="form-control form-control-sm"
placeholder="d/m/Y" style="width:140px;">
</div>
{$source_field}
<div class="col-auto">
<label class="form-label mb-1 small">GL Formula <span class="text-danger">*</span></label>
<select id="gl-formula-{$doc_type}" class="form-select form-select-sm" style="width:220px;">
</select>
</div>
<div class="col-auto">
<button id="gl-btn-load-{$doc_type}" class="btn btn-secondary btn-sm"
onclick="load_gl_tab('{$doc_type}')">
@@ -629,7 +565,7 @@ HTML;
<th>Contact</th>
<th class="text-end">Amount</th>
<th>Date</th>
<th>{$accounts_header}</th>
<th>Product Accounts</th>
<th>GL Status</th>
</tr>
</thead>
+4 -18
View File
@@ -48,12 +48,6 @@
<option value="payment">Payment</option>
<option value="manual">Manual</option>
<option value="reversal">Reversal</option>
<optgroup label="Asset Management">
<option value="asset_capitalization">[Asset Mgmt] Capitalization</option>
<option value="asset_depreciation">[Asset Mgmt] Depreciation</option>
<option value="asset_amortization">[Asset Mgmt] Amortization</option>
<option value="asset_disposal">[Asset Mgmt] Disposal</option>
</optgroup>
</select>
</div>
<div class="col-auto">
@@ -194,11 +188,7 @@
receipt: 'Receipt',
payment: 'Payment',
manual: 'Manual',
reversal: 'Reversal',
asset_capitalization: 'Asset Mgmt · Capitalization',
asset_depreciation: 'Asset Mgmt · Depreciation',
asset_amortization: 'Asset Mgmt · Amortization',
asset_disposal: 'Asset Mgmt · Disposal'
reversal: 'Reversal'
};
var SOURCE_BADGE = {
@@ -209,11 +199,7 @@
receipt: 'bg-info-subtle text-info',
payment: 'bg-danger-subtle text-danger',
manual: 'bg-dark-subtle text-dark',
reversal: 'bg-danger-subtle text-danger',
asset_capitalization: 'bg-success text-white',
asset_depreciation: 'bg-success text-white',
asset_amortization: 'bg-success text-white',
asset_disposal: 'bg-success text-white'
reversal: 'bg-danger-subtle text-danger'
};
function source_badge(type) {
@@ -521,9 +507,9 @@
? '<span class="badge bg-success-subtle text-success ms-2">Balanced</span>'
: '<span class="badge bg-danger-subtle text-danger ms-2">Unbalanced</span>';
var extra_fields = (h.source_type === 'manual' || String(h.source_type).indexOf('asset_') === 0)
var extra_fields = h.source_type === 'manual'
? '<div class="col-sm-4"><div class="text-muted small">Reference</div><div class="fw-semibold">' + escape_html(h.reference || ('MJE-' + h.id)) + '</div></div>' +
'<div class="col-sm-4"><div class="text-muted small">Journal Date</div><div>' + escape_html(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">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>' +
-2
View File
@@ -139,8 +139,6 @@
invoice: 'Invoice', credit_note: 'Credit Note',
purchase_invoice: 'Purchase Invoice', supplier_credit_note: 'Supplier Credit Note',
receipt: 'Receipt', payment: 'Payment', manual: 'Manual',
asset_capitalization: 'Asset Mgmt · Capitalization', asset_depreciation: 'Asset Mgmt · Depreciation',
asset_amortization: 'Asset Mgmt · Amortization', asset_disposal: 'Asset Mgmt · Disposal',
};
load_departments();
-9
View File
@@ -1,9 +0,0 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_app_access('asset');
require_once '../../../assets/utils/classes_as/AssetManager.php';
$answer['output'] = (new AssetManager($pdo2, $company_id))->getList((int)($data['invoice_id'] ?? 0));
$answer['success'] = 1;
exit(json_encode($answer));
-21
View File
@@ -1,21 +0,0 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_app_access('asset');
require_once '../../../assets/utils/classes_as/AssetManager.php';
require_once '../../../assets/utils/classes_as/AssetRunManager.php';
try {
$answer['output'] = (new AssetManager($pdo2, $company_id))->getStats();
$answer['due'] = (new AssetRunManager($pdo2, $company_id))->dueSummary();
$answer['success'] = 1;
} catch (PDOException $e) {
error_log('[asset stats] ' . $e->getMessage());
$answer['message'] = 'Database error, please try again.';
http_response_code(500);
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
-11
View File
@@ -1,11 +0,0 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_app_access('asset');
require_once '../../../assets/utils/classes_as/AssetCategoryManager.php';
$categories = new AssetCategoryManager($pdo2, $company_id);
$answer['output'] = $categories->getAll();
$answer['stats'] = $categories->getStats();
$answer['success'] = 1;
exit(json_encode($answer));
-63
View File
@@ -1,63 +0,0 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_app_access('asset');
require_once '../../../assets/utils/classes/UsageGuard.php';
require_once '../../../assets/utils/classes_ac/PostingWindowGuard.php';
require_once '../../../assets/utils/classes_ac/GlManager.php';
require_once '../../../assets/utils/classes_as/AssetRunManager.php';
$action = (string)($data['action'] ?? 'list');
$class = trim((string)($data['asset_class'] ?? ''));
$through = trim((string)($data['through_period'] ?? ''));
$run_id = (int)($data['run_id'] ?? 0);
if (in_array($action, ['run', 'void'], true)) {
require_role($user_role, ['owner', 'admin']);
}
try {
$runs = new AssetRunManager($pdo2, $company_id);
switch ($action) {
case 'list':
$answer['output'] = $runs->getList($class);
break;
case 'preview':
$answer['output'] = $runs->preview($class, $through);
break;
case 'detail':
$answer['output'] = $runs->getDetail($run_id);
break;
case 'run':
$created = dbTransaction($pdo2, fn($pdo) => (new AssetRunManager($pdo, $company_id))->run($class, $through, $user_id));
(new UsageGuard($pdo1, $company_id, $packages))->increment();
$answer['output'] = $created;
$answer['message'] = count($created) . ' run(s) created. Accounting posts them from Batch GL Entries.';
break;
case 'void':
dbTransaction($pdo2, function ($pdo) use ($company_id, $run_id, $pdo1) {
$gl = new GlManager($pdo, $company_id, new PostingWindowGuard($pdo1, $company_id));
(new AssetRunManager($pdo, $company_id))->void($run_id, $gl);
});
$answer['message'] = 'Run voided.';
break;
default:
throw new Exception('Invalid action.');
}
$answer['success'] = 1;
} catch (PDOException $e) {
$answer['message'] = 'Database error, please try again.';
http_response_code(500);
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
-28
View File
@@ -1,28 +0,0 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_app_access('asset');
require_once '../../../assets/utils/classes_as/AssetManager.php';
try {
$assets = new AssetManager($pdo2, $company_id);
if (($data['action'] ?? '') === 'line') {
$answer['output'] = $assets->getInvoiceLine((int)($data['invoice_id'] ?? 0), (int)($data['item_id'] ?? 0));
} else {
$answer['output'] = $assets->getCapitalizableLines(
(string)($data['date_from'] ?? ''),
(string)($data['date_to'] ?? '')
);
}
$answer['success'] = 1;
} catch (PDOException $e) {
error_log('[asset invoice lines] ' . $e->getMessage());
$answer['message'] = 'Database error, please try again.';
http_response_code(500);
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
-70
View File
@@ -1,70 +0,0 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_app_access('asset');
require_once '../../../assets/utils/classes/UsageGuard.php';
require_once '../../../assets/utils/classes_ac/PostingWindowGuard.php';
require_once '../../../assets/utils/classes_ac/GlManager.php';
require_once '../../../assets/utils/classes_as/AssetManager.php';
$action = (string)($data['action'] ?? '');
$id = (int)($data['id'] ?? 0);
$messages = [
'create' => 'Asset saved as a draft.',
'update' => 'Asset saved.',
'activate' => 'Asset activated. Accounting posts its capitalization from Batch GL Entries.',
'delete' => 'Draft asset deleted.',
'void' => 'Asset voided.',
'dispose' => 'Disposal recorded. Accounting posts it from Batch GL Entries.',
'undo_disposal' => 'Disposal undone.',
];
if (!isset($messages[$action])) {
$answer['message'] = 'Invalid action.';
http_response_code(400);
exit(json_encode($answer));
}
// Staff may prepare assets; everything that changes the books is owner/admin.
require_role($user_role, in_array($action, ['create', 'update'], true) ? ['owner', 'admin', 'staff'] : ['owner', 'admin']);
if ($action !== 'create' && $id <= 0) {
$answer['message'] = 'Missing asset id.';
http_response_code(400);
exit(json_encode($answer));
}
try {
$result_id = dbTransaction($pdo2, function ($pdo) use ($action, $id, $data, $company_id, $user_id, $pdo1) {
$assets = new AssetManager($pdo, $company_id);
$gl = new GlManager($pdo, $company_id, new PostingWindowGuard($pdo1, $company_id));
switch ($action) {
case 'create':
case 'update': return $assets->save($data, $user_id);
case 'activate': $assets->activate($id); break;
case 'delete': $assets->delete($id); break;
case 'void': $assets->void($id, $gl); break;
case 'dispose': $assets->dispose($id, $data); break;
case 'undo_disposal': $assets->undoDisposal($id, $gl); break;
}
return $id;
});
if ($action === 'create') {
(new UsageGuard($pdo1, $company_id, $packages))->increment();
}
$answer['success'] = 1;
$answer['output'] = ['id' => $result_id];
$answer['message'] = $messages[$action];
} catch (PDOException $e) {
$answer['message'] = 'Database error, please try again.';
http_response_code(500);
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
-22
View File
@@ -1,22 +0,0 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_app_access('asset');
require_role($user_role, ['owner', 'admin']);
require_once '../../../assets/utils/classes_as/AssetCategoryManager.php';
try {
$id = (new AssetCategoryManager($pdo2, $company_id))->save($data);
$answer['success'] = 1;
$answer['output'] = ['id' => $id];
$answer['message'] = !empty($data['id']) ? 'Category updated.' : 'Category created.';
} catch (PDOException $e) {
error_log('[asset category] ' . $e->getMessage());
$answer['message'] = 'Database error, please try again.';
http_response_code(500);
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
-15
View File
@@ -1,15 +0,0 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_app_access('asset');
require_role($user_role, ['owner', 'admin']);
require_once '../../../assets/utils/classes_as/AssetCategoryManager.php';
$id = (int)($data['id'] ?? 0);
if (!$id) { $answer['message'] = 'Missing id'; exit(json_encode($answer)); }
(new AssetCategoryManager($pdo2, $company_id))->deactivate($id);
$answer['success'] = 1;
$answer['message'] = 'Category deactivated.';
exit(json_encode($answer));
-19
View File
@@ -1,19 +0,0 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_app_access('asset');
require_once '../../../assets/utils/classes_as/AssetManager.php';
try {
$answer['output'] = (new AssetManager($pdo2, $company_id))->getDetail((int)($data['id'] ?? 0));
$answer['success'] = 1;
} catch (PDOException $e) {
error_log('[asset retrieve] ' . $e->getMessage());
$answer['message'] = 'Database error, please try again.';
http_response_code(500);
} catch (Exception $e) {
$answer['message'] = $e->getMessage();
http_response_code(400);
}
exit(json_encode($answer));
@@ -1,15 +0,0 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_app_access('asset');
require_once '../../../assets/utils/classes_as/AssetCategoryManager.php';
$id = (int)($data['id'] ?? 0);
if (!$id) { $answer['message'] = 'Missing id'; exit(json_encode($answer)); }
$row = (new AssetCategoryManager($pdo2, $company_id))->getById($id);
if (!$row) { $answer['message'] = 'Category not found'; exit(json_encode($answer)); }
$answer['output'] = $row;
$answer['success'] = 1;
exit(json_encode($answer));
-158
View File
@@ -1,158 +0,0 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_sidebar_asset.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<!-- Page header -->
<div class="row">
<div class="col-12">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-5 gap-3">
<div>
<h1 class="fs-3 mb-1">Asset Categories</h1>
<p class="mb-0">Useful life and GL accounts used by each kind of asset</p>
</div>
<div>
<a href="<?php echo $server_url?>asset/manage_category.php" class="btn btn-primary" data-min-role="admin">
<i class="ti ti-plus me-1"></i>Add Category
</a>
</div>
</div>
</div>
</div>
<!-- Table -->
<div class="row">
<div class="col-12">
<div class="card overflow-hidden">
<div class="card-body p-6">
<div class="mb-5 d-flex justify-content-between align-items-center gap-3">
<span class="text-muted small" id="category_summary"></span>
<div class="position-relative" style="min-width:240px;">
<input type="search" id="search_category" class="form-control ps-9" placeholder="Search code or name…">
<span class="position-absolute top-25 ms-4">
<i class="ti ti-search text-muted"></i>
</span>
</div>
</div>
<div class="table-responsive">
<table class="table mb-0 text-nowrap table-hover table-centered" id="category_table">
<thead class="table-primary border-light">
<tr>
<th>Code</th>
<th>Name</th>
<th>Class</th>
<th class="text-end">Life (months)</th>
<th>Cost / Accumulated / Expense</th>
<th class="text-end">Assets</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
<tfoot></tfoot>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
<script src="<?php echo $server_url?>assets/js/asset.js"></script>
<script>
var search_term = '';
function retrieve_categories() {
return ajax_request({
url: server_url + 'asset/api/engine/category.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
alasql('CREATE TABLE IF NOT EXISTS md_asset_category');
alasql.tables.md_asset_category.data = res.output || [];
var s = res.stats || {};
$('#category_summary').text((s.total || 0) + ' categories · ' + (s.active || 0) + ' active');
change_page(1);
}
});
}
function change_page(page_num) {
var offset = (page_num - 1) * prop_limit;
var kw = search_term.toLowerCase();
var rows = alasql('SELECT * FROM md_asset_category ORDER BY category_code').filter(function(r) {
return !kw || String(r.category_code).toLowerCase().indexOf(kw) !== -1 || String(r.category_name).toLowerCase().indexOf(kw) !== -1;
});
$('table#category_table tfoot').html(generate_pagination('category_table', rows.length));
if (!rows.length) {
$('table#category_table tbody').html('<tr><td colspan="8" class="text-center py-5 text-muted">No categories yet. Add one before creating assets.</td></tr>');
return;
}
var body = '';
rows.slice(offset, offset + parseInt(prop_limit, 10)).forEach(function(item) {
body += `<tr>
<td class="py-3 fw-semibold">${escape_html(item.category_code)}</td>
<td class="py-3">${escape_html(item.category_name)}</td>
<td class="py-3">${asset_class_badge(item.asset_class)}</td>
<td class="py-3 text-end">${item.useful_life_months}</td>
<td class="py-3 small text-muted">${escape_html(item.cost_account_code)} / ${escape_html(item.accum_account_code)} / ${escape_html(item.expense_account_code)}</td>
<td class="py-3 text-end">${item.asset_count}</td>
<td class="py-3">${String(item.status) === '1' ? '<span class="badge bg-success">Active</span>' : '<span class="badge bg-secondary">Inactive</span>'}</td>
<td class="py-3">
<a href="${server_url}asset/manage_category.php?id=${item.id}"><i class="ti ti-eye fs-5"></i></a>
${String(item.status) === '1' ? `<a href="javascript:;" class="link-danger ms-2" data-min-role="admin" onclick="remove_category(${item.id})"><i class="ti ti-ban fs-5"></i></a>` : ''}
</td>
</tr>`;
});
$('table#category_table tbody').html(body);
}
function remove_category(id) {
bootbox.confirm({
message: 'Deactivate this category? Existing assets keep using its accounts.',
buttons: { confirm: { label: 'Deactivate', className: 'btn-danger' }, cancel: { label: 'Back', className: 'btn-secondary' } },
callback: function(ok) {
if (!ok) return;
ajax_request({
url: server_url + 'asset/api/engine/remove_category.php',
autoPrepare: true,
checkRequired: 0,
action: 'delete',
data: { id: id },
onSuccess: function() { retrieve_categories(); }
});
}
});
}
$('#search_category').on('input', debounce(function() {
search_term = $(this).val().trim();
change_page(1);
}, 200));
$(async function() {
try { await retrieve_categories(); } catch (e) { console.log(e); }
});
</script>
</body>
</html>
-317
View File
@@ -1,317 +0,0 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_sidebar_asset.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="mb-5">
<h1 class="fs-3 mb-1">Depreciation &amp; Amortization Runs</h1>
<p class="mb-0">Charge each month's straight-line depreciation (tangible) or amortization (intangible)</p>
</div>
</div>
</div>
<!-- New run -->
<div class="card mb-5" data-min-role="admin">
<div class="card-body p-5">
<div class="row g-3 align-items-end">
<div class="col-md-4">
<label class="form-label mb-1">Type</label>
<select id="asset_class" class="form-select">
<option value="tangible">Depreciation — tangible assets</option>
<option value="intangible">Amortization — intangible assets</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label mb-1">Run Through Month</label>
<input type="text" id="through_period" class="form-control" placeholder="YYYY-MM">
</div>
<div class="col-md-5 d-flex gap-2">
<button class="btn btn-secondary" onclick="preview_run()"><i class="ti ti-eye me-1"></i>Preview</button>
<button class="btn btn-primary" id="btn_run" onclick="create_run()" disabled><i class="ti ti-player-play me-1"></i>Create Runs</button>
</div>
</div>
<p class="text-muted small mt-3 mb-0">
One run is created per month still due, oldest first. The in-service month counts as a full month and the
disposal month is not charged. Each run becomes one GL entry that Accounting posts from
<strong>Batch GL Entries</strong>, labelled <strong>[Asset Mgmt]</strong>.
</p>
<div id="preview_area" class="mt-4"></div>
</div>
</div>
<!-- Runs -->
<div class="card">
<div class="card-body p-5">
<div class="d-flex justify-content-between align-items-center mb-4 gap-3">
<h2 class="fs-5 mb-0">Runs</h2>
<select id="filter_class" class="form-select form-select-sm" style="width:220px;">
<option value="">All types</option>
<option value="tangible">Depreciation</option>
<option value="intangible">Amortization</option>
</select>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0 table-centered text-nowrap" id="run_table">
<thead class="table-primary border-light">
<tr>
<th>Run No.</th>
<th>Type</th>
<th>Month</th>
<th>Run Date</th>
<th class="text-end">Assets</th>
<th class="text-end">Amount</th>
<th>GL</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<tr><td colspan="9" class="text-center text-muted py-5">Loading...</td></tr>
</tbody>
<tfoot></tfoot>
</table>
</div>
</div>
</div>
</div>
</main>
<!-- Run detail -->
<div class="modal fade" id="run_detail_modal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="run_detail_title">Run</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="run_detail_body"></div>
</div>
</div>
</div>
<?php require '../include_ending.php'; ?>
<script src="<?php echo $server_url?>assets/js/asset.js"></script>
<script>
var can_admin = ['owner', 'admin'].includes(user_role);
var all_runs = [];
var last_preview = null;
function run_type_label(asset_class) {
return asset_class === 'intangible' ? 'Amortization' : 'Depreciation';
}
function preview_run() {
last_preview = null;
set_btn_state('#btn_run', false);
return ajax_request({
url: server_url + 'asset/api/engine/depreciation_run.php',
autoPrepare: true,
checkRequired: 0,
action: 'preview',
data: { asset_class: $('#asset_class').val(), through_period: $('#through_period').val() },
onSuccess: function(res) {
var runs = res.output || [];
last_preview = { asset_class: $('#asset_class').val(), through_period: $('#through_period').val(), runs: runs };
render_preview(runs);
set_btn_state('#btn_run', runs.length > 0);
}
});
}
function render_preview(runs) {
if (!runs.length) {
$('#preview_area').html('<div class="alert alert-success small mb-0"><i class="ti ti-circle-check me-1"></i>Nothing is due through this month.</div>');
return;
}
var total = 0;
var html = '<div class="table-responsive"><table class="table table-sm mb-0 table-centered">'
+ '<thead class="table-light"><tr><th>Month</th><th>Run Date</th><th class="text-end">Assets</th><th class="text-end">Amount</th><th></th></tr></thead><tbody>';
runs.forEach(function(r, idx) {
total += parseFloat(r.total) || 0;
html += '<tr>'
+ '<td class="fw-semibold">' + escape_html(r.period) + '</td>'
+ '<td>' + format_date(r.run_date) + '</td>'
+ '<td class="text-end">' + r.asset_count + '</td>'
+ '<td class="text-end">' + format_number(r.total, 2) + '</td>'
+ '<td class="text-end"><a href="javascript:;" class="small" onclick="$(\'#preview_items_' + idx + '\').toggleClass(\'d-none\')">Assets</a></td>'
+ '</tr>'
+ '<tr id="preview_items_' + idx + '" class="d-none"><td colspan="5" class="bg-light">'
+ items_table(r.items, false)
+ '</td></tr>';
});
html += '</tbody><tfoot><tr class="fw-semibold"><td colspan="3">Total</td><td class="text-end">' + format_number(total, 2) + '</td><td></td></tr></tfoot></table></div>';
$('#preview_area').html(html);
}
function items_table(items, with_dept) {
var html = '<table class="table table-sm mb-0"><thead><tr><th>Asset</th><th>Category</th>'
+ (with_dept ? '<th>Dept</th>' : '')
+ '<th class="text-end">Amount</th><th class="text-end">Accumulated</th><th class="text-end">Book Value</th></tr></thead><tbody>';
(items || []).forEach(function(i) {
html += '<tr>'
+ '<td>' + escape_html(i.asset_number) + ' <span class="text-muted">' + escape_html(i.asset_name) + '</span></td>'
+ '<td>' + escape_html((i.category_code ? i.category_code + ' ' : '') + (i.category_name || '')) + '</td>'
+ (with_dept ? '<td>' + escape_html(i.dept_code || '—') + '</td>' : '')
+ '<td class="text-end">' + format_number(i.amount, 2) + '</td>'
+ '<td class="text-end">' + format_number(i.accumulated_after, 2) + '</td>'
+ '<td class="text-end">' + format_number(i.book_value_after, 2) + '</td>'
+ '</tr>';
});
return html + '</tbody></table>';
}
function create_run() {
if (!last_preview || !last_preview.runs.length) return;
var total = last_preview.runs.reduce(function(s, r) { return s + (parseFloat(r.total) || 0); }, 0);
bootbox.confirm({
message: 'Create <strong>' + last_preview.runs.length + '</strong> ' + run_type_label(last_preview.asset_class).toLowerCase()
+ ' run(s) through ' + escape_html(last_preview.through_period) + ', totalling <strong>' + format_number(total, 2) + '</strong>?',
buttons: { confirm: { label: 'Create Runs', className: 'btn-primary' }, cancel: { label: 'Back', className: 'btn-secondary' } },
callback: function(ok) {
if (!ok) return;
ajax_request({
url: server_url + 'asset/api/engine/depreciation_run.php',
autoPrepare: true,
checkRequired: 0,
action: 'run',
data: { asset_class: last_preview.asset_class, through_period: last_preview.through_period },
onSuccess: function(res) {
toastr.success(res.message || 'Runs created.');
$('#preview_area').empty();
set_btn_state('#btn_run', false);
last_preview = null;
load_runs();
}
});
}
});
}
function load_runs() {
return ajax_request({
url: server_url + 'asset/api/engine/depreciation_run.php',
autoPrepare: true,
checkRequired: 0,
action: 'list',
data: { asset_class: '' },
onSuccess: function(res) {
all_runs = res.output || [];
change_page(1);
}
});
}
function change_page(page_num) {
var filter = $('#filter_class').val();
var rows = all_runs.filter(function(r) { return !filter || r.asset_class === filter; });
var limit = parseInt(prop_limit, 10);
var offset = (page_num - 1) * limit;
$('#run_table tfoot').html(generate_pagination('run_table', rows.length));
if (!rows.length) {
$('#run_table tbody').html('<tr><td colspan="9" class="text-center text-muted py-5">No runs yet.</td></tr>');
return;
}
var html = '';
rows.slice(offset, offset + limit).forEach(function(r) {
var active = String(r.status) === '1';
html += '<tr class="' + (active ? '' : 'text-muted') + '">'
+ '<td class="fw-semibold">' + escape_html(r.run_number) + '</td>'
+ '<td>' + run_type_label(r.asset_class) + '</td>'
+ '<td>' + escape_html(r.period) + '</td>'
+ '<td>' + format_date(r.run_date) + '</td>'
+ '<td class="text-end">' + r.asset_count + '</td>'
+ '<td class="text-end">' + format_number(r.total_amount, 2) + '</td>'
+ '<td>' + (active ? asset_gl_badge(r.gl_status) : '—') + '</td>'
+ '<td>' + (active ? '<span class="badge bg-success">Active</span>' : '<span class="badge bg-light text-dark">Void</span>') + '</td>'
+ '<td class="text-end">'
+ (active ? '<a href="javascript:;" onclick="show_run(' + r.id + ')"><i class="ti ti-eye fs-5"></i></a>' : '')
+ (active && can_admin ? '<a href="javascript:;" class="link-danger ms-2" title="Void run" onclick="void_run(' + r.id + ', \'' + escape_html(r.run_number) + '\', ' + r.gl_status + ')"><i class="ti ti-ban fs-5"></i></a>' : '')
+ '</td></tr>';
});
$('#run_table tbody').html(html);
}
function show_run(run_id) {
$('#run_detail_body').html('<div class="text-center text-muted py-5">Loading...</div>');
$('#run_detail_modal').modal('show');
ajax_request({
url: server_url + 'asset/api/engine/depreciation_run.php',
autoPrepare: true,
checkRequired: 0,
action: 'detail',
data: { run_id: run_id },
onSuccess: function(res) {
var run = res.output.run;
$('#run_detail_title').text(run.run_number + ' · ' + run_type_label(run.asset_class) + ' ' + run.period);
$('#run_detail_body').html(
'<div class="small text-muted mb-3">Run date ' + format_date(run.run_date) + ' · '
+ run.asset_count + ' assets · total ' + format_number(run.total_amount, 2) + '</div>'
+ items_table(res.output.items, true)
);
}
});
}
function void_run(run_id, run_number, gl_status) {
var note = String(gl_status) === '1'
? '<br><span class="text-danger">Its GL entry is posted, so a reversal entry will be created.</span>'
: '';
bootbox.confirm({
message: '<strong>Void run ' + run_number + '?</strong><br>Its charges are removed from the assets.' + note,
buttons: { confirm: { label: 'Void', className: 'btn-danger' }, cancel: { label: 'Back', className: 'btn-secondary' } },
callback: function(ok) {
if (!ok) return;
ajax_request({
url: server_url + 'asset/api/engine/depreciation_run.php',
autoPrepare: true,
checkRequired: 0,
action: 'void',
data: { run_id: run_id },
onSuccess: function(res) {
toastr.success(res.message || 'Run voided.');
load_runs();
}
});
}
});
}
$('#filter_class').on('change', function() { change_page(1); });
$('#asset_class, #through_period').on('change', function() {
last_preview = null;
set_btn_state('#btn_run', false);
$('#preview_area').empty();
});
$(async function() {
var params = new URLSearchParams(window.location.search);
if (params.get('class') === 'intangible' || params.get('class') === 'tangible') {
$('#asset_class').val(params.get('class'));
}
flatpickr('#through_period', {
plugins: [new monthSelectPlugin({ shorthand: true, dateFormat: 'Y-m', altFormat: 'M Y' })],
defaultDate: current_period() + '-01',
maxDate: new Date()
});
$('#through_period').val(current_period());
try { await load_runs(); } catch (e) { console.log(e); }
});
</script>
</body>
</html>
-174
View File
@@ -1,174 +0,0 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_sidebar_asset.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<!-- Page header -->
<div class="row">
<div class="col-12">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-5 gap-3">
<div>
<h1 class="fs-3 mb-1">Asset Register</h1>
<p class="mb-0">Every asset with its cost, accumulated depreciation and book value</p>
</div>
<div class="d-flex gap-2">
<a href="<?php echo $server_url?>asset/purchase_invoices.php" class="btn btn-outline-primary">
<i class="ti ti-file-invoice me-1"></i>From Purchase Invoice
</a>
<a href="<?php echo $server_url?>asset/manage_asset.php" class="btn btn-primary">
<i class="ti ti-plus me-1"></i>New Asset
</a>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card overflow-hidden">
<div class="card-body p-6">
<div class="mb-5 d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3">
<ul class="nav nav-pills small" id="status_filter">
<li class="nav-item"><a class="nav-link active" href="javascript:;" data-status="">All <span class="count"></span></a></li>
<li class="nav-item"><a class="nav-link" href="javascript:;" data-status="0">Draft <span class="count"></span></a></li>
<li class="nav-item"><a class="nav-link" href="javascript:;" data-status="1">Active <span class="count"></span></a></li>
<li class="nav-item"><a class="nav-link" href="javascript:;" data-status="2">Fully depreciated <span class="count"></span></a></li>
<li class="nav-item"><a class="nav-link" href="javascript:;" data-status="3">Disposed <span class="count"></span></a></li>
<li class="nav-item"><a class="nav-link" href="javascript:;" data-status="4">Void <span class="count"></span></a></li>
</ul>
<div class="position-relative" style="min-width:240px;">
<input type="search" id="search_asset" class="form-control ps-9" placeholder="Search number, name, category…">
<span class="position-absolute top-25 ms-4">
<i class="ti ti-search text-muted"></i>
</span>
</div>
</div>
<div class="table-responsive">
<table class="table mb-0 text-nowrap table-hover table-centered" id="asset_table">
<thead class="table-primary border-light">
<tr>
<th>Asset No.</th>
<th>Name</th>
<th>Category</th>
<th>In Service</th>
<th class="text-end">Cost</th>
<th class="text-end">Accumulated</th>
<th class="text-end">Book Value</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
<tfoot></tfoot>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
<script src="<?php echo $server_url?>assets/js/asset.js"></script>
<script>
var all_assets = [];
var search_term = '';
var status_filter = new URLSearchParams(window.location.search).get('status') || '';
function retrieve_assets() {
return ajax_request({
url: server_url + 'asset/api/engine/asset.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
all_assets = res.output || [];
update_counts();
change_page(1);
}
});
}
function update_counts() {
$('#status_filter a').each(function() {
var s = String($(this).data('status'));
var n = s === '' ? all_assets.length : all_assets.filter(function(a) { return String(a.status) === s; }).length;
$(this).find('.count').text('(' + n + ')');
$(this).toggleClass('active', s === status_filter);
});
}
function filtered_assets() {
var kw = search_term.toLowerCase();
return all_assets.filter(function(a) {
if (status_filter !== '' && String(a.status) !== status_filter) return false;
if (!kw) return true;
return [a.asset_number, a.asset_name, a.category_code, a.category_name, a.invoice_number]
.some(function(v) { return String(v || '').toLowerCase().indexOf(kw) !== -1; });
});
}
function change_page(page_num) {
var rows = filtered_assets();
var limit = parseInt(prop_limit, 10);
var offset = (page_num - 1) * limit;
$('table#asset_table tfoot').html(generate_pagination('asset_table', rows.length));
if (!rows.length) {
$('table#asset_table tbody').html('<tr><td colspan="9" class="text-center py-5 text-muted">No assets found.</td></tr>');
return;
}
var body = '';
rows.slice(offset, offset + limit).forEach(function(a) {
var source = a.invoice_number
? '<div class="small text-muted"><i class="ti ti-file-invoice me-1"></i>' + escape_html(a.invoice_number) + '</div>'
: '';
body += `<tr>
<td class="py-3 fw-semibold">${escape_html(a.asset_number)}</td>
<td class="py-3">${escape_html(a.asset_name)}${source}</td>
<td class="py-3">${escape_html(a.category_code)} <span class="text-muted">${escape_html(a.category_name)}</span></td>
<td class="py-3">${format_date(a.in_service_date)}</td>
<td class="py-3 text-end">${format_number(a.cost, 2)}</td>
<td class="py-3 text-end">${format_number(a.accumulated, 2)}</td>
<td class="py-3 text-end">${format_number(a.book_value, 2)}</td>
<td class="py-3">${asset_status_badge(a.status, a.asset_class)}</td>
<td class="py-3"><a href="${server_url}asset/manage_asset.php?id=${a.id}"><i class="ti ti-eye fs-5"></i></a></td>
</tr>`;
});
$('table#asset_table tbody').html(body);
}
$('#status_filter').on('click', 'a', function() {
status_filter = String($(this).data('status'));
update_counts();
change_page(1);
});
$('#search_asset').on('input', debounce(function() {
search_term = $(this).val().trim();
change_page(1);
}, 200));
$(async function() {
try { await retrieve_assets(); } catch (e) { console.log(e); }
});
</script>
</body>
</html>
-595
View File
@@ -1,595 +0,0 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
$asset_id = (int)($_GET['id'] ?? 0);
$invoice_id = (int)($_GET['invoice_id'] ?? 0);
$item_id = (int)($_GET['item_id'] ?? 0);
$pi_link_ok = in_array($_SESSION['login_app_access'] ?? 'wms', ['all', 'accounting'], true);
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_sidebar_asset.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="mb-6 d-flex justify-content-between align-items-center gap-3">
<div>
<h1 class="fs-3 mb-1" id="page_title">New Asset</h1>
<p class="mb-0 text-muted" id="page_subtitle">Straight-line depreciation with your own salvage value</p>
</div>
<a href="<?php echo $server_url?>asset/index.php" class="btn btn-light">
<i class="ti ti-arrow-left me-1"></i>Back
</a>
</div>
</div>
</div>
<div class="row g-5">
<!-- Left: form + schedule -->
<div class="col-lg-8">
<div class="alert alert-info small d-none" id="source_alert"></div>
<div class="card mb-5" id="assetForm">
<div class="card-body p-5">
<h2 class="fs-5 mb-4">Asset Details</h2>
<input type="hidden" id="id" value="<?php echo $asset_id ?: ''; ?>">
<div class="row g-4">
<div class="col-md-8">
<label class="form-label">Asset Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="asset_name" required>
</div>
<div class="col-md-4">
<label class="form-label">Department</label>
<select class="form-select" id="department_id"></select>
</div>
<div class="col-md-6">
<label class="form-label">Category <span class="text-danger">*</span></label>
<select class="form-select" id="category_id" required></select>
<div class="form-text" id="class_hint"></div>
</div>
<div class="col-md-6">
<label class="form-label">Clearing Account</label>
<select class="form-select" id="clearing_account_code"></select>
<div class="form-text">Credited when the asset is capitalized — the account the purchase was posted to. Leave empty if the asset is already on the books.</div>
</div>
<div class="col-md-3">
<label class="form-label">Acquisition Date <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="acquisition_date" placeholder="DD/MM/YYYY" required>
</div>
<div class="col-md-3">
<label class="form-label">In-Service Date <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="in_service_date" placeholder="DD/MM/YYYY" required>
</div>
<div class="col-md-6"></div>
<div class="col-md-4">
<label class="form-label">Cost <span class="text-danger">*</span></label>
<input type="number" class="form-control text-end" id="cost" min="0" step="0.01" required>
</div>
<div class="col-md-4">
<label class="form-label">Salvage Value</label>
<input type="number" class="form-control text-end" id="salvage_value" min="0" step="0.01" value="0">
</div>
<div class="col-md-4">
<label class="form-label">Useful Life (months) <span class="text-danger">*</span></label>
<input type="number" class="form-control text-end" id="useful_life_months" min="1" max="1200" step="1" required>
</div>
<div class="col-12">
<div class="bg-light rounded-2 px-4 py-3 d-flex flex-wrap gap-4 small">
<div><span class="text-muted">Amount to <span class="label-charge">depreciate</span>:</span> <strong id="calc_base">—</strong></div>
<div><span class="text-muted">Per month:</span> <strong id="calc_monthly">—</strong></div>
<div><span class="text-muted">Last month:</span> <strong id="calc_last">—</strong></div>
</div>
</div>
<div class="col-12">
<label class="form-label">Description</label>
<textarea class="form-control" id="description" rows="2" placeholder="Serial number, location, notes"></textarea>
</div>
</div>
<div class="form-text mt-3 d-none" id="locked_hint">
<i class="ti ti-lock me-1"></i>Cost, salvage value, life, dates and category are locked because months have already been charged.
</div>
</div>
</div>
<div class="card d-none" id="schedule_card">
<div class="card-body p-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="fs-5 mb-0"><span class="label-schedule">Depreciation</span> Schedule</h2>
<span class="small text-muted" id="schedule_summary"></span>
</div>
<div class="table-responsive" style="max-height:480px;">
<table class="table table-sm mb-0 table-centered">
<thead class="table-light" style="position:sticky; top:0;">
<tr>
<th>#</th>
<th>Month</th>
<th class="text-end">Amount</th>
<th class="text-end">Accumulated</th>
<th class="text-end">Book Value</th>
<th>State</th>
</tr>
</thead>
<tbody id="schedule_tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Right: status + actions -->
<div class="col-lg-4">
<div class="card mb-5 d-none" id="status_card">
<div class="card-body p-4">
<h2 class="fs-5 mb-3">Status</h2>
<div class="mb-3 d-flex gap-2 flex-wrap" id="status_badges"></div>
<div class="small mb-2"><span class="text-muted">Accumulated:</span> <span class="fw-semibold ms-1" id="display_accumulated">—</span></div>
<div class="small mb-2"><span class="text-muted">Book value:</span> <span class="fw-semibold ms-1" id="display_book_value">—</span></div>
<div class="small mb-2"><span class="text-muted">Last month charged:</span> <span class="ms-1" id="display_last_period">—</span></div>
<div class="small mb-2 d-none" id="source_row"><span class="text-muted">Source:</span> <span class="ms-1" id="display_source"></span></div>
<hr>
<h3 class="fs-6 mb-2">GL Entries <span class="badge bg-success text-white ms-1">[Asset Mgmt]</span></h3>
<div class="small mb-2"><div class="text-muted">Capitalization</div><div id="gl_capitalization"></div></div>
<div class="small mb-2 d-none" id="gl_disposal_row"><div class="text-muted">Disposal</div><div id="gl_disposal"></div></div>
<div class="d-none" id="disposal_info">
<hr>
<h3 class="fs-6 mb-2">Disposal</h3>
<div class="small mb-1"><span class="text-muted">Date:</span> <span class="ms-1" id="display_disposal_date"></span></div>
<div class="small mb-1"><span class="text-muted">Sale price:</span> <span class="ms-1" id="display_sale_price"></span></div>
<div class="small mb-1"><span class="text-muted">Gain / (loss):</span> <span class="ms-1 fw-semibold" id="display_gain_loss"></span></div>
<div class="small mb-1 text-muted" id="display_disposal_notes"></div>
</div>
</div>
</div>
<div class="card">
<div class="card-body p-4 d-flex flex-column gap-2">
<button class="btn btn-primary w-100" id="btn_save" onclick="save_asset()">
<i class="ti ti-device-floppy me-1"></i><span>Save as Draft</span>
</button>
<button class="btn btn-success w-100 d-none" id="btn_activate" onclick="asset_action('activate')">
<i class="ti ti-circle-check me-1"></i>Activate
</button>
<button class="btn btn-outline-dark w-100 d-none" id="btn_dispose" onclick="open_disposal()">
<i class="ti ti-receipt-refund me-1"></i>Sell / Dispose
</button>
<button class="btn btn-outline-warning w-100 d-none" id="btn_undo_disposal" onclick="asset_action('undo_disposal')">
<i class="ti ti-arrow-back-up me-1"></i>Undo Disposal
</button>
<button class="btn btn-outline-danger w-100 d-none" id="btn_void" onclick="asset_action('void')">
<i class="ti ti-ban me-1"></i>Void
</button>
<button class="btn btn-outline-danger w-100 d-none" id="btn_delete" onclick="asset_action('delete')">
<i class="ti ti-trash me-1"></i>Delete Draft
</button>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- Disposal modal -->
<div class="modal fade" id="disposal_modal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Sell / Dispose of Asset</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Disposal Date</label>
<input type="text" class="form-control" id="disposal_date" placeholder="DD/MM/YYYY">
</div>
<div class="col-md-6">
<label class="form-label">Sale Price</label>
<input type="number" class="form-control text-end" id="sale_price" min="0" step="0.01" value="0">
</div>
<div class="col-12">
<label class="form-label">Proceeds Account</label>
<select class="form-select" id="proceeds_account_code"></select>
<div class="form-text">Cash, bank or receivable that received the money. Not needed for a write-off (price 0).</div>
</div>
<div class="col-12">
<label class="form-label">Notes</label>
<textarea class="form-control" id="disposal_notes" rows="2" placeholder="Buyer, reason"></textarea>
</div>
<div class="col-12">
<div class="bg-light rounded-2 px-3 py-2 small">
<div class="d-flex justify-content-between"><span class="text-muted">Cost</span><span id="dp_cost"></span></div>
<div class="d-flex justify-content-between"><span class="text-muted">Accumulated (months charged so far)</span><span id="dp_accumulated"></span></div>
<div class="d-flex justify-content-between"><span class="text-muted">Book value</span><span id="dp_book"></span></div>
<div class="d-flex justify-content-between fw-semibold"><span id="dp_result_label">Gain / (loss)</span><span id="dp_result"></span></div>
</div>
<div class="form-text">Every month before the disposal month must already be charged. The disposal month itself is not charged.</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-bs-dismiss="modal">Back</button>
<button class="btn btn-dark" onclick="submit_disposal()"><i class="ti ti-receipt-refund me-1"></i>Record Disposal</button>
</div>
</div>
</div>
</div>
<?php require '../include_ending.php'; ?>
<script src="<?php echo $server_url?>assets/js/asset.js"></script>
<script>
var asset_id = <?php echo $asset_id; ?>;
var invoice_id = <?php echo $invoice_id; ?>;
var item_id = <?php echo $item_id; ?>;
var pi_link_ok = <?php echo $pi_link_ok ? 'true' : 'false'; ?>;
var can_admin = ['owner', 'admin'].includes(user_role);
var can_edit = ['owner', 'admin', 'staff'].includes(user_role);
var categories = [];
var accounts = [];
var detail = null;
var invoice_line = null;
var life_touched = false;
var FINANCIAL_FIELDS = '#category_id, #acquisition_date, #in_service_date, #cost, #salvage_value, #useful_life_months, #clearing_account_code';
// ── lookups ────────────────────────────────────────────────────────────────
function load_categories() {
return ajax_request({
url: server_url + 'asset/api/engine/category.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
queueLock: false,
noLoading: true,
onSuccess: function(res) { categories = res.output || []; }
});
}
function category_by_id(id) {
return categories.find(function(c) { return String(c.id) === String(id); }) || null;
}
function fill_category_select(selected) {
var html = '<option value="">— Select category —</option>';
categories.forEach(function(c) {
if (String(c.status) !== '1' && String(c.id) !== String(selected)) return;
html += '<option value="' + c.id + '">' + escape_html(c.category_code + ' — ' + c.category_name)
+ (c.asset_class === 'intangible' ? ' (intangible)' : '') + '</option>';
});
$('#category_id').html(html).val(selected ? String(selected) : '');
}
function update_class_hint() {
var c = category_by_id($('#category_id').val());
var intangible = c && c.asset_class === 'intangible';
$('#class_hint').html(c ? asset_class_badge(c.asset_class) : '');
$('.label-charge').text(intangible ? 'amortize' : 'depreciate');
$('.label-schedule').text(intangible ? 'Amortization' : 'Depreciation');
}
// ── live calculation ───────────────────────────────────────────────────────
function update_calc() {
var cost = parseFloat($('#cost').val()) || 0;
var salvage = parseFloat($('#salvage_value').val()) || 0;
var life = parseInt($('#useful_life_months').val(), 10) || 0;
var base = round_dp(cost - salvage, 2);
if (base <= 0 || life <= 0) {
$('#calc_base, #calc_monthly, #calc_last').text('—');
return;
}
var monthly = round_dp(base / life, 2);
var last = round_dp(base - monthly * (life - 1), 2);
$('#calc_base').text(format_number(base, 2));
$('#calc_monthly').text(format_number(monthly, 2));
$('#calc_last').text(life > 1 ? format_number(Math.max(last, 0), 2) : format_number(base, 2));
}
// ── load ───────────────────────────────────────────────────────────────────
function retrieve_asset() {
return ajax_request({
url: server_url + 'asset/api/engine/retrieve_asset.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
data: { id: asset_id },
onSuccess: function(res) {
detail = res.output;
render_detail();
}
});
}
function load_invoice_line() {
return ajax_request({
url: server_url + 'asset/api/engine/invoice_lines.php',
autoPrepare: true,
checkRequired: 0,
action: 'line',
data: { invoice_id: invoice_id, item_id: item_id },
onSuccess: function(res) {
invoice_line = res.output;
var l = invoice_line;
$('#asset_name').val(l.product_name || l.product_sku);
$('#acquisition_date, #in_service_date').val(format_date_input(l.issued_date));
$('#cost').val(Math.max(parseFloat(l.remaining) || 0, 0).toFixed(2));
$('#clearing_account_code').html(account_options(accounts, [], l.clearing_account_code, '— None —'));
load_departments('department_id', l.department_id);
$('#source_alert').removeClass('d-none').html(
'<i class="ti ti-file-invoice me-1"></i>From purchase invoice <strong>' + escape_html(l.invoice_number) + '</strong>'
+ ' (' + escape_html(l.contact_name || '—') + '), line <strong>' + escape_html(l.product_sku) + '</strong>: '
+ 'amount ' + format_number(l.line_amount, 2) + ', already capitalized ' + format_number(l.capitalized, 2)
+ ', remaining <strong>' + format_number(l.remaining, 2) + '</strong>.'
+ (l.clearing_account_code ? '' : ' <span class="text-danger">Select the account the invoice line was posted to as the clearing account.</span>')
);
$('#page_subtitle').text('Capitalizing a purchase invoice line');
update_calc();
}
});
}
function render_detail() {
var a = detail.asset;
var status = parseInt(a.status, 10);
var recorded = detail.schedule.some(function(r) { return r.state === 'recorded'; });
var closed = status === 3 || status === 4;
$('#page_title').text(a.asset_number + ' · ' + a.asset_name);
$('#page_subtitle').text(a.category_code + ' ' + a.category_name);
$('#id').val(a.id);
$('#asset_name').val(a.asset_name);
fill_category_select(a.category_id);
update_class_hint();
$('#clearing_account_code').html(account_options(accounts, [], a.clearing_account_code, '— None —'));
$('#acquisition_date').val(format_date_input(a.acquisition_date));
$('#in_service_date').val(format_date_input(a.in_service_date));
$('#cost').val(parseFloat(a.cost).toFixed(2));
$('#salvage_value').val(parseFloat(a.salvage_value).toFixed(2));
$('#useful_life_months').val(a.useful_life_months);
$('#description').val(a.description || '');
load_departments('department_id', a.department_id);
life_touched = true;
update_calc();
// Status card
$('#status_card').removeClass('d-none');
$('#status_badges').html(asset_status_badge(a.status, a.asset_class) + asset_class_badge(a.asset_class));
$('#display_accumulated').text(format_number(a.accumulated, 2));
$('#display_book_value').text(format_number(a.book_value, 2));
$('#display_last_period').text(a.last_period || '—');
if (parseInt(a.invoice_id, 10) > 0) {
var ref = escape_html(a.invoice_number || ('PI #' + a.invoice_id));
$('#display_source').html(pi_link_ok
? '<a href="' + server_url + 'expense/manage_purchase_invoice.php?id=' + a.invoice_id + '">' + ref + '</a>'
: ref);
$('#source_row').removeClass('d-none');
}
// GL entries
var cap_html;
if (!a.clearing_account_code) {
cap_html = '<span class="text-muted">No entry — no clearing account.</span>';
} else if (status === 0) {
cap_html = '<span class="text-muted">Activate the asset, then Accounting posts it.</span>';
} else {
cap_html = gl_entry_html(detail.gl.capitalization);
}
$('#gl_capitalization').html(cap_html);
$('#gl_disposal_row').toggleClass('d-none', status !== 3);
$('#gl_disposal').html(gl_entry_html(detail.gl.disposal));
// Disposal
if (status === 3) {
var gain = round_dp(parseFloat(a.sale_price) - (parseFloat(a.cost) - parseFloat(a.accumulated)), 2);
$('#display_disposal_date').text(format_date(a.disposal_date));
$('#display_sale_price').text(format_number(a.sale_price, 2));
$('#display_gain_loss').text(gain < 0 ? '(' + format_number(-gain, 2) + ')' : format_number(gain, 2))
.toggleClass('text-danger', gain < 0).toggleClass('text-success', gain > 0);
$('#display_disposal_notes').text(a.disposal_notes || '');
$('#disposal_info').removeClass('d-none');
} else {
$('#disposal_info').addClass('d-none');
}
render_schedule();
// Editing rules
$(FINANCIAL_FIELDS).prop('disabled', closed || recorded || !can_edit);
$('#asset_name, #description, #department_id').prop('disabled', closed || !can_edit);
$('#locked_hint').toggleClass('d-none', !(recorded && !closed));
$('#btn_save').toggleClass('d-none', closed || !can_edit).find('span').text(status === 0 ? 'Save Draft' : 'Save');
$('#btn_activate').toggleClass('d-none', !(status === 0 && can_admin));
$('#btn_dispose').toggleClass('d-none', !((status === 1 || status === 2) && can_admin));
$('#btn_undo_disposal').toggleClass('d-none', !(status === 3 && can_admin));
$('#btn_void').toggleClass('d-none', !((status === 1 || status === 2) && can_admin && !recorded));
$('#btn_delete').toggleClass('d-none', !(status === 0 && can_admin));
}
function gl_entry_html(entry) {
if (entry) {
return '<span class="badge bg-success-subtle text-success">Posted</span>'
+ ' <span class="text-muted ms-1">' + escape_html(entry.reference) + ' · ' + format_date(entry.journal_date) + '</span>';
}
return '<span class="badge bg-danger-subtle text-danger">Not posted</span>'
+ ' <span class="text-muted ms-1">Accounting → Batch GL Entries</span>';
}
function render_schedule() {
var rows = detail.schedule || [];
if (!rows.length) { $('#schedule_card').addClass('d-none'); return; }
var state_badge = {
recorded: function(r) { return '<span class="badge bg-success-subtle text-success">Charged</span> <span class="small text-muted">' + escape_html(r.run_number || '') + '</span>'; },
projected: function() { return '<span class="badge bg-light text-dark">Projected</span>'; },
stopped: function() { return '<span class="badge bg-secondary-subtle text-secondary">Not charged</span>'; }
};
var html = '';
var charged = 0;
rows.forEach(function(r, i) {
if (r.state === 'recorded') charged++;
html += '<tr class="' + (r.state === 'stopped' ? 'text-muted' : '') + '">'
+ '<td>' + (i + 1) + '</td>'
+ '<td>' + escape_html(r.period) + '</td>'
+ '<td class="text-end">' + format_number(r.amount, 2) + '</td>'
+ '<td class="text-end">' + format_number(r.accumulated, 2) + '</td>'
+ '<td class="text-end">' + format_number(r.book_value, 2) + '</td>'
+ '<td>' + state_badge[r.state](r) + '</td>'
+ '</tr>';
});
$('#schedule_tbody').html(html);
$('#schedule_summary').text(charged + ' of ' + rows.length + ' months charged · ' + format_number(detail.monthly_amount, 2) + ' per month');
$('#schedule_card').removeClass('d-none');
}
// ── actions ────────────────────────────────────────────────────────────────
function save_asset() {
return ajax_request({
url: server_url + 'asset/api/engine/manage_asset.php',
autoPrepare: true,
checkRequired: 1,
action: 'manage',
data: { id: asset_id || '', invoice_id: invoice_id, invoice_item_id: item_id },
onSuccess: function(res) {
var saved_id = res.output && res.output.id;
if (!asset_id && saved_id) {
window.location.href = server_url + 'asset/manage_asset.php?id=' + saved_id;
return;
}
toastr.success(res.message || 'Saved.');
retrieve_asset();
}
});
}
var ACTION_CONFIRM = {
activate: ['Activate this asset? It will start appearing in depreciation runs from its in-service month.', 'Activate', 'btn-success'],
undo_disposal: ['Undo the disposal? If its GL entry was posted, a reversal entry is created.', 'Undo Disposal', 'btn-warning'],
void: ['<strong>Void this asset?</strong><br>If its capitalization was posted, a reversal entry is created. This cannot be undone.', 'Void', 'btn-danger'],
delete: ['Delete this draft asset?', 'Delete', 'btn-danger']
};
function asset_action(action) {
var c = ACTION_CONFIRM[action];
bootbox.confirm({
message: c[0],
buttons: { confirm: { label: c[1], className: c[2] }, cancel: { label: 'Back', className: 'btn-secondary' } },
callback: function(ok) {
if (!ok) return;
ajax_request({
url: server_url + 'asset/api/engine/manage_asset.php',
autoPrepare: false,
action: action,
data: { json: JSON.stringify({
otp: document.getElementById('session-context').dataset.otp,
company_id: company_id,
action: action,
id: asset_id
}) },
onSuccess: function(res) {
if (action === 'delete') { window.location.href = server_url + 'asset/index.php'; return; }
toastr.success(res.message || 'Done.');
retrieve_asset();
}
});
}
});
}
function open_disposal() {
var a = detail.asset;
$('#proceeds_account_code').html(account_options(accounts, ['asset'], '', '— None (write-off) —'));
$('#sale_price').val('0');
$('#disposal_notes').val('');
$('#dp_cost').text(format_number(a.cost, 2));
$('#dp_accumulated').text(format_number(a.accumulated, 2));
$('#dp_book').text(format_number(a.book_value, 2));
update_disposal_result();
$('#disposal_modal').modal('show');
}
function update_disposal_result() {
if (!detail) return;
var gain = round_dp((parseFloat($('#sale_price').val()) || 0) - parseFloat(detail.asset.book_value), 2);
$('#dp_result_label').text(gain < 0 ? 'Loss' : 'Gain');
$('#dp_result').text(format_number(Math.abs(gain), 2)).toggleClass('text-danger', gain < 0).toggleClass('text-success', gain > 0);
}
function submit_disposal() {
ajax_request({
url: server_url + 'asset/api/engine/manage_asset.php',
autoPrepare: false,
action: 'dispose',
data: { json: JSON.stringify({
otp: document.getElementById('session-context').dataset.otp,
company_id: company_id,
action: 'dispose',
id: asset_id,
disposal_date: $('#disposal_date').val(),
sale_price: $('#sale_price').val(),
proceeds_account_code: $('#proceeds_account_code').val(),
disposal_notes: $('#disposal_notes').val()
}) },
onSuccess: function(res) {
$('#disposal_modal').modal('hide');
toastr.success(res.message || 'Disposal recorded.');
retrieve_asset();
}
});
}
// ── events ─────────────────────────────────────────────────────────────────
$('#cost, #salvage_value, #useful_life_months').on('input', update_calc);
$('#useful_life_months').on('input', function() { life_touched = true; });
$('#category_id').on('change', function() {
var c = category_by_id($(this).val());
if (c && !life_touched) {
$('#useful_life_months').val(c.useful_life_months);
update_calc();
}
update_class_hint();
});
$('#sale_price').on('input', update_disposal_result);
$(async function() {
flatpickr('#acquisition_date, #in_service_date, #disposal_date', { dateFormat: 'd/m/Y', allowInput: true });
try {
await load_categories();
await load_posting_accounts(function(rows) { accounts = rows; });
fill_category_select('');
$('#clearing_account_code').html(account_options(accounts, [], '', '— None —'));
if (asset_id) {
await retrieve_asset();
} else {
load_departments('department_id', 0);
if (invoice_id && item_id) await load_invoice_line();
if (!can_edit) $('#btn_save').addClass('d-none');
}
} catch (e) { console.log(e); }
});
</script>
</body>
</html>
-179
View File
@@ -1,179 +0,0 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_sidebar_asset.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="d-flex align-items-center mb-5 gap-3">
<a href="<?php echo $server_url?>asset/categories.php" class="btn btn-light btn-sm">
<i class="ti ti-arrow-left me-1"></i>Back
</a>
<h1 class="fs-3 mb-0">Manage Asset Category</h1>
</div>
</div>
</div>
<div class="row" id="categoryForm">
<div class="col-lg-8">
<div class="card">
<div class="card-body p-6">
<input type="hidden" id="id">
<div class="row g-4">
<div class="col-md-3">
<label class="form-label">Code <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="category_code" placeholder="e.g. IT" required>
</div>
<div class="col-md-5">
<label class="form-label">Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="category_name" placeholder="e.g. Computer equipment" required>
</div>
<div class="col-md-4">
<label class="form-label">Status</label>
<select class="form-select" id="status">
<option value="1">Active</option>
<option value="0">Inactive</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Asset Class <span class="text-danger">*</span></label>
<select class="form-select" id="asset_class" required>
<option value="tangible">Tangible — straight-line depreciation</option>
<option value="intangible">Intangible — straight-line amortization</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Default Useful Life (months) <span class="text-danger">*</span></label>
<input type="number" class="form-control" id="useful_life_months" min="1" max="1200" step="1" value="60" required>
</div>
<div class="col-12">
<h2 class="fs-6 mb-0 mt-2">GL Accounts</h2>
<p class="text-muted small mb-0">Used when Accounting posts this category's entries from Batch GL Entries.</p>
</div>
<div class="col-md-6">
<label class="form-label">Asset Cost Account <span class="text-danger">*</span></label>
<select class="form-select" id="cost_account_code" required></select>
</div>
<div class="col-md-6">
<label class="form-label"><span class="label-accum">Accumulated Depreciation</span> Account <span class="text-danger">*</span></label>
<select class="form-select" id="accum_account_code" required></select>
</div>
<div class="col-md-6">
<label class="form-label"><span class="label-expense">Depreciation</span> Expense Account <span class="text-danger">*</span></label>
<select class="form-select" id="expense_account_code" required></select>
</div>
<div class="col-md-6"></div>
<div class="col-md-6">
<label class="form-label">Gain on Disposal Account <span class="text-danger">*</span></label>
<select class="form-select" id="gain_account_code" required></select>
</div>
<div class="col-md-6">
<label class="form-label">Loss on Disposal Account <span class="text-danger">*</span></label>
<select class="form-select" id="loss_account_code" required></select>
</div>
<div class="col-12">
<label class="form-label">Description</label>
<textarea class="form-control" id="description" rows="2" placeholder="Optional"></textarea>
</div>
</div>
<div class="d-flex gap-2 mt-5">
<button class="btn btn-primary" onclick="manage_category();" id="btn_submit" data-min-role="admin">Create Category</button>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
<script src="<?php echo $server_url?>assets/js/asset.js"></script>
<script>
var category_id = <?php echo (int)($_GET['id'] ?? 0); ?>;
var accounts = [];
var ACCOUNT_SELECTS = {
cost_account_code: ['asset'],
accum_account_code: ['asset'],
expense_account_code: ['expense'],
gain_account_code: ['revenue'],
loss_account_code: ['expense']
};
function fill_account_selects(values) {
Object.keys(ACCOUNT_SELECTS).forEach(function(field) {
$('#' + field).html(account_options(accounts, ACCOUNT_SELECTS[field], (values || {})[field] || ''));
});
}
function update_class_labels() {
var intangible = $('#asset_class').val() === 'intangible';
$('.label-accum').text(intangible ? 'Accumulated Amortization' : 'Accumulated Depreciation');
$('.label-expense').text(intangible ? 'Amortization' : 'Depreciation');
}
function manage_category() {
return ajax_request({
url: server_url + 'asset/api/engine/manage_category.php',
autoPrepare: true,
checkRequired: 1,
action: 'manage',
onSuccess: function() {
window.location.href = server_url + 'asset/categories.php';
}
});
}
function retrieve_for_edit() {
if (!category_id) return Promise.resolve();
return ajax_request({
url: server_url + 'asset/api/engine/retrieve_category.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
data: { id: category_id },
onSuccess: function(res) {
var c = res.output;
$('#id').val(c.id);
$('#category_code').val(c.category_code);
$('#category_name').val(c.category_name);
$('#asset_class').val(c.asset_class);
$('#useful_life_months').val(c.useful_life_months);
$('#description').val(c.description || '');
$('#status').val(c.status);
fill_account_selects(c);
update_class_labels();
$('#btn_submit').text('Update Category');
}
});
}
$('#asset_class').on('change', update_class_labels);
$(async function() {
try {
await load_posting_accounts(function(rows) { accounts = rows; fill_account_selects({}); });
await retrieve_for_edit();
} catch (e) { console.log(e); }
});
</script>
</body>
</html>
-173
View File
@@ -1,173 +0,0 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
$pi_link_ok = in_array($_SESSION['login_app_access'] ?? 'wms', ['all', 'accounting'], true);
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_sidebar_asset.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="mb-5">
<h1 class="fs-3 mb-1">Assets from Purchase Invoices</h1>
<p class="mb-0">Issued purchase invoice lines (AP) that can be capitalized as assets</p>
</div>
</div>
</div>
<div class="card mb-5">
<div class="card-body p-4">
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label mb-1 small">Invoice Date From</label>
<input type="text" id="date_from" class="form-control form-control-sm" placeholder="d/m/Y" style="width:140px;">
</div>
<div class="col-auto">
<label class="form-label mb-1 small">Invoice Date To</label>
<input type="text" id="date_to" class="form-control form-control-sm" placeholder="d/m/Y" style="width:140px;">
</div>
<div class="col-auto">
<button class="btn btn-secondary btn-sm" onclick="load_lines()">
<i class="ti ti-search me-1"></i>Load
</button>
</div>
<div class="col-auto ms-3">
<div class="form-check mb-1">
<input class="form-check-input" type="checkbox" id="show_capitalized">
<label class="form-check-label small" for="show_capitalized">Show fully capitalized lines</label>
</div>
</div>
<div class="col ms-auto d-flex justify-content-end">
<div class="position-relative" style="min-width:220px;">
<input type="search" id="search_line" class="form-control form-control-sm ps-8" placeholder="Search invoice, supplier, item…">
<span class="position-absolute top-25 ms-3"><i class="ti ti-search text-muted small"></i></span>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0 table-centered text-nowrap" id="line_table">
<thead class="table-primary border-light">
<tr>
<th>Invoice</th>
<th>Date</th>
<th>Supplier</th>
<th>Item</th>
<th class="text-end">Qty</th>
<th class="text-end">Line Amount</th>
<th class="text-end">Capitalized</th>
<th class="text-end">Remaining</th>
<th></th>
</tr>
</thead>
<tbody>
<tr><td colspan="9" class="text-center text-muted py-5">Use the filters above and click Load.</td></tr>
</tbody>
<tfoot></tfoot>
</table>
</div>
</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
<script src="<?php echo $server_url?>assets/js/asset.js"></script>
<script>
var pi_link_ok = <?php echo $pi_link_ok ? 'true' : 'false'; ?>;
var all_lines = [];
var search_term = '';
function load_lines() {
return ajax_request({
url: server_url + 'asset/api/engine/invoice_lines.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
data: { date_from: $('#date_from').val(), date_to: $('#date_to').val() },
onSuccess: function(res) {
all_lines = res.output || [];
change_page(1);
}
});
}
function visible_lines() {
var show_all = $('#show_capitalized').is(':checked');
var kw = search_term.toLowerCase();
return all_lines.filter(function(l) {
if (!show_all && parseFloat(l.remaining) <= 0) return false;
if (!kw) return true;
return [l.invoice_number, l.contact_name, l.product_sku, l.product_name]
.some(function(v) { return String(v || '').toLowerCase().indexOf(kw) !== -1; });
});
}
function change_page(page_num) {
var rows = visible_lines();
var limit = parseInt(prop_limit, 10);
var offset = (page_num - 1) * limit;
$('#line_table tfoot').html(generate_pagination('line_table', rows.length));
if (!rows.length) {
$('#line_table tbody').html('<tr><td colspan="9" class="text-center text-muted py-5">No invoice lines to capitalize in this date range.</td></tr>');
return;
}
var html = '';
rows.slice(offset, offset + limit).forEach(function(l) {
var invoice = pi_link_ok
? '<a href="' + server_url + 'expense/manage_purchase_invoice.php?id=' + l.invoice_id + '">' + escape_html(l.invoice_number) + '</a>'
: escape_html(l.invoice_number);
var action = parseFloat(l.remaining) > 0
? '<a class="btn btn-sm btn-primary" href="' + server_url + 'asset/manage_asset.php?invoice_id=' + l.invoice_id + '&item_id=' + l.item_id + '">'
+ '<i class="ti ti-plus me-1"></i>Create Asset</a>'
: '<span class="badge bg-success-subtle text-success">Capitalized</span>';
var assets = parseInt(l.asset_count, 10) > 0
? ' <span class="small text-muted">(' + l.asset_count + ' asset' + (l.asset_count == 1 ? '' : 's') + ')</span>'
: '';
html += '<tr>'
+ '<td>' + invoice + '</td>'
+ '<td>' + format_date(l.issued_date) + '</td>'
+ '<td>' + escape_html(l.contact_name || '—') + '</td>'
+ '<td>' + escape_html(l.product_name || l.product_sku) + '<div class="small text-muted">' + escape_html(l.product_sku) + '</div></td>'
+ '<td class="text-end">' + format_number(l.quantity, 2) + '</td>'
+ '<td class="text-end">' + format_number(l.line_amount, 2) + '</td>'
+ '<td class="text-end">' + format_number(l.capitalized, 2) + assets + '</td>'
+ '<td class="text-end fw-semibold">' + format_number(l.remaining, 2) + '</td>'
+ '<td class="text-end">' + action + '</td>'
+ '</tr>';
});
$('#line_table tbody').html(html);
}
$('#show_capitalized').on('change', function() { change_page(1); });
$('#search_line').on('input', debounce(function() {
search_term = $(this).val().trim();
change_page(1);
}, 200));
$(function() {
var today = new Date();
var start = new Date(today.getFullYear(), 0, 1);
flatpickr('#date_from', { dateFormat: 'd/m/Y', allowInput: true, defaultDate: start });
flatpickr('#date_to', { dateFormat: 'd/m/Y', allowInput: true, defaultDate: today });
load_lines().catch(function(e) { console.log(e); });
});
</script>
</body>
</html>
-267
View File
@@ -1,267 +0,0 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_sidebar_asset.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<!-- Page header -->
<div class="row">
<div class="col-12">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-6 gap-3">
<div>
<h1 class="fs-3 mb-1">Asset Management</h1>
<p class="mb-0">Fixed assets, depreciation and amortization</p>
</div>
<div class="d-flex gap-2">
<a href="<?php echo $server_url?>asset/purchase_invoices.php" class="btn btn-outline-primary">
<i class="ti ti-file-invoice me-1"></i>From Purchase Invoice
</a>
<a href="<?php echo $server_url?>asset/manage_asset.php" class="btn btn-primary">
<i class="ti ti-plus me-1"></i>New Asset
</a>
</div>
</div>
</div>
</div>
<!-- Summary cards -->
<div class="row g-5 mb-5">
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4 bg-success bg-opacity-10 border border-success border-opacity-25 rounded-2">
<div class="d-flex gap-3">
<div class="icon-shape icon-md bg-success text-white rounded-2">
<i class="ti ti-building-warehouse fs-4"></i>
</div>
<div>
<h2 class="mb-3 fs-6">Assets in Use</h2>
<h3 class="fw-bold mb-0" id="stat_in_use">—</h3>
<p class="text-success mb-0 small" id="stat_in_use_note">&nbsp;</p>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4 bg-info bg-opacity-10 border border-info border-opacity-25 rounded-2">
<div class="d-flex gap-3">
<div class="icon-shape icon-md bg-info text-white rounded-2">
<i class="ti ti-cash fs-4"></i>
</div>
<div>
<h2 class="mb-3 fs-6">Cost</h2>
<h3 class="fw-bold mb-0" id="stat_cost">—</h3>
<p class="text-info mb-0 small">Assets in use</p>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4 bg-warning bg-opacity-10 border border-warning border-opacity-25 rounded-2">
<div class="d-flex gap-3">
<div class="icon-shape icon-md bg-warning text-white rounded-2">
<i class="ti ti-trending-down fs-4"></i>
</div>
<div>
<h2 class="mb-3 fs-6">Accumulated</h2>
<h3 class="fw-bold mb-0" id="stat_accumulated">—</h3>
<p class="text-warning mb-0 small">Depreciation + amortization</p>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-sm-6 col-12">
<div class="card p-4 bg-primary bg-opacity-10 border border-primary border-opacity-25 rounded-2">
<div class="d-flex gap-3">
<div class="icon-shape icon-md bg-primary text-white rounded-2">
<i class="ti ti-scale fs-4"></i>
</div>
<div>
<h2 class="mb-3 fs-6">Net Book Value</h2>
<h3 class="fw-bold mb-0" id="stat_book">—</h3>
<p class="text-primary mb-0 small">Cost minus accumulated</p>
</div>
</div>
</div>
</div>
</div>
<div class="row g-5 mb-5">
<!-- Due -->
<div class="col-lg-5">
<div class="card h-100">
<div class="card-body p-5">
<h2 class="fs-5 mb-1">Due this month</h2>
<p class="text-muted small mb-4">Months not yet charged, up to <span id="due_month"></span></p>
<div id="due_list"><div class="text-muted small">Loading...</div></div>
<div class="border-top pt-3 mt-3 small">
<span class="text-muted">Drafts waiting to be activated:</span>
<a href="<?php echo $server_url?>asset/index.php?status=0" class="fw-semibold ms-1" id="stat_draft">—</a>
</div>
</div>
</div>
</div>
<!-- Recent runs -->
<div class="col-lg-7">
<div class="card h-100">
<div class="card-body p-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="fs-5 mb-0">Recent runs</h2>
<a href="<?php echo $server_url?>asset/depreciation.php" class="small">All runs</a>
</div>
<div class="table-responsive">
<table class="table table-sm mb-0 table-centered">
<thead class="table-light">
<tr>
<th>Run No.</th>
<th>Month</th>
<th class="text-end">Amount</th>
<th>GL</th>
</tr>
</thead>
<tbody id="runs_tbody">
<tr><td colspan="4" class="text-center text-muted py-4">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- By category -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body p-5">
<h2 class="fs-5 mb-4">Assets in use by category</h2>
<div class="table-responsive">
<table class="table mb-0 table-hover table-centered">
<thead class="table-primary border-light">
<tr>
<th>Category</th>
<th>Class</th>
<th class="text-end">Assets</th>
<th class="text-end">Cost</th>
<th class="text-end">Accumulated</th>
<th class="text-end">Net Book Value</th>
</tr>
</thead>
<tbody id="category_tbody">
<tr><td colspan="6" class="text-center text-muted py-4">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<?php require '../include_ending.php'; ?>
<script src="<?php echo $server_url?>assets/js/asset.js"></script>
<script>
function load_stats() {
return ajax_request({
url: server_url + 'asset/api/engine/asset_stats.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
onSuccess: function(res) {
var s = res.output || {};
$('#stat_in_use').text(parseInt(s.active || 0, 10) + parseInt(s.fully_depreciated || 0, 10));
$('#stat_in_use_note').text((s.fully_depreciated || 0) + ' fully depreciated · ' + (s.disposed || 0) + ' disposed');
$('#stat_cost').text(format_number(s.cost_in_use || 0, 2));
$('#stat_accumulated').text(format_number(s.accumulated_in_use || 0, 2));
$('#stat_book').text(format_number(s.book_value_in_use || 0, 2));
$('#stat_draft').text(s.draft || 0);
render_due(res.due || {});
render_categories(s.by_category || []);
}
});
}
function render_due(due) {
$('#due_month').text(current_period());
var html = '';
['tangible', 'intangible'].forEach(function(asset_class) {
var d = due[asset_class] || {};
var body = d.period_count > 0
? '<div class="fw-semibold">' + format_number(d.total, 2) + '</div>'
+ '<div class="small text-muted">' + d.period_count + ' month(s): ' + escape_html(d.from) + (d.to !== d.from ? ' → ' + escape_html(d.to) : '') + '</div>'
: '<div class="small text-success"><i class="ti ti-circle-check me-1"></i>Up to date</div>';
var button = d.period_count > 0
? '<a class="btn btn-sm btn-primary" href="' + server_url + 'asset/depreciation.php?class=' + asset_class + '">Run</a>'
: '';
html += '<div class="d-flex align-items-center justify-content-between py-2 border-bottom">'
+ '<div><div class="small text-muted">' + escape_html(d.label || asset_class) + '</div>' + body + '</div>'
+ button + '</div>';
});
$('#due_list').html(html);
}
function render_categories(rows) {
if (!rows.length) {
$('#category_tbody').html('<tr><td colspan="6" class="text-center text-muted py-4">No active assets yet.</td></tr>');
return;
}
var html = '';
rows.forEach(function(r) {
html += '<tr>'
+ '<td><span class="fw-semibold">' + escape_html(r.category_code) + '</span> ' + escape_html(r.category_name) + '</td>'
+ '<td>' + asset_class_badge(r.asset_class) + '</td>'
+ '<td class="text-end">' + r.asset_count + '</td>'
+ '<td class="text-end">' + format_number(r.cost, 2) + '</td>'
+ '<td class="text-end">' + format_number(r.accumulated, 2) + '</td>'
+ '<td class="text-end">' + format_number((parseFloat(r.cost) || 0) - (parseFloat(r.accumulated) || 0), 2) + '</td>'
+ '</tr>';
});
$('#category_tbody').html(html);
}
function load_runs() {
return ajax_request({
url: server_url + 'asset/api/engine/depreciation_run.php',
autoPrepare: true,
checkRequired: 0,
action: 'list',
onSuccess: function(res) {
var rows = (res.output || []).filter(function(r) { return String(r.status) === '1'; }).slice(0, 6);
if (!rows.length) {
$('#runs_tbody').html('<tr><td colspan="4" class="text-center text-muted py-4">No runs yet.</td></tr>');
return;
}
var html = '';
rows.forEach(function(r) {
html += '<tr>'
+ '<td>' + escape_html(r.run_number) + '</td>'
+ '<td>' + escape_html(r.period) + '</td>'
+ '<td class="text-end">' + format_number(r.total_amount, 2) + '</td>'
+ '<td>' + asset_gl_badge(r.gl_status) + '</td>'
+ '</tr>';
});
$('#runs_tbody').html(html);
}
});
}
$(async function() {
try {
await load_stats();
await load_runs();
} catch (e) { console.log(e); }
});
</script>
</body>
</html>
-7
View File
@@ -1,13 +1,6 @@
html, body { overflow-x: hidden; }
/* Soft badges (app access badges: WMS orange, Accounting blue, Asset Management green) */
.bg-label-primary { background-color: var(--bs-primary-bg-subtle) !important; color: var(--bs-primary-text-emphasis) !important; }
.bg-label-info { background-color: #e0f2fe !important; color: #0369a1 !important; }
.bg-label-success { background-color: #dcfce7 !important; color: #15803d !important; }
.bg-label-secondary,
.bg-label-dark { background-color: #e9ecef !important; color: #343a40 !important; }
.flatpickr-day.selected {
background: var(--bs-primary) !important;
border-color: var(--bs-primary) !important;
-74
View File
@@ -1,74 +0,0 @@
// Shared helpers for the Asset Management pages.
var ASSET_STATUS = {
0: ['Draft', 'bg-secondary'],
1: ['Active', 'bg-success'],
2: ['Fully depreciated', 'bg-info text-white'],
3: ['Disposed', 'bg-dark'],
4: ['Void', 'bg-light text-dark']
};
function asset_status_badge(status, asset_class) {
var code = parseInt(status, 10);
var entry = ASSET_STATUS[code] || ['—', 'bg-light text-dark'];
var label = (code === 2 && asset_class === 'intangible') ? 'Fully amortized' : entry[0];
return '<span class="badge ' + entry[1] + '">' + label + '</span>';
}
function asset_class_badge(asset_class) {
return asset_class === 'intangible'
? '<span class="badge bg-warning-subtle text-warning">Intangible · Amortization</span>'
: '<span class="badge bg-success-subtle text-success">Tangible · Depreciation</span>';
}
function asset_gl_badge(posted) {
return String(posted) === '1'
? '<span class="badge bg-success-subtle text-success">Posted</span>'
: '<span class="badge bg-danger-subtle text-danger">Unposted</span>';
}
// Active posting accounts from the chart of accounts.
function load_posting_accounts(onLoaded) {
return ajax_request({
url: server_url + 'accounting/api/engine/account.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
queueLock: false,
noLoading: true,
onSuccess: function(res) {
var rows = (res.output || []).filter(function(a) {
return String(a.is_posting) === '1' && String(a.status) === '1';
});
onLoaded(rows);
}
});
}
// <option> list for accounts, narrowed to account_types when any match.
function account_options(accounts, account_types, selected, empty_label) {
var list = accounts;
if (account_types && account_types.length) {
var narrowed = accounts.filter(function(a) { return account_types.indexOf(a.account_type) !== -1; });
if (narrowed.length) list = narrowed;
}
var html = '<option value="">' + escape_html(empty_label || '— Select account —') + '</option>';
var found = false;
list.forEach(function(a) {
var is_selected = String(a.account_code) === String(selected || '');
if (is_selected) found = true;
html += '<option value="' + escape_html(a.account_code) + '"' + (is_selected ? ' selected' : '') + '>'
+ escape_html(a.account_code + ' — ' + a.account_name) + '</option>';
});
// Keep a saved code visible even if it is no longer an active posting account.
if (selected && !found) {
html += '<option value="' + escape_html(selected) + '" selected>' + escape_html(selected) + ' (not active)</option>';
}
return html;
}
function current_period() {
var d = new Date();
return d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2);
}
+2 -11
View File
@@ -995,24 +995,15 @@ document.addEventListener('DOMContentLoaded', () => {
/**
* Helper function to format date strings (assuming input is in ISO format)
*/
// Display format used across the app: YYYY-MM-DD, or YYYY-MM-DD HH:mm:ss when the value has a time.
function format_date(iso_string) {
if (!iso_string) return '—';
var split = String(iso_string).split(" ");
var split = iso_string.split(" ");
var datePart = split[0].split("-");
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;
}
// DD/MM/YYYY for filling date inputs (flatpickr dateFormat 'd/m/Y'); to_iso_date() reverses it.
function format_date_input(iso_string) {
if (!iso_string) return '';
var datePart = String(iso_string).split(" ")[0].split("-");
if (datePart.length !== 3) return iso_string;
return `${datePart[2]}/${datePart[1]}/${datePart[0]}`;
}
function to_iso_date(dateStr) {
if (!dateStr) return null;
-52
View File
@@ -1,52 +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, badge colour and home
// page of each. Used by the app switcher, the login redirect and the Users
// Access page.
//
// config.php may define its own $app_registry. Configs written before Asset
// Management existed (every Docker-generated config.php among them) list fewer
// apps and have no 'home', so missing apps and keys are filled in from the
// defaults below. Without a registry 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', 'accounting', 'asset'
// ('all' is implied and never listed here).
$_default_app_registry = [
'wms' => ['label' => 'WMS', 'icon' => 'ti-box', 'color' => 'bg-label-primary', 'home' => 'dashboard/index.php'],
'accounting' => ['label' => 'Accounting', 'icon' => 'ti-calculator', 'color' => 'bg-label-info', 'home' => 'ac_dashboard/index.php'],
'asset' => ['label' => 'Asset Management', 'icon' => 'ti-building-warehouse', 'color' => 'bg-label-success', 'home' => 'asset_dashboard/index.php'],
];
if (!isset($app_registry) || !is_array($app_registry) || !$app_registry) {
$app_registry = $_default_app_registry;
} else {
foreach ($_default_app_registry as $_registry_key => $_registry_defaults) {
$app_registry[$_registry_key] = array_merge($_registry_defaults, $app_registry[$_registry_key] ?? []);
}
}
unset($_default_app_registry, $_registry_key, $_registry_defaults);
if (!function_exists('app_can_access')) {
/** 'all' opens every app. */
function app_can_access(string $access, string $app): bool
{
return $access === 'all' || $access === $app;
}
/** The app a user lands in: their single app, or WMS for 'all'. */
function app_default_for_access(array $registry, string $access): string
{
return ($access !== 'all' && isset($registry[$access])) ? $access : 'wms';
}
/** An app's home page, relative to $server_url. */
function app_home_path(array $registry, string $app): string
{
return $registry[$app]['home'] ?? 'dashboard/index.php';
}
}
@@ -99,7 +99,7 @@ class CompanyProfileManager
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(
"UPDATE company_list SET
@@ -33,7 +33,6 @@ class DocumentNumberManager
'manual' => ['name' => 'Manual Journal', 'prefix' => 'JV', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
'return' => ['name' => 'Customer Return', 'prefix' => 'RET', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
'supplier_return' => ['name' => 'Supplier Return', 'prefix' => 'SRN', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
'asset' => ['name' => 'Fixed Asset', 'prefix' => 'FA', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 0],
];
public function __construct(PDO $pdo, int $company_id)
+5 -8
View File
@@ -79,8 +79,7 @@ class StockManager {
public function getStockList(int $warehouse_id, string $type): array
{
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
// The transfer list shows the outbound row, whose quantity is in `out` (its `in` is always 0).
$column = in_array($type, ['out', 'transfer'], true) ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)';
$column = $type === 'out' ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)';
$stock_ref = $this->stockReferenceSql();
// 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).
// `in` is decimal(18,4) — an int cast would drop fractional quantities.
$quantity = (float)$source_stock['in'];
// Quantity and identifiers come from the existing stock_in row (immutable)
$quantity = (int)$source_stock['in'];
$ref_id = (int)$source_stock['id'];
$lot_number = $source_stock['lot_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).
// `in` is decimal(18,4) — an int cast would drop fractional quantities.
$quantity = (float)$source_stock['in'];
// Quantity and identifiers come from the source stock_in row (immutable)
$quantity = (int)$source_stock['in'];
$lot_number = $source_stock['lot_number'] ?? null;
$serial_number = $source_stock['serial_number'] ?? null;
+1 -1
View File
@@ -426,7 +426,7 @@ class UserManager {
* within the owner's license.
*
* @param int $map_id The company_map_user.map_id to update.
* @param string $app_access New value: 'wms', 'accounting', 'asset', or 'all'.
* @param string $app_access New value: 'wms', 'accounting', or 'all'.
* @throws Exception If the member is not found or is the owner.
*/
public function updateAppAccess(int $map_id, string $app_access): void {
@@ -697,7 +697,7 @@ class FinancialReports
COALESCE(d.dept_code, '') AS dept_code,
COALESCE(d.dept_name, '') AS dept_name,
COALESCE(g.journal_date, DATE(g.created_at)) AS entry_date,
DATE_FORMAT(g.created_at, '%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.credit,
COALESCE(i.description, '') AS line_description
+7 -25
View File
@@ -1,6 +1,4 @@
<?php
require_once __DIR__ . '/../classes_as/AssetPosting.php';
class GlQueryManager
{
private PDO $pdo;
@@ -8,10 +6,8 @@ class GlQueryManager
private array $invoiceTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note'];
private array $mappingTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'purchase_order'];
private array $postingTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'receipt', 'payment', 'purchase_order',
'asset_capitalization', 'asset_depreciation', 'asset_amortization', 'asset_disposal'];
private array $journalTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'receipt', 'payment', 'manual', 'purchase_order', 'reversal',
'asset_capitalization', 'asset_depreciation', 'asset_amortization', 'asset_disposal'];
private array $postingTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'receipt', 'payment', 'purchase_order'];
private array $journalTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'receipt', 'payment', 'manual', 'purchase_order', 'reversal'];
public function __construct(PDO $pdo, int $company_id)
{
@@ -29,11 +25,6 @@ class GlQueryManager
throw new Exception('Invalid doc_type.');
}
// Asset Management sources have no formula; accounts come from the asset category.
if (AssetPosting::isAssetSource($doc_type)) {
return (new AssetPosting($this->pdo, $this->companyId))->getPostableDocuments($doc_type, $date_from, $date_to);
}
if (in_array($doc_type, $this->invoiceTypes, true)) {
$rows = $this->getInvoicePostableDocuments($doc_type, $date_from, $date_to, $formula_id);
} elseif ($doc_type === 'receipt') {
@@ -84,17 +75,13 @@ class GlQueryManager
g.current_version,
g.formula_id,
COALESCE(f.formula_name, '') AS formula_name,
DATE_FORMAT(g.created_at, '%Y-%m-%d %H:%i:%s') AS posted_at,
DATE_FORMAT(g.updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at,
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
DATE_FORMAT(g.updated_at, '%d/%m/%Y %H:%i') AS updated_at,
CASE g.source_type
WHEN 'receipt' THEN r.receipt_number
WHEN 'payment' THEN p.payment_number
WHEN 'manual' THEN IF(g.reference != '', g.reference, CONCAT('MJE-', g.id))
WHEN 'purchase_order' THEN po.po_number
WHEN 'asset_capitalization' THEN g.reference
WHEN 'asset_depreciation' THEN g.reference
WHEN 'asset_amortization' THEN g.reference
WHEN 'asset_disposal' THEN g.reference
ELSE i.invoice_number
END AS doc_number,
COALESCE(
@@ -103,10 +90,6 @@ class GlQueryManager
WHEN 'payment' THEN cp.contact_name
WHEN 'manual' THEN g.description
WHEN 'purchase_order' THEN cpo.contact_name
WHEN 'asset_capitalization' THEN g.description
WHEN 'asset_depreciation' THEN g.description
WHEN 'asset_amortization' THEN g.description
WHEN 'asset_disposal' THEN g.description
ELSE ci.contact_name
END, ''
) AS contact_name,
@@ -118,8 +101,7 @@ class GlQueryManager
LEFT JOIN td_gl_item gi
ON gi.company_id = g.company_id AND gi.gl_id = g.id
LEFT JOIN td_invoice i
ON g.source_type NOT IN ('receipt','payment','manual','purchase_order',
'asset_capitalization','asset_depreciation','asset_amortization','asset_disposal')
ON g.source_type NOT IN ('receipt','payment','manual','purchase_order')
AND i.company_id = g.company_id AND i.id = g.source_id
LEFT JOIN md_contact ci
ON ci.company_id = g.company_id AND ci.id = i.contact_id
@@ -160,8 +142,8 @@ class GlQueryManager
$sth = $this->pdo->prepare(
"SELECT g.*,
DATE_FORMAT(g.created_at, '%Y-%m-%d %H:%i:%s') AS posted_at,
DATE_FORMAT(g.updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at_fmt,
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
DATE_FORMAT(g.updated_at, '%d/%m/%Y %H:%i') AS updated_at_fmt,
DATE_FORMAT(g.journal_date, '%d/%m/%Y') AS journal_date_fmt,
COALESCE(f.formula_name, '') AS formula_name
FROM td_gl g
@@ -1,173 +0,0 @@
<?php
/**
* AssetCategoryManager
*
* Asset categories decide the asset class (tangible = depreciation,
* intangible = amortization), the default useful life, and every GL account
* the Asset Management postings use.
*/
class AssetCategoryManager
{
public const CLASSES = ['tangible', 'intangible'];
public const ACCOUNT_FIELDS = [
'cost_account_code' => 'Asset cost account',
'accum_account_code' => 'Accumulated depreciation / amortization account',
'expense_account_code' => 'Depreciation / amortization expense account',
'gain_account_code' => 'Gain on disposal account',
'loss_account_code' => 'Loss on disposal account',
];
private PDO $pdo;
private int $companyId;
public function __construct(PDO $pdo, int $company_id)
{
$this->pdo = $pdo;
$this->companyId = $company_id;
}
public static function isPostingAccount(PDO $pdo, int $company_id, string $account_code): bool
{
if ($account_code === '') return false;
$sth = $pdo->prepare(
"SELECT COUNT(*) FROM md_account
WHERE company_id = :cid AND account_code = :code AND is_posting = 1 AND status = 1"
);
$sth->execute([':cid' => $company_id, ':code' => $account_code]);
return (int)$sth->fetchColumn() > 0;
}
public function getAll(): array
{
$sth = $this->pdo->prepare(
"SELECT c.*, COUNT(a.id) AS asset_count
FROM md_asset_category c
LEFT JOIN td_asset a
ON a.company_id = c.company_id
AND a.category_id = c.id
AND a.status <> 4
WHERE c.company_id = :cid
GROUP BY c.id
ORDER BY c.category_code ASC"
);
$sth->execute([':cid' => $this->companyId]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
public function getById(int $id): ?array
{
$sth = $this->pdo->prepare(
"SELECT * FROM md_asset_category WHERE company_id = :cid AND id = :id LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
return $sth->fetch(PDO::FETCH_ASSOC) ?: null;
}
public function save(array $data): int
{
$id = (int)($data['id'] ?? 0);
$code = strtoupper(trim((string)($data['category_code'] ?? '')));
$name = trim((string)($data['category_name'] ?? ''));
$class = trim((string)($data['asset_class'] ?? ''));
$life = (int)($data['useful_life_months'] ?? 0);
if ($code === '' || $name === '') throw new Exception('Category code and name are required.');
if (!in_array($class, self::CLASSES, true)) throw new Exception('Select tangible or intangible.');
if ($life < 1 || $life > 1200) throw new Exception('Default useful life must be between 1 and 1200 months.');
$accounts = [];
foreach (self::ACCOUNT_FIELDS as $field => $label) {
$account_code = trim((string)($data[$field] ?? ''));
if ($account_code === '') throw new Exception("{$label} is required.");
if (!self::isPostingAccount($this->pdo, $this->companyId, $account_code)) {
throw new Exception("{$label} {$account_code} is not an active posting account.");
}
$accounts[$field] = $account_code;
}
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM md_asset_category
WHERE company_id = :cid AND category_code = :code AND id <> :id"
);
$sth->execute([':cid' => $this->companyId, ':code' => $code, ':id' => $id]);
if ((int)$sth->fetchColumn() > 0) throw new Exception("Category code {$code} already exists.");
$params = array_merge([
':cid' => $this->companyId,
':code' => $code,
':name' => $name,
':class' => $class,
':life' => $life,
':desc' => trim((string)($data['description'] ?? '')),
':status' => (int)($data['status'] ?? 1) === 1 ? 1 : 0,
], [
':cost_acc' => $accounts['cost_account_code'],
':accum_acc' => $accounts['accum_account_code'],
':expense_acc' => $accounts['expense_account_code'],
':gain_acc' => $accounts['gain_account_code'],
':loss_acc' => $accounts['loss_account_code'],
]);
if ($id) {
$existing = $this->getById($id);
if (!$existing) throw new Exception('Category not found.');
if ($existing['asset_class'] !== $class && $this->countAssets($id) > 0) {
throw new Exception('The asset class cannot change once assets use this category.');
}
$params[':id'] = $id;
$this->pdo->prepare(
"UPDATE md_asset_category SET
category_code = :code, category_name = :name, asset_class = :class,
useful_life_months = :life, description = :desc, status = :status,
cost_account_code = :cost_acc, accum_account_code = :accum_acc,
expense_account_code = :expense_acc, gain_account_code = :gain_acc,
loss_account_code = :loss_acc, updated_at = NOW()
WHERE company_id = :cid AND id = :id"
)->execute($params);
return $id;
}
$this->pdo->prepare(
"INSERT INTO md_asset_category
(company_id, category_code, category_name, asset_class, useful_life_months, description, status,
cost_account_code, accum_account_code, expense_account_code, gain_account_code, loss_account_code,
created_at)
VALUES
(:cid, :code, :name, :class, :life, :desc, :status,
:cost_acc, :accum_acc, :expense_acc, :gain_acc, :loss_acc,
NOW())"
)->execute($params);
return (int)$this->pdo->lastInsertId();
}
/** Categories are only deactivated: existing assets keep their accounts. */
public function deactivate(int $id): void
{
$this->pdo->prepare(
"UPDATE md_asset_category SET status = 0, updated_at = NOW() WHERE company_id = :cid AND id = :id"
)->execute([':cid' => $this->companyId, ':id' => $id]);
}
public function getStats(): array
{
$sth = $this->pdo->prepare(
"SELECT COUNT(*) AS total,
COALESCE(SUM(status = 1), 0) AS active,
COALESCE(SUM(status = 0), 0) AS inactive
FROM md_asset_category WHERE company_id = :cid"
);
$sth->execute([':cid' => $this->companyId]);
return $sth->fetch(PDO::FETCH_ASSOC) ?: [];
}
private function countAssets(int $category_id): int
{
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_asset WHERE company_id = :cid AND category_id = :id AND status <> 4"
);
$sth->execute([':cid' => $this->companyId, ':id' => $category_id]);
return (int)$sth->fetchColumn();
}
}
@@ -1,596 +0,0 @@
<?php
require_once __DIR__ . '/DepreciationCalculator.php';
require_once __DIR__ . '/AssetCategoryManager.php';
require_once __DIR__ . '/../classes/DocumentNumberManager.php';
/**
* AssetManager
*
* The asset register: create (manually or from a purchase invoice line),
* activate, void, dispose of, and undo a disposal.
*
* Status: 0 draft, 1 active, 2 fully depreciated, 3 disposed, 4 void.
* GL entries are not posted here — Accounting posts them from Batch GL Entries.
* Voiding reverses entries that were already posted.
*/
class AssetManager
{
public const STATUS_DRAFT = 0;
public const STATUS_ACTIVE = 1;
public const STATUS_FULLY = 2;
public const STATUS_DISPOSED = 3;
public const STATUS_VOID = 4;
private PDO $pdo;
private int $companyId;
public function __construct(PDO $pdo, int $company_id)
{
$this->pdo = $pdo;
$this->companyId = $company_id;
}
// ── read ─────────────────────────────────────────────────────────────────
public function getList(int $invoice_id = 0): array
{
$where = ['a.company_id = :cid'];
$params = [':cid' => $this->companyId, ':cid_d' => $this->companyId];
if ($invoice_id > 0) {
$where[] = 'a.invoice_id = :invoice_id';
$params[':invoice_id'] = $invoice_id;
}
$sth = $this->pdo->prepare($this->baseSelect() . ' WHERE ' . implode(' AND ', $where) . ' ORDER BY a.id DESC');
$sth->execute($params);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
public function getById(int $id): ?array
{
$sth = $this->pdo->prepare($this->baseSelect() . ' WHERE a.company_id = :cid AND a.id = :id LIMIT 1');
$sth->execute([':cid' => $this->companyId, ':cid_d' => $this->companyId, ':id' => $id]);
return $sth->fetch(PDO::FETCH_ASSOC) ?: null;
}
/** Asset, month-by-month schedule (recorded + projected) and its GL entries. */
public function getDetail(int $id): array
{
$asset = $this->getById($id);
if (!$asset) throw new Exception('Asset not found.');
$sth = $this->pdo->prepare(
"SELECT d.period, d.amount, d.accumulated_after, d.book_value_after, d.run_id,
COALESCE(r.run_number, '') AS run_number
FROM td_asset_depreciation d
LEFT JOIN td_asset_run r ON r.company_id = d.company_id AND r.id = d.run_id
WHERE d.company_id = :cid AND d.asset_id = :id
ORDER BY d.period ASC"
);
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
$recorded = [];
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
$recorded[$row['period']] = $row;
}
$cost = (float)$asset['cost'];
$salvage = (float)$asset['salvage_value'];
$life = (int)$asset['useful_life_months'];
$disposal_period = $asset['disposal_date'] ? DepreciationCalculator::periodOf($asset['disposal_date']) : '';
$schedule = [];
if ($asset['in_service_date'] && $life > 0 && $cost > $salvage) {
foreach (DepreciationCalculator::schedule($cost, $salvage, $life, $asset['in_service_date']) as $row) {
if (isset($recorded[$row['period']])) {
$r = $recorded[$row['period']];
$row = [
'period' => $row['period'],
'amount' => (float)$r['amount'],
'accumulated' => (float)$r['accumulated_after'],
'book_value' => (float)$r['book_value_after'],
'state' => 'recorded',
'run_id' => (int)$r['run_id'],
'run_number' => $r['run_number'],
];
} else {
$stopped = $disposal_period !== '' && $row['period'] >= $disposal_period;
$row['state'] = $stopped || (int)$asset['status'] === self::STATUS_VOID ? 'stopped' : 'projected';
}
$schedule[] = $row;
}
}
return [
'asset' => $asset,
'schedule' => $schedule,
'monthly_amount' => $life > 0 ? DepreciationCalculator::monthlyAmount($cost, $salvage, $life) : 0,
'gl' => [
'capitalization' => $this->glEntry('asset_capitalization', $id),
'disposal' => $this->glEntry('asset_disposal', $id),
],
];
}
/**
* One purchase invoice line, the part of it already capitalized, and the
* account its purchase posting debited (the default clearing account).
*/
public function getInvoiceLine(int $invoice_id, int $item_id, int $exclude_asset_id = 0): array
{
$sth = $this->pdo->prepare(
"SELECT i.id AS invoice_id, i.invoice_number, i.issued_date, i.contact_id, i.department_id,
i.status AS invoice_status, i.formula_id, i.grand_total, i.tax,
COALESCE(c.contact_name, '') AS contact_name,
it.id AS item_id, it.product_sku, it.product_name, it.quantity, it.unit_price, it.total_price,
COALESCE(NULLIF(p.purchase_account_code, ''), '') AS product_account_code
FROM td_invoice i
JOIN td_invoice_item it
ON it.company_id = i.company_id AND it.invoice_id = i.id
LEFT JOIN md_contact c
ON c.company_id = i.company_id AND c.id = i.contact_id
LEFT JOIN md_product p
ON p.company_id = it.company_id AND p.sku = it.product_sku
WHERE i.company_id = :cid AND i.id = :invoice_id AND it.id = :item_id
AND i.doc_type = 'purchase_invoice'
LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':invoice_id' => $invoice_id, ':item_id' => $item_id]);
$line = $sth->fetch(PDO::FETCH_ASSOC);
if (!$line) throw new Exception('Purchase invoice line not found.');
if (!in_array((int)$line['invoice_status'], [1, 2], true)) {
throw new Exception('Only issued or paid purchase invoices can be turned into assets.');
}
$line['line_amount'] = $this->postedLineAmount($invoice_id, (float)$line['total_price'], (float)$line['grand_total'], (float)$line['tax']);
$sth = $this->pdo->prepare(
"SELECT COALESCE(SUM(cost), 0) FROM td_asset
WHERE company_id = :cid AND invoice_item_id = :item_id AND status <> 4 AND id <> :exclude"
);
$sth->execute([':cid' => $this->companyId, ':item_id' => $item_id, ':exclude' => $exclude_asset_id]);
$line['capitalized'] = round((float)$sth->fetchColumn(), 2);
$line['remaining'] = round($line['line_amount'] - $line['capitalized'], 2);
$line['clearing_account_code'] = $line['product_account_code'] !== ''
? $line['product_account_code']
: $this->formulaTotalAccount((int)$line['formula_id']);
return $line;
}
/** Issued purchase invoice lines with how much of each has been capitalized. */
public function getCapitalizableLines(string $date_from = '', string $date_to = ''): array
{
$where = ['i.company_id = :cid', "i.doc_type = 'purchase_invoice'", 'i.status IN (1, 2)'];
$params = [':cid' => $this->companyId, ':cid_s' => $this->companyId, ':cid_x' => $this->companyId];
if ($date_from = $this->parseDate($date_from)) { $where[] = 'i.issued_date >= :date_from'; $params[':date_from'] = $date_from; }
if ($date_to = $this->parseDate($date_to)) { $where[] = 'i.issued_date <= :date_to'; $params[':date_to'] = $date_to; }
$sth = $this->pdo->prepare(
"SELECT i.id AS invoice_id, i.invoice_number, i.issued_date, i.status AS invoice_status,
i.grand_total, i.tax, s.sum_total,
COALESCE(c.contact_name, '') AS contact_name,
it.id AS item_id, it.product_sku, it.product_name, it.quantity, it.total_price,
COALESCE(x.capitalized, 0) AS capitalized,
COALESCE(x.asset_count, 0) AS asset_count
FROM td_invoice i
JOIN td_invoice_item it
ON it.company_id = i.company_id AND it.invoice_id = i.id
JOIN (SELECT invoice_id, SUM(ABS(total_price)) AS sum_total
FROM td_invoice_item WHERE company_id = :cid_s GROUP BY invoice_id) s
ON s.invoice_id = i.id
LEFT JOIN md_contact c
ON c.company_id = i.company_id AND c.id = i.contact_id
LEFT JOIN (SELECT invoice_item_id, SUM(cost) AS capitalized, COUNT(*) AS asset_count
FROM td_asset WHERE company_id = :cid_x AND status <> 4 GROUP BY invoice_item_id) x
ON x.invoice_item_id = it.id
WHERE " . implode(' AND ', $where) . "
ORDER BY i.issued_date DESC, i.id DESC, it.item_id ASC"
);
$sth->execute($params);
$rows = [];
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
$sum = (float)$row['sum_total'];
$row['line_amount'] = $sum > 0
? round(((float)$row['grand_total'] - (float)$row['tax']) * abs((float)$row['total_price']) / $sum, 2)
: 0.0;
$row['capitalized'] = round((float)$row['capitalized'], 2);
$row['remaining'] = round($row['line_amount'] - $row['capitalized'], 2);
unset($row['grand_total'], $row['tax'], $row['sum_total']);
$rows[] = $row;
}
return $rows;
}
public function getStats(): array
{
$sth = $this->pdo->prepare(
"SELECT COUNT(*) AS total,
COALESCE(SUM(a.status = 0), 0) AS draft,
COALESCE(SUM(a.status = 1), 0) AS active,
COALESCE(SUM(a.status = 2), 0) AS fully_depreciated,
COALESCE(SUM(a.status = 3), 0) AS disposed,
COALESCE(SUM(IF(a.status IN (1, 2), a.cost, 0)), 0) AS cost_in_use,
COALESCE(SUM(IF(a.status IN (1, 2), COALESCE(d.accumulated, 0), 0)), 0) AS accumulated_in_use
FROM td_asset a
LEFT JOIN (SELECT asset_id, SUM(amount) AS accumulated
FROM td_asset_depreciation WHERE company_id = :cid_d GROUP BY asset_id) d
ON d.asset_id = a.id
WHERE a.company_id = :cid AND a.status <> 4"
);
$sth->execute([':cid' => $this->companyId, ':cid_d' => $this->companyId]);
$stats = $sth->fetch(PDO::FETCH_ASSOC) ?: [];
$stats['book_value_in_use'] = round((float)($stats['cost_in_use'] ?? 0) - (float)($stats['accumulated_in_use'] ?? 0), 2);
$sth = $this->pdo->prepare(
"SELECT c.id, c.category_code, c.category_name, c.asset_class,
COUNT(a.id) AS asset_count,
COALESCE(SUM(a.cost), 0) AS cost,
COALESCE(SUM(COALESCE(d.accumulated, 0)), 0) AS accumulated
FROM td_asset a
JOIN md_asset_category c
ON c.company_id = a.company_id AND c.id = a.category_id
LEFT JOIN (SELECT asset_id, SUM(amount) AS accumulated
FROM td_asset_depreciation WHERE company_id = :cid_d GROUP BY asset_id) d
ON d.asset_id = a.id
WHERE a.company_id = :cid AND a.status IN (1, 2)
GROUP BY c.id, c.category_code, c.category_name, c.asset_class
ORDER BY c.category_code ASC"
);
$sth->execute([':cid' => $this->companyId, ':cid_d' => $this->companyId]);
$stats['by_category'] = $sth->fetchAll(PDO::FETCH_ASSOC);
return $stats;
}
// ── write ────────────────────────────────────────────────────────────────
public function save(array $data, int $user_id): int
{
$id = (int)($data['id'] ?? 0);
$existing = $id ? $this->getById($id) : null;
if ($id && !$existing) throw new Exception('Asset not found.');
if ($existing && in_array((int)$existing['status'], [self::STATUS_DISPOSED, self::STATUS_VOID], true)) {
throw new Exception('Disposed or void assets cannot be edited.');
}
$name = trim((string)($data['asset_name'] ?? ''));
if ($name === '') throw new Exception('Asset name is required.');
$description = trim((string)($data['description'] ?? ''));
$department_id = (int)($data['department_id'] ?? 0);
// Once a month has been charged, the numbers behind the schedule are fixed.
if ($existing && $this->hasDepreciation($id)) {
$this->pdo->prepare(
"UPDATE td_asset SET asset_name = :name, description = :desc, department_id = :dept, updated_at = NOW()
WHERE company_id = :cid AND id = :id"
)->execute([':name' => $name, ':desc' => $description, ':dept' => $department_id, ':cid' => $this->companyId, ':id' => $id]);
return $id;
}
$category = (new AssetCategoryManager($this->pdo, $this->companyId))->getById((int)($data['category_id'] ?? 0));
if (!$category) throw new Exception('Select an asset category.');
$same_category = $existing && (int)$existing['category_id'] === (int)$category['id'];
if ((int)$category['status'] !== 1 && !$same_category) throw new Exception('The selected category is inactive.');
$cost = round((float)($data['cost'] ?? 0), 2);
$salvage = round((float)($data['salvage_value'] ?? 0), 2);
$life = (int)($data['useful_life_months'] ?? 0);
DepreciationCalculator::assertInputs($cost, $salvage, $life);
$acquisition = $this->parseDate((string)($data['acquisition_date'] ?? ''));
$in_service = $this->parseDate((string)($data['in_service_date'] ?? '')) ?: $acquisition;
if ($acquisition === '') throw new Exception('Acquisition date is required.');
if ($in_service < $acquisition) throw new Exception('The in-service date cannot be before the acquisition date.');
$clearing = trim((string)($data['clearing_account_code'] ?? ''));
if ($clearing !== '' && !AssetCategoryManager::isPostingAccount($this->pdo, $this->companyId, $clearing)) {
throw new Exception("Clearing account {$clearing} is not an active posting account.");
}
$invoice_id = $existing ? (int)$existing['invoice_id'] : (int)($data['invoice_id'] ?? 0);
$item_id = $existing ? (int)$existing['invoice_item_id'] : (int)($data['invoice_item_id'] ?? 0);
$contact_id = $existing ? (int)$existing['contact_id'] : 0;
$source = 'manual';
if ($item_id > 0) {
$line = $this->getInvoiceLine($invoice_id, $item_id, $id);
if ($cost > $line['remaining'] + 0.004) {
throw new Exception('Cost is more than the part of this invoice line not yet capitalized (' . number_format($line['remaining'], 2) . ').');
}
if ($clearing === '') {
throw new Exception('Assets from a purchase invoice need the clearing account the invoice was posted to.');
}
$contact_id = (int)$line['contact_id'];
$source = 'purchase_invoice';
}
$params = [
':cid' => $this->companyId,
':name' => $name,
':category_id' => (int)$category['id'],
':class' => $category['asset_class'],
':desc' => $description,
':dept' => $department_id,
':acquisition' => $acquisition,
':in_service' => $in_service,
':cost' => $cost,
':salvage' => $salvage,
':life' => $life,
':clearing' => $clearing,
];
if ($existing) {
$params[':id'] = $id;
$this->pdo->prepare(
"UPDATE td_asset SET
asset_name = :name, category_id = :category_id, asset_class = :class, description = :desc,
department_id = :dept, acquisition_date = :acquisition, in_service_date = :in_service,
cost = :cost, salvage_value = :salvage, useful_life_months = :life,
clearing_account_code = :clearing, updated_at = NOW()
WHERE company_id = :cid AND id = :id"
)->execute($params);
return $id;
}
$params += [
':number' => (new DocumentNumberManager($this->pdo, $this->companyId))->generate('asset'),
':source' => $source,
':invoice_id' => $invoice_id,
':item_id' => $item_id,
':contact_id' => $contact_id,
':user_id' => $user_id,
];
$this->pdo->prepare(
"INSERT INTO td_asset
(company_id, asset_number, asset_name, category_id, asset_class, description, source,
invoice_id, invoice_item_id, contact_id, department_id, acquisition_date, in_service_date,
cost, salvage_value, useful_life_months, clearing_account_code, status, created_by, created_at)
VALUES
(:cid, :number, :name, :category_id, :class, :desc, :source,
:invoice_id, :item_id, :contact_id, :dept, :acquisition, :in_service,
:cost, :salvage, :life, :clearing, 0, :user_id, NOW())"
)->execute($params);
return (int)$this->pdo->lastInsertId();
}
public function activate(int $id): void
{
$asset = $this->requireAsset($id);
if ((int)$asset['status'] !== self::STATUS_DRAFT) throw new Exception('Only draft assets can be activated.');
$category = (new AssetCategoryManager($this->pdo, $this->companyId))->getById((int)$asset['category_id']);
if (!$category || (int)$category['status'] !== 1) throw new Exception('The asset category is missing or inactive.');
DepreciationCalculator::assertInputs((float)$asset['cost'], (float)$asset['salvage_value'], (int)$asset['useful_life_months']);
$this->setStatus($id, self::STATUS_ACTIVE);
}
/** Drafts are removed outright (soft delete, like other documents). */
public function delete(int $id): void
{
$asset = $this->requireAsset($id);
if ((int)$asset['status'] !== self::STATUS_DRAFT) throw new Exception('Only draft assets can be deleted. Void an active asset instead.');
$this->pdo->prepare(
"UPDATE td_asset SET company_id = company_id * -1, updated_at = NOW() WHERE company_id = :cid AND id = :id"
)->execute([':cid' => $this->companyId, ':id' => $id]);
}
/** Void an active asset recorded in error. Reverses its capitalization entry if posted. */
public function void(int $id, GlManager $gl): void
{
$asset = $this->requireAsset($id);
if (!in_array((int)$asset['status'], [self::STATUS_ACTIVE, self::STATUS_FULLY], true)) {
throw new Exception('Only active assets can be voided.');
}
if ($this->hasDepreciation($id)) {
throw new Exception('This asset has depreciation recorded. Void its depreciation runs first.');
}
$gl->delete('asset_capitalization', $id);
$this->setStatus($id, self::STATUS_VOID);
}
/**
* Record a sale or write-off. Every month before the disposal month must
* already be charged; the disposal month itself gets no charge.
*/
public function dispose(int $id, array $data): void
{
$asset = $this->requireAsset($id);
if (!in_array((int)$asset['status'], [self::STATUS_ACTIVE, self::STATUS_FULLY], true)) {
throw new Exception('Only active assets can be disposed of.');
}
$date = $this->parseDate((string)($data['disposal_date'] ?? ''));
if ($date === '') throw new Exception('Disposal date is required.');
if ($date < $asset['in_service_date']) throw new Exception('The disposal date cannot be before the in-service date.');
$sale_price = round((float)($data['sale_price'] ?? 0), 2);
if ($sale_price < 0) throw new Exception('Sale price cannot be negative.');
$proceeds = trim((string)($data['proceeds_account_code'] ?? ''));
if ($sale_price > 0 && $proceeds === '') throw new Exception('Select the account that received the sale proceeds.');
if ($proceeds !== '' && !AssetCategoryManager::isPostingAccount($this->pdo, $this->companyId, $proceeds)) {
throw new Exception("Proceeds account {$proceeds} is not an active posting account.");
}
$disposal_period = DepreciationCalculator::periodOf($date);
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_asset_depreciation WHERE company_id = :cid AND asset_id = :id AND period >= :period"
);
$sth->execute([':cid' => $this->companyId, ':id' => $id, ':period' => $disposal_period]);
if ((int)$sth->fetchColumn() > 0) {
throw new Exception("Depreciation is already recorded for {$disposal_period} or later. Void those runs before disposing of the asset on this date.");
}
$sth = $this->pdo->prepare("SELECT period FROM td_asset_depreciation WHERE company_id = :cid AND asset_id = :id");
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
$recorded = array_flip($sth->fetchAll(PDO::FETCH_COLUMN));
$schedule = DepreciationCalculator::schedule(
(float)$asset['cost'], (float)$asset['salvage_value'], (int)$asset['useful_life_months'], $asset['in_service_date']
);
foreach ($schedule as $row) {
if ($row['period'] >= $disposal_period) break;
if ($row['amount'] > 0 && !isset($recorded[$row['period']])) {
$label = $asset['asset_class'] === 'intangible' ? 'amortization' : 'depreciation';
throw new Exception("Run {$label} through " . DepreciationCalculator::addMonths($disposal_period, -1)
. " before disposing of this asset (first month not charged: {$row['period']}).");
}
}
$this->pdo->prepare(
"UPDATE td_asset SET status = :status, disposal_date = :date, sale_price = :price,
proceeds_account_code = :proceeds, disposal_notes = :notes, updated_at = NOW()
WHERE company_id = :cid AND id = :id"
)->execute([
':status' => self::STATUS_DISPOSED,
':date' => $date,
':price' => $sale_price,
':proceeds' => $proceeds,
':notes' => trim((string)($data['disposal_notes'] ?? '')),
':cid' => $this->companyId,
':id' => $id,
]);
}
/** Reverse a disposal (and its GL entry if posted). */
public function undoDisposal(int $id, GlManager $gl): void
{
$asset = $this->requireAsset($id);
if ((int)$asset['status'] !== self::STATUS_DISPOSED) throw new Exception('This asset has not been disposed of.');
$gl->delete('asset_disposal', $id);
$this->pdo->prepare(
"UPDATE td_asset SET status = :status, disposal_date = NULL, sale_price = 0,
proceeds_account_code = '', disposal_notes = '', updated_at = NOW()
WHERE company_id = :cid AND id = :id"
)->execute([':status' => self::STATUS_ACTIVE, ':cid' => $this->companyId, ':id' => $id]);
$this->syncStatuses([$id]);
}
/** Set active / fully depreciated from what has been charged so far. */
public function syncStatuses(array $asset_ids): void
{
$ids = array_values(array_unique(array_filter(array_map('intval', $asset_ids))));
if (!$ids) return;
$this->pdo->prepare(
"UPDATE td_asset a
LEFT JOIN (SELECT asset_id, SUM(amount) AS accumulated
FROM td_asset_depreciation WHERE company_id = :cid_d GROUP BY asset_id) d
ON d.asset_id = a.id
SET a.status = IF(COALESCE(d.accumulated, 0) >= ROUND(a.cost - a.salvage_value, 2) - 0.004, 2, 1),
a.updated_at = NOW()
WHERE a.company_id = :cid AND a.status IN (1, 2) AND a.id IN (" . implode(',', $ids) . ")"
)->execute([':cid' => $this->companyId, ':cid_d' => $this->companyId]);
}
// ── private ──────────────────────────────────────────────────────────────
private function baseSelect(): string
{
return "SELECT a.*,
COALESCE(c.category_code, '') AS category_code,
COALESCE(c.category_name, '') AS category_name,
COALESCE(ct.contact_name, '') AS contact_name,
COALESCE(i.invoice_number, '') AS invoice_number,
COALESCE(d.accumulated, 0) AS accumulated,
COALESCE(d.last_period, '') AS last_period,
ROUND(a.cost - COALESCE(d.accumulated, 0), 4) AS book_value
FROM td_asset a
LEFT JOIN md_asset_category c
ON c.company_id = a.company_id AND c.id = a.category_id
LEFT JOIN md_contact ct
ON ct.company_id = a.company_id AND ct.id = a.contact_id
LEFT JOIN td_invoice i
ON i.company_id = a.company_id AND i.id = a.invoice_id
LEFT JOIN (SELECT asset_id, SUM(amount) AS accumulated, MAX(period) AS last_period
FROM td_asset_depreciation WHERE company_id = :cid_d GROUP BY asset_id) d
ON d.asset_id = a.id";
}
private function requireAsset(int $id): array
{
$sth = $this->pdo->prepare("SELECT * FROM td_asset WHERE company_id = :cid AND id = :id FOR UPDATE");
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
$asset = $sth->fetch(PDO::FETCH_ASSOC);
if (!$asset) throw new Exception('Asset not found.');
return $asset;
}
private function setStatus(int $id, int $status): void
{
$this->pdo->prepare("UPDATE td_asset SET status = :status, updated_at = NOW() WHERE company_id = :cid AND id = :id")
->execute([':status' => $status, ':cid' => $this->companyId, ':id' => $id]);
}
private function hasDepreciation(int $id): bool
{
$sth = $this->pdo->prepare("SELECT COUNT(*) FROM td_asset_depreciation WHERE company_id = :cid AND asset_id = :id");
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
return (int)$sth->fetchColumn() > 0;
}
private function glEntry(string $source_type, int $source_id): ?array
{
$sth = $this->pdo->prepare(
"SELECT id, reference, journal_date, period, current_version,
DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS posted_at
FROM td_gl WHERE company_id = :cid AND source_type = :type AND source_id = :id LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':type' => $source_type, ':id' => $source_id]);
return $sth->fetch(PDO::FETCH_ASSOC) ?: null;
}
/**
* The part of the invoice's pre-tax total the purchase posting put on this
* line — PurchaseInvoicePosting splits (grand_total - tax) by line total.
*/
private function postedLineAmount(int $invoice_id, float $line_total, float $grand_total, float $tax): float
{
$sth = $this->pdo->prepare(
"SELECT COALESCE(SUM(ABS(total_price)), 0) FROM td_invoice_item WHERE company_id = :cid AND invoice_id = :id"
);
$sth->execute([':cid' => $this->companyId, ':id' => $invoice_id]);
$sum = (float)$sth->fetchColumn();
if ($sum <= 0) return 0.0;
return round((abs($grand_total) - abs($tax)) * abs($line_total) / $sum, 2);
}
private function formulaTotalAccount(int $formula_id): string
{
if ($formula_id <= 0) {
$sth = $this->pdo->prepare(
"SELECT id FROM md_account_formula
WHERE company_id = :cid AND document_type = 'purchase_invoice' AND is_default = 1 AND status = 1
LIMIT 1"
);
$sth->execute([':cid' => $this->companyId]);
$formula_id = (int)$sth->fetchColumn();
}
if ($formula_id <= 0) return '';
$sth = $this->pdo->prepare(
"SELECT account_code FROM md_account_formula_item
WHERE company_id = :cid AND formula_id = :fid AND amount_key = 'total' AND drcr = 'D'
ORDER BY sort_order ASC LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':fid' => $formula_id]);
return (string)($sth->fetchColumn() ?: '');
}
private function parseDate(string $value): string
{
$value = trim($value);
if ($value === '') return '';
if (preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $value, $m)) return "{$m[3]}-{$m[2]}-{$m[1]}";
if (preg_match('#^\d{4}-\d{2}-\d{2}$#', $value)) return $value;
throw new Exception("Invalid date '{$value}'.");
}
}
@@ -1,267 +0,0 @@
<?php
/**
* AssetPosting
*
* Builds the GL lines for the four Asset Management sources. Unlike the
* formula-based BasePosting classes, the accounts come from the asset category.
* Every entry's reference is the asset or run number and every description
* starts with "[Asset Mgmt]" so it is recognisable in Accounting.
*
* asset_capitalization DR asset cost / CR clearing account (the account the purchase invoice hit)
* asset_depreciation DR depreciation expense / CR accumulated depreciation (one run, grouped by category + department)
* asset_amortization DR amortization expense / CR accumulated amortization
* asset_disposal DR proceeds + accumulated (+ loss) / CR asset cost (+ gain)
*/
class AssetPosting
{
public const SOURCE_TYPES = ['asset_capitalization', 'asset_depreciation', 'asset_amortization', 'asset_disposal'];
public const LABEL = '[Asset Mgmt]';
private PDO $pdo;
private int $companyId;
public function __construct(PDO $pdo, int $company_id)
{
$this->pdo = $pdo;
$this->companyId = $company_id;
}
public static function isAssetSource(string $source_type): bool
{
return in_array($source_type, self::SOURCE_TYPES, true);
}
/**
* @return array{formula_id: int, period: string, doc_date: string, lines: array, reference: string, description: string}
*/
public function build(string $source_type, int $id): array
{
switch ($source_type) {
case 'asset_capitalization': $built = $this->buildCapitalization($id); break;
case 'asset_depreciation': $built = $this->buildRun($id, 'tangible'); break;
case 'asset_amortization': $built = $this->buildRun($id, 'intangible'); break;
case 'asset_disposal': $built = $this->buildDisposal($id); break;
default: throw new Exception("Unknown asset source '{$source_type}'.");
}
$this->assertBalanced($built['lines'], $built['reference']);
return [
'formula_id' => 0,
'period' => substr($built['doc_date'], 0, 7),
'doc_date' => $built['doc_date'],
'lines' => $built['lines'],
'reference' => mb_substr($built['reference'], 0, 100),
'description' => mb_substr($built['description'], 0, 255),
];
}
/** Rows for the Batch GL Entries tabs, in the same shape GlQueryManager returns. */
public function getPostableDocuments(string $source_type, string $date_from, string $date_to): array
{
$params = [':cid' => $this->companyId, ':source_type' => $source_type];
if ($source_type === 'asset_depreciation' || $source_type === 'asset_amortization') {
$where = ['r.company_id = :cid', 'r.status = 1', 'r.asset_class = :class'];
$params[':class'] = $source_type === 'asset_amortization' ? 'intangible' : 'tangible';
if ($date_from) { $where[] = 'r.run_date >= :date_from'; $params[':date_from'] = $date_from; }
if ($date_to) { $where[] = 'r.run_date <= :date_to'; $params[':date_to'] = $date_to; }
$sql = "SELECT r.id,
r.run_number AS doc_number,
CONCAT(r.asset_count, IF(r.asset_count = 1, ' asset', ' assets'), ' · ', r.period) AS contact_name,
r.total_amount AS grand_total,
r.run_date AS doc_date,
IF(g.id IS NULL, 0, 1) AS gl_status,
0 AS gl_formula_id, 0 AS product_mapping_checked, 0 AS product_mapping_missing
FROM td_asset_run r
LEFT JOIN td_gl g
ON g.company_id = r.company_id AND g.source_type = :source_type AND g.source_id = r.id
WHERE " . implode(' AND ', $where) . "
ORDER BY r.run_date DESC, r.id DESC";
} else {
$is_disposal = $source_type === 'asset_disposal';
$date_col = $is_disposal ? 'a.disposal_date' : 'a.acquisition_date';
$where = ['a.company_id = :cid'];
$where[] = $is_disposal ? 'a.status = 3' : "a.status IN (1, 2, 3) AND a.clearing_account_code <> ''";
if ($date_from) { $where[] = "{$date_col} >= :date_from"; $params[':date_from'] = $date_from; }
if ($date_to) { $where[] = "{$date_col} <= :date_to"; $params[':date_to'] = $date_to; }
$amount = $is_disposal ? 'a.sale_price' : 'a.cost';
$sql = "SELECT a.id,
a.asset_number AS doc_number,
a.asset_name AS contact_name,
{$amount} AS grand_total,
{$date_col} AS doc_date,
IF(g.id IS NULL, 0, 1) AS gl_status,
0 AS gl_formula_id, 0 AS product_mapping_checked, 0 AS product_mapping_missing
FROM td_asset a
LEFT JOIN td_gl g
ON g.company_id = a.company_id AND g.source_type = :source_type AND g.source_id = a.id
WHERE " . implode(' AND ', $where) . "
ORDER BY {$date_col} DESC, a.id DESC";
}
$sth = $this->pdo->prepare($sql);
$sth->execute($params);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
// ── builders ─────────────────────────────────────────────────────────────
private function buildCapitalization(int $id): array
{
$asset = $this->fetchAsset($id);
if (!in_array((int)$asset['status'], [1, 2, 3], true)) {
throw new Exception("Asset {$asset['asset_number']} is not active.");
}
if ($asset['clearing_account_code'] === '') {
throw new Exception("Asset {$asset['asset_number']} has no clearing account, so it has no capitalization entry.");
}
$cost = round((float)$asset['cost'], 2);
$text = self::LABEL . " Capitalize {$asset['asset_number']} {$asset['asset_name']}";
$dept = (int)$asset['department_id'];
return [
'doc_date' => $asset['acquisition_date'],
'reference' => $asset['asset_number'],
'description' => $text,
'lines' => [
$this->line($this->account($asset, 'cost_account_code'), $dept, $cost, 0, $text),
$this->line($asset['clearing_account_code'], $dept, 0, $cost, $text),
],
];
}
private function buildRun(int $run_id, string $asset_class): array
{
$sth = $this->pdo->prepare("SELECT * FROM td_asset_run WHERE company_id = :cid AND id = :id LIMIT 1");
$sth->execute([':cid' => $this->companyId, ':id' => $run_id]);
$run = $sth->fetch(PDO::FETCH_ASSOC);
if (!$run || (int)$run['status'] !== 1) throw new Exception("Asset run #{$run_id} not found or void.");
if ($run['asset_class'] !== $asset_class) throw new Exception("Asset run {$run['run_number']} is not a {$asset_class} run.");
$sth = $this->pdo->prepare(
"SELECT d.category_id, d.department_id, SUM(d.amount) AS amount,
c.category_code, c.category_name, c.expense_account_code, c.accum_account_code
FROM td_asset_depreciation d
LEFT JOIN md_asset_category c
ON c.company_id = d.company_id AND c.id = d.category_id
WHERE d.company_id = :cid AND d.run_id = :run_id
GROUP BY d.category_id, d.department_id, c.category_code, c.category_name,
c.expense_account_code, c.accum_account_code
ORDER BY c.category_code ASC, d.department_id ASC"
);
$sth->execute([':cid' => $this->companyId, ':run_id' => $run_id]);
$groups = $sth->fetchAll(PDO::FETCH_ASSOC);
if (!$groups) throw new Exception("Asset run {$run['run_number']} has no lines.");
$label = $asset_class === 'intangible' ? 'Amortization' : 'Depreciation';
$lines = [];
foreach ($groups as $g) {
$amount = round((float)$g['amount'], 2);
$text = self::LABEL . " {$label} {$run['period']} · {$g['category_code']} {$g['category_name']}";
$dept = (int)$g['department_id'];
$lines[] = $this->line($this->account($g, 'expense_account_code'), $dept, $amount, 0, $text);
$lines[] = $this->line($this->account($g, 'accum_account_code'), $dept, 0, $amount, $text);
}
return [
'doc_date' => $run['run_date'],
'reference' => $run['run_number'],
'description' => self::LABEL . " {$label} {$run['period']} ({$run['asset_count']} assets)",
'lines' => $lines,
];
}
private function buildDisposal(int $id): array
{
$asset = $this->fetchAsset($id);
if ((int)$asset['status'] !== 3) throw new Exception("Asset {$asset['asset_number']} has not been disposed of.");
$sth = $this->pdo->prepare(
"SELECT COALESCE(SUM(amount), 0) FROM td_asset_depreciation WHERE company_id = :cid AND asset_id = :id"
);
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
$accumulated = round((float)$sth->fetchColumn(), 2);
$cost = round((float)$asset['cost'], 2);
$sale_price = round((float)$asset['sale_price'], 2);
$gain = round($sale_price - ($cost - $accumulated), 2);
$dept = (int)$asset['department_id'];
$text = self::LABEL . " Disposal {$asset['asset_number']} {$asset['asset_name']}";
$lines = [];
if ($sale_price > 0) {
if ($asset['proceeds_account_code'] === '') throw new Exception("Asset {$asset['asset_number']} has a sale price but no proceeds account.");
$lines[] = $this->line($asset['proceeds_account_code'], $dept, $sale_price, 0, $text . ' · proceeds');
}
if ($accumulated > 0) {
$lines[] = $this->line($this->account($asset, 'accum_account_code'), $dept, $accumulated, 0, $text . ' · accumulated');
}
if ($gain < 0) {
$lines[] = $this->line($this->account($asset, 'loss_account_code'), $dept, -$gain, 0, $text . ' · loss');
}
$lines[] = $this->line($this->account($asset, 'cost_account_code'), $dept, 0, $cost, $text . ' · cost');
if ($gain > 0) {
$lines[] = $this->line($this->account($asset, 'gain_account_code'), $dept, 0, $gain, $text . ' · gain');
}
return [
'doc_date' => $asset['disposal_date'],
'reference' => $asset['asset_number'],
'description' => $text,
'lines' => $lines,
];
}
// ── helpers ──────────────────────────────────────────────────────────────
private function fetchAsset(int $id): array
{
$sth = $this->pdo->prepare(
"SELECT a.*, c.category_code, c.category_name, c.cost_account_code, c.accum_account_code,
c.expense_account_code, c.gain_account_code, c.loss_account_code
FROM td_asset a
LEFT JOIN md_asset_category c
ON c.company_id = a.company_id AND c.id = a.category_id
WHERE a.company_id = :cid AND a.id = :id
LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
$asset = $sth->fetch(PDO::FETCH_ASSOC);
if (!$asset) throw new Exception("Asset #{$id} not found.");
return $asset;
}
private function account(array $row, string $field): string
{
$code = trim((string)($row[$field] ?? ''));
if ($code === '') {
$category = trim(($row['category_code'] ?? '') . ' ' . ($row['category_name'] ?? ''));
throw new Exception("Asset category {$category} has no " . str_replace('_', ' ', $field) . '.');
}
return $code;
}
private function line(string $account_code, int $department_id, float $debit, float $credit, string $description): array
{
return [
'account_code' => $account_code,
'department_id' => $department_id,
'debit' => round($debit, 2),
'credit' => round($credit, 2),
'description' => mb_substr($description, 0, 255),
];
}
private function assertBalanced(array $lines, string $reference): void
{
$debit = round(array_sum(array_column($lines, 'debit')), 2);
$credit = round(array_sum(array_column($lines, 'credit')), 2);
if (!$lines || abs($debit - $credit) > 0.004) {
throw new Exception("GL entry for {$reference} does not balance (debit {$debit}, credit {$credit}).");
}
}
}
@@ -1,334 +0,0 @@
<?php
require_once __DIR__ . '/DepreciationCalculator.php';
require_once __DIR__ . '/AssetManager.php';
/**
* AssetRunManager
*
* Monthly depreciation (tangible) and amortization (intangible) runs. A run
* covers one class and one month; each becomes one GL entry that Accounting
* posts from Batch GL Entries. Running "through" a month creates one run per
* month that still has charges due, oldest first.
*/
class AssetRunManager
{
private const CLASS_META = [
'tangible' => ['source_type' => 'asset_depreciation', 'prefix' => 'AM-DEP', 'label' => 'Depreciation'],
'intangible' => ['source_type' => 'asset_amortization', 'prefix' => 'AM-AMT', 'label' => 'Amortization'],
];
private PDO $pdo;
private int $companyId;
public function __construct(PDO $pdo, int $company_id)
{
$this->pdo = $pdo;
$this->companyId = $company_id;
}
public static function sourceTypeFor(string $asset_class): string
{
return self::meta($asset_class)['source_type'];
}
public static function labelFor(string $asset_class): string
{
return self::meta($asset_class)['label'];
}
/**
* Charges due for every active asset of the class, from each asset's next
* uncharged month up to $through_period.
*
* @return array<int, array{period: string, run_date: string, asset_count: int, total: float, items: array}>
*/
public function preview(string $asset_class, string $through_period): array
{
self::meta($asset_class);
DepreciationCalculator::addMonths($through_period, 0); // validates the format
if (DepreciationCalculator::monthsBetween(date('Y-m'), $through_period) > 0) {
throw new Exception('You cannot run ' . strtolower(self::labelFor($asset_class)) . ' for a future month.');
}
$by_period = [];
foreach ($this->activeAssets($asset_class) as $asset) {
$cost = (float)$asset['cost'];
$salvage = (float)$asset['salvage_value'];
$life = (int)$asset['useful_life_months'];
$accumulated = round((float)$asset['accumulated'], 2);
$period = $asset['last_period'] !== ''
? DepreciationCalculator::addMonths($asset['last_period'], 1)
: DepreciationCalculator::firstPeriod($asset['in_service_date']);
while (DepreciationCalculator::monthsBetween($period, $through_period) >= 0) {
$index = DepreciationCalculator::periodIndex($asset['in_service_date'], $period);
$amount = DepreciationCalculator::amountFor($cost, $salvage, $life, $index, $accumulated);
if ($amount <= 0) break;
$accumulated = round($accumulated + $amount, 2);
$by_period[$period][] = [
'asset_id' => (int)$asset['id'],
'asset_number' => $asset['asset_number'],
'asset_name' => $asset['asset_name'],
'category_id' => (int)$asset['category_id'],
'category_name' => $asset['category_name'],
'department_id' => (int)$asset['department_id'],
'amount' => $amount,
'accumulated_after' => $accumulated,
'book_value_after' => round($cost - $accumulated, 2),
];
$period = DepreciationCalculator::addMonths($period, 1);
}
}
ksort($by_period);
$runs = [];
foreach ($by_period as $period => $items) {
$runs[] = [
'period' => $period,
'run_date' => DepreciationCalculator::periodEndDate($period),
'asset_count' => count($items),
'total' => round(array_sum(array_column($items, 'amount')), 2),
'items' => $items,
];
}
return $runs;
}
/** Create the runs shown by preview(). Call inside a transaction. */
public function run(string $asset_class, string $through_period, int $user_id): array
{
// Serialize concurrent runs for the same class.
$this->pdo->prepare(
"SELECT id FROM td_asset WHERE company_id = :cid AND asset_class = :class AND status = 1 FOR UPDATE"
)->execute([':cid' => $this->companyId, ':class' => $asset_class]);
$preview = $this->preview($asset_class, $through_period);
if (!$preview) {
throw new Exception('No ' . strtolower(self::labelFor($asset_class)) . " is due through {$through_period}.");
}
$insert_run = $this->pdo->prepare(
"INSERT INTO td_asset_run
(company_id, run_number, asset_class, period, run_date, asset_count, total_amount, status, created_by, created_at)
VALUES
(:cid, :number, :class, :period, :run_date, :count, :total, 1, :user_id, NOW())"
);
$insert_row = $this->pdo->prepare(
"INSERT INTO td_asset_depreciation
(company_id, asset_id, run_id, period, category_id, department_id,
amount, accumulated_after, book_value_after, created_at)
VALUES
(:cid, :asset_id, :run_id, :period, :category_id, :department_id,
:amount, :accumulated, :book_value, NOW())"
);
$created = [];
$asset_ids = [];
foreach ($preview as $run) {
$number = $this->nextRunNumber($asset_class, $run['period']);
$insert_run->execute([
':cid' => $this->companyId,
':number' => $number,
':class' => $asset_class,
':period' => $run['period'],
':run_date' => $run['run_date'],
':count' => $run['asset_count'],
':total' => $run['total'],
':user_id' => $user_id,
]);
$run_id = (int)$this->pdo->lastInsertId();
foreach ($run['items'] as $item) {
$insert_row->execute([
':cid' => $this->companyId,
':asset_id' => $item['asset_id'],
':run_id' => $run_id,
':period' => $run['period'],
':category_id' => $item['category_id'],
':department_id' => $item['department_id'],
':amount' => $item['amount'],
':accumulated' => $item['accumulated_after'],
':book_value' => $item['book_value_after'],
]);
$asset_ids[] = $item['asset_id'];
}
$created[] = [
'id' => $run_id,
'run_number' => $number,
'period' => $run['period'],
'asset_count' => $run['asset_count'],
'total' => $run['total'],
];
}
(new AssetManager($this->pdo, $this->companyId))->syncStatuses($asset_ids);
return $created;
}
public function getList(string $asset_class = ''): array
{
$where = ['r.company_id = :cid'];
$params = [':cid' => $this->companyId];
if ($asset_class !== '') {
self::meta($asset_class);
$where[] = 'r.asset_class = :class';
$params[':class'] = $asset_class;
}
$sth = $this->pdo->prepare(
"SELECT r.*,
DATE_FORMAT(r.created_at, '%Y-%m-%d %H:%i:%s') AS created_at_fmt,
IF(g.id IS NULL, 0, 1) AS gl_status,
COALESCE(g.id, 0) AS gl_id
FROM td_asset_run r
LEFT JOIN td_gl g
ON g.company_id = r.company_id
AND g.source_id = r.id
AND g.source_type = IF(r.asset_class = 'intangible', 'asset_amortization', 'asset_depreciation')
WHERE " . implode(' AND ', $where) . "
ORDER BY r.period DESC, r.id DESC"
);
$sth->execute($params);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
public function getDetail(int $run_id): array
{
$sth = $this->pdo->prepare("SELECT * FROM td_asset_run WHERE company_id = :cid AND id = :id LIMIT 1");
$sth->execute([':cid' => $this->companyId, ':id' => $run_id]);
$run = $sth->fetch(PDO::FETCH_ASSOC);
if (!$run) throw new Exception('Run not found.');
$sth = $this->pdo->prepare(
"SELECT d.asset_id, d.amount, d.accumulated_after, d.book_value_after, d.department_id,
a.asset_number, a.asset_name,
COALESCE(c.category_code, '') AS category_code,
COALESCE(c.category_name, '') AS category_name,
COALESCE(dp.dept_code, '') AS dept_code
FROM td_asset_depreciation d
JOIN td_asset a
ON a.id = d.asset_id AND ABS(a.company_id) = d.company_id
LEFT JOIN md_asset_category c
ON c.company_id = d.company_id AND c.id = d.category_id
LEFT JOIN md_department dp
ON dp.company_id = d.company_id AND dp.id = d.department_id
WHERE d.company_id = :cid AND d.run_id = :run_id
ORDER BY a.asset_number ASC"
);
$sth->execute([':cid' => $this->companyId, ':run_id' => $run_id]);
$items = $sth->fetchAll(PDO::FETCH_ASSOC);
// A void run's rows are gone; show what it held from the header only.
return ['run' => $run, 'items' => $items];
}
/**
* Void a run: reverse its GL entry if posted and remove its charges. Only
* the latest month of each asset can be removed, and not after a disposal.
*/
public function void(int $run_id, GlManager $gl): void
{
$sth = $this->pdo->prepare("SELECT * FROM td_asset_run WHERE company_id = :cid AND id = :id FOR UPDATE");
$sth->execute([':cid' => $this->companyId, ':id' => $run_id]);
$run = $sth->fetch(PDO::FETCH_ASSOC);
if (!$run) throw new Exception('Run not found.');
if ((int)$run['status'] !== 1) throw new Exception('This run is already void.');
$sth = $this->pdo->prepare(
"SELECT a.asset_number
FROM td_asset_depreciation d
JOIN td_asset a ON a.company_id = d.company_id AND a.id = d.asset_id
WHERE d.company_id = :cid AND d.run_id = :run_id AND a.status = 3
LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':run_id' => $run_id]);
if ($number = $sth->fetchColumn()) {
throw new Exception("Asset {$number} in this run has been disposed of. Undo the disposal first.");
}
$sth = $this->pdo->prepare(
"SELECT later.period
FROM td_asset_depreciation d
JOIN td_asset_depreciation later
ON later.company_id = d.company_id AND later.asset_id = d.asset_id AND later.period > d.period
WHERE d.company_id = :cid AND d.run_id = :run_id
ORDER BY later.period DESC
LIMIT 1"
);
$sth->execute([':cid' => $this->companyId, ':run_id' => $run_id]);
if ($later = $sth->fetchColumn()) {
throw new Exception("Assets in this run were charged again in {$later}. Void the later runs first.");
}
$gl->delete(self::sourceTypeFor($run['asset_class']), $run_id);
$sth = $this->pdo->prepare("SELECT asset_id FROM td_asset_depreciation WHERE company_id = :cid AND run_id = :run_id");
$sth->execute([':cid' => $this->companyId, ':run_id' => $run_id]);
$asset_ids = $sth->fetchAll(PDO::FETCH_COLUMN);
$this->pdo->prepare("DELETE FROM td_asset_depreciation WHERE company_id = :cid AND run_id = :run_id")
->execute([':cid' => $this->companyId, ':run_id' => $run_id]);
$this->pdo->prepare("UPDATE td_asset_run SET status = 4, voided_at = NOW() WHERE company_id = :cid AND id = :id")
->execute([':cid' => $this->companyId, ':id' => $run_id]);
(new AssetManager($this->pdo, $this->companyId))->syncStatuses($asset_ids);
}
/** What is due through the current month, per class (for the dashboard). */
public function dueSummary(): array
{
$summary = [];
foreach (array_keys(self::CLASS_META) as $asset_class) {
$runs = $this->preview($asset_class, date('Y-m'));
$summary[$asset_class] = [
'label' => self::labelFor($asset_class),
'period_count' => count($runs),
'from' => $runs ? $runs[0]['period'] : '',
'to' => $runs ? $runs[count($runs) - 1]['period'] : '',
'total' => round(array_sum(array_column($runs, 'total')), 2),
];
}
return $summary;
}
private function activeAssets(string $asset_class): array
{
$sth = $this->pdo->prepare(
"SELECT a.id, a.asset_number, a.asset_name, a.category_id, a.department_id,
a.cost, a.salvage_value, a.useful_life_months, a.in_service_date,
COALESCE(c.category_name, '') AS category_name,
COALESCE(d.accumulated, 0) AS accumulated,
COALESCE(d.last_period, '') AS last_period
FROM td_asset a
LEFT JOIN md_asset_category c
ON c.company_id = a.company_id AND c.id = a.category_id
LEFT JOIN (SELECT asset_id, SUM(amount) AS accumulated, MAX(period) AS last_period
FROM td_asset_depreciation WHERE company_id = :cid_d GROUP BY asset_id) d
ON d.asset_id = a.id
WHERE a.company_id = :cid AND a.asset_class = :class AND a.status = 1
ORDER BY a.asset_number ASC"
);
$sth->execute([':cid' => $this->companyId, ':cid_d' => $this->companyId, ':class' => $asset_class]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
private function nextRunNumber(string $asset_class, string $period): string
{
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM td_asset_run WHERE company_id = :cid AND asset_class = :class AND period = :period"
);
$sth->execute([':cid' => $this->companyId, ':class' => $asset_class, ':period' => $period]);
$existing = (int)$sth->fetchColumn();
$number = self::meta($asset_class)['prefix'] . '-' . $period;
return $existing > 0 ? $number . '-' . ($existing + 1) : $number;
}
private static function meta(string $asset_class): array
{
if (!isset(self::CLASS_META[$asset_class])) throw new Exception('Invalid asset class.');
return self::CLASS_META[$asset_class];
}
}
@@ -1,135 +0,0 @@
<?php
/**
* DepreciationCalculator
*
* Straight-line depreciation (tangible) and amortization (intangible) maths.
* No database access — AssetManager and AssetRunManager call it.
*
* Conventions:
* - Amounts are rounded to 2 decimals per month; the last month of the life
* takes whatever is left so the book value ends exactly at salvage value.
* - The month the asset is put in service counts as a full month.
* - The month an asset is disposed of gets no charge.
* - Periods are 'YYYY-MM' strings.
*/
final class DepreciationCalculator
{
public static function periodOf(string $date): string
{
if (!preg_match('/^(\d{4})-(\d{2})/', $date, $m)) {
throw new Exception("Invalid date '{$date}'.");
}
return "{$m[1]}-{$m[2]}";
}
public static function addMonths(string $period, int $months): string
{
[$y, $m] = self::splitPeriod($period);
$index = $y * 12 + ($m - 1) + $months;
return sprintf('%04d-%02d', intdiv($index, 12), $index % 12 + 1);
}
/** Number of months from $from to $to (0 when equal, negative when $to is earlier). */
public static function monthsBetween(string $from, string $to): int
{
[$fy, $fm] = self::splitPeriod($from);
[$ty, $tm] = self::splitPeriod($to);
return ($ty * 12 + $tm) - ($fy * 12 + $fm);
}
public static function periodEndDate(string $period): string
{
[$y, $m] = self::splitPeriod($period);
return date('Y-m-t', mktime(0, 0, 0, $m, 1, $y));
}
public static function depreciableBase(float $cost, float $salvage): float
{
return round($cost - $salvage, 2);
}
public static function monthlyAmount(float $cost, float $salvage, int $life_months): float
{
if ($life_months <= 0) return 0.0;
return round(self::depreciableBase($cost, $salvage) / $life_months, 2);
}
public static function firstPeriod(string $in_service_date): string
{
return self::periodOf($in_service_date);
}
public static function lastPeriod(string $in_service_date, int $life_months): string
{
return self::addMonths(self::firstPeriod($in_service_date), max(1, $life_months) - 1);
}
/**
* Charge for one period.
*
* @param int $period_index 1 for the in-service month, up to $life_months.
* @param float $accumulated Total already charged before this period.
*/
public static function amountFor(float $cost, float $salvage, int $life_months, int $period_index, float $accumulated): float
{
if ($life_months <= 0 || $period_index < 1 || $period_index > $life_months) return 0.0;
$remaining = round(self::depreciableBase($cost, $salvage) - $accumulated, 2);
if ($remaining <= 0) return 0.0;
if ($period_index === $life_months) return $remaining;
return min(self::monthlyAmount($cost, $salvage, $life_months), $remaining);
}
/**
* Full projected schedule.
*
* @return array<int, array{period: string, amount: float, accumulated: float, book_value: float}>
*/
public static function schedule(float $cost, float $salvage, int $life_months, string $in_service_date): array
{
$rows = [];
$accumulated = 0.0;
$period = self::firstPeriod($in_service_date);
for ($i = 1; $i <= $life_months; $i++) {
$amount = self::amountFor($cost, $salvage, $life_months, $i, $accumulated);
$accumulated = round($accumulated + $amount, 2);
$rows[] = [
'period' => $period,
'amount' => $amount,
'accumulated' => $accumulated,
'book_value' => round($cost - $accumulated, 2),
];
$period = self::addMonths($period, 1);
}
return $rows;
}
/** 1-based position of $period in the asset's life (0 or less = before in service). */
public static function periodIndex(string $in_service_date, string $period): int
{
return self::monthsBetween(self::firstPeriod($in_service_date), $period) + 1;
}
public static function assertInputs(float $cost, float $salvage, int $life_months): void
{
if ($cost <= 0) throw new Exception('Cost must be greater than zero.');
if ($salvage < 0) throw new Exception('Salvage value cannot be negative.');
if ($salvage >= $cost) throw new Exception('Salvage value must be less than cost.');
if ($life_months < 1) throw new Exception('Useful life must be at least 1 month.');
if ($life_months > 1200) throw new Exception('Useful life cannot exceed 1200 months.');
if (self::monthlyAmount($cost, $salvage, $life_months) <= 0) {
throw new Exception('The amount to depreciate is too small for this useful life.');
}
}
private static function splitPeriod(string $period): array
{
if (!preg_match('/^(\d{4})-(\d{2})$/', $period, $m) || (int)$m[2] < 1 || (int)$m[2] > 12) {
throw new Exception("Invalid period '{$period}'.");
}
return [(int)$m[1], (int)$m[2]];
}
}
-11
View File
@@ -20,17 +20,6 @@ if (!function_exists('require_role')) {
}
}
// Blocks users whose app_access does not include $app ('all' includes every app).
if (!function_exists('require_app_access')) {
function require_app_access(string $app): void {
$access = $_SESSION['login_app_access'] ?? 'wms';
if ($access !== 'all' && $access !== $app) {
http_response_code(403);
exit(json_encode(['success' => 0, 'message' => 'You do not have access to this app.']));
}
}
}
if(!empty($_SESSION["login_company_id"])){
// CSRF Validation — add right at the top of the logged-in block
-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
));
}
}
-20
View File
@@ -38,26 +38,6 @@ if (!defined('NODE_EMIT_SECRET')) {
define('NODE_EMIT_SECRET', 'YOUR_NODE_EMIT_SECRET'); // must match nodejs/.env EMIT_SECRET
}
// ── Login OTP ────────────────────────────────────────────────────────────────
// Email OTP on sign-in. OFF BY DEFAULT: only the boolean true turns it on —
// anything else, the constant being absent included, leaves sign-in password
// only (logged as OTP_BYPASSED, shown on the login page and top bar). Turn it
// on only with working SMTP. Password-reset OTPs are not affected.
if (!defined('OTP_REQUIRED')) {
define('OTP_REQUIRED', false);
}
// ── App registry ─────────────────────────────────────────────────────────────
// Apps a user can be given access to, as shown on Setting → Users Access. Keys
// must match the user.app_access enum ('wms', 'accounting', 'asset'). If this is
// left out, assets/utils/app_registry.php supplies the same default and fills in
// any app or 'home' page missing here.
$app_registry = [
'wms' => ['label' => 'WMS', 'icon' => 'ti-box', 'color' => 'bg-label-primary', 'home' => 'dashboard/index.php'],
'accounting' => ['label' => 'Accounting', 'icon' => 'ti-calculator', 'color' => 'bg-label-info', 'home' => 'ac_dashboard/index.php'],
'asset' => ['label' => 'Asset Management', 'icon' => 'ti-building-warehouse', 'color' => 'bg-label-success', 'home' => 'asset_dashboard/index.php'],
];
// ── Usage packages ───────────────────────────────────────────────────────────
// Keyed by company_list.package (defaults to 'starter'). Read by UsageGuard to
// enforce daily/weekly action limits and which features lock once exceeded.
-5
View File
@@ -27,11 +27,6 @@ class db_statement extends PDOStatement {
$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) {
// Perform logging here. PDO object is accessible
// from $this->pdo.
+2 -71
View File
@@ -4,8 +4,6 @@
require '../include_header.php';
$invoice_id = (int)($_GET['id'] ?? 0);
// Users with Asset Management can turn issued invoice lines into assets.
$pi_asset_access = in_array($_SESSION['login_app_access'] ?? 'wms', ['all', 'asset'], true);
?>
<body>
@@ -75,30 +73,6 @@
</div>
</div>
</div>
<!-- Assets created from this invoice (Asset Management) -->
<div class="card mt-5 d-none" id="asset_card">
<div class="card-body p-5">
<h2 class="fs-5 mb-4">
<span class="badge bg-success text-white me-2"><i class="ti ti-building-warehouse me-1"></i>Asset Management</span>
Assets from this invoice
</h2>
<div class="table-responsive">
<table class="table mb-0 table-hover">
<thead class="table-light">
<tr>
<th>Asset No.</th>
<th>Name</th>
<th class="text-end">Cost</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody id="asset_tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Right: Info + actions -->
@@ -186,44 +160,6 @@
var invoice_id = <?php echo $invoice_id; ?>;
var invoice_data = null;
var show_asset_actions = <?php echo $pi_asset_access ? 'true' : 'false'; ?>;
var ASSET_STATUS_BADGE = {
'0': '<span class="badge bg-secondary">Draft</span>',
'1': '<span class="badge bg-success">Active</span>',
'2': '<span class="badge bg-info text-white">Fully depreciated</span>',
'3': '<span class="badge bg-dark">Disposed</span>',
'4': '<span class="badge bg-light text-dark">Void</span>',
};
function load_invoice_assets() {
return ajax_request({
url: '<?php echo $server_url?>asset/api/engine/asset.php',
autoPrepare: true,
checkRequired: 0,
action: 'read',
queueLock: false,
noLoading: true,
noAlert: true,
data: { invoice_id: invoice_id },
onSuccess: function(res) {
var rows = res.output || [];
if (!rows.length) { $('#asset_card').addClass('d-none'); return; }
var html = '';
$.each(rows, function(i, a) {
html += `<tr>
<td>${escape_html(a.asset_number)}</td>
<td>${escape_html(a.asset_name)}</td>
<td class="text-end">${format_number(a.cost, 2)}</td>
<td>${ASSET_STATUS_BADGE[String(a.status)] || '—'}</td>
<td><a href="<?php echo $server_url?>asset/manage_asset.php?id=${a.id}"><i class="ti ti-eye fs-5"></i></a></td>
</tr>`;
});
$('#asset_tbody').html(html);
$('#asset_card').removeClass('d-none');
}
});
}
function doc_type_badge(doc_type) {
const map = {
@@ -294,7 +230,7 @@
$('#badge_status').html(invoice_status_badge(inv.status, inv.due_date));
$('#display_contact').text(inv.contact_name || '—');
$('#display_issued').text(format_date(inv.issued_date) || '—');
$('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : '');
$('#due_date').val(inv.due_date ? format_date(inv.due_date) : '');
$('#notes').val(inv.notes || '');
// Source link
@@ -309,14 +245,10 @@
}
// Items
var can_capitalize = show_asset_actions && inv.doc_type === 'purchase_invoice' && [1, 2].includes(parseInt(inv.status));
var tbody = '';
$.each(inv.items || [], function(i, item) {
var asset_link = can_capitalize
? `<div><a class="small link-success" href="<?php echo $server_url?>asset/manage_asset.php?invoice_id=${invoice_id}&item_id=${item.id}"><i class="ti ti-building-warehouse me-1"></i>Create asset</a></div>`
: '';
tbody += `<tr>
<td>${escape_html(item.product_name || item.product_sku)}${asset_link}</td>
<td>${escape_html(item.product_name || item.product_sku)}</td>
<td class="text-end">${format_number(item.quantity, 2)}</td>
<td class="text-end">${format_number(item.unit_price, 2)}</td>
<td class="text-end">${format_number(item.tax_rate, 2)}</td>
@@ -378,7 +310,6 @@
$('#btn_save_formula').prop('disabled', parseInt(inv.status) === 4);
set_mode(inv);
if (show_asset_actions && inv.doc_type === 'purchase_invoice') load_invoice_assets();
}
});
}
+2 -2
View File
@@ -274,8 +274,8 @@
$('#po_number_display').text(po_data.po_number || '');
$('#contact').val(po_data.contact_name || '');
$('#contact_id').val(po_data.contact_id || '');
$('#po_date').val(po_data.po_date ? format_date_input(po_data.po_date) : '');
$('#expected_date').val(po_data.expected_date ? format_date_input(po_data.expected_date) : '');
$('#po_date').val(po_data.po_date ? format_date(po_data.po_date) : '');
$('#expected_date').val(po_data.expected_date ? format_date(po_data.expected_date) : '');
$('#department_id').val(po_data.department_id || 0);
$('#notes').val(po_data.notes || '');
$('#discount').val(po_data.discount || 0);
+2 -2
View File
@@ -253,8 +253,8 @@
.html(request_data.po_id > 0 ? 'PO: <a href="<?php echo $server_url?>expense/manage_purchase_order.php?id=' + request_data.po_id + '">' + escape_html(request_data.po_number || ('PO #' + request_data.po_id)) + '</a>' : '');
$('#contact').val(request_data.contact_name || '');
$('#contact_id').val(request_data.contact_id || 0);
$('#request_date').val(request_data.request_date ? format_date_input(request_data.request_date) : '');
$('#required_date').val(request_data.required_date ? format_date_input(request_data.required_date) : '');
$('#request_date').val(request_data.request_date ? format_date(request_data.request_date) : '');
$('#required_date').val(request_data.required_date ? format_date(request_data.required_date) : '');
$('#department_id').val(request_data.department_id || 0);
$('#notes').val(request_data.notes || '');
$('#discount').val(request_data.discount || 0);
-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>
</div>
<!-- Quantity: a stock-out always takes everything in the location -->
<div class="mb-3 col-lg-6">
<label for="location_quantity" class="form-label">Quantity</label>
<div class="input-group">
<input type="text" id="location_quantity" class="form-control text-end" placeholder="—" disabled>
<span class="input-group-text" id="location_uom" style="min-width:60px;">&nbsp;</span>
</div>
<div class="form-text">The whole quantity in the selected location is taken out.</div>
</div>
<!-- Contact -->
<div class="mb-3 col-lg-6">
<label for="contact" class="form-label">Contact</label>
@@ -169,35 +159,6 @@
}
set_select_value('#bin', data.bin, data.bin);
scan_location_ready = true;
show_location_quantity();
});
}
// Quantity held in the chosen location — the amount this stock-out moves.
function show_location_quantity() {
var bin_val = $('#bin').val();
$('#location_quantity').val('');
$('#location_uom').html('&nbsp;');
if (!$('#warehouse').val() || !bin_val) return;
return ajax_request({
url: '<?php echo $server_url?>ics/api/engine/retrieve_bin_stock.php',
autoPrepare: true,
checkRequired: 0,
noLoading: true,
queueLock: false,
action: 'read',
data: {
warehouse: $('#warehouse').val(),
zone: advanced ? $('#zone').val() : bin_val,
aisle: advanced ? $('#aisle').val() : bin_val,
bin: bin_val
},
onSuccess: function(res) {
if (!res.output) return;
$('#location_quantity').val(format_number(res.output.quantity, 2));
$('#location_uom').text(res.output.uom || '');
}
});
}
@@ -522,8 +483,6 @@
$('#zone').html('<option value="">Please select ' + label_zone.toLowerCase() + '</option>').prop('disabled', true);
$('#aisle').html('<option value="">Please select ' + label_aisle.toLowerCase() + '</option>').prop('disabled', true);
$('#bin').html('<option value="">Please select ' + label_bin.toLowerCase() + '</option>').prop('disabled', true);
$('#location_quantity').val('');
$('#location_uom').html('&nbsp;');
}
@@ -550,7 +509,7 @@
$('#serial_number').html(`<option value="${data.serial_number || ''}">${data.serial_number || '—'}</option>`)
.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() {
$.each(data, function(key, item) {
@@ -563,8 +522,6 @@
.attr('data-id', data.contact_id);
$('#product_name').val(data.product_name);
$('#product_sku').attr('secondary', data.product_sku).prop('disabled', true);
$('#location_quantity').val(format_number(data.quantity, 2));
$('#location_uom').text(data.uom || '');
$('button[type=submit]').text('Update');
$('button[type=reset]').hide();
if (data.status == 0) {
@@ -662,9 +619,6 @@
if (advanced) retrieve_bin();
});
// Bin selected → show how much it holds
$('#bin').on('change', show_location_quantity);
<?php } ?>
</script>
-45
View File
@@ -92,15 +92,6 @@
<option value="">Please select bin</option>
</select>
</div>
<!-- Quantity: a transfer always moves everything in the from-location -->
<div class="mb-3 col-lg-3">
<label for="transfer_quantity" class="form-label">Quantity</label>
<div class="input-group">
<input type="text" id="transfer_quantity" class="form-control text-end" placeholder="—" disabled>
<span class="input-group-text" id="transfer_uom" style="min-width:52px;">&nbsp;</span>
</div>
<div class="form-text">Whole location is moved.</div>
</div>
<div class="col-12"><hr class="my-2"></div>
@@ -213,41 +204,12 @@
if (role === 'from') {
scan_from_ready = true;
show_from_quantity();
} else {
scan_to_ready = true;
}
});
}
// Quantity held in the from-location — the amount this transfer moves.
function show_from_quantity() {
var bin_val = $('#bin_from').val();
$('#transfer_quantity').val('');
$('#transfer_uom').html('&nbsp;');
if (!$('#warehouse_from').val() || !bin_val) return;
return ajax_request({
url: '<?php echo $server_url?>ics/api/engine/retrieve_bin_stock.php',
autoPrepare: true,
checkRequired: 0,
noLoading: true,
queueLock: false,
action: 'read',
data: {
warehouse: $('#warehouse_from').val(),
zone: advanced ? $('#zone_from').val() : bin_val,
aisle: advanced ? $('#aisle_from').val() : bin_val,
bin: bin_val
},
onSuccess: function(res) {
if (!res.output) return;
$('#transfer_quantity').val(format_number(res.output.quantity, 2));
$('#transfer_uom').text(res.output.uom || '');
}
});
}
function same_location_as_from(data) {
return String($('#warehouse_from').val()) === String(data.warehouse_id)
&& String($('#zone_from').val()) === String(data.zone)
@@ -612,8 +574,6 @@
$('#zone_from').html('<option value="">Please select ' + label_zone.toLowerCase() + '</option>').prop('disabled', true);
$('#aisle_from').html('<option value="">Please select ' + label_aisle.toLowerCase() + '</option>').prop('disabled', true);
$('#bin_from').html('<option value="">Please select ' + label_bin.toLowerCase() + '</option>').prop('disabled', true);
$('#transfer_quantity').val('');
$('#transfer_uom').html('&nbsp;');
}
@@ -680,8 +640,6 @@
$('#contact').val(data.contact_name)
.attr('secondary', data.contact_name)
.attr('data-id', data.contact_id);
$('#transfer_quantity').val(format_number(data.quantity, 2));
$('#transfer_uom').text(data.uom || '');
$('button[type=submit]').text('Update');
$('button[type=reset]').hide();
if (data.status == 0) {
@@ -774,9 +732,6 @@
if (advanced) retrieve_bin('from');
});
// From bin → show how much it holds
$('#bin_from').on('change', show_from_quantity);
// To warehouse → zone (advanced) or bin directly (simple)
$('select[name="warehouse"][role="to"]').on('change', function() {
if (advanced) { retrieve_zone('to'); } else { retrieve_bin('to'); }
-19
View File
@@ -1,23 +1,4 @@
<?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.
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: SAMEORIGIN');
+6 -4
View File
@@ -9,10 +9,12 @@
$master_can_admin = in_array($master_role, ['owner', 'admin'], true);
$master_app_access = $_SESSION['login_app_access'] ?? 'wms';
$master_show_accounting = in_array($master_app_access, ['all', 'accounting']);
require_once __DIR__ . '/assets/utils/app_registry.php';
$master_current_app = $_SESSION['login_current_app'] ?? 'wms';
$master_back_url = $server_url . app_home_path($app_registry, $master_current_app);
$master_back_label = 'Back to ' . ($app_registry[$master_current_app]['label'] ?? 'WMS');
$master_back_url = ($_SESSION['login_current_app'] ?? 'wms') === 'accounting'
? $server_url . 'ac_dashboard/index.php'
: $server_url . 'dashboard/index.php';
$master_back_label = ($_SESSION['login_current_app'] ?? 'wms') === 'accounting'
? 'Back to Accounting'
: 'Back to WMS';
?>
<!-- SIDEBAR -->
+1 -2
View File
@@ -93,9 +93,8 @@
<small class="nav-text text-muted">Navigation</small>
</li>
<?php require_once __DIR__ . '/assets/utils/app_registry.php'; ?>
<li>
<a class="nav-link" href="<?php echo $server_url . app_home_path($app_registry, $_SESSION['login_current_app'] ?? 'wms'); ?>">
<a class="nav-link" href="<?php echo $server_url?>dashboard/index.php">
<i class="ti ti-arrow-left"></i>
<span class="nav-text">Back to App</span>
</a>
-94
View File
@@ -1,94 +0,0 @@
<?php
$cp = $_SERVER['PHP_SELF'];
function asset_active(...$frags) {
global $cp;
foreach ($frags as $f) { if (str_contains($cp, $f)) return 'active'; }
return '';
}
$asset_show_accounting = in_array($_SESSION['login_app_access'] ?? 'wms', ['all', 'accounting'], true);
?>
<!-- SIDEBAR -->
<aside id="sidebar" class="sidebar overflow-y-auto">
<div class="logo-area">
<a href="<?php echo $server_url?>asset_dashboard/index.php" class="d-inline-flex">
<span class="logo-text">
<img src="<?php echo $server_url?>assets/images/logo.png" alt="" height="48"/>
</span>
</a>
</div>
<ul class="nav flex-column">
<!-- Main -->
<li class="nav-text-space">
<small class="nav-text text-muted">Main</small>
</li>
<li>
<a class="nav-link <?php echo asset_active('/asset_dashboard/'); ?>" href="<?php echo $server_url?>asset_dashboard/index.php">
<i class="ti ti-home"></i>
<span class="nav-text">Dashboard</span>
</a>
</li>
<!-- Assets -->
<li class="nav-text-space">
<small class="nav-text text-muted">Assets</small>
</li>
<li>
<a class="nav-link <?php echo asset_active('/asset/index', '/asset/manage_asset'); ?>" href="<?php echo $server_url?>asset/index.php">
<i class="ti ti-building-warehouse"></i>
<span class="nav-text">Asset Register</span>
</a>
</li>
<li>
<a class="nav-link <?php echo asset_active('/asset/purchase_invoices'); ?>" href="<?php echo $server_url?>asset/purchase_invoices.php">
<i class="ti ti-file-invoice"></i>
<span class="nav-text">From Purchase Invoices</span>
</a>
</li>
<!-- Depreciation -->
<li class="nav-text-space">
<small class="nav-text text-muted">Depreciation</small>
</li>
<li>
<a class="nav-link <?php echo asset_active('/asset/depreciation'); ?>" href="<?php echo $server_url?>asset/depreciation.php">
<i class="ti ti-calendar-stats"></i>
<span class="nav-text">Depreciation Runs</span>
</a>
</li>
<!-- Setup -->
<li class="nav-text-space">
<small class="nav-text text-muted">Setup</small>
</li>
<li>
<a class="nav-link <?php echo asset_active('/asset/categories', '/asset/manage_category'); ?>" href="<?php echo $server_url?>asset/categories.php">
<i class="ti ti-category"></i>
<span class="nav-text">Asset Categories</span>
</a>
</li>
<?php if ($asset_show_accounting): ?>
<!-- Accounting -->
<li class="nav-text-space">
<small class="nav-text text-muted">Accounting</small>
</li>
<li>
<a class="nav-link" href="<?php echo $server_url?>accounting/gl_entries.php">
<i class="ti ti-stack-2"></i>
<span class="nav-text">Batch GL Entries</span>
</a>
</li>
<?php endif; ?>
<li><br><br><br><br></li>
</ul>
</aside>
+69 -107
View File
@@ -3,17 +3,11 @@
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/dbconn.php';
require_once __DIR__ . '/assets/utils/classes/UsageGuard.php';
require_once __DIR__ . '/assets/utils/otp_policy.php';
// Redirect to login if the user has not completed full authentication.
// login_company_id is only written by login_confirm.php after OTP is verified —
// using it (not "otp") ensures half-logged-in sessions are also redirected.
if(empty($_SESSION["login_company_id"])){
// Discard the markup include_header.php has already buffered so the browser
// receives a clean redirect rather than a partially rendered page body.
while (ob_get_level() > 0) {
ob_end_clean();
}
header('Location: '.$server_url.'login/index.php');
exit;
}
@@ -29,32 +23,21 @@ $topbar_avatar_src = $topbar_picture
: htmlspecialchars($server_url . 'assets/images/avatar-1.jpg', ENT_QUOTES, 'UTF-8');
// ── App switcher ──────────────────────────────────────────────────────────────
require_once __DIR__ . '/assets/utils/app_registry.php';
$topbar_app_access = $_SESSION['login_app_access'] ?? 'wms';
// Detect which app this page belongs to.
// Master-data pages (/accounting/, /inventory/, /contact/, /setting/) are neutral — don't change the session.
// '/asset_dashboard/' and '/ac_dashboard/' do not match '/dashboard/' (no slash before "dashboard").
$_app_paths = [
'wms' => ['/dashboard/', '/ics/', '/order/', '/po/', '/reports/'],
'accounting' => ['/ac_dashboard/', '/revenue/', '/expense/', '/finance/', '/journal/'],
'asset' => ['/asset_dashboard/', '/asset/'],
];
$_wms_paths = ['/dashboard/', '/ics/', '/order/', '/po/', '/reports/'];
$_ac_paths = ['/ac_dashboard/', '/revenue/', '/expense/', '/finance/', '/journal/'];
$topbar_current_app = null; // null = neutral page (master data / setting)
foreach ($_app_paths as $_app_key => $_paths) {
foreach ($_paths as $_p) {
if (str_contains($_SERVER['PHP_SELF'], $_p)) { $topbar_current_app = $_app_key; break 2; }
}
foreach ($_wms_paths as $_p) {
if (str_contains($_SERVER['PHP_SELF'], $_p)) { $topbar_current_app = 'wms'; break; }
}
// Asset Management pages are only for users given that app.
if ($topbar_current_app === 'asset' && !app_can_access($topbar_app_access, 'asset')) {
while (ob_get_level() > 0) {
ob_end_clean();
if (!$topbar_current_app) {
foreach ($_ac_paths as $_p) {
if (str_contains($_SERVER['PHP_SELF'], $_p)) { $topbar_current_app = 'accounting'; break; }
}
header('Location: ' . $server_url . app_home_path($app_registry, app_default_for_access($app_registry, $topbar_app_access)));
exit;
}
// Only persist to session when on a definitive app page
@@ -63,24 +46,11 @@ if ($topbar_current_app) {
}
// Default session on first visit or neutral pages
if (empty($_SESSION['login_current_app'])) {
$_SESSION['login_current_app'] = app_default_for_access($app_registry, $topbar_app_access);
$_SESSION['login_current_app'] = ($topbar_app_access === 'accounting') ? 'accounting' : 'wms';
}
$topbar_show_wms = app_can_access($topbar_app_access, 'wms');
$topbar_show_accounting = app_can_access($topbar_app_access, 'accounting');
$topbar_switch_apps = array_filter(
$app_registry,
fn($key) => app_can_access($topbar_app_access, $key),
ARRAY_FILTER_USE_KEY
);
// Per-app colour. WMS keeps the compiled orange theme (#E66239); the others override it.
$_app_themes = [
'accounting' => ['main' => '#0284c7', 'rgb' => '2, 132, 199', 'dark' => '#0369a1', 'subtle' => '#e0f2fe', 'border' => '#bae6fd'],
'asset' => ['main' => '#16a34a', 'rgb' => '22, 163, 74', 'dark' => '#15803d', 'subtle' => '#dcfce7', 'border' => '#bbf7d0'],
];
$_theme_app = $_SESSION['login_current_app'] ?? 'wms';
$_theme = $_app_themes[$_theme_app] ?? null;
$topbar_show_wms = in_array($topbar_app_access, ['all', 'wms']);
$topbar_show_accounting = in_array($topbar_app_access, ['all', 'accounting']);
$_usage = (new UsageGuard($pdo1, (int)$_SESSION['login_company_id'], $packages))->getStatus();
$_usage_max_pct = max($_usage['daily_pct'], $_usage['weekly_pct']);
@@ -88,67 +58,67 @@ $_usage_warn = $_usage_max_pct >= 80 && $_usage_max_pct < 100;
$_usage_full = $_usage_max_pct >= 100;
?>
<?php if ($_theme): ?>
<style id="theme-<?php echo $_theme_app; ?>">
<?php if (($_SESSION['login_current_app'] ?? 'wms') === 'accounting'): ?>
<style id="theme-accounting">
:root {
--bs-primary: <?php echo $_theme['main']; ?>;
--bs-primary-rgb: <?php echo $_theme['rgb']; ?>;
--bs-primary-bg-subtle: <?php echo $_theme['subtle']; ?>;
--bs-primary-border-subtle: <?php echo $_theme['border']; ?>;
--bs-primary-text-emphasis: <?php echo $_theme['dark']; ?>;
--bs-focus-ring-color: rgba(<?php echo $_theme['rgb']; ?>, .25);
--bs-primary: #0284c7;
--bs-primary-rgb: 2, 132, 199;
--bs-primary-bg-subtle: #e0f2fe;
--bs-primary-border-subtle: #bae6fd;
--bs-primary-text-emphasis: #0369a1;
--bs-focus-ring-color: rgba(2, 132, 199, .25);
}
/* Sidebar active/hover — hardcoded in theme */
.sidebar .nav-link:hover,
.sidebar .nav-link.active {
color: <?php echo $_theme['main']; ?> !important;
background-color: rgba(<?php echo $_theme['rgb']; ?>, 0.09) !important;
color: #0284c7 !important;
background-color: rgba(2, 132, 199, 0.09) !important;
}
/* Buttons */
.btn-primary {
--bs-btn-bg: <?php echo $_theme['main']; ?>;
--bs-btn-border-color: <?php echo $_theme['main']; ?>;
--bs-btn-hover-bg: <?php echo $_theme['dark']; ?>;
--bs-btn-hover-border-color: <?php echo $_theme['dark']; ?>;
--bs-btn-active-bg: <?php echo $_theme['dark']; ?>;
--bs-btn-active-border-color: <?php echo $_theme['dark']; ?>;
--bs-btn-disabled-bg: <?php echo $_theme['main']; ?>;
--bs-btn-disabled-border-color: <?php echo $_theme['main']; ?>;
--bs-btn-focus-shadow-rgb: <?php echo $_theme['rgb']; ?>;
--bs-btn-bg: #0284c7;
--bs-btn-border-color: #0284c7;
--bs-btn-hover-bg: #0369a1;
--bs-btn-hover-border-color: #0369a1;
--bs-btn-active-bg: #0369a1;
--bs-btn-active-border-color: #0369a1;
--bs-btn-disabled-bg: #0284c7;
--bs-btn-disabled-border-color: #0284c7;
--bs-btn-focus-shadow-rgb: 2, 132, 199;
}
.btn-outline-primary {
--bs-btn-color: <?php echo $_theme['main']; ?>;
--bs-btn-border-color: <?php echo $_theme['main']; ?>;
--bs-btn-hover-bg: <?php echo $_theme['main']; ?>;
--bs-btn-hover-border-color: <?php echo $_theme['main']; ?>;
--bs-btn-active-bg: <?php echo $_theme['main']; ?>;
--bs-btn-active-border-color: <?php echo $_theme['main']; ?>;
--bs-btn-disabled-color: <?php echo $_theme['main']; ?>;
--bs-btn-disabled-border-color: <?php echo $_theme['main']; ?>;
--bs-btn-color: #0284c7;
--bs-btn-border-color: #0284c7;
--bs-btn-hover-bg: #0284c7;
--bs-btn-hover-border-color: #0284c7;
--bs-btn-active-bg: #0284c7;
--bs-btn-active-border-color: #0284c7;
--bs-btn-disabled-color: #0284c7;
--bs-btn-disabled-border-color: #0284c7;
}
.pagination {
--bs-pagination-active-bg: <?php echo $_theme['main']; ?>;
--bs-pagination-active-border-color: <?php echo $_theme['main']; ?>;
--bs-pagination-active-bg: #0284c7;
--bs-pagination-active-border-color: #0284c7;
}
.nav-pills { --bs-nav-pills-link-active-bg: <?php echo $_theme['main']; ?>; }
.dropdown-menu { --bs-dropdown-link-active-bg: <?php echo $_theme['main']; ?>; }
.nav-pills { --bs-nav-pills-link-active-bg: #0284c7; }
.dropdown-menu { --bs-dropdown-link-active-bg: #0284c7; }
.list-group {
--bs-list-group-active-bg: <?php echo $_theme['main']; ?>;
--bs-list-group-active-border-color: <?php echo $_theme['main']; ?>;
--bs-list-group-active-bg: #0284c7;
--bs-list-group-active-border-color: #0284c7;
}
.progress, .progress-stacked { --bs-progress-bar-bg: <?php echo $_theme['main']; ?>; }
.progress, .progress-stacked { --bs-progress-bar-bg: #0284c7; }
.table-primary {
--bs-table-color: <?php echo $_theme['main']; ?>;
--bs-table-bg: rgba(<?php echo $_theme['rgb']; ?>, 0.09);
--bs-table-border-color: rgba(<?php echo $_theme['rgb']; ?>, 0.15);
--bs-table-striped-bg: rgba(<?php echo $_theme['rgb']; ?>, 0.12);
--bs-table-active-bg: rgba(<?php echo $_theme['rgb']; ?>, 0.15);
--bs-table-hover-bg: rgba(<?php echo $_theme['rgb']; ?>, 0.12);
--bs-table-color: #0284c7;
--bs-table-bg: rgba(2, 132, 199, 0.09);
--bs-table-border-color: rgba(2, 132, 199, 0.15);
--bs-table-striped-bg: rgba(2, 132, 199, 0.12);
--bs-table-active-bg: rgba(2, 132, 199, 0.15);
--bs-table-hover-bg: rgba(2, 132, 199, 0.12);
}
thead.border-light th {
background-color: rgba(<?php echo $_theme['rgb']; ?>, 0.09) !important;
color: <?php echo $_theme['main']; ?> !important;
border-color: rgba(<?php echo $_theme['rgb']; ?>, 0.15) !important;
background-color: rgba(2, 132, 199, 0.09) !important;
color: #0284c7 !important;
border-color: rgba(2, 132, 199, 0.15) !important;
}
</style>
<?php endif; ?>
@@ -195,40 +165,34 @@ $_usage_full = $_usage_max_pct >= 100;
<ul class="list-unstyled d-flex align-items-center mb-0 gap-1">
<!-- App switcher -->
<?php if (count($topbar_switch_apps) > 1): ?>
<?php if ($topbar_show_wms && $topbar_show_accounting): ?>
<li class="dropdown">
<a class="position-relative btn-icon btn-sm btn-light btn rounded-circle" data-bs-toggle="dropdown"
aria-expanded="false" href="#" role="button" title="Switch App">
<i class="ti ti-layout-grid" style="font-size:18px;"></i>
</a>
<div class="dropdown-menu dropdown-menu-end p-2" style="min-width:210px;">
<div class="dropdown-menu dropdown-menu-end p-2" style="min-width:170px;">
<?php $_sess_app = $_SESSION['login_current_app'] ?? 'wms'; ?>
<?php foreach ($topbar_switch_apps as $_app_key => $_app): ?>
<a href="<?php echo $server_url . app_home_path($app_registry, $_app_key); ?>"
class="dropdown-item rounded d-flex align-items-center gap-2 py-2 <?php echo $_sess_app === $_app_key ? 'active' : ''; ?>">
<i class="ti <?php echo htmlspecialchars($_app['icon'] ?? 'ti-apps', ENT_QUOTES, 'UTF-8'); ?>"></i>
<span><?php echo htmlspecialchars($_app['label'] ?? $_app_key, ENT_QUOTES, 'UTF-8'); ?></span>
<?php if ($_sess_app === $_app_key): ?>
<a href="<?php echo $server_url?>dashboard/index.php"
class="dropdown-item rounded d-flex align-items-center gap-2 py-2 <?php echo $_sess_app === 'wms' ? 'active' : ''; ?>">
<i class="ti ti-box"></i>
<span>WMS</span>
<?php if ($_sess_app === 'wms'): ?>
<i class="ti ti-check ms-auto text-success"></i>
<?php endif; ?>
</a>
<a href="<?php echo $server_url?>ac_dashboard/index.php"
class="dropdown-item rounded d-flex align-items-center gap-2 py-2 <?php echo $_sess_app === 'accounting' ? 'active' : ''; ?>">
<i class="ti ti-calculator"></i>
<span>Accounting</span>
<?php if ($_sess_app === 'accounting'): ?>
<i class="ti ti-check ms-auto text-success"></i>
<?php endif; ?>
</a>
<?php endforeach; ?>
</div>
</li>
<?php endif; ?>
<!-- Email OTP off (the default): a password-only sign-in must never be invisible to whoever is using it -->
<?php if (!otp_required()): ?>
<li class="d-none d-md-block">
<span class="badge bg-warning text-dark d-flex align-items-center gap-1 px-2 py-1"
style="font-size:11px; cursor:default;"
title="OTP_REQUIRED is not true in config.php">
<i class="ti ti-shield-off"></i>
OTP off
</span>
</li>
<?php endif; ?>
<!-- Usage limit warning -->
<?php if ($_usage_full || $_usage_warn): ?>
<li>
@@ -438,9 +402,7 @@ function do_switch_branch(company_id) {
autoPrepare: true,
checkRequired: 0,
action: 'update',
// Sent under its own key: prepare_form_data() always fills company_id with
// the CURRENT company, and only options.data reaches the payload.
data: { target_company_id: company_id },
company_id: company_id,
onSuccess: function(res) {
window.location.reload();
}
+1 -13
View File
@@ -43,12 +43,6 @@
<option value="receipt">Receipt</option>
<option value="payment">Payment</option>
<option value="manual">Manual</option>
<optgroup label="Asset Management">
<option value="asset_capitalization">[Asset Mgmt] Capitalization</option>
<option value="asset_depreciation">[Asset Mgmt] Depreciation</option>
<option value="asset_amortization">[Asset Mgmt] Amortization</option>
<option value="asset_disposal">[Asset Mgmt] Disposal</option>
</optgroup>
</select>
</div>
<div class="col-auto">
@@ -111,8 +105,6 @@
invoice: 'Invoice', credit_note: 'Credit Note',
purchase_invoice: 'Purchase Invoice', supplier_credit_note: 'Supplier Credit Note',
receipt: 'Receipt', payment: 'Payment', manual: 'Manual',
asset_capitalization: 'Asset Mgmt · Capitalization', asset_depreciation: 'Asset Mgmt · Depreciation',
asset_amortization: 'Asset Mgmt · Amortization', asset_disposal: 'Asset Mgmt · Disposal',
};
var type_badge_cls = {
invoice: 'bg-primary-subtle text-primary',
@@ -122,10 +114,6 @@
supplier_credit_note: 'bg-secondary-subtle text-secondary',
credit_note: 'bg-info-subtle text-info',
manual: 'bg-dark-subtle text-dark',
asset_capitalization: 'bg-success text-white',
asset_depreciation: 'bg-success text-white',
asset_amortization: 'bg-success text-white',
asset_disposal: 'bg-success text-white',
};
function load_listing() {
@@ -199,7 +187,7 @@
document.getElementById('gl_detail_body').innerHTML =
'<div class="row g-3 mb-4">' +
'<div class="col-6"><p class="text-muted small mb-0">Date</p><strong>' + escape_html(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">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>' +
+2 -9
View File
@@ -58,7 +58,6 @@ require_once '../../../config.php';
require_once '../../../preset.php';
define('UNAUTHENTICATED_ROUTE', true);
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/otp_policy.php';
// ── Step 1: Load session state written by login_otp.php ───────────────────────
$data["username"] = $_SESSION["login_data"]['username'];
@@ -101,15 +100,9 @@ $_SESSION["diff"] = $otp_diff_minutes;
// ── Step 4: Validate OTP value and expiry ─────────────────────────────────────
// Skipped for staff/viewer roles — login_otp.php sets skip_otp=true in session
// so they never receive or enter an OTP. Admin/owner always go through this check,
// 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.
// so they never receive or enter an OTP. Admin/owner always go through this check.
if (empty($_SESSION['skip_otp'])) {
if (!otp_required()) {
if (!empty($user_id)) {
otp_log_bypass($user_id, 'login_confirm');
}
} elseif ($data["otp"] != $otp || $otp_diff_minutes > 5) {
if ($data["otp"] != $otp || $otp_diff_minutes > 5) {
$answer["message"] = "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)";
exit(json_encode($answer));
}
+4 -9
View File
@@ -62,7 +62,6 @@ require_once '../../../config.php';
require_once '../../../preset.php';
define('UNAUTHENTICATED_ROUTE', true);
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/otp_policy.php';
// ── Step 1: Resolve user_id from username or email (case-insensitive) ────────
$sth = $pdo1->prepare("select user_id from user where ? in (username,email) ");
@@ -254,15 +253,11 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
exit(json_encode($answer));
}
// ── Step 5g: OTP policy, then role check — staff/viewer skip OTP entirely ─
// OTP_REQUIRED=false in config.php turns the email OTP off for everyone and
// logs the sign-in as a bypass (see assets/utils/otp_policy.php).
// Otherwise owners always require 2FA. Invited users (license='user') require 2FA only
// ── Step 5g: Role check — staff/viewer skip OTP entirely ─────────────────
// Owners always require 2FA. Invited users (license='user') require 2FA only
// if their role in this company is admin or owner; staff/viewer go straight in.
$requires_otp = otp_required();
if (!$requires_otp) {
otp_log_bypass($user_id, 'login_otp');
} elseif (($r['license'] ?? 'owner') !== 'owner') {
$requires_otp = true;
if (($r['license'] ?? 'owner') !== 'owner') {
$sth_role = $pdo1->prepare(
"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.
* 3. Decode and sanitise input fields.
* 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
* provided while email OTP is on (company SMTP delivers the OTP). With
* OTP_REQUIRED=false they are optional but all-or-nothing: left blank,
* steps 6-8 and 13 are skipped and the company is created without SMTP.
* 5. Required SMTP validation — smtp_host, smtp_username, smtp_password
* must all be provided (company SMTP is mandatory for WMS email delivery).
* 6. Normalise smtp_port to one of ['25', '465', '587'] (default: 587).
* Normalise smtp_encryption to one of ['tls', 'ssl', 'none'] (default: tls).
* 7. Encrypt SMTP password with OpenSSL (same method/iv/key as rest of app).
@@ -54,7 +52,6 @@ require_once '../../../session.php';
require_once '../../../config.php';
require_once '../../../dbconn.php';
require_once '../../../assets/utils/db_helpers.php';
require_once '../../../assets/utils/otp_policy.php';
header('Content-Type: application/json; charset=utf-8');
@@ -100,9 +97,9 @@ try {
$company_name = trim($data['company_name'] ?? '');
$company_name2 = trim($data['company_name2'] ?? '');
// channel_name is the URL slug / identifier — lowercase first, then strip
// everything except lowercase letters, digits, hyphens, and underscores.
$channel_name = preg_replace('/[^a-z0-9\-_]/', '', strtolower(trim($data['channel_name'] ?? '')));
// channel_name is the URL slug / identifier — strip everything except
// lowercase letters, digits, hyphens, and underscores.
$channel_name = strtolower(preg_replace('/[^a-z0-9\-_]/', '', $data['channel_name'] ?? ''));
$branch = trim($data['branch'] ?? 'สำนักงานใหญ่');
$branch_no = trim($data['branch_no'] ?? '00000');
@@ -117,67 +114,59 @@ try {
}
// ── Step 5: SMTP field validation ────────────────────────────────────────
// While email OTP is on, SMTP is mandatory: the company needs it to send OTP
// emails, and an account without working SMTP could not complete 2FA login.
// With OTP_REQUIRED=false in config.php it is optional — all three fields
// left blank means "no SMTP", and the test send (step 8) and the company_smtp
// row (step 13) are skipped. Partly filled is an error either way.
// SMTP is mandatory because the company needs to send OTP emails to users.
// An account without working SMTP would be unable to complete 2FA login.
$smtp_host = trim($data['smtp_host'] ?? '');
$smtp_username = trim($data['smtp_username'] ?? '');
$smtp_password = $data['smtp_password'] ?? '';
$smtp_given = ($smtp_host !== '' || $smtp_username !== '' || $smtp_password !== '');
if ((otp_required() || $smtp_given) && (!$smtp_host || !$smtp_username || !$smtp_password)) {
$answer['message'] = otp_required()
? 'SMTP configuration is required. Please fill in all SMTP fields.'
: 'Fill in SMTP host, username and password, or leave all three blank.';
if (!$smtp_host || !$smtp_username || !$smtp_password) {
$answer['message'] = 'SMTP configuration is required. Please fill in all SMTP fields.';
http_response_code(422);
exit(json_encode($answer));
}
if ($smtp_given) {
// ── Step 6: Normalise SMTP port and encryption ────────────────────────
// Clamp to known-good values to prevent storing unsupported configuration.
$smtp_port = trim($data['smtp_port'] ?? '587');
$smtp_encryption = trim($data['smtp_encryption'] ?? 'tls');
// ── Step 6: Normalise SMTP port and encryption ────────────────────────────
// Clamp to known-good values to prevent storing unsupported configuration.
$smtp_port = trim($data['smtp_port'] ?? '587');
$smtp_encryption = trim($data['smtp_encryption'] ?? 'tls');
if (!in_array($smtp_port, ['25', '465', '587'], true)) $smtp_port = '587';
if (!in_array($smtp_encryption, ['tls', 'ssl', 'none'], true)) $smtp_encryption = 'tls';
if (!in_array($smtp_port, ['25', '465', '587'], true)) $smtp_port = '587';
if (!in_array($smtp_encryption, ['tls', 'ssl', 'none'], true)) $smtp_encryption = 'tls';
// ── Step 7: Encrypt SMTP password ────────────────────────────────────
// Uses the same OpenSSL method/iv/key as the rest of the app (from config.php)
// so the stored password can be decrypted by the mailer module.
$encrypted_pass = openssl_encrypt($smtp_password, $method, $pinkey, 0, $iv);
// ── Step 7: Encrypt SMTP password ────────────────────────────────────────
// Uses the same OpenSSL method/iv/key as the rest of the app (from config.php)
// so the stored password can be decrypted by the mailer module.
$encrypted_pass = openssl_encrypt($smtp_password, $method, $pinkey, 0, $iv);
// Assemble a temporary SMTP config for the test send (step 8)
$smtp_config = [
'server' => $smtp_host,
'port' => $smtp_port,
'username' => $smtp_username,
'password' => $encrypted_pass,
'from_name' => $company_name ?: $smtp_username,
'from_email' => $email ?: $smtp_username,
'encryption' => $smtp_encryption,
];
// Assemble a temporary SMTP config for the test send (step 8)
$smtp_config = [
'server' => $smtp_host,
'port' => $smtp_port,
'username' => $smtp_username,
'password' => $encrypted_pass,
'from_name' => $company_name ?: $smtp_username,
'from_email' => $email ?: $smtp_username,
'encryption' => $smtp_encryption,
];
// ── Step 8: Silent SMTP test — before any DB writes ──────────────────
// Sends a test email to the onboarding user's registered address.
// If the mailer throws or exits, no DB records have been created yet,
// so the user can correct their SMTP settings and retry cleanly.
require_once '../../../assets/utils/module/mailer.php';
// ── Step 8: Silent SMTP test — before any DB writes ──────────────────────
// Sends a test email to the onboarding user's registered address.
// If the mailer throws or exits, no DB records have been created yet,
// so the user can correct their SMTP settings and retry cleanly.
require_once '../../../assets/utils/module/mailer.php';
$mailer = new mailer(['pdo1' => $pdo1]);
$mailer->send_email([
'company_id' => 0,
'smtp' => $smtp_config,
'to' => $_SESSION['onboarding_email'] ?? $smtp_username,
'subject' => 'WMS — SMTP Verification',
'message' => "Your SMTP is working correctly.\n\nSetup is now complete.",
'channel_name' => $company_name ?: 'WMS',
'key' => $pinkey,
]);
// If mailer fails, it calls exit() internally — nothing below this line runs.
}
$mailer = new mailer(['pdo1' => $pdo1]);
$mailer->send_email([
'company_id' => 0,
'smtp' => $smtp_config,
'to' => $_SESSION['onboarding_email'] ?? $smtp_username,
'subject' => 'WMS — SMTP Verification',
'message' => "Your SMTP is working correctly.\n\nSetup is now complete.",
'channel_name' => $company_name ?: 'WMS',
'key' => $pinkey,
]);
// If mailer fails, it calls exit() internally — nothing below this line runs.
// ── Step 9: Duplicate channel_name check ─────────────────────────────────
// channel_name is the unique identifier used in URLs and API calls — must be globally unique.
@@ -237,29 +226,25 @@ try {
// ── Step 13: Save company SMTP settings ──────────────────────────────────
// Stored with the encrypted password so the mailer module can decrypt and
// use it for all outgoing email from this company (OTP, notifications, etc.).
// Skipped when no SMTP was given (only allowed with OTP_REQUIRED=false); it
// can be added later under Settings → SMTP.
if ($smtp_given) {
$sth = $pdo1->prepare("
INSERT INTO company_smtp
(company_id, server, port, username, password,
from_name, from_email, encryption, updated_at)
VALUES
(:company_id, :server, :port, :username, :password,
:from_name, :from_email, :encryption, NOW())
");
$sth->execute([
':company_id' => $company_id,
':server' => $smtp_host,
':port' => $smtp_port,
':username' => $smtp_username,
':password' => $encrypted_pass,
':from_name' => $company_name,
':from_email' => $email ?: $smtp_username,
':encryption' => $smtp_encryption,
]);
db_check($sth, $answer);
}
$sth = $pdo1->prepare("
INSERT INTO company_smtp
(company_id, server, port, username, password,
from_name, from_email, encryption, updated_at)
VALUES
(:company_id, :server, :port, :username, :password,
:from_name, :from_email, :encryption, NOW())
");
$sth->execute([
':company_id' => $company_id,
':server' => $smtp_host,
':port' => $smtp_port,
':username' => $smtp_username,
':password' => $encrypted_pass,
':from_name' => $company_name,
':from_email' => $email ?: $smtp_username,
':encryption' => $smtp_encryption,
]);
db_check($sth, $answer);
// ── Step 14: Clear onboarding session keys ───────────────────────────────
// These keys are no longer needed and should not persist into the
+1 -3
View File
@@ -3,9 +3,7 @@
require '../config.php';
if (!empty($_SESSION['login_status'])) {
require_once '../assets/utils/app_registry.php';
$login_app = app_default_for_access($app_registry, $_SESSION['login_app_access'] ?? 'wms');
header('Location: ' . $server_url . app_home_path($app_registry, $login_app));
header('Location: ' . $server_url . 'dashboard/index.php');
exit;
}
+5 -21
View File
@@ -1,13 +1,12 @@
<?php
require '../session.php';
require '../config.php';
require_once '../assets/utils/otp_policy.php';
require '../include_header.php';
require_once '../assets/utils/app_registry.php';
// successful login — redirect based on app_access
if(!empty($_SESSION["login_status"])){
$login_app = app_default_for_access($app_registry, $_SESSION['login_app_access'] ?? 'wms');
$redirect = $server_url . app_home_path($app_registry, $login_app);
$redirect = ($_SESSION['login_app_access'] ?? 'wms') === 'accounting'
? $server_url . 'ac_dashboard/index.php'
: $server_url . 'dashboard/index.php';
header('Location: ' . $redirect);
exit;
}
@@ -33,13 +32,6 @@
</div>
<form class="needs-validation mt-3" novalidate id="login-form">
<?php if (!otp_required()): ?>
<!-- OTP_REQUIRED is not true in config.php (the default): a password-only sign-in must never be invisible -->
<div class="alert alert-warning small py-2 mb-3" title="OTP_REQUIRED is not true in config.php">
<i class="ti ti-alert-triangle me-1"></i>
Email OTP is off — sign-in is password only.
</div>
<?php endif; ?>
<!-- first step login [OTP] -->
<?php if(!isset($_SESSION['login_data'])){?>
<div class="mb-3">
@@ -59,7 +51,7 @@
<div class="d-flex justify-content-between align-items-center mb-3">
<!-- "Remember me" is intentionally excluded.
This login uses 2FA (OTP via email) on every session 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.
Do not add this back. -->
</div>
@@ -70,7 +62,6 @@
</p>
<?php }else{ ?>
<!-- second step login -->
<?php if (otp_required()): ?>
<div class="alert alert-warning small py-2 mb-3">
<i class="ti ti-mail me-1"></i>
OTP is sent via your company's SMTP setting.
@@ -82,20 +73,13 @@
<span>One Time Password</span>
</label>
<input id="otp" type="otp" class="form-control"
placeholder="your otp for reference number <?php echo $_SESSION["reference"] ?? ''?>" required minlength="6">
placeholder="your otp for reference number <?php echo $_SESSION["reference"]?>" required minlength="6">
<div class="invalid-feedback">Please provide a otp (min 6 characters).</div>
</div>
<?php else: ?>
<!-- OTP_REQUIRED was switched off while this session sat on the OTP step:
login_confirm.php no longer checks the code, so there is nothing to type. -->
<input id="otp" type="hidden" value="">
<?php endif; ?>
<div class="mb-3">
<label for="password" class="form-label d-flex justify-content-between">
<a href="javascript:;" class="small link-primary" onclick="back()">Back</a>
<?php if (otp_required()): ?>
<a href="javascript:;" class="small link-primary" onclick="request_new_otp();">Request New OTP</a>
<?php endif; ?>
</label>
</div>
<button class="btn btn-primary w-100" onclick="login_confirm();">Sign in</button>
+8 -29
View File
@@ -1,7 +1,6 @@
<?php
require '../session.php';
require '../config.php';
require_once '../assets/utils/otp_policy.php';
// Must come from email verification
if (empty($_SESSION['onboarding_user_id'])) {
@@ -49,8 +48,7 @@
</div>
<div class="col-md-6">
<label class="form-label">Channel Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="channel_name" placeholder="e.g. my-shop"
oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9_-]/g,'')">
<input type="text" class="form-control" id="channel_name" placeholder="e.g. my-shop">
<div class="form-text">Unique identifier. Lowercase, no spaces.</div>
</div>
<div class="col-md-3">
@@ -79,20 +77,11 @@
<div class="d-flex justify-content-between align-items-start mb-1">
<h2 class="fs-5 mb-0"><i class="ti ti-mail-cog me-2"></i>SMTP / Email Setting</h2>
<?php if (otp_required()): ?>
<span class="badge bg-label-danger">Required</span>
<?php else: ?>
<span class="badge bg-label-secondary">Optional</span>
<?php endif; ?>
</div>
<p class="text-muted small mb-3">
<?php if (otp_required()): ?>
SMTP is required to send OTP during login.
A verification email will be sent when you finish setup.
<?php else: ?>
Email OTP is turned off, so SMTP is optional. Leave it blank to skip;
you can add it later under Settings → SMTP.
<?php endif; ?>
</p>
<!-- SMTP User Guide (collapsible) -->
@@ -185,11 +174,11 @@
<!-- SMTP Form -->
<div class="row g-3">
<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">
</div>
<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">
<option value="587">587 — TLS</option>
<option value="465">465 — SSL</option>
@@ -197,11 +186,11 @@
</select>
</div>
<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">
</div>
<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">
<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">
@@ -262,23 +251,13 @@
return;
}
// Mirrors api/engine/onboarding.php: SMTP is required while email OTP is on;
// with OTP_REQUIRED=false it is optional, but the three fields go together.
const smtp_required = <?php echo otp_required() ? 'true' : 'false'; ?>;
const smtp_host = $('#smtp_host').val().trim();
const smtp_user = $('#smtp_username').val().trim();
const smtp_pass = $('#smtp_password').val();
const smtp_given = !!(smtp_host || smtp_user || smtp_pass);
if ((smtp_required || smtp_given) && (!smtp_host || !smtp_user || !smtp_pass)) {
bootbox.alert(smtp_required
? 'SMTP host, username and password are required.'
: 'Fill in SMTP host, username and password, or leave all three blank.');
if (!$('#smtp_host').val().trim() || !$('#smtp_username').val().trim() || !$('#smtp_password').val()) {
bootbox.alert('SMTP host, username and password are required.');
return;
}
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();
+1 -3
View File
@@ -4,9 +4,7 @@
// Redirect if already logged in
if (!empty($_SESSION['login_status'])) {
require_once '../assets/utils/app_registry.php';
$login_app = app_default_for_access($app_registry, $_SESSION['login_app_access'] ?? 'wms');
header('Location: ' . $server_url . app_home_path($app_registry, $login_app));
header('Location: ' . $server_url . 'dashboard/index.php');
exit;
}
+1 -1
View File
@@ -250,7 +250,7 @@
$('#display_contact').text(inv.contact_name || '—');
$('#display_department').text(get_dept_label(inv.department_id));
$('#display_issued').text(format_date(inv.issued_date) || '—');
$('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : '');
$('#due_date').val(inv.due_date ? format_date(inv.due_date) : '');
$('#notes').val(inv.notes || '');
var gl_type = inv.doc_type === 'credit_note' ? 'sales_credit_note' : 'sales_invoice';
+1 -1
View File
@@ -493,7 +493,7 @@
// Fields
$('#contact').val(o.contact_name || '');
$('#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);
$('#notes').val(o.notes || '');
$('#discount').val(o.discount || 0);
+1 -1
View File
@@ -795,7 +795,7 @@
$('#badge_status').html(return_status_badge(r.status));
$('#return_number_display').text(r.return_number);
$('#return_date').val(r.return_date ? format_date_input(r.return_date) : '');
$('#return_date').val(r.return_date ? format_date(r.return_date) : '');
$('#reason').val(r.reason || '');
$('#tax_adjustment').val(parseFloat(r.tax_adjustment || 0).toFixed(2));
$('#display_department').text(get_dept_label(r.department_id));
+2 -2
View File
@@ -682,8 +682,8 @@
$('#contact').val(o.contact_name || '');
$('#contact_id').val(o.contact_id || '');
$('#po_date').val(o.po_date ? format_date_input(o.po_date) : '');
$('#expected_date').val(o.expected_date ? format_date_input(o.expected_date) : '');
$('#po_date').val(o.po_date ? format_date(o.po_date) : '');
$('#expected_date').val(o.expected_date ? format_date(o.expected_date) : '');
$('#warehouse_id').val(o.warehouse_id || '');
$('#department_id').val(o.department_id || 0);
$('#notes').val(o.notes || '');
+1 -1
View File
@@ -226,7 +226,7 @@
$('#display_contact').text(inv.contact_name || '—');
$('#display_department').text(get_dept_label(inv.department_id));
$('#display_issued').text(format_date(inv.issued_date) || '—');
$('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : '');
$('#due_date').val(inv.due_date ? format_date(inv.due_date) : '');
$('#notes').val(inv.notes || '');
// Source link
+1 -1
View File
@@ -432,7 +432,7 @@
$('#badge_status').html(return_status_badge(r.status));
$('#badge_fulfillment').html(fulfillment_status_badge(r.fulfillment_status));
$('#return_number_display').text(r.return_number);
$('#return_date').val(r.return_date ? format_date_input(r.return_date) : '');
$('#return_date').val(r.return_date ? format_date(r.return_date) : '');
$('#reason').val(r.reason || '');
$('#tax_adjustment').val(parseFloat(r.tax_adjustment || 0).toFixed(2));
$('#display_department').text(get_dept_label(r.department_id));
+1 -1
View File
@@ -207,7 +207,7 @@
$('#display_contact').html(display_text(inv.contact_name));
$('#display_issued').html(inv.issued_date ? format_date(inv.issued_date) : '<span class="text-muted">—</span>');
$('#display_due').html(inv.due_date ? format_date(inv.due_date) : '<span class="text-muted">—</span>');
$('#due_date').val(inv.due_date ? format_date_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>');
var rows = '';
+1 -1
View File
@@ -356,7 +356,7 @@
$('#order_number_display').text(o.order_number);
$('#contact').val(o.contact_name || '');
$('#contact_id').val(o.contact_id || 0);
$('#order_date').val(o.order_date ? format_date_input(o.order_date) : '');
$('#order_date').val(o.order_date ? format_date(o.order_date) : '');
$('#notes').val(o.notes || '');
$('#discount').val(o.discount || 0);
$('#tax_adjustment').val(o.tax_adjustment || 0);
+2 -2
View File
@@ -379,8 +379,8 @@
$('#contact').val(q.contact_name || '');
$('#contact_id').val(q.contact_id || 0);
$('#quotation_date').val(q.quotation_date ? format_date_input(q.quotation_date) : '');
$('#valid_until').val(q.valid_until ? format_date_input(q.valid_until) : '');
$('#quotation_date').val(q.quotation_date ? format_date(q.quotation_date) : '');
$('#valid_until').val(q.valid_until ? format_date(q.valid_until) : '');
$('#department_id').val(q.department_id || 0);
$('#notes').val(q.notes || '');
$('#discount').val(parseFloat(q.discount) || 0);
-6
View File
@@ -2,12 +2,6 @@
// app/session.php
ob_start(); // ensure output buffering is on regardless of php.ini — prevents stray output from corrupting JSON API responses
// The buffer is still flushed, so notices would still land in front of the JSON
// body and break the client's parse ("Server error occurred."). The login API
// engines load this file instead of db_auth.php, so apply the same policy here.
ini_set('display_errors', '0');
ini_set('log_errors', '1');
if (session_status() === PHP_SESSION_NONE) {
// Derive cookie path dynamically from the current script location.
+2 -11
View File
@@ -1,7 +1,6 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/app_registry.php';
require_once '../../../assets/utils/classes/UserManager.php';
if ($user_role !== 'owner') { http_response_code(403); exit(json_encode(['success' => 0, 'message' => 'Only the owner can modify this setting.'])); }
@@ -31,11 +30,7 @@
$result = $um->inviteUser($email, $role, $app_access);
// Both new and existing users require explicit acceptance via email
// Absolute URL: the link is opened from a mail client, where a bare
// /app/... path goes nowhere. Same construction as register.php.
$invite_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
. '://' . $_SERVER['HTTP_HOST']
. rtrim($server_url, '/') . ($result['new_user']
$invite_url = rtrim($server_url, '/') . ($result['new_user']
? '/login/invited_onboarding.php?token=' . $result['token']
: '/login/accept_invite.php?token=' . $result['token']);
@@ -93,11 +88,7 @@
$map_id = (int)($data['map_id'] ?? 0);
$result = $um->resendInvite($map_id);
// Absolute URL: the link is opened from a mail client, where a bare
// /app/... path goes nowhere. Same construction as register.php.
$invite_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
. '://' . $_SERVER['HTTP_HOST']
. rtrim($server_url, '/') . ($result['is_new_user']
$invite_url = rtrim($server_url, '/') . ($result['is_new_user']
? '/login/invited_onboarding.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.
*
* action: 'read' → return list of companies the user belongs to
* action: 'update' → switch to target_company_id
*
* The target is read from target_company_id, not company_id: every request
* carries company_id = the CURRENT company (prepare_form_data in custom.js),
* so reading it made a switch silently re-select the company already active.
* action: 'update' → switch to the requested company_id
*/
session_start();
require_once '../../../assets/utils/db_auth.php';
@@ -24,7 +20,7 @@ if ($action === 'read') {
}
if ($action === 'update') {
$target_company_id = (int)($data['target_company_id'] ?? 0);
$target_company_id = (int)($data['company_id'] ?? 0);
if (!$target_company_id) {
$answer['message'] = 'Invalid company.';
+1 -2
View File
@@ -121,8 +121,7 @@
<div class="col-md-6 mb-3">
<label class="form-label">Nickname / Channel Name</label>
<input type="text" class="form-control" id="channel_name" placeholder="e.g. my-shop"
oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9_-]/g,'')">
<input type="text" class="form-control" id="channel_name" placeholder="e.g. my-shop">
<div class="form-text">Unique identifier. Lowercase, no spaces.</div>
</div>
<div class="col-md-3 mb-3">
+3 -2
View File
@@ -1,7 +1,6 @@
<?php
session_start();
require '../config.php';
require_once '../assets/utils/app_registry.php';
require '../include_header.php';
?>
@@ -257,7 +256,9 @@
const license_badge = license_html(u.license);
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;
+2 -4
View File
@@ -532,11 +532,9 @@ foreach ($stockout_defs as $so) {
$qty = $so['qty'];
$cost = $p['cost'];
$uom = $p['uom'];
// The batch is bought in from a supplier; only the stock-out goes to the customer.
$supplier_id = $supplier_ids[0];
dbTransaction($pdo2, function ($pdo2) use (
$stockMgmt, $whMgmt, $company_id, $main_wh_id, $sku, $qty, $cost, $customer_id, $supplier_id,
$stockMgmt, $whMgmt, $company_id, $main_wh_id, $sku, $qty, $cost, $customer_id,
$logging, $dispatch_in_marker, $dispatch_out_marker
) {
$bin = findFreeBin($pdo2, $company_id, $main_wh_id);
@@ -551,7 +549,7 @@ foreach ($stockout_defs as $so) {
'zone' => $bin,
'aisle' => $bin,
'bin' => $bin,
'contact_id' => $supplier_id,
'contact_id' => $customer_id,
'description' => $dispatch_in_marker,
], $logging, $in_uuid);
$stockMgmt->approveStock($stock_id, $main_wh_id, 'in', $whMgmt);
-374
View File
@@ -1,374 +0,0 @@
<?php
/**
* Demo Data Seeder — Asset Management
* Run after demo_seed.php and demo_seed_transactions.php: php demo_seed_assets.php
*
* Seeds the Asset Management app through the same classes its pages and the
* Batch GL Entries page use:
* 1. GL accounts for fixed assets, and asset categories (AssetCategoryManager)
* 2. A purchase of a laptop fleet + an ERP licence: PO -> confirm -> purchase
* invoice -> issue -> GL post (PurchaseOrderManager, InvoiceManager), the
* only way a purchase invoice exists in the app
* 3. Assets capitalized from those invoice lines, plus two vehicles bought
* before the system was used and already on the books (AssetManager)
* 4. Depreciation runs up to last month, the sale of the forklift today, then
* this month's depreciation and amortization runs (AssetRunManager)
* 5. GL posting of the purchase invoice, capitalizations, the disposal and every
* run before this month (AssetPosting + GlManager, as
* accounting/api/engine/post_gl_entry.php does) — this month's runs stay
* unposted so Batch GL Entries has Asset Management work to show.
*
* Dates are relative to today: the app stamps documents with the current date.
*
* Safe to re-run: every step checks what already exists.
*/
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit('Run via CLI only: php demo_seed_assets.php');
}
$_SESSION = [];
require_once __DIR__ . '/app/config.php';
require_once __DIR__ . '/app/dbconn.php';
require_once __DIR__ . '/app/assets/utils/db_helpers.php';
require_once __DIR__ . '/app/assets/utils/classes/WarehouseManager.php';
require_once __DIR__ . '/app/assets/utils/classes/StockManager.php';
require_once __DIR__ . '/app/assets/utils/classes/ProductManager.php';
require_once __DIR__ . '/app/assets/utils/classes/PurchaseOrderManager.php';
require_once __DIR__ . '/app/assets/utils/classes/InvoiceManager.php';
require_once __DIR__ . '/app/assets/utils/classes_ac/ChartOfAccounts.php';
require_once __DIR__ . '/app/assets/utils/classes_ac/PostingWindowGuard.php';
require_once __DIR__ . '/app/assets/utils/classes_ac/GlManager.php';
require_once __DIR__ . '/app/assets/utils/classes_ac/posting/BasePosting.php';
require_once __DIR__ . '/app/assets/utils/classes_ac/posting/PurchaseInvoicePosting.php';
require_once __DIR__ . '/app/assets/utils/classes_as/AssetManager.php';
require_once __DIR__ . '/app/assets/utils/classes_as/AssetRunManager.php';
require_once __DIR__ . '/app/assets/utils/classes_as/AssetPosting.php';
function info(string $msg): void { echo "\033[36m •\033[0m {$msg}\n"; }
function ok(string $msg): void { echo "\033[32m ✓\033[0m {$msg}\n"; }
function skip(string $msg): void { echo "\033[33m –\033[0m {$msg}\n"; }
echo "\n\033[1m=== brnwms Demo Asset Management Seeder ===\033[0m\n\n";
// ─────────────────────────────────────────────────────────────────────────────
// 0. Resolve what the earlier seeders created
// ─────────────────────────────────────────────────────────────────────────────
$sth = $pdo1->prepare("SELECT company_id FROM company_list WHERE channel_name = 'demo' LIMIT 1");
$sth->execute();
$company_id = (int)($sth->fetchColumn() ?: 0);
if (!$company_id) exit("ERROR: demo company not found — run demo_seed.php first.\n");
$sth = $pdo1->prepare("SELECT user_id FROM user WHERE username = 'admin' LIMIT 1");
$sth->execute();
$owner_user_id = (int)($sth->fetchColumn() ?: 0);
$lookup = function (string $sql, array $params) use ($pdo2): int {
$sth = $pdo2->prepare($sql);
$sth->execute($params);
return (int)($sth->fetchColumn() ?: 0);
};
$main_wh = $lookup("SELECT id FROM md_warehouse WHERE company_id = :c AND warehouse_name = 'Main Warehouse' LIMIT 1", [':c' => $company_id]);
$supplier_id = $lookup("SELECT id FROM md_contact WHERE company_id = :c AND contact_name = 'Asia Electronics Wholesale' LIMIT 1", [':c' => $company_id]);
$wh_dept_id = $lookup("SELECT id FROM md_department WHERE company_id = :c AND dept_code = 'WH' LIMIT 1", [':c' => $company_id]);
if (!$main_wh || !$supplier_id || !$wh_dept_id) {
exit("ERROR: warehouse, supplier or WH department missing — run demo_seed.php and demo_seed_transactions.php first.\n");
}
$coa = new ChartOfAccounts($pdo2, $company_id);
foreach (['1000', '4000', '5000'] as $code) {
if (!$coa->getByCode($code)) exit("ERROR: account {$code} missing — run demo_seed_transactions.php first.\n");
}
$logging = ['user_id' => $owner_user_id, 'dt' => date('Y-m-d H:i:s'), 'login' => null, 'action' => 'seed_assets'];
$today = date('Y-m-d');
$this_period = date('Y-m');
$last_period = DepreciationCalculator::addMonths($this_period, -1);
// ─────────────────────────────────────────────────────────────────────────────
// 1. Accounts + asset categories
// ─────────────────────────────────────────────────────────────────────────────
echo "--- Asset accounts ---\n";
$account_defs = [
['code' => '1500', 'name' => 'Equipment and Vehicles', 'type' => 'asset'],
['code' => '1590', 'name' => 'Accumulated Depreciation', 'type' => 'asset'],
['code' => '1600', 'name' => 'Software and Licences', 'type' => 'asset'],
['code' => '1690', 'name' => 'Accumulated Amortization', 'type' => 'asset'],
['code' => '4900', 'name' => 'Gain on Asset Disposal', 'type' => 'revenue'],
['code' => '6100', 'name' => 'Depreciation Expense', 'type' => 'expense'],
['code' => '6200', 'name' => 'Amortization Expense', 'type' => 'expense'],
['code' => '6900', 'name' => 'Loss on Asset Disposal', 'type' => 'expense'],
];
foreach ($account_defs as $a) {
if ($coa->getByCode($a['code'])) {
skip("Account {$a['code']} — {$a['name']} already exists");
continue;
}
$coa->create([
'account_code' => $a['code'], 'account_name' => $a['name'], 'account_type' => $a['type'],
'account_category' => '', 'parent_code' => null, 'is_posting' => 1, 'status' => 1,
]);
ok("Created account {$a['code']} — {$a['name']} ({$a['type']})");
}
echo "\n--- Asset categories ---\n";
$category_defs = [
['code' => 'IT', 'name' => 'Computer Equipment', 'class' => 'tangible', 'life' => 36, 'cost' => '1500', 'accum' => '1590', 'expense' => '6100'],
['code' => 'VEH', 'name' => 'Vehicles and Forklifts', 'class' => 'tangible', 'life' => 60, 'cost' => '1500', 'accum' => '1590', 'expense' => '6100'],
['code' => 'SW', 'name' => 'Software Licences', 'class' => 'intangible', 'life' => 24, 'cost' => '1600', 'accum' => '1690', 'expense' => '6200'],
];
$category_ids = [];
foreach ($category_defs as $c) {
$id = $lookup("SELECT id FROM md_asset_category WHERE company_id = :c AND category_code = :code LIMIT 1", [':c' => $company_id, ':code' => $c['code']]);
if ($id) {
skip("Category {$c['code']} already exists (id={$id})");
} else {
$id = (new AssetCategoryManager($pdo2, $company_id))->save([
'category_code' => $c['code'], 'category_name' => $c['name'], 'asset_class' => $c['class'],
'useful_life_months' => $c['life'], 'status' => 1, 'description' => '',
'cost_account_code' => $c['cost'], 'accum_account_code' => $c['accum'], 'expense_account_code' => $c['expense'],
'gain_account_code' => '4900', 'loss_account_code' => '6900',
]);
ok("Created category {$c['code']} — {$c['name']} ({$c['class']}, {$c['life']} months)");
}
$category_ids[$c['code']] = $id;
}
// ─────────────────────────────────────────────────────────────────────────────
// 2. Purchase: PO -> confirm -> purchase invoice -> issue -> GL
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- Asset purchase (PO -> purchase invoice) ---\n";
$prodMgmt = new ProductManager($pdo2, $company_id);
$cat_id = $lookup("SELECT id FROM md_product_category WHERE company_id = :c AND slug = 'fixed-assets' LIMIT 1", [':c' => $company_id]);
if (!$cat_id) {
dbTransaction($pdo2, fn($pdo) => (new ProductManager($pdo, $company_id))->saveCategory([
'id' => 0, 'category' => 'Fixed Assets', 'slug' => 'fixed-assets', 'description' => '', 'status' => 1,
], $logging));
$cat_id = $lookup("SELECT id FROM md_product_category WHERE company_id = :c AND slug = 'fixed-assets' LIMIT 1", [':c' => $company_id]);
ok("Created product category 'Fixed Assets' (id={$cat_id})");
}
$asset_product_defs = [
['sku' => 'FA-LAPTOP', 'name' => 'Laptop - Dell Latitude 5440', 'price' => 32000],
['sku' => 'FA-ERP', 'name' => 'ERP Software Licence (2 years)', 'price' => 48000],
];
$products = [];
foreach ($asset_product_defs as $p) {
$id = $lookup("SELECT id FROM md_product WHERE company_id = :c AND sku = :s LIMIT 1", [':c' => $company_id, ':s' => $p['sku']]);
if ($id) {
skip("Product {$p['sku']} already exists");
} else {
dbTransaction($pdo2, fn($pdo) => (new ProductManager($pdo, $company_id))->saveProduct([
'id' => 0, 'product_name' => $p['name'], 'sku' => $p['sku'], 'barcode' => '', 'uom' => 'pcs',
'price' => $p['price'], 'cost_price' => $p['price'], 'min_stock' => 0, 'reorder_point' => 0,
'category' => $cat_id, 'description' => 'Bought as a fixed asset (demo seed)', 'status' => 1,
], $logging, ''));
$id = $lookup("SELECT id FROM md_product WHERE company_id = :c AND sku = :s LIMIT 1", [':c' => $company_id, ':s' => $p['sku']]);
// Same accounts as the other demo products: the purchase posts to 5000, and the
// capitalization entry reclassifies it from there to the asset account.
dbTransaction($pdo2, fn($pdo) => (new ProductManager($pdo, $company_id))->updateAccountMapping($id, '4000', '5000', $logging));
ok("Created product {$p['sku']} — {$p['name']} (sales 4000, purchase 5000)");
}
$products[$p['sku']] = $p;
}
$po_marker = 'Demo seed asset purchase';
$po_id = $lookup("SELECT id FROM td_purchase_order WHERE company_id = :c AND notes = :n LIMIT 1", [':c' => $company_id, ':n' => $po_marker]);
if ($po_id) {
skip("Asset purchase PO already exists (id={$po_id})");
} else {
$po_items = [];
foreach ([['FA-LAPTOP', 3], ['FA-ERP', 1]] as $i => [$sku, $qty]) {
$total = round($qty * $products[$sku]['price'], 4);
$po_items[] = [
'item_id' => $i + 1, 'product_sku' => $sku, 'product_name' => $products[$sku]['name'],
'quantity' => $qty, 'unit_price' => $products[$sku]['price'], 'total_price' => $total,
'tax_rate' => 7.0, 'tax_amount' => round($total * 0.07, 4),
];
}
$po_id = dbTransaction($pdo2, function ($pdo) use ($company_id, $supplier_id, $wh_dept_id, $main_wh, $po_items, $today, $po_marker, $logging) {
$poMgmt = new PurchaseOrderManager($pdo, $company_id);
$pid = $poMgmt->savePo([
'id' => 0, 'contact_id' => $supplier_id, 'department_id' => $wh_dept_id,
'po_date' => $today, 'expected_date' => date('Y-m-d', strtotime('+7 days')),
'warehouse_id' => $main_wh, 'items' => $po_items, 'discount' => 0, 'shipping_fee' => 0,
'notes' => $po_marker,
], $logging);
$poMgmt->confirmPo($pid, $logging);
return $pid;
});
ok("Created + confirmed PO for 3 laptops and 1 ERP licence (id={$po_id})");
}
$pi_id = $lookup(
"SELECT id FROM td_invoice WHERE company_id = :c AND source = 'po' AND source_id = :p AND doc_type = 'purchase_invoice' AND status <> 4 LIMIT 1",
[':c' => $company_id, ':p' => $po_id]
);
if ($pi_id) {
skip("Purchase invoice for the asset PO already exists (id={$pi_id})");
} else {
$pi_id = dbTransaction($pdo2, function ($pdo) use ($company_id, $po_id, $logging) {
$invMgmt = new InvoiceManager($pdo, $company_id);
$iid = $invMgmt->createFromPo($po_id, $logging);
$invMgmt->issueInvoice($iid, $logging, date('Y-m-d', strtotime('+30 days')));
return $iid;
});
ok("Created + issued purchase invoice from the asset PO (id={$pi_id})");
}
$gl = new GlManager($pdo2, $company_id, new PostingWindowGuard($pdo1, $company_id));
if ($gl->getBySource('purchase_invoice', $pi_id)) {
skip("GL already posted for purchase invoice #{$pi_id}");
} else {
// Same as order/api/engine/issue_invoice.php's auto-post.
$built = (new PurchaseInvoicePosting($pdo2, $company_id))->build($pi_id, null);
$pdo2->beginTransaction();
$gl->post('purchase_invoice', $pi_id, $built['formula_id'], $built['period'], $built['lines'], ['journal_date' => $built['doc_date']]);
$pdo2->commit();
ok("GL posted for purchase invoice #{$pi_id} (" . count($built['lines']) . " lines)");
}
// ─────────────────────────────────────────────────────────────────────────────
// 3. Assets
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- Assets ---\n";
$sth = $pdo2->prepare("SELECT product_sku, id FROM td_invoice_item WHERE company_id = :c AND invoice_id = :i");
$sth->execute([':c' => $company_id, ':i' => $pi_id]);
$invoice_items = $sth->fetchAll(PDO::FETCH_KEY_PAIR); // sku => td_invoice_item.id
$asset_defs = [
['key' => 'laptops', 'name' => 'Laptop fleet - Dell Latitude 5440 (3 units)', 'category' => 'IT', 'sku' => 'FA-LAPTOP', 'salvage' => 6000, 'life' => 36],
['key' => 'erp', 'name' => 'ERP software licence', 'category' => 'SW', 'sku' => 'FA-ERP', 'salvage' => 0, 'life' => 24],
['key' => 'truck', 'name' => 'Delivery truck - Isuzu D-Max', 'category' => 'VEH', 'acquired' => date('Y-m-15', strtotime('first day of -18 months')), 'cost' => 850000, 'salvage' => 150000, 'life' => 60],
['key' => 'forklift','name' => 'Forklift - Toyota 8FD25', 'category' => 'VEH', 'acquired' => date('Y-m-01', strtotime('first day of -30 months')), 'cost' => 420000, 'salvage' => 20000, 'life' => 48],
];
$asset_ids = [];
foreach ($asset_defs as $d) {
$sth = $pdo2->prepare("SELECT id, status FROM td_asset WHERE company_id = :c AND asset_name = :n AND status <> 4 LIMIT 1");
$sth->execute([':c' => $company_id, ':n' => $d['name']]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if ($row) {
skip("Asset '{$d['name']}' already exists (id={$row['id']})");
$id = (int)$row['id'];
} else {
$data = [
'asset_name' => $d['name'], 'category_id' => $category_ids[$d['category']],
'salvage_value' => $d['salvage'], 'useful_life_months' => $d['life'],
'department_id' => $wh_dept_id, 'description' => 'Demo seed',
];
if (!empty($d['sku'])) {
// Capitalized from the purchase invoice line, as asset/manage_asset.php?invoice_id=&item_id= does.
$item_id = (int)($invoice_items[$d['sku']] ?? 0);
$line = (new AssetManager($pdo2, $company_id))->getInvoiceLine($pi_id, $item_id);
$data += [
'invoice_id' => $pi_id, 'invoice_item_id' => $item_id,
'cost' => $line['remaining'], 'clearing_account_code' => $line['clearing_account_code'],
'acquisition_date' => $line['issued_date'], 'in_service_date' => $line['issued_date'],
];
} else {
// Bought before the system was used: already on the books, so no clearing account.
$data += ['cost' => $d['cost'], 'clearing_account_code' => '', 'acquisition_date' => $d['acquired'], 'in_service_date' => $d['acquired']];
}
$id = dbTransaction($pdo2, fn($pdo) => (new AssetManager($pdo, $company_id))->save($data, $owner_user_id));
ok("Created asset '{$d['name']}' (id={$id}), cost " . number_format((float)$data['cost'], 2) . ", in service {$data['in_service_date']}");
}
if ((int)($row['status'] ?? 0) === 0) {
dbTransaction($pdo2, fn($pdo) => (new AssetManager($pdo, $company_id))->activate($id));
ok("Activated '{$d['name']}'");
}
$asset_ids[$d['key']] = $id;
}
// ─────────────────────────────────────────────────────────────────────────────
// 4. Runs and the forklift sale
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- Depreciation / amortization runs ---\n";
$run_through = function (string $asset_class, string $period) use ($pdo2, $company_id, $owner_user_id) {
$label = AssetRunManager::labelFor($asset_class);
if (!(new AssetRunManager($pdo2, $company_id))->preview($asset_class, $period)) {
skip("{$label} already charged through {$period}");
return;
}
$created = dbTransaction($pdo2, fn($pdo) => (new AssetRunManager($pdo, $company_id))->run($asset_class, $period, $owner_user_id));
$first = $created[0]['run_number'];
$last = $created[count($created) - 1]['run_number'];
ok("{$label}: " . count($created) . " run(s) {$first}" . ($first !== $last ? " .. {$last}" : '')
. ", total " . number_format(array_sum(array_column($created, 'total')), 2));
};
$run_through('tangible', $last_period);
$forklift = (new AssetManager($pdo2, $company_id))->getById($asset_ids['forklift']);
if (in_array((int)$forklift['status'], [AssetManager::STATUS_ACTIVE, AssetManager::STATUS_FULLY], true)) {
dbTransaction($pdo2, fn($pdo) => (new AssetManager($pdo, $company_id))->dispose($asset_ids['forklift'], [
'disposal_date' => $today, 'sale_price' => 180000, 'proceeds_account_code' => '1000',
'disposal_notes' => 'Sold to a used-equipment dealer (demo seed)',
]));
ok("Sold '{$forklift['asset_name']}' today for 180,000.00 (book value " . number_format((float)$forklift['book_value'], 2) . ")");
} else {
skip("'{$forklift['asset_name']}' already disposed of");
}
$run_through('tangible', $this_period);
$run_through('intangible', $this_period);
// ─────────────────────────────────────────────────────────────────────────────
// 5. GL posting (Batch GL Entries equivalent)
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- GL posting ---\n";
$post_asset_gl = function (string $source_type, int $id) use ($pdo1, $pdo2, $company_id) {
$gl = new GlManager($pdo2, $company_id, new PostingWindowGuard($pdo1, $company_id));
if ($gl->getBySource($source_type, $id)) return null;
$built = (new AssetPosting($pdo2, $company_id))->build($source_type, $id);
$pdo2->beginTransaction();
try {
$gl->post($source_type, $id, 0, $built['period'], $built['lines'], [
'journal_date' => $built['doc_date'], 'reference' => $built['reference'], 'description' => $built['description'],
]);
$pdo2->commit();
} catch (Throwable $e) {
if ($pdo2->inTransaction()) $pdo2->rollBack();
throw $e;
}
return $built;
};
$queue = [];
$sth = $pdo2->prepare("SELECT id FROM td_asset WHERE company_id = :c AND status IN (1, 2, 3) AND clearing_account_code <> '' ORDER BY id");
$sth->execute([':c' => $company_id]);
foreach ($sth->fetchAll(PDO::FETCH_COLUMN) as $id) $queue[] = ['asset_capitalization', (int)$id];
$sth = $pdo2->prepare("SELECT id, asset_class FROM td_asset_run WHERE company_id = :c AND status = 1 AND period < :p ORDER BY period, id");
$sth->execute([':c' => $company_id, ':p' => $this_period]);
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $r) $queue[] = [AssetRunManager::sourceTypeFor($r['asset_class']), (int)$r['id']];
$sth = $pdo2->prepare("SELECT id FROM td_asset WHERE company_id = :c AND status = 3 ORDER BY id");
$sth->execute([':c' => $company_id]);
foreach ($sth->fetchAll(PDO::FETCH_COLUMN) as $id) $queue[] = ['asset_disposal', (int)$id];
$posted = 0;
foreach ($queue as [$source_type, $id]) {
$built = $post_asset_gl($source_type, $id);
if ($built) {
$posted++;
ok("Posted {$built['reference']} — {$built['description']}");
}
}
if (!$posted) skip("Asset GL entries already posted");
info("This month's runs are left unposted — post them from Accounting > Batch GL Entries.");
echo "\n\033[1m=== Done ===\033[0m\n\n";
+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);
return [
'product_sku' => $sku,
'product_name' => $p['product_name'],
'product_name' => $sku,
'quantity' => $qty,
'unit_price' => $unit_price,
'total_price' => $total_price,
@@ -69,16 +69,11 @@ $sth->execute([':c' => $company_id]);
$sales_dept_id = (int)$sth->fetchColumn();
if (!$sales_dept_id) { exit("ERROR: SALES department not found — run demo_seed_transactions.php first.\n"); }
$sth = $pdo2->prepare("SELECT sku, 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]);
$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 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 = $pdo2->prepare("SELECT id, contact_name FROM md_contact WHERE company_id = :c AND contact_type = 1 ORDER BY id");
$sth->execute([':c' => $company_id]);
$customers = $sth->fetchAll(PDO::FETCH_ASSOC);
$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,
* 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
* 5. Sales cycle: quotation -> sales order -> invoice -> GL post
* 6. AR: receipt billing -> receipt -> GL post
* 7. Purchasing cycle: purchase request -> PO -> receive -> purchase invoice -> GL post
* 8. AP: payment billing -> payment -> GL post
* 9. Supplier return (confirmed, restocks reversed)
* 10. Customer return (draft only — see note below)
* 11. Barcode labels for a handful of products
* 1. Additional stock-in replenishment (restock events)
* 2. Stock-out (direct dispatch) + stock transfer (Main -> Bangna)
* 3. Chart of accounts, departments, GL posting formulas
* 4. Sales cycle: quotation -> sales order -> invoice -> GL post
* 5. AR: receipt billing -> receipt -> GL post
* 6. Purchasing cycle: purchase request -> PO -> receive -> purchase invoice -> GL post
* 7. AP: payment billing -> payment -> GL post
* 8. Supplier return (confirmed, restocks reversed)
* 9. Customer return (draft only — see note below)
* 10. Barcode labels for a handful of products
*
* Every write goes through the same Manager classes + engine-file patterns
* 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/classes/WarehouseManager.php';
require_once __DIR__ . '/app/assets/utils/classes/StockManager.php';
require_once __DIR__ . '/app/assets/utils/classes/ProductManager.php';
require_once __DIR__ . '/app/assets/utils/classes/QuotationManager.php';
require_once __DIR__ . '/app/assets/utils/classes/OrderManager.php';
require_once __DIR__ . '/app/assets/utils/classes/InvoiceManager.php';
@@ -88,29 +89,24 @@ $sth->execute();
$owner_user_id = (int)($sth->fetchColumn() ?: 0);
if (!$owner_user_id) exit("ERROR: demo owner user not found — run demo_seed.php first.\n");
// Resolve by name: ids depend on what else exists in the database.
$sth = $pdo2->prepare("SELECT id, warehouse_name FROM md_warehouse WHERE company_id = :c ORDER BY id");
$sth->execute([':c' => $company_id]);
$warehouses = $sth->fetchAll(PDO::FETCH_KEY_PAIR); // id => name
$main_wh = (int)(array_search('Main Warehouse', $warehouses, true) ?: 0);
$bangna_wh = (int)(array_search('Bangna Distribution Center', $warehouses, true) ?: 0);
if (!$main_wh || !$bangna_wh) exit("ERROR: expected Main Warehouse and Bangna Distribution Center — run demo_seed.php first.\n");
if (count($warehouses) < 2) exit("ERROR: expected 2 warehouses — run demo_seed.php first.\n");
$wh_ids = array_keys($warehouses);
$main_wh = $wh_ids[0]; // Main Warehouse
$bangna_wh = $wh_ids[1]; // Bangna Distribution Center
// md_contact.contact_type is an md_contact_type id, so match on the type name.
$sth = $pdo2->prepare(
"SELECT c.id, c.contact_name, t.contact_type AS type_name
FROM md_contact c
JOIN md_contact_type t ON t.company_id = c.company_id AND t.id = c.contact_type
WHERE c.company_id = :c
ORDER BY c.id"
);
$sth = $pdo2->prepare("SELECT id, contact_name, contact_type FROM md_contact WHERE company_id = :c ORDER BY id");
$sth->execute([':c' => $company_id]);
$contacts = $sth->fetchAll(PDO::FETCH_ASSOC);
$customer_ids = array_values(array_column(array_filter($contacts, fn($c) => $c['type_name'] === 'Customer'), 'id'));
$supplier_ids = array_values(array_column(array_filter($contacts, fn($c) => $c['type_name'] === 'Supplier'), 'id'));
if (count($customer_ids) < 4 || count($supplier_ids) < 3) exit("ERROR: expected 4 customers and 3 suppliers — run demo_seed.php first.\n");
if (count($contacts) < 7) exit("ERROR: expected 7 contacts — run demo_seed.php first.\n");
$customer_ids = array_column(array_filter($contacts, fn($c) => (int)$c['contact_type'] === 1), 'id');
$supplier_ids = array_column(array_filter($contacts, fn($c) => (int)$c['contact_type'] === 2), 'id');
$customer_ids = array_values($customer_ids);
$supplier_ids = array_values($supplier_ids);
$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]);
$products = $sth->fetchAll(PDO::FETCH_ASSOC | PDO::FETCH_UNIQUE);
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'];
$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
@@ -233,25 +379,6 @@ foreach ($formula_defs as $doc_type => $def) {
$formula_ids[$doc_type] = $id;
}
echo "\n--- Product account mapping ---\n";
// The sales and purchase formulas split 'total' per product, so Batch GL Entries
// refuses to post until every product has sales/purchase accounts
// (Account Formulas > Product Accounts, accounting/api/engine/product_account_mapping.php).
$sth = $pdo2->prepare("SELECT id, sku, sales_account_code, purchase_account_code FROM md_product WHERE company_id = :c ORDER BY id");
$sth->execute([':c' => $company_id]);
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $prod) {
if ($prod['sales_account_code'] && $prod['purchase_account_code']) {
skip("Product {$prod['sku']} already mapped");
continue;
}
$sales_code = $prod['sales_account_code'] ?: '4000';
$purchase_code = $prod['purchase_account_code'] ?: '5000';
dbTransaction($pdo2, fn($pdo) => (new ProductManager($pdo, $company_id))
->updateAccountMapping((int)$prod['id'], $sales_code, $purchase_code, $logging));
ok("Mapped {$prod['sku']}: sales {$sales_code}, purchase {$purchase_code}");
}
/** Post (or replace) GL for a document, mirroring order/api/engine/issue_invoice.php exactly. */
function postGl(PDO $pdo1, PDO $pdo2, int $company_id, string $doc_type, int $doc_id, string $posting_class): array {
require_once __DIR__ . "/app/assets/utils/classes_ac/posting/{$posting_class}.php";
@@ -282,7 +409,7 @@ function buildLineItem(array $products, string $sku, float $qty): array {
$tax_amount = round($total_price * $tax_rate / 100, 4);
return [
'product_sku' => $sku,
'product_name' => $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,
'unit_price' => $unit_price,
'total_price' => $total_price,
-1
View File
@@ -22,7 +22,6 @@ services:
EMIT_SECRET: ${EMIT_SECRET}
SMTP_USERNAME: ${SMTP_USERNAME}
SMTP_PASSWORD: ${SMTP_PASSWORD}
OTP_REQUIRED: ${OTP_REQUIRED:-false}
volumes:
- .:/var/www/html/wms-app
ports:
-1
View File
@@ -55,7 +55,6 @@ PUBLIC_HOST=$public_host
EMIT_SECRET=$emit_secret
SMTP_USERNAME=$smtp_user
SMTP_PASSWORD=$smtp_pass
OTP_REQUIRED=false
HTTP_PORT=$http_port
EOF
chmod 600 "$ENV_FILE"
-2
View File
@@ -2,8 +2,6 @@ FROM php:8.3-apache
RUN apt-get update && apt-get install -y --no-install-recommends \
libzip-dev libicu-dev libonig-dev default-mysql-client gettext-base \
libpng-dev libjpeg-dev libfreetype6-dev \
&& docker-php-ext-configure gd --with-jpeg --with-freetype \
&& docker-php-ext-install pdo_mysql mysqli mbstring gd zip intl sockets exif opcache \
&& a2enmod rewrite \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
-17
View File
@@ -33,23 +33,6 @@ if (!defined('NODE_EMIT_SECRET')) {
define('NODE_EMIT_SECRET', '${EMIT_SECRET}');
}
// ── Login OTP ────────────────────────────────────────────────────────────────
// Set from OTP_REQUIRED in .env and reconciled by the entrypoint on every start.
// Off by default: anything other than the boolean true leaves the OTP step off.
if (!defined('OTP_REQUIRED')) {
define('OTP_REQUIRED', ${OTP_REQUIRED});
}
// ── App registry ─────────────────────────────────────────────────────────────
// Apps a user can be given access to (Setting → Users Access). Keys must match
// the user.app_access enum; assets/utils/app_registry.php supplies this default
// for configs that do not define it.
$app_registry = [
'wms' => ['label' => 'WMS', 'icon' => 'ti-box', 'color' => 'bg-label-primary', 'home' => 'dashboard/index.php'],
'accounting' => ['label' => 'Accounting', 'icon' => 'ti-calculator', 'color' => 'bg-label-info', 'home' => 'ac_dashboard/index.php'],
'asset' => ['label' => 'Asset Management', 'icon' => 'ti-building-warehouse', 'color' => 'bg-label-success', 'home' => 'asset_dashboard/index.php'],
];
// ── Usage packages ───────────────────────────────────────────────────────────
$packages = [
'starter' => [
+1 -25
View File
@@ -4,39 +4,15 @@ set -e
APP_DIR=/var/www/html/wms-app
CONFIG=$APP_DIR/app/config.php
# Email OTP on sign-in, OFF BY DEFAULT. Only the exact string "true" turns it
# on; a missing variable or anything else means false.
: "${OTP_REQUIRED:=false}"
[ "$OTP_REQUIRED" = "true" ] || OTP_REQUIRED=false
export OTP_REQUIRED
# Generate app/config.php from template on first run only.
# Restrict envsubst to known placeholders so it never touches the app's own
# $variable syntax (envsubst blanks out any $NAME it doesn't recognize).
if [ ! -f "$CONFIG" ]; then
echo "[entrypoint] generating app/config.php"
envsubst '${DB_ROOT_PASSWORD} ${PUBLIC_HOST} ${EMIT_SECRET} ${SMTP_USERNAME} ${SMTP_PASSWORD} ${OTP_REQUIRED}' \
envsubst '${DB_ROOT_PASSWORD} ${PUBLIC_HOST} ${EMIT_SECRET} ${SMTP_USERNAME} ${SMTP_PASSWORD}' \
< /usr/local/etc/wms/config.php.template > "$CONFIG"
fi
# config.php is never regenerated once it exists, so OTP_REQUIRED is the one
# line reconciled on every start: the .env value always wins, and a config.php
# written before this switch existed gets the line added.
if grep -q "define('OTP_REQUIRED'" "$CONFIG"; then
if ! grep -q "define('OTP_REQUIRED', ${OTP_REQUIRED});" "$CONFIG"; then
sed -i "s/define('OTP_REQUIRED', [A-Za-z]*);/define('OTP_REQUIRED', ${OTP_REQUIRED});/" "$CONFIG"
echo "[entrypoint] OTP_REQUIRED is now ${OTP_REQUIRED}"
fi
else
# Drop a closing ?> on the last line so the appended block stays inside PHP.
sed -i -e '${/^[[:space:]]*?>[[:space:]]*$/d}' "$CONFIG"
printf "\nif (!defined('OTP_REQUIRED')) {\n\tdefine('OTP_REQUIRED', %s);\n}\n" "$OTP_REQUIRED" >> "$CONFIG"
echo "[entrypoint] added OTP_REQUIRED = ${OTP_REQUIRED} to an existing config.php"
fi
if [ "$OTP_REQUIRED" = "false" ]; then
echo "[entrypoint] email OTP is off (OTP_REQUIRED=false); sign-in is password only."
fi
mkdir -p "$APP_DIR/app/uploads"
chown -R www-data:www-data "$APP_DIR/app/uploads"
+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); });

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