| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272 |
- <?php
- declare(strict_types=1);
- namespace plugin\saiadmin\app\service;
- use plugin\saiadmin\app\model\water\WaterModel;
- use plugin\saiadmin\app\model\water\WaterDriver;
- use plugin\saiadmin\app\model\water\WaterDriverShift;
- use plugin\saiadmin\app\model\water\WaterDelivery;
- use plugin\saiadmin\app\model\water\WaterRoute;
- use plugin\saiadmin\app\model\water\WaterLedger as WaterLedgerModel;
- use plugin\saiadmin\app\model\water\WaterSku;
- use plugin\saiadmin\exception\ApiException;
- class WaterDispatch
- {
- public static function date($value): string
- {
- $value = (string)$value;
- $date = \DateTimeImmutable::createFromFormat('!Y-m-d',$value);
- if (!$date || $date->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];
- });
- }
- }
|