Files
wms-app/demo_seed_transactions.php
T
Thanakorn 3668f22e55 Fix customer return confirm, auto credit note VAT and quotation link
Store the put-away location on return lines (new td_return_item columns, needs setup.php), split auto credit notes into net and VAT with the parent's department, drop the write to a td_quotation column that does not exist, and have the demo seed confirm its customer return.
2026-09-19 07:32:07 +07:00

793 lines
42 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 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 (restock / dispatch / transfer movements are seeded by demo_seed.php):
*
* 4. Chart of accounts, departments, GL posting formulas, product account mapping
* 5. Sales cycle: quotation -> sales order -> invoice -> GL post
* 6. AR: receipt billing -> receipt -> GL post
* 7. Purchasing cycle: purchase request -> PO -> receive -> purchase invoice -> GL post
* 8. AP: payment billing -> payment -> GL post
* 9. Supplier return (confirmed, restocks reversed)
* 10. Customer return (draft only — see note below)
* 11. Barcode labels for a handful of products
*
* Every write goes through the same Manager classes + engine-file patterns
* the app itself uses (dbTransaction wrapping, GlManager posting exactly as
* 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.
*
* Customer returns: the return is saved with a put-away bin and then
* confirmed, the same two steps the Customer Return page performs. (It used to
* be left in draft: td_return_item had no zone/aisle/bin columns, so the
* location was lost on save and confirmReturn() could not restock. setup.php
* now adds them.)
*
* 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/ProductManager.php';
require_once __DIR__ . '/app/assets/utils/classes/QuotationManager.php';
require_once __DIR__ . '/app/assets/utils/classes/OrderManager.php';
require_once __DIR__ . '/app/assets/utils/classes/InvoiceManager.php';
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");
// Resolve by name: ids depend on what else exists in the database.
$sth = $pdo2->prepare("SELECT id, warehouse_name FROM md_warehouse WHERE company_id = :c ORDER BY id");
$sth->execute([':c' => $company_id]);
$warehouses = $sth->fetchAll(PDO::FETCH_KEY_PAIR); // id => name
$main_wh = (int)(array_search('Main Warehouse', $warehouses, true) ?: 0);
$bangna_wh = (int)(array_search('Bangna Distribution Center', $warehouses, true) ?: 0);
if (!$main_wh || !$bangna_wh) exit("ERROR: expected Main Warehouse and Bangna Distribution Center — run demo_seed.php first.\n");
// md_contact.contact_type is an md_contact_type id, so match on the type name.
$sth = $pdo2->prepare(
"SELECT c.id, c.contact_name, t.contact_type AS type_name
FROM md_contact c
JOIN md_contact_type t ON t.company_id = c.company_id AND t.id = c.contact_type
WHERE c.company_id = :c
ORDER BY c.id"
);
$sth->execute([':c' => $company_id]);
$contacts = $sth->fetchAll(PDO::FETCH_ASSOC);
$customer_ids = array_values(array_column(array_filter($contacts, fn($c) => $c['type_name'] === 'Customer'), 'id'));
$supplier_ids = array_values(array_column(array_filter($contacts, fn($c) => $c['type_name'] === 'Supplier'), 'id'));
if (count($customer_ids) < 4 || count($supplier_ids) < 3) exit("ERROR: expected 4 customers and 3 suppliers — run demo_seed.php first.\n");
$sth = $pdo2->prepare("SELECT sku, product_name, uom, cost_price, price FROM md_product WHERE company_id = :c ORDER BY id");
$sth->execute([':c' => $company_id]);
$products = $sth->fetchAll(PDO::FETCH_ASSOC | PDO::FETCH_UNIQUE);
if (count($products) < 14) exit("ERROR: expected 14 products — run demo_seed.php first.\n");
$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);
// ─────────────────────────────────────────────────────────────────────────────
// 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;
}
echo "\n--- Product account mapping ---\n";
// The sales and purchase formulas split 'total' per product, so Batch GL Entries
// refuses to post until every product has sales/purchase accounts
// (Account Formulas > Product Accounts, accounting/api/engine/product_account_mapping.php).
$sth = $pdo2->prepare("SELECT id, sku, sales_account_code, purchase_account_code FROM md_product WHERE company_id = :c ORDER BY id");
$sth->execute([':c' => $company_id]);
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $prod) {
if ($prod['sales_account_code'] && $prod['purchase_account_code']) {
skip("Product {$prod['sku']} already mapped");
continue;
}
$sales_code = $prod['sales_account_code'] ?: '4000';
$purchase_code = $prod['purchase_account_code'] ?: '5000';
dbTransaction($pdo2, fn($pdo) => (new ProductManager($pdo, $company_id))
->updateAccountMapping((int)$prod['id'], $sales_code, $purchase_code, $logging));
ok("Mapped {$prod['sku']}: sales {$sales_code}, purchase {$purchase_code}");
}
/** Post (or replace) GL for a document, mirroring order/api/engine/issue_invoice.php exactly. */
function postGl(PDO $pdo1, PDO $pdo2, int $company_id, string $doc_type, int $doc_id, string $posting_class): array {
require_once __DIR__ . "/app/assets/utils/classes_ac/posting/{$posting_class}.php";
$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' => $p['product_name'], // documents show the product name, as the UI's product search fills it
'quantity' => $qty,
'unit_price' => $unit_price,
'total_price' => $total_price,
'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);
// The link between a quotation and its order is source='quotation' /
// source_id=$qid on td_order, which QuotationManager::getById() joins on —
// that's what this script relies on, 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 — saved with a put-away bin, then confirmed (restocked).
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- Customer return ---\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);
// Returned goods go back into a free bin of the warehouse they shipped from.
$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' => (int)$order_line['warehouse_id']]);
$return_bin = (string)($sth->fetchColumn() ?: '');
if ($return_bin === '') {
throw new Exception("No free bin in warehouse #{$order_line['warehouse_id']} for the demo customer return.");
}
$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_bin) {
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'],
// simple location mode: zone and aisle mirror the bin
'zone' => $return_bin, 'aisle' => $return_bin, 'bin' => $return_bin,
]],
], $logging);
});
// Confirm exactly as order/api/engine/confirm_return.php does: restock,
// auto-approve the stock-in, and raise the credit note.
dbTransaction($pdo2, function ($pdo2) use ($company_id, $customer_return_id, $logging) {
$ret = new ReturnManager($pdo2, $company_id);
$ret->confirmReturn(
$customer_return_id, bin2hex(random_bytes(16)), $logging,
new WarehouseManager($pdo2, $company_id), new InvoiceManager($pdo2, $company_id),
true, true
);
});
ok("Created + confirmed customer return for {$ret_qty} x {$order_line['product_sku']} into bin {$return_bin} (id={$customer_return_id})");
}
// ─────────────────────────────────────────────────────────────────────────────
// 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";