更新
This commit is contained in:
@@ -0,0 +1,476 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user