Files
zyt/server/app/common/service/tcm/RevisitDueCalculator.php
T
2026-10-15 11:03:54 +08:00

463 lines
19 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\common\service\tcm;
/**
* 复诊到期纯函数计算器(无 DB、无 config() 依赖,可直接单测)。
*
* - computeFacts():由「最新有效订单 + 处方天数 + 签收/发货时间 + 挂号 + 首次触达」算出快照表事实列(方案 §1.2);
* - deriveState():由快照行事实列 + today 推导展示状态(方案 §1.3 九态与优先级),列表 / 弹窗 / 主管视图共用;
* - escalationLevel():延迟次数 → 升级等级(方案 §1.4)。
*
* 约定:所有日期为 'Y-m-d' 字符串(或 null),时间戳为 int 秒;配置缺项用 defaultConfig() 补齐。
*/
final class RevisitDueCalculator
{
public const STATE_VISITED = 'visited';
public const STATE_BOOKED = 'booked';
public const STATE_MISSED = 'missed';
public const STATE_CONTACTED = 'contacted';
public const STATE_OVERDUE = 'overdue';
public const STATE_WARNING = 'warning';
public const STATE_PENDING = 'pending';
public const STATE_NO_DAYS = 'no_days';
public const STATE_NONE = 'none';
/** 起算来源:1=签收 2=发货 3=下单 0=无 */
public const ANCHOR_NONE = 0;
public const ANCHOR_SIGN = 1;
public const ANCHOR_SHIP = 2;
public const ANCHOR_CREATE = 3;
/** 天数来源:0=缺失(或配置兜底) 1=订单 2=处方 */
public const DAYS_SRC_NONE = 0;
public const DAYS_SRC_ORDER = 1;
public const DAYS_SRC_RX = 2;
/** 周期状态(与 DiagnosisRevisitDue::CLOSED_* 一致) */
public const CLOSED_OPEN = 0;
public const CLOSED_BOOKED = 2;
public const CLOSED_VISITED = 3;
public const CLOSED_MANUAL = 4;
public const CLOSED_INVALID = 5;
/** 挂号事实状态:0=无 1=已约 3=已面诊 4=过号 */
public const APPT_NONE = 0;
public const APPT_BOOKED = 1;
public const APPT_VISITED = 3;
public const APPT_MISSED = 4;
/** 偏移量上限(与 RevisitVisitSlotService::MAX_OFFSET 一致) */
private const MAX_OFFSET = 20;
/**
* 默认配置(与 config/project.php 的 revisit_reach 段同键;此处只含计算相关项)。
*
* @return array<string, mixed>
*/
public static function defaultConfig(): array
{
return [
'anchor_chain' => ['sign', 'ship', 'create'],
'days_fallback_rx' => true,
'days_default' => 0,
'day_buckets' => [14, 21, 28, 30],
'grace_days' => 0,
'warn_days' => 3,
'severe_overdue_days' => 7,
'appointment_min_gap_days' => 1,
'limit_er_center' => true,
];
}
/**
* 合并默认值并做类型归一(容忍 .env / 配置里写成字符串)。
*
* @param array<string, mixed> $cfg
*
* @return array<string, mixed>
*/
public static function normalizeConfig(array $cfg): array
{
$out = array_replace(self::defaultConfig(), $cfg);
$chain = $out['anchor_chain'];
if (is_string($chain)) {
$chain = explode(',', $chain);
}
if (!is_array($chain)) {
$chain = ['create'];
}
$normChain = [];
foreach ($chain as $c) {
$c = strtolower(trim((string) $c));
if (in_array($c, ['sign', 'ship', 'create'], true) && !in_array($c, $normChain, true)) {
$normChain[] = $c;
}
}
$out['anchor_chain'] = $normChain;
$buckets = is_array($out['day_buckets']) ? $out['day_buckets'] : [];
$out['day_buckets'] = array_values(array_unique(array_filter(array_map('intval', $buckets), static fn (int $v): bool => $v > 0)));
$out['days_fallback_rx'] = (bool) $out['days_fallback_rx'];
$out['days_default'] = max(0, (int) $out['days_default']);
$out['grace_days'] = max(0, (int) $out['grace_days']);
$out['warn_days'] = max(0, (int) $out['warn_days']);
$out['appointment_min_gap_days'] = max(0, (int) ($out['appointment_min_gap_days'] ?? 1));
$out['limit_er_center'] = (bool) $out['limit_er_center'];
// severe_overdue_days 允许阶梯数组:取最小阈值作为「深红」起点
$severe = $out['severe_overdue_days'];
if (is_array($severe)) {
$vals = array_filter(array_map('intval', $severe), static fn (int $v): bool => $v > 0);
$severe = $vals !== [] ? min($vals) : 7;
}
$out['severe_overdue_days'] = max(0, (int) $severe);
return $out;
}
/**
* 计算快照事实列。
*
* @param array{
* diagnosis_id:int, assistant_id:int, patient_name?:string,
* latest_order:null|array{id:int, create_time:int, medication_days?:int, prescription_id?:int},
* valid_order_count:int, offset:int, rx_usage_days?:int|null,
* sign_ts?:int|null, ship_ts?:int|null,
* appointments?:list<array{id:int, status:int, appointment_date:string, appointment_time?:string}>,
* first_touch_ts?:int|null, total_medication_days?:int, diagnosis_active?:bool
* } $input
* @param array<string, mixed> $cfg revisit_reach 配置(可只传部分键)
* @param string $today 'Y-m-d',用于判定「已约」是否仍在未来
*
* @return array{
* source_order_id:int, order_create_time:int, valid_order_count:int,
* anchor_ts:int, anchor_src:int, medication_days:int, days_src:int, rule_days:int,
* total_medication_days:int, current_slot:int, next_slot:int,
* first_touch_date:string|null, due_date:string|null,
* appointment_state:int, appointment_date:string|null, booked_appointment_id:int, closed_state_auto:int
* }
*/
public static function computeFacts(array $input, array $cfg, string $today): array
{
$cfg = self::normalizeConfig($cfg);
$assistantId = (int) ($input['assistant_id'] ?? 0);
$validCount = max(0, (int) ($input['valid_order_count'] ?? 0));
$offset = (int) ($input['offset'] ?? 0);
if ($offset < 0) {
$offset = 0;
}
if ($offset > self::MAX_OFFSET) {
$offset = self::MAX_OFFSET;
}
$currentSlot = $validCount + $offset;
$firstTouchTs = (int) ($input['first_touch_ts'] ?? 0);
$diagnosisActive = (bool) ($input['diagnosis_active'] ?? true);
$order = $input['latest_order'] ?? null;
if (!is_array($order) || (int) ($order['id'] ?? 0) <= 0) {
$order = null;
}
$facts = [
'source_order_id' => 0,
'order_create_time' => 0,
'valid_order_count' => $validCount,
'anchor_ts' => 0,
'anchor_src' => self::ANCHOR_NONE,
'medication_days' => 0,
'days_src' => self::DAYS_SRC_NONE,
'rule_days' => 0,
'total_medication_days' => max(0, (int) ($input['total_medication_days'] ?? 0)),
'current_slot' => $currentSlot,
'next_slot' => $currentSlot + 1,
'first_touch_date' => $firstTouchTs > 0 ? date('Y-m-d', $firstTouchTs) : null,
'due_date' => null,
'appointment_state' => self::APPT_NONE,
'appointment_date' => null,
'booked_appointment_id' => 0,
'closed_state_auto' => self::CLOSED_INVALID,
];
// 锚点订单先于失效判定写入:失效行(无医助/禁用/删除)也记录最新有效订单,
// 否则列表自愈会因 source_order_id=0 ≠ 最新订单 id 而在每次加载时重复刷新这些行
$orderCreateTime = $order !== null ? (int) ($order['create_time'] ?? 0) : 0;
$facts['source_order_id'] = $order !== null ? (int) ($order['id'] ?? 0) : 0;
$facts['order_create_time'] = $orderCreateTime;
// 无有效订单 / 无医助 / 诊单删除或禁用 → 失效周期
if ($order === null || $validCount <= 0 || $assistantId <= 0 || !$diagnosisActive) {
return $facts;
}
// 1. 用药起算锚点:按 anchor_chain 回退(签收 → 发货 → 下单);链耗尽仍无结果时兜底下单时间
$signTs = (int) ($input['sign_ts'] ?? 0);
$shipTs = (int) ($input['ship_ts'] ?? 0);
foreach ($cfg['anchor_chain'] as $src) {
if ($src === 'sign' && $signTs > 0) {
$facts['anchor_ts'] = $signTs;
$facts['anchor_src'] = self::ANCHOR_SIGN;
break;
}
if ($src === 'ship' && $shipTs > 0) {
$facts['anchor_ts'] = $shipTs;
$facts['anchor_src'] = self::ANCHOR_SHIP;
break;
}
if ($src === 'create' && $orderCreateTime > 0) {
$facts['anchor_ts'] = $orderCreateTime;
$facts['anchor_src'] = self::ANCHOR_CREATE;
break;
}
}
if ($facts['anchor_ts'] <= 0 && $orderCreateTime > 0) {
$facts['anchor_ts'] = $orderCreateTime;
$facts['anchor_src'] = self::ANCHOR_CREATE;
}
// 2. 开药天数:订单 medication_days → 处方 usage_days(已剔系统代开/作废)→ 配置兜底(days_default,src 仍记 0)
$orderDays = (int) ($order['medication_days'] ?? 0);
$rxDays = (int) ($input['rx_usage_days'] ?? 0);
if ($orderDays > 0) {
$facts['medication_days'] = $orderDays;
$facts['days_src'] = self::DAYS_SRC_ORDER;
} elseif ($cfg['days_fallback_rx'] && $rxDays > 0) {
$facts['medication_days'] = $rxDays;
$facts['days_src'] = self::DAYS_SRC_RX;
} elseif ($cfg['days_default'] > 0) {
$facts['medication_days'] = (int) $cfg['days_default'];
$facts['days_src'] = self::DAYS_SRC_NONE;
}
// 3. 档位:仅当天数恰好命中 day_buckets 才归档(15 天不归 14),只用于筛选/展示
if ($facts['medication_days'] > 0 && in_array($facts['medication_days'], $cfg['day_buckets'], true)) {
$facts['rule_days'] = $facts['medication_days'];
}
// 4. 预计复诊日 = DATE(anchor_ts) + medication_days + grace_days;缺天数为 null
if ($facts['anchor_ts'] > 0 && $facts['medication_days'] > 0) {
$facts['due_date'] = self::addDays(date('Y-m-d', $facts['anchor_ts']), $facts['medication_days'] + (int) $cfg['grace_days']);
}
// 5. 最新有效挂号 A*(status ∈ 1,3,4;按 appointment_date, appointment_time, id 取最新)
// 只认「下单日 + appointment_min_gap_days(默认 1,即次日起)」之后的挂号:
// 实际流程多为「挂号 → 面诊 → 开方 → 当天下单」,同一天(及更早)的面诊是产生这笔药的那次面诊,
// 不是这笔药之后的复诊,若计入会把周期立刻关闭、患者到期后永远不被提醒。
$orderDate = $orderCreateTime > 0 ? date('Y-m-d', $orderCreateTime) : '';
$minAptDate = $orderDate !== '' ? self::addDays($orderDate, (int) $cfg['appointment_min_gap_days']) : '';
$best = null;
$bestKey = '';
foreach ((array) ($input['appointments'] ?? []) as $apt) {
if (!is_array($apt)) {
continue;
}
$status = (int) ($apt['status'] ?? 0);
if (!in_array($status, [self::APPT_BOOKED, self::APPT_VISITED, self::APPT_MISSED], true)) {
continue;
}
$aptDate = substr(trim((string) ($apt['appointment_date'] ?? '')), 0, 10);
if ($aptDate === '' || ($minAptDate !== '' && $aptDate < $minAptDate)) {
continue;
}
$key = $aptDate . ' ' . self::normalizeTime((string) ($apt['appointment_time'] ?? '')) . ' ' . sprintf('%012d', (int) ($apt['id'] ?? 0));
if ($best === null || strcmp($key, $bestKey) > 0) {
$best = $apt;
$bestKey = $key;
$best['_date'] = $aptDate;
$best['_status'] = $status;
}
}
if ($best !== null) {
$facts['appointment_state'] = (int) $best['_status'];
$facts['appointment_date'] = (string) $best['_date'];
$facts['booked_appointment_id'] = (int) ($best['id'] ?? 0);
}
// 6. 自动周期状态:已面诊 → 3;已约且日期未过 → 2;其余(无挂号/过号/已约已过期/缺天数)保持进行中 0
if ($facts['appointment_state'] === self::APPT_VISITED) {
$facts['closed_state_auto'] = self::CLOSED_VISITED;
} elseif ($facts['appointment_state'] === self::APPT_BOOKED && $facts['appointment_date'] !== null && $facts['appointment_date'] >= $today) {
$facts['closed_state_auto'] = self::CLOSED_BOOKED;
} else {
$facts['closed_state_auto'] = self::CLOSED_OPEN;
}
return $facts;
}
/**
* 由快照行 + today 推导展示状态(方案 §1.3)。
*
* 优先级:visited > booked > missed > contacted > overdue > warning > pending > no_days > none。
* missed 不受 due_date 限制(到期日前挂号并过号同样标红);人工关闭(closed_state=4) 的行保留事实状态但不深红,文案前缀「已关闭·」。
*
* @param array<string, mixed> $row 快照行(含 closed_state/appointment_state/appointment_date/due_date/promised_date/valid_order_count/source_order_id/assistant_id)
* @param array<string, mixed> $cfg revisit_reach 配置
*
* @return array{state:string, state_text:string, days_left:int|null, overdue_days:int|null, severe:int}
*/
public static function deriveState(array $row, string $today, array $cfg): array
{
$cfg = self::normalizeConfig($cfg);
$warnDays = (int) $cfg['warn_days'];
$severeDays = (int) $cfg['severe_overdue_days'];
$closed = (int) ($row['closed_state'] ?? self::CLOSED_OPEN);
$apptState = (int) ($row['appointment_state'] ?? self::APPT_NONE);
$apptDate = self::nullableDate($row['appointment_date'] ?? null);
$dueDate = self::nullableDate($row['due_date'] ?? null);
$promised = self::nullableDate($row['promised_date'] ?? null);
$validCount = (int) ($row['valid_order_count'] ?? 0);
$sourceOrderId = (int) ($row['source_order_id'] ?? 0);
$assistantId = (int) ($row['assistant_id'] ?? 0);
$none = ['state' => self::STATE_NONE, 'state_text' => '', 'days_left' => null, 'overdue_days' => null, 'severe' => 0];
if ($validCount <= 0 || $sourceOrderId <= 0 || $assistantId <= 0 || $closed === self::CLOSED_INVALID) {
return $none;
}
$daysLeft = $dueDate !== null ? self::dayDiff($today, $dueDate) : null;
$overdueDays = $daysLeft !== null ? max(0, -$daysLeft) : null;
$severe = ($overdueDays !== null && $severeDays > 0 && $overdueDays >= $severeDays) ? 1 : 0;
$state = self::STATE_NONE;
$text = '';
if ($apptState === self::APPT_VISITED) {
$state = self::STATE_VISITED;
$text = '已面诊' . self::mdSuffix($apptDate);
$severe = 0;
} elseif ($apptState === self::APPT_BOOKED && ($apptDate === null || $apptDate >= $today)) {
$state = self::STATE_BOOKED;
$text = '已约' . self::mdSuffix($apptDate);
$severe = 0;
} elseif ($apptState === self::APPT_MISSED || ($apptState === self::APPT_BOOKED && $apptDate !== null && $apptDate < $today)) {
$state = self::STATE_MISSED;
$text = '过号' . self::mdSuffix($apptDate);
} elseif ($promised !== null && $promised >= $today) {
$state = self::STATE_CONTACTED;
$text = '已联系·约 ' . substr($promised, 5);
$severe = 0;
} elseif ($dueDate === null || $daysLeft === null) {
$state = self::STATE_NO_DAYS;
$text = '缺开药天数';
$severe = 0;
} elseif ($daysLeft <= 0) {
$state = self::STATE_OVERDUE;
$text = $overdueDays === 0 ? '今日应挂号' : '超期 ' . $overdueDays . ' 天';
} elseif ($daysLeft <= $warnDays) {
$state = self::STATE_WARNING;
$text = '剩 ' . $daysLeft . ' 天';
$severe = 0;
} else {
$state = self::STATE_PENDING;
$text = '剩 ' . $daysLeft . ' 天';
$severe = 0;
}
if ($closed === self::CLOSED_MANUAL && in_array($state, [self::STATE_MISSED, self::STATE_OVERDUE, self::STATE_WARNING, self::STATE_PENDING, self::STATE_NO_DAYS], true)) {
$text = '已关闭·' . $text;
$severe = 0;
}
return [
'state' => $state,
'state_text' => $text,
'days_left' => $daysLeft,
'overdue_days' => $overdueDays,
'severe' => $severe,
];
}
/**
* 延迟次数 → 升级等级:0→0,1~2→1,3~4→2,≥5→3(方案 §1.4)。
*/
public static function escalationLevel(int $snoozeCount): int
{
if ($snoozeCount <= 0) {
return 0;
}
if ($snoozeCount <= 2) {
return 1;
}
if ($snoozeCount <= 4) {
return 2;
}
return 3;
}
/**
* 两个 'Y-m-d' 日期的天数差:to − from(负数表示 to 早于 from);任一非法返回 0。
*/
public static function dayDiff(string $from, string $to): int
{
$a = \DateTimeImmutable::createFromFormat('!Y-m-d', substr(trim($from), 0, 10));
$b = \DateTimeImmutable::createFromFormat('!Y-m-d', substr(trim($to), 0, 10));
if ($a === false || $b === false) {
return 0;
}
$diff = $a->diff($b);
return (int) $diff->days * ($diff->invert === 1 ? -1 : 1);
}
/**
* 'Y-m-d' 加 N 天(N 可为负);非法日期原样返回。
*/
public static function addDays(string $date, int $days): string
{
$d = \DateTimeImmutable::createFromFormat('!Y-m-d', substr(trim($date), 0, 10));
if ($d === false) {
return $date;
}
return $d->modify(sprintf('%+d days', $days))->format('Y-m-d');
}
/**
* 挂号时间归一:空串/NULL → '00:00:00','HH:MM' → 'HH:MM:00'(用于字符串排序)。
*/
private static function normalizeTime(string $time): string
{
$t = trim($time);
if ($t === '' || $t === '0' || $t === '00:00') {
return '00:00:00';
}
if (preg_match('/^\d{1,2}:\d{2}$/', $t) === 1) {
$t .= ':00';
}
if (preg_match('/^(\d{1,2}):(\d{2}):(\d{2})$/', $t, $m) === 1) {
return sprintf('%02d:%02d:%02d', (int) $m[1], (int) $m[2], (int) $m[3]);
}
return $t;
}
/**
* 日期列归一:空串 / '0000-00-00' / null → null,其余截取前 10 位。
*/
private static function nullableDate(mixed $value): ?string
{
if ($value === null) {
return null;
}
$s = substr(trim((string) $value), 0, 10);
if ($s === '' || $s === '0000-00-00') {
return null;
}
return $s;
}
/** 'Y-m-d' → ' MM-DD'(null 返回空串),用于 chip 文案 */
private static function mdSuffix(?string $date): string
{
return $date !== null ? ' ' . substr($date, 5) : '';
}
}