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)) : ''
);
}
}