feat: add partner store analytics, history logs, and global SRP validation

This commit is contained in:
Developer
2026-07-01 15:02:43 +08:00
parent 29bf7a714b
commit 62bcf68bdb
4 changed files with 382 additions and 3 deletions

View File

@@ -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'),
]);
}
}

View File

@@ -73,6 +73,7 @@
<nav class="flex gap-0" role="tablist">
<button data-tab="details" class="tab-btn tab-active" role="tab" aria-selected="true">Details</button>
<button data-tab="inventory" class="tab-btn" role="tab" aria-selected="false">Inventory</button>
<button data-tab="analytics" class="tab-btn" role="tab" aria-selected="false">Analytics</button>
</nav>
</div>
@@ -134,6 +135,86 @@
<h4 class="font-semibold text-sm text-neutral-900 mb-3">Quick Actions</h4>
<button type="button" id="tab-issue-btn" class="btn-primary text-xs px-3 py-1.5">Issue Wholesale Inventory</button>
</div>
<div class="pt-4 border-t border-neutral-100">
<h4 class="font-semibold text-sm text-neutral-900 mb-3">Wholesale Purchase History</h4>
<div class="table-wrap">
<table class="table min-w-full text-xs">
<thead>
<tr>
<th>Date</th>
<th>Batch #</th>
<th class="!text-right">Qty</th>
<th class="!text-right">Cost ()</th>
</tr>
</thead>
<tbody id="inventory-purchases-rows">
<tr><td colspan="4" class="px-4 py-4 text-center text-neutral-400">No purchases found.</td></tr>
</tbody>
</table>
</div>
<div id="inventory-purchases-more" class="hidden text-center mt-3">
<button type="button" id="inventory-purchases-more-btn" class="btn-ghost text-xs px-3 py-1.5">Load More</button>
</div>
</div>
</div>
{{-- TAB: Analytics (edit mode only) --}}
<div id="tab-analytics" class="hidden space-y-6">
{{-- KPI Grid --}}
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="card p-3 border border-neutral-100 bg-neutral-50 shadow-xs">
<div class="text-xs text-neutral-500 font-medium">Gross Revenue</div>
<div class="text-lg font-bold text-neutral-900 mt-1" id="analytics-revenue">₱0.00</div>
</div>
<div class="card p-3 border border-neutral-100 bg-neutral-50 shadow-xs">
<div class="text-xs text-neutral-500 font-medium">Wholesale Cost</div>
<div class="text-lg font-bold text-neutral-900 mt-1" id="analytics-cost">₱0.00</div>
</div>
<div class="card p-3 border border-neutral-100 bg-neutral-50 shadow-xs">
<div class="text-xs text-neutral-500 font-medium">Gross Profit</div>
<div class="text-lg font-bold text-green-700 mt-1" id="analytics-profit">₱0.00</div>
</div>
<div class="card p-3 border border-neutral-100 bg-neutral-50 shadow-xs">
<div class="text-xs text-neutral-500 font-medium">Commission Earned</div>
<div class="text-lg font-bold text-blue-700 mt-1" id="analytics-commission">₱0.00</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="card p-3 border border-neutral-100 bg-neutral-50">
<div class="text-xs text-neutral-500 font-medium">Total Issued Codes</div>
<div class="text-lg font-bold text-neutral-950 mt-1" id="analytics-codes-issued">0</div>
</div>
<div class="card p-3 border border-neutral-100 bg-neutral-50">
<div class="text-xs text-neutral-500 font-medium">Total Sold Codes</div>
<div class="text-lg font-bold text-neutral-950 mt-1" id="analytics-codes-sold">0</div>
</div>
</div>
{{-- Retail Sales History --}}
<div class="pt-2">
<h4 class="font-semibold text-sm text-neutral-900 mb-3">Resident Retail Sales History</h4>
<div class="table-wrap">
<table class="table min-w-full text-xs">
<thead>
<tr>
<th>Date</th>
<th>Resident</th>
<th class="!text-right">Qty</th>
<th class="!text-right">Price ()</th>
<th class="!text-right">Comm. ()</th>
</tr>
</thead>
<tbody id="analytics-sales-rows">
<tr><td colspan="5" class="px-4 py-4 text-center text-neutral-400">No sales recorded.</td></tr>
</tbody>
</table>
</div>
<div id="analytics-sales-more" class="hidden text-center mt-3">
<button type="button" id="analytics-sales-more-btn" class="btn-ghost text-xs px-3 py-1.5">Load More</button>
</div>
</div>
</div>
</form>
</div>
@@ -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 = `<tr><td colspan="4" class="py-4 text-center text-neutral-400">Loading history…</td></tr>`;
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 = `<tr><td colspan="4" class="py-4 text-center text-red-500">Failed to load history.</td></tr>`;
}
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 `<tr>
<td class="px-4 py-3 text-neutral-700 font-mono text-[10px]">${dateStr}</td>
<td class="px-4 py-3 text-neutral-950 font-medium font-mono">${window.Verde.escapeHtml(p.batch_number)}</td>
<td class="px-4 py-3 text-right text-neutral-900 font-semibold">${p.quantity}</td>
<td class="px-4 py-3 text-right text-neutral-900 font-mono">${p.wholesale_price_pesos}</td>
</tr>`;
}).join('');
if (page === 1) {
tbody.innerHTML = rowsHtml.length > 0 ? rowsHtml : `<tr><td colspan="4" class="py-4 text-center text-neutral-400">No purchases found.</td></tr>`;
} 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 = `<tr><td colspan="5" class="py-4 text-center text-neutral-400">Loading history…</td></tr>`;
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 = `<tr><td colspan="5" class="py-4 text-center text-red-500">Failed to load history.</td></tr>`;
}
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 `<tr>
<td class="px-4 py-3 text-neutral-700 font-mono text-[10px]">${dateStr}</td>
<td class="px-4 py-3 text-neutral-900 font-medium">
<div>${window.Verde.escapeHtml(s.household_name)}</div>
<div class="text-[10px] text-neutral-400 font-mono">${window.Verde.escapeHtml(s.household_email)}</div>
</td>
<td class="px-4 py-3 text-right text-neutral-900 font-semibold">${s.quantity}</td>
<td class="px-4 py-3 text-right text-neutral-900 font-mono">${s.retail_price_pesos}</td>
<td class="px-4 py-3 text-right text-green-700 font-semibold font-mono">${s.commission_pesos}</td>
</tr>`;
}).join('');
if (page === 1) {
tbody.innerHTML = rowsHtml.length > 0 ? rowsHtml : `<tr><td colspan="5" class="py-4 text-center text-neutral-400">No sales recorded.</td></tr>`;
} 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();
</script>

View File

@@ -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')

View File

@@ -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,
]
]);
}
}