Compare commits
23
Commits
sdlc
..
931d95bafc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
931d95bafc | ||
|
|
d199fa7f67 | ||
|
|
c957280aa5 | ||
|
|
9512d440dd | ||
|
|
45331948c1 | ||
|
|
ba73456185 | ||
|
|
501c70050f | ||
|
|
f6909b08eb | ||
|
|
5846c8b282 | ||
|
|
6a271c1f7d | ||
|
|
2ef2f32107 | ||
|
|
1f72633cb6 | ||
|
|
4efab9f7c9 | ||
|
|
44c66c49a5 | ||
|
|
6b3a590aa9 | ||
|
|
21148bf50c | ||
|
|
cf8106771b | ||
|
|
d7203583b7 | ||
|
|
9afcf072b0 | ||
|
|
2f290ddb26 | ||
|
|
5d021d7683 | ||
|
|
5ee0c8d41b | ||
|
|
c3113bc70d |
@@ -13,5 +13,11 @@ 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
|
||||
|
||||
+3
-3
@@ -6,6 +6,9 @@ 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
|
||||
|
||||
@@ -16,6 +19,3 @@ lib/zxcvbn-php-master/vendor/sebastian/
|
||||
notes/
|
||||
docs/
|
||||
SESSION.php
|
||||
|
||||
# SDLC delivery zip is packaged by hand, outside the repo
|
||||
sdlc-delivery.zip
|
||||
|
||||
@@ -199,7 +199,11 @@
|
||||
supplier_credit_note: 'Supplier Credit Note',
|
||||
receipt: 'Receipt',
|
||||
payment: 'Payment',
|
||||
manual: 'Manual'
|
||||
manual: 'Manual',
|
||||
asset_capitalization: 'Asset Mgmt · Capitalization',
|
||||
asset_depreciation: 'Asset Mgmt · Depreciation',
|
||||
asset_amortization: 'Asset Mgmt · Amortization',
|
||||
asset_disposal: 'Asset Mgmt · Disposal'
|
||||
};
|
||||
|
||||
var source_badges = {
|
||||
@@ -209,7 +213,11 @@
|
||||
supplier_credit_note: 'bg-secondary-subtle text-secondary',
|
||||
receipt: 'bg-info-subtle text-info',
|
||||
payment: 'bg-danger-subtle text-danger',
|
||||
manual: 'bg-dark-subtle text-dark'
|
||||
manual: 'bg-dark-subtle text-dark',
|
||||
asset_capitalization: 'bg-success text-white',
|
||||
asset_depreciation: 'bg-success text-white',
|
||||
asset_amortization: 'bg-success text-white',
|
||||
asset_disposal: 'bg-success text-white'
|
||||
};
|
||||
|
||||
function fmt_num(n) {
|
||||
|
||||
@@ -11,6 +11,7 @@ require_once '../../../assets/utils/classes_ac/posting/SupplierCreditNotePosting
|
||||
require_once '../../../assets/utils/classes_ac/posting/ReceiptPosting.php';
|
||||
require_once '../../../assets/utils/classes_ac/posting/PaymentPosting.php';
|
||||
require_once '../../../assets/utils/classes_ac/posting/PurchasePosting.php';
|
||||
require_once '../../../assets/utils/classes_as/AssetPosting.php';
|
||||
|
||||
require_role($user_role, ['owner', 'admin']);
|
||||
require_once '../../../assets/utils/notify_node.php';
|
||||
@@ -29,19 +30,31 @@ $posting_map = [
|
||||
'purchase_order' => PurchasePosting::class,
|
||||
];
|
||||
|
||||
if (!isset($posting_map[$doc_type]) || $id <= 0) {
|
||||
$is_asset_source = AssetPosting::isAssetSource($doc_type);
|
||||
|
||||
if ((!isset($posting_map[$doc_type]) && !$is_asset_source) || $id <= 0) {
|
||||
$answer['message'] = 'Invalid doc_type or id.';
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
try {
|
||||
$posting_class = $posting_map[$doc_type];
|
||||
$posting = new $posting_class($pdo2, $company_id);
|
||||
$built = $posting->build($id, $formula_id);
|
||||
if ($is_asset_source) {
|
||||
// Asset Management entries carry their own reference and "[Asset Mgmt]" description.
|
||||
$built = (new AssetPosting($pdo2, $company_id))->build($doc_type, $id);
|
||||
$meta = [
|
||||
'journal_date' => $built['doc_date'],
|
||||
'reference' => $built['reference'],
|
||||
'description' => $built['description'],
|
||||
];
|
||||
} else {
|
||||
$posting_class = $posting_map[$doc_type];
|
||||
$posting = new $posting_class($pdo2, $company_id);
|
||||
$built = $posting->build($id, $formula_id);
|
||||
$meta = ['journal_date' => $built['doc_date'] ?? null];
|
||||
}
|
||||
|
||||
$guard = new PostingWindowGuard($pdo1, $company_id);
|
||||
$gl = new GlManager($pdo2, $company_id, $guard);
|
||||
$meta = ['journal_date' => $built['doc_date'] ?? null];
|
||||
|
||||
$pdo2->beginTransaction();
|
||||
|
||||
|
||||
@@ -39,6 +39,22 @@
|
||||
<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">
|
||||
@@ -60,6 +76,18 @@
|
||||
<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>
|
||||
@@ -76,9 +104,17 @@
|
||||
'purchase_invoice': 'purchase_invoice',
|
||||
'supplier_credit_note': 'supplier_credit_note',
|
||||
'receipt': 'receipt',
|
||||
'payment': 'payment'
|
||||
'payment': 'payment',
|
||||
'asset_capitalization': 'asset_capitalization',
|
||||
'asset_depreciation': 'asset_depreciation',
|
||||
'asset_amortization': 'asset_amortization',
|
||||
'asset_disposal': 'asset_disposal'
|
||||
};
|
||||
|
||||
// Prepared in Asset Management: no GL formula, accounts come from the asset category.
|
||||
var ASSET_TYPES = ['asset_capitalization', 'asset_depreciation', 'asset_amortization', 'asset_disposal'];
|
||||
function is_asset_type(doc_type) { return ASSET_TYPES.indexOf(doc_type) !== -1; }
|
||||
|
||||
var _gl_loaded = {}; // doc_type → [ids] (flat, used by single-formula run)
|
||||
var _gl_rows = {}; // doc_type → [rows]
|
||||
var _gl_failed = {}; // doc_type → failed items from last run
|
||||
@@ -91,11 +127,13 @@
|
||||
if (_inited[doc_type]) return;
|
||||
_inited[doc_type] = true;
|
||||
|
||||
load_formula_options('gl-formula-' + doc_type, doc_type, null, function(formulas) {
|
||||
var $sel = $('#gl-formula-' + doc_type);
|
||||
$sel.prepend('<option value="all">— All Formulas (' + formulas.length + ') —</option>');
|
||||
$sel.val('all');
|
||||
});
|
||||
if (!is_asset_type(doc_type)) {
|
||||
load_formula_options('gl-formula-' + doc_type, doc_type, null, function(formulas) {
|
||||
var $sel = $('#gl-formula-' + doc_type);
|
||||
$sel.prepend('<option value="all">— All Formulas (' + formulas.length + ') —</option>');
|
||||
$sel.val('all');
|
||||
});
|
||||
}
|
||||
|
||||
var today = new Date();
|
||||
var firstDay = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
@@ -117,7 +155,7 @@
|
||||
|
||||
// ── load documents ────────────────────────────────────────────────────────
|
||||
function load_gl_tab(doc_type) {
|
||||
var formula_id = $('#gl-formula-' + doc_type).val();
|
||||
var formula_id = is_asset_type(doc_type) ? '0' : $('#gl-formula-' + doc_type).val();
|
||||
if (!formula_id) { bootbox.alert('Please select a GL formula first.'); return; }
|
||||
|
||||
if (formula_id === 'all') {
|
||||
@@ -256,7 +294,9 @@
|
||||
var status_badge = r.gl_status == 1
|
||||
? '<span class="badge bg-success-subtle text-success">Posted</span>'
|
||||
: '<span class="badge bg-danger-subtle text-danger">Unposted</span>';
|
||||
var mapping_badge = product_mapping_badge(r);
|
||||
var mapping_badge = is_asset_type(doc_type)
|
||||
? '<span class="badge bg-success-subtle text-success">Asset category</span>'
|
||||
: product_mapping_badge(r);
|
||||
html +=
|
||||
'<tr>' +
|
||||
'<td>' + escape_html(r.doc_number) + '</td>' +
|
||||
@@ -302,11 +342,15 @@
|
||||
purchase_invoice: 'Purchase Invoice',
|
||||
supplier_credit_note: 'Supplier Credit Note',
|
||||
receipt: 'Receipt',
|
||||
payment: 'Payment'
|
||||
payment: 'Payment',
|
||||
asset_capitalization: '[Asset Mgmt] Capitalization',
|
||||
asset_depreciation: '[Asset Mgmt] Depreciation',
|
||||
asset_amortization: '[Asset Mgmt] Amortization',
|
||||
asset_disposal: '[Asset Mgmt] Disposal'
|
||||
};
|
||||
|
||||
function run_gl_batch(doc_type) {
|
||||
var formula_id = $('#gl-formula-' + doc_type).val();
|
||||
var formula_id = is_asset_type(doc_type) ? '0' : $('#gl-formula-' + doc_type).val();
|
||||
|
||||
if (formula_id === 'all') {
|
||||
run_all_formula_batches(doc_type);
|
||||
@@ -393,7 +437,7 @@
|
||||
|
||||
// ── core batch processor ──────────────────────────────────────────────────
|
||||
function _start_gl_batch(doc_type, ids, onComplete, formula_id_override) {
|
||||
var formula_id = formula_id_override || $('#gl-formula-' + doc_type).val();
|
||||
var formula_id = formula_id_override || (is_asset_type(doc_type) ? 0 : $('#gl-formula-' + doc_type).val());
|
||||
var $msg = $('#gl-msg-' + doc_type);
|
||||
|
||||
set_btn_state('#gl-btn-load-' + doc_type, false);
|
||||
@@ -516,10 +560,34 @@
|
||||
</html>
|
||||
|
||||
<?php
|
||||
function gl_tab_html(string $doc_type, string $label): string {
|
||||
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';
|
||||
|
||||
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">
|
||||
@@ -532,11 +600,7 @@ function gl_tab_html(string $doc_type, string $label): string {
|
||||
<input type="text" id="gl-date-to-{$doc_type}" 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">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>
|
||||
{$source_field}
|
||||
<div class="col-auto">
|
||||
<button id="gl-btn-load-{$doc_type}" class="btn btn-secondary btn-sm"
|
||||
onclick="load_gl_tab('{$doc_type}')">
|
||||
@@ -565,7 +629,7 @@ function gl_tab_html(string $doc_type, string $label): string {
|
||||
<th>Contact</th>
|
||||
<th class="text-end">Amount</th>
|
||||
<th>Date</th>
|
||||
<th>Product Accounts</th>
|
||||
<th>{$accounts_header}</th>
|
||||
<th>GL Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -48,6 +48,12 @@
|
||||
<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">
|
||||
@@ -188,7 +194,11 @@
|
||||
receipt: 'Receipt',
|
||||
payment: 'Payment',
|
||||
manual: 'Manual',
|
||||
reversal: 'Reversal'
|
||||
reversal: 'Reversal',
|
||||
asset_capitalization: 'Asset Mgmt · Capitalization',
|
||||
asset_depreciation: 'Asset Mgmt · Depreciation',
|
||||
asset_amortization: 'Asset Mgmt · Amortization',
|
||||
asset_disposal: 'Asset Mgmt · Disposal'
|
||||
};
|
||||
|
||||
var SOURCE_BADGE = {
|
||||
@@ -199,7 +209,11 @@
|
||||
receipt: 'bg-info-subtle text-info',
|
||||
payment: 'bg-danger-subtle text-danger',
|
||||
manual: 'bg-dark-subtle text-dark',
|
||||
reversal: 'bg-danger-subtle text-danger'
|
||||
reversal: 'bg-danger-subtle text-danger',
|
||||
asset_capitalization: 'bg-success text-white',
|
||||
asset_depreciation: 'bg-success text-white',
|
||||
asset_amortization: 'bg-success text-white',
|
||||
asset_disposal: 'bg-success text-white'
|
||||
};
|
||||
|
||||
function source_badge(type) {
|
||||
@@ -507,9 +521,9 @@
|
||||
? '<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'
|
||||
var extra_fields = (h.source_type === 'manual' || String(h.source_type).indexOf('asset_') === 0)
|
||||
? '<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(h.journal_date_fmt || '—') + '</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">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,6 +139,8 @@
|
||||
invoice: 'Invoice', credit_note: 'Credit Note',
|
||||
purchase_invoice: 'Purchase Invoice', supplier_credit_note: 'Supplier Credit Note',
|
||||
receipt: 'Receipt', payment: 'Payment', manual: 'Manual',
|
||||
asset_capitalization: 'Asset Mgmt · Capitalization', asset_depreciation: 'Asset Mgmt · Depreciation',
|
||||
asset_amortization: 'Asset Mgmt · Amortization', asset_disposal: 'Asset Mgmt · Disposal',
|
||||
};
|
||||
|
||||
load_departments();
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?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));
|
||||
@@ -0,0 +1,21 @@
|
||||
<?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));
|
||||
@@ -0,0 +1,11 @@
|
||||
<?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));
|
||||
@@ -0,0 +1,63 @@
|
||||
<?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));
|
||||
@@ -0,0 +1,28 @@
|
||||
<?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));
|
||||
@@ -0,0 +1,70 @@
|
||||
<?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));
|
||||
@@ -0,0 +1,22 @@
|
||||
<?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));
|
||||
@@ -0,0 +1,15 @@
|
||||
<?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));
|
||||
@@ -0,0 +1,19 @@
|
||||
<?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));
|
||||
@@ -0,0 +1,15 @@
|
||||
<?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));
|
||||
@@ -0,0 +1,158 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,317 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,174 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,595 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,179 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,173 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,267 @@
|
||||
<?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,6 +1,13 @@
|
||||
|
||||
html, body { overflow-x: hidden; }
|
||||
|
||||
/* Soft badges (app access badges: WMS orange, Accounting blue, Asset Management green) */
|
||||
.bg-label-primary { background-color: var(--bs-primary-bg-subtle) !important; color: var(--bs-primary-text-emphasis) !important; }
|
||||
.bg-label-info { background-color: #e0f2fe !important; color: #0369a1 !important; }
|
||||
.bg-label-success { background-color: #dcfce7 !important; color: #15803d !important; }
|
||||
.bg-label-secondary,
|
||||
.bg-label-dark { background-color: #e9ecef !important; color: #343a40 !important; }
|
||||
|
||||
.flatpickr-day.selected {
|
||||
background: var(--bs-primary) !important;
|
||||
border-color: var(--bs-primary) !important;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// Shared helpers for the Asset Management pages.
|
||||
|
||||
var ASSET_STATUS = {
|
||||
0: ['Draft', 'bg-secondary'],
|
||||
1: ['Active', 'bg-success'],
|
||||
2: ['Fully depreciated', 'bg-info text-white'],
|
||||
3: ['Disposed', 'bg-dark'],
|
||||
4: ['Void', 'bg-light text-dark']
|
||||
};
|
||||
|
||||
function asset_status_badge(status, asset_class) {
|
||||
var code = parseInt(status, 10);
|
||||
var entry = ASSET_STATUS[code] || ['—', 'bg-light text-dark'];
|
||||
var label = (code === 2 && asset_class === 'intangible') ? 'Fully amortized' : entry[0];
|
||||
return '<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);
|
||||
}
|
||||
+11
-2
@@ -995,15 +995,24 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
/**
|
||||
* Helper function to format date strings (assuming input is in ISO format)
|
||||
*/
|
||||
// Display format used across the app: YYYY-MM-DD, or YYYY-MM-DD HH:mm:ss when the value has a time.
|
||||
function format_date(iso_string) {
|
||||
if (!iso_string) return '—';
|
||||
var split = iso_string.split(" ");
|
||||
var split = String(iso_string).split(" ");
|
||||
var datePart = split[0].split("-");
|
||||
if (datePart.length !== 3) return iso_string;
|
||||
var formatted = `${datePart[2]}/${datePart[1]}/${datePart[0]}`;
|
||||
var formatted = `${datePart[0]}-${datePart[1]}-${datePart[2]}`;
|
||||
return split.length === 2 ? `${formatted} ${split[1]}` : formatted;
|
||||
}
|
||||
|
||||
// DD/MM/YYYY for filling date inputs (flatpickr dateFormat 'd/m/Y'); to_iso_date() reverses it.
|
||||
function format_date_input(iso_string) {
|
||||
if (!iso_string) return '';
|
||||
var datePart = String(iso_string).split(" ")[0].split("-");
|
||||
if (datePart.length !== 3) return iso_string;
|
||||
return `${datePart[2]}/${datePart[1]}/${datePart[0]}`;
|
||||
}
|
||||
|
||||
|
||||
function to_iso_date(dateStr) {
|
||||
if (!dateStr) return null;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?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 = strtolower(preg_replace('/[^a-z0-9\-_]/', '', $data['channel_name'] ?? ''));
|
||||
$channel = preg_replace('/[^a-z0-9\-_]/', '', strtolower(trim($data['channel_name'] ?? '')));
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"UPDATE company_list SET
|
||||
|
||||
@@ -33,6 +33,7 @@ class DocumentNumberManager
|
||||
'manual' => ['name' => 'Manual Journal', 'prefix' => 'JV', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
||||
'return' => ['name' => 'Customer Return', 'prefix' => 'RET', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
||||
'supplier_return' => ['name' => 'Supplier Return', 'prefix' => 'SRN', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
||||
'asset' => ['name' => 'Fixed Asset', 'prefix' => 'FA', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 0],
|
||||
];
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id)
|
||||
|
||||
@@ -79,7 +79,8 @@ class StockManager {
|
||||
public function getStockList(int $warehouse_id, string $type): array
|
||||
{
|
||||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||||
$column = $type === 'out' ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)';
|
||||
// The transfer list shows the outbound row, whose quantity is in `out` (its `in` is always 0).
|
||||
$column = in_array($type, ['out', 'transfer'], true) ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)';
|
||||
$stock_ref = $this->stockReferenceSql();
|
||||
|
||||
// Transfer list: show only the outbound side (out > 0) to avoid duplicate display
|
||||
@@ -439,8 +440,9 @@ class StockManager {
|
||||
);
|
||||
}
|
||||
|
||||
// Quantity and identifiers come from the existing stock_in row (immutable)
|
||||
$quantity = (int)$source_stock['in'];
|
||||
// Quantity and identifiers come from the existing stock_in row (immutable).
|
||||
// `in` is decimal(18,4) — an int cast would drop fractional quantities.
|
||||
$quantity = (float)$source_stock['in'];
|
||||
$ref_id = (int)$source_stock['id'];
|
||||
$lot_number = $source_stock['lot_number'] ?? null;
|
||||
$serial_number = $source_stock['serial_number'] ?? null;
|
||||
@@ -611,8 +613,9 @@ class StockManager {
|
||||
);
|
||||
}
|
||||
|
||||
// Quantity and identifiers come from the source stock_in row (immutable)
|
||||
$quantity = (int)$source_stock['in'];
|
||||
// Quantity and identifiers come from the source stock_in row (immutable).
|
||||
// `in` is decimal(18,4) — an int cast would drop fractional quantities.
|
||||
$quantity = (float)$source_stock['in'];
|
||||
$lot_number = $source_stock['lot_number'] ?? null;
|
||||
$serial_number = $source_stock['serial_number'] ?? null;
|
||||
|
||||
|
||||
@@ -426,7 +426,7 @@ class UserManager {
|
||||
* within the owner's license.
|
||||
*
|
||||
* @param int $map_id The company_map_user.map_id to update.
|
||||
* @param string $app_access New value: 'wms', 'accounting', or 'all'.
|
||||
* @param string $app_access New value: 'wms', 'accounting', 'asset', or 'all'.
|
||||
* @throws Exception If the member is not found or is the owner.
|
||||
*/
|
||||
public function updateAppAccess(int $map_id, string $app_access): void {
|
||||
|
||||
@@ -697,7 +697,7 @@ class FinancialReports
|
||||
COALESCE(d.dept_code, '') AS dept_code,
|
||||
COALESCE(d.dept_name, '') AS dept_name,
|
||||
COALESCE(g.journal_date, DATE(g.created_at)) AS entry_date,
|
||||
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
|
||||
DATE_FORMAT(g.created_at, '%Y-%m-%d %H:%i:%s') AS posted_at,
|
||||
i.debit,
|
||||
i.credit,
|
||||
COALESCE(i.description, '') AS line_description
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../classes_as/AssetPosting.php';
|
||||
|
||||
class GlQueryManager
|
||||
{
|
||||
private PDO $pdo;
|
||||
@@ -6,8 +8,10 @@ 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'];
|
||||
private array $journalTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'receipt', 'payment', 'manual', 'purchase_order', 'reversal'];
|
||||
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'];
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id)
|
||||
{
|
||||
@@ -25,6 +29,11 @@ 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') {
|
||||
@@ -75,13 +84,17 @@ class GlQueryManager
|
||||
g.current_version,
|
||||
g.formula_id,
|
||||
COALESCE(f.formula_name, '') AS formula_name,
|
||||
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
|
||||
DATE_FORMAT(g.updated_at, '%d/%m/%Y %H:%i') AS updated_at,
|
||||
DATE_FORMAT(g.created_at, '%Y-%m-%d %H:%i:%s') AS posted_at,
|
||||
DATE_FORMAT(g.updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at,
|
||||
CASE g.source_type
|
||||
WHEN 'receipt' THEN r.receipt_number
|
||||
WHEN 'payment' THEN p.payment_number
|
||||
WHEN 'manual' THEN IF(g.reference != '', g.reference, CONCAT('MJE-', g.id))
|
||||
WHEN 'purchase_order' THEN po.po_number
|
||||
WHEN 'asset_capitalization' THEN g.reference
|
||||
WHEN 'asset_depreciation' THEN g.reference
|
||||
WHEN 'asset_amortization' THEN g.reference
|
||||
WHEN 'asset_disposal' THEN g.reference
|
||||
ELSE i.invoice_number
|
||||
END AS doc_number,
|
||||
COALESCE(
|
||||
@@ -90,6 +103,10 @@ class GlQueryManager
|
||||
WHEN 'payment' THEN cp.contact_name
|
||||
WHEN 'manual' THEN g.description
|
||||
WHEN 'purchase_order' THEN cpo.contact_name
|
||||
WHEN 'asset_capitalization' THEN g.description
|
||||
WHEN 'asset_depreciation' THEN g.description
|
||||
WHEN 'asset_amortization' THEN g.description
|
||||
WHEN 'asset_disposal' THEN g.description
|
||||
ELSE ci.contact_name
|
||||
END, ''
|
||||
) AS contact_name,
|
||||
@@ -101,7 +118,8 @@ class GlQueryManager
|
||||
LEFT JOIN td_gl_item gi
|
||||
ON gi.company_id = g.company_id AND gi.gl_id = g.id
|
||||
LEFT JOIN td_invoice i
|
||||
ON g.source_type NOT IN ('receipt','payment','manual','purchase_order')
|
||||
ON g.source_type NOT IN ('receipt','payment','manual','purchase_order',
|
||||
'asset_capitalization','asset_depreciation','asset_amortization','asset_disposal')
|
||||
AND i.company_id = g.company_id AND i.id = g.source_id
|
||||
LEFT JOIN md_contact ci
|
||||
ON ci.company_id = g.company_id AND ci.id = i.contact_id
|
||||
@@ -142,8 +160,8 @@ class GlQueryManager
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT g.*,
|
||||
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
|
||||
DATE_FORMAT(g.updated_at, '%d/%m/%Y %H:%i') AS updated_at_fmt,
|
||||
DATE_FORMAT(g.created_at, '%Y-%m-%d %H:%i:%s') AS posted_at,
|
||||
DATE_FORMAT(g.updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at_fmt,
|
||||
DATE_FORMAT(g.journal_date, '%d/%m/%Y') AS journal_date_fmt,
|
||||
COALESCE(f.formula_name, '') AS formula_name
|
||||
FROM td_gl g
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
<?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}'.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
<?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}).");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
<?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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<?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,6 +20,17 @@ if (!function_exists('require_role')) {
|
||||
}
|
||||
}
|
||||
|
||||
// Blocks users whose app_access does not include $app ('all' includes every app).
|
||||
if (!function_exists('require_app_access')) {
|
||||
function require_app_access(string $app): void {
|
||||
$access = $_SESSION['login_app_access'] ?? 'wms';
|
||||
if ($access !== 'all' && $access !== $app) {
|
||||
http_response_code(403);
|
||||
exit(json_encode(['success' => 0, 'message' => 'You do not have access to this app.']));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(!empty($_SESSION["login_company_id"])){
|
||||
|
||||
// CSRF Validation — add right at the top of the logged-in block
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?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,6 +38,26 @@ 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,6 +27,11 @@ 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,6 +4,8 @@
|
||||
require '../include_header.php';
|
||||
|
||||
$invoice_id = (int)($_GET['id'] ?? 0);
|
||||
// Users with Asset Management can turn issued invoice lines into assets.
|
||||
$pi_asset_access = in_array($_SESSION['login_app_access'] ?? 'wms', ['all', 'asset'], true);
|
||||
?>
|
||||
|
||||
<body>
|
||||
@@ -73,6 +75,30 @@
|
||||
</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 -->
|
||||
@@ -160,6 +186,44 @@
|
||||
|
||||
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 = {
|
||||
@@ -230,7 +294,7 @@
|
||||
$('#badge_status').html(invoice_status_badge(inv.status, inv.due_date));
|
||||
$('#display_contact').text(inv.contact_name || '—');
|
||||
$('#display_issued').text(format_date(inv.issued_date) || '—');
|
||||
$('#due_date').val(inv.due_date ? format_date(inv.due_date) : '');
|
||||
$('#due_date').val(inv.due_date ? format_date_input(inv.due_date) : '');
|
||||
$('#notes').val(inv.notes || '');
|
||||
|
||||
// Source link
|
||||
@@ -245,10 +309,14 @@
|
||||
}
|
||||
|
||||
// Items
|
||||
var can_capitalize = show_asset_actions && inv.doc_type === 'purchase_invoice' && [1, 2].includes(parseInt(inv.status));
|
||||
var tbody = '';
|
||||
$.each(inv.items || [], function(i, item) {
|
||||
var asset_link = can_capitalize
|
||||
? `<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)}</td>
|
||||
<td>${escape_html(item.product_name || item.product_sku)}${asset_link}</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>
|
||||
@@ -310,6 +378,7 @@
|
||||
$('#btn_save_formula').prop('disabled', parseInt(inv.status) === 4);
|
||||
|
||||
set_mode(inv);
|
||||
if (show_asset_actions && inv.doc_type === 'purchase_invoice') load_invoice_assets();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -274,8 +274,8 @@
|
||||
$('#po_number_display').text(po_data.po_number || '');
|
||||
$('#contact').val(po_data.contact_name || '');
|
||||
$('#contact_id').val(po_data.contact_id || '');
|
||||
$('#po_date').val(po_data.po_date ? format_date(po_data.po_date) : '');
|
||||
$('#expected_date').val(po_data.expected_date ? format_date(po_data.expected_date) : '');
|
||||
$('#po_date').val(po_data.po_date ? format_date_input(po_data.po_date) : '');
|
||||
$('#expected_date').val(po_data.expected_date ? format_date_input(po_data.expected_date) : '');
|
||||
$('#department_id').val(po_data.department_id || 0);
|
||||
$('#notes').val(po_data.notes || '');
|
||||
$('#discount').val(po_data.discount || 0);
|
||||
|
||||
@@ -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(request_data.request_date) : '');
|
||||
$('#required_date').val(request_data.required_date ? format_date(request_data.required_date) : '');
|
||||
$('#request_date').val(request_data.request_date ? format_date_input(request_data.request_date) : '');
|
||||
$('#required_date').val(request_data.required_date ? format_date_input(request_data.required_date) : '');
|
||||
$('#department_id').val(request_data.department_id || 0);
|
||||
$('#notes').val(request_data.notes || '');
|
||||
$('#discount').val(request_data.discount || 0);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?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,6 +93,16 @@
|
||||
</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>
|
||||
@@ -159,6 +169,35 @@
|
||||
}
|
||||
set_select_value('#bin', data.bin, data.bin);
|
||||
scan_location_ready = true;
|
||||
show_location_quantity();
|
||||
});
|
||||
}
|
||||
|
||||
// Quantity held in the chosen location — the amount this stock-out moves.
|
||||
function show_location_quantity() {
|
||||
var bin_val = $('#bin').val();
|
||||
$('#location_quantity').val('');
|
||||
$('#location_uom').html(' ');
|
||||
if (!$('#warehouse').val() || !bin_val) return;
|
||||
|
||||
return ajax_request({
|
||||
url: '<?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 || '');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -483,6 +522,8 @@
|
||||
$('#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(' ');
|
||||
}
|
||||
|
||||
|
||||
@@ -509,7 +550,7 @@
|
||||
$('#serial_number').html(`<option value="${data.serial_number || ''}">${data.serial_number || '—'}</option>`)
|
||||
.val(data.serial_number || '').prop('disabled', true);
|
||||
|
||||
$('#warehouse').val('<?php echo $_GET["wh"];?>').prop('disabled', true);
|
||||
$('#warehouse').val('<?php echo (int)($_GET["wh"] ?? 0);?>').prop('disabled', true);
|
||||
|
||||
function populate_fields() {
|
||||
$.each(data, function(key, item) {
|
||||
@@ -522,6 +563,8 @@
|
||||
.attr('data-id', data.contact_id);
|
||||
$('#product_name').val(data.product_name);
|
||||
$('#product_sku').attr('secondary', data.product_sku).prop('disabled', true);
|
||||
$('#location_quantity').val(format_number(data.quantity, 2));
|
||||
$('#location_uom').text(data.uom || '');
|
||||
$('button[type=submit]').text('Update');
|
||||
$('button[type=reset]').hide();
|
||||
if (data.status == 0) {
|
||||
@@ -619,6 +662,9 @@
|
||||
if (advanced) retrieve_bin();
|
||||
});
|
||||
|
||||
// Bin selected → show how much it holds
|
||||
$('#bin').on('change', show_location_quantity);
|
||||
|
||||
<?php } ?>
|
||||
</script>
|
||||
|
||||
|
||||
@@ -92,6 +92,15 @@
|
||||
<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>
|
||||
|
||||
@@ -204,12 +213,41 @@
|
||||
|
||||
if (role === 'from') {
|
||||
scan_from_ready = true;
|
||||
show_from_quantity();
|
||||
} else {
|
||||
scan_to_ready = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Quantity held in the from-location — the amount this transfer moves.
|
||||
function show_from_quantity() {
|
||||
var bin_val = $('#bin_from').val();
|
||||
$('#transfer_quantity').val('');
|
||||
$('#transfer_uom').html(' ');
|
||||
if (!$('#warehouse_from').val() || !bin_val) return;
|
||||
|
||||
return ajax_request({
|
||||
url: '<?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)
|
||||
@@ -574,6 +612,8 @@
|
||||
$('#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(' ');
|
||||
}
|
||||
|
||||
|
||||
@@ -640,6 +680,8 @@
|
||||
$('#contact').val(data.contact_name)
|
||||
.attr('secondary', data.contact_name)
|
||||
.attr('data-id', data.contact_id);
|
||||
$('#transfer_quantity').val(format_number(data.quantity, 2));
|
||||
$('#transfer_uom').text(data.uom || '');
|
||||
$('button[type=submit]').text('Update');
|
||||
$('button[type=reset]').hide();
|
||||
if (data.status == 0) {
|
||||
@@ -732,6 +774,9 @@
|
||||
if (advanced) retrieve_bin('from');
|
||||
});
|
||||
|
||||
// From bin → show how much it holds
|
||||
$('#bin_from').on('change', show_from_quantity);
|
||||
|
||||
// To warehouse → zone (advanced) or bin directly (simple)
|
||||
$('select[name="warehouse"][role="to"]').on('change', function() {
|
||||
if (advanced) { retrieve_zone('to'); } else { retrieve_bin('to'); }
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
<?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,12 +9,10 @@
|
||||
$master_can_admin = in_array($master_role, ['owner', 'admin'], true);
|
||||
$master_app_access = $_SESSION['login_app_access'] ?? 'wms';
|
||||
$master_show_accounting = in_array($master_app_access, ['all', 'accounting']);
|
||||
$master_back_url = ($_SESSION['login_current_app'] ?? 'wms') === 'accounting'
|
||||
? $server_url . 'ac_dashboard/index.php'
|
||||
: $server_url . 'dashboard/index.php';
|
||||
$master_back_label = ($_SESSION['login_current_app'] ?? 'wms') === 'accounting'
|
||||
? 'Back to Accounting'
|
||||
: 'Back to WMS';
|
||||
require_once __DIR__ . '/assets/utils/app_registry.php';
|
||||
$master_current_app = $_SESSION['login_current_app'] ?? 'wms';
|
||||
$master_back_url = $server_url . app_home_path($app_registry, $master_current_app);
|
||||
$master_back_label = 'Back to ' . ($app_registry[$master_current_app]['label'] ?? 'WMS');
|
||||
?>
|
||||
|
||||
<!-- SIDEBAR -->
|
||||
|
||||
@@ -93,8 +93,9 @@
|
||||
<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?>dashboard/index.php">
|
||||
<a class="nav-link" href="<?php echo $server_url . app_home_path($app_registry, $_SESSION['login_current_app'] ?? 'wms'); ?>">
|
||||
<i class="ti ti-arrow-left"></i>
|
||||
<span class="nav-text">Back to App</span>
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<?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>
|
||||
+108
-70
@@ -3,11 +3,17 @@
|
||||
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;
|
||||
}
|
||||
@@ -23,34 +29,58 @@ $topbar_avatar_src = $topbar_picture
|
||||
: htmlspecialchars($server_url . 'assets/images/avatar-1.jpg', ENT_QUOTES, 'UTF-8');
|
||||
|
||||
// ── App switcher ──────────────────────────────────────────────────────────────
|
||||
require_once __DIR__ . '/assets/utils/app_registry.php';
|
||||
$topbar_app_access = $_SESSION['login_app_access'] ?? 'wms';
|
||||
|
||||
// Detect which app this page belongs to.
|
||||
// Master-data pages (/accounting/, /inventory/, /contact/, /setting/) are neutral — don't change the session.
|
||||
$_wms_paths = ['/dashboard/', '/ics/', '/order/', '/po/', '/reports/'];
|
||||
$_ac_paths = ['/ac_dashboard/', '/revenue/', '/expense/', '/finance/', '/journal/'];
|
||||
// '/asset_dashboard/' and '/ac_dashboard/' do not match '/dashboard/' (no slash before "dashboard").
|
||||
$_app_paths = [
|
||||
'wms' => ['/dashboard/', '/ics/', '/order/', '/po/', '/reports/'],
|
||||
'accounting' => ['/ac_dashboard/', '/revenue/', '/expense/', '/finance/', '/journal/'],
|
||||
'asset' => ['/asset_dashboard/', '/asset/'],
|
||||
];
|
||||
|
||||
$topbar_current_app = null; // null = neutral page (master data / setting)
|
||||
foreach ($_wms_paths as $_p) {
|
||||
if (str_contains($_SERVER['PHP_SELF'], $_p)) { $topbar_current_app = 'wms'; break; }
|
||||
}
|
||||
if (!$topbar_current_app) {
|
||||
foreach ($_ac_paths as $_p) {
|
||||
if (str_contains($_SERVER['PHP_SELF'], $_p)) { $topbar_current_app = 'accounting'; break; }
|
||||
foreach ($_app_paths as $_app_key => $_paths) {
|
||||
foreach ($_paths as $_p) {
|
||||
if (str_contains($_SERVER['PHP_SELF'], $_p)) { $topbar_current_app = $_app_key; break 2; }
|
||||
}
|
||||
}
|
||||
|
||||
// Asset Management pages are only for users given that app.
|
||||
if ($topbar_current_app === 'asset' && !app_can_access($topbar_app_access, 'asset')) {
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
header('Location: ' . $server_url . app_home_path($app_registry, app_default_for_access($app_registry, $topbar_app_access)));
|
||||
exit;
|
||||
}
|
||||
|
||||
// Only persist to session when on a definitive app page
|
||||
if ($topbar_current_app) {
|
||||
$_SESSION['login_current_app'] = $topbar_current_app;
|
||||
}
|
||||
// Default session on first visit or neutral pages
|
||||
if (empty($_SESSION['login_current_app'])) {
|
||||
$_SESSION['login_current_app'] = ($topbar_app_access === 'accounting') ? 'accounting' : 'wms';
|
||||
$_SESSION['login_current_app'] = app_default_for_access($app_registry, $topbar_app_access);
|
||||
}
|
||||
|
||||
$topbar_show_wms = in_array($topbar_app_access, ['all', 'wms']);
|
||||
$topbar_show_accounting = in_array($topbar_app_access, ['all', 'accounting']);
|
||||
$topbar_show_wms = app_can_access($topbar_app_access, 'wms');
|
||||
$topbar_show_accounting = app_can_access($topbar_app_access, 'accounting');
|
||||
$topbar_switch_apps = array_filter(
|
||||
$app_registry,
|
||||
fn($key) => app_can_access($topbar_app_access, $key),
|
||||
ARRAY_FILTER_USE_KEY
|
||||
);
|
||||
|
||||
// Per-app colour. WMS keeps the compiled orange theme (#E66239); the others override it.
|
||||
$_app_themes = [
|
||||
'accounting' => ['main' => '#0284c7', 'rgb' => '2, 132, 199', 'dark' => '#0369a1', 'subtle' => '#e0f2fe', 'border' => '#bae6fd'],
|
||||
'asset' => ['main' => '#16a34a', 'rgb' => '22, 163, 74', 'dark' => '#15803d', 'subtle' => '#dcfce7', 'border' => '#bbf7d0'],
|
||||
];
|
||||
$_theme_app = $_SESSION['login_current_app'] ?? 'wms';
|
||||
$_theme = $_app_themes[$_theme_app] ?? null;
|
||||
|
||||
$_usage = (new UsageGuard($pdo1, (int)$_SESSION['login_company_id'], $packages))->getStatus();
|
||||
$_usage_max_pct = max($_usage['daily_pct'], $_usage['weekly_pct']);
|
||||
@@ -58,67 +88,67 @@ $_usage_warn = $_usage_max_pct >= 80 && $_usage_max_pct < 100;
|
||||
$_usage_full = $_usage_max_pct >= 100;
|
||||
?>
|
||||
|
||||
<?php if (($_SESSION['login_current_app'] ?? 'wms') === 'accounting'): ?>
|
||||
<style id="theme-accounting">
|
||||
<?php if ($_theme): ?>
|
||||
<style id="theme-<?php echo $_theme_app; ?>">
|
||||
:root {
|
||||
--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);
|
||||
--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);
|
||||
}
|
||||
/* Sidebar active/hover — hardcoded in theme */
|
||||
.sidebar .nav-link:hover,
|
||||
.sidebar .nav-link.active {
|
||||
color: #0284c7 !important;
|
||||
background-color: rgba(2, 132, 199, 0.09) !important;
|
||||
color: <?php echo $_theme['main']; ?> !important;
|
||||
background-color: rgba(<?php echo $_theme['rgb']; ?>, 0.09) !important;
|
||||
}
|
||||
/* Buttons */
|
||||
.btn-primary {
|
||||
--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;
|
||||
--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']; ?>;
|
||||
}
|
||||
.btn-outline-primary {
|
||||
--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;
|
||||
--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']; ?>;
|
||||
}
|
||||
.pagination {
|
||||
--bs-pagination-active-bg: #0284c7;
|
||||
--bs-pagination-active-border-color: #0284c7;
|
||||
--bs-pagination-active-bg: <?php echo $_theme['main']; ?>;
|
||||
--bs-pagination-active-border-color: <?php echo $_theme['main']; ?>;
|
||||
}
|
||||
.nav-pills { --bs-nav-pills-link-active-bg: #0284c7; }
|
||||
.dropdown-menu { --bs-dropdown-link-active-bg: #0284c7; }
|
||||
.nav-pills { --bs-nav-pills-link-active-bg: <?php echo $_theme['main']; ?>; }
|
||||
.dropdown-menu { --bs-dropdown-link-active-bg: <?php echo $_theme['main']; ?>; }
|
||||
.list-group {
|
||||
--bs-list-group-active-bg: #0284c7;
|
||||
--bs-list-group-active-border-color: #0284c7;
|
||||
--bs-list-group-active-bg: <?php echo $_theme['main']; ?>;
|
||||
--bs-list-group-active-border-color: <?php echo $_theme['main']; ?>;
|
||||
}
|
||||
.progress, .progress-stacked { --bs-progress-bar-bg: #0284c7; }
|
||||
.progress, .progress-stacked { --bs-progress-bar-bg: <?php echo $_theme['main']; ?>; }
|
||||
.table-primary {
|
||||
--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);
|
||||
--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);
|
||||
}
|
||||
thead.border-light th {
|
||||
background-color: rgba(2, 132, 199, 0.09) !important;
|
||||
color: #0284c7 !important;
|
||||
border-color: rgba(2, 132, 199, 0.15) !important;
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
<?php endif; ?>
|
||||
@@ -165,34 +195,40 @@ $_usage_full = $_usage_max_pct >= 100;
|
||||
<ul class="list-unstyled d-flex align-items-center mb-0 gap-1">
|
||||
|
||||
<!-- App switcher -->
|
||||
<?php if ($topbar_show_wms && $topbar_show_accounting): ?>
|
||||
<?php if (count($topbar_switch_apps) > 1): ?>
|
||||
<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:170px;">
|
||||
<div class="dropdown-menu dropdown-menu-end p-2" style="min-width:210px;">
|
||||
<?php $_sess_app = $_SESSION['login_current_app'] ?? 'wms'; ?>
|
||||
<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'): ?>
|
||||
<?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): ?>
|
||||
<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>
|
||||
@@ -402,7 +438,9 @@ function do_switch_branch(company_id) {
|
||||
autoPrepare: true,
|
||||
checkRequired: 0,
|
||||
action: 'update',
|
||||
company_id: company_id,
|
||||
// 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 },
|
||||
onSuccess: function(res) {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
+13
-1
@@ -43,6 +43,12 @@
|
||||
<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">
|
||||
@@ -105,6 +111,8 @@
|
||||
invoice: 'Invoice', credit_note: 'Credit Note',
|
||||
purchase_invoice: 'Purchase Invoice', supplier_credit_note: 'Supplier Credit Note',
|
||||
receipt: 'Receipt', payment: 'Payment', manual: 'Manual',
|
||||
asset_capitalization: 'Asset Mgmt · Capitalization', asset_depreciation: 'Asset Mgmt · Depreciation',
|
||||
asset_amortization: 'Asset Mgmt · Amortization', asset_disposal: 'Asset Mgmt · Disposal',
|
||||
};
|
||||
var type_badge_cls = {
|
||||
invoice: 'bg-primary-subtle text-primary',
|
||||
@@ -114,6 +122,10 @@
|
||||
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() {
|
||||
@@ -187,7 +199,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(h.journal_date_fmt || h.journal_date || '—') + '</strong></div>' +
|
||||
'<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">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,6 +58,7 @@ 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'];
|
||||
@@ -100,9 +101,15 @@ $_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.
|
||||
// so they never receive or enter an OTP. Admin/owner always go through this check,
|
||||
// unless OTP_REQUIRED=false in config.php: that also covers a user who was already
|
||||
// on the OTP screen when the switch was turned off.
|
||||
if (empty($_SESSION['skip_otp'])) {
|
||||
if ($data["otp"] != $otp || $otp_diff_minutes > 5) {
|
||||
if (!otp_required()) {
|
||||
if (!empty($user_id)) {
|
||||
otp_log_bypass($user_id, 'login_confirm');
|
||||
}
|
||||
} elseif ($data["otp"] != $otp || $otp_diff_minutes > 5) {
|
||||
$answer["message"] = "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)";
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ 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) ");
|
||||
@@ -253,11 +254,15 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
// ── Step 5g: Role check — staff/viewer skip OTP entirely ─────────────────
|
||||
// Owners always require 2FA. Invited users (license='user') require 2FA only
|
||||
// ── 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
|
||||
// if their role in this company is admin or owner; staff/viewer go straight in.
|
||||
$requires_otp = true;
|
||||
if (($r['license'] ?? 'owner') !== 'owner') {
|
||||
$requires_otp = otp_required();
|
||||
if (!$requires_otp) {
|
||||
otp_log_bypass($user_id, 'login_otp');
|
||||
} elseif (($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,8 +19,10 @@
|
||||
* 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. Required SMTP validation — smtp_host, smtp_username, smtp_password
|
||||
* must all be provided (company SMTP is mandatory for WMS email delivery).
|
||||
* 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.
|
||||
* 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).
|
||||
@@ -52,6 +54,7 @@ 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');
|
||||
|
||||
@@ -97,9 +100,9 @@ try {
|
||||
$company_name = trim($data['company_name'] ?? '');
|
||||
$company_name2 = trim($data['company_name2'] ?? '');
|
||||
|
||||
// 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'] ?? ''));
|
||||
// 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'] ?? '')));
|
||||
|
||||
$branch = trim($data['branch'] ?? 'สำนักงานใหญ่');
|
||||
$branch_no = trim($data['branch_no'] ?? '00000');
|
||||
@@ -114,59 +117,67 @@ try {
|
||||
}
|
||||
|
||||
// ── Step 5: SMTP field validation ────────────────────────────────────────
|
||||
// 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.
|
||||
// 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_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 (!$smtp_host || !$smtp_username || !$smtp_password) {
|
||||
$answer['message'] = 'SMTP configuration is required. Please fill in all SMTP fields.';
|
||||
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.';
|
||||
http_response_code(422);
|
||||
exit(json_encode($answer));
|
||||
}
|
||||
|
||||
// ── 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 ($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');
|
||||
|
||||
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.
|
||||
@@ -226,25 +237,29 @@ 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.).
|
||||
$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);
|
||||
// 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);
|
||||
}
|
||||
|
||||
// ── Step 14: Clear onboarding session keys ───────────────────────────────
|
||||
// These keys are no longer needed and should not persist into the
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
require '../config.php';
|
||||
|
||||
if (!empty($_SESSION['login_status'])) {
|
||||
header('Location: ' . $server_url . 'dashboard/index.php');
|
||||
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));
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
+21
-5
@@ -1,12 +1,13 @@
|
||||
<?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"])){
|
||||
$redirect = ($_SESSION['login_app_access'] ?? 'wms') === 'accounting'
|
||||
? $server_url . 'ac_dashboard/index.php'
|
||||
: $server_url . 'dashboard/index.php';
|
||||
$login_app = app_default_for_access($app_registry, $_SESSION['login_app_access'] ?? 'wms');
|
||||
$redirect = $server_url . app_home_path($app_registry, $login_app);
|
||||
header('Location: ' . $redirect);
|
||||
exit;
|
||||
}
|
||||
@@ -32,6 +33,13 @@
|
||||
</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">
|
||||
@@ -51,7 +59,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.
|
||||
This login uses 2FA (OTP via email) on every session when OTP_REQUIRED=true in config.php (off by default).
|
||||
A persistent login would bypass the OTP step and undermine the security model.
|
||||
Do not add this back. -->
|
||||
</div>
|
||||
@@ -62,6 +70,7 @@
|
||||
</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.
|
||||
@@ -73,13 +82,20 @@
|
||||
<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,6 +1,7 @@
|
||||
<?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'])) {
|
||||
@@ -48,7 +49,8 @@
|
||||
</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">
|
||||
<input type="text" class="form-control" id="channel_name" placeholder="e.g. my-shop"
|
||||
oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9_-]/g,'')">
|
||||
<div class="form-text">Unique identifier. Lowercase, no spaces.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
@@ -77,11 +79,20 @@
|
||||
|
||||
<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) -->
|
||||
@@ -174,11 +185,11 @@
|
||||
<!-- SMTP Form -->
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">SMTP Host <span class="text-danger">*</span></label>
|
||||
<label class="form-label">SMTP Host <?php if (otp_required()): ?><span class="text-danger">*</span><?php endif; ?></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 <span class="text-danger">*</span></label>
|
||||
<label class="form-label">Port <?php if (otp_required()): ?><span class="text-danger">*</span><?php endif; ?></label>
|
||||
<select class="form-select" id="smtp_port">
|
||||
<option value="587">587 — TLS</option>
|
||||
<option value="465">465 — SSL</option>
|
||||
@@ -186,11 +197,11 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Username / Email <span class="text-danger">*</span></label>
|
||||
<label class="form-label">Username / Email <?php if (otp_required()): ?><span class="text-danger">*</span><?php endif; ?></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 <span class="text-danger">*</span></label>
|
||||
<label class="form-label">Password <?php if (otp_required()): ?><span class="text-danger">*</span><?php endif; ?></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">
|
||||
@@ -251,13 +262,23 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$('#smtp_host').val().trim() || !$('#smtp_username').val().trim() || !$('#smtp_password').val()) {
|
||||
bootbox.alert('SMTP host, username and password are required.');
|
||||
// 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.');
|
||||
return;
|
||||
}
|
||||
|
||||
const $btn = $('#btn_finish');
|
||||
$btn.prop('disabled', true).html('<i class="ti ti-loader-2 me-1"></i>Verifying SMTP…');
|
||||
$btn.prop('disabled', true).html('<i class="ti ti-loader-2 me-1"></i>' + (smtp_given ? 'Verifying SMTP…' : 'Setting up…'));
|
||||
|
||||
const encryption = $('input[name="smtp_encryption"]:checked').val();
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
// Redirect if already logged in
|
||||
if (!empty($_SESSION['login_status'])) {
|
||||
header('Location: ' . $server_url . 'dashboard/index.php');
|
||||
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));
|
||||
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(inv.due_date) : '');
|
||||
$('#due_date').val(inv.due_date ? format_date_input(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(o.order_date) : '');
|
||||
$('#order_date').val(o.order_date ? format_date_input(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(r.return_date) : '');
|
||||
$('#return_date').val(r.return_date ? format_date_input(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(o.po_date) : '');
|
||||
$('#expected_date').val(o.expected_date ? format_date(o.expected_date) : '');
|
||||
$('#po_date').val(o.po_date ? format_date_input(o.po_date) : '');
|
||||
$('#expected_date').val(o.expected_date ? format_date_input(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(inv.due_date) : '');
|
||||
$('#due_date').val(inv.due_date ? format_date_input(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(r.return_date) : '');
|
||||
$('#return_date').val(r.return_date ? format_date_input(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(inv.due_date) : '');
|
||||
$('#due_date').val(inv.due_date ? format_date_input(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(o.order_date) : '');
|
||||
$('#order_date').val(o.order_date ? format_date_input(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(q.quotation_date) : '');
|
||||
$('#valid_until').val(q.valid_until ? format_date(q.valid_until) : '');
|
||||
$('#quotation_date').val(q.quotation_date ? format_date_input(q.quotation_date) : '');
|
||||
$('#valid_until').val(q.valid_until ? format_date_input(q.valid_until) : '');
|
||||
$('#department_id').val(q.department_id || 0);
|
||||
$('#notes').val(q.notes || '');
|
||||
$('#discount').val(parseFloat(q.discount) || 0);
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
// 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,6 +1,7 @@
|
||||
<?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.'])); }
|
||||
@@ -30,7 +31,11 @@
|
||||
$result = $um->inviteUser($email, $role, $app_access);
|
||||
|
||||
// Both new and existing users require explicit acceptance via email
|
||||
$invite_url = rtrim($server_url, '/') . ($result['new_user']
|
||||
// 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']
|
||||
? '/login/invited_onboarding.php?token=' . $result['token']
|
||||
: '/login/accept_invite.php?token=' . $result['token']);
|
||||
|
||||
@@ -88,7 +93,11 @@
|
||||
$map_id = (int)($data['map_id'] ?? 0);
|
||||
$result = $um->resendInvite($map_id);
|
||||
|
||||
$invite_url = rtrim($server_url, '/') . ($result['is_new_user']
|
||||
// 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']
|
||||
? '/login/invited_onboarding.php?token=' . $result['token']
|
||||
: '/login/accept_invite.php?token=' . $result['token']);
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
* 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 the requested company_id
|
||||
* 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.
|
||||
*/
|
||||
session_start();
|
||||
require_once '../../../assets/utils/db_auth.php';
|
||||
@@ -20,7 +24,7 @@ if ($action === 'read') {
|
||||
}
|
||||
|
||||
if ($action === 'update') {
|
||||
$target_company_id = (int)($data['company_id'] ?? 0);
|
||||
$target_company_id = (int)($data['target_company_id'] ?? 0);
|
||||
|
||||
if (!$target_company_id) {
|
||||
$answer['message'] = 'Invalid company.';
|
||||
|
||||
@@ -121,7 +121,8 @@
|
||||
|
||||
<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">
|
||||
<input type="text" class="form-control" id="channel_name" placeholder="e.g. my-shop"
|
||||
oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9_-]/g,'')">
|
||||
<div class="form-text">Unique identifier. Lowercase, no spaces.</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
session_start();
|
||||
require '../config.php';
|
||||
require_once '../assets/utils/app_registry.php';
|
||||
require '../include_header.php';
|
||||
?>
|
||||
|
||||
@@ -256,9 +257,7 @@
|
||||
const license_badge = license_html(u.license);
|
||||
const access_badge = app_access_html(u.app_access);
|
||||
|
||||
const joined = u.created_at
|
||||
? new Date(u.created_at).toLocaleDateString('en-GB', {day:'2-digit', month:'short', year:'numeric'})
|
||||
: '—';
|
||||
const joined = u.created_at ? format_date(String(u.created_at).split(' ')[0]) : '—';
|
||||
|
||||
const is_pending = u.status === 'pending' && u.is_pending_invite == 1;
|
||||
|
||||
|
||||
+4
-2
@@ -532,9 +532,11 @@ 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,
|
||||
$stockMgmt, $whMgmt, $company_id, $main_wh_id, $sku, $qty, $cost, $customer_id, $supplier_id,
|
||||
$logging, $dispatch_in_marker, $dispatch_out_marker
|
||||
) {
|
||||
$bin = findFreeBin($pdo2, $company_id, $main_wh_id);
|
||||
@@ -549,7 +551,7 @@ foreach ($stockout_defs as $so) {
|
||||
'zone' => $bin,
|
||||
'aisle' => $bin,
|
||||
'bin' => $bin,
|
||||
'contact_id' => $customer_id,
|
||||
'contact_id' => $supplier_id,
|
||||
'description' => $dispatch_in_marker,
|
||||
], $logging, $in_uuid);
|
||||
$stockMgmt->approveStock($stock_id, $main_wh_id, 'in', $whMgmt);
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
<?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' => $sku,
|
||||
'product_name' => $p['product_name'],
|
||||
'quantity' => $qty,
|
||||
'unit_price' => $unit_price,
|
||||
'total_price' => $total_price,
|
||||
@@ -69,11 +69,16 @@ $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, price FROM md_product WHERE company_id = :c");
|
||||
$sth = $pdo2->prepare("SELECT sku, product_name, price FROM md_product WHERE company_id = :c");
|
||||
$sth->execute([':c' => $company_id]);
|
||||
$products = $sth->fetchAll(PDO::FETCH_ASSOC | PDO::FETCH_UNIQUE);
|
||||
|
||||
$sth = $pdo2->prepare("SELECT id, contact_name FROM md_contact WHERE company_id = :c AND contact_type = 1 ORDER BY id");
|
||||
// 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->execute([':c' => $company_id]);
|
||||
$customers = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
$customer_ids = array_column($customers, 'id');
|
||||
|
||||
+46
-173
@@ -5,18 +5,16 @@
|
||||
*
|
||||
* 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:
|
||||
* app's features (restock / dispatch / transfer movements are seeded by demo_seed.php):
|
||||
*
|
||||
* 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
|
||||
* 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
|
||||
*
|
||||
* Every write goes through the same Manager classes + engine-file patterns
|
||||
* the app itself uses (dbTransaction wrapping, GlManager posting exactly as
|
||||
@@ -46,6 +44,7 @@ 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';
|
||||
@@ -89,24 +88,29 @@ $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
|
||||
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
|
||||
$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");
|
||||
|
||||
$sth = $pdo2->prepare("SELECT id, contact_name, contact_type FROM md_contact WHERE company_id = :c ORDER BY id");
|
||||
// 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->execute([':c' => $company_id]);
|
||||
$contacts = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
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);
|
||||
$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");
|
||||
|
||||
$sth = $pdo2->prepare("SELECT sku, uom, cost_price, price FROM md_product WHERE company_id = :c ORDER BY id");
|
||||
$sth = $pdo2->prepare("SELECT sku, product_name, 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");
|
||||
@@ -114,156 +118,6 @@ 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
|
||||
@@ -379,6 +233,25 @@ 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";
|
||||
@@ -409,7 +282,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' => $sku, // demo_seed.php products keyed by SKU; name not needed for GL/report correctness
|
||||
'product_name' => $p['product_name'], // documents show the product name, as the UI's product search fills it
|
||||
'quantity' => $qty,
|
||||
'unit_price' => $unit_price,
|
||||
'total_price' => $total_price,
|
||||
|
||||
@@ -22,6 +22,7 @@ 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,6 +55,7 @@ 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"
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# Serves the app at the vhost root instead of under /wms-app/.
|
||||
# Mounted over the image's default site by docker-compose.override.yml.
|
||||
<VirtualHost *:80>
|
||||
DocumentRoot /var/www/html/wms-app
|
||||
|
||||
<Directory /var/www/html/wms-app>
|
||||
Options -Indexes +FollowSymLinks
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
# Never serve deploy artefacts or seeders over HTTP.
|
||||
<FilesMatch "^(\.env|docker-compose(\.override)?\.yml|.*\.log|setup\.php|demo_seed.*\.php|finish-deploy\.sh)$">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
|
||||
# nginx terminates TLS upstream; let PHP know the real scheme so the app
|
||||
# does not emit http:// URLs into an https page.
|
||||
SetEnvIf X-Forwarded-Proto "^https$" HTTPS=on
|
||||
|
||||
ErrorLog ${APACHE_LOG_DIR}/error.log
|
||||
CustomLog ${APACHE_LOG_DIR}/access.log combined
|
||||
</VirtualHost>
|
||||
@@ -33,6 +33,23 @@ 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,15 +4,39 @@ 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}' \
|
||||
envsubst '${DB_ROOT_PASSWORD} ${PUBLIC_HOST} ${EMIT_SECRET} ${SMTP_USERNAME} ${SMTP_PASSWORD} ${OTP_REQUIRED}' \
|
||||
< /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"
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
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.
|
||||
@@ -1,58 +0,0 @@
|
||||
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.
|
||||
@@ -1,255 +0,0 @@
|
||||
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
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
if ($argc !== 2) {
|
||||
fwrite(STDERR, "Usage: php adjust_docx_whitespace.php <document.docx>\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$path = $argv[1];
|
||||
$wordNs = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($path) !== true) {
|
||||
throw new RuntimeException("Cannot open DOCX: {$path}");
|
||||
}
|
||||
|
||||
$documentXml = $zip->getFromName('word/document.xml');
|
||||
if ($documentXml === false) {
|
||||
throw new RuntimeException('word/document.xml is missing');
|
||||
}
|
||||
|
||||
$doc = new DOMDocument('1.0', 'UTF-8');
|
||||
$doc->preserveWhiteSpace = false;
|
||||
$doc->formatOutput = false;
|
||||
if (!$doc->loadXML($documentXml)) {
|
||||
throw new RuntimeException('word/document.xml is invalid');
|
||||
}
|
||||
|
||||
$xpath = new DOMXPath($doc);
|
||||
$xpath->registerNamespace('w', $wordNs);
|
||||
|
||||
function attr(DOMElement $element, string $name, string $value, string $ns): void
|
||||
{
|
||||
$element->setAttributeNS($ns, 'w:' . $name, $value);
|
||||
}
|
||||
|
||||
function child(DOMDocument $doc, DOMElement $parent, string $localName, string $ns): DOMElement
|
||||
{
|
||||
foreach ($parent->childNodes as $node) {
|
||||
if ($node instanceof DOMElement && $node->namespaceURI === $ns && $node->localName === $localName) {
|
||||
return $node;
|
||||
}
|
||||
}
|
||||
$element = $doc->createElementNS($ns, 'w:' . $localName);
|
||||
$parent->appendChild($element);
|
||||
return $element;
|
||||
}
|
||||
|
||||
foreach ($xpath->query('//w:body/w:p | //w:body/w:tbl//w:p') as $paragraph) {
|
||||
if (!$paragraph instanceof DOMElement) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$text = '';
|
||||
foreach ($xpath->query('.//w:t', $paragraph) as $textNode) {
|
||||
$text .= $textNode->textContent;
|
||||
}
|
||||
$text = trim(preg_replace('/\s+/u', ' ', $text) ?? '');
|
||||
|
||||
$pPrNode = $xpath->query('./w:pPr', $paragraph)->item(0);
|
||||
if (!$pPrNode instanceof DOMElement) {
|
||||
$pPrNode = $doc->createElementNS($wordNs, 'w:pPr');
|
||||
$paragraph->insertBefore($pPrNode, $paragraph->firstChild);
|
||||
}
|
||||
$spacing = child($doc, $pPrNode, 'spacing', $wordNs);
|
||||
|
||||
$before = '0';
|
||||
$after = '30';
|
||||
$line = '320';
|
||||
|
||||
if ($text === '') {
|
||||
$after = '20';
|
||||
$line = '120';
|
||||
} elseif (preg_match('/^[1-7]\.\s/u', $text)) {
|
||||
$before = '120';
|
||||
$after = '45';
|
||||
$line = '330';
|
||||
} elseif (preg_match('/^[1-7]\.\d\s/u', $text)) {
|
||||
$before = '70';
|
||||
$after = '30';
|
||||
$line = '320';
|
||||
} elseif (str_starts_with($text, '•')) {
|
||||
$after = '15';
|
||||
$line = '310';
|
||||
} elseif (str_starts_with($text, 'วันที่ 5 มกราคม')) {
|
||||
$before = '40';
|
||||
$after = '100';
|
||||
$line = '330';
|
||||
} elseif (str_starts_with($text, 'เรื่อง') || str_starts_with($text, 'เรียน')) {
|
||||
$after = '55';
|
||||
$line = '330';
|
||||
} elseif (str_starts_with($text, 'ลงชื่อ')) {
|
||||
$before = '160';
|
||||
$after = '10';
|
||||
$line = '310';
|
||||
} elseif (str_starts_with($text, '(') || str_starts_with($text, 'ตำแหน่ง') || str_starts_with($text, 'วันที่ _') || str_starts_with($text, 'ผู้มีอำนาจ')) {
|
||||
$after = '10';
|
||||
$line = '310';
|
||||
} elseif (str_starts_with($text, 'บริษัท บี.อาร์.เอ็น.') || str_starts_with($text, 'เอกสารฉบับนี้') || str_starts_with($text, 'ผลส่งมอบประกอบ') || str_starts_with($text, 'การเปลี่ยนแปลง') || str_starts_with($text, 'จึงจัดทำ')) {
|
||||
$after = '55';
|
||||
$line = '330';
|
||||
}
|
||||
|
||||
attr($spacing, 'before', $before, $wordNs);
|
||||
attr($spacing, 'after', $after, $wordNs);
|
||||
attr($spacing, 'line', $line, $wordNs);
|
||||
attr($spacing, 'lineRule', 'auto', $wordNs);
|
||||
}
|
||||
|
||||
foreach ($xpath->query('//w:sectPr/w:pgMar') as $margin) {
|
||||
if (!$margin instanceof DOMElement) {
|
||||
continue;
|
||||
}
|
||||
attr($margin, 'top', '850', $wordNs);
|
||||
attr($margin, 'bottom', '850', $wordNs);
|
||||
attr($margin, 'left', '1080', $wordNs);
|
||||
attr($margin, 'right', '1080', $wordNs);
|
||||
attr($margin, 'header', '400', $wordNs);
|
||||
attr($margin, 'footer', '400', $wordNs);
|
||||
}
|
||||
|
||||
foreach ($xpath->query('//w:tbl/w:tblPr') as $tableProperties) {
|
||||
if (!$tableProperties instanceof DOMElement) {
|
||||
continue;
|
||||
}
|
||||
$margins = child($doc, $tableProperties, 'tblCellMar', $wordNs);
|
||||
foreach (['top' => '45', 'left' => '70', 'bottom' => '45', 'right' => '70'] as $side => $width) {
|
||||
$edge = child($doc, $margins, $side, $wordNs);
|
||||
attr($edge, 'w', $width, $wordNs);
|
||||
attr($edge, 'type', 'dxa', $wordNs);
|
||||
}
|
||||
}
|
||||
|
||||
$updatedXml = $doc->saveXML();
|
||||
if ($updatedXml === false) {
|
||||
throw new RuntimeException('Cannot serialize adjusted document XML');
|
||||
}
|
||||
if (!$zip->addFromString('word/document.xml', $updatedXml)) {
|
||||
throw new RuntimeException('Cannot update word/document.xml');
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
echo $path . PHP_EOL;
|
||||
@@ -14,16 +14,6 @@ if [[ ! -d "$TOOL_DIR/node_modules/playwright" ]]; then
|
||||
npx --prefix "$TOOL_DIR" playwright install chromium
|
||||
fi
|
||||
|
||||
# The Software Design figures are drawn in scripts/sdlc-seed/figures.mjs and
|
||||
# rasterised with the document font before the seed copies them into sdlc/.
|
||||
echo "Rendering Software Design figures..."
|
||||
node "$TOOL_DIR/render-figures.mjs"
|
||||
|
||||
# sdlc/ is generated output: the seed rebuilds every work product from the
|
||||
# controlled project data in scripts/sdlc-seed/ before anything is rendered.
|
||||
echo "Seeding work products from project data..."
|
||||
node "$SCRIPT_DIR/sdlc-seed/build.mjs"
|
||||
|
||||
rm -rf "$STAGING_DIR"
|
||||
mkdir -p "$STAGING_DIR"
|
||||
|
||||
@@ -32,35 +22,7 @@ while IFS= read -r -d '' source; do
|
||||
destination="$STAGING_DIR/${relative%.md}.pdf"
|
||||
echo "Rendering ${relative%.md}.pdf"
|
||||
node "$TOOL_DIR/render-sdlc.mjs" "$source" "$destination"
|
||||
done < <(find "$SOURCE_DIR" -mindepth 2 -type f -name '*.md' -print0 | sort -z)
|
||||
|
||||
# Every .pdf gets a Word copy beside it, reconstructed from the rendered page.
|
||||
# The CI header bands need Playwright, so they are rendered here with node and
|
||||
# handed to the converter, which then only needs Python with pdf2docx: the local
|
||||
# interpreter ($PYTHON) when it has it, otherwise a throwaway python container.
|
||||
BAND_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$BAND_DIR"' EXIT
|
||||
echo "Rendering CI header bands..."
|
||||
node "$TOOL_DIR/render-ci-band.mjs" "$SOURCE_DIR" "$BAND_DIR"
|
||||
|
||||
echo "Converting PDFs to Word..."
|
||||
PYTHON="${PYTHON:-python3}"
|
||||
if "$PYTHON" -c 'import pdf2docx, docx' 2>/dev/null; then
|
||||
"$PYTHON" "$TOOL_DIR/convert-to-docx.py" "$SOURCE_DIR" "$STAGING_DIR" "$BAND_DIR"
|
||||
elif command -v docker >/dev/null 2>&1; then
|
||||
PY_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/sdlc-delivery-python"
|
||||
mkdir -p "$PY_CACHE"
|
||||
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -e PYTHONPATH=/py \
|
||||
-v "$ROOT_DIR:$ROOT_DIR" -v "$BAND_DIR:$BAND_DIR:ro" -v "$PY_CACHE:/py" \
|
||||
-w "$ROOT_DIR" python:3.12-slim sh -c '
|
||||
python -c "import pdf2docx, docx" 2>/dev/null ||
|
||||
pip install --quiet --disable-pip-version-check --target /py -r "$1"
|
||||
shift; python "$@"' _ \
|
||||
"$TOOL_DIR/requirements.txt" "$TOOL_DIR/convert-to-docx.py" "$SOURCE_DIR" "$STAGING_DIR" "$BAND_DIR"
|
||||
else
|
||||
echo "pdf2docx is not available: pip install -r $TOOL_DIR/requirements.txt, or install docker." >&2
|
||||
exit 1
|
||||
fi
|
||||
done < <(find "$SOURCE_DIR" -type f -name '*.md' -print0 | sort -z)
|
||||
|
||||
echo "Verifying staged delivery package..."
|
||||
"$SCRIPT_DIR/verify-sdlc-delivery.sh" "$STAGING_DIR"
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Applies the approved print layout from the first formatted Progress Status
|
||||
* Record to every generated Progress Status Record HTML file. Markdown source
|
||||
* files are deliberately not read or changed.
|
||||
*
|
||||
* Usage:
|
||||
* php scripts/format_progress_status_html.php
|
||||
*/
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$directory = $root . '/sdlc/1-PM Process (10 Work Product)/3.Progess Status Record';
|
||||
$templatePath = $directory . '/200-WMS-26-001-00 Progress Status Record 25690123 V1.0 ApS.html';
|
||||
|
||||
if (!is_file($templatePath)) {
|
||||
fwrite(STDERR, "Template Progress Status Record HTML was not found.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$template = file_get_contents($templatePath);
|
||||
if ($template === false || !preg_match('~(/\* BRN Progress Status Record print layout.*?</style>)~s', $template, $match)) {
|
||||
fwrite(STDERR, "The template does not contain the approved Progress Status Record CSS.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// The CSS must work for every record number, not just record 1.
|
||||
$layoutCss = str_replace(
|
||||
'#progress-status-record-1-of-13',
|
||||
'h1[id^="progress-status-record-"]',
|
||||
$match[1]
|
||||
);
|
||||
|
||||
/** @return string */
|
||||
function approvalCard(string $id, string $title, string $paragraph): string
|
||||
{
|
||||
$lines = preg_split('~<br\s*/?>\s*~i', trim($paragraph)) ?: [];
|
||||
$items = [];
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('~^(Signature|Date):\s*_*\s*$~i', strip_tags($line), $label)) {
|
||||
$items[] = '<p class="sign-row"><strong>' . htmlspecialchars($label[1], ENT_QUOTES, 'UTF-8') . ':</strong><span class="write-line"></span></p>';
|
||||
} else {
|
||||
$items[] = '<p>' . $line . '</p>';
|
||||
}
|
||||
}
|
||||
return '<div class="approval-card"><h3 id="' . $id . '">' . $title . '</h3>' . implode("\n", $items) . '</div>';
|
||||
}
|
||||
|
||||
$files = glob($directory . '/*.html') ?: [];
|
||||
$formatted = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($files as $path) {
|
||||
$html = file_get_contents($path);
|
||||
if ($html === false) {
|
||||
fwrite(STDERR, "Could not read: $path\n");
|
||||
continue;
|
||||
}
|
||||
if (str_contains($html, '/* BRN Progress Status Record print layout')) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$html = preg_replace('~</head>~', "<style>\n$layoutCss\n</style>\n</head>", $html, 1, $headCount);
|
||||
if ($headCount !== 1) {
|
||||
fwrite(STDERR, "Skipped malformed HTML: " . basename($path) . "\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
$header = '<header class="psr-letterhead"><img class="document-header-image" src="../../../app/assets/images/brn-document-header-left.png" alt="BRN corporate identity"><div class="psr-title-band">Progress Status Record</div></header>';
|
||||
$html = preg_replace(
|
||||
'~(<h1 id="progress-status-record-[^"]+">.*?</h1>\s*)<table>~s',
|
||||
$header . "\n" . '$1<table class="document-control">',
|
||||
$html,
|
||||
1,
|
||||
$titleCount
|
||||
);
|
||||
if ($titleCount !== 1) {
|
||||
fwrite(STDERR, "Skipped unsupported record structure: " . basename($path) . "\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('~<h2 id="8-approval">8\. Approval</h2>(.*?)(?=</body>)~s', $html, $approvalMatch)) {
|
||||
preg_match_all('~<h3 id="([^"]+)">(.*?)</h3>\s*<p>(.*?)</p>~s', $approvalMatch[1], $blocks, PREG_SET_ORDER);
|
||||
if (count($blocks) === 3) {
|
||||
$cards = [];
|
||||
foreach ($blocks as $block) {
|
||||
$cards[] = approvalCard($block[1], $block[2], $block[3]);
|
||||
}
|
||||
$approval = '<section class="approval-page"><h2 id="8-approval">8. Approval</h2><div class="approval-grid">'
|
||||
. implode("\n", $cards)
|
||||
. '</div></section>';
|
||||
$html = str_replace($approvalMatch[0], $approval, $html);
|
||||
}
|
||||
}
|
||||
|
||||
$date = 'Progress Status Record';
|
||||
if (preg_match('~<td>Report date</td>\s*<td>(.*?)</td>~s', $html, $dateMatch)) {
|
||||
$date = trim(strip_tags($dateMatch[1]));
|
||||
}
|
||||
$footer = '<footer class="psr-footer">200-WMS-26-001-00 · BRN WMS · Progress Status Record · '
|
||||
. htmlspecialchars($date, ENT_QUOTES, 'UTF-8') . ' · V1.0 Final</footer>';
|
||||
$html = str_replace('</body>', $footer . "\n</body>", $html);
|
||||
|
||||
if (file_put_contents($path, $html) === false) {
|
||||
fwrite(STDERR, "Could not write: $path\n");
|
||||
continue;
|
||||
}
|
||||
$formatted++;
|
||||
}
|
||||
|
||||
printf("Formatted: %d; already formatted: %d\n", $formatted, $skipped);
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$directory = __DIR__ . '/../sdlc/1-PM Process (10 Work Product)/3.Progess Status Record';
|
||||
|
||||
$records = [
|
||||
['25690123','5–23 January 2026','Project initiation','Establish the project need, stakeholders, objectives, scope, and authority.','Formal project start and scope boundary were established retrospectively from the agreed lifecycle.','Agreed reconstructed','Statement of Work; Work Schedule','Not rated — reconstructed','Historical initiation records were not created contemporaneously.','Complete customer requirements and project planning baseline.'],
|
||||
['25690218','12 January–18 February 2026','Requirements and planning baseline','Collect customer requirements and define schedule, resources, risks, controls, and work-product responsibilities.','Customer Requirements, Work Schedule, and Software Project Plan were reconstructed from agreed scope and implementation evidence.','Document reconstructed','Project Plan work products','Not rated — reconstructed','Exact elicitation dates and contemporaneous approvals are unavailable.','Begin controlled software implementation on 19 February 2026.'],
|
||||
['25690225','19–25 February 2026','Application initialization','Initialize the repository and establish initial application and file-upload capability.','WMS repository initialized; verification commit and dropzone/file-upload work completed.','Git evidenced','`9a50080`, `42a3876`, `8625652`, `1843308`','Green','No unresolved blocker is evidenced in the repository for this period.','Protect configuration, address security findings, and establish the stock database foundation.'],
|
||||
['25690317','9–17 March 2026','Security, database, and ICS foundation','Protect local configuration, address initial security findings, design the stock database, and introduce inventory-control modules.','Configuration was removed from tracking, security-audit fixes were applied, stock database design was committed, and ICS modules were introduced.','Git evidenced','`93d903c`, `7cb78d0`, `2e558a5`, `a4f474b`','Green','The period contains a gap in commit activity before the documented security/database work.','Develop stock visibility, warehouse structure, product controls, and operational reports.'],
|
||||
['25690429','9–29 April 2026','Inventory, warehouse, and access foundation','Implement stock dashboard, products, warehouse capacity, traceability, reports, settings, authentication, and reusable managers.','Dashboard, product files, OOP utilities, capacity/occupancy, lot/serial/expiry, reports, settings, login/onboarding, and manager classes were implemented.','Git evidenced','`3b8f94f` through `db5c47b`','Green','Rapid module growth increased the need for consistent authorization and lifecycle review.','Complete document approval logic, orders, warehouse layers, barcode, and role guards.'],
|
||||
['25690508','30 April–8 May 2026','Orders, barcode, and security controls','Implement approval logic, customer order flows, flexible warehouse layers, barcode operations, and centralized role guards.','Password scoring, stock approval, orders/returns/invoices, switchable warehouse layers, barcode functions, role guards, security fixes, and naming/session corrections were completed.','Git evidenced','30-Apr through 08-May commits','Green','Security gaps were actively corrected during implementation; formal correction records remain to be linked.','Prepare production setup and introduce accounting capabilities.'],
|
||||
['25690513','11–13 May 2026','Production preparation and accounting foundation','Prepare deployment, automate setup, correct onboarding/recovery, populate test data, and introduce accounting.','Production preparation, dynamic base URL, automated setup, password recovery, onboarding fixes, test data, dashboard updates, and accounting modules were committed.','Git evidenced','11–13 May commits','Green','Multiple fixes and a revert occurred during integration; results require traceability to tests.','Integrate accounting workflows, document control, access limits, and supporting services.'],
|
||||
['25690523','20–23 May 2026','Accounting integration and supporting services','Integrate accounting, user invitation/access controls, transaction limits, document flows, Node.js, aggregates, and reports.','Accounting workflows, access controls, setup script, soft delete, Socket service, GL aggregation, accounting reports, journal batching, and GL automation were implemented.','Git evidenced','20–23 May commits','Green','Integration breadth increased regression and tenant-isolation risk.','Complete security hardening, notification control, sequencing, scheduler, tenant scoping, and final review.'],
|
||||
['25690529','24–29 May 2026','Development baseline','Harden security and integrity, close known implementation gaps, stabilize services, and establish the substantially complete development baseline.','Session controls, aggregates, notifications, security/lifecycle reviews, transaction limits, document sequencing, role guards, scheduler fixes, bin naming, tenant scoping, and final refactoring were completed.','Git evidenced','24–29 May commits; final `a0677d6`','Green','Formal verification, validation, and acceptance evidence remained to be consolidated after development.','Conduct verification, validation, documentation, and delivery preparation.'],
|
||||
['25690731','30 May–31 July 2026','Verification, validation, and documentation','Verify requirements/design/software, validate intended use, prepare operational documents, and consolidate delivery evidence.','This activity period is part of the agreed timeline, but contemporaneous Git evidence is unavailable; assurance and documentation records are being reconstructed from the delivered system.','Document reconstructed','SRS, Design, Traceability, Test, Guide, Verification, and Validation work products','Amber','Missing contemporaneous evidence creates an audit and acceptance gap.','Complete stabilization corrections, finalize evidence, and obtain stakeholder review.'],
|
||||
['25690803','3 August 2026','Stabilization correction','Correct login and environment-configuration issues identified after the development baseline.','Login and configuration corrections were committed.','Git evidenced','`b2c4374`','Green','The correction requires linkage to the Correction Register and verification evidence.','Prepare representative demonstration data and complete final delivery checks.'],
|
||||
['25690814','4–14 August 2026','Demonstration and completion boundary','Prepare controlled demonstration data, complete final checks, and reach the agreed project-completion boundary.','Demonstration data was committed on 14 August 2026 and the agreed formal completion date was reached.','Git evidenced / agreed boundary','`dd48a8b`; agreed completion date','Green','Formal signatures and some controlled ISO/IEC 29110 evidence remained open at completion.','Consolidate remaining work products and obtain Project Sponsor authorization.'],
|
||||
['25690817','15–17 August 2026','Evidence consolidation and authorization preparation','Complete missing controlled work products, align roles, and prepare records for review and authorization.','PM work products were converted to reviewable Markdown, project identities and roles were aligned, and missing evidence was identified for subsequent completion.','Document reconstructed','Current `sdlc/` repository and Git status','Amber','Verification, validation, repository-backup, and acceptance evidence and signatures remain open.','Complete remaining PM/SI work products and obtain Project Sponsor review and authorization.'],
|
||||
];
|
||||
|
||||
$tasksByDate = [
|
||||
'25690123' => [
|
||||
['1.1','Identify project need','09-Jan-2026','09-Jan-2026','100%','Statement of Work','Reconstructed completed'],
|
||||
['1.2','Identify stakeholders and objectives','16-Jan-2026','16-Jan-2026','100%','Project role confirmation','Reconstructed completed'],
|
||||
['1.3','Approve project scope','23-Jan-2026','Pending signature','90%','Statement of Work V1.0 Final','Approval pending'],
|
||||
],
|
||||
'25690218' => [
|
||||
['2.1','Collect customer requirements','06-Feb-2026','06-Feb-2026','100%','Customer Requirements V1.0 Final','Reconstructed completed'],
|
||||
['2.2','Prepare Software Project Plan','13-Feb-2026','13-Feb-2026','100%','Software Project Plan V1.0 Final','Reconstructed completed'],
|
||||
['2.3','Baseline requirements and schedule','18-Feb-2026','18-Feb-2026','100%','Work Schedule and Project Plan','Reconstructed completed; approval pending'],
|
||||
],
|
||||
'25690225' => [
|
||||
['3.1','Initialize WMS application','25-Feb-2026','25-Feb-2026','100%','Git `9a50080`–`1843308`','Completed'],
|
||||
],
|
||||
'25690317' => [
|
||||
['3.2','Security and database foundation','17-Mar-2026','17-Mar-2026','100%','Git `93d903c`–`a4f474b`','Completed'],
|
||||
],
|
||||
'25690429' => [
|
||||
['3.3','Inventory and warehouse modules','29-Apr-2026','29-Apr-2026','100%','Git `3b8f94f`–`db5c47b`','Completed'],
|
||||
],
|
||||
'25690508' => [
|
||||
['3.4','Authentication and onboarding','12-May-2026','In progress at 08-May','70%','Commits through 08-May-2026','In progress; carried forward'],
|
||||
['3.5','Order and barcode workflows','08-May-2026','08-May-2026','100%','02–08 May commits','Completed'],
|
||||
],
|
||||
'25690513' => [
|
||||
['3.4','Authentication and onboarding','12-May-2026','12-May-2026','100%','Login/onboarding and recovery commits','Completed'],
|
||||
['3.6','Production preparation and setup','13-May-2026','13-May-2026','100%','11–13 May commits','Completed'],
|
||||
['3.7','Accounting and finance workflows','23-May-2026','In progress at 13-May','20%','Accounting foundation commits','In progress; carried forward'],
|
||||
],
|
||||
'25690523' => [
|
||||
['3.7','Accounting and finance workflows','23-May-2026','23-May-2026','100%','13–23 May accounting commits','Completed'],
|
||||
['3.8','Real-time services and scheduled jobs','27-May-2026','In progress at 23-May','45%','Node.js and GL aggregate commits','In progress; carried forward'],
|
||||
['3.9','Security hardening and lifecycle review','28-May-2026','In progress at 23-May','35%','Access, flow, and role-guard commits','In progress; carried forward'],
|
||||
],
|
||||
'25690529' => [
|
||||
['3.8','Real-time services and scheduled jobs','27-May-2026','27-May-2026','100%','22–27 May Node.js/scheduler commits','Completed'],
|
||||
['3.9','Security hardening and lifecycle review','28-May-2026','28-May-2026','100%','21–28 May hardening/review commits','Completed'],
|
||||
['3.10','Refactor and development baseline','29-May-2026','29-May-2026','100%','Git `a0677d6`','Completed'],
|
||||
],
|
||||
'25690731' => [
|
||||
['4.3','Verify requirements, design, and implementation','31-Jul-2026','Evidence consolidation pending','80%','Reconstructed verification work products','In progress; evidence gap'],
|
||||
['4.4','Customer-oriented system validation','07-Aug-2026','In progress at 31-Jul','85%','Reconstructed validation activities','In progress; carried forward'],
|
||||
['4.5','Prepare operational documentation','07-Aug-2026','In progress at 31-Jul','85%','Configuration and draft work products','In progress; carried forward'],
|
||||
],
|
||||
'25690803' => [
|
||||
['4.6','Configuration and login corrections','03-Aug-2026','03-Aug-2026','100%','Git `b2c4374`','Completed; formal correction closure pending'],
|
||||
],
|
||||
'25690814' => [
|
||||
['4.4','Customer-oriented system validation','07-Aug-2026','Evidence consolidation pending','90%','Validation work products','In progress; approval pending'],
|
||||
['4.5','Prepare operational documentation','07-Aug-2026','Evidence consolidation pending','90%','Operational documentation work products','In progress; approval pending'],
|
||||
['4.7','Prepare demonstration data','14-Aug-2026','14-Aug-2026','100%','Git `dd48a8b`','Completed'],
|
||||
['5.1','Final repository and work-product review','14-Aug-2026','In progress','70%','Repository and SDLC gap review','In progress; carried forward'],
|
||||
['5.2','Acceptance and project closure','14-Aug-2026','Pending signature','75%','Completion boundary reached','Acceptance pending'],
|
||||
],
|
||||
'25690817' => [
|
||||
['5.1','Final repository and work-product review','14-Aug-2026','In progress at 17-Aug','80%','BRN WMS PM work products and gap list','In progress'],
|
||||
['5.2','Acceptance and project closure','14-Aug-2026','Pending evidence and signature','75%','Acceptance evidence not yet authorized','Acceptance pending'],
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($records as $index => $record) {
|
||||
[$date,$period,$milestone,$planned,$completed,$evidenceClass,$evidence,$rag,$issue,$next] = $record;
|
||||
$number = $index + 1;
|
||||
$taskRows = '';
|
||||
foreach ($tasksByDate[$date] as $task) {
|
||||
$taskRows .= '| ' . implode(' | ', $task) . " |\n";
|
||||
}
|
||||
|
||||
$content = <<<MD
|
||||
# Progress Status Record {$number} of 13
|
||||
|
||||
| Document field | Value |
|
||||
|---|---|
|
||||
| Document | Progress Status Record |
|
||||
| Project | BRN WMS |
|
||||
| Project code | 200-WMS-26-001-00 |
|
||||
| Reporting period | {$period} |
|
||||
| Report date | {$date} |
|
||||
| Actual preparation date | 17 August 2026 |
|
||||
| Release | {$date} V1.0 Final |
|
||||
| Prepared by | คุณอภิรัชต์ สุภัทรประทีป — Project Manager |
|
||||
| Technical evidence | ธนกร สถิตวิทยากุล — Developer / System Analyst |
|
||||
| Status | Final — ready for review and authorization |
|
||||
|
||||
## 1. Retrospective-record disclosure
|
||||
|
||||
This record was prepared retrospectively on 17 August 2026. It covers the historical reporting period shown above and does not claim to have been authored or committed on the historical report date. Statements are limited to the evidence classification and references identified below.
|
||||
|
||||
## 2. Period objective
|
||||
|
||||
**Milestone:** {$milestone}
|
||||
|
||||
**Planned outcome:** {$planned}
|
||||
|
||||
## 3. Task progress
|
||||
|
||||
**Period summary:** {$completed}
|
||||
|
||||
| Task ID | Task | Planned finish | Actual / evidence date | Completion | Evidence | Status |
|
||||
|---|---|---:|---:|---:|---|---|
|
||||
{$taskRows}
|
||||
|
||||
| Evidence field | Value |
|
||||
|---|---|
|
||||
| Evidence classification | {$evidenceClass} |
|
||||
| Evidence reference | {$evidence} |
|
||||
| Overall period status | {$rag} |
|
||||
|
||||
## 4. Schedule status
|
||||
|
||||
Work is assessed against the agreed project boundaries of 5 January–14 August 2026. No unsupported numeric variance is asserted for this period. Where the evidence is reconstructed, schedule status remains explicitly unrated rather than represented as contemporaneously measured.
|
||||
|
||||
## 5. Issues, risks, and corrective action
|
||||
|
||||
**Issue or risk:** {$issue}
|
||||
|
||||
**Control:** Preserve the stated evidence basis, link applicable defects to the Correction Register, update traceability and tests, and obtain the required review or authorization without backdating records or signatures.
|
||||
|
||||
## 6. Changes
|
||||
|
||||
Technical commits in this period are implementation evidence. They must be classified separately as in-scope implementation, defect correction, or an authorized baseline change before being entered in the Change Report.
|
||||
|
||||
## 7. Next-period plan
|
||||
|
||||
{$next}
|
||||
|
||||
## 8. Approval
|
||||
|
||||
### Prepared by
|
||||
|
||||
Name: คุณอภิรัชต์ สุภัทรประทีป
|
||||
Role: Project Manager
|
||||
Signature: ______________________________________________
|
||||
Date: ___________________________________________________
|
||||
|
||||
### Technical evidence provided by
|
||||
|
||||
Name: ธนกร สถิตวิทยากุล
|
||||
Role: Developer / System Analyst
|
||||
Signature: ______________________________________________
|
||||
Date: ___________________________________________________
|
||||
|
||||
### Reviewed and authorized by
|
||||
|
||||
Name: คุณเสรี วิริยะสกุลธรณ์
|
||||
Project roles: Project Sponsor / Customer Representative / Authorized Approver
|
||||
Position: กรรมการผู้จัดการ
|
||||
Company: บริษัท บี.อาร์.เอ็น. เอ็นเตอร์ไพรส์ จำกัด
|
||||
Signature: ______________________________________________
|
||||
Date: ___________________________________________________
|
||||
MD;
|
||||
|
||||
$filename = "200-WMS-26-001-00 Progress Status Record {$date} V1.0 ApS.md";
|
||||
if (file_put_contents($directory . '/' . $filename, $content . PHP_EOL) === false) {
|
||||
throw new RuntimeException('Unable to write ' . $filename);
|
||||
}
|
||||
}
|
||||
|
||||
require __DIR__ . '/normalize_document_dates.php';
|
||||
echo count($records) . " progress status records generated.\n";
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$base = __DIR__ . '/../sdlc/1-PM Process (10 Work Product)/2.Project Plan/';
|
||||
$w = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
||||
|
||||
function e(string $s): string { return htmlspecialchars($s, ENT_XML1 | ENT_QUOTES, 'UTF-8'); }
|
||||
function r(string $s, bool $b = false, int $sz = 28): string {
|
||||
return '<w:r><w:rPr>' . ($b ? '<w:b/><w:bCs/>' : '') . '<w:rFonts w:ascii="TH Sarabun New" w:hAnsi="TH Sarabun New" w:eastAsia="TH Sarabun New" w:cs="TH Sarabun New"/><w:sz w:val="'.$sz.'"/><w:szCs w:val="'.$sz.'"/></w:rPr><w:t xml:space="preserve">'.e($s).'</w:t></w:r>';
|
||||
}
|
||||
function p(string $s = '', string $a = 'left', bool $b = false, int $sz = 28, int $before = 0, int $after = 40): string {
|
||||
return '<w:p><w:pPr><w:jc w:val="'.$a.'"/><w:spacing w:before="'.$before.'" w:after="'.$after.'" w:line="320" w:lineRule="auto"/></w:pPr>'.r($s,$b,$sz).'</w:p>';
|
||||
}
|
||||
function h(string $no, string $title): string { return p($no.' '.$title,'left',true,31,120,45); }
|
||||
function b(string $s): string { return '<w:p><w:pPr><w:ind w:left="650" w:hanging="300"/><w:spacing w:after="20" w:line="310" w:lineRule="auto"/></w:pPr>'.r('• '.$s,false,28).'</w:p>'; }
|
||||
function pb(): string { return '<w:p><w:r><w:br w:type="page"/></w:r></w:p>'; }
|
||||
function tr(array $cells, bool $head = false, array $widths = []): string {
|
||||
$x='<w:tr>';
|
||||
foreach($cells as $i=>$v){$width=$widths[$i]??2200;$x.='<w:tc><w:tcPr><w:tcW w:w="'.$width.'" w:type="dxa"/>'.($head?'<w:shd w:fill="D9EAF7"/>':'').'</w:tcPr>'.p((string)$v,'left',$head,25,0,15).'</w:tc>';}
|
||||
return $x.'</w:tr>';
|
||||
}
|
||||
function tbl(array $rows, array $widths, bool $firstHead = true): string {
|
||||
$x='<w:tbl><w:tblPr><w:tblW w:w="9000" w:type="dxa"/><w:tblBorders><w:top w:val="single" w:sz="4" w:color="8AA4B5"/><w:left w:val="single" w:sz="4" w:color="8AA4B5"/><w:bottom w:val="single" w:sz="4" w:color="8AA4B5"/><w:right w:val="single" w:sz="4" w:color="8AA4B5"/><w:insideH w:val="single" w:sz="4" w:color="B7C9D6"/><w:insideV w:val="single" w:sz="4" w:color="B7C9D6"/></w:tblBorders></w:tblPr><w:tblGrid>';
|
||||
foreach($widths as $wd)$x.='<w:gridCol w:w="'.$wd.'"/>';$x.='</w:tblGrid>';
|
||||
foreach($rows as $i=>$row)$x.=tr($row,$firstHead&&$i===0,$widths);
|
||||
return $x.'</w:tbl>';
|
||||
}
|
||||
function top(string $label): string {
|
||||
return '<w:tbl><w:tblPr><w:tblW w:w="9000" w:type="dxa"/></w:tblPr><w:tblGrid><w:gridCol w:w="6000"/><w:gridCol w:w="3000"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:tcW w:w="6000" w:type="dxa"/></w:tcPr>'.p('B.R.N. ENTERPRISE CO., LTD.','left',true,29,0,0).p('1011 Supalai Grand Tower, 7th Floor, Unit 6-7, Rama 3 Road, Bangkok 10120','left',false,18,0,0).'</w:tc><w:tc><w:tcPr><w:tcW w:w="3000" w:type="dxa"/><w:shd w:fill="0798D1"/><w:vAlign w:val="center"/></w:tcPr>'.p($label,'center',false,29,0,0).'</w:tc></w:tr></w:tbl>';
|
||||
}
|
||||
function meta(string $doc, string $title): string {
|
||||
return tbl([
|
||||
['Document No',$doc,'Release, Version','05/01/26 V1.0 Final'],
|
||||
['Project Name','โครงการพัฒนาระบบบริหารจัดการคลังสินค้า BRN WMS','Project Code','200-WMS-26-001-00'],
|
||||
['Title',$title,'Project Period','5 มกราคม – 14 สิงหาคม 2569'],
|
||||
['Prepared by','คุณอภิรัชต์ สุภัทรประทีป (Project Manager)','Approval','Pending authorized signature'],
|
||||
],[1600,3500,1600,2300],false);
|
||||
}
|
||||
function saveDoc(string $path,string $title,string $body,string $code): void {
|
||||
$document='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body>'.$body.'<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="850" w:right="1080" w:bottom="850" w:left="1080" w:header="400" w:footer="400"/><w:footerReference w:type="default" r:id="rId1"/></w:sectPr></w:body></w:document>';
|
||||
$ct='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/></Types>';
|
||||
$rels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/></Relationships>';
|
||||
$drels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/></Relationships>';
|
||||
$footer='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:ftr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'.p('ISO/IEC 29110 | 200-WMS-26-001-00 | '.$code.' | V1.0 Final','center',false,18,0,0).'</w:ftr>';
|
||||
$core='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dc:title>'.e($title).'</dc:title><dc:creator>คุณอภิรัชต์ สุภัทรประทีป (Project Manager)</dc:creator><dc:description>Editable ISO/IEC 29110 controlled final document ready for authorization.</dc:description><dcterms:created xsi:type="dcterms:W3CDTF">2026-01-05T00:00:00+07:00</dcterms:created></cp:coreProperties>';
|
||||
$z=new ZipArchive();if($z->open($path,ZipArchive::CREATE|ZipArchive::OVERWRITE)!==true)throw new RuntimeException('Cannot create '.$path);
|
||||
foreach(['[Content_Types].xml'=>$ct,'_rels/.rels'=>$rels,'word/document.xml'=>$document,'word/_rels/document.xml.rels'=>$drels,'word/footer1.xml'=>$footer,'docProps/core.xml'=>$core] as $n=>$v)$z->addFromString($n,$v);$z->close();
|
||||
}
|
||||
|
||||
$plan = top('Software Project Plan').p().meta('Software Project Plan','แผนการดำเนินโครงการ').h('1.','วัตถุประสงค์และภาพรวม')
|
||||
.p('โครงการ BRN WMS จัดทำระบบเว็บแบบหลายบริษัทและหลายคลัง เพื่อควบคุมข้อมูลหลัก สินค้าคงคลัง เอกสารซื้อขาย การเงินและบัญชี รายงาน และการแจ้งเตือน โดยบริหารงานตามกระบวนการ Project Management และ Software Implementation ของ ISO/IEC 29110 Basic Profile.')
|
||||
.h('2.','ขอบเขตและผลส่งมอบ').b('ซอฟต์แวร์ BRN WMS และฐานข้อมูลสำหรับ Authentication/Company และ WMS/Accounting').b('โมดูลผู้ใช้ บริษัท ผู้ติดต่อ สินค้า คลัง/ตำแหน่งจัดเก็บ Stock In/Out/Transfer Lot Serial Expiry และ Barcode').b('โมดูล Quotation, Order, Return, Purchase, Invoice, Receipt, Payment, General Ledger และรายงาน').b('Node.js Socket.IO สำหรับแจ้งเตือน และ Scheduler สำหรับ Stock/GL maintenance และ alerts').b('Work Products ISO/IEC 29110 จำนวน 22 รายการและ Project Repository ที่ควบคุมเวอร์ชัน')
|
||||
.h('3.','แนวทางดำเนินโครงการ').tbl([['Phase','Period','Primary output'],['Initiation and planning','05-Jan–18-Feb-2026','SOW, Customer Requirements, Project Plan'],['Implementation','19-Feb–29-May-2026','Requirements, Design, Components, Tests, Software'],['Verification, validation and documentation','30-May–07-Aug-2026','Test/Verification/Validation results and guides'],['Stabilization and closure','03-Aug–14-Aug-2026','Corrections, demo data, acceptance and repository closure']],[2600,2500,3900])
|
||||
.h('4.','ทีมงานและความรับผิดชอบ').tbl([['Role','Responsibility'],['Project Sponsor / Customer Representative / Authorized Approver — คุณเสรี วิริยะสกุลธรณ์','Represent customer needs and authorize scope, resources, changes and acceptance.'],['Project Manager — คุณอภิรัชต์ สุภัทรประทีป','Plan, monitor, communicate, control risks/issues and close the project.'],['Developer / System Analyst — ธนกร สถิตวิทยากุล','Analyze, design, implement, configure, correct and maintain technical records aligned with Git evidence.'],['Tester / Reviewer','Prepare and execute tests; verify work products and record results.'],['Document Control','Control identifiers, versions, approvals, repository and evidence.']],[3200,5800])
|
||||
.h('5.','ทรัพยากรและสภาพแวดล้อม').b('PHP 8+, MySQL/MariaDB, Nginx or compatible web server, Node.js, npm and PM2').b('Git main branch as source baseline; configuration secrets excluded from version control').b('Two logical databases: identity/company data and WMS/accounting operational data').b('Development and deployment in Asia/Bangkok time zone with controlled configuration')
|
||||
.h('6.','การบริหารความเสี่ยง').tbl([['Risk','Response / Control'],['Reconstructed pre-development evidence','Mark inferred records clearly and obtain retrospective review/approval.'],['Unauthorized access or tenant-data exposure','Role guards, company/warehouse scoping, session controls and security review.'],['Inventory inconsistency','Transactions, validation, locking, approval flow, stock summaries and reconciliation tests.'],['Configuration or secret exposure','Ignored local configuration, templates, deployment guide and restricted web access.'],['Insufficient acceptance evidence','Trace requirements to tests and obtain authorized signatures before baselining.'],['Scope expansion','Record Change Report, assess impact and authorize before implementation.']],[3600,5400])
|
||||
.h('7.','คุณภาพ การทวนสอบ และการตรวจสอบความใช้ได้').b('Every approved requirement receives a unique ID and forward/backward traceability.').b('Reviews cover requirements, design, code/configuration, test records and operational documentation.').b('Critical failures prevent acceptance; other defects require disposition in the Correction Register.').b('Validation uses representative warehouse, order, finance and role-based workflows with controlled demo data.')
|
||||
.h('8.','การควบคุมการเปลี่ยนแปลงและการกำหนดค่า').b('Configuration items include source code, database/setup scripts, Node.js services, controlled documents and releases.').b('Changes are requested, analyzed, approved, implemented, tested and closed with linked evidence.').b('Document filenames use project code, work-product name, Buddhist date, version and status/author indicator.')
|
||||
.h('9.','การสื่อสารและการติดตาม').tbl([['Record','Frequency / trigger','Owner'],['Progress Status Record','Weekly during active work','Project Manager'],['Meeting Record','At project/review/decision meetings','Recorder'],['Correction Register','When an issue or defect is identified','Project Manager / Tester'],['Change Report','When baseline scope/configuration changes','Project Manager'],['Repository review','At baselines and closure','Document Control']],[3400,3300,2300])
|
||||
.h('10.','เกณฑ์การส่งมอบและปิดโครงการ').b('Scope functions pass their linked test cases and no unresolved critical defect remains.').b('Installation, user, operation and maintenance information is available and reviewed.').b('Software and all required work products are stored in the controlled repository.').b('Verification, validation and acceptance results are reviewed and authorized.').p('Approval signature: ____________________________________','center',false,28,180,20).p('Name/position: _______________________________________','center').p('Date: ________________________________________________','center');
|
||||
|
||||
$reqs = [
|
||||
['FR-001','Identity','Register/onboard company owner and invited users.','Must'],['FR-002','Identity','Authenticate users and apply role-based access for Owner, Admin, Staff and Viewer.','Must'],['FR-003','Identity','Support password recovery, session control and applicable OTP verification.','Must'],['FR-004','Administration','Maintain company profile, SMTP, system settings, users and application access.','Must'],
|
||||
['FR-005','Master data','Maintain warehouses, storage areas/bins, product categories, products and contacts.','Must'],['FR-006','Master data','Support simple and layered warehouse-location models.','Should'],['FR-007','Inventory','Record stock-in with product, quantity, warehouse/bin and traceability attributes.','Must'],['FR-008','Inventory','Record stock-out with authorization and balance validation.','Must'],['FR-009','Inventory','Transfer stock between controlled warehouse locations.','Must'],['FR-010','Inventory','Track lot, serial number and expiry-date information where applicable.','Must'],['FR-011','Inventory','Display stock overview, movement history, capacity/occupancy and low-stock information.','Must'],['FR-012','Inventory','Generate SKU and location barcode labels and support scanning workflows.','Should'],
|
||||
['FR-013','Sales','Create and manage quotations, sales orders, invoices, returns and credit notes.','Must'],['FR-014','Purchasing','Create and manage purchase requests, purchase orders, supplier invoices and supplier returns.','Must'],['FR-015','Finance','Create and manage receipt/payment billing, receipts and payments.','Must'],['FR-016','Accounting','Maintain chart of accounts, departments, formulas, journals and GL entries.','Must'],['FR-017','Accounting','Produce trial balance, profit/loss, balance sheet, VAT and GL movement reports.','Must'],['FR-018','Documents','Generate controlled document numbers and manage document lifecycle/status.','Must'],['FR-019','Documents','Attach permitted files to supported records.','Should'],['FR-020','Reports','Filter, view and export operational and management reports.','Must'],
|
||||
['FR-021','Notifications','Notify authorized users of relevant status transitions and operational alerts.','Should'],['FR-022','Scheduler','Maintain stock/GL summaries and produce low-stock/overdue-invoice alerts.','Should'],['FR-023','Audit','Preserve creator/updater/status and transaction history required for review.','Must'],['FR-024','Isolation','Restrict company and warehouse data to the authorized user context.','Must'],
|
||||
['NFR-001','Security','Protect secrets from source control and direct web access.','Must'],['NFR-002','Security','Validate input, authorize server-side actions and prevent cross-tenant access.','Must'],['NFR-003','Integrity','Execute related database updates transactionally and prevent invalid negative/duplicate movements.','Must'],['NFR-004','Availability','Provide documented installation, configuration, backup and recovery procedures.','Must'],['NFR-005','Usability','Provide responsive browser UI suitable for desktop and warehouse-floor devices.','Should'],['NFR-006','Performance','Provide practical response times for daily operations and use aggregate tables for dashboards/reports.','Should'],['NFR-007','Maintainability','Use modular managers/APIs, centralized helpers, configuration templates and version control.','Should'],['NFR-008','Compatibility','Run on PHP 8+, MySQL/MariaDB and current standards-based browsers.','Must'],['NFR-009','Traceability','Link each approved requirement to design, component and verification evidence.','Must'],['NFR-010','Time','Use Asia/Bangkok consistently for application and scheduled jobs.','Must'],
|
||||
];
|
||||
|
||||
$cr = top('Customer Requirements').p().meta('Customer Requirements','เอกสารบันทึกและสรุปความต้องการของลูกค้า').h('1.','วัตถุประสงค์').p('เอกสารฉบับนี้กำหนดความต้องการระดับลูกค้าและผู้ใช้ของ BRN WMS เพื่อเป็นฐานสำหรับ Software Requirements Specification, Software Design, Traceability Record, Test Cases และ Acceptance. ข้อมูลถูกรวบรวมย้อนหลังจากขอบเขตที่ตกลง ซอร์สโค้ด ฐานข้อมูล และประวัติ Git โดยธนกร สถิตวิทยากุล ในบทบาท Developer / System Analyst และต้องได้รับการทบทวนและอนุมัติโดยคุณเสรี วิริยะสกุลธรณ์ ในบทบาท Project Sponsor / Customer Representative / Authorized Approver ก่อน Baseline.')
|
||||
.h('2.','ผู้มีส่วนได้ส่วนเสีย').tbl([['Stakeholder','Interest / role'],['Authorized Sponsor','Approve scope, resources, changes and final acceptance.'],['Warehouse Manager / Staff','Accurate receiving, issuing, transfer, stock status, traceability and reports.'],['Sales and Purchasing Users','Controlled quotation/order/purchase/invoice/return workflows.'],['Finance and Accounting Users','Billing, receipts, payments, journals, GL and financial reports.'],['System Administrator','Company configuration, user access, deployment, monitoring and recovery.'],['Auditor / Management','Traceable transactions, controlled records and management information.']],[3400,5600])
|
||||
.h('3.','สมมติฐานและข้อจำกัด').b('The solution is a browser-based application deployed on a controlled PHP/MySQL environment.').b('External ERP integration, legacy-data migration, hardware procurement and custom third-party services are outside scope unless changed formally.').b('Customer representatives provide representative data, review requirements and participate in validation and acceptance.').b('Requirements marked Must are acceptance-critical; Should requirements may be accepted with documented disposition.')
|
||||
.h('4.','รายการความต้องการ');
|
||||
$reqRows=[['ID','Area','Requirement','Priority']];foreach($reqs as $q)$reqRows[]=$q;$cr.=tbl($reqRows,[1300,1700,5100,900]).h('5.','เกณฑ์การยอมรับความต้องการ').b('Each Must requirement is implemented, traced to one or more test cases and passes verification/validation.').b('No critical security, tenant-isolation, inventory-integrity or transaction defect remains open.').b('Any exception or deferred Should requirement is recorded and authorized.').b('Customer review confirms that representative end-to-end workflows support intended warehouse operations.').h('6.','การอนุมัติ Baseline').p('Customer representative signature: ______________________________','center',false,28,180,20).p('Name/position: ______________________________________________','center').p('Date: ________________________________________________________','center');
|
||||
|
||||
saveDoc($base.'2-Software Project Plan/200-WMS-26-001-00 Software Project Plan 25690105 V1.0 Final.docx','200-WMS-26-001-00 Software Project Plan',$plan,'PP');
|
||||
saveDoc($base.'3-Customer Requirement/200-WMS-26-001-00 Customer Requirements 25690112 V1.0 Final.docx','200-WMS-26-001-00 Customer Requirements',$cr,'CR');
|
||||
echo "Project Plan documents generated.\n";
|
||||
require __DIR__ . '/normalize_document_dates.php';
|
||||
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Generate the ISO/IEC 29110 Statement of Work for BRN WMS.
|
||||
*
|
||||
* The document is generated as native OOXML so it remains editable in Word.
|
||||
*/
|
||||
|
||||
$output = __DIR__ . '/../sdlc/1-PM Process (10 Work Product)/1.Statement of Work/'
|
||||
. '200-WMS-26-001-00 Statement of Work 25690105 V1.0 Final.docx';
|
||||
|
||||
function xml(string $value): string
|
||||
{
|
||||
return htmlspecialchars($value, ENT_XML1 | ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
function run(string $text, bool $bold = false, int $size = 30): string
|
||||
{
|
||||
$boldXml = $bold ? '<w:b/><w:bCs/>' : '';
|
||||
return '<w:r><w:rPr>' . $boldXml
|
||||
. '<w:rFonts w:ascii="TH Sarabun New" w:hAnsi="TH Sarabun New" w:eastAsia="TH Sarabun New" w:cs="TH Sarabun New"/>'
|
||||
. '<w:sz w:val="' . $size . '"/><w:szCs w:val="' . $size . '"/>'
|
||||
. '<w:lang w:val="th-TH" w:eastAsia="th-TH" w:bidi="th-TH"/>'
|
||||
. '</w:rPr><w:t xml:space="preserve">' . xml($text) . '</w:t></w:r>';
|
||||
}
|
||||
|
||||
function para(string $text = '', string $align = 'left', bool $bold = false, int $size = 30, int $before = 0, int $after = 80, int $indent = 0): string
|
||||
{
|
||||
return '<w:p><w:pPr><w:jc w:val="' . $align . '"/>'
|
||||
. '<w:spacing w:before="' . $before . '" w:after="' . $after . '" w:line="360" w:lineRule="auto"/>'
|
||||
. ($indent > 0 ? '<w:ind w:firstLine="' . $indent . '"/>' : '')
|
||||
. '</w:pPr>' . run($text, $bold, $size) . '</w:p>';
|
||||
}
|
||||
|
||||
function bullet(string $text): string
|
||||
{
|
||||
return '<w:p><w:pPr><w:ind w:left="720" w:hanging="360"/><w:spacing w:after="50" w:line="340" w:lineRule="auto"/></w:pPr>'
|
||||
. run('• ' . $text, false, 29) . '</w:p>';
|
||||
}
|
||||
|
||||
function tableRow(string $label, string $value): string
|
||||
{
|
||||
return '<w:tr>'
|
||||
. '<w:tc><w:tcPr><w:tcW w:w="2200" w:type="dxa"/><w:shd w:fill="D9EAF7"/></w:tcPr>'
|
||||
. para($label, 'left', true, 27, 0, 20) . '</w:tc>'
|
||||
. '<w:tc><w:tcPr><w:tcW w:w="6800" w:type="dxa"/></w:tcPr>'
|
||||
. para($value, 'left', false, 27, 0, 20) . '</w:tc>'
|
||||
. '</w:tr>';
|
||||
}
|
||||
|
||||
$header = '<w:tbl><w:tblPr><w:tblW w:w="9000" w:type="dxa"/><w:tblLayout w:type="fixed"/></w:tblPr><w:tblGrid><w:gridCol w:w="6100"/><w:gridCol w:w="2900"/></w:tblGrid>'
|
||||
. '<w:tr><w:tc><w:tcPr><w:tcW w:w="6100" w:type="dxa"/></w:tcPr>'
|
||||
. para('B.R.N. ENTERPRISE CO., LTD.', 'left', true, 29, 0, 0)
|
||||
. para('1011 Supalai Grand Tower, 7th Floor, Unit 6-7, Rama 3 Road,', 'left', false, 19, 0, 0)
|
||||
. para('Chongnonsi, Yannawa, Bangkok 10120', 'left', false, 19, 0, 0)
|
||||
. '</w:tc><w:tc><w:tcPr><w:tcW w:w="2900" w:type="dxa"/><w:shd w:fill="0798D1"/><w:vAlign w:val="center"/></w:tcPr>'
|
||||
. para('Statement of Work', 'center', false, 29, 0, 0)
|
||||
. '</w:tc></w:tr></w:tbl>';
|
||||
|
||||
$meta = '<w:tbl><w:tblPr><w:tblW w:w="9000" w:type="dxa"/><w:tblBorders>'
|
||||
. '<w:top w:val="single" w:sz="4" w:color="9E9E9E"/><w:left w:val="single" w:sz="4" w:color="9E9E9E"/>'
|
||||
. '<w:bottom w:val="single" w:sz="4" w:color="9E9E9E"/><w:right w:val="single" w:sz="4" w:color="9E9E9E"/>'
|
||||
. '<w:insideH w:val="single" w:sz="4" w:color="9E9E9E"/><w:insideV w:val="single" w:sz="4" w:color="9E9E9E"/>'
|
||||
. '</w:tblBorders></w:tblPr><w:tblGrid><w:gridCol w:w="2200"/><w:gridCol w:w="6800"/></w:tblGrid>'
|
||||
. tableRow('รหัสโครงการ', '200-WMS-26-001-00')
|
||||
. tableRow('ชื่อโครงการ', 'โครงการพัฒนาระบบบริหารจัดการคลังสินค้า BRN WMS')
|
||||
. tableRow('ระยะเวลาโครงการ', '5 มกราคม 2569 – 14 สิงหาคม 2569')
|
||||
. tableRow('เอกสาร', 'Statement of Work · Version 1.0 · สถานะ Final')
|
||||
. '</w:tbl>';
|
||||
|
||||
$body = $header
|
||||
. para('', 'left', false, 10, 0, 80)
|
||||
. para('วันที่ 5 มกราคม 2569', 'center', false, 30, 80, 180)
|
||||
. para('เรื่อง ขอบเขตการดำเนินงานโครงการพัฒนาระบบบริหารจัดการคลังสินค้า BRN WMS', 'left', true, 30, 0, 100)
|
||||
. para('เรียน ผู้บริหารและผู้มีส่วนเกี่ยวข้องในโครงการ', 'left', true, 30, 0, 180)
|
||||
. para('บริษัท บี.อาร์.เอ็น. เอ็นเตอร์ไพรส์ จำกัด มีความประสงค์ดำเนินโครงการพัฒนาระบบบริหารจัดการคลังสินค้า BRN WMS เพื่อเพิ่มความถูกต้องและความรวดเร็วของงานคลังสินค้า ทำให้สามารถติดตามสินค้าคงคลัง การเคลื่อนไหวสินค้า คำสั่งซื้อ เอกสารทางธุรกิจ และข้อมูลบัญชีที่เกี่ยวข้องได้อย่างเป็นระบบ โดยใช้ข้อมูลแบบรวมศูนย์และกำหนดสิทธิ์การเข้าถึงตามบทบาทผู้ใช้งาน', 'both', false, 30, 0, 120, 720)
|
||||
. para('เอกสารฉบับนี้กำหนดขอบเขต ผลส่งมอบ เกณฑ์การยอมรับ หน้าที่ความรับผิดชอบ และกรอบระยะเวลาของโครงการตามแนวทาง ISO/IEC 29110 โดยมีรายละเอียดดังต่อไปนี้', 'both', false, 30, 0, 180, 720)
|
||||
. $meta
|
||||
. para('1. วัตถุประสงค์', 'left', true, 32, 180, 80)
|
||||
. bullet('พัฒนาระบบเว็บสำหรับควบคุมสินค้าคงคลังและการปฏิบัติงานคลังสินค้าแบบหลายคลัง')
|
||||
. bullet('สนับสนุนการรับเข้า เบิกจ่าย โอนย้าย ปรับปรุง และตรวจสอบยอดคงเหลือ พร้อมการติดตาม Lot, Serial Number และวันหมดอายุ')
|
||||
. bullet('ลดความผิดพลาดจากการทำงานด้วยมือและเพิ่มความสามารถในการตรวจสอบย้อนหลัง')
|
||||
. bullet('สนับสนุนการบริหารคำสั่งซื้อ จัดซื้อ คืนสินค้า ใบแจ้งหนี้ รายงาน และรายการบัญชีที่เกี่ยวข้อง')
|
||||
. bullet('จัดให้มีการรักษาความมั่นคงปลอดภัย การแยกข้อมูลรายบริษัท การกำหนดสิทธิ์ และการแจ้งเตือนแบบเรียลไทม์')
|
||||
. para('2. ขอบเขตงาน', 'left', true, 32, 150, 80)
|
||||
. para('2.1 งานวิเคราะห์และออกแบบ', 'left', true, 29, 0, 50)
|
||||
. bullet('รวบรวมและวิเคราะห์ความต้องการของผู้ใช้ กำหนดกระบวนการทำงาน ข้อมูล และกฎทางธุรกิจ')
|
||||
. bullet('ออกแบบสถาปัตยกรรมระบบ ฐานข้อมูล ส่วนติดต่อผู้ใช้ การเชื่อมต่อบริการ และมาตรการความมั่นคงปลอดภัย')
|
||||
. para('2.2 งานพัฒนาระบบ', 'left', true, 29, 80, 50)
|
||||
. bullet('ข้อมูลหลัก: บริษัท ผู้ใช้ ผู้ติดต่อ สินค้า คลังสินค้า โซน ทางเดิน ตำแหน่งจัดเก็บ และหน่วยนับ')
|
||||
. bullet('สินค้าคงคลัง: รับเข้า จ่ายออก โอนย้าย ปรับปรุงยอด ตรวจนับ ยอดคงเหลือ และประวัติการเคลื่อนไหว')
|
||||
. bullet('เอกสารธุรกิจ: Sales Order, Purchase Order, Return, Invoice และลำดับเลขที่เอกสาร')
|
||||
. bullet('การเงินและบัญชี: รายรับ รายจ่าย สมุดรายวัน รายการบัญชี และรายงานที่ระบบรองรับ')
|
||||
. bullet('Dashboard, รายงาน, การส่งออกข้อมูล, Barcode/Label และการแนบไฟล์')
|
||||
. bullet('การยืนยันตัวตน การกำหนดบทบาท Owner/Admin/Staff/Viewer การจำกัดข้อมูลตามบริษัท และการควบคุม session')
|
||||
. bullet('บริการแจ้งเตือนแบบเรียลไทม์และงานตามกำหนดเวลาด้วย Node.js/Socket.IO')
|
||||
. para('2.3 งานทดสอบและส่งมอบ', 'left', true, 29, 80, 50)
|
||||
. bullet('จัดทำและดำเนินการทดสอบตามความต้องการ บันทึกผล แก้ไขข้อบกพร่อง และทดสอบยืนยันผล')
|
||||
. bullet('จัดเตรียมคู่มือผู้ใช้ คู่มือการติดตั้ง/ปฏิบัติการ และเอกสารบำรุงรักษา')
|
||||
. bullet('จัดเตรียมหลักฐานการทวนสอบ การตรวจสอบความใช้ได้ และการยอมรับระบบ')
|
||||
. para('3. ผลส่งมอบ', 'left', true, 32, 150, 80)
|
||||
. para('ผลส่งมอบประกอบด้วยซอฟต์แวร์ BRN WMS ซอร์สโค้ด สคริปต์การติดตั้งและฐานข้อมูล การกำหนดค่า คู่มือ และ Work Products ของกระบวนการ Project Management และ Software Implementation ตาม ISO/IEC 29110 รวม 22 รายการ โดยจัดเก็บใน Project Repository ภายใต้การควบคุมเวอร์ชัน', 'both', false, 30, 0, 100, 720)
|
||||
. para('4. ข้อยกเว้นและข้อสมมติ', 'left', true, 32, 120, 80)
|
||||
. bullet('ไม่รวมการจัดหาเครื่องแม่ข่าย อุปกรณ์เครือข่าย เครื่องสแกน Barcode หรือบริการจากบุคคลภายนอก เว้นแต่ได้รับอนุมัติเพิ่มเติม')
|
||||
. bullet('การย้ายข้อมูลเดิม การเชื่อมต่อ ERP/บริการภายนอก และการปรับแต่งนอกขอบเขตต้องผ่านกระบวนการ Change Request')
|
||||
. bullet('ผู้มีส่วนเกี่ยวข้องต้องให้ข้อมูล ทบทวนเอกสาร และเข้าร่วมการทดสอบ/ยอมรับตามกำหนด')
|
||||
. para('5. แผนงานและจุดควบคุม', 'left', true, 32, 150, 80)
|
||||
. bullet('เริ่มต้นและวางแผนโครงการ: 5 มกราคม – 18 กุมภาพันธ์ 2569')
|
||||
. bullet('พัฒนาและทดสอบภายใน: 19 กุมภาพันธ์ – 29 พฤษภาคม 2569')
|
||||
. bullet('ทดสอบการยอมรับ จัดทำเอกสาร ส่งมอบ และปรับเสถียรภาพ: 30 พฤษภาคม – 14 สิงหาคม 2569')
|
||||
. bullet('วันเสร็จสิ้นโครงการอย่างเป็นทางการ: 14 สิงหาคม 2569')
|
||||
. para('6. เกณฑ์การยอมรับ', 'left', true, 32, 150, 80)
|
||||
. bullet('ฟังก์ชันที่อยู่ในขอบเขตผ่าน Test Cases และเชื่อมโยงกับความต้องการใน Traceability Record')
|
||||
. bullet('ข้อบกพร่องระดับร้ายแรงที่ขัดขวางการใช้งานได้รับการแก้ไขหรือมีแนวทางที่ผู้มีอำนาจยอมรับ')
|
||||
. bullet('เอกสารการติดตั้ง การใช้งาน การปฏิบัติการ และการบำรุงรักษาพร้อมใช้งาน')
|
||||
. bullet('ผลการ Verification, Validation และ Acceptance ได้รับการทบทวนและลงนามโดยผู้มีอำนาจ')
|
||||
. para('7. การบริหารการเปลี่ยนแปลง', 'left', true, 32, 150, 80)
|
||||
. para('การเปลี่ยนแปลงขอบเขต กำหนดการ หรือผลส่งมอบต้องบันทึกใน Change Report ประเมินผลกระทบ และได้รับอนุมัติก่อนดำเนินการ การแก้ไขข้อบกพร่องให้บันทึกใน Correction Register และเชื่อมโยงกับหลักฐานการทดสอบที่เกี่ยวข้อง', 'both', false, 30, 0, 120, 720)
|
||||
. para('จึงจัดทำ Statement of Work ฉบับนี้เพื่อใช้เป็นกรอบดำเนินงานและขอให้ผู้เกี่ยวข้องพิจารณาอนุมัติตามอำนาจหน้าที่', 'both', false, 30, 80, 240, 720)
|
||||
. para('ลงชื่อ ____________________________________', 'center', false, 30, 240, 20)
|
||||
. para('(คุณเสรี วิริยะสกุลธรณ์)', 'center', false, 30, 0, 20)
|
||||
. para('กรรมการผู้จัดการ · Project Sponsor / Customer Representative / Authorized Approver', 'center', false, 26, 0, 20)
|
||||
. para('บริษัท บี.อาร์.เอ็น. เอ็นเตอร์ไพรส์ จำกัด', 'center', false, 30, 0, 20)
|
||||
. para('วันที่ ____________________________________', 'center', false, 30, 0, 80);
|
||||
|
||||
$document = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
|
||||
. '<w:body>' . $body
|
||||
. '<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1080" w:right="1080" w:bottom="1080" w:left="1260" w:header="500" w:footer="500"/>'
|
||||
. '<w:footerReference w:type="default" r:id="rId1"/></w:sectPr></w:body></w:document>';
|
||||
|
||||
$contentTypes = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||
. '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||
. '<Default Extension="xml" ContentType="application/xml"/>'
|
||||
. '<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
|
||||
. '<Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/>'
|
||||
. '<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>'
|
||||
. '<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>'
|
||||
. '</Types>';
|
||||
|
||||
$rels = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
. '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>'
|
||||
. '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>'
|
||||
. '<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>'
|
||||
. '</Relationships>';
|
||||
|
||||
$documentRels = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
. '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/>'
|
||||
. '</Relationships>';
|
||||
|
||||
$footer = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<w:ftr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
|
||||
. '<w:p><w:pPr><w:jc w:val="center"/></w:pPr>'
|
||||
. run('200-WMS-26-001-00 · Statement of Work · V1.0 · Final', false, 18)
|
||||
. '</w:p></w:ftr>';
|
||||
|
||||
$core = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" '
|
||||
. 'xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">'
|
||||
. '<dc:title>200-WMS-26-001-00 Statement of Work</dc:title><dc:subject>ISO/IEC 29110 Project Management Work Product</dc:subject>'
|
||||
. '<dc:creator>BRN WMS Project Team</dc:creator><cp:keywords>ISO/IEC 29110; Statement of Work; BRN WMS</cp:keywords>'
|
||||
. '<dc:description>Editable final document ready for authorized approval and signature.</dc:description>'
|
||||
. '<dcterms:created xsi:type="dcterms:W3CDTF">2026-01-05T00:00:00+07:00</dcterms:created>'
|
||||
. '<dcterms:modified xsi:type="dcterms:W3CDTF">2026-08-17T00:00:00+07:00</dcterms:modified>'
|
||||
. '</cp:coreProperties>';
|
||||
|
||||
$app = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" '
|
||||
. 'xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">'
|
||||
. '<Application>Microsoft Office Word</Application><Company>B.R.N. Enterprise Co., Ltd.</Company><AppVersion>16.0000</AppVersion>'
|
||||
. '</Properties>';
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($output, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
throw new RuntimeException('Unable to create ' . $output);
|
||||
}
|
||||
$zip->addFromString('[Content_Types].xml', $contentTypes);
|
||||
$zip->addFromString('_rels/.rels', $rels);
|
||||
$zip->addFromString('word/document.xml', $document);
|
||||
$zip->addFromString('word/_rels/document.xml.rels', $documentRels);
|
||||
$zip->addFromString('word/footer1.xml', $footer);
|
||||
$zip->addFromString('docProps/core.xml', $core);
|
||||
$zip->addFromString('docProps/app.xml', $app);
|
||||
$zip->close();
|
||||
|
||||
echo $output . PHP_EOL;
|
||||
require __DIR__ . '/normalize_document_dates.php';
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$output = __DIR__ . '/../sdlc/1-PM Process (10 Work Product)/2.Project Plan/1-Work Schedule/'
|
||||
. '200-WMS-26-001-00 Work Schedule 25690105 V1.0 Final.xlsx';
|
||||
|
||||
$headers = ['No.', 'Phase', 'Task', 'Details / Basis', 'Responsible Role', 'Start', 'Finish', 'Duration (days)', 'Deliverable / Evidence', 'Status', 'Remarks'];
|
||||
$rows = [
|
||||
['1.1', 'Initiation', 'Identify project need', 'Establish need for centralized warehouse, inventory, order, and accounting control.', 'Project Sponsor / Project Manager', '05-Jan-2026', '09-Jan-2026', '5', 'Statement of Work', 'Completed', 'Reconstructed planning activity'],
|
||||
['1.2', 'Initiation', 'Identify stakeholders and objectives', 'Identify sponsor, operational users, system administrator, development, and approval roles.', 'Project Manager', '05-Jan-2026', '16-Jan-2026', '10', 'Stakeholder and objective records', 'Completed', 'Reconstructed planning activity'],
|
||||
['1.3', 'Initiation', 'Approve project scope', 'Confirm project boundaries, assumptions, deliverables, and acceptance approach.', 'Project Sponsor', '19-Jan-2026', '23-Jan-2026', '5', 'Approved Statement of Work', 'Pending signature', 'Authority signature required'],
|
||||
['2.1', 'Planning', 'Collect customer requirements', 'Document functional, data, security, operational, and quality requirements.', 'System Analyst / Customer Representatives', '12-Jan-2026', '06-Feb-2026', '20', 'Customer Requirements', 'Completed', 'Reconstructed from implemented system'],
|
||||
['2.2', 'Planning', 'Prepare Software Project Plan', 'Define lifecycle, resources, risks, repository, configuration, communication, and controls.', 'Project Manager', '26-Jan-2026', '13-Feb-2026', '15', 'Software Project Plan', 'Completed', 'Reconstructed project plan'],
|
||||
['2.3', 'Planning', 'Baseline requirements and schedule', 'Review initial requirements, priorities, milestones, and work-product responsibilities.', 'Project Manager / System Analyst', '16-Feb-2026', '18-Feb-2026', '3', 'Baseline plan and requirements', 'Completed', 'Development begins 19-Feb-2026'],
|
||||
['3.1', 'Execution', 'Initialize WMS application', 'Create initial PHP application repository and baseline structure.', 'System Analyst / Developer', '19-Feb-2026', '25-Feb-2026', '5', 'Git commits 9a50080–1843308', 'Completed', 'Git evidenced'],
|
||||
['3.2', 'Execution', 'Security and database foundation', 'Configuration protection, security audit actions, stock database design.', 'Developer', '09-Mar-2026', '17-Mar-2026', '7', 'Git commits 93d903c–a4f474b', 'Completed', 'Git evidenced'],
|
||||
['3.3', 'Execution', 'Inventory and warehouse modules', 'Warehouse capacity, product, storage/bin, lot, serial, expiry, stock movements, and reports.', 'Developer', '09-Apr-2026', '29-Apr-2026', '15', 'Inventory, ICS, dashboard and report modules', 'Completed', 'Git evidenced'],
|
||||
['3.4', 'Execution', 'Authentication and onboarding', 'Login, registration, onboarding, password controls, and role-based access.', 'Developer', '28-Apr-2026', '12-May-2026', '11', 'Authentication and user-management modules', 'Completed', 'Git evidenced'],
|
||||
['3.5', 'Execution', 'Order and barcode workflows', 'Orders, returns, invoices, switchable warehouse layers, barcode labels, and scanning.', 'Developer', '02-May-2026', '08-May-2026', '5', 'Order and barcode modules', 'Completed', 'Git evidenced'],
|
||||
['3.6', 'Execution', 'Production preparation and setup', 'Dynamic base URL, automated database setup, recovery flow, and production preparation.', 'Developer', '11-May-2026', '13-May-2026', '3', 'Setup and configuration implementation', 'Completed', 'Git evidenced'],
|
||||
['3.7', 'Execution', 'Accounting and finance workflows', 'Chart of accounts, GL, journals, reports, billing, payment and receipt workflows.', 'Developer', '13-May-2026', '23-May-2026', '9', 'Accounting and finance modules', 'Completed', 'Git evidenced'],
|
||||
['3.8', 'Execution', 'Real-time services and scheduled jobs', 'Socket notifications, stock/GL aggregates, low-stock and overdue-invoice schedules.', 'Developer', '22-May-2026', '27-May-2026', '4', 'Node.js service and cron jobs', 'Completed', 'Git evidenced'],
|
||||
['3.9', 'Execution', 'Security hardening and lifecycle review', 'Role guards, tenant scoping, document flows, transaction limits, and correction closure.', 'Developer / Reviewer', '21-May-2026', '28-May-2026', '6', 'Security and lifecycle review commits', 'Completed', 'Git evidenced'],
|
||||
['3.10', 'Execution', 'Refactor and development baseline', 'Remove redundancy and establish substantially complete development baseline.', 'Developer', '29-May-2026', '29-May-2026', '1', 'Git commit a0677d6', 'Completed', 'Development substantially complete'],
|
||||
['4.1', 'Control', 'Maintain progress and meeting records', 'Track progress, issues, decisions, risks, and corrective actions throughout the project.', 'Project Manager / Document Control', '05-Jan-2026', '14-Aug-2026', '160', 'Progress Status, Meeting Records, Correction Register', 'Completed', 'Records require evidence review'],
|
||||
['4.2', 'Control', 'Configuration and repository control', 'Control source, baselines, document versions, configuration, and backups.', 'Configuration Manager', '19-Feb-2026', '14-Aug-2026', '127', 'Git repository and Software Configuration', 'Completed', 'Git repository evidenced'],
|
||||
['4.3', 'Verification', 'Verify requirements, design, and implementation', 'Conduct reviews and tests; link requirements, design, code, and test evidence.', 'Reviewer / Tester', '30-May-2026', '31-Jul-2026', '45', 'Verification Results and Traceability Record', 'Completed', 'Formal approval pending'],
|
||||
['4.4', 'Validation', 'Customer-oriented system validation', 'Validate operational workflows and quality attributes against intended use.', 'Customer Representatives / Tester', '01-Jun-2026', '07-Aug-2026', '50', 'Validation Results and Test Report', 'Completed', 'Formal approval pending'],
|
||||
['4.5', 'Documentation', 'Prepare operational documentation', 'Prepare user, operation, configuration, deployment, and maintenance guidance.', 'Developer / Document Control', '01-Jun-2026', '07-Aug-2026', '50', 'User Documentation, Operation Guide, Maintenance Documentation', 'Completed', 'Reconstructed documentation period'],
|
||||
['4.6', 'Stabilization', 'Configuration and login corrections', 'Correct login and environment configuration issues identified after baseline.', 'Developer', '03-Aug-2026', '03-Aug-2026', '1', 'Git commit b2c4374', 'Completed', 'Git evidenced'],
|
||||
['4.7', 'Stabilization', 'Prepare demonstration data', 'Populate controlled demonstration data for validation and handover support.', 'Developer / Tester', '14-Aug-2026', '14-Aug-2026', '1', 'Git commit dd48a8b', 'Completed', 'Git evidenced'],
|
||||
['5.1', 'Closure', 'Final repository and work-product review', 'Confirm required work products, traceability, configuration items, and unresolved actions.', 'Project Manager / Document Control', '10-Aug-2026', '14-Aug-2026', '5', 'Repository review and List of Evidence', 'Completed', 'Approval records pending'],
|
||||
['5.2', 'Closure', 'Acceptance and project closure', 'Obtain authorized acceptance and record project closure and follow-up actions.', 'Project Sponsor / Project Manager', '14-Aug-2026', '14-Aug-2026', '1', 'Acceptance Report and closure record', 'Pending signature', 'Authority signature required'],
|
||||
];
|
||||
|
||||
function esc(string $value): string
|
||||
{
|
||||
return htmlspecialchars($value, ENT_XML1 | ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
function colName(int $number): string
|
||||
{
|
||||
$name = '';
|
||||
while ($number > 0) {
|
||||
$number--;
|
||||
$name = chr(65 + ($number % 26)) . $name;
|
||||
$number = intdiv($number, 26);
|
||||
}
|
||||
return $name;
|
||||
}
|
||||
|
||||
function cell(string $reference, string $value, int $style): string
|
||||
{
|
||||
return '<c r="' . $reference . '" t="inlineStr" s="' . $style . '"><is><t xml:space="preserve">' . esc($value) . '</t></is></c>';
|
||||
}
|
||||
|
||||
$sheetRows = [];
|
||||
$sheetRows[] = '<row r="1" ht="30" customHeight="1">'
|
||||
. cell('A1', 'Document No: Work Schedule', 1)
|
||||
. cell('B1', 'Project: BRN WMS', 1)
|
||||
. cell('C1', 'Project Code: 200-WMS-26-001-00', 1)
|
||||
. cell('D1', 'Period: 05-Jan-2026 to 14-Aug-2026', 1)
|
||||
. cell('E1', 'Release: 05/01/26 V1.0 Final', 1)
|
||||
. '</row>';
|
||||
$sheetRows[] = '<row r="2" ht="26" customHeight="1">';
|
||||
foreach ($headers as $index => $header) {
|
||||
$sheetRows[1] .= cell(colName($index + 1) . '2', $header, 2);
|
||||
}
|
||||
$sheetRows[1] .= '</row>';
|
||||
|
||||
foreach ($rows as $rowIndex => $row) {
|
||||
$excelRow = $rowIndex + 3;
|
||||
$xml = '<row r="' . $excelRow . '" ht="42" customHeight="1">';
|
||||
foreach ($row as $columnIndex => $value) {
|
||||
$style = $columnIndex === 9
|
||||
? ($value === 'Completed' ? 4 : 5)
|
||||
: ($columnIndex === 0 || $columnIndex >= 5 && $columnIndex <= 7 ? 3 : 6);
|
||||
$xml .= cell(colName($columnIndex + 1) . $excelRow, $value, $style);
|
||||
}
|
||||
$sheetRows[] = $xml . '</row>';
|
||||
}
|
||||
|
||||
$lastRow = count($rows) + 2;
|
||||
$sheet = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
|
||||
. '<sheetPr><pageSetUpPr fitToPage="1"/></sheetPr><dimension ref="A1:K' . $lastRow . '"/>'
|
||||
. '<sheetViews><sheetView workbookViewId="0"><pane ySplit="2" topLeftCell="A3" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
|
||||
. '<cols><col min="1" max="1" width="7" customWidth="1"/><col min="2" max="2" width="14" customWidth="1"/>'
|
||||
. '<col min="3" max="3" width="29" customWidth="1"/><col min="4" max="4" width="48" customWidth="1"/>'
|
||||
. '<col min="5" max="5" width="28" customWidth="1"/><col min="6" max="7" width="14" customWidth="1"/>'
|
||||
. '<col min="8" max="8" width="14" customWidth="1"/><col min="9" max="9" width="34" customWidth="1"/>'
|
||||
. '<col min="10" max="10" width="18" customWidth="1"/><col min="11" max="11" width="28" customWidth="1"/></cols>'
|
||||
. '<sheetData>' . implode('', $sheetRows) . '</sheetData>'
|
||||
. '<autoFilter ref="A2:K' . $lastRow . '"/><mergeCells count="5"><mergeCell ref="A1:A1"/><mergeCell ref="B1:B1"/><mergeCell ref="C1:C1"/><mergeCell ref="D1:D1"/><mergeCell ref="E1:K1"/></mergeCells>'
|
||||
. '<pageMargins left="0.25" right="0.25" top="0.4" bottom="0.4" header="0.2" footer="0.2"/>'
|
||||
. '<pageSetup orientation="landscape" paperSize="9" fitToWidth="1" fitToHeight="0"/>'
|
||||
. '<printOptions horizontalCentered="1"/><headerFooter><oddFooter>&LISO/IEC 29110&C200-WMS-26-001-00&RPage &P of &N</oddFooter></headerFooter>'
|
||||
. '</worksheet>';
|
||||
|
||||
$styles = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
. '<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
||||
. '<fonts count="4"><font><sz val="10"/><name val="Arial"/></font><font><b/><color rgb="FFFFFFFF"/><sz val="11"/><name val="Arial"/></font>'
|
||||
. '<font><b/><color rgb="FFFFFFFF"/><sz val="10"/><name val="Arial"/></font><font><sz val="9"/><name val="Arial"/></font></fonts>'
|
||||
. '<fills count="5"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill>'
|
||||
. '<fill><patternFill patternType="solid"><fgColor rgb="FF075985"/></patternFill></fill>'
|
||||
. '<fill><patternFill patternType="solid"><fgColor rgb="FF0E7490"/></patternFill></fill>'
|
||||
. '<fill><patternFill patternType="solid"><fgColor rgb="FFFEF3C7"/></patternFill></fill></fills>'
|
||||
. '<borders count="2"><border/><border><left style="thin"><color rgb="FFB7C9D6"/></left><right style="thin"><color rgb="FFB7C9D6"/></right><top style="thin"><color rgb="FFB7C9D6"/></top><bottom style="thin"><color rgb="FFB7C9D6"/></bottom></border></borders>'
|
||||
. '<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
|
||||
. '<cellXfs count="7"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
|
||||
. '<xf numFmtId="0" fontId="1" fillId="2" borderId="1" xfId="0" applyAlignment="1"><alignment vertical="center" wrapText="1"/></xf>'
|
||||
. '<xf numFmtId="0" fontId="2" fillId="2" borderId="1" xfId="0" applyAlignment="1"><alignment horizontal="center" vertical="center" wrapText="1"/></xf>'
|
||||
. '<xf numFmtId="0" fontId="3" fillId="0" borderId="1" xfId="0" applyAlignment="1"><alignment horizontal="center" vertical="top" wrapText="1"/></xf>'
|
||||
. '<xf numFmtId="0" fontId="3" fillId="3" borderId="1" xfId="0" applyAlignment="1"><alignment horizontal="center" vertical="top" wrapText="1"/></xf>'
|
||||
. '<xf numFmtId="0" fontId="3" fillId="4" borderId="1" xfId="0" applyAlignment="1"><alignment horizontal="center" vertical="top" wrapText="1"/></xf>'
|
||||
. '<xf numFmtId="0" fontId="3" fillId="0" borderId="1" xfId="0" applyAlignment="1"><alignment vertical="top" wrapText="1"/></xf></cellXfs>'
|
||||
. '<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>';
|
||||
|
||||
$contentTypes = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||
. '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/>'
|
||||
. '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
|
||||
. '<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
|
||||
. '<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>'
|
||||
. '<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>'
|
||||
. '<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/></Types>';
|
||||
$rootRels = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
. '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
|
||||
. '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>'
|
||||
. '<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/></Relationships>';
|
||||
$workbook = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
|
||||
. '<sheets><sheet name="Work Schedule" sheetId="1" r:id="rId1"/></sheets><definedNames><definedName name="_xlnm.Print_Area" localSheetId="0">\'Work Schedule\'!$A$1:$K$' . $lastRow . '</definedName></definedNames></workbook>';
|
||||
$workbookRels = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
. '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>'
|
||||
. '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
|
||||
$core = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dc:title>200-WMS-26-001-00 Work Schedule</dc:title><dc:creator>BRN WMS Project Team</dc:creator><dc:description>ISO/IEC 29110 reconstructed final project schedule ready for authorization.</dc:description><dcterms:created xsi:type="dcterms:W3CDTF">2026-01-05T00:00:00+07:00</dcterms:created></cp:coreProperties>';
|
||||
$app = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"><Application>Microsoft Excel</Application><Company>B.R.N. Enterprise Co., Ltd.</Company></Properties>';
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($output, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
throw new RuntimeException('Cannot create ' . $output);
|
||||
}
|
||||
foreach (['[Content_Types].xml' => $contentTypes, '_rels/.rels' => $rootRels, 'xl/workbook.xml' => $workbook, 'xl/_rels/workbook.xml.rels' => $workbookRels, 'xl/worksheets/sheet1.xml' => $sheet, 'xl/styles.xml' => $styles, 'docProps/core.xml' => $core, 'docProps/app.xml' => $app] as $name => $contents) {
|
||||
$zip->addFromString($name, $contents);
|
||||
}
|
||||
$zip->close();
|
||||
echo $output . PHP_EOL;
|
||||
require __DIR__ . '/normalize_document_dates.php';
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$root = __DIR__ . '/../sdlc';
|
||||
$english = [
|
||||
'January'=>'01','February'=>'02','March'=>'03','April'=>'04','May'=>'05','June'=>'06',
|
||||
'July'=>'07','August'=>'08','September'=>'09','October'=>'10','November'=>'11','December'=>'12',
|
||||
'Jan'=>'01','Feb'=>'02','Mar'=>'03','Apr'=>'04','Jun'=>'06','Jul'=>'07','Aug'=>'08','Sep'=>'09','Oct'=>'10','Nov'=>'11','Dec'=>'12',
|
||||
];
|
||||
$thai = ['มกราคม'=>'01','กุมภาพันธ์'=>'02','มีนาคม'=>'03','เมษายน'=>'04','พฤษภาคม'=>'05','มิถุนายน'=>'06','กรกฎาคม'=>'07','สิงหาคม'=>'08','กันยายน'=>'09','ตุลาคม'=>'10','พฤศจิกายน'=>'11','ธันวาคม'=>'12'];
|
||||
|
||||
function normalDate(int|string $day, string $month, int|string $year, array $english, array $thai): string
|
||||
{
|
||||
$monthNo = $english[$month] ?? $thai[$month] ?? null;
|
||||
if ($monthNo === null) return (string)$day . ' ' . $month . ' ' . (string)$year;
|
||||
$yearNo = (int)$year;
|
||||
if ($yearNo >= 2400) $yearNo -= 543;
|
||||
return sprintf('%02d/%s/%02d', (int)$day, $monthNo, $yearNo % 100);
|
||||
}
|
||||
|
||||
function normalizeDates(string $text, array $english, array $thai): string
|
||||
{
|
||||
$months = implode('|', array_map(static fn($v) => preg_quote($v, '/'), array_keys($english + $thai)));
|
||||
|
||||
// Compact Buddhist document/report dates, e.g. 25690817.
|
||||
$text = preg_replace_callback('/\b(25\d{2})(\d{2})(\d{2})\b/', static function ($m) {
|
||||
return sprintf('%02d/%02d/%02d', (int)$m[3], (int)$m[2], ((int)$m[1] - 543) % 100);
|
||||
}, $text) ?? $text;
|
||||
|
||||
// Shared-month range: 11–13 May 2026.
|
||||
$text = preg_replace_callback('/\b(\d{1,2})\s*[–-]\s*(\d{1,2})\s+('.$months.')\s+(20\d{2}|25\d{2})\b/u', static function ($m) use ($english,$thai) {
|
||||
return normalDate($m[1],$m[3],$m[4],$english,$thai).'–'.normalDate($m[2],$m[3],$m[4],$english,$thai);
|
||||
}, $text) ?? $text;
|
||||
|
||||
// Two-month range with a shared year: 5 January–14 August 2026.
|
||||
$text = preg_replace_callback('/\b(\d{1,2})\s+('.$months.')\s*[–-]\s*(\d{1,2})\s+('.$months.')\s+(20\d{2}|25\d{2})\b/u', static function ($m) use ($english,$thai) {
|
||||
return normalDate($m[1],$m[2],$m[5],$english,$thai).'–'.normalDate($m[3],$m[4],$m[5],$english,$thai);
|
||||
}, $text) ?? $text;
|
||||
|
||||
// Hyphenated two-month range: 19-Feb–29-May-2026.
|
||||
$text = preg_replace_callback('/\b(\d{1,2})-('.$months.')\s*[–-]\s*(\d{1,2})-('.$months.')-(20\d{2}|25\d{2})\b/u', static function ($m) use ($english,$thai) {
|
||||
return normalDate($m[1],$m[2],$m[5],$english,$thai).'–'.normalDate($m[3],$m[4],$m[5],$english,$thai);
|
||||
}, $text) ?? $text;
|
||||
|
||||
// A full single date using a month name or abbreviation.
|
||||
$text = preg_replace_callback('/\b(\d{1,2})(?:-|\s+)('.$months.')(?:-|\s+)(20\d{2}|25\d{2})\b/u', static function ($m) use ($english,$thai) {
|
||||
return normalDate($m[1],$m[2],$m[3],$english,$thai);
|
||||
}, $text) ?? $text;
|
||||
|
||||
// Period descriptions without a repeated year, all known to be in 2026.
|
||||
$text = preg_replace_callback('/\b(\d{1,2})\s*[–-]\s*(\d{1,2})\s+('.$months.')(?=\s+(?:commits|accounting|May|April|January|February|March|June|July|August))/u', static function ($m) use ($english,$thai) {
|
||||
return normalDate($m[1],$m[3],2026,$english,$thai).'–'.normalDate($m[2],$m[3],2026,$english,$thai);
|
||||
}, $text) ?? $text;
|
||||
|
||||
// Commit-period shorthand: 30-Apr through 08-May.
|
||||
$text = preg_replace_callback('/\b(\d{1,2})-('.$months.')\s+through\s+(\d{1,2})-('.$months.')\b/u', static function ($m) use ($english,$thai) {
|
||||
return normalDate($m[1],$m[2],2026,$english,$thai).'–'.normalDate($m[3],$m[4],2026,$english,$thai);
|
||||
}, $text) ?? $text;
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS));
|
||||
$updated = 0;
|
||||
foreach ($iterator as $file) {
|
||||
if (!$file->isFile() || !str_starts_with($file->getFilename(), '200-WMS-')) continue;
|
||||
$path = $file->getPathname();
|
||||
$extension = strtolower($file->getExtension());
|
||||
if ($extension === 'md') {
|
||||
$before = file_get_contents($path);
|
||||
if ($before === false) continue;
|
||||
$after = normalizeDates($before, $english, $thai);
|
||||
if ($after !== $before) {
|
||||
file_put_contents($path, $after);
|
||||
$updated++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!in_array($extension, ['docx','xlsx'], true)) continue;
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($path) !== true) continue;
|
||||
$changed = false;
|
||||
for ($i=0; $i<$zip->numFiles; $i++) {
|
||||
$name = $zip->getNameIndex($i);
|
||||
if (!str_ends_with($name, '.xml')) continue;
|
||||
$before = $zip->getFromIndex($i);
|
||||
$after = normalizeDates($before, $english, $thai);
|
||||
if ($after === $before) continue;
|
||||
$dom = new DOMDocument();
|
||||
if (!$dom->loadXML($after)) throw new RuntimeException("Invalid XML after date conversion: {$path}::{$name}");
|
||||
$zip->addFromString($name, $after);
|
||||
$changed = true;
|
||||
}
|
||||
$zip->close();
|
||||
if ($changed) $updated++;
|
||||
}
|
||||
|
||||
echo "Updated {$updated} BRN WMS document(s).\n";
|
||||
@@ -0,0 +1,7 @@
|
||||
<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,7 +1,4 @@
|
||||
<!-- Repeating page footer: rule, standard reference at the left, document
|
||||
abbreviation at the right, matching the audited reference package. -->
|
||||
<div style="-webkit-print-color-adjust:exact; print-color-adjust:exact; font-family:'BRN Thai',Arial,sans-serif; margin:0; padding:0; position:relative; width:210mm;">
|
||||
<div style="border-top:.75pt solid #111; left:30mm; position:absolute; top:0; width:160mm;"></div>
|
||||
<div style="color:#111; font-size:9pt; left:30mm; position:absolute; top:1.2mm;">{{standard}}</div>
|
||||
<div style="color:#111; font-size:9pt; position:absolute; right:20mm; text-align:right; top:1.2mm;">{{footerTag}}</div>
|
||||
<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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user