463 lines
19 KiB
PHP
463 lines
19 KiB
PHP
<?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) : '';
|
||
}
|
||
}
|