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

477 lines
20 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\common\service\tcm\RevisitVisitSlotService;
/**
* 复诊接诊率 v2 纯计算器(不连库,承接 RevisitRateLogic::buildStatsCore 的内存计算;单测可直接驱动)。
*
* 口径(方案 §1.1,定义 A 累计型、每列独立分母):
* - NI(A):新指派集合 = 当月 is_inherit=0、to=A 的指派日志对应诊单(slot 模式下忽略 from==to 行)\ R;
* - IH(A):继承集合 = 当月 is_inherit=1(或 slot 模式下命中历史回退行)、to=A、from≠to 的诊单,且 D ∉ NI(A),\ R;
* 同一 (A, D) 当月多条继承取最早一条;
* - R:名下存在履约拒收(9)/退款(10) 订单的诊单(由调用方查库后传入);
* - 继承时诊次 k_D = slotBefore(该诊单有效订单 create_time 列表, offset, t_l)(严格小于,与持有人规则 t <= ct 对偶);
* 起算诊次 entry_D = max(inherit_min_next_slot, k_D + 1);entry_D > MAX_VISIT_SLOT 的继承行只进合计分母、不进任何诊次列;
* - 分子 Num_s(A):当月下单、slot = s、2 <= s <= 50、下单时点持有人为 A 的订单(与原实现完全一致,不受模式影响);
* - 无论模式都另行输出 NI_exclude(A)(:380 现状口径:仅 is_inherit=0,不忽略 from==to、不豁免回退行),
* 供 visit2_rate_new_assign 使用。
*
* inherit_mode:
* - exclude:完全复现旧口径,IH = ∅,NI = NI_exclude;
* - slot :继承按继承时诊次计入每列独立分母。
*/
final class RevisitRateCalculator
{
public const MODE_EXCLUDE = 'exclude';
public const MODE_SLOT = 'slot';
/** 复诊序号统计上限(与 RevisitRateLogic::MAX_VISIT_SLOT 一致) */
public const MAX_VISIT_SLOT = 50;
/** 继承起算诊次下限默认值(config project.revisit_rate.inherit_min_next_slot) */
public const DEFAULT_MIN_NEXT_SLOT = 2;
/** 旧口径 :380 的字段名(供自动分配过渡期分档) */
public const FIELD_RATE_NEW_ASSIGN = 'visit2_rate_new_assign';
/** 口径白名单归一化;非法值回退 $default */
public static function normalizeMode(mixed $raw, string $default = self::MODE_SLOT): string
{
$mode = strtolower(trim((string) $raw));
if ($mode === self::MODE_EXCLUDE || $mode === self::MODE_SLOT) {
return $mode;
}
$default = strtolower(trim($default));
return $default === self::MODE_EXCLUDE ? self::MODE_EXCLUDE : self::MODE_SLOT;
}
/** 历史回退行匹配键:auto_assign_log(diagnosis_id, rollback_time, rollback_admin_id) ↔ assign_log(diagnosis_id, create_time, operator_admin_id) */
public static function rollbackKey(int $diagnosisId, int $time, int $operatorAdminId): string
{
return $diagnosisId . '|' . $time . '|' . $operatorAdminId;
}
/**
* 当月被指派(to>0、t∈M,含继承 / from==to 行)的诊单 id 去重列表,供查询拒收/退款剔除集合。
*
* @param list<array<string, mixed>> $logRows
*
* @return list<int>
*/
public static function monthAssignedDiagIds(array $logRows, int $startTs, int $endTs): array
{
$set = [];
foreach ($logRows as $r) {
$did = (int) ($r['diagnosis_id'] ?? 0);
$aid = (int) ($r['to_assistant_id'] ?? 0);
$t = (int) ($r['create_time'] ?? 0);
if ($did > 0 && $aid > 0 && $t >= $startTs && $t <= $endTs) {
$set[$did] = true;
}
}
return array_keys($set);
}
/**
* 曾被指派(to>0,任意时间,含继承)的诊单 id 列表:分子候选、订单序列拉取范围。
*
* @param list<array<string, mixed>> $logRows
*
* @return list<int>
*/
public static function candidateDiagIds(array $logRows): array
{
$set = [];
foreach ($logRows as $r) {
$did = (int) ($r['diagnosis_id'] ?? 0);
if ($did > 0 && (int) ($r['to_assistant_id'] ?? 0) > 0) {
$set[$did] = true;
}
}
return array_keys($set);
}
/**
* 核心计算。
*
* @param list<array<string, mixed>> $logRows 指派日志(≤ 月末、诊单未删除),须按 (diagnosis_id, create_time, id) 升序;
* 键:id, diagnosis_id, from_assistant_id, to_assistant_id, operator_admin_id, create_time, is_inherit
* @param iterable<array<string, mixed>> $orderRows 有效订单行,须按 (diagnosis_id, create_time, id) 升序且同一诊单连续(可分块生成器);
* 键:id, diagnosis_id, create_time(其余字段原样进入 slotOrdersByAssistant)
* @param array<int, int> $offsetMap diagnosis_id => revisit_slot_start_offset(原始值)
* @param array<int, true> $refundRejectSet 名下有拒收/退款订单的诊单集合 R
* @param array<string, true> $rollbackKeySet 历史回退行键集合(rollbackKey()),仅 slot 模式使用
* @param string $inheritMode exclude | slot
* @param int $inheritMinNextSlot 继承起算诊次下限(默认 2)
*
* @return array{
* 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{diagnosis_id:int,to_assistant_id:int,from_assistant_id:int,create_time:int,log_id:int,inherit_visit_slot:int,entry_slot:int,assign_count:int,is_rollback:int,inherit_visit_slot_snapshot:int}>,
* slotOrdersByAssistant:array<int,array<int,list<array<string,mixed>>>>,
* maxEntry:int
* }
*/
public static function compute(
array $logRows,
iterable $orderRows,
array $offsetMap,
array $refundRejectSet,
array $rollbackKeySet,
int $startTs,
int $endTs,
string $inheritMode,
int $inheritMinNextSlot = self::DEFAULT_MIN_NEXT_SLOT
): array {
$inheritMode = self::normalizeMode($inheritMode);
$slotMode = $inheritMode === self::MODE_SLOT;
if ($inheritMinNextSlot < 2) {
$inheritMinNextSlot = 2;
}
/** @var array<int, list<array{t:int,to:int}>> $timeline 诊单 => 持有变更时间线(升序,含释放/继承/from==to 行) */
$timeline = [];
$pairsRaw = [];
/** @var array<int, array<int, true>> $diagsByAssistant 当前模式下的 NI */
$diagsByAssistant = [];
/** @var array<int, array<int, true>> $excludeDiagsByAssistant :380 现状口径 NI_exclude */
$excludeDiagsByAssistant = [];
/** @var array<int, array<int, array<string, mixed>>> $inheritFirst 医助 => 诊单 => 最早一条继承日志(附 assign_count) */
$inheritFirst = [];
foreach ($logRows as $r) {
$did = (int) ($r['diagnosis_id'] ?? 0);
$aid = (int) ($r['to_assistant_id'] ?? 0);
$from = (int) ($r['from_assistant_id'] ?? 0);
$t = (int) ($r['create_time'] ?? 0);
$isInherit = (int) ($r['is_inherit'] ?? 0) === 1;
$logId = (int) ($r['id'] ?? 0);
if ($did <= 0) {
continue;
}
$timeline[$did][] = ['t' => $t, 'to' => $aid];
if ($aid <= 0 || $t < $startTs || $t > $endTs) {
continue;
}
// 旧口径集合:仅看 is_inherit=0(不忽略 from==to、不豁免回退行)
if (!$isInherit) {
$excludeDiagsByAssistant[$aid][$did] = true;
}
if (!$slotMode) {
if (!$isInherit) {
$pairsRaw[] = [
'diagnosis_id' => $did,
'to_assistant_id' => $aid,
'from_assistant_id' => $from,
'create_time' => $t,
'log_id' => $logId,
];
$diagsByAssistant[$aid][$did] = true;
}
continue;
}
// slot 模式:指派给自己(from == to)不计任何集合
if ($from === $aid) {
continue;
}
$isRollback = $rollbackKeySet !== []
&& isset($rollbackKeySet[self::rollbackKey($did, $t, (int) ($r['operator_admin_id'] ?? 0))]);
if ($isInherit || $isRollback) {
if (!isset($inheritFirst[$aid][$did])) {
$inheritFirst[$aid][$did] = [
'diagnosis_id' => $did,
'to_assistant_id' => $aid,
'from_assistant_id' => $from,
'create_time' => $t,
'log_id' => $logId,
'is_rollback' => $isRollback ? 1 : 0,
'inherit_visit_slot_snapshot' => (int) ($r['inherit_visit_slot'] ?? 0),
'assign_count' => 1,
];
} else {
$inheritFirst[$aid][$did]['assign_count']++;
}
continue;
}
$pairsRaw[] = [
'diagnosis_id' => $did,
'to_assistant_id' => $aid,
'from_assistant_id' => $from,
'create_time' => $t,
'log_id' => $logId,
];
$diagsByAssistant[$aid][$did] = true;
}
// D 同月既被非继承又被继承指派给同一 A 时只算 NI
foreach ($inheritFirst as $aid => $diagMap) {
foreach ($diagMap as $did => $_) {
if (isset($diagsByAssistant[$aid][$did])) {
unset($inheritFirst[$aid][$did]);
}
}
if ($inheritFirst[$aid] === []) {
unset($inheritFirst[$aid]);
}
}
// 剔除名下存在拒收(9)/退款(10) 业务订单的诊单(NI / NI_exclude / IH 同时生效)
if ($refundRejectSet !== []) {
$strip = static function (array &$map) use ($refundRejectSet): void {
foreach ($map as $aid => $diagMap) {
foreach ($diagMap as $did => $_) {
if (isset($refundRejectSet[$did])) {
unset($map[$aid][$did]);
}
}
if ($map[$aid] === []) {
unset($map[$aid]);
}
}
};
$strip($diagsByAssistant);
$strip($excludeDiagsByAssistant);
$strip($inheritFirst);
$pairsRaw = array_values(array_filter(
$pairsRaw,
static fn (array $p): bool => !isset($refundRejectSet[$p['diagnosis_id']])
));
}
/** @var array<int, true> $inheritDiagSet 需要收集订单时间列表的诊单 */
$inheritDiagSet = [];
foreach ($inheritFirst as $diagMap) {
foreach ($diagMap as $did => $_) {
$inheritDiagSet[$did] = true;
}
}
/** @var array<int, list<int>> $inheritOrderCts 继承诊单 => 有效订单 create_time 列表 */
$inheritOrderCts = [];
// 分子:曾被指派诊单的订单,统计诊次 = 实单全局序号 + 诊单偏移;持有人 = 订单时间之前(含同刻)最近一次指派
/** @var array<int, array<int, list<array<string, mixed>>>> $slotOrdersByAssistant */
$slotOrdersByAssistant = [];
$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 = RevisitVisitSlotService::resolveRevisitSlotStartOffset($did, $offsetMap);
}
$seq++;
$effectiveSlot = $seq + $offset;
$ct = (int) ($r['create_time'] ?? 0);
if (isset($inheritDiagSet[$did])) {
$inheritOrderCts[$did][] = $ct;
}
// 推进时间线指针:订单时间之前(含同刻)最近一次指派的持有人
$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;
}
}
// 继承时诊次 k 与起算诊次 entry
/** @var array<int, array<int, int>> $inheritEntryByAssistant */
$inheritEntryByAssistant = [];
$inheritPairs = [];
$maxEntry = 0;
foreach ($inheritFirst as $aid => $diagMap) {
foreach ($diagMap as $did => $row) {
$k = RevisitVisitSlotService::slotBefore(
$inheritOrderCts[$did] ?? [],
RevisitVisitSlotService::resolveRevisitSlotStartOffset($did, $offsetMap),
(int) $row['create_time']
);
$entry = max($inheritMinNextSlot, $k + 1);
$inheritEntryByAssistant[$aid][$did] = $entry;
if ($entry <= self::MAX_VISIT_SLOT && $entry > $maxEntry) {
$maxEntry = $entry;
}
$row['inherit_visit_slot'] = $k;
$row['entry_slot'] = $entry;
$inheritPairs[] = $row;
}
}
return [
'inheritMode' => $inheritMode,
'pairsRaw' => $pairsRaw,
'diagsByAssistant' => $diagsByAssistant,
'excludeDiagsByAssistant' => $excludeDiagsByAssistant,
'inheritEntryByAssistant' => $inheritEntryByAssistant,
'inheritPairs' => $inheritPairs,
'slotOrdersByAssistant' => $slotOrdersByAssistant,
'maxEntry' => $maxEntry,
];
}
/**
* 诊单集合按医助并集(部门行 / 合计行按诊单去重)。
*
* @param array<int, array<int, true>> $setsByAssistant
* @param list<int> $assistantIds
*
* @return array<int, true>
*/
public static function unionDiagSets(array $setsByAssistant, array $assistantIds): array
{
$out = [];
foreach ($assistantIds as $aid) {
foreach ($setsByAssistant[(int) $aid] ?? [] as $did => $_) {
$out[(int) $did] = true;
}
}
return $out;
}
/**
* 部门行 / 合计行的 NI 与 IH 合并:NI_dept = ∪NI(A);IH_dept = (∪IH(A)) \ NI_dept,entry 取各继承者中的最小值。
*
* @param array<int, array<int, true>> $diagsByAssistant
* @param array<int, array<int, int>> $inheritEntryByAssistant
* @param list<int> $assistantIds
*
* @return array{0: array<int, true>, 1: array<int, int>} [NI 去重集合, 诊单 => entry]
*/
public static function mergeInheritEntries(array $diagsByAssistant, array $inheritEntryByAssistant, array $assistantIds): array
{
$niSet = self::unionDiagSets($diagsByAssistant, $assistantIds);
$ih = [];
foreach ($assistantIds as $aid) {
foreach ($inheritEntryByAssistant[(int) $aid] ?? [] as $did => $entry) {
$did = (int) $did;
if (isset($niSet[$did])) {
continue;
}
$entry = (int) $entry;
$ih[$did] = isset($ih[$did]) ? min($ih[$did], $entry) : $entry;
}
}
return [$niSet, $ih];
}
/**
* 列范围:2 ~ min(MAX, max(minCeiling, 当月分子最大 slot, 最大起算诊次))。
*
* @param array<int, array<int, list<array<string, mixed>>>> $slotOrdersByAssistant
*
* @return list<int>
*/
public static function slotRange(array $slotOrdersByAssistant, int $maxEntry, int $minCeiling = 4): array
{
$maxHitSlot = 0;
foreach ($slotOrdersByAssistant as $slotMap) {
foreach ($slotMap as $slot => $_) {
if ((int) $slot > $maxHitSlot) {
$maxHitSlot = (int) $slot;
}
}
}
$upper = max($minCeiling, $maxHitSlot, $maxEntry);
$upper = min(self::MAX_VISIT_SLOT, max(2, $upper));
return range(2, $upper);
}
/**
* 指标包:
* assigned_count(新指派,语义不变)、inherit_count、assigned_total_count、
* visit{N}_count / _den / _rate(Den_s = NI + |{entry <= s}|)、visit_all_count / _den / _rate、visit2_rate_new_assign。
*
* @param array<int, int> $inheritEntryByDiag 诊单 => 起算诊次
* @param array<int, int> $slotCounts [slot => n]
* @param list<int> $slots 需输出的分档列表(保证各行键齐全)
*
* @return array<string, int|float|null>
*/
public static function metricPack(
int $newAssigned,
array $inheritEntryByDiag,
array $slotCounts,
array $slots,
int $newAssignedExclude
): array {
$inheritCount = \count($inheritEntryByDiag);
/** @var array<int, int> $byEntry entry => 继承诊单数 */
$byEntry = [];
foreach ($inheritEntryByDiag as $entry) {
$entry = (int) $entry;
$byEntry[$entry] = ($byEntry[$entry] ?? 0) + 1;
}
ksort($byEntry);
$pack = [
'assigned_count' => $newAssigned,
'inherit_count' => $inheritCount,
'assigned_total_count' => $newAssigned + $inheritCount,
];
foreach ($slots as $slot) {
$slot = (int) $slot;
$cnt = (int) ($slotCounts[$slot] ?? 0);
$den = $newAssigned;
foreach ($byEntry as $entry => $n) {
if ($entry > $slot) {
break;
}
$den += $n;
}
$pack['visit' . $slot . '_count'] = $cnt;
$pack['visit' . $slot . '_den'] = $den;
$pack['visit' . $slot . '_rate'] = $den > 0 ? round($cnt / $den * 100, 2) : null;
}
$allCount = 0;
foreach ($slotCounts as $slot => $cnt) {
$slot = (int) $slot;
if ($slot >= 2 && $slot <= self::MAX_VISIT_SLOT) {
$allCount += (int) $cnt;
}
}
$allDen = $newAssigned + $inheritCount;
$pack['visit_all_count'] = $allCount;
$pack['visit_all_den'] = $allDen;
$pack['visit_all_rate'] = $allDen > 0 ? round($allCount / $allDen * 100, 2) : null;
$num2 = (int) ($slotCounts[2] ?? 0);
$pack[self::FIELD_RATE_NEW_ASSIGN] = $newAssignedExclude > 0
? round($num2 / $newAssignedExclude * 100, 2)
: null;
return $pack;
}
}