This commit is contained in:
Your Name
2026-05-08 15:20:02 +08:00
parent dfbb6d82b3
commit 623bda2ecb
5 changed files with 271 additions and 98 deletions
+8 -7
View File
@@ -411,7 +411,7 @@
<div style="max-width: 320px; line-height: 1.7; font-size: 12px"> <div style="max-width: 320px; line-height: 1.7; font-size: 12px">
add_external_contact 事件数按接待企微成员归属部门定渠道时须客户带对应企微标签<br /> add_external_contact 事件数按接待企微成员归属部门定渠道时须客户带对应企微标签<br />
<template v-if="tb.channel_name"> <template v-if="tb.channel_name">
<b>名称含二中心的部门</b>在选定渠道下进线计 <b>0</b> <b>二中心及其组织下级</b>与部门 ErCenter 子树一致在选定渠道下进线计 <b>0</b>
</template> </template>
</div> </div>
</template> </template>
@@ -423,9 +423,9 @@
<el-tooltip placement="top" effect="dark" :show-after="200"> <el-tooltip placement="top" effect="dark" :show-after="200">
<template #content> <template #content>
<div style="max-width: 320px; line-height: 1.7; font-size: 12px"> <div style="max-width: 320px; line-height: 1.7; font-size: 12px">
诊单指派日志条成功指派<b>to_assistant_id &gt; 0</b><b>被指派医助</b>归属展示部门<template v-if="tb.channel_name"> <b>tcm_diagnosis_assign_log</b> 成功指派<b>to_assistant_id &gt; 0</b>**仅按 <b>related_po_create_time</b>** 落在所选区间内与日志表快照一致诊单手动指派已写入该字段<b>被指派医助</b>归属展示部门<template v-if="tb.channel_name">
选定渠道时收窄与{{ tb.channel_name }}业绩同口径标签诊单或 channels EXISTS<br /> 选定渠道时收窄与{{ tb.channel_name }}业绩同口径标签诊单或 channels EXISTS<br />
<b>名称含二中心的部门</b>在选定渠道下本列计 <b>0</b> <b>二中心及其组织下级</b>在选定渠道下本列计 <b>0</b>
</template> </template>
<template v-else><br />未选渠道时不限客户来源合计业绩列渠道无关</template> <template v-else><br />未选渠道时不限客户来源合计业绩列渠道无关</template>
</div> </div>
@@ -547,11 +547,12 @@
<template #content> <template #content>
<div style="max-width: 320px; line-height: 1.7; font-size: 12px"> <div style="max-width: 320px; line-height: 1.7; font-size: 12px">
<template v-if="tb.channel_name"> <template v-if="tb.channel_name">
有渠道时<b>合计行 ROI</b> = <b>{{ tb.channel_name }}业绩合计</b>与上表该渠道业绩列合计一致<b>不含名称含二中心的部门</b>÷ <b>投放成本合计</b><br /> 有渠道时<b>合计行 ROI</b> = <b>{{ tb.channel_name }}业绩合计</b>与上表该渠道业绩列合计一致<b>不含二中心及其组织下级</b>÷ <b>投放成本合计</b><br />
部门行 ROI = 该部门 <b>{{ tb.channel_name }}业绩</b> ÷ 该部门 <b>投放成本</b>二中心部门该渠道业绩与进线为 0合计业绩列无关 部门行 ROI = 该部门 <b>{{ tb.channel_name }}业绩</b> ÷ 该部门 <b>投放成本</b><b>二中心子树</b>部门该渠道业绩与进线为 0投放成本分摊进线计 0故无 ROI合计业绩列无关
</template> </template>
<template v-else> <template v-else>
<b>合计业绩</b> ÷ <b>投放成本</b>投放成本按各部门<b>进线条数占比</b>从总成本中分摊 <b>合计 ROI</b> = 全表业绩合计<b>不含二中心子树</b>各部门行业绩÷ <b>投放成本合计</b><br />
部门行 = 该部门合计业绩口径业绩 ÷ 该部门均摊投放成本<b>二中心子树</b>部门不参与成本分摊进线占比计 0故无 ROI
</template> </template>
</div> </div>
</template> </template>
@@ -564,7 +565,7 @@
<template #content> <template #content>
<div style="max-width: 320px; line-height: 1.7; font-size: 12px"> <div style="max-width: 320px; line-height: 1.7; font-size: 12px">
区间与渠道与账务表一致后得到<b>总投放成本</b><b>单进线成本</b> = 总成本 ÷ 各展示部门<b>进线合计</b><br /> 区间与渠道与账务表一致后得到<b>总投放成本</b><b>单进线成本</b> = 总成本 ÷ 各展示部门<b>进线合计</b><br />
各部门行投放成本 = 单进线成本 × 该部门进线四舍五入尾差并入进线最多的部门 各部门行投放成本 = 单进线成本 × 该部门进线四舍五入尾差并入进线最多的部门<b>二中心及其组织下级</b>进线在分摊中计 <b>0</b> ROI 分子扣除范围一致
</div> </div>
</template> </template>
<el-icon class="col-info"><InfoFilled /></el-icon> <el-icon class="col-info"><InfoFilled /></el-icon>
@@ -12,6 +12,25 @@
{{ assistantLogCellText(row, 'to') }} {{ assistantLogCellText(row, 'to') }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="快照·业务单创建人" min-width="130" show-overflow-tooltip>
<template #default="{ row }">
{{ relatedPoCreatorCell(row) }}
</template>
</el-table-column>
<el-table-column label="快照·业务单创建时间" width="190">
<template #header>
<span>快照·业务单创建时间</span>
<el-tooltip
placement="top"
content="related_po_create_time:触发本次操作的处方业务订单 create_time;与原文助一致时表示医助创建订单时间"
>
<el-icon class="assign-log-col-hint"><QuestionFilled /></el-icon>
</el-tooltip>
</template>
<template #default="{ row }">
{{ relatedPoCreateTimeCell(row) }}
</template>
</el-table-column>
<el-table-column label="操作人" width="110" prop="operator_name" /> <el-table-column label="操作人" width="110" prop="operator_name" />
<el-table-column label="操作账号" width="120" prop="operator_account" show-overflow-tooltip /> <el-table-column label="操作账号" width="120" prop="operator_account" show-overflow-tooltip />
<el-table-column label="IP" width="130" prop="ip" show-overflow-tooltip /> <el-table-column label="IP" width="130" prop="ip" show-overflow-tooltip />
@@ -20,6 +39,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { QuestionFilled } from '@element-plus/icons-vue'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { tcmDiagnosisAssignLogList } from '@/api/tcm' import { tcmDiagnosisAssignLogList } from '@/api/tcm'
@@ -30,6 +50,35 @@ const props = defineProps<{
const loading = ref(false) const loading = ref(false)
const rows = ref<any[]>([]) const rows = ref<any[]>([])
/** 对应日志表 related_po_creator_id:触发本次释放/指派的业务订单 creator */
function relatedPoCreatorCell(row: Record<string, unknown>): string {
const name = row.related_po_creator_name
if (name != null && String(name).trim() !== '') {
return String(name)
}
const id = Number(row.related_po_creator_id)
if (Number.isFinite(id) && id > 0) {
return `ID:${id}`
}
return '—'
}
/** 对应日志表 related_po_create_timeUnix)格式化为可读时间 */
function relatedPoCreateTimeCell(row: Record<string, unknown>): string {
const t = row.related_po_create_time_text
if (t != null && String(t).trim() !== '') {
return String(t)
}
const ts = Number(row.related_po_create_time)
if (!Number.isFinite(ts) || ts <= 0) {
return '—'
}
const d = new Date(ts * 1000)
if (Number.isNaN(d.getTime())) return '—'
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
function assistantLogCellText(row: Record<string, unknown>, side: 'from' | 'to'): string { function assistantLogCellText(row: Record<string, unknown>, side: 'from' | 'to'): string {
const nameKey = side === 'from' ? 'from_assistant_name' : 'to_assistant_name' const nameKey = side === 'from' ? 'from_assistant_name' : 'to_assistant_name'
const idKey = side === 'from' ? 'from_assistant_id' : 'to_assistant_id' const idKey = side === 'from' ? 'from_assistant_id' : 'to_assistant_id'
@@ -69,3 +118,12 @@ watch(
defineExpose({ refresh: load }) defineExpose({ refresh: load })
</script> </script>
<style scoped>
.assign-log-col-hint {
margin-left: 4px;
vertical-align: middle;
color: var(--el-text-color-secondary);
cursor: help;
}
</style>
+4 -5
View File
@@ -1390,13 +1390,12 @@ const handleConfirmAssign = async () => {
feedback.msgWarning('没有可指派的诊单,请关闭后重新勾选') feedback.msgWarning('没有可指派的诊单,请关闭后重新勾选')
return return
} }
const promises = ids.map((id) => for (const id of ids) {
tcmDiagnosisAssign({ await tcmDiagnosisAssign({
id, id,
assistant_id: assignForm.value.assistant_id assistant_id: assignForm.value.assistant_id!,
}) })
) }
await Promise.all(promises)
selectedIds.value = [] selectedIds.value = []
batchAssignIds.value = [] batchAssignIds.value = []
} }
@@ -15,9 +15,9 @@ use think\facade\Db;
* 业绩看板(部门 × 时间区间 × 渠道来源) * 业绩看板(部门 × 时间区间 × 渠道来源)
* *
* - 进线数据 = 区间内 add_external_contact 事件,按接待员工(user_id) 归属部门 * - 进线数据 = 区间内 add_external_contact 事件,按接待员工(user_id) 归属部门
* · 与 qywx.customer/todayArrival、客户列表同源事件表;选定渠道时客户须带对应企微标签(contact_tag);**二中心展示部门在选定渠道下计 0** * · 与 qywx.customer/todayArrival、客户列表同源事件表;选定渠道时客户须带对应企微标签(contact_tag);**二中心及其组织下级在选定渠道下进线计 0**
* - 被指派数 = 区间内 tcm_diagnosis_assign_log 成功指派次数(to_assistant_id>0),按**被指派医助**归属展示部门;选定渠道时收窄与「{渠道}业绩」同源(channels EXISTS 或 tag 诊单/医助 * - 被指派数 = 区间内 `tcm_diagnosis_assign_log` 且 **to_assistant_id>0**,且 **related_po_create_time>0** 并落在区间(**一律按快照字段统计**,与履约释放写入一致;诊单后台指派时会回填该字段,见 DiagnosisLogic::assign
* · **二中心展示部门在选定渠道下计 0**(与进线一致) * · **二中心及其组织下级在选定渠道下计 0**(与进线一致)
* - 复诊统计 = 仅**名称含「二中心」的部门及其组织架构下级**:区间内业务单按患者 create_time 排序,第 1 笔不计,第 2 笔起分列「复诊2」「复诊3」…;口径同业绩单条件;按诊单医助在组织树内归属 rollup(含下级),与 dept 模块 ErCenter 复诊算法一致 * - 复诊统计 = 仅**名称含「二中心」的部门及其组织架构下级**:区间内业务单按患者 create_time 排序,第 1 笔不计,第 2 笔起分列「复诊2」「复诊3」…;口径同业绩单条件;按诊单医助在组织树内归属 rollup(含下级),与 dept 模块 ErCenter 复诊算法一致
* - 已完成挂号 = status=3 挂号条数(appointment_date),列「已完成挂号」;部门归属:有效医助→接诊医生→展示行映射。 * - 已完成挂号 = status=3 挂号条数(appointment_date),列「已完成挂号」;部门归属:有效医助→接诊医生→展示行映射。
* - 接诊诊单 = 列名对应**业务订单条数**(非取消、create_time 区间),与处方列表按 **assistant_dept_id** 筛选时一致:诊单 **assistant_id** 或订单 **creator_id** 任一人事部门落在该行子树即计入;多行命中时取**最深的展示部门行**,与「业绩归属医助」分组可能不同。 * - 接诊诊单 = 列名对应**业务订单条数**(非取消、create_time 区间),与处方列表按 **assistant_dept_id** 筛选时一致:诊单 **assistant_id** 或订单 **creator_id** 任一人事部门落在该行子树即计入;多行命中时取**最深的展示部门行**,与「业绩归属医助」分组可能不同。
@@ -25,15 +25,15 @@ use think\facade\Db;
* - 合计业绩 = 区间内业务订单 amount 求和(排除已取消(4) 与软删),**部门列**与「接诊诊单」一致:诊单医助或订单创建人的人事部门落在该展示行子树则计入,多行命中取最深展示行;与处方列表按 assistant_dept_id 筛选后的金额合计一致。 * - 合计业绩 = 区间内业务订单 amount 求和(排除已取消(4) 与软删),**部门列**与「接诊诊单」一致:诊单医助或订单创建人的人事部门落在该展示行子树则计入,多行命中取最深展示行;与处方列表按 assistant_dept_id 筛选后的金额合计一致。
* 无法归入任一展示部门的金额单列「未归属中心」行;**全表合计行**仍为同时间/业绩条件的全表 SUM(不限部门)。 * 无法归入任一展示部门的金额单列「未归属中心」行;**全表合计行**仍为同时间/业绩条件的全表 SUM(不限部门)。
* · 时间窗与 stats_order_amount_performance 一致;列表若另有患者筛选或账号数据域收窄,金额可能仍不同 * · 时间窗与 stats_order_amount_performance 一致;列表若另有患者筛选或账号数据域收窄,金额可能仍不同
* · 选定渠道时「{渠道}业绩」列仍按比例展示;合计列全量与渠道列含未归属补差;**名称含「二中心」的展示部门不参与该渠道进线/业绩/成交单/ROI 分子** * · 选定渠道时「{渠道}业绩」列仍按比例展示;合计列全量与渠道列含未归属补差;**二中心及其组织下级展示部门不参与该渠道进线/业绩/成交单/ROI 分子**(与同模块 DeptLogic::getErCenterSubtreeDeptIdSet 一致)
* - {渠道}业绩 = 与「处方订单列表」业绩同口径(≠4):按 **o.create_time** 落入区间,按**业绩归属医助**归部门(与处方列表医助筛选一致); * - {渠道}业绩 = 与「处方订单列表」业绩同口径(≠4):按 **o.create_time** 落入区间,按**业绩归属医助**归部门(与处方列表医助筛选一致);
* 渠道判定仅用 EXISTS:"患者有任一已完成挂号 (status=3) 且 channels 命中字典",**不限挂号时点**;无字典映射时回退 tag/creator 口径 * 渠道判定仅用 EXISTS:"患者有任一已完成挂号 (status=3) 且 channels 命中字典",**不限挂号时点**;无字典映射时回退 tag/creator 口径
* - 成交单数 = 区间内非取消业务订单 **条数**;按**业绩归属医助**映射到「中心」(与处方订单列表医助条件一致);无法归属计入「未归属中心」;合计=全表 COUNT。 * - 成交单数 = 区间内非取消业务订单 **条数**;按**业绩归属医助**映射到「中心」(与处方订单列表医助条件一致);无法归属计入「未归属中心」;合计=全表 COUNT。
* - 投放成本 = zyt_account_cost 在日期区间内 **amount** 合计(选定渠道时 `media_channel_code = channel_code` 过滤)。 * - 投放成本 = zyt_account_cost 在日期区间内 **amount** 合计(选定渠道时 `media_channel_code = channel_code` 过滤)。
* · **各部门行投放成本** = 总投放成本 × (该部门进线 ÷ 各展示部门进线合计)。相当于先算 **单进线成本** = 总成本 ÷ 总进线,再乘部门进线。 * · **各部门行投放成本** = 总投放成本 × (该部门进线 ÷ 各展示部门进线合计)。相当于先算 **单进线成本** = 总成本 ÷ 总进线,再乘部门进线。
* · 选定渠道下二中心部门进线为 0,故该行分摊成本为 0;合计行成本 = 账务汇总总额(部门行分摊之和与之对齐,尾差归入进线最多的部门) * · **二中心及其组织下级**不参与均摊投放成本(进线分摊分母对其计 0,与选定渠道下进线清零规则独立);其余部门进线占比分摊。合计行成本 = 账务汇总总额
* · ROI = 渠道业绩合计 ÷ 总投放成本(选定渠道时分子不含二中心渠道业绩)。 * · ROI = 渠道业绩合计或全量业绩合计,**均不含二中心子树各部门的对应业绩**)÷ 总投放成本(成本为 0 时返回 null)
* - ROI = 选定渠道时为 **{渠道}业绩合计** ÷ 投放成本(与表格「渠道业绩」列合计一致;未选渠道时分子为全业绩合计;成本为 0 时返回 null * - ROI = **不含二中心子树**后的 **{渠道}业绩合计**(或未选渠道时为全业绩口径下同一套「部门行之和」)÷ 投放成本;部门行在四舍五入前若分摊成本为 0 则无 ROI
* *
* 「业绩/接诊标签匹配」: * 「业绩/接诊标签匹配」:
* 优先 tcm_diagnosis.external_userid = 客户 external_userid * 优先 tcm_diagnosis.external_userid = 客户 external_userid
@@ -274,6 +274,7 @@ class YejiStatsLogic
: null; : null;
$leads = self::countLeads($startTs, $endTs, $adminToPrimary, $tagFilterId); $leads = self::countLeads($startTs, $endTs, $adminToPrimary, $tagFilterId);
$erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet();
$assignsByDept = self::countDiagnosisAssignsByDept( $assignsByDept = self::countDiagnosisAssignsByDept(
$startTs, $startTs,
$endTs, $endTs,
@@ -338,6 +339,14 @@ class YejiStatsLogic
); );
$performance = $perfBoth['all']; $performance = $perfBoth['all'];
$channelPerformance = $perfBoth['scoped']; $channelPerformance = $perfBoth['scoped'];
/** 二中心子树展示行「计入 ROI 分子的业绩」合计({渠道}业绩或与合计业绩相同),供未选渠道时从全表分子里扣除 */
$sumCompletedPerfErCenter = 0.0;
foreach ($tableRowDeptIds as $erDeptId) {
if (!isset($erCenterDeptSet[(int) $erDeptId])) {
continue;
}
$sumCompletedPerfErCenter += round((float) ($channelPerformance[$erDeptId] ?? 0), 1);
}
// 成交单数(全量):与合计业绩列同列表部门口径 // 成交单数(全量):与合计业绩列同列表部门口径
$dealOrderByDept = $listAlignedPack !== null $dealOrderByDept = $listAlignedPack !== null
? $listAlignedPack['count'] ? $listAlignedPack['count']
@@ -352,17 +361,15 @@ class YejiStatsLogic
); );
$globalDealOrderCount = self::countGlobalDealOrders($startTs, $endTs); $globalDealOrderCount = self::countGlobalDealOrders($startTs, $endTs);
/** 选定渠道时从全量渠道 SUM 与部门行中同步扣减二中心」展示部门的渠道业绩 */ /** 选定渠道时从全量渠道 SUM 与部门行中同步扣减二中心子树部门的渠道业绩/成交单;进线、被指派同清零 */
$erCenterChannelStrip = 0.0; $erCenterChannelStrip = 0.0;
if ($channelCode !== '') { if ($channelCode !== '') {
$erCenterChannelStrip = self::excludeErCenterFromChannelMetrics( $erCenterChannelStrip = self::excludeErCenterSubtreeFromChannelMetrics(
$primaryDeptIds, $tableRowDeptIds,
$deptNameMap, $erCenterDeptSet,
$channelPerformance, $channelPerformance,
$channelConsults $channelConsults
); );
self::excludeErCenterFromLeads($primaryDeptIds, $deptNameMap, $leads);
self::excludeErCenterFromLeads($primaryDeptIds, $deptNameMap, $assignsByDept);
} }
$globalCost = round((float) self::sumGlobalCost($startDate, $endDate, $channelCode), 1); $globalCost = round((float) self::sumGlobalCost($startDate, $endDate, $channelCode), 1);
@@ -372,8 +379,14 @@ class YejiStatsLogic
$leads, $leads,
$deptToDescendants $deptToDescendants
); );
self::zeroIntKeyedDeptMetricsInErCenterSubtree($leadsForCost, $erCenterDeptSet);
$costs = self::allocateCostByLeads($globalCost, $tableRowDeptIds, $leadsForCost); $costs = self::allocateCostByLeads($globalCost, $tableRowDeptIds, $leadsForCost);
if ($channelCode !== '') {
self::zeroIntKeyedDeptMetricsInErCenterSubtree($leads, $erCenterDeptSet);
self::zeroIntKeyedDeptMetricsInErCenterSubtree($assignsByDept, $erCenterDeptSet);
}
@set_time_limit(120); @set_time_limit(120);
$revisitPack = DeptLogic::getErCenterRevisitRollupIndexedByDept($startTs, $endTs); $revisitPack = DeptLogic::getErCenterRevisitRollupIndexedByDept($startTs, $endTs);
$revisitTotals = $revisitPack['totals']; $revisitTotals = $revisitPack['totals'];
@@ -537,8 +550,16 @@ class YejiStatsLogic
} }
$totalCost = round($globalCost, 1); $totalCost = round($globalCost, 1);
// ROI 合计 = 渠道业绩合计(与「completed」列一致,**不含二中心**)÷ 投放成本合计 // ROI 合计:选渠道时底栏「{渠道}业绩合计」已扣除二中心子树($erCenterChannelStrip);未选渠道时用全表业绩并减去各二中心子树展示行之和
$totalRoiNumerator = round($totalChannelPerf, 1); $totalRoiNumerator = round(
max(
0.0,
$channelFilterActive
? $totalChannelPerf
: ($totalChannelPerf - $sumCompletedPerfErCenter)
),
1
);
if ($totalRevisitSlots !== []) { if ($totalRevisitSlots !== []) {
ksort($totalRevisitSlots, SORT_NUMERIC); ksort($totalRevisitSlots, SORT_NUMERIC);
@@ -550,7 +571,7 @@ class YejiStatsLogic
. ($dataScopeRestricted . ($dataScopeRestricted
? ' 当前账号受数据范围限制时,底栏合计业绩与列表同域。' ? ' 当前账号受数据范围限制时,底栏合计业绩与列表同域。'
: ''); : '');
$baseNoteNoChannel = '业绩口径:除履约已取消(4)外全部计入。部门行「合计业绩」与「接诊诊单」均为列表 assistant_dept_id 口径(诊单医助或订单创建人人事部门∪子树,多行命中取最深展示行)。多表「本月」区间为当月1日至服务器当日。**投放成本**按进线占比分摊。'; $baseNoteNoChannel = '业绩口径:除履约已取消(4)外全部计入。部门行「合计业绩」与「接诊诊单」均为列表 assistant_dept_id 口径(诊单医助或订单创建人人事部门∪子树,多行命中取最深展示行)。多表「本月」区间为当月1日至服务器当日。**投放成本**按进线占比分摊,**二中心子树展示部门进线在分摊中计 0**(与部门 ErCenter 子树一致)。';
return [ return [
'start_date' => $startDate, 'start_date' => $startDate,
@@ -562,7 +583,7 @@ class YejiStatsLogic
'channel_filter_note' => $channelCode !== '' 'channel_filter_note' => $channelCode !== ''
? ('{渠道}业绩:与「处方订单列表」业绩同口径,按订单 create_time 归日、仅剔除履约已取消(4)(NULL 与其它状态均计入);渠道判定用"该患者是否有挂号 channels 命中 dict + status=3"(不限挂号时点)。' ? ('{渠道}业绩:与「处方订单列表」业绩同口径,按订单 create_time 归日、仅剔除履约已取消(4)(NULL 与其它状态均计入);渠道判定用"该患者是否有挂号 channels 命中 dict + status=3"(不限挂号时点)。'
. ' {渠道}成交单数:与{渠道}业绩同源,仅剔除已取消(4)的订单条数(非挂号条数)。' . ' {渠道}成交单数:与{渠道}业绩同源,仅剔除已取消(4)的订单条数(非挂号条数)。'
. ' 「已完成挂号」列同 admin 挂号已完成列表;「接诊诊单」列为业务订单条数(非取消),与列表按 assistant_dept_id 筛选时一致(诊单医助或创建人∪子树)。名称含「二中心」的部门**不计入**该渠道进线、业绩与成交单。**投放成本**按各部门进线条数占展示部门进线合计的比例,从账务总投放金额中分摊(总成本÷总进线=单进线成本)。ROI = 渠道业绩(不含二中心)÷ 总投放成本。进线按所选标签客户精确过滤。' . ' 「已完成挂号」列同 admin 挂号已完成列表;「接诊诊单」列为业务订单条数(非取消),与列表按 assistant_dept_id 筛选时一致(诊单医助或创建人∪子树)。**二中心及其组织下级**(与部门模块 ErCenter 子树一致)**不计入**该渠道进线、业绩与成交单。**投放成本**按各部门进线条数占展示部门进线合计的比例分摊,**二中心子树进线在分摊中计 0**(未选渠道时仍显示进线数,仅不参与均摊)。ROI = 渠道业绩(不含二中心子树)÷ 总投放成本。进线按所选标签客户精确过滤。'
. ($tagFallback . ($tagFallback
? ' ⚠ 当前业务尚未回填 tcm_diagnosis.external_userid 与 user_auth.unionid,已自动退化为「该标签客户的跟进医助 → 区间内接诊/业绩」口径,仅用于趋势观察。' ? ' ⚠ 当前业务尚未回填 tcm_diagnosis.external_userid 与 user_auth.unionid,已自动退化为「该标签客户的跟进医助 → 区间内接诊/业绩」口径,仅用于趋势观察。'
: '') : '')
@@ -707,9 +728,10 @@ class YejiStatsLogic
); );
if ($channelFilterActive) { if ($channelFilterActive) {
$erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet();
self::excludeErCenterFromAdminLeaderboard( self::excludeErCenterFromAdminLeaderboard(
$adminToPrimary, $adminToPrimary,
$deptNameMap, $erCenterDeptSet,
$feeByAdmin, $feeByAdmin,
$leadsByAdmin, $leadsByAdmin,
$dealOrderByAdmin, $dealOrderByAdmin,
@@ -745,7 +767,7 @@ class YejiStatsLogic
$rangeNote = $channelFilterActive $rangeNote = $channelFilterActive
? ('排行诊金 = 选定渠道「' . $channelName . '」业绩口径(与部门表该渠道业绩列一致)。被指派数与部门表同口径。接诊率 = 诊金 ÷ 进线(元/进线)。' ? ('排行诊金 = 选定渠道「' . $channelName . '」业绩口径(与部门表该渠道业绩列一致)。被指派数与部门表同口径。接诊率 = 诊金 ÷ 进线(元/进线)。'
. '「二中心」医助在该渠道下诊金、进线与被指派计 0。' . '「二中心」及其组织下级医助在该渠道下诊金、进线与被指派计 0。'
. ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。') . ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。')
: '排行诊金 = 处方订单列表医助口径(诊单医助或代建创建人与列表一致)。被指派数为指派至该医助的次数。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。' : '排行诊金 = 处方订单列表医助口径(诊单医助或代建创建人与列表一致)。被指派数为指派至该医助的次数。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。'
. ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。'; . ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。';
@@ -815,7 +837,7 @@ class YejiStatsLogic
'channel_code' => $channelCode, 'channel_code' => $channelCode,
'channel_name' => $channelName, 'channel_name' => $channelName,
'channel_filter_note' => $channelCode !== '' 'channel_filter_note' => $channelCode !== ''
? '医助榜:名称含「二中心」的部门在选定渠道下诊金、进线、被指派数计 0,与部门看板一致。' ? '医助榜:**二中心及其组织下级**在选定渠道下诊金、进线、被指派数计 0,与部门看板一致。'
: '', : '',
'range_note' => $rangeNote, 'range_note' => $rangeNote,
'leaderboards' => $leaderboards, 'leaderboards' => $leaderboards,
@@ -1613,7 +1635,8 @@ class YejiStatsLogic
} }
/** /**
* 被指派数:诊单指派日志(to_assistant_id>0)按被指派医助归属展示部门;选定渠道时收窄与「{渠道}业绩」同源 * 被指派数:统计 `tcm_diagnosis_assign_log`to_assistant_id>0),**仅**按 **related_po_create_time** 落入区间(须 >0
* 选定字典渠道时 INNER JOIN 诊单以满足挂号 channels EXISTS;tag 退化口径只过滤日志字段,不强制挂诊单。
* *
* @param array<int, int> $adminToPrimary * @param array<int, int> $adminToPrimary
* @param int[]|null $tagDiagIds * @param int[]|null $tagDiagIds
@@ -1636,13 +1659,11 @@ class YejiStatsLogic
return []; return [];
} }
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$query = Db::name('tcm_diagnosis_assign_log') $query = Db::name('tcm_diagnosis_assign_log')
->alias('lg') ->alias('lg')
->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER') ->where('lg.related_po_create_time', '>', 0)
->where('lg.create_time', 'between', [$startTs, $endTs]) ->where('lg.related_po_create_time', 'between', [$startTs, $endTs])
->where('lg.to_assistant_id', '>', 0); ->where('lg.to_assistant_id', '>', 0);
if ($channelFilterActive) { if ($channelFilterActive) {
if ($appointmentChannelValues !== []) { if ($appointmentChannelValues !== []) {
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues); $norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
@@ -1653,6 +1674,8 @@ class YejiStatsLogic
if ($pack === null) { if ($pack === null) {
return []; return [];
} }
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$query->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER');
$query->whereRaw($pack[0], $pack[1]); $query->whereRaw($pack[0], $pack[1]);
} elseif ($tagDiagIds !== null || $tagAssistantIds !== null) { } elseif ($tagDiagIds !== null || $tagAssistantIds !== null) {
if ($tagDiagIds !== null && $tagDiagIds === []) { if ($tagDiagIds !== null && $tagDiagIds === []) {
@@ -1718,11 +1741,10 @@ class YejiStatsLogic
?array $dataScopeAdminIds = null, ?array $dataScopeAdminIds = null,
?array $channelInfo = null ?array $channelInfo = null
): array { ): array {
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$query = Db::name('tcm_diagnosis_assign_log') $query = Db::name('tcm_diagnosis_assign_log')
->alias('lg') ->alias('lg')
->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER') ->where('lg.related_po_create_time', '>', 0)
->where('lg.create_time', 'between', [$startTs, $endTs]) ->where('lg.related_po_create_time', 'between', [$startTs, $endTs])
->where('lg.to_assistant_id', '>', 0); ->where('lg.to_assistant_id', '>', 0);
if ($channelFilterActive) { if ($channelFilterActive) {
@@ -1735,6 +1757,8 @@ class YejiStatsLogic
if ($pack === null) { if ($pack === null) {
return []; return [];
} }
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$query->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER');
$query->whereRaw($pack[0], $pack[1]); $query->whereRaw($pack[0], $pack[1]);
} elseif ($tagDiagIds !== null || $tagAssistantIds !== null) { } elseif ($tagDiagIds !== null || $tagAssistantIds !== null) {
if ($tagDiagIds !== null && $tagDiagIds === []) { if ($tagDiagIds !== null && $tagDiagIds === []) {
@@ -2361,14 +2385,14 @@ class YejiStatsLogic
); );
} }
$deptNameMap = $c['deptNameMap'];
$channelCode = (string) $c['channelCode']; $channelCode = (string) $c['channelCode'];
if ($channelCode !== '' && self::isErCenterDisplayPrimaryName((string) ($deptNameMap[$deptId] ?? ''))) { $erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet();
if ($channelCode !== '' && isset($erCenterDeptSet[$deptId])) {
return $empty( return $empty(
$c, $c,
$deptId, $deptId,
self::formatYejiDeptRowDisplayName($deptId, $c['deptById']), self::formatYejiDeptRowDisplayName($deptId, $c['deptById']),
'与看板一致:名称含「二中心」的展示部门在选定渠道下进线计 0,无明细。' '与看板一致:二中心及其组织下级展示部门在选定渠道下进线计 0,无明细。'
); );
} }
@@ -3227,22 +3251,26 @@ class YejiStatsLogic
} }
/** /**
* 选定渠道时:二中心展示部门下属医助的渠道诊金、进线清零(与部门行一致)。 * 选定渠道时:二中心子树(DeptLogic::getErCenterSubtreeDeptIdSet下属医助的渠道诊金、进线清零(与部门行一致)。
* *
* @param array<int, float> $feeByAdmin * @param array<int, int> $adminToPrimary
* @param array<int, int> $leadsByAdmin * @param array<int, true> $erCenterDeptSet
* @param array<int, float> $feeByAdmin
* @param array<int, int> $leadsByAdmin
* @param array<int, int>|null $dealOrderByAdmin
* @param array<int, int>|null $assignsByAdmin * @param array<int, int>|null $assignsByAdmin
*/ */
private static function excludeErCenterFromAdminLeaderboard( private static function excludeErCenterFromAdminLeaderboard(
array $adminToPrimary, array $adminToPrimary,
array $deptNameMap, array $erCenterDeptSet,
array &$feeByAdmin, array &$feeByAdmin,
array &$leadsByAdmin, array &$leadsByAdmin,
?array &$dealOrderByAdmin = null, ?array &$dealOrderByAdmin = null,
?array &$assignsByAdmin = null ?array &$assignsByAdmin = null
): void { ): void {
foreach ($adminToPrimary as $aid => $pid) { foreach ($adminToPrimary as $aid => $pid) {
if (!self::isErCenterDisplayPrimaryName($deptNameMap[$pid] ?? '')) { $pid = (int) $pid;
if ($pid <= 0 || !isset($erCenterDeptSet[$pid])) {
continue; continue;
} }
$feeByAdmin[$aid] = 0.0; $feeByAdmin[$aid] = 0.0;
@@ -4270,51 +4298,46 @@ class YejiStatsLogic
} }
/** /**
* 展示部门名称是否属「二中心」系:选定渠道时该部门不参与该渠道进线/业绩/成交单/ROI 分子 * 选定渠道时:二中心子树展示行渠道业绩与渠道成交单置 0,并返回扣减金额(与 sumGlobalChannelScopedPerformance 对齐)
*/
private static function isErCenterDisplayPrimaryName(string $name): bool
{
return mb_strpos($name, '二中心') !== false;
}
/**
* 选定渠道时:名称含「二中心」的展示部门渠道业绩与渠道成交单置 0,并从后续全量渠道 SUM 中扣减同等金额。
* *
* @param int[] $tableRowDeptIds
* @param array<int, true> $erCenterDeptSet
* @param array<int, float|string|int> $channelPerformance * @param array<int, float|string|int> $channelPerformance
* @param array<int, int> $channelConsults * @param array<int, int> $channelConsults
* @return float 扣减金额(与 sumGlobalChannelScopedPerformance 对齐)
*/ */
private static function excludeErCenterFromChannelMetrics( private static function excludeErCenterSubtreeFromChannelMetrics(
array $primaryDeptIds, array $tableRowDeptIds,
array $deptNameMap, array $erCenterDeptSet,
array &$channelPerformance, array &$channelPerformance,
array &$channelConsults array &$channelConsults
): float { ): float {
$stripped = 0.0; $stripped = 0.0;
foreach ($primaryDeptIds as $pid) { foreach ($tableRowDeptIds as $tid) {
if (!self::isErCenterDisplayPrimaryName($deptNameMap[$pid] ?? '')) { $tid = (int) $tid;
if (!isset($erCenterDeptSet[$tid])) {
continue; continue;
} }
$stripped += (float) ($channelPerformance[$pid] ?? 0); $stripped += (float) ($channelPerformance[$tid] ?? 0);
$channelPerformance[$pid] = 0.0; $channelPerformance[$tid] = 0.0;
$channelConsults[$pid] = 0; $channelConsults[$tid] = 0;
} }
return round($stripped, 1); return round($stripped, 1);
} }
/** /**
* 选定渠道时:名称含「二中心」的展示部门进线数置 0(与同渠道向业绩/成交单一致) * 将 map 中落在二中心子树(DeptLogic)的部门 key 清零,用于进线分摊或渠道清零后的展示列
* *
* @param array<int, int> $leads * @param array<int|string, int> $map
* @param array<int, true> $erCenterDeptSet
*/ */
private static function excludeErCenterFromLeads(array $primaryDeptIds, array $deptNameMap, array &$leads): void private static function zeroIntKeyedDeptMetricsInErCenterSubtree(array &$map, array $erCenterDeptSet): void
{ {
foreach ($primaryDeptIds as $pid) { foreach (array_keys($map) as $k) {
if (!self::isErCenterDisplayPrimaryName($deptNameMap[$pid] ?? '')) { $id = (int) $k;
continue; if ($id > 0 && isset($erCenterDeptSet[$id])) {
$map[$k] = 0;
} }
$leads[$pid] = 0;
} }
} }
+116 -24
View File
@@ -478,7 +478,66 @@ class DiagnosisLogic extends BaseLogic
} }
/** /**
* @notes 指派医助(每次成功更新写入一条操作记录,批量指派为多次接口调用各记一条) * 诊单下用于指派日志快照的「最新」业务单:按 create_time 最新,其次 id(避免误用更早下单但 id 更大的记录)。
*
* @return array{creator_id: int, create_time: int}|null
*/
private static function getLatestPrescriptionOrderSnapshotForDiagnosis(int $diagnosisId): ?array
{
if ($diagnosisId <= 0) {
return null;
}
$row = Db::name('tcm_prescription_order')
->where('diagnosis_id', $diagnosisId)
->whereNull('delete_time')
->order('create_time', 'desc')
->order('id', 'desc')
->field(['creator_id', 'create_time'])
->find();
if ($row === null || $row === []) {
return null;
}
return [
'creator_id' => (int) ($row['creator_id'] ?? 0),
'create_time' => (int) ($row['create_time'] ?? 0),
];
}
/**
* 诊单 current assistant_id 为 0 时,从最近一条指派日志推断「原医助」(如发货释放后:上一条多为 from=X、to=0)。
* - 最近一条 to_assistant_id > 0:视为上一任持有人(库未同步时的兜底)
* - 否则若 from_assistant_id > 0:视为释放/取消前的持有人
*/
private static function inferPreviousAssistantIdFromLatestAssignLog(int $diagnosisId): int
{
if ($diagnosisId <= 0) {
return 0;
}
$prev = Db::name('tcm_diagnosis_assign_log')
->where('diagnosis_id', $diagnosisId)
->order('id', 'desc')
->field(['from_assistant_id', 'to_assistant_id'])
->find();
if ($prev === null || $prev === []) {
return 0;
}
$pTo = (int) ($prev['to_assistant_id'] ?? 0);
$pFrom = (int) ($prev['from_assistant_id'] ?? 0);
if ($pTo > 0) {
return $pTo;
}
if ($pFrom > 0) {
return $pFrom;
}
return 0;
}
/**
* @notes 指派医助(每次成功更新写入一条操作记录,批量指派为多次接口调用各记一条)。
* **原医助**:优先诊单 `assistant_id`;若为 0 且本次为指派到某人,则从**最近一条**指派日志推断(覆盖发货释放后库为 0、业务上仍有「前任」医助的情况)。
* @param array $params * @param array $params
* @return bool * @return bool
*/ */
@@ -493,33 +552,66 @@ class DiagnosisLogic extends BaseLogic
return false; return false;
} }
$diagnosis = Diagnosis::where('id', $id)->whereNull('delete_time')->find(); $now = time();
if (!$diagnosis) { $poSnap = self::getLatestPrescriptionOrderSnapshotForDiagnosis($id);
self::setError('诊单不存在'); $relatedPoCreatorId = 0;
$relatedPoCreateTime = 0;
if ($poSnap !== null) {
$relatedPoCreatorId = $poSnap['creator_id'];
$relatedPoCreateTime = $poSnap['create_time'];
}
if ($relatedPoCreateTime <= 0) {
$relatedPoCreateTime = $now;
$relatedPoCreatorId = 0;
}
Db::startTrans();
try {
// 事务内 FOR UPDATE 直读库:确保「原医助」与 tcm_diagnosis.assistant_id 一致,避免模型缓存或并发覆盖
$diagLock = Db::name('tcm_diagnosis')
->where('id', $id)
->whereNull('delete_time')
->lock(true)
->field(['id', 'assistant_id'])
->find();
if ($diagLock === null || $diagLock === []) {
Db::rollback();
self::setError('诊单不存在');
return false;
}
$fromAssistantId = (int) ($diagLock['assistant_id'] ?? 0);
if ($fromAssistantId <= 0 && $toAssistantId > 0) {
$fromAssistantId = self::inferPreviousAssistantIdFromLatestAssignLog($id);
}
Db::name('tcm_diagnosis')
->where('id', $id)
->whereNull('delete_time')
->update(['assistant_id' => $toAssistantId]);
$req = request();
$admin = $req->adminInfo ?? [];
// Db::insert 直写:与 assignLogList 一致,避免模型 allowField/schema 丢列
Db::name('tcm_diagnosis_assign_log')->insert([
'diagnosis_id' => $id,
'from_assistant_id' => $fromAssistantId,
'to_assistant_id' => $toAssistantId,
'operator_admin_id' => (int) ($admin['admin_id'] ?? 0),
'operator_name' => (string) ($admin['name'] ?? ''),
'operator_account' => (string) ($admin['account'] ?? ''),
'ip' => (string) ($req->ip() ?? ''),
'related_po_creator_id' => $relatedPoCreatorId,
'related_po_create_time' => $relatedPoCreateTime,
'create_time' => $now,
]);
} catch (\Throwable $e) {
Db::rollback();
self::setError($e->getMessage());
return false; return false;
} }
$fromAssistantId = (int) ($diagnosis->getAttr('assistant_id') ?? 0);
Db::startTrans();
Diagnosis::where('id', $id)->whereNull('delete_time')->update([
'assistant_id' => $toAssistantId,
]);
$req = request();
$admin = $req->adminInfo ?? [];
DiagnosisAssignLog::create([
'diagnosis_id' => $id,
'from_assistant_id' => $fromAssistantId,
'to_assistant_id' => $toAssistantId,
'operator_admin_id' => (int) ($admin['admin_id'] ?? 0),
'operator_name' => (string) ($admin['name'] ?? ''),
'operator_account' => (string) ($admin['account'] ?? ''),
'ip' => (string) ($req->ip() ?? ''),
'create_time' => time(),
]);
Db::commit(); Db::commit();
return true; return true;