Files
zyt/server/app/command/AutoAssignPendingDiagnosis.php
2026-07-16 09:04:40 +08:00

572 lines
24 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\command;
use app\adminapi\logic\dept\DeptLogic;
use app\adminapi\logic\stats\RevisitRateLogic;
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;
/**
* 待分配诊单自动指派(每天定时执行)
*
* 使用方法:
* php think tcm:auto-assign-pending # 正式执行
* php think tcm:auto-assign-pending --dry-run # 只演练输出分配计划,不写库
*
* 推荐 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 口径完全一致):
* - 接诊率 > 70% :第一优先档,当日每人最多 3 条
* - 接诊率 60% ~ 70% :第二档,当日每人最多 2 条
* - 接诊率 50% ~ 60% :第三档,当日每人最多 1 条
* - 接诊率 < 50% 或上月无被指派数据:不参与分配
* - 仅分配给「二中心」及其组织下级部门的在职医助(当前部门校验,调离二中心即不再参与)
*
* 轮询方式:按轮次分配,每轮内先 >70% 档每人 1 条,再 60%~70% 档每人 1 条,再 50%~60% 档每人 1 条;
* 一轮结束还有剩余待指派诊单则进入下一轮,直至待指派池为空或所有医助当日额度用尽。
* 例:当天 20 条,>70% 有 3 人、60%~70% 有 7 人、50%~60% 有 5 人 →
* 第一轮 3+7+5=15 条;剩余 5 条进第二轮:>70% 再各 1 条(3 条),余 2 条给 60%~70% 档前 2 人。
*
* 日上限跨执行累计:同一天重复执行命令时,会先从 tcm_diagnosis_auto_assign_log 扣减当日已自动分配数,不会超额。
*
* 日志:无论分配与否,每条待指派诊单都会写入 tcm_diagnosis_auto_assign_log,记录原因(为什么分配 / 为什么不分配)。
* 成功分配同时写 tcm_diagnosis_assign_logis_inherit=0,计入次月接诊率分母),与手动指派同口径。
*/
class AutoAssignPendingDiagnosis extends Command
{
/** 档位标识 */
private const TIER_GT70 = 'gt70';
private const TIER_60_70 = '60_70';
private const TIER_50_60 = '50_60';
/** 各档位当日每人分配上限(按优先级排列,先高档后低档) */
private const TIER_DAILY_CAPS = [
self::TIER_GT70 => 3,
self::TIER_60_70 => 2,
self::TIER_50_60 => 1,
];
private const TIER_LABELS = [
self::TIER_GT70 => '>70%',
self::TIER_60_70 => '60%~70%',
self::TIER_50_60 => '50%~60%',
];
protected function configure()
{
$this->setName('tcm:auto-assign-pending')
->setDescription('待分配诊单自动指派:按上月二诊复诊接诊率分档轮询分配,并写入自动指派日志')
->addOption('dry-run', null, Option::VALUE_NONE, '演练模式:只输出分配计划,不写库');
}
protected function execute(Input $input, Output $output): int
{
$now = time();
$dryRun = (bool) $input->getOption('dry-run');
$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);
$output->writeln(sprintf(
'[%s] 开始自动指派待分配诊单,批次=%s,统计月=%s%s',
date('Y-m-d H:i:s', $now),
$batchNo,
$statMonth,
$dryRun ? '(演练模式,不写库)'
: ''
));
try {
// 1. 上月二诊复诊接诊率 → 医助分档
$tiers = $this->buildAssistantTiers($statMonth);
$tierTotal = array_sum(array_map('count', $tiers));
$output->writeln(sprintf(
'医助分档:>70%% 共 %d 人,60%%~70%% 共 %d 人,50%%~60%% 共 %d 人',
\count($tiers[self::TIER_GT70]),
\count($tiers[self::TIER_60_70]),
\count($tiers[self::TIER_50_60])
));
// 2. 当日剩余额度(扣减当日已自动分配数,防止同日重复执行超额)
$remaining = $this->buildRemainingQuota($tiers, $runDate);
// 3. 待指派池:与「待分配医助」Tab 同口径(assistant_id 空/0 + 当月有业务订单),先到先分
[$pool, $ineligible] = $this->fetchPendingPool($now);
$output->writeln(sprintf('待指派池:符合条件 %d 条,不符合条件 %d 条', \count($pool), \count($ineligible)));
$logRows = [];
// 不符合条件的待指派诊单:不分配,逐条记录原因
foreach ($ineligible as $item) {
$logRows[] = $this->buildLogRow($batchNo, $runDate, $statMonth, $item['diag'], [
'action' => 0,
'reason' => $item['reason'],
], $now);
}
if ($pool === []) {
$this->flushLogs($logRows, $dryRun, $output);
$output->writeln('待指派池为空,本次无需分配。');
return 0;
}
if ($tierTotal === 0) {
$reason = sprintf('未分配:上月(%s)无二诊复诊接诊率≥50%%的医助,本批次不执行分配', $statMonth);
foreach ($pool as $diag) {
$logRows[] = $this->buildLogRow($batchNo, $runDate, $statMonth, $diag, [
'action' => 0,
'reason' => $reason,
], $now);
}
$this->flushLogs($logRows, $dryRun, $output);
$output->writeln($reason);
return 0;
}
// 4. 轮询排分配计划
[$plan, $leftover] = $this->buildAssignPlan($pool, $tiers, $remaining);
// 5. 执行计划(逐条事务 + 行锁复核,落库诊单/指派日志/自动指派日志)
$assigned = 0;
$skipped = 0;
foreach ($plan as $item) {
if ($dryRun) {
$assigned++;
$output->writeln(sprintf(
'[演练] 诊单#%d(%s) → %s%s,第%d轮,当日第%d/%d条)',
$item['diagnosis']['id'],
(string) $item['diagnosis']['patient_name'],
$item['assistant']['name'],
self::TIER_LABELS[$item['tier']],
$item['round'],
$item['day_seq'],
self::TIER_DAILY_CAPS[$item['tier']]
));
continue;
}
$ok = $this->applyAssignment($item, $now);
if ($ok) {
$assigned++;
$logRows[] = $this->buildLogRow($batchNo, $runDate, $statMonth, $item['diagnosis'], [
'action' => 1,
'assistant_id' => $item['assistant']['id'],
'assistant_name' => $item['assistant']['name'],
'tier' => $item['tier'],
'visit2_rate' => $item['assistant']['rate'],
'round_no' => $item['round'],
'reason' => sprintf(
'已分配给医助[%s](ID:%d):上月(%s)二诊复诊接诊率 %.2f%%,档位[%s](日上限%d条),第 %d 轮轮询分得,当日该医助第 %d 条',
$item['assistant']['name'],
$item['assistant']['id'],
$statMonth,
$item['assistant']['rate'],
self::TIER_LABELS[$item['tier']],
self::TIER_DAILY_CAPS[$item['tier']],
$item['round'],
$item['day_seq']
),
], $now);
} else {
$skipped++;
$logRows[] = $this->buildLogRow($batchNo, $runDate, $statMonth, $item['diagnosis'], [
'action' => 0,
'reason' => '未分配:执行时诊单已被指派给其他医助(并发/人工抢先),本次跳过',
], $now);
}
}
// 6. 额度用尽后剩余的待指派诊单:不分配,记录原因
foreach ($leftover as $diag) {
$logRows[] = $this->buildLogRow($batchNo, $runDate, $statMonth, $diag, [
'action' => 0,
'reason' => '未分配:各档位医助当日剩余额度已用尽(>70%每人3条、60%~70%每人2条、50%~60%每人1条),顺延至下次执行',
], $now);
}
$this->flushLogs($logRows, $dryRun, $output);
$output->writeln(sprintf(
'执行完成。计划分配: %d, 实际分配: %d, 并发跳过: %d, 额度不足未分: %d, 不符合条件: %d',
\count($plan),
$assigned,
$skipped,
\count($leftover),
\count($ineligible)
));
return 0;
} catch (\Throwable $e) {
Log::error('待分配诊单自动指派异常: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
$output->error('执行异常: ' . $e->getMessage());
return 1;
}
}
/**
* 按上月二诊复诊接诊率把医助分档(复用 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}>}
*/
private function buildAssistantTiers(string $statMonth): array
{
$overview = RevisitRateLogic::overview(['month' => $statMonth]);
/** @var list<array{id:int,name:string,rate: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% 的医助不参与分配
if ($aid <= 0 || $rate === null || (float) $rate < 50.0) {
continue;
}
$candidates[] = [
'id' => $aid,
'name' => (string) ($row['assistant_name'] ?? ('#' . $aid)),
'rate' => (float) $rate,
];
}
}
if ($candidates === []) {
return [self::TIER_GT70 => [], self::TIER_60_70 => [], self::TIER_50_60 => []];
}
// 只分配给当前在职可用的医助账号(role_id=2 且未禁用未删除),
// 且当前部门须在「二中心」子树内(统计月在二中心、后来调离的不再参与)
$erDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet();
if ($erDeptSet === []) {
return [self::TIER_GT70 => [], self::TIER_60_70 => [], self::TIER_50_60 => []];
}
$activeIds = Db::name('admin')
->alias('a')
->join('admin_role ar', 'a.id = ar.admin_id')
->join('admin_dept ad', 'a.id = ad.admin_id')
->where('ar.role_id', 2)
->where('a.disable', 0)
->whereNull('a.delete_time')
->whereIn('ad.dept_id', array_keys($erDeptSet))
->whereIn('a.id', array_column($candidates, 'id'))
->group('a.id')
->column('a.id');
$activeSet = array_fill_keys(array_map('intval', $activeIds), true);
$tiers = [self::TIER_GT70 => [], self::TIER_60_70 => [], self::TIER_50_60 => []];
foreach ($candidates as $c) {
if (!isset($activeSet[$c['id']])) {
continue;
}
if ($c['rate'] > 70.0) {
$tiers[self::TIER_GT70][] = $c;
} elseif ($c['rate'] > 60.0) {
$tiers[self::TIER_60_70][] = $c;
} else { // 50 <= rate <= 60
$tiers[self::TIER_50_60][] = $c;
}
}
// 档内按接诊率降序、id 升序,保证分配顺序确定可复现
foreach ($tiers as &$list) {
usort($list, static function (array $a, array $b): int {
if ($a['rate'] !== $b['rate']) {
return $b['rate'] <=> $a['rate'];
}
return $a['id'] <=> $b['id'];
});
}
unset($list);
return $tiers;
}
/**
* 各医助当日剩余额度 = 档位日上限 - 当日已自动分配条数(同日重复执行不超额)。
*
* @param array<string, list<array{id:int,name:string,rate:float}>> $tiers
*
* @return array<int, int> assistant_id => 剩余额度
*/
private function buildRemainingQuota(array $tiers, string $runDate): array
{
$usedToday = Db::name('tcm_diagnosis_auto_assign_log')
->where('run_date', $runDate)
->where('action', 1)
->where('assistant_id', '>', 0)
->group('assistant_id')
->column('COUNT(*)', 'assistant_id');
$remaining = [];
foreach (self::TIER_DAILY_CAPS as $tier => $cap) {
foreach ($tiers[$tier] as $assistant) {
$used = (int) ($usedToday[$assistant['id']] ?? 0);
$remaining[$assistant['id']] = max(0, $cap - $used);
}
}
return $remaining;
}
/**
* 待指派池:与后台「待分配医助」Tab 同口径 —— assistant_id 为空/0、未删除,
* 且当月内存在业务订单(order.patient_id = 诊单 idTab 默认按当月过滤)。先到先分。
* 返回 [符合条件, 不符合条件(附原因)] 两组;不符合条件的诊单不分配,只记日志。
*
* @return array{0: list<array<string,mixed>>, 1: list<array{diag:array<string,mixed>,reason:string}>}
*/
private function fetchPendingPool(int $now): array
{
$rows = Db::name('tcm_diagnosis')
->whereRaw('(assistant_id IS NULL OR assistant_id = 0)')
->whereNull('delete_time')
->field(['id', 'patient_name', 'phone', 'status', 'create_time'])
->order(['create_time' => 'asc', 'id' => 'asc'])
->select()
->toArray();
if ($rows === []) {
return [[], []];
}
// 当月存在业务订单的诊单集合(order.create_time 兼容整型时间戳与 datetime 字符串,与 DiagnosisLists 一致)
$tStart = (int) strtotime(date('Y-m-01 00:00:00', $now));
$tEnd = (int) strtotime(date('Y-m-t 23:59:59', $now));
$dsStart = date('Y-m-d H:i:s', $tStart);
$dsEnd = date('Y-m-d H:i:s', $tEnd);
$hasOrderSet = [];
foreach (array_chunk(array_column($rows, 'id'), 2000) as $chunk) {
$ids = Db::name('order')
->whereIn('patient_id', $chunk)
->whereNull('delete_time')
->where(static function ($q) use ($tStart, $tEnd, $dsStart, $dsEnd) {
$q->whereBetween('create_time', [$tStart, $tEnd])
->whereOr(static function ($q2) use ($dsStart, $dsEnd) {
$q2->where('create_time', '>=', $dsStart)->where('create_time', '<=', $dsEnd);
});
})
->group('patient_id')
->column('patient_id');
foreach ($ids as $id) {
$hasOrderSet[(int) $id] = true;
}
}
$curMonth = date('Y-m', $now);
$eligible = [];
$ineligible = [];
foreach ($rows as $r) {
$did = (int) ($r['id'] ?? 0);
if (!isset($hasOrderSet[$did])) {
$ineligible[] = [
'diag' => $r,
'reason' => sprintf('未分配:诊单当月(%s)无业务订单,不在「待分配医助」列表范围内,不满足自动分配条件', $curMonth),
];
continue;
}
if ((int) ($r['status'] ?? 0) !== 1) {
$ineligible[] = [
'diag' => $r,
'reason' => '未分配:诊单未启用(status≠1),不满足自动分配条件',
];
continue;
}
$eligible[] = $r;
}
return [$eligible, $ineligible];
}
/**
* 轮询排分配计划:每轮内先 >70% 档每人 1 条,再 60%~70%,再 50%~60%;受各自当日剩余额度约束。
*
* @param list<array<string,mixed>> $pool 待指派诊单(先到先分)
* @param array<string, list<array{id:int,name:string,rate:float}>> $tiers
* @param array<int, int> $remaining assistant_id => 剩余额度(会被消耗)
*
* @return array{
* 0: list<array{diagnosis:array<string,mixed>,assistant:array{id:int,name:string,rate:float},tier:string,round:int,day_seq:int}>,
* 1: list<array<string,mixed>>
* } [分配计划, 额度用尽后剩余诊单]
*/
private function buildAssignPlan(array $pool, array $tiers, array $remaining): array
{
$plan = [];
$poolIdx = 0;
$poolCount = \count($pool);
/** @var array<int, int> $daySeq 医助当日已排序号(含历史已用额度) */
$daySeq = [];
foreach ($remaining as $aid => $left) {
// 起始序号 = 日上限 - 剩余额度(同日多次执行时序号衔接)
$cap = 0;
foreach (self::TIER_DAILY_CAPS as $tier => $tierCap) {
foreach ($tiers[$tier] as $assistant) {
if ($assistant['id'] === $aid) {
$cap = $tierCap;
break 2;
}
}
}
$daySeq[$aid] = $cap - $left;
}
$round = 0;
while ($poolIdx < $poolCount) {
$round++;
$assignedThisRound = 0;
foreach (self::TIER_DAILY_CAPS as $tier => $_cap) {
foreach ($tiers[$tier] as $assistant) {
if ($poolIdx >= $poolCount) {
break 2;
}
$aid = $assistant['id'];
if (($remaining[$aid] ?? 0) <= 0) {
continue;
}
$remaining[$aid]--;
$daySeq[$aid]++;
$plan[] = [
'diagnosis' => $pool[$poolIdx],
'assistant' => $assistant,
'tier' => $tier,
'round' => $round,
'day_seq' => $daySeq[$aid],
];
$poolIdx++;
$assignedThisRound++;
}
}
if ($assignedThisRound === 0) {
// 所有医助额度用尽,剩余诊单不再分配
break;
}
}
return [$plan, \array_slice($pool, $poolIdx)];
}
/**
* 落库单条分配:事务 + 行锁复核诊单仍未指派,更新诊单并写指派日志(与手动指派 DiagnosisLogic::assign 同口径)。
*
* @param array{diagnosis:array<string,mixed>,assistant:array{id:int,name:string,rate:float}} $item
*/
private function applyAssignment(array $item, int $now): bool
{
$diagnosisId = (int) $item['diagnosis']['id'];
$toAssistantId = (int) $item['assistant']['id'];
Db::startTrans();
try {
$diagLock = Db::name('tcm_diagnosis')
->where('id', $diagnosisId)
->whereNull('delete_time')
->lock(true)
->field(['id', 'assistant_id'])
->find();
if ($diagLock === null || $diagLock === [] || (int) ($diagLock['assistant_id'] ?? 0) > 0) {
Db::rollback();
return false;
}
Db::name('tcm_diagnosis')
->where('id', $diagnosisId)
->whereNull('delete_time')
->update([
'assistant_id' => $toAssistantId,
'assign_read_at' => null,
]);
$poSnap = Db::name('tcm_prescription_order')
->where('diagnosis_id', $diagnosisId)
->whereNull('delete_time')
->order(['create_time' => 'desc', 'id' => 'desc'])
->field(['creator_id', 'create_time'])
->find();
$relatedPoCreatorId = (int) ($poSnap['creator_id'] ?? 0);
$relatedPoCreateTime = (int) ($poSnap['create_time'] ?? 0);
if ($relatedPoCreateTime <= 0) {
$relatedPoCreateTime = $now;
$relatedPoCreatorId = 0;
}
Db::name('tcm_diagnosis_assign_log')->insert([
'diagnosis_id' => $diagnosisId,
'from_assistant_id' => 0,
'to_assistant_id' => $toAssistantId,
'operator_admin_id' => 0,
'operator_name' => '系统自动分配',
'operator_account' => 'system',
'ip' => '',
'related_po_creator_id' => $relatedPoCreatorId,
'related_po_create_time' => $relatedPoCreateTime,
'is_inherit' => 0,
'create_time' => $now,
]);
Db::commit();
return true;
} catch (\Throwable $e) {
Db::rollback();
Log::error(sprintf('自动指派落库失败 diagnosis_id=%d assistant_id=%d msg=%s', $diagnosisId, $toAssistantId, $e->getMessage()));
return false;
}
}
/**
* @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
*
* @return array<string,mixed>
*/
private function buildLogRow(string $batchNo, string $runDate, string $statMonth, array $diag, array $extra, int $now): array
{
return [
'batch_no' => $batchNo,
'run_date' => $runDate,
'stat_month' => $statMonth,
'diagnosis_id' => (int) ($diag['id'] ?? 0),
'patient_name' => mb_substr(trim((string) ($diag['patient_name'] ?? '')), 0, 64),
'patient_phone' => mb_substr(trim((string) ($diag['phone'] ?? '')), 0, 32),
'action' => (int) ($extra['action'] ?? 0),
'assistant_id' => (int) ($extra['assistant_id'] ?? 0),
'assistant_name' => mb_substr((string) ($extra['assistant_name'] ?? ''), 0, 64),
'tier' => (string) ($extra['tier'] ?? ''),
'visit2_rate' => $extra['visit2_rate'] ?? null,
'round_no' => (int) ($extra['round_no'] ?? 0),
'reason' => mb_substr((string) ($extra['reason'] ?? ''), 0, 500),
'create_time' => $now,
];
}
/**
* @param list<array<string,mixed>> $logRows
*/
private function flushLogs(array $logRows, bool $dryRun, Output $output): void
{
if ($logRows === []) {
return;
}
if ($dryRun) {
$output->writeln(sprintf('[演练] 应写入自动指派日志 %d 条(未落库)', \count($logRows)));
return;
}
foreach (array_chunk($logRows, 500) as $chunk) {
Db::name('tcm_diagnosis_auto_assign_log')->insertAll($chunk);
}
$output->writeln(sprintf('已写入自动指派日志 %d 条', \count($logRows)));
}
}