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

498 lines
18 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\adminapi\logic\stats;
use app\common\model\auth\Admin;
use app\common\service\DataScope\DataScopeService;
use think\facade\Db;
/**
* 医生统计:日期区间、渠道/标签、数据范围与业绩看板一致;**不按展示部门收窄**(始终按全部「中心」展示树,再套数据权限)。
*
* 医生范围:<b>admin_role.role_id = 1</b> 且管理员 <b>未软删</b>delete_time 为空);再按账号「数据范围」收窄。
*
* - 系统/手动开方:tcm_prescription.prescription_date ∈ [start,end];渠道/标签与业绩渠道列同源 EXISTS / 诊单标签
* - 成交:订单 create_time、排除履约(4),按处方 creator_id;渠道/标签同 sumPerformance 口径
* - 挂号:appointment_date ∈ [start,end];选渠道时挂号 channels 命中字典值;标签渠道时 patient_id ∈ 标签诊单集
*/
class DoctorDailyStatsLogic
{
/**
* @param array{
* start_date?:string,
* end_date?: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,避免与业绩表部门筛选联动
$paramsForCtx = $params;
unset($paramsForCtx['dept_ids']);
$ctx = YejiStatsLogic::resolveSharedYejiFilterContext($paramsForCtx, $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);
$doctorIds = self::resolveDoctorAdminIdsForStats($viewerAdminId, $viewerAdminInfo);
if ($filterDoctorId > 0) {
$doctorIds = in_array($filterDoctorId, $doctorIds, true) ? [$filterDoctorId] : [];
}
if ($doctorIds === []) {
return [
'start_date' => $startDate,
'end_date' => $endDate,
'rows' => [],
'total' => self::emptyTotals(),
];
}
$rxMap = self::loadPrescriptionCounts(
$startDate,
$endDate,
$doctorIds,
$appointmentChannelValues,
$channelFilterActive,
$tagDiagIds,
$tagAssistantIds,
$tagFallback
);
$orderMap = self::loadOrderAggregates(
$startTs,
$endTs,
$doctorIds,
$appointmentChannelValues,
$channelFilterActive,
$tagDiagIds,
$tagAssistantIds,
$tagFallback
);
$apptMap = self::loadAppointmentAggregates(
$startDate,
$endDate,
$doctorIds,
$appointmentChannelValues,
$channelFilterActive,
$tagDiagIds
);
$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];
$cnt = (int) $ord['count'];
$amt = round((float) $ord['amount'], 2);
$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_completed' => (int) $ap['completed'],
'appointment_missed' => (int) $ap['missed'],
'appointment_cancelled' => (int) $ap['cancelled'],
];
}
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),
];
}
/**
* 医生角色(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_completed' => 0,
'appointment_missed' => 0,
'appointment_cancelled' => 0,
];
}
/**
* @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_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;
return $t;
}
/**
* @param int[] $appointmentChannelValues
* @param int[]|null $tagDiagIds
* @param array<int,int>|null $tagAssistantIds
*
* @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 {
if (!self::tagScopeNonEmpty($tagDiagIds, $tagAssistantIds)) {
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);
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
*
* @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 {
if (!self::tagScopeNonEmpty($tagDiagIds, $tagAssistantIds)) {
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])
->whereRaw('NOT (o.fulfillment_status <=> 4)')
->whereIn('rx.creator_id', $doctorIds)
->where('o.diagnosis_id', '>', 0);
$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
*
* @return array<int, array{completed:int, missed:int, cancelled:int}>
*/
private static function loadAppointmentAggregates(
string $startDate,
string $endDate,
array $doctorIds,
array $appointmentChannelValues,
bool $channelFilterActive,
?array $tagDiagIds
): array {
$q = Db::name('doctor_appointment')
->whereBetween('appointment_date', [$startDate, $endDate])
->whereIn('doctor_id', $doctorIds);
$normCh = self::normalizeAppointmentChannelInts($appointmentChannelValues);
if ($normCh !== []) {
$strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $normCh)));
$q->whereIn('channels', $strVals);
} elseif ($channelFilterActive && $tagDiagIds !== null) {
if ($tagDiagIds === []) {
return [];
}
$q->whereIn('patient_id', $tagDiagIds);
}
$q->field([
'doctor_id',
Db::raw('SUM(CASE WHEN status = 3 THEN 1 ELSE 0 END) AS completed'),
Db::raw('SUM(CASE WHEN status = 4 THEN 1 ELSE 0 END) AS missed'),
Db::raw('SUM(CASE WHEN status = 2 THEN 1 ELSE 0 END) AS cancelled'),
])->group('doctor_id');
$out = [];
foreach ($q->select()->toArray() as $r) {
$id = (int) ($r['doctor_id'] ?? 0);
if ($id <= 0) {
continue;
}
$out[$id] = [
'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;
}
}