Files
zyt/server/app/command/RevisitDueNotify.php
T
2026-10-15 11:03:54 +08:00

781 lines
33 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\command;
use app\adminapi\logic\tcm\RevisitDueLogic;
use app\common\model\tcm\DiagnosisRevisitDueLog;
use app\common\service\tcm\RevisitDueCalculator;
use app\common\service\tcm\RevisitDueService;
use app\common\service\wechat\WechatWorkAppMessageService;
use think\console\Command;
use think\console\Input;
use think\console\input\Option;
use think\console\Output;
use think\facade\Db;
use think\facade\Log;
/**
* 复诊到期提醒 - 企业微信推送(方案 §5.2,P2;project.revisit_reach.wecom_enabled=false 时空跑)
*
* 使用方法:
* php think tcm:revisit-due-notify --mode=daily # 每日 09:00:每位有「到期未挂号 / 过号」行的医助一条汇总
* php think tcm:revisit-due-notify --mode=tick # 每 30 分钟:escalation_level>=3 且到提醒时刻的行推本人(每行每日 ≤ wecom_daily_cap),
* # snooze_count>=阈值 的行每日抄送一次配置名单主管(supervisor_admin_ids)
* php think tcm:revisit-due-notify --mode=daily --dry-run # 只打印拟发送内容,不调企微、不写流水、不写回计数
* php think tcm:revisit-due-notify --mode=daily --date=2026-10-20 --dry-run # 以指定基准日回放
*
* 选项:
* --mode=daily|tick 默认 daily
* --dry-run 只打印拟发送内容
* --date=Y-m-d 基准日(默认今天);与今天不同时自动切换演练模式
*
* 约定:
* - 推送只在本命令内发(接口不同步 curl);逐条 usleep(200ms);每条 try/catch;整体 try/catch 并 return 0
* (Crontab::start 捕获到异常会把 dev_crontab.status 置 ERROR 停跑);
* - 接收人取 admin.work_wechat_userid(同 DiagnosisTodoNotify),未绑定则跳过并写 event=8 流水(payload.skipped=unbound,不算失败);
* - daily 去重:当日已有 event=8 且 payload.kind=daily 的医助不再发(流水判断,不改表);
* - tick 本人:wecom_self_at / wecom_self_count_day 日切重置后计数,达到 wecom_daily_cap 不再推;
* - tick 主管:wecom_supervisor_at 与今天同日则不再抄送;主管名单剔除医助本人并去重。
*
* 纯函数(isSameDay / selfSentToday / selfQuotaLeft / shouldCcSupervisor / normalizeSupervisorIds / overdueLabel /
* buildDailyText / buildSelfText / buildSupervisorText / withinWorkHours / dayRange)不连库、不读 config,
* 供 server/tests/RevisitDueNotifyTest.php 直接单测。
*/
class RevisitDueNotify extends Command
{
/** 流水 payload.kind:每日汇总 */
public const KIND_DAILY = 'daily';
/** 流水 payload.kind:tick 推本人 */
public const KIND_SELF = 'self';
/** 流水 payload.kind:tick 抄送主管 */
public const KIND_SUPERVISOR = 'supervisor';
/** 文案落款 */
public const SIGNATURE = '— 二中心跟踪系统';
/** 每日汇总最多列出的患者数(配置 wecom_daily_list_max 缺省值) */
public const DAILY_LIST_MAX_DEFAULT = 5;
/** 抄送主管的延迟次数阈值(配置 wecom_supervisor_snooze_min 缺省值) */
public const SUPERVISOR_SNOOZE_MIN_DEFAULT = 5;
/** 每条每日推送本人上限缺省值(配置 wecom_daily_cap 缺省值) */
public const DAILY_CAP_DEFAULT = 2;
/** tick 单次最多处理行数 */
private const TICK_ROW_LIMIT = 500;
/** 逐条发送间隔(微秒) */
private const SEND_GAP_USEC = 200000;
/** 进入推送的展示状态 */
private const PUSH_STATES = [RevisitDueCalculator::STATE_OVERDUE, RevisitDueCalculator::STATE_MISSED];
protected function configure()
{
$this->setName('tcm:revisit-due-notify')
->setDescription('复诊到期提醒企微推送:daily 每日汇总推医助本人 / tick 升级行推本人并抄送主管(wecom_enabled=false 时空跑)')
->addOption('mode', null, Option::VALUE_OPTIONAL, '推送模式 daily|tick', 'daily')
->addOption('dry-run', null, Option::VALUE_NONE, '只打印拟发送内容,不调企微、不写流水')
->addOption('date', null, Option::VALUE_OPTIONAL, '基准日 Y-m-d(默认今天)', '');
}
protected function execute(Input $input, Output $output): int
{
$startedAt = microtime(true);
$now = time();
try {
$mode = strtolower(trim((string) $input->getOption('mode')));
if ($mode === '') {
$mode = 'daily';
}
if (!in_array($mode, ['daily', 'tick'], true)) {
$output->writeln('<error>无效 --mode,仅支持 daily|tick</error>');
return 0;
}
$dryRun = (bool) $input->getOption('dry-run');
$today = trim((string) $input->getOption('date'));
if ($today !== '' && !$this->isValidDate($today)) {
$output->writeln('<error>无效 --date,格式应为 Y-m-d</error>');
return 0;
}
if ($today === '') {
$today = date('Y-m-d', $now);
} elseif ($today !== date('Y-m-d', $now) && !$dryRun) {
$output->writeln('<comment>--date 与今天不同,已自动切换为演练模式(--dry-run),不发送、不写库</comment>');
$dryRun = true;
}
$cfg = RevisitDueService::config();
$output->writeln(sprintf(
'[%s] 复诊到期企微推送 mode=%s date=%s%s',
date('Y-m-d H:i:s', $now),
$mode,
$today,
$dryRun ? ' (演练模式,不发送)' : ''
));
if (empty($cfg['wecom_enabled'])) {
$output->writeln('企微推送未启用(project.revisit_reach.wecom_enabled=false),本次不处理。');
return 0;
}
$stats = $mode === 'daily'
? $this->runDaily($output, $cfg, $today, $now, $dryRun)
: $this->runTick($output, $cfg, $today, $now, $dryRun);
$output->writeln(sprintf(
'完成。候选行 %d,发送 %d,失败 %d,未绑定跳过 %d,去重跳过 %d,抄送主管 %d,耗时 %ss',
$stats['rows'],
$stats['sent'],
$stats['failed'],
$stats['unbound'],
$stats['deduped'],
$stats['supervisor'],
round(microtime(true) - $startedAt, 2)
));
} catch (\Throwable $e) {
Log::error('tcm:revisit-due-notify 执行异常: ' . $e->getMessage());
$output->writeln('<error>执行异常:' . $e->getMessage() . '</error>');
}
return 0;
}
// ---------------------------------------------------------------- daily
/**
* 每日汇总:按医助分组,一人一条。
*
* @param array<string, mixed> $cfg
*
* @return array{rows:int, sent:int, failed:int, unbound:int, deduped:int, supervisor:int}
*/
private function runDaily(Output $output, array $cfg, string $today, int $now, bool $dryRun): array
{
$stats = $this->emptyStats();
$listMax = max(1, (int) ($cfg['wecom_daily_list_max'] ?? self::DAILY_LIST_MAX_DEFAULT));
$rows = $this->dueQuery($today)
->order('assistant_id ASC, escalation_level DESC, due_date ASC, id ASC')
->select()
->toArray();
$stats['rows'] = \count($rows);
if ($rows === []) {
$output->writeln('无到期未挂号 / 过号行。');
return $stats;
}
// 按医助分组,只保留读取侧推导为 超期 / 过号 的行
$groups = [];
foreach ($rows as $row) {
$st = RevisitDueCalculator::deriveState($row, $today, $cfg);
if (!in_array($st['state'], self::PUSH_STATES, true)) {
continue;
}
$row['_st'] = $st;
$groups[(int) $row['assistant_id']][] = $row;
}
if ($groups === []) {
$output->writeln('候选行经状态推导后均不需提醒。');
return $stats;
}
$assistantIds = array_keys($groups);
// 当日已发过汇总的医助(流水判断:event=8 且 payload.kind=daily)
$sentToday = $this->dailySentAssistantIds($assistantIds, $today);
$admins = $this->fetchAdmins($assistantIds);
foreach ($groups as $aid => $items) {
try {
if (isset($sentToday[$aid])) {
$stats['deduped']++;
$output->writeln(sprintf(' 医助 %d 今日已发过汇总,跳过(%d 行)', $aid, \count($items)));
continue;
}
$text = self::buildDailyText(array_map(static fn (array $r): array => [
'patient_name' => (string) ($r['patient_name'] ?? ''),
'state' => (string) $r['_st']['state'],
'overdue_days' => $r['_st']['overdue_days'],
], $items), $listMax);
$admin = $admins[$aid] ?? null;
$wxId = trim((string) ($admin['work_wechat_userid'] ?? ''));
$name = (string) ($admin['name'] ?? ('#' . $aid));
if ($dryRun) {
$output->writeln(sprintf(' [拟发送 daily] 医助 %s(%d) → %s | %s', $name, $aid, $wxId !== '' ? $wxId : '(未绑定)', $text));
$wxId !== '' ? $stats['sent']++ : $stats['unbound']++;
continue;
}
$payloadBase = ['kind' => self::KIND_DAILY, 'to' => $wxId, 'n' => \count($items)];
if ($wxId === '') {
$stats['unbound']++;
$output->writeln(sprintf(' 医助 %s(%d) 未绑定企微 userid,跳过(%d 行)', $name, $aid, \count($items)));
$this->logRows($items, $today, $now, $payloadBase + ['ok' => false, 'skipped' => 'unbound']);
continue;
}
$res = WechatWorkAppMessageService::sendTextToUser($wxId, $text);
$ok = (bool) ($res['ok'] ?? false);
$payload = $payloadBase + ['ok' => $ok];
if (!$ok) {
$payload['errcode'] = (int) ($res['errcode'] ?? -1);
$payload['msg'] = mb_substr((string) ($res['message'] ?? ''), 0, 60);
$stats['failed']++;
$output->writeln(sprintf(' <error>医助 %s(%d) 发送失败:%s</error>', $name, $aid, (string) ($res['message'] ?? '')));
} else {
$stats['sent']++;
$output->writeln(sprintf(' 医助 %s(%d) 已发送汇总(%d 行)', $name, $aid, \count($items)));
}
$this->logRows($items, $today, $now, $payload);
usleep(self::SEND_GAP_USEC);
} catch (\Throwable $e) {
$stats['failed']++;
Log::error('tcm:revisit-due-notify daily 单医助处理异常 assistant_id=' . $aid . ' msg=' . $e->getMessage());
$output->writeln(sprintf(' <error>医助 %d 处理异常:%s</error>', $aid, $e->getMessage()));
}
}
return $stats;
}
// ---------------------------------------------------------------- tick
/**
* tick:升级行推本人(每行每日 ≤ cap),达阈值的行每日抄送主管一次。
*
* @param array<string, mixed> $cfg
*
* @return array{rows:int, sent:int, failed:int, unbound:int, deduped:int, supervisor:int}
*/
private function runTick(Output $output, array $cfg, string $today, int $now, bool $dryRun): array
{
$stats = $this->emptyStats();
$cap = max(0, (int) ($cfg['wecom_daily_cap'] ?? self::DAILY_CAP_DEFAULT));
$minSnooze = max(1, (int) ($cfg['wecom_supervisor_snooze_min'] ?? self::SUPERVISOR_SNOOZE_MIN_DEFAULT));
if (!self::withinWorkHours($now, $cfg['work_hours'] ?? null)) {
$output->writeln('当前不在工作时段(work_hours),tick 不推送。');
if (!$dryRun) {
return $stats;
}
$output->writeln('<comment>演练模式继续计算拟发送内容</comment>');
}
$rows = $this->dueQuery($today)
->where('escalation_level', '>=', 3)
->where('next_remind_at', '<=', $now)
->order('escalation_level DESC, due_date ASC, id ASC')
->limit(self::TICK_ROW_LIMIT)
->select()
->toArray();
$stats['rows'] = \count($rows);
if ($rows === []) {
$output->writeln('无需推送的升级行。');
return $stats;
}
$assistantIds = array_values(array_unique(array_map(static fn (array $r): int => (int) $r['assistant_id'], $rows)));
$supervisorPool = self::normalizeSupervisorIds($cfg['supervisor_admin_ids'] ?? []);
$admins = $this->fetchAdmins(array_values(array_unique(array_merge($assistantIds, $supervisorPool))));
$warnedNoSupervisor = false;
foreach ($rows as $row) {
$id = (int) $row['id'];
$aid = (int) $row['assistant_id'];
try {
$st = RevisitDueCalculator::deriveState($row, $today, $cfg);
if (!in_array($st['state'], self::PUSH_STATES, true)) {
continue;
}
$admin = $admins[$aid] ?? null;
$wxId = trim((string) ($admin['work_wechat_userid'] ?? ''));
$name = (string) ($admin['name'] ?? ('#' . $aid));
$patient = trim((string) ($row['patient_name'] ?? ''));
$logBase = [
'snooze_count_after' => (int) ($row['snooze_count'] ?? 0),
'level_after' => (int) ($row['escalation_level'] ?? 0),
'overdue_days' => $this->overdueDaysForLog($row, $today),
'operator_id' => 0,
'create_time' => $now,
];
// ---- 推本人(每行每日 ≤ cap) ----
$sentCnt = self::selfSentToday($row, $now);
$left = self::selfQuotaLeft($row, $now, $cap);
if ($left <= 0) {
$stats['deduped']++;
} else {
$text = self::buildSelfText($row, $st);
if ($dryRun) {
$output->writeln(sprintf(' [拟发送 self] due#%d 医助 %s(%d) → %s | %s', $id, $name, $aid, $wxId !== '' ? $wxId : '(未绑定)', $text));
$wxId !== '' ? $stats['sent']++ : $stats['unbound']++;
} else {
$payload = ['kind' => self::KIND_SELF, 'to' => $wxId, 'seq' => $sentCnt + 1, 'cap' => $cap];
if ($wxId === '') {
$stats['unbound']++;
$payload += ['ok' => false, 'skipped' => 'unbound'];
$output->writeln(sprintf(' due#%d 医助 %s(%d) 未绑定企微 userid,跳过', $id, $name, $aid));
} else {
$res = WechatWorkAppMessageService::sendTextToUser($wxId, $text);
$ok = (bool) ($res['ok'] ?? false);
$payload['ok'] = $ok;
if ($ok) {
$stats['sent']++;
$output->writeln(sprintf(' due#%d 医助 %s(%d) 患者 %s 已推送本人(今日第 %d 次)', $id, $name, $aid, $patient, $sentCnt + 1));
} else {
$stats['failed']++;
$payload['errcode'] = (int) ($res['errcode'] ?? -1);
$payload['msg'] = mb_substr((string) ($res['message'] ?? ''), 0, 60);
$output->writeln(sprintf(' <error>due#%d 医助 %s(%d) 推送失败:%s</error>', $id, $name, $aid, (string) ($res['message'] ?? '')));
}
usleep(self::SEND_GAP_USEC);
}
// 未绑定 / 失败同样计入当日次数,避免每 30 分钟重复刷流水
Db::name(RevisitDueService::TABLE)->where('id', $id)->update([
'wecom_self_at' => $now,
'wecom_self_count_day' => min(255, $sentCnt + 1),
'update_time' => $now,
]);
RevisitDueService::writeLog($id, (int) $row['diagnosis_id'], $aid, (int) $row['source_order_id'], DiagnosisRevisitDueLog::EVENT_WECOM_PUSH, $logBase + ['payload' => $payload]);
}
}
// ---- 抄送主管(每行每日一次) ----
if (!self::shouldCcSupervisor($row, $now, $minSnooze)) {
continue;
}
$supervisors = self::normalizeSupervisorIds($supervisorPool, $aid);
if ($supervisors === []) {
if (!$warnedNoSupervisor) {
$output->writeln('<comment> 未配置主管名单(project.revisit_reach.supervisor_admin_ids)或仅含医助本人,跳过抄送</comment>');
$warnedNoSupervisor = true;
}
continue;
}
$supText = self::buildSupervisorText($name, $row, $st);
$toList = [];
$okCnt = 0;
$failCnt = 0;
$firstErr = null;
foreach ($supervisors as $sid) {
$sWx = trim((string) ($admins[$sid]['work_wechat_userid'] ?? ''));
if ($sWx === '') {
$output->writeln(sprintf(' 主管 %d 未绑定企微 userid,跳过', $sid));
continue;
}
$toList[] = $sWx;
if ($dryRun) {
$output->writeln(sprintf(' [拟发送 supervisor] due#%d → 主管 %d(%s) | %s', $id, $sid, $sWx, $supText));
$okCnt++;
continue;
}
try {
$res = WechatWorkAppMessageService::sendTextToUser($sWx, $supText);
if (!empty($res['ok'])) {
$okCnt++;
} else {
$failCnt++;
$firstErr ??= (int) ($res['errcode'] ?? -1);
$output->writeln(sprintf(' <error>due#%d 抄送主管 %d 失败:%s</error>', $id, $sid, (string) ($res['message'] ?? '')));
}
} catch (\Throwable $e) {
$failCnt++;
Log::error('tcm:revisit-due-notify 抄送主管异常 due_id=' . $id . ' supervisor=' . $sid . ' msg=' . $e->getMessage());
}
usleep(self::SEND_GAP_USEC);
}
if ($dryRun) {
$stats['supervisor'] += $okCnt > 0 ? 1 : 0;
continue;
}
$payload = ['kind' => self::KIND_SUPERVISOR, 'to' => implode(',', $toList), 'ok' => $failCnt === 0 && $okCnt > 0, 'sent' => $okCnt, 'fail' => $failCnt];
if ($toList === []) {
$payload['skipped'] = 'unbound';
}
if ($firstErr !== null) {
$payload['errcode'] = $firstErr;
}
// 不论成败,当日只抄送一次
Db::name(RevisitDueService::TABLE)->where('id', $id)->update(['wecom_supervisor_at' => $now, 'update_time' => $now]);
RevisitDueService::writeLog($id, (int) $row['diagnosis_id'], $aid, (int) $row['source_order_id'], DiagnosisRevisitDueLog::EVENT_WECOM_PUSH, $logBase + ['payload' => $payload]);
if ($okCnt > 0) {
$stats['supervisor']++;
$output->writeln(sprintf(' due#%d 已抄送主管 %d 人(医助 %s,患者 %s,延迟 %d 次)', $id, $okCnt, $name, $patient, (int) $row['snooze_count']));
}
} catch (\Throwable $e) {
$stats['failed']++;
Log::error('tcm:revisit-due-notify tick 单行处理异常 due_id=' . $id . ' msg=' . $e->getMessage());
$output->writeln(sprintf(' <error>due#%d 处理异常:%s</error>', $id, $e->getMessage()));
}
}
return $stats;
}
// ---------------------------------------------------------------- 纯函数
/**
* 两个时间戳是否同一自然日(ts<=0 视为从未发生 → false)。
*/
public static function isSameDay(int $ts, int $now): bool
{
if ($ts <= 0) {
return false;
}
return date('Y-m-d', $ts) === date('Y-m-d', $now);
}
/**
* 当日已推送本人次数:wecom_self_at 与 now 同日取 wecom_self_count_day,否则日切归 0。
*
* @param array<string, mixed> $row
*/
public static function selfSentToday(array $row, int $now): int
{
$at = (int) ($row['wecom_self_at'] ?? 0);
if (!self::isSameDay($at, $now)) {
return 0;
}
return max(0, (int) ($row['wecom_self_count_day'] ?? 0));
}
/**
* 当日剩余可推本人次数(cap<=0 表示不推)。
*
* @param array<string, mixed> $row
*/
public static function selfQuotaLeft(array $row, int $now, int $cap): int
{
if ($cap <= 0) {
return 0;
}
return max(0, $cap - self::selfSentToday($row, $now));
}
/**
* 是否需要抄送主管:snooze_count >= minSnooze 且今天尚未抄送(wecom_supervisor_at 日切)。
*
* @param array<string, mixed> $row
*/
public static function shouldCcSupervisor(array $row, int $now, int $minSnooze): bool
{
if ((int) ($row['snooze_count'] ?? 0) < max(1, $minSnooze)) {
return false;
}
return !self::isSameDay((int) ($row['wecom_supervisor_at'] ?? 0), $now);
}
/**
* 主管名单归一:容忍逗号字符串 / 数组,转正整数、去重、剔除医助本人。
*
* @return list<int>
*/
public static function normalizeSupervisorIds(mixed $ids, int $excludeAdminId = 0): array
{
if (is_string($ids)) {
$ids = preg_split('/[\s,]+/', trim($ids)) ?: [];
}
if (!is_array($ids)) {
return [];
}
$out = [];
foreach ($ids as $v) {
$id = (int) $v;
if ($id > 0 && $id !== $excludeAdminId && !in_array($id, $out, true)) {
$out[] = $id;
}
}
return $out;
}
/**
* 超期标签:过号 → 「过号」(有超期天数再接「·超期N天」);超期 N 天 → 「超期N天」;0 → 「今日应挂号」。
*
* @param array{state?:string, overdue_days?:int|null} $st deriveState 结果
*/
public static function overdueLabel(array $st): string
{
$overdue = $st['overdue_days'] ?? null;
$overdue = $overdue === null ? null : (int) $overdue;
if (($st['state'] ?? '') === RevisitDueCalculator::STATE_MISSED) {
return '过号' . ($overdue !== null && $overdue > 0 ? '·超期' . $overdue . '天' : '');
}
if ($overdue === null) {
return '到期';
}
return $overdue > 0 ? '超期' . $overdue . '天' : '今日应挂号';
}
/**
* 每日汇总文案:按超期天数降序列出前 listMax 人。
*
* @param list<array{patient_name:string, state:string, overdue_days:int|null}> $items
*/
public static function buildDailyText(array $items, int $listMax = self::DAILY_LIST_MAX_DEFAULT): string
{
$n = \count($items);
if ($n === 0) {
return '';
}
$listMax = max(1, $listMax);
usort($items, static function (array $a, array $b): int {
return (int) ($b['overdue_days'] ?? 0) <=> (int) ($a['overdue_days'] ?? 0);
});
$maxOverdue = 0;
foreach ($items as $it) {
$maxOverdue = max($maxOverdue, (int) ($it['overdue_days'] ?? 0));
}
$parts = [];
foreach (array_slice($items, 0, $listMax) as $it) {
$name = trim((string) ($it['patient_name'] ?? ''));
$parts[] = ($name !== '' ? $name : '患者') . '(' . self::overdueLabel($it) . ')';
}
$text = '【复诊到期提醒】您有 ' . $n . ' 位患者到期未挂号';
if ($maxOverdue > 0) {
$text .= ',最久超期 ' . $maxOverdue . ' 天';
}
$text .= ':' . implode('、', $parts);
if ($n > $listMax) {
$text .= '…等 ' . $n . ' 人';
}
return $text . ',请尽快联系患者挂号。' . self::SIGNATURE;
}
/**
* tick 推本人文案。
*
* @param array<string, mixed> $row 快照行
* @param array{state?:string, overdue_days?:int|null} $st deriveState 结果
*/
public static function buildSelfText(array $row, array $st): string
{
$name = trim((string) ($row['patient_name'] ?? ''));
$name = $name !== '' ? $name : '患者';
$slot = (int) ($row['current_slot'] ?? 0);
$due = self::dateStr($row['due_date'] ?? null);
$snooze = (int) ($row['snooze_count'] ?? 0);
$text = '【复诊到期提醒】患者 ' . $name;
$text .= '(' . ($slot > 0 ? '第' . $slot . '诊后' : '待复诊') . ($due !== '' ? ',预计复诊 ' . substr($due, 5) : '') . ')';
$text .= self::overdueLabel($st) . '未挂号,您已延迟 ' . $snooze . ' 次,请尽快联系患者挂号。';
return $text . self::SIGNATURE;
}
/**
* tick 抄送主管文案:含医助姓名、患者、超期天数、已延迟次数。
*
* @param array<string, mixed> $row
* @param array{state?:string, overdue_days?:int|null} $st
*/
public static function buildSupervisorText(string $assistantName, array $row, array $st): string
{
$assistantName = trim($assistantName);
$assistantName = $assistantName !== '' ? $assistantName : ('#' . (int) ($row['assistant_id'] ?? 0));
$name = trim((string) ($row['patient_name'] ?? ''));
$name = $name !== '' ? $name : '患者';
$slot = (int) ($row['current_slot'] ?? 0);
$due = self::dateStr($row['due_date'] ?? null);
$snooze = (int) ($row['snooze_count'] ?? 0);
$contact = (int) ($row['contact_count'] ?? 0);
$text = '【复诊到期升级】医助 ' . $assistantName . ' 的患者 ' . $name . ' ' . self::overdueLabel($st) . '未挂号';
$text .= '(' . ($slot > 0 ? '第' . $slot . '诊后' : '待复诊') . ($due !== '' ? ',预计复诊 ' . substr($due, 5) : '') . ')';
$text .= ',已延迟 ' . $snooze . ' 次' . ($contact > 0 ? '、已联系 ' . $contact . ' 次' : '') . ',请关注。';
return $text . self::SIGNATURE;
}
/**
* now 是否落在工作时段 [start, end)(复用 RevisitDueLogic::parseWorkHours 的归一规则,非法配置回落 09:00~20:00)。
*/
public static function withinWorkHours(int $now, mixed $workHours): bool
{
[$startMin, $endMin] = RevisitDueLogic::parseWorkHours($workHours);
$minuteOfDay = (int) date('G', $now) * 60 + (int) date('i', $now);
return $minuteOfDay >= $startMin && $minuteOfDay < $endMin;
}
/**
* 'Y-m-d' → 当日 [00:00:00, 23:59:59] 时间戳;非法日期按今天。
*
* @return array{0:int, 1:int}
*/
public static function dayRange(string $today): array
{
$d = \DateTimeImmutable::createFromFormat('!Y-m-d', substr(trim($today), 0, 10));
if ($d === false) {
$d = new \DateTimeImmutable('today');
}
$start = $d->getTimestamp();
return [$start, $start + 86399];
}
// ---------------------------------------------------------------- 内部:取数与写入
/**
* 到期未挂号 / 过号的进行中行(口径同 RevisitDueLogic::applyDueWhere,基准日用 --date)。
*
* @return \think\db\Query
*/
private function dueQuery(string $today)
{
return Db::name(RevisitDueService::TABLE)
->where('closed_state', RevisitDueCalculator::CLOSED_OPEN)
->where('assistant_id', '>', 0)
->whereRaw('(appointment_state = ' . RevisitDueCalculator::APPT_MISSED . ' OR (due_date IS NOT NULL AND due_date <= :d1))', ['d1' => $today])
->whereRaw('(promised_date IS NULL OR promised_date < :d2)', ['d2' => $today]);
}
/**
* 当日已发过 daily 汇总的医助 id 集合(event=8 且 payload.kind=daily)。
*
* @param list<int> $assistantIds
*
* @return array<int, true>
*/
private function dailySentAssistantIds(array $assistantIds, string $today): array
{
if ($assistantIds === []) {
return [];
}
[$start, $end] = self::dayRange($today);
$ids = Db::name(RevisitDueService::LOG_TABLE)
->where('event', DiagnosisRevisitDueLog::EVENT_WECOM_PUSH)
->whereIn('assistant_id', $assistantIds)
->whereBetween('create_time', [$start, $end])
->whereLike('payload', '%"kind":"' . self::KIND_DAILY . '"%')
->distinct(true)
->column('assistant_id');
$out = [];
foreach ($ids as $id) {
$out[(int) $id] = true;
}
return $out;
}
/**
* 批量取账号姓名与企微 userid。
*
* @param list<int> $adminIds
*
* @return array<int, array{name:string, work_wechat_userid:string}>
*/
private function fetchAdmins(array $adminIds): array
{
$adminIds = array_values(array_filter(array_map('intval', $adminIds), static fn (int $v): bool => $v > 0));
if ($adminIds === []) {
return [];
}
$out = [];
foreach (array_chunk($adminIds, 500) as $chunk) {
// 直接查表:Admin 模型的 append(role_id/dept_id 访问器)会逐行再查库
$rows = Db::name('admin')
->whereIn('id', $chunk)
->whereNull('delete_time')
->field(['id', 'name', 'work_wechat_userid'])
->select()
->toArray();
foreach ($rows as $r) {
$out[(int) $r['id']] = [
'name' => (string) ($r['name'] ?? ''),
'work_wechat_userid' => (string) ($r['work_wechat_userid'] ?? ''),
];
}
}
return $out;
}
/**
* daily:为汇总覆盖的每一行写一条 event=8 流水(主管视图逐行可见)。
*
* @param list<array<string, mixed>> $items
* @param array<string, mixed> $payload
*/
private function logRows(array $items, string $today, int $now, array $payload): void
{
foreach ($items as $row) {
RevisitDueService::writeLog(
(int) $row['id'],
(int) $row['diagnosis_id'],
(int) $row['assistant_id'],
(int) $row['source_order_id'],
DiagnosisRevisitDueLog::EVENT_WECOM_PUSH,
[
'snooze_count_after' => (int) ($row['snooze_count'] ?? 0),
'level_after' => (int) ($row['escalation_level'] ?? 0),
'overdue_days' => $this->overdueDaysForLog($row, $today),
'operator_id' => 0,
'create_time' => $now,
'payload' => $payload,
]
);
}
}
/**
* 流水 overdue_days:today − due_date(负=未到期),无 due_date 记 0。
*
* @param array<string, mixed> $row
*/
private function overdueDaysForLog(array $row, string $today): int
{
$due = self::dateStr($row['due_date'] ?? null);
return $due !== '' ? RevisitDueCalculator::dayDiff($due, $today) : 0;
}
/**
* @return array{rows:int, sent:int, failed:int, unbound:int, deduped:int, supervisor:int}
*/
private function emptyStats(): array
{
return ['rows' => 0, 'sent' => 0, 'failed' => 0, 'unbound' => 0, 'deduped' => 0, 'supervisor' => 0];
}
private function isValidDate(string $date): bool
{
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) {
return false;
}
$d = \DateTimeImmutable::createFromFormat('!Y-m-d', $date);
return $d !== false && $d->format('Y-m-d') === $date;
}
/** 日期列 → '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;
}
}