新增功能

This commit is contained in:
Your Name
2026-08-07 14:32:35 +08:00
parent 16d301f302
commit 58a7197d3e
383 changed files with 713 additions and 542 deletions
@@ -81,7 +81,7 @@ class FirstVisitConversionLogic
'start_date' => $startDate,
'end_date' => $endDate,
'include_filters' => 1,
'include_members' => 0,
'include_members' => 1,
'exclude_cancelled_appointments' => 1,
'order_metric_mode' => 'performance',
'page_no' => 1,
@@ -109,14 +109,15 @@ class FirstVisitConversionLogic
$rowDeptIdSet = [];
self::collectRowDeptIds($rows, $rowDeptIdSet);
$openDirect = self::loadOpenCountByDept(
$openCounts = self::loadOpenCounts(
$startDate,
$endDate,
$effectiveAdminIds,
array_fill_keys(array_keys($rowDeptIdSet), true),
self::personalYejiMediaSources($selectedMediaChannelCode, $selectedMediaChannel)
);
self::applyOpenCounts($rows, $openDirect);
$openDirect = $openCounts['dept'];
self::applyOpenCounts($rows, $openDirect, $openCounts['admin']);
$summary = is_array($conversion['summary'] ?? null) ? $conversion['summary'] : [];
$summary['total_open_count'] = array_sum($openDirect);
@@ -170,6 +171,7 @@ class FirstVisitConversionLogic
? '个人业绩录入'
: '个人业绩录入(按渠道名称匹配)',
'appointment_rule' => '按预约日期统计,归属优先挂号医助、再回退诊单医助;仅含已预约、已完成和已过号',
'registration_rule' => '按支付时间统计已支付且实收金额大于 0、低于 10 元的订单,每笔计 1 个挂号并按订单创建人归属',
'performance_rule' => '按业务订单创建时间和创建人统计,排除取消、拒收、退款及已发生退款的订单',
],
'filters' => [
@@ -294,6 +296,10 @@ class FirstVisitConversionLogic
if (!is_array($row)) {
continue;
}
if (in_array((string) ($row['type'] ?? ''), ['member', 'unbound'], true)) {
$out[] = $row;
continue;
}
$children = self::filterDeptRows(is_array($row['children'] ?? null) ? $row['children'] : [], $allowedSet);
$id = (int) ($row['id'] ?? 0);
if (isset($allowedSet[$id])) {
@@ -328,9 +334,9 @@ class FirstVisitConversionLogic
* @param int[]|null $effectiveAdminIds
* @param array<int,true> $rowDeptSet
* @param string[]|null $mediaSources null=全部渠道;空数组=所选渠道没有可匹配的手工来源
* @return array<int,int>
* @return array{dept:array<int,int>,admin:array<int,int>}
*/
private static function loadOpenCountByDept(
private static function loadOpenCounts(
string $startDate,
string $endDate,
?array $effectiveAdminIds,
@@ -339,7 +345,7 @@ class FirstVisitConversionLogic
): array
{
if ($effectiveAdminIds === [] || $rowDeptSet === [] || $mediaSources === []) {
return [];
return ['dept' => [], 'admin' => []];
}
$query = PersonalYeji::whereBetween('yeji_date', [$startDate, $endDate]);
if ($effectiveAdminIds !== null) {
@@ -354,7 +360,7 @@ class FirstVisitConversionLogic
->select()
->toArray();
if ($rows === []) {
return [];
return ['dept' => [], 'admin' => []];
}
$creatorIds = self::normalizeIds(array_column($rows, 'creator_id'));
@@ -415,6 +421,7 @@ class FirstVisitConversionLogic
unset($deptIds);
$direct = [];
$adminDirect = [];
foreach ($rows as $row) {
$adminId = (int) ($row['creator_id'] ?? 0);
$targetDeptId = 0;
@@ -428,24 +435,48 @@ class FirstVisitConversionLogic
$targetDeptId = -2;
}
if ($targetDeptId !== 0) {
$direct[$targetDeptId] = ($direct[$targetDeptId] ?? 0) + (int) ($row['open_count'] ?? 0);
$openCount = (int) ($row['open_count'] ?? 0);
$direct[$targetDeptId] = ($direct[$targetDeptId] ?? 0) + $openCount;
$adminDirect[$adminId] = ($adminDirect[$adminId] ?? 0) + $openCount;
}
}
return $direct;
return ['dept' => $direct, 'admin' => $adminDirect];
}
/** @param array<int,array<string,mixed>> $rows @param array<int,int> $direct */
private static function applyOpenCounts(array &$rows, array $direct): int
/**
* @param array<int,array<string,mixed>> $rows
* @param array<int,int> $deptDirect
* @param array<int,int> $adminDirect
*/
private static function applyOpenCounts(array &$rows, array $deptDirect, array $adminDirect): int
{
$sum = 0;
foreach ($rows as &$row) {
$rowType = (string) ($row['type'] ?? '');
if (in_array($rowType, ['member', 'unbound'], true)) {
$count = $rowType === 'member'
? (int) ($adminDirect[(int) ($row['admin_id'] ?? 0)] ?? 0)
: 0;
$row['total_open_count'] = $count;
$row['total_open_rate'] = self::percent($count, (int) ($row['add_fans_count'] ?? 0));
$row['open_appointment_rate'] = self::percent(
(int) ($row['paid_appointment_count'] ?? 0),
$count
);
$row['open_receive_rate'] = self::percent(
(int) ($row['completed_order_count'] ?? 0),
$count
);
continue;
}
$children = is_array($row['children'] ?? null) ? $row['children'] : [];
$childTotal = self::applyOpenCounts($children, $direct);
$childTotal = self::applyOpenCounts($children, $deptDirect, $adminDirect);
if ($children !== []) {
$row['children'] = $children;
}
$count = (int) ($direct[(int) ($row['id'] ?? 0)] ?? 0) + $childTotal;
$directCount = (int) ($deptDirect[(int) ($row['id'] ?? 0)] ?? 0);
$count = $directCount + $childTotal;
$row['total_open_count'] = $count;
$row['total_open_rate'] = self::percent($count, (int) ($row['add_fans_count'] ?? 0));
$row['open_appointment_rate'] = self::percent(
@@ -453,7 +484,7 @@ class FirstVisitConversionLogic
$count
);
$row['open_receive_rate'] = self::percent((int) ($row['completed_order_count'] ?? 0), $count);
$sum += (int) ($direct[(int) ($row['id'] ?? 0)] ?? 0) + $childTotal;
$sum += $directCount + $childTotal;
}
unset($row);
@@ -14,7 +14,7 @@ use think\facade\Db;
/**
* 一诊「医生看板」。
*
* 医生是最终展示维度;部门权限通过实际经手医助下推到挂号、诊单与业绩:
* 医生是最终展示维度;部门权限通过实际经手医助下推到预约、诊单与业绩:
* - 医生 SELF:只看本人医生数据,不限制经手医助;
* - 医助 SELF:只看本人经手患者关联的医生数据;
* - 组长/经理:只看数据范围内医助经手患者关联的医生数据;
@@ -79,7 +79,15 @@ class FirstVisitDoctorDashboardLogic
$doctorDeptNames,
$doctorStatus
);
$summary = self::buildSummary($rows);
// 支付单没有医生字段,当前数据中的低额支付单也未关联患者;挂号只能按创建人及权限范围汇总,
// 不能为了医生排行而将医助创建的支付单虚构分摊给某位医生。
$registrationCreatorIds = $doctorSelf ? [$adminId] : $assistantIds;
$registrationTotal = self::loadRegistrationTotal(
$range['start'],
$range['end'],
$registrationCreatorIds
);
$summary = self::buildSummary($rows, $registrationTotal);
$trend = self::buildAmountTrend($doctorIds, $assistantIds);
$selectedDeptName = $selectedDeptId > 0
@@ -108,7 +116,8 @@ class FirstVisitDoctorDashboardLogic
'selected_dept_name' => $selectedDeptName,
'selected_doctor_name' => $selectedDoctorName,
'doctor_count' => count($rows),
'appointment_rule' => '总挂号包含已预约、已取消、已完成和已过号;面诊取状态为已完成的挂号',
'registration_rule' => '总挂号按支付时间统计已支付且实收金额大于 0、低于 10 元的订单,每笔计 1 个,并按订单创建人及当前权限范围归属',
'appointment_rule' => '总预约包含已预约、已取消、已完成和已过号;面诊取状态为已完成的预约',
'performance_rule' => '诊单按订单创建时间统计,排除已取消、拒收、全额退款及部分退款,金额归属处方开方医生',
],
'filters' => [
@@ -122,7 +131,8 @@ class FirstVisitDoctorDashboardLogic
'conversion' => self::ranking($rows, 'receive_conversion_rate', 8),
],
'funnel' => [
['key' => 'appointment', 'label' => '挂号', 'value' => (int) $summary['appointment_total']],
['key' => 'registration', 'label' => '挂号', 'value' => (int) $summary['registration_total']],
['key' => 'appointment', 'label' => '预约', 'value' => (int) $summary['appointment_total']],
['key' => 'interview', 'label' => '面诊', 'value' => (int) $summary['interview_count']],
['key' => 'receive', 'label' => '接诊', 'value' => (int) $summary['order_count']],
['key' => 'deal', 'label' => '成交', 'value' => (int) $summary['order_count']],
@@ -327,7 +337,7 @@ class FirstVisitDoctorDashboardLogic
}
/** @param array<int,array<string,mixed>> $rows @return array<string,mixed> */
private static function buildSummary(array $rows): array
private static function buildSummary(array $rows, int $registrationTotal): array
{
$appointmentTotal = 0;
$interviewCount = 0;
@@ -345,6 +355,7 @@ class FirstVisitDoctorDashboardLogic
}
return [
'registration_total' => $registrationTotal,
'appointment_total' => $appointmentTotal,
'interview_count' => $interviewCount,
'order_count' => $orderCount,
@@ -361,6 +372,40 @@ class FirstVisitDoctorDashboardLogic
];
}
/**
* 新挂号口径:支付时间位于筛选区间、状态为已支付、0 < 实收金额 < 10 元。
* null 表示全部创建人,空数组表示当前权限范围没有可统计创建人。
*
* @param int[]|null $creatorIds
*/
private static function loadRegistrationTotal(
string $startDate,
string $endDate,
?array $creatorIds
): int {
if ($creatorIds === []) {
return 0;
}
$query = Db::name('order')
->whereNull('delete_time')
->where('status', 2)
->where('amount', '>', 0)
->where('amount', '<', 10)
->whereNotNull('payment_time')
->where('payment_time', '<>', '')
->whereBetweenTime(
'payment_time',
$startDate . ' 00:00:00',
$endDate . ' 23:59:59'
);
if ($creatorIds !== null) {
$query->whereIn('creator_id', $creatorIds);
}
return (int) $query->count();
}
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
private static function ranking(array $rows, string $field, int $limit): array
{
@@ -14,7 +14,9 @@ use think\facade\Db;
* 一诊「挂号统计」。
*
* 统计口径:
* - 挂号:doctor_appointment.appointment_date,状态 1/3/4,排除已取消 2
* - 挂号:order.payment_time,已支付且 0 < amount < 10,每笔支付订单计 1 个
* 按支付订单 creator_id 归属员工。
* - 预约:doctor_appointment.appointment_date,状态 1/3/4,排除已取消 2;
* 归属优先挂号医助 assistant_id,再回退诊单医助 assistant_id。
* - 诊单:tcm_prescription_order.create_time,归属订单 creator_id,排除履约 4/9/10。
* - 所有部门和员工筛选都只能收窄 DataScope,不允许 HTTP 参数扩大当前账号范围。
@@ -62,6 +64,11 @@ class FirstVisitRegistrationStatsLogic
$range['day_after_tomorrow'],
$assistantIds
);
$registrationDaily = self::loadRegistrationDaily(
min($range['compare_start'], $range['start']),
$range['end'],
$assistantIds
);
$orderDaily = self::loadOrderDaily(
min($range['compare_start'], $range['start']),
$range['end'],
@@ -73,6 +80,7 @@ class FirstVisitRegistrationStatsLogic
$assistantIds,
$assignment,
$appointmentDaily,
$registrationDaily,
$orderDaily,
$range
);
@@ -112,6 +120,7 @@ class FirstVisitRegistrationStatsLogic
'selected_dept_name' => $selectedDeptName,
'selected_assistant_name' => $selectedAssistantName,
'member_count' => count($assistantIds),
'registration_rule' => '支付时间在统计区间,状态为已支付且实收金额低于 10 元(大于 0 元),每笔支付订单计 1 个挂号',
'appointment_rule' => '预约日期在统计区间,状态为已预约、已完成或已过号,排除已取消',
'performance_rule' => '按订单创建时间和创建人统计,排除已取消、拒收和退款',
],
@@ -123,6 +132,7 @@ class FirstVisitRegistrationStatsLogic
'employee_rows' => $groups,
'rankings' => [
'performance' => self::rankMembers($members, 'order_amount', 10),
'registrations' => self::rankMembers($members, 'registration_count', 10),
'appointments' => self::rankMembers($members, 'appointment_count', 10),
],
'departments' => self::departmentSummaryRows($groups),
@@ -305,6 +315,39 @@ class FirstVisitRegistrationStatsLogic
return $out;
}
/** @param int[] $assistantIds @return array<int,array<string,array{count:int}>> */
private static function loadRegistrationDaily(string $startDate, string $endDate, array $assistantIds): array
{
if ($assistantIds === []) {
return [];
}
$rows = Db::name('order')->alias('o')
->whereNull('o.delete_time')
->where('o.status', 2)
->where('o.amount', '>', 0)
->where('o.amount', '<', 10)
->whereBetweenTime(
'o.payment_time',
$startDate . ' 00:00:00',
$endDate . ' 23:59:59'
)
->whereIn('o.creator_id', $assistantIds)
->fieldRaw('o.creator_id AS assistant_id, DATE(o.payment_time) AS date_label, COUNT(*) AS item_count')
->group(['o.creator_id', 'date_label'])
->select()
->toArray();
$out = [];
foreach ($rows as $row) {
$aid = (int) ($row['assistant_id'] ?? 0);
$date = (string) ($row['date_label'] ?? '');
if ($aid > 0 && $date !== '') {
$out[$aid][$date] = ['count' => (int) ($row['item_count'] ?? 0)];
}
}
return $out;
}
/** @param int[] $assistantIds @return array<int,array<string,array{count:int,amount:float}>> */
private static function loadOrderDaily(string $startDate, string $endDate, array $assistantIds): array
{
@@ -345,6 +388,7 @@ class FirstVisitRegistrationStatsLogic
array $assistantIds,
array $assignment,
array $appointmentDaily,
array $registrationDaily,
array $orderDaily,
array $range
): array {
@@ -356,6 +400,8 @@ class FirstVisitRegistrationStatsLogic
foreach ($assistantIds as $aid) {
$appointmentCount = self::sumDaily($appointmentDaily[$aid] ?? [], $range['start'], $range['end'], 'count');
$compareAppointmentCount = self::sumDaily($appointmentDaily[$aid] ?? [], $range['compare_start'], $range['compare_end'], 'count');
$registrationCount = self::sumDaily($registrationDaily[$aid] ?? [], $range['start'], $range['end'], 'count');
$compareRegistrationCount = self::sumDaily($registrationDaily[$aid] ?? [], $range['compare_start'], $range['compare_end'], 'count');
$orderCount = self::sumDaily($orderDaily[$aid] ?? [], $range['start'], $range['end'], 'count');
$orderAmount = self::sumDaily($orderDaily[$aid] ?? [], $range['start'], $range['end'], 'amount');
$rows[] = [
@@ -364,6 +410,9 @@ class FirstVisitRegistrationStatsLogic
'dept_id' => (int) ($assignment[$aid] ?? 0),
'name' => (string) ($assistantIndex[$aid] ?? '未命名员工'),
'row_type' => 'employee',
'registration_count' => (int) $registrationCount,
'compare_registration_count' => (int) $compareRegistrationCount,
'registration_compare_rate' => self::relativeChange($registrationCount, $compareRegistrationCount),
'appointment_count' => (int) $appointmentCount,
'compare_appointment_count' => (int) $compareAppointmentCount,
'appointment_compare_rate' => self::relativeChange($appointmentCount, $compareAppointmentCount),
@@ -374,7 +423,7 @@ class FirstVisitRegistrationStatsLogic
'status' => 'normal',
];
}
usort($rows, static fn (array $a, array $b): int => ($b['appointment_count'] <=> $a['appointment_count']) ?: ($b['order_amount'] <=> $a['order_amount']));
usort($rows, static fn (array $a, array $b): int => ($b['registration_count'] <=> $a['registration_count']) ?: ($b['appointment_count'] <=> $a['appointment_count']) ?: ($b['order_amount'] <=> $a['order_amount']));
return $rows;
}
@@ -393,6 +442,8 @@ class FirstVisitRegistrationStatsLogic
'name' => $key > 0 ? (string) ($deptIndex[$key]['name'] ?? '未命名部门') : '未分配部门',
'row_type' => 'department',
'member_count' => 0,
'registration_count' => 0,
'compare_registration_count' => 0,
'appointment_count' => 0,
'compare_appointment_count' => 0,
'tomorrow_count' => 0,
@@ -405,13 +456,17 @@ class FirstVisitRegistrationStatsLogic
}
$groups[$key]['children'][] = $member;
$groups[$key]['member_count']++;
foreach (['appointment_count', 'compare_appointment_count', 'tomorrow_count', 'day_after_count', 'order_count'] as $field) {
foreach (['registration_count', 'compare_registration_count', 'appointment_count', 'compare_appointment_count', 'tomorrow_count', 'day_after_count', 'order_count'] as $field) {
$groups[$key][$field] += (int) ($member[$field] ?? 0);
}
$groups[$key]['order_amount'] += (float) ($member['order_amount'] ?? 0);
}
foreach ($groups as &$group) {
$group['order_amount'] = round((float) $group['order_amount'], 2);
$group['registration_compare_rate'] = self::relativeChange(
(float) $group['registration_count'],
(float) $group['compare_registration_count']
);
$group['appointment_compare_rate'] = self::relativeChange(
(float) $group['appointment_count'],
(float) $group['compare_appointment_count']
@@ -432,11 +487,15 @@ class FirstVisitRegistrationStatsLogic
/** @param array<int,array<string,mixed>> $members @return array<string,mixed> */
private static function buildSummary(array $members, array $range): array
{
$registrationCount = 0;
$compareRegistrationCount = 0;
$appointmentCount = 0;
$compareAppointmentCount = 0;
$orderCount = 0;
$orderAmount = 0.0;
foreach ($members as $member) {
$registrationCount += (int) ($member['registration_count'] ?? 0);
$compareRegistrationCount += (int) ($member['compare_registration_count'] ?? 0);
$appointmentCount += (int) ($member['appointment_count'] ?? 0);
$compareAppointmentCount += (int) ($member['compare_appointment_count'] ?? 0);
$orderCount += (int) ($member['order_count'] ?? 0);
@@ -444,6 +503,9 @@ class FirstVisitRegistrationStatsLogic
}
return [
'registration_count' => $registrationCount,
'registration_compare_count' => $compareRegistrationCount,
'registration_compare_rate' => self::relativeChange($registrationCount, $compareRegistrationCount),
'appointment_count' => $appointmentCount,
'appointment_compare_count' => $compareAppointmentCount,
'appointment_compare_rate' => self::relativeChange($appointmentCount, $compareAppointmentCount),
@@ -460,13 +522,18 @@ class FirstVisitRegistrationStatsLogic
usort($rows, static fn (array $a, array $b): int => (($b[$field] ?? 0) <=> ($a[$field] ?? 0)) ?: strcmp((string) $a['name'], (string) $b['name']));
$out = [];
foreach (array_slice($rows, 0, $limit) as $row) {
$countField = match ($field) {
'order_amount' => 'order_count',
'registration_count' => 'registration_count',
default => 'appointment_count',
};
$out[] = [
'admin_id' => (int) ($row['admin_id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'value' => $field === 'order_amount'
? round((float) ($row[$field] ?? 0), 2)
: (int) ($row[$field] ?? 0),
'count' => $field === 'order_amount' ? (int) ($row['order_count'] ?? 0) : (int) ($row['appointment_count'] ?? 0),
'count' => (int) ($row[$countField] ?? 0),
];
}
@@ -156,7 +156,8 @@ class ConversionLogic
$endDate,
$mediaChannel,
$visibleAdminIds,
$excludeCancelledAppointments
$excludeCancelledAppointments,
$usePerformanceOrderMetrics
);
self::hydrateOrderAndAmountStats(
$entities,
@@ -251,7 +252,9 @@ class ConversionLogic
$adminToDeptIds,
$validDeptIds,
$globalAccountCost,
$visibleAdminIds
$visibleAdminIds,
$excludeCancelledAppointments,
$usePerformanceOrderMetrics
);
$pagedRows = self::attachDeptMembers($pagedRows, $memberRowsByDeptId);
}
@@ -983,7 +986,8 @@ class ConversionLogic
string $endDate,
?array $mediaChannel,
?array $visibleAdminIds = null,
bool $excludeCancelledAppointments = false
bool $excludeCancelledAppointments = false,
bool $useRegistrationMetric = false
): void {
$sourceExpr = $dimension === 'doctor'
? 'a.doctor_id'
@@ -1040,7 +1044,17 @@ class ConversionLogic
}
}
self::hydratePaidAppointmentStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
self::hydratePaidAppointmentStats(
$entities,
$dimension,
$entityIds,
$adminToDeptIds,
$startTimestamp,
$endTimestamp,
$mediaChannel,
$visibleAdminIds,
$useRegistrationMetric
);
foreach ($entities as &$entity) {
$appointmentTotalCount = (int)($entity['appointment_total_count'] ?? 0);
@@ -1065,7 +1079,8 @@ class ConversionLogic
int $startTimestamp,
int $endTimestamp,
?array $mediaChannel,
?array $visibleAdminIds = null
?array $visibleAdminIds = null,
bool $useRegistrationMetric = false
): void {
$startDateTime = date('Y-m-d H:i:s', $startTimestamp);
$endDateTime = date('Y-m-d H:i:s', $endTimestamp);
@@ -1073,14 +1088,20 @@ class ConversionLogic
->alias('o')
->whereNull('o.delete_time')
->where('o.status', 2)
->where('o.order_type', 1)
->where('o.amount', 5)
->whereNotNull('o.payment_time')
->where('o.payment_time', '<>', '')
->whereBetweenTime('o.payment_time', $startDateTime, $endDateTime)
->fieldRaw('o.creator_id AS source_admin_id, COUNT(*) AS paid_appointment_count')
->group('o.creator_id');
if ($useRegistrationMetric) {
// 一诊页面的新“挂号”:已支付且 0 < 实收金额 < 10 元,每笔支付订单计 1 个。
$query->where('o.amount', '>', 0)->where('o.amount', '<', 10);
} else {
// 保留旧统计页面的历史“付费挂号”字段,避免本次一诊改造改变其它模块口径。
$query->where('o.order_type', 1)->where('o.amount', 5);
}
if ($mediaChannel !== null) {
$query->leftJoin('qywx_external_contact q', 'q.external_userid = o.payer_external_userid');
MediaChannelService::applyFollowUsersChannelFilter($query, 'q.follow_users', $mediaChannel);
@@ -1683,6 +1704,8 @@ class ConversionLogic
* @param float $globalAccountCost 由调用方提前计算好的本期总账户消耗(zyt_account_cost SUM)。
* -1 表示让本函数自行 hydrate;>= 0 时直接复用,避免重复 SQL。
* @param int[]|null $visibleAdminIds 可见 admin 集合(null = SCOPE_ALL);用于成员明细的隔离
* @param bool $excludeCancelledAppointments 是否排除已取消挂号,须与部门汇总口径一致
* @param bool $usePerformanceOrderMetrics 是否使用有效业绩订单口径,须与部门汇总口径一致
* @return array<int, array<int, array<string, mixed>>> dept_id => [member_row, ...]
*/
private static function buildMemberRowsByDept(
@@ -1697,7 +1720,9 @@ class ConversionLogic
array $adminToDeptIds,
array $validDeptIds = [],
float $globalAccountCost = -1.0,
?array $visibleAdminIds = null
?array $visibleAdminIds = null,
bool $excludeCancelledAppointments = false,
bool $usePerformanceOrderMetrics = false
): array
{
$assistantEntities = self::loadAdminEntities(2, 0, $visibleAdminIds);
@@ -1706,15 +1731,15 @@ class ConversionLogic
$assistantIds = array_keys($assistantEntities);
$doctorIds = array_keys($doctorEntities);
if ($assistantIds !== []) {
if ($assistantIds !== [] && !$usePerformanceOrderMetrics) {
self::hydrateFanStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
self::hydrateAppointmentStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds);
self::hydrateOrderAndAmountStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
self::hydrateAppointmentStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds, $excludeCancelledAppointments);
self::hydrateOrderAndAmountStats($assistantEntities, 'assistant', $assistantIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds, $usePerformanceOrderMetrics);
}
if ($doctorIds !== []) {
if ($doctorIds !== [] && !$usePerformanceOrderMetrics) {
self::hydrateFanStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
self::hydrateAppointmentStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds);
self::hydrateOrderAndAmountStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
self::hydrateAppointmentStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds, $excludeCancelledAppointments);
self::hydrateOrderAndAmountStats($doctorEntities, 'doctor', $doctorIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds, $usePerformanceOrderMetrics);
}
// 复用调用方传入的 global account cost;只有兜底未传时才回查一次(保留向后兼容)。
@@ -1749,6 +1774,15 @@ class ConversionLogic
}
}
if ($usePerformanceOrderMetrics && $combined !== []) {
// 一诊综合转化的部门指标均按业务归属人统计。成员明细也必须沿用同一归属,
// 不能再分别按“医助/医生”统计后相加,否则双角色员工会重复、人员合计也无法与部门汇总对齐。
$combinedIds = array_keys($combined);
self::hydrateFanStats($combined, 'assistant', $combinedIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds);
self::hydrateAppointmentStats($combined, 'assistant', $combinedIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $startDate, $endDate, $mediaChannel, $visibleAdminIds, $excludeCancelledAppointments, true);
self::hydrateOrderAndAmountStats($combined, 'assistant', $combinedIds, $adminToDeptIds, $startTimestamp, $endTimestamp, $mediaChannel, $visibleAdminIds, true);
}
if ($combined === []) {
return [];
}
@@ -18,14 +18,13 @@ use think\facade\Db;
* 数据口径:
* - 所有“业绩/接诊诊单”与业绩统计、业务订单列表保持一致:按业务订单创建时间,
* 排除履约已取消/拒收/退款(4/9/10),金额取业务订单 amount,归属人取订单 creator_id。
* - 今日加粉、挂号、面诊和转化率继续沿用 ConversionLogic它们不是业绩指标
* - 今日预约、面诊沿用 ConversionLogic挂号按已支付且实收低于 10 元的订单统计
* - 趋势使用同一业绩条件的轻量按日 SQL,固定补齐最近 7 个自然日。
* - 所有查询都使用 DataScopeService 返回的可见管理员集合收窄。
*/
class PerformanceDashboardLogic
{
private const TREND_DAYS = 7;
private const PAID_APPOINTMENT_DEPT_NAME = '一中心';
/**
* @return array<string, mixed>
@@ -57,7 +56,7 @@ class PerformanceDashboardLogic
$orderDaily = self::loadPerformanceOrderDaily($previousMonthStart, $today, $visibleAdminIds);
$personalOrderDaily = self::loadPerformanceOrderDaily($monthStart, $today, [$adminId]);
$lowAmountPaymentDaily = self::loadLowAmountPaymentDaily($yesterday, $today, $visibleAdminIds);
$registrationDaily = self::loadRegistrationDaily($trendStart, $today, $visibleAdminIds);
$monthAmount = self::sumDailyMetric($orderDaily, $monthStart, $today, 'amount');
$previousMonthAmount = self::sumDailyMetric(
@@ -98,7 +97,7 @@ class PerformanceDashboardLogic
'start_date' => $today,
'end_date' => $today,
], $adminId, $adminInfo);
$appointmentRanking = self::buildAppointmentRanking(
$appointmentRanking = self::buildRegistrationRanking(
$adminId,
$adminInfo,
$scope,
@@ -112,7 +111,14 @@ class PerformanceDashboardLogic
'start_date' => $trendStart,
'end_date' => $today,
], $adminId, $adminInfo);
$trend = self::buildTrend($trendStart, $today, $visibleAdminIds, $orderDaily, $trendContext);
$trend = self::buildTrend(
$trendStart,
$today,
$visibleAdminIds,
$orderDaily,
$registrationDaily,
$trendContext
);
$todayTrendIndex = max(0, count($trend['dates'] ?? []) - 1);
$yesterdayTrendIndex = max(0, $todayTrendIndex - 1);
@@ -126,10 +132,10 @@ class PerformanceDashboardLogic
$yesterdayOrderCount = (int) self::dailyMetric($orderDaily, $yesterday, 'count');
$todayOrderAmount = self::dailyMetric($orderDaily, $today, 'amount');
$yesterdayOrderAmount = self::dailyMetric($orderDaily, $yesterday, 'amount');
$todayLowAmountPaymentCount = (int) self::dailyMetric($lowAmountPaymentDaily, $today, 'count');
$yesterdayLowAmountPaymentCount = (int) self::dailyMetric($lowAmountPaymentDaily, $yesterday, 'count');
$todayPaidAppointmentCount = self::paidAppointmentCountWithCenterRule($todayOverview);
$yesterdayPaidAppointmentCount = self::paidAppointmentCountWithCenterRule($yesterdayOverview);
$todayLowAmountPaymentCount = (int) self::dailyMetric($registrationDaily, $today, 'count');
$yesterdayLowAmountPaymentCount = (int) self::dailyMetric($registrationDaily, $yesterday, 'count');
$todayPaidAppointmentCount = $todayLowAmountPaymentCount;
$yesterdayPaidAppointmentCount = $yesterdayLowAmountPaymentCount;
$todayPaidAppointmentRate = self::percent($todayPaidAppointmentCount, $todayAddFansCount);
$yesterdayPaidAppointmentRate = self::percent($yesterdayPaidAppointmentCount, $yesterdayAddFansCount);
// 接诊卡片使用的是有效业务订单,接诊率必须使用同一订单口径,不能继续读取旧的双审完成单。
@@ -210,60 +216,11 @@ class PerformanceDashboardLogic
'generated_at' => date('Y-m-d H:i:s'),
'timezone' => date_default_timezone_get(),
'commission_note' => '本人业绩按当前账号创建的业务订单统计,排除已取消、拒收和退款订单。',
'rate_note' => '付费挂号率:一中心员工的有效挂号全部按付费挂号,其他部门按 5 元实付挂号;接诊率:有效业务诊单数 / 已完成面诊数。近 7 天挂号排除已取消记录,诊单按订单创建时间统计并排除履约 4/9/10。',
'rate_note' => '挂号及挂号率:按支付时间统计已支付且 0<实收金额<10 元的订单,每笔订单计 1 个挂号,并按订单创建人归属;预约按预约日期统计有效预约记录。接诊率:有效业务诊单数 / 已完成面诊数。',
],
];
}
/**
* 一中心员工的有效挂号全部视为付费;其它部门仍沿用 5 元实付挂号口径。
* 部门树的父节点已汇总子节点,因此命中“一中心”后不再向下递归,避免重复累计。
*
* @param array<string, mixed> $overview
*/
private static function paidAppointmentCountWithCenterRule(array $overview): int
{
$summary = is_array($overview['summary'] ?? null) ? $overview['summary'] : [];
$paidTotal = (int) ($summary['paid_appointment_count'] ?? 0);
$rows = is_array($overview['lists'] ?? null) ? $overview['lists'] : [];
[$centerAppointments, $centerPaidAppointments] = self::namedDepartmentAppointmentMetrics(
$rows,
self::PAID_APPOINTMENT_DEPT_NAME
);
return max(0, $paidTotal - $centerPaidAppointments + $centerAppointments);
}
/**
* @param array<int, array<string, mixed>> $rows
* @return array{0:int,1:int}
*/
private static function namedDepartmentAppointmentMetrics(array $rows, string $departmentName): array
{
$appointmentCount = 0;
$paidAppointmentCount = 0;
foreach ($rows as $row) {
if (trim((string) ($row['name'] ?? '')) === $departmentName) {
$appointmentCount += (int) ($row['appointment_total_count'] ?? 0);
$paidAppointmentCount += (int) ($row['paid_appointment_count'] ?? 0);
continue;
}
$children = is_array($row['children'] ?? null) ? $row['children'] : [];
if ($children === []) {
continue;
}
[$childAppointments, $childPaidAppointments] = self::namedDepartmentAppointmentMetrics(
$children,
$departmentName
);
$appointmentCount += $childAppointments;
$paidAppointmentCount += $childPaidAppointments;
}
return [$appointmentCount, $paidAppointmentCount];
}
/**
* @return array<string, mixed>
*/
@@ -393,12 +350,12 @@ class PerformanceDashboardLogic
}
/**
* 当日 0 < 实收金额 <= 10 元的已支付订单笔数;退款订单状态为 4,不会进入统计。
* 0 < 实收金额 < 10 元的已支付订单笔数;退款订单状态为 4,不会进入统计。
*
* @param array<int>|null $visibleAdminIds
* @return array<string, array{count:int}>
*/
private static function loadLowAmountPaymentDaily(
private static function loadRegistrationDaily(
string $startDate,
string $endDate,
?array $visibleAdminIds
@@ -411,7 +368,7 @@ class PerformanceDashboardLogic
->whereNull('delete_time')
->where('status', 2)
->where('amount', '>', 0)
->where('amount', '<=', 10)
->where('amount', '<', 10)
->whereNotNull('payment_time')
->where('payment_time', '<>', '')
->whereBetweenTime(
@@ -502,7 +459,7 @@ class PerformanceDashboardLogic
* @param array<string, mixed> $scope
* @return array<string, mixed>
*/
private static function buildAppointmentRanking(
private static function buildRegistrationRanking(
int $adminId,
array $adminInfo,
array $scope,
@@ -515,33 +472,6 @@ class PerformanceDashboardLogic
&& in_array(1, $roleIds, true)
&& !in_array(2, $roleIds, true);
if ($isDoctorSelf) {
$doctorStats = DoctorDailyStatsLogic::overview([
'start_date' => date('Y-m-d'),
'end_date' => date('Y-m-d'),
], $adminId, $adminInfo);
$items = [];
foreach (array_slice($doctorStats['rows'] ?? [], 0, 5) as $row) {
$items[] = [
'id' => (int) ($row['admin_id'] ?? 0),
'name' => (string) ($row['doctor_name'] ?? ''),
// DoctorDailyStats 的 total 含已取消;驾驶舱实时排行只统计有效挂号。
'count' => max(
0,
(int) ($row['appointment_total'] ?? 0) - (int) ($row['appointment_cancelled'] ?? 0)
),
'amount' => round((float) ($row['deal_amount'] ?? 0), 2),
];
}
return [
'title' => '实时挂号排行',
'kind' => 'doctor',
'scope_label' => (string) ($scope['label'] ?? ''),
'items' => $items,
];
}
$rankingVisibleAdminIds = $baseVisibleAdminIds;
$rankingScopeLabel = (string) ($scope['label'] ?? '');
if (
@@ -562,42 +492,42 @@ class PerformanceDashboardLogic
$rankingScopeLabel = (string) (Dept::where('id', $rankingDeptId)->value('name') ?? $rankingScopeLabel);
}
$assistantStats = ConversionLogic::overview([
'dimension' => 'assistant',
'time_type' => 'today',
'include_filters' => 0,
'exclude_cancelled_appointments' => 1,
'page_no' => 1,
'page_size' => $rankingVisibleAdminIds !== null ? max(1, count($rankingVisibleAdminIds)) : 100,
], $adminId, $adminInfo, $rankingVisibleAdminIds);
$rows = is_array($assistantStats['lists'] ?? null) ? $assistantStats['lists'] : [];
usort($rows, static function (array $a, array $b): int {
$byAppointment = (int) ($b['appointment_total_count'] ?? 0) <=> (int) ($a['appointment_total_count'] ?? 0);
if ($byAppointment !== 0) {
return $byAppointment;
}
$byAmount = (float) ($b['completed_order_amount'] ?? 0) <=> (float) ($a['completed_order_amount'] ?? 0);
if ($byAmount !== 0) {
return $byAmount;
}
return (int) ($a['id'] ?? 0) <=> (int) ($b['id'] ?? 0);
});
$items = [];
foreach (array_slice($rows, 0, 5) as $row) {
$items[] = [
'id' => (int) ($row['id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'count' => (int) ($row['appointment_total_count'] ?? 0),
'amount' => round((float) ($row['completed_order_amount'] ?? 0), 2),
];
if ($rankingVisibleAdminIds !== []) {
$query = Db::name('order')
->alias('o')
->join('admin a', 'a.id = o.creator_id AND a.delete_time IS NULL', 'INNER')
->whereNull('o.delete_time')
->where('o.status', 2)
->where('o.amount', '>', 0)
->where('o.amount', '<', 10)
->whereNotNull('o.payment_time')
->where('o.payment_time', '<>', '')
->whereBetweenTime('o.payment_time', date('Y-m-d 00:00:00'), date('Y-m-d 23:59:59'));
if ($rankingVisibleAdminIds !== null) {
$query->whereIn('o.creator_id', $rankingVisibleAdminIds);
}
$rows = $query
->fieldRaw('o.creator_id AS id, a.name, COUNT(*) AS item_count, SUM(o.amount) AS amount_sum')
->group(['o.creator_id', 'a.name'])
->orderRaw('item_count DESC, amount_sum DESC, o.creator_id ASC')
->limit(5)
->select()
->toArray();
foreach ($rows as $row) {
$items[] = [
'id' => (int) ($row['id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'count' => (int) ($row['item_count'] ?? 0),
'amount' => round((float) ($row['amount_sum'] ?? 0), 2),
];
}
}
return [
'title' => '实时挂号排行',
'kind' => 'assistant',
'kind' => $isDoctorSelf ? 'doctor' : 'member',
'scope_label' => $rankingScopeLabel,
'items' => $items,
];
@@ -704,6 +634,7 @@ class PerformanceDashboardLogic
/**
* @param array<int>|null $visibleAdminIds
* @param array<string, array{amount: float, count: int}> $orderDaily
* @param array<string, array{count: int}> $registrationDaily
* @param array<string, mixed> $trendContext
* @return array<string, mixed>
*/
@@ -712,6 +643,7 @@ class PerformanceDashboardLogic
string $endDate,
?array $visibleAdminIds,
array $orderDaily,
array $registrationDaily,
array $trendContext
): array {
$adminToPrimary = is_array($trendContext['adminToPrimary'] ?? null)
@@ -729,6 +661,7 @@ class PerformanceDashboardLogic
$tableRowDeptIds
);
$dates = [];
$registrations = [];
$appointments = [];
$leads = [];
$orders = [];
@@ -738,6 +671,7 @@ class PerformanceDashboardLogic
while ($cursor <= $end) {
$date = date('Y-m-d', $cursor);
$dates[] = date('m-d', $cursor);
$registrations[] = (int) ($registrationDaily[$date]['count'] ?? 0);
$appointments[] = (int) ($appointmentDaily[$date] ?? 0);
$leads[] = (int) ($leadDaily[$date] ?? 0);
$orders[] = (int) ($orderDaily[$date]['count'] ?? 0);
@@ -747,6 +681,7 @@ class PerformanceDashboardLogic
return [
'date_range' => [$startDate, $endDate],
'dates' => $dates,
'registrations' => $registrations,
'appointments' => $appointments,
'leads' => $leads,
'orders' => $orders,