477 lines
20 KiB
PHP
477 lines
20 KiB
PHP
<?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;
|
||
}
|
||
}
|