setName('tcm:revisit-due-scan') ->setDescription('复诊到期快照重算:full 全量 / incremental 增量(订单、挂号、指派、诊单变更 + 已挂号行重评)') ->addOption('mode', null, Option::VALUE_OPTIONAL, '扫描模式 full|incremental', 'full') ->addOption('dry-run', null, Option::VALUE_NONE, '只计算不写库,打印前 20 条拟写入行') ->addOption('diagnosis-id', null, Option::VALUE_OPTIONAL, '仅处理指定诊单 id(逗号分隔)', '') ->addOption('date', null, Option::VALUE_OPTIONAL, '基准日 Y-m-d(默认今天)', '') ->addOption('since-minutes', null, Option::VALUE_OPTIONAL, '增量窗口(分钟)', 120); } protected function execute(Input $input, Output $output): int { $startedAt = microtime(true); $now = time(); try { $mode = strtolower(trim((string) $input->getOption('mode'))); if ($mode === '') { $mode = 'full'; } if (!in_array($mode, ['full', 'incremental'], true)) { $output->writeln('无效 --mode,仅支持 full|incremental'); 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) { // --date 只用于基准日回放:以假基准日真正写库会把线上快照(已约是否过期、流水超期天数)算错 $output->writeln('--date 与今天不同,已自动切换为演练模式(--dry-run),不写库'); $dryRun = true; } $sinceMinutes = (int) $input->getOption('since-minutes'); if ($sinceMinutes <= 0) { $sinceMinutes = 120; } $explicitIds = $this->parseIdList((string) $input->getOption('diagnosis-id')); $output->writeln(sprintf( '[%s] 复诊到期快照重算 mode=%s date=%s%s%s', date('Y-m-d H:i:s', $now), $mode, $today, $dryRun ? ' (演练模式,不写库)' : '', $explicitIds !== [] ? ' diagnosis_id=' . implode(',', $explicitIds) : '' )); // 候选 if ($explicitIds !== []) { $ids = $explicitIds; } else { if (!$dryRun && !$this->acquireReentryGuard($mode, $now)) { $output->writeln(sprintf('同 mode 最近 %d 秒内已启动过一次,本次跳过。', self::REENTRY_GUARD_SECONDS)); return 0; } $sinceTs = $mode === 'incremental' ? $now - $sinceMinutes * 60 : 0; $ids = RevisitDueService::candidateDiagnosisIds($mode, $sinceTs); if ($mode === 'incremental') { $output->writeln(sprintf('增量窗口:自 %s 起', date('Y-m-d H:i:s', $sinceTs))); } } $total = \count($ids); $output->writeln('候选诊单:' . $total . ' 条'); if ($total === 0) { $output->writeln('无需处理。'); return 0; } $stats = ['refreshed' => 0, 'inserted' => 0, 'closed' => 0, 'reopened' => 0, 'no_days' => 0, 'chunks' => 0, 'errors' => 0]; $printed = 0; $cfg = RevisitDueService::config(); foreach (array_chunk($ids, self::CHUNK_SIZE) as $chunk) { $stats['chunks']++; try { $res = RevisitDueService::refreshDiagnoses($chunk, [ 'reason' => 'cron:' . $mode, 'dry_run' => $dryRun, 'now' => $now, 'today' => $today, ]); foreach (['refreshed', 'inserted', 'closed', 'reopened', 'no_days'] as $k) { $stats[$k] += (int) ($res[$k] ?? 0); } if ($dryRun) { foreach ((array) ($res['rows'] ?? []) as $row) { if ($printed >= self::DRY_RUN_PRINT_LIMIT) { break; } $output->writeln($this->formatDryRunRow($row, $today, $cfg)); $printed++; } } } catch (\Throwable $e) { $stats['errors']++; Log::error('tcm:revisit-due-scan 分块失败 mode=' . $mode . ' first_id=' . (int) ($chunk[0] ?? 0) . ' msg=' . $e->getMessage()); $output->writeln('分块失败(首 id ' . (int) ($chunk[0] ?? 0) . '):' . $e->getMessage() . ''); } } if ($dryRun && $total > $printed) { $output->writeln(sprintf('(演练仅打印前 %d 条,其余 %d 条未打印)', $printed, $total - $printed)); } $output->writeln(sprintf( '完成。候选 %d,刷新 %d(新增 %d),自动关闭 %d,重开 %d,缺天数 %d,分块 %d(失败 %d),耗时 %ss', $total, $stats['refreshed'], $stats['inserted'], $stats['closed'], $stats['reopened'], $stats['no_days'], $stats['chunks'], $stats['errors'], round(microtime(true) - $startedAt, 2) )); } catch (\Throwable $e) { Log::error('tcm:revisit-due-scan 执行异常: ' . $e->getMessage()); $output->writeln('执行异常:' . $e->getMessage() . ''); } return 0; } /** * 防重入:同 mode 最近一次开始时间距今 < 5 分钟则返回 false;否则记录本次开始时间。缓存异常时放行。 */ private function acquireReentryGuard(string $mode, int $now): bool { $key = self::CACHE_KEY_PREFIX . $mode; try { $last = (int) Cache::get($key, 0); if ($last > 0 && $now - $last < self::REENTRY_GUARD_SECONDS) { return false; } Cache::set($key, $now, self::REENTRY_GUARD_SECONDS); } catch (\Throwable $e) { Log::warning('tcm:revisit-due-scan 防重入标记读写失败,放行: ' . $e->getMessage()); } return true; } /** * @return list */ private function parseIdList(string $raw): array { $out = []; foreach (preg_split('/[\s,]+/', trim($raw)) ?: [] as $part) { $id = (int) $part; if ($id > 0 && !in_array($id, $out, true)) { $out[] = $id; } } return $out; } 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; } /** * dry-run 单行输出:诊单 / 医助 / 诊次 / 锚点 / 天数 / 预计复诊 / 挂号 / 周期状态 / 推导状态 / 事件 * * @param array $row 拟写入行(含 _events/_is_new) * @param array $cfg */ private function formatDryRunRow(array $row, string $today, array $cfg): string { $st = RevisitDueCalculator::deriveState($row, $today, $cfg); $anchorTs = (int) ($row['anchor_ts'] ?? 0); $events = (array) ($row['_events'] ?? []); return sprintf( ' diag=%d%s assistant=%d 患者=%s | 第%d诊(有效单%d,待复诊第%d诊) | 订单#%d %s | 锚点=%s(src=%d) 天数=%d(src=%d,档%d) 药尽=%s | 挂号=%d %s | closed=%d | %s[%s]%s%s', (int) ($row['diagnosis_id'] ?? 0), !empty($row['_is_new']) ? '(新增)' : '', (int) ($row['assistant_id'] ?? 0), (string) ($row['patient_name'] ?? ''), (int) ($row['current_slot'] ?? 0), (int) ($row['valid_order_count'] ?? 0), (int) ($row['next_slot'] ?? 0), (int) ($row['source_order_id'] ?? 0), (int) ($row['order_create_time'] ?? 0) > 0 ? date('Y-m-d', (int) $row['order_create_time']) : '-', $anchorTs > 0 ? date('Y-m-d', $anchorTs) : '-', (int) ($row['anchor_src'] ?? 0), (int) ($row['medication_days'] ?? 0), (int) ($row['days_src'] ?? 0), (int) ($row['rule_days'] ?? 0), (string) ($row['due_date'] ?? '') !== '' ? (string) $row['due_date'] : '-', (int) ($row['appointment_state'] ?? 0), (string) ($row['appointment_date'] ?? '') !== '' ? (string) $row['appointment_date'] : '-', (int) ($row['closed_state'] ?? 0), (string) $st['state'], (string) $st['state_text'], (int) $st['severe'] === 1 ? ' 深红' : '', $events !== [] ? ' events=' . implode(',', array_map('intval', $events)) : '' ); } }