This commit is contained in:
gr
2026-10-15 11:03:54 +08:00
parent 70ddc1a70a
commit bb28d93cec
64 changed files with 12747 additions and 420 deletions
@@ -0,0 +1,462 @@
<?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) : '';
}
}