initial: bootstrap from BukidBountyApp base
This commit is contained in:
472
app/Http/Controllers/Market/PerformanceController.php
Normal file
472
app/Http/Controllers/Market/PerformanceController.php
Normal file
@@ -0,0 +1,472 @@
|
||||
<?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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user