first commit
This commit is contained in:
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 被指派明细(按诊单聚合,与「被指派数」同口径可对账)。
|
||||
* scope:assistant_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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 行级 scope:assistant_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
Reference in New Issue
Block a user