> /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_log(is_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, 60_70: list, 50_60: list} */ private function buildAssistantTiers(string $statMonth): array { $overview = RevisitRateLogic::overview(['month' => $statMonth]); /** @var list $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> $tiers * * @return array 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 = 诊单 id,Tab 默认按当月过滤)。先到先分。 * 返回 [符合条件, 不符合条件(附原因)] 两组;不符合条件的诊单不分配,只记日志。 * * @return array{0: list>, 1: list,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> $pool 待指派诊单(先到先分) * @param array> $tiers * @param array $remaining assistant_id => 剩余额度(会被消耗) * * @return array{ * 0: list,assistant:array{id:int,name:string,rate:float},tier:string,round:int,day_seq:int}>, * 1: list> * } [分配计划, 额度用尽后剩余诊单] */ private function buildAssignPlan(array $pool, array $tiers, array $remaining): array { $plan = []; $poolIdx = 0; $poolCount = \count($pool); /** @var array $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,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 $diag 诊单行(含 id/patient_name/phone) * @param array $extra action/assistant_id/assistant_name/tier/visit2_rate/round_no/reason * * @return array */ 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> $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))); } }