This commit is contained in:
Your Name
2026-08-18 14:08:38 +08:00
parent 8b9df1154c
commit bc1228a310
77 changed files with 10763 additions and 1181 deletions
@@ -627,10 +627,35 @@ class AppointmentLogic extends BaseLogic
* @param array $params
* @return array
*/
public static function reception(array $params): array
{
// 1) 挂号详情(已包含 patient_name / patient_phone / doctor_name / status_desc 等)
$appointment = self::detail($params);
public static function reception(array $params, int $adminId, array $adminInfo): array
{
self::$error = '';
$appointmentId = (int) ($params['id'] ?? 0);
$appointmentRow = $appointmentId > 0
? Appointment::where('id', $appointmentId)->field(['id', 'patient_id', 'doctor_id'])->find()
: null;
if (!$appointmentRow) {
self::setError('预约记录不存在或无权访问');
return [];
}
$diagnosisRow = Diagnosis::where('id', (int) $appointmentRow->patient_id)
->whereNull('delete_time')
->field(['id', 'assistant_id'])
->find();
if (!self::appointmentRowManageableByAdmin(
$appointmentRow,
$diagnosisRow ?: null,
$adminId,
$adminInfo
)) {
self::setError('预约记录不存在或无权访问');
return [];
}
// 1) 挂号详情(已包含 patient_name / patient_phone / doctor_name / status_desc 等)
$appointment = self::detail($params);
if (empty($appointment)) {
return [];
}
@@ -883,40 +908,62 @@ class AppointmentLogic extends BaseLogic
/**
* 与 AppointmentLists 一致的可见性(不含 progress_board / diag_scope_relax
*/
private static function appointmentRowManageableByAdmin(
Appointment $appointment,
?Diagnosis $diag,
int $adminId,
array $adminInfo
): bool {
$roleIds = array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id'));
if (in_array(1, $roleIds, true) && (int) $appointment->doctor_id !== $adminId) {
return false;
}
if (in_array(2, $roleIds, true)) {
$asst = $diag ? (int) $diag->assistant_id : 0;
if ($asst !== $adminId) {
return false;
}
}
if (!DataScopeService::isEnabled()) {
return true;
}
$ids = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
if ($ids === []) {
return false;
}
if ($ids === null) {
return true;
}
$docId = (int) $appointment->doctor_id;
$asstId = $diag ? (int) $diag->assistant_id : 0;
return in_array($docId, $ids, true)
|| ($asstId > 0 && in_array($asstId, $ids, true));
}
private static function appointmentRowManageableByAdmin(
Appointment $appointment,
?Diagnosis $diag,
int $adminId,
array $adminInfo
): bool {
$docId = (int) $appointment->doctor_id;
$asstId = $diag ? (int) $diag->assistant_id : 0;
$isRoot = !empty($adminInfo['root']) && (int) $adminInfo['root'] === 1;
$roleIds = $isRoot
? []
: array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id'));
$visibleIds = null;
if (!$isRoot && DataScopeService::isEnabled()) {
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
}
return self::appointmentRowManageableForScope(
$docId,
$asstId,
$adminId,
$roleIds,
$visibleIds,
$isRoot
);
}
/**
* @param array<int, int> $roleIds
* @param array<int, int>|null $visibleIds null 表示未启用数据范围或全量可见
*/
private static function appointmentRowManageableForScope(
int $doctorId,
int $assistantId,
int $adminId,
array $roleIds,
?array $visibleIds,
bool $isRoot
): bool {
if ($isRoot) {
return true;
}
if (in_array(1, $roleIds, true) && $doctorId !== $adminId) {
return false;
}
if (in_array(2, $roleIds, true) && $assistantId !== $adminId) {
return false;
}
if ($visibleIds === []) {
return false;
}
return $visibleIds === null
|| in_array($doctorId, $visibleIds, true)
|| ($assistantId > 0 && in_array($assistantId, $visibleIds, true));
}
/**
* 后台编辑挂号(预约日期/时段/类型/状态/备注/医助)
@@ -25,8 +25,8 @@ class DoctorNoteLogic extends BaseLogic
->find();
$newContent = trim($params['content'] ?? '');
$newImages = array_map([self::class, 'toRelativePath'], self::parseJsonArray($params['tongue_images'] ?? []));
$newReports = array_map([self::class, 'toRelativePath'], self::parseJsonArray($params['report_files'] ?? []));
$newImages = self::normalizeNewAttachmentPaths($params['tongue_images'] ?? []);
$newReports = self::normalizeNewAttachmentPaths($params['report_files'] ?? []);
if ($existing) {
$data = [];
@@ -169,15 +169,51 @@ class DoctorNoteLogic extends BaseLogic
*/
private static function toRelativePath(string $url): string
{
if (empty($url)) return $url;
if (stripos($url, 'http://') !== 0 && stripos($url, 'https://') !== 0) {
$url = trim($url);
if ($url === '') return $url;
$urlParts = parse_url($url);
if (!is_array($urlParts) || empty($urlParts['scheme'])) {
return $url;
}
$scheme = strtolower((string) $urlParts['scheme']);
if (!in_array($scheme, ['http', 'https'], true)) {
return $url;
}
// 获取当前存储域名
$domain = self::getStorageDomain();
if ($domain && stripos($url, rtrim($domain, '/')) === 0) {
$relative = substr($url, strlen(rtrim($domain, '/')));
return ltrim($relative, '/');
$domain = rtrim(self::getStorageDomain(), '/');
$domainParts = $domain !== '' ? parse_url($domain) : false;
if (is_array($domainParts)) {
$domainScheme = strtolower((string) ($domainParts['scheme'] ?? ''));
$urlHost = strtolower(rtrim((string) ($urlParts['host'] ?? ''), '.'));
$domainHost = strtolower(rtrim((string) ($domainParts['host'] ?? ''), '.'));
$urlPort = (int) ($urlParts['port'] ?? ($scheme === 'https' ? 443 : 80));
$domainPort = (int) (
$domainParts['port'] ?? ($domainScheme === 'https' ? 443 : 80)
);
$urlPath = (string) ($urlParts['path'] ?? '');
$domainPath = rtrim((string) ($domainParts['path'] ?? ''), '/');
$pathInsideDomain = $domainPath === ''
|| $urlPath === $domainPath
|| str_starts_with($urlPath, $domainPath . '/');
if (
$domainScheme === $scheme
&& $domainHost !== ''
&& $domainHost === $urlHost
&& $domainPort === $urlPort
&& $pathInsideDomain
) {
$relative = $domainPath === ''
? $urlPath
: substr($urlPath, strlen($domainPath));
if (isset($urlParts['query']) && $urlParts['query'] !== '') {
$relative .= '?' . $urlParts['query'];
}
return ltrim($relative, '/');
}
}
// 非当前存储域名,保留完整 URL
return $url;
@@ -193,6 +229,36 @@ class DoctorNoteLogic extends BaseLogic
return $storage ? ($storage['domain'] ?? '') : '';
}
/**
* 备注附件只接受站内相对路径或当前存储域已上传的 URL。
* 存储域 URL 先转为相对路径,避免将任意外部 URL 持久化到病例页。
*
* @param mixed $value
* @return array<int, string>
*/
private static function normalizeNewAttachmentPaths($value): array
{
$paths = [];
foreach (self::parseJsonArray($value) as $rawPath) {
$path = trim((string) $rawPath);
if ($path === '') {
continue;
}
$path = self::toRelativePath($path);
$scheme = parse_url($path, PHP_URL_SCHEME);
if (
(is_string($scheme) && $scheme !== '')
|| str_starts_with($path, '//')
|| str_contains($path, "\0")
) {
throw new \InvalidArgumentException('备注附件必须来自当前文件存储域');
}
$paths[] = $path;
}
return array_values(array_unique($paths));
}
private static function parseJsonArray($value): array
{
if (is_array($value)) return $value;
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace app\adminapi\logic\firstvisit;
use app\adminapi\logic\auth\AuthLogic;
use app\adminapi\logic\dept\DeptLogic;
use app\adminapi\logic\stats\ConversionLogic;
use app\adminapi\logic\stats\YejiStatsLogic;
@@ -23,6 +24,9 @@ use think\facade\Db;
class FirstVisitConversionLogic
{
private const ASSISTANT_ROLE_ID = 2;
private const FINANCE_PERMISSION = 'firstvisit.conversion/viewFinance';
private const FINANCE_ALWAYS_ROLE_NAMES = ['经理', '管理员', '系统管理员'];
private const FINANCE_FIELD_KEYS = ['account_cost', 'cash_cost', 'roi'];
/** @return array<string,mixed> */
public static function overview(array $params, int $adminId, array $adminInfo): array
@@ -64,11 +68,11 @@ class FirstVisitConversionLogic
$effectiveAdminIds = self::intersectVisibleIds($effectiveAdminIds, $deptAdminIds);
}
$selectedAssistantValid = $selectedAssistantId <= 0;
if ($selectedAssistantId > 0) {
$selectedAssistantValid = self::isActiveAssistant($selectedAssistantId)
&& ($effectiveAdminIds === null || in_array($selectedAssistantId, $effectiveAdminIds, true));
$effectiveAdminIds = $selectedAssistantValid ? [$selectedAssistantId] : [];
$selectedAssistantValid = $selectedAssistantId <= 0;
if ($selectedAssistantId > 0) {
$selectedAssistantValid = self::isActiveAssistant($selectedAssistantId)
&& ($effectiveAdminIds === null || in_array($selectedAssistantId, $effectiveAdminIds, true));
$effectiveAdminIds = $selectedAssistantValid ? [$selectedAssistantId] : [];
}
$costAllocationAdminIds = self::costAllocationAdminIds(
$effectiveAdminIds,
@@ -137,23 +141,36 @@ class FirstVisitConversionLogic
(int) $summary['total_open_count']
);
$rankingKind = self::rankingKind($scopeValue, $selectedAssistantId);
$rankingRows = self::rankingRows($rows, $rankingKind);
$rankingKind = self::rankingKind($scopeValue, $selectedAssistantId);
$rankingRows = self::rankingRows($rows, $rankingKind);
// 目前只维护了部门月度目标;本人范围或筛选单个员工时不能拿整个部门目标冒充个人目标。
$targetDeptIds = ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0)
? []
: self::resolveTargetDeptIds($allowedDeptSet, $selectedDeptIds, $selectedDeptId);
$target = self::buildTargetProgress((int) date('Y'), $effectiveAdminIds, $targetDeptIds);
$selectedDeptName = $selectedDeptId > 0 && $deptSelectionValid
? (string) (Db::name('dept')->where('id', $selectedDeptId)->whereNull('delete_time')->value('name') ?? '')
: '';
$selectedAssistantName = $selectedAssistantId > 0 && $selectedAssistantValid
? (string) (Admin::where('id', $selectedAssistantId)->whereNull('delete_time')->value('name') ?? '')
: '';
$selectedDeptName = $selectedDeptId > 0 && $deptSelectionValid
? (string) (Db::name('dept')->where('id', $selectedDeptId)->whereNull('delete_time')->value('name') ?? '')
: '';
$selectedAssistantName = $selectedAssistantId > 0 && $selectedAssistantValid
? (string) (Admin::where('id', $selectedAssistantId)->whereNull('delete_time')->value('name') ?? '')
: '';
$selectedMediaChannelName = $selectedMediaChannelCode !== ''
? (string) ($selectedMediaChannel['channel_name'] ?? $selectedMediaChannelCode)
: '';
if ($selectedMediaChannelName !== '' && !empty($selectedMediaChannel['is_group'])) {
$selectedMediaChannelName .= '(全部)';
}
$canViewFinance = self::canViewFinance($adminId, $adminInfo);
if (!$canViewFinance) {
$summary = self::maskFinanceFields($summary);
foreach ($rows as &$row) {
if (is_array($row)) {
$row = self::maskFinanceFields($row);
}
}
unset($row);
}
return [
'meta' => [
'time_type' => $timeType,
@@ -161,9 +178,9 @@ class FirstVisitConversionLogic
'start_date' => $startDate,
'end_date' => $endDate,
'generated_at' => date('Y-m-d H:i:s'),
'scope_value' => $scopeValue,
'scope_label' => DataScopeService::scopeLabel($scopeValue),
'ranking_kind' => $rankingKind,
'scope_value' => $scopeValue,
'scope_label' => DataScopeService::scopeLabel($scopeValue),
'ranking_kind' => $rankingKind,
'selected_dept_name' => $selectedDeptName,
'selected_assistant_name' => $selectedAssistantName,
'selected_media_channel_code' => $selectedMediaChannelCode,
@@ -171,6 +188,7 @@ class FirstVisitConversionLogic
'open_count_source' => $selectedMediaChannelCode === ''
? '个人业绩录入'
: '个人业绩录入(按渠道名称匹配)',
'can_view_finance' => $canViewFinance,
'appointment_rule' => '按预约日期统计,归属优先挂号医助、再回退诊单医助;仅含已预约、已完成和已过号',
'registration_rule' => '按支付时间统计已支付且实收金额大于 0、低于 10 元的订单,每笔计 1 个挂号并按订单创建人归属',
'performance_rule' => '按业务订单创建时间和创建人统计,排除取消、拒收、退款及已发生退款的订单',
@@ -520,45 +538,101 @@ class FirstVisitConversionLogic
return [];
}
$values = [
$channelCode,
$channel['channel_name'] ?? '',
$channel['source_tag_name'] ?? '',
$channel['legacy_channel_name'] ?? '',
$channel['legacy_source_tag_name'] ?? '',
];
foreach (['channel_codes', 'channel_names', 'source_tag_names'] as $listKey) {
if (!isset($channel[$listKey]) || !is_array($channel[$listKey])) {
continue;
}
foreach ($channel[$listKey] as $item) {
$values[] = $item;
}
}
return array_values(array_unique(array_filter(array_map(
static fn ($value): string => trim((string) $value),
[
$channelCode,
$channel['channel_name'] ?? '',
$channel['source_tag_name'] ?? '',
$channel['legacy_channel_name'] ?? '',
$channel['legacy_source_tag_name'] ?? '',
]
), static fn (string $value): bool => $value !== '')));
$values
), static fn (string $value): bool => $value !== '' && !str_starts_with($value, MediaChannelService::GROUP_CODE_PREFIX))));
}
/** 根据生效数据范围返回排行榜展示维度,不能把 scope_value 当作角色枚举。 */
private static function rankingKind(int $scopeValue, int $selectedAssistantId = 0): string
{
if ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0) {
return 'hidden';
}
return $scopeValue === DataScopeService::SCOPE_DEPT ? 'member' : 'group';
}
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
private static function rankingRows(array $rows, string $rankingKind): array
{
if ($rankingKind === 'hidden') {
return [];
}
// “仅本部门”范围使用可见成员维度;更大范围使用当前可见组织根节点的
// 直属下级,避免父子汇总同时参与占比。
if ($rankingKind === 'member') {
$members = [];
self::collectRankingMembers($rows, $members);
return array_values($members);
}
// lists 里可能同时存在“未绑定/未分配部门”等虚拟根节点。它们会让顶层节点数量
private static function canViewFinance(int $adminId, array $adminInfo): bool
{
if ((int) ($adminInfo['root'] ?? 0) === 1) {
return true;
}
foreach (self::roleNamesFromAdminInfo($adminInfo) as $roleName) {
if (in_array($roleName, self::FINANCE_ALWAYS_ROLE_NAMES, true)) {
return true;
}
}
if ($adminId <= 0) {
return false;
}
return in_array(self::FINANCE_PERMISSION, AuthLogic::getAuthByAdminId($adminId), true);
}
/** @return string[] */
private static function roleNamesFromAdminInfo(array $adminInfo): array
{
$names = preg_split('/[\/,,、]/u', (string) ($adminInfo['role_name'] ?? '')) ?: [];
return array_values(array_filter(array_map('trim', $names), static fn (string $name): bool => $name !== ''));
}
/**
* @param array<string, mixed> $entity
* @return array<string, mixed>
*/
private static function maskFinanceFields(array $entity): array
{
foreach (self::FINANCE_FIELD_KEYS as $key) {
unset($entity[$key]);
}
if (isset($entity['children']) && is_array($entity['children'])) {
foreach ($entity['children'] as &$child) {
if (is_array($child)) {
$child = self::maskFinanceFields($child);
}
}
unset($child);
}
return $entity;
}
/** 根据生效数据范围返回排行榜展示维度,不能把 scope_value 当作角色枚举。 */
private static function rankingKind(int $scopeValue, int $selectedAssistantId = 0): string
{
if ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0) {
return 'hidden';
}
return $scopeValue === DataScopeService::SCOPE_DEPT ? 'member' : 'group';
}
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
private static function rankingRows(array $rows, string $rankingKind): array
{
if ($rankingKind === 'hidden') {
return [];
}
// “仅本部门”范围使用可见成员维度;更大范围使用当前可见组织根节点的
// 直属下级,避免父子汇总同时参与占比。
if ($rankingKind === 'member') {
$members = [];
self::collectRankingMembers($rows, $members);
return array_values($members);
}
// lists 里可能同时存在“未绑定/未分配部门”等虚拟根节点。它们会让顶层节点数量
// 大于 1,导致原逻辑无法展开唯一的真实组织根节点,图表最终只显示医院汇总行。
$visibleRows = array_values(array_filter($rows, static function (array $row): bool {
return (int) ($row['id'] ?? 0) > 0 && !((bool) ($row['_virtual_bucket'] ?? false));
@@ -583,59 +657,59 @@ class FirstVisitConversionLogic
$chartRows[] = $row;
}
return $chartRows;
}
/**
* @param array<int,array<string,mixed>> $rows
* @param array<int,array<string,mixed>> $members
*/
private static function collectRankingMembers(array $rows, array &$members): void
{
foreach ($rows as $row) {
if ((string) ($row['type'] ?? '') === 'member') {
$adminId = (int) ($row['admin_id'] ?? 0);
if ($adminId > 0) {
$members[$adminId] = $row;
}
continue;
}
self::collectRankingMembers(
is_array($row['children'] ?? null) ? $row['children'] : [],
$members
);
}
}
return $chartRows;
}
/**
* @param array<int,array<string,mixed>> $rows
* @param array<int,array<string,mixed>> $members
*/
private static function collectRankingMembers(array $rows, array &$members): void
{
foreach ($rows as $row) {
if ((string) ($row['type'] ?? '') === 'member') {
$adminId = (int) ($row['admin_id'] ?? 0);
if ($adminId > 0) {
$members[$adminId] = $row;
}
continue;
}
self::collectRankingMembers(
is_array($row['children'] ?? null) ? $row['children'] : [],
$members
);
}
}
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
private static function topRows(array $rows, string $metric): array
{
$rows = array_values(array_filter($rows, static function (array $row): bool {
if ((string) ($row['type'] ?? '') === 'member') {
return (int) ($row['admin_id'] ?? 0) > 0;
}
return (int) ($row['id'] ?? 0) > 0;
}));
usort($rows, static function (array $left, array $right) use ($metric): int {
$valueCompare = (float) ($right[$metric] ?? 0) <=> (float) ($left[$metric] ?? 0);
if ($valueCompare !== 0) {
return $valueCompare;
}
$nameCompare = strnatcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
if ($nameCompare !== 0) {
return $nameCompare;
}
return strcmp((string) ($left['id'] ?? ''), (string) ($right['id'] ?? ''));
});
return array_map(static fn (array $row): array => [
'id' => $row['id'] ?? 0,
'name' => (string) ($row['name'] ?? ''),
'value' => round((float) ($row[$metric] ?? 0), 2),
], $rows);
}
private static function topRows(array $rows, string $metric): array
{
$rows = array_values(array_filter($rows, static function (array $row): bool {
if ((string) ($row['type'] ?? '') === 'member') {
return (int) ($row['admin_id'] ?? 0) > 0;
}
return (int) ($row['id'] ?? 0) > 0;
}));
usort($rows, static function (array $left, array $right) use ($metric): int {
$valueCompare = (float) ($right[$metric] ?? 0) <=> (float) ($left[$metric] ?? 0);
if ($valueCompare !== 0) {
return $valueCompare;
}
$nameCompare = strnatcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
if ($nameCompare !== 0) {
return $nameCompare;
}
return strcmp((string) ($left['id'] ?? ''), (string) ($right['id'] ?? ''));
});
return array_map(static fn (array $row): array => [
'id' => $row['id'] ?? 0,
'name' => (string) ($row['name'] ?? ''),
'value' => round((float) ($row[$metric] ?? 0), 2),
], $rows);
}
/** @param int[]|null $baseVisibleAdminIds @param int[] $selectedDeptIds @return array<int,array{id:int,name:string}> */
private static function assistantOptions(?array $baseVisibleAdminIds, array $selectedDeptIds, int $selectedDeptId): array
@@ -62,7 +62,9 @@ class ConversionLogic
if ($mediaChannel === null && $requestedMediaChannelCode !== '') {
$mediaChannel = MediaChannelService::getChannelByCode($requestedMediaChannelCode);
}
$mediaChannelCode = $mediaChannel !== null ? $requestedMediaChannelCode : '';
$mediaChannelCodes = $mediaChannel !== null
? MediaChannelService::getChannelCodesForStats($mediaChannel)
: null;
$filterEmptyEntities = $mediaChannel !== null;
[$startTimestamp, $endTimestamp, $startDate, $endDate] = self::resolveTimeRange($params);
$pageNo = max(1, (int)($params['page_no'] ?? 1));
@@ -188,11 +190,11 @@ class ConversionLogic
);
// 数据隔离:可见部门 = 可见 admin 所属部门并集;用于 account_cost 与下游 cost 分摊。
$visibleDeptIds = self::resolveVisibleDeptIds($visibleAdminIds);
[$globalAccountCost, $accountCostDeptIds] = self::hydrateAccountCostStats($entities, $startDate, $endDate, $mediaChannelCode, $visibleDeptIds);
[$globalAccountCost, $accountCostDeptIds] = self::hydrateAccountCostStats($entities, $startDate, $endDate, $mediaChannelCodes, $visibleDeptIds);
$supportsDeptBinding = AccountCost::supportsDeptBinding();
$restrictAccountCostByDept = $supportsDeptBinding;
$channelBoundDeptIds = $supportsDeptBinding && $mediaChannelCode !== ''
? self::loadChannelBoundDeptIds($mediaChannelCode)
$channelBoundDeptIds = $supportsDeptBinding && $mediaChannelCodes !== null && $mediaChannelCodes !== []
? self::loadChannelBoundDeptIds($mediaChannelCodes)
: [];
// 渠道尚未维护投放成本时,不能把真实的加粉、挂号和订单一并过滤为空。
// 已维护绑定关系的渠道继续按绑定部门收窄;成本本身仍只在实际成本部门内分摊。
@@ -267,7 +269,7 @@ class ConversionLogic
$startDate,
$endDate,
$mediaChannel,
$mediaChannelCode,
$mediaChannelCodes,
$restrictAccountCostByDept,
$eligibleDeptIds,
$adminToDeptIds,
@@ -824,17 +826,18 @@ class ConversionLogic
/**
* 渠道绑定部门不依赖当前统计区间,避免某天没有录入成本时把统计实体过滤为空。
*
* @param string[] $mediaChannelCodes
* @return int[]
*/
private static function loadChannelBoundDeptIds(string $mediaChannelCode): array
private static function loadChannelBoundDeptIds(array $mediaChannelCodes): array
{
$mediaChannelCode = trim($mediaChannelCode);
if ($mediaChannelCode === '' || !AccountCost::supportsDeptBinding()) {
$mediaChannelCodes = self::normalizeMediaChannelCodes($mediaChannelCodes);
if ($mediaChannelCodes === [] || !AccountCost::supportsDeptBinding()) {
return [];
}
$deptIds = Db::name('account_cost')
->where('media_channel_code', $mediaChannelCode)
->whereIn('media_channel_code', $mediaChannelCodes)
->where('dept_id', '>', 0)
->distinct(true)
->column('dept_id');
@@ -1596,6 +1599,7 @@ class ConversionLogic
* 账户消耗:来源于独立维护表 zyt_account_cost。
*
* @param array<int, array<string, mixed>> $entities
* @param string[]|null $mediaChannelCodes null=不按渠道过滤;[]=已选渠道但无匹配 code,成本记 0
* @param int[]|null $visibleDeptIds 可见部门集合(null = SCOPE_ALL,不收窄)
* @return array{0: float, 1: int[]}
*/
@@ -1603,10 +1607,21 @@ class ConversionLogic
array &$entities,
string $startDate,
string $endDate,
string $mediaChannelCode,
?array $mediaChannelCodes,
?array $visibleDeptIds = null
): array
{
$mediaChannelCodes = $mediaChannelCodes === null ? null : self::normalizeMediaChannelCodes($mediaChannelCodes);
if ($mediaChannelCodes === []) {
foreach ($entities as &$entity) {
$entity['account_cost'] = 0.0;
$entity['_global_account_cost'] = 0.0;
}
unset($entity);
return [0.0, []];
}
$supportsDeptBinding = AccountCost::supportsDeptBinding();
$query = Db::name('account_cost')
->where('cost_date', '>=', $startDate)
@@ -1618,8 +1633,8 @@ class ConversionLogic
$query->field('amount');
}
if ($mediaChannelCode !== '') {
$query->where('media_channel_code', $mediaChannelCode);
if ($mediaChannelCodes !== null) {
$query->whereIn('media_channel_code', $mediaChannelCodes);
}
if ($supportsDeptBinding) {
@@ -2049,7 +2064,7 @@ class ConversionLogic
string $startDate,
string $endDate,
?array $mediaChannel,
string $mediaChannelCode,
?array $mediaChannelCodes,
bool $restrictAccountCostByDept,
array $eligibleDeptIds,
array $adminToDeptIds,
@@ -2081,9 +2096,9 @@ class ConversionLogic
if ($globalAccountCost < 0) {
// 任选一个非空 entity 集合查一次即可——查询本身只与日期 / 渠道相关。
if ($assistantIds !== []) {
[$globalAccountCost] = self::hydrateAccountCostStats($assistantEntities, $startDate, $endDate, $mediaChannelCode);
[$globalAccountCost] = self::hydrateAccountCostStats($assistantEntities, $startDate, $endDate, $mediaChannelCodes);
} elseif ($doctorIds !== []) {
[$globalAccountCost] = self::hydrateAccountCostStats($doctorEntities, $startDate, $endDate, $mediaChannelCode);
[$globalAccountCost] = self::hydrateAccountCostStats($doctorEntities, $startDate, $endDate, $mediaChannelCodes);
} else {
$globalAccountCost = 0.0;
}
@@ -2905,10 +2920,29 @@ class ConversionLogic
return [
'code' => (string)($mediaChannel['channel_code'] ?? ''),
'tag_id' => (string)($mediaChannel['source_tag_id'] ?? ''),
'tag_ids' => $mediaChannel['source_tag_ids'] ?? [],
'tag_name' => (string)($mediaChannel['source_tag_name'] ?? ''),
];
}
/**
* @param string|string[] $mediaChannelCode
* @return string[]
*/
private static function normalizeMediaChannelCodes(string|array $mediaChannelCode): array
{
$values = is_array($mediaChannelCode) ? $mediaChannelCode : [$mediaChannelCode];
$codes = [];
foreach ($values as $value) {
$code = trim((string)$value);
if ($code !== '' && !str_starts_with($code, MediaChannelService::GROUP_CODE_PREFIX)) {
$codes[$code] = $code;
}
}
return array_values($codes);
}
/**
* @param int[]|null $visibleAdminIds
* @param int[] $eligibleDeptIds
@@ -4,13 +4,12 @@ declare(strict_types=1);
namespace app\adminapi\logic\tcm;
use app\common\cache\AdminAuthCache;
use app\common\logic\BaseLogic;
use app\common\model\auth\AdminRole;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\DiagnosisAiReport;
use app\common\service\DataScope\DataScopeService;
use app\common\service\DifyChatService;
use app\common\cache\AdminAuthCache;
use app\adminapi\logic\firstvisit\MyPatientLogic;
use app\common\logic\BaseLogic;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\DiagnosisAiReport;
use app\common\service\DifyChatService;
use think\facade\Db;
use think\facade\Log;
@@ -201,6 +200,44 @@ class DiagnosisAiLogic extends BaseLogic
string $prompt,
int $adminId,
array $adminInfo
): ?array {
$prepared = self::prepareAssistant($diagnosisId, $task, $prompt, $adminId, $adminInfo);
if ($prepared === null) {
return null;
}
try {
$result = DifyChatService::chat(
$prepared['profile'],
$prepared['inputs'],
$prepared['query'],
$prepared['user']
);
} catch (\Throwable $e) {
self::logAssistantFailure($diagnosisId, $prepared['profile'], $adminId, $e);
self::setError('AI 助手暂时不可用,请稍后重试');
return null;
}
return self::formatAssistantResult($prepared, $result);
}
/**
* 在 SSE headers 发出前完成参数、权限、DataScope、病例和模型选择预检。
* 返回值只供同一请求内的流执行使用,绝不能直接序列化给客户端。
*
* @param array<string,mixed> $adminInfo
* @return array{
* diagnosis_id:int,profile:string,model_name:string,model_label:string,task:string,
* inputs:array<string,mixed>,query:string,user:string,admin_id:int
* }|null
*/
public static function prepareAssistant(
int $diagnosisId,
string $task,
string $prompt,
int $adminId,
array $adminInfo
): ?array {
$diagnosis = self::loadAuthorizedDiagnosis(
$diagnosisId,
@@ -239,28 +276,63 @@ class DiagnosisAiLogic extends BaseLogic
return null;
}
return [
'diagnosis_id' => $diagnosisId,
'profile' => $profile,
'model_name' => $model,
'model_label' => $modelLabel,
'task' => $task,
'inputs' => self::buildUpstreamInputs(
$context,
'病例问诊助手',
self::ASSISTANT_PROMPT_VERSION
),
'query' => self::buildAssistantPrompt($context, $task, $prompt),
'user' => 'admin-diagnosis-assistant-' . $adminId,
'admin_id' => $adminId,
];
}
/**
* @param array<string,mixed> $prepared prepareAssistant() 的内部返回值
* @param callable(string):mixed $onDelta
* @param callable():bool|null $shouldAbort
* @return array{answer:string,model_key:string,model_label:string,model_name:string,task:string}|null
*/
public static function streamPreparedAssistant(
array $prepared,
callable $onDelta,
?callable $shouldAbort = null
): ?array {
$diagnosisId = (int) ($prepared['diagnosis_id'] ?? 0);
$profile = (string) ($prepared['profile'] ?? '');
$adminId = (int) ($prepared['admin_id'] ?? 0);
try {
$result = DifyChatService::chat(
$result = DifyChatService::streamChat(
$profile,
self::buildUpstreamInputs(
$context,
'病例问诊助手',
self::ASSISTANT_PROMPT_VERSION
),
self::buildAssistantPrompt($context, $task, $prompt),
'admin-diagnosis-assistant-' . $adminId
is_array($prepared['inputs'] ?? null) ? $prepared['inputs'] : [],
(string) ($prepared['query'] ?? ''),
(string) ($prepared['user'] ?? ''),
$onDelta,
$shouldAbort
);
} catch (\Throwable $e) {
Log::warning('diagnosis ai assistant upstream call failed', [
'diagnosis_id' => $diagnosisId,
'profile' => $profile,
'admin_id' => $adminId,
'exception_class' => get_class($e),
]);
self::logAssistantFailure($diagnosisId, $profile, $adminId, $e);
self::setError('AI 助手暂时不可用,请稍后重试');
return null;
}
return self::formatAssistantResult($prepared, $result);
}
/**
* @param array<string,mixed> $prepared
* @param array<string,mixed> $result
* @return array{answer:string,model_key:string,model_label:string,model_name:string,task:string}|null
*/
private static function formatAssistantResult(array $prepared, array $result): ?array
{
if (empty($result['ok'])) {
self::setError((string) ($result['error'] ?? 'AI 助手暂时不可用,请稍后重试'));
return null;
@@ -273,13 +345,27 @@ class DiagnosisAiLogic extends BaseLogic
return [
'answer' => $content,
'model_key' => $profile,
'model_label' => $modelLabel,
'model_name' => $model,
'task' => $task,
'model_key' => (string) ($prepared['profile'] ?? ''),
'model_label' => (string) ($prepared['model_label'] ?? ''),
'model_name' => (string) ($prepared['model_name'] ?? ''),
'task' => (string) ($prepared['task'] ?? ''),
];
}
private static function logAssistantFailure(
int $diagnosisId,
string $profile,
int $adminId,
\Throwable $exception
): void {
Log::warning('diagnosis ai assistant upstream call failed', [
'diagnosis_id' => $diagnosisId,
'profile' => $profile,
'admin_id' => $adminId,
'exception_class' => get_class($exception),
]);
}
/**
* 接诊台结构化 AI 智能分析。每次只调用客户端白名单键对应的服务端模型,
* 上游失败或响应不符合契约时直接失败,不构造本地伪分析。
@@ -604,28 +690,10 @@ class DiagnosisAiLogic extends BaseLogic
return null;
}
$accessQuery = Diagnosis::where('id', $id)->whereNull('delete_time');
$isRoot = !empty($adminInfo['root']) && (int) $adminInfo['root'] === 1;
if (!$isRoot) {
$roleIds = array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id'));
if (in_array(2, $roleIds, true)) {
$accessQuery->where('assistant_id', $adminId);
}
if (DataScopeService::isEnabled()) {
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
if ($visibleIds === []) {
self::setError('诊单不存在或无权访问');
return null;
}
if (is_array($visibleIds)) {
$accessQuery->whereIn('assistant_id', $visibleIds);
}
}
if (!MyPatientLogic::canAccessDiagnosis($id, $adminId, $adminInfo)) {
self::setError('诊单不存在或无权访问');
return null;
}
if (!$accessQuery->find()) {
self::setError('诊单不存在或无权访问');
return null;
}
$diagnosis = DiagnosisLogic::detail(['id' => $id], $adminInfo);
if ($diagnosis === [] || empty($diagnosis['id'])) {
@@ -28,10 +28,11 @@ use app\common\model\DiagnosisViewRecord;
use app\common\model\doctor\Appointment;
use app\common\model\auth\Admin;
use app\common\model\auth\AdminRole;
use app\adminapi\logic\auth\AuthLogic;
use app\adminapi\logic\doctor\DoctorNoteLogic;
use app\adminapi\logic\doctor\AppointmentLogic;
use app\adminapi\logic\tcm\TrackingNoteLogic;
use app\adminapi\logic\auth\AuthLogic;
use app\adminapi\logic\doctor\DoctorNoteLogic;
use app\adminapi\logic\doctor\AppointmentLogic;
use app\adminapi\logic\firstvisit\MyPatientLogic;
use app\adminapi\logic\tcm\TrackingNoteLogic;
use app\common\service\ConfigService;
use app\common\service\FileService;
use app\common\service\DataScope\DataScopeService;
@@ -4182,32 +4183,10 @@ class DiagnosisLogic extends BaseLogic
return [];
}
// 1) 数据权限闸 — 不通过则返回「不存在或无权访问」
$accessQuery = Diagnosis::where('id', $diagnosisId)->whereNull('delete_time');
$roleIds = array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id'));
if (in_array(2, $roleIds, true)) {
// 医助仅看自己被指派的
$accessQuery->where('assistant_id', $adminId);
}
if (DataScopeService::isEnabled()) {
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
if ($visibleIds === []) {
self::setError('诊单不存在或无权访问');
return [];
}
if (is_array($visibleIds)) {
$accessQuery->whereIn('assistant_id', $visibleIds);
}
}
if (!$accessQuery->find()) {
self::setError('诊单不存在或无权访问');
return [];
}
// 1) 数据权限闸 — 不通过则返回「不存在或无权访问」
if (!self::canViewReadonlyDiagnosis($diagnosisId, $adminId, $adminInfo)) {
return [];
}
// 2) 诊单详情(含图片聚合等)+ 字典翻译
$diagnosis = self::detail(['id' => $diagnosisId]);
@@ -4238,24 +4217,43 @@ class DiagnosisLogic extends BaseLogic
$unservedDays = $maxRecordTs > 0 ? max(0, (int) floor((time() - $maxRecordTs) / 86400)) : null;
$lastBloodRecordAt = $maxRecordTs > 0 ? date('Y-m-d', $maxRecordTs) : null;
return [
return [
'appointment' => $appointment,
'diagnosis' => $diagnosis,
'doctor_notes' => $doctorNotes,
'tracking_notes' => $trackingNotes,
'unserved_days' => $unservedDays,
'last_blood_record_at' => $lastBloodRecordAt,
];
}
/**
];
}
/**
* 与 readonlyDetail 共用的诊单行级可见性入口。
*
* 复用“我的患者”统一行权策略:医生按有效接诊关系,医助按归属关系,
* 团队管理角色才使用 DataScope。不存在与越权使用同一错误避免枚举。
*/
public static function canViewReadonlyDiagnosis(int $diagnosisId, int $adminId, array $adminInfo): bool
{
self::$error = '';
if (!MyPatientLogic::canAccessDiagnosis($diagnosisId, $adminId, $adminInfo)) {
self::setError('诊单不存在或无权访问');
return false;
}
return true;
}
/**
* 取指定日期区间内的三类跟踪记录(血糖血压 / 饮食 / 运动),供 readonlyDetail 与
* 医生接诊台 reception 通过独立接口 lazy load。
*
* 区间语义:闭区间 [startDate, endDate]Y-m-d),均不传则不限。
*
* @return array{
* blood_records: array<int,array<string,mixed>>,
* diagnosis_id: int,
* blood_records: array<int,array<string,mixed>>,
* diet_records: array<int,array<string,mixed>>,
* exercise_records: array<int,array<string,mixed>>,
* start_date: string,
@@ -4267,10 +4265,11 @@ class DiagnosisLogic extends BaseLogic
$sinceTs = $startDate !== '' ? (int) strtotime($startDate . ' 00:00:00') : 0;
$untilTs = $endDate !== '' ? (int) strtotime($endDate . ' 23:59:59') : 0;
$sinceTs = $sinceTs > 0 ? $sinceTs : 0;
$untilTs = $untilTs > 0 ? $untilTs : 0;
return [
'blood_records' => self::fetchBloodRecordsForReadonly($diagnosisId, $sinceTs, $untilTs),
$untilTs = $untilTs > 0 ? $untilTs : 0;
return [
'diagnosis_id' => $diagnosisId,
'blood_records' => self::fetchBloodRecordsForReadonly($diagnosisId, $sinceTs, $untilTs),
'diet_records' => self::fetchDietRecordsForReadonly($diagnosisId, $sinceTs, $untilTs),
'exercise_records' => self::fetchExerciseRecordsForReadonly($diagnosisId, $sinceTs, $untilTs),
'start_date' => $startDate,
@@ -2,9 +2,10 @@
declare(strict_types=1);
namespace app\adminapi\logic\tcm;
use app\common\model\auth\Admin;
namespace app\adminapi\logic\tcm;
use app\adminapi\logic\firstvisit\MyPatientLogic;
use app\common\model\auth\Admin;
use app\common\model\doctor\Appointment;
use app\common\model\doctor\Medicine as DoctorMedicine;
use app\common\model\tcm\Prescription;
@@ -934,14 +935,35 @@ class PrescriptionLogic
/**
* 根据诊单ID获取处方列表
*/
public static function listByDiagnosis(int $diagnosisId): array
{
return Prescription::where('diagnosis_id', $diagnosisId)
->whereNull('delete_time')
->order('id', 'desc')
->select()
->toArray();
}
public static function listByDiagnosis(int $diagnosisId, int $viewerAdminId, array $viewerAdminInfo): array
{
self::$error = '';
if (!MyPatientLogic::canAccessDiagnosis($diagnosisId, $viewerAdminId, $viewerAdminInfo)) {
self::setError('诊单不存在或无权访问');
return [];
}
$rows = Prescription::where('diagnosis_id', $diagnosisId)
->whereNull('delete_time')
->order('id', 'desc')
->select()
->toArray();
return self::filterViewablePrescriptions($rows, $viewerAdminId, $viewerAdminInfo);
}
/**
* @param array<int, array<string,mixed>> $rows
* @return array<int, array<string,mixed>>
*/
private static function filterViewablePrescriptions(array $rows, int $viewerAdminId, array $viewerAdminInfo): array
{
return array_values(array_filter(
$rows,
static fn (array $row): bool => self::canViewPrescription($row, $viewerAdminId, $viewerAdminInfo)
));
}
/**
* 根据预约ID获取处方(带权限检查)