=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;
}
}