Files
BarangaySystem/app/Http/Controllers/Market/PerformanceController.php
2026-06-06 18:43:00 +08:00

473 lines
17 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers\Market;
use App\Enums\UserTypes;
use App\Http\Controllers\Helpers\ResponseHelper;
use App\Models\Market\PosSession;
use App\Models\Market\PosTransaction;
use App\Models\Market\Product;
use App\Models\Market\Store;
use App\Models\User;
use Hyperf\Stringable\Str;
use Hypervel\Http\Request;
use Hypervel\Support\Facades\DB;
use Hypervel\Support\Facades\Hash;
use Hypervel\Support\Facades\Response;
/**
* Performance / load-testing endpoints.
*
* These bypass session auth so a client machine can hit them with curl.
* Auth is via header `X-Perf-Token` matched against env `PERF_API_TOKEN`.
* If `PERF_API_TOKEN` is unset the endpoints are disabled (403 for all).
*
* All endpoints return timing metrics (ms) so the caller can chart how the
* box behaves under different batch sizes.
*/
class PerformanceController
{
private const DEFAULT_LIMIT = 1000;
private function authorize(Request $request)
{
$expected = (string) env('PERF_API_TOKEN', '');
if ($expected === '') {
return ResponseHelper::returnError('Performance API is disabled (PERF_API_TOKEN not set)', 403);
}
$provided = (string) ($request->header('X-Perf-Token') ?? $request->input('token', ''));
if (!hash_equals($expected, $provided)) {
return ResponseHelper::returnError('Invalid perf token', 401);
}
return null;
}
private function actingUser(): ?User
{
$hash = (string) env('PERF_ACTOR_HASH', '');
if ($hash !== '') {
$u = User::where('hashkey', $hash)->first();
if ($u) return $u;
}
return User::where('acct_type', UserTypes::ULTIMATE->value)
->orderBy('id', 'asc')
->first();
}
private function clampCount($raw): int
{
$n = (int) $raw;
if ($n < 1) $n = 1;
if ($n > self::DEFAULT_LIMIT) $n = self::DEFAULT_LIMIT;
return $n;
}
private function ms(float $start): float
{
return round((microtime(true) - $start) * 1000, 3);
}
public function ping(Request $request)
{
$deny = $this->authorize($request);
if ($deny) return $deny;
return Response::json([
'success' => true,
'ts' => now()->toIso8601String(),
'php' => PHP_VERSION,
]);
}
/**
* Core user seeder, returns ['hashes' => [...], 'ms' => float].
*/
private function _seedUsers(User $actor, int $count, string $type, ?int $parentId, string $prefix): array
{
$parentId = $parentId ?? $actor->id;
$hashed = Hash::make('Perf12345!');
$start = microtime(true);
$created = [];
DB::beginTransaction();
try {
foreach (range(1, $count) as $i) {
$suffix = Str::random(10);
$u = User::create([
'username' => "{$prefix}_{$suffix}",
'name' => "Perf User {$i}",
'mobile_number' => '09' . str_pad((string) random_int(0, 999999999), 9, '0', STR_PAD_LEFT),
'password' => $hashed,
'acct_type' => $type,
'parentuid' => $parentId,
'active' => true,
]);
$created[] = $u->hashkey;
}
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
return ['hashes' => $created, 'ms' => $this->ms($start)];
}
private function _seedStores(User $actor, int $count, string $category, ?int $ownerId, string $prefix): array
{
$start = microtime(true);
$created = [];
DB::beginTransaction();
try {
foreach (range(1, $count) as $i) {
$storeCode = StoreController::generateStoreCode($category);
$s = Store::create([
'storecode' => $storeCode,
'name' => "{$prefix} " . Str::random(8),
'description' => "Synthetic store for perf testing #{$i}",
'address' => 'Perf Lane ' . random_int(1, 9999),
'category' => $category,
'subcategory' => '',
'owner_id' => $ownerId,
'created_by' => $actor->id,
'is_active' => true,
'status' => 'active',
]);
$created[] = $s->hashkey;
}
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
return ['hashes' => $created, 'ms' => $this->ms($start)];
}
private function _seedProducts(User $actor, int $count, ?Store $store, bool $attach, string $prefix): array
{
$start = microtime(true);
$created = [];
DB::beginTransaction();
try {
foreach (range(1, $count) as $i) {
$price = random_int(10, 5000);
$available = random_int(10, 500);
$p = Product::create([
'name' => "{$prefix} " . Str::random(8),
'description' => "Synthetic product for perf testing #{$i}",
'price' => $price,
'unitname' => 'pc',
'available' => $available,
'category' => 'Perf',
'subcategory' => 'Synthetic',
'created_by' => $actor->id,
'is_active' => true,
]);
if ($store && $attach) {
$store->products()->attach($p->id, [
'available' => $available,
'price' => $price,
'description' => $p->description,
'is_active' => true,
]);
}
$created[] = $p->hashkey;
}
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
return ['hashes' => $created, 'ms' => $this->ms($start)];
}
/**
* POST /api/perf/seed/users
* body: { count, type?, parent_hash?, prefix? }
*/
public function seedUsers(Request $request)
{
$deny = $this->authorize($request);
if ($deny) return $deny;
$actor = $this->actingUser();
if (!$actor) return ResponseHelper::returnError('No actor user available', 500);
$count = $this->clampCount($request->input('count', 10));
$type = (string) $request->input('type', UserTypes::USER->value);
$prefix = (string) $request->input('prefix', 'perf');
if (!UserTypes::tryFrom($type)) {
return ResponseHelper::returnError("Invalid user type '{$type}'", 422);
}
$parentId = null;
if ($p = $request->input('parent_hash')) {
$parent = User::where('hashkey', (string) $p)->first();
if ($parent) $parentId = $parent->id;
}
try {
$r = $this->_seedUsers($actor, $count, $type, $parentId, $prefix);
} catch (\Throwable $e) {
return ResponseHelper::returnError($e->getMessage());
}
return Response::json([
'success' => true,
'count' => count($r['hashes']),
'total_ms' => $r['ms'],
'avg_ms' => round($r['ms'] / max(1, count($r['hashes'])), 3),
'sample' => array_slice($r['hashes'], 0, 5),
]);
}
/**
* POST /api/perf/seed/stores
* body: { count, owner_hash?, category?, prefix? }
*/
public function seedStores(Request $request)
{
$deny = $this->authorize($request);
if ($deny) return $deny;
$actor = $this->actingUser();
if (!$actor) return ResponseHelper::returnError('No actor user available', 500);
$count = $this->clampCount($request->input('count', 10));
$category = (string) $request->input('category', 'General');
$prefix = (string) $request->input('prefix', 'PerfStore');
$ownerId = null;
if ($h = $request->input('owner_hash')) {
$owner = User::where('hashkey', (string) $h)->first();
if ($owner) $ownerId = $owner->id;
}
try {
$r = $this->_seedStores($actor, $count, $category, $ownerId, $prefix);
} catch (\Throwable $e) {
return ResponseHelper::returnError($e->getMessage());
}
return Response::json([
'success' => true,
'count' => count($r['hashes']),
'total_ms' => $r['ms'],
'avg_ms' => round($r['ms'] / max(1, count($r['hashes'])), 3),
'sample' => array_slice($r['hashes'], 0, 5),
]);
}
/**
* POST /api/perf/seed/products
* body: { count, target_store_hash?, prefix?, attach_to_store? }
*/
public function seedProducts(Request $request)
{
$deny = $this->authorize($request);
if ($deny) return $deny;
$actor = $this->actingUser();
if (!$actor) return ResponseHelper::returnError('No actor user available', 500);
$count = $this->clampCount($request->input('count', 10));
$prefix = (string) $request->input('prefix', 'PerfProduct');
$attach = (bool) $request->input('attach_to_store', true);
$store = null;
if ($h = $request->input('target_store_hash')) {
$store = Store::where('hashkey', (string) $h)->first();
if (!$store) return ResponseHelper::returnError('Target store not found', 404);
}
try {
$r = $this->_seedProducts($actor, $count, $store, $attach, $prefix);
} catch (\Throwable $e) {
return ResponseHelper::returnError($e->getMessage());
}
return Response::json([
'success' => true,
'count' => count($r['hashes']),
'total_ms' => $r['ms'],
'avg_ms' => round($r['ms'] / max(1, count($r['hashes'])), 3),
'attached_to_store' => $store?->hashkey,
'sample' => array_slice($r['hashes'], 0, 5),
]);
}
/**
* POST /api/perf/seed/batch
* body: { users?, stores?, products?, prefix?, type?, category? }
* Runs all three seeders sequentially with per-phase timings.
*/
public function seedBatch(Request $request)
{
$deny = $this->authorize($request);
if ($deny) return $deny;
$actor = $this->actingUser();
if (!$actor) return ResponseHelper::returnError('No actor user available', 500);
$prefix = (string) $request->input('prefix', 'perf');
$type = (string) $request->input('type', UserTypes::USER->value);
if (!UserTypes::tryFrom($type)) {
return ResponseHelper::returnError("Invalid user type '{$type}'", 422);
}
$category = (string) $request->input('category', 'General');
$users = max(0, (int) $request->input('users', 0));
$stores = max(0, (int) $request->input('stores', 0));
$products = max(0, (int) $request->input('products', 0));
$phases = [];
$totalStart = microtime(true);
try {
if ($users > 0) {
$r = $this->_seedUsers($actor, $this->clampCount($users), $type, null, $prefix);
$phases['users'] = ['count' => count($r['hashes']), 'ms' => $r['ms']];
}
if ($stores > 0) {
$r = $this->_seedStores($actor, $this->clampCount($stores), $category, null, $prefix . 'Store');
$phases['stores'] = ['count' => count($r['hashes']), 'ms' => $r['ms']];
}
if ($products > 0) {
$r = $this->_seedProducts($actor, $this->clampCount($products), null, false, $prefix . 'Product');
$phases['products'] = ['count' => count($r['hashes']), 'ms' => $r['ms']];
}
} catch (\Throwable $e) {
return ResponseHelper::returnError($e->getMessage());
}
return Response::json([
'success' => true,
'total_ms' => $this->ms($totalStart),
'phases' => $phases,
]);
}
/**
* POST /api/perf/pos/simulate
* body: { store_hash, items?, cycles?, complete? }
*
* Runs end-to-end POS cycles entirely server-side: open session, add N
* line items, optionally complete + archive. Returns per-phase timings.
*/
public function simulatePos(Request $request)
{
$deny = $this->authorize($request);
if ($deny) return $deny;
$actor = $this->actingUser();
if (!$actor) return ResponseHelper::returnError('No actor user available', 500);
$storeHash = (string) $request->input('store_hash', '');
if ($storeHash === '') return ResponseHelper::returnError('store_hash is required', 422);
$store = Store::where('hashkey', $storeHash)->first();
if (!$store) return ResponseHelper::returnError('Store not found', 404);
$items = max(1, min(200, (int) $request->input('items', 5)));
$cycles = max(1, min(100, (int) $request->input('cycles', 1)));
$complete = (bool) $request->input('complete', true);
// Pull a pool of products attached to this store; fall back to global
// active products if the store has none yet.
$productIds = DB::table('prd_str')
->where('store_id', $store->id)
->where('is_active', true)
->pluck('product_id')
->toArray();
if (empty($productIds)) {
$productIds = Product::where('is_active', true)
->limit(max(50, $items * 2))
->pluck('id')
->toArray();
}
if (empty($productIds)) {
return ResponseHelper::returnError('No products available to simulate sales', 422);
}
$products = Product::whereIn('id', $productIds)->get(['id', 'hashkey', 'price'])->keyBy('id');
$cycleResults = [];
$totalStart = microtime(true);
for ($c = 1; $c <= $cycles; $c++) {
$cStart = microtime(true);
// 1. Open session
$t = microtime(true);
$session = PosSession::create([
'access_key' => Str::random(32),
'store_id' => $store->id,
'customer_name' => 'Perf Customer ' . $c,
'status' => 'active',
'created_by' => $actor->id,
]);
$openMs = $this->ms($t);
// 2. Add items (raw inserts for speed, mirrors PosController::addItem)
$t = microtime(true);
$now = now();
$rows = [];
$total = 0;
for ($i = 0; $i < $items; $i++) {
$pid = $productIds[array_rand($productIds)];
$product = $products[$pid] ?? null;
if (!$product) continue;
$qty = random_int(1, 5);
$price = (int) $product->price;
$line = $price * $qty;
$total += $line;
$rows[] = [
'pos_session_id' => $session->id,
'product_id' => $pid,
'quantity' => $qty,
'price_at_sale' => $price,
'total_price' => $line,
'hashkey' => Str::uuid()->toString() . Str::random(100),
'created_at' => $now,
'updated_at' => $now,
'created_by' => $actor->id,
];
}
if (!empty($rows)) {
DB::table('pos_transactions')->insert($rows);
}
$addMs = $this->ms($t);
// 3. Optionally complete the session
$completeMs = null;
if ($complete) {
$t = microtime(true);
DB::table('pos_sessions')->where('id', $session->id)->update([
'total_amount' => $total,
'received_amount' => $total,
'change_amount' => 0,
'status' => 'completed',
'payment_method' => 'cash',
'updated_at' => $now,
'updated_by' => $actor->id,
]);
$completeMs = $this->ms($t);
}
$cycleResults[] = [
'session_hash' => $session->hashkey,
'items' => count($rows),
'total' => $total,
'open_ms' => $openMs,
'add_items_ms' => $addMs,
'complete_ms' => $completeMs,
'cycle_ms' => $this->ms($cStart),
];
}
$totalMs = $this->ms($totalStart);
$cycleMsValues = array_column($cycleResults, 'cycle_ms');
$avg = $cycleMsValues ? array_sum($cycleMsValues) / count($cycleMsValues) : 0.0;
return Response::json([
'success' => true,
'store_hash' => $store->hashkey,
'cycles' => $cycles,
'items_per_cycle' => $items,
'total_ms' => $totalMs,
'avg_cycle_ms' => round($avg, 3),
'min_cycle_ms' => $cycleMsValues ? min($cycleMsValues) : 0,
'max_cycle_ms' => $cycleMsValues ? max($cycleMsValues) : 0,
'detail' => $cycleResults,
]);
}
}