format('Y-m-d') !== $value) throw new ApiException('配送日期格式不正确'); return $value; } public static function coordinates(array $data): array { $lng = $data['longitude'] ?? null; $lat = $data['latitude'] ?? null; if ($lng === '') $lng = null; if ($lat === '') $lat = null; if (($lng === null) !== ($lat === null)) throw new ApiException('经纬度必须同时填写'); if ($lng !== null && (!is_numeric($lng) || !is_numeric($lat) || abs((float)$lng)>180 || abs((float)$lat)>90)) throw new ApiException('经纬度范围不正确'); return ['longitude'=>$lng === null ? null : round((float)$lng,6),'latitude'=>$lat === null ? null : round((float)$lat,6)]; } public function drivers(string $date): array { $drivers = WaterDriver::query()->orderBy('id')->get()->toArray(); $shifts = WaterDriverShift::query()->where('work_date',$date)->pluck('state','driver_id')->all(); $busy = WaterDelivery::query()->whereIn('status',['assigned','transit','returning'])->whereNotNull('driver_id') ->selectRaw("driver_id,COUNT(*) AS tasks,SUM(quantity - CASE WHEN status='returning' THEN delivered_qty ELSE 0 END) AS buckets")->groupByRaw('driver_id')->get()->toArray(); $loads = array_column($busy,null,'driver_id'); $openRoutes = WaterRoute::query()->whereNull('settled_at')->selectRaw('driver_id,COUNT(*) AS total')->groupBy('driver_id')->pluck('total','driver_id')->all(); foreach ($drivers as &$driver) { $driver['shift'] = $shifts[$driver['id']] ?? 'unset'; $driver['tasks'] = (int)($loads[$driver['id']]['tasks'] ?? 0); $driver['buckets'] = (int)($loads[$driver['id']]['buckets'] ?? 0); $driver['unsettled_routes'] = (int)($openRoutes[$driver['id']]??0); $driver['state'] = ($driver['tasks'] || $driver['unsettled_routes']) ? 'busy' : ((int)$driver['status'] !== 1 ? 'disabled' : ($driver['shift'] === 'on' ? 'available' : $driver['shift'])); } return $drivers; } public function board(array $filters): array { $date = self::date($filters['date'] ?? date('Y-m-d')); $keyword = trim((string)($filters['keyword'] ?? '')); if (mb_strlen($keyword)>120) throw new ApiException('搜索内容过长'); $query = WaterDelivery::query()->alias('d')->leftJoin('sa_water_enterprise as e','e.id','=','d.enterprise_id') ->leftJoin('sa_water_sku as s','s.id','=','d.sku_id')->leftJoin('sa_water_route as r','r.id','=','d.route_id') ->selectRaw('d.*,e.name AS enterprise_name,e.contact,e.phone AS enterprise_phone,s.name AS sku_name,r.code AS route_code,r.settled_at AS route_settled_at,r.optimization AS route_optimization,COALESCE(d.scheduled_date,DATE(d.created_at)) AS work_date'); // 日期当天所有任务,加上此前尚未完成的待办,避免逾期任务消失。 $query->whereRaw('(COALESCE(d.scheduled_date,DATE(d.created_at)) = ? OR (COALESCE(d.scheduled_date,DATE(d.created_at)) < ? AND d.status IN (\'pending\',\'assigned\',\'transit\',\'returning\')) OR DATE(d.returned_at) = ? OR DATE(d.completed_at) = ?)',[$date,$date,$date,$date]); if ($keyword !== '') $query->matching('e.name|d.region|d.driver|r.code','%'.$keyword.'%'); if (($filters['region'] ?? '') !== '') $query->where('d.region',(string)$filters['region']); $tasks = $query->orderBy('d.id','desc')->limit(5001)->get()->toArray(); $children=WaterDelivery::query()->whereIn('redelivery_of',array_column($tasks,'id'))->pluck('id','redelivery_of')->all(); foreach ($tasks as &$task) $task['redelivery_id']=$children[$task['id']]??null; unset($task); if (count($tasks)>5000) throw new ApiException('当前任务超过 5000 条,请按企业或片区缩小范围'); $areas = []; $summary = ['tasks'=>0,'buckets'=>0,'pending'=>0,'assigned'=>0,'transit'=>0,'completed'=>0,'cancelled'=>0,'returning'=>0,'returned'=>0]; foreach ($tasks as $task) { $summary[$task['status']]++; if (in_array($task['status'],['completed','cancelled','returned'],true)) continue; $buckets=(int)$task['quantity']-($task['status']==='returning'?(int)$task['delivered_qty']:0); $summary['tasks']++; $summary['buckets'] += $buckets; $region = $task['region']; if (!isset($areas[$region])) $areas[$region] = ['region'=>$region,'tasks'=>0,'buckets'=>0,'urgent'=>0]; $areas[$region]['tasks']++; $areas[$region]['buckets'] += $buckets; if ($task['deadline_at'] && $task['deadline_at'] <= $date.' 23:59:59') $areas[$region]['urgent']++; } $regions = WaterDelivery::query()->distinct(true)->orderBy('region')->pluck('region')->all(); $settlementRoutes = WaterRoute::query()->alias('r')->join('sa_water_driver as v','v.id','=','r.driver_id') ->whereNull('r.settled_at')->where('r.work_date','<=',$date)->selectRaw('r.*,v.name AS driver')->orderBy('r.id','desc')->get()->toArray(); $remaining = WaterDelivery::query()->whereIn('route_id',array_column($settlementRoutes,'id'))->whereNotIn('status',['completed','returned','cancelled']) ->selectRaw('route_id,COUNT(*) AS total')->groupBy('route_id')->pluck('total','route_id')->all(); foreach ($settlementRoutes as &$settlementRoute) $settlementRoute['remaining_tasks']=(int)($remaining[$settlementRoute['id']]??0); unset($settlementRoute); return ['settlement_routes'=>$settlementRoutes,'date'=>$date,'tasks'=>$tasks,'areas'=>array_values($areas),'regions'=>$regions,'summary'=>$summary,'drivers'=>$this->drivers($date)]; } public function route(array $filters): array { $id = WaterBusiness::positive($filters['id'] ?? null,'配送路线'); $route = WaterRoute::query()->where('id',$id)->first()?->toArray(); if (!$route) throw new ApiException('配送路线不存在'); $route['tasks'] = WaterDelivery::query()->alias('d') ->leftJoin('sa_water_enterprise as e','e.id','=','d.enterprise_id')->leftJoin('sa_water_sku as s','s.id','=','d.sku_id') ->where('d.route_id',$id)->selectRaw('d.*,e.name AS enterprise_name,e.contact,e.phone AS enterprise_phone,s.name AS sku_name') ->orderBy('d.stop_order')->get()->toArray(); foreach ($route['tasks'] as &$task) $task['route_optimization']=$route['optimization']; unset($task); $route['fulfillment']=(new WaterFulfillment())->routeSummary($route); return $route; } private function word(array $data,string $key,int $max,bool $required=false): string { $value = trim((string)($data[$key] ?? '')); if (($required && $value==='') || mb_strlen($value)>$max) throw new ApiException($key.'不能为空或超出长度'); return $value; } private function audit(string $reference,string $note,int $actor): void { WaterLedgerModel::query()->insert(['kind'=>'dispatch_config','created_by'=>$actor,'reference'=>$reference,'note'=>$note,'quantity'=>0,'created_at'=>date('Y-m-d H:i:s')]); } public function saveDriver(array $data,int $actor): array { return WaterModel::transaction(function () use ($data,$actor) { $id = WaterBusiness::positive($data['id'] ?? 0,'配送员',true); $old = $id ? WaterDriver::query()->where('id',$id)->lock(true)->first()?->toArray() : null; if ($id && !$old) throw new ApiException('配送员不存在'); $values = ['name'=>$this->word($data,'name',60,true),'phone'=>$this->word($data,'phone',30,true), 'region'=>$this->word($data,'region',80),'vehicle'=>$this->word($data,'vehicle',40), 'capacity'=>WaterBusiness::positive($data['capacity'] ?? null,'车辆容量'),'status'=>(int)($data['status'] ?? 1),'updated_at'=>date('Y-m-d H:i:s')]; if (!in_array($values['status'],[1,2],true)) throw new ApiException('配送员状态不支持'); if (WaterDriver::query()->where('phone',$values['phone'])->where('id','<>',$id)->count()) throw new ApiException('联系电话已存在'); if ($id && WaterRoute::query()->where('driver_id',$id)->whereNull('settled_at')->exists() && ($values['status'] !== 1 || $values['capacity'] !== (int)$old['capacity'] || $values['region'] !== $old['region'])) throw new ApiException('还有未结算路线,不能停用或变更运力及片区'); if ($id && WaterDelivery::query()->where('driver_id',$id)->whereIn('status',['assigned','transit','returning'])->count() && ($values['status'] !== 1 || $values['capacity'] !== (int)$old['capacity'] || $values['region'] !== $old['region'])) throw new ApiException('有未完成任务时不能停用或变更运力及服务片区'); if ($id) WaterDriver::query()->where('id',$id)->update($values); else $id = (int)WaterDriver::query()->insertGetId($values+['created_at'=>date('Y-m-d H:i:s')]); $this->audit('driver:'.$id,'维护配送员档案',$actor); return ['id'=>$id]; }); } public function shift(array $data,int $actor): array { $id = WaterBusiness::positive($data['driver_id'] ?? null,'配送员'); $date = self::date($data['date'] ?? ''); $state = $data['state'] ?? ''; if (!in_array($state,['on','rest'],true)) throw new ApiException('排班状态不支持'); return WaterModel::transaction(function () use ($id,$date,$state,$actor) { if (!WaterDriver::query()->where('id',$id)->where('status',1)->lock(true)->first()?->toArray()) throw new ApiException('配送员不存在或已停用'); if ($state==='rest' && WaterRoute::query()->where('driver_id',$id)->whereNull('settled_at')->exists()) throw new ApiException('还有未结算路线,不能安排休息'); if ($state==='rest' && WaterDelivery::query()->where('driver_id',$id)->whereIn('status',['assigned','transit','returning']) ->whereRaw('COALESCE(scheduled_date,DATE(created_at)) <= ?',[$date])->count()) throw new ApiException('还有未完成配送,不能安排休息'); $query = WaterDriverShift::query()->where(['driver_id'=>$id,'work_date'=>$date]); $values = ['state'=>$state,'updated_at'=>date('Y-m-d H:i:s')]; if ((clone $query)->first()?->toArray()) $query->update($values); else WaterDriverShift::query()->insert($values+['driver_id'=>$id,'work_date'=>$date]); $this->audit('driver:'.$id,$date.' 排班 '.$state,$actor); return ['id'=>$id]; }); } public function schedule(array $data,int $actor): array { $id = WaterBusiness::positive($data['id'] ?? null,'配送任务'); $date = self::date($data['date'] ?? ''); $deadline = trim((string)($data['deadline_at'] ?? '')); if ($deadline !== '' && (!strtotime($deadline) || date('Y-m-d H:i:s',strtotime($deadline))!==$deadline || substr($deadline,0,10)!==$date)) throw new ApiException('送达时限必须是配送当日的有效时间'); return WaterModel::transaction(function () use ($data,$id,$date,$deadline,$actor) { $task = WaterDelivery::query()->where('id',$id)->lock(true)->first()?->toArray(); if (!$task || $task['status']!=='pending') throw new ApiException('仅待派单任务可调整配送计划'); $origin=[]; if (array_key_exists('origin_longitude',$data) || array_key_exists('origin_latitude',$data)) { $point=self::coordinates(['longitude'=>$data['origin_longitude']??null,'latitude'=>$data['origin_latitude']??null]); $origin=['origin_longitude'=>$point['longitude'],'origin_latitude'=>$point['latitude'],'origin_address'=>$this->word($data,'origin_address',255)]; } WaterDelivery::query()->where('id',$id)->update(['scheduled_date'=>$date,'deadline_at'=>$deadline ?: null,'updated_at'=>date('Y-m-d H:i:s')]+self::coordinates($data)+$origin); $this->audit('delivery:'.$id,'调整配送日期、时限及位置',$actor); return ['id'=>$id]; }); } public function plan(array $filters): array { $board = $this->board($filters); $tasks = array_values(array_filter($board['tasks'],fn($t)=>$t['status']==='pending')); usort($tasks,fn($a,$b)=>[$a['deadline_at'] ?: $a['work_date'].' 23:59:59',$a['id']] <=> [$b['deadline_at'] ?: $b['work_date'].' 23:59:59',$b['id']]); $drivers = array_values(array_filter($board['drivers'],fn($d)=>$d['state']==='available')); $routes = []; $unplanned = []; foreach ($tasks as $task) { $chosen = null; foreach ($drivers as $driver) { $id = $driver['id']; $route = $routes[$id] ?? null; if (($driver['region']!=='' && $driver['region']!==$task['region']) || ($route && $route['region']!==$task['region'])) continue; if (($route['buckets'] ?? 0)+(int)$task['quantity'] > (int)$driver['capacity']) continue; $chosen = $driver; break; } if (!$chosen) { $unplanned[] = ['id'=>$task['id'],'enterprise_name'=>$task['enterprise_name'],'reason'=>'没有已排班且空闲的匹配运力,或剩余车辆容量不足']; continue; } $id = $chosen['id']; if (!isset($routes[$id])) $routes[$id] = ['driver_id'=>$id,'driver'=>$chosen['name'],'region'=>$task['region'],'capacity'=>$chosen['capacity'],'buckets'=>0,'task_ids'=>[],'tasks'=>[]]; $routes[$id]['buckets'] += (int)$task['quantity']; $routes[$id]['task_ids'][] = (int)$task['id']; $routes[$id]['tasks'][] = $task; } return ['date'=>$board['date'],'routes'=>array_values($routes),'unplanned'=>$unplanned]; } public function commit(array $data,int $actor): array { $date = self::date($data['date'] ?? ''); $key = $this->word($data,'request_key',70,true); if (!preg_match('/^[a-zA-Z0-9_-]+$/D',$key)) throw new ApiException('合单请求标识不正确'); $groups = $data['routes'] ?? []; if (!is_array($groups) || !array_is_list($groups) || !$groups || count($groups)>50) throw new ApiException('请选择 1 至 50 条配送路线'); $ids=[]; $driverIds=[]; foreach ($groups as $group) { $driverIds[] = WaterBusiness::positive($group['driver_id'] ?? null,'配送员'); if (!is_array($group['task_ids'] ?? null) || !array_is_list($group['task_ids']) || !$group['task_ids']) throw new ApiException('路线不能没有任务'); foreach ($group['task_ids'] as $id) $ids[] = WaterBusiness::positive($id,'配送任务'); } if (count($ids)>500 || count(array_unique($ids))!==count($ids) || count(array_unique($driverIds))!==count($driverIds)) throw new ApiException('任务或配送员重复,或单次超过 500 个任务'); return WaterModel::transaction(function () use ($groups,$ids,$driverIds,$key,$date,$actor) { $signatureGroups=array_map(fn($g)=>[(int)$g['driver_id'],array_map('intval',$g['task_ids'])],$groups); if (array_filter($groups,fn($g)=>!empty($g['optimization_token']))) foreach ($groups as $i=>$group) $signatureGroups[$i][]=$group['optimization_token']??null; $signature = hash('sha256',json_encode([$date,$actor,$signatureGroups])); $skuIds = WaterDelivery::query()->whereIn('id',$ids)->pluck('sku_id')->all(); if (!$skuIds) throw new ApiException('配送任务不存在'); WaterSku::query()->whereIn('id',$skuIds)->orderBy('id')->lock(true)->get(); WaterDriver::query()->whereIn('id',$driverIds)->orderBy('id')->lock(true)->get(); $keys = array_map(fn($i)=>$key.'-'.$i,array_keys($groups)); $previous = WaterRoute::query()->whereIn('request_key',$keys)->get()->toArray(); if ($previous) { if (count($previous)!==count($groups)) throw new ApiException('请求标识已用于其他方案'); $byKey = array_column($previous,null,'request_key'); foreach ($groups as $i=>$group) { $old = $byKey[$key.'-'.$i]; if ($old['request_hash'] !== $signature) throw new ApiException('请求标识已用于其他方案'); $oldIds = array_map('intval',WaterDelivery::query()->where('route_id',$old['id'])->orderBy('stop_order')->pluck('id')->all()); if ((int)$old['driver_id']!==(int)$group['driver_id'] || $old['work_date']!==$date || $oldIds!==array_map('intval',$group['task_ids'])) throw new ApiException('请求标识已用于其他方案'); } return ['route_ids'=>array_column($previous,'id'),'replayed'=>true]; } $tasks = WaterDelivery::query()->whereIn('id',$ids)->orderBy('id')->lock(true)->get()->toArray(); $tasks = array_column($tasks,null,'id'); $drivers = array_column($this->drivers($date),null,'id'); $created=[]; foreach ($groups as $i=>$group) { $driver = $drivers[$group['driver_id']] ?? null; if (!$driver || $driver['state']!=='available') throw new ApiException('配送员未排班、已有任务或已停用,请重新生成方案'); $buckets=0; $region=null; foreach ($group['task_ids'] as $id) { $task = $tasks[$id] ?? null; if (!$task || $task['status']!=='pending' || $task['route_id']) throw new ApiException('任务状态已改变,请重新生成方案'); $workDate = $task['scheduled_date'] ?: substr($task['created_at'],0,10); if ($workDate>$date) throw new ApiException('不能提前合并未来日期任务'); if ($region!==null && $region!==$task['region']) throw new ApiException('同一路线须为同一片区'); $region=$task['region']; $buckets+=(int)$task['quantity']; } if ($buckets>(int)$driver['capacity']) throw new ApiException('配送桶数超过车辆容量'); if ($driver['region']!=='' && $driver['region']!==$region) throw new ApiException('配送员服务片区不匹配'); $optimization=null; if (!empty($group['optimization_token'])) { $optimization=(new WaterRoutePlanning())->validated((string)$group['optimization_token'],array_map(fn($id)=>$tasks[$id],$group['task_ids']),$actor); if ($optimization['optimized']['task_ids']!==array_map('intval',$group['task_ids'])) throw new ApiException('提交站序与优化方案不一致'); if (substr($optimization['departure_at'],0,10)!==$date) throw new ApiException('出发日期须与配送日期一致'); } $routeId=(int)WaterRoute::query()->insertGetId(['code'=>'PS-'.date('YmdHis').'-'.bin2hex(random_bytes(3)), 'driver_id'=>$driver['id'],'work_date'=>$date,'region'=>$region,'capacity'=>$driver['capacity'], 'request_key'=>$key.'-'.$i,'request_hash'=>$signature,'optimization'=>$optimization?json_encode($optimization,JSON_UNESCAPED_UNICODE):null,'created_by'=>$actor,'created_at'=>date('Y-m-d H:i:s')]); foreach ($group['task_ids'] as $order=>$id) { (new WaterBusiness())->transition(['id'=>$id,'action'=>'assign','driver'=>$driver['name'],'driver_phone'=>$driver['phone']],$actor); WaterDelivery::query()->where('id',$id)->update(['driver_id'=>$driver['id'],'route_id'=>$routeId,'stop_order'=>$order+1]); } $this->audit('route:'.$routeId,'生成配送路线 '.$buckets.'桶',$actor); $created[]=$routeId; } return ['route_ids'=>$created]; }); } }