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->sellToHousehold( $store, $household, (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(), ]), 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('qr.verify', ['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), 'total_issued_codes' => (int) $store->purchases()->sum('quantity'), 'total_sold_codes' => (int) $store->sales()->sum('quantity'), ]); } }