AutomaticDispatch.php 15 KB

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