From 62bcf68bdb8da8a27e3c5355e72c2ecf491cb71e Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 1 Jul 2026 15:02:43 +0800 Subject: [PATCH] feat: add partner store analytics, history logs, and global SRP validation --- .../V1/Admin/AdminPartnerStoreController.php | 77 +++++- .../views/admin/partner-stores.blade.php | 226 +++++++++++++++++- routes/api.php | 3 + .../Feature/Api/V1/Store/PartnerStoreTest.php | 79 ++++++ 4 files changed, 382 insertions(+), 3 deletions(-) diff --git a/app/Http/Controllers/Api/V1/Admin/AdminPartnerStoreController.php b/app/Http/Controllers/Api/V1/Admin/AdminPartnerStoreController.php index 1109af4..39f1d61 100644 --- a/app/Http/Controllers/Api/V1/Admin/AdminPartnerStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/AdminPartnerStoreController.php @@ -128,19 +128,23 @@ class AdminPartnerStoreController extends ApiController public function recordSale(Request $request, PartnerStore $store): JsonResponse { + $globalPrice = config('qr.default_retail_price_per_code_centavos', 1000); + $data = $request->validate([ 'household_id' => ['required', 'integer', 'exists:households,id'], 'quantity' => ['required', 'integer', 'min:1', 'max:1000'], - 'retail_price_per_code_centavos' => ['required', 'integer', 'min:0'], + 'retail_price_per_code_centavos' => ['nullable', 'integer', 'in:' . $globalPrice], ]); + $retailPrice = (int) ($data['retail_price_per_code_centavos'] ?? $globalPrice); + try { $household = Household::findOrFail($data['household_id']); $sale = $this->ops->sellToHousehold( $store, $household, (int) $data['quantity'], - (int) $data['retail_price_per_code_centavos'], + $retailPrice, ); } catch (\DomainException $e) { return $this->fail($e->getMessage(), null, 422); @@ -153,4 +157,73 @@ class AdminPartnerStoreController extends ApiController 'commission_centavos' => $sale->commission_centavos, ], 'Sale recorded'); } + + public function purchases(Request $request, PartnerStore $store): JsonResponse + { + $purchases = $store->purchases() + ->with('batch') + ->latest() + ->paginate(15); + + return $this->ok( + $purchases->map(fn ($p) => [ + 'id' => $p->id, + 'batch_number' => $p->batch?->batch_number ?? '—', + 'quantity' => $p->quantity, + 'wholesale_price_pesos' => number_format($p->wholesale_price_centavos / 100, 2), + 'paid_at' => $p->paid_at?->toIso8601String() ?? $p->created_at?->toIso8601String(), + ]), + null, + [ + 'page' => $purchases->currentPage(), + 'per_page' => $purchases->perPage(), + 'total' => $purchases->total(), + 'last_page' => $purchases->lastPage(), + ] + ); + } + + public function salesHistory(Request $request, PartnerStore $store): JsonResponse + { + $sales = $store->sales() + ->with(['household.head']) + ->latest() + ->paginate(15); + + return $this->ok( + $sales->map(fn ($s) => [ + 'id' => $s->id, + 'household_name' => $s->household?->head?->full_name ?? '—', + 'household_email' => $s->household?->head?->email ?? '—', + 'quantity' => $s->quantity, + 'retail_price_pesos' => number_format($s->retail_price_centavos / 100, 2), + 'commission_pesos' => number_format($s->commission_centavos / 100, 2), + 'sold_at' => $s->sold_at?->toIso8601String() ?? $s->created_at?->toIso8601String(), + ]), + null, + [ + 'page' => $sales->currentPage(), + 'per_page' => $sales->perPage(), + 'total' => $sales->total(), + 'last_page' => $sales->lastPage(), + ] + ); + } + + public function analytics(PartnerStore $store): JsonResponse + { + $totalWholesaleCost = (int) $store->purchases()->sum('wholesale_price_centavos'); + $totalRetailRevenue = (int) $store->sales()->sum('retail_price_centavos'); + $totalCommission = (int) $store->sales()->sum('commission_centavos'); + $grossProfit = $totalRetailRevenue - $totalWholesaleCost; + + return $this->ok([ + 'total_wholesale_cost_pesos' => number_format($totalWholesaleCost / 100, 2), + 'total_retail_revenue_pesos' => number_format($totalRetailRevenue / 100, 2), + 'total_commission_pesos' => number_format($totalCommission / 100, 2), + 'gross_profit_pesos' => number_format($grossProfit / 100, 2), + 'total_issued_codes' => (int) $store->purchases()->sum('quantity'), + 'total_sold_codes' => (int) $store->sales()->sum('quantity'), + ]); + } } diff --git a/resources/views/admin/partner-stores.blade.php b/resources/views/admin/partner-stores.blade.php index bfc84ba..c28bd55 100644 --- a/resources/views/admin/partner-stores.blade.php +++ b/resources/views/admin/partner-stores.blade.php @@ -73,6 +73,7 @@ @@ -134,6 +135,86 @@

Quick Actions

+ +
+

Wholesale Purchase History

+
+ + + + + + + + + + + + +
DateBatch #QtyCost (₱)
No purchases found.
+
+ +
+ + + {{-- TAB: Analytics (edit mode only) --}} + @@ -238,9 +319,133 @@ }); } + let purchasesPage = 1; + let salesPage = 1; + + async function fetchPurchases(storeId, page = 1) { + if (page === 1) { + document.getElementById('inventory-purchases-rows').innerHTML = `Loading history…`; + document.getElementById('inventory-purchases-more').classList.add('hidden'); + } else { + const btn = document.getElementById('inventory-purchases-more-btn'); + btn.textContent = 'Loading…'; + btn.disabled = true; + } + + const res = await window.Verde.apiFetch(`/api/v1/admin/partner-stores/${storeId}/purchases?page=${page}`); + if (!res.ok) { + if (page === 1) { + document.getElementById('inventory-purchases-rows').innerHTML = `Failed to load history.`; + } + return; + } + + const data = res.body.data ?? []; + const meta = res.body.meta ?? {}; + const tbody = document.getElementById('inventory-purchases-rows'); + + const rowsHtml = data.map(p => { + const dateStr = p.paid_at ? new Date(p.paid_at).toLocaleString() : '—'; + return ` + ${dateStr} + ${window.Verde.escapeHtml(p.batch_number)} + ${p.quantity} + ₱${p.wholesale_price_pesos} + `; + }).join(''); + + if (page === 1) { + tbody.innerHTML = rowsHtml.length > 0 ? rowsHtml : `No purchases found.`; + } else { + tbody.insertAdjacentHTML('beforeend', rowsHtml); + } + + const btn = document.getElementById('inventory-purchases-more-btn'); + btn.textContent = 'Load More'; + btn.disabled = false; + + const hasMore = meta.page < meta.last_page; + document.getElementById('inventory-purchases-more').classList.toggle('hidden', !hasMore); + } + + async function fetchSales(storeId, page = 1) { + if (page === 1) { + document.getElementById('analytics-sales-rows').innerHTML = `Loading history…`; + document.getElementById('analytics-sales-more').classList.add('hidden'); + } else { + const btn = document.getElementById('analytics-sales-more-btn'); + btn.textContent = 'Loading…'; + btn.disabled = true; + } + + const res = await window.Verde.apiFetch(`/api/v1/admin/partner-stores/${storeId}/sales-history?page=${page}`); + if (!res.ok) { + if (page === 1) { + document.getElementById('analytics-sales-rows').innerHTML = `Failed to load history.`; + } + return; + } + + const data = res.body.data ?? []; + const meta = res.body.meta ?? {}; + const tbody = document.getElementById('analytics-sales-rows'); + + const rowsHtml = data.map(s => { + const dateStr = s.sold_at ? new Date(s.sold_at).toLocaleString() : '—'; + return ` + ${dateStr} + +
${window.Verde.escapeHtml(s.household_name)}
+
${window.Verde.escapeHtml(s.household_email)}
+ + ${s.quantity} + ₱${s.retail_price_pesos} + ₱${s.commission_pesos} + `; + }).join(''); + + if (page === 1) { + tbody.innerHTML = rowsHtml.length > 0 ? rowsHtml : `No sales recorded.`; + } else { + tbody.insertAdjacentHTML('beforeend', rowsHtml); + } + + const btn = document.getElementById('analytics-sales-more-btn'); + btn.textContent = 'Load More'; + btn.disabled = false; + + const hasMore = meta.page < meta.last_page; + document.getElementById('analytics-sales-more').classList.toggle('hidden', !hasMore); + } + + async function fetchAnalytics(storeId) { + document.getElementById('analytics-revenue').textContent = '₱...'; + document.getElementById('analytics-cost').textContent = '₱...'; + document.getElementById('analytics-profit').textContent = '₱...'; + document.getElementById('analytics-commission').textContent = '₱...'; + document.getElementById('analytics-codes-issued').textContent = '...'; + document.getElementById('analytics-codes-sold').textContent = '...'; + + const res = await window.Verde.apiFetch(`/api/v1/admin/partner-stores/${storeId}/analytics`); + if (!res.ok) return; + + const data = res.body; + document.getElementById('analytics-revenue').textContent = `₱${data.total_retail_revenue_pesos}`; + document.getElementById('analytics-cost').textContent = `₱${data.total_wholesale_cost_pesos}`; + + const profitVal = parseFloat(data.gross_profit_pesos.replace(/,/g, '')); + const profitEl = document.getElementById('analytics-profit'); + profitEl.textContent = `₱${data.gross_profit_pesos}`; + profitEl.className = profitVal >= 0 ? 'text-lg font-bold text-green-700 mt-1' : 'text-lg font-bold text-red-600 mt-1'; + + document.getElementById('analytics-commission').textContent = `₱${data.total_commission_pesos}`; + document.getElementById('analytics-codes-issued').textContent = data.total_issued_codes; + document.getElementById('analytics-codes-sold').textContent = data.total_sold_codes; + } + // Tab switching (edit mode only) function switchTab(name) { - document.querySelectorAll('#tab-details, #tab-inventory').forEach(p => p.classList.add('hidden')); + document.querySelectorAll('#tab-details, #tab-inventory, #tab-analytics').forEach(p => p.classList.add('hidden')); document.getElementById(`tab-${name}`).classList.remove('hidden'); document.querySelectorAll('.tab-btn').forEach(b => { const active = b.dataset.tab === name; @@ -249,6 +454,13 @@ }); if (name === 'details' && pickerMap) { setTimeout(() => pickerMap.invalidateSize(), 50); + } else if (name === 'inventory' && editingStoreId) { + purchasesPage = 1; + fetchPurchases(editingStoreId, 1); + } else if (name === 'analytics' && editingStoreId) { + salesPage = 1; + fetchAnalytics(editingStoreId); + fetchSales(editingStoreId, 1); } } @@ -618,6 +830,18 @@ } }); + document.getElementById('inventory-purchases-more-btn').addEventListener('click', () => { + if (!editingStoreId) return; + purchasesPage += 1; + fetchPurchases(editingStoreId, purchasesPage); + }); + + document.getElementById('analytics-sales-more-btn').addEventListener('click', () => { + if (!editingStoreId) return; + salesPage += 1; + fetchSales(editingStoreId, salesPage); + }); + loadOwners(); load(); diff --git a/routes/api.php b/routes/api.php index dd50170..adfa39f 100644 --- a/routes/api.php +++ b/routes/api.php @@ -307,6 +307,9 @@ Route::prefix('admin/partner-stores') Route::patch('/{store}', [AdminPartnerStoreController::class, 'update'])->name('update'); Route::post('/{store}/issue-inventory', [AdminPartnerStoreController::class, 'issueInventory'])->name('issue-inventory'); Route::post('/{store}/sales', [AdminPartnerStoreController::class, 'recordSale'])->name('sales'); + Route::get('/{store}/purchases', [AdminPartnerStoreController::class, 'purchases'])->name('purchases'); + Route::get('/{store}/sales-history', [AdminPartnerStoreController::class, 'salesHistory'])->name('sales-history'); + Route::get('/{store}/analytics', [AdminPartnerStoreController::class, 'analytics'])->name('analytics'); }); Route::prefix('admin/reports') diff --git a/tests/Feature/Api/V1/Store/PartnerStoreTest.php b/tests/Feature/Api/V1/Store/PartnerStoreTest.php index 001bcfb..9e3a552 100644 --- a/tests/Feature/Api/V1/Store/PartnerStoreTest.php +++ b/tests/Feature/Api/V1/Store/PartnerStoreTest.php @@ -116,4 +116,83 @@ class PartnerStoreTest extends TestCase $response->assertStatus(422); } + + public function test_sale_omitting_price_uses_global_srp(): void + { + Sanctum::actingAs($this->admin); + $store = PartnerStore::factory()->create(['status' => 'active', 'commission_rate_percent' => 10]); + app(StoreOperations::class)->issueWholesale($store, 10, 50000); + $household = Household::factory()->create(); + + $response = $this->postJson("/api/v1/admin/partner-stores/{$store->uuid}/sales", [ + 'household_id' => $household->id, + 'quantity' => 5, + ]); + + $response->assertCreated() + ->assertJsonPath('data.quantity', 5) + ->assertJsonPath('data.retail_price_centavos', 5000) + ->assertJsonPath('data.commission_centavos', 500); + } + + public function test_sale_with_incorrect_price_fails_validation(): void + { + Sanctum::actingAs($this->admin); + $store = PartnerStore::factory()->create(['status' => 'active']); + app(StoreOperations::class)->issueWholesale($store, 10, 50000); + $household = Household::factory()->create(); + + $response = $this->postJson("/api/v1/admin/partner-stores/{$store->uuid}/sales", [ + 'household_id' => $household->id, + 'quantity' => 5, + 'retail_price_per_code_centavos' => 1200, + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['retail_price_per_code_centavos']); + } + + public function test_admin_can_view_analytics_and_history_endpoints(): void + { + Sanctum::actingAs($this->admin); + $store = PartnerStore::factory()->create(['status' => 'active', 'commission_rate_percent' => 10]); + + // Issue wholesale (creates purchase) + app(StoreOperations::class)->issueWholesale($store, 20, 100000); + + // Sell to household (creates sale) + $household = Household::factory()->create(); + app(StoreOperations::class)->sellToHousehold($store, $household, 5, 1000); + + // Test purchases endpoint + $response = $this->getJson("/api/v1/admin/partner-stores/{$store->uuid}/purchases"); + $response->assertOk() + ->assertJsonStructure(['data', 'meta']) + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.quantity', 20) + ->assertJsonPath('data.0.wholesale_price_pesos', '1,000.00'); + + // Test sales-history endpoint + $response = $this->getJson("/api/v1/admin/partner-stores/{$store->uuid}/sales-history"); + $response->assertOk() + ->assertJsonStructure(['data', 'meta']) + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.quantity', 5) + ->assertJsonPath('data.0.retail_price_pesos', '50.00') + ->assertJsonPath('data.0.commission_pesos', '5.00'); + + // Test analytics endpoint + $response = $this->getJson("/api/v1/admin/partner-stores/{$store->uuid}/analytics"); + $response->assertOk() + ->assertJson([ + 'data' => [ + 'total_wholesale_cost_pesos' => '1,000.00', + 'total_retail_revenue_pesos' => '50.00', + 'total_commission_pesos' => '5.00', + 'gross_profit_pesos' => '-950.00', + 'total_issued_codes' => 20, + 'total_sold_codes' => 5, + ] + ]); + } }