Files
wms-app/demo_seed_assets.php
T
Thanakorn d199fa7f67 Fix transfer quantity, unify date format, align demo seeds
Stock Transfer list showed 0.00 (read in instead of out); stock-out/transfer forms show the location quantity; dates display as YYYY-MM-DD HH:mm:ss. Demo seeds map product accounts, use product names and supplier batches, and add demo_seed_assets.php.
2026-09-15 12:43:47 +07:00

375 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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";