diff --git a/app/ac_dashboard/index.php b/app/ac_dashboard/index.php index 1822968..228eee5 100644 --- a/app/ac_dashboard/index.php +++ b/app/ac_dashboard/index.php @@ -199,7 +199,11 @@ supplier_credit_note: 'Supplier Credit Note', receipt: 'Receipt', payment: 'Payment', - manual: 'Manual' + manual: 'Manual', + asset_capitalization: 'Asset Mgmt · Capitalization', + asset_depreciation: 'Asset Mgmt · Depreciation', + asset_amortization: 'Asset Mgmt · Amortization', + asset_disposal: 'Asset Mgmt · Disposal' }; var source_badges = { @@ -209,7 +213,11 @@ 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' + 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 fmt_num(n) { diff --git a/app/accounting/api/engine/post_gl_entry.php b/app/accounting/api/engine/post_gl_entry.php index aad8114..c1c5ddc 100644 --- a/app/accounting/api/engine/post_gl_entry.php +++ b/app/accounting/api/engine/post_gl_entry.php @@ -11,6 +11,7 @@ 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'; @@ -29,19 +30,31 @@ $posting_map = [ 'purchase_order' => PurchasePosting::class, ]; -if (!isset($posting_map[$doc_type]) || $id <= 0) { +$is_asset_source = AssetPosting::isAssetSource($doc_type); + +if ((!isset($posting_map[$doc_type]) && !$is_asset_source) || $id <= 0) { $answer['message'] = 'Invalid doc_type or id.'; exit(json_encode($answer)); } try { - $posting_class = $posting_map[$doc_type]; - $posting = new $posting_class($pdo2, $company_id); - $built = $posting->build($id, $formula_id); + 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]; + } $guard = new PostingWindowGuard($pdo1, $company_id); $gl = new GlManager($pdo2, $company_id, $guard); - $meta = ['journal_date' => $built['doc_date'] ?? null]; $pdo2->beginTransaction(); diff --git a/app/accounting/gl_entries.php b/app/accounting/gl_entries.php index 9e7d7ca..38bbc1e 100644 --- a/app/accounting/gl_entries.php +++ b/app/accounting/gl_entries.php @@ -39,6 +39,22 @@ + + + + + +
@@ -60,6 +76,18 @@
+
+ +
+
+ +
+
+ +
+
+ +
@@ -76,9 +104,17 @@ 'purchase_invoice': 'purchase_invoice', 'supplier_credit_note': 'supplier_credit_note', 'receipt': 'receipt', - 'payment': 'payment' + 'payment': 'payment', + 'asset_capitalization': 'asset_capitalization', + 'asset_depreciation': 'asset_depreciation', + 'asset_amortization': 'asset_amortization', + 'asset_disposal': 'asset_disposal' }; + // 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 @@ -91,11 +127,13 @@ if (_inited[doc_type]) return; _inited[doc_type] = true; - load_formula_options('gl-formula-' + doc_type, doc_type, null, function(formulas) { - var $sel = $('#gl-formula-' + doc_type); - $sel.prepend(''); - $sel.val('all'); - }); + 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(''); + $sel.val('all'); + }); + } var today = new Date(); var firstDay = new Date(today.getFullYear(), today.getMonth(), 1); @@ -117,7 +155,7 @@ // ── load documents ──────────────────────────────────────────────────────── function load_gl_tab(doc_type) { - var formula_id = $('#gl-formula-' + doc_type).val(); + var formula_id = is_asset_type(doc_type) ? '0' : $('#gl-formula-' + doc_type).val(); if (!formula_id) { bootbox.alert('Please select a GL formula first.'); return; } if (formula_id === 'all') { @@ -256,7 +294,9 @@ var status_badge = r.gl_status == 1 ? 'Posted' : 'Unposted'; - var mapping_badge = product_mapping_badge(r); + var mapping_badge = is_asset_type(doc_type) + ? 'Asset category' + : product_mapping_badge(r); html += '' + '' + escape_html(r.doc_number) + '' + @@ -302,11 +342,15 @@ purchase_invoice: 'Purchase Invoice', supplier_credit_note: 'Supplier Credit Note', receipt: 'Receipt', - payment: 'Payment' + payment: 'Payment', + asset_capitalization: '[Asset Mgmt] Capitalization', + asset_depreciation: '[Asset Mgmt] Depreciation', + asset_amortization: '[Asset Mgmt] Amortization', + asset_disposal: '[Asset Mgmt] Disposal' }; function run_gl_batch(doc_type) { - var formula_id = $('#gl-formula-' + doc_type).val(); + var formula_id = is_asset_type(doc_type) ? '0' : $('#gl-formula-' + doc_type).val(); if (formula_id === 'all') { run_all_formula_batches(doc_type); @@ -393,7 +437,7 @@ // ── core batch processor ────────────────────────────────────────────────── function _start_gl_batch(doc_type, ids, onComplete, formula_id_override) { - var formula_id = formula_id_override || $('#gl-formula-' + doc_type).val(); + var formula_id = formula_id_override || (is_asset_type(doc_type) ? 0 : $('#gl-formula-' + doc_type).val()); var $msg = $('#gl-msg-' + doc_type); set_btn_state('#gl-btn-load-' + doc_type, false); @@ -516,10 +560,34 @@ + +
From Asset Management
+ +HTML + : << + + + +HTML; + $source_note = $from_asset + ? '
' + . 'Prepared in Asset Management. Accounts come from each asset category; ' + . 'entries are labelled [Asset Mgmt] in the GL Journal.
' + : ''; + $accounts_header = $from_asset ? 'Accounts' : 'Product Accounts'; + return << + {$source_note} +
@@ -532,11 +600,7 @@ function gl_tab_html(string $doc_type, string $label): string {
-
- - -
+{$source_field}
@@ -188,7 +194,11 @@ receipt: 'Receipt', payment: 'Payment', manual: 'Manual', - reversal: 'Reversal' + reversal: 'Reversal', + asset_capitalization: 'Asset Mgmt · Capitalization', + asset_depreciation: 'Asset Mgmt · Depreciation', + asset_amortization: 'Asset Mgmt · Amortization', + asset_disposal: 'Asset Mgmt · Disposal' }; var SOURCE_BADGE = { @@ -199,7 +209,11 @@ receipt: 'bg-info-subtle text-info', payment: 'bg-danger-subtle text-danger', manual: 'bg-dark-subtle text-dark', - reversal: 'bg-danger-subtle text-danger' + 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' }; function source_badge(type) { @@ -507,9 +521,9 @@ ? 'Balanced' : 'Unbalanced'; - var extra_fields = h.source_type === 'manual' + var extra_fields = (h.source_type === 'manual' || String(h.source_type).indexOf('asset_') === 0) ? '
Reference
' + escape_html(h.reference || ('MJE-' + h.id)) + '
' + - '
Journal Date
' + escape_html(h.journal_date_fmt || '—') + '
' + + '
Journal Date
' + escape_html(format_date(h.journal_date)) + '
' + '
Description
' + escape_html(h.description || '—') + '
' : '
Formula
' + escape_html(h.formula_name) + '
' + '
Version
v' + h.current_version + (h.current_version > 1 ? ' (replaced)' : '') + '
' + diff --git a/app/accounting/vat_report.php b/app/accounting/vat_report.php index c2f90b7..3acda06 100644 --- a/app/accounting/vat_report.php +++ b/app/accounting/vat_report.php @@ -139,6 +139,8 @@ 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(); diff --git a/app/asset/api/engine/asset.php b/app/asset/api/engine/asset.php new file mode 100644 index 0000000..f26ea9c --- /dev/null +++ b/app/asset/api/engine/asset.php @@ -0,0 +1,9 @@ +getList((int)($data['invoice_id'] ?? 0)); +$answer['success'] = 1; +exit(json_encode($answer)); diff --git a/app/asset/api/engine/asset_stats.php b/app/asset/api/engine/asset_stats.php new file mode 100644 index 0000000..51dbe8f --- /dev/null +++ b/app/asset/api/engine/asset_stats.php @@ -0,0 +1,21 @@ +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)); diff --git a/app/asset/api/engine/category.php b/app/asset/api/engine/category.php new file mode 100644 index 0000000..14c5912 --- /dev/null +++ b/app/asset/api/engine/category.php @@ -0,0 +1,11 @@ +getAll(); +$answer['stats'] = $categories->getStats(); +$answer['success'] = 1; +exit(json_encode($answer)); diff --git a/app/asset/api/engine/depreciation_run.php b/app/asset/api/engine/depreciation_run.php new file mode 100644 index 0000000..dd9c701 --- /dev/null +++ b/app/asset/api/engine/depreciation_run.php @@ -0,0 +1,63 @@ +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)); diff --git a/app/asset/api/engine/invoice_lines.php b/app/asset/api/engine/invoice_lines.php new file mode 100644 index 0000000..a2373f0 --- /dev/null +++ b/app/asset/api/engine/invoice_lines.php @@ -0,0 +1,28 @@ +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)); diff --git a/app/asset/api/engine/manage_asset.php b/app/asset/api/engine/manage_asset.php new file mode 100644 index 0000000..da91f06 --- /dev/null +++ b/app/asset/api/engine/manage_asset.php @@ -0,0 +1,70 @@ + '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)); diff --git a/app/asset/api/engine/manage_category.php b/app/asset/api/engine/manage_category.php new file mode 100644 index 0000000..891dcc6 --- /dev/null +++ b/app/asset/api/engine/manage_category.php @@ -0,0 +1,22 @@ +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)); diff --git a/app/asset/api/engine/remove_category.php b/app/asset/api/engine/remove_category.php new file mode 100644 index 0000000..9c7fd6f --- /dev/null +++ b/app/asset/api/engine/remove_category.php @@ -0,0 +1,15 @@ +deactivate($id); + +$answer['success'] = 1; +$answer['message'] = 'Category deactivated.'; +exit(json_encode($answer)); diff --git a/app/asset/api/engine/retrieve_asset.php b/app/asset/api/engine/retrieve_asset.php new file mode 100644 index 0000000..5e10b7f --- /dev/null +++ b/app/asset/api/engine/retrieve_asset.php @@ -0,0 +1,19 @@ +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)); diff --git a/app/asset/api/engine/retrieve_category.php b/app/asset/api/engine/retrieve_category.php new file mode 100644 index 0000000..a95fec6 --- /dev/null +++ b/app/asset/api/engine/retrieve_category.php @@ -0,0 +1,15 @@ +getById($id); +if (!$row) { $answer['message'] = 'Category not found'; exit(json_encode($answer)); } + +$answer['output'] = $row; +$answer['success'] = 1; +exit(json_encode($answer)); diff --git a/app/asset/categories.php b/app/asset/categories.php new file mode 100644 index 0000000..cab070e --- /dev/null +++ b/app/asset/categories.php @@ -0,0 +1,158 @@ + + + + + + +
+
+ + +
+
+
+
+

Asset Categories

+

Useful life and GL accounts used by each kind of asset

+
+ +
+
+
+ + +
+
+
+
+ +
+ +
+ + + + +
+
+ +
+ + + + + + + + + + + + + + + +
CodeNameClassLife (months)Cost / Accumulated / ExpenseAssetsStatusActions
+
+ +
+
+
+
+ +
+
+ + + + + + + + diff --git a/app/asset/depreciation.php b/app/asset/depreciation.php new file mode 100644 index 0000000..7efb277 --- /dev/null +++ b/app/asset/depreciation.php @@ -0,0 +1,317 @@ + + + + + + +
+
+ +
+
+
+

Depreciation & Amortization Runs

+

Charge each month's straight-line depreciation (tangible) or amortization (intangible)

+
+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+

+ 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 + Batch GL Entries, labelled [Asset Mgmt]. +

+
+
+
+ + +
+
+
+

Runs

+ +
+
+ + + + + + + + + + + + + + + + + + +
Run No.TypeMonthRun DateAssetsAmountGLStatus
Loading...
+
+
+
+ +
+
+ + + + + + + + + + + diff --git a/app/asset/index.php b/app/asset/index.php new file mode 100644 index 0000000..479419b --- /dev/null +++ b/app/asset/index.php @@ -0,0 +1,174 @@ + + + + + + +
+
+ + +
+
+
+
+

Asset Register

+

Every asset with its cost, accumulated depreciation and book value

+
+ +
+
+
+ +
+
+
+
+ + + +
+ + + + + + + + + + + + + + + + +
Asset No.NameCategoryIn ServiceCostAccumulatedBook ValueStatus
+
+ +
+
+
+
+ +
+
+ + + + + + + + diff --git a/app/asset/manage_asset.php b/app/asset/manage_asset.php new file mode 100644 index 0000000..2f88466 --- /dev/null +++ b/app/asset/manage_asset.php @@ -0,0 +1,595 @@ + + + + + + +
+
+ +
+
+
+
+

New Asset

+

Straight-line depreciation with your own salvage value

+
+ + Back + +
+
+
+ +
+ + +
+ +
+ +
+
+

Asset Details

+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+
+ + +
Credited when the asset is capitalized — the account the purchase was posted to. Leave empty if the asset is already on the books.
+
+ +
+ + +
+
+ + +
+
+ +
+ + +
+
+ + +
+
+ + +
+ +
+
+
Amount to depreciate: —
+
Per month: —
+
Last month: —
+
+
+ +
+ + +
+
+
+ Cost, salvage value, life, dates and category are locked because months have already been charged. +
+
+
+ +
+
+
+

Depreciation Schedule

+ +
+
+ + + + + + + + + + + + +
#MonthAmountAccumulatedBook ValueState
+
+
+
+
+ + +
+ +
+
+

Status

+
+
Accumulated: —
+
Book value: —
+
Last month charged: —
+
Source:
+ +
+

GL Entries [Asset Mgmt]

+
Capitalization
+
Disposal
+ +
+
+

Disposal

+
Date:
+
Sale price:
+
Gain / (loss):
+
+
+
+
+ +
+
+ + + + + + +
+
+ +
+
+
+
+ + + + + + + + + + + diff --git a/app/asset/manage_category.php b/app/asset/manage_category.php new file mode 100644 index 0000000..1bb786c --- /dev/null +++ b/app/asset/manage_category.php @@ -0,0 +1,179 @@ + + + + + + +
+
+ +
+
+
+ + Back + +

Manage Asset Category

+
+
+
+ +
+
+
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+

GL Accounts

+

Used when Accounting posts this category's entries from Batch GL Entries.

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ +
+ + +
+
+ +
+ +
+ +
+
+
+
+ +
+
+ + + + + + + + diff --git a/app/asset/purchase_invoices.php b/app/asset/purchase_invoices.php new file mode 100644 index 0000000..0eb14b3 --- /dev/null +++ b/app/asset/purchase_invoices.php @@ -0,0 +1,173 @@ + + + + + + +
+
+ +
+
+
+

Assets from Purchase Invoices

+

Issued purchase invoice lines (AP) that can be capitalized as assets

+
+
+
+ +
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + +
InvoiceDateSupplierItemQtyLine AmountCapitalizedRemaining
Use the filters above and click Load.
+
+
+
+ +
+
+ + + + + + + + diff --git a/app/asset_dashboard/index.php b/app/asset_dashboard/index.php new file mode 100644 index 0000000..95a8ded --- /dev/null +++ b/app/asset_dashboard/index.php @@ -0,0 +1,267 @@ + + + + + + +
+
+ + +
+
+
+
+

Asset Management

+

Fixed assets, depreciation and amortization

+
+ +
+
+
+ + +
+
+
+
+
+ +
+
+

Assets in Use

+

—

+

 

+
+
+
+
+
+
+
+
+ +
+
+

Cost

+

—

+

Assets in use

+
+
+
+
+
+
+
+
+ +
+
+

Accumulated

+

—

+

Depreciation + amortization

+
+
+
+
+
+
+
+
+ +
+
+

Net Book Value

+

—

+

Cost minus accumulated

+
+
+
+
+
+ +
+ +
+
+
+

Due this month

+

Months not yet charged, up to

+
Loading...
+
+ Drafts waiting to be activated: + — +
+
+
+
+ + +
+
+
+
+

Recent runs

+ All runs +
+
+ + + + + + + + + + + + +
Run No.MonthAmountGL
Loading...
+
+
+
+
+
+ + +
+
+
+
+

Assets in use by category

+
+ + + + + + + + + + + + + + +
CategoryClassAssetsCostAccumulatedNet Book Value
Loading...
+
+
+
+
+
+ +
+
+ + + + + + + + diff --git a/app/assets/css/custom.css b/app/assets/css/custom.css index 5e8c939..aef2e09 100644 --- a/app/assets/css/custom.css +++ b/app/assets/css/custom.css @@ -1,6 +1,13 @@ 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; diff --git a/app/assets/js/asset.js b/app/assets/js/asset.js new file mode 100644 index 0000000..56e10b3 --- /dev/null +++ b/app/assets/js/asset.js @@ -0,0 +1,74 @@ +// 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 '' + label + ''; +} + +function asset_class_badge(asset_class) { + return asset_class === 'intangible' + ? 'Intangible · Amortization' + : 'Tangible · Depreciation'; +} + +function asset_gl_badge(posted) { + return String(posted) === '1' + ? 'Posted' + : 'Unposted'; +} + +// 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); + } + }); +} + +// '; + var found = false; + list.forEach(function(a) { + var is_selected = String(a.account_code) === String(selected || ''); + if (is_selected) found = true; + html += ''; + }); + // Keep a saved code visible even if it is no longer an active posting account. + if (selected && !found) { + html += ''; + } + return html; +} + +function current_period() { + var d = new Date(); + return d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2); +} diff --git a/app/assets/js/custom.js b/app/assets/js/custom.js index 0a54c2e..3d7aea4 100644 --- a/app/assets/js/custom.js +++ b/app/assets/js/custom.js @@ -995,15 +995,24 @@ document.addEventListener('DOMContentLoaded', () => { /** * Helper function to format date strings (assuming input is in ISO format) */ +// Display format used across the app: YYYY-MM-DD, or YYYY-MM-DD HH:mm:ss when the value has a time. function format_date(iso_string) { if (!iso_string) return '—'; - var split = iso_string.split(" "); + var split = String(iso_string).split(" "); var datePart = split[0].split("-"); if (datePart.length !== 3) return iso_string; - var formatted = `${datePart[2]}/${datePart[1]}/${datePart[0]}`; + var formatted = `${datePart[0]}-${datePart[1]}-${datePart[2]}`; return split.length === 2 ? `${formatted} ${split[1]}` : formatted; } +// DD/MM/YYYY for filling date inputs (flatpickr dateFormat 'd/m/Y'); to_iso_date() reverses it. +function format_date_input(iso_string) { + if (!iso_string) return ''; + var datePart = String(iso_string).split(" ")[0].split("-"); + if (datePart.length !== 3) return iso_string; + return `${datePart[2]}/${datePart[1]}/${datePart[0]}`; +} + function to_iso_date(dateStr) { if (!dateStr) return null; diff --git a/app/assets/utils/app_registry.php b/app/assets/utils/app_registry.php index 0bf1c11..3c62d99 100644 --- a/app/assets/utils/app_registry.php +++ b/app/assets/utils/app_registry.php @@ -2,21 +2,51 @@ // app/assets/utils/app_registry.php // // The apps a user can be given access to (user.app_access and -// company_map_user.app_access), with the label, icon and badge colour the -// Users Access page shows for each. +// 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; this file only fills it in when -// it is missing or empty — which is every Docker-generated config.php written -// before the setting was documented. Without it the Add User dialog breaks +// 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' and 'accounting' +// 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 = [ - 'wms' => ['label' => 'WMS', 'icon' => 'ti-box', 'color' => 'bg-label-primary'], - 'accounting' => ['label' => 'Accounting', 'icon' => 'ti-calculator', 'color' => 'bg-label-success'], - ]; + $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'; + } } diff --git a/app/assets/utils/classes/DocumentNumberManager.php b/app/assets/utils/classes/DocumentNumberManager.php index 0c1c248..4231a1d 100644 --- a/app/assets/utils/classes/DocumentNumberManager.php +++ b/app/assets/utils/classes/DocumentNumberManager.php @@ -33,6 +33,7 @@ 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) diff --git a/app/assets/utils/classes/StockManager.php b/app/assets/utils/classes/StockManager.php index bc3d6b5..ebf2de9 100644 --- a/app/assets/utils/classes/StockManager.php +++ b/app/assets/utils/classes/StockManager.php @@ -79,7 +79,8 @@ class StockManager { public function getStockList(int $warehouse_id, string $type): array { $table = $this->stockTableNameFromWarehouseId($warehouse_id); - $column = $type === 'out' ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)'; + // 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)'; $stock_ref = $this->stockReferenceSql(); // Transfer list: show only the outbound side (out > 0) to avoid duplicate display @@ -439,8 +440,9 @@ class StockManager { ); } - // Quantity and identifiers come from the existing stock_in row (immutable) - $quantity = (int)$source_stock['in']; + // Quantity and identifiers come from the existing stock_in row (immutable). + // `in` is decimal(18,4) — an int cast would drop fractional quantities. + $quantity = (float)$source_stock['in']; $ref_id = (int)$source_stock['id']; $lot_number = $source_stock['lot_number'] ?? null; $serial_number = $source_stock['serial_number'] ?? null; @@ -611,8 +613,9 @@ class StockManager { ); } - // Quantity and identifiers come from the source stock_in row (immutable) - $quantity = (int)$source_stock['in']; + // Quantity and identifiers come from the source stock_in row (immutable). + // `in` is decimal(18,4) — an int cast would drop fractional quantities. + $quantity = (float)$source_stock['in']; $lot_number = $source_stock['lot_number'] ?? null; $serial_number = $source_stock['serial_number'] ?? null; diff --git a/app/assets/utils/classes/UserManager.php b/app/assets/utils/classes/UserManager.php index a4093b3..8936ce6 100644 --- a/app/assets/utils/classes/UserManager.php +++ b/app/assets/utils/classes/UserManager.php @@ -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', or 'all'. + * @param string $app_access New value: 'wms', 'accounting', 'asset', or 'all'. * @throws Exception If the member is not found or is the owner. */ public function updateAppAccess(int $map_id, string $app_access): void { diff --git a/app/assets/utils/classes_ac/FinancialReports.php b/app/assets/utils/classes_ac/FinancialReports.php index c533201..9c2c572 100644 --- a/app/assets/utils/classes_ac/FinancialReports.php +++ b/app/assets/utils/classes_ac/FinancialReports.php @@ -697,7 +697,7 @@ class FinancialReports COALESCE(d.dept_code, '') AS dept_code, COALESCE(d.dept_name, '') AS dept_name, COALESCE(g.journal_date, DATE(g.created_at)) AS entry_date, - DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at, + DATE_FORMAT(g.created_at, '%Y-%m-%d %H:%i:%s') AS posted_at, i.debit, i.credit, COALESCE(i.description, '') AS line_description diff --git a/app/assets/utils/classes_ac/GlQueryManager.php b/app/assets/utils/classes_ac/GlQueryManager.php index 553dc42..25df724 100644 --- a/app/assets/utils/classes_ac/GlQueryManager.php +++ b/app/assets/utils/classes_ac/GlQueryManager.php @@ -1,4 +1,6 @@ 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') { @@ -75,13 +84,17 @@ class GlQueryManager g.current_version, g.formula_id, COALESCE(f.formula_name, '') AS formula_name, - DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at, - DATE_FORMAT(g.updated_at, '%d/%m/%Y %H:%i') AS updated_at, + DATE_FORMAT(g.created_at, '%Y-%m-%d %H:%i:%s') AS posted_at, + DATE_FORMAT(g.updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at, CASE g.source_type WHEN 'receipt' THEN r.receipt_number WHEN 'payment' THEN p.payment_number 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( @@ -90,6 +103,10 @@ 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, @@ -101,7 +118,8 @@ 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') + ON g.source_type NOT IN ('receipt','payment','manual','purchase_order', + 'asset_capitalization','asset_depreciation','asset_amortization','asset_disposal') 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 @@ -142,8 +160,8 @@ class GlQueryManager $sth = $this->pdo->prepare( "SELECT g.*, - DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at, - DATE_FORMAT(g.updated_at, '%d/%m/%Y %H:%i') AS updated_at_fmt, + DATE_FORMAT(g.created_at, '%Y-%m-%d %H:%i:%s') AS posted_at, + DATE_FORMAT(g.updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at_fmt, DATE_FORMAT(g.journal_date, '%d/%m/%Y') AS journal_date_fmt, COALESCE(f.formula_name, '') AS formula_name FROM td_gl g diff --git a/app/assets/utils/classes_as/AssetCategoryManager.php b/app/assets/utils/classes_as/AssetCategoryManager.php new file mode 100644 index 0000000..0e9cf3c --- /dev/null +++ b/app/assets/utils/classes_as/AssetCategoryManager.php @@ -0,0 +1,173 @@ + '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(); + } +} diff --git a/app/assets/utils/classes_as/AssetManager.php b/app/assets/utils/classes_as/AssetManager.php new file mode 100644 index 0000000..5b1b29c --- /dev/null +++ b/app/assets/utils/classes_as/AssetManager.php @@ -0,0 +1,596 @@ +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}'."); + } +} diff --git a/app/assets/utils/classes_as/AssetPosting.php b/app/assets/utils/classes_as/AssetPosting.php new file mode 100644 index 0000000..e72d658 --- /dev/null +++ b/app/assets/utils/classes_as/AssetPosting.php @@ -0,0 +1,267 @@ +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})."); + } + } +} diff --git a/app/assets/utils/classes_as/AssetRunManager.php b/app/assets/utils/classes_as/AssetRunManager.php new file mode 100644 index 0000000..dab6303 --- /dev/null +++ b/app/assets/utils/classes_as/AssetRunManager.php @@ -0,0 +1,334 @@ + ['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 + */ + 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]; + } +} diff --git a/app/assets/utils/classes_as/DepreciationCalculator.php b/app/assets/utils/classes_as/DepreciationCalculator.php new file mode 100644 index 0000000..ea1eb60 --- /dev/null +++ b/app/assets/utils/classes_as/DepreciationCalculator.php @@ -0,0 +1,135 @@ + $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 + */ + 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]]; + } +} diff --git a/app/assets/utils/db_auth.php b/app/assets/utils/db_auth.php index c7fe263..a1428eb 100644 --- a/app/assets/utils/db_auth.php +++ b/app/assets/utils/db_auth.php @@ -20,6 +20,17 @@ 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 diff --git a/app/config.example.php b/app/config.example.php index 691b6f4..96070b6 100644 --- a/app/config.example.php +++ b/app/config.example.php @@ -49,11 +49,13 @@ if (!defined('OTP_REQUIRED')) { // ── App registry ───────────────────────────────────────────────────────────── // Apps a user can be given access to, as shown on Setting → Users Access. Keys -// must match the user.app_access enum ('wms', 'accounting'). If this is left -// out, assets/utils/app_registry.php supplies the same default. +// 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'], - 'accounting' => ['label' => 'Accounting', 'icon' => 'ti-calculator', 'color' => 'bg-label-success'], + '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 ─────────────────────────────────────────────────────────── diff --git a/app/expense/manage_purchase_invoice.php b/app/expense/manage_purchase_invoice.php index ee3397e..9dd1f74 100644 --- a/app/expense/manage_purchase_invoice.php +++ b/app/expense/manage_purchase_invoice.php @@ -4,6 +4,8 @@ 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); ?> @@ -73,6 +75,30 @@
+ + +
+
+

+ Asset Management + Assets from this invoice +

+
+ + + + + + + + + + + +
Asset No.NameCostStatus
+
+
+
@@ -160,6 +186,44 @@ var invoice_id = ; var invoice_data = null; + var show_asset_actions = ; + + var ASSET_STATUS_BADGE = { + '0': 'Draft', + '1': 'Active', + '2': 'Fully depreciated', + '3': 'Disposed', + '4': 'Void', + }; + + function load_invoice_assets() { + return ajax_request({ + 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 += ` + ${escape_html(a.asset_number)} + ${escape_html(a.asset_name)} + ${format_number(a.cost, 2)} + ${ASSET_STATUS_BADGE[String(a.status)] || '—'} + + `; + }); + $('#asset_tbody').html(html); + $('#asset_card').removeClass('d-none'); + } + }); + } function doc_type_badge(doc_type) { const map = { @@ -230,7 +294,7 @@ $('#badge_status').html(invoice_status_badge(inv.status, inv.due_date)); $('#display_contact').text(inv.contact_name || '—'); $('#display_issued').text(format_date(inv.issued_date) || '—'); - $('#due_date').val(inv.due_date ? format_date(inv.due_date) : ''); + $('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : ''); $('#notes').val(inv.notes || ''); // Source link @@ -245,10 +309,14 @@ } // 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 + ? `
Create asset
` + : ''; tbody += ` - ${escape_html(item.product_name || item.product_sku)} + ${escape_html(item.product_name || item.product_sku)}${asset_link} ${format_number(item.quantity, 2)} ${format_number(item.unit_price, 2)} ${format_number(item.tax_rate, 2)} @@ -310,6 +378,7 @@ $('#btn_save_formula').prop('disabled', parseInt(inv.status) === 4); set_mode(inv); + if (show_asset_actions && inv.doc_type === 'purchase_invoice') load_invoice_assets(); } }); } diff --git a/app/expense/manage_purchase_order.php b/app/expense/manage_purchase_order.php index 258be84..86986ca 100644 --- a/app/expense/manage_purchase_order.php +++ b/app/expense/manage_purchase_order.php @@ -274,8 +274,8 @@ $('#po_number_display').text(po_data.po_number || ''); $('#contact').val(po_data.contact_name || ''); $('#contact_id').val(po_data.contact_id || ''); - $('#po_date').val(po_data.po_date ? format_date(po_data.po_date) : ''); - $('#expected_date').val(po_data.expected_date ? format_date(po_data.expected_date) : ''); + $('#po_date').val(po_data.po_date ? format_date_input(po_data.po_date) : ''); + $('#expected_date').val(po_data.expected_date ? format_date_input(po_data.expected_date) : ''); $('#department_id').val(po_data.department_id || 0); $('#notes').val(po_data.notes || ''); $('#discount').val(po_data.discount || 0); diff --git a/app/expense/manage_purchase_request.php b/app/expense/manage_purchase_request.php index c4b1c07..08f4db4 100644 --- a/app/expense/manage_purchase_request.php +++ b/app/expense/manage_purchase_request.php @@ -253,8 +253,8 @@ .html(request_data.po_id > 0 ? 'PO: ' + escape_html(request_data.po_number || ('PO #' + request_data.po_id)) + '' : ''); $('#contact').val(request_data.contact_name || ''); $('#contact_id').val(request_data.contact_id || 0); - $('#request_date').val(request_data.request_date ? format_date(request_data.request_date) : ''); - $('#required_date').val(request_data.required_date ? format_date(request_data.required_date) : ''); + $('#request_date').val(request_data.request_date ? format_date_input(request_data.request_date) : ''); + $('#required_date').val(request_data.required_date ? format_date_input(request_data.required_date) : ''); $('#department_id').val(request_data.department_id || 0); $('#notes').val(request_data.notes || ''); $('#discount').val(request_data.discount || 0); diff --git a/app/ics/api/engine/retrieve_bin_stock.php b/app/ics/api/engine/retrieve_bin_stock.php new file mode 100644 index 0000000..1736df9 --- /dev/null +++ b/app/ics/api/engine/retrieve_bin_stock.php @@ -0,0 +1,38 @@ +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)); +?> diff --git a/app/ics/manage_stock_out.php b/app/ics/manage_stock_out.php index 5f64dfc..fe893e3 100644 --- a/app/ics/manage_stock_out.php +++ b/app/ics/manage_stock_out.php @@ -93,6 +93,16 @@ + +
+ +
+ +   +
+
The whole quantity in the selected location is taken out.
+
+
@@ -159,6 +169,35 @@ } set_select_value('#bin', data.bin, data.bin); scan_location_ready = true; + show_location_quantity(); + }); + } + + // Quantity held in the chosen location — the amount this stock-out moves. + function show_location_quantity() { + var bin_val = $('#bin').val(); + $('#location_quantity').val(''); + $('#location_uom').html(' '); + if (!$('#warehouse').val() || !bin_val) return; + + return ajax_request({ + url: 'ics/api/engine/retrieve_bin_stock.php', + autoPrepare: true, + checkRequired: 0, + noLoading: true, + queueLock: false, + action: 'read', + data: { + warehouse: $('#warehouse').val(), + zone: advanced ? $('#zone').val() : bin_val, + aisle: advanced ? $('#aisle').val() : bin_val, + bin: bin_val + }, + onSuccess: function(res) { + if (!res.output) return; + $('#location_quantity').val(format_number(res.output.quantity, 2)); + $('#location_uom').text(res.output.uom || ''); + } }); } @@ -483,6 +522,8 @@ $('#zone').html('').prop('disabled', true); $('#aisle').html('').prop('disabled', true); $('#bin').html('').prop('disabled', true); + $('#location_quantity').val(''); + $('#location_uom').html(' '); } @@ -509,7 +550,7 @@ $('#serial_number').html(``) .val(data.serial_number || '').prop('disabled', true); - $('#warehouse').val('').prop('disabled', true); + $('#warehouse').val('').prop('disabled', true); function populate_fields() { $.each(data, function(key, item) { @@ -522,6 +563,8 @@ .attr('data-id', data.contact_id); $('#product_name').val(data.product_name); $('#product_sku').attr('secondary', data.product_sku).prop('disabled', true); + $('#location_quantity').val(format_number(data.quantity, 2)); + $('#location_uom').text(data.uom || ''); $('button[type=submit]').text('Update'); $('button[type=reset]').hide(); if (data.status == 0) { @@ -619,6 +662,9 @@ if (advanced) retrieve_bin(); }); + // Bin selected → show how much it holds + $('#bin').on('change', show_location_quantity); + diff --git a/app/ics/manage_stock_transfer.php b/app/ics/manage_stock_transfer.php index 64ff825..61da8c0 100644 --- a/app/ics/manage_stock_transfer.php +++ b/app/ics/manage_stock_transfer.php @@ -92,6 +92,15 @@
+ +
+ +
+ +   +
+
Whole location is moved.
+

@@ -204,12 +213,41 @@ if (role === 'from') { scan_from_ready = true; + show_from_quantity(); } else { scan_to_ready = true; } }); } + // Quantity held in the from-location — the amount this transfer moves. + function show_from_quantity() { + var bin_val = $('#bin_from').val(); + $('#transfer_quantity').val(''); + $('#transfer_uom').html(' '); + if (!$('#warehouse_from').val() || !bin_val) return; + + return ajax_request({ + url: 'ics/api/engine/retrieve_bin_stock.php', + autoPrepare: true, + checkRequired: 0, + noLoading: true, + queueLock: false, + action: 'read', + data: { + warehouse: $('#warehouse_from').val(), + zone: advanced ? $('#zone_from').val() : bin_val, + aisle: advanced ? $('#aisle_from').val() : bin_val, + bin: bin_val + }, + onSuccess: function(res) { + if (!res.output) return; + $('#transfer_quantity').val(format_number(res.output.quantity, 2)); + $('#transfer_uom').text(res.output.uom || ''); + } + }); + } + function same_location_as_from(data) { return String($('#warehouse_from').val()) === String(data.warehouse_id) && String($('#zone_from').val()) === String(data.zone) @@ -574,6 +612,8 @@ $('#zone_from').html('').prop('disabled', true); $('#aisle_from').html('').prop('disabled', true); $('#bin_from').html('').prop('disabled', true); + $('#transfer_quantity').val(''); + $('#transfer_uom').html(' '); } @@ -640,6 +680,8 @@ $('#contact').val(data.contact_name) .attr('secondary', data.contact_name) .attr('data-id', data.contact_id); + $('#transfer_quantity').val(format_number(data.quantity, 2)); + $('#transfer_uom').text(data.uom || ''); $('button[type=submit]').text('Update'); $('button[type=reset]').hide(); if (data.status == 0) { @@ -732,6 +774,9 @@ if (advanced) retrieve_bin('from'); }); + // From bin → show how much it holds + $('#bin_from').on('change', show_from_quantity); + // To warehouse → zone (advanced) or bin directly (simple) $('select[name="warehouse"][role="to"]').on('change', function() { if (advanced) { retrieve_zone('to'); } else { retrieve_bin('to'); } diff --git a/app/include_master_sidebar.php b/app/include_master_sidebar.php index 3be3492..a75a523 100644 --- a/app/include_master_sidebar.php +++ b/app/include_master_sidebar.php @@ -9,12 +9,10 @@ $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']); - $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'; + 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'); ?> diff --git a/app/include_setting_sidebar.php b/app/include_setting_sidebar.php index ee660a5..e360ea9 100644 --- a/app/include_setting_sidebar.php +++ b/app/include_setting_sidebar.php @@ -93,8 +93,9 @@ Navigation +
  • - + Back to App diff --git a/app/include_sidebar_asset.php b/app/include_sidebar_asset.php new file mode 100644 index 0000000..02d3a4e --- /dev/null +++ b/app/include_sidebar_asset.php @@ -0,0 +1,94 @@ + + + diff --git a/app/include_topbar.php b/app/include_topbar.php index a031eac..6da4b01 100644 --- a/app/include_topbar.php +++ b/app/include_topbar.php @@ -29,34 +29,58 @@ $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. -$_wms_paths = ['/dashboard/', '/ics/', '/order/', '/po/', '/reports/']; -$_ac_paths = ['/ac_dashboard/', '/revenue/', '/expense/', '/finance/', '/journal/']; +// '/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/'], +]; $topbar_current_app = null; // null = neutral page (master data / setting) -foreach ($_wms_paths as $_p) { - if (str_contains($_SERVER['PHP_SELF'], $_p)) { $topbar_current_app = 'wms'; break; } -} -if (!$topbar_current_app) { - foreach ($_ac_paths as $_p) { - if (str_contains($_SERVER['PHP_SELF'], $_p)) { $topbar_current_app = 'accounting'; break; } +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; } } } +// 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(); + } + 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 if ($topbar_current_app) { $_SESSION['login_current_app'] = $topbar_current_app; } // Default session on first visit or neutral pages if (empty($_SESSION['login_current_app'])) { - $_SESSION['login_current_app'] = ($topbar_app_access === 'accounting') ? 'accounting' : 'wms'; + $_SESSION['login_current_app'] = app_default_for_access($app_registry, $topbar_app_access); } -$topbar_show_wms = in_array($topbar_app_access, ['all', 'wms']); -$topbar_show_accounting = in_array($topbar_app_access, ['all', 'accounting']); +$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; $_usage = (new UsageGuard($pdo1, (int)$_SESSION['login_company_id'], $packages))->getStatus(); $_usage_max_pct = max($_usage['daily_pct'], $_usage['weekly_pct']); @@ -64,67 +88,67 @@ $_usage_warn = $_usage_max_pct >= 80 && $_usage_max_pct < 100; $_usage_full = $_usage_max_pct >= 100; ?> - - @@ -171,30 +195,24 @@ $_usage_full = $_usage_max_pct >= 100;