Files
zyt/server/app/adminapi/logic/stats/DoctorDailyStatsLogic.php
T
2026-05-28 12:18:14 +08:00

622 lines
24 KiB
PHP
Executable File
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\adminapi\logic\stats;
use app\adminapi\logic\stats\YejiStatsLogic;
use app\common\model\auth\Admin;
use app\common\service\DataScope\DataScopeService;
use think\facade\Db;
/**
* 医生统计:日期区间、渠道/标签、数据范围与业绩看板一致;当显式传入 dept_ids 时,
* **以部门下的医助为入口**收窄聚合(部门 → admin_dept 命中的医助集合 → 该医助经手的挂号 / 订单 / 处方 → 医生)。
*
* 医生范围:<b>admin_role.role_id = 1</b> 且管理员 <b>未软删</b>delete_time 为空);再按账号「数据范围」收窄。
*
* - 系统/手动开方:tcm_prescription.prescription_date ∈ [start,end];渠道/标签与业绩渠道列同源 EXISTS / 诊单标签
* - 成交:订单 create_time、排除履约 4/9/10,按处方 creator_id;渠道/标签同 sumPerformance 口径
* - 挂号:appointment_date ∈ [start,end];选渠道时挂号 channels 命中字典值;标签渠道时 patient_id ∈ 标签诊单集
* - 部门:dept_ids 由 YejiStatsLogic::resolveSharedYejiFilterContext 解析后透出 adminToPrimary(含全部子级展开后的 admin 集合),
* 下方聚合在挂号 / 订单 / 处方表上用以下口径筛选医助:
* · 挂号:COALESCE(NULLIF(a.assistant_id,0), NULLIF(u.assistant_id,0)) ∈ 医助集合
* · 订单:o.creator_id ∈ 医助集合(与业绩归属同源)
* · 处方:tcm_diagnosis.assistant_id ∈ 医助集合(rx.diagnosis_id JOIN tcm_diagnosis
* 医生集合不被部门收窄(医生通常不挂在「中心」部门);展示行最终在前端按所有医生输出,仅在 dept_filter 激活时隐藏「全 0」医生。
*/
class DoctorDailyStatsLogic
{
/**
* @param array{
* start_date?:string,
* end_date?:string,
* dept_ids?:int[]|string,
* channel_code?:string,
* tag_id?:string,
* doctor_id?:int|string
* } $params
*
* @return array{start_date:string,end_date:string,rows:array,total:array<string,mixed>}
*/
public static function overview(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array
{
// dept_ids 透传至共享上下文:未传时仍按默认「中心」树解析(仅用于挂号率默认 0 等兜底);
// 显式传入时由下方 $deptScopedAdminIds 分支用 adminToPrimary 取出医助集合,并下推到三类聚合作为「经手医助」筛选。
$ctx = YejiStatsLogic::resolveSharedYejiFilterContext($params, $viewerAdminId, $viewerAdminInfo);
$startDate = $ctx['startDate'];
$endDate = $ctx['endDate'];
$startTs = $ctx['startTs'];
$endTs = $ctx['endTs'];
$appointmentChannelValues = $ctx['appointmentChannelValues'];
$channelFilterActive = $ctx['channelFilterActive'];
$tagDiagIds = $ctx['tagDiagIds'];
$tagAssistantIds = $ctx['tagAssistantIds'];
$tagFallback = $ctx['tagFallback'];
$filterDoctorId = (int) ($params['doctor_id'] ?? 0);
$deptFilterActive = self::hasExplicitDeptIds($params['dept_ids'] ?? null);
$doctorIds = self::resolveDoctorAdminIdsForStats($viewerAdminId, $viewerAdminInfo);
if ($filterDoctorId > 0) {
$doctorIds = in_array($filterDoctorId, $doctorIds, true) ? [$filterDoctorId] : [];
}
// 部门下医助集合(含全部子级展开后的 admin_dept 命中者);未显式选部门时不参与筛选 → null。
// 显式选部门但集合为空 ⇒ 该部门下无可见医助,直接返回空结果。
$deptScopedAdminIds = null;
if ($deptFilterActive) {
$deptScopedAdminIds = array_values(array_unique(array_map(
'intval',
array_keys($ctx['adminToPrimary'] ?? [])
)));
if ($deptScopedAdminIds === []) {
return [
'start_date' => $startDate,
'end_date' => $endDate,
'rows' => [],
'total' => self::emptyTotals(),
];
}
}
if ($doctorIds === []) {
return [
'start_date' => $startDate,
'end_date' => $endDate,
'rows' => [],
'total' => self::emptyTotals(),
];
}
$rxMap = self::loadPrescriptionCounts(
$startDate,
$endDate,
$doctorIds,
$appointmentChannelValues,
$channelFilterActive,
$tagDiagIds,
$tagAssistantIds,
$tagFallback,
$deptScopedAdminIds
);
$orderMap = self::loadOrderAggregates(
$startTs,
$endTs,
$doctorIds,
$appointmentChannelValues,
$channelFilterActive,
$tagDiagIds,
$tagAssistantIds,
$tagFallback,
$deptScopedAdminIds
);
$apptMap = self::loadAppointmentAggregates(
$startDate,
$endDate,
$doctorIds,
$appointmentChannelValues,
$channelFilterActive,
$tagDiagIds,
$deptScopedAdminIds
);
$adminRows = Admin::whereIn('id', $doctorIds)
->whereNull('delete_time')
->field(['id', 'name'])
->order('id', 'asc')
->select()
->toArray();
$nameById = [];
foreach ($adminRows as $r) {
$nameById[(int) $r['id']] = (string) ($r['name'] ?? '');
}
$rows = [];
foreach ($doctorIds as $aid) {
$rx = $rxMap[$aid] ?? ['system' => 0, 'manual' => 0];
$ord = $orderMap[$aid] ?? ['amount' => 0.0, 'count' => 0];
$ap = $apptMap[$aid] ?? ['completed' => 0, 'missed' => 0, 'cancelled' => 0, 'total' => 0];
$cnt = (int) $ord['count'];
$amt = round((float) $ord['amount'], 2);
$apTotal = (int) ($ap['total'] ?? 0);
$rows[] = [
'admin_id' => $aid,
'doctor_name' => $nameById[$aid] ?? ('#' . $aid),
'system_prescription_count' => (int) $rx['system'],
'manual_prescription_count' => (int) $rx['manual'],
'deal_amount' => $amt,
'deal_order_count' => $cnt,
'avg_deal_amount' => $cnt > 0 ? round($amt / $cnt, 2) : null,
'appointment_total' => $apTotal,
'appointment_completed' => (int) $ap['completed'],
'appointment_missed' => (int) $ap['missed'],
'appointment_cancelled' => (int) $ap['cancelled'],
// 挂号率 = 成交单数 / 总挂号数 × 100;总挂号为 0 时返回 null(前端展示「—」)。
'appointment_conversion_rate' => $apTotal > 0 ? round($cnt / $apTotal * 100, 2) : null,
];
}
// 显式部门筛选时隐藏「该部门无任何关联」的医生,避免列出大量全 0 行。
if ($deptFilterActive) {
$rows = array_values(array_filter($rows, static function (array $r): bool {
return (int) ($r['system_prescription_count'] ?? 0) > 0
|| (int) ($r['manual_prescription_count'] ?? 0) > 0
|| (float) ($r['deal_amount'] ?? 0) > 0
|| (int) ($r['deal_order_count'] ?? 0) > 0
|| (int) ($r['appointment_total'] ?? 0) > 0;
}));
}
usort($rows, static function (array $a, array $b): int {
if (($a['deal_amount'] ?? 0) != ($b['deal_amount'] ?? 0)) {
return ($b['deal_amount'] ?? 0) <=> ($a['deal_amount'] ?? 0);
}
return strcmp((string) ($a['doctor_name'] ?? ''), (string) ($b['doctor_name'] ?? ''));
});
return [
'start_date' => $startDate,
'end_date' => $endDate,
'rows' => $rows,
'total' => self::sumTotals($rows),
];
}
/**
* 是否显式传入 dept_ids(含 1 个以上正整数即视为显式)。与 YejiStatsLogic::hasExplicitDeptIdsParam 同口径。
*
* @param mixed $raw
*/
private static function hasExplicitDeptIds($raw): bool
{
if (\is_string($raw) && trim($raw) !== '') {
foreach (explode(',', $raw) as $p) {
if ((int) trim($p) > 0) {
return true;
}
}
}
if (\is_array($raw)) {
foreach ($raw as $v) {
if ((int) $v > 0) {
return true;
}
}
}
return false;
}
/**
* 医生角色(role_id=1)、管理员未删除、且在数据范围内的 admin_id。
*
* @return int[]
*/
private static function resolveDoctorAdminIdsForStats(int $viewerAdminId, array $viewerAdminInfo): array
{
$roleDoctors = 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');
$doctorIds = array_values(array_unique(array_filter(array_map('intval', $roleDoctors), static function (int $v): bool {
return $v > 0;
})));
sort($doctorIds);
if ($viewerAdminId > 0 && DataScopeService::isEnabled()) {
$visibleIds = DataScopeService::getVisibleAdminIds($viewerAdminId, $viewerAdminInfo);
if ($visibleIds !== null) {
if ($visibleIds === []) {
return [];
}
$flip = array_flip($visibleIds);
$doctorIds = array_values(array_filter($doctorIds, static function (int $id) use ($flip): bool {
return isset($flip[$id]);
}));
}
}
return $doctorIds;
}
/**
* @return array<string, float|int|null>
*/
private static function emptyTotals(): array
{
return [
'system_prescription_count' => 0,
'manual_prescription_count' => 0,
'deal_amount' => 0.0,
'deal_order_count' => 0,
'avg_deal_amount' => null,
'appointment_total' => 0,
'appointment_completed' => 0,
'appointment_missed' => 0,
'appointment_cancelled' => 0,
'appointment_conversion_rate' => null,
];
}
/**
* @param array<int, array<string, mixed>> $rows
*
* @return array<string, float|int|null>
*/
private static function sumTotals(array $rows): array
{
$t = self::emptyTotals();
foreach ($rows as $r) {
$t['system_prescription_count'] += (int) ($r['system_prescription_count'] ?? 0);
$t['manual_prescription_count'] += (int) ($r['manual_prescription_count'] ?? 0);
$t['deal_amount'] += (float) ($r['deal_amount'] ?? 0);
$t['deal_order_count'] += (int) ($r['deal_order_count'] ?? 0);
$t['appointment_total'] += (int) ($r['appointment_total'] ?? 0);
$t['appointment_completed'] += (int) ($r['appointment_completed'] ?? 0);
$t['appointment_missed'] += (int) ($r['appointment_missed'] ?? 0);
$t['appointment_cancelled'] += (int) ($r['appointment_cancelled'] ?? 0);
}
$t['deal_amount'] = round((float) $t['deal_amount'], 2);
$dc = (int) $t['deal_order_count'];
$t['avg_deal_amount'] = $dc > 0 ? round((float) $t['deal_amount'] / $dc, 2) : null;
$apTotal = (int) $t['appointment_total'];
// 合计行挂号率:行行加总后再统一计算,与汇总后的 成交单数 / 总挂号数 对齐。
$t['appointment_conversion_rate'] = $apTotal > 0 ? round($dc / $apTotal * 100, 2) : null;
return $t;
}
/**
* @param int[] $appointmentChannelValues
* @param int[]|null $tagDiagIds
* @param array<int,int>|null $tagAssistantIds
* @param int[]|null $deptScopedAdminIds 部门下医助集合:非 null 时加 tcm_diagnosis.assistant_id IN (...) 约束
*
* @return array<int, array{system:int, manual:int}>
*/
private static function loadPrescriptionCounts(
string $startDate,
string $endDate,
array $doctorIds,
array $appointmentChannelValues,
bool $channelFilterActive,
?array $tagDiagIds,
?array $tagAssistantIds,
bool $tagFallback,
?array $deptScopedAdminIds = null
): array {
if (!self::tagScopeNonEmpty($tagDiagIds, $tagAssistantIds)) {
return [];
}
if ($deptScopedAdminIds !== null && $deptScopedAdminIds === []) {
return [];
}
$query = Db::name('tcm_prescription')
->alias('rx')
->whereNull('rx.delete_time')
->whereRaw('IFNULL(rx.void_status, 0) <> 1')
->whereBetween('rx.prescription_date', [$startDate, $endDate])
->whereIn('rx.creator_id', $doctorIds)
->where('rx.diagnosis_id', '>', 0);
// 部门 → 医助:仅保留经手医助归属在所选部门子树的处方(通过诊单 assistant_id 关联)
if ($deptScopedAdminIds !== null) {
$query->join('tcm_diagnosis dg', 'dg.id = rx.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->whereIn('dg.assistant_id', $deptScopedAdminIds);
}
self::applyPrescriptionChannelTagFilter(
$query,
'rx',
$appointmentChannelValues,
$channelFilterActive,
$tagDiagIds,
$tagAssistantIds,
$tagFallback
);
$query->field([
'rx.creator_id',
Db::raw('SUM(CASE WHEN IFNULL(rx.is_system_auto, 0) = 1 THEN 1 ELSE 0 END) AS system_cnt'),
Db::raw('SUM(CASE WHEN IFNULL(rx.is_system_auto, 0) <> 1 THEN 1 ELSE 0 END) AS manual_cnt'),
])->group('rx.creator_id');
$out = [];
foreach ($query->select()->toArray() as $r) {
$id = (int) ($r['creator_id'] ?? 0);
if ($id <= 0) {
continue;
}
$out[$id] = [
'system' => (int) ($r['system_cnt'] ?? 0),
'manual' => (int) ($r['manual_cnt'] ?? 0),
];
}
return $out;
}
/**
* @param int[] $appointmentChannelValues
* @param int[]|null $tagDiagIds
* @param array<int,int>|null $tagAssistantIds
* @param int[]|null $deptScopedAdminIds 部门下医助集合:非 null 时加 o.creator_id IN (...) 约束(与业绩归属同源)
*
* @return array<int, array{amount: float, count: int}>
*/
private static function loadOrderAggregates(
int $startTs,
int $endTs,
array $doctorIds,
array $appointmentChannelValues,
bool $channelFilterActive,
?array $tagDiagIds,
?array $tagAssistantIds,
bool $tagFallback,
?array $deptScopedAdminIds = null
): array {
if (!self::tagScopeNonEmpty($tagDiagIds, $tagAssistantIds)) {
return [];
}
if ($deptScopedAdminIds !== null && $deptScopedAdminIds === []) {
return [];
}
$q = Db::name('tcm_prescription_order')
->alias('o')
->join('tcm_prescription rx', 'rx.id = o.prescription_id AND rx.delete_time IS NULL', 'INNER')
->whereNull('o.delete_time')
->whereBetween('o.create_time', [$startTs, $endTs]);
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, 'o');
$q->whereIn('rx.creator_id', $doctorIds)
->where('o.diagnosis_id', '>', 0);
// 部门 → 医助:业绩归属同源(o.creator_id 即订单创建医助)
if ($deptScopedAdminIds !== null) {
$q->whereIn('o.creator_id', $deptScopedAdminIds);
}
$normCh = self::normalizeAppointmentChannelInts($appointmentChannelValues);
if ($normCh !== []) {
$apTable = self::tableWithPrefix('doctor_appointment');
$adminRoleTable = self::tableWithPrefix('admin_role');
$ph = implode(',', array_fill(0, count($normCh), '?'));
$strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $normCh)));
$channelCond = "ap.channels IN ({$ph})";
$existsSql = "EXISTS (SELECT 1 FROM {$apTable} ap INNER JOIN {$adminRoleTable} ar "
. "ON ar.admin_id = ap.assistant_id AND ar.role_id = 2 WHERE ap.patient_id = o.diagnosis_id "
. "AND ap.status = 3 AND {$channelCond})";
$q->whereRaw($existsSql, $strVals);
} elseif ($channelFilterActive) {
self::applyOrderTagFilter($q, 'o', 'rx', $tagDiagIds, $tagAssistantIds, $tagFallback);
}
$q->field([
'rx.creator_id',
Db::raw('SUM(o.amount) AS amount_sum'),
Db::raw('COUNT(*) AS order_cnt'),
])->group('rx.creator_id');
$out = [];
foreach ($q->select()->toArray() as $r) {
$id = (int) ($r['creator_id'] ?? 0);
if ($id <= 0) {
continue;
}
$out[$id] = [
'amount' => (float) ($r['amount_sum'] ?? 0),
'count' => (int) ($r['order_cnt'] ?? 0),
];
}
return $out;
}
/**
* 订单标签条件(无字典渠道映射时,与业绩 tag 分支一致;开方人维度用 rx.creator_id 兜底)。
*
* @param \think\db\Query $q
*/
private static function applyOrderTagFilter(
$q,
string $orderAlias,
string $rxAlias,
?array $tagDiagIds,
?array $tagAssistantIds,
bool $tagFallback
): void {
if ($tagDiagIds !== null) {
$q->whereIn("{$orderAlias}.diagnosis_id", $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$ids = array_keys($tagAssistantIds);
if ($tagFallback) {
$q->whereIn("{$rxAlias}.creator_id", array_map('intval', $ids));
} else {
$q->whereIn("{$orderAlias}.creator_id", array_map('intval', $ids));
}
}
}
/**
* @param \think\db\Query $query rx 别名查询
* @param string $rxAlias
* @param int[] $appointmentChannelValues
* @param int[]|null $tagDiagIds
* @param array<int,int>|null $tagAssistantIds
*/
private static function applyPrescriptionChannelTagFilter(
$query,
string $rxAlias,
array $appointmentChannelValues,
bool $channelFilterActive,
?array $tagDiagIds,
?array $tagAssistantIds,
bool $tagFallback
): void {
$normCh = self::normalizeAppointmentChannelInts($appointmentChannelValues);
if ($normCh !== []) {
$apTable = self::tableWithPrefix('doctor_appointment');
$adminRoleTable = self::tableWithPrefix('admin_role');
$ph = implode(',', array_fill(0, count($normCh), '?'));
$strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $normCh)));
$channelCond = "ap.channels IN ({$ph})";
$existsSql = "EXISTS (SELECT 1 FROM {$apTable} ap INNER JOIN {$adminRoleTable} ar "
. "ON ar.admin_id = ap.assistant_id AND ar.role_id = 2 WHERE ap.patient_id = {$rxAlias}.diagnosis_id "
. "AND ap.status = 3 AND {$channelCond})";
$query->whereRaw($existsSql, $strVals);
} elseif ($channelFilterActive) {
if ($tagDiagIds !== null) {
$query->whereIn("{$rxAlias}.diagnosis_id", $tagDiagIds);
}
if ($tagAssistantIds !== null && $tagFallback) {
$query->whereIn("{$rxAlias}.creator_id", array_map('intval', array_keys($tagAssistantIds)));
}
}
}
/**
* 标签范围显式为空(0 条诊单/医助)时整段统计不再查询。
*/
private static function tagScopeNonEmpty(?array $tagDiagIds, ?array $tagAssistantIds): bool
{
if ($tagDiagIds !== null && $tagDiagIds === []) {
return false;
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return false;
}
return true;
}
/**
* @param int[] $appointmentChannelValues
* @param int[]|null $tagDiagIds
* @param int[]|null $deptScopedAdminIds 部门下医助集合:非 null 时加
* COALESCE(NULLIF(a.assistant_id,0), NULLIF(u.assistant_id,0)) IN (...) 约束
*
* @return array<int, array{completed:int, missed:int, cancelled:int, total:int}>
*/
private static function loadAppointmentAggregates(
string $startDate,
string $endDate,
array $doctorIds,
array $appointmentChannelValues,
bool $channelFilterActive,
?array $tagDiagIds,
?array $deptScopedAdminIds = null
): array {
if ($deptScopedAdminIds !== null && $deptScopedAdminIds === []) {
return [];
}
$needsDiagJoin = $deptScopedAdminIds !== null;
if ($needsDiagJoin) {
$q = Db::name('doctor_appointment')->alias('a')
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
->whereBetween('a.appointment_date', [$startDate, $endDate])
->whereIn('a.doctor_id', $doctorIds)
->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)');
$aliasPrefix = 'a.';
} else {
$q = Db::name('doctor_appointment')
->whereBetween('appointment_date', [$startDate, $endDate])
->whereIn('doctor_id', $doctorIds);
$aliasPrefix = '';
}
$normCh = self::normalizeAppointmentChannelInts($appointmentChannelValues);
if ($normCh !== []) {
$strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $normCh)));
$q->whereIn($aliasPrefix . 'channels', $strVals);
} elseif ($channelFilterActive && $tagDiagIds !== null) {
if ($tagDiagIds === []) {
return [];
}
$q->whereIn($aliasPrefix . 'patient_id', $tagDiagIds);
}
// 部门 → 医助:与业绩看板 consultEffectiveAssistantSql 同口径 —— 优先挂号创建人,回退诊单医助
if ($needsDiagJoin) {
$effIds = array_map('intval', $deptScopedAdminIds);
$inList = implode(',', $effIds);
$q->whereRaw('COALESCE(NULLIF(a.assistant_id, 0), NULLIF(u.assistant_id, 0)) IN (' . $inList . ')');
}
// total = 当前筛选下挂号总数(含 status=1 已预约/2 已取消/3 已完成/4 已过号),用于计算「挂号率」。
$q->field([
$aliasPrefix . 'doctor_id',
Db::raw('COUNT(*) AS total'),
Db::raw('SUM(CASE WHEN ' . $aliasPrefix . 'status = 3 THEN 1 ELSE 0 END) AS completed'),
Db::raw('SUM(CASE WHEN ' . $aliasPrefix . 'status = 4 THEN 1 ELSE 0 END) AS missed'),
Db::raw('SUM(CASE WHEN ' . $aliasPrefix . 'status = 2 THEN 1 ELSE 0 END) AS cancelled'),
])->group($aliasPrefix . 'doctor_id');
$out = [];
foreach ($q->select()->toArray() as $r) {
$id = (int) ($r['doctor_id'] ?? 0);
if ($id <= 0) {
continue;
}
$out[$id] = [
'total' => (int) ($r['total'] ?? 0),
'completed' => (int) ($r['completed'] ?? 0),
'missed' => (int) ($r['missed'] ?? 0),
'cancelled' => (int) ($r['cancelled'] ?? 0),
];
}
return $out;
}
/**
* @return int[]
*/
private static function normalizeAppointmentChannelInts(array $raw): array
{
$out = [];
foreach ($raw as $v) {
$i = (int) $v;
if ($i > 0) {
$out[] = $i;
}
}
return array_values(array_unique($out));
}
private static function tableWithPrefix(string $table): string
{
$prefix = (string) (Db::getConfig('connections.mysql.prefix') ?: 'zyt_');
return $prefix . $table;
}
}