diff --git a/admin/src/App.vue b/admin/src/App.vue index 2dee8388e..a91aa8df7 100644 --- a/admin/src/App.vue +++ b/admin/src/App.vue @@ -9,6 +9,7 @@ import useAppStore from './stores/modules/app' import useSettingStore from './stores/modules/setting' import useUserStore from './stores/modules/user' import ChatNotifyToast from './components/chat-notify-toast/index.vue' +import RevisitDueToast from './components/revisit-due-toast/index.vue' const appStore = useAppStore() const settingStore = useSettingStore() @@ -58,6 +59,8 @@ watch( + + diff --git a/admin/src/api/revisitDue.ts b/admin/src/api/revisitDue.ts new file mode 100644 index 000000000..2ed0663a9 --- /dev/null +++ b/admin/src/api/revisitDue.ts @@ -0,0 +1,195 @@ +import request from '@/utils/request' + +/** + * 复诊到期弹窗(需求 4)接口:/adminapi/tcm.revisitDue/ + * 契约见 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 { + 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 { + return request.get({ url: '/tcm.revisitDue/summary' }) +} + +/** 延迟一次(幂等) */ +export function revisitDueSnooze(params: { id: number }): Promise { + return request.post({ url: '/tcm.revisitDue/snooze', params }) +} + +/** 批量延迟(仅 escalation_level<=1 的项) */ +export function revisitDueSnoozeAll(params: { + ids: number[] +}): Promise { + return request.post({ url: '/tcm.revisitDue/snoozeAll', params }) +} + +/** 已联系:记录患者承诺复诊日 */ +export function revisitDueContact(params: { + id: number + promised_date: string + remark?: string +}): Promise { + 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 { + 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 + 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 }) +} diff --git a/admin/src/api/stats.ts b/admin/src/api/stats.ts index 858666e48..57f1098f6 100644 --- a/admin/src/api/stats.ts +++ b/admin/src/api/stats.ts @@ -204,8 +204,26 @@ export function commissionSettlementConfirmRevoke(params: Record) { 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 }) } @@ -214,23 +232,29 @@ export function revisitRateDeptOptions() { return request.get({ url: '/stats.revisitRate/deptOptions' }) } -/** 复诊接诊率:「被指派数」点击下钻(按诊单聚合明细) */ +/** + * 复诊接诊率:「新指派数 / 继承数 / 被指派合计」点击下钻(按诊单聚合明细)。 + * 行含 assign_type(new|inherit)、inherit_visit_slot(继承时诊次 k)、entry_slot(起算诊次) + */ export function revisitRateAssignLines(params: { month?: string dept_ids?: string assistant_id?: number dept_id?: number + inherit_mode?: RevisitRateInheritMode + kind?: RevisitRateAssignKind }) { return request.get({ url: '/stats.revisitRate/assignLines', params }) } -/** 复诊接诊率:「N 诊单数」点击下钻(具体订单明细) */ +/** 复诊接诊率:「N 诊单数」点击下钻(具体订单明细);slot=0 为全部 N≥2(复诊合计单数),行含 visit_slot */ export function revisitRateVisitOrderLines(params: { month?: string slot: number dept_ids?: string assistant_id?: number dept_id?: number + inherit_mode?: RevisitRateInheritMode }) { return request.get({ url: '/stats.revisitRate/visitOrderLines', params }) } @@ -253,3 +277,25 @@ export function assistantPerformanceOverview(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 | null +} + +/** + * 医助个人业绩:我的复诊接诊率(按月,仅本人行;口径与复诊接诊率页一致, + * 继承患者按继承时诊次从第 k+1 诊起计入分母) + */ +export function assistantPerformanceRevisitRate(params: { + month?: string +}): Promise { + return request.get({ url: '/stats.assistantPerformance/revisitRate', params }) +} diff --git a/admin/src/components/revisit-due-toast/index.vue b/admin/src/components/revisit-due-toast/index.vue new file mode 100644 index 000000000..1e923743f --- /dev/null +++ b/admin/src/components/revisit-due-toast/index.vue @@ -0,0 +1,1115 @@ + + + + + + + diff --git a/admin/src/utils/revisitDue.ts b/admin/src/utils/revisitDue.ts new file mode 100644 index 000000000..bfaea4171 --- /dev/null +++ b/admin/src/utils/revisitDue.ts @@ -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 = { + 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 + 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-- [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(' · ') +} diff --git a/admin/src/views/stats/assistant-performance/index.vue b/admin/src/views/stats/assistant-performance/index.vue index 2ace7eeeb..20de60f6a 100644 --- a/admin/src/views/stats/assistant-performance/index.vue +++ b/admin/src/views/stats/assistant-performance/index.vue @@ -56,6 +56,70 @@ /> + + + + +
+ + +
+ 口径:N 诊接诊率 = 当月 N 诊成交单数 ÷ N 诊分母(新指派数 + 起算诊次 ≤ N 的继承数),分母为 0 显示「—」; + + + 复诊合计 = 各诊次成交单数之和 ÷ 被指派合计(新指派 ∪ 继承),可超过 100%。 +
+
+
@@ -63,7 +127,12 @@ import { computed, onMounted, reactive, ref } from 'vue' import { ElMessage } from 'element-plus' 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 dateRange = ref([]) @@ -191,8 +260,78 @@ const handleReset = () => { 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(currentMonth) +const revisitLoading = ref(false) +const revisitResult = ref(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(() => { fetchData() + fetchRevisitRate() }) @@ -270,4 +409,57 @@ onMounted(() => { .stats-chart { 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; +} diff --git a/admin/src/views/stats/auto_assign_log/index.vue b/admin/src/views/stats/auto_assign_log/index.vue index 1868336a6..240b782ad 100644 --- a/admin/src/views/stats/auto_assign_log/index.vue +++ b/admin/src/views/stats/auto_assign_log/index.vue @@ -67,7 +67,10 @@
由每日定时任务「待分配诊单自动指派」写入:按上月二诊复诊接诊率分档轮询分配(>70% 每人3条/日,60%~70% - 每人2条/日,50%~60% 每人1条/日,<50% 不分)。「回退」可将已分配医助撤回到自动分配前的原医助(通常为一中心),并写入指派日志。 + 每人2条/日,50%~60% 每人1条/日,<50% 不分)。「回退」可将已分配医助撤回到自动分配前的原医助(通常为一中心),并写入指派日志(原医助重新接手按「继承」记录)。 +
+ 口径版本:v1 旧口径 = 分母仅新指派(visit2_rate_new_assign);v2 新口径 = 每列独立分母,继承按继承时诊次计入(visit2_rate)。 + 每条日志同时记录两套率并标注本批次分档所用口径(配置 project.revisit_rate.auto_assign_tier_field);2026-10 前的历史行仅有旧口径率。
@@ -137,7 +140,7 @@ @@ -145,6 +148,29 @@ + + + + - + + + + + + + + + + + + + + + + + + @@ -162,7 +304,7 @@ @@ -193,6 +335,33 @@ + + + + + + + + + + + @@ -210,6 +379,9 @@ {{ row.order_no || `#${row.order_id}` }} + + + - + + @@ -85,6 +97,20 @@ function relatedPoCreateTimeCell(row: Record): string { 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 { + 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, side: 'from' | 'to'): string { const nameKey = side === 'from' ? 'from_assistant_name' : 'to_assistant_name' const idKey = side === 'from' ? 'from_assistant_id' : 'to_assistant_id' @@ -132,4 +158,11 @@ defineExpose({ refresh: load }) color: var(--el-text-color-secondary); cursor: help; } + +.assign-log-inherit-slot { + margin-top: 2px; + font-size: 12px; + line-height: 1.3; + color: var(--el-text-color-secondary); +} diff --git a/admin/src/views/tcm/diagnosis/components/RevisitNodeCell.vue b/admin/src/views/tcm/diagnosis/components/RevisitNodeCell.vue new file mode 100644 index 000000000..b4ab5aff9 --- /dev/null +++ b/admin/src/views/tcm/diagnosis/components/RevisitNodeCell.vue @@ -0,0 +1,303 @@ + + + + + diff --git a/admin/src/views/tcm/diagnosis/index.vue b/admin/src/views/tcm/diagnosis/index.vue index 7a1c189ed..f5916ee13 100644 --- a/admin/src/views/tcm/diagnosis/index.vue +++ b/admin/src/views/tcm/diagnosis/index.vue @@ -14,6 +14,7 @@ formData.pending_assign !== '1' && formData.pending_booking !== '1' && formData.completed_appointment !== '1' && + formData.revisit_state === '' && !hasLatestAppointmentFilter() && formData.appointment_date === tab.value } @@ -30,6 +31,14 @@ 待预约 {{ pendingBookingCount }} + + + 复诊到期 + {{ revisitDueCount }} + + + + + + + 重置 @@ -278,22 +317,20 @@ 未确认 - + + @@ -301,12 +338,6 @@ {{ getAssistantName(row.assistant_id || row.assistant) }} - - - 继承 - 勾选后写入指派记录
{{ batchAssignIds.length }} 条诊单记录
@@ -737,7 +767,7 @@