499 lines
18 KiB
PHP
Executable File
499 lines
18 KiB
PHP
Executable File
<?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;
|
||
|
||
/**
|
||
* 医生统计:日期区间、渠道/标签、数据范围与业绩看板一致;**不按展示部门收窄**(始终按全部「中心」展示树,再套数据权限)。
|
||
*
|
||
* 医生范围:<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 ∈ 标签诊单集
|
||
*/
|
||
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]);
|
||
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, 'o');
|
||
$q->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;
|
||
}
|
||
}
|