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

664 lines
32 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Demo 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";