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 @@
- 仅已完成(status=3),与 admin「医生预约 / 已完成」列表计数条件一致(appointment_date、诊单未软删);归属规则同「接诊诊单」列数据源说明。
+ 仅已完成(status=3),与 admin「医生预约 / 已完成」列表计数条件一致(appointment_date、诊单未软删);部门归属医助优先,否则接诊医生。
@@ -511,8 +511,8 @@
- 与「处方订单列表」在 assistant_dept_id 下条数同源:订单 create_time 落入区间、履约 fulfillment_status ≠ 4;
- 按诊单 医助 assistant_id(经 admin_dept 映射到展示「中心」),非订单 creator_id。选定渠道时本列仍为全量。
+ 业务订单条数(非取消):订单 create_time 落入区间、fulfillment_status ≠ 4;与列表筛选
+ assistant_dept_id 时一致——诊单 医助或订单 创建人的人事部门落在该部门子树即计入(表格多行命中时取最深的展示部门)。与侧栏勾选「与表格业绩对齐」时的集合可能略有差异。选定渠道时本列仍为全量。
@@ -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) }}
+ )
+
+
+
+
+
+ 「字典 value」= 系统字典
+ channels 的 value,与业绩表里按渠道收窄时所用挂号 channels一致;便于和进线列所选渠道对照,不是企微
+ add_external_contact 单上的独立编号。
+
+
+
+
—
@@ -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`;