Demo Data Population
This commit is contained in:
@@ -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
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -16,3 +16,6 @@
|
||||
2026-08-03T10:11:12: [2026-08-03T03:11:12.241Z] [PID: 477] [32m[NODE-CRON][32m [33m[WARN][0m 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] [32m[NODE-CRON][32m [33m[WARN][0m 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] [32m[NODE-CRON][32m [33m[WARN][0m 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] [32m[NODE-CRON][32m [33m[WARN][0m 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] [32m[NODE-CRON][32m [33m[WARN][0m 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] [32m[NODE-CRON][32m [33m[WARN][0m 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.
|
||||
|
||||
@@ -48,3 +48,11 @@
|
||||
2026-08-03T10:11:12: [2026-08-03T03:11:12.241Z] [PID: 477] [32m[NODE-CRON][32m [33m[WARN][0m 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] [32m[NODE-CRON][32m [33m[WARN][0m 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] [32m[NODE-CRON][32m [33m[WARN][0m 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] [32m[NODE-CRON][32m [33m[WARN][0m 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] [32m[NODE-CRON][32m [33m[WARN][0m 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] [32m[NODE-CRON][32m [33m[WARN][0m 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+1074
File diff suppressed because it is too large
Load Diff
@@ -11,5 +11,8 @@
|
||||
"express": "^4.18.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"socket.io": "^4.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"pm2": "^7.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user