'患者流失', '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 $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 $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 $adminInfo * * @return array{items: list>, summary: array} */ 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 $adminInfo * * @return array */ 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 $adminInfo * * @return array{item: array, 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 $ids * @param array $adminInfo * * @return array{items: list>, 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 $adminInfo * * @return array{item: array, 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 $adminInfo * * @return array{item: array, 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 $adminInfo * * @return array{due_id:int, diagnosis_id:int, assistant_id:int, patient_name:string, list:list>}|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 $row * @param array $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 $adminInfo * * @return array|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 $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 $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 $adminInfo * @param array $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> $rows * @param array $cfg * * @return list> 保持原排序 */ 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> $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> $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 $cfg * * @return array */ 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 $cfg * * @return array */ 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 $cfg */ private static function toastEnabled(array $cfg): bool { return (bool) ($cfg['toast_enabled'] ?? false); } /** * 快照行 → 弹窗条目(契约见方案 §3.3)。 * * @param array $row * @param array $cfg * * @return array */ 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|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 $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; } }