267 lines
12 KiB
PHP
267 lines
12 KiB
PHP
<?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)) : ''
|
||
);
|
||
}
|
||
}
|