Files
zyt/server/app/adminapi/logic/tcm/RevisitDueLogic.php
T
2026-10-15 11:03:54 +08:00

1184 lines
45 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\adminapi\logic\tcm;
use app\adminapi\logic\dept\DeptLogic;
use app\common\logic\BaseLogic;
use app\common\model\auth\Admin;
use app\common\model\auth\AdminRole;
use app\common\model\tcm\DiagnosisRevisitDueLog;
use app\common\service\DataScope\DataScopeService;
use app\common\service\tcm\RevisitDueCalculator;
use app\common\service\tcm\RevisitDueService;
use think\facade\Db;
use think\facade\Log;
/**
* 复诊到期弹窗与延迟处理逻辑(需求 4「越点越上心」,方案 §1.4 / §3.3)。
*
* 读接口:
* - pending():本人到期未挂号 / 过号且 next_remind_at 已到的行(≤ toast_limit),返回前做轻量实时校验,
* 并更新 first_shown_at / last_shown_at(首次弹出写 event=11);不做服务端节流,每次返回当前全集;
* - summary():仅计数(到期总数 / 最大超期天数 / 预警数),不更新 shown。
* 写接口(均在本类内校验归属:assistant_id === adminId 或超管,不依赖路由层):
* - snooze():条件 UPDATE 延迟一次,next_remind_at = now + snooze_intervals[n] 钳入 work_hours;
* - snoozeAll():仅 escalation_level<=1 的项逐项 snooze;
* - contact():记录患者承诺复诊日,前 contact_max_per_cycle 次不计延迟,之后按一次延迟计;
* - close():人工关闭(closed_state=4),主管视图标记。
* 主管视图(P2):
* - logs():某条提醒的事件流水,归属校验 = 本人 / 超管 / DataScope 可见医助(与 RevisitDueLists 的 applyDataScopeByOwner 同口径);
* 列表本身由 lists/tcm/RevisitDueLists 实现。
*
* 纯函数(snoozeInterval / clampToWorkHours / nextRemindAfterSnooze / promisedRemindAt / isPromisedDateInRange /
* remindTimeText / toastEligible)不连库、不读 config,供 server/tests/RevisitDueLogicTest.php 直接单测。
*
* 并发:File 缓存无锁,互斥全靠条件 UPDATE(closed_state=0 AND next_remind_at<=now AND snooze_count=旧值),
* affected=0 时返回当前行以保证幂等;cron 不改延迟字段,本类不改事实列,列级隔离。
*/
class RevisitDueLogic extends BaseLogic
{
/** 超出档位且配置缺 default 时的延迟秒数(30 分钟) */
public const DEFAULT_SNOOZE_SECONDS = 1800;
/** 工作时段缺省 */
public const DEFAULT_WORK_HOURS = ['09:00', '20:00'];
/** 人工关闭原因 */
public const CLOSE_REASONS = ['lost', 'other'];
/** 人工关闭原因文案 */
public const CLOSE_REASON_TEXT = ['lost' => '患者流失', 'other' => '其它'];
/** 进弹窗的展示状态 */
private const TOAST_STATES = [RevisitDueCalculator::STATE_OVERDUE, RevisitDueCalculator::STATE_MISSED];
// ---------------------------------------------------------------- 纯函数
/**
* 工作时段 ['09:00', '20:00'] → [开始分钟, 结束分钟](以 0 点起算)。
* 非法 / 缺省 / 开始不早于结束 时回落 DEFAULT_WORK_HOURS;结束时间允许 24:00。
*
* @return array{0:int, 1:int}
*/
public static function parseWorkHours(mixed $workHours): array
{
$default = [9 * 60, 20 * 60];
if (!is_array($workHours) || count($workHours) < 2) {
return $default;
}
$vals = array_values($workHours);
$start = self::parseHm((string) $vals[0]);
$end = self::parseHm((string) $vals[1]);
if ($start === null || $end === null || $start >= $end) {
return $default;
}
return [$start, $end];
}
/**
* 第 n 次延迟的间隔秒数:snooze_intervals[n] ?? snooze_intervals['default'] ?? 1800。
*/
public static function snoozeInterval(int $n, mixed $intervals): int
{
if (!is_array($intervals)) {
return self::DEFAULT_SNOOZE_SECONDS;
}
$v = null;
if ($n > 0 && array_key_exists($n, $intervals)) {
$v = $intervals[$n];
} elseif (array_key_exists('default', $intervals)) {
$v = $intervals['default'];
}
$v = (int) $v;
return $v > 0 ? $v : self::DEFAULT_SNOOZE_SECONDS;
}
/**
* 把时间戳钳入工作时段:早于开始时间 → 当日开始时间;晚于结束时间 → 次日开始时间;其余原样返回。
* 以 PHP 默认时区计算日界。
*/
public static function clampToWorkHours(int $ts, mixed $workHours): int
{
[$startMin, $endMin] = self::parseWorkHours($workHours);
$tz = new \DateTimeZone(date_default_timezone_get());
$d = (new \DateTimeImmutable('@' . $ts))->setTimezone($tz);
$secOfDay = (int) $d->format('G') * 3600 + (int) $d->format('i') * 60 + (int) $d->format('s');
$startH = intdiv($startMin, 60);
$startM = $startMin % 60;
if ($secOfDay < $startMin * 60) {
return $d->setTime($startH, $startM, 0)->getTimestamp();
}
if ($secOfDay > $endMin * 60) {
return $d->modify('+1 day')->setTime($startH, $startM, 0)->getTimestamp();
}
return $ts;
}
/**
* 第 n 次延迟后的下次提醒时刻 = now + 间隔,钳入工作时段。
*
* @param array<string, mixed> $cfg revisit_reach 配置(读 snooze_intervals / work_hours)
*/
public static function nextRemindAfterSnooze(int $now, int $n, array $cfg): int
{
$interval = self::snoozeInterval($n, $cfg['snooze_intervals'] ?? null);
return self::clampToWorkHours($now + $interval, $cfg['work_hours'] ?? self::DEFAULT_WORK_HOURS);
}
/**
* 承诺复诊日 → 当日工作时段开始时刻(如 09:00)的时间戳;日期非法返回 0。
*/
public static function promisedRemindAt(string $promisedDate, mixed $workHours): int
{
$d = \DateTimeImmutable::createFromFormat('!Y-m-d', substr(trim($promisedDate), 0, 10), new \DateTimeZone(date_default_timezone_get()));
if ($d === false || $d->format('Y-m-d') !== substr(trim($promisedDate), 0, 10)) {
return 0;
}
[$startMin] = self::parseWorkHours($workHours);
return $d->setTime(intdiv($startMin, 60), $startMin % 60, 0)->getTimestamp();
}
/**
* 承诺日是否落在 [today, today + maxDays]。
*/
public static function isPromisedDateInRange(string $promisedDate, string $today, int $maxDays): bool
{
$date = substr(trim($promisedDate), 0, 10);
$d = \DateTimeImmutable::createFromFormat('!Y-m-d', $date);
if ($d === false || $d->format('Y-m-d') !== $date) {
return false;
}
$diff = RevisitDueCalculator::dayDiff($today, $date);
return $diff >= 0 && $diff <= max(0, $maxDays);
}
/**
* 提醒时刻文案:当日 'HH:mm',次日 '明日 HH:mm',更远 'MM-DD HH:mm'。
*/
public static function remindTimeText(int $ts, int $now): string
{
if ($ts <= 0) {
return '';
}
$day = date('Y-m-d', $ts);
$today = date('Y-m-d', $now);
if ($day === $today) {
return date('H:i', $ts);
}
if ($day === RevisitDueCalculator::addDays($today, 1)) {
return '明日 ' . date('H:i', $ts);
}
return date('m-d H:i', $ts);
}
/**
* 快照行是否满足弹窗 SQL 口径(closed_state=0 且 (过号 或 到期) 且 承诺日未生效);与 pending() 的 WHERE 同义,供重读后复核。
*
* @param array<string, mixed> $row
*/
public static function toastEligible(array $row, string $today): bool
{
if ((int) ($row['closed_state'] ?? -1) !== RevisitDueCalculator::CLOSED_OPEN) {
return false;
}
$due = self::dateStr($row['due_date'] ?? null);
$missed = (int) ($row['appointment_state'] ?? 0) === RevisitDueCalculator::APPT_MISSED;
if (!$missed && ($due === '' || $due > $today)) {
return false;
}
$promised = self::dateStr($row['promised_date'] ?? null);
return $promised === '' || $promised < $today;
}
// ---------------------------------------------------------------- 读接口
/**
* 弹窗待办:本人到期未挂号 / 过号且已到提醒时刻的行,附汇总计数。
*
* @param array<string, mixed> $adminInfo
*
* @return array{items: list<array<string, mixed>>, summary: array<string, mixed>}
*/
public static function pending(int $adminId, array $adminInfo): array
{
$cfg = RevisitDueService::config();
$now = time();
$today = date('Y-m-d', $now);
$empty = ['items' => [], 'summary' => self::emptySummary($cfg)];
if (!self::toastEnabled($cfg) || $adminId <= 0) {
return $empty;
}
if (!self::assistantInScope($adminId, $adminInfo, $cfg)) {
return $empty;
}
$limit = max(1, (int) ($cfg['toast_limit'] ?? 20));
$q = Db::name(RevisitDueService::TABLE);
self::applyDueWhere($q, $adminId);
// 升级等级高者在前;同级按超期最久(due_date 最早)在前,缺预计复诊日的过号行放到同级末尾
$rows = $q->where('next_remind_at', '<=', $now)
->orderRaw('escalation_level DESC, due_date IS NULL ASC, due_date ASC, id ASC')
->limit($limit)
->select()
->toArray();
$items = [];
if ($rows !== []) {
$rows = self::liveCheck($rows, $adminId, $today, $now, $cfg);
foreach ($rows as $row) {
$items[] = self::formatItem($row, $today, $cfg, $now);
}
self::markShown($rows, $adminId, $today, $now);
}
return ['items' => $items, 'summary' => self::buildSummary($adminId, $cfg)];
}
/**
* 仅计数(右下角小气泡 / 顶栏用),不更新 shown。
*
* @param array<string, mixed> $adminInfo
*
* @return array<string, mixed>
*/
public static function summary(int $adminId, array $adminInfo): array
{
$cfg = RevisitDueService::config();
if (!self::toastEnabled($cfg) || $adminId <= 0 || !self::assistantInScope($adminId, $adminInfo, $cfg)) {
return self::emptySummary($cfg);
}
return self::buildSummary($adminId, $cfg);
}
// ---------------------------------------------------------------- 写接口
/**
* 延迟一次。affected=0(被他人处理 / 已关闭 / 未到提醒时刻)也返回当前行,幂等。
*
* @param array<string, mixed> $adminInfo
*
* @return array{item: array<string, mixed>, changed: bool, message: string}|false
*/
public static function snooze(int $id, int $adminId, array $adminInfo): array|false
{
$cfg = RevisitDueService::config();
$now = time();
$today = date('Y-m-d', $now);
$row = self::loadRowForWrite($id, $adminId, $adminInfo);
if ($row === null) {
return false;
}
if ((int) $row['closed_state'] !== RevisitDueCalculator::CLOSED_OPEN) {
return ['item' => self::formatItem($row, $today, $cfg, $now), 'changed' => false, 'message' => '该提醒已关闭,无需延迟'];
}
$oldCount = (int) $row['snooze_count'];
$oldLevel = (int) $row['escalation_level'];
$n = $oldCount + 1;
$level = RevisitDueCalculator::escalationLevel($n);
$next = self::nextRemindAfterSnooze($now, $n, $cfg);
try {
$affected = Db::name(RevisitDueService::TABLE)
->where('id', $id)
->where('assistant_id', (int) $row['assistant_id'])
->where('closed_state', RevisitDueCalculator::CLOSED_OPEN)
->where('next_remind_at', '<=', $now)
->where('snooze_count', $oldCount)
->update([
'snooze_count' => min(65535, $n),
'escalation_level' => $level,
'next_remind_at' => $next,
'last_snoozed_at' => $now,
'last_snoozed_by' => $adminId,
'update_time' => $now,
]);
} catch (\Throwable $e) {
self::setError('延迟失败:' . $e->getMessage());
return false;
}
if ((int) $affected > 0) {
$overdue = self::overdueDaysForLog($row, $today);
$logBase = [
'snooze_count_after' => $n,
'level_after' => $level,
'overdue_days' => $overdue,
'operator_id' => $adminId,
'create_time' => $now,
];
RevisitDueService::writeLog(
$id,
(int) $row['diagnosis_id'],
(int) $row['assistant_id'],
(int) $row['source_order_id'],
DiagnosisRevisitDueLog::EVENT_SNOOZE,
$logBase + ['payload' => [
'next_remind_at' => $next,
'interval' => self::snoozeInterval($n, $cfg['snooze_intervals'] ?? null),
'source' => 'toast',
]]
);
if ($level !== $oldLevel) {
RevisitDueService::writeLog(
$id,
(int) $row['diagnosis_id'],
(int) $row['assistant_id'],
(int) $row['source_order_id'],
DiagnosisRevisitDueLog::EVENT_ESCALATED,
$logBase + ['payload' => ['from_level' => $oldLevel, 'to_level' => $level, 'trigger' => 'snooze']]
);
}
$fresh = self::fetchRow($id) ?? $row;
return [
'item' => self::formatItem($fresh, $today, $cfg, $now),
'changed' => true,
'message' => sprintf('已延迟,%s 再提醒(第 %d 次)', self::remindTimeText($next, $now), $n),
];
}
// 幂等:被其它标签页 / 他人处理过,按当前行返回
$fresh = self::fetchRow($id) ?? $row;
$item = self::formatItem($fresh, $today, $cfg, $now);
if ((int) $fresh['closed_state'] !== RevisitDueCalculator::CLOSED_OPEN) {
$message = '该提醒已关闭,无需延迟';
} elseif ((int) $fresh['next_remind_at'] > $now) {
$message = sprintf('已延迟,%s 再提醒(第 %d 次)', self::remindTimeText((int) $fresh['next_remind_at'], $now), (int) $fresh['snooze_count']);
} else {
$message = '提醒状态已更新';
}
return ['item' => $item, 'changed' => false, 'message' => $message];
}
/**
* 批量延迟:仅 escalation_level<=1 的本人进行中行,逐项同 snooze。
*
* @param list<int|string> $ids
* @param array<string, mixed> $adminInfo
*
* @return array{items: list<array<string, mixed>>, snoozed: int, skipped: int, message: string}
*/
public static function snoozeAll(array $ids, int $adminId, array $adminInfo): array
{
$cfg = RevisitDueService::config();
$limit = max(1, (int) ($cfg['toast_limit'] ?? 20));
$normIds = [];
foreach ($ids as $v) {
$v = (int) $v;
if ($v > 0 && !in_array($v, $normIds, true)) {
$normIds[] = $v;
}
}
$normIds = array_slice($normIds, 0, $limit * 2);
$result = ['items' => [], 'snoozed' => 0, 'skipped' => 0, 'message' => ''];
if ($normIds === []) {
$result['message'] = '没有可延迟的提醒';
return $result;
}
$rows = Db::name(RevisitDueService::TABLE)->whereIn('id', $normIds)->select()->toArray();
$byId = [];
foreach ($rows as $r) {
$byId[(int) $r['id']] = $r;
}
$isSuper = self::isSuperAdmin($adminId, $adminInfo);
foreach ($normIds as $id) {
$row = $byId[$id] ?? null;
if ($row === null
|| !self::canOperate($row, $adminId, $isSuper)
|| (int) $row['closed_state'] !== RevisitDueCalculator::CLOSED_OPEN
|| (int) $row['escalation_level'] > 1) {
$result['skipped']++;
continue;
}
$one = self::snooze($id, $adminId, $adminInfo);
if ($one === false) {
$result['skipped']++;
continue;
}
$result['items'][] = $one['item'];
if ($one['changed']) {
$result['snoozed']++;
} else {
$result['skipped']++;
}
}
$result['message'] = $result['snoozed'] > 0
? sprintf('已延迟 %d 位患者的提醒', $result['snoozed'])
: '没有可延迟的提醒';
return $result;
}
/**
* 已联系:记录承诺复诊日,next_remind_at = 承诺日工作时段开始;前 contact_max_per_cycle 次不计延迟。
*
* @param array<string, mixed> $adminInfo
*
* @return array{item: array<string, mixed>, changed: bool, message: string}|false
*/
public static function contact(int $id, string $promisedDate, string $remark, int $adminId, array $adminInfo): array|false
{
$cfg = RevisitDueService::config();
$now = time();
$today = date('Y-m-d', $now);
$promisedDate = substr(trim($promisedDate), 0, 10);
$remark = mb_substr(trim($remark), 0, 200);
$maxDays = max(0, (int) ($cfg['promise_max_days'] ?? 14));
if (!self::isPromisedDateInRange($promisedDate, $today, $maxDays)) {
self::setError(sprintf('承诺复诊日需在今天至 %d 天内', $maxDays));
return false;
}
$row = self::loadRowForWrite($id, $adminId, $adminInfo);
if ($row === null) {
return false;
}
if ((int) $row['closed_state'] !== RevisitDueCalculator::CLOSED_OPEN) {
self::setError('该提醒已关闭或已失效');
return false;
}
$oldContact = (int) $row['contact_count'];
$oldCount = (int) $row['snooze_count'];
$oldLevel = (int) $row['escalation_level'];
$maxContact = max(0, (int) ($cfg['contact_max_per_cycle'] ?? 2));
$countedAsSnooze = $oldContact >= $maxContact;
$n = $countedAsSnooze ? $oldCount + 1 : $oldCount;
$level = $countedAsSnooze ? RevisitDueCalculator::escalationLevel($n) : $oldLevel;
$next = self::promisedRemindAt($promisedDate, $cfg['work_hours'] ?? self::DEFAULT_WORK_HOURS);
$data = [
'promised_date' => $promisedDate,
'contact_count' => min(255, $oldContact + 1),
'contacted_at' => $now,
'next_remind_at' => $next,
'update_time' => $now,
];
if ($countedAsSnooze) {
$data['snooze_count'] = min(65535, $n);
$data['escalation_level'] = $level;
$data['last_snoozed_at'] = $now;
$data['last_snoozed_by'] = $adminId;
}
try {
$affected = Db::name(RevisitDueService::TABLE)
->where('id', $id)
->where('assistant_id', (int) $row['assistant_id'])
->where('closed_state', RevisitDueCalculator::CLOSED_OPEN)
->where('contact_count', $oldContact)
->update($data);
} catch (\Throwable $e) {
self::setError('记录失败:' . $e->getMessage());
return false;
}
if ((int) $affected <= 0) {
$fresh = self::fetchRow($id) ?? $row;
return ['item' => self::formatItem($fresh, $today, $cfg, $now), 'changed' => false, 'message' => '提醒状态已更新'];
}
$logBase = [
'snooze_count_after' => $n,
'level_after' => $level,
'overdue_days' => self::overdueDaysForLog($row, $today),
'operator_id' => $adminId,
'create_time' => $now,
];
RevisitDueService::writeLog(
$id,
(int) $row['diagnosis_id'],
(int) $row['assistant_id'],
(int) $row['source_order_id'],
DiagnosisRevisitDueLog::EVENT_CONTACT,
$logBase + ['payload' => [
'promised_date' => $promisedDate,
'remark' => $remark,
'next_remind_at' => $next,
'contact_count' => $oldContact + 1,
'counted_as_snooze' => $countedAsSnooze ? 1 : 0,
]]
);
if ($countedAsSnooze && $level !== $oldLevel) {
RevisitDueService::writeLog(
$id,
(int) $row['diagnosis_id'],
(int) $row['assistant_id'],
(int) $row['source_order_id'],
DiagnosisRevisitDueLog::EVENT_ESCALATED,
$logBase + ['payload' => ['from_level' => $oldLevel, 'to_level' => $level, 'trigger' => 'contact']]
);
}
$fresh = self::fetchRow($id) ?? $row;
$message = sprintf('已记录联系,%s 再提醒', self::remindTimeText($next, $now));
if ($countedAsSnooze) {
$message .= sprintf('(本周期已联系超过 %d 次,按一次延迟计)', $maxContact);
}
return ['item' => self::formatItem($fresh, $today, $cfg, $now), 'changed' => true, 'message' => $message];
}
/**
* 人工关闭:closed_state=4,主管视图标记;新周期由 RevisitDueService 自动重开。
*
* @param array<string, mixed> $adminInfo
*
* @return array{item: array<string, mixed>, changed: bool, message: string}|false
*/
public static function close(int $id, string $reason, string $remark, int $adminId, array $adminInfo): array|false
{
$cfg = RevisitDueService::config();
$now = time();
$today = date('Y-m-d', $now);
$reason = strtolower(trim($reason));
$remark = mb_substr(trim($remark), 0, 200);
if (!in_array($reason, self::CLOSE_REASONS, true)) {
self::setError('关闭原因不合法');
return false;
}
if ($remark === '') {
self::setError('请填写关闭说明');
return false;
}
$row = self::loadRowForWrite($id, $adminId, $adminInfo);
if ($row === null) {
return false;
}
if ((int) $row['closed_state'] !== RevisitDueCalculator::CLOSED_OPEN) {
self::setError('该提醒已关闭或已失效');
return false;
}
try {
$affected = Db::name(RevisitDueService::TABLE)
->where('id', $id)
->where('assistant_id', (int) $row['assistant_id'])
->where('closed_state', RevisitDueCalculator::CLOSED_OPEN)
->update([
'closed_state' => RevisitDueCalculator::CLOSED_MANUAL,
'handled_at' => $now,
'handled_by' => $adminId,
'handle_reason' => $reason,
'handle_remark' => $remark,
'update_time' => $now,
]);
} catch (\Throwable $e) {
self::setError('关闭失败:' . $e->getMessage());
return false;
}
if ((int) $affected <= 0) {
$fresh = self::fetchRow($id) ?? $row;
return ['item' => self::formatItem($fresh, $today, $cfg, $now), 'changed' => false, 'message' => '该提醒已被处理'];
}
RevisitDueService::writeLog(
$id,
(int) $row['diagnosis_id'],
(int) $row['assistant_id'],
(int) $row['source_order_id'],
DiagnosisRevisitDueLog::EVENT_MANUAL_CLOSE,
[
'snooze_count_after' => (int) $row['snooze_count'],
'level_after' => (int) $row['escalation_level'],
'overdue_days' => self::overdueDaysForLog($row, $today),
'operator_id' => $adminId,
'create_time' => $now,
'payload' => ['reason' => $reason, 'reason_text' => self::CLOSE_REASON_TEXT[$reason] ?? $reason, 'remark' => $remark],
]
);
$fresh = self::fetchRow($id) ?? $row;
return ['item' => self::formatItem($fresh, $today, $cfg, $now), 'changed' => true, 'message' => '已关闭提醒'];
}
// ---------------------------------------------------------------- 内部:归属与范围
// ---------------------------------------------------------------- 主管视图(P2)
/** logs() 单次最多返回条数 */
public const LOGS_LIMIT = 500;
/**
* 某条提醒的事件流水(按时间倒序,最多 LOGS_LIMIT 条)。
* 归属:本人 / 超管 / DataScope 可见医助(DataScope 未启用时与列表一致不收窄);不可见时 setError 并返回 false。
*
* @param array<string, mixed> $adminInfo
*
* @return array{due_id:int, diagnosis_id:int, assistant_id:int, patient_name:string, list:list<array<string, mixed>>}|false
*/
public static function logs(int $dueId, int $adminId, array $adminInfo): array|false
{
if ($dueId <= 0 || $adminId <= 0) {
self::setError('参数错误');
return false;
}
$row = self::fetchRow($dueId);
if ($row === null) {
self::setError('提醒不存在');
return false;
}
if (!self::canViewRow($row, $adminId, $adminInfo)) {
self::setError('无权查看该提醒的流水');
return false;
}
$logs = DiagnosisRevisitDueLog::where('due_id', $dueId)
->order(['id' => 'desc'])
->limit(self::LOGS_LIMIT)
->select()
->append(['event_text', 'payload_arr', 'create_time_text'])
->toArray();
$operatorIds = [];
foreach ($logs as $log) {
$oid = (int) ($log['operator_id'] ?? 0);
if ($oid > 0) {
$operatorIds[$oid] = true;
}
}
$nameMap = [];
if ($operatorIds !== []) {
try {
foreach (Admin::whereIn('id', array_keys($operatorIds))->column('name', 'id') as $id => $n) {
$nameMap[(int) $id] = (string) $n;
}
} catch (\Throwable $e) {
Log::warning('复诊到期流水:操作人姓名查询失败 ' . $e->getMessage());
}
}
$list = [];
foreach ($logs as $log) {
$oid = (int) ($log['operator_id'] ?? 0);
$list[] = [
'id' => (int) ($log['id'] ?? 0),
'event' => (int) ($log['event'] ?? 0),
'event_text' => (string) ($log['event_text'] ?? ''),
'snooze_count_after' => (int) ($log['snooze_count_after'] ?? 0),
'level_after' => (int) ($log['level_after'] ?? 0),
'overdue_days' => (int) ($log['overdue_days'] ?? 0),
'payload_arr' => is_array($log['payload_arr'] ?? null) ? $log['payload_arr'] : [],
'operator_id' => $oid,
'operator_name' => $oid > 0 ? (string) ($nameMap[$oid] ?? ('#' . $oid)) : '系统',
'create_time_text' => (string) ($log['create_time_text'] ?? ''),
];
}
$name = trim((string) ($row['patient_name'] ?? ''));
return [
'due_id' => (int) $row['id'],
'diagnosis_id' => (int) ($row['diagnosis_id'] ?? 0),
'assistant_id' => (int) ($row['assistant_id'] ?? 0),
'patient_name' => $name !== '' ? $name : '患者',
'list' => $list,
];
}
/**
* 主管视图可见性:本人 / 超管 / DataScope 可见医助;DataScope 未启用时不收窄(与 HasDataScopeFilter 一致)。
*
* @param array<string, mixed> $row
* @param array<string, mixed> $adminInfo
*/
private static function canViewRow(array $row, int $adminId, array $adminInfo): bool
{
$owner = (int) ($row['assistant_id'] ?? 0);
if ($adminId > 0 && $owner === $adminId) {
return true;
}
if (self::isSuperAdmin($adminId, $adminInfo)) {
return true;
}
try {
if (!DataScopeService::isEnabled()) {
return true;
}
$visible = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
if ($visible === null) {
return true;
}
return $owner > 0 && in_array($owner, array_map('intval', $visible), true);
} catch (\Throwable $e) {
Log::warning('复诊到期流水:数据范围判定失败,按不可见处理 admin_id=' . $adminId . ' ' . $e->getMessage());
return false;
}
}
/**
* 读取待写入的行并校验归属;失败时 setError 并返回 null。
*
* @param array<string, mixed> $adminInfo
*
* @return array<string, mixed>|null
*/
private static function loadRowForWrite(int $id, int $adminId, array $adminInfo): ?array
{
if ($id <= 0 || $adminId <= 0) {
self::setError('参数错误');
return null;
}
$row = self::fetchRow($id);
if ($row === null) {
self::setError('提醒不存在');
return null;
}
if (!self::canOperate($row, $adminId, self::isSuperAdmin($adminId, $adminInfo))) {
self::setError('仅持有医助本人可操作该提醒');
return null;
}
return $row;
}
/**
* @param array<string, mixed> $row
*/
private static function canOperate(array $row, int $adminId, bool $isSuper): bool
{
if ($isSuper) {
return true;
}
return $adminId > 0 && (int) ($row['assistant_id'] ?? 0) === $adminId;
}
/**
* 超管:adminInfo.root=1 或 角色含 role_id=1。
*
* @param array<string, mixed> $adminInfo
*/
private static function isSuperAdmin(int $adminId, array $adminInfo): bool
{
if ((int) ($adminInfo['root'] ?? 0) === 1) {
return true;
}
$roleIds = $adminInfo['role_id'] ?? null;
if (is_array($roleIds) && in_array(1, array_map('intval', $roleIds), true)) {
return true;
}
if ($adminId <= 0) {
return false;
}
try {
$ids = AdminRole::where('admin_id', $adminId)->column('role_id');
return in_array(1, array_map('intval', $ids), true);
} catch (\Throwable $e) {
return false;
}
}
/**
* limit_er_center 时:本人须在「二中心」子树部门内(admin_dept);超管不受限;查询异常时放行(行本就只含本人)。
*
* @param array<string, mixed> $adminInfo
* @param array<string, mixed> $cfg
*/
private static function assistantInScope(int $adminId, array $adminInfo, array $cfg): bool
{
if (empty($cfg['limit_er_center'])) {
return true;
}
if (self::isSuperAdmin($adminId, $adminInfo)) {
return true;
}
try {
$deptSet = DeptLogic::getErCenterSubtreeDeptIdSet();
if ($deptSet === []) {
return false;
}
$hit = Db::name('admin_dept')
->where('admin_id', $adminId)
->whereIn('dept_id', array_keys($deptSet))
->count();
return $hit > 0;
} catch (\Throwable $e) {
Log::warning('复诊到期弹窗:二中心范围判定失败,按放行处理 admin_id=' . $adminId . ' ' . $e->getMessage());
return true;
}
}
// ---------------------------------------------------------------- 内部:取数与校验
/**
* 弹窗 / 计数共用的到期条件(不含 next_remind_at)。
*
* @param mixed $q think Query
*/
private static function applyDueWhere($q, int $adminId): void
{
$q->where('assistant_id', $adminId)
->where('closed_state', RevisitDueCalculator::CLOSED_OPEN)
->whereRaw('(appointment_state = ' . RevisitDueCalculator::APPT_MISSED . ' OR (due_date IS NOT NULL AND due_date <= CURDATE()))')
->whereRaw('(promised_date IS NULL OR promised_date < CURDATE())');
}
/**
* 轻量实时校验:
* 1. 锚点订单日之后出现 已面诊 / 未过期的已约 → 顺手 refresh(钩子漏接兜底);
* 2. RevisitDueService::buildListPayload(self_heal) 校正 source_order_id / current_slot 漂移;
* 3. 以自愈后的 state 过滤(仅 overdue / missed),重读行并复核归属、closed_state、next_remind_at。
*
* @param list<array<string, mixed>> $rows
* @param array<string, mixed> $cfg
*
* @return list<array<string, mixed>> 保持原排序
*/
private static function liveCheck(array $rows, int $adminId, string $today, int $now, array $cfg): array
{
try {
self::refreshIfNewlyBooked($rows, $adminId);
} catch (\Throwable $e) {
Log::warning('复诊到期弹窗:挂号实时校验失败 ' . $e->getMessage());
}
$diagIds = [];
foreach ($rows as $r) {
$diagIds[] = (int) $r['diagnosis_id'];
}
$payloads = [];
try {
$payloads = RevisitDueService::buildListPayload($diagIds, ['self_heal' => true, 'today' => $today]);
} catch (\Throwable $e) {
Log::warning('复诊到期弹窗:自愈校验失败 ' . $e->getMessage());
}
$keepIds = [];
foreach ($rows as $r) {
$p = $payloads[(int) $r['diagnosis_id']] ?? null;
if ($p === null) {
// 读取失败时信任快照行自身推导
$st = RevisitDueCalculator::deriveState($r, $today, $cfg);
if (in_array($st['state'], self::TOAST_STATES, true)) {
$keepIds[] = (int) $r['id'];
}
continue;
}
if (in_array((string) $p['state'], self::TOAST_STATES, true) && (int) $p['closed_state'] === RevisitDueCalculator::CLOSED_OPEN) {
$keepIds[] = (int) $r['id'];
}
}
if ($keepIds === []) {
return [];
}
$freshById = [];
foreach (Db::name(RevisitDueService::TABLE)->whereIn('id', $keepIds)->select()->toArray() as $fr) {
$freshById[(int) $fr['id']] = $fr;
}
$out = [];
foreach ($keepIds as $id) {
$fr = $freshById[$id] ?? null;
if ($fr === null
|| (int) $fr['assistant_id'] !== $adminId
|| (int) $fr['next_remind_at'] > $now
|| !self::toastEligible($fr, $today)) {
continue;
}
$out[] = $fr;
}
return $out;
}
/**
* 锚点订单日之后存在 已面诊(3) 或 未过期的已约(1) 挂号的诊单 → refreshDiagnoses(会自动关闭并从弹窗剔除)。
*
* @param list<array<string, mixed>> $rows
*/
private static function refreshIfNewlyBooked(array $rows, int $adminId): void
{
// 与 RevisitDueCalculator 同口径:只认下单日 + appointment_min_gap_days(默认次日起)之后的挂号
$gapDays = max(0, (int) ($cfg['appointment_min_gap_days'] ?? 1));
$orderDateByDiag = [];
foreach ($rows as $r) {
$did = (int) $r['diagnosis_id'];
$oct = (int) ($r['order_create_time'] ?? 0);
$orderDateByDiag[$did] = $oct > 0
? date('Y-m-d', strtotime(date('Y-m-d', $oct) . ' +' . $gapDays . ' day'))
: '1970-01-01';
}
if ($orderDateByDiag === []) {
return;
}
$minDate = min(array_values($orderDateByDiag));
$apts = Db::name('doctor_appointment')
->whereIn('patient_id', array_keys($orderDateByDiag))
->where('appointment_date', '>=', $minDate)
->whereRaw('(status = ' . RevisitDueCalculator::APPT_VISITED . ' OR (status = ' . RevisitDueCalculator::APPT_BOOKED . ' AND appointment_date >= CURDATE()))')
->field(['patient_id', 'appointment_date'])
->select()
->toArray();
$hits = [];
foreach ($apts as $a) {
$did = (int) ($a['patient_id'] ?? 0);
$ad = substr(trim((string) ($a['appointment_date'] ?? '')), 0, 10);
if (isset($orderDateByDiag[$did]) && $ad !== '' && $ad >= $orderDateByDiag[$did]) {
$hits[$did] = true;
}
}
if ($hits !== []) {
RevisitDueService::refreshDiagnoses(array_keys($hits), ['reason' => 'toast_live_check', 'operator_id' => $adminId]);
}
}
/**
* 更新 last_shown_at;first_shown_at 为 0 的行写一次并记 event=11。失败只记 warning。
*
* @param list<array<string, mixed>> $rows
*/
private static function markShown(array $rows, int $adminId, string $today, int $now): void
{
$ids = [];
$firstRows = [];
foreach ($rows as $r) {
$ids[] = (int) $r['id'];
if ((int) ($r['first_shown_at'] ?? 0) <= 0) {
$firstRows[] = $r;
}
}
if ($ids === []) {
return;
}
try {
Db::name(RevisitDueService::TABLE)->whereIn('id', $ids)->update(['last_shown_at' => $now]);
if ($firstRows !== []) {
$firstIds = array_map(static fn (array $r): int => (int) $r['id'], $firstRows);
$affected = (int) Db::name(RevisitDueService::TABLE)
->whereIn('id', $firstIds)
->where('first_shown_at', 0)
->update(['first_shown_at' => $now]);
if ($affected > 0) {
// 只给本轮真正写入 first_shown_at 的行记流水(并发轮询可能已先标记了其中一部分)
$writtenIds = array_map('intval', Db::name(RevisitDueService::TABLE)
->whereIn('id', $firstIds)
->where('first_shown_at', $now)
->column('id'));
$writtenSet = array_fill_keys($writtenIds, true);
foreach ($firstRows as $r) {
if (!isset($writtenSet[(int) $r['id']])) {
continue;
}
RevisitDueService::writeLog(
(int) $r['id'],
(int) $r['diagnosis_id'],
(int) $r['assistant_id'],
(int) $r['source_order_id'],
DiagnosisRevisitDueLog::EVENT_FIRST_SHOWN,
[
'snooze_count_after' => (int) $r['snooze_count'],
'level_after' => (int) $r['escalation_level'],
'overdue_days' => self::overdueDaysForLog($r, $today),
'operator_id' => $adminId,
'create_time' => $now,
'payload' => ['source' => 'toast'],
]
);
}
}
}
} catch (\Throwable $e) {
Log::warning('复诊到期弹窗:更新弹出时间失败 ' . $e->getMessage());
}
}
/**
* 汇总:到期总数 / 最大超期天数 / 预警数(预警口径与 DiagnosisLists revisit_state=warning 一致)。
*
* @param array<string, mixed> $cfg
*
* @return array<string, mixed>
*/
private static function buildSummary(int $adminId, array $cfg): array
{
$summary = self::emptySummary($cfg);
try {
$q = Db::name(RevisitDueService::TABLE);
self::applyDueWhere($q, $adminId);
$summary['due_total'] = (int) (clone $q)->count();
$maxRow = (clone $q)->whereNotNull('due_date')->fieldRaw('MAX(DATEDIFF(CURDATE(), due_date)) AS m')->find();
$summary['max_overdue_days'] = max(0, (int) ($maxRow['m'] ?? 0));
$warnDays = max(0, (int) ($cfg['warn_days'] ?? 3));
$summary['warning_total'] = (int) Db::name(RevisitDueService::TABLE)
->where('assistant_id', $adminId)
->where('closed_state', RevisitDueCalculator::CLOSED_OPEN)
->whereNotNull('due_date')
->whereRaw('due_date > CURDATE()')
->whereRaw("due_date <= DATE_ADD(CURDATE(), INTERVAL {$warnDays} DAY)")
->where('appointment_state', '<>', RevisitDueCalculator::APPT_MISSED)
->whereRaw('NOT (appointment_state = ' . RevisitDueCalculator::APPT_BOOKED . ' AND appointment_date < CURDATE())')
->whereRaw('(promised_date IS NULL OR promised_date < CURDATE())')
->count();
} catch (\Throwable $e) {
Log::warning('复诊到期弹窗:汇总计数失败 ' . $e->getMessage());
}
return $summary;
}
/**
* @param array<string, mixed> $cfg
*
* @return array<string, mixed>
*/
private static function emptySummary(array $cfg): array
{
[$startMin, $endMin] = self::parseWorkHours($cfg['work_hours'] ?? self::DEFAULT_WORK_HOURS);
return [
'enabled' => self::toastEnabled($cfg),
'due_total' => 0,
'max_overdue_days' => 0,
'warning_total' => 0,
'promise_max_days' => max(0, (int) ($cfg['promise_max_days'] ?? 14)),
'contact_max_per_cycle' => max(0, (int) ($cfg['contact_max_per_cycle'] ?? 2)),
'work_hours' => [sprintf('%02d:%02d', intdiv($startMin, 60), $startMin % 60), sprintf('%02d:%02d', intdiv($endMin, 60), $endMin % 60)],
];
}
/**
* @param array<string, mixed> $cfg
*/
private static function toastEnabled(array $cfg): bool
{
return (bool) ($cfg['toast_enabled'] ?? false);
}
/**
* 快照行 → 弹窗条目(契约见方案 §3.3)。
*
* @param array<string, mixed> $row
* @param array<string, mixed> $cfg
*
* @return array<string, mixed>
*/
private static function formatItem(array $row, string $today, array $cfg, int $now): array
{
$st = RevisitDueCalculator::deriveState($row, $today, $cfg);
$snoozeCount = (int) ($row['snooze_count'] ?? 0);
$nextRemind = (int) ($row['next_remind_at'] ?? 0);
$name = trim((string) ($row['patient_name'] ?? ''));
return [
'id' => (int) ($row['id'] ?? 0),
'diagnosis_id' => (int) ($row['diagnosis_id'] ?? 0),
'assistant_id' => (int) ($row['assistant_id'] ?? 0),
'patient_name' => $name !== '' ? $name : '患者',
'current_slot' => (int) ($row['current_slot'] ?? 0),
'next_slot' => (int) ($row['next_slot'] ?? 0),
'medication_days' => (int) ($row['medication_days'] ?? 0),
'due_date' => self::dateStr($row['due_date'] ?? null),
'overdue_days' => $st['overdue_days'],
'state' => $st['state'],
'state_text' => $st['state_text'],
'severe' => (int) $st['severe'],
'missed_count' => (int) ($row['missed_count'] ?? 0),
'snooze_count' => $snoozeCount,
'escalation_level' => (int) ($row['escalation_level'] ?? 0),
'contact_count' => (int) ($row['contact_count'] ?? 0),
'promised_date' => self::dateStr($row['promised_date'] ?? null),
'next_remind_at' => $nextRemind,
'next_remind_at_text' => self::remindTimeText($nextRemind, $now),
'next_snooze_seconds' => self::snoozeInterval($snoozeCount + 1, $cfg['snooze_intervals'] ?? null),
'appointment_date' => self::dateStr($row['appointment_date'] ?? null),
'closed_state' => (int) ($row['closed_state'] ?? 0),
];
}
/**
* @return array<string, mixed>|null
*/
private static function fetchRow(int $id): ?array
{
if ($id <= 0) {
return null;
}
$row = Db::name(RevisitDueService::TABLE)->where('id', $id)->find();
return is_array($row) ? $row : null;
}
/**
* 流水 overdue_days:today − due_date(负=未到期),无 due_date 记 0。
*
* @param array<string, mixed> $row
*/
private static function overdueDaysForLog(array $row, string $today): int
{
$due = self::dateStr($row['due_date'] ?? null);
return $due !== '' ? RevisitDueCalculator::dayDiff($due, $today) : 0;
}
/** 'HH:mm' → 分钟数;非法返回 null(允许 24:00) */
private static function parseHm(string $s): ?int
{
if (preg_match('/^\s*(\d{1,2}):(\d{2})\s*$/', $s, $m) !== 1) {
return null;
}
$h = (int) $m[1];
$mi = (int) $m[2];
if ($mi > 59 || $h > 24 || ($h === 24 && $mi !== 0)) {
return null;
}
return $h * 60 + $mi;
}
/** 日期列 → 'Y-m-d' 或 ''(null / 空 / 0000-00-00 均视为空) */
private static function dateStr(mixed $value): string
{
if ($value === null) {
return '';
}
$s = substr(trim((string) $value), 0, 10);
return ($s === '' || $s === '0000-00-00') ? '' : $s;
}
}