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
@@ -9,7 +9,8 @@ use app\adminapi\logic\stats\AssistantPerformanceLogic;
/**
* 医助个人业绩
*
* - GET stats.assistantPerformance/overview 个人业绩概览
* - GET stats.assistantPerformance/overview 个人业绩概览
* - GET stats.assistantPerformance/revisitRate 我的复诊接诊率(仅本人行,需在菜单登记:server/sql/1.9.20261012/10_add_assistant_revisit_rate_menu.sql)
*/
class AssistantPerformanceController extends BaseAdminController
{
@@ -18,4 +19,13 @@ class AssistantPerformanceController extends BaseAdminController
$params = $this->request->get();
return $this->data(AssistantPerformanceLogic::overview($params, $this->adminId, $this->adminInfo));
}
/**
* @notes 我的复诊接诊率:按月返回本人在复诊接诊率报表中的行(方案 §10 D1-h)
*/
public function revisitRate()
{
$month = (string) $this->request->get('month', '');
return $this->data(AssistantPerformanceLogic::revisitRate($this->adminId, $month));
}
}
@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\tcm;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\lists\tcm\RevisitDueLists;
use app\adminapi\logic\tcm\RevisitDueLogic;
use app\adminapi\validate\tcm\RevisitDueValidate;
/**
* 复诊到期弹窗控制器(需求 4:到期未面诊弹窗 + 延迟处理;P2 主管团队视图 lists / logs)
*
* URL 自动映射 /adminapi/tcm.revisitDue/<action>,权限串 tcm.revisitDue/<action>,
* 弹窗六个 action 在菜单登记于 server/sql/1.9.20261012/05_add_revisit_due_menu.sql,
* 主管视图 lists(C 类页面 /stats/revisit_due)/ logs(A 类)登记于 08_add_revisit_due_team_menu.sql,
* 否则 AuthMiddleware 对未登记接口放行。归属校验在 RevisitDueLogic 内完成,lists 的数据范围由 RevisitDueLists 处理。
*
* @package app\adminapi\controller\tcm
*/
class RevisitDueController extends BaseAdminController
{
/**
* @notes 主管团队视图:到期跟进列表(分页 / 筛选 / 导出 export=1|2),数据范围按角色 data_scope
*/
public function lists()
{
return $this->dataLists(new RevisitDueLists());
}
/**
* @notes 主管团队视图:某条提醒的事件流水(需在当前账号数据范围内)
*/
public function logs()
{
$params = (new RevisitDueValidate())->get()->goCheck('logs');
$result = RevisitDueLogic::logs((int) $params['due_id'], $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(RevisitDueLogic::getError() ?: '流水读取失败');
}
return $this->data($result);
}
/**
* @notes 弹窗待办(轮询):本人到期未挂号 / 过号且已到提醒时刻的项 + 汇总计数
*/
public function pending()
{
return $this->data(RevisitDueLogic::pending($this->adminId, $this->adminInfo));
}
/**
* @notes 仅计数(不更新弹出时间)
*/
public function summary()
{
return $this->data(RevisitDueLogic::summary($this->adminId, $this->adminInfo));
}
/**
* @notes 延迟一次(幂等:affected=0 也返回当前行)
*/
public function snooze()
{
$params = (new RevisitDueValidate())->post()->goCheck('snooze');
$result = RevisitDueLogic::snooze((int) $params['id'], $this->adminId, $this->adminInfo);
if ($result === false) {
return $this->fail(RevisitDueLogic::getError() ?: '延迟失败');
}
return $this->success($result['message'], $result['item'], 1, 1);
}
/**
* @notes 批量延迟(仅 escalation_level<=1 的项)
*/
public function snoozeAll()
{
$params = (new RevisitDueValidate())->post()->goCheck('snoozeAll');
$result = RevisitDueLogic::snoozeAll((array) $params['ids'], $this->adminId, $this->adminInfo);
return $this->success($result['message'], $result, 1, 1);
}
/**
* @notes 已联系:记录患者承诺复诊日
*/
public function contact()
{
$params = (new RevisitDueValidate())->post()->goCheck('contact');
$result = RevisitDueLogic::contact(
(int) $params['id'],
(string) $params['promised_date'],
(string) ($params['remark'] ?? ''),
$this->adminId,
$this->adminInfo
);
if ($result === false) {
return $this->fail(RevisitDueLogic::getError() ?: '记录失败');
}
return $this->success($result['message'], $result['item'], 1, 1);
}
/**
* @notes 人工关闭(原因 lost|other + 说明必填)
*/
public function close()
{
$params = (new RevisitDueValidate())->post()->goCheck('close');
$result = RevisitDueLogic::close(
(int) $params['id'],
(string) $params['reason'],
(string) ($params['remark'] ?? ''),
$this->adminId,
$this->adminInfo
);
if ($result === false) {
return $this->fail(RevisitDueLogic::getError() ?: '关闭失败');
}
return $this->success($result['message'], $result['item'], 1, 1);
}
}
@@ -22,6 +22,12 @@ class AutoAssignLogLists extends BaseAdminDataLists implements ListsSearchInterf
'50_60' => '50%~60%',
];
/** 分档口径版本(auto_assign_log.rate_version)文案 */
private const RATE_VERSION_LABELS = [
'v1' => '旧口径·分母仅新指派',
'v2' => '新口径·每列独立分母',
];
/**
* @notes 设置搜索条件
*/
@@ -49,6 +55,12 @@ class AutoAssignLogLists extends BaseAdminDataLists implements ListsSearchInterf
$row['action_text'] = (int) ($row['action'] ?? 0) === 1 ? '已分配' : '未分配';
$row['tier_text'] = self::TIER_LABELS[(string) ($row['tier'] ?? '')] ?? '';
$row['visit2_rate'] = $row['visit2_rate'] !== null ? (float) $row['visit2_rate'] : null;
// 两套口径:visit2_rate=新口径 v2(每列独立分母),visit2_rate_new_assign=旧口径 v1(分母仅新指派);rate_version=本批次分档所用口径
$newAssignRate = $row['visit2_rate_new_assign'] ?? null;
$row['visit2_rate_new_assign'] = $newAssignRate !== null ? (float) $newAssignRate : null;
$rateVersion = trim((string) ($row['rate_version'] ?? ''));
$row['rate_version'] = $rateVersion !== '' ? $rateVersion : 'v1';
$row['rate_version_text'] = self::RATE_VERSION_LABELS[$row['rate_version']] ?? $row['rate_version'];
$rbTs = (int) ($row['rollback_time'] ?? 0);
$row['rollback_time_text'] = $rbTs > 0 ? date('Y-m-d H:i:s', $rbTs) : '';
@@ -31,8 +31,10 @@ use app\common\model\auth\Admin;
use app\common\model\auth\AdminDept;
use app\common\model\auth\AdminRole;
use app\common\model\dict\DictData;
use app\common\model\tcm\DiagnosisRevisitDue;
use app\common\lists\ListsSearchInterface;
use app\common\lists\Traits\HasDataScopeFilter;
use app\common\service\tcm\RevisitDueService;
use think\facade\Db;
/**
@@ -143,6 +145,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
$this->applyLatestAppointmentFilters($query, $pendingWideSearch);
$this->applyLatestAssignFilters($query, $pendingWideSearch);
$this->applyRevisitDueFilters($query, $pendingWideSearch);
$this->applyPendingAssignBusinessOrderMonthFilter($query);
@@ -460,6 +463,19 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
}
unset($item);
// 复诊到期(契约 C):按快照表批量读取,缺行/不一致时由服务内部自愈(仅小页前 50 行);读取异常由服务降级为 none 占位
$revisitDueMap = [];
if (!empty($diagIdsForUnserved)) {
$revisitDueMap = RevisitDueService::buildListPayload($diagIdsForUnserved, [
'self_heal' => true,
'page_size' => (int) $this->limitLength,
]);
}
foreach ($lists as &$item) {
$item['revisit_due'] = $revisitDueMap[(int) $item['id']] ?? RevisitDueService::emptyPayload();
}
unset($item);
return $lists;
}
@@ -598,6 +614,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
$this->applyLatestAppointmentFilters($query, $pendingWideSearch);
$this->applyLatestAssignFilters($query, $pendingWideSearch);
$this->applyRevisitDueFilters($query, $pendingWideSearch);
// 仅已开方(待分配+关键词检索时不限制)
if (!$pendingWideSearch && isset($this->params['only_has_prescription']) && (string) $this->params['only_has_prescription'] === '1') {
@@ -714,11 +731,120 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
}
/**
* 列表默认排序;支持 sort_unserved_days=asc|desc 按未服务天数排序。
* 复诊到期筛选(方案 §3.2 ②):以快照表 tcm_diagnosis_revisit_due 的 EXISTS 子查询实现。
*
* revisit_state:due(超期 ∪ 过号,排除已承诺未到期)| warning(到期前 warn_days 内)| booked(已约且日期未过)
* | visited(已面诊)| contacted(已承诺复诊日未过)| no_days(有有效订单但缺开药天数)| pending(到期日在预警窗之外)
* revisit_days_bucket:14|21|28|30 精确命中档位;other = 未命中档位且有开药天数
* 待分配宽搜不套此过滤;lists() 与 count() 各调用一次保证分页总数一致。
*
* @param mixed $query
*/
private function applyRevisitDueFilters($query, bool $pendingWideSearch): void
{
if ($pendingWideSearch) {
return;
}
$state = strtolower(trim((string) ($this->params['revisit_state'] ?? '')));
$bucket = strtolower(trim((string) ($this->params['revisit_days_bucket'] ?? '')));
if ($state === '' && $bucket === '') {
return;
}
$rdTbl = (new DiagnosisRevisitDue())->getTable();
$diagTbl = (new Diagnosis())->getTable();
$conditions = ["rd.diagnosis_id = {$diagTbl}.id"];
if ($state !== '') {
$warnDays = (int) (RevisitDueService::config()['warn_days'] ?? 3);
if ($warnDays < 0) {
$warnDays = 0;
}
$warnEdge = "DATE_ADD(CURDATE(), INTERVAL {$warnDays} DAY)";
switch ($state) {
case 'due':
$conditions[] = 'rd.closed_state = 0';
$conditions[] = '(rd.appointment_state = 4 OR (rd.due_date IS NOT NULL AND rd.due_date <= CURDATE()))';
$conditions[] = '(rd.promised_date IS NULL OR rd.promised_date < CURDATE())';
break;
case 'warning':
// 与读取侧 deriveState 对齐:过号(4)/已约已过期/已联系(承诺日未到) 的行优先级更高,不落入预警
$conditions[] = 'rd.closed_state = 0';
$conditions[] = 'rd.due_date IS NOT NULL';
$conditions[] = 'rd.due_date > CURDATE()';
$conditions[] = "rd.due_date <= {$warnEdge}";
$conditions[] = 'rd.appointment_state <> 4';
$conditions[] = 'NOT (rd.appointment_state = 1 AND rd.appointment_date < CURDATE())';
$conditions[] = '(rd.promised_date IS NULL OR rd.promised_date < CURDATE())';
break;
case 'booked':
$conditions[] = 'rd.appointment_state = 1';
$conditions[] = 'rd.appointment_date IS NOT NULL';
$conditions[] = 'rd.appointment_date >= CURDATE()';
break;
case 'visited':
$conditions[] = '(rd.closed_state = 3 OR rd.appointment_state = 3)';
break;
case 'contacted':
$conditions[] = 'rd.promised_date IS NOT NULL';
$conditions[] = 'rd.promised_date >= CURDATE()';
break;
case 'no_days':
// 与 deriveState 的 no_days 分支对齐:进行中、有有效订单但算不出预计复诊日(失效行不计)
$conditions[] = 'rd.closed_state = 0';
$conditions[] = 'rd.valid_order_count > 0';
$conditions[] = 'rd.due_date IS NULL';
break;
case 'pending':
$conditions[] = 'rd.closed_state = 0';
$conditions[] = 'rd.due_date IS NOT NULL';
$conditions[] = "rd.due_date > {$warnEdge}";
$conditions[] = 'rd.appointment_state <> 4';
$conditions[] = 'NOT (rd.appointment_state = 1 AND rd.appointment_date < CURDATE())';
$conditions[] = '(rd.promised_date IS NULL OR rd.promised_date < CURDATE())';
break;
default:
// 未知状态值:不做任何收窄(与其它未知筛选参数一致,避免误清空列表)
break;
}
}
if ($bucket !== '') {
if ($bucket === 'other') {
$conditions[] = 'rd.rule_days = 0';
$conditions[] = 'rd.medication_days > 0';
} elseif (ctype_digit($bucket) && (int) $bucket > 0) {
$conditions[] = 'rd.rule_days = ' . (int) $bucket;
}
}
if (count($conditions) <= 1) {
return;
}
$query->whereExists("SELECT 1 FROM {$rdTbl} rd WHERE " . implode(' AND ', $conditions));
}
/**
* 列表默认排序;支持 sort_revisit_due=asc|desc 按复诊到期日排序(优先),
* 或 sort_unserved_days=asc|desc 按未服务天数排序;两者互斥,前者存在时忽略后者。
*/
private function resolveListOrderRaw(bool $pendingWideSearch): string
{
$diagTbl = (new Diagnosis())->getTable();
// 复诊到期日排序(方案 §3.2 ③):单行子查询取进行中快照的 due_date,无快照 / 无到期日的行置后
$sortRevisitDue = strtolower(trim((string) ($this->params['sort_revisit_due'] ?? '')));
if (in_array($sortRevisitDue, ['asc', 'desc'], true)) {
$rdTbl = (new DiagnosisRevisitDue())->getTable();
$dueExpr = "(SELECT rd.due_date FROM {$rdTbl} rd WHERE rd.diagnosis_id = {$diagTbl}.id AND rd.closed_state = 0 LIMIT 1)";
$nullLast = "CASE WHEN {$dueExpr} IS NULL THEN 1 ELSE 0 END ASC";
$dir = $sortRevisitDue === 'desc' ? 'DESC' : 'ASC';
return "{$nullLast}, {$dueExpr} {$dir}, {$diagTbl}.id DESC";
}
$sortUnserved = strtolower(trim((string) ($this->params['sort_unserved_days'] ?? '')));
if (in_array($sortUnserved, ['asc', 'desc'], true)) {
$anchorExpr = $this->unservedAnchorExpr($diagTbl);
@@ -25,6 +25,7 @@ use app\common\model\tcm\PrescriptionOrderPayOrder;
use app\common\model\ExpressTracking;
use app\common\service\gancao\GancaoScmRecipelService;
use app\common\service\pharmacy\EjPharmacyClient;
use app\common\service\tcm\RevisitVisitSlotService;
use think\facade\Config;
use think\facade\Db;
use think\db\Query;
@@ -1464,7 +1465,9 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
}
/**
* 诊单编辑-业务订单 tab:标注全局诊次及是否计入复诊接诊率(与 RevisitRateLogic 同口径)
* 诊单编辑-业务订单 tab:标注全局诊次及是否计入复诊接诊率(与 RevisitRateLogic 同口径)。
* 诊次口径单点在 RevisitVisitSlotService:有效订单序列(fetchOrderSeqRows,一次 SQL 覆盖全部诊单,消除逐诊单 N+1)
* + 诊单偏移(fetchRevisitSlotStartOffsetMap / resolveRevisitSlotStartOffset 钳 0~20);global_visit_seq / counts_for_revisit_rate 规则不变。
*
* @param array<int, array<string, mixed>> $lists
*/
@@ -1490,26 +1493,23 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
return;
}
$offsetRows = Diagnosis::whereIn('id', $diagIdList)
->whereNull('delete_time')
->column('revisit_slot_start_offset', 'id');
$offsetMap = [];
foreach ($offsetRows as $id => $offset) {
$offsetMap[(int) $id] = max(0, min(20, (int) $offset));
}
$offsetMap = RevisitVisitSlotService::fetchRevisitSlotStartOffsetMap($diagIdList);
/** @var array<int, int> $seqByOrderId order_id => global seq within diagnosis */
/** @var array<int, int> $seqByOrderId order_id => 诊单内实单全局序号 */
$seqByOrderId = [];
foreach ($diagIdList as $did) {
$q = PrescriptionOrder::where('diagnosis_id', $did)->whereNull('delete_time');
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, '');
$orderIds = $q
->order(['create_time' => 'asc', 'id' => 'asc'])
->column('id');
foreach (array_chunk($diagIdList, 2000) as $chunk) {
// 已按 (diagnosis_id, create_time, id) 升序,同一诊单连续
$orderRows = RevisitVisitSlotService::fetchOrderSeqRows($chunk, ['o.id', 'o.diagnosis_id']);
$curDid = 0;
$seq = 0;
foreach ($orderIds as $oid) {
foreach ($orderRows as $r) {
$did = (int) ($r['diagnosis_id'] ?? 0);
if ($did !== $curDid) {
$curDid = $did;
$seq = 0;
}
$seq++;
$seqByOrderId[(int) $oid] = $seq;
$seqByOrderId[(int) ($r['id'] ?? 0)] = $seq;
}
}
@@ -1517,7 +1517,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$oid = (int) ($row['id'] ?? 0);
$did = (int) ($row['diagnosis_id'] ?? 0);
$seq = (int) ($seqByOrderId[$oid] ?? 0);
$offset = (int) ($offsetMap[$did] ?? 0);
$offset = RevisitVisitSlotService::resolveRevisitSlotStartOffset($did, $offsetMap);
$effectiveSlot = $seq > 0 ? $seq + $offset : 0;
$row['global_visit_seq'] = $effectiveSlot > 0 ? $effectiveSlot : null;
$row['raw_visit_seq'] = $seq > 0 ? $seq : null;
@@ -0,0 +1,381 @@
<?php
declare(strict_types=1);
namespace app\adminapi\lists\tcm;
use app\adminapi\lists\BaseAdminDataLists;
use app\adminapi\logic\dept\DeptLogic;
use app\adminapi\logic\tcm\RevisitDueLogic;
use app\common\lists\ListsExcelInterface;
use app\common\lists\ListsSearchInterface;
use app\common\lists\Traits\HasDataScopeFilter;
use app\common\model\auth\Admin;
use app\common\model\auth\AdminDept;
use app\common\model\dept\Dept;
use app\common\model\tcm\DiagnosisRevisitDue;
use app\common\service\tcm\RevisitDueCalculator;
use app\common\service\tcm\RevisitDueService;
use think\facade\Db;
/**
* 复诊到期跟进 - 主管团队视图列表(P2,方案 §3.3 tcm.revisitDue/lists)
*
* 数据来源:tcm_diagnosis_revisit_due 快照表(别名 rd),一诊单一行;时间相关状态由 RevisitDueCalculator::deriveState 按 today 推导。
* 筛选:assistant_id / dept_id(经 admin_dept 子查询,含下级部门)/ state / days_bucket / min_snooze / keyword(患者姓名);默认排除失效行(closed_state<>5)。
* state 的 SQL 条件与 DiagnosisLists::applyRevisitDueFilters 完全一致,另加 closed = closed_state=4(人工关闭,主管视图标记)。
* 数据范围:HasDataScopeFilter::applyDataScopeByOwner('rd.assistant_id'),主管按角色 data_scope 看下属。
* 排序:escalation_level desc(越点越上心优先),due_date asc(NULL 置后),id asc。
* 导出:export=1/2,字段见 setExcelFields。
*/
class RevisitDueLists extends BaseAdminDataLists implements ListsSearchInterface, ListsExcelInterface
{
use HasDataScopeFilter;
/** 支持的 state 筛选值 */
public const STATES = ['due', 'warning', 'booked', 'visited', 'contacted', 'no_days', 'pending', 'closed'];
/**
* @notes 设置搜索条件(精确匹配列走框架 createWhere,其余在 buildQuery 内处理)
*/
public function setSearch(): array
{
return [
'=' => ['rd.assistant_id'],
];
}
/**
* @notes 获取列表
*/
public function lists(): array
{
$rows = $this->buildQuery()
->orderRaw('rd.escalation_level DESC, rd.due_date IS NULL ASC, rd.due_date ASC, rd.id ASC')
->limit($this->limitOffset, $this->limitLength)
->select()
->toArray();
if ($rows === []) {
return [];
}
$cfg = RevisitDueService::config();
$today = date('Y-m-d');
$assistantIds = array_values(array_unique(array_map(static fn (array $r): int => (int) $r['assistant_id'], $rows)));
$nameMap = $this->fetchAssistantNames($assistantIds);
$deptMap = $this->fetchAssistantDeptNames($assistantIds);
$out = [];
foreach ($rows as $row) {
$out[] = $this->formatRow($row, $today, $cfg, $nameMap, $deptMap);
}
return $out;
}
/**
* @notes 获取数量
*/
public function count(): int
{
return $this->buildQuery()->count();
}
/**
* @notes 导出文件名
*/
public function setFileName(): string
{
return '复诊到期跟进';
}
/**
* @notes 导出字段
*/
public function setExcelFields(): array
{
return [
'patient_name' => '患者',
'assistant_name' => '医助',
'dept_name' => '部门',
'export_slot' => '第N诊',
'medication_days' => '开药天数',
'due_date' => '预计复诊',
'export_overdue_days' => '超期天数',
'state_text' => '状态',
'snooze_count' => '延迟次数',
'contact_count' => '已联系',
'promised_date' => '承诺日',
'missed_count' => '过号次数',
'export_close_reason' => '关闭原因',
];
}
// ---------------------------------------------------------------- 内部
/**
* @return \think\db\Query
*/
private function buildQuery()
{
$query = Db::name(RevisitDueService::TABLE)->alias('rd')->where($this->searchWhere);
// 默认排除失效行(无医助 / 诊单删除或禁用 / 无有效订单)
$query->where('rd.closed_state', '<>', RevisitDueCalculator::CLOSED_INVALID);
// 数据范围:主管按角色 data_scope 看下属医助
$this->applyDataScopeByOwner($query, 'rd.assistant_id');
// 部门:医助 ∈ admin_dept 且部门在所选部门子树内
$deptId = (int) ($this->params['dept_id'] ?? 0);
if ($deptId > 0) {
$deptIds = array_values(array_filter(array_map('intval', DeptLogic::getSelfAndDescendantIds($deptId)), static fn (int $v): bool => $v > 0));
if ($deptIds === []) {
$query->whereRaw('0 = 1');
} else {
$adTbl = (new AdminDept())->getTable();
$query->whereExists('SELECT 1 FROM `' . $adTbl . '` ad WHERE ad.admin_id = rd.assistant_id AND ad.dept_id IN (' . implode(',', $deptIds) . ')');
}
}
// 患者姓名
$keyword = trim((string) ($this->params['keyword'] ?? ''));
if ($keyword !== '') {
$query->whereLike('rd.patient_name', '%' . $keyword . '%');
}
// 延迟次数下限
$minSnooze = (int) ($this->params['min_snooze'] ?? 0);
if ($minSnooze > 0) {
$query->where('rd.snooze_count', '>=', $minSnooze);
}
$this->applyStateFilter($query, strtolower(trim((string) ($this->params['state'] ?? ''))));
$this->applyBucketFilter($query, strtolower(trim((string) ($this->params['days_bucket'] ?? ''))));
return $query;
}
/**
* state 筛选:SQL 条件与 DiagnosisLists::applyRevisitDueFilters 完全一致(表别名 rd),另加 closed。
*
* @param mixed $query
*/
private function applyStateFilter($query, string $state): void
{
if ($state === '' || !in_array($state, self::STATES, true)) {
return;
}
$warnDays = (int) (RevisitDueService::config()['warn_days'] ?? 3);
if ($warnDays < 0) {
$warnDays = 0;
}
$warnEdge = "DATE_ADD(CURDATE(), INTERVAL {$warnDays} DAY)";
$conditions = [];
switch ($state) {
case 'due':
$conditions[] = 'rd.closed_state = 0';
$conditions[] = '(rd.appointment_state = 4 OR (rd.due_date IS NOT NULL AND rd.due_date <= CURDATE()))';
$conditions[] = '(rd.promised_date IS NULL OR rd.promised_date < CURDATE())';
break;
case 'warning':
$conditions[] = 'rd.closed_state = 0';
$conditions[] = 'rd.due_date IS NOT NULL';
$conditions[] = 'rd.due_date > CURDATE()';
$conditions[] = "rd.due_date <= {$warnEdge}";
$conditions[] = 'rd.appointment_state <> 4';
$conditions[] = 'NOT (rd.appointment_state = 1 AND rd.appointment_date < CURDATE())';
$conditions[] = '(rd.promised_date IS NULL OR rd.promised_date < CURDATE())';
break;
case 'booked':
$conditions[] = 'rd.appointment_state = 1';
$conditions[] = 'rd.appointment_date IS NOT NULL';
$conditions[] = 'rd.appointment_date >= CURDATE()';
break;
case 'visited':
$conditions[] = '(rd.closed_state = 3 OR rd.appointment_state = 3)';
break;
case 'contacted':
$conditions[] = 'rd.promised_date IS NOT NULL';
$conditions[] = 'rd.promised_date >= CURDATE()';
break;
case 'no_days':
$conditions[] = 'rd.closed_state = 0';
$conditions[] = 'rd.valid_order_count > 0';
$conditions[] = 'rd.due_date IS NULL';
break;
case 'pending':
$conditions[] = 'rd.closed_state = 0';
$conditions[] = 'rd.due_date IS NOT NULL';
$conditions[] = "rd.due_date > {$warnEdge}";
$conditions[] = 'rd.appointment_state <> 4';
$conditions[] = 'NOT (rd.appointment_state = 1 AND rd.appointment_date < CURDATE())';
$conditions[] = '(rd.promised_date IS NULL OR rd.promised_date < CURDATE())';
break;
case 'closed':
$conditions[] = 'rd.closed_state = ' . RevisitDueCalculator::CLOSED_MANUAL;
break;
}
foreach ($conditions as $cond) {
$query->whereRaw($cond);
}
}
/**
* days_bucket:14|21|28|30 精确命中档位;other = 未命中档位且有开药天数。
*
* @param mixed $query
*/
private function applyBucketFilter($query, string $bucket): void
{
if ($bucket === '') {
return;
}
if ($bucket === 'other') {
$query->where('rd.rule_days', 0)->where('rd.medication_days', '>', 0);
} elseif (ctype_digit($bucket) && (int) $bucket > 0) {
$query->where('rd.rule_days', (int) $bucket);
}
}
/**
* 行格式化:事实列 + deriveState 推导 + 医助/部门名 + 导出列。
*
* @param array<string, mixed> $row
* @param array<string, mixed> $cfg
* @param array<int, string> $nameMap
* @param array<int, string> $deptMap
*
* @return array<string, mixed>
*/
private function formatRow(array $row, string $today, array $cfg, array $nameMap, array $deptMap): array
{
$st = RevisitDueCalculator::deriveState($row, $today, $cfg);
$aid = (int) ($row['assistant_id'] ?? 0);
$anchorTs = (int) ($row['anchor_ts'] ?? 0);
$closed = (int) ($row['closed_state'] ?? 0);
$slot = (int) ($row['current_slot'] ?? 0);
$reason = (string) ($row['handle_reason'] ?? '');
$remark = (string) ($row['handle_remark'] ?? '');
$reasonText = RevisitDueLogic::CLOSE_REASON_TEXT[$reason] ?? $reason;
$name = trim((string) ($row['patient_name'] ?? ''));
return [
'id' => (int) ($row['id'] ?? 0),
'diagnosis_id' => (int) ($row['diagnosis_id'] ?? 0),
'patient_name' => $name !== '' ? $name : '患者',
'assistant_id' => $aid,
'assistant_name' => (string) ($nameMap[$aid] ?? ''),
'dept_name' => (string) ($deptMap[$aid] ?? ''),
'current_slot' => $slot,
'next_slot' => (int) ($row['next_slot'] ?? 0),
'medication_days' => (int) ($row['medication_days'] ?? 0),
'rule_days' => (int) ($row['rule_days'] ?? 0),
'anchor_date' => $anchorTs > 0 ? date('Y-m-d', $anchorTs) : '',
'due_date' => self::dateStr($row['due_date'] ?? null),
'overdue_days' => $st['overdue_days'],
'days_left' => $st['days_left'],
'state' => $st['state'],
'state_text' => $st['state_text'],
'severe' => (int) $st['severe'],
'appointment_state' => (int) ($row['appointment_state'] ?? 0),
'appointment_date' => self::dateStr($row['appointment_date'] ?? null),
'snooze_count' => (int) ($row['snooze_count'] ?? 0),
'escalation_level' => (int) ($row['escalation_level'] ?? 0),
'contact_count' => (int) ($row['contact_count'] ?? 0),
'promised_date' => self::dateStr($row['promised_date'] ?? null),
'missed_count' => (int) ($row['missed_count'] ?? 0),
'closed_state' => $closed,
'closed_state_text' => DiagnosisRevisitDue::CLOSED_STATE_TEXT_MAP[$closed] ?? '未知',
'handle_reason' => $reason,
'handle_remark' => $remark,
'first_shown_at_text' => self::tsText($row['first_shown_at'] ?? 0),
'last_snoozed_at_text' => self::tsText($row['last_snoozed_at'] ?? 0),
'computed_at_text' => self::tsText($row['computed_at'] ?? 0),
// 导出列
'export_slot' => $slot > 0 ? '第' . $slot . '诊' : '',
'export_overdue_days' => $st['overdue_days'] === null ? '' : (string) $st['overdue_days'],
'export_close_reason' => $closed === RevisitDueCalculator::CLOSED_MANUAL
? trim($reasonText . ($remark !== '' ? ':' . $remark : ''))
: '',
];
}
/**
* @param list<int> $ids
*
* @return array<int, string>
*/
private function fetchAssistantNames(array $ids): array
{
$ids = array_values(array_filter($ids, static fn (int $v): bool => $v > 0));
if ($ids === []) {
return [];
}
$map = [];
foreach (Admin::whereIn('id', $ids)->column('name', 'id') as $id => $n) {
$map[(int) $id] = (string) $n;
}
return $map;
}
/**
* 医助部门名:admin_dept 取最小 dept_id,再查 dept.name。
*
* @param list<int> $ids
*
* @return array<int, string>
*/
private function fetchAssistantDeptNames(array $ids): array
{
$ids = array_values(array_filter($ids, static fn (int $v): bool => $v > 0));
if ($ids === []) {
return [];
}
$minDept = [];
$adRows = AdminDept::whereIn('admin_id', $ids)->field(['admin_id', 'dept_id'])->select()->toArray();
foreach ($adRows as $ad) {
$aid = (int) ($ad['admin_id'] ?? 0);
$did = (int) ($ad['dept_id'] ?? 0);
if ($aid <= 0 || $did <= 0) {
continue;
}
if (!isset($minDept[$aid]) || $did < $minDept[$aid]) {
$minDept[$aid] = $did;
}
}
if ($minDept === []) {
return [];
}
$deptNames = [];
foreach (Dept::whereIn('id', array_values(array_unique($minDept)))->column('name', 'id') as $id => $n) {
$deptNames[(int) $id] = (string) $n;
}
$out = [];
foreach ($minDept as $aid => $did) {
$out[$aid] = (string) ($deptNames[$did] ?? '');
}
return $out;
}
/** 时间戳 → 'Y-m-d H:i' 或 '' */
private static function tsText(mixed $ts): string
{
$ts = (int) $ts;
return $ts > 0 ? date('Y-m-d H:i', $ts) : '';
}
/** 日期列 → 'Y-m-d' 或 ''(null / 空 / 0000-00-00 均视为空) */
private static function dateStr(mixed $value): string
{
if ($value === null) {
return '';
}
$s = substr(trim((string) $value), 0, 10);
return ($s === '' || $s === '0000-00-00') ? '' : $s;
}
}
@@ -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
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace app\adminapi\validate\tcm;
use app\common\validate\BaseValidate;
/**
* 复诊到期弹窗写接口验证(snooze / snoozeAll / contact / close)与主管视图流水(logs)
*
* 归属(本人 / 超管 / 数据范围)与业务约束(承诺日范围、周期状态)在 RevisitDueLogic 内校验,此处只做入参形态。
*
* @package app\adminapi\validate\tcm
*/
class RevisitDueValidate extends BaseValidate
{
protected $rule = [
'id' => 'require|number|gt:0',
'ids' => 'require|array',
'due_id' => 'require|number|gt:0',
'promised_date' => 'require|dateFormat:Y-m-d',
'reason' => 'require|in:lost,other',
'remark' => 'max:200',
];
protected $message = [
'id.require' => '参数缺失',
'id.number' => '提醒 id 非法',
'id.gt' => '提醒 id 非法',
'due_id.require' => '参数缺失 due_id',
'due_id.number' => '提醒 id 非法',
'due_id.gt' => '提醒 id 非法',
'ids.require' => '请选择要延迟的提醒',
'ids.array' => '提醒 id 列表格式错误',
'promised_date.require' => '请选择患者承诺复诊日',
'promised_date.dateFormat' => '承诺复诊日格式应为 YYYY-MM-DD',
'reason.require' => '请选择关闭原因',
'reason.in' => '关闭原因只能是 lost 或 other',
'remark.require' => '请填写关闭说明',
'remark.max' => '说明不能超过 200 字',
];
public function sceneSnooze()
{
return $this->only(['id']);
}
public function sceneSnoozeAll()
{
return $this->only(['ids']);
}
public function sceneContact()
{
return $this->only(['id', 'promised_date', 'remark']);
}
public function sceneClose()
{
return $this->only(['id', 'reason', 'remark'])
->append('remark', 'require');
}
public function sceneLogs()
{
return $this->only(['due_id']);
}
}
@@ -9,6 +9,7 @@ use app\common\model\tcm\PrescriptionOrder;
use app\common\model\tcm\PrescriptionOrderLog;
use app\common\model\tcm\PrescriptionOrderPayOrder;
use app\common\service\ExpressTrackingService;
use app\common\service\tcm\RevisitDueHook;
use think\facade\Config;
use think\facade\Log;
use think\Response;
@@ -303,6 +304,8 @@ class GancaoCallbackController extends BaseApiController
private function updateOrderStatus(PrescriptionOrder $order, int $state, array $ext): void
{
$order->gancao_order_state = $state;
// 甘草撤单/驳回把履约状态置 4(已取消):订单退出有效口径,需在落库成功后刷新复诊到期快照
$revisitDueOrderCancelled = false;
switch ($state) {
case 10:
@@ -331,6 +334,7 @@ class GancaoCallbackController extends BaseApiController
case 91:
if ((int) $order->fulfillment_status !== 3) {
$order->fulfillment_status = 4; // 已取消
$revisitDueOrderCancelled = true;
}
$order->gancao_remark = '甘草主动撤单(已退费)';
break;
@@ -338,6 +342,7 @@ class GancaoCallbackController extends BaseApiController
case 92:
if ((int) $order->fulfillment_status !== 3) {
$order->fulfillment_status = 4; // 已取消
$revisitDueOrderCancelled = true;
}
$order->gancao_remark = '甘草驳回(无法制作并退费)';
break;
@@ -354,6 +359,10 @@ class GancaoCallbackController extends BaseApiController
]);
}
if ($savedOk && $revisitDueOrderCancelled) {
RevisitDueHook::onOrderChanged((int) $order->diagnosis_id, (int) $order->id, 'gancao:' . $state);
}
if ($savedOk && in_array((int) $order->fulfillment_status, [5, 6], true)) {
ExpressTrackingService::applyAssistantReleaseForShippedPrescriptionOrder($order, [
'tracking_number' => (string) ($order->tracking_number ?? ''),
+126 -17
View File
@@ -5,7 +5,9 @@ declare(strict_types=1);
namespace app\command;
use app\adminapi\logic\dept\DeptLogic;
use app\adminapi\logic\stats\RevisitRateCalculator;
use app\adminapi\logic\stats\RevisitRateLogic;
use app\common\service\tcm\RevisitDueHook;
use think\console\Command;
use think\console\Input;
use think\console\input\Option;
@@ -23,13 +25,19 @@ use think\facade\Log;
* 推荐 cron(每天 09:00 执行一次;或经 zyt_dev_crontab 表调度):
* 0 9 * * * cd /path/to/server && php think tcm:auto-assign-pending >> /var/log/zyt-auto-assign.log 2>&1
*
* 分配规则(依据上个自然月「二诊复诊接诊率」,与 RevisitRateLogic::overview 口径完全一致):
* 分配规则(依据上个自然月「二诊复诊接诊率」,取 RevisitRateLogic::overview(inherit_mode=slot) 的分档字段):
* - 接诊率 > 70% :第一优先档,当日每人最多 3 条
* - 接诊率 60% ~ 70% :第二档,当日每人最多 2 条
* - 接诊率 50% ~ 60% :第三档,当日每人最多 1 条
* - 接诊率 < 50% 或上月无被指派数据:不参与分配
* - 接诊率 < 50% 或上月无被指派数据(分母为 0 → null):不参与分配
* - 仅分配给「二中心」及其组织下级部门的在职医助(当前部门校验,调离二中心即不再参与)
*
* 分档口径(config project.revisit_rate.auto_assign_tier_field,方案 D1-f 过渡期):
* - visit2_rate_new_assign(默认,rate_version=v1):旧口径,分母仅新指派(is_inherit=0),与接诊率 v2 上线前完全一致;
* - visit2_rate(rate_version=v2):新口径,分母 = 新指派 + 起算诊次 ≤ 2 的继承;产品确认后改配置即可切换,不发版。
* 自动指派日志同时记录两套率(visit2_rate / visit2_rate_new_assign)与所用口径 rate_version;
* --dry-run 额外输出两套口径的分档名单对照,便于切换前核对。
*
* 轮询方式:按轮次分配,每轮内先 >70% 档每人 1 条,再 60%~70% 档每人 1 条,再 50%~60% 档每人 1 条;
* 一轮结束还有剩余待指派诊单则进入下一轮,直至待指派池为空或所有医助当日额度用尽。
* 例:当天 20 条,>70% 有 3 人、60%~70% 有 7 人、50%~60% 有 5 人 →
@@ -61,11 +69,26 @@ class AutoAssignPendingDiagnosis extends Command
self::TIER_50_60 => '50%~60%',
];
/** 可用的分档字段 => 口径版本(写入 auto_assign_log.rate_version) */
private const TIER_FIELDS = [
RevisitRateCalculator::FIELD_RATE_NEW_ASSIGN => 'v1',
'visit2_rate' => 'v2',
];
private const RATE_VERSION_LABELS = [
'v1' => '旧口径·分母仅新指派',
'v2' => '新口径·每列独立分母',
];
/** 本次执行的分档字段与口径版本(由 config 解析) */
private string $tierField = RevisitRateCalculator::FIELD_RATE_NEW_ASSIGN;
private string $rateVersion = 'v1';
protected function configure()
{
$this->setName('tcm:auto-assign-pending')
->setDescription('待分配诊单自动指派:按上月二诊复诊接诊率分档轮询分配,并写入自动指派日志')
->addOption('dry-run', null, Option::VALUE_NONE, '演练模式:只输出分配计划,不写库');
->addOption('dry-run', null, Option::VALUE_NONE, '演练模式:只输出分配计划与两套口径分档对照,不写库');
}
protected function execute(Input $input, Output $output): int
@@ -75,19 +98,27 @@ class AutoAssignPendingDiagnosis extends Command
$runDate = date('Y-m-d', $now);
$statMonth = date('Y-m', strtotime(date('Y-m-01', $now) . ' -1 month'));
$batchNo = date('YmdHis', $now) . str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT);
$this->tierField = $this->resolveTierField();
$this->rateVersion = self::TIER_FIELDS[$this->tierField];
$output->writeln(sprintf(
'[%s] 开始自动指派待分配诊单,批次=%s,统计月=%s%s',
'[%s] 开始自动指派待分配诊单,批次=%s,统计月=%s,分档口径=%s(%s)%s',
date('Y-m-d H:i:s', $now),
$batchNo,
$statMonth,
$this->rateVersion,
self::RATE_VERSION_LABELS[$this->rateVersion],
$dryRun ? '(演练模式,不写库)'
: ''
));
try {
// 1. 上月二诊复诊接诊率 → 医助分档
$tiers = $this->buildAssistantTiers($statMonth);
// 1. 上月二诊复诊接诊率(slot 口径 overview,同时含旧口径字段)→ 医助分档
$overview = RevisitRateLogic::overview([
'month' => $statMonth,
'inherit_mode' => RevisitRateCalculator::MODE_SLOT,
]);
$tiers = $this->buildAssistantTiers($overview, $this->tierField);
$tierTotal = array_sum(array_map('count', $tiers));
$output->writeln(sprintf(
'医助分档:>70%% 共 %d 人,60%%~70%% 共 %d 人,50%%~60%% 共 %d 人',
@@ -95,6 +126,9 @@ class AutoAssignPendingDiagnosis extends Command
\count($tiers[self::TIER_60_70]),
\count($tiers[self::TIER_50_60])
));
if ($dryRun) {
$this->printTierComparison($overview, $output);
}
// 2. 当日剩余额度(扣减当日已自动分配数,防止同日重复执行超额)
$remaining = $this->buildRemainingQuota($tiers, $runDate);
@@ -163,14 +197,16 @@ class AutoAssignPendingDiagnosis extends Command
'assistant_id' => $item['assistant']['id'],
'assistant_name' => $item['assistant']['name'],
'tier' => $item['tier'],
'visit2_rate' => $item['assistant']['rate'],
'visit2_rate' => $item['assistant']['visit2_rate'],
'visit2_rate_new_assign' => $item['assistant']['visit2_rate_new_assign'],
'round_no' => $item['round'],
'reason' => sprintf(
'已分配给医助[%s](ID:%d):上月(%s)二诊复诊接诊率 %.2f%%,档位[%s](日上限%d条),第 %d 轮轮询分得,当日该医助第 %d 条',
'已分配给医助[%s](ID:%d):上月(%s)二诊复诊接诊率 %.2f%%(%s),档位[%s](日上限%d条),第 %d 轮轮询分得,当日该医助第 %d 条',
$item['assistant']['name'],
$item['assistant']['id'],
$statMonth,
$item['assistant']['rate'],
self::RATE_VERSION_LABELS[$this->rateVersion],
self::TIER_LABELS[$item['tier']],
self::TIER_DAILY_CAPS[$item['tier']],
$item['round'],
@@ -215,28 +251,95 @@ class AutoAssignPendingDiagnosis extends Command
}
/**
* 按上月二诊复诊接诊率把医助分档(复用 RevisitRateLogic 口径),并剔除已禁用/已删除账号。
*
* @return array{gt70: list<array{id:int,name:string,rate:float}>, 60_70: list<array{id:int,name:string,rate:float}>, 50_60: list<array{id:int,name:string,rate:float}>}
* 分档字段白名单:config project.revisit_rate.auto_assign_tier_field ∈ {visit2_rate_new_assign, visit2_rate},非法回退旧口径。
*/
private function buildAssistantTiers(string $statMonth): array
private function resolveTierField(): string
{
$overview = RevisitRateLogic::overview(['month' => $statMonth]);
$raw = trim((string) (config('project.revisit_rate.auto_assign_tier_field') ?? ''));
/** @var list<array{id:int,name:string,rate:float}> $candidates */
return isset(self::TIER_FIELDS[$raw]) ? $raw : RevisitRateCalculator::FIELD_RATE_NEW_ASSIGN;
}
/**
* 演练输出:两套口径(v1 旧 / v2 新)的分档名单对照,标出档位变动的医助。
*
* @param array<string, mixed> $overview RevisitRateLogic::overview(inherit_mode=slot) 返回体
*/
private function printTierComparison(array $overview, Output $output): void
{
$byField = [];
foreach (self::TIER_FIELDS as $field => $version) {
$byField[$version] = $this->buildAssistantTiers($overview, $field);
}
$output->writeln('[演练] 两套口径分档对照(v1=旧口径·分母仅新指派,v2=新口径·每列独立分母):');
/** @var array<int, array{name:string,v1:string,v2:string,r1:?float,r2:?float}> $perAssistant */
$perAssistant = [];
foreach ($byField as $version => $tiers) {
foreach ($tiers as $tier => $list) {
$names = array_map(static fn (array $a): string => sprintf('%s(%.2f%%)', $a['name'], $a['rate']), $list);
$output->writeln(sprintf(' [%s] %-8s %d 人:%s', $version, self::TIER_LABELS[$tier], \count($list), $names === [] ? '—' : implode('、', $names)));
foreach ($list as $a) {
$perAssistant[$a['id']] ??= ['name' => $a['name'], 'v1' => '', 'v2' => '', 'r1' => null, 'r2' => null];
$perAssistant[$a['id']][$version] = $tier;
$perAssistant[$a['id']][$version === 'v1' ? 'r1' : 'r2'] = $a['rate'];
}
}
}
$changed = [];
foreach ($perAssistant as $aid => $info) {
if ($info['v1'] !== $info['v2']) {
$changed[] = sprintf(
'%s(ID:%d) v1=%s%s → v2=%s%s',
$info['name'],
$aid,
$info['v1'] !== '' ? self::TIER_LABELS[$info['v1']] : '不参与',
$info['r1'] !== null ? sprintf('(%.2f%%)', $info['r1']) : '',
$info['v2'] !== '' ? self::TIER_LABELS[$info['v2']] : '不参与',
$info['r2'] !== null ? sprintf('(%.2f%%)', $info['r2']) : ''
);
}
}
$output->writeln(sprintf(
' 档位变动 %d 人:%s',
\count($changed),
$changed === [] ? '无(两套口径分档一致)' : implode(';', $changed)
));
$output->writeln(sprintf(' 本次实际采用:%s(%s)', $this->rateVersion, $this->tierField));
}
/**
* 按上月二诊复诊接诊率把医助分档(取 overview 行的 $tierField 字段),并剔除已禁用/已删除账号。
* 每位候选同时携带两套率(visit2_rate=v2、visit2_rate_new_assign=v1),供日志回溯。
*
* @param array<string, mixed> $overview RevisitRateLogic::overview(inherit_mode=slot) 返回体
* @param string $tierField 分档字段(self::TIER_FIELDS 键)
*
* @return array{gt70: list<array{id:int,name:string,rate:float,visit2_rate:?float,visit2_rate_new_assign:?float}>, 60_70: list<array{id:int,name:string,rate:float,visit2_rate:?float,visit2_rate_new_assign:?float}>, 50_60: list<array{id:int,name:string,rate:float,visit2_rate:?float,visit2_rate_new_assign:?float}>}
*/
private function buildAssistantTiers(array $overview, string $tierField): array
{
if (!isset(self::TIER_FIELDS[$tierField])) {
$tierField = RevisitRateCalculator::FIELD_RATE_NEW_ASSIGN;
}
/** @var list<array{id:int,name:string,rate:float,visit2_rate:?float,visit2_rate_new_assign:?float}> $candidates */
$candidates = [];
foreach ($overview['rows'] ?? [] as $deptRow) {
foreach ($deptRow['children'] ?? [] as $row) {
$aid = (int) ($row['assistant_id'] ?? 0);
$rate = $row['visit2_rate'] ?? null;
// 上月无被指派数据(rate=null)或接诊率低于 50% 的医助不参与分配
$rate = $row[$tierField] ?? null;
// 上月无被指派数据(分母 0 → rate=null)或接诊率低于 50% 的医助不参与分配
if ($aid <= 0 || $rate === null || (float) $rate < 50.0) {
continue;
}
$v2 = $row['visit2_rate'] ?? null;
$v1 = $row[RevisitRateCalculator::FIELD_RATE_NEW_ASSIGN] ?? null;
$candidates[] = [
'id' => $aid,
'name' => (string) ($row['assistant_name'] ?? ('#' . $aid)),
'rate' => (float) $rate,
'visit2_rate' => $v2 === null ? null : (float) $v2,
'visit2_rate_new_assign' => $v1 === null ? null : (float) $v1,
];
}
}
@@ -520,6 +623,9 @@ class AutoAssignPendingDiagnosis extends Command
Db::commit();
// 事务提交后刷新复诊到期快照(自动分配 = 换医助;钩子内部吞异常,不影响分配结果)
RevisitDueHook::onAssistantChanged($diagnosisId, $toAssistantId, 'auto_assign');
return true;
} catch (\Throwable $e) {
Db::rollback();
@@ -613,7 +719,7 @@ class AutoAssignPendingDiagnosis extends Command
/**
* @param array<string,mixed> $diag 诊单行(含 id/patient_name/phone)
* @param array<string,mixed> $extra action/assistant_id/assistant_name/tier/visit2_rate/round_no/reason
* @param array<string,mixed> $extra action/assistant_id/assistant_name/tier/visit2_rate/visit2_rate_new_assign/round_no/reason
*
* @return array<string,mixed>
*/
@@ -630,7 +736,10 @@ class AutoAssignPendingDiagnosis extends Command
'assistant_id' => (int) ($extra['assistant_id'] ?? 0),
'assistant_name' => mb_substr((string) ($extra['assistant_name'] ?? ''), 0, 64),
'tier' => (string) ($extra['tier'] ?? ''),
// 两套口径的二诊率同时落库:visit2_rate=新口径 v2,visit2_rate_new_assign=旧口径 v1;rate_version 标识本批次分档所用口径
'visit2_rate' => $extra['visit2_rate'] ?? null,
'visit2_rate_new_assign' => $extra['visit2_rate_new_assign'] ?? null,
'rate_version' => $this->rateVersion,
'round_no' => (int) ($extra['round_no'] ?? 0),
'reason' => mb_substr((string) ($extra['reason'] ?? ''), 0, 500),
'create_time' => $now,
@@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\common\service\tcm\RevisitVisitSlotService;
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;
/**
* 继承指派「继承时诊次」快照回填 / 对账(tcm_diagnosis_assign_log.inherit_visit_slot / inherit_visit_slot_calc_time)
*
* 使用方法:
* php think tcm:backfill-inherit-visit-slot --dry-run # 只计算、打印前 50 条拟写入,不写库
* php think tcm:backfill-inherit-visit-slot # 回填 calc_time=0 的继承行
* php think tcm:backfill-inherit-visit-slot --recalc # 全部继承行重算(含已回填)
* php think tcm:backfill-inherit-visit-slot --verify # 对账:快照 vs 实时重算,输出漂移清单,不写库
* php think tcm:backfill-inherit-visit-slot --month=2026-09 # 仅处理指派时间落在该月的继承行(可与以上组合)
*
* 口径(RevisitVisitSlotService::slotBefore,与 RevisitRateLogic / RevisitRateCalculator 完全一致):
* k = 继承时刻之前(严格小于 create_time)的有效业务订单数(剔除履约 4/9/10、软删)+ 诊单偏移(钳 0~20)。
* 统计口径永远实时重算,本列只用于指派记录面板展示与对账;事后退款/取消/改偏移会让快照与实时值漂移,属预期(--verify 列出)。
*
* 范围:is_inherit=1 AND to_assistant_id>0(to=0 的释放行 is_inherit 恒为 0)。按诊单分块 2000 批量取订单序列,不逐行查库。
* 异常:整体 try/catch 并 return 0(Crontab::start 捕获异常会把 dev_crontab.status 置 ERROR 停跑)。
*/
class BackfillInheritVisitSlot extends Command
{
/** 每批处理的日志行数(按诊单分块取订单) */
private const CHUNK_SIZE = 2000;
/** dry-run / verify 最多打印条数 */
private const PRINT_LIMIT = 50;
protected function configure()
{
$this->setName('tcm:backfill-inherit-visit-slot')
->setDescription('继承指派「继承时诊次」快照回填 / 对账(inherit_visit_slot)')
->addOption('dry-run', null, Option::VALUE_NONE, '只计算不写库,打印前 50 条拟写入')
->addOption('recalc', null, Option::VALUE_NONE, '重算全部继承行(默认只回填 calc_time=0 的行)')
->addOption('verify', null, Option::VALUE_NONE, '对账模式:输出快照 vs 实时漂移清单,不写库')
->addOption('month', null, Option::VALUE_OPTIONAL, '仅处理指派时间落在该月(YYYY-MM)的继承行', '');
}
protected function execute(Input $input, Output $output): int
{
$startedAt = microtime(true);
$now = time();
try {
$dryRun = (bool) $input->getOption('dry-run');
$recalc = (bool) $input->getOption('recalc');
$verify = (bool) $input->getOption('verify');
$month = trim((string) $input->getOption('month'));
if ($month !== '' && preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month) !== 1) {
$output->writeln('<error>无效 --month,格式应为 YYYY-MM</error>');
return 0;
}
$mode = $verify ? 'verify' : ($dryRun ? 'dry-run' : ($recalc ? 'recalc' : 'backfill'));
$output->writeln(sprintf(
'[%s] 继承时诊次快照 %s 开始%s',
date('Y-m-d H:i:s', $now),
$mode,
$month !== '' ? ',月份=' . $month : ''
));
$query = Db::name('tcm_diagnosis_assign_log')
->where('is_inherit', 1)
->where('to_assistant_id', '>', 0)
->where('diagnosis_id', '>', 0);
if ($month !== '') {
$startTs = (int) strtotime($month . '-01 00:00:00');
$endTs = (int) strtotime(date('Y-m-t', $startTs) . ' 23:59:59');
$query->where('create_time', '>=', $startTs)->where('create_time', '<=', $endTs);
}
if (!$verify && !$recalc) {
$query->where('inherit_visit_slot_calc_time', 0);
}
$rows = $query
->field(['id', 'diagnosis_id', 'to_assistant_id', 'create_time', 'inherit_visit_slot', 'inherit_visit_slot_calc_time'])
->order(['diagnosis_id' => 'asc', 'id' => 'asc'])
->select()
->toArray();
$total = \count($rows);
$output->writeln(sprintf('待处理继承行 %d 条', $total));
if ($total === 0) {
return 0;
}
$processed = 0;
$written = 0;
$drift = 0;
$printed = 0;
foreach (array_chunk($rows, self::CHUNK_SIZE) as $chunk) {
$diagIds = array_values(array_unique(array_map(static fn (array $r): int => (int) $r['diagnosis_id'], $chunk)));
$offsetMap = RevisitVisitSlotService::fetchRevisitSlotStartOffsetMap($diagIds);
/** @var array<int, list<int>> $ctsByDiag */
$ctsByDiag = [];
foreach (RevisitVisitSlotService::fetchOrderSeqRows($diagIds, ['o.diagnosis_id', 'o.create_time']) as $o) {
$ctsByDiag[(int) $o['diagnosis_id']][] = (int) $o['create_time'];
}
foreach ($chunk as $r) {
$processed++;
$logId = (int) $r['id'];
$did = (int) $r['diagnosis_id'];
$t = (int) $r['create_time'];
$realtime = min(255, RevisitVisitSlotService::slotBefore(
$ctsByDiag[$did] ?? [],
RevisitVisitSlotService::resolveRevisitSlotStartOffset($did, $offsetMap),
$t
));
$snapshot = (int) $r['inherit_visit_slot'];
$calcTime = (int) $r['inherit_visit_slot_calc_time'];
if ($verify) {
if ($calcTime > 0 && $snapshot === $realtime) {
continue;
}
$drift++;
if ($printed < self::PRINT_LIMIT) {
$printed++;
$output->writeln(sprintf(
' 漂移 log_id=%d diagnosis_id=%d 指派时间=%s 快照=%d 实时=%d 原因=%s',
$logId,
$did,
date('Y-m-d H:i:s', $t),
$snapshot,
$realtime,
self::driftReason($calcTime, $snapshot, $realtime)
));
}
continue;
}
if ($dryRun) {
if ($printed < self::PRINT_LIMIT) {
$printed++;
$output->writeln(sprintf(
' [演练] log_id=%d diagnosis_id=%d 指派时间=%s 当前快照=%d → 拟写入=%d',
$logId,
$did,
date('Y-m-d H:i:s', $t),
$snapshot,
$realtime
));
}
continue;
}
Db::name('tcm_diagnosis_assign_log')
->where('id', $logId)
->update([
'inherit_visit_slot' => $realtime,
'inherit_visit_slot_calc_time' => $now,
]);
$written++;
}
}
if ($verify) {
$output->writeln(sprintf(
'对账完成:检查 %d 条,漂移 %d 条%s,耗时 %.2fs',
$processed,
$drift,
$drift > self::PRINT_LIMIT ? sprintf('(仅打印前 %d 条)', self::PRINT_LIMIT) : '',
microtime(true) - $startedAt
));
} elseif ($dryRun) {
$output->writeln(sprintf('演练完成:应回填 %d 条(未落库),耗时 %.2fs', $processed, microtime(true) - $startedAt));
} else {
$output->writeln(sprintf('回填完成:处理 %d 条,写入 %d 条,耗时 %.2fs', $processed, $written, microtime(true) - $startedAt));
}
return 0;
} catch (\Throwable $e) {
Log::error('继承时诊次快照回填异常: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
$output->writeln('<error>执行异常: ' . $e->getMessage() . '</error>');
return 0;
}
}
/** 漂移原因(无历史快照时只能按方向推断) */
private static function driftReason(int $calcTime, int $snapshot, int $realtime): string
{
if ($calcTime <= 0) {
return '未回填(calc_time=0,加列前的历史继承行)';
}
if ($realtime > $snapshot) {
return '实时值更大:继承前订单被恢复/新增有效订单,或诊单偏移上调';
}
return '实时值更小:继承前订单被取消/退款/软删,或诊单偏移下调';
}
}
+780
View File
@@ -0,0 +1,780 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\adminapi\logic\tcm\RevisitDueLogic;
use app\common\model\tcm\DiagnosisRevisitDueLog;
use app\common\service\tcm\RevisitDueCalculator;
use app\common\service\tcm\RevisitDueService;
use app\common\service\wechat\WechatWorkAppMessageService;
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;
/**
* 复诊到期提醒 - 企业微信推送(方案 §5.2,P2;project.revisit_reach.wecom_enabled=false 时空跑)
*
* 使用方法:
* php think tcm:revisit-due-notify --mode=daily # 每日 09:00:每位有「到期未挂号 / 过号」行的医助一条汇总
* php think tcm:revisit-due-notify --mode=tick # 每 30 分钟:escalation_level>=3 且到提醒时刻的行推本人(每行每日 ≤ wecom_daily_cap),
* # snooze_count>=阈值 的行每日抄送一次配置名单主管(supervisor_admin_ids)
* php think tcm:revisit-due-notify --mode=daily --dry-run # 只打印拟发送内容,不调企微、不写流水、不写回计数
* php think tcm:revisit-due-notify --mode=daily --date=2026-10-20 --dry-run # 以指定基准日回放
*
* 选项:
* --mode=daily|tick 默认 daily
* --dry-run 只打印拟发送内容
* --date=Y-m-d 基准日(默认今天);与今天不同时自动切换演练模式
*
* 约定:
* - 推送只在本命令内发(接口不同步 curl);逐条 usleep(200ms);每条 try/catch;整体 try/catch 并 return 0
* (Crontab::start 捕获到异常会把 dev_crontab.status 置 ERROR 停跑);
* - 接收人取 admin.work_wechat_userid(同 DiagnosisTodoNotify),未绑定则跳过并写 event=8 流水(payload.skipped=unbound,不算失败);
* - daily 去重:当日已有 event=8 且 payload.kind=daily 的医助不再发(流水判断,不改表);
* - tick 本人:wecom_self_at / wecom_self_count_day 日切重置后计数,达到 wecom_daily_cap 不再推;
* - tick 主管:wecom_supervisor_at 与今天同日则不再抄送;主管名单剔除医助本人并去重。
*
* 纯函数(isSameDay / selfSentToday / selfQuotaLeft / shouldCcSupervisor / normalizeSupervisorIds / overdueLabel /
* buildDailyText / buildSelfText / buildSupervisorText / withinWorkHours / dayRange)不连库、不读 config,
* 供 server/tests/RevisitDueNotifyTest.php 直接单测。
*/
class RevisitDueNotify extends Command
{
/** 流水 payload.kind:每日汇总 */
public const KIND_DAILY = 'daily';
/** 流水 payload.kind:tick 推本人 */
public const KIND_SELF = 'self';
/** 流水 payload.kind:tick 抄送主管 */
public const KIND_SUPERVISOR = 'supervisor';
/** 文案落款 */
public const SIGNATURE = '— 二中心跟踪系统';
/** 每日汇总最多列出的患者数(配置 wecom_daily_list_max 缺省值) */
public const DAILY_LIST_MAX_DEFAULT = 5;
/** 抄送主管的延迟次数阈值(配置 wecom_supervisor_snooze_min 缺省值) */
public const SUPERVISOR_SNOOZE_MIN_DEFAULT = 5;
/** 每条每日推送本人上限缺省值(配置 wecom_daily_cap 缺省值) */
public const DAILY_CAP_DEFAULT = 2;
/** tick 单次最多处理行数 */
private const TICK_ROW_LIMIT = 500;
/** 逐条发送间隔(微秒) */
private const SEND_GAP_USEC = 200000;
/** 进入推送的展示状态 */
private const PUSH_STATES = [RevisitDueCalculator::STATE_OVERDUE, RevisitDueCalculator::STATE_MISSED];
protected function configure()
{
$this->setName('tcm:revisit-due-notify')
->setDescription('复诊到期提醒企微推送:daily 每日汇总推医助本人 / tick 升级行推本人并抄送主管(wecom_enabled=false 时空跑)')
->addOption('mode', null, Option::VALUE_OPTIONAL, '推送模式 daily|tick', 'daily')
->addOption('dry-run', null, Option::VALUE_NONE, '只打印拟发送内容,不调企微、不写流水')
->addOption('date', null, Option::VALUE_OPTIONAL, '基准日 Y-m-d(默认今天)', '');
}
protected function execute(Input $input, Output $output): int
{
$startedAt = microtime(true);
$now = time();
try {
$mode = strtolower(trim((string) $input->getOption('mode')));
if ($mode === '') {
$mode = 'daily';
}
if (!in_array($mode, ['daily', 'tick'], true)) {
$output->writeln('<error>无效 --mode,仅支持 daily|tick</error>');
return 0;
}
$dryRun = (bool) $input->getOption('dry-run');
$today = trim((string) $input->getOption('date'));
if ($today !== '' && !$this->isValidDate($today)) {
$output->writeln('<error>无效 --date,格式应为 Y-m-d</error>');
return 0;
}
if ($today === '') {
$today = date('Y-m-d', $now);
} elseif ($today !== date('Y-m-d', $now) && !$dryRun) {
$output->writeln('<comment>--date 与今天不同,已自动切换为演练模式(--dry-run),不发送、不写库</comment>');
$dryRun = true;
}
$cfg = RevisitDueService::config();
$output->writeln(sprintf(
'[%s] 复诊到期企微推送 mode=%s date=%s%s',
date('Y-m-d H:i:s', $now),
$mode,
$today,
$dryRun ? ' (演练模式,不发送)' : ''
));
if (empty($cfg['wecom_enabled'])) {
$output->writeln('企微推送未启用(project.revisit_reach.wecom_enabled=false),本次不处理。');
return 0;
}
$stats = $mode === 'daily'
? $this->runDaily($output, $cfg, $today, $now, $dryRun)
: $this->runTick($output, $cfg, $today, $now, $dryRun);
$output->writeln(sprintf(
'完成。候选行 %d,发送 %d,失败 %d,未绑定跳过 %d,去重跳过 %d,抄送主管 %d,耗时 %ss',
$stats['rows'],
$stats['sent'],
$stats['failed'],
$stats['unbound'],
$stats['deduped'],
$stats['supervisor'],
round(microtime(true) - $startedAt, 2)
));
} catch (\Throwable $e) {
Log::error('tcm:revisit-due-notify 执行异常: ' . $e->getMessage());
$output->writeln('<error>执行异常:' . $e->getMessage() . '</error>');
}
return 0;
}
// ---------------------------------------------------------------- daily
/**
* 每日汇总:按医助分组,一人一条。
*
* @param array<string, mixed> $cfg
*
* @return array{rows:int, sent:int, failed:int, unbound:int, deduped:int, supervisor:int}
*/
private function runDaily(Output $output, array $cfg, string $today, int $now, bool $dryRun): array
{
$stats = $this->emptyStats();
$listMax = max(1, (int) ($cfg['wecom_daily_list_max'] ?? self::DAILY_LIST_MAX_DEFAULT));
$rows = $this->dueQuery($today)
->order('assistant_id ASC, escalation_level DESC, due_date ASC, id ASC')
->select()
->toArray();
$stats['rows'] = \count($rows);
if ($rows === []) {
$output->writeln('无到期未挂号 / 过号行。');
return $stats;
}
// 按医助分组,只保留读取侧推导为 超期 / 过号 的行
$groups = [];
foreach ($rows as $row) {
$st = RevisitDueCalculator::deriveState($row, $today, $cfg);
if (!in_array($st['state'], self::PUSH_STATES, true)) {
continue;
}
$row['_st'] = $st;
$groups[(int) $row['assistant_id']][] = $row;
}
if ($groups === []) {
$output->writeln('候选行经状态推导后均不需提醒。');
return $stats;
}
$assistantIds = array_keys($groups);
// 当日已发过汇总的医助(流水判断:event=8 且 payload.kind=daily)
$sentToday = $this->dailySentAssistantIds($assistantIds, $today);
$admins = $this->fetchAdmins($assistantIds);
foreach ($groups as $aid => $items) {
try {
if (isset($sentToday[$aid])) {
$stats['deduped']++;
$output->writeln(sprintf(' 医助 %d 今日已发过汇总,跳过(%d 行)', $aid, \count($items)));
continue;
}
$text = self::buildDailyText(array_map(static fn (array $r): array => [
'patient_name' => (string) ($r['patient_name'] ?? ''),
'state' => (string) $r['_st']['state'],
'overdue_days' => $r['_st']['overdue_days'],
], $items), $listMax);
$admin = $admins[$aid] ?? null;
$wxId = trim((string) ($admin['work_wechat_userid'] ?? ''));
$name = (string) ($admin['name'] ?? ('#' . $aid));
if ($dryRun) {
$output->writeln(sprintf(' [拟发送 daily] 医助 %s(%d) → %s | %s', $name, $aid, $wxId !== '' ? $wxId : '(未绑定)', $text));
$wxId !== '' ? $stats['sent']++ : $stats['unbound']++;
continue;
}
$payloadBase = ['kind' => self::KIND_DAILY, 'to' => $wxId, 'n' => \count($items)];
if ($wxId === '') {
$stats['unbound']++;
$output->writeln(sprintf(' 医助 %s(%d) 未绑定企微 userid,跳过(%d 行)', $name, $aid, \count($items)));
$this->logRows($items, $today, $now, $payloadBase + ['ok' => false, 'skipped' => 'unbound']);
continue;
}
$res = WechatWorkAppMessageService::sendTextToUser($wxId, $text);
$ok = (bool) ($res['ok'] ?? false);
$payload = $payloadBase + ['ok' => $ok];
if (!$ok) {
$payload['errcode'] = (int) ($res['errcode'] ?? -1);
$payload['msg'] = mb_substr((string) ($res['message'] ?? ''), 0, 60);
$stats['failed']++;
$output->writeln(sprintf(' <error>医助 %s(%d) 发送失败:%s</error>', $name, $aid, (string) ($res['message'] ?? '')));
} else {
$stats['sent']++;
$output->writeln(sprintf(' 医助 %s(%d) 已发送汇总(%d 行)', $name, $aid, \count($items)));
}
$this->logRows($items, $today, $now, $payload);
usleep(self::SEND_GAP_USEC);
} catch (\Throwable $e) {
$stats['failed']++;
Log::error('tcm:revisit-due-notify daily 单医助处理异常 assistant_id=' . $aid . ' msg=' . $e->getMessage());
$output->writeln(sprintf(' <error>医助 %d 处理异常:%s</error>', $aid, $e->getMessage()));
}
}
return $stats;
}
// ---------------------------------------------------------------- tick
/**
* tick:升级行推本人(每行每日 ≤ cap),达阈值的行每日抄送主管一次。
*
* @param array<string, mixed> $cfg
*
* @return array{rows:int, sent:int, failed:int, unbound:int, deduped:int, supervisor:int}
*/
private function runTick(Output $output, array $cfg, string $today, int $now, bool $dryRun): array
{
$stats = $this->emptyStats();
$cap = max(0, (int) ($cfg['wecom_daily_cap'] ?? self::DAILY_CAP_DEFAULT));
$minSnooze = max(1, (int) ($cfg['wecom_supervisor_snooze_min'] ?? self::SUPERVISOR_SNOOZE_MIN_DEFAULT));
if (!self::withinWorkHours($now, $cfg['work_hours'] ?? null)) {
$output->writeln('当前不在工作时段(work_hours),tick 不推送。');
if (!$dryRun) {
return $stats;
}
$output->writeln('<comment>演练模式继续计算拟发送内容</comment>');
}
$rows = $this->dueQuery($today)
->where('escalation_level', '>=', 3)
->where('next_remind_at', '<=', $now)
->order('escalation_level DESC, due_date ASC, id ASC')
->limit(self::TICK_ROW_LIMIT)
->select()
->toArray();
$stats['rows'] = \count($rows);
if ($rows === []) {
$output->writeln('无需推送的升级行。');
return $stats;
}
$assistantIds = array_values(array_unique(array_map(static fn (array $r): int => (int) $r['assistant_id'], $rows)));
$supervisorPool = self::normalizeSupervisorIds($cfg['supervisor_admin_ids'] ?? []);
$admins = $this->fetchAdmins(array_values(array_unique(array_merge($assistantIds, $supervisorPool))));
$warnedNoSupervisor = false;
foreach ($rows as $row) {
$id = (int) $row['id'];
$aid = (int) $row['assistant_id'];
try {
$st = RevisitDueCalculator::deriveState($row, $today, $cfg);
if (!in_array($st['state'], self::PUSH_STATES, true)) {
continue;
}
$admin = $admins[$aid] ?? null;
$wxId = trim((string) ($admin['work_wechat_userid'] ?? ''));
$name = (string) ($admin['name'] ?? ('#' . $aid));
$patient = trim((string) ($row['patient_name'] ?? ''));
$logBase = [
'snooze_count_after' => (int) ($row['snooze_count'] ?? 0),
'level_after' => (int) ($row['escalation_level'] ?? 0),
'overdue_days' => $this->overdueDaysForLog($row, $today),
'operator_id' => 0,
'create_time' => $now,
];
// ---- 推本人(每行每日 ≤ cap) ----
$sentCnt = self::selfSentToday($row, $now);
$left = self::selfQuotaLeft($row, $now, $cap);
if ($left <= 0) {
$stats['deduped']++;
} else {
$text = self::buildSelfText($row, $st);
if ($dryRun) {
$output->writeln(sprintf(' [拟发送 self] due#%d 医助 %s(%d) → %s | %s', $id, $name, $aid, $wxId !== '' ? $wxId : '(未绑定)', $text));
$wxId !== '' ? $stats['sent']++ : $stats['unbound']++;
} else {
$payload = ['kind' => self::KIND_SELF, 'to' => $wxId, 'seq' => $sentCnt + 1, 'cap' => $cap];
if ($wxId === '') {
$stats['unbound']++;
$payload += ['ok' => false, 'skipped' => 'unbound'];
$output->writeln(sprintf(' due#%d 医助 %s(%d) 未绑定企微 userid,跳过', $id, $name, $aid));
} else {
$res = WechatWorkAppMessageService::sendTextToUser($wxId, $text);
$ok = (bool) ($res['ok'] ?? false);
$payload['ok'] = $ok;
if ($ok) {
$stats['sent']++;
$output->writeln(sprintf(' due#%d 医助 %s(%d) 患者 %s 已推送本人(今日第 %d 次)', $id, $name, $aid, $patient, $sentCnt + 1));
} else {
$stats['failed']++;
$payload['errcode'] = (int) ($res['errcode'] ?? -1);
$payload['msg'] = mb_substr((string) ($res['message'] ?? ''), 0, 60);
$output->writeln(sprintf(' <error>due#%d 医助 %s(%d) 推送失败:%s</error>', $id, $name, $aid, (string) ($res['message'] ?? '')));
}
usleep(self::SEND_GAP_USEC);
}
// 未绑定 / 失败同样计入当日次数,避免每 30 分钟重复刷流水
Db::name(RevisitDueService::TABLE)->where('id', $id)->update([
'wecom_self_at' => $now,
'wecom_self_count_day' => min(255, $sentCnt + 1),
'update_time' => $now,
]);
RevisitDueService::writeLog($id, (int) $row['diagnosis_id'], $aid, (int) $row['source_order_id'], DiagnosisRevisitDueLog::EVENT_WECOM_PUSH, $logBase + ['payload' => $payload]);
}
}
// ---- 抄送主管(每行每日一次) ----
if (!self::shouldCcSupervisor($row, $now, $minSnooze)) {
continue;
}
$supervisors = self::normalizeSupervisorIds($supervisorPool, $aid);
if ($supervisors === []) {
if (!$warnedNoSupervisor) {
$output->writeln('<comment> 未配置主管名单(project.revisit_reach.supervisor_admin_ids)或仅含医助本人,跳过抄送</comment>');
$warnedNoSupervisor = true;
}
continue;
}
$supText = self::buildSupervisorText($name, $row, $st);
$toList = [];
$okCnt = 0;
$failCnt = 0;
$firstErr = null;
foreach ($supervisors as $sid) {
$sWx = trim((string) ($admins[$sid]['work_wechat_userid'] ?? ''));
if ($sWx === '') {
$output->writeln(sprintf(' 主管 %d 未绑定企微 userid,跳过', $sid));
continue;
}
$toList[] = $sWx;
if ($dryRun) {
$output->writeln(sprintf(' [拟发送 supervisor] due#%d → 主管 %d(%s) | %s', $id, $sid, $sWx, $supText));
$okCnt++;
continue;
}
try {
$res = WechatWorkAppMessageService::sendTextToUser($sWx, $supText);
if (!empty($res['ok'])) {
$okCnt++;
} else {
$failCnt++;
$firstErr ??= (int) ($res['errcode'] ?? -1);
$output->writeln(sprintf(' <error>due#%d 抄送主管 %d 失败:%s</error>', $id, $sid, (string) ($res['message'] ?? '')));
}
} catch (\Throwable $e) {
$failCnt++;
Log::error('tcm:revisit-due-notify 抄送主管异常 due_id=' . $id . ' supervisor=' . $sid . ' msg=' . $e->getMessage());
}
usleep(self::SEND_GAP_USEC);
}
if ($dryRun) {
$stats['supervisor'] += $okCnt > 0 ? 1 : 0;
continue;
}
$payload = ['kind' => self::KIND_SUPERVISOR, 'to' => implode(',', $toList), 'ok' => $failCnt === 0 && $okCnt > 0, 'sent' => $okCnt, 'fail' => $failCnt];
if ($toList === []) {
$payload['skipped'] = 'unbound';
}
if ($firstErr !== null) {
$payload['errcode'] = $firstErr;
}
// 不论成败,当日只抄送一次
Db::name(RevisitDueService::TABLE)->where('id', $id)->update(['wecom_supervisor_at' => $now, 'update_time' => $now]);
RevisitDueService::writeLog($id, (int) $row['diagnosis_id'], $aid, (int) $row['source_order_id'], DiagnosisRevisitDueLog::EVENT_WECOM_PUSH, $logBase + ['payload' => $payload]);
if ($okCnt > 0) {
$stats['supervisor']++;
$output->writeln(sprintf(' due#%d 已抄送主管 %d 人(医助 %s,患者 %s,延迟 %d 次)', $id, $okCnt, $name, $patient, (int) $row['snooze_count']));
}
} catch (\Throwable $e) {
$stats['failed']++;
Log::error('tcm:revisit-due-notify tick 单行处理异常 due_id=' . $id . ' msg=' . $e->getMessage());
$output->writeln(sprintf(' <error>due#%d 处理异常:%s</error>', $id, $e->getMessage()));
}
}
return $stats;
}
// ---------------------------------------------------------------- 纯函数
/**
* 两个时间戳是否同一自然日(ts<=0 视为从未发生 → false)。
*/
public static function isSameDay(int $ts, int $now): bool
{
if ($ts <= 0) {
return false;
}
return date('Y-m-d', $ts) === date('Y-m-d', $now);
}
/**
* 当日已推送本人次数:wecom_self_at 与 now 同日取 wecom_self_count_day,否则日切归 0。
*
* @param array<string, mixed> $row
*/
public static function selfSentToday(array $row, int $now): int
{
$at = (int) ($row['wecom_self_at'] ?? 0);
if (!self::isSameDay($at, $now)) {
return 0;
}
return max(0, (int) ($row['wecom_self_count_day'] ?? 0));
}
/**
* 当日剩余可推本人次数(cap<=0 表示不推)。
*
* @param array<string, mixed> $row
*/
public static function selfQuotaLeft(array $row, int $now, int $cap): int
{
if ($cap <= 0) {
return 0;
}
return max(0, $cap - self::selfSentToday($row, $now));
}
/**
* 是否需要抄送主管:snooze_count >= minSnooze 且今天尚未抄送(wecom_supervisor_at 日切)。
*
* @param array<string, mixed> $row
*/
public static function shouldCcSupervisor(array $row, int $now, int $minSnooze): bool
{
if ((int) ($row['snooze_count'] ?? 0) < max(1, $minSnooze)) {
return false;
}
return !self::isSameDay((int) ($row['wecom_supervisor_at'] ?? 0), $now);
}
/**
* 主管名单归一:容忍逗号字符串 / 数组,转正整数、去重、剔除医助本人。
*
* @return list<int>
*/
public static function normalizeSupervisorIds(mixed $ids, int $excludeAdminId = 0): array
{
if (is_string($ids)) {
$ids = preg_split('/[\s,]+/', trim($ids)) ?: [];
}
if (!is_array($ids)) {
return [];
}
$out = [];
foreach ($ids as $v) {
$id = (int) $v;
if ($id > 0 && $id !== $excludeAdminId && !in_array($id, $out, true)) {
$out[] = $id;
}
}
return $out;
}
/**
* 超期标签:过号 → 「过号」(有超期天数再接「·超期N天」);超期 N 天 → 「超期N天」;0 → 「今日应挂号」。
*
* @param array{state?:string, overdue_days?:int|null} $st deriveState 结果
*/
public static function overdueLabel(array $st): string
{
$overdue = $st['overdue_days'] ?? null;
$overdue = $overdue === null ? null : (int) $overdue;
if (($st['state'] ?? '') === RevisitDueCalculator::STATE_MISSED) {
return '过号' . ($overdue !== null && $overdue > 0 ? '·超期' . $overdue . '天' : '');
}
if ($overdue === null) {
return '到期';
}
return $overdue > 0 ? '超期' . $overdue . '天' : '今日应挂号';
}
/**
* 每日汇总文案:按超期天数降序列出前 listMax 人。
*
* @param list<array{patient_name:string, state:string, overdue_days:int|null}> $items
*/
public static function buildDailyText(array $items, int $listMax = self::DAILY_LIST_MAX_DEFAULT): string
{
$n = \count($items);
if ($n === 0) {
return '';
}
$listMax = max(1, $listMax);
usort($items, static function (array $a, array $b): int {
return (int) ($b['overdue_days'] ?? 0) <=> (int) ($a['overdue_days'] ?? 0);
});
$maxOverdue = 0;
foreach ($items as $it) {
$maxOverdue = max($maxOverdue, (int) ($it['overdue_days'] ?? 0));
}
$parts = [];
foreach (array_slice($items, 0, $listMax) as $it) {
$name = trim((string) ($it['patient_name'] ?? ''));
$parts[] = ($name !== '' ? $name : '患者') . '(' . self::overdueLabel($it) . ')';
}
$text = '【复诊到期提醒】您有 ' . $n . ' 位患者到期未挂号';
if ($maxOverdue > 0) {
$text .= ',最久超期 ' . $maxOverdue . ' 天';
}
$text .= ':' . implode('、', $parts);
if ($n > $listMax) {
$text .= '…等 ' . $n . ' 人';
}
return $text . ',请尽快联系患者挂号。' . self::SIGNATURE;
}
/**
* tick 推本人文案。
*
* @param array<string, mixed> $row 快照行
* @param array{state?:string, overdue_days?:int|null} $st deriveState 结果
*/
public static function buildSelfText(array $row, array $st): string
{
$name = trim((string) ($row['patient_name'] ?? ''));
$name = $name !== '' ? $name : '患者';
$slot = (int) ($row['current_slot'] ?? 0);
$due = self::dateStr($row['due_date'] ?? null);
$snooze = (int) ($row['snooze_count'] ?? 0);
$text = '【复诊到期提醒】患者 ' . $name;
$text .= '(' . ($slot > 0 ? '第' . $slot . '诊后' : '待复诊') . ($due !== '' ? ',预计复诊 ' . substr($due, 5) : '') . ')';
$text .= self::overdueLabel($st) . '未挂号,您已延迟 ' . $snooze . ' 次,请尽快联系患者挂号。';
return $text . self::SIGNATURE;
}
/**
* tick 抄送主管文案:含医助姓名、患者、超期天数、已延迟次数。
*
* @param array<string, mixed> $row
* @param array{state?:string, overdue_days?:int|null} $st
*/
public static function buildSupervisorText(string $assistantName, array $row, array $st): string
{
$assistantName = trim($assistantName);
$assistantName = $assistantName !== '' ? $assistantName : ('#' . (int) ($row['assistant_id'] ?? 0));
$name = trim((string) ($row['patient_name'] ?? ''));
$name = $name !== '' ? $name : '患者';
$slot = (int) ($row['current_slot'] ?? 0);
$due = self::dateStr($row['due_date'] ?? null);
$snooze = (int) ($row['snooze_count'] ?? 0);
$contact = (int) ($row['contact_count'] ?? 0);
$text = '【复诊到期升级】医助 ' . $assistantName . ' 的患者 ' . $name . ' ' . self::overdueLabel($st) . '未挂号';
$text .= '(' . ($slot > 0 ? '第' . $slot . '诊后' : '待复诊') . ($due !== '' ? ',预计复诊 ' . substr($due, 5) : '') . ')';
$text .= ',已延迟 ' . $snooze . ' 次' . ($contact > 0 ? '、已联系 ' . $contact . ' 次' : '') . ',请关注。';
return $text . self::SIGNATURE;
}
/**
* now 是否落在工作时段 [start, end)(复用 RevisitDueLogic::parseWorkHours 的归一规则,非法配置回落 09:00~20:00)。
*/
public static function withinWorkHours(int $now, mixed $workHours): bool
{
[$startMin, $endMin] = RevisitDueLogic::parseWorkHours($workHours);
$minuteOfDay = (int) date('G', $now) * 60 + (int) date('i', $now);
return $minuteOfDay >= $startMin && $minuteOfDay < $endMin;
}
/**
* 'Y-m-d' → 当日 [00:00:00, 23:59:59] 时间戳;非法日期按今天。
*
* @return array{0:int, 1:int}
*/
public static function dayRange(string $today): array
{
$d = \DateTimeImmutable::createFromFormat('!Y-m-d', substr(trim($today), 0, 10));
if ($d === false) {
$d = new \DateTimeImmutable('today');
}
$start = $d->getTimestamp();
return [$start, $start + 86399];
}
// ---------------------------------------------------------------- 内部:取数与写入
/**
* 到期未挂号 / 过号的进行中行(口径同 RevisitDueLogic::applyDueWhere,基准日用 --date)。
*
* @return \think\db\Query
*/
private function dueQuery(string $today)
{
return Db::name(RevisitDueService::TABLE)
->where('closed_state', RevisitDueCalculator::CLOSED_OPEN)
->where('assistant_id', '>', 0)
->whereRaw('(appointment_state = ' . RevisitDueCalculator::APPT_MISSED . ' OR (due_date IS NOT NULL AND due_date <= :d1))', ['d1' => $today])
->whereRaw('(promised_date IS NULL OR promised_date < :d2)', ['d2' => $today]);
}
/**
* 当日已发过 daily 汇总的医助 id 集合(event=8 且 payload.kind=daily)。
*
* @param list<int> $assistantIds
*
* @return array<int, true>
*/
private function dailySentAssistantIds(array $assistantIds, string $today): array
{
if ($assistantIds === []) {
return [];
}
[$start, $end] = self::dayRange($today);
$ids = Db::name(RevisitDueService::LOG_TABLE)
->where('event', DiagnosisRevisitDueLog::EVENT_WECOM_PUSH)
->whereIn('assistant_id', $assistantIds)
->whereBetween('create_time', [$start, $end])
->whereLike('payload', '%"kind":"' . self::KIND_DAILY . '"%')
->distinct(true)
->column('assistant_id');
$out = [];
foreach ($ids as $id) {
$out[(int) $id] = true;
}
return $out;
}
/**
* 批量取账号姓名与企微 userid。
*
* @param list<int> $adminIds
*
* @return array<int, array{name:string, work_wechat_userid:string}>
*/
private function fetchAdmins(array $adminIds): array
{
$adminIds = array_values(array_filter(array_map('intval', $adminIds), static fn (int $v): bool => $v > 0));
if ($adminIds === []) {
return [];
}
$out = [];
foreach (array_chunk($adminIds, 500) as $chunk) {
// 直接查表:Admin 模型的 append(role_id/dept_id 访问器)会逐行再查库
$rows = Db::name('admin')
->whereIn('id', $chunk)
->whereNull('delete_time')
->field(['id', 'name', 'work_wechat_userid'])
->select()
->toArray();
foreach ($rows as $r) {
$out[(int) $r['id']] = [
'name' => (string) ($r['name'] ?? ''),
'work_wechat_userid' => (string) ($r['work_wechat_userid'] ?? ''),
];
}
}
return $out;
}
/**
* daily:为汇总覆盖的每一行写一条 event=8 流水(主管视图逐行可见)。
*
* @param list<array<string, mixed>> $items
* @param array<string, mixed> $payload
*/
private function logRows(array $items, string $today, int $now, array $payload): void
{
foreach ($items as $row) {
RevisitDueService::writeLog(
(int) $row['id'],
(int) $row['diagnosis_id'],
(int) $row['assistant_id'],
(int) $row['source_order_id'],
DiagnosisRevisitDueLog::EVENT_WECOM_PUSH,
[
'snooze_count_after' => (int) ($row['snooze_count'] ?? 0),
'level_after' => (int) ($row['escalation_level'] ?? 0),
'overdue_days' => $this->overdueDaysForLog($row, $today),
'operator_id' => 0,
'create_time' => $now,
'payload' => $payload,
]
);
}
}
/**
* 流水 overdue_days:today − due_date(负=未到期),无 due_date 记 0。
*
* @param array<string, mixed> $row
*/
private function overdueDaysForLog(array $row, string $today): int
{
$due = self::dateStr($row['due_date'] ?? null);
return $due !== '' ? RevisitDueCalculator::dayDiff($due, $today) : 0;
}
/**
* @return array{rows:int, sent:int, failed:int, unbound:int, deduped:int, supervisor:int}
*/
private function emptyStats(): array
{
return ['rows' => 0, 'sent' => 0, 'failed' => 0, 'unbound' => 0, 'deduped' => 0, 'supervisor' => 0];
}
private function isValidDate(string $date): bool
{
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) {
return false;
}
$d = \DateTimeImmutable::createFromFormat('!Y-m-d', $date);
return $d !== false && $d->format('Y-m-d') === $date;
}
/** 日期列 → 'Y-m-d' 或 ''(null / 空 / 0000-00-00 均视为空) */
private static function dateStr(mixed $value): string
{
if ($value === null) {
return '';
}
$s = substr(trim((string) $value), 0, 10);
return ($s === '' || $s === '0000-00-00') ? '' : $s;
}
}
+266
View File
@@ -0,0 +1,266 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\common\service\tcm\RevisitDueCalculator;
use app\common\service\tcm\RevisitDueService;
use think\console\Command;
use think\console\Input;
use think\console\input\Option;
use think\console\Output;
use think\facade\Cache;
use think\facade\Log;
/**
* 复诊到期快照重算(tcm_diagnosis_revisit_due)
*
* 使用方法:
* php think tcm:revisit-due-scan --mode=full # 每日全量(zyt_dev_crontab:30 8 * * *)
* php think tcm:revisit-due-scan --mode=incremental # 增量(每 30 分钟):近 N 分钟有订单/挂号/指派/诊单变更的诊单 + closed_state=2 行
* php think tcm:revisit-due-scan --dry-run --diagnosis-id=123 # 只计算不写库,打印拟写入行(抽样核对页面)
* php think tcm:revisit-due-scan --mode=full --date=2026-10-20 --dry-run # 以指定基准日回放状态
*
* 选项:
* --mode=full|incremental 默认 full
* --dry-run 只计算并打印前 20 条结果,不写库、不写流水、不更新防重入标记
* --diagnosis-id=1,2,3 仅处理指定诊单(逗号分隔),跳过候选扫描与防重入
* --date=Y-m-d 基准日(默认今天),影响「已约是否仍在未来」与打印的推导状态
* --since-minutes=120 增量窗口(分钟),仅 incremental 有效
*
* 防重入:同一 mode 上次开始时间距今 < 5 分钟则跳过(Cache 标记;钩子也会刷 computed_at,故不用 MAX(computed_at) 判断)。
* 异常:整体 try/catch 并 return 0、按块隔离(Crontab::start 捕获到异常会把 dev_crontab.status 置 ERROR 停跑)。
*/
class RevisitDueScan extends Command
{
/** 每批刷新的诊单数 */
private const CHUNK_SIZE = 500;
/** 防重入窗口(秒) */
private const REENTRY_GUARD_SECONDS = 300;
/** dry-run 最多打印条数 */
private const DRY_RUN_PRINT_LIMIT = 20;
/** 防重入缓存键前缀 */
private const CACHE_KEY_PREFIX = 'tcm_revisit_due_scan_last_start:';
protected function configure()
{
$this->setName('tcm:revisit-due-scan')
->setDescription('复诊到期快照重算:full 全量 / incremental 增量(订单、挂号、指派、诊单变更 + 已挂号行重评)')
->addOption('mode', null, Option::VALUE_OPTIONAL, '扫描模式 full|incremental', 'full')
->addOption('dry-run', null, Option::VALUE_NONE, '只计算不写库,打印前 20 条拟写入行')
->addOption('diagnosis-id', null, Option::VALUE_OPTIONAL, '仅处理指定诊单 id(逗号分隔)', '')
->addOption('date', null, Option::VALUE_OPTIONAL, '基准日 Y-m-d(默认今天)', '')
->addOption('since-minutes', null, Option::VALUE_OPTIONAL, '增量窗口(分钟)', 120);
}
protected function execute(Input $input, Output $output): int
{
$startedAt = microtime(true);
$now = time();
try {
$mode = strtolower(trim((string) $input->getOption('mode')));
if ($mode === '') {
$mode = 'full';
}
if (!in_array($mode, ['full', 'incremental'], true)) {
$output->writeln('<error>无效 --mode,仅支持 full|incremental</error>');
return 0;
}
$dryRun = (bool) $input->getOption('dry-run');
$today = trim((string) $input->getOption('date'));
if ($today !== '' && !$this->isValidDate($today)) {
$output->writeln('<error>无效 --date,格式应为 Y-m-d</error>');
return 0;
}
if ($today === '') {
$today = date('Y-m-d', $now);
} elseif ($today !== date('Y-m-d', $now) && !$dryRun) {
// --date 只用于基准日回放:以假基准日真正写库会把线上快照(已约是否过期、流水超期天数)算错
$output->writeln('<comment>--date 与今天不同,已自动切换为演练模式(--dry-run),不写库</comment>');
$dryRun = true;
}
$sinceMinutes = (int) $input->getOption('since-minutes');
if ($sinceMinutes <= 0) {
$sinceMinutes = 120;
}
$explicitIds = $this->parseIdList((string) $input->getOption('diagnosis-id'));
$output->writeln(sprintf(
'[%s] 复诊到期快照重算 mode=%s date=%s%s%s',
date('Y-m-d H:i:s', $now),
$mode,
$today,
$dryRun ? ' (演练模式,不写库)' : '',
$explicitIds !== [] ? ' diagnosis_id=' . implode(',', $explicitIds) : ''
));
// 候选
if ($explicitIds !== []) {
$ids = $explicitIds;
} else {
if (!$dryRun && !$this->acquireReentryGuard($mode, $now)) {
$output->writeln(sprintf('同 mode 最近 %d 秒内已启动过一次,本次跳过。', self::REENTRY_GUARD_SECONDS));
return 0;
}
$sinceTs = $mode === 'incremental' ? $now - $sinceMinutes * 60 : 0;
$ids = RevisitDueService::candidateDiagnosisIds($mode, $sinceTs);
if ($mode === 'incremental') {
$output->writeln(sprintf('增量窗口:自 %s 起', date('Y-m-d H:i:s', $sinceTs)));
}
}
$total = \count($ids);
$output->writeln('候选诊单:' . $total . ' 条');
if ($total === 0) {
$output->writeln('无需处理。');
return 0;
}
$stats = ['refreshed' => 0, 'inserted' => 0, 'closed' => 0, 'reopened' => 0, 'no_days' => 0, 'chunks' => 0, 'errors' => 0];
$printed = 0;
$cfg = RevisitDueService::config();
foreach (array_chunk($ids, self::CHUNK_SIZE) as $chunk) {
$stats['chunks']++;
try {
$res = RevisitDueService::refreshDiagnoses($chunk, [
'reason' => 'cron:' . $mode,
'dry_run' => $dryRun,
'now' => $now,
'today' => $today,
]);
foreach (['refreshed', 'inserted', 'closed', 'reopened', 'no_days'] as $k) {
$stats[$k] += (int) ($res[$k] ?? 0);
}
if ($dryRun) {
foreach ((array) ($res['rows'] ?? []) as $row) {
if ($printed >= self::DRY_RUN_PRINT_LIMIT) {
break;
}
$output->writeln($this->formatDryRunRow($row, $today, $cfg));
$printed++;
}
}
} catch (\Throwable $e) {
$stats['errors']++;
Log::error('tcm:revisit-due-scan 分块失败 mode=' . $mode . ' first_id=' . (int) ($chunk[0] ?? 0) . ' msg=' . $e->getMessage());
$output->writeln('<error>分块失败(首 id ' . (int) ($chunk[0] ?? 0) . '):' . $e->getMessage() . '</error>');
}
}
if ($dryRun && $total > $printed) {
$output->writeln(sprintf('(演练仅打印前 %d 条,其余 %d 条未打印)', $printed, $total - $printed));
}
$output->writeln(sprintf(
'完成。候选 %d,刷新 %d(新增 %d),自动关闭 %d,重开 %d,缺天数 %d,分块 %d(失败 %d),耗时 %ss',
$total,
$stats['refreshed'],
$stats['inserted'],
$stats['closed'],
$stats['reopened'],
$stats['no_days'],
$stats['chunks'],
$stats['errors'],
round(microtime(true) - $startedAt, 2)
));
} catch (\Throwable $e) {
Log::error('tcm:revisit-due-scan 执行异常: ' . $e->getMessage());
$output->writeln('<error>执行异常:' . $e->getMessage() . '</error>');
}
return 0;
}
/**
* 防重入:同 mode 最近一次开始时间距今 < 5 分钟则返回 false;否则记录本次开始时间。缓存异常时放行。
*/
private function acquireReentryGuard(string $mode, int $now): bool
{
$key = self::CACHE_KEY_PREFIX . $mode;
try {
$last = (int) Cache::get($key, 0);
if ($last > 0 && $now - $last < self::REENTRY_GUARD_SECONDS) {
return false;
}
Cache::set($key, $now, self::REENTRY_GUARD_SECONDS);
} catch (\Throwable $e) {
Log::warning('tcm:revisit-due-scan 防重入标记读写失败,放行: ' . $e->getMessage());
}
return true;
}
/**
* @return list<int>
*/
private function parseIdList(string $raw): array
{
$out = [];
foreach (preg_split('/[\s,]+/', trim($raw)) ?: [] as $part) {
$id = (int) $part;
if ($id > 0 && !in_array($id, $out, true)) {
$out[] = $id;
}
}
return $out;
}
private function isValidDate(string $date): bool
{
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) {
return false;
}
$d = \DateTimeImmutable::createFromFormat('!Y-m-d', $date);
return $d !== false && $d->format('Y-m-d') === $date;
}
/**
* dry-run 单行输出:诊单 / 医助 / 诊次 / 锚点 / 天数 / 预计复诊 / 挂号 / 周期状态 / 推导状态 / 事件
*
* @param array<string, mixed> $row 拟写入行(含 _events/_is_new)
* @param array<string, mixed> $cfg
*/
private function formatDryRunRow(array $row, string $today, array $cfg): string
{
$st = RevisitDueCalculator::deriveState($row, $today, $cfg);
$anchorTs = (int) ($row['anchor_ts'] ?? 0);
$events = (array) ($row['_events'] ?? []);
return sprintf(
' diag=%d%s assistant=%d 患者=%s | 第%d诊(有效单%d,待复诊第%d诊) | 订单#%d %s | 锚点=%s(src=%d) 天数=%d(src=%d,档%d) 药尽=%s | 挂号=%d %s | closed=%d | %s[%s]%s%s',
(int) ($row['diagnosis_id'] ?? 0),
!empty($row['_is_new']) ? '(新增)' : '',
(int) ($row['assistant_id'] ?? 0),
(string) ($row['patient_name'] ?? ''),
(int) ($row['current_slot'] ?? 0),
(int) ($row['valid_order_count'] ?? 0),
(int) ($row['next_slot'] ?? 0),
(int) ($row['source_order_id'] ?? 0),
(int) ($row['order_create_time'] ?? 0) > 0 ? date('Y-m-d', (int) $row['order_create_time']) : '-',
$anchorTs > 0 ? date('Y-m-d', $anchorTs) : '-',
(int) ($row['anchor_src'] ?? 0),
(int) ($row['medication_days'] ?? 0),
(int) ($row['days_src'] ?? 0),
(int) ($row['rule_days'] ?? 0),
(string) ($row['due_date'] ?? '') !== '' ? (string) $row['due_date'] : '-',
(int) ($row['appointment_state'] ?? 0),
(string) ($row['appointment_date'] ?? '') !== '' ? (string) $row['appointment_date'] : '-',
(int) ($row['closed_state'] ?? 0),
(string) $st['state'],
(string) $st['state_text'],
(int) $st['severe'] === 1 ? ' 深红' : '',
$events !== [] ? ' events=' . implode(',', array_map('intval', $events)) : ''
);
}
}
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace app\common\command;
use app\common\model\doctor\Appointment;
use app\common\service\tcm\RevisitDueHook;
use think\console\Command;
use think\console\Input;
use think\console\input\Option;
@@ -55,6 +56,9 @@ class UpdateAppointmentStatus extends Command
$missedCount = Db::execute($missedSql, [$now]);
$cancelCount = Db::execute($cancelSql, [$now]);
// 过号/取消落库后重评复诊到期快照中「已约」行(当天过号即进标红/弹窗;钩子内部已 try/catch)
RevisitDueHook::onAppointmentStatusCronDone();
$output->writeln("已过号(1→4): {$missedCount} 条");
$output->writeln("已取消(4→2,过号超8小时): {$cancelCount} 条");
Log::info("update_appointment_status: 已过号 {$missedCount}, 已取消 {$cancelCount}");
@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace app\common\model\tcm;
use app\common\model\BaseModel;
/**
* 诊单复诊到期快照模型(每诊单一行,当前周期 = 最新有效业务订单)
*
* 写入方:RevisitDueService::refreshDiagnoses(cron / 钩子 / 列表自愈)与需求 4 的延迟/已联系/关闭接口。
* 时间相关状态(到期/预警/超期/过号/已约)不落列,由 RevisitDueCalculator::deriveState 按事实列 + today 推导。
*
* @package app\common\model\tcm
*/
class DiagnosisRevisitDue extends BaseModel
{
protected $name = 'tcm_diagnosis_revisit_due';
// 自动时间戳类型设置为整型(表无 delete_time 列)
protected $autoWriteTimestamp = true;
protected $createTime = 'create_time';
protected $updateTime = 'update_time';
protected $deleteTime = false;
// 取出字段保持整型,前端按需要再格式化
protected $dateFormat = false;
/** 周期状态:进行中 */
public const CLOSED_OPEN = 0;
/** 周期状态:已挂号(自动关闭,待面诊) */
public const CLOSED_BOOKED = 2;
/** 周期状态:已面诊(自动关闭) */
public const CLOSED_VISITED = 3;
/** 周期状态:人工关闭 */
public const CLOSED_MANUAL = 4;
/** 周期状态:失效(无医助 / 诊单删除或禁用 / 无有效订单) */
public const CLOSED_INVALID = 5;
/** @var array<int,string> */
public const CLOSED_STATE_TEXT_MAP = [
self::CLOSED_OPEN => '进行中',
self::CLOSED_BOOKED => '已挂号',
self::CLOSED_VISITED => '已面诊',
self::CLOSED_MANUAL => '人工关闭',
self::CLOSED_INVALID => '失效',
];
/** 用药起算来源:无 */
public const ANCHOR_NONE = 0;
/** 用药起算来源:物流签收 */
public const ANCHOR_SIGN = 1;
/** 用药起算来源:人工发货日志 */
public const ANCHOR_SHIP = 2;
/** 用药起算来源:下单时间 */
public const ANCHOR_CREATE = 3;
/** @var array<int,string> */
public const ANCHOR_SRC_TEXT_MAP = [
self::ANCHOR_NONE => '',
self::ANCHOR_SIGN => '签收起',
self::ANCHOR_SHIP => '发货起',
self::ANCHOR_CREATE => '下单起',
];
/** 天数来源:缺失(或配置兜底) */
public const DAYS_SRC_NONE = 0;
/** 天数来源:订单 medication_days */
public const DAYS_SRC_ORDER = 1;
/** 天数来源:处方 usage_days */
public const DAYS_SRC_RX = 2;
/** @var array<int,string> */
public const DAYS_SRC_TEXT_MAP = [
self::DAYS_SRC_NONE => '缺开药天数',
self::DAYS_SRC_ORDER => '订单',
self::DAYS_SRC_RX => '处方',
];
/** 锚点订单之后最新有效挂号状态:无 */
public const APPT_NONE = 0;
/** 已约(doctor_appointment.status=1) */
public const APPT_BOOKED = 1;
/** 已面诊(status=3) */
public const APPT_VISITED = 3;
/** 已过号(status=4) */
public const APPT_MISSED = 4;
/** @var array<int,string> */
public const APPOINTMENT_STATE_TEXT_MAP = [
self::APPT_NONE => '未挂号',
self::APPT_BOOKED => '已约',
self::APPT_VISITED => '已面诊',
self::APPT_MISSED => '已过号',
];
/**
* @notes 周期状态文本
*/
public function getClosedStateTextAttr($value, $data): string
{
$v = (int) ($data['closed_state'] ?? 0);
return self::CLOSED_STATE_TEXT_MAP[$v] ?? '未知';
}
/**
* @notes 用药起算来源文本
*/
public function getAnchorSrcTextAttr($value, $data): string
{
$v = (int) ($data['anchor_src'] ?? 0);
return self::ANCHOR_SRC_TEXT_MAP[$v] ?? '';
}
/**
* @notes 开药天数来源文本
*/
public function getDaysSrcTextAttr($value, $data): string
{
$v = (int) ($data['days_src'] ?? 0);
return self::DAYS_SRC_TEXT_MAP[$v] ?? '';
}
/**
* @notes 挂号事实状态文本
*/
public function getAppointmentStateTextAttr($value, $data): string
{
$v = (int) ($data['appointment_state'] ?? 0);
return self::APPOINTMENT_STATE_TEXT_MAP[$v] ?? '未知';
}
/**
* @notes 最近重算时间格式化(Y-m-d H:i)
*/
public function getComputedAtTextAttr($value, $data): string
{
$ts = (int) ($data['computed_at'] ?? 0);
return $ts > 0 ? date('Y-m-d H:i', $ts) : '';
}
/**
* @notes 下次提醒时间格式化(Y-m-d H:i)
*/
public function getNextRemindAtTextAttr($value, $data): string
{
$ts = (int) ($data['next_remind_at'] ?? 0);
return $ts > 0 ? date('Y-m-d H:i', $ts) : '';
}
/**
* @notes 用药起算日(Y-m-d)
*/
public function getAnchorDateAttr($value, $data): string
{
$ts = (int) ($data['anchor_ts'] ?? 0);
return $ts > 0 ? date('Y-m-d', $ts) : '';
}
}
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace app\common\model\tcm;
use app\common\model\BaseModel;
/**
* 诊单复诊到期事件流水模型(审计与「越点越上心」统计)
*
* 注意:弹窗轮询不写流水(只更新 first/last_shown_at),避免日志膨胀。
*
* @package app\common\model\tcm
*/
class DiagnosisRevisitDueLog extends BaseModel
{
protected $name = 'tcm_diagnosis_revisit_due_log';
// 仅有 create_time,整型时间戳
protected $autoWriteTimestamp = true;
protected $createTime = 'create_time';
protected $updateTime = false;
protected $deleteTime = false;
protected $dateFormat = false;
/** 事件:延迟 */
public const EVENT_SNOOZE = 1;
/** 事件:已联系(填承诺日) */
public const EVENT_CONTACT = 2;
/** 事件:人工关闭 */
public const EVENT_MANUAL_CLOSE = 3;
/** 事件:自动关闭-已挂号 */
public const EVENT_AUTO_CLOSE_BOOKED = 4;
/** 事件:自动关闭-已面诊 */
public const EVENT_AUTO_CLOSE_VISITED = 5;
/** 事件:周期重置(新有效订单成为锚点) */
public const EVENT_CYCLE_RESET = 6;
/** 事件:重新打开(挂号取消/过号) */
public const EVENT_REOPEN = 7;
/** 事件:企微推送 */
public const EVENT_WECOM_PUSH = 8;
/** 事件:医助变更(含继承),延迟字段清零 */
public const EVENT_ASSISTANT_CHANGED = 9;
/** 事件:形态升级(escalation_level 变化) */
public const EVENT_ESCALATED = 10;
/** 事件:首次弹出 */
public const EVENT_FIRST_SHOWN = 11;
/** @var array<int,string> */
public const EVENT_TEXT_MAP = [
self::EVENT_SNOOZE => '延迟',
self::EVENT_CONTACT => '已联系',
self::EVENT_MANUAL_CLOSE => '人工关闭',
self::EVENT_AUTO_CLOSE_BOOKED => '自动关闭-已挂号',
self::EVENT_AUTO_CLOSE_VISITED => '自动关闭-已面诊',
self::EVENT_CYCLE_RESET => '周期重置',
self::EVENT_REOPEN => '重新打开',
self::EVENT_WECOM_PUSH => '企微推送',
self::EVENT_ASSISTANT_CHANGED => '医助变更',
self::EVENT_ESCALATED => '形态升级',
self::EVENT_FIRST_SHOWN => '首次弹出',
];
/**
* @notes 事件文本
*/
public function getEventTextAttr($value, $data): string
{
$v = (int) ($data['event'] ?? 0);
return self::EVENT_TEXT_MAP[$v] ?? '未知';
}
/**
* @notes 事件时间格式化(Y-m-d H:i:s)
*/
public function getCreateTimeTextAttr($value, $data): string
{
$ts = (int) ($data['create_time'] ?? 0);
return $ts > 0 ? date('Y-m-d H:i:s', $ts) : '';
}
/**
* @notes payload JSON 解码为数组(解码失败返回空数组)
*
* @return array<string,mixed>
*/
public function getPayloadArrAttr($value, $data): array
{
$raw = (string) ($data['payload'] ?? '');
if ($raw === '') {
return [];
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
}
@@ -0,0 +1,462 @@
<?php
declare(strict_types=1);
namespace app\common\service\tcm;
/**
* 复诊到期纯函数计算器(无 DB、无 config() 依赖,可直接单测)。
*
* - computeFacts():由「最新有效订单 + 处方天数 + 签收/发货时间 + 挂号 + 首次触达」算出快照表事实列(方案 §1.2);
* - deriveState():由快照行事实列 + today 推导展示状态(方案 §1.3 九态与优先级),列表 / 弹窗 / 主管视图共用;
* - escalationLevel():延迟次数 → 升级等级(方案 §1.4)。
*
* 约定:所有日期为 'Y-m-d' 字符串(或 null),时间戳为 int 秒;配置缺项用 defaultConfig() 补齐。
*/
final class RevisitDueCalculator
{
public const STATE_VISITED = 'visited';
public const STATE_BOOKED = 'booked';
public const STATE_MISSED = 'missed';
public const STATE_CONTACTED = 'contacted';
public const STATE_OVERDUE = 'overdue';
public const STATE_WARNING = 'warning';
public const STATE_PENDING = 'pending';
public const STATE_NO_DAYS = 'no_days';
public const STATE_NONE = 'none';
/** 起算来源:1=签收 2=发货 3=下单 0=无 */
public const ANCHOR_NONE = 0;
public const ANCHOR_SIGN = 1;
public const ANCHOR_SHIP = 2;
public const ANCHOR_CREATE = 3;
/** 天数来源:0=缺失(或配置兜底) 1=订单 2=处方 */
public const DAYS_SRC_NONE = 0;
public const DAYS_SRC_ORDER = 1;
public const DAYS_SRC_RX = 2;
/** 周期状态(与 DiagnosisRevisitDue::CLOSED_* 一致) */
public const CLOSED_OPEN = 0;
public const CLOSED_BOOKED = 2;
public const CLOSED_VISITED = 3;
public const CLOSED_MANUAL = 4;
public const CLOSED_INVALID = 5;
/** 挂号事实状态:0=无 1=已约 3=已面诊 4=过号 */
public const APPT_NONE = 0;
public const APPT_BOOKED = 1;
public const APPT_VISITED = 3;
public const APPT_MISSED = 4;
/** 偏移量上限(与 RevisitVisitSlotService::MAX_OFFSET 一致) */
private const MAX_OFFSET = 20;
/**
* 默认配置(与 config/project.php 的 revisit_reach 段同键;此处只含计算相关项)。
*
* @return array<string, mixed>
*/
public static function defaultConfig(): array
{
return [
'anchor_chain' => ['sign', 'ship', 'create'],
'days_fallback_rx' => true,
'days_default' => 0,
'day_buckets' => [14, 21, 28, 30],
'grace_days' => 0,
'warn_days' => 3,
'severe_overdue_days' => 7,
'appointment_min_gap_days' => 1,
'limit_er_center' => true,
];
}
/**
* 合并默认值并做类型归一(容忍 .env / 配置里写成字符串)。
*
* @param array<string, mixed> $cfg
*
* @return array<string, mixed>
*/
public static function normalizeConfig(array $cfg): array
{
$out = array_replace(self::defaultConfig(), $cfg);
$chain = $out['anchor_chain'];
if (is_string($chain)) {
$chain = explode(',', $chain);
}
if (!is_array($chain)) {
$chain = ['create'];
}
$normChain = [];
foreach ($chain as $c) {
$c = strtolower(trim((string) $c));
if (in_array($c, ['sign', 'ship', 'create'], true) && !in_array($c, $normChain, true)) {
$normChain[] = $c;
}
}
$out['anchor_chain'] = $normChain;
$buckets = is_array($out['day_buckets']) ? $out['day_buckets'] : [];
$out['day_buckets'] = array_values(array_unique(array_filter(array_map('intval', $buckets), static fn (int $v): bool => $v > 0)));
$out['days_fallback_rx'] = (bool) $out['days_fallback_rx'];
$out['days_default'] = max(0, (int) $out['days_default']);
$out['grace_days'] = max(0, (int) $out['grace_days']);
$out['warn_days'] = max(0, (int) $out['warn_days']);
$out['appointment_min_gap_days'] = max(0, (int) ($out['appointment_min_gap_days'] ?? 1));
$out['limit_er_center'] = (bool) $out['limit_er_center'];
// severe_overdue_days 允许阶梯数组:取最小阈值作为「深红」起点
$severe = $out['severe_overdue_days'];
if (is_array($severe)) {
$vals = array_filter(array_map('intval', $severe), static fn (int $v): bool => $v > 0);
$severe = $vals !== [] ? min($vals) : 7;
}
$out['severe_overdue_days'] = max(0, (int) $severe);
return $out;
}
/**
* 计算快照事实列。
*
* @param array{
* diagnosis_id:int, assistant_id:int, patient_name?:string,
* latest_order:null|array{id:int, create_time:int, medication_days?:int, prescription_id?:int},
* valid_order_count:int, offset:int, rx_usage_days?:int|null,
* sign_ts?:int|null, ship_ts?:int|null,
* appointments?:list<array{id:int, status:int, appointment_date:string, appointment_time?:string}>,
* first_touch_ts?:int|null, total_medication_days?:int, diagnosis_active?:bool
* } $input
* @param array<string, mixed> $cfg revisit_reach 配置(可只传部分键)
* @param string $today 'Y-m-d',用于判定「已约」是否仍在未来
*
* @return array{
* source_order_id:int, order_create_time:int, valid_order_count:int,
* anchor_ts:int, anchor_src:int, medication_days:int, days_src:int, rule_days:int,
* total_medication_days:int, current_slot:int, next_slot:int,
* first_touch_date:string|null, due_date:string|null,
* appointment_state:int, appointment_date:string|null, booked_appointment_id:int, closed_state_auto:int
* }
*/
public static function computeFacts(array $input, array $cfg, string $today): array
{
$cfg = self::normalizeConfig($cfg);
$assistantId = (int) ($input['assistant_id'] ?? 0);
$validCount = max(0, (int) ($input['valid_order_count'] ?? 0));
$offset = (int) ($input['offset'] ?? 0);
if ($offset < 0) {
$offset = 0;
}
if ($offset > self::MAX_OFFSET) {
$offset = self::MAX_OFFSET;
}
$currentSlot = $validCount + $offset;
$firstTouchTs = (int) ($input['first_touch_ts'] ?? 0);
$diagnosisActive = (bool) ($input['diagnosis_active'] ?? true);
$order = $input['latest_order'] ?? null;
if (!is_array($order) || (int) ($order['id'] ?? 0) <= 0) {
$order = null;
}
$facts = [
'source_order_id' => 0,
'order_create_time' => 0,
'valid_order_count' => $validCount,
'anchor_ts' => 0,
'anchor_src' => self::ANCHOR_NONE,
'medication_days' => 0,
'days_src' => self::DAYS_SRC_NONE,
'rule_days' => 0,
'total_medication_days' => max(0, (int) ($input['total_medication_days'] ?? 0)),
'current_slot' => $currentSlot,
'next_slot' => $currentSlot + 1,
'first_touch_date' => $firstTouchTs > 0 ? date('Y-m-d', $firstTouchTs) : null,
'due_date' => null,
'appointment_state' => self::APPT_NONE,
'appointment_date' => null,
'booked_appointment_id' => 0,
'closed_state_auto' => self::CLOSED_INVALID,
];
// 锚点订单先于失效判定写入:失效行(无医助/禁用/删除)也记录最新有效订单,
// 否则列表自愈会因 source_order_id=0 ≠ 最新订单 id 而在每次加载时重复刷新这些行
$orderCreateTime = $order !== null ? (int) ($order['create_time'] ?? 0) : 0;
$facts['source_order_id'] = $order !== null ? (int) ($order['id'] ?? 0) : 0;
$facts['order_create_time'] = $orderCreateTime;
// 无有效订单 / 无医助 / 诊单删除或禁用 → 失效周期
if ($order === null || $validCount <= 0 || $assistantId <= 0 || !$diagnosisActive) {
return $facts;
}
// 1. 用药起算锚点:按 anchor_chain 回退(签收 → 发货 → 下单);链耗尽仍无结果时兜底下单时间
$signTs = (int) ($input['sign_ts'] ?? 0);
$shipTs = (int) ($input['ship_ts'] ?? 0);
foreach ($cfg['anchor_chain'] as $src) {
if ($src === 'sign' && $signTs > 0) {
$facts['anchor_ts'] = $signTs;
$facts['anchor_src'] = self::ANCHOR_SIGN;
break;
}
if ($src === 'ship' && $shipTs > 0) {
$facts['anchor_ts'] = $shipTs;
$facts['anchor_src'] = self::ANCHOR_SHIP;
break;
}
if ($src === 'create' && $orderCreateTime > 0) {
$facts['anchor_ts'] = $orderCreateTime;
$facts['anchor_src'] = self::ANCHOR_CREATE;
break;
}
}
if ($facts['anchor_ts'] <= 0 && $orderCreateTime > 0) {
$facts['anchor_ts'] = $orderCreateTime;
$facts['anchor_src'] = self::ANCHOR_CREATE;
}
// 2. 开药天数:订单 medication_days → 处方 usage_days(已剔系统代开/作废)→ 配置兜底(days_default,src 仍记 0)
$orderDays = (int) ($order['medication_days'] ?? 0);
$rxDays = (int) ($input['rx_usage_days'] ?? 0);
if ($orderDays > 0) {
$facts['medication_days'] = $orderDays;
$facts['days_src'] = self::DAYS_SRC_ORDER;
} elseif ($cfg['days_fallback_rx'] && $rxDays > 0) {
$facts['medication_days'] = $rxDays;
$facts['days_src'] = self::DAYS_SRC_RX;
} elseif ($cfg['days_default'] > 0) {
$facts['medication_days'] = (int) $cfg['days_default'];
$facts['days_src'] = self::DAYS_SRC_NONE;
}
// 3. 档位:仅当天数恰好命中 day_buckets 才归档(15 天不归 14),只用于筛选/展示
if ($facts['medication_days'] > 0 && in_array($facts['medication_days'], $cfg['day_buckets'], true)) {
$facts['rule_days'] = $facts['medication_days'];
}
// 4. 预计复诊日 = DATE(anchor_ts) + medication_days + grace_days;缺天数为 null
if ($facts['anchor_ts'] > 0 && $facts['medication_days'] > 0) {
$facts['due_date'] = self::addDays(date('Y-m-d', $facts['anchor_ts']), $facts['medication_days'] + (int) $cfg['grace_days']);
}
// 5. 最新有效挂号 A*(status ∈ 1,3,4;按 appointment_date, appointment_time, id 取最新)
// 只认「下单日 + appointment_min_gap_days(默认 1,即次日起)」之后的挂号:
// 实际流程多为「挂号 → 面诊 → 开方 → 当天下单」,同一天(及更早)的面诊是产生这笔药的那次面诊,
// 不是这笔药之后的复诊,若计入会把周期立刻关闭、患者到期后永远不被提醒。
$orderDate = $orderCreateTime > 0 ? date('Y-m-d', $orderCreateTime) : '';
$minAptDate = $orderDate !== '' ? self::addDays($orderDate, (int) $cfg['appointment_min_gap_days']) : '';
$best = null;
$bestKey = '';
foreach ((array) ($input['appointments'] ?? []) as $apt) {
if (!is_array($apt)) {
continue;
}
$status = (int) ($apt['status'] ?? 0);
if (!in_array($status, [self::APPT_BOOKED, self::APPT_VISITED, self::APPT_MISSED], true)) {
continue;
}
$aptDate = substr(trim((string) ($apt['appointment_date'] ?? '')), 0, 10);
if ($aptDate === '' || ($minAptDate !== '' && $aptDate < $minAptDate)) {
continue;
}
$key = $aptDate . ' ' . self::normalizeTime((string) ($apt['appointment_time'] ?? '')) . ' ' . sprintf('%012d', (int) ($apt['id'] ?? 0));
if ($best === null || strcmp($key, $bestKey) > 0) {
$best = $apt;
$bestKey = $key;
$best['_date'] = $aptDate;
$best['_status'] = $status;
}
}
if ($best !== null) {
$facts['appointment_state'] = (int) $best['_status'];
$facts['appointment_date'] = (string) $best['_date'];
$facts['booked_appointment_id'] = (int) ($best['id'] ?? 0);
}
// 6. 自动周期状态:已面诊 → 3;已约且日期未过 → 2;其余(无挂号/过号/已约已过期/缺天数)保持进行中 0
if ($facts['appointment_state'] === self::APPT_VISITED) {
$facts['closed_state_auto'] = self::CLOSED_VISITED;
} elseif ($facts['appointment_state'] === self::APPT_BOOKED && $facts['appointment_date'] !== null && $facts['appointment_date'] >= $today) {
$facts['closed_state_auto'] = self::CLOSED_BOOKED;
} else {
$facts['closed_state_auto'] = self::CLOSED_OPEN;
}
return $facts;
}
/**
* 由快照行 + today 推导展示状态(方案 §1.3)。
*
* 优先级:visited > booked > missed > contacted > overdue > warning > pending > no_days > none。
* missed 不受 due_date 限制(到期日前挂号并过号同样标红);人工关闭(closed_state=4) 的行保留事实状态但不深红,文案前缀「已关闭·」。
*
* @param array<string, mixed> $row 快照行(含 closed_state/appointment_state/appointment_date/due_date/promised_date/valid_order_count/source_order_id/assistant_id)
* @param array<string, mixed> $cfg revisit_reach 配置
*
* @return array{state:string, state_text:string, days_left:int|null, overdue_days:int|null, severe:int}
*/
public static function deriveState(array $row, string $today, array $cfg): array
{
$cfg = self::normalizeConfig($cfg);
$warnDays = (int) $cfg['warn_days'];
$severeDays = (int) $cfg['severe_overdue_days'];
$closed = (int) ($row['closed_state'] ?? self::CLOSED_OPEN);
$apptState = (int) ($row['appointment_state'] ?? self::APPT_NONE);
$apptDate = self::nullableDate($row['appointment_date'] ?? null);
$dueDate = self::nullableDate($row['due_date'] ?? null);
$promised = self::nullableDate($row['promised_date'] ?? null);
$validCount = (int) ($row['valid_order_count'] ?? 0);
$sourceOrderId = (int) ($row['source_order_id'] ?? 0);
$assistantId = (int) ($row['assistant_id'] ?? 0);
$none = ['state' => self::STATE_NONE, 'state_text' => '', 'days_left' => null, 'overdue_days' => null, 'severe' => 0];
if ($validCount <= 0 || $sourceOrderId <= 0 || $assistantId <= 0 || $closed === self::CLOSED_INVALID) {
return $none;
}
$daysLeft = $dueDate !== null ? self::dayDiff($today, $dueDate) : null;
$overdueDays = $daysLeft !== null ? max(0, -$daysLeft) : null;
$severe = ($overdueDays !== null && $severeDays > 0 && $overdueDays >= $severeDays) ? 1 : 0;
$state = self::STATE_NONE;
$text = '';
if ($apptState === self::APPT_VISITED) {
$state = self::STATE_VISITED;
$text = '已面诊' . self::mdSuffix($apptDate);
$severe = 0;
} elseif ($apptState === self::APPT_BOOKED && ($apptDate === null || $apptDate >= $today)) {
$state = self::STATE_BOOKED;
$text = '已约' . self::mdSuffix($apptDate);
$severe = 0;
} elseif ($apptState === self::APPT_MISSED || ($apptState === self::APPT_BOOKED && $apptDate !== null && $apptDate < $today)) {
$state = self::STATE_MISSED;
$text = '过号' . self::mdSuffix($apptDate);
} elseif ($promised !== null && $promised >= $today) {
$state = self::STATE_CONTACTED;
$text = '已联系·约 ' . substr($promised, 5);
$severe = 0;
} elseif ($dueDate === null || $daysLeft === null) {
$state = self::STATE_NO_DAYS;
$text = '缺开药天数';
$severe = 0;
} elseif ($daysLeft <= 0) {
$state = self::STATE_OVERDUE;
$text = $overdueDays === 0 ? '今日应挂号' : '超期 ' . $overdueDays . ' 天';
} elseif ($daysLeft <= $warnDays) {
$state = self::STATE_WARNING;
$text = '剩 ' . $daysLeft . ' 天';
$severe = 0;
} else {
$state = self::STATE_PENDING;
$text = '剩 ' . $daysLeft . ' 天';
$severe = 0;
}
if ($closed === self::CLOSED_MANUAL && in_array($state, [self::STATE_MISSED, self::STATE_OVERDUE, self::STATE_WARNING, self::STATE_PENDING, self::STATE_NO_DAYS], true)) {
$text = '已关闭·' . $text;
$severe = 0;
}
return [
'state' => $state,
'state_text' => $text,
'days_left' => $daysLeft,
'overdue_days' => $overdueDays,
'severe' => $severe,
];
}
/**
* 延迟次数 → 升级等级:0→0,1~2→1,3~4→2,≥5→3(方案 §1.4)。
*/
public static function escalationLevel(int $snoozeCount): int
{
if ($snoozeCount <= 0) {
return 0;
}
if ($snoozeCount <= 2) {
return 1;
}
if ($snoozeCount <= 4) {
return 2;
}
return 3;
}
/**
* 两个 'Y-m-d' 日期的天数差:to − from(负数表示 to 早于 from);任一非法返回 0。
*/
public static function dayDiff(string $from, string $to): int
{
$a = \DateTimeImmutable::createFromFormat('!Y-m-d', substr(trim($from), 0, 10));
$b = \DateTimeImmutable::createFromFormat('!Y-m-d', substr(trim($to), 0, 10));
if ($a === false || $b === false) {
return 0;
}
$diff = $a->diff($b);
return (int) $diff->days * ($diff->invert === 1 ? -1 : 1);
}
/**
* 'Y-m-d' 加 N 天(N 可为负);非法日期原样返回。
*/
public static function addDays(string $date, int $days): string
{
$d = \DateTimeImmutable::createFromFormat('!Y-m-d', substr(trim($date), 0, 10));
if ($d === false) {
return $date;
}
return $d->modify(sprintf('%+d days', $days))->format('Y-m-d');
}
/**
* 挂号时间归一:空串/NULL → '00:00:00','HH:MM' → 'HH:MM:00'(用于字符串排序)。
*/
private static function normalizeTime(string $time): string
{
$t = trim($time);
if ($t === '' || $t === '0' || $t === '00:00') {
return '00:00:00';
}
if (preg_match('/^\d{1,2}:\d{2}$/', $t) === 1) {
$t .= ':00';
}
if (preg_match('/^(\d{1,2}):(\d{2}):(\d{2})$/', $t, $m) === 1) {
return sprintf('%02d:%02d:%02d', (int) $m[1], (int) $m[2], (int) $m[3]);
}
return $t;
}
/**
* 日期列归一:空串 / '0000-00-00' / null → null,其余截取前 10 位。
*/
private static function nullableDate(mixed $value): ?string
{
if ($value === null) {
return null;
}
$s = substr(trim((string) $value), 0, 10);
if ($s === '' || $s === '0000-00-00') {
return null;
}
return $s;
}
/** 'Y-m-d' → ' MM-DD'(null 返回空串),用于 chip 文案 */
private static function mdSuffix(?string $date): string
{
return $date !== null ? ' ' . substr($date, 5) : '';
}
}
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace app\common\service\tcm;
use think\facade\Db;
use think\facade\Log;
/**
* 复诊到期快照业务钩子(订单 / 挂号 / 指派 / 诊单变更 / 过号 cron)。
*
* 所有方法整体 try/catch 并 Log::warning,绝不向调用方抛异常、不改变主流程返回值;
* 任一钩子漏接由 30 分钟增量 cron(tcm:revisit-due-scan --mode=incremental)与列表自愈兜底。
*
* 接入点(由其它代理在业务 Logic 的事务提交之后调用):
* - PrescriptionOrderLogic create/editTime/withdraw/complete(9,10)/refund、GancaoCallbackController case 91/92 → onOrderChanged
* - AppointmentLogic create/cancel/complete/adminEdit → onAppointmentChanged
* - DiagnosisLogic::assign、AutoAssignPendingDiagnosis、AutoAssignLogLogic 回退 → onAssistantChanged
* - DiagnosisLogic::setRevisitSlotStartOffset 等诊单字段变更 → onDiagnosisChanged
* - UpdateAppointmentStatus 两条 Db::execute 之后 → onAppointmentStatusCronDone
*/
final class RevisitDueHook
{
/**
* 业务订单变化(新建 / 改下单时间 / 撤回 / 完成为 9,10 / 退款 / 甘草回调置 4)。
* diagnosisId 为 0 时按 orderId 反查诊单。
*/
public static function onOrderChanged(int $diagnosisId, int $orderId = 0, string $reason = ''): void
{
try {
if ($diagnosisId <= 0 && $orderId > 0) {
$diagnosisId = (int) Db::name('tcm_prescription_order')->where('id', $orderId)->value('diagnosis_id');
}
if ($diagnosisId <= 0) {
return;
}
RevisitDueService::refreshDiagnoses([$diagnosisId], [
'reason' => self::reasonTag('order', $reason),
'operator_id' => self::currentAdminId(),
]);
} catch (\Throwable $e) {
Log::warning(sprintf('复诊到期钩子 onOrderChanged 失败 diagnosis_id=%d order_id=%d: %s', $diagnosisId, $orderId, $e->getMessage()));
}
}
/**
* 挂号变化(创建 / 取消 / 完成 / 后台编辑)。diagnosisId 为 0 时按 appointmentId 反查(patient_id 存诊单 id)。
*/
public static function onAppointmentChanged(int $diagnosisId, int $appointmentId = 0, string $reason = ''): void
{
try {
if ($diagnosisId <= 0 && $appointmentId > 0) {
$diagnosisId = (int) Db::name('doctor_appointment')->where('id', $appointmentId)->value('patient_id');
}
if ($diagnosisId <= 0) {
return;
}
RevisitDueService::refreshDiagnoses([$diagnosisId], [
'reason' => self::reasonTag('appointment', $reason),
'operator_id' => self::currentAdminId(),
]);
} catch (\Throwable $e) {
Log::warning(sprintf('复诊到期钩子 onAppointmentChanged 失败 diagnosis_id=%d appointment_id=%d: %s', $diagnosisId, $appointmentId, $e->getMessage()));
}
}
/**
* 医助变更(指派 / 继承 / 释放 to=0 / 自动分配 / 回退)。toAssistantId 仅用于流水 payload,实际以库内 tcm_diagnosis.assistant_id 为准。
*/
public static function onAssistantChanged(int $diagnosisId, int $toAssistantId, string $reason = ''): void
{
try {
if ($diagnosisId <= 0) {
return;
}
$tag = self::reasonTag('assign', $reason);
if ($toAssistantId >= 0) {
$tag .= ':to=' . $toAssistantId;
}
RevisitDueService::refreshDiagnoses([$diagnosisId], [
'reason' => $tag,
'operator_id' => self::currentAdminId(),
]);
} catch (\Throwable $e) {
Log::warning(sprintf('复诊到期钩子 onAssistantChanged 失败 diagnosis_id=%d to=%d: %s', $diagnosisId, $toAssistantId, $e->getMessage()));
}
}
/**
* 诊单其它字段变更(如 revisit_slot_start_offset、status 禁用/启用、软删)。
*/
public static function onDiagnosisChanged(int $diagnosisId, string $reason = ''): void
{
try {
if ($diagnosisId <= 0) {
return;
}
RevisitDueService::refreshDiagnoses([$diagnosisId], [
'reason' => self::reasonTag('diagnosis', $reason),
'operator_id' => self::currentAdminId(),
]);
} catch (\Throwable $e) {
Log::warning(sprintf('复诊到期钩子 onDiagnosisChanged 失败 diagnosis_id=%d: %s', $diagnosisId, $e->getMessage()));
}
}
/**
* 过号 cron(update_appointment_status)执行之后:重评 closed_state=2 / appointment_state=1 的行,使当天过号即进标红/弹窗。
*/
public static function onAppointmentStatusCronDone(): void
{
try {
RevisitDueService::reevaluateBookedRows();
} catch (\Throwable $e) {
Log::warning('复诊到期钩子 onAppointmentStatusCronDone 失败: ' . $e->getMessage());
}
}
/** 流水 payload 的 reason 标签:<来源>[:<说明>],限 64 字符 */
private static function reasonTag(string $source, string $reason): string
{
$reason = trim($reason);
return mb_substr($reason !== '' ? $source . ':' . $reason : $source, 0, 64);
}
/** 当前登录管理员 id(CLI / 回调无登录态时为 0),任何异常均视为 0 */
private static function currentAdminId(): int
{
try {
$req = request();
$admin = $req->adminInfo ?? null;
if (is_array($admin)) {
return (int) ($admin['admin_id'] ?? 0);
}
} catch (\Throwable $e) {
// CLI 场景无请求上下文,忽略
}
return 0;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,278 @@
<?php
declare(strict_types=1);
namespace app\common\service\tcm;
use app\adminapi\logic\stats\YejiStatsLogic;
use think\facade\Db;
/**
* 诊次(第几诊)口径单点实现,复诊接诊率 / 诊单列表「复诊节点」/ 到期快照共用。
*
* 口径(与 RevisitRateLogic 完全一致):
* - 有效业务订单 E(D):tcm_prescription_order 中 delete_time IS NULL 且 fulfillment_status IS NULL OR NOT IN (4,9,10)
* (统一经 YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery),按 (create_time, id) 升序;
* - 偏移 off(D) = tcm_diagnosis.revisit_slot_start_offset 钳到 0~20;
* - 第 n 笔有效订单的诊次 = n + off(D);患者当前已完成诊次 current_slot = |E(D)| + off(D);待复诊诊次 next_slot = current_slot + 1;
* - 某时刻 t 患者处于第几诊 slot_at(D, t) = |{o : create_time(o) < t}| + off(D)(严格小于,与持有人规则 t <= ct 对偶)。
*
* MySQL 5.7 无窗口函数:batchCurrentSlot 先 GROUP BY 聚合,再按 (diagnosis_id, create_time) 回表,PHP 侧取 id 最大的一行。
*/
final class RevisitVisitSlotService
{
/** 偏移量上限(与 RevisitRateLogic 历史实现一致) */
public const MAX_OFFSET = 20;
/** 单次 SQL 的诊单分块大小 */
private const CHUNK_SIZE = 2000;
/**
* 诊单订单序列源查询(与业绩口径一致),统一排序保证序号稳定。
* 从 RevisitRateLogic::fetchOrderSeqRows 搬出,行为不变(调用方自行分块)。
*
* @param list<int> $diagnosisIds
* @param list<string> $fields 需带 `o.` 别名前缀,如 ['o.id', 'o.diagnosis_id', 'o.create_time']
*
* @return list<array<string, mixed>>
*/
public static function fetchOrderSeqRows(array $diagnosisIds, array $fields): array
{
if ($diagnosisIds === []) {
return [];
}
$q = Db::name('tcm_prescription_order')
->alias('o')
->whereIn('o.diagnosis_id', $diagnosisIds)
->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();
}
/**
* 诊单 => revisit_slot_start_offset(未钳制的原始值;已软删诊单不返回)。
* 从 RevisitRateLogic::fetchRevisitSlotStartOffsetMap 搬出,行为不变。
*
* @param list<int> $diagnosisIds
*
* @return array<int, int>
*/
public static function fetchRevisitSlotStartOffsetMap(array $diagnosisIds): array
{
if ($diagnosisIds === []) {
return [];
}
$out = [];
foreach (array_chunk($diagnosisIds, self::CHUNK_SIZE) 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;
}
/**
* 诊单复诊统计起始偏移(默认 0:第 1 笔实单计为一诊;统计诊次 = 实单序号 + 偏移),钳到 0~20。
* 从 RevisitRateLogic::resolveRevisitSlotStartOffset 搬出,行为不变。
*
* @param array<int, int> $map diagnosis_id => offset
*/
public static function resolveRevisitSlotStartOffset(int $diagnosisId, array $map): int
{
$offset = (int) ($map[$diagnosisId] ?? 0);
if ($offset < 0) {
$offset = 0;
}
if ($offset > self::MAX_OFFSET) {
$offset = self::MAX_OFFSET;
}
return $offset;
}
/**
* 纯函数:时刻 atTs 之前(严格小于)的有效订单数 + 偏移 = 该时刻患者已完成的诊次。
* 同一秒的订单视为「指派之后」,不计入(与持有人规则 t <= ct 对偶)。
*
* @param list<int|string> $orderCreateTimes 有效订单的 create_time 列表(顺序不限)
*/
public static function slotBefore(array $orderCreateTimes, int $offset, int $atTs): int
{
$n = 0;
foreach ($orderCreateTimes as $ct) {
if ((int) $ct < $atTs) {
$n++;
}
}
if ($offset < 0) {
$offset = 0;
}
if ($offset > self::MAX_OFFSET) {
$offset = self::MAX_OFFSET;
}
return $n + $offset;
}
/**
* 某时刻患者处于第几诊(含偏移)。诊单不存在或无订单时返回偏移值。
*/
public static function slotAtTime(int $diagnosisId, int $ts): int
{
if ($diagnosisId <= 0) {
return 0;
}
$offsetMap = self::fetchRevisitSlotStartOffsetMap([$diagnosisId]);
$offset = self::resolveRevisitSlotStartOffset($diagnosisId, $offsetMap);
$rows = self::fetchOrderSeqRows([$diagnosisId], ['o.create_time']);
$cts = [];
foreach ($rows as $r) {
$cts[] = (int) ($r['create_time'] ?? 0);
}
return self::slotBefore($cts, $offset, $ts);
}
/**
* 批量计算每个诊单的当前诊次与最新有效订单(每个入参诊单都有返回,无订单时 latest_order 为 null)。
*
* @param list<int> $diagnosisIds
*
* @return array<int, array{
* diagnosis_id:int, offset:int, valid_order_count:int, current_slot:int, next_slot:int,
* first_order_time:int, total_medication_days:int,
* latest_order:null|array{id:int, order_no:string, create_time:int, medication_days:int, prescription_id:int, tracking_number:string, fulfillment_status:int|null, creator_id:int}
* }>
*/
public static function batchCurrentSlot(array $diagnosisIds): array
{
$ids = [];
foreach ($diagnosisIds as $id) {
$id = (int) $id;
if ($id > 0) {
$ids[$id] = true;
}
}
$ids = array_keys($ids);
if ($ids === []) {
return [];
}
$offsetMap = self::fetchRevisitSlotStartOffsetMap($ids);
/** @var array<int, array{cnt:int, max_ct:int, min_ct:int, sum_days:int}> $agg */
$agg = [];
/** @var array<int, array<string, mixed>> $latest diagnosis_id => 最新有效订单行 */
$latest = [];
foreach (array_chunk($ids, self::CHUNK_SIZE) as $chunk) {
// 1. 聚合:有效订单数 / 最新与最早下单时间 / 累计开药天数
// 累计开药天数按「订单 medication_days ?: 处方 usage_days」逐单回落(处方剔除系统代开/作废/软删,一单对一方,不会放大行数)
$q = Db::name('tcm_prescription_order')
->alias('o')
->leftJoin('tcm_prescription rx', 'rx.id = o.prescription_id AND rx.is_system_auto = 0 AND rx.void_status = 0 AND rx.delete_time IS NULL')
->whereIn('o.diagnosis_id', $chunk)
->whereNull('o.delete_time');
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, 'o');
$aggRows = $q
->fieldRaw('o.diagnosis_id, COUNT(*) AS cnt, MAX(o.create_time) AS max_ct, MIN(o.create_time) AS min_ct, SUM(COALESCE(NULLIF(o.medication_days, 0), NULLIF(rx.usage_days, 0), 0)) AS sum_days')
->group('o.diagnosis_id')
->select()
->toArray();
$maxCts = [];
$chunkDiagWithOrders = [];
foreach ($aggRows as $r) {
$did = (int) ($r['diagnosis_id'] ?? 0);
if ($did <= 0) {
continue;
}
$maxCt = (int) ($r['max_ct'] ?? 0);
$agg[$did] = [
'cnt' => (int) ($r['cnt'] ?? 0),
'max_ct' => $maxCt,
'min_ct' => (int) ($r['min_ct'] ?? 0),
'sum_days' => (int) ($r['sum_days'] ?? 0),
];
$maxCts[$maxCt] = true;
$chunkDiagWithOrders[] = $did;
}
if ($chunkDiagWithOrders === []) {
continue;
}
// 2. 回表:按 (diagnosis_id, create_time) 取候选行,PHP 侧过滤精确匹配并取 id 最大者
$q2 = Db::name('tcm_prescription_order')
->alias('o')
->whereIn('o.diagnosis_id', $chunkDiagWithOrders)
->whereIn('o.create_time', array_keys($maxCts))
->whereNull('o.delete_time');
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q2, 'o');
$candRows = $q2
->field([
'o.id', 'o.order_no', 'o.diagnosis_id', 'o.create_time', 'o.medication_days',
'o.prescription_id', 'o.tracking_number', 'o.fulfillment_status', 'o.creator_id',
])
->select()
->toArray();
foreach ($candRows as $r) {
$did = (int) ($r['diagnosis_id'] ?? 0);
if ($did <= 0 || !isset($agg[$did])) {
continue;
}
if ((int) ($r['create_time'] ?? 0) !== $agg[$did]['max_ct']) {
continue;
}
$oid = (int) ($r['id'] ?? 0);
if (!isset($latest[$did]) || $oid > (int) ($latest[$did]['id'] ?? 0)) {
$latest[$did] = $r;
}
}
}
$out = [];
foreach ($ids as $did) {
$offset = self::resolveRevisitSlotStartOffset($did, $offsetMap);
$a = $agg[$did] ?? null;
$cnt = $a !== null ? $a['cnt'] : 0;
$currentSlot = $cnt + $offset;
$lo = $latest[$did] ?? null;
$latestOrder = null;
if ($lo !== null) {
$fs = $lo['fulfillment_status'] ?? null;
$latestOrder = [
'id' => (int) ($lo['id'] ?? 0),
'order_no' => (string) ($lo['order_no'] ?? ''),
'create_time' => (int) ($lo['create_time'] ?? 0),
'medication_days' => (int) ($lo['medication_days'] ?? 0),
'prescription_id' => (int) ($lo['prescription_id'] ?? 0),
'tracking_number' => trim((string) ($lo['tracking_number'] ?? '')),
'fulfillment_status' => $fs === null ? null : (int) $fs,
'creator_id' => (int) ($lo['creator_id'] ?? 0),
];
}
$out[$did] = [
'diagnosis_id' => $did,
'offset' => $offset,
'valid_order_count' => $cnt,
'current_slot' => $currentSlot,
'next_slot' => $currentSlot + 1,
'first_order_time' => $a !== null ? $a['min_ct'] : 0,
'total_medication_days' => $a !== null ? $a['sum_days'] : 0,
'latest_order' => $latestOrder,
];
}
return $out;
}
}
+438 -7
View File
@@ -1,6 +1,6 @@
<?php
// 由 php app/mcp/cli/catalog.php --write 生成,请勿手工修改;人工审核结论写在 resources.php。
// 生成时间:2026-09-24 08:54:20
// 生成时间:2026-10-14 12:06:18
return array (
'article.article/add' =>
array (
@@ -8494,6 +8494,7 @@ return array (
),
'params' =>
array (
0 => 'kind',
),
'no_login' => false,
),
@@ -9034,8 +9035,7 @@ return array (
'http' => 'POST',
'writes' =>
array (
0 => '->save(',
1 => '::create(',
0 => '::transaction(',
),
'external' =>
array (
@@ -10063,10 +10063,13 @@ return array (
19 => 'latest_appointment_channel_source',
20 => 'latest_assign_start_date',
21 => 'latest_assign_end_date',
22 => 'sort_unserved_days',
23 => 'pending_assign_keyword',
24 => 'keyword',
25 => 'pending_assign_order_month',
22 => 'revisit_state',
23 => 'revisit_days_bucket',
24 => 'sort_revisit_due',
25 => 'sort_unserved_days',
26 => 'pending_assign_keyword',
27 => 'keyword',
28 => 'pending_assign_order_month',
),
'no_login' => false,
),
@@ -10806,6 +10809,238 @@ return array (
),
'no_login' => false,
),
'tcm.followupAudio/apply' =>
array (
'controller' => 'app\\adminapi\\controller\\tcm\\FollowupAudioController',
'action' => 'apply',
'kind' => 'write',
'lists' => NULL,
'http' => 'GET',
'writes' =>
array (
),
'external' =>
array (
),
'logic' =>
array (
),
'params' =>
array (
),
'no_login' => false,
),
'tcm.followupAudio/audio' =>
array (
'controller' => 'app\\adminapi\\controller\\tcm\\FollowupAudioController',
'action' => 'audio',
'kind' => 'other',
'lists' => NULL,
'http' => 'GET',
'writes' =>
array (
),
'external' =>
array (
),
'logic' =>
array (
),
'params' =>
array (
),
'no_login' => false,
),
'tcm.followupAudio/capabilities' =>
array (
'controller' => 'app\\adminapi\\controller\\tcm\\FollowupAudioController',
'action' => 'capabilities',
'kind' => 'other',
'lists' => NULL,
'http' => 'GET',
'writes' =>
array (
),
'external' =>
array (
),
'logic' =>
array (
),
'params' =>
array (
),
'no_login' => false,
),
'tcm.followupAudio/create' =>
array (
'controller' => 'app\\adminapi\\controller\\tcm\\FollowupAudioController',
'action' => 'create',
'kind' => 'write',
'lists' => NULL,
'http' => 'GET',
'writes' =>
array (
0 => '::create(',
),
'external' =>
array (
),
'logic' =>
array (
),
'params' =>
array (
),
'no_login' => false,
),
'tcm.followupAudio/detail' =>
array (
'controller' => 'app\\adminapi\\controller\\tcm\\FollowupAudioController',
'action' => 'detail',
'kind' => 'detail',
'lists' => NULL,
'http' => 'GET',
'writes' =>
array (
),
'external' =>
array (
),
'logic' =>
array (
),
'params' =>
array (
),
'no_login' => false,
),
'tcm.followupAudio/lists' =>
array (
'controller' => 'app\\adminapi\\controller\\tcm\\FollowupAudioController',
'action' => 'lists',
'kind' => 'report',
'lists' => NULL,
'http' => 'GET',
'writes' =>
array (
),
'external' =>
array (
),
'logic' =>
array (
),
'params' =>
array (
),
'no_login' => false,
),
'tcm.followupAudio/retry' =>
array (
'controller' => 'app\\adminapi\\controller\\tcm\\FollowupAudioController',
'action' => 'retry',
'kind' => 'write',
'lists' => NULL,
'http' => 'GET',
'writes' =>
array (
),
'external' =>
array (
),
'logic' =>
array (
),
'params' =>
array (
),
'no_login' => false,
),
'tcm.followupAudio/saveDraft' =>
array (
'controller' => 'app\\adminapi\\controller\\tcm\\FollowupAudioController',
'action' => 'saveDraft',
'kind' => 'write',
'lists' => NULL,
'http' => 'GET',
'writes' =>
array (
),
'external' =>
array (
),
'logic' =>
array (
),
'params' =>
array (
),
'no_login' => false,
),
'tcm.followupAudio/uploadChunk' =>
array (
'controller' => 'app\\adminapi\\controller\\tcm\\FollowupAudioController',
'action' => 'uploadChunk',
'kind' => 'write',
'lists' => NULL,
'http' => 'GET',
'writes' =>
array (
),
'external' =>
array (
),
'logic' =>
array (
),
'params' =>
array (
),
'no_login' => false,
),
'tcm.followupAudio/uploadComplete' =>
array (
'controller' => 'app\\adminapi\\controller\\tcm\\FollowupAudioController',
'action' => 'uploadComplete',
'kind' => 'write',
'lists' => NULL,
'http' => 'GET',
'writes' =>
array (
),
'external' =>
array (
),
'logic' =>
array (
),
'params' =>
array (
),
'no_login' => false,
),
'tcm.followupAudio/uploadSession' =>
array (
'controller' => 'app\\adminapi\\controller\\tcm\\FollowupAudioController',
'action' => 'uploadSession',
'kind' => 'write',
'lists' => NULL,
'http' => 'GET',
'writes' =>
array (
),
'external' =>
array (
),
'logic' =>
array (
),
'params' =>
array (
),
'no_login' => false,
),
'tcm.prescription/add' =>
array (
'controller' => 'app\\adminapi\\controller\\tcm\\PrescriptionController',
@@ -12396,6 +12631,202 @@ return array (
),
'no_login' => false,
),
'tcm.revisitDue/close' =>
array (
'controller' => 'app\\adminapi\\controller\\tcm\\RevisitDueController',
'action' => 'close',
'kind' => 'write',
'lists' => NULL,
'http' => 'POST',
'writes' =>
array (
0 => '->update([',
),
'external' =>
array (
),
'logic' =>
array (
0 => 'RevisitDueLogic::close',
1 => 'RevisitDueLogic::getError',
),
'params' =>
array (
0 => 'id',
1 => 'reason',
2 => 'remark',
),
'no_login' => false,
),
'tcm.revisitDue/contact' =>
array (
'controller' => 'app\\adminapi\\controller\\tcm\\RevisitDueController',
'action' => 'contact',
'kind' => 'other',
'lists' => NULL,
'http' => 'POST',
'writes' =>
array (
),
'external' =>
array (
),
'logic' =>
array (
0 => 'RevisitDueLogic::contact',
1 => 'RevisitDueLogic::getError',
),
'params' =>
array (
0 => 'id',
1 => 'promised_date',
2 => 'remark',
),
'no_login' => false,
),
'tcm.revisitDue/lists' =>
array (
'controller' => 'app\\adminapi\\controller\\tcm\\RevisitDueController',
'action' => 'lists',
'kind' => 'list',
'lists' => 'app\\adminapi\\lists\\tcm\\RevisitDueLists',
'http' => 'GET',
'writes' =>
array (
),
'external' =>
array (
),
'logic' =>
array (
),
'params' =>
array (
0 => 'assistant_id',
1 => 'dept_id',
2 => 'keyword',
3 => 'min_snooze',
4 => 'state',
5 => 'days_bucket',
),
'no_login' => false,
),
'tcm.revisitDue/logs' =>
array (
'controller' => 'app\\adminapi\\controller\\tcm\\RevisitDueController',
'action' => 'logs',
'kind' => 'report',
'lists' => NULL,
'http' => 'GET',
'writes' =>
array (
),
'external' =>
array (
),
'logic' =>
array (
0 => 'RevisitDueLogic::logs',
1 => 'RevisitDueLogic::getError',
),
'params' =>
array (
0 => 'due_id',
),
'no_login' => false,
),
'tcm.revisitDue/pending' =>
array (
'controller' => 'app\\adminapi\\controller\\tcm\\RevisitDueController',
'action' => 'pending',
'kind' => 'other',
'lists' => NULL,
'http' => 'GET',
'writes' =>
array (
),
'external' =>
array (
),
'logic' =>
array (
0 => 'RevisitDueLogic::pending',
),
'params' =>
array (
),
'no_login' => false,
),
'tcm.revisitDue/snooze' =>
array (
'controller' => 'app\\adminapi\\controller\\tcm\\RevisitDueController',
'action' => 'snooze',
'kind' => 'other',
'lists' => NULL,
'http' => 'POST',
'writes' =>
array (
0 => '->update([',
),
'external' =>
array (
),
'logic' =>
array (
0 => 'RevisitDueLogic::snooze',
1 => 'RevisitDueLogic::getError',
),
'params' =>
array (
0 => 'id',
),
'no_login' => false,
),
'tcm.revisitDue/snoozeAll' =>
array (
'controller' => 'app\\adminapi\\controller\\tcm\\RevisitDueController',
'action' => 'snoozeAll',
'kind' => 'other',
'lists' => NULL,
'http' => 'POST',
'writes' =>
array (
),
'external' =>
array (
),
'logic' =>
array (
0 => 'RevisitDueLogic::snoozeAll',
),
'params' =>
array (
0 => 'ids',
),
'no_login' => false,
),
'tcm.revisitDue/summary' =>
array (
'controller' => 'app\\adminapi\\controller\\tcm\\RevisitDueController',
'action' => 'summary',
'kind' => 'report',
'lists' => NULL,
'http' => 'GET',
'writes' =>
array (
),
'external' =>
array (
),
'logic' =>
array (
0 => 'RevisitDueLogic::summary',
),
'params' =>
array (
),
'no_login' => false,
),
'tools.generator/dataTable' =>
array (
'controller' => 'app\\adminapi\\controller\\tools\\GeneratorController',
+23 -6
View File
@@ -18,8 +18,12 @@
* 子接口自身已登记时仍按自身判断(与后台一致);未登记时依次改用 Tab 权限(tabLeaderboard/tabDoctor/tabZyyt…)
* 或页面权限 fans/yeji ——后台对未登记接口不校验、只靠这些权限控制页面可见,这样不会比后台页面更宽。
* - timeout:业绩看板、医生统计、提成结算、综合转化是多张大表的聚合(后台控制器自己放宽到 120 秒),单条 SQL 超时放宽到 30 秒。
* - controller:晚于 generated.php 生成的 action(如 stats.assistantPerformance/revisitRate)显式写 controller 以通过 AiMcpUnitTest 的
* 资源存在性校验;重跑 catalog.php --write 后可去掉该键。
*/
use app\adminapi\controller\stats\AssistantPerformanceController;
$dateRange = [
'start_date' => '开始日期 YYYY-MM-DD(不传默认今天)',
'end_date' => '结束日期 YYYY-MM-DD(不传同开始日期)',
@@ -43,9 +47,18 @@ return [
],
],
'stats.assistantPerformance/revisitRate' => [
'status' => 'open', 'kind' => 'report', 'name' => '医助个人业绩 · 我的复诊接诊率',
'controller' => AssistantPerformanceController::class, 'http' => 'GET',
'note' => '仅返回当前账号本人的复诊接诊率行(RevisitRateLogic::assistantOverview:从 stats.revisitRate/overview 全量结果中按本人 assistant_id 挑出一行,不含其他医助、不含部门/合计行),口径与复诊接诊率页一致(inherit_mode 固定取配置 project.revisit_rate.default_inherit_mode,默认 slot=继承患者按继承时诊次从第 k+1 诊起计入分母)。row 字段:assigned_count 新指派数、inherit_count 继承数、assigned_total_count 被指派合计、visit{N}_count/_den/_rate 各诊次单数/分母/接诊率(den=0 为 null)、visit_all_count/_den/_rate 复诊合计(可超 100%);slots 为诊次列范围。本人非二中心医助(in_scope=false)或当月无任何被指派/成交(in_scope=true)时 row=null。结果缓存 600 秒。',
'params_allow' => [
'month' => '统计月份 YYYY-MM(默认本月)',
],
],
'stats.autoAssignLog/lists' => [
'status' => 'open', 'kind' => 'list', 'name' => '待分配诊单自动指派日志',
'note' => '后台本身不按数据范围过滤:拥有该权限的账号可看到全部自动指派日志(含患者姓名、手机号快照)。数据由定时任务 tcm:auto-assign-pending 写入,每条待指派诊单一行;action 1=已分配、0=未分配,tier 为医助上月二诊复诊接诊率档位(gt70/60_70/50_60),reason 为分配或不分配原因。',
'note' => '后台本身不按数据范围过滤:拥有该权限的账号可看到全部自动指派日志(含患者姓名、手机号快照)。数据由定时任务 tcm:auto-assign-pending 写入,每条待指派诊单一行;action 1=已分配、0=未分配,tier 为医助上月二诊复诊接诊率档位(gt70/60_70/50_60),reason 为分配或不分配原因。口径:visit2_rate=新口径(inherit_mode=slot,分母=新指派+起算诊次≤2 的继承)、visit2_rate_new_assign=旧口径(分母仅新指派)、rate_version=本批次分档所用口径(v1=旧 v2=新,由配置 project.revisit_rate.auto_assign_tier_field 决定;2026-10 前的历史行无 visit2_rate_new_assign、rate_version=v1 且 visit2_rate 即旧口径)。',
'params_allow' => [
'run_date' => '执行日期 YYYY-MM-DD(精确匹配)',
'start_date' => '执行日期起 YYYY-MM-DD',
@@ -172,39 +185,43 @@ return [
'stats.revisitRate/assignLines' => [
'status' => 'open', 'kind' => 'report', 'name' => '复诊接诊率 · 被指派明细',
'note' => '后台本身不按数据范围过滤:拥有该权限的账号可看到二中心全部医助当月被指派的诊单(含患者姓名、手机号)。口径:按指派操作时间落月、非继承指派、医助×诊单去重,剔除名下有拒收/退款订单的诊单;仅统计二中心及其下级部门。不传 assistant_id/dept_id 时返回全部。',
'note' => '后台本身不按数据范围过滤:拥有该权限的账号可看到二中心全部医助当月被指派的诊单(含患者姓名、手机号)。口径:按指派操作时间落月、医助×诊单去重,剔除名下有拒收/退款订单的诊单;仅统计二中心及其下级部门。行字段 assign_type=new(新指派,is_inherit=0)/inherit(继承,is_inherit=1,含历史回退自动分配行),inherit_visit_slot=继承时患者已完成诊次 k,entry_slot=起算诊次(max(2,k+1);新指派恒为 2)。部门/合计范围下诊单对任一医助为新指派即记 new,否则记 inherit 且 entry 取最小,故 kind=all 的「new 行数 + entry<=N 的 inherit 行数」= 复诊接诊率 visit{N}_den。inherit_mode=exclude 时只有 new 行(旧口径)。不传 assistant_id/dept_id 时返回全部。',
'params_allow' => [
'month' => '统计月份 YYYY-MM(默认本月)',
'dept_ids' => '部门筛选(限二中心子树),多个逗号分隔',
'assistant_id' => '只看某医助',
'dept_id' => '只看某部门分组(0=未分配部门)',
'inherit_mode' => '口径:slot=继承按继承时诊次计入分母(默认,配置 project.revisit_rate.default_inherit_mode)/ exclude=继承不计分母(旧口径,与 2026-10 前一致)',
'kind' => '行类型:new 仅新指派 / inherit 仅继承 / all 两者(默认 all)',
],
],
'stats.revisitRate/deptOptions' => [
'status' => 'open', 'kind' => 'report', 'name' => '复诊接诊率 · 部门选项',
'note' => '二中心及其下级部门(id、pid、name),供 dept_ids 参数取值。',
'note' => '二中心及其下级部门(id、pid、name),供 revisitRate 各接口 dept_ids 参数取值;不受 inherit_mode 影响。',
'params_allow' => [],
],
'stats.revisitRate/overview' => [
'status' => 'open', 'kind' => 'report', 'name' => '复诊接诊率',
'note' => '后台本身不按数据范围过滤:拥有该权限的账号可看到二中心全部医助的数据。口径:当月被指派数=当月非继承指派的医助×诊单(剔除名下有拒收/退款订单的诊单);N 诊单数=当月下单且为该诊单全局第 N 笔计入业绩的业务订单(剔除取消/拒收/退款,诊次跨月累计),归属下单时的持有医助;N 诊接诊率=N 诊单数÷当月被指派数(往月指派当月成交会使比率超过 100%)。按部门→医助分组并有合计行。',
'note' => '后台本身不按数据范围过滤:拥有该权限的账号可看到二中心全部医助的数据。口径 v2(每列独立分母):assigned_count=新指派数(当月 is_inherit=0 的医助×诊单,剔除名下有拒收/退款订单的诊单,语义与旧版「当月被指派数」相同);inherit_count=继承数(当月 is_inherit=1、from≠to,同诊单多次继承取最早,既新指派又继承只算新指派;含历史回退自动分配行);assigned_total_count=两者并集;visit{N}_count=当月下单且为该诊单全局第 N 笔计入业绩的业务订单(剔除取消/拒收/退款,诊次跨月累计),归属下单时的持有医助;visit{N}_den=新指派数+起算诊次≤N 的继承数(起算诊次=继承时已完成诊次+1,最小 2);visit{N}_rate=count÷den(den=0 为 null;往月指派当月成交会使比率超过 100%);visit_all_count=Σ visit{N}_count、visit_all_den=assigned_total_count、visit_all_rate=复诊合计接诊率(可超 100%);visit2_rate_new_assign=旧口径二诊率(分母仅新指派,自动分配过渡期分档用)。slots 为列范围(2~max(4,分子最大诊次,最大起算诊次),上限 50)。inherit_mode=exclude 复现旧口径:inherit_count=0、visit{N}_den=assigned_count,assigned_count/visit{N}_count/visit{N}_rate 与 2026-10 前逐值一致。继承分母依赖 is_inherit 列,2026-06 之前的继承无法回溯。按部门→医助分组并有合计行(部门/合计按诊单去重,继承起算诊次取部门内最小)。',
'params_allow' => [
'month' => '统计月份 YYYY-MM(默认本月)',
'dept_ids' => '部门筛选(限二中心子树,含下级),多个逗号分隔',
'inherit_mode' => '口径:slot=继承按继承时诊次计入每列独立分母(默认,配置 project.revisit_rate.default_inherit_mode)/ exclude=继承不计分母(旧口径)',
],
],
'stats.revisitRate/visitOrderLines' => [
'status' => 'open', 'kind' => 'report', 'name' => '复诊接诊率 · N 诊订单明细',
'note' => '后台本身不按数据范围过滤:拥有该权限的账号可看到二中心全部医助的 N 诊订单(含订单号、金额、患者姓名、手机号)。与复诊接诊率“N 诊单数”同口径,可对账。',
'note' => '后台本身不按数据范围过滤:拥有该权限的账号可看到二中心全部医助的 N 诊订单(含订单号、金额、患者姓名、手机号)。与复诊接诊率“N 诊单数”(visit{N}_count) 同口径,可对账;slot=0 返回全部 N>=2 的订单,与「复诊合计单数」(visit_all_count) 对账,行字段 visit_slot 标明诊次。分子不受 inherit_mode 影响。',
'params_allow' => [
'month' => '统计月份 YYYY-MM(默认本月)',
'slot' => '诊次 N(必填,2=二诊,最大 50)',
'slot' => '诊次 N(必填,2=二诊,最大 50;0=全部 N>=2 即复诊合计)',
'dept_ids' => '部门筛选(限二中心子树),多个逗号分隔',
'assistant_id' => '只看某医助',
'dept_id' => '只看某部门分组(0=未分配部门)',
'inherit_mode' => '口径 slot|exclude(仅影响医助范围判定,不影响订单归属)',
],
],
+33
View File
@@ -1,6 +1,7 @@
<?php
/**
* AI 数据目录人工审核:中医诊单与处方(tcm.*)的全部非写、非 POST 接口(51 个)。
* 例外:复诊到期弹窗 tcm.revisitDue/* 的写接口也显式登记为 excluded(控制器晚于 generated.php 生成,需显式 controller 过单测)。
*
* 约定:
* - 按诊单/患者/记录ID取数的接口一律按「详情」调用(kind=detail):zyt_get 会把 ID 固定为单个值,
@@ -10,6 +11,7 @@
* - 'builtin' 表示接口自身已有逐条校验,函数写在各条目上方注释里。
*/
use app\adminapi\controller\tcm\RevisitDueController;
use app\adminapi\logic\tcm\DiagnosisLogic;
use app\adminapi\validate\tcm\DiagnosisValidate;
@@ -134,6 +136,37 @@ return [
'tcm.diagnosisTodo/detail' => ['status' => 'pending', 'name' => '诊单待办详情',
'reason' => '按待办ID读取任意诊单的待办,无逐条权限校验(DiagnosisTodoLogic::detail);可改用 tcm.diagnosisTodo/lists(按诊单ID,已校验诊单可见)'],
// ───────────── 复诊到期提醒(需求 4 右下角弹窗 + P2 主管团队视图,controller/tcm/RevisitDueController.php) ─────────────
// 控制器晚于 generated.php 生成,显式写 controller 以通过 AiMcpUnitTest 的资源存在性校验;重跑 catalog.php --write 后可去掉该键。
// pending 是轮询端点且「读取即写」(first_shown_at/last_shown_at + 首次弹出流水,RevisitDueLogic::markShown),只读事务下会报错回滚,
// 又含患者姓名快照,不对 AI 开放;按医助查询到期患者请走 P2 的 tcm.revisitDue/lists(主管视图,HasDataScopeFilter,待评审后开放)。
'tcm.revisitDue/pending' => ['status' => 'excluded', 'name' => '复诊到期弹窗-待办', 'controller' => RevisitDueController::class, 'http' => 'GET',
'reason' => '右下角弹窗轮询接口:读取即更新弹出时间并写「首次弹出」流水(只读事务下回滚),且返回患者姓名;到期患者查询请用 tcm.revisitDue/lists(P2 主管视图,待评审开放)'],
'tcm.revisitDue/summary' => ['status' => 'pending', 'name' => '复诊到期弹窗-计数', 'controller' => RevisitDueController::class, 'http' => 'GET', 'kind' => 'report',
'reason' => '仅返回当前登录医助本人的到期 / 预警计数(RevisitDueLogic::summary,不写库、无患者信息),供弹窗气泡;待 P2 主管视图 tcm.revisitDue/lists 评审口径后一并开放'],
// P2 主管团队视图:lists 为快照表(tcm_diagnosis_revisit_due)分页列表,RevisitDueLists 按 HasDataScopeFilter::applyDataScopeByOwner('assistant_id') 收窄,
// 不写库(无自愈),但逐行返回患者姓名快照与医助延迟行为(snooze_count/escalation_level/人工关闭原因),且 state 由 CURDATE() 推导、
// 菜单 08_add_revisit_due_team_menu.sql 默认不授予任何角色;待产品验收口径与授权范围后再开放。
'tcm.revisitDue/lists' => ['status' => 'pending', 'name' => '复诊到期跟进(主管团队视图)', 'controller' => RevisitDueController::class, 'http' => 'GET', 'kind' => 'list',
'params_allow' => [
'assistant_id' => '医助(后台账号)ID', 'dept_id' => '医助所属部门ID(含下级部门)',
'state' => '状态 due 到期未挂号/过号 | warning 预警 | booked 已约 | visited 已面诊 | contacted 已联系 | no_days 缺开药天数 | pending 未到期 | closed 人工关闭',
'days_bucket' => '开药天数档位 14|21|28|30|other', 'min_snooze' => '延迟次数下限', 'keyword' => '患者姓名(模糊)',
],
'forbid' => ['export', 'file_name', 'page_start', 'page_end'],
'reason' => '逐行返回患者姓名快照与医助「延迟/人工关闭」行为数据(越点越上心的考核依据),数据范围虽按角色 data_scope 收窄,但主管视图菜单(08_add_revisit_due_team_menu.sql)默认不授予任何角色、口径(CURDATE() 推导状态 / 人工关闭标记)尚待产品验收;验收后改 open'],
'tcm.revisitDue/logs' => ['status' => 'pending', 'name' => '复诊到期跟进-事件流水', 'controller' => RevisitDueController::class, 'http' => 'GET', 'kind' => 'detail', 'id_param' => 'due_id',
'params_allow' => [],
'reason' => '按提醒 id(due_id)返回延迟/已联系/人工关闭/企微推送等流水,含患者姓名与人工关闭说明(自由文本可能含手机号等);逐条校验(RevisitDueLogic::canViewRow:本人 / 超管 / DataScope 可见医助)已内建,待 tcm.revisitDue/lists 开放后一并评审;开放时可写 guard=builtin'],
'tcm.revisitDue/snooze' => ['status' => 'excluded', 'name' => '复诊到期弹窗-延迟', 'controller' => RevisitDueController::class, 'http' => 'POST', 'kind' => 'write',
'reason' => '写接口:延迟提醒(更新 snooze_count/next_remind_at 并写流水),AI 只读'],
'tcm.revisitDue/snoozeAll' => ['status' => 'excluded', 'name' => '复诊到期弹窗-全部延迟', 'controller' => RevisitDueController::class, 'http' => 'POST', 'kind' => 'write',
'reason' => '写接口:批量延迟提醒,AI 只读'],
'tcm.revisitDue/contact' => ['status' => 'excluded', 'name' => '复诊到期弹窗-已联系', 'controller' => RevisitDueController::class, 'http' => 'POST', 'kind' => 'write',
'reason' => '写接口:记录患者承诺复诊日(更新 promised_date/contact_count 并写流水),AI 只读'],
'tcm.revisitDue/close' => ['status' => 'excluded', 'name' => '复诊到期弹窗-人工关闭', 'controller' => RevisitDueController::class, 'http' => 'POST', 'kind' => 'write',
'reason' => '写接口:人工关闭提醒周期(closed_state=4 并写流水),AI 只读'],
// ───────────── 处方 ─────────────
'tcm.prescription/lists' => ['status' => 'open', 'kind' => 'list',
'params_allow' => ['patient_name' => '患者姓名(模糊)', 'sn' => '处方编号(模糊)',
+6
View File
@@ -56,5 +56,11 @@ return [
'tcm:diagnosis-todo-notify' => 'app\\command\\DiagnosisTodoNotify',
// 待分配诊单自动指派:按上月二诊复诊接诊率分档轮询分配(>70% 3条/60~70% 2条/50~60% 1条),写自动指派日志
'tcm:auto-assign-pending' => 'app\\command\\AutoAssignPendingDiagnosis',
// 继承指派「继承时诊次」快照回填 / 对账(--dry-run / --recalc / --verify / --month=YYYY-MM)
'tcm:backfill-inherit-visit-slot' => 'app\\command\\BackfillInheritVisitSlot',
// 复诊到期快照重算:--mode=full 每日全量 / --mode=incremental 每 30 分钟增量(支持 --dry-run / --diagnosis-id= / --date=)
'tcm:revisit-due-scan' => 'app\\command\\RevisitDueScan',
// 复诊到期企微推送(P2):--mode=daily 每日 09:00 汇总推医助本人 / --mode=tick 每 30 分钟升级行推本人并抄送主管(wecom_enabled=false 时空跑;支持 --dry-run / --date=)
'tcm:revisit-due-notify' => 'app\\command\\RevisitDueNotify',
],
];
+54
View File
@@ -152,6 +152,60 @@ return [
'exempt_roles' => [],
],
/*
* 复诊接诊率 stats.revisitRate(RevisitRateLogic)口径开关
* default_inherit_mode:页面默认口径,slot=继承按继承时诊次计入每列分母(新)| exclude=继承不计分母(旧,与线上现状一致)
* auto_assign_tier_field:tcm:auto-assign-pending 分档所用字段;过渡期沿用旧口径 visit2_rate_new_assign,产品确认后改 visit2_rate
* inherit_min_next_slot:继承起算诊次下限(k<=1 的继承等同新指派,从二诊列起算)
*/
'revisit_rate' => [
'default_inherit_mode' => 'exclude', // 用户要求复诊接诊率保持原口径;新口径仅在页面手动切换时启用
'auto_assign_tier_field' => 'visit2_rate_new_assign',
'inherit_min_next_slot' => 2,
],
/*
* 复诊到期提醒(tcm_diagnosis_revisit_due 快照 / 列表标红 / 弹窗延迟升级),代码统一 config('project.revisit_reach.xxx') 读取
* limit_er_center:全量扫描候选诊单限「二中心」子树医助(DeptLogic::getErCenterSubtreeDeptIdSet + admin_dept)
* anchor_chain:用药起算锚点回退链,sign=物流签收 ship=人工发货日志 create=下单时间;可收窄为 ['create'] 跳过物流/日志查询
* days_fallback_rx:订单 medication_days 缺失时回落处方 usage_days(剔除系统代开/作废/软删处方)
* days_default:订单与处方都缺天数时的兜底天数;0=不兜底、不计算 due_date、列表显示「缺开药天数」
* day_buckets:常见疗程档位,仅用于筛选/展示(到期按实际天数计算,15 天不归 14 档)
* grace_days / warn_days / severe_overdue_days:到期宽限天数 / 到期前预警天数(橙) / 超期深红阈值天数
* row_highlight_priority:列表行高亮优先级 overdue_first=复诊超期>未确认>未挂号 | unconfirmed_first
* toast_enabled / toast_limit:右下角到期弹窗总开关(上线先 false,主管用 chip 清存量)/ 单次最多条数
* snooze_intervals:第 n 次延迟的间隔秒数(4h→2h→1h→30min),default 为超出档位后的间隔
* work_hours:延迟提醒钳入的工作时段,落到结束时间之后顺延至次日开始时间
* contact_max_per_cycle / promise_max_days:每周期「已联系」免计延迟次数上限 / 承诺复诊日最远天数
* wecom_enabled / wecom_daily_cap / supervisor_admin_ids:企微推送(P2)开关 / 每条每日推送上限 / 抄送主管名单(dept 表无 leader 字段,配置过渡)
* wecom_daily_list_max:每日汇总文案最多列出的患者数(其余以「等 N 人」收尾)
* wecom_supervisor_snooze_min:延迟次数达到该值的行,tick 每日抄送一次主管名单(命令 tcm:revisit-due-notify --mode=tick)
*/
'revisit_reach' => [
'limit_er_center' => true,
'anchor_chain' => ['sign', 'ship', 'create'],
'days_fallback_rx' => true,
'days_default' => 0,
'day_buckets' => [14, 21, 28, 30],
'grace_days' => 0,
'warn_days' => 3,
'severe_overdue_days' => 7,
// 挂号与下单日至少相隔的天数才算「这笔药之后的复诊」(默认 1 = 次日起);同一天的面诊是开药前的那次面诊,不关闭周期
'appointment_min_gap_days' => 1,
'row_highlight_priority' => 'overdue_first',
'toast_enabled' => true,
'toast_limit' => 20,
'snooze_intervals' => [1 => 14400, 2 => 7200, 3 => 3600, 'default' => 1800],
'work_hours' => ['09:00', '20:00'],
'contact_max_per_cycle' => 2,
'promise_max_days' => 14,
'wecom_enabled' => false,
'wecom_daily_cap' => 2,
'supervisor_admin_ids' => [],
'wecom_daily_list_max' => 5,
'wecom_supervisor_snooze_min' => 5,
],
/*
* 提成结算 stats.commissionSettlement(YejiStatsLogic)
* require_system_auto_prescription:true(默认)仅系统代开口径(is_system_auto=1),常与订单列表「全部已完成」条数不一致。
@@ -0,0 +1,4 @@
-- 诊次序号 / 每诊单最新有效订单 的批量查询覆盖索引(RevisitVisitSlotService、tcm:revisit-due-scan、RevisitRateLogic::fetchOrderSeqRows)
-- 现有仅 idx_diagnosis_id(2026_04_07_create_tcm_prescription_order.sql:33)。ADD INDEX 不幂等,重复执行报 1061 可忽略;执行前 SHOW INDEX 确认
ALTER TABLE `zyt_tcm_prescription_order`
ADD KEY `idx_diag_del_fs_ct` (`diagnosis_id`, `delete_time`, `fulfillment_status`, `create_time`);
@@ -0,0 +1,55 @@
-- 复诊到期快照:每诊单一行(当前周期 = 最新有效业务订单)。
-- 写入:php think tcm:revisit-due-scan --mode=full(每日)/ --mode=incremental(每 30 分钟)+ 业务钩子(订单/挂号/指派)即时刷新单条。
-- 诊次口径与复诊接诊率一致:有效业务订单(剔 4/9/10、未软删)序号 + tcm_diagnosis.revisit_slot_start_offset(RevisitVisitSlotService)。
-- 时间相关状态(到期/预警/超期/过号/已约)在读取时由本表事实列(due_date、appointment_state、appointment_date、promised_date、closed_state)与 CURDATE() 推导,不再 join 挂号表。
-- 缺开药天数(medication_days=0)的行保持 closed_state=0,由读取侧推导为「缺开药天数」;仅无医助/诊单删除或禁用/无有效订单时置 5。
-- 索引:uk_diagnosis 一诊单一行/幂等 upsert;idx_assistant_open 弹窗拉取与列表 chip 计数;idx_open_due cron 与主管视图;idx_source_order 订单变更钩子。
CREATE TABLE IF NOT EXISTS `zyt_tcm_diagnosis_revisit_due` (
`id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`diagnosis_id` int unsigned NOT NULL DEFAULT 0 COMMENT '诊单ID(zyt_tcm_diagnosis.id)',
`assistant_id` int unsigned NOT NULL DEFAULT 0 COMMENT '当前持有医助(随 tcm_diagnosis.assistant_id 同步,(int) 强转)',
`patient_name` varchar(64) NOT NULL DEFAULT '' COMMENT '患者姓名快照',
`source_order_id` int unsigned NOT NULL DEFAULT 0 COMMENT '本周期锚点业务订单 zyt_tcm_prescription_order.id(最新有效订单)',
`order_create_time` int unsigned NOT NULL DEFAULT 0 COMMENT '锚点订单下单时间',
`valid_order_count` smallint unsigned NOT NULL DEFAULT 0 COMMENT '有效订单数',
`anchor_ts` int unsigned NOT NULL DEFAULT 0 COMMENT '用药起算时间戳',
`anchor_src` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '起算来源:1=签收 2=发货 3=下单',
`medication_days` smallint unsigned NOT NULL DEFAULT 0 COMMENT '开药天数(订单 medication_days,缺省回落处方 usage_days;0=缺失)',
`days_src` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '天数来源:0=缺失 1=订单 2=处方',
`rule_days` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '天数档位:14/21/28/30,0=其它',
`total_medication_days` smallint unsigned NOT NULL DEFAULT 0 COMMENT '累计开药天数',
`current_slot` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '患者当前已完成诊次(有效订单数+偏移)',
`next_slot` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '待复诊诊次 = current_slot+1',
`first_touch_date` date NULL COMMENT '首次触达日(首笔有效订单/首条指派取早)',
`due_date` date NULL COMMENT '预计复诊日 = DATE(anchor_ts)+medication_days+grace;缺天数为 NULL',
`closed_state` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '0=进行中 2=已挂号(自动) 3=已面诊(自动) 4=人工关闭 5=失效(无医助/诊单删除或禁用/无有效订单)',
`booked_appointment_id` int unsigned NOT NULL DEFAULT 0 COMMENT '触发关闭或当前待面诊的挂号单 id',
`appointment_state` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '锚点订单之后最新有效挂号状态(事实列):0=无 1=已约 3=已面诊 4=过号',
`appointment_date` date NULL COMMENT '上述挂号的预约日期(事实列)',
`missed_count` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '本周期过号/已约未到次数',
`contact_count` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '本周期「已联系」次数(上限 2,之后按延迟计)',
`contacted_at` int unsigned NOT NULL DEFAULT 0 COMMENT '最近一次「已联系」时间',
`promised_date` date NULL COMMENT '患者承诺复诊日期',
`snooze_count` smallint unsigned NOT NULL DEFAULT 0 COMMENT '延迟处理次数(越点越上心的依据)',
`escalation_level` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '升级等级 0~3 = f(snooze_count)',
`next_remind_at` int unsigned NOT NULL DEFAULT 0 COMMENT '下次弹窗时刻;弹窗只取 next_remind_at<=now',
`first_shown_at` int unsigned NOT NULL DEFAULT 0 COMMENT '首次弹出时间(仅写一次)',
`last_shown_at` int unsigned NOT NULL DEFAULT 0 COMMENT '最近弹出时间(统计用,不做节流)',
`last_snoozed_at` int unsigned NOT NULL DEFAULT 0 COMMENT '最近延迟时间',
`last_snoozed_by` int unsigned NOT NULL DEFAULT 0 COMMENT '最近延迟人',
`handled_at` int unsigned NOT NULL DEFAULT 0 COMMENT '人工关闭时间',
`handled_by` int unsigned NOT NULL DEFAULT 0 COMMENT '人工关闭人',
`handle_reason` varchar(16) NOT NULL DEFAULT '' COMMENT '人工关闭原因 lost/other',
`handle_remark` varchar(200) NOT NULL DEFAULT '' COMMENT '人工关闭说明',
`wecom_self_at` int unsigned NOT NULL DEFAULT 0 COMMENT '最近一次企微推送本人(P2)',
`wecom_self_count_day` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '当日已推送本人次数(P2)',
`wecom_supervisor_at` int unsigned NOT NULL DEFAULT 0 COMMENT '最近一次企微抄送主管(P2)',
`computed_at` int unsigned NOT NULL DEFAULT 0 COMMENT '最近重算时间',
`create_time` int unsigned NOT NULL DEFAULT 0 COMMENT '创建时间',
`update_time` int unsigned NOT NULL DEFAULT 0 COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_diagnosis` (`diagnosis_id`),
KEY `idx_assistant_open` (`assistant_id`, `closed_state`, `appointment_state`, `due_date`, `next_remind_at`),
KEY `idx_open_due` (`closed_state`, `due_date`),
KEY `idx_source_order` (`source_order_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '诊单复诊到期快照(每诊单一行,列表标红/弹窗/延迟升级共用)';
@@ -0,0 +1,20 @@
-- 复诊到期事件流水:供审计与「越点越上心」统计(主管视图可按延迟/升级次数排序)。
-- 注意:弹窗轮询不写流水(只更新 first/last_shown_at),避免日志膨胀。
CREATE TABLE IF NOT EXISTS `zyt_tcm_diagnosis_revisit_due_log` (
`id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`due_id` int unsigned NOT NULL DEFAULT 0 COMMENT 'zyt_tcm_diagnosis_revisit_due.id',
`diagnosis_id` int unsigned NOT NULL DEFAULT 0 COMMENT '诊单ID',
`assistant_id` int unsigned NOT NULL DEFAULT 0 COMMENT '事件时持有医助',
`source_order_id` int unsigned NOT NULL DEFAULT 0 COMMENT '事件时周期订单',
`event` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '1=延迟 2=已联系 3=人工关闭 4=自动关闭-已挂号 5=自动关闭-已面诊 6=周期重置(新订单) 7=重新打开(挂号取消/过号) 8=企微推送 9=医助变更 10=形态升级 11=首次弹出',
`snooze_count_after` smallint unsigned NOT NULL DEFAULT 0 COMMENT '事件后延迟次数',
`level_after` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '事件后升级等级',
`overdue_days` smallint NOT NULL DEFAULT 0 COMMENT '事件时超期天数(负=未到期)',
`payload` varchar(500) NOT NULL DEFAULT '' COMMENT 'JSON:next_remind_at/promised_date/appointment_id/order_id/remark/from_assistant 等',
`operator_id` int unsigned NOT NULL DEFAULT 0 COMMENT '操作人 admin.id,0=系统',
`create_time` int unsigned NOT NULL DEFAULT 0 COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_due_time` (`due_id`, `create_time`),
KEY `idx_assistant_event_time` (`assistant_id`, `event`, `create_time`),
KEY `idx_diagnosis_id` (`diagnosis_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '诊单复诊到期事件流水';
@@ -0,0 +1,8 @@
-- 依赖:先执行 02/03;服务器 crontab 每分钟 php think crontab;首次 last_time 为空时调度器只记下次时间不执行(Crontab.php:46-54)
-- 幂等:按 command + params 判重,可重复执行。命令内部整体 try/catch 并 return 0,避免 Crontab::start 将任务置 ERROR。
INSERT INTO `zyt_dev_crontab` (`name`,`type`,`system`,`remark`,`command`,`params`,`status`,`expression`,`create_time`,`update_time`)
SELECT '复诊到期快照-每日全量', 1, 0, '全量重算 tcm_diagnosis_revisit_due(签收时间/过号变化/钩子漏刷兜底)', 'tcm:revisit-due-scan', '--mode=full', 1, '30 8 * * *', UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `zyt_dev_crontab` WHERE `command`='tcm:revisit-due-scan' AND `params`='--mode=full' AND `delete_time` IS NULL);
INSERT INTO `zyt_dev_crontab` (`name`,`type`,`system`,`remark`,`command`,`params`,`status`,`expression`,`create_time`,`update_time`)
SELECT '复诊到期快照-增量', 1, 0, '仅重算近 2 小时有订单/挂号/指派变更的诊单,并重评 closed_state=2 的行', 'tcm:revisit-due-scan', '--mode=incremental', 1, '*/30 * * * *', UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `zyt_dev_crontab` WHERE `command`='tcm:revisit-due-scan' AND `params`='--mode=incremental' AND `delete_time` IS NULL);
@@ -0,0 +1,56 @@
-- 复诊到期提醒(需求 4 弹窗)接口按钮权限,挂在「中医诊单」(perms=tcm.diagnosis/lists, type=C) 下。
-- 为什么必须登记:AuthMiddleware.php:111-117 对「未在菜单登记」的接口对所有登录账号直接放行,
-- 因此 pending / summary / snooze / snoozeAll / contact / close 六个 action 全部登记为 A 类权限点,
-- 未授权账号调用将返回「权限不足」(验收 §11 需求 4 第 7 条)。
-- 为什么自动授权:登记后若不授予,现有医助会立刻失权、弹窗不弹;末尾 INSERT IGNORE 把六个权限点授予
-- 所有已拥有 tcm.diagnosis/lists 的角色(写法照抄 1.9.20260803/add_performance_dashboard_menu.sql:50-56)。
-- 超管(root=1)不走菜单校验;「全部菜单」类角色若按 * 配置需人工复核。执行后需清理 AdminAuthCache 或等缓存过期。
-- 幂等:INSERT … SELECT … WHERE NOT EXISTS,可重复执行。排序 70~75 紧随 1.9.20260505 的待办权限(60~62)。
SET @diag_menu_id := (SELECT id FROM zyt_system_menu WHERE perms = 'tcm.diagnosis/lists' AND type = 'C' LIMIT 1);
INSERT INTO zyt_system_menu (pid, type, name, icon, sort, perms, paths, component, selected, params, is_cache, is_show, is_disable, create_time, update_time)
SELECT @diag_menu_id, 'A', '复诊到期-弹窗待办', '', 70, 'tcm.revisitDue/pending', '', '', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL
WHERE @diag_menu_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'tcm.revisitDue/pending');
INSERT INTO zyt_system_menu (pid, type, name, icon, sort, perms, paths, component, selected, params, is_cache, is_show, is_disable, create_time, update_time)
SELECT @diag_menu_id, 'A', '复诊到期-计数', '', 71, 'tcm.revisitDue/summary', '', '', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL
WHERE @diag_menu_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'tcm.revisitDue/summary');
INSERT INTO zyt_system_menu (pid, type, name, icon, sort, perms, paths, component, selected, params, is_cache, is_show, is_disable, create_time, update_time)
SELECT @diag_menu_id, 'A', '复诊到期-延迟', '', 72, 'tcm.revisitDue/snooze', '', '', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL
WHERE @diag_menu_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'tcm.revisitDue/snooze');
INSERT INTO zyt_system_menu (pid, type, name, icon, sort, perms, paths, component, selected, params, is_cache, is_show, is_disable, create_time, update_time)
SELECT @diag_menu_id, 'A', '复诊到期-全部延迟', '', 73, 'tcm.revisitDue/snoozeAll', '', '', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL
WHERE @diag_menu_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'tcm.revisitDue/snoozeAll');
INSERT INTO zyt_system_menu (pid, type, name, icon, sort, perms, paths, component, selected, params, is_cache, is_show, is_disable, create_time, update_time)
SELECT @diag_menu_id, 'A', '复诊到期-已联系', '', 74, 'tcm.revisitDue/contact', '', '', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL
WHERE @diag_menu_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'tcm.revisitDue/contact');
INSERT INTO zyt_system_menu (pid, type, name, icon, sort, perms, paths, component, selected, params, is_cache, is_show, is_disable, create_time, update_time)
SELECT @diag_menu_id, 'A', '复诊到期-人工关闭', '', 75, 'tcm.revisitDue/close', '', '', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL
WHERE @diag_menu_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'tcm.revisitDue/close');
-- 自动授予:所有已拥有「中医诊单」列表权限的角色,默认获得六个弹窗权限点
INSERT IGNORE INTO zyt_system_role_menu (role_id, menu_id)
SELECT DISTINCT rm.role_id, m2.id
FROM zyt_system_role_menu rm
INNER JOIN zyt_system_menu m1 ON m1.id = rm.menu_id AND m1.perms = 'tcm.diagnosis/lists'
INNER JOIN zyt_system_menu m2 ON m2.perms IN (
'tcm.revisitDue/pending', 'tcm.revisitDue/summary', 'tcm.revisitDue/snooze',
'tcm.revisitDue/snoozeAll', 'tcm.revisitDue/contact', 'tcm.revisitDue/close'
);
@@ -0,0 +1,6 @@
-- 继承指派时患者所处诊次快照(RevisitVisitSlotService::slotAtTime 口径:继承时刻之前的有效实单数 + 偏移)。
-- 统计以实时重算为准(事后退款/取消/改 offset 会联动);本列用于指派记录面板展示与对账(php think tcm:backfill-inherit-visit-slot --verify)。
-- ALTER 不幂等,重复执行报列已存在可忽略。
ALTER TABLE `zyt_tcm_diagnosis_assign_log`
ADD COLUMN `inherit_visit_slot` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '继承时患者已完成诊次(实单数+偏移);0=非继承/未计算' AFTER `is_inherit`,
ADD COLUMN `inherit_visit_slot_calc_time` int unsigned NOT NULL DEFAULT 0 COMMENT '快照计算/回填时间' AFTER `inherit_visit_slot`;
@@ -0,0 +1,7 @@
-- 自动分配日志同时记录两套口径的二诊率,便于回溯分档依据
-- visit2_rate:当前口径(inherit_mode=slot,每列独立分母);visit2_rate_new_assign:旧口径(分母仅新指派)
-- rate_version:本批次分档所用字段(config project.revisit_rate.auto_assign_tier_field):v1=仅新指派 v2=每列独立分母
-- ALTER 不幂等,重复执行报列已存在可忽略。
ALTER TABLE `zyt_tcm_diagnosis_auto_assign_log`
ADD COLUMN `visit2_rate_new_assign` decimal(8,2) NULL DEFAULT NULL COMMENT '旧口径二诊率(分母仅新指派)' AFTER `visit2_rate`,
ADD COLUMN `rate_version` varchar(8) NOT NULL DEFAULT 'v1' COMMENT '分档所用口径:v1=仅新指派 v2=每列独立分母' AFTER `visit2_rate_new_assign`;
@@ -0,0 +1,34 @@
-- 复诊到期跟进(P2 主管团队视图)菜单,挂在「数据统计」目录(name='数据统计' AND type='M')下,模板 1.9.20260715/add_auto_assign_log_menu.sql。
-- 页面路径: /stats/revisit_due
-- 前端组件: /stats/revisit_due/index
-- 查询权限: tcm.revisitDue/lists(C 类页面,lists 支持 export=1/2 导出,不另设 export 权限点)
-- 流水权限: tcm.revisitDue/logs(A 类按钮,挂在页面下)
-- 数据来源: zyt_tcm_diagnosis_revisit_due / zyt_tcm_diagnosis_revisit_due_log(命令 tcm:revisit-due-scan 与业务钩子写入)
-- 为什么必须登记:AuthMiddleware 对「未在菜单登记」的接口对所有登录账号直接放行,登记后未授权账号返回「权限不足」。
-- 注意:本文件【不自动授予任何角色】——主管视图由管理员在「角色管理」里按角色勾选;
-- 数据范围由角色 data_scope 决定(HasDataScopeFilter::applyDataScopeByOwner('assistant_id')),医助本人只能看到自己的行。
-- 幂等:INSERT … SELECT … WHERE NOT EXISTS,可重复执行。
SET @stats_root_id := (SELECT id FROM zyt_system_menu WHERE name = '数据统计' AND type = 'M' LIMIT 1);
INSERT INTO `zyt_system_menu`
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`)
SELECT
@stats_root_id, 'C', '复诊到期跟进', '', 13,
'tcm.revisitDue/lists', '/stats/revisit_due', '/stats/revisit_due/index',
'', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL
WHERE @stats_root_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'tcm.revisitDue/lists');
SET @revisit_due_page_id := (SELECT id FROM zyt_system_menu WHERE perms = 'tcm.revisitDue/lists' AND type = 'C' LIMIT 1);
INSERT INTO `zyt_system_menu`
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`)
SELECT
@revisit_due_page_id, 'A', '复诊到期-流水', '', 1,
'tcm.revisitDue/logs', '', '',
'', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL
WHERE @revisit_due_page_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'tcm.revisitDue/logs');
@@ -0,0 +1,12 @@
-- 复诊到期提醒企微推送定时任务(P2,方案 §5.2),模板 04_add_revisit_due_crontab.sql。
-- 依赖:先执行 02/03(快照与流水表);服务器 crontab 每分钟 php think crontab;首次 last_time 为空时调度器只记下次时间不执行。
-- status=1 直接启用,但命令内部先读 project.revisit_reach.wecom_enabled:默认 false 时每次只输出「未启用」即返回(空跑,不调企微、不写流水);
-- 产品确认开启后把配置改为 true 即生效,不需要再改本表。
-- 命令内部整体 try/catch 并 return 0,避免 Crontab::start 将任务置 ERROR;params 不超过 64 字符。
-- 幂等:按 command + params 判重,可重复执行。
INSERT INTO `zyt_dev_crontab` (`name`,`type`,`system`,`remark`,`command`,`params`,`status`,`expression`,`create_time`,`update_time`)
SELECT '复诊到期企微-每日汇总', 1, 0, '每位有到期未挂号/过号行的医助一条企微汇总(wecom_enabled=false 时空跑)', 'tcm:revisit-due-notify', '--mode=daily', 1, '0 9 * * *', UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `zyt_dev_crontab` WHERE `command`='tcm:revisit-due-notify' AND `params`='--mode=daily' AND `delete_time` IS NULL);
INSERT INTO `zyt_dev_crontab` (`name`,`type`,`system`,`remark`,`command`,`params`,`status`,`expression`,`create_time`,`update_time`)
SELECT '复诊到期企微-升级推送', 1, 0, 'escalation_level>=3 的行推本人(每行每日≤wecom_daily_cap),snooze_count>=5 每日抄送主管一次(wecom_enabled=false 时空跑)', 'tcm:revisit-due-notify', '--mode=tick', 1, '*/30 * * * *', UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `zyt_dev_crontab` WHERE `command`='tcm:revisit-due-notify' AND `params`='--mode=tick' AND `delete_time` IS NULL);
@@ -0,0 +1,23 @@
-- 「我的复诊接诊率」卡片接口权限(方案 §10 D1-h,P2),挂在「个人业绩」页面(perms=stats.assistantPerformance/overview, type=C) 下。
-- 为什么必须登记:AuthMiddleware.php:111-117 对「未在菜单登记」的接口对所有登录账号直接放行,
-- 因此 stats.assistantPerformance/revisitRate 登记为 A 类权限点,未授权账号调用返回「权限不足」。
-- 为什么自动授权:该接口只返回本人行(RevisitRateLogic::assistantOverview),与个人业绩页同受众;
-- 末尾 INSERT IGNORE 把该权限点授予所有已拥有个人业绩页的角色,避免「登记后卡片报权限不足」
-- (写法照抄 1.9.20260803/add_performance_dashboard_menu.sql:50-56 / 本目录 05_add_revisit_due_menu.sql)。
-- 超管(root=1)不走菜单校验;「全部菜单」类角色若按 * 配置需人工复核。执行后需清理 AdminAuthCache 或等缓存过期。
-- 幂等:INSERT … SELECT … WHERE NOT EXISTS,可重复执行。
SET @perf_menu_id := (SELECT id FROM zyt_system_menu WHERE perms = 'stats.assistantPerformance/overview' AND type = 'C' LIMIT 1);
INSERT INTO zyt_system_menu (pid, type, name, icon, sort, perms, paths, component, selected, params, is_cache, is_show, is_disable, create_time, update_time)
SELECT @perf_menu_id, 'A', '个人业绩-我的复诊接诊率', '', 10, 'stats.assistantPerformance/revisitRate', '', '', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL
WHERE @perf_menu_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM zyt_system_menu WHERE perms = 'stats.assistantPerformance/revisitRate');
-- 自动授予:所有已拥有「个人业绩」页面权限的角色,默认获得「我的复诊接诊率」权限点
INSERT IGNORE INTO zyt_system_role_menu (role_id, menu_id)
SELECT DISTINCT rm.role_id, m2.id
FROM zyt_system_role_menu rm
INNER JOIN zyt_system_menu m1 ON m1.id = rm.menu_id AND m1.perms = 'stats.assistantPerformance/overview'
INNER JOIN zyt_system_menu m2 ON m2.perms = 'stats.assistantPerformance/revisitRate';
+235
View File
@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
/**
* 复诊到期计算器(RevisitDueCalculator)纯函数单测,不连库。
* php server/tests/RevisitDueCalculatorTest.php
* 覆盖:锚点回退链三种、天数回落三种、15 天不归 14 档、computeFacts 失效/挂号选择、
* deriveState 九态与优先级(missed 优先于 overdue 且不受 due_date 限制、contacted、warning/pending 边界、no_days、none)、
* 人工关闭文案、escalationLevel、日期工具。
*/
require dirname(__DIR__) . '/vendor/autoload.php';
use app\common\service\tcm\RevisitDueCalculator as C;
$checks = 0;
function expect(bool $condition, string $message): void
{
global $checks;
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
$checks++;
}
$cfg = C::defaultConfig();
$ts = static fn (string $dt): int => (int) strtotime($dt);
/** 基础输入:订单 09-20 10:00 下单、28 天 */
$baseInput = static function (array $override = []) use ($ts): array {
return array_replace([
'diagnosis_id' => 1,
'assistant_id' => 7,
'patient_name' => '张三',
'latest_order' => ['id' => 100, 'create_time' => $ts('2026-09-20 10:00:00'), 'medication_days' => 28, 'prescription_id' => 55],
'valid_order_count' => 3,
'offset' => 0,
'rx_usage_days' => null,
'sign_ts' => 0,
'ship_ts' => 0,
'appointments' => [],
'first_touch_ts' => $ts('2026-03-02 09:00:00'),
'total_medication_days' => 84,
], $override);
};
// ---------- 锚点回退链 ----------
$f = C::computeFacts($baseInput(['sign_ts' => $ts('2026-09-22 15:30:00'), 'ship_ts' => $ts('2026-09-21 08:00:00')]), $cfg, '2026-10-01');
expect($f['anchor_src'] === C::ANCHOR_SIGN && $f['anchor_ts'] === $ts('2026-09-22 15:30:00'), '锚点:有签收优先签收');
expect($f['due_date'] === '2026-10-20', '锚点签收 09-22 + 28 天 = 10-20');
$f = C::computeFacts($baseInput(['ship_ts' => $ts('2026-09-21 08:00:00')]), $cfg, '2026-10-01');
expect($f['anchor_src'] === C::ANCHOR_SHIP && $f['due_date'] === '2026-10-19', '锚点:无签收回落发货 09-21 → 10-19');
$f = C::computeFacts($baseInput(), $cfg, '2026-10-01');
expect($f['anchor_src'] === C::ANCHOR_CREATE && $f['due_date'] === '2026-10-18', '锚点:都没有用下单日 09-20 → 10-18');
$f = C::computeFacts($baseInput(['sign_ts' => $ts('2026-09-22 15:30:00')]), ['anchor_chain' => ['create']], '2026-10-01');
expect($f['anchor_src'] === C::ANCHOR_CREATE, 'anchor_chain 收窄为 create 时忽略签收');
$f = C::computeFacts($baseInput(['sign_ts' => $ts('2026-09-22 15:30:00')]), ['anchor_chain' => ['ship']], '2026-10-01');
expect($f['anchor_src'] === C::ANCHOR_CREATE, '链耗尽无结果时兜底下单时间');
$f = C::computeFacts($baseInput(['sign_ts' => $ts('2026-09-22 15:30:00')]), ['anchor_chain' => 'ship,sign'], '2026-10-01');
expect($f['anchor_src'] === C::ANCHOR_SIGN, 'anchor_chain 支持逗号字符串');
// ---------- 天数回落 ----------
$f = C::computeFacts($baseInput(['rx_usage_days' => 14]), $cfg, '2026-10-01');
expect($f['medication_days'] === 28 && $f['days_src'] === C::DAYS_SRC_ORDER, '天数:订单有值用订单');
$f = C::computeFacts($baseInput(['latest_order' => ['id' => 100, 'create_time' => $ts('2026-09-20 10:00:00'), 'medication_days' => 0, 'prescription_id' => 55], 'rx_usage_days' => 14]), $cfg, '2026-10-01');
expect($f['medication_days'] === 14 && $f['days_src'] === C::DAYS_SRC_RX && $f['due_date'] === '2026-10-04', '天数:订单缺失回落处方 14');
$f = C::computeFacts($baseInput(['latest_order' => ['id' => 100, 'create_time' => $ts('2026-09-20 10:00:00'), 'medication_days' => null, 'prescription_id' => 55], 'rx_usage_days' => null]), $cfg, '2026-10-01');
expect($f['medication_days'] === 0 && $f['days_src'] === C::DAYS_SRC_NONE && $f['due_date'] === null, '天数:都缺失 → 0 且无 due_date');
expect($f['closed_state_auto'] === C::CLOSED_OPEN, '缺天数仍为进行中(由读取侧推导 no_days)');
$f = C::computeFacts($baseInput(['latest_order' => ['id' => 100, 'create_time' => $ts('2026-09-20 10:00:00'), 'medication_days' => 0, 'prescription_id' => 55], 'rx_usage_days' => 14]), ['days_fallback_rx' => false], '2026-10-01');
expect($f['medication_days'] === 0, 'days_fallback_rx=false 不回落处方');
$f = C::computeFacts($baseInput(['latest_order' => ['id' => 100, 'create_time' => $ts('2026-09-20 10:00:00'), 'medication_days' => 0, 'prescription_id' => 0]]), ['days_default' => 7], '2026-10-01');
expect($f['medication_days'] === 7 && $f['days_src'] === C::DAYS_SRC_NONE && $f['due_date'] === '2026-09-27', 'days_default>0 时兜底天数且 days_src 仍为 0');
// ---------- 档位 ----------
$f = C::computeFacts($baseInput(['latest_order' => ['id' => 100, 'create_time' => $ts('2026-09-20 10:00:00'), 'medication_days' => 15, 'prescription_id' => 55]]), $cfg, '2026-10-01');
expect($f['rule_days'] === 0 && $f['due_date'] === '2026-10-05', '15 天不归 14 档,到期仍按实际天数');
$f = C::computeFacts($baseInput(), $cfg, '2026-10-01');
expect($f['rule_days'] === 28, '28 天命中档位');
$f = C::computeFacts($baseInput(), ['grace_days' => 2], '2026-10-01');
expect($f['due_date'] === '2026-10-20', 'grace_days 顺延');
// ---------- 诊次 / 首次触达 / 失效 ----------
$f = C::computeFacts($baseInput(['offset' => 2]), $cfg, '2026-10-01');
expect($f['current_slot'] === 5 && $f['next_slot'] === 6, 'current_slot = 有效单 3 + 偏移 2');
expect($f['first_touch_date'] === '2026-03-02', 'first_touch_date 由时间戳转日期');
$f = C::computeFacts($baseInput(['offset' => 99]), $cfg, '2026-10-01');
expect($f['current_slot'] === 23, '偏移钳到 20');
$f = C::computeFacts($baseInput(['latest_order' => null, 'valid_order_count' => 0]), $cfg, '2026-10-01');
expect($f['closed_state_auto'] === C::CLOSED_INVALID && $f['source_order_id'] === 0 && $f['due_date'] === null, '无有效订单 → 失效');
$f = C::computeFacts($baseInput(['assistant_id' => 0]), $cfg, '2026-10-01');
expect($f['closed_state_auto'] === C::CLOSED_INVALID, '无医助 → 失效');
$f = C::computeFacts($baseInput(['diagnosis_active' => false]), $cfg, '2026-10-01');
expect($f['closed_state_auto'] === C::CLOSED_INVALID, '诊单删除/禁用 → 失效');
// ---------- 挂号选择 ----------
$appts = [
['id' => 1, 'status' => 3, 'appointment_date' => '2026-09-19', 'appointment_time' => '10:00:00'], // 订单日之前,忽略
['id' => 2, 'status' => 2, 'appointment_date' => '2026-10-21', 'appointment_time' => '10:00:00'], // 已取消,忽略
['id' => 3, 'status' => 1, 'appointment_date' => '2026-10-23', 'appointment_time' => ''],
['id' => 4, 'status' => 1, 'appointment_date' => '2026-10-23', 'appointment_time' => '09:30:00'],
];
$f = C::computeFacts($baseInput(['appointments' => $appts]), $cfg, '2026-10-21');
expect($f['appointment_state'] === 1 && $f['appointment_date'] === '2026-10-23' && $f['booked_appointment_id'] === 4, '挂号取锚点日之后最新一条(空时间视为 00:00:00,排在 09:30 之前)');
expect($f['closed_state_auto'] === C::CLOSED_BOOKED, '已约且日期未过 → 自动关闭 2');
$f = C::computeFacts($baseInput(['appointments' => $appts]), $cfg, '2026-10-24');
expect($f['appointment_state'] === 1 && $f['closed_state_auto'] === C::CLOSED_OPEN, '已约但日期已过(cron 未跑)→ 进行中');
$f = C::computeFacts($baseInput(['appointments' => [['id' => 9, 'status' => 3, 'appointment_date' => '2026-10-23', 'appointment_time' => '10:00:00']]]), $cfg, '2026-10-24');
expect($f['appointment_state'] === 3 && $f['closed_state_auto'] === C::CLOSED_VISITED && $f['booked_appointment_id'] === 9, '已面诊 → 自动关闭 3');
$f = C::computeFacts($baseInput(['appointments' => [['id' => 9, 'status' => 4, 'appointment_date' => '2026-10-23', 'appointment_time' => '10:00:00']]]), $cfg, '2026-10-23');
expect($f['appointment_state'] === 4 && $f['closed_state_auto'] === C::CLOSED_OPEN, '过号 → 进行中');
// 下单当天(及更早)的面诊是产生这笔药的那次面诊,不算复诊;次日起才算(appointment_min_gap_days 默认 1)
$f = C::computeFacts($baseInput(['appointments' => [['id' => 11, 'status' => 3, 'appointment_date' => '2026-09-20', 'appointment_time' => '09:30:00']]]), $cfg, '2026-09-21');
expect($f['appointment_state'] === 0 && $f['closed_state_auto'] === C::CLOSED_OPEN && $f['booked_appointment_id'] === 0, '下单当天的已面诊不计入,周期保持进行中');
$f = C::computeFacts($baseInput(['appointments' => [['id' => 12, 'status' => 3, 'appointment_date' => '2026-09-21', 'appointment_time' => '09:30:00']]]), $cfg, '2026-09-22');
expect($f['appointment_state'] === 3 && $f['closed_state_auto'] === C::CLOSED_VISITED, '下单次日的已面诊计入 → 自动关闭 3');
$f = C::computeFacts($baseInput(['appointments' => [['id' => 12, 'status' => 3, 'appointment_date' => '2026-09-21', 'appointment_time' => '09:30:00']]]), ['appointment_min_gap_days' => 2] + $cfg, '2026-09-22');
expect($f['appointment_state'] === 0, 'appointment_min_gap_days=2 时下单次日的面诊也不计入');
// ---------- deriveState ----------
$row = static function (array $override = []): array {
return array_replace([
'closed_state' => 0,
'assistant_id' => 7,
'source_order_id' => 100,
'valid_order_count' => 3,
'medication_days' => 28,
'due_date' => '2026-10-20',
'appointment_state' => 0,
'appointment_date' => null,
'promised_date' => null,
'snooze_count' => 0,
], $override);
};
$s = C::deriveState($row(['appointment_state' => 3, 'appointment_date' => '2026-10-23']), '2026-10-30', $cfg);
expect($s['state'] === C::STATE_VISITED && $s['state_text'] === '已面诊 10-23' && $s['severe'] === 0, 'visited 优先且不深红');
$s = C::deriveState($row(['appointment_state' => 1, 'appointment_date' => '2026-10-23']), '2026-10-21', $cfg);
expect($s['state'] === C::STATE_BOOKED && $s['state_text'] === '已约 10-23', 'booked');
$s = C::deriveState($row(['appointment_state' => 1, 'appointment_date' => '2026-10-21']), '2026-10-21', $cfg);
expect($s['state'] === C::STATE_BOOKED, 'booked:预约日 = today 仍为已约');
$s = C::deriveState($row(['appointment_state' => 4, 'appointment_date' => '2026-10-23']), '2026-10-23', $cfg);
expect($s['state'] === C::STATE_MISSED && $s['state_text'] === '过号 10-23', 'missed:status=4');
$s = C::deriveState($row(['appointment_state' => 1, 'appointment_date' => '2026-10-22']), '2026-10-23', $cfg);
expect($s['state'] === C::STATE_MISSED, 'missed:已约日期已过(cron 未跑兜底)');
$s = C::deriveState($row(['appointment_state' => 4, 'appointment_date' => '2026-10-02', 'due_date' => '2026-10-20']), '2026-10-02', $cfg);
expect($s['state'] === C::STATE_MISSED && $s['days_left'] === 18 && $s['overdue_days'] === 0, 'missed 不受 due_date 限制(到期前过号同样标红)');
$s = C::deriveState($row(['appointment_state' => 4, 'appointment_date' => '2026-10-23', 'promised_date' => '2026-10-30']), '2026-10-23', $cfg);
expect($s['state'] === C::STATE_MISSED, 'missed 优先于 contacted');
$s = C::deriveState($row(['appointment_state' => 4, 'appointment_date' => '2026-10-28']), '2026-10-28', $cfg);
expect($s['state'] === C::STATE_MISSED && $s['overdue_days'] === 8 && $s['severe'] === 1, 'missed 且超期 ≥7 天 → 深红');
$s = C::deriveState($row(['promised_date' => '2026-10-25']), '2026-10-22', $cfg);
expect($s['state'] === C::STATE_CONTACTED && $s['state_text'] === '已联系·约 10-25' && $s['severe'] === 0, 'contacted 覆盖 overdue');
$s = C::deriveState($row(['promised_date' => '2026-10-22']), '2026-10-22', $cfg);
expect($s['state'] === C::STATE_CONTACTED, 'contacted:承诺日 = today 仍为已联系');
$s = C::deriveState($row(['promised_date' => '2026-10-21']), '2026-10-22', $cfg);
expect($s['state'] === C::STATE_OVERDUE, '承诺日已过 → 回到 overdue');
$s = C::deriveState($row(), '2026-10-20', $cfg);
expect($s['state'] === C::STATE_OVERDUE && $s['state_text'] === '今日应挂号' && $s['overdue_days'] === 0 && $s['days_left'] === 0 && $s['severe'] === 0, 'overdue:到期日当天');
$s = C::deriveState($row(), '2026-10-26', $cfg);
expect($s['state'] === C::STATE_OVERDUE && $s['state_text'] === '超期 6 天' && $s['severe'] === 0, 'overdue 6 天未深红');
$s = C::deriveState($row(), '2026-10-27', $cfg);
expect($s['state'] === C::STATE_OVERDUE && $s['overdue_days'] === 7 && $s['severe'] === 1, 'overdue 7 天深红');
$s = C::deriveState($row(), '2026-10-27', ['severe_overdue_days' => [14, 21, 28, 30]]);
expect($s['severe'] === 0, 'severe 阶梯数组取最小阈值 14:7 天不深红');
$s = C::deriveState($row(), '2026-11-03', ['severe_overdue_days' => [14, 21, 28, 30]]);
expect($s['severe'] === 1, 'severe 阶梯数组:14 天深红');
$s = C::deriveState($row(), '2026-10-19', $cfg);
expect($s['state'] === C::STATE_WARNING && $s['state_text'] === '剩 1 天' && $s['days_left'] === 1, 'warning:剩 1 天');
$s = C::deriveState($row(), '2026-10-17', $cfg);
expect($s['state'] === C::STATE_WARNING && $s['days_left'] === 3, 'warning 边界:剩 3 天 = warn_days');
$s = C::deriveState($row(), '2026-10-16', $cfg);
expect($s['state'] === C::STATE_PENDING && $s['state_text'] === '剩 4 天' && $s['overdue_days'] === 0, 'pending 边界:剩 4 天');
$s = C::deriveState($row(), '2026-10-16', ['warn_days' => 5]);
expect($s['state'] === C::STATE_WARNING, 'warn_days 可配');
$s = C::deriveState($row(['due_date' => null, 'medication_days' => 0]), '2026-10-16', $cfg);
expect($s['state'] === C::STATE_NO_DAYS && $s['state_text'] === '缺开药天数' && $s['days_left'] === null && $s['overdue_days'] === null, 'no_days');
$s = C::deriveState($row(['due_date' => '', 'medication_days' => 0, 'appointment_state' => 4, 'appointment_date' => '2026-10-10']), '2026-10-10', $cfg);
expect($s['state'] === C::STATE_MISSED, '缺天数但过号 → missed 优先');
$s = C::deriveState($row(['valid_order_count' => 0, 'source_order_id' => 0]), '2026-10-16', $cfg);
expect($s['state'] === C::STATE_NONE && $s['state_text'] === '' && $s['days_left'] === null, 'none:无有效订单');
$s = C::deriveState($row(['assistant_id' => 0]), '2026-10-16', $cfg);
expect($s['state'] === C::STATE_NONE, 'none:无医助');
$s = C::deriveState($row(['closed_state' => 5]), '2026-10-16', $cfg);
expect($s['state'] === C::STATE_NONE, 'none:closed_state=5');
$s = C::deriveState($row(['closed_state' => 4]), '2026-10-27', $cfg);
expect($s['state'] === C::STATE_OVERDUE && $s['state_text'] === '已关闭·超期 7 天' && $s['severe'] === 0, '人工关闭保留事实状态、前缀文案、不深红');
$s = C::deriveState($row(['closed_state' => 4, 'appointment_state' => 3, 'appointment_date' => '2026-10-23']), '2026-10-27', $cfg);
expect($s['state'] === C::STATE_VISITED && $s['state_text'] === '已面诊 10-23', '人工关闭后已面诊文案不加前缀');
$s = C::deriveState($row(['closed_state' => 2, 'appointment_state' => 1, 'appointment_date' => '2026-10-23']), '2026-10-24', $cfg);
expect($s['state'] === C::STATE_MISSED, 'closed_state=2 但预约日已过 → 事实推导 missed');
// ---------- escalationLevel ----------
expect(C::escalationLevel(0) === 0, 'level 0');
expect(C::escalationLevel(1) === 1 && C::escalationLevel(2) === 1, 'level 1 (1~2)');
expect(C::escalationLevel(3) === 2 && C::escalationLevel(4) === 2, 'level 2 (3~4)');
expect(C::escalationLevel(5) === 3 && C::escalationLevel(99) === 3, 'level 3 (>=5)');
expect(C::escalationLevel(-1) === 0, '负数视为 0');
// ---------- 日期工具 ----------
expect(C::dayDiff('2026-10-20', '2026-10-27') === 7 && C::dayDiff('2026-10-27', '2026-10-20') === -7 && C::dayDiff('2026-10-20', '2026-10-20') === 0, 'dayDiff 正负零');
expect(C::dayDiff('2026-02-27', '2026-03-02') === 3, 'dayDiff 跨月(2026 非闰年)');
expect(C::addDays('2026-09-22', 28) === '2026-10-20' && C::addDays('2026-12-31', 1) === '2027-01-01' && C::addDays('2026-01-01', -1) === '2025-12-31', 'addDays 跨月跨年');
expect(C::dayDiff('bad', '2026-10-20') === 0 && C::addDays('bad', 3) === 'bad', '非法日期容错');
// ---------- normalizeConfig ----------
$n = C::normalizeConfig(['anchor_chain' => ['SIGN', 'x', 'create', 'create'], 'day_buckets' => ['14', 21, 0], 'warn_days' => '2']);
expect($n['anchor_chain'] === ['sign', 'create'] && $n['day_buckets'] === [14, 21] && $n['warn_days'] === 2, 'normalizeConfig 归一');
expect($n['severe_overdue_days'] === 7 && $n['days_fallback_rx'] === true, 'normalizeConfig 补默认值');
echo "RevisitDueCalculatorTest OK ({$checks} checks)\n";
+131
View File
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
/**
* 复诊到期弹窗逻辑(RevisitDueLogic)纯函数单测,不连库、不读 config。
* php server/tests/RevisitDueLogicTest.php
* 覆盖:延迟间隔阶梯(4h→2h→1h→30min、default 回落、非法配置)、工作时段解析、
* 钳制(早于开始 → 当日开始;晚于结束 → 次日开始;边界 20:00:00 / 20:00:01)、
* nextRemindAfterSnooze 组合、承诺日 09:00、承诺日范围、提醒文案、升级等级、弹窗口径。
*/
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\tcm\RevisitDueLogic as L;
use app\common\service\tcm\RevisitDueCalculator as C;
date_default_timezone_set('Asia/Shanghai');
$checks = 0;
function expect(bool $condition, string $message): void
{
global $checks;
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
$checks++;
}
$ts = static fn (string $dt): int => (int) strtotime($dt);
$fmt = static fn (int $t): string => date('Y-m-d H:i:s', $t);
$cfg = [
'snooze_intervals' => [1 => 14400, 2 => 7200, 3 => 3600, 'default' => 1800],
'work_hours' => ['09:00', '20:00'],
'promise_max_days' => 14,
'contact_max_per_cycle' => 2,
];
// ---------- snoozeInterval ----------
expect(L::snoozeInterval(1, $cfg['snooze_intervals']) === 14400, '第 1 次延迟 4h');
expect(L::snoozeInterval(2, $cfg['snooze_intervals']) === 7200, '第 2 次延迟 2h');
expect(L::snoozeInterval(3, $cfg['snooze_intervals']) === 3600, '第 3 次延迟 1h');
expect(L::snoozeInterval(4, $cfg['snooze_intervals']) === 1800, '第 4 次起回落 default 30min');
expect(L::snoozeInterval(99, $cfg['snooze_intervals']) === 1800, '第 99 次仍 30min');
expect(L::snoozeInterval(1, ['1' => '7200', 'default' => '600']) === 7200, '字符串配置可被归一');
expect(L::snoozeInterval(5, [1 => 100]) === L::DEFAULT_SNOOZE_SECONDS, '缺 default 时回落常量 1800');
expect(L::snoozeInterval(1, null) === L::DEFAULT_SNOOZE_SECONDS, '非数组配置回落 1800');
expect(L::snoozeInterval(1, [1 => 0, 'default' => -5]) === L::DEFAULT_SNOOZE_SECONDS, '非正数回落 1800');
expect(L::snoozeInterval(0, $cfg['snooze_intervals']) === 1800, 'n=0 取 default');
// ---------- parseWorkHours ----------
expect(L::parseWorkHours(['09:00', '20:00']) === [540, 1200], '09:00~20:00 → 540~1200 分钟');
expect(L::parseWorkHours(['08:30', '18:00']) === [510, 1080], '08:30~18:00');
expect(L::parseWorkHours(['09:00', '24:00']) === [540, 1440], '结束允许 24:00');
expect(L::parseWorkHours(['20:00', '09:00']) === [540, 1200], '开始不早于结束 → 默认');
expect(L::parseWorkHours(['9', '20']) === [540, 1200], '非 HH:mm → 默认');
expect(L::parseWorkHours(null) === [540, 1200], 'null → 默认');
expect(L::parseWorkHours(['09:00']) === [540, 1200], '少于两项 → 默认');
// ---------- clampToWorkHours ----------
$wh = $cfg['work_hours'];
expect(L::clampToWorkHours($ts('2026-10-13 14:00:00'), $wh) === $ts('2026-10-13 14:00:00'), '工作时段内原样返回');
expect(L::clampToWorkHours($ts('2026-10-13 20:30:00'), $wh) === $ts('2026-10-14 09:00:00'), '20:30 → 次日 09:00');
expect(L::clampToWorkHours($ts('2026-10-13 20:00:00'), $wh) === $ts('2026-10-13 20:00:00'), '恰好 20:00:00 不顺延');
expect(L::clampToWorkHours($ts('2026-10-13 20:00:01'), $wh) === $ts('2026-10-14 09:00:00'), '20:00:01 顺延次日 09:00');
expect(L::clampToWorkHours($ts('2026-10-13 03:00:00'), $wh) === $ts('2026-10-13 09:00:00'), '凌晨 → 当日 09:00');
expect(L::clampToWorkHours($ts('2026-10-13 08:59:59'), $wh) === $ts('2026-10-13 09:00:00'), '08:59:59 → 09:00:00');
expect(L::clampToWorkHours($ts('2026-10-13 09:00:00'), $wh) === $ts('2026-10-13 09:00:00'), '恰好 09:00 原样');
expect(L::clampToWorkHours($ts('2026-10-31 23:30:00'), $wh) === $ts('2026-11-01 09:00:00'), '跨月顺延');
expect(L::clampToWorkHours($ts('2026-12-31 21:00:00'), $wh) === $ts('2027-01-01 09:00:00'), '跨年顺延');
expect(L::clampToWorkHours($ts('2026-10-13 18:30:00'), ['08:30', '18:00']) === $ts('2026-10-14 08:30:00'), '自定义时段 18:30 → 次日 08:30');
expect(L::clampToWorkHours($ts('2026-10-13 23:59:59'), ['09:00', '24:00']) === $ts('2026-10-13 23:59:59'), '24:00 结束时深夜不顺延');
// ---------- nextRemindAfterSnooze ----------
expect($fmt(L::nextRemindAfterSnooze($ts('2026-10-13 09:30:00'), 1, $cfg)) === '2026-10-13 13:30:00', '09:30 第 1 次 → 13:30');
expect($fmt(L::nextRemindAfterSnooze($ts('2026-10-13 17:30:00'), 1, $cfg)) === '2026-10-14 09:00:00', '17:30 + 4h 越过 20:00 → 次日 09:00');
expect($fmt(L::nextRemindAfterSnooze($ts('2026-10-13 18:30:00'), 3, $cfg)) === '2026-10-13 19:30:00', '18:30 第 3 次 +1h → 19:30');
expect($fmt(L::nextRemindAfterSnooze($ts('2026-10-13 19:50:00'), 4, $cfg)) === '2026-10-14 09:00:00', '19:50 第 4 次 +30min → 次日 09:00');
expect($fmt(L::nextRemindAfterSnooze($ts('2026-10-13 20:30:00'), 1, $cfg)) === '2026-10-14 09:00:00', '20:30 点延迟 → 次日 09:00(验收 §11-4-2)');
expect($fmt(L::nextRemindAfterSnooze($ts('2026-10-13 19:00:00'), 5, $cfg)) === '2026-10-13 19:30:00', '第 5 次 30min 仍在时段内');
expect(L::nextRemindAfterSnooze($ts('2026-10-13 10:00:00'), 1, []) === $ts('2026-10-13 10:30:00'), '空配置:30min + 默认时段');
expect(L::nextRemindAfterSnooze($ts('2026-10-13 10:00:00'), 1, $cfg) > $ts('2026-10-13 10:00:00'), '下次提醒恒晚于 now(条件 UPDATE 幂等前提)');
// ---------- promisedRemindAt ----------
expect($fmt(L::promisedRemindAt('2026-10-16', $wh)) === '2026-10-16 09:00:00', '承诺日 09:00');
expect($fmt(L::promisedRemindAt('2026-10-16', ['08:30', '18:00'])) === '2026-10-16 08:30:00', '承诺日取时段开始');
expect(L::promisedRemindAt('2026-13-40', $wh) === 0, '非法日期 → 0');
expect(L::promisedRemindAt('', $wh) === 0, '空 → 0');
expect($fmt(L::promisedRemindAt('2026-10-16 12:00:00', $wh)) === '2026-10-16 09:00:00', '带时间的串只取日期');
// ---------- isPromisedDateInRange ----------
expect(L::isPromisedDateInRange('2026-10-13', '2026-10-13', 14), '今天可选');
expect(L::isPromisedDateInRange('2026-10-27', '2026-10-13', 14), '今天 +14 可选');
expect(!L::isPromisedDateInRange('2026-10-28', '2026-10-13', 14), '今天 +15 不可选');
expect(!L::isPromisedDateInRange('2026-10-12', '2026-10-13', 14), '昨天不可选');
expect(!L::isPromisedDateInRange('bad', '2026-10-13', 14), '非法日期不可选');
expect(!L::isPromisedDateInRange('2026-02-30', '2026-02-13', 30), '不存在的日期不可选');
expect(L::isPromisedDateInRange('2026-10-13', '2026-10-13', 0), 'max=0 仅今天');
// ---------- remindTimeText ----------
$now = $ts('2026-10-13 10:00:00');
expect(L::remindTimeText($ts('2026-10-13 14:30:00'), $now) === '14:30', '当日 HH:mm');
expect(L::remindTimeText($ts('2026-10-14 09:00:00'), $now) === '明日 09:00', '次日前缀');
expect(L::remindTimeText($ts('2026-10-16 09:00:00'), $now) === '10-16 09:00', '更远 MM-DD HH:mm');
expect(L::remindTimeText(0, $now) === '', '0 → 空');
// ---------- escalationLevel(与 Calculator 一致) ----------
expect(C::escalationLevel(1) === 1 && C::escalationLevel(2) === 1, '1~2 次 → level 1');
expect(C::escalationLevel(3) === 2 && C::escalationLevel(4) === 2, '3~4 次 → level 2');
expect(C::escalationLevel(5) === 3, '第 5 次 → level 3 居中模态');
// ---------- toastEligible ----------
$row = static fn (array $o = []): array => array_replace([
'closed_state' => 0, 'appointment_state' => 0, 'due_date' => '2026-10-10', 'promised_date' => null,
], $o);
expect(L::toastEligible($row(), '2026-10-13'), '到期未挂号进弹窗');
expect(L::toastEligible($row(['due_date' => '2026-10-13']), '2026-10-13'), '今日到期进弹窗');
expect(!L::toastEligible($row(['due_date' => '2026-10-14']), '2026-10-13'), '未到期不进');
expect(L::toastEligible($row(['appointment_state' => 4, 'due_date' => '2026-10-20']), '2026-10-13'), '过号不受到期日限制');
expect(L::toastEligible($row(['appointment_state' => 4, 'due_date' => null]), '2026-10-13'), '过号且缺天数仍进');
expect(!L::toastEligible($row(['due_date' => null]), '2026-10-13'), '缺天数不进');
expect(!L::toastEligible($row(['promised_date' => '2026-10-13']), '2026-10-13'), '承诺日=今天不进');
expect(!L::toastEligible($row(['promised_date' => '2026-10-16']), '2026-10-13'), '承诺日未到不进');
expect(L::toastEligible($row(['promised_date' => '2026-10-12']), '2026-10-13'), '承诺日已过再弹');
expect(!L::toastEligible($row(['closed_state' => 4]), '2026-10-13'), '人工关闭不进');
expect(!L::toastEligible($row(['closed_state' => 2]), '2026-10-13'), '已挂号不进');
expect(L::toastEligible($row(['promised_date' => '0000-00-00']), '2026-10-13'), '0000-00-00 视为无承诺');
echo "RevisitDueLogicTest OK ({$checks} checks)\n";
+134
View File
@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
/**
* 复诊到期企微推送命令(RevisitDueNotify)纯函数单测,不连库、不读 config、不调企微。
* php server/tests/RevisitDueNotifyTest.php
* 覆盖:文案拼装(daily 汇总排序/截断/过号标签、self、supervisor)、每日上限与日切判断(selfSentToday / selfQuotaLeft)、
* 主管抄送去重判断(shouldCcSupervisor 阈值 + wecom_supervisor_at 日切、normalizeSupervisorIds 去重/剔除本人)、
* 工作时段判断、日切区间。
*/
require dirname(__DIR__) . '/vendor/autoload.php';
use app\command\RevisitDueNotify as N;
use app\common\service\tcm\RevisitDueCalculator as C;
date_default_timezone_set('Asia/Shanghai');
$checks = 0;
function expect(bool $condition, string $message): void
{
global $checks;
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
$checks++;
}
$ts = static fn (string $dt): int => (int) strtotime($dt);
// ---------- 日切判断 ----------
expect(N::isSameDay($ts('2026-10-14 09:00:00'), $ts('2026-10-14 23:59:59')), '同日:同一自然日');
expect(!N::isSameDay($ts('2026-10-13 23:59:59'), $ts('2026-10-14 00:00:00')), '同日:跨午夜不同日');
expect(!N::isSameDay(0, $ts('2026-10-14 09:00:00')), '同日:0 视为从未发生');
[$s, $e] = N::dayRange('2026-10-14');
expect(date('Y-m-d H:i:s', $s) === '2026-10-14 00:00:00' && date('Y-m-d H:i:s', $e) === '2026-10-14 23:59:59', 'dayRange 当日起止');
[$s2] = N::dayRange('bad');
expect(date('Y-m-d', $s2) === date('Y-m-d'), 'dayRange 非法日期回落今天');
// ---------- 每日上限 ----------
$now = $ts('2026-10-14 10:30:00');
$row = ['wecom_self_at' => 0, 'wecom_self_count_day' => 0];
expect(N::selfSentToday($row, $now) === 0 && N::selfQuotaLeft($row, $now, 2) === 2, '从未推送:剩余 = cap');
$row = ['wecom_self_at' => $ts('2026-10-14 09:10:00'), 'wecom_self_count_day' => 1];
expect(N::selfSentToday($row, $now) === 1 && N::selfQuotaLeft($row, $now, 2) === 1, '今日已推 1 次:剩余 1');
$row = ['wecom_self_at' => $ts('2026-10-14 09:10:00'), 'wecom_self_count_day' => 2];
expect(N::selfQuotaLeft($row, $now, 2) === 0, '今日已推 2 次:达上限');
$row = ['wecom_self_at' => $ts('2026-10-13 19:50:00'), 'wecom_self_count_day' => 2];
expect(N::selfSentToday($row, $now) === 0 && N::selfQuotaLeft($row, $now, 2) === 2, '昨日推满:日切重置为 cap');
$row = ['wecom_self_at' => $ts('2026-10-14 09:10:00'), 'wecom_self_count_day' => 5];
expect(N::selfQuotaLeft($row, $now, 2) === 0, '计数超过 cap 不为负');
expect(N::selfQuotaLeft(['wecom_self_at' => 0, 'wecom_self_count_day' => 0], $now, 0) === 0, 'cap=0 不推');
// ---------- 主管抄送判断 ----------
$row = ['snooze_count' => 5, 'wecom_supervisor_at' => 0];
expect(N::shouldCcSupervisor($row, $now, 5), '首次达到 5 次且未抄送 → 抄送');
$row = ['snooze_count' => 4, 'wecom_supervisor_at' => 0];
expect(!N::shouldCcSupervisor($row, $now, 5), '4 次不抄送');
$row = ['snooze_count' => 7, 'wecom_supervisor_at' => $ts('2026-10-14 09:00:00')];
expect(!N::shouldCcSupervisor($row, $now, 5), '今日已抄送 → 不重复');
$row = ['snooze_count' => 7, 'wecom_supervisor_at' => $ts('2026-10-13 09:00:00')];
expect(N::shouldCcSupervisor($row, $now, 5), '昨日抄送过 → 今日再抄送一次');
$row = ['snooze_count' => 3, 'wecom_supervisor_at' => 0];
expect(N::shouldCcSupervisor($row, $now, 3), '阈值可配置(3)');
expect(!N::shouldCcSupervisor(['snooze_count' => 0, 'wecom_supervisor_at' => 0], $now, 0), '阈值 0 按 1 处理,0 次不抄送');
expect(N::normalizeSupervisorIds([3, '3', 0, -1, 'x', 5, 7], 5) === [3, 7], '主管名单:去重、剔除非正整数、剔除医助本人');
expect(N::normalizeSupervisorIds('3, 7,,7 9', 0) === [3, 7, 9], '主管名单:逗号/空格字符串');
expect(N::normalizeSupervisorIds(null) === [] && N::normalizeSupervisorIds([]) === [] && N::normalizeSupervisorIds([5], 5) === [], '主管名单:空 / 仅本人 → 空');
// ---------- 超期标签 ----------
expect(N::overdueLabel(['state' => C::STATE_OVERDUE, 'overdue_days' => 3]) === '超期3天', '标签:超期 3 天');
expect(N::overdueLabel(['state' => C::STATE_OVERDUE, 'overdue_days' => 0]) === '今日应挂号', '标签:今日应挂号');
expect(N::overdueLabel(['state' => C::STATE_MISSED, 'overdue_days' => null]) === '过号', '标签:过号无到期日');
expect(N::overdueLabel(['state' => C::STATE_MISSED, 'overdue_days' => 2]) === '过号·超期2天', '标签:过号且超期');
expect(N::overdueLabel(['state' => C::STATE_OVERDUE, 'overdue_days' => null]) === '到期', '标签:缺天数兜底');
// ---------- daily 文案 ----------
$items = [
['patient_name' => '李四', 'state' => C::STATE_OVERDUE, 'overdue_days' => 1],
['patient_name' => '张三', 'state' => C::STATE_OVERDUE, 'overdue_days' => 3],
['patient_name' => '王五', 'state' => C::STATE_MISSED, 'overdue_days' => null],
];
$text = N::buildDailyText($items, 5);
expect(str_starts_with($text, '【复诊到期提醒】您有 3 位患者到期未挂号,最久超期 3 天:'), 'daily:头部含人数与最久超期');
expect(str_contains($text, '张三(超期3天)、李四(超期1天)、王五(过号)'), 'daily:按超期天数降序,过号排末');
expect(!str_contains($text, '等 3 人') && str_ends_with($text, N::SIGNATURE), 'daily:未超出列数不加「等 N 人」,带落款');
$many = [];
for ($i = 1; $i <= 7; $i++) {
$many[] = ['patient_name' => '患者' . $i, 'state' => C::STATE_OVERDUE, 'overdue_days' => $i];
}
$text = N::buildDailyText($many, 5);
expect(str_contains($text, '您有 7 位患者') && str_contains($text, '最久超期 7 天'), 'daily:7 人统计');
expect(str_contains($text, '患者7(超期7天)、患者6(超期6天)、患者5(超期5天)、患者4(超期4天)、患者3(超期3天)…等 7 人'), 'daily:最多列 5 人并加「等 7 人」');
expect(!str_contains($text, '患者2('), 'daily:第 6 人起不列出');
$text = N::buildDailyText([['patient_name' => '', 'state' => C::STATE_OVERDUE, 'overdue_days' => 0]], 5);
expect(str_contains($text, '您有 1 位患者到期未挂号:患者(今日应挂号)') && !str_contains($text, '最久超期'), 'daily:空姓名兜底「患者」,无超期不写「最久超期」');
expect(N::buildDailyText([], 5) === '', 'daily:空列表返回空串');
expect(str_contains(N::buildDailyText($many, 0), '…等 7 人'), 'daily:listMax<=0 按 1 处理');
// ---------- self 文案 ----------
$row = ['patient_name' => '张三', 'current_slot' => 2, 'due_date' => '2026-10-10', 'snooze_count' => 5, 'assistant_id' => 7];
$st = ['state' => C::STATE_OVERDUE, 'overdue_days' => 4];
$text = N::buildSelfText($row, $st);
expect($text === '【复诊到期提醒】患者 张三(第2诊后,预计复诊 10-10)超期4天未挂号,您已延迟 5 次,请尽快联系患者挂号。' . N::SIGNATURE, 'self 文案');
$text = N::buildSelfText(['patient_name' => '', 'current_slot' => 0, 'due_date' => null, 'snooze_count' => 6], ['state' => C::STATE_MISSED, 'overdue_days' => null]);
expect($text === '【复诊到期提醒】患者 患者(待复诊)过号未挂号,您已延迟 6 次,请尽快联系患者挂号。' . N::SIGNATURE, 'self 文案:缺姓名/诊次/到期日');
// ---------- supervisor 文案 ----------
$row = ['patient_name' => '张三', 'current_slot' => 2, 'due_date' => '2026-10-10', 'snooze_count' => 5, 'contact_count' => 1, 'assistant_id' => 7];
$text = N::buildSupervisorText('李医助', $row, $st);
expect($text === '【复诊到期升级】医助 李医助 的患者 张三 超期4天未挂号(第2诊后,预计复诊 10-10),已延迟 5 次、已联系 1 次,请关注。' . N::SIGNATURE, 'supervisor 文案:含医助、患者、超期天数、延迟/联系次数');
$text = N::buildSupervisorText('', ['patient_name' => '张三', 'current_slot' => 1, 'due_date' => '2026-10-10', 'snooze_count' => 5, 'contact_count' => 0, 'assistant_id' => 7], $st);
expect(str_contains($text, '医助 #7 的患者') && !str_contains($text, '已联系'), 'supervisor 文案:医助缺名用 #id,无联系次数不写');
// ---------- 工作时段 ----------
$wh = ['09:00', '20:00'];
expect(N::withinWorkHours($ts('2026-10-14 09:00:00'), $wh), '工作时段:09:00 含起点');
expect(N::withinWorkHours($ts('2026-10-14 19:59:59'), $wh), '工作时段:19:59 在内');
expect(!N::withinWorkHours($ts('2026-10-14 20:00:00'), $wh), '工作时段:20:00 不含终点');
expect(!N::withinWorkHours($ts('2026-10-14 08:59:59'), $wh), '工作时段:08:59 在外');
expect(N::withinWorkHours($ts('2026-10-14 03:00:00'), ['00:00', '24:00']), '工作时段:全天配置');
expect(!N::withinWorkHours($ts('2026-10-14 21:00:00'), 'bad'), '工作时段:非法配置回落 09:00~20:00');
echo "RevisitDueNotifyTest OK ({$checks} checks)\n";
+133
View File
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
/**
* 复诊接诊率 v2 源码契约(ReflectionMethod 读源码,不连库)。
* php server/tests/RevisitRateContractTest.php
* 守住:
* - 分子订单过滤仍走 YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery(RevisitVisitSlotService::fetchOrderSeqRows);
* - RevisitRateLogic::buildStatsCore 经 RevisitRateCalculator::compute 计算,订单序列仍经 fetchOrderSeqRows 委托;
* - 计算器中 slot<2 / >50 跳过、持有人指针 t <= ct、继承诊次用 RevisitVisitSlotService::slotBefore;
* - 旧口径集合只看 is_inherit;exclude 模式不进入 from==to / 回退豁免分支;
* - DiagnosisLogic::assign 继承时在事务内写 inherit_visit_slot(slotAtTime);
* - AutoAssignLogLogic::rollbackOne 回退仍写 is_inherit = 0(原逻辑不变);
* - AutoAssignPendingDiagnosis 以 inherit_mode=slot 取 overview 并按配置字段分档。
*/
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\stats\AutoAssignLogLogic;
use app\adminapi\logic\stats\RevisitRateCalculator;
use app\adminapi\logic\stats\RevisitRateLogic;
use app\adminapi\logic\tcm\DiagnosisLogic;
use app\command\AutoAssignPendingDiagnosis;
use app\common\service\tcm\RevisitVisitSlotService;
$checks = 0;
function expect(bool $condition, string $message): void
{
global $checks;
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
$checks++;
}
function methodSource(string $class, string $method): string
{
$ref = (new ReflectionClass($class))->getMethod($method);
$lines = file($ref->getFileName());
if (!is_array($lines)) {
throw new RuntimeException('method source must be readable: ' . $class . '::' . $method);
}
return implode('', array_slice($lines, $ref->getStartLine() - 1, $ref->getEndLine() - $ref->getStartLine() + 1));
}
// ---------- 分子过滤口径 ----------
$fetch = methodSource(RevisitVisitSlotService::class, 'fetchOrderSeqRows');
expect(str_contains($fetch, 'YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery'), '订单序列仍走业绩有效订单过滤');
expect(str_contains($fetch, "whereNull('o.delete_time')"), '订单序列剔除软删');
expect(str_contains($fetch, "'o.create_time' => 'asc', 'o.id' => 'asc'"), '订单序列按 (create_time, id) 升序');
$slotAt = methodSource(RevisitVisitSlotService::class, 'slotAtTime');
expect(str_contains($slotAt, 'self::fetchOrderSeqRows') && str_contains($slotAt, 'self::slotBefore'), 'slotAtTime 复用同一订单序列与 slotBefore');
$slotBefore = methodSource(RevisitVisitSlotService::class, 'slotBefore');
expect(str_contains($slotBefore, '(int) $ct < $atTs'), 'slotBefore 严格小于(与持有人 t <= ct 对偶)');
$logicFetch = methodSource(RevisitRateLogic::class, 'fetchOrderSeqRows');
expect(str_contains($logicFetch, 'RevisitVisitSlotService::fetchOrderSeqRows'), 'RevisitRateLogic::fetchOrderSeqRows 委托诊次服务');
$iter = methodSource(RevisitRateLogic::class, 'iterateOrderSeqRows');
expect(str_contains($iter, 'self::fetchOrderSeqRows'), '订单生成器经委托方法取数');
// ---------- buildStatsCore 经计算器 ----------
$core = methodSource(RevisitRateLogic::class, 'buildStatsCore');
expect(str_contains($core, 'RevisitRateCalculator::compute('), 'buildStatsCore 调用 RevisitRateCalculator::compute');
expect(str_contains($core, 'self::fetchRefundOrRejectDiagnosisSet'), '仍剔除拒收/退款诊单');
expect(str_contains($core, 'RevisitRateCalculator::monthAssignedDiagIds'), '拒收/退款剔除集合覆盖继承诊单');
expect(str_contains($core, 'self::fetchRollbackKeySet'), 'slot 模式读历史回退行');
expect(str_contains($core, "'lg.from_assistant_id'") && str_contains($core, "'lg.inherit_visit_slot'"), '字段列表含 from_assistant_id / inherit_visit_slot');
expect(str_contains($core, '$inheritEntryByAssistant[$aid]') && str_contains($core, '$excludeDiagsByAssistant[$aid]'), '二中心过滤同步 unset 继承集合与旧口径集合');
expect(str_contains($core, '$diagsByAssistant + $slotOrdersByAssistant + $inheritEntryByAssistant + $excludeDiagsByAssistant'), 'universe 并集含继承医助');
$universe = methodSource(RevisitRateLogic::class, 'assistantUniverse');
expect(str_contains($universe, "'inheritEntryByAssistant'"), 'assistantUniverse 含继承集合(当月仅有继承行的医助不丢行)');
// ---------- 计算器核心不变量 ----------
$compute = methodSource(RevisitRateCalculator::class, 'compute');
expect(str_contains($compute, '$effectiveSlot < 2 || $effectiveSlot > self::MAX_VISIT_SLOT'), 'slot<2 或 >50 跳过');
expect(str_contains($compute, "\$tl[\$ptr]['t'] <= \$ct"), '持有人指针 t <= ct(同一秒归新持有人)');
expect(str_contains($compute, '$ct < $startTs || $ct > $endTs'), '分子按当月下单过滤');
expect(str_contains($compute, 'RevisitVisitSlotService::slotBefore('), '继承诊次 k 经 slotBefore 单点计算');
expect(str_contains($compute, 'RevisitVisitSlotService::resolveRevisitSlotStartOffset('), '偏移经诊次服务钳制');
expect(str_contains($compute, 'max($inheritMinNextSlot, $k + 1)'), 'entry = max(min_next_slot, k+1)');
expect(str_contains($compute, 'if (!$isInherit) {' . "\n" . ' $excludeDiagsByAssistant[$aid][$did] = true;'), '旧口径集合仅看 is_inherit=0');
expect(str_contains($compute, 'if (!$slotMode) {') && str_contains($compute, 'if ($from === $aid) {'), 'from==to 忽略仅在 slot 分支');
expect(str_contains($compute, 'isset($diagsByAssistant[$aid][$did])') && str_contains($compute, 'unset($inheritFirst[$aid][$did])'), 'NI 与 IH 同诊单只算 NI');
$pack = methodSource(RevisitRateCalculator::class, 'metricPack');
expect(str_contains($pack, "'assigned_count' => \$newAssigned") && str_contains($pack, "'inherit_count' => \$inheritCount"), '指标包键名');
expect(str_contains($pack, "'_den'") && str_contains($pack, "'visit_all_rate'") && str_contains($pack, 'self::FIELD_RATE_NEW_ASSIGN'), '新键 den / visit_all / visit2_rate_new_assign');
expect(str_contains($pack, '$den > 0 ? round($cnt / $den * 100, 2) : null'), 'den=0 → null(AutoAssign 依赖 null 语义)');
$logicPack = methodSource(RevisitRateLogic::class, 'buildMetricPack');
expect(str_contains($logicPack, 'RevisitRateCalculator::metricPack('), 'Logic 指标包委托计算器');
$overview = methodSource(RevisitRateLogic::class, 'overview');
expect(str_contains($overview, 'RevisitRateCalculator::slotRange(') && str_contains($overview, "\$ctx['maxEntry']"), 'slots 上界含 maxEntry');
expect(str_contains($overview, 'RevisitRateCalculator::mergeInheritEntries('), '部门/合计行按 §1.1 去重');
expect(str_contains($overview, "'inherit_mode' => \$ctx['inheritMode']"), '返回体含 inherit_mode');
expect(str_contains($overview, "\$b['assigned_count'] <=> \$a['assigned_count']"), '排序仍按 assigned_count');
$mode = methodSource(RevisitRateLogic::class, 'resolveInheritMode');
expect(str_contains($mode, "config('project.revisit_rate.default_inherit_mode')") && str_contains($mode, 'RevisitRateCalculator::normalizeMode('), '口径白名单读参数/配置');
$lines = methodSource(RevisitRateLogic::class, 'assignLines');
expect(str_contains($lines, "['new', 'inherit', 'all']") && str_contains($lines, "'assign_type' => 'inherit'") && str_contains($lines, "'entry_slot'"), 'assignLines 支持 kind 并输出 assign_type/entry_slot');
$visit = methodSource(RevisitRateLogic::class, 'visitOrderLines');
expect(str_contains($visit, '$slot < 0 || $slot === 1 || $slot > self::MAX_VISIT_SLOT'), 'visitOrderLines 允许 slot=0');
// ---------- 写入侧 ----------
$assign = methodSource(DiagnosisLogic::class, 'assign');
expect(str_contains($assign, 'RevisitVisitSlotService::slotAtTime($id, $now)') && str_contains($assign, "'inherit_visit_slot'") && str_contains($assign, "'inherit_visit_slot_calc_time'"), 'assign 继承时写诊次快照');
expect(strpos($assign, 'RevisitVisitSlotService::slotAtTime') > strpos($assign, 'Db::startTrans()') && strpos($assign, 'RevisitVisitSlotService::slotAtTime') < strpos($assign, 'Db::commit()'), '快照计算在事务内');
expect(str_contains($assign, 'RevisitDueHook::onAssistantChanged'), '保留复诊到期钩子');
$rollback = methodSource(AutoAssignLogLogic::class, 'rollbackOne');
// 用户要求复诊接诊率原逻辑不变:回退行保持 is_inherit=0(新口径下由 rollbackKeySet 匹配按继承处理)
expect(str_contains($rollback, "'is_inherit' => 0"), '回退写 is_inherit=0(原逻辑)');
expect(!str_contains($rollback, 'RevisitVisitSlotService::slotAtTime'), '回退不写 inherit_visit_slot 快照');
expect(str_contains($rollback, 'RevisitDueHook::onAssistantChanged'), '回退保留复诊到期钩子');
$exec = methodSource(AutoAssignPendingDiagnosis::class, 'execute');
expect(str_contains($exec, "'inherit_mode' => RevisitRateCalculator::MODE_SLOT"), '自动分配以 slot 口径取 overview');
$tierField = methodSource(AutoAssignPendingDiagnosis::class, 'resolveTierField');
expect(str_contains($tierField, "config('project.revisit_rate.auto_assign_tier_field')"), '分档字段读配置');
$logRow = methodSource(AutoAssignPendingDiagnosis::class, 'buildLogRow');
expect(str_contains($logRow, "'visit2_rate_new_assign'") && str_contains($logRow, "'rate_version' => \$this->rateVersion"), '自动指派日志写两套率与口径版本');
$apply = methodSource(AutoAssignPendingDiagnosis::class, 'applyAssignment');
expect(str_contains($apply, 'RevisitDueHook::onAssistantChanged'), '自动分配保留复诊到期钩子');
echo "RevisitRateContractTest OK ({$checks} checks)\n";
+259
View File
@@ -0,0 +1,259 @@
<?php
declare(strict_types=1);
/**
* 复诊接诊率 v2 计算器(RevisitRateCalculator)纯函数单测,不连库。
* php server/tests/RevisitRateInheritSlotTest.php
* 覆盖(方案 §1.1 / §11 需求 1):
* 示例计算(9 月 B 继承 k=3 → entry=4)、k 计算与同一秒对偶、from==to 忽略、多次继承取最早、
* NI 与 IH 同诊单只算 NI、k=0 归二诊、历史回退行按继承处理、拒收/退款剔除同时作用于继承、
* 部门去重与 entry 取 min、slots 上界含 maxEntry、entry>50 只进合计分母、
* exclude 模式退化(IH=∅、不忽略 from==to、不豁免回退行、den=assigned_count)、visit2_rate_new_assign 两种模式一致。
*/
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\stats\RevisitRateCalculator as C;
$checks = 0;
function expect(bool $condition, string $message): void
{
global $checks;
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
$checks++;
}
$ts = static fn (string $dt): int => (int) strtotime($dt);
$monthRange = static function (string $month) use ($ts): array {
$start = $ts($month . '-01 00:00:00');
return [$start, $ts(date('Y-m-t', $start) . ' 23:59:59')];
};
/** 日志行构造:id 自增,默认 operator=100 */
$logSeq = 0;
$log = static function (int $did, int $from, int $to, string $dt, int $inherit = 0, int $op = 100) use (&$logSeq, $ts): array {
return [
'id' => ++$logSeq,
'diagnosis_id' => $did,
'from_assistant_id' => $from,
'to_assistant_id' => $to,
'operator_admin_id' => $op,
'create_time' => $ts($dt),
'is_inherit' => $inherit,
'inherit_visit_slot' => 0,
];
};
$orderSeq = 0;
$order = static function (int $did, string $dt) use (&$orderSeq, $ts): array {
return ['id' => ++$orderSeq, 'order_no' => 'N' . $orderSeq, 'diagnosis_id' => $did, 'create_time' => $ts($dt), 'amount' => 100, 'creator_id' => 0];
};
/** 日志/订单按计算器要求排序 */
$sortLogs = static function (array $rows): array {
usort($rows, static fn (array $a, array $b): int => [$a['diagnosis_id'], $a['create_time'], $a['id']] <=> [$b['diagnosis_id'], $b['create_time'], $b['id']]);
return $rows;
};
$sortOrders = static function (array $rows): array {
usort($rows, static fn (array $a, array $b): int => [$a['diagnosis_id'], $a['create_time'], $a['id']] <=> [$b['diagnosis_id'], $b['create_time'], $b['id']]);
return $rows;
};
$slotCounts = static function (array $calc, int $aid): array {
$out = [];
foreach ($calc['slotOrdersByAssistant'][$aid] ?? [] as $slot => $orders) {
$out[$slot] = \count($orders);
}
return $out;
};
// ───────────────────────── 示例计算(§1.1):A 持有 D 三单,09-10 继承给 B,09-25 B 名下第 4 单 ─────────────────────────
$A = 1;
$B = 2;
$D = 10;
[$s9, $e9] = $monthRange('2026-09');
$logs = $sortLogs([
$log($D, 0, $A, '2026-05-01 09:00:00'),
$log($D, $A, $B, '2026-09-10 10:00:00', 1),
]);
$orders = $sortOrders([
$order($D, '2026-05-03 10:00:00'),
$order($D, '2026-06-12 10:00:00'),
$order($D, '2026-08-20 10:00:00'),
$order($D, '2026-09-25 10:00:00'),
]);
$calc = C::compute($logs, $orders, [], [], [], $s9, $e9, C::MODE_SLOT);
expect($calc['inheritMode'] === 'slot', '模式归一化');
expect(!isset($calc['diagsByAssistant'][$B]), 'B 无新指派');
expect(($calc['inheritEntryByAssistant'][$B][$D] ?? null) === 4, 'k=3 → entry=4');
expect(\count($calc['inheritPairs']) === 1 && $calc['inheritPairs'][0]['inherit_visit_slot'] === 3 && $calc['inheritPairs'][0]['entry_slot'] === 4, 'inheritPairs 带 k/entry');
expect(($slotCounts($calc, $B)[4] ?? 0) === 1, '09-25 第 4 单归 B 的 visit4');
expect(!isset($calc['slotOrdersByAssistant'][$A]), 'A 9 月无成交');
expect($calc['maxEntry'] === 4, 'maxEntry=4');
$slots = C::slotRange($calc['slotOrdersByAssistant'], $calc['maxEntry']);
expect($slots === [2, 3, 4], 'slots 2~4');
$packB = C::metricPack(0, $calc['inheritEntryByAssistant'][$B], $slotCounts($calc, $B), $slots, 0);
expect($packB['assigned_count'] === 0 && $packB['inherit_count'] === 1 && $packB['assigned_total_count'] === 1, 'B 新指派 0 / 继承 1 / 合计 1');
expect($packB['visit2_den'] === 0 && $packB['visit3_den'] === 0 && $packB['visit4_den'] === 1, 'visit2/3_den 不含 D,visit4_den 含 D');
expect($packB['visit2_rate'] === null && $packB['visit4_count'] === 1 && $packB['visit4_rate'] === 100.0, 'visit2_rate=null、visit4_rate=100');
expect($packB['visit_all_count'] === 1 && $packB['visit_all_den'] === 1 && $packB['visit_all_rate'] === 100.0, '合计列 1/1=100');
expect($packB[C::FIELD_RATE_NEW_ASSIGN] === null, 'B 旧口径新指派 0 → null');
// exclude 模式退化:B assigned_count=0、visit4_count=1、visit4_rate=null(与线上现状一致)
$calcEx = C::compute($logs, $orders, [], [], [], $s9, $e9, C::MODE_EXCLUDE);
expect($calcEx['inheritEntryByAssistant'] === [] && $calcEx['inheritPairs'] === [] && $calcEx['maxEntry'] === 0, 'exclude:IH=∅');
expect(($slotCounts($calcEx, $B)[4] ?? 0) === 1, 'exclude:分子不变');
$packBEx = C::metricPack(0, [], $slotCounts($calcEx, $B), C::slotRange($calcEx['slotOrdersByAssistant'], 0), 0);
expect($packBEx['assigned_count'] === 0 && $packBEx['visit4_count'] === 1 && $packBEx['visit4_rate'] === null, 'exclude:B assigned=0、visit4_rate=null');
expect($packBEx['inherit_count'] === 0 && $packBEx['visit4_den'] === 0 && $packBEx['visit_all_den'] === 0, 'exclude:新键存在但为 0');
// 变体:继承时无任何有效订单(k=0)→ entry=2,与新指派等价
$calc0 = C::compute(
$sortLogs([$log(11, 0, $A, '2026-08-01 09:00:00'), $log(11, $A, $B, '2026-09-10 10:00:00', 1)]),
$sortOrders([$order(11, '2026-09-20 10:00:00')]),
[],
[],
[],
$s9,
$e9,
C::MODE_SLOT
);
expect(($calc0['inheritEntryByAssistant'][$B][11] ?? null) === 2, 'k=0 → entry=2');
expect($calc0['inheritPairs'][0]['inherit_visit_slot'] === 0, 'k=0 记录');
// 变体:订单与继承同一秒 → 订单归 B 且不计入 k
$calcSame = C::compute(
$sortLogs([$log(12, 0, $A, '2026-05-01 09:00:00'), $log(12, $A, $B, '2026-09-10 10:00:00', 1)]),
$sortOrders([$order(12, '2026-08-01 10:00:00'), $order(12, '2026-09-10 10:00:00')]),
[],
[],
[],
$s9,
$e9,
C::MODE_SLOT
);
expect(($calcSame['inheritEntryByAssistant'][$B][12] ?? null) === 2, '同一秒:k=1(不含同秒订单)→ entry=2');
expect(($slotCounts($calcSame, $B)[2] ?? 0) === 1 && !isset($calcSame['slotOrdersByAssistant'][$A]), '同一秒:订单归新持有人 B');
// 偏移:offset=2,继承前 1 单 → k=3,entry=4
$calcOff = C::compute(
$sortLogs([$log(13, 0, $A, '2026-05-01 09:00:00'), $log(13, $A, $B, '2026-09-10 10:00:00', 1)]),
$sortOrders([$order(13, '2026-08-01 10:00:00')]),
[13 => 2],
[],
[],
$s9,
$e9,
C::MODE_SLOT
);
expect(($calcOff['inheritEntryByAssistant'][$B][13] ?? null) === 4, '偏移 2 + 1 单 → k=3 → entry=4');
// ───────────────────────── from == to 忽略(仅 slot);exclude 不忽略 ─────────────────────────
$logsSelf = $sortLogs([
$log(20, 0, $A, '2026-09-02 09:00:00'),
$log(20, $A, $A, '2026-09-05 09:00:00'), // 指派给自己(非继承)
$log(21, $B, $B, '2026-09-06 09:00:00', 1), // 继承给自己
]);
$calcSelf = C::compute($logsSelf, [], [], [], [], $s9, $e9, C::MODE_SLOT);
expect(isset($calcSelf['diagsByAssistant'][$A][20]) && \count($calcSelf['pairsRaw']) === 1, 'slot:from==to 的非继承行忽略,不重复计 pairsRaw');
expect(!isset($calcSelf['inheritEntryByAssistant'][$B]), 'slot:from==to 的继承行忽略');
expect(isset($calcSelf['excludeDiagsByAssistant'][$A][20]) && !isset($calcSelf['excludeDiagsByAssistant'][$B]), '旧口径集合:is_inherit=0 计入、=1 不计');
$calcSelfEx = C::compute($logsSelf, [], [], [], [], $s9, $e9, C::MODE_EXCLUDE);
expect(\count($calcSelfEx['pairsRaw']) === 2 && isset($calcSelfEx['diagsByAssistant'][$A][20]), 'exclude:from==to 行照旧计入 pairsRaw');
expect($calcSelfEx['diagsByAssistant'] === $calcSelfEx['excludeDiagsByAssistant'], 'exclude:NI === NI_exclude');
// ───────────────────────── 多次继承取最早;NI 与 IH 同诊单只算 NI ─────────────────────────
$logsMulti = $sortLogs([
$log(30, 0, $A, '2026-05-01 09:00:00'),
$log(30, $A, $B, '2026-09-03 09:00:00', 1), // 最早继承:此时 1 单 → k=1 → entry=2
$log(30, $B, $A, '2026-09-10 09:00:00'),
$log(30, $A, $B, '2026-09-20 09:00:00', 1), // 第二次继承:此时 2 单 → k=2 → entry=3(应忽略)
$log(31, 0, $B, '2026-09-04 09:00:00'), // 31 新指派给 B
$log(31, $B, $A, '2026-09-05 09:00:00'),
$log(31, $A, $B, '2026-09-06 09:00:00', 1), // 31 又继承给 B → 只算 NI
]);
$ordersMulti = $sortOrders([$order(30, '2026-08-01 10:00:00'), $order(30, '2026-09-15 10:00:00')]);
$calcMulti = C::compute($logsMulti, $ordersMulti, [], [], [], $s9, $e9, C::MODE_SLOT);
expect(($calcMulti['inheritEntryByAssistant'][$B][30] ?? null) === 2, '多次继承取最早(entry=2 而非 3)');
$pair30 = array_values(array_filter($calcMulti['inheritPairs'], static fn (array $p): bool => $p['diagnosis_id'] === 30))[0];
expect($pair30['assign_count'] === 2 && $pair30['inherit_visit_slot'] === 1, '继承行 assign_count=2、k 取最早');
expect(isset($calcMulti['diagsByAssistant'][$B][31]) && !isset($calcMulti['inheritEntryByAssistant'][$B][31]), '既新指派又继承给同一医助 → 只算 NI');
expect(isset($calcMulti['diagsByAssistant'][$A][30]) && isset($calcMulti['diagsByAssistant'][$A][31]), 'A 的两条非继承指派计 NI(A)');
// 09-15 的第 2 单:持有人为 09-10 指派后的 A
expect(($slotCounts($calcMulti, $A)[2] ?? 0) === 1 && !isset($calcMulti['slotOrdersByAssistant'][$B]), '持有人按时间线:09-15 订单归 A');
// ───────────────────────── 历史回退行按继承处理(仅 slot) ─────────────────────────
$rbTime = '2026-09-12 09:00:00';
$logsRb = $sortLogs([
$log(40, 0, 5, '2026-05-01 09:00:00'), // 一中心 5 持有
$log(40, 5, $B, '2026-09-01 09:00:00', 0, 0), // 系统自动分配给 B
$log(40, $B, 5, $rbTime, 0, 77), // 回退(历史行 is_inherit=0,operator=77)
]);
$rbKeys = [C::rollbackKey(40, $ts($rbTime), 77) => true];
$ordersRb = $sortOrders([$order(40, '2026-06-01 10:00:00'), $order(40, '2026-07-01 10:00:00')]);
$calcRb = C::compute($logsRb, $ordersRb, [], [], $rbKeys, $s9, $e9, C::MODE_SLOT);
expect(isset($calcRb['diagsByAssistant'][$B][40]), '自动分配行计 NI(B)');
expect(!isset($calcRb['diagsByAssistant'][5]) && ($calcRb['inheritEntryByAssistant'][5][40] ?? null) === 3, '回退行按继承:k=2 → entry=3');
expect($calcRb['inheritPairs'][0]['is_rollback'] === 1, '回退行标记 is_rollback');
$calcRbEx = C::compute($logsRb, $ordersRb, [], [], $rbKeys, $s9, $e9, C::MODE_EXCLUDE);
expect(isset($calcRbEx['diagsByAssistant'][5][40]) && $calcRbEx['inheritEntryByAssistant'] === [], 'exclude:回退行不豁免,照旧计 NI(5)');
$calcRbNoKey = C::compute($logsRb, $ordersRb, [], [], [], $s9, $e9, C::MODE_SLOT);
expect(isset($calcRbNoKey['diagsByAssistant'][5][40]), '无回退键集合时按普通新指派');
// ───────────────────────── 拒收/退款剔除同时作用于 NI / IH / NI_exclude ─────────────────────────
$calcR = C::compute($logs, $orders, [], [$D => true], [], $s9, $e9, C::MODE_SLOT);
expect($calcR['inheritEntryByAssistant'] === [] && $calcR['inheritPairs'] === [], 'R 剔除继承诊单');
$calcR2 = C::compute($logsSelf, [], [], [20 => true], [], $s9, $e9, C::MODE_SLOT);
expect($calcR2['diagsByAssistant'] === [] && $calcR2['excludeDiagsByAssistant'] === [] && $calcR2['pairsRaw'] === [], 'R 剔除 NI 与 NI_exclude');
// ───────────────────────── 部门去重与 entry 取 min ─────────────────────────
$C_ = 3;
$ni = [$A => [100 => true, 101 => true], $B => [101 => true, 102 => true], $C_ => []];
$ih = [$A => [200 => 5, 102 => 3], $B => [200 => 3, 201 => 4], $C_ => [100 => 2]];
[$niSet, $ihEntries] = C::mergeInheritEntries($ni, $ih, [$A, $B, $C_]);
expect(\count($niSet) === 3, 'NI_dept 去重:100/101/102');
expect(!isset($ihEntries[102]) && !isset($ihEntries[100]), 'IH_dept 剔除已在 NI_dept 的诊单');
expect(($ihEntries[200] ?? null) === 3 && ($ihEntries[201] ?? null) === 4, 'entry 取部门内最小');
$deptPack = C::metricPack(\count($niSet), $ihEntries, [2 => 4, 3 => 2, 4 => 1, 5 => 1], [2, 3, 4, 5], 3);
expect($deptPack['visit2_den'] === 3 && $deptPack['visit3_den'] === 4 && $deptPack['visit4_den'] === 5 && $deptPack['visit5_den'] === 5, '部门分母逐列累计');
expect($deptPack['assigned_total_count'] === 5 && $deptPack['visit_all_count'] === 8 && $deptPack['visit_all_rate'] === 160.0, '合计列:8/5=160%(可超 100%)');
expect($deptPack[C::FIELD_RATE_NEW_ASSIGN] === round(4 / 3 * 100, 2), 'visit2_rate_new_assign = Num_2 / NI_exclude');
// 子行之和 >= 部门去重
$packA = C::metricPack(2, $ih[$A], [], [2, 3, 4, 5], 2);
$packBd = C::metricPack(2, $ih[$B], [], [2, 3, 4, 5], 2);
$packC = C::metricPack(0, $ih[$C_], [], [2, 3, 4, 5], 0);
expect($packA['visit5_den'] + $packBd['visit5_den'] + $packC['visit5_den'] >= $deptPack['visit5_den'], '部门 den <= 子行之和');
expect(C::unionDiagSets($ni, [$A, $B]) === [100 => true, 101 => true, 102 => true], 'unionDiagSets');
// ───────────────────────── slots 上界含 maxEntry;entry>50 只进合计分母 ─────────────────────────
expect(C::slotRange([], 7) === range(2, 7), '只有继承到第 6 诊(entry=7)也显示到七诊列');
expect(C::slotRange([1 => [9 => [[]]]], 0) === range(2, 9), '分子最大 slot 决定上界');
expect(C::slotRange([], 0) === [2, 3, 4], '最少到四诊');
expect(C::slotRange([], 99) === range(2, 50), '上界封顶 50');
$big = C::compute(
$sortLogs([$log(50, 0, $A, '2026-01-01 09:00:00'), $log(50, $A, $B, '2026-09-10 10:00:00', 1)]),
$sortOrders([$order(50, '2026-08-01 10:00:00')]),
[50 => 20],
[],
[],
$s9,
$e9,
C::MODE_SLOT
);
expect(($big['inheritEntryByAssistant'][$B][50] ?? null) === 22 && $big['maxEntry'] === 22, '偏移 20 + 1 单 → entry 22');
$packBig = C::metricPack(0, [50 => 60], [], [2, 3], 0);
expect($packBig['visit2_den'] === 0 && $packBig['inherit_count'] === 1 && $packBig['visit_all_den'] === 1, 'entry>50:不进诊次列、只进合计分母');
// ───────────────────────── 辅助函数 ─────────────────────────
expect(C::normalizeMode('EXCLUDE') === 'exclude' && C::normalizeMode('bogus') === 'slot' && C::normalizeMode('', 'exclude') === 'exclude', 'normalizeMode 白名单与默认');
expect(C::monthAssignedDiagIds($logsMulti, $s9, $e9) === [30, 31], 'monthAssignedDiagIds 含继承行、按月过滤');
expect(C::candidateDiagIds($logsRb) === [40], 'candidateDiagIds');
$packEmpty = C::metricPack(0, [], [], [2, 3, 4], 0);
expect($packEmpty['visit2_rate'] === null && $packEmpty['visit_all_rate'] === null && $packEmpty[C::FIELD_RATE_NEW_ASSIGN] === null, '分母 0 → null');
echo "RevisitRateInheritSlotTest OK ({$checks} checks)\n";
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
/**
* 诊次服务(RevisitVisitSlotService)纯函数单测,不连库。
* php server/tests/RevisitVisitSlotServiceTest.php
* 覆盖:slotBefore 严格小于边界(同一秒订单不计入)、偏移钳制、resolveRevisitSlotStartOffset、
* 与 RevisitRateLogic 持有人规则(t <= ct 归新持有人)的对偶关系。
*/
require dirname(__DIR__) . '/vendor/autoload.php';
use app\common\service\tcm\RevisitVisitSlotService as S;
$checks = 0;
function expect(bool $condition, string $message): void
{
global $checks;
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
$checks++;
}
$t = static fn (string $dt): int => (int) strtotime($dt);
$orders = [$t('2026-05-03 10:00:00'), $t('2026-06-12 10:00:00'), $t('2026-08-20 10:00:00')];
// ---------- slotBefore 基本 ----------
expect(S::slotBefore($orders, 0, $t('2026-09-10 10:00:00')) === 3, '三笔订单之后继承 → k=3');
expect(S::slotBefore($orders, 0, $t('2026-07-01 00:00:00')) === 2, '两笔之后 → k=2');
expect(S::slotBefore($orders, 0, $t('2026-05-03 09:59:59')) === 0, '首单之前 → k=0');
expect(S::slotBefore([], 0, $t('2026-09-10 10:00:00')) === 0, '无订单 → k=0');
expect(S::slotBefore([], 1, $t('2026-09-10 10:00:00')) === 1, '无订单有偏移 → k=offset');
// ---------- 严格小于:同一秒 ----------
expect(S::slotBefore($orders, 0, $t('2026-08-20 10:00:00')) === 2, '同一秒订单视为「指派之后」,不计入 k');
expect(S::slotBefore($orders, 0, $t('2026-08-20 10:00:01')) === 3, '晚一秒即计入');
// 对偶:持有人规则 t <= ct 归新持有人(RevisitRateLogic.php 指针 while tl[ptr].t <= ct)
$assignTs = $t('2026-08-20 10:00:00');
$orderCt = $t('2026-08-20 10:00:00');
expect(($assignTs <= $orderCt) && S::slotBefore([$orderCt], 0, $assignTs) === 0, '同一秒:订单归新持有人且不计入 k(对偶成立)');
// ---------- 偏移 ----------
expect(S::slotBefore($orders, 2, $t('2026-09-10 10:00:00')) === 5, '偏移叠加');
expect(S::slotBefore($orders, -3, $t('2026-09-10 10:00:00')) === 3, '负偏移钳到 0');
expect(S::slotBefore($orders, 99, $t('2026-09-10 10:00:00')) === 23, '偏移上限 20');
expect(S::slotBefore(['1778000000', 1779000000], 0, 1778500000) === 1, '字符串时间戳可用');
// ---------- resolveRevisitSlotStartOffset ----------
$map = [1 => 0, 2 => 1, 3 => -5, 4 => 25, 5 => '2'];
expect(S::resolveRevisitSlotStartOffset(1, $map) === 0, 'offset 0');
expect(S::resolveRevisitSlotStartOffset(2, $map) === 1, 'offset 1');
expect(S::resolveRevisitSlotStartOffset(3, $map) === 0, '负值钳 0');
expect(S::resolveRevisitSlotStartOffset(4, $map) === S::MAX_OFFSET, '超限钳 20');
expect(S::resolveRevisitSlotStartOffset(5, $map) === 2, '字符串转 int');
expect(S::resolveRevisitSlotStartOffset(999, $map) === 0, '缺键默认 0');
// ---------- current_slot / next_slot 口径(与 batchCurrentSlot 组装一致) ----------
$validCount = \count($orders);
$offset = S::resolveRevisitSlotStartOffset(2, $map);
$currentSlot = $validCount + $offset;
expect($currentSlot === 4 && $currentSlot + 1 === 5, 'current_slot = |E(D)| + off(D),next_slot = current_slot + 1');
expect(S::slotBefore($orders, $offset, PHP_INT_MAX) === $currentSlot, '未来时刻的 slot_at 等于 current_slot');
echo "RevisitVisitSlotServiceTest OK ({$checks} checks)\n";