AutomaticDispatch.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476
  1. <?php
  2. namespace app\common\command;
  3. use think\facade\Db;
  4. use think\facade\Log;
  5. use think\console\Input;
  6. use think\console\Output;
  7. use think\console\Command;
  8. use app\adminapi\service\WeCallService;
  9. use app\common\model\works\ServiceWork;
  10. use app\common\model\goods_time\GoodsTime;
  11. use app\common\model\master_worker\MasterWorker;
  12. use app\common\model\works\ServiceWorkAnomalous;
  13. use app\common\model\master_worker\MasterWorkerTeam;
  14. use app\common\model\works\ServiceWorkAllocateWorkerLog;
  15. use app\workerapi\logic\ServiceWorkerAllocateWorkerLogic;
  16. use app\common\model\master_worker\MasterWorkerServiceTime;
  17. class AutomaticDispatch extends Command
  18. {
  19. //地理时效评分占比
  20. protected $distanceRate = 0.25;
  21. //工程师权重评分占比
  22. protected $weightRate = 0.2;
  23. //工程师综合评分占比
  24. protected $comprehensiveRate = 0.55;
  25. //默认服务时长 180 分钟
  26. protected $defaultServiceTime = 180;
  27. //外呼客户列表
  28. protected $customerList = [];
  29. //服务类目
  30. protected $categoryType = [
  31. 1 => '安装',
  32. 2 => '维修',
  33. 3 => '清洗',
  34. ];
  35. protected function configure()
  36. {
  37. $this->setName('automatic_dispatch')
  38. ->setDescription('自动派单');
  39. }
  40. protected function execute(Input $input, Output $output)
  41. {
  42. //自动派单
  43. // $this->autoDispatch();
  44. // //执行外呼任务
  45. // $h = date('H');
  46. // if ($h >= 8 && $h <= 22) {
  47. // $this->startTask();
  48. // }
  49. //异常工单:已过预约时间工程师未确认上门
  50. $this->workAnomalous();
  51. //异常工单:上门时间超过两小时工单未确认完成
  52. $this->unFinishedWorkAnomalous();
  53. }
  54. /**
  55. * 异常工单:已过预约时间工程师未确认上门
  56. */
  57. protected function workAnomalous()
  58. {
  59. $size = 100;
  60. $startTime = strtotime(date('Y-m-d 00:00:00'));
  61. $endTime = time(); // 当前时间戳
  62. $list = ServiceWork::alias("a")
  63. ->leftJoin('service_work_anomalous b','a.id = b.work_id')
  64. ->where('a.work_status','<',3)
  65. ->where('a.service_status','<',2)
  66. ->where('a.refund_approval',0)
  67. ->where('a.work_pay_status',1)
  68. ->where('a.appointment_time','between', [$startTime, $endTime])
  69. ->whereNotExists(function($query) {
  70. $query->table('la_service_work_anomalous c')
  71. ->whereRaw('a.id = c.work_id')
  72. ->where('c.reason_type', 3);
  73. })
  74. ->field('a.id,a.appointment_time,b.id as anomalous_id,b.reason_type')
  75. ->group('a.id')
  76. ->order('a.create_time','asc')
  77. ->limit($size)
  78. ->select()
  79. ->toArray();
  80. if (!$list) {
  81. return ;
  82. }
  83. foreach($list as $item) {
  84. try {
  85. ServiceWorkAnomalous::create([
  86. 'work_id' => $item['id'],
  87. 'reason_type' => 3,
  88. 'reason' => '已过预约时间工程师未确认上门',
  89. ]);
  90. } catch (\Exception $e) {
  91. Log::write('异常工单:'.$e->getMessage());
  92. }
  93. }
  94. }
  95. /**
  96. * 异常工单:上门时间超过两小时工单未确认完成
  97. */
  98. protected function unFinishedWorkAnomalous()
  99. {
  100. $size = 100;
  101. $startTime = strtotime(date('Y-m-d 00:00:00'));
  102. $endTime = time() - 7200; // 当前时间减去7200秒(2小时)
  103. $list = ServiceWork::alias("a")
  104. ->leftJoin('service_work_anomalous b','a.id = b.work_id')
  105. ->where('a.work_status', '>=', 4)
  106. ->where('a.service_status','<',3)
  107. ->where('a.refund_approval',0)
  108. ->where('a.work_pay_status',1)
  109. ->where('a.appointment_time','between', [$startTime, $endTime])
  110. ->whereNotExists(function($query) {
  111. $query->table('la_service_work_anomalous c')
  112. ->whereRaw('a.id = c.work_id')
  113. ->where('c.reason_type', 4);
  114. })
  115. ->field('a.id,a.appointment_time,b.id as anomalous_id')
  116. ->group('a.id')
  117. ->order('a.create_time','asc')
  118. ->limit($size)
  119. ->select()
  120. ->toArray();
  121. if (!$list) {
  122. return ;
  123. }
  124. foreach($list as $item) {
  125. try {
  126. ServiceWorkAnomalous::create([
  127. 'work_id' => $item['id'],
  128. 'reason_type' => 4,
  129. 'reason' => '上门时间超过两小时工单未确认完成',
  130. ]);
  131. } catch (\Exception $e) {
  132. Log::write('异常工单:'.$e->getMessage());
  133. }
  134. }
  135. }
  136. /*
  137. * 自动派单总分100分
  138. 1、地理效率得分(distance score)25%
  139. 2、工程师权重「自定义是否有证」(weight score)20%
  140. 3、工程师综合服务分 55%
  141. */
  142. protected function autoDispatch()
  143. {
  144. $size = 100;
  145. $startTime = strtotime(date('Y-m-d 00:00:00'));
  146. $endTime = strtotime(date('Y-m-d 23:59:59'));
  147. $list = ServiceWork::where('work_status',0)
  148. ->where('service_status',0)
  149. ->where('refund_approval',0)
  150. ->where('work_pay_status',1)
  151. ->where('exec_num','<', 2)
  152. ->where('appointment_time','between', [$startTime, $endTime])
  153. ->field('id,category_type,goods_category_id,service_area_id,lon,lat,province,city,title,appointment_time,address,mobile,work_sn')
  154. ->order('create_time','asc')
  155. ->limit($size)
  156. ->select()
  157. ->toArray();
  158. if (!$list) {
  159. return ;
  160. }
  161. foreach($list as $item) {
  162. try {
  163. //优先平台工程师派单
  164. $res = $this->platformWorker($item);
  165. if ($res === false) {
  166. //门店负责人派单
  167. $res = $this->teamWorker($item);
  168. if ($res === false) {
  169. ServiceWork::where('id',$item['id'])
  170. ->update([
  171. 'exec_num' => 2,
  172. ]);
  173. ServiceWorkAnomalous::create([
  174. 'work_id' => $item['id'],
  175. 'reason_type' => 2,
  176. 'reason' => '自动派单:找不到工程师',
  177. ]);
  178. }
  179. }
  180. } catch (\Exception $e) {
  181. Log::write('自动派单异常:'.$e->getMessage());
  182. }
  183. }
  184. }
  185. /**
  186. * 执行外呼任务
  187. */
  188. protected function startTask() {
  189. if ($this->customerList) {
  190. $weCallService = new WeCallService();
  191. $res = $weCallService->importUser($this->customerList);
  192. if (isset($res['code']) && $res['code'] == 200) {
  193. $res = $weCallService->startTask();
  194. }
  195. }
  196. $this->customerList = [];
  197. }
  198. /**
  199. * 派单给平台工程师
  200. */
  201. protected function platformWorker($item) {
  202. // 定义地球半径(单位:米)
  203. $earthRadius = 6371000;
  204. // 定义 Haversine 公式计算距离的 SQL 片段
  205. $distanceCalculation = "{$earthRadius} * 2 * ASIN(SQRT(
  206. POWER(SIN((RADIANS({$item['lat']}) - RADIANS(a.lat)) / 2), 2) +
  207. COS(RADIANS({$item['lat']})) * COS(RADIANS(a.lat)) *
  208. POWER(SIN((RADIANS({$item['lon']}) - RADIANS(a.lon)) / 2), 2)
  209. ))";
  210. // 计算距离的字段定义
  211. $real_distance = Db::raw("{$distanceCalculation} AS real_distance");
  212. // 获取符合条件的工程师
  213. $worker = MasterWorker::alias('a')
  214. ->leftJoin('master_worker_score b', 'a.id = b.worker_id')
  215. ->where([
  216. ['is_disable', '=', 0],
  217. ['work_status', '=', 0],
  218. ['accept_order_status', '=', 1],
  219. ['city', '=', $item['city']],
  220. ['service_area_id', '=', $item['service_area_id']],
  221. ['tenant_id', '=', 0]
  222. ])
  223. ->distinct('a.id')
  224. ->whereRaw('FIND_IN_SET(' . $item['goods_category_id'] . ', a.category_ids)')
  225. ->whereRaw("{$distanceCalculation} <= a.distance")
  226. ->field([
  227. 'a.id',
  228. 'a.tenant_id',
  229. 'a.distance',
  230. 'a.lon',
  231. 'a.lat',
  232. 'a.worker_number',
  233. 'a.real_name',
  234. 'a.mobile',
  235. 'a.is_wecall',
  236. 'b.comprehensive_score',
  237. 'b.weight_score',
  238. $real_distance
  239. ])
  240. ->orderRaw('(b.comprehensive_score + b.weight_score) desc')
  241. ->limit(100)
  242. ->select()
  243. ->toArray();
  244. //echo MasterWorker::getLastSql();die;
  245. $queue = [];
  246. foreach($worker as $key => $value) {
  247. //过滤已接过此单的师傅
  248. $exists = ServiceWorkAllocateWorkerLog::where('work_id', $item['id'])->where('master_worker_id',$value['id'])->count();
  249. if ($exists) {
  250. continue;
  251. }
  252. //计算地理效率得分
  253. $realDistance = bcdiv($value['real_distance'],1000,2);
  254. $travelTime = $realDistance * 2;//预计每公里行驶2分钟
  255. $distanceScore = 100 - ($travelTime * 1.5) - ($realDistance * 5);
  256. $distanceScore = bcadd($distanceScore, 0, 2);
  257. $tmpDistanceRate = bcmul($distanceScore, $this->distanceRate, 2);
  258. $tmpRate = 0;
  259. $value['travelTime'] = $travelTime;
  260. $value['comprehensive_score'] = isset($value['comprehensive_score']) ? $value['comprehensive_score'] : 0;
  261. $value['weight_score'] = isset($value['weight_score']) ? $value['weight_score'] : 0;
  262. $tmpRate = bcmul($value['comprehensive_score'], $this->comprehensiveRate, 2) + bcmul($value['weight_score'], $this->weightRate, 2);
  263. $tmpRate = bcadd($tmpRate, $tmpDistanceRate,2);
  264. $tmpKey = isset($queue[$tmpRate]) ? bcadd($tmpRate, $key / 100,2) : $tmpRate;//防止键名重复
  265. $queue[$tmpKey] = $value;
  266. }
  267. //按照工程师的总分值倒序排序
  268. krsort($queue);
  269. foreach($queue as $worker) {
  270. $serviceTime = MasterWorkerServiceTime::where('master_worker_id',$worker['id'])->where('goods_category_id',$item['goods_category_id'])->value('service_time');
  271. if (empty($serviceTime)) {
  272. $serviceTime = GoodsTime::whereRaw('FIND_IN_SET('.$item['goods_category_id'].', goods_category_ids)')->value('service_time');
  273. $serviceTime = $serviceTime ?? $this->defaultServiceTime;//默认服务时长
  274. }
  275. //预约开始时间和结束时间
  276. $appointment_time = is_numeric($item['appointment_time']) ? $item['appointment_time'] : strtotime($item['appointment_time']);
  277. $estimated_finish_time = $appointment_time + $serviceTime * 60 + $worker['travelTime'] * 60;
  278. //校验客户的预约时间是否在工程师的空挡期内
  279. $count = ServiceWork::where([
  280. ['master_worker_id','=',$worker['id']],
  281. ['work_status','>=',1],
  282. ['work_status','<=',5],
  283. ['service_status','<',4]
  284. ])
  285. ->where(function ($query) use ($appointment_time,$estimated_finish_time) {
  286. $query->where('appointment_time', 'between',[$appointment_time, $estimated_finish_time])
  287. ->whereOr('estimated_finish_time', 'between', [$appointment_time, $estimated_finish_time]);
  288. })
  289. ->count();
  290. if ($count == 0) {
  291. $operaLog = '系统自动派单于'.date('Y-m-d H:i:s',time()).'分配了工程师'.'编号['.$worker['worker_number'].']'.$worker['real_name'];
  292. $res = $this->allocateWorker($item,$worker['id'],$worker['tenant_id'],$operaLog,$estimated_finish_time);
  293. if ($res === true && $worker['is_wecall'] == 1) {
  294. $this->customerList[] = [
  295. 'phone' => $worker['mobile'],
  296. 'properties' => [
  297. '订单号' => substr($item['work_sn'], -4),
  298. // '详细地址'=>$item['address'],
  299. // '服务类型'=> isset($this->categoryType[$item['category_type']]) ? $this->categoryType[$item['category_type']] : '',
  300. // '客户手机号'=>$item['mobile']
  301. ]
  302. ];
  303. }
  304. return $res;
  305. }
  306. }
  307. return false;
  308. }
  309. /**
  310. * 派单给门店负责人
  311. */
  312. protected function teamWorker($item) {
  313. // 地球半径,单位:米
  314. $earthRadius = 6371000;
  315. // 定义 Haversine 公式计算距离的 SQL 片段
  316. $distanceCalculation = "{$earthRadius} * 2 * ASIN(SQRT(
  317. POWER(SIN((RADIANS({$item['lat']}) - RADIANS(lat)) / 2), 2) +
  318. COS(RADIANS({$item['lat']})) * COS(RADIANS(lat)) *
  319. POWER(SIN((RADIANS({$item['lon']}) - RADIANS(lon)) / 2), 2)
  320. ))";
  321. // 计算距离的字段定义
  322. $real_distance = Db::raw("{$distanceCalculation} AS real_distance");
  323. // 判断预约时间是上午还是下午
  324. $isAm = date("H", strtotime($item['appointment_time'])) < 12 ? 1 : 0;
  325. // 根据上午或下午构建查询条件
  326. $whereRaw = $isAm ? 'am_order < am_limit' : 'pm_order < pm_limit';
  327. // 获取符合条件的工程师团队
  328. $worker = MasterWorkerTeam::where([
  329. ['accept_order_status', '=', 1],
  330. ['city', '=', $item['city']],
  331. ['service_area_id', '=', $item['service_area_id']],
  332. ])
  333. ->whereRaw($whereRaw)
  334. ->whereRaw('FIND_IN_SET(' . $item['goods_category_id'] . ', goods_category_ids)')
  335. // 使用 Haversine 公式替换 ST_Distance_Sphere 函数进行距离筛选
  336. ->whereRaw("{$distanceCalculation} <= distance")
  337. ->field([
  338. 'id',
  339. 'lon',
  340. 'lat',
  341. 'distance',
  342. 'tenant_id',
  343. 'team_name',
  344. 'master_worker_id',
  345. 'am_order',
  346. 'am_limit',
  347. 'pm_order',
  348. 'pm_limit',
  349. 'min_order',
  350. 'comprehensive_score',
  351. $real_distance
  352. ])
  353. ->order('comprehensive_score', 'desc')
  354. ->limit(100)
  355. ->select()
  356. ->toArray();
  357. //echo MasterWorkerTeam::getLastSql();die;
  358. $minQueue = [];
  359. $queue = [];
  360. foreach($worker as $key => $value) {
  361. //过滤已接过此单的师傅
  362. $exists = ServiceWorkAllocateWorkerLog::where('work_id', $item['id'])->where('master_worker_id',$value['master_worker_id'])->count();
  363. if ($exists) {
  364. continue;
  365. }
  366. if ($value['am_order'] + $value['pm_order'] < $value['min_order']) {
  367. $minQueue[] = $value;
  368. } else {
  369. $queue[] = $value;
  370. }
  371. }
  372. $queue = array_merge($minQueue,$queue);
  373. //优先给接单数量不足最低接单数的团队派单,其次再给服务评分高的派单
  374. foreach($queue as $worker) {
  375. $operaLog = '系统自动派单于'.date('Y-m-d H:i:s',time()).'分配了团队ID['.$worker['id'].']'.$worker['team_name'];
  376. $res = $this->allocateWorker($item, $worker['master_worker_id'], $worker['tenant_id'], $operaLog);
  377. if ($res === true) {
  378. $updateData = $isAm == 1 ? ['am_order' => Db::raw('am_order + 1')] : ['pm_order' => Db::raw('pm_order + 1')];
  379. MasterWorkerTeam::where('id',$worker['id'])->update($updateData);
  380. $this->customerList[] = [
  381. 'phone' => MasterWorker::where('id',$worker['master_worker_id'])->value('mobile'),
  382. 'properties' => [
  383. '订单号' => substr($item['work_sn'], -4),
  384. // '详细地址'=>$item['address'],
  385. // '服务类型'=> isset($this->categoryType[$item['category_type']]) ? $this->categoryType[$item['category_type']] : '',
  386. // '客户手机号'=>$item['mobile']
  387. ]
  388. ];
  389. return true;
  390. }
  391. }
  392. return false;
  393. }
  394. /**
  395. * 分配工程师
  396. */
  397. protected function allocateWorker($workDetail, $masterWorkerId, $tenant_id,$operaLog, $estimated_finish_time=0)
  398. {
  399. Db::startTrans();
  400. try {
  401. ServiceWork::where('id',$workDetail['id'])->update([
  402. 'master_worker_id'=>$masterWorkerId,
  403. 'tenant_id' => $tenant_id,
  404. 'work_status'=>1,
  405. 'estimated_finish_time' => $estimated_finish_time,
  406. 'dispatch_time'=>time(),
  407. 'exec_num' => Db::raw('exec_num + 1'),
  408. ]);
  409. MasterWorker::setWorktotal('inc',$masterWorkerId);
  410. $work_log = [
  411. 'work_id'=>$workDetail['id'],
  412. 'master_worker_id'=>$masterWorkerId,
  413. 'type' => 0,
  414. 'opera_log'=> $operaLog
  415. ];
  416. ServiceWorkerAllocateWorkerLogic::add($work_log);
  417. Db::commit();
  418. } catch (\Exception $e) {
  419. Db::rollback();
  420. Log::write('自动派单分配工程师异常:'.$e->getMessage());
  421. return false;
  422. }
  423. // 工程师派单通知【给工程师的通知】【公众号通知,不发短信】
  424. $res = event('Notice', [
  425. 'scene_id' => 113,
  426. 'params' => [
  427. 'user_id' => $masterWorkerId,
  428. 'order_id' => $workDetail['id'],
  429. 'thing9' => $workDetail['title'],
  430. 'time7' => $workDetail['appointment_time'],
  431. 'thing8' => (iconv_strlen($workDetail['address'])>15)?(mb_substr($workDetail['address'],0,15,'UTF-8').'...'):$workDetail['address'],
  432. 'phone_number6' => asteriskString($workDetail['mobile']),
  433. ]
  434. ]);
  435. return true;
  436. }
  437. }