Files
zyt/server/app/adminapi/logic/stats/RevisitCohortCalculator.php
T
2026-10-17 16:08:24 +08:00

215 lines
8.3 KiB
PHP

<?php
declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\common\service\tcm\RevisitVisitSlotService;
/** 指派日期队列:先圈定患者,再累计其持有期间的有效复诊订单;不改变全局诊次。 */
final class RevisitCohortCalculator
{
public const TYPE_NEW = 'new';
public const TYPE_INHERIT = 'inherit';
/**
* 日志与订单分别按 (diagnosis_id, create_time, id) 升序。
* 新指派保留全局诊次;继承每次从二诊重新起算,同一秒订单归最新指派。
* 同一医助、诊单、诊次只计一次,避免反复继承把单诊接诊率推高至 100% 以上。
*/
public static function compute(
array $logRows,
iterable $orderRows,
array $offsetMap,
array $refundRejectSet,
array $rollbackKeySet,
int $startTs,
int $endTs,
int $observedAt,
string $type
): array {
$type = $type === self::TYPE_INHERIT ? self::TYPE_INHERIT : self::TYPE_NEW;
$timeline = [];
$pairs = [];
$lastTarget = [];
foreach ($logRows as $r) {
$did = (int) ($r['diagnosis_id'] ?? 0);
$aid = (int) ($r['to_assistant_id'] ?? 0);
$from = (int) ($r['from_assistant_id'] ?? 0);
$time = (int) ($r['create_time'] ?? 0);
if ($did <= 0 || $time > $observedAt) {
continue;
}
// 从未分配重新指派时,写入侧可能把 from 回填为原医助,形成 A→A。
// 以实际时间线判断持有人是否变化,保留释放后的重新承接。
$previous = $lastTarget[$did] ?? null;
if (($previous !== null && $previous === $aid) || ($previous === null && $from === $aid)) {
continue;
}
$lastTarget[$did] = $aid;
$isRollback = isset($rollbackKeySet[RevisitRateCalculator::rollbackKey(
$did, $time, (int) ($r['operator_admin_id'] ?? 0)
)]);
$eventType = (int) ($r['is_inherit'] ?? 0) === 1 || $isRollback
? self::TYPE_INHERIT : self::TYPE_NEW;
$selected = $aid > 0 && $time >= $startTs && $time <= $endTs
&& $eventType === $type && !isset($refundRejectSet[$did]);
$timeline[$did][] = [
'assistant_id' => $aid,
'time' => $time,
'selected' => $selected,
'type' => $eventType,
];
if (!$selected) {
continue;
}
if (!isset($pairs[$aid][$did])) {
$pairs[$aid][$did] = [
'diagnosis_id' => $did,
'assistant_id' => $aid,
'assign_type' => $type,
'assign_count' => 0,
'first_assign_time' => $time,
'last_assign_time' => $time,
'is_rollback' => 0,
];
}
$pairs[$aid][$did]['assign_count']++;
$pairs[$aid][$did]['last_assign_time'] = $time;
$pairs[$aid][$did]['is_rollback'] |= $isRollback ? 1 : 0;
}
$orders = [];
$curDid = 0;
$globalSeq = 0;
$segmentSeq = 0;
$ptr = 0;
$offset = 0;
$event = null;
foreach ($orderRows as $r) {
$did = (int) ($r['diagnosis_id'] ?? 0);
$ct = (int) ($r['create_time'] ?? 0);
if ($did <= 0 || $ct > $observedAt) {
continue;
}
if ($curDid !== $did) {
$curDid = $did;
$globalSeq = 0;
$segmentSeq = 0;
$ptr = 0;
$event = null;
$offset = RevisitVisitSlotService::resolveRevisitSlotStartOffset($did, $offsetMap);
}
$globalSeq++;
$tl = $timeline[$did] ?? [];
while ($ptr < \count($tl) && $tl[$ptr]['time'] <= $ct) {
$event = $tl[$ptr++];
$segmentSeq = 0;
}
if ($event === null) {
continue;
}
$segmentSeq++;
if (!$event['selected']) {
continue;
}
$slot = $type === self::TYPE_INHERIT ? $segmentSeq + 1 : $globalSeq + $offset;
if ($slot < 2 || $slot > RevisitRateCalculator::MAX_VISIT_SLOT) {
continue;
}
$aid = $event['assistant_id'];
// 重复承接同一患者时,明细保留首次达到该诊次的订单,与人数指标逐项对账。
if (isset($orders[$aid][$slot][$did])) {
continue;
}
$r['assistant_id'] = $aid;
$r['visit_slot'] = $slot;
$r['global_visit_slot'] = $globalSeq + $offset;
$r['cohort_assign_time'] = $event['time'];
$orders[$aid][$slot][$did] = $r;
}
return ['pairsByAssistant' => $pairs, 'ordersByAssistant' => $orders, 'cohort_type' => $type];
}
/** 部门与总计按诊单去重;每个诊次保留同一患者最早一笔订单。 */
public static function scopedOrders(array $calc, array $assistantIds, int $slot = 0): array
{
$out = [];
foreach ($assistantIds as $aid) {
foreach ($calc['ordersByAssistant'][$aid] ?? [] as $s => $byDiag) {
if ($slot > 0 && $s !== $slot) {
continue;
}
foreach ($byDiag as $did => $r) {
$previous = $out[$s][$did] ?? null;
if ($previous === null || [(int) $r['create_time'], (int) $r['id']]
< [(int) $previous['create_time'], (int) $previous['id']]) {
$out[$s][$did] = $r;
}
}
}
}
return $out;
}
public static function metricPack(array $calc, array $assistantIds, array $slots): array
{
$diagSet = [];
foreach ($assistantIds as $aid) {
foreach ($calc['pairsByAssistant'][$aid] ?? [] as $did => $_) {
$diagSet[$did] = true;
}
}
$count = \count($diagSet);
$inherit = $calc['cohort_type'] === self::TYPE_INHERIT;
$pack = [
'assigned_count' => $inherit ? 0 : $count,
'inherit_count' => $inherit ? $count : 0,
'assigned_total_count' => $count,
];
$orders = self::scopedOrders($calc, $assistantIds);
$all = array_sum(array_map('count', $orders));
foreach ($slots as $slot) {
$num = \count($orders[$slot] ?? []);
$pack['visit' . $slot . '_count'] = $num;
$pack['visit' . $slot . '_den'] = $count;
$pack['visit' . $slot . '_rate'] = $count > 0 ? round($num / $count * 100, 2) : null;
}
$pack['visit_all_count'] = $all;
$pack['visit_all_den'] = $count;
$pack['visit_all_rate'] = $count > 0 ? round($all / $count * 100, 2) : null;
return $pack;
}
/** 严格校验日期,避免非法日期被 strtotime 自动滚到次月。 */
public static function dateRange(array $params, int $now): array
{
$start = trim((string) ($params['start_date'] ?? ''));
$end = trim((string) ($params['end_date'] ?? ''));
if ($start === '' && $end === '') {
$month = trim((string) ($params['month'] ?? ''));
if (preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month) === 1) {
$start = $month . '-01';
$end = date('Y-m-t', (int) strtotime($start));
} else {
$start = date('Y-m-01', $now);
$end = date('Y-m-d', $now);
}
}
foreach ([$start, $end] as $date) {
$parsed = \DateTimeImmutable::createFromFormat('!Y-m-d', $date);
if ($parsed === false || $parsed->format('Y-m-d') !== $date) {
throw new \InvalidArgumentException('请选择有效的开始日期和结束日期');
}
}
if ($start > $end) {
throw new \InvalidArgumentException('开始日期不能晚于结束日期');
}
return [$start, $end, (int) strtotime($start . ' 00:00:00'), (int) strtotime($end . ' 23:59:59')];
}
}