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,