Demo Data Population

This commit is contained in:
Thanakorn
2026-08-14 14:10:31 +07:00
parent b2c437426b
commit dd48a8b96d
9 changed files with 2905 additions and 1 deletions
+901
View File
@@ -0,0 +1,901 @@
<?php
/**
* Demo Transaction Data Seeder
* Run once after demo_seed.php: php demo_seed_transactions.php
*
* 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:
*
* 1. Additional stock-in replenishment (restock events)
* 2. Stock-out (direct dispatch) + stock transfer (Main -> Bangna)
* 3. Chart of accounts, departments, GL posting formulas
* 4. Sales cycle: quotation -> sales order -> invoice -> GL post
* 5. AR: receipt billing -> receipt -> GL post
* 6. Purchasing cycle: purchase request -> PO -> receive -> purchase invoice -> GL post
* 7. AP: payment billing -> payment -> GL post
* 8. Supplier return (confirmed, restocks reversed)
* 9. Customer return (draft only — see note below)
* 10. Barcode labels for a handful of products
*
* Every write goes through the same Manager classes + engine-file patterns
* the app itself uses (dbTransaction wrapping, GlManager posting exactly as
* order/api/engine/issue_invoice.php and finance/api/engine/manage_receipt.php
* do it), so the data matches what the real UI would have produced.
*
* NOTE — customer returns: ReturnManager::confirmReturn() reads zone/aisle/bin
* from td_return_item rows, but td_return_item has no zone/aisle/bin columns
* (see setup.php) and no engine file back-fills them before calling
* confirmReturn(). Confirming ANY customer return in this app currently
* throws "Location - - - does not exist" from WarehouseManager::occupyBin().
* This script creates one customer return and leaves it in draft status —
* confirming it is not possible until that's fixed.
*
* Safe to re-run: every insert is guarded by an existence check.
*/
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit('Run via CLI only: php demo_seed_transactions.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/QuotationManager.php';
require_once __DIR__ . '/app/assets/utils/classes/OrderManager.php';
require_once __DIR__ . '/app/assets/utils/classes/InvoiceManager.php';
require_once __DIR__ . '/app/assets/utils/classes/ReturnManager.php';
require_once __DIR__ . '/app/assets/utils/classes/PurchaseRequestManager.php';
require_once __DIR__ . '/app/assets/utils/classes/PurchaseOrderManager.php';
require_once __DIR__ . '/app/assets/utils/classes/SupplierReturnManager.php';
require_once __DIR__ . '/app/assets/utils/classes/ReceiptBillingManager.php';
require_once __DIR__ . '/app/assets/utils/classes/ReceiptManager.php';
require_once __DIR__ . '/app/assets/utils/classes/PaymentBillingManager.php';
require_once __DIR__ . '/app/assets/utils/classes/PaymentManager.php';
require_once __DIR__ . '/app/assets/utils/classes/BarcodeManager.php';
require_once __DIR__ . '/app/assets/utils/classes_ac/ChartOfAccounts.php';
require_once __DIR__ . '/app/assets/utils/classes_ac/DepartmentManager.php';
require_once __DIR__ . '/app/assets/utils/classes_ac/AccountFormulaManager.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/InvoicePosting.php';
require_once __DIR__ . '/app/assets/utils/classes_ac/posting/PurchaseInvoicePosting.php';
require_once __DIR__ . '/app/assets/utils/classes_ac/posting/ReceiptPosting.php';
require_once __DIR__ . '/app/assets/utils/classes_ac/posting/PaymentPosting.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"; }
function warn(string $msg): void { echo "\033[31m !\033[0m {$msg}\n"; }
echo "\n\033[1m=== mn3wms Demo Transaction Seeder ===\033[0m\n\n";
// ─────────────────────────────────────────────────────────────────────────────
// 0. Resolve company / owner / warehouses / contacts seeded by demo_seed.php
// ─────────────────────────────────────────────────────────────────────────────
$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);
if (!$owner_user_id) exit("ERROR: demo owner user not found — run demo_seed.php first.\n");
$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
$sth = $pdo2->prepare("SELECT id, contact_name, contact_type FROM md_contact WHERE company_id = :c ORDER BY id");
$sth->execute([':c' => $company_id]);
$contacts = $sth->fetchAll(PDO::FETCH_ASSOC);
if (count($contacts) < 7) exit("ERROR: expected 7 contacts — run demo_seed.php first.\n");
$customer_ids = array_column(array_filter($contacts, fn($c) => (int)$c['contact_type'] === 1), 'id');
$supplier_ids = array_column(array_filter($contacts, fn($c) => (int)$c['contact_type'] === 2), 'id');
$customer_ids = array_values($customer_ids);
$supplier_ids = array_values($supplier_ids);
$sth = $pdo2->prepare("SELECT sku, 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");
$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
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- Chart of accounts ---\n";
$coa = new ChartOfAccounts($pdo2, $company_id);
$account_defs = [
['code' => '1000', 'name' => 'Cash and Bank', 'type' => 'asset'],
['code' => '1150', 'name' => 'Input VAT', 'type' => 'asset'],
['code' => '1200', 'name' => 'Accounts Receivable', 'type' => 'asset'],
['code' => '2000', 'name' => 'Accounts Payable', 'type' => 'liability'],
['code' => '2100', 'name' => 'Output VAT', 'type' => 'liability'],
['code' => '4000', 'name' => 'Sales Revenue', 'type' => 'revenue'],
['code' => '5000', 'name' => 'Cost of Goods Sold', 'type' => 'expense'],
];
$account_ids = [];
foreach ($account_defs as $a) {
$existing = $coa->getByCode($a['code']);
if ($existing) {
skip("Account {$a['code']} — {$a['name']} already exists");
$account_ids[$a['code']] = (int)$existing['id'];
continue;
}
$id = $coa->create([
'account_code' => $a['code'], 'account_name' => $a['name'], 'account_type' => $a['type'],
'account_category' => '', 'parent_code' => null, 'is_posting' => 1, 'status' => 1,
]);
$account_ids[$a['code']] = $id;
ok("Created account {$a['code']} — {$a['name']} ({$a['type']})");
}
echo "\n--- Departments ---\n";
$deptMgmt = new DepartmentManager($pdo2, $company_id);
$dept_defs = [
['code' => 'SALES', 'name' => 'Sales'],
['code' => 'WH', 'name' => 'Warehouse Operations'],
];
$dept_ids = [];
foreach ($dept_defs as $d) {
$sth = $pdo2->prepare("SELECT id FROM md_department WHERE company_id = :c AND dept_code = :code LIMIT 1");
$sth->execute([':c' => $company_id, ':code' => $d['code']]);
$id = (int)($sth->fetchColumn() ?: 0);
if ($id) {
skip("Department {$d['code']} already exists (id={$id})");
} else {
$id = $deptMgmt->create(['dept_code' => $d['code'], 'dept_name' => $d['name'], 'description' => '', 'status' => 1]);
ok("Created department {$d['code']} — {$d['name']} (id={$id})");
}
$dept_ids[$d['code']] = $id;
}
echo "\n--- GL posting formulas ---\n";
$formulaMgmt = new AccountFormulaManager($pdo2, $company_id);
// document_type => [formula_name, items[ [drcr, account_code, amount_key, description] ]]
$formula_defs = [
'invoice' => [
'name' => 'Standard Sales Invoice',
'items' => [
['D', '1200', 'grand_total', 'Accounts Receivable'],
['C', '4000', 'total', 'Sales Revenue'],
['C', '2100', 'tax', 'Output VAT'],
],
],
'purchase_invoice' => [
'name' => 'Standard Purchase Invoice',
'items' => [
['D', '5000', 'total', 'Cost of Goods Sold'],
['D', '1150', 'tax', 'Input VAT'],
['C', '2000', 'grand_total', 'Accounts Payable'],
],
],
'receipt' => [
'name' => 'Standard Receipt',
'items' => [
['D', '1000', 'amount', 'Cash and Bank'],
['C', '1200', 'amount', 'Accounts Receivable'],
],
],
'payment' => [
'name' => 'Standard Payment',
'items' => [
['D', '2000', 'amount', 'Accounts Payable'],
['C', '1000', 'amount', 'Cash and Bank'],
],
],
];
$formula_ids = [];
foreach ($formula_defs as $doc_type => $def) {
$sth = $pdo2->prepare(
"SELECT id FROM md_account_formula WHERE company_id = :c AND document_type = :t AND is_default = 1 LIMIT 1"
);
$sth->execute([':c' => $company_id, ':t' => $doc_type]);
$id = (int)($sth->fetchColumn() ?: 0);
if ($id) {
skip("Default formula for '{$doc_type}' already exists (id={$id})");
} else {
$items = array_map(fn($i) => [
'drcr' => $i[0], 'account_code' => $i[1], 'amount_key' => $i[2], 'description' => $i[3],
], $def['items']);
$id = $formulaMgmt->save([
'id' => 0, 'formula_name' => $def['name'], 'document_type' => $doc_type,
'description' => '', 'is_default' => 1, 'status' => 1, 'items' => $items,
]);
ok("Created default GL formula for '{$doc_type}': {$def['name']} (id={$id})");
}
$formula_ids[$doc_type] = $id;
}
/** 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";
$posting = new $posting_class($pdo2, $company_id);
$built = $posting->build($doc_id, null);
$guard = new PostingWindowGuard($pdo1, $company_id);
$gl = new GlManager($pdo2, $company_id, $guard);
$meta = ['journal_date' => $built['doc_date'] ?? null];
$existing = $gl->getBySource($doc_type, $doc_id);
$pdo2->beginTransaction();
if ($existing) {
$gl->replace($doc_type, $doc_id, $built['formula_id'], $built['period'], $built['lines'], $meta);
$pdo2->commit();
return ['action' => 'replaced', 'lines' => count($built['lines'])];
}
$gl->post($doc_type, $doc_id, $built['formula_id'], $built['period'], $built['lines'], $meta);
$pdo2->commit();
return ['action' => 'posted', 'lines' => count($built['lines'])];
}
/** Build a quotation/order/PR/PO line item with 7% VAT from a product SKU + qty. */
function buildLineItem(array $products, string $sku, float $qty): array {
$p = $products[$sku];
$unit_price = (float)$p['price'];
$total_price = round($qty * $unit_price, 4);
$tax_rate = 7.0;
$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
'quantity' => $qty,
'unit_price' => $unit_price,
'total_price' => $total_price,
'tax_amount' => $tax_amount,
'tax_rate' => $tax_rate,
];
}
// ─────────────────────────────────────────────────────────────────────────────
// 5. Sales cycle: quotation -> sales order -> invoice -> GL post
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- Sales cycle (quotation -> order -> invoice) ---\n";
$quotMgmt = new QuotationManager($pdo2, $company_id);
$orderMgmt = new OrderManager($pdo2, $company_id);
$invMgmt = new InvoiceManager($pdo2, $company_id);
// NOTE: OrderManager::linkQuotationToOrder() is broken — it writes to a
// td_quotation.order_id column that does not exist in the schema (see
// setup.php's td_quotation definition). We never call it. The real link
// between a quotation and its order is source='quotation'/source_id=$qid on
// td_order, which QuotationManager::getById() already joins on — that's what
// this script relies on too, both to link and to look the link back up.
$sth = $pdo2->prepare("SELECT id FROM td_quotation WHERE company_id = :c AND notes = 'Demo seed sales cycle' LIMIT 1");
$sth->execute([':c' => $company_id]);
$sales_quotation_id = (int)($sth->fetchColumn() ?: 0);
if ($sales_quotation_id) {
skip("Demo sales-cycle quotation already exists (id={$sales_quotation_id})");
} else {
$sales_items = [
buildLineItem($products, 'EL-002', 15),
buildLineItem($products, 'OF-001', 25),
];
$sales_quotation_id = dbTransaction($pdo2, function ($pdo2) use ($quotMgmt, $customer_ids, $dept_ids, $sales_items, $logging) {
$qid = $quotMgmt->save([
'id' => 0, 'contact_id' => $customer_ids[0], 'department_id' => $dept_ids['SALES'],
'quotation_date' => date('Y-m-d'), 'valid_until' => date('Y-m-d', strtotime('+30 days')),
'items' => $sales_items, 'discount' => 0, 'notes' => 'Demo seed sales cycle',
], $logging);
$quotMgmt->updateStatus($qid, 'send', $logging);
$quotMgmt->updateStatus($qid, 'accept', $logging);
return $qid;
});
ok("Created + accepted quotation (id={$sales_quotation_id})");
}
$sth = $pdo2->prepare("SELECT id FROM td_order WHERE company_id = :c AND source = 'quotation' AND source_id = :qid LIMIT 1");
$sth->execute([':c' => $company_id, ':qid' => $sales_quotation_id]);
$sales_order_id = (int)($sth->fetchColumn() ?: 0);
if ($sales_order_id) {
skip("Order converted from demo quotation already exists (id={$sales_order_id})");
} else {
// Re-derive line items from the quotation itself (works whether the
// quotation was just created above or already existed from a prior run).
$sth = $pdo2->prepare(
"SELECT item_id, product_sku, product_name, quantity, unit_price, total_price, tax_amount, tax_rate
FROM td_quotation_item WHERE quotation_id = :qid AND company_id = :c ORDER BY item_id"
);
$sth->execute([':qid' => $sales_quotation_id, ':c' => $company_id]);
$order_items = array_map(fn($it) => $it + ['warehouse_id' => $main_wh], $sth->fetchAll(PDO::FETCH_ASSOC));
$sales_order_id = dbTransaction($pdo2, function ($pdo2) use ($orderMgmt, $quotMgmt, $customer_ids, $dept_ids, $order_items, $sales_quotation_id, $logging) {
// Insert with source='quotation' -> starts as status=-2 (pending warehouse assignment)
$oid = $orderMgmt->saveOrder([
'id' => 0, 'source' => 'quotation', 'source_id' => $sales_quotation_id,
'contact_id' => $customer_ids[0], 'department_id' => $dept_ids['SALES'],
'order_date' => date('Y-m-d'), 'items' => $order_items, 'discount' => 0,
'shipping_fee' => 0, 'notes' => 'Demo seed sales cycle',
], $logging);
// Re-save with warehouse_id already set on every item -> auto-promotes -2 to draft (0)
$orderMgmt->saveOrder([
'id' => $oid, 'contact_id' => $customer_ids[0], 'department_id' => $dept_ids['SALES'],
'order_date' => date('Y-m-d'), 'items' => $order_items, 'discount' => 0,
'shipping_fee' => 0, 'notes' => 'Demo seed sales cycle',
], $logging);
$quotMgmt->incrementConvertedQty($sales_quotation_id, array_map(
fn($it) => ['item_id' => $it['item_id'], 'quantity' => $it['quantity']], $order_items
));
$orderMgmt->confirmOrder($oid, bin2hex(random_bytes(16)), $logging, true); // auto-approve stock-out
return $oid;
});
ok("Created + confirmed sales order from quotation (id={$sales_order_id}), stock-out auto-approved");
}
$sth = $pdo2->prepare("SELECT id, status FROM td_invoice WHERE company_id = :c AND order_id = :oid AND doc_type = 'invoice' LIMIT 1");
$sth->execute([':c' => $company_id, ':oid' => $sales_order_id]);
$inv_row = $sth->fetch(PDO::FETCH_ASSOC);
if ($inv_row) {
skip("Invoice for sales order already exists (id={$inv_row['id']})");
$sales_invoice_id = (int)$inv_row['id'];
} else {
$sales_invoice_id = dbTransaction($pdo2, function ($pdo2) use ($invMgmt, $sales_order_id, $logging) {
$iid = $invMgmt->createFromOrder($sales_order_id, $logging);
$invMgmt->issueInvoice($iid, $logging, date('Y-m-d', strtotime('+30 days')));
return $iid;
});
ok("Created + issued invoice from sales order (id={$sales_invoice_id})");
}
$existing_gl = (new GlManager($pdo2, $company_id))->getBySource('invoice', $sales_invoice_id);
if ($existing_gl) {
skip("GL already posted for invoice #{$sales_invoice_id}");
} else {
$res = postGl($pdo1, $pdo2, $company_id, 'invoice', $sales_invoice_id, 'InvoicePosting');
ok("GL {$res['action']} for invoice #{$sales_invoice_id} ({$res['lines']} lines)");
}
// ─────────────────────────────────────────────────────────────────────────────
// 6. AR: receipt billing -> receipt -> GL post
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- AR (receipt billing -> receipt) ---\n";
$receiptBillingMgmt = new ReceiptBillingManager($pdo2, $company_id);
$receiptMgmt = new ReceiptManager($pdo2, $company_id);
$sth = $pdo2->prepare("SELECT id FROM td_receipt_billing WHERE company_id = :c AND notes = 'Demo seed AR' LIMIT 1");
$sth->execute([':c' => $company_id]);
$receipt_billing_id = (int)($sth->fetchColumn() ?: 0);
if ($receipt_billing_id) {
skip("Demo receipt billing already exists (id={$receipt_billing_id})");
} else {
$sth = $pdo2->prepare("SELECT grand_total FROM td_invoice WHERE id = :id AND company_id = :c LIMIT 1");
$sth->execute([':id' => $sales_invoice_id, ':c' => $company_id]);
$invoice_total = (float)$sth->fetchColumn();
$receipt_billing_id = dbTransaction($pdo2, function ($pdo2) use ($receiptBillingMgmt, $customer_ids, $sales_invoice_id, $invoice_total, $logging) {
return $receiptBillingMgmt->createBilling([
'contact_id' => $customer_ids[0], 'billing_date' => date('Y-m-d'), 'notes' => 'Demo seed AR',
'allocations' => [['invoice_id' => $sales_invoice_id, 'amount' => $invoice_total]],
], $logging);
});
ok("Created receipt billing for invoice #{$sales_invoice_id} (id={$receipt_billing_id}), amount " . number_format($invoice_total, 2));
}
$sth = $pdo2->prepare("SELECT id FROM td_receipt WHERE company_id = :c AND receipt_billing_id = :bid LIMIT 1");
$sth->execute([':c' => $company_id, ':bid' => $receipt_billing_id]);
$receipt_id = (int)($sth->fetchColumn() ?: 0);
if ($receipt_id) {
skip("Receipt for billing #{$receipt_billing_id} already exists (id={$receipt_id})");
} else {
$sth = $pdo2->prepare("SELECT contact_id, amount FROM td_receipt_billing WHERE id = :id LIMIT 1");
$sth->execute([':id' => $receipt_billing_id]);
$billing = $sth->fetch(PDO::FETCH_ASSOC);
$receipt_id = dbTransaction($pdo2, function ($pdo2) use ($receiptMgmt, $billing, $receipt_billing_id, $logging) {
return $receiptMgmt->createReceipt([
'receipt_billing_id' => $receipt_billing_id, 'receipt_date' => date('Y-m-d'),
'payment_method' => 'bank_transfer', 'amount' => $billing['amount'], 'notes' => 'Demo seed AR',
], $logging);
});
ok("Created receipt #{$receipt_id} for billing #{$receipt_billing_id}, amount " . number_format($billing['amount'], 2));
}
$existing_gl = (new GlManager($pdo2, $company_id))->getBySource('receipt', $receipt_id);
if ($existing_gl) {
skip("GL already posted for receipt #{$receipt_id}");
} else {
$res = postGl($pdo1, $pdo2, $company_id, 'receipt', $receipt_id, 'ReceiptPosting');
ok("GL {$res['action']} for receipt #{$receipt_id} ({$res['lines']} lines)");
}
// ─────────────────────────────────────────────────────────────────────────────
// 7. Purchasing cycle: purchase request -> PO -> receive -> purchase invoice -> GL post
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- Purchasing cycle (request -> PO -> receipt -> invoice) ---\n";
$prMgmt = new PurchaseRequestManager($pdo2, $company_id);
$poMgmt = new PurchaseOrderManager($pdo2, $company_id);
$sth = $pdo2->prepare("SELECT id FROM td_purchase_request WHERE company_id = :c AND notes = 'Demo seed purchasing cycle' LIMIT 1");
$sth->execute([':c' => $company_id]);
$pr_id = (int)($sth->fetchColumn() ?: 0);
if ($pr_id) {
skip("Demo purchase request already exists (id={$pr_id})");
} else {
$pr_items = [
buildLineItem($products, 'EL-003', 20),
buildLineItem($products, 'PK-001', 100),
];
$pr_id = dbTransaction($pdo2, function ($pdo2) use ($prMgmt, $supplier_ids, $dept_ids, $pr_items, $logging) {
$rid = $prMgmt->save([
'id' => 0, 'contact_id' => $supplier_ids[0], 'department_id' => $dept_ids['WH'],
'request_date' => date('Y-m-d'), 'required_date' => date('Y-m-d', strtotime('+14 days')),
'items' => $pr_items, 'discount' => 0, 'shipping_fee' => 0, 'notes' => 'Demo seed purchasing cycle',
], $logging);
$prMgmt->updateStatus($rid, 'submit', $logging);
$prMgmt->updateStatus($rid, 'approve', $logging);
return $rid;
});
ok("Created + approved purchase request (id={$pr_id})");
}
$sth = $pdo2->prepare("SELECT id FROM td_purchase_order WHERE company_id = :c AND source = 'purchase_request' AND source_id = :rid LIMIT 1");
$sth->execute([':c' => $company_id, ':rid' => $pr_id]);
$po_id = (int)($sth->fetchColumn() ?: 0);
if ($po_id) {
skip("PO converted from demo purchase request already exists (id={$po_id})");
} else {
$sth = $pdo2->prepare(
"SELECT item_id, product_sku, product_name, quantity, unit_price, total_price, tax_amount, tax_rate
FROM td_purchase_request_item WHERE request_id = :rid AND company_id = :c ORDER BY item_id"
);
$sth->execute([':rid' => $pr_id, ':c' => $company_id]);
$po_items = $sth->fetchAll(PDO::FETCH_ASSOC);
$po_id = dbTransaction($pdo2, function ($pdo2) use ($poMgmt, $prMgmt, $supplier_ids, $dept_ids, $po_items, $pr_id, $main_wh, $logging) {
$pid = $poMgmt->savePo([
'id' => 0, 'source' => 'purchase_request', 'source_id' => $pr_id,
'contact_id' => $supplier_ids[0], 'department_id' => $dept_ids['WH'],
'po_date' => date('Y-m-d'), 'expected_date' => date('Y-m-d', strtotime('+7 days')),
'warehouse_id' => $main_wh, 'items' => $po_items, 'discount' => 0,
'shipping_fee' => 0, 'notes' => 'Demo seed purchasing cycle',
], $logging);
// Insert with source set -> status=-2 (pending). Re-save with warehouse_id
// present -> auto-promotes to draft (0), same two-step dance as saveOrder().
$poMgmt->savePo([
'id' => $pid, 'contact_id' => $supplier_ids[0], 'department_id' => $dept_ids['WH'],
'po_date' => date('Y-m-d'), 'expected_date' => date('Y-m-d', strtotime('+7 days')),
'warehouse_id' => $main_wh, 'items' => $po_items, 'discount' => 0,
'shipping_fee' => 0, 'notes' => 'Demo seed purchasing cycle',
], $logging);
$poMgmt->confirmPo($pid, $logging);
$prMgmt->incrementConvertedQty($pr_id, array_map(
fn($it) => ['item_id' => $it['item_id'], 'quantity' => $it['quantity']], $po_items
));
return $pid;
});
ok("Created + confirmed PO from purchase request (id={$po_id})");
}
$sth = $pdo2->prepare(
"SELECT COUNT(*) FROM td_purchase_order_item WHERE order_id = :pid AND company_id = :c AND received_qty > 0"
);
$sth->execute([':pid' => $po_id, ':c' => $company_id]);
if ((int)$sth->fetchColumn() > 0) {
skip("PO #{$po_id} already received");
} else {
$sth = $pdo2->prepare(
"SELECT item_id, product_sku, unit_price, quantity FROM td_purchase_order_item
WHERE order_id = :pid AND company_id = :c ORDER BY item_id"
);
$sth->execute([':pid' => $po_id, ':c' => $company_id]);
$po_line_items = $sth->fetchAll(PDO::FETCH_ASSOC);
// Grab as many free bins as line items in one query — none of these are
// occupied until receivePo() runs, so a single up-front list (rather than
// repeated nextFreeBin() calls, which would all see the same still-empty
// bin and collide) is what keeps each line's location distinct.
$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"
);
$sth->execute([':c' => $company_id, ':w' => $main_wh]);
$free_bins = array_slice($sth->fetchAll(PDO::FETCH_COLUMN), 0, count($po_line_items));
if (count($free_bins) < count($po_line_items)) {
throw new Exception("Not enough free bins in {$warehouses[$main_wh]} to receive PO #{$po_id}.");
}
$receive_items = [];
foreach ($po_line_items as $i => $it) {
$bin = $free_bins[$i];
$receive_items[] = [
'item_id' => (int)$it['item_id'], 'product_sku' => $it['product_sku'],
'warehouse_id' => $main_wh, 'quantity' => (float)$it['quantity'],
'unit_price' => (float)$it['unit_price'],
'zone' => $bin, 'aisle' => $bin, 'bin' => $bin,
'contact_id' => $supplier_ids[0],
];
}
$po_uuid = bin2hex(random_bytes(16));
dbTransaction($pdo2, function ($pdo2) use ($poMgmt, $whMgmt, $po_id, $receive_items, $po_uuid, $logging) {
$poMgmt->receivePo($po_id, $receive_items, $po_uuid, $logging, true); // auto-approve stock-in
});
ok("Received PO #{$po_id} — " . count($receive_items) . " line(s) into {$warehouses[$main_wh]}, stock-in auto-approved");
}
$sth = $pdo2->prepare(
"SELECT id FROM td_invoice WHERE company_id = :c AND source = 'po' AND source_id = :pid AND doc_type = 'purchase_invoice' LIMIT 1"
);
$sth->execute([':c' => $company_id, ':pid' => $po_id]);
$purchase_invoice_id = (int)($sth->fetchColumn() ?: 0);
if ($purchase_invoice_id) {
skip("Purchase invoice for PO already exists (id={$purchase_invoice_id})");
} else {
$purchase_invoice_id = dbTransaction($pdo2, function ($pdo2) use ($invMgmt, $po_id, $logging) {
$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 PO (id={$purchase_invoice_id})");
}
$existing_gl = (new GlManager($pdo2, $company_id))->getBySource('purchase_invoice', $purchase_invoice_id);
if ($existing_gl) {
skip("GL already posted for purchase invoice #{$purchase_invoice_id}");
} else {
$res = postGl($pdo1, $pdo2, $company_id, 'purchase_invoice', $purchase_invoice_id, 'PurchaseInvoicePosting');
ok("GL {$res['action']} for purchase invoice #{$purchase_invoice_id} ({$res['lines']} lines)");
}
// ─────────────────────────────────────────────────────────────────────────────
// 8. AP: payment billing -> payment -> GL post
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- AP (payment billing -> payment) ---\n";
$paymentBillingMgmt = new PaymentBillingManager($pdo2, $company_id);
$paymentMgmt = new PaymentManager($pdo2, $company_id);
$sth = $pdo2->prepare("SELECT id FROM td_payment_billing WHERE company_id = :c AND notes = 'Demo seed AP' LIMIT 1");
$sth->execute([':c' => $company_id]);
$payment_billing_id = (int)($sth->fetchColumn() ?: 0);
if ($payment_billing_id) {
skip("Demo payment billing already exists (id={$payment_billing_id})");
} else {
$sth = $pdo2->prepare("SELECT grand_total FROM td_invoice WHERE id = :id AND company_id = :c LIMIT 1");
$sth->execute([':id' => $purchase_invoice_id, ':c' => $company_id]);
$po_invoice_total = (float)$sth->fetchColumn();
$payment_billing_id = dbTransaction($pdo2, function ($pdo2) use ($paymentBillingMgmt, $supplier_ids, $purchase_invoice_id, $po_invoice_total, $logging) {
return $paymentBillingMgmt->createBilling([
'contact_id' => $supplier_ids[0], 'billing_date' => date('Y-m-d'), 'notes' => 'Demo seed AP',
'allocations' => [['invoice_id' => $purchase_invoice_id, 'amount' => $po_invoice_total]],
], $logging);
});
ok("Created payment billing for purchase invoice #{$purchase_invoice_id} (id={$payment_billing_id}), amount " . number_format($po_invoice_total, 2));
}
$sth = $pdo2->prepare("SELECT id FROM td_payment WHERE company_id = :c AND payment_billing_id = :bid LIMIT 1");
$sth->execute([':c' => $company_id, ':bid' => $payment_billing_id]);
$payment_id = (int)($sth->fetchColumn() ?: 0);
if ($payment_id) {
skip("Payment for billing #{$payment_billing_id} already exists (id={$payment_id})");
} else {
$sth = $pdo2->prepare("SELECT contact_id, amount FROM td_payment_billing WHERE id = :id LIMIT 1");
$sth->execute([':id' => $payment_billing_id]);
$pbilling = $sth->fetch(PDO::FETCH_ASSOC);
$payment_id = dbTransaction($pdo2, function ($pdo2) use ($paymentMgmt, $pbilling, $payment_billing_id, $logging) {
return $paymentMgmt->createPayment([
'payment_billing_id' => $payment_billing_id, 'payment_date' => date('Y-m-d'),
'payment_method' => 'bank_transfer', 'amount' => $pbilling['amount'], 'notes' => 'Demo seed AP',
], $logging);
});
ok("Created payment #{$payment_id} for billing #{$payment_billing_id}, amount " . number_format($pbilling['amount'], 2));
}
$existing_gl = (new GlManager($pdo2, $company_id))->getBySource('payment', $payment_id);
if ($existing_gl) {
skip("GL already posted for payment #{$payment_id}");
} else {
$res = postGl($pdo1, $pdo2, $company_id, 'payment', $payment_id, 'PaymentPosting');
ok("GL {$res['action']} for payment #{$payment_id} ({$res['lines']} lines)");
}
// ─────────────────────────────────────────────────────────────────────────────
// 9. Supplier return — return the full PK-001 line from the demo PO
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- Supplier return ---\n";
$supReturnMgmt = new SupplierReturnManager($pdo2, $company_id);
$sth = $pdo2->prepare("SELECT id FROM td_supplier_return WHERE company_id = :c AND po_id = :pid LIMIT 1");
$sth->execute([':c' => $company_id, ':pid' => $po_id]);
$supplier_return_id = (int)($sth->fetchColumn() ?: 0);
if ($supplier_return_id) {
skip("Demo supplier return already exists (id={$supplier_return_id})");
} else {
// td_purchase_order_item has no warehouse_id column — the PO is single-warehouse
// (td_purchase_order.warehouse_id), which is $main_wh here (set when the PO was created).
$sth = $pdo2->prepare(
"SELECT item_id, product_sku, product_name, quantity, unit_price, total_price, tax_amount, tax_rate,
stock_in_id
FROM td_purchase_order_item
WHERE order_id = :pid AND company_id = :c AND product_sku = 'PK-001' LIMIT 1"
);
$sth->execute([':pid' => $po_id, ':c' => $company_id]);
$po_line = $sth->fetch(PDO::FETCH_ASSOC);
$sth = $pdo2->prepare("SELECT zone, aisle, bin FROM `td_stock_{$main_wh}` WHERE id = :id AND company_id = :c LIMIT 1");
$sth->execute([':id' => $po_line['stock_in_id'], ':c' => $company_id]);
$bin_row = $sth->fetch(PDO::FETCH_ASSOC);
$return_item = [
'product_sku' => $po_line['product_sku'], 'product_name' => $po_line['product_name'],
'quantity' => $po_line['quantity'], 'unit_price' => $po_line['unit_price'],
'total_price' => $po_line['total_price'], 'tax_amount' => $po_line['tax_amount'],
'tax_rate' => $po_line['tax_rate'], 'warehouse_id' => $main_wh,
'stock_in_id' => $po_line['stock_in_id'],
'zone' => $bin_row['zone'], 'aisle' => $bin_row['aisle'], 'bin' => $bin_row['bin'],
];
$supplier_return_id = dbTransaction($pdo2, function ($pdo2) use ($supReturnMgmt, $whMgmt, $supplier_ids, $dept_ids, $return_item, $po_id, $logging) {
$rid = $supReturnMgmt->saveReturn([
'id' => 0, 'po_id' => $po_id, 'contact_id' => $supplier_ids[0], 'department_id' => $dept_ids['WH'],
'return_date' => date('Y-m-d'), 'reason' => 'Damaged on arrival (demo seed)',
'items' => [$return_item], 'tax_adjustment' => 0, 'notes' => 'Demo seed supplier return',
], $logging);
$supReturnMgmt->confirmReturn($rid, bin2hex(random_bytes(16)), $logging, $whMgmt, true); // auto-approve
return $rid;
});
ok("Created + confirmed supplier return for {$return_item['quantity']} x {$return_item['product_sku']} (id={$supplier_return_id})");
}
// ─────────────────────────────────────────────────────────────────────────────
// 10. Customer return — draft only (see NOTE at top of this file: confirming
// customer returns is currently broken in the app itself).
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- Customer return (draft only — confirming is currently broken, see file header) ---\n";
$returnMgmt = new ReturnManager($pdo2, $company_id);
$sth = $pdo2->prepare("SELECT id FROM td_return WHERE company_id = :c AND notes = 'Demo seed customer return' LIMIT 1");
$sth->execute([':c' => $company_id]);
$customer_return_id = (int)($sth->fetchColumn() ?: 0);
if ($customer_return_id) {
skip("Demo customer return already exists (id={$customer_return_id})");
} else {
// Return 5 units of EL-002 against the demo sales order/invoice.
$sth = $pdo2->prepare(
"SELECT item_id, product_sku, product_name, unit_price, tax_rate, warehouse_id, stock_out_id
FROM td_order_item WHERE order_id = :oid AND company_id = :c AND product_sku = 'EL-002' LIMIT 1"
);
$sth->execute([':oid' => $sales_order_id, ':c' => $company_id]);
$order_line = $sth->fetch(PDO::FETCH_ASSOC);
$ret_qty = 5.0;
$ret_total = round($ret_qty * (float)$order_line['unit_price'], 4);
$ret_tax = round($ret_total * (float)$order_line['tax_rate'] / 100, 4);
$customer_return_id = dbTransaction($pdo2, function ($pdo2) use ($returnMgmt, $customer_ids, $order_line, $ret_qty, $ret_total, $ret_tax, $sales_order_id, $sales_invoice_id, $logging) {
return $returnMgmt->saveReturn([
'id' => 0, 'order_id' => $sales_order_id, 'invoice_id' => $sales_invoice_id,
'contact_id' => $customer_ids[0], 'return_date' => date('Y-m-d'),
'reason' => 'Customer changed mind (demo seed)', 'tax_adjustment' => 0,
'notes' => 'Demo seed customer return',
'items' => [[
'product_sku' => $order_line['product_sku'], 'product_name' => $order_line['product_name'],
'quantity' => $ret_qty, 'unit_price' => $order_line['unit_price'],
'total_price' => $ret_total, 'tax_amount' => $ret_tax, 'tax_rate' => $order_line['tax_rate'],
'warehouse_id' => $order_line['warehouse_id'], 'stock_out_id' => $order_line['stock_out_id'],
'stock_out_warehouse_id' => $order_line['warehouse_id'],
]],
], $logging);
});
ok("Created draft customer return for {$ret_qty} x {$order_line['product_sku']} (id={$customer_return_id}) — left in draft, not confirmed");
}
// ─────────────────────────────────────────────────────────────────────────────
// 11. Barcode labels for a handful of products
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- Barcode labels ---\n";
$barcodeMgmt = new BarcodeManager($pdo2, $company_id);
$label_skus = ['EL-001', 'EL-002', 'OF-001', 'BV-001', 'PK-001'];
foreach ($label_skus as $sku) {
$sth = $pdo2->prepare(
"SELECT COUNT(*) FROM md_barcode WHERE company_id = :c AND barcode_type = 'sku' AND product_sku = :sku"
);
$sth->execute([':c' => $company_id, ':sku' => $sku]);
if ((int)$sth->fetchColumn() > 0) {
skip("SKU label for {$sku} already exists");
continue;
}
$label = dbTransaction($pdo2, function ($pdo2) use ($barcodeMgmt, $sku) {
return $barcodeMgmt->createSkuLabel($sku, '', '', true);
});
ok("Created SKU label for {$sku}: {$label['barcode']}");
}
echo "\n\033[1m=== Done ===\033[0m\n\n";