292 lines
23 KiB
PHP
292 lines
23 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
/**
|
||
* AI 助手(MCP)业绩工具测试:zyt_perf_assistants / zyt_perf_doctors / zyt_stats_performance / zyt_perf_trend。
|
||
* 覆盖:金额口径(取消单不计、部分退款单医助计入而医生不计)、排名与合计、数据范围(经理看本部门、医助只看自己)、
|
||
* 部门名称解析、趋势按日/周/月归并、图表输出、权限点未登记时回退到 Tab 权限、params 里写分页参数。
|
||
*
|
||
* 需要与 AiMcpHttpContractTest 相同的一次性测试库(库名以 _test 结尾)和指向它的运行实例:
|
||
* AI_MCP_TEST_MYSQL=1 AI_MCP_TEST_BASE_URL=http://127.0.0.1:8099 php server/tests/AiMcpPerfTest.php
|
||
* 夹具 ID 段:账号 92001-92009、部门 9921-9923、角色 197-199、诊单 95011-95013、处方 8101-8104、订单号 PERFT*(2031 年 2 月)。
|
||
*/
|
||
|
||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||
|
||
use think\App;
|
||
use think\facade\Db;
|
||
|
||
function aiMcpPerfExpect(bool $condition, string $message): void
|
||
{
|
||
if (!$condition) {
|
||
fwrite(STDERR, "FAIL: {$message}\n");
|
||
exit(1);
|
||
}
|
||
}
|
||
|
||
$base = rtrim((string) getenv('AI_MCP_TEST_BASE_URL'), '/');
|
||
if (getenv('AI_MCP_TEST_MYSQL') !== '1' || $base === '') {
|
||
echo "AiMcpPerfTest SKIP (set AI_MCP_TEST_MYSQL=1, AI_MCP_TEST_BASE_URL and PHP_DATABASE_* for a disposable *_test database)\n";
|
||
exit(0);
|
||
}
|
||
|
||
$app = new App(dirname(__DIR__) . DIRECTORY_SEPARATOR);
|
||
$app->initialize();
|
||
$database = (string) config('database.connections.' . config('database.default') . '.database');
|
||
aiMcpPerfExpect(str_ends_with($database, '_test'), "refusing to run on database '{$database}' (name must end with _test)");
|
||
aiMcpPerfExpect((int) Db::name('system_menu')->where('perms', 'ai.mcp/access')->count() === 1, 'run 2026_09_24_ai_mcp.sql on the test database first');
|
||
|
||
// ---------------------------------------------------------------- 夹具
|
||
$now = time();
|
||
$pwd = create_password('Test@123456', (string) config('project.unique_identification'));
|
||
$roles = [197 => ['业绩经理', 2], 198 => ['业绩受限经理', 2], 199 => ['业绩医助附加', 4]];
|
||
Db::name('system_role')->whereIn('id', array_keys($roles))->delete();
|
||
foreach ($roles as $id => [$name, $scope]) {
|
||
Db::name('system_role')->insert(['id' => $id, 'name' => $name, 'desc' => 'ai-mcp-perf-test', 'sort' => 0, 'data_scope' => $scope, 'create_time' => $now, 'update_time' => $now]);
|
||
}
|
||
Db::name('dept')->whereIn('id', [9921, 9922, 9923])->delete();
|
||
foreach ([9921 => ['AI业绩总部', 0], 9922 => ['AI业绩一中心', 9921], 9923 => ['AI业绩三中心', 9921]] as $id => [$name, $pid]) {
|
||
Db::name('dept')->insert(['id' => $id, 'name' => $name, 'pid' => $pid, 'sort' => 0, 'leader' => '', 'mobile' => '', 'status' => 1, 'create_time' => $now, 'update_time' => $now]);
|
||
}
|
||
// 医助、医生必须用系统角色 2 / 1:医助排行榜和医生统计按这两个角色取人
|
||
$admins = [
|
||
92001 => ['p_mgr', '业绩经理甲', [197], 9921], 92002 => ['p_doc_a', '业绩张医生', [1], 9922], 92003 => ['p_doc_b', '业绩李医生', [1], 9923],
|
||
92004 => ['p_asst_c', '业绩王医助', [2, 199], 9922], 92005 => ['p_lim', '业绩受限乙', [198], 9921], 92008 => ['p_root', '业绩超管', [], 9921],
|
||
92009 => ['p_asst_g', '业绩钱医助', [2], 9923],
|
||
];
|
||
Db::name('admin')->whereIn('id', array_keys($admins))->delete();
|
||
Db::name('admin_role')->whereIn('admin_id', array_keys($admins))->delete();
|
||
Db::name('admin_dept')->whereIn('admin_id', array_keys($admins))->delete();
|
||
Db::name('ai_grant')->whereIn('admin_id', array_keys($admins))->delete();
|
||
foreach ($admins as $id => [$account, $name, $roleIds, $dept]) {
|
||
Db::name('admin')->insert(['id' => $id, 'root' => $account === 'p_root' ? 1 : 0, 'name' => $name, 'avatar' => '', 'account' => $account, 'password' => $pwd, 'multipoint_login' => 1,
|
||
'is_paw' => 1, 'work_wechat_userid' => '', 'disable' => 0, 'phone' => '1370000' . substr((string) $id, -4), 'create_time' => $now, 'update_time' => $now]);
|
||
foreach ($roleIds as $roleId) {
|
||
Db::name('admin_role')->insert(['admin_id' => $id, 'role_id' => $roleId]);
|
||
}
|
||
Db::name('admin_dept')->insert(['admin_id' => $id, 'dept_id' => $dept]);
|
||
}
|
||
$menuId = static function (string $perm) use ($now): int {
|
||
$id = (int) Db::name('system_menu')->where('perms', $perm)->value('id');
|
||
return $id ?: (int) Db::name('system_menu')->insertGetId(['pid' => 0, 'type' => 'A', 'name' => 'AI测试 ' . $perm, 'icon' => '', 'sort' => 0, 'perms' => $perm,
|
||
'paths' => '', 'component' => '', 'selected' => '', 'params' => '', 'is_cache' => 0, 'is_show' => 0, 'is_disable' => 0, 'create_time' => $now, 'update_time' => $now]);
|
||
};
|
||
$leaderboardMenu = $menuId('stats.yejiStats/leaderboard');
|
||
Db::name('system_menu')->where('id', $leaderboardMenu)->update(['is_disable' => 0]);
|
||
$grantsByRole = [
|
||
197 => ['ai.mcp/access', 'stats.yejiStats/leaderboard', 'stats.doctorDailyStats/overview', 'stats.yejiStats/overview', 'tcm.prescriptionOrder/lists'],
|
||
198 => ['ai.mcp/access', 'stats.yejiStats/overview', 'stats.yejiStats/tabLeaderboard'],
|
||
199 => ['ai.mcp/access', 'stats.yejiStats/leaderboard'],
|
||
];
|
||
Db::name('system_role_menu')->whereIn('role_id', array_keys($grantsByRole))->delete();
|
||
foreach ($grantsByRole as $role => $perms) {
|
||
foreach ($perms as $perm) {
|
||
Db::name('system_role_menu')->insert(['role_id' => $role, 'menu_id' => $menuId($perm)]);
|
||
}
|
||
}
|
||
Db::name('tcm_diagnosis')->whereIn('id', [95011, 95012, 95013])->delete();
|
||
foreach ([95011 => ['业一', 92004], 95012 => ['业二', 92004], 95013 => ['业三', 92009]] as $id => [$name, $assistant]) {
|
||
Db::name('tcm_diagnosis')->insert(['id' => $id, 'patient_id' => $id + 1000, 'patient_name' => $name, 'phone' => '1382222' . substr((string) $id, -4),
|
||
'id_card' => '', 'gender' => 1, 'age' => 40, 'status' => 1, 'assistant_id' => $assistant, 'create_time' => $now, 'update_time' => $now]);
|
||
}
|
||
Db::name('tcm_prescription')->whereIn('id', [8101, 8102, 8103, 8104])->delete();
|
||
foreach ([8101 => [95011, 92002], 8102 => [95012, 92003], 8103 => [95013, 92003], 8104 => [95012, 92002]] as $id => [$diag, $doctor]) {
|
||
Db::name('tcm_prescription')->insert(['id' => $id, 'sn' => 'PERFRX' . $id, 'diagnosis_id' => $diag, 'creator_id' => $doctor, 'prescription_date' => '2031-02-01']);
|
||
}
|
||
Db::name('tcm_prescription_order')->whereLike('order_no', 'PERFT%')->delete();
|
||
$orders = [
|
||
// [处方, 诊单, 创建人(医助), 金额, 履约状态, 退款, 创建时间]
|
||
['PERFT1', 8101, 95011, 92004, 1000, 3, 0, '2031-02-03 10:00:00'],
|
||
['PERFT2', 8101, 95011, 92004, 500, 4, 0, '2031-02-04 10:00:00'], // 已取消:都不计
|
||
['PERFT3', 8102, 95012, 92004, 700, 5, 0, '2031-02-10 09:00:00'],
|
||
['PERFT4', 8103, 95013, 92009, 2500, 3, 300, '2031-02-10 15:00:00'], // 部分退款:医助诊金计入,医生成交金额不计
|
||
['PERFT5', 8104, 95012, 92009, 400, 1, 0, '2031-02-20 11:00:00'],
|
||
];
|
||
foreach ($orders as [$no, $rx, $diag, $creator, $amount, $status, $refund, $time]) {
|
||
Db::name('tcm_prescription_order')->insert(['order_no' => $no, 'prescription_id' => $rx, 'diagnosis_id' => $diag, 'creator_id' => $creator,
|
||
'amount' => $amount, 'fulfillment_status' => $status, 'refund_amount' => $refund, 'create_time' => strtotime($time)]);
|
||
}
|
||
// 已完成的挂号(面诊完成数,后台列名“接诊单数”):王医助 2 人次、钱医助 1 人次——与订单数无关
|
||
Db::name('doctor_appointment')->whereIn('id', [96201, 96202, 96203])->delete();
|
||
foreach ([96201 => [95011, 92002, 92004, '2031-02-03'], 96202 => [95012, 92003, 92004, '2031-02-10'], 96203 => [95013, 92003, 92009, '2031-02-10']] as $id => [$diag, $doctor, $assistant, $day]) {
|
||
Db::name('doctor_appointment')->insert(['id' => $id, 'patient_id' => $diag, 'doctor_id' => $doctor, 'assistant_id' => $assistant, 'roster_id' => 0,
|
||
'appointment_date' => $day, 'period' => 'morning', 'appointment_time' => '09:00:00', 'appointment_type' => 'video', 'status' => 3,
|
||
'remark' => '', 'channel_source' => '', 'create_time' => $now, 'update_time' => $now]);
|
||
}
|
||
\think\facade\Cache::clear();
|
||
|
||
// ---------------------------------------------------------------- HTTP 工具
|
||
function aiMcpPerfHttp(string $url, array $body, array $headers): array
|
||
{
|
||
$ch = curl_init($url);
|
||
$lines = ['Content-Type: application/json'];
|
||
foreach ($headers as $k => $v) {
|
||
$lines[] = $k . ': ' . $v;
|
||
}
|
||
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $lines, CURLOPT_TIMEOUT => 120,
|
||
CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_UNICODE)]);
|
||
$raw = (string) curl_exec($ch);
|
||
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||
curl_close($ch);
|
||
return [$status, json_decode($raw, true)];
|
||
}
|
||
|
||
$grant = static function (string $account) use ($base): string {
|
||
[, $body] = aiMcpPerfHttp($base . '/mcp/auth/grant', ['account' => $account, 'password' => 'Test@123456', 'client' => 'xingzhi', 'client_instance' => 'perf-test'], []);
|
||
aiMcpPerfExpect(($body['code'] ?? null) === 1, "grant for {$account}: " . json_encode($body, JSON_UNESCAPED_UNICODE));
|
||
return (string) $body['data']['token'];
|
||
};
|
||
$rpc = static function (string $token, string $method, array $params = []) use ($base): array {
|
||
static $id = 0;
|
||
[$status, $body] = aiMcpPerfHttp($base . '/mcp', ['jsonrpc' => '2.0', 'id' => ++$id, 'method' => $method, 'params' => $params],
|
||
['Authorization' => 'Bearer ' . $token, 'Accept' => 'application/json, text/event-stream', 'X-Xingzhi-Task-Id' => 'perf-task']);
|
||
aiMcpPerfExpect($status === 200 && isset($body['result']), "{$method} should return a result, got HTTP {$status}: " . json_encode($body, JSON_UNESCAPED_UNICODE));
|
||
return $body['result'];
|
||
};
|
||
$toolNames = static fn (string $token): array => array_column($rpc($token, 'tools/list')['tools'] ?? [], 'name');
|
||
$call = static function (string $token, string $name, array $args) use ($rpc): array {
|
||
return $rpc($token, 'tools/call', ['name' => $name, 'arguments' => $args]);
|
||
};
|
||
$ok = static function (string $token, string $name, array $args) use ($call): array {
|
||
$result = $call($token, $name, $args);
|
||
aiMcpPerfExpect(empty($result['isError']), "{$name} " . json_encode($args, JSON_UNESCAPED_UNICODE) . ' should succeed: ' . ($result['content'][0]['text'] ?? ''));
|
||
return $result;
|
||
};
|
||
$byId = static fn (array $rows): array => array_column($rows, null, 'admin_id');
|
||
$feb = ['start_date' => '2031-02-01', 'end_date' => '2031-02-28'];
|
||
|
||
$mgr = $grant('p_mgr');
|
||
$asst = $grant('p_asst_c');
|
||
$lim = $grant('p_lim');
|
||
$root = $grant('p_root');
|
||
|
||
// ---------------------------------------------------------------- 工具列表
|
||
$names = $toolNames($mgr);
|
||
foreach (['zyt_perf_assistants', 'zyt_perf_doctors', 'zyt_stats_performance', 'zyt_perf_trend'] as $name) {
|
||
aiMcpPerfExpect(in_array($name, $names, true), "manager sees {$name}");
|
||
}
|
||
foreach ($rpc($mgr, 'tools/list')['tools'] as $definition) {
|
||
aiMcpPerfExpect(($definition['annotations']['readOnlyHint'] ?? false) === true, $definition['name'] . ' is annotated read-only');
|
||
}
|
||
$names = $toolNames($asst);
|
||
aiMcpPerfExpect(in_array('zyt_perf_assistants', $names, true) && !in_array('zyt_perf_doctors', $names, true), 'assistant sees only the tools its permissions allow');
|
||
|
||
// ---------------------------------------------------------------- 医助排行
|
||
$result = $ok($mgr, 'zyt_perf_assistants', $feb);
|
||
$data = $result['structuredContent'];
|
||
$rows = $byId($data['rows']);
|
||
aiMcpPerfExpect(array_keys($rows) === [92009, 92004], 'manager ranks its two assistants by fee: ' . json_encode(array_keys($rows)));
|
||
aiMcpPerfExpect($rows[92009]['fee_amount'] == 2900 && $rows[92009]['deal_order_count'] === 2 && $rows[92009]['rank'] === 1, 'partially refunded order counts for the assistant fee');
|
||
aiMcpPerfExpect($rows[92004]['fee_amount'] == 1700 && $rows[92004]['deal_order_count'] === 2, 'cancelled order is excluded from the assistant fee');
|
||
aiMcpPerfExpect($data['totals']['fee_amount'] == 4600 && $data['totals']['deal_order_count'] === 4 && $data['assistants'] === 2, 'totals add up: ' . json_encode($data['totals']));
|
||
aiMcpPerfExpect(($data['chart']['type'] ?? '') === 'bar' && $data['chart']['labels'] === ['业绩钱医助', '业绩王医助'] && $data['chart']['series'][0]['data'] == [2900, 1700], 'ranking chart: ' . json_encode($data['chart'] ?? null, JSON_UNESCAPED_UNICODE));
|
||
aiMcpPerfExpect(str_contains($result['content'][0]['text'], "```chart\n{") && str_contains($result['content'][0]['text'], '业绩钱医助'), 'text result carries the chart block');
|
||
|
||
$data = $ok($mgr, 'zyt_perf_assistants', $feb + ['name' => '王'])['structuredContent'];
|
||
aiMcpPerfExpect(count($data['rows']) === 1 && $data['rows'][0]['admin_id'] === 92004 && $data['rows'][0]['rank'] === 2 && !isset($data['chart']), 'name filter keeps the overall rank and draws no one-bar chart');
|
||
$data = $ok($mgr, 'zyt_perf_assistants', $feb + ['dept' => 'AI业绩一中心'])['structuredContent'];
|
||
aiMcpPerfExpect(array_column($data['rows'], 'admin_id') === [92004] && $data['totals']['fee_amount'] == 1700, 'department name filter');
|
||
$data = $ok($mgr, 'zyt_perf_assistants', $feb + ['sort_by' => 'deal_order_count', 'top' => 1])['structuredContent'];
|
||
aiMcpPerfExpect(count($data['rows']) === 1 && $data['rows'][0]['admin_id'] === 92009, 'sort_by with tie falls back to fee; top limits rows');
|
||
$data = $ok($root, 'zyt_perf_assistants', $feb + ['dept' => 'AI业绩一中心,AI业绩三中心'])['structuredContent'];
|
||
$ids = array_column($data['rows'], 'admin_id');
|
||
sort($ids);
|
||
aiMcpPerfExpect($ids === [92004, 92009], 'root with two department names sees both assistants');
|
||
$data = $ok($asst, 'zyt_perf_assistants', $feb)['structuredContent'];
|
||
aiMcpPerfExpect(array_column($data['rows'], 'admin_id') === [92004] && $data['totals']['fee_amount'] == 1700, 'assistant with self data scope only sees itself');
|
||
|
||
// 指标名称:后台“接诊单数”是面诊完成人次,不能被说成订单数(线上把 96 人次面诊说成了 96 单)
|
||
$result = $ok($mgr, 'zyt_perf_assistants', $feb + ['sort_by' => 'consult_count']);
|
||
$data = $result['structuredContent'];
|
||
aiMcpPerfExpect(str_contains($data['columns']['consult_count'], '面诊完成数') && str_contains($data['columns']['consult_count'], '接诊单数')
|
||
&& str_contains($data['columns']['deal_order_count'], '成交订单数') && str_contains($data['definitions']['consult_count'], '不是订单数'), 'metric names say what is counted: ' . json_encode($data['columns'], JSON_UNESCAPED_UNICODE));
|
||
aiMcpPerfExpect(($data['chart']['title'] ?? '') === '医助面诊完成数排行' && $data['chart']['unit'] === '人次' && $data['chart']['series'][0]['data'] == [2, 1]
|
||
&& str_contains($data['chart']['note'], '不是订单数'), 'completed consultations chart: ' . json_encode($data['chart'] ?? null, JSON_UNESCAPED_UNICODE));
|
||
aiMcpPerfExpect(str_contains($result['content'][0]['text'], '不是订单数') && str_contains($result['content'][0]['text'], '成交订单 4 单'), 'summary warns that consultations are not orders');
|
||
|
||
// 只看一个人:附上处方业务订单列表(按医助筛选)的数字,列表含已取消的 1 单,业绩不含
|
||
$result = $ok($mgr, 'zyt_perf_assistants', $feb + ['name' => '王']);
|
||
$check = $result['structuredContent']['order_list'] ?? null;
|
||
aiMcpPerfExpect($check !== null && $check['total'] === 3 && $check['amount'] == 2200 && $check['performance_amount'] == 1700 && $check['not_counted'] === 1,
|
||
'single assistant carries the order-list reconciliation: ' . json_encode($check, JSON_UNESCAPED_UNICODE));
|
||
aiMcpPerfExpect(str_contains($result['content'][0]['text'], '对账') && str_contains($result['content'][0]['text'], '成交订单是 2 单'), 'reconciliation is spelled out in the text');
|
||
$data = $ok($mgr, 'zyt_perf_doctors', $feb + ['name' => '张'])['structuredContent'];
|
||
aiMcpPerfExpect(!isset($data['order_list']), 'doctor reconciliation is skipped for accounts that only see their own orders in the list');
|
||
$check = $ok($root, 'zyt_perf_doctors', $feb + ['name' => '业绩张'])['structuredContent']['order_list'] ?? null;
|
||
aiMcpPerfExpect($check !== null && $check['total'] === 3 && $check['not_counted'] === 1, 'doctor reconciliation for accounts that see all orders: ' . json_encode($check, JSON_UNESCAPED_UNICODE));
|
||
|
||
// ---------------------------------------------------------------- 医生排行
|
||
$data = $ok($mgr, 'zyt_perf_doctors', $feb)['structuredContent'];
|
||
$rows = $byId($data['rows']);
|
||
aiMcpPerfExpect(array_keys($rows) === [92002, 92003], 'manager sees the two doctors of its department, ranked: ' . json_encode(array_keys($rows)));
|
||
aiMcpPerfExpect($rows[92002]['deal_amount'] == 1400 && $rows[92002]['deal_order_count'] === 2, 'doctor amount excludes cancelled orders');
|
||
aiMcpPerfExpect($rows[92003]['deal_amount'] == 700 && $rows[92003]['deal_order_count'] === 1, 'doctor amount excludes refunded orders');
|
||
aiMcpPerfExpect(($data['chart']['labels'] ?? []) === ['业绩张医生', '业绩李医生'] && ($data['chart']['unit'] ?? '') === '元', 'doctor ranking chart');
|
||
|
||
// ---------------------------------------------------------------- 部门业绩看板
|
||
$data = $ok($mgr, 'zyt_stats_performance', $feb)['structuredContent'];
|
||
$amounts = array_column($data['rows'], 'performance_amount', 'dept_id');
|
||
aiMcpPerfExpect(($amounts[9922] ?? null) == 1700 && ($amounts[9923] ?? null) == 2900, 'department board: ' . json_encode($amounts));
|
||
aiMcpPerfExpect(($data['chart']['series'][0]['data'] ?? []) == [2900, 1700], 'department chart sorted by amount');
|
||
|
||
// ---------------------------------------------------------------- 业绩走势
|
||
$data = $ok($mgr, 'zyt_perf_trend', $feb + ['names' => '业绩王医助,业绩钱医助', 'granularity' => 'day'])['structuredContent'];
|
||
aiMcpPerfExpect(count($data['periods']) === 28 && $data['periods'][0] === '02-01' && count($data['series']) === 2, 'daily buckets for February');
|
||
[$wang, $qian] = $data['series'];
|
||
aiMcpPerfExpect($wang['admin_id'] === 92004 && $wang['amount'][2] == 1000 && $wang['amount'][9] == 700 && $wang['total_amount'] == 1700 && $wang['total_orders'] === 2, 'assistant daily trend: ' . json_encode($wang));
|
||
aiMcpPerfExpect($qian['amount'][9] == 2500 && $qian['amount'][19] == 400 && $qian['total_amount'] == 2900, 'second series');
|
||
aiMcpPerfExpect(($data['chart']['type'] ?? '') === 'line' && count($data['chart']['series']) === 2 && count($data['chart']['labels']) === 28, 'trend chart is a two-series line');
|
||
$data = $ok($mgr, 'zyt_perf_trend', $feb)['structuredContent'];
|
||
aiMcpPerfExpect(count($data['series']) === 1 && $data['series'][0]['total_amount'] == 4600 && $data['granularity'] === 'day', 'trend without names sums the visible scope');
|
||
$data = $ok($mgr, 'zyt_perf_trend', $feb + ['names' => '业绩王医助', 'granularity' => 'week'])['structuredContent'];
|
||
aiMcpPerfExpect($data['periods'] === ['02-01~02-02', '02-03~02-09', '02-10~02-16', '02-17~02-23', '02-24~02-28'] && $data['series'][0]['amount'] == [0, 1000, 700, 0, 0], 'weekly buckets start on Monday: ' . json_encode($data['periods']));
|
||
$data = $ok($mgr, 'zyt_perf_trend', $feb + ['granularity' => 'month', 'metric' => 'orders'])['structuredContent'];
|
||
aiMcpPerfExpect($data['periods'] === ['2月'] && ($data['chart']['type'] ?? '') === 'column' && $data['chart']['series'][0]['data'] == [4], 'single month becomes a column chart of order counts');
|
||
$data = $ok($mgr, 'zyt_perf_trend', $feb + ['by' => 'doctor'])['structuredContent'];
|
||
aiMcpPerfExpect($data['series'][0]['total_amount'] == 2100 && $data['series'][0]['total_orders'] === 3, 'doctor trend uses the doctor amount rule');
|
||
$data = $ok($asst, 'zyt_perf_trend', $feb)['structuredContent'];
|
||
aiMcpPerfExpect($data['series'][0]['name'] === '业绩王医助' && $data['series'][0]['total_amount'] == 1700, 'assistant trend is named after itself');
|
||
$result = $call($asst, 'zyt_perf_trend', $feb + ['names' => '业绩钱医助']);
|
||
aiMcpPerfExpect(!empty($result['isError']) && str_contains($result['content'][0]['text'], '找不到'), 'assistant cannot look at a colleague outside its scope');
|
||
$result = $call($mgr, 'zyt_perf_trend', $feb + ['names' => 'a,b,c,d,e']);
|
||
aiMcpPerfExpect(!empty($result['isError']) && str_contains($result['content'][0]['text'], '最多'), 'at most four people');
|
||
|
||
// ---------------------------------------------------------------- 参数校验
|
||
foreach ([['sort_by' => 'nope'], ['period' => 'someday'], ['start_date' => '2031-02-10', 'end_date' => '2031-02-01'], ['start_date' => '2031/02/01'], ['dept' => '不存在的部门']] as $bad) {
|
||
$result = $call($mgr, 'zyt_perf_assistants', $bad);
|
||
aiMcpPerfExpect(!empty($result['isError']), 'invalid arguments are rejected: ' . json_encode($bad, JSON_UNESCAPED_UNICODE));
|
||
}
|
||
$data = $ok($mgr, 'zyt_perf_assistants', ['period' => 'this_month'])['structuredContent'];
|
||
aiMcpPerfExpect($data['period']['start_date'] === date('Y-m-01') && $data['period']['end_date'] === date('Y-m-d'), 'period shortcut resolves on the server');
|
||
|
||
// ---------------------------------------------------------------- 权限:未登记时回退到 Tab 权限
|
||
aiMcpPerfExpect(!in_array('zyt_perf_assistants', $toolNames($lim), true), 'tab permission alone does not open the leaderboard while its own permission is registered');
|
||
$result = $call($lim, 'zyt_perf_assistants', $feb);
|
||
aiMcpPerfExpect(!empty($result['isError']) && str_contains($result['content'][0]['text'], '无权限'), 'calling it anyway is denied');
|
||
Db::name('system_menu')->where('id', $leaderboardMenu)->update(['is_disable' => 1]);
|
||
try {
|
||
aiMcpPerfExpect(in_array('zyt_perf_assistants', $toolNames($lim), true), 'with the leaderboard permission unregistered, the tab permission applies');
|
||
$data = $ok($lim, 'zyt_perf_assistants', $feb)['structuredContent'];
|
||
aiMcpPerfExpect(count($data['rows']) === 2, 'fallback permission returns the same data scope');
|
||
aiMcpPerfExpect(!in_array('zyt_perf_doctors', $toolNames($lim), true), 'no doctor tools without doctor permissions');
|
||
} finally {
|
||
Db::name('system_menu')->where('id', $leaderboardMenu)->update(['is_disable' => 0]);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- params 里写分页
|
||
$result = $ok($root, 'zyt_query', ['resource' => 'doctor.appointment/lists', 'params' => ['start_date' => '2026-01-01', 'end_date' => '2026-12-31', 'page_size' => 2, 'page' => 1]]);
|
||
aiMcpPerfExpect(($result['structuredContent']['page_size'] ?? 0) === 2, 'page_size inside params is treated as paging');
|
||
// 自带分页的统计明细:外层 page/page_size 生效(以前被忽略,翻页永远拿到第一页,看起来像同一批记录反复出现)
|
||
$lines = static fn (int $page) => $ok($root, 'zyt_query', ['resource' => 'stats.yejiStats/appointmentLines', 'params' => $feb + ['assistant_id' => 92004], 'page' => $page, 'page_size' => 1])['structuredContent']['result'];
|
||
[$first, $second] = [$lines(1), $lines(2)];
|
||
aiMcpPerfExpect(($first['paging'] ?? null) == ['total' => 2, 'page' => 1, 'page_size' => 1, 'has_more' => true] && ($second['paging']['has_more'] ?? null) === false,
|
||
'report paging follows the outer page arguments: ' . json_encode([$first['paging'] ?? null, $second['paging'] ?? null]));
|
||
aiMcpPerfExpect(count($first['lists']) === 1 && count($second['lists']) === 1 && $first['lists'][0]['id'] !== $second['lists'][0]['id'], 'page 2 returns the next record, not page 1 again');
|
||
|
||
// ---------------------------------------------------------------- 审计
|
||
$logged = Db::name('ai_access_log')->where('admin_id', 92001)->where('tool', 'zyt_perf_assistants')->where('client_task_id', 'perf-task')->order('id', 'desc')->find();
|
||
aiMcpPerfExpect($logged && $logged['resource'] === 'stats.yejiStats/leaderboard' && $logged['status'] === 'ok', 'performance calls are audited');
|
||
|
||
echo "AiMcpPerfTest OK\n";
|