Fix transfer quantity, unify date format, align demo seeds
Stock Transfer list showed 0.00 (read in instead of out); stock-out/transfer forms show the location quantity; dates display as YYYY-MM-DD HH:mm:ss. Demo seeds map product accounts, use product names and supplier batches, and add demo_seed_assets.php.
This commit is contained in:
+46
-173
@@ -5,18 +5,16 @@
|
||||
*
|
||||
* Extends the base demo data (demo_seed.php: company, warehouses, products,
|
||||
* contacts, opening stock) with transactional data covering the rest of the
|
||||
* app's features:
|
||||
* app's features (restock / dispatch / transfer movements are seeded by demo_seed.php):
|
||||
*
|
||||
* 1. Additional stock-in replenishment (restock events)
|
||||
* 2. Stock-out (direct dispatch) + stock transfer (Main -> Bangna)
|
||||
* 3. Chart of accounts, departments, GL posting formulas
|
||||
* 4. Sales cycle: quotation -> sales order -> invoice -> GL post
|
||||
* 5. AR: receipt billing -> receipt -> GL post
|
||||
* 6. Purchasing cycle: purchase request -> PO -> receive -> purchase invoice -> GL post
|
||||
* 7. AP: payment billing -> payment -> GL post
|
||||
* 8. Supplier return (confirmed, restocks reversed)
|
||||
* 9. Customer return (draft only — see note below)
|
||||
* 10. Barcode labels for a handful of products
|
||||
* 4. Chart of accounts, departments, GL posting formulas, product account mapping
|
||||
* 5. Sales cycle: quotation -> sales order -> invoice -> GL post
|
||||
* 6. AR: receipt billing -> receipt -> GL post
|
||||
* 7. Purchasing cycle: purchase request -> PO -> receive -> purchase invoice -> GL post
|
||||
* 8. AP: payment billing -> payment -> GL post
|
||||
* 9. Supplier return (confirmed, restocks reversed)
|
||||
* 10. Customer return (draft only — see note below)
|
||||
* 11. 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
|
||||
@@ -46,6 +44,7 @@ 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/ProductManager.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';
|
||||
@@ -89,24 +88,29 @@ $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");
|
||||
|
||||
// Resolve by name: ids depend on what else exists in the database.
|
||||
$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
|
||||
$main_wh = (int)(array_search('Main Warehouse', $warehouses, true) ?: 0);
|
||||
$bangna_wh = (int)(array_search('Bangna Distribution Center', $warehouses, true) ?: 0);
|
||||
if (!$main_wh || !$bangna_wh) exit("ERROR: expected Main Warehouse and Bangna Distribution Center — run demo_seed.php first.\n");
|
||||
|
||||
$sth = $pdo2->prepare("SELECT id, contact_name, contact_type FROM md_contact WHERE company_id = :c ORDER BY id");
|
||||
// md_contact.contact_type is an md_contact_type id, so match on the type name.
|
||||
$sth = $pdo2->prepare(
|
||||
"SELECT c.id, c.contact_name, t.contact_type AS type_name
|
||||
FROM md_contact c
|
||||
JOIN md_contact_type t ON t.company_id = c.company_id AND t.id = c.contact_type
|
||||
WHERE c.company_id = :c
|
||||
ORDER BY c.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);
|
||||
$customer_ids = array_values(array_column(array_filter($contacts, fn($c) => $c['type_name'] === 'Customer'), 'id'));
|
||||
$supplier_ids = array_values(array_column(array_filter($contacts, fn($c) => $c['type_name'] === 'Supplier'), 'id'));
|
||||
if (count($customer_ids) < 4 || count($supplier_ids) < 3) exit("ERROR: expected 4 customers and 3 suppliers — run demo_seed.php first.\n");
|
||||
|
||||
$sth = $pdo2->prepare("SELECT sku, uom, cost_price, price FROM md_product WHERE company_id = :c ORDER BY id");
|
||||
$sth = $pdo2->prepare("SELECT sku, product_name, 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");
|
||||
@@ -114,156 +118,6 @@ if (count($products) < 14) exit("ERROR: expected 14 products — run demo_seed.p
|
||||
$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
|
||||
@@ -379,6 +233,25 @@ foreach ($formula_defs as $doc_type => $def) {
|
||||
$formula_ids[$doc_type] = $id;
|
||||
}
|
||||
|
||||
echo "\n--- Product account mapping ---\n";
|
||||
|
||||
// The sales and purchase formulas split 'total' per product, so Batch GL Entries
|
||||
// refuses to post until every product has sales/purchase accounts
|
||||
// (Account Formulas > Product Accounts, accounting/api/engine/product_account_mapping.php).
|
||||
$sth = $pdo2->prepare("SELECT id, sku, sales_account_code, purchase_account_code FROM md_product WHERE company_id = :c ORDER BY id");
|
||||
$sth->execute([':c' => $company_id]);
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $prod) {
|
||||
if ($prod['sales_account_code'] && $prod['purchase_account_code']) {
|
||||
skip("Product {$prod['sku']} already mapped");
|
||||
continue;
|
||||
}
|
||||
$sales_code = $prod['sales_account_code'] ?: '4000';
|
||||
$purchase_code = $prod['purchase_account_code'] ?: '5000';
|
||||
dbTransaction($pdo2, fn($pdo) => (new ProductManager($pdo, $company_id))
|
||||
->updateAccountMapping((int)$prod['id'], $sales_code, $purchase_code, $logging));
|
||||
ok("Mapped {$prod['sku']}: sales {$sales_code}, purchase {$purchase_code}");
|
||||
}
|
||||
|
||||
/** 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";
|
||||
@@ -409,7 +282,7 @@ function buildLineItem(array $products, string $sku, float $qty): array {
|
||||
$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
|
||||
'product_name' => $p['product_name'], // documents show the product name, as the UI's product search fills it
|
||||
'quantity' => $qty,
|
||||
'unit_price' => $unit_price,
|
||||
'total_price' => $total_price,
|
||||
|
||||
Reference in New Issue
Block a user