1184 lines
45 KiB
PHP
1184 lines
45 KiB
PHP
<?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;
|
||
}
|
||
}
|