first commit

This commit is contained in:
Your Name
2026-09-08 11:40:15 +08:00
commit a5353f7eb5
9568 changed files with 1646214 additions and 0 deletions
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\stats;
use think\facade\Db;
class AssistantPerformanceLogic
{
/** 履约完成 */
private const FULFILLMENT_COMPLETED = 3;
public static function overview(array $params, int $adminId, array $adminInfo): array
{
// 时间范围解析
$timeType = $params['time_type'] ?? 'month';
$today = date('Y-m-d');
switch ($timeType) {
case 'today':
$startDate = $today;
$endDate = $today;
break;
case 'yesterday':
$startDate = date('Y-m-d', strtotime('-1 day'));
$endDate = $startDate;
break;
case 'week':
$startDate = date('Y-m-d', strtotime('-6 days'));
$endDate = $today;
break;
case 'month':
$startDate = date('Y-m-d', strtotime('-29 days'));
$endDate = $today;
break;
case 'custom':
$startDate = $params['start_date'] ?? $today;
$endDate = $params['end_date'] ?? $today;
break;
default:
$startDate = date('Y-m-d', strtotime('-29 days'));
$endDate = $today;
}
$startTs = strtotime($startDate . ' 00:00:00');
$endTs = strtotime($endDate . ' 23:59:59');
// 查询当前医助创建的、履约已完成的处方业务订单
$baseQuery = Db::name('tcm_prescription_order')
->where('delete_time IS NULL')
->where('diagnosis_id', '>', 0)
->where('creator_id', $adminId)
->where('fulfillment_status', self::FULFILLMENT_COMPLETED)
->where('create_time', '>=', $startTs)
->where('create_time', '<=', $endTs);
// 业绩总额
$totalAmount = (clone $baseQuery)->sum('amount');
// 有效订单数
$totalCount = (clone $baseQuery)->count();
// 按日期分组的折线图数据
$dailyData = (clone $baseQuery)
->field("FROM_UNIXTIME(create_time, '%Y-%m-%d') as date_label, SUM(amount) as daily_amount, COUNT(*) as daily_count")
->group('date_label')
->order('date_label', 'asc')
->select()
->toArray();
// 补全日期范围内的空日期
$dateMap = [];
foreach ($dailyData as $row) {
$dateMap[$row['date_label']] = [
'amount' => round((float)$row['daily_amount'], 2),
'count' => (int)$row['daily_count'],
];
}
$dates = [];
$amounts = [];
$counts = [];
$cursor = strtotime($startDate);
$endCursor = strtotime($endDate);
while ($cursor <= $endCursor) {
$d = date('Y-m-d', $cursor);
$dates[] = substr($d, 5); // MM-DD
$amounts[] = $dateMap[$d]['amount'] ?? 0;
$counts[] = $dateMap[$d]['count'] ?? 0;
$cursor = strtotime('+1 day', $cursor);
}
return [
'date_range' => [$startDate, $endDate],
'summary' => [
'total_amount' => round((float)$totalAmount, 2),
'total_count' => (int)$totalCount,
],
'chart' => [
'dates' => $dates,
'amounts' => $amounts,
'counts' => $counts,
],
];
}
}
@@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\common\logic\BaseLogic;
use think\facade\Db;
use think\facade\Log;
/**
* 待分配诊单自动指派日志:回退已自动分配的医助
*/
class AutoAssignLogLogic extends BaseLogic
{
/**
* 批量回退:将诊单医助撤回到自动分配前的原医助(指派日志 from_assistant_id),并标记自动分配日志已回退。
*
* @param list<int|string> $ids 自动分配日志 id
* @return array{success:int,failed:int,messages:list<string>}|false
*/
public static function rollback(array $ids, int $adminId, array $adminInfo = []): array|false
{
$idList = array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $id) => $id > 0)));
if ($idList === []) {
self::setError('请选择要回退的记录');
return false;
}
$adminName = (string) ($adminInfo['name'] ?? '');
$adminAccount = (string) ($adminInfo['account'] ?? '');
$req = request();
$ip = (string) ($req->ip() ?? '');
$now = time();
$success = 0;
$failed = 0;
$messages = [];
foreach ($idList as $logId) {
try {
$ret = self::rollbackOne($logId, $adminId, $adminName, $adminAccount, $ip, $now);
if ($ret === true) {
$success++;
} else {
$failed++;
$messages[] = (string) $ret;
}
} catch (\Throwable $e) {
$failed++;
$messages[] = sprintf('日志#%d%s', $logId, $e->getMessage());
Log::warning('auto assign rollback failed: ' . $e->getMessage(), ['log_id' => $logId]);
}
}
if ($success === 0 && $failed > 0) {
self::setError($messages[0] ?? '回退失败');
return false;
}
return [
'success' => $success,
'failed' => $failed,
'messages' => $messages,
];
}
/**
* @return true|string true=成功,string=失败原因
*/
private static function rollbackOne(
int $logId,
int $adminId,
string $adminName,
string $adminAccount,
string $ip,
int $now
): bool|string {
Db::startTrans();
try {
$log = Db::name('tcm_diagnosis_auto_assign_log')
->where('id', $logId)
->lock(true)
->find();
if ($log === null || $log === []) {
Db::rollback();
return sprintf('日志#%d:记录不存在', $logId);
}
if ((int) ($log['action'] ?? 0) !== 1) {
Db::rollback();
return sprintf('日志#%d:仅「已分配」记录可回退', $logId);
}
if ((int) ($log['rollback_time'] ?? 0) > 0) {
Db::rollback();
return sprintf('日志#%d:已回退,勿重复操作', $logId);
}
$diagnosisId = (int) ($log['diagnosis_id'] ?? 0);
$assignedAssistantId = (int) ($log['assistant_id'] ?? 0);
if ($diagnosisId <= 0 || $assignedAssistantId <= 0) {
Db::rollback();
return sprintf('日志#%d:数据不完整,无法回退', $logId);
}
$diag = Db::name('tcm_diagnosis')
->where('id', $diagnosisId)
->whereNull('delete_time')
->lock(true)
->field(['id', 'assistant_id'])
->find();
if ($diag === null || $diag === []) {
Db::rollback();
return sprintf('日志#%d:诊单#%d 不存在', $logId, $diagnosisId);
}
$currentAssistantId = (int) ($diag['assistant_id'] ?? 0);
if ($currentAssistantId !== $assignedAssistantId) {
Db::rollback();
return sprintf(
'日志#%d:诊单#%d 当前医助已变更(非自动分配的医助),跳过回退',
$logId,
$diagnosisId
);
}
$prevAssistantId = self::resolvePreviousAssistantIdFromAutoAssign(
$diagnosisId,
$assignedAssistantId,
(int) ($log['create_time'] ?? 0)
);
$poSnap = Db::name('tcm_prescription_order')
->where('diagnosis_id', $diagnosisId)
->whereNull('delete_time')
->order(['create_time' => 'desc', 'id' => 'desc'])
->field(['creator_id', 'create_time'])
->find();
$relatedPoCreatorId = (int) ($poSnap['creator_id'] ?? 0);
$relatedPoCreateTime = (int) ($poSnap['create_time'] ?? 0);
if ($relatedPoCreateTime <= 0) {
$relatedPoCreateTime = $now;
$relatedPoCreatorId = 0;
}
Db::name('tcm_diagnosis')
->where('id', $diagnosisId)
->whereNull('delete_time')
->update([
'assistant_id' => $prevAssistantId,
'assign_read_at' => $prevAssistantId > 0 ? null : 0,
]);
Db::name('tcm_diagnosis_assign_log')->insert([
'diagnosis_id' => $diagnosisId,
'from_assistant_id' => $currentAssistantId,
'to_assistant_id' => $prevAssistantId,
'operator_admin_id' => $adminId,
'operator_name' => $adminName !== '' ? $adminName : '回退自动分配',
'operator_account' => $adminAccount,
'ip' => $ip,
'related_po_creator_id' => $relatedPoCreatorId,
'related_po_create_time' => $relatedPoCreateTime,
'is_inherit' => 0,
'create_time' => $now,
]);
Db::name('tcm_diagnosis_auto_assign_log')
->where('id', $logId)
->update([
'rollback_time' => $now,
'rollback_admin_id' => $adminId,
'rollback_admin_name' => $adminName,
]);
Db::commit();
return true;
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
}
/**
* 从「系统自动分配」指派日志取 from_assistant_id 作为回退目标。
*/
private static function resolvePreviousAssistantIdFromAutoAssign(
int $diagnosisId,
int $assignedAssistantId,
int $autoLogCreateTime
): int {
$query = Db::name('tcm_diagnosis_assign_log')
->where('diagnosis_id', $diagnosisId)
->where('to_assistant_id', $assignedAssistantId)
->where('operator_name', '系统自动分配');
if ($autoLogCreateTime > 0) {
$query->where('create_time', '>=', $autoLogCreateTime - 30)
->where('create_time', '<=', $autoLogCreateTime + 30);
}
$row = $query->order('id', 'desc')->field(['from_assistant_id'])->find();
if ($row !== null && $row !== []) {
return (int) ($row['from_assistant_id'] ?? 0);
}
// 兜底:不限时间窗再查最近一条系统自动分配
$fallback = Db::name('tcm_diagnosis_assign_log')
->where('diagnosis_id', $diagnosisId)
->where('to_assistant_id', $assignedAssistantId)
->where('operator_name', '系统自动分配')
->order('id', 'desc')
->field(['from_assistant_id'])
->find();
return (int) ($fallback['from_assistant_id'] ?? 0);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,662 @@
<?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;
/**
* 医生统计:日期区间、渠道/标签、数据范围与业绩看板一致;当显式传入 dept_ids 时,
* **以部门下的医助为入口**收窄聚合(部门 → admin_dept 命中的医助集合 → 该医助经手的挂号 / 订单 / 处方 → 医生)。
*
* 医生范围:<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 ∈ 标签诊单集
* - 部门:dept_ids 由 YejiStatsLogic::resolveSharedYejiFilterContext 解析后透出 adminToPrimary(含全部子级展开后的 admin 集合),
* 下方聚合在挂号 / 订单 / 处方表上用以下口径筛选医助:
* · 挂号:COALESCE(NULLIF(a.assistant_id,0), NULLIF(u.assistant_id,0)) ∈ 医助集合
* · 订单:o.creator_id ∈ 医助集合(与业绩归属同源)
* · 处方:tcm_diagnosis.assistant_id ∈ 医助集合(rx.diagnosis_id JOIN tcm_diagnosis
* 医生集合不被部门收窄(医生通常不挂在「中心」部门);展示行最终在前端按所有医生输出,仅在 dept_filter 激活时隐藏「全 0」医生。
*/
class DoctorDailyStatsLogic
{
/**
* @param array{
* start_date?:string,
* end_date?:string,
* dept_ids?:int[]|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 $trustedDoctorIds = null,
?array $trustedAssistantIds = null
): array
{
// dept_ids 透传至共享上下文:未传时仍按默认「中心」树解析(仅用于挂号率默认 0 等兜底);
// 显式传入时由下方 $deptScopedAdminIds 分支用 adminToPrimary 取出医助集合,并下推到三类聚合作为「经手医助」筛选。
$ctx = YejiStatsLogic::resolveSharedYejiFilterContext($params, $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);
$deptFilterActive = self::hasExplicitDeptIds($params['dept_ids'] ?? null);
$doctorIds = $trustedDoctorIds === null
? self::resolveDoctorAdminIdsForStats($viewerAdminId, $viewerAdminInfo)
: self::resolveTrustedDoctorIds($trustedDoctorIds);
if ($filterDoctorId > 0) {
$doctorIds = in_array($filterDoctorId, $doctorIds, true) ? [$filterDoctorId] : [];
}
// 部门下医助集合(含全部子级展开后的 admin_dept 命中者);未显式选部门时不参与筛选 → null。
// 显式选部门但集合为空 ⇒ 该部门下无可见医助,直接返回空结果。
$deptScopedAdminIds = $trustedAssistantIds === null
? null
: self::normalizePositiveIds($trustedAssistantIds);
if ($trustedAssistantIds === null && $deptFilterActive) {
$deptScopedAdminIds = array_values(array_unique(array_map(
'intval',
array_keys($ctx['adminToPrimary'] ?? [])
)));
if ($deptScopedAdminIds === []) {
return [
'start_date' => $startDate,
'end_date' => $endDate,
'rows' => [],
'total' => self::emptyTotals(),
];
}
}
if ($doctorIds === []) {
return [
'start_date' => $startDate,
'end_date' => $endDate,
'rows' => [],
'total' => self::emptyTotals(),
];
}
$rxMap = self::loadPrescriptionCounts(
$startDate,
$endDate,
$doctorIds,
$appointmentChannelValues,
$channelFilterActive,
$tagDiagIds,
$tagAssistantIds,
$tagFallback,
$deptScopedAdminIds
);
$orderMap = self::loadOrderAggregates(
$startTs,
$endTs,
$doctorIds,
$appointmentChannelValues,
$channelFilterActive,
$tagDiagIds,
$tagAssistantIds,
$tagFallback,
$deptScopedAdminIds
);
$apptMap = self::loadAppointmentAggregates(
$startDate,
$endDate,
$doctorIds,
$appointmentChannelValues,
$channelFilterActive,
$tagDiagIds,
$deptScopedAdminIds
);
$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, 'total' => 0];
$cnt = (int) $ord['count'];
$amt = round((float) $ord['amount'], 2);
$apTotal = (int) ($ap['total'] ?? 0);
$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_total' => $apTotal,
'appointment_completed' => (int) $ap['completed'],
'appointment_missed' => (int) $ap['missed'],
'appointment_cancelled' => (int) $ap['cancelled'],
// 挂号率 = 成交单数 / 总挂号数 × 100;总挂号为 0 时返回 null(前端展示「—」)。
'appointment_conversion_rate' => $apTotal > 0 ? round($cnt / $apTotal * 100, 2) : null,
];
}
// 显式部门筛选时隐藏「该部门无任何关联」的医生,避免列出大量全 0 行。
if ($deptFilterActive || $trustedAssistantIds !== null) {
$rows = array_values(array_filter($rows, static function (array $r): bool {
return (int) ($r['system_prescription_count'] ?? 0) > 0
|| (int) ($r['manual_prescription_count'] ?? 0) > 0
|| (float) ($r['deal_amount'] ?? 0) > 0
|| (int) ($r['deal_order_count'] ?? 0) > 0
|| (int) ($r['appointment_total'] ?? 0) > 0;
}));
}
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),
];
}
/**
* 是否显式传入 dept_ids(含 1 个以上正整数即视为显式)。与 YejiStatsLogic::hasExplicitDeptIdsParam 同口径。
*
* @param mixed $raw
*/
private static function hasExplicitDeptIds($raw): bool
{
if (\is_string($raw) && trim($raw) !== '') {
foreach (explode(',', $raw) as $p) {
if ((int) trim($p) > 0) {
return true;
}
}
}
if (\is_array($raw)) {
foreach ($raw as $v) {
if ((int) $v > 0) {
return true;
}
}
}
return false;
}
/**
* 医生角色(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;
}
/**
* 仅供服务端内部聚合页传入已经过权限计算的医生集合;仍再次校验医生角色与软删除状态。
* HTTP 参数不会进入此分支。
*
* @param array<int|string,mixed> $trustedDoctorIds
* @return int[]
*/
private static function resolveTrustedDoctorIds(array $trustedDoctorIds): array
{
$ids = self::normalizePositiveIds($trustedDoctorIds);
if ($ids === []) {
return [];
}
return self::normalizePositiveIds(Db::name('admin_role')->alias('ar')
->join('admin a', 'a.id = ar.admin_id')
->where('ar.role_id', 1)
->whereIn('ar.admin_id', $ids)
->whereNull('a.delete_time')
->column('ar.admin_id'));
}
/** @param array<int|string,mixed> $ids @return int[] */
private static function normalizePositiveIds(array $ids): array
{
return array_values(array_unique(array_filter(array_map(
'intval',
$ids
), static fn (int $id): bool => $id > 0)));
}
/**
* @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_total' => 0,
'appointment_completed' => 0,
'appointment_missed' => 0,
'appointment_cancelled' => 0,
'appointment_conversion_rate' => null,
];
}
/**
* @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_total'] += (int) ($r['appointment_total'] ?? 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;
$apTotal = (int) $t['appointment_total'];
// 合计行挂号率:行行加总后再统一计算,与汇总后的 成交单数 / 总挂号数 对齐。
$t['appointment_conversion_rate'] = $apTotal > 0 ? round($dc / $apTotal * 100, 2) : null;
return $t;
}
/**
* @param int[] $appointmentChannelValues
* @param int[]|null $tagDiagIds
* @param array<int,int>|null $tagAssistantIds
* @param int[]|null $deptScopedAdminIds 部门下医助集合:非 null 时加 tcm_diagnosis.assistant_id IN (...) 约束
*
* @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 $deptScopedAdminIds = null
): array {
if (!self::tagScopeNonEmpty($tagDiagIds, $tagAssistantIds)) {
return [];
}
if ($deptScopedAdminIds !== null && $deptScopedAdminIds === []) {
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);
// 部门 → 医助:仅保留经手医助归属在所选部门子树的处方(通过诊单 assistant_id 关联)
if ($deptScopedAdminIds !== null) {
$query->join('tcm_diagnosis dg', 'dg.id = rx.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->whereIn('dg.assistant_id', $deptScopedAdminIds);
}
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
* @param int[]|null $deptScopedAdminIds 部门下医助集合:非 null 时加 o.creator_id IN (...) 约束(与业绩归属同源)
*
* @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 $deptScopedAdminIds = null
): array {
if (!self::tagScopeNonEmpty($tagDiagIds, $tagAssistantIds)) {
return [];
}
if ($deptScopedAdminIds !== null && $deptScopedAdminIds === []) {
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::applyPrescriptionOrderEffectiveAmountQuery($q, 'o');
$q->whereIn('rx.creator_id', $doctorIds)
->where('o.diagnosis_id', '>', 0);
// 部门 → 医助:业绩归属同源(o.creator_id 即订单创建医助)
if ($deptScopedAdminIds !== null) {
$q->whereIn('o.creator_id', $deptScopedAdminIds);
}
$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
* @param int[]|null $deptScopedAdminIds 部门下医助集合:非 null 时加
* COALESCE(NULLIF(a.assistant_id,0), NULLIF(u.assistant_id,0)) IN (...) 约束
*
* @return array<int, array{completed:int, missed:int, cancelled:int, total:int}>
*/
private static function loadAppointmentAggregates(
string $startDate,
string $endDate,
array $doctorIds,
array $appointmentChannelValues,
bool $channelFilterActive,
?array $tagDiagIds,
?array $deptScopedAdminIds = null
): array {
if ($deptScopedAdminIds !== null && $deptScopedAdminIds === []) {
return [];
}
$needsDiagJoin = $deptScopedAdminIds !== null;
if ($needsDiagJoin) {
$q = Db::name('doctor_appointment')->alias('a')
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
->whereBetween('a.appointment_date', [$startDate, $endDate])
->whereIn('a.doctor_id', $doctorIds)
->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)');
$aliasPrefix = 'a.';
} else {
$q = Db::name('doctor_appointment')
->whereBetween('appointment_date', [$startDate, $endDate])
->whereIn('doctor_id', $doctorIds);
$aliasPrefix = '';
}
$normCh = self::normalizeAppointmentChannelInts($appointmentChannelValues);
if ($normCh !== []) {
$strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $normCh)));
$q->whereIn($aliasPrefix . 'channels', $strVals);
} elseif ($channelFilterActive && $tagDiagIds !== null) {
if ($tagDiagIds === []) {
return [];
}
$q->whereIn($aliasPrefix . 'patient_id', $tagDiagIds);
}
// 部门 → 医助:与业绩看板 consultEffectiveAssistantSql 同口径 —— 优先挂号创建人,回退诊单医助
if ($needsDiagJoin) {
$effIds = array_map('intval', $deptScopedAdminIds);
$inList = implode(',', $effIds);
$q->whereRaw('COALESCE(NULLIF(a.assistant_id, 0), NULLIF(u.assistant_id, 0)) IN (' . $inList . ')');
}
// total = 当前筛选下挂号总数(含 status=1 已预约/2 已取消/3 已完成/4 已过号),用于计算「挂号率」。
$q->field([
$aliasPrefix . 'doctor_id',
Db::raw('COUNT(*) AS total'),
Db::raw('SUM(CASE WHEN ' . $aliasPrefix . 'status = 3 THEN 1 ELSE 0 END) AS completed'),
Db::raw('SUM(CASE WHEN ' . $aliasPrefix . 'status = 4 THEN 1 ELSE 0 END) AS missed'),
Db::raw('SUM(CASE WHEN ' . $aliasPrefix . 'status = 2 THEN 1 ELSE 0 END) AS cancelled'),
])->group($aliasPrefix . 'doctor_id');
$out = [];
foreach ($q->select()->toArray() as $r) {
$id = (int) ($r['doctor_id'] ?? 0);
if ($id <= 0) {
continue;
}
$out[$id] = [
'total' => (int) ($r['total'] ?? 0),
'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;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,407 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\adminapi\logic\dept\DeptLogic;
use app\common\model\auth\AdminDept;
use app\common\model\auth\AdminRole;
use app\common\model\auth\SystemRole;
use app\common\model\dept\Dept;
use app\common\service\DataScope\DataScopeService;
/**
* 首页 KPI 数据范围:按角色而不是单纯按 data_scope。
*
* 医助=仅本人;组长=本小组全部成员;经理=本部门及下级;管理员/超管=全部。
* 本人业绩卡片始终按登录账号单独统计,不走这套范围。
*/
class PerformanceDashboardScope
{
public const KIND_ADMIN = 'admin';
public const KIND_MANAGER = 'manager';
public const KIND_GROUP_LEADER = 'group_leader';
public const KIND_ASSISTANT = 'assistant';
/**
* @return array{
* kind: string,
* label: string,
* metric_admin_ids: array<int>|null
* }
*/
public static function resolve(int $adminId, array $adminInfo): array
{
$roleNames = self::roleNames($adminId);
$kind = self::classify((int) ($adminInfo['root'] ?? 0) === 1, $roleNames, $adminInfo);
$metricAdminIds = self::metricAdminIds($kind, $adminId, $adminInfo);
return [
'kind' => $kind,
'label' => self::kindLabel($kind),
'metric_admin_ids' => $metricAdminIds,
];
}
public static function kindLabel(string $kind): string
{
return [
self::KIND_ADMIN => '全部数据',
self::KIND_MANAGER => '本部门',
self::KIND_GROUP_LEADER => '本小组',
self::KIND_ASSISTANT => '仅本人',
][$kind] ?? '数据范围';
}
/**
* @param string[] $roleNames
*/
public static function classify(bool $isRoot, array $roleNames, array $adminInfo = []): string
{
if ($isRoot) {
return self::KIND_ADMIN;
}
if (self::roleNamesMatch($roleNames, ['管理员'])) {
return self::KIND_ADMIN;
}
if (self::roleNamesMatch($roleNames, ['经理'])) {
return self::KIND_MANAGER;
}
if (self::roleNamesMatch($roleNames, ['诊室组长', '组长'])) {
return self::KIND_GROUP_LEADER;
}
// 医助角色固定仅本人,不因部门负责人或 data_scope 放大到小组。
if (self::roleNamesMatch($roleNames, ['医助'])) {
return self::KIND_ASSISTANT;
}
$scope = DataScopeService::getEffectiveScope($adminInfo);
return match ($scope) {
DataScopeService::SCOPE_ALL => self::KIND_ADMIN,
DataScopeService::SCOPE_DEPT_AND_CHILD => self::KIND_MANAGER,
DataScopeService::SCOPE_DEPT => self::KIND_GROUP_LEADER,
default => self::KIND_ASSISTANT,
};
}
/**
* @return array<int>|null
*/
private static function metricAdminIds(string $kind, int $adminId, array $adminInfo = []): ?array
{
if ($kind === self::KIND_ADMIN) {
return null;
}
if ($kind === self::KIND_ASSISTANT || $adminId <= 0) {
return $adminId > 0 ? [$adminId] : [];
}
if ($kind === self::KIND_GROUP_LEADER) {
$ids = self::adminsInGroup($adminId, $adminInfo);
return $ids !== [] ? $ids : ($adminId > 0 ? [$adminId] : []);
}
$ids = self::adminsInOwnDeptTree($adminId);
if ($ids === []) {
return $adminId > 0 ? [$adminId] : [];
}
return $ids;
}
/**
* 组长小组:只取本人最深的部门(不含一中心/二中心整棵树),并并入其担任负责人的部门。
*
* @return int[]
*/
private static function adminsInGroup(int $adminId, array $adminInfo): array
{
$ownDeptIds = self::ownDeptIds($adminId);
$leafDeptIds = self::leafDeptIds($ownDeptIds);
$ledDeptIds = self::ledDeptIds($adminId, $adminInfo, $ownDeptIds);
$groupDeptIds = array_values(array_unique(array_merge($leafDeptIds, $ledDeptIds)));
$groupDeptIds = self::dropCenterRootsIfHasDeeper($groupDeptIds);
if ($groupDeptIds === []) {
$groupDeptIds = $leafDeptIds !== [] ? $leafDeptIds : $ownDeptIds;
}
$deptIds = [];
foreach ($groupDeptIds as $deptId) {
foreach (DeptLogic::getSelfAndDescendantIds((int) $deptId) as $id) {
$id = (int) $id;
if ($id > 0) {
$deptIds[] = $id;
}
}
}
$deptIds = array_values(array_unique($deptIds));
if ($deptIds === []) {
return $adminId > 0 ? [$adminId] : [];
}
$adminIds = array_values(array_unique(array_filter(
array_map('intval', AdminDept::whereIn('dept_id', $deptIds)->column('admin_id')),
static fn (int $id): bool => $id > 0
)));
if ($adminId > 0 && !in_array($adminId, $adminIds, true)) {
$adminIds[] = $adminId;
}
return $adminIds;
}
/**
* @return int[]
*/
private static function ownDeptIds(int $adminId): array
{
return array_values(array_unique(array_filter(
array_map('intval', AdminDept::where('admin_id', $adminId)->column('dept_id')),
static fn (int $id): bool => $id > 0
)));
}
/**
* 在本人所属部门里只留最深的节点,避免挂在「一中心」上就把整个中心当成小组。
*
* @param int[] $ownDeptIds
* @return int[]
*/
private static function leafDeptIds(array $ownDeptIds): array
{
if ($ownDeptIds === []) {
return [];
}
$deptById = [];
$rows = Dept::whereNull('delete_time')->field(['id', 'pid', 'name'])->select()->toArray();
foreach ($rows as $row) {
$id = (int) ($row['id'] ?? 0);
if ($id > 0) {
$deptById[$id] = [
'pid' => (int) ($row['pid'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
];
}
}
$ownSet = array_fill_keys($ownDeptIds, true);
$leaves = [];
foreach ($ownDeptIds as $id) {
$hasOwnDescendant = false;
foreach ($ownDeptIds as $other) {
if ($other === $id) {
continue;
}
if (self::isAncestorOf($id, $other, $deptById)) {
$hasOwnDescendant = true;
break;
}
}
if (!$hasOwnDescendant && isset($ownSet[$id])) {
$leaves[] = $id;
}
}
return array_values(array_unique($leaves));
}
/**
* @param array<int, array{pid: int, name: string}> $deptById
*/
private static function isAncestorOf(int $ancestorId, int $nodeId, array $deptById): bool
{
$current = $nodeId;
$seen = [];
while ($current > 0 && isset($deptById[$current]) && !isset($seen[$current])) {
$seen[$current] = true;
$pid = $deptById[$current]['pid'];
if ($pid === $ancestorId) {
return true;
}
$current = $pid;
}
return false;
}
/**
* 部门负责人姓名匹配当前组长时,把该部门算进小组。仅用于已判定为组长的账号。
*
* @param int[] $ownDeptIds
* @return int[]
*/
private static function ledDeptIds(int $adminId, array $adminInfo, array $ownDeptIds): array
{
$name = self::normalizePersonName((string) ($adminInfo['name'] ?? ''));
if ($adminId <= 0 || $name === '') {
return [];
}
$rows = Dept::whereNull('delete_time')->field(['id', 'pid', 'leader'])->select()->toArray();
$ownSet = array_fill_keys($ownDeptIds, true);
$led = [];
foreach ($rows as $row) {
$deptId = (int) ($row['id'] ?? 0);
$leaderName = self::normalizePersonName((string) ($row['leader'] ?? ''));
if ($deptId <= 0 || $leaderName === '' || $leaderName !== $name) {
continue;
}
if ($ownSet === [] || isset($ownSet[$deptId]) || self::deptUnderOwnTree($deptId, $ownDeptIds)) {
$led[] = $deptId;
}
}
return array_values(array_unique($led));
}
/**
* @param int[] $ownDeptIds
*/
private static function deptUnderOwnTree(int $deptId, array $ownDeptIds): bool
{
foreach ($ownDeptIds as $rootId) {
$ids = DeptLogic::getSelfAndDescendantIds((int) $rootId);
foreach ($ids as $id) {
if ((int) $id === $deptId) {
return true;
}
}
}
return false;
}
/**
* @param int[] $deptIds
* @return int[]
*/
private static function dropCenterRootsIfHasDeeper(array $deptIds): array
{
$names = [];
if ($deptIds !== []) {
$names = Dept::whereIn('id', $deptIds)->whereNull('delete_time')->column('name', 'id');
}
$hasDeeper = false;
foreach ($deptIds as $id) {
$name = (string) ($names[$id] ?? '');
if ($name !== '' && mb_strpos($name, '一中心') === false && mb_strpos($name, '二中心') === false) {
$hasDeeper = true;
break;
}
}
if (!$hasDeeper) {
return $deptIds;
}
$kept = [];
foreach ($deptIds as $id) {
$name = (string) ($names[$id] ?? '');
if ($name !== '' && (mb_strpos($name, '一中心') !== false || mb_strpos($name, '二中心') !== false)) {
continue;
}
$kept[] = $id;
}
return $kept;
}
private static function normalizePersonName(string $raw): string
{
$value = trim($raw);
if ($value === '') {
return '';
}
$value = preg_replace('/[(][^)]*[)]/u', '', $value) ?? $value;
$value = preg_replace('/[\s\x{3000}]+/u', '', $value) ?? $value;
$suffixes = ['组长', '负责人', '主管', '主任', '医师', '医生', '医助', '老师'];
foreach ($suffixes as $suffix) {
$len = mb_strlen($suffix);
while (mb_strlen($value) > $len && mb_substr($value, -$len) === $suffix) {
$value = mb_substr($value, 0, mb_strlen($value) - $len);
}
}
return trim($value);
}
/**
* @return int[]
*/
private static function adminsInOwnDeptTree(int $adminId): array
{
$ownDeptIds = array_values(array_unique(array_filter(
array_map('intval', AdminDept::where('admin_id', $adminId)->column('dept_id')),
static fn (int $id): bool => $id > 0
)));
if ($ownDeptIds === []) {
return $adminId > 0 ? [$adminId] : [];
}
$deptIds = [];
foreach ($ownDeptIds as $deptId) {
foreach (DeptLogic::getSelfAndDescendantIds($deptId) as $id) {
$id = (int) $id;
if ($id > 0) {
$deptIds[] = $id;
}
}
}
$deptIds = array_values(array_unique($deptIds));
if ($deptIds === []) {
return [$adminId];
}
$adminIds = array_values(array_unique(array_filter(
array_map('intval', AdminDept::whereIn('dept_id', $deptIds)->column('admin_id')),
static fn (int $id): bool => $id > 0
)));
if ($adminId > 0 && !in_array($adminId, $adminIds, true)) {
$adminIds[] = $adminId;
}
return $adminIds;
}
/**
* @return string[]
*/
private static function roleNames(int $adminId): array
{
if ($adminId <= 0) {
return [];
}
$roleIds = array_values(array_unique(array_filter(
array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id')),
static fn (int $id): bool => $id > 0
)));
if ($roleIds === []) {
return [];
}
$names = SystemRole::whereIn('id', $roleIds)
->whereNull('delete_time')
->column('name');
return array_values(array_filter(array_map('strval', $names)));
}
/**
* @param string[] $roleNames
* @param string[] $needles
*/
private static function roleNamesMatch(array $roleNames, array $needles): bool
{
foreach ($roleNames as $name) {
$name = trim($name);
if ($name === '') {
continue;
}
foreach ($needles as $needle) {
if ($name === $needle || mb_strpos($name, $needle) !== false) {
return true;
}
}
}
return false;
}
}
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\common\logic\BaseLogic;
use app\common\model\stats\PersonalAccountCost;
class PersonalAccountCostLogic extends BaseLogic
{
use PersonalStatsScopeTrait;
public static function add(array $params, int $adminId, string $adminName): bool
{
try {
$costDate = (string) $params['cost_date'];
$mediaSource = self::normalizeMediaSource((string) ($params['media_source'] ?? ''));
if ($mediaSource === '') {
self::setError('请填写自媒体来源');
return false;
}
$dupId = self::findCostDuplicateId($adminId, $costDate, $mediaSource);
if ($dupId > 0) {
self::setError("您在 {$costDate} 已录入过【{$mediaSource}】账户消耗(记录#{$dupId}),请直接编辑该记录");
return false;
}
PersonalAccountCost::create([
'cost_date' => $costDate,
'media_source' => $mediaSource,
'amount' => round((float) ($params['amount'] ?? 0), 2),
'remark' => (string) ($params['remark'] ?? ''),
'creator_id' => $adminId,
'creator_name' => $adminName,
'updater_id' => $adminId,
'updater_name' => $adminName,
'dept_id' => self::resolvePrimaryDeptId($adminId),
]);
return true;
} catch (\Throwable $e) {
if (self::isUniqueConstraintViolation($e)) {
self::setError('该日期下该渠道的账户消耗已存在(唯一索引冲突),请刷新列表后直接编辑');
} else {
self::setError($e->getMessage());
}
return false;
}
}
public static function edit(array $params, int $adminId, string $adminName, array $adminInfo): bool
{
try {
$model = PersonalAccountCost::find($params['id']);
if (!$model) {
self::setError('记录不存在');
return false;
}
if (!self::assertRecordVisible($adminId, $adminInfo, (int) $model->creator_id)) {
self::setError('无权操作该记录');
return false;
}
$model->amount = round((float) ($params['amount'] ?? 0), 2);
$model->remark = (string) ($params['remark'] ?? '');
$model->updater_id = $adminId;
$model->updater_name = $adminName;
$model->save();
return true;
} catch (\Throwable $e) {
self::setError($e->getMessage());
return false;
}
}
public static function delete(int $id, int $adminId, array $adminInfo): bool
{
try {
$model = PersonalAccountCost::find($id);
if (!$model) {
self::setError('记录不存在');
return false;
}
if (!self::assertRecordVisible($adminId, $adminInfo, (int) $model->creator_id)) {
self::setError('无权操作该记录');
return false;
}
$model->delete();
return true;
} catch (\Throwable $e) {
self::setError($e->getMessage());
return false;
}
}
public static function detail(int $id, int $adminId, array $adminInfo): array
{
$model = PersonalAccountCost::find($id);
if (!$model) {
return [];
}
if (!self::assertRecordVisible($adminId, $adminInfo, (int) $model->creator_id)) {
return [];
}
return $model->toArray();
}
}
@@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\adminapi\logic\dept\DeptLogic;
use app\common\model\auth\AdminDept;
use app\common\model\dept\Dept;
use app\common\model\stats\PersonalAccountCost;
use app\common\model\stats\PersonalYeji;
use app\common\service\DataScope\DataScopeService;
use think\facade\Config;
trait PersonalStatsScopeTrait
{
protected static function resolvePrimaryDeptId(int $adminId): int
{
if ($adminId <= 0) {
return 0;
}
$deptId = AdminDept::where('admin_id', $adminId)->value('dept_id');
return (int) ($deptId ?: 0);
}
/**
* @param array<int, int> $creatorIds
* @return array{0: array<int, int>, 1: array<int, string>, 2: array<int, string>}
* [adminId => deptId, deptId => name, deptId => "祖/父/当前"]
*/
protected static function loadAdminDeptMap(array $creatorIds): array
{
$creatorIds = array_values(array_unique(array_filter(array_map('intval', $creatorIds))));
if ($creatorIds === []) {
return [[], [], []];
}
$rows = AdminDept::whereIn('admin_id', $creatorIds)
->field('admin_id, dept_id')
->select()
->toArray();
$adminToDeptId = [];
foreach ($rows as $row) {
$adminId = (int) ($row['admin_id'] ?? 0);
$deptId = (int) ($row['dept_id'] ?? 0);
if ($adminId <= 0 || $deptId <= 0) {
continue;
}
if (!isset($adminToDeptId[$adminId])) {
$adminToDeptId[$adminId] = $deptId;
}
}
if ($adminToDeptId === []) {
return [[], [], []];
}
$allDeptRows = Dept::field('id, pid, name')->select()->toArray();
$deptIndex = [];
foreach ($allDeptRows as $row) {
$id = (int) ($row['id'] ?? 0);
if ($id <= 0) {
continue;
}
$deptIndex[$id] = [
'pid' => (int) ($row['pid'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
];
}
$deptNameMap = [];
$deptPathMap = [];
foreach (array_unique(array_values($adminToDeptId)) as $deptId) {
$deptId = (int) $deptId;
if ($deptId <= 0 || !isset($deptIndex[$deptId])) {
continue;
}
$deptNameMap[$deptId] = $deptIndex[$deptId]['name'];
$deptPathMap[$deptId] = self::resolveDeptPath($deptId, $deptIndex);
}
return [$adminToDeptId, $deptNameMap, $deptPathMap];
}
/**
* 从根节点到当前部门的完整链路(用 / 分隔)。
*
* @param array<int, array{pid: int, name: string}> $deptIndex
*/
private static function resolveDeptPath(int $deptId, array $deptIndex): string
{
$names = [];
$guard = 0;
$cursor = $deptId;
while ($cursor > 0 && isset($deptIndex[$cursor]) && $guard++ < 32) {
$node = $deptIndex[$cursor];
$name = trim($node['name']);
if ($name !== '') {
array_unshift($names, $name);
}
$cursor = $node['pid'];
}
return implode(' / ', $names);
}
/**
* @param array<int, array<string, mixed>> $rows
* @return array<int, array<string, mixed>>
*/
protected static function attachDeptInfoToRows(array $rows): array
{
if ($rows === []) {
return $rows;
}
$creatorIds = array_map(static fn (array $row): int => (int) ($row['creator_id'] ?? 0), $rows);
[$adminToDeptId, $deptNameMap, $deptPathMap] = self::loadAdminDeptMap($creatorIds);
foreach ($rows as &$row) {
$creatorId = (int) ($row['creator_id'] ?? 0);
$deptId = $adminToDeptId[$creatorId] ?? 0;
$row['dept_id'] = $deptId;
$row['dept_name'] = $deptId > 0 ? (string) ($deptNameMap[$deptId] ?? '') : '';
$row['dept_path'] = $deptId > 0 ? (string) ($deptPathMap[$deptId] ?? '') : '';
}
unset($row);
return $rows;
}
/**
* 根据 dept_id(包含其子部门)收窄可见 admin id 集合。
* 与 visibleAdminIds 取交集。
*
* @param array<int>|null $visibleAdminIds null 表示不限
* @return array<int>|null 返回 null 表示外部条件无需附加;返回 [] 表示无可见 admin
*/
protected static function intersectVisibleByDept(?array $visibleAdminIds, int $deptId): ?array
{
if ($deptId <= 0) {
return $visibleAdminIds;
}
$deptIds = DeptLogic::getSelfAndDescendantIds($deptId);
if ($deptIds === []) {
$deptIds = [$deptId];
}
$adminIds = AdminDept::whereIn('dept_id', $deptIds)->column('admin_id');
$adminIds = array_values(array_unique(array_filter(array_map('intval', $adminIds), static fn (int $v): bool => $v > 0)));
if ($visibleAdminIds === null) {
return $adminIds;
}
return array_values(array_intersect($visibleAdminIds, $adminIds));
}
protected static function normalizeMediaSource(string $mediaSource): string
{
return trim($mediaSource);
}
/**
* 与 project.self_input_stats_view_all_roles 一致:超管或白名单角色可见全部录入人数据。
*/
protected static function canViewAllSelfInputStats(array $adminInfo): bool
{
if ((int) ($adminInfo['root'] ?? 0) === 1) {
return true;
}
$allow = Config::get('project.self_input_stats_view_all_roles', []);
$allow = array_map('intval', is_array($allow) ? $allow : []);
if ($allow === []) {
return false;
}
$myRoles = array_map('intval', $adminInfo['role_id'] ?? []);
return count(array_intersect($myRoles, $allow)) > 0;
}
/**
* @return array<int>|null null=全部录入人;[]=无可见;int[]=可见 creator_id 集合
*/
protected static function getVisibleCreatorIds(int $adminId, array $adminInfo): ?array
{
if (self::canViewAllSelfInputStats($adminInfo)) {
return null;
}
return DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
}
protected static function assertRecordVisible(int $adminId, array $adminInfo, int $creatorId): bool
{
$visibleIds = self::getVisibleCreatorIds($adminId, $adminInfo);
if ($visibleIds === null) {
return true;
}
return in_array($creatorId, $visibleIds, true);
}
/**
* 同一录入人 + 同一天 + 同一渠道唯一(不同录入人可同日同渠道各录一条)。
* 命中返回冲突记录 ID,未命中返回 0。
*/
protected static function findYejiDuplicateId(int $creatorId, string $yejiDate, string $mediaSource, int $excludeId = 0): int
{
$query = PersonalYeji::where('creator_id', $creatorId)
->where('yeji_date', $yejiDate)
->where('media_source', $mediaSource)
->whereNull('delete_time');
if ($excludeId > 0) {
$query->where('id', '<>', $excludeId);
}
return (int) ($query->value('id') ?? 0);
}
protected static function isYejiDuplicate(int $creatorId, string $yejiDate, string $mediaSource, int $excludeId = 0): bool
{
return self::findYejiDuplicateId($creatorId, $yejiDate, $mediaSource, $excludeId) > 0;
}
/**
* 同一录入人 + 同一天 + 同一渠道唯一(不同录入人可同日同渠道各录一条)。
*/
protected static function findCostDuplicateId(int $creatorId, string $costDate, string $mediaSource, int $excludeId = 0): int
{
$query = PersonalAccountCost::where('creator_id', $creatorId)
->where('cost_date', $costDate)
->where('media_source', $mediaSource)
->whereNull('delete_time');
if ($excludeId > 0) {
$query->where('id', '<>', $excludeId);
}
return (int) ($query->value('id') ?? 0);
}
protected static function isCostDuplicate(int $creatorId, string $costDate, string $mediaSource, int $excludeId = 0): bool
{
return self::findCostDuplicateId($creatorId, $costDate, $mediaSource, $excludeId) > 0;
}
/**
* MySQL 唯一索引冲突 1062 兜底转友好提示(避免裸 SQL 异常)。
*/
protected static function isUniqueConstraintViolation(\Throwable $e): bool
{
return (int) $e->getCode() === 23000 || str_contains($e->getMessage(), '1062');
}
}
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\common\logic\BaseLogic;
use app\common\model\stats\PersonalYeji;
class PersonalYejiLogic extends BaseLogic
{
use PersonalStatsScopeTrait;
public static function add(array $params, int $adminId, string $adminName): bool
{
try {
$yejiDate = (string) $params['yeji_date'];
$mediaSource = self::normalizeMediaSource((string) ($params['media_source'] ?? ''));
if ($mediaSource === '') {
self::setError('请填写自媒体来源');
return false;
}
$dupId = self::findYejiDuplicateId($adminId, $yejiDate, $mediaSource);
if ($dupId > 0) {
self::setError("您在 {$yejiDate} 已录入过【{$mediaSource}】业绩(记录#{$dupId}),请直接编辑该记录");
return false;
}
PersonalYeji::create([
'yeji_date' => $yejiDate,
'media_source' => $mediaSource,
'add_fans_count' => (int) ($params['add_fans_count'] ?? 0),
'total_open_count' => (int) ($params['total_open_count'] ?? 0),
'unreplied_count' => (int) ($params['unreplied_count'] ?? 0),
'paid_appointment_count' => (int) ($params['paid_appointment_count'] ?? 0),
'free_appointment_count' => (int) ($params['free_appointment_count'] ?? 0),
'interview_count' => (int) ($params['interview_count'] ?? 0),
'order_amount' => round((float) ($params['order_amount'] ?? 0), 2),
'completed_order_count' => (int) ($params['completed_order_count'] ?? 0),
'remark' => (string) ($params['remark'] ?? ''),
'creator_id' => $adminId,
'creator_name' => $adminName,
'updater_id' => $adminId,
'updater_name' => $adminName,
'dept_id' => self::resolvePrimaryDeptId($adminId),
]);
return true;
} catch (\Throwable $e) {
if (self::isUniqueConstraintViolation($e)) {
self::setError('该日期下该渠道的业绩已存在(唯一索引冲突),请刷新列表后直接编辑');
} else {
self::setError($e->getMessage());
}
return false;
}
}
public static function edit(array $params, int $adminId, string $adminName, array $adminInfo): bool
{
try {
$model = PersonalYeji::find($params['id']);
if (!$model) {
self::setError('记录不存在');
return false;
}
if (!self::assertRecordVisible($adminId, $adminInfo, (int) $model->creator_id)) {
self::setError('无权操作该记录');
return false;
}
$model->add_fans_count = (int) ($params['add_fans_count'] ?? 0);
$model->total_open_count = (int) ($params['total_open_count'] ?? 0);
$model->unreplied_count = (int) ($params['unreplied_count'] ?? 0);
$model->paid_appointment_count = (int) ($params['paid_appointment_count'] ?? 0);
$model->free_appointment_count = (int) ($params['free_appointment_count'] ?? 0);
$model->interview_count = (int) ($params['interview_count'] ?? 0);
$model->order_amount = round((float) ($params['order_amount'] ?? 0), 2);
$model->completed_order_count = (int) ($params['completed_order_count'] ?? 0);
$model->remark = (string) ($params['remark'] ?? '');
$model->updater_id = $adminId;
$model->updater_name = $adminName;
$model->save();
return true;
} catch (\Throwable $e) {
self::setError($e->getMessage());
return false;
}
}
public static function delete(int $id, int $adminId, array $adminInfo): bool
{
try {
$model = PersonalYeji::find($id);
if (!$model) {
self::setError('记录不存在');
return false;
}
if (!self::assertRecordVisible($adminId, $adminInfo, (int) $model->creator_id)) {
self::setError('无权操作该记录');
return false;
}
$model->delete();
return true;
} catch (\Throwable $e) {
self::setError($e->getMessage());
return false;
}
}
public static function detail(int $id, int $adminId, array $adminInfo): array
{
$model = PersonalYeji::find($id);
if (!$model) {
return [];
}
if (!self::assertRecordVisible($adminId, $adminInfo, (int) $model->creator_id)) {
return [];
}
return $model->toArray();
}
}
@@ -0,0 +1,829 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\adminapi\logic\dept\DeptLogic;
use think\facade\Db;
/**
* 复诊接诊率统计(按月)
*
* 口径说明:
* - 当月被指派总数 = 当月内 `tcm_diagnosis_assign_log`(按 **指派操作时间 lg.create_time** 落月,to_assistant_id>0
* **剔除勾选「继承」的指派 is_inherit=1**,诊单未删除)去重后的「医助 × 诊单」组合;
* 同一诊单当月被多次指派给同一医助只计 1 次;
* **再剔除**名下存在履约「拒收(9) / 退款(10)」业务订单(未软删)的诊单。
* - 第 N 次下单 = 诊单(患者)名下计入业绩的业务订单(剔除履约 4/9/10、软删)按 create_time 升序的**全局**序列中第 N 笔;
* 诊次**跨月累计不重置**:例如 5 月指派后旗下成交 4 单为二诊~五诊,6 月再成交即为六诊。
* 诊单可配置 `revisit_slot_start_offset`(默认 0:第 1 笔实单计为一诊;设为 1 则第 1 笔实单计为二诊;设为 2 则计为三诊,即在实单序号上叠加偏移,5 笔实单+偏移 2 等价于计至七诊)。
* - 当月 N 诊单数 = **当月内下单**且全局序号为 N 的订单数,归属下单时点的**持有医助**——
* 按指派日志时间线取「订单时间之前最近一次指派」的 to_assistant_id(释放 to=0 即不再归属;
* 「继承」指派会转移持有人用于归属,但不计被指派数)。指派可发生在往月。
* - 当月 N 诊接诊率 = 当月 N 诊单数 ÷ 当月被指派总数;往月指派当月成交会推高分子,故比率可能超过 100%;
* 医助当月无新指派但旗下有成交时,被指派数为 0、比率显示为空。
* - 分档动态产出:N 从 2 起,至当月命中数据的最大序号(至少展示到四诊,上限 MAX_VISIT_SLOT 防御异常数据),
* 返回 `slots` 列表供前端动态渲染「五诊」「六诊」… 列。
* - 部门归类:医助按其人事部门(admin_dept 最小 dept_id)归组;**仅统计「二中心」及其组织下级**(与 DeptLogic::getErCenterSubtreeDeptIdSet 一致);
* 部门筛选下拉与未选部门时的默认范围均限定在该子树内,选定部门时含其组织下级。
* - 部门行 / 合计行:被指派数按诊单去重(可能小于下级行相加);N 诊单数为下级行求和(每笔订单唯一归属一名医助)。
*/
class RevisitRateLogic
{
/** 至少展示到的复诊序号(二诊/三诊/四诊) */
private const MIN_VISIT_SLOT_CEILING = 4;
/** 复诊序号统计上限(防御脏数据导致列爆炸;诊次跨月累计,上限放宽) */
private const MAX_VISIT_SLOT = 50;
/** 未分配部门的占位分组 */
private const UNASSIGNED_DEPT_NAME = '未分配部门';
/**
* @param array{month?:string,dept_ids?:int[]|string} $params
*
* @return array{
* month:string,start_date:string,end_date:string,
* slots:list<int>,
* total:array<string,int|float|null>,
* rows:list<array<string,mixed>>
* }
*/
public static function overview(array $params): array
{
$ctx = self::buildStatsCore($params);
$month = $ctx['month'];
$minSlots = range(2, self::MIN_VISIT_SLOT_CEILING);
$universe = self::assistantUniverse($ctx);
if ($universe === []) {
return [
'month' => $month,
'start_date' => $ctx['startDate'],
'end_date' => $ctx['endDate'],
'slots' => $minSlots,
'total' => self::buildMetricPack(0, [], $minSlots),
'rows' => [],
];
}
// 分档:2 ~ max(4, 当月命中的最大诊次)
$maxHitSlot = 0;
foreach ($ctx['slotOrdersByAssistant'] as $slotMap) {
foreach ($slotMap as $slot => $_) {
if ((int) $slot > $maxHitSlot) {
$maxHitSlot = (int) $slot;
}
}
}
$slots = range(2, max(self::MIN_VISIT_SLOT_CEILING, $maxHitSlot));
$nameMap = Db::name('admin')
->whereIn('id', array_keys($universe))
->column('name', 'id');
// 医助行(按部门分组收集)
/** @var array<int, list<array<string, mixed>>> $assistantRowsByDept */
$assistantRowsByDept = [];
/** @var array<int, array<int, true>> $deptDiagSet 部门 => 去重被指派诊单集合 */
$deptDiagSet = [];
/** @var array<int, array<int, int>> $deptSlotCounts 部门 => [slot => 订单数] */
$deptSlotCounts = [];
/** @var array<int, true> $totalDiagSet */
$totalDiagSet = [];
/** @var array<int, int> $totalSlotCounts */
$totalSlotCounts = [];
foreach ($universe as $aid => $_) {
$deptId = (int) ($ctx['assistantDept'][$aid] ?? 0);
$diagSet = $ctx['diagsByAssistant'][$aid] ?? [];
foreach ($diagSet as $did => $_d) {
$deptDiagSet[$deptId][$did] = true;
$totalDiagSet[$did] = true;
}
$slotCounts = [];
foreach ($ctx['slotOrdersByAssistant'][$aid] ?? [] as $slot => $orders) {
$cnt = \count($orders);
$slotCounts[$slot] = $cnt;
$deptSlotCounts[$deptId][$slot] = ($deptSlotCounts[$deptId][$slot] ?? 0) + $cnt;
$totalSlotCounts[$slot] = ($totalSlotCounts[$slot] ?? 0) + $cnt;
}
$deptName = $deptId > 0
? (string) ($ctx['deptNames'][$deptId] ?? ('#' . $deptId))
: self::UNASSIGNED_DEPT_NAME;
$assistantRowsByDept[$deptId][] = [
'row_key' => 'a' . $aid,
'is_dept' => 0,
'assistant_id' => (int) $aid,
'assistant_name' => (string) ($nameMap[$aid] ?? ('#' . $aid)),
'dept_id' => $deptId,
'dept_name' => $deptName,
] + self::buildMetricPack(\count($diagSet), $slotCounts, $slots);
}
// 部门行 + 子行
$rows = [];
foreach ($assistantRowsByDept as $deptId => $children) {
usort($children, static function (array $a, array $b): int {
if ($a['assigned_count'] !== $b['assigned_count']) {
return $b['assigned_count'] <=> $a['assigned_count'];
}
return strcmp((string) $a['assistant_name'], (string) $b['assistant_name']);
});
$deptName = $deptId > 0
? (string) ($ctx['deptNames'][$deptId] ?? ('#' . $deptId))
: self::UNASSIGNED_DEPT_NAME;
$rows[] = [
'row_key' => 'd' . $deptId,
'is_dept' => 1,
'dept_id' => (int) $deptId,
'dept_name' => $deptName,
'assistant_count' => \count($children),
'children' => $children,
] + self::buildMetricPack(\count($deptDiagSet[$deptId] ?? []), $deptSlotCounts[$deptId] ?? [], $slots);
}
usort($rows, static function (array $a, array $b): int {
if ($a['assigned_count'] !== $b['assigned_count']) {
return $b['assigned_count'] <=> $a['assigned_count'];
}
return strcmp((string) $a['dept_name'], (string) $b['dept_name']);
});
return [
'month' => $month,
'start_date' => $ctx['startDate'],
'end_date' => $ctx['endDate'],
'slots' => $slots,
'total' => self::buildMetricPack(\count($totalDiagSet), $totalSlotCounts, $slots),
'rows' => $rows,
];
}
/**
* 被指派明细(按诊单聚合,与「被指派数」同口径可对账)。
* scopeassistant_id(医助行)/ dept_id(部门行,含 0=未分配部门)/ 都不传 = 当前部门筛选下合计。
*
* @param array{month?:string,dept_ids?:int[]|string,assistant_id?:int|string,dept_id?:int|string} $params
*
* @return array{month:string,count:int,rows:list<array<string,mixed>>}
*/
public static function assignLines(array $params): array
{
$ctx = self::buildStatsCore($params);
$assistantSet = self::applyRowScope($ctx, $params);
/** @var array<int, array{assistants: array<int, true>, assign_count: int, last_time: int}> $byDiag */
$byDiag = [];
foreach ($ctx['pairsRaw'] as $p) {
$aid = (int) $p['to_assistant_id'];
$did = (int) $p['diagnosis_id'];
if (!isset($assistantSet[$aid])) {
continue;
}
if (!isset($byDiag[$did])) {
$byDiag[$did] = ['assistants' => [], 'assign_count' => 0, 'last_time' => 0];
}
$byDiag[$did]['assistants'][$aid] = true;
$byDiag[$did]['assign_count']++;
$byDiag[$did]['last_time'] = max($byDiag[$did]['last_time'], (int) $p['create_time']);
}
if ($byDiag === []) {
return ['month' => $ctx['month'], 'count' => 0, 'rows' => []];
}
$diagInfo = self::fetchDiagnosisInfo(array_keys($byDiag));
$assistantIds = [];
foreach ($byDiag as $d) {
foreach ($d['assistants'] as $aid => $_) {
$assistantIds[$aid] = true;
}
}
$nameMap = Db::name('admin')
->whereIn('id', array_keys($assistantIds))
->column('name', 'id');
$rows = [];
foreach ($byDiag as $did => $d) {
$names = [];
foreach ($d['assistants'] as $aid => $_) {
$names[] = (string) ($nameMap[$aid] ?? ('#' . $aid));
}
$rows[] = [
'diagnosis_id' => (int) $did,
'patient_name' => (string) ($diagInfo[$did]['patient_name'] ?? ''),
'patient_phone' => (string) ($diagInfo[$did]['phone'] ?? ''),
'assistant_names' => implode('、', $names),
'assign_count' => (int) $d['assign_count'],
'last_assign_time' => (int) $d['last_time'],
'last_assign_time_text' => $d['last_time'] > 0 ? date('Y-m-d H:i:s', $d['last_time']) : '',
];
}
usort($rows, static fn (array $a, array $b): int => $b['last_assign_time'] <=> $a['last_assign_time']);
return ['month' => $ctx['month'], 'count' => \count($rows), 'rows' => $rows];
}
/**
* N 诊订单明细:scope 内当月下单、全局序号 = slot 的具体订单(归属持有医助),与 visit{slot}_count 同口径可对账。
*
* @param array{month?:string,slot?:int|string,dept_ids?:int[]|string,assistant_id?:int|string,dept_id?:int|string} $params
*
* @return array{month:string,slot:int,count:int,rows:list<array<string,mixed>>}
*/
public static function visitOrderLines(array $params): array
{
$slot = (int) ($params['slot'] ?? 0);
if ($slot < 2 || $slot > self::MAX_VISIT_SLOT) {
return ['month' => self::normalizeMonth((string) ($params['month'] ?? '')), 'slot' => $slot, 'count' => 0, 'rows' => []];
}
$ctx = self::buildStatsCore($params);
$assistantSet = self::applyRowScope($ctx, $params);
$orderRows = [];
foreach ($assistantSet as $aid => $_) {
foreach ($ctx['slotOrdersByAssistant'][$aid][$slot] ?? [] as $r) {
$r['holder_assistant_id'] = (int) $aid;
$orderRows[] = $r;
}
}
if ($orderRows === []) {
return ['month' => $ctx['month'], 'slot' => $slot, 'count' => 0, 'rows' => []];
}
$diagIds = array_values(array_unique(array_map(
static fn (array $r): int => (int) $r['diagnosis_id'],
$orderRows
)));
$diagInfo = self::fetchDiagnosisInfo($diagIds);
$adminIds = [];
foreach ($orderRows as $r) {
if ((int) $r['creator_id'] > 0) {
$adminIds[(int) $r['creator_id']] = true;
}
$adminIds[(int) $r['holder_assistant_id']] = true;
}
$adminNames = $adminIds !== []
? Db::name('admin')->whereIn('id', array_keys($adminIds))->column('name', 'id')
: [];
$rows = [];
foreach ($orderRows as $r) {
$did = (int) $r['diagnosis_id'];
$cid = (int) $r['creator_id'];
$hid = (int) $r['holder_assistant_id'];
$ct = (int) $r['create_time'];
$rows[] = [
'order_id' => (int) $r['id'],
'order_no' => (string) ($r['order_no'] ?? ''),
'diagnosis_id' => $did,
'patient_name' => (string) ($diagInfo[$did]['patient_name'] ?? ''),
'patient_phone' => (string) ($diagInfo[$did]['phone'] ?? ''),
'amount' => round((float) ($r['amount'] ?? 0), 2),
'create_time' => $ct,
'create_time_text' => $ct > 0 ? date('Y-m-d H:i:s', $ct) : '',
'creator_id' => $cid,
'creator_name' => $cid > 0 ? (string) ($adminNames[$cid] ?? ('#' . $cid)) : '—',
'assistant_id' => $hid,
'assistant_name' => $hid > 0 ? (string) ($adminNames[$hid] ?? ('#' . $hid)) : '—',
];
}
usort($rows, static fn (array $a, array $b): int => $b['create_time'] <=> $a['create_time']);
return ['month' => $ctx['month'], 'slot' => $slot, 'count' => \count($rows), 'rows' => $rows];
}
/**
* 部门下拉:仅「二中心」及其组织下级(与业绩看板 ErCenter 子树一致)。
*
* @return array{rows: list<array{id:int,pid:int,name:string}>}
*/
public static function deptOptions(): array
{
$erSet = DeptLogic::getErCenterSubtreeDeptIdSet();
if ($erSet === []) {
return ['rows' => []];
}
$rows = Db::name('dept')
->whereNull('delete_time')
->whereIn('id', array_keys($erSet))
->field(['id', 'pid', 'name'])
->order('sort', 'desc')
->order('id', 'asc')
->select()
->toArray();
return [
'rows' => array_map(static fn (array $r): array => [
'id' => (int) $r['id'],
'pid' => (int) $r['pid'],
'name' => (string) $r['name'],
], $rows),
];
}
// ─────────────────────────── 内部实现 ───────────────────────────
/**
* 核心统计上下文:
* 1. 全量指派日志(≤ 月末)构建持有时间线;
* 2. 分母:当月非继承指派的「医助 × 诊单」,再剔除名下存在拒收(9)/退款(10) 订单的诊单;
* 3. 分子:曾被指派诊单的当月订单,统计诊次 = 实单序号 + 诊单偏移(默认第 1 笔实单为一诊);
* 4. 应用部门筛选:默认限定「二中心」子树;选定部门时再收窄到该部门及其下级(且须落在二中心子树内)。
*
* @param array{month?:string,dept_ids?:int[]|string} $params
*
* @return array{
* month:string,startDate:string,endDate:string,startTs:int,endTs:int,
* pairsRaw:list<array{diagnosis_id:int,to_assistant_id:int,create_time:int}>,
* diagsByAssistant:array<int,array<int,true>>,
* slotOrdersByAssistant:array<int,array<int,list<array<string,mixed>>>>,
* assistantDept:array<int,int>,
* deptNames:array<int,string>
* }
*/
private static function buildStatsCore(array $params): array
{
$month = self::normalizeMonth((string) ($params['month'] ?? ''));
$startTs = (int) strtotime($month . '-01 00:00:00');
$endTs = (int) strtotime(date('Y-m-t', $startTs) . ' 23:59:59');
// 全量指派日志(≤ 月末,诊单未删除):含释放(to=0)与继承行,用于持有时间线
$logRows = Db::name('tcm_diagnosis_assign_log')
->alias('lg')
->join('tcm_diagnosis dg', 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->where('lg.create_time', '<=', $endTs)
->where('lg.diagnosis_id', '>', 0)
->field(['lg.id', 'lg.diagnosis_id', 'lg.to_assistant_id', 'lg.create_time', 'lg.is_inherit'])
->order(['lg.diagnosis_id' => 'asc', 'lg.create_time' => 'asc', 'lg.id' => 'asc'])
->select()
->toArray();
/** @var array<int, list<array{t:int,to:int}>> $timeline 诊单 => 持有变更时间线(升序) */
$timeline = [];
$pairsRaw = [];
/** @var array<int, array<int, true>> $diagsByAssistant 分母:医助 => 诊单集合 */
$diagsByAssistant = [];
/** @var array<int, true> $candidateDiagSet 曾被指派(to>0,含继承)的诊单 */
$candidateDiagSet = [];
foreach ($logRows as $r) {
$did = (int) ($r['diagnosis_id'] ?? 0);
$aid = (int) ($r['to_assistant_id'] ?? 0);
$t = (int) ($r['create_time'] ?? 0);
$timeline[$did][] = ['t' => $t, 'to' => $aid];
if ($aid > 0) {
$candidateDiagSet[$did] = true;
if ((int) ($r['is_inherit'] ?? 0) === 0 && $t >= $startTs && $t <= $endTs) {
$pairsRaw[] = ['diagnosis_id' => $did, 'to_assistant_id' => $aid, 'create_time' => $t];
$diagsByAssistant[$aid][$did] = true;
}
}
}
// 分母:剔除名下存在拒收(9)/退款(10) 业务订单的诊单(与明细 assignLines 同口径)
$assignedDiagIds = [];
foreach ($diagsByAssistant as $diagSet) {
foreach ($diagSet as $did => $_) {
$assignedDiagIds[(int) $did] = true;
}
}
$refundRejectDiagSet = self::fetchRefundOrRejectDiagnosisSet(array_keys($assignedDiagIds));
if ($refundRejectDiagSet !== []) {
foreach ($diagsByAssistant as $aid => $diagSet) {
foreach ($diagSet as $did => $_) {
if (isset($refundRejectDiagSet[$did])) {
unset($diagsByAssistant[$aid][$did]);
}
}
if ($diagsByAssistant[$aid] === []) {
unset($diagsByAssistant[$aid]);
}
}
$pairsRaw = array_values(array_filter(
$pairsRaw,
static fn (array $p): bool => !isset($refundRejectDiagSet[(int) $p['diagnosis_id']])
));
}
// 分子:曾被指派诊单的当月订单,统计诊次 = 实单全局序号 + 诊单偏移(默认偏移 0 → 第 1 笔实单为一诊)
/** @var array<int, array<int, list<array<string, mixed>>>> $slotOrdersByAssistant */
$slotOrdersByAssistant = [];
$offsetMap = self::fetchRevisitSlotStartOffsetMap(array_keys($candidateDiagSet));
foreach (array_chunk(array_keys($candidateDiagSet), 2000) as $chunk) {
$orderRows = self::fetchOrderSeqRows(
$chunk,
['o.id', 'o.order_no', 'o.diagnosis_id', 'o.create_time', 'o.amount', 'o.creator_id']
);
$curDid = 0;
$seq = 0;
$ptr = 0;
$holder = 0;
$offset = 0;
foreach ($orderRows as $r) {
$did = (int) ($r['diagnosis_id'] ?? 0);
if ($did <= 0) {
continue;
}
if ($did !== $curDid) {
$curDid = $did;
$seq = 0;
$ptr = 0;
$holder = 0;
$offset = self::resolveRevisitSlotStartOffset($did, $offsetMap);
}
$seq++;
$effectiveSlot = $seq + $offset;
$ct = (int) ($r['create_time'] ?? 0);
// 推进时间线指针:订单时间之前(含同刻)最近一次指派的持有人
$tl = $timeline[$did] ?? [];
$tlCount = \count($tl);
while ($ptr < $tlCount && $tl[$ptr]['t'] <= $ct) {
$holder = (int) $tl[$ptr]['to'];
$ptr++;
}
if ($effectiveSlot < 2 || $effectiveSlot > self::MAX_VISIT_SLOT) {
continue;
}
if ($ct < $startTs || $ct > $endTs) {
continue;
}
if ($holder > 0) {
$slotOrdersByAssistant[$holder][$effectiveSlot][] = $r;
}
}
}
// 医助归属部门 + 部门筛选(默认仅二中心子树;选定部门时再收窄,含组织下级)
$universeIds = array_keys($diagsByAssistant + $slotOrdersByAssistant);
[$assistantDept, $deptNames] = self::buildAssistantDeptIndex($universeIds);
$subtreeSet = self::resolveDeptFilterSet($params['dept_ids'] ?? null);
if ($subtreeSet === []) {
// 无二中心部门时整表为空,避免误展示其它中心数据
$diagsByAssistant = [];
$slotOrdersByAssistant = [];
} else {
foreach ($universeIds as $aid) {
$deptId = (int) ($assistantDept[$aid] ?? 0);
if ($deptId <= 0 || !isset($subtreeSet[$deptId])) {
unset($diagsByAssistant[$aid], $slotOrdersByAssistant[$aid]);
}
}
}
return [
'month' => $month,
'startDate' => date('Y-m-d', $startTs),
'endDate' => date('Y-m-d', $endTs),
'startTs' => $startTs,
'endTs' => $endTs,
'pairsRaw' => $pairsRaw,
'diagsByAssistant' => $diagsByAssistant,
'slotOrdersByAssistant' => $slotOrdersByAssistant,
'assistantDept' => $assistantDept,
'deptNames' => $deptNames,
];
}
/**
* 统计涉及的医助全集:分母(被指派)∪ 分子(持有成交)。
*
* @param array{diagsByAssistant:array<int,array<int,true>>,slotOrdersByAssistant:array<int,array<int,list<array<string,mixed>>>>} $ctx
*
* @return array<int, true>
*/
private static function assistantUniverse(array $ctx): array
{
$set = [];
foreach (array_keys($ctx['diagsByAssistant']) as $aid) {
$set[(int) $aid] = true;
}
foreach (array_keys($ctx['slotOrdersByAssistant']) as $aid) {
$set[(int) $aid] = true;
}
return $set;
}
/**
* 行级 scopeassistant_id(医助行)优先;其次 dept_id(部门归组行,0=未分配部门);都不传 = 全部(已含部门筛选)。
*
* @return array<int, true> scope 内医助集合
*/
private static function applyRowScope(array $ctx, array $params): array
{
$assistantId = (int) ($params['assistant_id'] ?? 0);
$hasDeptScope = isset($params['dept_id']) && $params['dept_id'] !== '' && $params['dept_id'] !== null;
$deptScopeId = $hasDeptScope ? (int) $params['dept_id'] : -1;
$assistantSet = [];
foreach (self::assistantUniverse($ctx) as $aid => $_) {
if ($assistantId > 0) {
if ((int) $aid === $assistantId) {
$assistantSet[$aid] = true;
}
continue;
}
if ($hasDeptScope) {
if ((int) ($ctx['assistantDept'][$aid] ?? 0) === $deptScopeId) {
$assistantSet[$aid] = true;
}
continue;
}
$assistantSet[$aid] = true;
}
return $assistantSet;
}
/**
* 医助 → 归属部门 + 部门名称表。
*
* @param list<int> $adminIds
*
* @return array{0: array<int,int>, 1: array<int,string>}
*/
private static function buildAssistantDeptIndex(array $adminIds): array
{
if ($adminIds === []) {
return [[], []];
}
// admin_dept 为 (admin_id, dept_id) 联合主键、无自增 id;取最小 dept_id 作为归属部门保证确定性
$relRows = Db::name('admin_dept')
->whereIn('admin_id', $adminIds)
->order(['admin_id' => 'asc', 'dept_id' => 'asc'])
->field(['admin_id', 'dept_id'])
->select()
->toArray();
$deptNames = Db::name('dept')
->whereNull('delete_time')
->column('name', 'id');
$canonical = [];
foreach ($relRows as $r) {
$aid = (int) ($r['admin_id'] ?? 0);
$deptId = (int) ($r['dept_id'] ?? 0);
if ($aid <= 0 || $deptId <= 0 || isset($canonical[$aid])) {
continue;
}
if (!isset($deptNames[$deptId])) {
continue;
}
$canonical[$aid] = $deptId;
}
$names = [];
foreach ($deptNames as $id => $name) {
$names[(int) $id] = (string) $name;
}
return [$canonical, $names];
}
/**
* 部门筛选集合:始终落在「二中心」子树内。
* - 未传 dept_ids:整棵二中心子树
* - 已传:所选部门及其下级 ∩ 二中心子树(非法/非二中心 id 被忽略)
*
* @param mixed $raw
*
* @return array<int, true>
*/
private static function resolveDeptFilterSet(mixed $raw): array
{
$erSet = DeptLogic::getErCenterSubtreeDeptIdSet();
if ($erSet === []) {
return [];
}
$deptFilterIds = self::parseDeptIds($raw);
if ($deptFilterIds === []) {
return $erSet;
}
$allowedRoots = [];
foreach ($deptFilterIds as $id) {
if (isset($erSet[$id])) {
$allowedRoots[] = $id;
}
}
if ($allowedRoots === []) {
return [];
}
$expanded = self::expandDeptSubtreeSet($allowedRoots);
$out = [];
foreach ($expanded as $id => $_) {
if (isset($erSet[$id])) {
$out[$id] = true;
}
}
return $out;
}
/**
* @param mixed $raw int[] | 逗号分隔字符串
*
* @return list<int>
*/
private static function parseDeptIds(mixed $raw): array
{
if ($raw === null || $raw === '' || $raw === []) {
return [];
}
$list = \is_array($raw) ? $raw : explode(',', (string) $raw);
return array_values(array_unique(array_filter(
array_map('intval', $list),
static fn (int $v): bool => $v > 0
)));
}
/**
* 选中部门 + 全部组织下级的 id 集合。
*
* @param list<int> $deptIds
*
* @return array<int, true>
*/
private static function expandDeptSubtreeSet(array $deptIds): array
{
$rows = Db::name('dept')
->whereNull('delete_time')
->field(['id', 'pid'])
->select()
->toArray();
$childrenByPid = [];
foreach ($rows as $r) {
$childrenByPid[(int) $r['pid']][] = (int) $r['id'];
}
$set = [];
$queue = $deptIds;
while ($queue !== []) {
$id = (int) array_shift($queue);
if ($id <= 0 || isset($set[$id])) {
continue;
}
$set[$id] = true;
foreach ($childrenByPid[$id] ?? [] as $childId) {
$queue[] = $childId;
}
}
return $set;
}
/**
* 名下存在履约「拒收(9) / 退款(10)」业务订单(未软删)的诊单集合。
* 用于「当月被指派总数」分母过滤;不限订单创建月份。
*
* @param list<int> $diagIds
*
* @return array<int, true>
*/
private static function fetchRefundOrRejectDiagnosisSet(array $diagIds): array
{
if ($diagIds === []) {
return [];
}
$out = [];
foreach (array_chunk($diagIds, 2000) as $chunk) {
$ids = Db::name('tcm_prescription_order')
->whereIn('diagnosis_id', $chunk)
->whereNull('delete_time')
->whereIn('fulfillment_status', [9, 10])
->group('diagnosis_id')
->column('diagnosis_id');
foreach ($ids as $id) {
$out[(int) $id] = true;
}
}
return $out;
}
/**
* 诊单订单序列源查询(与业绩口径一致),统一排序保证序号稳定。
*
* @param list<int> $diagIds
* @param list<string> $fields
*
* @return list<array<string, mixed>>
*/
private static function fetchOrderSeqRows(array $diagIds, array $fields): array
{
$q = Db::name('tcm_prescription_order')
->alias('o')
->whereIn('o.diagnosis_id', $diagIds)
->whereNull('o.delete_time');
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, 'o');
return $q
->field($fields)
->order(['o.diagnosis_id' => 'asc', 'o.create_time' => 'asc', 'o.id' => 'asc'])
->select()
->toArray();
}
/**
* @param list<int> $diagIds
*
* @return array<int, int> diagnosis_id => revisit_slot_start_offset
*/
private static function fetchRevisitSlotStartOffsetMap(array $diagIds): array
{
if ($diagIds === []) {
return [];
}
$out = [];
foreach (array_chunk($diagIds, 2000) as $chunk) {
$rows = Db::name('tcm_diagnosis')
->whereIn('id', $chunk)
->whereNull('delete_time')
->column('revisit_slot_start_offset', 'id');
foreach ($rows as $id => $offset) {
$out[(int) $id] = (int) $offset;
}
}
return $out;
}
/**
* 诊单复诊统计起始偏移(默认 0:第 1 笔实单计为一诊;统计诊次 = 实单序号 + 偏移)
*
* @param array<int, int> $offsetMap
*/
private static function resolveRevisitSlotStartOffset(int $diagId, array $offsetMap): int
{
$offset = (int) ($offsetMap[$diagId] ?? 0);
if ($offset < 0) {
$offset = 0;
}
if ($offset > 20) {
$offset = 20;
}
return $offset;
}
/**
* @param list<int> $diagIds
*
* @return array<int, array{patient_name:string,phone:string}>
*/
private static function fetchDiagnosisInfo(array $diagIds): array
{
if ($diagIds === []) {
return [];
}
$rows = Db::name('tcm_diagnosis')
->whereIn('id', $diagIds)
->field(['id', 'patient_name', 'phone'])
->select()
->toArray();
$out = [];
foreach ($rows as $r) {
$out[(int) $r['id']] = [
'patient_name' => trim((string) ($r['patient_name'] ?? '')),
'phone' => trim((string) ($r['phone'] ?? '')),
];
}
return $out;
}
/**
* @param array<int, int> $slotCounts [slot => n]
* @param list<int> $slots 需输出的分档列表(保证各行键齐全)
*
* @return array<string, int|float|null>
*/
private static function buildMetricPack(int $assigned, array $slotCounts, array $slots): array
{
$pack = ['assigned_count' => $assigned];
foreach ($slots as $slot) {
$cnt = (int) ($slotCounts[$slot] ?? 0);
$pack['visit' . $slot . '_count'] = $cnt;
$pack['visit' . $slot . '_rate'] = $assigned > 0
? round($cnt / $assigned * 100, 2)
: null;
}
return $pack;
}
/** 归一化月份参数为 YYYY-MM,非法时回退当前月 */
private static function normalizeMonth(string $month): string
{
$month = trim($month);
if (preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month) === 1) {
return $month;
}
return date('Y-m');
}
}
@@ -0,0 +1,385 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\common\logic\BaseLogic;
use app\common\model\dict\DictData;
use app\common\model\stats\PersonalAccountCost;
use app\common\model\stats\PersonalYeji;
class SelfInputLogic extends BaseLogic
{
use PersonalStatsScopeTrait;
public static function overview(array $params, int $adminId, array $adminInfo): array
{
[$startDate, $endDate] = self::resolveTimeRange($params);
$pageNo = max(1, (int) ($params['page_no'] ?? 1));
$pageSize = min(100, max(1, (int) ($params['page_size'] ?? 15)));
$mediaSource = trim((string) ($params['media_source'] ?? ''));
$deptId = (int) ($params['dept_id'] ?? 0);
$effectiveAdminIds = self::resolveEffectiveAdminIds($adminId, $adminInfo, $deptId);
$yejiQuery = self::buildYejiQuery($startDate, $endDate, $effectiveAdminIds);
if ($mediaSource !== '') {
$yejiQuery->where('media_source', $mediaSource);
}
$count = (int) (clone $yejiQuery)->count();
$rows = (clone $yejiQuery)
->order(['yeji_date' => 'desc', 'id' => 'desc'])
->page($pageNo, $pageSize)
->select()
->toArray();
$costMap = self::loadAccountCostMap($startDate, $endDate, $effectiveAdminIds, $mediaSource);
$lists = [];
foreach ($rows as $row) {
$entity = self::normalizeYejiRow($row);
$costKey = self::buildCostKey(
(int) $entity['creator_id'],
(string) $entity['yeji_date'],
(string) $entity['media_source']
);
$entity['account_cost'] = round((float) ($costMap[$costKey] ?? 0), 2);
$lists[] = self::finalizeMetrics($entity);
}
$lists = self::attachDeptInfoToRows($lists);
$allYejiRows = self::buildYejiQuery($startDate, $endDate, $effectiveAdminIds);
if ($mediaSource !== '') {
$allYejiRows->where('media_source', $mediaSource);
}
$allYeji = $allYejiRows->select()->toArray();
$summaryCostMap = $costMap;
$summaryBase = self::emptyMetrics();
foreach ($allYeji as $row) {
$entity = self::normalizeYejiRow($row);
$costKey = self::buildCostKey(
(int) $entity['creator_id'],
(string) $entity['yeji_date'],
(string) $entity['media_source']
);
$entity['account_cost'] = round((float) ($summaryCostMap[$costKey] ?? 0), 2);
unset($summaryCostMap[$costKey]);
$entity = self::finalizeMetrics($entity);
$summaryBase = self::accumulateMetrics($summaryBase, $entity);
}
foreach ($summaryCostMap as $amount) {
$summaryBase['account_cost'] += round((float) $amount, 2);
}
$summary = self::finalizeMetrics($summaryBase);
$canViewFinance = self::canViewAllSelfInputStats($adminInfo);
if (!$canViewFinance) {
$summary = self::maskFinanceFields($summary);
foreach ($lists as &$item) {
$item = self::maskFinanceFields($item);
}
unset($item);
}
return [
'summary' => $summary,
'lists' => $lists,
'count' => $count,
'page_no' => $pageNo,
'page_size' => $pageSize,
'extend' => [
'summary' => $summary,
'date_range' => [$startDate, $endDate],
'can_view_finance' => $canViewFinance,
],
];
}
/**
* 财务相关字段(账户消耗 / 现金成本 / ROI):非豁免角色不可见,剔除字段避免被嗅探。
*
* @param array<string, mixed> $entity
* @return array<string, mixed>
*/
private static function maskFinanceFields(array $entity): array
{
foreach (['account_cost', 'cash_cost', 'roi'] as $key) {
unset($entity[$key]);
}
return $entity;
}
/**
* 自媒体来源选项:来自字典「推广渠道」(type_value=channels),按 sort/id 排序。
* 用 dict_data.name 作为存储值(与历史录入兼容;保留 value 仅作展示标识)。
*
* @return array<int, array{name: string, value: string}>
*/
public static function mediaSourceOptions(int $adminId, array $adminInfo): array
{
unset($adminId, $adminInfo);
$rows = DictData::where('type_value', 'channels')
->where('status', 1)
->order(['sort' => 'desc', 'id' => 'asc'])
->field('name, value')
->select()
->toArray();
$list = [];
$seen = [];
foreach ($rows as $row) {
$name = self::normalizeMediaSource((string) ($row['name'] ?? ''));
if ($name === '' || isset($seen[$name])) {
continue;
}
$seen[$name] = true;
$list[] = [
'name' => $name,
'value' => (string) ($row['value'] ?? ''),
];
}
return $list;
}
/**
* @return array<int>|null null = 不限;[] = 无可见
*/
private static function resolveEffectiveAdminIds(int $adminId, array $adminInfo, int $deptId): ?array
{
$visibleIds = self::getVisibleCreatorIds($adminId, $adminInfo);
return self::intersectVisibleByDept($visibleIds, $deptId);
}
/**
* @param array<int>|null $effectiveAdminIds
*/
private static function buildYejiQuery(string $startDate, string $endDate, ?array $effectiveAdminIds)
{
$query = PersonalYeji::whereBetween('yeji_date', [$startDate, $endDate]);
if ($effectiveAdminIds === []) {
$query->whereRaw('0 = 1');
} elseif ($effectiveAdminIds !== null) {
$query->whereIn('creator_id', $effectiveAdminIds);
}
return $query;
}
/**
* @param array<int>|null $effectiveAdminIds
* @return array<string, float>
*/
private static function loadAccountCostMap(
string $startDate,
string $endDate,
?array $effectiveAdminIds,
string $mediaSource
): array {
$query = PersonalAccountCost::whereBetween('cost_date', [$startDate, $endDate]);
if ($effectiveAdminIds === []) {
return [];
}
if ($effectiveAdminIds !== null) {
$query->whereIn('creator_id', $effectiveAdminIds);
}
if ($mediaSource !== '') {
$query->where('media_source', $mediaSource);
}
$rows = $query
->fieldRaw('creator_id, cost_date, media_source, SUM(amount) AS total_amount')
->group('creator_id, cost_date, media_source')
->select()
->toArray();
$map = [];
foreach ($rows as $row) {
$key = self::buildCostKey(
(int) $row['creator_id'],
(string) $row['cost_date'],
(string) $row['media_source']
);
$map[$key] = round((float) ($row['total_amount'] ?? 0), 2);
}
return $map;
}
private static function buildCostKey(int $creatorId, string $date, string $mediaSource): string
{
return $creatorId . '|' . $date . '|' . self::normalizeMediaSource($mediaSource);
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private static function normalizeYejiRow(array $row): array
{
return [
'id' => (int) ($row['id'] ?? 0),
'yeji_date' => (string) ($row['yeji_date'] ?? ''),
'media_source' => (string) ($row['media_source'] ?? ''),
'creator_id' => (int) ($row['creator_id'] ?? 0),
'creator_name' => (string) ($row['creator_name'] ?? ''),
'remark' => (string) ($row['remark'] ?? ''),
'add_fans_count' => (int) ($row['add_fans_count'] ?? 0),
'total_open_count' => (int) ($row['total_open_count'] ?? 0),
'unreplied_count' => (int) ($row['unreplied_count'] ?? 0),
'paid_appointment_count' => (int) ($row['paid_appointment_count'] ?? 0),
'free_appointment_count' => (int) ($row['free_appointment_count'] ?? 0),
'interview_count' => (int) ($row['interview_count'] ?? 0),
'order_amount' => round((float) ($row['order_amount'] ?? 0), 2),
'completed_order_count' => (int) ($row['completed_order_count'] ?? 0),
'account_cost' => 0.0,
];
}
/**
* @return array<string, mixed>
*/
private static function emptyMetrics(): array
{
return [
'add_fans_count' => 0,
'total_open_count' => 0,
'unreplied_count' => 0,
'paid_appointment_count' => 0,
'free_appointment_count' => 0,
'appointment_total_count' => 0,
'interview_count' => 0,
'order_amount' => 0.0,
'completed_order_count' => 0,
'account_cost' => 0.0,
];
}
/**
* @param array<string, mixed> $base
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private static function accumulateMetrics(array $base, array $row): array
{
$base['add_fans_count'] += (int) ($row['add_fans_count'] ?? 0);
$base['total_open_count'] += (int) ($row['total_open_count'] ?? 0);
$base['unreplied_count'] += (int) ($row['unreplied_count'] ?? 0);
$base['paid_appointment_count'] += (int) ($row['paid_appointment_count'] ?? 0);
$base['free_appointment_count'] += (int) ($row['free_appointment_count'] ?? 0);
$base['interview_count'] += (int) ($row['interview_count'] ?? 0);
$base['order_amount'] = round((float) $base['order_amount'] + (float) ($row['order_amount'] ?? 0), 2);
$base['completed_order_count'] += (int) ($row['completed_order_count'] ?? 0);
$base['account_cost'] = round((float) $base['account_cost'] + (float) ($row['account_cost'] ?? 0), 2);
return $base;
}
/**
* @param array<string, mixed> $entity
* @return array<string, mixed>
*/
private static function finalizeMetrics(array $entity): array
{
$paidAppointmentCount = (int) ($entity['paid_appointment_count'] ?? 0);
$freeAppointmentCount = (int) ($entity['free_appointment_count'] ?? 0);
$appointmentTotalCount = $paidAppointmentCount + $freeAppointmentCount;
$interviewCount = (int) ($entity['interview_count'] ?? 0);
$addFansCount = (int) ($entity['add_fans_count'] ?? 0);
$totalOpenCount = (int) ($entity['total_open_count'] ?? 0);
$completedOrderCount = (int) ($entity['completed_order_count'] ?? 0);
$orderAmount = round((float) ($entity['order_amount'] ?? 0), 2);
$accountCost = round((float) ($entity['account_cost'] ?? 0), 2);
$entity['appointment_total_count'] = $appointmentTotalCount;
$entity['order_amount'] = $orderAmount;
$entity['account_cost'] = $accountCost;
$entity['total_open_rate'] = self::percent($totalOpenCount, $addFansCount);
$entity['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
$entity['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
$entity['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
$entity['receive_rate'] = self::percent($completedOrderCount, $addFansCount);
$entity['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
$entity['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
$entity['avg_unit_price'] = self::safeDivideMoney($orderAmount, $completedOrderCount);
$entity['cash_cost'] = self::safeDivideMoney($accountCost, $addFansCount);
$entity['roi'] = self::safeDivideRatio($orderAmount, $accountCost);
return $entity;
}
/**
* @return array{0: string, 1: string}
*/
private static function resolveTimeRange(array $params): array
{
$today = date('Y-m-d');
$timeType = (string) ($params['time_type'] ?? 'today');
switch ($timeType) {
case 'yesterday':
$startDate = date('Y-m-d', strtotime('-1 day'));
$endDate = $startDate;
break;
case 'week':
$startDate = date('Y-m-d', strtotime('-6 days'));
$endDate = $today;
break;
case 'month':
$startDate = date('Y-m-d', strtotime('-29 days'));
$endDate = $today;
break;
case 'custom':
$startDate = trim((string) ($params['start_date'] ?? ''));
$endDate = trim((string) ($params['end_date'] ?? ''));
if ($startDate === '' || $endDate === '') {
$startDate = $today;
$endDate = $today;
} elseif ($startDate > $endDate) {
[$startDate, $endDate] = [$endDate, $startDate];
}
break;
case 'today':
default:
$startDate = $today;
$endDate = $today;
}
return [$startDate, $endDate];
}
private static function percent(int $numerator, int $denominator): float
{
if ($denominator <= 0) {
return 0.0;
}
return round(($numerator / $denominator) * 100, 2);
}
private static function safeDivideMoney(float $numerator, int $denominator): float
{
if ($denominator <= 0) {
return 0.0;
}
return round($numerator / $denominator, 2);
}
private static function safeDivideRatio(float $numerator, float $denominator): float
{
if ($denominator <= 0) {
return 0.0;
}
return round($numerator / $denominator, 2);
}
}
File diff suppressed because it is too large Load Diff