Demo Data Population

This commit is contained in:
Thanakorn
2026-08-14 14:10:31 +07:00
parent b2c437426b
commit dd48a8b96d
9 changed files with 2905 additions and 1 deletions
+1 -1
View File
@@ -86,7 +86,7 @@
<div>
<h2 class="mb-3 fs-6">Total Revenue</h2>
<h3 class="fw-bold mb-0" id="stat_total_revenue">—</h3>
<p class="text-info mb-0 small">Sub-total to date (excl. VAT)</p>
<p class="text-info mb-0 small">Subtotal, excl. VAT</p>
</div>
</div>
<div class="card-loader-overlay" id="overlay_revenue">
+663
View File
@@ -0,0 +1,663 @@
<?php
/**
* Demo Data Seeder
* Run once after setup.php on a fresh install: php demo_seed.php
*
* What it does:
* 1. Reads DB credentials from app/config.php (same as setup.php)
* 2. Creates a demo company + owner user (bypasses email verification —
* inserted directly with status='active' so you can log in immediately)
* 3. Creates 2 warehouses with bin ranges
* 4. Creates product categories + products
* 5. Creates contact types + contacts (customers/suppliers)
* 6. Receives opening stock for every product into Main Warehouse, approved
* (so dashboard/report balances are non-zero from the first login)
* 7. Receives a few later restock/replenishment batches (additional stock-in,
* separate from the opening-stock batch) into fresh bins in Main Warehouse
* and Bangna Distribution Center
* 8. Ships a few products out to customers (stock-out)
* 9. Transfers a few products from Main Warehouse to Bangna Distribution
* Center (stock-transfer)
*
* Together, steps 6-9 give the Stock In / Stock Out / Stock Transfer list
* pages several realistic entries each, instead of just one opening-balance batch.
*
* All writes go through the same Manager classes the app itself uses
* (WarehouseManager, ProductManager, ContactManager, StockManager), so the
* data is guaranteed to match what the UI/API would have produced.
*
* Safe to re-run: every insert is guarded by an existence check.
*
* No company_smtp row is created for the demo company, which means login
* skips the OTP email step entirely (see login_otp.php) — you can log in
* with just username + password.
*/
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit('Run via CLI only: php demo_seed.php');
}
$_SESSION = []; // Manager classes read $_SESSION['login_user_id'] for audit logs; CLI has no session.
require __DIR__ . '/app/config.php';
require __DIR__ . '/app/dbconn.php';
require __DIR__ . '/app/assets/utils/db_helpers.php';
require __DIR__ . '/app/assets/utils/classes/WarehouseManager.php';
require __DIR__ . '/app/assets/utils/classes/ProductManager.php';
require __DIR__ . '/app/assets/utils/classes/ContactManager.php';
require __DIR__ . '/app/assets/utils/classes/StockManager.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 findProductDef(array $product_defs, string $sku): array
{
foreach ($product_defs as $p) {
if ($p['sku'] === $sku) return $p;
}
throw new Exception("demo_seed.php: unknown SKU '{$sku}' referenced by a later seeding step.");
}
/**
* Find the first unoccupied bin in a warehouse (simple-mode: zone = aisle = bin).
* Queried fresh on every call so it stays correct as bins fill up / free up
* across the run — safe to call repeatedly even against a warehouse that
* already has unrelated stock in it.
*/
function findFreeBin(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 id ASC
LIMIT 1"
);
$sth->execute([':c' => $company_id, ':w' => $warehouse_id]);
$bin = $sth->fetchColumn();
if ($bin === false) {
throw new Exception("demo_seed.php: no free bin available in warehouse #{$warehouse_id}.");
}
return $bin;
}
echo "\n\033[1m=== mn3wms Demo Data Seeder ===\033[0m\n\n";
// ─────────────────────────────────────────────────────────────────────────────
// 1. Owner user + company (pdo1 = wms auth/identity db)
// ─────────────────────────────────────────────────────────────────────────────
echo "--- Owner account & company ---\n";
$demo_username = 'admin';
$demo_email = 'thanakorn.inbox@gmail.com';
$demo_password = 'Demo@12345';
$demo_channel = 'demo';
$sth = $pdo1->prepare("SELECT user_id FROM user WHERE username = :u OR email = :e LIMIT 1");
$sth->execute([':u' => $demo_username, ':e' => $demo_email]);
$owner_user_id = (int)($sth->fetchColumn() ?: 0);
if ($owner_user_id) {
skip("User '{$demo_username}' already exists (user_id={$owner_user_id})");
} else {
$hashed = password_hash($demo_password, PASSWORD_BCRYPT);
$sth = $pdo1->prepare("
INSERT INTO user (username, name, surname, email, password, status, profile_picture, license, app_access)
VALUES (:username, :name, :surname, :email, :password, 'active', '', 'owner', 'all')
");
$sth->execute([
':username' => $demo_username,
':name' => 'Thanakorn',
':surname' => 'Admin',
':email' => $demo_email,
':password' => $hashed,
]);
$owner_user_id = (int)$pdo1->lastInsertId();
ok("Created user '{$demo_username}' (user_id={$owner_user_id}), password: {$demo_password}");
}
$sth = $pdo1->prepare("SELECT company_id FROM company_list WHERE channel_name = :c LIMIT 1");
$sth->execute([':c' => $demo_channel]);
$company_id = (int)($sth->fetchColumn() ?: 0);
if ($company_id) {
skip("Company channel '{$demo_channel}' already exists (company_id={$company_id})");
} else {
$sth = $pdo1->prepare("
INSERT INTO company_list (channel_name, company_name, company_name2, branch, branch_no, email, phone, fx)
VALUES (:channel_name, :company_name, :company_name2, :branch, :branch_no, :email, :phone, 'thb')
");
$sth->execute([
':channel_name' => $demo_channel,
':company_name' => 'Demo Trading Co., Ltd.',
':company_name2' => 'Demo Trading',
':branch' => 'สำนักงานใหญ่',
':branch_no' => '00000',
':email' => $demo_email,
':phone' => '021234567',
]);
$company_id = (int)$pdo1->lastInsertId();
ok("Created company 'Demo Trading Co., Ltd.' (company_id={$company_id})");
}
$sth = $pdo1->prepare("SELECT 1 FROM company_map_user WHERE company_id = :c AND user_id = :u LIMIT 1");
$sth->execute([':c' => $company_id, ':u' => $owner_user_id]);
if ($sth->fetchColumn()) {
skip("company_map_user link already exists");
} else {
$pdo1->prepare("
INSERT INTO company_map_user (company_id, user_id, role, app_access, created_at)
VALUES (:company_id, :user_id, 'owner', 'all', NOW())
")->execute([':company_id' => $company_id, ':user_id' => $owner_user_id]);
ok("Linked user to company as owner");
}
$pdo1->prepare("UPDATE user SET default_company = :c WHERE user_id = :u AND (default_company IS NULL OR default_company = 0)")
->execute([':c' => $company_id, ':u' => $owner_user_id]);
// No company_smtp row is inserted on purpose — login_otp.php skips the OTP
// step entirely when a company has no SMTP configured.
$logging = ['user_id' => $owner_user_id, 'dt' => date('Y-m-d H:i:s'), 'login' => null, 'action' => 'seed'];
// ─────────────────────────────────────────────────────────────────────────────
// 2. Warehouses + storage/bins (pdo2 = wms2 business db)
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- Warehouses ---\n";
$whMgmt = new WarehouseManager($pdo2, $company_id);
$warehouse_defs = [
['name' => 'Main Warehouse', 'location' => '123 Sukhumvit Rd, Bangkok', 'bin_to' => 30],
['name' => 'Bangna Distribution Center', 'location' => '456 Bangna-Trad Rd, Samut Prakan', 'bin_to' => 15],
];
$warehouse_ids = []; // name => id
foreach ($warehouse_defs as $wh) {
$sth = $pdo2->prepare("SELECT id FROM md_warehouse WHERE company_id = :c AND warehouse_name = :n LIMIT 1");
$sth->execute([':c' => $company_id, ':n' => $wh['name']]);
$id = (int)($sth->fetchColumn() ?: 0);
if ($id) {
skip("Warehouse '{$wh['name']}' already exists (id={$id})");
} else {
dbTransaction($pdo2, function ($pdo2) use ($whMgmt, $wh, $company_id, $owner_user_id, $logging, &$id) {
$id = $whMgmt->saveWarehouse([
'id' => 0,
'warehouse_name' => $wh['name'],
'location' => $wh['location'],
'manager' => $owner_user_id,
'description' => 'Demo warehouse',
'status' => 1,
], $logging);
});
$whMgmt->createStockTableForWarehouse($id);
ok("Created warehouse '{$wh['name']}' (id={$id}) + td_stock_{$id} table");
}
$warehouse_ids[$wh['name']] = $id;
// Simple-mode storage: one bin range "A-1".."A-N" (zone=aisle=bin=same value)
$sth = $pdo2->prepare("SELECT id FROM md_storage WHERE company_id = :c AND warehouse = :w LIMIT 1");
$sth->execute([':c' => $company_id, ':w' => $id]);
if ($sth->fetchColumn()) {
skip("Storage range for '{$wh['name']}' already exists");
} else {
dbTransaction($pdo2, function ($pdo2) use ($whMgmt, $id, $wh, $logging) {
$whMgmt->saveStorage([
'id' => 0,
'warehouse' => (string)$id,
'bin_from' => 'A-1',
'bin_to' => 'A-' . $wh['bin_to'],
'description' => 'Demo storage range',
'status' => 1,
], $logging, false);
});
ok("Created storage range A-1..A-{$wh['bin_to']} for '{$wh['name']}' ({$wh['bin_to']} bins)");
}
}
$main_wh_id = $warehouse_ids['Main Warehouse'];
$bangna_wh_id = $warehouse_ids['Bangna Distribution Center'];
// ─────────────────────────────────────────────────────────────────────────────
// 3. Product categories + products
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- Products ---\n";
$prodMgmt = new ProductManager($pdo2, $company_id);
$category_defs = [
'electronics' => 'Electronics',
'office-supplies' => 'Office Supplies',
'beverages' => 'Beverages',
'packaging-materials' => 'Packaging Materials',
];
$category_ids = []; // slug => id
foreach ($category_defs as $slug => $name) {
$sth = $pdo2->prepare("SELECT id FROM md_product_category WHERE company_id = :c AND slug = :s LIMIT 1");
$sth->execute([':c' => $company_id, ':s' => $slug]);
$id = (int)($sth->fetchColumn() ?: 0);
if ($id) {
skip("Category '{$name}' already exists (id={$id})");
} else {
dbTransaction($pdo2, function ($pdo2) use ($prodMgmt, $name, $slug, $logging) {
$prodMgmt->saveCategory([
'id' => 0,
'category' => $name,
'slug' => $slug,
'description' => '',
'status' => 1,
], $logging);
});
$sth = $pdo2->prepare("SELECT id FROM md_product_category WHERE company_id = :c AND slug = :s LIMIT 1");
$sth->execute([':c' => $company_id, ':s' => $slug]);
$id = (int)$sth->fetchColumn();
ok("Created category '{$name}' (id={$id})");
}
$category_ids[$slug] = $id;
}
$product_defs = [
['sku' => 'EL-001', 'name' => 'Wireless Mouse', 'cat' => 'electronics', 'uom' => 'pcs', 'price' => 590, 'cost' => 350, 'min' => 10, 'reorder' => 20, 'qty' => 120],
['sku' => 'EL-002', 'name' => 'USB-C Cable 1m', 'cat' => 'electronics', 'uom' => 'pcs', 'price' => 190, 'cost' => 90, 'min' => 20, 'reorder' => 40, 'qty' => 250],
['sku' => 'EL-003', 'name' => 'Bluetooth Speaker', 'cat' => 'electronics', 'uom' => 'pcs', 'price' => 1290, 'cost' => 780, 'min' => 5, 'reorder' => 10, 'qty' => 45],
['sku' => 'EL-004', 'name' => 'Power Bank 10000mAh', 'cat' => 'electronics', 'uom' => 'pcs', 'price' => 890, 'cost' => 520, 'min' => 8, 'reorder' => 15, 'qty' => 60],
['sku' => 'OF-001', 'name' => 'A4 Paper Ream', 'cat' => 'office-supplies', 'uom' => 'ream', 'price' => 120, 'cost' => 75, 'min' => 30, 'reorder' => 60, 'qty' => 300],
['sku' => 'OF-002', 'name' => 'Ballpoint Pen (Box of 12)', 'cat' => 'office-supplies', 'uom' => 'box', 'price' => 85, 'cost' => 45, 'min' => 15, 'reorder' => 30, 'qty' => 150],
['sku' => 'OF-003', 'name' => 'Stapler', 'cat' => 'office-supplies', 'uom' => 'pcs', 'price' => 65, 'cost' => 35, 'min' => 10, 'reorder' => 20, 'qty' => 80],
['sku' => 'OF-004', 'name' => 'Sticky Notes Pack', 'cat' => 'office-supplies', 'uom' => 'pack', 'price' => 45, 'cost' => 22, 'min' => 20, 'reorder' => 40, 'qty' => 200],
['sku' => 'BV-001', 'name' => 'Bottled Water 600ml (Pack of 12)', 'cat' => 'beverages', 'uom' => 'pack', 'price' => 72, 'cost' => 48, 'min' => 25, 'reorder' => 50, 'qty' => 180],
['sku' => 'BV-002', 'name' => 'Instant Coffee 3-in-1 (Box of 30)', 'cat' => 'beverages', 'uom' => 'box', 'price' => 145, 'cost' => 98, 'min' => 15, 'reorder' => 30, 'qty' => 90],
['sku' => 'BV-003', 'name' => 'Green Tea Bags (Box of 25)', 'cat' => 'beverages', 'uom' => 'box', 'price' => 89, 'cost' => 55, 'min' => 12, 'reorder' => 24, 'qty' => 70],
['sku' => 'PK-001', 'name' => 'Corrugated Box Medium', 'cat' => 'packaging-materials', 'uom' => 'pcs', 'price' => 18, 'cost' => 9, 'min' => 100, 'reorder' => 200, 'qty' => 500],
['sku' => 'PK-002', 'name' => 'Bubble Wrap Roll', 'cat' => 'packaging-materials', 'uom' => 'roll', 'price' => 250, 'cost' => 160, 'min' => 10, 'reorder' => 20, 'qty' => 40],
['sku' => 'PK-003', 'name' => 'Packing Tape', 'cat' => 'packaging-materials', 'uom' => 'pcs', 'price' => 35, 'cost' => 18, 'min' => 40, 'reorder' => 80, 'qty' => 220],
];
foreach ($product_defs as $p) {
$sth = $pdo2->prepare("SELECT id FROM md_product WHERE company_id = :c AND sku = :s LIMIT 1");
$sth->execute([':c' => $company_id, ':s' => $p['sku']]);
if ($sth->fetchColumn()) {
skip("Product '{$p['sku']}' already exists");
continue;
}
dbTransaction($pdo2, function ($pdo2) use ($prodMgmt, $p, $category_ids, $logging) {
$prodMgmt->saveProduct([
'id' => 0,
'product_name' => $p['name'],
'sku' => $p['sku'],
'barcode' => '',
'uom' => $p['uom'],
'price' => $p['price'],
'cost_price' => $p['cost'],
'min_stock' => $p['min'],
'reorder_point' => $p['reorder'],
'category' => $category_ids[$p['cat']],
'description' => '',
'status' => 1,
], $logging, '');
});
ok("Created product {$p['sku']} — {$p['name']}");
}
// ─────────────────────────────────────────────────────────────────────────────
// 4. Contact types + contacts
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- Contacts ---\n";
$contactMgmt = new ContactManager($pdo2, $company_id);
$contact_type_defs = ['Customer', 'Supplier'];
$contact_type_ids = [];
foreach ($contact_type_defs as $name) {
$sth = $pdo2->prepare("SELECT id FROM md_contact_type WHERE company_id = :c AND contact_type = :n LIMIT 1");
$sth->execute([':c' => $company_id, ':n' => $name]);
$id = (int)($sth->fetchColumn() ?: 0);
if ($id) {
skip("Contact type '{$name}' already exists (id={$id})");
} else {
dbTransaction($pdo2, function ($pdo2) use ($contactMgmt, $name, $logging) {
$contactMgmt->saveContactType([
'id' => 0,
'contact_type' => $name,
'description' => '',
'status' => 1,
], $logging);
});
$sth = $pdo2->prepare("SELECT id FROM md_contact_type WHERE company_id = :c AND contact_type = :n LIMIT 1");
$sth->execute([':c' => $company_id, ':n' => $name]);
$id = (int)$sth->fetchColumn();
ok("Created contact type '{$name}' (id={$id})");
}
$contact_type_ids[$name] = $id;
}
$contact_defs = [
['name' => 'Siam Retail Co., Ltd.', 'type' => 'Customer', 'tax' => '0105561012341'],
['name' => 'Bangkok Mart', 'type' => 'Customer', 'tax' => '0105561012342'],
['name' => 'Northern Distribution Partners', 'type' => 'Customer', 'tax' => '0105561012343'],
['name' => 'Chao Phraya Trading', 'type' => 'Customer', 'tax' => '0105561012344'],
['name' => 'Global Parts Supply Co., Ltd.', 'type' => 'Supplier', 'tax' => '0105561098761'],
['name' => 'Thai Packaging Industries', 'type' => 'Supplier', 'tax' => '0105561098762'],
['name' => 'Asia Electronics Wholesale', 'type' => 'Supplier', 'tax' => '0105561098763'],
];
$supplier_ids = [];
$customer_ids = [];
foreach ($contact_defs as $c) {
$sth = $pdo2->prepare("SELECT id FROM md_contact WHERE company_id = :c AND contact_name = :n LIMIT 1");
$sth->execute([':c' => $company_id, ':n' => $c['name']]);
$id = (int)($sth->fetchColumn() ?: 0);
if ($id) {
skip("Contact '{$c['name']}' already exists (id={$id})");
} else {
dbTransaction($pdo2, function ($pdo2) use ($contactMgmt, $c, $contact_type_ids, $logging) {
$contactMgmt->saveContact([
'id' => 0,
'contact_name' => $c['name'],
'tax_id' => $c['tax'],
'organization' => $c['name'],
'branch' => 'สำนักงานใหญ่',
'contact_type' => $contact_type_ids[$c['type']],
'billing_address' => '99 Business Rd, Bangkok 10110, Thailand',
'shipping_location' => 'Head Office',
'shipping_address' => '99 Business Rd, Bangkok 10110, Thailand',
'remark' => '',
'status' => 1,
], $logging, '');
});
$sth = $pdo2->prepare("SELECT id FROM md_contact WHERE company_id = :c AND contact_name = :n LIMIT 1");
$sth->execute([':c' => $company_id, ':n' => $c['name']]);
$id = (int)$sth->fetchColumn();
ok("Created contact '{$c['name']}' ({$c['type']}, id={$id})");
}
if ($c['type'] === 'Supplier') {
$supplier_ids[] = $id;
} else {
$customer_ids[] = $id;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// 5. Opening stock — receive every product into Main Warehouse bins, approved
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- Opening stock (Main Warehouse) ---\n";
$stockMgmt = new StockManager($pdo2, $company_id);
$sth = $pdo2->prepare("SELECT COUNT(*) FROM `td_stock_{$main_wh_id}` WHERE company_id = :c AND type = 'in'");
$sth->execute([':c' => $company_id]);
if ((int)$sth->fetchColumn() > 0) {
skip("Opening stock already exists in Main Warehouse — skipping stock-in step");
} else {
$bin_index = 1;
foreach ($product_defs as $p) {
$bin = 'A-' . $bin_index;
$bin_index++;
$supplier_id = $supplier_ids[array_rand($supplier_ids)];
$uuid = bin2hex(random_bytes(16));
dbTransaction($pdo2, function ($pdo2) use ($stockMgmt, $whMgmt, $main_wh_id, $p, $bin, $supplier_id, $logging, $uuid) {
$stock_id = $stockMgmt->saveStockIn([
'id' => 0,
'warehouse' => $main_wh_id,
'product_sku' => $p['sku'],
'quantity' => $p['qty'],
'price' => $p['cost'],
'zone' => $bin,
'aisle' => $bin,
'bin' => $bin,
'contact_id' => $supplier_id,
'description' => 'Opening stock (demo seed)',
], $logging, $uuid);
$stockMgmt->approveStock($stock_id, $main_wh_id, 'in', $whMgmt);
});
ok("Received {$p['qty']} {$p['uom']} of {$p['sku']} into bin {$bin}");
}
}
// ─────────────────────────────────────────────────────────────────────────────
// 6. Restock — later replenishment receipts from suppliers, into fresh bins
// (separate batch from opening stock, so the Stock In list shows more than
// one receiving event). Bin is resolved dynamically (first free bin in the
// target warehouse) rather than hardcoded, so this stays correct regardless
// of which bins earlier runs / other seed steps have already taken.
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- Restock (additional stock-in) ---\n";
$restock_marker = 'Restock (demo seed)';
$restock_defs = [
['warehouse' => $main_wh_id, 'warehouse_name' => 'Main Warehouse', 'sku' => 'EL-001', 'qty' => 80],
['warehouse' => $main_wh_id, 'warehouse_name' => 'Main Warehouse', 'sku' => 'OF-001', 'qty' => 200],
['warehouse' => $bangna_wh_id, 'warehouse_name' => 'Bangna Distribution Center', 'sku' => 'BV-001', 'qty' => 120],
['warehouse' => $main_wh_id, 'warehouse_name' => 'Main Warehouse', 'sku' => 'EL-002', 'qty' => 100],
['warehouse' => $main_wh_id, 'warehouse_name' => 'Main Warehouse', 'sku' => 'PK-001', 'qty' => 150],
['warehouse' => $bangna_wh_id, 'warehouse_name' => 'Bangna Distribution Center', 'sku' => 'BV-002', 'qty' => 60],
['warehouse' => $bangna_wh_id, 'warehouse_name' => 'Bangna Distribution Center', 'sku' => 'OF-002', 'qty' => 90],
];
foreach ($restock_defs as $r) {
$p = findProductDef($product_defs, $r['sku']);
$warehouse_id = $r['warehouse'];
$sth = $pdo2->prepare(
"SELECT COUNT(*) FROM `td_stock_{$warehouse_id}`
WHERE company_id = :c AND type = 'in' AND description = :d AND product_sku = :s"
);
$sth->execute([':c' => $company_id, ':d' => $restock_marker, ':s' => $r['sku']]);
if ((int)$sth->fetchColumn() > 0) {
skip("Restock of {$r['sku']} into '{$r['warehouse_name']}' already exists");
continue;
}
$bin = findFreeBin($pdo2, $company_id, $warehouse_id);
$supplier_id = $supplier_ids[array_rand($supplier_ids)];
$uuid = bin2hex(random_bytes(16));
dbTransaction($pdo2, function ($pdo2) use ($stockMgmt, $whMgmt, $warehouse_id, $p, $r, $bin, $supplier_id, $logging, $uuid, $restock_marker) {
$stock_id = $stockMgmt->saveStockIn([
'id' => 0,
'warehouse' => $warehouse_id,
'product_sku' => $r['sku'],
'quantity' => $r['qty'],
'price' => $p['cost'],
'zone' => $bin,
'aisle' => $bin,
'bin' => $bin,
'contact_id' => $supplier_id,
'description' => $restock_marker,
], $logging, $uuid);
$stockMgmt->approveStock($stock_id, $warehouse_id, 'in', $whMgmt);
});
ok("Received {$r['qty']} {$p['uom']} of {$r['sku']} into bin {$bin} ({$r['warehouse_name']})");
}
// ─────────────────────────────────────────────────────────────────────────────
// 7. Stock-out — ship product out to customers.
//
// saveStockOut() always takes the *entire* quantity sitting in the source
// bin (there is no partial-out parameter), so to produce a stock-out of a
// specific, controlled quantity we first receive a small dedicated batch
// into a fresh bin, approve it, then immediately move that whole batch out
// again. This leaves the opening-stock / restock balances untouched while
// still creating a standalone, realistic outbound movement.
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- Stock-out (Main Warehouse) ---\n";
$dispatch_in_marker = 'Batch for dispatch (demo seed)';
$dispatch_out_marker = 'Direct dispatch (demo seed)';
$stockout_defs = [
['sku' => 'PK-003', 'qty' => 25, 'customer' => 'Siam Retail Co., Ltd.'],
['sku' => 'OF-003', 'qty' => 15, 'customer' => 'Bangkok Mart'],
['sku' => 'EL-004', 'qty' => 10, 'customer' => 'Chao Phraya Trading'],
['sku' => 'BV-002', 'qty' => 20, 'customer' => 'Northern Distribution Partners'],
['sku' => 'PK-001', 'qty' => 40, 'customer' => 'Siam Retail Co., Ltd.'],
['sku' => 'EL-002', 'qty' => 30, 'customer' => 'Bangkok Mart'],
];
foreach ($stockout_defs as $so) {
$p = findProductDef($product_defs, $so['sku']);
$sth = $pdo2->prepare(
"SELECT COUNT(*) FROM `td_stock_{$main_wh_id}`
WHERE company_id = :c AND type = 'out' AND description = :d AND product_sku = :s"
);
$sth->execute([':c' => $company_id, ':d' => $dispatch_out_marker, ':s' => $so['sku']]);
if ((int)$sth->fetchColumn() > 0) {
skip("Dispatch of {$so['sku']} already exists");
continue;
}
$sth = $pdo2->prepare("SELECT id FROM md_contact WHERE company_id = :c AND contact_name = :n LIMIT 1");
$sth->execute([':c' => $company_id, ':n' => $so['customer']]);
$customer_id = (int)$sth->fetchColumn();
$sku = $so['sku'];
$qty = $so['qty'];
$cost = $p['cost'];
$uom = $p['uom'];
dbTransaction($pdo2, function ($pdo2) use (
$stockMgmt, $whMgmt, $company_id, $main_wh_id, $sku, $qty, $cost, $customer_id,
$logging, $dispatch_in_marker, $dispatch_out_marker
) {
$bin = findFreeBin($pdo2, $company_id, $main_wh_id);
$in_uuid = bin2hex(random_bytes(16));
$stock_id = $stockMgmt->saveStockIn([
'id' => 0,
'warehouse' => $main_wh_id,
'product_sku' => $sku,
'quantity' => $qty,
'price' => $cost,
'zone' => $bin,
'aisle' => $bin,
'bin' => $bin,
'contact_id' => $customer_id,
'description' => $dispatch_in_marker,
], $logging, $in_uuid);
$stockMgmt->approveStock($stock_id, $main_wh_id, 'in', $whMgmt);
$out_uuid = bin2hex(random_bytes(16));
$out_id = $stockMgmt->saveStockOut([
'id' => 0,
'warehouse' => $main_wh_id,
'product_sku' => $sku,
'zone' => $bin,
'aisle' => $bin,
'bin' => $bin,
'contact_id' => $customer_id,
'description' => $dispatch_out_marker,
], $logging, $out_uuid);
$stockMgmt->approveStock($out_id, $main_wh_id, 'out', $whMgmt);
});
ok("Shipped {$qty} {$uom} of {$sku} to {$so['customer']}");
}
// ─────────────────────────────────────────────────────────────────────────────
// 8. Stock-transfer — move product from Main Warehouse to Bangna Distribution
// Center. Same batch-then-move approach as stock-out above: receive a
// dedicated small batch into a fresh Main Warehouse bin, then transfer that
// whole batch to a fresh Bangna bin, so opening-stock / restock balances
// in Main Warehouse are left untouched.
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- Stock-transfer (Main Warehouse -> Bangna Distribution Center) ---\n";
$transfer_in_marker = 'Batch for transfer (demo seed)';
$transfer_out_marker = 'Transfer to Bangna (demo seed)';
$transfer_defs = [
['sku' => 'BV-001', 'qty' => 30],
['sku' => 'PK-002', 'qty' => 12],
['sku' => 'OF-004', 'qty' => 20],
['sku' => 'EL-003', 'qty' => 15],
['sku' => 'OF-002', 'qty' => 25],
['sku' => 'PK-001', 'qty' => 50],
];
foreach ($transfer_defs as $t) {
$p = findProductDef($product_defs, $t['sku']);
$sth = $pdo2->prepare(
"SELECT COUNT(*) FROM `td_stock_{$main_wh_id}`
WHERE company_id = :c AND type = 'transfer' AND description = :d
AND product_sku = :s AND `out` > 0"
);
$sth->execute([':c' => $company_id, ':d' => $transfer_out_marker, ':s' => $t['sku']]);
if ((int)$sth->fetchColumn() > 0) {
skip("Transfer of {$t['sku']} to Bangna Distribution Center already exists");
continue;
}
$sku = $t['sku'];
$qty = $t['qty'];
$cost = $p['cost'];
$uom = $p['uom'];
dbTransaction($pdo2, function ($pdo2) use (
$stockMgmt, $whMgmt, $company_id, $main_wh_id, $bangna_wh_id, $sku, $qty, $cost,
$logging, $transfer_in_marker, $transfer_out_marker
) {
$from_bin = findFreeBin($pdo2, $company_id, $main_wh_id);
$in_uuid = bin2hex(random_bytes(16));
$stock_id = $stockMgmt->saveStockIn([
'id' => 0,
'warehouse' => $main_wh_id,
'product_sku' => $sku,
'quantity' => $qty,
'price' => $cost,
'zone' => $from_bin,
'aisle' => $from_bin,
'bin' => $from_bin,
'contact_id' => 0,
'description' => $transfer_in_marker,
], $logging, $in_uuid);
$stockMgmt->approveStock($stock_id, $main_wh_id, 'in', $whMgmt);
$to_bin = findFreeBin($pdo2, $company_id, $bangna_wh_id);
$transfer_uuid = bin2hex(random_bytes(16));
$transfer_id = $stockMgmt->saveStockTransfer([
'id' => 0,
'warehouse_from' => $main_wh_id,
'zone_from' => $from_bin,
'aisle_from' => $from_bin,
'bin_from' => $from_bin,
'warehouse_to' => $bangna_wh_id,
'zone_to' => $to_bin,
'aisle_to' => $to_bin,
'bin_to' => $to_bin,
'product_sku' => $sku,
'contact_id' => 0,
'description' => $transfer_out_marker,
], $logging, $transfer_uuid);
$stockMgmt->approveStock($transfer_id, $main_wh_id, 'transfer', $whMgmt);
});
ok("Transferred {$qty} {$uom} of {$sku} from Main Warehouse to Bangna Distribution Center");
}
// ─────────────────────────────────────────────────────────────────────────────
echo "\n\033[1m=== Done ===\033[0m\n\n";
echo "Log in at /login/index.php with:\n";
echo " Username: {$demo_username}\n";
echo " Password: {$demo_password}\n";
echo " (No OTP step — the demo company has no SMTP configured.)\n\n";
+134
View File
@@ -0,0 +1,134 @@
<?php
/**
* Demo Data Seeder — additional sales orders
* Run once after demo_seed.php + demo_seed_transactions.php: php demo_seed_more_orders.php
*
* demo_seed_transactions.php only creates 1 sales order (via the quotation ->
* order -> invoice cycle). This adds 4 more direct orders (no quotation) to
* give the Orders list page realistic volume and status variety:
* - 2 left in Draft (status=0) — never confirmed
* - 2 Confirmed (status=1) — stock-out auto-approved via FIFO bin selection
*
* All writes go through OrderManager (the same class the app itself uses),
* wrapped in dbTransaction(). Safe to re-run: guarded by order.notes markers.
*/
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit('Run via CLI only: php demo_seed_more_orders.php');
}
$_SESSION = [];
require __DIR__ . '/app/config.php';
require __DIR__ . '/app/dbconn.php';
require __DIR__ . '/app/assets/utils/db_helpers.php';
require __DIR__ . '/app/assets/utils/classes/WarehouseManager.php';
require __DIR__ . '/app/assets/utils/classes/StockManager.php';
require __DIR__ . '/app/assets/utils/classes/OrderManager.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 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,
'quantity' => $qty,
'unit_price' => $unit_price,
'total_price' => $total_price,
'tax_amount' => $tax_amount,
'tax_rate' => $tax_rate,
];
}
echo "\n\033[1m=== mn3wms Demo Data Seeder — more sales orders ===\033[0m\n\n";
// ── Resolve shared context (company, warehouse, products, customers, dept) ────
$sth = $pdo1->prepare("SELECT company_id FROM company_list WHERE channel_name = 'demo' LIMIT 1");
$sth->execute();
$company_id = (int)$sth->fetchColumn();
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();
$sth = $pdo2->prepare("SELECT id FROM md_warehouse WHERE company_id = :c AND warehouse_name = 'Main Warehouse' LIMIT 1");
$sth->execute([':c' => $company_id]);
$main_wh = (int)$sth->fetchColumn();
$sth = $pdo2->prepare("SELECT id FROM md_department WHERE company_id = :c AND dept_code = 'SALES' LIMIT 1");
$sth->execute([':c' => $company_id]);
$sales_dept_id = (int)$sth->fetchColumn();
if (!$sales_dept_id) { exit("ERROR: SALES department not found — run demo_seed_transactions.php first.\n"); }
$sth = $pdo2->prepare("SELECT sku, price FROM md_product WHERE company_id = :c");
$sth->execute([':c' => $company_id]);
$products = $sth->fetchAll(PDO::FETCH_ASSOC | PDO::FETCH_UNIQUE);
$sth = $pdo2->prepare("SELECT id, contact_name FROM md_contact WHERE company_id = :c AND contact_type = 1 ORDER BY id");
$sth->execute([':c' => $company_id]);
$customers = $sth->fetchAll(PDO::FETCH_ASSOC);
$customer_ids = array_column($customers, 'id');
$logging = ['user_id' => $owner_user_id, 'dt' => date('Y-m-d H:i:s'), 'login' => null, 'action' => 'seed_more_orders'];
$orderMgmt = new OrderManager($pdo2, $company_id);
// ── Order definitions ──────────────────────────────────────────────────────
$order_defs = [
['marker' => 'Demo seed extra order 1', 'customer_idx' => 1, 'confirm' => false,
'items' => [['sku' => 'EL-001', 'qty' => 10], ['sku' => 'PK-002', 'qty' => 5]]],
['marker' => 'Demo seed extra order 2', 'customer_idx' => 2, 'confirm' => true,
'items' => [['sku' => 'BV-003', 'qty' => 20], ['sku' => 'OF-002', 'qty' => 8]]],
['marker' => 'Demo seed extra order 3', 'customer_idx' => 3, 'confirm' => true,
'items' => [['sku' => 'EL-004', 'qty' => 5], ['sku' => 'PK-003', 'qty' => 30]]],
['marker' => 'Demo seed extra order 4', 'customer_idx' => 0, 'confirm' => false,
'items' => [['sku' => 'OF-004', 'qty' => 10], ['sku' => 'BV-001', 'qty' => 15]]],
];
echo "--- Sales orders ---\n";
foreach ($order_defs as $d) {
$sth = $pdo2->prepare("SELECT id, status FROM td_order WHERE company_id = :c AND notes = :n LIMIT 1");
$sth->execute([':c' => $company_id, ':n' => $d['marker']]);
$existing = $sth->fetch(PDO::FETCH_ASSOC);
if ($existing) {
skip("{$d['marker']} already exists (id={$existing['id']}, status={$existing['status']})");
continue;
}
$customer_id = $customer_ids[$d['customer_idx']];
$items = array_map(
fn($it) => buildLineItem($products, $it['sku'], $it['qty']) + ['warehouse_id' => $main_wh],
$d['items']
);
$order_id = dbTransaction($pdo2, function ($pdo2) use ($orderMgmt, $customer_id, $sales_dept_id, $items, $logging, $d) {
// No 'source' -> direct WMS order -> starts as Draft (status=0) immediately.
$oid = $orderMgmt->saveOrder([
'id' => 0, 'contact_id' => $customer_id, 'department_id' => $sales_dept_id,
'order_date' => date('Y-m-d'), 'items' => $items, 'discount' => 0,
'shipping_fee' => 0, 'notes' => $d['marker'],
], $logging);
if ($d['confirm']) {
$orderMgmt->confirmOrder($oid, bin2hex(random_bytes(16)), $logging, true); // auto-approve stock-out
}
return $oid;
});
$state = $d['confirm'] ? 'confirmed (stock-out auto-approved)' : 'left as draft';
ok("Created order {$d['marker']} (id={$order_id}), {$state}");
}
echo "\n\033[1m=== Done ===\033[0m\n\n";
+901
View File
@@ -0,0 +1,901 @@
<?php
/**
* Demo Transaction Data Seeder
* Run once after demo_seed.php: php demo_seed_transactions.php
*
* Extends the base demo data (demo_seed.php: company, warehouses, products,
* contacts, opening stock) with transactional data covering the rest of the
* app's features:
*
* 1. Additional stock-in replenishment (restock events)
* 2. Stock-out (direct dispatch) + stock transfer (Main -> Bangna)
* 3. Chart of accounts, departments, GL posting formulas
* 4. Sales cycle: quotation -> sales order -> invoice -> GL post
* 5. AR: receipt billing -> receipt -> GL post
* 6. Purchasing cycle: purchase request -> PO -> receive -> purchase invoice -> GL post
* 7. AP: payment billing -> payment -> GL post
* 8. Supplier return (confirmed, restocks reversed)
* 9. Customer return (draft only — see note below)
* 10. Barcode labels for a handful of products
*
* Every write goes through the same Manager classes + engine-file patterns
* the app itself uses (dbTransaction wrapping, GlManager posting exactly as
* order/api/engine/issue_invoice.php and finance/api/engine/manage_receipt.php
* do it), so the data matches what the real UI would have produced.
*
* NOTE — customer returns: ReturnManager::confirmReturn() reads zone/aisle/bin
* from td_return_item rows, but td_return_item has no zone/aisle/bin columns
* (see setup.php) and no engine file back-fills them before calling
* confirmReturn(). Confirming ANY customer return in this app currently
* throws "Location - - - does not exist" from WarehouseManager::occupyBin().
* This script creates one customer return and leaves it in draft status —
* confirming it is not possible until that's fixed.
*
* Safe to re-run: every insert is guarded by an existence check.
*/
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit('Run via CLI only: php demo_seed_transactions.php');
}
$_SESSION = [];
require_once __DIR__ . '/app/config.php';
require_once __DIR__ . '/app/dbconn.php';
require_once __DIR__ . '/app/assets/utils/db_helpers.php';
require_once __DIR__ . '/app/assets/utils/classes/WarehouseManager.php';
require_once __DIR__ . '/app/assets/utils/classes/StockManager.php';
require_once __DIR__ . '/app/assets/utils/classes/QuotationManager.php';
require_once __DIR__ . '/app/assets/utils/classes/OrderManager.php';
require_once __DIR__ . '/app/assets/utils/classes/InvoiceManager.php';
require_once __DIR__ . '/app/assets/utils/classes/ReturnManager.php';
require_once __DIR__ . '/app/assets/utils/classes/PurchaseRequestManager.php';
require_once __DIR__ . '/app/assets/utils/classes/PurchaseOrderManager.php';
require_once __DIR__ . '/app/assets/utils/classes/SupplierReturnManager.php';
require_once __DIR__ . '/app/assets/utils/classes/ReceiptBillingManager.php';
require_once __DIR__ . '/app/assets/utils/classes/ReceiptManager.php';
require_once __DIR__ . '/app/assets/utils/classes/PaymentBillingManager.php';
require_once __DIR__ . '/app/assets/utils/classes/PaymentManager.php';
require_once __DIR__ . '/app/assets/utils/classes/BarcodeManager.php';
require_once __DIR__ . '/app/assets/utils/classes_ac/ChartOfAccounts.php';
require_once __DIR__ . '/app/assets/utils/classes_ac/DepartmentManager.php';
require_once __DIR__ . '/app/assets/utils/classes_ac/AccountFormulaManager.php';
require_once __DIR__ . '/app/assets/utils/classes_ac/PostingWindowGuard.php';
require_once __DIR__ . '/app/assets/utils/classes_ac/GlManager.php';
require_once __DIR__ . '/app/assets/utils/classes_ac/posting/BasePosting.php';
require_once __DIR__ . '/app/assets/utils/classes_ac/posting/InvoicePosting.php';
require_once __DIR__ . '/app/assets/utils/classes_ac/posting/PurchaseInvoicePosting.php';
require_once __DIR__ . '/app/assets/utils/classes_ac/posting/ReceiptPosting.php';
require_once __DIR__ . '/app/assets/utils/classes_ac/posting/PaymentPosting.php';
function info(string $msg): void { echo "\033[36m •\033[0m {$msg}\n"; }
function ok(string $msg): void { echo "\033[32m ✓\033[0m {$msg}\n"; }
function skip(string $msg): void { echo "\033[33m –\033[0m {$msg}\n"; }
function warn(string $msg): void { echo "\033[31m !\033[0m {$msg}\n"; }
echo "\n\033[1m=== mn3wms Demo Transaction Seeder ===\033[0m\n\n";
// ─────────────────────────────────────────────────────────────────────────────
// 0. Resolve company / owner / warehouses / contacts seeded by demo_seed.php
// ─────────────────────────────────────────────────────────────────────────────
$sth = $pdo1->prepare("SELECT company_id FROM company_list WHERE channel_name = 'demo' LIMIT 1");
$sth->execute();
$company_id = (int)($sth->fetchColumn() ?: 0);
if (!$company_id) exit("ERROR: demo company not found — run demo_seed.php first.\n");
$sth = $pdo1->prepare("SELECT user_id FROM user WHERE username = 'admin' LIMIT 1");
$sth->execute();
$owner_user_id = (int)($sth->fetchColumn() ?: 0);
if (!$owner_user_id) exit("ERROR: demo owner user not found — run demo_seed.php first.\n");
$sth = $pdo2->prepare("SELECT id, warehouse_name FROM md_warehouse WHERE company_id = :c ORDER BY id");
$sth->execute([':c' => $company_id]);
$warehouses = $sth->fetchAll(PDO::FETCH_KEY_PAIR); // id => name
if (count($warehouses) < 2) exit("ERROR: expected 2 warehouses — run demo_seed.php first.\n");
$wh_ids = array_keys($warehouses);
$main_wh = $wh_ids[0]; // Main Warehouse
$bangna_wh = $wh_ids[1]; // Bangna Distribution Center
$sth = $pdo2->prepare("SELECT id, contact_name, contact_type FROM md_contact WHERE company_id = :c ORDER BY id");
$sth->execute([':c' => $company_id]);
$contacts = $sth->fetchAll(PDO::FETCH_ASSOC);
if (count($contacts) < 7) exit("ERROR: expected 7 contacts — run demo_seed.php first.\n");
$customer_ids = array_column(array_filter($contacts, fn($c) => (int)$c['contact_type'] === 1), 'id');
$supplier_ids = array_column(array_filter($contacts, fn($c) => (int)$c['contact_type'] === 2), 'id');
$customer_ids = array_values($customer_ids);
$supplier_ids = array_values($supplier_ids);
$sth = $pdo2->prepare("SELECT sku, uom, cost_price, price FROM md_product WHERE company_id = :c ORDER BY id");
$sth->execute([':c' => $company_id]);
$products = $sth->fetchAll(PDO::FETCH_ASSOC | PDO::FETCH_UNIQUE);
if (count($products) < 14) exit("ERROR: expected 14 products — run demo_seed.php first.\n");
$logging = ['user_id' => $owner_user_id, 'dt' => date('Y-m-d H:i:s'), 'login' => null, 'action' => 'seed_transactions'];
$whMgmt = new WarehouseManager($pdo2, $company_id);
$stockMgmt = new StockManager($pdo2, $company_id);
/** Find the next unused simple-location bin label "A-N" for a warehouse. */
function nextFreeBin(PDO $pdo2, int $company_id, int $warehouse_id): string {
$sth = $pdo2->prepare(
"SELECT bin FROM md_bin WHERE company_id = :c AND warehouse = :w AND product_sku IS NULL
ORDER BY CAST(SUBSTRING(bin, 3) AS UNSIGNED) ASC LIMIT 1"
);
$sth->execute([':c' => $company_id, ':w' => $warehouse_id]);
$bin = $sth->fetchColumn();
if (!$bin) throw new Exception("No free bin available in warehouse {$warehouse_id}.");
return $bin;
}
// ─────────────────────────────────────────────────────────────────────────────
// 1. Additional stock-in replenishment (restock events)
// ─────────────────────────────────────────────────────────────────────────────
echo "--- Restock (additional stock-in) ---\n";
$restock_defs = [
['sku' => 'EL-001', 'warehouse' => $main_wh, 'qty' => 40, 'supplier_idx' => 0],
['sku' => 'OF-001', 'warehouse' => $main_wh, 'qty' => 60, 'supplier_idx' => 1],
['sku' => 'BV-002', 'warehouse' => $bangna_wh, 'qty' => 35, 'supplier_idx' => 2],
];
foreach ($restock_defs as $r) {
$sth = $pdo2->prepare(
"SELECT COUNT(*) FROM `td_stock_{$r['warehouse']}`
WHERE company_id = :c AND product_sku = :sku AND type = 'in' AND description = 'Restock (demo seed)'"
);
$sth->execute([':c' => $company_id, ':sku' => $r['sku']]);
if ((int)$sth->fetchColumn() > 0) {
skip("Restock for {$r['sku']} in warehouse {$r['warehouse']} already exists");
continue;
}
$bin = nextFreeBin($pdo2, $company_id, $r['warehouse']);
$supplier_id = $supplier_ids[$r['supplier_idx']];
$uuid = bin2hex(random_bytes(16));
$p = $products[$r['sku']];
dbTransaction($pdo2, function ($pdo2) use ($stockMgmt, $whMgmt, $r, $bin, $supplier_id, $logging, $uuid, $p) {
$stock_id = $stockMgmt->saveStockIn([
'id' => 0, 'warehouse' => $r['warehouse'], 'product_sku' => $r['sku'],
'quantity' => $r['qty'], 'price' => $p['cost_price'],
'zone' => $bin, 'aisle' => $bin, 'bin' => $bin,
'contact_id' => $supplier_id, 'description' => 'Restock (demo seed)',
], $logging, $uuid);
$stockMgmt->approveStock($stock_id, $r['warehouse'], 'in', $whMgmt);
});
ok("Restocked {$r['qty']} {$p['uom']} of {$r['sku']} into {$warehouses[$r['warehouse']]} bin {$bin}");
}
// ─────────────────────────────────────────────────────────────────────────────
// 2. Stock-out (direct dispatch) — dedicated batch in + full-bin out
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- Stock-out (direct dispatch) ---\n";
$dispatch_defs = [
['sku' => 'PK-003', 'warehouse' => $main_wh, 'qty' => 25, 'customer_idx' => 0],
['sku' => 'OF-003', 'warehouse' => $main_wh, 'qty' => 15, 'customer_idx' => 1],
];
foreach ($dispatch_defs as $d) {
$sth = $pdo2->prepare(
"SELECT COUNT(*) FROM `td_stock_{$d['warehouse']}`
WHERE company_id = :c AND product_sku = :sku AND type = 'out' AND description = 'Direct dispatch (demo seed)'"
);
$sth->execute([':c' => $company_id, ':sku' => $d['sku']]);
if ((int)$sth->fetchColumn() > 0) {
skip("Dispatch for {$d['sku']} already exists");
continue;
}
$bin = nextFreeBin($pdo2, $company_id, $d['warehouse']);
$customer_id = $customer_ids[$d['customer_idx']];
$p = $products[$d['sku']];
dbTransaction($pdo2, function ($pdo2) use ($stockMgmt, $whMgmt, $d, $bin, $customer_id, $logging, $p) {
// Receive a dedicated batch first (so we don't touch demo_seed.php's opening-stock bins)
$in_uuid = bin2hex(random_bytes(16));
$in_id = $stockMgmt->saveStockIn([
'id' => 0, 'warehouse' => $d['warehouse'], 'product_sku' => $d['sku'],
'quantity' => $d['qty'], 'price' => $p['cost_price'],
'zone' => $bin, 'aisle' => $bin, 'bin' => $bin,
'contact_id' => $customer_id, 'description' => 'Batch for dispatch (demo seed)',
], $logging, $in_uuid);
$stockMgmt->approveStock($in_id, $d['warehouse'], 'in', $whMgmt);
// Dispatch it straight out (saveStockOut takes the whole bin)
$out_uuid = bin2hex(random_bytes(16));
$out_id = $stockMgmt->saveStockOut([
'id' => 0, 'warehouse' => $d['warehouse'], 'product_sku' => $d['sku'],
'zone' => $bin, 'aisle' => $bin, 'bin' => $bin,
'contact_id' => $customer_id, 'description' => 'Direct dispatch (demo seed)',
], $logging, $out_uuid);
$stockMgmt->approveStock($out_id, $d['warehouse'], 'out', $whMgmt);
});
ok("Dispatched {$d['qty']} {$p['uom']} of {$d['sku']} from {$warehouses[$d['warehouse']]}");
}
// ─────────────────────────────────────────────────────────────────────────────
// 3. Stock transfer — Main Warehouse -> Bangna Distribution Center
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- Stock transfer (Main -> Bangna) ---\n";
$transfer_defs = [
['sku' => 'BV-001', 'qty' => 30],
['sku' => 'PK-002', 'qty' => 12],
];
foreach ($transfer_defs as $t) {
$sth = $pdo2->prepare(
"SELECT COUNT(*) FROM `td_stock_{$main_wh}`
WHERE company_id = :c AND product_sku = :sku AND type = 'transfer' AND description = 'Transfer to Bangna (demo seed)'"
);
$sth->execute([':c' => $company_id, ':sku' => $t['sku']]);
if ((int)$sth->fetchColumn() > 0) {
skip("Transfer for {$t['sku']} already exists");
continue;
}
// Bin allocation is independent of the transaction below — resolve both up front.
$from_bin = nextFreeBin($pdo2, $company_id, $main_wh);
$to_bin = nextFreeBin($pdo2, $company_id, $bangna_wh);
$p = $products[$t['sku']];
dbTransaction($pdo2, function ($pdo2) use ($stockMgmt, $whMgmt, $t, $from_bin, $to_bin, $main_wh, $bangna_wh, $logging, $p) {
// Receive a dedicated batch first (so we don't touch demo_seed.php's opening-stock bins)
$in_uuid = bin2hex(random_bytes(16));
$in_id = $stockMgmt->saveStockIn([
'id' => 0, 'warehouse' => $main_wh, 'product_sku' => $t['sku'],
'quantity' => $t['qty'], 'price' => $p['cost_price'],
'zone' => $from_bin, 'aisle' => $from_bin, 'bin' => $from_bin,
'contact_id' => 0, 'description' => 'Batch for transfer (demo seed)',
], $logging, $in_uuid);
$stockMgmt->approveStock($in_id, $main_wh, 'in', $whMgmt);
$tr_uuid = bin2hex(random_bytes(16));
$out_id = $stockMgmt->saveStockTransfer([
'id' => 0, 'warehouse_from' => $main_wh, 'warehouse_to' => $bangna_wh,
'product_sku' => $t['sku'],
'zone_from' => $from_bin, 'aisle_from' => $from_bin, 'bin_from' => $from_bin,
'zone_to' => $to_bin, 'aisle_to' => $to_bin, 'bin_to' => $to_bin,
'contact_id' => 0, 'description' => 'Transfer to Bangna (demo seed)',
], $logging, $tr_uuid);
$stockMgmt->approveStock($out_id, $main_wh, 'transfer', $whMgmt);
});
ok("Transferred {$t['qty']} {$p['uom']} of {$t['sku']}: {$warehouses[$main_wh]} bin {$from_bin} -> {$warehouses[$bangna_wh]} bin {$to_bin}");
}
// ─────────────────────────────────────────────────────────────────────────────
// 4. Chart of accounts + departments + GL posting formulas
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- Chart of accounts ---\n";
$coa = new ChartOfAccounts($pdo2, $company_id);
$account_defs = [
['code' => '1000', 'name' => 'Cash and Bank', 'type' => 'asset'],
['code' => '1150', 'name' => 'Input VAT', 'type' => 'asset'],
['code' => '1200', 'name' => 'Accounts Receivable', 'type' => 'asset'],
['code' => '2000', 'name' => 'Accounts Payable', 'type' => 'liability'],
['code' => '2100', 'name' => 'Output VAT', 'type' => 'liability'],
['code' => '4000', 'name' => 'Sales Revenue', 'type' => 'revenue'],
['code' => '5000', 'name' => 'Cost of Goods Sold', 'type' => 'expense'],
];
$account_ids = [];
foreach ($account_defs as $a) {
$existing = $coa->getByCode($a['code']);
if ($existing) {
skip("Account {$a['code']} — {$a['name']} already exists");
$account_ids[$a['code']] = (int)$existing['id'];
continue;
}
$id = $coa->create([
'account_code' => $a['code'], 'account_name' => $a['name'], 'account_type' => $a['type'],
'account_category' => '', 'parent_code' => null, 'is_posting' => 1, 'status' => 1,
]);
$account_ids[$a['code']] = $id;
ok("Created account {$a['code']} — {$a['name']} ({$a['type']})");
}
echo "\n--- Departments ---\n";
$deptMgmt = new DepartmentManager($pdo2, $company_id);
$dept_defs = [
['code' => 'SALES', 'name' => 'Sales'],
['code' => 'WH', 'name' => 'Warehouse Operations'],
];
$dept_ids = [];
foreach ($dept_defs as $d) {
$sth = $pdo2->prepare("SELECT id FROM md_department WHERE company_id = :c AND dept_code = :code LIMIT 1");
$sth->execute([':c' => $company_id, ':code' => $d['code']]);
$id = (int)($sth->fetchColumn() ?: 0);
if ($id) {
skip("Department {$d['code']} already exists (id={$id})");
} else {
$id = $deptMgmt->create(['dept_code' => $d['code'], 'dept_name' => $d['name'], 'description' => '', 'status' => 1]);
ok("Created department {$d['code']} — {$d['name']} (id={$id})");
}
$dept_ids[$d['code']] = $id;
}
echo "\n--- GL posting formulas ---\n";
$formulaMgmt = new AccountFormulaManager($pdo2, $company_id);
// document_type => [formula_name, items[ [drcr, account_code, amount_key, description] ]]
$formula_defs = [
'invoice' => [
'name' => 'Standard Sales Invoice',
'items' => [
['D', '1200', 'grand_total', 'Accounts Receivable'],
['C', '4000', 'total', 'Sales Revenue'],
['C', '2100', 'tax', 'Output VAT'],
],
],
'purchase_invoice' => [
'name' => 'Standard Purchase Invoice',
'items' => [
['D', '5000', 'total', 'Cost of Goods Sold'],
['D', '1150', 'tax', 'Input VAT'],
['C', '2000', 'grand_total', 'Accounts Payable'],
],
],
'receipt' => [
'name' => 'Standard Receipt',
'items' => [
['D', '1000', 'amount', 'Cash and Bank'],
['C', '1200', 'amount', 'Accounts Receivable'],
],
],
'payment' => [
'name' => 'Standard Payment',
'items' => [
['D', '2000', 'amount', 'Accounts Payable'],
['C', '1000', 'amount', 'Cash and Bank'],
],
],
];
$formula_ids = [];
foreach ($formula_defs as $doc_type => $def) {
$sth = $pdo2->prepare(
"SELECT id FROM md_account_formula WHERE company_id = :c AND document_type = :t AND is_default = 1 LIMIT 1"
);
$sth->execute([':c' => $company_id, ':t' => $doc_type]);
$id = (int)($sth->fetchColumn() ?: 0);
if ($id) {
skip("Default formula for '{$doc_type}' already exists (id={$id})");
} else {
$items = array_map(fn($i) => [
'drcr' => $i[0], 'account_code' => $i[1], 'amount_key' => $i[2], 'description' => $i[3],
], $def['items']);
$id = $formulaMgmt->save([
'id' => 0, 'formula_name' => $def['name'], 'document_type' => $doc_type,
'description' => '', 'is_default' => 1, 'status' => 1, 'items' => $items,
]);
ok("Created default GL formula for '{$doc_type}': {$def['name']} (id={$id})");
}
$formula_ids[$doc_type] = $id;
}
/** Post (or replace) GL for a document, mirroring order/api/engine/issue_invoice.php exactly. */
function postGl(PDO $pdo1, PDO $pdo2, int $company_id, string $doc_type, int $doc_id, string $posting_class): array {
require_once __DIR__ . "/app/assets/utils/classes_ac/posting/{$posting_class}.php";
$posting = new $posting_class($pdo2, $company_id);
$built = $posting->build($doc_id, null);
$guard = new PostingWindowGuard($pdo1, $company_id);
$gl = new GlManager($pdo2, $company_id, $guard);
$meta = ['journal_date' => $built['doc_date'] ?? null];
$existing = $gl->getBySource($doc_type, $doc_id);
$pdo2->beginTransaction();
if ($existing) {
$gl->replace($doc_type, $doc_id, $built['formula_id'], $built['period'], $built['lines'], $meta);
$pdo2->commit();
return ['action' => 'replaced', 'lines' => count($built['lines'])];
}
$gl->post($doc_type, $doc_id, $built['formula_id'], $built['period'], $built['lines'], $meta);
$pdo2->commit();
return ['action' => 'posted', 'lines' => count($built['lines'])];
}
/** Build a quotation/order/PR/PO line item with 7% VAT from a product SKU + qty. */
function buildLineItem(array $products, string $sku, float $qty): array {
$p = $products[$sku];
$unit_price = (float)$p['price'];
$total_price = round($qty * $unit_price, 4);
$tax_rate = 7.0;
$tax_amount = round($total_price * $tax_rate / 100, 4);
return [
'product_sku' => $sku,
'product_name' => $sku, // demo_seed.php products keyed by SKU; name not needed for GL/report correctness
'quantity' => $qty,
'unit_price' => $unit_price,
'total_price' => $total_price,
'tax_amount' => $tax_amount,
'tax_rate' => $tax_rate,
];
}
// ─────────────────────────────────────────────────────────────────────────────
// 5. Sales cycle: quotation -> sales order -> invoice -> GL post
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- Sales cycle (quotation -> order -> invoice) ---\n";
$quotMgmt = new QuotationManager($pdo2, $company_id);
$orderMgmt = new OrderManager($pdo2, $company_id);
$invMgmt = new InvoiceManager($pdo2, $company_id);
// NOTE: OrderManager::linkQuotationToOrder() is broken — it writes to a
// td_quotation.order_id column that does not exist in the schema (see
// setup.php's td_quotation definition). We never call it. The real link
// between a quotation and its order is source='quotation'/source_id=$qid on
// td_order, which QuotationManager::getById() already joins on — that's what
// this script relies on too, both to link and to look the link back up.
$sth = $pdo2->prepare("SELECT id FROM td_quotation WHERE company_id = :c AND notes = 'Demo seed sales cycle' LIMIT 1");
$sth->execute([':c' => $company_id]);
$sales_quotation_id = (int)($sth->fetchColumn() ?: 0);
if ($sales_quotation_id) {
skip("Demo sales-cycle quotation already exists (id={$sales_quotation_id})");
} else {
$sales_items = [
buildLineItem($products, 'EL-002', 15),
buildLineItem($products, 'OF-001', 25),
];
$sales_quotation_id = dbTransaction($pdo2, function ($pdo2) use ($quotMgmt, $customer_ids, $dept_ids, $sales_items, $logging) {
$qid = $quotMgmt->save([
'id' => 0, 'contact_id' => $customer_ids[0], 'department_id' => $dept_ids['SALES'],
'quotation_date' => date('Y-m-d'), 'valid_until' => date('Y-m-d', strtotime('+30 days')),
'items' => $sales_items, 'discount' => 0, 'notes' => 'Demo seed sales cycle',
], $logging);
$quotMgmt->updateStatus($qid, 'send', $logging);
$quotMgmt->updateStatus($qid, 'accept', $logging);
return $qid;
});
ok("Created + accepted quotation (id={$sales_quotation_id})");
}
$sth = $pdo2->prepare("SELECT id FROM td_order WHERE company_id = :c AND source = 'quotation' AND source_id = :qid LIMIT 1");
$sth->execute([':c' => $company_id, ':qid' => $sales_quotation_id]);
$sales_order_id = (int)($sth->fetchColumn() ?: 0);
if ($sales_order_id) {
skip("Order converted from demo quotation already exists (id={$sales_order_id})");
} else {
// Re-derive line items from the quotation itself (works whether the
// quotation was just created above or already existed from a prior run).
$sth = $pdo2->prepare(
"SELECT item_id, product_sku, product_name, quantity, unit_price, total_price, tax_amount, tax_rate
FROM td_quotation_item WHERE quotation_id = :qid AND company_id = :c ORDER BY item_id"
);
$sth->execute([':qid' => $sales_quotation_id, ':c' => $company_id]);
$order_items = array_map(fn($it) => $it + ['warehouse_id' => $main_wh], $sth->fetchAll(PDO::FETCH_ASSOC));
$sales_order_id = dbTransaction($pdo2, function ($pdo2) use ($orderMgmt, $quotMgmt, $customer_ids, $dept_ids, $order_items, $sales_quotation_id, $logging) {
// Insert with source='quotation' -> starts as status=-2 (pending warehouse assignment)
$oid = $orderMgmt->saveOrder([
'id' => 0, 'source' => 'quotation', 'source_id' => $sales_quotation_id,
'contact_id' => $customer_ids[0], 'department_id' => $dept_ids['SALES'],
'order_date' => date('Y-m-d'), 'items' => $order_items, 'discount' => 0,
'shipping_fee' => 0, 'notes' => 'Demo seed sales cycle',
], $logging);
// Re-save with warehouse_id already set on every item -> auto-promotes -2 to draft (0)
$orderMgmt->saveOrder([
'id' => $oid, 'contact_id' => $customer_ids[0], 'department_id' => $dept_ids['SALES'],
'order_date' => date('Y-m-d'), 'items' => $order_items, 'discount' => 0,
'shipping_fee' => 0, 'notes' => 'Demo seed sales cycle',
], $logging);
$quotMgmt->incrementConvertedQty($sales_quotation_id, array_map(
fn($it) => ['item_id' => $it['item_id'], 'quantity' => $it['quantity']], $order_items
));
$orderMgmt->confirmOrder($oid, bin2hex(random_bytes(16)), $logging, true); // auto-approve stock-out
return $oid;
});
ok("Created + confirmed sales order from quotation (id={$sales_order_id}), stock-out auto-approved");
}
$sth = $pdo2->prepare("SELECT id, status FROM td_invoice WHERE company_id = :c AND order_id = :oid AND doc_type = 'invoice' LIMIT 1");
$sth->execute([':c' => $company_id, ':oid' => $sales_order_id]);
$inv_row = $sth->fetch(PDO::FETCH_ASSOC);
if ($inv_row) {
skip("Invoice for sales order already exists (id={$inv_row['id']})");
$sales_invoice_id = (int)$inv_row['id'];
} else {
$sales_invoice_id = dbTransaction($pdo2, function ($pdo2) use ($invMgmt, $sales_order_id, $logging) {
$iid = $invMgmt->createFromOrder($sales_order_id, $logging);
$invMgmt->issueInvoice($iid, $logging, date('Y-m-d', strtotime('+30 days')));
return $iid;
});
ok("Created + issued invoice from sales order (id={$sales_invoice_id})");
}
$existing_gl = (new GlManager($pdo2, $company_id))->getBySource('invoice', $sales_invoice_id);
if ($existing_gl) {
skip("GL already posted for invoice #{$sales_invoice_id}");
} else {
$res = postGl($pdo1, $pdo2, $company_id, 'invoice', $sales_invoice_id, 'InvoicePosting');
ok("GL {$res['action']} for invoice #{$sales_invoice_id} ({$res['lines']} lines)");
}
// ─────────────────────────────────────────────────────────────────────────────
// 6. AR: receipt billing -> receipt -> GL post
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- AR (receipt billing -> receipt) ---\n";
$receiptBillingMgmt = new ReceiptBillingManager($pdo2, $company_id);
$receiptMgmt = new ReceiptManager($pdo2, $company_id);
$sth = $pdo2->prepare("SELECT id FROM td_receipt_billing WHERE company_id = :c AND notes = 'Demo seed AR' LIMIT 1");
$sth->execute([':c' => $company_id]);
$receipt_billing_id = (int)($sth->fetchColumn() ?: 0);
if ($receipt_billing_id) {
skip("Demo receipt billing already exists (id={$receipt_billing_id})");
} else {
$sth = $pdo2->prepare("SELECT grand_total FROM td_invoice WHERE id = :id AND company_id = :c LIMIT 1");
$sth->execute([':id' => $sales_invoice_id, ':c' => $company_id]);
$invoice_total = (float)$sth->fetchColumn();
$receipt_billing_id = dbTransaction($pdo2, function ($pdo2) use ($receiptBillingMgmt, $customer_ids, $sales_invoice_id, $invoice_total, $logging) {
return $receiptBillingMgmt->createBilling([
'contact_id' => $customer_ids[0], 'billing_date' => date('Y-m-d'), 'notes' => 'Demo seed AR',
'allocations' => [['invoice_id' => $sales_invoice_id, 'amount' => $invoice_total]],
], $logging);
});
ok("Created receipt billing for invoice #{$sales_invoice_id} (id={$receipt_billing_id}), amount " . number_format($invoice_total, 2));
}
$sth = $pdo2->prepare("SELECT id FROM td_receipt WHERE company_id = :c AND receipt_billing_id = :bid LIMIT 1");
$sth->execute([':c' => $company_id, ':bid' => $receipt_billing_id]);
$receipt_id = (int)($sth->fetchColumn() ?: 0);
if ($receipt_id) {
skip("Receipt for billing #{$receipt_billing_id} already exists (id={$receipt_id})");
} else {
$sth = $pdo2->prepare("SELECT contact_id, amount FROM td_receipt_billing WHERE id = :id LIMIT 1");
$sth->execute([':id' => $receipt_billing_id]);
$billing = $sth->fetch(PDO::FETCH_ASSOC);
$receipt_id = dbTransaction($pdo2, function ($pdo2) use ($receiptMgmt, $billing, $receipt_billing_id, $logging) {
return $receiptMgmt->createReceipt([
'receipt_billing_id' => $receipt_billing_id, 'receipt_date' => date('Y-m-d'),
'payment_method' => 'bank_transfer', 'amount' => $billing['amount'], 'notes' => 'Demo seed AR',
], $logging);
});
ok("Created receipt #{$receipt_id} for billing #{$receipt_billing_id}, amount " . number_format($billing['amount'], 2));
}
$existing_gl = (new GlManager($pdo2, $company_id))->getBySource('receipt', $receipt_id);
if ($existing_gl) {
skip("GL already posted for receipt #{$receipt_id}");
} else {
$res = postGl($pdo1, $pdo2, $company_id, 'receipt', $receipt_id, 'ReceiptPosting');
ok("GL {$res['action']} for receipt #{$receipt_id} ({$res['lines']} lines)");
}
// ─────────────────────────────────────────────────────────────────────────────
// 7. Purchasing cycle: purchase request -> PO -> receive -> purchase invoice -> GL post
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- Purchasing cycle (request -> PO -> receipt -> invoice) ---\n";
$prMgmt = new PurchaseRequestManager($pdo2, $company_id);
$poMgmt = new PurchaseOrderManager($pdo2, $company_id);
$sth = $pdo2->prepare("SELECT id FROM td_purchase_request WHERE company_id = :c AND notes = 'Demo seed purchasing cycle' LIMIT 1");
$sth->execute([':c' => $company_id]);
$pr_id = (int)($sth->fetchColumn() ?: 0);
if ($pr_id) {
skip("Demo purchase request already exists (id={$pr_id})");
} else {
$pr_items = [
buildLineItem($products, 'EL-003', 20),
buildLineItem($products, 'PK-001', 100),
];
$pr_id = dbTransaction($pdo2, function ($pdo2) use ($prMgmt, $supplier_ids, $dept_ids, $pr_items, $logging) {
$rid = $prMgmt->save([
'id' => 0, 'contact_id' => $supplier_ids[0], 'department_id' => $dept_ids['WH'],
'request_date' => date('Y-m-d'), 'required_date' => date('Y-m-d', strtotime('+14 days')),
'items' => $pr_items, 'discount' => 0, 'shipping_fee' => 0, 'notes' => 'Demo seed purchasing cycle',
], $logging);
$prMgmt->updateStatus($rid, 'submit', $logging);
$prMgmt->updateStatus($rid, 'approve', $logging);
return $rid;
});
ok("Created + approved purchase request (id={$pr_id})");
}
$sth = $pdo2->prepare("SELECT id FROM td_purchase_order WHERE company_id = :c AND source = 'purchase_request' AND source_id = :rid LIMIT 1");
$sth->execute([':c' => $company_id, ':rid' => $pr_id]);
$po_id = (int)($sth->fetchColumn() ?: 0);
if ($po_id) {
skip("PO converted from demo purchase request already exists (id={$po_id})");
} else {
$sth = $pdo2->prepare(
"SELECT item_id, product_sku, product_name, quantity, unit_price, total_price, tax_amount, tax_rate
FROM td_purchase_request_item WHERE request_id = :rid AND company_id = :c ORDER BY item_id"
);
$sth->execute([':rid' => $pr_id, ':c' => $company_id]);
$po_items = $sth->fetchAll(PDO::FETCH_ASSOC);
$po_id = dbTransaction($pdo2, function ($pdo2) use ($poMgmt, $prMgmt, $supplier_ids, $dept_ids, $po_items, $pr_id, $main_wh, $logging) {
$pid = $poMgmt->savePo([
'id' => 0, 'source' => 'purchase_request', 'source_id' => $pr_id,
'contact_id' => $supplier_ids[0], 'department_id' => $dept_ids['WH'],
'po_date' => date('Y-m-d'), 'expected_date' => date('Y-m-d', strtotime('+7 days')),
'warehouse_id' => $main_wh, 'items' => $po_items, 'discount' => 0,
'shipping_fee' => 0, 'notes' => 'Demo seed purchasing cycle',
], $logging);
// Insert with source set -> status=-2 (pending). Re-save with warehouse_id
// present -> auto-promotes to draft (0), same two-step dance as saveOrder().
$poMgmt->savePo([
'id' => $pid, 'contact_id' => $supplier_ids[0], 'department_id' => $dept_ids['WH'],
'po_date' => date('Y-m-d'), 'expected_date' => date('Y-m-d', strtotime('+7 days')),
'warehouse_id' => $main_wh, 'items' => $po_items, 'discount' => 0,
'shipping_fee' => 0, 'notes' => 'Demo seed purchasing cycle',
], $logging);
$poMgmt->confirmPo($pid, $logging);
$prMgmt->incrementConvertedQty($pr_id, array_map(
fn($it) => ['item_id' => $it['item_id'], 'quantity' => $it['quantity']], $po_items
));
return $pid;
});
ok("Created + confirmed PO from purchase request (id={$po_id})");
}
$sth = $pdo2->prepare(
"SELECT COUNT(*) FROM td_purchase_order_item WHERE order_id = :pid AND company_id = :c AND received_qty > 0"
);
$sth->execute([':pid' => $po_id, ':c' => $company_id]);
if ((int)$sth->fetchColumn() > 0) {
skip("PO #{$po_id} already received");
} else {
$sth = $pdo2->prepare(
"SELECT item_id, product_sku, unit_price, quantity FROM td_purchase_order_item
WHERE order_id = :pid AND company_id = :c ORDER BY item_id"
);
$sth->execute([':pid' => $po_id, ':c' => $company_id]);
$po_line_items = $sth->fetchAll(PDO::FETCH_ASSOC);
// Grab as many free bins as line items in one query — none of these are
// occupied until receivePo() runs, so a single up-front list (rather than
// repeated nextFreeBin() calls, which would all see the same still-empty
// bin and collide) is what keeps each line's location distinct.
$sth = $pdo2->prepare(
"SELECT bin FROM md_bin WHERE company_id = :c AND warehouse = :w AND product_sku IS NULL
ORDER BY CAST(SUBSTRING(bin, 3) AS UNSIGNED) ASC"
);
$sth->execute([':c' => $company_id, ':w' => $main_wh]);
$free_bins = array_slice($sth->fetchAll(PDO::FETCH_COLUMN), 0, count($po_line_items));
if (count($free_bins) < count($po_line_items)) {
throw new Exception("Not enough free bins in {$warehouses[$main_wh]} to receive PO #{$po_id}.");
}
$receive_items = [];
foreach ($po_line_items as $i => $it) {
$bin = $free_bins[$i];
$receive_items[] = [
'item_id' => (int)$it['item_id'], 'product_sku' => $it['product_sku'],
'warehouse_id' => $main_wh, 'quantity' => (float)$it['quantity'],
'unit_price' => (float)$it['unit_price'],
'zone' => $bin, 'aisle' => $bin, 'bin' => $bin,
'contact_id' => $supplier_ids[0],
];
}
$po_uuid = bin2hex(random_bytes(16));
dbTransaction($pdo2, function ($pdo2) use ($poMgmt, $whMgmt, $po_id, $receive_items, $po_uuid, $logging) {
$poMgmt->receivePo($po_id, $receive_items, $po_uuid, $logging, true); // auto-approve stock-in
});
ok("Received PO #{$po_id} — " . count($receive_items) . " line(s) into {$warehouses[$main_wh]}, stock-in auto-approved");
}
$sth = $pdo2->prepare(
"SELECT id FROM td_invoice WHERE company_id = :c AND source = 'po' AND source_id = :pid AND doc_type = 'purchase_invoice' LIMIT 1"
);
$sth->execute([':c' => $company_id, ':pid' => $po_id]);
$purchase_invoice_id = (int)($sth->fetchColumn() ?: 0);
if ($purchase_invoice_id) {
skip("Purchase invoice for PO already exists (id={$purchase_invoice_id})");
} else {
$purchase_invoice_id = dbTransaction($pdo2, function ($pdo2) use ($invMgmt, $po_id, $logging) {
$iid = $invMgmt->createFromPo($po_id, $logging);
$invMgmt->issueInvoice($iid, $logging, date('Y-m-d', strtotime('+30 days')));
return $iid;
});
ok("Created + issued purchase invoice from PO (id={$purchase_invoice_id})");
}
$existing_gl = (new GlManager($pdo2, $company_id))->getBySource('purchase_invoice', $purchase_invoice_id);
if ($existing_gl) {
skip("GL already posted for purchase invoice #{$purchase_invoice_id}");
} else {
$res = postGl($pdo1, $pdo2, $company_id, 'purchase_invoice', $purchase_invoice_id, 'PurchaseInvoicePosting');
ok("GL {$res['action']} for purchase invoice #{$purchase_invoice_id} ({$res['lines']} lines)");
}
// ─────────────────────────────────────────────────────────────────────────────
// 8. AP: payment billing -> payment -> GL post
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- AP (payment billing -> payment) ---\n";
$paymentBillingMgmt = new PaymentBillingManager($pdo2, $company_id);
$paymentMgmt = new PaymentManager($pdo2, $company_id);
$sth = $pdo2->prepare("SELECT id FROM td_payment_billing WHERE company_id = :c AND notes = 'Demo seed AP' LIMIT 1");
$sth->execute([':c' => $company_id]);
$payment_billing_id = (int)($sth->fetchColumn() ?: 0);
if ($payment_billing_id) {
skip("Demo payment billing already exists (id={$payment_billing_id})");
} else {
$sth = $pdo2->prepare("SELECT grand_total FROM td_invoice WHERE id = :id AND company_id = :c LIMIT 1");
$sth->execute([':id' => $purchase_invoice_id, ':c' => $company_id]);
$po_invoice_total = (float)$sth->fetchColumn();
$payment_billing_id = dbTransaction($pdo2, function ($pdo2) use ($paymentBillingMgmt, $supplier_ids, $purchase_invoice_id, $po_invoice_total, $logging) {
return $paymentBillingMgmt->createBilling([
'contact_id' => $supplier_ids[0], 'billing_date' => date('Y-m-d'), 'notes' => 'Demo seed AP',
'allocations' => [['invoice_id' => $purchase_invoice_id, 'amount' => $po_invoice_total]],
], $logging);
});
ok("Created payment billing for purchase invoice #{$purchase_invoice_id} (id={$payment_billing_id}), amount " . number_format($po_invoice_total, 2));
}
$sth = $pdo2->prepare("SELECT id FROM td_payment WHERE company_id = :c AND payment_billing_id = :bid LIMIT 1");
$sth->execute([':c' => $company_id, ':bid' => $payment_billing_id]);
$payment_id = (int)($sth->fetchColumn() ?: 0);
if ($payment_id) {
skip("Payment for billing #{$payment_billing_id} already exists (id={$payment_id})");
} else {
$sth = $pdo2->prepare("SELECT contact_id, amount FROM td_payment_billing WHERE id = :id LIMIT 1");
$sth->execute([':id' => $payment_billing_id]);
$pbilling = $sth->fetch(PDO::FETCH_ASSOC);
$payment_id = dbTransaction($pdo2, function ($pdo2) use ($paymentMgmt, $pbilling, $payment_billing_id, $logging) {
return $paymentMgmt->createPayment([
'payment_billing_id' => $payment_billing_id, 'payment_date' => date('Y-m-d'),
'payment_method' => 'bank_transfer', 'amount' => $pbilling['amount'], 'notes' => 'Demo seed AP',
], $logging);
});
ok("Created payment #{$payment_id} for billing #{$payment_billing_id}, amount " . number_format($pbilling['amount'], 2));
}
$existing_gl = (new GlManager($pdo2, $company_id))->getBySource('payment', $payment_id);
if ($existing_gl) {
skip("GL already posted for payment #{$payment_id}");
} else {
$res = postGl($pdo1, $pdo2, $company_id, 'payment', $payment_id, 'PaymentPosting');
ok("GL {$res['action']} for payment #{$payment_id} ({$res['lines']} lines)");
}
// ─────────────────────────────────────────────────────────────────────────────
// 9. Supplier return — return the full PK-001 line from the demo PO
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- Supplier return ---\n";
$supReturnMgmt = new SupplierReturnManager($pdo2, $company_id);
$sth = $pdo2->prepare("SELECT id FROM td_supplier_return WHERE company_id = :c AND po_id = :pid LIMIT 1");
$sth->execute([':c' => $company_id, ':pid' => $po_id]);
$supplier_return_id = (int)($sth->fetchColumn() ?: 0);
if ($supplier_return_id) {
skip("Demo supplier return already exists (id={$supplier_return_id})");
} else {
// td_purchase_order_item has no warehouse_id column — the PO is single-warehouse
// (td_purchase_order.warehouse_id), which is $main_wh here (set when the PO was created).
$sth = $pdo2->prepare(
"SELECT item_id, product_sku, product_name, quantity, unit_price, total_price, tax_amount, tax_rate,
stock_in_id
FROM td_purchase_order_item
WHERE order_id = :pid AND company_id = :c AND product_sku = 'PK-001' LIMIT 1"
);
$sth->execute([':pid' => $po_id, ':c' => $company_id]);
$po_line = $sth->fetch(PDO::FETCH_ASSOC);
$sth = $pdo2->prepare("SELECT zone, aisle, bin FROM `td_stock_{$main_wh}` WHERE id = :id AND company_id = :c LIMIT 1");
$sth->execute([':id' => $po_line['stock_in_id'], ':c' => $company_id]);
$bin_row = $sth->fetch(PDO::FETCH_ASSOC);
$return_item = [
'product_sku' => $po_line['product_sku'], 'product_name' => $po_line['product_name'],
'quantity' => $po_line['quantity'], 'unit_price' => $po_line['unit_price'],
'total_price' => $po_line['total_price'], 'tax_amount' => $po_line['tax_amount'],
'tax_rate' => $po_line['tax_rate'], 'warehouse_id' => $main_wh,
'stock_in_id' => $po_line['stock_in_id'],
'zone' => $bin_row['zone'], 'aisle' => $bin_row['aisle'], 'bin' => $bin_row['bin'],
];
$supplier_return_id = dbTransaction($pdo2, function ($pdo2) use ($supReturnMgmt, $whMgmt, $supplier_ids, $dept_ids, $return_item, $po_id, $logging) {
$rid = $supReturnMgmt->saveReturn([
'id' => 0, 'po_id' => $po_id, 'contact_id' => $supplier_ids[0], 'department_id' => $dept_ids['WH'],
'return_date' => date('Y-m-d'), 'reason' => 'Damaged on arrival (demo seed)',
'items' => [$return_item], 'tax_adjustment' => 0, 'notes' => 'Demo seed supplier return',
], $logging);
$supReturnMgmt->confirmReturn($rid, bin2hex(random_bytes(16)), $logging, $whMgmt, true); // auto-approve
return $rid;
});
ok("Created + confirmed supplier return for {$return_item['quantity']} x {$return_item['product_sku']} (id={$supplier_return_id})");
}
// ─────────────────────────────────────────────────────────────────────────────
// 10. Customer return — draft only (see NOTE at top of this file: confirming
// customer returns is currently broken in the app itself).
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- Customer return (draft only — confirming is currently broken, see file header) ---\n";
$returnMgmt = new ReturnManager($pdo2, $company_id);
$sth = $pdo2->prepare("SELECT id FROM td_return WHERE company_id = :c AND notes = 'Demo seed customer return' LIMIT 1");
$sth->execute([':c' => $company_id]);
$customer_return_id = (int)($sth->fetchColumn() ?: 0);
if ($customer_return_id) {
skip("Demo customer return already exists (id={$customer_return_id})");
} else {
// Return 5 units of EL-002 against the demo sales order/invoice.
$sth = $pdo2->prepare(
"SELECT item_id, product_sku, product_name, unit_price, tax_rate, warehouse_id, stock_out_id
FROM td_order_item WHERE order_id = :oid AND company_id = :c AND product_sku = 'EL-002' LIMIT 1"
);
$sth->execute([':oid' => $sales_order_id, ':c' => $company_id]);
$order_line = $sth->fetch(PDO::FETCH_ASSOC);
$ret_qty = 5.0;
$ret_total = round($ret_qty * (float)$order_line['unit_price'], 4);
$ret_tax = round($ret_total * (float)$order_line['tax_rate'] / 100, 4);
$customer_return_id = dbTransaction($pdo2, function ($pdo2) use ($returnMgmt, $customer_ids, $order_line, $ret_qty, $ret_total, $ret_tax, $sales_order_id, $sales_invoice_id, $logging) {
return $returnMgmt->saveReturn([
'id' => 0, 'order_id' => $sales_order_id, 'invoice_id' => $sales_invoice_id,
'contact_id' => $customer_ids[0], 'return_date' => date('Y-m-d'),
'reason' => 'Customer changed mind (demo seed)', 'tax_adjustment' => 0,
'notes' => 'Demo seed customer return',
'items' => [[
'product_sku' => $order_line['product_sku'], 'product_name' => $order_line['product_name'],
'quantity' => $ret_qty, 'unit_price' => $order_line['unit_price'],
'total_price' => $ret_total, 'tax_amount' => $ret_tax, 'tax_rate' => $order_line['tax_rate'],
'warehouse_id' => $order_line['warehouse_id'], 'stock_out_id' => $order_line['stock_out_id'],
'stock_out_warehouse_id' => $order_line['warehouse_id'],
]],
], $logging);
});
ok("Created draft customer return for {$ret_qty} x {$order_line['product_sku']} (id={$customer_return_id}) — left in draft, not confirmed");
}
// ─────────────────────────────────────────────────────────────────────────────
// 11. Barcode labels for a handful of products
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- Barcode labels ---\n";
$barcodeMgmt = new BarcodeManager($pdo2, $company_id);
$label_skus = ['EL-001', 'EL-002', 'OF-001', 'BV-001', 'PK-001'];
foreach ($label_skus as $sku) {
$sth = $pdo2->prepare(
"SELECT COUNT(*) FROM md_barcode WHERE company_id = :c AND barcode_type = 'sku' AND product_sku = :sku"
);
$sth->execute([':c' => $company_id, ':sku' => $sku]);
if ((int)$sth->fetchColumn() > 0) {
skip("SKU label for {$sku} already exists");
continue;
}
$label = dbTransaction($pdo2, function ($pdo2) use ($barcodeMgmt, $sku) {
return $barcodeMgmt->createSkuLabel($sku, '', '', true);
});
ok("Created SKU label for {$sku}: {$label['barcode']}");
}
echo "\n\033[1m=== Done ===\033[0m\n\n";
+3
View File
@@ -16,3 +16,6 @@
2026-08-03T10:11:12: [2026-08-03T03:11:12.241Z] [PID: 477] [NODE-CRON] [WARN] missed execution at Mon Aug 03 2026 10:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-03T10:31:46: [2026-08-03T03:31:46.573Z] [PID: 477] [NODE-CRON] [WARN] missed execution at Mon Aug 03 2026 10:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-03T11:08:26: [2026-08-03T04:08:26.686Z] [PID: 477] [NODE-CRON] [WARN] missed execution at Mon Aug 03 2026 11:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-04T15:02:31: [2026-08-04T08:02:31.243Z] [PID: 78443] [NODE-CRON] [WARN] missed execution at Tue Aug 04 2026 15:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-04T15:32:15: [2026-08-04T08:32:15.561Z] [PID: 78443] [NODE-CRON] [WARN] missed execution at Tue Aug 04 2026 15:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-04T17:01:03: [2026-08-04T10:01:03.525Z] [PID: 78443] [NODE-CRON] [WARN] missed execution at Tue Aug 04 2026 17:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
+8
View File
@@ -48,3 +48,11 @@
2026-08-03T10:11:12: [2026-08-03T03:11:12.241Z] [PID: 477] [NODE-CRON] [WARN] missed execution at Mon Aug 03 2026 10:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-03T10:31:46: [2026-08-03T03:31:46.573Z] [PID: 477] [NODE-CRON] [WARN] missed execution at Mon Aug 03 2026 10:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-03T11:08:26: [2026-08-03T04:08:26.686Z] [PID: 477] [NODE-CRON] [WARN] missed execution at Mon Aug 03 2026 11:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-04T13:35:14: [scheduler] running — etl_gl + etl_stock (hourly) | low_stock + overdue_invoices alerts (daily)
2026-08-04T15:02:31: [2026-08-04T08:02:31.243Z] [PID: 78443] [NODE-CRON] [WARN] missed execution at Tue Aug 04 2026 15:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-04T15:32:15: [2026-08-04T08:32:15.561Z] [PID: 78443] [NODE-CRON] [WARN] missed execution at Tue Aug 04 2026 15:30:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
2026-08-04T16:00:00: [scheduler] etl_gl slot=16
2026-08-04T16:00:00: [scheduler] etl_gl slot=16 — no companies
2026-08-04T16:30:00: [scheduler] etl_stock slot=16
2026-08-04T16:30:00: [scheduler] etl_stock slot=16 — no companies
2026-08-04T17:01:03: [2026-08-04T10:01:03.525Z] [PID: 78443] [NODE-CRON] [WARN] missed execution at Tue Aug 04 2026 17:00:00 GMT+0700 (Indochina Time)! Possible blocking IO or high CPU user at the same process used by node-cron.
+118
View File
@@ -135,3 +135,121 @@
2026-08-03T10:54:19: [connect] socket=FMnx-fmSk96rxIfwAAAL company=1 user=1 role=owner
2026-08-03T11:13:57: [disconnect] socket=FMnx-fmSk96rxIfwAAAL
2026-08-03T11:13:59: [connect] socket=LgVxBoN_6JuJtxHyAAAN company=1 user=1 role=owner
2026-08-04T13:35:14: Node.js real-time server running on port 3000
2026-08-04T13:36:21: [connect] socket=BOvxSU23giBsnIXWAAAB company=1 user=1 role=owner
2026-08-04T13:36:25: [disconnect] socket=BOvxSU23giBsnIXWAAAB
2026-08-04T13:36:25: [connect] socket=pJXUXD7HNX_V9peAAAAD company=1 user=1 role=owner
2026-08-04T13:36:27: [disconnect] socket=pJXUXD7HNX_V9peAAAAD
2026-08-04T13:36:27: [connect] socket=St7RkiRumWpwc47xAAAF company=1 user=1 role=owner
2026-08-04T13:36:28: [disconnect] socket=St7RkiRumWpwc47xAAAF
2026-08-04T13:36:28: [connect] socket=a9NsNFmUpIL1vW8uAAAH company=1 user=1 role=owner
2026-08-04T13:40:28: [disconnect] socket=a9NsNFmUpIL1vW8uAAAH
2026-08-04T13:40:28: [connect] socket=uYLFddlhCkOxrcJNAAAJ company=1 user=1 role=owner
2026-08-04T13:48:10: [disconnect] socket=uYLFddlhCkOxrcJNAAAJ
2026-08-04T13:48:11: [connect] socket=VekC6UabiJABBbjhAAAL company=1 user=1 role=owner
2026-08-04T13:50:28: [disconnect] socket=VekC6UabiJABBbjhAAAL
2026-08-04T13:50:29: [connect] socket=gj-glld-ZsxWbGQuAAAN company=1 user=1 role=owner
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:08:49: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
2026-08-04T14:11:36: [disconnect] socket=gj-glld-ZsxWbGQuAAAN
2026-08-04T14:11:36: [connect] socket=v97BofsQmYBAEJMmAAAP company=1 user=1 role=owner
2026-08-04T14:13:54: [disconnect] socket=v97BofsQmYBAEJMmAAAP
2026-08-04T14:13:54: [connect] socket=WYJSdI9xVDaTML9xAAAR company=1 user=1 role=owner
2026-08-04T14:17:32: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T14:17:32: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T14:21:12: [disconnect] socket=WYJSdI9xVDaTML9xAAAR
2026-08-04T14:21:12: [connect] socket=UhIyCllsOjal4UwCAAAT company=1 user=1 role=owner
2026-08-04T14:21:13: [disconnect] socket=UhIyCllsOjal4UwCAAAT
2026-08-04T14:21:13: [connect] socket=6hZ-_fAyDgWzwsgvAAAV company=1 user=1 role=owner
2026-08-04T14:21:38: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:21:38: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:25:22: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T14:25:22: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T14:25:22: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:25:22: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:29:14: [disconnect] socket=6hZ-_fAyDgWzwsgvAAAV
2026-08-04T14:29:14: [connect] socket=7ocTMfufQlyO36XrAAAX company=1 user=1 role=owner
2026-08-04T14:29:19: [disconnect] socket=7ocTMfufQlyO36XrAAAX
2026-08-04T14:36:58: [connect] socket=kY4WNaECxPvGVj2JAAAZ company=1 user=1 role=owner
2026-08-04T14:37:12: [disconnect] socket=kY4WNaECxPvGVj2JAAAZ
2026-08-04T14:37:12: [connect] socket=OabEymZu5SehwNWnAAAb company=1 user=1 role=owner
2026-08-04T14:37:40: [disconnect] socket=OabEymZu5SehwNWnAAAb
2026-08-04T14:41:55: [connect] socket=kAlZOJl54kd8RXKAAAAd company=1 user=1 role=owner
2026-08-04T14:42:42: [disconnect] socket=kAlZOJl54kd8RXKAAAAd
2026-08-04T14:42:42: [connect] socket=DBSytTfd-o12DxhfAAAf company=1 user=1 role=owner
2026-08-04T14:49:38: [disconnect] socket=DBSytTfd-o12DxhfAAAf
2026-08-04T14:49:39: [connect] socket=sAr1qebAYGyIq8zrAAAh company=1 user=1 role=owner
2026-08-04T14:58:10: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
2026-08-04T14:58:10: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:58:10: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T14:58:10: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T14:58:10: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
2026-08-04T14:59:17: [disconnect] socket=sAr1qebAYGyIq8zrAAAh
2026-08-04T14:59:17: [connect] socket=a264uVnqws4cIAy-AAAj company=1 user=1 role=owner
2026-08-04T15:21:19: [disconnect] socket=a264uVnqws4cIAy-AAAj
2026-08-04T15:21:19: [connect] socket=JL7kcUwnQI_NExMkAAAl company=1 user=1 role=owner
2026-08-04T15:21:22: [disconnect] socket=JL7kcUwnQI_NExMkAAAl
2026-08-04T15:27:09: [connect] socket=VcbOCpKEShqcqqM0AAAn company=1 user=1 role=owner
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 2, type: 'in' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'in' }
2026-08-04T15:28:36: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'transfer' }
2026-08-04T15:30:09: [disconnect] socket=VcbOCpKEShqcqqM0AAAn
2026-08-04T15:30:09: [connect] socket=ecBAVshG1Eng4jh5AAAp company=1 user=1 role=owner
2026-08-04T15:33:32: [disconnect] socket=ecBAVshG1Eng4jh5AAAp
2026-08-04T15:33:32: [connect] socket=M43MyEQ7wipYOgd5AAAr company=1 user=1 role=owner
2026-08-04T15:34:32: [disconnect] socket=M43MyEQ7wipYOgd5AAAr
2026-08-04T15:34:32: [connect] socket=TiGDT6OMJsYlixlkAAAt company=1 user=1 role=owner
2026-08-04T15:35:36: [disconnect] socket=TiGDT6OMJsYlixlkAAAt
2026-08-04T15:35:36: [connect] socket=uHRGhZU0TJVvvh_aAAAv company=1 user=1 role=owner
2026-08-04T15:36:24: [disconnect] socket=uHRGhZU0TJVvvh_aAAAv
2026-08-04T15:36:24: [connect] socket=Hsui_73WGENhGdRIAAAx company=1 user=1 role=owner
2026-08-04T15:38:27: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T15:38:27: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T15:38:27: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T15:38:27: [emit] company=1 event=stock_updated { warehouse_id: 1, type: 'out' }
2026-08-04T15:39:55: [disconnect] socket=Hsui_73WGENhGdRIAAAx
2026-08-04T15:39:55: [connect] socket=xSl0-9raxlwU2K9rAAAz company=1 user=1 role=owner
2026-08-04T15:52:53: [disconnect] socket=xSl0-9raxlwU2K9rAAAz
2026-08-04T15:52:53: [connect] socket=n_l9kHYTF1AXNNRYAAA1 company=1 user=1 role=owner
2026-08-04T15:58:37: [disconnect] socket=n_l9kHYTF1AXNNRYAAA1
2026-08-04T15:58:37: [connect] socket=T9mpzRMT3I1qjj0BAAA3 company=1 user=1 role=owner
2026-08-04T15:58:39: [disconnect] socket=T9mpzRMT3I1qjj0BAAA3
2026-08-04T15:58:39: [connect] socket=Q1jGtGwwFc49KKxDAAA5 company=1 user=1 role=owner
2026-08-04T15:58:40: [disconnect] socket=Q1jGtGwwFc49KKxDAAA5
2026-08-04T15:58:41: [connect] socket=Fk7l8SLr2IIRFFHbAAA7 company=1 user=1 role=owner
2026-08-04T15:58:42: [disconnect] socket=Fk7l8SLr2IIRFFHbAAA7
2026-08-04T15:58:42: [connect] socket=pAuTSmpDDC86EZwXAAA9 company=1 user=1 role=owner
2026-08-04T16:41:11: [disconnect] socket=pAuTSmpDDC86EZwXAAA9
2026-08-04T16:41:13: [connect] socket=XmFaoUIej-g8203TAAA_ company=1 user=1 role=owner
2026-08-04T16:41:25: [disconnect] socket=XmFaoUIej-g8203TAAA_
2026-08-04T16:41:28: [connect] socket=TxwzTsUHzqDLHpODAABB company=1 user=1 role=owner
2026-08-04T16:43:43: [disconnect] socket=TxwzTsUHzqDLHpODAABB
2026-08-04T16:44:26: [connect] socket=TOhs2YrBWQkiGDZDAABD company=1 user=1 role=owner
2026-08-04T16:58:05: [disconnect] socket=TOhs2YrBWQkiGDZDAABD
2026-08-04T16:59:15: [connect] socket=p1xNFoGJFKox8FaOAABF company=1 user=1 role=owner
2026-08-04T17:25:03: [disconnect] socket=p1xNFoGJFKox8FaOAABF
2026-08-04T17:25:03: [connect] socket=j3s6wvwe_BxVzCA_AABH company=1 user=1 role=owner
2026-08-04T17:25:05: [disconnect] socket=j3s6wvwe_BxVzCA_AABH
2026-08-04T17:25:05: [connect] socket=4wkwmOWCAvQSicL3AABJ company=1 user=1 role=owner
2026-08-04T17:26:40: [disconnect] socket=4wkwmOWCAvQSicL3AABJ
+1074
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -11,5 +11,8 @@
"express": "^4.18.0",
"node-cron": "^4.2.1",
"socket.io": "^4.7.0"
},
"devDependencies": {
"pm2": "^7.0.3"
}
}