Compare commits
48
Commits
931d95bafc
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c39700d74 | ||
|
|
136084f259 | ||
|
|
63cea23dc4 | ||
|
|
dd48a8b96d | ||
|
|
b2c437426b | ||
|
|
a0677d6d8d | ||
|
|
ed3dd2f215 | ||
|
|
fda211b1a8 | ||
|
|
9a50238347 | ||
|
|
5df67367fa | ||
|
|
8f57ab5570 | ||
|
|
b8798bc02d | ||
|
|
bbe89b0380 | ||
|
|
714b70d552 | ||
|
|
f3c0e3c876 | ||
|
|
a6651c41b9 | ||
|
|
458a883810 | ||
|
|
5221b3a1a1 | ||
|
|
418dbf9ba6 | ||
|
|
99ae35dc98 | ||
|
|
1f371c6f94 | ||
|
|
d7f104ff25 | ||
|
|
dfeb57533a | ||
|
|
cb36d3b8fd | ||
|
|
5687b562fb | ||
|
|
d93abb0b81 | ||
|
|
b4b1f5cbec | ||
|
|
1904fea84c | ||
|
|
4f884177a5 | ||
|
|
4733c78ac6 | ||
|
|
9b41c0a73a | ||
|
|
ba96de50a1 | ||
|
|
293097363b | ||
|
|
b07882e3f4 | ||
|
|
eddb10aa22 | ||
|
|
363da054e0 | ||
|
|
65e9c1668d | ||
|
|
f4ef776e9c | ||
|
|
59037b5158 | ||
|
|
f5ea74e134 | ||
|
|
9c275eb358 | ||
|
|
2410f7bade | ||
|
|
f04554793e | ||
|
|
ba8e275426 | ||
|
|
e36d304521 | ||
|
|
b76dc679af | ||
|
|
fdf6292466 | ||
|
|
94032dd65b |
@@ -13,11 +13,5 @@ EMIT_SECRET=
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
|
||||
# Email OTP on sign-in. Off by default; only the exact value "true" turns it on,
|
||||
# and that needs working SMTP. While off, sign-in is password only (logged as
|
||||
# OTP_BYPASSED, shown on the login page and top bar).
|
||||
# Applied to app/config.php by the php container on every start.
|
||||
OTP_REQUIRED=false
|
||||
|
||||
# Port to expose the web app on (default 80)
|
||||
HTTP_PORT=80
|
||||
|
||||
+2
-3
@@ -6,9 +6,6 @@ app/uploads
|
||||
node_modules/
|
||||
nodejs/.env
|
||||
|
||||
# PM2 runtime logs (written by the node container; nodejs/logs/.gitkeep keeps the folder)
|
||||
nodejs/logs/*.log
|
||||
|
||||
# Docker deploy secrets
|
||||
/.env
|
||||
|
||||
@@ -18,4 +15,6 @@ lib/zxcvbn-php-master/vendor/sebastian/
|
||||
# custom files
|
||||
notes/
|
||||
docs/
|
||||
.claude/
|
||||
SESSION.php
|
||||
sdlc/
|
||||
|
||||
@@ -199,11 +199,7 @@
|
||||
supplier_credit_note: 'Supplier Credit Note',
|
||||
receipt: 'Receipt',
|
||||
payment: 'Payment',
|
||||
manual: 'Manual',
|
||||
asset_capitalization: 'Asset Mgmt · Capitalization',
|
||||
asset_depreciation: 'Asset Mgmt · Depreciation',
|
||||
asset_amortization: 'Asset Mgmt · Amortization',
|
||||
asset_disposal: 'Asset Mgmt · Disposal'
|
||||
manual: 'Manual'
|
||||
};
|
||||
|
||||
var source_badges = {
|
||||
@@ -213,11 +209,7 @@
|
||||
supplier_credit_note: 'bg-secondary-subtle text-secondary',
|
||||
receipt: 'bg-info-subtle text-info',
|
||||
payment: 'bg-danger-subtle text-danger',
|
||||
manual: 'bg-dark-subtle text-dark',
|
||||
asset_capitalization: 'bg-success text-white',
|
||||
asset_depreciation: 'bg-success text-white',
|
||||
asset_amortization: 'bg-success text-white',
|
||||
asset_disposal: 'bg-success text-white'
|
||||
manual: 'bg-dark-subtle text-dark'
|
||||
};
|
||||
|
||||
function fmt_num(n) {
|
||||
|
||||
@@ -11,7 +11,6 @@ require_once '../../../assets/utils/classes_ac/posting/SupplierCreditNotePosting
|
||||
require_once '../../../assets/utils/classes_ac/posting/ReceiptPosting.php';
|
||||
require_once '../../../assets/utils/classes_ac/posting/PaymentPosting.php';
|
||||
require_once '../../../assets/utils/classes_ac/posting/PurchasePosting.php';
|
||||
require_once '../../../assets/utils/classes_as/AssetPosting.php';
|
||||
|
||||
require_role($user_role, ['owner', 'admin']);
|
||||
require_once '../../../assets/utils/notify_node.php';
|
||||
@@ -30,31 +29,19 @@ $posting_map = [
|
||||
'purchase_order' => PurchasePosting::class,
|
||||
];
|
||||
|
||||
$is_asset_source = AssetPosting::isAssetSource($doc_type);
|
||||
|
||||
if ((!isset($posting_map[$doc_type]) && !$is_asset_source) || $id <= 0) {
|
||||
if (!isset($posting_map[$doc_type]) || $id <= 0) {
|
||||
$answer['message'] = 'Invalid doc_type or id.';
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
try {
|
||||
if ($is_asset_source) {
|
||||
// Asset Management entries carry their own reference and "[Asset Mgmt]" description.
|
||||
$built = (new AssetPosting($pdo2, $company_id))->build($doc_type, $id);
|
||||
$meta = [
|
||||
'journal_date' => $built['doc_date'],
|
||||
'reference' => $built['reference'],
|
||||
'description' => $built['description'],
|
||||
];
|
||||
} else {
|
||||
$posting_class = $posting_map[$doc_type];
|
||||
$posting = new $posting_class($pdo2, $company_id);
|
||||
$built = $posting->build($id, $formula_id);
|
||||
$meta = ['journal_date' => $built['doc_date'] ?? null];
|
||||
}
|
||||
$posting_class = $posting_map[$doc_type];
|
||||
$posting = new $posting_class($pdo2, $company_id);
|
||||
$built = $posting->build($id, $formula_id);
|
||||
|
||||
$guard = new PostingWindowGuard($pdo1, $company_id);
|
||||
$gl = new GlManager($pdo2, $company_id, $guard);
|
||||
$meta = ['journal_date' => $built['doc_date'] ?? null];
|
||||
|
||||
$pdo2->beginTransaction();
|
||||
|
||||
|
||||
@@ -39,22 +39,6 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#tab-payment">Payment</a>
|
||||
</li>
|
||||
<!-- Entries prepared in Asset Management -->
|
||||
<li class="nav-item d-flex align-items-center ms-lg-3 px-2">
|
||||
<span class="badge bg-success text-white"><i class="ti ti-building-warehouse me-1"></i>Asset Management</span>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-success" data-bs-toggle="tab" href="#tab-asset-capitalization">Capitalization</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-success" data-bs-toggle="tab" href="#tab-asset-depreciation">Depreciation</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-success" data-bs-toggle="tab" href="#tab-asset-amortization">Amortization</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-success" data-bs-toggle="tab" href="#tab-asset-disposal">Disposal</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
@@ -76,18 +60,6 @@
|
||||
<div class="tab-pane fade" id="tab-payment">
|
||||
<?php echo gl_tab_html('payment', 'Payment'); ?>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="tab-asset-capitalization">
|
||||
<?php echo gl_tab_html('asset_capitalization', 'Asset Capitalization', true); ?>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="tab-asset-depreciation">
|
||||
<?php echo gl_tab_html('asset_depreciation', 'Asset Depreciation', true); ?>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="tab-asset-amortization">
|
||||
<?php echo gl_tab_html('asset_amortization', 'Asset Amortization', true); ?>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="tab-asset-disposal">
|
||||
<?php echo gl_tab_html('asset_disposal', 'Asset Disposal', true); ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,17 +76,9 @@
|
||||
'purchase_invoice': 'purchase_invoice',
|
||||
'supplier_credit_note': 'supplier_credit_note',
|
||||
'receipt': 'receipt',
|
||||
'payment': 'payment',
|
||||
'asset_capitalization': 'asset_capitalization',
|
||||
'asset_depreciation': 'asset_depreciation',
|
||||
'asset_amortization': 'asset_amortization',
|
||||
'asset_disposal': 'asset_disposal'
|
||||
'payment': 'payment'
|
||||
};
|
||||
|
||||
// Prepared in Asset Management: no GL formula, accounts come from the asset category.
|
||||
var ASSET_TYPES = ['asset_capitalization', 'asset_depreciation', 'asset_amortization', 'asset_disposal'];
|
||||
function is_asset_type(doc_type) { return ASSET_TYPES.indexOf(doc_type) !== -1; }
|
||||
|
||||
var _gl_loaded = {}; // doc_type → [ids] (flat, used by single-formula run)
|
||||
var _gl_rows = {}; // doc_type → [rows]
|
||||
var _gl_failed = {}; // doc_type → failed items from last run
|
||||
@@ -127,13 +91,11 @@
|
||||
if (_inited[doc_type]) return;
|
||||
_inited[doc_type] = true;
|
||||
|
||||
if (!is_asset_type(doc_type)) {
|
||||
load_formula_options('gl-formula-' + doc_type, doc_type, null, function(formulas) {
|
||||
var $sel = $('#gl-formula-' + doc_type);
|
||||
$sel.prepend('<option value="all">— All Formulas (' + formulas.length + ') —</option>');
|
||||
$sel.val('all');
|
||||
});
|
||||
}
|
||||
load_formula_options('gl-formula-' + doc_type, doc_type, null, function(formulas) {
|
||||
var $sel = $('#gl-formula-' + doc_type);
|
||||
$sel.prepend('<option value="all">— All Formulas (' + formulas.length + ') —</option>');
|
||||
$sel.val('all');
|
||||
});
|
||||
|
||||
var today = new Date();
|
||||
var firstDay = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
@@ -155,7 +117,7 @@
|
||||
|
||||
// ── load documents ────────────────────────────────────────────────────────
|
||||
function load_gl_tab(doc_type) {
|
||||
var formula_id = is_asset_type(doc_type) ? '0' : $('#gl-formula-' + doc_type).val();
|
||||
var formula_id = $('#gl-formula-' + doc_type).val();
|
||||
if (!formula_id) { bootbox.alert('Please select a GL formula first.'); return; }
|
||||
|
||||
if (formula_id === 'all') {
|
||||
@@ -294,9 +256,7 @@
|
||||
var status_badge = r.gl_status == 1
|
||||
? '<span class="badge bg-success-subtle text-success">Posted</span>'
|
||||
: '<span class="badge bg-danger-subtle text-danger">Unposted</span>';
|
||||
var mapping_badge = is_asset_type(doc_type)
|
||||
? '<span class="badge bg-success-subtle text-success">Asset category</span>'
|
||||
: product_mapping_badge(r);
|
||||
var mapping_badge = product_mapping_badge(r);
|
||||
html +=
|
||||
'<tr>' +
|
||||
'<td>' + escape_html(r.doc_number) + '</td>' +
|
||||
@@ -342,15 +302,11 @@
|
||||
purchase_invoice: 'Purchase Invoice',
|
||||
supplier_credit_note: 'Supplier Credit Note',
|
||||
receipt: 'Receipt',
|
||||
payment: 'Payment',
|
||||
asset_capitalization: '[Asset Mgmt] Capitalization',
|
||||
asset_depreciation: '[Asset Mgmt] Depreciation',
|
||||
asset_amortization: '[Asset Mgmt] Amortization',
|
||||
asset_disposal: '[Asset Mgmt] Disposal'
|
||||
payment: 'Payment'
|
||||
};
|
||||
|
||||
function run_gl_batch(doc_type) {
|
||||
var formula_id = is_asset_type(doc_type) ? '0' : $('#gl-formula-' + doc_type).val();
|
||||
var formula_id = $('#gl-formula-' + doc_type).val();
|
||||
|
||||
if (formula_id === 'all') {
|
||||
run_all_formula_batches(doc_type);
|
||||
@@ -437,7 +393,7 @@
|
||||
|
||||
// ── core batch processor ──────────────────────────────────────────────────
|
||||
function _start_gl_batch(doc_type, ids, onComplete, formula_id_override) {
|
||||
var formula_id = formula_id_override || (is_asset_type(doc_type) ? 0 : $('#gl-formula-' + doc_type).val());
|
||||
var formula_id = formula_id_override || $('#gl-formula-' + doc_type).val();
|
||||
var $msg = $('#gl-msg-' + doc_type);
|
||||
|
||||
set_btn_state('#gl-btn-load-' + doc_type, false);
|
||||
@@ -560,34 +516,10 @@
|
||||
</html>
|
||||
|
||||
<?php
|
||||
function gl_tab_html(string $doc_type, string $label, bool $from_asset = false): string {
|
||||
// Asset Management entries have no GL formula: say where they come from instead.
|
||||
$source_field = $from_asset
|
||||
? <<<HTML
|
||||
<div class="col-auto">
|
||||
<label class="form-label mb-1 small">Source</label>
|
||||
<div><span class="badge bg-success text-white py-2 px-3"><i class="ti ti-building-warehouse me-1"></i>From Asset Management</span></div>
|
||||
</div>
|
||||
HTML
|
||||
: <<<HTML
|
||||
<div class="col-auto">
|
||||
<label class="form-label mb-1 small">GL Formula <span class="text-danger">*</span></label>
|
||||
<select id="gl-formula-{$doc_type}" class="form-select form-select-sm" style="width:220px;">
|
||||
</select>
|
||||
</div>
|
||||
HTML;
|
||||
$source_note = $from_asset
|
||||
? '<div class="alert alert-success small py-2 mb-3"><i class="ti ti-info-circle me-1"></i>'
|
||||
. 'Prepared in <strong>Asset Management</strong>. Accounts come from each asset category; '
|
||||
. 'entries are labelled <strong>[Asset Mgmt]</strong> in the GL Journal.</div>'
|
||||
: '';
|
||||
$accounts_header = $from_asset ? 'Accounts' : 'Product Accounts';
|
||||
|
||||
function gl_tab_html(string $doc_type, string $label): string {
|
||||
return <<<HTML
|
||||
<div class="card p-4">
|
||||
|
||||
{$source_note}
|
||||
|
||||
<!-- Filters + action buttons -->
|
||||
<div class="row g-2 align-items-end mb-3">
|
||||
<div class="col-auto">
|
||||
@@ -600,7 +532,11 @@ HTML;
|
||||
<input type="text" id="gl-date-to-{$doc_type}" class="form-control form-control-sm"
|
||||
placeholder="d/m/Y" style="width:140px;">
|
||||
</div>
|
||||
{$source_field}
|
||||
<div class="col-auto">
|
||||
<label class="form-label mb-1 small">GL Formula <span class="text-danger">*</span></label>
|
||||
<select id="gl-formula-{$doc_type}" class="form-select form-select-sm" style="width:220px;">
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button id="gl-btn-load-{$doc_type}" class="btn btn-secondary btn-sm"
|
||||
onclick="load_gl_tab('{$doc_type}')">
|
||||
@@ -629,7 +565,7 @@ HTML;
|
||||
<th>Contact</th>
|
||||
<th class="text-end">Amount</th>
|
||||
<th>Date</th>
|
||||
<th>{$accounts_header}</th>
|
||||
<th>Product Accounts</th>
|
||||
<th>GL Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -48,12 +48,6 @@
|
||||
<option value="payment">Payment</option>
|
||||
<option value="manual">Manual</option>
|
||||
<option value="reversal">Reversal</option>
|
||||
<optgroup label="Asset Management">
|
||||
<option value="asset_capitalization">[Asset Mgmt] Capitalization</option>
|
||||
<option value="asset_depreciation">[Asset Mgmt] Depreciation</option>
|
||||
<option value="asset_amortization">[Asset Mgmt] Amortization</option>
|
||||
<option value="asset_disposal">[Asset Mgmt] Disposal</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
@@ -194,11 +188,7 @@
|
||||
receipt: 'Receipt',
|
||||
payment: 'Payment',
|
||||
manual: 'Manual',
|
||||
reversal: 'Reversal',
|
||||
asset_capitalization: 'Asset Mgmt · Capitalization',
|
||||
asset_depreciation: 'Asset Mgmt · Depreciation',
|
||||
asset_amortization: 'Asset Mgmt · Amortization',
|
||||
asset_disposal: 'Asset Mgmt · Disposal'
|
||||
reversal: 'Reversal'
|
||||
};
|
||||
|
||||
var SOURCE_BADGE = {
|
||||
@@ -209,11 +199,7 @@
|
||||
receipt: 'bg-info-subtle text-info',
|
||||
payment: 'bg-danger-subtle text-danger',
|
||||
manual: 'bg-dark-subtle text-dark',
|
||||
reversal: 'bg-danger-subtle text-danger',
|
||||
asset_capitalization: 'bg-success text-white',
|
||||
asset_depreciation: 'bg-success text-white',
|
||||
asset_amortization: 'bg-success text-white',
|
||||
asset_disposal: 'bg-success text-white'
|
||||
reversal: 'bg-danger-subtle text-danger'
|
||||
};
|
||||
|
||||
function source_badge(type) {
|
||||
@@ -521,9 +507,9 @@
|
||||
? '<span class="badge bg-success-subtle text-success ms-2">Balanced</span>'
|
||||
: '<span class="badge bg-danger-subtle text-danger ms-2">Unbalanced</span>';
|
||||
|
||||
var extra_fields = (h.source_type === 'manual' || String(h.source_type).indexOf('asset_') === 0)
|
||||
var extra_fields = h.source_type === 'manual'
|
||||
? '<div class="col-sm-4"><div class="text-muted small">Reference</div><div class="fw-semibold">' + escape_html(h.reference || ('MJE-' + h.id)) + '</div></div>' +
|
||||
'<div class="col-sm-4"><div class="text-muted small">Journal Date</div><div>' + escape_html(format_date(h.journal_date)) + '</div></div>' +
|
||||
'<div class="col-sm-4"><div class="text-muted small">Journal Date</div><div>' + escape_html(h.journal_date_fmt || '—') + '</div></div>' +
|
||||
'<div class="col-sm-4"><div class="text-muted small">Description</div><div>' + escape_html(h.description || '—') + '</div></div>'
|
||||
: '<div class="col-sm-4"><div class="text-muted small">Formula</div><div>' + escape_html(h.formula_name) + '</div></div>' +
|
||||
'<div class="col-sm-4"><div class="text-muted small">Version</div><div>v' + h.current_version + (h.current_version > 1 ? ' <span class="text-muted small">(replaced)</span>' : '') + '</div></div>' +
|
||||
|
||||
@@ -139,8 +139,6 @@
|
||||
invoice: 'Invoice', credit_note: 'Credit Note',
|
||||
purchase_invoice: 'Purchase Invoice', supplier_credit_note: 'Supplier Credit Note',
|
||||
receipt: 'Receipt', payment: 'Payment', manual: 'Manual',
|
||||
asset_capitalization: 'Asset Mgmt · Capitalization', asset_depreciation: 'Asset Mgmt · Depreciation',
|
||||
asset_amortization: 'Asset Mgmt · Amortization', asset_disposal: 'Asset Mgmt · Disposal',
|
||||
};
|
||||
|
||||
load_departments();
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_app_access('asset');
|
||||
require_once '../../../assets/utils/classes_as/AssetManager.php';
|
||||
|
||||
$answer['output'] = (new AssetManager($pdo2, $company_id))->getList((int)($data['invoice_id'] ?? 0));
|
||||
$answer['success'] = 1;
|
||||
exit(json_encode($answer));
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_app_access('asset');
|
||||
require_once '../../../assets/utils/classes_as/AssetManager.php';
|
||||
require_once '../../../assets/utils/classes_as/AssetRunManager.php';
|
||||
|
||||
try {
|
||||
$answer['output'] = (new AssetManager($pdo2, $company_id))->getStats();
|
||||
$answer['due'] = (new AssetRunManager($pdo2, $company_id))->dueSummary();
|
||||
$answer['success'] = 1;
|
||||
} catch (PDOException $e) {
|
||||
error_log('[asset stats] ' . $e->getMessage());
|
||||
$answer['message'] = 'Database error, please try again.';
|
||||
http_response_code(500);
|
||||
} catch (Exception $e) {
|
||||
$answer['message'] = $e->getMessage();
|
||||
http_response_code(400);
|
||||
}
|
||||
|
||||
exit(json_encode($answer));
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_app_access('asset');
|
||||
require_once '../../../assets/utils/classes_as/AssetCategoryManager.php';
|
||||
|
||||
$categories = new AssetCategoryManager($pdo2, $company_id);
|
||||
$answer['output'] = $categories->getAll();
|
||||
$answer['stats'] = $categories->getStats();
|
||||
$answer['success'] = 1;
|
||||
exit(json_encode($answer));
|
||||
@@ -1,63 +0,0 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_app_access('asset');
|
||||
require_once '../../../assets/utils/classes/UsageGuard.php';
|
||||
require_once '../../../assets/utils/classes_ac/PostingWindowGuard.php';
|
||||
require_once '../../../assets/utils/classes_ac/GlManager.php';
|
||||
require_once '../../../assets/utils/classes_as/AssetRunManager.php';
|
||||
|
||||
$action = (string)($data['action'] ?? 'list');
|
||||
$class = trim((string)($data['asset_class'] ?? ''));
|
||||
$through = trim((string)($data['through_period'] ?? ''));
|
||||
$run_id = (int)($data['run_id'] ?? 0);
|
||||
|
||||
if (in_array($action, ['run', 'void'], true)) {
|
||||
require_role($user_role, ['owner', 'admin']);
|
||||
}
|
||||
|
||||
try {
|
||||
$runs = new AssetRunManager($pdo2, $company_id);
|
||||
|
||||
switch ($action) {
|
||||
case 'list':
|
||||
$answer['output'] = $runs->getList($class);
|
||||
break;
|
||||
|
||||
case 'preview':
|
||||
$answer['output'] = $runs->preview($class, $through);
|
||||
break;
|
||||
|
||||
case 'detail':
|
||||
$answer['output'] = $runs->getDetail($run_id);
|
||||
break;
|
||||
|
||||
case 'run':
|
||||
$created = dbTransaction($pdo2, fn($pdo) => (new AssetRunManager($pdo, $company_id))->run($class, $through, $user_id));
|
||||
(new UsageGuard($pdo1, $company_id, $packages))->increment();
|
||||
$answer['output'] = $created;
|
||||
$answer['message'] = count($created) . ' run(s) created. Accounting posts them from Batch GL Entries.';
|
||||
break;
|
||||
|
||||
case 'void':
|
||||
dbTransaction($pdo2, function ($pdo) use ($company_id, $run_id, $pdo1) {
|
||||
$gl = new GlManager($pdo, $company_id, new PostingWindowGuard($pdo1, $company_id));
|
||||
(new AssetRunManager($pdo, $company_id))->void($run_id, $gl);
|
||||
});
|
||||
$answer['message'] = 'Run voided.';
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Exception('Invalid action.');
|
||||
}
|
||||
|
||||
$answer['success'] = 1;
|
||||
} catch (PDOException $e) {
|
||||
$answer['message'] = 'Database error, please try again.';
|
||||
http_response_code(500);
|
||||
} catch (Exception $e) {
|
||||
$answer['message'] = $e->getMessage();
|
||||
http_response_code(400);
|
||||
}
|
||||
|
||||
exit(json_encode($answer));
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_app_access('asset');
|
||||
require_once '../../../assets/utils/classes_as/AssetManager.php';
|
||||
|
||||
try {
|
||||
$assets = new AssetManager($pdo2, $company_id);
|
||||
|
||||
if (($data['action'] ?? '') === 'line') {
|
||||
$answer['output'] = $assets->getInvoiceLine((int)($data['invoice_id'] ?? 0), (int)($data['item_id'] ?? 0));
|
||||
} else {
|
||||
$answer['output'] = $assets->getCapitalizableLines(
|
||||
(string)($data['date_from'] ?? ''),
|
||||
(string)($data['date_to'] ?? '')
|
||||
);
|
||||
}
|
||||
$answer['success'] = 1;
|
||||
} catch (PDOException $e) {
|
||||
error_log('[asset invoice lines] ' . $e->getMessage());
|
||||
$answer['message'] = 'Database error, please try again.';
|
||||
http_response_code(500);
|
||||
} catch (Exception $e) {
|
||||
$answer['message'] = $e->getMessage();
|
||||
http_response_code(400);
|
||||
}
|
||||
|
||||
exit(json_encode($answer));
|
||||
@@ -1,70 +0,0 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_app_access('asset');
|
||||
require_once '../../../assets/utils/classes/UsageGuard.php';
|
||||
require_once '../../../assets/utils/classes_ac/PostingWindowGuard.php';
|
||||
require_once '../../../assets/utils/classes_ac/GlManager.php';
|
||||
require_once '../../../assets/utils/classes_as/AssetManager.php';
|
||||
|
||||
$action = (string)($data['action'] ?? '');
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
|
||||
$messages = [
|
||||
'create' => 'Asset saved as a draft.',
|
||||
'update' => 'Asset saved.',
|
||||
'activate' => 'Asset activated. Accounting posts its capitalization from Batch GL Entries.',
|
||||
'delete' => 'Draft asset deleted.',
|
||||
'void' => 'Asset voided.',
|
||||
'dispose' => 'Disposal recorded. Accounting posts it from Batch GL Entries.',
|
||||
'undo_disposal' => 'Disposal undone.',
|
||||
];
|
||||
|
||||
if (!isset($messages[$action])) {
|
||||
$answer['message'] = 'Invalid action.';
|
||||
http_response_code(400);
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
// Staff may prepare assets; everything that changes the books is owner/admin.
|
||||
require_role($user_role, in_array($action, ['create', 'update'], true) ? ['owner', 'admin', 'staff'] : ['owner', 'admin']);
|
||||
|
||||
if ($action !== 'create' && $id <= 0) {
|
||||
$answer['message'] = 'Missing asset id.';
|
||||
http_response_code(400);
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
try {
|
||||
$result_id = dbTransaction($pdo2, function ($pdo) use ($action, $id, $data, $company_id, $user_id, $pdo1) {
|
||||
$assets = new AssetManager($pdo, $company_id);
|
||||
$gl = new GlManager($pdo, $company_id, new PostingWindowGuard($pdo1, $company_id));
|
||||
|
||||
switch ($action) {
|
||||
case 'create':
|
||||
case 'update': return $assets->save($data, $user_id);
|
||||
case 'activate': $assets->activate($id); break;
|
||||
case 'delete': $assets->delete($id); break;
|
||||
case 'void': $assets->void($id, $gl); break;
|
||||
case 'dispose': $assets->dispose($id, $data); break;
|
||||
case 'undo_disposal': $assets->undoDisposal($id, $gl); break;
|
||||
}
|
||||
return $id;
|
||||
});
|
||||
|
||||
if ($action === 'create') {
|
||||
(new UsageGuard($pdo1, $company_id, $packages))->increment();
|
||||
}
|
||||
|
||||
$answer['success'] = 1;
|
||||
$answer['output'] = ['id' => $result_id];
|
||||
$answer['message'] = $messages[$action];
|
||||
} catch (PDOException $e) {
|
||||
$answer['message'] = 'Database error, please try again.';
|
||||
http_response_code(500);
|
||||
} catch (Exception $e) {
|
||||
$answer['message'] = $e->getMessage();
|
||||
http_response_code(400);
|
||||
}
|
||||
|
||||
exit(json_encode($answer));
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_app_access('asset');
|
||||
require_role($user_role, ['owner', 'admin']);
|
||||
require_once '../../../assets/utils/classes_as/AssetCategoryManager.php';
|
||||
|
||||
try {
|
||||
$id = (new AssetCategoryManager($pdo2, $company_id))->save($data);
|
||||
$answer['success'] = 1;
|
||||
$answer['output'] = ['id' => $id];
|
||||
$answer['message'] = !empty($data['id']) ? 'Category updated.' : 'Category created.';
|
||||
} catch (PDOException $e) {
|
||||
error_log('[asset category] ' . $e->getMessage());
|
||||
$answer['message'] = 'Database error, please try again.';
|
||||
http_response_code(500);
|
||||
} catch (Exception $e) {
|
||||
$answer['message'] = $e->getMessage();
|
||||
http_response_code(400);
|
||||
}
|
||||
|
||||
exit(json_encode($answer));
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_app_access('asset');
|
||||
require_role($user_role, ['owner', 'admin']);
|
||||
require_once '../../../assets/utils/classes_as/AssetCategoryManager.php';
|
||||
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
if (!$id) { $answer['message'] = 'Missing id'; exit(json_encode($answer)); }
|
||||
|
||||
(new AssetCategoryManager($pdo2, $company_id))->deactivate($id);
|
||||
|
||||
$answer['success'] = 1;
|
||||
$answer['message'] = 'Category deactivated.';
|
||||
exit(json_encode($answer));
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_app_access('asset');
|
||||
require_once '../../../assets/utils/classes_as/AssetManager.php';
|
||||
|
||||
try {
|
||||
$answer['output'] = (new AssetManager($pdo2, $company_id))->getDetail((int)($data['id'] ?? 0));
|
||||
$answer['success'] = 1;
|
||||
} catch (PDOException $e) {
|
||||
error_log('[asset retrieve] ' . $e->getMessage());
|
||||
$answer['message'] = 'Database error, please try again.';
|
||||
http_response_code(500);
|
||||
} catch (Exception $e) {
|
||||
$answer['message'] = $e->getMessage();
|
||||
http_response_code(400);
|
||||
}
|
||||
|
||||
exit(json_encode($answer));
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_app_access('asset');
|
||||
require_once '../../../assets/utils/classes_as/AssetCategoryManager.php';
|
||||
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
if (!$id) { $answer['message'] = 'Missing id'; exit(json_encode($answer)); }
|
||||
|
||||
$row = (new AssetCategoryManager($pdo2, $company_id))->getById($id);
|
||||
if (!$row) { $answer['message'] = 'Category not found'; exit(json_encode($answer)); }
|
||||
|
||||
$answer['output'] = $row;
|
||||
$answer['success'] = 1;
|
||||
exit(json_encode($answer));
|
||||
@@ -1,158 +0,0 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../config.php';
|
||||
require '../include_header.php';
|
||||
?>
|
||||
|
||||
<body>
|
||||
<?php require '../include_topbar.php'; ?>
|
||||
<?php require '../include_sidebar_asset.php'; ?>
|
||||
|
||||
<main id="content" class="content py-15">
|
||||
<div class="container-fluid">
|
||||
|
||||
<!-- Page header -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-5 gap-3">
|
||||
<div>
|
||||
<h1 class="fs-3 mb-1">Asset Categories</h1>
|
||||
<p class="mb-0">Useful life and GL accounts used by each kind of asset</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="<?php echo $server_url?>asset/manage_category.php" class="btn btn-primary" data-min-role="admin">
|
||||
<i class="ti ti-plus me-1"></i>Add Category
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card overflow-hidden">
|
||||
<div class="card-body p-6">
|
||||
|
||||
<div class="mb-5 d-flex justify-content-between align-items-center gap-3">
|
||||
<span class="text-muted small" id="category_summary"></span>
|
||||
<div class="position-relative" style="min-width:240px;">
|
||||
<input type="search" id="search_category" class="form-control ps-9" placeholder="Search code or name…">
|
||||
<span class="position-absolute top-25 ms-4">
|
||||
<i class="ti ti-search text-muted"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0 text-nowrap table-hover table-centered" id="category_table">
|
||||
<thead class="table-primary border-light">
|
||||
<tr>
|
||||
<th>Code</th>
|
||||
<th>Name</th>
|
||||
<th>Class</th>
|
||||
<th class="text-end">Life (months)</th>
|
||||
<th>Cost / Accumulated / Expense</th>
|
||||
<th class="text-end">Assets</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
<tfoot></tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php require '../include_ending.php'; ?>
|
||||
<script src="<?php echo $server_url?>assets/js/asset.js"></script>
|
||||
|
||||
<script>
|
||||
var search_term = '';
|
||||
|
||||
function retrieve_categories() {
|
||||
return ajax_request({
|
||||
url: server_url + 'asset/api/engine/category.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
onSuccess: function(res) {
|
||||
alasql('CREATE TABLE IF NOT EXISTS md_asset_category');
|
||||
alasql.tables.md_asset_category.data = res.output || [];
|
||||
var s = res.stats || {};
|
||||
$('#category_summary').text((s.total || 0) + ' categories · ' + (s.active || 0) + ' active');
|
||||
change_page(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function change_page(page_num) {
|
||||
var offset = (page_num - 1) * prop_limit;
|
||||
var kw = search_term.toLowerCase();
|
||||
var rows = alasql('SELECT * FROM md_asset_category ORDER BY category_code').filter(function(r) {
|
||||
return !kw || String(r.category_code).toLowerCase().indexOf(kw) !== -1 || String(r.category_name).toLowerCase().indexOf(kw) !== -1;
|
||||
});
|
||||
|
||||
$('table#category_table tfoot').html(generate_pagination('category_table', rows.length));
|
||||
|
||||
if (!rows.length) {
|
||||
$('table#category_table tbody').html('<tr><td colspan="8" class="text-center py-5 text-muted">No categories yet. Add one before creating assets.</td></tr>');
|
||||
return;
|
||||
}
|
||||
|
||||
var body = '';
|
||||
rows.slice(offset, offset + parseInt(prop_limit, 10)).forEach(function(item) {
|
||||
body += `<tr>
|
||||
<td class="py-3 fw-semibold">${escape_html(item.category_code)}</td>
|
||||
<td class="py-3">${escape_html(item.category_name)}</td>
|
||||
<td class="py-3">${asset_class_badge(item.asset_class)}</td>
|
||||
<td class="py-3 text-end">${item.useful_life_months}</td>
|
||||
<td class="py-3 small text-muted">${escape_html(item.cost_account_code)} / ${escape_html(item.accum_account_code)} / ${escape_html(item.expense_account_code)}</td>
|
||||
<td class="py-3 text-end">${item.asset_count}</td>
|
||||
<td class="py-3">${String(item.status) === '1' ? '<span class="badge bg-success">Active</span>' : '<span class="badge bg-secondary">Inactive</span>'}</td>
|
||||
<td class="py-3">
|
||||
<a href="${server_url}asset/manage_category.php?id=${item.id}"><i class="ti ti-eye fs-5"></i></a>
|
||||
${String(item.status) === '1' ? `<a href="javascript:;" class="link-danger ms-2" data-min-role="admin" onclick="remove_category(${item.id})"><i class="ti ti-ban fs-5"></i></a>` : ''}
|
||||
</td>
|
||||
</tr>`;
|
||||
});
|
||||
$('table#category_table tbody').html(body);
|
||||
}
|
||||
|
||||
function remove_category(id) {
|
||||
bootbox.confirm({
|
||||
message: 'Deactivate this category? Existing assets keep using its accounts.',
|
||||
buttons: { confirm: { label: 'Deactivate', className: 'btn-danger' }, cancel: { label: 'Back', className: 'btn-secondary' } },
|
||||
callback: function(ok) {
|
||||
if (!ok) return;
|
||||
ajax_request({
|
||||
url: server_url + 'asset/api/engine/remove_category.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'delete',
|
||||
data: { id: id },
|
||||
onSuccess: function() { retrieve_categories(); }
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$('#search_category').on('input', debounce(function() {
|
||||
search_term = $(this).val().trim();
|
||||
change_page(1);
|
||||
}, 200));
|
||||
|
||||
$(async function() {
|
||||
try { await retrieve_categories(); } catch (e) { console.log(e); }
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,317 +0,0 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../config.php';
|
||||
require '../include_header.php';
|
||||
?>
|
||||
|
||||
<body>
|
||||
<?php require '../include_topbar.php'; ?>
|
||||
<?php require '../include_sidebar_asset.php'; ?>
|
||||
|
||||
<main id="content" class="content py-15">
|
||||
<div class="container-fluid">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="mb-5">
|
||||
<h1 class="fs-3 mb-1">Depreciation & Amortization Runs</h1>
|
||||
<p class="mb-0">Charge each month's straight-line depreciation (tangible) or amortization (intangible)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New run -->
|
||||
<div class="card mb-5" data-min-role="admin">
|
||||
<div class="card-body p-5">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label mb-1">Type</label>
|
||||
<select id="asset_class" class="form-select">
|
||||
<option value="tangible">Depreciation — tangible assets</option>
|
||||
<option value="intangible">Amortization — intangible assets</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label mb-1">Run Through Month</label>
|
||||
<input type="text" id="through_period" class="form-control" placeholder="YYYY-MM">
|
||||
</div>
|
||||
<div class="col-md-5 d-flex gap-2">
|
||||
<button class="btn btn-secondary" onclick="preview_run()"><i class="ti ti-eye me-1"></i>Preview</button>
|
||||
<button class="btn btn-primary" id="btn_run" onclick="create_run()" disabled><i class="ti ti-player-play me-1"></i>Create Runs</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted small mt-3 mb-0">
|
||||
One run is created per month still due, oldest first. The in-service month counts as a full month and the
|
||||
disposal month is not charged. Each run becomes one GL entry that Accounting posts from
|
||||
<strong>Batch GL Entries</strong>, labelled <strong>[Asset Mgmt]</strong>.
|
||||
</p>
|
||||
<div id="preview_area" class="mt-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Runs -->
|
||||
<div class="card">
|
||||
<div class="card-body p-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 gap-3">
|
||||
<h2 class="fs-5 mb-0">Runs</h2>
|
||||
<select id="filter_class" class="form-select form-select-sm" style="width:220px;">
|
||||
<option value="">All types</option>
|
||||
<option value="tangible">Depreciation</option>
|
||||
<option value="intangible">Amortization</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 table-centered text-nowrap" id="run_table">
|
||||
<thead class="table-primary border-light">
|
||||
<tr>
|
||||
<th>Run No.</th>
|
||||
<th>Type</th>
|
||||
<th>Month</th>
|
||||
<th>Run Date</th>
|
||||
<th class="text-end">Assets</th>
|
||||
<th class="text-end">Amount</th>
|
||||
<th>GL</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td colspan="9" class="text-center text-muted py-5">Loading...</td></tr>
|
||||
</tbody>
|
||||
<tfoot></tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Run detail -->
|
||||
<div class="modal fade" id="run_detail_modal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="run_detail_title">Run</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="run_detail_body"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php require '../include_ending.php'; ?>
|
||||
<script src="<?php echo $server_url?>assets/js/asset.js"></script>
|
||||
|
||||
<script>
|
||||
var can_admin = ['owner', 'admin'].includes(user_role);
|
||||
var all_runs = [];
|
||||
var last_preview = null;
|
||||
|
||||
function run_type_label(asset_class) {
|
||||
return asset_class === 'intangible' ? 'Amortization' : 'Depreciation';
|
||||
}
|
||||
|
||||
function preview_run() {
|
||||
last_preview = null;
|
||||
set_btn_state('#btn_run', false);
|
||||
return ajax_request({
|
||||
url: server_url + 'asset/api/engine/depreciation_run.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'preview',
|
||||
data: { asset_class: $('#asset_class').val(), through_period: $('#through_period').val() },
|
||||
onSuccess: function(res) {
|
||||
var runs = res.output || [];
|
||||
last_preview = { asset_class: $('#asset_class').val(), through_period: $('#through_period').val(), runs: runs };
|
||||
render_preview(runs);
|
||||
set_btn_state('#btn_run', runs.length > 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function render_preview(runs) {
|
||||
if (!runs.length) {
|
||||
$('#preview_area').html('<div class="alert alert-success small mb-0"><i class="ti ti-circle-check me-1"></i>Nothing is due through this month.</div>');
|
||||
return;
|
||||
}
|
||||
var total = 0;
|
||||
var html = '<div class="table-responsive"><table class="table table-sm mb-0 table-centered">'
|
||||
+ '<thead class="table-light"><tr><th>Month</th><th>Run Date</th><th class="text-end">Assets</th><th class="text-end">Amount</th><th></th></tr></thead><tbody>';
|
||||
runs.forEach(function(r, idx) {
|
||||
total += parseFloat(r.total) || 0;
|
||||
html += '<tr>'
|
||||
+ '<td class="fw-semibold">' + escape_html(r.period) + '</td>'
|
||||
+ '<td>' + format_date(r.run_date) + '</td>'
|
||||
+ '<td class="text-end">' + r.asset_count + '</td>'
|
||||
+ '<td class="text-end">' + format_number(r.total, 2) + '</td>'
|
||||
+ '<td class="text-end"><a href="javascript:;" class="small" onclick="$(\'#preview_items_' + idx + '\').toggleClass(\'d-none\')">Assets</a></td>'
|
||||
+ '</tr>'
|
||||
+ '<tr id="preview_items_' + idx + '" class="d-none"><td colspan="5" class="bg-light">'
|
||||
+ items_table(r.items, false)
|
||||
+ '</td></tr>';
|
||||
});
|
||||
html += '</tbody><tfoot><tr class="fw-semibold"><td colspan="3">Total</td><td class="text-end">' + format_number(total, 2) + '</td><td></td></tr></tfoot></table></div>';
|
||||
$('#preview_area').html(html);
|
||||
}
|
||||
|
||||
function items_table(items, with_dept) {
|
||||
var html = '<table class="table table-sm mb-0"><thead><tr><th>Asset</th><th>Category</th>'
|
||||
+ (with_dept ? '<th>Dept</th>' : '')
|
||||
+ '<th class="text-end">Amount</th><th class="text-end">Accumulated</th><th class="text-end">Book Value</th></tr></thead><tbody>';
|
||||
(items || []).forEach(function(i) {
|
||||
html += '<tr>'
|
||||
+ '<td>' + escape_html(i.asset_number) + ' <span class="text-muted">' + escape_html(i.asset_name) + '</span></td>'
|
||||
+ '<td>' + escape_html((i.category_code ? i.category_code + ' ' : '') + (i.category_name || '')) + '</td>'
|
||||
+ (with_dept ? '<td>' + escape_html(i.dept_code || '—') + '</td>' : '')
|
||||
+ '<td class="text-end">' + format_number(i.amount, 2) + '</td>'
|
||||
+ '<td class="text-end">' + format_number(i.accumulated_after, 2) + '</td>'
|
||||
+ '<td class="text-end">' + format_number(i.book_value_after, 2) + '</td>'
|
||||
+ '</tr>';
|
||||
});
|
||||
return html + '</tbody></table>';
|
||||
}
|
||||
|
||||
function create_run() {
|
||||
if (!last_preview || !last_preview.runs.length) return;
|
||||
var total = last_preview.runs.reduce(function(s, r) { return s + (parseFloat(r.total) || 0); }, 0);
|
||||
bootbox.confirm({
|
||||
message: 'Create <strong>' + last_preview.runs.length + '</strong> ' + run_type_label(last_preview.asset_class).toLowerCase()
|
||||
+ ' run(s) through ' + escape_html(last_preview.through_period) + ', totalling <strong>' + format_number(total, 2) + '</strong>?',
|
||||
buttons: { confirm: { label: 'Create Runs', className: 'btn-primary' }, cancel: { label: 'Back', className: 'btn-secondary' } },
|
||||
callback: function(ok) {
|
||||
if (!ok) return;
|
||||
ajax_request({
|
||||
url: server_url + 'asset/api/engine/depreciation_run.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'run',
|
||||
data: { asset_class: last_preview.asset_class, through_period: last_preview.through_period },
|
||||
onSuccess: function(res) {
|
||||
toastr.success(res.message || 'Runs created.');
|
||||
$('#preview_area').empty();
|
||||
set_btn_state('#btn_run', false);
|
||||
last_preview = null;
|
||||
load_runs();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function load_runs() {
|
||||
return ajax_request({
|
||||
url: server_url + 'asset/api/engine/depreciation_run.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'list',
|
||||
data: { asset_class: '' },
|
||||
onSuccess: function(res) {
|
||||
all_runs = res.output || [];
|
||||
change_page(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function change_page(page_num) {
|
||||
var filter = $('#filter_class').val();
|
||||
var rows = all_runs.filter(function(r) { return !filter || r.asset_class === filter; });
|
||||
var limit = parseInt(prop_limit, 10);
|
||||
var offset = (page_num - 1) * limit;
|
||||
|
||||
$('#run_table tfoot').html(generate_pagination('run_table', rows.length));
|
||||
if (!rows.length) {
|
||||
$('#run_table tbody').html('<tr><td colspan="9" class="text-center text-muted py-5">No runs yet.</td></tr>');
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '';
|
||||
rows.slice(offset, offset + limit).forEach(function(r) {
|
||||
var active = String(r.status) === '1';
|
||||
html += '<tr class="' + (active ? '' : 'text-muted') + '">'
|
||||
+ '<td class="fw-semibold">' + escape_html(r.run_number) + '</td>'
|
||||
+ '<td>' + run_type_label(r.asset_class) + '</td>'
|
||||
+ '<td>' + escape_html(r.period) + '</td>'
|
||||
+ '<td>' + format_date(r.run_date) + '</td>'
|
||||
+ '<td class="text-end">' + r.asset_count + '</td>'
|
||||
+ '<td class="text-end">' + format_number(r.total_amount, 2) + '</td>'
|
||||
+ '<td>' + (active ? asset_gl_badge(r.gl_status) : '—') + '</td>'
|
||||
+ '<td>' + (active ? '<span class="badge bg-success">Active</span>' : '<span class="badge bg-light text-dark">Void</span>') + '</td>'
|
||||
+ '<td class="text-end">'
|
||||
+ (active ? '<a href="javascript:;" onclick="show_run(' + r.id + ')"><i class="ti ti-eye fs-5"></i></a>' : '')
|
||||
+ (active && can_admin ? '<a href="javascript:;" class="link-danger ms-2" title="Void run" onclick="void_run(' + r.id + ', \'' + escape_html(r.run_number) + '\', ' + r.gl_status + ')"><i class="ti ti-ban fs-5"></i></a>' : '')
|
||||
+ '</td></tr>';
|
||||
});
|
||||
$('#run_table tbody').html(html);
|
||||
}
|
||||
|
||||
function show_run(run_id) {
|
||||
$('#run_detail_body').html('<div class="text-center text-muted py-5">Loading...</div>');
|
||||
$('#run_detail_modal').modal('show');
|
||||
ajax_request({
|
||||
url: server_url + 'asset/api/engine/depreciation_run.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'detail',
|
||||
data: { run_id: run_id },
|
||||
onSuccess: function(res) {
|
||||
var run = res.output.run;
|
||||
$('#run_detail_title').text(run.run_number + ' · ' + run_type_label(run.asset_class) + ' ' + run.period);
|
||||
$('#run_detail_body').html(
|
||||
'<div class="small text-muted mb-3">Run date ' + format_date(run.run_date) + ' · '
|
||||
+ run.asset_count + ' assets · total ' + format_number(run.total_amount, 2) + '</div>'
|
||||
+ items_table(res.output.items, true)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function void_run(run_id, run_number, gl_status) {
|
||||
var note = String(gl_status) === '1'
|
||||
? '<br><span class="text-danger">Its GL entry is posted, so a reversal entry will be created.</span>'
|
||||
: '';
|
||||
bootbox.confirm({
|
||||
message: '<strong>Void run ' + run_number + '?</strong><br>Its charges are removed from the assets.' + note,
|
||||
buttons: { confirm: { label: 'Void', className: 'btn-danger' }, cancel: { label: 'Back', className: 'btn-secondary' } },
|
||||
callback: function(ok) {
|
||||
if (!ok) return;
|
||||
ajax_request({
|
||||
url: server_url + 'asset/api/engine/depreciation_run.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'void',
|
||||
data: { run_id: run_id },
|
||||
onSuccess: function(res) {
|
||||
toastr.success(res.message || 'Run voided.');
|
||||
load_runs();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$('#filter_class').on('change', function() { change_page(1); });
|
||||
$('#asset_class, #through_period').on('change', function() {
|
||||
last_preview = null;
|
||||
set_btn_state('#btn_run', false);
|
||||
$('#preview_area').empty();
|
||||
});
|
||||
|
||||
$(async function() {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
if (params.get('class') === 'intangible' || params.get('class') === 'tangible') {
|
||||
$('#asset_class').val(params.get('class'));
|
||||
}
|
||||
flatpickr('#through_period', {
|
||||
plugins: [new monthSelectPlugin({ shorthand: true, dateFormat: 'Y-m', altFormat: 'M Y' })],
|
||||
defaultDate: current_period() + '-01',
|
||||
maxDate: new Date()
|
||||
});
|
||||
$('#through_period').val(current_period());
|
||||
|
||||
try { await load_runs(); } catch (e) { console.log(e); }
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,174 +0,0 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../config.php';
|
||||
require '../include_header.php';
|
||||
?>
|
||||
|
||||
<body>
|
||||
<?php require '../include_topbar.php'; ?>
|
||||
<?php require '../include_sidebar_asset.php'; ?>
|
||||
|
||||
<main id="content" class="content py-15">
|
||||
<div class="container-fluid">
|
||||
|
||||
<!-- Page header -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-5 gap-3">
|
||||
<div>
|
||||
<h1 class="fs-3 mb-1">Asset Register</h1>
|
||||
<p class="mb-0">Every asset with its cost, accumulated depreciation and book value</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="<?php echo $server_url?>asset/purchase_invoices.php" class="btn btn-outline-primary">
|
||||
<i class="ti ti-file-invoice me-1"></i>From Purchase Invoice
|
||||
</a>
|
||||
<a href="<?php echo $server_url?>asset/manage_asset.php" class="btn btn-primary">
|
||||
<i class="ti ti-plus me-1"></i>New Asset
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card overflow-hidden">
|
||||
<div class="card-body p-6">
|
||||
|
||||
<div class="mb-5 d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3">
|
||||
<ul class="nav nav-pills small" id="status_filter">
|
||||
<li class="nav-item"><a class="nav-link active" href="javascript:;" data-status="">All <span class="count"></span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="javascript:;" data-status="0">Draft <span class="count"></span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="javascript:;" data-status="1">Active <span class="count"></span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="javascript:;" data-status="2">Fully depreciated <span class="count"></span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="javascript:;" data-status="3">Disposed <span class="count"></span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="javascript:;" data-status="4">Void <span class="count"></span></a></li>
|
||||
</ul>
|
||||
<div class="position-relative" style="min-width:240px;">
|
||||
<input type="search" id="search_asset" class="form-control ps-9" placeholder="Search number, name, category…">
|
||||
<span class="position-absolute top-25 ms-4">
|
||||
<i class="ti ti-search text-muted"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0 text-nowrap table-hover table-centered" id="asset_table">
|
||||
<thead class="table-primary border-light">
|
||||
<tr>
|
||||
<th>Asset No.</th>
|
||||
<th>Name</th>
|
||||
<th>Category</th>
|
||||
<th>In Service</th>
|
||||
<th class="text-end">Cost</th>
|
||||
<th class="text-end">Accumulated</th>
|
||||
<th class="text-end">Book Value</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
<tfoot></tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php require '../include_ending.php'; ?>
|
||||
<script src="<?php echo $server_url?>assets/js/asset.js"></script>
|
||||
|
||||
<script>
|
||||
var all_assets = [];
|
||||
var search_term = '';
|
||||
var status_filter = new URLSearchParams(window.location.search).get('status') || '';
|
||||
|
||||
function retrieve_assets() {
|
||||
return ajax_request({
|
||||
url: server_url + 'asset/api/engine/asset.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
onSuccess: function(res) {
|
||||
all_assets = res.output || [];
|
||||
update_counts();
|
||||
change_page(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function update_counts() {
|
||||
$('#status_filter a').each(function() {
|
||||
var s = String($(this).data('status'));
|
||||
var n = s === '' ? all_assets.length : all_assets.filter(function(a) { return String(a.status) === s; }).length;
|
||||
$(this).find('.count').text('(' + n + ')');
|
||||
$(this).toggleClass('active', s === status_filter);
|
||||
});
|
||||
}
|
||||
|
||||
function filtered_assets() {
|
||||
var kw = search_term.toLowerCase();
|
||||
return all_assets.filter(function(a) {
|
||||
if (status_filter !== '' && String(a.status) !== status_filter) return false;
|
||||
if (!kw) return true;
|
||||
return [a.asset_number, a.asset_name, a.category_code, a.category_name, a.invoice_number]
|
||||
.some(function(v) { return String(v || '').toLowerCase().indexOf(kw) !== -1; });
|
||||
});
|
||||
}
|
||||
|
||||
function change_page(page_num) {
|
||||
var rows = filtered_assets();
|
||||
var limit = parseInt(prop_limit, 10);
|
||||
var offset = (page_num - 1) * limit;
|
||||
|
||||
$('table#asset_table tfoot').html(generate_pagination('asset_table', rows.length));
|
||||
|
||||
if (!rows.length) {
|
||||
$('table#asset_table tbody').html('<tr><td colspan="9" class="text-center py-5 text-muted">No assets found.</td></tr>');
|
||||
return;
|
||||
}
|
||||
|
||||
var body = '';
|
||||
rows.slice(offset, offset + limit).forEach(function(a) {
|
||||
var source = a.invoice_number
|
||||
? '<div class="small text-muted"><i class="ti ti-file-invoice me-1"></i>' + escape_html(a.invoice_number) + '</div>'
|
||||
: '';
|
||||
body += `<tr>
|
||||
<td class="py-3 fw-semibold">${escape_html(a.asset_number)}</td>
|
||||
<td class="py-3">${escape_html(a.asset_name)}${source}</td>
|
||||
<td class="py-3">${escape_html(a.category_code)} <span class="text-muted">${escape_html(a.category_name)}</span></td>
|
||||
<td class="py-3">${format_date(a.in_service_date)}</td>
|
||||
<td class="py-3 text-end">${format_number(a.cost, 2)}</td>
|
||||
<td class="py-3 text-end">${format_number(a.accumulated, 2)}</td>
|
||||
<td class="py-3 text-end">${format_number(a.book_value, 2)}</td>
|
||||
<td class="py-3">${asset_status_badge(a.status, a.asset_class)}</td>
|
||||
<td class="py-3"><a href="${server_url}asset/manage_asset.php?id=${a.id}"><i class="ti ti-eye fs-5"></i></a></td>
|
||||
</tr>`;
|
||||
});
|
||||
$('table#asset_table tbody').html(body);
|
||||
}
|
||||
|
||||
$('#status_filter').on('click', 'a', function() {
|
||||
status_filter = String($(this).data('status'));
|
||||
update_counts();
|
||||
change_page(1);
|
||||
});
|
||||
|
||||
$('#search_asset').on('input', debounce(function() {
|
||||
search_term = $(this).val().trim();
|
||||
change_page(1);
|
||||
}, 200));
|
||||
|
||||
$(async function() {
|
||||
try { await retrieve_assets(); } catch (e) { console.log(e); }
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,595 +0,0 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../config.php';
|
||||
require '../include_header.php';
|
||||
|
||||
$asset_id = (int)($_GET['id'] ?? 0);
|
||||
$invoice_id = (int)($_GET['invoice_id'] ?? 0);
|
||||
$item_id = (int)($_GET['item_id'] ?? 0);
|
||||
$pi_link_ok = in_array($_SESSION['login_app_access'] ?? 'wms', ['all', 'accounting'], true);
|
||||
?>
|
||||
|
||||
<body>
|
||||
<?php require '../include_topbar.php'; ?>
|
||||
<?php require '../include_sidebar_asset.php'; ?>
|
||||
|
||||
<main id="content" class="content py-15">
|
||||
<div class="container-fluid">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="mb-6 d-flex justify-content-between align-items-center gap-3">
|
||||
<div>
|
||||
<h1 class="fs-3 mb-1" id="page_title">New Asset</h1>
|
||||
<p class="mb-0 text-muted" id="page_subtitle">Straight-line depreciation with your own salvage value</p>
|
||||
</div>
|
||||
<a href="<?php echo $server_url?>asset/index.php" class="btn btn-light">
|
||||
<i class="ti ti-arrow-left me-1"></i>Back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-5">
|
||||
|
||||
<!-- Left: form + schedule -->
|
||||
<div class="col-lg-8">
|
||||
|
||||
<div class="alert alert-info small d-none" id="source_alert"></div>
|
||||
|
||||
<div class="card mb-5" id="assetForm">
|
||||
<div class="card-body p-5">
|
||||
<h2 class="fs-5 mb-4">Asset Details</h2>
|
||||
<input type="hidden" id="id" value="<?php echo $asset_id ?: ''; ?>">
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">Asset Name <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="asset_name" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Department</label>
|
||||
<select class="form-select" id="department_id"></select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Category <span class="text-danger">*</span></label>
|
||||
<select class="form-select" id="category_id" required></select>
|
||||
<div class="form-text" id="class_hint"></div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Clearing Account</label>
|
||||
<select class="form-select" id="clearing_account_code"></select>
|
||||
<div class="form-text">Credited when the asset is capitalized — the account the purchase was posted to. Leave empty if the asset is already on the books.</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Acquisition Date <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="acquisition_date" placeholder="DD/MM/YYYY" required>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">In-Service Date <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="in_service_date" placeholder="DD/MM/YYYY" required>
|
||||
</div>
|
||||
<div class="col-md-6"></div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Cost <span class="text-danger">*</span></label>
|
||||
<input type="number" class="form-control text-end" id="cost" min="0" step="0.01" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Salvage Value</label>
|
||||
<input type="number" class="form-control text-end" id="salvage_value" min="0" step="0.01" value="0">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Useful Life (months) <span class="text-danger">*</span></label>
|
||||
<input type="number" class="form-control text-end" id="useful_life_months" min="1" max="1200" step="1" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="bg-light rounded-2 px-4 py-3 d-flex flex-wrap gap-4 small">
|
||||
<div><span class="text-muted">Amount to <span class="label-charge">depreciate</span>:</span> <strong id="calc_base">—</strong></div>
|
||||
<div><span class="text-muted">Per month:</span> <strong id="calc_monthly">—</strong></div>
|
||||
<div><span class="text-muted">Last month:</span> <strong id="calc_last">—</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control" id="description" rows="2" placeholder="Serial number, location, notes"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text mt-3 d-none" id="locked_hint">
|
||||
<i class="ti ti-lock me-1"></i>Cost, salvage value, life, dates and category are locked because months have already been charged.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card d-none" id="schedule_card">
|
||||
<div class="card-body p-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="fs-5 mb-0"><span class="label-schedule">Depreciation</span> Schedule</h2>
|
||||
<span class="small text-muted" id="schedule_summary"></span>
|
||||
</div>
|
||||
<div class="table-responsive" style="max-height:480px;">
|
||||
<table class="table table-sm mb-0 table-centered">
|
||||
<thead class="table-light" style="position:sticky; top:0;">
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Month</th>
|
||||
<th class="text-end">Amount</th>
|
||||
<th class="text-end">Accumulated</th>
|
||||
<th class="text-end">Book Value</th>
|
||||
<th>State</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="schedule_tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: status + actions -->
|
||||
<div class="col-lg-4">
|
||||
|
||||
<div class="card mb-5 d-none" id="status_card">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="fs-5 mb-3">Status</h2>
|
||||
<div class="mb-3 d-flex gap-2 flex-wrap" id="status_badges"></div>
|
||||
<div class="small mb-2"><span class="text-muted">Accumulated:</span> <span class="fw-semibold ms-1" id="display_accumulated">—</span></div>
|
||||
<div class="small mb-2"><span class="text-muted">Book value:</span> <span class="fw-semibold ms-1" id="display_book_value">—</span></div>
|
||||
<div class="small mb-2"><span class="text-muted">Last month charged:</span> <span class="ms-1" id="display_last_period">—</span></div>
|
||||
<div class="small mb-2 d-none" id="source_row"><span class="text-muted">Source:</span> <span class="ms-1" id="display_source"></span></div>
|
||||
|
||||
<hr>
|
||||
<h3 class="fs-6 mb-2">GL Entries <span class="badge bg-success text-white ms-1">[Asset Mgmt]</span></h3>
|
||||
<div class="small mb-2"><div class="text-muted">Capitalization</div><div id="gl_capitalization"></div></div>
|
||||
<div class="small mb-2 d-none" id="gl_disposal_row"><div class="text-muted">Disposal</div><div id="gl_disposal"></div></div>
|
||||
|
||||
<div class="d-none" id="disposal_info">
|
||||
<hr>
|
||||
<h3 class="fs-6 mb-2">Disposal</h3>
|
||||
<div class="small mb-1"><span class="text-muted">Date:</span> <span class="ms-1" id="display_disposal_date"></span></div>
|
||||
<div class="small mb-1"><span class="text-muted">Sale price:</span> <span class="ms-1" id="display_sale_price"></span></div>
|
||||
<div class="small mb-1"><span class="text-muted">Gain / (loss):</span> <span class="ms-1 fw-semibold" id="display_gain_loss"></span></div>
|
||||
<div class="small mb-1 text-muted" id="display_disposal_notes"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-4 d-flex flex-column gap-2">
|
||||
<button class="btn btn-primary w-100" id="btn_save" onclick="save_asset()">
|
||||
<i class="ti ti-device-floppy me-1"></i><span>Save as Draft</span>
|
||||
</button>
|
||||
<button class="btn btn-success w-100 d-none" id="btn_activate" onclick="asset_action('activate')">
|
||||
<i class="ti ti-circle-check me-1"></i>Activate
|
||||
</button>
|
||||
<button class="btn btn-outline-dark w-100 d-none" id="btn_dispose" onclick="open_disposal()">
|
||||
<i class="ti ti-receipt-refund me-1"></i>Sell / Dispose
|
||||
</button>
|
||||
<button class="btn btn-outline-warning w-100 d-none" id="btn_undo_disposal" onclick="asset_action('undo_disposal')">
|
||||
<i class="ti ti-arrow-back-up me-1"></i>Undo Disposal
|
||||
</button>
|
||||
<button class="btn btn-outline-danger w-100 d-none" id="btn_void" onclick="asset_action('void')">
|
||||
<i class="ti ti-ban me-1"></i>Void
|
||||
</button>
|
||||
<button class="btn btn-outline-danger w-100 d-none" id="btn_delete" onclick="asset_action('delete')">
|
||||
<i class="ti ti-trash me-1"></i>Delete Draft
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Disposal modal -->
|
||||
<div class="modal fade" id="disposal_modal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Sell / Dispose of Asset</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Disposal Date</label>
|
||||
<input type="text" class="form-control" id="disposal_date" placeholder="DD/MM/YYYY">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Sale Price</label>
|
||||
<input type="number" class="form-control text-end" id="sale_price" min="0" step="0.01" value="0">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Proceeds Account</label>
|
||||
<select class="form-select" id="proceeds_account_code"></select>
|
||||
<div class="form-text">Cash, bank or receivable that received the money. Not needed for a write-off (price 0).</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Notes</label>
|
||||
<textarea class="form-control" id="disposal_notes" rows="2" placeholder="Buyer, reason"></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="bg-light rounded-2 px-3 py-2 small">
|
||||
<div class="d-flex justify-content-between"><span class="text-muted">Cost</span><span id="dp_cost"></span></div>
|
||||
<div class="d-flex justify-content-between"><span class="text-muted">Accumulated (months charged so far)</span><span id="dp_accumulated"></span></div>
|
||||
<div class="d-flex justify-content-between"><span class="text-muted">Book value</span><span id="dp_book"></span></div>
|
||||
<div class="d-flex justify-content-between fw-semibold"><span id="dp_result_label">Gain / (loss)</span><span id="dp_result"></span></div>
|
||||
</div>
|
||||
<div class="form-text">Every month before the disposal month must already be charged. The disposal month itself is not charged.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" data-bs-dismiss="modal">Back</button>
|
||||
<button class="btn btn-dark" onclick="submit_disposal()"><i class="ti ti-receipt-refund me-1"></i>Record Disposal</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php require '../include_ending.php'; ?>
|
||||
<script src="<?php echo $server_url?>assets/js/asset.js"></script>
|
||||
|
||||
<script>
|
||||
var asset_id = <?php echo $asset_id; ?>;
|
||||
var invoice_id = <?php echo $invoice_id; ?>;
|
||||
var item_id = <?php echo $item_id; ?>;
|
||||
var pi_link_ok = <?php echo $pi_link_ok ? 'true' : 'false'; ?>;
|
||||
var can_admin = ['owner', 'admin'].includes(user_role);
|
||||
var can_edit = ['owner', 'admin', 'staff'].includes(user_role);
|
||||
|
||||
var categories = [];
|
||||
var accounts = [];
|
||||
var detail = null;
|
||||
var invoice_line = null;
|
||||
var life_touched = false;
|
||||
|
||||
var FINANCIAL_FIELDS = '#category_id, #acquisition_date, #in_service_date, #cost, #salvage_value, #useful_life_months, #clearing_account_code';
|
||||
|
||||
// ── lookups ────────────────────────────────────────────────────────────────
|
||||
function load_categories() {
|
||||
return ajax_request({
|
||||
url: server_url + 'asset/api/engine/category.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
queueLock: false,
|
||||
noLoading: true,
|
||||
onSuccess: function(res) { categories = res.output || []; }
|
||||
});
|
||||
}
|
||||
|
||||
function category_by_id(id) {
|
||||
return categories.find(function(c) { return String(c.id) === String(id); }) || null;
|
||||
}
|
||||
|
||||
function fill_category_select(selected) {
|
||||
var html = '<option value="">— Select category —</option>';
|
||||
categories.forEach(function(c) {
|
||||
if (String(c.status) !== '1' && String(c.id) !== String(selected)) return;
|
||||
html += '<option value="' + c.id + '">' + escape_html(c.category_code + ' — ' + c.category_name)
|
||||
+ (c.asset_class === 'intangible' ? ' (intangible)' : '') + '</option>';
|
||||
});
|
||||
$('#category_id').html(html).val(selected ? String(selected) : '');
|
||||
}
|
||||
|
||||
function update_class_hint() {
|
||||
var c = category_by_id($('#category_id').val());
|
||||
var intangible = c && c.asset_class === 'intangible';
|
||||
$('#class_hint').html(c ? asset_class_badge(c.asset_class) : '');
|
||||
$('.label-charge').text(intangible ? 'amortize' : 'depreciate');
|
||||
$('.label-schedule').text(intangible ? 'Amortization' : 'Depreciation');
|
||||
}
|
||||
|
||||
// ── live calculation ───────────────────────────────────────────────────────
|
||||
function update_calc() {
|
||||
var cost = parseFloat($('#cost').val()) || 0;
|
||||
var salvage = parseFloat($('#salvage_value').val()) || 0;
|
||||
var life = parseInt($('#useful_life_months').val(), 10) || 0;
|
||||
var base = round_dp(cost - salvage, 2);
|
||||
if (base <= 0 || life <= 0) {
|
||||
$('#calc_base, #calc_monthly, #calc_last').text('—');
|
||||
return;
|
||||
}
|
||||
var monthly = round_dp(base / life, 2);
|
||||
var last = round_dp(base - monthly * (life - 1), 2);
|
||||
$('#calc_base').text(format_number(base, 2));
|
||||
$('#calc_monthly').text(format_number(monthly, 2));
|
||||
$('#calc_last').text(life > 1 ? format_number(Math.max(last, 0), 2) : format_number(base, 2));
|
||||
}
|
||||
|
||||
// ── load ───────────────────────────────────────────────────────────────────
|
||||
function retrieve_asset() {
|
||||
return ajax_request({
|
||||
url: server_url + 'asset/api/engine/retrieve_asset.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
data: { id: asset_id },
|
||||
onSuccess: function(res) {
|
||||
detail = res.output;
|
||||
render_detail();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function load_invoice_line() {
|
||||
return ajax_request({
|
||||
url: server_url + 'asset/api/engine/invoice_lines.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'line',
|
||||
data: { invoice_id: invoice_id, item_id: item_id },
|
||||
onSuccess: function(res) {
|
||||
invoice_line = res.output;
|
||||
var l = invoice_line;
|
||||
$('#asset_name').val(l.product_name || l.product_sku);
|
||||
$('#acquisition_date, #in_service_date').val(format_date_input(l.issued_date));
|
||||
$('#cost').val(Math.max(parseFloat(l.remaining) || 0, 0).toFixed(2));
|
||||
$('#clearing_account_code').html(account_options(accounts, [], l.clearing_account_code, '— None —'));
|
||||
load_departments('department_id', l.department_id);
|
||||
$('#source_alert').removeClass('d-none').html(
|
||||
'<i class="ti ti-file-invoice me-1"></i>From purchase invoice <strong>' + escape_html(l.invoice_number) + '</strong>'
|
||||
+ ' (' + escape_html(l.contact_name || '—') + '), line <strong>' + escape_html(l.product_sku) + '</strong>: '
|
||||
+ 'amount ' + format_number(l.line_amount, 2) + ', already capitalized ' + format_number(l.capitalized, 2)
|
||||
+ ', remaining <strong>' + format_number(l.remaining, 2) + '</strong>.'
|
||||
+ (l.clearing_account_code ? '' : ' <span class="text-danger">Select the account the invoice line was posted to as the clearing account.</span>')
|
||||
);
|
||||
$('#page_subtitle').text('Capitalizing a purchase invoice line');
|
||||
update_calc();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function render_detail() {
|
||||
var a = detail.asset;
|
||||
var status = parseInt(a.status, 10);
|
||||
var recorded = detail.schedule.some(function(r) { return r.state === 'recorded'; });
|
||||
var closed = status === 3 || status === 4;
|
||||
|
||||
$('#page_title').text(a.asset_number + ' · ' + a.asset_name);
|
||||
$('#page_subtitle').text(a.category_code + ' ' + a.category_name);
|
||||
|
||||
$('#id').val(a.id);
|
||||
$('#asset_name').val(a.asset_name);
|
||||
fill_category_select(a.category_id);
|
||||
update_class_hint();
|
||||
$('#clearing_account_code').html(account_options(accounts, [], a.clearing_account_code, '— None —'));
|
||||
$('#acquisition_date').val(format_date_input(a.acquisition_date));
|
||||
$('#in_service_date').val(format_date_input(a.in_service_date));
|
||||
$('#cost').val(parseFloat(a.cost).toFixed(2));
|
||||
$('#salvage_value').val(parseFloat(a.salvage_value).toFixed(2));
|
||||
$('#useful_life_months').val(a.useful_life_months);
|
||||
$('#description').val(a.description || '');
|
||||
load_departments('department_id', a.department_id);
|
||||
life_touched = true;
|
||||
update_calc();
|
||||
|
||||
// Status card
|
||||
$('#status_card').removeClass('d-none');
|
||||
$('#status_badges').html(asset_status_badge(a.status, a.asset_class) + asset_class_badge(a.asset_class));
|
||||
$('#display_accumulated').text(format_number(a.accumulated, 2));
|
||||
$('#display_book_value').text(format_number(a.book_value, 2));
|
||||
$('#display_last_period').text(a.last_period || '—');
|
||||
if (parseInt(a.invoice_id, 10) > 0) {
|
||||
var ref = escape_html(a.invoice_number || ('PI #' + a.invoice_id));
|
||||
$('#display_source').html(pi_link_ok
|
||||
? '<a href="' + server_url + 'expense/manage_purchase_invoice.php?id=' + a.invoice_id + '">' + ref + '</a>'
|
||||
: ref);
|
||||
$('#source_row').removeClass('d-none');
|
||||
}
|
||||
|
||||
// GL entries
|
||||
var cap_html;
|
||||
if (!a.clearing_account_code) {
|
||||
cap_html = '<span class="text-muted">No entry — no clearing account.</span>';
|
||||
} else if (status === 0) {
|
||||
cap_html = '<span class="text-muted">Activate the asset, then Accounting posts it.</span>';
|
||||
} else {
|
||||
cap_html = gl_entry_html(detail.gl.capitalization);
|
||||
}
|
||||
$('#gl_capitalization').html(cap_html);
|
||||
$('#gl_disposal_row').toggleClass('d-none', status !== 3);
|
||||
$('#gl_disposal').html(gl_entry_html(detail.gl.disposal));
|
||||
|
||||
// Disposal
|
||||
if (status === 3) {
|
||||
var gain = round_dp(parseFloat(a.sale_price) - (parseFloat(a.cost) - parseFloat(a.accumulated)), 2);
|
||||
$('#display_disposal_date').text(format_date(a.disposal_date));
|
||||
$('#display_sale_price').text(format_number(a.sale_price, 2));
|
||||
$('#display_gain_loss').text(gain < 0 ? '(' + format_number(-gain, 2) + ')' : format_number(gain, 2))
|
||||
.toggleClass('text-danger', gain < 0).toggleClass('text-success', gain > 0);
|
||||
$('#display_disposal_notes').text(a.disposal_notes || '');
|
||||
$('#disposal_info').removeClass('d-none');
|
||||
} else {
|
||||
$('#disposal_info').addClass('d-none');
|
||||
}
|
||||
|
||||
render_schedule();
|
||||
|
||||
// Editing rules
|
||||
$(FINANCIAL_FIELDS).prop('disabled', closed || recorded || !can_edit);
|
||||
$('#asset_name, #description, #department_id').prop('disabled', closed || !can_edit);
|
||||
$('#locked_hint').toggleClass('d-none', !(recorded && !closed));
|
||||
|
||||
$('#btn_save').toggleClass('d-none', closed || !can_edit).find('span').text(status === 0 ? 'Save Draft' : 'Save');
|
||||
$('#btn_activate').toggleClass('d-none', !(status === 0 && can_admin));
|
||||
$('#btn_dispose').toggleClass('d-none', !((status === 1 || status === 2) && can_admin));
|
||||
$('#btn_undo_disposal').toggleClass('d-none', !(status === 3 && can_admin));
|
||||
$('#btn_void').toggleClass('d-none', !((status === 1 || status === 2) && can_admin && !recorded));
|
||||
$('#btn_delete').toggleClass('d-none', !(status === 0 && can_admin));
|
||||
}
|
||||
|
||||
function gl_entry_html(entry) {
|
||||
if (entry) {
|
||||
return '<span class="badge bg-success-subtle text-success">Posted</span>'
|
||||
+ ' <span class="text-muted ms-1">' + escape_html(entry.reference) + ' · ' + format_date(entry.journal_date) + '</span>';
|
||||
}
|
||||
return '<span class="badge bg-danger-subtle text-danger">Not posted</span>'
|
||||
+ ' <span class="text-muted ms-1">Accounting → Batch GL Entries</span>';
|
||||
}
|
||||
|
||||
function render_schedule() {
|
||||
var rows = detail.schedule || [];
|
||||
if (!rows.length) { $('#schedule_card').addClass('d-none'); return; }
|
||||
|
||||
var state_badge = {
|
||||
recorded: function(r) { return '<span class="badge bg-success-subtle text-success">Charged</span> <span class="small text-muted">' + escape_html(r.run_number || '') + '</span>'; },
|
||||
projected: function() { return '<span class="badge bg-light text-dark">Projected</span>'; },
|
||||
stopped: function() { return '<span class="badge bg-secondary-subtle text-secondary">Not charged</span>'; }
|
||||
};
|
||||
|
||||
var html = '';
|
||||
var charged = 0;
|
||||
rows.forEach(function(r, i) {
|
||||
if (r.state === 'recorded') charged++;
|
||||
html += '<tr class="' + (r.state === 'stopped' ? 'text-muted' : '') + '">'
|
||||
+ '<td>' + (i + 1) + '</td>'
|
||||
+ '<td>' + escape_html(r.period) + '</td>'
|
||||
+ '<td class="text-end">' + format_number(r.amount, 2) + '</td>'
|
||||
+ '<td class="text-end">' + format_number(r.accumulated, 2) + '</td>'
|
||||
+ '<td class="text-end">' + format_number(r.book_value, 2) + '</td>'
|
||||
+ '<td>' + state_badge[r.state](r) + '</td>'
|
||||
+ '</tr>';
|
||||
});
|
||||
$('#schedule_tbody').html(html);
|
||||
$('#schedule_summary').text(charged + ' of ' + rows.length + ' months charged · ' + format_number(detail.monthly_amount, 2) + ' per month');
|
||||
$('#schedule_card').removeClass('d-none');
|
||||
}
|
||||
|
||||
// ── actions ────────────────────────────────────────────────────────────────
|
||||
function save_asset() {
|
||||
return ajax_request({
|
||||
url: server_url + 'asset/api/engine/manage_asset.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 1,
|
||||
action: 'manage',
|
||||
data: { id: asset_id || '', invoice_id: invoice_id, invoice_item_id: item_id },
|
||||
onSuccess: function(res) {
|
||||
var saved_id = res.output && res.output.id;
|
||||
if (!asset_id && saved_id) {
|
||||
window.location.href = server_url + 'asset/manage_asset.php?id=' + saved_id;
|
||||
return;
|
||||
}
|
||||
toastr.success(res.message || 'Saved.');
|
||||
retrieve_asset();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var ACTION_CONFIRM = {
|
||||
activate: ['Activate this asset? It will start appearing in depreciation runs from its in-service month.', 'Activate', 'btn-success'],
|
||||
undo_disposal: ['Undo the disposal? If its GL entry was posted, a reversal entry is created.', 'Undo Disposal', 'btn-warning'],
|
||||
void: ['<strong>Void this asset?</strong><br>If its capitalization was posted, a reversal entry is created. This cannot be undone.', 'Void', 'btn-danger'],
|
||||
delete: ['Delete this draft asset?', 'Delete', 'btn-danger']
|
||||
};
|
||||
|
||||
function asset_action(action) {
|
||||
var c = ACTION_CONFIRM[action];
|
||||
bootbox.confirm({
|
||||
message: c[0],
|
||||
buttons: { confirm: { label: c[1], className: c[2] }, cancel: { label: 'Back', className: 'btn-secondary' } },
|
||||
callback: function(ok) {
|
||||
if (!ok) return;
|
||||
ajax_request({
|
||||
url: server_url + 'asset/api/engine/manage_asset.php',
|
||||
autoPrepare: false,
|
||||
action: action,
|
||||
data: { json: JSON.stringify({
|
||||
otp: document.getElementById('session-context').dataset.otp,
|
||||
company_id: company_id,
|
||||
action: action,
|
||||
id: asset_id
|
||||
}) },
|
||||
onSuccess: function(res) {
|
||||
if (action === 'delete') { window.location.href = server_url + 'asset/index.php'; return; }
|
||||
toastr.success(res.message || 'Done.');
|
||||
retrieve_asset();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function open_disposal() {
|
||||
var a = detail.asset;
|
||||
$('#proceeds_account_code').html(account_options(accounts, ['asset'], '', '— None (write-off) —'));
|
||||
$('#sale_price').val('0');
|
||||
$('#disposal_notes').val('');
|
||||
$('#dp_cost').text(format_number(a.cost, 2));
|
||||
$('#dp_accumulated').text(format_number(a.accumulated, 2));
|
||||
$('#dp_book').text(format_number(a.book_value, 2));
|
||||
update_disposal_result();
|
||||
$('#disposal_modal').modal('show');
|
||||
}
|
||||
|
||||
function update_disposal_result() {
|
||||
if (!detail) return;
|
||||
var gain = round_dp((parseFloat($('#sale_price').val()) || 0) - parseFloat(detail.asset.book_value), 2);
|
||||
$('#dp_result_label').text(gain < 0 ? 'Loss' : 'Gain');
|
||||
$('#dp_result').text(format_number(Math.abs(gain), 2)).toggleClass('text-danger', gain < 0).toggleClass('text-success', gain > 0);
|
||||
}
|
||||
|
||||
function submit_disposal() {
|
||||
ajax_request({
|
||||
url: server_url + 'asset/api/engine/manage_asset.php',
|
||||
autoPrepare: false,
|
||||
action: 'dispose',
|
||||
data: { json: JSON.stringify({
|
||||
otp: document.getElementById('session-context').dataset.otp,
|
||||
company_id: company_id,
|
||||
action: 'dispose',
|
||||
id: asset_id,
|
||||
disposal_date: $('#disposal_date').val(),
|
||||
sale_price: $('#sale_price').val(),
|
||||
proceeds_account_code: $('#proceeds_account_code').val(),
|
||||
disposal_notes: $('#disposal_notes').val()
|
||||
}) },
|
||||
onSuccess: function(res) {
|
||||
$('#disposal_modal').modal('hide');
|
||||
toastr.success(res.message || 'Disposal recorded.');
|
||||
retrieve_asset();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── events ─────────────────────────────────────────────────────────────────
|
||||
$('#cost, #salvage_value, #useful_life_months').on('input', update_calc);
|
||||
$('#useful_life_months').on('input', function() { life_touched = true; });
|
||||
$('#category_id').on('change', function() {
|
||||
var c = category_by_id($(this).val());
|
||||
if (c && !life_touched) {
|
||||
$('#useful_life_months').val(c.useful_life_months);
|
||||
update_calc();
|
||||
}
|
||||
update_class_hint();
|
||||
});
|
||||
$('#sale_price').on('input', update_disposal_result);
|
||||
|
||||
$(async function() {
|
||||
flatpickr('#acquisition_date, #in_service_date, #disposal_date', { dateFormat: 'd/m/Y', allowInput: true });
|
||||
|
||||
try {
|
||||
await load_categories();
|
||||
await load_posting_accounts(function(rows) { accounts = rows; });
|
||||
fill_category_select('');
|
||||
$('#clearing_account_code').html(account_options(accounts, [], '', '— None —'));
|
||||
|
||||
if (asset_id) {
|
||||
await retrieve_asset();
|
||||
} else {
|
||||
load_departments('department_id', 0);
|
||||
if (invoice_id && item_id) await load_invoice_line();
|
||||
if (!can_edit) $('#btn_save').addClass('d-none');
|
||||
}
|
||||
} catch (e) { console.log(e); }
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,179 +0,0 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../config.php';
|
||||
require '../include_header.php';
|
||||
?>
|
||||
|
||||
<body>
|
||||
<?php require '../include_topbar.php'; ?>
|
||||
<?php require '../include_sidebar_asset.php'; ?>
|
||||
|
||||
<main id="content" class="content py-15">
|
||||
<div class="container-fluid">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex align-items-center mb-5 gap-3">
|
||||
<a href="<?php echo $server_url?>asset/categories.php" class="btn btn-light btn-sm">
|
||||
<i class="ti ti-arrow-left me-1"></i>Back
|
||||
</a>
|
||||
<h1 class="fs-3 mb-0">Manage Asset Category</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" id="categoryForm">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-body p-6">
|
||||
|
||||
<input type="hidden" id="id">
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Code <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="category_code" placeholder="e.g. IT" required>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">Name <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="category_name" placeholder="e.g. Computer equipment" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Status</label>
|
||||
<select class="form-select" id="status">
|
||||
<option value="1">Active</option>
|
||||
<option value="0">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Asset Class <span class="text-danger">*</span></label>
|
||||
<select class="form-select" id="asset_class" required>
|
||||
<option value="tangible">Tangible — straight-line depreciation</option>
|
||||
<option value="intangible">Intangible — straight-line amortization</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Default Useful Life (months) <span class="text-danger">*</span></label>
|
||||
<input type="number" class="form-control" id="useful_life_months" min="1" max="1200" step="1" value="60" required>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<h2 class="fs-6 mb-0 mt-2">GL Accounts</h2>
|
||||
<p class="text-muted small mb-0">Used when Accounting posts this category's entries from Batch GL Entries.</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Asset Cost Account <span class="text-danger">*</span></label>
|
||||
<select class="form-select" id="cost_account_code" required></select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><span class="label-accum">Accumulated Depreciation</span> Account <span class="text-danger">*</span></label>
|
||||
<select class="form-select" id="accum_account_code" required></select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><span class="label-expense">Depreciation</span> Expense Account <span class="text-danger">*</span></label>
|
||||
<select class="form-select" id="expense_account_code" required></select>
|
||||
</div>
|
||||
<div class="col-md-6"></div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Gain on Disposal Account <span class="text-danger">*</span></label>
|
||||
<select class="form-select" id="gain_account_code" required></select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Loss on Disposal Account <span class="text-danger">*</span></label>
|
||||
<select class="form-select" id="loss_account_code" required></select>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control" id="description" rows="2" placeholder="Optional"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 mt-5">
|
||||
<button class="btn btn-primary" onclick="manage_category();" id="btn_submit" data-min-role="admin">Create Category</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php require '../include_ending.php'; ?>
|
||||
<script src="<?php echo $server_url?>assets/js/asset.js"></script>
|
||||
|
||||
<script>
|
||||
var category_id = <?php echo (int)($_GET['id'] ?? 0); ?>;
|
||||
var accounts = [];
|
||||
|
||||
var ACCOUNT_SELECTS = {
|
||||
cost_account_code: ['asset'],
|
||||
accum_account_code: ['asset'],
|
||||
expense_account_code: ['expense'],
|
||||
gain_account_code: ['revenue'],
|
||||
loss_account_code: ['expense']
|
||||
};
|
||||
|
||||
function fill_account_selects(values) {
|
||||
Object.keys(ACCOUNT_SELECTS).forEach(function(field) {
|
||||
$('#' + field).html(account_options(accounts, ACCOUNT_SELECTS[field], (values || {})[field] || ''));
|
||||
});
|
||||
}
|
||||
|
||||
function update_class_labels() {
|
||||
var intangible = $('#asset_class').val() === 'intangible';
|
||||
$('.label-accum').text(intangible ? 'Accumulated Amortization' : 'Accumulated Depreciation');
|
||||
$('.label-expense').text(intangible ? 'Amortization' : 'Depreciation');
|
||||
}
|
||||
|
||||
function manage_category() {
|
||||
return ajax_request({
|
||||
url: server_url + 'asset/api/engine/manage_category.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 1,
|
||||
action: 'manage',
|
||||
onSuccess: function() {
|
||||
window.location.href = server_url + 'asset/categories.php';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function retrieve_for_edit() {
|
||||
if (!category_id) return Promise.resolve();
|
||||
return ajax_request({
|
||||
url: server_url + 'asset/api/engine/retrieve_category.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
data: { id: category_id },
|
||||
onSuccess: function(res) {
|
||||
var c = res.output;
|
||||
$('#id').val(c.id);
|
||||
$('#category_code').val(c.category_code);
|
||||
$('#category_name').val(c.category_name);
|
||||
$('#asset_class').val(c.asset_class);
|
||||
$('#useful_life_months').val(c.useful_life_months);
|
||||
$('#description').val(c.description || '');
|
||||
$('#status').val(c.status);
|
||||
fill_account_selects(c);
|
||||
update_class_labels();
|
||||
$('#btn_submit').text('Update Category');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$('#asset_class').on('change', update_class_labels);
|
||||
|
||||
$(async function() {
|
||||
try {
|
||||
await load_posting_accounts(function(rows) { accounts = rows; fill_account_selects({}); });
|
||||
await retrieve_for_edit();
|
||||
} catch (e) { console.log(e); }
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,173 +0,0 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../config.php';
|
||||
require '../include_header.php';
|
||||
$pi_link_ok = in_array($_SESSION['login_app_access'] ?? 'wms', ['all', 'accounting'], true);
|
||||
?>
|
||||
|
||||
<body>
|
||||
<?php require '../include_topbar.php'; ?>
|
||||
<?php require '../include_sidebar_asset.php'; ?>
|
||||
|
||||
<main id="content" class="content py-15">
|
||||
<div class="container-fluid">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="mb-5">
|
||||
<h1 class="fs-3 mb-1">Assets from Purchase Invoices</h1>
|
||||
<p class="mb-0">Issued purchase invoice lines (AP) that can be capitalized as assets</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-5">
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<label class="form-label mb-1 small">Invoice Date From</label>
|
||||
<input type="text" id="date_from" class="form-control form-control-sm" placeholder="d/m/Y" style="width:140px;">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label mb-1 small">Invoice Date To</label>
|
||||
<input type="text" id="date_to" class="form-control form-control-sm" placeholder="d/m/Y" style="width:140px;">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-secondary btn-sm" onclick="load_lines()">
|
||||
<i class="ti ti-search me-1"></i>Load
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-auto ms-3">
|
||||
<div class="form-check mb-1">
|
||||
<input class="form-check-input" type="checkbox" id="show_capitalized">
|
||||
<label class="form-check-label small" for="show_capitalized">Show fully capitalized lines</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col ms-auto d-flex justify-content-end">
|
||||
<div class="position-relative" style="min-width:220px;">
|
||||
<input type="search" id="search_line" class="form-control form-control-sm ps-8" placeholder="Search invoice, supplier, item…">
|
||||
<span class="position-absolute top-25 ms-3"><i class="ti ti-search text-muted small"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 table-centered text-nowrap" id="line_table">
|
||||
<thead class="table-primary border-light">
|
||||
<tr>
|
||||
<th>Invoice</th>
|
||||
<th>Date</th>
|
||||
<th>Supplier</th>
|
||||
<th>Item</th>
|
||||
<th class="text-end">Qty</th>
|
||||
<th class="text-end">Line Amount</th>
|
||||
<th class="text-end">Capitalized</th>
|
||||
<th class="text-end">Remaining</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td colspan="9" class="text-center text-muted py-5">Use the filters above and click Load.</td></tr>
|
||||
</tbody>
|
||||
<tfoot></tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php require '../include_ending.php'; ?>
|
||||
<script src="<?php echo $server_url?>assets/js/asset.js"></script>
|
||||
|
||||
<script>
|
||||
var pi_link_ok = <?php echo $pi_link_ok ? 'true' : 'false'; ?>;
|
||||
var all_lines = [];
|
||||
var search_term = '';
|
||||
|
||||
function load_lines() {
|
||||
return ajax_request({
|
||||
url: server_url + 'asset/api/engine/invoice_lines.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
data: { date_from: $('#date_from').val(), date_to: $('#date_to').val() },
|
||||
onSuccess: function(res) {
|
||||
all_lines = res.output || [];
|
||||
change_page(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function visible_lines() {
|
||||
var show_all = $('#show_capitalized').is(':checked');
|
||||
var kw = search_term.toLowerCase();
|
||||
return all_lines.filter(function(l) {
|
||||
if (!show_all && parseFloat(l.remaining) <= 0) return false;
|
||||
if (!kw) return true;
|
||||
return [l.invoice_number, l.contact_name, l.product_sku, l.product_name]
|
||||
.some(function(v) { return String(v || '').toLowerCase().indexOf(kw) !== -1; });
|
||||
});
|
||||
}
|
||||
|
||||
function change_page(page_num) {
|
||||
var rows = visible_lines();
|
||||
var limit = parseInt(prop_limit, 10);
|
||||
var offset = (page_num - 1) * limit;
|
||||
|
||||
$('#line_table tfoot').html(generate_pagination('line_table', rows.length));
|
||||
|
||||
if (!rows.length) {
|
||||
$('#line_table tbody').html('<tr><td colspan="9" class="text-center text-muted py-5">No invoice lines to capitalize in this date range.</td></tr>');
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '';
|
||||
rows.slice(offset, offset + limit).forEach(function(l) {
|
||||
var invoice = pi_link_ok
|
||||
? '<a href="' + server_url + 'expense/manage_purchase_invoice.php?id=' + l.invoice_id + '">' + escape_html(l.invoice_number) + '</a>'
|
||||
: escape_html(l.invoice_number);
|
||||
var action = parseFloat(l.remaining) > 0
|
||||
? '<a class="btn btn-sm btn-primary" href="' + server_url + 'asset/manage_asset.php?invoice_id=' + l.invoice_id + '&item_id=' + l.item_id + '">'
|
||||
+ '<i class="ti ti-plus me-1"></i>Create Asset</a>'
|
||||
: '<span class="badge bg-success-subtle text-success">Capitalized</span>';
|
||||
var assets = parseInt(l.asset_count, 10) > 0
|
||||
? ' <span class="small text-muted">(' + l.asset_count + ' asset' + (l.asset_count == 1 ? '' : 's') + ')</span>'
|
||||
: '';
|
||||
html += '<tr>'
|
||||
+ '<td>' + invoice + '</td>'
|
||||
+ '<td>' + format_date(l.issued_date) + '</td>'
|
||||
+ '<td>' + escape_html(l.contact_name || '—') + '</td>'
|
||||
+ '<td>' + escape_html(l.product_name || l.product_sku) + '<div class="small text-muted">' + escape_html(l.product_sku) + '</div></td>'
|
||||
+ '<td class="text-end">' + format_number(l.quantity, 2) + '</td>'
|
||||
+ '<td class="text-end">' + format_number(l.line_amount, 2) + '</td>'
|
||||
+ '<td class="text-end">' + format_number(l.capitalized, 2) + assets + '</td>'
|
||||
+ '<td class="text-end fw-semibold">' + format_number(l.remaining, 2) + '</td>'
|
||||
+ '<td class="text-end">' + action + '</td>'
|
||||
+ '</tr>';
|
||||
});
|
||||
$('#line_table tbody').html(html);
|
||||
}
|
||||
|
||||
$('#show_capitalized').on('change', function() { change_page(1); });
|
||||
$('#search_line').on('input', debounce(function() {
|
||||
search_term = $(this).val().trim();
|
||||
change_page(1);
|
||||
}, 200));
|
||||
|
||||
$(function() {
|
||||
var today = new Date();
|
||||
var start = new Date(today.getFullYear(), 0, 1);
|
||||
flatpickr('#date_from', { dateFormat: 'd/m/Y', allowInput: true, defaultDate: start });
|
||||
flatpickr('#date_to', { dateFormat: 'd/m/Y', allowInput: true, defaultDate: today });
|
||||
load_lines().catch(function(e) { console.log(e); });
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,267 +0,0 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../config.php';
|
||||
require '../include_header.php';
|
||||
?>
|
||||
|
||||
<body>
|
||||
<?php require '../include_topbar.php'; ?>
|
||||
<?php require '../include_sidebar_asset.php'; ?>
|
||||
|
||||
<main id="content" class="content py-15">
|
||||
<div class="container-fluid">
|
||||
|
||||
<!-- Page header -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-6 gap-3">
|
||||
<div>
|
||||
<h1 class="fs-3 mb-1">Asset Management</h1>
|
||||
<p class="mb-0">Fixed assets, depreciation and amortization</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="<?php echo $server_url?>asset/purchase_invoices.php" class="btn btn-outline-primary">
|
||||
<i class="ti ti-file-invoice me-1"></i>From Purchase Invoice
|
||||
</a>
|
||||
<a href="<?php echo $server_url?>asset/manage_asset.php" class="btn btn-primary">
|
||||
<i class="ti ti-plus me-1"></i>New Asset
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary cards -->
|
||||
<div class="row g-5 mb-5">
|
||||
<div class="col-lg-3 col-sm-6 col-12">
|
||||
<div class="card p-4 bg-success bg-opacity-10 border border-success border-opacity-25 rounded-2">
|
||||
<div class="d-flex gap-3">
|
||||
<div class="icon-shape icon-md bg-success text-white rounded-2">
|
||||
<i class="ti ti-building-warehouse fs-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="mb-3 fs-6">Assets in Use</h2>
|
||||
<h3 class="fw-bold mb-0" id="stat_in_use">—</h3>
|
||||
<p class="text-success mb-0 small" id="stat_in_use_note"> </p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-sm-6 col-12">
|
||||
<div class="card p-4 bg-info bg-opacity-10 border border-info border-opacity-25 rounded-2">
|
||||
<div class="d-flex gap-3">
|
||||
<div class="icon-shape icon-md bg-info text-white rounded-2">
|
||||
<i class="ti ti-cash fs-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="mb-3 fs-6">Cost</h2>
|
||||
<h3 class="fw-bold mb-0" id="stat_cost">—</h3>
|
||||
<p class="text-info mb-0 small">Assets in use</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-sm-6 col-12">
|
||||
<div class="card p-4 bg-warning bg-opacity-10 border border-warning border-opacity-25 rounded-2">
|
||||
<div class="d-flex gap-3">
|
||||
<div class="icon-shape icon-md bg-warning text-white rounded-2">
|
||||
<i class="ti ti-trending-down fs-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="mb-3 fs-6">Accumulated</h2>
|
||||
<h3 class="fw-bold mb-0" id="stat_accumulated">—</h3>
|
||||
<p class="text-warning mb-0 small">Depreciation + amortization</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-sm-6 col-12">
|
||||
<div class="card p-4 bg-primary bg-opacity-10 border border-primary border-opacity-25 rounded-2">
|
||||
<div class="d-flex gap-3">
|
||||
<div class="icon-shape icon-md bg-primary text-white rounded-2">
|
||||
<i class="ti ti-scale fs-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="mb-3 fs-6">Net Book Value</h2>
|
||||
<h3 class="fw-bold mb-0" id="stat_book">—</h3>
|
||||
<p class="text-primary mb-0 small">Cost minus accumulated</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-5 mb-5">
|
||||
<!-- Due -->
|
||||
<div class="col-lg-5">
|
||||
<div class="card h-100">
|
||||
<div class="card-body p-5">
|
||||
<h2 class="fs-5 mb-1">Due this month</h2>
|
||||
<p class="text-muted small mb-4">Months not yet charged, up to <span id="due_month"></span></p>
|
||||
<div id="due_list"><div class="text-muted small">Loading...</div></div>
|
||||
<div class="border-top pt-3 mt-3 small">
|
||||
<span class="text-muted">Drafts waiting to be activated:</span>
|
||||
<a href="<?php echo $server_url?>asset/index.php?status=0" class="fw-semibold ms-1" id="stat_draft">—</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent runs -->
|
||||
<div class="col-lg-7">
|
||||
<div class="card h-100">
|
||||
<div class="card-body p-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="fs-5 mb-0">Recent runs</h2>
|
||||
<a href="<?php echo $server_url?>asset/depreciation.php" class="small">All runs</a>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0 table-centered">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Run No.</th>
|
||||
<th>Month</th>
|
||||
<th class="text-end">Amount</th>
|
||||
<th>GL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="runs_tbody">
|
||||
<tr><td colspan="4" class="text-center text-muted py-4">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- By category -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body p-5">
|
||||
<h2 class="fs-5 mb-4">Assets in use by category</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0 table-hover table-centered">
|
||||
<thead class="table-primary border-light">
|
||||
<tr>
|
||||
<th>Category</th>
|
||||
<th>Class</th>
|
||||
<th class="text-end">Assets</th>
|
||||
<th class="text-end">Cost</th>
|
||||
<th class="text-end">Accumulated</th>
|
||||
<th class="text-end">Net Book Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="category_tbody">
|
||||
<tr><td colspan="6" class="text-center text-muted py-4">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php require '../include_ending.php'; ?>
|
||||
<script src="<?php echo $server_url?>assets/js/asset.js"></script>
|
||||
|
||||
<script>
|
||||
function load_stats() {
|
||||
return ajax_request({
|
||||
url: server_url + 'asset/api/engine/asset_stats.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
onSuccess: function(res) {
|
||||
var s = res.output || {};
|
||||
$('#stat_in_use').text(parseInt(s.active || 0, 10) + parseInt(s.fully_depreciated || 0, 10));
|
||||
$('#stat_in_use_note').text((s.fully_depreciated || 0) + ' fully depreciated · ' + (s.disposed || 0) + ' disposed');
|
||||
$('#stat_cost').text(format_number(s.cost_in_use || 0, 2));
|
||||
$('#stat_accumulated').text(format_number(s.accumulated_in_use || 0, 2));
|
||||
$('#stat_book').text(format_number(s.book_value_in_use || 0, 2));
|
||||
$('#stat_draft').text(s.draft || 0);
|
||||
|
||||
render_due(res.due || {});
|
||||
render_categories(s.by_category || []);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function render_due(due) {
|
||||
$('#due_month').text(current_period());
|
||||
var html = '';
|
||||
['tangible', 'intangible'].forEach(function(asset_class) {
|
||||
var d = due[asset_class] || {};
|
||||
var body = d.period_count > 0
|
||||
? '<div class="fw-semibold">' + format_number(d.total, 2) + '</div>'
|
||||
+ '<div class="small text-muted">' + d.period_count + ' month(s): ' + escape_html(d.from) + (d.to !== d.from ? ' → ' + escape_html(d.to) : '') + '</div>'
|
||||
: '<div class="small text-success"><i class="ti ti-circle-check me-1"></i>Up to date</div>';
|
||||
var button = d.period_count > 0
|
||||
? '<a class="btn btn-sm btn-primary" href="' + server_url + 'asset/depreciation.php?class=' + asset_class + '">Run</a>'
|
||||
: '';
|
||||
html += '<div class="d-flex align-items-center justify-content-between py-2 border-bottom">'
|
||||
+ '<div><div class="small text-muted">' + escape_html(d.label || asset_class) + '</div>' + body + '</div>'
|
||||
+ button + '</div>';
|
||||
});
|
||||
$('#due_list').html(html);
|
||||
}
|
||||
|
||||
function render_categories(rows) {
|
||||
if (!rows.length) {
|
||||
$('#category_tbody').html('<tr><td colspan="6" class="text-center text-muted py-4">No active assets yet.</td></tr>');
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
rows.forEach(function(r) {
|
||||
html += '<tr>'
|
||||
+ '<td><span class="fw-semibold">' + escape_html(r.category_code) + '</span> ' + escape_html(r.category_name) + '</td>'
|
||||
+ '<td>' + asset_class_badge(r.asset_class) + '</td>'
|
||||
+ '<td class="text-end">' + r.asset_count + '</td>'
|
||||
+ '<td class="text-end">' + format_number(r.cost, 2) + '</td>'
|
||||
+ '<td class="text-end">' + format_number(r.accumulated, 2) + '</td>'
|
||||
+ '<td class="text-end">' + format_number((parseFloat(r.cost) || 0) - (parseFloat(r.accumulated) || 0), 2) + '</td>'
|
||||
+ '</tr>';
|
||||
});
|
||||
$('#category_tbody').html(html);
|
||||
}
|
||||
|
||||
function load_runs() {
|
||||
return ajax_request({
|
||||
url: server_url + 'asset/api/engine/depreciation_run.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'list',
|
||||
onSuccess: function(res) {
|
||||
var rows = (res.output || []).filter(function(r) { return String(r.status) === '1'; }).slice(0, 6);
|
||||
if (!rows.length) {
|
||||
$('#runs_tbody').html('<tr><td colspan="4" class="text-center text-muted py-4">No runs yet.</td></tr>');
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
rows.forEach(function(r) {
|
||||
html += '<tr>'
|
||||
+ '<td>' + escape_html(r.run_number) + '</td>'
|
||||
+ '<td>' + escape_html(r.period) + '</td>'
|
||||
+ '<td class="text-end">' + format_number(r.total_amount, 2) + '</td>'
|
||||
+ '<td>' + asset_gl_badge(r.gl_status) + '</td>'
|
||||
+ '</tr>';
|
||||
});
|
||||
$('#runs_tbody').html(html);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$(async function() {
|
||||
try {
|
||||
await load_stats();
|
||||
await load_runs();
|
||||
} catch (e) { console.log(e); }
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,13 +1,6 @@
|
||||
|
||||
html, body { overflow-x: hidden; }
|
||||
|
||||
/* Soft badges (app access badges: WMS orange, Accounting blue, Asset Management green) */
|
||||
.bg-label-primary { background-color: var(--bs-primary-bg-subtle) !important; color: var(--bs-primary-text-emphasis) !important; }
|
||||
.bg-label-info { background-color: #e0f2fe !important; color: #0369a1 !important; }
|
||||
.bg-label-success { background-color: #dcfce7 !important; color: #15803d !important; }
|
||||
.bg-label-secondary,
|
||||
.bg-label-dark { background-color: #e9ecef !important; color: #343a40 !important; }
|
||||
|
||||
.flatpickr-day.selected {
|
||||
background: var(--bs-primary) !important;
|
||||
border-color: var(--bs-primary) !important;
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
// Shared helpers for the Asset Management pages.
|
||||
|
||||
var ASSET_STATUS = {
|
||||
0: ['Draft', 'bg-secondary'],
|
||||
1: ['Active', 'bg-success'],
|
||||
2: ['Fully depreciated', 'bg-info text-white'],
|
||||
3: ['Disposed', 'bg-dark'],
|
||||
4: ['Void', 'bg-light text-dark']
|
||||
};
|
||||
|
||||
function asset_status_badge(status, asset_class) {
|
||||
var code = parseInt(status, 10);
|
||||
var entry = ASSET_STATUS[code] || ['—', 'bg-light text-dark'];
|
||||
var label = (code === 2 && asset_class === 'intangible') ? 'Fully amortized' : entry[0];
|
||||
return '<span class="badge ' + entry[1] + '">' + label + '</span>';
|
||||
}
|
||||
|
||||
function asset_class_badge(asset_class) {
|
||||
return asset_class === 'intangible'
|
||||
? '<span class="badge bg-warning-subtle text-warning">Intangible · Amortization</span>'
|
||||
: '<span class="badge bg-success-subtle text-success">Tangible · Depreciation</span>';
|
||||
}
|
||||
|
||||
function asset_gl_badge(posted) {
|
||||
return String(posted) === '1'
|
||||
? '<span class="badge bg-success-subtle text-success">Posted</span>'
|
||||
: '<span class="badge bg-danger-subtle text-danger">Unposted</span>';
|
||||
}
|
||||
|
||||
// Active posting accounts from the chart of accounts.
|
||||
function load_posting_accounts(onLoaded) {
|
||||
return ajax_request({
|
||||
url: server_url + 'accounting/api/engine/account.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
queueLock: false,
|
||||
noLoading: true,
|
||||
onSuccess: function(res) {
|
||||
var rows = (res.output || []).filter(function(a) {
|
||||
return String(a.is_posting) === '1' && String(a.status) === '1';
|
||||
});
|
||||
onLoaded(rows);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// <option> list for accounts, narrowed to account_types when any match.
|
||||
function account_options(accounts, account_types, selected, empty_label) {
|
||||
var list = accounts;
|
||||
if (account_types && account_types.length) {
|
||||
var narrowed = accounts.filter(function(a) { return account_types.indexOf(a.account_type) !== -1; });
|
||||
if (narrowed.length) list = narrowed;
|
||||
}
|
||||
|
||||
var html = '<option value="">' + escape_html(empty_label || '— Select account —') + '</option>';
|
||||
var found = false;
|
||||
list.forEach(function(a) {
|
||||
var is_selected = String(a.account_code) === String(selected || '');
|
||||
if (is_selected) found = true;
|
||||
html += '<option value="' + escape_html(a.account_code) + '"' + (is_selected ? ' selected' : '') + '>'
|
||||
+ escape_html(a.account_code + ' — ' + a.account_name) + '</option>';
|
||||
});
|
||||
// Keep a saved code visible even if it is no longer an active posting account.
|
||||
if (selected && !found) {
|
||||
html += '<option value="' + escape_html(selected) + '" selected>' + escape_html(selected) + ' (not active)</option>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function current_period() {
|
||||
var d = new Date();
|
||||
return d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2);
|
||||
}
|
||||
+2
-11
@@ -995,24 +995,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
/**
|
||||
* Helper function to format date strings (assuming input is in ISO format)
|
||||
*/
|
||||
// Display format used across the app: YYYY-MM-DD, or YYYY-MM-DD HH:mm:ss when the value has a time.
|
||||
function format_date(iso_string) {
|
||||
if (!iso_string) return '—';
|
||||
var split = String(iso_string).split(" ");
|
||||
var split = iso_string.split(" ");
|
||||
var datePart = split[0].split("-");
|
||||
if (datePart.length !== 3) return iso_string;
|
||||
var formatted = `${datePart[0]}-${datePart[1]}-${datePart[2]}`;
|
||||
var formatted = `${datePart[2]}/${datePart[1]}/${datePart[0]}`;
|
||||
return split.length === 2 ? `${formatted} ${split[1]}` : formatted;
|
||||
}
|
||||
|
||||
// DD/MM/YYYY for filling date inputs (flatpickr dateFormat 'd/m/Y'); to_iso_date() reverses it.
|
||||
function format_date_input(iso_string) {
|
||||
if (!iso_string) return '';
|
||||
var datePart = String(iso_string).split(" ")[0].split("-");
|
||||
if (datePart.length !== 3) return iso_string;
|
||||
return `${datePart[2]}/${datePart[1]}/${datePart[0]}`;
|
||||
}
|
||||
|
||||
|
||||
function to_iso_date(dateStr) {
|
||||
if (!dateStr) return null;
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
<?php
|
||||
// app/assets/utils/app_registry.php
|
||||
//
|
||||
// The apps a user can be given access to (user.app_access and
|
||||
// company_map_user.app_access), with the label, icon, badge colour and home
|
||||
// page of each. Used by the app switcher, the login redirect and the Users
|
||||
// Access page.
|
||||
//
|
||||
// config.php may define its own $app_registry. Configs written before Asset
|
||||
// Management existed (every Docker-generated config.php among them) list fewer
|
||||
// apps and have no 'home', so missing apps and keys are filled in from the
|
||||
// defaults below. Without a registry the Add User dialog breaks
|
||||
// (Object.entries(null) in setting/users.php) and inviting a user fails
|
||||
// (array_keys(null) in setting/api/engine/manage_users.php).
|
||||
//
|
||||
// Keys must stay within the user.app_access enum: 'wms', 'accounting', 'asset'
|
||||
// ('all' is implied and never listed here).
|
||||
|
||||
$_default_app_registry = [
|
||||
'wms' => ['label' => 'WMS', 'icon' => 'ti-box', 'color' => 'bg-label-primary', 'home' => 'dashboard/index.php'],
|
||||
'accounting' => ['label' => 'Accounting', 'icon' => 'ti-calculator', 'color' => 'bg-label-info', 'home' => 'ac_dashboard/index.php'],
|
||||
'asset' => ['label' => 'Asset Management', 'icon' => 'ti-building-warehouse', 'color' => 'bg-label-success', 'home' => 'asset_dashboard/index.php'],
|
||||
];
|
||||
|
||||
if (!isset($app_registry) || !is_array($app_registry) || !$app_registry) {
|
||||
$app_registry = $_default_app_registry;
|
||||
} else {
|
||||
foreach ($_default_app_registry as $_registry_key => $_registry_defaults) {
|
||||
$app_registry[$_registry_key] = array_merge($_registry_defaults, $app_registry[$_registry_key] ?? []);
|
||||
}
|
||||
}
|
||||
unset($_default_app_registry, $_registry_key, $_registry_defaults);
|
||||
|
||||
if (!function_exists('app_can_access')) {
|
||||
/** 'all' opens every app. */
|
||||
function app_can_access(string $access, string $app): bool
|
||||
{
|
||||
return $access === 'all' || $access === $app;
|
||||
}
|
||||
|
||||
/** The app a user lands in: their single app, or WMS for 'all'. */
|
||||
function app_default_for_access(array $registry, string $access): string
|
||||
{
|
||||
return ($access !== 'all' && isset($registry[$access])) ? $access : 'wms';
|
||||
}
|
||||
|
||||
/** An app's home page, relative to $server_url. */
|
||||
function app_home_path(array $registry, string $app): string
|
||||
{
|
||||
return $registry[$app]['home'] ?? 'dashboard/index.php';
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,7 @@ class CompanyProfileManager
|
||||
|
||||
public function saveProfile(array $data, string $company_logo, string $company_seal): void
|
||||
{
|
||||
$channel = preg_replace('/[^a-z0-9\-_]/', '', strtolower(trim($data['channel_name'] ?? '')));
|
||||
$channel = strtolower(preg_replace('/[^a-z0-9\-_]/', '', $data['channel_name'] ?? ''));
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"UPDATE company_list SET
|
||||
|
||||
@@ -33,7 +33,6 @@ class DocumentNumberManager
|
||||
'manual' => ['name' => 'Manual Journal', 'prefix' => 'JV', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
||||
'return' => ['name' => 'Customer Return', 'prefix' => 'RET', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
||||
'supplier_return' => ['name' => 'Supplier Return', 'prefix' => 'SRN', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
||||
'asset' => ['name' => 'Fixed Asset', 'prefix' => 'FA', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 0],
|
||||
];
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id)
|
||||
|
||||
@@ -79,8 +79,7 @@ class StockManager {
|
||||
public function getStockList(int $warehouse_id, string $type): array
|
||||
{
|
||||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||||
// The transfer list shows the outbound row, whose quantity is in `out` (its `in` is always 0).
|
||||
$column = in_array($type, ['out', 'transfer'], true) ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)';
|
||||
$column = $type === 'out' ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)';
|
||||
$stock_ref = $this->stockReferenceSql();
|
||||
|
||||
// Transfer list: show only the outbound side (out > 0) to avoid duplicate display
|
||||
@@ -440,9 +439,8 @@ class StockManager {
|
||||
);
|
||||
}
|
||||
|
||||
// Quantity and identifiers come from the existing stock_in row (immutable).
|
||||
// `in` is decimal(18,4) — an int cast would drop fractional quantities.
|
||||
$quantity = (float)$source_stock['in'];
|
||||
// Quantity and identifiers come from the existing stock_in row (immutable)
|
||||
$quantity = (int)$source_stock['in'];
|
||||
$ref_id = (int)$source_stock['id'];
|
||||
$lot_number = $source_stock['lot_number'] ?? null;
|
||||
$serial_number = $source_stock['serial_number'] ?? null;
|
||||
@@ -613,9 +611,8 @@ class StockManager {
|
||||
);
|
||||
}
|
||||
|
||||
// Quantity and identifiers come from the source stock_in row (immutable).
|
||||
// `in` is decimal(18,4) — an int cast would drop fractional quantities.
|
||||
$quantity = (float)$source_stock['in'];
|
||||
// Quantity and identifiers come from the source stock_in row (immutable)
|
||||
$quantity = (int)$source_stock['in'];
|
||||
$lot_number = $source_stock['lot_number'] ?? null;
|
||||
$serial_number = $source_stock['serial_number'] ?? null;
|
||||
|
||||
|
||||
@@ -426,7 +426,7 @@ class UserManager {
|
||||
* within the owner's license.
|
||||
*
|
||||
* @param int $map_id The company_map_user.map_id to update.
|
||||
* @param string $app_access New value: 'wms', 'accounting', 'asset', or 'all'.
|
||||
* @param string $app_access New value: 'wms', 'accounting', or 'all'.
|
||||
* @throws Exception If the member is not found or is the owner.
|
||||
*/
|
||||
public function updateAppAccess(int $map_id, string $app_access): void {
|
||||
|
||||
@@ -697,7 +697,7 @@ class FinancialReports
|
||||
COALESCE(d.dept_code, '') AS dept_code,
|
||||
COALESCE(d.dept_name, '') AS dept_name,
|
||||
COALESCE(g.journal_date, DATE(g.created_at)) AS entry_date,
|
||||
DATE_FORMAT(g.created_at, '%Y-%m-%d %H:%i:%s') AS posted_at,
|
||||
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
|
||||
i.debit,
|
||||
i.credit,
|
||||
COALESCE(i.description, '') AS line_description
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../classes_as/AssetPosting.php';
|
||||
|
||||
class GlQueryManager
|
||||
{
|
||||
private PDO $pdo;
|
||||
@@ -8,10 +6,8 @@ class GlQueryManager
|
||||
|
||||
private array $invoiceTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note'];
|
||||
private array $mappingTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'purchase_order'];
|
||||
private array $postingTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'receipt', 'payment', 'purchase_order',
|
||||
'asset_capitalization', 'asset_depreciation', 'asset_amortization', 'asset_disposal'];
|
||||
private array $journalTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'receipt', 'payment', 'manual', 'purchase_order', 'reversal',
|
||||
'asset_capitalization', 'asset_depreciation', 'asset_amortization', 'asset_disposal'];
|
||||
private array $postingTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'receipt', 'payment', 'purchase_order'];
|
||||
private array $journalTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'receipt', 'payment', 'manual', 'purchase_order', 'reversal'];
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id)
|
||||
{
|
||||
@@ -29,11 +25,6 @@ class GlQueryManager
|
||||
throw new Exception('Invalid doc_type.');
|
||||
}
|
||||
|
||||
// Asset Management sources have no formula; accounts come from the asset category.
|
||||
if (AssetPosting::isAssetSource($doc_type)) {
|
||||
return (new AssetPosting($this->pdo, $this->companyId))->getPostableDocuments($doc_type, $date_from, $date_to);
|
||||
}
|
||||
|
||||
if (in_array($doc_type, $this->invoiceTypes, true)) {
|
||||
$rows = $this->getInvoicePostableDocuments($doc_type, $date_from, $date_to, $formula_id);
|
||||
} elseif ($doc_type === 'receipt') {
|
||||
@@ -84,17 +75,13 @@ class GlQueryManager
|
||||
g.current_version,
|
||||
g.formula_id,
|
||||
COALESCE(f.formula_name, '') AS formula_name,
|
||||
DATE_FORMAT(g.created_at, '%Y-%m-%d %H:%i:%s') AS posted_at,
|
||||
DATE_FORMAT(g.updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at,
|
||||
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
|
||||
DATE_FORMAT(g.updated_at, '%d/%m/%Y %H:%i') AS updated_at,
|
||||
CASE g.source_type
|
||||
WHEN 'receipt' THEN r.receipt_number
|
||||
WHEN 'payment' THEN p.payment_number
|
||||
WHEN 'manual' THEN IF(g.reference != '', g.reference, CONCAT('MJE-', g.id))
|
||||
WHEN 'purchase_order' THEN po.po_number
|
||||
WHEN 'asset_capitalization' THEN g.reference
|
||||
WHEN 'asset_depreciation' THEN g.reference
|
||||
WHEN 'asset_amortization' THEN g.reference
|
||||
WHEN 'asset_disposal' THEN g.reference
|
||||
ELSE i.invoice_number
|
||||
END AS doc_number,
|
||||
COALESCE(
|
||||
@@ -103,10 +90,6 @@ class GlQueryManager
|
||||
WHEN 'payment' THEN cp.contact_name
|
||||
WHEN 'manual' THEN g.description
|
||||
WHEN 'purchase_order' THEN cpo.contact_name
|
||||
WHEN 'asset_capitalization' THEN g.description
|
||||
WHEN 'asset_depreciation' THEN g.description
|
||||
WHEN 'asset_amortization' THEN g.description
|
||||
WHEN 'asset_disposal' THEN g.description
|
||||
ELSE ci.contact_name
|
||||
END, ''
|
||||
) AS contact_name,
|
||||
@@ -118,8 +101,7 @@ class GlQueryManager
|
||||
LEFT JOIN td_gl_item gi
|
||||
ON gi.company_id = g.company_id AND gi.gl_id = g.id
|
||||
LEFT JOIN td_invoice i
|
||||
ON g.source_type NOT IN ('receipt','payment','manual','purchase_order',
|
||||
'asset_capitalization','asset_depreciation','asset_amortization','asset_disposal')
|
||||
ON g.source_type NOT IN ('receipt','payment','manual','purchase_order')
|
||||
AND i.company_id = g.company_id AND i.id = g.source_id
|
||||
LEFT JOIN md_contact ci
|
||||
ON ci.company_id = g.company_id AND ci.id = i.contact_id
|
||||
@@ -160,8 +142,8 @@ class GlQueryManager
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT g.*,
|
||||
DATE_FORMAT(g.created_at, '%Y-%m-%d %H:%i:%s') AS posted_at,
|
||||
DATE_FORMAT(g.updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at_fmt,
|
||||
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
|
||||
DATE_FORMAT(g.updated_at, '%d/%m/%Y %H:%i') AS updated_at_fmt,
|
||||
DATE_FORMAT(g.journal_date, '%d/%m/%Y') AS journal_date_fmt,
|
||||
COALESCE(f.formula_name, '') AS formula_name
|
||||
FROM td_gl g
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* AssetCategoryManager
|
||||
*
|
||||
* Asset categories decide the asset class (tangible = depreciation,
|
||||
* intangible = amortization), the default useful life, and every GL account
|
||||
* the Asset Management postings use.
|
||||
*/
|
||||
class AssetCategoryManager
|
||||
{
|
||||
public const CLASSES = ['tangible', 'intangible'];
|
||||
|
||||
public const ACCOUNT_FIELDS = [
|
||||
'cost_account_code' => 'Asset cost account',
|
||||
'accum_account_code' => 'Accumulated depreciation / amortization account',
|
||||
'expense_account_code' => 'Depreciation / amortization expense account',
|
||||
'gain_account_code' => 'Gain on disposal account',
|
||||
'loss_account_code' => 'Loss on disposal account',
|
||||
];
|
||||
|
||||
private PDO $pdo;
|
||||
private int $companyId;
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->companyId = $company_id;
|
||||
}
|
||||
|
||||
public static function isPostingAccount(PDO $pdo, int $company_id, string $account_code): bool
|
||||
{
|
||||
if ($account_code === '') return false;
|
||||
$sth = $pdo->prepare(
|
||||
"SELECT COUNT(*) FROM md_account
|
||||
WHERE company_id = :cid AND account_code = :code AND is_posting = 1 AND status = 1"
|
||||
);
|
||||
$sth->execute([':cid' => $company_id, ':code' => $account_code]);
|
||||
return (int)$sth->fetchColumn() > 0;
|
||||
}
|
||||
|
||||
public function getAll(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT c.*, COUNT(a.id) AS asset_count
|
||||
FROM md_asset_category c
|
||||
LEFT JOIN td_asset a
|
||||
ON a.company_id = c.company_id
|
||||
AND a.category_id = c.id
|
||||
AND a.status <> 4
|
||||
WHERE c.company_id = :cid
|
||||
GROUP BY c.id
|
||||
ORDER BY c.category_code ASC"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function getById(int $id): ?array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM md_asset_category WHERE company_id = :cid AND id = :id LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
return $sth->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
}
|
||||
|
||||
public function save(array $data): int
|
||||
{
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
$code = strtoupper(trim((string)($data['category_code'] ?? '')));
|
||||
$name = trim((string)($data['category_name'] ?? ''));
|
||||
$class = trim((string)($data['asset_class'] ?? ''));
|
||||
$life = (int)($data['useful_life_months'] ?? 0);
|
||||
|
||||
if ($code === '' || $name === '') throw new Exception('Category code and name are required.');
|
||||
if (!in_array($class, self::CLASSES, true)) throw new Exception('Select tangible or intangible.');
|
||||
if ($life < 1 || $life > 1200) throw new Exception('Default useful life must be between 1 and 1200 months.');
|
||||
|
||||
$accounts = [];
|
||||
foreach (self::ACCOUNT_FIELDS as $field => $label) {
|
||||
$account_code = trim((string)($data[$field] ?? ''));
|
||||
if ($account_code === '') throw new Exception("{$label} is required.");
|
||||
if (!self::isPostingAccount($this->pdo, $this->companyId, $account_code)) {
|
||||
throw new Exception("{$label} {$account_code} is not an active posting account.");
|
||||
}
|
||||
$accounts[$field] = $account_code;
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM md_asset_category
|
||||
WHERE company_id = :cid AND category_code = :code AND id <> :id"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':code' => $code, ':id' => $id]);
|
||||
if ((int)$sth->fetchColumn() > 0) throw new Exception("Category code {$code} already exists.");
|
||||
|
||||
$params = array_merge([
|
||||
':cid' => $this->companyId,
|
||||
':code' => $code,
|
||||
':name' => $name,
|
||||
':class' => $class,
|
||||
':life' => $life,
|
||||
':desc' => trim((string)($data['description'] ?? '')),
|
||||
':status' => (int)($data['status'] ?? 1) === 1 ? 1 : 0,
|
||||
], [
|
||||
':cost_acc' => $accounts['cost_account_code'],
|
||||
':accum_acc' => $accounts['accum_account_code'],
|
||||
':expense_acc' => $accounts['expense_account_code'],
|
||||
':gain_acc' => $accounts['gain_account_code'],
|
||||
':loss_acc' => $accounts['loss_account_code'],
|
||||
]);
|
||||
|
||||
if ($id) {
|
||||
$existing = $this->getById($id);
|
||||
if (!$existing) throw new Exception('Category not found.');
|
||||
if ($existing['asset_class'] !== $class && $this->countAssets($id) > 0) {
|
||||
throw new Exception('The asset class cannot change once assets use this category.');
|
||||
}
|
||||
|
||||
$params[':id'] = $id;
|
||||
$this->pdo->prepare(
|
||||
"UPDATE md_asset_category SET
|
||||
category_code = :code, category_name = :name, asset_class = :class,
|
||||
useful_life_months = :life, description = :desc, status = :status,
|
||||
cost_account_code = :cost_acc, accum_account_code = :accum_acc,
|
||||
expense_account_code = :expense_acc, gain_account_code = :gain_acc,
|
||||
loss_account_code = :loss_acc, updated_at = NOW()
|
||||
WHERE company_id = :cid AND id = :id"
|
||||
)->execute($params);
|
||||
return $id;
|
||||
}
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO md_asset_category
|
||||
(company_id, category_code, category_name, asset_class, useful_life_months, description, status,
|
||||
cost_account_code, accum_account_code, expense_account_code, gain_account_code, loss_account_code,
|
||||
created_at)
|
||||
VALUES
|
||||
(:cid, :code, :name, :class, :life, :desc, :status,
|
||||
:cost_acc, :accum_acc, :expense_acc, :gain_acc, :loss_acc,
|
||||
NOW())"
|
||||
)->execute($params);
|
||||
return (int)$this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/** Categories are only deactivated: existing assets keep their accounts. */
|
||||
public function deactivate(int $id): void
|
||||
{
|
||||
$this->pdo->prepare(
|
||||
"UPDATE md_asset_category SET status = 0, updated_at = NOW() WHERE company_id = :cid AND id = :id"
|
||||
)->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
}
|
||||
|
||||
public function getStats(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) AS total,
|
||||
COALESCE(SUM(status = 1), 0) AS active,
|
||||
COALESCE(SUM(status = 0), 0) AS inactive
|
||||
FROM md_asset_category WHERE company_id = :cid"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
return $sth->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
}
|
||||
|
||||
private function countAssets(int $category_id): int
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM td_asset WHERE company_id = :cid AND category_id = :id AND status <> 4"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $category_id]);
|
||||
return (int)$sth->fetchColumn();
|
||||
}
|
||||
}
|
||||
@@ -1,596 +0,0 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/DepreciationCalculator.php';
|
||||
require_once __DIR__ . '/AssetCategoryManager.php';
|
||||
require_once __DIR__ . '/../classes/DocumentNumberManager.php';
|
||||
|
||||
/**
|
||||
* AssetManager
|
||||
*
|
||||
* The asset register: create (manually or from a purchase invoice line),
|
||||
* activate, void, dispose of, and undo a disposal.
|
||||
*
|
||||
* Status: 0 draft, 1 active, 2 fully depreciated, 3 disposed, 4 void.
|
||||
* GL entries are not posted here — Accounting posts them from Batch GL Entries.
|
||||
* Voiding reverses entries that were already posted.
|
||||
*/
|
||||
class AssetManager
|
||||
{
|
||||
public const STATUS_DRAFT = 0;
|
||||
public const STATUS_ACTIVE = 1;
|
||||
public const STATUS_FULLY = 2;
|
||||
public const STATUS_DISPOSED = 3;
|
||||
public const STATUS_VOID = 4;
|
||||
|
||||
private PDO $pdo;
|
||||
private int $companyId;
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->companyId = $company_id;
|
||||
}
|
||||
|
||||
// ── read ─────────────────────────────────────────────────────────────────
|
||||
|
||||
public function getList(int $invoice_id = 0): array
|
||||
{
|
||||
$where = ['a.company_id = :cid'];
|
||||
$params = [':cid' => $this->companyId, ':cid_d' => $this->companyId];
|
||||
if ($invoice_id > 0) {
|
||||
$where[] = 'a.invoice_id = :invoice_id';
|
||||
$params[':invoice_id'] = $invoice_id;
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare($this->baseSelect() . ' WHERE ' . implode(' AND ', $where) . ' ORDER BY a.id DESC');
|
||||
$sth->execute($params);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function getById(int $id): ?array
|
||||
{
|
||||
$sth = $this->pdo->prepare($this->baseSelect() . ' WHERE a.company_id = :cid AND a.id = :id LIMIT 1');
|
||||
$sth->execute([':cid' => $this->companyId, ':cid_d' => $this->companyId, ':id' => $id]);
|
||||
return $sth->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
}
|
||||
|
||||
/** Asset, month-by-month schedule (recorded + projected) and its GL entries. */
|
||||
public function getDetail(int $id): array
|
||||
{
|
||||
$asset = $this->getById($id);
|
||||
if (!$asset) throw new Exception('Asset not found.');
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT d.period, d.amount, d.accumulated_after, d.book_value_after, d.run_id,
|
||||
COALESCE(r.run_number, '') AS run_number
|
||||
FROM td_asset_depreciation d
|
||||
LEFT JOIN td_asset_run r ON r.company_id = d.company_id AND r.id = d.run_id
|
||||
WHERE d.company_id = :cid AND d.asset_id = :id
|
||||
ORDER BY d.period ASC"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
$recorded = [];
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$recorded[$row['period']] = $row;
|
||||
}
|
||||
|
||||
$cost = (float)$asset['cost'];
|
||||
$salvage = (float)$asset['salvage_value'];
|
||||
$life = (int)$asset['useful_life_months'];
|
||||
$disposal_period = $asset['disposal_date'] ? DepreciationCalculator::periodOf($asset['disposal_date']) : '';
|
||||
|
||||
$schedule = [];
|
||||
if ($asset['in_service_date'] && $life > 0 && $cost > $salvage) {
|
||||
foreach (DepreciationCalculator::schedule($cost, $salvage, $life, $asset['in_service_date']) as $row) {
|
||||
if (isset($recorded[$row['period']])) {
|
||||
$r = $recorded[$row['period']];
|
||||
$row = [
|
||||
'period' => $row['period'],
|
||||
'amount' => (float)$r['amount'],
|
||||
'accumulated' => (float)$r['accumulated_after'],
|
||||
'book_value' => (float)$r['book_value_after'],
|
||||
'state' => 'recorded',
|
||||
'run_id' => (int)$r['run_id'],
|
||||
'run_number' => $r['run_number'],
|
||||
];
|
||||
} else {
|
||||
$stopped = $disposal_period !== '' && $row['period'] >= $disposal_period;
|
||||
$row['state'] = $stopped || (int)$asset['status'] === self::STATUS_VOID ? 'stopped' : 'projected';
|
||||
}
|
||||
$schedule[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'asset' => $asset,
|
||||
'schedule' => $schedule,
|
||||
'monthly_amount' => $life > 0 ? DepreciationCalculator::monthlyAmount($cost, $salvage, $life) : 0,
|
||||
'gl' => [
|
||||
'capitalization' => $this->glEntry('asset_capitalization', $id),
|
||||
'disposal' => $this->glEntry('asset_disposal', $id),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* One purchase invoice line, the part of it already capitalized, and the
|
||||
* account its purchase posting debited (the default clearing account).
|
||||
*/
|
||||
public function getInvoiceLine(int $invoice_id, int $item_id, int $exclude_asset_id = 0): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT i.id AS invoice_id, i.invoice_number, i.issued_date, i.contact_id, i.department_id,
|
||||
i.status AS invoice_status, i.formula_id, i.grand_total, i.tax,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
it.id AS item_id, it.product_sku, it.product_name, it.quantity, it.unit_price, it.total_price,
|
||||
COALESCE(NULLIF(p.purchase_account_code, ''), '') AS product_account_code
|
||||
FROM td_invoice i
|
||||
JOIN td_invoice_item it
|
||||
ON it.company_id = i.company_id AND it.invoice_id = i.id
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = i.company_id AND c.id = i.contact_id
|
||||
LEFT JOIN md_product p
|
||||
ON p.company_id = it.company_id AND p.sku = it.product_sku
|
||||
WHERE i.company_id = :cid AND i.id = :invoice_id AND it.id = :item_id
|
||||
AND i.doc_type = 'purchase_invoice'
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':invoice_id' => $invoice_id, ':item_id' => $item_id]);
|
||||
$line = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$line) throw new Exception('Purchase invoice line not found.');
|
||||
if (!in_array((int)$line['invoice_status'], [1, 2], true)) {
|
||||
throw new Exception('Only issued or paid purchase invoices can be turned into assets.');
|
||||
}
|
||||
|
||||
$line['line_amount'] = $this->postedLineAmount($invoice_id, (float)$line['total_price'], (float)$line['grand_total'], (float)$line['tax']);
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COALESCE(SUM(cost), 0) FROM td_asset
|
||||
WHERE company_id = :cid AND invoice_item_id = :item_id AND status <> 4 AND id <> :exclude"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':item_id' => $item_id, ':exclude' => $exclude_asset_id]);
|
||||
$line['capitalized'] = round((float)$sth->fetchColumn(), 2);
|
||||
$line['remaining'] = round($line['line_amount'] - $line['capitalized'], 2);
|
||||
|
||||
$line['clearing_account_code'] = $line['product_account_code'] !== ''
|
||||
? $line['product_account_code']
|
||||
: $this->formulaTotalAccount((int)$line['formula_id']);
|
||||
|
||||
return $line;
|
||||
}
|
||||
|
||||
/** Issued purchase invoice lines with how much of each has been capitalized. */
|
||||
public function getCapitalizableLines(string $date_from = '', string $date_to = ''): array
|
||||
{
|
||||
$where = ['i.company_id = :cid', "i.doc_type = 'purchase_invoice'", 'i.status IN (1, 2)'];
|
||||
$params = [':cid' => $this->companyId, ':cid_s' => $this->companyId, ':cid_x' => $this->companyId];
|
||||
if ($date_from = $this->parseDate($date_from)) { $where[] = 'i.issued_date >= :date_from'; $params[':date_from'] = $date_from; }
|
||||
if ($date_to = $this->parseDate($date_to)) { $where[] = 'i.issued_date <= :date_to'; $params[':date_to'] = $date_to; }
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT i.id AS invoice_id, i.invoice_number, i.issued_date, i.status AS invoice_status,
|
||||
i.grand_total, i.tax, s.sum_total,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
it.id AS item_id, it.product_sku, it.product_name, it.quantity, it.total_price,
|
||||
COALESCE(x.capitalized, 0) AS capitalized,
|
||||
COALESCE(x.asset_count, 0) AS asset_count
|
||||
FROM td_invoice i
|
||||
JOIN td_invoice_item it
|
||||
ON it.company_id = i.company_id AND it.invoice_id = i.id
|
||||
JOIN (SELECT invoice_id, SUM(ABS(total_price)) AS sum_total
|
||||
FROM td_invoice_item WHERE company_id = :cid_s GROUP BY invoice_id) s
|
||||
ON s.invoice_id = i.id
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = i.company_id AND c.id = i.contact_id
|
||||
LEFT JOIN (SELECT invoice_item_id, SUM(cost) AS capitalized, COUNT(*) AS asset_count
|
||||
FROM td_asset WHERE company_id = :cid_x AND status <> 4 GROUP BY invoice_item_id) x
|
||||
ON x.invoice_item_id = it.id
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
ORDER BY i.issued_date DESC, i.id DESC, it.item_id ASC"
|
||||
);
|
||||
$sth->execute($params);
|
||||
|
||||
$rows = [];
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$sum = (float)$row['sum_total'];
|
||||
$row['line_amount'] = $sum > 0
|
||||
? round(((float)$row['grand_total'] - (float)$row['tax']) * abs((float)$row['total_price']) / $sum, 2)
|
||||
: 0.0;
|
||||
$row['capitalized'] = round((float)$row['capitalized'], 2);
|
||||
$row['remaining'] = round($row['line_amount'] - $row['capitalized'], 2);
|
||||
unset($row['grand_total'], $row['tax'], $row['sum_total']);
|
||||
$rows[] = $row;
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public function getStats(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) AS total,
|
||||
COALESCE(SUM(a.status = 0), 0) AS draft,
|
||||
COALESCE(SUM(a.status = 1), 0) AS active,
|
||||
COALESCE(SUM(a.status = 2), 0) AS fully_depreciated,
|
||||
COALESCE(SUM(a.status = 3), 0) AS disposed,
|
||||
COALESCE(SUM(IF(a.status IN (1, 2), a.cost, 0)), 0) AS cost_in_use,
|
||||
COALESCE(SUM(IF(a.status IN (1, 2), COALESCE(d.accumulated, 0), 0)), 0) AS accumulated_in_use
|
||||
FROM td_asset a
|
||||
LEFT JOIN (SELECT asset_id, SUM(amount) AS accumulated
|
||||
FROM td_asset_depreciation WHERE company_id = :cid_d GROUP BY asset_id) d
|
||||
ON d.asset_id = a.id
|
||||
WHERE a.company_id = :cid AND a.status <> 4"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':cid_d' => $this->companyId]);
|
||||
$stats = $sth->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
$stats['book_value_in_use'] = round((float)($stats['cost_in_use'] ?? 0) - (float)($stats['accumulated_in_use'] ?? 0), 2);
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT c.id, c.category_code, c.category_name, c.asset_class,
|
||||
COUNT(a.id) AS asset_count,
|
||||
COALESCE(SUM(a.cost), 0) AS cost,
|
||||
COALESCE(SUM(COALESCE(d.accumulated, 0)), 0) AS accumulated
|
||||
FROM td_asset a
|
||||
JOIN md_asset_category c
|
||||
ON c.company_id = a.company_id AND c.id = a.category_id
|
||||
LEFT JOIN (SELECT asset_id, SUM(amount) AS accumulated
|
||||
FROM td_asset_depreciation WHERE company_id = :cid_d GROUP BY asset_id) d
|
||||
ON d.asset_id = a.id
|
||||
WHERE a.company_id = :cid AND a.status IN (1, 2)
|
||||
GROUP BY c.id, c.category_code, c.category_name, c.asset_class
|
||||
ORDER BY c.category_code ASC"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':cid_d' => $this->companyId]);
|
||||
$stats['by_category'] = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
// ── write ────────────────────────────────────────────────────────────────
|
||||
|
||||
public function save(array $data, int $user_id): int
|
||||
{
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
$existing = $id ? $this->getById($id) : null;
|
||||
if ($id && !$existing) throw new Exception('Asset not found.');
|
||||
if ($existing && in_array((int)$existing['status'], [self::STATUS_DISPOSED, self::STATUS_VOID], true)) {
|
||||
throw new Exception('Disposed or void assets cannot be edited.');
|
||||
}
|
||||
|
||||
$name = trim((string)($data['asset_name'] ?? ''));
|
||||
if ($name === '') throw new Exception('Asset name is required.');
|
||||
$description = trim((string)($data['description'] ?? ''));
|
||||
$department_id = (int)($data['department_id'] ?? 0);
|
||||
|
||||
// Once a month has been charged, the numbers behind the schedule are fixed.
|
||||
if ($existing && $this->hasDepreciation($id)) {
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_asset SET asset_name = :name, description = :desc, department_id = :dept, updated_at = NOW()
|
||||
WHERE company_id = :cid AND id = :id"
|
||||
)->execute([':name' => $name, ':desc' => $description, ':dept' => $department_id, ':cid' => $this->companyId, ':id' => $id]);
|
||||
return $id;
|
||||
}
|
||||
|
||||
$category = (new AssetCategoryManager($this->pdo, $this->companyId))->getById((int)($data['category_id'] ?? 0));
|
||||
if (!$category) throw new Exception('Select an asset category.');
|
||||
$same_category = $existing && (int)$existing['category_id'] === (int)$category['id'];
|
||||
if ((int)$category['status'] !== 1 && !$same_category) throw new Exception('The selected category is inactive.');
|
||||
|
||||
$cost = round((float)($data['cost'] ?? 0), 2);
|
||||
$salvage = round((float)($data['salvage_value'] ?? 0), 2);
|
||||
$life = (int)($data['useful_life_months'] ?? 0);
|
||||
DepreciationCalculator::assertInputs($cost, $salvage, $life);
|
||||
|
||||
$acquisition = $this->parseDate((string)($data['acquisition_date'] ?? ''));
|
||||
$in_service = $this->parseDate((string)($data['in_service_date'] ?? '')) ?: $acquisition;
|
||||
if ($acquisition === '') throw new Exception('Acquisition date is required.');
|
||||
if ($in_service < $acquisition) throw new Exception('The in-service date cannot be before the acquisition date.');
|
||||
|
||||
$clearing = trim((string)($data['clearing_account_code'] ?? ''));
|
||||
if ($clearing !== '' && !AssetCategoryManager::isPostingAccount($this->pdo, $this->companyId, $clearing)) {
|
||||
throw new Exception("Clearing account {$clearing} is not an active posting account.");
|
||||
}
|
||||
|
||||
$invoice_id = $existing ? (int)$existing['invoice_id'] : (int)($data['invoice_id'] ?? 0);
|
||||
$item_id = $existing ? (int)$existing['invoice_item_id'] : (int)($data['invoice_item_id'] ?? 0);
|
||||
$contact_id = $existing ? (int)$existing['contact_id'] : 0;
|
||||
$source = 'manual';
|
||||
|
||||
if ($item_id > 0) {
|
||||
$line = $this->getInvoiceLine($invoice_id, $item_id, $id);
|
||||
if ($cost > $line['remaining'] + 0.004) {
|
||||
throw new Exception('Cost is more than the part of this invoice line not yet capitalized (' . number_format($line['remaining'], 2) . ').');
|
||||
}
|
||||
if ($clearing === '') {
|
||||
throw new Exception('Assets from a purchase invoice need the clearing account the invoice was posted to.');
|
||||
}
|
||||
$contact_id = (int)$line['contact_id'];
|
||||
$source = 'purchase_invoice';
|
||||
}
|
||||
|
||||
$params = [
|
||||
':cid' => $this->companyId,
|
||||
':name' => $name,
|
||||
':category_id' => (int)$category['id'],
|
||||
':class' => $category['asset_class'],
|
||||
':desc' => $description,
|
||||
':dept' => $department_id,
|
||||
':acquisition' => $acquisition,
|
||||
':in_service' => $in_service,
|
||||
':cost' => $cost,
|
||||
':salvage' => $salvage,
|
||||
':life' => $life,
|
||||
':clearing' => $clearing,
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$params[':id'] = $id;
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_asset SET
|
||||
asset_name = :name, category_id = :category_id, asset_class = :class, description = :desc,
|
||||
department_id = :dept, acquisition_date = :acquisition, in_service_date = :in_service,
|
||||
cost = :cost, salvage_value = :salvage, useful_life_months = :life,
|
||||
clearing_account_code = :clearing, updated_at = NOW()
|
||||
WHERE company_id = :cid AND id = :id"
|
||||
)->execute($params);
|
||||
return $id;
|
||||
}
|
||||
|
||||
$params += [
|
||||
':number' => (new DocumentNumberManager($this->pdo, $this->companyId))->generate('asset'),
|
||||
':source' => $source,
|
||||
':invoice_id' => $invoice_id,
|
||||
':item_id' => $item_id,
|
||||
':contact_id' => $contact_id,
|
||||
':user_id' => $user_id,
|
||||
];
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO td_asset
|
||||
(company_id, asset_number, asset_name, category_id, asset_class, description, source,
|
||||
invoice_id, invoice_item_id, contact_id, department_id, acquisition_date, in_service_date,
|
||||
cost, salvage_value, useful_life_months, clearing_account_code, status, created_by, created_at)
|
||||
VALUES
|
||||
(:cid, :number, :name, :category_id, :class, :desc, :source,
|
||||
:invoice_id, :item_id, :contact_id, :dept, :acquisition, :in_service,
|
||||
:cost, :salvage, :life, :clearing, 0, :user_id, NOW())"
|
||||
)->execute($params);
|
||||
return (int)$this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
public function activate(int $id): void
|
||||
{
|
||||
$asset = $this->requireAsset($id);
|
||||
if ((int)$asset['status'] !== self::STATUS_DRAFT) throw new Exception('Only draft assets can be activated.');
|
||||
|
||||
$category = (new AssetCategoryManager($this->pdo, $this->companyId))->getById((int)$asset['category_id']);
|
||||
if (!$category || (int)$category['status'] !== 1) throw new Exception('The asset category is missing or inactive.');
|
||||
DepreciationCalculator::assertInputs((float)$asset['cost'], (float)$asset['salvage_value'], (int)$asset['useful_life_months']);
|
||||
|
||||
$this->setStatus($id, self::STATUS_ACTIVE);
|
||||
}
|
||||
|
||||
/** Drafts are removed outright (soft delete, like other documents). */
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$asset = $this->requireAsset($id);
|
||||
if ((int)$asset['status'] !== self::STATUS_DRAFT) throw new Exception('Only draft assets can be deleted. Void an active asset instead.');
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_asset SET company_id = company_id * -1, updated_at = NOW() WHERE company_id = :cid AND id = :id"
|
||||
)->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
}
|
||||
|
||||
/** Void an active asset recorded in error. Reverses its capitalization entry if posted. */
|
||||
public function void(int $id, GlManager $gl): void
|
||||
{
|
||||
$asset = $this->requireAsset($id);
|
||||
if (!in_array((int)$asset['status'], [self::STATUS_ACTIVE, self::STATUS_FULLY], true)) {
|
||||
throw new Exception('Only active assets can be voided.');
|
||||
}
|
||||
if ($this->hasDepreciation($id)) {
|
||||
throw new Exception('This asset has depreciation recorded. Void its depreciation runs first.');
|
||||
}
|
||||
|
||||
$gl->delete('asset_capitalization', $id);
|
||||
$this->setStatus($id, self::STATUS_VOID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a sale or write-off. Every month before the disposal month must
|
||||
* already be charged; the disposal month itself gets no charge.
|
||||
*/
|
||||
public function dispose(int $id, array $data): void
|
||||
{
|
||||
$asset = $this->requireAsset($id);
|
||||
if (!in_array((int)$asset['status'], [self::STATUS_ACTIVE, self::STATUS_FULLY], true)) {
|
||||
throw new Exception('Only active assets can be disposed of.');
|
||||
}
|
||||
|
||||
$date = $this->parseDate((string)($data['disposal_date'] ?? ''));
|
||||
if ($date === '') throw new Exception('Disposal date is required.');
|
||||
if ($date < $asset['in_service_date']) throw new Exception('The disposal date cannot be before the in-service date.');
|
||||
|
||||
$sale_price = round((float)($data['sale_price'] ?? 0), 2);
|
||||
if ($sale_price < 0) throw new Exception('Sale price cannot be negative.');
|
||||
|
||||
$proceeds = trim((string)($data['proceeds_account_code'] ?? ''));
|
||||
if ($sale_price > 0 && $proceeds === '') throw new Exception('Select the account that received the sale proceeds.');
|
||||
if ($proceeds !== '' && !AssetCategoryManager::isPostingAccount($this->pdo, $this->companyId, $proceeds)) {
|
||||
throw new Exception("Proceeds account {$proceeds} is not an active posting account.");
|
||||
}
|
||||
|
||||
$disposal_period = DepreciationCalculator::periodOf($date);
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM td_asset_depreciation WHERE company_id = :cid AND asset_id = :id AND period >= :period"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id, ':period' => $disposal_period]);
|
||||
if ((int)$sth->fetchColumn() > 0) {
|
||||
throw new Exception("Depreciation is already recorded for {$disposal_period} or later. Void those runs before disposing of the asset on this date.");
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT period FROM td_asset_depreciation WHERE company_id = :cid AND asset_id = :id");
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
$recorded = array_flip($sth->fetchAll(PDO::FETCH_COLUMN));
|
||||
|
||||
$schedule = DepreciationCalculator::schedule(
|
||||
(float)$asset['cost'], (float)$asset['salvage_value'], (int)$asset['useful_life_months'], $asset['in_service_date']
|
||||
);
|
||||
foreach ($schedule as $row) {
|
||||
if ($row['period'] >= $disposal_period) break;
|
||||
if ($row['amount'] > 0 && !isset($recorded[$row['period']])) {
|
||||
$label = $asset['asset_class'] === 'intangible' ? 'amortization' : 'depreciation';
|
||||
throw new Exception("Run {$label} through " . DepreciationCalculator::addMonths($disposal_period, -1)
|
||||
. " before disposing of this asset (first month not charged: {$row['period']}).");
|
||||
}
|
||||
}
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_asset SET status = :status, disposal_date = :date, sale_price = :price,
|
||||
proceeds_account_code = :proceeds, disposal_notes = :notes, updated_at = NOW()
|
||||
WHERE company_id = :cid AND id = :id"
|
||||
)->execute([
|
||||
':status' => self::STATUS_DISPOSED,
|
||||
':date' => $date,
|
||||
':price' => $sale_price,
|
||||
':proceeds' => $proceeds,
|
||||
':notes' => trim((string)($data['disposal_notes'] ?? '')),
|
||||
':cid' => $this->companyId,
|
||||
':id' => $id,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Reverse a disposal (and its GL entry if posted). */
|
||||
public function undoDisposal(int $id, GlManager $gl): void
|
||||
{
|
||||
$asset = $this->requireAsset($id);
|
||||
if ((int)$asset['status'] !== self::STATUS_DISPOSED) throw new Exception('This asset has not been disposed of.');
|
||||
|
||||
$gl->delete('asset_disposal', $id);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_asset SET status = :status, disposal_date = NULL, sale_price = 0,
|
||||
proceeds_account_code = '', disposal_notes = '', updated_at = NOW()
|
||||
WHERE company_id = :cid AND id = :id"
|
||||
)->execute([':status' => self::STATUS_ACTIVE, ':cid' => $this->companyId, ':id' => $id]);
|
||||
$this->syncStatuses([$id]);
|
||||
}
|
||||
|
||||
/** Set active / fully depreciated from what has been charged so far. */
|
||||
public function syncStatuses(array $asset_ids): void
|
||||
{
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $asset_ids))));
|
||||
if (!$ids) return;
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_asset a
|
||||
LEFT JOIN (SELECT asset_id, SUM(amount) AS accumulated
|
||||
FROM td_asset_depreciation WHERE company_id = :cid_d GROUP BY asset_id) d
|
||||
ON d.asset_id = a.id
|
||||
SET a.status = IF(COALESCE(d.accumulated, 0) >= ROUND(a.cost - a.salvage_value, 2) - 0.004, 2, 1),
|
||||
a.updated_at = NOW()
|
||||
WHERE a.company_id = :cid AND a.status IN (1, 2) AND a.id IN (" . implode(',', $ids) . ")"
|
||||
)->execute([':cid' => $this->companyId, ':cid_d' => $this->companyId]);
|
||||
}
|
||||
|
||||
// ── private ──────────────────────────────────────────────────────────────
|
||||
|
||||
private function baseSelect(): string
|
||||
{
|
||||
return "SELECT a.*,
|
||||
COALESCE(c.category_code, '') AS category_code,
|
||||
COALESCE(c.category_name, '') AS category_name,
|
||||
COALESCE(ct.contact_name, '') AS contact_name,
|
||||
COALESCE(i.invoice_number, '') AS invoice_number,
|
||||
COALESCE(d.accumulated, 0) AS accumulated,
|
||||
COALESCE(d.last_period, '') AS last_period,
|
||||
ROUND(a.cost - COALESCE(d.accumulated, 0), 4) AS book_value
|
||||
FROM td_asset a
|
||||
LEFT JOIN md_asset_category c
|
||||
ON c.company_id = a.company_id AND c.id = a.category_id
|
||||
LEFT JOIN md_contact ct
|
||||
ON ct.company_id = a.company_id AND ct.id = a.contact_id
|
||||
LEFT JOIN td_invoice i
|
||||
ON i.company_id = a.company_id AND i.id = a.invoice_id
|
||||
LEFT JOIN (SELECT asset_id, SUM(amount) AS accumulated, MAX(period) AS last_period
|
||||
FROM td_asset_depreciation WHERE company_id = :cid_d GROUP BY asset_id) d
|
||||
ON d.asset_id = a.id";
|
||||
}
|
||||
|
||||
private function requireAsset(int $id): array
|
||||
{
|
||||
$sth = $this->pdo->prepare("SELECT * FROM td_asset WHERE company_id = :cid AND id = :id FOR UPDATE");
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
$asset = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$asset) throw new Exception('Asset not found.');
|
||||
return $asset;
|
||||
}
|
||||
|
||||
private function setStatus(int $id, int $status): void
|
||||
{
|
||||
$this->pdo->prepare("UPDATE td_asset SET status = :status, updated_at = NOW() WHERE company_id = :cid AND id = :id")
|
||||
->execute([':status' => $status, ':cid' => $this->companyId, ':id' => $id]);
|
||||
}
|
||||
|
||||
private function hasDepreciation(int $id): bool
|
||||
{
|
||||
$sth = $this->pdo->prepare("SELECT COUNT(*) FROM td_asset_depreciation WHERE company_id = :cid AND asset_id = :id");
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
return (int)$sth->fetchColumn() > 0;
|
||||
}
|
||||
|
||||
private function glEntry(string $source_type, int $source_id): ?array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, reference, journal_date, period, current_version,
|
||||
DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS posted_at
|
||||
FROM td_gl WHERE company_id = :cid AND source_type = :type AND source_id = :id LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':type' => $source_type, ':id' => $source_id]);
|
||||
return $sth->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The part of the invoice's pre-tax total the purchase posting put on this
|
||||
* line — PurchaseInvoicePosting splits (grand_total - tax) by line total.
|
||||
*/
|
||||
private function postedLineAmount(int $invoice_id, float $line_total, float $grand_total, float $tax): float
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COALESCE(SUM(ABS(total_price)), 0) FROM td_invoice_item WHERE company_id = :cid AND invoice_id = :id"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $invoice_id]);
|
||||
$sum = (float)$sth->fetchColumn();
|
||||
if ($sum <= 0) return 0.0;
|
||||
return round((abs($grand_total) - abs($tax)) * abs($line_total) / $sum, 2);
|
||||
}
|
||||
|
||||
private function formulaTotalAccount(int $formula_id): string
|
||||
{
|
||||
if ($formula_id <= 0) {
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id FROM md_account_formula
|
||||
WHERE company_id = :cid AND document_type = 'purchase_invoice' AND is_default = 1 AND status = 1
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
$formula_id = (int)$sth->fetchColumn();
|
||||
}
|
||||
if ($formula_id <= 0) return '';
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT account_code FROM md_account_formula_item
|
||||
WHERE company_id = :cid AND formula_id = :fid AND amount_key = 'total' AND drcr = 'D'
|
||||
ORDER BY sort_order ASC LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':fid' => $formula_id]);
|
||||
return (string)($sth->fetchColumn() ?: '');
|
||||
}
|
||||
|
||||
private function parseDate(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') return '';
|
||||
if (preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $value, $m)) return "{$m[3]}-{$m[2]}-{$m[1]}";
|
||||
if (preg_match('#^\d{4}-\d{2}-\d{2}$#', $value)) return $value;
|
||||
throw new Exception("Invalid date '{$value}'.");
|
||||
}
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* AssetPosting
|
||||
*
|
||||
* Builds the GL lines for the four Asset Management sources. Unlike the
|
||||
* formula-based BasePosting classes, the accounts come from the asset category.
|
||||
* Every entry's reference is the asset or run number and every description
|
||||
* starts with "[Asset Mgmt]" so it is recognisable in Accounting.
|
||||
*
|
||||
* asset_capitalization DR asset cost / CR clearing account (the account the purchase invoice hit)
|
||||
* asset_depreciation DR depreciation expense / CR accumulated depreciation (one run, grouped by category + department)
|
||||
* asset_amortization DR amortization expense / CR accumulated amortization
|
||||
* asset_disposal DR proceeds + accumulated (+ loss) / CR asset cost (+ gain)
|
||||
*/
|
||||
class AssetPosting
|
||||
{
|
||||
public const SOURCE_TYPES = ['asset_capitalization', 'asset_depreciation', 'asset_amortization', 'asset_disposal'];
|
||||
public const LABEL = '[Asset Mgmt]';
|
||||
|
||||
private PDO $pdo;
|
||||
private int $companyId;
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->companyId = $company_id;
|
||||
}
|
||||
|
||||
public static function isAssetSource(string $source_type): bool
|
||||
{
|
||||
return in_array($source_type, self::SOURCE_TYPES, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{formula_id: int, period: string, doc_date: string, lines: array, reference: string, description: string}
|
||||
*/
|
||||
public function build(string $source_type, int $id): array
|
||||
{
|
||||
switch ($source_type) {
|
||||
case 'asset_capitalization': $built = $this->buildCapitalization($id); break;
|
||||
case 'asset_depreciation': $built = $this->buildRun($id, 'tangible'); break;
|
||||
case 'asset_amortization': $built = $this->buildRun($id, 'intangible'); break;
|
||||
case 'asset_disposal': $built = $this->buildDisposal($id); break;
|
||||
default: throw new Exception("Unknown asset source '{$source_type}'.");
|
||||
}
|
||||
|
||||
$this->assertBalanced($built['lines'], $built['reference']);
|
||||
|
||||
return [
|
||||
'formula_id' => 0,
|
||||
'period' => substr($built['doc_date'], 0, 7),
|
||||
'doc_date' => $built['doc_date'],
|
||||
'lines' => $built['lines'],
|
||||
'reference' => mb_substr($built['reference'], 0, 100),
|
||||
'description' => mb_substr($built['description'], 0, 255),
|
||||
];
|
||||
}
|
||||
|
||||
/** Rows for the Batch GL Entries tabs, in the same shape GlQueryManager returns. */
|
||||
public function getPostableDocuments(string $source_type, string $date_from, string $date_to): array
|
||||
{
|
||||
$params = [':cid' => $this->companyId, ':source_type' => $source_type];
|
||||
|
||||
if ($source_type === 'asset_depreciation' || $source_type === 'asset_amortization') {
|
||||
$where = ['r.company_id = :cid', 'r.status = 1', 'r.asset_class = :class'];
|
||||
$params[':class'] = $source_type === 'asset_amortization' ? 'intangible' : 'tangible';
|
||||
if ($date_from) { $where[] = 'r.run_date >= :date_from'; $params[':date_from'] = $date_from; }
|
||||
if ($date_to) { $where[] = 'r.run_date <= :date_to'; $params[':date_to'] = $date_to; }
|
||||
|
||||
$sql = "SELECT r.id,
|
||||
r.run_number AS doc_number,
|
||||
CONCAT(r.asset_count, IF(r.asset_count = 1, ' asset', ' assets'), ' · ', r.period) AS contact_name,
|
||||
r.total_amount AS grand_total,
|
||||
r.run_date AS doc_date,
|
||||
IF(g.id IS NULL, 0, 1) AS gl_status,
|
||||
0 AS gl_formula_id, 0 AS product_mapping_checked, 0 AS product_mapping_missing
|
||||
FROM td_asset_run r
|
||||
LEFT JOIN td_gl g
|
||||
ON g.company_id = r.company_id AND g.source_type = :source_type AND g.source_id = r.id
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
ORDER BY r.run_date DESC, r.id DESC";
|
||||
} else {
|
||||
$is_disposal = $source_type === 'asset_disposal';
|
||||
$date_col = $is_disposal ? 'a.disposal_date' : 'a.acquisition_date';
|
||||
$where = ['a.company_id = :cid'];
|
||||
$where[] = $is_disposal ? 'a.status = 3' : "a.status IN (1, 2, 3) AND a.clearing_account_code <> ''";
|
||||
if ($date_from) { $where[] = "{$date_col} >= :date_from"; $params[':date_from'] = $date_from; }
|
||||
if ($date_to) { $where[] = "{$date_col} <= :date_to"; $params[':date_to'] = $date_to; }
|
||||
|
||||
$amount = $is_disposal ? 'a.sale_price' : 'a.cost';
|
||||
$sql = "SELECT a.id,
|
||||
a.asset_number AS doc_number,
|
||||
a.asset_name AS contact_name,
|
||||
{$amount} AS grand_total,
|
||||
{$date_col} AS doc_date,
|
||||
IF(g.id IS NULL, 0, 1) AS gl_status,
|
||||
0 AS gl_formula_id, 0 AS product_mapping_checked, 0 AS product_mapping_missing
|
||||
FROM td_asset a
|
||||
LEFT JOIN td_gl g
|
||||
ON g.company_id = a.company_id AND g.source_type = :source_type AND g.source_id = a.id
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
ORDER BY {$date_col} DESC, a.id DESC";
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare($sql);
|
||||
$sth->execute($params);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
// ── builders ─────────────────────────────────────────────────────────────
|
||||
|
||||
private function buildCapitalization(int $id): array
|
||||
{
|
||||
$asset = $this->fetchAsset($id);
|
||||
if (!in_array((int)$asset['status'], [1, 2, 3], true)) {
|
||||
throw new Exception("Asset {$asset['asset_number']} is not active.");
|
||||
}
|
||||
if ($asset['clearing_account_code'] === '') {
|
||||
throw new Exception("Asset {$asset['asset_number']} has no clearing account, so it has no capitalization entry.");
|
||||
}
|
||||
|
||||
$cost = round((float)$asset['cost'], 2);
|
||||
$text = self::LABEL . " Capitalize {$asset['asset_number']} {$asset['asset_name']}";
|
||||
$dept = (int)$asset['department_id'];
|
||||
|
||||
return [
|
||||
'doc_date' => $asset['acquisition_date'],
|
||||
'reference' => $asset['asset_number'],
|
||||
'description' => $text,
|
||||
'lines' => [
|
||||
$this->line($this->account($asset, 'cost_account_code'), $dept, $cost, 0, $text),
|
||||
$this->line($asset['clearing_account_code'], $dept, 0, $cost, $text),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function buildRun(int $run_id, string $asset_class): array
|
||||
{
|
||||
$sth = $this->pdo->prepare("SELECT * FROM td_asset_run WHERE company_id = :cid AND id = :id LIMIT 1");
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $run_id]);
|
||||
$run = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$run || (int)$run['status'] !== 1) throw new Exception("Asset run #{$run_id} not found or void.");
|
||||
if ($run['asset_class'] !== $asset_class) throw new Exception("Asset run {$run['run_number']} is not a {$asset_class} run.");
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT d.category_id, d.department_id, SUM(d.amount) AS amount,
|
||||
c.category_code, c.category_name, c.expense_account_code, c.accum_account_code
|
||||
FROM td_asset_depreciation d
|
||||
LEFT JOIN md_asset_category c
|
||||
ON c.company_id = d.company_id AND c.id = d.category_id
|
||||
WHERE d.company_id = :cid AND d.run_id = :run_id
|
||||
GROUP BY d.category_id, d.department_id, c.category_code, c.category_name,
|
||||
c.expense_account_code, c.accum_account_code
|
||||
ORDER BY c.category_code ASC, d.department_id ASC"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':run_id' => $run_id]);
|
||||
$groups = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
if (!$groups) throw new Exception("Asset run {$run['run_number']} has no lines.");
|
||||
|
||||
$label = $asset_class === 'intangible' ? 'Amortization' : 'Depreciation';
|
||||
$lines = [];
|
||||
foreach ($groups as $g) {
|
||||
$amount = round((float)$g['amount'], 2);
|
||||
$text = self::LABEL . " {$label} {$run['period']} · {$g['category_code']} {$g['category_name']}";
|
||||
$dept = (int)$g['department_id'];
|
||||
$lines[] = $this->line($this->account($g, 'expense_account_code'), $dept, $amount, 0, $text);
|
||||
$lines[] = $this->line($this->account($g, 'accum_account_code'), $dept, 0, $amount, $text);
|
||||
}
|
||||
|
||||
return [
|
||||
'doc_date' => $run['run_date'],
|
||||
'reference' => $run['run_number'],
|
||||
'description' => self::LABEL . " {$label} {$run['period']} ({$run['asset_count']} assets)",
|
||||
'lines' => $lines,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildDisposal(int $id): array
|
||||
{
|
||||
$asset = $this->fetchAsset($id);
|
||||
if ((int)$asset['status'] !== 3) throw new Exception("Asset {$asset['asset_number']} has not been disposed of.");
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COALESCE(SUM(amount), 0) FROM td_asset_depreciation WHERE company_id = :cid AND asset_id = :id"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
$accumulated = round((float)$sth->fetchColumn(), 2);
|
||||
|
||||
$cost = round((float)$asset['cost'], 2);
|
||||
$sale_price = round((float)$asset['sale_price'], 2);
|
||||
$gain = round($sale_price - ($cost - $accumulated), 2);
|
||||
$dept = (int)$asset['department_id'];
|
||||
$text = self::LABEL . " Disposal {$asset['asset_number']} {$asset['asset_name']}";
|
||||
|
||||
$lines = [];
|
||||
if ($sale_price > 0) {
|
||||
if ($asset['proceeds_account_code'] === '') throw new Exception("Asset {$asset['asset_number']} has a sale price but no proceeds account.");
|
||||
$lines[] = $this->line($asset['proceeds_account_code'], $dept, $sale_price, 0, $text . ' · proceeds');
|
||||
}
|
||||
if ($accumulated > 0) {
|
||||
$lines[] = $this->line($this->account($asset, 'accum_account_code'), $dept, $accumulated, 0, $text . ' · accumulated');
|
||||
}
|
||||
if ($gain < 0) {
|
||||
$lines[] = $this->line($this->account($asset, 'loss_account_code'), $dept, -$gain, 0, $text . ' · loss');
|
||||
}
|
||||
$lines[] = $this->line($this->account($asset, 'cost_account_code'), $dept, 0, $cost, $text . ' · cost');
|
||||
if ($gain > 0) {
|
||||
$lines[] = $this->line($this->account($asset, 'gain_account_code'), $dept, 0, $gain, $text . ' · gain');
|
||||
}
|
||||
|
||||
return [
|
||||
'doc_date' => $asset['disposal_date'],
|
||||
'reference' => $asset['asset_number'],
|
||||
'description' => $text,
|
||||
'lines' => $lines,
|
||||
];
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private function fetchAsset(int $id): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT a.*, c.category_code, c.category_name, c.cost_account_code, c.accum_account_code,
|
||||
c.expense_account_code, c.gain_account_code, c.loss_account_code
|
||||
FROM td_asset a
|
||||
LEFT JOIN md_asset_category c
|
||||
ON c.company_id = a.company_id AND c.id = a.category_id
|
||||
WHERE a.company_id = :cid AND a.id = :id
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
$asset = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$asset) throw new Exception("Asset #{$id} not found.");
|
||||
return $asset;
|
||||
}
|
||||
|
||||
private function account(array $row, string $field): string
|
||||
{
|
||||
$code = trim((string)($row[$field] ?? ''));
|
||||
if ($code === '') {
|
||||
$category = trim(($row['category_code'] ?? '') . ' ' . ($row['category_name'] ?? ''));
|
||||
throw new Exception("Asset category {$category} has no " . str_replace('_', ' ', $field) . '.');
|
||||
}
|
||||
return $code;
|
||||
}
|
||||
|
||||
private function line(string $account_code, int $department_id, float $debit, float $credit, string $description): array
|
||||
{
|
||||
return [
|
||||
'account_code' => $account_code,
|
||||
'department_id' => $department_id,
|
||||
'debit' => round($debit, 2),
|
||||
'credit' => round($credit, 2),
|
||||
'description' => mb_substr($description, 0, 255),
|
||||
];
|
||||
}
|
||||
|
||||
private function assertBalanced(array $lines, string $reference): void
|
||||
{
|
||||
$debit = round(array_sum(array_column($lines, 'debit')), 2);
|
||||
$credit = round(array_sum(array_column($lines, 'credit')), 2);
|
||||
if (!$lines || abs($debit - $credit) > 0.004) {
|
||||
throw new Exception("GL entry for {$reference} does not balance (debit {$debit}, credit {$credit}).");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,334 +0,0 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/DepreciationCalculator.php';
|
||||
require_once __DIR__ . '/AssetManager.php';
|
||||
|
||||
/**
|
||||
* AssetRunManager
|
||||
*
|
||||
* Monthly depreciation (tangible) and amortization (intangible) runs. A run
|
||||
* covers one class and one month; each becomes one GL entry that Accounting
|
||||
* posts from Batch GL Entries. Running "through" a month creates one run per
|
||||
* month that still has charges due, oldest first.
|
||||
*/
|
||||
class AssetRunManager
|
||||
{
|
||||
private const CLASS_META = [
|
||||
'tangible' => ['source_type' => 'asset_depreciation', 'prefix' => 'AM-DEP', 'label' => 'Depreciation'],
|
||||
'intangible' => ['source_type' => 'asset_amortization', 'prefix' => 'AM-AMT', 'label' => 'Amortization'],
|
||||
];
|
||||
|
||||
private PDO $pdo;
|
||||
private int $companyId;
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->companyId = $company_id;
|
||||
}
|
||||
|
||||
public static function sourceTypeFor(string $asset_class): string
|
||||
{
|
||||
return self::meta($asset_class)['source_type'];
|
||||
}
|
||||
|
||||
public static function labelFor(string $asset_class): string
|
||||
{
|
||||
return self::meta($asset_class)['label'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Charges due for every active asset of the class, from each asset's next
|
||||
* uncharged month up to $through_period.
|
||||
*
|
||||
* @return array<int, array{period: string, run_date: string, asset_count: int, total: float, items: array}>
|
||||
*/
|
||||
public function preview(string $asset_class, string $through_period): array
|
||||
{
|
||||
self::meta($asset_class);
|
||||
DepreciationCalculator::addMonths($through_period, 0); // validates the format
|
||||
if (DepreciationCalculator::monthsBetween(date('Y-m'), $through_period) > 0) {
|
||||
throw new Exception('You cannot run ' . strtolower(self::labelFor($asset_class)) . ' for a future month.');
|
||||
}
|
||||
|
||||
$by_period = [];
|
||||
foreach ($this->activeAssets($asset_class) as $asset) {
|
||||
$cost = (float)$asset['cost'];
|
||||
$salvage = (float)$asset['salvage_value'];
|
||||
$life = (int)$asset['useful_life_months'];
|
||||
$accumulated = round((float)$asset['accumulated'], 2);
|
||||
$period = $asset['last_period'] !== ''
|
||||
? DepreciationCalculator::addMonths($asset['last_period'], 1)
|
||||
: DepreciationCalculator::firstPeriod($asset['in_service_date']);
|
||||
|
||||
while (DepreciationCalculator::monthsBetween($period, $through_period) >= 0) {
|
||||
$index = DepreciationCalculator::periodIndex($asset['in_service_date'], $period);
|
||||
$amount = DepreciationCalculator::amountFor($cost, $salvage, $life, $index, $accumulated);
|
||||
if ($amount <= 0) break;
|
||||
|
||||
$accumulated = round($accumulated + $amount, 2);
|
||||
$by_period[$period][] = [
|
||||
'asset_id' => (int)$asset['id'],
|
||||
'asset_number' => $asset['asset_number'],
|
||||
'asset_name' => $asset['asset_name'],
|
||||
'category_id' => (int)$asset['category_id'],
|
||||
'category_name' => $asset['category_name'],
|
||||
'department_id' => (int)$asset['department_id'],
|
||||
'amount' => $amount,
|
||||
'accumulated_after' => $accumulated,
|
||||
'book_value_after' => round($cost - $accumulated, 2),
|
||||
];
|
||||
$period = DepreciationCalculator::addMonths($period, 1);
|
||||
}
|
||||
}
|
||||
|
||||
ksort($by_period);
|
||||
$runs = [];
|
||||
foreach ($by_period as $period => $items) {
|
||||
$runs[] = [
|
||||
'period' => $period,
|
||||
'run_date' => DepreciationCalculator::periodEndDate($period),
|
||||
'asset_count' => count($items),
|
||||
'total' => round(array_sum(array_column($items, 'amount')), 2),
|
||||
'items' => $items,
|
||||
];
|
||||
}
|
||||
return $runs;
|
||||
}
|
||||
|
||||
/** Create the runs shown by preview(). Call inside a transaction. */
|
||||
public function run(string $asset_class, string $through_period, int $user_id): array
|
||||
{
|
||||
// Serialize concurrent runs for the same class.
|
||||
$this->pdo->prepare(
|
||||
"SELECT id FROM td_asset WHERE company_id = :cid AND asset_class = :class AND status = 1 FOR UPDATE"
|
||||
)->execute([':cid' => $this->companyId, ':class' => $asset_class]);
|
||||
|
||||
$preview = $this->preview($asset_class, $through_period);
|
||||
if (!$preview) {
|
||||
throw new Exception('No ' . strtolower(self::labelFor($asset_class)) . " is due through {$through_period}.");
|
||||
}
|
||||
|
||||
$insert_run = $this->pdo->prepare(
|
||||
"INSERT INTO td_asset_run
|
||||
(company_id, run_number, asset_class, period, run_date, asset_count, total_amount, status, created_by, created_at)
|
||||
VALUES
|
||||
(:cid, :number, :class, :period, :run_date, :count, :total, 1, :user_id, NOW())"
|
||||
);
|
||||
$insert_row = $this->pdo->prepare(
|
||||
"INSERT INTO td_asset_depreciation
|
||||
(company_id, asset_id, run_id, period, category_id, department_id,
|
||||
amount, accumulated_after, book_value_after, created_at)
|
||||
VALUES
|
||||
(:cid, :asset_id, :run_id, :period, :category_id, :department_id,
|
||||
:amount, :accumulated, :book_value, NOW())"
|
||||
);
|
||||
|
||||
$created = [];
|
||||
$asset_ids = [];
|
||||
foreach ($preview as $run) {
|
||||
$number = $this->nextRunNumber($asset_class, $run['period']);
|
||||
$insert_run->execute([
|
||||
':cid' => $this->companyId,
|
||||
':number' => $number,
|
||||
':class' => $asset_class,
|
||||
':period' => $run['period'],
|
||||
':run_date' => $run['run_date'],
|
||||
':count' => $run['asset_count'],
|
||||
':total' => $run['total'],
|
||||
':user_id' => $user_id,
|
||||
]);
|
||||
$run_id = (int)$this->pdo->lastInsertId();
|
||||
|
||||
foreach ($run['items'] as $item) {
|
||||
$insert_row->execute([
|
||||
':cid' => $this->companyId,
|
||||
':asset_id' => $item['asset_id'],
|
||||
':run_id' => $run_id,
|
||||
':period' => $run['period'],
|
||||
':category_id' => $item['category_id'],
|
||||
':department_id' => $item['department_id'],
|
||||
':amount' => $item['amount'],
|
||||
':accumulated' => $item['accumulated_after'],
|
||||
':book_value' => $item['book_value_after'],
|
||||
]);
|
||||
$asset_ids[] = $item['asset_id'];
|
||||
}
|
||||
|
||||
$created[] = [
|
||||
'id' => $run_id,
|
||||
'run_number' => $number,
|
||||
'period' => $run['period'],
|
||||
'asset_count' => $run['asset_count'],
|
||||
'total' => $run['total'],
|
||||
];
|
||||
}
|
||||
|
||||
(new AssetManager($this->pdo, $this->companyId))->syncStatuses($asset_ids);
|
||||
return $created;
|
||||
}
|
||||
|
||||
public function getList(string $asset_class = ''): array
|
||||
{
|
||||
$where = ['r.company_id = :cid'];
|
||||
$params = [':cid' => $this->companyId];
|
||||
if ($asset_class !== '') {
|
||||
self::meta($asset_class);
|
||||
$where[] = 'r.asset_class = :class';
|
||||
$params[':class'] = $asset_class;
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT r.*,
|
||||
DATE_FORMAT(r.created_at, '%Y-%m-%d %H:%i:%s') AS created_at_fmt,
|
||||
IF(g.id IS NULL, 0, 1) AS gl_status,
|
||||
COALESCE(g.id, 0) AS gl_id
|
||||
FROM td_asset_run r
|
||||
LEFT JOIN td_gl g
|
||||
ON g.company_id = r.company_id
|
||||
AND g.source_id = r.id
|
||||
AND g.source_type = IF(r.asset_class = 'intangible', 'asset_amortization', 'asset_depreciation')
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
ORDER BY r.period DESC, r.id DESC"
|
||||
);
|
||||
$sth->execute($params);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function getDetail(int $run_id): array
|
||||
{
|
||||
$sth = $this->pdo->prepare("SELECT * FROM td_asset_run WHERE company_id = :cid AND id = :id LIMIT 1");
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $run_id]);
|
||||
$run = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$run) throw new Exception('Run not found.');
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT d.asset_id, d.amount, d.accumulated_after, d.book_value_after, d.department_id,
|
||||
a.asset_number, a.asset_name,
|
||||
COALESCE(c.category_code, '') AS category_code,
|
||||
COALESCE(c.category_name, '') AS category_name,
|
||||
COALESCE(dp.dept_code, '') AS dept_code
|
||||
FROM td_asset_depreciation d
|
||||
JOIN td_asset a
|
||||
ON a.id = d.asset_id AND ABS(a.company_id) = d.company_id
|
||||
LEFT JOIN md_asset_category c
|
||||
ON c.company_id = d.company_id AND c.id = d.category_id
|
||||
LEFT JOIN md_department dp
|
||||
ON dp.company_id = d.company_id AND dp.id = d.department_id
|
||||
WHERE d.company_id = :cid AND d.run_id = :run_id
|
||||
ORDER BY a.asset_number ASC"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':run_id' => $run_id]);
|
||||
$items = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// A void run's rows are gone; show what it held from the header only.
|
||||
return ['run' => $run, 'items' => $items];
|
||||
}
|
||||
|
||||
/**
|
||||
* Void a run: reverse its GL entry if posted and remove its charges. Only
|
||||
* the latest month of each asset can be removed, and not after a disposal.
|
||||
*/
|
||||
public function void(int $run_id, GlManager $gl): void
|
||||
{
|
||||
$sth = $this->pdo->prepare("SELECT * FROM td_asset_run WHERE company_id = :cid AND id = :id FOR UPDATE");
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $run_id]);
|
||||
$run = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$run) throw new Exception('Run not found.');
|
||||
if ((int)$run['status'] !== 1) throw new Exception('This run is already void.');
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT a.asset_number
|
||||
FROM td_asset_depreciation d
|
||||
JOIN td_asset a ON a.company_id = d.company_id AND a.id = d.asset_id
|
||||
WHERE d.company_id = :cid AND d.run_id = :run_id AND a.status = 3
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':run_id' => $run_id]);
|
||||
if ($number = $sth->fetchColumn()) {
|
||||
throw new Exception("Asset {$number} in this run has been disposed of. Undo the disposal first.");
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT later.period
|
||||
FROM td_asset_depreciation d
|
||||
JOIN td_asset_depreciation later
|
||||
ON later.company_id = d.company_id AND later.asset_id = d.asset_id AND later.period > d.period
|
||||
WHERE d.company_id = :cid AND d.run_id = :run_id
|
||||
ORDER BY later.period DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':run_id' => $run_id]);
|
||||
if ($later = $sth->fetchColumn()) {
|
||||
throw new Exception("Assets in this run were charged again in {$later}. Void the later runs first.");
|
||||
}
|
||||
|
||||
$gl->delete(self::sourceTypeFor($run['asset_class']), $run_id);
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT asset_id FROM td_asset_depreciation WHERE company_id = :cid AND run_id = :run_id");
|
||||
$sth->execute([':cid' => $this->companyId, ':run_id' => $run_id]);
|
||||
$asset_ids = $sth->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
$this->pdo->prepare("DELETE FROM td_asset_depreciation WHERE company_id = :cid AND run_id = :run_id")
|
||||
->execute([':cid' => $this->companyId, ':run_id' => $run_id]);
|
||||
$this->pdo->prepare("UPDATE td_asset_run SET status = 4, voided_at = NOW() WHERE company_id = :cid AND id = :id")
|
||||
->execute([':cid' => $this->companyId, ':id' => $run_id]);
|
||||
|
||||
(new AssetManager($this->pdo, $this->companyId))->syncStatuses($asset_ids);
|
||||
}
|
||||
|
||||
/** What is due through the current month, per class (for the dashboard). */
|
||||
public function dueSummary(): array
|
||||
{
|
||||
$summary = [];
|
||||
foreach (array_keys(self::CLASS_META) as $asset_class) {
|
||||
$runs = $this->preview($asset_class, date('Y-m'));
|
||||
$summary[$asset_class] = [
|
||||
'label' => self::labelFor($asset_class),
|
||||
'period_count' => count($runs),
|
||||
'from' => $runs ? $runs[0]['period'] : '',
|
||||
'to' => $runs ? $runs[count($runs) - 1]['period'] : '',
|
||||
'total' => round(array_sum(array_column($runs, 'total')), 2),
|
||||
];
|
||||
}
|
||||
return $summary;
|
||||
}
|
||||
|
||||
private function activeAssets(string $asset_class): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT a.id, a.asset_number, a.asset_name, a.category_id, a.department_id,
|
||||
a.cost, a.salvage_value, a.useful_life_months, a.in_service_date,
|
||||
COALESCE(c.category_name, '') AS category_name,
|
||||
COALESCE(d.accumulated, 0) AS accumulated,
|
||||
COALESCE(d.last_period, '') AS last_period
|
||||
FROM td_asset a
|
||||
LEFT JOIN md_asset_category c
|
||||
ON c.company_id = a.company_id AND c.id = a.category_id
|
||||
LEFT JOIN (SELECT asset_id, SUM(amount) AS accumulated, MAX(period) AS last_period
|
||||
FROM td_asset_depreciation WHERE company_id = :cid_d GROUP BY asset_id) d
|
||||
ON d.asset_id = a.id
|
||||
WHERE a.company_id = :cid AND a.asset_class = :class AND a.status = 1
|
||||
ORDER BY a.asset_number ASC"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':cid_d' => $this->companyId, ':class' => $asset_class]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
private function nextRunNumber(string $asset_class, string $period): string
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM td_asset_run WHERE company_id = :cid AND asset_class = :class AND period = :period"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':class' => $asset_class, ':period' => $period]);
|
||||
$existing = (int)$sth->fetchColumn();
|
||||
|
||||
$number = self::meta($asset_class)['prefix'] . '-' . $period;
|
||||
return $existing > 0 ? $number . '-' . ($existing + 1) : $number;
|
||||
}
|
||||
|
||||
private static function meta(string $asset_class): array
|
||||
{
|
||||
if (!isset(self::CLASS_META[$asset_class])) throw new Exception('Invalid asset class.');
|
||||
return self::CLASS_META[$asset_class];
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* DepreciationCalculator
|
||||
*
|
||||
* Straight-line depreciation (tangible) and amortization (intangible) maths.
|
||||
* No database access — AssetManager and AssetRunManager call it.
|
||||
*
|
||||
* Conventions:
|
||||
* - Amounts are rounded to 2 decimals per month; the last month of the life
|
||||
* takes whatever is left so the book value ends exactly at salvage value.
|
||||
* - The month the asset is put in service counts as a full month.
|
||||
* - The month an asset is disposed of gets no charge.
|
||||
* - Periods are 'YYYY-MM' strings.
|
||||
*/
|
||||
final class DepreciationCalculator
|
||||
{
|
||||
public static function periodOf(string $date): string
|
||||
{
|
||||
if (!preg_match('/^(\d{4})-(\d{2})/', $date, $m)) {
|
||||
throw new Exception("Invalid date '{$date}'.");
|
||||
}
|
||||
return "{$m[1]}-{$m[2]}";
|
||||
}
|
||||
|
||||
public static function addMonths(string $period, int $months): string
|
||||
{
|
||||
[$y, $m] = self::splitPeriod($period);
|
||||
$index = $y * 12 + ($m - 1) + $months;
|
||||
return sprintf('%04d-%02d', intdiv($index, 12), $index % 12 + 1);
|
||||
}
|
||||
|
||||
/** Number of months from $from to $to (0 when equal, negative when $to is earlier). */
|
||||
public static function monthsBetween(string $from, string $to): int
|
||||
{
|
||||
[$fy, $fm] = self::splitPeriod($from);
|
||||
[$ty, $tm] = self::splitPeriod($to);
|
||||
return ($ty * 12 + $tm) - ($fy * 12 + $fm);
|
||||
}
|
||||
|
||||
public static function periodEndDate(string $period): string
|
||||
{
|
||||
[$y, $m] = self::splitPeriod($period);
|
||||
return date('Y-m-t', mktime(0, 0, 0, $m, 1, $y));
|
||||
}
|
||||
|
||||
public static function depreciableBase(float $cost, float $salvage): float
|
||||
{
|
||||
return round($cost - $salvage, 2);
|
||||
}
|
||||
|
||||
public static function monthlyAmount(float $cost, float $salvage, int $life_months): float
|
||||
{
|
||||
if ($life_months <= 0) return 0.0;
|
||||
return round(self::depreciableBase($cost, $salvage) / $life_months, 2);
|
||||
}
|
||||
|
||||
public static function firstPeriod(string $in_service_date): string
|
||||
{
|
||||
return self::periodOf($in_service_date);
|
||||
}
|
||||
|
||||
public static function lastPeriod(string $in_service_date, int $life_months): string
|
||||
{
|
||||
return self::addMonths(self::firstPeriod($in_service_date), max(1, $life_months) - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge for one period.
|
||||
*
|
||||
* @param int $period_index 1 for the in-service month, up to $life_months.
|
||||
* @param float $accumulated Total already charged before this period.
|
||||
*/
|
||||
public static function amountFor(float $cost, float $salvage, int $life_months, int $period_index, float $accumulated): float
|
||||
{
|
||||
if ($life_months <= 0 || $period_index < 1 || $period_index > $life_months) return 0.0;
|
||||
|
||||
$remaining = round(self::depreciableBase($cost, $salvage) - $accumulated, 2);
|
||||
if ($remaining <= 0) return 0.0;
|
||||
if ($period_index === $life_months) return $remaining;
|
||||
|
||||
return min(self::monthlyAmount($cost, $salvage, $life_months), $remaining);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full projected schedule.
|
||||
*
|
||||
* @return array<int, array{period: string, amount: float, accumulated: float, book_value: float}>
|
||||
*/
|
||||
public static function schedule(float $cost, float $salvage, int $life_months, string $in_service_date): array
|
||||
{
|
||||
$rows = [];
|
||||
$accumulated = 0.0;
|
||||
$period = self::firstPeriod($in_service_date);
|
||||
|
||||
for ($i = 1; $i <= $life_months; $i++) {
|
||||
$amount = self::amountFor($cost, $salvage, $life_months, $i, $accumulated);
|
||||
$accumulated = round($accumulated + $amount, 2);
|
||||
$rows[] = [
|
||||
'period' => $period,
|
||||
'amount' => $amount,
|
||||
'accumulated' => $accumulated,
|
||||
'book_value' => round($cost - $accumulated, 2),
|
||||
];
|
||||
$period = self::addMonths($period, 1);
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** 1-based position of $period in the asset's life (0 or less = before in service). */
|
||||
public static function periodIndex(string $in_service_date, string $period): int
|
||||
{
|
||||
return self::monthsBetween(self::firstPeriod($in_service_date), $period) + 1;
|
||||
}
|
||||
|
||||
public static function assertInputs(float $cost, float $salvage, int $life_months): void
|
||||
{
|
||||
if ($cost <= 0) throw new Exception('Cost must be greater than zero.');
|
||||
if ($salvage < 0) throw new Exception('Salvage value cannot be negative.');
|
||||
if ($salvage >= $cost) throw new Exception('Salvage value must be less than cost.');
|
||||
if ($life_months < 1) throw new Exception('Useful life must be at least 1 month.');
|
||||
if ($life_months > 1200) throw new Exception('Useful life cannot exceed 1200 months.');
|
||||
if (self::monthlyAmount($cost, $salvage, $life_months) <= 0) {
|
||||
throw new Exception('The amount to depreciate is too small for this useful life.');
|
||||
}
|
||||
}
|
||||
|
||||
private static function splitPeriod(string $period): array
|
||||
{
|
||||
if (!preg_match('/^(\d{4})-(\d{2})$/', $period, $m) || (int)$m[2] < 1 || (int)$m[2] > 12) {
|
||||
throw new Exception("Invalid period '{$period}'.");
|
||||
}
|
||||
return [(int)$m[1], (int)$m[2]];
|
||||
}
|
||||
}
|
||||
@@ -20,17 +20,6 @@ if (!function_exists('require_role')) {
|
||||
}
|
||||
}
|
||||
|
||||
// Blocks users whose app_access does not include $app ('all' includes every app).
|
||||
if (!function_exists('require_app_access')) {
|
||||
function require_app_access(string $app): void {
|
||||
$access = $_SESSION['login_app_access'] ?? 'wms';
|
||||
if ($access !== 'all' && $access !== $app) {
|
||||
http_response_code(403);
|
||||
exit(json_encode(['success' => 0, 'message' => 'You do not have access to this app.']));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(!empty($_SESSION["login_company_id"])){
|
||||
|
||||
// CSRF Validation — add right at the top of the logged-in block
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
// app/assets/utils/otp_policy.php
|
||||
//
|
||||
// Email OTP login policy, set by OTP_REQUIRED in config.php.
|
||||
//
|
||||
// OFF BY DEFAULT: the OTP step runs only when the constant is defined and is
|
||||
// exactly the boolean true. A missing constant (any config.php written before
|
||||
// this switch existed), 1, 'true' or a typo all leave it off, so sign-in is
|
||||
// password only and no SMTP is needed to log in.
|
||||
//
|
||||
// While it is off, every sign-in that skips the OTP because of it is logged as
|
||||
// OTP_BYPASSED, and the login page and top bar both say so on screen — a
|
||||
// password-only sign-in must never be invisible to whoever is using it.
|
||||
//
|
||||
// Only the login OTP is affected. When it is on, the staff/viewer and no-SMTP
|
||||
// skips in login_otp.php still apply; password-reset OTPs (PasswordResetManager)
|
||||
// are a separate flow that stays on regardless.
|
||||
|
||||
if (!function_exists('otp_required')) {
|
||||
function otp_required(): bool {
|
||||
return defined('OTP_REQUIRED') && OTP_REQUIRED === true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('otp_log_bypass')) {
|
||||
// There is no auth log table in this app, so bypasses go to the PHP error
|
||||
// log (the container's Apache log) under a fixed, greppable tag.
|
||||
function otp_log_bypass($user_id, string $where): void {
|
||||
error_log(sprintf(
|
||||
'[auth] OTP_BYPASSED user_id=%d ip=%s where=%s -- OTP_REQUIRED is not true in config.php',
|
||||
(int)$user_id,
|
||||
$_SERVER['REMOTE_ADDR'] ?? '-',
|
||||
$where
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -38,26 +38,6 @@ if (!defined('NODE_EMIT_SECRET')) {
|
||||
define('NODE_EMIT_SECRET', 'YOUR_NODE_EMIT_SECRET'); // must match nodejs/.env EMIT_SECRET
|
||||
}
|
||||
|
||||
// ── Login OTP ────────────────────────────────────────────────────────────────
|
||||
// Email OTP on sign-in. OFF BY DEFAULT: only the boolean true turns it on —
|
||||
// anything else, the constant being absent included, leaves sign-in password
|
||||
// only (logged as OTP_BYPASSED, shown on the login page and top bar). Turn it
|
||||
// on only with working SMTP. Password-reset OTPs are not affected.
|
||||
if (!defined('OTP_REQUIRED')) {
|
||||
define('OTP_REQUIRED', false);
|
||||
}
|
||||
|
||||
// ── App registry ─────────────────────────────────────────────────────────────
|
||||
// Apps a user can be given access to, as shown on Setting → Users Access. Keys
|
||||
// must match the user.app_access enum ('wms', 'accounting', 'asset'). If this is
|
||||
// left out, assets/utils/app_registry.php supplies the same default and fills in
|
||||
// any app or 'home' page missing here.
|
||||
$app_registry = [
|
||||
'wms' => ['label' => 'WMS', 'icon' => 'ti-box', 'color' => 'bg-label-primary', 'home' => 'dashboard/index.php'],
|
||||
'accounting' => ['label' => 'Accounting', 'icon' => 'ti-calculator', 'color' => 'bg-label-info', 'home' => 'ac_dashboard/index.php'],
|
||||
'asset' => ['label' => 'Asset Management', 'icon' => 'ti-building-warehouse', 'color' => 'bg-label-success', 'home' => 'asset_dashboard/index.php'],
|
||||
];
|
||||
|
||||
// ── Usage packages ───────────────────────────────────────────────────────────
|
||||
// Keyed by company_list.package (defaults to 'starter'). Read by UsageGuard to
|
||||
// enforce daily/weekly action limits and which features lock once exceeded.
|
||||
|
||||
@@ -27,11 +27,6 @@ class db_statement extends PDOStatement {
|
||||
$this->pdo = $pdo;
|
||||
}
|
||||
|
||||
// PDOStatement::execute() is declared ?array $params = null : bool. This
|
||||
// override deliberately accepts a looser signature so callers may pass
|
||||
// positional arguments (see func_get_args() below), so the tightened return
|
||||
// type is opted out of rather than the call sites being changed.
|
||||
#[\ReturnTypeWillChange]
|
||||
public function execute($args = null) {
|
||||
// Perform logging here. PDO object is accessible
|
||||
// from $this->pdo.
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
require '../include_header.php';
|
||||
|
||||
$invoice_id = (int)($_GET['id'] ?? 0);
|
||||
// Users with Asset Management can turn issued invoice lines into assets.
|
||||
$pi_asset_access = in_array($_SESSION['login_app_access'] ?? 'wms', ['all', 'asset'], true);
|
||||
?>
|
||||
|
||||
<body>
|
||||
@@ -75,30 +73,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assets created from this invoice (Asset Management) -->
|
||||
<div class="card mt-5 d-none" id="asset_card">
|
||||
<div class="card-body p-5">
|
||||
<h2 class="fs-5 mb-4">
|
||||
<span class="badge bg-success text-white me-2"><i class="ti ti-building-warehouse me-1"></i>Asset Management</span>
|
||||
Assets from this invoice
|
||||
</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0 table-hover">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Asset No.</th>
|
||||
<th>Name</th>
|
||||
<th class="text-end">Cost</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="asset_tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Info + actions -->
|
||||
@@ -186,44 +160,6 @@
|
||||
|
||||
var invoice_id = <?php echo $invoice_id; ?>;
|
||||
var invoice_data = null;
|
||||
var show_asset_actions = <?php echo $pi_asset_access ? 'true' : 'false'; ?>;
|
||||
|
||||
var ASSET_STATUS_BADGE = {
|
||||
'0': '<span class="badge bg-secondary">Draft</span>',
|
||||
'1': '<span class="badge bg-success">Active</span>',
|
||||
'2': '<span class="badge bg-info text-white">Fully depreciated</span>',
|
||||
'3': '<span class="badge bg-dark">Disposed</span>',
|
||||
'4': '<span class="badge bg-light text-dark">Void</span>',
|
||||
};
|
||||
|
||||
function load_invoice_assets() {
|
||||
return ajax_request({
|
||||
url: '<?php echo $server_url?>asset/api/engine/asset.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'read',
|
||||
queueLock: false,
|
||||
noLoading: true,
|
||||
noAlert: true,
|
||||
data: { invoice_id: invoice_id },
|
||||
onSuccess: function(res) {
|
||||
var rows = res.output || [];
|
||||
if (!rows.length) { $('#asset_card').addClass('d-none'); return; }
|
||||
var html = '';
|
||||
$.each(rows, function(i, a) {
|
||||
html += `<tr>
|
||||
<td>${escape_html(a.asset_number)}</td>
|
||||
<td>${escape_html(a.asset_name)}</td>
|
||||
<td class="text-end">${format_number(a.cost, 2)}</td>
|
||||
<td>${ASSET_STATUS_BADGE[String(a.status)] || '—'}</td>
|
||||
<td><a href="<?php echo $server_url?>asset/manage_asset.php?id=${a.id}"><i class="ti ti-eye fs-5"></i></a></td>
|
||||
</tr>`;
|
||||
});
|
||||
$('#asset_tbody').html(html);
|
||||
$('#asset_card').removeClass('d-none');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function doc_type_badge(doc_type) {
|
||||
const map = {
|
||||
@@ -294,7 +230,7 @@
|
||||
$('#badge_status').html(invoice_status_badge(inv.status, inv.due_date));
|
||||
$('#display_contact').text(inv.contact_name || '—');
|
||||
$('#display_issued').text(format_date(inv.issued_date) || '—');
|
||||
$('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : '');
|
||||
$('#due_date').val(inv.due_date ? format_date(inv.due_date) : '');
|
||||
$('#notes').val(inv.notes || '');
|
||||
|
||||
// Source link
|
||||
@@ -309,14 +245,10 @@
|
||||
}
|
||||
|
||||
// Items
|
||||
var can_capitalize = show_asset_actions && inv.doc_type === 'purchase_invoice' && [1, 2].includes(parseInt(inv.status));
|
||||
var tbody = '';
|
||||
$.each(inv.items || [], function(i, item) {
|
||||
var asset_link = can_capitalize
|
||||
? `<div><a class="small link-success" href="<?php echo $server_url?>asset/manage_asset.php?invoice_id=${invoice_id}&item_id=${item.id}"><i class="ti ti-building-warehouse me-1"></i>Create asset</a></div>`
|
||||
: '';
|
||||
tbody += `<tr>
|
||||
<td>${escape_html(item.product_name || item.product_sku)}${asset_link}</td>
|
||||
<td>${escape_html(item.product_name || item.product_sku)}</td>
|
||||
<td class="text-end">${format_number(item.quantity, 2)}</td>
|
||||
<td class="text-end">${format_number(item.unit_price, 2)}</td>
|
||||
<td class="text-end">${format_number(item.tax_rate, 2)}</td>
|
||||
@@ -378,7 +310,6 @@
|
||||
$('#btn_save_formula').prop('disabled', parseInt(inv.status) === 4);
|
||||
|
||||
set_mode(inv);
|
||||
if (show_asset_actions && inv.doc_type === 'purchase_invoice') load_invoice_assets();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -274,8 +274,8 @@
|
||||
$('#po_number_display').text(po_data.po_number || '');
|
||||
$('#contact').val(po_data.contact_name || '');
|
||||
$('#contact_id').val(po_data.contact_id || '');
|
||||
$('#po_date').val(po_data.po_date ? format_date_input(po_data.po_date) : '');
|
||||
$('#expected_date').val(po_data.expected_date ? format_date_input(po_data.expected_date) : '');
|
||||
$('#po_date').val(po_data.po_date ? format_date(po_data.po_date) : '');
|
||||
$('#expected_date').val(po_data.expected_date ? format_date(po_data.expected_date) : '');
|
||||
$('#department_id').val(po_data.department_id || 0);
|
||||
$('#notes').val(po_data.notes || '');
|
||||
$('#discount').val(po_data.discount || 0);
|
||||
|
||||
@@ -253,8 +253,8 @@
|
||||
.html(request_data.po_id > 0 ? 'PO: <a href="<?php echo $server_url?>expense/manage_purchase_order.php?id=' + request_data.po_id + '">' + escape_html(request_data.po_number || ('PO #' + request_data.po_id)) + '</a>' : '');
|
||||
$('#contact').val(request_data.contact_name || '');
|
||||
$('#contact_id').val(request_data.contact_id || 0);
|
||||
$('#request_date').val(request_data.request_date ? format_date_input(request_data.request_date) : '');
|
||||
$('#required_date').val(request_data.required_date ? format_date_input(request_data.required_date) : '');
|
||||
$('#request_date').val(request_data.request_date ? format_date(request_data.request_date) : '');
|
||||
$('#required_date').val(request_data.required_date ? format_date(request_data.required_date) : '');
|
||||
$('#department_id').val(request_data.department_id || 0);
|
||||
$('#notes').val(request_data.notes || '');
|
||||
$('#discount').val(request_data.discount || 0);
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/classes/WarehouseManager.php';
|
||||
|
||||
// What one location holds — the quantity a stock-out or transfer from it moves
|
||||
// (StockManager takes the whole approved stock-in row in the bin).
|
||||
try {
|
||||
$whMgmt = new WarehouseManager($pdo2, $company_id);
|
||||
$row = $whMgmt->getBinStock(
|
||||
(int)($data['warehouse'] ?? 0),
|
||||
$data['zone'] ?? '',
|
||||
$data['aisle'] ?? '',
|
||||
$data['bin'] ?? ''
|
||||
);
|
||||
|
||||
$output = null;
|
||||
if ($row) {
|
||||
$sth = $pdo2->prepare("SELECT uom FROM md_product WHERE company_id = :c AND sku = :sku LIMIT 1");
|
||||
$sth->execute([':c' => $company_id, ':sku' => $row['product_sku']]);
|
||||
$output = [
|
||||
'product_sku' => $row['product_sku'],
|
||||
'lot_number' => $row['lot_number'],
|
||||
'serial_number' => $row['serial_number'],
|
||||
'quantity' => (float)$row['in'],
|
||||
'uom' => (string)($sth->fetchColumn() ?: ''),
|
||||
];
|
||||
}
|
||||
|
||||
$answer['output'] = $output;
|
||||
$answer['success'] = 1;
|
||||
} catch (Exception $e) {
|
||||
$answer['message'] = $e->getMessage();
|
||||
http_response_code(400);
|
||||
}
|
||||
|
||||
exit(json_encode($answer));
|
||||
?>
|
||||
@@ -93,16 +93,6 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Quantity: a stock-out always takes everything in the location -->
|
||||
<div class="mb-3 col-lg-6">
|
||||
<label for="location_quantity" class="form-label">Quantity</label>
|
||||
<div class="input-group">
|
||||
<input type="text" id="location_quantity" class="form-control text-end" placeholder="—" disabled>
|
||||
<span class="input-group-text" id="location_uom" style="min-width:60px;"> </span>
|
||||
</div>
|
||||
<div class="form-text">The whole quantity in the selected location is taken out.</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact -->
|
||||
<div class="mb-3 col-lg-6">
|
||||
<label for="contact" class="form-label">Contact</label>
|
||||
@@ -169,35 +159,6 @@
|
||||
}
|
||||
set_select_value('#bin', data.bin, data.bin);
|
||||
scan_location_ready = true;
|
||||
show_location_quantity();
|
||||
});
|
||||
}
|
||||
|
||||
// Quantity held in the chosen location — the amount this stock-out moves.
|
||||
function show_location_quantity() {
|
||||
var bin_val = $('#bin').val();
|
||||
$('#location_quantity').val('');
|
||||
$('#location_uom').html(' ');
|
||||
if (!$('#warehouse').val() || !bin_val) return;
|
||||
|
||||
return ajax_request({
|
||||
url: '<?php echo $server_url?>ics/api/engine/retrieve_bin_stock.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
noLoading: true,
|
||||
queueLock: false,
|
||||
action: 'read',
|
||||
data: {
|
||||
warehouse: $('#warehouse').val(),
|
||||
zone: advanced ? $('#zone').val() : bin_val,
|
||||
aisle: advanced ? $('#aisle').val() : bin_val,
|
||||
bin: bin_val
|
||||
},
|
||||
onSuccess: function(res) {
|
||||
if (!res.output) return;
|
||||
$('#location_quantity').val(format_number(res.output.quantity, 2));
|
||||
$('#location_uom').text(res.output.uom || '');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -522,8 +483,6 @@
|
||||
$('#zone').html('<option value="">Please select ' + label_zone.toLowerCase() + '</option>').prop('disabled', true);
|
||||
$('#aisle').html('<option value="">Please select ' + label_aisle.toLowerCase() + '</option>').prop('disabled', true);
|
||||
$('#bin').html('<option value="">Please select ' + label_bin.toLowerCase() + '</option>').prop('disabled', true);
|
||||
$('#location_quantity').val('');
|
||||
$('#location_uom').html(' ');
|
||||
}
|
||||
|
||||
|
||||
@@ -550,7 +509,7 @@
|
||||
$('#serial_number').html(`<option value="${data.serial_number || ''}">${data.serial_number || '—'}</option>`)
|
||||
.val(data.serial_number || '').prop('disabled', true);
|
||||
|
||||
$('#warehouse').val('<?php echo (int)($_GET["wh"] ?? 0);?>').prop('disabled', true);
|
||||
$('#warehouse').val('<?php echo $_GET["wh"];?>').prop('disabled', true);
|
||||
|
||||
function populate_fields() {
|
||||
$.each(data, function(key, item) {
|
||||
@@ -563,8 +522,6 @@
|
||||
.attr('data-id', data.contact_id);
|
||||
$('#product_name').val(data.product_name);
|
||||
$('#product_sku').attr('secondary', data.product_sku).prop('disabled', true);
|
||||
$('#location_quantity').val(format_number(data.quantity, 2));
|
||||
$('#location_uom').text(data.uom || '');
|
||||
$('button[type=submit]').text('Update');
|
||||
$('button[type=reset]').hide();
|
||||
if (data.status == 0) {
|
||||
@@ -662,9 +619,6 @@
|
||||
if (advanced) retrieve_bin();
|
||||
});
|
||||
|
||||
// Bin selected → show how much it holds
|
||||
$('#bin').on('change', show_location_quantity);
|
||||
|
||||
<?php } ?>
|
||||
</script>
|
||||
|
||||
|
||||
@@ -92,15 +92,6 @@
|
||||
<option value="">Please select bin</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Quantity: a transfer always moves everything in the from-location -->
|
||||
<div class="mb-3 col-lg-3">
|
||||
<label for="transfer_quantity" class="form-label">Quantity</label>
|
||||
<div class="input-group">
|
||||
<input type="text" id="transfer_quantity" class="form-control text-end" placeholder="—" disabled>
|
||||
<span class="input-group-text" id="transfer_uom" style="min-width:52px;"> </span>
|
||||
</div>
|
||||
<div class="form-text">Whole location is moved.</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12"><hr class="my-2"></div>
|
||||
|
||||
@@ -213,41 +204,12 @@
|
||||
|
||||
if (role === 'from') {
|
||||
scan_from_ready = true;
|
||||
show_from_quantity();
|
||||
} else {
|
||||
scan_to_ready = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Quantity held in the from-location — the amount this transfer moves.
|
||||
function show_from_quantity() {
|
||||
var bin_val = $('#bin_from').val();
|
||||
$('#transfer_quantity').val('');
|
||||
$('#transfer_uom').html(' ');
|
||||
if (!$('#warehouse_from').val() || !bin_val) return;
|
||||
|
||||
return ajax_request({
|
||||
url: '<?php echo $server_url?>ics/api/engine/retrieve_bin_stock.php',
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
noLoading: true,
|
||||
queueLock: false,
|
||||
action: 'read',
|
||||
data: {
|
||||
warehouse: $('#warehouse_from').val(),
|
||||
zone: advanced ? $('#zone_from').val() : bin_val,
|
||||
aisle: advanced ? $('#aisle_from').val() : bin_val,
|
||||
bin: bin_val
|
||||
},
|
||||
onSuccess: function(res) {
|
||||
if (!res.output) return;
|
||||
$('#transfer_quantity').val(format_number(res.output.quantity, 2));
|
||||
$('#transfer_uom').text(res.output.uom || '');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function same_location_as_from(data) {
|
||||
return String($('#warehouse_from').val()) === String(data.warehouse_id)
|
||||
&& String($('#zone_from').val()) === String(data.zone)
|
||||
@@ -612,8 +574,6 @@
|
||||
$('#zone_from').html('<option value="">Please select ' + label_zone.toLowerCase() + '</option>').prop('disabled', true);
|
||||
$('#aisle_from').html('<option value="">Please select ' + label_aisle.toLowerCase() + '</option>').prop('disabled', true);
|
||||
$('#bin_from').html('<option value="">Please select ' + label_bin.toLowerCase() + '</option>').prop('disabled', true);
|
||||
$('#transfer_quantity').val('');
|
||||
$('#transfer_uom').html(' ');
|
||||
}
|
||||
|
||||
|
||||
@@ -680,8 +640,6 @@
|
||||
$('#contact').val(data.contact_name)
|
||||
.attr('secondary', data.contact_name)
|
||||
.attr('data-id', data.contact_id);
|
||||
$('#transfer_quantity').val(format_number(data.quantity, 2));
|
||||
$('#transfer_uom').text(data.uom || '');
|
||||
$('button[type=submit]').text('Update');
|
||||
$('button[type=reset]').hide();
|
||||
if (data.status == 0) {
|
||||
@@ -774,9 +732,6 @@
|
||||
if (advanced) retrieve_bin('from');
|
||||
});
|
||||
|
||||
// From bin → show how much it holds
|
||||
$('#bin_from').on('change', show_from_quantity);
|
||||
|
||||
// To warehouse → zone (advanced) or bin directly (simple)
|
||||
$('select[name="warehouse"][role="to"]').on('change', function() {
|
||||
if (advanced) { retrieve_zone('to'); } else { retrieve_bin('to'); }
|
||||
|
||||
@@ -1,23 +1,4 @@
|
||||
<?php
|
||||
// Output buffering must be active before the first byte of HTML below, so that
|
||||
// header() calls made later in the page still work — notably the
|
||||
// not-logged-in redirect in include_topbar.php, which runs *after* this file
|
||||
// has already emitted <!DOCTYPE html>. Without a buffer that redirect depends
|
||||
// entirely on php.ini's output_buffering: it is on for the dev stack but off
|
||||
// in production, where every protected page answered 200 with a half-rendered
|
||||
// body instead of sending the browser to the login form. session.php starts a
|
||||
// buffer for the same reason.
|
||||
if (ob_get_level() === 0) {
|
||||
ob_start();
|
||||
}
|
||||
|
||||
// Never render PHP notices/warnings into the page: they leak absolute server
|
||||
// paths to anonymous visitors and corrupt the markup. Errors still reach the
|
||||
// server log. This mirrors the policy db_auth.php already applies to the JSON
|
||||
// API routes, and keeps the app safe even where php.ini has display_errors on.
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('log_errors', '1');
|
||||
|
||||
// Security headers — emitted before any HTML output.
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('X-Frame-Options: SAMEORIGIN');
|
||||
|
||||
@@ -9,10 +9,12 @@
|
||||
$master_can_admin = in_array($master_role, ['owner', 'admin'], true);
|
||||
$master_app_access = $_SESSION['login_app_access'] ?? 'wms';
|
||||
$master_show_accounting = in_array($master_app_access, ['all', 'accounting']);
|
||||
require_once __DIR__ . '/assets/utils/app_registry.php';
|
||||
$master_current_app = $_SESSION['login_current_app'] ?? 'wms';
|
||||
$master_back_url = $server_url . app_home_path($app_registry, $master_current_app);
|
||||
$master_back_label = 'Back to ' . ($app_registry[$master_current_app]['label'] ?? 'WMS');
|
||||
$master_back_url = ($_SESSION['login_current_app'] ?? 'wms') === 'accounting'
|
||||
? $server_url . 'ac_dashboard/index.php'
|
||||
: $server_url . 'dashboard/index.php';
|
||||
$master_back_label = ($_SESSION['login_current_app'] ?? 'wms') === 'accounting'
|
||||
? 'Back to Accounting'
|
||||
: 'Back to WMS';
|
||||
?>
|
||||
|
||||
<!-- SIDEBAR -->
|
||||
|
||||
@@ -93,9 +93,8 @@
|
||||
<small class="nav-text text-muted">Navigation</small>
|
||||
</li>
|
||||
|
||||
<?php require_once __DIR__ . '/assets/utils/app_registry.php'; ?>
|
||||
<li>
|
||||
<a class="nav-link" href="<?php echo $server_url . app_home_path($app_registry, $_SESSION['login_current_app'] ?? 'wms'); ?>">
|
||||
<a class="nav-link" href="<?php echo $server_url?>dashboard/index.php">
|
||||
<i class="ti ti-arrow-left"></i>
|
||||
<span class="nav-text">Back to App</span>
|
||||
</a>
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
<?php
|
||||
$cp = $_SERVER['PHP_SELF'];
|
||||
function asset_active(...$frags) {
|
||||
global $cp;
|
||||
foreach ($frags as $f) { if (str_contains($cp, $f)) return 'active'; }
|
||||
return '';
|
||||
}
|
||||
$asset_show_accounting = in_array($_SESSION['login_app_access'] ?? 'wms', ['all', 'accounting'], true);
|
||||
?>
|
||||
<!-- SIDEBAR -->
|
||||
<aside id="sidebar" class="sidebar overflow-y-auto">
|
||||
<div class="logo-area">
|
||||
<a href="<?php echo $server_url?>asset_dashboard/index.php" class="d-inline-flex">
|
||||
<span class="logo-text">
|
||||
<img src="<?php echo $server_url?>assets/images/logo.png" alt="" height="48"/>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<ul class="nav flex-column">
|
||||
|
||||
<!-- Main -->
|
||||
<li class="nav-text-space">
|
||||
<small class="nav-text text-muted">Main</small>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a class="nav-link <?php echo asset_active('/asset_dashboard/'); ?>" href="<?php echo $server_url?>asset_dashboard/index.php">
|
||||
<i class="ti ti-home"></i>
|
||||
<span class="nav-text">Dashboard</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- Assets -->
|
||||
<li class="nav-text-space">
|
||||
<small class="nav-text text-muted">Assets</small>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a class="nav-link <?php echo asset_active('/asset/index', '/asset/manage_asset'); ?>" href="<?php echo $server_url?>asset/index.php">
|
||||
<i class="ti ti-building-warehouse"></i>
|
||||
<span class="nav-text">Asset Register</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a class="nav-link <?php echo asset_active('/asset/purchase_invoices'); ?>" href="<?php echo $server_url?>asset/purchase_invoices.php">
|
||||
<i class="ti ti-file-invoice"></i>
|
||||
<span class="nav-text">From Purchase Invoices</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- Depreciation -->
|
||||
<li class="nav-text-space">
|
||||
<small class="nav-text text-muted">Depreciation</small>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a class="nav-link <?php echo asset_active('/asset/depreciation'); ?>" href="<?php echo $server_url?>asset/depreciation.php">
|
||||
<i class="ti ti-calendar-stats"></i>
|
||||
<span class="nav-text">Depreciation Runs</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- Setup -->
|
||||
<li class="nav-text-space">
|
||||
<small class="nav-text text-muted">Setup</small>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a class="nav-link <?php echo asset_active('/asset/categories', '/asset/manage_category'); ?>" href="<?php echo $server_url?>asset/categories.php">
|
||||
<i class="ti ti-category"></i>
|
||||
<span class="nav-text">Asset Categories</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<?php if ($asset_show_accounting): ?>
|
||||
<!-- Accounting -->
|
||||
<li class="nav-text-space">
|
||||
<small class="nav-text text-muted">Accounting</small>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a class="nav-link" href="<?php echo $server_url?>accounting/gl_entries.php">
|
||||
<i class="ti ti-stack-2"></i>
|
||||
<span class="nav-text">Batch GL Entries</span>
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
<li><br><br><br><br></li>
|
||||
|
||||
</ul>
|
||||
</aside>
|
||||
+69
-107
@@ -3,17 +3,11 @@
|
||||
require_once __DIR__ . '/config.php';
|
||||
require_once __DIR__ . '/dbconn.php';
|
||||
require_once __DIR__ . '/assets/utils/classes/UsageGuard.php';
|
||||
require_once __DIR__ . '/assets/utils/otp_policy.php';
|
||||
|
||||
// Redirect to login if the user has not completed full authentication.
|
||||
// login_company_id is only written by login_confirm.php after OTP is verified —
|
||||
// using it (not "otp") ensures half-logged-in sessions are also redirected.
|
||||
if(empty($_SESSION["login_company_id"])){
|
||||
// Discard the markup include_header.php has already buffered so the browser
|
||||
// receives a clean redirect rather than a partially rendered page body.
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
header('Location: '.$server_url.'login/index.php');
|
||||
exit;
|
||||
}
|
||||
@@ -29,32 +23,21 @@ $topbar_avatar_src = $topbar_picture
|
||||
: htmlspecialchars($server_url . 'assets/images/avatar-1.jpg', ENT_QUOTES, 'UTF-8');
|
||||
|
||||
// ── App switcher ──────────────────────────────────────────────────────────────
|
||||
require_once __DIR__ . '/assets/utils/app_registry.php';
|
||||
$topbar_app_access = $_SESSION['login_app_access'] ?? 'wms';
|
||||
|
||||
// Detect which app this page belongs to.
|
||||
// Master-data pages (/accounting/, /inventory/, /contact/, /setting/) are neutral — don't change the session.
|
||||
// '/asset_dashboard/' and '/ac_dashboard/' do not match '/dashboard/' (no slash before "dashboard").
|
||||
$_app_paths = [
|
||||
'wms' => ['/dashboard/', '/ics/', '/order/', '/po/', '/reports/'],
|
||||
'accounting' => ['/ac_dashboard/', '/revenue/', '/expense/', '/finance/', '/journal/'],
|
||||
'asset' => ['/asset_dashboard/', '/asset/'],
|
||||
];
|
||||
$_wms_paths = ['/dashboard/', '/ics/', '/order/', '/po/', '/reports/'];
|
||||
$_ac_paths = ['/ac_dashboard/', '/revenue/', '/expense/', '/finance/', '/journal/'];
|
||||
|
||||
$topbar_current_app = null; // null = neutral page (master data / setting)
|
||||
foreach ($_app_paths as $_app_key => $_paths) {
|
||||
foreach ($_paths as $_p) {
|
||||
if (str_contains($_SERVER['PHP_SELF'], $_p)) { $topbar_current_app = $_app_key; break 2; }
|
||||
}
|
||||
foreach ($_wms_paths as $_p) {
|
||||
if (str_contains($_SERVER['PHP_SELF'], $_p)) { $topbar_current_app = 'wms'; break; }
|
||||
}
|
||||
|
||||
// Asset Management pages are only for users given that app.
|
||||
if ($topbar_current_app === 'asset' && !app_can_access($topbar_app_access, 'asset')) {
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
if (!$topbar_current_app) {
|
||||
foreach ($_ac_paths as $_p) {
|
||||
if (str_contains($_SERVER['PHP_SELF'], $_p)) { $topbar_current_app = 'accounting'; break; }
|
||||
}
|
||||
header('Location: ' . $server_url . app_home_path($app_registry, app_default_for_access($app_registry, $topbar_app_access)));
|
||||
exit;
|
||||
}
|
||||
|
||||
// Only persist to session when on a definitive app page
|
||||
@@ -63,24 +46,11 @@ if ($topbar_current_app) {
|
||||
}
|
||||
// Default session on first visit or neutral pages
|
||||
if (empty($_SESSION['login_current_app'])) {
|
||||
$_SESSION['login_current_app'] = app_default_for_access($app_registry, $topbar_app_access);
|
||||
$_SESSION['login_current_app'] = ($topbar_app_access === 'accounting') ? 'accounting' : 'wms';
|
||||
}
|
||||
|
||||
$topbar_show_wms = app_can_access($topbar_app_access, 'wms');
|
||||
$topbar_show_accounting = app_can_access($topbar_app_access, 'accounting');
|
||||
$topbar_switch_apps = array_filter(
|
||||
$app_registry,
|
||||
fn($key) => app_can_access($topbar_app_access, $key),
|
||||
ARRAY_FILTER_USE_KEY
|
||||
);
|
||||
|
||||
// Per-app colour. WMS keeps the compiled orange theme (#E66239); the others override it.
|
||||
$_app_themes = [
|
||||
'accounting' => ['main' => '#0284c7', 'rgb' => '2, 132, 199', 'dark' => '#0369a1', 'subtle' => '#e0f2fe', 'border' => '#bae6fd'],
|
||||
'asset' => ['main' => '#16a34a', 'rgb' => '22, 163, 74', 'dark' => '#15803d', 'subtle' => '#dcfce7', 'border' => '#bbf7d0'],
|
||||
];
|
||||
$_theme_app = $_SESSION['login_current_app'] ?? 'wms';
|
||||
$_theme = $_app_themes[$_theme_app] ?? null;
|
||||
$topbar_show_wms = in_array($topbar_app_access, ['all', 'wms']);
|
||||
$topbar_show_accounting = in_array($topbar_app_access, ['all', 'accounting']);
|
||||
|
||||
$_usage = (new UsageGuard($pdo1, (int)$_SESSION['login_company_id'], $packages))->getStatus();
|
||||
$_usage_max_pct = max($_usage['daily_pct'], $_usage['weekly_pct']);
|
||||
@@ -88,67 +58,67 @@ $_usage_warn = $_usage_max_pct >= 80 && $_usage_max_pct < 100;
|
||||
$_usage_full = $_usage_max_pct >= 100;
|
||||
?>
|
||||
|
||||
<?php if ($_theme): ?>
|
||||
<style id="theme-<?php echo $_theme_app; ?>">
|
||||
<?php if (($_SESSION['login_current_app'] ?? 'wms') === 'accounting'): ?>
|
||||
<style id="theme-accounting">
|
||||
:root {
|
||||
--bs-primary: <?php echo $_theme['main']; ?>;
|
||||
--bs-primary-rgb: <?php echo $_theme['rgb']; ?>;
|
||||
--bs-primary-bg-subtle: <?php echo $_theme['subtle']; ?>;
|
||||
--bs-primary-border-subtle: <?php echo $_theme['border']; ?>;
|
||||
--bs-primary-text-emphasis: <?php echo $_theme['dark']; ?>;
|
||||
--bs-focus-ring-color: rgba(<?php echo $_theme['rgb']; ?>, .25);
|
||||
--bs-primary: #0284c7;
|
||||
--bs-primary-rgb: 2, 132, 199;
|
||||
--bs-primary-bg-subtle: #e0f2fe;
|
||||
--bs-primary-border-subtle: #bae6fd;
|
||||
--bs-primary-text-emphasis: #0369a1;
|
||||
--bs-focus-ring-color: rgba(2, 132, 199, .25);
|
||||
}
|
||||
/* Sidebar active/hover — hardcoded in theme */
|
||||
.sidebar .nav-link:hover,
|
||||
.sidebar .nav-link.active {
|
||||
color: <?php echo $_theme['main']; ?> !important;
|
||||
background-color: rgba(<?php echo $_theme['rgb']; ?>, 0.09) !important;
|
||||
color: #0284c7 !important;
|
||||
background-color: rgba(2, 132, 199, 0.09) !important;
|
||||
}
|
||||
/* Buttons */
|
||||
.btn-primary {
|
||||
--bs-btn-bg: <?php echo $_theme['main']; ?>;
|
||||
--bs-btn-border-color: <?php echo $_theme['main']; ?>;
|
||||
--bs-btn-hover-bg: <?php echo $_theme['dark']; ?>;
|
||||
--bs-btn-hover-border-color: <?php echo $_theme['dark']; ?>;
|
||||
--bs-btn-active-bg: <?php echo $_theme['dark']; ?>;
|
||||
--bs-btn-active-border-color: <?php echo $_theme['dark']; ?>;
|
||||
--bs-btn-disabled-bg: <?php echo $_theme['main']; ?>;
|
||||
--bs-btn-disabled-border-color: <?php echo $_theme['main']; ?>;
|
||||
--bs-btn-focus-shadow-rgb: <?php echo $_theme['rgb']; ?>;
|
||||
--bs-btn-bg: #0284c7;
|
||||
--bs-btn-border-color: #0284c7;
|
||||
--bs-btn-hover-bg: #0369a1;
|
||||
--bs-btn-hover-border-color: #0369a1;
|
||||
--bs-btn-active-bg: #0369a1;
|
||||
--bs-btn-active-border-color: #0369a1;
|
||||
--bs-btn-disabled-bg: #0284c7;
|
||||
--bs-btn-disabled-border-color: #0284c7;
|
||||
--bs-btn-focus-shadow-rgb: 2, 132, 199;
|
||||
}
|
||||
.btn-outline-primary {
|
||||
--bs-btn-color: <?php echo $_theme['main']; ?>;
|
||||
--bs-btn-border-color: <?php echo $_theme['main']; ?>;
|
||||
--bs-btn-hover-bg: <?php echo $_theme['main']; ?>;
|
||||
--bs-btn-hover-border-color: <?php echo $_theme['main']; ?>;
|
||||
--bs-btn-active-bg: <?php echo $_theme['main']; ?>;
|
||||
--bs-btn-active-border-color: <?php echo $_theme['main']; ?>;
|
||||
--bs-btn-disabled-color: <?php echo $_theme['main']; ?>;
|
||||
--bs-btn-disabled-border-color: <?php echo $_theme['main']; ?>;
|
||||
--bs-btn-color: #0284c7;
|
||||
--bs-btn-border-color: #0284c7;
|
||||
--bs-btn-hover-bg: #0284c7;
|
||||
--bs-btn-hover-border-color: #0284c7;
|
||||
--bs-btn-active-bg: #0284c7;
|
||||
--bs-btn-active-border-color: #0284c7;
|
||||
--bs-btn-disabled-color: #0284c7;
|
||||
--bs-btn-disabled-border-color: #0284c7;
|
||||
}
|
||||
.pagination {
|
||||
--bs-pagination-active-bg: <?php echo $_theme['main']; ?>;
|
||||
--bs-pagination-active-border-color: <?php echo $_theme['main']; ?>;
|
||||
--bs-pagination-active-bg: #0284c7;
|
||||
--bs-pagination-active-border-color: #0284c7;
|
||||
}
|
||||
.nav-pills { --bs-nav-pills-link-active-bg: <?php echo $_theme['main']; ?>; }
|
||||
.dropdown-menu { --bs-dropdown-link-active-bg: <?php echo $_theme['main']; ?>; }
|
||||
.nav-pills { --bs-nav-pills-link-active-bg: #0284c7; }
|
||||
.dropdown-menu { --bs-dropdown-link-active-bg: #0284c7; }
|
||||
.list-group {
|
||||
--bs-list-group-active-bg: <?php echo $_theme['main']; ?>;
|
||||
--bs-list-group-active-border-color: <?php echo $_theme['main']; ?>;
|
||||
--bs-list-group-active-bg: #0284c7;
|
||||
--bs-list-group-active-border-color: #0284c7;
|
||||
}
|
||||
.progress, .progress-stacked { --bs-progress-bar-bg: <?php echo $_theme['main']; ?>; }
|
||||
.progress, .progress-stacked { --bs-progress-bar-bg: #0284c7; }
|
||||
.table-primary {
|
||||
--bs-table-color: <?php echo $_theme['main']; ?>;
|
||||
--bs-table-bg: rgba(<?php echo $_theme['rgb']; ?>, 0.09);
|
||||
--bs-table-border-color: rgba(<?php echo $_theme['rgb']; ?>, 0.15);
|
||||
--bs-table-striped-bg: rgba(<?php echo $_theme['rgb']; ?>, 0.12);
|
||||
--bs-table-active-bg: rgba(<?php echo $_theme['rgb']; ?>, 0.15);
|
||||
--bs-table-hover-bg: rgba(<?php echo $_theme['rgb']; ?>, 0.12);
|
||||
--bs-table-color: #0284c7;
|
||||
--bs-table-bg: rgba(2, 132, 199, 0.09);
|
||||
--bs-table-border-color: rgba(2, 132, 199, 0.15);
|
||||
--bs-table-striped-bg: rgba(2, 132, 199, 0.12);
|
||||
--bs-table-active-bg: rgba(2, 132, 199, 0.15);
|
||||
--bs-table-hover-bg: rgba(2, 132, 199, 0.12);
|
||||
}
|
||||
thead.border-light th {
|
||||
background-color: rgba(<?php echo $_theme['rgb']; ?>, 0.09) !important;
|
||||
color: <?php echo $_theme['main']; ?> !important;
|
||||
border-color: rgba(<?php echo $_theme['rgb']; ?>, 0.15) !important;
|
||||
background-color: rgba(2, 132, 199, 0.09) !important;
|
||||
color: #0284c7 !important;
|
||||
border-color: rgba(2, 132, 199, 0.15) !important;
|
||||
}
|
||||
</style>
|
||||
<?php endif; ?>
|
||||
@@ -195,40 +165,34 @@ $_usage_full = $_usage_max_pct >= 100;
|
||||
<ul class="list-unstyled d-flex align-items-center mb-0 gap-1">
|
||||
|
||||
<!-- App switcher -->
|
||||
<?php if (count($topbar_switch_apps) > 1): ?>
|
||||
<?php if ($topbar_show_wms && $topbar_show_accounting): ?>
|
||||
<li class="dropdown">
|
||||
<a class="position-relative btn-icon btn-sm btn-light btn rounded-circle" data-bs-toggle="dropdown"
|
||||
aria-expanded="false" href="#" role="button" title="Switch App">
|
||||
<i class="ti ti-layout-grid" style="font-size:18px;"></i>
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-end p-2" style="min-width:210px;">
|
||||
<div class="dropdown-menu dropdown-menu-end p-2" style="min-width:170px;">
|
||||
<?php $_sess_app = $_SESSION['login_current_app'] ?? 'wms'; ?>
|
||||
<?php foreach ($topbar_switch_apps as $_app_key => $_app): ?>
|
||||
<a href="<?php echo $server_url . app_home_path($app_registry, $_app_key); ?>"
|
||||
class="dropdown-item rounded d-flex align-items-center gap-2 py-2 <?php echo $_sess_app === $_app_key ? 'active' : ''; ?>">
|
||||
<i class="ti <?php echo htmlspecialchars($_app['icon'] ?? 'ti-apps', ENT_QUOTES, 'UTF-8'); ?>"></i>
|
||||
<span><?php echo htmlspecialchars($_app['label'] ?? $_app_key, ENT_QUOTES, 'UTF-8'); ?></span>
|
||||
<?php if ($_sess_app === $_app_key): ?>
|
||||
<a href="<?php echo $server_url?>dashboard/index.php"
|
||||
class="dropdown-item rounded d-flex align-items-center gap-2 py-2 <?php echo $_sess_app === 'wms' ? 'active' : ''; ?>">
|
||||
<i class="ti ti-box"></i>
|
||||
<span>WMS</span>
|
||||
<?php if ($_sess_app === 'wms'): ?>
|
||||
<i class="ti ti-check ms-auto text-success"></i>
|
||||
<?php endif; ?>
|
||||
</a>
|
||||
<a href="<?php echo $server_url?>ac_dashboard/index.php"
|
||||
class="dropdown-item rounded d-flex align-items-center gap-2 py-2 <?php echo $_sess_app === 'accounting' ? 'active' : ''; ?>">
|
||||
<i class="ti ti-calculator"></i>
|
||||
<span>Accounting</span>
|
||||
<?php if ($_sess_app === 'accounting'): ?>
|
||||
<i class="ti ti-check ms-auto text-success"></i>
|
||||
<?php endif; ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Email OTP off (the default): a password-only sign-in must never be invisible to whoever is using it -->
|
||||
<?php if (!otp_required()): ?>
|
||||
<li class="d-none d-md-block">
|
||||
<span class="badge bg-warning text-dark d-flex align-items-center gap-1 px-2 py-1"
|
||||
style="font-size:11px; cursor:default;"
|
||||
title="OTP_REQUIRED is not true in config.php">
|
||||
<i class="ti ti-shield-off"></i>
|
||||
OTP off
|
||||
</span>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Usage limit warning -->
|
||||
<?php if ($_usage_full || $_usage_warn): ?>
|
||||
<li>
|
||||
@@ -438,9 +402,7 @@ function do_switch_branch(company_id) {
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'update',
|
||||
// Sent under its own key: prepare_form_data() always fills company_id with
|
||||
// the CURRENT company, and only options.data reaches the payload.
|
||||
data: { target_company_id: company_id },
|
||||
company_id: company_id,
|
||||
onSuccess: function(res) {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
+1
-13
@@ -43,12 +43,6 @@
|
||||
<option value="receipt">Receipt</option>
|
||||
<option value="payment">Payment</option>
|
||||
<option value="manual">Manual</option>
|
||||
<optgroup label="Asset Management">
|
||||
<option value="asset_capitalization">[Asset Mgmt] Capitalization</option>
|
||||
<option value="asset_depreciation">[Asset Mgmt] Depreciation</option>
|
||||
<option value="asset_amortization">[Asset Mgmt] Amortization</option>
|
||||
<option value="asset_disposal">[Asset Mgmt] Disposal</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
@@ -111,8 +105,6 @@
|
||||
invoice: 'Invoice', credit_note: 'Credit Note',
|
||||
purchase_invoice: 'Purchase Invoice', supplier_credit_note: 'Supplier Credit Note',
|
||||
receipt: 'Receipt', payment: 'Payment', manual: 'Manual',
|
||||
asset_capitalization: 'Asset Mgmt · Capitalization', asset_depreciation: 'Asset Mgmt · Depreciation',
|
||||
asset_amortization: 'Asset Mgmt · Amortization', asset_disposal: 'Asset Mgmt · Disposal',
|
||||
};
|
||||
var type_badge_cls = {
|
||||
invoice: 'bg-primary-subtle text-primary',
|
||||
@@ -122,10 +114,6 @@
|
||||
supplier_credit_note: 'bg-secondary-subtle text-secondary',
|
||||
credit_note: 'bg-info-subtle text-info',
|
||||
manual: 'bg-dark-subtle text-dark',
|
||||
asset_capitalization: 'bg-success text-white',
|
||||
asset_depreciation: 'bg-success text-white',
|
||||
asset_amortization: 'bg-success text-white',
|
||||
asset_disposal: 'bg-success text-white',
|
||||
};
|
||||
|
||||
function load_listing() {
|
||||
@@ -199,7 +187,7 @@
|
||||
|
||||
document.getElementById('gl_detail_body').innerHTML =
|
||||
'<div class="row g-3 mb-4">' +
|
||||
'<div class="col-6"><p class="text-muted small mb-0">Date</p><strong>' + escape_html(format_date(h.journal_date)) + '</strong></div>' +
|
||||
'<div class="col-6"><p class="text-muted small mb-0">Date</p><strong>' + escape_html(h.journal_date_fmt || h.journal_date || '—') + '</strong></div>' +
|
||||
'<div class="col-6"><p class="text-muted small mb-0">Period</p><strong>' + escape_html(h.period) + '</strong></div>' +
|
||||
'<div class="col-6"><p class="text-muted small mb-0">Reference</p><strong>' + escape_html(h.reference || '—') + '</strong></div>' +
|
||||
'<div class="col-6"><p class="text-muted small mb-0">Source</p><strong>' + escape_html(src_labels[h.source_type] || h.source_type) + '</strong></div>' +
|
||||
|
||||
@@ -58,7 +58,6 @@ require_once '../../../config.php';
|
||||
require_once '../../../preset.php';
|
||||
define('UNAUTHENTICATED_ROUTE', true);
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/otp_policy.php';
|
||||
|
||||
// ── Step 1: Load session state written by login_otp.php ───────────────────────
|
||||
$data["username"] = $_SESSION["login_data"]['username'];
|
||||
@@ -101,15 +100,9 @@ $_SESSION["diff"] = $otp_diff_minutes;
|
||||
|
||||
// ── Step 4: Validate OTP value and expiry ─────────────────────────────────────
|
||||
// Skipped for staff/viewer roles — login_otp.php sets skip_otp=true in session
|
||||
// so they never receive or enter an OTP. Admin/owner always go through this check,
|
||||
// unless OTP_REQUIRED=false in config.php: that also covers a user who was already
|
||||
// on the OTP screen when the switch was turned off.
|
||||
// so they never receive or enter an OTP. Admin/owner always go through this check.
|
||||
if (empty($_SESSION['skip_otp'])) {
|
||||
if (!otp_required()) {
|
||||
if (!empty($user_id)) {
|
||||
otp_log_bypass($user_id, 'login_confirm');
|
||||
}
|
||||
} elseif ($data["otp"] != $otp || $otp_diff_minutes > 5) {
|
||||
if ($data["otp"] != $otp || $otp_diff_minutes > 5) {
|
||||
$answer["message"] = "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)";
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
@@ -62,7 +62,6 @@ require_once '../../../config.php';
|
||||
require_once '../../../preset.php';
|
||||
define('UNAUTHENTICATED_ROUTE', true);
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/otp_policy.php';
|
||||
|
||||
// ── Step 1: Resolve user_id from username or email (case-insensitive) ────────
|
||||
$sth = $pdo1->prepare("select user_id from user where ? in (username,email) ");
|
||||
@@ -254,15 +253,11 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
// ── Step 5g: OTP policy, then role check — staff/viewer skip OTP entirely ─
|
||||
// OTP_REQUIRED=false in config.php turns the email OTP off for everyone and
|
||||
// logs the sign-in as a bypass (see assets/utils/otp_policy.php).
|
||||
// Otherwise owners always require 2FA. Invited users (license='user') require 2FA only
|
||||
// ── Step 5g: Role check — staff/viewer skip OTP entirely ─────────────────
|
||||
// Owners always require 2FA. Invited users (license='user') require 2FA only
|
||||
// if their role in this company is admin or owner; staff/viewer go straight in.
|
||||
$requires_otp = otp_required();
|
||||
if (!$requires_otp) {
|
||||
otp_log_bypass($user_id, 'login_otp');
|
||||
} elseif (($r['license'] ?? 'owner') !== 'owner') {
|
||||
$requires_otp = true;
|
||||
if (($r['license'] ?? 'owner') !== 'owner') {
|
||||
$sth_role = $pdo1->prepare(
|
||||
"SELECT role FROM company_map_user WHERE company_id = :cid AND user_id = :uid LIMIT 1"
|
||||
);
|
||||
|
||||
@@ -19,10 +19,8 @@
|
||||
* 2. CSRF check — rejects requests missing a valid X-CSRF-Token header.
|
||||
* 3. Decode and sanitise input fields.
|
||||
* 4. Required field validation — company_name and channel_name must be non-empty.
|
||||
* 5. SMTP validation — smtp_host, smtp_username, smtp_password must all be
|
||||
* provided while email OTP is on (company SMTP delivers the OTP). With
|
||||
* OTP_REQUIRED=false they are optional but all-or-nothing: left blank,
|
||||
* steps 6-8 and 13 are skipped and the company is created without SMTP.
|
||||
* 5. Required SMTP validation — smtp_host, smtp_username, smtp_password
|
||||
* must all be provided (company SMTP is mandatory for WMS email delivery).
|
||||
* 6. Normalise smtp_port to one of ['25', '465', '587'] (default: 587).
|
||||
* Normalise smtp_encryption to one of ['tls', 'ssl', 'none'] (default: tls).
|
||||
* 7. Encrypt SMTP password with OpenSSL (same method/iv/key as rest of app).
|
||||
@@ -54,7 +52,6 @@ require_once '../../../session.php';
|
||||
require_once '../../../config.php';
|
||||
require_once '../../../dbconn.php';
|
||||
require_once '../../../assets/utils/db_helpers.php';
|
||||
require_once '../../../assets/utils/otp_policy.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
@@ -100,9 +97,9 @@ try {
|
||||
$company_name = trim($data['company_name'] ?? '');
|
||||
$company_name2 = trim($data['company_name2'] ?? '');
|
||||
|
||||
// channel_name is the URL slug / identifier — lowercase first, then strip
|
||||
// everything except lowercase letters, digits, hyphens, and underscores.
|
||||
$channel_name = preg_replace('/[^a-z0-9\-_]/', '', strtolower(trim($data['channel_name'] ?? '')));
|
||||
// channel_name is the URL slug / identifier — strip everything except
|
||||
// lowercase letters, digits, hyphens, and underscores.
|
||||
$channel_name = strtolower(preg_replace('/[^a-z0-9\-_]/', '', $data['channel_name'] ?? ''));
|
||||
|
||||
$branch = trim($data['branch'] ?? 'สำนักงานใหญ่');
|
||||
$branch_no = trim($data['branch_no'] ?? '00000');
|
||||
@@ -117,67 +114,59 @@ try {
|
||||
}
|
||||
|
||||
// ── Step 5: SMTP field validation ────────────────────────────────────────
|
||||
// While email OTP is on, SMTP is mandatory: the company needs it to send OTP
|
||||
// emails, and an account without working SMTP could not complete 2FA login.
|
||||
// With OTP_REQUIRED=false in config.php it is optional — all three fields
|
||||
// left blank means "no SMTP", and the test send (step 8) and the company_smtp
|
||||
// row (step 13) are skipped. Partly filled is an error either way.
|
||||
// SMTP is mandatory because the company needs to send OTP emails to users.
|
||||
// An account without working SMTP would be unable to complete 2FA login.
|
||||
$smtp_host = trim($data['smtp_host'] ?? '');
|
||||
$smtp_username = trim($data['smtp_username'] ?? '');
|
||||
$smtp_password = $data['smtp_password'] ?? '';
|
||||
$smtp_given = ($smtp_host !== '' || $smtp_username !== '' || $smtp_password !== '');
|
||||
|
||||
if ((otp_required() || $smtp_given) && (!$smtp_host || !$smtp_username || !$smtp_password)) {
|
||||
$answer['message'] = otp_required()
|
||||
? 'SMTP configuration is required. Please fill in all SMTP fields.'
|
||||
: 'Fill in SMTP host, username and password, or leave all three blank.';
|
||||
if (!$smtp_host || !$smtp_username || !$smtp_password) {
|
||||
$answer['message'] = 'SMTP configuration is required. Please fill in all SMTP fields.';
|
||||
http_response_code(422);
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
if ($smtp_given) {
|
||||
// ── Step 6: Normalise SMTP port and encryption ────────────────────────
|
||||
// Clamp to known-good values to prevent storing unsupported configuration.
|
||||
$smtp_port = trim($data['smtp_port'] ?? '587');
|
||||
$smtp_encryption = trim($data['smtp_encryption'] ?? 'tls');
|
||||
// ── Step 6: Normalise SMTP port and encryption ────────────────────────────
|
||||
// Clamp to known-good values to prevent storing unsupported configuration.
|
||||
$smtp_port = trim($data['smtp_port'] ?? '587');
|
||||
$smtp_encryption = trim($data['smtp_encryption'] ?? 'tls');
|
||||
|
||||
if (!in_array($smtp_port, ['25', '465', '587'], true)) $smtp_port = '587';
|
||||
if (!in_array($smtp_encryption, ['tls', 'ssl', 'none'], true)) $smtp_encryption = 'tls';
|
||||
if (!in_array($smtp_port, ['25', '465', '587'], true)) $smtp_port = '587';
|
||||
if (!in_array($smtp_encryption, ['tls', 'ssl', 'none'], true)) $smtp_encryption = 'tls';
|
||||
|
||||
// ── Step 7: Encrypt SMTP password ────────────────────────────────────
|
||||
// Uses the same OpenSSL method/iv/key as the rest of the app (from config.php)
|
||||
// so the stored password can be decrypted by the mailer module.
|
||||
$encrypted_pass = openssl_encrypt($smtp_password, $method, $pinkey, 0, $iv);
|
||||
// ── Step 7: Encrypt SMTP password ────────────────────────────────────────
|
||||
// Uses the same OpenSSL method/iv/key as the rest of the app (from config.php)
|
||||
// so the stored password can be decrypted by the mailer module.
|
||||
$encrypted_pass = openssl_encrypt($smtp_password, $method, $pinkey, 0, $iv);
|
||||
|
||||
// Assemble a temporary SMTP config for the test send (step 8)
|
||||
$smtp_config = [
|
||||
'server' => $smtp_host,
|
||||
'port' => $smtp_port,
|
||||
'username' => $smtp_username,
|
||||
'password' => $encrypted_pass,
|
||||
'from_name' => $company_name ?: $smtp_username,
|
||||
'from_email' => $email ?: $smtp_username,
|
||||
'encryption' => $smtp_encryption,
|
||||
];
|
||||
// Assemble a temporary SMTP config for the test send (step 8)
|
||||
$smtp_config = [
|
||||
'server' => $smtp_host,
|
||||
'port' => $smtp_port,
|
||||
'username' => $smtp_username,
|
||||
'password' => $encrypted_pass,
|
||||
'from_name' => $company_name ?: $smtp_username,
|
||||
'from_email' => $email ?: $smtp_username,
|
||||
'encryption' => $smtp_encryption,
|
||||
];
|
||||
|
||||
// ── Step 8: Silent SMTP test — before any DB writes ──────────────────
|
||||
// Sends a test email to the onboarding user's registered address.
|
||||
// If the mailer throws or exits, no DB records have been created yet,
|
||||
// so the user can correct their SMTP settings and retry cleanly.
|
||||
require_once '../../../assets/utils/module/mailer.php';
|
||||
// ── Step 8: Silent SMTP test — before any DB writes ──────────────────────
|
||||
// Sends a test email to the onboarding user's registered address.
|
||||
// If the mailer throws or exits, no DB records have been created yet,
|
||||
// so the user can correct their SMTP settings and retry cleanly.
|
||||
require_once '../../../assets/utils/module/mailer.php';
|
||||
|
||||
$mailer = new mailer(['pdo1' => $pdo1]);
|
||||
$mailer->send_email([
|
||||
'company_id' => 0,
|
||||
'smtp' => $smtp_config,
|
||||
'to' => $_SESSION['onboarding_email'] ?? $smtp_username,
|
||||
'subject' => 'WMS — SMTP Verification',
|
||||
'message' => "Your SMTP is working correctly.\n\nSetup is now complete.",
|
||||
'channel_name' => $company_name ?: 'WMS',
|
||||
'key' => $pinkey,
|
||||
]);
|
||||
// If mailer fails, it calls exit() internally — nothing below this line runs.
|
||||
}
|
||||
$mailer = new mailer(['pdo1' => $pdo1]);
|
||||
$mailer->send_email([
|
||||
'company_id' => 0,
|
||||
'smtp' => $smtp_config,
|
||||
'to' => $_SESSION['onboarding_email'] ?? $smtp_username,
|
||||
'subject' => 'WMS — SMTP Verification',
|
||||
'message' => "Your SMTP is working correctly.\n\nSetup is now complete.",
|
||||
'channel_name' => $company_name ?: 'WMS',
|
||||
'key' => $pinkey,
|
||||
]);
|
||||
// If mailer fails, it calls exit() internally — nothing below this line runs.
|
||||
|
||||
// ── Step 9: Duplicate channel_name check ─────────────────────────────────
|
||||
// channel_name is the unique identifier used in URLs and API calls — must be globally unique.
|
||||
@@ -237,29 +226,25 @@ try {
|
||||
// ── Step 13: Save company SMTP settings ──────────────────────────────────
|
||||
// Stored with the encrypted password so the mailer module can decrypt and
|
||||
// use it for all outgoing email from this company (OTP, notifications, etc.).
|
||||
// Skipped when no SMTP was given (only allowed with OTP_REQUIRED=false); it
|
||||
// can be added later under Settings → SMTP.
|
||||
if ($smtp_given) {
|
||||
$sth = $pdo1->prepare("
|
||||
INSERT INTO company_smtp
|
||||
(company_id, server, port, username, password,
|
||||
from_name, from_email, encryption, updated_at)
|
||||
VALUES
|
||||
(:company_id, :server, :port, :username, :password,
|
||||
:from_name, :from_email, :encryption, NOW())
|
||||
");
|
||||
$sth->execute([
|
||||
':company_id' => $company_id,
|
||||
':server' => $smtp_host,
|
||||
':port' => $smtp_port,
|
||||
':username' => $smtp_username,
|
||||
':password' => $encrypted_pass,
|
||||
':from_name' => $company_name,
|
||||
':from_email' => $email ?: $smtp_username,
|
||||
':encryption' => $smtp_encryption,
|
||||
]);
|
||||
db_check($sth, $answer);
|
||||
}
|
||||
$sth = $pdo1->prepare("
|
||||
INSERT INTO company_smtp
|
||||
(company_id, server, port, username, password,
|
||||
from_name, from_email, encryption, updated_at)
|
||||
VALUES
|
||||
(:company_id, :server, :port, :username, :password,
|
||||
:from_name, :from_email, :encryption, NOW())
|
||||
");
|
||||
$sth->execute([
|
||||
':company_id' => $company_id,
|
||||
':server' => $smtp_host,
|
||||
':port' => $smtp_port,
|
||||
':username' => $smtp_username,
|
||||
':password' => $encrypted_pass,
|
||||
':from_name' => $company_name,
|
||||
':from_email' => $email ?: $smtp_username,
|
||||
':encryption' => $smtp_encryption,
|
||||
]);
|
||||
db_check($sth, $answer);
|
||||
|
||||
// ── Step 14: Clear onboarding session keys ───────────────────────────────
|
||||
// These keys are no longer needed and should not persist into the
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
require '../config.php';
|
||||
|
||||
if (!empty($_SESSION['login_status'])) {
|
||||
require_once '../assets/utils/app_registry.php';
|
||||
$login_app = app_default_for_access($app_registry, $_SESSION['login_app_access'] ?? 'wms');
|
||||
header('Location: ' . $server_url . app_home_path($app_registry, $login_app));
|
||||
header('Location: ' . $server_url . 'dashboard/index.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
+5
-21
@@ -1,13 +1,12 @@
|
||||
<?php
|
||||
require '../session.php';
|
||||
require '../config.php';
|
||||
require_once '../assets/utils/otp_policy.php';
|
||||
require '../include_header.php';
|
||||
require_once '../assets/utils/app_registry.php';
|
||||
// successful login — redirect based on app_access
|
||||
if(!empty($_SESSION["login_status"])){
|
||||
$login_app = app_default_for_access($app_registry, $_SESSION['login_app_access'] ?? 'wms');
|
||||
$redirect = $server_url . app_home_path($app_registry, $login_app);
|
||||
$redirect = ($_SESSION['login_app_access'] ?? 'wms') === 'accounting'
|
||||
? $server_url . 'ac_dashboard/index.php'
|
||||
: $server_url . 'dashboard/index.php';
|
||||
header('Location: ' . $redirect);
|
||||
exit;
|
||||
}
|
||||
@@ -33,13 +32,6 @@
|
||||
</div>
|
||||
|
||||
<form class="needs-validation mt-3" novalidate id="login-form">
|
||||
<?php if (!otp_required()): ?>
|
||||
<!-- OTP_REQUIRED is not true in config.php (the default): a password-only sign-in must never be invisible -->
|
||||
<div class="alert alert-warning small py-2 mb-3" title="OTP_REQUIRED is not true in config.php">
|
||||
<i class="ti ti-alert-triangle me-1"></i>
|
||||
Email OTP is off — sign-in is password only.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<!-- first step login [OTP] -->
|
||||
<?php if(!isset($_SESSION['login_data'])){?>
|
||||
<div class="mb-3">
|
||||
@@ -59,7 +51,7 @@
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<!-- "Remember me" is intentionally excluded.
|
||||
This login uses 2FA (OTP via email) on every session when OTP_REQUIRED=true in config.php (off by default).
|
||||
This login uses 2FA (OTP via email) on every session.
|
||||
A persistent login would bypass the OTP step and undermine the security model.
|
||||
Do not add this back. -->
|
||||
</div>
|
||||
@@ -70,7 +62,6 @@
|
||||
</p>
|
||||
<?php }else{ ?>
|
||||
<!-- second step login -->
|
||||
<?php if (otp_required()): ?>
|
||||
<div class="alert alert-warning small py-2 mb-3">
|
||||
<i class="ti ti-mail me-1"></i>
|
||||
OTP is sent via your company's SMTP setting.
|
||||
@@ -82,20 +73,13 @@
|
||||
<span>One Time Password</span>
|
||||
</label>
|
||||
<input id="otp" type="otp" class="form-control"
|
||||
placeholder="your otp for reference number <?php echo $_SESSION["reference"] ?? ''?>" required minlength="6">
|
||||
placeholder="your otp for reference number <?php echo $_SESSION["reference"]?>" required minlength="6">
|
||||
<div class="invalid-feedback">Please provide a otp (min 6 characters).</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<!-- OTP_REQUIRED was switched off while this session sat on the OTP step:
|
||||
login_confirm.php no longer checks the code, so there is nothing to type. -->
|
||||
<input id="otp" type="hidden" value="">
|
||||
<?php endif; ?>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label d-flex justify-content-between">
|
||||
<a href="javascript:;" class="small link-primary" onclick="back()">Back</a>
|
||||
<?php if (otp_required()): ?>
|
||||
<a href="javascript:;" class="small link-primary" onclick="request_new_otp();">Request New OTP</a>
|
||||
<?php endif; ?>
|
||||
</label>
|
||||
</div>
|
||||
<button class="btn btn-primary w-100" onclick="login_confirm();">Sign in</button>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<?php
|
||||
require '../session.php';
|
||||
require '../config.php';
|
||||
require_once '../assets/utils/otp_policy.php';
|
||||
|
||||
// Must come from email verification
|
||||
if (empty($_SESSION['onboarding_user_id'])) {
|
||||
@@ -49,8 +48,7 @@
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Channel Name <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="channel_name" placeholder="e.g. my-shop"
|
||||
oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9_-]/g,'')">
|
||||
<input type="text" class="form-control" id="channel_name" placeholder="e.g. my-shop">
|
||||
<div class="form-text">Unique identifier. Lowercase, no spaces.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
@@ -79,20 +77,11 @@
|
||||
|
||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||
<h2 class="fs-5 mb-0"><i class="ti ti-mail-cog me-2"></i>SMTP / Email Setting</h2>
|
||||
<?php if (otp_required()): ?>
|
||||
<span class="badge bg-label-danger">Required</span>
|
||||
<?php else: ?>
|
||||
<span class="badge bg-label-secondary">Optional</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<p class="text-muted small mb-3">
|
||||
<?php if (otp_required()): ?>
|
||||
SMTP is required to send OTP during login.
|
||||
A verification email will be sent when you finish setup.
|
||||
<?php else: ?>
|
||||
Email OTP is turned off, so SMTP is optional. Leave it blank to skip;
|
||||
you can add it later under Settings → SMTP.
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
|
||||
<!-- SMTP User Guide (collapsible) -->
|
||||
@@ -185,11 +174,11 @@
|
||||
<!-- SMTP Form -->
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">SMTP Host <?php if (otp_required()): ?><span class="text-danger">*</span><?php endif; ?></label>
|
||||
<label class="form-label">SMTP Host <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="smtp_host" placeholder="e.g. smtp.gmail.com">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Port <?php if (otp_required()): ?><span class="text-danger">*</span><?php endif; ?></label>
|
||||
<label class="form-label">Port <span class="text-danger">*</span></label>
|
||||
<select class="form-select" id="smtp_port">
|
||||
<option value="587">587 — TLS</option>
|
||||
<option value="465">465 — SSL</option>
|
||||
@@ -197,11 +186,11 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Username / Email <?php if (otp_required()): ?><span class="text-danger">*</span><?php endif; ?></label>
|
||||
<label class="form-label">Username / Email <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="smtp_username" placeholder="your@email.com">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Password <?php if (otp_required()): ?><span class="text-danger">*</span><?php endif; ?></label>
|
||||
<label class="form-label">Password <span class="text-danger">*</span></label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="smtp_password" placeholder="SMTP password">
|
||||
<button class="btn btn-outline-secondary toggle-pw" type="button" data-target="smtp_password">
|
||||
@@ -262,23 +251,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Mirrors api/engine/onboarding.php: SMTP is required while email OTP is on;
|
||||
// with OTP_REQUIRED=false it is optional, but the three fields go together.
|
||||
const smtp_required = <?php echo otp_required() ? 'true' : 'false'; ?>;
|
||||
const smtp_host = $('#smtp_host').val().trim();
|
||||
const smtp_user = $('#smtp_username').val().trim();
|
||||
const smtp_pass = $('#smtp_password').val();
|
||||
const smtp_given = !!(smtp_host || smtp_user || smtp_pass);
|
||||
|
||||
if ((smtp_required || smtp_given) && (!smtp_host || !smtp_user || !smtp_pass)) {
|
||||
bootbox.alert(smtp_required
|
||||
? 'SMTP host, username and password are required.'
|
||||
: 'Fill in SMTP host, username and password, or leave all three blank.');
|
||||
if (!$('#smtp_host').val().trim() || !$('#smtp_username').val().trim() || !$('#smtp_password').val()) {
|
||||
bootbox.alert('SMTP host, username and password are required.');
|
||||
return;
|
||||
}
|
||||
|
||||
const $btn = $('#btn_finish');
|
||||
$btn.prop('disabled', true).html('<i class="ti ti-loader-2 me-1"></i>' + (smtp_given ? 'Verifying SMTP…' : 'Setting up…'));
|
||||
$btn.prop('disabled', true).html('<i class="ti ti-loader-2 me-1"></i>Verifying SMTP…');
|
||||
|
||||
const encryption = $('input[name="smtp_encryption"]:checked').val();
|
||||
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
|
||||
// Redirect if already logged in
|
||||
if (!empty($_SESSION['login_status'])) {
|
||||
require_once '../assets/utils/app_registry.php';
|
||||
$login_app = app_default_for_access($app_registry, $_SESSION['login_app_access'] ?? 'wms');
|
||||
header('Location: ' . $server_url . app_home_path($app_registry, $login_app));
|
||||
header('Location: ' . $server_url . 'dashboard/index.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
@@ -250,7 +250,7 @@
|
||||
$('#display_contact').text(inv.contact_name || '—');
|
||||
$('#display_department').text(get_dept_label(inv.department_id));
|
||||
$('#display_issued').text(format_date(inv.issued_date) || '—');
|
||||
$('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : '');
|
||||
$('#due_date').val(inv.due_date ? format_date(inv.due_date) : '');
|
||||
$('#notes').val(inv.notes || '');
|
||||
|
||||
var gl_type = inv.doc_type === 'credit_note' ? 'sales_credit_note' : 'sales_invoice';
|
||||
|
||||
@@ -493,7 +493,7 @@
|
||||
// Fields
|
||||
$('#contact').val(o.contact_name || '');
|
||||
$('#contact_id').val(o.contact_id);
|
||||
$('#order_date').val(o.order_date ? format_date_input(o.order_date) : '');
|
||||
$('#order_date').val(o.order_date ? format_date(o.order_date) : '');
|
||||
$('#department_id').val(o.department_id || 0);
|
||||
$('#notes').val(o.notes || '');
|
||||
$('#discount').val(o.discount || 0);
|
||||
|
||||
@@ -795,7 +795,7 @@
|
||||
$('#badge_status').html(return_status_badge(r.status));
|
||||
$('#return_number_display').text(r.return_number);
|
||||
|
||||
$('#return_date').val(r.return_date ? format_date_input(r.return_date) : '');
|
||||
$('#return_date').val(r.return_date ? format_date(r.return_date) : '');
|
||||
$('#reason').val(r.reason || '');
|
||||
$('#tax_adjustment').val(parseFloat(r.tax_adjustment || 0).toFixed(2));
|
||||
$('#display_department').text(get_dept_label(r.department_id));
|
||||
|
||||
@@ -682,8 +682,8 @@
|
||||
|
||||
$('#contact').val(o.contact_name || '');
|
||||
$('#contact_id').val(o.contact_id || '');
|
||||
$('#po_date').val(o.po_date ? format_date_input(o.po_date) : '');
|
||||
$('#expected_date').val(o.expected_date ? format_date_input(o.expected_date) : '');
|
||||
$('#po_date').val(o.po_date ? format_date(o.po_date) : '');
|
||||
$('#expected_date').val(o.expected_date ? format_date(o.expected_date) : '');
|
||||
$('#warehouse_id').val(o.warehouse_id || '');
|
||||
$('#department_id').val(o.department_id || 0);
|
||||
$('#notes').val(o.notes || '');
|
||||
|
||||
@@ -226,7 +226,7 @@
|
||||
$('#display_contact').text(inv.contact_name || '—');
|
||||
$('#display_department').text(get_dept_label(inv.department_id));
|
||||
$('#display_issued').text(format_date(inv.issued_date) || '—');
|
||||
$('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : '');
|
||||
$('#due_date').val(inv.due_date ? format_date(inv.due_date) : '');
|
||||
$('#notes').val(inv.notes || '');
|
||||
|
||||
// Source link
|
||||
|
||||
@@ -432,7 +432,7 @@
|
||||
$('#badge_status').html(return_status_badge(r.status));
|
||||
$('#badge_fulfillment').html(fulfillment_status_badge(r.fulfillment_status));
|
||||
$('#return_number_display').text(r.return_number);
|
||||
$('#return_date').val(r.return_date ? format_date_input(r.return_date) : '');
|
||||
$('#return_date').val(r.return_date ? format_date(r.return_date) : '');
|
||||
$('#reason').val(r.reason || '');
|
||||
$('#tax_adjustment').val(parseFloat(r.tax_adjustment || 0).toFixed(2));
|
||||
$('#display_department').text(get_dept_label(r.department_id));
|
||||
|
||||
@@ -207,7 +207,7 @@
|
||||
$('#display_contact').html(display_text(inv.contact_name));
|
||||
$('#display_issued').html(inv.issued_date ? format_date(inv.issued_date) : '<span class="text-muted">—</span>');
|
||||
$('#display_due').html(inv.due_date ? format_date(inv.due_date) : '<span class="text-muted">—</span>');
|
||||
$('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : '');
|
||||
$('#due_date').val(inv.due_date ? format_date(inv.due_date) : '');
|
||||
$('#display_notes').html(inv.notes ? escape_html(inv.notes).replace(/\n/g, '<br>') : '<span class="text-muted">—</span>');
|
||||
|
||||
var rows = '';
|
||||
|
||||
@@ -356,7 +356,7 @@
|
||||
$('#order_number_display').text(o.order_number);
|
||||
$('#contact').val(o.contact_name || '');
|
||||
$('#contact_id').val(o.contact_id || 0);
|
||||
$('#order_date').val(o.order_date ? format_date_input(o.order_date) : '');
|
||||
$('#order_date').val(o.order_date ? format_date(o.order_date) : '');
|
||||
$('#notes').val(o.notes || '');
|
||||
$('#discount').val(o.discount || 0);
|
||||
$('#tax_adjustment').val(o.tax_adjustment || 0);
|
||||
|
||||
@@ -379,8 +379,8 @@
|
||||
|
||||
$('#contact').val(q.contact_name || '');
|
||||
$('#contact_id').val(q.contact_id || 0);
|
||||
$('#quotation_date').val(q.quotation_date ? format_date_input(q.quotation_date) : '');
|
||||
$('#valid_until').val(q.valid_until ? format_date_input(q.valid_until) : '');
|
||||
$('#quotation_date').val(q.quotation_date ? format_date(q.quotation_date) : '');
|
||||
$('#valid_until').val(q.valid_until ? format_date(q.valid_until) : '');
|
||||
$('#department_id').val(q.department_id || 0);
|
||||
$('#notes').val(q.notes || '');
|
||||
$('#discount').val(parseFloat(q.discount) || 0);
|
||||
|
||||
@@ -2,12 +2,6 @@
|
||||
// app/session.php
|
||||
ob_start(); // ensure output buffering is on regardless of php.ini — prevents stray output from corrupting JSON API responses
|
||||
|
||||
// The buffer is still flushed, so notices would still land in front of the JSON
|
||||
// body and break the client's parse ("Server error occurred."). The login API
|
||||
// engines load this file instead of db_auth.php, so apply the same policy here.
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('log_errors', '1');
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
|
||||
// Derive cookie path dynamically from the current script location.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<?php
|
||||
session_start();
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
require_once '../../../assets/utils/app_registry.php';
|
||||
require_once '../../../assets/utils/classes/UserManager.php';
|
||||
|
||||
if ($user_role !== 'owner') { http_response_code(403); exit(json_encode(['success' => 0, 'message' => 'Only the owner can modify this setting.'])); }
|
||||
@@ -31,11 +30,7 @@
|
||||
$result = $um->inviteUser($email, $role, $app_access);
|
||||
|
||||
// Both new and existing users require explicit acceptance via email
|
||||
// Absolute URL: the link is opened from a mail client, where a bare
|
||||
// /app/... path goes nowhere. Same construction as register.php.
|
||||
$invite_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
|
||||
. '://' . $_SERVER['HTTP_HOST']
|
||||
. rtrim($server_url, '/') . ($result['new_user']
|
||||
$invite_url = rtrim($server_url, '/') . ($result['new_user']
|
||||
? '/login/invited_onboarding.php?token=' . $result['token']
|
||||
: '/login/accept_invite.php?token=' . $result['token']);
|
||||
|
||||
@@ -93,11 +88,7 @@
|
||||
$map_id = (int)($data['map_id'] ?? 0);
|
||||
$result = $um->resendInvite($map_id);
|
||||
|
||||
// Absolute URL: the link is opened from a mail client, where a bare
|
||||
// /app/... path goes nowhere. Same construction as register.php.
|
||||
$invite_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
|
||||
. '://' . $_SERVER['HTTP_HOST']
|
||||
. rtrim($server_url, '/') . ($result['is_new_user']
|
||||
$invite_url = rtrim($server_url, '/') . ($result['is_new_user']
|
||||
? '/login/invited_onboarding.php?token=' . $result['token']
|
||||
: '/login/accept_invite.php?token=' . $result['token']);
|
||||
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
* switch_branch.php — Switch the active company for the current session.
|
||||
*
|
||||
* action: 'read' → return list of companies the user belongs to
|
||||
* action: 'update' → switch to target_company_id
|
||||
*
|
||||
* The target is read from target_company_id, not company_id: every request
|
||||
* carries company_id = the CURRENT company (prepare_form_data in custom.js),
|
||||
* so reading it made a switch silently re-select the company already active.
|
||||
* action: 'update' → switch to the requested company_id
|
||||
*/
|
||||
session_start();
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
@@ -24,7 +20,7 @@ if ($action === 'read') {
|
||||
}
|
||||
|
||||
if ($action === 'update') {
|
||||
$target_company_id = (int)($data['target_company_id'] ?? 0);
|
||||
$target_company_id = (int)($data['company_id'] ?? 0);
|
||||
|
||||
if (!$target_company_id) {
|
||||
$answer['message'] = 'Invalid company.';
|
||||
|
||||
@@ -121,8 +121,7 @@
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Nickname / Channel Name</label>
|
||||
<input type="text" class="form-control" id="channel_name" placeholder="e.g. my-shop"
|
||||
oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9_-]/g,'')">
|
||||
<input type="text" class="form-control" id="channel_name" placeholder="e.g. my-shop">
|
||||
<div class="form-text">Unique identifier. Lowercase, no spaces.</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../config.php';
|
||||
require_once '../assets/utils/app_registry.php';
|
||||
require '../include_header.php';
|
||||
?>
|
||||
|
||||
@@ -257,7 +256,9 @@
|
||||
const license_badge = license_html(u.license);
|
||||
const access_badge = app_access_html(u.app_access);
|
||||
|
||||
const joined = u.created_at ? format_date(String(u.created_at).split(' ')[0]) : '—';
|
||||
const joined = u.created_at
|
||||
? new Date(u.created_at).toLocaleDateString('en-GB', {day:'2-digit', month:'short', year:'numeric'})
|
||||
: '—';
|
||||
|
||||
const is_pending = u.status === 'pending' && u.is_pending_invite == 1;
|
||||
|
||||
|
||||
+2
-4
@@ -532,11 +532,9 @@ foreach ($stockout_defs as $so) {
|
||||
$qty = $so['qty'];
|
||||
$cost = $p['cost'];
|
||||
$uom = $p['uom'];
|
||||
// The batch is bought in from a supplier; only the stock-out goes to the customer.
|
||||
$supplier_id = $supplier_ids[0];
|
||||
|
||||
dbTransaction($pdo2, function ($pdo2) use (
|
||||
$stockMgmt, $whMgmt, $company_id, $main_wh_id, $sku, $qty, $cost, $customer_id, $supplier_id,
|
||||
$stockMgmt, $whMgmt, $company_id, $main_wh_id, $sku, $qty, $cost, $customer_id,
|
||||
$logging, $dispatch_in_marker, $dispatch_out_marker
|
||||
) {
|
||||
$bin = findFreeBin($pdo2, $company_id, $main_wh_id);
|
||||
@@ -551,7 +549,7 @@ foreach ($stockout_defs as $so) {
|
||||
'zone' => $bin,
|
||||
'aisle' => $bin,
|
||||
'bin' => $bin,
|
||||
'contact_id' => $supplier_id,
|
||||
'contact_id' => $customer_id,
|
||||
'description' => $dispatch_in_marker,
|
||||
], $logging, $in_uuid);
|
||||
$stockMgmt->approveStock($stock_id, $main_wh_id, 'in', $whMgmt);
|
||||
|
||||
@@ -1,374 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Demo Data Seeder — Asset Management
|
||||
* Run after demo_seed.php and demo_seed_transactions.php: php demo_seed_assets.php
|
||||
*
|
||||
* Seeds the Asset Management app through the same classes its pages and the
|
||||
* Batch GL Entries page use:
|
||||
* 1. GL accounts for fixed assets, and asset categories (AssetCategoryManager)
|
||||
* 2. A purchase of a laptop fleet + an ERP licence: PO -> confirm -> purchase
|
||||
* invoice -> issue -> GL post (PurchaseOrderManager, InvoiceManager), the
|
||||
* only way a purchase invoice exists in the app
|
||||
* 3. Assets capitalized from those invoice lines, plus two vehicles bought
|
||||
* before the system was used and already on the books (AssetManager)
|
||||
* 4. Depreciation runs up to last month, the sale of the forklift today, then
|
||||
* this month's depreciation and amortization runs (AssetRunManager)
|
||||
* 5. GL posting of the purchase invoice, capitalizations, the disposal and every
|
||||
* run before this month (AssetPosting + GlManager, as
|
||||
* accounting/api/engine/post_gl_entry.php does) — this month's runs stay
|
||||
* unposted so Batch GL Entries has Asset Management work to show.
|
||||
*
|
||||
* Dates are relative to today: the app stamps documents with the current date.
|
||||
*
|
||||
* Safe to re-run: every step checks what already exists.
|
||||
*/
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
http_response_code(403);
|
||||
exit('Run via CLI only: php demo_seed_assets.php');
|
||||
}
|
||||
|
||||
$_SESSION = [];
|
||||
|
||||
require_once __DIR__ . '/app/config.php';
|
||||
require_once __DIR__ . '/app/dbconn.php';
|
||||
require_once __DIR__ . '/app/assets/utils/db_helpers.php';
|
||||
require_once __DIR__ . '/app/assets/utils/classes/WarehouseManager.php';
|
||||
require_once __DIR__ . '/app/assets/utils/classes/StockManager.php';
|
||||
require_once __DIR__ . '/app/assets/utils/classes/ProductManager.php';
|
||||
require_once __DIR__ . '/app/assets/utils/classes/PurchaseOrderManager.php';
|
||||
require_once __DIR__ . '/app/assets/utils/classes/InvoiceManager.php';
|
||||
require_once __DIR__ . '/app/assets/utils/classes_ac/ChartOfAccounts.php';
|
||||
require_once __DIR__ . '/app/assets/utils/classes_ac/PostingWindowGuard.php';
|
||||
require_once __DIR__ . '/app/assets/utils/classes_ac/GlManager.php';
|
||||
require_once __DIR__ . '/app/assets/utils/classes_ac/posting/BasePosting.php';
|
||||
require_once __DIR__ . '/app/assets/utils/classes_ac/posting/PurchaseInvoicePosting.php';
|
||||
require_once __DIR__ . '/app/assets/utils/classes_as/AssetManager.php';
|
||||
require_once __DIR__ . '/app/assets/utils/classes_as/AssetRunManager.php';
|
||||
require_once __DIR__ . '/app/assets/utils/classes_as/AssetPosting.php';
|
||||
|
||||
function info(string $msg): void { echo "\033[36m •\033[0m {$msg}\n"; }
|
||||
function ok(string $msg): void { echo "\033[32m ✓\033[0m {$msg}\n"; }
|
||||
function skip(string $msg): void { echo "\033[33m –\033[0m {$msg}\n"; }
|
||||
|
||||
echo "\n\033[1m=== brnwms Demo Asset Management Seeder ===\033[0m\n\n";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 0. Resolve what the earlier seeders created
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
$sth = $pdo1->prepare("SELECT company_id FROM company_list WHERE channel_name = 'demo' LIMIT 1");
|
||||
$sth->execute();
|
||||
$company_id = (int)($sth->fetchColumn() ?: 0);
|
||||
if (!$company_id) exit("ERROR: demo company not found — run demo_seed.php first.\n");
|
||||
|
||||
$sth = $pdo1->prepare("SELECT user_id FROM user WHERE username = 'admin' LIMIT 1");
|
||||
$sth->execute();
|
||||
$owner_user_id = (int)($sth->fetchColumn() ?: 0);
|
||||
|
||||
$lookup = function (string $sql, array $params) use ($pdo2): int {
|
||||
$sth = $pdo2->prepare($sql);
|
||||
$sth->execute($params);
|
||||
return (int)($sth->fetchColumn() ?: 0);
|
||||
};
|
||||
|
||||
$main_wh = $lookup("SELECT id FROM md_warehouse WHERE company_id = :c AND warehouse_name = 'Main Warehouse' LIMIT 1", [':c' => $company_id]);
|
||||
$supplier_id = $lookup("SELECT id FROM md_contact WHERE company_id = :c AND contact_name = 'Asia Electronics Wholesale' LIMIT 1", [':c' => $company_id]);
|
||||
$wh_dept_id = $lookup("SELECT id FROM md_department WHERE company_id = :c AND dept_code = 'WH' LIMIT 1", [':c' => $company_id]);
|
||||
if (!$main_wh || !$supplier_id || !$wh_dept_id) {
|
||||
exit("ERROR: warehouse, supplier or WH department missing — run demo_seed.php and demo_seed_transactions.php first.\n");
|
||||
}
|
||||
|
||||
$coa = new ChartOfAccounts($pdo2, $company_id);
|
||||
foreach (['1000', '4000', '5000'] as $code) {
|
||||
if (!$coa->getByCode($code)) exit("ERROR: account {$code} missing — run demo_seed_transactions.php first.\n");
|
||||
}
|
||||
|
||||
$logging = ['user_id' => $owner_user_id, 'dt' => date('Y-m-d H:i:s'), 'login' => null, 'action' => 'seed_assets'];
|
||||
$today = date('Y-m-d');
|
||||
$this_period = date('Y-m');
|
||||
$last_period = DepreciationCalculator::addMonths($this_period, -1);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 1. Accounts + asset categories
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
echo "--- Asset accounts ---\n";
|
||||
|
||||
$account_defs = [
|
||||
['code' => '1500', 'name' => 'Equipment and Vehicles', 'type' => 'asset'],
|
||||
['code' => '1590', 'name' => 'Accumulated Depreciation', 'type' => 'asset'],
|
||||
['code' => '1600', 'name' => 'Software and Licences', 'type' => 'asset'],
|
||||
['code' => '1690', 'name' => 'Accumulated Amortization', 'type' => 'asset'],
|
||||
['code' => '4900', 'name' => 'Gain on Asset Disposal', 'type' => 'revenue'],
|
||||
['code' => '6100', 'name' => 'Depreciation Expense', 'type' => 'expense'],
|
||||
['code' => '6200', 'name' => 'Amortization Expense', 'type' => 'expense'],
|
||||
['code' => '6900', 'name' => 'Loss on Asset Disposal', 'type' => 'expense'],
|
||||
];
|
||||
foreach ($account_defs as $a) {
|
||||
if ($coa->getByCode($a['code'])) {
|
||||
skip("Account {$a['code']} — {$a['name']} already exists");
|
||||
continue;
|
||||
}
|
||||
$coa->create([
|
||||
'account_code' => $a['code'], 'account_name' => $a['name'], 'account_type' => $a['type'],
|
||||
'account_category' => '', 'parent_code' => null, 'is_posting' => 1, 'status' => 1,
|
||||
]);
|
||||
ok("Created account {$a['code']} — {$a['name']} ({$a['type']})");
|
||||
}
|
||||
|
||||
echo "\n--- Asset categories ---\n";
|
||||
|
||||
$category_defs = [
|
||||
['code' => 'IT', 'name' => 'Computer Equipment', 'class' => 'tangible', 'life' => 36, 'cost' => '1500', 'accum' => '1590', 'expense' => '6100'],
|
||||
['code' => 'VEH', 'name' => 'Vehicles and Forklifts', 'class' => 'tangible', 'life' => 60, 'cost' => '1500', 'accum' => '1590', 'expense' => '6100'],
|
||||
['code' => 'SW', 'name' => 'Software Licences', 'class' => 'intangible', 'life' => 24, 'cost' => '1600', 'accum' => '1690', 'expense' => '6200'],
|
||||
];
|
||||
$category_ids = [];
|
||||
foreach ($category_defs as $c) {
|
||||
$id = $lookup("SELECT id FROM md_asset_category WHERE company_id = :c AND category_code = :code LIMIT 1", [':c' => $company_id, ':code' => $c['code']]);
|
||||
if ($id) {
|
||||
skip("Category {$c['code']} already exists (id={$id})");
|
||||
} else {
|
||||
$id = (new AssetCategoryManager($pdo2, $company_id))->save([
|
||||
'category_code' => $c['code'], 'category_name' => $c['name'], 'asset_class' => $c['class'],
|
||||
'useful_life_months' => $c['life'], 'status' => 1, 'description' => '',
|
||||
'cost_account_code' => $c['cost'], 'accum_account_code' => $c['accum'], 'expense_account_code' => $c['expense'],
|
||||
'gain_account_code' => '4900', 'loss_account_code' => '6900',
|
||||
]);
|
||||
ok("Created category {$c['code']} — {$c['name']} ({$c['class']}, {$c['life']} months)");
|
||||
}
|
||||
$category_ids[$c['code']] = $id;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 2. Purchase: PO -> confirm -> purchase invoice -> issue -> GL
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
echo "\n--- Asset purchase (PO -> purchase invoice) ---\n";
|
||||
|
||||
$prodMgmt = new ProductManager($pdo2, $company_id);
|
||||
|
||||
$cat_id = $lookup("SELECT id FROM md_product_category WHERE company_id = :c AND slug = 'fixed-assets' LIMIT 1", [':c' => $company_id]);
|
||||
if (!$cat_id) {
|
||||
dbTransaction($pdo2, fn($pdo) => (new ProductManager($pdo, $company_id))->saveCategory([
|
||||
'id' => 0, 'category' => 'Fixed Assets', 'slug' => 'fixed-assets', 'description' => '', 'status' => 1,
|
||||
], $logging));
|
||||
$cat_id = $lookup("SELECT id FROM md_product_category WHERE company_id = :c AND slug = 'fixed-assets' LIMIT 1", [':c' => $company_id]);
|
||||
ok("Created product category 'Fixed Assets' (id={$cat_id})");
|
||||
}
|
||||
|
||||
$asset_product_defs = [
|
||||
['sku' => 'FA-LAPTOP', 'name' => 'Laptop - Dell Latitude 5440', 'price' => 32000],
|
||||
['sku' => 'FA-ERP', 'name' => 'ERP Software Licence (2 years)', 'price' => 48000],
|
||||
];
|
||||
$products = [];
|
||||
foreach ($asset_product_defs as $p) {
|
||||
$id = $lookup("SELECT id FROM md_product WHERE company_id = :c AND sku = :s LIMIT 1", [':c' => $company_id, ':s' => $p['sku']]);
|
||||
if ($id) {
|
||||
skip("Product {$p['sku']} already exists");
|
||||
} else {
|
||||
dbTransaction($pdo2, fn($pdo) => (new ProductManager($pdo, $company_id))->saveProduct([
|
||||
'id' => 0, 'product_name' => $p['name'], 'sku' => $p['sku'], 'barcode' => '', 'uom' => 'pcs',
|
||||
'price' => $p['price'], 'cost_price' => $p['price'], 'min_stock' => 0, 'reorder_point' => 0,
|
||||
'category' => $cat_id, 'description' => 'Bought as a fixed asset (demo seed)', 'status' => 1,
|
||||
], $logging, ''));
|
||||
$id = $lookup("SELECT id FROM md_product WHERE company_id = :c AND sku = :s LIMIT 1", [':c' => $company_id, ':s' => $p['sku']]);
|
||||
// Same accounts as the other demo products: the purchase posts to 5000, and the
|
||||
// capitalization entry reclassifies it from there to the asset account.
|
||||
dbTransaction($pdo2, fn($pdo) => (new ProductManager($pdo, $company_id))->updateAccountMapping($id, '4000', '5000', $logging));
|
||||
ok("Created product {$p['sku']} — {$p['name']} (sales 4000, purchase 5000)");
|
||||
}
|
||||
$products[$p['sku']] = $p;
|
||||
}
|
||||
|
||||
$po_marker = 'Demo seed asset purchase';
|
||||
$po_id = $lookup("SELECT id FROM td_purchase_order WHERE company_id = :c AND notes = :n LIMIT 1", [':c' => $company_id, ':n' => $po_marker]);
|
||||
if ($po_id) {
|
||||
skip("Asset purchase PO already exists (id={$po_id})");
|
||||
} else {
|
||||
$po_items = [];
|
||||
foreach ([['FA-LAPTOP', 3], ['FA-ERP', 1]] as $i => [$sku, $qty]) {
|
||||
$total = round($qty * $products[$sku]['price'], 4);
|
||||
$po_items[] = [
|
||||
'item_id' => $i + 1, 'product_sku' => $sku, 'product_name' => $products[$sku]['name'],
|
||||
'quantity' => $qty, 'unit_price' => $products[$sku]['price'], 'total_price' => $total,
|
||||
'tax_rate' => 7.0, 'tax_amount' => round($total * 0.07, 4),
|
||||
];
|
||||
}
|
||||
$po_id = dbTransaction($pdo2, function ($pdo) use ($company_id, $supplier_id, $wh_dept_id, $main_wh, $po_items, $today, $po_marker, $logging) {
|
||||
$poMgmt = new PurchaseOrderManager($pdo, $company_id);
|
||||
$pid = $poMgmt->savePo([
|
||||
'id' => 0, 'contact_id' => $supplier_id, 'department_id' => $wh_dept_id,
|
||||
'po_date' => $today, 'expected_date' => date('Y-m-d', strtotime('+7 days')),
|
||||
'warehouse_id' => $main_wh, 'items' => $po_items, 'discount' => 0, 'shipping_fee' => 0,
|
||||
'notes' => $po_marker,
|
||||
], $logging);
|
||||
$poMgmt->confirmPo($pid, $logging);
|
||||
return $pid;
|
||||
});
|
||||
ok("Created + confirmed PO for 3 laptops and 1 ERP licence (id={$po_id})");
|
||||
}
|
||||
|
||||
$pi_id = $lookup(
|
||||
"SELECT id FROM td_invoice WHERE company_id = :c AND source = 'po' AND source_id = :p AND doc_type = 'purchase_invoice' AND status <> 4 LIMIT 1",
|
||||
[':c' => $company_id, ':p' => $po_id]
|
||||
);
|
||||
if ($pi_id) {
|
||||
skip("Purchase invoice for the asset PO already exists (id={$pi_id})");
|
||||
} else {
|
||||
$pi_id = dbTransaction($pdo2, function ($pdo) use ($company_id, $po_id, $logging) {
|
||||
$invMgmt = new InvoiceManager($pdo, $company_id);
|
||||
$iid = $invMgmt->createFromPo($po_id, $logging);
|
||||
$invMgmt->issueInvoice($iid, $logging, date('Y-m-d', strtotime('+30 days')));
|
||||
return $iid;
|
||||
});
|
||||
ok("Created + issued purchase invoice from the asset PO (id={$pi_id})");
|
||||
}
|
||||
|
||||
$gl = new GlManager($pdo2, $company_id, new PostingWindowGuard($pdo1, $company_id));
|
||||
if ($gl->getBySource('purchase_invoice', $pi_id)) {
|
||||
skip("GL already posted for purchase invoice #{$pi_id}");
|
||||
} else {
|
||||
// Same as order/api/engine/issue_invoice.php's auto-post.
|
||||
$built = (new PurchaseInvoicePosting($pdo2, $company_id))->build($pi_id, null);
|
||||
$pdo2->beginTransaction();
|
||||
$gl->post('purchase_invoice', $pi_id, $built['formula_id'], $built['period'], $built['lines'], ['journal_date' => $built['doc_date']]);
|
||||
$pdo2->commit();
|
||||
ok("GL posted for purchase invoice #{$pi_id} (" . count($built['lines']) . " lines)");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 3. Assets
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
echo "\n--- Assets ---\n";
|
||||
|
||||
$sth = $pdo2->prepare("SELECT product_sku, id FROM td_invoice_item WHERE company_id = :c AND invoice_id = :i");
|
||||
$sth->execute([':c' => $company_id, ':i' => $pi_id]);
|
||||
$invoice_items = $sth->fetchAll(PDO::FETCH_KEY_PAIR); // sku => td_invoice_item.id
|
||||
|
||||
$asset_defs = [
|
||||
['key' => 'laptops', 'name' => 'Laptop fleet - Dell Latitude 5440 (3 units)', 'category' => 'IT', 'sku' => 'FA-LAPTOP', 'salvage' => 6000, 'life' => 36],
|
||||
['key' => 'erp', 'name' => 'ERP software licence', 'category' => 'SW', 'sku' => 'FA-ERP', 'salvage' => 0, 'life' => 24],
|
||||
['key' => 'truck', 'name' => 'Delivery truck - Isuzu D-Max', 'category' => 'VEH', 'acquired' => date('Y-m-15', strtotime('first day of -18 months')), 'cost' => 850000, 'salvage' => 150000, 'life' => 60],
|
||||
['key' => 'forklift','name' => 'Forklift - Toyota 8FD25', 'category' => 'VEH', 'acquired' => date('Y-m-01', strtotime('first day of -30 months')), 'cost' => 420000, 'salvage' => 20000, 'life' => 48],
|
||||
];
|
||||
|
||||
$asset_ids = [];
|
||||
foreach ($asset_defs as $d) {
|
||||
$sth = $pdo2->prepare("SELECT id, status FROM td_asset WHERE company_id = :c AND asset_name = :n AND status <> 4 LIMIT 1");
|
||||
$sth->execute([':c' => $company_id, ':n' => $d['name']]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($row) {
|
||||
skip("Asset '{$d['name']}' already exists (id={$row['id']})");
|
||||
$id = (int)$row['id'];
|
||||
} else {
|
||||
$data = [
|
||||
'asset_name' => $d['name'], 'category_id' => $category_ids[$d['category']],
|
||||
'salvage_value' => $d['salvage'], 'useful_life_months' => $d['life'],
|
||||
'department_id' => $wh_dept_id, 'description' => 'Demo seed',
|
||||
];
|
||||
if (!empty($d['sku'])) {
|
||||
// Capitalized from the purchase invoice line, as asset/manage_asset.php?invoice_id=&item_id= does.
|
||||
$item_id = (int)($invoice_items[$d['sku']] ?? 0);
|
||||
$line = (new AssetManager($pdo2, $company_id))->getInvoiceLine($pi_id, $item_id);
|
||||
$data += [
|
||||
'invoice_id' => $pi_id, 'invoice_item_id' => $item_id,
|
||||
'cost' => $line['remaining'], 'clearing_account_code' => $line['clearing_account_code'],
|
||||
'acquisition_date' => $line['issued_date'], 'in_service_date' => $line['issued_date'],
|
||||
];
|
||||
} else {
|
||||
// Bought before the system was used: already on the books, so no clearing account.
|
||||
$data += ['cost' => $d['cost'], 'clearing_account_code' => '', 'acquisition_date' => $d['acquired'], 'in_service_date' => $d['acquired']];
|
||||
}
|
||||
$id = dbTransaction($pdo2, fn($pdo) => (new AssetManager($pdo, $company_id))->save($data, $owner_user_id));
|
||||
ok("Created asset '{$d['name']}' (id={$id}), cost " . number_format((float)$data['cost'], 2) . ", in service {$data['in_service_date']}");
|
||||
}
|
||||
|
||||
if ((int)($row['status'] ?? 0) === 0) {
|
||||
dbTransaction($pdo2, fn($pdo) => (new AssetManager($pdo, $company_id))->activate($id));
|
||||
ok("Activated '{$d['name']}'");
|
||||
}
|
||||
$asset_ids[$d['key']] = $id;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 4. Runs and the forklift sale
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
echo "\n--- Depreciation / amortization runs ---\n";
|
||||
|
||||
$run_through = function (string $asset_class, string $period) use ($pdo2, $company_id, $owner_user_id) {
|
||||
$label = AssetRunManager::labelFor($asset_class);
|
||||
if (!(new AssetRunManager($pdo2, $company_id))->preview($asset_class, $period)) {
|
||||
skip("{$label} already charged through {$period}");
|
||||
return;
|
||||
}
|
||||
$created = dbTransaction($pdo2, fn($pdo) => (new AssetRunManager($pdo, $company_id))->run($asset_class, $period, $owner_user_id));
|
||||
$first = $created[0]['run_number'];
|
||||
$last = $created[count($created) - 1]['run_number'];
|
||||
ok("{$label}: " . count($created) . " run(s) {$first}" . ($first !== $last ? " .. {$last}" : '')
|
||||
. ", total " . number_format(array_sum(array_column($created, 'total')), 2));
|
||||
};
|
||||
|
||||
$run_through('tangible', $last_period);
|
||||
|
||||
$forklift = (new AssetManager($pdo2, $company_id))->getById($asset_ids['forklift']);
|
||||
if (in_array((int)$forklift['status'], [AssetManager::STATUS_ACTIVE, AssetManager::STATUS_FULLY], true)) {
|
||||
dbTransaction($pdo2, fn($pdo) => (new AssetManager($pdo, $company_id))->dispose($asset_ids['forklift'], [
|
||||
'disposal_date' => $today, 'sale_price' => 180000, 'proceeds_account_code' => '1000',
|
||||
'disposal_notes' => 'Sold to a used-equipment dealer (demo seed)',
|
||||
]));
|
||||
ok("Sold '{$forklift['asset_name']}' today for 180,000.00 (book value " . number_format((float)$forklift['book_value'], 2) . ")");
|
||||
} else {
|
||||
skip("'{$forklift['asset_name']}' already disposed of");
|
||||
}
|
||||
|
||||
$run_through('tangible', $this_period);
|
||||
$run_through('intangible', $this_period);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 5. GL posting (Batch GL Entries equivalent)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
echo "\n--- GL posting ---\n";
|
||||
|
||||
$post_asset_gl = function (string $source_type, int $id) use ($pdo1, $pdo2, $company_id) {
|
||||
$gl = new GlManager($pdo2, $company_id, new PostingWindowGuard($pdo1, $company_id));
|
||||
if ($gl->getBySource($source_type, $id)) return null;
|
||||
|
||||
$built = (new AssetPosting($pdo2, $company_id))->build($source_type, $id);
|
||||
$pdo2->beginTransaction();
|
||||
try {
|
||||
$gl->post($source_type, $id, 0, $built['period'], $built['lines'], [
|
||||
'journal_date' => $built['doc_date'], 'reference' => $built['reference'], 'description' => $built['description'],
|
||||
]);
|
||||
$pdo2->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo2->inTransaction()) $pdo2->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
return $built;
|
||||
};
|
||||
|
||||
$queue = [];
|
||||
$sth = $pdo2->prepare("SELECT id FROM td_asset WHERE company_id = :c AND status IN (1, 2, 3) AND clearing_account_code <> '' ORDER BY id");
|
||||
$sth->execute([':c' => $company_id]);
|
||||
foreach ($sth->fetchAll(PDO::FETCH_COLUMN) as $id) $queue[] = ['asset_capitalization', (int)$id];
|
||||
|
||||
$sth = $pdo2->prepare("SELECT id, asset_class FROM td_asset_run WHERE company_id = :c AND status = 1 AND period < :p ORDER BY period, id");
|
||||
$sth->execute([':c' => $company_id, ':p' => $this_period]);
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $r) $queue[] = [AssetRunManager::sourceTypeFor($r['asset_class']), (int)$r['id']];
|
||||
|
||||
$sth = $pdo2->prepare("SELECT id FROM td_asset WHERE company_id = :c AND status = 3 ORDER BY id");
|
||||
$sth->execute([':c' => $company_id]);
|
||||
foreach ($sth->fetchAll(PDO::FETCH_COLUMN) as $id) $queue[] = ['asset_disposal', (int)$id];
|
||||
|
||||
$posted = 0;
|
||||
foreach ($queue as [$source_type, $id]) {
|
||||
$built = $post_asset_gl($source_type, $id);
|
||||
if ($built) {
|
||||
$posted++;
|
||||
ok("Posted {$built['reference']} — {$built['description']}");
|
||||
}
|
||||
}
|
||||
if (!$posted) skip("Asset GL entries already posted");
|
||||
info("This month's runs are left unposted — post them from Accounting > Batch GL Entries.");
|
||||
|
||||
echo "\n\033[1m=== Done ===\033[0m\n\n";
|
||||
@@ -39,7 +39,7 @@ function buildLineItem(array $products, string $sku, float $qty): array {
|
||||
$tax_amount = round($total_price * $tax_rate / 100, 4);
|
||||
return [
|
||||
'product_sku' => $sku,
|
||||
'product_name' => $p['product_name'],
|
||||
'product_name' => $sku,
|
||||
'quantity' => $qty,
|
||||
'unit_price' => $unit_price,
|
||||
'total_price' => $total_price,
|
||||
@@ -69,16 +69,11 @@ $sth->execute([':c' => $company_id]);
|
||||
$sales_dept_id = (int)$sth->fetchColumn();
|
||||
if (!$sales_dept_id) { exit("ERROR: SALES department not found — run demo_seed_transactions.php first.\n"); }
|
||||
|
||||
$sth = $pdo2->prepare("SELECT sku, product_name, price FROM md_product WHERE company_id = :c");
|
||||
$sth = $pdo2->prepare("SELECT sku, price FROM md_product WHERE company_id = :c");
|
||||
$sth->execute([':c' => $company_id]);
|
||||
$products = $sth->fetchAll(PDO::FETCH_ASSOC | PDO::FETCH_UNIQUE);
|
||||
|
||||
// contact_type holds an md_contact_type id — resolve 'Customer' by name, not by a fixed id.
|
||||
$sth = $pdo2->prepare(
|
||||
"SELECT c.id, c.contact_name FROM md_contact c
|
||||
JOIN md_contact_type t ON t.company_id = c.company_id AND t.id = c.contact_type
|
||||
WHERE c.company_id = :c AND t.contact_type = 'Customer' ORDER BY c.id"
|
||||
);
|
||||
$sth = $pdo2->prepare("SELECT id, contact_name FROM md_contact WHERE company_id = :c AND contact_type = 1 ORDER BY id");
|
||||
$sth->execute([':c' => $company_id]);
|
||||
$customers = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
$customer_ids = array_column($customers, 'id');
|
||||
|
||||
+173
-46
@@ -5,16 +5,18 @@
|
||||
*
|
||||
* Extends the base demo data (demo_seed.php: company, warehouses, products,
|
||||
* contacts, opening stock) with transactional data covering the rest of the
|
||||
* app's features (restock / dispatch / transfer movements are seeded by demo_seed.php):
|
||||
* app's features:
|
||||
*
|
||||
* 4. Chart of accounts, departments, GL posting formulas, product account mapping
|
||||
* 5. Sales cycle: quotation -> sales order -> invoice -> GL post
|
||||
* 6. AR: receipt billing -> receipt -> GL post
|
||||
* 7. Purchasing cycle: purchase request -> PO -> receive -> purchase invoice -> GL post
|
||||
* 8. AP: payment billing -> payment -> GL post
|
||||
* 9. Supplier return (confirmed, restocks reversed)
|
||||
* 10. Customer return (draft only — see note below)
|
||||
* 11. Barcode labels for a handful of products
|
||||
* 1. Additional stock-in replenishment (restock events)
|
||||
* 2. Stock-out (direct dispatch) + stock transfer (Main -> Bangna)
|
||||
* 3. Chart of accounts, departments, GL posting formulas
|
||||
* 4. Sales cycle: quotation -> sales order -> invoice -> GL post
|
||||
* 5. AR: receipt billing -> receipt -> GL post
|
||||
* 6. Purchasing cycle: purchase request -> PO -> receive -> purchase invoice -> GL post
|
||||
* 7. AP: payment billing -> payment -> GL post
|
||||
* 8. Supplier return (confirmed, restocks reversed)
|
||||
* 9. Customer return (draft only — see note below)
|
||||
* 10. Barcode labels for a handful of products
|
||||
*
|
||||
* Every write goes through the same Manager classes + engine-file patterns
|
||||
* the app itself uses (dbTransaction wrapping, GlManager posting exactly as
|
||||
@@ -44,7 +46,6 @@ require_once __DIR__ . '/app/dbconn.php';
|
||||
require_once __DIR__ . '/app/assets/utils/db_helpers.php';
|
||||
require_once __DIR__ . '/app/assets/utils/classes/WarehouseManager.php';
|
||||
require_once __DIR__ . '/app/assets/utils/classes/StockManager.php';
|
||||
require_once __DIR__ . '/app/assets/utils/classes/ProductManager.php';
|
||||
require_once __DIR__ . '/app/assets/utils/classes/QuotationManager.php';
|
||||
require_once __DIR__ . '/app/assets/utils/classes/OrderManager.php';
|
||||
require_once __DIR__ . '/app/assets/utils/classes/InvoiceManager.php';
|
||||
@@ -88,29 +89,24 @@ $sth->execute();
|
||||
$owner_user_id = (int)($sth->fetchColumn() ?: 0);
|
||||
if (!$owner_user_id) exit("ERROR: demo owner user not found — run demo_seed.php first.\n");
|
||||
|
||||
// Resolve by name: ids depend on what else exists in the database.
|
||||
$sth = $pdo2->prepare("SELECT id, warehouse_name FROM md_warehouse WHERE company_id = :c ORDER BY id");
|
||||
$sth->execute([':c' => $company_id]);
|
||||
$warehouses = $sth->fetchAll(PDO::FETCH_KEY_PAIR); // id => name
|
||||
$main_wh = (int)(array_search('Main Warehouse', $warehouses, true) ?: 0);
|
||||
$bangna_wh = (int)(array_search('Bangna Distribution Center', $warehouses, true) ?: 0);
|
||||
if (!$main_wh || !$bangna_wh) exit("ERROR: expected Main Warehouse and Bangna Distribution Center — run demo_seed.php first.\n");
|
||||
if (count($warehouses) < 2) exit("ERROR: expected 2 warehouses — run demo_seed.php first.\n");
|
||||
$wh_ids = array_keys($warehouses);
|
||||
$main_wh = $wh_ids[0]; // Main Warehouse
|
||||
$bangna_wh = $wh_ids[1]; // Bangna Distribution Center
|
||||
|
||||
// md_contact.contact_type is an md_contact_type id, so match on the type name.
|
||||
$sth = $pdo2->prepare(
|
||||
"SELECT c.id, c.contact_name, t.contact_type AS type_name
|
||||
FROM md_contact c
|
||||
JOIN md_contact_type t ON t.company_id = c.company_id AND t.id = c.contact_type
|
||||
WHERE c.company_id = :c
|
||||
ORDER BY c.id"
|
||||
);
|
||||
$sth = $pdo2->prepare("SELECT id, contact_name, contact_type FROM md_contact WHERE company_id = :c ORDER BY id");
|
||||
$sth->execute([':c' => $company_id]);
|
||||
$contacts = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
$customer_ids = array_values(array_column(array_filter($contacts, fn($c) => $c['type_name'] === 'Customer'), 'id'));
|
||||
$supplier_ids = array_values(array_column(array_filter($contacts, fn($c) => $c['type_name'] === 'Supplier'), 'id'));
|
||||
if (count($customer_ids) < 4 || count($supplier_ids) < 3) exit("ERROR: expected 4 customers and 3 suppliers — run demo_seed.php first.\n");
|
||||
if (count($contacts) < 7) exit("ERROR: expected 7 contacts — run demo_seed.php first.\n");
|
||||
$customer_ids = array_column(array_filter($contacts, fn($c) => (int)$c['contact_type'] === 1), 'id');
|
||||
$supplier_ids = array_column(array_filter($contacts, fn($c) => (int)$c['contact_type'] === 2), 'id');
|
||||
$customer_ids = array_values($customer_ids);
|
||||
$supplier_ids = array_values($supplier_ids);
|
||||
|
||||
$sth = $pdo2->prepare("SELECT sku, product_name, uom, cost_price, price FROM md_product WHERE company_id = :c ORDER BY id");
|
||||
$sth = $pdo2->prepare("SELECT sku, uom, cost_price, price FROM md_product WHERE company_id = :c ORDER BY id");
|
||||
$sth->execute([':c' => $company_id]);
|
||||
$products = $sth->fetchAll(PDO::FETCH_ASSOC | PDO::FETCH_UNIQUE);
|
||||
if (count($products) < 14) exit("ERROR: expected 14 products — run demo_seed.php first.\n");
|
||||
@@ -118,6 +114,156 @@ if (count($products) < 14) exit("ERROR: expected 14 products — run demo_seed.p
|
||||
$logging = ['user_id' => $owner_user_id, 'dt' => date('Y-m-d H:i:s'), 'login' => null, 'action' => 'seed_transactions'];
|
||||
|
||||
$whMgmt = new WarehouseManager($pdo2, $company_id);
|
||||
$stockMgmt = new StockManager($pdo2, $company_id);
|
||||
|
||||
/** Find the next unused simple-location bin label "A-N" for a warehouse. */
|
||||
function nextFreeBin(PDO $pdo2, int $company_id, int $warehouse_id): string {
|
||||
$sth = $pdo2->prepare(
|
||||
"SELECT bin FROM md_bin WHERE company_id = :c AND warehouse = :w AND product_sku IS NULL
|
||||
ORDER BY CAST(SUBSTRING(bin, 3) AS UNSIGNED) ASC LIMIT 1"
|
||||
);
|
||||
$sth->execute([':c' => $company_id, ':w' => $warehouse_id]);
|
||||
$bin = $sth->fetchColumn();
|
||||
if (!$bin) throw new Exception("No free bin available in warehouse {$warehouse_id}.");
|
||||
return $bin;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 1. Additional stock-in replenishment (restock events)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
echo "--- Restock (additional stock-in) ---\n";
|
||||
|
||||
$restock_defs = [
|
||||
['sku' => 'EL-001', 'warehouse' => $main_wh, 'qty' => 40, 'supplier_idx' => 0],
|
||||
['sku' => 'OF-001', 'warehouse' => $main_wh, 'qty' => 60, 'supplier_idx' => 1],
|
||||
['sku' => 'BV-002', 'warehouse' => $bangna_wh, 'qty' => 35, 'supplier_idx' => 2],
|
||||
];
|
||||
|
||||
foreach ($restock_defs as $r) {
|
||||
$sth = $pdo2->prepare(
|
||||
"SELECT COUNT(*) FROM `td_stock_{$r['warehouse']}`
|
||||
WHERE company_id = :c AND product_sku = :sku AND type = 'in' AND description = 'Restock (demo seed)'"
|
||||
);
|
||||
$sth->execute([':c' => $company_id, ':sku' => $r['sku']]);
|
||||
if ((int)$sth->fetchColumn() > 0) {
|
||||
skip("Restock for {$r['sku']} in warehouse {$r['warehouse']} already exists");
|
||||
continue;
|
||||
}
|
||||
|
||||
$bin = nextFreeBin($pdo2, $company_id, $r['warehouse']);
|
||||
$supplier_id = $supplier_ids[$r['supplier_idx']];
|
||||
$uuid = bin2hex(random_bytes(16));
|
||||
$p = $products[$r['sku']];
|
||||
|
||||
dbTransaction($pdo2, function ($pdo2) use ($stockMgmt, $whMgmt, $r, $bin, $supplier_id, $logging, $uuid, $p) {
|
||||
$stock_id = $stockMgmt->saveStockIn([
|
||||
'id' => 0, 'warehouse' => $r['warehouse'], 'product_sku' => $r['sku'],
|
||||
'quantity' => $r['qty'], 'price' => $p['cost_price'],
|
||||
'zone' => $bin, 'aisle' => $bin, 'bin' => $bin,
|
||||
'contact_id' => $supplier_id, 'description' => 'Restock (demo seed)',
|
||||
], $logging, $uuid);
|
||||
$stockMgmt->approveStock($stock_id, $r['warehouse'], 'in', $whMgmt);
|
||||
});
|
||||
ok("Restocked {$r['qty']} {$p['uom']} of {$r['sku']} into {$warehouses[$r['warehouse']]} bin {$bin}");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 2. Stock-out (direct dispatch) — dedicated batch in + full-bin out
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
echo "\n--- Stock-out (direct dispatch) ---\n";
|
||||
|
||||
$dispatch_defs = [
|
||||
['sku' => 'PK-003', 'warehouse' => $main_wh, 'qty' => 25, 'customer_idx' => 0],
|
||||
['sku' => 'OF-003', 'warehouse' => $main_wh, 'qty' => 15, 'customer_idx' => 1],
|
||||
];
|
||||
|
||||
foreach ($dispatch_defs as $d) {
|
||||
$sth = $pdo2->prepare(
|
||||
"SELECT COUNT(*) FROM `td_stock_{$d['warehouse']}`
|
||||
WHERE company_id = :c AND product_sku = :sku AND type = 'out' AND description = 'Direct dispatch (demo seed)'"
|
||||
);
|
||||
$sth->execute([':c' => $company_id, ':sku' => $d['sku']]);
|
||||
if ((int)$sth->fetchColumn() > 0) {
|
||||
skip("Dispatch for {$d['sku']} already exists");
|
||||
continue;
|
||||
}
|
||||
|
||||
$bin = nextFreeBin($pdo2, $company_id, $d['warehouse']);
|
||||
$customer_id = $customer_ids[$d['customer_idx']];
|
||||
$p = $products[$d['sku']];
|
||||
|
||||
dbTransaction($pdo2, function ($pdo2) use ($stockMgmt, $whMgmt, $d, $bin, $customer_id, $logging, $p) {
|
||||
// Receive a dedicated batch first (so we don't touch demo_seed.php's opening-stock bins)
|
||||
$in_uuid = bin2hex(random_bytes(16));
|
||||
$in_id = $stockMgmt->saveStockIn([
|
||||
'id' => 0, 'warehouse' => $d['warehouse'], 'product_sku' => $d['sku'],
|
||||
'quantity' => $d['qty'], 'price' => $p['cost_price'],
|
||||
'zone' => $bin, 'aisle' => $bin, 'bin' => $bin,
|
||||
'contact_id' => $customer_id, 'description' => 'Batch for dispatch (demo seed)',
|
||||
], $logging, $in_uuid);
|
||||
$stockMgmt->approveStock($in_id, $d['warehouse'], 'in', $whMgmt);
|
||||
|
||||
// Dispatch it straight out (saveStockOut takes the whole bin)
|
||||
$out_uuid = bin2hex(random_bytes(16));
|
||||
$out_id = $stockMgmt->saveStockOut([
|
||||
'id' => 0, 'warehouse' => $d['warehouse'], 'product_sku' => $d['sku'],
|
||||
'zone' => $bin, 'aisle' => $bin, 'bin' => $bin,
|
||||
'contact_id' => $customer_id, 'description' => 'Direct dispatch (demo seed)',
|
||||
], $logging, $out_uuid);
|
||||
$stockMgmt->approveStock($out_id, $d['warehouse'], 'out', $whMgmt);
|
||||
});
|
||||
ok("Dispatched {$d['qty']} {$p['uom']} of {$d['sku']} from {$warehouses[$d['warehouse']]}");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 3. Stock transfer — Main Warehouse -> Bangna Distribution Center
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
echo "\n--- Stock transfer (Main -> Bangna) ---\n";
|
||||
|
||||
$transfer_defs = [
|
||||
['sku' => 'BV-001', 'qty' => 30],
|
||||
['sku' => 'PK-002', 'qty' => 12],
|
||||
];
|
||||
|
||||
foreach ($transfer_defs as $t) {
|
||||
$sth = $pdo2->prepare(
|
||||
"SELECT COUNT(*) FROM `td_stock_{$main_wh}`
|
||||
WHERE company_id = :c AND product_sku = :sku AND type = 'transfer' AND description = 'Transfer to Bangna (demo seed)'"
|
||||
);
|
||||
$sth->execute([':c' => $company_id, ':sku' => $t['sku']]);
|
||||
if ((int)$sth->fetchColumn() > 0) {
|
||||
skip("Transfer for {$t['sku']} already exists");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Bin allocation is independent of the transaction below — resolve both up front.
|
||||
$from_bin = nextFreeBin($pdo2, $company_id, $main_wh);
|
||||
$to_bin = nextFreeBin($pdo2, $company_id, $bangna_wh);
|
||||
$p = $products[$t['sku']];
|
||||
|
||||
dbTransaction($pdo2, function ($pdo2) use ($stockMgmt, $whMgmt, $t, $from_bin, $to_bin, $main_wh, $bangna_wh, $logging, $p) {
|
||||
// Receive a dedicated batch first (so we don't touch demo_seed.php's opening-stock bins)
|
||||
$in_uuid = bin2hex(random_bytes(16));
|
||||
$in_id = $stockMgmt->saveStockIn([
|
||||
'id' => 0, 'warehouse' => $main_wh, 'product_sku' => $t['sku'],
|
||||
'quantity' => $t['qty'], 'price' => $p['cost_price'],
|
||||
'zone' => $from_bin, 'aisle' => $from_bin, 'bin' => $from_bin,
|
||||
'contact_id' => 0, 'description' => 'Batch for transfer (demo seed)',
|
||||
], $logging, $in_uuid);
|
||||
$stockMgmt->approveStock($in_id, $main_wh, 'in', $whMgmt);
|
||||
|
||||
$tr_uuid = bin2hex(random_bytes(16));
|
||||
$out_id = $stockMgmt->saveStockTransfer([
|
||||
'id' => 0, 'warehouse_from' => $main_wh, 'warehouse_to' => $bangna_wh,
|
||||
'product_sku' => $t['sku'],
|
||||
'zone_from' => $from_bin, 'aisle_from' => $from_bin, 'bin_from' => $from_bin,
|
||||
'zone_to' => $to_bin, 'aisle_to' => $to_bin, 'bin_to' => $to_bin,
|
||||
'contact_id' => 0, 'description' => 'Transfer to Bangna (demo seed)',
|
||||
], $logging, $tr_uuid);
|
||||
$stockMgmt->approveStock($out_id, $main_wh, 'transfer', $whMgmt);
|
||||
});
|
||||
ok("Transferred {$t['qty']} {$p['uom']} of {$t['sku']}: {$warehouses[$main_wh]} bin {$from_bin} -> {$warehouses[$bangna_wh]} bin {$to_bin}");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 4. Chart of accounts + departments + GL posting formulas
|
||||
@@ -233,25 +379,6 @@ foreach ($formula_defs as $doc_type => $def) {
|
||||
$formula_ids[$doc_type] = $id;
|
||||
}
|
||||
|
||||
echo "\n--- Product account mapping ---\n";
|
||||
|
||||
// The sales and purchase formulas split 'total' per product, so Batch GL Entries
|
||||
// refuses to post until every product has sales/purchase accounts
|
||||
// (Account Formulas > Product Accounts, accounting/api/engine/product_account_mapping.php).
|
||||
$sth = $pdo2->prepare("SELECT id, sku, sales_account_code, purchase_account_code FROM md_product WHERE company_id = :c ORDER BY id");
|
||||
$sth->execute([':c' => $company_id]);
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $prod) {
|
||||
if ($prod['sales_account_code'] && $prod['purchase_account_code']) {
|
||||
skip("Product {$prod['sku']} already mapped");
|
||||
continue;
|
||||
}
|
||||
$sales_code = $prod['sales_account_code'] ?: '4000';
|
||||
$purchase_code = $prod['purchase_account_code'] ?: '5000';
|
||||
dbTransaction($pdo2, fn($pdo) => (new ProductManager($pdo, $company_id))
|
||||
->updateAccountMapping((int)$prod['id'], $sales_code, $purchase_code, $logging));
|
||||
ok("Mapped {$prod['sku']}: sales {$sales_code}, purchase {$purchase_code}");
|
||||
}
|
||||
|
||||
/** Post (or replace) GL for a document, mirroring order/api/engine/issue_invoice.php exactly. */
|
||||
function postGl(PDO $pdo1, PDO $pdo2, int $company_id, string $doc_type, int $doc_id, string $posting_class): array {
|
||||
require_once __DIR__ . "/app/assets/utils/classes_ac/posting/{$posting_class}.php";
|
||||
@@ -282,7 +409,7 @@ function buildLineItem(array $products, string $sku, float $qty): array {
|
||||
$tax_amount = round($total_price * $tax_rate / 100, 4);
|
||||
return [
|
||||
'product_sku' => $sku,
|
||||
'product_name' => $p['product_name'], // documents show the product name, as the UI's product search fills it
|
||||
'product_name' => $sku, // demo_seed.php products keyed by SKU; name not needed for GL/report correctness
|
||||
'quantity' => $qty,
|
||||
'unit_price' => $unit_price,
|
||||
'total_price' => $total_price,
|
||||
|
||||
@@ -22,7 +22,6 @@ services:
|
||||
EMIT_SECRET: ${EMIT_SECRET}
|
||||
SMTP_USERNAME: ${SMTP_USERNAME}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD}
|
||||
OTP_REQUIRED: ${OTP_REQUIRED:-false}
|
||||
volumes:
|
||||
- .:/var/www/html/wms-app
|
||||
ports:
|
||||
|
||||
@@ -55,7 +55,6 @@ PUBLIC_HOST=$public_host
|
||||
EMIT_SECRET=$emit_secret
|
||||
SMTP_USERNAME=$smtp_user
|
||||
SMTP_PASSWORD=$smtp_pass
|
||||
OTP_REQUIRED=false
|
||||
HTTP_PORT=$http_port
|
||||
EOF
|
||||
chmod 600 "$ENV_FILE"
|
||||
|
||||
@@ -2,8 +2,6 @@ FROM php:8.3-apache
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libzip-dev libicu-dev libonig-dev default-mysql-client gettext-base \
|
||||
libpng-dev libjpeg-dev libfreetype6-dev \
|
||||
&& docker-php-ext-configure gd --with-jpeg --with-freetype \
|
||||
&& docker-php-ext-install pdo_mysql mysqli mbstring gd zip intl sockets exif opcache \
|
||||
&& a2enmod rewrite \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -33,23 +33,6 @@ if (!defined('NODE_EMIT_SECRET')) {
|
||||
define('NODE_EMIT_SECRET', '${EMIT_SECRET}');
|
||||
}
|
||||
|
||||
// ── Login OTP ────────────────────────────────────────────────────────────────
|
||||
// Set from OTP_REQUIRED in .env and reconciled by the entrypoint on every start.
|
||||
// Off by default: anything other than the boolean true leaves the OTP step off.
|
||||
if (!defined('OTP_REQUIRED')) {
|
||||
define('OTP_REQUIRED', ${OTP_REQUIRED});
|
||||
}
|
||||
|
||||
// ── App registry ─────────────────────────────────────────────────────────────
|
||||
// Apps a user can be given access to (Setting → Users Access). Keys must match
|
||||
// the user.app_access enum; assets/utils/app_registry.php supplies this default
|
||||
// for configs that do not define it.
|
||||
$app_registry = [
|
||||
'wms' => ['label' => 'WMS', 'icon' => 'ti-box', 'color' => 'bg-label-primary', 'home' => 'dashboard/index.php'],
|
||||
'accounting' => ['label' => 'Accounting', 'icon' => 'ti-calculator', 'color' => 'bg-label-info', 'home' => 'ac_dashboard/index.php'],
|
||||
'asset' => ['label' => 'Asset Management', 'icon' => 'ti-building-warehouse', 'color' => 'bg-label-success', 'home' => 'asset_dashboard/index.php'],
|
||||
];
|
||||
|
||||
// ── Usage packages ───────────────────────────────────────────────────────────
|
||||
$packages = [
|
||||
'starter' => [
|
||||
|
||||
@@ -4,39 +4,15 @@ set -e
|
||||
APP_DIR=/var/www/html/wms-app
|
||||
CONFIG=$APP_DIR/app/config.php
|
||||
|
||||
# Email OTP on sign-in, OFF BY DEFAULT. Only the exact string "true" turns it
|
||||
# on; a missing variable or anything else means false.
|
||||
: "${OTP_REQUIRED:=false}"
|
||||
[ "$OTP_REQUIRED" = "true" ] || OTP_REQUIRED=false
|
||||
export OTP_REQUIRED
|
||||
|
||||
# Generate app/config.php from template on first run only.
|
||||
# Restrict envsubst to known placeholders so it never touches the app's own
|
||||
# $variable syntax (envsubst blanks out any $NAME it doesn't recognize).
|
||||
if [ ! -f "$CONFIG" ]; then
|
||||
echo "[entrypoint] generating app/config.php"
|
||||
envsubst '${DB_ROOT_PASSWORD} ${PUBLIC_HOST} ${EMIT_SECRET} ${SMTP_USERNAME} ${SMTP_PASSWORD} ${OTP_REQUIRED}' \
|
||||
envsubst '${DB_ROOT_PASSWORD} ${PUBLIC_HOST} ${EMIT_SECRET} ${SMTP_USERNAME} ${SMTP_PASSWORD}' \
|
||||
< /usr/local/etc/wms/config.php.template > "$CONFIG"
|
||||
fi
|
||||
|
||||
# config.php is never regenerated once it exists, so OTP_REQUIRED is the one
|
||||
# line reconciled on every start: the .env value always wins, and a config.php
|
||||
# written before this switch existed gets the line added.
|
||||
if grep -q "define('OTP_REQUIRED'" "$CONFIG"; then
|
||||
if ! grep -q "define('OTP_REQUIRED', ${OTP_REQUIRED});" "$CONFIG"; then
|
||||
sed -i "s/define('OTP_REQUIRED', [A-Za-z]*);/define('OTP_REQUIRED', ${OTP_REQUIRED});/" "$CONFIG"
|
||||
echo "[entrypoint] OTP_REQUIRED is now ${OTP_REQUIRED}"
|
||||
fi
|
||||
else
|
||||
# Drop a closing ?> on the last line so the appended block stays inside PHP.
|
||||
sed -i -e '${/^[[:space:]]*?>[[:space:]]*$/d}' "$CONFIG"
|
||||
printf "\nif (!defined('OTP_REQUIRED')) {\n\tdefine('OTP_REQUIRED', %s);\n}\n" "$OTP_REQUIRED" >> "$CONFIG"
|
||||
echo "[entrypoint] added OTP_REQUIRED = ${OTP_REQUIRED} to an existing config.php"
|
||||
fi
|
||||
if [ "$OTP_REQUIRED" = "false" ]; then
|
||||
echo "[entrypoint] email OTP is off (OTP_REQUIRED=false); sign-in is password only."
|
||||
fi
|
||||
|
||||
mkdir -p "$APP_DIR/app/uploads"
|
||||
chown -R www-data:www-data "$APP_DIR/app/uploads"
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
2026-07-22T14:37:48: [2026-07-22T07:37:48.626Z] [PID: 505] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Wed Jul 22 2026 14:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T15:14:43: [2026-07-23T08:14:43.767Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 15:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T20:39:04: [2026-07-23T13:39:04.943Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 17:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T20:39:04: [2026-07-23T13:39:04.953Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 18:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T20:39:04: [2026-07-23T13:39:04.959Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 19:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T20:39:04: [2026-07-23T13:39:04.965Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 20:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T21:02:17: [2026-07-23T14:02:17.033Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 21:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T21:11:21: [2026-07-23T14:11:21.977Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 17:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T21:11:21: [2026-07-23T14:11:21.982Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 18:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T21:11:21: [2026-07-23T14:11:21.985Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 19:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T21:11:21: [2026-07-23T14:11:21.987Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 20:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-25T14:44:32: [2026-07-25T07:44:32.121Z] [PID: 476] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Sat Jul 25 2026 13:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-25T14:44:32: [2026-07-25T07:44:32.127Z] [PID: 476] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Sat Jul 25 2026 14:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-25T15:24:00: [2026-07-25T08:24:00.029Z] [PID: 476] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Sat Jul 25 2026 14:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-25T15:24:00: [2026-07-25T08:24:00.032Z] [PID: 476] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Sat Jul 25 2026 15:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-08-03T10:11:12: [2026-08-03T03:11:12.241Z] [PID: 477] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Mon Aug 03 2026 10:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-08-03T10:31:46: [2026-08-03T03:31:46.573Z] [PID: 477] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Mon Aug 03 2026 10:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-08-03T11:08:26: [2026-08-03T04:08:26.686Z] [PID: 477] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Mon Aug 03 2026 11:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-08-04T15:02:31: [2026-08-04T08:02:31.243Z] [PID: 78443] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Tue Aug 04 2026 15:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-08-04T15:32:15: [2026-08-04T08:32:15.561Z] [PID: 78443] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Tue Aug 04 2026 15:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-08-04T17:01:03: [2026-08-04T10:01:03.525Z] [PID: 78443] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Tue Aug 04 2026 17:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
@@ -0,0 +1,58 @@
|
||||
2026-07-20T17:35:51: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
|
||||
2026-07-20T21:00:00: [scheduler] etl_gl slot=21
|
||||
2026-07-20T21:00:00: [scheduler] etl_gl slot=21 — no companies
|
||||
2026-07-21T16:06:58: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
|
||||
2026-07-22T13:08:58: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
|
||||
2026-07-22T14:37:48: [2026-07-22T07:37:48.626Z] [PID: 505] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Wed Jul 22 2026 14:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-22T15:00:00: [scheduler] etl_gl slot=15
|
||||
2026-07-22T15:00:00: [scheduler] etl_gl slot=15 — no companies
|
||||
2026-07-22T15:30:00: [scheduler] etl_stock slot=15
|
||||
2026-07-22T15:30:00: [scheduler] etl_stock slot=15 — no companies
|
||||
2026-07-22T16:00:00: [scheduler] etl_gl slot=16
|
||||
2026-07-22T16:00:00: [scheduler] etl_gl slot=16 — no companies
|
||||
2026-07-23T12:11:51: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
|
||||
2026-07-23T12:30:00: [scheduler] etl_stock slot=12
|
||||
2026-07-23T12:30:00: [scheduler] etl_stock slot=12 — no companies
|
||||
2026-07-23T13:36:04: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
|
||||
2026-07-23T15:14:43: [2026-07-23T08:14:43.767Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 15:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T15:30:00: [scheduler] etl_stock slot=15
|
||||
2026-07-23T15:30:00: [scheduler] etl_stock slot=15 — no companies
|
||||
2026-07-23T16:00:00: [scheduler] etl_gl slot=16
|
||||
2026-07-23T16:00:00: [scheduler] etl_gl slot=16 — no companies
|
||||
2026-07-23T16:30:00: [scheduler] etl_stock slot=16
|
||||
2026-07-23T16:30:00: [scheduler] etl_stock slot=16 — no companies
|
||||
2026-07-23T20:39:04: [2026-07-23T13:39:04.943Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 17:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T20:39:04: [2026-07-23T13:39:04.953Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 18:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T20:39:04: [2026-07-23T13:39:04.959Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 19:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T20:39:04: [2026-07-23T13:39:04.965Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 20:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T21:02:17: [2026-07-23T14:02:17.033Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 21:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T21:11:21: [2026-07-23T14:11:21.977Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 17:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T21:11:21: [2026-07-23T14:11:21.982Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 18:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T21:11:21: [2026-07-23T14:11:21.985Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 19:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T21:11:21: [2026-07-23T14:11:21.987Z] [PID: 451] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Thu Jul 23 2026 20:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-23T21:30:00: [scheduler] etl_stock slot=21
|
||||
2026-07-23T21:30:00: [scheduler] etl_stock slot=21 — no companies
|
||||
2026-07-25T12:19:55: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
|
||||
2026-07-25T12:30:00: [scheduler] etl_stock slot=12
|
||||
2026-07-25T12:30:00: [scheduler] etl_stock slot=12 — no companies
|
||||
2026-07-25T13:00:00: [scheduler] etl_gl slot=13
|
||||
2026-07-25T13:00:00: [scheduler] etl_gl slot=13 — no companies
|
||||
2026-07-25T14:44:32: [2026-07-25T07:44:32.121Z] [PID: 476] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Sat Jul 25 2026 13:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-25T14:44:32: [2026-07-25T07:44:32.127Z] [PID: 476] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Sat Jul 25 2026 14:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-25T15:24:00: [2026-07-25T08:24:00.029Z] [PID: 476] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Sat Jul 25 2026 14:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-07-25T15:24:00: [2026-07-25T08:24:00.032Z] [PID: 476] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Sat Jul 25 2026 15:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-08-03T08:52:15: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
|
||||
2026-08-03T09:00:00: [scheduler] etl_gl slot=9
|
||||
2026-08-03T09:00:00: [scheduler] alert_overdue_invoices running
|
||||
2026-08-03T09:00:00: [scheduler] etl_gl slot=9 — no companies
|
||||
2026-08-03T10:11:12: [2026-08-03T03:11:12.241Z] [PID: 477] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Mon Aug 03 2026 10:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-08-03T10:31:46: [2026-08-03T03:31:46.573Z] [PID: 477] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Mon Aug 03 2026 10:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-08-03T11:08:26: [2026-08-03T04:08:26.686Z] [PID: 477] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Mon Aug 03 2026 11:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-08-04T13:35:14: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
|
||||
2026-08-04T15:02:31: [2026-08-04T08:02:31.243Z] [PID: 78443] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Tue Aug 04 2026 15:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-08-04T15:32:15: [2026-08-04T08:32:15.561Z] [PID: 78443] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Tue Aug 04 2026 15:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
2026-08-04T16:00:00: [scheduler] etl_gl slot=16
|
||||
2026-08-04T16:00:00: [scheduler] etl_gl slot=16 — no companies
|
||||
2026-08-04T16:30:00: [scheduler] etl_stock slot=16
|
||||
2026-08-04T16:30:00: [scheduler] etl_stock slot=16 — no companies
|
||||
2026-08-04T17:01:03: [2026-08-04T10:01:03.525Z] [PID: 78443] [32m[NODE-CRON][32m [33m[WARN][0m missed execution at Tue Aug 04 2026 17:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
|
||||
@@ -0,0 +1,255 @@
|
||||
2026-07-20T17:35:51: Node.js real-time server running on port 3000
|
||||
2026-07-21T16:06:58: Node.js real-time server running on port 3000
|
||||
2026-07-22T13:08:58: Node.js real-time server running on port 3000
|
||||
2026-07-22T13:10:29: [connect] socket=0-LquhazY9eKKN7LAAAB company=1 user=1 role=owner
|
||||
2026-07-22T13:15:09: [disconnect] socket=0-LquhazY9eKKN7LAAAB
|
||||
2026-07-22T13:15:12: [connect] socket=P4rxlPGuHWMqT35XAAAD company=1 user=1 role=owner
|
||||
2026-07-22T13:26:21: [disconnect] socket=P4rxlPGuHWMqT35XAAAD
|
||||
2026-07-22T13:26:23: [connect] socket=EBxi3tj8fNMUmyoyAAAF company=1 user=1 role=owner
|
||||
2026-07-22T13:30:24: [disconnect] socket=EBxi3tj8fNMUmyoyAAAF
|
||||
2026-07-22T13:30:25: [connect] socket=SJtRh1L6usnHrWebAAAH company=1 user=1 role=owner
|
||||
2026-07-22T13:31:59: [disconnect] socket=SJtRh1L6usnHrWebAAAH
|
||||
2026-07-22T13:31:59: [connect] socket=xvRdZhqqg-soDWr7AAAJ company=1 user=1 role=owner
|
||||
2026-07-22T13:41:17: [disconnect] socket=xvRdZhqqg-soDWr7AAAJ
|
||||
2026-07-22T13:41:17: [connect] socket=QZkAkh2pHOGltp-0AAAL company=1 user=1 role=owner
|
||||
2026-07-22T13:42:28: [disconnect] socket=QZkAkh2pHOGltp-0AAAL
|
||||
2026-07-22T13:42:28: [connect] socket=0Vb5YTMHDs1gOC47AAAN company=1 user=1 role=owner
|
||||
2026-07-22T13:42:36: [disconnect] socket=0Vb5YTMHDs1gOC47AAAN
|
||||
2026-07-22T13:42:36: [connect] socket=idQO9l1OGLiXPyEoAAAP company=1 user=1 role=owner
|
||||
2026-07-22T13:42:43: [disconnect] socket=idQO9l1OGLiXPyEoAAAP
|
||||
2026-07-22T13:42:43: [connect] socket=nvfS_4EF_3kF5PGsAAAR company=1 user=1 role=owner
|
||||
2026-07-22T13:47:25: [disconnect] socket=nvfS_4EF_3kF5PGsAAAR
|
||||
2026-07-22T13:47:27: [connect] socket=yytX3fzh594f9phBAAAT company=1 user=1 role=owner
|
||||
2026-07-22T13:50:19: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-07-22T13:50:19: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-07-22T13:50:19: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-07-22T13:50:19: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-07-22T13:50:19: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-07-22T13:59:42: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||
2026-07-22T13:59:42: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||
2026-07-22T13:59:43: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||
2026-07-22T14:03:26: [disconnect] socket=yytX3fzh594f9phBAAAT
|
||||
2026-07-22T14:03:28: [connect] socket=waEM4mo4V099RHhAAAAV company=1 user=1 role=owner
|
||||
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
|
||||
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
|
||||
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
|
||||
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
|
||||
2026-07-22T14:03:30: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
|
||||
2026-07-22T14:11:10: [disconnect] socket=waEM4mo4V099RHhAAAAV
|
||||
2026-07-22T14:35:14: [connect] socket=ZSmIs38dJh1u4If4AAAX company=1 user=1 role=owner
|
||||
2026-07-22T14:35:49: [disconnect] socket=ZSmIs38dJh1u4If4AAAX
|
||||
2026-07-22T14:35:49: [connect] socket=1bcHdQOC7cBTftxyAAAZ company=1 user=1 role=owner
|
||||
2026-07-22T14:36:37: [disconnect] socket=1bcHdQOC7cBTftxyAAAZ
|
||||
2026-07-22T14:36:38: [connect] socket=fMazKVGWKhaTm7OUAAAb company=1 user=1 role=owner
|
||||
2026-07-22T14:36:46: [disconnect] socket=fMazKVGWKhaTm7OUAAAb
|
||||
2026-07-22T14:36:46: [connect] socket=VfqVaEiOI3NTCA7fAAAd company=1 user=1 role=owner
|
||||
2026-07-22T14:36:48: [disconnect] socket=VfqVaEiOI3NTCA7fAAAd
|
||||
2026-07-22T14:36:48: [connect] socket=DAww8irzEgS7j7JLAAAf company=1 user=1 role=owner
|
||||
2026-07-22T14:36:49: [disconnect] socket=DAww8irzEgS7j7JLAAAf
|
||||
2026-07-22T14:36:49: [connect] socket=WgF3HArg1rthWkktAAAh company=1 user=1 role=owner
|
||||
2026-07-22T14:37:05: [disconnect] socket=WgF3HArg1rthWkktAAAh
|
||||
2026-07-22T14:37:05: [connect] socket=pfMLLNR0x-qoq485AAAj company=1 user=1 role=owner
|
||||
2026-07-22T14:37:08: [disconnect] socket=pfMLLNR0x-qoq485AAAj
|
||||
2026-07-22T14:37:09: [connect] socket=7ZtkCaJZqcYXBSZWAAAl company=1 user=1 role=owner
|
||||
2026-07-22T14:37:14: [disconnect] socket=7ZtkCaJZqcYXBSZWAAAl
|
||||
2026-07-22T14:37:14: [connect] socket=F6_OvPrmc-vv_KoqAAAn company=1 user=1 role=owner
|
||||
2026-07-22T14:37:18: [disconnect] socket=F6_OvPrmc-vv_KoqAAAn
|
||||
2026-07-22T14:37:18: [connect] socket=gdlZxPbkkcdkdE2AAAAp company=1 user=1 role=owner
|
||||
2026-07-22T14:40:08: [disconnect] socket=gdlZxPbkkcdkdE2AAAAp
|
||||
2026-07-22T14:40:09: [connect] socket=c8j8ybp-e7MPpa6cAAAr company=1 user=1 role=owner
|
||||
2026-07-22T14:40:13: [disconnect] socket=c8j8ybp-e7MPpa6cAAAr
|
||||
2026-07-22T14:40:13: [connect] socket=D36DmJgraENmU5xsAAAt company=1 user=1 role=owner
|
||||
2026-07-22T14:40:20: [disconnect] socket=D36DmJgraENmU5xsAAAt
|
||||
2026-07-22T14:40:21: [connect] socket=saY0q6gBioHWgq7RAAAv company=1 user=1 role=owner
|
||||
2026-07-22T14:40:25: [disconnect] socket=saY0q6gBioHWgq7RAAAv
|
||||
2026-07-22T14:40:25: [connect] socket=o9h4_9i-KOjDfVmrAAAx company=1 user=1 role=owner
|
||||
2026-07-22T14:40:27: [disconnect] socket=o9h4_9i-KOjDfVmrAAAx
|
||||
2026-07-22T14:40:27: [connect] socket=aIM89idl78lyhTbNAAAz company=1 user=1 role=owner
|
||||
2026-07-22T14:56:05: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||
2026-07-22T14:56:05: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'in' }
|
||||
2026-07-22T14:56:06: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
|
||||
2026-07-22T14:56:06: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'out' }
|
||||
2026-07-22T15:21:05: [emit] company=1 event=stock_updated { warehouse_id: 3, type: 'transfer' }
|
||||
2026-07-22T15:28:23: [disconnect] socket=aIM89idl78lyhTbNAAAz
|
||||
2026-07-22T15:43:35: [connect] socket=q1N4BECKfiomOEfyAAA1 company=1 user=1 role=owner
|
||||
2026-07-22T15:43:38: [disconnect] socket=q1N4BECKfiomOEfyAAA1
|
||||
2026-07-22T15:43:38: [connect] socket=yM_0j72uX0V2b-GuAAA3 company=1 user=1 role=owner
|
||||
2026-07-22T15:43:43: [disconnect] socket=yM_0j72uX0V2b-GuAAA3
|
||||
2026-07-22T15:43:43: [connect] socket=dvzUKq0p7lXQMuSLAAA5 company=1 user=1 role=owner
|
||||
2026-07-22T15:43:45: [disconnect] socket=dvzUKq0p7lXQMuSLAAA5
|
||||
2026-07-22T15:43:45: [connect] socket=TYvNpQgcOHR5-V1RAAA7 company=1 user=1 role=owner
|
||||
2026-07-22T15:43:46: [disconnect] socket=TYvNpQgcOHR5-V1RAAA7
|
||||
2026-07-22T15:43:46: [connect] socket=waNTeqU5sAu8W2jqAAA9 company=1 user=1 role=owner
|
||||
2026-07-22T16:05:01: [disconnect] socket=waNTeqU5sAu8W2jqAAA9
|
||||
2026-07-23T12:11:51: Node.js real-time server running on port 3000
|
||||
2026-07-23T12:13:27: [connect] socket=NYO9Tg4V6Cqp3e4cAAAB company=1 user=1 role=owner
|
||||
2026-07-23T12:33:05: [disconnect] socket=NYO9Tg4V6Cqp3e4cAAAB
|
||||
2026-07-23T12:33:06: [connect] socket=pEDe8Dms2dU7gdhCAAAD company=1 user=1 role=owner
|
||||
2026-07-23T12:34:13: [disconnect] socket=pEDe8Dms2dU7gdhCAAAD
|
||||
2026-07-23T12:34:17: [connect] socket=vaGUT12vPXkqH_7kAAAF company=1 user=1 role=owner
|
||||
2026-07-23T12:55:49: [disconnect] socket=vaGUT12vPXkqH_7kAAAF
|
||||
2026-07-23T13:36:04: Node.js real-time server running on port 3000
|
||||
2026-07-23T13:36:55: [connect] socket=nVGY71aXPas0zYS_AAAB company=1 user=1 role=owner
|
||||
2026-07-23T13:51:36: [disconnect] socket=nVGY71aXPas0zYS_AAAB
|
||||
2026-07-23T13:51:36: [connect] socket=5peGIWbSaRCxnlYBAAAD company=1 user=1 role=owner
|
||||
2026-07-23T13:51:38: [disconnect] socket=5peGIWbSaRCxnlYBAAAD
|
||||
2026-07-23T13:51:38: [connect] socket=fGwRmZaYGzedlqqRAAAF company=1 user=1 role=owner
|
||||
2026-07-23T13:51:40: [disconnect] socket=fGwRmZaYGzedlqqRAAAF
|
||||
2026-07-23T13:51:40: [connect] socket=YZk7xLKHpmi29ykIAAAH company=1 user=1 role=owner
|
||||
2026-07-23T13:51:41: [disconnect] socket=YZk7xLKHpmi29ykIAAAH
|
||||
2026-07-23T13:51:41: [connect] socket=f078x01mtX2eGd2HAAAJ company=1 user=1 role=owner
|
||||
2026-07-23T13:57:26: [disconnect] socket=f078x01mtX2eGd2HAAAJ
|
||||
2026-07-23T13:57:28: [connect] socket=vToy_ZVIAk6w9099AAAL company=1 user=1 role=owner
|
||||
2026-07-23T14:17:58: [disconnect] socket=vToy_ZVIAk6w9099AAAL
|
||||
2026-07-23T14:18:00: [connect] socket=7yHLdkKxBAAO_nF4AAAN company=1 user=1 role=owner
|
||||
2026-07-23T16:25:41: [disconnect] socket=7yHLdkKxBAAO_nF4AAAN
|
||||
2026-07-25T12:19:55: Node.js real-time server running on port 3000
|
||||
2026-08-03T08:52:15: Node.js real-time server running on port 3000
|
||||
2026-08-03T10:17:41: [connect] socket=kNyFq-T_JVC3-_WLAAAB company=1 user=1 role=owner
|
||||
2026-08-03T10:18:50: [disconnect] socket=kNyFq-T_JVC3-_WLAAAB
|
||||
2026-08-03T10:18:50: [connect] socket=efcou9_hk0YUTnvQAAAD company=1 user=1 role=owner
|
||||
2026-08-03T10:18:59: [disconnect] socket=efcou9_hk0YUTnvQAAAD
|
||||
2026-08-03T10:18:59: [connect] socket=PmKuy_3CCIDze4P3AAAF company=1 user=1 role=owner
|
||||
2026-08-03T10:32:14: [disconnect] socket=PmKuy_3CCIDze4P3AAAF
|
||||
2026-08-03T10:32:18: [connect] socket=-ZlIPBBmpRazD30gAAAH company=1 user=1 role=owner
|
||||
2026-08-03T10:49:23: [disconnect] socket=-ZlIPBBmpRazD30gAAAH
|
||||
2026-08-03T10:49:26: [connect] socket=Azbj9ipTPqb3aOW3AAAJ company=1 user=1 role=owner
|
||||
2026-08-03T10:54:19: [disconnect] socket=Azbj9ipTPqb3aOW3AAAJ
|
||||
2026-08-03T10:54:19: [connect] socket=FMnx-fmSk96rxIfwAAAL company=1 user=1 role=owner
|
||||
2026-08-03T11:13:57: [disconnect] socket=FMnx-fmSk96rxIfwAAAL
|
||||
2026-08-03T11:13:59: [connect] socket=LgVxBoN_6JuJtxHyAAAN company=1 user=1 role=owner
|
||||
2026-08-04T13:35:14: Node.js real-time server running on port 3000
|
||||
2026-08-04T13:36:21: [connect] socket=BOvxSU23giBsnIXWAAAB company=1 user=1 role=owner
|
||||
2026-08-04T13:36:25: [disconnect] socket=BOvxSU23giBsnIXWAAAB
|
||||
2026-08-04T13:36:25: [connect] socket=pJXUXD7HNX_V9peAAAAD company=1 user=1 role=owner
|
||||
2026-08-04T13:36:27: [disconnect] socket=pJXUXD7HNX_V9peAAAAD
|
||||
2026-08-04T13:36:27: [connect] socket=St7RkiRumWpwc47xAAAF company=1 user=1 role=owner
|
||||
2026-08-04T13:36:28: [disconnect] socket=St7RkiRumWpwc47xAAAF
|
||||
2026-08-04T13:36:28: [connect] socket=a9NsNFmUpIL1vW8uAAAH company=1 user=1 role=owner
|
||||
2026-08-04T13:40:28: [disconnect] socket=a9NsNFmUpIL1vW8uAAAH
|
||||
2026-08-04T13:40:28: [connect] socket=uYLFddlhCkOxrcJNAAAJ company=1 user=1 role=owner
|
||||
2026-08-04T13:48:10: [disconnect] socket=uYLFddlhCkOxrcJNAAAJ
|
||||
2026-08-04T13:48:11: [connect] socket=VekC6UabiJABBbjhAAAL company=1 user=1 role=owner
|
||||
2026-08-04T13:50:28: [disconnect] socket=VekC6UabiJABBbjhAAAL
|
||||
2026-08-04T13:50:29: [connect] socket=gj-glld-ZsxWbGQuAAAN company=1 user=1 role=owner
|
||||
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
|
||||
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
|
||||
2026-08-04T14:11:36: [disconnect] socket=gj-glld-ZsxWbGQuAAAN
|
||||
2026-08-04T14:11:36: [connect] socket=v97BofsQmYBAEJMmAAAP company=1 user=1 role=owner
|
||||
2026-08-04T14:13:54: [disconnect] socket=v97BofsQmYBAEJMmAAAP
|
||||
2026-08-04T14:13:54: [connect] socket=WYJSdI9xVDaTML9xAAAR company=1 user=1 role=owner
|
||||
2026-08-04T14:17:32: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||
2026-08-04T14:17:32: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||
2026-08-04T14:21:12: [disconnect] socket=WYJSdI9xVDaTML9xAAAR
|
||||
2026-08-04T14:21:12: [connect] socket=UhIyCllsOjal4UwCAAAT company=1 user=1 role=owner
|
||||
2026-08-04T14:21:13: [disconnect] socket=UhIyCllsOjal4UwCAAAT
|
||||
2026-08-04T14:21:13: [connect] socket=6hZ-_fAyDgWzwsgvAAAV company=1 user=1 role=owner
|
||||
2026-08-04T14:21:38: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T14:21:38: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T14:25:22: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||
2026-08-04T14:25:22: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||
2026-08-04T14:25:22: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T14:25:22: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T14:29:14: [disconnect] socket=6hZ-_fAyDgWzwsgvAAAV
|
||||
2026-08-04T14:29:14: [connect] socket=7ocTMfufQlyO36XrAAAX company=1 user=1 role=owner
|
||||
2026-08-04T14:29:19: [disconnect] socket=7ocTMfufQlyO36XrAAAX
|
||||
2026-08-04T14:36:58: [connect] socket=kY4WNaECxPvGVj2JAAAZ company=1 user=1 role=owner
|
||||
2026-08-04T14:37:12: [disconnect] socket=kY4WNaECxPvGVj2JAAAZ
|
||||
2026-08-04T14:37:12: [connect] socket=OabEymZu5SehwNWnAAAb company=1 user=1 role=owner
|
||||
2026-08-04T14:37:40: [disconnect] socket=OabEymZu5SehwNWnAAAb
|
||||
2026-08-04T14:41:55: [connect] socket=kAlZOJl54kd8RXKAAAAd company=1 user=1 role=owner
|
||||
2026-08-04T14:42:42: [disconnect] socket=kAlZOJl54kd8RXKAAAAd
|
||||
2026-08-04T14:42:42: [connect] socket=DBSytTfd-o12DxhfAAAf company=1 user=1 role=owner
|
||||
2026-08-04T14:49:38: [disconnect] socket=DBSytTfd-o12DxhfAAAf
|
||||
2026-08-04T14:49:39: [connect] socket=sAr1qebAYGyIq8zrAAAh company=1 user=1 role=owner
|
||||
2026-08-04T14:58:10: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||
2026-08-04T14:58:10: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T14:58:10: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||
2026-08-04T14:58:10: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T14:58:10: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
|
||||
2026-08-04T14:59:17: [disconnect] socket=sAr1qebAYGyIq8zrAAAh
|
||||
2026-08-04T14:59:17: [connect] socket=a264uVnqws4cIAy-AAAj company=1 user=1 role=owner
|
||||
2026-08-04T15:21:19: [disconnect] socket=a264uVnqws4cIAy-AAAj
|
||||
2026-08-04T15:21:19: [connect] socket=JL7kcUwnQI_NExMkAAAl company=1 user=1 role=owner
|
||||
2026-08-04T15:21:22: [disconnect] socket=JL7kcUwnQI_NExMkAAAl
|
||||
2026-08-04T15:27:09: [connect] socket=VcbOCpKEShqcqqM0AAAn company=1 user=1 role=owner
|
||||
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
|
||||
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
|
||||
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
|
||||
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
|
||||
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
|
||||
2026-08-04T15:30:09: [disconnect] socket=VcbOCpKEShqcqqM0AAAn
|
||||
2026-08-04T15:30:09: [connect] socket=ecBAVshG1Eng4jh5AAAp company=1 user=1 role=owner
|
||||
2026-08-04T15:33:32: [disconnect] socket=ecBAVshG1Eng4jh5AAAp
|
||||
2026-08-04T15:33:32: [connect] socket=M43MyEQ7wipYOgd5AAAr company=1 user=1 role=owner
|
||||
2026-08-04T15:34:32: [disconnect] socket=M43MyEQ7wipYOgd5AAAr
|
||||
2026-08-04T15:34:32: [connect] socket=TiGDT6OMJsYlixlkAAAt company=1 user=1 role=owner
|
||||
2026-08-04T15:35:36: [disconnect] socket=TiGDT6OMJsYlixlkAAAt
|
||||
2026-08-04T15:35:36: [connect] socket=uHRGhZU0TJVvvh_aAAAv company=1 user=1 role=owner
|
||||
2026-08-04T15:36:24: [disconnect] socket=uHRGhZU0TJVvvh_aAAAv
|
||||
2026-08-04T15:36:24: [connect] socket=Hsui_73WGENhGdRIAAAx company=1 user=1 role=owner
|
||||
2026-08-04T15:38:27: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||
2026-08-04T15:38:27: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||
2026-08-04T15:38:27: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||
2026-08-04T15:38:27: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
|
||||
2026-08-04T15:39:55: [disconnect] socket=Hsui_73WGENhGdRIAAAx
|
||||
2026-08-04T15:39:55: [connect] socket=xSl0-9raxlwU2K9rAAAz company=1 user=1 role=owner
|
||||
2026-08-04T15:52:53: [disconnect] socket=xSl0-9raxlwU2K9rAAAz
|
||||
2026-08-04T15:52:53: [connect] socket=n_l9kHYTF1AXNNRYAAA1 company=1 user=1 role=owner
|
||||
2026-08-04T15:58:37: [disconnect] socket=n_l9kHYTF1AXNNRYAAA1
|
||||
2026-08-04T15:58:37: [connect] socket=T9mpzRMT3I1qjj0BAAA3 company=1 user=1 role=owner
|
||||
2026-08-04T15:58:39: [disconnect] socket=T9mpzRMT3I1qjj0BAAA3
|
||||
2026-08-04T15:58:39: [connect] socket=Q1jGtGwwFc49KKxDAAA5 company=1 user=1 role=owner
|
||||
2026-08-04T15:58:40: [disconnect] socket=Q1jGtGwwFc49KKxDAAA5
|
||||
2026-08-04T15:58:41: [connect] socket=Fk7l8SLr2IIRFFHbAAA7 company=1 user=1 role=owner
|
||||
2026-08-04T15:58:42: [disconnect] socket=Fk7l8SLr2IIRFFHbAAA7
|
||||
2026-08-04T15:58:42: [connect] socket=pAuTSmpDDC86EZwXAAA9 company=1 user=1 role=owner
|
||||
2026-08-04T16:41:11: [disconnect] socket=pAuTSmpDDC86EZwXAAA9
|
||||
2026-08-04T16:41:13: [connect] socket=XmFaoUIej-g8203TAAA_ company=1 user=1 role=owner
|
||||
2026-08-04T16:41:25: [disconnect] socket=XmFaoUIej-g8203TAAA_
|
||||
2026-08-04T16:41:28: [connect] socket=TxwzTsUHzqDLHpODAABB company=1 user=1 role=owner
|
||||
2026-08-04T16:43:43: [disconnect] socket=TxwzTsUHzqDLHpODAABB
|
||||
2026-08-04T16:44:26: [connect] socket=TOhs2YrBWQkiGDZDAABD company=1 user=1 role=owner
|
||||
2026-08-04T16:58:05: [disconnect] socket=TOhs2YrBWQkiGDZDAABD
|
||||
2026-08-04T16:59:15: [connect] socket=p1xNFoGJFKox8FaOAABF company=1 user=1 role=owner
|
||||
2026-08-04T17:25:03: [disconnect] socket=p1xNFoGJFKox8FaOAABF
|
||||
2026-08-04T17:25:03: [connect] socket=j3s6wvwe_BxVzCA_AABH company=1 user=1 role=owner
|
||||
2026-08-04T17:25:05: [disconnect] socket=j3s6wvwe_BxVzCA_AABH
|
||||
2026-08-04T17:25:05: [connect] socket=4wkwmOWCAvQSicL3AABJ company=1 user=1 role=owner
|
||||
2026-08-04T17:26:40: [disconnect] socket=4wkwmOWCAvQSicL3AABJ
|
||||
@@ -1,32 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
|
||||
TOOL_DIR="$SCRIPT_DIR/sdlc-delivery"
|
||||
SOURCE_DIR="$ROOT_DIR/sdlc"
|
||||
DELIVERY_ROOT="$ROOT_DIR/sdlc-delivery"
|
||||
STAGING_DIR="$ROOT_DIR/.sdlc-delivery.staging"
|
||||
|
||||
if [[ ! -d "$TOOL_DIR/node_modules/playwright" ]]; then
|
||||
echo "Installing the local PDF renderer..."
|
||||
npm install --prefix "$TOOL_DIR"
|
||||
npx --prefix "$TOOL_DIR" playwright install chromium
|
||||
fi
|
||||
|
||||
rm -rf "$STAGING_DIR"
|
||||
mkdir -p "$STAGING_DIR"
|
||||
|
||||
while IFS= read -r -d '' source; do
|
||||
relative="${source#"$ROOT_DIR/sdlc/"}"
|
||||
destination="$STAGING_DIR/${relative%.md}.pdf"
|
||||
echo "Rendering ${relative%.md}.pdf"
|
||||
node "$TOOL_DIR/render-sdlc.mjs" "$source" "$destination"
|
||||
done < <(find "$SOURCE_DIR" -type f -name '*.md' -print0 | sort -z)
|
||||
|
||||
echo "Verifying staged delivery package..."
|
||||
"$SCRIPT_DIR/verify-sdlc-delivery.sh" "$STAGING_DIR"
|
||||
|
||||
rm -rf "$DELIVERY_ROOT"
|
||||
mv "$STAGING_DIR" "$DELIVERY_ROOT"
|
||||
echo "Delivery and verification passed: $DELIVERY_ROOT"
|
||||
Binary file not shown.
@@ -1,7 +0,0 @@
|
||||
<table class="document-control">
|
||||
<tbody>
|
||||
<tr><th>Document</th><td>{{documentType}}</td><th>Release</th><td>{{release}}</td></tr>
|
||||
<tr><th>Project</th><td>{{projectName}}</td><th>Project code</th><td>{{projectCode}}</td></tr>
|
||||
<tr><th>Project period</th><td colspan="3">{{projectPeriod}}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -1,4 +0,0 @@
|
||||
<div style="border-top:1.5pt solid #1f2933; color:#52606d; display:flex; font-family:'BRN Thai',Arial,sans-serif; font-size:8pt; justify-content:space-between; margin:0 16mm; padding-top:2.5mm; width:calc(100% - 32mm);">
|
||||
<span>ISO/IEC 29110-4-1:2018</span>
|
||||
<span>{{projectCode}}</span>
|
||||
</div>
|
||||
@@ -1,8 +0,0 @@
|
||||
<header class="document-header">
|
||||
<img class="document-header__logo" src="{{logoPath}}" alt="B.R.N. Enterprise Co., Ltd.">
|
||||
<div class="document-header__company">
|
||||
<p class="document-header__company-name">B.R.N. ENTERPRISE CO., LTD.</p>
|
||||
<p class="document-header__address">1011 Supalai Grand Tower, 7th Floor, Unit 6-7, Rama 3 Road, Chongnonsi, Yannawa, Bangkok 10120<br>Tel. (+66) 2687 0250 Fax. (+66) 2687 0255</p>
|
||||
</div>
|
||||
<div class="document-header__title">{{documentTitle}}</div>
|
||||
</header>
|
||||
@@ -1,37 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>SDLC delivery theme preview</title>
|
||||
<link rel="stylesheet" href="sdlc-delivery.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="delivery-document">
|
||||
<header class="document-header">
|
||||
<img class="document-header__logo" src="../../../app/assets/images/logo.png" alt="B.R.N. Enterprise Co., Ltd.">
|
||||
<div class="document-header__company">
|
||||
<p class="document-header__company-name">B.R.N. ENTERPRISE CO., LTD.</p>
|
||||
<p class="document-header__address">1011 Supalai Grand Tower, 7th Floor, Unit 6-7, Rama 3 Road, Chongnonsi, Yannawa, Bangkok 10120<br>Tel. (+66) 2687 0250 Fax. (+66) 2687 0255</p>
|
||||
</div>
|
||||
<div class="document-header__title">Software Project Plan</div>
|
||||
</header>
|
||||
|
||||
<table class="document-control">
|
||||
<tbody>
|
||||
<tr><th>Document</th><td>Software Project Plan</td><th>Release</th><td>05/01/26 V1.0 Final</td></tr>
|
||||
<tr><th>Project</th><td>BRN WMS</td><th>Project code</th><td>200-WMS-26-001-00</td></tr>
|
||||
<tr><th>Project period</th><td colspan="3">05/01/26–24/08/26</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Project overview</h2>
|
||||
<p>This preview demonstrates the shared A4 header, document-control table, headings, and standard data tables used by every generated delivery PDF.</p>
|
||||
|
||||
<table>
|
||||
<thead><tr><th>Phase</th><th>Period</th><th>Primary output</th></tr></thead>
|
||||
<tbody><tr><td>Planning</td><td>12/01/26–18/02/26</td><td>Project Plan</td></tr></tbody>
|
||||
</table>
|
||||
</main>
|
||||
<footer class="document-footer"><span>ISO/IEC 29110-4-1:2018</span><span>200-WMS-26-001-00 · 1 / 1</span></footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,130 +0,0 @@
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 18mm 16mm 28mm;
|
||||
}
|
||||
|
||||
:root {
|
||||
--brn-blue: #0789c9;
|
||||
--brn-red: #bd0e2d;
|
||||
--brn-ink: #1f2933;
|
||||
--brn-muted: #52606d;
|
||||
--brn-line: #aab7c4;
|
||||
--brn-pale-blue: #eaf6fc;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
color: var(--brn-ink);
|
||||
font-family: "BRN Thai", Arial, sans-serif;
|
||||
font-size: 10.5pt;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
body { margin: 0; }
|
||||
|
||||
.delivery-document { width: 100%; }
|
||||
|
||||
.document-header {
|
||||
align-items: center;
|
||||
border-bottom: 1.5pt solid var(--brn-blue);
|
||||
display: flex;
|
||||
gap: 9mm;
|
||||
margin: 0 0 7mm;
|
||||
min-height: 22mm;
|
||||
padding: 0 0 3mm;
|
||||
}
|
||||
|
||||
.document-header__logo {
|
||||
flex: 0 0 auto;
|
||||
height: 16mm;
|
||||
object-fit: contain;
|
||||
width: 16mm;
|
||||
}
|
||||
|
||||
.document-header__company { flex: 1; min-width: 0; }
|
||||
|
||||
.document-header__company-name {
|
||||
font-size: 13pt;
|
||||
font-weight: 700;
|
||||
letter-spacing: .03em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.document-header__address {
|
||||
color: var(--brn-muted);
|
||||
font-size: 7.5pt;
|
||||
line-height: 1.35;
|
||||
margin: 1mm 0 0;
|
||||
}
|
||||
|
||||
.document-header__title {
|
||||
background: var(--brn-blue);
|
||||
color: #fff;
|
||||
font-size: 15pt;
|
||||
font-weight: 500;
|
||||
flex: 0 1 72mm;
|
||||
overflow-wrap: anywhere;
|
||||
min-width: 52mm;
|
||||
padding: 4mm 6mm;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.document-control {
|
||||
border-collapse: collapse;
|
||||
margin: 0 0 7mm;
|
||||
page-break-inside: avoid;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.document-control th,
|
||||
.document-control td {
|
||||
border: .5pt solid var(--brn-line);
|
||||
padding: 2.2mm 3mm;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.document-control th {
|
||||
background: var(--brn-pale-blue);
|
||||
font-weight: 700;
|
||||
width: 26%;
|
||||
}
|
||||
|
||||
h1, h2, h3 { break-after: avoid; color: var(--brn-ink); }
|
||||
h1 { font-size: 18pt; margin: 0 0 5mm; }
|
||||
h2 { border-left: 4pt solid var(--brn-blue); font-size: 14pt; margin: 9mm 0 4mm; padding-left: 3mm; }
|
||||
h3 { color: var(--brn-blue); font-size: 11.5pt; margin: 6mm 0 3mm; }
|
||||
|
||||
p { margin: 0 0 3.5mm; }
|
||||
|
||||
table:not(.document-control) {
|
||||
border-collapse: collapse;
|
||||
border: .75pt solid #718096;
|
||||
font-size: 9pt;
|
||||
margin: 0 0 5mm;
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
table:not(.document-control) th,
|
||||
table:not(.document-control) td {
|
||||
border: .5pt solid var(--brn-line);
|
||||
overflow-wrap: anywhere;
|
||||
padding: 2mm;
|
||||
vertical-align: top;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
table:not(.document-control) th { background: var(--brn-pale-blue); font-weight: 700; }
|
||||
thead { display: table-header-group; }
|
||||
tr { break-inside: avoid; }
|
||||
|
||||
.approval-block { break-inside: avoid; margin-top: 9mm; }
|
||||
.approval-block__person { margin: 5mm 0; min-height: 21mm; }
|
||||
.approval-block__line { border-bottom: .5pt solid var(--brn-ink); display: inline-block; min-width: 70mm; }
|
||||
.approval-fields {
|
||||
line-height: 1.62;
|
||||
margin: 0 0 8mm;
|
||||
padding: 2.5mm 0 4mm;
|
||||
}
|
||||
Generated
-54
@@ -1,54 +0,0 @@
|
||||
{
|
||||
"name": "brn-wms-sdlc-delivery",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "brn-wms-sdlc-delivery",
|
||||
"dependencies": {
|
||||
"playwright": "1.50.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.50.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.50.0.tgz",
|
||||
"integrity": "sha512-+GinGfGTrd2IfX1TA4N2gNmeIksSb+IAe589ZH+FlmpV3MYTx6+buChGIuDLQwrGNCw2lWibqV50fU510N7S+w==",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.50.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.50.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.50.0.tgz",
|
||||
"integrity": "sha512-CXkSSlr4JaZs2tZHI40DsZUN/NIwgaUPsyLuOAaIZp2CyF2sN5MM5NJsyB188lFSSozFxQ5fPT4qM+f0tH/6wQ==",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "brn-wms-sdlc-delivery",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"playwright": "1.50.0"
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, '../..');
|
||||
const assets = path.join(here, 'assets');
|
||||
|
||||
const escapeHtml = (value) => value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>');
|
||||
|
||||
const renderInline = (value) => escapeHtml(value)
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
|
||||
const applyTemplate = (template, values) => template.replace(/{{(\w+)}}/g, (_, key) => values[key] ?? '');
|
||||
|
||||
function isTableLine(line) {
|
||||
return /^\|.*\|\s*$/.test(line.trim());
|
||||
}
|
||||
|
||||
function tableCells(line) {
|
||||
return line.trim().slice(1, -1).split('|').map((cell) => cell.trim());
|
||||
}
|
||||
|
||||
function isSeparator(cells) {
|
||||
return cells.every((cell) => /^:?-{3,}:?$/.test(cell));
|
||||
}
|
||||
|
||||
function renderTable(lines, className = '') {
|
||||
const rows = lines.map(tableCells);
|
||||
const header = rows[0];
|
||||
const data = isSeparator(rows[1] ?? []) ? rows.slice(2) : rows.slice(1);
|
||||
const cells = (tag, row) => row.map((cell) => `<${tag}>${renderInline(cell)}</${tag}>`).join('');
|
||||
return `<table${className ? ` class="${className}"` : ''}><thead><tr>${cells('th', header)}</tr></thead><tbody>${data.map((row) => `<tr>${cells('td', row)}</tr>`).join('')}</tbody></table>`;
|
||||
}
|
||||
|
||||
function renderParagraph(lines) {
|
||||
return lines.map((line, index) => {
|
||||
const trimmed = line.trimEnd();
|
||||
const hasHardBreak = /\s{2,}$/.test(line);
|
||||
const isApprovalField = /^(Name|Role|Signature|Date|Position|Company|Project roles|Decision):/.test(trimmed);
|
||||
const separator = hasHardBreak || isApprovalField ? '<br>' : (index < lines.length - 1 ? ' ' : '');
|
||||
return `${renderInline(trimmed)}${separator}`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function markdownToHtml(markdown) {
|
||||
const lines = markdown.replaceAll('\r\n', '\n').split('\n');
|
||||
const output = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index];
|
||||
if (!line.trim()) { index += 1; continue; }
|
||||
|
||||
if (isTableLine(line)) {
|
||||
const table = [];
|
||||
while (index < lines.length && isTableLine(lines[index])) table.push(lines[index++]);
|
||||
output.push(renderTable(table));
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = line.match(/^(#{1,3})\s+(.+)$/);
|
||||
if (heading) {
|
||||
const level = heading[1].length;
|
||||
output.push(`<h${level}>${renderInline(heading[2])}</h${level}>`);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^[-*]\s+/.test(line)) {
|
||||
const items = [];
|
||||
while (index < lines.length && /^[-*]\s+/.test(lines[index])) items.push(`<li>${renderInline(lines[index++].replace(/^[-*]\s+/, ''))}</li>`);
|
||||
output.push(`<ul>${items.join('')}</ul>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const paragraph = [];
|
||||
while (index < lines.length && lines[index].trim() && !isTableLine(lines[index]) && !/^(#{1,3})\s+/.test(lines[index]) && !/^[-*]\s+/.test(lines[index])) paragraph.push(lines[index++]);
|
||||
const approvalClass = paragraph.some((line) => /^(Name|Role|Signature|Date|Position|Company|Project roles|Decision):/.test(line.trimEnd())) ? ' class="approval-fields"' : '';
|
||||
output.push(`<p${approvalClass}>${renderParagraph(paragraph)}</p>`);
|
||||
}
|
||||
|
||||
return output.join('\n');
|
||||
}
|
||||
|
||||
function extractDocument(markdown) {
|
||||
const titleMatch = markdown.match(/^#\s+(.+)\n+/m);
|
||||
const title = titleMatch?.[1] ?? 'BRN WMS';
|
||||
const afterTitle = markdown.slice((titleMatch?.index ?? 0) + (titleMatch?.[0].length ?? 0));
|
||||
const lines = afterTitle.split('\n');
|
||||
const metadata = {};
|
||||
let bodyStart = 0;
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
if (!isTableLine(lines[index])) continue;
|
||||
|
||||
const table = [];
|
||||
while (index < lines.length && isTableLine(lines[index])) table.push(lines[index++]);
|
||||
const candidate = {};
|
||||
const dataStart = isSeparator(tableCells(table[1] ?? '')) ? 2 : 1;
|
||||
for (const row of table.slice(dataStart).map(tableCells)) {
|
||||
if (row.length >= 2) candidate[row[0]] = row[1];
|
||||
}
|
||||
|
||||
if (candidate.Document && candidate.Project) {
|
||||
Object.assign(metadata, candidate);
|
||||
bodyStart = index;
|
||||
break;
|
||||
}
|
||||
|
||||
index -= 1;
|
||||
}
|
||||
|
||||
return { title, metadata, body: lines.slice(bodyStart).join('\n') };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [source, destination] = process.argv.slice(2);
|
||||
if (!source || !destination) throw new Error('Usage: render-sdlc.mjs SOURCE.md DESTINATION.pdf');
|
||||
|
||||
const [markdown, css, headerTemplate, footerTemplate, logo, thaiFont] = await Promise.all([
|
||||
fs.readFile(source, 'utf8'),
|
||||
fs.readFile(path.join(assets, 'sdlc-delivery.css'), 'utf8'),
|
||||
fs.readFile(path.join(assets, 'header.html'), 'utf8'),
|
||||
fs.readFile(path.join(assets, 'footer.html'), 'utf8'),
|
||||
fs.readFile(path.join(root, 'app/assets/images/logo.svg')),
|
||||
fs.readFile(path.join(assets, 'LeelawadeeUI.ttf'))
|
||||
]);
|
||||
const { title, metadata, body } = extractDocument(markdown);
|
||||
const hasWideTable = markdown.split('\n').some((line) => isTableLine(line) && tableCells(line).length >= 8);
|
||||
const isLandscape = metadata.Document === 'Work Schedule' || title === 'Work Schedule' || hasWideTable;
|
||||
const values = {
|
||||
logoPath: `data:image/svg+xml;base64,${logo.toString('base64')}`,
|
||||
documentTitle: title,
|
||||
documentType: metadata.Document ?? title,
|
||||
release: metadata.Release ?? '',
|
||||
projectName: metadata.Project ?? 'BRN WMS',
|
||||
projectCode: metadata['Project code'] ?? '200-WMS-26-001-00',
|
||||
projectPeriod: metadata['Project period'] ?? '05/01/26–24/08/26',
|
||||
pageNumber: '<span class="pageNumber"></span>',
|
||||
totalPages: '<span class="totalPages"></span>'
|
||||
};
|
||||
const control = await fs.readFile(path.join(assets, 'document-control.html'), 'utf8');
|
||||
const pdfFooter = applyTemplate(footerTemplate, values);
|
||||
const fontFace = `@font-face { font-family: "BRN Thai"; src: url(data:font/ttf;base64,${thaiFont.toString('base64')}) format("truetype"); font-weight: 400 700; }`;
|
||||
const pageLayout = isLandscape
|
||||
? '@page { size: A4 landscape; margin: 18mm 16mm 28mm; }'
|
||||
: '';
|
||||
const html = `<!doctype html><html><head><meta charset="utf-8"><style>${fontFace}${css}${pageLayout}</style></head><body><main class="delivery-document">${applyTemplate(headerTemplate, values)}${applyTemplate(control, values)}${markdownToHtml(body)}</main></body></html>`;
|
||||
|
||||
await fs.mkdir(path.dirname(destination), { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setContent(html, { waitUntil: 'networkidle' });
|
||||
await page.pdf({
|
||||
path: destination,
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
preferCSSPageSize: true,
|
||||
displayHeaderFooter: true,
|
||||
headerTemplate: '<div></div>',
|
||||
footerTemplate: pdfFooter
|
||||
});
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => { console.error(error); process.exit(1); });
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user