This commit is contained in:
2026-04-21 17:24:51 +08:00
parent d47ef8d680
commit 4f45ecbd63
13 changed files with 675 additions and 105 deletions
@@ -6,7 +6,6 @@ namespace app\adminapi\logic\stats;
use app\adminapi\logic\dept\DeptLogic;
use app\adminapi\logic\tcm\DiagnosisLogic;
use think\facade\Cache;
use think\facade\Db;
class ConversionLogic
@@ -17,18 +16,6 @@ class ConversionLogic
public static function overview(array $params = []): array
{
$includeFilters = (int)($params['include_filters'] ?? 0) === 1;
$cacheParams = $params;
ksort($cacheParams);
$cacheKey = 'conversion_stats_overview_' . md5(json_encode($cacheParams, JSON_UNESCAPED_UNICODE));
$cached = Cache::get($cacheKey);
if (is_array($cached)) {
if ($includeFilters) {
$cached['extend']['filters'] = self::buildFilterOptions();
}
return $cached;
}
$dimension = self::normalizeDimension((string)($params['dimension'] ?? 'dept'));
[$startTimestamp, $endTimestamp, $startDate, $endDate] = self::resolveTimeRange($params);
$pageNo = max(1, (int)($params['page_no'] ?? 1));
@@ -57,7 +44,6 @@ class ConversionLogic
if ($includeFilters) {
$result['extend']['filters'] = self::buildFilterOptions();
}
Cache::set($cacheKey, $result, 60);
return $result;
}
@@ -65,9 +51,9 @@ class ConversionLogic
$adminToDeptIds = self::loadAdminDeptMap();
self::hydrateFanStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp);
self::hydrateAppointmentStats($entities, $dimension, $entityIds, $adminToDeptIds, $startDate, $endDate);
self::finalizeAppointmentStats($entities);
self::hydratePaidAuditAmountStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp);
self::hydrateCompletedOrderStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp);
self::hydrateAccountCostStats($entities, $startDate, $endDate);
if ($dimension === 'dept') {
[$allRows, $pagedRows, $chartRows] = self::buildDeptTreeRows(
@@ -96,7 +82,6 @@ class ConversionLogic
if ($includeFilters) {
$result['extend']['filters'] = self::buildFilterOptions();
}
Cache::set($cacheKey, $result, 60);
return $result;
}
@@ -124,7 +109,6 @@ class ConversionLogic
if ($includeFilters) {
$result['extend']['filters'] = self::buildFilterOptions();
}
Cache::set($cacheKey, $result, 60);
return $result;
}
@@ -134,20 +118,11 @@ class ConversionLogic
*/
private static function buildFilterOptions(): array
{
$cacheKey = 'conversion_stats_filters';
$cached = Cache::get($cacheKey);
if (is_array($cached)) {
return $cached;
}
$filters = [
return [
'departments' => DeptLogic::getAllData(),
'assistants' => DiagnosisLogic::getAssistants(),
'doctors' => DiagnosisLogic::getDoctors(),
];
Cache::set($cacheKey, $filters, 600);
return $filters;
}
private static function normalizeDimension(string $dimension): string
@@ -316,7 +291,6 @@ class ConversionLogic
'completed_order_amount' => 0.0,
'completed_order_count' => 0,
'account_cost' => 0.0,
'_appointment_groups' => [],
];
}
@@ -364,6 +338,7 @@ class ConversionLogic
->select()
->toArray();
$countsByAdmin = [];
foreach ($rows as $row) {
$adminIds = self::extractFollowAdminIds($row, $wxUserIdToAdminIds);
if ($adminIds === []) {
@@ -371,9 +346,13 @@ class ConversionLogic
}
foreach ($adminIds as $adminId) {
foreach (self::mapEntityIds($dimension, $adminId, $entityIds, $adminToDeptIds) as $entityId) {
$entities[$entityId]['add_fans_count']++;
}
$countsByAdmin[$adminId] = ($countsByAdmin[$adminId] ?? 0) + 1;
}
}
foreach ($countsByAdmin as $adminId => $count) {
foreach (self::mapEntityIds($dimension, (int) $adminId, $entityIds, $adminToDeptIds) as $entityId) {
$entities[$entityId]['add_fans_count'] += (int) $count;
}
}
}
@@ -457,10 +436,15 @@ class ConversionLogic
string $startDate,
string $endDate
): void {
$sourceExpr = $dimension === 'doctor' ? 'a.doctor_id' : 'u.assistant_id';
$rows = Db::name('doctor_appointment')
->where('appointment_date', '>=', $startDate)
->where('appointment_date', '<=', $endDate)
->field('patient_id, doctor_id, assistant_id, status')
->alias('a')
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
->where('a.appointment_date', '>=', $startDate)
->where('a.appointment_date', '<=', $endDate)
->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)')
->fieldRaw("{$sourceExpr} AS source_admin_id, a.patient_id AS diagnosis_id, COUNT(*) AS appointment_count, SUM(CASE WHEN a.status = 3 THEN 1 ELSE 0 END) AS interview_count")
->group("{$sourceExpr}, a.patient_id")
->select()
->toArray();
@@ -470,61 +454,26 @@ class ConversionLogic
continue;
}
$sourceAdminId = $dimension === 'doctor'
? (int)($row['doctor_id'] ?? 0)
: (int)($row['assistant_id'] ?? 0);
$sourceAdminId = (int)($row['source_admin_id'] ?? 0);
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds);
if ($mappedEntityIds === []) {
continue;
}
$appointmentCount = (int)($row['appointment_count'] ?? 0);
$interviewCount = (int)($row['interview_count'] ?? 0);
foreach ($mappedEntityIds as $entityId) {
$entities[$entityId]['appointment_total_count']++;
if (!isset($entities[$entityId]['_appointment_groups'][$diagnosisId])) {
$entities[$entityId]['_appointment_groups'][$diagnosisId] = [
'count' => 0,
'has_completed' => false,
];
}
$entities[$entityId]['_appointment_groups'][$diagnosisId]['count']++;
if ((int)($row['status'] ?? 0) === 3) {
$entities[$entityId]['_appointment_groups'][$diagnosisId]['has_completed'] = true;
$entities[$entityId]['interview_count']++;
$entities[$entityId]['appointment_total_count'] += $appointmentCount;
$entities[$entityId]['paid_appointment_count'] += 1;
if ($appointmentCount > 1) {
$entities[$entityId]['free_appointment_count'] += ($appointmentCount - 1);
}
$entities[$entityId]['interview_count'] += $interviewCount;
}
}
}
/**
* @param array<int, array<string, mixed>> $entities
*/
private static function finalizeAppointmentStats(array &$entities): void
{
foreach ($entities as &$entity) {
$groups = $entity['_appointment_groups'] ?? [];
$paid = 0;
$free = 0;
foreach ($groups as $group) {
$count = (int)($group['count'] ?? 0);
if ($count <= 0) {
continue;
}
$paid += 1;
// 与问诊列表总数口径对齐:
// 同一诊单第一条记为付费挂号,其余挂号记录都记为免费挂号,
// 否则“重复挂号但未完成”的记录会被漏掉,导致付费+免费 < 挂号总数。
if ($count > 1) {
$free += ($count - 1);
}
}
$entity['paid_appointment_count'] = $paid;
$entity['free_appointment_count'] = $free;
unset($entity['_appointment_groups']);
}
unset($entity);
}
/**
* @param array<int, array<string, mixed>> $entities
* @param int[] $entityIds
@@ -538,6 +487,7 @@ class ConversionLogic
int $startTimestamp,
int $endTimestamp
): void {
$sourceExpr = $dimension === 'doctor' ? 'rx.creator_id' : 'rx.assistant_id';
$rows = Db::name('tcm_prescription_order')
->alias('po')
->leftJoin('tcm_prescription rx', 'rx.id = po.prescription_id')
@@ -546,29 +496,47 @@ class ConversionLogic
->where('po.prescription_audit_status', 1)
->where('po.payment_slip_audit_status', 1)
->where('po.update_time', 'between', [$startTimestamp, $endTimestamp])
->field('po.amount, po.internal_cost, rx.assistant_id, rx.creator_id')
->fieldRaw("{$sourceExpr} AS source_admin_id, COUNT(*) AS order_count")
->group($sourceExpr)
->select()
->toArray();
foreach ($rows as $row) {
$sourceAdminId = $dimension === 'doctor'
? (int)($row['creator_id'] ?? 0)
: (int)($row['assistant_id'] ?? 0);
$sourceAdminId = (int)($row['source_admin_id'] ?? 0);
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds);
if ($mappedEntityIds === []) {
continue;
}
$internalCost = round((float)($row['internal_cost'] ?? 0), 2);
$orderCount = (int)($row['order_count'] ?? 0);
foreach ($mappedEntityIds as $entityId) {
$entities[$entityId]['completed_order_count']++;
$entities[$entityId]['account_cost'] = round($entities[$entityId]['account_cost'] + $internalCost, 2);
$entities[$entityId]['completed_order_count'] += $orderCount;
}
}
}
/**
* 账户消耗:来源于独立维护表 zyt_account_cost,为全局日维度数据。
* 由于当前没有部门/医助/医生拆分来源,只在汇总层使用,不向各明细行分摊。
*
* @param array<int, array<string, mixed>> $entities
*/
private static function hydrateAccountCostStats(array &$entities, string $startDate, string $endDate): void
{
$totalAmount = round((float) Db::name('account_cost')
->where('cost_date', '>=', $startDate)
->where('cost_date', '<=', $endDate)
->sum('amount'), 2);
foreach ($entities as &$entity) {
$entity['account_cost'] = 0.0;
$entity['_global_account_cost'] = $totalAmount;
}
unset($entity);
}
/**
* 诊单金额:按支付单审核通过(payment_slip_audit_status=1)的业务订单 amount 汇总
*
@@ -584,27 +552,27 @@ class ConversionLogic
int $startTimestamp,
int $endTimestamp
): void {
$sourceExpr = $dimension === 'doctor' ? 'rx.creator_id' : 'rx.assistant_id';
$rows = Db::name('tcm_prescription_order')
->alias('po')
->leftJoin('tcm_prescription rx', 'rx.id = po.prescription_id')
->whereNull('po.delete_time')
->where('po.payment_slip_audit_status', 1)
->where('po.update_time', 'between', [$startTimestamp, $endTimestamp])
->field('po.amount, rx.assistant_id, rx.creator_id')
->fieldRaw("{$sourceExpr} AS source_admin_id, SUM(po.amount) AS total_amount")
->group($sourceExpr)
->select()
->toArray();
foreach ($rows as $row) {
$sourceAdminId = $dimension === 'doctor'
? (int)($row['creator_id'] ?? 0)
: (int)($row['assistant_id'] ?? 0);
$sourceAdminId = (int)($row['source_admin_id'] ?? 0);
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds);
if ($mappedEntityIds === []) {
continue;
}
$amount = round((float)($row['amount'] ?? 0), 2);
$amount = round((float)($row['total_amount'] ?? 0), 2);
foreach ($mappedEntityIds as $entityId) {
$entities[$entityId]['completed_order_amount'] = round($entities[$entityId]['completed_order_amount'] + $amount, 2);
}
@@ -641,6 +609,7 @@ class ConversionLogic
private static function finalizeRows(array $entities): array
{
$rows = [];
$globalAccountCost = 0.0;
foreach ($entities as $entity) {
$paidAppointmentCount = (int)($entity['paid_appointment_count'] ?? 0);
$freeAppointmentCount = (int)($entity['free_appointment_count'] ?? 0);
@@ -651,12 +620,14 @@ class ConversionLogic
$completedOrderCount = (int)$entity['completed_order_count'];
$completedOrderAmount = round((float)$entity['completed_order_amount'], 2);
$accountCost = round((float)$entity['account_cost'], 2);
$globalAccountCost = max($globalAccountCost, round((float)($entity['_global_account_cost'] ?? 0), 2));
$effectiveAccountCost = $globalAccountCost > 0 ? $globalAccountCost : $accountCost;
$entity['paid_appointment_count'] = $paidAppointmentCount;
$entity['free_appointment_count'] = $freeAppointmentCount;
$entity['appointment_total_count'] = $appointmentTotalCount;
$entity['completed_order_amount'] = $completedOrderAmount;
$entity['account_cost'] = $accountCost;
$entity['account_cost'] = $effectiveAccountCost;
$entity['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
$entity['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
$entity['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
@@ -664,8 +635,8 @@ class ConversionLogic
$entity['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
$entity['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
$entity['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
$entity['cash_cost'] = self::safeDivideMoney($accountCost, $addFansCount);
$entity['roi'] = self::safeDivideRatio($completedOrderAmount, $accountCost);
$entity['cash_cost'] = self::safeDivideMoney($effectiveAccountCost, $addFansCount);
$entity['roi'] = self::safeDivideRatio($completedOrderAmount, $effectiveAccountCost);
$rows[] = $entity;
}
@@ -683,6 +654,13 @@ class ConversionLogic
return $right['add_fans_count'] <=> $left['add_fans_count'];
});
if ($globalAccountCost > 0 && $rows !== []) {
foreach ($rows as &$row) {
$row['_global_account_cost'] = $globalAccountCost;
}
unset($row);
}
return $rows;
}
@@ -815,12 +793,14 @@ class ConversionLogic
$completedOrderCount = (int)$node['completed_order_count'];
$completedOrderAmount = round((float)$node['completed_order_amount'], 2);
$accountCost = round((float)$node['account_cost'], 2);
$globalAccountCost = round((float)($node['_global_account_cost'] ?? 0), 2);
$effectiveAccountCost = $globalAccountCost > 0 ? $globalAccountCost : $accountCost;
$node['paid_appointment_count'] = $paidAppointmentCount;
$node['free_appointment_count'] = $freeAppointmentCount;
$node['appointment_total_count'] = $appointmentTotalCount;
$node['completed_order_amount'] = $completedOrderAmount;
$node['account_cost'] = $accountCost;
$node['account_cost'] = $effectiveAccountCost;
$node['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
$node['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
$node['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
@@ -828,8 +808,11 @@ class ConversionLogic
$node['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
$node['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
$node['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
$node['cash_cost'] = self::safeDivideMoney($accountCost, $addFansCount);
$node['roi'] = self::safeDivideRatio($completedOrderAmount, $accountCost);
$node['cash_cost'] = self::safeDivideMoney($effectiveAccountCost, $addFansCount);
$node['roi'] = self::safeDivideRatio($completedOrderAmount, $effectiveAccountCost);
if ($globalAccountCost > 0) {
$node['_global_account_cost'] = $globalAccountCost;
}
unset($node['sort'], $node['pid']);
return $node;
@@ -853,6 +836,7 @@ class ConversionLogic
'completed_order_count' => 0,
'account_cost' => 0.0,
];
$globalAccountCost = 0.0;
foreach ($rows as $row) {
$summary['add_fans_count'] += (int)$row['add_fans_count'];
@@ -864,9 +848,11 @@ class ConversionLogic
$summary['interview_count'] += (int)$row['interview_count'];
$summary['completed_order_count'] += (int)$row['completed_order_count'];
$summary['completed_order_amount'] = round($summary['completed_order_amount'] + (float)$row['completed_order_amount'], 2);
$summary['account_cost'] = round($summary['account_cost'] + (float)$row['account_cost'], 2);
$globalAccountCost = max($globalAccountCost, round((float)($row['_global_account_cost'] ?? 0), 2));
}
$summary['account_cost'] = $globalAccountCost;
$summary['paid_appointment_rate'] = self::percent($summary['paid_appointment_count'], $summary['add_fans_count']);
$summary['open_appointment_rate'] = self::percent($summary['paid_appointment_count'], $summary['total_open_count']);
$summary['interview_rate'] = self::percent($summary['interview_count'], $summary['appointment_total_count']);