924 lines
57 KiB
PHP
924 lines
57 KiB
PHP
<?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;
|
||
}
|
||
}
|