Files
zyt/server/app/adminapi/logic/stats/RevisitRateLogic.php
T
2026-10-15 11:03:54 +08:00

1002 lines
43 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\adminapi\logic\dept\DeptLogic;
use app\common\service\tcm\RevisitVisitSlotService;
use think\facade\Db;
/**
* 复诊接诊率统计(按月)—— v2:继承按继承时诊次计入每列独立分母 + 复诊合计列(方案 §1.1,定义 A 累计型)。
*
* 口径说明(内存计算见 RevisitRateCalculator,诊次口径见 RevisitVisitSlotService):
* - 新指派数 assigned_count = 当月内 `tcm_diagnosis_assign_log`(按 **指派操作时间 lg.create_time** 落月,to_assistant_id>0,
* **is_inherit=0**,诊单未删除)去重后的「医助 × 诊单」组合(NI);同一诊单当月多次指派给同一医助只计 1 次;
* **再剔除**名下存在履约「拒收(9) / 退款(10)」业务订单(未软删)的诊单(R,不限月份)。
* - 继承数 inherit_count = 当月 **is_inherit=1**、to>0、from≠to 的「医助 × 诊单」(IH),同一 (医助, 诊单) 多条继承取最早;
* 同月既新指派又继承给同一医助只算新指派;剔除 R。历史「回退自动分配」行(auto_assign_log.rollback_time 匹配)按继承处理。
* 继承时诊次 k = 继承时刻之前(严格小于)的有效实单数 + 偏移;起算诊次 entry = max(2, k+1);entry>50 只进合计分母。
* - 第 N 次下单 = 诊单(患者)名下计入业绩的业务订单(剔除履约 4/9/10、软删)按 create_time 升序的**全局**序列中第 N 笔;
* 诊次**跨月累计不重置**;诊单可配置 `revisit_slot_start_offset`(默认 0:第 1 笔实单计为一诊;偏移 n 即在实单序号上叠加 n)。
* - 当月 N 诊单数 visit{N}_count = **当月内下单**且诊次为 N 的订单数,归属下单时点的**持有医助**——
* 按指派日志时间线取「订单时间之前(含同刻)最近一次指派」的 to_assistant_id(释放 to=0 即不再归属;继承会转移持有人)。
* - 当月 N 诊分母 visit{N}_den = 新指派数 + 起算诊次 ≤ N 的继承数;接诊率 visit{N}_rate = count ÷ den(den=0 → null)。
* - 复诊合计:visit_all_count = Σ_{N≥2} visit{N}_count;visit_all_den = assigned_total_count = |NI ∪ IH|;visit_all_rate 可超过 100%。
* - 旧口径开关 inherit_mode=exclude|slot(默认 config project.revisit_rate.default_inherit_mode):
* exclude 完全复现旧实现(IH=∅、不忽略 from==to、不豁免回退行,assigned_count/visit{N}_count/visit{N}_rate 与历史逐值一致);
* 无论模式都输出 visit2_rate_new_assign = 二诊单数 ÷ 旧口径新指派数(供自动分配过渡期分档)。
* - 分档动态产出:N 从 2 起,至 max(4, 当月分子最大诊次, 最大起算诊次)(上限 MAX_VISIT_SLOT),返回 `slots` 供前端动态渲染。
* - 部门归类:医助按其人事部门(admin_dept 最小 dept_id)归组;**仅统计「二中心」及其组织下级**(与 DeptLogic::getErCenterSubtreeDeptIdSet 一致);
* 部门筛选下拉与未选部门时的默认范围均限定在该子树内,选定部门时含其组织下级。
* - 部门行 / 合计行:NI 按诊单去重;IH = (∪IH(A)) \ NI_dept,entry 取部门内最小值;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,inherit_mode?:string} $params
*
* @return array{
* month:string,start_date:string,end_date:string,inherit_mode: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'],
'inherit_mode' => $ctx['inheritMode'],
'slots' => $minSlots,
'total' => self::buildMetricPack(0, [], [], $minSlots, 0),
'rows' => [],
];
}
// 分档:2 ~ max(4, 当月命中的最大诊次, 最大起算诊次),上限 MAX_VISIT_SLOT
$slots = RevisitRateCalculator::slotRange(
$ctx['slotOrdersByAssistant'],
(int) $ctx['maxEntry'],
self::MIN_VISIT_SLOT_CEILING
);
$nameMap = Db::name('admin')
->whereIn('id', array_keys($universe))
->column('name', 'id');
// 医助行(按部门分组收集)
/** @var array<int, list<array<string, mixed>>> $assistantRowsByDept */
$assistantRowsByDept = [];
/** @var array<int, list<int>> $deptAssistants 部门 => 医助 id 列表(部门行按诊单去重用) */
$deptAssistants = [];
/** @var array<int, array<int, int>> $deptSlotCounts 部门 => [slot => 订单数] */
$deptSlotCounts = [];
/** @var array<int, int> $totalSlotCounts */
$totalSlotCounts = [];
foreach ($universe as $aid => $_) {
$deptId = (int) ($ctx['assistantDept'][$aid] ?? 0);
$deptAssistants[$deptId][] = (int) $aid;
$diagSet = $ctx['diagsByAssistant'][$aid] ?? [];
$inheritEntries = $ctx['inheritEntryByAssistant'][$aid] ?? [];
$excludeSet = $ctx['excludeDiagsByAssistant'][$aid] ?? [];
$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), $inheritEntries, $slotCounts, $slots, \count($excludeSet));
}
// 部门行 + 子行(NI 按诊单去重;IH = ∪IH(A) \ NI_dept,entry 取最小)
$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;
$aids = $deptAssistants[$deptId] ?? [];
[$deptNiSet, $deptIhEntries] = RevisitRateCalculator::mergeInheritEntries(
$ctx['diagsByAssistant'],
$ctx['inheritEntryByAssistant'],
$aids
);
$deptExcludeSet = RevisitRateCalculator::unionDiagSets($ctx['excludeDiagsByAssistant'], $aids);
$rows[] = [
'row_key' => 'd' . $deptId,
'is_dept' => 1,
'dept_id' => (int) $deptId,
'dept_name' => $deptName,
'assistant_count' => \count($children),
'children' => $children,
] + self::buildMetricPack(
\count($deptNiSet),
$deptIhEntries,
$deptSlotCounts[$deptId] ?? [],
$slots,
\count($deptExcludeSet)
);
}
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']);
});
$allAids = array_map('intval', array_keys($universe));
[$totalNiSet, $totalIhEntries] = RevisitRateCalculator::mergeInheritEntries(
$ctx['diagsByAssistant'],
$ctx['inheritEntryByAssistant'],
$allAids
);
$totalExcludeSet = RevisitRateCalculator::unionDiagSets($ctx['excludeDiagsByAssistant'], $allAids);
return [
'month' => $month,
'start_date' => $ctx['startDate'],
'end_date' => $ctx['endDate'],
'inherit_mode' => $ctx['inheritMode'],
'slots' => $slots,
'total' => self::buildMetricPack(
\count($totalNiSet),
$totalIhEntries,
$totalSlotCounts,
$slots,
\count($totalExcludeSet)
),
'rows' => $rows,
];
}
/**
* 医助本人「我的 N 诊接诊率」(个人业绩页卡片,方案 §10 D1-h)。
*
* 复用 overview() 的全量结果(overview 不按个人数据范围过滤、仅限二中心子树),只从各部门行的 children 中
* 挑出 assistant_id 匹配的医助行返回,不暴露其他医助数据;本人不在二中心子树或当月无任何指派 / 成交时 row=null,
* in_scope 用于区分两种情况(false=非二中心医助)。结果按 month / assistant / mode 缓存 600 秒,缓存不可用时直接计算。
*
* @return array{month:string,inherit_mode:string,slots:list<int>,in_scope:bool,row:array<string,mixed>|null}
*/
public static function assistantOverview(int $assistantId, string $month, string $inheritMode = 'slot'): array
{
$month = self::normalizeMonth($month);
$inheritMode = $inheritMode === 'exclude' ? 'exclude' : 'slot';
$cacheKey = sprintf('revisit_rate:assistant_overview:%s:%d:%s', $month, $assistantId, $inheritMode);
try {
$cached = \think\facade\Cache::get($cacheKey);
if (\is_array($cached) && isset($cached['month'], $cached['slots'])) {
return $cached;
}
} catch (\Throwable) {
// 缓存驱动不可用:直接计算
}
$overview = self::overview(['month' => $month, 'inherit_mode' => $inheritMode]);
$row = null;
if ($assistantId > 0) {
foreach ($overview['rows'] as $deptRow) {
foreach ((array) ($deptRow['children'] ?? []) as $child) {
if ((int) ($child['assistant_id'] ?? 0) === $assistantId) {
$row = $child;
break 2;
}
}
}
}
// 是否属于二中心子树:有行必然在子树内;无行时按人事部门判断,供前端区分「非二中心」与「当月无被指派患者」
$inScope = $row !== null;
if (!$inScope && $assistantId > 0) {
[$assistantDept] = self::buildAssistantDeptIndex([$assistantId]);
$deptId = (int) ($assistantDept[$assistantId] ?? 0);
$erSet = DeptLogic::getErCenterSubtreeDeptIdSet();
$inScope = $deptId > 0 && isset($erSet[$deptId]);
}
$result = [
'month' => (string) $overview['month'],
'inherit_mode' => (string) $overview['inherit_mode'],
'slots' => array_values(array_map('intval', (array) $overview['slots'])),
'in_scope' => $inScope,
'row' => $row,
];
try {
\think\facade\Cache::set($cacheKey, $result, 600);
} catch (\Throwable) {
// 写缓存失败不影响返回
}
return $result;
}
/**
* 被指派明细(按诊单聚合,与「新指派数 / 继承数 / 被指派合计」同口径可对账)。
* scope:assistant_id(医助行)/ dept_id(部门行,含 0=未分配部门)/ 都不传 = 当前部门筛选下合计。
* kind:new=仅新指派行 / inherit=仅继承行 / all(默认)=两者;行字段 assign_type(new|inherit)、
* inherit_visit_slot(继承时诊次 k,新指派为 null)、entry_slot(起算诊次;新指派恒为 2)。
* 部门/合计 scope 下与 overview 去重规则一致:诊单只要对 scope 内任一医助为新指派即记为 new,
* 否则为 inherit 且 entry 取 scope 内最小值;故 kind=all 的「new 行数 + entry<=N 的 inherit 行数」= visit{N}_den。
*
* @param array{month?:string,dept_ids?:int[]|string,assistant_id?:int|string,dept_id?:int|string,inherit_mode?:string,kind?:string} $params
*
* @return array{month:string,inherit_mode:string,kind:string,count:int,rows:list<array<string,mixed>>}
*/
public static function assignLines(array $params): array
{
$ctx = self::buildStatsCore($params);
$assistantSet = self::applyRowScope($ctx, $params);
$kind = strtolower(trim((string) ($params['kind'] ?? 'all')));
if (!\in_array($kind, ['new', 'inherit', 'all'], true)) {
$kind = 'all';
}
$minNext = self::inheritMinNextSlot();
/** @var array<int, array{assistants: array<int, true>, assign_count: int, last_time: int}> $niByDiag */
$niByDiag = [];
foreach ($ctx['pairsRaw'] as $p) {
$aid = (int) $p['to_assistant_id'];
$did = (int) $p['diagnosis_id'];
if (!isset($assistantSet[$aid])) {
continue;
}
if (!isset($niByDiag[$did])) {
$niByDiag[$did] = ['assistants' => [], 'assign_count' => 0, 'last_time' => 0];
}
$niByDiag[$did]['assistants'][$aid] = true;
$niByDiag[$did]['assign_count']++;
$niByDiag[$did]['last_time'] = max($niByDiag[$did]['last_time'], (int) $p['create_time']);
}
/** @var array<int, array{assistants: array<int, true>, assign_count: int, last_time: int, k: int, entry: int, is_rollback: int}> $ihByDiag */
$ihByDiag = [];
foreach ($ctx['inheritPairs'] as $p) {
$aid = (int) $p['to_assistant_id'];
$did = (int) $p['diagnosis_id'];
if (!isset($assistantSet[$aid]) || isset($niByDiag[$did])) {
continue;
}
$entry = (int) $p['entry_slot'];
if (!isset($ihByDiag[$did])) {
$ihByDiag[$did] = [
'assistants' => [],
'assign_count' => 0,
'last_time' => 0,
'k' => (int) $p['inherit_visit_slot'],
'entry' => $entry,
'is_rollback' => (int) ($p['is_rollback'] ?? 0),
];
} elseif ($entry < $ihByDiag[$did]['entry']) {
$ihByDiag[$did]['entry'] = $entry;
$ihByDiag[$did]['k'] = (int) $p['inherit_visit_slot'];
$ihByDiag[$did]['is_rollback'] = (int) ($p['is_rollback'] ?? 0);
}
$ihByDiag[$did]['assistants'][$aid] = true;
$ihByDiag[$did]['assign_count'] += (int) ($p['assign_count'] ?? 1);
$ihByDiag[$did]['last_time'] = max($ihByDiag[$did]['last_time'], (int) $p['create_time']);
}
if ($kind === 'new') {
$ihByDiag = [];
} elseif ($kind === 'inherit') {
$niByDiag = [];
}
if ($niByDiag === [] && $ihByDiag === []) {
return ['month' => $ctx['month'], 'inherit_mode' => $ctx['inheritMode'], 'kind' => $kind, 'count' => 0, 'rows' => []];
}
$diagInfo = self::fetchDiagnosisInfo(array_values(array_unique(array_merge(array_keys($niByDiag), array_keys($ihByDiag)))));
$assistantIds = [];
foreach ([$niByDiag, $ihByDiag] as $map) {
foreach ($map as $d) {
foreach ($d['assistants'] as $aid => $_) {
$assistantIds[$aid] = true;
}
}
}
$nameMap = Db::name('admin')
->whereIn('id', array_keys($assistantIds))
->column('name', 'id');
$rows = [];
foreach ($niByDiag 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']) : '',
'assign_type' => 'new',
'assign_type_text' => '新指派',
'inherit_visit_slot' => null,
'entry_slot' => $minNext,
'is_rollback' => 0,
];
}
foreach ($ihByDiag 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']) : '',
'assign_type' => 'inherit',
'assign_type_text' => $d['is_rollback'] === 1 ? '继承(回退)' : '继承',
'inherit_visit_slot' => (int) $d['k'],
'entry_slot' => (int) $d['entry'],
'is_rollback' => (int) $d['is_rollback'],
];
}
usort($rows, static fn (array $a, array $b): int => $b['last_assign_time'] <=> $a['last_assign_time']);
return ['month' => $ctx['month'], 'inherit_mode' => $ctx['inheritMode'], 'kind' => $kind, 'count' => \count($rows), 'rows' => $rows];
}
/**
* N 诊订单明细:scope 内当月下单、全局序号 = slot 的具体订单(归属持有医助),与 visit{slot}_count 同口径可对账。
* slot=0 表示全部 N>=2(与 visit_all_count 对账),行附 visit_slot。
*
* @param array{month?:string,slot?:int|string,dept_ids?:int[]|string,assistant_id?:int|string,dept_id?:int|string,inherit_mode?: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 < 0 || $slot === 1 || $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 => $_) {
$slotMap = $ctx['slotOrdersByAssistant'][$aid] ?? [];
if ($slot > 0) {
$slotMap = isset($slotMap[$slot]) ? [$slot => $slotMap[$slot]] : [];
}
foreach ($slotMap as $s => $orders) {
foreach ($orders as $r) {
$r['holder_assistant_id'] = (int) $aid;
$r['visit_slot'] = (int) $s;
$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)) : '—',
'visit_slot' => (int) ($r['visit_slot'] ?? 0),
];
}
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. 分母:当月新指派 NI 与继承 IH 的「医助 × 诊单」(RevisitRateCalculator),再剔除名下存在拒收(9)/退款(10) 订单的诊单;
* 3. 分子:曾被指派诊单的当月订单,统计诊次 = 实单序号 + 诊单偏移(默认第 1 笔实单为一诊);
* 4. 应用部门筛选:默认限定「二中心」子树;选定部门时再收窄到该部门及其下级(且须落在二中心子树内);
* 继承集合与旧口径集合与分母/分子一并过滤(当月仅有继承行的医助不会静默丢失、也不会逃过二中心过滤)。
*
* @param array{month?:string,dept_ids?:int[]|string,inherit_mode?:string} $params
*
* @return array{
* month:string,startDate:string,endDate:string,startTs:int,endTs:int,inheritMode:string,
* pairsRaw:list<array{diagnosis_id:int,to_assistant_id:int,from_assistant_id:int,create_time:int,log_id:int}>,
* diagsByAssistant:array<int,array<int,true>>,
* excludeDiagsByAssistant:array<int,array<int,true>>,
* inheritEntryByAssistant:array<int,array<int,int>>,
* inheritPairs:list<array<string,mixed>>,
* slotOrdersByAssistant:array<int,array<int,list<array<string,mixed>>>>,
* maxEntry:int,
* 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');
$inheritMode = self::resolveInheritMode($params);
// 全量指派日志(≤ 月末,诊单未删除):含释放(to=0)与继承行,用于持有时间线
// inherit_visit_slot 为写入时快照,仅供明细展示;统计一律以实时重算为准
$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.from_assistant_id', 'lg.to_assistant_id', 'lg.operator_admin_id',
'lg.create_time', 'lg.is_inherit', 'lg.inherit_visit_slot',
])
->order(['lg.diagnosis_id' => 'asc', 'lg.create_time' => 'asc', 'lg.id' => 'asc'])
->select()
->toArray();
// 分母剔除集合:当月被指派(含继承)诊单中名下存在拒收(9)/退款(10) 业务订单者(与明细 assignLines 同口径)
$refundRejectDiagSet = self::fetchRefundOrRejectDiagnosisSet(
RevisitRateCalculator::monthAssignedDiagIds($logRows, $startTs, $endTs)
);
// 历史「回退自动分配」行(slot 模式按继承处理)
$rollbackKeySet = $inheritMode === RevisitRateCalculator::MODE_SLOT
? self::fetchRollbackKeySet($startTs, $endTs)
: [];
// 分子:曾被指派诊单的订单序列(分块生成器,同一诊单连续),统计诊次 = 实单全局序号 + 诊单偏移
$candidateDiagIds = RevisitRateCalculator::candidateDiagIds($logRows);
$offsetMap = self::fetchRevisitSlotStartOffsetMap($candidateDiagIds);
$orderRows = self::iterateOrderSeqRows(
$candidateDiagIds,
['o.id', 'o.order_no', 'o.diagnosis_id', 'o.create_time', 'o.amount', 'o.creator_id']
);
$calc = RevisitRateCalculator::compute(
$logRows,
$orderRows,
$offsetMap,
$refundRejectDiagSet,
$rollbackKeySet,
$startTs,
$endTs,
$inheritMode,
self::inheritMinNextSlot()
);
unset($logRows);
$pairsRaw = $calc['pairsRaw'];
$diagsByAssistant = $calc['diagsByAssistant'];
$excludeDiagsByAssistant = $calc['excludeDiagsByAssistant'];
$inheritEntryByAssistant = $calc['inheritEntryByAssistant'];
$inheritPairs = $calc['inheritPairs'];
$slotOrdersByAssistant = $calc['slotOrdersByAssistant'];
// 医助归属部门 + 部门筛选(默认仅二中心子树;选定部门时再收窄,含组织下级)
$universeIds = array_keys(
$diagsByAssistant + $slotOrdersByAssistant + $inheritEntryByAssistant + $excludeDiagsByAssistant
);
[$assistantDept, $deptNames] = self::buildAssistantDeptIndex($universeIds);
$subtreeSet = self::resolveDeptFilterSet($params['dept_ids'] ?? null);
if ($subtreeSet === []) {
// 无二中心部门时整表为空,避免误展示其它中心数据
$diagsByAssistant = [];
$excludeDiagsByAssistant = [];
$inheritEntryByAssistant = [];
$slotOrdersByAssistant = [];
} else {
foreach ($universeIds as $aid) {
$deptId = (int) ($assistantDept[$aid] ?? 0);
if ($deptId <= 0 || !isset($subtreeSet[$deptId])) {
unset(
$diagsByAssistant[$aid],
$excludeDiagsByAssistant[$aid],
$inheritEntryByAssistant[$aid],
$slotOrdersByAssistant[$aid]
);
}
}
}
// 列范围上界只看过滤后仍在表内的继承行
$maxEntry = 0;
foreach ($inheritEntryByAssistant as $entries) {
foreach ($entries as $entry) {
if ((int) $entry <= self::MAX_VISIT_SLOT && (int) $entry > $maxEntry) {
$maxEntry = (int) $entry;
}
}
}
return [
'month' => $month,
'startDate' => date('Y-m-d', $startTs),
'endDate' => date('Y-m-d', $endTs),
'startTs' => $startTs,
'endTs' => $endTs,
'inheritMode' => $inheritMode,
'pairsRaw' => $pairsRaw,
'diagsByAssistant' => $diagsByAssistant,
'excludeDiagsByAssistant' => $excludeDiagsByAssistant,
'inheritEntryByAssistant' => $inheritEntryByAssistant,
'inheritPairs' => $inheritPairs,
'slotOrdersByAssistant' => $slotOrdersByAssistant,
'maxEntry' => $maxEntry,
'assistantDept' => $assistantDept,
'deptNames' => $deptNames,
];
}
/**
* 口径白名单:请求参数 inherit_mode=exclude|slot,缺省取 config project.revisit_rate.default_inherit_mode(再缺省 slot)。
*/
private static function resolveInheritMode(array $params): string
{
$default = (string) (config('project.revisit_rate.default_inherit_mode') ?? RevisitRateCalculator::MODE_SLOT);
return RevisitRateCalculator::normalizeMode($params['inherit_mode'] ?? '', $default);
}
/** 继承起算诊次下限(config project.revisit_rate.inherit_min_next_slot,默认 2,最小 2) */
private static function inheritMinNextSlot(): int
{
$v = (int) (config('project.revisit_rate.inherit_min_next_slot') ?? RevisitRateCalculator::DEFAULT_MIN_NEXT_SLOT);
return max(RevisitRateCalculator::DEFAULT_MIN_NEXT_SLOT, $v);
}
/**
* 当月「回退自动分配」键集合:auto_assign_log(diagnosis_id, rollback_time, rollback_admin_id) 与
* assign_log(diagnosis_id, create_time, operator_admin_id) 一一对应(AutoAssignLogLogic::rollbackOne 同一事务同一 $now)。
*
* @return array<string, true>
*/
private static function fetchRollbackKeySet(int $startTs, int $endTs): array
{
$rows = Db::name('tcm_diagnosis_auto_assign_log')
->where('rollback_time', '>=', $startTs)
->where('rollback_time', '<=', $endTs)
->where('diagnosis_id', '>', 0)
->field(['diagnosis_id', 'rollback_time', 'rollback_admin_id'])
->select()
->toArray();
$out = [];
foreach ($rows as $r) {
$out[RevisitRateCalculator::rollbackKey(
(int) ($r['diagnosis_id'] ?? 0),
(int) ($r['rollback_time'] ?? 0),
(int) ($r['rollback_admin_id'] ?? 0)
)] = true;
}
return $out;
}
/**
* 订单序列分块生成器:每块按诊单 id 切分(一个诊单的订单全部落在同一块),块内已按 (diagnosis_id, create_time, id) 升序。
*
* @param list<int> $diagIds
* @param list<string> $fields
*
* @return \Generator<int, array<string, mixed>>
*/
private static function iterateOrderSeqRows(array $diagIds, array $fields): \Generator
{
foreach (array_chunk($diagIds, 2000) as $chunk) {
foreach (self::fetchOrderSeqRows($chunk, $fields) as $r) {
yield $r;
}
}
}
/**
* 统计涉及的医助全集:新指派 ∪ 继承 ∪ 旧口径新指派 ∪ 分子(持有成交)。
*
* @param array{diagsByAssistant:array<int,array<int,true>>,excludeDiagsByAssistant:array<int,array<int,true>>,inheritEntryByAssistant:array<int,array<int,int>>,slotOrdersByAssistant:array<int,array<int,list<array<string,mixed>>>>} $ctx
*
* @return array<int, true>
*/
private static function assistantUniverse(array $ctx): array
{
$set = [];
foreach (['diagsByAssistant', 'excludeDiagsByAssistant', 'inheritEntryByAssistant', 'slotOrdersByAssistant'] as $key) {
foreach (array_keys($ctx[$key] ?? []) 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
{
// 实现已搬至 RevisitVisitSlotService(诊次口径单点维护),此处保留委托以减少 diff
return RevisitVisitSlotService::fetchOrderSeqRows($diagIds, $fields);
}
/**
* @param list<int> $diagIds
*
* @return array<int, int> diagnosis_id => revisit_slot_start_offset
*/
private static function fetchRevisitSlotStartOffsetMap(array $diagIds): array
{
// 实现已搬至 RevisitVisitSlotService(诊次口径单点维护),此处保留委托以减少 diff
return RevisitVisitSlotService::fetchRevisitSlotStartOffsetMap($diagIds);
}
/**
* 诊单复诊统计起始偏移(默认 0:第 1 笔实单计为一诊;统计诊次 = 实单序号 + 偏移)
*
* @param array<int, int> $offsetMap
*/
private static function resolveRevisitSlotStartOffset(int $diagId, array $offsetMap): int
{
// 实现已搬至 RevisitVisitSlotService(诊次口径单点维护),此处保留委托以减少 diff
return RevisitVisitSlotService::resolveRevisitSlotStartOffset($diagId, $offsetMap);
}
/**
* @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;
}
/**
* 指标包:assigned_count(新指派,语义不变)、inherit_count、assigned_total_count、
* visit{N}_count/_den/_rate、visit_all_count/_den/_rate、visit2_rate_new_assign(实现见 RevisitRateCalculator::metricPack)。
*
* @param array<int, int> $inheritEntryByDiag 诊单 => 起算诊次
* @param array<int, int> $slotCounts [slot => n]
* @param list<int> $slots 需输出的分档列表(保证各行键齐全)
* @param int $newAssignedExclude 旧口径(:380 现状)新指派数,供 visit2_rate_new_assign
*
* @return array<string, int|float|null>
*/
private static function buildMetricPack(
int $newAssigned,
array $inheritEntryByDiag,
array $slotCounts,
array $slots,
int $newAssignedExclude
): array {
return RevisitRateCalculator::metricPack($newAssigned, $inheritEntryByDiag, $slotCounts, $slots, $newAssignedExclude);
}
/** 归一化月份参数为 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');
}
}