feat: integrate dynamic geofence fetching, service area spatial boundaries, partner store profiles & editing with Leaflet maps, custom store portal icons, QR distribution charts with geographic/LGU filters, and optimize admin dashboards
This commit is contained in:
@@ -36,6 +36,7 @@ class AdminHouseholdController extends ApiController
|
||||
$request->validate([
|
||||
'verification_status' => ['nullable', 'in:pending,approved,rejected'],
|
||||
'barangay_id' => ['nullable', 'integer'],
|
||||
'service_area_id' => ['nullable', 'integer'],
|
||||
'q' => ['nullable', 'string', 'max:100'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:500'],
|
||||
]);
|
||||
@@ -43,7 +44,7 @@ class AdminHouseholdController extends ApiController
|
||||
$perPage = (int) $request->input('per_page', 25);
|
||||
|
||||
$households = Household::query()
|
||||
->with(['head', 'barangay'])
|
||||
->with(['head', 'barangay.serviceAreas'])
|
||||
->withCount('members')
|
||||
->when(
|
||||
$request->filled('verification_status'),
|
||||
@@ -53,6 +54,10 @@ class AdminHouseholdController extends ApiController
|
||||
$request->filled('barangay_id'),
|
||||
fn ($q) => $q->where('barangay_id', $request->integer('barangay_id')),
|
||||
)
|
||||
->when(
|
||||
$request->filled('service_area_id'),
|
||||
fn ($q) => $q->whereHas('barangay.serviceAreas', fn ($sa) => $sa->where('service_areas.id', $request->integer('service_area_id'))),
|
||||
)
|
||||
->when($request->filled('q'), function ($q) use ($request) {
|
||||
$term = '%'.$request->string('q').'%';
|
||||
$q->where(function ($qq) use ($term) {
|
||||
@@ -81,7 +86,7 @@ class AdminHouseholdController extends ApiController
|
||||
|
||||
public function show(Household $household): JsonResponse
|
||||
{
|
||||
$household->load(['head', 'barangay.cityMunicipality.province', 'members.user'])
|
||||
$household->load(['head', 'barangay.cityMunicipality.province', 'barangay.serviceAreas', 'members.user'])
|
||||
->loadCount('members');
|
||||
|
||||
return $this->ok(new HouseholdResource($household));
|
||||
@@ -104,7 +109,7 @@ class AdminHouseholdController extends ApiController
|
||||
$household->markVerified($request->user());
|
||||
HouseholdVerified::dispatch($household->fresh(), $request->user());
|
||||
|
||||
$household->load(['head', 'barangay'])->loadCount('members');
|
||||
$household->load(['head', 'barangay.serviceAreas'])->loadCount('members');
|
||||
|
||||
return $this->ok(new HouseholdResource($household), 'Household approved');
|
||||
}
|
||||
@@ -121,7 +126,7 @@ class AdminHouseholdController extends ApiController
|
||||
);
|
||||
}
|
||||
|
||||
$household->load(['head', 'barangay'])->loadCount('members');
|
||||
$household->load(['head', 'barangay.serviceAreas'])->loadCount('members');
|
||||
|
||||
return $this->ok(new HouseholdResource($household), 'Household rejected');
|
||||
}
|
||||
@@ -225,7 +230,7 @@ class AdminHouseholdController extends ApiController
|
||||
return $h;
|
||||
});
|
||||
|
||||
$household->load(['head', 'barangay', 'members.user', 'assignedDropOffPoint'])->loadCount('members');
|
||||
$household->load(['head', 'barangay.serviceAreas', 'members.user', 'assignedDropOffPoint'])->loadCount('members');
|
||||
|
||||
return $this->created(
|
||||
new HouseholdResource($household),
|
||||
@@ -276,7 +281,7 @@ class AdminHouseholdController extends ApiController
|
||||
->update(['user_id' => $newHeadId]);
|
||||
}
|
||||
|
||||
$household->load(['head', 'barangay', 'members.user', 'assignedDropOffPoint'])->loadCount('members');
|
||||
$household->load(['head', 'barangay.serviceAreas', 'members.user', 'assignedDropOffPoint'])->loadCount('members');
|
||||
|
||||
return $this->ok(new HouseholdResource($household), 'Household updated successfully.');
|
||||
}
|
||||
@@ -300,7 +305,7 @@ class AdminHouseholdController extends ApiController
|
||||
'assigned_drop_off_point_id' => $request->integer('drop_off_point_id'),
|
||||
]);
|
||||
|
||||
$household->load(['head', 'barangay', 'assignedDropOffPoint'])->loadCount('members');
|
||||
$household->load(['head', 'barangay.serviceAreas', 'assignedDropOffPoint'])->loadCount('members');
|
||||
|
||||
return $this->ok(new HouseholdResource($household), 'Drop-off point updated.');
|
||||
}
|
||||
@@ -406,7 +411,7 @@ class AdminHouseholdController extends ApiController
|
||||
return $m;
|
||||
});
|
||||
|
||||
$household->load(['head', 'barangay', 'members.user', 'assignedDropOffPoint'])->loadCount('members');
|
||||
$household->load(['head', 'barangay.serviceAreas', 'members.user', 'assignedDropOffPoint'])->loadCount('members');
|
||||
|
||||
return $this->created(new HouseholdResource($household), 'Household member added successfully.');
|
||||
}
|
||||
@@ -447,7 +452,7 @@ class AdminHouseholdController extends ApiController
|
||||
'date_of_birth' => $data['date_of_birth'] ?? null,
|
||||
]);
|
||||
|
||||
$household->load(['head', 'barangay', 'members.user', 'assignedDropOffPoint'])->loadCount('members');
|
||||
$household->load(['head', 'barangay.serviceAreas', 'members.user', 'assignedDropOffPoint'])->loadCount('members');
|
||||
|
||||
return $this->ok(new HouseholdResource($household), 'Household member updated successfully.');
|
||||
}
|
||||
@@ -464,7 +469,7 @@ class AdminHouseholdController extends ApiController
|
||||
|
||||
$member->delete();
|
||||
|
||||
$household->load(['head', 'barangay', 'members.user', 'assignedDropOffPoint'])->loadCount('members');
|
||||
$household->load(['head', 'barangay.serviceAreas', 'members.user', 'assignedDropOffPoint'])->loadCount('members');
|
||||
|
||||
return $this->ok(new HouseholdResource($household), 'Household member removed successfully.');
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ use App\Models\Household;
|
||||
use App\Models\PartnerStore;
|
||||
use App\Models\QrCode;
|
||||
use App\Models\StoreInventoryAdjustment;
|
||||
use App\Models\StoreSale;
|
||||
use App\Models\User;
|
||||
use App\Services\Store\StoreOperations;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -146,9 +148,10 @@ class AdminPartnerStoreController extends ApiController
|
||||
|
||||
try {
|
||||
$household = Household::findOrFail($data['household_id']);
|
||||
$sale = $this->ops->sellToHousehold(
|
||||
$sale = $this->ops->sell(
|
||||
$store,
|
||||
$household,
|
||||
null,
|
||||
(int) $data['quantity'],
|
||||
$retailPrice,
|
||||
);
|
||||
@@ -370,4 +373,173 @@ class AdminPartnerStoreController extends ApiController
|
||||
'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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
373
app/Http/Controllers/Api/V1/Admin/AdminTeamReportController.php
Normal file
373
app/Http/Controllers/Api/V1/Admin/AdminTeamReportController.php
Normal file
@@ -0,0 +1,373 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin;
|
||||
|
||||
use App\Http\Controllers\Api\V1\ApiController;
|
||||
use App\Models\CollectionLog;
|
||||
use App\Models\CollectionTeam;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Trip;
|
||||
use App\Models\TripTimelineEvent;
|
||||
use App\Tenancy\Tenancy;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AdminTeamReportController extends ApiController
|
||||
{
|
||||
/**
|
||||
* GET /api/v1/admin/reports/teams/leaderboard
|
||||
*
|
||||
* Returns a ranked list of all collection teams with aggregate KPIs
|
||||
* for the given date range. Super-admins can pass tenant_id to scope
|
||||
* to a specific LGU, or omit it to see all LGUs combined.
|
||||
*/
|
||||
public function leaderboard(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'from' => ['nullable', 'date'],
|
||||
'to' => ['nullable', 'date'],
|
||||
'tenant_id' => ['nullable', 'integer', 'exists:tenants,id'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:200'],
|
||||
]);
|
||||
|
||||
$from = Carbon::parse($data['from'] ?? now()->subDays(30))->startOfDay();
|
||||
$to = Carbon::parse($data['to'] ?? now())->endOfDay();
|
||||
$perPage = (int) ($data['per_page'] ?? 50);
|
||||
|
||||
$tenant = $this->resolveTargetTenant($request);
|
||||
|
||||
// For the leaderboard we run a single DB query per team to avoid N+1.
|
||||
// We use subquery aggregates to compute everything in one pass.
|
||||
$rows = Tenancy::withoutScope(function () use ($from, $to, $tenant, $perPage) {
|
||||
$query = CollectionTeam::withoutTrashed()
|
||||
->with(['driver', 'scanner', 'truck'])
|
||||
->when($tenant, fn ($q) => $q->where('collection_teams.tenant_id', $tenant->id));
|
||||
|
||||
// Eager-load trip IDs, then compute aggregates in PHP to avoid
|
||||
// complex cross-table subqueries that differ by DB engine.
|
||||
return $query->orderBy('name')->paginate($perPage);
|
||||
});
|
||||
|
||||
$teamIds = $rows->pluck('id');
|
||||
$fromStr = $from->toDateTimeString();
|
||||
$toStr = $to->toDateTimeString();
|
||||
|
||||
// Batch-load all aggregate data using a single query per metric.
|
||||
$tripStats = Tenancy::withoutScope(function () use ($teamIds, $from, $to) {
|
||||
return DB::table('trips')
|
||||
->select(
|
||||
'team_id',
|
||||
DB::raw('COUNT(*) as total_trips'),
|
||||
DB::raw("SUM(CASE WHEN status IN ('completed','handed_off') THEN 1 ELSE 0 END) as completed_trips"),
|
||||
DB::raw("SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled_trips"),
|
||||
DB::raw('SUM(COALESCE(total_load_kg, 0)) as total_weight_kg'),
|
||||
DB::raw('MAX(scheduled_date) as last_trip_date')
|
||||
)
|
||||
->whereIn('team_id', $teamIds)
|
||||
->whereBetween('scheduled_date', [$from->toDateString(), $to->toDateString()])
|
||||
->whereNull('deleted_at')
|
||||
->groupBy('team_id')
|
||||
->get()
|
||||
->keyBy('team_id');
|
||||
});
|
||||
|
||||
$scanStats = Tenancy::withoutScope(function () use ($teamIds, $fromStr, $toStr) {
|
||||
return DB::table('collection_logs')
|
||||
->join('trips', 'trips.id', '=', 'collection_logs.trip_id')
|
||||
->select('trips.team_id', DB::raw('COUNT(collection_logs.id) as total_scans'))
|
||||
->whereIn('trips.team_id', $teamIds)
|
||||
->whereBetween('collection_logs.scanned_at', [$fromStr, $toStr])
|
||||
->whereNull('trips.deleted_at')
|
||||
->groupBy('trips.team_id')
|
||||
->get()
|
||||
->keyBy('team_id');
|
||||
});
|
||||
|
||||
$eventStats = Tenancy::withoutScope(function () use ($teamIds, $fromStr, $toStr) {
|
||||
return DB::table('trip_timeline_events')
|
||||
->join('trips', 'trips.id', '=', 'trip_timeline_events.trip_id')
|
||||
->select('trips.team_id', DB::raw('COUNT(trip_timeline_events.id) as event_count'))
|
||||
->whereIn('trips.team_id', $teamIds)
|
||||
->whereBetween('trip_timeline_events.event_at', [$fromStr, $toStr])
|
||||
->whereNull('trips.deleted_at')
|
||||
->groupBy('trips.team_id')
|
||||
->get()
|
||||
->keyBy('team_id');
|
||||
});
|
||||
|
||||
// Map teams to leaderboard rows.
|
||||
$leaderboard = $rows->map(function (CollectionTeam $team) use ($tripStats, $scanStats, $eventStats) {
|
||||
$ts = $tripStats->get($team->id);
|
||||
$ss = $scanStats->get($team->id);
|
||||
$es = $eventStats->get($team->id);
|
||||
|
||||
$totalTrips = (int) ($ts->total_trips ?? 0);
|
||||
$completedTrips = (int) ($ts->completed_trips ?? 0);
|
||||
$totalWeight = (int) ($ts->total_weight_kg ?? 0);
|
||||
$totalScans = (int) ($ss->total_scans ?? 0);
|
||||
|
||||
return [
|
||||
'team_uuid' => $team->uuid,
|
||||
'team_name' => $team->name,
|
||||
'driver_name' => $team->driver?->full_name,
|
||||
'scanner_name' => $team->scanner?->full_name,
|
||||
'truck_plate' => $team->truck?->plate_number,
|
||||
'status' => $team->status,
|
||||
'total_trips' => $totalTrips,
|
||||
'completed_trips' => $completedTrips,
|
||||
'cancelled_trips' => (int) ($ts->cancelled_trips ?? 0),
|
||||
'completion_rate_percent'=> $totalTrips > 0
|
||||
? round(($completedTrips / $totalTrips) * 100, 1)
|
||||
: 0,
|
||||
'total_scans' => $totalScans,
|
||||
'total_weight_kg' => $totalWeight,
|
||||
'avg_load_per_trip_kg' => $totalTrips > 0
|
||||
? (int) round($totalWeight / $totalTrips)
|
||||
: 0,
|
||||
'event_count' => (int) ($es->event_count ?? 0),
|
||||
'last_trip_date' => $ts?->last_trip_date,
|
||||
];
|
||||
})->sortByDesc('total_scans')->values();
|
||||
|
||||
return $this->ok($leaderboard, null, [
|
||||
'page' => $rows->currentPage(),
|
||||
'per_page' => $rows->perPage(),
|
||||
'total' => $rows->total(),
|
||||
'last_page' => $rows->lastPage(),
|
||||
'from' => $from->toDateString(),
|
||||
'to' => $to->toDateString(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/reports/teams/{team:uuid}/profile
|
||||
*
|
||||
* Returns the full analytics profile for a single team:
|
||||
* KPIs, daily scan trend, weekly scan trend, trip history, and full event log.
|
||||
*/
|
||||
public function teamProfile(Request $request, CollectionTeam $team): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'from' => ['nullable', 'date'],
|
||||
'to' => ['nullable', 'date'],
|
||||
'trip_page' => ['nullable', 'integer', 'min:1'],
|
||||
'tenant_id' => ['nullable', 'integer', 'exists:tenants,id'],
|
||||
]);
|
||||
|
||||
$from = Carbon::parse($data['from'] ?? now()->subDays(30))->startOfDay();
|
||||
$to = Carbon::parse($data['to'] ?? now())->endOfDay();
|
||||
$tripPage = (int) ($data['trip_page'] ?? 1);
|
||||
$fromDate = $from->toDateString();
|
||||
$toDate = $to->toDateString();
|
||||
$fromDT = $from->toDateTimeString();
|
||||
$toDT = $to->toDateTimeString();
|
||||
|
||||
// Re-load team fully (route model binding already resolved it, but load relations)
|
||||
$team->load(['driver', 'scanner', 'truck', 'area']);
|
||||
|
||||
// Get all trip IDs for this team in range.
|
||||
$tripIds = Tenancy::withoutScope(function () use ($team, $fromDate, $toDate) {
|
||||
return Trip::withoutGlobalScopes()
|
||||
->where('team_id', $team->id)
|
||||
->whereBetween('scheduled_date', [$fromDate, $toDate])
|
||||
->pluck('id');
|
||||
});
|
||||
|
||||
// --- KPIs ---
|
||||
$kpis = $this->buildKpis($team->id, $tripIds, $fromDate, $toDate, $fromDT, $toDT);
|
||||
|
||||
// --- Daily scans (group by DATE) ---
|
||||
$dailyScans = Tenancy::withoutScope(function () use ($tripIds, $fromDT, $toDT) {
|
||||
return DB::table('collection_logs')
|
||||
->selectRaw('DATE(scanned_at) as date, COUNT(*) as scans')
|
||||
->whereIn('trip_id', $tripIds)
|
||||
->whereBetween('scanned_at', [$fromDT, $toDT])
|
||||
->groupByRaw('DATE(scanned_at)')
|
||||
->orderBy('date')
|
||||
->get()
|
||||
->map(fn ($r) => ['date' => $r->date, 'scans' => (int) $r->scans]);
|
||||
});
|
||||
|
||||
// --- Weekly scans (group by YEARWEEK) ---
|
||||
$weeklyScans = Tenancy::withoutScope(function () use ($tripIds, $fromDT, $toDT) {
|
||||
return DB::table('collection_logs')
|
||||
->selectRaw("DATE(DATE_SUB(scanned_at, INTERVAL WEEKDAY(scanned_at) DAY)) as week_start, COUNT(*) as scans")
|
||||
->whereIn('trip_id', $tripIds)
|
||||
->whereBetween('scanned_at', [$fromDT, $toDT])
|
||||
->groupByRaw("DATE(DATE_SUB(scanned_at, INTERVAL WEEKDAY(scanned_at) DAY))")
|
||||
->orderBy('week_start')
|
||||
->get()
|
||||
->map(fn ($r) => ['week_start' => $r->week_start, 'scans' => (int) $r->scans]);
|
||||
});
|
||||
|
||||
// --- Trip history (paginated, 10/page) ---
|
||||
$tripsData = Tenancy::withoutScope(function () use ($team, $fromDate, $toDate, $tripPage, $tripIds) {
|
||||
$perPage = 10;
|
||||
$trips = Trip::withoutGlobalScopes()
|
||||
->withCount('collectionLogs')
|
||||
->where('team_id', $team->id)
|
||||
->whereBetween('scheduled_date', [$fromDate, $toDate])
|
||||
->orderByDesc('scheduled_date')
|
||||
->paginate($perPage, ['*'], 'trip_page', $tripPage);
|
||||
|
||||
return [
|
||||
'data' => $trips->map(fn (Trip $t) => [
|
||||
'trip_number' => $t->trip_number,
|
||||
'scheduled_date' => $t->scheduled_date?->toDateString(),
|
||||
'status' => $t->status,
|
||||
'scans_count' => $t->collection_logs_count,
|
||||
'total_load_kg' => (int) ($t->total_load_kg ?? 0),
|
||||
'duration_minutes' => ($t->actual_start_time && $t->actual_end_time)
|
||||
? (int) $t->actual_start_time->diffInMinutes($t->actual_end_time)
|
||||
: null,
|
||||
'actual_start_time' => $t->actual_start_time?->toIso8601String(),
|
||||
'actual_end_time' => $t->actual_end_time?->toIso8601String(),
|
||||
]),
|
||||
'meta' => [
|
||||
'page' => $trips->currentPage(),
|
||||
'per_page' => $trips->perPage(),
|
||||
'total' => $trips->total(),
|
||||
'last_page' => $trips->lastPage(),
|
||||
],
|
||||
];
|
||||
});
|
||||
|
||||
// --- Event log (all event types, last 200) ---
|
||||
$events = Tenancy::withoutScope(function () use ($tripIds, $fromDT, $toDT) {
|
||||
return DB::table('trip_timeline_events')
|
||||
->join('trips', 'trips.id', '=', 'trip_timeline_events.trip_id')
|
||||
->select(
|
||||
'trip_timeline_events.event_type',
|
||||
'trip_timeline_events.event_at',
|
||||
'trip_timeline_events.notes',
|
||||
'trip_timeline_events.metadata',
|
||||
'trips.trip_number'
|
||||
)
|
||||
->whereIn('trip_timeline_events.trip_id', $tripIds)
|
||||
->whereBetween('trip_timeline_events.event_at', [$fromDT, $toDT])
|
||||
->orderByDesc('trip_timeline_events.event_at')
|
||||
->limit(200)
|
||||
->get()
|
||||
->map(fn ($e) => [
|
||||
'event_type' => $e->event_type,
|
||||
'event_at' => $e->event_at,
|
||||
'trip_number' => $e->trip_number,
|
||||
'notes' => $e->notes,
|
||||
'metadata' => $e->metadata ? json_decode($e->metadata, true) : null,
|
||||
]);
|
||||
});
|
||||
|
||||
return $this->ok([
|
||||
'team' => [
|
||||
'uuid' => $team->uuid,
|
||||
'name' => $team->name,
|
||||
'status' => $team->status,
|
||||
'area' => $team->area?->name,
|
||||
'driver' => $team->driver ? [
|
||||
'name' => $team->driver->full_name,
|
||||
'email' => $team->driver->email,
|
||||
] : null,
|
||||
'scanner' => $team->scanner ? [
|
||||
'name' => $team->scanner->full_name,
|
||||
'email' => $team->scanner->email,
|
||||
] : null,
|
||||
'truck' => $team->truck ? [
|
||||
'plate' => $team->truck->plate_number,
|
||||
'model' => $team->truck->model,
|
||||
'capacity_kg' => $team->truck->capacity_kg,
|
||||
] : null,
|
||||
],
|
||||
'kpis' => $kpis,
|
||||
'daily_scans' => $dailyScans,
|
||||
'weekly_scans' => $weeklyScans,
|
||||
'trips' => $tripsData,
|
||||
'events' => $events,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute all KPI metrics for a team within the given date range.
|
||||
*/
|
||||
private function buildKpis(int $teamId, $tripIds, string $fromDate, string $toDate, string $fromDT, string $toDT): array
|
||||
{
|
||||
$tripStats = Tenancy::withoutScope(function () use ($teamId, $fromDate, $toDate) {
|
||||
return DB::table('trips')
|
||||
->select(
|
||||
DB::raw('COUNT(*) as total_trips'),
|
||||
DB::raw("SUM(CASE WHEN status IN ('completed','handed_off') THEN 1 ELSE 0 END) as completed_trips"),
|
||||
DB::raw("SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled_trips"),
|
||||
DB::raw('SUM(COALESCE(total_load_kg,0)) as total_weight_kg'),
|
||||
// On-time: actual_start_time <= scheduled_start_time + 30 minutes
|
||||
DB::raw("SUM(CASE
|
||||
WHEN actual_start_time IS NOT NULL AND scheduled_start_time IS NOT NULL
|
||||
AND actual_start_time <= DATE_ADD(
|
||||
CONCAT(scheduled_date, ' ', scheduled_start_time),
|
||||
INTERVAL 30 MINUTE
|
||||
)
|
||||
THEN 1 ELSE 0 END) as on_time_trips")
|
||||
)
|
||||
->where('team_id', $teamId)
|
||||
->whereBetween('scheduled_date', [$fromDate, $toDate])
|
||||
->whereNull('deleted_at')
|
||||
->first();
|
||||
});
|
||||
|
||||
$scanStats = Tenancy::withoutScope(function () use ($tripIds, $fromDT, $toDT) {
|
||||
return DB::table('collection_logs')
|
||||
->selectRaw('COUNT(*) as total_scans, SUM(COALESCE(weight_kg,0)) as total_scan_weight')
|
||||
->whereIn('trip_id', $tripIds)
|
||||
->whereBetween('scanned_at', [$fromDT, $toDT])
|
||||
->first();
|
||||
});
|
||||
|
||||
$eventStats = Tenancy::withoutScope(function () use ($tripIds, $fromDT, $toDT) {
|
||||
return DB::table('trip_timeline_events')
|
||||
->selectRaw("
|
||||
COUNT(*) as total_events,
|
||||
SUM(CASE WHEN event_type IN ('incident_reported','breakdown') THEN 1 ELSE 0 END) as incident_count,
|
||||
SUM(CASE WHEN event_type = 'stop_skipped' THEN 1 ELSE 0 END) as stops_skipped_count,
|
||||
SUM(CASE WHEN event_type = 'detour_to_dumpsite' THEN 1 ELSE 0 END) as detours_count,
|
||||
SUM(CASE WHEN event_type = 'breakdown' THEN 1 ELSE 0 END) as breakdowns_count,
|
||||
SUM(CASE WHEN event_type = 'truck_full_warning' THEN 1 ELSE 0 END) as truck_full_count
|
||||
")
|
||||
->whereIn('trip_id', $tripIds)
|
||||
->whereBetween('event_at', [$fromDT, $toDT])
|
||||
->first();
|
||||
});
|
||||
|
||||
$totalTrips = (int) ($tripStats->total_trips ?? 0);
|
||||
$completedTrips = (int) ($tripStats->completed_trips ?? 0);
|
||||
$onTimeTrips = (int) ($tripStats->on_time_trips ?? 0);
|
||||
$totalWeight = (int) ($tripStats->total_weight_kg ?? 0);
|
||||
$totalScans = (int) ($scanStats->total_scans ?? 0);
|
||||
|
||||
return [
|
||||
'total_trips' => $totalTrips,
|
||||
'completed_trips' => $completedTrips,
|
||||
'cancelled_trips' => (int) ($tripStats->cancelled_trips ?? 0),
|
||||
'completion_rate_percent' => $totalTrips > 0 ? round(($completedTrips / $totalTrips) * 100, 1) : 0,
|
||||
'on_time_trips' => $onTimeTrips,
|
||||
'on_time_rate_percent' => $completedTrips > 0 ? round(($onTimeTrips / $completedTrips) * 100, 1) : 0,
|
||||
'total_scans' => $totalScans,
|
||||
'total_weight_kg' => $totalWeight,
|
||||
'avg_load_per_trip_kg' => $totalTrips > 0 ? (int) round($totalWeight / $totalTrips) : 0,
|
||||
'incident_count' => (int) ($eventStats->incident_count ?? 0),
|
||||
'stops_skipped_count' => (int) ($eventStats->stops_skipped_count ?? 0),
|
||||
'detours_count' => (int) ($eventStats->detours_count ?? 0),
|
||||
'breakdowns_count' => (int) ($eventStats->breakdowns_count ?? 0),
|
||||
'truck_full_count' => (int) ($eventStats->truck_full_count ?? 0),
|
||||
'total_events' => (int) ($eventStats->total_events ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveTargetTenant(Request $request): ?Tenant
|
||||
{
|
||||
if (optional($request->user())->isSuperAdmin() && $request->filled('tenant_id')) {
|
||||
return Tenant::find($request->input('tenant_id'));
|
||||
}
|
||||
|
||||
return Tenancy::current();
|
||||
}
|
||||
}
|
||||
@@ -290,7 +290,7 @@ class AdminUserController extends ApiController
|
||||
|
||||
return $this->ok([
|
||||
'user' => new UserDetailResource($user),
|
||||
'household' => new HouseholdResource($household->load(['head', 'barangay'])),
|
||||
'household' => new HouseholdResource($household->load(['head', 'barangay.serviceAreas'])),
|
||||
], 'User successfully grouped into household');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,13 @@ class BarangayController extends ApiController
|
||||
|
||||
$user = $request->user();
|
||||
$barangays = Barangay::query()
|
||||
->when($user && $user->tenant_id, function ($q) use ($user) {
|
||||
->when($request->filled('tenant_id'), function ($q) use ($request) {
|
||||
$tenant = Tenant::find($request->integer('tenant_id'));
|
||||
if ($tenant) {
|
||||
$q->where('city_municipality_id', $tenant->city_municipality_id);
|
||||
}
|
||||
})
|
||||
->when(!$request->filled('tenant_id') && $user && $user->tenant_id, function ($q) use ($user) {
|
||||
$tenant = $user->tenant ?: Tenant::find($user->tenant_id);
|
||||
if ($tenant) {
|
||||
$q->where('city_municipality_id', $tenant->city_municipality_id);
|
||||
|
||||
68
app/Http/Controllers/Api/V1/Geo/FetchBoundaryController.php
Normal file
68
app/Http/Controllers/Api/V1/Geo/FetchBoundaryController.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Geo;
|
||||
|
||||
use App\Http\Controllers\Api\V1\ApiController;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class FetchBoundaryController extends ApiController
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'q' => ['required', 'string', 'max:100'],
|
||||
]);
|
||||
|
||||
$searchQuery = $request->string('q');
|
||||
$user = $request->user();
|
||||
$lguSuffix = '';
|
||||
|
||||
if ($user && $user->tenant_id) {
|
||||
$tenant = $user->tenant ?: Tenant::find($user->tenant_id);
|
||||
if ($tenant && $tenant->cityMunicipality) {
|
||||
$lguSuffix = ', ' . $tenant->cityMunicipality->name;
|
||||
}
|
||||
}
|
||||
|
||||
$fullQuery = $searchQuery . $lguSuffix . ', Philippines';
|
||||
|
||||
// Cache lookup results to protect Nominatim API rate limits
|
||||
$cacheKey = 'geo_boundary_' . md5(strtolower($fullQuery));
|
||||
|
||||
$results = Cache::remember($cacheKey, now()->addDays(7), function () use ($fullQuery) {
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'User-Agent' => 'Verde Waste Management App (admin@verde.local)',
|
||||
])->get('https://nominatim.openstreetmap.org/search', [
|
||||
'q' => $fullQuery,
|
||||
'format' => 'json',
|
||||
'polygon_geojson' => 1,
|
||||
'limit' => 5,
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$data = $response->json() ?? [];
|
||||
return collect($data)
|
||||
->filter(fn ($item) => isset($item['geojson']))
|
||||
->map(fn ($item) => [
|
||||
'display_name' => $item['display_name'] ?? 'Unknown Location',
|
||||
'geojson' => $item['geojson'],
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Nominatim boundary fetch failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
return $this->ok($results);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,9 @@ use App\Models\ServiceArea;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use MatanYadaev\EloquentSpatial\Objects\LineString;
|
||||
use MatanYadaev\EloquentSpatial\Objects\Point;
|
||||
use MatanYadaev\EloquentSpatial\Objects\Polygon;
|
||||
|
||||
class ServiceAreaController extends ApiController
|
||||
{
|
||||
@@ -23,6 +26,9 @@ class ServiceAreaController extends ApiController
|
||||
]);
|
||||
|
||||
$areas = ServiceArea::query()
|
||||
->when($request->filled('tenant_id'), function ($q) use ($request) {
|
||||
$q->withoutGlobalScopes()->where('tenant_id', $request->integer('tenant_id'));
|
||||
})
|
||||
->withCount('barangays')
|
||||
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
|
||||
->when($request->filled('q'), fn ($q) => $q->where('name', 'like', '%'.$request->string('q').'%'))
|
||||
@@ -38,6 +44,10 @@ class ServiceAreaController extends ApiController
|
||||
$barangayIds = $data['barangay_ids'] ?? [];
|
||||
unset($data['barangay_ids']);
|
||||
|
||||
if (! empty($data['boundary'])) {
|
||||
$data['boundary'] = $this->buildPolygon($data['boundary']);
|
||||
}
|
||||
|
||||
$area = DB::transaction(function () use ($data, $barangayIds) {
|
||||
$area = ServiceArea::create($data);
|
||||
if (! empty($barangayIds)) {
|
||||
@@ -67,6 +77,10 @@ class ServiceAreaController extends ApiController
|
||||
$barangayIds = $data['barangay_ids'] ?? null;
|
||||
unset($data['barangay_ids']);
|
||||
|
||||
if (array_key_exists('boundary', $data)) {
|
||||
$data['boundary'] = ! empty($data['boundary']) ? $this->buildPolygon($data['boundary']) : null;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($serviceArea, $data, $barangayIds) {
|
||||
$serviceArea->update($data);
|
||||
if ($barangayIds !== null) {
|
||||
@@ -80,6 +94,22 @@ class ServiceAreaController extends ApiController
|
||||
);
|
||||
}
|
||||
|
||||
private function buildPolygon(array $points): Polygon
|
||||
{
|
||||
$ring = array_map(
|
||||
fn ($p) => new Point((float) $p['lat'], (float) $p['lng'], 4326),
|
||||
$points,
|
||||
);
|
||||
|
||||
$first = $ring[0];
|
||||
$last = $ring[count($ring) - 1];
|
||||
if ($first->latitude !== $last->latitude || $first->longitude !== $last->longitude) {
|
||||
$ring[] = new Point($first->latitude, $first->longitude, 4326);
|
||||
}
|
||||
|
||||
return new Polygon([new LineString($ring)], 4326);
|
||||
}
|
||||
|
||||
public function destroy(ServiceArea $serviceArea): JsonResponse
|
||||
{
|
||||
$serviceArea->delete();
|
||||
|
||||
@@ -23,7 +23,7 @@ class MyHouseholdController extends ApiController
|
||||
$tenantId = Tenancy::current()?->id;
|
||||
Log::info("DEBUG HOUSEHOLD show for User $userId, Tenant: $tenantId");
|
||||
$household = Household::with([
|
||||
'head', 'barangay.cityMunicipality.province.region',
|
||||
'head', 'barangay.cityMunicipality.province.region', 'barangay.serviceAreas',
|
||||
'members.user',
|
||||
'assignedDropOffPoint',
|
||||
])
|
||||
|
||||
@@ -191,7 +191,7 @@ class PaymentController extends ApiController
|
||||
}
|
||||
|
||||
try {
|
||||
$stores->sellToHousehold($store, $household, $qty, $price);
|
||||
$stores->sell($store, $household, null, $qty, $price);
|
||||
} catch (\DomainException $e) {
|
||||
$payment->forceFill(['status' => Payment::STATUS_FAILED])->save();
|
||||
\Log::warning('Fulfillment failed', ['payment' => $payment->uuid, 'error' => $e->getMessage()]);
|
||||
|
||||
@@ -49,6 +49,8 @@ class MyQrCodeController extends ApiController
|
||||
$household = Household::where('head_user_id', $request->user()->id)->first();
|
||||
if (! $household) {
|
||||
return $this->ok([
|
||||
'balance' => 0,
|
||||
'household_name' => 'No Household',
|
||||
'active' => 0, 'allocated' => 0, 'used' => 0, 'expired' => 0,
|
||||
'low_balance' => false,
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Resident;
|
||||
|
||||
use App\Http\Controllers\Api\V1\ApiController;
|
||||
use App\Models\QrPurchaseOrder;
|
||||
use App\Models\PartnerStore;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class QrPurchaseController extends ApiController
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$orders = QrPurchaseOrder::with(['store.storePartnerProfile'])
|
||||
->where('resident_id', $request->user()->id)
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
|
||||
return $this->ok($orders);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'store_id' => 'required|exists:partner_stores,uuid',
|
||||
]);
|
||||
|
||||
$partnerStore = PartnerStore::where('uuid', $request->store_id)->firstOrFail();
|
||||
|
||||
// Enforce LGU constraint: Resident and Store must belong to the same tenant
|
||||
if ($partnerStore->tenant_id !== $request->user()->tenant_id) {
|
||||
return $this->fail('The selected store does not belong to your LGU.', null, 403);
|
||||
}
|
||||
|
||||
$order = QrPurchaseOrder::create([
|
||||
'resident_id' => $request->user()->id,
|
||||
'store_id' => $partnerStore->owner_user_id,
|
||||
'tenant_id' => $request->user()->tenant_id,
|
||||
'amount' => config('qr.default_retail_price_per_code_centavos', 1000) / 100.0,
|
||||
]);
|
||||
|
||||
return $this->created($order->load('store.storePartnerProfile'), 'QR Reservation created successfully');
|
||||
}
|
||||
}
|
||||
@@ -412,12 +412,89 @@ class StorePortalController extends ApiController
|
||||
public function getSettings(): JsonResponse
|
||||
{
|
||||
$store = $this->getStore();
|
||||
$store->loadMissing('inventory');
|
||||
$tenant = $store->tenant;
|
||||
|
||||
return $this->ok([
|
||||
'retail_price_centavos' => (int) ($tenant->qr_retail_price_centavos ?? 1000),
|
||||
'retail_price_pesos' => ($tenant->qr_retail_price_centavos ?? 1000) / 100,
|
||||
'commission_rate_percent' => (int) $store->commission_rate_percent,
|
||||
'inventory_balance' => (int) ($store->inventory?->current_code_balance ?? 0),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get store profile details.
|
||||
*/
|
||||
public function profile(): JsonResponse
|
||||
{
|
||||
$store = $this->getStore();
|
||||
$store->load(['owner', 'barangay', 'tenant']);
|
||||
|
||||
$barangayBoundary = null;
|
||||
if ($store->barangay && $store->barangay->boundary) {
|
||||
$rings = $store->barangay->boundary->getGeometries();
|
||||
$ring = $rings->first();
|
||||
if ($ring) {
|
||||
$barangayBoundary = $ring->getGeometries()
|
||||
->map(fn ($p) => ['lat' => $p->latitude, 'lng' => $p->longitude])
|
||||
->all();
|
||||
}
|
||||
}
|
||||
|
||||
return $this->ok([
|
||||
'id' => $store->uuid,
|
||||
'business_name' => $store->business_name,
|
||||
'business_permit_number' => $store->business_permit_number,
|
||||
'address_line' => $store->address_line,
|
||||
'barangay_name' => $store->barangay?->name ?? 'N/A',
|
||||
'lgu_name' => $store->tenant?->name ?? 'N/A',
|
||||
'barangay_boundary' => $barangayBoundary,
|
||||
'commission_rate_percent' => (int) $store->commission_rate_percent,
|
||||
'status' => $store->status,
|
||||
'operating_hours' => $store->operating_hours,
|
||||
'owner' => [
|
||||
'name' => $store->owner?->full_name,
|
||||
'email' => $store->owner?->email,
|
||||
'phone' => $store->owner?->phone,
|
||||
],
|
||||
'coordinates' => $store->coordinates ? [
|
||||
'lat' => $store->coordinates->latitude,
|
||||
'lng' => $store->coordinates->longitude,
|
||||
] : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update store profile details.
|
||||
*/
|
||||
public function updateProfile(Request $request): JsonResponse
|
||||
{
|
||||
$store = $this->getStore();
|
||||
|
||||
$data = $request->validate([
|
||||
'business_name' => 'required|string|max:255',
|
||||
'business_permit_number' => 'nullable|string|max:255',
|
||||
'address_line' => 'required|string|max:255',
|
||||
'operating_hours' => 'nullable|array',
|
||||
'operating_hours.open' => 'nullable|string',
|
||||
'operating_hours.close' => 'nullable|string',
|
||||
'owner_phone' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$store->update([
|
||||
'business_name' => $data['business_name'],
|
||||
'business_permit_number' => $data['business_permit_number'] ?? null,
|
||||
'address_line' => $data['address_line'],
|
||||
'operating_hours' => $data['operating_hours'] ?? null,
|
||||
]);
|
||||
|
||||
if ($store->owner && isset($data['owner_phone'])) {
|
||||
$store->owner->update([
|
||||
'phone' => $data['owner_phone'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->ok(null, 'Profile updated successfully.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Store;
|
||||
|
||||
use App\Http\Controllers\Api\V1\ApiController;
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrPurchaseOrder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class StoreQrPurchaseController extends ApiController
|
||||
{
|
||||
public function pending(Request $request): JsonResponse
|
||||
{
|
||||
$orders = QrPurchaseOrder::with(['resident'])
|
||||
->where('store_id', $request->user()->id)
|
||||
->where('status', 'pending_payment')
|
||||
->orderBy('created_at')
|
||||
->get();
|
||||
|
||||
return $this->ok($orders);
|
||||
}
|
||||
|
||||
public function complete(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'scanned_qr_data' => 'required|string',
|
||||
]);
|
||||
|
||||
$order = QrPurchaseOrder::where('uuid', $uuid)
|
||||
->where('store_id', $request->user()->id)
|
||||
->where('status', 'pending_payment')
|
||||
->firstOrFail();
|
||||
|
||||
// Find the scanned QR code
|
||||
$qrCode = QrCode::where('serial', $request->scanned_qr_data)->first();
|
||||
|
||||
if (!$qrCode) {
|
||||
return $this->fail('Invalid QR Code. Not found in the system.', null, 404);
|
||||
}
|
||||
|
||||
if (! $qrCode->status->equals(\App\States\QrCode\Allocated::class) && ! $qrCode->status->equals(\App\States\QrCode\Unassigned::class)) {
|
||||
return $this->fail('This QR Code cannot be assigned (already used or active).', null, 422);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($order, $qrCode) {
|
||||
$order->update([
|
||||
'status' => 'completed',
|
||||
'completed_at' => now(),
|
||||
'qr_code_id' => $qrCode->id,
|
||||
]);
|
||||
|
||||
$qrCode->status->transitionTo(\App\States\QrCode\Active::class);
|
||||
$qrCode->update([
|
||||
'assigned_to_user_id' => $order->resident_id,
|
||||
'activated_at' => now(),
|
||||
]);
|
||||
});
|
||||
|
||||
return $this->ok($order->fresh(), 'QR Purchase completed and assigned successfully.');
|
||||
}
|
||||
}
|
||||
@@ -25,4 +25,14 @@ class StoreDashboardController extends Controller
|
||||
{
|
||||
return view('store.financials');
|
||||
}
|
||||
|
||||
public function qrPurchases()
|
||||
{
|
||||
return view('store.qr-purchases');
|
||||
}
|
||||
|
||||
public function profile()
|
||||
{
|
||||
return view('store.profile');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user