新增功能

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
@@ -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,