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
@@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\common\service\tcm\RevisitVisitSlotService;
use think\console\Command;
use think\console\Input;
use think\console\input\Option;
use think\console\Output;
use think\facade\Db;
use think\facade\Log;
/**
* 继承指派「继承时诊次」快照回填 / 对账(tcm_diagnosis_assign_log.inherit_visit_slot / inherit_visit_slot_calc_time)
*
* 使用方法:
* php think tcm:backfill-inherit-visit-slot --dry-run # 只计算、打印前 50 条拟写入,不写库
* php think tcm:backfill-inherit-visit-slot # 回填 calc_time=0 的继承行
* php think tcm:backfill-inherit-visit-slot --recalc # 全部继承行重算(含已回填)
* php think tcm:backfill-inherit-visit-slot --verify # 对账:快照 vs 实时重算,输出漂移清单,不写库
* php think tcm:backfill-inherit-visit-slot --month=2026-09 # 仅处理指派时间落在该月的继承行(可与以上组合)
*
* 口径(RevisitVisitSlotService::slotBefore,与 RevisitRateLogic / RevisitRateCalculator 完全一致):
* k = 继承时刻之前(严格小于 create_time)的有效业务订单数(剔除履约 4/9/10、软删)+ 诊单偏移(钳 0~20)。
* 统计口径永远实时重算,本列只用于指派记录面板展示与对账;事后退款/取消/改偏移会让快照与实时值漂移,属预期(--verify 列出)。
*
* 范围:is_inherit=1 AND to_assistant_id>0(to=0 的释放行 is_inherit 恒为 0)。按诊单分块 2000 批量取订单序列,不逐行查库。
* 异常:整体 try/catch 并 return 0(Crontab::start 捕获异常会把 dev_crontab.status 置 ERROR 停跑)。
*/
class BackfillInheritVisitSlot extends Command
{
/** 每批处理的日志行数(按诊单分块取订单) */
private const CHUNK_SIZE = 2000;
/** dry-run / verify 最多打印条数 */
private const PRINT_LIMIT = 50;
protected function configure()
{
$this->setName('tcm:backfill-inherit-visit-slot')
->setDescription('继承指派「继承时诊次」快照回填 / 对账(inherit_visit_slot)')
->addOption('dry-run', null, Option::VALUE_NONE, '只计算不写库,打印前 50 条拟写入')
->addOption('recalc', null, Option::VALUE_NONE, '重算全部继承行(默认只回填 calc_time=0 的行)')
->addOption('verify', null, Option::VALUE_NONE, '对账模式:输出快照 vs 实时漂移清单,不写库')
->addOption('month', null, Option::VALUE_OPTIONAL, '仅处理指派时间落在该月(YYYY-MM)的继承行', '');
}
protected function execute(Input $input, Output $output): int
{
$startedAt = microtime(true);
$now = time();
try {
$dryRun = (bool) $input->getOption('dry-run');
$recalc = (bool) $input->getOption('recalc');
$verify = (bool) $input->getOption('verify');
$month = trim((string) $input->getOption('month'));
if ($month !== '' && preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month) !== 1) {
$output->writeln('<error>无效 --month,格式应为 YYYY-MM</error>');
return 0;
}
$mode = $verify ? 'verify' : ($dryRun ? 'dry-run' : ($recalc ? 'recalc' : 'backfill'));
$output->writeln(sprintf(
'[%s] 继承时诊次快照 %s 开始%s',
date('Y-m-d H:i:s', $now),
$mode,
$month !== '' ? ',月份=' . $month : ''
));
$query = Db::name('tcm_diagnosis_assign_log')
->where('is_inherit', 1)
->where('to_assistant_id', '>', 0)
->where('diagnosis_id', '>', 0);
if ($month !== '') {
$startTs = (int) strtotime($month . '-01 00:00:00');
$endTs = (int) strtotime(date('Y-m-t', $startTs) . ' 23:59:59');
$query->where('create_time', '>=', $startTs)->where('create_time', '<=', $endTs);
}
if (!$verify && !$recalc) {
$query->where('inherit_visit_slot_calc_time', 0);
}
$rows = $query
->field(['id', 'diagnosis_id', 'to_assistant_id', 'create_time', 'inherit_visit_slot', 'inherit_visit_slot_calc_time'])
->order(['diagnosis_id' => 'asc', 'id' => 'asc'])
->select()
->toArray();
$total = \count($rows);
$output->writeln(sprintf('待处理继承行 %d 条', $total));
if ($total === 0) {
return 0;
}
$processed = 0;
$written = 0;
$drift = 0;
$printed = 0;
foreach (array_chunk($rows, self::CHUNK_SIZE) as $chunk) {
$diagIds = array_values(array_unique(array_map(static fn (array $r): int => (int) $r['diagnosis_id'], $chunk)));
$offsetMap = RevisitVisitSlotService::fetchRevisitSlotStartOffsetMap($diagIds);
/** @var array<int, list<int>> $ctsByDiag */
$ctsByDiag = [];
foreach (RevisitVisitSlotService::fetchOrderSeqRows($diagIds, ['o.diagnosis_id', 'o.create_time']) as $o) {
$ctsByDiag[(int) $o['diagnosis_id']][] = (int) $o['create_time'];
}
foreach ($chunk as $r) {
$processed++;
$logId = (int) $r['id'];
$did = (int) $r['diagnosis_id'];
$t = (int) $r['create_time'];
$realtime = min(255, RevisitVisitSlotService::slotBefore(
$ctsByDiag[$did] ?? [],
RevisitVisitSlotService::resolveRevisitSlotStartOffset($did, $offsetMap),
$t
));
$snapshot = (int) $r['inherit_visit_slot'];
$calcTime = (int) $r['inherit_visit_slot_calc_time'];
if ($verify) {
if ($calcTime > 0 && $snapshot === $realtime) {
continue;
}
$drift++;
if ($printed < self::PRINT_LIMIT) {
$printed++;
$output->writeln(sprintf(
' 漂移 log_id=%d diagnosis_id=%d 指派时间=%s 快照=%d 实时=%d 原因=%s',
$logId,
$did,
date('Y-m-d H:i:s', $t),
$snapshot,
$realtime,
self::driftReason($calcTime, $snapshot, $realtime)
));
}
continue;
}
if ($dryRun) {
if ($printed < self::PRINT_LIMIT) {
$printed++;
$output->writeln(sprintf(
' [演练] log_id=%d diagnosis_id=%d 指派时间=%s 当前快照=%d → 拟写入=%d',
$logId,
$did,
date('Y-m-d H:i:s', $t),
$snapshot,
$realtime
));
}
continue;
}
Db::name('tcm_diagnosis_assign_log')
->where('id', $logId)
->update([
'inherit_visit_slot' => $realtime,
'inherit_visit_slot_calc_time' => $now,
]);
$written++;
}
}
if ($verify) {
$output->writeln(sprintf(
'对账完成:检查 %d 条,漂移 %d 条%s,耗时 %.2fs',
$processed,
$drift,
$drift > self::PRINT_LIMIT ? sprintf('(仅打印前 %d 条)', self::PRINT_LIMIT) : '',
microtime(true) - $startedAt
));
} elseif ($dryRun) {
$output->writeln(sprintf('演练完成:应回填 %d 条(未落库),耗时 %.2fs', $processed, microtime(true) - $startedAt));
} else {
$output->writeln(sprintf('回填完成:处理 %d 条,写入 %d 条,耗时 %.2fs', $processed, $written, microtime(true) - $startedAt));
}
return 0;
} catch (\Throwable $e) {
Log::error('继承时诊次快照回填异常: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
$output->writeln('<error>执行异常: ' . $e->getMessage() . '</error>');
return 0;
}
}
/** 漂移原因(无历史快照时只能按方向推断) */
private static function driftReason(int $calcTime, int $snapshot, int $realtime): string
{
if ($calcTime <= 0) {
return '未回填(calc_time=0,加列前的历史继承行)';
}
if ($realtime > $snapshot) {
return '实时值更大:继承前订单被恢复/新增有效订单,或诊单偏移上调';
}
return '实时值更小:继承前订单被取消/退款/软删,或诊单偏移下调';
}
}