668 lines
21 KiB
PHP
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);
|
|
}
|
|
}
|
|
}
|