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
+126 -17
View File
@@ -5,7 +5,9 @@ declare(strict_types=1);
namespace app\command;
use app\adminapi\logic\dept\DeptLogic;
use app\adminapi\logic\stats\RevisitRateCalculator;
use app\adminapi\logic\stats\RevisitRateLogic;
use app\common\service\tcm\RevisitDueHook;
use think\console\Command;
use think\console\Input;
use think\console\input\Option;
@@ -23,13 +25,19 @@ use think\facade\Log;
* 推荐 cron(每天 09:00 执行一次;或经 zyt_dev_crontab 表调度):
* 0 9 * * * cd /path/to/server && php think tcm:auto-assign-pending >> /var/log/zyt-auto-assign.log 2>&1
*
* 分配规则(依据上个自然月「二诊复诊接诊率」,与 RevisitRateLogic::overview 口径完全一致):
* 分配规则(依据上个自然月「二诊复诊接诊率」,取 RevisitRateLogic::overview(inherit_mode=slot) 的分档字段):
* - 接诊率 > 70% :第一优先档,当日每人最多 3 条
* - 接诊率 60% ~ 70% :第二档,当日每人最多 2 条
* - 接诊率 50% ~ 60% :第三档,当日每人最多 1 条
* - 接诊率 < 50% 或上月无被指派数据:不参与分配
* - 接诊率 < 50% 或上月无被指派数据(分母为 0 → null):不参与分配
* - 仅分配给「二中心」及其组织下级部门的在职医助(当前部门校验,调离二中心即不再参与)
*
* 分档口径(config project.revisit_rate.auto_assign_tier_field,方案 D1-f 过渡期):
* - visit2_rate_new_assign(默认,rate_version=v1):旧口径,分母仅新指派(is_inherit=0),与接诊率 v2 上线前完全一致;
* - visit2_rate(rate_version=v2):新口径,分母 = 新指派 + 起算诊次 ≤ 2 的继承;产品确认后改配置即可切换,不发版。
* 自动指派日志同时记录两套率(visit2_rate / visit2_rate_new_assign)与所用口径 rate_version;
* --dry-run 额外输出两套口径的分档名单对照,便于切换前核对。
*
* 轮询方式:按轮次分配,每轮内先 >70% 档每人 1 条,再 60%~70% 档每人 1 条,再 50%~60% 档每人 1 条;
* 一轮结束还有剩余待指派诊单则进入下一轮,直至待指派池为空或所有医助当日额度用尽。
* 例:当天 20 条,>70% 有 3 人、60%~70% 有 7 人、50%~60% 有 5 人 →
@@ -61,11 +69,26 @@ class AutoAssignPendingDiagnosis extends Command
self::TIER_50_60 => '50%~60%',
];
/** 可用的分档字段 => 口径版本(写入 auto_assign_log.rate_version) */
private const TIER_FIELDS = [
RevisitRateCalculator::FIELD_RATE_NEW_ASSIGN => 'v1',
'visit2_rate' => 'v2',
];
private const RATE_VERSION_LABELS = [
'v1' => '旧口径·分母仅新指派',
'v2' => '新口径·每列独立分母',
];
/** 本次执行的分档字段与口径版本(由 config 解析) */
private string $tierField = RevisitRateCalculator::FIELD_RATE_NEW_ASSIGN;
private string $rateVersion = 'v1';
protected function configure()
{
$this->setName('tcm:auto-assign-pending')
->setDescription('待分配诊单自动指派:按上月二诊复诊接诊率分档轮询分配,并写入自动指派日志')
->addOption('dry-run', null, Option::VALUE_NONE, '演练模式:只输出分配计划,不写库');
->addOption('dry-run', null, Option::VALUE_NONE, '演练模式:只输出分配计划与两套口径分档对照,不写库');
}
protected function execute(Input $input, Output $output): int
@@ -75,19 +98,27 @@ class AutoAssignPendingDiagnosis extends Command
$runDate = date('Y-m-d', $now);
$statMonth = date('Y-m', strtotime(date('Y-m-01', $now) . ' -1 month'));
$batchNo = date('YmdHis', $now) . str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT);
$this->tierField = $this->resolveTierField();
$this->rateVersion = self::TIER_FIELDS[$this->tierField];
$output->writeln(sprintf(
'[%s] 开始自动指派待分配诊单,批次=%s,统计月=%s%s',
'[%s] 开始自动指派待分配诊单,批次=%s,统计月=%s,分档口径=%s(%s)%s',
date('Y-m-d H:i:s', $now),
$batchNo,
$statMonth,
$this->rateVersion,
self::RATE_VERSION_LABELS[$this->rateVersion],
$dryRun ? '(演练模式,不写库)'
: ''
));
try {
// 1. 上月二诊复诊接诊率 → 医助分档
$tiers = $this->buildAssistantTiers($statMonth);
// 1. 上月二诊复诊接诊率(slot 口径 overview,同时含旧口径字段)→ 医助分档
$overview = RevisitRateLogic::overview([
'month' => $statMonth,
'inherit_mode' => RevisitRateCalculator::MODE_SLOT,
]);
$tiers = $this->buildAssistantTiers($overview, $this->tierField);
$tierTotal = array_sum(array_map('count', $tiers));
$output->writeln(sprintf(
'医助分档:>70%% 共 %d 人,60%%~70%% 共 %d 人,50%%~60%% 共 %d 人',
@@ -95,6 +126,9 @@ class AutoAssignPendingDiagnosis extends Command
\count($tiers[self::TIER_60_70]),
\count($tiers[self::TIER_50_60])
));
if ($dryRun) {
$this->printTierComparison($overview, $output);
}
// 2. 当日剩余额度(扣减当日已自动分配数,防止同日重复执行超额)
$remaining = $this->buildRemainingQuota($tiers, $runDate);
@@ -163,14 +197,16 @@ class AutoAssignPendingDiagnosis extends Command
'assistant_id' => $item['assistant']['id'],
'assistant_name' => $item['assistant']['name'],
'tier' => $item['tier'],
'visit2_rate' => $item['assistant']['rate'],
'visit2_rate' => $item['assistant']['visit2_rate'],
'visit2_rate_new_assign' => $item['assistant']['visit2_rate_new_assign'],
'round_no' => $item['round'],
'reason' => sprintf(
'已分配给医助[%s](ID:%d):上月(%s)二诊复诊接诊率 %.2f%%,档位[%s](日上限%d条),第 %d 轮轮询分得,当日该医助第 %d 条',
'已分配给医助[%s](ID:%d):上月(%s)二诊复诊接诊率 %.2f%%(%s),档位[%s](日上限%d条),第 %d 轮轮询分得,当日该医助第 %d 条',
$item['assistant']['name'],
$item['assistant']['id'],
$statMonth,
$item['assistant']['rate'],
self::RATE_VERSION_LABELS[$this->rateVersion],
self::TIER_LABELS[$item['tier']],
self::TIER_DAILY_CAPS[$item['tier']],
$item['round'],
@@ -215,28 +251,95 @@ class AutoAssignPendingDiagnosis extends Command
}
/**
* 按上月二诊复诊接诊率把医助分档(复用 RevisitRateLogic 口径),并剔除已禁用/已删除账号。
*
* @return array{gt70: list<array{id:int,name:string,rate:float}>, 60_70: list<array{id:int,name:string,rate:float}>, 50_60: list<array{id:int,name:string,rate:float}>}
* 分档字段白名单:config project.revisit_rate.auto_assign_tier_field ∈ {visit2_rate_new_assign, visit2_rate},非法回退旧口径。
*/
private function buildAssistantTiers(string $statMonth): array
private function resolveTierField(): string
{
$overview = RevisitRateLogic::overview(['month' => $statMonth]);
$raw = trim((string) (config('project.revisit_rate.auto_assign_tier_field') ?? ''));
/** @var list<array{id:int,name:string,rate:float}> $candidates */
return isset(self::TIER_FIELDS[$raw]) ? $raw : RevisitRateCalculator::FIELD_RATE_NEW_ASSIGN;
}
/**
* 演练输出:两套口径(v1 旧 / v2 新)的分档名单对照,标出档位变动的医助。
*
* @param array<string, mixed> $overview RevisitRateLogic::overview(inherit_mode=slot) 返回体
*/
private function printTierComparison(array $overview, Output $output): void
{
$byField = [];
foreach (self::TIER_FIELDS as $field => $version) {
$byField[$version] = $this->buildAssistantTiers($overview, $field);
}
$output->writeln('[演练] 两套口径分档对照(v1=旧口径·分母仅新指派,v2=新口径·每列独立分母):');
/** @var array<int, array{name:string,v1:string,v2:string,r1:?float,r2:?float}> $perAssistant */
$perAssistant = [];
foreach ($byField as $version => $tiers) {
foreach ($tiers as $tier => $list) {
$names = array_map(static fn (array $a): string => sprintf('%s(%.2f%%)', $a['name'], $a['rate']), $list);
$output->writeln(sprintf(' [%s] %-8s %d 人:%s', $version, self::TIER_LABELS[$tier], \count($list), $names === [] ? '—' : implode('、', $names)));
foreach ($list as $a) {
$perAssistant[$a['id']] ??= ['name' => $a['name'], 'v1' => '', 'v2' => '', 'r1' => null, 'r2' => null];
$perAssistant[$a['id']][$version] = $tier;
$perAssistant[$a['id']][$version === 'v1' ? 'r1' : 'r2'] = $a['rate'];
}
}
}
$changed = [];
foreach ($perAssistant as $aid => $info) {
if ($info['v1'] !== $info['v2']) {
$changed[] = sprintf(
'%s(ID:%d) v1=%s%s → v2=%s%s',
$info['name'],
$aid,
$info['v1'] !== '' ? self::TIER_LABELS[$info['v1']] : '不参与',
$info['r1'] !== null ? sprintf('(%.2f%%)', $info['r1']) : '',
$info['v2'] !== '' ? self::TIER_LABELS[$info['v2']] : '不参与',
$info['r2'] !== null ? sprintf('(%.2f%%)', $info['r2']) : ''
);
}
}
$output->writeln(sprintf(
' 档位变动 %d 人:%s',
\count($changed),
$changed === [] ? '无(两套口径分档一致)' : implode(';', $changed)
));
$output->writeln(sprintf(' 本次实际采用:%s(%s)', $this->rateVersion, $this->tierField));
}
/**
* 按上月二诊复诊接诊率把医助分档(取 overview 行的 $tierField 字段),并剔除已禁用/已删除账号。
* 每位候选同时携带两套率(visit2_rate=v2、visit2_rate_new_assign=v1),供日志回溯。
*
* @param array<string, mixed> $overview RevisitRateLogic::overview(inherit_mode=slot) 返回体
* @param string $tierField 分档字段(self::TIER_FIELDS 键)
*
* @return array{gt70: list<array{id:int,name:string,rate:float,visit2_rate:?float,visit2_rate_new_assign:?float}>, 60_70: list<array{id:int,name:string,rate:float,visit2_rate:?float,visit2_rate_new_assign:?float}>, 50_60: list<array{id:int,name:string,rate:float,visit2_rate:?float,visit2_rate_new_assign:?float}>}
*/
private function buildAssistantTiers(array $overview, string $tierField): array
{
if (!isset(self::TIER_FIELDS[$tierField])) {
$tierField = RevisitRateCalculator::FIELD_RATE_NEW_ASSIGN;
}
/** @var list<array{id:int,name:string,rate:float,visit2_rate:?float,visit2_rate_new_assign:?float}> $candidates */
$candidates = [];
foreach ($overview['rows'] ?? [] as $deptRow) {
foreach ($deptRow['children'] ?? [] as $row) {
$aid = (int) ($row['assistant_id'] ?? 0);
$rate = $row['visit2_rate'] ?? null;
// 上月无被指派数据(rate=null)或接诊率低于 50% 的医助不参与分配
$rate = $row[$tierField] ?? null;
// 上月无被指派数据(分母 0 → rate=null)或接诊率低于 50% 的医助不参与分配
if ($aid <= 0 || $rate === null || (float) $rate < 50.0) {
continue;
}
$v2 = $row['visit2_rate'] ?? null;
$v1 = $row[RevisitRateCalculator::FIELD_RATE_NEW_ASSIGN] ?? null;
$candidates[] = [
'id' => $aid,
'name' => (string) ($row['assistant_name'] ?? ('#' . $aid)),
'rate' => (float) $rate,
'visit2_rate' => $v2 === null ? null : (float) $v2,
'visit2_rate_new_assign' => $v1 === null ? null : (float) $v1,
];
}
}
@@ -520,6 +623,9 @@ class AutoAssignPendingDiagnosis extends Command
Db::commit();
// 事务提交后刷新复诊到期快照(自动分配 = 换医助;钩子内部吞异常,不影响分配结果)
RevisitDueHook::onAssistantChanged($diagnosisId, $toAssistantId, 'auto_assign');
return true;
} catch (\Throwable $e) {
Db::rollback();
@@ -613,7 +719,7 @@ class AutoAssignPendingDiagnosis extends Command
/**
* @param array<string,mixed> $diag 诊单行(含 id/patient_name/phone)
* @param array<string,mixed> $extra action/assistant_id/assistant_name/tier/visit2_rate/round_no/reason
* @param array<string,mixed> $extra action/assistant_id/assistant_name/tier/visit2_rate/visit2_rate_new_assign/round_no/reason
*
* @return array<string,mixed>
*/
@@ -630,7 +736,10 @@ class AutoAssignPendingDiagnosis extends Command
'assistant_id' => (int) ($extra['assistant_id'] ?? 0),
'assistant_name' => mb_substr((string) ($extra['assistant_name'] ?? ''), 0, 64),
'tier' => (string) ($extra['tier'] ?? ''),
// 两套口径的二诊率同时落库:visit2_rate=新口径 v2,visit2_rate_new_assign=旧口径 v1;rate_version 标识本批次分档所用口径
'visit2_rate' => $extra['visit2_rate'] ?? null,
'visit2_rate_new_assign' => $extra['visit2_rate_new_assign'] ?? null,
'rate_version' => $this->rateVersion,
'round_no' => (int) ($extra['round_no'] ?? 0),
'reason' => mb_substr((string) ($extra['reason'] ?? ''), 0, 500),
'create_time' => $now,
@@ -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 '实时值更小:继承前订单被取消/退款/软删,或诊单偏移下调';
}
}
+780
View File
@@ -0,0 +1,780 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\adminapi\logic\tcm\RevisitDueLogic;
use app\common\model\tcm\DiagnosisRevisitDueLog;
use app\common\service\tcm\RevisitDueCalculator;
use app\common\service\tcm\RevisitDueService;
use app\common\service\wechat\WechatWorkAppMessageService;
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;
/**
* 复诊到期提醒 - 企业微信推送(方案 §5.2,P2;project.revisit_reach.wecom_enabled=false 时空跑)
*
* 使用方法:
* php think tcm:revisit-due-notify --mode=daily # 每日 09:00:每位有「到期未挂号 / 过号」行的医助一条汇总
* php think tcm:revisit-due-notify --mode=tick # 每 30 分钟:escalation_level>=3 且到提醒时刻的行推本人(每行每日 ≤ wecom_daily_cap),
* # snooze_count>=阈值 的行每日抄送一次配置名单主管(supervisor_admin_ids)
* php think tcm:revisit-due-notify --mode=daily --dry-run # 只打印拟发送内容,不调企微、不写流水、不写回计数
* php think tcm:revisit-due-notify --mode=daily --date=2026-10-20 --dry-run # 以指定基准日回放
*
* 选项:
* --mode=daily|tick 默认 daily
* --dry-run 只打印拟发送内容
* --date=Y-m-d 基准日(默认今天);与今天不同时自动切换演练模式
*
* 约定:
* - 推送只在本命令内发(接口不同步 curl);逐条 usleep(200ms);每条 try/catch;整体 try/catch 并 return 0
* (Crontab::start 捕获到异常会把 dev_crontab.status 置 ERROR 停跑);
* - 接收人取 admin.work_wechat_userid(同 DiagnosisTodoNotify),未绑定则跳过并写 event=8 流水(payload.skipped=unbound,不算失败);
* - daily 去重:当日已有 event=8 且 payload.kind=daily 的医助不再发(流水判断,不改表);
* - tick 本人:wecom_self_at / wecom_self_count_day 日切重置后计数,达到 wecom_daily_cap 不再推;
* - tick 主管:wecom_supervisor_at 与今天同日则不再抄送;主管名单剔除医助本人并去重。
*
* 纯函数(isSameDay / selfSentToday / selfQuotaLeft / shouldCcSupervisor / normalizeSupervisorIds / overdueLabel /
* buildDailyText / buildSelfText / buildSupervisorText / withinWorkHours / dayRange)不连库、不读 config,
* 供 server/tests/RevisitDueNotifyTest.php 直接单测。
*/
class RevisitDueNotify extends Command
{
/** 流水 payload.kind:每日汇总 */
public const KIND_DAILY = 'daily';
/** 流水 payload.kind:tick 推本人 */
public const KIND_SELF = 'self';
/** 流水 payload.kind:tick 抄送主管 */
public const KIND_SUPERVISOR = 'supervisor';
/** 文案落款 */
public const SIGNATURE = '— 二中心跟踪系统';
/** 每日汇总最多列出的患者数(配置 wecom_daily_list_max 缺省值) */
public const DAILY_LIST_MAX_DEFAULT = 5;
/** 抄送主管的延迟次数阈值(配置 wecom_supervisor_snooze_min 缺省值) */
public const SUPERVISOR_SNOOZE_MIN_DEFAULT = 5;
/** 每条每日推送本人上限缺省值(配置 wecom_daily_cap 缺省值) */
public const DAILY_CAP_DEFAULT = 2;
/** tick 单次最多处理行数 */
private const TICK_ROW_LIMIT = 500;
/** 逐条发送间隔(微秒) */
private const SEND_GAP_USEC = 200000;
/** 进入推送的展示状态 */
private const PUSH_STATES = [RevisitDueCalculator::STATE_OVERDUE, RevisitDueCalculator::STATE_MISSED];
protected function configure()
{
$this->setName('tcm:revisit-due-notify')
->setDescription('复诊到期提醒企微推送:daily 每日汇总推医助本人 / tick 升级行推本人并抄送主管(wecom_enabled=false 时空跑)')
->addOption('mode', null, Option::VALUE_OPTIONAL, '推送模式 daily|tick', 'daily')
->addOption('dry-run', null, Option::VALUE_NONE, '只打印拟发送内容,不调企微、不写流水')
->addOption('date', null, Option::VALUE_OPTIONAL, '基准日 Y-m-d(默认今天)', '');
}
protected function execute(Input $input, Output $output): int
{
$startedAt = microtime(true);
$now = time();
try {
$mode = strtolower(trim((string) $input->getOption('mode')));
if ($mode === '') {
$mode = 'daily';
}
if (!in_array($mode, ['daily', 'tick'], true)) {
$output->writeln('<error>无效 --mode,仅支持 daily|tick</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) {
$output->writeln('<comment>--date 与今天不同,已自动切换为演练模式(--dry-run),不发送、不写库</comment>');
$dryRun = true;
}
$cfg = RevisitDueService::config();
$output->writeln(sprintf(
'[%s] 复诊到期企微推送 mode=%s date=%s%s',
date('Y-m-d H:i:s', $now),
$mode,
$today,
$dryRun ? ' (演练模式,不发送)' : ''
));
if (empty($cfg['wecom_enabled'])) {
$output->writeln('企微推送未启用(project.revisit_reach.wecom_enabled=false),本次不处理。');
return 0;
}
$stats = $mode === 'daily'
? $this->runDaily($output, $cfg, $today, $now, $dryRun)
: $this->runTick($output, $cfg, $today, $now, $dryRun);
$output->writeln(sprintf(
'完成。候选行 %d,发送 %d,失败 %d,未绑定跳过 %d,去重跳过 %d,抄送主管 %d,耗时 %ss',
$stats['rows'],
$stats['sent'],
$stats['failed'],
$stats['unbound'],
$stats['deduped'],
$stats['supervisor'],
round(microtime(true) - $startedAt, 2)
));
} catch (\Throwable $e) {
Log::error('tcm:revisit-due-notify 执行异常: ' . $e->getMessage());
$output->writeln('<error>执行异常:' . $e->getMessage() . '</error>');
}
return 0;
}
// ---------------------------------------------------------------- daily
/**
* 每日汇总:按医助分组,一人一条。
*
* @param array<string, mixed> $cfg
*
* @return array{rows:int, sent:int, failed:int, unbound:int, deduped:int, supervisor:int}
*/
private function runDaily(Output $output, array $cfg, string $today, int $now, bool $dryRun): array
{
$stats = $this->emptyStats();
$listMax = max(1, (int) ($cfg['wecom_daily_list_max'] ?? self::DAILY_LIST_MAX_DEFAULT));
$rows = $this->dueQuery($today)
->order('assistant_id ASC, escalation_level DESC, due_date ASC, id ASC')
->select()
->toArray();
$stats['rows'] = \count($rows);
if ($rows === []) {
$output->writeln('无到期未挂号 / 过号行。');
return $stats;
}
// 按医助分组,只保留读取侧推导为 超期 / 过号 的行
$groups = [];
foreach ($rows as $row) {
$st = RevisitDueCalculator::deriveState($row, $today, $cfg);
if (!in_array($st['state'], self::PUSH_STATES, true)) {
continue;
}
$row['_st'] = $st;
$groups[(int) $row['assistant_id']][] = $row;
}
if ($groups === []) {
$output->writeln('候选行经状态推导后均不需提醒。');
return $stats;
}
$assistantIds = array_keys($groups);
// 当日已发过汇总的医助(流水判断:event=8 且 payload.kind=daily)
$sentToday = $this->dailySentAssistantIds($assistantIds, $today);
$admins = $this->fetchAdmins($assistantIds);
foreach ($groups as $aid => $items) {
try {
if (isset($sentToday[$aid])) {
$stats['deduped']++;
$output->writeln(sprintf(' 医助 %d 今日已发过汇总,跳过(%d 行)', $aid, \count($items)));
continue;
}
$text = self::buildDailyText(array_map(static fn (array $r): array => [
'patient_name' => (string) ($r['patient_name'] ?? ''),
'state' => (string) $r['_st']['state'],
'overdue_days' => $r['_st']['overdue_days'],
], $items), $listMax);
$admin = $admins[$aid] ?? null;
$wxId = trim((string) ($admin['work_wechat_userid'] ?? ''));
$name = (string) ($admin['name'] ?? ('#' . $aid));
if ($dryRun) {
$output->writeln(sprintf(' [拟发送 daily] 医助 %s(%d) → %s | %s', $name, $aid, $wxId !== '' ? $wxId : '(未绑定)', $text));
$wxId !== '' ? $stats['sent']++ : $stats['unbound']++;
continue;
}
$payloadBase = ['kind' => self::KIND_DAILY, 'to' => $wxId, 'n' => \count($items)];
if ($wxId === '') {
$stats['unbound']++;
$output->writeln(sprintf(' 医助 %s(%d) 未绑定企微 userid,跳过(%d 行)', $name, $aid, \count($items)));
$this->logRows($items, $today, $now, $payloadBase + ['ok' => false, 'skipped' => 'unbound']);
continue;
}
$res = WechatWorkAppMessageService::sendTextToUser($wxId, $text);
$ok = (bool) ($res['ok'] ?? false);
$payload = $payloadBase + ['ok' => $ok];
if (!$ok) {
$payload['errcode'] = (int) ($res['errcode'] ?? -1);
$payload['msg'] = mb_substr((string) ($res['message'] ?? ''), 0, 60);
$stats['failed']++;
$output->writeln(sprintf(' <error>医助 %s(%d) 发送失败:%s</error>', $name, $aid, (string) ($res['message'] ?? '')));
} else {
$stats['sent']++;
$output->writeln(sprintf(' 医助 %s(%d) 已发送汇总(%d 行)', $name, $aid, \count($items)));
}
$this->logRows($items, $today, $now, $payload);
usleep(self::SEND_GAP_USEC);
} catch (\Throwable $e) {
$stats['failed']++;
Log::error('tcm:revisit-due-notify daily 单医助处理异常 assistant_id=' . $aid . ' msg=' . $e->getMessage());
$output->writeln(sprintf(' <error>医助 %d 处理异常:%s</error>', $aid, $e->getMessage()));
}
}
return $stats;
}
// ---------------------------------------------------------------- tick
/**
* tick:升级行推本人(每行每日 ≤ cap),达阈值的行每日抄送主管一次。
*
* @param array<string, mixed> $cfg
*
* @return array{rows:int, sent:int, failed:int, unbound:int, deduped:int, supervisor:int}
*/
private function runTick(Output $output, array $cfg, string $today, int $now, bool $dryRun): array
{
$stats = $this->emptyStats();
$cap = max(0, (int) ($cfg['wecom_daily_cap'] ?? self::DAILY_CAP_DEFAULT));
$minSnooze = max(1, (int) ($cfg['wecom_supervisor_snooze_min'] ?? self::SUPERVISOR_SNOOZE_MIN_DEFAULT));
if (!self::withinWorkHours($now, $cfg['work_hours'] ?? null)) {
$output->writeln('当前不在工作时段(work_hours),tick 不推送。');
if (!$dryRun) {
return $stats;
}
$output->writeln('<comment>演练模式继续计算拟发送内容</comment>');
}
$rows = $this->dueQuery($today)
->where('escalation_level', '>=', 3)
->where('next_remind_at', '<=', $now)
->order('escalation_level DESC, due_date ASC, id ASC')
->limit(self::TICK_ROW_LIMIT)
->select()
->toArray();
$stats['rows'] = \count($rows);
if ($rows === []) {
$output->writeln('无需推送的升级行。');
return $stats;
}
$assistantIds = array_values(array_unique(array_map(static fn (array $r): int => (int) $r['assistant_id'], $rows)));
$supervisorPool = self::normalizeSupervisorIds($cfg['supervisor_admin_ids'] ?? []);
$admins = $this->fetchAdmins(array_values(array_unique(array_merge($assistantIds, $supervisorPool))));
$warnedNoSupervisor = false;
foreach ($rows as $row) {
$id = (int) $row['id'];
$aid = (int) $row['assistant_id'];
try {
$st = RevisitDueCalculator::deriveState($row, $today, $cfg);
if (!in_array($st['state'], self::PUSH_STATES, true)) {
continue;
}
$admin = $admins[$aid] ?? null;
$wxId = trim((string) ($admin['work_wechat_userid'] ?? ''));
$name = (string) ($admin['name'] ?? ('#' . $aid));
$patient = trim((string) ($row['patient_name'] ?? ''));
$logBase = [
'snooze_count_after' => (int) ($row['snooze_count'] ?? 0),
'level_after' => (int) ($row['escalation_level'] ?? 0),
'overdue_days' => $this->overdueDaysForLog($row, $today),
'operator_id' => 0,
'create_time' => $now,
];
// ---- 推本人(每行每日 ≤ cap) ----
$sentCnt = self::selfSentToday($row, $now);
$left = self::selfQuotaLeft($row, $now, $cap);
if ($left <= 0) {
$stats['deduped']++;
} else {
$text = self::buildSelfText($row, $st);
if ($dryRun) {
$output->writeln(sprintf(' [拟发送 self] due#%d 医助 %s(%d) → %s | %s', $id, $name, $aid, $wxId !== '' ? $wxId : '(未绑定)', $text));
$wxId !== '' ? $stats['sent']++ : $stats['unbound']++;
} else {
$payload = ['kind' => self::KIND_SELF, 'to' => $wxId, 'seq' => $sentCnt + 1, 'cap' => $cap];
if ($wxId === '') {
$stats['unbound']++;
$payload += ['ok' => false, 'skipped' => 'unbound'];
$output->writeln(sprintf(' due#%d 医助 %s(%d) 未绑定企微 userid,跳过', $id, $name, $aid));
} else {
$res = WechatWorkAppMessageService::sendTextToUser($wxId, $text);
$ok = (bool) ($res['ok'] ?? false);
$payload['ok'] = $ok;
if ($ok) {
$stats['sent']++;
$output->writeln(sprintf(' due#%d 医助 %s(%d) 患者 %s 已推送本人(今日第 %d 次)', $id, $name, $aid, $patient, $sentCnt + 1));
} else {
$stats['failed']++;
$payload['errcode'] = (int) ($res['errcode'] ?? -1);
$payload['msg'] = mb_substr((string) ($res['message'] ?? ''), 0, 60);
$output->writeln(sprintf(' <error>due#%d 医助 %s(%d) 推送失败:%s</error>', $id, $name, $aid, (string) ($res['message'] ?? '')));
}
usleep(self::SEND_GAP_USEC);
}
// 未绑定 / 失败同样计入当日次数,避免每 30 分钟重复刷流水
Db::name(RevisitDueService::TABLE)->where('id', $id)->update([
'wecom_self_at' => $now,
'wecom_self_count_day' => min(255, $sentCnt + 1),
'update_time' => $now,
]);
RevisitDueService::writeLog($id, (int) $row['diagnosis_id'], $aid, (int) $row['source_order_id'], DiagnosisRevisitDueLog::EVENT_WECOM_PUSH, $logBase + ['payload' => $payload]);
}
}
// ---- 抄送主管(每行每日一次) ----
if (!self::shouldCcSupervisor($row, $now, $minSnooze)) {
continue;
}
$supervisors = self::normalizeSupervisorIds($supervisorPool, $aid);
if ($supervisors === []) {
if (!$warnedNoSupervisor) {
$output->writeln('<comment> 未配置主管名单(project.revisit_reach.supervisor_admin_ids)或仅含医助本人,跳过抄送</comment>');
$warnedNoSupervisor = true;
}
continue;
}
$supText = self::buildSupervisorText($name, $row, $st);
$toList = [];
$okCnt = 0;
$failCnt = 0;
$firstErr = null;
foreach ($supervisors as $sid) {
$sWx = trim((string) ($admins[$sid]['work_wechat_userid'] ?? ''));
if ($sWx === '') {
$output->writeln(sprintf(' 主管 %d 未绑定企微 userid,跳过', $sid));
continue;
}
$toList[] = $sWx;
if ($dryRun) {
$output->writeln(sprintf(' [拟发送 supervisor] due#%d → 主管 %d(%s) | %s', $id, $sid, $sWx, $supText));
$okCnt++;
continue;
}
try {
$res = WechatWorkAppMessageService::sendTextToUser($sWx, $supText);
if (!empty($res['ok'])) {
$okCnt++;
} else {
$failCnt++;
$firstErr ??= (int) ($res['errcode'] ?? -1);
$output->writeln(sprintf(' <error>due#%d 抄送主管 %d 失败:%s</error>', $id, $sid, (string) ($res['message'] ?? '')));
}
} catch (\Throwable $e) {
$failCnt++;
Log::error('tcm:revisit-due-notify 抄送主管异常 due_id=' . $id . ' supervisor=' . $sid . ' msg=' . $e->getMessage());
}
usleep(self::SEND_GAP_USEC);
}
if ($dryRun) {
$stats['supervisor'] += $okCnt > 0 ? 1 : 0;
continue;
}
$payload = ['kind' => self::KIND_SUPERVISOR, 'to' => implode(',', $toList), 'ok' => $failCnt === 0 && $okCnt > 0, 'sent' => $okCnt, 'fail' => $failCnt];
if ($toList === []) {
$payload['skipped'] = 'unbound';
}
if ($firstErr !== null) {
$payload['errcode'] = $firstErr;
}
// 不论成败,当日只抄送一次
Db::name(RevisitDueService::TABLE)->where('id', $id)->update(['wecom_supervisor_at' => $now, 'update_time' => $now]);
RevisitDueService::writeLog($id, (int) $row['diagnosis_id'], $aid, (int) $row['source_order_id'], DiagnosisRevisitDueLog::EVENT_WECOM_PUSH, $logBase + ['payload' => $payload]);
if ($okCnt > 0) {
$stats['supervisor']++;
$output->writeln(sprintf(' due#%d 已抄送主管 %d 人(医助 %s,患者 %s,延迟 %d 次)', $id, $okCnt, $name, $patient, (int) $row['snooze_count']));
}
} catch (\Throwable $e) {
$stats['failed']++;
Log::error('tcm:revisit-due-notify tick 单行处理异常 due_id=' . $id . ' msg=' . $e->getMessage());
$output->writeln(sprintf(' <error>due#%d 处理异常:%s</error>', $id, $e->getMessage()));
}
}
return $stats;
}
// ---------------------------------------------------------------- 纯函数
/**
* 两个时间戳是否同一自然日(ts<=0 视为从未发生 → false)。
*/
public static function isSameDay(int $ts, int $now): bool
{
if ($ts <= 0) {
return false;
}
return date('Y-m-d', $ts) === date('Y-m-d', $now);
}
/**
* 当日已推送本人次数:wecom_self_at 与 now 同日取 wecom_self_count_day,否则日切归 0。
*
* @param array<string, mixed> $row
*/
public static function selfSentToday(array $row, int $now): int
{
$at = (int) ($row['wecom_self_at'] ?? 0);
if (!self::isSameDay($at, $now)) {
return 0;
}
return max(0, (int) ($row['wecom_self_count_day'] ?? 0));
}
/**
* 当日剩余可推本人次数(cap<=0 表示不推)。
*
* @param array<string, mixed> $row
*/
public static function selfQuotaLeft(array $row, int $now, int $cap): int
{
if ($cap <= 0) {
return 0;
}
return max(0, $cap - self::selfSentToday($row, $now));
}
/**
* 是否需要抄送主管:snooze_count >= minSnooze 且今天尚未抄送(wecom_supervisor_at 日切)。
*
* @param array<string, mixed> $row
*/
public static function shouldCcSupervisor(array $row, int $now, int $minSnooze): bool
{
if ((int) ($row['snooze_count'] ?? 0) < max(1, $minSnooze)) {
return false;
}
return !self::isSameDay((int) ($row['wecom_supervisor_at'] ?? 0), $now);
}
/**
* 主管名单归一:容忍逗号字符串 / 数组,转正整数、去重、剔除医助本人。
*
* @return list<int>
*/
public static function normalizeSupervisorIds(mixed $ids, int $excludeAdminId = 0): array
{
if (is_string($ids)) {
$ids = preg_split('/[\s,]+/', trim($ids)) ?: [];
}
if (!is_array($ids)) {
return [];
}
$out = [];
foreach ($ids as $v) {
$id = (int) $v;
if ($id > 0 && $id !== $excludeAdminId && !in_array($id, $out, true)) {
$out[] = $id;
}
}
return $out;
}
/**
* 超期标签:过号 → 「过号」(有超期天数再接「·超期N天」);超期 N 天 → 「超期N天」;0 → 「今日应挂号」。
*
* @param array{state?:string, overdue_days?:int|null} $st deriveState 结果
*/
public static function overdueLabel(array $st): string
{
$overdue = $st['overdue_days'] ?? null;
$overdue = $overdue === null ? null : (int) $overdue;
if (($st['state'] ?? '') === RevisitDueCalculator::STATE_MISSED) {
return '过号' . ($overdue !== null && $overdue > 0 ? '·超期' . $overdue . '天' : '');
}
if ($overdue === null) {
return '到期';
}
return $overdue > 0 ? '超期' . $overdue . '天' : '今日应挂号';
}
/**
* 每日汇总文案:按超期天数降序列出前 listMax 人。
*
* @param list<array{patient_name:string, state:string, overdue_days:int|null}> $items
*/
public static function buildDailyText(array $items, int $listMax = self::DAILY_LIST_MAX_DEFAULT): string
{
$n = \count($items);
if ($n === 0) {
return '';
}
$listMax = max(1, $listMax);
usort($items, static function (array $a, array $b): int {
return (int) ($b['overdue_days'] ?? 0) <=> (int) ($a['overdue_days'] ?? 0);
});
$maxOverdue = 0;
foreach ($items as $it) {
$maxOverdue = max($maxOverdue, (int) ($it['overdue_days'] ?? 0));
}
$parts = [];
foreach (array_slice($items, 0, $listMax) as $it) {
$name = trim((string) ($it['patient_name'] ?? ''));
$parts[] = ($name !== '' ? $name : '患者') . '(' . self::overdueLabel($it) . ')';
}
$text = '【复诊到期提醒】您有 ' . $n . ' 位患者到期未挂号';
if ($maxOverdue > 0) {
$text .= ',最久超期 ' . $maxOverdue . ' 天';
}
$text .= ':' . implode('、', $parts);
if ($n > $listMax) {
$text .= '…等 ' . $n . ' 人';
}
return $text . ',请尽快联系患者挂号。' . self::SIGNATURE;
}
/**
* tick 推本人文案。
*
* @param array<string, mixed> $row 快照行
* @param array{state?:string, overdue_days?:int|null} $st deriveState 结果
*/
public static function buildSelfText(array $row, array $st): string
{
$name = trim((string) ($row['patient_name'] ?? ''));
$name = $name !== '' ? $name : '患者';
$slot = (int) ($row['current_slot'] ?? 0);
$due = self::dateStr($row['due_date'] ?? null);
$snooze = (int) ($row['snooze_count'] ?? 0);
$text = '【复诊到期提醒】患者 ' . $name;
$text .= '(' . ($slot > 0 ? '第' . $slot . '诊后' : '待复诊') . ($due !== '' ? ',预计复诊 ' . substr($due, 5) : '') . ')';
$text .= self::overdueLabel($st) . '未挂号,您已延迟 ' . $snooze . ' 次,请尽快联系患者挂号。';
return $text . self::SIGNATURE;
}
/**
* tick 抄送主管文案:含医助姓名、患者、超期天数、已延迟次数。
*
* @param array<string, mixed> $row
* @param array{state?:string, overdue_days?:int|null} $st
*/
public static function buildSupervisorText(string $assistantName, array $row, array $st): string
{
$assistantName = trim($assistantName);
$assistantName = $assistantName !== '' ? $assistantName : ('#' . (int) ($row['assistant_id'] ?? 0));
$name = trim((string) ($row['patient_name'] ?? ''));
$name = $name !== '' ? $name : '患者';
$slot = (int) ($row['current_slot'] ?? 0);
$due = self::dateStr($row['due_date'] ?? null);
$snooze = (int) ($row['snooze_count'] ?? 0);
$contact = (int) ($row['contact_count'] ?? 0);
$text = '【复诊到期升级】医助 ' . $assistantName . ' 的患者 ' . $name . ' ' . self::overdueLabel($st) . '未挂号';
$text .= '(' . ($slot > 0 ? '第' . $slot . '诊后' : '待复诊') . ($due !== '' ? ',预计复诊 ' . substr($due, 5) : '') . ')';
$text .= ',已延迟 ' . $snooze . ' 次' . ($contact > 0 ? '、已联系 ' . $contact . ' 次' : '') . ',请关注。';
return $text . self::SIGNATURE;
}
/**
* now 是否落在工作时段 [start, end)(复用 RevisitDueLogic::parseWorkHours 的归一规则,非法配置回落 09:00~20:00)。
*/
public static function withinWorkHours(int $now, mixed $workHours): bool
{
[$startMin, $endMin] = RevisitDueLogic::parseWorkHours($workHours);
$minuteOfDay = (int) date('G', $now) * 60 + (int) date('i', $now);
return $minuteOfDay >= $startMin && $minuteOfDay < $endMin;
}
/**
* 'Y-m-d' → 当日 [00:00:00, 23:59:59] 时间戳;非法日期按今天。
*
* @return array{0:int, 1:int}
*/
public static function dayRange(string $today): array
{
$d = \DateTimeImmutable::createFromFormat('!Y-m-d', substr(trim($today), 0, 10));
if ($d === false) {
$d = new \DateTimeImmutable('today');
}
$start = $d->getTimestamp();
return [$start, $start + 86399];
}
// ---------------------------------------------------------------- 内部:取数与写入
/**
* 到期未挂号 / 过号的进行中行(口径同 RevisitDueLogic::applyDueWhere,基准日用 --date)。
*
* @return \think\db\Query
*/
private function dueQuery(string $today)
{
return Db::name(RevisitDueService::TABLE)
->where('closed_state', RevisitDueCalculator::CLOSED_OPEN)
->where('assistant_id', '>', 0)
->whereRaw('(appointment_state = ' . RevisitDueCalculator::APPT_MISSED . ' OR (due_date IS NOT NULL AND due_date <= :d1))', ['d1' => $today])
->whereRaw('(promised_date IS NULL OR promised_date < :d2)', ['d2' => $today]);
}
/**
* 当日已发过 daily 汇总的医助 id 集合(event=8 且 payload.kind=daily)。
*
* @param list<int> $assistantIds
*
* @return array<int, true>
*/
private function dailySentAssistantIds(array $assistantIds, string $today): array
{
if ($assistantIds === []) {
return [];
}
[$start, $end] = self::dayRange($today);
$ids = Db::name(RevisitDueService::LOG_TABLE)
->where('event', DiagnosisRevisitDueLog::EVENT_WECOM_PUSH)
->whereIn('assistant_id', $assistantIds)
->whereBetween('create_time', [$start, $end])
->whereLike('payload', '%"kind":"' . self::KIND_DAILY . '"%')
->distinct(true)
->column('assistant_id');
$out = [];
foreach ($ids as $id) {
$out[(int) $id] = true;
}
return $out;
}
/**
* 批量取账号姓名与企微 userid。
*
* @param list<int> $adminIds
*
* @return array<int, array{name:string, work_wechat_userid:string}>
*/
private function fetchAdmins(array $adminIds): array
{
$adminIds = array_values(array_filter(array_map('intval', $adminIds), static fn (int $v): bool => $v > 0));
if ($adminIds === []) {
return [];
}
$out = [];
foreach (array_chunk($adminIds, 500) as $chunk) {
// 直接查表:Admin 模型的 append(role_id/dept_id 访问器)会逐行再查库
$rows = Db::name('admin')
->whereIn('id', $chunk)
->whereNull('delete_time')
->field(['id', 'name', 'work_wechat_userid'])
->select()
->toArray();
foreach ($rows as $r) {
$out[(int) $r['id']] = [
'name' => (string) ($r['name'] ?? ''),
'work_wechat_userid' => (string) ($r['work_wechat_userid'] ?? ''),
];
}
}
return $out;
}
/**
* daily:为汇总覆盖的每一行写一条 event=8 流水(主管视图逐行可见)。
*
* @param list<array<string, mixed>> $items
* @param array<string, mixed> $payload
*/
private function logRows(array $items, string $today, int $now, array $payload): void
{
foreach ($items as $row) {
RevisitDueService::writeLog(
(int) $row['id'],
(int) $row['diagnosis_id'],
(int) $row['assistant_id'],
(int) $row['source_order_id'],
DiagnosisRevisitDueLog::EVENT_WECOM_PUSH,
[
'snooze_count_after' => (int) ($row['snooze_count'] ?? 0),
'level_after' => (int) ($row['escalation_level'] ?? 0),
'overdue_days' => $this->overdueDaysForLog($row, $today),
'operator_id' => 0,
'create_time' => $now,
'payload' => $payload,
]
);
}
}
/**
* 流水 overdue_days:today − due_date(负=未到期),无 due_date 记 0。
*
* @param array<string, mixed> $row
*/
private function overdueDaysForLog(array $row, string $today): int
{
$due = self::dateStr($row['due_date'] ?? null);
return $due !== '' ? RevisitDueCalculator::dayDiff($due, $today) : 0;
}
/**
* @return array{rows:int, sent:int, failed:int, unbound:int, deduped:int, supervisor:int}
*/
private function emptyStats(): array
{
return ['rows' => 0, 'sent' => 0, 'failed' => 0, 'unbound' => 0, 'deduped' => 0, 'supervisor' => 0];
}
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;
}
/** 日期列 → 'Y-m-d' 或 ''(null / 空 / 0000-00-00 均视为空) */
private static function dateStr(mixed $value): string
{
if ($value === null) {
return '';
}
$s = substr(trim((string) $value), 0, 10);
return ($s === '' || $s === '0000-00-00') ? '' : $s;
}
}
+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)) : ''
);
}
}