This commit is contained in:
Your Name
2026-05-12 10:42:14 +08:00
parent 15cf6324f6
commit a3892001d3
8 changed files with 1294 additions and 444 deletions
File diff suppressed because it is too large Load Diff
+14 -12
View File
@@ -290,12 +290,12 @@
</table> </table>
</div> </div>
<div class="yeji-table__foot yeji-table__foot--left"> <div class="yeji-table__foot yeji-table__foot--left">
<span>诊金单位 · 被指派数与部门业绩表同口径 · 接诊率 = 诊金 ÷ 进线/进线</span> <span>诊金单位 · 诊金/接诊诊单/复诊均按订单创建人 = 该医助归属与部门合计业绩 / 接诊诊单 / 复诊同口径 · 被指派数与部门业绩表同口径 · 接诊率 = 诊金 ÷ 进线/进线</span>
<span v-if="lb.er_center_subtree"> <span v-if="lb.er_center_subtree">
· 复诊列为二中心口径同一诊单业务单序列第 2 笔起点击数字查看对应业务订单 · 复诊列为二中心口径同一诊单业务单序列第 2 笔起点击数字查看对应业务订单
</span> </span>
<span> <span>
· 预约诊单预约日期在统计区间内状态含已预约/已完成/已过号不含已取消医助归属与接诊单数一致各科组展示全部医助 · 预约诊单预约日期在统计区间内状态含已预约/已完成/已过号不含已取消医助归属与接诊单数一致按挂号 assistant_id 优先否则诊单 assistant_id各科组展示全部医助
</span> </span>
</div> </div>
</div> </div>
@@ -370,7 +370,7 @@
<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>名称含二中心</b>的部门及其组织下级与本表区间一致按患者业务单 <b>create_time</b> 排序<b> 1 笔不计</b> 2 笔起计入合计分列复诊2复诊三含本部门及下级汇总订单条件同合计业绩非取消非二中心子树部门为 0 <b>名称含二中心</b>的部门及其组织下级候选订单为有诊单医助的非取消业务单按患者 <b>create_time</b> 排序<b> 1 笔不计</b> 2 笔起计入合计分列复诊2复诊三<b>归属</b>按订单创建人的人事部门<b>合计业绩</b>列同口径非二中心子树部门为 0
</div> </div>
</template> </template>
<el-icon class="col-info"><InfoFilled /></el-icon> <el-icon class="col-info"><InfoFilled /></el-icon>
@@ -395,8 +395,8 @@
<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: 300px; line-height: 1.7; font-size: 12px"> <div style="max-width: 300px; line-height: 1.7; font-size: 12px">
按订单<b>创建时间</b>统计剔除履约已取消金额与<b>接诊诊单</b>列一致诊单<b>医助</b>或订单<b>创建人</b>的人事部门落在本展示行含下级计入多行命中时取最深的展示部门<br /> 按订单<b>创建时间</b>统计剔除履约已取消金额与<b>接诊诊单</b>列一致仅按<b>订单创建人</b>的人事部门落在本展示行含下级计入多行命中时取最深的展示部门<b>医助排行榜诊金</b>同口径<br />
<b>不受渠道筛选影响</b>与侧栏按部门打开的业务订单列表对齐<br /> <b>不受渠道筛选影响</b>与侧栏按部门打开的业务订单列表对齐<br />
点击金额可查看对应业务订单明细侧栏默认合计业绩全量列表 点击金额可查看对应业务订单明细侧栏默认合计业绩全量列表
</div> </div>
</template> </template>
@@ -445,8 +445,7 @@
<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>业务订单条数</b>非取消订单 <b>create_time</b> 落入区间<b>fulfillment_status 4</b>与列表筛选 <b>业务订单条数</b>非取消订单 <b>create_time</b> 落入区间<b>fulfillment_status 4</b><b>订单创建人</b>的人事部门落在该部门子树即计入表格多行命中时取最深的展示部门<b>合计业绩 / 医助排行榜接诊诊单</b>同口径选定渠道时本列仍为全量
<b>assistant_dept_id</b> 时一致诊单 <b>医助</b>或订单 <b>创建人</b>的人事部门落在该部门子树即计入表格多行命中时取最深的展示部门与侧栏勾选与表格业绩对齐时的集合可能略有差异选定渠道时本列仍为全量
</div> </div>
</template> </template>
<el-icon class="col-info"><InfoFilled /></el-icon> <el-icon class="col-info"><InfoFilled /></el-icon>
@@ -2629,12 +2628,15 @@ function buildOrderDrawerListParams(): Record<string, any> | null {
end_time: `${f.end} 23:59:59`, end_time: `${f.end} 23:59:59`,
/** 与业绩列口径一致:不列出履约已取消(4) */ /** 与业绩列口径一致:不列出履约已取消(4) */
exclude_fulfillment_cancelled: 1, exclude_fulfillment_cancelled: 1,
/** 侧栏展示约诊数等 extend 字段 */ /**
* 业绩看板入口标志:后端 PrescriptionOrderLists::applyDoctorAssistantFilters 看到此标志后,
* assistant_id / assistant_dept_id 一律按订单创建人收窄(与表格诊金/合计业绩/复诊同口径),
* 而非默认的「创建人 ∪ 诊单医助」并集。同时启用 extend 字段(约诊数、合计业绩对照)。
*/
yeji_order_drawer: 1, yeji_order_drawer: 1,
} }
if (f.mode === 'dept') { if (f.mode === 'dept') {
params.assistant_dept_id = f.deptId params.assistant_dept_id = f.deptId
/** 勿传 yeji_drawer_match_table_performance:该参数按「业绩归因」医助收窄,与看板渠道成交/渠道业绩的列表摊行(诊单医助∪创建人∪最深展示行)不一致 */
if (selectedDeptIds.value.length > 0) { if (selectedDeptIds.value.length > 0) {
params.dept_ids = selectedDeptIds.value.join(',') params.dept_ids = selectedDeptIds.value.join(',')
} }
@@ -2742,7 +2744,7 @@ function openOrderDrawerByDept(tb: YejiTable, row: YejiRow, opts?: { listTab?: '
end: tb.end_date, end: tb.end_date,
} }
orderDrawerTitle.value = `业务订单 · ${row.dept_name}` orderDrawerTitle.value = `业务订单 · ${row.dept_name}`
orderDrawerHint.value = `创建时间:${tb.start_date} ${tb.end_date} · 与「接诊诊单」同口径:诊单医助或订单创建人落在本部门(含下级)· 不含履约已取消` orderDrawerHint.value = `创建时间:${tb.start_date} ${tb.end_date} · 与「合计业绩 / 接诊诊单」同口径:订单创建人人事部门 ∈ 本部门子树 · 不含履约已取消`
orderDrawerPage.value = 1 orderDrawerPage.value = 1
orderDrawerVisible.value = true orderDrawerVisible.value = true
void fetchOrderDrawerPage() void fetchOrderDrawerPage()
@@ -2822,7 +2824,7 @@ function openOrderDrawerByAssistant(row: LeaderboardPack['leaderboards'][0]['row
end, end,
} }
orderDrawerTitle.value = `业务订单 · ${row.name}` orderDrawerTitle.value = `业务订单 · ${row.name}`
orderDrawerHint.value = `创建时间:${start} ${end} · 诊单医助 · 不含履约已取消` orderDrawerHint.value = `创建时间:${start} ${end} · 创建人=${row.name}(与排行榜诊金同口径)· 不含履约已取消`
orderDrawerPage.value = 1 orderDrawerPage.value = 1
orderDrawerVisible.value = true orderDrawerVisible.value = true
void fetchOrderDrawerPage() void fetchOrderDrawerPage()
@@ -2846,7 +2848,7 @@ function openOrderDrawerByAssistantErCenterRevisit(
} }
const slotLbl = revisitSlot === 0 ? '复诊合计' : yejiRevisitSlotColumnTitle(revisitSlot) const slotLbl = revisitSlot === 0 ? '复诊合计' : yejiRevisitSlotColumnTitle(revisitSlot)
orderDrawerTitle.value = `业务订单 · ${assistant.name} · ${slotLbl}` orderDrawerTitle.value = `业务订单 · ${assistant.name} · ${slotLbl}`
orderDrawerHint.value = `创建时间:${start} ${end} · 诊单医助 · 仅二中心复诊口径 · ${slotLbl} · 不含履约已取消` orderDrawerHint.value = `创建时间:${start} ${end} · 创建人=${assistant.name}(与部门表 / 排行榜复诊同口径)· 仅二中心复诊口径 · ${slotLbl} · 不含履约已取消`
orderDrawerPage.value = 1 orderDrawerPage.value = 1
orderDrawerVisible.value = true orderDrawerVisible.value = true
void fetchOrderDrawerPage() void fetchOrderDrawerPage()
@@ -143,6 +143,8 @@ const load = async () => {
await loadChannels() await loadChannels()
const res = await appointmentLists({ const res = await appointmentLists({
patient_id: props.diagnosisId, patient_id: props.diagnosisId,
/** 诊单维度拉挂号:后端豁免医生/医助与数据范围收窄 */
diag_scope_relax: 1,
page_no: 1, page_no: 1,
page_size: 500 page_size: 500
}) })
File diff suppressed because one or more lines are too long
@@ -20,6 +20,16 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
{ {
use HasDataScopeFilter; use HasDataScopeFilter;
/**
* 诊单编辑/详情「挂号记录」Tab:按诊单 ID 拉全量挂号,不做医生/医助角色收窄与数据范围过滤。
* 须同时传 patient_id(挂号表存的是诊单 id)与本开关,避免列表页被滥用拓宽可见范围。
*/
private function appointmentListsScopeRelaxedForDiagnosis(): bool
{
return (int) ($this->params['diag_scope_relax'] ?? 0) === 1
&& (int) ($this->params['patient_id'] ?? 0) > 0;
}
/** /**
* 数据隔离:医生或医助(u.assistant_id = diag.assistant_id)命中可见集合;progress_board 场景放开(看板跨医生查看) * 数据隔离:医生或医助(u.assistant_id = diag.assistant_id)命中可见集合;progress_board 场景放开(看板跨医生查看)
*/ */
@@ -28,6 +38,9 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
if ($progressBoard) { if ($progressBoard) {
return; return;
} }
if ($this->appointmentListsScopeRelaxedForDiagnosis()) {
return;
}
if (!$this->dataScopeShouldApply()) { if (!$this->dataScopeShouldApply()) {
return; return;
} }
@@ -128,7 +141,8 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
// 面诊进度看板:可切换查看各医生挂号,不按当前账号角色收窄 // 面诊进度看板:可切换查看各医生挂号,不按当前账号角色收窄
$progressBoard = (int) ($this->params['progress_board'] ?? 0) === 1; $progressBoard = (int) ($this->params['progress_board'] ?? 0) === 1;
if (!$progressBoard) { $diagScopeRelax = $this->appointmentListsScopeRelaxedForDiagnosis();
if (!$progressBoard && !$diagScopeRelax) {
// 如果是医生角色(role_id=1),只显示挂自己号的预约 // 如果是医生角色(role_id=1),只显示挂自己号的预约
if (in_array(1, $roleIds)) { if (in_array(1, $roleIds)) {
$query->where('a.doctor_id', $this->adminId); $query->where('a.doctor_id', $this->adminId);
@@ -288,7 +302,8 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
} }
$progressBoard = (int) ($this->params['progress_board'] ?? 0) === 1; $progressBoard = (int) ($this->params['progress_board'] ?? 0) === 1;
if (!$progressBoard) { $diagScopeRelax = $this->appointmentListsScopeRelaxedForDiagnosis();
if (!$progressBoard && !$diagScopeRelax) {
if (in_array(1, $roleIds)) { if (in_array(1, $roleIds)) {
$query->where('a.doctor_id', $this->adminId); $query->where('a.doctor_id', $this->adminId);
} }
@@ -117,6 +117,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
/** /**
* 业绩看板:仅列出二中心复诊口径下的业务订单(须同时传 assistant_id、start_time、end_timerevisit_slot=0 为全部复诊分项合计)。 * 业绩看板:仅列出二中心复诊口径下的业务订单(须同时传 assistant_id、start_time、end_timerevisit_slot=0 为全部复诊分项合计)。
* admin 维度按订单创建人 o.creator_id(与部门表复诊列、排行榜复诊列同口径)。
*/ */
private function applyYejiErCenterRevisitOrderFilter($query): void private function applyYejiErCenterRevisitOrderFilter($query): void
{ {
@@ -138,7 +139,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
if ($t0 === false || $t1 === false) { if ($t0 === false || $t1 === false) {
return; return;
} }
$ids = DeptLogic::listErCenterRevisitOrderIdsForAssistant( $ids = DeptLogic::listErCenterRevisitOrderIdsForCreator(
$assistantId, $assistantId,
$slot, $slot,
(int) $t0, (int) $t0,
@@ -870,16 +871,23 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
$query->whereExists("SELECT 1 FROM {$rxTbl} rx WHERE rx.id = {$poTbl}.prescription_id AND rx.delete_time IS NULL AND rx.creator_id = {$doctorId}"); $query->whereExists("SELECT 1 FROM {$rxTbl} rx WHERE rx.id = {$poTbl}.prescription_id AND rx.delete_time IS NULL AND rx.creator_id = {$doctorId}");
} }
/** 业绩看板入口(yeji_order_drawer=1)所有筛选统一按订单创建人;其它入口(处方订单列表 / 患者跟进等)保留旧口径(创建人 ∪ 诊单医助) */
$yejiPanel = (int) ($this->params['yeji_order_drawer'] ?? 0) === 1;
if (isset($this->params['assistant_id']) && (int) $this->params['assistant_id'] > 0) { if (isset($this->params['assistant_id']) && (int) $this->params['assistant_id'] > 0) {
$assistantId = (int) $this->params['assistant_id']; $assistantId = (int) $this->params['assistant_id'];
$diagTbl = (new Diagnosis())->getTable(); if ($yejiPanel) {
$query->where(function ($q) use ($poTbl, $diagTbl, $assistantId) { $query->where('creator_id', $assistantId);
$q->where('creator_id', $assistantId); } else {
$q->whereOrRaw( $diagTbl = (new Diagnosis())->getTable();
"EXISTS (SELECT 1 FROM `{$diagTbl}` dg WHERE dg.`id` = `{$poTbl}`.`diagnosis_id`" $query->where(function ($q) use ($poTbl, $diagTbl, $assistantId) {
. " AND dg.`delete_time` IS NULL AND dg.`assistant_id` = {$assistantId})" $q->where('creator_id', $assistantId);
); $q->whereOrRaw(
}); "EXISTS (SELECT 1 FROM `{$diagTbl}` dg WHERE dg.`id` = `{$poTbl}`.`diagnosis_id`"
. " AND dg.`delete_time` IS NULL AND dg.`assistant_id` = {$assistantId})"
);
});
}
} }
if (isset($this->params['assistant_dept_id']) && $this->params['assistant_dept_id'] !== '' && (int) $this->params['assistant_dept_id'] > 0) { if (isset($this->params['assistant_dept_id']) && $this->params['assistant_dept_id'] !== '' && (int) $this->params['assistant_dept_id'] > 0) {
@@ -909,23 +917,31 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
return; return;
} }
$inList = implode(',', $deptIds); $inList = implode(',', $deptIds);
$diagTbl = (new Diagnosis())->getTable();
$adTbl = (new AdminDept())->getTable(); $adTbl = (new AdminDept())->getTable();
$query->where(function ($q) use ($poTbl, $diagTbl, $adTbl, $inList) { if ($yejiPanel) {
$q->whereExists( /** 业绩看板部门侧栏:仅订单创建人人事部门 ∈ 子树(与「合计业绩」/「接诊诊单」/「复诊」聚合同口径) */
"SELECT 1 FROM `{$adTbl}` ad INNER JOIN `{$diagTbl}` dg ON ad.admin_id = dg.assistant_id AND dg.delete_time IS NULL " $query->whereExists(
. "WHERE dg.`id` = `{$poTbl}`.`diagnosis_id` AND ad.dept_id IN ({$inList})" "SELECT 1 FROM `{$adTbl}` adc WHERE adc.`admin_id` = `{$poTbl}`.`creator_id` AND adc.`dept_id` IN ({$inList})"
); );
$q->whereExists( } else {
"SELECT 1 FROM `{$adTbl}` adc WHERE adc.`admin_id` = `{$poTbl}`.`creator_id` AND adc.`dept_id` IN ({$inList})", $diagTbl = (new Diagnosis())->getTable();
'OR' $query->where(function ($q) use ($poTbl, $diagTbl, $adTbl, $inList) {
); $q->whereExists(
}); "SELECT 1 FROM `{$adTbl}` ad INNER JOIN `{$diagTbl}` dg ON ad.admin_id = dg.assistant_id AND dg.delete_time IS NULL "
. "WHERE dg.`id` = `{$poTbl}`.`diagnosis_id` AND ad.dept_id IN ({$inList})"
);
$q->whereExists(
"SELECT 1 FROM `{$adTbl}` adc WHERE adc.`admin_id` = `{$poTbl}`.`creator_id` AND adc.`dept_id` IN ({$inList})",
'OR'
);
});
}
} }
} }
/** /**
* 业绩看板侧栏:只保留「业绩归属医助」落在给定 admin_id 集合的订单(与 YejiStatsLogic::sqlPerformanceAttributionAdminExpr 一致)。 * 业绩看板侧栏:只保留「业绩归属医助」落在给定 admin_id 集合的订单
* (与 YejiStatsLogic::sqlPerformanceAttributionAdminExpr 一致——按订单创建人)。
* *
* @param int[] $adminIds * @param int[] $adminIds
*/ */
@@ -939,18 +955,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
return; return;
} }
$poTbl = (new PrescriptionOrder())->getTable(); $query->whereIn('creator_id', $adminIds);
$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 . '))'
);
} }
/** /**
+53 -70
View File
@@ -219,21 +219,24 @@ class DeptLogic extends BaseLogic
} }
return $q return $q
->field(['o.diagnosis_id', 'o.create_time', 'o.id', 'dg.assistant_id']) ->field(['o.diagnosis_id', 'o.create_time', 'o.id', 'dg.assistant_id', 'o.creator_id'])
->order(['o.diagnosis_id' => 'asc', 'o.create_time' => 'asc', 'o.id' => 'asc']) ->order(['o.diagnosis_id' => 'asc', 'o.create_time' => 'asc', 'o.id' => 'asc'])
->select() ->select()
->toArray(); ->toArray();
} }
/** /**
* 二中心子树:各部门复诊 rollup;同时返回按诊单医助聚合的分项(供医助排行榜)。 * 二中心子树:各部门复诊 rollup(按订单创建人 creator_id 归属)。
* 候选订单仍要求 dg.assistant_id > 0(保持「er-center 案例 = 有医助的诊单」的定义),
* 但**复诊归属**改为订单创建人——leaf 部门归属取 creator_id 在二中心子树内的 canonical 部门,
* 并产出 by_creator_slots 供医助排行榜复诊列复用,与诊金 / 接诊诊单口径完全一致。
* *
* @param list<int> $subtreeDeptIds * @param list<int> $subtreeDeptIds
* *
* @return array{ * @return array{
* totals: array<int, int>, * totals: array<int, int>,
* slots: array<int, array<int, int>>, * slots: array<int, array<int, int>>,
* by_assistant_slots: array<int, array<int, int>> * by_creator_slots: array<int, array<int, int>>
* } * }
*/ */
private static function computeErCenterRevisitFullPack( private static function computeErCenterRevisitFullPack(
@@ -249,12 +252,12 @@ class DeptLogic extends BaseLogic
$emptyTotals[$did] = 0; $emptyTotals[$did] = 0;
} }
if ($subtreeDeptIds === []) { if ($subtreeDeptIds === []) {
return ['totals' => [], 'slots' => [], 'by_assistant_slots' => []]; return ['totals' => [], 'slots' => [], 'by_creator_slots' => []];
} }
$subtreeSet = array_fill_keys($subtreeDeptIds, true); $subtreeSet = array_fill_keys($subtreeDeptIds, true);
$orderRows = self::fetchErCenterRevisitCandidateOrders($orderStartTs, $orderEndTs); $orderRows = self::fetchErCenterRevisitCandidateOrders($orderStartTs, $orderEndTs);
if ($orderRows === []) { if ($orderRows === []) {
return ['totals' => $emptyTotals, 'slots' => $emptySlots, 'by_assistant_slots' => []]; return ['totals' => $emptyTotals, 'slots' => $emptySlots, 'by_creator_slots' => []];
} }
$byPatient = []; $byPatient = [];
@@ -266,29 +269,30 @@ class DeptLogic extends BaseLogic
$byPatient[$pid][] = $r; $byPatient[$pid][] = $r;
} }
$assistantNeed = []; $creatorNeed = [];
foreach ($byPatient as $orders) { foreach ($byPatient as $orders) {
$n = \count($orders); $n = \count($orders);
for ($i = 1; $i < $n; $i++) { for ($i = 1; $i < $n; $i++) {
$aid = (int) ($orders[$i]['assistant_id'] ?? 0); $cid = (int) ($orders[$i]['creator_id'] ?? 0);
if ($aid > 0) { if ($cid > 0) {
$assistantNeed[$aid] = true; $creatorNeed[$cid] = true;
} }
} }
} }
$assistantIds = array_keys($assistantNeed); $creatorIds = array_keys($creatorNeed);
$canonicalDept = self::buildAssistantCanonicalDeptInSubtree($assistantIds, $subtreeSet); /** 复用 buildAssistantCanonicalDeptInSubtree —— 该方法实际是 admin → 子树内 canonical 部门,与 admin 角色无关 */
$canonicalDept = self::buildAssistantCanonicalDeptInSubtree($creatorIds, $subtreeSet);
/** @var array<int, array<int, int>> $leafByDeptSlot dept_id => [ slot => cnt ]slot≥2 */ /** @var array<int, array<int, int>> $leafByDeptSlot dept_id => [ slot => cnt ]slot≥2 */
$leafByDeptSlot = []; $leafByDeptSlot = [];
/** @var array<int, array<int, int>> $byAssistantSlot */ /** @var array<int, array<int, int>> $byCreatorSlot 复诊按订单创建人聚合(与诊金/接诊诊单口径一致) */
$byAssistantSlot = []; $byCreatorSlot = [];
foreach ($byPatient as $orders) { foreach ($byPatient as $orders) {
$n = \count($orders); $n = \count($orders);
for ($i = 1; $i < $n; $i++) { for ($i = 1; $i < $n; $i++) {
$slot = $i + 1; $slot = $i + 1;
$aid = (int) ($orders[$i]['assistant_id'] ?? 0); $cid = (int) ($orders[$i]['creator_id'] ?? 0);
$deptId = $canonicalDept[$aid] ?? null; $deptId = $canonicalDept[$cid] ?? null;
if ($deptId === null || !isset($subtreeSet[$deptId])) { if ($deptId === null || !isset($subtreeSet[$deptId])) {
continue; continue;
} }
@@ -296,11 +300,11 @@ class DeptLogic extends BaseLogic
$leafByDeptSlot[$deptId] = []; $leafByDeptSlot[$deptId] = [];
} }
$leafByDeptSlot[$deptId][$slot] = ($leafByDeptSlot[$deptId][$slot] ?? 0) + 1; $leafByDeptSlot[$deptId][$slot] = ($leafByDeptSlot[$deptId][$slot] ?? 0) + 1;
if ($aid > 0) { if ($cid > 0) {
if (!isset($byAssistantSlot[$aid])) { if (!isset($byCreatorSlot[$cid])) {
$byAssistantSlot[$aid] = []; $byCreatorSlot[$cid] = [];
} }
$byAssistantSlot[$aid][$slot] = ($byAssistantSlot[$aid][$slot] ?? 0) + 1; $byCreatorSlot[$cid][$slot] = ($byCreatorSlot[$cid][$slot] ?? 0) + 1;
} }
} }
} }
@@ -313,16 +317,16 @@ class DeptLogic extends BaseLogic
$rollupTotals[$id] = array_sum($rollupSlots[$id] ?? []); $rollupTotals[$id] = array_sum($rollupSlots[$id] ?? []);
} }
foreach ($byAssistantSlot as $aid => $sm) { foreach ($byCreatorSlot as $cid => $sm) {
if ($sm !== []) { if ($sm !== []) {
ksort($byAssistantSlot[$aid], SORT_NUMERIC); ksort($byCreatorSlot[$cid], SORT_NUMERIC);
} }
} }
return [ return [
'totals' => $rollupTotals, 'totals' => $rollupTotals,
'slots' => $rollupSlots, 'slots' => $rollupSlots,
'by_assistant_slots' => $byAssistantSlot, 'by_creator_slots' => $byCreatorSlot,
]; ];
} }
@@ -345,11 +349,11 @@ class DeptLogic extends BaseLogic
} }
/** /**
* 二中心子树内:各医助成交的复诊业务单笔数(合计 + 复诊2/3/…分项),口径与部门 rollup 同源 * 二中心子树复诊:按订单创建人 creator_id 聚合(与部门 rollup 同源,与诊金/接诊诊单口径一致)
* *
* @return array{totals: array<int, int>, slots: array<int, array<int, int>>} * @return array{totals: array<int, int>, slots: array<int, array<int, int>>}
*/ */
public static function getErCenterRevisitCountsByAssistant(?int $orderStartTs = null, ?int $orderEndTs = null): array public static function getErCenterRevisitCountsByCreator(?int $orderStartTs = null, ?int $orderEndTs = null): array
{ {
$erRoots = self::findErCenterRootDeptIds(); $erRoots = self::findErCenterRootDeptIds();
$subtreeIds = self::unionErCenterSubtreeDeptIds($erRoots); $subtreeIds = self::unionErCenterSubtreeDeptIds($erRoots);
@@ -357,42 +361,36 @@ class DeptLogic extends BaseLogic
return ['totals' => [], 'slots' => []]; return ['totals' => [], 'slots' => []];
} }
$pack = self::computeErCenterRevisitFullPack($subtreeIds, $orderStartTs, $orderEndTs); $pack = self::computeErCenterRevisitFullPack($subtreeIds, $orderStartTs, $orderEndTs);
$by = $pack['by_assistant_slots']; $by = $pack['by_creator_slots'];
$totals = []; $totals = [];
$slots = []; $slots = [];
foreach ($by as $aid => $slotMap) { foreach ($by as $cid => $slotMap) {
$aid = (int) $aid; $cid = (int) $cid;
$totals[$aid] = (int) array_sum($slotMap); $totals[$cid] = (int) array_sum($slotMap);
$slots[$aid] = $slotMap; $slots[$cid] = $slotMap;
} }
return ['totals' => $totals, 'slots' => $slots]; return ['totals' => $totals, 'slots' => $slots];
} }
/** /**
* 某医助在区间内、计入二中心复诊的业务订单 id(用于看板侧栏按复诊笔数下钻)。 * 二中心子树复诊:列出某「订单创建人」在区间内、复诊序列(slot≥2)命中的业务订单 id(用于看板侧栏按复诊笔数下钻)。
* $revisitSlot = 0 表示累计全部复诊(第 2 笔起);≥2 时仅该分项。 * $revisitSlot = 0 表示累计全部复诊(第 2 笔起);≥2 时仅该分项。
* *
* @return list<int> * @return list<int>
*/ */
public static function listErCenterRevisitOrderIdsForAssistant( public static function listErCenterRevisitOrderIdsForCreator(
int $assistantId, int $creatorId,
int $revisitSlot, int $revisitSlot,
int $orderStartTs, int $orderStartTs,
int $orderEndTs int $orderEndTs
): array { ): array {
if ($assistantId <= 0 || $orderStartTs <= 0 || $orderEndTs < $orderStartTs) { if ($creatorId <= 0 || $orderStartTs <= 0 || $orderEndTs < $orderStartTs) {
return []; return [];
} }
if ($revisitSlot < 0 || $revisitSlot === 1) { if ($revisitSlot < 0 || $revisitSlot === 1) {
return []; return [];
} }
$erRoots = self::findErCenterRootDeptIds();
$subtreeIds = self::unionErCenterSubtreeDeptIds($erRoots);
if ($subtreeIds === []) {
return [];
}
$subtreeSet = array_fill_keys($subtreeIds, true);
$orderRows = self::fetchErCenterRevisitCandidateOrders($orderStartTs, $orderEndTs); $orderRows = self::fetchErCenterRevisitCandidateOrders($orderStartTs, $orderEndTs);
if ($orderRows === []) { if ($orderRows === []) {
return []; return [];
@@ -405,31 +403,16 @@ class DeptLogic extends BaseLogic
} }
$byPatient[$pid][] = $r; $byPatient[$pid][] = $r;
} }
$assistantNeed = [];
foreach ($byPatient as $orders) {
$n = \count($orders);
for ($i = 1; $i < $n; $i++) {
$aid = (int) ($orders[$i]['assistant_id'] ?? 0);
if ($aid > 0) {
$assistantNeed[$aid] = true;
}
}
}
$canonicalDept = self::buildAssistantCanonicalDeptInSubtree(array_keys($assistantNeed), $subtreeSet);
$ids = []; $ids = [];
foreach ($byPatient as $orders) { foreach ($byPatient as $orders) {
$n = \count($orders); $n = \count($orders);
for ($i = 1; $i < $n; $i++) { for ($i = 1; $i < $n; $i++) {
$slot = $i + 1; $slot = $i + 1;
$aid = (int) ($orders[$i]['assistant_id'] ?? 0);
if ($aid !== $assistantId) {
continue;
}
if ($revisitSlot > 0 && $slot !== $revisitSlot) { if ($revisitSlot > 0 && $slot !== $revisitSlot) {
continue; continue;
} }
$dcan = $canonicalDept[$aid] ?? null; $cid = (int) ($orders[$i]['creator_id'] ?? 0);
if ($dcan === null || !isset($subtreeSet[$dcan])) { if ($cid !== $creatorId) {
continue; continue;
} }
$oid = (int) ($orders[$i]['id'] ?? 0); $oid = (int) ($orders[$i]['id'] ?? 0);
@@ -443,8 +426,8 @@ class DeptLogic extends BaseLogic
} }
/** /**
* 某展示部门行(组织架构上含下级 rollup)内,二中心复诊按诊单医助拆解笔数。 * 某展示部门行(组织架构上含下级 rollup)内,二中心复诊按**订单创建人**拆解笔数。
* 与看板行内「复诊」列口径一致:诊单下订单序列第 2 笔起;医助 canonical 部门须落在二中心子树且在该部门行的子树内。 * 与看板行内「复诊」列口径一致:订单序列第 2 笔起;创建人 canonical 部门须落在二中心子树且在该部门行的子树内。
* *
* @return array{rows: list<array{admin_id: int, name: string, order_count: int}>} * @return array{rows: list<array{admin_id: int, name: string, order_count: int}>}
*/ */
@@ -484,17 +467,17 @@ class DeptLogic extends BaseLogic
} }
$byPatient[$pid][] = $r; $byPatient[$pid][] = $r;
} }
$assistantNeed = []; $creatorNeed = [];
foreach ($byPatient as $orders) { foreach ($byPatient as $orders) {
$n = \count($orders); $n = \count($orders);
for ($i = 1; $i < $n; $i++) { for ($i = 1; $i < $n; $i++) {
$aid = (int) ($orders[$i]['assistant_id'] ?? 0); $cid = (int) ($orders[$i]['creator_id'] ?? 0);
if ($aid > 0) { if ($cid > 0) {
$assistantNeed[$aid] = true; $creatorNeed[$cid] = true;
} }
} }
} }
$canonicalDept = self::buildAssistantCanonicalDeptInSubtree(array_keys($assistantNeed), $erSubtreeSet); $canonicalDept = self::buildAssistantCanonicalDeptInSubtree(array_keys($creatorNeed), $erSubtreeSet);
/** @var array<int, int> $counts */ /** @var array<int, int> $counts */
$counts = []; $counts = [];
foreach ($byPatient as $orders) { foreach ($byPatient as $orders) {
@@ -504,15 +487,15 @@ class DeptLogic extends BaseLogic
if ($revisitSlot > 0 && $slot !== $revisitSlot) { if ($revisitSlot > 0 && $slot !== $revisitSlot) {
continue; continue;
} }
$aid = (int) ($orders[$i]['assistant_id'] ?? 0); $cid = (int) ($orders[$i]['creator_id'] ?? 0);
if ($aid <= 0) { if ($cid <= 0) {
continue; continue;
} }
$canon = $canonicalDept[$aid] ?? null; $canon = $canonicalDept[$cid] ?? null;
if ($canon === null || !isset($erSubtreeSet[$canon]) || !isset($descFlip[$canon])) { if ($canon === null || !isset($erSubtreeSet[$canon]) || !isset($descFlip[$canon])) {
continue; continue;
} }
$counts[$aid] = ($counts[$aid] ?? 0) + 1; $counts[$cid] = ($counts[$cid] ?? 0) + 1;
} }
} }
if ($counts === []) { if ($counts === []) {
@@ -527,10 +510,10 @@ class DeptLogic extends BaseLogic
->column('name', 'id'); ->column('name', 'id');
} }
$rows = []; $rows = [];
foreach ($counts as $aid => $cnt) { foreach ($counts as $cid => $cnt) {
$rows[] = [ $rows[] = [
'admin_id' => (int) $aid, 'admin_id' => (int) $cid,
'name' => (string) ($nameRows[$aid] ?? ('#' . $aid)), 'name' => (string) ($nameRows[$cid] ?? ('#' . $cid)),
'order_count' => (int) $cnt, 'order_count' => (int) $cnt,
]; ];
} }
+317 -205
View File
@@ -18,17 +18,17 @@ use think\facade\Db;
* · 与 qywx.customer/todayArrival、客户列表同源事件表;选定渠道时客户须带对应企微标签(contact_tag);**二中心及其组织下级在选定渠道下进线计 0** * · 与 qywx.customer/todayArrival、客户列表同源事件表;选定渠道时客户须带对应企微标签(contact_tag);**二中心及其组织下级在选定渠道下进线计 0**
* - 被指派数 = 区间内 `tcm_diagnosis_assign_log` 且 **to_assistant_id>0**,且 **related_po_create_time>0** 并落在区间(**一律按快照字段统计**,与履约释放写入一致;诊单后台指派时会回填该字段,见 DiagnosisLogic::assign * - 被指派数 = 区间内 `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(含下级),与排行榜复诊一致
* - 已完成挂号 = status=3 挂号条数(appointment_date),列「已完成挂号」;部门归属:有效医助→接诊医生→展示行映射。 * - 已完成挂号 = status=3 挂号条数(appointment_date),列「已完成挂号」;部门归属:有效医助→接诊医生→展示行映射。
* - 接诊诊单 = 列名对应**业务订单条数**(非取消、create_time 区间),与处方列表按 **assistant_dept_id** 筛选时一致:诊单 **assistant_id** 或订单 **creator_id** 任一人事部门落在该行子树即计入;多行命中时取**最深的展示部门行**,与「业绩归属医助」分组可能不同 * - 接诊诊单 = 列名对应**业务订单条数**(非取消、create_time 区间),按**订单创建人**人事部门落在该行子树即计入;多行命中时取**最深的展示部门行**。与处方列表按 assistant_dept_id 筛选不同(列表保留旧口径:诊单医助 ∪ 创建人)
* - {渠道}成交单 = 选定渠道时与 {渠道}业绩同源:区间内 **非取消业务订单条数**(仅剔除 fulfillment_status=4NULL 与其它状态计入,按 create_time 归日);dict(channels) 与 tag 口径均按订单计(tag 时按诊单医助收窄),非挂号条数。 * - {渠道}成交单 = 选定渠道时与 {渠道}业绩同源:区间内 **非取消业务订单条数**(仅剔除 fulfillment_status=4NULL 与其它状态计入,按 create_time 归日);dict(channels) 与 tag 口径均按订单计(按订单创建人归属),非挂号条数。
* - 合计业绩 = 区间内业务订单 amount 求和(排除已取消(4) 与软删),**部门列**与「接诊诊单」一致:诊单医助或订单创建人人事部门落在该展示行子树则计入,多行命中取最深展示行;与处方列表按 assistant_dept_id 筛选后的金额合计一致 * - 合计业绩 = 区间内业务订单 amount 求和(排除已取消(4) 与软删),**部门列**与「接诊诊单」一致:**订单创建人**人事部门落在该展示行子树则计入,多行命中取最深展示行;与排行榜诊金同口径
* 无法归入任一展示部门的金额单列「未归属中心」行;**全表合计行**仍为同时间/业绩条件的全表 SUM(不限部门) * 无法归入任一展示部门的金额单列「未归属中心」行;**底栏「合计业绩」「接诊诊单」= 当前表内各行业绩列相加**(含未归属行),与弹窗摊分口径一致
* · 时间窗与 stats_order_amount_performance 一致;列表若另有患者筛选或账号数据域收窄,金额可能仍不同 * · 时间窗与 stats_order_amount_performance 一致;列表若另有患者筛选或账号数据域收窄,金额可能仍不同
* · 选定渠道时「{渠道}业绩」列仍按比例展示;合计列全量与渠道列含未归属补差;**二中心及其组织下级展示部门不参与该渠道进线/业绩/成交单/ROI 分子**(与同模块 DeptLogic::getErCenterSubtreeDeptIdSet 一致) * · 选定渠道时「{渠道}业绩」列仍按比例展示;合计列全量与渠道列含未归属补差;**二中心及其组织下级展示部门不参与该渠道进线/业绩/成交单/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,与选定渠道下进线清零规则独立);其余部门按进线占比分摊。合计行成本 = 账务汇总总额。
@@ -482,27 +482,6 @@ class YejiStatsLogic
} }
$totalBooked = $narrowTotalsToTable ? $bookedSumInTable : array_sum($bookedByDept); $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) { if ($narrowTotalsToTable) {
$totalConsult = $consultSumInTable; $totalConsult = $consultSumInTable;
$unmappedDealOrder = 0; $unmappedDealOrder = 0;
@@ -539,15 +518,20 @@ class YejiStatsLogic
]; ];
} }
if ($narrowTotalsToTable) { // 底栏「合计业绩」「{渠道}业绩」「接诊诊单」= 当前表格全部数据行相加(含「未归属中心」),
$totalPerf = $listAlignedTotalPerf !== null ? $listAlignedTotalPerf : $allocatedPerf; // 与各行展示口径一致;不再使用全表 SUM(会与仅含展示部门的摊分行脱钩)。
$totalChannelPerf = $allocatedChannelPerf; $sumPerfRows = 0.0;
$totalDealOrderCount = $listAlignedDealCnt !== null ? $listAlignedDealCnt : $allocatedDealOrderSum; $sumChannelPerfRows = 0.0;
} else { $sumDealRows = 0;
$totalPerf = $globalPerfAll; foreach ($rows as $rw) {
$totalChannelPerf = $globalPerfChannel; $sumPerfRows += (float) ($rw['performance_amount'] ?? 0);
$totalDealOrderCount = $globalDealOrderCount; $sumChannelPerfRows += (float) ($rw['completed_performance_amount'] ?? 0);
$sumDealRows += (int) ($rw['deal_order_count'] ?? 0);
} }
$totalPerf = round($sumPerfRows, 1);
$totalChannelPerf = round($sumChannelPerfRows, 1);
$totalDealOrderCount = $sumDealRows;
$totalCost = round($globalCost, 1); $totalCost = round($globalCost, 1);
// ROI 合计:选渠道时底栏「{渠道}业绩合计」已扣除二中心子树($erCenterChannelStrip);未选渠道时用全表业绩并减去各二中心子树展示行之和 // ROI 合计:选渠道时底栏「{渠道}业绩合计」已扣除二中心子树($erCenterChannelStrip);未选渠道时用全表业绩并减去各二中心子树展示行之和
@@ -566,12 +550,12 @@ class YejiStatsLogic
} }
$scopedTableNote = ($explicitDeptFilter $scopedTableNote = ($explicitDeptFilter
? ' 已筛选展示部门时,表格「合计业绩」「接诊诊单」各行为同一套列表部门口径(诊单医助或创建人∪子树、最深展示行);底栏合计业绩/成交单与处方订单列表 extend(所选部门树+数据域)一致,可能与各行相加不同。' ? ' 已筛选展示部门时,表格仅展示所选树中的部门行;底栏「合计业绩」「接诊诊单」与当前表内各行业绩列相加一致(含「未归属中心」行若展示)。'
: '') : '')
. ($dataScopeRestricted . ($dataScopeRestricted
? ' 当前账号受数据范围限制时,底栏合计业绩与列表同域。' ? ' 当前账号受数据范围限制时,底栏合计业绩与列表同域。'
: ''); : '');
$baseNoteNoChannel = '业绩口径:除履约已取消(4)外全部计入。部门行「合计业绩」「接诊诊单」均为列表 assistant_dept_id 口径(诊单医助或订单创建人人事部门∪子树,多行命中取最深展示行)。多表「本月」区间为当月1日至服务器当日。**投放成本**按进线占比分摊,**二中心子树展示部门进线在分摊中计 0**(与部门 ErCenter 子树一致)。'; $baseNoteNoChannel = '业绩口径:除履约已取消(4)外全部计入。部门行「合计业绩」「接诊诊单」「复诊」均按**订单创建人人事部门 ∈ 该展示行子树**归属(多行命中取最深展示行),与医助排行榜诊金一致;与处方订单列表 assistant_dept_id 默认筛选(诊单医助 ∪ 创建人)不同——侧栏点击会自动收窄为创建人口径。多表「本月」区间为当月1日至服务器当日。**投放成本**按进线占比分摊,**二中心子树展示部门进线在分摊中计 0**(与部门 ErCenter 子树一致)。';
return [ return [
'start_date' => $startDate, 'start_date' => $startDate,
@@ -581,9 +565,9 @@ class YejiStatsLogic
'channel_name' => $channelInfo ? (string) $channelInfo['channel_name'] : '', 'channel_name' => $channelInfo ? (string) $channelInfo['channel_name'] : '',
'channel_group' => $channelInfo ? (string) $channelInfo['source_group_name'] : '', 'channel_group' => $channelInfo ? (string) $channelInfo['source_group_name'] : '',
'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 筛选时一致(诊单医助或创建人∪子树)。**二中心及其组织下级**(与部门模块 ErCenter 子树一致)**不计入**该渠道进线、业绩与成交单。**投放成本**按各部门进线条数占展示部门进线合计的比例分摊,**二中心子树进线在分摊中计 0**(未选渠道时仍显示进线数,仅不参与均摊)。ROI = 渠道业绩(不含二中心子树)÷ 总投放成本。进线按所选标签客户精确过滤。' . ' 「已完成挂号」列同 admin 挂号已完成列表;「接诊诊单」列为业务订单条数(非取消),同样按订单创建人归部门。**二中心及其组织下级**(与部门模块 ErCenter 子树一致)**不计入**该渠道进线、业绩与成交单。**投放成本**按各部门进线条数占展示部门进线合计的比例分摊,**二中心子树进线在分摊中计 0**(未选渠道时仍显示进线数,仅不参与均摊)。ROI = 渠道业绩(不含二中心子树)÷ 总投放成本。进线按所选标签客户精确过滤。'
. ($tagFallback . ($tagFallback
? ' ⚠ 当前业务尚未回填 tcm_diagnosis.external_userid 与 user_auth.unionid,已自动退化为「该标签客户的跟进医助 → 区间内接诊/业绩」口径,仅用于趋势观察。' ? ' ⚠ 当前业务尚未回填 tcm_diagnosis.external_userid 与 user_auth.unionid,已自动退化为「该标签客户的跟进医助 → 区间内接诊/业绩」口径,仅用于趋势观察。'
: '') : '')
@@ -740,7 +724,8 @@ class YejiStatsLogic
} }
$erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet(); $erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet();
$revisitByAssistant = DeptLogic::getErCenterRevisitCountsByAssistant($startTs, $endTs); /** 医助排行榜复诊:按订单创建人归属(与诊金 / 接诊诊单同口径),不再用 dg.assistant_id */
$revisitByAssistant = DeptLogic::getErCenterRevisitCountsByCreator($startTs, $endTs);
$assistantIdRows = Db::name('admin_role')->where('role_id', 2)->column('admin_id'); $assistantIdRows = Db::name('admin_role')->where('role_id', 2)->column('admin_id');
$assistantSet = []; $assistantSet = [];
@@ -766,10 +751,10 @@ class YejiStatsLogic
} }
$rangeNote = $channelFilterActive $rangeNote = $channelFilterActive
? ('排行诊金 = 选定渠道「' . $channelName . '」业绩口径(与部门表该渠道业绩列一致)。被指派数与部门表同口径。接诊率 = 诊金 ÷ 进线(元/进线)。' ? ('排行诊金 = 选定渠道「' . $channelName . '」业绩中「订单创建人 = 该医助」的部分;与部门表该渠道业绩列同口径(按订单创建人归属)。被指派数与部门表同口径。接诊率 = 诊金 ÷ 进线(元/进线)。'
. '「二中心」及其组织下级医助在该渠道下诊金、进线与被指派计 0。' . '「二中心」及其组织下级医助在该渠道下诊金、进线与被指派计 0。'
. ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。') . ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。')
: '排行诊金 = 处方订单列表医助口径(诊单医助或代建创建人与列表一致)。被指派数为指派至该医助的次数。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。' : '排行诊金 = 业务订单中「订单创建人 = 该医助」的金额合计;与部门表「合计业绩」同口径(按订单创建人归属)。被指派数为指派至该医助的次数。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。'
. ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。'; . ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。';
$leaderboards = []; $leaderboards = [];
@@ -2966,6 +2951,7 @@ class YejiStatsLogic
/** /**
* 与 sumPerformance 对应,按 admin 维度返回全量/渠道业绩金额(不按部门折叠)。 * 与 sumPerformance 对应,按 admin 维度返回全量/渠道业绩金额(不按部门折叠)。
* 归属固定为 sqlPerformanceAttributionAdminExpr()——按订单创建人。
* *
* @param int[] $appointmentChannelValues * @param int[] $appointmentChannelValues
* *
@@ -3001,7 +2987,7 @@ class YejiStatsLogic
} }
/** /**
* 与 sumPerformanceByAdmin 对应:按诊单医助统计成交单数(非取消业务订单条数),全量/渠道分支一致。 * 与 sumPerformanceByAdmin 对应:按订单创建人统计成交单数(非取消业务订单条数),全量/渠道分支一致。
* *
* @param int[] $appointmentChannelValues * @param int[] $appointmentChannelValues
* *
@@ -3091,7 +3077,7 @@ class YejiStatsLogic
} }
/** /**
* dict(channels) 渠道成交单数按诊单医助聚合(与 aggregatePerformanceAmountsByAssistantChannel 同源)。 * dict(channels) 渠道成交单数按业绩归属医助聚合(与 aggregatePerformanceAmountsByAssistantChannel 同源)。
* *
* @param int[] $appointmentChannelValues * @param int[] $appointmentChannelValues
* *
@@ -3146,7 +3132,7 @@ class YejiStatsLogic
} }
/** /**
* 业绩按归属医助聚合到 admin(与处方订单列表医助筛选口径一致:诊单医助或代建创建人)。 * 业绩按归属医助聚合到 admin(统一按订单创建人 o.creator_id;与 sqlPerformanceAttributionAdminExpr 一致)。
* *
* @return array<int, float> * @return array<int, float>
*/ */
@@ -3335,13 +3321,14 @@ class YejiStatsLogic
/** /**
* 业绩归属医助 admin_id SQL 表达式(表别名固定为 o / dg / rx)。 * 业绩归属医助 admin_id SQL 表达式(表别名固定为 o / dg / rx)。
* 与 PrescriptionOrderLists 医助筛选一致:诊单 assistant_id 优先;否则订单创建人且非关联处方开方人 * 业绩看板(部门表 / 医助排行榜 / 渠道列 / ROI / 复诊 / 未归属中心)统一按订单创建人 o.creator_id 归属
* 即:诊金、接诊诊单、复诊、渠道业绩等所有金额/条数仅记给实际下单的医助;不再回退到诊单 assistant_id。
* 处方订单列表侧栏(yeji_order_drawer=1)的「医助」/「部门」筛选与本归属对齐;其它管理端的 assistant_id /
* assistant_dept_id 列表过滤保留旧口径(创建人 ∪ 诊单医助),不受本表达式影响。
*/ */
private static function sqlPerformanceAttributionAdminExpr(): string private static function sqlPerformanceAttributionAdminExpr(): string
{ {
return 'CASE WHEN dg.assistant_id > 0 THEN dg.assistant_id ' return 'o.creator_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';
} }
/** /**
@@ -3411,106 +3398,6 @@ class YejiStatsLogic
return array_values(array_unique($out)); 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,无部门归属过滤)。 * 标签/医助兜底口径下的渠道向业绩全量 SUM(与 sumPerformance 的 scoped 分支同一组 WHERE,无部门归属过滤)。
*/ */
@@ -3657,7 +3544,7 @@ class YejiStatsLogic
if ($adminToPrimary === []) { if ($adminToPrimary === []) {
return ['all' => [], 'scoped' => []]; return ['all' => [], 'scoped' => []];
} }
// 1) 部门「合计业绩」:有展示行时与「接诊诊单」同列表部门口径(可由 overview 预计算避免重复查单 // 1) 部门「合计业绩」:有展示行时与「接诊诊单」同属 {@see aggregateListAlignedOrderMetricsByDept}(仅订单创建人摊行
if ($precomputedListAlignedAmountByDept !== null) { if ($precomputedListAlignedAmountByDept !== null) {
$allByDept = []; $allByDept = [];
foreach ($precomputedListAlignedAmountByDept as $deptId => $v) { foreach ($precomputedListAlignedAmountByDept as $deptId => $v) {
@@ -3749,7 +3636,7 @@ class YejiStatsLogic
/** /**
* 渠道业绩:与「处方订单列表」业绩完全同口径 —— 按 o.create_time 归日、仅剔除已取消(4)(含 NULL); * 渠道业绩:与「处方订单列表」业绩完全同口径 —— 按 o.create_time 归日、仅剔除已取消(4)(含 NULL);
* EXISTS 仅用于"该患者属于此渠道"的判定(有任一挂号 channels 命中 + status=3**不限挂号时点**); * EXISTS 仅用于"该患者属于此渠道"的判定(有任一挂号 channels 命中 + status=3**不限挂号时点**);
* 部门归属按业绩归属医助(与处方列表医助条件一致:诊单医助或代建创建人)。 * 部门归属按业绩归属表达式(订单创建人 o.creator_id)。
*/ */
private static function aggregatePerformanceByAppointmentChannels( private static function aggregatePerformanceByAppointmentChannels(
int $startTs, int $startTs,
@@ -3937,8 +3824,8 @@ class YejiStatsLogic
} }
/** /**
* 与 {@see PrescriptionOrderLists::applyDoctorAssistantFilters} 中 assistant_dept_id(未开抽屉对齐开关)一致: * 与业绩看板部门列一致:仅按订单创建人候选,将其人事部门落在展示行子树即命中;命中多行时取组织深度最大的展示行。
* 诊单医助或订单创建人的人事部门落在该行子树即命中;命中多行时取组织深度最大的展示行。 * (处方订单列表默认 assistant_dept_id 仍为「诊单医助 ∪ 创建人」并集,与看板有别。)
* *
* @param array<int, true> $candidates admin_id => true * @param array<int, true> $candidates admin_id => true
* @param array<int, int[]> $adminDeptIdList * @param array<int, int[]> $adminDeptIdList
@@ -4012,7 +3899,8 @@ class YejiStatsLogic
} }
/** /**
* 列表部门口径(诊单医助 ∪ 创建人 ⇄ 展示行子树,最深行):同时聚单笔数与金额,供「接诊诊单」与「合计业绩」列共用。 * 看板「合计业绩 / 接诊诊单」列用的部门摊行:仅按订单创建人 o.creator_id 的人事部门映射到展示行子树(命中多行取最深),
* 与侧栏 yeji_order_drawer=1、{@see sqlPerformanceAttributionAdminExpr} 一致。不再把诊单医助纳入归属候选。
* *
* @return array{count: array<int, int>, amount: array<int, float>} * @return array{count: array<int, int>, amount: array<int, float>}
*/ */
@@ -4038,7 +3926,6 @@ class YejiStatsLogic
} }
$diagTable = self::tableWithPrefix('tcm_diagnosis'); $diagTable = self::tableWithPrefix('tcm_diagnosis');
$att = self::sqlPerformanceAttributionAdminExpr();
$query = Db::name('tcm_prescription_order') $query = Db::name('tcm_prescription_order')
->alias('o') ->alias('o')
->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER') ->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
@@ -4047,21 +3934,20 @@ class YejiStatsLogic
->where('o.diagnosis_id', '>', 0); ->where('o.diagnosis_id', '>', 0);
self::joinPrescriptionForPerformanceAttribution($query); self::joinPrescriptionForPerformanceAttribution($query);
self::applyPrescriptionOrderNotCancelledForPerformance($query); self::applyPrescriptionOrderNotCancelledForPerformance($query);
$query->whereRaw('(dg.assistant_id > 0 OR o.creator_id > 0)'); $query->where('o.creator_id', '>', 0);
if ($tagDiagIds !== null) { if ($tagDiagIds !== null) {
$query->whereIn('o.diagnosis_id', $tagDiagIds); $query->whereIn('o.diagnosis_id', $tagDiagIds);
} }
if ($tagAssistantIds !== null) { if ($tagAssistantIds !== null) {
$ids = array_map('intval', array_keys($tagAssistantIds)); $ids = array_map('intval', array_keys($tagAssistantIds));
$in = implode(',', $ids); $in = implode(',', $ids);
$query->whereRaw("(({$att}) IN ({$in}) OR dg.assistant_id IN ({$in}) OR o.creator_id IN ({$in}))"); $query->whereRaw("o.creator_id IN ({$in})");
} }
if ($channelScopedExistsSql !== null && $channelScopedExistsSql !== '') { if ($channelScopedExistsSql !== null && $channelScopedExistsSql !== '') {
$query->whereRaw($channelScopedExistsSql, $channelScopedExistsBindings); $query->whereRaw($channelScopedExistsSql, $channelScopedExistsBindings);
} }
$orderRows = $query->field([ $orderRows = $query->field([
Db::raw('dg.assistant_id AS diag_assistant_id'),
'o.creator_id', 'o.creator_id',
'o.amount', 'o.amount',
])->select()->toArray(); ])->select()->toArray();
@@ -4081,11 +3967,7 @@ class YejiStatsLogic
$adminIds = []; $adminIds = [];
foreach ($orderRows as $r) { foreach ($orderRows as $r) {
$a = (int) ($r['diag_assistant_id'] ?? 0);
$c = (int) ($r['creator_id'] ?? 0); $c = (int) ($r['creator_id'] ?? 0);
if ($a > 0) {
$adminIds[] = $a;
}
if ($c > 0) { if ($c > 0) {
$adminIds[] = $c; $adminIds[] = $c;
} }
@@ -4107,15 +3989,11 @@ class YejiStatsLogic
$byCnt = []; $byCnt = [];
$byAmt = []; $byAmt = [];
foreach ($orderRows as $r) { foreach ($orderRows as $r) {
$asst = (int) ($r['diag_assistant_id'] ?? 0);
$cr = (int) ($r['creator_id'] ?? 0); $cr = (int) ($r['creator_id'] ?? 0);
$cand = []; if ($cr <= 0) {
if ($asst > 0) { continue;
$cand[$asst] = true;
}
if ($cr > 0) {
$cand[$cr] = true;
} }
$cand = [$cr => true];
$deptKey = self::resolveDeepestYejiTableRowForListDeptOr( $deptKey = self::resolveDeepestYejiTableRowForListDeptOr(
$cand, $cand,
$adminDeptIdList, $adminDeptIdList,
@@ -4136,9 +4014,9 @@ class YejiStatsLogic
} }
/** /**
* 成交单数按部门:**处方订单列表**按 assistant_dept_id 筛选时的业务单条数(诊单医助或创建人 ∪ 部门子树), * 成交单数按部门:有展示行时与 {@see aggregateListAlignedOrderMetricsByDept} 同口径(仅订单创建人归属)。
* 与 {@see PrescriptionOrderLists::applyDoctorAssistantFilters} 在 <b>未</b> 传 yeji_drawer_match_table_performance 时一致 * 无展示行时按 sqlPerformanceAttributionAdminExpr 分组再 fold(同样为创建人)
* 选定医助 tag 时收窄为(业绩归属医助 ∈ 集 **或** 诊单医助 ∈ 集 **或** 创建人 ∈ 集 * 选定医助 tag 时收窄为 o.creator_id ∈ 集。
* *
* @return array<int, int> * @return array<int, int>
*/ */
@@ -4179,14 +4057,14 @@ class YejiStatsLogic
->where('o.diagnosis_id', '>', 0); ->where('o.diagnosis_id', '>', 0);
self::joinPrescriptionForPerformanceAttribution($query); self::joinPrescriptionForPerformanceAttribution($query);
self::applyPrescriptionOrderNotCancelledForPerformance($query); self::applyPrescriptionOrderNotCancelledForPerformance($query);
$query->whereRaw('(dg.assistant_id > 0 OR o.creator_id > 0)'); $query->where('o.creator_id', '>', 0);
if ($tagDiagIds !== null) { if ($tagDiagIds !== null) {
$query->whereIn('o.diagnosis_id', $tagDiagIds); $query->whereIn('o.diagnosis_id', $tagDiagIds);
} }
if ($tagAssistantIds !== null) { if ($tagAssistantIds !== null) {
$ids = array_map('intval', array_keys($tagAssistantIds)); $ids = array_map('intval', array_keys($tagAssistantIds));
$in = implode(',', $ids); $in = implode(',', $ids);
$query->whereRaw("(({$att}) IN ({$in}) OR dg.assistant_id IN ({$in}) OR o.creator_id IN ({$in}))"); $query->whereRaw("o.creator_id IN ({$in})");
} }
$query->whereRaw("({$att}) > 0", []); $query->whereRaw("({$att}) > 0", []);
@@ -4446,7 +4324,7 @@ class YejiStatsLogic
} }
/** /**
* 「未归属中心」业绩下钻:按诊单医助汇总无法映射到展示「中心」的金额(与 aggregatePerformance 部门行互补)。 * 「未归属中心」业绩下钻:按订单创建人汇总无法映射到展示「中心」的金额(与 aggregatePerformance 部门行互补)。
* *
* @return array{ * @return array{
* start_date: string, * start_date: string,
@@ -4533,7 +4411,7 @@ class YejiStatsLogic
foreach ($rows as &$row) { foreach ($rows as &$row) {
$aid = (int) $row['admin_id']; $aid = (int) $row['admin_id'];
if ($aid <= 0) { if ($aid <= 0) {
$row['name'] = '(无单或医助未填)'; $row['name'] = '(无单或创建人未填)';
} else { } else {
$row['name'] = (string) ($names[$aid] ?? ('#' . $aid)); $row['name'] = (string) ($names[$aid] ?? ('#' . $aid));
} }
@@ -4545,7 +4423,7 @@ class YejiStatsLogic
'end_date' => (string) $c['endDate'], 'end_date' => (string) $c['endDate'],
'channel_code' => (string) $c['channelCode'], 'channel_code' => (string) $c['channelCode'],
'rows' => $rows, 'rows' => $rows,
'note' => '与看板「未归属中心」补差一致:按订单 create_time、剔除履约已取消(4)诊单医助无法映射到任一展示「中心」计入各行;其余(无诊单/医助为空)汇总在首行。账号数据权限下仅列出可见医助。', 'note' => '与看板「未归属中心」补差一致:按订单 create_time、剔除履约已取消(4)**订单创建人**人事部门无法映射到任一展示「中心」计入各行;其余(无诊单/创建人为空)汇总在首行。账号数据权限下仅列出可见医助。',
]; ];
} }
@@ -5164,6 +5042,96 @@ class YejiStatsLogic
return $out; return $out;
} }
/**
* 挂号渠道分组:根据诊单 id 列表,反查每个诊单对应的「挂号渠道值」(doctor_appointment.channels
* 与 dict_data(type_value=channels).value 对齐)。一个诊单理论上 1:1 关联一条挂号,多挂号场景取 id 最大者。
*
* @param int[] $diagnosisIds
*
* @return array<int, int> diagnosis_id => channels (>0)
*/
private static function commissionSettlementDiagnosisLatestApptChannel(array $diagnosisIds): array
{
$ids = array_values(array_unique(array_filter(array_map('intval', $diagnosisIds), static fn (int $x): bool => $x > 0)));
if ($ids === []) {
return [];
}
$rows = Db::name('doctor_appointment')
->whereIn('patient_id', $ids)
->where('status', 3)
->where('channels', '>', 0)
->order('patient_id asc, id desc')
->field(['patient_id', 'channels'])
->select()
->toArray();
$out = [];
foreach ($rows as $r) {
$dg = (int) ($r['patient_id'] ?? 0);
if ($dg <= 0 || isset($out[$dg])) {
continue;
}
$ch = (int) ($r['channels'] ?? 0);
if ($ch > 0) {
$out[$dg] = $ch;
}
}
return $out;
}
/**
* 挂号字典 channelsvalue(int) => name(string)。仅取本次涉及到的 value 集合,减少 dict 全量读取。
*
* @param int[] $values
*
* @return array<int, string>
*/
private static function commissionSettlementApptChannelsDictMap(array $values): array
{
$values = array_values(array_unique(array_filter(array_map('intval', $values), static fn (int $x): bool => $x > 0)));
if ($values === []) {
return [];
}
$rows = Db::name('dict_data')
->where('type_value', 'channels')
->whereIn('value', array_map('strval', $values))
->field(['value', 'name'])
->select()
->toArray();
$out = [];
foreach ($rows as $r) {
$v = (int) ($r['value'] ?? 0);
$n = trim((string) ($r['name'] ?? ''));
if ($v > 0 && $n !== '') {
$out[$v] = $n;
}
}
return $out;
}
/**
* 渠道值 → 展示标签。未关联 / 字典缺失时统一标签为「未匹配挂号渠道」。
*
* @param array<int, string> $dictMap
*/
private static function commissionSettlementApptChannelLabel(int $channelValue, array $dictMap): string
{
if ($channelValue <= 0) {
return '未匹配挂号渠道';
}
$name = trim((string) ($dictMap[$channelValue] ?? ''));
if ($name === '') {
return '未匹配挂号渠道·#' . $channelValue;
}
return $name;
}
/** /**
* 签收 / 尾款时间:对物流与支付关联做 GROUP BY 后 LEFT JOIN,避免 SELECT 中逐行相关子查询导致 overview 超时。 * 签收 / 尾款时间:对物流与支付关联做 GROUP BY 后 LEFT JOIN,避免 SELECT 中逐行相关子查询导致 overview 超时。
*/ */
@@ -5273,19 +5241,21 @@ class YejiStatsLogic
if ($tableRowDeptIds === [] || $adminToPrimary === []) { if ($tableRowDeptIds === [] || $adminToPrimary === []) {
return [ return [
'settlement_month' => $monthRaw, 'settlement_month' => $monthRaw,
'order_month' => $orderMonthLabel, 'order_month' => $orderMonthLabel,
'cutoff_end' => date('Y-m-d H:i:s', $cutoffTs), 'cutoff_end' => date('Y-m-d H:i:s', $cutoffTs),
'rule_note' => '订单创建月为结算月的上一自然月;统计系统代开处方对应的已完成业务订单;签收与尾款均在结算月 7 日 24 点前完成的金额计入本期提成,否则计入顺延下期。上期「确定业绩」写入的顺延订单会在本期并入统计。', 'rule_note' => '订单创建月为结算月的上一自然月;统计系统代开处方对应的已完成业务订单;签收与尾款均在结算月 7 日 24 点前完成的金额计入本期提成,否则计入顺延下期。上期「确定业绩」写入的顺延订单会在本期并入统计。',
'channel_code' => $c['channelCode'], 'channel_code' => $c['channelCode'],
'channel_name' => $c['channelInfo'] ? (string) $c['channelInfo']['channel_name'] : '', 'channel_name' => $c['channelInfo'] ? (string) $c['channelInfo']['channel_name'] : '',
'scope_dept_ids_key' => $pack['deptKey'], 'scope_dept_ids_key' => $pack['deptKey'],
'carry_in_order_count' => 0, 'carry_in_order_count' => 0,
'confirm' => $confirm, 'confirm' => $confirm,
'rows' => [], 'rows' => [],
'assistant_rows' => [], 'assistant_rows' => [],
'doctor_rows' => [], 'doctor_rows' => [],
'total' => $emptyTotal, 'assistant_channel_rows' => [],
'doctor_channel_rows' => [],
'total' => $emptyTotal,
]; ];
} }
@@ -5303,6 +5273,16 @@ class YejiStatsLogic
Db::raw('IFNULL(rx.creator_id, 0) AS doctor_id'), Db::raw('IFNULL(rx.creator_id, 0) AS doctor_id'),
], $signPayFields))->select()->toArray(); ], $signPayFields))->select()->toArray();
$diagIdSet = [];
foreach ($rowsRaw as $r) {
$dg = (int) ($r['diagnosis_id'] ?? 0);
if ($dg > 0) {
$diagIdSet[$dg] = true;
}
}
$diagToApptChannel = self::commissionSettlementDiagnosisLatestApptChannel(array_keys($diagIdSet));
$apptChannelsDictMap = self::commissionSettlementApptChannelsDictMap(array_values(array_unique(array_map('intval', $diagToApptChannel))));
$curAgg = []; $curAgg = [];
$defAgg = []; $defAgg = [];
$totAgg = []; $totAgg = [];
@@ -5319,6 +5299,22 @@ class YejiStatsLogic
$cntDoctorCur = []; $cntDoctorCur = [];
$cntDoctorDef = []; $cntDoctorDef = [];
/** 按 (业绩归属医助 × 挂号渠道值) / (开方医生 × 挂号渠道值) 拆分聚合,渠道值取该订单对应诊单的最新已完成挂号 channels */
$assistChTot = [];
$assistChCur = [];
$assistChDef = [];
$assistChCnt = [];
$assistChCurCnt = [];
$assistChDefCnt = [];
$assistChCarry = [];
$doctorChTot = [];
$doctorChCur = [];
$doctorChDef = [];
$doctorChCnt = [];
$doctorChCurCnt = [];
$doctorChDefCnt = [];
$doctorChCarry = [];
foreach ($rowsRaw as $r) { foreach ($rowsRaw as $r) {
$oid = (int) ($r['id'] ?? 0); $oid = (int) ($r['id'] ?? 0);
$isCarry = $oid > 0 && isset($carryFlip[$oid]); $isCarry = $oid > 0 && isset($carryFlip[$oid]);
@@ -5331,6 +5327,8 @@ class YejiStatsLogic
$signTs = (int) ($r['sign_ts'] ?? 0); $signTs = (int) ($r['sign_ts'] ?? 0);
$payTs = (int) ($r['pay_ts'] ?? 0); $payTs = (int) ($r['pay_ts'] ?? 0);
$amt = round((float) ($r['amount'] ?? 0), 2); $amt = round((float) ($r['amount'] ?? 0), 2);
$dgId = (int) ($r['diagnosis_id'] ?? 0);
$apCh = (int) ($diagToApptChannel[$dgId] ?? 0);
if ($aid <= 0 || $amt < 0) { if ($aid <= 0 || $amt < 0) {
continue; continue;
@@ -5362,6 +5360,32 @@ class YejiStatsLogic
$defByDoctor[$doctorId] = ($defByDoctor[$doctorId] ?? 0.0) + $amt; $defByDoctor[$doctorId] = ($defByDoctor[$doctorId] ?? 0.0) + $amt;
$cntDoctorDef[$doctorId] = ($cntDoctorDef[$doctorId] ?? 0) + 1; $cntDoctorDef[$doctorId] = ($cntDoctorDef[$doctorId] ?? 0) + 1;
} }
$assistChTot[$aid][$apCh] = ($assistChTot[$aid][$apCh] ?? 0.0) + $amt;
$assistChCnt[$aid][$apCh] = ($assistChCnt[$aid][$apCh] ?? 0) + 1;
if ($eligible) {
$assistChCur[$aid][$apCh] = ($assistChCur[$aid][$apCh] ?? 0.0) + $amt;
$assistChCurCnt[$aid][$apCh] = ($assistChCurCnt[$aid][$apCh] ?? 0) + 1;
} else {
$assistChDef[$aid][$apCh] = ($assistChDef[$aid][$apCh] ?? 0.0) + $amt;
$assistChDefCnt[$aid][$apCh] = ($assistChDefCnt[$aid][$apCh] ?? 0) + 1;
}
if ($isCarry) {
$assistChCarry[$aid][$apCh] = ($assistChCarry[$aid][$apCh] ?? 0) + 1;
}
$doctorChTot[$doctorId][$apCh] = ($doctorChTot[$doctorId][$apCh] ?? 0.0) + $amt;
$doctorChCnt[$doctorId][$apCh] = ($doctorChCnt[$doctorId][$apCh] ?? 0) + 1;
if ($eligible) {
$doctorChCur[$doctorId][$apCh] = ($doctorChCur[$doctorId][$apCh] ?? 0.0) + $amt;
$doctorChCurCnt[$doctorId][$apCh] = ($doctorChCurCnt[$doctorId][$apCh] ?? 0) + 1;
} else {
$doctorChDef[$doctorId][$apCh] = ($doctorChDef[$doctorId][$apCh] ?? 0.0) + $amt;
$doctorChDefCnt[$doctorId][$apCh] = ($doctorChDefCnt[$doctorId][$apCh] ?? 0) + 1;
}
if ($isCarry) {
$doctorChCarry[$doctorId][$apCh] = ($doctorChCarry[$doctorId][$apCh] ?? 0) + 1;
}
} }
$foldAmt = static function (array $byAssistant): array { $foldAmt = static function (array $byAssistant): array {
@@ -5503,20 +5527,108 @@ class YejiStatsLogic
return $cmp !== 0 ? $cmp : (($b['doctor_id'] ?? 0) <=> ($a['doctor_id'] ?? 0)); return $cmp !== 0 ? $cmp : (($b['doctor_id'] ?? 0) <=> ($a['doctor_id'] ?? 0));
}); });
/** 按 (业绩归属医助 × 挂号渠道) 拆分行:复诊2 / 复诊3 / 自媒体1 等每个 channels 值各自独立一行 */
$assistantChannelRows = [];
foreach ($assistChTot as $aid => $byCh) {
$aid = (int) $aid;
if ($aid <= 0) {
continue;
}
$pid = (int) ($adminToPrimary[$aid] ?? 0);
foreach ($byCh as $ch => $amt) {
$ch = (int) $ch;
$assistantChannelRows[] = [
'assistant_id' => $aid,
'assistant_name' => (string) ($assistNameMap[$aid] ?? ('#' . $aid)),
'primary_dept_id' => $pid,
'primary_dept_name' => $pid > 0 ? self::formatYejiDeptRowDisplayName($pid, $deptById) : '',
'appt_channel_value' => $ch,
'appt_channel_name' => self::commissionSettlementApptChannelLabel($ch, $apptChannelsDictMap),
'carry_in_order_count' => (int) ($assistChCarry[$aid][$ch] ?? 0),
'completed_order_count' => (int) ($assistChCnt[$aid][$ch] ?? 0),
'completed_order_amount' => round((float) $amt, 2),
'current_period_count' => (int) ($assistChCurCnt[$aid][$ch] ?? 0),
'current_period_amount' => round((float) ($assistChCur[$aid][$ch] ?? 0), 2),
'deferred_period_count' => (int) ($assistChDefCnt[$aid][$ch] ?? 0),
'deferred_period_amount' => round((float) ($assistChDef[$aid][$ch] ?? 0), 2),
];
}
}
/** 同医助按总业绩 desc 排序,组内按当前行金额 desc;保证同医助行连续,方便前端合并单元格 */
usort($assistantChannelRows, static function (array $a, array $b) use ($totAgg): int {
$aa = (int) ($a['assistant_id'] ?? 0);
$bb = (int) ($b['assistant_id'] ?? 0);
if ($aa !== $bb) {
$aTot = (float) ($totAgg[$aa] ?? 0);
$bTot = (float) ($totAgg[$bb] ?? 0);
if ($aTot !== $bTot) {
return $bTot <=> $aTot;
}
return $bb <=> $aa;
}
return ($b['completed_order_amount'] ?? 0) <=> ($a['completed_order_amount'] ?? 0);
});
/** 按 (开方医生 × 挂号渠道) 拆分行;未关联开方人(0) 行也保留,沉到末尾 */
$doctorChannelRows = [];
foreach ($doctorChTot as $did => $byCh) {
$did = (int) $did;
$pid = $did > 0 ? (int) ($adminToPrimary[$did] ?? 0) : 0;
foreach ($byCh as $ch => $amt) {
$ch = (int) $ch;
$doctorChannelRows[] = [
'doctor_id' => $did,
'doctor_name' => $did > 0 ? (string) ($doctorNameMap[$did] ?? ('#' . $did)) : '未关联开方人',
'primary_dept_id' => $pid,
'primary_dept_name' => $pid > 0 ? self::formatYejiDeptRowDisplayName($pid, $deptById) : '',
'appt_channel_value' => $ch,
'appt_channel_name' => self::commissionSettlementApptChannelLabel($ch, $apptChannelsDictMap),
'carry_in_order_count' => (int) ($doctorChCarry[$did][$ch] ?? 0),
'completed_order_count' => (int) ($doctorChCnt[$did][$ch] ?? 0),
'completed_order_amount' => round((float) $amt, 2),
'current_period_count' => (int) ($doctorChCurCnt[$did][$ch] ?? 0),
'current_period_amount' => round((float) ($doctorChCur[$did][$ch] ?? 0), 2),
'deferred_period_count' => (int) ($doctorChDefCnt[$did][$ch] ?? 0),
'deferred_period_amount' => round((float) ($doctorChDef[$did][$ch] ?? 0), 2),
];
}
}
usort($doctorChannelRows, static function (array $a, array $b) use ($totByDoctor): int {
$aa = (int) ($a['doctor_id'] ?? 0);
$bb = (int) ($b['doctor_id'] ?? 0);
/** 「未关联开方人」(doctor_id=0) 一律置末尾 */
$za = $aa === 0 ? 1 : 0;
$zb = $bb === 0 ? 1 : 0;
if ($za !== $zb) {
return $za <=> $zb;
}
if ($aa !== $bb) {
$aTot = (float) ($totByDoctor[$aa] ?? 0);
$bTot = (float) ($totByDoctor[$bb] ?? 0);
if ($aTot !== $bTot) {
return $bTot <=> $aTot;
}
return $bb <=> $aa;
}
return ($b['completed_order_amount'] ?? 0) <=> ($a['completed_order_amount'] ?? 0);
});
return [ return [
'settlement_month' => $monthRaw, 'settlement_month' => $monthRaw,
'order_month' => $orderMonthLabel, 'order_month' => $orderMonthLabel,
'cutoff_end' => date('Y-m-d H:i:s', $cutoffTs), 'cutoff_end' => date('Y-m-d H:i:s', $cutoffTs),
'rule_note' => '订单创建月为结算月的上一自然月;仅统计系统代开处方(is_system_auto=1)对应的履约已完成业务订单。签收时间取物流表签收时间最大值;尾款时间取关联支付单已支付记录(优先类型为尾款/全部)。签收与尾款均不晚于结算月 7 日 24 点的计入「本期提成」,其余已完成订单计入「顺延下期」。上期同渠道同部门范围内「确定业绩」产生的顺延订单,会计入本期订单池。「按医助下发」按业绩归属医助聚合;「按医生下发」按处方开方人(creator_id)聚合。', 'rule_note' => '订单创建月为结算月的上一自然月;仅统计系统代开处方(is_system_auto=1)对应的履约已完成业务订单。签收时间取物流表签收时间最大值;尾款时间取关联支付单已支付记录(优先类型为尾款/全部)。签收与尾款均不晚于结算月 7 日 24 点的计入「本期提成」,其余已完成订单计入「顺延下期」。上期同渠道同部门范围内「确定业绩」产生的顺延订单,会计入本期订单池。「按医助下发」「按医生下发」按 (人 × 挂号渠道) 拆分;挂号渠道取该订单对应诊单的最新已完成挂号 doctor_appointment.channelsdict_data type_value=channels),如「复诊2」「自媒体1」等保持各自独立一行,未关联挂号统一展示为「未匹配挂号渠道」。',
'channel_code' => $c['channelCode'], 'channel_code' => $c['channelCode'],
'channel_name' => $c['channelInfo'] ? (string) $c['channelInfo']['channel_name'] : '', 'channel_name' => $c['channelInfo'] ? (string) $c['channelInfo']['channel_name'] : '',
'scope_dept_ids_key' => $pack['deptKey'], 'scope_dept_ids_key' => $pack['deptKey'],
'carry_in_order_count' => $carryInCnt, 'carry_in_order_count' => $carryInCnt,
'confirm' => $confirm, 'confirm' => $confirm,
'rows' => $rows, 'rows' => $rows,
'assistant_rows' => $assistantRows, 'assistant_rows' => $assistantRows,
'doctor_rows' => $doctorRows, 'doctor_rows' => $doctorRows,
'total' => [ 'assistant_channel_rows' => $assistantChannelRows,
'doctor_channel_rows' => $doctorChannelRows,
'total' => [
'completed_order_count' => $sumCompletedCnt, 'completed_order_count' => $sumCompletedCnt,
'completed_order_amount' => round($sumCompletedAmt, 2), 'completed_order_amount' => round($sumCompletedAmt, 2),
'current_period_count' => $sumCurCnt, 'current_period_count' => $sumCurCnt,