Files
zyt/server/app/mcp/service/PerfTools.php
T
2026-09-24 11:48:20 +08:00

924 lines
57 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\mcp\service;
use app\adminapi\logic\stats\YejiStatsLogic;
use app\adminapi\logic\tcm\PrescriptionOrderLogic;
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';
/**
* 指标 => [名称, 单位, 后台列名, 口径]。名称写清楚数的是什么;后台列名留作对账用——后台“接诊单数”其实是
* 面诊完成的挂号人次、“接诊诊单”才是订单数,直接沿用会让人把 96 人次面诊当成 96 单(线上真实出现过)。
*/
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' => ['挂号成交率', '%', '挂号率', '成交订单数 ÷ 挂号总数 × 100'],
'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 代码块请原样放进回答,行知会显示为统计图。';
$countNote = '注意:订单数看“成交订单数”(后台列名“接诊诊单”);“面诊完成数”(后台列名“接诊单数”)是完成的挂号人次,不是订单数,回答时不要写成“X 单”。';
$tools = [];
if ($canAssistants) {
$tools[] = Tools::tool('zyt_perf_assistants',
'医助业绩排行(与后台“业绩看板·医助排行榜”同口径):一次返回全部可见医助的诊金、成交订单数、面诊完成数、预约数、被指派数、进线数、每进线诊金,含排名、合计、部门小计和每个指标的口径。'
. '问“医助业绩/排行/谁业绩最好/某位医助本月业绩或订单数”时直接用它,不要逐人、逐天调用明细接口;只看一个人时还会给出处方业务订单列表的对账数字。' . $countNote . $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 及合计。问“各部门/各中心业绩”时用它。' . $countNote . $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] = 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));
}
$summary .= self::notOrdersNote($sortBy, self::ASSISTANT_METRICS);
$out = [
'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),
'definitions' => self::metricDefinitions(self::ASSISTANT_METRICS),
'note' => (string) ($data['range_note'] ?? ''),
];
// 只看一个人时附上处方业务订单列表(按医助筛选)的数字,用户拿后台列表对账时能直接看出差在哪里
if ($filtered && count($matched) === 1) {
$person = $matched[0];
$check = self::orderListCheck($identity, 'assistant_id', $person['admin_id'], $start, $end);
if ($check !== null) {
$check['not_counted'] = max(0, $check['total'] - $person['deal_order_count']);
$out['order_list'] = $check;
$summary .= sprintf('对账:处方业务订单列表按该医助、同一时间段筛选共 %d 条(含全部状态),金额 %s 元,其中计入业绩 %s 元;业绩只算未取消、未拒收、未退款的订单,所以成交订单是 %d 单%s。',
$check['total'], self::num($check['amount']), self::num($check['performance_amount']), $person['deal_order_count'],
$check['not_counted'] > 0 ? ',列表里另有 ' . $check['not_counted'] . ' 条不计入业绩' : '');
}
}
$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, $out, $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));
}
$summary .= self::notOrdersNote($sortBy, self::DOCTOR_METRICS);
$out = [
'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' => '取消'],
'definitions' => self::metricDefinitions(self::DOCTOR_METRICS),
'note' => '只统计当前账号数据范围内的医生;按部门筛选时只统计该部门医助经手的挂号、订单和处方,并隐藏全为 0 的医生。',
];
// 按开方医生筛选列表时,非“业务订单全量”角色只能看到自己创建的订单,数字会偏小,只给能看全量订单的账号对账
if ($filtered && count($matched) === 1 && PrescriptionOrderLogic::canSeeAllPrescriptionOrders($identity->adminInfo)) {
$person = $matched[0];
$check = self::orderListCheck($identity, 'doctor_id', $person['admin_id'], $start, $end);
if ($check !== null) {
$check['not_counted'] = max(0, $check['total'] - $person['deal_order_count']);
$out['order_list'] = $check;
$summary .= sprintf('对账:处方业务订单列表按该开方医生、同一时间段筛选共 %d 条(含全部状态),金额 %s 元;成交订单只算未取消、未拒收、未退款且没有发生过退款的订单,所以是 %d 单%s。',
$check['total'], self::num($check['amount']), $person['deal_order_count'],
$check['not_counted'] > 0 ? ',列表里另有 ' . $check['not_counted'] . ' 条不计入' : '');
}
}
$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, $out, $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) . ' 元。';
}
$summary .= self::notOrdersNote($sortBy, self::DEPT_METRICS);
$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' => '渠道业绩'],
'definitions' => self::metricDefinitions(self::DEPT_METRICS),
'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] . ($v[2] !== $v[0] ? '(后台“' . $v[2] . '”)' : ''), array_keys($metrics), $metrics));
}
/** 列名:名称(单位)· 后台列名 */
private static function columns(array $metrics): array
{
return array_map(static fn ($v) => $v[0] . '(' . $v[1] . ')' . ($v[2] !== $v[0] ? ' · 后台“' . $v[2] . '”' : ''), $metrics);
}
/** 每个指标数的是什么,让模型按口径解释数字 */
private static function metricDefinitions(array $metrics): array
{
return array_map(static fn ($v) => $v[0] . ':' . $v[3] . ($v[2] !== $v[0] ? '(后台列名“' . $v[2] . '”)' : ''), $metrics);
}
/** 按不是订单的指标排名时提醒一句,避免把“面诊完成 96 人次”说成“96 单” */
private static function notOrdersNote(string $metric, array $metrics): string
{
[$name, $unit, $alias] = $metrics[$metric];
if (!in_array($metric, ['consult_count', 'appointment_count', 'appointment_booked_count', 'appointment_total', 'appointment_completed', 'assign_count', 'lead_count'], true)) {
return '';
}
return sprintf('注意:%s(%s%s)不是订单数,订单数看成交订单数(后台“接诊诊单”)。', $name, $unit, $alias !== $name ? ',后台列名“' . $alias . '”' : '');
}
/**
* 处方业务订单列表按医助(订单创建人)或开方医生筛选、同一时间段的条数和金额——就是用户在后台列表页看到的数字。
* 账号没有列表权限或查询失败时返回 null(不影响业绩结果)。
*/
private static function orderListCheck(Identity $identity, string $filter, int $adminId, string $start, string $end): ?array
{
$resource = Catalog::get('tcm.prescriptionOrder/lists');
if ($resource === null || Catalog::denialFor($identity, $resource) !== null) {
return null;
}
$params = Tools::params($resource, [$filter => $adminId, 'start_time' => $start . ' 00:00:00', 'end_time' => $end . ' 23:59:59'], true);
$envelope = Dispatcher::call($identity, $resource, Tools::listParams($resource, $params, 1, 1));
if ($envelope['code'] !== 1) {
return null;
}
$extend = (array) ($envelope['data']['extend'] ?? []);
return [
'filter' => $filter === 'doctor_id' ? '开方医生' : '医助(订单创建人)',
'total' => (int) ($envelope['data']['count'] ?? 0),
'amount' => round((float) ($extend['stats_order_amount'] ?? 0), 2),
'performance_amount' => round((float) ($extend['stats_order_amount_performance'] ?? $extend['stats_order_amount_not_cancelled'] ?? 0), 2),
'cancelled_amount' => round((float) ($extend['stats_order_amount_cancelled'] ?? 0), 2),
'note' => '处方业务订单列表按该条件、同一时间段筛选的结果:total 含全部状态;performance_amount 是其中计入业绩的金额(不含已取消/拒收/退款)。',
];
}
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);
// 图下说明写清楚这个数是什么(含后台列名),图离开上下文被转发时也不会被误读
$meaning = $meta[0] . ($meta[2] !== $meta[0] ? '(后台“' . $meta[2] . '”)' : '') . ':' . $meta[3];
return self::chart('bar', $title, $subtitle, $meta[1], $labels, [['name' => $meta[0], 'data' => array_column($rows, $metric)]], $meaning . '。' . $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;
}
}