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
@@ -20,6 +20,16 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
{
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 场景放开(看板跨医生查看)
*/
@@ -28,6 +38,9 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
if ($progressBoard) {
return;
}
if ($this->appointmentListsScopeRelaxedForDiagnosis()) {
return;
}
if (!$this->dataScopeShouldApply()) {
return;
}
@@ -128,7 +141,8 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac
// 面诊进度看板:可切换查看各医生挂号,不按当前账号角色收窄
$progressBoard = (int) ($this->params['progress_board'] ?? 0) === 1;
if (!$progressBoard) {
$diagScopeRelax = $this->appointmentListsScopeRelaxedForDiagnosis();
if (!$progressBoard && !$diagScopeRelax) {
// 如果是医生角色(role_id=1),只显示挂自己号的预约
if (in_array(1, $roleIds)) {
$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;
if (!$progressBoard) {
$diagScopeRelax = $this->appointmentListsScopeRelaxedForDiagnosis();
if (!$progressBoard && !$diagScopeRelax) {
if (in_array(1, $roleIds)) {
$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 为全部复诊分项合计)。
* admin 维度按订单创建人 o.creator_id(与部门表复诊列、排行榜复诊列同口径)。
*/
private function applyYejiErCenterRevisitOrderFilter($query): void
{
@@ -138,7 +139,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
if ($t0 === false || $t1 === false) {
return;
}
$ids = DeptLogic::listErCenterRevisitOrderIdsForAssistant(
$ids = DeptLogic::listErCenterRevisitOrderIdsForCreator(
$assistantId,
$slot,
(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}");
}
/** 业绩看板入口(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) {
$assistantId = (int) $this->params['assistant_id'];
$diagTbl = (new Diagnosis())->getTable();
$query->where(function ($q) use ($poTbl, $diagTbl, $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 ($yejiPanel) {
$query->where('creator_id', $assistantId);
} else {
$diagTbl = (new Diagnosis())->getTable();
$query->where(function ($q) use ($poTbl, $diagTbl, $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) {
@@ -909,23 +917,31 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
return;
}
$inList = implode(',', $deptIds);
$diagTbl = (new Diagnosis())->getTable();
$adTbl = (new AdminDept())->getTable();
$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})"
if ($yejiPanel) {
/** 业绩看板部门侧栏:仅订单创建人人事部门 ∈ 子树(与「合计业绩」/「接诊诊单」/「复诊」聚合同口径) */
$query->whereExists(
"SELECT 1 FROM `{$adTbl}` adc WHERE adc.`admin_id` = `{$poTbl}`.`creator_id` AND adc.`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'
);
});
} else {
$diagTbl = (new Diagnosis())->getTable();
$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
*/
@@ -939,18 +955,7 @@ class PrescriptionOrderLists extends BaseAdminDataLists implements ListsSearchIn
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 . '))'
);
$query->whereIn('creator_id', $adminIds);
}
/**
+53 -70
View File
@@ -219,21 +219,24 @@ class DeptLogic extends BaseLogic
}
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'])
->select()
->toArray();
}
/**
* 二中心子树:各部门复诊 rollup;同时返回按诊单医助聚合的分项(供医助排行榜)。
* 二中心子树:各部门复诊 rollup(按订单创建人 creator_id 归属)。
* 候选订单仍要求 dg.assistant_id > 0(保持「er-center 案例 = 有医助的诊单」的定义),
* 但**复诊归属**改为订单创建人——leaf 部门归属取 creator_id 在二中心子树内的 canonical 部门,
* 并产出 by_creator_slots 供医助排行榜复诊列复用,与诊金 / 接诊诊单口径完全一致。
*
* @param list<int> $subtreeDeptIds
*
* @return array{
* totals: 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(
@@ -249,12 +252,12 @@ class DeptLogic extends BaseLogic
$emptyTotals[$did] = 0;
}
if ($subtreeDeptIds === []) {
return ['totals' => [], 'slots' => [], 'by_assistant_slots' => []];
return ['totals' => [], 'slots' => [], 'by_creator_slots' => []];
}
$subtreeSet = array_fill_keys($subtreeDeptIds, true);
$orderRows = self::fetchErCenterRevisitCandidateOrders($orderStartTs, $orderEndTs);
if ($orderRows === []) {
return ['totals' => $emptyTotals, 'slots' => $emptySlots, 'by_assistant_slots' => []];
return ['totals' => $emptyTotals, 'slots' => $emptySlots, 'by_creator_slots' => []];
}
$byPatient = [];
@@ -266,29 +269,30 @@ class DeptLogic extends BaseLogic
$byPatient[$pid][] = $r;
}
$assistantNeed = [];
$creatorNeed = [];
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;
$cid = (int) ($orders[$i]['creator_id'] ?? 0);
if ($cid > 0) {
$creatorNeed[$cid] = true;
}
}
}
$assistantIds = array_keys($assistantNeed);
$canonicalDept = self::buildAssistantCanonicalDeptInSubtree($assistantIds, $subtreeSet);
$creatorIds = array_keys($creatorNeed);
/** 复用 buildAssistantCanonicalDeptInSubtree —— 该方法实际是 admin → 子树内 canonical 部门,与 admin 角色无关 */
$canonicalDept = self::buildAssistantCanonicalDeptInSubtree($creatorIds, $subtreeSet);
/** @var array<int, array<int, int>> $leafByDeptSlot dept_id => [ slot => cnt ]slot≥2 */
$leafByDeptSlot = [];
/** @var array<int, array<int, int>> $byAssistantSlot */
$byAssistantSlot = [];
/** @var array<int, array<int, int>> $byCreatorSlot 复诊按订单创建人聚合(与诊金/接诊诊单口径一致) */
$byCreatorSlot = [];
foreach ($byPatient as $orders) {
$n = \count($orders);
for ($i = 1; $i < $n; $i++) {
$slot = $i + 1;
$aid = (int) ($orders[$i]['assistant_id'] ?? 0);
$deptId = $canonicalDept[$aid] ?? null;
$cid = (int) ($orders[$i]['creator_id'] ?? 0);
$deptId = $canonicalDept[$cid] ?? null;
if ($deptId === null || !isset($subtreeSet[$deptId])) {
continue;
}
@@ -296,11 +300,11 @@ class DeptLogic extends BaseLogic
$leafByDeptSlot[$deptId] = [];
}
$leafByDeptSlot[$deptId][$slot] = ($leafByDeptSlot[$deptId][$slot] ?? 0) + 1;
if ($aid > 0) {
if (!isset($byAssistantSlot[$aid])) {
$byAssistantSlot[$aid] = [];
if ($cid > 0) {
if (!isset($byCreatorSlot[$cid])) {
$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] ?? []);
}
foreach ($byAssistantSlot as $aid => $sm) {
foreach ($byCreatorSlot as $cid => $sm) {
if ($sm !== []) {
ksort($byAssistantSlot[$aid], SORT_NUMERIC);
ksort($byCreatorSlot[$cid], SORT_NUMERIC);
}
}
return [
'totals' => $rollupTotals,
'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>>}
*/
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();
$subtreeIds = self::unionErCenterSubtreeDeptIds($erRoots);
@@ -357,42 +361,36 @@ class DeptLogic extends BaseLogic
return ['totals' => [], 'slots' => []];
}
$pack = self::computeErCenterRevisitFullPack($subtreeIds, $orderStartTs, $orderEndTs);
$by = $pack['by_assistant_slots'];
$by = $pack['by_creator_slots'];
$totals = [];
$slots = [];
foreach ($by as $aid => $slotMap) {
$aid = (int) $aid;
$totals[$aid] = (int) array_sum($slotMap);
$slots[$aid] = $slotMap;
foreach ($by as $cid => $slotMap) {
$cid = (int) $cid;
$totals[$cid] = (int) array_sum($slotMap);
$slots[$cid] = $slotMap;
}
return ['totals' => $totals, 'slots' => $slots];
}
/**
* 某医助在区间内、计入二中心复诊的业务订单 id(用于看板侧栏按复诊笔数下钻)。
* 二中心子树复诊:列出某「订单创建人」在区间内、复诊序列(slot≥2)命中的业务订单 id(用于看板侧栏按复诊笔数下钻)。
* $revisitSlot = 0 表示累计全部复诊(第 2 笔起);≥2 时仅该分项。
*
* @return list<int>
*/
public static function listErCenterRevisitOrderIdsForAssistant(
int $assistantId,
public static function listErCenterRevisitOrderIdsForCreator(
int $creatorId,
int $revisitSlot,
int $orderStartTs,
int $orderEndTs
): array {
if ($assistantId <= 0 || $orderStartTs <= 0 || $orderEndTs < $orderStartTs) {
if ($creatorId <= 0 || $orderStartTs <= 0 || $orderEndTs < $orderStartTs) {
return [];
}
if ($revisitSlot < 0 || $revisitSlot === 1) {
return [];
}
$erRoots = self::findErCenterRootDeptIds();
$subtreeIds = self::unionErCenterSubtreeDeptIds($erRoots);
if ($subtreeIds === []) {
return [];
}
$subtreeSet = array_fill_keys($subtreeIds, true);
$orderRows = self::fetchErCenterRevisitCandidateOrders($orderStartTs, $orderEndTs);
if ($orderRows === []) {
return [];
@@ -405,31 +403,16 @@ class DeptLogic extends BaseLogic
}
$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 = [];
foreach ($byPatient as $orders) {
$n = \count($orders);
for ($i = 1; $i < $n; $i++) {
$slot = $i + 1;
$aid = (int) ($orders[$i]['assistant_id'] ?? 0);
if ($aid !== $assistantId) {
continue;
}
if ($revisitSlot > 0 && $slot !== $revisitSlot) {
continue;
}
$dcan = $canonicalDept[$aid] ?? null;
if ($dcan === null || !isset($subtreeSet[$dcan])) {
$cid = (int) ($orders[$i]['creator_id'] ?? 0);
if ($cid !== $creatorId) {
continue;
}
$oid = (int) ($orders[$i]['id'] ?? 0);
@@ -443,8 +426,8 @@ class DeptLogic extends BaseLogic
}
/**
* 某展示部门行(组织架构上含下级 rollup)内,二中心复诊按诊单医助拆解笔数。
* 与看板行内「复诊」列口径一致:诊单下订单序列第 2 笔起;医助 canonical 部门须落在二中心子树且在该部门行的子树内。
* 某展示部门行(组织架构上含下级 rollup)内,二中心复诊按**订单创建人**拆解笔数。
* 与看板行内「复诊」列口径一致:订单序列第 2 笔起;创建人 canonical 部门须落在二中心子树且在该部门行的子树内。
*
* @return array{rows: list<array{admin_id: int, name: string, order_count: int}>}
*/
@@ -484,17 +467,17 @@ class DeptLogic extends BaseLogic
}
$byPatient[$pid][] = $r;
}
$assistantNeed = [];
$creatorNeed = [];
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;
$cid = (int) ($orders[$i]['creator_id'] ?? 0);
if ($cid > 0) {
$creatorNeed[$cid] = true;
}
}
}
$canonicalDept = self::buildAssistantCanonicalDeptInSubtree(array_keys($assistantNeed), $erSubtreeSet);
$canonicalDept = self::buildAssistantCanonicalDeptInSubtree(array_keys($creatorNeed), $erSubtreeSet);
/** @var array<int, int> $counts */
$counts = [];
foreach ($byPatient as $orders) {
@@ -504,15 +487,15 @@ class DeptLogic extends BaseLogic
if ($revisitSlot > 0 && $slot !== $revisitSlot) {
continue;
}
$aid = (int) ($orders[$i]['assistant_id'] ?? 0);
if ($aid <= 0) {
$cid = (int) ($orders[$i]['creator_id'] ?? 0);
if ($cid <= 0) {
continue;
}
$canon = $canonicalDept[$aid] ?? null;
$canon = $canonicalDept[$cid] ?? null;
if ($canon === null || !isset($erSubtreeSet[$canon]) || !isset($descFlip[$canon])) {
continue;
}
$counts[$aid] = ($counts[$aid] ?? 0) + 1;
$counts[$cid] = ($counts[$cid] ?? 0) + 1;
}
}
if ($counts === []) {
@@ -527,10 +510,10 @@ class DeptLogic extends BaseLogic
->column('name', 'id');
}
$rows = [];
foreach ($counts as $aid => $cnt) {
foreach ($counts as $cid => $cnt) {
$rows[] = [
'admin_id' => (int) $aid,
'name' => (string) ($nameRows[$aid] ?? ('#' . $aid)),
'admin_id' => (int) $cid,
'name' => (string) ($nameRows[$cid] ?? ('#' . $cid)),
'order_count' => (int) $cnt,
];
}
+317 -205
View File
@@ -18,17 +18,17 @@ use think\facade\Db;
* · 与 qywx.customer/todayArrival、客户列表同源事件表;选定渠道时客户须带对应企微标签(contact_tag);**二中心及其组织下级在选定渠道下进线计 0**
* - 被指派数 = 区间内 `tcm_diagnosis_assign_log` 且 **to_assistant_id>0**,且 **related_po_create_time>0** 并落在区间(**一律按快照字段统计**,与履约释放写入一致;诊单后台指派时会回填该字段,见 DiagnosisLogic::assign
* · **二中心及其组织下级在选定渠道下计 0**(与进线一致)
* - 复诊统计 = 仅**名称含「二中心」的部门及其组织架构下级**:区间内业务单按患者 create_time 排序,第 1 笔不计,第 2 笔起分列「复诊2」「复诊3」…;口径同业绩单条件;按诊单医助在组织树内归属 rollup(含下级),与 dept 模块 ErCenter 复诊算法一致
* - 复诊统计 = 仅**名称含「二中心」的部门及其组织架构下级**:区间内业务单按患者 create_time 排序,第 1 笔不计,第 2 笔起分列「复诊2」「复诊3」…;口径同业绩单条件;按**订单创建人**所在部门在组织树内 rollup(含下级),与排行榜复诊一致
* - 已完成挂号 = status=3 挂号条数(appointment_date),列「已完成挂号」;部门归属:有效医助→接诊医生→展示行映射。
* - 接诊诊单 = 列名对应**业务订单条数**(非取消、create_time 区间),与处方列表按 **assistant_dept_id** 筛选时一致:诊单 **assistant_id** 或订单 **creator_id** 任一人事部门落在该行子树即计入;多行命中时取**最深的展示部门行**,与「业绩归属医助」分组可能不同
* - {渠道}成交单 = 选定渠道时与 {渠道}业绩同源:区间内 **非取消业务订单条数**(仅剔除 fulfillment_status=4NULL 与其它状态计入,按 create_time 归日);dict(channels) 与 tag 口径均按订单计(tag 时按诊单医助收窄),非挂号条数。
* - 合计业绩 = 区间内业务订单 amount 求和(排除已取消(4) 与软删),**部门列**与「接诊诊单」一致:诊单医助或订单创建人人事部门落在该展示行子树则计入,多行命中取最深展示行;与处方列表按 assistant_dept_id 筛选后的金额合计一致
* 无法归入任一展示部门的金额单列「未归属中心」行;**全表合计行**仍为同时间/业绩条件的全表 SUM(不限部门)
* - 接诊诊单 = 列名对应**业务订单条数**(非取消、create_time 区间),按**订单创建人**人事部门落在该行子树即计入;多行命中时取**最深的展示部门行**。与处方列表按 assistant_dept_id 筛选不同(列表保留旧口径:诊单医助 ∪ 创建人)
* - {渠道}成交单 = 选定渠道时与 {渠道}业绩同源:区间内 **非取消业务订单条数**(仅剔除 fulfillment_status=4NULL 与其它状态计入,按 create_time 归日);dict(channels) 与 tag 口径均按订单计(按订单创建人归属),非挂号条数。
* - 合计业绩 = 区间内业务订单 amount 求和(排除已取消(4) 与软删),**部门列**与「接诊诊单」一致:**订单创建人**人事部门落在该展示行子树则计入,多行命中取最深展示行;与排行榜诊金同口径
* 无法归入任一展示部门的金额单列「未归属中心」行;**底栏「合计业绩」「接诊诊单」= 当前表内各行业绩列相加**(含未归属行),与弹窗摊分口径一致
* · 时间窗与 stats_order_amount_performance 一致;列表若另有患者筛选或账号数据域收窄,金额可能仍不同
* · 选定渠道时「{渠道}业绩」列仍按比例展示;合计列全量与渠道列含未归属补差;**二中心及其组织下级展示部门不参与该渠道进线/业绩/成交单/ROI 分子**(与同模块 DeptLogic::getErCenterSubtreeDeptIdSet 一致)
* - {渠道}业绩 = 与「处方订单列表」业绩同口径(≠4):按 **o.create_time** 落入区间,按**业绩归属医助**归部门(与处方列表医助筛选一致)
* - {渠道}业绩 = 与「处方订单列表」业绩同口径(≠4):按 **o.create_time** 落入区间,按**订单创建人**归部门
* 渠道判定仅用 EXISTS:"患者有任一已完成挂号 (status=3) 且 channels 命中字典",**不限挂号时点**;无字典映射时回退 tag/creator 口径
* - 成交单数 = 区间内非取消业务订单 **条数**;按**业绩归属医助**映射到「中心」(与处方订单列表医助条件一致);无法归属计入「未归属中心」;合计=全表 COUNT。
* - 成交单数 = 区间内非取消业务订单 **条数**;按**订单创建人**映射到「中心」;无法归属计入「未归属中心」;合计=全表 COUNT。
* - 投放成本 = zyt_account_cost 在日期区间内 **amount** 合计(选定渠道时 `media_channel_code = channel_code` 过滤)。
* · **各部门行投放成本** = 总投放成本 × (该部门进线 ÷ 各展示部门进线合计)。相当于先算 **单进线成本** = 总成本 ÷ 总进线,再乘部门进线。
* · **二中心及其组织下级**不参与均摊投放成本(进线分摊分母对其计 0,与选定渠道下进线清零规则独立);其余部门按进线占比分摊。合计行成本 = 账务汇总总额。
@@ -482,27 +482,6 @@ 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;
@@ -539,15 +518,20 @@ class YejiStatsLogic
];
}
if ($narrowTotalsToTable) {
$totalPerf = $listAlignedTotalPerf !== null ? $listAlignedTotalPerf : $allocatedPerf;
$totalChannelPerf = $allocatedChannelPerf;
$totalDealOrderCount = $listAlignedDealCnt !== null ? $listAlignedDealCnt : $allocatedDealOrderSum;
} else {
$totalPerf = $globalPerfAll;
$totalChannelPerf = $globalPerfChannel;
$totalDealOrderCount = $globalDealOrderCount;
// 底栏「合计业绩」「{渠道}业绩」「接诊诊单」= 当前表格全部数据行相加(含「未归属中心」),
// 与各行展示口径一致;不再使用全表 SUM(会与仅含展示部门的摊分行脱钩)。
$sumPerfRows = 0.0;
$sumChannelPerfRows = 0.0;
$sumDealRows = 0;
foreach ($rows as $rw) {
$sumPerfRows += (float) ($rw['performance_amount'] ?? 0);
$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);
// ROI 合计:选渠道时底栏「{渠道}业绩合计」已扣除二中心子树($erCenterChannelStrip);未选渠道时用全表业绩并减去各二中心子树展示行之和
@@ -566,12 +550,12 @@ class YejiStatsLogic
}
$scopedTableNote = ($explicitDeptFilter
? ' 已筛选展示部门时,表格「合计业绩」「接诊诊单」各行为同一套列表部门口径(诊单医助或创建人∪子树、最深展示行);底栏合计业绩/成交单与处方订单列表 extend(所选部门树+数据域)一致,可能与各行相加不同。'
? ' 已筛选展示部门时,表格仅展示所选树中的部门行;底栏「合计业绩」「接诊诊单」与当前表内各行业绩列相加一致(含「未归属中心」行若展示)。'
: '')
. ($dataScopeRestricted
? ' 当前账号受数据范围限制时,底栏合计业绩与列表同域。'
: '');
$baseNoteNoChannel = '业绩口径:除履约已取消(4)外全部计入。部门行「合计业绩」「接诊诊单」均为列表 assistant_dept_id 口径(诊单医助或订单创建人人事部门∪子树,多行命中取最深展示行)。多表「本月」区间为当月1日至服务器当日。**投放成本**按进线占比分摊,**二中心子树展示部门进线在分摊中计 0**(与部门 ErCenter 子树一致)。';
$baseNoteNoChannel = '业绩口径:除履约已取消(4)外全部计入。部门行「合计业绩」「接诊诊单」「复诊」均按**订单创建人人事部门 ∈ 该展示行子树**归属(多行命中取最深展示行),与医助排行榜诊金一致;与处方订单列表 assistant_dept_id 默认筛选(诊单医助 ∪ 创建人)不同——侧栏点击会自动收窄为创建人口径。多表「本月」区间为当月1日至服务器当日。**投放成本**按进线占比分摊,**二中心子树展示部门进线在分摊中计 0**(与部门 ErCenter 子树一致)。';
return [
'start_date' => $startDate,
@@ -581,9 +565,9 @@ class YejiStatsLogic
'channel_name' => $channelInfo ? (string) $channelInfo['channel_name'] : '',
'channel_group' => $channelInfo ? (string) $channelInfo['source_group_name'] : '',
'channel_filter_note' => $channelCode !== ''
? ('{渠道}业绩:与「处方订单列表」业绩同口径,按订单 create_time 归日、仅剔除履约已取消(4)(NULL 与其它状态均计入);渠道判定用"该患者是否有挂号 channels 命中 dict + status=3"(不限挂号时点)。'
. ' {渠道}成交单数:与{渠道}业绩同源,仅剔除已取消(4)的订单条数(非挂号条数)。'
. ' 「已完成挂号」列同 admin 挂号已完成列表;「接诊诊单」列为业务订单条数(非取消),与列表按 assistant_dept_id 筛选时一致(诊单医助或创建人∪子树)。**二中心及其组织下级**(与部门模块 ErCenter 子树一致)**不计入**该渠道进线、业绩与成交单。**投放成本**按各部门进线条数占展示部门进线合计的比例分摊,**二中心子树进线在分摊中计 0**(未选渠道时仍显示进线数,仅不参与均摊)。ROI = 渠道业绩(不含二中心子树)÷ 总投放成本。进线按所选标签客户精确过滤。'
? ('{渠道}业绩:按订单 create_time 归日、仅剔除履约已取消(4)(NULL 与其它状态均计入);渠道判定用"该患者是否有挂号 channels 命中 dict + status=3"(不限挂号时点)。部门归属按**订单创建人**人事部门 ∈ 子树。'
. ' {渠道}成交单数:与{渠道}业绩同源,仅剔除已取消(4)的订单条数(非挂号条数),按订单创建人归部门。'
. ' 「已完成挂号」列同 admin 挂号已完成列表;「接诊诊单」列为业务订单条数(非取消),同样按订单创建人归部门。**二中心及其组织下级**(与部门模块 ErCenter 子树一致)**不计入**该渠道进线、业绩与成交单。**投放成本**按各部门进线条数占展示部门进线合计的比例分摊,**二中心子树进线在分摊中计 0**(未选渠道时仍显示进线数,仅不参与均摊)。ROI = 渠道业绩(不含二中心子树)÷ 总投放成本。进线按所选标签客户精确过滤。'
. ($tagFallback
? ' ⚠ 当前业务尚未回填 tcm_diagnosis.external_userid 与 user_auth.unionid,已自动退化为「该标签客户的跟进医助 → 区间内接诊/业绩」口径,仅用于趋势观察。'
: '')
@@ -740,7 +724,8 @@ class YejiStatsLogic
}
$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');
$assistantSet = [];
@@ -766,10 +751,10 @@ class YejiStatsLogic
}
$rangeNote = $channelFilterActive
? ('排行诊金 = 选定渠道「' . $channelName . '」业绩口径(与部门表该渠道业绩列一致)。被指派数与部门表同口径。接诊率 = 诊金 ÷ 进线(元/进线)。'
? ('排行诊金 = 选定渠道「' . $channelName . '」业绩中「订单创建人 = 该医助」的部分;与部门表该渠道业绩列同口径(按订单创建人归属)。被指派数与部门表同口径。接诊率 = 诊金 ÷ 进线(元/进线)。'
. '「二中心」及其组织下级医助在该渠道下诊金、进线与被指派计 0。'
. ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。')
: '排行诊金 = 处方订单列表医助口径(诊单医助或代建创建人与列表一致)。被指派数为指派至该医助的次数。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。'
: '排行诊金 = 业务订单中「订单创建人 = 该医助」的金额合计;与部门表「合计业绩」同口径(按订单创建人归属)。被指派数为指派至该医助的次数。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。'
. ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。';
$leaderboards = [];
@@ -2966,6 +2951,7 @@ class YejiStatsLogic
/**
* 与 sumPerformance 对应,按 admin 维度返回全量/渠道业绩金额(不按部门折叠)。
* 归属固定为 sqlPerformanceAttributionAdminExpr()——按订单创建人。
*
* @param int[] $appointmentChannelValues
*
@@ -3001,7 +2987,7 @@ class YejiStatsLogic
}
/**
* 与 sumPerformanceByAdmin 对应:按诊单医助统计成交单数(非取消业务订单条数),全量/渠道分支一致。
* 与 sumPerformanceByAdmin 对应:按订单创建人统计成交单数(非取消业务订单条数),全量/渠道分支一致。
*
* @param int[] $appointmentChannelValues
*
@@ -3091,7 +3077,7 @@ class YejiStatsLogic
}
/**
* dict(channels) 渠道成交单数按诊单医助聚合(与 aggregatePerformanceAmountsByAssistantChannel 同源)。
* dict(channels) 渠道成交单数按业绩归属医助聚合(与 aggregatePerformanceAmountsByAssistantChannel 同源)。
*
* @param int[] $appointmentChannelValues
*
@@ -3146,7 +3132,7 @@ class YejiStatsLogic
}
/**
* 业绩按归属医助聚合到 admin(与处方订单列表医助筛选口径一致:诊单医助或代建创建人)。
* 业绩按归属医助聚合到 admin(统一按订单创建人 o.creator_id;与 sqlPerformanceAttributionAdminExpr 一致)。
*
* @return array<int, float>
*/
@@ -3335,13 +3321,14 @@ class YejiStatsLogic
/**
* 业绩归属医助 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
{
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';
return 'o.creator_id';
}
/**
@@ -3411,106 +3398,6 @@ class YejiStatsLogic
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,无部门归属过滤)。
*/
@@ -3657,7 +3544,7 @@ class YejiStatsLogic
if ($adminToPrimary === []) {
return ['all' => [], 'scoped' => []];
}
// 1) 部门「合计业绩」:有展示行时与「接诊诊单」同列表部门口径(可由 overview 预计算避免重复查单
// 1) 部门「合计业绩」:有展示行时与「接诊诊单」同属 {@see aggregateListAlignedOrderMetricsByDept}(仅订单创建人摊行
if ($precomputedListAlignedAmountByDept !== null) {
$allByDept = [];
foreach ($precomputedListAlignedAmountByDept as $deptId => $v) {
@@ -3749,7 +3636,7 @@ class YejiStatsLogic
/**
* 渠道业绩:与「处方订单列表」业绩完全同口径 —— 按 o.create_time 归日、仅剔除已取消(4)(含 NULL);
* EXISTS 仅用于"该患者属于此渠道"的判定(有任一挂号 channels 命中 + status=3**不限挂号时点**);
* 部门归属按业绩归属医助(与处方列表医助条件一致:诊单医助或代建创建人)。
* 部门归属按业绩归属表达式(订单创建人 o.creator_id)。
*/
private static function aggregatePerformanceByAppointmentChannels(
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, 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>}
*/
@@ -4038,7 +3926,6 @@ class YejiStatsLogic
}
$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')
@@ -4047,21 +3934,20 @@ class YejiStatsLogic
->where('o.diagnosis_id', '>', 0);
self::joinPrescriptionForPerformanceAttribution($query);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$query->whereRaw('(dg.assistant_id > 0 OR o.creator_id > 0)');
$query->where('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}))");
$query->whereRaw("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();
@@ -4081,11 +3967,7 @@ class YejiStatsLogic
$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;
}
@@ -4107,15 +3989,11 @@ class YejiStatsLogic
$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;
if ($cr <= 0) {
continue;
}
$cand = [$cr => true];
$deptKey = self::resolveDeepestYejiTableRowForListDeptOr(
$cand,
$adminDeptIdList,
@@ -4136,9 +4014,9 @@ class YejiStatsLogic
}
/**
* 成交单数按部门:**处方订单列表**按 assistant_dept_id 筛选时的业务单条数(诊单医助或创建人 ∪ 部门子树),
* 与 {@see PrescriptionOrderLists::applyDoctorAssistantFilters} 在 <b>未</b> 传 yeji_drawer_match_table_performance 时一致
* 选定医助 tag 时收窄为(业绩归属医助 ∈ 集 **或** 诊单医助 ∈ 集 **或** 创建人 ∈ 集
* 成交单数按部门:有展示行时与 {@see aggregateListAlignedOrderMetricsByDept} 同口径(仅订单创建人归属)。
* 无展示行时按 sqlPerformanceAttributionAdminExpr 分组再 fold(同样为创建人)
* 选定医助 tag 时收窄为 o.creator_id ∈ 集。
*
* @return array<int, int>
*/
@@ -4179,14 +4057,14 @@ class YejiStatsLogic
->where('o.diagnosis_id', '>', 0);
self::joinPrescriptionForPerformanceAttribution($query);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$query->whereRaw('(dg.assistant_id > 0 OR o.creator_id > 0)');
$query->where('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}))");
$query->whereRaw("o.creator_id IN ({$in})");
}
$query->whereRaw("({$att}) > 0", []);
@@ -4446,7 +4324,7 @@ class YejiStatsLogic
}
/**
* 「未归属中心」业绩下钻:按诊单医助汇总无法映射到展示「中心」的金额(与 aggregatePerformance 部门行互补)。
* 「未归属中心」业绩下钻:按订单创建人汇总无法映射到展示「中心」的金额(与 aggregatePerformance 部门行互补)。
*
* @return array{
* start_date: string,
@@ -4533,7 +4411,7 @@ class YejiStatsLogic
foreach ($rows as &$row) {
$aid = (int) $row['admin_id'];
if ($aid <= 0) {
$row['name'] = '(无单或医助未填)';
$row['name'] = '(无单或创建人未填)';
} else {
$row['name'] = (string) ($names[$aid] ?? ('#' . $aid));
}
@@ -4545,7 +4423,7 @@ class YejiStatsLogic
'end_date' => (string) $c['endDate'],
'channel_code' => (string) $c['channelCode'],
'rows' => $rows,
'note' => '与看板「未归属中心」补差一致:按订单 create_time、剔除履约已取消(4)诊单医助无法映射到任一展示「中心」计入各行;其余(无诊单/医助为空)汇总在首行。账号数据权限下仅列出可见医助。',
'note' => '与看板「未归属中心」补差一致:按订单 create_time、剔除履约已取消(4)**订单创建人**人事部门无法映射到任一展示「中心」计入各行;其余(无诊单/创建人为空)汇总在首行。账号数据权限下仅列出可见医助。',
];
}
@@ -5164,6 +5042,96 @@ class YejiStatsLogic
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 超时。
*/
@@ -5273,19 +5241,21 @@ class YejiStatsLogic
if ($tableRowDeptIds === [] || $adminToPrimary === []) {
return [
'settlement_month' => $monthRaw,
'order_month' => $orderMonthLabel,
'cutoff_end' => date('Y-m-d H:i:s', $cutoffTs),
'rule_note' => '订单创建月为结算月的上一自然月;统计系统代开处方对应的已完成业务订单;签收与尾款均在结算月 7 日 24 点前完成的金额计入本期提成,否则计入顺延下期。上期「确定业绩」写入的顺延订单会在本期并入统计。',
'channel_code' => $c['channelCode'],
'channel_name' => $c['channelInfo'] ? (string) $c['channelInfo']['channel_name'] : '',
'scope_dept_ids_key' => $pack['deptKey'],
'carry_in_order_count' => 0,
'confirm' => $confirm,
'rows' => [],
'assistant_rows' => [],
'doctor_rows' => [],
'total' => $emptyTotal,
'settlement_month' => $monthRaw,
'order_month' => $orderMonthLabel,
'cutoff_end' => date('Y-m-d H:i:s', $cutoffTs),
'rule_note' => '订单创建月为结算月的上一自然月;统计系统代开处方对应的已完成业务订单;签收与尾款均在结算月 7 日 24 点前完成的金额计入本期提成,否则计入顺延下期。上期「确定业绩」写入的顺延订单会在本期并入统计。',
'channel_code' => $c['channelCode'],
'channel_name' => $c['channelInfo'] ? (string) $c['channelInfo']['channel_name'] : '',
'scope_dept_ids_key' => $pack['deptKey'],
'carry_in_order_count' => 0,
'confirm' => $confirm,
'rows' => [],
'assistant_rows' => [],
'doctor_rows' => [],
'assistant_channel_rows' => [],
'doctor_channel_rows' => [],
'total' => $emptyTotal,
];
}
@@ -5303,6 +5273,16 @@ class YejiStatsLogic
Db::raw('IFNULL(rx.creator_id, 0) AS doctor_id'),
], $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 = [];
$defAgg = [];
$totAgg = [];
@@ -5319,6 +5299,22 @@ class YejiStatsLogic
$cntDoctorCur = [];
$cntDoctorDef = [];
/** 按 (业绩归属医助 × 挂号渠道值) / (开方医生 × 挂号渠道值) 拆分聚合,渠道值取该订单对应诊单的最新已完成挂号 channels */
$assistChTot = [];
$assistChCur = [];
$assistChDef = [];
$assistChCnt = [];
$assistChCurCnt = [];
$assistChDefCnt = [];
$assistChCarry = [];
$doctorChTot = [];
$doctorChCur = [];
$doctorChDef = [];
$doctorChCnt = [];
$doctorChCurCnt = [];
$doctorChDefCnt = [];
$doctorChCarry = [];
foreach ($rowsRaw as $r) {
$oid = (int) ($r['id'] ?? 0);
$isCarry = $oid > 0 && isset($carryFlip[$oid]);
@@ -5331,6 +5327,8 @@ class YejiStatsLogic
$signTs = (int) ($r['sign_ts'] ?? 0);
$payTs = (int) ($r['pay_ts'] ?? 0);
$amt = round((float) ($r['amount'] ?? 0), 2);
$dgId = (int) ($r['diagnosis_id'] ?? 0);
$apCh = (int) ($diagToApptChannel[$dgId] ?? 0);
if ($aid <= 0 || $amt < 0) {
continue;
@@ -5362,6 +5360,32 @@ class YejiStatsLogic
$defByDoctor[$doctorId] = ($defByDoctor[$doctorId] ?? 0.0) + $amt;
$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 {
@@ -5503,20 +5527,108 @@ class YejiStatsLogic
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 [
'settlement_month' => $monthRaw,
'order_month' => $orderMonthLabel,
'cutoff_end' => date('Y-m-d H:i:s', $cutoffTs),
'rule_note' => '订单创建月为结算月的上一自然月;仅统计系统代开处方(is_system_auto=1)对应的履约已完成业务订单。签收时间取物流表签收时间最大值;尾款时间取关联支付单已支付记录(优先类型为尾款/全部)。签收与尾款均不晚于结算月 7 日 24 点的计入「本期提成」,其余已完成订单计入「顺延下期」。上期同渠道同部门范围内「确定业绩」产生的顺延订单,会计入本期订单池。「按医助下发」按业绩归属医助聚合;「按医生下发」按处方开方人(creator_id)聚合。',
'channel_code' => $c['channelCode'],
'channel_name' => $c['channelInfo'] ? (string) $c['channelInfo']['channel_name'] : '',
'scope_dept_ids_key' => $pack['deptKey'],
'carry_in_order_count' => $carryInCnt,
'confirm' => $confirm,
'rows' => $rows,
'assistant_rows' => $assistantRows,
'doctor_rows' => $doctorRows,
'total' => [
'settlement_month' => $monthRaw,
'order_month' => $orderMonthLabel,
'cutoff_end' => date('Y-m-d H:i:s', $cutoffTs),
'rule_note' => '订单创建月为结算月的上一自然月;仅统计系统代开处方(is_system_auto=1)对应的履约已完成业务订单。签收时间取物流表签收时间最大值;尾款时间取关联支付单已支付记录(优先类型为尾款/全部)。签收与尾款均不晚于结算月 7 日 24 点的计入「本期提成」,其余已完成订单计入「顺延下期」。上期同渠道同部门范围内「确定业绩」产生的顺延订单,会计入本期订单池。「按医助下发」「按医生下发」按 (人 × 挂号渠道) 拆分;挂号渠道取该订单对应诊单的最新已完成挂号 doctor_appointment.channelsdict_data type_value=channels),如「复诊2」「自媒体1」等保持各自独立一行,未关联挂号统一展示为「未匹配挂号渠道」。',
'channel_code' => $c['channelCode'],
'channel_name' => $c['channelInfo'] ? (string) $c['channelInfo']['channel_name'] : '',
'scope_dept_ids_key' => $pack['deptKey'],
'carry_in_order_count' => $carryInCnt,
'confirm' => $confirm,
'rows' => $rows,
'assistant_rows' => $assistantRows,
'doctor_rows' => $doctorRows,
'assistant_channel_rows' => $assistantChannelRows,
'doctor_channel_rows' => $doctorChannelRows,
'total' => [
'completed_order_count' => $sumCompletedCnt,
'completed_order_amount' => round($sumCompletedAmt, 2),
'current_period_count' => $sumCurCnt,