diff --git a/app/dashboard/index.php b/app/dashboard/index.php
index 2045569..3f4db9e 100644
--- a/app/dashboard/index.php
+++ b/app/dashboard/index.php
@@ -86,7 +86,7 @@
Total Revenue
—
-
Sub-total to date (excl. VAT)
+
Subtotal, excl. VAT
diff --git a/demo_seed.php b/demo_seed.php
new file mode 100644
index 0000000..5f6fb6a
--- /dev/null
+++ b/demo_seed.php
@@ -0,0 +1,663 @@
+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";
diff --git a/demo_seed_more_orders.php b/demo_seed_more_orders.php
new file mode 100644
index 0000000..dcb4e40
--- /dev/null
+++ b/demo_seed_more_orders.php
@@ -0,0 +1,134 @@
+
+ * 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";
diff --git a/demo_seed_transactions.php b/demo_seed_transactions.php
new file mode 100644
index 0000000..b9f580f
--- /dev/null
+++ b/demo_seed_transactions.php
@@ -0,0 +1,901 @@
+ 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";
diff --git a/nodejs/logs/scheduler-error.log b/nodejs/logs/scheduler-error.log
index 9fbc691..373e447 100644
--- a/nodejs/logs/scheduler-error.log
+++ b/nodejs/logs/scheduler-error.log
@@ -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.
diff --git a/nodejs/logs/scheduler.log b/nodejs/logs/scheduler.log
index bb3ca5a..7b38dd2 100644
--- a/nodejs/logs/scheduler.log
+++ b/nodejs/logs/scheduler.log
@@ -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.
diff --git a/nodejs/logs/socket.log b/nodejs/logs/socket.log
index 440c3c9..61a8b42 100644
--- a/nodejs/logs/socket.log
+++ b/nodejs/logs/socket.log
@@ -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
diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json
index 6be9333..de9d2b6 100644
--- a/nodejs/package-lock.json
+++ b/nodejs/package-lock.json
@@ -12,14 +12,115 @@
"express": "^4.18.0",
"node-cron": "^4.2.1",
"socket.io": "^4.7.0"
+ },
+ "devDependencies": {
+ "pm2": "^7.0.3"
}
},
+ "node_modules/@pm2/blessed": {
+ "version": "0.1.81",
+ "resolved": "https://registry.npmjs.org/@pm2/blessed/-/blessed-0.1.81.tgz",
+ "integrity": "sha512-ZcNHqQjMuNRcQ7Z1zJbFIQZO/BDKV3KbiTckWdfbUaYhj7uNmUwb+FbdDWSCkvxNr9dBJQwvV17o6QBkAvgO0g==",
+ "dev": true,
+ "bin": {
+ "blessed": "bin/tput.js"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/@pm2/js-api": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@pm2/js-api/-/js-api-0.8.1.tgz",
+ "integrity": "sha512-n9tDOz1ojyDOs05XthEXrLFVQYbbh2oAN19UakLPyEZDrUyEq05h8wIZU8+dNXBQY/KeFlWMLVA76nnX52ofRg==",
+ "dev": true,
+ "dependencies": {
+ "async": "^2.6.3",
+ "debug": "~4.3.1",
+ "eventemitter2": "^6.3.1",
+ "extrareqp2": "^1.0.0",
+ "ws": "^8.21.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/@pm2/js-api/node_modules/async": {
+ "version": "2.6.4",
+ "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz",
+ "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==",
+ "dev": true,
+ "dependencies": {
+ "lodash": "^4.17.14"
+ }
+ },
+ "node_modules/@pm2/js-api/node_modules/debug": {
+ "version": "4.3.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+ "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+ "dev": true,
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@pm2/js-api/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true
+ },
+ "node_modules/@pm2/pm2-version-check": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@pm2/pm2-version-check/-/pm2-version-check-1.0.4.tgz",
+ "integrity": "sha512-SXsM27SGH3yTWKc2fKR4SYNxsmnvuBQ9dd6QHtEWmiZ/VqaOYPAIlS8+vMcn27YLtAEBGvNRSh3TPNvtjZgfqA==",
+ "dev": true,
+ "dependencies": {
+ "debug": "^4.3.1"
+ }
+ },
+ "node_modules/@pm2/pm2-version-check/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@pm2/pm2-version-check/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true
+ },
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
+ "node_modules/@tootallnate/quickjs-emscripten": {
+ "version": "0.23.0",
+ "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",
+ "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==",
+ "dev": true
+ },
"node_modules/@types/cors": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
@@ -60,12 +161,97 @@
"node": ">= 0.6"
}
},
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/amp": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/amp/-/amp-0.3.1.tgz",
+ "integrity": "sha512-OwIuC4yZaRogHKiuU5WlMR5Xk/jAcpPtawWL05Gj8Lvm2F6mwoJt4O/bHI+DHwG79vWd+8OFYM4/BzYqyRd3qw==",
+ "dev": true
+ },
+ "node_modules/amp-message": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/amp-message/-/amp-message-0.1.2.tgz",
+ "integrity": "sha512-JqutcFwoU1+jhv7ArgW38bqrE+LQdcRv4NxNw0mp0JHQyB6tXesWRjtYKlDgHRY2o3JE5UTaBGUK8kSWUdxWUg==",
+ "dev": true,
+ "dependencies": {
+ "amp": "0.3.1"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/ansis": {
+ "version": "4.0.0-node10",
+ "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.0.0-node10.tgz",
+ "integrity": "sha512-BRrU0Bo1X9dFGw6KgGz6hWrqQuOlVEDOzkb0QSLZY9sXHqA7pNj7yHPVJRz7y/rj4EOJ3d/D5uxH+ee9leYgsg==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true
+ },
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
+ "node_modules/ast-types": {
+ "version": "0.13.4",
+ "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
+ "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==",
+ "dev": true,
+ "dependencies": {
+ "tslib": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/async": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
+ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
+ "dev": true
+ },
"node_modules/base64id": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
@@ -75,6 +261,27 @@
"node": "^4.5.0 || >= 5.9"
}
},
+ "node_modules/basic-ftp": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz",
+ "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==",
+ "dev": true,
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/body-parser": {
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
@@ -99,6 +306,18 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -137,6 +356,79 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/chalk": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz",
+ "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/cli-tableau": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/cli-tableau/-/cli-tableau-2.0.1.tgz",
+ "integrity": "sha512-he+WTicka9cl0Fg/y+YyxcN6/bfQ/1O3QmgxRXDhABKqLzvoOSM4fMzp39uMyLBulAFuywD2N7UaoQE7WaADxQ==",
+ "dev": true,
+ "dependencies": {
+ "chalk": "3.0.0"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "node_modules/commander": {
+ "version": "2.15.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz",
+ "integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==",
+ "dev": true
+ },
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -190,6 +482,27 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/croner": {
+ "version": "4.1.97",
+ "resolved": "https://registry.npmjs.org/croner/-/croner-4.1.97.tgz",
+ "integrity": "sha512-/f6gpQuxDaqXu+1kwQYSckUglPaOrHdbIlBAu0YuW8/Cdb45XwXYNUBXg3r/9Mo6n540Kn/smKcZWko5x99KrQ==",
+ "dev": true
+ },
+ "node_modules/data-uri-to-buffer": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz",
+ "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/dayjs": {
+ "version": "1.11.15",
+ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.15.tgz",
+ "integrity": "sha512-MC+DfnSWiM9APs7fpiurHGCoeIx0Gdl6QZBy+5lu8MbYKN5FZEXqOgrundfibdfhGZ15o9hzmZ2xJjZnbvgKXQ==",
+ "dev": true
+ },
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -199,6 +512,20 @@
"ms": "2.0.0"
}
},
+ "node_modules/degenerator": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz",
+ "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==",
+ "dev": true,
+ "dependencies": {
+ "ast-types": "^0.13.4",
+ "escodegen": "^2.1.0",
+ "esprima": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -348,6 +675,58 @@
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
+ "node_modules/escodegen": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
+ "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
+ "dev": true,
+ "dependencies": {
+ "esprima": "^4.0.1",
+ "estraverse": "^5.2.0",
+ "esutils": "^2.0.2"
+ },
+ "bin": {
+ "escodegen": "bin/escodegen.js",
+ "esgenerate": "bin/esgenerate.js"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "optionalDependencies": {
+ "source-map": "~0.6.1"
+ }
+ },
+ "node_modules/esprima": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+ "dev": true,
+ "bin": {
+ "esparse": "bin/esparse.js",
+ "esvalidate": "bin/esvalidate.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@@ -357,6 +736,12 @@
"node": ">= 0.6"
}
},
+ "node_modules/eventemitter2": {
+ "version": "6.4.9",
+ "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz",
+ "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==",
+ "dev": true
+ },
"node_modules/express": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
@@ -403,6 +788,33 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/extrareqp2": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/extrareqp2/-/extrareqp2-1.0.0.tgz",
+ "integrity": "sha512-Gum0g1QYb6wpPJCVypWP3bbIuaibcFiJcpuPM10YSXp/tzqi84x9PJageob+eN4xVRIOto4wjSGNLyMD54D2xA==",
+ "dev": true,
+ "dependencies": {
+ "follow-redirects": "^1.14.0"
+ }
+ },
+ "node_modules/fast-json-patch": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz",
+ "integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==",
+ "dev": true
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -421,6 +833,26 @@
"node": ">= 0.8"
}
},
+ "node_modules/follow-redirects": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -439,6 +871,20 @@
"node": ">= 0.6"
}
},
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -485,6 +931,55 @@
"node": ">= 0.4"
}
},
+ "node_modules/get-uri": {
+ "version": "6.0.5",
+ "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz",
+ "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==",
+ "dev": true,
+ "dependencies": {
+ "basic-ftp": "^5.0.2",
+ "data-uri-to-buffer": "^6.0.2",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/get-uri/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/get-uri/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true
+ },
+ "node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -497,6 +992,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -541,6 +1045,78 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "dev": true,
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/http-proxy-agent/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/http-proxy-agent/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/https-proxy-agent/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true
+ },
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -559,6 +1135,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
+ "node_modules/ip-address": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
+ "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
+ "dev": true,
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -568,6 +1153,91 @@
"node": ">= 0.10"
}
},
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/js-yaml": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
+ "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/json-stringify-safe": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
+ "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
+ "dev": true
+ },
+ "node_modules/lodash": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+ "dev": true
+ },
+ "node_modules/lru-cache": {
+ "version": "7.18.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
+ "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -652,6 +1322,15 @@
"node": ">= 0.6"
}
},
+ "node_modules/netmask": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz",
+ "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
"node_modules/node-cron": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
@@ -661,6 +1340,15 @@
"node": ">=6.0.0"
}
},
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -694,6 +1382,61 @@
"node": ">= 0.8"
}
},
+ "node_modules/pac-proxy-agent": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz",
+ "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==",
+ "dev": true,
+ "dependencies": {
+ "@tootallnate/quickjs-emscripten": "^0.23.0",
+ "agent-base": "^7.1.2",
+ "debug": "^4.3.4",
+ "get-uri": "^6.0.1",
+ "http-proxy-agent": "^7.0.0",
+ "https-proxy-agent": "^7.0.6",
+ "pac-resolver": "^7.0.1",
+ "socks-proxy-agent": "^8.0.5"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/pac-proxy-agent/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pac-proxy-agent/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true
+ },
+ "node_modules/pac-resolver": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz",
+ "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==",
+ "dev": true,
+ "dependencies": {
+ "degenerator": "^5.0.0",
+ "netmask": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -709,6 +1452,126 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
+ "node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pidusage": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/pidusage/-/pidusage-4.0.1.tgz",
+ "integrity": "sha512-yCH2dtLHfEBnzlHUJymR/Z1nN2ePG3m392Mv8TFlTP1B0xkpMQNHAnfkY0n2tAi6ceKO6YWhxYfZ96V4vVkh/g==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "^5.2.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/pm2": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/pm2/-/pm2-7.0.3.tgz",
+ "integrity": "sha512-zRJOdburpb9OEPB0uqoNT8C1Gp7hPJPVy4Kr67XJNuT9UlMQcOt1WXrYQUmwqKPHk8FyauvP1CPhqoCrCaPw0Q==",
+ "dev": true,
+ "dependencies": {
+ "@pm2/blessed": "0.1.81",
+ "@pm2/js-api": "0.8.1",
+ "@pm2/pm2-version-check": "1.0.4",
+ "amp": "0.3.1",
+ "amp-message": "0.1.2",
+ "ansis": "4.0.0-node10",
+ "async": "3.2.6",
+ "chokidar": "3.6.0",
+ "cli-tableau": "2.0.1",
+ "commander": "2.15.1",
+ "croner": "4.1.97",
+ "dayjs": "1.11.15",
+ "debug": "4.4.3",
+ "eventemitter2": "6.4.9",
+ "fast-json-patch": "3.1.1",
+ "js-yaml": "4.3.0",
+ "pidusage": "4.0.1",
+ "pm2-deploy": "1.0.2",
+ "proxy-agent": "6.5.0",
+ "semver": "7.7.2",
+ "tx2": "1.0.5",
+ "ws": "8.21.0"
+ },
+ "bin": {
+ "pm2": "bin/pm2",
+ "pm2-dev": "bin/pm2-dev",
+ "pm2-docker": "bin/pm2-docker",
+ "pm2-runtime": "bin/pm2-runtime"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/pm2-deploy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/pm2-deploy/-/pm2-deploy-1.0.2.tgz",
+ "integrity": "sha512-YJx6RXKrVrWaphEYf++EdOOx9EH18vM8RSZN/P1Y+NokTKqYAca/ejXwVLyiEpNju4HPZEk3Y2uZouwMqUlcgg==",
+ "dev": true,
+ "dependencies": {
+ "run-series": "^1.1.8",
+ "tv4": "^1.3.0"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pm2/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pm2/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true
+ },
+ "node_modules/pm2/node_modules/ws": {
+ "version": "8.21.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
+ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
+ "dev": true,
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -722,6 +1585,54 @@
"node": ">= 0.10"
}
},
+ "node_modules/proxy-agent": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz",
+ "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==",
+ "dev": true,
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "^4.3.4",
+ "http-proxy-agent": "^7.0.1",
+ "https-proxy-agent": "^7.0.6",
+ "lru-cache": "^7.14.1",
+ "pac-proxy-agent": "^7.1.0",
+ "proxy-from-env": "^1.1.0",
+ "socks-proxy-agent": "^8.0.5"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/proxy-agent/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/proxy-agent/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true
+ },
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+ "dev": true
+ },
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
@@ -761,6 +1672,38 @@
"node": ">= 0.8"
}
},
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/run-series": {
+ "version": "1.1.9",
+ "resolved": "https://registry.npmjs.org/run-series/-/run-series-1.1.9.tgz",
+ "integrity": "sha512-Arc4hUN896vjkqCYrUXquBFtRZdv1PfLbTYP71efP6butxyQ0kWpiNJyAgsxscmQg1cqvHY32/UCBzXedTpU2g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -787,6 +1730,18 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
+ "node_modules/semver": {
+ "version": "7.7.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
+ "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+ "dev": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
@@ -910,6 +1865,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/smart-buffer": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
+ "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 6.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
"node_modules/socket.io": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz",
@@ -1020,6 +1985,67 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
+ "node_modules/socks": {
+ "version": "2.8.9",
+ "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
+ "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
+ "dev": true,
+ "dependencies": {
+ "ip-address": "^10.1.1",
+ "smart-buffer": "^4.2.0"
+ },
+ "engines": {
+ "node": ">= 10.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/socks-proxy-agent": {
+ "version": "8.0.5",
+ "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
+ "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
+ "dev": true,
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "^4.3.4",
+ "socks": "^2.8.3"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/socks-proxy-agent/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/socks-proxy-agent/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true
+ },
+ "node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1029,6 +2055,30 @@
"node": ">= 0.8"
}
},
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -1038,6 +2088,30 @@
"node": ">=0.6"
}
},
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true
+ },
+ "node_modules/tv4": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/tv4/-/tv4-1.3.0.tgz",
+ "integrity": "sha512-afizzfpJgvPr+eDkREK4MxJ/+r8nEEHcmitwgnPUqpaP+FpwQyadnxNoSACbgc/b1LsZYtODGoPiFxQrgJgjvw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/tx2": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/tx2/-/tx2-1.0.5.tgz",
+ "integrity": "sha512-sJ24w0y03Md/bxzK4FU8J8JveYYUbSs2FViLJ2D/8bytSiyPRbuE3DyL/9UKYXTZlV3yXq0L8GLlhobTnekCVg==",
+ "dev": true,
+ "dependencies": {
+ "json-stringify-safe": "^5.0.1"
+ }
+ },
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
diff --git a/nodejs/package.json b/nodejs/package.json
index 36b96b6..07a8776 100644
--- a/nodejs/package.json
+++ b/nodejs/package.json
@@ -11,5 +11,8 @@
"express": "^4.18.0",
"node-cron": "^4.2.1",
"socket.io": "^4.7.0"
+ },
+ "devDependencies": {
+ "pm2": "^7.0.3"
}
}