From 7473a3e03ddcd7ff429c6a71023994891c65f31b Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 8 May 2026 14:13:19 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../consumer/prescription/order_list.vue | 6 +- admin/src/views/fans/yeji.vue | 85 +- .../lists/tcm/PrescriptionOrderLists.php | 44 + .../adminapi/logic/stats/YejiStatsLogic.php | 1398 ++++++++++++++--- .../logic/tcm/PrescriptionOrderLogic.php | 7 + server/app/command/ExpressAutoUpdate.php | 5 +- .../app/command/GancaoSyncLogisticsRoute.php | 3 + .../common/service/ExpressTrackingService.php | 38 +- ...is_shipped_non_er_assistant_cleared_at.sql | 6 + 9 files changed, 1324 insertions(+), 268 deletions(-) create mode 100644 server/sql/1.9.20260507/add_diagnosis_shipped_non_er_assistant_cleared_at.sql diff --git a/admin/src/views/consumer/prescription/order_list.vue b/admin/src/views/consumer/prescription/order_list.vue index 097a8d89..4f7879fd 100644 --- a/admin/src/views/consumer/prescription/order_list.vue +++ b/admin/src/views/consumer/prescription/order_list.vue @@ -491,7 +491,11 @@
- +
diff --git a/admin/src/views/fans/yeji.vue b/admin/src/views/fans/yeji.vue index 6f4affb9..a190d5dc 100644 --- a/admin/src/views/fans/yeji.vue +++ b/admin/src/views/fans/yeji.vue @@ -463,8 +463,8 @@ @@ -500,7 +500,7 @@ @@ -511,8 +511,8 @@ @@ -994,13 +994,36 @@ 类型 {{ row.linked_appointment.appointment_type_desc || '—' }} -
+
渠道 - {{ - row.linked_appointment.channel_source_desc || - row.linked_appointment.channel_source || - '—' - }} + + {{ + row.linked_appointment.channel_source_desc || + row.linked_appointment.channel_source || + '—' + }} + + (字典 value + {{ orderDrawerLinkedApptChannelDictValue(row.linked_appointment) }} + ) + + + + + +
@@ -2526,6 +2549,23 @@ function orderDrawerApptSchedule(a: Record): string { return date || tail || '—' } +/** 挂号 channels 字典 value(后端 channel_dict_value,与业绩/进线所用字典同源);无数值则空串 */ +function orderDrawerLinkedApptChannelDictValue(a: Record | null | undefined): string { + if (!a) return '' + const v = a.channel_dict_value + if (v !== null && v !== undefined && v !== '') { + const n = Number(v) + if (Number.isFinite(n)) { + return String(Math.trunc(n)) + } + } + const raw = a.channel_source + if (raw === null || raw === undefined) return '' + const s = String(raw).trim() + if (s === '' || !/^\d+$/.test(s)) return '' + return String(parseInt(s, 10)) +} + function formatOrderDrawerTime(t: unknown): string { if (t == null || t === '') return '—' const n = Number(t) @@ -2562,6 +2602,10 @@ async function fetchOrderDrawerPage() { } if (f.mode === 'dept') { params.assistant_dept_id = f.deptId + /** 与看板「接诊诊单」列一致:处方列表默认 assistant_dept_id(诊单医助或创建人∪子树),不用业绩摊行医助集合 */ + if (selectedDeptIds.value.length > 0) { + params.dept_ids = selectedDeptIds.value.join(',') + } } else { params.assistant_id = f.assistantId if (f.erCenterRevisitSlot !== undefined) { @@ -2616,7 +2660,7 @@ function openOrderDrawerByDept(tb: YejiTable, row: YejiRow) { end: tb.end_date, } orderDrawerTitle.value = `业务订单 · ${row.dept_name}` - orderDrawerHint.value = `创建时间:${tb.start_date} ~ ${tb.end_date} · 诊单医助所属部门 · 不含履约已取消` + orderDrawerHint.value = `创建时间:${tb.start_date} ~ ${tb.end_date} · 与「接诊诊单」同口径:诊单医助或订单创建人落在本部门(含下级)· 不含履约已取消` orderDrawerPage.value = 1 orderDrawerVisible.value = true void fetchOrderDrawerPage() @@ -4493,6 +4537,23 @@ onMounted(async () => { &:last-child { margin-bottom: 0; } + + &.yeji-order-appt-line--channel { + align-items: flex-start; + + .yeji-order-appt-channel-text { + flex: 1; + min-width: 0; + } + } +} + +.yeji-order-appt-info { + font-size: 14px; + color: var(--yj-muted, #94a3b8); + cursor: help; + flex-shrink: 0; + margin-top: 1px; } .yeji-order-appt-k { diff --git a/server/app/adminapi/lists/tcm/PrescriptionOrderLists.php b/server/app/adminapi/lists/tcm/PrescriptionOrderLists.php index 40d4cf83..a662f11d 100644 --- a/server/app/adminapi/lists/tcm/PrescriptionOrderLists.php +++ b/server/app/adminapi/lists/tcm/PrescriptionOrderLists.php @@ -883,6 +883,21 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn } if (isset($this->params['assistant_dept_id']) && $this->params['assistant_dept_id'] !== '' && (int) $this->params['assistant_dept_id'] > 0) { + if ((int) ($this->params['yeji_drawer_match_table_performance'] ?? 0) === 1) { + $rowDeptId = (int) $this->params['assistant_dept_id']; + $adminIds = YejiStatsLogic::collectAdminIdsAttributedToYejiTableRow( + $rowDeptId, + $this->params['dept_ids'] ?? null + ); + if ($adminIds === []) { + $query->whereRaw('0 = 1'); + + return; + } + $this->applyPerformanceAttributionAdminInFilter($query, $adminIds); + + return; + } $rootDeptId = (int) $this->params['assistant_dept_id']; $deptIds = DeptLogic::getSelfAndDescendantIds($rootDeptId); $deptIds = array_values(array_filter(array_map('intval', $deptIds), static function (int $id): bool { @@ -909,6 +924,35 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn } } + /** + * 业绩看板侧栏:只保留「业绩归属医助」落在给定 admin_id 集合的订单(与 YejiStatsLogic::sqlPerformanceAttributionAdminExpr 一致)。 + * + * @param int[] $adminIds + */ + private function applyPerformanceAttributionAdminInFilter($query, array $adminIds): void + { + $adminIds = array_values(array_unique(array_filter(array_map('intval', $adminIds), static function (int $x): bool { + return $x > 0; + }))); + if ($adminIds === []) { + $query->whereRaw('0 = 1'); + + return; + } + $poTbl = (new PrescriptionOrder())->getTable(); + $diagTbl = (new Diagnosis())->getTable(); + $rxTbl = (new Prescription())->getTable(); + $inList = implode(',', $adminIds); + $query->whereRaw( + "EXISTS (SELECT 1 FROM `{$diagTbl}` dg " + . "LEFT JOIN `{$rxTbl}` rx ON rx.`id` = `{$poTbl}`.`prescription_id` AND rx.`delete_time` IS NULL " + . "WHERE dg.`id` = `{$poTbl}`.`diagnosis_id` AND dg.`delete_time` IS NULL AND `{$poTbl}`.`diagnosis_id` > 0 AND " + . '(CASE WHEN dg.`assistant_id` > 0 THEN dg.`assistant_id` ' + . "WHEN `{$poTbl}`.`creator_id` > 0 AND (rx.`id` IS NULL OR NOT (`{$poTbl}`.`creator_id` <=> rx.`creator_id`)) THEN `{$poTbl}`.`creator_id` " + . 'ELSE 0 END) IN (' . $inList . '))' + ); + } + /** * 按诊单患者姓名 / 手机号模糊检索(关联 diagnosis_id) */ diff --git a/server/app/adminapi/logic/stats/YejiStatsLogic.php b/server/app/adminapi/logic/stats/YejiStatsLogic.php index 75d75d77..f1a24942 100644 --- a/server/app/adminapi/logic/stats/YejiStatsLogic.php +++ b/server/app/adminapi/logic/stats/YejiStatsLogic.php @@ -19,16 +19,16 @@ use think\facade\Db; * - 被指派数 = 区间内 tcm_diagnosis_assign_log 成功指派次数(to_assistant_id>0),按**被指派医助**归属展示部门;选定渠道时收窄与「{渠道}业绩」同源(channels EXISTS 或 tag 诊单/医助) * · **二中心展示部门在选定渠道下计 0**(与进线一致) * - 复诊统计 = 仅**名称含「二中心」的部门及其组织架构下级**:区间内业务单按患者 create_time 排序,第 1 笔不计,第 2 笔起分列「复诊2」「复诊3」…;口径同业绩单条件;按诊单医助在组织树内归属 rollup(含下级),与 dept 模块 ErCenter 复诊算法一致 - * - 接诊诊单 = 已完成挂号 status=3,**与 admin 挂号列表条数同口径**(appointment_date、诊单未软删),不限医助角色、不要求 assistant_id 非 0; - * 部门归属:优先 COALESCE(挂号.assistant_id, 诊单.assistant_id) 映射到展示「中心」,否则回退接诊医生 doctor_id;仍无法归属则计入「未归属中心」行。 + * - 已完成挂号 = status=3 挂号条数(appointment_date),列「已完成挂号」;部门归属:有效医助→接诊医生→展示行映射。 + * - 接诊诊单 = 列名对应**业务订单条数**(非取消、create_time 区间),与处方列表按 **assistant_dept_id** 筛选时一致:诊单 **assistant_id** 或订单 **creator_id** 任一人事部门落在该行子树即计入;多行命中时取**最深的展示部门行**,与「业绩归属医助」分组可能不同。 * - {渠道}成交单 = 选定渠道时与 {渠道}业绩同源:区间内 **非取消业务订单条数**(仅剔除 fulfillment_status=4;NULL 与其它状态计入,按 create_time 归日);dict(channels) 与 tag 口径均按订单计(tag 时按诊单医助收窄),非挂号条数。 - * - 合计业绩 = 区间内 zyt_tcm_prescription_order.amount 求和(排除已取消(4) 与软删),按 dg.assistant_id 归属到「中心」展示部门; - * 无法归属到任一展示部门的金额单列「未归属中心」行;合计行 = 与列表同条件的全表 SUM(不限部门) - * · 时间窗与业绩条件与列表 stats_order_amount_performance 一致;列表若另有患者/单号等筛选或账号数据域收窄,金额可能仍不同 + * - 合计业绩 = 区间内业务订单 amount 求和(排除已取消(4) 与软删),**部门列**与「接诊诊单」一致:诊单医助或订单创建人的人事部门落在该展示行子树则计入,多行命中取最深展示行;与处方列表按 assistant_dept_id 筛选后的金额合计一致。 + * 无法归入任一展示部门的金额单列「未归属中心」行;**全表合计行**仍为同时间/业绩条件的全表 SUM(不限部门)。 + * · 时间窗与 stats_order_amount_performance 一致;列表若另有患者筛选或账号数据域收窄,金额可能仍不同 * · 选定渠道时「{渠道}业绩」列仍按比例展示;合计列全量与渠道列含未归属补差;**名称含「二中心」的展示部门不参与该渠道进线/业绩/成交单/ROI 分子** - * - {渠道}业绩 = 与「处方订单列表」业绩同口径(≠4):按 **o.create_time** 落入区间,按「诊单医助 dg.assistant_id」归部门(与处方列表 assistant_dept 一致); + * - {渠道}业绩 = 与「处方订单列表」业绩同口径(≠4):按 **o.create_time** 落入区间,按**业绩归属医助**归部门(与处方列表医助筛选一致); * 渠道判定仅用 EXISTS:"患者有任一已完成挂号 (status=3) 且 channels 命中字典",**不限挂号时点**;无字典映射时回退 tag/creator 口径 - * - 成交单数 = 区间内非取消业务订单 **条数**;**按诊单医助 dg.assistant_id** 映射到「中心」部门(与处方订单列表 assistant_dept_id / assistant_id 筛选同源,非订单 creator_id);无法归属计入「未归属中心」;合计=全表 COUNT。 + * - 成交单数 = 区间内非取消业务订单 **条数**;按**业绩归属医助**映射到「中心」(与处方订单列表医助条件一致);无法归属计入「未归属中心」;合计=全表 COUNT。 * - 投放成本 = zyt_account_cost 在日期区间内 **amount** 合计(选定渠道时 `media_channel_code = channel_code` 过滤)。 * · **各部门行投放成本** = 总投放成本 × (该部门进线 ÷ 各展示部门进线合计)。相当于先算 **单进线成本** = 总成本 ÷ 总进线,再乘部门进线。 * · 选定渠道下二中心部门进线为 0,故该行分摊成本为 0;合计行成本 = 账务汇总总额(部门行分摊之和与之对齐,尾差归入进线最多的部门)。 @@ -282,7 +282,8 @@ class YejiStatsLogic $tagFallback ? $tagAssistantIds : null, $appointmentChannelValues, $channelFilterActive, - $dataScopeAdminIds + $dataScopeAdminIds, + $channelInfo ); // 接诊: // - consults(全量) → "接诊诊单" 列;不限渠道 @@ -295,7 +296,10 @@ class YejiStatsLogic $tagAssistantIds, $appointmentChannelValues, $channelFilterActive, - $dataScopeAdminIds + $dataScopeAdminIds, + $tableRowDeptIds, + $deptById, + $channelInfo ); $consults = $consultsBoth['all']; $channelConsults = $consultsBoth['scoped']; @@ -307,9 +311,18 @@ class YejiStatsLogic $tagAssistantIds, $dataScopeAdminIds ); - // 业绩:与 /tcm.prescriptionOrder/lists 同口径(仅剔除已取消=4,含 NULL) - // - performance(全量) → "合计业绩" 列;不受所选渠道影响 - // - channelPerformance(渠道) → "{渠道}业绩" 列 + ROI 分子;未选渠道时与 performance 相同 + $listAlignedPack = ($tableRowDeptIds !== [] && $deptById !== []) + ? self::aggregateListAlignedOrderMetricsByDept( + $startTs, + $endTs, + $adminToPrimary, + null, + null, + $tableRowDeptIds, + $deptById + ) + : null; + // 业绩:部门行「合计业绩」与列表部门摊行一致;「{渠道}业绩」同列表摊行 + 渠道 EXISTS(与抽屉一致) $perfBoth = self::sumPerformance( $startTs, $endTs, @@ -317,12 +330,26 @@ class YejiStatsLogic $tagDiagIds, $tagFallback ? $tagAssistantIds : null, $appointmentChannelValues, - $channelFilterActive + $channelFilterActive, + $tableRowDeptIds, + $deptById, + $listAlignedPack !== null ? $listAlignedPack['amount'] : null, + $channelInfo ); $performance = $perfBoth['all']; $channelPerformance = $perfBoth['scoped']; - // 成交单数(全量):与合计业绩同 WHERE,按诊单医助归属部门,合计用全表 COUNT - $dealOrderByDept = self::aggregateDealOrderCount($startTs, $endTs, $adminToPrimary, null, null); + // 成交单数(全量):与合计业绩列同列表部门口径 + $dealOrderByDept = $listAlignedPack !== null + ? $listAlignedPack['count'] + : self::aggregateDealOrderCount( + $startTs, + $endTs, + $adminToPrimary, + null, + null, + $tableRowDeptIds, + $deptById + ); $globalDealOrderCount = self::countGlobalDealOrders($startTs, $endTs); /** 选定渠道时从全量渠道 SUM 与部门行中同步扣减「二中心」展示部门上的渠道业绩 */ @@ -430,7 +457,8 @@ class YejiStatsLogic $endTs, $appointmentChannelValues, $tagDiagIds, - $tagFallback ? $tagAssistantIds : null + $tagFallback ? $tagAssistantIds : null, + $channelInfo ); $globalPerfChannel = round(max(0.0, $globalPerfChannel - $erCenterChannelStrip), 1); } @@ -441,6 +469,27 @@ class YejiStatsLogic } $totalBooked = $narrowTotalsToTable ? $bookedSumInTable : array_sum($bookedByDept); + $listAlignedTotalPerf = null; + $listAlignedDealCnt = null; + if ($narrowTotalsToTable) { + $deptForListAlign = $explicitDeptFilter && $primaryDeptIds !== [] ? $primaryDeptIds : null; + $visForListAlign = $dataScopeRestricted && $dataScopeAdminIds !== null && $dataScopeAdminIds !== [] ? $dataScopeAdminIds : null; + if ($deptForListAlign !== null || $visForListAlign !== null) { + $listAlignedTotalPerf = self::sumOrderListAlignedPerformanceAmount( + $startTs, + $endTs, + $deptForListAlign, + $visForListAlign + ); + $listAlignedDealCnt = self::countOrderListAlignedPerformanceOrders( + $startTs, + $endTs, + $deptForListAlign, + $visForListAlign + ); + } + } + if ($narrowTotalsToTable) { $totalConsult = $consultSumInTable; $unmappedDealOrder = 0; @@ -478,9 +527,9 @@ class YejiStatsLogic } if ($narrowTotalsToTable) { - $totalPerf = $allocatedPerf; + $totalPerf = $listAlignedTotalPerf !== null ? $listAlignedTotalPerf : $allocatedPerf; $totalChannelPerf = $allocatedChannelPerf; - $totalDealOrderCount = $allocatedDealOrderSum; + $totalDealOrderCount = $listAlignedDealCnt !== null ? $listAlignedDealCnt : $allocatedDealOrderSum; } else { $totalPerf = $globalPerfAll; $totalChannelPerf = $globalPerfChannel; @@ -496,12 +545,12 @@ class YejiStatsLogic } $scopedTableNote = ($explicitDeptFilter - ? ' 已筛选展示部门时,表格合计仅含所选部门树内数据,不出现「全站未归属」补差行(避免把其他中心业绩/单量算入未归属)。若勾选的是带下级的部门(如某「中心」),表格只列出其下级部门行,不单独占一行父级。' + ? ' 已筛选展示部门时,表格「合计业绩」「接诊诊单」各行为同一套列表部门口径(诊单医助或创建人∪子树、最深展示行);底栏合计业绩/成交单与处方订单列表 extend(所选部门树+数据域)一致,可能与各行相加不同。' : '') . ($dataScopeRestricted - ? ' 当前账号受角色「数据范围」限制,仅统计可见成员及其归属展示中心;合计与表格一致,不含权限范围外数据。' + ? ' 当前账号受数据范围限制时,底栏合计业绩与列表同域。' : ''); - $baseNoteNoChannel = '业绩口径:除履约已取消(4)外全部计入(与订单列表 stats_order_amount_performance 一致)。无法归入「中心」展示部门的诊单医助业绩(及无诊单医助的订单)计入「未归属中心」行;合计=全表 SUM。**投放成本**按进线占比分摊至各部门。'; + $baseNoteNoChannel = '业绩口径:除履约已取消(4)外全部计入。部门行「合计业绩」与「接诊诊单」均为列表 assistant_dept_id 口径(诊单医助或订单创建人人事部门∪子树,多行命中取最深展示行)。多表「本月」区间为当月1日至服务器当日。**投放成本**按进线占比分摊。'; return [ 'start_date' => $startDate, @@ -513,7 +562,7 @@ class YejiStatsLogic 'channel_filter_note' => $channelCode !== '' ? ('{渠道}业绩:与「处方订单列表」业绩同口径,按订单 create_time 归日、仅剔除履约已取消(4)(NULL 与其它状态均计入);渠道判定用"该患者是否有挂号 channels 命中 dict + status=3"(不限挂号时点)。' . ' {渠道}成交单数:与{渠道}业绩同源,仅剔除已取消(4)的订单条数(非挂号条数)。' - . ' 「接诊诊单」与已完成挂号列表同条件(appointment_date、status=3、诊单未软删);部门列按医助→接诊医生归属,无法归属计入「未归属中心」。名称含「二中心」的部门**不计入**该渠道进线、业绩与成交单。**投放成本**按各部门进线条数占展示部门进线合计的比例,从账务总投放金额中分摊(总成本÷总进线=单进线成本)。ROI = 渠道业绩(不含二中心)÷ 总投放成本。进线按所选标签客户精确过滤。' + . ' 「已完成挂号」列同 admin 挂号已完成列表;「接诊诊单」列为业务订单条数(非取消),与列表按 assistant_dept_id 筛选时一致(诊单医助或创建人∪子树)。名称含「二中心」的部门**不计入**该渠道进线、业绩与成交单。**投放成本**按各部门进线条数占展示部门进线合计的比例,从账务总投放金额中分摊(总成本÷总进线=单进线成本)。ROI = 渠道业绩(不含二中心)÷ 总投放成本。进线按所选标签客户精确过滤。' . ($tagFallback ? ' ⚠ 当前业务尚未回填 tcm_diagnosis.external_userid 与 user_auth.unionid,已自动退化为「该标签客户的跟进医助 → 区间内接诊/业绩」口径,仅用于趋势观察。' : '') @@ -564,7 +613,7 @@ class YejiStatsLogic /** * 医助排行榜:按展示部门分表,每表内按诊金降序排名。 - * 诊金口径:无渠道=诊单 dg.assistant_id 业绩(与列表 assistant_id 筛选一致);有渠道={渠道}业绩/dict 诊单医助;二中心在选渠道下注 0。 + * 诊金口径:无渠道=业绩归属医助(与列表医助筛选一致);有渠道={渠道}业绩/dict;二中心在选渠道下注 0。 * 被指派数 = 区间内诊单指派日志(to_assistant_id>0)按**被指派医助**计数,与部门表「被指派数」同收窄/二中心规则。 * 接诊率 = 排行用诊金 ÷ 进线条数(元/进线,1 位小数;进线为 0 时为 0)。 * 复诊(属二中心组织架构的展示部门):同一诊单下业务单按 create_time 排序第 2 笔起分项,与部门业绩表「二中心复诊」同源;前端点击数字可下钻对应业务订单。 @@ -622,7 +671,8 @@ class YejiStatsLogic $tagDiagIds, $scopedAssistants, $appointmentChannelValues, - $channelFilterActive + $channelFilterActive, + $channelInfo ); $feeByAdmin = $channelFilterActive ? $perfBoth['scoped'] : $perfBoth['all']; $dealBoth = self::sumDealOrderByAdmin( @@ -631,7 +681,8 @@ class YejiStatsLogic $tagDiagIds, $scopedAssistants, $appointmentChannelValues, - $channelFilterActive + $channelFilterActive, + $channelInfo ); $dealOrderByAdmin = $channelFilterActive ? $dealBoth['scoped'] : $dealBoth['all']; $leadsByAdmin = self::countLeadsByAdmin($startTs, $endTs, $tagFilterId); @@ -644,7 +695,8 @@ class YejiStatsLogic $tagFallback ? $tagAssistantIds : null, $appointmentChannelValues, $channelFilterActive, - $dataScopeAdminIds + $dataScopeAdminIds, + $channelInfo ); $appointmentByAdmin = self::countBookedAppointmentsByAdminForLeaderboard( $startTs, @@ -695,7 +747,7 @@ class YejiStatsLogic ? ('排行诊金 = 选定渠道「' . $channelName . '」业绩口径(与部门表该渠道业绩列一致)。被指派数与部门表同口径。接诊率 = 诊金 ÷ 进线(元/进线)。' . '「二中心」医助在该渠道下诊金、进线与被指派计 0。' . ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。') - : '排行诊金 = 处方订单列表 assistant_id 口径(诊单医助业绩,与列表按医助筛选一致)。被指派数为指派至该医助的次数。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。' + : '排行诊金 = 处方订单列表医助口径(诊单医助或代建创建人与列表一致)。被指派数为指派至该医助的次数。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。' . ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。'; $leaderboards = []; @@ -1222,6 +1274,287 @@ class YejiStatsLogic return $adminToPrimary; } + /** + * 业绩/成交单按「表格展示部门行」归类:先自 admin 的部门链向上命中第一个 $tableRowDeptIds, + * 否则再向下取「位于该部门子树内的首个表格行」(与勾选父部门时隐藏父行、业绩需落到可见子行一致)。 + * + * @param int[] $adminIds + * @param int[] $tableRowDeptIds + * @param array> $deptById + * + * @return array admin_id => table_row_dept_id + */ + private static function batchMapPerformanceAdminsToTableDept(array $adminIds, array $tableRowDeptIds, array $deptById): array + { + $adminIds = array_values(array_unique(array_filter(array_map('intval', $adminIds), static fn (int $id): bool => $id > 0))); + if ($adminIds === [] || $tableRowDeptIds === []) { + return []; + } + $tableRowSet = array_flip(array_map('intval', $tableRowDeptIds)); + $links = Db::name('admin_dept') + ->whereIn('admin_id', $adminIds) + ->field(['admin_id', 'dept_id']) + ->select() + ->toArray(); + $byAdmin = []; + foreach ($links as $l) { + $aid = (int) $l['admin_id']; + $did = (int) $l['dept_id']; + if ($aid > 0 && $did > 0) { + $byAdmin[$aid][$did] = true; + } + } + $out = []; + foreach ($adminIds as $aid) { + $deptIds = array_keys($byAdmin[$aid] ?? []); + if ($deptIds === []) { + continue; + } + $mapped = self::mapAdminDeptLinksToTableRow($deptIds, $tableRowDeptIds, $tableRowSet, $deptById); + if ($mapped > 0) { + $out[$aid] = $mapped; + } + } + + return $out; + } + + /** + * @param int[] $deptIdsForAdmin + * @param array $tableRowSet + * @param array> $deptById + */ + private static function mapAdminDeptLinksToTableRow(array $deptIdsForAdmin, array $tableRowDeptIds, array $tableRowSet, array $deptById): int + { + $best = 0; + $bestDepth = -1; + foreach ($deptIdsForAdmin as $d) { + $d = (int) $d; + if ($d <= 0) { + continue; + } + $hit = self::resolveDeptToNearestTableRowUpward($d, $tableRowSet, $deptById); + if ($hit > 0) { + $depth = self::deptDepthFromRoot($hit, $deptById); + if ($depth > $bestDepth || ($depth === $bestDepth && ($best === 0 || $hit < $best))) { + $best = $hit; + $bestDepth = $depth; + } + } + } + if ($best > 0) { + return $best; + } + foreach ($deptIdsForAdmin as $d) { + $d = (int) $d; + if ($d <= 0) { + continue; + } + $hit = self::resolveFirstTableRowUnderDept($d, $tableRowDeptIds, $deptById); + if ($hit > 0) { + return $hit; + } + } + + return 0; + } + + private static function deptDepthFromRoot(int $deptId, array $deptById): int + { + $n = 0; + $cur = $deptId; + for ($i = 0; $i < 50 && $cur > 0; $i++) { + $n++; + $cur = (int) ($deptById[$cur]['pid'] ?? 0); + } + + return $n; + } + + /** + * @param array $tableRowSet + * @param array> $deptById + */ + private static function resolveDeptToNearestTableRowUpward(int $deptId, array $tableRowSet, array $deptById): int + { + $cur = $deptId; + for ($i = 0; $i < 50 && $cur > 0; $i++) { + if (isset($tableRowSet[$cur])) { + return $cur; + } + $cur = (int) ($deptById[$cur]['pid'] ?? 0); + } + + return 0; + } + + /** + * $ancestorId 是否为 $deptId 的祖先(含自身相等)。 + * + * @param array> $deptById + */ + private static function deptIsAncestorOf(int $ancestorId, int $deptId, array $deptById): bool + { + $cur = $deptId; + for ($i = 0; $i < 50 && $cur > 0; $i++) { + if ($cur === $ancestorId) { + return true; + } + $cur = (int) ($deptById[$cur]['pid'] ?? 0); + } + + return false; + } + + /** + * @param int[] $tableRowDeptIds + * @param array> $deptById + */ + private static function resolveFirstTableRowUnderDept(int $adminDeptId, array $tableRowDeptIds, array $deptById): int + { + foreach ($tableRowDeptIds as $tid) { + $tid = (int) $tid; + if ($tid > 0 && self::deptIsAncestorOf($adminDeptId, $tid, $deptById)) { + return $tid; + } + } + + return 0; + } + + /** + * 将聚合结果行映射到展示部门:优先 batchMap(父部门仅挂在表格外时仍能落到子行),否则退回 adminToPrimary。 + * + * @param array> $rows query 结果,含 assistant_id + * @param array $adminToPrimary + * @param int[] $tableRowDeptIds + * @param array> $deptById + * @param string $amtKey 数值字段名(金额 amt 或条数 cnt) + * + * @return array + */ + private static function foldPerformanceRowsToDeptByTable( + array $rows, + array $adminToPrimary, + array $tableRowDeptIds, + array $deptById, + string $amtKey + ): array { + if ($rows === []) { + return []; + } + $aids = []; + foreach ($rows as $r) { + $aid = (int) ($r['assistant_id'] ?? 0); + if ($aid > 0) { + $aids[] = $aid; + } + } + $adminToTable = ($tableRowDeptIds !== [] && $deptById !== []) + ? self::batchMapPerformanceAdminsToTableDept($aids, $tableRowDeptIds, $deptById) + : []; + $byDept = []; + foreach ($rows as $r) { + $aid = (int) ($r['assistant_id'] ?? 0); + if ($aid <= 0) { + continue; + } + $primary = (int) ($adminToTable[$aid] ?? 0); + if ($primary <= 0) { + $primary = (int) ($adminToPrimary[$aid] ?? 0); + } + if ($primary <= 0) { + continue; + } + $byDept[$primary] = ($byDept[$primary] ?? 0) + ($amtKey === 'cnt' ? (int) ($r['cnt'] ?? 0) : (float) ($r['amt'] ?? 0)); + } + + return $byDept; + } + + /** + * 接诊挂号 aggregateConsults 结果行 → 表格展示部门:规则同业绩 fold(优先 batchMap 到 tableRow,再 adminToPrimary;先有效医助再接诊医生)。 + * + * @param array> $rows eff_a, doctor_id, cnt + * + * @return array + */ + private static function foldConsultCountRowsToDeptByTable( + array $rows, + array $adminToPrimary, + array $tableRowDeptIds, + array $deptById, + ?array $dataScopeAdminIds + ): array { + if ($rows === []) { + return []; + } + $visFlip = $dataScopeAdminIds !== null && $dataScopeAdminIds !== [] + ? array_flip($dataScopeAdminIds) + : null; + + $aids = []; + foreach ($rows as $r) { + $e = (int) ($r['eff_a'] ?? 0); + $d = (int) ($r['doctor_id'] ?? 0); + if ($e > 0) { + $aids[] = $e; + } + if ($d > 0) { + $aids[] = $d; + } + } + $adminToTable = ($tableRowDeptIds !== [] && $deptById !== []) + ? self::batchMapPerformanceAdminsToTableDept($aids, $tableRowDeptIds, $deptById) + : []; + + $byDept = []; + foreach ($rows as $r) { + $effId = (int) ($r['eff_a'] ?? 0); + $docId = (int) ($r['doctor_id'] ?? 0); + $cnt = (int) ($r['cnt'] ?? 0); + if ($cnt <= 0) { + continue; + } + if ($visFlip !== null) { + if ($effId > 0) { + if (!isset($visFlip[$effId])) { + continue; + } + } elseif ($docId > 0) { + if (!isset($visFlip[$docId])) { + continue; + } + } else { + continue; + } + } + + $primary = 0; + foreach ([$effId, $docId] as $aid) { + if ($aid <= 0) { + continue; + } + $p = (int) ($adminToTable[$aid] ?? 0); + if ($p <= 0) { + $p = (int) ($adminToPrimary[$aid] ?? 0); + } + if ($p > 0) { + $primary = $p; + break; + } + } + + if ($primary > 0) { + $byDept[$primary] = ($byDept[$primary] ?? 0) + $cnt; + } else { + $byDept[0] = ($byDept[0] ?? 0) + $cnt; + } + } + + return $byDept; + } + /** * 进线数据:add_external_contact 事件按 user_id(work_wechat_userid) → admin → primary dept * @@ -1296,7 +1629,8 @@ class YejiStatsLogic ?array $tagAssistantIds, array $appointmentChannelValues, bool $channelFilterActive, - ?array $dataScopeAdminIds = null + ?array $dataScopeAdminIds = null, + ?array $channelInfo = null ): array { if ($adminToPrimary === []) { return []; @@ -1315,15 +1649,11 @@ class YejiStatsLogic if ($norm === []) { return []; } - $apTable = self::tableWithPrefix('doctor_appointment'); - $adminRoleTable = self::tableWithPrefix('admin_role'); - $strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $norm))); - $ph = implode(',', array_fill(0, count($strVals), '?')); - $channelCond = "ap.channels IN ({$ph})"; - $existsSql = "EXISTS (SELECT 1 FROM {$apTable} ap INNER JOIN {$adminRoleTable} ar " - . "ON ar.admin_id = ap.assistant_id AND ar.role_id = 2 WHERE ap.patient_id = dg.id " - . "AND ap.status = 3 AND {$channelCond})"; - $query->whereRaw($existsSql, $strVals); + $pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('dg.id', $norm, $channelInfo); + if ($pack === null) { + return []; + } + $query->whereRaw($pack[0], $pack[1]); } elseif ($tagDiagIds !== null || $tagAssistantIds !== null) { if ($tagDiagIds !== null && $tagDiagIds === []) { return []; @@ -1385,7 +1715,8 @@ class YejiStatsLogic ?array $tagAssistantIds, array $appointmentChannelValues, bool $channelFilterActive, - ?array $dataScopeAdminIds = null + ?array $dataScopeAdminIds = null, + ?array $channelInfo = null ): array { $diagTable = self::tableWithPrefix('tcm_diagnosis'); $query = Db::name('tcm_diagnosis_assign_log') @@ -1400,15 +1731,11 @@ class YejiStatsLogic if ($norm === []) { return []; } - $apTable = self::tableWithPrefix('doctor_appointment'); - $adminRoleTable = self::tableWithPrefix('admin_role'); - $strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $norm))); - $ph = implode(',', array_fill(0, count($strVals), '?')); - $channelCond = "ap.channels IN ({$ph})"; - $existsSql = "EXISTS (SELECT 1 FROM {$apTable} ap INNER JOIN {$adminRoleTable} ar " - . "ON ar.admin_id = ap.assistant_id AND ar.role_id = 2 WHERE ap.patient_id = dg.id " - . "AND ap.status = 3 AND {$channelCond})"; - $query->whereRaw($existsSql, $strVals); + $pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('dg.id', $norm, $channelInfo); + if ($pack === null) { + return []; + } + $query->whereRaw($pack[0], $pack[1]); } elseif ($tagDiagIds !== null || $tagAssistantIds !== null) { if ($tagDiagIds !== null && $tagDiagIds === []) { return []; @@ -1457,7 +1784,7 @@ class YejiStatsLogic /** * 接诊诊单聚合:返回两组按部门归属的接诊计数。 * - all: 区间内 status=3 挂号条数按部门归属(与列表计数一致),不含「仅 role_id=2」等额外限制。 - * - scoped: 选定渠道时「{渠道}成交单」= **非取消业务订单条数**(与 {渠道}业绩同源):有 dict(channels) 走诊单医助+EXISTS 口径;否则走 tag(diagnosis_id IN / **诊单医助** IN)与 aggregatePerformance 完全一致。 + * - scoped: 选定渠道时「{渠道}成交单」= **非取消业务订单条数**(与 {渠道}业绩同源):dict 下 EXISTS 与侧栏一致(channels ∪ channel_source);有展示行时按列表部门摊行;否则走归因折叠。 * 未选渠道时 scoped == all(不再额外查一次)。 * * @param int[] $appointmentChannelValues dict_data value 整型列表(通常 1=自媒体1) @@ -1477,12 +1804,24 @@ class YejiStatsLogic ?array $tagAssistantIds, array $appointmentChannelValues = [], bool $channelFilterActive = false, - ?array $dataScopeAdminIds = null + ?array $dataScopeAdminIds = null, + array $tableRowDeptIds = [], + array $deptById = [], + ?array $channelInfo = null ): array { if ($adminToPrimary === []) { return ['all' => [], 'scoped' => []]; } - $allByDept = self::aggregateConsults($startTs, $endTs, $adminToPrimary, null, null, $dataScopeAdminIds); + $allByDept = self::aggregateConsults( + $startTs, + $endTs, + $adminToPrimary, + null, + null, + $dataScopeAdminIds, + $tableRowDeptIds, + $deptById + ); if (!$channelFilterActive) { return ['all' => $allByDept, 'scoped' => $allByDept]; } @@ -1491,13 +1830,33 @@ class YejiStatsLogic if ($norm === []) { $scopedByDept = []; } else { - // 字典映射渠道:「{渠道}接诊」= 成交业务单数(非取消订单),与 aggregatePerformanceByAppointmentChannels 同源,非挂号条数 - $scopedByDept = self::aggregateDealOrderCountByAppointmentChannels( - $startTs, - $endTs, - $adminToPrimary, - $norm - ); + $pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('o.diagnosis_id', $norm, $channelInfo); + if ($pack === null) { + $scopedByDept = []; + } elseif ($tableRowDeptIds !== [] && $deptById !== []) { + $scopedAligned = self::aggregateListAlignedOrderMetricsByDept( + $startTs, + $endTs, + $adminToPrimary, + null, + null, + $tableRowDeptIds, + $deptById, + $pack[0], + $pack[1] + ); + $scopedByDept = $scopedAligned['count']; + } else { + $scopedByDept = self::aggregateDealOrderCountByAppointmentChannels( + $startTs, + $endTs, + $adminToPrimary, + $norm, + $tableRowDeptIds, + $deptById, + $channelInfo + ); + } } } elseif ($tagDiagIds !== null || $tagAssistantIds !== null) { $scopedByDept = self::aggregateDealOrderCount( @@ -1505,7 +1864,9 @@ class YejiStatsLogic $endTs, $adminToPrimary, $tagDiagIds, - $tagAssistantIds + $tagAssistantIds, + $tableRowDeptIds, + $deptById ); } else { $scopedByDept = []; @@ -1526,7 +1887,9 @@ class YejiStatsLogic array $adminToPrimary, ?array $tagDiagIds, ?array $tagAssistantIds, - ?array $dataScopeAdminIds = null + ?array $dataScopeAdminIds = null, + array $tableRowDeptIds = [], + array $deptById = [] ): array { if ($tagDiagIds !== null && $tagDiagIds === []) { return []; @@ -1559,6 +1922,9 @@ class YejiStatsLogic if ($rows === []) { return []; } + if ($tableRowDeptIds !== [] && $deptById !== []) { + return self::foldConsultCountRowsToDeptByTable($rows, $adminToPrimary, $tableRowDeptIds, $deptById, $dataScopeAdminIds); + } $visFlip = $dataScopeAdminIds !== null && $dataScopeAdminIds !== [] ? array_flip($dataScopeAdminIds) @@ -2583,7 +2949,8 @@ class YejiStatsLogic ?array $tagDiagIds, ?array $tagAssistantIdsForScoped, array $appointmentChannelValues, - bool $channelFilterActive + bool $channelFilterActive, + ?array $channelInfo = null ): array { $all = self::aggregatePerformanceAmountsByAdmin($startTs, $endTs, null, null); if (!$channelFilterActive) { @@ -2594,7 +2961,7 @@ class YejiStatsLogic if ($norm === []) { $scoped = []; } else { - $scoped = self::aggregatePerformanceAmountsByAssistantChannel($startTs, $endTs, $norm); + $scoped = self::aggregatePerformanceAmountsByAssistantChannel($startTs, $endTs, $norm, $channelInfo); } } elseif ($tagDiagIds !== null || $tagAssistantIdsForScoped !== null) { $scoped = self::aggregatePerformanceAmountsByAdmin($startTs, $endTs, $tagDiagIds, $tagAssistantIdsForScoped); @@ -2618,7 +2985,8 @@ class YejiStatsLogic ?array $tagDiagIds, ?array $tagAssistantIdsForScoped, array $appointmentChannelValues, - bool $channelFilterActive + bool $channelFilterActive, + ?array $channelInfo = null ): array { $all = self::aggregateDealOrderCountGroupedByAssistant($startTs, $endTs, null, null); if (!$channelFilterActive) { @@ -2629,7 +2997,7 @@ class YejiStatsLogic if ($norm === []) { $scoped = []; } else { - $scoped = self::aggregateDealOrderCountByAssistantChannel($startTs, $endTs, $norm); + $scoped = self::aggregateDealOrderCountByAssistantChannel($startTs, $endTs, $norm, $channelInfo); } } elseif ($tagDiagIds !== null || $tagAssistantIdsForScoped !== null) { $scoped = self::aggregateDealOrderCountGroupedByAssistant($startTs, $endTs, $tagDiagIds, $tagAssistantIdsForScoped); @@ -2641,7 +3009,7 @@ class YejiStatsLogic } /** - * 成交单数按 dg.assistant_id 聚合(与 aggregatePerformanceAmountsByAdmin 同一 WHERE,计条数非金额)。 + * 成交单数按业绩归属医助聚合(与 aggregatePerformanceAmountsByAdmin 同一 WHERE,计条数非金额)。 * * @return array */ @@ -2658,23 +3026,25 @@ class YejiStatsLogic return []; } $diagTable = self::tableWithPrefix('tcm_diagnosis'); + $att = self::sqlPerformanceAttributionAdminExpr(); $query = Db::name('tcm_prescription_order') ->alias('o') ->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER') ->whereNull('o.delete_time') ->where('o.create_time', 'between', [$startTs, $endTs]) - ->where('o.diagnosis_id', '>', 0) - ->where('dg.assistant_id', '>', 0); + ->where('o.diagnosis_id', '>', 0); + self::joinPrescriptionForPerformanceAttribution($query); self::applyPrescriptionOrderNotCancelledForPerformance($query); - $query->field(['dg.assistant_id', 'COUNT(*) AS cnt']) - ->group('dg.assistant_id'); - + $query->whereRaw("({$att}) > 0", []); if ($tagDiagIds !== null) { $query->whereIn('o.diagnosis_id', $tagDiagIds); } if ($tagAssistantIds !== null) { - $query->whereIn('dg.assistant_id', array_keys($tagAssistantIds)); + $ids = array_map('intval', array_keys($tagAssistantIds)); + $query->whereRaw("({$att}) IN (" . implode(',', $ids) . ')'); } + $query->field([Db::raw("({$att}) AS assistant_id"), Db::raw('COUNT(*) AS cnt')]) + ->group($att); $rows = $query->select()->toArray(); if ($rows === []) { @@ -2702,34 +3072,34 @@ class YejiStatsLogic private static function aggregateDealOrderCountByAssistantChannel( int $startTs, int $endTs, - array $appointmentChannelValues + array $appointmentChannelValues, + ?array $channelInfo = null ): array { if ($appointmentChannelValues === []) { return []; } - $apTable = self::tableWithPrefix('doctor_appointment'); + $norm = self::normalizeAppointmentChannelValues($appointmentChannelValues); + $pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('o.diagnosis_id', $norm, $channelInfo); + if ($pack === null) { + return []; + } + $existsSql = $pack[0]; + $existsBindings = $pack[1]; $diagTable = self::tableWithPrefix('tcm_diagnosis'); - $adminRoleTable = self::tableWithPrefix('admin_role'); - $strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $appointmentChannelValues))); - $ph = implode(',', array_fill(0, count($strVals), '?')); - $channelCond = "ap.channels IN ({$ph})"; - - $existsSql = "EXISTS (SELECT 1 FROM {$apTable} ap INNER JOIN {$adminRoleTable} ar " - . "ON ar.admin_id = ap.assistant_id AND ar.role_id = 2 WHERE ap.patient_id = o.diagnosis_id " - . "AND ap.status = 3 AND {$channelCond})"; - $existsBindings = $strVals; $query = Db::name('tcm_prescription_order') ->alias('o') ->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER') ->whereNull('o.delete_time') ->where('o.create_time', 'between', [$startTs, $endTs]); + self::joinPrescriptionForPerformanceAttribution($query); self::applyPrescriptionOrderNotCancelledForPerformance($query); + $att = self::sqlPerformanceAttributionAdminExpr(); $query->where('o.diagnosis_id', '>', 0) - ->where('dg.assistant_id', '>', 0) + ->whereRaw("({$att}) > 0", []) ->whereRaw($existsSql, $existsBindings) - ->field(['dg.assistant_id', 'COUNT(*) AS cnt']) - ->group('dg.assistant_id'); + ->field([Db::raw("({$att}) AS assistant_id"), Db::raw('COUNT(*) AS cnt')]) + ->group($att); $rows = $query->select()->toArray(); if ($rows === []) { @@ -2748,7 +3118,7 @@ class YejiStatsLogic } /** - * 业绩按 dg.assistant_id 聚合到 admin(与处方订单列表 assistant_id 筛选口径一致)。 + * 业绩按归属医助聚合到 admin(与处方订单列表医助筛选口径一致:诊单医助或代建创建人)。 * * @return array */ @@ -2765,23 +3135,25 @@ class YejiStatsLogic return []; } $diagTable = self::tableWithPrefix('tcm_diagnosis'); + $att = self::sqlPerformanceAttributionAdminExpr(); $query = Db::name('tcm_prescription_order') ->alias('o') ->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER') ->whereNull('o.delete_time') ->where('o.create_time', 'between', [$startTs, $endTs]) - ->where('o.diagnosis_id', '>', 0) - ->where('dg.assistant_id', '>', 0); + ->where('o.diagnosis_id', '>', 0); + self::joinPrescriptionForPerformanceAttribution($query); self::applyPrescriptionOrderNotCancelledForPerformance($query); - $query->field(['dg.assistant_id', 'SUM(o.amount) AS amt']) - ->group('dg.assistant_id'); - + $query->whereRaw("({$att}) > 0", []); if ($tagDiagIds !== null) { $query->whereIn('o.diagnosis_id', $tagDiagIds); } if ($tagAssistantIds !== null) { - $query->whereIn('dg.assistant_id', array_keys($tagAssistantIds)); + $ids = array_map('intval', array_keys($tagAssistantIds)); + $query->whereRaw("({$att}) IN (" . implode(',', $ids) . ')'); } + $query->field([Db::raw("({$att}) AS assistant_id"), Db::raw('SUM(o.amount) AS amt')]) + ->group($att); $rows = $query->select()->toArray(); if ($rows === []) { @@ -2800,7 +3172,7 @@ class YejiStatsLogic } /** - * dict(channels) 渠道业绩按诊单医助 assistant_id 聚合(与 aggregatePerformanceByAppointmentChannels 同源)。 + * dict(channels) 渠道业绩按业绩归属医助聚合(与 aggregatePerformanceByAppointmentChannels 同源)。 * * @param int[] $appointmentChannelValues * @@ -2809,34 +3181,34 @@ class YejiStatsLogic private static function aggregatePerformanceAmountsByAssistantChannel( int $startTs, int $endTs, - array $appointmentChannelValues + array $appointmentChannelValues, + ?array $channelInfo = null ): array { if ($appointmentChannelValues === []) { return []; } - $apTable = self::tableWithPrefix('doctor_appointment'); + $norm = self::normalizeAppointmentChannelValues($appointmentChannelValues); + $pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('o.diagnosis_id', $norm, $channelInfo); + if ($pack === null) { + return []; + } + $existsSql = $pack[0]; + $existsBindings = $pack[1]; $diagTable = self::tableWithPrefix('tcm_diagnosis'); - $adminRoleTable = self::tableWithPrefix('admin_role'); - $strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $appointmentChannelValues))); - $ph = implode(',', array_fill(0, count($strVals), '?')); - $channelCond = "ap.channels IN ({$ph})"; - - $existsSql = "EXISTS (SELECT 1 FROM {$apTable} ap INNER JOIN {$adminRoleTable} ar " - . "ON ar.admin_id = ap.assistant_id AND ar.role_id = 2 WHERE ap.patient_id = o.diagnosis_id " - . "AND ap.status = 3 AND {$channelCond})"; - $existsBindings = $strVals; $query = Db::name('tcm_prescription_order') ->alias('o') ->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER') ->whereNull('o.delete_time') ->where('o.create_time', 'between', [$startTs, $endTs]); + self::joinPrescriptionForPerformanceAttribution($query); self::applyPrescriptionOrderNotCancelledForPerformance($query); + $att = self::sqlPerformanceAttributionAdminExpr(); $query->where('o.diagnosis_id', '>', 0) - ->where('dg.assistant_id', '>', 0) + ->whereRaw("({$att}) > 0", []) ->whereRaw($existsSql, $existsBindings) - ->field(['dg.assistant_id', 'SUM(o.amount) AS amt']) - ->group('dg.assistant_id'); + ->field([Db::raw("({$att}) AS assistant_id"), Db::raw('SUM(o.amount) AS amt')]) + ->group($att); $rows = $query->select()->toArray(); if ($rows === []) { @@ -2920,6 +3292,26 @@ class YejiStatsLogic self::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, $tableAlias); } + /** + * 业绩按医助归属:须 alias 订单 o、诊单 dg,并调用本方法 left join 处方 rx(rx.id = o.prescription_id) + */ + private static function joinPrescriptionForPerformanceAttribution($query): void + { + $rxTable = self::tableWithPrefix('tcm_prescription'); + $query->leftJoin("{$rxTable} rx", 'rx.id = o.prescription_id AND rx.delete_time IS NULL'); + } + + /** + * 业绩归属医助 admin_id SQL 表达式(表别名固定为 o / dg / rx)。 + * 与 PrescriptionOrderLists 医助筛选一致:诊单 assistant_id 优先;否则订单创建人且非关联处方开方人。 + */ + private static function sqlPerformanceAttributionAdminExpr(): string + { + return 'CASE WHEN dg.assistant_id > 0 THEN dg.assistant_id ' + . 'WHEN o.creator_id > 0 AND (rx.id IS NULL OR NOT (o.creator_id <=> rx.creator_id)) THEN o.creator_id ' + . 'ELSE 0 END'; + } + /** * 处方订单业绩全量合计(不按部门归属截断),与订单列表在**同一 create_time 区间、同一业绩条件**下的 SUM(amount) 对齐。 */ @@ -2934,6 +3326,159 @@ class YejiStatsLogic return round((float) $query->sum('o.amount'), 1); } + /** + * 看板「合计业绩」某部门行对应的业绩归属医助 id(与 overview 中 foldPerformanceRowsToDeptByTable 摊行一致)。 + * $yejiDeptIdsParam 与看板本次请求的 dept_ids 一致(未选则为 null)。 + * + * @param int|string|int[]|string[]|null $yejiDeptIdsParam + * + * @return int[] + */ + public static function collectAdminIdsAttributedToYejiTableRow(int $tableRowDeptId, $yejiDeptIdsParam): array + { + if ($tableRowDeptId <= 0) { + return []; + } + [$primaryDeptIds, , $tableRowDeptIds, $deptById] = self::resolvePrimaryDepts($yejiDeptIdsParam); + if ($tableRowDeptIds === []) { + return []; + } + $deptToDescendants = self::buildDeptDescendants($primaryDeptIds); + $adminToPrimary = self::buildAdminToPrimaryDept($primaryDeptIds, $deptToDescendants); + + $links = Db::name('admin_dept')->field(['admin_id', 'dept_id'])->select()->toArray(); + $byAdmin = []; + foreach ($links as $l) { + $aid = (int) $l['admin_id']; + $did = (int) $l['dept_id']; + if ($aid > 0 && $did > 0) { + $byAdmin[$aid][$did] = true; + } + } + $candidateIds = array_values(array_unique(array_merge(array_keys($byAdmin), array_keys($adminToPrimary)))); + $tableRowSet = array_flip(array_map('intval', $tableRowDeptIds)); + $out = []; + foreach ($candidateIds as $aid) { + $aid = (int) $aid; + if ($aid <= 0) { + continue; + } + $deptIdsForAdmin = array_keys($byAdmin[$aid] ?? []); + $mapped = 0; + if ($deptIdsForAdmin !== []) { + $mapped = self::mapAdminDeptLinksToTableRow($deptIdsForAdmin, $tableRowDeptIds, $tableRowSet, $deptById); + } + if ($mapped <= 0) { + $mapped = (int) ($adminToPrimary[$aid] ?? 0); + } + if ($mapped === $tableRowDeptId) { + $out[] = $aid; + } + } + + return array_values(array_unique($out)); + } + + /** + * 与处方订单列表 buildBaseQueryForStats(仅人事筛选维度)对齐的订单查询 WHERE。 + * + * @param int[]|null $expandedDeptIds + * @param int[]|null $visibleAdminIds + */ + private static function applyOrderListStatsVisibilityToPrescriptionQuery( + $query, + ?array $expandedDeptIds, + ?array $visibleAdminIds + ): void { + $hasDept = $expandedDeptIds !== null && $expandedDeptIds !== []; + $hasVis = $visibleAdminIds !== null && $visibleAdminIds !== []; + if (!$hasDept && !$hasVis) { + return; + } + $diagTable = self::tableWithPrefix('tcm_diagnosis'); + $adTable = self::tableWithPrefix('admin_dept'); + if ($hasDept) { + $ids = array_values(array_unique(array_filter(array_map('intval', $expandedDeptIds), static function (int $x): bool { + return $x > 0; + }))); + if ($ids === []) { + $query->whereRaw('0 = 1'); + + return; + } + $inList = implode(',', $ids); + $query->where(function ($q) use ($diagTable, $adTable, $inList) { + $q->whereExists( + "SELECT 1 FROM {$adTable} ad INNER JOIN {$diagTable} dg ON ad.admin_id = dg.assistant_id AND dg.delete_time IS NULL " + . "WHERE dg.id = o.diagnosis_id AND ad.dept_id IN ({$inList})" + ); + $q->whereExists( + "SELECT 1 FROM {$adTable} adc WHERE adc.admin_id = o.creator_id AND adc.dept_id IN ({$inList})", + 'OR' + ); + }); + } + if ($hasVis) { + $ids = array_values(array_unique(array_filter(array_map('intval', $visibleAdminIds), static function (int $x): bool { + return $x > 0; + }))); + if ($ids === []) { + $query->whereRaw('0 = 1'); + + return; + } + $inList = implode(',', $ids); + $query->where(function ($q) use ($diagTable, $inList, $ids) { + $q->whereIn('o.creator_id', $ids); + $q->whereOrRaw( + "EXISTS (SELECT 1 FROM {$diagTable} dg WHERE dg.id = o.diagnosis_id " + . "AND dg.delete_time IS NULL AND dg.assistant_id IN ({$inList}))" + ); + }); + } + } + + /** + * 收窄合计时与订单列表 extend「业绩金额」一致(create_time、NOT(fulfillment<=>4)、人事筛选)。 + * + * @param int[]|null $expandedDeptIds + * @param int[]|null $visibleAdminIds + */ + private static function sumOrderListAlignedPerformanceAmount( + int $startTs, + int $endTs, + ?array $expandedDeptIds, + ?array $visibleAdminIds + ): float { + $query = Db::name('tcm_prescription_order') + ->alias('o') + ->whereNull('o.delete_time') + ->where('o.create_time', 'between', [$startTs, $endTs]); + self::applyPrescriptionOrderNotCancelledForPerformance($query); + self::applyOrderListStatsVisibilityToPrescriptionQuery($query, $expandedDeptIds, $visibleAdminIds); + + return round((float) $query->sum('o.amount'), 1); + } + + /** + * 与 sumOrderListAlignedPerformanceAmount 同源,计订单条数。 + */ + private static function countOrderListAlignedPerformanceOrders( + int $startTs, + int $endTs, + ?array $expandedDeptIds, + ?array $visibleAdminIds + ): int { + $query = Db::name('tcm_prescription_order') + ->alias('o') + ->whereNull('o.delete_time') + ->where('o.create_time', 'between', [$startTs, $endTs]); + self::applyPrescriptionOrderNotCancelledForPerformance($query); + self::applyOrderListStatsVisibilityToPrescriptionQuery($query, $expandedDeptIds, $visibleAdminIds); + + return (int) $query->count(); + } + /** * 标签/医助兜底口径下的渠道向业绩全量 SUM(与 sumPerformance 的 scoped 分支同一组 WHERE,无部门归属过滤)。 */ @@ -2950,19 +3495,22 @@ class YejiStatsLogic return 0.0; } $diagTable = self::tableWithPrefix('tcm_diagnosis'); + $att = self::sqlPerformanceAttributionAdminExpr(); $query = Db::name('tcm_prescription_order') ->alias('o') ->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER') ->whereNull('o.delete_time') ->where('o.create_time', 'between', [$startTs, $endTs]) - ->where('o.diagnosis_id', '>', 0) - ->where('dg.assistant_id', '>', 0); + ->where('o.diagnosis_id', '>', 0); + self::joinPrescriptionForPerformanceAttribution($query); self::applyPrescriptionOrderNotCancelledForPerformance($query); + $query->whereRaw("({$att}) > 0", []); if ($tagDiagIds !== null) { $query->whereIn('o.diagnosis_id', $tagDiagIds); } if ($tagAssistantIds !== null) { - $query->whereIn('dg.assistant_id', array_keys($tagAssistantIds)); + $ids = array_map('intval', array_keys($tagAssistantIds)); + $query->whereRaw("({$att}) IN (" . implode(',', $ids) . ')'); } return round((float) $query->sum('o.amount'), 1); @@ -2971,31 +3519,38 @@ class YejiStatsLogic /** * dict(channels) 映射下渠道业绩全量 SUM(与 aggregatePerformanceByAppointmentChannels 同源,不按 primary dept 汇总)。 */ - private static function sumGlobalPerformanceAppointmentChannels(int $startTs, int $endTs, array $appointmentChannelValues): float - { + private static function sumGlobalPerformanceAppointmentChannels( + int $startTs, + int $endTs, + array $appointmentChannelValues, + ?array $channelInfo = null + ): float { if ($appointmentChannelValues === []) { return 0.0; } - $apTable = self::tableWithPrefix('doctor_appointment'); + $norm = self::normalizeAppointmentChannelValues($appointmentChannelValues); + if ($norm === []) { + return 0.0; + } + $pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('o.diagnosis_id', $norm, $channelInfo); + if ($pack === null) { + return 0.0; + } $diagTable = self::tableWithPrefix('tcm_diagnosis'); - $adminRoleTable = self::tableWithPrefix('admin_role'); - $strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $appointmentChannelValues))); - $ph = implode(',', array_fill(0, count($strVals), '?')); - $channelCond = "ap.channels IN ({$ph})"; - $existsSql = "EXISTS (SELECT 1 FROM {$apTable} ap INNER JOIN {$adminRoleTable} ar " - . "ON ar.admin_id = ap.assistant_id AND ar.role_id = 2 WHERE ap.patient_id = o.diagnosis_id " - . "AND ap.status = 3 AND {$channelCond})"; - $existsBindings = $strVals; + $existsSql = $pack[0]; + $existsBindings = $pack[1]; $query = Db::name('tcm_prescription_order') ->alias('o') ->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER') ->whereNull('o.delete_time') ->where('o.create_time', 'between', [$startTs, $endTs]); + self::joinPrescriptionForPerformanceAttribution($query); self::applyPrescriptionOrderNotCancelledForPerformance($query); + $att = self::sqlPerformanceAttributionAdminExpr(); $query->where('o.diagnosis_id', '>', 0) - ->where('dg.assistant_id', '>', 0) + ->whereRaw("({$att}) > 0", []) ->whereRaw($existsSql, $existsBindings); return round((float) $query->sum('o.amount'), 1); @@ -3009,7 +3564,8 @@ class YejiStatsLogic int $endTs, array $appointmentChannelValues, ?array $tagDiagIds, - ?array $tagAssistantIdsForScoped + ?array $tagAssistantIdsForScoped, + ?array $channelInfo = null ): float { if ($appointmentChannelValues !== []) { $norm = self::normalizeAppointmentChannelValues($appointmentChannelValues); @@ -3017,7 +3573,7 @@ class YejiStatsLogic return 0.0; } - return self::sumGlobalPerformanceAppointmentChannels($startTs, $endTs, $norm); + return self::sumGlobalPerformanceAppointmentChannels($startTs, $endTs, $norm, $channelInfo); } if ($tagDiagIds !== null || $tagAssistantIdsForScoped !== null) { return self::sumGlobalPerformanceTagScoped($startTs, $endTs, $tagDiagIds, $tagAssistantIdsForScoped); @@ -3046,8 +3602,7 @@ class YejiStatsLogic * 业绩 = SUM(amount) WHERE NOT (fulfillment_status <=> 4) AND delete_time IS NULL * * - all: 部门全量业绩(不限渠道)。用于「合计业绩」列展示。 - * - scoped: 「{渠道}业绩」与 ROI 分子:优先挂号 channels(dict);否则 tag;已选渠道但两者皆不可用时为空。 - * 未选渠道时 scoped == all(不再额外查一次)。 + * - scoped: 「{渠道}业绩」:患者渠道 EXISTS 与侧栏/抽屉一致(channels ∪ channel_source);有展示行时部门列与「合计业绩」同为列表部门摊行。 * * @return array{ * all: array, @@ -3061,13 +3616,46 @@ class YejiStatsLogic ?array $tagDiagIds, ?array $tagAssistantIds = null, array $appointmentChannelValues = [], - bool $channelFilterActive = false + bool $channelFilterActive = false, + array $tableRowDeptIds = [], + array $deptById = [], + ?array $precomputedListAlignedAmountByDept = null, + ?array $channelInfo = null ): array { if ($adminToPrimary === []) { return ['all' => [], 'scoped' => []]; } - // 1) 部门全量业绩(≠4),任何场景都跑一次 → "合计业绩" - $allByDept = self::aggregatePerformance($startTs, $endTs, $adminToPrimary, null, null); + // 1) 部门「合计业绩」:有展示行时与「接诊诊单」同列表部门口径(可由 overview 预计算避免重复查单) + if ($precomputedListAlignedAmountByDept !== null) { + $allByDept = []; + foreach ($precomputedListAlignedAmountByDept as $deptId => $v) { + $allByDept[(int) $deptId] = round((float) $v, 1); + } + } elseif ($tableRowDeptIds !== [] && $deptById !== []) { + $aligned = self::aggregateListAlignedOrderMetricsByDept( + $startTs, + $endTs, + $adminToPrimary, + null, + null, + $tableRowDeptIds, + $deptById + ); + $allByDept = []; + foreach ($aligned['amount'] as $deptId => $v) { + $allByDept[(int) $deptId] = round((float) $v, 1); + } + } else { + $allByDept = self::aggregatePerformance( + $startTs, + $endTs, + $adminToPrimary, + null, + null, + $tableRowDeptIds, + $deptById + ); + } if (!$channelFilterActive) { return ['all' => $allByDept, 'scoped' => $allByDept]; @@ -3078,12 +3666,36 @@ class YejiStatsLogic if ($norm === []) { $scopedByDept = []; } else { - $scopedByDept = self::aggregatePerformanceByAppointmentChannels( - $startTs, - $endTs, - $adminToPrimary, - $norm - ); + $pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('o.diagnosis_id', $norm, $channelInfo); + if ($pack === null) { + $scopedByDept = []; + } elseif ($tableRowDeptIds !== [] && $deptById !== []) { + $scopedAligned = self::aggregateListAlignedOrderMetricsByDept( + $startTs, + $endTs, + $adminToPrimary, + null, + null, + $tableRowDeptIds, + $deptById, + $pack[0], + $pack[1] + ); + $scopedByDept = []; + foreach ($scopedAligned['amount'] as $deptId => $v) { + $scopedByDept[(int) $deptId] = round((float) $v, 1); + } + } else { + $scopedByDept = self::aggregatePerformanceByAppointmentChannels( + $startTs, + $endTs, + $adminToPrimary, + $norm, + $tableRowDeptIds, + $deptById, + $channelInfo + ); + } } } elseif ($tagDiagIds !== null || $tagAssistantIds !== null) { $scopedByDept = self::aggregatePerformance( @@ -3091,7 +3703,9 @@ class YejiStatsLogic $endTs, $adminToPrimary, $tagDiagIds, - $tagAssistantIds + $tagAssistantIds, + $tableRowDeptIds, + $deptById ); } else { $scopedByDept = []; @@ -3103,59 +3717,43 @@ class YejiStatsLogic /** * 渠道业绩:与「处方订单列表」业绩完全同口径 —— 按 o.create_time 归日、仅剔除已取消(4)(含 NULL); * EXISTS 仅用于"该患者属于此渠道"的判定(有任一挂号 channels 命中 + status=3,**不限挂号时点**); - * 部门归属按「诊单医助 dg.assistant_id」(与处方列表 assistant_dept 过滤条件一致)。 + * 部门归属按业绩归属医助(与处方列表医助条件一致:诊单医助或代建创建人)。 */ private static function aggregatePerformanceByAppointmentChannels( int $startTs, int $endTs, array $adminToPrimary, - array $appointmentChannelValues + array $appointmentChannelValues, + array $tableRowDeptIds = [], + array $deptById = [], + ?array $channelInfo = null ): array { if ($appointmentChannelValues === [] || $adminToPrimary === []) { return []; } - $apTable = self::tableWithPrefix('doctor_appointment'); + $norm = self::normalizeAppointmentChannelValues($appointmentChannelValues); + $pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('o.diagnosis_id', $norm, $channelInfo); + if ($pack === null) { + return []; + } $diagTable = self::tableWithPrefix('tcm_diagnosis'); - $adminRoleTable = self::tableWithPrefix('admin_role'); - $strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $appointmentChannelValues))); - $ph = implode(',', array_fill(0, count($strVals), '?')); - $channelCond = "ap.channels IN ({$ph})"; - - $existsSql = "EXISTS (SELECT 1 FROM {$apTable} ap INNER JOIN {$adminRoleTable} ar " - . "ON ar.admin_id = ap.assistant_id AND ar.role_id = 2 WHERE ap.patient_id = o.diagnosis_id " - . "AND ap.status = 3 AND {$channelCond})"; - $existsBindings = $strVals; - + $att = self::sqlPerformanceAttributionAdminExpr(); $query = Db::name('tcm_prescription_order') ->alias('o') ->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER') ->whereNull('o.delete_time') ->where('o.create_time', 'between', [$startTs, $endTs]); + self::joinPrescriptionForPerformanceAttribution($query); self::applyPrescriptionOrderNotCancelledForPerformance($query); $query->where('o.diagnosis_id', '>', 0) - ->where('dg.assistant_id', '>', 0) - ->whereRaw($existsSql, $existsBindings) - ->field(['dg.assistant_id', 'SUM(o.amount) AS amt']) - ->group('dg.assistant_id'); + ->whereRaw("({$att}) > 0", []) + ->whereRaw($pack[0], $pack[1]) + ->field([Db::raw("({$att}) AS assistant_id"), Db::raw('SUM(o.amount) AS amt')]) + ->group($att); $rows = $query->select()->toArray(); - if ($rows === []) { - return []; - } - $byDept = []; - foreach ($rows as $r) { - $aid = (int) ($r['assistant_id'] ?? 0); - if ($aid <= 0) { - continue; - } - $primary = (int) ($adminToPrimary[$aid] ?? 0); - if ($primary <= 0) { - continue; - } - $byDept[$primary] = ($byDept[$primary] ?? 0) + (float) $r['amt']; - } - return $byDept; + return self::foldPerformanceRowsToDeptByTable($rows, $adminToPrimary, $tableRowDeptIds, $deptById, 'amt'); } /** @@ -3168,65 +3766,56 @@ class YejiStatsLogic int $startTs, int $endTs, array $adminToPrimary, - array $appointmentChannelValues + array $appointmentChannelValues, + array $tableRowDeptIds = [], + array $deptById = [], + ?array $channelInfo = null ): array { if ($appointmentChannelValues === [] || $adminToPrimary === []) { return []; } - $apTable = self::tableWithPrefix('doctor_appointment'); + $norm = self::normalizeAppointmentChannelValues($appointmentChannelValues); + $pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('o.diagnosis_id', $norm, $channelInfo); + if ($pack === null) { + return []; + } $diagTable = self::tableWithPrefix('tcm_diagnosis'); - $adminRoleTable = self::tableWithPrefix('admin_role'); - $strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $appointmentChannelValues))); - $ph = implode(',', array_fill(0, count($strVals), '?')); - $channelCond = "ap.channels IN ({$ph})"; - - $existsSql = "EXISTS (SELECT 1 FROM {$apTable} ap INNER JOIN {$adminRoleTable} ar " - . "ON ar.admin_id = ap.assistant_id AND ar.role_id = 2 WHERE ap.patient_id = o.diagnosis_id " - . "AND ap.status = 3 AND {$channelCond})"; - $existsBindings = $strVals; - + $att = self::sqlPerformanceAttributionAdminExpr(); $query = Db::name('tcm_prescription_order') ->alias('o') ->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER') ->whereNull('o.delete_time') ->where('o.create_time', 'between', [$startTs, $endTs]); + self::joinPrescriptionForPerformanceAttribution($query); self::applyPrescriptionOrderNotCancelledForPerformance($query); $query->where('o.diagnosis_id', '>', 0) - ->where('dg.assistant_id', '>', 0) - ->whereRaw($existsSql, $existsBindings) - ->field(['dg.assistant_id', 'COUNT(o.id) AS cnt']) - ->group('dg.assistant_id'); + ->whereRaw("({$att}) > 0", []) + ->whereRaw($pack[0], $pack[1]) + ->field([Db::raw("({$att}) AS assistant_id"), Db::raw('COUNT(o.id) AS cnt')]) + ->group($att); $rows = $query->select()->toArray(); - if ($rows === []) { - return []; - } - $byDept = []; - foreach ($rows as $r) { - $aid = (int) ($r['assistant_id'] ?? 0); - if ($aid <= 0) { - continue; - } - $primary = (int) ($adminToPrimary[$aid] ?? 0); - if ($primary <= 0) { - continue; - } - $byDept[$primary] = ($byDept[$primary] ?? 0) + (int) $r['cnt']; - } - return $byDept; + return self::foldPerformanceRowsToDeptByTable($rows, $adminToPrimary, $tableRowDeptIds, $deptById, 'cnt'); } /** * 单次业绩聚合:与 aggregatePerformanceAmountsByAdmin / aggregateDealOrderCount 同源 —— - * INNER 诊单,按 dg.assistant_id SUM(amount) 后映射到展示「中心」(与 PrescriptionOrderLists assistant_dept 一致)。 - * WHERE: delete_time IS NULL、NOT(fulfillment <=> 4)、create_time 区间、diagnosis_id>0、dg.assistant_id>0; - * 可选 tag:diagnosis_id IN、dg.assistant_id IN。 + * INNER 诊单,按业绩归属医助 SUM(amount) 后映射到展示「中心」。 + * WHERE: delete_time IS NULL、NOT(fulfillment <=> 4)、create_time 区间、diagnosis_id>0、归属医助>0; + * 可选 tag:diagnosis_id IN、归属医助 IN。 * * @return array */ - private static function aggregatePerformance(int $startTs, int $endTs, array $adminToPrimary, ?array $tagDiagIds, ?array $tagAssistantIds): array - { + private static function aggregatePerformance( + int $startTs, + int $endTs, + array $adminToPrimary, + ?array $tagDiagIds, + ?array $tagAssistantIds, + array $tableRowDeptIds = [], + array $deptById = [] + ): array { if ($tagDiagIds !== null && $tagDiagIds === []) { return []; } @@ -3234,42 +3823,29 @@ class YejiStatsLogic return []; } $diagTable = self::tableWithPrefix('tcm_diagnosis'); + $att = self::sqlPerformanceAttributionAdminExpr(); $query = Db::name('tcm_prescription_order') ->alias('o') ->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER') ->whereNull('o.delete_time') ->where('o.create_time', 'between', [$startTs, $endTs]) - ->where('o.diagnosis_id', '>', 0) - ->where('dg.assistant_id', '>', 0); + ->where('o.diagnosis_id', '>', 0); + self::joinPrescriptionForPerformanceAttribution($query); self::applyPrescriptionOrderNotCancelledForPerformance($query); - $query->field(['dg.assistant_id', 'SUM(o.amount) AS amt']) - ->group('dg.assistant_id'); - + $query->whereRaw("({$att}) > 0", []); if ($tagDiagIds !== null) { $query->whereIn('o.diagnosis_id', $tagDiagIds); } if ($tagAssistantIds !== null) { - $query->whereIn('dg.assistant_id', array_keys($tagAssistantIds)); + $ids = array_map('intval', array_keys($tagAssistantIds)); + $query->whereRaw("({$att}) IN (" . implode(',', $ids) . ')'); } + $query->field([Db::raw("({$att}) AS assistant_id"), Db::raw('SUM(o.amount) AS amt')]) + ->group($att); $rows = $query->select()->toArray(); - if ($rows === []) { - return []; - } - $byDept = []; - foreach ($rows as $r) { - $aid = (int) ($r['assistant_id'] ?? 0); - if ($aid <= 0) { - continue; - } - $primary = (int) ($adminToPrimary[$aid] ?? 0); - if ($primary <= 0) { - continue; - } - $byDept[$primary] = ($byDept[$primary] ?? 0) + (float) $r['amt']; - } - return $byDept; + return self::foldPerformanceRowsToDeptByTable($rows, $adminToPrimary, $tableRowDeptIds, $deptById, 'amt'); } /** @@ -3287,8 +3863,250 @@ class YejiStatsLogic } /** - * 成交单数按部门:关联诊单,按 dg.assistant_id 分组后映射到展示「中心」(与 PrescriptionOrderLists::applyDoctorAssistantFilters assistant_dept 一致)。 - * 仅统计诊单医助 > 0 的订单行;诊单无医助或医助无法映射的不计入各部门(由 global − sum 归入未归属中心)。 + * 全量部门 org 树:pid => 子部门 id 列表(缓存一次请求内多次 aggregate 复用)。 + * + * @return array + */ + private static function yejiFullDeptChildrenOf(): array + { + static $cache = null; + if ($cache !== null) { + return $cache; + } + $rows = Db::name('dept')->whereNull('delete_time')->field(['id', 'pid'])->select()->toArray(); + $childrenOf = []; + foreach ($rows as $r) { + $pid = (int) $r['pid']; + $childrenOf[$pid][] = (int) $r['id']; + } + + return $cache = $childrenOf; + } + + /** + * @return array + */ + private static function yejiDeptSubtreeIdFlip(int $rootDeptId, array $childrenOf): array + { + $flip = []; + $stack = [$rootDeptId]; + while ($stack !== []) { + $cur = (int) array_pop($stack); + if ($cur <= 0 || isset($flip[$cur])) { + continue; + } + $flip[$cur] = true; + foreach ($childrenOf[$cur] ?? [] as $cid) { + $stack[] = (int) $cid; + } + } + + return $flip; + } + + /** + * 与 {@see PrescriptionOrderLists::applyDoctorAssistantFilters} 中 assistant_dept_id(未开抽屉对齐开关)一致: + * 诊单医助或订单创建人的人事部门落在该行子树即命中;命中多行时取组织深度最大的展示行。 + * + * @param array $candidates admin_id => true + * @param array $adminDeptIdList + * @param array> $rowSubtreeFlips + * @param int[] $tableRowDeptIds + * @param array> $deptById + * + * @return int 表格展示部门 id,无法归属则为 0 + */ + private static function resolveDeepestYejiTableRowForListDeptOr( + array $candidates, + array $adminDeptIdList, + array $rowSubtreeFlips, + array $tableRowDeptIds, + array $deptById, + array $adminToPrimary + ): int { + if ($candidates === []) { + return 0; + } + /** @var int[] $matching */ + $matching = []; + foreach ($tableRowDeptIds as $rid) { + $rid = (int) $rid; + if ($rid <= 0) { + continue; + } + $flip = $rowSubtreeFlips[$rid] ?? []; + if ($flip === []) { + continue; + } + foreach ($candidates as $aid => $_) { + $aid = (int) $aid; + if ($aid <= 0) { + continue; + } + foreach ($adminDeptIdList[$aid] ?? [] as $d) { + $d = (int) $d; + if ($d > 0 && isset($flip[$d])) { + $matching[] = $rid; + break 2; + } + } + } + } + if ($matching === []) { + foreach ($candidates as $aid => $_) { + $p = (int) ($adminToPrimary[(int) $aid] ?? 0); + if ($p > 0) { + foreach ($tableRowDeptIds as $rid) { + if ((int) $rid === $p) { + return $p; + } + } + } + } + + return 0; + } + $best = 0; + $bestDepth = -1; + foreach ($matching as $rid) { + $depth = self::deptDepthFromRoot($rid, $deptById); + if ($depth > $bestDepth || ($depth === $bestDepth && ($best === 0 || $rid < $best))) { + $best = $rid; + $bestDepth = $depth; + } + } + + return $best; + } + + /** + * 列表部门口径(诊单医助 ∪ 创建人 ⇄ 展示行子树,最深行):同时聚单笔数与金额,供「接诊诊单」与「合计业绩」列共用。 + * + * @return array{count: array, amount: array} + */ + private static function aggregateListAlignedOrderMetricsByDept( + int $startTs, + int $endTs, + array $adminToPrimary, + ?array $tagDiagIds, + ?array $tagAssistantIds, + array $tableRowDeptIds, + array $deptById, + ?string $channelScopedExistsSql = null, + array $channelScopedExistsBindings = [] + ): array { + if ($tagDiagIds !== null && $tagDiagIds === []) { + return ['count' => [], 'amount' => []]; + } + if ($tagAssistantIds !== null && $tagAssistantIds === []) { + return ['count' => [], 'amount' => []]; + } + if ($tableRowDeptIds === [] || $deptById === []) { + return ['count' => [], 'amount' => []]; + } + + $diagTable = self::tableWithPrefix('tcm_diagnosis'); + $att = self::sqlPerformanceAttributionAdminExpr(); + $query = Db::name('tcm_prescription_order') + ->alias('o') + ->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER') + ->whereNull('o.delete_time') + ->where('o.create_time', 'between', [$startTs, $endTs]) + ->where('o.diagnosis_id', '>', 0); + self::joinPrescriptionForPerformanceAttribution($query); + self::applyPrescriptionOrderNotCancelledForPerformance($query); + $query->whereRaw('(dg.assistant_id > 0 OR o.creator_id > 0)'); + if ($tagDiagIds !== null) { + $query->whereIn('o.diagnosis_id', $tagDiagIds); + } + if ($tagAssistantIds !== null) { + $ids = array_map('intval', array_keys($tagAssistantIds)); + $in = implode(',', $ids); + $query->whereRaw("(({$att}) IN ({$in}) OR dg.assistant_id IN ({$in}) OR o.creator_id IN ({$in}))"); + } + if ($channelScopedExistsSql !== null && $channelScopedExistsSql !== '') { + $query->whereRaw($channelScopedExistsSql, $channelScopedExistsBindings); + } + + $orderRows = $query->field([ + Db::raw('dg.assistant_id AS diag_assistant_id'), + 'o.creator_id', + 'o.amount', + ])->select()->toArray(); + if ($orderRows === []) { + return ['count' => [], 'amount' => []]; + } + + $childrenOf = self::yejiFullDeptChildrenOf(); + /** @var array> $rowSubtreeFlips */ + $rowSubtreeFlips = []; + foreach ($tableRowDeptIds as $rid) { + $rid = (int) $rid; + if ($rid > 0) { + $rowSubtreeFlips[$rid] = self::yejiDeptSubtreeIdFlip($rid, $childrenOf); + } + } + + $adminIds = []; + foreach ($orderRows as $r) { + $a = (int) ($r['diag_assistant_id'] ?? 0); + $c = (int) ($r['creator_id'] ?? 0); + if ($a > 0) { + $adminIds[] = $a; + } + if ($c > 0) { + $adminIds[] = $c; + } + } + $adminIds = array_values(array_unique(array_filter($adminIds, static fn (int $x): bool => $x > 0))); + /** @var array $adminDeptIdList */ + $adminDeptIdList = []; + if ($adminIds !== []) { + $links = Db::name('admin_dept')->whereIn('admin_id', $adminIds)->field(['admin_id', 'dept_id'])->select()->toArray(); + foreach ($links as $l) { + $aid = (int) $l['admin_id']; + $did = (int) $l['dept_id']; + if ($aid > 0 && $did > 0) { + $adminDeptIdList[$aid][] = $did; + } + } + } + + $byCnt = []; + $byAmt = []; + foreach ($orderRows as $r) { + $asst = (int) ($r['diag_assistant_id'] ?? 0); + $cr = (int) ($r['creator_id'] ?? 0); + $cand = []; + if ($asst > 0) { + $cand[$asst] = true; + } + if ($cr > 0) { + $cand[$cr] = true; + } + $deptKey = self::resolveDeepestYejiTableRowForListDeptOr( + $cand, + $adminDeptIdList, + $rowSubtreeFlips, + $tableRowDeptIds, + $deptById, + $adminToPrimary + ); + if ($deptKey <= 0) { + continue; + } + $amt = (float) ($r['amount'] ?? 0); + $byCnt[$deptKey] = ($byCnt[$deptKey] ?? 0) + 1; + $byAmt[$deptKey] = ($byAmt[$deptKey] ?? 0.0) + $amt; + } + + return ['count' => $byCnt, 'amount' => $byAmt]; + } + + /** + * 成交单数按部门:**处方订单列表**按 assistant_dept_id 筛选时的业务单条数(诊单医助或创建人 ∪ 部门子树), + * 与 {@see PrescriptionOrderLists::applyDoctorAssistantFilters} 在 传 yeji_drawer_match_table_performance 时一致。 + * 选定医助 tag 时收窄为(业绩归属医助 ∈ 集 **或** 诊单医助 ∈ 集 **或** 创建人 ∈ 集)。 * * @return array */ @@ -3297,7 +4115,9 @@ class YejiStatsLogic int $endTs, array $adminToPrimary, ?array $tagDiagIds, - ?array $tagAssistantIds + ?array $tagAssistantIds, + array $tableRowDeptIds = [], + array $deptById = [] ): array { if ($tagDiagIds !== null && $tagDiagIds === []) { return []; @@ -3305,43 +4125,44 @@ class YejiStatsLogic if ($tagAssistantIds !== null && $tagAssistantIds === []) { return []; } + if ($tableRowDeptIds !== [] && $deptById !== []) { + return self::aggregateListAlignedOrderMetricsByDept( + $startTs, + $endTs, + $adminToPrimary, + $tagDiagIds, + $tagAssistantIds, + $tableRowDeptIds, + $deptById + )['count']; + } + $diagTable = self::tableWithPrefix('tcm_diagnosis'); + $att = self::sqlPerformanceAttributionAdminExpr(); $query = Db::name('tcm_prescription_order') ->alias('o') ->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER') ->whereNull('o.delete_time') ->where('o.create_time', 'between', [$startTs, $endTs]) - ->where('o.diagnosis_id', '>', 0) - ->where('dg.assistant_id', '>', 0); + ->where('o.diagnosis_id', '>', 0); + self::joinPrescriptionForPerformanceAttribution($query); self::applyPrescriptionOrderNotCancelledForPerformance($query); - $query->field(['dg.assistant_id', 'COUNT(*) AS cnt']) - ->group('dg.assistant_id'); - + $query->whereRaw('(dg.assistant_id > 0 OR o.creator_id > 0)'); if ($tagDiagIds !== null) { $query->whereIn('o.diagnosis_id', $tagDiagIds); } if ($tagAssistantIds !== null) { - $query->whereIn('dg.assistant_id', array_keys($tagAssistantIds)); + $ids = array_map('intval', array_keys($tagAssistantIds)); + $in = implode(',', $ids); + $query->whereRaw("(({$att}) IN ({$in}) OR dg.assistant_id IN ({$in}) OR o.creator_id IN ({$in}))"); } + $query->whereRaw("({$att}) > 0", []); + $query->field([Db::raw("({$att}) AS assistant_id"), Db::raw('COUNT(*) AS cnt')]) + ->group($att); $rows = $query->select()->toArray(); - if ($rows === []) { - return []; - } - $byDept = []; - foreach ($rows as $r) { - $aid = (int) ($r['assistant_id'] ?? 0); - if ($aid <= 0) { - continue; - } - $primary = (int) ($adminToPrimary[$aid] ?? 0); - if ($primary <= 0) { - continue; - } - $byDept[$primary] = ($byDept[$primary] ?? 0) + (int) $r['cnt']; - } - return $byDept; + return self::foldPerformanceRowsToDeptByTable($rows, $adminToPrimary, $tableRowDeptIds, $deptById, 'cnt'); } /** @@ -3783,6 +4604,79 @@ class YejiStatsLogic return self::$doctorAppointmentHasChannelSourceColumn; } + /** @var bool|null 进程内缓存:doctor_appointment 是否存在 channels 列 */ + private static ?bool $doctorAppointmentHasChannelsColumnCache = null; + + private static function doctorAppointmentHasChannelsColumn(): bool + { + if (self::$doctorAppointmentHasChannelsColumnCache !== null) { + return self::$doctorAppointmentHasChannelsColumnCache; + } + try { + $fields = Db::name('doctor_appointment')->getTableFields(); + self::$doctorAppointmentHasChannelsColumnCache = is_array($fields) + && in_array('channels', $fields, true); + } catch (\Throwable) { + self::$doctorAppointmentHasChannelsColumnCache = false; + } + + return self::$doctorAppointmentHasChannelsColumnCache; + } + + /** + * 选定 dict(channels) 渠道时:患者存在已完成挂号,且挂号助理为医助(role_id=2),且 (ap.channels 或 ap.channel_source) 命中配置, + * 与 {@see self::applyYejiDrawerChannelFilterToPrescriptionOrderQuery} 一致。 + * + * @param string $patientIdExpr 与 ap.patient_id 比较的 SQL 表达式,如 o.diagnosis_id、dg.id + * + * @return array{0: string, 1: array}|null 无可用工件列时返回 null + */ + private static function buildChannelScopedAppointmentExistsSqlAndBindings( + string $patientIdExpr, + array $normInts, + ?array $channelInfo + ): ?array { + $normInts = self::normalizeAppointmentChannelValues($normInts); + $strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $normInts))); + $srcFromInfo = self::appointmentChannelDictStringValuesForDrawer($channelInfo); + $allSrcStrings = array_values(array_unique(array_filter( + array_merge($strVals, $srcFromInfo), + static fn (string $s): bool => $s !== '' + ))); + + $hasCh = self::doctorAppointmentHasChannelsColumn(); + $hasSrc = self::doctorAppointmentHasChannelSourceColumn(); + + $parts = []; + $bind = []; + if ($hasCh && $strVals !== []) { + $ph = implode(',', array_fill(0, count($strVals), '?')); + $parts[] = "ap.channels IN ({$ph})"; + foreach ($strVals as $sv) { + $bind[] = $sv; + } + } + if ($hasSrc && $allSrcStrings !== []) { + $ph = implode(',', array_fill(0, count($allSrcStrings), '?')); + $parts[] = "ap.channel_source IN ({$ph})"; + foreach ($allSrcStrings as $s) { + $bind[] = $s; + } + } + if ($parts === []) { + return null; + } + + $apTable = self::tableWithPrefix('doctor_appointment'); + $adminRoleTable = self::tableWithPrefix('admin_role'); + $innerOr = implode(' OR ', $parts); + $sql = "EXISTS (SELECT 1 FROM {$apTable} ap INNER JOIN {$adminRoleTable} ar " + . "ON ar.admin_id = ap.assistant_id AND ar.role_id = 2 WHERE ap.patient_id = {$patientIdExpr} " + . "AND ap.status = 3 AND ({$innerOr}))"; + + return [$sql, $bind]; + } + /** * 业绩看板业务订单侧栏:按 channel_code 收窄(与 overview「{渠道}业绩」同患者判定;含 ap.channels 与 ap.channel_source) * diff --git a/server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php b/server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php index 7a9d8434..cf54b27c 100644 --- a/server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php +++ b/server/app/adminapi/logic/tcm/PrescriptionOrderLogic.php @@ -1000,6 +1000,11 @@ class PrescriptionOrderLogic } } + $channelDictValue = null; + if ($chSrc !== '' && is_numeric($chSrc)) { + $channelDictValue = (int) $chSrc; + } + return [ 'id' => $apptId, 'appointment_date' => (string) ($row['appointment_date'] ?? ''), @@ -1012,6 +1017,8 @@ class PrescriptionOrderLogic 'doctor_name' => $doctorName, 'channel_source' => $chSrc, 'channel_source_desc' => $chDesc, + /** 字典 type_value=channels 的 value(整型);与业绩看板挂号 channels 收窄、进线统计所用字典为同套 */ + 'channel_dict_value' => $channelDictValue, 'remark' => (string) ($row['remark'] ?? ''), ]; } diff --git a/server/app/command/ExpressAutoUpdate.php b/server/app/command/ExpressAutoUpdate.php index d99a782a..16e960b9 100644 --- a/server/app/command/ExpressAutoUpdate.php +++ b/server/app/command/ExpressAutoUpdate.php @@ -17,6 +17,9 @@ use think\console\Output; * * 配置 crontab(每 10 分钟):拉快递 100 + 按履约「已发货/已签收」核对释放诊单医助(与是否甘草单无关) * 0,10,20,30,40,50 * * * * cd /path/to/server && php think express:auto-update >> /dev/null 2>&1 + * + * 已对非二中心医助执行发货/签收自动释放的诊单会写入 tcm_diagnosis.shipped_non_er_assistant_cleared_at, + * 后续履约核对不再重复扫描(需先执行 sql/1.9.20260507/add_diagnosis_shipped_non_er_assistant_cleared_at.sql)。 */ class ExpressAutoUpdate extends Command { @@ -34,7 +37,7 @@ class ExpressAutoUpdate extends Command try { $result = ExpressTrackingService::autoUpdateBatch(500); - $recon = ExpressTrackingService::reconcileAssistantReleaseForShippedPrescriptionOrders(200); + $recon = ExpressTrackingService::reconcileAssistantReleaseForShippedPrescriptionOrders(500); $duration = round(microtime(true) - $startTime, 2); diff --git a/server/app/command/GancaoSyncLogisticsRoute.php b/server/app/command/GancaoSyncLogisticsRoute.php index 08235960..2ef043f6 100644 --- a/server/app/command/GancaoSyncLogisticsRoute.php +++ b/server/app/command/GancaoSyncLogisticsRoute.php @@ -28,6 +28,9 @@ use think\console\Output; * * 建议 crontab(每 30 分钟执行一次): * 0,30 * * * * cd /path/to/server && php think gancao:sync-logistics --limit=200 >> runtime/log/gancao_sync.log 2>&1 + * + * 已对非二中心医助执行发货/签收自动释放的诊单会写入 tcm_diagnosis.shipped_non_er_assistant_cleared_at, + * 后续履约核对不再重复扫描(需先执行 sql/1.9.20260507/add_diagnosis_shipped_non_er_assistant_cleared_at.sql)。 */ class GancaoSyncLogisticsRoute extends Command { diff --git a/server/app/common/service/ExpressTrackingService.php b/server/app/common/service/ExpressTrackingService.php index ceabd520..a09989cc 100644 --- a/server/app/common/service/ExpressTrackingService.php +++ b/server/app/common/service/ExpressTrackingService.php @@ -13,6 +13,7 @@ use app\common\model\ExpressStateLog; use app\common\model\ExpressQueryLog; use app\common\model\tcm\Diagnosis; use app\common\model\tcm\DiagnosisAssignLog; +use app\common\model\tcm\Prescription; use app\common\model\tcm\PrescriptionOrder; use app\common\model\tcm\PrescriptionOrderLog; use think\facade\Db; @@ -250,6 +251,7 @@ class ExpressTrackingService * 快递 100 定时拉轨迹、甘草路由、甘草回调与履约核对共用同一规则。 * 例外:指派日志中若存在「从某医助改派到另一医助」(原、新医助 ID 均大于 0)的记录,视为人工指派链,自动释放跳过。 * 例外:患者当前医助在「二中心」及其全部子部门(与后台部门树一致)下任职时,不自动清空医助(按 admin_dept 与 dept 树判定)。 + * 非二中心且已自动释放:诊单写入 shipped_non_er_assistant_cleared_at,assistant_id 已为 0 时后续履约核对不再重复扫描(重新指派医助后可再次处理)。 * * @param array{tracking_id?:int,tracking_number?:string,source?:string} $meta * @return array{ @@ -288,7 +290,26 @@ class ExpressTrackingService if (!$diag) { return null; } + $shippedClearedAt = (int) ($diag->getAttr('shipped_non_er_assistant_cleared_at') ?? 0); + $diagAssistantIdNow = (int) ($diag->getAttr('assistant_id') ?? 0); + if ($shippedClearedAt > 0 && $diagAssistantIdNow === 0) { + return null; + } + // 诊单 assistant_id 与列表「医助」筛选一致:医助常代建单但诊单未写 assistant_id,此时用创建人作为待释放身份(创建人即开方医生时不采用,避免误记指派日志) $fromAssistantId = (int) ($diag->getAttr('assistant_id') ?? 0); + if ($fromAssistantId <= 0) { + $creatorId = (int) ($order->getAttr('creator_id') ?? 0); + if ($creatorId > 0) { + $rxId = (int) ($order->getAttr('prescription_id') ?? 0); + $rxCreator = 0; + if ($rxId > 0) { + $rxCreator = (int) Prescription::where('id', $rxId)->whereNull('delete_time')->value('creator_id'); + } + if ($rxId <= 0 || $creatorId !== $rxCreator) { + $fromAssistantId = $creatorId; + } + } + } if ($fromAssistantId <= 0) { return null; } @@ -328,7 +349,10 @@ class ExpressTrackingService Db::startTrans(); try { - Diagnosis::where('id', $diagnosisId)->whereNull('delete_time')->update(['assistant_id' => 0]); + Diagnosis::where('id', $diagnosisId)->whereNull('delete_time')->update([ + 'assistant_id' => 0, + 'shipped_non_er_assistant_cleared_at' => time(), + ]); DiagnosisAssignLog::create([ 'diagnosis_id' => $diagnosisId, @@ -624,19 +648,29 @@ class ExpressTrackingService /** * 按处方订单履约核对:已发货(5)/已签收(6) 时诊单仍挂在医助的,一律释放并写指派日志。 * 与快递 100 / 甘草来自哪个物流渠道无关,保障「已发货」后患者侧医助被清掉。 + * 已对非二中心医助做过发货/签收释放且 assistant_id=0 的诊单(shipped_non_er_assistant_cleared_at>0)不再纳入本扫描,避免重复命中 creator 分支。 * * @return array{scanned: int, cleared: int, lines: list} */ public static function reconcileAssistantReleaseForShippedPrescriptionOrders(int $limit = 200): array { $dTable = (new Diagnosis())->getTable(); + $rxTable = (new Prescription())->getTable(); $orders = PrescriptionOrder::alias('po') ->join($dTable . ' d', 'd.id = po.diagnosis_id') + ->leftJoin($rxTable . ' rx', 'rx.id = po.prescription_id AND rx.delete_time IS NULL') ->whereNull('po.delete_time') ->whereNull('d.delete_time') + ->whereRaw('(IFNULL(d.shipped_non_er_assistant_cleared_at, 0) = 0 OR d.assistant_id > 0)') ->whereIn('po.fulfillment_status', [5, 6]) ->where('po.diagnosis_id', '>', 0) - ->where('d.assistant_id', '>', 0) + ->where(function ($w) { + $w->where('d.assistant_id', '>', 0) + ->whereOr(function ($w2) { + $w2->where('po.creator_id', '>', 0) + ->whereRaw('(rx.id IS NULL OR po.creator_id <> rx.creator_id)'); + }); + }) ->order('po.id', 'asc') ->limit($limit) ->field('po.*') diff --git a/server/sql/1.9.20260507/add_diagnosis_shipped_non_er_assistant_cleared_at.sql b/server/sql/1.9.20260507/add_diagnosis_shipped_non_er_assistant_cleared_at.sql new file mode 100644 index 00000000..ca8bbbf6 --- /dev/null +++ b/server/sql/1.9.20260507/add_diagnosis_shipped_non_er_assistant_cleared_at.sql @@ -0,0 +1,6 @@ +-- 非二中心医助因业务订单已发货/已签收被自动释放后打标,履约核对等任务不再重复处理同一诊单(人工重新指派医助后可再次参与) + +ALTER TABLE `zyt_tcm_diagnosis` + ADD COLUMN `shipped_non_er_assistant_cleared_at` int(10) unsigned NOT NULL DEFAULT 0 + COMMENT '非二中心医助·发货/签收自动释放诊单医助的时间戳;>0 且 assistant_id=0 时跳过重复核对' + AFTER `assistant_id`;