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=== brnwms 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";