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

267 lines
12 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\common\service\tcm\RevisitDueCalculator;
use app\common\service\tcm\RevisitDueService;
use think\console\Command;
use think\console\Input;
use think\console\input\Option;
use think\console\Output;
use think\facade\Cache;
use think\facade\Log;
/**
* 复诊到期快照重算(tcm_diagnosis_revisit_due)
*
* 使用方法:
* php think tcm:revisit-due-scan --mode=full # 每日全量(zyt_dev_crontab:30 8 * * *)
* php think tcm:revisit-due-scan --mode=incremental # 增量(每 30 分钟):近 N 分钟有订单/挂号/指派/诊单变更的诊单 + closed_state=2 行
* php think tcm:revisit-due-scan --dry-run --diagnosis-id=123 # 只计算不写库,打印拟写入行(抽样核对页面)
* php think tcm:revisit-due-scan --mode=full --date=2026-10-20 --dry-run # 以指定基准日回放状态
*
* 选项:
* --mode=full|incremental 默认 full
* --dry-run 只计算并打印前 20 条结果,不写库、不写流水、不更新防重入标记
* --diagnosis-id=1,2,3 仅处理指定诊单(逗号分隔),跳过候选扫描与防重入
* --date=Y-m-d 基准日(默认今天),影响「已约是否仍在未来」与打印的推导状态
* --since-minutes=120 增量窗口(分钟),仅 incremental 有效
*
* 防重入:同一 mode 上次开始时间距今 < 5 分钟则跳过(Cache 标记;钩子也会刷 computed_at,故不用 MAX(computed_at) 判断)。
* 异常:整体 try/catch 并 return 0、按块隔离(Crontab::start 捕获到异常会把 dev_crontab.status 置 ERROR 停跑)。
*/
class RevisitDueScan extends Command
{
/** 每批刷新的诊单数 */
private const CHUNK_SIZE = 500;
/** 防重入窗口(秒) */
private const REENTRY_GUARD_SECONDS = 300;
/** dry-run 最多打印条数 */
private const DRY_RUN_PRINT_LIMIT = 20;
/** 防重入缓存键前缀 */
private const CACHE_KEY_PREFIX = 'tcm_revisit_due_scan_last_start:';
protected function configure()
{
$this->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('<error>无效 --mode,仅支持 full|incremental</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) {
// --date 只用于基准日回放:以假基准日真正写库会把线上快照(已约是否过期、流水超期天数)算错
$output->writeln('<comment>--date 与今天不同,已自动切换为演练模式(--dry-run),不写库</comment>');
$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('<error>分块失败(首 id ' . (int) ($chunk[0] ?? 0) . '):' . $e->getMessage() . '</error>');
}
}
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('<error>执行异常:' . $e->getMessage() . '</error>');
}
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<int>
*/
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<string, mixed> $row 拟写入行(含 _events/_is_new)
* @param array<string, mixed> $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)) : ''
);
}
}