自动派单

This commit is contained in:
hant 2025-05-18 16:49:03 +08:00
parent 83a19267e8
commit b40fb65203
6 changed files with 422 additions and 42 deletions

View File

@ -3,12 +3,26 @@
namespace app\admin\command;
use app\admin\addresmart\Address;
use app\admin\controller\orders\DispatchLogic;
use app\admin\model\Order;
use app\admin\model\OrderDispatch;
use app\admin\model\OrderReview;
use app\admin\model\Worker;
use app\admin\model\WorkerItem;
use think\Collection;
use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\Db;
use think\exception\DbException;
use think\Hook;
use think\Model;
use function Symfony\Component\Clock\now;
class Test extends Command
{
protected function configure()
{
$this->setName('test')
@ -17,10 +31,12 @@ class Test extends Command
protected function execute(Input $input, Output $output)
{
$string = '张三 13800138000 120113196808214821深圳市龙华区龙华街道1980科技文化产业园3栋317 地板';
$r = Address::smart($string);
dd($r);
$order = (new Order())->find(110);
$id = (new DispatchLogic())->getMaxScoreWorker($order);
dd($id);
}
}

View File

@ -3,7 +3,7 @@
namespace app\admin\controller;
use app\admin\addresmart\Address;
use app\admin\model\AuthGroupAccess;
use app\admin\controller\orders\DispatchLogic;
use app\admin\model\order\Invoice;
use app\admin\model\OrderDispatch;
use app\admin\model\Worker;
@ -27,7 +27,6 @@ use function Symfony\Component\Clock\now;
*/
class Order extends Backend
{
/**
* Order模型对象
* @var \app\admin\model\Order
@ -347,24 +346,13 @@ class Order extends Backend
private function autoDispatch($order)
{
if ($order->dispatch_type != 2) {
return false;
}
//
// if ($order->dispatch_type != 2) {
// return false;
// }
$worker_ids = (new Worker())->where('area_id', $order->area_id)
->where('status', 1)
->field(['id', 'area_id', 'lng'], 'lat')
->column('id');
$worker_items_ids = (new WorkerItem())
->where('item_id', $order->item_id)
->whereIn('worker_id', $worker_ids)
->field(['worker_id'], 'lat')
->column('worker_id');
$out_workers = array_intersect($worker_ids, $worker_items_ids);
$worker_id = $out_workers[0] ?? false;
$worker_id = (new DispatchLogic())->getMaxScoreWorker($order);
if (!$worker_id) {
$order->dispatch_type = 1;
@ -389,7 +377,7 @@ class Order extends Backend
$orderDispatch->allowField(true)->save($insert);
$order->status = \app\admin\model\Order::STATUS_DISPATCHED;
$order->dispatch_time = date('Y-m-d H:i:s');
// $order->dispatch_admin_id = $this->auth->id;
// $order->dispatch_admin_id = $this->auth->id;
$order->worker_id = $worker_id;
$order->save();

View File

@ -0,0 +1,310 @@
<?php
namespace app\admin\controller\orders;
use app\admin\model\Order;
use app\admin\model\OrderDispatch;
use app\admin\model\OrderReview;
use app\admin\model\Worker;
use app\admin\model\WorkerItem;
use think\Collection;
use think\Db;
use think\exception\DbException;
use function Symfony\Component\Clock\now;
/**
* 自动派单
*
* @icon
*/
class DispatchLogic
{
function getMaxScoreWorker($order){
$worker_info = Db::query("SELECT id, `name`, lng, lat, distance,star
FROM (
SELECT id, `name`, lng, lat,star,
ST_Distance_Sphere(
point(lng, lat),
point(?, ?)
) AS distance
FROM fa_worker
) AS t
WHERE distance < 40000
ORDER BY distance;",[$order->lng,$order->lat]);
$worker_ids = array_column($worker_info,'id');
$worker_items_ids = (new WorkerItem())
->where('item_id', $order->item_id)
->whereIn('worker_id', $worker_ids)
->field(['worker_id'])
->column('worker_id');
$out_worker_ids = array_intersect($worker_ids, $worker_items_ids);
$worker_doings = (new OrderDispatch())
->whereIn('worker_id', $out_worker_ids)
->group('worker_id')
->field(['worker_id','count(if(status > 0 & status < 60,1,null)) count'])
->select();
$worker_doing_map = [];
foreach ($worker_doings as $item){
$worker_doing_map[$item->worker_id] = $item->toArray();
}
// 获取工人信息;
$worker_rate = $this->getWorkerRate($out_worker_ids);
$worker_scores = [];
$worker_rate_map = array_column($worker_rate,null,'worker_id');
foreach ($worker_info as $item){
if (in_array($item['id'],$out_worker_ids)){
$worker_scores [] = [
'id' => $item['id'],
'distance' => $item['distance'],
// 'star' => $item['star'],
'doing' => $worker_doing_map[$item['id']]['count'] ?? 0,
'status' => $worker_rate_map[$item['id']] ?? [
'arrive_rate' => 0,
'refuse_rate' => 0,
'trans_rate' => 0,
'good_rate' => 0,
'avg_time_diff' => 0,
],
];
}
}
$worker_score_map = [];
foreach ($worker_scores as $worker_score){
$worker_score_map [$worker_score['id']] = $this->scoreWorker($worker_score);
}
// 根据得分排序(从高到低)
arsort($worker_score_map); // 保持键名不变
// 取出第一个 key就是得分最高的 ID
return array_key_first($worker_score_map);
}
function scoreWorker($worker, $maxDistance = 10000, $requiredSkills = []) {
// 距离得分(越近分数越高)
$geoScore = max(0, 1 - ($worker['distance'] / $maxDistance)); // 范围 [0,1]
// 技能得分(假设这个人不匹配)
$skillScore = count($requiredSkills) ? 0 : 1;
// 当前状态得分(未完成订单越少越好)
$doing = $worker['doing'];
$statusScore = $doing >= 10 ? 0 : (10 - $doing) / 10;
// 历史表现得分(各项 0~1
$s = $worker['status'];
$historyScore = (
0.4 * (floatval($s['good_rate']) / 100) +
0.25 * (floatval($s['trans_rate']) / 100) +
0.2 * (floatval($s['arrive_rate']) / 100) +
0.1 * (1 - min(floatval($s['avg_time_diff']) / 10, 1)) + // 联系时间越短越好
0.05 * (1 - floatval($s['refuse_rate']) / 100) // 拒单率越低越好
);
// 综合得分(加权)
$totalScore =
0.3 * $geoScore +
0.2 * $skillScore +
0.2 * $statusScore +
0.3 * $historyScore;
return round($totalScore, 4);
}
private function getWorkerRate($worker_ids){
$filter = [
'start_time' => now()->modify('-31 days')->format('Y-m-d H:i:s'),
'end_timne'=> now()->format('Y-m-d H:i:s')
];
//派单表
$dispatchSubsql = $this->dispatchSubsql($filter);
//订单表
$orderSubsql = $this->oderSubsql($filter);
//评分表
$viewSubsql = $this->viewSubsql($filter);
$list = (new Worker())->alias('fa_worker')
->whereIn('id',$worker_ids)
->field([
'fa_worker.*',
'IFNULL(a.dispatch_count, 0) AS dispatch_count',
'IFNULL(a.get_count, 0) AS get_count',
'IFNULL(a.refuse_count, 0) AS refuse_count',
'IFNULL(a.arrive_count, 0) AS arrive_count',
'IFNULL(a.avg_time_diff, 0) AS avg_time_diff',
'IFNULL(b.finish_num, 0) AS finish_num',
'IFNULL(b.total, 0) AS total',
'IFNULL(b.performance, 0) AS performance',
'IFNULL(b.refund_total, 0) AS refund_total',
'IFNULL(b.refund_count, 0) AS refund_count',
'IFNULL(b.cost, 0) AS cost',
'IFNULL(c.good_count, 0) AS good_count'
])
->join([$dispatchSubsql => 'a'], 'fa_worker.id = a.worker_id', 'LEFT')
->join([$orderSubsql => 'b'], 'fa_worker.id = b.worker_id', 'LEFT')
->join([$viewSubsql => 'c'], 'fa_worker.id = c.worker_id', 'LEFT')
->select();
$this->_toList($list);
$out = [];
foreach ($list as $item){
$out [] = [
'worker_id' => $item->id,
'arrive_rate' => $item->arrive_rate,
'refuse_rate' => $item->refuse_rate,
'trans_rate' => $item->trans_rate,
'good_rate' => $item->good_rate,
'avg_time_diff' => $item->avg_time_diff,
];
}
return $out;
}
/**
* @return bool|Collection|PDOStatement|string
* @throws DbException
*/
public function viewSubsql($filter=[]){
//评分表
$viewSubsql = OrderReview::where('worker_star',5)->field(['worker_id','count(*) as good_count']);
if(!empty($filter['start_time'])){
$viewSubsql->where('create_time','>=',$filter['start_time']);
}
if(!empty($filter['end_time'])){
$viewSubsql->where('create_time','<=',$filter['start_time']);
}
return $viewSubsql->group('worker_id')->buildSql();
}
/**
* @return bool|PDOStatement|string|Collection
* @throws DbException
*/
public function dispatchSubsql($filter): Collection|bool|string|PDOStatement
{
$builder = new OrderDispatch();
$fields = [
'worker_id',
// 使用 IFNULL 确保结果为 null 时返回 0
"IFNULL(COUNT(*), 0) AS dispatch_count", //分配数
"IFNULL(COUNT(CASE WHEN status NOT IN (0, -10) THEN 1 END), 0) AS get_count", //接单数
//"COUNT(CASE WHEN status IN (60) THEN 1 END) AS finish_count", //完成数
"IFNULL(COUNT(CASE WHEN status NOT IN (-10) THEN 1 END), 0) AS refuse_count", //拒绝数
"IFNULL(COUNT(arrive_time), 0) AS arrive_count", //上门数
"IFNULL(AVG(CASE WHEN status = 60 AND arrive_time IS NOT NULL THEN UNIX_TIMESTAMP(arrive_time) - UNIX_TIMESTAMP(create_time) END), 0) AS avg_time_diff", //联系时效
];
$builder->field($fields);
if(!empty($filter['start_time'])){
$builder->where('create_time','>=',$filter['start_time']);
}
if(!empty($filter['end_time'])){
$builder->where('create_time','<=',$filter['start_time']);
}
$builder->group('worker_id');
return $builder->buildSql();
}
//图表统计
/**
* @throws DbException
*/
public function oderSubsql($filter = []): Collection|bool|string|PDOStatement
{
$orderValid = implode(',',(new Order())->tabStatus(Order::TAB_VALID));
//"COUNT(CASE WHEN status IN (".$orderValid.") THEN 1 END) AS ing_num",
$fields = [
'worker_id',
// 使用 IFNULL 确保结果为 null 时返回 0
"IFNULL(COUNT(CASE WHEN status = 60 THEN 1 END), 0) AS finish_num", //完成数
//"COUNT(CASE WHEN status IN (".$orderValid.") THEN 1 END) AS count_num", //总订单数 (排除取消 和草稿)
"IFNULL(SUM(CASE WHEN status = 60 THEN total END), 0) AS total", //成效额
"IFNULL(SUM(CASE WHEN status = 60 THEN performance END), 0) AS performance", //业绩
"IFNULL(SUM(CASE WHEN status = 60 THEN cost END), 0) AS cost", //成效额
// "SUM(CASE WHEN status = 60 THEN (cost + material_cost) END) AS cost_total", //总成本
"IFNULL(SUM(CASE WHEN status = 60 THEN (refund_amount + worker_refund_amount) END), 0) AS refund_total", //退款总数
"IFNULL(COUNT(CASE WHEN refund_amount > 0 OR worker_refund_amount > 0 THEN 1 END), 0) AS refund_count", //退款订单数量
//"AVG(CASE WHEN status > 10 THEN UNIX_TIMESTAMP(dispatch_time) - UNIX_TIMESTAMP(create_time) END) AS avg_time_diff", //派单时效
// "SUM(CASE WHEN status = 60 THEN (field1 + field2) END) AS performance",
];
$builder = (new Order())->field($fields);
if(!empty($filter['start_time'])){
$builder->where('create_time','>=',$filter['start_time']);
}
if(!empty($filter['end_time'])){
$builder->where('create_time','<=',$filter['start_time']);
}
//->where('dispatch_admin_id','>',0);
return $builder->group('worker_id')->buildSql();
}
private function _toList($list)
{
foreach ($list as &$datum){
//利润率 = 总业绩/总成效额
$datum->performance_rate = $this->_calc($datum->performance,$datum->total,4,true);
//转化率 = 完单数 / 总接单数
$datum->trans_rate = $this->_calc($datum->finish_num,$datum->get_count,4,true);
//变现值 = 总业绩 / 总接单数
$datum->cash_value = $this->_calc($datum->performance,$datum->get_count,2);
//拒单率 = 拒绝数 / 派单数
$datum->refuse_rate = $this->_calc($datum->refuse_count,$datum->dispatch_count,4,true);
//上门率 = 打卡数 / 接单数
$datum->arrive_rate = $this->_calc($datum->arrive_count,$datum->get_count,4,true);
//好评率
$datum->good_rate = $this->_calc($datum->good_count,$datum->finish_num,4,true);
$datum->avg_time_diff = $this->_calc($datum->avg_time_diff,3600,2);
}
}
private function _calc($a, $b, int $scale=4, bool $is_percent=false): int|string
{
$a = $a??0;
$b = $b??0;
$val = $b > 0 ? bcdiv($a,$b,$scale) : '0.00';
if($is_percent){
return bcmul($val,100,2);
}
return $val;
}
}

View File

@ -42,13 +42,12 @@ class Item extends Backend
{
$build = new Order();
$start = now()->modify('-14 days')->format('Y-m-d');
$start = now()->modify('-7 days')->format('Y-m-d');
$end_at = now()->format('Y-m-d 23:29:59');
$filter = json_decode(request()->get('filter','[]'),true);
if (!empty($filter['daterange'])) {
$arr = explode(' - ', $filter['daterange']);
$filter = request()->get('range','');
if (!empty($filter)){
$arr = explode(' - ', $filter);
if (trim($arr[0])) {
$start = trim($arr[0]);
}
@ -56,6 +55,14 @@ class Item extends Backend
$end_at = trim($arr[1]) . ' 23:29:59';
}
}
$area_id = request()->get('area_id');
if ($area_id) {
$area_id = $this->trimSpecialZeros($area_id);
$build->where('area_id', 'like', $area_id . '%');
}
$build->whereBetween('create_time', [$start, $end_at])
->field([
'item_title name', // 类型
@ -99,6 +106,21 @@ class Item extends Backend
}
function trimSpecialZeros($str) {
if (strlen($str) !== 6) {
return $str; // 非6位字符串直接返回
}
if (substr($str, -4) === "0000") {
return substr($str, 0, 2); // 去掉后4位
} elseif (substr($str, -2) === "00") {
return substr($str, 0, 4); // 去掉后2位
}
return $str; // 不符合条件则原样返回
}
public function chartData()
{
$build = new Order();
@ -159,16 +181,32 @@ class Item extends Backend
$refundRate[] = $total > 0 ? round($refund / $total * 100, 2) : 0;
$monetizedValue[] = $total / $count;
}
$result = [
'xAxis' => $xAxis,
'series' => [
['name' => '总业绩(¥)', 'type' => 'bar', 'yAxisIndex' => 0, 'data' => $totalPerformance],
['name' => '转化率(%)', 'type' => 'bar', 'yAxisIndex' => 1, 'data' => $conversionRate],
['name' => '利润率(%)', 'type' => 'bar', 'yAxisIndex' => 1, 'data' => $profitRate],
['name' => '退款率(%)', 'type' => 'bar', 'yAxisIndex' => 1, 'data' => $refundRate],
['name' => '变现值(¥)', 'type' => 'line', 'yAxisIndex' => 0, 'data' => $monetizedValue],
]
];
if ($totalPerformance){
$result = [
'xAxis' => $xAxis,
'series' => [
['name' => '总业绩(¥)', 'type' => 'bar', 'yAxisIndex' => 0, 'data' => $totalPerformance],
['name' => '转化率(%)', 'type' => 'bar', 'yAxisIndex' => 1, 'data' => $conversionRate],
['name' => '利润率(%)', 'type' => 'bar', 'yAxisIndex' => 1, 'data' => $profitRate],
['name' => '退款率(%)', 'type' => 'bar', 'yAxisIndex' => 1, 'data' => $refundRate],
['name' => '变现值(¥)', 'type' => 'line', 'yAxisIndex' => 0, 'data' => $monetizedValue],
]
];
}else{
$result = [
'xAxis' => ['无'],
'series' => [
['name' => '总业绩(¥)', 'type' => 'bar', 'yAxisIndex' => 0, 'data' => [0]],
['name' => '转化率(%)', 'type' => 'bar', 'yAxisIndex' => 1, 'data' => [0]],
['name' => '利润率(%)', 'type' => 'bar', 'yAxisIndex' => 1, 'data' => [0]],
['name' => '退款率(%)', 'type' => 'bar', 'yAxisIndex' => 1, 'data' => [0]],
['name' => '变现值(¥)', 'type' => 'line', 'yAxisIndex' => 0, 'data' => [0]],
]
];
}
return $result;
}

View File

@ -44,11 +44,14 @@
</div>
<div class="tab-pane fade" id="second">
<div id="chart-filter-table" style="margin-top:20px;margin-bottom: 30px;">
<div style="display: inline-block;width: 300px;position: relative">
<input class="form-control" data-toggle="city-picker" type="text" placeholder="地区" id="area-table">
<input type="text" class="form-control datetimerange" data-locale='{"format":"YYYY-MM-DD"}' placeholder="指定日期" name="filter[daterange]" id="daterange-table" autocomplete="off" style="width: 180px;">
<input style="display: none" type="text" id="area_id">
</div>
<div style="display: inline-block;width: 200px;position: relative">
<input type="text" class="form-control datetimerange" data-locale='{"format":"YYYY-MM-DD"}'
placeholder="指定日期" name="filter[daterange]" id="daterange-table" autocomplete="off" style="width: 180px;">
</div>
<!-- 查询按钮 -->
<button class="btn btn-default" id="filter-btn-table" style="margin-left: 15px;">查询</button>
</div>

View File

@ -18,7 +18,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form', 'echarts', 'echarts-t
// 表格2
var table2 = $("#table2");
table2.bootstrapTable({
url: 'statistics/item/list' + location.search,
url: 'statistics/item/list',
toolbar: '#toolbar1',
sortName: 'id',
search: false,
@ -71,6 +71,23 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form', 'echarts', 'echarts-t
$('#filter-btn').on('click', function () {
Controller.api.getChartData();
});
$('#filter-btn-table').on('click', function () {
const area_id = $('#area_id').val();
const range = $('#daterange-table').val();
let data = '';
if (area_id !== ''){
data += 'area_id=' + area_id+'&';
}
if (area_id !== ''){
data += 'range=' + range;
}
// data = encodeURIComponent(data);
$("#table2").bootstrapTable('refresh',{
url:'statistics/item/list?' + data,
});
});
},
add: function () {
@ -158,6 +175,14 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form', 'echarts', 'echarts-t
},
areapicker: function () {
$("#area-table").citypicker();
$("#area-table").on("cp:updated", function() {
var citypicker = $(this).data("citypicker");
var code = citypicker.getCode("district") || citypicker.getCode("city") || citypicker.getCode("province");
// table.bootstrapTable('refresh',{query: {area_code: code}});
$('#area_id').val(code);
});
},
getChartData: function () {
// 获取日期范围值