更新
This commit is contained in:
@@ -9,6 +9,7 @@ import useAppStore from './stores/modules/app'
|
|||||||
import useSettingStore from './stores/modules/setting'
|
import useSettingStore from './stores/modules/setting'
|
||||||
import useUserStore from './stores/modules/user'
|
import useUserStore from './stores/modules/user'
|
||||||
import ChatNotifyToast from './components/chat-notify-toast/index.vue'
|
import ChatNotifyToast from './components/chat-notify-toast/index.vue'
|
||||||
|
import RevisitDueToast from './components/revisit-due-toast/index.vue'
|
||||||
|
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
@@ -58,6 +59,8 @@ watch(
|
|||||||
<el-config-provider :locale="elConfig.locale" :z-index="elConfig.zIndex">
|
<el-config-provider :locale="elConfig.locale" :z-index="elConfig.zIndex">
|
||||||
<router-view />
|
<router-view />
|
||||||
<ChatNotifyToast v-if="showChatNotifyToast" />
|
<ChatNotifyToast v-if="showChatNotifyToast" />
|
||||||
|
<!-- 复诊到期弹窗(右下角):与会话通知并列挂载,复用同一条件避免未绑企微时无意义轮询 -->
|
||||||
|
<RevisitDueToast v-if="showChatNotifyToast" />
|
||||||
</el-config-provider>
|
</el-config-provider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复诊到期弹窗(需求 4)接口:/adminapi/tcm.revisitDue/<action>
|
||||||
|
* 契约见 docs/plans/assistant-revisit-reach-reminder-2026-10-12.md §3.3。
|
||||||
|
* 写接口后端 show=1,成功提示由 utils/request 统一弹出,调用方不要重复 ElMessage。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 弹窗条目(RevisitDueLogic::formatItem) */
|
||||||
|
export interface RevisitDueToastItem {
|
||||||
|
id: number
|
||||||
|
diagnosis_id: number
|
||||||
|
assistant_id: number
|
||||||
|
patient_name: string
|
||||||
|
current_slot: number
|
||||||
|
next_slot: number
|
||||||
|
medication_days: number
|
||||||
|
/** 'YYYY-MM-DD' 或 '' */
|
||||||
|
due_date: string
|
||||||
|
overdue_days: number | null
|
||||||
|
/** overdue | missed(弹窗只含这两态) */
|
||||||
|
state: string
|
||||||
|
state_text: string
|
||||||
|
severe: 0 | 1
|
||||||
|
missed_count: number
|
||||||
|
snooze_count: number
|
||||||
|
/** 0~3,3 = 居中模态 */
|
||||||
|
escalation_level: number
|
||||||
|
contact_count: number
|
||||||
|
promised_date: string
|
||||||
|
next_remind_at: number
|
||||||
|
next_remind_at_text: string
|
||||||
|
/** 下一次点「延迟」会顺延的秒数(按钮文案用) */
|
||||||
|
next_snooze_seconds: number
|
||||||
|
appointment_date: string
|
||||||
|
closed_state: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 汇总计数(RevisitDueLogic::buildSummary) */
|
||||||
|
export interface RevisitDueToastSummary {
|
||||||
|
enabled: boolean
|
||||||
|
due_total: number
|
||||||
|
max_overdue_days: number
|
||||||
|
warning_total: number
|
||||||
|
promise_max_days: number
|
||||||
|
contact_max_per_cycle: number
|
||||||
|
work_hours: [string, string]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevisitDuePendingResult {
|
||||||
|
items: RevisitDueToastItem[]
|
||||||
|
summary: RevisitDueToastSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写接口返回处理后的当前行(幂等:被他人处理时也返回当前行) */
|
||||||
|
export type RevisitDueWriteResult = RevisitDueToastItem
|
||||||
|
|
||||||
|
export interface RevisitDueSnoozeAllResult {
|
||||||
|
items: RevisitDueToastItem[]
|
||||||
|
snoozed: number
|
||||||
|
skipped: number
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 轮询接口不走统一 code 处理:权限不足 / 开关关闭时不要每分钟弹一次错误提示 */
|
||||||
|
const POLL_OPTIONS = {
|
||||||
|
isTransformResponse: false,
|
||||||
|
ignoreCancelToken: true,
|
||||||
|
isOpenRetry: false,
|
||||||
|
retryCount: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 弹窗待办(60s 轮询):本人到期未挂号 / 过号且已到提醒时刻的项,每次返回全集。
|
||||||
|
* 非 code=1 一律 reject(调用方静默处理),避免轮询刷错误提示。
|
||||||
|
*/
|
||||||
|
export async function revisitDuePending(): Promise<RevisitDuePendingResult> {
|
||||||
|
const res = await request.get({ url: '/tcm.revisitDue/pending' }, POLL_OPTIONS)
|
||||||
|
if (res && typeof res === 'object' && Number(res.code) === 1) {
|
||||||
|
return res.data as RevisitDuePendingResult
|
||||||
|
}
|
||||||
|
return Promise.reject(res?.msg ?? 'revisit due pending failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅计数,不更新弹出时间 */
|
||||||
|
export function revisitDueSummary(): Promise<RevisitDueToastSummary> {
|
||||||
|
return request.get({ url: '/tcm.revisitDue/summary' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 延迟一次(幂等) */
|
||||||
|
export function revisitDueSnooze(params: { id: number }): Promise<RevisitDueWriteResult> {
|
||||||
|
return request.post({ url: '/tcm.revisitDue/snooze', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量延迟(仅 escalation_level<=1 的项) */
|
||||||
|
export function revisitDueSnoozeAll(params: {
|
||||||
|
ids: number[]
|
||||||
|
}): Promise<RevisitDueSnoozeAllResult> {
|
||||||
|
return request.post({ url: '/tcm.revisitDue/snoozeAll', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已联系:记录患者承诺复诊日 */
|
||||||
|
export function revisitDueContact(params: {
|
||||||
|
id: number
|
||||||
|
promised_date: string
|
||||||
|
remark?: string
|
||||||
|
}): Promise<RevisitDueWriteResult> {
|
||||||
|
return request.post({ url: '/tcm.revisitDue/contact', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 人工关闭(reason lost|other,remark 必填 ≤200) */
|
||||||
|
export function revisitDueClose(params: {
|
||||||
|
id: number
|
||||||
|
reason: 'lost' | 'other'
|
||||||
|
remark: string
|
||||||
|
}): Promise<RevisitDueWriteResult> {
|
||||||
|
return request.post({ url: '/tcm.revisitDue/close', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主管视图列表行(RevisitDueLists,按角色 data_scope 收窄) */
|
||||||
|
export interface RevisitDueListRow {
|
||||||
|
id: number
|
||||||
|
diagnosis_id: number
|
||||||
|
patient_name: string
|
||||||
|
assistant_id: number
|
||||||
|
assistant_name: string
|
||||||
|
dept_name: string
|
||||||
|
current_slot: number
|
||||||
|
next_slot: number
|
||||||
|
medication_days: number
|
||||||
|
/** 14/21/28/30 或 0=其它 */
|
||||||
|
rule_days: number
|
||||||
|
anchor_date: string
|
||||||
|
due_date: string
|
||||||
|
overdue_days: number | null
|
||||||
|
days_left: number | null
|
||||||
|
state: string
|
||||||
|
state_text: string
|
||||||
|
severe: 0 | 1
|
||||||
|
appointment_state: number
|
||||||
|
appointment_date: string
|
||||||
|
snooze_count: number
|
||||||
|
escalation_level: number
|
||||||
|
contact_count: number
|
||||||
|
promised_date: string
|
||||||
|
missed_count: number
|
||||||
|
closed_state: number
|
||||||
|
closed_state_text: string
|
||||||
|
handle_reason: string
|
||||||
|
handle_remark: string
|
||||||
|
first_shown_at_text: string
|
||||||
|
last_snoozed_at_text: string
|
||||||
|
computed_at_text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主管视图筛选参数(state:due|warning|booked|visited|contacted|no_days|pending|closed;days_bucket:14|21|28|30|other) */
|
||||||
|
export interface RevisitDueListsParams {
|
||||||
|
assistant_id?: number | ''
|
||||||
|
dept_id?: number | ''
|
||||||
|
state?: string
|
||||||
|
days_bucket?: string
|
||||||
|
min_snooze?: number | ''
|
||||||
|
keyword?: string
|
||||||
|
page_no?: number
|
||||||
|
page_size?: number
|
||||||
|
/** 1 = 导出(export-data 组件追加) */
|
||||||
|
export?: number
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主管视图:复诊到期跟进分页列表(lists/count/page_no/page_size/extend 包络,供 usePaging / export-data 消费) */
|
||||||
|
export function revisitDueLists(params: RevisitDueListsParams) {
|
||||||
|
return request.get({ url: '/tcm.revisitDue/lists', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 事件流水行(RevisitDueLogic::logs) */
|
||||||
|
export interface RevisitDueLogRow {
|
||||||
|
id: number
|
||||||
|
/** 1 延迟 2 已联系 3 人工关闭 4 自动关闭-已挂号 5 自动关闭-已面诊 6 周期重置 7 重新打开 8 企微推送 9 医助变更 10 形态升级 11 首次弹出 */
|
||||||
|
event: number
|
||||||
|
event_text: string
|
||||||
|
snooze_count_after: number
|
||||||
|
level_after: number
|
||||||
|
overdue_days: number
|
||||||
|
/** payload JSON 解析后的对象(next_remind_at / promised_date / appointment_id / order_id / remark / from_assistant 等) */
|
||||||
|
payload_arr: Record<string, any>
|
||||||
|
operator_id: number
|
||||||
|
operator_name: string
|
||||||
|
create_time_text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主管视图:某条提醒的事件流水(按 due_id,时间倒序由后端决定) */
|
||||||
|
export function revisitDueLogs(params: { due_id: number }) {
|
||||||
|
return request.get({ url: '/tcm.revisitDue/logs', params })
|
||||||
|
}
|
||||||
+50
-4
@@ -204,8 +204,26 @@ export function commissionSettlementConfirmRevoke(params: Record<string, any>) {
|
|||||||
return request.post({ url: '/stats.commissionSettlement/confirmRevoke', params })
|
return request.post({ url: '/stats.commissionSettlement/confirmRevoke', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 复诊接诊率(按月):当月 N 诊接诊率,部门 → 医助分组 + 合计;剔除「继承」指派 */
|
/**
|
||||||
export function revisitRateOverview(params: { month?: string; dept_ids?: string }) {
|
* 复诊接诊率口径:
|
||||||
|
* - slot(默认,后端 config project.revisit_rate.default_inherit_mode):继承按继承时诊次计入每列独立分母(visit{N}_den = 新指派数 + 起算诊次 ≤ N 的继承数)
|
||||||
|
* - exclude:继承不计分母(旧口径,assigned_count / visit{N}_count / visit{N}_rate 与 2026-10 前一致)
|
||||||
|
*/
|
||||||
|
export type RevisitRateInheritMode = 'exclude' | 'slot'
|
||||||
|
|
||||||
|
/** 被指派明细行类型:new 新指派 / inherit 继承 / all 两者(默认) */
|
||||||
|
export type RevisitRateAssignKind = 'new' | 'inherit' | 'all'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复诊接诊率(按月):部门 → 医助分组 + 合计。
|
||||||
|
* 返回 inherit_mode、slots,行含 assigned_count(新指派)、inherit_count、assigned_total_count、
|
||||||
|
* visit{N}_count/_den/_rate、visit_all_count/_den/_rate、visit2_rate_new_assign(旧口径二诊率)
|
||||||
|
*/
|
||||||
|
export function revisitRateOverview(params: {
|
||||||
|
month?: string
|
||||||
|
dept_ids?: string
|
||||||
|
inherit_mode?: RevisitRateInheritMode
|
||||||
|
}) {
|
||||||
return request.get({ url: '/stats.revisitRate/overview', params })
|
return request.get({ url: '/stats.revisitRate/overview', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,23 +232,29 @@ export function revisitRateDeptOptions() {
|
|||||||
return request.get({ url: '/stats.revisitRate/deptOptions' })
|
return request.get({ url: '/stats.revisitRate/deptOptions' })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 复诊接诊率:「被指派数」点击下钻(按诊单聚合明细) */
|
/**
|
||||||
|
* 复诊接诊率:「新指派数 / 继承数 / 被指派合计」点击下钻(按诊单聚合明细)。
|
||||||
|
* 行含 assign_type(new|inherit)、inherit_visit_slot(继承时诊次 k)、entry_slot(起算诊次)
|
||||||
|
*/
|
||||||
export function revisitRateAssignLines(params: {
|
export function revisitRateAssignLines(params: {
|
||||||
month?: string
|
month?: string
|
||||||
dept_ids?: string
|
dept_ids?: string
|
||||||
assistant_id?: number
|
assistant_id?: number
|
||||||
dept_id?: number
|
dept_id?: number
|
||||||
|
inherit_mode?: RevisitRateInheritMode
|
||||||
|
kind?: RevisitRateAssignKind
|
||||||
}) {
|
}) {
|
||||||
return request.get({ url: '/stats.revisitRate/assignLines', params })
|
return request.get({ url: '/stats.revisitRate/assignLines', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 复诊接诊率:「N 诊单数」点击下钻(具体订单明细) */
|
/** 复诊接诊率:「N 诊单数」点击下钻(具体订单明细);slot=0 为全部 N≥2(复诊合计单数),行含 visit_slot */
|
||||||
export function revisitRateVisitOrderLines(params: {
|
export function revisitRateVisitOrderLines(params: {
|
||||||
month?: string
|
month?: string
|
||||||
slot: number
|
slot: number
|
||||||
dept_ids?: string
|
dept_ids?: string
|
||||||
assistant_id?: number
|
assistant_id?: number
|
||||||
dept_id?: number
|
dept_id?: number
|
||||||
|
inherit_mode?: RevisitRateInheritMode
|
||||||
}) {
|
}) {
|
||||||
return request.get({ url: '/stats.revisitRate/visitOrderLines', params })
|
return request.get({ url: '/stats.revisitRate/visitOrderLines', params })
|
||||||
}
|
}
|
||||||
@@ -253,3 +277,25 @@ export function assistantPerformanceOverview(params: {
|
|||||||
}) {
|
}) {
|
||||||
return request.get({ url: '/stats.assistantPerformance/overview', params })
|
return request.get({ url: '/stats.assistantPerformance/overview', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 「我的复诊接诊率」卡片返回(RevisitRateLogic::assistantOverview,仅本人行) */
|
||||||
|
export interface AssistantRevisitRateResult {
|
||||||
|
month: string
|
||||||
|
inherit_mode: RevisitRateInheritMode
|
||||||
|
/** 诊次列范围(2~N) */
|
||||||
|
slots: number[]
|
||||||
|
/** false = 本人不属于二中心子树(复诊接诊率仅统计二中心医助) */
|
||||||
|
in_scope: boolean
|
||||||
|
/** 本人行:assigned_count / inherit_count / assigned_total_count / visit{N}_count|_den|_rate / visit_all_*;无数据为 null */
|
||||||
|
row: Record<string, any> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 医助个人业绩:我的复诊接诊率(按月,仅本人行;口径与复诊接诊率页一致,
|
||||||
|
* 继承患者按继承时诊次从第 k+1 诊起计入分母)
|
||||||
|
*/
|
||||||
|
export function assistantPerformanceRevisitRate(params: {
|
||||||
|
month?: string
|
||||||
|
}): Promise<AssistantRevisitRateResult> {
|
||||||
|
return request.get({ url: '/stats.assistantPerformance/revisitRate', params })
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,403 @@
|
|||||||
|
/**
|
||||||
|
* 复诊节点(revisit_due)前端纯函数集合
|
||||||
|
*
|
||||||
|
* 数据契约与后端 `RevisitDueService::buildListPayload` 输出一致(方案 docs/plans/assistant-revisit-reach-reminder-2026-10-12.md 契约 C):
|
||||||
|
* 所有日期为 'Y-m-d' 字符串或 '';无快照且无法计算时后端返回 state='none' 的对象,前端显示「—」。
|
||||||
|
*
|
||||||
|
* 本文件只做 state → 文案 / 颜色 class / el-tag type 的映射与日期、天数文案拼装,不含任何请求或响应式逻辑,
|
||||||
|
* 供 PC 列表、H5 卡片、只读页三处共用(RevisitNodeCell.vue + readonly.vue)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type RevisitDueState =
|
||||||
|
| 'visited'
|
||||||
|
| 'booked'
|
||||||
|
| 'missed'
|
||||||
|
| 'contacted'
|
||||||
|
| 'overdue'
|
||||||
|
| 'warning'
|
||||||
|
| 'pending'
|
||||||
|
| 'no_days'
|
||||||
|
| 'none'
|
||||||
|
|
||||||
|
/** 列表行 `revisit_due` 对象(契约 C) */
|
||||||
|
export interface RevisitDue {
|
||||||
|
current_slot: number
|
||||||
|
next_slot: number
|
||||||
|
valid_order_count: number
|
||||||
|
medication_days: number
|
||||||
|
/** 0=缺失 1=订单 2=处方 */
|
||||||
|
days_src: 0 | 1 | 2
|
||||||
|
/** 14/21/28/30 或 0=其它 */
|
||||||
|
rule_days: number
|
||||||
|
/** 0=无 1=签收 2=发货 3=下单 */
|
||||||
|
anchor_src: 0 | 1 | 2 | 3
|
||||||
|
anchor_date: string
|
||||||
|
order_date: string
|
||||||
|
due_date: string
|
||||||
|
days_left: number | null
|
||||||
|
overdue_days: number | null
|
||||||
|
state: RevisitDueState | string
|
||||||
|
state_text: string
|
||||||
|
severe: 0 | 1
|
||||||
|
/** 0=无 1=已约 3=已面诊 4=过号 */
|
||||||
|
appointment_state: number
|
||||||
|
appointment_date: string
|
||||||
|
snooze_count: number
|
||||||
|
escalation_level: number
|
||||||
|
promised_date: string
|
||||||
|
missed_count: number
|
||||||
|
first_touch_date: string
|
||||||
|
in_care_days: number | null
|
||||||
|
total_medication_days: number
|
||||||
|
closed_state: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「复诊状态」筛选下拉选项(请求参数 revisit_state;due = overdue ∪ missed) */
|
||||||
|
export const REVISIT_STATE_FILTER_OPTIONS: Array<{ label: string; value: string }> = [
|
||||||
|
{ label: '到期未挂号 / 过号', value: 'due' },
|
||||||
|
{ label: '即将到期', value: 'warning' },
|
||||||
|
{ label: '已约待面诊', value: 'booked' },
|
||||||
|
{ label: '已面诊', value: 'visited' },
|
||||||
|
{ label: '已联系', value: 'contacted' },
|
||||||
|
{ label: '缺开药天数', value: 'no_days' },
|
||||||
|
{ label: '未到期', value: 'pending' }
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 「开药天数档」筛选下拉选项(请求参数 revisit_days_bucket) */
|
||||||
|
export const REVISIT_DAYS_BUCKET_OPTIONS: Array<{ label: string; value: string }> = [
|
||||||
|
{ label: '14 天', value: '14' },
|
||||||
|
{ label: '21 天', value: '21' },
|
||||||
|
{ label: '28 天', value: '28' },
|
||||||
|
{ label: '30 天', value: '30' },
|
||||||
|
{ label: '其它天数', value: 'other' }
|
||||||
|
]
|
||||||
|
|
||||||
|
/** state → 默认文案(后端 state_text 缺失时兜底) */
|
||||||
|
const STATE_LABEL: Record<string, string> = {
|
||||||
|
visited: '已面诊',
|
||||||
|
booked: '已约',
|
||||||
|
missed: '过号',
|
||||||
|
contacted: '已联系',
|
||||||
|
overdue: '超期',
|
||||||
|
warning: '即将到期',
|
||||||
|
pending: '未到期',
|
||||||
|
no_days: '缺开药天数',
|
||||||
|
none: '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
const toInt = (v: unknown, fallback = 0): number => {
|
||||||
|
if (v === null || v === undefined || v === '') return fallback
|
||||||
|
const n = Number(v)
|
||||||
|
return Number.isFinite(n) ? Math.trunc(n) : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const toNullableInt = (v: unknown): number | null => {
|
||||||
|
if (v === null || v === undefined || v === '') return null
|
||||||
|
const n = Number(v)
|
||||||
|
return Number.isFinite(n) ? Math.trunc(n) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const toDateStr = (v: unknown): string => {
|
||||||
|
if (v === null || v === undefined) return ''
|
||||||
|
const s = String(v).trim()
|
||||||
|
if (s === '' || s === '0000-00-00') return ''
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把列表行 / 详情里的 `revisit_due` 原始值规整为 RevisitDue;
|
||||||
|
* 字段缺失(旧接口)或非对象时返回 null,调用方据此不渲染。
|
||||||
|
*/
|
||||||
|
export function normalizeRevisitDue(raw: unknown): RevisitDue | null {
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
||||||
|
const r = raw as Record<string, unknown>
|
||||||
|
const state = String(r.state ?? 'none').trim() || 'none'
|
||||||
|
const daysSrc = toInt(r.days_src)
|
||||||
|
const anchorSrc = toInt(r.anchor_src)
|
||||||
|
return {
|
||||||
|
current_slot: toInt(r.current_slot),
|
||||||
|
next_slot: toInt(r.next_slot),
|
||||||
|
valid_order_count: toInt(r.valid_order_count),
|
||||||
|
medication_days: toInt(r.medication_days),
|
||||||
|
days_src: (daysSrc === 1 || daysSrc === 2 ? daysSrc : 0) as 0 | 1 | 2,
|
||||||
|
rule_days: toInt(r.rule_days),
|
||||||
|
anchor_src: (anchorSrc >= 1 && anchorSrc <= 3 ? anchorSrc : 0) as 0 | 1 | 2 | 3,
|
||||||
|
anchor_date: toDateStr(r.anchor_date),
|
||||||
|
order_date: toDateStr(r.order_date),
|
||||||
|
due_date: toDateStr(r.due_date),
|
||||||
|
days_left: toNullableInt(r.days_left),
|
||||||
|
overdue_days: toNullableInt(r.overdue_days),
|
||||||
|
state,
|
||||||
|
state_text: String(r.state_text ?? '').trim(),
|
||||||
|
severe: toInt(r.severe) === 1 ? 1 : 0,
|
||||||
|
appointment_state: toInt(r.appointment_state),
|
||||||
|
appointment_date: toDateStr(r.appointment_date),
|
||||||
|
snooze_count: toInt(r.snooze_count),
|
||||||
|
escalation_level: toInt(r.escalation_level),
|
||||||
|
promised_date: toDateStr(r.promised_date),
|
||||||
|
missed_count: toInt(r.missed_count),
|
||||||
|
first_touch_date: toDateStr(r.first_touch_date),
|
||||||
|
in_care_days: toNullableInt(r.in_care_days),
|
||||||
|
total_medication_days: toInt(r.total_medication_days),
|
||||||
|
closed_state: toInt(r.closed_state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否有可展示的复诊节点(state !== 'none') */
|
||||||
|
export function hasRevisitDue(due: RevisitDue | null | undefined): due is RevisitDue {
|
||||||
|
return !!due && due.state !== 'none' && due.state !== ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行级标红:到期未挂号 / 过号未面诊;人工关闭或已自动关闭(closed_state!==0)的行不标红,与「复诊到期」chip / 筛选口径一致 */
|
||||||
|
export function isRevisitDueHighlight(due: RevisitDue | null | undefined): boolean {
|
||||||
|
return (
|
||||||
|
hasRevisitDue(due) &&
|
||||||
|
toInt(due.closed_state) === 0 &&
|
||||||
|
(due.state === 'overdue' || due.state === 'missed')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 深红:超期达到 severe_overdue_days(后端已算好 severe=1) */
|
||||||
|
export function isRevisitDueSevere(due: RevisitDue | null | undefined): boolean {
|
||||||
|
return isRevisitDueHighlight(due) && Number(due?.severe) === 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* el-table :row-class-name 片段:
|
||||||
|
* '' | 'row-revisit-overdue' | 'row-revisit-overdue row-revisit-overdue-deep'
|
||||||
|
*/
|
||||||
|
export function revisitRowClassName(due: RevisitDue | null | undefined): string {
|
||||||
|
if (!isRevisitDueHighlight(due)) return ''
|
||||||
|
return isRevisitDueSevere(due) ? 'row-revisit-overdue row-revisit-overdue-deep' : 'row-revisit-overdue'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 'YYYY-MM-DD' → 'MM-DD';非法/空值返回 '' */
|
||||||
|
export function formatRevisitMonthDay(date: string | null | undefined): string {
|
||||||
|
const s = toDateStr(date)
|
||||||
|
if (!s) return ''
|
||||||
|
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s)
|
||||||
|
if (m) return `${m[2]}-${m[3]}`
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用药起算锚点来源文案(行 2 末尾) */
|
||||||
|
export function revisitAnchorSrcText(src: number | null | undefined): string {
|
||||||
|
const n = toInt(src)
|
||||||
|
if (n === 1) return '签收起'
|
||||||
|
if (n === 2) return '发货起'
|
||||||
|
if (n === 3) return '下单起'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 开药天数来源文案(tooltip 用) */
|
||||||
|
export function revisitDaysSrcText(src: number | null | undefined): string {
|
||||||
|
const n = toInt(src)
|
||||||
|
if (n === 1) return '订单'
|
||||||
|
if (n === 2) return '处方'
|
||||||
|
return '缺失'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否缺开药天数(days_src=0 或 medication_days<=0) */
|
||||||
|
export function isRevisitDaysMissing(due: RevisitDue | null | undefined): boolean {
|
||||||
|
if (!hasRevisitDue(due)) return false
|
||||||
|
return toInt(due.days_src) === 0 || toInt(due.medication_days) <= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「第 N 诊」文案 */
|
||||||
|
export function revisitSlotText(due: RevisitDue | null | undefined): string {
|
||||||
|
if (!hasRevisitDue(due)) return ''
|
||||||
|
const slot = toInt(due.current_slot)
|
||||||
|
return slot > 0 ? `第 ${slot} 诊` : '未成交'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「开药 X 天」或「缺开药天数」 */
|
||||||
|
export function revisitMedicationDaysText(due: RevisitDue | null | undefined): string {
|
||||||
|
if (!hasRevisitDue(due)) return ''
|
||||||
|
if (isRevisitDaysMissing(due)) return '缺开药天数'
|
||||||
|
return `开药 ${toInt(due.medication_days)} 天`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「药尽 MM-DD」;缺天数无 due_date 时返回 '' */
|
||||||
|
export function revisitDueDateText(due: RevisitDue | null | undefined): string {
|
||||||
|
if (!hasRevisitDue(due)) return ''
|
||||||
|
const md = formatRevisitMonthDay(due.due_date)
|
||||||
|
return md ? `药尽 ${md}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** state 兜底文案(后端 state_text 为空时使用) */
|
||||||
|
export function revisitStateLabel(state: string | null | undefined): string {
|
||||||
|
const key = String(state ?? '').trim()
|
||||||
|
return STATE_LABEL[key] ?? (key || '—')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态 chip 文案:优先后端 state_text;为空时按 state + 数字自行拼装,
|
||||||
|
* 保证旧接口 / 字段缺失时也可读。
|
||||||
|
*/
|
||||||
|
export function revisitStateChipText(due: RevisitDue | null | undefined): string {
|
||||||
|
if (!hasRevisitDue(due)) return ''
|
||||||
|
if (due.state_text) return due.state_text
|
||||||
|
const state = String(due.state)
|
||||||
|
switch (state) {
|
||||||
|
case 'overdue': {
|
||||||
|
const n = toInt(due.overdue_days)
|
||||||
|
return n <= 0 ? '今日应挂号' : `超期 ${n} 天`
|
||||||
|
}
|
||||||
|
case 'missed': {
|
||||||
|
const md = formatRevisitMonthDay(due.appointment_date)
|
||||||
|
return md ? `过号 ${md}` : '过号'
|
||||||
|
}
|
||||||
|
case 'booked': {
|
||||||
|
const md = formatRevisitMonthDay(due.appointment_date)
|
||||||
|
return md ? `已约 ${md}` : '已约'
|
||||||
|
}
|
||||||
|
case 'visited': {
|
||||||
|
const md = formatRevisitMonthDay(due.appointment_date)
|
||||||
|
return md ? `已面诊 ${md}` : '已面诊'
|
||||||
|
}
|
||||||
|
case 'contacted': {
|
||||||
|
const md = formatRevisitMonthDay(due.promised_date)
|
||||||
|
return md ? `已联系·约 ${md}` : '已联系'
|
||||||
|
}
|
||||||
|
case 'warning':
|
||||||
|
case 'pending': {
|
||||||
|
const n = due.days_left
|
||||||
|
return n === null ? revisitStateLabel(state) : `剩 ${n} 天`
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return revisitStateLabel(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 状态 chip 的 el-tag type */
|
||||||
|
export function revisitStateTagType(
|
||||||
|
state: string | null | undefined
|
||||||
|
): 'danger' | 'warning' | 'primary' | 'success' | 'info' {
|
||||||
|
switch (String(state ?? '')) {
|
||||||
|
case 'overdue':
|
||||||
|
case 'missed':
|
||||||
|
return 'danger'
|
||||||
|
case 'warning':
|
||||||
|
case 'no_days':
|
||||||
|
return 'warning'
|
||||||
|
case 'booked':
|
||||||
|
case 'contacted':
|
||||||
|
return 'primary'
|
||||||
|
case 'visited':
|
||||||
|
return 'success'
|
||||||
|
default:
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 状态 chip 的 el-tag effect:红为 dark;描边态(已联系 / 缺天数)为 plain;其余 light */
|
||||||
|
export function revisitStateTagEffect(state: string | null | undefined): 'dark' | 'light' | 'plain' {
|
||||||
|
switch (String(state ?? '')) {
|
||||||
|
case 'overdue':
|
||||||
|
case 'missed':
|
||||||
|
return 'dark'
|
||||||
|
case 'contacted':
|
||||||
|
case 'no_days':
|
||||||
|
return 'plain'
|
||||||
|
default:
|
||||||
|
return 'light'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态 → 徽标颜色 class(复用列表 / 只读页既有的 unserved-* 色板):
|
||||||
|
* overdue/missed → unserved-red;warning/no_days → unserved-orange;
|
||||||
|
* visited → unserved-green;booked/contacted → unserved-blue;其余 → unserved-muted
|
||||||
|
*/
|
||||||
|
export function revisitStateColorClass(
|
||||||
|
state: string | null | undefined
|
||||||
|
): 'unserved-red' | 'unserved-orange' | 'unserved-green' | 'unserved-blue' | 'unserved-muted' {
|
||||||
|
switch (String(state ?? '')) {
|
||||||
|
case 'overdue':
|
||||||
|
case 'missed':
|
||||||
|
return 'unserved-red'
|
||||||
|
case 'warning':
|
||||||
|
case 'no_days':
|
||||||
|
return 'unserved-orange'
|
||||||
|
case 'visited':
|
||||||
|
return 'unserved-green'
|
||||||
|
case 'booked':
|
||||||
|
case 'contacted':
|
||||||
|
return 'unserved-blue'
|
||||||
|
default:
|
||||||
|
return 'unserved-muted'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 状态 chip 附加 class(组件内样式钩子):rv-chip rv-chip--<state> [rv-chip--severe] */
|
||||||
|
export function revisitStateChipClass(due: RevisitDue | null | undefined): string {
|
||||||
|
if (!hasRevisitDue(due)) return 'rv-chip'
|
||||||
|
const cls = ['rv-chip', `rv-chip--${String(due.state)}`]
|
||||||
|
if (isRevisitDueSevere(due)) cls.push('rv-chip--severe')
|
||||||
|
return cls.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行 4:「已延迟 N 次」 */
|
||||||
|
export function revisitSnoozeText(due: RevisitDue | null | undefined): string {
|
||||||
|
if (!hasRevisitDue(due)) return ''
|
||||||
|
const n = toInt(due.snooze_count)
|
||||||
|
return n > 0 ? `已延迟 ${n} 次` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行 4:「已联系 约 MM-DD」 */
|
||||||
|
export function revisitContactText(due: RevisitDue | null | undefined): string {
|
||||||
|
if (!hasRevisitDue(due)) return ''
|
||||||
|
const md = formatRevisitMonthDay(due.promised_date)
|
||||||
|
return md ? `已联系 约 ${md}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行 4 合并文案:仅 snooze_count>0 或 promised_date 有值时非空 */
|
||||||
|
export function revisitFollowLineText(due: RevisitDue | null | undefined): string {
|
||||||
|
const parts = [revisitSnoozeText(due), revisitContactText(due)].filter((s) => s !== '')
|
||||||
|
return parts.join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行 2:「MM-DD 开药 · 开药 X 天 · 签收起」 */
|
||||||
|
export function revisitOrderLineText(due: RevisitDue | null | undefined): string {
|
||||||
|
if (!hasRevisitDue(due)) return ''
|
||||||
|
const parts: string[] = []
|
||||||
|
const md = formatRevisitMonthDay(due.order_date)
|
||||||
|
if (md) parts.push(`${md} 开药`)
|
||||||
|
parts.push(revisitMedicationDaysText(due))
|
||||||
|
if (!isRevisitDaysMissing(due)) {
|
||||||
|
const anchor = revisitAnchorSrcText(due.anchor_src)
|
||||||
|
if (anchor) parts.push(anchor)
|
||||||
|
}
|
||||||
|
return parts.filter((s) => s !== '').join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 诊次 tag tooltip 行 */
|
||||||
|
export function revisitTooltipLines(due: RevisitDue | null | undefined): string[] {
|
||||||
|
if (!hasRevisitDue(due)) return []
|
||||||
|
const lines: string[] = []
|
||||||
|
const offset = Math.max(0, toInt(due.current_slot) - toInt(due.valid_order_count))
|
||||||
|
lines.push(`有效订单 ${toInt(due.valid_order_count)} 笔 · 诊次偏移 +${offset}`)
|
||||||
|
if (toInt(due.next_slot) > 0) lines.push(`待复诊:第 ${toInt(due.next_slot)} 诊`)
|
||||||
|
const first = formatRevisitMonthDay(due.first_touch_date)
|
||||||
|
if (first) {
|
||||||
|
const care = due.in_care_days
|
||||||
|
lines.push(care === null ? `首次触达 ${first}` : `首次触达 ${first} · 在管 ${care} 天`)
|
||||||
|
}
|
||||||
|
if (toInt(due.total_medication_days) > 0) lines.push(`累计开药 ${toInt(due.total_medication_days)} 天`)
|
||||||
|
const anchorMd = formatRevisitMonthDay(due.anchor_date)
|
||||||
|
const anchorSrc = revisitAnchorSrcText(due.anchor_src)
|
||||||
|
if (anchorMd && anchorSrc) lines.push(`用药起算 ${anchorMd}(${anchorSrc.replace(/起$/, '')})`)
|
||||||
|
if (!isRevisitDaysMissing(due)) lines.push(`开药天数来源:${revisitDaysSrcText(due.days_src)}`)
|
||||||
|
if (toInt(due.missed_count) > 0) lines.push(`曾过号 ${toInt(due.missed_count)} 次`)
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 只读页徽标文案:「第 N 诊 · 药尽 MM-DD · 超期 N 天」
|
||||||
|
* 无 due_date 时省略中段;尾段为状态 chip 文案。
|
||||||
|
*/
|
||||||
|
export function revisitBadgeText(due: RevisitDue | null | undefined): string {
|
||||||
|
if (!hasRevisitDue(due)) return ''
|
||||||
|
const parts = [revisitSlotText(due), revisitDueDateText(due), revisitStateChipText(due)]
|
||||||
|
return parts.filter((s) => s !== '').join(' · ')
|
||||||
|
}
|
||||||
@@ -56,6 +56,70 @@
|
|||||||
/>
|
/>
|
||||||
<el-empty v-else description="暂无数据" />
|
<el-empty v-else description="暂无数据" />
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 我的复诊接诊率(方案 §10 D1-h):仅本人行,口径与「复诊接诊率」报表一致 -->
|
||||||
|
<el-card v-if="canViewRevisitRate" class="!border-none mt-4" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-header-left">
|
||||||
|
<span class="card-title">我的复诊接诊率</span>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="revisitMonth"
|
||||||
|
type="month"
|
||||||
|
value-format="YYYY-MM"
|
||||||
|
placeholder="选择月份"
|
||||||
|
:clearable="false"
|
||||||
|
:disabled-date="disableFutureMonth"
|
||||||
|
size="small"
|
||||||
|
style="width: 130px"
|
||||||
|
@change="fetchRevisitRate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="card-hint">{{ revisitMonthText }} · 与「复诊接诊率」报表同口径</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-loading="revisitLoading" class="revisit-rate-body">
|
||||||
|
<template v-if="revisitRow">
|
||||||
|
<div class="revisit-kpi-grid">
|
||||||
|
<div class="revisit-kpi-card">
|
||||||
|
<div class="stats-kpi-label">新指派数</div>
|
||||||
|
<div class="revisit-kpi-value">
|
||||||
|
{{ Number(revisitRow.assigned_count ?? 0) }}
|
||||||
|
</div>
|
||||||
|
<div class="revisit-kpi-sub">当月新指派给我的患者</div>
|
||||||
|
</div>
|
||||||
|
<div class="revisit-kpi-card">
|
||||||
|
<div class="stats-kpi-label">继承数</div>
|
||||||
|
<div class="revisit-kpi-value">
|
||||||
|
{{ Number(revisitRow.inherit_count ?? 0) }}
|
||||||
|
</div>
|
||||||
|
<div class="revisit-kpi-sub">当月继承给我的患者</div>
|
||||||
|
</div>
|
||||||
|
<div class="revisit-kpi-card is-primary">
|
||||||
|
<div class="stats-kpi-label">复诊合计接诊率</div>
|
||||||
|
<div class="revisit-kpi-value">
|
||||||
|
{{ formatRate(revisitRow.visit_all_rate) }}
|
||||||
|
</div>
|
||||||
|
<div class="revisit-kpi-sub">{{ visitAllFractionText }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-for="slot in revisitSlots" :key="slot" class="revisit-kpi-card">
|
||||||
|
<div class="stats-kpi-label">{{ slotLabel(slot) }}接诊率</div>
|
||||||
|
<div class="revisit-kpi-value">
|
||||||
|
{{ formatRate(slotMetric(slot, 'rate')) }}
|
||||||
|
</div>
|
||||||
|
<div class="revisit-kpi-sub">{{ slotFractionText(slot) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-empty v-else :description="revisitEmptyText" :image-size="72" />
|
||||||
|
<div class="revisit-caliber">
|
||||||
|
口径:N 诊接诊率 = 当月 N 诊成交单数 ÷ N 诊分母(新指派数 + 起算诊次 ≤ N 的继承数),分母为 0 显示「—」;
|
||||||
|
<template v-if="revisitInheritMode === 'exclude'">当前为旧口径,继承患者不计入分母;</template>
|
||||||
|
<template v-else>继承患者按继承时诊次从第 k+1 诊起计入;</template>
|
||||||
|
复诊合计 = 各诊次成交单数之和 ÷ 被指派合计(新指派 ∪ 继承),可超过 100%。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -63,7 +127,12 @@
|
|||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import vCharts from 'vue-echarts'
|
import vCharts from 'vue-echarts'
|
||||||
import { assistantPerformanceOverview } from '@/api/stats'
|
import {
|
||||||
|
assistantPerformanceOverview,
|
||||||
|
assistantPerformanceRevisitRate,
|
||||||
|
type AssistantRevisitRateResult
|
||||||
|
} from '@/api/stats'
|
||||||
|
import { hasPermission } from '@/utils/perm'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const dateRange = ref<string[]>([])
|
const dateRange = ref<string[]>([])
|
||||||
@@ -191,8 +260,78 @@ const handleReset = () => {
|
|||||||
fetchData()
|
fetchData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 我的复诊接诊率(方案 §10 D1-h)────────────────────────
|
||||||
|
/** 权限点由 server/sql/1.9.20261012/10_add_assistant_revisit_rate_menu.sql 登记并自动授予已有个人业绩页的角色 */
|
||||||
|
const canViewRevisitRate = hasPermission(['stats.assistantPerformance/revisitRate'])
|
||||||
|
|
||||||
|
const initNow = new Date()
|
||||||
|
const currentMonth = `${initNow.getFullYear()}-${String(initNow.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
const revisitMonth = ref<string>(currentMonth)
|
||||||
|
const revisitLoading = ref(false)
|
||||||
|
const revisitResult = ref<AssistantRevisitRateResult | null>(null)
|
||||||
|
|
||||||
|
const revisitRow = computed(() => revisitResult.value?.row ?? null)
|
||||||
|
const revisitInheritMode = computed(() => revisitResult.value?.inherit_mode ?? 'slot')
|
||||||
|
const revisitSlots = computed(() =>
|
||||||
|
(revisitResult.value?.slots ?? []).map((s) => Number(s)).filter((s) => s >= 2)
|
||||||
|
)
|
||||||
|
const revisitMonthText = computed(() => {
|
||||||
|
const [y, m] = (revisitResult.value?.month || revisitMonth.value).split('-')
|
||||||
|
return y && m ? `${y} 年 ${Number(m)} 月` : revisitMonth.value
|
||||||
|
})
|
||||||
|
/** row=null:非二中心医助(in_scope=false)与「当月无被指派患者」文案区分 */
|
||||||
|
const revisitEmptyText = computed(() => {
|
||||||
|
if (revisitResult.value && revisitResult.value.in_scope === false) {
|
||||||
|
return '暂无数据(仅二中心医助统计)'
|
||||||
|
}
|
||||||
|
return revisitMonth.value === currentMonth ? '本月暂无被指派患者' : '该月暂无被指派患者'
|
||||||
|
})
|
||||||
|
|
||||||
|
const disableFutureMonth = (date: Date) => {
|
||||||
|
const now = new Date()
|
||||||
|
return date.getFullYear() * 12 + date.getMonth() > now.getFullYear() * 12 + now.getMonth()
|
||||||
|
}
|
||||||
|
|
||||||
|
const cnDigits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
|
||||||
|
const slotLabel = (slot: number) => {
|
||||||
|
if (slot >= 2 && slot <= 10) return `${cnDigits[slot]}诊`
|
||||||
|
if (slot > 10 && slot < 20) return `十${cnDigits[slot - 10]}诊`
|
||||||
|
return `${slot}诊`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatRate = (rate: unknown) =>
|
||||||
|
rate === null || rate === undefined || rate === '' ? '—' : `${Number(rate)}%`
|
||||||
|
|
||||||
|
/** 读取本人行的 visit{N}_count / visit{N}_den / visit{N}_rate */
|
||||||
|
const slotMetric = (slot: number, kind: 'count' | 'den' | 'rate') =>
|
||||||
|
revisitRow.value?.[`visit${slot}_${kind}`]
|
||||||
|
|
||||||
|
/** 「成交 N 单 / 分母 M」 */
|
||||||
|
const fractionText = (count: unknown, den: unknown) =>
|
||||||
|
`成交 ${Number(count ?? 0)} 单 / 分母 ${Number(den ?? 0)}`
|
||||||
|
|
||||||
|
const slotFractionText = (slot: number) =>
|
||||||
|
fractionText(slotMetric(slot, 'count'), slotMetric(slot, 'den'))
|
||||||
|
|
||||||
|
const visitAllFractionText = computed(() =>
|
||||||
|
fractionText(revisitRow.value?.visit_all_count, revisitRow.value?.visit_all_den)
|
||||||
|
)
|
||||||
|
|
||||||
|
const fetchRevisitRate = async () => {
|
||||||
|
if (!canViewRevisitRate) return
|
||||||
|
revisitLoading.value = true
|
||||||
|
try {
|
||||||
|
revisitResult.value = await assistantPerformanceRevisitRate({ month: revisitMonth.value })
|
||||||
|
} catch {
|
||||||
|
// 错误提示由 utils/request 统一弹出;失败时保留上一次结果
|
||||||
|
} finally {
|
||||||
|
revisitLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchData()
|
fetchData()
|
||||||
|
fetchRevisitRate()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -270,4 +409,57 @@ onMounted(() => {
|
|||||||
.stats-chart {
|
.stats-chart {
|
||||||
height: 360px;
|
height: 360px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revisit-rate-body {
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revisit-kpi-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revisit-kpi-card {
|
||||||
|
background: linear-gradient(145deg, #ffffff, #f7f9ff);
|
||||||
|
border: 1px solid #ebf1ff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revisit-kpi-card.is-primary {
|
||||||
|
background: linear-gradient(145deg, #ffffff, #eef3ff);
|
||||||
|
border-color: #d9e4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revisit-kpi-value {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.1;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revisit-kpi-card.is-primary .revisit-kpi-value {
|
||||||
|
color: #4a78ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revisit-kpi-sub {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revisit-caliber {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -67,7 +67,10 @@
|
|||||||
</el-form>
|
</el-form>
|
||||||
<div class="log-hint">
|
<div class="log-hint">
|
||||||
由每日定时任务「待分配诊单自动指派」写入:按上月二诊复诊接诊率分档轮询分配(>70% 每人3条/日,60%~70%
|
由每日定时任务「待分配诊单自动指派」写入:按上月二诊复诊接诊率分档轮询分配(>70% 每人3条/日,60%~70%
|
||||||
每人2条/日,50%~60% 每人1条/日,<50% 不分)。「回退」可将已分配医助撤回到自动分配前的原医助(通常为一中心),并写入指派日志。
|
每人2条/日,50%~60% 每人1条/日,<50% 不分)。「回退」可将已分配医助撤回到自动分配前的原医助(通常为一中心),并写入指派日志(原医助重新接手按「继承」记录)。
|
||||||
|
<br />
|
||||||
|
口径版本:<b>v1 旧口径</b> = 分母仅新指派(visit2_rate_new_assign);<b>v2 新口径</b> = 每列独立分母,继承按继承时诊次计入(visit2_rate)。
|
||||||
|
每条日志同时记录两套率并标注本批次分档所用口径(配置 project.revisit_rate.auto_assign_tier_field);2026-10 前的历史行仅有旧口径率。
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
@@ -137,7 +140,7 @@
|
|||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<template v-if="Number(row.assistant_id) > 0">
|
<template v-if="Number(row.assistant_id) > 0">
|
||||||
<div>{{ row.assistant_name || '#' + row.assistant_id }}</div>
|
<div>{{ row.assistant_name || '#' + row.assistant_id }}</div>
|
||||||
<div class="cell-sub">上月二诊接诊率 {{ formatRate(row.visit2_rate) }}</div>
|
<div class="cell-sub">上月二诊接诊率 {{ formatRate(tierRate(row)) }}</div>
|
||||||
</template>
|
</template>
|
||||||
<span v-else>—</span>
|
<span v-else>—</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -145,6 +148,29 @@
|
|||||||
<el-table-column label="档位" width="100" align="center">
|
<el-table-column label="档位" width="100" align="center">
|
||||||
<template #default="{ row }">{{ row.tier_text || '—' }}</template>
|
<template #default="{ row }">{{ row.tier_text || '—' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="分档口径" min-width="150">
|
||||||
|
<template #header>
|
||||||
|
<span>分档口径</span>
|
||||||
|
<el-tooltip
|
||||||
|
placement="top"
|
||||||
|
effect="dark"
|
||||||
|
content="本批次分档所用口径;下方为两套口径的上月二诊接诊率(新口径 = 每列独立分母 / 旧口径 = 分母仅新指派)"
|
||||||
|
>
|
||||||
|
<el-icon class="col-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<template v-if="Number(row.action) === 1">
|
||||||
|
<el-tag :type="row.rate_version === 'v2' ? 'primary' : 'info'" size="small">
|
||||||
|
{{ row.rate_version_text || row.rate_version || 'v1' }}
|
||||||
|
</el-tag>
|
||||||
|
<div class="cell-sub">
|
||||||
|
新口径 {{ formatRate(row.visit2_rate) }} · 旧口径 {{ formatRate(row.visit2_rate_new_assign) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<span v-else class="cell-sub">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="轮次" width="70" align="center">
|
<el-table-column label="轮次" width="70" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ Number(row.round_no) > 0 ? '第' + row.round_no + '轮' : '—' }}
|
{{ Number(row.round_no) > 0 ? '第' + row.round_no + '轮' : '—' }}
|
||||||
@@ -186,6 +212,7 @@
|
|||||||
import { autoAssignLogLists, autoAssignLogRollback } from '@/api/stats'
|
import { autoAssignLogLists, autoAssignLogRollback } from '@/api/stats'
|
||||||
import { usePaging } from '@/hooks/usePaging'
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
import feedback from '@/utils/feedback'
|
import feedback from '@/utils/feedback'
|
||||||
|
import { InfoFilled } from '@element-plus/icons-vue'
|
||||||
import { onMounted, reactive, ref, watch } from 'vue'
|
import { onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
|
||||||
const dateRange = ref<[string, string] | null>(null)
|
const dateRange = ref<[string, string] | null>(null)
|
||||||
@@ -241,6 +268,14 @@ const handleReset = () => {
|
|||||||
const formatRate = (rate: number | null | undefined) =>
|
const formatRate = (rate: number | null | undefined) =>
|
||||||
rate === null || rate === undefined ? '—' : `${rate}%`
|
rate === null || rate === undefined ? '—' : `${rate}%`
|
||||||
|
|
||||||
|
/** 「医助」列显示的接诊率 = 本批次分档所用口径的率(v1 旧口径取 visit2_rate_new_assign,历史行无该列时回落 visit2_rate) */
|
||||||
|
const tierRate = (row: any): number | null | undefined => {
|
||||||
|
if (row?.rate_version === 'v1' && row?.visit2_rate_new_assign !== null && row?.visit2_rate_new_assign !== undefined) {
|
||||||
|
return row.visit2_rate_new_assign
|
||||||
|
}
|
||||||
|
return row?.visit2_rate
|
||||||
|
}
|
||||||
|
|
||||||
const rowSelectable = (row: any) => Number(row.can_rollback) === 1
|
const rowSelectable = (row: any) => Number(row.can_rollback) === 1
|
||||||
|
|
||||||
const onSelectionChange = (rows: any[]) => {
|
const onSelectionChange = (rows: any[]) => {
|
||||||
@@ -315,5 +350,12 @@ onMounted(() => getLists())
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.col-hint {
|
||||||
|
margin-left: 2px;
|
||||||
|
vertical-align: -2px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,863 @@
|
|||||||
|
<template>
|
||||||
|
<div class="revisit-due-page">
|
||||||
|
<el-card class="!border-none" shadow="never">
|
||||||
|
<el-form :inline="true" class="due-filter-form">
|
||||||
|
<el-form-item label="医助">
|
||||||
|
<el-select
|
||||||
|
v-model="queryParams.assistant_id"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
placeholder="全部"
|
||||||
|
class="w-[160px]"
|
||||||
|
@change="doSearch"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in assistantOptions"
|
||||||
|
:key="item.id"
|
||||||
|
:label="item.name"
|
||||||
|
:value="Number(item.id)"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="部门">
|
||||||
|
<el-tree-select
|
||||||
|
v-model="queryParams.dept_id"
|
||||||
|
:data="deptTreeOptions"
|
||||||
|
placeholder="全部(二中心)"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
check-strictly
|
||||||
|
node-key="id"
|
||||||
|
:default-expand-all="true"
|
||||||
|
:props="deptTreeProps"
|
||||||
|
class="w-[200px]"
|
||||||
|
@change="doSearch"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select
|
||||||
|
v-model="queryParams.state"
|
||||||
|
clearable
|
||||||
|
placeholder="全部"
|
||||||
|
class="w-[170px]"
|
||||||
|
@change="doSearch"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="opt in stateOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="开药天数">
|
||||||
|
<el-select
|
||||||
|
v-model="queryParams.days_bucket"
|
||||||
|
clearable
|
||||||
|
placeholder="全部"
|
||||||
|
class="w-[120px]"
|
||||||
|
@change="doSearch"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="opt in REVISIT_DAYS_BUCKET_OPTIONS"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="延迟次数 ≥">
|
||||||
|
<el-input-number
|
||||||
|
v-model="minSnooze"
|
||||||
|
:min="0"
|
||||||
|
:max="999"
|
||||||
|
:step="1"
|
||||||
|
step-strictly
|
||||||
|
controls-position="right"
|
||||||
|
placeholder="不限"
|
||||||
|
class="!w-[120px]"
|
||||||
|
@change="onMinSnoozeChange"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="患者">
|
||||||
|
<el-input
|
||||||
|
v-model="queryParams.keyword"
|
||||||
|
placeholder="患者姓名"
|
||||||
|
clearable
|
||||||
|
class="w-[160px]"
|
||||||
|
@keyup.enter="doSearch"
|
||||||
|
@clear="doSearch"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="doSearch">查询</el-button>
|
||||||
|
<el-button @click="handleReset">重置</el-button>
|
||||||
|
<export-data
|
||||||
|
class="ml-2.5"
|
||||||
|
:fetch-fun="revisitDueLists"
|
||||||
|
:params="queryParams"
|
||||||
|
:page-size="pager.size"
|
||||||
|
export-hint="导出范围与当前筛选一致(医助 / 部门 / 状态 / 开药天数 / 延迟次数 / 患者)"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<div class="due-hint">
|
||||||
|
每位患者(诊单)一行,取最新有效业务订单为本轮周期:预计复诊日 = 用药起算日(签收 → 发货 → 下单)+ 开药天数;
|
||||||
|
到期仍无有效挂号 / 过号未面诊的行标红,持有医助登录后台会收到右下角弹窗。
|
||||||
|
「延迟次数」为医助在弹窗上点「延迟」的累计次数(越多说明越未跟进),「已联系」为填写患者承诺复诊日的次数,
|
||||||
|
「人工关闭」由医助填原因关闭、在此标记供主管复核。数据范围按角色数据权限收窄;状态按今天实时推导。
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card class="!border-none mt-3" shadow="never">
|
||||||
|
<el-table
|
||||||
|
:data="pager.lists"
|
||||||
|
v-loading="pager.loading"
|
||||||
|
size="default"
|
||||||
|
stripe
|
||||||
|
row-key="id"
|
||||||
|
:row-class-name="rowClassName"
|
||||||
|
>
|
||||||
|
<el-table-column label="患者" min-width="120" fixed="left">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="cell-main">{{ row.patient_name || '—' }}</div>
|
||||||
|
<div class="cell-sub">诊单 #{{ row.diagnosis_id }}</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="医助" min-width="100">
|
||||||
|
<template #default="{ row }">{{ assistantText(row) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="部门" min-width="110" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">{{ row.dept_name || '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="诊次" width="110" align="center">
|
||||||
|
<template #header>
|
||||||
|
<span>诊次</span>
|
||||||
|
<el-tooltip
|
||||||
|
placement="top"
|
||||||
|
effect="dark"
|
||||||
|
content="患者当前已完成诊次 = 有效业务订单数 + 诊单诊次偏移,与复诊接诊率同口径;下一行为待复诊诊次"
|
||||||
|
>
|
||||||
|
<el-icon class="col-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="cell-main">{{ slotText(row) }}</div>
|
||||||
|
<div v-if="Number(row.next_slot) > 0" class="cell-sub">
|
||||||
|
待复诊第 {{ row.next_slot }} 诊
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="开药天数" width="110" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<template v-if="Number(row.medication_days) > 0">
|
||||||
|
<span>{{ row.medication_days }} 天</span>
|
||||||
|
<el-tag
|
||||||
|
v-if="Number(row.rule_days) > 0"
|
||||||
|
size="small"
|
||||||
|
type="info"
|
||||||
|
effect="plain"
|
||||||
|
class="ml-1"
|
||||||
|
>
|
||||||
|
{{ row.rule_days }} 档
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
<span v-else class="text-missing">缺开药天数</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="预计复诊" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="cell-main">{{ row.due_date || '—' }}</div>
|
||||||
|
<div v-if="row.anchor_date" class="cell-sub">
|
||||||
|
起算 {{ formatRevisitMonthDay(row.anchor_date) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="超期天数" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :class="['overdue-cell', overdueCell(row).cls]">
|
||||||
|
{{ overdueCell(row).text }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag
|
||||||
|
:type="revisitStateTagType(row.state)"
|
||||||
|
:effect="revisitStateTagEffect(row.state)"
|
||||||
|
size="small"
|
||||||
|
:class="revisitStateChipClass(dueOf(row))"
|
||||||
|
>
|
||||||
|
{{ chipText(row) }}
|
||||||
|
</el-tag>
|
||||||
|
<el-tag
|
||||||
|
v-if="Number(row.closed_state) === 4"
|
||||||
|
size="small"
|
||||||
|
type="info"
|
||||||
|
effect="plain"
|
||||||
|
class="ml-1"
|
||||||
|
>
|
||||||
|
人工关闭
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="延迟次数" width="100" align="center">
|
||||||
|
<template #header>
|
||||||
|
<span>延迟次数</span>
|
||||||
|
<el-tooltip
|
||||||
|
placement="top"
|
||||||
|
effect="dark"
|
||||||
|
content="医助在到期弹窗上点「延迟」的累计次数;1~2 次 L1、3~4 次 L2、≥5 次 L3(居中强制模态)。换医助后清零"
|
||||||
|
>
|
||||||
|
<el-icon class="col-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :class="['snooze-cell', snoozeClass(row)]">
|
||||||
|
{{ Number(row.snooze_count ?? 0) }}
|
||||||
|
</span>
|
||||||
|
<div v-if="Number(row.escalation_level) > 0" class="cell-sub">
|
||||||
|
等级 L{{ row.escalation_level }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="已联系" width="80" align="center">
|
||||||
|
<template #default="{ row }">{{ Number(row.contact_count ?? 0) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="承诺日" width="100">
|
||||||
|
<template #default="{ row }">{{ row.promised_date || '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="过号" width="70" align="center">
|
||||||
|
<template #default="{ row }">{{ Number(row.missed_count ?? 0) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="关闭原因" min-width="170" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<template v-if="Number(row.closed_state) === 4">
|
||||||
|
<span>{{ handleReasonText(row.handle_reason) }}</span>
|
||||||
|
<span v-if="row.handle_remark" class="cell-sub">
|
||||||
|
:{{ row.handle_remark }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<span v-else-if="Number(row.closed_state) > 0" class="cell-sub">
|
||||||
|
{{ row.closed_state_text || '—' }}
|
||||||
|
</span>
|
||||||
|
<span v-else>—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="130" fixed="right" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
v-perms="['tcm.revisitDue/logs']"
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
@click="openLogs(row)"
|
||||||
|
>
|
||||||
|
流水
|
||||||
|
</el-button>
|
||||||
|
<el-button link type="primary" @click="goDiagnosis(row)">去诊单</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div class="flex justify-end mt-4">
|
||||||
|
<pagination v-model="pager" @change="getLists" />
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-drawer
|
||||||
|
v-model="logVisible"
|
||||||
|
:title="logTitle"
|
||||||
|
direction="rtl"
|
||||||
|
size="540px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<div v-loading="logLoading" class="log-drawer-body">
|
||||||
|
<div v-if="logRow" class="log-summary">
|
||||||
|
<div class="log-summary-row">
|
||||||
|
<span>医助:{{ assistantText(logRow) }}</span>
|
||||||
|
<span>{{ slotText(logRow) }}</span>
|
||||||
|
<span v-if="logRow.due_date">预计复诊 {{ logRow.due_date }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="log-summary-row">
|
||||||
|
<el-tag
|
||||||
|
:type="revisitStateTagType(logRow.state)"
|
||||||
|
:effect="revisitStateTagEffect(logRow.state)"
|
||||||
|
size="small"
|
||||||
|
:class="revisitStateChipClass(dueOf(logRow))"
|
||||||
|
>
|
||||||
|
{{ chipText(logRow) }}
|
||||||
|
</el-tag>
|
||||||
|
<span class="cell-sub">{{ followCountText(logRow) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="hasShownMeta(logRow)" class="log-summary-row cell-sub">
|
||||||
|
<span v-if="logRow.first_shown_at_text">
|
||||||
|
首次弹出 {{ logRow.first_shown_at_text }}
|
||||||
|
</span>
|
||||||
|
<span v-if="logRow.last_snoozed_at_text">
|
||||||
|
最近延迟 {{ logRow.last_snoozed_at_text }}
|
||||||
|
</span>
|
||||||
|
<span v-if="logRow.computed_at_text">
|
||||||
|
重算于 {{ logRow.computed_at_text }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-timeline v-if="logRows.length" class="mt-3">
|
||||||
|
<el-timeline-item
|
||||||
|
v-for="item in logRows"
|
||||||
|
:key="item.id"
|
||||||
|
:timestamp="item.create_time_text"
|
||||||
|
placement="top"
|
||||||
|
:type="eventTimelineType(item.event)"
|
||||||
|
:hollow="!isUserEvent(item.event)"
|
||||||
|
>
|
||||||
|
<div class="log-event">
|
||||||
|
<span class="log-event-title">
|
||||||
|
{{ item.event_text || eventLabel(item.event) }}
|
||||||
|
</span>
|
||||||
|
<span class="log-event-operator">{{ operatorText(item) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="log-event-meta">
|
||||||
|
<span>延迟 {{ Number(item.snooze_count_after ?? 0) }} 次</span>
|
||||||
|
<span>等级 L{{ Number(item.level_after ?? 0) }}</span>
|
||||||
|
<span>{{ logOverdueText(item.overdue_days) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="payloadEntries(item).length" class="log-event-payload">
|
||||||
|
<span
|
||||||
|
v-for="p in payloadEntries(item)"
|
||||||
|
:key="p.key"
|
||||||
|
class="log-payload-chip"
|
||||||
|
>
|
||||||
|
{{ p.label }}:{{ p.value }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</el-timeline-item>
|
||||||
|
</el-timeline>
|
||||||
|
<el-empty v-else-if="!logLoading" description="暂无流水" :image-size="80" />
|
||||||
|
</div>
|
||||||
|
</el-drawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts" name="statsRevisitDue">
|
||||||
|
import { InfoFilled } from '@element-plus/icons-vue'
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import {
|
||||||
|
revisitDueLists,
|
||||||
|
revisitDueLogs,
|
||||||
|
type RevisitDueListRow,
|
||||||
|
type RevisitDueListsParams,
|
||||||
|
type RevisitDueLogRow
|
||||||
|
} from '@/api/revisitDue'
|
||||||
|
import { revisitRateDeptOptions } from '@/api/stats'
|
||||||
|
import { getAssistants } from '@/api/tcm'
|
||||||
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
|
import {
|
||||||
|
REVISIT_DAYS_BUCKET_OPTIONS,
|
||||||
|
REVISIT_STATE_FILTER_OPTIONS,
|
||||||
|
formatRevisitMonthDay,
|
||||||
|
normalizeRevisitDue,
|
||||||
|
revisitRowClassName,
|
||||||
|
revisitStateChipClass,
|
||||||
|
revisitStateChipText,
|
||||||
|
revisitStateTagEffect,
|
||||||
|
revisitStateTagType,
|
||||||
|
type RevisitDue
|
||||||
|
} from '@/utils/revisitDue'
|
||||||
|
|
||||||
|
interface DeptTreeNode {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
pid: number
|
||||||
|
children?: DeptTreeNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
/** 状态筛选:诊单列表的 7 个状态 + 主管视图专有「人工关闭」 */
|
||||||
|
const stateOptions = [...REVISIT_STATE_FILTER_OPTIONS, { label: '人工关闭', value: 'closed' }]
|
||||||
|
|
||||||
|
const assistantOptions = ref<Array<{ id: number; name: string }>>([])
|
||||||
|
const deptTreeOptions = ref<DeptTreeNode[]>([])
|
||||||
|
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
|
||||||
|
|
||||||
|
const queryParams = reactive<RevisitDueListsParams>({
|
||||||
|
assistant_id: '',
|
||||||
|
dept_id: '',
|
||||||
|
state: '',
|
||||||
|
days_bucket: '',
|
||||||
|
min_snooze: '',
|
||||||
|
keyword: ''
|
||||||
|
})
|
||||||
|
/** el-input-number 清空时为 null/undefined,单独持有后再写回 queryParams 的 '' */
|
||||||
|
const minSnooze = ref<number | undefined>(undefined)
|
||||||
|
|
||||||
|
const { pager, getLists, resetPage } = usePaging({
|
||||||
|
fetchFun: revisitDueLists,
|
||||||
|
params: queryParams
|
||||||
|
})
|
||||||
|
|
||||||
|
const doSearch = () => resetPage()
|
||||||
|
|
||||||
|
const onMinSnoozeChange = (val: number | undefined | null) => {
|
||||||
|
queryParams.min_snooze = val === null || val === undefined ? '' : Number(val)
|
||||||
|
doSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
queryParams.assistant_id = ''
|
||||||
|
queryParams.dept_id = ''
|
||||||
|
queryParams.state = ''
|
||||||
|
queryParams.days_bucket = ''
|
||||||
|
queryParams.min_snooze = ''
|
||||||
|
queryParams.keyword = ''
|
||||||
|
minSnooze.value = undefined
|
||||||
|
doSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 单元格 ──────────────────────────────────────────
|
||||||
|
/** 列表行 → 复诊节点对象(复用 utils/revisitDue 的 chip 映射;缺失字段按默认值补齐) */
|
||||||
|
const dueOf = (row: RevisitDueListRow | Record<string, any>): RevisitDue | null =>
|
||||||
|
normalizeRevisitDue(row)
|
||||||
|
|
||||||
|
const rowClassName = ({ row }: { row: RevisitDueListRow }) => revisitRowClassName(dueOf(row))
|
||||||
|
|
||||||
|
const assistantText = (row: RevisitDueListRow) =>
|
||||||
|
row.assistant_name || (Number(row.assistant_id) > 0 ? `#${row.assistant_id}` : '—')
|
||||||
|
|
||||||
|
const slotText = (row: RevisitDueListRow) => {
|
||||||
|
const slot = Number(row.current_slot ?? 0)
|
||||||
|
return slot > 0 ? `第 ${slot} 诊` : '未成交'
|
||||||
|
}
|
||||||
|
|
||||||
|
const chipText = (row: RevisitDueListRow) =>
|
||||||
|
revisitStateChipText(dueOf(row)) || row.state_text || '—'
|
||||||
|
|
||||||
|
/** 抽屉摘要:「延迟 N 次 · 已联系 N 次 · 过号 N 次」 */
|
||||||
|
const followCountText = (row: RevisitDueListRow) => {
|
||||||
|
const snooze = Number(row.snooze_count ?? 0)
|
||||||
|
const contact = Number(row.contact_count ?? 0)
|
||||||
|
const missed = Number(row.missed_count ?? 0)
|
||||||
|
return `延迟 ${snooze} 次 · 已联系 ${contact} 次 · 过号 ${missed} 次`
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasShownMeta = (row: RevisitDueListRow) =>
|
||||||
|
!!(row.first_shown_at_text || row.last_snoozed_at_text || row.computed_at_text)
|
||||||
|
|
||||||
|
const toNullableNumber = (v: unknown): number | null => {
|
||||||
|
if (v === null || v === undefined || v === '') return null
|
||||||
|
const n = Number(v)
|
||||||
|
return Number.isFinite(n) ? n : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 超期天数列:到期/过号红字(深红 = severe),其它状态显示剩余天数或历史超期 */
|
||||||
|
const overdueCell = (row: RevisitDueListRow): { text: string; cls: string } => {
|
||||||
|
const od = toNullableNumber(row.overdue_days)
|
||||||
|
const dl = toNullableNumber(row.days_left)
|
||||||
|
const isDue = row.state === 'overdue' || row.state === 'missed'
|
||||||
|
if (isDue && od !== null && od >= 0) {
|
||||||
|
return {
|
||||||
|
text: od === 0 ? '今日到期' : `${od} 天`,
|
||||||
|
cls: Number(row.severe) === 1 ? 'is-severe' : 'is-overdue'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (od !== null && od > 0) {
|
||||||
|
return { text: `${od} 天`, cls: 'is-muted' }
|
||||||
|
}
|
||||||
|
if (dl !== null && dl >= 0) {
|
||||||
|
return { text: `剩 ${dl} 天`, cls: 'is-muted' }
|
||||||
|
}
|
||||||
|
return { text: '—', cls: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const snoozeClass = (row: RevisitDueListRow) => {
|
||||||
|
const level = Number(row.escalation_level ?? 0)
|
||||||
|
if (level >= 3) return 'is-level-3'
|
||||||
|
if (level === 2) return 'is-level-2'
|
||||||
|
if (level === 1) return 'is-level-1'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReasonText = (reason: string | null | undefined) => {
|
||||||
|
const key = String(reason ?? '').trim()
|
||||||
|
if (key === 'lost') return '患者流失'
|
||||||
|
if (key === 'other') return '其它'
|
||||||
|
return key || '人工关闭'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 流水抽屉 ──────────────────────────────────────────
|
||||||
|
const logVisible = ref(false)
|
||||||
|
const logLoading = ref(false)
|
||||||
|
const logRow = ref<RevisitDueListRow | null>(null)
|
||||||
|
const logRows = ref<RevisitDueLogRow[]>([])
|
||||||
|
|
||||||
|
const logTitle = computed(() =>
|
||||||
|
logRow.value ? `${logRow.value.patient_name || '患者'} · 复诊到期流水` : '复诊到期流水'
|
||||||
|
)
|
||||||
|
|
||||||
|
const EVENT_LABEL: Record<number, string> = {
|
||||||
|
1: '延迟',
|
||||||
|
2: '已联系',
|
||||||
|
3: '人工关闭',
|
||||||
|
4: '自动关闭 · 已挂号',
|
||||||
|
5: '自动关闭 · 已面诊',
|
||||||
|
6: '周期重置(新订单)',
|
||||||
|
7: '重新打开(挂号取消 / 过号)',
|
||||||
|
8: '企微推送',
|
||||||
|
9: '医助变更',
|
||||||
|
10: '形态升级',
|
||||||
|
11: '首次弹出'
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventLabel = (event: number) => EVENT_LABEL[Number(event)] ?? `事件 ${event}`
|
||||||
|
|
||||||
|
/** 1 延迟 / 2 已联系 / 3 人工关闭 由医助操作,实心节点;其余系统事件空心 */
|
||||||
|
const isUserEvent = (event: number) => [1, 2, 3].includes(Number(event))
|
||||||
|
|
||||||
|
type TimelineType = 'primary' | 'success' | 'warning' | 'danger' | 'info'
|
||||||
|
|
||||||
|
const eventTimelineType = (event: number): TimelineType => {
|
||||||
|
switch (Number(event)) {
|
||||||
|
case 1:
|
||||||
|
return 'warning'
|
||||||
|
case 2:
|
||||||
|
case 6:
|
||||||
|
return 'primary'
|
||||||
|
case 4:
|
||||||
|
case 5:
|
||||||
|
return 'success'
|
||||||
|
case 7:
|
||||||
|
case 10:
|
||||||
|
return 'danger'
|
||||||
|
default:
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const operatorText = (item: RevisitDueLogRow) => {
|
||||||
|
if (item.operator_name) return item.operator_name
|
||||||
|
return Number(item.operator_id) > 0 ? `#${item.operator_id}` : '系统'
|
||||||
|
}
|
||||||
|
|
||||||
|
const logOverdueText = (days: number | null | undefined) => {
|
||||||
|
const n = toNullableNumber(days)
|
||||||
|
if (n === null) return ''
|
||||||
|
if (n > 0) return `超期 ${n} 天`
|
||||||
|
if (n === 0) return '到期当天'
|
||||||
|
return `未到期(剩 ${-n} 天)`
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAYLOAD_LABEL: Record<string, string> = {
|
||||||
|
next_remind_at: '下次提醒',
|
||||||
|
next_remind_at_text: '下次提醒',
|
||||||
|
promised_date: '承诺日',
|
||||||
|
appointment_id: '挂号单',
|
||||||
|
appointment_date: '预约日',
|
||||||
|
order_id: '订单',
|
||||||
|
source_order_id: '周期订单',
|
||||||
|
remark: '备注',
|
||||||
|
reason: '原因',
|
||||||
|
from_assistant: '原医助',
|
||||||
|
from_assistant_id: '原医助',
|
||||||
|
to_assistant: '新医助',
|
||||||
|
to_assistant_id: '新医助',
|
||||||
|
interval: '间隔(秒)',
|
||||||
|
level: '等级',
|
||||||
|
channel: '渠道',
|
||||||
|
message: '说明'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatPayloadValue = (key: string, value: unknown): string => {
|
||||||
|
if (value === null || value === undefined || value === '') return ''
|
||||||
|
if (typeof value === 'object') return JSON.stringify(value)
|
||||||
|
// 秒级时间戳(next_remind_at 等)转本地时间
|
||||||
|
if (/_at$/.test(key) && typeof value === 'number' && value > 1e9) {
|
||||||
|
const d = new Date(value * 1000)
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||||
|
return `${date} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PayloadEntry {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** payload_arr → [{key,label,value}],有 *_text 版本时隐藏对应原始时间戳 */
|
||||||
|
const payloadEntries = (item: RevisitDueLogRow): PayloadEntry[] => {
|
||||||
|
const payload = item.payload_arr
|
||||||
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return []
|
||||||
|
const out: PayloadEntry[] = []
|
||||||
|
for (const [key, raw] of Object.entries(payload)) {
|
||||||
|
if (`${key}_text` in payload) continue
|
||||||
|
const value = formatPayloadValue(key, raw)
|
||||||
|
if (value === '') continue
|
||||||
|
out.push({ key, label: PAYLOAD_LABEL[key] ?? key, value })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const openLogs = async (row: RevisitDueListRow) => {
|
||||||
|
logRow.value = row
|
||||||
|
logVisible.value = true
|
||||||
|
logLoading.value = true
|
||||||
|
logRows.value = []
|
||||||
|
try {
|
||||||
|
// 契约「返回 list」:兼容直接返回数组或 { list } / { lists } 包络
|
||||||
|
const res: any = await revisitDueLogs({ due_id: Number(row.id) })
|
||||||
|
const list = Array.isArray(res) ? res : (res?.list ?? res?.lists ?? [])
|
||||||
|
logRows.value = Array.isArray(list) ? list : []
|
||||||
|
} finally {
|
||||||
|
logLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const goDiagnosis = (row: RevisitDueListRow) => {
|
||||||
|
const id = Number(row.diagnosis_id ?? 0)
|
||||||
|
if (id <= 0) return
|
||||||
|
router.push({ path: '/tcm/diagnosis', query: { id: String(id) } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 下拉数据 ──────────────────────────────────────────
|
||||||
|
const buildDeptTree = (flat: Array<{ id: number; pid: number; name: string }>): DeptTreeNode[] => {
|
||||||
|
const map = new Map<number, DeptTreeNode>()
|
||||||
|
for (const d of flat) {
|
||||||
|
map.set(Number(d.id), { id: Number(d.id), pid: Number(d.pid), name: d.name, children: [] })
|
||||||
|
}
|
||||||
|
const roots: DeptTreeNode[] = []
|
||||||
|
for (const d of flat) {
|
||||||
|
const node = map.get(Number(d.id))!
|
||||||
|
if (Number(d.pid) > 0 && map.has(Number(d.pid))) {
|
||||||
|
map.get(Number(d.pid))!.children!.push(node)
|
||||||
|
} else {
|
||||||
|
roots.push(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const strip = (n: DeptTreeNode) => {
|
||||||
|
if (n.children?.length) {
|
||||||
|
n.children.forEach(strip)
|
||||||
|
} else {
|
||||||
|
delete n.children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
roots.forEach(strip)
|
||||||
|
return roots
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadOptions = async () => {
|
||||||
|
const [assistants, depts] = await Promise.allSettled([
|
||||||
|
getAssistants(),
|
||||||
|
revisitRateDeptOptions()
|
||||||
|
])
|
||||||
|
if (assistants.status === 'fulfilled') {
|
||||||
|
assistantOptions.value = Array.isArray(assistants.value) ? assistants.value : []
|
||||||
|
}
|
||||||
|
if (depts.status === 'fulfilled') {
|
||||||
|
deptTreeOptions.value = buildDeptTree(depts.value?.rows ?? [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 下拉与列表并行加载,不阻塞首屏
|
||||||
|
void loadOptions()
|
||||||
|
getLists()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.revisit-due-page {
|
||||||
|
.due-filter-form {
|
||||||
|
:deep(.el-form-item) {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.due-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-main {
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-hint {
|
||||||
|
margin-left: 2px;
|
||||||
|
vertical-align: -2px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-missing {
|
||||||
|
color: #ea580c;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overdue-cell.is-overdue {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overdue-cell.is-severe {
|
||||||
|
color: #b91c1c;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overdue-cell.is-muted {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snooze-cell {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snooze-cell.is-level-1 {
|
||||||
|
color: #ea580c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snooze-cell.is-level-2 {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.snooze-cell.is-level-3 {
|
||||||
|
color: #b91c1c;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 行级标红:与诊单列表一致(utils/revisitDue.ts revisitRowClassName) */
|
||||||
|
:deep(.el-table__row.row-revisit-overdue) {
|
||||||
|
border-left: 3px solid var(--el-color-danger);
|
||||||
|
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.06) 0%, transparent 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__row.row-revisit-overdue:hover > td) {
|
||||||
|
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.1) 0%, transparent 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__row.row-revisit-overdue.row-revisit-overdue-deep) {
|
||||||
|
border-left-color: #b91c1c;
|
||||||
|
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.1) 0%, transparent 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__row.row-revisit-overdue.row-revisit-overdue-deep:hover > td) {
|
||||||
|
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.14) 0%, transparent 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 状态 chip 配色:与 RevisitNodeCell 一致 */
|
||||||
|
:deep(.rv-chip) {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.rv-chip--overdue),
|
||||||
|
:deep(.rv-chip--missed) {
|
||||||
|
--el-tag-bg-color: #dc2626;
|
||||||
|
--el-tag-border-color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.rv-chip--severe) {
|
||||||
|
font-weight: 700;
|
||||||
|
--el-tag-bg-color: #b91c1c;
|
||||||
|
--el-tag-border-color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.rv-chip--warning) {
|
||||||
|
--el-tag-text-color: #ea580c;
|
||||||
|
--el-tag-bg-color: rgba(234, 88, 12, 0.1);
|
||||||
|
--el-tag-border-color: rgba(234, 88, 12, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.rv-chip--no_days) {
|
||||||
|
--el-tag-text-color: #ea580c;
|
||||||
|
--el-tag-border-color: #ea580c;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.rv-chip--visited) {
|
||||||
|
--el-tag-text-color: #16a34a;
|
||||||
|
--el-tag-bg-color: rgba(22, 163, 74, 0.1);
|
||||||
|
--el-tag-border-color: rgba(22, 163, 74, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.rv-chip--booked) {
|
||||||
|
--el-tag-text-color: #4a5dff;
|
||||||
|
--el-tag-bg-color: rgba(74, 93, 255, 0.1);
|
||||||
|
--el-tag-border-color: rgba(74, 93, 255, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-drawer-body {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-summary {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-summary-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-event {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-event-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-event-operator {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-event-meta {
|
||||||
|
margin-top: 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-event-payload {
|
||||||
|
margin-top: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-payload-chip {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--el-fill-color);
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -29,12 +29,18 @@
|
|||||||
@change="loadData"
|
@change="loadData"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="口径">
|
||||||
|
<el-radio-group v-model="inheritMode" size="small" @change="loadData">
|
||||||
|
<el-radio-button value="exclude">原口径(继承不计分母)</el-radio-button>
|
||||||
|
<el-radio-button value="slot">新口径(继承按继承时诊次计入)</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" :loading="loading" @click="loadData">查询</el-button>
|
<el-button type="primary" :loading="loading" @click="loadData">查询</el-button>
|
||||||
<el-button :disabled="loading" @click="handleReset">重置</el-button>
|
<el-button :disabled="loading" @click="handleReset">重置</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-popover placement="bottom-start" :width="500" trigger="hover">
|
<el-popover placement="bottom-start" :width="560" trigger="hover">
|
||||||
<template #reference>
|
<template #reference>
|
||||||
<span class="rate-caliber-trigger">
|
<span class="rate-caliber-trigger">
|
||||||
<el-icon><InfoFilled /></el-icon>
|
<el-icon><InfoFilled /></el-icon>
|
||||||
@@ -43,16 +49,29 @@
|
|||||||
</template>
|
</template>
|
||||||
<div class="rate-caliber">
|
<div class="rate-caliber">
|
||||||
<p>
|
<p>
|
||||||
<b>当月被指派总数</b>:当月内诊单被指派给医助(按指派操作时间落月,<b>剔除勾选「继承」的指派</b>)的诊单数,按「医助 × 诊单」去重;部门行 / 合计行按诊单去重;<b>再剔除</b>名下存在履约「拒收 / 退款」业务订单的诊单。
|
<b>{{ isSlotMode ? '新指派数' : '当月被指派数' }}</b>:当月内诊单被指派给医助(按指派操作时间落月,<b>不含勾选「继承」的指派</b>)的诊单数,按「医助 × 诊单」去重;部门行 / 合计行按诊单去重;<b>再剔除</b>名下存在履约「拒收 / 退款」业务订单的诊单。与业绩看板「被指派数」同口径。
|
||||||
|
</p>
|
||||||
|
<template v-if="isSlotMode">
|
||||||
|
<p>
|
||||||
|
<b>继承数</b>:当月勾选「继承」转接给该医助的诊单数(同一诊单多次继承取最早;同月既新指派又继承给同一医助只算新指派;历史「回退自动分配」行按继承处理;指派给自己的记录忽略)。<b>继承时诊次 k</b> = 继承时刻之前患者已完成的诊次(有效实单数 + 偏移),<b>起算诊次 = max(2, k+1)</b>:继承者只对第 k+1 诊及以后负责。
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<b>诊次(第 N 次下单)</b>:患者(诊单)名下计入业绩的业务订单(剔除已取消 / 拒收 / 退款)按下单时间升序编号为「实单序号」,<b>统计诊次 = 实单序号 + 诊单偏移</b>(默认偏移 0 → 第 1 笔实单为一诊;偏移 1 → 第 1 笔实单为二诊;偏移 2 → 第 1 笔实单为三诊,5 笔实单等价七诊)。诊次<b>跨月累计不重置</b>。诊单可在「业务订单」tab 配置偏移量。
|
<b>N 诊分母</b> = 新指派数 + 起算诊次 ≤ N 的继承数(每列独立);<b>N 诊接诊率</b> = N 诊单数 ÷ N 诊分母,分母为 0 显示「—」。<b>复诊合计</b>:单数 = 各诊次单数之和,分母 = 新指派 ∪ 继承(被指派合计),比率可超过 100%(人均复诊成交单数)。
|
||||||
|
</p>
|
||||||
|
<p class="rate-caliber-note">
|
||||||
|
继承分母依赖指派记录的「继承」标记(2026-06 起写入),之前的转接无法回溯;切换为「旧口径」可对照上线前数字。
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<p>
|
||||||
|
<b>旧口径</b>:继承指派不计入分母,N 诊分母 = 新指派数(对所有 N 相同),新指派数 / N 诊单数 / N 诊接诊率与 2026-10 之前完全一致;继承数恒为 0。切换为「新口径」后继承按继承时诊次计入每列独立分母。
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<p>
|
||||||
|
<b>诊次(第 N 次下单)</b>:患者(诊单)名下计入业绩的业务订单(剔除已取消 / 拒收 / 退款)按下单时间升序编号为「实单序号」,<b>统计诊次 = 实单序号 + 诊单偏移</b>(默认偏移 0 → 第 1 笔实单为一诊)。诊次<b>跨月累计不重置</b>。诊单可在「业务订单」tab 配置偏移量。
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<b>当月 N 诊单数</b>:当月内下单且诊次为 N 的订单数,归属下单时点<b>持有该患者的医助</b>(指派可在往月;释放后不再归属;「继承」指派会转移持有人但不计被指派数)。
|
<b>当月 N 诊单数</b>:当月内下单且诊次为 N 的订单数,归属下单时点<b>持有该患者的医助</b>(指派可在往月;释放后不再归属;继承会转移持有人)。往月指派、当月成交会推高分子,比率可能超过 100%。
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<b>当月 N 诊接诊率</b> = 当月 N 诊单数 ÷ 当月被指派总数。往月指派、当月成交会推高分子,比率可能超过 100%;医助当月无新指派但旗下有成交时,被指派数为 0、比率显示「—」。
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
医助按人事部门归组;<b>仅统计「二中心」及其组织下级</b>;部门下拉与未选时的默认范围均限定在该子树,选定部门时含其组织下级。
|
医助按人事部门归组;<b>仅统计「二中心」及其组织下级</b>;部门下拉与未选时的默认范围均限定在该子树,选定部门时含其组织下级。
|
||||||
@@ -81,7 +100,9 @@
|
|||||||
<template #header>
|
<template #header>
|
||||||
<div class="rate-card-header">
|
<div class="rate-card-header">
|
||||||
<span>部门 · 医助明细({{ monthText }})</span>
|
<span>部门 · 医助明细({{ monthText }})</span>
|
||||||
<span class="rate-card-hint">点击「被指派数 / N诊单数」可查看具体诊单与订单;底栏合计的被指派数按诊单去重</span>
|
<span class="rate-card-hint">
|
||||||
|
点击「新指派数 / 继承数 / 被指派合计 / N诊单数 / 复诊合计单数」可查看具体诊单与订单;接诊率下方灰字为「单数 / 分母」;底栏合计按诊单去重
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-table
|
<el-table
|
||||||
@@ -104,19 +125,88 @@
|
|||||||
<span v-else>{{ row.assistant_name }}</span>
|
<span v-else>{{ row.assistant_name }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="assigned_count" label="当月被指派数" min-width="110" align="right">
|
<el-table-column
|
||||||
|
prop="assigned_count"
|
||||||
|
:label="isSlotMode ? '新指派数' : '当月被指派数'"
|
||||||
|
min-width="96"
|
||||||
|
align="right"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<span>{{ isSlotMode ? '新指派数' : '当月被指派数' }}</span>
|
||||||
|
<el-tooltip
|
||||||
|
effect="dark"
|
||||||
|
placement="top"
|
||||||
|
content="当月非继承指派的「医助 × 诊单」去重数(剔除名下有拒收/退款订单的诊单),与业绩看板「被指派数」同口径"
|
||||||
|
>
|
||||||
|
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-link
|
<el-link
|
||||||
v-if="canViewAssignLines && row.assigned_count > 0"
|
v-if="canViewAssignLines && row.assigned_count > 0"
|
||||||
type="primary"
|
type="primary"
|
||||||
:underline="false"
|
:underline="false"
|
||||||
@click="openAssignDetail(row)"
|
@click="openAssignDetail(row, 'new')"
|
||||||
>
|
>
|
||||||
{{ row.assigned_count }}
|
{{ row.assigned_count }}
|
||||||
</el-link>
|
</el-link>
|
||||||
<span v-else>{{ row.assigned_count }}</span>
|
<span v-else>{{ row.assigned_count }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
v-if="isSlotMode"
|
||||||
|
prop="inherit_count"
|
||||||
|
label="继承数"
|
||||||
|
min-width="88"
|
||||||
|
align="right"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<span>继承数</span>
|
||||||
|
<el-tooltip
|
||||||
|
effect="dark"
|
||||||
|
placement="top"
|
||||||
|
content="当月勾选「继承」转接给该医助的诊单数(按继承时诊次 k 从第 k+1 诊起计入分母;点击查看每条的继承时诊次与起算诊次)"
|
||||||
|
>
|
||||||
|
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link
|
||||||
|
v-if="canViewAssignLines && row.inherit_count > 0"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
@click="openAssignDetail(row, 'inherit')"
|
||||||
|
>
|
||||||
|
{{ row.inherit_count }}
|
||||||
|
</el-link>
|
||||||
|
<span v-else>{{ row.inherit_count ?? 0 }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
v-if="isSlotMode"
|
||||||
|
prop="assigned_total_count"
|
||||||
|
label="被指派合计"
|
||||||
|
min-width="100"
|
||||||
|
align="right"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<span>被指派合计</span>
|
||||||
|
<el-tooltip effect="dark" placement="top" content="新指派 ∪ 继承(按诊单去重),即「复诊合计接诊率」的分母">
|
||||||
|
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link
|
||||||
|
v-if="canViewAssignLines && row.assigned_total_count > 0"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
@click="openAssignDetail(row, 'all')"
|
||||||
|
>
|
||||||
|
{{ row.assigned_total_count }}
|
||||||
|
</el-link>
|
||||||
|
<span v-else>{{ row.assigned_total_count ?? 0 }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<template v-for="slot in visitSlots" :key="slot">
|
<template v-for="slot in visitSlots" :key="slot">
|
||||||
<el-table-column
|
<el-table-column
|
||||||
:prop="`visit${slot}_count`"
|
:prop="`visit${slot}_count`"
|
||||||
@@ -149,12 +239,64 @@
|
|||||||
<el-table-column
|
<el-table-column
|
||||||
:prop="`visit${slot}_rate`"
|
:prop="`visit${slot}_rate`"
|
||||||
:label="`${slotLabel(slot)}接诊率`"
|
:label="`${slotLabel(slot)}接诊率`"
|
||||||
min-width="96"
|
min-width="104"
|
||||||
align="right"
|
align="right"
|
||||||
>
|
>
|
||||||
<template #default="{ row }">{{ formatRate(row[`visit${slot}_rate`]) }}</template>
|
<template #header>
|
||||||
|
<span>{{ slotLabel(slot) }}接诊率</span>
|
||||||
|
<el-tooltip effect="dark" placement="top" :content="rateHeaderTip(slot)">
|
||||||
|
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="rate-cell">
|
||||||
|
<span class="rate-cell-main">{{ formatRate(row[`visit${slot}_rate`]) }}</span>
|
||||||
|
<span v-if="isSlotMode" class="rate-cell-sub">{{ denText(row, `visit${slot}`) }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</template>
|
</template>
|
||||||
|
<el-table-column prop="visit_all_count" label="复诊合计单数" min-width="110" align="right">
|
||||||
|
<template #header>
|
||||||
|
<span>复诊合计单数</span>
|
||||||
|
<el-tooltip effect="dark" placement="top" content="当月内下单、诊次 ≥ 2 的订单数之和(= 各 N 诊单数之和)">
|
||||||
|
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link
|
||||||
|
v-if="canViewOrderLines && row.visit_all_count > 0"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
@click="openOrderDetail(0, row)"
|
||||||
|
>
|
||||||
|
{{ row.visit_all_count }}
|
||||||
|
</el-link>
|
||||||
|
<span v-else>{{ row.visit_all_count ?? 0 }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="visit_all_rate" label="复诊合计接诊率" min-width="118" align="right">
|
||||||
|
<template #header>
|
||||||
|
<span>复诊合计接诊率</span>
|
||||||
|
<el-tooltip
|
||||||
|
effect="dark"
|
||||||
|
placement="top"
|
||||||
|
:content="
|
||||||
|
isSlotMode
|
||||||
|
? '复诊合计单数 ÷ 被指派合计(新指派 ∪ 继承);可超过 100%,语义为人均复诊成交单数'
|
||||||
|
: '复诊合计单数 ÷ 新指派数(旧口径继承不计分母);可超过 100%'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="rate-cell">
|
||||||
|
<span class="rate-cell-main">{{ formatRate(row.visit_all_rate) }}</span>
|
||||||
|
<span v-if="isSlotMode" class="rate-cell-sub">{{ denText(row, 'visit_all') }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
@@ -162,7 +304,7 @@
|
|||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="detailVisible"
|
v-model="detailVisible"
|
||||||
:title="detailTitle"
|
:title="detailTitle"
|
||||||
width="980px"
|
width="1080px"
|
||||||
top="6vh"
|
top="6vh"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
>
|
>
|
||||||
@@ -193,6 +335,33 @@
|
|||||||
<template #default="{ row }">{{ row.patient_phone || '—' }}</template>
|
<template #default="{ row }">{{ row.patient_phone || '—' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="assistant_names" label="被指派医助" min-width="140" show-overflow-tooltip />
|
<el-table-column prop="assistant_names" label="被指派医助" min-width="140" show-overflow-tooltip />
|
||||||
|
<el-table-column v-if="isSlotMode" prop="assign_type" label="指派类型" width="110" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.assign_type === 'inherit' ? 'warning' : 'success'" size="small">
|
||||||
|
{{ row.assign_type_text || (row.assign_type === 'inherit' ? '继承' : '新指派') }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column v-if="isSlotMode" prop="inherit_visit_slot" label="继承时诊次" width="100" align="center">
|
||||||
|
<template #header>
|
||||||
|
<span>继承时诊次</span>
|
||||||
|
<el-tooltip effect="dark" placement="top" content="k:继承时刻之前患者已完成的诊次(有效实单数 + 偏移);新指派无此值">
|
||||||
|
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.assign_type === 'inherit' ? `第 ${Number(row.inherit_visit_slot ?? 0)} 诊` : '—' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="entry_slot" label="起算诊次" width="96" align="center">
|
||||||
|
<template #header>
|
||||||
|
<span>起算诊次</span>
|
||||||
|
<el-tooltip effect="dark" placement="top" content="从该诊次列起计入分母:继承 = max(2, k+1);新指派 = 2(二诊)">
|
||||||
|
<el-icon class="rate-th-hint"><InfoFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template #default="{ row }">{{ entrySlotText(row.entry_slot) }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="assign_count" label="当月指派次数" width="110" align="right" />
|
<el-table-column prop="assign_count" label="当月指派次数" width="110" align="right" />
|
||||||
<el-table-column prop="last_assign_time_text" label="最近指派时间" min-width="160" />
|
<el-table-column prop="last_assign_time_text" label="最近指派时间" min-width="160" />
|
||||||
</template>
|
</template>
|
||||||
@@ -210,6 +379,9 @@
|
|||||||
<span v-else>{{ row.order_no || `#${row.order_id}` }}</span>
|
<span v-else>{{ row.order_no || `#${row.order_id}` }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column v-if="detailSlot === 0" prop="visit_slot" label="诊次" width="80" align="center">
|
||||||
|
<template #default="{ row }">{{ slotLabel(Number(row.visit_slot)) }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="diagnosis_id" label="诊单ID" width="90" />
|
<el-table-column prop="diagnosis_id" label="诊单ID" width="90" />
|
||||||
<el-table-column prop="patient_name" label="患者" min-width="100">
|
<el-table-column prop="patient_name" label="患者" min-width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -251,7 +423,9 @@ import {
|
|||||||
revisitRateAssignLines,
|
revisitRateAssignLines,
|
||||||
revisitRateDeptOptions,
|
revisitRateDeptOptions,
|
||||||
revisitRateOverview,
|
revisitRateOverview,
|
||||||
revisitRateVisitOrderLines
|
revisitRateVisitOrderLines,
|
||||||
|
type RevisitRateAssignKind,
|
||||||
|
type RevisitRateInheritMode
|
||||||
} from '@/api/stats'
|
} from '@/api/stats'
|
||||||
import { hasPermission } from '@/utils/perm'
|
import { hasPermission } from '@/utils/perm'
|
||||||
|
|
||||||
@@ -263,7 +437,14 @@ const OrderDetailDrawer = defineAsyncComponent(
|
|||||||
const DiagnosisEditPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
const DiagnosisEditPopup = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
||||||
|
|
||||||
interface MetricPack extends Record<string, any> {
|
interface MetricPack extends Record<string, any> {
|
||||||
|
/** 新指派数(语义不变) */
|
||||||
assigned_count: number
|
assigned_count: number
|
||||||
|
inherit_count?: number
|
||||||
|
assigned_total_count?: number
|
||||||
|
visit_all_count?: number
|
||||||
|
visit_all_den?: number
|
||||||
|
visit_all_rate?: number | null
|
||||||
|
visit2_rate_new_assign?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StatsRow extends MetricPack {
|
interface StatsRow extends MetricPack {
|
||||||
@@ -289,11 +470,14 @@ const canViewOrderLines = hasPermission(['stats.revisitRate/visitOrderLines'])
|
|||||||
const canViewOrderDetail = hasPermission(['tcm.prescriptionOrder/detail'])
|
const canViewOrderDetail = hasPermission(['tcm.prescriptionOrder/detail'])
|
||||||
const canViewPatientDetail = hasPermission(['tcm.diagnosis/detail'])
|
const canViewPatientDetail = hasPermission(['tcm.diagnosis/detail'])
|
||||||
|
|
||||||
/** 接口动态返回的诊次分档(最少 2~4,按数据扩展到五诊、六诊…) */
|
/** 接口动态返回的诊次分档(最少 2~4,按分子最大诊次 / 最大起算诊次扩展到五诊、六诊…) */
|
||||||
const visitSlots = ref<number[]>([2, 3, 4])
|
const visitSlots = ref<number[]>([2, 3, 4])
|
||||||
|
|
||||||
const cnDigits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
|
const cnDigits = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
|
||||||
const slotLabel = (slot: number) => {
|
const slotLabel = (slot: number) => {
|
||||||
|
if (slot === 0) {
|
||||||
|
return '复诊合计'
|
||||||
|
}
|
||||||
if (slot >= 2 && slot <= 10) {
|
if (slot >= 2 && slot <= 10) {
|
||||||
return `${cnDigits[slot]}诊`
|
return `${cnDigits[slot]}诊`
|
||||||
}
|
}
|
||||||
@@ -307,6 +491,8 @@ const initNow = new Date()
|
|||||||
const currentMonth = `${initNow.getFullYear()}-${String(initNow.getMonth() + 1).padStart(2, '0')}`
|
const currentMonth = `${initNow.getFullYear()}-${String(initNow.getMonth() + 1).padStart(2, '0')}`
|
||||||
const month = ref<string>(currentMonth)
|
const month = ref<string>(currentMonth)
|
||||||
const deptId = ref<number | undefined>(undefined)
|
const deptId = ref<number | undefined>(undefined)
|
||||||
|
/** 口径:首次加载不传、取后端默认(config default_inherit_mode),之后以用户选择为准 */
|
||||||
|
const inheritMode = ref<RevisitRateInheritMode | ''>('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const rows = ref<StatsRow[]>([])
|
const rows = ref<StatsRow[]>([])
|
||||||
const total = ref<MetricPack | null>(null)
|
const total = ref<MetricPack | null>(null)
|
||||||
@@ -314,6 +500,8 @@ const total = ref<MetricPack | null>(null)
|
|||||||
const deptTreeOptions = ref<DeptTreeNode[]>([])
|
const deptTreeOptions = ref<DeptTreeNode[]>([])
|
||||||
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
|
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
|
||||||
|
|
||||||
|
const isSlotMode = computed(() => inheritMode.value !== 'exclude')
|
||||||
|
|
||||||
const monthText = computed(() => {
|
const monthText = computed(() => {
|
||||||
const [y, m] = month.value.split('-')
|
const [y, m] = month.value.split('-')
|
||||||
return y && m ? `${y}年${Number(m)}月` : month.value
|
return y && m ? `${y}年${Number(m)}月` : month.value
|
||||||
@@ -322,9 +510,30 @@ const monthText = computed(() => {
|
|||||||
const formatRate = (rate: number | null | undefined) =>
|
const formatRate = (rate: number | null | undefined) =>
|
||||||
rate === null || rate === undefined ? '—' : `${rate}%`
|
rate === null || rate === undefined ? '—' : `${rate}%`
|
||||||
|
|
||||||
|
/** 接诊率单元格下方灰字:单数 / 分母 */
|
||||||
|
const denText = (row: Record<string, any>, prefix: string) => {
|
||||||
|
const cnt = Number(row[`${prefix}_count`] ?? 0)
|
||||||
|
const den = row[`${prefix}_den`]
|
||||||
|
return `${cnt} / ${den === null || den === undefined ? '—' : den}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateHeaderTip = (slot: number) =>
|
||||||
|
isSlotMode.value
|
||||||
|
? `${slotLabel(slot)}单数 ÷ ${slotLabel(slot)}分母;分母 = 当月新指派数 + 起算诊次 ≤ ${slot} 的继承数;分母为 0 显示「—」`
|
||||||
|
: `${slotLabel(slot)}单数 ÷ 当月新指派数(旧口径:继承不计分母);分母为 0 显示「—」`
|
||||||
|
|
||||||
|
const entrySlotText = (entry: unknown) => {
|
||||||
|
const n = Number(entry ?? 0)
|
||||||
|
if (n <= 0) {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
return n > 50 ? `${n}(仅计合计)` : slotLabel(n)
|
||||||
|
}
|
||||||
|
|
||||||
const baseQuery = () => ({
|
const baseQuery = () => ({
|
||||||
month: month.value,
|
month: month.value,
|
||||||
dept_ids: deptId.value ? String(deptId.value) : ''
|
dept_ids: deptId.value ? String(deptId.value) : '',
|
||||||
|
inherit_mode: (inheritMode.value || undefined) as RevisitRateInheritMode | undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
interface SummaryCard {
|
interface SummaryCard {
|
||||||
@@ -341,11 +550,11 @@ const summaryCards = computed<SummaryCard[]>(() => {
|
|||||||
const cards: SummaryCard[] = [
|
const cards: SummaryCard[] = [
|
||||||
{
|
{
|
||||||
key: 'assigned',
|
key: 'assigned',
|
||||||
label: '当月被指派总数',
|
label: isSlotMode.value ? '当月新指派数' : '当月被指派数',
|
||||||
value: t ? String(t.assigned_count) : '—',
|
value: t ? String(t.assigned_count) : '—',
|
||||||
sub: '',
|
sub: t && isSlotMode.value ? `继承 ${t.inherit_count ?? 0} · 被指派合计 ${t.assigned_total_count ?? 0}` : '',
|
||||||
clickable: !!(canViewAssignLines && t && t.assigned_count > 0),
|
clickable: !!(canViewAssignLines && t && t.assigned_count > 0),
|
||||||
onClick: () => openAssignDetail(null)
|
onClick: () => openAssignDetail(null, 'new')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
for (const slot of visitSlots.value) {
|
for (const slot of visitSlots.value) {
|
||||||
@@ -355,11 +564,19 @@ const summaryCards = computed<SummaryCard[]>(() => {
|
|||||||
key: `visit${slot}`,
|
key: `visit${slot}`,
|
||||||
label: `当月${slotLabel(slot)}接诊率`,
|
label: `当月${slotLabel(slot)}接诊率`,
|
||||||
value: t ? formatRate(rate) : '—',
|
value: t ? formatRate(rate) : '—',
|
||||||
sub: t ? `${slotLabel(slot)}下单 ${cnt} 单` : '',
|
sub: t ? `${slotLabel(slot)}下单 ${cnt} 单 / 分母 ${t[`visit${slot}_den`] ?? '—'}` : '',
|
||||||
clickable: !!(canViewOrderLines && cnt > 0),
|
clickable: !!(canViewOrderLines && cnt > 0),
|
||||||
onClick: () => openOrderDetail(slot, null)
|
onClick: () => openOrderDetail(slot, null)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
cards.push({
|
||||||
|
key: 'visit_all',
|
||||||
|
label: '当月复诊合计接诊率',
|
||||||
|
value: t ? formatRate(t.visit_all_rate) : '—',
|
||||||
|
sub: t ? `复诊下单 ${t.visit_all_count ?? 0} 单 / 被指派合计 ${t.visit_all_den ?? '—'}` : '',
|
||||||
|
clickable: !!(canViewOrderLines && t && Number(t.visit_all_count) > 0),
|
||||||
|
onClick: () => openOrderDetail(0, null)
|
||||||
|
})
|
||||||
return cards
|
return cards
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -374,15 +591,16 @@ const getSummaries = ({ columns }: { columns: any[] }) => {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
const prop = String(col.property || '')
|
const prop = String(col.property || '')
|
||||||
if (prop === 'assigned_count') {
|
if (prop === 'assigned_count' || prop === 'inherit_count' || prop === 'assigned_total_count') {
|
||||||
return String(t.assigned_count)
|
return String(t[prop] ?? 0)
|
||||||
|
}
|
||||||
|
const m = /^visit(\d+|all)_(count|den|rate)$/.exec(prop)
|
||||||
|
if (m) {
|
||||||
|
if (m[2] === 'rate') {
|
||||||
|
return `${formatRate(t[prop])}(${denText(t, `visit${m[1]}`)})`
|
||||||
}
|
}
|
||||||
if (/^visit\d+_count$/.test(prop)) {
|
|
||||||
return String(t[prop] ?? '')
|
return String(t[prop] ?? '')
|
||||||
}
|
}
|
||||||
if (/^visit\d+_rate$/.test(prop)) {
|
|
||||||
return formatRate(t[prop])
|
|
||||||
}
|
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -404,6 +622,9 @@ const loadData = async () => {
|
|||||||
if (res?.month) {
|
if (res?.month) {
|
||||||
month.value = res.month
|
month.value = res.month
|
||||||
}
|
}
|
||||||
|
if (res?.inherit_mode === 'slot' || res?.inherit_mode === 'exclude') {
|
||||||
|
inheritMode.value = res.inherit_mode
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -449,6 +670,8 @@ const detailType = ref<'assign' | 'orders'>('assign')
|
|||||||
const detailTitle = ref('')
|
const detailTitle = ref('')
|
||||||
const detailLoading = ref(false)
|
const detailLoading = ref(false)
|
||||||
const detailRows = ref<any[]>([])
|
const detailRows = ref<any[]>([])
|
||||||
|
/** 订单明细的诊次(0 = 复诊合计,表格多显示「诊次」列) */
|
||||||
|
const detailSlot = ref(2)
|
||||||
|
|
||||||
/** row 为 null 时表示合计(当前部门筛选范围) */
|
/** row 为 null 时表示合计(当前部门筛选范围) */
|
||||||
const rowScope = (row: StatsRow | null): { assistant_id?: number; dept_id?: number; label: string } => {
|
const rowScope = (row: StatsRow | null): { assistant_id?: number; dept_id?: number; label: string } => {
|
||||||
@@ -461,13 +684,19 @@ const rowScope = (row: StatsRow | null): { assistant_id?: number; dept_id?: numb
|
|||||||
return { assistant_id: row.assistant_id, label: row.assistant_name || '' }
|
return { assistant_id: row.assistant_id, label: row.assistant_name || '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const openAssignDetail = async (row: StatsRow | null) => {
|
const assignKindLabel: Record<RevisitRateAssignKind, string> = {
|
||||||
|
new: '新指派明细',
|
||||||
|
inherit: '继承明细',
|
||||||
|
all: '被指派合计明细'
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAssignDetail = async (row: StatsRow | null, kind: RevisitRateAssignKind = 'all') => {
|
||||||
if (!canViewAssignLines) {
|
if (!canViewAssignLines) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const scope = rowScope(row)
|
const scope = rowScope(row)
|
||||||
detailType.value = 'assign'
|
detailType.value = 'assign'
|
||||||
detailTitle.value = `${scope.label} · 被指派明细(${monthText.value})`
|
detailTitle.value = `${scope.label} · ${assignKindLabel[kind]}(${monthText.value})`
|
||||||
detailVisible.value = true
|
detailVisible.value = true
|
||||||
detailLoading.value = true
|
detailLoading.value = true
|
||||||
detailRows.value = []
|
detailRows.value = []
|
||||||
@@ -475,7 +704,8 @@ const openAssignDetail = async (row: StatsRow | null) => {
|
|||||||
const res = await revisitRateAssignLines({
|
const res = await revisitRateAssignLines({
|
||||||
...baseQuery(),
|
...baseQuery(),
|
||||||
assistant_id: scope.assistant_id,
|
assistant_id: scope.assistant_id,
|
||||||
dept_id: scope.dept_id
|
dept_id: scope.dept_id,
|
||||||
|
kind
|
||||||
})
|
})
|
||||||
detailRows.value = res?.rows ?? []
|
detailRows.value = res?.rows ?? []
|
||||||
} finally {
|
} finally {
|
||||||
@@ -483,12 +713,14 @@ const openAssignDetail = async (row: StatsRow | null) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** slot=0 表示全部诊次 ≥ 2(复诊合计单数) */
|
||||||
const openOrderDetail = async (slot: number, row: StatsRow | null) => {
|
const openOrderDetail = async (slot: number, row: StatsRow | null) => {
|
||||||
if (!canViewOrderLines) {
|
if (!canViewOrderLines) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const scope = rowScope(row)
|
const scope = rowScope(row)
|
||||||
detailType.value = 'orders'
|
detailType.value = 'orders'
|
||||||
|
detailSlot.value = slot
|
||||||
detailTitle.value = `${scope.label} · ${slotLabel(slot)}订单明细(${monthText.value})`
|
detailTitle.value = `${scope.label} · ${slotLabel(slot)}订单明细(${monthText.value})`
|
||||||
detailVisible.value = true
|
detailVisible.value = true
|
||||||
detailLoading.value = true
|
detailLoading.value = true
|
||||||
@@ -529,6 +761,7 @@ const openPatientDetail = (diagnosisId: number) => {
|
|||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
month.value = currentMonth
|
month.value = currentMonth
|
||||||
deptId.value = undefined
|
deptId.value = undefined
|
||||||
|
inheritMode.value = ''
|
||||||
loadData()
|
loadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,6 +856,22 @@ onMounted(() => {
|
|||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
cursor: help;
|
cursor: help;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rate-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
line-height: 1.3;
|
||||||
|
|
||||||
|
.rate-cell-main {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rate-cell-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.rate-detail-footer {
|
.rate-detail-footer {
|
||||||
@@ -640,5 +889,10 @@ onMounted(() => {
|
|||||||
p + p {
|
p + p {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rate-caliber-note {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -12,9 +12,21 @@
|
|||||||
{{ assistantLogCellText(row, 'to') }}
|
{{ assistantLogCellText(row, 'to') }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="继承" width="72" align="center">
|
<el-table-column label="继承" width="118" align="center">
|
||||||
|
<template #header>
|
||||||
|
<span>继承</span>
|
||||||
|
<el-tooltip
|
||||||
|
placement="top"
|
||||||
|
content="继承时第 k 诊:继承时刻之前患者已完成的诊次(有效实单数 + 偏移),复诊接诊率从第 k+1 诊起计入新医助分母;2026-10 前的继承记录无此快照"
|
||||||
|
>
|
||||||
|
<el-icon class="assign-log-col-hint"><QuestionFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag v-if="Number(row.is_inherit) === 1" type="success" size="small">是</el-tag>
|
<template v-if="Number(row.is_inherit) === 1">
|
||||||
|
<el-tag type="success" size="small">是</el-tag>
|
||||||
|
<div v-if="inheritSlotText(row)" class="assign-log-inherit-slot">{{ inheritSlotText(row) }}</div>
|
||||||
|
</template>
|
||||||
<span v-else class="text-gray-400">否</span>
|
<span v-else class="text-gray-400">否</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -85,6 +97,20 @@ function relatedPoCreateTimeCell(row: Record<string, unknown>): string {
|
|||||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 对应日志表 inherit_visit_slot / inherit_visit_slot_calc_time:后端已拼好「继承时第 k 诊」文案,缺失时按快照列兜底 */
|
||||||
|
function inheritSlotText(row: Record<string, unknown>): string {
|
||||||
|
const text = row.inherit_visit_slot_text
|
||||||
|
if (text != null && String(text).trim() !== '') {
|
||||||
|
return String(text)
|
||||||
|
}
|
||||||
|
const calcTime = Number(row.inherit_visit_slot_calc_time)
|
||||||
|
if (!Number.isFinite(calcTime) || calcTime <= 0) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const k = Number(row.inherit_visit_slot)
|
||||||
|
return Number.isFinite(k) && k > 0 ? `继承时第 ${k} 诊` : '继承时未成交'
|
||||||
|
}
|
||||||
|
|
||||||
function assistantLogCellText(row: Record<string, unknown>, side: 'from' | 'to'): string {
|
function assistantLogCellText(row: Record<string, unknown>, side: 'from' | 'to'): string {
|
||||||
const nameKey = side === 'from' ? 'from_assistant_name' : 'to_assistant_name'
|
const nameKey = side === 'from' ? 'from_assistant_name' : 'to_assistant_name'
|
||||||
const idKey = side === 'from' ? 'from_assistant_id' : 'to_assistant_id'
|
const idKey = side === 'from' ? 'from_assistant_id' : 'to_assistant_id'
|
||||||
@@ -132,4 +158,11 @@ defineExpose({ refresh: load })
|
|||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
cursor: help;
|
cursor: help;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.assign-log-inherit-slot {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="['rv-cell', { 'rv-cell--compact': compact }]">
|
||||||
|
<!-- 行 1:[第 N 诊] tag + 已开方/未开方 + 复诊医生 -->
|
||||||
|
<div class="rv-line rv-line--head">
|
||||||
|
<el-tooltip
|
||||||
|
v-if="showDue && tooltipLines.length"
|
||||||
|
placement="top"
|
||||||
|
:show-after="300"
|
||||||
|
:hide-after="0"
|
||||||
|
>
|
||||||
|
<template #content>
|
||||||
|
<div class="rv-tooltip">
|
||||||
|
<div v-for="line in tooltipLines" :key="line">{{ line }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-tag size="small" :type="slotTagType" effect="light" class="rv-slot-tag">
|
||||||
|
{{ slotText }}
|
||||||
|
</el-tag>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tag
|
||||||
|
v-else-if="showDue"
|
||||||
|
size="small"
|
||||||
|
:type="slotTagType"
|
||||||
|
effect="light"
|
||||||
|
class="rv-slot-tag"
|
||||||
|
>
|
||||||
|
{{ slotText }}
|
||||||
|
</el-tag>
|
||||||
|
<span
|
||||||
|
v-if="prescribedLabel"
|
||||||
|
:class="['rv-prescribed', hasPrescription ? 'rv-prescribed--yes' : 'rv-prescribed--no']"
|
||||||
|
>
|
||||||
|
{{ prescribedLabel }}
|
||||||
|
</span>
|
||||||
|
<span v-if="followupDoctorName" class="rv-doctor">
|
||||||
|
<span v-if="prescribedLabel" class="rv-dot">·</span>{{ followupDoctorName }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="showDue">
|
||||||
|
<!-- 行 2:最新开药日 · 开药 X 天 · 锚点来源;缺天数时橙色 -->
|
||||||
|
<div class="rv-line rv-line--order">
|
||||||
|
<span v-if="orderDateText" class="rv-order-date">{{ orderDateText }} 开药</span>
|
||||||
|
<span v-if="orderDateText" class="rv-dot">·</span>
|
||||||
|
<span :class="['rv-days', { 'rv-days--missing': daysMissing }]">{{ medicationText }}</span>
|
||||||
|
<template v-if="!daysMissing && anchorText">
|
||||||
|
<span class="rv-dot">·</span>
|
||||||
|
<span class="rv-anchor">{{ anchorText }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 行 3:药尽 MM-DD + 状态 chip -->
|
||||||
|
<div class="rv-line rv-line--due">
|
||||||
|
<span v-if="dueDateText" class="rv-due-date">{{ dueDateText }}</span>
|
||||||
|
<el-tag
|
||||||
|
v-if="chipText"
|
||||||
|
size="small"
|
||||||
|
:type="chipType"
|
||||||
|
:effect="chipEffect"
|
||||||
|
:class="chipClass"
|
||||||
|
>
|
||||||
|
{{ chipText }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 行 4:已延迟 N 次 · 已联系 约 MM-DD(有值才显示) -->
|
||||||
|
<div v-if="followLineText" class="rv-line rv-line--follow">{{ followLineText }}</div>
|
||||||
|
</template>
|
||||||
|
<span v-else class="rv-none">—</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 复诊节点单元格(PC 列表 / H5 卡片紧凑 / 只读页 三处共用)
|
||||||
|
*
|
||||||
|
* 只负责展示,不发请求;数据来自列表行 `revisit_due`(契约 C),由父组件用
|
||||||
|
* `normalizeRevisitDue` 规整后传入。state='none' 或 due 为 null 时仅显示行 1 + 「—」。
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatRevisitMonthDay,
|
||||||
|
hasRevisitDue,
|
||||||
|
isRevisitDaysMissing,
|
||||||
|
revisitAnchorSrcText,
|
||||||
|
revisitDueDateText,
|
||||||
|
revisitFollowLineText,
|
||||||
|
revisitMedicationDaysText,
|
||||||
|
revisitSlotText,
|
||||||
|
revisitStateChipClass,
|
||||||
|
revisitStateChipText,
|
||||||
|
revisitStateTagEffect,
|
||||||
|
revisitStateTagType,
|
||||||
|
revisitTooltipLines,
|
||||||
|
type RevisitDue
|
||||||
|
} from '@/utils/revisitDue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 规整后的复诊节点对象;null / state='none' 显示「—」 */
|
||||||
|
due: RevisitDue | null
|
||||||
|
/** H5 紧凑模式:更小字号、更紧行距 */
|
||||||
|
compact?: boolean
|
||||||
|
/** 是否已开方(决定「已开方/未开方」默认文案与颜色) */
|
||||||
|
hasPrescription?: boolean
|
||||||
|
/** 复诊医生(原列表 followup_doctor_name) */
|
||||||
|
followupDoctorName?: string
|
||||||
|
/** 自定义「已开方/未开方」文案;传 '' 隐藏该段(H5 卡片头已有标签时用) */
|
||||||
|
prescribedText?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
compact: false,
|
||||||
|
hasPrescription: false,
|
||||||
|
followupDoctorName: ''
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const showDue = computed(() => hasRevisitDue(props.due))
|
||||||
|
|
||||||
|
const prescribedLabel = computed(() => {
|
||||||
|
if (props.prescribedText !== undefined) return props.prescribedText
|
||||||
|
return props.hasPrescription ? '已开方' : '未开方'
|
||||||
|
})
|
||||||
|
|
||||||
|
const slotText = computed(() => revisitSlotText(props.due))
|
||||||
|
const slotTagType = computed<'primary' | 'info'>(() =>
|
||||||
|
showDue.value && Number(props.due?.current_slot) > 0 ? 'primary' : 'info'
|
||||||
|
)
|
||||||
|
const tooltipLines = computed(() => revisitTooltipLines(props.due))
|
||||||
|
|
||||||
|
const orderDateText = computed(() => (showDue.value ? formatRevisitMonthDay(props.due?.order_date) : ''))
|
||||||
|
const daysMissing = computed(() => isRevisitDaysMissing(props.due))
|
||||||
|
const medicationText = computed(() => revisitMedicationDaysText(props.due))
|
||||||
|
const anchorText = computed(() => (showDue.value ? revisitAnchorSrcText(props.due?.anchor_src) : ''))
|
||||||
|
|
||||||
|
const dueDateText = computed(() => revisitDueDateText(props.due))
|
||||||
|
const chipText = computed(() => revisitStateChipText(props.due))
|
||||||
|
const chipType = computed(() => revisitStateTagType(props.due?.state))
|
||||||
|
const chipEffect = computed(() => revisitStateTagEffect(props.due?.state))
|
||||||
|
const chipClass = computed(() => revisitStateChipClass(props.due))
|
||||||
|
|
||||||
|
const followLineText = computed(() => revisitFollowLineText(props.due))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.rv-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
line-height: 1.35;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-line--head {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-slot-tag {
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-prescribed {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-prescribed--yes {
|
||||||
|
color: #4a5dff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-prescribed--no {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-doctor {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-dot {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-line--order {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-days--missing {
|
||||||
|
color: #ea580c;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-anchor {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-line--due {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-due-date {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 状态 chip:el-tag 基础色由 type/effect 决定,这里只补深红加粗与描边细节。
|
||||||
|
前缀 .rv-cell 提高特异性,压过 Element 的 .el-tag--dark.el-tag--danger 变量定义 */
|
||||||
|
.rv-cell .rv-chip {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-cell .rv-chip--overdue,
|
||||||
|
.rv-cell .rv-chip--missed {
|
||||||
|
--el-tag-bg-color: #dc2626;
|
||||||
|
--el-tag-border-color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-cell .rv-chip--severe,
|
||||||
|
.rv-cell .rv-chip--overdue.rv-chip--severe,
|
||||||
|
.rv-cell .rv-chip--missed.rv-chip--severe {
|
||||||
|
font-weight: 700;
|
||||||
|
--el-tag-bg-color: #b91c1c;
|
||||||
|
--el-tag-border-color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-cell .rv-chip--warning {
|
||||||
|
--el-tag-text-color: #ea580c;
|
||||||
|
--el-tag-bg-color: rgba(234, 88, 12, 0.1);
|
||||||
|
--el-tag-border-color: rgba(234, 88, 12, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-cell .rv-chip--no_days {
|
||||||
|
--el-tag-text-color: #ea580c;
|
||||||
|
--el-tag-border-color: #ea580c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-cell .rv-chip--visited {
|
||||||
|
--el-tag-text-color: #16a34a;
|
||||||
|
--el-tag-bg-color: rgba(22, 163, 74, 0.1);
|
||||||
|
--el-tag-border-color: rgba(22, 163, 74, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-cell .rv-chip--booked {
|
||||||
|
--el-tag-text-color: #4a5dff;
|
||||||
|
--el-tag-bg-color: rgba(74, 93, 255, 0.1);
|
||||||
|
--el-tag-border-color: rgba(74, 93, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-cell .rv-chip--contacted {
|
||||||
|
--el-tag-text-color: #4a5dff;
|
||||||
|
--el-tag-border-color: #4a5dff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-line--follow {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-none {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-tooltip {
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* H5 紧凑模式 */
|
||||||
|
.rv-cell--compact {
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
.rv-line--head,
|
||||||
|
.rv-line--due,
|
||||||
|
.rv-prescribed {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-line--order,
|
||||||
|
.rv-line--follow,
|
||||||
|
.rv-doctor {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rv-none {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
formData.pending_assign !== '1' &&
|
formData.pending_assign !== '1' &&
|
||||||
formData.pending_booking !== '1' &&
|
formData.pending_booking !== '1' &&
|
||||||
formData.completed_appointment !== '1' &&
|
formData.completed_appointment !== '1' &&
|
||||||
|
formData.revisit_state === '' &&
|
||||||
!hasLatestAppointmentFilter() &&
|
!hasLatestAppointmentFilter() &&
|
||||||
formData.appointment_date === tab.value
|
formData.appointment_date === tab.value
|
||||||
}
|
}
|
||||||
@@ -30,6 +31,14 @@
|
|||||||
待预约
|
待预约
|
||||||
<em v-if="pendingBookingCount !== undefined">{{ pendingBookingCount }}</em>
|
<em v-if="pendingBookingCount !== undefined">{{ pendingBookingCount }}</em>
|
||||||
</span>
|
</span>
|
||||||
|
<!-- 复诊到期:到期未挂号 ∪ 过号未面诊(revisit_state=due),点击默认按药尽日升序 -->
|
||||||
|
<span
|
||||||
|
:class="['chip', 'chip-revisit-due', { active: formData.revisit_state === 'due' }]"
|
||||||
|
@click="handleRevisitDueTabClick"
|
||||||
|
>
|
||||||
|
复诊到期
|
||||||
|
<em v-if="revisitDueCount !== undefined">{{ revisitDueCount }}</em>
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
:class="['chip', 'chip-completed-visit', { active: formData.completed_appointment === '1' }]"
|
:class="['chip', 'chip-completed-visit', { active: formData.completed_appointment === '1' }]"
|
||||||
@click="handleCompletedVisitTabClick"
|
@click="handleCompletedVisitTabClick"
|
||||||
@@ -147,6 +156,36 @@
|
|||||||
:value="String(item.value)"
|
:value="String(item.value)"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
|
<el-select
|
||||||
|
v-model="formData.revisit_state"
|
||||||
|
placeholder="复诊状态"
|
||||||
|
clearable
|
||||||
|
size="small"
|
||||||
|
class="revisit-state-select"
|
||||||
|
@change="handleRevisitFilterChange"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in revisitStateFilterOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-select
|
||||||
|
v-model="formData.revisit_days_bucket"
|
||||||
|
placeholder="开药天数档"
|
||||||
|
clearable
|
||||||
|
size="small"
|
||||||
|
class="revisit-days-bucket-select"
|
||||||
|
@change="doSearch"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in revisitDaysBucketOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
<el-button size="small" @click="handleReset">重置</el-button>
|
<el-button size="small" @click="handleReset">重置</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
@@ -278,22 +317,20 @@
|
|||||||
<span v-else class="status-unconfirmed">未确认</span>
|
<span v-else class="status-unconfirmed">未确认</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="复诊" min-width="120">
|
<!-- 复诊节点:吞掉原「复诊」「开方」两列;排序走后端 sort_revisit_due(药尽日,NULL 置后) -->
|
||||||
<template #default="{ row }">
|
<el-table-column
|
||||||
<div v-if="row.has_prescription" class="followup-cell">
|
label="复诊节点"
|
||||||
<div class="followup-time">{{ row.followup_time_text || '—' }}</div>
|
prop="revisit_due_date"
|
||||||
<div class="followup-doctor">{{ row.followup_doctor_name || '—' }}</div>
|
min-width="180"
|
||||||
<el-tag
|
sortable="custom"
|
||||||
v-if="row.followup_rx_voided"
|
|
||||||
type="info"
|
|
||||||
size="small"
|
|
||||||
effect="plain"
|
|
||||||
class="followup-void-tag"
|
|
||||||
>
|
>
|
||||||
处方已作废
|
<template #default="{ row }">
|
||||||
</el-tag>
|
<revisit-node-cell
|
||||||
</div>
|
:due="revisitDueOf(row)"
|
||||||
<span v-else class="followup-none">无</span>
|
:has-prescription="!!row.has_prescription"
|
||||||
|
:followup-doctor-name="row.followup_doctor_name || ''"
|
||||||
|
:prescribed-text="prescribedTextOf(row)"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="助理" width="100" show-overflow-tooltip>
|
<el-table-column label="助理" width="100" show-overflow-tooltip>
|
||||||
@@ -301,12 +338,6 @@
|
|||||||
<span class="assistant-cell">{{ getAssistantName(row.assistant_id || row.assistant) }}</span>
|
<span class="assistant-cell">{{ getAssistantName(row.assistant_id || row.assistant) }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="开方" width="72" align="center">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<span v-if="row.has_prescription" class="status-prescribed">已开方</span>
|
|
||||||
<span v-else class="status-unprescribed">未开方</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column
|
<el-table-column
|
||||||
label="未服务天数"
|
label="未服务天数"
|
||||||
prop="unserved_days"
|
prop="unserved_days"
|
||||||
@@ -519,7 +550,6 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="继承">
|
<el-form-item label="继承">
|
||||||
<el-checkbox v-model="assignForm.is_inherit">继承</el-checkbox>
|
<el-checkbox v-model="assignForm.is_inherit">继承</el-checkbox>
|
||||||
<span class="text-gray-400 text-xs ml-2">勾选后写入指派记录</span>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="assignMode === 'batch'" label="已选择">
|
<el-form-item v-if="assignMode === 'batch'" label="已选择">
|
||||||
<div class="text-gray-500">{{ batchAssignIds.length }} 条诊单记录</div>
|
<div class="text-gray-500">{{ batchAssignIds.length }} 条诊单记录</div>
|
||||||
@@ -737,7 +767,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts" name="tcmDiagnosis">
|
<script setup lang="ts" name="tcmDiagnosis">
|
||||||
import { tcmDiagnosisLists, tcmDiagnosisDelete, tcmDiagnosisAssign, tcmDiagnosisGuahaoLogList, getAssistants, generateMiniProgramQrcode, generateOrderQrcode, getWechatChatRecords, addWechatChatRecord, deleteWechatChatRecord, getWechatExternalContact, fillIdCard } from '@/api/tcm'
|
import { tcmDiagnosisLists, tcmDiagnosisDelete, tcmDiagnosisAssign, tcmDiagnosisGuahaoLogList, getAssistants, generateMiniProgramQrcode, generateOrderQrcode, getWechatChatRecords, addWechatChatRecord, deleteWechatChatRecord, getWechatExternalContact, fillIdCard, diagnosisReadonlyDetail } from '@/api/tcm'
|
||||||
import { orderCreate } from '@/api/order'
|
import { orderCreate } from '@/api/order'
|
||||||
import { cancelAppointment } from '@/api/doctor'
|
import { cancelAppointment } from '@/api/doctor'
|
||||||
import { usePaging } from '@/hooks/usePaging'
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
@@ -752,6 +782,15 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { computed, defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
|
import { computed, defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import DaterangePicker from '@/components/daterange-picker/index.vue'
|
import DaterangePicker from '@/components/daterange-picker/index.vue'
|
||||||
|
import RevisitNodeCell from './components/RevisitNodeCell.vue'
|
||||||
|
import { formatGender } from '@/utils/diag-display'
|
||||||
|
import {
|
||||||
|
normalizeRevisitDue,
|
||||||
|
REVISIT_DAYS_BUCKET_OPTIONS,
|
||||||
|
REVISIT_STATE_FILTER_OPTIONS,
|
||||||
|
revisitRowClassName,
|
||||||
|
type RevisitDue
|
||||||
|
} from '@/utils/revisitDue'
|
||||||
|
|
||||||
const EditPopup = defineAsyncComponent(() => import('./edit.vue'))
|
const EditPopup = defineAsyncComponent(() => import('./edit.vue'))
|
||||||
const DetailPopup = defineAsyncComponent(() => import('./detail.vue'))
|
const DetailPopup = defineAsyncComponent(() => import('./detail.vue'))
|
||||||
@@ -798,6 +837,12 @@ const formData = reactive({
|
|||||||
latest_assign_end_date: '' as string,
|
latest_assign_end_date: '' as string,
|
||||||
/** 未服务天数排序:desc=天数多到少 asc=少到多 */
|
/** 未服务天数排序:desc=天数多到少 asc=少到多 */
|
||||||
sort_unserved_days: '' as '' | 'asc' | 'desc',
|
sort_unserved_days: '' as '' | 'asc' | 'desc',
|
||||||
|
/** 复诊状态筛选:due(到期未挂号∪过号)|warning|booked|visited|contacted|no_days|pending,空=不限 */
|
||||||
|
revisit_state: '' as string,
|
||||||
|
/** 开药天数档筛选:14|21|28|30|other,空=不限 */
|
||||||
|
revisit_days_bucket: '' as string,
|
||||||
|
/** 复诊节点(药尽日)排序:asc=最早到期在前(NULL 置后) desc 反之;与 sort_unserved_days 互斥 */
|
||||||
|
sort_revisit_due: '' as '' | 'asc' | 'desc',
|
||||||
diagnosis_confirmed: '' as '' | '0' | '1',
|
diagnosis_confirmed: '' as '' | '0' | '1',
|
||||||
appointment_date: '' as string,
|
appointment_date: '' as string,
|
||||||
has_appointment: '' as '' | '0' | '1',
|
has_appointment: '' as '' | '0' | '1',
|
||||||
@@ -898,11 +943,13 @@ function buildSharedDiagnosisFilterPayload(): Record<string, unknown> {
|
|||||||
latest_appointment_end_date: formData.latest_appointment_end_date,
|
latest_appointment_end_date: formData.latest_appointment_end_date,
|
||||||
latest_appointment_channel_source: formData.latest_appointment_channel_source,
|
latest_appointment_channel_source: formData.latest_appointment_channel_source,
|
||||||
latest_assign_start_date: formData.latest_assign_start_date,
|
latest_assign_start_date: formData.latest_assign_start_date,
|
||||||
latest_assign_end_date: formData.latest_assign_end_date
|
latest_assign_end_date: formData.latest_assign_end_date,
|
||||||
|
revisit_state: formData.revisit_state,
|
||||||
|
revisit_days_bucket: formData.revisit_days_bucket
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 顶部 Tab 角标 count 请求:带上共用筛选,再叠加各 Tab 专属条件 */
|
/** 顶部 Tab 角标 count 请求:带上共用筛选,再叠加各 Tab 专属条件(revisit_state 视为 Tab 条件,角标间互不影响) */
|
||||||
function buildDateCountRequestPayload(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
function buildDateCountRequestPayload(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
page_no: 1,
|
page_no: 1,
|
||||||
@@ -915,6 +962,8 @@ function buildDateCountRequestPayload(overrides: Record<string, unknown> = {}):
|
|||||||
pending_assign_order_month: '',
|
pending_assign_order_month: '',
|
||||||
pending_assign_keyword: '',
|
pending_assign_keyword: '',
|
||||||
sort_unserved_days: '',
|
sort_unserved_days: '',
|
||||||
|
revisit_state: '',
|
||||||
|
sort_revisit_due: '',
|
||||||
...overrides
|
...overrides
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -960,6 +1009,9 @@ function clearSecondaryFiltersWhenPendingAssignWideSearch() {
|
|||||||
formData.latest_assign_start_date = ''
|
formData.latest_assign_start_date = ''
|
||||||
formData.latest_assign_end_date = ''
|
formData.latest_assign_end_date = ''
|
||||||
formData.sort_unserved_days = ''
|
formData.sort_unserved_days = ''
|
||||||
|
formData.revisit_state = ''
|
||||||
|
formData.revisit_days_bucket = ''
|
||||||
|
formData.sort_revisit_due = ''
|
||||||
formData.pending_assign_order_month = ''
|
formData.pending_assign_order_month = ''
|
||||||
activeTab.value = 'all'
|
activeTab.value = 'all'
|
||||||
if (kw1 !== '') {
|
if (kw1 !== '') {
|
||||||
@@ -996,6 +1048,7 @@ const handleDateTabClick = async (value: string) => {
|
|||||||
formData.pending_booking = ''
|
formData.pending_booking = ''
|
||||||
formData.completed_appointment = ''
|
formData.completed_appointment = ''
|
||||||
formData.has_appointment = ''
|
formData.has_appointment = ''
|
||||||
|
clearRevisitTabFilters()
|
||||||
clearLatestAppointmentFilters()
|
clearLatestAppointmentFilters()
|
||||||
formData.appointment_date = value
|
formData.appointment_date = value
|
||||||
pager.page = 1
|
pager.page = 1
|
||||||
@@ -1012,6 +1065,7 @@ const handlePendingBookingTabClick = async () => {
|
|||||||
formData.completed_appointment = ''
|
formData.completed_appointment = ''
|
||||||
formData.appointment_date = ''
|
formData.appointment_date = ''
|
||||||
formData.has_appointment = '0'
|
formData.has_appointment = '0'
|
||||||
|
clearRevisitTabFilters()
|
||||||
clearLatestAppointmentFilters()
|
clearLatestAppointmentFilters()
|
||||||
pager.page = 1
|
pager.page = 1
|
||||||
await getLists()
|
await getLists()
|
||||||
@@ -1027,6 +1081,7 @@ const handleCompletedVisitTabClick = async () => {
|
|||||||
formData.completed_appointment = '1'
|
formData.completed_appointment = '1'
|
||||||
formData.has_appointment = ''
|
formData.has_appointment = ''
|
||||||
formData.appointment_date = ''
|
formData.appointment_date = ''
|
||||||
|
clearRevisitTabFilters()
|
||||||
clearLatestAppointmentFilters()
|
clearLatestAppointmentFilters()
|
||||||
pager.page = 1
|
pager.page = 1
|
||||||
await getLists()
|
await getLists()
|
||||||
@@ -1039,6 +1094,7 @@ const handlePendingAssignTabClick = async () => {
|
|||||||
formData.completed_appointment = ''
|
formData.completed_appointment = ''
|
||||||
formData.has_appointment = ''
|
formData.has_appointment = ''
|
||||||
formData.appointment_date = ''
|
formData.appointment_date = ''
|
||||||
|
clearRevisitTabFilters()
|
||||||
clearLatestAppointmentFilters()
|
clearLatestAppointmentFilters()
|
||||||
if (!formData.pending_assign_order_month) {
|
if (!formData.pending_assign_order_month) {
|
||||||
formData.pending_assign_order_month = dayjs().format('YYYY-MM')
|
formData.pending_assign_order_month = dayjs().format('YYYY-MM')
|
||||||
@@ -1057,10 +1113,66 @@ const onPendingAssignOrderMonthChange = async (val: string | null) => {
|
|||||||
fetchDateCounts()
|
fetchDateCounts()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取各日期挂号数量 + 全部 + 待预约 + 已完成 + 待分配医助数量
|
/** 复诊状态 / 开药天数档 下拉选项(文案与请求值映射在 utils/revisitDue.ts) */
|
||||||
|
const revisitStateFilterOptions = REVISIT_STATE_FILTER_OPTIONS
|
||||||
|
const revisitDaysBucketOptions = REVISIT_DAYS_BUCKET_OPTIONS
|
||||||
|
|
||||||
|
/** 顶部「复诊到期」角标:revisit_state=due 的 count */
|
||||||
|
const revisitDueCount = ref<number | undefined>(undefined)
|
||||||
|
|
||||||
|
/** 切换其它顶部 Tab 时清掉复诊 chip 状态与其排序(revisit_days_bucket 属「更多筛选」,保留) */
|
||||||
|
const clearRevisitTabFilters = () => {
|
||||||
|
formData.revisit_state = ''
|
||||||
|
formData.sort_revisit_due = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 顶部「复诊到期」chip:到期未挂号 ∪ 过号未面诊,默认按药尽日升序(超期最多在前) */
|
||||||
|
const handleRevisitDueTabClick = async () => {
|
||||||
|
formData.pending_assign = ''
|
||||||
|
formData.pending_assign_order_month = ''
|
||||||
|
formData.pending_assign_keyword = ''
|
||||||
|
formData.pending_booking = ''
|
||||||
|
formData.completed_appointment = ''
|
||||||
|
formData.appointment_date = ''
|
||||||
|
formData.has_appointment = ''
|
||||||
|
clearLatestAppointmentFilters()
|
||||||
|
formData.revisit_state = 'due'
|
||||||
|
formData.sort_revisit_due = 'asc'
|
||||||
|
formData.sort_unserved_days = ''
|
||||||
|
pager.page = 1
|
||||||
|
await getLists()
|
||||||
|
fetchDateCounts()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「更多筛选 → 复诊状态」下拉:与顶部日期 Tab 互斥(同 chip 语义),选中后不再默认当天挂号 */
|
||||||
|
const handleRevisitFilterChange = () => {
|
||||||
|
if (formData.revisit_state !== '') {
|
||||||
|
formData.appointment_date = ''
|
||||||
|
formData.pending_booking = ''
|
||||||
|
formData.completed_appointment = ''
|
||||||
|
const noSortYet = formData.sort_revisit_due === '' && formData.sort_unserved_days === ''
|
||||||
|
if (formData.revisit_state === 'due' && noSortYet) {
|
||||||
|
formData.sort_revisit_due = 'asc'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
formData.sort_revisit_due = ''
|
||||||
|
}
|
||||||
|
doSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表行 revisit_due → 规整对象(旧接口无字段时为 null,单元格显示「—」) */
|
||||||
|
const revisitDueOf = (row: any): RevisitDue | null => normalizeRevisitDue(row?.revisit_due)
|
||||||
|
|
||||||
|
/** 「复诊节点」行 1 的开方文案:已开方 / 已开方(处方已作废) / 未开方 */
|
||||||
|
const prescribedTextOf = (row: any): string => {
|
||||||
|
if (!row?.has_prescription) return '未开方'
|
||||||
|
return row.followup_rx_voided ? '已开方(处方已作废)' : '已开方'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取各日期挂号数量 + 全部 + 待预约 + 已完成 + 待分配医助数量 + 复诊到期数量
|
||||||
const fetchDateCounts = async () => {
|
const fetchDateCounts = async () => {
|
||||||
try {
|
try {
|
||||||
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending] = await Promise.all([
|
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending, revisitDue] = await Promise.all([
|
||||||
tcmDiagnosisLists(
|
tcmDiagnosisLists(
|
||||||
buildDateCountRequestPayload({ appointment_date: yesterdayStr.value, has_appointment: '' }) as any
|
buildDateCountRequestPayload({ appointment_date: yesterdayStr.value, has_appointment: '' }) as any
|
||||||
),
|
),
|
||||||
@@ -1081,7 +1193,10 @@ const fetchDateCounts = async () => {
|
|||||||
tcmDiagnosisLists(
|
tcmDiagnosisLists(
|
||||||
buildDateCountRequestPayload({ completed_appointment: 1, has_appointment: '' }) as any
|
buildDateCountRequestPayload({ completed_appointment: 1, has_appointment: '' }) as any
|
||||||
),
|
),
|
||||||
tcmDiagnosisLists(buildPendingAssignCountPayload() as any)
|
tcmDiagnosisLists(buildPendingAssignCountPayload() as any),
|
||||||
|
tcmDiagnosisLists(
|
||||||
|
buildDateCountRequestPayload({ revisit_state: 'due', has_appointment: '' }) as any
|
||||||
|
)
|
||||||
])
|
])
|
||||||
dateCounts.value = {
|
dateCounts.value = {
|
||||||
[yesterdayStr.value]: yesterday?.count ?? 0,
|
[yesterdayStr.value]: yesterday?.count ?? 0,
|
||||||
@@ -1094,6 +1209,7 @@ const fetchDateCounts = async () => {
|
|||||||
pendingBookingCount.value = noApt?.count ?? 0
|
pendingBookingCount.value = noApt?.count ?? 0
|
||||||
completedVisitCount.value = doneVisit?.count ?? 0
|
completedVisitCount.value = doneVisit?.count ?? 0
|
||||||
pendingAssignCount.value = pending?.count ?? 0
|
pendingAssignCount.value = pending?.count ?? 0
|
||||||
|
revisitDueCount.value = revisitDue?.count ?? 0
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('获取日期数量失败', e)
|
console.error('获取日期数量失败', e)
|
||||||
}
|
}
|
||||||
@@ -1252,12 +1368,11 @@ const handleTableSortChange = ({
|
|||||||
prop: string
|
prop: string
|
||||||
order: 'ascending' | 'descending' | null
|
order: 'ascending' | 'descending' | null
|
||||||
}) => {
|
}) => {
|
||||||
if (prop === 'unserved_days') {
|
const dir: '' | 'asc' | 'desc' =
|
||||||
formData.sort_unserved_days =
|
|
||||||
order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : ''
|
order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : ''
|
||||||
} else {
|
// 按列 prop 分派;两种排序后端互斥,切换列时清掉另一列
|
||||||
formData.sort_unserved_days = ''
|
formData.sort_unserved_days = prop === 'unserved_days' ? dir : ''
|
||||||
}
|
formData.sort_revisit_due = prop === 'revisit_due_date' ? dir : ''
|
||||||
pager.page = 1
|
pager.page = 1
|
||||||
getLists()
|
getLists()
|
||||||
}
|
}
|
||||||
@@ -1311,6 +1426,9 @@ const handleReset = () => {
|
|||||||
formData.latest_assign_start_date = ''
|
formData.latest_assign_start_date = ''
|
||||||
formData.latest_assign_end_date = ''
|
formData.latest_assign_end_date = ''
|
||||||
formData.sort_unserved_days = ''
|
formData.sort_unserved_days = ''
|
||||||
|
formData.revisit_state = ''
|
||||||
|
formData.revisit_days_bucket = ''
|
||||||
|
formData.sort_revisit_due = ''
|
||||||
formData.diagnosis_confirmed = ''
|
formData.diagnosis_confirmed = ''
|
||||||
formData.appointment_date = ''
|
formData.appointment_date = ''
|
||||||
formData.has_appointment = ''
|
formData.has_appointment = ''
|
||||||
@@ -1343,8 +1461,10 @@ const unservedDaysClass = (n: unknown) => {
|
|||||||
return 'unserved-green'
|
return 'unserved-green'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 行高亮:未确认诊单优先,其次未挂号
|
// 行高亮优先级(方案 D3-c 默认 overdue_first):复诊超期/过号 > 未确认 > 未挂号
|
||||||
const getRowClassName = ({ row }: { row: any }) => {
|
const getRowClassName = ({ row }: { row: any }) => {
|
||||||
|
const revisitCls = revisitRowClassName(revisitDueOf(row))
|
||||||
|
if (revisitCls) return revisitCls
|
||||||
if (!isDiagnosisConfirmed(row)) return 'row-unconfirmed'
|
if (!isDiagnosisConfirmed(row)) return 'row-unconfirmed'
|
||||||
if (!row.has_appointment) return 'row-no-appointment'
|
if (!row.has_appointment) return 'row-no-appointment'
|
||||||
return ''
|
return ''
|
||||||
@@ -1356,6 +1476,8 @@ const prescriptionRef = ref()
|
|||||||
const appointmentRef = ref()
|
const appointmentRef = ref()
|
||||||
/** 从路由带 id 进入时,诊单弹窗为异步组件,需在 ref 就绪后再 open */
|
/** 从路由带 id 进入时,诊单弹窗为异步组件,需在 ref 就绪后再 open */
|
||||||
const pendingRouteDiagnosisId = ref<number | null>(null)
|
const pendingRouteDiagnosisId = ref<number | null>(null)
|
||||||
|
/** 路由 ?id=&action=appointment 深链(复诊到期弹窗「去挂号」):挂号弹窗同为异步组件,先暂存行再 open */
|
||||||
|
const pendingRouteAppointmentRow = ref<Record<string, any> | null>(null)
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -1370,6 +1492,53 @@ watch(
|
|||||||
{ flush: 'post' }
|
{ flush: 'post' }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[appointmentRef, pendingRouteAppointmentRow],
|
||||||
|
() => {
|
||||||
|
const row = pendingRouteAppointmentRow.value
|
||||||
|
const opener = appointmentRef.value as { open?: (row: any) => void } | undefined
|
||||||
|
if (!row || !opener?.open) return
|
||||||
|
pendingRouteAppointmentRow.value = null
|
||||||
|
handleAppointment(row)
|
||||||
|
},
|
||||||
|
{ flush: 'post' }
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 深链打开挂号弹窗:优先用当前列表里的行(字段最全);不在本页时回退只读详情接口拼最小行
|
||||||
|
* (appointment.vue open() 只读 patient_id / patient_name / gender_desc / age)。
|
||||||
|
*/
|
||||||
|
const openAppointmentFromRoute = async (diagnosisId: number) => {
|
||||||
|
// 深链打开挂号弹窗前先校验挂号权限(与行内「预约」按钮 v-perms 同口径)
|
||||||
|
if (!hasPermission(['tcm.diagnosis/guahao'])) {
|
||||||
|
feedback.msgWarning('无挂号权限')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const inList = (pager.lists as any[]).find((r) => Number(r?.id) === diagnosisId)
|
||||||
|
if (inList) {
|
||||||
|
pendingRouteAppointmentRow.value = inList
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res: any = await diagnosisReadonlyDetail({ id: diagnosisId })
|
||||||
|
const diag = res?.diagnosis
|
||||||
|
if (!diag || !diag.patient_id) {
|
||||||
|
feedback.msgWarning('未找到该诊单或患者信息不完整,无法打开挂号')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingRouteAppointmentRow.value = {
|
||||||
|
...diag,
|
||||||
|
id: diagnosisId,
|
||||||
|
patient_id: diag.patient_id,
|
||||||
|
patient_name: diag.patient_name || '',
|
||||||
|
gender_desc: formatGender(diag.gender),
|
||||||
|
age: diag.age ?? 0
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
feedback.msgWarning(e?.msg || e?.message || '加载诊单失败,无法打开挂号')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const watchCallVisible = ref(false)
|
const watchCallVisible = ref(false)
|
||||||
const watchCallDiagnosisId = ref(0)
|
const watchCallDiagnosisId = ref(0)
|
||||||
|
|
||||||
@@ -2220,20 +2389,55 @@ const goReadonly = (row: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 默认展示当天挂号,突出重点
|
|
||||||
formData.appointment_date = todayStr.value
|
|
||||||
// 字典与列表并行,避免 await 字典阻塞首屏列表(LCP)
|
// 字典与列表并行,避免 await 字典阻塞首屏列表(LCP)
|
||||||
getDictOptions()
|
getDictOptions()
|
||||||
|
if (String(route.query.revisit_state ?? '') === 'due') {
|
||||||
|
// 复诊到期弹窗「查看全部」深链:直接激活「复诊到期」chip(内部会 getLists + fetchDateCounts)
|
||||||
|
void handleRevisitDueTabClick()
|
||||||
|
} else {
|
||||||
|
// 默认展示当天挂号,突出重点
|
||||||
|
formData.appointment_date = todayStr.value
|
||||||
getLists()
|
getLists()
|
||||||
scheduleDeferredDateCounts()
|
scheduleDeferredDateCounts()
|
||||||
|
}
|
||||||
|
|
||||||
silentListPollTimer = setInterval(silentRefreshListsAndDateCounts, SILENT_LIST_POLL_MS)
|
silentListPollTimer = setInterval(silentRefreshListsAndDateCounts, SILENT_LIST_POLL_MS)
|
||||||
|
|
||||||
const raw = route.query.id
|
const raw = route.query.id
|
||||||
if (raw != null && raw !== '') {
|
if (raw != null && raw !== '') {
|
||||||
const num = Number(raw)
|
const num = Number(raw)
|
||||||
if (!Number.isNaN(num)) pendingRouteDiagnosisId.value = num
|
if (!Number.isNaN(num) && num > 0) {
|
||||||
|
if (String(route.query.action ?? '') === 'appointment') {
|
||||||
|
// 复诊到期弹窗「去挂号」深链:等首屏列表回来后再找行,找不到走详情接口
|
||||||
|
void getListsPromiseForRoute().then(() => openAppointmentFromRoute(num))
|
||||||
|
} else {
|
||||||
|
pendingRouteDiagnosisId.value = num
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** onMounted 里 getLists() 已发起;等待首屏列表 loading 结束(最多 8s)再找行,失败/超时不阻断 */
|
||||||
|
const getListsPromiseForRoute = () =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
if (!pager.loading) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let settled = false
|
||||||
|
const finish = () => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
stop()
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
const stop = watch(
|
||||||
|
() => pager.loading,
|
||||||
|
(loading) => {
|
||||||
|
if (!loading) finish()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
setTimeout(finish, 8000)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -2313,6 +2517,21 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chip-revisit-due {
|
||||||
|
margin-left: 8px;
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
background: var(--el-color-danger-light-9);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--el-color-danger-light-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.chip-completed-visit {
|
.chip-completed-visit {
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
color: var(--el-color-success);
|
color: var(--el-color-success);
|
||||||
@@ -2451,6 +2670,14 @@ onUnmounted(() => {
|
|||||||
.latest-appointment-channel {
|
.latest-appointment-channel {
|
||||||
width: 170px;
|
width: 170px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.revisit-state-select {
|
||||||
|
width: 170px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revisit-days-bucket-select {
|
||||||
|
width: 130px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-card {
|
.list-card {
|
||||||
@@ -2641,17 +2868,6 @@ onUnmounted(() => {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-prescribed {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #4A5DFF;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-unprescribed {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--el-text-color-placeholder);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 未服务天数(T3):null=灰;>=7=红;3-6=橙;<=2=绿 */
|
/* 未服务天数(T3):null=灰;>=7=红;3-6=橙;<=2=绿 */
|
||||||
.unserved-days {
|
.unserved-days {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -2696,33 +2912,6 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.followup-cell {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
line-height: 1.35;
|
|
||||||
|
|
||||||
.followup-time {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--el-text-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.followup-doctor {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.followup-void-tag {
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.followup-none {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--el-text-color-placeholder);
|
|
||||||
}
|
|
||||||
|
|
||||||
.assistant-cell {
|
.assistant-cell {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
@@ -2782,6 +2971,22 @@ onUnmounted(() => {
|
|||||||
:deep(.el-table__row.row-no-appointment:hover > td) {
|
:deep(.el-table__row.row-no-appointment:hover > td) {
|
||||||
background: linear-gradient(90deg, rgba(var(--el-color-info-rgb), 0.08) 0%, transparent 100%) !important;
|
background: linear-gradient(90deg, rgba(var(--el-color-info-rgb), 0.08) 0%, transparent 100%) !important;
|
||||||
}
|
}
|
||||||
|
/* 复诊到期未挂号 / 过号未面诊:红色左边条(同 row-unconfirmed 结构,warning → danger) */
|
||||||
|
:deep(.el-table__row.row-revisit-overdue) {
|
||||||
|
border-left: 3px solid var(--el-color-danger);
|
||||||
|
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.06) 0%, transparent 100%) !important;
|
||||||
|
}
|
||||||
|
:deep(.el-table__row.row-revisit-overdue:hover > td) {
|
||||||
|
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.1) 0%, transparent 100%) !important;
|
||||||
|
}
|
||||||
|
/* 超期 ≥ severe_overdue_days:底色加深到 10%,悬停 14% */
|
||||||
|
:deep(.el-table__row.row-revisit-overdue.row-revisit-overdue-deep) {
|
||||||
|
border-left-color: #b91c1c;
|
||||||
|
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.1) 0%, transparent 100%) !important;
|
||||||
|
}
|
||||||
|
:deep(.el-table__row.row-revisit-overdue.row-revisit-overdue-deep:hover > td) {
|
||||||
|
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.14) 0%, transparent 100%) !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.record-list {
|
.record-list {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
formData.pending_assign !== '1' &&
|
formData.pending_assign !== '1' &&
|
||||||
formData.pending_booking !== '1' &&
|
formData.pending_booking !== '1' &&
|
||||||
formData.completed_appointment !== '1' &&
|
formData.completed_appointment !== '1' &&
|
||||||
|
formData.revisit_state === '' &&
|
||||||
formData.appointment_date === tab.value
|
formData.appointment_date === tab.value
|
||||||
}
|
}
|
||||||
]"
|
]"
|
||||||
@@ -40,6 +41,13 @@
|
|||||||
待预约
|
待预约
|
||||||
<em v-if="pendingBookingCount !== undefined">{{ pendingBookingCount }}</em>
|
<em v-if="pendingBookingCount !== undefined">{{ pendingBookingCount }}</em>
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
:class="['dh5-chip', 'dh5-chip--revisit', { active: formData.revisit_state === 'due' }]"
|
||||||
|
@click="handleRevisitDueTabClick"
|
||||||
|
>
|
||||||
|
复诊到期
|
||||||
|
<em v-if="revisitDueCount !== undefined">{{ revisitDueCount }}</em>
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
:class="['dh5-chip', 'dh5-chip--done', { active: formData.completed_appointment === '1' }]"
|
:class="['dh5-chip', 'dh5-chip--done', { active: formData.completed_appointment === '1' }]"
|
||||||
@click="handleCompletedVisitTabClick"
|
@click="handleCompletedVisitTabClick"
|
||||||
@@ -170,13 +178,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 复诊块(仅已开方时显示) -->
|
<!-- 复诊节点块(已开方或有复诊节点数据时显示;卡片头已有「已开方」标签,此处隐藏开方文案) -->
|
||||||
<div v-if="row.has_prescription" class="dh5-row dh5-row--followup">
|
<div v-if="row.has_prescription || hasRevisitDue(revisitDueOf(row))" class="dh5-row dh5-row--followup">
|
||||||
<span class="dh5-row-key">复诊</span>
|
<span class="dh5-row-key">复诊</span>
|
||||||
<div class="dh5-row-val">
|
<div class="dh5-row-val dh5-row-val--revisit">
|
||||||
<span>{{ row.followup_time_text || '—' }}</span>
|
<revisit-node-cell
|
||||||
<span class="dh5-meta-doctor">{{ row.followup_doctor_name || '—' }}</span>
|
compact
|
||||||
<span v-if="row.followup_rx_voided" class="dh5-tag dh5-tag--gray">处方作废</span>
|
:due="revisitDueOf(row)"
|
||||||
|
:has-prescription="!!row.has_prescription"
|
||||||
|
:followup-doctor-name="row.followup_doctor_name || ''"
|
||||||
|
:prescribed-text="row.followup_rx_voided ? '处方作废' : ''"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -625,6 +637,13 @@ import {
|
|||||||
import useUserStore from '@/stores/modules/user'
|
import useUserStore from '@/stores/modules/user'
|
||||||
import { defineAsyncComponent, computed, reactive, ref, onMounted, onUnmounted } from 'vue'
|
import { defineAsyncComponent, computed, reactive, ref, onMounted, onUnmounted } from 'vue'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
import RevisitNodeCell from './components/RevisitNodeCell.vue'
|
||||||
|
import {
|
||||||
|
hasRevisitDue,
|
||||||
|
isRevisitDueHighlight,
|
||||||
|
normalizeRevisitDue,
|
||||||
|
type RevisitDue
|
||||||
|
} from '@/utils/revisitDue'
|
||||||
|
|
||||||
const EditPopup = defineAsyncComponent(() => import('./edit.vue'))
|
const EditPopup = defineAsyncComponent(() => import('./edit.vue'))
|
||||||
const DetailPopup = defineAsyncComponent(() => import('./detail.vue'))
|
const DetailPopup = defineAsyncComponent(() => import('./detail.vue'))
|
||||||
@@ -685,7 +704,9 @@ const formData = reactive({
|
|||||||
has_appointment: '' as '' | '0' | '1',
|
has_appointment: '' as '' | '0' | '1',
|
||||||
pending_assign: '' as '' | '1',
|
pending_assign: '' as '' | '1',
|
||||||
pending_booking: '' as '' | '1',
|
pending_booking: '' as '' | '1',
|
||||||
completed_appointment: '' as '' | '1'
|
completed_appointment: '' as '' | '1',
|
||||||
|
/** 顶部「复诊到期」chip:due = 到期未挂号 ∪ 过号未面诊(后端 revisit_state) */
|
||||||
|
revisit_state: '' as '' | 'due'
|
||||||
})
|
})
|
||||||
|
|
||||||
const showMoreFilter = ref(false)
|
const showMoreFilter = ref(false)
|
||||||
@@ -740,6 +761,7 @@ const handleReset = () => {
|
|||||||
formData.pending_assign = ''
|
formData.pending_assign = ''
|
||||||
formData.pending_booking = ''
|
formData.pending_booking = ''
|
||||||
formData.completed_appointment = ''
|
formData.completed_appointment = ''
|
||||||
|
formData.revisit_state = ''
|
||||||
pager.page = 1
|
pager.page = 1
|
||||||
getLists()
|
getLists()
|
||||||
fetchDateCounts()
|
fetchDateCounts()
|
||||||
@@ -767,6 +789,7 @@ const handleDateTabClick = async (value: string) => {
|
|||||||
formData.pending_assign = ''
|
formData.pending_assign = ''
|
||||||
formData.pending_booking = ''
|
formData.pending_booking = ''
|
||||||
formData.completed_appointment = ''
|
formData.completed_appointment = ''
|
||||||
|
formData.revisit_state = ''
|
||||||
formData.has_appointment = ''
|
formData.has_appointment = ''
|
||||||
formData.appointment_date = value
|
formData.appointment_date = value
|
||||||
pager.page = 1
|
pager.page = 1
|
||||||
@@ -778,6 +801,7 @@ const handlePendingBookingTabClick = async () => {
|
|||||||
formData.pending_assign = ''
|
formData.pending_assign = ''
|
||||||
formData.pending_booking = '1'
|
formData.pending_booking = '1'
|
||||||
formData.completed_appointment = ''
|
formData.completed_appointment = ''
|
||||||
|
formData.revisit_state = ''
|
||||||
formData.appointment_date = ''
|
formData.appointment_date = ''
|
||||||
formData.has_appointment = '0'
|
formData.has_appointment = '0'
|
||||||
pager.page = 1
|
pager.page = 1
|
||||||
@@ -789,6 +813,7 @@ const handleCompletedVisitTabClick = async () => {
|
|||||||
formData.pending_assign = ''
|
formData.pending_assign = ''
|
||||||
formData.pending_booking = ''
|
formData.pending_booking = ''
|
||||||
formData.completed_appointment = '1'
|
formData.completed_appointment = '1'
|
||||||
|
formData.revisit_state = ''
|
||||||
formData.has_appointment = ''
|
formData.has_appointment = ''
|
||||||
formData.appointment_date = ''
|
formData.appointment_date = ''
|
||||||
pager.page = 1
|
pager.page = 1
|
||||||
@@ -800,6 +825,7 @@ const handlePendingAssignTabClick = async () => {
|
|||||||
formData.pending_assign = '1'
|
formData.pending_assign = '1'
|
||||||
formData.pending_booking = ''
|
formData.pending_booking = ''
|
||||||
formData.completed_appointment = ''
|
formData.completed_appointment = ''
|
||||||
|
formData.revisit_state = ''
|
||||||
formData.has_appointment = ''
|
formData.has_appointment = ''
|
||||||
formData.appointment_date = ''
|
formData.appointment_date = ''
|
||||||
pager.page = 1
|
pager.page = 1
|
||||||
@@ -807,9 +833,27 @@ const handlePendingAssignTabClick = async () => {
|
|||||||
fetchDateCounts()
|
fetchDateCounts()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 顶部「复诊到期」chip:到期未挂号 ∪ 过号未面诊;后端按 revisit_state=due 过滤,排序默认药尽日升序 */
|
||||||
|
const handleRevisitDueTabClick = async () => {
|
||||||
|
formData.pending_assign = ''
|
||||||
|
formData.pending_booking = ''
|
||||||
|
formData.completed_appointment = ''
|
||||||
|
formData.has_appointment = ''
|
||||||
|
formData.appointment_date = ''
|
||||||
|
formData.revisit_state = 'due'
|
||||||
|
pager.page = 1
|
||||||
|
await getLists()
|
||||||
|
fetchDateCounts()
|
||||||
|
}
|
||||||
|
|
||||||
|
const revisitDueCount = ref<number | undefined>(undefined)
|
||||||
|
|
||||||
|
/** 列表行 revisit_due → 规整对象(旧接口无字段时为 null,不渲染复诊块) */
|
||||||
|
const revisitDueOf = (row: any): RevisitDue | null => normalizeRevisitDue(row?.revisit_due)
|
||||||
|
|
||||||
const fetchDateCounts = async () => {
|
const fetchDateCounts = async () => {
|
||||||
try {
|
try {
|
||||||
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending] = await Promise.all([
|
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending, revisitDue] = await Promise.all([
|
||||||
tcmDiagnosisLists({ appointment_date: yesterdayStr.value, page_no: 1, page_size: 1 }),
|
tcmDiagnosisLists({ appointment_date: yesterdayStr.value, page_no: 1, page_size: 1 }),
|
||||||
tcmDiagnosisLists({ appointment_date: dayBeforeStr.value, page_no: 1, page_size: 1 }),
|
tcmDiagnosisLists({ appointment_date: dayBeforeStr.value, page_no: 1, page_size: 1 }),
|
||||||
tcmDiagnosisLists({ appointment_date: todayStr.value, page_no: 1, page_size: 1 }),
|
tcmDiagnosisLists({ appointment_date: todayStr.value, page_no: 1, page_size: 1 }),
|
||||||
@@ -818,7 +862,8 @@ const fetchDateCounts = async () => {
|
|||||||
tcmDiagnosisLists({ page_no: 1, page_size: 1 }),
|
tcmDiagnosisLists({ page_no: 1, page_size: 1 }),
|
||||||
tcmDiagnosisLists({ has_appointment: 0, page_no: 1, page_size: 1 }),
|
tcmDiagnosisLists({ has_appointment: 0, page_no: 1, page_size: 1 }),
|
||||||
tcmDiagnosisLists({ completed_appointment: 1, page_no: 1, page_size: 1 }),
|
tcmDiagnosisLists({ completed_appointment: 1, page_no: 1, page_size: 1 }),
|
||||||
tcmDiagnosisLists({ pending_assign: 1, page_no: 1, page_size: 1 })
|
tcmDiagnosisLists({ pending_assign: 1, page_no: 1, page_size: 1 }),
|
||||||
|
tcmDiagnosisLists({ revisit_state: 'due', page_no: 1, page_size: 1 })
|
||||||
])
|
])
|
||||||
dateCounts.value = {
|
dateCounts.value = {
|
||||||
[yesterdayStr.value]: yesterday?.count ?? 0,
|
[yesterdayStr.value]: yesterday?.count ?? 0,
|
||||||
@@ -831,6 +876,7 @@ const fetchDateCounts = async () => {
|
|||||||
pendingBookingCount.value = noApt?.count ?? 0
|
pendingBookingCount.value = noApt?.count ?? 0
|
||||||
completedVisitCount.value = doneVisit?.count ?? 0
|
completedVisitCount.value = doneVisit?.count ?? 0
|
||||||
pendingAssignCount.value = pending?.count ?? 0
|
pendingAssignCount.value = pending?.count ?? 0
|
||||||
|
revisitDueCount.value = revisitDue?.count ?? 0
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('获取日期数量失败', e)
|
console.error('获取日期数量失败', e)
|
||||||
}
|
}
|
||||||
@@ -898,7 +944,9 @@ const isDiagnosisConfirmed = (row: any) => {
|
|||||||
return records.length > 0 && records.some((r: any) => r.is_confirmed == 1)
|
return records.length > 0 && records.some((r: any) => r.is_confirmed == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 卡片左边条优先级(同 PC 行高亮):复诊超期/过号 > 未确认 > 未挂号
|
||||||
const getCardClass = (row: any) => {
|
const getCardClass = (row: any) => {
|
||||||
|
if (isRevisitDueHighlight(revisitDueOf(row))) return 'dh5-card--overdue'
|
||||||
if (!isDiagnosisConfirmed(row)) return 'dh5-card--unconfirmed'
|
if (!isDiagnosisConfirmed(row)) return 'dh5-card--unconfirmed'
|
||||||
if (!row.has_appointment) return 'dh5-card--noapt'
|
if (!row.has_appointment) return 'dh5-card--noapt'
|
||||||
return ''
|
return ''
|
||||||
@@ -1582,6 +1630,8 @@ $dh5-card-bg: #ffffff;
|
|||||||
.dh5-chip--booking.active { background: $dh5-warn; }
|
.dh5-chip--booking.active { background: $dh5-warn; }
|
||||||
.dh5-chip--done.active { background: $dh5-success; }
|
.dh5-chip--done.active { background: $dh5-success; }
|
||||||
.dh5-chip--pending.active { background: $dh5-danger; }
|
.dh5-chip--pending.active { background: $dh5-danger; }
|
||||||
|
.dh5-chip--revisit { color: #b91c1c; background: rgba(239, 68, 68, 0.10); }
|
||||||
|
.dh5-chip--revisit.active { color: #fff; background: $dh5-danger; }
|
||||||
|
|
||||||
.dh5-quick {
|
.dh5-quick {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1675,6 +1725,12 @@ $dh5-card-bg: #ffffff;
|
|||||||
|
|
||||||
.dh5-card--unconfirmed::before { background: $dh5-danger; }
|
.dh5-card--unconfirmed::before { background: $dh5-danger; }
|
||||||
.dh5-card--noapt::before { background: $dh5-warn; }
|
.dh5-card--noapt::before { background: $dh5-warn; }
|
||||||
|
/* 复诊到期未挂号 / 过号未面诊:左边条同 danger,外框与底色轻微染红以区分「未确认」 */
|
||||||
|
.dh5-card--overdue::before { background: $dh5-danger; }
|
||||||
|
.dh5-card--overdue {
|
||||||
|
border-color: rgba(239, 68, 68, 0.35);
|
||||||
|
background: linear-gradient(90deg, rgba(239, 68, 68, 0.05) 0%, $dh5-card-bg 60%);
|
||||||
|
}
|
||||||
|
|
||||||
.dh5-card-head {
|
.dh5-card-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1800,6 +1856,11 @@ $dh5-card-bg: #ffffff;
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 复诊节点块:单元格组件自带多行布局,取消外层 flex 换行间距 */
|
||||||
|
.dh5-row-val--revisit {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
/* 挂号块 - 状态色 */
|
/* 挂号块 - 状态色 */
|
||||||
.dh5-row--apt {
|
.dh5-row--apt {
|
||||||
background: #fafbfc;
|
background: #fafbfc;
|
||||||
|
|||||||
@@ -25,6 +25,17 @@
|
|||||||
</span>
|
</span>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
<span v-else class="unserved-badge unserved-muted">从未打卡</span>
|
<span v-else class="unserved-badge unserved-muted">从未打卡</span>
|
||||||
|
<!-- 复诊节点徽标:第 N 诊 · 药尽 MM-DD · 超期 N 天(接口无 revisit_due 字段时不渲染) -->
|
||||||
|
<el-tooltip
|
||||||
|
v-if="revisitBadgeLabel"
|
||||||
|
placement="bottom"
|
||||||
|
:disabled="!revisitTooltipText"
|
||||||
|
:content="revisitTooltipText"
|
||||||
|
>
|
||||||
|
<span :class="['unserved-badge', 'revisit-badge', revisitBadgeColorClass]">
|
||||||
|
{{ revisitBadgeLabel }}
|
||||||
|
</span>
|
||||||
|
</el-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -156,6 +167,12 @@ import { diagnosisReadonlyDetail } from '@/api/tcm'
|
|||||||
import { getDoctorNotes } from '@/api/patient'
|
import { getDoctorNotes } from '@/api/patient'
|
||||||
import { formatGender } from '@/utils/diag-display'
|
import { formatGender } from '@/utils/diag-display'
|
||||||
import { hasPermission } from '@/utils/perm'
|
import { hasPermission } from '@/utils/perm'
|
||||||
|
import {
|
||||||
|
normalizeRevisitDue,
|
||||||
|
revisitBadgeText,
|
||||||
|
revisitStateColorClass,
|
||||||
|
revisitTooltipLines
|
||||||
|
} from '@/utils/revisitDue'
|
||||||
import PatientInfoCard from './components/PatientInfoCard.vue'
|
import PatientInfoCard from './components/PatientInfoCard.vue'
|
||||||
import PatientCaseCard from './components/PatientCaseCard.vue'
|
import PatientCaseCard from './components/PatientCaseCard.vue'
|
||||||
import DailyMatrix from './components/DailyMatrix.vue'
|
import DailyMatrix from './components/DailyMatrix.vue'
|
||||||
@@ -193,6 +210,17 @@ const unservedDays = computed<number | null>(() => {
|
|||||||
})
|
})
|
||||||
const lastBloodAt = computed<string>(() => detail.value?.last_blood_record_at || '')
|
const lastBloodAt = computed<string>(() => detail.value?.last_blood_record_at || '')
|
||||||
|
|
||||||
|
/** 复诊节点:readonlyDetail 顶层 revisit_due(兼容挂在 diagnosis 下);字段不存在 → null → 不渲染徽标 */
|
||||||
|
const revisitDue = computed(() =>
|
||||||
|
normalizeRevisitDue(detail.value?.revisit_due ?? detail.value?.diagnosis?.revisit_due)
|
||||||
|
)
|
||||||
|
const revisitBadgeLabel = computed(() => revisitBadgeText(revisitDue.value))
|
||||||
|
const revisitBadgeColorClass = computed(() => {
|
||||||
|
const due = revisitDue.value
|
||||||
|
return due ? revisitStateColorClass(due.state) : 'unserved-muted'
|
||||||
|
})
|
||||||
|
const revisitTooltipText = computed(() => revisitTooltipLines(revisitDue.value).join(';'))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 「未服务天数」着色规则(与 T3 列表一致):
|
* 「未服务天数」着色规则(与 T3 列表一致):
|
||||||
* null/undefined → 灰;>=7 → 红;3-6 → 橙;<=2 → 绿
|
* null/undefined → 灰;>=7 → 红;3-6 → 橙;<=2 → 绿
|
||||||
@@ -352,6 +380,13 @@ onMounted(fetchDetail)
|
|||||||
border-color: #fecaca;
|
border-color: #fecaca;
|
||||||
background: rgba(220, 38, 38, 0.08);
|
background: rgba(220, 38, 38, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 复诊节点徽标专用:已约 / 已联系 */
|
||||||
|
&.unserved-blue {
|
||||||
|
color: #4a5dff;
|
||||||
|
border-color: #c7d2fe;
|
||||||
|
background: rgba(74, 93, 255, 0.08);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
|
|||||||
@@ -0,0 +1,640 @@
|
|||||||
|
# 二中心医助复诊跟进体系方案:诊次接诊率 / 患者触达节点 / 到期标红 / 到期弹窗与延迟升级
|
||||||
|
|
||||||
|
日期:2026-10-12。状态:用户已确认实施(2026-10-13);P0、P1 代码已落地(工作区未提交),P2 开发中。
|
||||||
|
|
||||||
|
**实施记录(2026-10-14 更新)**
|
||||||
|
- P0 需求 2/3、P1 需求 4、P1 需求 1 均已落地并经多路静态复核修复;本机 phpstudy PHP 8.2 `php -l` 全部通过,单测 `RevisitDueCalculatorTest`(66) / `RevisitVisitSlotServiceTest`(20) / `RevisitDueLogicTest`(67) / `RevisitRateInheritSlotTest`(57) / `RevisitRateContractTest`(45) / `AiMcpUnitTest` 全部通过。
|
||||||
|
- 与本文差异:业务钩子集中在 `app\common\service\tcm\RevisitDueHook`(而非 adminapi 的 RevisitDueLogic);快照表增加事实列 `appointment_state / appointment_date`;`DiagnosisLogic::delete` 也接了钩子;`tcm:revisit-due-scan` 防重入用 Cache 标记、`--date` 与今天不同时强制演练;弹窗 level 3 用 el-dialog 实现;`PrescriptionOrderLists` 改用 `fetchOrderSeqRows` 一次取序列(而非 batchCurrentSlot)。
|
||||||
|
- **2026-10-14 用户决定:复诊接诊率保持原来的逻辑不变。** 处理:`revisit_rate.default_inherit_mode` 改为 `exclude`(页面、个人页卡片、MCP 查询默认均为原口径,数值与改动前逐值一致),「新口径(继承按继承时诊次计入)」仅作页面上的手动切换项;原口径下页面列名、各列、下钻弹窗与原来一致(仅多出「复诊合计」两列);撤回 D1-d(指派弹窗不再默认勾选继承、不再二次确认)与 D1-e(自动分配回退行仍写 `is_inherit=0`);业绩看板 tooltip 恢复原文案;自动分配分档继续使用原口径字段。
|
||||||
|
- **2026-10-14 本地真库验证**:SQL 01~10 已在本地库执行;全量快照 4721 行与独立 SQL 推算逐字段一致(锚点/天数/预计复诊日/挂号状态/诊次/关闭状态),读取侧 9 态推导一致。发现并修复一处口径 bug:原规则把「下单当天及之后」的挂号算作复诊,导致 1096 个周期被开药前的同日面诊误关闭;改为「下单日 + `appointment_min_gap_days`(默认 1,次日起)」之后的挂号才计入(`RevisitDueCalculator`、弹窗实时校验、配置、单测同步),重算后到期未挂号由 328 升至 1185,已面诊关闭 609(459 条在预计复诊日附近、150 条为提前复诊)。该次重算在流水表写入了 1096 条「重新打开(7)」事件,属规则修正产生的痕迹。
|
||||||
|
- P2 已落地:`tcm:revisit-due-notify`(daily/tick,`wecom_enabled` 默认关闭)、主管视图 `tcm.revisitDue/lists|logs` + `/stats/revisit_due` 页面、医助个人页「我的复诊接诊率」卡片(`stats.assistantPerformance/revisitRate`,缓存 600 秒)、SQL 08~10;`generated.php` 已重生成。单测 `RevisitDueNotifyTest`(44) 通过。P2 代码只经实现代理自检与 `php -l`/单测,未做独立复核。
|
||||||
|
- 尚未完成:follow 页处置待产品确认(D2-c);本机无 Node,`npm run lint` / `type-check` 未跑;真库验证(SQL 01~10 按序执行、`tcm:revisit-due-scan --mode=full --dry-run` 压测、`tcm:backfill-inherit-visit-slot --dry-run`、`tcm:auto-assign-pending --dry-run` 分档对照)待有环境后执行;`generated.php` 重生成顺带暴露了 4 个尚无审核条目的 `tcm.followupAudio/*` 只读接口(既有缺口,非本次范围)。
|
||||||
|
分支:`tongji-10-12`(所有行号以该分支 2026-10-12 快照为准)。文档路径:`docs/plans/assistant-revisit-reach-reminder-2026-10-12.md`。
|
||||||
|
SQL 迁移目录:`server/sql/1.9.20261012/`(新增;仓库最新为 `server/sql/1.9.20260928/`,无自动迁移器,按文件序号手工执行)。
|
||||||
|
|
||||||
|
> 本稿由多代理工作流产出:7 个子系统调研 → 3 份独立方案(口径一致性 / 医助体验 / 风险分期优先)→ 3 位评审打分 → 以「医助体验优先」方案为骨架综合,移植口径一致性方案的审计机制与风险分期方案的配置化/口径开关,按评审修正事实错误(见 §0.2),并吸收完整性批评的 9 项补充(快照事实列、二中心过滤、偏移量钩子、exclude 模式语义、自愈上限、轮询语义、过号 cron 钩子包裹、D1-h)。本稿尚未修改任何业务代码;「已核实」仅指源码行号核对,不代表线上已验证。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 需求原文与解读
|
||||||
|
|
||||||
|
### 0.1 逐条解读
|
||||||
|
|
||||||
|
| # | 产品原文 | 解读 | 本方案落地点 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | 医助的所有诊数接诊率、继承的患者继承的时候是几诊,就算几诊的接诊率 | 两件事:(a) 复诊接诊率页除逐诊次列外增加「复诊合计」(所有诊次之和的接诊率);(b) 继承指派当前只转移持有人、不进分母(`server/app/adminapi/logic/stats/RevisitRateLogic.php:380-383`),导致继承者「只得分不担责」。改为:继承时患者已完成第 k 诊 → 该患者从第 k+1 诊列起计入继承者分母(每列独立分母) | §1.1 口径、§3.1 `RevisitRateLogic` 改造、§4.1 复诊接诊率页;统计实时重算 + `assign_log.inherit_visit_slot` 快照列用于展示/对账 |
|
||||||
|
| 2 | 患者在我们医院的触达时间——开药天数、到几诊 | 在医助每天看的「中医诊单」列表上,直接显示患者当前第几诊(与接诊率同口径)、本轮开药天数与来源、预计复诊日、首次触达日/在管天数 | §1.2 口径、§2.1 快照表、§4.2 「复诊节点」列(`RevisitNodeCell` 共用组件) |
|
||||||
|
| 3 | 患者该挂号了,但是没有挂号、接诊,系统标红显示(按照开药天数 14、21、28、30) | 预计复诊日 = 用药起算日 + 实际开药天数;到期仍无有效挂号/未面诊 → 行级标红 + 状态 chip;14/21/28/30 理解为常见疗程档位(用于筛选/展示),不是只对这四档提醒,也不是逾期阈值 | §1.3 状态机、§4.2 行高亮/chip/筛选/排序、§5.1 cron |
|
||||||
|
| 4 | 到时间未面诊的会有一个弹窗功能,让员工去点一下延迟处理,越点越上心 | 持有医助登录后台即见右下角汇总卡片;可「去挂号 / 已联系(填承诺日) / 延迟」;延迟间隔随次数递减、卡片形态随次数升级到强制模态;延迟次数在列表与主管视图持续可见 | §1.4 升级规则、§3.3 接口、§4.3 全局组件、§5.2 企微(P2) |
|
||||||
|
|
||||||
|
### 0.2 方案来源与事实修正(评审意见已吸收)
|
||||||
|
|
||||||
|
| 评审指出的问题 | 本稿处理 |
|
||||||
|
|---|---|
|
||||||
|
| 三份方案均写 `.row-unconfirmed` 样式在 `index.vue:2742-2749` | 已核实:`:deep(.el-table__row.row-unconfirmed)` 在 `admin/src/views/tcm/diagnosis/index.vue:2771-2777`,`.row-no-appointment` 在 `:2778-2782`(`:2745` 为 `.video-watch-trigger`) |
|
||||||
|
| 方案 1 用 `operator_name='回退自动分配'` 豁免回退行 | 已核实不可行:`server/app/adminapi/logic/stats/AutoAssignLogLogic.php:167` 写 `$adminName !== '' ? $adminName : '回退自动分配'`,正常为操作人姓名。改为:回退写入时 `to>0` 则 `is_inherit=1`(§3.1),历史行用 `tcm_diagnosis_auto_assign_log.rollback_time`(列见 `server/sql/1.9.20260717/add_auto_assign_log_rollback.sql:6-7`)关联豁免 |
|
||||||
|
| 方案 0 回退改继承未限定 `to>0` | `AutoAssignLogLogic.php:165` 的 `to_assistant_id = $prevAssistantId` 可能为 0;本稿要求 `is_inherit = $prevAssistantId > 0 ? 1 : 0`,与 `server/app/adminapi/logic/tcm/DiagnosisLogic.php:671` 约定一致 |
|
||||||
|
| 方案 2 仅用 `matchPermissionAlias` 别名、不登记菜单 | 已核实无效:`server/app/adminapi/http/middleware/AuthMiddleware.php:111-117` 对不在 `$allUri` 的 URI 直接 `return $next($request)`,`:124` 的别名判断永不执行。本稿全部新 action 登记 A 类菜单并用 `INSERT IGNORE INTO zyt_system_role_menu` 自动授予(模板 `server/sql/1.9.20260803/add_performance_dashboard_menu.sql:50-56`),别名只作兜底 |
|
||||||
|
| 方案 1 把 `assigned_count` 重定义为 `|NI∪IH|` | 保留 `assigned_count` = 新指派数(`RevisitRateLogic.php:129/148` 排序、`revisit_rate/index.vue:107`、`server/app/mcp/catalog/review/stats.php:190-197`、`YejiStatsLogic` 被指派数均依赖该语义),新增 `inherit_count`、`assigned_total_count`、`visit{N}_den`、`visit_all_*` |
|
||||||
|
| 方案 1 「Den_2 差异 = 继承时尚无成交历史的继承数」 | 修正:`entry=max(2,k+1) ≤ 2` 含 k=0 与 k=1 两类继承 |
|
||||||
|
| 行号偏差:POLL_INTERVAL、setInterval、UpdateAppointmentStatus 追加点、Db::commit、甘草回调 | 已核实:`admin/src/components/chat-notify-toast/index.vue:89`(`POLL_INTERVAL = 5000`)、`:127`(`setInterval`)、`:140-141`(`top:72px; right:24px`);`server/app/common/command/UpdateAppointmentStatus.php:55-56` 为两条 `Db::execute`(`:52` 是 dry 分支 return);`DiagnosisLogic.php:742` 为 `Db::commit()`;`server/app/api/controller/GancaoCallbackController.php:327-329` case 90 仅写 `gancao_remark`,`:331-341` case 91/92 置 `fulfillment_status=4`,`:380-383`/`:406-409` 推 5 |
|
||||||
|
| 方案 0 `visit2_rate_legacy decimal(6,2)` | 现有 `visit2_rate decimal(8,2)`(`server/sql/1.9.20260715/add_tcm_diagnosis_auto_assign_log.sql:20`),本稿同精度 |
|
||||||
|
| 方案 0 `idx_to_inherit_time` 对 `buildStatsCore` 无效 | `RevisitRateLogic.php:356-364` 仅按 `create_time<=月末` 过滤、按 `diagnosis_id` 排序;本稿不加该索引 |
|
||||||
|
| 方案 1 `RevisitRateLogic.php:362` 字段列表缺 `from_assistant_id` | 本稿要求 field 增 `lg.from_assistant_id`(用于统计时忽略 from==to) |
|
||||||
|
| 方案 2「exclude 模式逐字节一致」 | 修正表述:既有键的**值**一致,响应新增 `visit{N}_den/visit_all_*/inherit_count` 键 |
|
||||||
|
| 方案 1 H5 `formData :89-101` | 已核实 `admin/src/views/tcm/diagnosis/index_h5.vue:678`;复诊块 `:174-181`;`getCardClass :901-903`;`dh5-tag--warn :1762` |
|
||||||
|
| 方案 0/1 工期标题与正文不一致 | 本稿统一为 §8 的数字 |
|
||||||
|
|
||||||
|
### 0.3 现状关键事实(已核对源码)
|
||||||
|
|
||||||
|
| 事实 | 位置 |
|
||||||
|
|---|---|
|
||||||
|
| 分母 = 当月 `is_inherit=0 && to>0` 的医助×诊单去重;继承行进时间线与分子候选、不进分母 | `RevisitRateLogic.php:374-385`(`:380` 为 is_inherit 过滤) |
|
||||||
|
| 分母剔除名下任何时期存在 9/10 订单的诊单 | `RevisitRateLogic.php:387-395`、`fetchRefundOrRejectDiagnosisSet :687` |
|
||||||
|
| 分子:有效订单按 `(create_time,id)` 升序,`effectiveSlot = seq + offset`,持有人 = 时间线上 `t <= ct` 的最后一条 `to`;`slot<2 或 >50` 跳过 | `RevisitRateLogic.php:426-457`(`:438` seq++,`:444-447` 指针,`:448` slot 过滤,`:451` 月份过滤,`:454-456` 归属);`fetchOrderSeqRows :716`、`fetchRevisitSlotStartOffsetMap :736`、`resolveRevisitSlotStartOffset :760` |
|
||||||
|
| 单一分母 `buildMetricPack(int $assigned, array $slotCounts, array $slots)` | `RevisitRateLogic.php:805-817` |
|
||||||
|
| 列范围只看分子命中 `range(2, max(4, maxHitSlot))` | `RevisitRateLogic.php:71-80` |
|
||||||
|
| 自动分配读上月 `visit2_rate`,null 或 <50 跳过 | `server/app/command/AutoAssignPendingDiagnosis.php:224-233` |
|
||||||
|
| 业绩看板「被指派数」三处 `is_inherit=0`,自称与复诊接诊率同口径 | `server/app/adminapi/logic/stats/YejiStatsLogic.php:24, 1781, 1812, 1874, 1894, 2848`;`admin/src/views/fans/yeji.vue:401` |
|
||||||
|
| 业务订单有效口径常量 `[4,9,10]` 与统一过滤方法 | `YejiStatsLogic.php:61`、`:3886-3894` |
|
||||||
|
| 业务订单 `medication_days int(11) NULL`,创建/编辑不校验 | `server/database/migrations/2026_04_07_create_tcm_prescription_order.sql:12`;`server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php:1080-1104, 1898-1917`;`server/app/adminapi/validate/tcm/PrescriptionOrderValidate.php:71-82` 场景不含该字段 |
|
||||||
|
| 处方 `usage_days` 默认 7;完成面诊自动生成系统代开处方 `usage_days=7, is_system_auto=1` | `server/app/adminapi/logic/tcm/PrescriptionLogic.php:373, 1114, 1214, 1226` |
|
||||||
|
| 挂号 `patient_id` 存诊单 id;status 1 已预约 / 2 已取消 / 3 已完成 / 4 已过号 | `server/sql/doctor_appointment.sql`;`server/app/common/model/doctor/Appointment.php:34-39` |
|
||||||
|
| 过号:status=1 超 35 分钟 → 4;status=4 超 8 小时 → 2 | `UpdateAppointmentStatus.php:38-44`,执行 `:55-56` |
|
||||||
|
| 面诊完成 = 人工点「完成」 | `server/app/adminapi/logic/doctor/AppointmentLogic.php:576`(通知 `:603`,系统代开 `:616`);创建 `:280`、取消 `:420`、后台编辑 `:978` |
|
||||||
|
| 指派:事务 + 行锁,写 `assign_log`(12 列,无 delete_time),`to=0` 强制 `is_inherit=0` | `DiagnosisLogic.php:665-746`(`:671`、`:697`、`:722-734`、`Db::commit :742`);基表 `server/database/migrations/2026_03_31_tcm_diagnosis_assign_log.sql`(索引仅 `idx_diagnosis_id`、`idx_create_time`);`server/sql/1.9.20260601/add_diagnosis_assign_log_is_inherit.sql` |
|
||||||
|
| 诊单列表:医助角色强制本人;筛选在 `lists()`/`count()` 双写;排序相关子查询 | `server/app/adminapi/lists/tcm/DiagnosisLists.php:70-173, 531-612`(`:88/:546` role 2;`:94/:551` DataScope;`:77/:539` pendingWideSearch);`resolveListOrderRaw :719`;`unservedAnchorExpr :754`;处方聚合 `:343-366`,`followup_prescription_count :406`;未服务天数 `:459` |
|
||||||
|
| 全局弹窗 `ChatNotifyToast`:5s 轮询、Cache 读即消费 | `admin/src/App.vue:11, 19-23, 60`;`chat-notify-toast/index.vue:89, 127, 140-141`;`server/app/api/logic/ChatNotifyLogic.php:106-117` |
|
||||||
|
| 定时任务调度器:命令抛异常即把 `dev_crontab.status` 置 ERROR 停跑 | `server/app/common/command/Crontab.php:81-87` |
|
||||||
|
| 缓存驱动 File,无分布式锁 | `server/config/cache.php:9` |
|
||||||
|
| 权限:未登记 perms 的接口对所有登录账号放行 | `AuthMiddleware.php:111-117`;`matchPermissionAlias :171`(dailyRecord 别名 `:202-206`) |
|
||||||
|
| 签收时间只在物流库,批量解析函数纯读库 | `server/app/common/service/ExpressTrackingService.php:1642`(`batchResolveSignUnixFromDbForOrders`);签收推 6 `:559`、`auto_sign` 日志 `:567` |
|
||||||
|
| 发货日志 `action='ship'` 仅人工发货写入;甘草回调推 5 不写日志 | `PrescriptionOrderLogic.php:2143`(ship);`GancaoCallbackController.php:380-383, 406-409` |
|
||||||
|
| 三套「几诊」口径并存 | 接诊率:订单序号+offset(`RevisitRateLogic.php:438`);诊单列表:处方张数(`DiagnosisLists.php:406`);一中心:已完成挂号−1(`server/app/adminapi/lists/firstvisit/MyPatientLists.php:283-284`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 口径定义
|
||||||
|
|
||||||
|
### 1.0 统一「诊次」语言(四项需求共用)
|
||||||
|
|
||||||
|
- `E(D)`:诊单 D 名下**有效业务订单** = `tcm_prescription_order` 中 `delete_time IS NULL` 且 `fulfillment_status IS NULL OR NOT IN (4,9,10)`(统一用 `YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery`,`YejiStatsLogic.php:3886-3894`),按 `(create_time, id)` 升序;
|
||||||
|
- `off(D)` = `tcm_diagnosis.revisit_slot_start_offset` 钳到 0~20(`RevisitRateLogic.php:760-771`;列定义 `server/sql/1.9.20260709/add_diagnosis_revisit_slot_start_offset.sql`);
|
||||||
|
- 第 n 笔有效订单的诊次 `slot = n + off(D)`;
|
||||||
|
- **患者当前已完成诊次** `current_slot(D) = |E(D)| + off(D)`;**待复诊诊次** `next_slot = current_slot + 1`(该笔订单产生时会落入复诊接诊率的 `visit{next_slot}_count`,列表与报表数字一致);
|
||||||
|
- **某时刻 t 患者处于第几诊** `slot_at(D, t) = |{o ∈ E(D) : ct(o) < t}| + off(D)`(严格小于,与持有人规则 `t <= ct`(`RevisitRateLogic.php:444`)对偶:同一秒的订单视为「指派之后」、归新持有人)。
|
||||||
|
|
||||||
|
以上全部由新服务 `RevisitVisitSlotService`(§3.1)单点实现;`PrescriptionOrderLists::appendDiagnosisEditVisitSeqFields`(`server/app/adminapi/lists/tcm/PrescriptionOrderLists.php:1471-1524`)改为调用它;`DeptLogic::fetchErCenterRevisitCandidateOrders`(`server/app/adminapi/logic/dept/DeptLogic.php:240-258`,看板复诊 2/3 列,区间内重排、创建人归属、无 offset)是另一套口径,**本次不改**。
|
||||||
|
|
||||||
|
### 1.1 需求 1:复诊接诊率 v2(定义 A,累计型,每列独立分母)
|
||||||
|
|
||||||
|
记月窗 `M=[startTs,endTs]`,诊单 D,医助 A,`R` = 名下存在履约 9/10 订单的诊单集合(不限月份,`RevisitRateLogic.php:687`)。
|
||||||
|
|
||||||
|
**集合**
|
||||||
|
|
||||||
|
- `NI(A)` = {D : ∃ 指派日志 l,`is_inherit=0`、`to=A`、`from≠to`(仅 slot 模式)、`t_l∈M`} \ R (现状分母,字段 `assigned_count` 语义不变)
|
||||||
|
- `IH(A)` = {D : ∃ 指派日志 l,`is_inherit=1`、`to=A`、`from≠to`、`t_l∈M`,且 D ∉ NI(A)} \ R (新增:继承分母)
|
||||||
|
- 同一 (A, D) 当月多条继承取**最早**一条 `t_l`;D 同月既被非继承又被继承指派给同一 A 时只算 NI;
|
||||||
|
- `from == to` 的日志(继承/指派给自己,`DiagnosisLogic::assign` 无校验)统计时忽略;
|
||||||
|
- 回退自动分配行(`AutoAssignLogLogic.php:162-174`):新数据按 §3.1 写 `is_inherit=1`;历史行通过 `tcm_diagnosis_auto_assign_log(diagnosis_id, rollback_time, rollback_admin_id)` 与 `assign_log(diagnosis_id, create_time, operator_admin_id)` 匹配后按继承处理。
|
||||||
|
- **继承时诊次** `k_D = slot_at(D, t_l)`;**起算诊次** `entry_D = max(2, k_D + 1)`(患者已完成 k 诊,继承者只对第 k+1 诊及以后负责;k ≤ 1 的继承等同新指派,从二诊列起算,不产生一诊列);`entry_D > 50` 的继承行不进任何诊次列、只进合计分母。
|
||||||
|
|
||||||
|
**每列独立分母 / 分子 / 比率**(s = 2..S)
|
||||||
|
|
||||||
|
```
|
||||||
|
Den_s(A) = |NI(A)| + |{ D ∈ IH(A) : entry_D ≤ s }|
|
||||||
|
Num_s(A) = 现状不变:当月下单、slot = s、2 ≤ s ≤ 50、下单时点持有人为 A 的订单数(RevisitRateLogic.php:438-456)
|
||||||
|
Rate_s(A) = Num_s(A) / Den_s(A) × 100;Den_s = 0 → null(AutoAssignPendingDiagnosis.php:233 依赖 null 语义)
|
||||||
|
```
|
||||||
|
|
||||||
|
**复诊合计列(「所有诊数接诊率」)**
|
||||||
|
|
||||||
|
```
|
||||||
|
Den_all(A) = |NI(A) ∪ IH(A)| (按诊单去重,输出 assigned_total_count)
|
||||||
|
Num_all(A) = Σ_{s=2}^{50} Num_s(A)
|
||||||
|
Rate_all(A) = Num_all / Den_all × 100 (可超过 100%,语义「人均复诊成交单数」)
|
||||||
|
```
|
||||||
|
|
||||||
|
**部门行 / 合计行**(按诊单去重)
|
||||||
|
|
||||||
|
- `NI_dept = ∪_A NI(A)`;`IH_dept = (∪_A IH(A)) \ NI_dept`,`entry_D` 取该部门内各继承者中的**最小值**;
|
||||||
|
- `Den_s(dept) = |NI_dept| + |{D ∈ IH_dept : entry_D ≤ s}|`;`Num_s(dept) = Σ_A Num_s(A)`(每笔订单唯一归属);
|
||||||
|
- 列范围 `S = min(50, max(4, 当月分子最大 slot, max entry_D))`(修复 `RevisitRateLogic.php:71-80` 只看分子导致继承行静默丢失)。
|
||||||
|
|
||||||
|
**旧口径开关**:请求参数 `inherit_mode=exclude|slot`(移植方案 2)。`exclude` 时完全复现 `RevisitRateLogic.php:380` 现状:`IH(A)=∅`,**不**忽略 `from==to` 行、**不**豁免历史回退行,`Den_s = assigned_count` 对所有 s 成立,`visit{N}_count/rate`、`assigned_count` 的值与线上一致(响应会多出 `visit{N}_den/visit_all_*/inherit_count` 键)。`from≠to` 忽略与回退行按继承处理**仅在 `slot` 模式生效**;`YejiStatsLogic` 三处被指派数与下文 `visit2_rate_new_assign` 均沿用 `:380` 口径(仅 `is_inherit=0`,不做 from≠to/回退豁免),保证「与新指派数同口径」文案成立、过渡期自动分配分档不变。页面默认取 `config('project.revisit_rate.default_inherit_mode')`(建议 `slot`)。
|
||||||
|
|
||||||
|
**供自动分配使用的旧口径字段**:无论模式,`overview` 都输出 `visit2_rate_new_assign = Num_2 / |NI_exclude| × 100`(`NI_exclude` 为 `:380` 现状口径的新指派集合;为 0 → null)。
|
||||||
|
|
||||||
|
**示例计算**(offset=0)
|
||||||
|
|
||||||
|
| 时间 | 事件 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 05-03 | D 第 1 笔有效订单(slot 1) | 持有人 A |
|
||||||
|
| 06-12 | 第 2 笔(slot 2) | A 名下,6 月 `visit2_count(A)+1` |
|
||||||
|
| 08-20 | 第 3 笔(slot 3) | A 名下 |
|
||||||
|
| 09-10 10:00 | A → B,勾选继承 | `k_D = 3`,`entry_D = 4` |
|
||||||
|
| 09-25 | B 名下第 4 笔(slot 4) | 9 月 `visit4_count(B)+1` |
|
||||||
|
|
||||||
|
9 月报表(slot 模式):B 的 `inherit_count=1`、`visit2_den/visit3_den` 不含 D、`visit4_den` 含 D、`visit4_count=1`(若 B 只有这一位患者则 `visit4_rate=100`、`visit2_rate=null`)、`assigned_total_count=1`、`visit_all_count=1`、`visit_all_rate=100`;A 的 9 月数字不变。exclude 模式:B `assigned_count=0`、`visit4_count=1`、`visit4_rate=null`(与线上现状一致)。
|
||||||
|
变体:若 09-10 继承时 D 尚无任何有效订单(k=0)→ `entry=2`,与新指派等价;若 09-25 的订单与继承同一秒 → 订单归 B 且不计入 k。
|
||||||
|
|
||||||
|
### 1.2 需求 2:触达节点字段
|
||||||
|
|
||||||
|
| 字段 | 定义 | 来源 / 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 最新有效订单 `O*` | `E(D)` 中 `(create_time, id)` 最大的一笔 | 退款/取消的订单自动退回上一笔有效单 |
|
||||||
|
| 开药天数 `medication_days` | `COALESCE(NULLIF(O*.medication_days,0), NULLIF(rx.usage_days,0))`,rx = `O*.prescription_id` 对应处方且 `is_system_auto=0 AND void_status=0 AND delete_time IS NULL`;两者皆空 → `days_src=0`(缺天数),**不兜底 7**,列表显示橙色「缺开药天数」促补填 | 配置 `revisit_reach.days_fallback_rx=true`、`days_default=0` |
|
||||||
|
| 天数来源 `days_src` | 0=缺失 1=订单 2=处方 | — |
|
||||||
|
| 档位 `rule_days` | `medication_days ∈ {14,21,28,30}` 取该值,否则 0=其它;仅用于筛选/展示,**到期按实际天数算**(15 天不归 14) | 配置 `revisit_reach.day_buckets` |
|
||||||
|
| 用药起算锚点 `anchor_ts` / `anchor_src` | `COALESCE(签收时间, 发货时间, O*.create_time)`,`anchor_src` 1 签收 / 2 发货 / 3 下单 | 签收:`ExpressTrackingService::batchResolveSignUnixFromDbForOrders`(`:1642`,纯读库);发货:`tcm_prescription_order_log.action='ship'` 最早 `create_time`(仅人工发货,`PrescriptionOrderLogic.php:2143`);下单:`create_time`(可被 `editTime :1152` 修改)。链顺序由 `revisit_reach.anchor_chain` 配置,可收窄为 `['create']` |
|
||||||
|
| 预计复诊日 `due_date` | `DATE(anchor_ts) + medication_days + grace_days(默认 0)` | 缺天数时 NULL |
|
||||||
|
| 当前诊次 / 待复诊诊次 | `current_slot = |E(D)| + off(D)`;`next_slot = current_slot + 1` | §1.0 |
|
||||||
|
| 首次触达日 `first_touch_date` / 在管天数 | `min(首笔有效订单日, 首条 to>0 指派日志日)`;`today − first_touch_date` | 展示在诊次 tag 的 tooltip |
|
||||||
|
| 累计开药天数 `total_medication_days` | Σ 有效订单的(订单天数 ?: 处方天数) | tooltip 可选 |
|
||||||
|
|
||||||
|
### 1.3 需求 3:到期判定状态机(读取时推导,列表 / 弹窗 / 筛选共用)
|
||||||
|
|
||||||
|
`A*` = 下单日之后(`appointment_date >= DATE(O*.create_time) + appointment_min_gap_days`,默认 1 即次日起;**2026-10-14 本地数据验证发现**同一天的面诊几乎都是产生这笔药的那次面诊而非复诊,若计入会把 1096/1705 个周期误关闭)的挂号中,按 `(appointment_date, appointment_time, id)` 最新一条、`status IN (1,3,4)`(`doctor_appointment.patient_id = 诊单 id`,`appointment_time` 空串用 `IFNULL(NULLIF(TRIM(...),''),'00:00:00')`)。`A*` 的状态与日期由 `RevisitDueService::refresh` 与挂号钩子写入快照表**事实列** `appointment_state`(0=无 1=已约 3=已面诊 4=过号)与 `appointment_date`;下表 `state` 由纯函数**只读快照行 + today** 推导,列表 / `pending()` / 主管视图不再各自 join 挂号表,避免口径分叉。
|
||||||
|
|
||||||
|
| 优先级 | `state` | 判定 | chip | 行高亮 | 进弹窗 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | `visited` 已面诊 | `A*.status=3` | 绿「已面诊 MM-DD」 | 否 | 否(`closed_state=3`) |
|
||||||
|
| 2 | `booked` 待面诊 | `A*.status=1 AND appointment_date >= today` | 蓝「已约 MM-DD」 | 否 | 否(`closed_state=2`) |
|
||||||
|
| 3 | `missed` 过号未面诊 | `A*.status=4`,或 `status=1 AND appointment_date < today`(cron 未跑兜底) | 红「过号 MM-DD」 | **是** | **是** |
|
||||||
|
| 4 | `contacted` 已联系 | `promised_date >= today` | 蓝描边「已联系·约 MM-DD」 | 否 | 否(`next_remind_at = promised_date 09:00`) |
|
||||||
|
| 5 | `overdue` 到期未挂号 | 无 A*(或仅 status=2)且 `today >= due_date` | 红「超期 N 天」(N=0「今日应挂号」;N ≥ `severe_overdue_days(7)` 深红加粗) | **是** | **是** |
|
||||||
|
| 6 | `warning` 即将到期 | `0 < due_date − today <= warn_days(3)` | 橙「剩 N 天」 | 否 | 否 |
|
||||||
|
| 7 | `pending` 未到期 | `due_date − today > warn_days` | 灰「剩 N 天」 | 否 | 否 |
|
||||||
|
| 8 | `no_days` 缺天数 | 有有效订单但天数缺失 | 橙描边「缺开药天数」 | 否 | 否 |
|
||||||
|
| 9 | `none` | 无有效订单 / 无医助 / 诊单禁用 | — | 否 | 否(`closed_state=5`) |
|
||||||
|
|
||||||
|
补充规则:
|
||||||
|
- `missed`(过号)**不受** `due_date <= today` 限制:患者在到期日前挂号并过号(如 28 天疗程第 10 天过号)同样标红并进弹窗;重新打开(event=7)时 `next_remind_at = now`,当天即弹;
|
||||||
|
- 「已挂号」解除标红只认 `status IN (1,3)`;`2` 不算;`4` 算「过号未面诊」继续标红。过号 8 小时后被 cron 改 2(`UpdateAppointmentStatus.php:42-44`),因此「过号」只在当天可见,次日统一落入 `overdue`(患者确需重新挂号),`missed_count` 保留「曾过号」事实;
|
||||||
|
- `booked` 行的挂号后来变成 2/4 → 由钩子/cron **重新打开**(event=7),`missed_count+1`;
|
||||||
|
- 「接诊」闭环隐含:新有效订单成为新的 `O*`,锚点与到期日重算,旧周期关闭(event=6);
|
||||||
|
- 多笔疗程重叠以最新有效订单为准;
|
||||||
|
- 若产品实际意图是「逾期 14/21/28/30 天逐级加深」,只需把 `severe_overdue_days` 改为阶梯数组,不改结构(移植方案 2)。
|
||||||
|
|
||||||
|
**示例**:订单 09-20 下单、09-22 签收、28 天 → `anchor_src=1`、`due_date=10-20`;10-17~10-19 橙「剩 N 天」;10-20 红「今日应挂号」+ 红行;10-27 深红「超期 7 天」;10-21 挂 10-23 的号 → 蓝「已约 10-23」;10-23 过号 → 当天红「过号 10-23」,次日 cron 置 2 → 红「超期 N 天」、`missed_count=1`。
|
||||||
|
|
||||||
|
### 1.4 需求 4:延迟升级规则(「越点越上心」)
|
||||||
|
|
||||||
|
```
|
||||||
|
snooze():
|
||||||
|
n = snooze_count + 1
|
||||||
|
interval = snooze_intervals[n] ?? snooze_intervals.default // 4h → 2h → 1h → 30min
|
||||||
|
next_remind_at = now + interval,钳入 work_hours 09:00~20:00(落到 20:00 之后 → 次日 09:00)
|
||||||
|
escalation_level = f(n):n=0→0;1~2→1;3~4→2;≥5→3
|
||||||
|
```
|
||||||
|
|
||||||
|
| level | 延迟次数 | 卡片形态 |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | 0 | 白底红色左边条,可最小化为右下角小气泡 |
|
||||||
|
| 1 | 1~2 | 橙色标题栏「已延迟 N 次」,可最小化 |
|
||||||
|
| 2 | 3~4 | 红色标题栏、出现时轻微抖动、不可最小化、「全部延迟」隐藏,只能逐条处理 |
|
||||||
|
| 3 | ≥5 | 该患者单独弹居中模态(`ElMessageBox`,`closeOnClickModal=false`、无 X),按钮仅 [去挂号][已联系][再延迟 30 分钟];P2 同时企微推本人、每日 1 次抄送主管 |
|
||||||
|
|
||||||
|
「已联系」(移植方案 1 并加约束):填患者承诺复诊日 `promised_date`(today ~ today+14),`next_remind_at = promised_date 09:00`,**不增加 `snooze_count`**;每个周期最多 2 次,第 3 次起按一次延迟计入 `snooze_count`;承诺日仍无挂号 → 再弹并清空 `promised_date`。
|
||||||
|
「人工关闭」:医助可填原因(`lost/other` + 备注必填)关闭,但在主管视图标记;新周期自动重开。
|
||||||
|
换医助(含继承):`snooze_count/escalation_level/promised_date` 清零、`next_remind_at=now`,写 event=9(新医助不背前任的账;决策点 D4-c)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 数据模型变更(SQL 草稿,目录 `server/sql/1.9.20261012/`,均为新增文件)
|
||||||
|
|
||||||
|
### 2.1 `01_add_prescription_order_diag_ct_index.sql`(P0)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 诊次序号 / 每诊单最新有效订单 的批量查询覆盖索引(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`);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 `02_add_tcm_diagnosis_revisit_due.sql`(P0)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 复诊到期快照:每诊单一行(当前周期 = 最新有效业务订单)。
|
||||||
|
-- 写入: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 挂号表。
|
||||||
|
-- 索引: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=失效(无医助/诊单删除或禁用/无有效订单);缺开药天数的行保持 0,由读取侧推导为 no_days',
|
||||||
|
`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 = '诊单复诊到期快照(每诊单一行,列表标红/弹窗/延迟升级共用)';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 `03_add_tcm_diagnosis_revisit_due_log.sql`(P0)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 复诊到期事件流水:供审计与「越点越上心」统计(主管视图可按延迟/升级次数排序)。
|
||||||
|
-- 注意:弹窗轮询不写流水(只更新 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 = '诊单复诊到期事件流水';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 `04_add_revisit_due_crontab.sql`(P0,幂等,模板 `server/sql/1.9.20260805/upgrade_qywx_customer_acquisition_customers.sql:53-67`)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 依赖:先执行 02/03;服务器 crontab 每分钟 php think crontab;首次 last_time 为空时调度器只记下次时间不执行(Crontab.php:46-54)
|
||||||
|
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);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 `05_add_revisit_due_menu.sql`(P1,幂等;列清单照抄 `server/sql/1.9.20260505/add_diagnosis_todo_menu.sql:10-20`,授权照抄 `server/sql/1.9.20260803/add_performance_dashboard_menu.sql:50-56`)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 复诊到期提醒接口按钮权限,挂在「中医诊单」(perms=tcm.diagnosis/lists) 下。
|
||||||
|
-- AuthMiddleware.php:111-117:未登记的接口对所有登录账号放行,因此 pending/summary/snooze/snoozeAll/contact/close 全部登记;
|
||||||
|
-- 末尾自动授予所有已拥有 tcm.diagnosis/lists 的角色,避免「登记后医助失权」。「全部菜单」角色若已是 * 需人工复核。
|
||||||
|
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');
|
||||||
|
-- 同法插入:tcm.revisitDue/summary(71) / snooze(72) / snoozeAll(73) / contact(74) / close(75)
|
||||||
|
|
||||||
|
INSERT IGNORE INTO zyt_system_role_menu (role_id, menu_id)
|
||||||
|
SELECT DISTINCT rm.role_id, m2.id
|
||||||
|
FROM zyt_system_role_menu rm
|
||||||
|
JOIN zyt_system_menu m1 ON m1.id = rm.menu_id AND m1.perms = 'tcm.diagnosis/lists'
|
||||||
|
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');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.6 `06_add_diagnosis_assign_log_inherit_visit_slot.sql`(P1,移植方案 0)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 继承指派时患者所处诊次快照(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`;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.7 `07_alter_auto_assign_log_rate_version.sql`(P1,移植方案 0,精度对齐 `add_tcm_diagnosis_auto_assign_log.sql:20`)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 自动分配日志同时记录两套口径的二诊率,便于回溯分档依据
|
||||||
|
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`;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.8 P2:`08_add_revisit_due_team_menu.sql`(C 类页面 `/stats/revisit_due`,perms `tcm.revisitDue/lists`、A 类 `tcm.revisitDue/logs|export`,模板 `server/sql/1.9.20260715/add_auto_assign_log_menu.sql`)、`09_add_revisit_due_notify_crontab.sql`(`tcm:revisit-due-notify --mode=daily` 09:00 与 `--mode=tick` 每 30 分钟)。
|
||||||
|
|
||||||
|
### 2.9 配置(`server/config/project.php`,追加两段,与 `data_scope`(`:146`)同风格)
|
||||||
|
|
||||||
|
```php
|
||||||
|
'revisit_rate' => [
|
||||||
|
'default_inherit_mode' => 'slot', // 页面默认口径:slot=继承按继承时诊次计入 | exclude=继承不计分母(旧)
|
||||||
|
'auto_assign_tier_field' => 'visit2_rate_new_assign', // AutoAssign 分档字段:过渡期用旧口径,产品确认后改 'visit2_rate'
|
||||||
|
'inherit_min_next_slot' => 2,
|
||||||
|
],
|
||||||
|
'revisit_reach' => [
|
||||||
|
'limit_er_center' => true, // 候选诊单限二中心子树医助
|
||||||
|
'anchor_chain' => ['sign', 'ship', 'create'],
|
||||||
|
'days_fallback_rx' => true,
|
||||||
|
'days_default' => 0, // 0=缺天数不计算不标红
|
||||||
|
'day_buckets' => [14, 21, 28, 30],
|
||||||
|
'grace_days' => 0,
|
||||||
|
'warn_days' => 3,
|
||||||
|
'severe_overdue_days' => 7, // 可改为阶梯数组
|
||||||
|
'row_highlight_priority' => 'overdue_first', // overdue_first | unconfirmed_first
|
||||||
|
'toast_enabled' => false, // 弹窗总开关(上线先 false,由主管用 chip 清存量)
|
||||||
|
'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' => [], // dept 表无 leader 字段(server/app/common/model/dept/Dept.php),P2 用配置过渡
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 后端改动(按文件)
|
||||||
|
|
||||||
|
### 3.1 需求 1 与诊次公共服务
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|---|---|
|
||||||
|
| `server/app/common/service/tcm/RevisitVisitSlotService.php`(新增) | `declare(strict_types=1)`,无 likeadmin 头。从 `RevisitRateLogic` 搬出 `fetchOrderSeqRows`(`:716-729`)、`fetchRevisitSlotStartOffsetMap`(`:736-753`)、`resolveRevisitSlotStartOffset`(`:760-771`)为 public static(原方法保留为委托,减少 diff);新增 `batchCurrentSlot(array $diagIds): array<int,{seq,offset,current_slot,latest_order,first_order_time,valid_order_count,total_medication_days}>`(MySQL 5.7 无窗口函数:`GROUP BY diagnosis_id` 取 `COUNT/MAX(create_time)` 后按 `(diagnosis_id, create_time) IN (...)` 回表、PHP 按 id 取最大)、`slotAtTime(int $diagId, int $ts): int`、纯函数 `slotBefore(array $orderCreateTimes, int $offset, int $atTs): int`。 |
|
||||||
|
| `server/app/adminapi/logic/stats/RevisitRateCalculator.php`(新增) | 纯函数 `compute(array $logRows, array $orderRows, array $offsetMap, array $refundRejectSet, array $rollbackKeySet, int $startTs, int $endTs, string $inheritMode): array{diagsByAssistant, inheritEntryByAssistant, slotOrdersByAssistant, pairsRaw, inheritPairs, maxEntry}`,承接 `buildStatsCore :366-458` 的内存计算。继承诊次算法:订单循环中处理每笔订单(时间 ct)**之前**,把该诊单所有 `t_l <= ct` 且未定值的继承日志定为 `k = seq_before + offset`(即在 `:438` `$seq++` 之前取值);诊单切换/循环结束补 `k = seq_total + offset`;无任何有效订单的继承诊单循环后补 `k = offset`;`entry = max(inherit_min_next_slot, k+1)`。忽略 `from == to` 行;`rollbackKeySet`(`diagnosis_id|create_time|operator_admin_id`)命中的历史回退行按继承处理。 |
|
||||||
|
| `server/app/adminapi/logic/stats/RevisitRateLogic.php` | 类注释 `:10-31` 重写口径;`buildStatsCore` 查询字段 `:362` 增 `lg.from_assistant_id`;收集 `is_inherit=1 && aid>0 && t∈M` 行;`:387-395` 拒收/退款剔除集合并入继承诊单;查询 `tcm_diagnosis_auto_assign_log` 中 `rollback_time>0` 的 `(diagnosis_id, rollback_time, rollback_admin_id)` 组成 `rollbackKeySet`;调用 Calculator;`overview :71-80` slots 上界加入 `maxEntry`;`buildStatsCore :461` 的 `universeIds` 并集加入 `inheritEntryByAssistant` 的键,`:470-473` 二中心子树过滤时同步 `unset($inheritEntryByAssistant[$aid])`,返回体 `:477-488` 新增 `inheritEntryByAssistant / inheritPairs / maxEntry`,`assignLines` 的 `applyRowScope :176` 使用同一过滤后的集合(否则当月仅有继承行的医助会从 rows 静默丢失,或逃过二中心过滤);`:98-123/:125-153` 部门/合计行按 §1.1 去重;`buildMetricPack(int $newAssigned, array $inheritEntryByDiag, array $slotCounts, array $slots)` 输出 `assigned_count`(=新指派,语义不变)、`inherit_count`、`assigned_total_count`、`visit{N}_count/_den/_rate`、`visit_all_count/_den/_rate`、`visit2_rate_new_assign`;返回体增 `inherit_mode`;`assignLines :173-228` 遍历 `pairsRaw ∪ inheritPairs`,支持 `kind=new|inherit|all`,行增 `assign_type`、`inherit_visit_slot(k)`、`entry_slot`;`visitOrderLines :240` 允许 `slot=0` 表示全部 s≥2;`resolveInheritMode()` 白名单读参数/配置。排序 `:129/:148` 仍按 `assigned_count`。 |
|
||||||
|
| `server/app/adminapi/controller/stats/RevisitRateController.php` | 无新方法(`:20-50` 的 `$this->request->get()` 整体透传 `inherit_mode/kind/slot=0`)。 |
|
||||||
|
| `server/app/adminapi/logic/tcm/DiagnosisLogic.php` | `assign :665-746`:`$isInherit===1` 时在事务内调用 `RevisitVisitSlotService::slotAtTime($id, $now)` 写 `inherit_visit_slot/inherit_visit_slot_calc_time`(`:722-734` insert 增两列);`from == to` **不硬拦**(避免打断批量指派流程;`assign :666` 返回 bool,`DiagnosisController::assign :296-315` 与 `MyPatientController::assign :69-93` 均不透传 data,故不改后端返回结构),由前端在指派弹窗内用当前行 `assistant_id` 与目标比较后提示;`:849-851` 指派记录列表输出 `inherit_visit_slot_text`(「继承时第 k 诊」);`Db::commit() :742` 之后调 `RevisitDueLogic::onAssistantChanged($id, $toAssistantId)`(try/catch);`setRevisitSlotStartOffset :756-795`(入口 `DiagnosisController.php:104-110`)成功后调 `RevisitDueLogic::onDiagnosisChanged($id, 'offset')`——偏移量会直接改变 `current_slot/next_slot`,否则列表「第 N 诊」要等次日全量 cron 才更新。 |
|
||||||
|
| `server/app/adminapi/logic/stats/AutoAssignLogLogic.php:162-174` | 回退写 `'is_inherit' => $prevAssistantId > 0 ? 1 : 0` 并写 `inherit_visit_slot`;跨消费者影响:业绩看板「被指派数」(`YejiStatsLogic.php:1812/1894/2848` 按 `is_inherit=0`)在回退月少计一条,需产品确认(D1-e)。回退后调 `RevisitDueLogic::onAssistantChanged`。 |
|
||||||
|
| `server/app/command/BackfillInheritVisitSlot.php`(新增) | 命令 `tcm:backfill-inherit-visit-slot`,选项 `--dry-run`、`--recalc`、`--verify`(输出快照 vs 实时漂移清单:log_id/diagnosis_id/snapshot/realtime/原因)、`--month=`;遍历 `is_inherit=1 AND to_assistant_id>0`,按诊单分块 2000 调 `RevisitVisitSlotService`;整体 try/catch 返回 0。 |
|
||||||
|
| `server/app/command/AutoAssignPendingDiagnosis.php` | `:224` 改为 `RevisitRateLogic::overview(['month'=>$statMonth, 'inherit_mode'=>'slot'])`,`:231` 读 `config('project.revisit_rate.auto_assign_tier_field')` 指定字段;写 `tcm_diagnosis_auto_assign_log` 时同时写 `visit2_rate`、`visit2_rate_new_assign`、`rate_version`;`--dry-run` 输出两套口径的分档名单对照;类注释 `:26-42` 更新。 |
|
||||||
|
| `server/app/adminapi/lists/stats/AutoAssignLogLists.php:50-51` | 输出 `visit2_rate_new_assign`、`rate_version_text`。 |
|
||||||
|
| `server/app/adminapi/logic/stats/YejiStatsLogic.php` | 仅改注释/文案 `:24, 858-861, 1781, 1874, 2620, 2912-2913`:「与复诊接诊率的『新指派数』(assigned_count) 同口径」;三处 `where('lg.is_inherit', 0)` 不动。 |
|
||||||
|
| `server/app/adminapi/lists/tcm/PrescriptionOrderLists.php:1471-1524` | `appendDiagnosisEditVisitSeqFields` 改用 `RevisitVisitSlotService::batchCurrentSlot`,消除逐诊单 N+1,`global_visit_seq/counts_for_revisit_rate` 规则不变。 |
|
||||||
|
| `server/config/project.php`、`server/config/console.php` | 配置段(§2.9);注册 `'tcm:backfill-inherit-visit-slot' => 'app\\command\\BackfillInheritVisitSlot'`(放 `:56-58` 旁)。 |
|
||||||
|
| `server/tests/RevisitRateInheritSlotTest.php`(新增) | 纯单元(模式 1):k 计算、同一秒对偶、from==to 忽略、多次继承取最早、NI 与 IH 同诊单只算 NI、k=0 归二诊、部门去重与 entry 取 min、slots 上界、exclude 模式退化。`server/tests/RevisitRateContractTest.php`(新增):ReflectionMethod 守住「分子过滤仍用 `applyPrescriptionOrderNotCancelledForPerformanceQuery`、`slot<2` 跳过、holder `t <= ct`」。 |
|
||||||
|
|
||||||
|
### 3.2 需求 2/3:快照与列表
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|---|---|
|
||||||
|
| `server/app/common/service/tcm/RevisitDueCalculator.php`(新增) | 纯函数:输入 `latestOrder, validOrderCount, offset, rxUsageDays, signTs, shipTs, appointmentsAfterOrder[], firstTouchTs, today, cfg` → 输出 §1.2 事实字段 + §1.3 `state`。无 DB,可单测。 |
|
||||||
|
| `server/app/common/service/tcm/RevisitDueService.php`(新增) | `refreshDiagnoses(array $diagIds, int $operatorId = 0, string $reason = '')`:批量取最新有效订单(`batchCurrentSlot`)、处方 `usage_days`(`is_system_auto=0 AND void_status=0 AND delete_time IS NULL`)、签收(`batchResolveSignUnixFromDbForOrders`)、发货(`tcm_prescription_order_log action='ship' MIN(create_time)`,`anchor_chain` 不含 `ship` 时跳过)、挂号(`patient_id IN`,`status IN (1,3,4)`,`appointment_date >= 最小锚点日`),调用 Calculator,`Db::name('tcm_diagnosis_revisit_due')->duplicate([...])->insert()`(用法参照 `server/app/adminapi/logic/qywx/CustomerLogic.php:1058`);`source_order_id` 变化 → 重置延迟字段 + event=6;`assistant_id` 变化 → 重置延迟字段 + event=9;`to=0`/诊单删除/无有效订单 → `closed_state=5`;`closeByAppointment / reopenByAppointment / reevaluateBookedRows()`;所有写入 (int) 强转 `assistant_id`。 |
|
||||||
|
| `server/app/common/model/tcm/DiagnosisRevisitDue.php`、`DiagnosisRevisitDueLog.php`(新增) | 仿 `server/app/common/model/tcm/DiagnosisTodo.php:31, 37, 40-53`:`autoWriteTimestamp=true`、`dateFormat=false`、状态常量 + `*_text` 访问器。 |
|
||||||
|
| `server/app/adminapi/logic/tcm/RevisitDueLogic.php`(新增,需求 3/4 共用) | `extends BaseLogic`。统一钩子入口 `onDiagnosisChanged(int $diagnosisId, string $reason)`、`onAssistantChanged(int $diagnosisId, int $to)`、`onAppointmentChanged(int $diagnosisId, int $appointmentId)`,全部 `try { RevisitDueService::... } catch (\Throwable $e) { Log::warning(...) }`,不改变主流程返回值(模式同 `AppointmentLogic.php:597-611`);需求 4 的 `pending/snooze/contact/close` 业务(§3.3)。 |
|
||||||
|
| `server/app/adminapi/lists/tcm/DiagnosisLists.php` | ① `lists()` 在未服务天数段(`:431-461`)之后批量读 `tcm_diagnosis_revisit_due`(`whereIn diagnosis_id`),为每行追加 `revisit_due` 对象(`current_slot, next_slot, medication_days, days_src, rule_days, anchor_src, anchor_date, order_date, due_date, days_left, overdue_days, state, state_text, snooze_count, escalation_level, promised_date, missed_count, first_touch_date, in_care_days, closed_state`);**自愈**(移植方案 1/2):本页诊单若快照缺行、或 `batchCurrentSlot` 得到的最新有效订单 id ≠ 快照 `source_order_id`,仅当请求 `page_size ≤ 100` 时,对本页前 50 行内的不一致行调用 `RevisitDueService::refreshDiagnoses` 后再返回,其余交给增量 cron(`ListsValidate` 允许 `page_size` 直到 `project.lists.page_size_max`=25000,避免一次 GET 刷新成千行并逐批解析签收);比对条件同时包含 `batchCurrentSlot.current_slot ≠ 快照 current_slot`(覆盖偏移量修改)。替代「待计算」占位。② 新增私有方法 `applyRevisitDueFilters($query, bool $pendingWideSearch)`(移植方案 0),在 `lists()`(`:106-160` 段)与 `count()`(`:561-610` 段)各调用一次,参数:`revisit_state`(due=overdue∪missed / warning / booked / visited / contacted / no_days / pending)、`revisit_days_bucket`(14/21/28/30/other);实现为 `whereExists(SELECT 1 FROM zyt_tcm_diagnosis_revisit_due rd WHERE rd.diagnosis_id = {diag}.id AND ...)`,如 `due`(= overdue ∪ missed):`rd.closed_state=0 AND (rd.appointment_state=4 OR (rd.due_date IS NOT NULL AND rd.due_date <= CURDATE())) AND (rd.promised_date IS NULL OR rd.promised_date < CURDATE())`。③ `resolveListOrderRaw :719-752` 新增 `sort_revisit_due=asc|desc` 分支,锚点 `(SELECT rd.due_date FROM zyt_tcm_diagnosis_revisit_due rd WHERE rd.diagnosis_id = {diag}.id AND rd.closed_state = 0 LIMIT 1)`,NULL 置后写法同 `:724-731`,与 `sort_unserved_days` 互斥。 |
|
||||||
|
| `server/app/adminapi/logic/tcm/DiagnosisLogic.php` | 详情/只读接口附带同一 `revisit_due` 对象(供 `readonly.vue`)。 |
|
||||||
|
| 钩子接入(P0,均经 `RevisitDueLogic` 包裹) | `server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php`:`create :978`(成功后)、`editTime :1152`、`withdraw :2250`、`complete :3234`(目标状态 ∈ {9,10} 时)、`refund :3351`;`server/app/api/controller/GancaoCallbackController.php:331-341`(case 91/92 置 4 后);`server/app/adminapi/logic/doctor/AppointmentLogic.php`:`create :280`(写入后)、`cancel :420`、`complete :576`、`adminEdit :978`;`server/app/common/command/UpdateAppointmentStatus.php:55-56` 两条 `Db::execute` 之后调 `RevisitDueLogic::onAppointmentStatusCronDone()`(内部 try/catch + `Log::warning`;该命令 `execute()` 本身无 try/catch,若直接调 Service 抛异常会让 `Crontab::start` 把既有 `update_appointment_status` 任务置 ERROR 停跑);`DiagnosisLogic::assign :742` 之后;`DiagnosisLogic::setRevisitSlotStartOffset` 成功后;`server/app/command/AutoAssignPendingDiagnosis.php:507-518` 写日志后;`AutoAssignLogLogic.php:162-174` 回退后。甘草推 5(`:380-383, 406-409`)与签收推 6(`ExpressTrackingService.php:559`)不加钩子,由增量 cron 覆盖(锚点由下单日切到签收日最长滞后 30 分钟)。 |
|
||||||
|
| `server/app/command/RevisitDueScan.php`(新增) | 命令 `tcm:revisit-due-scan`,选项 `--mode=full|incremental`、`--dry-run`、`--diagnosis-id=`、`--date=`(基准日回放)。候选:`tcm_diagnosis.delete_time IS NULL AND status=1 AND CAST(assistant_id AS UNSIGNED) > 0`(`limit_er_center` 时再限 `DeptLogic::getErCenterSubtreeDeptIdSet`(`DeptLogic.php:214`)+ `admin_dept`);incremental 只取近 2 小时 `tcm_prescription_order.update_time`、`doctor_appointment.update_time`、`tcm_diagnosis_assign_log.create_time`、`tcm_diagnosis.update_time`(偏移量/医助变更)有变化的诊单 + `closed_state=2` 的行重评;分块 500 调 `RevisitDueService::refreshDiagnoses`;候选还并入 `closed_state<>5` 的既有快照行(诊单删除/禁用/释放医助后由全量重算推进为失效);防重入:按 mode 在 Cache 记录上次开始时间,5 分钟内跳过(钩子/自愈会持续刷新 `computed_at`,不能用它判断);`--date` 与今天不同时强制演练模式;**整体 try/catch 并 `return 0`、按块隔离异常**(`Crontab.php:81-87` 异常即停任务);输出刷新/新增/关闭/缺天数/耗时统计。注册到 `server/config/console.php`。 |
|
||||||
|
| `server/tests/RevisitDueCalculatorTest.php`(新增) | 状态机 9 态、锚点回退链三种、天数回落三种、15 天不归 14 档、承诺日/过号重开、新周期重置。 |
|
||||||
|
|
||||||
|
### 3.3 需求 4:接口
|
||||||
|
|
||||||
|
控制器 `server/app/adminapi/controller/tcm/RevisitDueController.php`(新增,URL 自动映射 `/adminapi/tcm.revisitDue/<action>`,权限串 `tcm.revisitDue/<action>`):
|
||||||
|
|
||||||
|
| 方法 | 接口 | 入参 | 行为 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `tcm.revisitDue/pending` | — | 本人 `assistant_id = adminId AND closed_state=0 AND (appointment_state=4 OR (due_date IS NOT NULL AND due_date <= CURDATE())) AND (promised_date IS NULL OR promised_date < CURDATE()) AND next_remind_at <= now`,≤ `toast_limit` 条,按 `escalation_level desc, overdue_days desc`;返回前对每条做轻量实时校验(EXISTS 新挂号 1/3 → 顺手关闭并剔除;最新有效订单 id ≠ `source_order_id` → refresh);`first_shown_at` 为 0 时写一次 + event=11,`last_shown_at` 每次更新;**不做服务端软节流**,每次返回当前全集,前端以响应为准整体替换卡片列表(被他人处理、换医助、cron 关闭的行自然消失,不需要 removed_ids);`toast_enabled=false` 返回空;`$this->data()`(show=0)。返回 `{items:[...], summary:{due_total, max_overdue_days, warning_total}}` |
|
||||||
|
| GET | `tcm.revisitDue/summary` | — | 仅计数(供右下角小气泡/顶栏),不更新 shown |
|
||||||
|
| POST | `tcm.revisitDue/snooze` | `id` | 条件更新 `UPDATE ... SET snooze_count=snooze_count+1, escalation_level=?, next_remind_at=?, last_snoozed_at=?, last_snoozed_by=? WHERE id=? AND assistant_id=? AND closed_state=0 AND next_remind_at<=?`;`affected=0` 也返回当前行(幂等);写 event=1,等级变化再写 event=10;`success('已延迟,HH:mm 再提醒', $data, 1, 1)` |
|
||||||
|
| POST | `tcm.revisitDue/snoozeAll` | `ids[]` | 仅 `escalation_level <= 1` 的项,逐项同 snooze |
|
||||||
|
| POST | `tcm.revisitDue/contact` | `id, promised_date, remark?` | `promised_date ∈ [today, today+promise_max_days]`;`contact_count < contact_max_per_cycle` 时不增 `snooze_count`,否则按一次延迟计;`next_remind_at = promised_date 09:00`;event=2 |
|
||||||
|
| POST | `tcm.revisitDue/close` | `id, reason in {lost,other}, remark(必填,≤200)` | `closed_state=4`(条件 `closed_state=0`),event=3;医助本人可操作,主管视图标记 |
|
||||||
|
| GET | `tcm.revisitDue/lists`(P2) | 分页 + `assistant_id/dept_id/state/days_bucket/min_snooze/keyword` | `RevisitDueLists`(`HasDataScopeFilter::applyDataScopeByOwner('assistant_id')`,`server/app/common/lists/Traits/HasDataScopeFilter.php:25`),支持导出 |
|
||||||
|
| GET | `tcm.revisitDue/logs`(P2) | `due_id` | 流水 |
|
||||||
|
|
||||||
|
配套:`server/app/adminapi/validate/tcm/RevisitDueValidate.php`(新增,`id require|number|gt:0`、`promised_date date`、`reason in:lost,other`、`remark max:200`,scene `snooze/snoozeAll/contact/close`,写法同 `server/app/adminapi/validate/tcm/DiagnosisTodoValidate.php`);`server/app/adminapi/lists/tcm/RevisitDueLists.php`(P2,新增)。所有写操作在 Logic 内校验 `assistant_id === adminId` 或超管(role_id=1)/数据范围可见,不依赖路由层。File 缓存无锁(`server/config/cache.php:9`),互斥全靠条件 UPDATE;cron 不改 `snooze_count/next_remind_at`(除新周期/换人),接口不改 cron 字段,列级隔离。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 前端改动
|
||||||
|
|
||||||
|
### 4.1 复诊接诊率页(需求 1)
|
||||||
|
|
||||||
|
`admin/src/views/stats/revisit_rate/index.vue`
|
||||||
|
- 筛选栏加 `el-radio-group`「口径:继承按继承时诊次计入(新)/ 继承不计分母(旧)」→ `inherit_mode`,默认取后端返回;切换即 `loadData`。
|
||||||
|
- 「当月被指派数」列(`:107`)标题改「新指派数」;新增「继承数」列(`inherit_count`,slot 模式显示,点击下钻 `kind=inherit`);新增「被指派合计」(`assigned_total_count`)。
|
||||||
|
- 每个 N 诊「接诊率」单元格(`:151-157`):主显示 `62.5%`,下方灰色小字 `5 / 8`(`visit{N}_count / visit{N}_den`);表头 tooltip 改为「分母 = 当月新指派数 + 起算诊次 ≤ N 的继承数」。
|
||||||
|
- 追加「复诊合计单数」「复诊合计接诊率」(`visit_all_count/visit_all_rate`),点击打开 `visitOrderLines(slot=0)`。
|
||||||
|
- KPI 卡(`:351-358`)增「当月复诊合计接诊率」;`getSummaries`(`:367-383`)正则扩为 `^visit(\d+|all)_(count|den|rate)$` 并加 `inherit_count/assigned_total_count`;`:402` 过滤 `s >= 2` 保留。
|
||||||
|
- 被指派明细弹窗(`:162-236`,列 `:196-197`)新增「指派类型」(新指派/继承 tag)、「继承时诊次 k」、「起算诊次」。
|
||||||
|
- 口径说明 popover(`:41-60`)按模式切换两段文案,注明「继承分母自 2026-06(is_inherit 列加入)起生效」。
|
||||||
|
- `admin/src/api/stats.ts:208-235`:参数类型加 `inherit_mode?: 'exclude'|'slot'`、`kind?`、`slot` 允许 0;注释更新。
|
||||||
|
- `admin/src/views/fans/yeji.vue:401` tooltip 改「与复诊接诊率『新指派数』同口径」;`admin/src/views/stats/auto_assign_log/index.vue:69,140` 显示口径版本与两套率。
|
||||||
|
- 指派弹窗 `admin/src/views/tcm/diagnosis/index.vue:520-521`、`admin/src/views/tcm/follow/index.vue:329-330`、`admin/src/views/first_visit/my_patients/index.vue:255-258`:当「当前医助>0 且 目标≠当前」时默认勾选「继承」并弹确认(文案:该患者当前第 k 诊,继承后从第 k+1 诊起计入您的接诊率;k 来自列表行 `revisit_due.current_slot`);目标 == 当前时提示「目标医助与当前相同」(D1-d,P1)。`admin/src/views/tcm/diagnosis/components/AssignLogPanel.vue:15-17` 继承列旁显示「继承时第 k 诊」。
|
||||||
|
|
||||||
|
### 4.2 诊单列表(需求 2/3)
|
||||||
|
|
||||||
|
**PC `admin/src/views/tcm/diagnosis/index.vue`**
|
||||||
|
- 「复诊」列(`:281`,min-width 120)改造为「复诊节点」列(min-width 180),吞掉「开方」列(`:304`,width 72),净增宽度≈0。单元格抽成共用组件 `admin/src/views/tcm/diagnosis/components/RevisitNodeCell.vue`(新增;PC / H5 / 只读共用),四行:
|
||||||
|
|
||||||
|
```
|
||||||
|
[第3诊] 已开方 · 王医生 ← 诊次 tag(tooltip:有效订单 3 笔 + 偏移 0;待复诊第 4 诊;首次触达 03-02 · 在管 224 天;累计开药 84 天)
|
||||||
|
09-20 开药 · 28 天 (签收起) ← 最新有效订单日 · 开药天数 · 锚点来源;缺天数时橙色「缺开药天数」
|
||||||
|
药尽 10-18 [超期 3 天] ← 预计复诊日 + 状态 chip(§1.3)
|
||||||
|
已延迟 2 次 · 已联系 约 10-15 ← 仅 snooze_count>0 或已联系时显示
|
||||||
|
```
|
||||||
|
- 状态文案/class 映射放 `admin/src/utils/revisitDue.ts`(新增);chip 色复用 `unserved-red #dc2626 / unserved-orange #ea580c / unserved-green #16a34a`(`:2665-2681`)与 `#4A5DFF`;el-tag 用法同 NEW 标签(`type="danger" size="small" effect="dark"`)。
|
||||||
|
- 行级标红:`getRowClassName`(`:1347-1350`)新增 `row-revisit-overdue`(state ∈ {overdue, missed})、`row-revisit-overdue-deep`(≥ `severe_overdue_days`),优先级按配置默认 **复诊超期 > 未确认 > 未挂号**;样式复制 `:deep(.el-table__row.row-unconfirmed)`(`:2771-2777`)把 `--el-color-warning` 换 `--el-color-danger`,deep 版本背景 10%。
|
||||||
|
- 顶部 chip 区(`:7-72`,「待预约」chip `:27`)后新增「复诊到期 N」(danger 色,样式仿 `.chip-pending-booking` `:2301`),点击设 `formData.revisit_state='due'` 并默认 `sort_revisit_due='asc'`;角标在 `fetchDateCounts`(`:1061-1082`)的 `Promise.all` 追加 `buildDateCountRequestPayload({ revisit_state: 'due' })`;chip active 互斥条件加入 `formData.revisit_state === ''`。
|
||||||
|
- 「更多筛选」(`:107-152`)新增「复诊状态」下拉 → `revisit_state`、「开药天数档」下拉(14/21/28/30/其它)→ `revisit_days_bucket`。
|
||||||
|
- 「复诊节点」列 `sortable="custom" prop="revisit_due_date"`;`handleTableSortChange`(`:1248`)改为按 prop 分派、互斥清空 `sort_unserved_days / sort_revisit_due`。
|
||||||
|
- `formData`(`:787`)新增 `revisit_state / revisit_days_bucket / sort_revisit_due`,同步 6 处:`handleReset :1302`、`clearSecondaryFiltersWhenPendingAssignWideSearch :938`、`buildSharedDiagnosisFilterPayload :889`、`buildDateCountRequestPayload :906`、chip active 判定、各 Tab 点击处理器(`:984-1057`)。
|
||||||
|
- `?id=` 处理(`:2232`)扩展 `action=appointment` → `appointmentRef.value?.open(row)`(`appointmentRef :1356`、`handleAppointment :1955-1961`),供弹窗「去挂号」深链。
|
||||||
|
|
||||||
|
**H5 `admin/src/views/tcm/diagnosis/index_h5.vue`**:复诊块(`:174-181`)替换为 `RevisitNodeCell` 紧凑模式;`formData`(`:678`)加 `revisit_state`;chip 区加「复诊到期」;`getCardClass`(`:901-903`)加 `dh5-card--overdue`(accent 色同 `:1676`);tag 复用 `dh5-tag--warn`(`:1762`)。
|
||||||
|
**只读页 `admin/src/views/tcm/diagnosis/readonly.vue:23-27`**:在「未服务」徽标旁加「第 N 诊 · 药尽 MM-DD · 超期 N 天」徽标,着色复用 `:200-205` 的 class。
|
||||||
|
**`admin/src/views/tcm/follow/index.vue`**(diagnosis 旧副本,组件同名 `tcmDiagnosis` `:548`,固定 `only_has_prescription=1` `:578`,「第 N 次复诊」= 处方张数 `:155-157`):待产品确认是否仍在用(D2-c);若在用则改用 `RevisitNodeCell` 并改组件名。
|
||||||
|
|
||||||
|
### 4.3 全局弹窗(需求 4)
|
||||||
|
|
||||||
|
新组件 `admin/src/components/revisit-due-toast/index.vue`(新增),在 `admin/src/App.vue:60` 与 `ChatNotifyToast` 并列挂载,复用 `showChatNotifyToast` 条件(`:19-23`,避免未绑企微 code=10 反复打断)。**不放诊单页内**(菜单 `is_cache=0`,`server/sql/tcm_diagnosis_menu.sql:13`,离开页面即卸载)。
|
||||||
|
|
||||||
|
- 位置:固定**右下角**(`bottom:24px; right:24px; z-index:3000`),避免与右上角 `ChatNotifyToast`(`chat-notify-toast/index.vue:140-141` `top:72px; right:24px`)叠放;`body.h5-mode` 下改为底部全宽。
|
||||||
|
- 形态:**一张汇总卡**(不是一人一张):标题「⚠ N 位患者到期未挂号」+ 每行「患者 · 第 N 诊·X 天 · 药尽 MM-DD · 超期 N 天 / 过号 · 曾过号 N 次」+ 按钮 [去挂号][已联系][延迟 Xh];超出 5 条折叠「还有 N 位 → 查看全部」(跳诊单页并激活「复诊到期」chip);底部「全部延迟 1 小时」仅当所有项 level ≤ 1 时显示;最小化仅 level ≤ 1 可用。
|
||||||
|
- 去挂号:`router.push({ path:'/tcm/diagnosis', query:{ id, action:'appointment' } })`,本地乐观移除,挂号成功由钩子关闭。
|
||||||
|
- 已联系:内联日期选择(默认今天+3,范围今天~+14)+ 备注 → `contact`。
|
||||||
|
- 延迟:`snooze` → 项消失,`ElMessage`「已延迟,14:30 再提醒(第 2 次)」。
|
||||||
|
- level 3:该患者单独 `ElMessageBox`(`admin/src/utils/feedback.ts` 已封装)。
|
||||||
|
- 轮询:60 秒(对比 chat 的 5 秒),首载立即一次,`visibilitychange` 回前台立即一次,`document.hidden` 跳过(参照 `index.vue:1117`);多标签页:`localStorage['revisit_due_toast:last_fetch']` 跨标签 30 秒节流 + `BroadcastChannel('revisit-due-toast')` 广播 snooze/contact/close 结果同步移除;按 `id` 去重。
|
||||||
|
- API:`admin/src/api/revisitDue.ts`(新增)`revisitDuePending/Summary/Snooze/SnoozeAll/Contact/Close/Lists/Logs`。
|
||||||
|
- P2 主管视图 `admin/src/views/stats/revisit_due/index.vue`(新增,菜单 `/stats/revisit_due`,component `/stats/revisit_due/index`):筛选 + 表格(患者/医助/部门/第 N 诊/开药天数/预计复诊/超期天数/延迟次数/已联系/承诺日/状态)+ 流水抽屉;`v-perms="['tcm.revisitDue/lists']"`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 定时任务与通知
|
||||||
|
|
||||||
|
| 命令 | 频率 | 职责 | 期 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `tcm:revisit-due-scan --mode=full`(`server/app/command/RevisitDueScan.php` 新增) | 每日 08:30 | 全量重算快照(签收时间落库、过号变化、钩子漏刷兜底) | P0 |
|
||||||
|
| `tcm:revisit-due-scan --mode=incremental` | 每 30 分钟 | 近 2 小时有订单/挂号/指派变更的诊单 + `closed_state=2` 行重评 | P0 |
|
||||||
|
| `tcm:backfill-inherit-visit-slot`(`server/app/command/BackfillInheritVisitSlot.php` 新增) | 一次性手工 + 需要时 `--verify` | 回填/校验 `inherit_visit_slot` | P1 |
|
||||||
|
| `tcm:revisit-due-notify --mode=daily|tick`(`server/app/command/RevisitDueNotify.php` 新增) | 09:00 / 每 30 分钟 | 企微推送(§5.2) | P2 |
|
||||||
|
| 既有 `update_appointment_status`(`UpdateAppointmentStatus.php`) | 线上频率需确认 | `:55-56` 之后追加 `RevisitDueLogic::onAppointmentStatusCronDone()`(try/catch 包裹),使当天过号即进标红/弹窗 | P0 |
|
||||||
|
| 既有 `tcm:auto-assign-pending` | 每日 09:00 | 读 `auto_assign_tier_field` 指定字段分档,日志双记录 | P1 |
|
||||||
|
|
||||||
|
命令约定:`setName/setDescription/addOption('dry-run')`,`execute()` 整体 try/catch 返回 0、按块隔离异常(`Crontab.php:81-87`);`dev_crontab.params` 仅 64 字符;注册于 `server/config/console.php`(`:56-58` 旁)。
|
||||||
|
|
||||||
|
### 5.2 企微联动(P2,`wecom_enabled=false` 默认关闭)
|
||||||
|
- daily:每位有 `overdue/missed` 行的医助一条汇总「您有 N 位患者到期未挂号,最久 X 天」;`WechatWorkAppMessageService::sendTextToUser`(`server/app/common/service/wechat/WechatWorkAppMessageService.php:130`),接收人 `admin.work_wechat_userid`(取法同 `server/app/command/DiagnosisTodoNotify.php:94-98`),未绑定跳过并写 event=8 payload 记录,不算失败。
|
||||||
|
- tick:`escalation_level>=3 AND next_remind_at<=now` 的行推本人(每条每日 ≤ `wecom_daily_cap`);`snooze_count>=5` 首次达到时抄送 `supervisor_admin_ids` 1 次/日。
|
||||||
|
- 推送只在命令内发(接口不同步 curl);逐条 `usleep(200ms)`;异常不得抛到 `Crontab::start`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 权限、菜单与 MCP 目录登记
|
||||||
|
|
||||||
|
1. **新 action 全部登记 A 类菜单**(§2.5),并用 `INSERT IGNORE INTO zyt_system_role_menu` 自动授予已拥有 `tcm.diagnosis/lists` 的角色;原因:`AuthMiddleware.php:111-117` 对未登记 URI 直接放行,`:124` 的 `matchPermissionAlias` 只对已登记 URI 生效。可在 `matchPermissionAlias :171-251` 增加「拥有 `tcm.diagnosis/lists` 即视为拥有 `tcm.revisitdue/pending|summary|snooze|snoozeall|contact|close`」作为兜底(写法同 dailyRecord 别名 `:202-206`)。
|
||||||
|
2. 需求 1 不新增接口(复用 `stats.revisitRate/overview|assignLines|visitOrderLines|deptOptions`,`server/sql/1.9.20260610/add_revisit_rate_menu.sql`)。
|
||||||
|
3. P2 主管视图 C 类菜单 `tcm.revisitDue/lists` + A 类 `logs/export`(§2.8);执行后 `AdminAuthCache::clearAuthCache`(`server/app/common/cache/AdminAuthCache.php:115`)或等缓存过期;「全部菜单」角色需复核。
|
||||||
|
4. MCP 目录:`server/app/mcp/catalog/review/stats.php:173-209` 四条 revisitRate 的 note/params_allow 加 `inherit_mode/kind/slot=0`,`:46-62` autoAssignLog 补口径说明;`server/app/mcp/catalog/review/tcm.php` 为 `tcm.revisitDue/lists|logs` 写 `status: pending`(含患者姓名,待评审),写接口/轮询端点 `excluded`;执行 `php app/mcp/cli/catalog.php --write` 重生成 `generated.php`(`server/app/mcp/catalog/review/README.md`);`php server/tests/AiMcpUnitTest.php` 通过。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 兼容、回填与灰度/回滚
|
||||||
|
|
||||||
|
| 项 | 做法 |
|
||||||
|
|---|---|
|
||||||
|
| 需求 1 历史数据 | 口径实时重算,历史月份切换 `inherit_mode` 即可对照;`is_inherit` 2026-06-01 加列前的转接全为 0,无法回溯(popover 注明)。`php think tcm:backfill-inherit-visit-slot --dry-run` → 正式回填 → `--verify` 漂移为 0。 |
|
||||||
|
| 自动分配分档 | `auto_assign_tier_field` 默认 `visit2_rate_new_assign`(旧口径),上线日分档不变;`--dry-run` 输出两套分档名单给产品对照,确认后改配置为 `visit2_rate`,不发版;历史 `auto_assign_log.visit2_rate` 不回溯,`rate_version='v1'` 标识。回滚 = 改回配置。 |
|
||||||
|
| 需求 1 页面回滚 | `default_inherit_mode='exclude'`,字段值回到现状。 |
|
||||||
|
| 快照首次初始化 | 部署后 `php think tcm:revisit-due-scan --mode=full`(分块 500,预计数千~数万诊单 1~5 分钟,需在测试库压测;可先 `anchor_chain=['create']` 跳过签收/发货查询);表为空或缺行时列表自愈(§3.2),不影响展示。 |
|
||||||
|
| 首日弹窗雪崩 | `toast_enabled=false` 上线 → 主管用「复诊到期」chip 清存量 → 开启;初始化时对 `due_date < 上线日−30 天` 的历史逾期行把 `next_remind_at` 置为上线日+1 09:00(移植方案 0)。 |
|
||||||
|
| `medication_days` 缺失 | 回落处方 `usage_days`(剔除系统代开/作废),仍缺不标红并显示「缺开药天数」;上线前核查 `SELECT COUNT(*) FROM zyt_tcm_prescription_order WHERE delete_time IS NULL AND (medication_days IS NULL OR medication_days=0)`;补填入口复用 `PrescriptionOrderDetailDrawer.vue:996` 的服用参数修补(`PrescriptionOrderLogic.php:5802-5869`)。 |
|
||||||
|
| `tcm_diagnosis.assistant_id` 列类型未知 | 候选查询用 `CAST(assistant_id AS UNSIGNED) > 0`,写入 (int) 强转;上线前 DESC 确认。 |
|
||||||
|
| P0/P1/P2 回滚 | P0:停两条 crontab、前端隐藏「复诊节点」新内容(恢复「复诊/开方」列),表保留;P1:`App.vue` 去掉挂载一行 + `toast_enabled=false`;P2:停 notify crontab。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 分期交付与工期(P0/P1/P2)
|
||||||
|
|
||||||
|
| 期 | 内容 | 工期(人日) | 上线步骤 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **P0 需求 2/3** | `RevisitVisitSlotService`、`RevisitDueCalculator/Service`、模型、快照表 + 流水表 + 索引、`tcm:revisit-due-scan`(full/incremental)、钩子接入、`DiagnosisLists` 字段/自愈/筛选/排序、`RevisitNodeCell` + PC 列改造 + 行标红 + chip + 筛选、H5/只读页同步、配置段、单测 | 7(后端 4、前端 2.5、联调 0.5) | SQL 01~04 → 部署后端 → `php think tcm:revisit-due-scan --mode=full` → 部署前端 → 抽样核对 20 个诊单(`--dry-run --diagnosis-id=X` 与页面一致) |
|
||||||
|
| **P1 需求 4 + 需求 1** | 接口 6 个 + Validate/Logic、全局组件(阶梯/升级/已联系/多标签去重)、`action=appointment`、菜单 SQL 05;需求 1:Calculator 重构、每列分母、合计列、下钻扩展、`inherit_mode`、快照列 + 回填命令、AutoAssign 可配字段 + 双记录、文案与 MCP 目录、指派弹窗默认勾继承 | 8(后端 4.5、前端 3、联调 0.5) | SQL 05~07 → 部署 → 清权限缓存 → `tcm:backfill-inherit-visit-slot` → `tcm:auto-assign-pending --dry-run` 对照 → `toast_enabled=false` 清存量 → 开启弹窗 |
|
||||||
|
| **P2 增强** | 企微 daily/tick + 主管抄送、主管团队视图页 + `RevisitDueLists`、触达时间线接口、follow 页处置、「补天数」快捷入口 | 4 | SQL 08~09 → 部署 → 角色勾权限 → `wecom_enabled` 观察一周后开启 |
|
||||||
|
|
||||||
|
合计 19 人日;含 15% 缓冲约 22 人日(前后端并行约 3 周)。P0 可独立上线;P1 依赖 P0 的服务与表;P2 依赖 P1 的接口。
|
||||||
|
|
||||||
|
每期前置自检:`php -l` 全部改动文件;`php server/tests/RevisitDueCalculatorTest.php`、`RevisitRateInheritSlotTest.php`、`RevisitRateContractTest.php`、`AiMcpUnitTest.php`;`admin` 下 `npm run type-check && npm run lint`;docker 环境跑一次 `php think tcm:revisit-due-scan --dry-run`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 风险
|
||||||
|
|
||||||
|
1. **口径变更连带**:`visit2_rate` 在 slot 模式下变化,影响 `AutoAssignPendingDiagnosis` 分档;过渡期用旧口径字段,页面与分档「对不上」需在自动分配日志页标注口径版本。
|
||||||
|
2. **继承勾选质量**:继承靠人工勾选、无默认推断(`DiagnosisLogic.php:671`),漏勾则仍按新指派计;P1 换人场景默认勾选 + 确认缓解;2026-06 前转接无法回溯。
|
||||||
|
3. **回退行改继承的跨消费者影响**:`AutoAssignLogLogic` 写 `is_inherit=1` 会使业绩看板「被指派数」(`is_inherit=0`)在回退月少计一条,需产品确认(D1-e)。
|
||||||
|
4. **快照与实时漂移**:事后退款/取消/改 offset 会让 `inherit_visit_slot` 与实时值不一致;统计以实时为准,`--verify` 定期跑、`--recalc` 可重算。
|
||||||
|
5. **`medication_days` 缺失**:不兜底 7 会有「缺天数」无 due_date 的行,依赖医助补填;上线前查分布。
|
||||||
|
6. **签收时间异步**:`express:auto-update` / `tcm:backfill-sign-time`(`server/config/console.php:52`)在线上是否启用未知;锚点由下单日切到签收日时 due_date 会后移 2~5 天;UI 标注来源,增量 cron 30 分钟内刷新。甘草自动发货不写 `ship` 日志,锚点会回退到下单日。
|
||||||
|
7. **过号 8 小时自动变取消**(`UpdateAppointmentStatus.php:42-44`),且该任务线上是否启用未知;「过号未面诊」只在当天可见,次日统一归「到期未挂号」。
|
||||||
|
8. **`DiagnosisLists` 性能与双写**:已有处方聚合 + 三表未服务天数 + 3 个相关子查询排序;新增筛选用 EXISTS 单表索引、排序用单行子查询;自愈最多刷新 25 行;`applyRevisitDueFilters` 单方法两处调用防遗漏。验收门槛:列表 P95 增幅 ≤150ms。
|
||||||
|
9. **全量 cron 耗时无压测依据**:含签收解析(`ExpressTrackingService.php:1642`,200/批)与 `tcm_prescription_order_log`(DDL/索引不在仓库)聚合;先在测试库压测,必要时 `anchor_chain=['create']`。
|
||||||
|
10. **权限模型**:未登记接口默认放行(`AuthMiddleware.php:111-117`);本稿全部登记 + 自动授予 + Logic 内归属校验;自建角色仍需人工复核。
|
||||||
|
11. **Crontab 异常即停**(`Crontab.php:81-87`):新命令必须吞异常返回 0;File 缓存无锁,一切并发控制靠条件 UPDATE。
|
||||||
|
12. **弹窗疲劳与「无脑点延迟」**:首发开关、限 20 条、「已联系」免计次但有上限、延迟次数主管可见;上线后一周调参。
|
||||||
|
13. **多标签页/多设备**:BroadcastChannel/localStorage 只覆盖同浏览器;跨设备靠 `next_remind_at` 去重,可能各弹一次。
|
||||||
|
14. **`tcm_diagnosis.assistant_id` 列类型未知**、`tcm_prescription_order_log` 无 DDL、MySQL 5.7 无窗口函数:DBA 上线前核对。
|
||||||
|
15. **follow 页**(`tcm/follow/index.vue`)为旧副本且组件同名 `tcmDiagnosis`,不同步会出现两页「复诊」口径不一致。
|
||||||
|
16. **钩子分散**(订单 5 处 + 甘草 1 处 + 挂号 4 处 + 过号 cron + 指派/自动分配/回退 3 处):任一漏接由 30 分钟增量 cron、列表自愈、`pending()` 实时校验三层兜底。
|
||||||
|
17. **MCP 目录滞后**:参数/接口变更后未重生成 `generated.php` 会导致 AI 查询参数校验落后,列入每期上线清单。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 需产品确认的决策点(每条给推荐选项)
|
||||||
|
|
||||||
|
| 编号 | 问题 | 选项 | 推荐 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| D1-a | 需求 1 继承分母采用哪种定义 | A 累计型:继承行按继承时诊次 k 进入 slot ≥ k+1 的每一列分母,分子不变 / B2 单列型:只进 slot=k+1 列且分子只计继承后首单 / C 取消 is_inherit 剔除 | **A**。分子不动、历史 N 诊单数不变、可与订单明细逐笔对账;B2 改分子语义,C 不体现「几诊」 |
|
||||||
|
| D1-b | 「几诊」指已完成第 k 诊还是即将发生的第 k+1 诊 | 从第 k+1 诊列起计入 / 从第 k 诊列起计入 | **k+1**。第 k 诊订单归前任持有人,继承者只能产生 k+1 及以后成交;k ≤ 1 的继承与新指派等价 |
|
||||||
|
| D1-c | 「所有诊数接诊率」含义;是否展示一诊 | 新增复诊合计列、不展示一诊 / 合计列 + 一诊列 / 仅动态展示所有诊次列(现状) | **合计列、不展示一诊**。二中心患者多已在一中心下首单(offset 机制即为此) |
|
||||||
|
| D1-d | 换人(from>0 且 to>0)时前端是否默认勾选「继承」并确认;目标==当前是否拦截 | 默认勾选 + 二次确认,目标相同仅提示 / 保持手动勾选 / 后端硬拦截目标相同 | **默认勾选 + 确认;不硬拦**(避免打断批量指派流程,统计侧忽略 from==to) |
|
||||||
|
| D1-e | 自动分配回退行如何计 | 回退写 is_inherit=1(to>0 时)+ 历史行按 rollback_time 关联豁免 / 保持 is_inherit=0 / 单独排除 | **第一项**。原医助重新接手不是新增工作量;副作用是业绩看板被指派数少计一条 |
|
||||||
|
| D1-f | AutoAssign 分档是否立即跟随新口径 | 过渡期用 visit2_rate_new_assign,一个月后切换 / 立即切换 / 永久旧口径 | **过渡一个月**,`--dry-run` 对照后改配置 |
|
||||||
|
| D1-g | 业绩看板「被指派数」语义 | 保留仅新指派、改 tooltip 文案 / 同步纳入继承 | **保留并改文案** |
|
||||||
|
| D1-h | 医助本人能否看到「我的 N 诊接诊率」 | 在医助个人业绩页(`admin/src/views/stats/assistant-performance/index.vue`,后端 `AssistantPerformanceLogic`)加「我的 N 诊接诊率」卡片,复用 `RevisitRateLogic::overview` 按本人 assistant_id 过滤 / 仅管理层在复诊接诊率页查看 / 诊单页顶部展示 | **个人业绩页加卡片(P2)**。复诊接诊率页权限不按 DataScope 过滤(`RevisitRateLogic.php:460-475` 仅限二中心子树),不宜直接开放给医助 |
|
||||||
|
| D2-a | 「触达时间」展示字段 | 列内:第 N 诊/最新开药·天数·锚点/预计复诊;tooltip:首次触达日/在管天数/累计开药 / 全部进列 / 仅天数 + 诊次 | **列 + tooltip**(控制表格宽度) |
|
||||||
|
| D2-b | 用药起算锚点 | 签收 → 发货 → 下单 回退链并标注来源 / 只用下单日 / 只用签收日 | **回退链**,`anchor_chain` 可收窄 |
|
||||||
|
| D2-c | follow 页处置 | 仍在用则同步 RevisitNodeCell 并改组件名 / 下线合并 / 不处理 | **先确认线上菜单是否挂该页;无人用则下线** |
|
||||||
|
| D3-a | 「14/21/28/30」含义与缺天数处理 | 按实际天数算到期,四档仅作标签/筛选;缺天数回落处方 usage_days(剔除系统代开),仍缺显示「缺开药天数」不标红 / 只对四档提醒 / 缺天数兜底 7 / 四档为逾期阈值阶梯 | **第一项**;若实为阈值阶梯,仅改 `severe_overdue_days` 为数组 |
|
||||||
|
| D3-b | 预警/严重阈值 | 到期前 3 天橙、到期红、≥7 天深红(可配) / 无预警 / 其它数值 | **3/7 天**,走配置 |
|
||||||
|
| D3-c | 行高亮优先级 | 复诊超期 > 未确认 > 未挂号 / 未确认 > 复诊超期 > 未挂号 / 只标单元格 | **复诊超期优先**,`row_highlight_priority` 可切 |
|
||||||
|
| D3-d | 「已挂号」解除标红标准 | status∈{1,3} 且 appointment_date ≥ 锚点订单日即解除;4/2 不算 / 必须 status=3 / 1/3/4 均算 | **第一项** |
|
||||||
|
| D3-e | 提醒范围 | 限二中心子树且 assistant_id>0 / 全院有医助诊单 / 含未指派诊单 | **限二中心 + 有医助**,`limit_er_center` 可放开 |
|
||||||
|
| D4-a | 弹窗对象 | 仅持有医助本人;主管看 P2 列表 + 企微抄送 / 主管也弹所辖 / 其它部门纳入 | **仅本人** |
|
||||||
|
| D4-b | 延迟阶梯与形态 | 4h→2h→1h→30min,第 5 次起居中模态,工作时段 09:00~20:00 / 24h→12h→4h→1h / 固定 24h 不升级 | **第一项**,全部走配置 |
|
||||||
|
| D4-c | 换医助后延迟次数 | 清零并立即对新医助提醒 / 保留迁移 / 保留但不提醒 | **清零**(度量个人态度,新医助不背前任账),event=9 留痕 |
|
||||||
|
| D4-d | 「已联系」约束 | 每周期 ≤2 次、承诺日 ≤+14 天、第 3 次起按延迟计 / 不限制 / 已联系等同一次延迟 | **第一项** |
|
||||||
|
| D4-e | 医助能否自行关闭 | 可填原因关闭,主管视图标记 / 仅主管可关闭 / 可直接关闭 | **可填原因关闭并标记** |
|
||||||
|
| D4-f | 企微与「主管」定义 | P2 默认关闭,开启后 daily 推本人 + level≥3 推本人 + snooze≥5 每日抄送配置名单主管 / dept 表新增 leader 字段 / 不做企微 | **P2 配置方式起步**(`Dept.php` 无 leader 字段) |
|
||||||
|
| D5 | 同一诊单当月多条继承/既新指派又继承给同一医助 | 只算新指派;多次继承取最早 / 取最晚 / 各算一次 | **第一项**(与现有医助×诊单去重一致) |
|
||||||
|
| D6 | 同一秒指派与订单归属 | 沿用 `t<=ct` 归新持有人,k 用 `ct<t` 严格小于 / 订单先于指派 | **沿用** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 验收标准
|
||||||
|
|
||||||
|
### 需求 1
|
||||||
|
1. 示例(§1.1):9 月 slot 模式 B 的 `inherit_count=1`、`visit4_den` 含 D、`visit2_den/visit3_den` 不含、`visit4_count=1`、`assigned_total_count=1`;exclude 模式 B `assigned_count=0`、`visit4_rate=null`,且 `from==to` 行与历史回退行按现状计入,`assigned_count/visit{N}_count/visit{N}_rate` 与线上逐值一致。
|
||||||
|
2. 同一诊单当月既新指派又继承给同一医助 → 只计 NI;多次继承取最早。
|
||||||
|
3. 继承时无任何有效订单(k=0)→ 起算二诊,与新指派等价;继承与订单同一秒 → 订单归新持有人且 k 不含该单。
|
||||||
|
4. slots 覆盖 `max entry`:继承时第 6 诊的患者在只有二~四诊成交的月份也显示七诊列(分母 1、单数 0)。
|
||||||
|
5. 部门行 `visit{N}_den` ≤ 子行之和(去重);合计行同理;`visit_all_count = Σ visit{N}_count`。
|
||||||
|
6. 任一医助任一列:`visit{N}_den` = 被指派明细(`kind=all`)中「新指派行数 + entry≤N 的继承行数」;`visit{N}_count` = 该列下钻订单行数。
|
||||||
|
7. `php think tcm:auto-assign-pending --dry-run` 在默认配置下分档与上线前一致,并输出两套口径对照;日志行含 `visit2_rate_new_assign/rate_version`。
|
||||||
|
8. `tcm:backfill-inherit-visit-slot --verify` 漂移为 0;`AssignLogPanel` 显示「继承时第 k 诊」与下钻一致。
|
||||||
|
9. `RevisitRateInheritSlotTest`、`RevisitRateContractTest`、`AiMcpUnitTest` 通过;`generated.php` 重生成。
|
||||||
|
10. 当月仅有继承行的非二中心医助不出现在 overview/assignLines;仅有继承行的二中心医助出现且 `assigned_count=0、inherit_count≥1`。
|
||||||
|
|
||||||
|
### 需求 2
|
||||||
|
1. 诊单 3 笔有效订单(第 2 笔已退款)→ 「第 2 诊」,最新有效订单为第 3 笔;列表「第 N 诊」= 业务订单 tab `global_visit_seq` 最大值 = 接诊率该诊单最后一笔 slot。
|
||||||
|
2. `medication_days=NULL`、处方 `usage_days=14`(非系统代开)→ 天数 14、`days_src=2`;处方为系统代开 → 「缺开药天数」且无 due_date。
|
||||||
|
3. 有签收 → `anchor_src=1`,due = 签收日 + 天数;仅人工发货日志 → 2;都无 → 3 并标注「按下单日估算」。
|
||||||
|
4. 新建业务订单后刷新列表,节点即刻切到新周期(钩子/自愈),不等 cron。
|
||||||
|
|
||||||
|
### 需求 3
|
||||||
|
1. §1.3 示例全路径(剩 N 天 → 今日应挂号 → 超期 7 天深红 → 已约 → 过号 → 次日超期且 `missed_count=1`)行色与 chip 正确;患者在到期日前挂号并过号 → 当天红「过号」并进弹窗(不受 due_date 限制)。
|
||||||
|
2. 医生点「完成接诊」→ 绿「已面诊」;之后新订单 → 新周期灰「剩 N 天」。
|
||||||
|
3. 顶部「复诊到期 N」角标 = 筛选后列表 `count`(lists/count 双写一致);医助只看本人;组长按 DataScope;待分配宽搜下新筛选/排序被跳过。
|
||||||
|
4. 排序「复诊节点」升序 → 超期最多在前,NULL 在后;与「未服务天数」排序互斥。
|
||||||
|
5. 列表 P95 响应增幅 ≤150ms;`tcm:revisit-due-scan --dry-run --diagnosis-id=X` 输出与页面一致;命令抛 DB 异常时 `dev_crontab.status` 不被置 ERROR;`onAppointmentStatusCronDone` 抛异常时 `update_appointment_status` 任务同样不被置 ERROR。
|
||||||
|
6. 业务订单 tab 修改诊单偏移量后,刷新列表「第 N 诊」即时更新(钩子/自愈),与业务订单 tab `global_visit_seq` 一致。
|
||||||
|
|
||||||
|
### 需求 4
|
||||||
|
1. 医助 A 有 3 位超期患者 → 登录 2 秒内右下角一张汇总卡,按超期天数降序;主管 B 不弹;`toast_enabled=false` 时 `pending` 返回空。
|
||||||
|
2. 点「延迟」→ 项消失,`snooze_count=1`、`next_remind_at ≈ now+4h`(钳入 09:00~20:00,20:30 点则次日 09:00);再弹时标题变橙「已延迟 1 次」;第 3 次红色不可最小化;第 5 次居中模态仅三按钮。
|
||||||
|
3. 点「已联系」填 10-15 → 项消失、列表 chip「已联系·约 10-15」、`snooze_count` 不变;第 3 次已联系按延迟计;10-15 09:00 仍无挂号再弹。
|
||||||
|
4. 点「去挂号」→ 跳诊单页自动打开挂号弹窗;挂号成功后卡片与红行同时消失(`closed_state=2`)。
|
||||||
|
5. 两个标签页同时打开仅一处可见新项;一处延迟另一处同步消失;重复点击 `snooze` 不双计;另一浏览器挂号成功后,本浏览器下一轮轮询卡片消失。
|
||||||
|
6. 诊单改派给 C → A 不再弹、C 立即弹且 `snooze_count=0`,流水 event=9。
|
||||||
|
7. 非医助账号(无 `tcm.diagnosis/lists`)调 `pending` 返回权限不足;医助 B 对 A 的行调 `snooze` 失败。
|
||||||
|
8. `wecom_enabled=true` 时 level≥3 行每日 ≤2 条企微,未绑定账号记录不中断。
|
||||||
@@ -10,6 +10,7 @@ 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
|
class AssistantPerformanceController extends BaseAdminController
|
||||||
{
|
{
|
||||||
@@ -18,4 +19,13 @@ class AssistantPerformanceController extends BaseAdminController
|
|||||||
$params = $this->request->get();
|
$params = $this->request->get();
|
||||||
return $this->data(AssistantPerformanceLogic::overview($params, $this->adminId, $this->adminInfo));
|
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%',
|
'50_60' => '50%~60%',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 分档口径版本(auto_assign_log.rate_version)文案 */
|
||||||
|
private const RATE_VERSION_LABELS = [
|
||||||
|
'v1' => '旧口径·分母仅新指派',
|
||||||
|
'v2' => '新口径·每列独立分母',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @notes 设置搜索条件
|
* @notes 设置搜索条件
|
||||||
*/
|
*/
|
||||||
@@ -49,6 +55,12 @@ class AutoAssignLogLists extends BaseAdminDataLists implements ListsSearchInterf
|
|||||||
$row['action_text'] = (int) ($row['action'] ?? 0) === 1 ? '已分配' : '未分配';
|
$row['action_text'] = (int) ($row['action'] ?? 0) === 1 ? '已分配' : '未分配';
|
||||||
$row['tier_text'] = self::TIER_LABELS[(string) ($row['tier'] ?? '')] ?? '';
|
$row['tier_text'] = self::TIER_LABELS[(string) ($row['tier'] ?? '')] ?? '';
|
||||||
$row['visit2_rate'] = $row['visit2_rate'] !== null ? (float) $row['visit2_rate'] : null;
|
$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);
|
$rbTs = (int) ($row['rollback_time'] ?? 0);
|
||||||
$row['rollback_time_text'] = $rbTs > 0 ? date('Y-m-d H:i:s', $rbTs) : '';
|
$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\AdminDept;
|
||||||
use app\common\model\auth\AdminRole;
|
use app\common\model\auth\AdminRole;
|
||||||
use app\common\model\dict\DictData;
|
use app\common\model\dict\DictData;
|
||||||
|
use app\common\model\tcm\DiagnosisRevisitDue;
|
||||||
use app\common\lists\ListsSearchInterface;
|
use app\common\lists\ListsSearchInterface;
|
||||||
use app\common\lists\Traits\HasDataScopeFilter;
|
use app\common\lists\Traits\HasDataScopeFilter;
|
||||||
|
use app\common\service\tcm\RevisitDueService;
|
||||||
use think\facade\Db;
|
use think\facade\Db;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -143,6 +145,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
|||||||
|
|
||||||
$this->applyLatestAppointmentFilters($query, $pendingWideSearch);
|
$this->applyLatestAppointmentFilters($query, $pendingWideSearch);
|
||||||
$this->applyLatestAssignFilters($query, $pendingWideSearch);
|
$this->applyLatestAssignFilters($query, $pendingWideSearch);
|
||||||
|
$this->applyRevisitDueFilters($query, $pendingWideSearch);
|
||||||
|
|
||||||
$this->applyPendingAssignBusinessOrderMonthFilter($query);
|
$this->applyPendingAssignBusinessOrderMonthFilter($query);
|
||||||
|
|
||||||
@@ -460,6 +463,19 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
|||||||
}
|
}
|
||||||
unset($item);
|
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;
|
return $lists;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,6 +614,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
|
|||||||
|
|
||||||
$this->applyLatestAppointmentFilters($query, $pendingWideSearch);
|
$this->applyLatestAppointmentFilters($query, $pendingWideSearch);
|
||||||
$this->applyLatestAssignFilters($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') {
|
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
|
private function resolveListOrderRaw(bool $pendingWideSearch): string
|
||||||
{
|
{
|
||||||
$diagTbl = (new Diagnosis())->getTable();
|
$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'] ?? '')));
|
$sortUnserved = strtolower(trim((string) ($this->params['sort_unserved_days'] ?? '')));
|
||||||
if (in_array($sortUnserved, ['asc', 'desc'], true)) {
|
if (in_array($sortUnserved, ['asc', 'desc'], true)) {
|
||||||
$anchorExpr = $this->unservedAnchorExpr($diagTbl);
|
$anchorExpr = $this->unservedAnchorExpr($diagTbl);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ use app\common\model\tcm\PrescriptionOrderPayOrder;
|
|||||||
use app\common\model\ExpressTracking;
|
use app\common\model\ExpressTracking;
|
||||||
use app\common\service\gancao\GancaoScmRecipelService;
|
use app\common\service\gancao\GancaoScmRecipelService;
|
||||||
use app\common\service\pharmacy\EjPharmacyClient;
|
use app\common\service\pharmacy\EjPharmacyClient;
|
||||||
|
use app\common\service\tcm\RevisitVisitSlotService;
|
||||||
use think\facade\Config;
|
use think\facade\Config;
|
||||||
use think\facade\Db;
|
use think\facade\Db;
|
||||||
use think\db\Query;
|
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
|
* @param array<int, array<string, mixed>> $lists
|
||||||
*/
|
*/
|
||||||
@@ -1490,26 +1493,23 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$offsetRows = Diagnosis::whereIn('id', $diagIdList)
|
$offsetMap = RevisitVisitSlotService::fetchRevisitSlotStartOffsetMap($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));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var array<int, int> $seqByOrderId order_id => global seq within diagnosis */
|
/** @var array<int, int> $seqByOrderId order_id => 诊单内实单全局序号 */
|
||||||
$seqByOrderId = [];
|
$seqByOrderId = [];
|
||||||
foreach ($diagIdList as $did) {
|
foreach (array_chunk($diagIdList, 2000) as $chunk) {
|
||||||
$q = PrescriptionOrder::where('diagnosis_id', $did)->whereNull('delete_time');
|
// 已按 (diagnosis_id, create_time, id) 升序,同一诊单连续
|
||||||
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($q, '');
|
$orderRows = RevisitVisitSlotService::fetchOrderSeqRows($chunk, ['o.id', 'o.diagnosis_id']);
|
||||||
$orderIds = $q
|
$curDid = 0;
|
||||||
->order(['create_time' => 'asc', 'id' => 'asc'])
|
|
||||||
->column('id');
|
|
||||||
$seq = 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++;
|
$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);
|
$oid = (int) ($row['id'] ?? 0);
|
||||||
$did = (int) ($row['diagnosis_id'] ?? 0);
|
$did = (int) ($row['diagnosis_id'] ?? 0);
|
||||||
$seq = (int) ($seqByOrderId[$oid] ?? 0);
|
$seq = (int) ($seqByOrderId[$oid] ?? 0);
|
||||||
$offset = (int) ($offsetMap[$did] ?? 0);
|
$offset = RevisitVisitSlotService::resolveRevisitSlotStartOffset($did, $offsetMap);
|
||||||
$effectiveSlot = $seq > 0 ? $seq + $offset : 0;
|
$effectiveSlot = $seq > 0 ? $seq + $offset : 0;
|
||||||
$row['global_visit_seq'] = $effectiveSlot > 0 ? $effectiveSlot : null;
|
$row['global_visit_seq'] = $effectiveSlot > 0 ? $effectiveSlot : null;
|
||||||
$row['raw_visit_seq'] = $seq > 0 ? $seq : 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\adminapi\logic\doctor\DoctorNoteLogic;
|
||||||
use app\common\model\tcm\Prescription;
|
use app\common\model\tcm\Prescription;
|
||||||
use app\common\service\wechat\WechatWorkAppMessageService;
|
use app\common\service\wechat\WechatWorkAppMessageService;
|
||||||
|
use app\common\service\tcm\RevisitDueHook;
|
||||||
use app\common\model\dict\DictData;
|
use app\common\model\dict\DictData;
|
||||||
use think\facade\Db;
|
use think\facade\Db;
|
||||||
use think\facade\Log;
|
use think\facade\Log;
|
||||||
@@ -404,6 +405,9 @@ class AppointmentLogic extends BaseLogic
|
|||||||
$summary
|
$summary
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 事务提交后刷新复诊到期快照(新挂号 → 已约自动关闭;钩子内部吞异常)
|
||||||
|
RevisitDueHook::onAppointmentChanged((int) $params['patient_id'], $newId, 'create');
|
||||||
|
|
||||||
return ['id' => $newId];
|
return ['id' => $newId];
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Db::rollback();
|
Db::rollback();
|
||||||
@@ -467,6 +471,9 @@ class AppointmentLogic extends BaseLogic
|
|||||||
$summary
|
$summary
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 刷新复诊到期快照(取消挂号 → 已约行重开;钩子内部吞异常)
|
||||||
|
RevisitDueHook::onAppointmentChanged($diagId, $aptId, 'cancel');
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
self::setError($e->getMessage());
|
self::setError($e->getMessage());
|
||||||
@@ -618,6 +625,9 @@ class AppointmentLogic extends BaseLogic
|
|||||||
Log::warning('完成挂号后系统代开处方失败: ' . $e->getMessage());
|
Log::warning('完成挂号后系统代开处方失败: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 刷新复诊到期快照(面诊完成 → 已面诊关闭;钩子内部吞异常)
|
||||||
|
RevisitDueHook::onAppointmentChanged((int) $appointment->patient_id, (int) $appointment->id, 'complete');
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
self::setError($e->getMessage());
|
self::setError($e->getMessage());
|
||||||
@@ -1161,6 +1171,9 @@ class AppointmentLogic extends BaseLogic
|
|||||||
);
|
);
|
||||||
self::appendDiagnosisGuahaoLog($patientId, $id, $adminId, $adminInfo, 'admin_edit', $summary);
|
self::appendDiagnosisGuahaoLog($patientId, $id, $adminId, $adminInfo, 'admin_edit', $summary);
|
||||||
|
|
||||||
|
// 事务提交后刷新复诊到期快照(日期/状态变更影响已约/过号/已面诊判定;钩子内部吞异常)
|
||||||
|
RevisitDueHook::onAppointmentChanged($patientId, $id, 'admin_edit');
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Db::rollback();
|
Db::rollback();
|
||||||
|
|||||||
@@ -10,6 +10,22 @@ class AssistantPerformanceLogic
|
|||||||
/** 履约完成 */
|
/** 履约完成 */
|
||||||
private const FULFILLMENT_COMPLETED = 3;
|
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
|
public static function overview(array $params, int $adminId, array $adminInfo): array
|
||||||
{
|
{
|
||||||
// 时间范围解析
|
// 时间范围解析
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ declare(strict_types=1);
|
|||||||
namespace app\adminapi\logic\stats;
|
namespace app\adminapi\logic\stats;
|
||||||
|
|
||||||
use app\common\logic\BaseLogic;
|
use app\common\logic\BaseLogic;
|
||||||
|
use app\common\service\tcm\RevisitDueHook;
|
||||||
use think\facade\Db;
|
use think\facade\Db;
|
||||||
use think\facade\Log;
|
use think\facade\Log;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 待分配诊单自动指派日志:回退已自动分配的医助
|
* 待分配诊单自动指派日志:回退已自动分配的医助
|
||||||
|
*
|
||||||
|
* 回退写入的指派日志保持原逻辑 is_inherit=0(复诊接诊率原口径不变,用户要求);
|
||||||
|
* 复诊接诊率「新口径」下,回退行由 RevisitRateLogic 按 (diagnosis_id, rollback_time, rollback_admin_id) 匹配后按继承处理,不依赖本字段。
|
||||||
*/
|
*/
|
||||||
class AutoAssignLogLogic extends BaseLogic
|
class AutoAssignLogLogic extends BaseLogic
|
||||||
{
|
{
|
||||||
@@ -183,6 +187,9 @@ class AutoAssignLogLogic extends BaseLogic
|
|||||||
|
|
||||||
Db::commit();
|
Db::commit();
|
||||||
|
|
||||||
|
// 事务提交后刷新复诊到期快照(回退 = 换回原医助或释放 to=0;钩子内部吞异常)
|
||||||
|
RevisitDueHook::onAssistantChanged($diagnosisId, $prevAssistantId, 'auto_assign_rollback');
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Db::rollback();
|
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;
|
namespace app\adminapi\logic\stats;
|
||||||
|
|
||||||
use app\adminapi\logic\dept\DeptLogic;
|
use app\adminapi\logic\dept\DeptLogic;
|
||||||
|
use app\common\service\tcm\RevisitVisitSlotService;
|
||||||
use think\facade\Db;
|
use think\facade\Db;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 复诊接诊率统计(按月)
|
* 复诊接诊率统计(按月)—— v2:继承按继承时诊次计入每列独立分母 + 复诊合计列(方案 §1.1,定义 A 累计型)。
|
||||||
*
|
*
|
||||||
* 口径说明:
|
* 口径说明(内存计算见 RevisitRateCalculator,诊次口径见 RevisitVisitSlotService):
|
||||||
* - 当月被指派总数 = 当月内 `tcm_diagnosis_assign_log`(按 **指派操作时间 lg.create_time** 落月,to_assistant_id>0,
|
* - 新指派数 assigned_count = 当月内 `tcm_diagnosis_assign_log`(按 **指派操作时间 lg.create_time** 落月,to_assistant_id>0,
|
||||||
* **剔除勾选「继承」的指派 is_inherit=1**,诊单未删除)去重后的「医助 × 诊单」组合;
|
* **is_inherit=0**,诊单未删除)去重后的「医助 × 诊单」组合(NI);同一诊单当月多次指派给同一医助只计 1 次;
|
||||||
* 同一诊单当月被多次指派给同一医助只计 1 次;
|
* **再剔除**名下存在履约「拒收(9) / 退款(10)」业务订单(未软删)的诊单(R,不限月份)。
|
||||||
* **再剔除**名下存在履约「拒收(9) / 退款(10)」业务订单(未软删)的诊单。
|
* - 继承数 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 笔;
|
* - 第 N 次下单 = 诊单(患者)名下计入业绩的业务订单(剔除履约 4/9/10、软删)按 create_time 升序的**全局**序列中第 N 笔;
|
||||||
* 诊次**跨月累计不重置**:例如 5 月指派后旗下成交 4 单为二诊~五诊,6 月再成交即为六诊。
|
* 诊次**跨月累计不重置**;诊单可配置 `revisit_slot_start_offset`(默认 0:第 1 笔实单计为一诊;偏移 n 即在实单序号上叠加 n)。
|
||||||
* 诊单可配置 `revisit_slot_start_offset`(默认 0:第 1 笔实单计为一诊;设为 1 则第 1 笔实单计为二诊;设为 2 则计为三诊,即在实单序号上叠加偏移,5 笔实单+偏移 2 等价于计至七诊)。
|
* - 当月 N 诊单数 visit{N}_count = **当月内下单**且诊次为 N 的订单数,归属下单时点的**持有医助**——
|
||||||
* - 当月 N 诊单数 = **当月内下单**且全局序号为 N 的订单数,归属下单时点的**持有医助**——
|
* 按指派日志时间线取「订单时间之前(含同刻)最近一次指派」的 to_assistant_id(释放 to=0 即不再归属;继承会转移持有人)。
|
||||||
* 按指派日志时间线取「订单时间之前最近一次指派」的 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%。
|
||||||
* - 当月 N 诊接诊率 = 当月 N 诊单数 ÷ 当月被指派总数;往月指派当月成交会推高分子,故比率可能超过 100%;
|
* - 旧口径开关 inherit_mode=exclude|slot(默认 config project.revisit_rate.default_inherit_mode):
|
||||||
* 医助当月无新指派但旗下有成交时,被指派数为 0、比率显示为空。
|
* exclude 完全复现旧实现(IH=∅、不忽略 from==to、不豁免回退行,assigned_count/visit{N}_count/visit{N}_rate 与历史逐值一致);
|
||||||
* - 分档动态产出:N 从 2 起,至当月命中数据的最大序号(至少展示到四诊,上限 MAX_VISIT_SLOT 防御异常数据),
|
* 无论模式都输出 visit2_rate_new_assign = 二诊单数 ÷ 旧口径新指派数(供自动分配过渡期分档)。
|
||||||
* 返回 `slots` 列表供前端动态渲染「五诊」「六诊」… 列。
|
* - 分档动态产出:N 从 2 起,至 max(4, 当月分子最大诊次, 最大起算诊次)(上限 MAX_VISIT_SLOT),返回 `slots` 供前端动态渲染。
|
||||||
* - 部门归类:医助按其人事部门(admin_dept 最小 dept_id)归组;**仅统计「二中心」及其组织下级**(与 DeptLogic::getErCenterSubtreeDeptIdSet 一致);
|
* - 部门归类:医助按其人事部门(admin_dept 最小 dept_id)归组;**仅统计「二中心」及其组织下级**(与 DeptLogic::getErCenterSubtreeDeptIdSet 一致);
|
||||||
* 部门筛选下拉与未选部门时的默认范围均限定在该子树内,选定部门时含其组织下级。
|
* 部门筛选下拉与未选部门时的默认范围均限定在该子树内,选定部门时含其组织下级。
|
||||||
* - 部门行 / 合计行:被指派数按诊单去重(可能小于下级行相加);N 诊单数为下级行求和(每笔订单唯一归属一名医助)。
|
* - 部门行 / 合计行:NI 按诊单去重;IH = (∪IH(A)) \ NI_dept,entry 取部门内最小值;N 诊单数为下级行求和(每笔订单唯一归属一名医助)。
|
||||||
*/
|
*/
|
||||||
class RevisitRateLogic
|
class RevisitRateLogic
|
||||||
{
|
{
|
||||||
@@ -41,10 +44,10 @@ class RevisitRateLogic
|
|||||||
private const UNASSIGNED_DEPT_NAME = '未分配部门';
|
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{
|
* @return array{
|
||||||
* month:string,start_date:string,end_date:string,
|
* month:string,start_date:string,end_date:string,inherit_mode:string,
|
||||||
* slots:list<int>,
|
* slots:list<int>,
|
||||||
* total:array<string,int|float|null>,
|
* total:array<string,int|float|null>,
|
||||||
* rows:list<array<string,mixed>>
|
* rows:list<array<string,mixed>>
|
||||||
@@ -62,22 +65,19 @@ class RevisitRateLogic
|
|||||||
'month' => $month,
|
'month' => $month,
|
||||||
'start_date' => $ctx['startDate'],
|
'start_date' => $ctx['startDate'],
|
||||||
'end_date' => $ctx['endDate'],
|
'end_date' => $ctx['endDate'],
|
||||||
|
'inherit_mode' => $ctx['inheritMode'],
|
||||||
'slots' => $minSlots,
|
'slots' => $minSlots,
|
||||||
'total' => self::buildMetricPack(0, [], $minSlots),
|
'total' => self::buildMetricPack(0, [], [], $minSlots, 0),
|
||||||
'rows' => [],
|
'rows' => [],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分档:2 ~ max(4, 当月命中的最大诊次)
|
// 分档:2 ~ max(4, 当月命中的最大诊次, 最大起算诊次),上限 MAX_VISIT_SLOT
|
||||||
$maxHitSlot = 0;
|
$slots = RevisitRateCalculator::slotRange(
|
||||||
foreach ($ctx['slotOrdersByAssistant'] as $slotMap) {
|
$ctx['slotOrdersByAssistant'],
|
||||||
foreach ($slotMap as $slot => $_) {
|
(int) $ctx['maxEntry'],
|
||||||
if ((int) $slot > $maxHitSlot) {
|
self::MIN_VISIT_SLOT_CEILING
|
||||||
$maxHitSlot = (int) $slot;
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$slots = range(2, max(self::MIN_VISIT_SLOT_CEILING, $maxHitSlot));
|
|
||||||
|
|
||||||
$nameMap = Db::name('admin')
|
$nameMap = Db::name('admin')
|
||||||
->whereIn('id', array_keys($universe))
|
->whereIn('id', array_keys($universe))
|
||||||
@@ -86,22 +86,19 @@ class RevisitRateLogic
|
|||||||
// 医助行(按部门分组收集)
|
// 医助行(按部门分组收集)
|
||||||
/** @var array<int, list<array<string, mixed>>> $assistantRowsByDept */
|
/** @var array<int, list<array<string, mixed>>> $assistantRowsByDept */
|
||||||
$assistantRowsByDept = [];
|
$assistantRowsByDept = [];
|
||||||
/** @var array<int, array<int, true>> $deptDiagSet 部门 => 去重被指派诊单集合 */
|
/** @var array<int, list<int>> $deptAssistants 部门 => 医助 id 列表(部门行按诊单去重用) */
|
||||||
$deptDiagSet = [];
|
$deptAssistants = [];
|
||||||
/** @var array<int, array<int, int>> $deptSlotCounts 部门 => [slot => 订单数] */
|
/** @var array<int, array<int, int>> $deptSlotCounts 部门 => [slot => 订单数] */
|
||||||
$deptSlotCounts = [];
|
$deptSlotCounts = [];
|
||||||
/** @var array<int, true> $totalDiagSet */
|
|
||||||
$totalDiagSet = [];
|
|
||||||
/** @var array<int, int> $totalSlotCounts */
|
/** @var array<int, int> $totalSlotCounts */
|
||||||
$totalSlotCounts = [];
|
$totalSlotCounts = [];
|
||||||
|
|
||||||
foreach ($universe as $aid => $_) {
|
foreach ($universe as $aid => $_) {
|
||||||
$deptId = (int) ($ctx['assistantDept'][$aid] ?? 0);
|
$deptId = (int) ($ctx['assistantDept'][$aid] ?? 0);
|
||||||
|
$deptAssistants[$deptId][] = (int) $aid;
|
||||||
$diagSet = $ctx['diagsByAssistant'][$aid] ?? [];
|
$diagSet = $ctx['diagsByAssistant'][$aid] ?? [];
|
||||||
foreach ($diagSet as $did => $_d) {
|
$inheritEntries = $ctx['inheritEntryByAssistant'][$aid] ?? [];
|
||||||
$deptDiagSet[$deptId][$did] = true;
|
$excludeSet = $ctx['excludeDiagsByAssistant'][$aid] ?? [];
|
||||||
$totalDiagSet[$did] = true;
|
|
||||||
}
|
|
||||||
$slotCounts = [];
|
$slotCounts = [];
|
||||||
foreach ($ctx['slotOrdersByAssistant'][$aid] ?? [] as $slot => $orders) {
|
foreach ($ctx['slotOrdersByAssistant'][$aid] ?? [] as $slot => $orders) {
|
||||||
$cnt = \count($orders);
|
$cnt = \count($orders);
|
||||||
@@ -119,10 +116,10 @@ class RevisitRateLogic
|
|||||||
'assistant_name' => (string) ($nameMap[$aid] ?? ('#' . $aid)),
|
'assistant_name' => (string) ($nameMap[$aid] ?? ('#' . $aid)),
|
||||||
'dept_id' => $deptId,
|
'dept_id' => $deptId,
|
||||||
'dept_name' => $deptName,
|
'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 = [];
|
$rows = [];
|
||||||
foreach ($assistantRowsByDept as $deptId => $children) {
|
foreach ($assistantRowsByDept as $deptId => $children) {
|
||||||
usort($children, static function (array $a, array $b): int {
|
usort($children, static function (array $a, array $b): int {
|
||||||
@@ -135,6 +132,13 @@ class RevisitRateLogic
|
|||||||
$deptName = $deptId > 0
|
$deptName = $deptId > 0
|
||||||
? (string) ($ctx['deptNames'][$deptId] ?? ('#' . $deptId))
|
? (string) ($ctx['deptNames'][$deptId] ?? ('#' . $deptId))
|
||||||
: self::UNASSIGNED_DEPT_NAME;
|
: self::UNASSIGNED_DEPT_NAME;
|
||||||
|
$aids = $deptAssistants[$deptId] ?? [];
|
||||||
|
[$deptNiSet, $deptIhEntries] = RevisitRateCalculator::mergeInheritEntries(
|
||||||
|
$ctx['diagsByAssistant'],
|
||||||
|
$ctx['inheritEntryByAssistant'],
|
||||||
|
$aids
|
||||||
|
);
|
||||||
|
$deptExcludeSet = RevisitRateCalculator::unionDiagSets($ctx['excludeDiagsByAssistant'], $aids);
|
||||||
$rows[] = [
|
$rows[] = [
|
||||||
'row_key' => 'd' . $deptId,
|
'row_key' => 'd' . $deptId,
|
||||||
'is_dept' => 1,
|
'is_dept' => 1,
|
||||||
@@ -142,7 +146,13 @@ class RevisitRateLogic
|
|||||||
'dept_name' => $deptName,
|
'dept_name' => $deptName,
|
||||||
'assistant_count' => \count($children),
|
'assistant_count' => \count($children),
|
||||||
'children' => $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 {
|
usort($rows, static function (array $a, array $b): int {
|
||||||
if ($a['assigned_count'] !== $b['assigned_count']) {
|
if ($a['assigned_count'] !== $b['assigned_count']) {
|
||||||
@@ -152,62 +162,182 @@ class RevisitRateLogic
|
|||||||
return strcmp((string) $a['dept_name'], (string) $b['dept_name']);
|
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 [
|
return [
|
||||||
'month' => $month,
|
'month' => $month,
|
||||||
'start_date' => $ctx['startDate'],
|
'start_date' => $ctx['startDate'],
|
||||||
'end_date' => $ctx['endDate'],
|
'end_date' => $ctx['endDate'],
|
||||||
|
'inherit_mode' => $ctx['inheritMode'],
|
||||||
'slots' => $slots,
|
'slots' => $slots,
|
||||||
'total' => self::buildMetricPack(\count($totalDiagSet), $totalSlotCounts, $slots),
|
'total' => self::buildMetricPack(
|
||||||
|
\count($totalNiSet),
|
||||||
|
$totalIhEntries,
|
||||||
|
$totalSlotCounts,
|
||||||
|
$slots,
|
||||||
|
\count($totalExcludeSet)
|
||||||
|
),
|
||||||
'rows' => $rows,
|
'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=未分配部门)/ 都不传 = 当前部门筛选下合计。
|
* 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
|
public static function assignLines(array $params): array
|
||||||
{
|
{
|
||||||
$ctx = self::buildStatsCore($params);
|
$ctx = self::buildStatsCore($params);
|
||||||
$assistantSet = self::applyRowScope($ctx, $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 */
|
/** @var array<int, array{assistants: array<int, true>, assign_count: int, last_time: int}> $niByDiag */
|
||||||
$byDiag = [];
|
$niByDiag = [];
|
||||||
foreach ($ctx['pairsRaw'] as $p) {
|
foreach ($ctx['pairsRaw'] as $p) {
|
||||||
$aid = (int) $p['to_assistant_id'];
|
$aid = (int) $p['to_assistant_id'];
|
||||||
$did = (int) $p['diagnosis_id'];
|
$did = (int) $p['diagnosis_id'];
|
||||||
if (!isset($assistantSet[$aid])) {
|
if (!isset($assistantSet[$aid])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!isset($byDiag[$did])) {
|
if (!isset($niByDiag[$did])) {
|
||||||
$byDiag[$did] = ['assistants' => [], 'assign_count' => 0, 'last_time' => 0];
|
$niByDiag[$did] = ['assistants' => [], 'assign_count' => 0, 'last_time' => 0];
|
||||||
}
|
}
|
||||||
$byDiag[$did]['assistants'][$aid] = true;
|
$niByDiag[$did]['assistants'][$aid] = true;
|
||||||
$byDiag[$did]['assign_count']++;
|
$niByDiag[$did]['assign_count']++;
|
||||||
$byDiag[$did]['last_time'] = max($byDiag[$did]['last_time'], (int) $p['create_time']);
|
$niByDiag[$did]['last_time'] = max($niByDiag[$did]['last_time'], (int) $p['create_time']);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($byDiag === []) {
|
/** @var array<int, array{assistants: array<int, true>, assign_count: int, last_time: int, k: int, entry: int, is_rollback: int}> $ihByDiag */
|
||||||
return ['month' => $ctx['month'], 'count' => 0, 'rows' => []];
|
$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 = [];
|
$assistantIds = [];
|
||||||
foreach ($byDiag as $d) {
|
foreach ([$niByDiag, $ihByDiag] as $map) {
|
||||||
|
foreach ($map as $d) {
|
||||||
foreach ($d['assistants'] as $aid => $_) {
|
foreach ($d['assistants'] as $aid => $_) {
|
||||||
$assistantIds[$aid] = true;
|
$assistantIds[$aid] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
$nameMap = Db::name('admin')
|
$nameMap = Db::name('admin')
|
||||||
->whereIn('id', array_keys($assistantIds))
|
->whereIn('id', array_keys($assistantIds))
|
||||||
->column('name', 'id');
|
->column('name', 'id');
|
||||||
|
|
||||||
$rows = [];
|
$rows = [];
|
||||||
foreach ($byDiag as $did => $d) {
|
foreach ($niByDiag as $did => $d) {
|
||||||
$names = [];
|
$names = [];
|
||||||
foreach ($d['assistants'] as $aid => $_) {
|
foreach ($d['assistants'] as $aid => $_) {
|
||||||
$names[] = (string) ($nameMap[$aid] ?? ('#' . $aid));
|
$names[] = (string) ($nameMap[$aid] ?? ('#' . $aid));
|
||||||
@@ -220,24 +350,50 @@ class RevisitRateLogic
|
|||||||
'assign_count' => (int) $d['assign_count'],
|
'assign_count' => (int) $d['assign_count'],
|
||||||
'last_assign_time' => (int) $d['last_time'],
|
'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']) : '',
|
'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']);
|
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 同口径可对账。
|
* 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>>}
|
* @return array{month:string,slot:int,count:int,rows:list<array<string,mixed>>}
|
||||||
*/
|
*/
|
||||||
public static function visitOrderLines(array $params): array
|
public static function visitOrderLines(array $params): array
|
||||||
{
|
{
|
||||||
$slot = (int) ($params['slot'] ?? 0);
|
$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' => []];
|
return ['month' => self::normalizeMonth((string) ($params['month'] ?? '')), 'slot' => $slot, 'count' => 0, 'rows' => []];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,11 +402,18 @@ class RevisitRateLogic
|
|||||||
|
|
||||||
$orderRows = [];
|
$orderRows = [];
|
||||||
foreach ($assistantSet as $aid => $_) {
|
foreach ($assistantSet as $aid => $_) {
|
||||||
foreach ($ctx['slotOrdersByAssistant'][$aid][$slot] ?? [] as $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['holder_assistant_id'] = (int) $aid;
|
||||||
|
$r['visit_slot'] = (int) $s;
|
||||||
$orderRows[] = $r;
|
$orderRows[] = $r;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if ($orderRows === []) {
|
if ($orderRows === []) {
|
||||||
return ['month' => $ctx['month'], 'slot' => $slot, 'count' => 0, 'rows' => []];
|
return ['month' => $ctx['month'], 'slot' => $slot, 'count' => 0, 'rows' => []];
|
||||||
}
|
}
|
||||||
@@ -290,6 +453,7 @@ class RevisitRateLogic
|
|||||||
'creator_name' => $cid > 0 ? (string) ($adminNames[$cid] ?? ('#' . $cid)) : '—',
|
'creator_name' => $cid > 0 ? (string) ($adminNames[$cid] ?? ('#' . $cid)) : '—',
|
||||||
'assistant_id' => $hid,
|
'assistant_id' => $hid,
|
||||||
'assistant_name' => $hid > 0 ? (string) ($adminNames[$hid] ?? ('#' . $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']);
|
usort($rows, static fn (array $a, array $b): int => $b['create_time'] <=> $a['create_time']);
|
||||||
@@ -331,17 +495,22 @@ class RevisitRateLogic
|
|||||||
/**
|
/**
|
||||||
* 核心统计上下文:
|
* 核心统计上下文:
|
||||||
* 1. 全量指派日志(≤ 月末)构建持有时间线;
|
* 1. 全量指派日志(≤ 月末)构建持有时间线;
|
||||||
* 2. 分母:当月非继承指派的「医助 × 诊单」,再剔除名下存在拒收(9)/退款(10) 订单的诊单;
|
* 2. 分母:当月新指派 NI 与继承 IH 的「医助 × 诊单」(RevisitRateCalculator),再剔除名下存在拒收(9)/退款(10) 订单的诊单;
|
||||||
* 3. 分子:曾被指派诊单的当月订单,统计诊次 = 实单序号 + 诊单偏移(默认第 1 笔实单为一诊);
|
* 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{
|
* @return array{
|
||||||
* month:string,startDate:string,endDate:string,startTs:int,endTs:int,
|
* month:string,startDate:string,endDate:string,startTs:int,endTs:int,inheritMode:string,
|
||||||
* pairsRaw:list<array{diagnosis_id:int,to_assistant_id:int,create_time:int}>,
|
* 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>>,
|
* 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>>>>,
|
* slotOrdersByAssistant:array<int,array<int,list<array<string,mixed>>>>,
|
||||||
|
* maxEntry:int,
|
||||||
* assistantDept:array<int,int>,
|
* assistantDept:array<int,int>,
|
||||||
* deptNames:array<int,string>
|
* deptNames:array<int,string>
|
||||||
* }
|
* }
|
||||||
@@ -351,125 +520,90 @@ class RevisitRateLogic
|
|||||||
$month = self::normalizeMonth((string) ($params['month'] ?? ''));
|
$month = self::normalizeMonth((string) ($params['month'] ?? ''));
|
||||||
$startTs = (int) strtotime($month . '-01 00:00:00');
|
$startTs = (int) strtotime($month . '-01 00:00:00');
|
||||||
$endTs = (int) strtotime(date('Y-m-t', $startTs) . ' 23:59:59');
|
$endTs = (int) strtotime(date('Y-m-t', $startTs) . ' 23:59:59');
|
||||||
|
$inheritMode = self::resolveInheritMode($params);
|
||||||
|
|
||||||
// 全量指派日志(≤ 月末,诊单未删除):含释放(to=0)与继承行,用于持有时间线
|
// 全量指派日志(≤ 月末,诊单未删除):含释放(to=0)与继承行,用于持有时间线
|
||||||
|
// inherit_visit_slot 为写入时快照,仅供明细展示;统计一律以实时重算为准
|
||||||
$logRows = Db::name('tcm_diagnosis_assign_log')
|
$logRows = Db::name('tcm_diagnosis_assign_log')
|
||||||
->alias('lg')
|
->alias('lg')
|
||||||
->join('tcm_diagnosis dg', 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
|
->join('tcm_diagnosis dg', 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
|
||||||
->where('lg.create_time', '<=', $endTs)
|
->where('lg.create_time', '<=', $endTs)
|
||||||
->where('lg.diagnosis_id', '>', 0)
|
->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'])
|
->order(['lg.diagnosis_id' => 'asc', 'lg.create_time' => 'asc', 'lg.id' => 'asc'])
|
||||||
->select()
|
->select()
|
||||||
->toArray();
|
->toArray();
|
||||||
|
|
||||||
/** @var array<int, list<array{t:int,to:int}>> $timeline 诊单 => 持有变更时间线(升序) */
|
// 分母剔除集合:当月被指派(含继承)诊单中名下存在拒收(9)/退款(10) 业务订单者(与明细 assignLines 同口径)
|
||||||
$timeline = [];
|
$refundRejectDiagSet = self::fetchRefundOrRejectDiagnosisSet(
|
||||||
$pairsRaw = [];
|
RevisitRateCalculator::monthAssignedDiagIds($logRows, $startTs, $endTs)
|
||||||
/** @var array<int, array<int, true>> $diagsByAssistant 分母:医助 => 诊单集合 */
|
);
|
||||||
$diagsByAssistant = [];
|
// 历史「回退自动分配」行(slot 模式按继承处理)
|
||||||
/** @var array<int, true> $candidateDiagSet 曾被指派(to>0,含继承)的诊单 */
|
$rollbackKeySet = $inheritMode === RevisitRateCalculator::MODE_SLOT
|
||||||
$candidateDiagSet = [];
|
? self::fetchRollbackKeySet($startTs, $endTs)
|
||||||
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 同口径)
|
// 分子:曾被指派诊单的订单序列(分块生成器,同一诊单连续),统计诊次 = 实单全局序号 + 诊单偏移
|
||||||
$assignedDiagIds = [];
|
$candidateDiagIds = RevisitRateCalculator::candidateDiagIds($logRows);
|
||||||
foreach ($diagsByAssistant as $diagSet) {
|
$offsetMap = self::fetchRevisitSlotStartOffsetMap($candidateDiagIds);
|
||||||
foreach ($diagSet as $did => $_) {
|
$orderRows = self::iterateOrderSeqRows(
|
||||||
$assignedDiagIds[(int) $did] = true;
|
$candidateDiagIds,
|
||||||
}
|
|
||||||
}
|
|
||||||
$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']])
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分子:曾被指派诊单的当月订单,统计诊次 = 实单全局序号 + 诊单偏移(默认偏移 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']
|
['o.id', 'o.order_no', 'o.diagnosis_id', 'o.create_time', 'o.amount', 'o.creator_id']
|
||||||
);
|
);
|
||||||
$curDid = 0;
|
|
||||||
$seq = 0;
|
$calc = RevisitRateCalculator::compute(
|
||||||
$ptr = 0;
|
$logRows,
|
||||||
$holder = 0;
|
$orderRows,
|
||||||
$offset = 0;
|
$offsetMap,
|
||||||
foreach ($orderRows as $r) {
|
$refundRejectDiagSet,
|
||||||
$did = (int) ($r['diagnosis_id'] ?? 0);
|
$rollbackKeySet,
|
||||||
if ($did <= 0) {
|
$startTs,
|
||||||
continue;
|
$endTs,
|
||||||
}
|
$inheritMode,
|
||||||
if ($did !== $curDid) {
|
self::inheritMinNextSlot()
|
||||||
$curDid = $did;
|
);
|
||||||
$seq = 0;
|
unset($logRows);
|
||||||
$ptr = 0;
|
$pairsRaw = $calc['pairsRaw'];
|
||||||
$holder = 0;
|
$diagsByAssistant = $calc['diagsByAssistant'];
|
||||||
$offset = self::resolveRevisitSlotStartOffset($did, $offsetMap);
|
$excludeDiagsByAssistant = $calc['excludeDiagsByAssistant'];
|
||||||
}
|
$inheritEntryByAssistant = $calc['inheritEntryByAssistant'];
|
||||||
$seq++;
|
$inheritPairs = $calc['inheritPairs'];
|
||||||
$effectiveSlot = $seq + $offset;
|
$slotOrdersByAssistant = $calc['slotOrdersByAssistant'];
|
||||||
$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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 医助归属部门 + 部门筛选(默认仅二中心子树;选定部门时再收窄,含组织下级)
|
// 医助归属部门 + 部门筛选(默认仅二中心子树;选定部门时再收窄,含组织下级)
|
||||||
$universeIds = array_keys($diagsByAssistant + $slotOrdersByAssistant);
|
$universeIds = array_keys(
|
||||||
|
$diagsByAssistant + $slotOrdersByAssistant + $inheritEntryByAssistant + $excludeDiagsByAssistant
|
||||||
|
);
|
||||||
[$assistantDept, $deptNames] = self::buildAssistantDeptIndex($universeIds);
|
[$assistantDept, $deptNames] = self::buildAssistantDeptIndex($universeIds);
|
||||||
$subtreeSet = self::resolveDeptFilterSet($params['dept_ids'] ?? null);
|
$subtreeSet = self::resolveDeptFilterSet($params['dept_ids'] ?? null);
|
||||||
if ($subtreeSet === []) {
|
if ($subtreeSet === []) {
|
||||||
// 无二中心部门时整表为空,避免误展示其它中心数据
|
// 无二中心部门时整表为空,避免误展示其它中心数据
|
||||||
$diagsByAssistant = [];
|
$diagsByAssistant = [];
|
||||||
|
$excludeDiagsByAssistant = [];
|
||||||
|
$inheritEntryByAssistant = [];
|
||||||
$slotOrdersByAssistant = [];
|
$slotOrdersByAssistant = [];
|
||||||
} else {
|
} else {
|
||||||
foreach ($universeIds as $aid) {
|
foreach ($universeIds as $aid) {
|
||||||
$deptId = (int) ($assistantDept[$aid] ?? 0);
|
$deptId = (int) ($assistantDept[$aid] ?? 0);
|
||||||
if ($deptId <= 0 || !isset($subtreeSet[$deptId])) {
|
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),
|
'endDate' => date('Y-m-d', $endTs),
|
||||||
'startTs' => $startTs,
|
'startTs' => $startTs,
|
||||||
'endTs' => $endTs,
|
'endTs' => $endTs,
|
||||||
|
'inheritMode' => $inheritMode,
|
||||||
'pairsRaw' => $pairsRaw,
|
'pairsRaw' => $pairsRaw,
|
||||||
'diagsByAssistant' => $diagsByAssistant,
|
'diagsByAssistant' => $diagsByAssistant,
|
||||||
|
'excludeDiagsByAssistant' => $excludeDiagsByAssistant,
|
||||||
|
'inheritEntryByAssistant' => $inheritEntryByAssistant,
|
||||||
|
'inheritPairs' => $inheritPairs,
|
||||||
'slotOrdersByAssistant' => $slotOrdersByAssistant,
|
'slotOrdersByAssistant' => $slotOrdersByAssistant,
|
||||||
|
'maxEntry' => $maxEntry,
|
||||||
'assistantDept' => $assistantDept,
|
'assistantDept' => $assistantDept,
|
||||||
'deptNames' => $deptNames,
|
'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>
|
* @return array<int, true>
|
||||||
*/
|
*/
|
||||||
private static function assistantUniverse(array $ctx): array
|
private static function assistantUniverse(array $ctx): array
|
||||||
{
|
{
|
||||||
$set = [];
|
$set = [];
|
||||||
foreach (array_keys($ctx['diagsByAssistant']) as $aid) {
|
foreach (['diagsByAssistant', 'excludeDiagsByAssistant', 'inheritEntryByAssistant', 'slotOrdersByAssistant'] as $key) {
|
||||||
|
foreach (array_keys($ctx[$key] ?? []) as $aid) {
|
||||||
$set[(int) $aid] = true;
|
$set[(int) $aid] = true;
|
||||||
}
|
}
|
||||||
foreach (array_keys($ctx['slotOrdersByAssistant']) as $aid) {
|
|
||||||
$set[(int) $aid] = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $set;
|
return $set;
|
||||||
@@ -715,17 +915,8 @@ class RevisitRateLogic
|
|||||||
*/
|
*/
|
||||||
private static function fetchOrderSeqRows(array $diagIds, array $fields): array
|
private static function fetchOrderSeqRows(array $diagIds, array $fields): array
|
||||||
{
|
{
|
||||||
$q = Db::name('tcm_prescription_order')
|
// 实现已搬至 RevisitVisitSlotService(诊次口径单点维护),此处保留委托以减少 diff
|
||||||
->alias('o')
|
return RevisitVisitSlotService::fetchOrderSeqRows($diagIds, $fields);
|
||||||
->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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -735,21 +926,8 @@ class RevisitRateLogic
|
|||||||
*/
|
*/
|
||||||
private static function fetchRevisitSlotStartOffsetMap(array $diagIds): array
|
private static function fetchRevisitSlotStartOffsetMap(array $diagIds): array
|
||||||
{
|
{
|
||||||
if ($diagIds === []) {
|
// 实现已搬至 RevisitVisitSlotService(诊次口径单点维护),此处保留委托以减少 diff
|
||||||
return [];
|
return RevisitVisitSlotService::fetchRevisitSlotStartOffsetMap($diagIds);
|
||||||
}
|
|
||||||
$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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -759,15 +937,8 @@ class RevisitRateLogic
|
|||||||
*/
|
*/
|
||||||
private static function resolveRevisitSlotStartOffset(int $diagId, array $offsetMap): int
|
private static function resolveRevisitSlotStartOffset(int $diagId, array $offsetMap): int
|
||||||
{
|
{
|
||||||
$offset = (int) ($offsetMap[$diagId] ?? 0);
|
// 实现已搬至 RevisitVisitSlotService(诊次口径单点维护),此处保留委托以减少 diff
|
||||||
if ($offset < 0) {
|
return RevisitVisitSlotService::resolveRevisitSlotStartOffset($diagId, $offsetMap);
|
||||||
$offset = 0;
|
|
||||||
}
|
|
||||||
if ($offset > 20) {
|
|
||||||
$offset = 20;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $offset;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -797,23 +968,24 @@ class RevisitRateLogic
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* 指标包: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 array<int, int> $slotCounts [slot => n]
|
||||||
* @param list<int> $slots 需输出的分档列表(保证各行键齐全)
|
* @param list<int> $slots 需输出的分档列表(保证各行键齐全)
|
||||||
|
* @param int $newAssignedExclude 旧口径(:380 现状)新指派数,供 visit2_rate_new_assign
|
||||||
*
|
*
|
||||||
* @return array<string, int|float|null>
|
* @return array<string, int|float|null>
|
||||||
*/
|
*/
|
||||||
private static function buildMetricPack(int $assigned, array $slotCounts, array $slots): array
|
private static function buildMetricPack(
|
||||||
{
|
int $newAssigned,
|
||||||
$pack = ['assigned_count' => $assigned];
|
array $inheritEntryByDiag,
|
||||||
foreach ($slots as $slot) {
|
array $slotCounts,
|
||||||
$cnt = (int) ($slotCounts[$slot] ?? 0);
|
array $slots,
|
||||||
$pack['visit' . $slot . '_count'] = $cnt;
|
int $newAssignedExclude
|
||||||
$pack['visit' . $slot . '_rate'] = $assigned > 0
|
): array {
|
||||||
? round($cnt / $assigned * 100, 2)
|
return RevisitRateCalculator::metricPack($newAssigned, $inheritEntryByDiag, $slotCounts, $slots, $newAssignedExclude);
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $pack;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 归一化月份参数为 YYYY-MM,非法时回退当前月 */
|
/** 归一化月份参数为 YYYY-MM,非法时回退当前月 */
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ use think\facade\Db;
|
|||||||
* - 进线数据 = 区间内 add_external_contact 事件,按接待员工(user_id) 归属部门
|
* - 进线数据 = 区间内 add_external_contact 事件,按接待员工(user_id) 归属部门
|
||||||
* · 与 qywx.customer/todayArrival、客户列表同源事件表;选定渠道时客户须带对应企微标签(contact_tag);**二中心及其组织下级在选定渠道下进线计 0**
|
* · 与 qywx.customer/todayArrival、客户列表同源事件表;选定渠道时客户须带对应企微标签(contact_tag);**二中心及其组织下级在选定渠道下进线计 0**
|
||||||
* - 被指派数 = 区间内 `tcm_diagnosis_assign_log` 且 **to_assistant_id>0**,**剔除勾选「继承」的指派 is_inherit=1**,按**指派操作时间 lg.create_time** 落区间,
|
* - 被指派数 = 区间内 `tcm_diagnosis_assign_log` 且 **to_assistant_id>0**,**剔除勾选「继承」的指派 is_inherit=1**,按**指派操作时间 lg.create_time** 落区间,
|
||||||
* 「医助 × 诊单」去重、剔除已删诊单 —— 与「复诊接诊率」页面「当月被指派总数」完全同口径(见 RevisitRateLogic)
|
* 「医助 × 诊单」去重、剔除已删诊单 —— 与「复诊接诊率」页面的『新指派数』(assigned_count) 同口径(见 RevisitRateLogic;
|
||||||
|
* 接诊率 v2 另有继承分母 inherit_count,本看板不纳入——方案 D1-g)
|
||||||
* · **二中心及其组织下级在选定渠道下计 0**(与进线一致)
|
* · **二中心及其组织下级在选定渠道下计 0**(与进线一致)
|
||||||
* - 复诊统计 = 仅**名称含「二中心」的部门及其组织架构下级**:区间内业务单按患者 create_time 排序,第 1 笔不计,第 2 笔起分列「复诊2」「复诊3」…;口径同业绩单条件;按**订单创建人**所在部门在组织树内 rollup(含下级),与排行榜复诊一致
|
* - 复诊统计 = 仅**名称含「二中心」的部门及其组织架构下级**:区间内业务单按患者 create_time 排序,第 1 笔不计,第 2 笔起分列「复诊2」「复诊3」…;口径同业绩单条件;按**订单创建人**所在部门在组织树内 rollup(含下级),与排行榜复诊一致
|
||||||
* - 已完成挂号 = status=3 挂号条数(appointment_date),列「已完成挂号」;部门归属:有效医助→接诊医生→展示行映射。
|
* - 已完成挂号 = status=3 挂号条数(appointment_date),列「已完成挂号」;部门归属:有效医助→接诊医生→展示行映射。
|
||||||
@@ -856,9 +857,9 @@ class YejiStatsLogic
|
|||||||
|
|
||||||
$rangeNote = $channelFilterActive
|
$rangeNote = $channelFilterActive
|
||||||
? ('排行诊金 = 选定渠道「' . $channelName . '」业绩中「订单创建人 = 该医助」的部分;与部门表该渠道业绩列同口径(按订单创建人归属)。被指派数与部门表同口径。接诊率 = 诊金 ÷ 进线(元/进线)。'
|
? ('排行诊金 = 选定渠道「' . $channelName . '」业绩中「订单创建人 = 该医助」的部分;与部门表该渠道业绩列同口径(按订单创建人归属)。被指派数与部门表同口径。接诊率 = 诊金 ÷ 进线(元/进线)。'
|
||||||
. '「二中心」及其组织下级医助在该渠道下诊金、进线与被指派计 0。'
|
. '「二中心」及其组织下级医助在该渠道下诊金、进线与被指派计 0。被指派数与复诊接诊率『新指派数』同口径(不含继承)。'
|
||||||
. ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。')
|
. ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。')
|
||||||
: '排行诊金 = 业务订单中「订单创建人 = 该医助」的金额合计;与部门表「合计业绩」同口径(按订单创建人归属)。被指派数为指派至该医助的次数(剔除勾选「继承」的指派)。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。'
|
: '排行诊金 = 业务订单中「订单创建人 = 该医助」的金额合计;与部门表「合计业绩」同口径(按订单创建人归属)。被指派数为指派至该医助的次数(剔除勾选「继承」的指派,与复诊接诊率『新指派数』同口径)。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。'
|
||||||
. ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。';
|
. ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。';
|
||||||
|
|
||||||
$leaderboards = [];
|
$leaderboards = [];
|
||||||
@@ -1780,7 +1781,7 @@ class YejiStatsLogic
|
|||||||
/**
|
/**
|
||||||
* 被指派数:统计 `tcm_diagnosis_assign_log`(to_assistant_id>0,**剔除勾选「继承」的指派 is_inherit=1**),
|
* 被指派数:统计 `tcm_diagnosis_assign_log`(to_assistant_id>0,**剔除勾选「继承」的指派 is_inherit=1**),
|
||||||
* 按**指派操作时间 lg.create_time** 落入区间,「医助 × 诊单」去重(COUNT DISTINCT diagnosis_id)、剔除已删诊单,
|
* 按**指派操作时间 lg.create_time** 落入区间,「医助 × 诊单」去重(COUNT DISTINCT diagnosis_id)、剔除已删诊单,
|
||||||
* 与「复诊接诊率」页面同口径。选定字典渠道时按挂号 channels EXISTS 收窄;tag 退化口径按日志字段过滤。
|
* 与「复诊接诊率」页面『新指派数』(assigned_count) 同口径(不含接诊率 v2 的继承分母)。选定字典渠道时按挂号 channels EXISTS 收窄;tag 退化口径按日志字段过滤。
|
||||||
*
|
*
|
||||||
* @param array<int, int> $adminToPrimary
|
* @param array<int, int> $adminToPrimary
|
||||||
* @param int[]|null $tagDiagIds
|
* @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>
|
* @return array<int, int>
|
||||||
*/
|
*/
|
||||||
@@ -2617,7 +2618,7 @@ class YejiStatsLogic
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 被指派明细:与看板「被指派数」同口径(区间内非继承指派,医助×诊单去重)。
|
* 被指派明细:与看板「被指派数」同口径(区间内非继承指派,医助×诊单去重;即复诊接诊率『新指派数』口径)。
|
||||||
*
|
*
|
||||||
* @param array{
|
* @param array{
|
||||||
* start_date?:string,end_date?:string,dept_ids?:int[]|string,channel_code?:string,tag_id?:string,
|
* 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,剔除勾选「继承」),'
|
$note = '与看板「被指派数」同口径:区间内 `tcm_diagnosis_assign_log` 成功指派(to_assistant_id>0,剔除勾选「继承」),'
|
||||||
. '按指派操作时间落区间,「医助 × 诊单」去重、剔除已删诊单。';
|
. '按指派操作时间落区间,「医助 × 诊单」去重、剔除已删诊单;与复诊接诊率『新指派数』(assigned_count) 同口径,不含继承。';
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'start_date' => (string) $c['startDate'],
|
'start_date' => (string) $c['startDate'],
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ use app\common\service\AppointmentCallPolicy;
|
|||||||
use app\common\service\FileService;
|
use app\common\service\FileService;
|
||||||
use app\common\service\DataScope\DataScopeService;
|
use app\common\service\DataScope\DataScopeService;
|
||||||
use app\common\service\storage\Driver as StorageDriver;
|
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;
|
use think\facade\Db;
|
||||||
/**
|
/**
|
||||||
* 中医辨房病因诊单逻辑
|
* 中医辨房病因诊单逻辑
|
||||||
@@ -260,6 +263,10 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
Diagnosis::destroy($params['id']);
|
Diagnosis::destroy($params['id']);
|
||||||
|
// 诊单删除后把复诊到期快照推进为失效(Hook 内部吞异常,不影响删除结果)
|
||||||
|
foreach ((array) $params['id'] as $deletedId) {
|
||||||
|
RevisitDueHook::onDiagnosisChanged((int) $deletedId, 'delete');
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
self::setError($e->getMessage());
|
self::setError($e->getMessage());
|
||||||
@@ -707,6 +714,15 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
if ($fromAssistantId <= 0 && $toAssistantId > 0) {
|
if ($fromAssistantId <= 0 && $toAssistantId > 0) {
|
||||||
$fromAssistantId = self::inferPreviousAssistantIdFromLatestAssignLog($id);
|
$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')
|
Db::name('tcm_diagnosis')
|
||||||
->where('id', $id)
|
->where('id', $id)
|
||||||
@@ -730,6 +746,8 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
'related_po_creator_id' => $relatedPoCreatorId,
|
'related_po_creator_id' => $relatedPoCreatorId,
|
||||||
'related_po_create_time' => $relatedPoCreateTime,
|
'related_po_create_time' => $relatedPoCreateTime,
|
||||||
'is_inherit' => $isInherit,
|
'is_inherit' => $isInherit,
|
||||||
|
'inherit_visit_slot' => $inheritVisitSlot,
|
||||||
|
'inherit_visit_slot_calc_time' => $inheritVisitSlotCalcTime,
|
||||||
'create_time' => $now,
|
'create_time' => $now,
|
||||||
]);
|
]);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
@@ -741,6 +759,9 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
|
|
||||||
Db::commit();
|
Db::commit();
|
||||||
|
|
||||||
|
// 事务提交后刷新复诊到期快照(换医助重置延迟字段;钩子内部吞异常,不影响指派结果)
|
||||||
|
RevisitDueHook::onAssistantChanged($id, $toAssistantId, 'assign');
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Db::rollback();
|
Db::rollback();
|
||||||
@@ -785,6 +806,9 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
try {
|
try {
|
||||||
$diagnosis->save(['revisit_slot_start_offset' => $offset]);
|
$diagnosis->save(['revisit_slot_start_offset' => $offset]);
|
||||||
|
|
||||||
|
// 起始偏移影响 current_slot/next_slot:刷新复诊到期快照(钩子内部吞异常)
|
||||||
|
RevisitDueHook::onDiagnosisChanged($diagnosisId, 'offset');
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
self::setError($e->getMessage());
|
self::setError($e->getMessage());
|
||||||
@@ -849,6 +873,16 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
$inherit = (int) ($r['is_inherit'] ?? 0);
|
$inherit = (int) ($r['is_inherit'] ?? 0);
|
||||||
$r['is_inherit'] = $inherit;
|
$r['is_inherit'] = $inherit;
|
||||||
$r['is_inherit_text'] = $inherit === 1 ? '是' : '否';
|
$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);
|
unset($r);
|
||||||
|
|
||||||
@@ -4281,6 +4315,9 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
$unservedDays = $maxRecordTs > 0 ? max(0, (int) floor((time() - $maxRecordTs) / 86400)) : null;
|
$unservedDays = $maxRecordTs > 0 ? max(0, (int) floor((time() - $maxRecordTs) / 86400)) : null;
|
||||||
$lastBloodRecordAt = $maxRecordTs > 0 ? date('Y-m-d', $maxRecordTs) : 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 [
|
return [
|
||||||
'appointment' => $appointment,
|
'appointment' => $appointment,
|
||||||
'diagnosis' => $diagnosis,
|
'diagnosis' => $diagnosis,
|
||||||
@@ -4288,6 +4325,7 @@ class DiagnosisLogic extends BaseLogic
|
|||||||
'tracking_notes' => $trackingNotes,
|
'tracking_notes' => $trackingNotes,
|
||||||
'unserved_days' => $unservedDays,
|
'unserved_days' => $unservedDays,
|
||||||
'last_blood_record_at' => $lastBloodRecordAt,
|
'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\PharmacySubmissionClaimService;
|
||||||
use app\common\service\pharmacy\PharmacySubmissionClaimWorkflow;
|
use app\common\service\pharmacy\PharmacySubmissionClaimWorkflow;
|
||||||
use app\common\service\pharmacy\PharmacySupplyMode;
|
use app\common\service\pharmacy\PharmacySupplyMode;
|
||||||
|
use app\common\service\tcm\RevisitDueHook;
|
||||||
use think\db\Query;
|
use think\db\Query;
|
||||||
use think\facade\Config;
|
use think\facade\Config;
|
||||||
use think\facade\Db;
|
use think\facade\Db;
|
||||||
@@ -979,7 +980,7 @@ class PrescriptionOrderLogic
|
|||||||
{
|
{
|
||||||
self::$error = '';
|
self::$error = '';
|
||||||
try {
|
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))
|
$rx = Prescription::where('id', (int) ($params['prescription_id'] ?? 0))
|
||||||
@@ -998,6 +999,13 @@ class PrescriptionOrderLogic
|
|||||||
self::$error = $e->getMessage();
|
self::$error = $e->getMessage();
|
||||||
return false;
|
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)
|
private static function createLocked(array $params, int $adminId, array $adminInfo)
|
||||||
@@ -1165,7 +1173,7 @@ class PrescriptionOrderLogic
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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'])
|
$order = PrescriptionOrder::where('id', (int) $params['id'])
|
||||||
->whereNull('delete_time')->lock(true)->find();
|
->whereNull('delete_time')->lock(true)->find();
|
||||||
if (!$order) {
|
if (!$order) {
|
||||||
@@ -1192,6 +1200,13 @@ class PrescriptionOrderLogic
|
|||||||
self::$error = $e->getMessage();
|
self::$error = $e->getMessage();
|
||||||
return false;
|
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
|
public static function detail(int $id, int $adminId, array $adminInfo): ?array
|
||||||
@@ -2251,7 +2266,7 @@ class PrescriptionOrderLogic
|
|||||||
{
|
{
|
||||||
self::setError('');
|
self::setError('');
|
||||||
try {
|
try {
|
||||||
return LockedPharmacySnapshotMutation::execute(
|
$withdrawn = LockedPharmacySnapshotMutation::execute(
|
||||||
$id,
|
$id,
|
||||||
static fn () => self::withdrawLocked($id, $adminId, $adminInfo)
|
static fn () => self::withdrawLocked($id, $adminId, $adminInfo)
|
||||||
);
|
);
|
||||||
@@ -2261,6 +2276,13 @@ class PrescriptionOrderLogic
|
|||||||
}
|
}
|
||||||
return false;
|
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)
|
private static function withdrawLocked(int $id, int $adminId, array $adminInfo)
|
||||||
@@ -3303,6 +3325,9 @@ class PrescriptionOrderLogic
|
|||||||
$logLine
|
$logLine
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 刷新复诊到期快照(9/10 退出有效口径,终态可能释放医助;钩子内部吞异常)
|
||||||
|
RevisitDueHook::onOrderChanged((int) $order->diagnosis_id, $id, 'complete:' . $targetFulfillmentStatus);
|
||||||
|
|
||||||
$out = $order->toArray();
|
$out = $order->toArray();
|
||||||
self::maskInternalCostIfNeeded($out, $adminInfo);
|
self::maskInternalCostIfNeeded($out, $adminInfo);
|
||||||
self::maskRemarkExtraIfNeeded($out, $adminInfo);
|
self::maskRemarkExtraIfNeeded($out, $adminInfo);
|
||||||
@@ -3478,6 +3503,9 @@ class PrescriptionOrderLogic
|
|||||||
|
|
||||||
self::writeLog($id, $adminId, $adminInfo, 'refund', $logLine);
|
self::writeLog($id, $adminId, $adminInfo, 'refund', $logLine);
|
||||||
|
|
||||||
|
// 刷新复诊到期快照(全额退款置 10 退出有效口径并可能释放医助;钩子内部吞异常)
|
||||||
|
RevisitDueHook::onOrderChanged((int) $order->diagnosis_id, $id, 'refund');
|
||||||
|
|
||||||
$out = $order->toArray();
|
$out = $order->toArray();
|
||||||
$out['this_refund_amount'] = $refundAmount;
|
$out['this_refund_amount'] = $refundAmount;
|
||||||
$out['refund_full'] = $isFullRefund;
|
$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\PrescriptionOrderLog;
|
||||||
use app\common\model\tcm\PrescriptionOrderPayOrder;
|
use app\common\model\tcm\PrescriptionOrderPayOrder;
|
||||||
use app\common\service\ExpressTrackingService;
|
use app\common\service\ExpressTrackingService;
|
||||||
|
use app\common\service\tcm\RevisitDueHook;
|
||||||
use think\facade\Config;
|
use think\facade\Config;
|
||||||
use think\facade\Log;
|
use think\facade\Log;
|
||||||
use think\Response;
|
use think\Response;
|
||||||
@@ -303,6 +304,8 @@ class GancaoCallbackController extends BaseApiController
|
|||||||
private function updateOrderStatus(PrescriptionOrder $order, int $state, array $ext): void
|
private function updateOrderStatus(PrescriptionOrder $order, int $state, array $ext): void
|
||||||
{
|
{
|
||||||
$order->gancao_order_state = $state;
|
$order->gancao_order_state = $state;
|
||||||
|
// 甘草撤单/驳回把履约状态置 4(已取消):订单退出有效口径,需在落库成功后刷新复诊到期快照
|
||||||
|
$revisitDueOrderCancelled = false;
|
||||||
|
|
||||||
switch ($state) {
|
switch ($state) {
|
||||||
case 10:
|
case 10:
|
||||||
@@ -331,6 +334,7 @@ class GancaoCallbackController extends BaseApiController
|
|||||||
case 91:
|
case 91:
|
||||||
if ((int) $order->fulfillment_status !== 3) {
|
if ((int) $order->fulfillment_status !== 3) {
|
||||||
$order->fulfillment_status = 4; // 已取消
|
$order->fulfillment_status = 4; // 已取消
|
||||||
|
$revisitDueOrderCancelled = true;
|
||||||
}
|
}
|
||||||
$order->gancao_remark = '甘草主动撤单(已退费)';
|
$order->gancao_remark = '甘草主动撤单(已退费)';
|
||||||
break;
|
break;
|
||||||
@@ -338,6 +342,7 @@ class GancaoCallbackController extends BaseApiController
|
|||||||
case 92:
|
case 92:
|
||||||
if ((int) $order->fulfillment_status !== 3) {
|
if ((int) $order->fulfillment_status !== 3) {
|
||||||
$order->fulfillment_status = 4; // 已取消
|
$order->fulfillment_status = 4; // 已取消
|
||||||
|
$revisitDueOrderCancelled = true;
|
||||||
}
|
}
|
||||||
$order->gancao_remark = '甘草驳回(无法制作并退费)';
|
$order->gancao_remark = '甘草驳回(无法制作并退费)';
|
||||||
break;
|
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)) {
|
if ($savedOk && in_array((int) $order->fulfillment_status, [5, 6], true)) {
|
||||||
ExpressTrackingService::applyAssistantReleaseForShippedPrescriptionOrder($order, [
|
ExpressTrackingService::applyAssistantReleaseForShippedPrescriptionOrder($order, [
|
||||||
'tracking_number' => (string) ($order->tracking_number ?? ''),
|
'tracking_number' => (string) ($order->tracking_number ?? ''),
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ declare(strict_types=1);
|
|||||||
namespace app\command;
|
namespace app\command;
|
||||||
|
|
||||||
use app\adminapi\logic\dept\DeptLogic;
|
use app\adminapi\logic\dept\DeptLogic;
|
||||||
|
use app\adminapi\logic\stats\RevisitRateCalculator;
|
||||||
use app\adminapi\logic\stats\RevisitRateLogic;
|
use app\adminapi\logic\stats\RevisitRateLogic;
|
||||||
|
use app\common\service\tcm\RevisitDueHook;
|
||||||
use think\console\Command;
|
use think\console\Command;
|
||||||
use think\console\Input;
|
use think\console\Input;
|
||||||
use think\console\input\Option;
|
use think\console\input\Option;
|
||||||
@@ -23,13 +25,19 @@ use think\facade\Log;
|
|||||||
* 推荐 cron(每天 09:00 执行一次;或经 zyt_dev_crontab 表调度):
|
* 推荐 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
|
* 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 条
|
* - 接诊率 > 70% :第一优先档,当日每人最多 3 条
|
||||||
* - 接诊率 60% ~ 70% :第二档,当日每人最多 2 条
|
* - 接诊率 60% ~ 70% :第二档,当日每人最多 2 条
|
||||||
* - 接诊率 50% ~ 60% :第三档,当日每人最多 1 条
|
* - 接诊率 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 条;
|
* 轮询方式:按轮次分配,每轮内先 >70% 档每人 1 条,再 60%~70% 档每人 1 条,再 50%~60% 档每人 1 条;
|
||||||
* 一轮结束还有剩余待指派诊单则进入下一轮,直至待指派池为空或所有医助当日额度用尽。
|
* 一轮结束还有剩余待指派诊单则进入下一轮,直至待指派池为空或所有医助当日额度用尽。
|
||||||
* 例:当天 20 条,>70% 有 3 人、60%~70% 有 7 人、50%~60% 有 5 人 →
|
* 例:当天 20 条,>70% 有 3 人、60%~70% 有 7 人、50%~60% 有 5 人 →
|
||||||
@@ -61,11 +69,26 @@ class AutoAssignPendingDiagnosis extends Command
|
|||||||
self::TIER_50_60 => '50%~60%',
|
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()
|
protected function configure()
|
||||||
{
|
{
|
||||||
$this->setName('tcm:auto-assign-pending')
|
$this->setName('tcm:auto-assign-pending')
|
||||||
->setDescription('待分配诊单自动指派:按上月二诊复诊接诊率分档轮询分配,并写入自动指派日志')
|
->setDescription('待分配诊单自动指派:按上月二诊复诊接诊率分档轮询分配,并写入自动指派日志')
|
||||||
->addOption('dry-run', null, Option::VALUE_NONE, '演练模式:只输出分配计划,不写库');
|
->addOption('dry-run', null, Option::VALUE_NONE, '演练模式:只输出分配计划与两套口径分档对照,不写库');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function execute(Input $input, Output $output): int
|
protected function execute(Input $input, Output $output): int
|
||||||
@@ -75,19 +98,27 @@ class AutoAssignPendingDiagnosis extends Command
|
|||||||
$runDate = date('Y-m-d', $now);
|
$runDate = date('Y-m-d', $now);
|
||||||
$statMonth = date('Y-m', strtotime(date('Y-m-01', $now) . ' -1 month'));
|
$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);
|
$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(
|
$output->writeln(sprintf(
|
||||||
'[%s] 开始自动指派待分配诊单,批次=%s,统计月=%s%s',
|
'[%s] 开始自动指派待分配诊单,批次=%s,统计月=%s,分档口径=%s(%s)%s',
|
||||||
date('Y-m-d H:i:s', $now),
|
date('Y-m-d H:i:s', $now),
|
||||||
$batchNo,
|
$batchNo,
|
||||||
$statMonth,
|
$statMonth,
|
||||||
|
$this->rateVersion,
|
||||||
|
self::RATE_VERSION_LABELS[$this->rateVersion],
|
||||||
$dryRun ? '(演练模式,不写库)'
|
$dryRun ? '(演练模式,不写库)'
|
||||||
: ''
|
: ''
|
||||||
));
|
));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. 上月二诊复诊接诊率 → 医助分档
|
// 1. 上月二诊复诊接诊率(slot 口径 overview,同时含旧口径字段)→ 医助分档
|
||||||
$tiers = $this->buildAssistantTiers($statMonth);
|
$overview = RevisitRateLogic::overview([
|
||||||
|
'month' => $statMonth,
|
||||||
|
'inherit_mode' => RevisitRateCalculator::MODE_SLOT,
|
||||||
|
]);
|
||||||
|
$tiers = $this->buildAssistantTiers($overview, $this->tierField);
|
||||||
$tierTotal = array_sum(array_map('count', $tiers));
|
$tierTotal = array_sum(array_map('count', $tiers));
|
||||||
$output->writeln(sprintf(
|
$output->writeln(sprintf(
|
||||||
'医助分档:>70%% 共 %d 人,60%%~70%% 共 %d 人,50%%~60%% 共 %d 人',
|
'医助分档:>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_60_70]),
|
||||||
\count($tiers[self::TIER_50_60])
|
\count($tiers[self::TIER_50_60])
|
||||||
));
|
));
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->printTierComparison($overview, $output);
|
||||||
|
}
|
||||||
|
|
||||||
// 2. 当日剩余额度(扣减当日已自动分配数,防止同日重复执行超额)
|
// 2. 当日剩余额度(扣减当日已自动分配数,防止同日重复执行超额)
|
||||||
$remaining = $this->buildRemainingQuota($tiers, $runDate);
|
$remaining = $this->buildRemainingQuota($tiers, $runDate);
|
||||||
@@ -163,14 +197,16 @@ class AutoAssignPendingDiagnosis extends Command
|
|||||||
'assistant_id' => $item['assistant']['id'],
|
'assistant_id' => $item['assistant']['id'],
|
||||||
'assistant_name' => $item['assistant']['name'],
|
'assistant_name' => $item['assistant']['name'],
|
||||||
'tier' => $item['tier'],
|
'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'],
|
'round_no' => $item['round'],
|
||||||
'reason' => sprintf(
|
'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']['name'],
|
||||||
$item['assistant']['id'],
|
$item['assistant']['id'],
|
||||||
$statMonth,
|
$statMonth,
|
||||||
$item['assistant']['rate'],
|
$item['assistant']['rate'],
|
||||||
|
self::RATE_VERSION_LABELS[$this->rateVersion],
|
||||||
self::TIER_LABELS[$item['tier']],
|
self::TIER_LABELS[$item['tier']],
|
||||||
self::TIER_DAILY_CAPS[$item['tier']],
|
self::TIER_DAILY_CAPS[$item['tier']],
|
||||||
$item['round'],
|
$item['round'],
|
||||||
@@ -215,28 +251,95 @@ class AutoAssignPendingDiagnosis extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按上月二诊复诊接诊率把医助分档(复用 RevisitRateLogic 口径),并剔除已禁用/已删除账号。
|
* 分档字段白名单:config project.revisit_rate.auto_assign_tier_field ∈ {visit2_rate_new_assign, visit2_rate},非法回退旧口径。
|
||||||
*
|
|
||||||
* @return array{gt70: list<array{id:int,name:string,rate:float}>, 60_70: list<array{id:int,name:string,rate:float}>, 50_60: list<array{id:int,name:string,rate:float}>}
|
|
||||||
*/
|
*/
|
||||||
private function buildAssistantTiers(string $statMonth): array
|
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 = [];
|
$candidates = [];
|
||||||
foreach ($overview['rows'] ?? [] as $deptRow) {
|
foreach ($overview['rows'] ?? [] as $deptRow) {
|
||||||
foreach ($deptRow['children'] ?? [] as $row) {
|
foreach ($deptRow['children'] ?? [] as $row) {
|
||||||
$aid = (int) ($row['assistant_id'] ?? 0);
|
$aid = (int) ($row['assistant_id'] ?? 0);
|
||||||
$rate = $row['visit2_rate'] ?? null;
|
$rate = $row[$tierField] ?? null;
|
||||||
// 上月无被指派数据(rate=null)或接诊率低于 50% 的医助不参与分配
|
// 上月无被指派数据(分母 0 → rate=null)或接诊率低于 50% 的医助不参与分配
|
||||||
if ($aid <= 0 || $rate === null || (float) $rate < 50.0) {
|
if ($aid <= 0 || $rate === null || (float) $rate < 50.0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
$v2 = $row['visit2_rate'] ?? null;
|
||||||
|
$v1 = $row[RevisitRateCalculator::FIELD_RATE_NEW_ASSIGN] ?? null;
|
||||||
$candidates[] = [
|
$candidates[] = [
|
||||||
'id' => $aid,
|
'id' => $aid,
|
||||||
'name' => (string) ($row['assistant_name'] ?? ('#' . $aid)),
|
'name' => (string) ($row['assistant_name'] ?? ('#' . $aid)),
|
||||||
'rate' => (float) $rate,
|
'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();
|
Db::commit();
|
||||||
|
|
||||||
|
// 事务提交后刷新复诊到期快照(自动分配 = 换医助;钩子内部吞异常,不影响分配结果)
|
||||||
|
RevisitDueHook::onAssistantChanged($diagnosisId, $toAssistantId, 'auto_assign');
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Db::rollback();
|
Db::rollback();
|
||||||
@@ -613,7 +719,7 @@ class AutoAssignPendingDiagnosis extends Command
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string,mixed> $diag 诊单行(含 id/patient_name/phone)
|
* @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>
|
* @return array<string,mixed>
|
||||||
*/
|
*/
|
||||||
@@ -630,7 +736,10 @@ class AutoAssignPendingDiagnosis extends Command
|
|||||||
'assistant_id' => (int) ($extra['assistant_id'] ?? 0),
|
'assistant_id' => (int) ($extra['assistant_id'] ?? 0),
|
||||||
'assistant_name' => mb_substr((string) ($extra['assistant_name'] ?? ''), 0, 64),
|
'assistant_name' => mb_substr((string) ($extra['assistant_name'] ?? ''), 0, 64),
|
||||||
'tier' => (string) ($extra['tier'] ?? ''),
|
'tier' => (string) ($extra['tier'] ?? ''),
|
||||||
|
// 两套口径的二诊率同时落库:visit2_rate=新口径 v2,visit2_rate_new_assign=旧口径 v1;rate_version 标识本批次分档所用口径
|
||||||
'visit2_rate' => $extra['visit2_rate'] ?? null,
|
'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),
|
'round_no' => (int) ($extra['round_no'] ?? 0),
|
||||||
'reason' => mb_substr((string) ($extra['reason'] ?? ''), 0, 500),
|
'reason' => mb_substr((string) ($extra['reason'] ?? ''), 0, 500),
|
||||||
'create_time' => $now,
|
'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 '实时值更小:继承前订单被取消/退款/软删,或诊单偏移下调';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
namespace app\common\command;
|
||||||
|
|
||||||
use app\common\model\doctor\Appointment;
|
use app\common\model\doctor\Appointment;
|
||||||
|
use app\common\service\tcm\RevisitDueHook;
|
||||||
use think\console\Command;
|
use think\console\Command;
|
||||||
use think\console\Input;
|
use think\console\Input;
|
||||||
use think\console\input\Option;
|
use think\console\input\Option;
|
||||||
@@ -55,6 +56,9 @@ class UpdateAppointmentStatus extends Command
|
|||||||
$missedCount = Db::execute($missedSql, [$now]);
|
$missedCount = Db::execute($missedSql, [$now]);
|
||||||
$cancelCount = Db::execute($cancelSql, [$now]);
|
$cancelCount = Db::execute($cancelSql, [$now]);
|
||||||
|
|
||||||
|
// 过号/取消落库后重评复诊到期快照中「已约」行(当天过号即进标红/弹窗;钩子内部已 try/catch)
|
||||||
|
RevisitDueHook::onAppointmentStatusCronDone();
|
||||||
|
|
||||||
$output->writeln("已过号(1→4): {$missedCount} 条");
|
$output->writeln("已过号(1→4): {$missedCount} 条");
|
||||||
$output->writeln("已取消(4→2,过号超8小时): {$cancelCount} 条");
|
$output->writeln("已取消(4→2,过号超8小时): {$cancelCount} 条");
|
||||||
Log::info("update_appointment_status: 已过号 {$missedCount}, 已取消 {$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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
// 由 php app/mcp/cli/catalog.php --write 生成,请勿手工修改;人工审核结论写在 resources.php。
|
// 由 php app/mcp/cli/catalog.php --write 生成,请勿手工修改;人工审核结论写在 resources.php。
|
||||||
// 生成时间:2026-09-24 08:54:20
|
// 生成时间:2026-10-14 12:06:18
|
||||||
return array (
|
return array (
|
||||||
'article.article/add' =>
|
'article.article/add' =>
|
||||||
array (
|
array (
|
||||||
@@ -8494,6 +8494,7 @@ return array (
|
|||||||
),
|
),
|
||||||
'params' =>
|
'params' =>
|
||||||
array (
|
array (
|
||||||
|
0 => 'kind',
|
||||||
),
|
),
|
||||||
'no_login' => false,
|
'no_login' => false,
|
||||||
),
|
),
|
||||||
@@ -9034,8 +9035,7 @@ return array (
|
|||||||
'http' => 'POST',
|
'http' => 'POST',
|
||||||
'writes' =>
|
'writes' =>
|
||||||
array (
|
array (
|
||||||
0 => '->save(',
|
0 => '::transaction(',
|
||||||
1 => '::create(',
|
|
||||||
),
|
),
|
||||||
'external' =>
|
'external' =>
|
||||||
array (
|
array (
|
||||||
@@ -10063,10 +10063,13 @@ return array (
|
|||||||
19 => 'latest_appointment_channel_source',
|
19 => 'latest_appointment_channel_source',
|
||||||
20 => 'latest_assign_start_date',
|
20 => 'latest_assign_start_date',
|
||||||
21 => 'latest_assign_end_date',
|
21 => 'latest_assign_end_date',
|
||||||
22 => 'sort_unserved_days',
|
22 => 'revisit_state',
|
||||||
23 => 'pending_assign_keyword',
|
23 => 'revisit_days_bucket',
|
||||||
24 => 'keyword',
|
24 => 'sort_revisit_due',
|
||||||
25 => 'pending_assign_order_month',
|
25 => 'sort_unserved_days',
|
||||||
|
26 => 'pending_assign_keyword',
|
||||||
|
27 => 'keyword',
|
||||||
|
28 => 'pending_assign_order_month',
|
||||||
),
|
),
|
||||||
'no_login' => false,
|
'no_login' => false,
|
||||||
),
|
),
|
||||||
@@ -10806,6 +10809,238 @@ return array (
|
|||||||
),
|
),
|
||||||
'no_login' => false,
|
'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' =>
|
'tcm.prescription/add' =>
|
||||||
array (
|
array (
|
||||||
'controller' => 'app\\adminapi\\controller\\tcm\\PrescriptionController',
|
'controller' => 'app\\adminapi\\controller\\tcm\\PrescriptionController',
|
||||||
@@ -12396,6 +12631,202 @@ return array (
|
|||||||
),
|
),
|
||||||
'no_login' => false,
|
'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' =>
|
'tools.generator/dataTable' =>
|
||||||
array (
|
array (
|
||||||
'controller' => 'app\\adminapi\\controller\\tools\\GeneratorController',
|
'controller' => 'app\\adminapi\\controller\\tools\\GeneratorController',
|
||||||
|
|||||||
@@ -18,8 +18,12 @@
|
|||||||
* 子接口自身已登记时仍按自身判断(与后台一致);未登记时依次改用 Tab 权限(tabLeaderboard/tabDoctor/tabZyyt…)
|
* 子接口自身已登记时仍按自身判断(与后台一致);未登记时依次改用 Tab 权限(tabLeaderboard/tabDoctor/tabZyyt…)
|
||||||
* 或页面权限 fans/yeji ——后台对未登记接口不校验、只靠这些权限控制页面可见,这样不会比后台页面更宽。
|
* 或页面权限 fans/yeji ——后台对未登记接口不校验、只靠这些权限控制页面可见,这样不会比后台页面更宽。
|
||||||
* - timeout:业绩看板、医生统计、提成结算、综合转化是多张大表的聚合(后台控制器自己放宽到 120 秒),单条 SQL 超时放宽到 30 秒。
|
* - timeout:业绩看板、医生统计、提成结算、综合转化是多张大表的聚合(后台控制器自己放宽到 120 秒),单条 SQL 超时放宽到 30 秒。
|
||||||
|
* - controller:晚于 generated.php 生成的 action(如 stats.assistantPerformance/revisitRate)显式写 controller 以通过 AiMcpUnitTest 的
|
||||||
|
* 资源存在性校验;重跑 catalog.php --write 后可去掉该键。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
use app\adminapi\controller\stats\AssistantPerformanceController;
|
||||||
|
|
||||||
$dateRange = [
|
$dateRange = [
|
||||||
'start_date' => '开始日期 YYYY-MM-DD(不传默认今天)',
|
'start_date' => '开始日期 YYYY-MM-DD(不传默认今天)',
|
||||||
'end_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' => [
|
'stats.autoAssignLog/lists' => [
|
||||||
'status' => 'open', 'kind' => 'list', 'name' => '待分配诊单自动指派日志',
|
'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' => [
|
'params_allow' => [
|
||||||
'run_date' => '执行日期 YYYY-MM-DD(精确匹配)',
|
'run_date' => '执行日期 YYYY-MM-DD(精确匹配)',
|
||||||
'start_date' => '执行日期起 YYYY-MM-DD',
|
'start_date' => '执行日期起 YYYY-MM-DD',
|
||||||
@@ -172,39 +185,43 @@ return [
|
|||||||
|
|
||||||
'stats.revisitRate/assignLines' => [
|
'stats.revisitRate/assignLines' => [
|
||||||
'status' => 'open', 'kind' => 'report', 'name' => '复诊接诊率 · 被指派明细',
|
'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' => [
|
'params_allow' => [
|
||||||
'month' => '统计月份 YYYY-MM(默认本月)',
|
'month' => '统计月份 YYYY-MM(默认本月)',
|
||||||
'dept_ids' => '部门筛选(限二中心子树),多个逗号分隔',
|
'dept_ids' => '部门筛选(限二中心子树),多个逗号分隔',
|
||||||
'assistant_id' => '只看某医助',
|
'assistant_id' => '只看某医助',
|
||||||
'dept_id' => '只看某部门分组(0=未分配部门)',
|
'dept_id' => '只看某部门分组(0=未分配部门)',
|
||||||
|
'inherit_mode' => '口径:slot=继承按继承时诊次计入分母(默认,配置 project.revisit_rate.default_inherit_mode)/ exclude=继承不计分母(旧口径,与 2026-10 前一致)',
|
||||||
|
'kind' => '行类型:new 仅新指派 / inherit 仅继承 / all 两者(默认 all)',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
'stats.revisitRate/deptOptions' => [
|
'stats.revisitRate/deptOptions' => [
|
||||||
'status' => 'open', 'kind' => 'report', 'name' => '复诊接诊率 · 部门选项',
|
'status' => 'open', 'kind' => 'report', 'name' => '复诊接诊率 · 部门选项',
|
||||||
'note' => '二中心及其下级部门(id、pid、name),供 dept_ids 参数取值。',
|
'note' => '二中心及其下级部门(id、pid、name),供 revisitRate 各接口 dept_ids 参数取值;不受 inherit_mode 影响。',
|
||||||
'params_allow' => [],
|
'params_allow' => [],
|
||||||
],
|
],
|
||||||
|
|
||||||
'stats.revisitRate/overview' => [
|
'stats.revisitRate/overview' => [
|
||||||
'status' => 'open', 'kind' => 'report', 'name' => '复诊接诊率',
|
'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' => [
|
'params_allow' => [
|
||||||
'month' => '统计月份 YYYY-MM(默认本月)',
|
'month' => '统计月份 YYYY-MM(默认本月)',
|
||||||
'dept_ids' => '部门筛选(限二中心子树,含下级),多个逗号分隔',
|
'dept_ids' => '部门筛选(限二中心子树,含下级),多个逗号分隔',
|
||||||
|
'inherit_mode' => '口径:slot=继承按继承时诊次计入每列独立分母(默认,配置 project.revisit_rate.default_inherit_mode)/ exclude=继承不计分母(旧口径)',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
'stats.revisitRate/visitOrderLines' => [
|
'stats.revisitRate/visitOrderLines' => [
|
||||||
'status' => 'open', 'kind' => 'report', 'name' => '复诊接诊率 · N 诊订单明细',
|
'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' => [
|
'params_allow' => [
|
||||||
'month' => '统计月份 YYYY-MM(默认本月)',
|
'month' => '统计月份 YYYY-MM(默认本月)',
|
||||||
'slot' => '诊次 N(必填,2=二诊,最大 50)',
|
'slot' => '诊次 N(必填,2=二诊,最大 50;0=全部 N>=2 即复诊合计)',
|
||||||
'dept_ids' => '部门筛选(限二中心子树),多个逗号分隔',
|
'dept_ids' => '部门筛选(限二中心子树),多个逗号分隔',
|
||||||
'assistant_id' => '只看某医助',
|
'assistant_id' => '只看某医助',
|
||||||
'dept_id' => '只看某部门分组(0=未分配部门)',
|
'dept_id' => '只看某部门分组(0=未分配部门)',
|
||||||
|
'inherit_mode' => '口径 slot|exclude(仅影响医助范围判定,不影响订单归属)',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* AI 数据目录人工审核:中医诊单与处方(tcm.*)的全部非写、非 POST 接口(51 个)。
|
* AI 数据目录人工审核:中医诊单与处方(tcm.*)的全部非写、非 POST 接口(51 个)。
|
||||||
|
* 例外:复诊到期弹窗 tcm.revisitDue/* 的写接口也显式登记为 excluded(控制器晚于 generated.php 生成,需显式 controller 过单测)。
|
||||||
*
|
*
|
||||||
* 约定:
|
* 约定:
|
||||||
* - 按诊单/患者/记录ID取数的接口一律按「详情」调用(kind=detail):zyt_get 会把 ID 固定为单个值,
|
* - 按诊单/患者/记录ID取数的接口一律按「详情」调用(kind=detail):zyt_get 会把 ID 固定为单个值,
|
||||||
@@ -10,6 +11,7 @@
|
|||||||
* - 'builtin' 表示接口自身已有逐条校验,函数写在各条目上方注释里。
|
* - 'builtin' 表示接口自身已有逐条校验,函数写在各条目上方注释里。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
use app\adminapi\controller\tcm\RevisitDueController;
|
||||||
use app\adminapi\logic\tcm\DiagnosisLogic;
|
use app\adminapi\logic\tcm\DiagnosisLogic;
|
||||||
use app\adminapi\validate\tcm\DiagnosisValidate;
|
use app\adminapi\validate\tcm\DiagnosisValidate;
|
||||||
|
|
||||||
@@ -134,6 +136,37 @@ return [
|
|||||||
'tcm.diagnosisTodo/detail' => ['status' => 'pending', 'name' => '诊单待办详情',
|
'tcm.diagnosisTodo/detail' => ['status' => 'pending', 'name' => '诊单待办详情',
|
||||||
'reason' => '按待办ID读取任意诊单的待办,无逐条权限校验(DiagnosisTodoLogic::detail);可改用 tcm.diagnosisTodo/lists(按诊单ID,已校验诊单可见)'],
|
'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',
|
'tcm.prescription/lists' => ['status' => 'open', 'kind' => 'list',
|
||||||
'params_allow' => ['patient_name' => '患者姓名(模糊)', 'sn' => '处方编号(模糊)',
|
'params_allow' => ['patient_name' => '患者姓名(模糊)', 'sn' => '处方编号(模糊)',
|
||||||
|
|||||||
@@ -56,5 +56,11 @@ return [
|
|||||||
'tcm:diagnosis-todo-notify' => 'app\\command\\DiagnosisTodoNotify',
|
'tcm:diagnosis-todo-notify' => 'app\\command\\DiagnosisTodoNotify',
|
||||||
// 待分配诊单自动指派:按上月二诊复诊接诊率分档轮询分配(>70% 3条/60~70% 2条/50~60% 1条),写自动指派日志
|
// 待分配诊单自动指派:按上月二诊复诊接诊率分档轮询分配(>70% 3条/60~70% 2条/50~60% 1条),写自动指派日志
|
||||||
'tcm:auto-assign-pending' => 'app\\command\\AutoAssignPendingDiagnosis',
|
'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',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -152,6 +152,60 @@ return [
|
|||||||
'exempt_roles' => [],
|
'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)
|
* 提成结算 stats.commissionSettlement(YejiStatsLogic)
|
||||||
* require_system_auto_prescription:true(默认)仅系统代开口径(is_system_auto=1),常与订单列表「全部已完成」条数不一致。
|
* 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';
|
||||||
@@ -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";
|
||||||
@@ -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";
|
||||||
@@ -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";
|
||||||
@@ -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";
|
||||||
@@ -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";
|
||||||
Reference in New Issue
Block a user