This commit is contained in:
gr
2026-10-15 11:03:54 +08:00
parent 70ddc1a70a
commit bb28d93cec
64 changed files with 12747 additions and 420 deletions
+266
View File
@@ -0,0 +1,266 @@
<?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)) : ''
);
}
}