feat: add partner store analytics, history logs, and global SRP validation
This commit is contained in:
@@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user