Files
nnterp-react-admin/app/Console/Commands/ErpStressTest.php

383 lines
18 KiB
PHP

<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use App\Models\User;
use Workdo\Account\Models\Customer;
use Workdo\Account\Models\Vendor;
use Workdo\ProductService\Models\ProductServiceItem;
use App\Models\Warehouse;
use App\Models\PurchaseInvoice;
use App\Models\PurchaseInvoiceItem;
use App\Models\SalesInvoice;
use App\Models\SalesInvoiceItem;
use Illuminate\Support\Facades\Auth;
use App\Events\PostPurchaseInvoice;
use App\Events\PostSalesInvoice;
use Illuminate\Support\Str;
use Workdo\Account\Helpers\AccountUtility;
use Workdo\ProductService\Models\WarehouseStock;
class ErpStressTest extends Command
{
protected $signature = 'erp:stress-test {--count=50 : Number of transactions to generate} {--wipe : Wipe existing transaction records before generating}';
protected $description = 'Stress test full cycle: generates multiple Purchase and Sales Invoices to test Warehouse and Ledgers.';
public function handle()
{
$count = (int) $this->option('count');
$this->info("Starting ERP Stress Test (Generating {$count} Purchases & Sales)...");
if ($this->option('wipe')) {
$this->info("Wiping transaction tables...");
DB::statement('SET FOREIGN_KEY_CHECKS=0;');
SalesInvoice::truncate();
SalesInvoiceItem::truncate();
PurchaseInvoice::truncate();
PurchaseInvoiceItem::truncate();
\Workdo\Account\Models\JournalEntry::truncate();
\Workdo\Account\Models\JournalEntryItem::truncate();
if (class_exists(\Workdo\Warehouse\Models\WarehouseStockMovement::class)) {
\Workdo\Warehouse\Models\WarehouseStockMovement::truncate();
}
if (class_exists(\Workdo\ProductService\Models\WarehouseStock::class)) {
\Workdo\ProductService\Models\WarehouseStock::truncate();
}
\Workdo\Account\Models\Customer::truncate();
\Workdo\Account\Models\Vendor::truncate();
\Workdo\ProductService\Models\ProductServiceItem::truncate();
Warehouse::truncate();
User::whereIn('type', ['client', 'vendor'])->delete();
\Spatie\Activitylog\Models\Activity::truncate();
DB::statement('SET FOREIGN_KEY_CHECKS=1;');
$this->info("Transaction tables wiped cleanly.");
}
// 1. Get Company User (Creator)
$company = User::where('type', 'company')->first();
if (!$company) {
$this->error("No company user found.");
return;
}
Auth::login($company);
$creatorId = creatorId();
$this->info("Authenticated as Company: {$company->name}");
// Setup Accounting default data (creates missing Chart of Accounts)
AccountUtility::defaultdata($creatorId);
// 2. Setup Vendors & Customers
$faker = \Faker\Factory::create('en_PH');
$vendors = [];
for ($i = 0; $i < 5; $i++) {
$vendorEmail = $faker->unique()->safeEmail();
$vendorUser = User::firstOrCreate(
['email' => $vendorEmail],
['name' => $faker->name(), 'password' => \Hash::make('123456'), 'type' => 'vendor', 'created_by' => $creatorId]
);
$vendors[] = Vendor::firstOrCreate(
['contact_person_email' => $vendorEmail],
[
'user_id' => $vendorUser->id, 'vendor_code' => 'V-' . strtoupper(Str::random(5)), 'company_name' => $faker->company() . ' Pharmacy',
'contact_person_name' => $vendorUser->name, 'billing_address' => ['name' => $vendorUser->name, 'address_line_1' => $faker->streetAddress(), 'city' => $faker->city(), 'state' => $faker->stateAbbr(), 'country' => 'Philippines', 'zip_code' => $faker->postcode()],
'shipping_address' => ['name' => $vendorUser->name, 'address_line_1' => $faker->streetAddress(), 'city' => $faker->city(), 'state' => $faker->stateAbbr(), 'country' => 'Philippines', 'zip_code' => $faker->postcode()],
'same_as_billing' => true, 'creator_id' => $company->id, 'created_by' => $creatorId
]
);
}
$customers = [];
for ($i = 0; $i < 5; $i++) {
$customerEmail = $faker->unique()->safeEmail();
$customerUser = User::firstOrCreate(
['email' => $customerEmail],
['name' => $faker->name(), 'password' => \Hash::make('123456'), 'type' => 'client', 'created_by' => $creatorId]
);
$customers[] = Customer::firstOrCreate(
['contact_person_email' => $customerEmail],
[
'user_id' => $customerUser->id, 'customer_code' => 'C-' . strtoupper(Str::random(5)), 'company_name' => $faker->company() . ' Clinic',
'contact_person_name' => $customerUser->name, 'billing_address' => ['name' => $customerUser->name, 'address_line_1' => $faker->streetAddress(), 'city' => $faker->city(), 'state' => $faker->stateAbbr(), 'country' => 'Philippines', 'zip_code' => $faker->postcode()],
'shipping_address' => ['name' => $customerUser->name, 'address_line_1' => $faker->streetAddress(), 'city' => $faker->city(), 'state' => $faker->stateAbbr(), 'country' => 'Philippines', 'zip_code' => $faker->postcode()],
'same_as_billing' => true, 'creator_id' => $company->id, 'created_by' => $creatorId
]
);
}
// 3. Setup Products (Medicines)
$medicineNames = ['Paracetamol', 'Ibuprofen', 'Amoxicillin', 'Cetirizine', 'Losartan', 'Amlodipine', 'Metformin', 'Omeprazole', 'Simvastatin', 'Azithromycin'];
$products = [];
foreach ($medicineNames as $medName) {
$productSku = 'MED-' . strtoupper(Str::random(6));
$products[] = ProductServiceItem::firstOrCreate(
['sku' => $productSku],
[
'name' => $medName . ' ' . $faker->numberBetween(100, 500) . 'mg',
'sale_price' => $faker->randomFloat(2, 50, 200),
'purchase_price' => $faker->randomFloat(2, 10, 40),
'type' => 'product',
'min_stock_level' => 100, // For low stock alerts
'created_by' => $creatorId
]
);
}
// Dead Stock Product (Purchased > 90 days ago, never sold)
$deadProduct = ProductServiceItem::firstOrCreate(
['sku' => 'DEAD-' . strtoupper(Str::random(6))],
[
'name' => 'Expired generic antibiotic',
'sale_price' => 150.00,
'purchase_price' => 50.00,
'type' => 'product',
'min_stock_level' => 10,
'created_by' => $creatorId
]
);
// 4. Setup Warehouses
$warehouses = [];
for ($i = 0; $i < 2; $i++) {
$warehouseName = $faker->city() . ' Distribution Center';
$warehouses[] = Warehouse::firstOrCreate(
['name' => $warehouseName, 'created_by' => $creatorId],
['address' => $faker->streetAddress(), 'city' => $faker->city(), 'zip_code' => $faker->postcode()]
);
}
$this->info("Entities Verified. Generating {$count} transactions...");
$bar = $this->output->createProgressBar($count * 2 + 1);
$bar->start();
// 5. Setup Dead Stock (95 days ago)
$deadVendor = $vendors[array_rand($vendors)];
$deadWarehouse = $warehouses[array_rand($warehouses)];
$deadDate = now()->subDays(95);
$deadQty = 200;
$deadPurchase = new PurchaseInvoice();
$deadPurchase->invoice_number = 'P-TEST-DEAD';
$deadPurchase->invoice_date = $deadDate;
$deadPurchase->due_date = $deadDate->copy()->addDays(7);
$deadPurchase->vendor_id = $deadVendor->user_id;
$deadPurchase->warehouse_id = $deadWarehouse->id;
$deadPurchase->status = 'posted';
$deadPurchase->subtotal = $deadQty * $deadProduct->purchase_price;
$deadPurchase->total_amount = $deadQty * $deadProduct->purchase_price;
$deadPurchase->balance_amount = $deadQty * $deadProduct->purchase_price;
$deadPurchase->creator_id = $company->id;
$deadPurchase->created_by = $creatorId;
$deadPurchase->save();
PurchaseInvoiceItem::create([
'invoice_id' => $deadPurchase->id,
'product_id' => $deadProduct->id,
'quantity' => $deadQty,
'unit_price' => $deadProduct->purchase_price,
]);
if (class_exists(\Workdo\ProductService\Models\WarehouseStock::class)) {
$stock = \Workdo\ProductService\Models\WarehouseStock::firstOrCreate(
['warehouse_id' => $deadWarehouse->id, 'product_id' => $deadProduct->id],
['quantity' => 0, 'created_by' => $creatorId, 'workspace' => getActiveWorkSpace() ?? 1]
);
$stock->quantity += $deadQty;
$stock->save();
}
if (class_exists(\Workdo\Warehouse\Models\WarehouseStockMovement::class)) {
\Workdo\Warehouse\Models\WarehouseStockMovement::create([
'product_id' => $deadProduct->id,
'warehouse_id' => $deadWarehouse->id,
'quantity' => $deadQty,
'type' => 'in',
'reason' => 'Purchase Receipt',
'reference_type' => PurchaseInvoice::class,
'reference_id' => $deadPurchase->id,
'description' => 'Received Dead Stock',
'created_by' => $creatorId,
'workspace_id' => getActiveWorkSpace() ?? 1,
'created_at' => $deadDate,
]);
}
$bar->advance();
// 6. Setup Low Stock product explicitly to ensure at least one triggers the alert
$lowStockProduct = $products[0]; // pick the first one
for ($i = 0; $i < $count; $i++) {
$randomVendor = $vendors[array_rand($vendors)];
$randomWarehouse = $warehouses[array_rand($warehouses)];
$randomProduct = $products[array_rand($products)];
// Purchase Invoice
$purchaseInvoiceNumber = 'P-TEST-' . strtoupper(Str::random(5)) . '-' . $i;
$purchase = new PurchaseInvoice();
$purchase->invoice_number = $purchaseInvoiceNumber;
$purchase->invoice_date = now()->subDays(rand(1, 30));
$purchase->due_date = now()->addDays(7);
$purchase->vendor_id = $randomVendor->user_id;
$purchase->warehouse_id = $randomWarehouse->id;
$purchase->status = 'draft';
$purchaseQty = rand(10, 100);
$purchaseTotal = $purchaseQty * $randomProduct->purchase_price;
$purchase->subtotal = $purchaseTotal;
$purchase->total_amount = $purchaseTotal;
$purchase->balance_amount = $purchaseTotal;
$purchase->creator_id = $company->id;
$purchase->created_by = $creatorId;
$purchase->save();
PurchaseInvoiceItem::create([
'invoice_id' => $purchase->id,
'product_id' => $randomProduct->id,
'quantity' => $purchaseQty,
'unit_price' => $randomProduct->purchase_price,
]);
try {
PostPurchaseInvoice::dispatch($purchase);
$purchase->update(['status' => 'posted']);
// Simulate GRN / Receiving movement for the dashboard
if (class_exists(\Workdo\Warehouse\Models\WarehouseStockMovement::class)) {
\Workdo\Warehouse\Models\WarehouseStockMovement::create([
'product_id' => $randomProduct->id,
'warehouse_id' => $randomWarehouse->id,
'quantity' => $purchaseQty,
'type' => 'in',
'reason' => 'Purchase Receipt',
'reference_type' => PurchaseInvoice::class,
'reference_id' => $purchase->id,
'description' => 'Received from Purchase ' . $purchaseInvoiceNumber,
'created_by' => $creatorId,
'workspace_id' => getActiveWorkSpace() ?? 1,
'created_at' => $purchase->invoice_date,
]);
}
} catch (\Exception $e) {
// Log and ignore to continue stress test
}
$bar->advance();
$randomCustomer = $customers[array_rand($customers)];
$randomSaleWarehouse = $warehouses[array_rand($warehouses)];
// To ensure low stock alert is triggered for products[0], we randomly sell a lot of it, while keeping purchasing random.
// Also ensure we NEVER sell the dead stock product.
$randomSaleProduct = $i % 5 === 0 ? $lowStockProduct : $products[array_rand($products)];
// Sales Invoice
$salesInvoiceNumber = 'S-TEST-' . strtoupper(Str::random(5)) . '-' . $i;
$sale = new SalesInvoice();
$sale->invoice_number = $salesInvoiceNumber;
$sale->invoice_date = now()->subDays(rand(1, 30));
$sale->due_date = now()->addDays(7);
$sale->customer_id = $randomCustomer->user_id;
$sale->warehouse_id = $randomSaleWarehouse->id;
$sale->status = 'draft';
// Generate higher sales qty for the designated low stock product to drain it
$saleQty = ($randomSaleProduct->id === $lowStockProduct->id) ? rand(20, 50) : rand(1, 5);
$saleTotal = $saleQty * $randomSaleProduct->sale_price;
$sale->subtotal = $saleTotal;
$sale->total_amount = $saleTotal;
$sale->balance_amount = $saleTotal;
$sale->creator_id = $company->id;
$sale->created_by = $creatorId;
$sale->save();
SalesInvoiceItem::create([
'invoice_id' => $sale->id,
'product_id' => $randomSaleProduct->id,
'quantity' => $saleQty,
'unit_price' => $randomSaleProduct->sale_price,
]);
try {
PostSalesInvoice::dispatch($sale);
$sale->update(['status' => 'posted']);
// Simulate Fulfillment/Dispatch movement and deduct stock
if (class_exists(\Workdo\Warehouse\Models\WarehouseStockMovement::class)) {
\Workdo\Warehouse\Models\WarehouseStockMovement::create([
'product_id' => $randomSaleProduct->id,
'warehouse_id' => $randomSaleWarehouse->id,
'quantity' => $saleQty,
'type' => 'out',
'reason' => 'Sales Dispatch',
'reference_type' => SalesInvoice::class,
'reference_id' => $sale->id,
'description' => 'Dispatched for Sale ' . $salesInvoiceNumber,
'created_by' => $creatorId,
'workspace_id' => getActiveWorkSpace() ?? 1,
'created_at' => $sale->invoice_date,
]);
}
// Deduct stock (since PostSalesInvoice doesn't do it automatically)
if (class_exists(\Workdo\ProductService\Models\WarehouseStock::class)) {
$stock = \Workdo\ProductService\Models\WarehouseStock::where('warehouse_id', $randomSaleWarehouse->id)
->where('product_id', $randomSaleProduct->id)
->first();
if ($stock) {
$stock->decrement('quantity', $saleQty);
}
}
} catch (\Exception $e) {
// Log and ignore
}
$bar->advance();
}
// Final drain of the low stock product to guarantee it goes under 100
if (class_exists(\Workdo\ProductService\Models\WarehouseStock::class)) {
$totalLowStock = \Workdo\ProductService\Models\WarehouseStock::where('product_id', $lowStockProduct->id)->sum('quantity');
if ($totalLowStock > 50) {
$drainQty = $totalLowStock - 10; // leave 10 left (which is < 100 min stock)
$drainWarehouse = $warehouses[0];
\Workdo\Warehouse\Models\WarehouseStockMovement::create([
'product_id' => $lowStockProduct->id,
'warehouse_id' => $drainWarehouse->id,
'quantity' => $drainQty,
'type' => 'out',
'reason' => 'Inventory Adjustment',
'reference_type' => null,
'reference_id' => 0,
'description' => 'Forced stock drain for Low Stock Alert',
'created_by' => $creatorId,
'workspace_id' => getActiveWorkSpace() ?? 1,
'created_at' => now(),
]);
$stock = \Workdo\ProductService\Models\WarehouseStock::where('warehouse_id', $drainWarehouse->id)
->where('product_id', $lowStockProduct->id)
->first();
if ($stock) {
$stock->decrement('quantity', $drainQty);
}
}
}
$bar->finish();
$this->newLine(2);
$this->info("ERP Stress Test Completed!");
$this->info("Total Purchases Generated: {$count}");
$this->info("Total Sales Generated: {$count}");
$this->info("Transactions can now be viewed in the ERP Dashboard!");
}
}