Files
zyt/server/app/mcp/service/LogisticsTools.php
T
2026-09-28 10:04:24 +08:00

434 lines
25 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\tcm\PrescriptionOrderLogic;
use app\common\model\ExpressTracking;
use app\common\service\ExpressTrackingService;
use app\common\service\ExpressTrackService;
use think\facade\Db;
/**
* 物流查询(2026-09-24 增补):zyt_logistics_order 按业务订单号 / 快递单号查一张处方业务订单的物流,
* zyt_logistics_patient 按患者查其最近订单的物流。
* - 订单只从「处方业务订单」列表里找(以调用账号身份执行后台原列表),能查到哪些订单与后台列表完全一致;
* 后台逐单接口(详情、物流轨迹)的 canAccessOrder 对任一订单权限直接放行,这里不用它来判断能不能看。
* - 轨迹需要后台「物流轨迹」权限(tcm.prescriptionOrder/logisticsTrace);没有时只给快递公司、单号和订单状态。
* - 轨迹优先读系统已同步的数据(express_tracking / express_trace,由快递同步任务和甘草、洛阳药房回调写入),在只读事务里查询,
* 并写明这张运单最后一次同步的时间和来源。
* - 系统里还没有这张运单的轨迹时,按需实时查询(live,默认开启):与后台打开订单详情时自动查询的是同一路径
* (PrescriptionOrderLogic::logisticsTrace → 快递100 / 京东官方接口,不写库),每个账号每小时有次数上限,
* .env [AI_MCP] LOGISTICS_LIVE = false 可关闭。
* - 收件人电话按权限脱敏,只给省市区、不给详细地址;轨迹文字里的手机号同样脱敏。
*/
class LogisticsTools
{
private const TOOLS = ['zyt_logistics_order', 'zyt_logistics_patient'];
private const ORDER_LIST = 'tcm.prescriptionOrder/lists';
private const TRACE_PERM = 'tcm.prescriptionOrder/logisticsTrace';
private const FULFILLMENT = [1 => '待双审通过', 2 => '待发货', 3 => '已完成', 4 => '已取消', 5 => '已发货', 6 => '已签收', 7 => '进行中',
8 => '暂不制药', 9 => '拒收', 10 => '退款', 11 => '保留药方', 12 => '制药缓发'];
private const CARRIERS = ['sf' => '顺丰速运', 'jd' => '京东快递', 'jt' => '极兔速递', 'yt' => '圆通速递', 'zt' => '中通快递', 'yd' => '韵达快递',
'st' => '申通快递', 'yz' => '中国邮政', 'ems' => 'EMS'];
private const SOURCES = ['kuaidi100' => '快递100', 'jd_official' => '京东物流官方接口', 'official_only' => '仅有官网查询链接', 'gancao' => '甘草药房',
'ej_pharmacy' => '洛阳药房'];
private const SUPPLY = ['gancao' => '甘草药房代发', 'direct' => '洛阳药房直发'];
private const NOTE = '物流数据来自系统同步(快递接口、甘草/洛阳药房回调)或实时查询;updated_at 是这张运单最后一次同步的时间。'
. '订单已完成、取消或运单已签收后系统不再更新轨迹。tracking_page 是快递公司官网的查询页。';
public static function handles(string $name): bool
{
return in_array($name, self::TOOLS, true);
}
/** 能查处方业务订单列表的账号才有这两个工具 */
public static function definitions(Identity $identity): array
{
if (self::orderList($identity) === null) {
return [];
}
$live = ['type' => 'boolean', 'description' => '系统里还没有这张运单的轨迹时,是否实时向快递公司查询(默认是;每小时有次数上限)'];
return [
Tools::tool('zyt_logistics_order', '查一张处方业务订单的物流:快递公司、运单号、订单履约状态、物流状态(在途/派件中/已签收等)、签收时间和物流轨迹。'
. '用业务订单号(如 PO2026…)或快递单号查询;只能查到当前账号在后台「处方业务订单」里看得到的订单。轨迹优先用系统已同步的数据,结果里写明更新时间。', [
'order_no' => ['type' => 'string', 'description' => '业务订单号,如 PO20260921154848894908'],
'tracking_no' => ['type' => 'string', 'description' => '快递单号,如 JDVE19085931055(与 order_no 二选一)'],
'live' => $live,
'trace_limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100, 'description' => '最多返回多少条轨迹,最新的在前(默认 30)'],
]),
Tools::tool('zyt_logistics_patient', '查一位患者最近的处方业务订单物流:每单的快递公司、运单号、履约状态、最新物流动态和是否签收,并给出最近一单已发货订单的完整轨迹。'
. '用患者姓名、手机号或患者ID查询,同名的不同患者会分开列出;只含当前账号在后台看得到的订单。', [
'patient' => ['type' => 'string', 'description' => '患者姓名或手机号(可以只写一部分)'],
'patient_id' => ['type' => 'integer', 'description' => '患者ID(诊单上的 patient_id;同名时用它区分)'],
'start_date' => ['type' => 'string', 'description' => '订单创建日期起 YYYY-MM-DD(可选)'],
'end_date' => ['type' => 'string', 'description' => '订单创建日期止 YYYY-MM-DD(可选)'],
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 20, 'description' => '最多列出几单,最新的在前(默认 10)'],
'live' => $live,
]),
];
}
public static function call(Identity $identity, string $name, array $args, array &$audit): array
{
$resource = self::orderList($identity);
if ($resource === null) {
throw new McpException('当前账号不能查询处方业务订单,所以也查不了物流', 'denied');
}
$audit['resource'] = self::ORDER_LIST;
return $name === 'zyt_logistics_order' ? self::order($identity, $resource, $args, $audit) : self::patient($identity, $resource, $args, $audit);
}
private static function orderList(Identity $identity): ?array
{
$resource = Catalog::get(self::ORDER_LIST);
return $resource !== null && Catalog::denialFor($identity, $resource) === null ? $resource : null;
}
private static function order(Identity $identity, array $resource, array $args, array &$audit): array
{
$orderNo = self::code($args['order_no'] ?? '', '业务订单号');
$trackingNo = self::code($args['tracking_no'] ?? '', '快递单号');
if ($orderNo === '' && $trackingNo === '') {
throw new McpException('请提供业务订单号 order_no 或快递单号 tracking_no', 'invalid');
}
// 列表里订单号是模糊匹配、快递关键字同时匹配快递公司:取回后再按原值精确筛
$rows = self::find($identity, $resource, $orderNo !== '' ? ['order_no' => $orderNo] : ['express_keyword' => $trackingNo], 20);
$rows = array_values(array_filter($rows, static fn (array $row) => $orderNo !== ''
? strcasecmp(trim((string) ($row['order_no'] ?? '')), $orderNo) === 0
: strcasecmp(trim((string) ($row['tracking_number'] ?? '')), $trackingNo) === 0));
$wanted = $orderNo !== '' ? '业务订单 ' . $orderNo : '快递单号 ' . $trackingNo;
if (!$rows) {
return self::result($identity, $wanted . ':没有找到,或这张订单不在当前账号「处方业务订单」的可见范围内。', ['found' => 0], $audit);
}
$canTrace = $identity->can(self::TRACE_PERM);
$limit = max(1, min(100, (int) ($args['trace_limit'] ?? 30)));
$orders = [];
foreach (array_slice($rows, 0, 3) as $row) {
$orders[] = self::withTrace($identity, $row, $canTrace, self::liveWanted($args), $limit);
}
$summary = implode("\n", array_map([self::class, 'line'], $orders));
if (count($rows) > 1) {
$summary .= "\n(这个快递单号关联了 " . count($rows) . ' 张订单)';
}
return self::result($identity, $summary, ['found' => count($rows), 'orders' => $orders, 'note' => self::NOTE], $audit, $orders);
}
private static function patient(Identity $identity, array $resource, array $args, array &$audit): array
{
$keyword = mb_substr(trim((string) ($args['patient'] ?? '')), 0, 30);
$patientId = max(0, (int) ($args['patient_id'] ?? 0));
if ($keyword === '' && $patientId === 0) {
throw new McpException('请提供患者姓名或手机号 patient,或患者ID patient_id', 'invalid');
}
$filter = $patientId > 0 ? ['patient_id' => $patientId] : ['patient_keyword' => $keyword];
$start = self::date($args['start_date'] ?? '', 'start_date');
$end = self::date($args['end_date'] ?? '', 'end_date');
if ($start !== '' || $end !== '') {
// 列表按创建时间筛选时需要起止两端;只给一端时补成一年内
$end = $end !== '' ? $end : date('Y-m-d');
$start = $start !== '' ? $start : date('Y-m-d', (int) strtotime($end . ' -365 days'));
$filter['start_time'] = $start . ' 00:00:00';
$filter['end_time'] = $end . ' 23:59:59';
}
$rows = self::find($identity, $resource, $filter, max(1, min(20, (int) ($args['limit'] ?? 10))));
$who = $patientId > 0 ? '患者ID ' . $patientId : '「' . $keyword . '」';
if (!$rows) {
return self::result($identity, $who . ':没有找到处方业务订单,或不在当前账号「处方业务订单」的可见范围内。', ['found' => 0], $audit);
}
$canTrace = $identity->can(self::TRACE_PERM);
$diagnoses = self::diagnoses(array_map(static fn (array $row) => (int) ($row['diagnosis_id'] ?? 0), $rows));
$stored = $canTrace ? self::storedTracking(array_map(static fn (array $row) => trim((string) ($row['tracking_number'] ?? '')), $rows)) : [];
$groups = [];
$orders = [];
foreach ($rows as $row) {
$diagnosis = $diagnoses[(int) ($row['diagnosis_id'] ?? 0)] ?? [];
$key = (int) ($diagnosis['patient_id'] ?? 0) ?: -(int) ($row['diagnosis_id'] ?? 0);
$groups[$key] ??= ['patient_id' => max(0, $key), 'patient_name' => (string) ($diagnosis['patient_name'] ?? ''),
'phone' => (string) ($diagnosis['phone'] ?? ''), 'orders' => []];
$order = self::basic($row);
$order['logistics'] = self::latest($stored[$order['tracking_number']] ?? null, $order, $canTrace);
$groups[$key]['orders'][] = $order;
$orders[] = $order;
}
$groups = array_values($groups);
$data = ['found' => count($rows), 'patients' => $groups, 'note' => self::NOTE];
$lines = [];
foreach ($groups as $group) {
$lines[] = sprintf('患者 %s(ID %s)最近 %d 单:', $group['patient_name'] ?: '未知', $group['patient_id'] ?: '无', count($group['orders']));
foreach ($group['orders'] as $order) {
$lines[] = '- ' . self::line($order);
}
}
if (count($groups) > 1) {
$lines[] = '有 ' . count($groups) . ' 位患者匹配' . $who . ',请确认是哪一位(可用 patient_id 查询)后再看完整轨迹。';
} else {
// 只有一位患者:给最近一张有快递单号的订单的完整轨迹
foreach ($rows as $row) {
if (trim((string) ($row['tracking_number'] ?? '')) !== '') {
$data['latest_shipment'] = self::withTrace($identity, $row, $canTrace, self::liveWanted($args), 30);
$lines[] = '最近一单的物流:' . self::line($data['latest_shipment']);
break;
}
}
}
return self::result($identity, implode("\n", $lines), $data, $audit, $orders);
}
/** 以调用账号身份查处方业务订单列表(权限、数据范围与后台一致) */
private static function find(Identity $identity, array $resource, array $filter, int $size): array
{
Tools::assertQuota($identity, $size);
$envelope = Dispatcher::call($identity, $resource, Tools::listParams($resource, Tools::params($resource, $filter, true), 1, $size));
if ($envelope['code'] !== 1) {
throw new McpException(Tools::failText($resource, $envelope), 'denied');
}
return array_values(array_filter((array) ($envelope['data']['lists'] ?? []), 'is_array'));
}
private static function basic(array $row): array
{
$status = (int) ($row['fulfillment_status'] ?? 0);
$company = strtolower(trim((string) ($row['express_company'] ?? '')));
$carrier = $company !== '' ? (self::CARRIERS[$company] ?? $company) : trim((string) ($row['gancao_shipping_name'] ?? ''));
$region = trim(implode('', array_map(static fn ($k) => trim((string) ($row[$k] ?? '')), ['shipping_province', 'shipping_city', 'shipping_district'])));
return [
'order_id' => (int) ($row['id'] ?? 0),
'order_no' => (string) ($row['order_no'] ?? ''),
'created_at' => self::time($row['create_time'] ?? 0),
'fulfillment_text' => self::FULFILLMENT[$status] ?? '未知',
'supply' => self::SUPPLY[(string) ($row['ship_mode'] ?? '')] ?? '',
'express_company' => $carrier,
'tracking_number' => trim((string) ($row['tracking_number'] ?? '')),
'recipient_name' => (string) ($row['recipient_name'] ?? ''),
'recipient_phone' => (string) ($row['recipient_phone'] ?? ''),
'recipient_region' => $region,
];
}
/** 一张订单的完整物流:已同步的轨迹,没有时按需实时查询 */
private static function withTrace(Identity $identity, array $row, bool $canTrace, bool $live, int $limit): array
{
$order = self::basic($row);
$number = $order['tracking_number'];
if ($number === '') {
$order['logistics'] = ['status' => '还没有快递单号', 'note' => '订单状态:' . $order['fulfillment_text']];
return $order;
}
$order['tracking_page'] = self::trackingPage($number, (string) ($row['express_company'] ?? ''));
if (!$canTrace) {
$order['logistics'] = ['status' => '无权查看物流轨迹', 'note' => '当前账号没有后台「物流轨迹」权限,只能看到快递公司和单号'];
return $order;
}
$stored = Dispatcher::readOnly(static fn () => ExpressTrackingService::getDetailByTrackingNumber($number));
if ($stored && !empty($stored['traces'])) {
$order['logistics'] = self::fromStored($stored, $limit);
return $order;
}
if (!$live || !McpConfig::logisticsLive()) {
$order['logistics'] = ['status' => '系统里暂无这张运单的物流记录', 'note' => '可以打开快递公司官网查询'];
return $order;
}
if (!RateLimiter::hit('logistics_live_' . $identity->adminId, McpConfig::logisticsLivePerHour(), 3600)) {
$order['logistics'] = ['status' => '系统里暂无这张运单的物流记录',
'note' => '本小时的实时查询次数已用完(每小时 ' . McpConfig::logisticsLivePerHour() . ' 次),请稍后再试或打开快递公司官网查询'];
return $order;
}
// 与后台打开订单详情时相同:库里没有轨迹就向快递100 / 京东官方接口查询,不写库
$payload = Dispatcher::readOnly(static fn () => PrescriptionOrderLogic::logisticsTrace($order['order_id'], '', $identity->adminId, $identity->adminInfo));
$order['logistics'] = $payload === null
? ['status' => '实时查询失败', 'note' => PrescriptionOrderLogic::getError() ?: '快递公司没有返回结果']
: self::fromLive($payload, $limit);
return $order;
}
private static function fromStored(array $stored, int $limit): array
{
$state = (string) ($stored['current_state'] ?? '');
$traces = self::traces((array) $stored['traces'], 'trace_time', 'trace_context', $limit);
$delivered = $state === ExpressTracking::STATE_SIGNED;
return self::compact([
'status' => self::stateText($state, (string) ($stored['current_state_text'] ?? '')),
'delivered' => $delivered,
'signed_at' => $delivered ? ($traces[0]['time'] ?? '') : '',
'carrier' => (string) ($stored['express_company_name'] ?? ''),
'trace_count' => count((array) $stored['traces']),
'traces' => $traces,
'updated_at' => self::time($stored['last_query_time'] ?? 0) ?: self::time($stored['update_time'] ?? 0),
'source' => self::SOURCES[(string) ($stored['data_source'] ?? '')] ?? '系统已同步',
]);
}
private static function fromLive(array $payload, int $limit): array
{
$traces = self::traces((array) ($payload['traces'] ?? []), 'time', 'context', $limit);
if (!$traces) {
return self::compact(['status' => '快递公司暂无轨迹', 'note' => trim((string) ($payload['hint'] ?? '')) ?: '可能刚下单还没揽收,或单号与快递公司不匹配',
'source' => '实时查询']);
}
$state = (string) ($payload['state'] ?? '');
$delivered = $state === ExpressTracking::STATE_SIGNED;
return self::compact([
'status' => self::stateText($state, (string) ($payload['state_text'] ?? '')) ?: '有轨迹',
'delivered' => $delivered,
'signed_at' => $delivered ? ($traces[0]['time'] ?? '') : '',
'carrier' => (string) ($payload['carrier_label'] ?? ''),
'trace_count' => count((array) $payload['traces']),
'traces' => $traces,
'updated_at' => date('Y-m-d H:i'),
'source' => '实时查询',
]);
}
/** 列表里多张订单只看同步表的最新一条动态(不逐单查轨迹、不实时查询) */
private static function latest(?array $tracking, array $order, bool $canTrace): array
{
if ($order['tracking_number'] === '') {
return ['status' => '还没有快递单号'];
}
if (!$canTrace) {
return ['status' => '无权查看物流轨迹'];
}
if (!$tracking) {
return ['status' => '系统里暂无这张运单的物流记录'];
}
$state = (string) ($tracking['current_state'] ?? '');
return self::compact([
'status' => self::stateText($state, (string) ($tracking['current_state_text'] ?? '')),
'delivered' => $state === ExpressTracking::STATE_SIGNED,
'latest_time' => (string) ($tracking['latest_trace_time'] ?? ''),
'latest' => (string) ($tracking['latest_trace_context'] ?? ''),
'location' => (string) ($tracking['latest_location'] ?? ''),
'updated_at' => self::time($tracking['last_query_time'] ?? 0) ?: self::time($tracking['update_time'] ?? 0),
'source' => self::SOURCES[(string) ($tracking['data_source'] ?? '')] ?? '系统已同步',
]);
}
private static function storedTracking(array $numbers): array
{
$numbers = array_values(array_unique(array_filter($numbers, static fn ($n) => $n !== '')));
if (!$numbers) {
return [];
}
return (array) Dispatcher::readOnly(static fn () => Db::name('express_tracking')->whereIn('tracking_number', $numbers)->whereNull('delete_time')
->column('tracking_number,express_company_name,current_state,current_state_text,latest_trace_time,latest_trace_context,latest_location,last_query_time,update_time,data_source', 'tracking_number'));
}
/** 订单所属诊单上的患者(患者ID、姓名、手机号);这些诊单都属于账号在列表里看得到的订单 */
private static function diagnoses(array $ids): array
{
$ids = array_values(array_unique(array_filter($ids)));
if (!$ids) {
return [];
}
return (array) Dispatcher::readOnly(static fn () => Db::name('tcm_diagnosis')->whereIn('id', $ids)->column('id,patient_id,patient_name,phone', 'id'));
}
private static function traces(array $rows, string $timeKey, string $textKey, int $limit): array
{
$out = [];
foreach (array_slice($rows, 0, $limit) as $row) {
if (!is_array($row)) {
continue;
}
$out[] = self::compact(['time' => (string) ($row[$timeKey] ?? $row['ftime'] ?? ''), 'context' => (string) ($row[$textKey] ?? ''),
'location' => (string) ($row['location'] ?? '')]);
}
return $out;
}
/** 摘要里的一行:订单号(履约状态);快递 单号;物流状态;签收时间或最新动态;数据更新时间 */
private static function line(array $order): string
{
$logistics = (array) ($order['logistics'] ?? []);
$parts = [$order['order_no'] . '(' . $order['fulfillment_text'] . ')'];
if ($order['tracking_number'] !== '') {
$parts[] = trim($order['express_company'] . ' ' . $order['tracking_number']);
}
$parts[] = (string) ($logistics['status'] ?? '');
$latest = $logistics['traces'][0] ?? (isset($logistics['latest']) ? ['time' => $logistics['latest_time'] ?? '', 'context' => $logistics['latest']] : null);
if (!empty($logistics['signed_at'])) {
$parts[] = '签收时间 ' . $logistics['signed_at'];
} elseif ($latest) {
$parts[] = '最新动态 ' . trim(($latest['time'] ?? '') . ' ' . mb_substr((string) ($latest['context'] ?? ''), 0, 80));
}
if (!empty($logistics['note'])) {
$parts[] = $logistics['note'];
}
if (!empty($logistics['updated_at'])) {
$parts[] = '数据更新于 ' . $logistics['updated_at'] . (!empty($logistics['source']) ? '(' . $logistics['source'] . ')' : '');
}
return implode(';', array_filter($parts, static fn ($p) => $p !== ''));
}
private static function result(Identity $identity, string $summary, array $data, array &$audit, array $orders = []): array
{
$policy = FieldPolicy::forIdentity($identity, 2000);
$data = $policy->apply($data);
$summary = $policy->maskFreeText($summary);
$ids = array_values(array_filter(array_map(static fn (array $o) => (int) ($o['order_id'] ?? 0), $orders)));
RateLimiter::addRows($identity->adminId, count($ids));
$audit['result_rows'] = count($ids);
$audit['record_ids'] = $ids;
return [
'content' => [['type' => 'text', 'text' => $summary . "\n" . json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)]],
'structuredContent' => $data ?: new \stdClass(),
'isError' => false,
];
}
private static function trackingPage(string $number, string $company): string
{
$urls = ExpressTrackService::officialUrls($number);
$company = strtolower(trim($company));
if (!isset($urls[$company])) {
$prefix = strtoupper(substr($number, 0, 2));
$company = $prefix === 'SF' ? 'sf' : ($prefix === 'JD' ? 'jd' : ($prefix === 'JT' ? 'jt' : ''));
}
return $urls[$company] ?? '';
}
private static function stateText(string $state, string $text): string
{
$text = trim($text);
return $text !== '' ? $text : ($state !== '' ? ExpressTracking::getStateText($state) : '');
}
private static function liveWanted(array $args): bool
{
return !array_key_exists('live', $args) || filter_var($args['live'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== false;
}
private static function code($value, string $label): string
{
$value = preg_replace('/\s+/u', '', (string) $value) ?? '';
if ($value !== '' && !preg_match('/^[A-Za-z0-9_\-]{4,64}$/', $value)) {
throw new McpException($label . '格式不正确(只能包含字母、数字和短横线)', 'invalid');
}
return $value;
}
private static function date($value, string $name): string
{
$value = trim((string) $value);
if ($value !== '' && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) || strtotime($value) === false)) {
throw new McpException($name . ' 需要 YYYY-MM-DD 格式', 'invalid');
}
return $value;
}
private static function time($value): string
{
if (is_numeric($value)) {
return (int) $value > 0 ? date('Y-m-d H:i', (int) $value) : '';
}
return mb_substr(trim((string) $value), 0, 16);
}
/** 去掉空值(保留 false 和 0) */
private static function compact(array $data): array
{
return array_filter($data, static fn ($v) => $v !== '' && $v !== null && $v !== []);
}
}