Files
wms-app/demo_seed_transactions.php
T
2026-08-14 14:10:31 +07:00

902 lines
48 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:
*
* 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";