更新
This commit is contained in:
@@ -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) : '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\tcm;
|
||||
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 复诊到期快照业务钩子(订单 / 挂号 / 指派 / 诊单变更 / 过号 cron)。
|
||||
*
|
||||
* 所有方法整体 try/catch 并 Log::warning,绝不向调用方抛异常、不改变主流程返回值;
|
||||
* 任一钩子漏接由 30 分钟增量 cron(tcm:revisit-due-scan --mode=incremental)与列表自愈兜底。
|
||||
*
|
||||
* 接入点(由其它代理在业务 Logic 的事务提交之后调用):
|
||||
* - PrescriptionOrderLogic create/editTime/withdraw/complete(9,10)/refund、GancaoCallbackController case 91/92 → onOrderChanged
|
||||
* - AppointmentLogic create/cancel/complete/adminEdit → onAppointmentChanged
|
||||
* - DiagnosisLogic::assign、AutoAssignPendingDiagnosis、AutoAssignLogLogic 回退 → onAssistantChanged
|
||||
* - DiagnosisLogic::setRevisitSlotStartOffset 等诊单字段变更 → onDiagnosisChanged
|
||||
* - UpdateAppointmentStatus 两条 Db::execute 之后 → onAppointmentStatusCronDone
|
||||
*/
|
||||
final class RevisitDueHook
|
||||
{
|
||||
/**
|
||||
* 业务订单变化(新建 / 改下单时间 / 撤回 / 完成为 9,10 / 退款 / 甘草回调置 4)。
|
||||
* diagnosisId 为 0 时按 orderId 反查诊单。
|
||||
*/
|
||||
public static function onOrderChanged(int $diagnosisId, int $orderId = 0, string $reason = ''): void
|
||||
{
|
||||
try {
|
||||
if ($diagnosisId <= 0 && $orderId > 0) {
|
||||
$diagnosisId = (int) Db::name('tcm_prescription_order')->where('id', $orderId)->value('diagnosis_id');
|
||||
}
|
||||
if ($diagnosisId <= 0) {
|
||||
return;
|
||||
}
|
||||
RevisitDueService::refreshDiagnoses([$diagnosisId], [
|
||||
'reason' => self::reasonTag('order', $reason),
|
||||
'operator_id' => self::currentAdminId(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning(sprintf('复诊到期钩子 onOrderChanged 失败 diagnosis_id=%d order_id=%d: %s', $diagnosisId, $orderId, $e->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 挂号变化(创建 / 取消 / 完成 / 后台编辑)。diagnosisId 为 0 时按 appointmentId 反查(patient_id 存诊单 id)。
|
||||
*/
|
||||
public static function onAppointmentChanged(int $diagnosisId, int $appointmentId = 0, string $reason = ''): void
|
||||
{
|
||||
try {
|
||||
if ($diagnosisId <= 0 && $appointmentId > 0) {
|
||||
$diagnosisId = (int) Db::name('doctor_appointment')->where('id', $appointmentId)->value('patient_id');
|
||||
}
|
||||
if ($diagnosisId <= 0) {
|
||||
return;
|
||||
}
|
||||
RevisitDueService::refreshDiagnoses([$diagnosisId], [
|
||||
'reason' => self::reasonTag('appointment', $reason),
|
||||
'operator_id' => self::currentAdminId(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning(sprintf('复诊到期钩子 onAppointmentChanged 失败 diagnosis_id=%d appointment_id=%d: %s', $diagnosisId, $appointmentId, $e->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 医助变更(指派 / 继承 / 释放 to=0 / 自动分配 / 回退)。toAssistantId 仅用于流水 payload,实际以库内 tcm_diagnosis.assistant_id 为准。
|
||||
*/
|
||||
public static function onAssistantChanged(int $diagnosisId, int $toAssistantId, string $reason = ''): void
|
||||
{
|
||||
try {
|
||||
if ($diagnosisId <= 0) {
|
||||
return;
|
||||
}
|
||||
$tag = self::reasonTag('assign', $reason);
|
||||
if ($toAssistantId >= 0) {
|
||||
$tag .= ':to=' . $toAssistantId;
|
||||
}
|
||||
RevisitDueService::refreshDiagnoses([$diagnosisId], [
|
||||
'reason' => $tag,
|
||||
'operator_id' => self::currentAdminId(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning(sprintf('复诊到期钩子 onAssistantChanged 失败 diagnosis_id=%d to=%d: %s', $diagnosisId, $toAssistantId, $e->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊单其它字段变更(如 revisit_slot_start_offset、status 禁用/启用、软删)。
|
||||
*/
|
||||
public static function onDiagnosisChanged(int $diagnosisId, string $reason = ''): void
|
||||
{
|
||||
try {
|
||||
if ($diagnosisId <= 0) {
|
||||
return;
|
||||
}
|
||||
RevisitDueService::refreshDiagnoses([$diagnosisId], [
|
||||
'reason' => self::reasonTag('diagnosis', $reason),
|
||||
'operator_id' => self::currentAdminId(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning(sprintf('复诊到期钩子 onDiagnosisChanged 失败 diagnosis_id=%d: %s', $diagnosisId, $e->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 过号 cron(update_appointment_status)执行之后:重评 closed_state=2 / appointment_state=1 的行,使当天过号即进标红/弹窗。
|
||||
*/
|
||||
public static function onAppointmentStatusCronDone(): void
|
||||
{
|
||||
try {
|
||||
RevisitDueService::reevaluateBookedRows();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('复诊到期钩子 onAppointmentStatusCronDone 失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 流水 payload 的 reason 标签:<来源>[:<说明>],限 64 字符 */
|
||||
private static function reasonTag(string $source, string $reason): string
|
||||
{
|
||||
$reason = trim($reason);
|
||||
|
||||
return mb_substr($reason !== '' ? $source . ':' . $reason : $source, 0, 64);
|
||||
}
|
||||
|
||||
/** 当前登录管理员 id(CLI / 回调无登录态时为 0),任何异常均视为 0 */
|
||||
private static function currentAdminId(): int
|
||||
{
|
||||
try {
|
||||
$req = request();
|
||||
$admin = $req->adminInfo ?? null;
|
||||
if (is_array($admin)) {
|
||||
return (int) ($admin['admin_id'] ?? 0);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// CLI 场景无请求上下文,忽略
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,278 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\tcm;
|
||||
|
||||
use app\adminapi\logic\stats\YejiStatsLogic;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 诊次(第几诊)口径单点实现,复诊接诊率 / 诊单列表「复诊节点」/ 到期快照共用。
|
||||
*
|
||||
* 口径(与 RevisitRateLogic 完全一致):
|
||||
* - 有效业务订单 E(D):tcm_prescription_order 中 delete_time IS NULL 且 fulfillment_status IS NULL OR NOT IN (4,9,10)
|
||||
* (统一经 YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery),按 (create_time, id) 升序;
|
||||
* - 偏移 off(D) = tcm_diagnosis.revisit_slot_start_offset 钳到 0~20;
|
||||
* - 第 n 笔有效订单的诊次 = n + off(D);患者当前已完成诊次 current_slot = |E(D)| + off(D);待复诊诊次 next_slot = current_slot + 1;
|
||||
* - 某时刻 t 患者处于第几诊 slot_at(D, t) = |{o : create_time(o) < t}| + off(D)(严格小于,与持有人规则 t <= ct 对偶)。
|
||||
*
|
||||
* MySQL 5.7 无窗口函数:batchCurrentSlot 先 GROUP BY 聚合,再按 (diagnosis_id, create_time) 回表,PHP 侧取 id 最大的一行。
|
||||
*/
|
||||
final class RevisitVisitSlotService
|
||||
{
|
||||
/** 偏移量上限(与 RevisitRateLogic 历史实现一致) */
|
||||
public const MAX_OFFSET = 20;
|
||||
|
||||
/** 单次 SQL 的诊单分块大小 */
|
||||
private const CHUNK_SIZE = 2000;
|
||||
|
||||
/**
|
||||
* 诊单订单序列源查询(与业绩口径一致),统一排序保证序号稳定。
|
||||
* 从 RevisitRateLogic::fetchOrderSeqRows 搬出,行为不变(调用方自行分块)。
|
||||
*
|
||||
* @param list<int> $diagnosisIds
|
||||
* @param list<string> $fields 需带 `o.` 别名前缀,如 ['o.id', 'o.diagnosis_id', 'o.create_time']
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public static function fetchOrderSeqRows(array $diagnosisIds, array $fields): array
|
||||
{
|
||||
if ($diagnosisIds === []) {
|
||||
return [];
|
||||
}
|
||||
$q = Db::name('tcm_prescription_order')
|
||||
->alias('o')
|
||||
->whereIn('o.diagnosis_id', $diagnosisIds)
|
||||
->whereNull('o.delete_time');
|
||||
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, 'o');
|
||||
|
||||
return $q
|
||||
->field($fields)
|
||||
->order(['o.diagnosis_id' => 'asc', 'o.create_time' => 'asc', 'o.id' => 'asc'])
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊单 => revisit_slot_start_offset(未钳制的原始值;已软删诊单不返回)。
|
||||
* 从 RevisitRateLogic::fetchRevisitSlotStartOffsetMap 搬出,行为不变。
|
||||
*
|
||||
* @param list<int> $diagnosisIds
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
public static function fetchRevisitSlotStartOffsetMap(array $diagnosisIds): array
|
||||
{
|
||||
if ($diagnosisIds === []) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach (array_chunk($diagnosisIds, self::CHUNK_SIZE) as $chunk) {
|
||||
$rows = Db::name('tcm_diagnosis')
|
||||
->whereIn('id', $chunk)
|
||||
->whereNull('delete_time')
|
||||
->column('revisit_slot_start_offset', 'id');
|
||||
foreach ($rows as $id => $offset) {
|
||||
$out[(int) $id] = (int) $offset;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊单复诊统计起始偏移(默认 0:第 1 笔实单计为一诊;统计诊次 = 实单序号 + 偏移),钳到 0~20。
|
||||
* 从 RevisitRateLogic::resolveRevisitSlotStartOffset 搬出,行为不变。
|
||||
*
|
||||
* @param array<int, int> $map diagnosis_id => offset
|
||||
*/
|
||||
public static function resolveRevisitSlotStartOffset(int $diagnosisId, array $map): int
|
||||
{
|
||||
$offset = (int) ($map[$diagnosisId] ?? 0);
|
||||
if ($offset < 0) {
|
||||
$offset = 0;
|
||||
}
|
||||
if ($offset > self::MAX_OFFSET) {
|
||||
$offset = self::MAX_OFFSET;
|
||||
}
|
||||
|
||||
return $offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯函数:时刻 atTs 之前(严格小于)的有效订单数 + 偏移 = 该时刻患者已完成的诊次。
|
||||
* 同一秒的订单视为「指派之后」,不计入(与持有人规则 t <= ct 对偶)。
|
||||
*
|
||||
* @param list<int|string> $orderCreateTimes 有效订单的 create_time 列表(顺序不限)
|
||||
*/
|
||||
public static function slotBefore(array $orderCreateTimes, int $offset, int $atTs): int
|
||||
{
|
||||
$n = 0;
|
||||
foreach ($orderCreateTimes as $ct) {
|
||||
if ((int) $ct < $atTs) {
|
||||
$n++;
|
||||
}
|
||||
}
|
||||
if ($offset < 0) {
|
||||
$offset = 0;
|
||||
}
|
||||
if ($offset > self::MAX_OFFSET) {
|
||||
$offset = self::MAX_OFFSET;
|
||||
}
|
||||
|
||||
return $n + $offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* 某时刻患者处于第几诊(含偏移)。诊单不存在或无订单时返回偏移值。
|
||||
*/
|
||||
public static function slotAtTime(int $diagnosisId, int $ts): int
|
||||
{
|
||||
if ($diagnosisId <= 0) {
|
||||
return 0;
|
||||
}
|
||||
$offsetMap = self::fetchRevisitSlotStartOffsetMap([$diagnosisId]);
|
||||
$offset = self::resolveRevisitSlotStartOffset($diagnosisId, $offsetMap);
|
||||
$rows = self::fetchOrderSeqRows([$diagnosisId], ['o.create_time']);
|
||||
$cts = [];
|
||||
foreach ($rows as $r) {
|
||||
$cts[] = (int) ($r['create_time'] ?? 0);
|
||||
}
|
||||
|
||||
return self::slotBefore($cts, $offset, $ts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量计算每个诊单的当前诊次与最新有效订单(每个入参诊单都有返回,无订单时 latest_order 为 null)。
|
||||
*
|
||||
* @param list<int> $diagnosisIds
|
||||
*
|
||||
* @return array<int, array{
|
||||
* diagnosis_id:int, offset:int, valid_order_count:int, current_slot:int, next_slot:int,
|
||||
* first_order_time:int, total_medication_days:int,
|
||||
* latest_order:null|array{id:int, order_no:string, create_time:int, medication_days:int, prescription_id:int, tracking_number:string, fulfillment_status:int|null, creator_id:int}
|
||||
* }>
|
||||
*/
|
||||
public static function batchCurrentSlot(array $diagnosisIds): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($diagnosisIds as $id) {
|
||||
$id = (int) $id;
|
||||
if ($id > 0) {
|
||||
$ids[$id] = true;
|
||||
}
|
||||
}
|
||||
$ids = array_keys($ids);
|
||||
if ($ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$offsetMap = self::fetchRevisitSlotStartOffsetMap($ids);
|
||||
|
||||
/** @var array<int, array{cnt:int, max_ct:int, min_ct:int, sum_days:int}> $agg */
|
||||
$agg = [];
|
||||
/** @var array<int, array<string, mixed>> $latest diagnosis_id => 最新有效订单行 */
|
||||
$latest = [];
|
||||
|
||||
foreach (array_chunk($ids, self::CHUNK_SIZE) as $chunk) {
|
||||
// 1. 聚合:有效订单数 / 最新与最早下单时间 / 累计开药天数
|
||||
// 累计开药天数按「订单 medication_days ?: 处方 usage_days」逐单回落(处方剔除系统代开/作废/软删,一单对一方,不会放大行数)
|
||||
$q = Db::name('tcm_prescription_order')
|
||||
->alias('o')
|
||||
->leftJoin('tcm_prescription rx', 'rx.id = o.prescription_id AND rx.is_system_auto = 0 AND rx.void_status = 0 AND rx.delete_time IS NULL')
|
||||
->whereIn('o.diagnosis_id', $chunk)
|
||||
->whereNull('o.delete_time');
|
||||
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, 'o');
|
||||
$aggRows = $q
|
||||
->fieldRaw('o.diagnosis_id, COUNT(*) AS cnt, MAX(o.create_time) AS max_ct, MIN(o.create_time) AS min_ct, SUM(COALESCE(NULLIF(o.medication_days, 0), NULLIF(rx.usage_days, 0), 0)) AS sum_days')
|
||||
->group('o.diagnosis_id')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$maxCts = [];
|
||||
$chunkDiagWithOrders = [];
|
||||
foreach ($aggRows as $r) {
|
||||
$did = (int) ($r['diagnosis_id'] ?? 0);
|
||||
if ($did <= 0) {
|
||||
continue;
|
||||
}
|
||||
$maxCt = (int) ($r['max_ct'] ?? 0);
|
||||
$agg[$did] = [
|
||||
'cnt' => (int) ($r['cnt'] ?? 0),
|
||||
'max_ct' => $maxCt,
|
||||
'min_ct' => (int) ($r['min_ct'] ?? 0),
|
||||
'sum_days' => (int) ($r['sum_days'] ?? 0),
|
||||
];
|
||||
$maxCts[$maxCt] = true;
|
||||
$chunkDiagWithOrders[] = $did;
|
||||
}
|
||||
if ($chunkDiagWithOrders === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. 回表:按 (diagnosis_id, create_time) 取候选行,PHP 侧过滤精确匹配并取 id 最大者
|
||||
$q2 = Db::name('tcm_prescription_order')
|
||||
->alias('o')
|
||||
->whereIn('o.diagnosis_id', $chunkDiagWithOrders)
|
||||
->whereIn('o.create_time', array_keys($maxCts))
|
||||
->whereNull('o.delete_time');
|
||||
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q2, 'o');
|
||||
$candRows = $q2
|
||||
->field([
|
||||
'o.id', 'o.order_no', 'o.diagnosis_id', 'o.create_time', 'o.medication_days',
|
||||
'o.prescription_id', 'o.tracking_number', 'o.fulfillment_status', 'o.creator_id',
|
||||
])
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($candRows as $r) {
|
||||
$did = (int) ($r['diagnosis_id'] ?? 0);
|
||||
if ($did <= 0 || !isset($agg[$did])) {
|
||||
continue;
|
||||
}
|
||||
if ((int) ($r['create_time'] ?? 0) !== $agg[$did]['max_ct']) {
|
||||
continue;
|
||||
}
|
||||
$oid = (int) ($r['id'] ?? 0);
|
||||
if (!isset($latest[$did]) || $oid > (int) ($latest[$did]['id'] ?? 0)) {
|
||||
$latest[$did] = $r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($ids as $did) {
|
||||
$offset = self::resolveRevisitSlotStartOffset($did, $offsetMap);
|
||||
$a = $agg[$did] ?? null;
|
||||
$cnt = $a !== null ? $a['cnt'] : 0;
|
||||
$currentSlot = $cnt + $offset;
|
||||
$lo = $latest[$did] ?? null;
|
||||
$latestOrder = null;
|
||||
if ($lo !== null) {
|
||||
$fs = $lo['fulfillment_status'] ?? null;
|
||||
$latestOrder = [
|
||||
'id' => (int) ($lo['id'] ?? 0),
|
||||
'order_no' => (string) ($lo['order_no'] ?? ''),
|
||||
'create_time' => (int) ($lo['create_time'] ?? 0),
|
||||
'medication_days' => (int) ($lo['medication_days'] ?? 0),
|
||||
'prescription_id' => (int) ($lo['prescription_id'] ?? 0),
|
||||
'tracking_number' => trim((string) ($lo['tracking_number'] ?? '')),
|
||||
'fulfillment_status' => $fs === null ? null : (int) $fs,
|
||||
'creator_id' => (int) ($lo['creator_id'] ?? 0),
|
||||
];
|
||||
}
|
||||
$out[$did] = [
|
||||
'diagnosis_id' => $did,
|
||||
'offset' => $offset,
|
||||
'valid_order_count' => $cnt,
|
||||
'current_slot' => $currentSlot,
|
||||
'next_slot' => $currentSlot + 1,
|
||||
'first_order_time' => $a !== null ? $a['min_ct'] : 0,
|
||||
'total_medication_days' => $a !== null ? $a['sum_days'] : 0,
|
||||
'latest_order' => $latestOrder,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user