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
@@ -20,6 +20,7 @@ use app\adminapi\logic\tcm\TrackingNoteLogic;
use app\adminapi\logic\doctor\DoctorNoteLogic;
use app\common\model\tcm\Prescription;
use app\common\service\wechat\WechatWorkAppMessageService;
use app\common\service\tcm\RevisitDueHook;
use app\common\model\dict\DictData;
use think\facade\Db;
use think\facade\Log;
@@ -404,6 +405,9 @@ class AppointmentLogic extends BaseLogic
$summary
);
// 事务提交后刷新复诊到期快照(新挂号 → 已约自动关闭;钩子内部吞异常)
RevisitDueHook::onAppointmentChanged((int) $params['patient_id'], $newId, 'create');
return ['id' => $newId];
} catch (\Exception $e) {
Db::rollback();
@@ -467,6 +471,9 @@ class AppointmentLogic extends BaseLogic
$summary
);
// 刷新复诊到期快照(取消挂号 → 已约行重开;钩子内部吞异常)
RevisitDueHook::onAppointmentChanged($diagId, $aptId, 'cancel');
return true;
} catch (\Exception $e) {
self::setError($e->getMessage());
@@ -618,6 +625,9 @@ class AppointmentLogic extends BaseLogic
Log::warning('完成挂号后系统代开处方失败: ' . $e->getMessage());
}
// 刷新复诊到期快照(面诊完成 → 已面诊关闭;钩子内部吞异常)
RevisitDueHook::onAppointmentChanged((int) $appointment->patient_id, (int) $appointment->id, 'complete');
return true;
} catch (\Exception $e) {
self::setError($e->getMessage());
@@ -1161,6 +1171,9 @@ class AppointmentLogic extends BaseLogic
);
self::appendDiagnosisGuahaoLog($patientId, $id, $adminId, $adminInfo, 'admin_edit', $summary);
// 事务提交后刷新复诊到期快照(日期/状态变更影响已约/过号/已面诊判定;钩子内部吞异常)
RevisitDueHook::onAppointmentChanged($patientId, $id, 'admin_edit');
return true;
} catch (\Throwable $e) {
Db::rollback();
@@ -10,6 +10,22 @@ class AssistantPerformanceLogic
/** 履约完成 */
private const FULFILLMENT_COMPLETED = 3;
/**
* 「我的复诊接诊率」卡片(方案 §10 D1-h):仅返回当前登录医助本人的复诊接诊率行。
* 口径与复诊接诊率页一致(inherit_mode 取配置 project.revisit_rate.default_inherit_mode,默认 slot:
* 继承患者按继承时诊次从第 k+1 诊起计入分母);非二中心医助或当月无被指派患者时 row=null。
*
* @param string $month 统计月份 YYYY-MM,空 / 非法时回退当前月
*
* @return array{month:string,inherit_mode:string,slots:list<int>,in_scope:bool,row:array<string,mixed>|null}
*/
public static function revisitRate(int $adminId, string $month): array
{
$mode = (string) config('project.revisit_rate.default_inherit_mode', 'slot');
return RevisitRateLogic::assistantOverview($adminId, $month, $mode);
}
public static function overview(array $params, int $adminId, array $adminInfo): array
{
// 时间范围解析
@@ -5,11 +5,15 @@ declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\common\logic\BaseLogic;
use app\common\service\tcm\RevisitDueHook;
use think\facade\Db;
use think\facade\Log;
/**
* 待分配诊单自动指派日志:回退已自动分配的医助
*
* 回退写入的指派日志保持原逻辑 is_inherit=0(复诊接诊率原口径不变,用户要求);
* 复诊接诊率「新口径」下,回退行由 RevisitRateLogic 按 (diagnosis_id, rollback_time, rollback_admin_id) 匹配后按继承处理,不依赖本字段。
*/
class AutoAssignLogLogic extends BaseLogic
{
@@ -183,6 +187,9 @@ class AutoAssignLogLogic extends BaseLogic
Db::commit();
// 事务提交后刷新复诊到期快照(回退 = 换回原医助或释放 to=0;钩子内部吞异常)
RevisitDueHook::onAssistantChanged($diagnosisId, $prevAssistantId, 'auto_assign_rollback');
return true;
} catch (\Throwable $e) {
Db::rollback();
@@ -0,0 +1,476 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\common\service\tcm\RevisitVisitSlotService;
/**
* 复诊接诊率 v2 纯计算器(不连库,承接 RevisitRateLogic::buildStatsCore 的内存计算;单测可直接驱动)。
*
* 口径(方案 §1.1,定义 A 累计型、每列独立分母):
* - NI(A):新指派集合 = 当月 is_inherit=0、to=A 的指派日志对应诊单(slot 模式下忽略 from==to 行)\ R;
* - IH(A):继承集合 = 当月 is_inherit=1(或 slot 模式下命中历史回退行)、to=A、from≠to 的诊单,且 D ∉ NI(A),\ R;
* 同一 (A, D) 当月多条继承取最早一条;
* - R:名下存在履约拒收(9)/退款(10) 订单的诊单(由调用方查库后传入);
* - 继承时诊次 k_D = slotBefore(该诊单有效订单 create_time 列表, offset, t_l)(严格小于,与持有人规则 t <= ct 对偶);
* 起算诊次 entry_D = max(inherit_min_next_slot, k_D + 1);entry_D > MAX_VISIT_SLOT 的继承行只进合计分母、不进任何诊次列;
* - 分子 Num_s(A):当月下单、slot = s、2 <= s <= 50、下单时点持有人为 A 的订单(与原实现完全一致,不受模式影响);
* - 无论模式都另行输出 NI_exclude(A)(:380 现状口径:仅 is_inherit=0,不忽略 from==to、不豁免回退行),
* 供 visit2_rate_new_assign 使用。
*
* inherit_mode:
* - exclude:完全复现旧口径,IH = ∅,NI = NI_exclude;
* - slot :继承按继承时诊次计入每列独立分母。
*/
final class RevisitRateCalculator
{
public const MODE_EXCLUDE = 'exclude';
public const MODE_SLOT = 'slot';
/** 复诊序号统计上限(与 RevisitRateLogic::MAX_VISIT_SLOT 一致) */
public const MAX_VISIT_SLOT = 50;
/** 继承起算诊次下限默认值(config project.revisit_rate.inherit_min_next_slot) */
public const DEFAULT_MIN_NEXT_SLOT = 2;
/** 旧口径 :380 的字段名(供自动分配过渡期分档) */
public const FIELD_RATE_NEW_ASSIGN = 'visit2_rate_new_assign';
/** 口径白名单归一化;非法值回退 $default */
public static function normalizeMode(mixed $raw, string $default = self::MODE_SLOT): string
{
$mode = strtolower(trim((string) $raw));
if ($mode === self::MODE_EXCLUDE || $mode === self::MODE_SLOT) {
return $mode;
}
$default = strtolower(trim($default));
return $default === self::MODE_EXCLUDE ? self::MODE_EXCLUDE : self::MODE_SLOT;
}
/** 历史回退行匹配键:auto_assign_log(diagnosis_id, rollback_time, rollback_admin_id) ↔ assign_log(diagnosis_id, create_time, operator_admin_id) */
public static function rollbackKey(int $diagnosisId, int $time, int $operatorAdminId): string
{
return $diagnosisId . '|' . $time . '|' . $operatorAdminId;
}
/**
* 当月被指派(to>0、t∈M,含继承 / from==to 行)的诊单 id 去重列表,供查询拒收/退款剔除集合。
*
* @param list<array<string, mixed>> $logRows
*
* @return list<int>
*/
public static function monthAssignedDiagIds(array $logRows, int $startTs, int $endTs): array
{
$set = [];
foreach ($logRows as $r) {
$did = (int) ($r['diagnosis_id'] ?? 0);
$aid = (int) ($r['to_assistant_id'] ?? 0);
$t = (int) ($r['create_time'] ?? 0);
if ($did > 0 && $aid > 0 && $t >= $startTs && $t <= $endTs) {
$set[$did] = true;
}
}
return array_keys($set);
}
/**
* 曾被指派(to>0,任意时间,含继承)的诊单 id 列表:分子候选、订单序列拉取范围。
*
* @param list<array<string, mixed>> $logRows
*
* @return list<int>
*/
public static function candidateDiagIds(array $logRows): array
{
$set = [];
foreach ($logRows as $r) {
$did = (int) ($r['diagnosis_id'] ?? 0);
if ($did > 0 && (int) ($r['to_assistant_id'] ?? 0) > 0) {
$set[$did] = true;
}
}
return array_keys($set);
}
/**
* 核心计算。
*
* @param list<array<string, mixed>> $logRows 指派日志(≤ 月末、诊单未删除),须按 (diagnosis_id, create_time, id) 升序;
* 键:id, diagnosis_id, from_assistant_id, to_assistant_id, operator_admin_id, create_time, is_inherit
* @param iterable<array<string, mixed>> $orderRows 有效订单行,须按 (diagnosis_id, create_time, id) 升序且同一诊单连续(可分块生成器);
* 键:id, diagnosis_id, create_time(其余字段原样进入 slotOrdersByAssistant)
* @param array<int, int> $offsetMap diagnosis_id => revisit_slot_start_offset(原始值)
* @param array<int, true> $refundRejectSet 名下有拒收/退款订单的诊单集合 R
* @param array<string, true> $rollbackKeySet 历史回退行键集合(rollbackKey()),仅 slot 模式使用
* @param string $inheritMode exclude | slot
* @param int $inheritMinNextSlot 继承起算诊次下限(默认 2)
*
* @return array{
* inheritMode:string,
* pairsRaw:list<array{diagnosis_id:int,to_assistant_id:int,from_assistant_id:int,create_time:int,log_id:int}>,
* diagsByAssistant:array<int,array<int,true>>,
* excludeDiagsByAssistant:array<int,array<int,true>>,
* inheritEntryByAssistant:array<int,array<int,int>>,
* inheritPairs:list<array{diagnosis_id:int,to_assistant_id:int,from_assistant_id:int,create_time:int,log_id:int,inherit_visit_slot:int,entry_slot:int,assign_count:int,is_rollback:int,inherit_visit_slot_snapshot:int}>,
* slotOrdersByAssistant:array<int,array<int,list<array<string,mixed>>>>,
* maxEntry:int
* }
*/
public static function compute(
array $logRows,
iterable $orderRows,
array $offsetMap,
array $refundRejectSet,
array $rollbackKeySet,
int $startTs,
int $endTs,
string $inheritMode,
int $inheritMinNextSlot = self::DEFAULT_MIN_NEXT_SLOT
): array {
$inheritMode = self::normalizeMode($inheritMode);
$slotMode = $inheritMode === self::MODE_SLOT;
if ($inheritMinNextSlot < 2) {
$inheritMinNextSlot = 2;
}
/** @var array<int, list<array{t:int,to:int}>> $timeline 诊单 => 持有变更时间线(升序,含释放/继承/from==to 行) */
$timeline = [];
$pairsRaw = [];
/** @var array<int, array<int, true>> $diagsByAssistant 当前模式下的 NI */
$diagsByAssistant = [];
/** @var array<int, array<int, true>> $excludeDiagsByAssistant :380 现状口径 NI_exclude */
$excludeDiagsByAssistant = [];
/** @var array<int, array<int, array<string, mixed>>> $inheritFirst 医助 => 诊单 => 最早一条继承日志(附 assign_count) */
$inheritFirst = [];
foreach ($logRows as $r) {
$did = (int) ($r['diagnosis_id'] ?? 0);
$aid = (int) ($r['to_assistant_id'] ?? 0);
$from = (int) ($r['from_assistant_id'] ?? 0);
$t = (int) ($r['create_time'] ?? 0);
$isInherit = (int) ($r['is_inherit'] ?? 0) === 1;
$logId = (int) ($r['id'] ?? 0);
if ($did <= 0) {
continue;
}
$timeline[$did][] = ['t' => $t, 'to' => $aid];
if ($aid <= 0 || $t < $startTs || $t > $endTs) {
continue;
}
// 旧口径集合:仅看 is_inherit=0(不忽略 from==to、不豁免回退行)
if (!$isInherit) {
$excludeDiagsByAssistant[$aid][$did] = true;
}
if (!$slotMode) {
if (!$isInherit) {
$pairsRaw[] = [
'diagnosis_id' => $did,
'to_assistant_id' => $aid,
'from_assistant_id' => $from,
'create_time' => $t,
'log_id' => $logId,
];
$diagsByAssistant[$aid][$did] = true;
}
continue;
}
// slot 模式:指派给自己(from == to)不计任何集合
if ($from === $aid) {
continue;
}
$isRollback = $rollbackKeySet !== []
&& isset($rollbackKeySet[self::rollbackKey($did, $t, (int) ($r['operator_admin_id'] ?? 0))]);
if ($isInherit || $isRollback) {
if (!isset($inheritFirst[$aid][$did])) {
$inheritFirst[$aid][$did] = [
'diagnosis_id' => $did,
'to_assistant_id' => $aid,
'from_assistant_id' => $from,
'create_time' => $t,
'log_id' => $logId,
'is_rollback' => $isRollback ? 1 : 0,
'inherit_visit_slot_snapshot' => (int) ($r['inherit_visit_slot'] ?? 0),
'assign_count' => 1,
];
} else {
$inheritFirst[$aid][$did]['assign_count']++;
}
continue;
}
$pairsRaw[] = [
'diagnosis_id' => $did,
'to_assistant_id' => $aid,
'from_assistant_id' => $from,
'create_time' => $t,
'log_id' => $logId,
];
$diagsByAssistant[$aid][$did] = true;
}
// D 同月既被非继承又被继承指派给同一 A 时只算 NI
foreach ($inheritFirst as $aid => $diagMap) {
foreach ($diagMap as $did => $_) {
if (isset($diagsByAssistant[$aid][$did])) {
unset($inheritFirst[$aid][$did]);
}
}
if ($inheritFirst[$aid] === []) {
unset($inheritFirst[$aid]);
}
}
// 剔除名下存在拒收(9)/退款(10) 业务订单的诊单(NI / NI_exclude / IH 同时生效)
if ($refundRejectSet !== []) {
$strip = static function (array &$map) use ($refundRejectSet): void {
foreach ($map as $aid => $diagMap) {
foreach ($diagMap as $did => $_) {
if (isset($refundRejectSet[$did])) {
unset($map[$aid][$did]);
}
}
if ($map[$aid] === []) {
unset($map[$aid]);
}
}
};
$strip($diagsByAssistant);
$strip($excludeDiagsByAssistant);
$strip($inheritFirst);
$pairsRaw = array_values(array_filter(
$pairsRaw,
static fn (array $p): bool => !isset($refundRejectSet[$p['diagnosis_id']])
));
}
/** @var array<int, true> $inheritDiagSet 需要收集订单时间列表的诊单 */
$inheritDiagSet = [];
foreach ($inheritFirst as $diagMap) {
foreach ($diagMap as $did => $_) {
$inheritDiagSet[$did] = true;
}
}
/** @var array<int, list<int>> $inheritOrderCts 继承诊单 => 有效订单 create_time 列表 */
$inheritOrderCts = [];
// 分子:曾被指派诊单的订单,统计诊次 = 实单全局序号 + 诊单偏移;持有人 = 订单时间之前(含同刻)最近一次指派
/** @var array<int, array<int, list<array<string, mixed>>>> $slotOrdersByAssistant */
$slotOrdersByAssistant = [];
$curDid = 0;
$seq = 0;
$ptr = 0;
$holder = 0;
$offset = 0;
foreach ($orderRows as $r) {
$did = (int) ($r['diagnosis_id'] ?? 0);
if ($did <= 0) {
continue;
}
if ($did !== $curDid) {
$curDid = $did;
$seq = 0;
$ptr = 0;
$holder = 0;
$offset = RevisitVisitSlotService::resolveRevisitSlotStartOffset($did, $offsetMap);
}
$seq++;
$effectiveSlot = $seq + $offset;
$ct = (int) ($r['create_time'] ?? 0);
if (isset($inheritDiagSet[$did])) {
$inheritOrderCts[$did][] = $ct;
}
// 推进时间线指针:订单时间之前(含同刻)最近一次指派的持有人
$tl = $timeline[$did] ?? [];
$tlCount = \count($tl);
while ($ptr < $tlCount && $tl[$ptr]['t'] <= $ct) {
$holder = (int) $tl[$ptr]['to'];
$ptr++;
}
if ($effectiveSlot < 2 || $effectiveSlot > self::MAX_VISIT_SLOT) {
continue;
}
if ($ct < $startTs || $ct > $endTs) {
continue;
}
if ($holder > 0) {
$slotOrdersByAssistant[$holder][$effectiveSlot][] = $r;
}
}
// 继承时诊次 k 与起算诊次 entry
/** @var array<int, array<int, int>> $inheritEntryByAssistant */
$inheritEntryByAssistant = [];
$inheritPairs = [];
$maxEntry = 0;
foreach ($inheritFirst as $aid => $diagMap) {
foreach ($diagMap as $did => $row) {
$k = RevisitVisitSlotService::slotBefore(
$inheritOrderCts[$did] ?? [],
RevisitVisitSlotService::resolveRevisitSlotStartOffset($did, $offsetMap),
(int) $row['create_time']
);
$entry = max($inheritMinNextSlot, $k + 1);
$inheritEntryByAssistant[$aid][$did] = $entry;
if ($entry <= self::MAX_VISIT_SLOT && $entry > $maxEntry) {
$maxEntry = $entry;
}
$row['inherit_visit_slot'] = $k;
$row['entry_slot'] = $entry;
$inheritPairs[] = $row;
}
}
return [
'inheritMode' => $inheritMode,
'pairsRaw' => $pairsRaw,
'diagsByAssistant' => $diagsByAssistant,
'excludeDiagsByAssistant' => $excludeDiagsByAssistant,
'inheritEntryByAssistant' => $inheritEntryByAssistant,
'inheritPairs' => $inheritPairs,
'slotOrdersByAssistant' => $slotOrdersByAssistant,
'maxEntry' => $maxEntry,
];
}
/**
* 诊单集合按医助并集(部门行 / 合计行按诊单去重)。
*
* @param array<int, array<int, true>> $setsByAssistant
* @param list<int> $assistantIds
*
* @return array<int, true>
*/
public static function unionDiagSets(array $setsByAssistant, array $assistantIds): array
{
$out = [];
foreach ($assistantIds as $aid) {
foreach ($setsByAssistant[(int) $aid] ?? [] as $did => $_) {
$out[(int) $did] = true;
}
}
return $out;
}
/**
* 部门行 / 合计行的 NI 与 IH 合并:NI_dept = ∪NI(A);IH_dept = (∪IH(A)) \ NI_dept,entry 取各继承者中的最小值。
*
* @param array<int, array<int, true>> $diagsByAssistant
* @param array<int, array<int, int>> $inheritEntryByAssistant
* @param list<int> $assistantIds
*
* @return array{0: array<int, true>, 1: array<int, int>} [NI 去重集合, 诊单 => entry]
*/
public static function mergeInheritEntries(array $diagsByAssistant, array $inheritEntryByAssistant, array $assistantIds): array
{
$niSet = self::unionDiagSets($diagsByAssistant, $assistantIds);
$ih = [];
foreach ($assistantIds as $aid) {
foreach ($inheritEntryByAssistant[(int) $aid] ?? [] as $did => $entry) {
$did = (int) $did;
if (isset($niSet[$did])) {
continue;
}
$entry = (int) $entry;
$ih[$did] = isset($ih[$did]) ? min($ih[$did], $entry) : $entry;
}
}
return [$niSet, $ih];
}
/**
* 列范围:2 ~ min(MAX, max(minCeiling, 当月分子最大 slot, 最大起算诊次))。
*
* @param array<int, array<int, list<array<string, mixed>>>> $slotOrdersByAssistant
*
* @return list<int>
*/
public static function slotRange(array $slotOrdersByAssistant, int $maxEntry, int $minCeiling = 4): array
{
$maxHitSlot = 0;
foreach ($slotOrdersByAssistant as $slotMap) {
foreach ($slotMap as $slot => $_) {
if ((int) $slot > $maxHitSlot) {
$maxHitSlot = (int) $slot;
}
}
}
$upper = max($minCeiling, $maxHitSlot, $maxEntry);
$upper = min(self::MAX_VISIT_SLOT, max(2, $upper));
return range(2, $upper);
}
/**
* 指标包:
* assigned_count(新指派,语义不变)、inherit_count、assigned_total_count、
* visit{N}_count / _den / _rate(Den_s = NI + |{entry <= s}|)、visit_all_count / _den / _rate、visit2_rate_new_assign。
*
* @param array<int, int> $inheritEntryByDiag 诊单 => 起算诊次
* @param array<int, int> $slotCounts [slot => n]
* @param list<int> $slots 需输出的分档列表(保证各行键齐全)
*
* @return array<string, int|float|null>
*/
public static function metricPack(
int $newAssigned,
array $inheritEntryByDiag,
array $slotCounts,
array $slots,
int $newAssignedExclude
): array {
$inheritCount = \count($inheritEntryByDiag);
/** @var array<int, int> $byEntry entry => 继承诊单数 */
$byEntry = [];
foreach ($inheritEntryByDiag as $entry) {
$entry = (int) $entry;
$byEntry[$entry] = ($byEntry[$entry] ?? 0) + 1;
}
ksort($byEntry);
$pack = [
'assigned_count' => $newAssigned,
'inherit_count' => $inheritCount,
'assigned_total_count' => $newAssigned + $inheritCount,
];
foreach ($slots as $slot) {
$slot = (int) $slot;
$cnt = (int) ($slotCounts[$slot] ?? 0);
$den = $newAssigned;
foreach ($byEntry as $entry => $n) {
if ($entry > $slot) {
break;
}
$den += $n;
}
$pack['visit' . $slot . '_count'] = $cnt;
$pack['visit' . $slot . '_den'] = $den;
$pack['visit' . $slot . '_rate'] = $den > 0 ? round($cnt / $den * 100, 2) : null;
}
$allCount = 0;
foreach ($slotCounts as $slot => $cnt) {
$slot = (int) $slot;
if ($slot >= 2 && $slot <= self::MAX_VISIT_SLOT) {
$allCount += (int) $cnt;
}
}
$allDen = $newAssigned + $inheritCount;
$pack['visit_all_count'] = $allCount;
$pack['visit_all_den'] = $allDen;
$pack['visit_all_rate'] = $allDen > 0 ? round($allCount / $allDen * 100, 2) : null;
$num2 = (int) ($slotCounts[2] ?? 0);
$pack[self::FIELD_RATE_NEW_ASSIGN] = $newAssignedExclude > 0
? round($num2 / $newAssignedExclude * 100, 2)
: null;
return $pack;
}
}
@@ -5,29 +5,32 @@ declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\adminapi\logic\dept\DeptLogic;
use app\common\service\tcm\RevisitVisitSlotService;
use think\facade\Db;
/**
* 复诊接诊率统计(按月)
* 复诊接诊率统计(按月)—— v2:继承按继承时诊次计入每列独立分母 + 复诊合计列(方案 §1.1,定义 A 累计型)。
*
* 口径说明:
* - 当月被指派总数 = 当月内 `tcm_diagnosis_assign_log`(按 **指派操作时间 lg.create_time** 落月,to_assistant_id>0,
* **剔除勾选「继承」的指派 is_inherit=1**,诊单未删除)去重后的「医助 × 诊单」组合;
* 同一诊单当月被多次指派给同一医助只计 1 次;
* **再剔除**名下存在履约「拒收(9) / 退款(10)」业务订单(未软删)的诊单。
* 口径说明(内存计算见 RevisitRateCalculator,诊次口径见 RevisitVisitSlotService):
* - 新指派数 assigned_count = 当月内 `tcm_diagnosis_assign_log`(按 **指派操作时间 lg.create_time** 落月,to_assistant_id>0,
* **is_inherit=0**,诊单未删除)去重后的「医助 × 诊单」组合(NI);同一诊单当月多次指派给同一医助只计 1 次;
* **再剔除**名下存在履约「拒收(9) / 退款(10)」业务订单(未软删)的诊单(R,不限月份)。
* - 继承数 inherit_count = 当月 **is_inherit=1**、to>0、from≠to 的「医助 × 诊单」(IH),同一 (医助, 诊单) 多条继承取最早;
* 同月既新指派又继承给同一医助只算新指派;剔除 R。历史「回退自动分配」行(auto_assign_log.rollback_time 匹配)按继承处理。
* 继承时诊次 k = 继承时刻之前(严格小于)的有效实单数 + 偏移;起算诊次 entry = max(2, k+1);entry>50 只进合计分母。
* - 第 N 次下单 = 诊单(患者)名下计入业绩的业务订单(剔除履约 4/9/10、软删)按 create_time 升序的**全局**序列中第 N 笔;
* 诊次**跨月累计不重置**:例如 5 月指派后旗下成交 4 单为二诊~五诊,6 月再成交即为六诊。
* 诊单可配置 `revisit_slot_start_offset`(默认 0:第 1 笔实单计为一诊;设为 1 则第 1 笔实单计为二诊;设为 2 则计为三诊,即在实单序号上叠加偏移,5 笔实单+偏移 2 等价于计至七诊)。
* - 当月 N 诊单数 = **当月内下单**且全局序号为 N 的订单数,归属下单时点的**持有医助**——
* 按指派日志时间线取「订单时间之前最近一次指派」的 to_assistant_id(释放 to=0 即不再归属;
* 「继承」指派会转移持有人用于归属,但不计被指派数)。指派可发生在往月。
* - 当月 N 诊接诊率 = 当月 N 诊单数 ÷ 当月被指派总数;往月指派当月成交会推高分子,故比率可能超过 100%;
* 医助当月无新指派但旗下有成交时,被指派数为 0、比率显示为空。
* - 分档动态产出:N 从 2 起,至当月命中数据的最大序号(至少展示到四诊,上限 MAX_VISIT_SLOT 防御异常数据),
* 返回 `slots` 列表供前端动态渲染「五诊」「六诊」… 列。
* 诊次**跨月累计不重置**;诊单可配置 `revisit_slot_start_offset`(默认 0:第 1 笔实单计为一诊;偏移 n 即在实单序号上叠加 n)。
* - 当月 N 诊单数 visit{N}_count = **当月内下单**且诊次为 N 的订单数,归属下单时点的**持有医助**——
* 按指派日志时间线取「订单时间之前(含同刻)最近一次指派」的 to_assistant_id(释放 to=0 即不再归属;继承会转移持有人)。
* - 当月 N 诊分母 visit{N}_den = 新指派数 + 起算诊次 ≤ N 的继承数;接诊率 visit{N}_rate = count ÷ den(den=0 → null)。
* - 复诊合计:visit_all_count = Σ_{N≥2} visit{N}_count;visit_all_den = assigned_total_count = |NI ∪ IH|;visit_all_rate 可超过 100%。
* - 旧口径开关 inherit_mode=exclude|slot(默认 config project.revisit_rate.default_inherit_mode):
* exclude 完全复现旧实现(IH=∅、不忽略 from==to、不豁免回退行,assigned_count/visit{N}_count/visit{N}_rate 与历史逐值一致);
* 无论模式都输出 visit2_rate_new_assign = 二诊单数 ÷ 旧口径新指派数(供自动分配过渡期分档)。
* - 分档动态产出:N 从 2 起,至 max(4, 当月分子最大诊次, 最大起算诊次)(上限 MAX_VISIT_SLOT),返回 `slots` 供前端动态渲染。
* - 部门归类:医助按其人事部门(admin_dept 最小 dept_id)归组;**仅统计「二中心」及其组织下级**(与 DeptLogic::getErCenterSubtreeDeptIdSet 一致);
* 部门筛选下拉与未选部门时的默认范围均限定在该子树内,选定部门时含其组织下级。
* - 部门行 / 合计行:被指派数按诊单去重(可能小于下级行相加);N 诊单数为下级行求和(每笔订单唯一归属一名医助)。
* - 部门行 / 合计行:NI 按诊单去重;IH = (∪IH(A)) \ NI_dept,entry 取部门内最小值;N 诊单数为下级行求和(每笔订单唯一归属一名医助)。
*/
class RevisitRateLogic
{
@@ -41,10 +44,10 @@ class RevisitRateLogic
private const UNASSIGNED_DEPT_NAME = '未分配部门';
/**
* @param array{month?:string,dept_ids?:int[]|string} $params
* @param array{month?:string,dept_ids?:int[]|string,inherit_mode?:string} $params
*
* @return array{
* month:string,start_date:string,end_date:string,
* month:string,start_date:string,end_date:string,inherit_mode:string,
* slots:list<int>,
* total:array<string,int|float|null>,
* rows:list<array<string,mixed>>
@@ -62,22 +65,19 @@ class RevisitRateLogic
'month' => $month,
'start_date' => $ctx['startDate'],
'end_date' => $ctx['endDate'],
'inherit_mode' => $ctx['inheritMode'],
'slots' => $minSlots,
'total' => self::buildMetricPack(0, [], $minSlots),
'total' => self::buildMetricPack(0, [], [], $minSlots, 0),
'rows' => [],
];
}
// 分档:2 ~ max(4, 当月命中的最大诊次)
$maxHitSlot = 0;
foreach ($ctx['slotOrdersByAssistant'] as $slotMap) {
foreach ($slotMap as $slot => $_) {
if ((int) $slot > $maxHitSlot) {
$maxHitSlot = (int) $slot;
}
}
}
$slots = range(2, max(self::MIN_VISIT_SLOT_CEILING, $maxHitSlot));
// 分档:2 ~ max(4, 当月命中的最大诊次, 最大起算诊次),上限 MAX_VISIT_SLOT
$slots = RevisitRateCalculator::slotRange(
$ctx['slotOrdersByAssistant'],
(int) $ctx['maxEntry'],
self::MIN_VISIT_SLOT_CEILING
);
$nameMap = Db::name('admin')
->whereIn('id', array_keys($universe))
@@ -86,22 +86,19 @@ class RevisitRateLogic
// 医助行(按部门分组收集)
/** @var array<int, list<array<string, mixed>>> $assistantRowsByDept */
$assistantRowsByDept = [];
/** @var array<int, array<int, true>> $deptDiagSet 部门 => 去重被指派诊单集合 */
$deptDiagSet = [];
/** @var array<int, list<int>> $deptAssistants 部门 => 医助 id 列表(部门行按诊单去重用) */
$deptAssistants = [];
/** @var array<int, array<int, int>> $deptSlotCounts 部门 => [slot => 订单数] */
$deptSlotCounts = [];
/** @var array<int, true> $totalDiagSet */
$totalDiagSet = [];
/** @var array<int, int> $totalSlotCounts */
$totalSlotCounts = [];
foreach ($universe as $aid => $_) {
$deptId = (int) ($ctx['assistantDept'][$aid] ?? 0);
$deptAssistants[$deptId][] = (int) $aid;
$diagSet = $ctx['diagsByAssistant'][$aid] ?? [];
foreach ($diagSet as $did => $_d) {
$deptDiagSet[$deptId][$did] = true;
$totalDiagSet[$did] = true;
}
$inheritEntries = $ctx['inheritEntryByAssistant'][$aid] ?? [];
$excludeSet = $ctx['excludeDiagsByAssistant'][$aid] ?? [];
$slotCounts = [];
foreach ($ctx['slotOrdersByAssistant'][$aid] ?? [] as $slot => $orders) {
$cnt = \count($orders);
@@ -119,10 +116,10 @@ class RevisitRateLogic
'assistant_name' => (string) ($nameMap[$aid] ?? ('#' . $aid)),
'dept_id' => $deptId,
'dept_name' => $deptName,
] + self::buildMetricPack(\count($diagSet), $slotCounts, $slots);
] + self::buildMetricPack(\count($diagSet), $inheritEntries, $slotCounts, $slots, \count($excludeSet));
}
// 部门行 + 子行
// 部门行 + 子行(NI 按诊单去重;IH = ∪IH(A) \ NI_dept,entry 取最小)
$rows = [];
foreach ($assistantRowsByDept as $deptId => $children) {
usort($children, static function (array $a, array $b): int {
@@ -135,6 +132,13 @@ class RevisitRateLogic
$deptName = $deptId > 0
? (string) ($ctx['deptNames'][$deptId] ?? ('#' . $deptId))
: self::UNASSIGNED_DEPT_NAME;
$aids = $deptAssistants[$deptId] ?? [];
[$deptNiSet, $deptIhEntries] = RevisitRateCalculator::mergeInheritEntries(
$ctx['diagsByAssistant'],
$ctx['inheritEntryByAssistant'],
$aids
);
$deptExcludeSet = RevisitRateCalculator::unionDiagSets($ctx['excludeDiagsByAssistant'], $aids);
$rows[] = [
'row_key' => 'd' . $deptId,
'is_dept' => 1,
@@ -142,7 +146,13 @@ class RevisitRateLogic
'dept_name' => $deptName,
'assistant_count' => \count($children),
'children' => $children,
] + self::buildMetricPack(\count($deptDiagSet[$deptId] ?? []), $deptSlotCounts[$deptId] ?? [], $slots);
] + self::buildMetricPack(
\count($deptNiSet),
$deptIhEntries,
$deptSlotCounts[$deptId] ?? [],
$slots,
\count($deptExcludeSet)
);
}
usort($rows, static function (array $a, array $b): int {
if ($a['assigned_count'] !== $b['assigned_count']) {
@@ -152,54 +162,174 @@ class RevisitRateLogic
return strcmp((string) $a['dept_name'], (string) $b['dept_name']);
});
$allAids = array_map('intval', array_keys($universe));
[$totalNiSet, $totalIhEntries] = RevisitRateCalculator::mergeInheritEntries(
$ctx['diagsByAssistant'],
$ctx['inheritEntryByAssistant'],
$allAids
);
$totalExcludeSet = RevisitRateCalculator::unionDiagSets($ctx['excludeDiagsByAssistant'], $allAids);
return [
'month' => $month,
'start_date' => $ctx['startDate'],
'end_date' => $ctx['endDate'],
'inherit_mode' => $ctx['inheritMode'],
'slots' => $slots,
'total' => self::buildMetricPack(\count($totalDiagSet), $totalSlotCounts, $slots),
'total' => self::buildMetricPack(
\count($totalNiSet),
$totalIhEntries,
$totalSlotCounts,
$slots,
\count($totalExcludeSet)
),
'rows' => $rows,
];
}
/**
* 被指派明细(按诊单聚合,与「被指派数」同口径可对账)。
* 医助本人「我的 N 诊接诊率」(个人业绩页卡片,方案 §10 D1-h)。
*
* 复用 overview() 的全量结果(overview 不按个人数据范围过滤、仅限二中心子树),只从各部门行的 children 中
* 挑出 assistant_id 匹配的医助行返回,不暴露其他医助数据;本人不在二中心子树或当月无任何指派 / 成交时 row=null,
* in_scope 用于区分两种情况(false=非二中心医助)。结果按 month / assistant / mode 缓存 600 秒,缓存不可用时直接计算。
*
* @return array{month:string,inherit_mode:string,slots:list<int>,in_scope:bool,row:array<string,mixed>|null}
*/
public static function assistantOverview(int $assistantId, string $month, string $inheritMode = 'slot'): array
{
$month = self::normalizeMonth($month);
$inheritMode = $inheritMode === 'exclude' ? 'exclude' : 'slot';
$cacheKey = sprintf('revisit_rate:assistant_overview:%s:%d:%s', $month, $assistantId, $inheritMode);
try {
$cached = \think\facade\Cache::get($cacheKey);
if (\is_array($cached) && isset($cached['month'], $cached['slots'])) {
return $cached;
}
} catch (\Throwable) {
// 缓存驱动不可用:直接计算
}
$overview = self::overview(['month' => $month, 'inherit_mode' => $inheritMode]);
$row = null;
if ($assistantId > 0) {
foreach ($overview['rows'] as $deptRow) {
foreach ((array) ($deptRow['children'] ?? []) as $child) {
if ((int) ($child['assistant_id'] ?? 0) === $assistantId) {
$row = $child;
break 2;
}
}
}
}
// 是否属于二中心子树:有行必然在子树内;无行时按人事部门判断,供前端区分「非二中心」与「当月无被指派患者」
$inScope = $row !== null;
if (!$inScope && $assistantId > 0) {
[$assistantDept] = self::buildAssistantDeptIndex([$assistantId]);
$deptId = (int) ($assistantDept[$assistantId] ?? 0);
$erSet = DeptLogic::getErCenterSubtreeDeptIdSet();
$inScope = $deptId > 0 && isset($erSet[$deptId]);
}
$result = [
'month' => (string) $overview['month'],
'inherit_mode' => (string) $overview['inherit_mode'],
'slots' => array_values(array_map('intval', (array) $overview['slots'])),
'in_scope' => $inScope,
'row' => $row,
];
try {
\think\facade\Cache::set($cacheKey, $result, 600);
} catch (\Throwable) {
// 写缓存失败不影响返回
}
return $result;
}
/**
* 被指派明细(按诊单聚合,与「新指派数 / 继承数 / 被指派合计」同口径可对账)。
* scope:assistant_id(医助行)/ dept_id(部门行,含 0=未分配部门)/ 都不传 = 当前部门筛选下合计。
* kind:new=仅新指派行 / inherit=仅继承行 / all(默认)=两者;行字段 assign_type(new|inherit)、
* inherit_visit_slot(继承时诊次 k,新指派为 null)、entry_slot(起算诊次;新指派恒为 2)。
* 部门/合计 scope 下与 overview 去重规则一致:诊单只要对 scope 内任一医助为新指派即记为 new,
* 否则为 inherit 且 entry 取 scope 内最小值;故 kind=all 的「new 行数 + entry<=N 的 inherit 行数」= visit{N}_den。
*
* @param array{month?:string,dept_ids?:int[]|string,assistant_id?:int|string,dept_id?:int|string} $params
* @param array{month?:string,dept_ids?:int[]|string,assistant_id?:int|string,dept_id?:int|string,inherit_mode?:string,kind?:string} $params
*
* @return array{month:string,count:int,rows:list<array<string,mixed>>}
* @return array{month:string,inherit_mode:string,kind:string,count:int,rows:list<array<string,mixed>>}
*/
public static function assignLines(array $params): array
{
$ctx = self::buildStatsCore($params);
$assistantSet = self::applyRowScope($ctx, $params);
$kind = strtolower(trim((string) ($params['kind'] ?? 'all')));
if (!\in_array($kind, ['new', 'inherit', 'all'], true)) {
$kind = 'all';
}
$minNext = self::inheritMinNextSlot();
/** @var array<int, array{assistants: array<int, true>, assign_count: int, last_time: int}> $byDiag */
$byDiag = [];
/** @var array<int, array{assistants: array<int, true>, assign_count: int, last_time: int}> $niByDiag */
$niByDiag = [];
foreach ($ctx['pairsRaw'] as $p) {
$aid = (int) $p['to_assistant_id'];
$did = (int) $p['diagnosis_id'];
if (!isset($assistantSet[$aid])) {
continue;
}
if (!isset($byDiag[$did])) {
$byDiag[$did] = ['assistants' => [], 'assign_count' => 0, 'last_time' => 0];
if (!isset($niByDiag[$did])) {
$niByDiag[$did] = ['assistants' => [], 'assign_count' => 0, 'last_time' => 0];
}
$byDiag[$did]['assistants'][$aid] = true;
$byDiag[$did]['assign_count']++;
$byDiag[$did]['last_time'] = max($byDiag[$did]['last_time'], (int) $p['create_time']);
$niByDiag[$did]['assistants'][$aid] = true;
$niByDiag[$did]['assign_count']++;
$niByDiag[$did]['last_time'] = max($niByDiag[$did]['last_time'], (int) $p['create_time']);
}
if ($byDiag === []) {
return ['month' => $ctx['month'], 'count' => 0, 'rows' => []];
/** @var array<int, array{assistants: array<int, true>, assign_count: int, last_time: int, k: int, entry: int, is_rollback: int}> $ihByDiag */
$ihByDiag = [];
foreach ($ctx['inheritPairs'] as $p) {
$aid = (int) $p['to_assistant_id'];
$did = (int) $p['diagnosis_id'];
if (!isset($assistantSet[$aid]) || isset($niByDiag[$did])) {
continue;
}
$entry = (int) $p['entry_slot'];
if (!isset($ihByDiag[$did])) {
$ihByDiag[$did] = [
'assistants' => [],
'assign_count' => 0,
'last_time' => 0,
'k' => (int) $p['inherit_visit_slot'],
'entry' => $entry,
'is_rollback' => (int) ($p['is_rollback'] ?? 0),
];
} elseif ($entry < $ihByDiag[$did]['entry']) {
$ihByDiag[$did]['entry'] = $entry;
$ihByDiag[$did]['k'] = (int) $p['inherit_visit_slot'];
$ihByDiag[$did]['is_rollback'] = (int) ($p['is_rollback'] ?? 0);
}
$ihByDiag[$did]['assistants'][$aid] = true;
$ihByDiag[$did]['assign_count'] += (int) ($p['assign_count'] ?? 1);
$ihByDiag[$did]['last_time'] = max($ihByDiag[$did]['last_time'], (int) $p['create_time']);
}
$diagInfo = self::fetchDiagnosisInfo(array_keys($byDiag));
if ($kind === 'new') {
$ihByDiag = [];
} elseif ($kind === 'inherit') {
$niByDiag = [];
}
if ($niByDiag === [] && $ihByDiag === []) {
return ['month' => $ctx['month'], 'inherit_mode' => $ctx['inheritMode'], 'kind' => $kind, 'count' => 0, 'rows' => []];
}
$diagInfo = self::fetchDiagnosisInfo(array_values(array_unique(array_merge(array_keys($niByDiag), array_keys($ihByDiag)))));
$assistantIds = [];
foreach ($byDiag as $d) {
foreach ($d['assistants'] as $aid => $_) {
$assistantIds[$aid] = true;
foreach ([$niByDiag, $ihByDiag] as $map) {
foreach ($map as $d) {
foreach ($d['assistants'] as $aid => $_) {
$assistantIds[$aid] = true;
}
}
}
$nameMap = Db::name('admin')
@@ -207,7 +337,7 @@ class RevisitRateLogic
->column('name', 'id');
$rows = [];
foreach ($byDiag as $did => $d) {
foreach ($niByDiag as $did => $d) {
$names = [];
foreach ($d['assistants'] as $aid => $_) {
$names[] = (string) ($nameMap[$aid] ?? ('#' . $aid));
@@ -220,24 +350,50 @@ class RevisitRateLogic
'assign_count' => (int) $d['assign_count'],
'last_assign_time' => (int) $d['last_time'],
'last_assign_time_text' => $d['last_time'] > 0 ? date('Y-m-d H:i:s', $d['last_time']) : '',
'assign_type' => 'new',
'assign_type_text' => '新指派',
'inherit_visit_slot' => null,
'entry_slot' => $minNext,
'is_rollback' => 0,
];
}
foreach ($ihByDiag as $did => $d) {
$names = [];
foreach ($d['assistants'] as $aid => $_) {
$names[] = (string) ($nameMap[$aid] ?? ('#' . $aid));
}
$rows[] = [
'diagnosis_id' => (int) $did,
'patient_name' => (string) ($diagInfo[$did]['patient_name'] ?? ''),
'patient_phone' => (string) ($diagInfo[$did]['phone'] ?? ''),
'assistant_names' => implode('、', $names),
'assign_count' => (int) $d['assign_count'],
'last_assign_time' => (int) $d['last_time'],
'last_assign_time_text' => $d['last_time'] > 0 ? date('Y-m-d H:i:s', $d['last_time']) : '',
'assign_type' => 'inherit',
'assign_type_text' => $d['is_rollback'] === 1 ? '继承(回退)' : '继承',
'inherit_visit_slot' => (int) $d['k'],
'entry_slot' => (int) $d['entry'],
'is_rollback' => (int) $d['is_rollback'],
];
}
usort($rows, static fn (array $a, array $b): int => $b['last_assign_time'] <=> $a['last_assign_time']);
return ['month' => $ctx['month'], 'count' => \count($rows), 'rows' => $rows];
return ['month' => $ctx['month'], 'inherit_mode' => $ctx['inheritMode'], 'kind' => $kind, 'count' => \count($rows), 'rows' => $rows];
}
/**
* N 诊订单明细:scope 内当月下单、全局序号 = slot 的具体订单(归属持有医助),与 visit{slot}_count 同口径可对账。
* slot=0 表示全部 N>=2(与 visit_all_count 对账),行附 visit_slot。
*
* @param array{month?:string,slot?:int|string,dept_ids?:int[]|string,assistant_id?:int|string,dept_id?:int|string} $params
* @param array{month?:string,slot?:int|string,dept_ids?:int[]|string,assistant_id?:int|string,dept_id?:int|string,inherit_mode?:string} $params
*
* @return array{month:string,slot:int,count:int,rows:list<array<string,mixed>>}
*/
public static function visitOrderLines(array $params): array
{
$slot = (int) ($params['slot'] ?? 0);
if ($slot < 2 || $slot > self::MAX_VISIT_SLOT) {
if ($slot < 0 || $slot === 1 || $slot > self::MAX_VISIT_SLOT) {
return ['month' => self::normalizeMonth((string) ($params['month'] ?? '')), 'slot' => $slot, 'count' => 0, 'rows' => []];
}
@@ -246,9 +402,16 @@ class RevisitRateLogic
$orderRows = [];
foreach ($assistantSet as $aid => $_) {
foreach ($ctx['slotOrdersByAssistant'][$aid][$slot] ?? [] as $r) {
$r['holder_assistant_id'] = (int) $aid;
$orderRows[] = $r;
$slotMap = $ctx['slotOrdersByAssistant'][$aid] ?? [];
if ($slot > 0) {
$slotMap = isset($slotMap[$slot]) ? [$slot => $slotMap[$slot]] : [];
}
foreach ($slotMap as $s => $orders) {
foreach ($orders as $r) {
$r['holder_assistant_id'] = (int) $aid;
$r['visit_slot'] = (int) $s;
$orderRows[] = $r;
}
}
}
if ($orderRows === []) {
@@ -290,6 +453,7 @@ class RevisitRateLogic
'creator_name' => $cid > 0 ? (string) ($adminNames[$cid] ?? ('#' . $cid)) : '—',
'assistant_id' => $hid,
'assistant_name' => $hid > 0 ? (string) ($adminNames[$hid] ?? ('#' . $hid)) : '—',
'visit_slot' => (int) ($r['visit_slot'] ?? 0),
];
}
usort($rows, static fn (array $a, array $b): int => $b['create_time'] <=> $a['create_time']);
@@ -331,17 +495,22 @@ class RevisitRateLogic
/**
* 核心统计上下文:
* 1. 全量指派日志(≤ 月末)构建持有时间线;
* 2. 分母:当月非继承指派的「医助 × 诊单」,再剔除名下存在拒收(9)/退款(10) 订单的诊单;
* 2. 分母:当月新指派 NI 与继承 IH 的「医助 × 诊单」(RevisitRateCalculator),再剔除名下存在拒收(9)/退款(10) 订单的诊单;
* 3. 分子:曾被指派诊单的当月订单,统计诊次 = 实单序号 + 诊单偏移(默认第 1 笔实单为一诊);
* 4. 应用部门筛选:默认限定「二中心」子树;选定部门时再收窄到该部门及其下级(且须落在二中心子树内)。
* 4. 应用部门筛选:默认限定「二中心」子树;选定部门时再收窄到该部门及其下级(且须落在二中心子树内);
* 继承集合与旧口径集合与分母/分子一并过滤(当月仅有继承行的医助不会静默丢失、也不会逃过二中心过滤)。
*
* @param array{month?:string,dept_ids?:int[]|string} $params
* @param array{month?:string,dept_ids?:int[]|string,inherit_mode?:string} $params
*
* @return array{
* month:string,startDate:string,endDate:string,startTs:int,endTs:int,
* pairsRaw:list<array{diagnosis_id:int,to_assistant_id:int,create_time:int}>,
* month:string,startDate:string,endDate:string,startTs:int,endTs:int,inheritMode:string,
* pairsRaw:list<array{diagnosis_id:int,to_assistant_id:int,from_assistant_id:int,create_time:int,log_id:int}>,
* diagsByAssistant:array<int,array<int,true>>,
* excludeDiagsByAssistant:array<int,array<int,true>>,
* inheritEntryByAssistant:array<int,array<int,int>>,
* inheritPairs:list<array<string,mixed>>,
* slotOrdersByAssistant:array<int,array<int,list<array<string,mixed>>>>,
* maxEntry:int,
* assistantDept:array<int,int>,
* deptNames:array<int,string>
* }
@@ -351,125 +520,90 @@ class RevisitRateLogic
$month = self::normalizeMonth((string) ($params['month'] ?? ''));
$startTs = (int) strtotime($month . '-01 00:00:00');
$endTs = (int) strtotime(date('Y-m-t', $startTs) . ' 23:59:59');
$inheritMode = self::resolveInheritMode($params);
// 全量指派日志(≤ 月末,诊单未删除):含释放(to=0)与继承行,用于持有时间线
// inherit_visit_slot 为写入时快照,仅供明细展示;统计一律以实时重算为准
$logRows = Db::name('tcm_diagnosis_assign_log')
->alias('lg')
->join('tcm_diagnosis dg', 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->where('lg.create_time', '<=', $endTs)
->where('lg.diagnosis_id', '>', 0)
->field(['lg.id', 'lg.diagnosis_id', 'lg.to_assistant_id', 'lg.create_time', 'lg.is_inherit'])
->field([
'lg.id', 'lg.diagnosis_id', 'lg.from_assistant_id', 'lg.to_assistant_id', 'lg.operator_admin_id',
'lg.create_time', 'lg.is_inherit', 'lg.inherit_visit_slot',
])
->order(['lg.diagnosis_id' => 'asc', 'lg.create_time' => 'asc', 'lg.id' => 'asc'])
->select()
->toArray();
/** @var array<int, list<array{t:int,to:int}>> $timeline 诊单 => 持有变更时间线(升序) */
$timeline = [];
$pairsRaw = [];
/** @var array<int, array<int, true>> $diagsByAssistant 分母:医助 => 诊单集合 */
$diagsByAssistant = [];
/** @var array<int, true> $candidateDiagSet 曾被指派(to>0,含继承)的诊单 */
$candidateDiagSet = [];
foreach ($logRows as $r) {
$did = (int) ($r['diagnosis_id'] ?? 0);
$aid = (int) ($r['to_assistant_id'] ?? 0);
$t = (int) ($r['create_time'] ?? 0);
$timeline[$did][] = ['t' => $t, 'to' => $aid];
if ($aid > 0) {
$candidateDiagSet[$did] = true;
if ((int) ($r['is_inherit'] ?? 0) === 0 && $t >= $startTs && $t <= $endTs) {
$pairsRaw[] = ['diagnosis_id' => $did, 'to_assistant_id' => $aid, 'create_time' => $t];
$diagsByAssistant[$aid][$did] = true;
}
}
}
// 分母剔除集合:当月被指派(含继承)诊单中名下存在拒收(9)/退款(10) 业务订单者(与明细 assignLines 同口径)
$refundRejectDiagSet = self::fetchRefundOrRejectDiagnosisSet(
RevisitRateCalculator::monthAssignedDiagIds($logRows, $startTs, $endTs)
);
// 历史「回退自动分配」行(slot 模式按继承处理)
$rollbackKeySet = $inheritMode === RevisitRateCalculator::MODE_SLOT
? self::fetchRollbackKeySet($startTs, $endTs)
: [];
// 分母:剔除名下存在拒收(9)/退款(10) 业务订单的诊单(与明细 assignLines 同口径)
$assignedDiagIds = [];
foreach ($diagsByAssistant as $diagSet) {
foreach ($diagSet as $did => $_) {
$assignedDiagIds[(int) $did] = true;
}
}
$refundRejectDiagSet = self::fetchRefundOrRejectDiagnosisSet(array_keys($assignedDiagIds));
if ($refundRejectDiagSet !== []) {
foreach ($diagsByAssistant as $aid => $diagSet) {
foreach ($diagSet as $did => $_) {
if (isset($refundRejectDiagSet[$did])) {
unset($diagsByAssistant[$aid][$did]);
}
}
if ($diagsByAssistant[$aid] === []) {
unset($diagsByAssistant[$aid]);
}
}
$pairsRaw = array_values(array_filter(
$pairsRaw,
static fn (array $p): bool => !isset($refundRejectDiagSet[(int) $p['diagnosis_id']])
));
}
// 分子:曾被指派诊单的订单序列(分块生成器,同一诊单连续),统计诊次 = 实单全局序号 + 诊单偏移
$candidateDiagIds = RevisitRateCalculator::candidateDiagIds($logRows);
$offsetMap = self::fetchRevisitSlotStartOffsetMap($candidateDiagIds);
$orderRows = self::iterateOrderSeqRows(
$candidateDiagIds,
['o.id', 'o.order_no', 'o.diagnosis_id', 'o.create_time', 'o.amount', 'o.creator_id']
);
// 分子:曾被指派诊单的当月订单,统计诊次 = 实单全局序号 + 诊单偏移(默认偏移 0 → 第 1 笔实单为一诊)
/** @var array<int, array<int, list<array<string, mixed>>>> $slotOrdersByAssistant */
$slotOrdersByAssistant = [];
$offsetMap = self::fetchRevisitSlotStartOffsetMap(array_keys($candidateDiagSet));
foreach (array_chunk(array_keys($candidateDiagSet), 2000) as $chunk) {
$orderRows = self::fetchOrderSeqRows(
$chunk,
['o.id', 'o.order_no', 'o.diagnosis_id', 'o.create_time', 'o.amount', 'o.creator_id']
);
$curDid = 0;
$seq = 0;
$ptr = 0;
$holder = 0;
$offset = 0;
foreach ($orderRows as $r) {
$did = (int) ($r['diagnosis_id'] ?? 0);
if ($did <= 0) {
continue;
}
if ($did !== $curDid) {
$curDid = $did;
$seq = 0;
$ptr = 0;
$holder = 0;
$offset = self::resolveRevisitSlotStartOffset($did, $offsetMap);
}
$seq++;
$effectiveSlot = $seq + $offset;
$ct = (int) ($r['create_time'] ?? 0);
// 推进时间线指针:订单时间之前(含同刻)最近一次指派的持有人
$tl = $timeline[$did] ?? [];
$tlCount = \count($tl);
while ($ptr < $tlCount && $tl[$ptr]['t'] <= $ct) {
$holder = (int) $tl[$ptr]['to'];
$ptr++;
}
if ($effectiveSlot < 2 || $effectiveSlot > self::MAX_VISIT_SLOT) {
continue;
}
if ($ct < $startTs || $ct > $endTs) {
continue;
}
if ($holder > 0) {
$slotOrdersByAssistant[$holder][$effectiveSlot][] = $r;
}
}
}
$calc = RevisitRateCalculator::compute(
$logRows,
$orderRows,
$offsetMap,
$refundRejectDiagSet,
$rollbackKeySet,
$startTs,
$endTs,
$inheritMode,
self::inheritMinNextSlot()
);
unset($logRows);
$pairsRaw = $calc['pairsRaw'];
$diagsByAssistant = $calc['diagsByAssistant'];
$excludeDiagsByAssistant = $calc['excludeDiagsByAssistant'];
$inheritEntryByAssistant = $calc['inheritEntryByAssistant'];
$inheritPairs = $calc['inheritPairs'];
$slotOrdersByAssistant = $calc['slotOrdersByAssistant'];
// 医助归属部门 + 部门筛选(默认仅二中心子树;选定部门时再收窄,含组织下级)
$universeIds = array_keys($diagsByAssistant + $slotOrdersByAssistant);
$universeIds = array_keys(
$diagsByAssistant + $slotOrdersByAssistant + $inheritEntryByAssistant + $excludeDiagsByAssistant
);
[$assistantDept, $deptNames] = self::buildAssistantDeptIndex($universeIds);
$subtreeSet = self::resolveDeptFilterSet($params['dept_ids'] ?? null);
if ($subtreeSet === []) {
// 无二中心部门时整表为空,避免误展示其它中心数据
$diagsByAssistant = [];
$excludeDiagsByAssistant = [];
$inheritEntryByAssistant = [];
$slotOrdersByAssistant = [];
} else {
foreach ($universeIds as $aid) {
$deptId = (int) ($assistantDept[$aid] ?? 0);
if ($deptId <= 0 || !isset($subtreeSet[$deptId])) {
unset($diagsByAssistant[$aid], $slotOrdersByAssistant[$aid]);
unset(
$diagsByAssistant[$aid],
$excludeDiagsByAssistant[$aid],
$inheritEntryByAssistant[$aid],
$slotOrdersByAssistant[$aid]
);
}
}
}
// 列范围上界只看过滤后仍在表内的继承行
$maxEntry = 0;
foreach ($inheritEntryByAssistant as $entries) {
foreach ($entries as $entry) {
if ((int) $entry <= self::MAX_VISIT_SLOT && (int) $entry > $maxEntry) {
$maxEntry = (int) $entry;
}
}
}
@@ -480,29 +614,95 @@ class RevisitRateLogic
'endDate' => date('Y-m-d', $endTs),
'startTs' => $startTs,
'endTs' => $endTs,
'inheritMode' => $inheritMode,
'pairsRaw' => $pairsRaw,
'diagsByAssistant' => $diagsByAssistant,
'excludeDiagsByAssistant' => $excludeDiagsByAssistant,
'inheritEntryByAssistant' => $inheritEntryByAssistant,
'inheritPairs' => $inheritPairs,
'slotOrdersByAssistant' => $slotOrdersByAssistant,
'maxEntry' => $maxEntry,
'assistantDept' => $assistantDept,
'deptNames' => $deptNames,
];
}
/**
* 统计涉及的医助全集:分母(被指派)∪ 分子(持有成交)。
* 口径白名单:请求参数 inherit_mode=exclude|slot,缺省取 config project.revisit_rate.default_inherit_mode(再缺省 slot)。
*/
private static function resolveInheritMode(array $params): string
{
$default = (string) (config('project.revisit_rate.default_inherit_mode') ?? RevisitRateCalculator::MODE_SLOT);
return RevisitRateCalculator::normalizeMode($params['inherit_mode'] ?? '', $default);
}
/** 继承起算诊次下限(config project.revisit_rate.inherit_min_next_slot,默认 2,最小 2) */
private static function inheritMinNextSlot(): int
{
$v = (int) (config('project.revisit_rate.inherit_min_next_slot') ?? RevisitRateCalculator::DEFAULT_MIN_NEXT_SLOT);
return max(RevisitRateCalculator::DEFAULT_MIN_NEXT_SLOT, $v);
}
/**
* 当月「回退自动分配」键集合:auto_assign_log(diagnosis_id, rollback_time, rollback_admin_id) 与
* assign_log(diagnosis_id, create_time, operator_admin_id) 一一对应(AutoAssignLogLogic::rollbackOne 同一事务同一 $now)。
*
* @param array{diagsByAssistant:array<int,array<int,true>>,slotOrdersByAssistant:array<int,array<int,list<array<string,mixed>>>>} $ctx
* @return array<string, true>
*/
private static function fetchRollbackKeySet(int $startTs, int $endTs): array
{
$rows = Db::name('tcm_diagnosis_auto_assign_log')
->where('rollback_time', '>=', $startTs)
->where('rollback_time', '<=', $endTs)
->where('diagnosis_id', '>', 0)
->field(['diagnosis_id', 'rollback_time', 'rollback_admin_id'])
->select()
->toArray();
$out = [];
foreach ($rows as $r) {
$out[RevisitRateCalculator::rollbackKey(
(int) ($r['diagnosis_id'] ?? 0),
(int) ($r['rollback_time'] ?? 0),
(int) ($r['rollback_admin_id'] ?? 0)
)] = true;
}
return $out;
}
/**
* 订单序列分块生成器:每块按诊单 id 切分(一个诊单的订单全部落在同一块),块内已按 (diagnosis_id, create_time, id) 升序。
*
* @param list<int> $diagIds
* @param list<string> $fields
*
* @return \Generator<int, array<string, mixed>>
*/
private static function iterateOrderSeqRows(array $diagIds, array $fields): \Generator
{
foreach (array_chunk($diagIds, 2000) as $chunk) {
foreach (self::fetchOrderSeqRows($chunk, $fields) as $r) {
yield $r;
}
}
}
/**
* 统计涉及的医助全集:新指派 ∪ 继承 ∪ 旧口径新指派 ∪ 分子(持有成交)。
*
* @param array{diagsByAssistant:array<int,array<int,true>>,excludeDiagsByAssistant:array<int,array<int,true>>,inheritEntryByAssistant:array<int,array<int,int>>,slotOrdersByAssistant:array<int,array<int,list<array<string,mixed>>>>} $ctx
*
* @return array<int, true>
*/
private static function assistantUniverse(array $ctx): array
{
$set = [];
foreach (array_keys($ctx['diagsByAssistant']) as $aid) {
$set[(int) $aid] = true;
}
foreach (array_keys($ctx['slotOrdersByAssistant']) as $aid) {
$set[(int) $aid] = true;
foreach (['diagsByAssistant', 'excludeDiagsByAssistant', 'inheritEntryByAssistant', 'slotOrdersByAssistant'] as $key) {
foreach (array_keys($ctx[$key] ?? []) as $aid) {
$set[(int) $aid] = true;
}
}
return $set;
@@ -715,17 +915,8 @@ class RevisitRateLogic
*/
private static function fetchOrderSeqRows(array $diagIds, array $fields): array
{
$q = Db::name('tcm_prescription_order')
->alias('o')
->whereIn('o.diagnosis_id', $diagIds)
->whereNull('o.delete_time');
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, 'o');
return $q
->field($fields)
->order(['o.diagnosis_id' => 'asc', 'o.create_time' => 'asc', 'o.id' => 'asc'])
->select()
->toArray();
// 实现已搬至 RevisitVisitSlotService(诊次口径单点维护),此处保留委托以减少 diff
return RevisitVisitSlotService::fetchOrderSeqRows($diagIds, $fields);
}
/**
@@ -735,21 +926,8 @@ class RevisitRateLogic
*/
private static function fetchRevisitSlotStartOffsetMap(array $diagIds): array
{
if ($diagIds === []) {
return [];
}
$out = [];
foreach (array_chunk($diagIds, 2000) as $chunk) {
$rows = Db::name('tcm_diagnosis')
->whereIn('id', $chunk)
->whereNull('delete_time')
->column('revisit_slot_start_offset', 'id');
foreach ($rows as $id => $offset) {
$out[(int) $id] = (int) $offset;
}
}
return $out;
// 实现已搬至 RevisitVisitSlotService(诊次口径单点维护),此处保留委托以减少 diff
return RevisitVisitSlotService::fetchRevisitSlotStartOffsetMap($diagIds);
}
/**
@@ -759,15 +937,8 @@ class RevisitRateLogic
*/
private static function resolveRevisitSlotStartOffset(int $diagId, array $offsetMap): int
{
$offset = (int) ($offsetMap[$diagId] ?? 0);
if ($offset < 0) {
$offset = 0;
}
if ($offset > 20) {
$offset = 20;
}
return $offset;
// 实现已搬至 RevisitVisitSlotService(诊次口径单点维护),此处保留委托以减少 diff
return RevisitVisitSlotService::resolveRevisitSlotStartOffset($diagId, $offsetMap);
}
/**
@@ -797,23 +968,24 @@ class RevisitRateLogic
}
/**
* @param array<int, int> $slotCounts [slot => n]
* @param list<int> $slots 需输出的分档列表(保证各行键齐全)
* 指标包:assigned_count(新指派,语义不变)、inherit_count、assigned_total_count、
* visit{N}_count/_den/_rate、visit_all_count/_den/_rate、visit2_rate_new_assign(实现见 RevisitRateCalculator::metricPack)。
*
* @param array<int, int> $inheritEntryByDiag 诊单 => 起算诊次
* @param array<int, int> $slotCounts [slot => n]
* @param list<int> $slots 需输出的分档列表(保证各行键齐全)
* @param int $newAssignedExclude 旧口径(:380 现状)新指派数,供 visit2_rate_new_assign
*
* @return array<string, int|float|null>
*/
private static function buildMetricPack(int $assigned, array $slotCounts, array $slots): array
{
$pack = ['assigned_count' => $assigned];
foreach ($slots as $slot) {
$cnt = (int) ($slotCounts[$slot] ?? 0);
$pack['visit' . $slot . '_count'] = $cnt;
$pack['visit' . $slot . '_rate'] = $assigned > 0
? round($cnt / $assigned * 100, 2)
: null;
}
return $pack;
private static function buildMetricPack(
int $newAssigned,
array $inheritEntryByDiag,
array $slotCounts,
array $slots,
int $newAssignedExclude
): array {
return RevisitRateCalculator::metricPack($newAssigned, $inheritEntryByDiag, $slotCounts, $slots, $newAssignedExclude);
}
/** 归一化月份参数为 YYYY-MM,非法时回退当前月 */
@@ -22,7 +22,8 @@ use think\facade\Db;
* - 进线数据 = 区间内 add_external_contact 事件,按接待员工(user_id) 归属部门
* · 与 qywx.customer/todayArrival、客户列表同源事件表;选定渠道时客户须带对应企微标签(contact_tag);**二中心及其组织下级在选定渠道下进线计 0**
* - 被指派数 = 区间内 `tcm_diagnosis_assign_log` 且 **to_assistant_id>0**,**剔除勾选「继承」的指派 is_inherit=1**,按**指派操作时间 lg.create_time** 落区间,
* 「医助 × 诊单」去重、剔除已删诊单 —— 与「复诊接诊率」页面「当月被指派总数」完全同口径(见 RevisitRateLogic)
* 「医助 × 诊单」去重、剔除已删诊单 —— 与「复诊接诊率」页面的『新指派数』(assigned_count) 同口径(见 RevisitRateLogic;
* 接诊率 v2 另有继承分母 inherit_count,本看板不纳入——方案 D1-g)
* · **二中心及其组织下级在选定渠道下计 0**(与进线一致)
* - 复诊统计 = 仅**名称含「二中心」的部门及其组织架构下级**:区间内业务单按患者 create_time 排序,第 1 笔不计,第 2 笔起分列「复诊2」「复诊3」…;口径同业绩单条件;按**订单创建人**所在部门在组织树内 rollup(含下级),与排行榜复诊一致
* - 已完成挂号 = status=3 挂号条数(appointment_date),列「已完成挂号」;部门归属:有效医助→接诊医生→展示行映射。
@@ -856,9 +857,9 @@ class YejiStatsLogic
$rangeNote = $channelFilterActive
? ('排行诊金 = 选定渠道「' . $channelName . '」业绩中「订单创建人 = 该医助」的部分;与部门表该渠道业绩列同口径(按订单创建人归属)。被指派数与部门表同口径。接诊率 = 诊金 ÷ 进线(元/进线)。'
. '「二中心」及其组织下级医助在该渠道下诊金、进线与被指派计 0。'
. '「二中心」及其组织下级医助在该渠道下诊金、进线与被指派计 0。被指派数与复诊接诊率『新指派数』同口径(不含继承)。'
. ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。')
: '排行诊金 = 业务订单中「订单创建人 = 该医助」的金额合计;与部门表「合计业绩」同口径(按订单创建人归属)。被指派数为指派至该医助的次数(剔除勾选「继承」的指派)。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。'
: '排行诊金 = 业务订单中「订单创建人 = 该医助」的金额合计;与部门表「合计业绩」同口径(按订单创建人归属)。被指派数为指派至该医助的次数(剔除勾选「继承」的指派,与复诊接诊率『新指派数』同口径)。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。'
. ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。';
$leaderboards = [];
@@ -1780,7 +1781,7 @@ class YejiStatsLogic
/**
* 被指派数:统计 `tcm_diagnosis_assign_log`(to_assistant_id>0,**剔除勾选「继承」的指派 is_inherit=1**),
* 按**指派操作时间 lg.create_time** 落入区间,「医助 × 诊单」去重(COUNT DISTINCT diagnosis_id)、剔除已删诊单,
* 与「复诊接诊率」页面同口径。选定字典渠道时按挂号 channels EXISTS 收窄;tag 退化口径按日志字段过滤。
* 与「复诊接诊率」页面『新指派数』(assigned_count) 同口径(不含接诊率 v2 的继承分母)。选定字典渠道时按挂号 channels EXISTS 收窄;tag 退化口径按日志字段过滤。
*
* @param array<int, int> $adminToPrimary
* @param int[]|null $tagDiagIds
@@ -1871,7 +1872,7 @@ class YejiStatsLogic
}
/**
* 医助维度被指派次数:与 countDiagnosisAssignsByDept 同过滤与区间(同样剔除 is_inherit=1),按 to_assistant_id 汇总(供排行榜)。
* 医助维度被指派次数:与 countDiagnosisAssignsByDept 同过滤与区间(同样剔除 is_inherit=1,与复诊接诊率『新指派数』同口径),按 to_assistant_id 汇总(供排行榜)。
*
* @return array<int, int>
*/
@@ -2617,7 +2618,7 @@ class YejiStatsLogic
}
/**
* 被指派明细:与看板「被指派数」同口径(区间内非继承指派,医助×诊单去重)。
* 被指派明细:与看板「被指派数」同口径(区间内非继承指派,医助×诊单去重;即复诊接诊率『新指派数』口径)。
*
* @param array{
* start_date?:string,end_date?:string,dept_ids?:int[]|string,channel_code?:string,tag_id?:string,
@@ -2910,7 +2911,7 @@ class YejiStatsLogic
}
$note = '与看板「被指派数」同口径:区间内 `tcm_diagnosis_assign_log` 成功指派(to_assistant_id>0,剔除勾选「继承」),'
. '按指派操作时间落区间,「医助 × 诊单」去重、剔除已删诊单。';
. '按指派操作时间落区间,「医助 × 诊单」去重、剔除已删诊单;与复诊接诊率『新指派数』(assigned_count) 同口径,不含继承。';
return [
'start_date' => (string) $c['startDate'],
@@ -38,6 +38,9 @@ use app\common\service\AppointmentCallPolicy;
use app\common\service\FileService;
use app\common\service\DataScope\DataScopeService;
use app\common\service\storage\Driver as StorageDriver;
use app\common\service\tcm\RevisitDueHook;
use app\common\service\tcm\RevisitDueService;
use app\common\service\tcm\RevisitVisitSlotService;
use think\facade\Db;
/**
* 中医辨房病因诊单逻辑
@@ -260,6 +263,10 @@ class DiagnosisLogic extends BaseLogic
{
try {
Diagnosis::destroy($params['id']);
// 诊单删除后把复诊到期快照推进为失效(Hook 内部吞异常,不影响删除结果)
foreach ((array) $params['id'] as $deletedId) {
RevisitDueHook::onDiagnosisChanged((int) $deletedId, 'delete');
}
return true;
} catch (\Exception $e) {
self::setError($e->getMessage());
@@ -707,6 +714,15 @@ class DiagnosisLogic extends BaseLogic
if ($fromAssistantId <= 0 && $toAssistantId > 0) {
$fromAssistantId = self::inferPreviousAssistantIdFromLatestAssignLog($id);
}
// from == to(指派给自己)不硬拦:批量指派流程不中断,复诊接诊率 slot 口径统计时忽略该行;前端弹窗负责提示
// 继承时患者所处诊次快照(继承时刻之前的有效实单数 + 偏移;统计以实时重算为准,本列供指派记录面板展示/对账)
$inheritVisitSlot = 0;
$inheritVisitSlotCalcTime = 0;
if ($isInherit === 1) {
$inheritVisitSlot = min(255, max(0, RevisitVisitSlotService::slotAtTime($id, $now)));
$inheritVisitSlotCalcTime = $now;
}
Db::name('tcm_diagnosis')
->where('id', $id)
@@ -720,17 +736,19 @@ class DiagnosisLogic extends BaseLogic
$admin = $req->adminInfo ?? [];
// Db::insert 直写:与 assignLogList 一致,避免模型 allowField/schema 丢列
Db::name('tcm_diagnosis_assign_log')->insert([
'diagnosis_id' => $id,
'from_assistant_id' => $fromAssistantId,
'to_assistant_id' => $toAssistantId,
'operator_admin_id' => (int) ($admin['admin_id'] ?? 0),
'operator_name' => (string) ($admin['name'] ?? ''),
'operator_account' => (string) ($admin['account'] ?? ''),
'ip' => (string) ($req->ip() ?? ''),
'related_po_creator_id' => $relatedPoCreatorId,
'related_po_create_time' => $relatedPoCreateTime,
'is_inherit' => $isInherit,
'create_time' => $now,
'diagnosis_id' => $id,
'from_assistant_id' => $fromAssistantId,
'to_assistant_id' => $toAssistantId,
'operator_admin_id' => (int) ($admin['admin_id'] ?? 0),
'operator_name' => (string) ($admin['name'] ?? ''),
'operator_account' => (string) ($admin['account'] ?? ''),
'ip' => (string) ($req->ip() ?? ''),
'related_po_creator_id' => $relatedPoCreatorId,
'related_po_create_time' => $relatedPoCreateTime,
'is_inherit' => $isInherit,
'inherit_visit_slot' => $inheritVisitSlot,
'inherit_visit_slot_calc_time' => $inheritVisitSlotCalcTime,
'create_time' => $now,
]);
} catch (\Throwable $e) {
Db::rollback();
@@ -741,6 +759,9 @@ class DiagnosisLogic extends BaseLogic
Db::commit();
// 事务提交后刷新复诊到期快照(换医助重置延迟字段;钩子内部吞异常,不影响指派结果)
RevisitDueHook::onAssistantChanged($id, $toAssistantId, 'assign');
return true;
} catch (\Exception $e) {
Db::rollback();
@@ -785,6 +806,9 @@ class DiagnosisLogic extends BaseLogic
try {
$diagnosis->save(['revisit_slot_start_offset' => $offset]);
// 起始偏移影响 current_slot/next_slot:刷新复诊到期快照(钩子内部吞异常)
RevisitDueHook::onDiagnosisChanged($diagnosisId, 'offset');
return true;
} catch (\Exception $e) {
self::setError($e->getMessage());
@@ -849,6 +873,16 @@ class DiagnosisLogic extends BaseLogic
$inherit = (int) ($r['is_inherit'] ?? 0);
$r['is_inherit'] = $inherit;
$r['is_inherit_text'] = $inherit === 1 ? '是' : '否';
// 继承时诊次快照(2026-10 加列前的历史继承行 calc_time=0,无法回溯,文案留空)
$k = (int) ($r['inherit_visit_slot'] ?? 0);
$kCalcTime = (int) ($r['inherit_visit_slot_calc_time'] ?? 0);
$r['inherit_visit_slot'] = $k;
$r['inherit_visit_slot_calc_time'] = $kCalcTime;
if ($inherit === 1 && $kCalcTime > 0) {
$r['inherit_visit_slot_text'] = $k > 0 ? sprintf('继承时第 %d 诊', $k) : '继承时未成交';
} else {
$r['inherit_visit_slot_text'] = '';
}
}
unset($r);
@@ -4281,6 +4315,9 @@ class DiagnosisLogic extends BaseLogic
$unservedDays = $maxRecordTs > 0 ? max(0, (int) floor((time() - $maxRecordTs) / 86400)) : null;
$lastBloodRecordAt = $maxRecordTs > 0 ? date('Y-m-d', $maxRecordTs) : null;
// 7) 复诊到期(契约 C,与诊单列表行 revisit_due 同构;无快照且无法计算时为 state='none')
$revisitDue = RevisitDueService::buildListPayload([$diagnosisId], ['self_heal' => true, 'page_size' => 1])[$diagnosisId] ?? null;
return [
'appointment' => $appointment,
'diagnosis' => $diagnosis,
@@ -4288,6 +4325,7 @@ class DiagnosisLogic extends BaseLogic
'tracking_notes' => $trackingNotes,
'unserved_days' => $unservedDays,
'last_blood_record_at' => $lastBloodRecordAt,
'revisit_due' => $revisitDue,
];
}
@@ -36,6 +36,7 @@ use app\common\service\pharmacy\LockedPharmacySnapshotMutation;
use app\common\service\pharmacy\PharmacySubmissionClaimService;
use app\common\service\pharmacy\PharmacySubmissionClaimWorkflow;
use app\common\service\pharmacy\PharmacySupplyMode;
use app\common\service\tcm\RevisitDueHook;
use think\db\Query;
use think\facade\Config;
use think\facade\Db;
@@ -979,7 +980,7 @@ class PrescriptionOrderLogic
{
self::$error = '';
try {
return Db::transaction(static function () use ($params, $adminId, $adminInfo) {
$created = Db::transaction(static function () use ($params, $adminId, $adminInfo) {
// 先锁始终存在的处方行;空订单集合也能串行化两次创建。
// 首个一致性读发生在获得该锁后,等待者可见前一次创建提交的订单。
$rx = Prescription::where('id', (int) ($params['prescription_id'] ?? 0))
@@ -998,6 +999,13 @@ class PrescriptionOrderLogic
self::$error = $e->getMessage();
return false;
}
// 事务提交后刷新复诊到期快照(新订单开启新周期;钩子内部吞异常,不影响创建结果)
if (is_array($created)) {
RevisitDueHook::onOrderChanged((int) ($created['diagnosis_id'] ?? 0), (int) ($created['id'] ?? 0), 'create');
}
return $created;
}
private static function createLocked(array $params, int $adminId, array $adminInfo)
@@ -1165,7 +1173,7 @@ class PrescriptionOrderLogic
}
try {
return Db::transaction(static function () use ($params, $adminId, $adminInfo) {
$edited = Db::transaction(static function () use ($params, $adminId, $adminInfo) {
$order = PrescriptionOrder::where('id', (int) $params['id'])
->whereNull('delete_time')->lock(true)->find();
if (!$order) {
@@ -1192,6 +1200,13 @@ class PrescriptionOrderLogic
self::$error = $e->getMessage();
return false;
}
// 事务提交后刷新复诊到期快照:下单时间影响锚点/诊次;返回体不含 diagnosis_id,由钩子按订单 id 反查
if (is_array($edited)) {
RevisitDueHook::onOrderChanged(0, (int) ($edited['id'] ?? 0), 'edit_time');
}
return $edited;
}
public static function detail(int $id, int $adminId, array $adminInfo): ?array
@@ -2251,7 +2266,7 @@ class PrescriptionOrderLogic
{
self::setError('');
try {
return LockedPharmacySnapshotMutation::execute(
$withdrawn = LockedPharmacySnapshotMutation::execute(
$id,
static fn () => self::withdrawLocked($id, $adminId, $adminInfo)
);
@@ -2261,6 +2276,13 @@ class PrescriptionOrderLogic
}
return false;
}
// 事务提交后刷新复诊到期快照(撤回置 4,订单退出有效口径;钩子内部吞异常)
if (is_array($withdrawn)) {
RevisitDueHook::onOrderChanged((int) ($withdrawn['diagnosis_id'] ?? 0), $id, 'withdraw');
}
return $withdrawn;
}
private static function withdrawLocked(int $id, int $adminId, array $adminInfo)
@@ -3303,6 +3325,9 @@ class PrescriptionOrderLogic
$logLine
);
// 刷新复诊到期快照(9/10 退出有效口径,终态可能释放医助;钩子内部吞异常)
RevisitDueHook::onOrderChanged((int) $order->diagnosis_id, $id, 'complete:' . $targetFulfillmentStatus);
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::maskRemarkExtraIfNeeded($out, $adminInfo);
@@ -3478,6 +3503,9 @@ class PrescriptionOrderLogic
self::writeLog($id, $adminId, $adminInfo, 'refund', $logLine);
// 刷新复诊到期快照(全额退款置 10 退出有效口径并可能释放医助;钩子内部吞异常)
RevisitDueHook::onOrderChanged((int) $order->diagnosis_id, $id, 'refund');
$out = $order->toArray();
$out['this_refund_amount'] = $refundAmount;
$out['refund_full'] = $isFullRefund;
File diff suppressed because it is too large Load Diff