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
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace app\common\command;
use app\common\model\doctor\Appointment;
use app\common\service\tcm\RevisitDueHook;
use think\console\Command;
use think\console\Input;
use think\console\input\Option;
@@ -55,6 +56,9 @@ class UpdateAppointmentStatus extends Command
$missedCount = Db::execute($missedSql, [$now]);
$cancelCount = Db::execute($cancelSql, [$now]);
// 过号/取消落库后重评复诊到期快照中「已约」行(当天过号即进标红/弹窗;钩子内部已 try/catch)
RevisitDueHook::onAppointmentStatusCronDone();
$output->writeln("已过号(1→4): {$missedCount} 条");
$output->writeln("已取消(4→2,过号超8小时): {$cancelCount} 条");
Log::info("update_appointment_status: 已过号 {$missedCount}, 已取消 {$cancelCount}");
@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace app\common\model\tcm;
use app\common\model\BaseModel;
/**
* 诊单复诊到期快照模型(每诊单一行,当前周期 = 最新有效业务订单)
*
* 写入方:RevisitDueService::refreshDiagnoses(cron / 钩子 / 列表自愈)与需求 4 的延迟/已联系/关闭接口。
* 时间相关状态(到期/预警/超期/过号/已约)不落列,由 RevisitDueCalculator::deriveState 按事实列 + today 推导。
*
* @package app\common\model\tcm
*/
class DiagnosisRevisitDue extends BaseModel
{
protected $name = 'tcm_diagnosis_revisit_due';
// 自动时间戳类型设置为整型(表无 delete_time 列)
protected $autoWriteTimestamp = true;
protected $createTime = 'create_time';
protected $updateTime = 'update_time';
protected $deleteTime = false;
// 取出字段保持整型,前端按需要再格式化
protected $dateFormat = false;
/** 周期状态:进行中 */
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;
/** @var array<int,string> */
public const CLOSED_STATE_TEXT_MAP = [
self::CLOSED_OPEN => '进行中',
self::CLOSED_BOOKED => '已挂号',
self::CLOSED_VISITED => '已面诊',
self::CLOSED_MANUAL => '人工关闭',
self::CLOSED_INVALID => '失效',
];
/** 用药起算来源:无 */
public const ANCHOR_NONE = 0;
/** 用药起算来源:物流签收 */
public const ANCHOR_SIGN = 1;
/** 用药起算来源:人工发货日志 */
public const ANCHOR_SHIP = 2;
/** 用药起算来源:下单时间 */
public const ANCHOR_CREATE = 3;
/** @var array<int,string> */
public const ANCHOR_SRC_TEXT_MAP = [
self::ANCHOR_NONE => '',
self::ANCHOR_SIGN => '签收起',
self::ANCHOR_SHIP => '发货起',
self::ANCHOR_CREATE => '下单起',
];
/** 天数来源:缺失(或配置兜底) */
public const DAYS_SRC_NONE = 0;
/** 天数来源:订单 medication_days */
public const DAYS_SRC_ORDER = 1;
/** 天数来源:处方 usage_days */
public const DAYS_SRC_RX = 2;
/** @var array<int,string> */
public const DAYS_SRC_TEXT_MAP = [
self::DAYS_SRC_NONE => '缺开药天数',
self::DAYS_SRC_ORDER => '订单',
self::DAYS_SRC_RX => '处方',
];
/** 锚点订单之后最新有效挂号状态:无 */
public const APPT_NONE = 0;
/** 已约(doctor_appointment.status=1) */
public const APPT_BOOKED = 1;
/** 已面诊(status=3) */
public const APPT_VISITED = 3;
/** 已过号(status=4) */
public const APPT_MISSED = 4;
/** @var array<int,string> */
public const APPOINTMENT_STATE_TEXT_MAP = [
self::APPT_NONE => '未挂号',
self::APPT_BOOKED => '已约',
self::APPT_VISITED => '已面诊',
self::APPT_MISSED => '已过号',
];
/**
* @notes 周期状态文本
*/
public function getClosedStateTextAttr($value, $data): string
{
$v = (int) ($data['closed_state'] ?? 0);
return self::CLOSED_STATE_TEXT_MAP[$v] ?? '未知';
}
/**
* @notes 用药起算来源文本
*/
public function getAnchorSrcTextAttr($value, $data): string
{
$v = (int) ($data['anchor_src'] ?? 0);
return self::ANCHOR_SRC_TEXT_MAP[$v] ?? '';
}
/**
* @notes 开药天数来源文本
*/
public function getDaysSrcTextAttr($value, $data): string
{
$v = (int) ($data['days_src'] ?? 0);
return self::DAYS_SRC_TEXT_MAP[$v] ?? '';
}
/**
* @notes 挂号事实状态文本
*/
public function getAppointmentStateTextAttr($value, $data): string
{
$v = (int) ($data['appointment_state'] ?? 0);
return self::APPOINTMENT_STATE_TEXT_MAP[$v] ?? '未知';
}
/**
* @notes 最近重算时间格式化(Y-m-d H:i)
*/
public function getComputedAtTextAttr($value, $data): string
{
$ts = (int) ($data['computed_at'] ?? 0);
return $ts > 0 ? date('Y-m-d H:i', $ts) : '';
}
/**
* @notes 下次提醒时间格式化(Y-m-d H:i)
*/
public function getNextRemindAtTextAttr($value, $data): string
{
$ts = (int) ($data['next_remind_at'] ?? 0);
return $ts > 0 ? date('Y-m-d H:i', $ts) : '';
}
/**
* @notes 用药起算日(Y-m-d)
*/
public function getAnchorDateAttr($value, $data): string
{
$ts = (int) ($data['anchor_ts'] ?? 0);
return $ts > 0 ? date('Y-m-d', $ts) : '';
}
}
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace app\common\model\tcm;
use app\common\model\BaseModel;
/**
* 诊单复诊到期事件流水模型(审计与「越点越上心」统计)
*
* 注意:弹窗轮询不写流水(只更新 first/last_shown_at),避免日志膨胀。
*
* @package app\common\model\tcm
*/
class DiagnosisRevisitDueLog extends BaseModel
{
protected $name = 'tcm_diagnosis_revisit_due_log';
// 仅有 create_time,整型时间戳
protected $autoWriteTimestamp = true;
protected $createTime = 'create_time';
protected $updateTime = false;
protected $deleteTime = false;
protected $dateFormat = false;
/** 事件:延迟 */
public const EVENT_SNOOZE = 1;
/** 事件:已联系(填承诺日) */
public const EVENT_CONTACT = 2;
/** 事件:人工关闭 */
public const EVENT_MANUAL_CLOSE = 3;
/** 事件:自动关闭-已挂号 */
public const EVENT_AUTO_CLOSE_BOOKED = 4;
/** 事件:自动关闭-已面诊 */
public const EVENT_AUTO_CLOSE_VISITED = 5;
/** 事件:周期重置(新有效订单成为锚点) */
public const EVENT_CYCLE_RESET = 6;
/** 事件:重新打开(挂号取消/过号) */
public const EVENT_REOPEN = 7;
/** 事件:企微推送 */
public const EVENT_WECOM_PUSH = 8;
/** 事件:医助变更(含继承),延迟字段清零 */
public const EVENT_ASSISTANT_CHANGED = 9;
/** 事件:形态升级(escalation_level 变化) */
public const EVENT_ESCALATED = 10;
/** 事件:首次弹出 */
public const EVENT_FIRST_SHOWN = 11;
/** @var array<int,string> */
public const EVENT_TEXT_MAP = [
self::EVENT_SNOOZE => '延迟',
self::EVENT_CONTACT => '已联系',
self::EVENT_MANUAL_CLOSE => '人工关闭',
self::EVENT_AUTO_CLOSE_BOOKED => '自动关闭-已挂号',
self::EVENT_AUTO_CLOSE_VISITED => '自动关闭-已面诊',
self::EVENT_CYCLE_RESET => '周期重置',
self::EVENT_REOPEN => '重新打开',
self::EVENT_WECOM_PUSH => '企微推送',
self::EVENT_ASSISTANT_CHANGED => '医助变更',
self::EVENT_ESCALATED => '形态升级',
self::EVENT_FIRST_SHOWN => '首次弹出',
];
/**
* @notes 事件文本
*/
public function getEventTextAttr($value, $data): string
{
$v = (int) ($data['event'] ?? 0);
return self::EVENT_TEXT_MAP[$v] ?? '未知';
}
/**
* @notes 事件时间格式化(Y-m-d H:i:s)
*/
public function getCreateTimeTextAttr($value, $data): string
{
$ts = (int) ($data['create_time'] ?? 0);
return $ts > 0 ? date('Y-m-d H:i:s', $ts) : '';
}
/**
* @notes payload JSON 解码为数组(解码失败返回空数组)
*
* @return array<string,mixed>
*/
public function getPayloadArrAttr($value, $data): array
{
$raw = (string) ($data['payload'] ?? '');
if ($raw === '') {
return [];
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
}
@@ -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;
}
}