更新
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user