diff --git a/admin/src/api/stats.ts b/admin/src/api/stats.ts index b6fb4eb5..25e5bbec 100644 --- a/admin/src/api/stats.ts +++ b/admin/src/api/stats.ts @@ -100,6 +100,7 @@ export function doctorDailyStatsOverview(params: { start_date?: string end_date?: string channel_code?: string + dept_ids?: number[] | string }) { return request.get({ url: '/stats.doctorDailyStats/overview', params }) } diff --git a/admin/src/views/consumer/prescription/index.vue b/admin/src/views/consumer/prescription/index.vue index e467f8dc..c3fe442c 100644 --- a/admin/src/views/consumer/prescription/index.vue +++ b/admin/src/views/consumer/prescription/index.vue @@ -1707,6 +1707,8 @@ type AuxUsageForm = { bags_per_dose: number times_per_day: number usage_days: number + /** 从处方库导入辅方时写入的模板名称,供药房联展示 */ + prescription_name?: string } function defaultAuxUsage(prescriptionType = '浓缩水丸'): AuxUsageForm { @@ -1753,7 +1755,8 @@ function normalizeAuxUsageForm(raw: unknown, prescriptionType: string): AuxUsage need_decoction: o.need_decoction === 1 || o.need_decoction === true, bags_per_dose: o.bags_per_dose != null ? Number(o.bags_per_dose) || 1 : base.bags_per_dose, times_per_day: o.times_per_day != null ? Number(o.times_per_day) || 3 : base.times_per_day, - usage_days: o.usage_days != null ? Number(o.usage_days) || 7 : base.usage_days + usage_days: o.usage_days != null ? Number(o.usage_days) || 7 : base.usage_days, + prescription_name: String(o.prescription_name ?? '').trim() } } @@ -3448,6 +3451,10 @@ const handleImportLibrary = (row: any) => { } else { editForm.herbs.push(...imported) } + if (formulaType === '辅方') { + editForm.aux_usage.prescription_name = String(row.prescription_name ?? '').trim() + } + const modeHint = libraryImportMode.value === 'append' ? '(已追加)' : '' if (findDuplicateHerbNamesLocal().length) { feedback.msgWarning( diff --git a/admin/src/views/consumer/prescription/order_list.vue b/admin/src/views/consumer/prescription/order_list.vue index d8995271..f8f56b94 100644 --- a/admin/src/views/consumer/prescription/order_list.vue +++ b/admin/src/views/consumer/prescription/order_list.vue @@ -293,7 +293,7 @@ :fetch-fun="prescriptionOrderExport" :params="prescriptionOrderExportParams" :page-size="pager.size" - export-hint="导出范围与上方筛选一致(履约状态、创建时间及其他条件均会生效)。含「自媒体渠道(挂号渠道来源)」:优先取该单关联处方登记的挂号;无则诊单下同患者挂号取 id 最大的一条(与前台挂号选择的记录一致);业绩侧栏带渠道筛选导出时与同页列表高亮挂号同源。" + export-hint="导出范围与上方筛选一致(履约状态、创建时间及其他条件均会生效)。含「自媒体渠道(挂号渠道来源)」:优先取该单关联处方登记的挂号;无则诊单下同患者挂号取 id 最大的一条(与前台挂号选择的记录一致);业绩侧栏带渠道筛选导出时与同页列表高亮挂号同源。「服务套餐」按字典 server_order 解析展示;「签收日期」与详情/业绩看板提成口径一致:以快递主表 sign_time 为主,无效时回退至签收类轨迹时间或已签收状态下的最新轨迹时间。" /> @@ -2449,7 +2449,9 @@ @@ -2714,6 +2734,7 @@ import { prescriptionOrderSubmitGancaoRecipel, prescriptionOrderPreviewGancaoRecipel, prescriptionDetail, + prescriptionLibraryLists, getDoctors, getAssistants } from '@/api/tcm' @@ -5480,6 +5501,8 @@ const SLIP_ADDRESS_LINE = '地址:四川省成都市双流区黄甲街道黄 const prescriptionViewVisible = ref(false) const prescriptionViewLoading = ref(false) const prescriptionViewData = ref(null) +/** 药房联辅方标题:处方库中该辅方模板的 prescription_name(导入或药材匹配解析) */ +const slipAuxLibraryName = ref('') const prescriptionTabType = ref('internal') const prescriptionSlipPrintRef = ref(null) const prescriptionSlipExporting = ref(false) @@ -5883,6 +5906,60 @@ function openPrescriptionViewFromEdit() { openPrescriptionView(editForm) } +function herbSetMatchKey(herbs: Array<{ name?: unknown; dosage?: unknown }>): string { + return herbs + .map((h) => `${String(h.name ?? '').trim()}:${Number(h.dosage) || 0}`) + .sort() + .join('|') +} + +function herbsMatchPrescriptionLibrary( + prescriptionHerbs: Array<{ name?: unknown; dosage?: unknown }>, + libraryHerbs: Array<{ name?: unknown; dosage?: unknown }> +): boolean { + if (!prescriptionHerbs.length || !libraryHerbs.length) return false + if (prescriptionHerbs.length !== libraryHerbs.length) return false + return herbSetMatchKey(prescriptionHerbs) === herbSetMatchKey(libraryHerbs) +} + +function readAuxLibraryNameFromUsage(raw: unknown): string { + if (!raw || typeof raw !== 'object') return '' + const o = raw as Record + return String(o.prescription_name ?? o.library_name ?? '').trim() +} + +/** 解析药房联辅方对应的处方库名称:优先 aux_usage 内持久化字段,否则按开方医师处方库辅方模板药材精确匹配 */ +async function resolveSlipAuxLibraryName(data: Record): Promise { + const fromUsage = readAuxLibraryNameFromUsage(data.aux_usage) + if (fromUsage) return fromUsage + + const herbs = Array.isArray(data.herbs) ? data.herbs : [] + const auxHerbs = herbs.filter((h) => normalizeSlipFormulaType((h as any)?.formula_type) === '辅方') + if (!auxHerbs.length) return '' + + const doctorId = Number(data.creator_id) > 0 ? Number(data.creator_id) : 0 + if (!doctorId) return '' + + try { + const res: any = await prescriptionLibraryLists({ + page_no: 1, + page_size: 200, + formula_type: '辅方', + prescribing_creator_id: doctorId + }) + const lists = Array.isArray(res?.lists) ? res.lists : [] + for (const row of lists) { + const libHerbs = Array.isArray(row?.herbs) ? row.herbs : [] + if (herbsMatchPrescriptionLibrary(auxHerbs, libHerbs)) { + return String(row?.prescription_name ?? '').trim() + } + } + } catch { + /* 静默:无匹配时仅展示「辅方」 */ + } + return '' +} + async function openPrescriptionView(row: any) { if (!row.prescription_id) { feedback.msgWarning('该订单未关联处方') @@ -5892,6 +5969,7 @@ async function openPrescriptionView(row: any) { prescriptionViewVisible.value = true prescriptionViewLoading.value = true prescriptionViewData.value = null + slipAuxLibraryName.value = '' prescriptionTabType.value = 'internal' try { @@ -5926,12 +6004,15 @@ async function openPrescriptionView(row: any) { merged.appointment_id = Number(linkedAp.id) } prescriptionViewData.value = merged + slipAuxLibraryName.value = await resolveSlipAuxLibraryName(merged) } else { prescriptionViewData.value = null + slipAuxLibraryName.value = '' } } catch (e: any) { feedback.msgError(e?.message || '加载处方详情失败') prescriptionViewVisible.value = false + slipAuxLibraryName.value = '' } finally { prescriptionViewLoading.value = false } @@ -6585,6 +6666,11 @@ async function downloadPrescriptionSlipPdf() { margin-top: 4px; } +.rx-herb-library-name { + font-weight: 500; + margin-left: 2px; +} + .rx-herb-cell { display: grid; grid-template-columns: 1fr 64px; diff --git a/admin/src/views/fans/yeji.vue b/admin/src/views/fans/yeji.vue index 1688c747..39f8f61f 100644 --- a/admin/src/views/fans/yeji.vue +++ b/admin/src/views/fans/yeji.vue @@ -181,9 +181,44 @@ + + + + + + + @@ -2078,6 +2113,10 @@ function buildDoctorDailyRequestParams(): Record { if (selectedChannel.value) { p.channel_code = selectedChannel.value } + // 选中父级时由后端 resolvePrimaryDepts 自动展开所有子级,再用 admin_dept 收窄医生集合。 + if (selectedDeptIds.value.length > 0) { + p.dept_ids = selectedDeptIds.value.join(',') + } if (customRange.value?.[0] && customRange.value?.[1]) { p.start_date = customRange.value[0] p.end_date = customRange.value[1] @@ -2096,6 +2135,15 @@ function formatDoctorDailyMoney(n: number | string | undefined) { return x.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } +/** 挂号率展示:null 或非有限数显示「—」;保留 2 位小数 + % 后缀 */ +function formatDoctorDailyRate(v: number | string | null | undefined): string { + if (v === null || v === undefined || v === '') return '—' + const n = Number(v) + if (!Number.isFinite(n)) return '—' + + return `${n.toFixed(2)}%` +} + function getDoctorDailySummaries(param: { columns: any[] }) { const t = doctorDailyTotal.value const sums: string[] = [] @@ -2118,6 +2166,9 @@ function getDoctorDailySummaries(param: { columns: any[] }) { case 'deal_order_count': sums[index] = String(t.deal_order_count ?? 0) break + case 'appointment_total': + sums[index] = String(t.appointment_total ?? 0) + break case 'appointment_completed': sums[index] = String(t.appointment_completed ?? 0) break @@ -2127,6 +2178,9 @@ function getDoctorDailySummaries(param: { columns: any[] }) { case 'appointment_cancelled': sums[index] = String(t.appointment_cancelled ?? 0) break + case 'appointment_conversion_rate': + sums[index] = formatDoctorDailyRate(t.appointment_conversion_rate) + break default: sums[index] = '' } @@ -4100,6 +4154,19 @@ onMounted(async () => { background: var(--yj-accent-soft) !important; font-size: 13px; } + + .doctor-daily-th__hint { + margin-left: 4px; + color: var(--yj-muted); + cursor: help; + vertical-align: -2px; + font-size: 13px; + transition: color 0.15s; + + &:hover { + color: var(--yj-brand); + } + } } .filter-surface { diff --git a/server/app/adminapi/controller/stats/DoctorDailyStatsController.php b/server/app/adminapi/controller/stats/DoctorDailyStatsController.php index a8b0a934..2990659a 100755 --- a/server/app/adminapi/controller/stats/DoctorDailyStatsController.php +++ b/server/app/adminapi/controller/stats/DoctorDailyStatsController.php @@ -8,11 +8,14 @@ use app\adminapi\controller\BaseAdminController; use app\adminapi\logic\stats\DoctorDailyStatsLogic; /** - * 医生日统计(系统/手动开方、成交、挂号状态) + * 医生日统计(系统/手动开方、成交、挂号状态、总挂号、挂号率) * * - GET stats.doctorDailyStats/overview - * ?start_date=&end_date=&channel_code= - * 不传/忽略 dept_ids(医生统计不按展示部门检索);未传日期时由后端默认当日。 + * ?start_date=&end_date=&channel_code=&dept_ids= + * dept_ids 可传逗号串或数组;选中父级会自动展开全部子级(与业绩看板部门树同口径)。 + * 筛选口径:部门 → admin_dept 命中的医助集合 → 该医助经手的挂号(COALESCE(a.assistant_id,u.assistant_id))/ + * 订单(o.creator_id)/ 处方(tcm_diagnosis.assistant_id),再按医生(rx.creator_id / a.doctor_id)汇总; + * 显式选部门时隐藏全 0 医生行。未传日期时由后端默认当日。 */ class DoctorDailyStatsController extends BaseAdminController { diff --git a/server/app/adminapi/lists/tcm/PrescriptionOrderLists.php b/server/app/adminapi/lists/tcm/PrescriptionOrderLists.php index a4ba7490..aabe99ae 100755 --- a/server/app/adminapi/lists/tcm/PrescriptionOrderLists.php +++ b/server/app/adminapi/lists/tcm/PrescriptionOrderLists.php @@ -776,11 +776,13 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn 'export_channel' => '渠道', 'export_guahao_channel_source' => '自媒体渠道(挂号渠道来源)', 'export_medication_form' => '药品形态', + 'export_service_package' => '服务套餐', 'export_medication_days' => '天数', 'export_amount' => '总金额', 'export_paid_amount' => '已付金额', 'export_agency_collect' => '代收金额', 'export_tracking_number' => '快递单号', + 'export_sign_time' => '签收日期', 'export_supply_mode' => '甘草还是自营', 'export_gancao_prescription_cost' => '处方成本', 'export_first_visit_assistant' => '初诊医助', diff --git a/server/app/adminapi/logic/stats/DoctorDailyStatsLogic.php b/server/app/adminapi/logic/stats/DoctorDailyStatsLogic.php index e1c03b6b..126848a0 100755 --- a/server/app/adminapi/logic/stats/DoctorDailyStatsLogic.php +++ b/server/app/adminapi/logic/stats/DoctorDailyStatsLogic.php @@ -10,13 +10,20 @@ use app\common\service\DataScope\DataScopeService; use think\facade\Db; /** - * 医生统计:日期区间、渠道/标签、数据范围与业绩看板一致;**不按展示部门收窄**(始终按全部「中心」展示树,再套数据权限)。 + * 医生统计:日期区间、渠道/标签、数据范围与业绩看板一致;当显式传入 dept_ids 时, + * **以部门下的医助为入口**收窄聚合(部门 → admin_dept 命中的医助集合 → 该医助经手的挂号 / 订单 / 处方 → 医生)。 * * 医生范围:admin_role.role_id = 1 且管理员 未软删(delete_time 为空);再按账号「数据范围」收窄。 * * - 系统/手动开方:tcm_prescription.prescription_date ∈ [start,end];渠道/标签与业绩渠道列同源 EXISTS / 诊单标签 * - 成交:订单 create_time、排除履约 4/9/10,按处方 creator_id;渠道/标签同 sumPerformance 口径 * - 挂号:appointment_date ∈ [start,end];选渠道时挂号 channels 命中字典值;标签渠道时 patient_id ∈ 标签诊单集 + * - 部门:dept_ids 由 YejiStatsLogic::resolveSharedYejiFilterContext 解析后透出 adminToPrimary(含全部子级展开后的 admin 集合), + * 下方聚合在挂号 / 订单 / 处方表上用以下口径筛选医助: + * · 挂号:COALESCE(NULLIF(a.assistant_id,0), NULLIF(u.assistant_id,0)) ∈ 医助集合 + * · 订单:o.creator_id ∈ 医助集合(与业绩归属同源) + * · 处方:tcm_diagnosis.assistant_id ∈ 医助集合(rx.diagnosis_id JOIN tcm_diagnosis) + * 医生集合不被部门收窄(医生通常不挂在「中心」部门);展示行最终在前端按所有医生输出,仅在 dept_filter 激活时隐藏「全 0」医生。 */ class DoctorDailyStatsLogic { @@ -24,6 +31,7 @@ class DoctorDailyStatsLogic * @param array{ * start_date?:string, * end_date?:string, + * dept_ids?:int[]|string, * channel_code?:string, * tag_id?:string, * doctor_id?:int|string @@ -33,11 +41,9 @@ class DoctorDailyStatsLogic */ public static function overview(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array { - // 医生统计不参与「展示部门」检索:忽略 dept_ids,避免与业绩表部门筛选联动 - $paramsForCtx = $params; - unset($paramsForCtx['dept_ids']); - - $ctx = YejiStatsLogic::resolveSharedYejiFilterContext($paramsForCtx, $viewerAdminId, $viewerAdminInfo); + // dept_ids 透传至共享上下文:未传时仍按默认「中心」树解析(仅用于挂号率默认 0 等兜底); + // 显式传入时由下方 $deptScopedAdminIds 分支用 adminToPrimary 取出医助集合,并下推到三类聚合作为「经手医助」筛选。 + $ctx = YejiStatsLogic::resolveSharedYejiFilterContext($params, $viewerAdminId, $viewerAdminInfo); $startDate = $ctx['startDate']; $endDate = $ctx['endDate']; $startTs = $ctx['startTs']; @@ -49,6 +55,7 @@ class DoctorDailyStatsLogic $tagFallback = $ctx['tagFallback']; $filterDoctorId = (int) ($params['doctor_id'] ?? 0); + $deptFilterActive = self::hasExplicitDeptIds($params['dept_ids'] ?? null); $doctorIds = self::resolveDoctorAdminIdsForStats($viewerAdminId, $viewerAdminInfo); @@ -56,6 +63,24 @@ class DoctorDailyStatsLogic $doctorIds = in_array($filterDoctorId, $doctorIds, true) ? [$filterDoctorId] : []; } + // 部门下医助集合(含全部子级展开后的 admin_dept 命中者);未显式选部门时不参与筛选 → null。 + // 显式选部门但集合为空 ⇒ 该部门下无可见医助,直接返回空结果。 + $deptScopedAdminIds = null; + if ($deptFilterActive) { + $deptScopedAdminIds = array_values(array_unique(array_map( + 'intval', + array_keys($ctx['adminToPrimary'] ?? []) + ))); + if ($deptScopedAdminIds === []) { + return [ + 'start_date' => $startDate, + 'end_date' => $endDate, + 'rows' => [], + 'total' => self::emptyTotals(), + ]; + } + } + if ($doctorIds === []) { return [ 'start_date' => $startDate, @@ -73,7 +98,8 @@ class DoctorDailyStatsLogic $channelFilterActive, $tagDiagIds, $tagAssistantIds, - $tagFallback + $tagFallback, + $deptScopedAdminIds ); $orderMap = self::loadOrderAggregates( $startTs, @@ -83,7 +109,8 @@ class DoctorDailyStatsLogic $channelFilterActive, $tagDiagIds, $tagAssistantIds, - $tagFallback + $tagFallback, + $deptScopedAdminIds ); $apptMap = self::loadAppointmentAggregates( $startDate, @@ -91,7 +118,8 @@ class DoctorDailyStatsLogic $doctorIds, $appointmentChannelValues, $channelFilterActive, - $tagDiagIds + $tagDiagIds, + $deptScopedAdminIds ); $adminRows = Admin::whereIn('id', $doctorIds) @@ -109,9 +137,10 @@ class DoctorDailyStatsLogic foreach ($doctorIds as $aid) { $rx = $rxMap[$aid] ?? ['system' => 0, 'manual' => 0]; $ord = $orderMap[$aid] ?? ['amount' => 0.0, 'count' => 0]; - $ap = $apptMap[$aid] ?? ['completed' => 0, 'missed' => 0, 'cancelled' => 0]; + $ap = $apptMap[$aid] ?? ['completed' => 0, 'missed' => 0, 'cancelled' => 0, 'total' => 0]; $cnt = (int) $ord['count']; $amt = round((float) $ord['amount'], 2); + $apTotal = (int) ($ap['total'] ?? 0); $rows[] = [ 'admin_id' => $aid, 'doctor_name' => $nameById[$aid] ?? ('#' . $aid), @@ -120,12 +149,26 @@ class DoctorDailyStatsLogic 'deal_amount' => $amt, 'deal_order_count' => $cnt, 'avg_deal_amount' => $cnt > 0 ? round($amt / $cnt, 2) : null, + 'appointment_total' => $apTotal, 'appointment_completed' => (int) $ap['completed'], 'appointment_missed' => (int) $ap['missed'], 'appointment_cancelled' => (int) $ap['cancelled'], + // 挂号率 = 成交单数 / 总挂号数 × 100;总挂号为 0 时返回 null(前端展示「—」)。 + 'appointment_conversion_rate' => $apTotal > 0 ? round($cnt / $apTotal * 100, 2) : null, ]; } + // 显式部门筛选时隐藏「该部门无任何关联」的医生,避免列出大量全 0 行。 + if ($deptFilterActive) { + $rows = array_values(array_filter($rows, static function (array $r): bool { + return (int) ($r['system_prescription_count'] ?? 0) > 0 + || (int) ($r['manual_prescription_count'] ?? 0) > 0 + || (float) ($r['deal_amount'] ?? 0) > 0 + || (int) ($r['deal_order_count'] ?? 0) > 0 + || (int) ($r['appointment_total'] ?? 0) > 0; + })); + } + usort($rows, static function (array $a, array $b): int { if (($a['deal_amount'] ?? 0) != ($b['deal_amount'] ?? 0)) { return ($b['deal_amount'] ?? 0) <=> ($a['deal_amount'] ?? 0); @@ -142,6 +185,31 @@ class DoctorDailyStatsLogic ]; } + /** + * 是否显式传入 dept_ids(含 1 个以上正整数即视为显式)。与 YejiStatsLogic::hasExplicitDeptIdsParam 同口径。 + * + * @param mixed $raw + */ + private static function hasExplicitDeptIds($raw): bool + { + if (\is_string($raw) && trim($raw) !== '') { + foreach (explode(',', $raw) as $p) { + if ((int) trim($p) > 0) { + return true; + } + } + } + if (\is_array($raw)) { + foreach ($raw as $v) { + if ((int) $v > 0) { + return true; + } + } + } + + return false; + } + /** * 医生角色(role_id=1)、管理员未删除、且在数据范围内的 admin_id。 * @@ -186,9 +254,11 @@ class DoctorDailyStatsLogic 'deal_amount' => 0.0, 'deal_order_count' => 0, 'avg_deal_amount' => null, + 'appointment_total' => 0, 'appointment_completed' => 0, 'appointment_missed' => 0, 'appointment_cancelled' => 0, + 'appointment_conversion_rate' => null, ]; } @@ -205,6 +275,7 @@ class DoctorDailyStatsLogic $t['manual_prescription_count'] += (int) ($r['manual_prescription_count'] ?? 0); $t['deal_amount'] += (float) ($r['deal_amount'] ?? 0); $t['deal_order_count'] += (int) ($r['deal_order_count'] ?? 0); + $t['appointment_total'] += (int) ($r['appointment_total'] ?? 0); $t['appointment_completed'] += (int) ($r['appointment_completed'] ?? 0); $t['appointment_missed'] += (int) ($r['appointment_missed'] ?? 0); $t['appointment_cancelled'] += (int) ($r['appointment_cancelled'] ?? 0); @@ -212,6 +283,9 @@ class DoctorDailyStatsLogic $t['deal_amount'] = round((float) $t['deal_amount'], 2); $dc = (int) $t['deal_order_count']; $t['avg_deal_amount'] = $dc > 0 ? round((float) $t['deal_amount'] / $dc, 2) : null; + $apTotal = (int) $t['appointment_total']; + // 合计行挂号率:行行加总后再统一计算,与汇总后的 成交单数 / 总挂号数 对齐。 + $t['appointment_conversion_rate'] = $apTotal > 0 ? round($dc / $apTotal * 100, 2) : null; return $t; } @@ -220,6 +294,7 @@ class DoctorDailyStatsLogic * @param int[] $appointmentChannelValues * @param int[]|null $tagDiagIds * @param array|null $tagAssistantIds + * @param int[]|null $deptScopedAdminIds 部门下医助集合:非 null 时加 tcm_diagnosis.assistant_id IN (...) 约束 * * @return array */ @@ -231,11 +306,15 @@ class DoctorDailyStatsLogic bool $channelFilterActive, ?array $tagDiagIds, ?array $tagAssistantIds, - bool $tagFallback + bool $tagFallback, + ?array $deptScopedAdminIds = null ): array { if (!self::tagScopeNonEmpty($tagDiagIds, $tagAssistantIds)) { return []; } + if ($deptScopedAdminIds !== null && $deptScopedAdminIds === []) { + return []; + } $query = Db::name('tcm_prescription') ->alias('rx') @@ -245,6 +324,12 @@ class DoctorDailyStatsLogic ->whereIn('rx.creator_id', $doctorIds) ->where('rx.diagnosis_id', '>', 0); + // 部门 → 医助:仅保留经手医助归属在所选部门子树的处方(通过诊单 assistant_id 关联) + if ($deptScopedAdminIds !== null) { + $query->join('tcm_diagnosis dg', 'dg.id = rx.diagnosis_id AND dg.delete_time IS NULL', 'INNER') + ->whereIn('dg.assistant_id', $deptScopedAdminIds); + } + self::applyPrescriptionChannelTagFilter( $query, 'rx', @@ -280,6 +365,7 @@ class DoctorDailyStatsLogic * @param int[] $appointmentChannelValues * @param int[]|null $tagDiagIds * @param array|null $tagAssistantIds + * @param int[]|null $deptScopedAdminIds 部门下医助集合:非 null 时加 o.creator_id IN (...) 约束(与业绩归属同源) * * @return array */ @@ -291,11 +377,15 @@ class DoctorDailyStatsLogic bool $channelFilterActive, ?array $tagDiagIds, ?array $tagAssistantIds, - bool $tagFallback + bool $tagFallback, + ?array $deptScopedAdminIds = null ): array { if (!self::tagScopeNonEmpty($tagDiagIds, $tagAssistantIds)) { return []; } + if ($deptScopedAdminIds !== null && $deptScopedAdminIds === []) { + return []; + } $q = Db::name('tcm_prescription_order') ->alias('o') @@ -306,6 +396,11 @@ class DoctorDailyStatsLogic $q->whereIn('rx.creator_id', $doctorIds) ->where('o.diagnosis_id', '>', 0); + // 部门 → 医助:业绩归属同源(o.creator_id 即订单创建医助) + if ($deptScopedAdminIds !== null) { + $q->whereIn('o.creator_id', $deptScopedAdminIds); + } + $normCh = self::normalizeAppointmentChannelInts($appointmentChannelValues); if ($normCh !== []) { $apTable = self::tableWithPrefix('doctor_appointment'); @@ -423,8 +518,10 @@ class DoctorDailyStatsLogic /** * @param int[] $appointmentChannelValues * @param int[]|null $tagDiagIds + * @param int[]|null $deptScopedAdminIds 部门下医助集合:非 null 时加 + * COALESCE(NULLIF(a.assistant_id,0), NULLIF(u.assistant_id,0)) IN (...) 约束 * - * @return array + * @return array */ private static function loadAppointmentAggregates( string $startDate, @@ -432,29 +529,54 @@ class DoctorDailyStatsLogic array $doctorIds, array $appointmentChannelValues, bool $channelFilterActive, - ?array $tagDiagIds + ?array $tagDiagIds, + ?array $deptScopedAdminIds = null ): array { - $q = Db::name('doctor_appointment') - ->whereBetween('appointment_date', [$startDate, $endDate]) - ->whereIn('doctor_id', $doctorIds); + if ($deptScopedAdminIds !== null && $deptScopedAdminIds === []) { + return []; + } + + $needsDiagJoin = $deptScopedAdminIds !== null; + if ($needsDiagJoin) { + $q = Db::name('doctor_appointment')->alias('a') + ->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id') + ->whereBetween('a.appointment_date', [$startDate, $endDate]) + ->whereIn('a.doctor_id', $doctorIds) + ->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)'); + $aliasPrefix = 'a.'; + } else { + $q = Db::name('doctor_appointment') + ->whereBetween('appointment_date', [$startDate, $endDate]) + ->whereIn('doctor_id', $doctorIds); + $aliasPrefix = ''; + } $normCh = self::normalizeAppointmentChannelInts($appointmentChannelValues); if ($normCh !== []) { $strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $normCh))); - $q->whereIn('channels', $strVals); + $q->whereIn($aliasPrefix . 'channels', $strVals); } elseif ($channelFilterActive && $tagDiagIds !== null) { if ($tagDiagIds === []) { return []; } - $q->whereIn('patient_id', $tagDiagIds); + $q->whereIn($aliasPrefix . 'patient_id', $tagDiagIds); } + // 部门 → 医助:与业绩看板 consultEffectiveAssistantSql 同口径 —— 优先挂号创建人,回退诊单医助 + if ($needsDiagJoin) { + $effIds = array_map('intval', $deptScopedAdminIds); + $inList = implode(',', $effIds); + $q->whereRaw('COALESCE(NULLIF(a.assistant_id, 0), NULLIF(u.assistant_id, 0)) IN (' . $inList . ')'); + } + + // total = 当前筛选下挂号总数(含 status=1 已预约/2 已取消/3 已完成/4 已过号),用于计算「挂号率」。 $q->field([ - 'doctor_id', - Db::raw('SUM(CASE WHEN status = 3 THEN 1 ELSE 0 END) AS completed'), - Db::raw('SUM(CASE WHEN status = 4 THEN 1 ELSE 0 END) AS missed'), - Db::raw('SUM(CASE WHEN status = 2 THEN 1 ELSE 0 END) AS cancelled'), - ])->group('doctor_id'); + $aliasPrefix . 'doctor_id', + Db::raw('COUNT(*) AS total'), + Db::raw('SUM(CASE WHEN ' . $aliasPrefix . 'status = 3 THEN 1 ELSE 0 END) AS completed'), + Db::raw('SUM(CASE WHEN ' . $aliasPrefix . 'status = 4 THEN 1 ELSE 0 END) AS missed'), + Db::raw('SUM(CASE WHEN ' . $aliasPrefix . 'status = 2 THEN 1 ELSE 0 END) AS cancelled'), + ])->group($aliasPrefix . 'doctor_id'); $out = []; foreach ($q->select()->toArray() as $r) { @@ -463,6 +585,7 @@ class DoctorDailyStatsLogic continue; } $out[$id] = [ + 'total' => (int) ($r['total'] ?? 0), 'completed' => (int) ($r['completed'] ?? 0), 'missed' => (int) ($r['missed'] ?? 0), 'cancelled' => (int) ($r['cancelled'] ?? 0), diff --git a/server/app/adminapi/logic/tcm/PrescriptionLogic.php b/server/app/adminapi/logic/tcm/PrescriptionLogic.php index 51c4aa75..9afee3a0 100755 --- a/server/app/adminapi/logic/tcm/PrescriptionLogic.php +++ b/server/app/adminapi/logic/tcm/PrescriptionLogic.php @@ -185,6 +185,7 @@ class PrescriptionLogic 'bags_per_dose' => isset($raw['bags_per_dose']) ? (int) $raw['bags_per_dose'] : 1, 'times_per_day' => (int) ($raw['times_per_day'] ?? 3), 'usage_days' => (int) ($raw['usage_days'] ?? 7), + 'prescription_name' => trim((string) ($raw['prescription_name'] ?? '')), ]; } diff --git a/server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php b/server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php index f77663b4..9e297c5f 100755 --- a/server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php +++ b/server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php @@ -2693,6 +2693,36 @@ class PrescriptionOrderLogic return $pathSegments === [] ? '' : implode(';', $pathSegments); } + /** + * 导出用:服务套餐(与列表/详情 formatServicePackage 同口径,按 dict_data type_value=server_order 解析) + * 入参可能是逗号分隔字符串或数组;空串返回空,避免 Excel 显示「—」占位。 + * + * @param mixed $value zyt_tcm_prescription_order.service_package + * @param array $packageNameByValue dict_data server_order value=>name 字典缓存 + */ + public static function formatServicePackageForExport($value, array $packageNameByValue): string + { + if ($value === null || $value === '' || $value === false) { + return ''; + } + $items = []; + if (\is_array($value)) { + $items = $value; + } else { + $items = explode(',', (string) $value); + } + $names = []; + foreach ($items as $raw) { + $key = trim((string) $raw); + if ($key === '') { + continue; + } + $names[] = (string) ($packageNameByValue[$key] ?? $packageNameByValue[(string) (int) $key] ?? $key); + } + + return $names === [] ? '' : implode('、', $names); + } + /** * 导出用:药品形态(丸剂 / 饮片+代煎|自煎等) * @@ -2944,6 +2974,117 @@ class PrescriptionOrderLogic } } + // 服务套餐字典:与前端 formatServicePackage 同口径(dict_data type_value=server_order;只取启用项做兜底取原值) + $packageNameByValue = []; + $hasAnyPackage = false; + foreach ($lists as $row) { + $pkRaw = $row['service_package'] ?? ''; + if (\is_array($pkRaw)) { + if ($pkRaw !== []) { + $hasAnyPackage = true; + break; + } + } elseif (trim((string) $pkRaw) !== '') { + $hasAnyPackage = true; + break; + } + } + if ($hasAnyPackage) { + try { + $packageNameByValue = DictData::where('type_value', 'server_order')->column('name', 'value'); + } catch (\Throwable) { + $packageNameByValue = []; + } + } + + // 签收时间批量解析:与 ExpressTrackingService::effectiveSignUnixFromTrackingDetail 同口径, + // 取 GREATEST(sign_time, max 签收类轨迹 trace_time_stamp, 已签收主表时的最新轨迹 trace_time_stamp); + // 与详情/业绩看板提成「签收时间」语义一致,避免历史数据 sign_time=0 但实际已签收时被当作「未签收」。 + /** @var array $signTsByTracking trim 后的 tracking_number => Unix 秒 */ + $signTsByTracking = []; + $trackingNumbers = []; + foreach ($lists as $row) { + $tn = trim((string) ($row['tracking_number'] ?? '')); + if ($tn !== '') { + $trackingNumbers[$tn] = true; + } + } + $trackingNumbers = array_keys($trackingNumbers); + if ($trackingNumbers !== []) { + try { + $etRows = Db::name('express_tracking') + ->whereIn('tracking_number', $trackingNumbers) + ->whereNull('delete_time') + ->field(['id', 'tracking_number', 'sign_time', 'current_state', 'is_signed']) + ->select() + ->toArray(); + $etIdToNum = []; + $signedEtIds = []; + $allEtIds = []; + foreach ($etRows as $r) { + $tn = trim((string) ($r['tracking_number'] ?? '')); + $eid = (int) ($r['id'] ?? 0); + if ($tn === '' || $eid <= 0) { + continue; + } + $etIdToNum[$eid] = $tn; + $allEtIds[] = $eid; + $st = (int) ($r['sign_time'] ?? 0); + if ($st > 0) { + $signTsByTracking[$tn] = max($signTsByTracking[$tn] ?? 0, $st); + } + if ((string) ($r['current_state'] ?? '') === '3' || (int) ($r['is_signed'] ?? 0) === 1) { + $signedEtIds[$eid] = true; + } + } + if ($allEtIds !== []) { + $sigCond = "(`status_code` IN ('3','301','302','304') " + . "OR `status` LIKE '%签收%' " + . "OR `trace_context` LIKE '%签收%' " + . "OR `trace_context` LIKE '%妥投%' " + . "OR `trace_context` LIKE '%已送达%' " + . "OR `trace_context` LIKE '%本人签收%')"; + $sigTraces = Db::name('express_trace') + ->whereIn('tracking_id', $allEtIds) + ->where('trace_time_stamp', '>', 0) + ->whereRaw($sigCond) + ->fieldRaw('tracking_id, MAX(trace_time_stamp) AS max_ts') + ->group('tracking_id') + ->select() + ->toArray(); + foreach ($sigTraces as $t) { + $eid = (int) ($t['tracking_id'] ?? 0); + $tn = $etIdToNum[$eid] ?? ''; + $ts = (int) ($t['max_ts'] ?? 0); + if ($tn !== '' && $ts > 0) { + $signTsByTracking[$tn] = max($signTsByTracking[$tn] ?? 0, $ts); + } + } + $signedIdList = array_keys($signedEtIds); + if ($signedIdList !== []) { + $anyTraces = Db::name('express_trace') + ->whereIn('tracking_id', $signedIdList) + ->where('trace_time_stamp', '>', 0) + ->fieldRaw('tracking_id, MAX(trace_time_stamp) AS max_ts') + ->group('tracking_id') + ->select() + ->toArray(); + foreach ($anyTraces as $t) { + $eid = (int) ($t['tracking_id'] ?? 0); + $tn = $etIdToNum[$eid] ?? ''; + $ts = (int) ($t['max_ts'] ?? 0); + if ($tn !== '' && $ts > 0) { + $signTsByTracking[$tn] = max($signTsByTracking[$tn] ?? 0, $ts); + } + } + } + } + } catch (\Throwable $e) { + Log::warning('appendPrescriptionOrderListExportRows resolve sign_time failed: ' . $e->getMessage()); + $signTsByTracking = []; + } + } + $assistantDeptCache = []; foreach ($lists as &$item) { @@ -2982,6 +3123,10 @@ class PrescriptionOrderLogic } $item['export_medication_form'] = self::formatMedicationFormForExport(\is_array($rx) ? $rx : []); + $item['export_service_package'] = self::formatServicePackageForExport( + $item['service_package'] ?? '', + $packageNameByValue + ); $item['export_channel'] = (string) ($item['service_channel'] ?? ''); $apptIdResolved = $resolvedApptIdByPoId[$poId] ?? 0; @@ -3000,6 +3145,9 @@ class PrescriptionOrderLogic $item['export_agency_collect'] = number_format(round($amt - $paid, 2), 2, '.', ''); $item['export_tracking_number'] = (string) ($item['tracking_number'] ?? ''); + $tnTrim = trim((string) ($item['tracking_number'] ?? '')); + $signTs = $tnTrim !== '' ? (int) ($signTsByTracking[$tnTrim] ?? 0) : 0; + $item['export_sign_time'] = $signTs > 0 ? date('Y-m-d', $signTs) : ''; $isGc = trim((string) ($item['gancao_reciperl_order_no'] ?? '')) !== ''; $item['export_supply_mode'] = $isGc ? '甘草' : '自营'; // 「处方成本」列:与列表/详情一致,取订单 internal_cost(含「测试价格」预报价写入;甘草/自营均导出)