validate([ 'status' => ['nullable', 'in:pending_kyc,active,suspended'], 'q' => ['nullable', 'string', 'max:100'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:500'], ]); $perPage = (int) $request->input('per_page', 25); $stores = PartnerStore::query() ->with(['owner', 'barangay', 'inventory']) ->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status'))) ->when($request->filled('q'), fn ($q) => $q->where('business_name', 'like', '%'.$request->string('q').'%')) ->orderBy('business_name') ->paginate($perPage); return $this->ok( PartnerStoreResource::collection($stores), null, [ 'page' => $stores->currentPage(), 'per_page' => $stores->perPage(), 'total' => $stores->total(), 'last_page' => $stores->lastPage(), ], ); } public function store(Request $request): JsonResponse { $data = $request->validate([ 'owner_user_id' => ['required', 'integer', 'exists:users,id'], 'business_name' => ['required', 'string', 'max:191'], 'business_permit_number' => ['nullable', 'string', 'max:64'], 'address_line' => ['nullable', 'string', 'max:255'], 'barangay_id' => ['nullable', 'integer', 'exists:barangays,id'], 'lat' => ['nullable', 'numeric', 'between:-90,90'], 'lng' => ['nullable', 'numeric', 'between:-180,180'], 'commission_rate_percent' => ['nullable', 'integer', 'min:0', 'max:50'], 'status' => ['nullable', Rule::in(['pending_kyc', 'active', 'suspended'])], ]); $payload = collect($data)->except(['lat', 'lng'])->all(); if (isset($data['lat'], $data['lng'])) { $payload['coordinates'] = new Point((float) $data['lat'], (float) $data['lng'], 4326); } $store = PartnerStore::create($payload); return $this->created( new PartnerStoreResource($store->load(['owner', 'barangay'])), 'Store created', ); } public function show(PartnerStore $store): JsonResponse { $store->load(['owner', 'barangay', 'inventory']); return $this->ok(new PartnerStoreResource($store)); } public function update(Request $request, PartnerStore $store): JsonResponse { $data = $request->validate([ 'business_name' => ['sometimes', 'required', 'string', 'max:191'], 'business_permit_number' => ['sometimes', 'nullable', 'string', 'max:64'], 'commission_rate_percent' => ['sometimes', 'integer', 'min:0', 'max:50'], 'status' => ['sometimes', Rule::in(['pending_kyc', 'active', 'suspended'])], 'address_line' => ['sometimes', 'nullable', 'string', 'max:255'], 'barangay_id' => ['sometimes', 'nullable', 'integer', 'exists:barangays,id'], 'lat' => ['sometimes', 'nullable', 'numeric', 'between:-90,90'], 'lng' => ['sometimes', 'nullable', 'numeric', 'between:-180,180'], ]); $payload = collect($data)->except(['lat', 'lng'])->all(); if (isset($data['lat'], $data['lng'])) { $payload['coordinates'] = new Point((float) $data['lat'], (float) $data['lng'], 4326); } $store->update($payload); return $this->ok(new PartnerStoreResource($store->fresh()->load(['owner', 'barangay', 'inventory'])), 'Store updated'); } public function issueInventory(Request $request, PartnerStore $store): JsonResponse { $data = $request->validate([ 'quantity' => ['required', 'integer', 'min:1', 'max:50000'], 'wholesale_price_centavos' => ['required', 'integer', 'min:0'], 'is_prepaid' => ['nullable', 'boolean'], ]); if ($store->status !== PartnerStore::STATUS_ACTIVE) { return $this->fail('Store must be active to receive inventory', null, 422); } $purchase = $this->ops->issueWholesale( $store, (int) $data['quantity'], (int) $data['wholesale_price_centavos'], null, $request->user(), (bool) ($data['is_prepaid'] ?? false) ); return $this->created([ 'purchase_id' => $purchase->id, 'batch_number' => $purchase->batch->batch_number, 'quantity' => $purchase->quantity, 'inventory_balance' => $store->fresh()->load('inventory')->inventory?->current_code_balance ?? 0, ], 'Inventory issued'); } 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' => ['nullable', 'integer', 'in:'.$globalPrice], ]); $retailPrice = (int) ($data['retail_price_per_code_centavos'] ?? $globalPrice); try { $household = Household::findOrFail($data['household_id']); $sale = $this->ops->sell( $store, $household, null, (int) $data['quantity'], $retailPrice, ); } catch (\DomainException $e) { return $this->fail($e->getMessage(), null, 422); } return $this->created([ 'sale_id' => $sale->id, 'quantity' => $sale->quantity, 'retail_price_centavos' => $sale->retail_price_centavos, 'commission_centavos' => $sale->commission_centavos, ], 'Sale recorded'); } public function reportIssue(Request $request, PartnerStore $store): JsonResponse { $data = $request->validate([ 'serial' => ['required', 'string', 'exists:qr_codes,serial'], 'reason' => ['required', 'string', 'max:255'], ]); try { $this->ops->reportDefective( $store, $data['serial'], $data['reason'], $request->user()->id ); } catch (\DomainException $e) { return $this->fail($e->getMessage(), null, 422); } return $this->ok([ 'inventory_balance' => $store->fresh()->load('inventory')->inventory?->current_code_balance ?? 0, ], 'Defective QR reported and inventory adjusted'); } public function adjustInventory(Request $request, PartnerStore $store): JsonResponse { $data = $request->validate([ 'quantity' => ['required', 'integer', 'not_in:0'], 'reason' => ['required', 'string', 'max:255'], ]); $this->ops->manualAdjustment( $store, (int) $data['quantity'], $data['reason'], $request->user()->id ); return $this->ok([ 'inventory_balance' => $store->fresh()->load('inventory')->inventory?->current_code_balance ?? 0, ], 'Inventory adjusted manually'); } public function inventoryLog(Request $request, PartnerStore $store): JsonResponse { $logs = StoreInventoryAdjustment::query() ->where('store_id', $store->id) ->with(['user', 'qrCode']) ->latest('id') ->paginate(15); return $this->ok( $logs->map(fn ($l) => [ 'id' => $l->id, 'type' => $l->type, 'quantity' => $l->quantity, 'qr_serial' => $l->qrCode?->serial ?? '—', 'reason' => $l->reason ?? '—', 'adjusted_by' => $l->user?->full_name ?? 'System', 'created_at' => $l->created_at->toIso8601String(), ]), null, [ 'page' => $logs->currentPage(), 'per_page' => $logs->perPage(), 'total' => $logs->total(), 'last_page' => $logs->lastPage(), ] ); } 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(), 'receipt_url' => \Illuminate\Support\Facades\URL::temporarySignedRoute( 'api.v1.store.sales.receipt', now()->addHours(24), ['sale' => $s->id] ), 'codes_url' => \Illuminate\Support\Facades\URL::temporarySignedRoute( 'api.v1.store.sales.codes', now()->addHours(24), ['sale' => $s->id] ), ]), null, [ 'page' => $sales->currentPage(), 'per_page' => $sales->perPage(), 'total' => $sales->total(), 'last_page' => $sales->lastPage(), ] ); } public function recordPayment(Request $request, PartnerStore $store): JsonResponse { $data = $request->validate([ 'amount_pesos' => ['required', 'numeric', 'min:0.01'], 'payment_method' => ['required', 'string', 'in:cash,gcash,maya,card'], 'reference_number' => ['nullable', 'string', 'max:100'], 'notes' => ['nullable', 'string', 'max:500'], ]); $settlement = $this->ops->recordPayment( $store, (int) round($data['amount_pesos'] * 100), $data['payment_method'], $data['reference_number'] ?? null, $data['notes'] ?? null, $request->user()->id ); return $this->ok([ 'id' => $settlement->id, 'balance_due_pesos' => number_format($this->ops->calculateBalanceDue($store) / 100, 2), ], 'Payment recorded successfully'); } public function settlementHistory(Request $request, PartnerStore $store): JsonResponse { $history = $store->settlements() ->with('recorder') ->latest('settled_at') ->paginate(15); return $this->ok( $history->map(fn ($s) => [ 'id' => $s->id, 'amount_pesos' => number_format($s->amount_centavos / 100, 2), 'method' => strtoupper($s->payment_method), 'reference' => $s->reference_number ?? '—', 'recorded_by' => $s->recorder?->full_name ?? '—', 'settled_at' => $s->settled_at->toIso8601String(), ]), null, [ 'page' => $history->currentPage(), 'per_page' => $history->perPage(), 'total' => $history->total(), 'last_page' => $history->lastPage(), ] ); } public function printReplacement(Request $request, PartnerStore $store, QrCode $qrCode): JsonResponse { // Safety check: ensure the QR belongs to this store and is a replacement if ($qrCode->assigned_to_store_id !== $store->id) { return $this->fail('Unauthorized access to this QR code', null, 403); } // In a real app, this might return a PDF or a signed URL to a PDF. // For now, we'll return the data needed to render a printable QR. return $this->ok([ 'serial' => $qrCode->serial, 'qr_data' => route('api.v1.admin.qr-codes.show', ['serial' => $qrCode->serial]), ]); } 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'); $totalSettled = (int) $store->settlements()->sum('amount_centavos'); $balanceDue = $this->ops->calculateBalanceDue($store); 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), 'total_settled_pesos' => number_format($totalSettled / 100, 2), 'balance_due_pesos' => number_format($balanceDue / 100, 2), 'gross_profit_pesos' => number_format(($totalRetailRevenue - $totalWholesaleCost) / 100, 2), 'total_issued_codes' => (int) $store->purchases()->sum('quantity'), 'total_sold_codes' => (int) $store->sales()->sum('quantity'), ]); } public function distributionChart(Request $request, PartnerStore $store): JsonResponse { $period = $request->input('period', 'daily'); $driver = \DB::getDriverName(); $isSqlite = $driver === 'sqlite'; $query = $store->sales(); switch ($period) { case 'weekly': $dateExpression = $isSqlite ? "strftime('%Y-W%W', COALESCE(sold_at, created_at))" : "DATE_FORMAT(COALESCE(sold_at, created_at), '%x-W%v')"; $rows = $query ->selectRaw("{$dateExpression} as label, SUM(quantity) as total") ->whereRaw("COALESCE(sold_at, created_at) >= ?", [now()->subWeeks(12)->startOfWeek()]) ->groupByRaw($dateExpression) ->orderByRaw($dateExpression) ->get(); break; case 'monthly': $dateExpression = $isSqlite ? "strftime('%Y-%m', COALESCE(sold_at, created_at))" : "DATE_FORMAT(COALESCE(sold_at, created_at), '%Y-%m')"; $rows = $query ->selectRaw("{$dateExpression} as label, SUM(quantity) as total") ->whereRaw("COALESCE(sold_at, created_at) >= ?", [now()->subMonths(12)->startOfMonth()]) ->groupByRaw($dateExpression) ->orderByRaw($dateExpression) ->get(); break; case 'overall': $dateExpression = $isSqlite ? "strftime('%Y-%m', COALESCE(sold_at, created_at))" : "DATE_FORMAT(COALESCE(sold_at, created_at), '%Y-%m')"; $rows = $query ->selectRaw("{$dateExpression} as label, SUM(quantity) as total") ->groupByRaw($dateExpression) ->orderByRaw($dateExpression) ->get(); break; default: // daily — last 30 days $dateExpression = $isSqlite ? "date(COALESCE(sold_at, created_at))" : "DATE(COALESCE(sold_at, created_at))"; $rows = $query ->selectRaw("{$dateExpression} as label, SUM(quantity) as total") ->whereRaw("COALESCE(sold_at, created_at) >= ?", [now()->subDays(29)->startOfDay()]) ->groupByRaw($dateExpression) ->orderByRaw($dateExpression) ->get(); break; } return $this->ok([ 'period' => $period, 'labels' => $rows->pluck('label'), 'values' => $rows->pluck('total')->map(fn ($v) => (int) $v), 'total_distributed' => (int) $store->sales()->sum('quantity'), ]); } public function overallDistributionChart(Request $request): JsonResponse { $period = $request->input('period', 'daily'); $user = $request->user(); $isSuperAdmin = $user && $user->role === User::ROLE_SUPER_ADMIN; $storeQuery = PartnerStore::query(); if ($isSuperAdmin) { if ($request->filled('tenant_id')) { $storeQuery->withoutGlobalScopes()->where('tenant_id', $request->integer('tenant_id')); } else { $storeQuery->withoutGlobalScopes(); } } else { if ($user && $user->tenant_id) { $storeQuery->where('tenant_id', $user->tenant_id); } } if ($request->filled('service_area_id')) { $storeQuery->whereHas('barangay.serviceAreas', function ($q) use ($request) { $q->where('service_areas.id', $request->integer('service_area_id')); }); } if ($request->filled('barangay_id')) { $storeQuery->where('barangay_id', $request->integer('barangay_id')); } $storeIds = $storeQuery->pluck('id'); $driver = \DB::getDriverName(); $isSqlite = $driver === 'sqlite'; $query = StoreSale::query()->whereIn('store_id', $storeIds); switch ($period) { case 'weekly': $dateExpression = $isSqlite ? "strftime('%Y-W%W', COALESCE(sold_at, created_at))" : "DATE_FORMAT(COALESCE(sold_at, created_at), '%x-W%v')"; $rows = $query ->selectRaw("{$dateExpression} as label, SUM(quantity) as total") ->whereRaw("COALESCE(sold_at, created_at) >= ?", [now()->subWeeks(12)->startOfWeek()]) ->groupByRaw($dateExpression) ->orderByRaw($dateExpression) ->get(); break; case 'monthly': $dateExpression = $isSqlite ? "strftime('%Y-%m', COALESCE(sold_at, created_at))" : "DATE_FORMAT(COALESCE(sold_at, created_at), '%Y-%m')"; $rows = $query ->selectRaw("{$dateExpression} as label, SUM(quantity) as total") ->whereRaw("COALESCE(sold_at, created_at) >= ?", [now()->subMonths(12)->startOfMonth()]) ->groupByRaw($dateExpression) ->orderByRaw($dateExpression) ->get(); break; case 'overall': $dateExpression = $isSqlite ? "strftime('%Y-%m', COALESCE(sold_at, created_at))" : "DATE_FORMAT(COALESCE(sold_at, created_at), '%Y-%m')"; $rows = $query ->selectRaw("{$dateExpression} as label, SUM(quantity) as total") ->groupByRaw($dateExpression) ->orderByRaw($dateExpression) ->get(); break; default: // daily — last 30 days $dateExpression = $isSqlite ? "date(COALESCE(sold_at, created_at))" : "DATE(COALESCE(sold_at, created_at))"; $rows = $query ->selectRaw("{$dateExpression} as label, SUM(quantity) as total") ->whereRaw("COALESCE(sold_at, created_at) >= ?", [now()->subDays(29)->startOfDay()]) ->groupByRaw($dateExpression) ->orderByRaw($dateExpression) ->get(); break; } return $this->ok([ 'period' => $period, 'labels' => $rows->pluck('label'), 'values' => $rows->pluck('total')->map(fn ($v) => (int) $v), 'total_distributed' => (int) $query->sum('quantity'), ]); } }