Files
GLOC/app/Http/Controllers/GeoPlanController.php
2026-01-23 19:18:52 +07:00

668 lines
21 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Models\SalesPlan;
use App\Models\PlanTarget;
use App\Helpers\GeoHelper;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Carbon\Carbon;
class GeoPlanController extends Controller
{
/**
* GET /api/sales-plans
* Get all plans with optional filters
*/
public function index(Request $request): JsonResponse
{
$query = SalesPlan::with(['user:id,employee_id,name,color', 'targets']);
// Filter by date
if ($request->has('date')) {
$date = Carbon::parse($request->date, config('app.timezone'));
$query->whereBetween('date', [
$date->copy()->startOfDay(),
$date->copy()->endOfDay()
]);
}
// Filter by salesId
if ($request->has('salesId')) {
$user = User::where('employee_id', $request->salesId)->first(['id']);
if ($user) {
$query->where('user_id', $user->id);
} else {
// User not found, return empty result
$query->where('user_id', 'non_existent_id');
}
}
// Filter by status
if ($request->has('status')) {
$query->where('status', $request->status);
}
$plans = $query->orderBy('date', 'desc')->get();
$formattedPlans = $plans->map(function ($plan) {
return [
'id' => (string) $plan->id,
'salesId' => $plan->user->employee_id,
'salesName' => $plan->user->name,
'salesColor' => $plan->user->color,
'date' => $plan->date->format('Y-m-d'),
'status' => $plan->status,
'createdAt' => $plan->created_at->toISOString(),
'targets' => $plan->targets->map(fn($t) => [
'id' => (string) $t->id,
'lat' => (float) $t->latitude,
'lng' => (float) $t->longitude,
'name' => $t->name,
'address' => $t->address ?? '',
'order' => $t->order,
'isCompleted' => $t->is_completed ?? false,
'completedAt' => $t->completed_at ? $t->completed_at->toISOString() : null,
]),
'optimizedRoute' => $plan->optimized_route,
'totalDistance' => $plan->total_distance_km,
];
});
return response()->json([
'success' => true,
'plans' => $formattedPlans,
]);
}
/**
* GET /api/sales-plans/dates
* Get available dates
*/
public function dates(): JsonResponse
{
// MongoDB Aggregation to get unique dates
$rawDates = SalesPlan::raw(function ($collection) {
return $collection->aggregate([
[
'$project' => [
'dateOnly' => ['$dateToString' => ['format' => '%Y-%m-%d', 'date' => '$date']]
]
],
[
'$group' => [
'_id' => '$dateOnly'
]
],
[
'$sort' => ['_id' => 1] // Ascending for Planner usually, but let's check original. Original was ASC.
]
]);
});
$dates = collect($rawDates)->map(function ($item) {
return $item['_id'];
})->values();
return response()->json([
'success' => true,
'dates' => $dates,
]);
}
/**
* GET /api/sales-plans/sales
* Get sales list
*/
public function sales(): JsonResponse
{
$users = User::where('is_active', true)
->where('role', 'sales')
->get(['id', 'employee_id', 'name', 'color']);
return response()->json([
'success' => true,
'salesList' => $users->map(fn($u) => [
'id' => $u->employee_id,
'name' => $u->name,
'color' => $u->color,
]),
]);
}
/**
* GET /api/sales-plans/{id}
* Get single plan
*/
public function show(string $id): JsonResponse
{
$plan = SalesPlan::with(['user:id,employee_id,name,color', 'targets'])->find($id);
if (!$plan) {
return response()->json(['success' => false, 'error' => 'Plan not found'], 404);
}
return response()->json([
'success' => true,
'plan' => [
'id' => (string) $plan->id,
'salesId' => $plan->user->employee_id,
'salesName' => $plan->user->name,
'salesColor' => $plan->user->color,
'date' => $plan->date->format('Y-m-d'),
'status' => $plan->status,
'targets' => $plan->targets->map(fn($t) => [
'id' => (string) $t->id,
'lat' => (float) $t->latitude,
'lng' => (float) $t->longitude,
'name' => $t->name,
'address' => $t->address ?? '',
'order' => $t->order,
'isCompleted' => $t->is_completed ?? false,
'completedAt' => $t->completed_at ? $t->completed_at->toISOString() : null,
]),
'optimizedRoute' => $plan->optimized_route,
'totalDistance' => $plan->total_distance_km,
],
]);
}
/**
* POST /api/sales-plans
* Create new plan
*/
public function store(Request $request): JsonResponse
{
$request->validate([
'salesId' => 'required|string',
'date' => 'required|date',
'targets' => 'array',
]);
$user = User::where('employee_id', $request->salesId)->first();
if (!$user) {
return response()->json(['success' => false, 'error' => 'Invalid salesId'], 400);
}
$plan = SalesPlan::create([
'user_id' => $user->id,
'date' => $request->date,
'status' => 'pending',
]);
// Create targets
$targets = collect($request->targets ?? [])->map(function ($t, $idx) use ($plan) {
return PlanTarget::create([
'sales_plan_id' => $plan->id,
'order' => $idx + 1,
'latitude' => $t['lat'],
'longitude' => $t['lng'],
'name' => $t['name'],
'address' => $t['address'] ?? '',
]);
});
return response()->json([
'success' => true,
'plan' => [
'id' => (string) $plan->id,
'salesId' => $user->employee_id,
'salesName' => $user->name,
'salesColor' => $user->color,
'date' => $plan->date->format('Y-m-d'),
'status' => $plan->status,
'targets' => $targets->map(fn($t) => [
'id' => (string) $t->id,
'lat' => (float) $t->latitude,
'lng' => (float) $t->longitude,
'name' => $t->name,
'address' => $t->address,
'order' => $t->order,
'isCompleted' => $t->is_completed ?? false,
'completedAt' => $t->completed_at ? $t->completed_at->toISOString() : null,
]),
'optimizedRoute' => null,
],
]);
}
/**
* PUT /api/sales-plans/{id}
* Update plan
*/
public function update(Request $request, string $id): JsonResponse
{
$plan = SalesPlan::with('user')->find($id);
if (!$plan) {
return response()->json(['success' => false, 'error' => 'Plan not found'], 404);
}
if ($request->has('targets')) {
// Delete existing targets
$plan->targets()->delete();
// Create new targets
foreach ($request->targets as $idx => $t) {
PlanTarget::create([
'sales_plan_id' => $plan->id,
'order' => $idx + 1,
'latitude' => $t['lat'],
'longitude' => $t['lng'],
'name' => $t['name'],
'address' => $t['address'] ?? '',
]);
}
$plan->status = 'pending';
$plan->optimized_route = null;
}
if ($request->has('date')) {
$plan->date = $request->date;
}
if ($request->has('salesId')) {
$user = User::where('employee_id', $request->salesId)->first();
if ($user) {
$plan->user_id = $user->id;
}
}
$plan->save();
$plan->load(['user', 'targets']);
return response()->json([
'success' => true,
'plan' => $this->formatPlan($plan),
]);
}
/**
* DELETE /api/sales-plans/{id}
* Delete plan
*/
public function destroy(string $id): JsonResponse
{
$plan = SalesPlan::find($id);
if (!$plan) {
return response()->json(['success' => false, 'error' => 'Plan not found'], 404);
}
$plan->targets()->delete();
$plan->delete();
return response()->json([
'success' => true,
'message' => 'Plan deleted',
]);
}
/**
* POST /api/sales-plans/{id}/optimize
* Optimize route using Nearest Neighbor algorithm
*/
public function optimize(string $id): JsonResponse
{
$plan = SalesPlan::with(['user', 'targets'])->find($id);
if (!$plan) {
return response()->json(['success' => false, 'error' => 'Plan not found'], 404);
}
$targets = $plan->targets->toArray();
if (count($targets) < 2) {
return response()->json(['success' => false, 'error' => 'Need at least 2 targets to optimize'], 400);
}
// Store original order
$originalOrder = array_keys($targets);
// Run optimization
$result = GeoHelper::optimizeRouteNearestNeighbor($targets);
$optimizedOrder = $result['order'];
$totalDistance = $result['totalDistance'];
// Reorder targets
foreach ($optimizedOrder as $newOrder => $originalIdx) {
PlanTarget::where('id', $targets[$originalIdx]['id'])
->update(['order' => $newOrder + 1]);
}
// Update plan
$plan->status = 'optimized';
$plan->optimized_at = now();
$plan->optimized_route = $optimizedOrder;
$plan->total_distance_km = $totalDistance;
$plan->save();
$plan->load('targets');
return response()->json([
'success' => true,
'plan' => $this->formatPlan($plan),
'optimization' => [
'originalOrder' => $originalOrder,
'optimizedOrder' => $optimizedOrder,
'totalDistanceKm' => $totalDistance,
],
]);
}
/**
* POST /api/sales-plans/{id}/add-target
* Add target to existing plan
*/
public function addTarget(Request $request, string $id): JsonResponse
{
$plan = SalesPlan::with('user')->find($id);
if (!$plan) {
return response()->json(['success' => false, 'error' => 'Plan not found'], 404);
}
$request->validate([
'lat' => 'required|numeric',
'lng' => 'required|numeric',
'name' => 'required|string',
]);
$maxOrder = $plan->targets()->max('order') ?? 0;
$target = PlanTarget::create([
'sales_plan_id' => $plan->id,
'order' => $maxOrder + 1,
'latitude' => $request->lat,
'longitude' => $request->lng,
'name' => $request->name,
'address' => $request->address ?? '',
'source' => 'mobile_manual', // Differentiate from admin plan
]);
$plan->status = 'pending';
$plan->optimized_route = null;
$plan->save();
$plan->load('targets');
return response()->json([
'success' => true,
'plan' => $this->formatPlan($plan),
'newTarget' => [
'id' => (string) $target->id,
'lat' => (float) $target->latitude,
'lng' => (float) $target->longitude,
'name' => $target->name,
'address' => $target->address,
'order' => $target->order,
],
]);
}
/**
* PUT /api/sales-plans/{id}/target/{targetId}
* Update existing target
*/
public function updateTarget(Request $request, string $id, string $targetId): JsonResponse
{
$plan = SalesPlan::find($id);
if (!$plan) {
return response()->json(['success' => false, 'error' => 'Plan not found'], 404);
}
$target = PlanTarget::where('id', $targetId)
->where('sales_plan_id', $plan->id)
->first();
if (!$target) {
return response()->json(['success' => false, 'error' => 'Target not found'], 404);
}
$request->validate([
'lat' => 'nullable|numeric',
'lng' => 'nullable|numeric',
'name' => 'nullable|string',
]);
if ($request->has('name'))
$target->name = $request->name;
if ($request->has('address'))
$target->address = $request->address;
if ($request->has('lat'))
$target->latitude = $request->lat;
if ($request->has('lng'))
$target->longitude = $request->lng;
$target->save();
// Mark plan as pending optimization
$plan->status = 'pending';
$plan->optimized_route = null;
$plan->save();
$plan->load(['user', 'targets']); // Reload user relation too just in case formatPlan needs it
return response()->json([
'success' => true,
'plan' => $this->formatPlan($plan),
'target' => [
'id' => (string) $target->id,
'lat' => (float) $target->latitude,
'lng' => (float) $target->longitude,
'name' => $target->name,
'address' => $target->address,
'order' => $target->order,
'isCompleted' => $target->is_completed ?? false,
'completedAt' => $target->completed_at ? $target->completed_at->toISOString() : null,
],
]);
}
/**
* DELETE /api/sales-plans/{id}/target/{targetId}
* Remove target from plan
*/
public function removeTarget(string $id, string $targetId): JsonResponse
{
$plan = SalesPlan::with('user')->find($id);
if (!$plan) {
return response()->json(['success' => false, 'error' => 'Plan not found'], 404);
}
$target = PlanTarget::where('id', $targetId)
->where('sales_plan_id', $plan->id)
->first();
if (!$target) {
return response()->json(['success' => false, 'error' => 'Target not found'], 404);
}
$target->delete();
// Reorder remaining targets
$plan->targets()->orderBy('order')->get()->each(function ($t, $idx) {
$t->update(['order' => $idx + 1]);
});
$plan->status = 'pending';
$plan->optimized_route = null;
$plan->save();
$plan->load('targets');
return response()->json([
'success' => true,
'plan' => $this->formatPlan($plan),
]);
}
/**
* POST /api/mobile/schedules/{id}/target/{targetId}/checkin
* Check-in to target (mark as completed)
*/
public function checkinTarget(string $id, string $targetId): JsonResponse
{
$plan = SalesPlan::with('user')->find($id);
if (!$plan) {
return response()->json(['success' => false, 'error' => 'Plan not found'], 404);
}
$target = PlanTarget::where('id', $targetId)
->where('sales_plan_id', $plan->id)
->first();
if (!$target) {
return response()->json(['success' => false, 'error' => 'Target not found'], 404);
}
$target->is_completed = true;
$target->completed_at = now();
$target->save();
// Refresh plan
$plan->load('targets');
return response()->json([
'success' => true,
'plan' => $this->formatPlan($plan),
]);
}
/**
* POST /api/sales-plans/assign-target
* Auto assign target to user's plan for today (find or create)
*/
public function autoAssignTarget(Request $request): JsonResponse
{
$request->validate([
'staffId' => 'required|string',
'placeData' => 'required|array',
'notes' => 'nullable|string',
]);
$user = User::where('employee_id', $request->staffId)->first();
// Fallback: check by mongo ID if not found by employee_id, or just assume input is right ID type
if (!$user) {
$user = User::find($request->staffId);
}
if (!$user) {
return response()->json(['success' => false, 'error' => 'Staff/User not found'], 404);
}
// Find or create plan for today in user's timezone (or app default)
$today = now()->startOfDay();
$plan = SalesPlan::where('user_id', $user->id)
->whereDate('date', $today)
->first();
if (!$plan) {
$plan = SalesPlan::create([
'user_id' => $user->id,
'date' => $today,
'status' => 'pending',
]);
}
$place = $request->placeData;
// Calculate order
$maxOrder = $plan->targets()->max('order') ?? 0;
$target = PlanTarget::create([
'sales_plan_id' => $plan->id,
'order' => $maxOrder + 1,
'latitude' => $place['lat'],
'longitude' => $place['lng'],
'name' => $place['name'],
'address' => $place['address'] ?? '',
'notes' => $request->notes,
'source' => 'web_assign',
]);
return response()->json([
'success' => true,
'message' => 'Target assigned successfully',
'planId' => $plan->id
]);
}
/**
* Format plan for response
*/
private function formatPlan(SalesPlan $plan): array
{
return [
'id' => (string) $plan->id,
'salesId' => $plan->user->employee_id,
'salesName' => $plan->user->name,
'salesColor' => $plan->user->color,
'date' => $plan->date->format('Y-m-d'),
'status' => $plan->status,
'targets' => $plan->targets->map(fn($t) => [
'id' => (string) $t->id,
'lat' => (float) $t->latitude,
'lng' => (float) $t->longitude,
'name' => $t->name,
'address' => $t->address ?? '',
'order' => $t->order,
'isCompleted' => $t->is_completed ?? false,
'completedAt' => $t->completed_at ? $t->completed_at->toISOString() : null,
]),
'optimizedRoute' => $plan->optimized_route,
'totalDistance' => $plan->total_distance_km,
];
}
/**
* GET /api/mobile/schedules
* Get plan for specific user (for mobile app)
*/
public function getMobileSchedule(Request $request): JsonResponse
{
$request->validate([
'user_id' => 'required|string', // Employee ID
'date' => 'nullable|date',
]);
// Find user
$user = User::where('employee_id', $request->user_id)->first();
if (!$user) {
return response()->json(['success' => false, 'error' => 'User not found'], 404);
}
// Get the date string for comparison (YYYY-MM-DD format)
$dateStr = $request->date ?? now()->toDateString();
// Create date range for the entire day in local timezone (Asia/Jakarta)
// This handles plans created with different timezone offsets
$startOfDay = \Carbon\Carbon::parse($dateStr, config('app.timezone'))->startOfDay();
$endOfDay = \Carbon\Carbon::parse($dateStr, config('app.timezone'))->endOfDay();
try {
// First, try to find an existing plan for this user on this date
$plan = SalesPlan::where('user_id', $user->id)
->whereBetween('date', [$startOfDay, $endOfDay])
->first();
// If no plan exists, create one
if (!$plan) {
$plan = SalesPlan::create([
'user_id' => $user->id,
'date' => $startOfDay, // Use local timezone start of day
'status' => 'pending',
'total_distance_km' => 0,
]);
}
$plan->load('targets');
return response()->json([
'success' => true,
'plan' => $this->formatPlan($plan),
]);
} catch (\Throwable $e) {
return response()->json([
'success' => false,
'error' => 'Server Error: ' . $e->getMessage(),
], 500);
}
}
}