@@ -0,0 +1,842 @@
< ? php
declare ( strict_types = 1 );
namespace app\mcp\service ;
use app\adminapi\logic\stats\YejiStatsLogic ;
use app\common\service\DataScope\DataScopeService ;
use think\facade\Db ;
/**
* 业绩快捷工具:医助排行、医生排行、部门业绩看板、业绩趋势。
* - 一次调用给出排名、合计与口径说明,并附带行知可直接渲染的统计图(```chart 代码块);
* - 排行与看板以调用账号身份执行后台原有统计接口(业绩看板·医助排行榜 / 医生统计 / 甄养堂诊金),
* 口径、数据范围与后台页面一致,数字可直接和后台对账;
* - 趋势用与排行榜相同口径的一条按日分组 SQL,在只读事务里执行,避免模型逐日、逐人循环调用明细接口。
*
* 图表格式(行知 RichText 渲染 ```chart 代码块):
* {"type":"bar|column|line","title":"…","subtitle":"…","unit":"元","labels":["…"],"series":[{"name":"…","data":[1,2]}],"note":"…"}
* bar=横向条形(排行),column=竖向柱形(少量时间段),line=折线(趋势);同一张图只放同一单位的数据。
*/
class PerfTools
{
public const ASSISTANTS = 'stats.yejiStats/leaderboard' ;
public const DOCTORS = 'stats.doctorDailyStats/overview' ;
public const DEPTS = 'stats.yejiStats/overview' ;
/** 指标 => [后台列名, 单位];名称与后台“业绩看板”页面列名一致,方便对账 */
private const ASSISTANT_METRICS = [
'fee_amount' => [ '诊金' , '元' ],
'deal_order_count' => [ '接诊诊单' , '单' ],
'consult_count' => [ '接诊单数' , '单' ],
'appointment_count' => [ '预约诊单' , '个' ],
'assign_count' => [ '被指派数' , '个' ],
'lead_count' => [ '进线' , '个' ],
'consult_rate' => [ '接诊率' , '元/进线' ],
];
private const DOCTOR_METRICS = [
'deal_amount' => [ '成交金额' , '元' ],
'deal_order_count' => [ '接诊诊单' , '单' ],
'avg_deal_amount' => [ '客单价' , '元' ],
'appointment_total' => [ '总挂号' , '个' ],
'appointment_completed' => [ '挂号完成' , '个' ],
'appointment_conversion_rate' => [ '挂号率' , '%' ],
'system_prescription_count' => [ '系统开方' , '张' ],
'manual_prescription_count' => [ '手动开方' , '张' ],
];
private const DEPT_METRICS = [
'performance_amount' => [ '合计业绩' , '元' ],
'deal_order_count' => [ '接诊诊单' , '单' ],
'consult_count' => [ '接诊单数' , '单' ],
'appointment_booked_count' => [ '预约诊单' , '个' ],
'lead_count' => [ '进线数据' , '个' ],
'assign_count' => [ '被指派数' , '个' ],
'cost_amount' => [ '投放成本' , '元' ],
];
private const PERIODS = [
'today' => '今天' , 'yesterday' => '昨天' , 'this_week' => '本周' , 'last_week' => '上周' ,
'this_month' => '本月' , 'last_month' => '上月' , 'last_7_days' => '最近7天' , 'last_30_days' => '最近30天' ,
];
private const TOOLS = [ 'zyt_perf_assistants' , 'zyt_perf_doctors' , 'zyt_stats_performance' , 'zyt_perf_trend' ];
public static function handles ( string $name ) : bool
{
return in_array ( $name , self :: TOOLS , true );
}
/** tools/list:只列出账号有权限的工具 */
public static function definitions ( Identity $identity ) : array
{
$canAssistants = self :: usable ( $identity , self :: ASSISTANTS );
$canDoctors = self :: usable ( $identity , self :: DOCTORS );
$period = [ 'type' => 'string' , 'enum' => array_keys ( self :: PERIODS ),
'description' => '快捷时间:today 今天、yesterday 昨天、this_week 本周、last_week 上周、this_month 本月、last_month 上月、last_7_days 最近7天、last_30_days 最近30天。与 start_date/end_date 二选一;都不传默认本月' ];
$date = [ 'type' => 'string' , 'description' => '日期 YYYY-MM-DD' ];
$dept = [ 'type' => 'string' , 'description' => '只看某些部门:部门名称(如“一中心”)或部门ID,多个用逗号分隔(可选)' ];
$channel = [ 'type' => 'string' , 'description' => '渠道编码(可选,取值见 stats.yejiStats/channelOptions) ' ];
$top = [ 'type' => 'integer' , 'minimum' => 1 , 'maximum' => 50 , 'description' => '返回前几名,默认 15' ];
$chartNote = '结果里的 ```chart 代码块请原样放进回答,行知会显示为统计图。' ;
$tools = [];
if ( $canAssistants ) {
$tools [] = Tools :: tool ( 'zyt_perf_assistants' ,
'医助业绩排行(与后台“业绩看板·医助排行榜”同口径):一次返回全部可见医助的诊金、接诊诊单、接诊单数、预约诊单、被指派数、进线、接诊率,含排名、合计和部门小计。'
. '问“医助业绩/排行/谁业绩最好/某位医助本月业绩”时直接用它,不要逐人、逐天调用明细接口。' . $chartNote , [
'period' => $period , 'start_date' => $date , 'end_date' => $date ,
'sort_by' => [ 'type' => 'string' , 'enum' => array_keys ( self :: ASSISTANT_METRICS ), 'description' => '排名依据:' . self :: metricText ( self :: ASSISTANT_METRICS ) . ';默认 fee_amount' ],
'top' => $top ,
'name' => [ 'type' => 'string' , 'description' => '只看姓名包含该文字的医助(可选;名次仍是在全部可见医助中的名次)' ],
'assistant_id' => [ 'type' => 'integer' , 'description' => '只看某位医助(后台账号ID,可选)' ],
'dept' => $dept , 'channel_code' => $channel ,
]);
}
if ( $canDoctors ) {
$tools [] = Tools :: tool ( 'zyt_perf_doctors' ,
'医生业绩排行(与后台“业绩看板·医生统计”同口径):一次返回可见医生的成交金额、接诊诊单、客单价、总挂号、挂号完成/过号/取消、挂号率、系统/手动开方数,含排名和合计。'
. '问“医生业绩/哪个医生成交最多/某医生本月业绩”时直接用它。' . $chartNote , [
'period' => $period , 'start_date' => $date , 'end_date' => $date ,
'sort_by' => [ 'type' => 'string' , 'enum' => array_keys ( self :: DOCTOR_METRICS ), 'description' => '排名依据:' . self :: metricText ( self :: DOCTOR_METRICS ) . ';默认 deal_amount' ],
'top' => $top ,
'name' => [ 'type' => 'string' , 'description' => '只看姓名包含该文字的医生(可选)' ],
'doctor_id' => [ 'type' => 'integer' , 'description' => '只看某位医生(后台账号ID,可选)' ],
'dept' => [ 'type' => 'string' , 'description' => '只统计这些部门的医助经手的数据:部门名称或ID,多个用逗号分隔(可选)' ],
'channel_code' => $channel ,
]);
}
if ( self :: usable ( $identity , self :: DEPTS )) {
$tools [] = Tools :: tool ( 'zyt_stats_performance' ,
'部门业绩看板(与后台“业绩看板·甄养堂诊金”同口径):一段时间内各部门的合计业绩、接诊诊单、接诊单数、预约诊单、进线、被指派、投放成本、ROI 及合计。问“各部门/各中心业绩”时用它。' . $chartNote , [
'period' => $period , 'start_date' => $date , 'end_date' => $date ,
'sort_by' => [ 'type' => 'string' , 'enum' => array_keys ( self :: DEPT_METRICS ), 'description' => '图表按哪个指标画:' . self :: metricText ( self :: DEPT_METRICS ) . ';默认 performance_amount' ],
'dept' => $dept ,
'dept_ids' => [ 'type' => 'string' , 'description' => '部门ID,多个逗号分隔(可选,与 dept 相同作用)' ],
'channel_code' => $channel ,
]);
}
if ( $canAssistants || $canDoctors ) {
$by = array_keys ( array_filter ([ 'assistant' => $canAssistants , 'doctor' => $canDoctors ]));
$tools [] = Tools :: tool ( 'zyt_perf_trend' ,
'业绩走势:按日/周/月给出业绩金额和接诊诊单数的变化,可对比最多 4 位医助或医生。问“本月每天业绩走势”“张三和李四这个月业绩对比”时用它,一次调用即可,不要逐天查询。'
. '医助口径同医助排行榜诊金(订单创建人),医生口径同医生统计成交金额(开方医生)。' . $chartNote , [
'period' => $period , 'start_date' => $date , 'end_date' => $date ,
'by' => [ 'type' => 'string' , 'enum' => $by , 'description' => 'assistant 按医助(默认)/ doctor 按医生' ],
'names' => [ 'type' => 'string' , 'description' => '要对比的人的姓名,多个用逗号分隔,最多 4 个(可选;不传=可见范围内全部合计)' ],
'ids' => [ 'type' => 'array' , 'items' => [ 'type' => 'integer' ], 'maxItems' => 4 , 'description' => '要对比的人的后台账号ID(可选,与 names 二选一)' ],
'granularity' => [ 'type' => 'string' , 'enum' => [ 'auto' , 'day' , 'week' , 'month' ], 'description' => '时间粒度,默认 auto(45 天内按日、190 天内按周、更长按月)' ],
'metric' => [ 'type' => 'string' , 'enum' => [ 'amount' , 'orders' ], 'description' => '图表画哪个:amount 金额(默认)/ orders 接诊诊单数' ],
]);
}
return $tools ;
}
/** tools/call */
public static function call ( Identity $identity , string $name , array $args , array & $audit ) : array
{
return match ( $name ) {
'zyt_perf_assistants' => self :: assistants ( $identity , $args , $audit ),
'zyt_perf_doctors' => self :: doctors ( $identity , $args , $audit ),
'zyt_stats_performance' => self :: depts ( $identity , $args , $audit ),
'zyt_perf_trend' => self :: trend ( $identity , $args , $audit ),
};
}
// ───────────────────────────── 医助排行 ─────────────────────────────
private static function assistants ( Identity $identity , array $args , array & $audit ) : array
{
$resource = Tools :: resource ( $identity , self :: ASSISTANTS );
$audit [ 'resource' ] = $resource [ 'key' ];
[ $start , $end , $periodName ] = self :: range ( $args );
$sortBy = self :: metric ( $args [ 'sort_by' ] ? ? 'fee_amount' , self :: ASSISTANT_METRICS );
$top = self :: top ( $args );
$deptIds = self :: deptIds ( $identity , $args );
$params = self :: filters ( $resource , $start , $end , $deptIds , self :: channel ( $args ));
$data = self :: run ( $identity , $resource , $params , $top );
$rows = [];
$depts = [];
foreach (( array ) ( $data [ 'leaderboards' ] ? ? []) as $board ) {
$deptName = ( string ) ( $board [ 'dept_name' ] ? ? '' );
$sub = [ 'dept' => $deptName , 'dept_id' => ( int ) ( $board [ 'dept_id' ] ? ? 0 ), 'assistants' => 0 , 'fee_amount' => 0.0 , 'deal_order_count' => 0 , 'consult_count' => 0 , 'lead_count' => 0 ];
foreach (( array ) ( $board [ 'rows' ] ? ? []) as $r ) {
$row = [
'admin_id' => ( int ) ( $r [ 'admin_id' ] ? ? 0 ),
'name' => ( string ) ( $r [ 'name' ] ? ? '' ),
'dept' => $deptName ,
'fee_amount' => round (( float ) ( $r [ 'fee_amount' ] ? ? 0 ), 2 ),
'deal_order_count' => ( int ) ( $r [ 'deal_order_count' ] ? ? 0 ),
'consult_count' => ( int ) ( $r [ 'consult_count' ] ? ? 0 ),
'appointment_count' => ( int ) ( $r [ 'appointment_count' ] ? ? 0 ),
'assign_count' => ( int ) ( $r [ 'assign_count' ] ? ? 0 ),
'lead_count' => ( int ) ( $r [ 'lead_count' ] ? ? 0 ),
'consult_rate' => round (( float ) ( $r [ 'consult_rate' ] ? ? 0 ), 1 ),
];
if ( isset ( $r [ 'revisit_count' ])) {
$row [ 'revisit_count' ] = ( int ) $r [ 'revisit_count' ];
}
$rows [] = $row ;
$sub [ 'assistants' ] ++ ;
$sub [ 'fee_amount' ] += $row [ 'fee_amount' ];
$sub [ 'deal_order_count' ] += $row [ 'deal_order_count' ];
$sub [ 'consult_count' ] += $row [ 'consult_count' ];
$sub [ 'lead_count' ] += $row [ 'lead_count' ];
}
$sub [ 'fee_amount' ] = round ( $sub [ 'fee_amount' ], 2 );
$depts [] = $sub ;
}
$totals = [ 'fee_amount' => 0.0 , 'deal_order_count' => 0 , 'consult_count' => 0 , 'appointment_count' => 0 , 'assign_count' => 0 , 'lead_count' => 0 ];
foreach ( $rows as $row ) {
foreach ( $totals as $k => $_ ) {
$totals [ $k ] += $row [ $k ];
}
}
$totals [ 'fee_amount' ] = round ( $totals [ 'fee_amount' ], 2 );
$totals [ 'consult_rate' ] = $totals [ 'lead_count' ] > 0 ? round ( $totals [ 'fee_amount' ] / $totals [ 'lead_count' ], 1 ) : 0.0 ;
$active = count ( array_filter ( $rows , static fn ( $r ) => $r [ 'fee_amount' ] != 0 || $r [ 'deal_order_count' ] > 0 ));
$ranked = self :: rank ( $rows , $sortBy , 'fee_amount' );
$matched = self :: filterPeople ( $ranked , $args , 'assistant_id' );
$shown = array_slice ( $matched , 0 , $top );
$filtered = $matched !== $ranked ;
[ $metricName , $unit ] = self :: ASSISTANT_METRICS [ $sortBy ];
$scope = self :: scopeText ( $start , $end , $periodName , $deptIds !== null ? $depts : null , ( string ) ( $data [ 'channel_name' ] ? ? '' ));
$summary = sprintf ( '医助业绩(%s):可见医助 %d 人,其中 %d 人有业绩;合计诊金 %s 元、接诊诊单 %s、接诊单数 %s、预约诊单 %s、被指派 %s、进线 %s,接诊率 %s 元/进线。' ,
$scope , count ( $rows ), $active , self :: num ( $totals [ 'fee_amount' ]), self :: num ( $totals [ 'deal_order_count' ]), self :: num ( $totals [ 'consult_count' ]),
self :: num ( $totals [ 'appointment_count' ]), self :: num ( $totals [ 'assign_count' ]), self :: num ( $totals [ 'lead_count' ]), self :: num ( $totals [ 'consult_rate' ], 1 ));
if ( $rows === []) {
$summary .= '当前账号在这个范围内没有可见的医助。' ;
} elseif ( $filtered ) {
$summary .= $shown === [] ? '没有找到匹配的医助(只在当前账号可见的医助中查找)。' : sprintf ( '匹配到 %d 位医助,名次为在全部可见医助中按%s的名次。' , count ( $matched ), $metricName );
} else {
$summary .= sprintf ( '按%s排名,下面是前 %d 名。' , $metricName , count ( $shown ));
}
$chart = $filtered && count ( $shown ) < 2 ? null : self :: rankingChart ( $shown , $sortBy , self :: ASSISTANT_METRICS [ $sortBy ],
'医助' . $metricName . '排行' , $scope . ( $filtered ? '' : ' · 前 ' . count ( $shown ) . ' 名' ), '口径同后台业绩看板·医助排行榜' );
return self :: respond ( $identity , $audit , $summary , [
'period' => [ 'start_date' => $start , 'end_date' => $end ],
'sort_by' => $sortBy ,
'assistants' => count ( $rows ),
'active_assistants' => $active ,
'totals' => $totals ,
'departments' => $depts ,
'rows' => $shown ,
'columns' => self :: columns ( self :: ASSISTANT_METRICS ),
'note' => ( string ) ( $data [ 'range_note' ] ? ? '' ),
], $chart );
}
// ───────────────────────────── 医生排行 ─────────────────────────────
private static function doctors ( Identity $identity , array $args , array & $audit ) : array
{
$resource = Tools :: resource ( $identity , self :: DOCTORS );
$audit [ 'resource' ] = $resource [ 'key' ];
[ $start , $end , $periodName ] = self :: range ( $args );
$sortBy = self :: metric ( $args [ 'sort_by' ] ? ? 'deal_amount' , self :: DOCTOR_METRICS );
$top = self :: top ( $args );
$deptIds = self :: deptIds ( $identity , $args );
$params = self :: filters ( $resource , $start , $end , $deptIds , self :: channel ( $args ));
if (( int ) ( $args [ 'doctor_id' ] ? ? 0 ) > 0 ) {
$params [ 'doctor_id' ] = ( int ) $args [ 'doctor_id' ];
}
$data = self :: run ( $identity , $resource , $params , $top );
$rows = [];
foreach (( array ) ( $data [ 'rows' ] ? ? []) as $r ) {
$rows [] = [
'admin_id' => ( int ) ( $r [ 'admin_id' ] ? ? 0 ),
'name' => ( string ) ( $r [ 'doctor_name' ] ? ? '' ),
'deal_amount' => round (( float ) ( $r [ 'deal_amount' ] ? ? 0 ), 2 ),
'deal_order_count' => ( int ) ( $r [ 'deal_order_count' ] ? ? 0 ),
'avg_deal_amount' => isset ( $r [ 'avg_deal_amount' ]) ? round (( float ) $r [ 'avg_deal_amount' ], 2 ) : null ,
'appointment_total' => ( int ) ( $r [ 'appointment_total' ] ? ? 0 ),
'appointment_completed' => ( int ) ( $r [ 'appointment_completed' ] ? ? 0 ),
'appointment_missed' => ( int ) ( $r [ 'appointment_missed' ] ? ? 0 ),
'appointment_cancelled' => ( int ) ( $r [ 'appointment_cancelled' ] ? ? 0 ),
'appointment_conversion_rate' => isset ( $r [ 'appointment_conversion_rate' ]) ? round (( float ) $r [ 'appointment_conversion_rate' ], 2 ) : null ,
'system_prescription_count' => ( int ) ( $r [ 'system_prescription_count' ] ? ? 0 ),
'manual_prescription_count' => ( int ) ( $r [ 'manual_prescription_count' ] ? ? 0 ),
];
}
$total = ( array ) ( $data [ 'total' ] ? ? []);
$active = count ( array_filter ( $rows , static fn ( $r ) => $r [ 'deal_amount' ] != 0 || $r [ 'deal_order_count' ] > 0 ));
$ranked = self :: rank ( $rows , $sortBy , 'deal_amount' );
$matched = self :: filterPeople ( $ranked , $args , 'doctor_id' );
$shown = array_slice ( $matched , 0 , $top );
$filtered = $matched !== $ranked ;
[ $metricName ] = self :: DOCTOR_METRICS [ $sortBy ];
$scope = self :: scopeText ( $start , $end , $periodName , $deptIds !== null ? [[ 'dept' => self :: deptNames ( $identity , $deptIds )]] : null , '' );
$summary = sprintf ( '医生业绩(%s):可见医生 %d 位,其中 %d 位有成交;合计成交金额 %s 元、接诊诊单 %s、客单价 %s 元,总挂号 %s、挂号完成 %s、过号 %s、取消 %s,挂号率 %s%%,系统开方 %s、手动开方 %s。' ,
$scope , count ( $rows ), $active , self :: num ( $total [ 'deal_amount' ] ? ? 0 ), self :: num ( $total [ 'deal_order_count' ] ? ? 0 ), self :: num ( $total [ 'avg_deal_amount' ] ? ? null ),
self :: num ( $total [ 'appointment_total' ] ? ? 0 ), self :: num ( $total [ 'appointment_completed' ] ? ? 0 ), self :: num ( $total [ 'appointment_missed' ] ? ? 0 ),
self :: num ( $total [ 'appointment_cancelled' ] ? ? 0 ), self :: num ( $total [ 'appointment_conversion_rate' ] ? ? null ), self :: num ( $total [ 'system_prescription_count' ] ? ? 0 ),
self :: num ( $total [ 'manual_prescription_count' ] ? ? 0 ));
if ( $rows === []) {
$summary .= '当前账号在这个范围内没有可见的医生(医生统计只统计账号数据范围内的医生)。' ;
} elseif ( $filtered ) {
$summary .= $shown === [] ? '没有找到匹配的医生(只在当前账号可见的医生中查找)。' : sprintf ( '匹配到 %d 位医生,名次为在全部可见医生中按%s的名次。' , count ( $matched ), $metricName );
} else {
$summary .= sprintf ( '按%s排名,下面是前 %d 名。' , $metricName , count ( $shown ));
}
$chart = $filtered && count ( $shown ) < 2 ? null : self :: rankingChart ( $shown , $sortBy , self :: DOCTOR_METRICS [ $sortBy ],
'医生' . $metricName . '排行' , $scope . ( $filtered ? '' : ' · 前 ' . count ( $shown ) . ' 名' ), '口径同后台业绩看板·医生统计' );
return self :: respond ( $identity , $audit , $summary , [
'period' => [ 'start_date' => $start , 'end_date' => $end ],
'sort_by' => $sortBy ,
'doctors' => count ( $rows ),
'active_doctors' => $active ,
'totals' => $total ,
'rows' => $shown ,
'columns' => self :: columns ( self :: DOCTOR_METRICS ) + [ 'appointment_missed' => '过号' , 'appointment_cancelled' => '取消' ],
'note' => '成交金额=业务订单金额,按订单创建时间,剔除已取消/拒收/退款及发生过退款的订单,归属开方医生;接诊诊单=这些订单条数;挂号按预约日期;挂号率=接诊诊单÷总挂号×100。'
. '只统计当前账号数据范围内的医生;按部门筛选时只统计该部门医助经手的挂号、订单和处方,并隐藏全为 0 的医生。' ,
], $chart );
}
// ───────────────────────────── 部门业绩看板 ─────────────────────────────
private static function depts ( Identity $identity , array $args , array & $audit ) : array
{
$resource = Tools :: resource ( $identity , self :: DEPTS );
$audit [ 'resource' ] = $resource [ 'key' ];
[ $start , $end , $periodName ] = self :: range ( $args );
$sortBy = self :: metric ( $args [ 'sort_by' ] ? ? 'performance_amount' , self :: DEPT_METRICS );
$deptIds = self :: deptIds ( $identity , $args );
$params = self :: filters ( $resource , $start , $end , $deptIds , self :: channel ( $args ));
$data = self :: run ( $identity , $resource , $params , 20 );
$channelName = ( string ) ( $data [ 'channel_name' ] ? ? '' );
$rows = [];
foreach (( array ) ( $data [ 'rows' ] ? ? []) as $r ) {
$row = [
'dept_id' => ( int ) ( $r [ 'dept_id' ] ? ? 0 ),
'name' => ( string ) ( $r [ 'dept_name' ] ? ? '' ),
'performance_amount' => round (( float ) ( $r [ 'performance_amount' ] ? ? 0 ), 2 ),
'deal_order_count' => ( int ) ( $r [ 'deal_order_count' ] ? ? 0 ),
'consult_count' => ( int ) ( $r [ 'consult_count' ] ? ? 0 ),
'appointment_booked_count' => ( int ) ( $r [ 'appointment_booked_count' ] ? ? 0 ),
'lead_count' => ( int ) ( $r [ 'lead_count' ] ? ? 0 ),
'assign_count' => ( int ) ( $r [ 'assign_count' ] ? ? 0 ),
'revisit_count' => ( int ) ( $r [ 'revisit_count' ] ? ? 0 ),
'cost_amount' => round (( float ) ( $r [ 'cost_amount' ] ? ? 0 ), 2 ),
'avg_price' => isset ( $r [ 'avg_price' ]) ? ( float ) $r [ 'avg_price' ] : null ,
'roi' => isset ( $r [ 'roi' ]) ? ( float ) $r [ 'roi' ] : null ,
];
if ( $channelName !== '' ) {
$row [ 'channel_performance_amount' ] = round (( float ) ( $r [ 'completed_performance_amount' ] ? ? 0 ), 2 );
}
$rows [] = $row ;
}
$total = ( array ) ( $data [ 'total' ] ? ? []);
unset ( $total [ 'revisit_slots' ]);
[ $metricName ] = self :: DEPT_METRICS [ $sortBy ];
$scope = self :: scopeText ( $start , $end , $periodName , null , $channelName );
$summary = sprintf ( '部门业绩看板(%s):%d 个部门行,合计业绩 %s 元、接诊诊单 %s、接诊单数 %s、预约诊单 %s、进线 %s、被指派 %s,投放成本 %s 元,ROI %s。' ,
$scope , count ( $rows ), self :: num ( $total [ 'performance_amount' ] ? ? 0 ), self :: num ( $total [ 'deal_order_count' ] ? ? 0 ), self :: num ( $total [ 'consult_count' ] ? ? 0 ),
self :: num ( $total [ 'appointment_booked_count' ] ? ? 0 ), self :: num ( $total [ 'lead_count' ] ? ? 0 ), self :: num ( $total [ 'assign_count' ] ? ? 0 ),
self :: num ( $total [ 'cost_amount' ] ? ? 0 ), self :: num ( $total [ 'roi' ] ? ? null , 1 ));
if ( $channelName !== '' ) {
$summary .= '渠道业绩合计 ' . self :: num ( $total [ 'completed_performance_amount' ] ? ? 0 ) . ' 元。' ;
}
$chart = self :: rankingChart ( self :: rank ( $rows , $sortBy , 'performance_amount' ), $sortBy , self :: DEPT_METRICS [ $sortBy ], '各部门' . $metricName , $scope , '口径同后台业绩看板·甄养堂诊金' );
return self :: respond ( $identity , $audit , $summary , [
'period' => [ 'start_date' => $start , 'end_date' => $end ],
'channel' => $channelName ,
'totals' => $total ,
'rows' => $rows ,
'columns' => self :: columns ( self :: DEPT_METRICS ) + [ 'revisit_count' => '复诊合计' , 'avg_price' => '客单价' , 'roi' => 'ROI' , 'channel_performance_amount' => '渠道业绩' ],
'note' => ( string ) ( $data [ 'channel_filter_note' ] ? ? '' ),
], $chart );
}
// ───────────────────────────── 业绩趋势 ─────────────────────────────
private static function trend ( Identity $identity , array $args , array & $audit ) : array
{
$by = ( string ) ( $args [ 'by' ] ? ? '' );
if ( $by === '' ) {
$by = ! self :: usable ( $identity , self :: ASSISTANTS ) && self :: usable ( $identity , self :: DOCTORS ) ? 'doctor' : 'assistant' ;
}
if ( ! in_array ( $by , [ 'assistant' , 'doctor' ], true )) {
throw new McpException ( 'by 只支持 assistant(医助)或 doctor(医生)' , 'invalid' );
}
$resource = Tools :: resource ( $identity , $by === 'doctor' ? self :: DOCTORS : self :: ASSISTANTS );
$audit [ 'resource' ] = $resource [ 'key' ] . '#trend' ;
[ $start , $end , $periodName ] = self :: range ( $args );
$metric = ( string ) ( $args [ 'metric' ] ? ? 'amount' );
if ( ! in_array ( $metric , [ 'amount' , 'orders' ], true )) {
throw new McpException ( 'metric 只支持 amount(金额)或 orders(接诊诊单数)' , 'invalid' );
}
$startTs = ( int ) strtotime ( $start . ' 00:00:00' );
$endTs = ( int ) strtotime ( $end . ' 23:59:59' );
$days = ( int ) round (( $endTs + 1 - $startTs ) / 86400 );
$granularity = self :: granularity (( string ) ( $args [ 'granularity' ] ? ? 'auto' ), $days );
Tools :: assertQuota ( $identity , 1 );
[ $people , $series , $poolName ] = Dispatcher :: readOnly ( static function () use ( $identity , $by , $args , $startTs , $endTs ) {
$visible = self :: visibleAdminIds ( $identity );
$pool = $by === 'doctor' ? self :: doctorIds ( $visible ) : $visible ;
$people = self :: resolvePeople ( $by , $args , $pool );
// 不指定人时画可见范围的合计;范围只有一个人(如医助看自己)时直接用他的名字
$poolName = $by === 'doctor' ? '全部医生' : '全部下单人' ;
if ( ! $people && $pool !== null && count ( $pool ) === 1 ) {
$poolName = ( string ) ( Db :: name ( 'admin' ) -> where ( 'id' , $pool [ 0 ]) -> value ( 'name' ) ? : $poolName );
}
return [ $people , self :: trendQuery ( $by , $startTs , $endTs , $people , $pool ), $poolName ];
}, 30 );
// 按日结果归并到日/周/月时间段,没有订单的时间段补 0
$buckets = [];
$dayToBucket = [];
for ( $i = 0 ; $i < $days ; $i ++ ) {
$ts = ( int ) strtotime ( '+' . $i . ' day' , $startTs );
$key = match ( $granularity ) {
'day' => date ( 'Y-m-d' , $ts ),
'week' => date ( 'Y-m-d' , ( int ) strtotime ( '-' . (( int ) date ( 'N' , $ts ) - 1 ) . ' day' , $ts )),
default => date ( 'Y-m' , $ts ),
};
$dayToBucket [ $i ] = $key ;
$buckets [ $key ] ? ? = [ 'from' => date ( 'Y-m-d' , $ts )];
$buckets [ $key ][ 'to' ] = date ( 'Y-m-d' , $ts );
}
$keys = array_keys ( $buckets );
$index = array_flip ( $keys );
$who = $people ? : [ 0 => $poolName ];
$out = [];
foreach ( $who as $id => $name ) {
$out [ $id ] = [ 'name' => $name , 'admin_id' => $id ? : null , 'amount' => array_fill ( 0 , count ( $keys ), 0.0 ), 'orders' => array_fill ( 0 , count ( $keys ), 0 )];
}
foreach ( $series as $row ) {
$id = $people ? ( int ) $row [ 'pid' ] : 0 ;
$day = ( int ) $row [ 'd' ];
if ( ! isset ( $out [ $id ], $dayToBucket [ $day ])) {
continue ;
}
$pos = $index [ $dayToBucket [ $day ]];
$out [ $id ][ 'amount' ][ $pos ] += ( float ) $row [ 'amount' ];
$out [ $id ][ 'orders' ][ $pos ] += ( int ) $row [ 'orders' ];
}
$labels = array_map ( static fn ( $k ) => self :: bucketLabel ( $granularity , $buckets [ $k ], $start , $end ), $keys );
$seriesOut = [];
foreach ( $out as $s ) {
$s [ 'amount' ] = array_map ( static fn ( $v ) => round ( $v , 2 ), $s [ 'amount' ]);
$s [ 'total_amount' ] = round ( array_sum ( $s [ 'amount' ]), 2 );
$s [ 'total_orders' ] = array_sum ( $s [ 'orders' ]);
$peak = $s [ 'amount' ] ? array_keys ( $s [ 'amount' ], max ( $s [ 'amount' ]))[ 0 ] : null ;
$s [ 'peak' ] = $peak !== null && $s [ 'amount' ][ $peak ] > 0 ? [ 'period' => $labels [ $peak ], 'amount' => $s [ 'amount' ][ $peak ]] : null ;
$seriesOut [] = $s ;
}
$amountName = $by === 'doctor' ? '成交金额' : '诊金' ;
$gText = [ 'day' => '按日' , 'week' => '按周' , 'month' => '按月' ][ $granularity ];
$scope = self :: scopeText ( $start , $end , $periodName , null , '' ) . ', ' . $gText ;
$parts = [];
foreach ( $seriesOut as $s ) {
$parts [] = sprintf ( '%s 合计%s %s 元、接诊诊单 %s%s' , $s [ 'name' ], $amountName , self :: num ( $s [ 'total_amount' ]), self :: num ( $s [ 'total_orders' ]),
$s [ 'peak' ] ? ',最高在 ' . $s [ 'peak' ][ 'period' ] . '( ' . self :: num ( $s [ 'peak' ][ 'amount' ]) . ' 元)' : '' );
}
$summary = ( $by === 'doctor' ? '医生' : '医助' ) . '业绩走势(' . $scope . '):' . implode ( '; ' , $parts ) . '。' ;
$unit = $metric === 'amount' ? '元' : '单' ;
$chart = self :: chart ( count ( $keys ) >= 3 ? 'line' : 'column' ,
( count ( $seriesOut ) > 1 ? '业绩走势对比 · ' : $seriesOut [ 0 ][ 'name' ] . ' · ' ) . ( $metric === 'amount' ? $amountName : '接诊诊单' ),
$scope , $unit , $labels ,
array_map ( static fn ( $s ) => [ 'name' => $s [ 'name' ], 'data' => $s [ $metric === 'amount' ? 'amount' : 'orders' ]], $seriesOut ),
$by === 'doctor' ? '口径同医生统计成交金额(开方医生)' : '口径同医助排行榜诊金(订单创建人)' );
return self :: respond ( $identity , $audit , $summary , [
'period' => [ 'start_date' => $start , 'end_date' => $end ],
'by' => $by ,
'granularity' => $granularity ,
'periods' => $labels ,
'series' => $seriesOut ,
'note' => $by === 'doctor'
? '金额=业务订单金额,按订单创建时间,剔除已取消/拒收/退款及发生过退款的订单,归属开方医生(与医生统计一致);只含当前账号数据范围内的医生。'
: '金额=业务订单金额,按订单创建时间,剔除已取消/拒收/退款,归属订单创建人(与医助排行榜诊金一致);不指定人时为当前账号数据范围内全部下单人的合计。' ,
], $chart , count ( $seriesOut ));
}
/** 同口径的按日聚合:医助=订单创建人(排行榜诊金),医生=开方医生(医生统计成交金额) */
private static function trendQuery ( string $by , int $startTs , int $endTs , array $people , ? array $pool ) : array
{
$day = 'FLOOR((o.create_time - ' . $startTs . ') / 86400)' ;
$query = Db :: name ( 'tcm_prescription_order' ) -> alias ( 'o' )
-> whereNull ( 'o.delete_time' )
-> where ( 'o.create_time' , 'between' , [ $startTs , $endTs ])
-> where ( 'o.diagnosis_id' , '>' , 0 );
if ( $by === 'doctor' ) {
$owner = 'rx.creator_id' ;
$query -> join ( 'tcm_prescription rx' , 'rx.id = o.prescription_id AND rx.delete_time IS NULL' , 'INNER' );
YejiStatsLogic :: applyPrescriptionOrderEffectiveAmountQuery ( $query , 'o' );
} else {
$owner = 'o.creator_id' ;
$query -> join ( 'tcm_diagnosis dg' , 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL' , 'INNER' ) -> where ( 'o.creator_id' , '>' , 0 );
YejiStatsLogic :: applyPrescriptionOrderNotCancelledForPerformanceQuery ( $query , 'o' );
}
$ids = $people ? array_keys ( $people ) : $pool ;
if ( $ids !== null ) {
$query -> whereIn ( $owner , $ids ? : [ 0 ]);
}
$fields = [ Db :: raw ( $day . ' AS d' ), Db :: raw ( 'SUM(o.amount) AS amount' ), Db :: raw ( 'COUNT(*) AS orders' )];
if ( $people ) {
$fields [] = Db :: raw ( $owner . ' AS pid' );
$query -> group ( $owner . ',d' );
} else {
$query -> group ( 'd' );
}
return $query -> field ( $fields ) -> select () -> toArray ();
}
/** 当前账号数据范围内可见的后台账号ID;null 表示不限(与业绩看板 applyYejiDataScope 相同) */
private static function visibleAdminIds ( Identity $identity ) : ? array
{
if ( ! DataScopeService :: isEnabled ()) {
return null ;
}
$ids = DataScopeService :: getVisibleAdminIds ( $identity -> adminId , $identity -> adminInfo );
return $ids === null ? null : array_values ( array_unique ( array_filter ( array_map ( 'intval' , $ids ), static fn ( $v ) => $v > 0 )));
}
/** 医生统计的医生范围:医生角色、未删除,且在数据范围内(与 DoctorDailyStatsLogic 相同) */
private static function doctorIds ( ? array $visible ) : array
{
$ids = array_map ( 'intval' , Db :: name ( 'admin_role' ) -> alias ( 'ar' ) -> join ( 'admin a' , 'a.id = ar.admin_id' )
-> where ( 'ar.role_id' , 1 ) -> whereNull ( 'a.delete_time' ) -> column ( 'ar.admin_id' ));
return array_values ( $visible === null ? array_unique ( $ids ) : array_intersect ( array_unique ( $ids ), $visible ));
}
/**
* 要对比的人:ids 或 names(最多 4 个),只能在可见范围内选。返回 [admin_id => 姓名],空数组表示看全部合计。
*/
private static function resolvePeople ( string $by , array $args , ? array $pool ) : array
{
$ids = $args [ 'ids' ] ? ? [];
if ( ! is_array ( $ids )) {
$ids = preg_split ( '/[,,、\s]+/u' , ( string ) $ids ) ? : [];
}
foreach ([ 'assistant_id' , 'doctor_id' ] as $single ) {
if (( int ) ( $args [ $single ] ? ? 0 ) > 0 ) {
$ids [] = ( int ) $args [ $single ];
}
}
$ids = array_values ( array_unique ( array_filter ( array_map ( 'intval' , $ids ), static fn ( $v ) => $v > 0 )));
$names = array_values ( array_unique ( array_filter ( array_map ( 'trim' , preg_split ( '/[,,、;; \s]+/u' , ( string ) ( $args [ 'names' ] ? ? $args [ 'name' ] ? ? '' )) ? : []), static fn ( $v ) => $v !== '' )));
if ( count ( $ids ) + count ( $names ) > 4 ) {
throw new McpException ( '一次最多对比 4 个人' , 'invalid' );
}
$role = $by === 'doctor' ? 1 : 2 ;
$who = $by === 'doctor' ? '医生' : '医助' ;
$people = [];
if ( $ids ) {
$rows = Db :: name ( 'admin' ) -> whereIn ( 'id' , $ids ) -> whereNull ( 'delete_time' ) -> column ( 'name' , 'id' );
foreach ( $ids as $id ) {
if ( ! isset ( $rows [ $id ]) || ( $pool !== null && ! in_array ( $id , $pool , true ))) {
throw new McpException ( 'ID 为 ' . $id . ' 的' . $who . '不存在或不在当前账号的数据范围内' , 'denied' );
}
$people [ $id ] = ( string ) $rows [ $id ];
}
}
foreach ( $names as $name ) {
$query = Db :: name ( 'admin' ) -> alias ( 'a' ) -> whereNull ( 'a.delete_time' ) -> where ( 'a.name' , $name );
if ( $pool !== null ) {
$query -> whereIn ( 'a.id' , $pool ? : [ 0 ]);
}
$found = array_map ( 'intval' , $query -> column ( 'a.id' ));
if ( count ( $found ) > 1 ) {
// 同名时优先取对应角色的账号
$withRole = array_map ( 'intval' , Db :: name ( 'admin_role' ) -> whereIn ( 'admin_id' , $found ) -> where ( 'role_id' , $role ) -> column ( 'admin_id' ));
$found = $withRole ? : $found ;
}
if ( $found === []) {
throw new McpException ( '在当前账号的数据范围内找不到叫「' . $name . '」的' . $who . '(姓名需完整)' , 'invalid' );
}
if ( count ( $found ) > 1 ) {
throw new McpException ( '有 ' . count ( $found ) . ' 位同名的「' . $name . '」(ID ' . implode ( '、' , $found ) . '),请改用 ids 指定' , 'invalid' );
}
$people [ $found [ 0 ]] = $name ;
}
return $people ;
}
private static function granularity ( string $value , int $days ) : string
{
if ( ! in_array ( $value , [ 'auto' , 'day' , 'week' , 'month' , '' ], true )) {
throw new McpException ( 'granularity 只支持 auto、day、week、month' , 'invalid' );
}
if ( $value === 'day' && $days > 93 ) {
throw new McpException ( '按日最多 93 天,请改用 week 或 month' , 'invalid' );
}
if ( $value !== 'auto' && $value !== '' ) {
return $value ;
}
return $days <= 45 ? 'day' : ( $days <= 190 ? 'week' : 'month' );
}
private static function bucketLabel ( string $granularity , array $bucket , string $start , string $end ) : string
{
$sameYear = substr ( $start , 0 , 4 ) === substr ( $end , 0 , 4 );
if ( $granularity === 'day' ) {
return $sameYear ? substr ( $bucket [ 'from' ], 5 ) : $bucket [ 'from' ];
}
if ( $granularity === 'week' ) {
return substr ( $bucket [ 'from' ], 5 ) . '~' . substr ( $bucket [ 'to' ], 5 );
}
return $sameYear ? ( int ) substr ( $bucket [ 'from' ], 5 , 2 ) . '月' : substr ( $bucket [ 'from' ], 0 , 7 );
}
// ───────────────────────────── 公共部分 ─────────────────────────────
private static function usable ( Identity $identity , string $key ) : bool
{
$resource = Catalog :: get ( $key );
return $resource !== null && Catalog :: denialFor ( $identity , $resource ) === null ;
}
/** 执行后台原有统计接口,失败时把后台提示交给模型 */
private static function run ( Identity $identity , array $resource , array $params , int $rows ) : array
{
Tools :: assertQuota ( $identity , $rows );
$envelope = Dispatcher :: call ( $identity , $resource , $params );
if ( $envelope [ 'code' ] !== 1 ) {
throw new McpException ( Tools :: failText ( $resource , $envelope ), 'denied' );
}
return ( array ) $envelope [ 'data' ];
}
private static function filters ( array $resource , string $start , string $end , ? string $deptIds , ? string $channel ) : array
{
return Tools :: params ( $resource , array_filter ([ 'start_date' => $start , 'end_date' => $end , 'dept_ids' => $deptIds , 'channel_code' => $channel ],
static fn ( $v ) => $v !== null && $v !== '' ), true );
}
/** @return array{0:string,1:string,2:string} [开始日期, 结束日期, 快捷时间名称] */
private static function range ( array $args ) : array
{
$period = trim (( string ) ( $args [ 'period' ] ? ? '' ));
if ( $period !== '' && ! isset ( self :: PERIODS [ $period ])) {
throw new McpException ( 'period 只支持:' . implode ( '、' , array_keys ( self :: PERIODS )), 'invalid' );
}
if ( $period === '' && empty ( $args [ 'start_date' ]) && empty ( $args [ 'end_date' ])) {
$period = 'this_month' ;
}
if ( $period !== '' ) {
$today = ( int ) strtotime ( 'today' );
$monday = ( int ) strtotime ( '-' . (( int ) date ( 'N' , $today ) - 1 ) . ' day' , $today );
$monthStart = ( int ) strtotime ( date ( 'Y-m-01' , $today ));
[ $from , $to ] = match ( $period ) {
'today' => [ $today , $today ],
'yesterday' => [ strtotime ( '-1 day' , $today ), strtotime ( '-1 day' , $today )],
'this_week' => [ $monday , $today ],
'last_week' => [ strtotime ( '-7 day' , $monday ), strtotime ( '-1 day' , $monday )],
'this_month' => [ $monthStart , $today ],
'last_month' => [ strtotime ( '-1 month' , $monthStart ), strtotime ( '-1 day' , $monthStart )],
'last_7_days' => [ strtotime ( '-6 day' , $today ), $today ],
'last_30_days' => [ strtotime ( '-29 day' , $today ), $today ],
};
return [ date ( 'Y-m-d' , ( int ) $from ), date ( 'Y-m-d' , ( int ) $to ), self :: PERIODS [ $period ]];
}
$start = self :: date (( string ) ( $args [ 'start_date' ] ? ? '' ) ? : ( string ) $args [ 'end_date' ], 'start_date' );
$end = self :: date (( string ) ( $args [ 'end_date' ] ? ? '' ) ? : $start , 'end_date' );
if ( $end < $start ) {
throw new McpException ( '结束日期不能早于开始日期' , 'invalid' );
}
if (( strtotime ( $end ) - strtotime ( $start )) / 86400 + 1 > McpConfig :: maxRangeDays ()) {
throw new McpException ( '时间范围超过 ' . McpConfig :: maxRangeDays () . ' 天,请缩小范围' , 'invalid' );
}
return [ $start , $end , '' ];
}
private static function date ( string $value , string $name ) : string
{
$value = trim ( $value );
$ts = preg_match ( '/^\d{4}-\d{1,2}-\d{1,2}$/' , $value ) ? strtotime ( $value ) : false ;
if ( $ts === false ) {
throw new McpException ( $name . ' 需要 YYYY-MM-DD 格式的日期' , 'invalid' );
}
return date ( 'Y-m-d' , $ts );
}
private static function metric ( $value , array $metrics ) : string
{
$value = ( string ) $value ;
if ( ! isset ( $metrics [ $value ])) {
throw new McpException ( 'sort_by 只支持:' . self :: metricText ( $metrics ), 'invalid' );
}
return $value ;
}
private static function metricText ( array $metrics ) : string
{
return implode ( '、' , array_map ( static fn ( $k , $v ) => $k . ' ' . $v [ 0 ], array_keys ( $metrics ), $metrics ));
}
private static function columns ( array $metrics ) : array
{
return array_map ( static fn ( $v ) => $v [ 0 ] . ( $v [ 1 ] !== '' ? '( ' . $v [ 1 ] . ') ' : '' ), $metrics );
}
private static function top ( array $args ) : int
{
return max ( 1 , min ( 50 , ( int ) ( $args [ 'top' ] ? ? 15 )));
}
private static function channel ( array $args ) : ? string
{
$channel = trim (( string ) ( $args [ 'channel_code' ] ? ? '' ));
if ( $channel !== '' && ! preg_match ( '/^[\w\-]{1,64}$/' , $channel )) {
throw new McpException ( 'channel_code 格式不正确(取值见 stats.yejiStats/channelOptions) ' , 'invalid' );
}
return $channel === '' ? null : $channel ;
}
/** 部门名称或ID → 逗号分隔的部门ID;名称只在当前账号可见的部门里找 */
private static function deptIds ( Identity $identity , array $args ) : ? string
{
$raw = $args [ 'dept' ] ? ? $args [ 'dept_ids' ] ? ? null ;
if ( $raw === null || $raw === '' || $raw === []) {
return null ;
}
$parts = is_array ( $raw ) ? $raw : ( preg_split ( '/[,,、;; ]+/u' , ( string ) $raw ) ? : []);
$parts = array_slice ( array_values ( array_filter ( array_map ( static fn ( $v ) => trim (( string ) $v ), $parts ), static fn ( $v ) => $v !== '' )), 0 , 20 );
$ids = [];
$unknown = [];
$options = null ;
foreach ( $parts as $part ) {
if ( ctype_digit ( $part )) {
$ids [] = ( int ) $part ;
continue ;
}
$options ? ? = self :: deptOptions ( $identity );
$exact = array_filter ( $options , static fn ( $d ) => $d [ 'name' ] === $part );
$matched = $exact ? : array_filter ( $options , static fn ( $d ) => mb_strpos ( $d [ 'name' ], $part ) !== false );
if ( $matched === []) {
$unknown [] = $part ;
} elseif ( count ( $matched ) > 8 ) {
throw new McpException ( '「' . $part . '」匹配到 ' . count ( $matched ) . ' 个部门,请写完整的部门名称' , 'invalid' );
} else {
array_push ( $ids , ... array_map ( static fn ( $d ) => ( int ) $d [ 'id' ], $matched ));
}
}
if ( $unknown ) {
throw new McpException ( '在当前账号可见的部门里找不到:' . implode ( '、' , $unknown ), 'invalid' );
}
$ids = array_values ( array_unique ( array_filter ( $ids , static fn ( $v ) => $v > 0 )));
return $ids ? implode ( ',' , $ids ) : null ;
}
private static function deptOptions ( Identity $identity ) : array
{
return Dispatcher :: readOnly ( static fn () => YejiStatsLogic :: deptOptions ( $identity -> adminId , $identity -> adminInfo ));
}
private static function deptNames ( Identity $identity , string $deptIds ) : string
{
$names = array_column ( self :: deptOptions ( $identity ), 'name' , 'id' );
return implode ( '、' , array_map ( static fn ( $id ) => $names [( int ) $id ] ? ? ( '部门' . $id ), explode ( ',' , $deptIds )));
}
private static function scopeText ( string $start , string $end , string $periodName , ? array $depts , string $channel ) : string
{
$text = ( $periodName !== '' ? $periodName . ' ' : '' ) . ( $start === $end ? $start : $start . ' 至 ' . $end );
if ( $depts ) {
$text .= ' · ' . implode ( '、' , array_slice ( array_values ( array_unique ( array_filter ( array_column ( $depts , 'dept' )))), 0 , 6 ));
}
if ( $channel !== '' ) {
$text .= ' · 渠道:' . $channel ;
}
return $text ;
}
/** 按指标从高到低排名(空值排最后),名次写入 rank */
private static function rank ( array $rows , string $metric , string $tie ) : array
{
usort ( $rows , static function ( array $a , array $b ) use ( $metric , $tie ) : int {
$x = $a [ $metric ] ? ? null ;
$y = $b [ $metric ] ? ? null ;
if ( $x === null || $y === null ) {
return ( $x === null ) <=> ( $y === null );
}
if ( $x != $y ) {
return $y <=> $x ;
}
if (( $a [ $tie ] ? ? 0 ) != ( $b [ $tie ] ? ? 0 )) {
return ( $b [ $tie ] ? ? 0 ) <=> ( $a [ $tie ] ? ? 0 );
}
return strcmp (( string ) ( $a [ 'name' ] ? ? '' ), ( string ) ( $b [ 'name' ] ? ? '' ));
});
foreach ( $rows as $i => $row ) {
$rows [ $i ] = [ 'rank' => $i + 1 ] + $row ;
}
return $rows ;
}
private static function filterPeople ( array $rows , array $args , string $idKey ) : array
{
$id = ( int ) ( $args [ $idKey ] ? ? 0 );
$name = trim (( string ) ( $args [ 'name' ] ? ? '' ));
if ( $id > 0 ) {
$rows = array_filter ( $rows , static fn ( $r ) => $r [ 'admin_id' ] === $id );
}
if ( $name !== '' ) {
$rows = array_filter ( $rows , static fn ( $r ) => mb_strpos ( $r [ 'name' ], $name ) !== false );
}
return array_values ( $rows );
}
/** 排行条形图:只画该指标不为 0 的行;少于 2 行不画(一根柱子不如直接说数字) */
private static function rankingChart ( array $rows , string $metric , array $meta , string $title , string $subtitle , string $note ) : ? array
{
$rows = array_values ( array_filter ( $rows , static fn ( $r ) => ( $r [ $metric ] ? ? null ) !== null && ( float ) $r [ $metric ] != 0.0 ));
if ( count ( $rows ) < 2 ) {
return null ;
}
$counts = array_count_values ( array_map ( 'strval' , array_column ( $rows , 'name' )));
$labels = array_map ( static fn ( $r ) => ( $counts [( string ) $r [ 'name' ]] ? ? 0 ) > 1 && ! empty ( $r [ 'dept' ]) ? $r [ 'name' ] . '( ' . $r [ 'dept' ] . ') ' : ( string ) $r [ 'name' ], $rows );
return self :: chart ( 'bar' , $title , $subtitle , $meta [ 1 ], $labels , [[ 'name' => $meta [ 0 ], 'data' => array_column ( $rows , $metric )]], $note );
}
private static function chart ( string $type , string $title , string $subtitle , string $unit , array $labels , array $series , string $note = '' ) : array
{
$chart = [ 'type' => $type , 'title' => $title , 'subtitle' => $subtitle , 'unit' => $unit , 'labels' => array_values ( array_map ( 'strval' , $labels )),
'series' => array_map ( static fn ( $s ) => [ 'name' => ( string ) $s [ 'name' ], 'data' => array_map ( static fn ( $v ) => $v === null ? null : round (( float ) $v , 2 ), array_values ( $s [ 'data' ]))], $series )];
if ( $note !== '' ) {
$chart [ 'note' ] = $note ;
}
return $chart ;
}
/** 统一输出:文字摘要 + 图表代码块 + JSON; structuredContent 带上 chart */
private static function respond ( Identity $identity , array & $audit , string $summary , array $data , ? array $chart , ? int $rows = null ) : array
{
$policy = FieldPolicy :: forIdentity ( $identity , 2000 );
$data = Tools :: fit ( $policy -> apply ( $data ));
$count = $rows ? ? ( isset ( $data [ 'rows' ]) && is_array ( $data [ 'rows' ]) ? count ( $data [ 'rows' ]) : 1 );
RateLimiter :: addRows ( $identity -> adminId , max ( 1 , $count ));
$audit [ 'result_rows' ] = $count ;
$text = $summary ;
if ( $chart ) {
$text .= " \n 回答时请把下面的 chart 代码块原样放进回复(行知会显示为统计图,不要改动其中的数字): \n ```chart \n "
. json_encode ( $chart , JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ) . " \n ``` " ;
}
$text .= " \n " . json_encode ( $data , JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
return [ 'content' => [[ 'type' => 'text' , 'text' => $text ]], 'structuredContent' => $chart ? $data + [ 'chart' => $chart ] : $data , 'isError' => false ];
}
private static function num ( $value , int $decimals = 2 ) : string
{
if ( $value === null || $value === '' ) {
return '—' ;
}
$text = number_format (( float ) $value , $decimals , '.' , ',' );
return str_contains ( $text , '.' ) ? rtrim ( rtrim ( $text , '0' ), '.' ) : $text ;
}
}