WaterDispatch.php 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. <?php
  2. declare(strict_types=1);
  3. namespace plugin\saiadmin\app\service;
  4. use plugin\saiadmin\app\model\water\WaterModel;
  5. use plugin\saiadmin\app\model\water\WaterDriver;
  6. use plugin\saiadmin\app\model\water\WaterDriverShift;
  7. use plugin\saiadmin\app\model\water\WaterDelivery;
  8. use plugin\saiadmin\app\model\water\WaterRoute;
  9. use plugin\saiadmin\app\model\water\WaterLedger as WaterLedgerModel;
  10. use plugin\saiadmin\app\model\water\WaterSku;
  11. use plugin\saiadmin\exception\ApiException;
  12. class WaterDispatch
  13. {
  14. public static function date($value): string
  15. {
  16. $value = (string)$value;
  17. $date = \DateTimeImmutable::createFromFormat('!Y-m-d',$value);
  18. if (!$date || $date->format('Y-m-d') !== $value) throw new ApiException('配送日期格式不正确');
  19. return $value;
  20. }
  21. public static function coordinates(array $data): array
  22. {
  23. $lng = $data['longitude'] ?? null; $lat = $data['latitude'] ?? null;
  24. if ($lng === '') $lng = null;
  25. if ($lat === '') $lat = null;
  26. if (($lng === null) !== ($lat === null)) throw new ApiException('经纬度必须同时填写');
  27. if ($lng !== null && (!is_numeric($lng) || !is_numeric($lat) || abs((float)$lng)>180 || abs((float)$lat)>90)) throw new ApiException('经纬度范围不正确');
  28. return ['longitude'=>$lng === null ? null : round((float)$lng,6),'latitude'=>$lat === null ? null : round((float)$lat,6)];
  29. }
  30. public function drivers(string $date): array
  31. {
  32. $drivers = WaterDriver::query()->orderBy('id')->get()->toArray();
  33. $shifts = WaterDriverShift::query()->where('work_date',$date)->pluck('state','driver_id')->all();
  34. $busy = WaterDelivery::query()->whereIn('status',['assigned','transit','returning'])->whereNotNull('driver_id')
  35. ->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();
  36. $loads = array_column($busy,null,'driver_id');
  37. $openRoutes = WaterRoute::query()->whereNull('settled_at')->selectRaw('driver_id,COUNT(*) AS total')->groupBy('driver_id')->pluck('total','driver_id')->all();
  38. foreach ($drivers as &$driver) {
  39. $driver['shift'] = $shifts[$driver['id']] ?? 'unset';
  40. $driver['tasks'] = (int)($loads[$driver['id']]['tasks'] ?? 0);
  41. $driver['buckets'] = (int)($loads[$driver['id']]['buckets'] ?? 0);
  42. $driver['unsettled_routes'] = (int)($openRoutes[$driver['id']]??0);
  43. $driver['state'] = ($driver['tasks'] || $driver['unsettled_routes']) ? 'busy' : ((int)$driver['status'] !== 1 ? 'disabled' : ($driver['shift'] === 'on' ? 'available' : $driver['shift']));
  44. }
  45. return $drivers;
  46. }
  47. public function board(array $filters): array
  48. {
  49. $date = self::date($filters['date'] ?? date('Y-m-d'));
  50. $keyword = trim((string)($filters['keyword'] ?? ''));
  51. if (mb_strlen($keyword)>120) throw new ApiException('搜索内容过长');
  52. $query = WaterDelivery::query()->alias('d')->leftJoin('sa_water_enterprise as e','e.id','=','d.enterprise_id')
  53. ->leftJoin('sa_water_sku as s','s.id','=','d.sku_id')->leftJoin('sa_water_route as r','r.id','=','d.route_id')
  54. ->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');
  55. // 日期当天所有任务,加上此前尚未完成的待办,避免逾期任务消失。
  56. $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]);
  57. if ($keyword !== '') $query->matching('e.name|d.region|d.driver|r.code','%'.$keyword.'%');
  58. if (($filters['region'] ?? '') !== '') $query->where('d.region',(string)$filters['region']);
  59. $tasks = $query->orderBy('d.id','desc')->limit(5001)->get()->toArray();
  60. $children=WaterDelivery::query()->whereIn('redelivery_of',array_column($tasks,'id'))->pluck('id','redelivery_of')->all();
  61. foreach ($tasks as &$task) $task['redelivery_id']=$children[$task['id']]??null;
  62. unset($task);
  63. if (count($tasks)>5000) throw new ApiException('当前任务超过 5000 条,请按企业或片区缩小范围');
  64. $areas = []; $summary = ['tasks'=>0,'buckets'=>0,'pending'=>0,'assigned'=>0,'transit'=>0,'completed'=>0,'cancelled'=>0,'returning'=>0,'returned'=>0];
  65. foreach ($tasks as $task) {
  66. $summary[$task['status']]++;
  67. if (in_array($task['status'],['completed','cancelled','returned'],true)) continue;
  68. $buckets=(int)$task['quantity']-($task['status']==='returning'?(int)$task['delivered_qty']:0);
  69. $summary['tasks']++; $summary['buckets'] += $buckets;
  70. $region = $task['region'];
  71. if (!isset($areas[$region])) $areas[$region] = ['region'=>$region,'tasks'=>0,'buckets'=>0,'urgent'=>0];
  72. $areas[$region]['tasks']++; $areas[$region]['buckets'] += $buckets;
  73. if ($task['deadline_at'] && $task['deadline_at'] <= $date.' 23:59:59') $areas[$region]['urgent']++;
  74. }
  75. $regions = WaterDelivery::query()->distinct(true)->orderBy('region')->pluck('region')->all();
  76. $settlementRoutes = WaterRoute::query()->alias('r')->join('sa_water_driver as v','v.id','=','r.driver_id')
  77. ->whereNull('r.settled_at')->where('r.work_date','<=',$date)->selectRaw('r.*,v.name AS driver')->orderBy('r.id','desc')->get()->toArray();
  78. $remaining = WaterDelivery::query()->whereIn('route_id',array_column($settlementRoutes,'id'))->whereNotIn('status',['completed','returned','cancelled'])
  79. ->selectRaw('route_id,COUNT(*) AS total')->groupBy('route_id')->pluck('total','route_id')->all();
  80. foreach ($settlementRoutes as &$settlementRoute) $settlementRoute['remaining_tasks']=(int)($remaining[$settlementRoute['id']]??0);
  81. unset($settlementRoute);
  82. return ['settlement_routes'=>$settlementRoutes,'date'=>$date,'tasks'=>$tasks,'areas'=>array_values($areas),'regions'=>$regions,'summary'=>$summary,'drivers'=>$this->drivers($date)];
  83. }
  84. public function route(array $filters): array
  85. {
  86. $id = WaterBusiness::positive($filters['id'] ?? null,'配送路线');
  87. $route = WaterRoute::query()->where('id',$id)->first()?->toArray();
  88. if (!$route) throw new ApiException('配送路线不存在');
  89. $route['tasks'] = WaterDelivery::query()->alias('d')
  90. ->leftJoin('sa_water_enterprise as e','e.id','=','d.enterprise_id')->leftJoin('sa_water_sku as s','s.id','=','d.sku_id')
  91. ->where('d.route_id',$id)->selectRaw('d.*,e.name AS enterprise_name,e.contact,e.phone AS enterprise_phone,s.name AS sku_name')
  92. ->orderBy('d.stop_order')->get()->toArray();
  93. foreach ($route['tasks'] as &$task) $task['route_optimization']=$route['optimization'];
  94. unset($task);
  95. $route['fulfillment']=(new WaterFulfillment())->routeSummary($route);
  96. return $route;
  97. }
  98. private function word(array $data,string $key,int $max,bool $required=false): string
  99. {
  100. $value = trim((string)($data[$key] ?? ''));
  101. if (($required && $value==='') || mb_strlen($value)>$max) throw new ApiException($key.'不能为空或超出长度');
  102. return $value;
  103. }
  104. private function audit(string $reference,string $note,int $actor): void
  105. {
  106. WaterLedgerModel::query()->insert(['kind'=>'dispatch_config','created_by'=>$actor,'reference'=>$reference,'note'=>$note,'quantity'=>0,'created_at'=>date('Y-m-d H:i:s')]);
  107. }
  108. public function saveDriver(array $data,int $actor): array
  109. {
  110. return WaterModel::transaction(function () use ($data,$actor) {
  111. $id = WaterBusiness::positive($data['id'] ?? 0,'配送员',true);
  112. $old = $id ? WaterDriver::query()->where('id',$id)->lock(true)->first()?->toArray() : null;
  113. if ($id && !$old) throw new ApiException('配送员不存在');
  114. $values = ['name'=>$this->word($data,'name',60,true),'phone'=>$this->word($data,'phone',30,true),
  115. 'region'=>$this->word($data,'region',80),'vehicle'=>$this->word($data,'vehicle',40),
  116. 'capacity'=>WaterBusiness::positive($data['capacity'] ?? null,'车辆容量'),'status'=>(int)($data['status'] ?? 1),'updated_at'=>date('Y-m-d H:i:s')];
  117. if (!in_array($values['status'],[1,2],true)) throw new ApiException('配送员状态不支持');
  118. if (WaterDriver::query()->where('phone',$values['phone'])->where('id','<>',$id)->count()) throw new ApiException('联系电话已存在');
  119. if ($id && WaterRoute::query()->where('driver_id',$id)->whereNull('settled_at')->exists()
  120. && ($values['status'] !== 1 || $values['capacity'] !== (int)$old['capacity'] || $values['region'] !== $old['region'])) throw new ApiException('还有未结算路线,不能停用或变更运力及片区');
  121. if ($id && WaterDelivery::query()->where('driver_id',$id)->whereIn('status',['assigned','transit','returning'])->count()
  122. && ($values['status'] !== 1 || $values['capacity'] !== (int)$old['capacity'] || $values['region'] !== $old['region'])) throw new ApiException('有未完成任务时不能停用或变更运力及服务片区');
  123. if ($id) WaterDriver::query()->where('id',$id)->update($values);
  124. else $id = (int)WaterDriver::query()->insertGetId($values+['created_at'=>date('Y-m-d H:i:s')]);
  125. $this->audit('driver:'.$id,'维护配送员档案',$actor);
  126. return ['id'=>$id];
  127. });
  128. }
  129. public function shift(array $data,int $actor): array
  130. {
  131. $id = WaterBusiness::positive($data['driver_id'] ?? null,'配送员');
  132. $date = self::date($data['date'] ?? ''); $state = $data['state'] ?? '';
  133. if (!in_array($state,['on','rest'],true)) throw new ApiException('排班状态不支持');
  134. return WaterModel::transaction(function () use ($id,$date,$state,$actor) {
  135. if (!WaterDriver::query()->where('id',$id)->where('status',1)->lock(true)->first()?->toArray()) throw new ApiException('配送员不存在或已停用');
  136. if ($state==='rest' && WaterRoute::query()->where('driver_id',$id)->whereNull('settled_at')->exists()) throw new ApiException('还有未结算路线,不能安排休息');
  137. if ($state==='rest' && WaterDelivery::query()->where('driver_id',$id)->whereIn('status',['assigned','transit','returning'])
  138. ->whereRaw('COALESCE(scheduled_date,DATE(created_at)) <= ?',[$date])->count()) throw new ApiException('还有未完成配送,不能安排休息');
  139. $query = WaterDriverShift::query()->where(['driver_id'=>$id,'work_date'=>$date]);
  140. $values = ['state'=>$state,'updated_at'=>date('Y-m-d H:i:s')];
  141. if ((clone $query)->first()?->toArray()) $query->update($values);
  142. else WaterDriverShift::query()->insert($values+['driver_id'=>$id,'work_date'=>$date]);
  143. $this->audit('driver:'.$id,$date.' 排班 '.$state,$actor);
  144. return ['id'=>$id];
  145. });
  146. }
  147. public function schedule(array $data,int $actor): array
  148. {
  149. $id = WaterBusiness::positive($data['id'] ?? null,'配送任务');
  150. $date = self::date($data['date'] ?? '');
  151. $deadline = trim((string)($data['deadline_at'] ?? ''));
  152. if ($deadline !== '' && (!strtotime($deadline) || date('Y-m-d H:i:s',strtotime($deadline))!==$deadline || substr($deadline,0,10)!==$date)) throw new ApiException('送达时限必须是配送当日的有效时间');
  153. return WaterModel::transaction(function () use ($data,$id,$date,$deadline,$actor) {
  154. $task = WaterDelivery::query()->where('id',$id)->lock(true)->first()?->toArray();
  155. if (!$task || $task['status']!=='pending') throw new ApiException('仅待派单任务可调整配送计划');
  156. $origin=[];
  157. if (array_key_exists('origin_longitude',$data) || array_key_exists('origin_latitude',$data)) {
  158. $point=self::coordinates(['longitude'=>$data['origin_longitude']??null,'latitude'=>$data['origin_latitude']??null]);
  159. $origin=['origin_longitude'=>$point['longitude'],'origin_latitude'=>$point['latitude'],'origin_address'=>$this->word($data,'origin_address',255)];
  160. }
  161. 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);
  162. $this->audit('delivery:'.$id,'调整配送日期、时限及位置',$actor);
  163. return ['id'=>$id];
  164. });
  165. }
  166. public function plan(array $filters): array
  167. {
  168. $board = $this->board($filters);
  169. $tasks = array_values(array_filter($board['tasks'],fn($t)=>$t['status']==='pending'));
  170. 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']]);
  171. $drivers = array_values(array_filter($board['drivers'],fn($d)=>$d['state']==='available'));
  172. $routes = []; $unplanned = [];
  173. foreach ($tasks as $task) {
  174. $chosen = null;
  175. foreach ($drivers as $driver) {
  176. $id = $driver['id']; $route = $routes[$id] ?? null;
  177. if (($driver['region']!=='' && $driver['region']!==$task['region']) || ($route && $route['region']!==$task['region'])) continue;
  178. if (($route['buckets'] ?? 0)+(int)$task['quantity'] > (int)$driver['capacity']) continue;
  179. $chosen = $driver; break;
  180. }
  181. if (!$chosen) { $unplanned[] = ['id'=>$task['id'],'enterprise_name'=>$task['enterprise_name'],'reason'=>'没有已排班且空闲的匹配运力,或剩余车辆容量不足']; continue; }
  182. $id = $chosen['id'];
  183. if (!isset($routes[$id])) $routes[$id] = ['driver_id'=>$id,'driver'=>$chosen['name'],'region'=>$task['region'],'capacity'=>$chosen['capacity'],'buckets'=>0,'task_ids'=>[],'tasks'=>[]];
  184. $routes[$id]['buckets'] += (int)$task['quantity']; $routes[$id]['task_ids'][] = (int)$task['id']; $routes[$id]['tasks'][] = $task;
  185. }
  186. return ['date'=>$board['date'],'routes'=>array_values($routes),'unplanned'=>$unplanned];
  187. }
  188. public function commit(array $data,int $actor): array
  189. {
  190. $date = self::date($data['date'] ?? '');
  191. $key = $this->word($data,'request_key',70,true);
  192. if (!preg_match('/^[a-zA-Z0-9_-]+$/D',$key)) throw new ApiException('合单请求标识不正确');
  193. $groups = $data['routes'] ?? [];
  194. if (!is_array($groups) || !array_is_list($groups) || !$groups || count($groups)>50) throw new ApiException('请选择 1 至 50 条配送路线');
  195. $ids=[]; $driverIds=[];
  196. foreach ($groups as $group) {
  197. $driverIds[] = WaterBusiness::positive($group['driver_id'] ?? null,'配送员');
  198. if (!is_array($group['task_ids'] ?? null) || !array_is_list($group['task_ids']) || !$group['task_ids']) throw new ApiException('路线不能没有任务');
  199. foreach ($group['task_ids'] as $id) $ids[] = WaterBusiness::positive($id,'配送任务');
  200. }
  201. if (count($ids)>500 || count(array_unique($ids))!==count($ids) || count(array_unique($driverIds))!==count($driverIds)) throw new ApiException('任务或配送员重复,或单次超过 500 个任务');
  202. return WaterModel::transaction(function () use ($groups,$ids,$driverIds,$key,$date,$actor) {
  203. $signatureGroups=array_map(fn($g)=>[(int)$g['driver_id'],array_map('intval',$g['task_ids'])],$groups);
  204. if (array_filter($groups,fn($g)=>!empty($g['optimization_token']))) foreach ($groups as $i=>$group) $signatureGroups[$i][]=$group['optimization_token']??null;
  205. $signature = hash('sha256',json_encode([$date,$actor,$signatureGroups]));
  206. $skuIds = WaterDelivery::query()->whereIn('id',$ids)->pluck('sku_id')->all();
  207. if (!$skuIds) throw new ApiException('配送任务不存在');
  208. WaterSku::query()->whereIn('id',$skuIds)->orderBy('id')->lock(true)->get();
  209. WaterDriver::query()->whereIn('id',$driverIds)->orderBy('id')->lock(true)->get();
  210. $keys = array_map(fn($i)=>$key.'-'.$i,array_keys($groups));
  211. $previous = WaterRoute::query()->whereIn('request_key',$keys)->get()->toArray();
  212. if ($previous) {
  213. if (count($previous)!==count($groups)) throw new ApiException('请求标识已用于其他方案');
  214. $byKey = array_column($previous,null,'request_key');
  215. foreach ($groups as $i=>$group) {
  216. $old = $byKey[$key.'-'.$i];
  217. if ($old['request_hash'] !== $signature) throw new ApiException('请求标识已用于其他方案');
  218. $oldIds = array_map('intval',WaterDelivery::query()->where('route_id',$old['id'])->orderBy('stop_order')->pluck('id')->all());
  219. if ((int)$old['driver_id']!==(int)$group['driver_id'] || $old['work_date']!==$date || $oldIds!==array_map('intval',$group['task_ids'])) throw new ApiException('请求标识已用于其他方案');
  220. }
  221. return ['route_ids'=>array_column($previous,'id'),'replayed'=>true];
  222. }
  223. $tasks = WaterDelivery::query()->whereIn('id',$ids)->orderBy('id')->lock(true)->get()->toArray();
  224. $tasks = array_column($tasks,null,'id');
  225. $drivers = array_column($this->drivers($date),null,'id');
  226. $created=[];
  227. foreach ($groups as $i=>$group) {
  228. $driver = $drivers[$group['driver_id']] ?? null;
  229. if (!$driver || $driver['state']!=='available') throw new ApiException('配送员未排班、已有任务或已停用,请重新生成方案');
  230. $buckets=0; $region=null;
  231. foreach ($group['task_ids'] as $id) {
  232. $task = $tasks[$id] ?? null;
  233. if (!$task || $task['status']!=='pending' || $task['route_id']) throw new ApiException('任务状态已改变,请重新生成方案');
  234. $workDate = $task['scheduled_date'] ?: substr($task['created_at'],0,10);
  235. if ($workDate>$date) throw new ApiException('不能提前合并未来日期任务');
  236. if ($region!==null && $region!==$task['region']) throw new ApiException('同一路线须为同一片区');
  237. $region=$task['region']; $buckets+=(int)$task['quantity'];
  238. }
  239. if ($buckets>(int)$driver['capacity']) throw new ApiException('配送桶数超过车辆容量');
  240. if ($driver['region']!=='' && $driver['region']!==$region) throw new ApiException('配送员服务片区不匹配');
  241. $optimization=null;
  242. if (!empty($group['optimization_token'])) {
  243. $optimization=(new WaterRoutePlanning())->validated((string)$group['optimization_token'],array_map(fn($id)=>$tasks[$id],$group['task_ids']),$actor);
  244. if ($optimization['optimized']['task_ids']!==array_map('intval',$group['task_ids'])) throw new ApiException('提交站序与优化方案不一致');
  245. if (substr($optimization['departure_at'],0,10)!==$date) throw new ApiException('出发日期须与配送日期一致');
  246. }
  247. $routeId=(int)WaterRoute::query()->insertGetId(['code'=>'PS-'.date('YmdHis').'-'.bin2hex(random_bytes(3)),
  248. 'driver_id'=>$driver['id'],'work_date'=>$date,'region'=>$region,'capacity'=>$driver['capacity'],
  249. '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')]);
  250. foreach ($group['task_ids'] as $order=>$id) {
  251. (new WaterBusiness())->transition(['id'=>$id,'action'=>'assign','driver'=>$driver['name'],'driver_phone'=>$driver['phone']],$actor);
  252. WaterDelivery::query()->where('id',$id)->update(['driver_id'=>$driver['id'],'route_id'=>$routeId,'stop_order'=>$order+1]);
  253. }
  254. $this->audit('route:'.$routeId,'生成配送路线 '.$buckets.'桶',$actor); $created[]=$routeId;
  255. }
  256. return ['route_ids'=>$created];
  257. });
  258. }
  259. }