=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('无效 --mode,仅支持 daily|tick'); return 0; } $dryRun = (bool) $input->getOption('dry-run'); $today = trim((string) $input->getOption('date')); if ($today !== '' && !$this->isValidDate($today)) { $output->writeln('无效 --date,格式应为 Y-m-d'); return 0; } if ($today === '') { $today = date('Y-m-d', $now); } elseif ($today !== date('Y-m-d', $now) && !$dryRun) { $output->writeln('--date 与今天不同,已自动切换为演练模式(--dry-run),不发送、不写库'); $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('执行异常:' . $e->getMessage() . ''); } return 0; } // ---------------------------------------------------------------- daily /** * 每日汇总:按医助分组,一人一条。 * * @param array $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(' 医助 %s(%d) 发送失败:%s', $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(' 医助 %d 处理异常:%s', $aid, $e->getMessage())); } } return $stats; } // ---------------------------------------------------------------- tick /** * tick:升级行推本人(每行每日 ≤ cap),达阈值的行每日抄送主管一次。 * * @param array $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('演练模式继续计算拟发送内容'); } $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(' due#%d 医助 %s(%d) 推送失败:%s', $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(' 未配置主管名单(project.revisit_reach.supervisor_admin_ids)或仅含医助本人,跳过抄送'); $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(' due#%d 抄送主管 %d 失败:%s', $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(' due#%d 处理异常:%s', $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 $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 $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 $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 */ 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 $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 $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 $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 $assistantIds * * @return array */ 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 $adminIds * * @return array */ 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> $items * @param array $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 $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; } }