This commit is contained in:
Your Name
2026-05-06 18:17:37 +08:00
parent c389fea73c
commit a7fd55aa1c
16 changed files with 2069 additions and 375 deletions
+555 -1
View File
@@ -19,6 +19,7 @@ use app\common\model\auth\Admin;
use app\common\model\auth\AdminDept;
use app\common\model\dept\Dept;
use app\common\service\DataScope\DataScopeService;
use think\facade\Db;
/**
@@ -64,7 +65,9 @@ class DeptLogic extends BaseLogic
if (!empty($lists)) {
$pid = min(array_column($lists, 'pid'));
}
return self::getTree($lists, $pid);
$tree = self::getTree($lists, $pid);
return $tree;
}
/**
@@ -141,6 +144,557 @@ class DeptLogic extends BaseLogic
return $map;
}
/**
* 名称含「二中心」的部门 id(与业绩看板二中心规则一致)。
*
* @return list<int>
*/
private static function findErCenterRootDeptIds(): array
{
$rows = Dept::whereNull('delete_time')
->field(['id', 'name'])
->select()
->toArray();
$out = [];
foreach ($rows as $r) {
$name = (string) ($r['name'] ?? '');
if ($name !== '' && mb_strpos($name, '二中心') !== false) {
$out[] = (int) $r['id'];
}
}
return array_values(array_unique($out));
}
/**
* @param list<int> $rootIds
*
* @return list<int>
*/
private static function unionErCenterSubtreeDeptIds(array $rootIds): array
{
$set = [];
foreach ($rootIds as $rid) {
$rid = (int) $rid;
if ($rid <= 0) {
continue;
}
foreach (self::getSelfAndDescendantIds($rid) as $id) {
$set[(int) $id] = true;
}
}
return array_keys($set);
}
/**
* 名称含「二中心」的部门及其全部下级 id(map),与业绩看板判定一致。
*
* @return array<int, true>
*/
public static function getErCenterSubtreeDeptIdSet(): array
{
$erRoots = self::findErCenterRootDeptIds();
$subtreeIds = self::unionErCenterSubtreeDeptIds($erRoots);
return array_fill_keys($subtreeIds, true);
}
/**
* 二中心复诊统计用的业务订单行(与 rollup 同源 SQL)。
*
* @return list<array{diagnosis_id: int, create_time: int, id: int, assistant_id: int}>
*/
private static function fetchErCenterRevisitCandidateOrders(?int $orderStartTs = null, ?int $orderEndTs = null): array
{
$q = Db::name('tcm_prescription_order')
->alias('o')
->join('tcm_diagnosis dg', 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->whereNull('o.delete_time')
->where('o.diagnosis_id', '>', 0)
->where('dg.assistant_id', '>', 0)
->whereRaw('NOT (o.fulfillment_status <=> ?)', [4]);
if ($orderStartTs !== null && $orderEndTs !== null) {
$q->where('o.create_time', 'between', [$orderStartTs, $orderEndTs]);
}
return $q
->field(['o.diagnosis_id', 'o.create_time', 'o.id', 'dg.assistant_id'])
->order(['o.diagnosis_id' => 'asc', 'o.create_time' => 'asc', 'o.id' => 'asc'])
->select()
->toArray();
}
/**
* 二中心子树:各部门复诊 rollup;同时返回按诊单医助聚合的分项(供医助排行榜)。
*
* @param list<int> $subtreeDeptIds
*
* @return array{
* totals: array<int, int>,
* slots: array<int, array<int, int>>,
* by_assistant_slots: array<int, array<int, int>>
* }
*/
private static function computeErCenterRevisitFullPack(
array $subtreeDeptIds,
?int $orderStartTs = null,
?int $orderEndTs = null
): array {
$emptySlots = [];
$emptyTotals = [];
foreach ($subtreeDeptIds as $did) {
$did = (int) $did;
$emptySlots[$did] = [];
$emptyTotals[$did] = 0;
}
if ($subtreeDeptIds === []) {
return ['totals' => [], 'slots' => [], 'by_assistant_slots' => []];
}
$subtreeSet = array_fill_keys($subtreeDeptIds, true);
$orderRows = self::fetchErCenterRevisitCandidateOrders($orderStartTs, $orderEndTs);
if ($orderRows === []) {
return ['totals' => $emptyTotals, 'slots' => $emptySlots, 'by_assistant_slots' => []];
}
$byPatient = [];
foreach ($orderRows as $r) {
$pid = (int) ($r['diagnosis_id'] ?? 0);
if ($pid <= 0) {
continue;
}
$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;
}
}
}
$assistantIds = array_keys($assistantNeed);
$canonicalDept = self::buildAssistantCanonicalDeptInSubtree($assistantIds, $subtreeSet);
/** @var array<int, array<int, int>> $leafByDeptSlot dept_id => [ slot => cnt ]slot≥2 */
$leafByDeptSlot = [];
/** @var array<int, array<int, int>> $byAssistantSlot */
$byAssistantSlot = [];
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;
if ($deptId === null || !isset($subtreeSet[$deptId])) {
continue;
}
if (!isset($leafByDeptSlot[$deptId])) {
$leafByDeptSlot[$deptId] = [];
}
$leafByDeptSlot[$deptId][$slot] = ($leafByDeptSlot[$deptId][$slot] ?? 0) + 1;
if ($aid > 0) {
if (!isset($byAssistantSlot[$aid])) {
$byAssistantSlot[$aid] = [];
}
$byAssistantSlot[$aid][$slot] = ($byAssistantSlot[$aid][$slot] ?? 0) + 1;
}
}
}
$childrenByPid = self::buildAllDeptChildrenByPid();
$rollupSlots = self::rollupRevisitSlotsBySubtree($subtreeDeptIds, $subtreeSet, $childrenByPid, $leafByDeptSlot);
$rollupTotals = [];
foreach ($subtreeDeptIds as $id) {
$id = (int) $id;
$rollupTotals[$id] = array_sum($rollupSlots[$id] ?? []);
}
foreach ($byAssistantSlot as $aid => $sm) {
if ($sm !== []) {
ksort($byAssistantSlot[$aid], SORT_NUMERIC);
}
}
return [
'totals' => $rollupTotals,
'slots' => $rollupSlots,
'by_assistant_slots' => $byAssistantSlot,
];
}
/**
* 二中心子树:各部门复诊合计与分项(第 2 笔订单=复诊2…),供业绩看板等复用。
* $orderStartTs/$orderEndTs 均非 null 时仅统计该 create_time 窗口内业务单(与看板区间一致);均为 null 时不限时间(慎用,数据量大)。
*
* @return array{totals: array<int, int>, slots: array<int, array<int, int>>}
*/
public static function getErCenterRevisitRollupIndexedByDept(?int $orderStartTs = null, ?int $orderEndTs = null): array
{
$erRoots = self::findErCenterRootDeptIds();
$subtreeIds = self::unionErCenterSubtreeDeptIds($erRoots);
if ($subtreeIds === []) {
return ['totals' => [], 'slots' => []];
}
$pack = self::computeErCenterRevisitFullPack($subtreeIds, $orderStartTs, $orderEndTs);
return ['totals' => $pack['totals'], 'slots' => $pack['slots']];
}
/**
* 二中心子树内:各医助成交的复诊业务单笔数(合计 + 复诊2/3/…分项),口径与部门 rollup 同源。
*
* @return array{totals: array<int, int>, slots: array<int, array<int, int>>}
*/
public static function getErCenterRevisitCountsByAssistant(?int $orderStartTs = null, ?int $orderEndTs = null): array
{
$erRoots = self::findErCenterRootDeptIds();
$subtreeIds = self::unionErCenterSubtreeDeptIds($erRoots);
if ($subtreeIds === []) {
return ['totals' => [], 'slots' => []];
}
$pack = self::computeErCenterRevisitFullPack($subtreeIds, $orderStartTs, $orderEndTs);
$by = $pack['by_assistant_slots'];
$totals = [];
$slots = [];
foreach ($by as $aid => $slotMap) {
$aid = (int) $aid;
$totals[$aid] = (int) array_sum($slotMap);
$slots[$aid] = $slotMap;
}
return ['totals' => $totals, 'slots' => $slots];
}
/**
* 某医助在区间内、计入二中心复诊的业务订单 id(用于看板侧栏按复诊笔数下钻)。
* $revisitSlot = 0 表示累计全部复诊(第 2 笔起);≥2 时仅该分项。
*
* @return list<int>
*/
public static function listErCenterRevisitOrderIdsForAssistant(
int $assistantId,
int $revisitSlot,
int $orderStartTs,
int $orderEndTs
): array {
if ($assistantId <= 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 [];
}
$byPatient = [];
foreach ($orderRows as $r) {
$pid = (int) ($r['diagnosis_id'] ?? 0);
if ($pid <= 0) {
continue;
}
$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])) {
continue;
}
$oid = (int) ($orders[$i]['id'] ?? 0);
if ($oid > 0) {
$ids[$oid] = true;
}
}
}
return array_keys($ids);
}
/**
* 某展示部门行(组织架构上含下级 rollup)内,二中心复诊按诊单医助拆解笔数。
* 与看板行内「复诊」列口径一致:诊单下订单序列第 2 笔起;医助 canonical 部门须落在二中心子树且在该部门行的子树内。
*
* @return array{rows: list<array{admin_id: int, name: string, order_count: int}>}
*/
public static function getErCenterRevisitAssistantBreakdownForDept(
int $rootDeptId,
int $revisitSlot,
int $orderStartTs,
int $orderEndTs
): array {
if ($rootDeptId <= 0 || $orderStartTs <= 0 || $orderEndTs < $orderStartTs) {
return ['rows' => []];
}
if ($revisitSlot < 0 || $revisitSlot === 1) {
return ['rows' => []];
}
$erSubtreeSet = self::getErCenterSubtreeDeptIdSet();
if ($erSubtreeSet === []) {
return ['rows' => []];
}
$descIds = self::getSelfAndDescendantIds($rootDeptId);
$descFlip = [];
foreach ($descIds as $did) {
$did = (int) $did;
if ($did > 0) {
$descFlip[$did] = true;
}
}
$orderRows = self::fetchErCenterRevisitCandidateOrders($orderStartTs, $orderEndTs);
if ($orderRows === []) {
return ['rows' => []];
}
$byPatient = [];
foreach ($orderRows as $r) {
$pid = (int) ($r['diagnosis_id'] ?? 0);
if ($pid <= 0) {
continue;
}
$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), $erSubtreeSet);
/** @var array<int, int> $counts */
$counts = [];
foreach ($byPatient as $orders) {
$n = \count($orders);
for ($i = 1; $i < $n; $i++) {
$slot = $i + 1;
if ($revisitSlot > 0 && $slot !== $revisitSlot) {
continue;
}
$aid = (int) ($orders[$i]['assistant_id'] ?? 0);
if ($aid <= 0) {
continue;
}
$canon = $canonicalDept[$aid] ?? null;
if ($canon === null || !isset($erSubtreeSet[$canon]) || !isset($descFlip[$canon])) {
continue;
}
$counts[$aid] = ($counts[$aid] ?? 0) + 1;
}
}
if ($counts === []) {
return ['rows' => []];
}
$ids = array_keys($counts);
$nameRows = [];
if ($ids !== []) {
$nameRows = Db::name('admin')
->whereIn('id', $ids)
->whereNull('delete_time')
->column('name', 'id');
}
$rows = [];
foreach ($counts as $aid => $cnt) {
$rows[] = [
'admin_id' => (int) $aid,
'name' => (string) ($nameRows[$aid] ?? ('#' . $aid)),
'order_count' => (int) $cnt,
];
}
usort($rows, static function (array $a, array $b): int {
if ($a['order_count'] !== $b['order_count']) {
return $b['order_count'] <=> $a['order_count'];
}
return strcmp((string) $a['name'], (string) $b['name']);
});
return ['rows' => $rows];
}
/**
* @return array<int, list<int>>
*/
private static function buildAllDeptChildrenByPid(): array
{
$rows = Dept::field(['id', 'pid'])->select()->toArray();
$out = [];
foreach ($rows as $r) {
$pid = (int) ($r['pid'] ?? 0);
$id = (int) ($r['id'] ?? 0);
if ($id <= 0) {
continue;
}
if (!isset($out[$pid])) {
$out[$pid] = [];
}
$out[$pid][] = $id;
}
return $out;
}
/**
* @param array<int, array<int, int>> $leafByDeptSlot
*
* @return array<int, array<int, int>> dept_id => [ slot => count ],已含下级汇总
*/
private static function rollupRevisitSlotsBySubtree(
array $subtreeDeptIds,
array $subtreeSet,
array $childrenByPid,
array $leafByDeptSlot
): array {
$memo = [];
$dfs = static function (int $id) use (&$dfs, $childrenByPid, $leafByDeptSlot, $subtreeSet, &$memo): array {
if (isset($memo[$id])) {
return $memo[$id];
}
$acc = [];
if (isset($leafByDeptSlot[$id])) {
foreach ($leafByDeptSlot[$id] as $slot => $cnt) {
$acc[(int) $slot] = (int) $cnt;
}
}
foreach ($childrenByPid[$id] ?? [] as $cid) {
$cid = (int) $cid;
if (!isset($subtreeSet[$cid])) {
continue;
}
$childAcc = $dfs($cid);
foreach ($childAcc as $slot => $cnt) {
$acc[$slot] = ($acc[$slot] ?? 0) + $cnt;
}
}
if ($acc !== []) {
ksort($acc, SORT_NUMERIC);
}
$memo[$id] = $acc;
return $acc;
};
$out = [];
foreach ($subtreeDeptIds as $id) {
$id = (int) $id;
$out[$id] = $dfs($id);
}
return $out;
}
/**
* @param list<int> $assistantIds
* @param array<int, true> $subtreeSet
*
* @return array<int, int> admin_id => dept_id
*/
private static function buildAssistantCanonicalDeptInSubtree(array $assistantIds, array $subtreeSet): array
{
$ids = array_values(array_unique(array_filter(array_map('intval', $assistantIds), static function (int $v): bool {
return $v > 0;
})));
if ($ids === []) {
return [];
}
$subtreeIdList = array_keys($subtreeSet);
$rows = AdminDept::whereIn('admin_id', $ids)
->whereIn('dept_id', $subtreeIdList)
->field(['admin_id', 'dept_id'])
->select()
->toArray();
$candidates = [];
foreach ($rows as $r) {
$a = (int) ($r['admin_id'] ?? 0);
$d = (int) ($r['dept_id'] ?? 0);
if ($a > 0 && $d > 0) {
$candidates[$a][] = $d;
}
}
$depthMap = self::buildDeptIdDepthMap();
$out = [];
foreach ($candidates as $aid => $depts) {
$best = null;
$bestDepth = -1;
foreach ($depts as $d) {
$depth = (int) ($depthMap[$d] ?? 0);
if (
$best === null
|| $depth > $bestDepth
|| ($depth === $bestDepth && $d < (int) $best)
) {
$bestDepth = $depth;
$best = $d;
}
}
if ($best !== null) {
$out[$aid] = (int) $best;
}
}
return $out;
}
/**
* @return array<int, int> dept_id => 从根层深度 0 起的层级
*/
private static function buildDeptIdDepthMap(): array
{
$rows = Dept::field(['id', 'pid'])->select()->toArray();
$pidById = [];
foreach ($rows as $r) {
$pidById[(int) $r['id']] = (int) $r['pid'];
}
$depth = [];
$getDepth = static function (int $id) use (&$getDepth, &$depth, $pidById): int {
if (isset($depth[$id])) {
return $depth[$id];
}
$p = (int) ($pidById[$id] ?? 0);
$depth[$id] = $p > 0 ? $getDepth($p) + 1 : 0;
return $depth[$id];
};
foreach (array_keys($pidById) as $id) {
$getDepth((int) $id);
}
return $depth;
}
/**
* @notes 列表树状结构
* @param $array
@@ -14,6 +14,9 @@ use think\facade\Db;
*
* - 进线数据 = 区间内 add_external_contact 事件,按接待员工(user_id) 归属部门
* · 与 qywx.customer/todayArrival、客户列表同源事件表;选定渠道时客户须带对应企微标签(contact_tag);**二中心展示部门在选定渠道下计 0**
* - 被指派数 = 区间内 tcm_diagnosis_assign_log 成功指派次数(to_assistant_id>0),按**被指派医助**归属展示部门;选定渠道时收窄与「{渠道}业绩」同源(channels EXISTS 或 tag 诊单/医助)
* · **二中心展示部门在选定渠道下计 0**(与进线一致)
* - 复诊统计 = 仅**名称含「二中心」的部门及其组织架构下级**:区间内业务单按患者 create_time 排序,第 1 笔不计,第 2 笔起分列「复诊2」「复诊3」…;口径同业绩单条件;按诊单医助在组织树内归属 rollup(含下级),与 dept 模块 ErCenter 复诊算法一致
* - 接诊诊单 = 已完成挂号 status=3,**与 admin 挂号列表条数同口径**appointment_date、诊单未软删),不限医助角色、不要求 assistant_id 非 0
* 部门归属:优先 COALESCE(挂号.assistant_id, 诊单.assistant_id) 映射到展示「中心」,否则回退接诊医生 doctor_id;仍无法归属则计入「未归属中心」行。
* - {渠道}成交单 = 选定渠道时与 {渠道}业绩同源:区间内 **非取消业务订单条数**(仅剔除 fulfillment_status=4NULL 与其它状态计入,按 create_time 归日);dict(channels) 与 tag/creator 两套口径均按订单计,非挂号条数。
@@ -269,6 +272,16 @@ class YejiStatsLogic
: null;
$leads = self::countLeads($startTs, $endTs, $adminToPrimary, $tagFilterId);
$assignsByDept = self::countDiagnosisAssignsByDept(
$startTs,
$endTs,
$adminToPrimary,
$tagDiagIds,
$tagFallback ? $tagAssistantIds : null,
$appointmentChannelValues,
$channelFilterActive,
$dataScopeAdminIds
);
// 接诊:
// - consults(全量) → "接诊诊单" 列;不限渠道
// - channelConsults(渠道) → 「{渠道}成交单」列;未选渠道时与 consults 相同
@@ -312,6 +325,7 @@ class YejiStatsLogic
$channelConsults
);
self::excludeErCenterFromLeads($primaryDeptIds, $deptNameMap, $leads);
self::excludeErCenterFromLeads($primaryDeptIds, $deptNameMap, $assignsByDept);
}
$globalCost = round((float) self::sumGlobalCost($startDate, $endDate, $channelCode), 1);
@@ -323,9 +337,18 @@ class YejiStatsLogic
);
$costs = self::allocateCostByLeads($globalCost, $tableRowDeptIds, $leadsForCost);
@set_time_limit(120);
$revisitPack = DeptLogic::getErCenterRevisitRollupIndexedByDept($startTs, $endTs);
$revisitTotals = $revisitPack['totals'];
$revisitSlotsByDept = $revisitPack['slots'];
// ── 5) 组装每行 ──────────────────────────────────────────────
$rows = [];
$totalLead = 0;
$totalAssign = 0;
$totalRevisit = 0;
/** @var array<int, int> $totalRevisitSlots */
$totalRevisitSlots = [];
$totalChannelConsult = 0;
$totalPerf = 0.0;
$totalChannelPerf = 0.0;
@@ -348,10 +371,16 @@ class YejiStatsLogic
$rowRoi = $rowCost > 0 ? round($cperf / $rowCost, 1) : null;
$dDeal = (int) ($dealOrderByDept[$deptId] ?? 0);
$assignCnt = (int) ($assignsByDept[$deptId] ?? 0);
$rv = (int) ($revisitTotals[$deptId] ?? 0);
$rvSlots = $revisitSlotsByDept[$deptId] ?? [];
$rows[] = [
'dept_id' => $deptId,
'dept_name' => self::formatYejiDeptRowDisplayName($deptId, $deptById),
'lead_count' => $lead,
'assign_count' => $assignCnt,
'revisit_count' => $rv,
'revisit_slots' => $rvSlots,
'consult_count' => $cnt,
'deal_order_count' => $dDeal,
// {渠道}成交单:选定渠道时与 completed_performance 同源;未选渠道时与 consult_count 相同
@@ -366,6 +395,12 @@ class YejiStatsLogic
];
$totalLead += $lead;
$totalAssign += $assignCnt;
$totalRevisit += $rv;
foreach ($rvSlots as $slot => $cntRv) {
$slot = (int) $slot;
$totalRevisitSlots[$slot] = ($totalRevisitSlots[$slot] ?? 0) + (int) $cntRv;
}
$totalChannelConsult += $cCnt;
$totalPerf += $perf;
$totalChannelPerf += $cperf;
@@ -411,6 +446,9 @@ class YejiStatsLogic
'dept_id' => 0,
'dept_name' => '未归属中心',
'lead_count' => 0,
'assign_count' => 0,
'revisit_count' => 0,
'revisit_slots' => [],
'consult_count' => $unmappedConsult,
'deal_order_count' => $unmappedDealOrder,
'channel_consult_count' => 0,
@@ -437,6 +475,10 @@ class YejiStatsLogic
// ROI 合计 = 渠道业绩合计(与「completed」列一致,**不含二中心**)÷ 投放成本合计
$totalRoiNumerator = round($totalChannelPerf, 1);
if ($totalRevisitSlots !== []) {
ksort($totalRevisitSlots, SORT_NUMERIC);
}
$scopedTableNote = ($explicitDeptFilter
? ' 已筛选展示部门时,表格合计仅含所选部门树内数据,不出现「全站未归属」补差行(避免把其他中心业绩/单量算入未归属)。若勾选的是带下级的部门(如某「中心」),表格只列出其下级部门行,不单独占一行父级。'
: '')
@@ -464,6 +506,9 @@ class YejiStatsLogic
'rows' => $rows,
'total' => [
'lead_count' => $totalLead,
'assign_count' => $totalAssign,
'revisit_count' => $totalRevisit,
'revisit_slots' => $totalRevisitSlots,
'consult_count' => $totalConsult,
'deal_order_count' => $totalDealOrderCount,
'channel_consult_count' => $totalChannelConsult,
@@ -503,7 +548,9 @@ class YejiStatsLogic
/**
* 医助排行榜:按展示部门分表,每表内按诊金降序排名。
* 诊金口径:无渠道=诊单 dg.assistant_id 业绩(与列表 assistant_id 筛选一致);有渠道={渠道}业绩/dict 诊单医助;二中心在选渠道下注 0。
* 被指派数 = 区间内诊单指派日志(to_assistant_id>0)按**被指派医助**计数,与部门表「被指派数」同收窄/二中心规则。
* 接诊率 = 排行用诊金 ÷ 进线条数(元/进线,1 位小数;进线为 0 时为 0)。
* 复诊(属二中心组织架构的展示部门):同一诊单下业务单按 create_time 排序第 2 笔起分项,与部门业绩表「二中心复诊」同源;前端点击数字可下钻对应业务订单。
*
* @param array{start_date?:string,end_date?:string,dept_ids?:int[]|string,channel_code?:string,tag_id?:string} $params
*
@@ -532,6 +579,10 @@ class YejiStatsLogic
$tagFallback = $c['tagFallback'];
$appointmentChannelValues = $c['appointmentChannelValues'];
$channelFilterActive = $c['channelFilterActive'];
$dataScopeRestricted = !empty($c['dataScopeRestricted']);
$dataScopeAdminIds = $dataScopeRestricted && isset($c['dataScopeVisibleAdminIds'])
? $c['dataScopeVisibleAdminIds']
: null;
$channelName = $channelInfo ? (string) $channelInfo['channel_name'] : '';
@@ -569,11 +620,30 @@ class YejiStatsLogic
$leadsByAdmin = self::countLeadsByAdmin($startTs, $endTs, $tagFilterId);
/** 与部门表接诊同口径;标签/医助穿透时与业绩同源收窄,避免榜上证务与接诊脱节 */
$consultsByAdmin = self::aggregateConsultsByAdmin($startTs, $endTs, $tagDiagIds, $scopedAssistants);
$assignsByAdmin = self::countDiagnosisAssignsByAdmin(
$startTs,
$endTs,
$tagDiagIds,
$tagFallback ? $tagAssistantIds : null,
$appointmentChannelValues,
$channelFilterActive,
$dataScopeAdminIds
);
if ($channelFilterActive) {
self::excludeErCenterFromAdminLeaderboard($adminToPrimary, $deptNameMap, $feeByAdmin, $leadsByAdmin, $dealOrderByAdmin);
self::excludeErCenterFromAdminLeaderboard(
$adminToPrimary,
$deptNameMap,
$feeByAdmin,
$leadsByAdmin,
$dealOrderByAdmin,
$assignsByAdmin
);
}
$erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet();
$revisitByAssistant = DeptLogic::getErCenterRevisitCountsByAssistant($startTs, $endTs);
$assistantIdRows = Db::name('admin_role')->where('role_id', 2)->column('admin_id');
$assistantSet = [];
foreach ($assistantIdRows as $rid) {
@@ -598,15 +668,16 @@ class YejiStatsLogic
}
$rangeNote = $channelFilterActive
? ('排行诊金 = 选定渠道「' . $channelName . '」业绩口径(与部门表该渠道业绩列一致)。接诊率 = 诊金 ÷ 进线(元/进线)。'
. '「二中心」医助在该渠道下诊金进线计 0。')
: '排行诊金 = 处方订单列表 assistant_id 口径(诊单医助业绩,与列表按医助筛选一致)。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。';
? ('排行诊金 = 选定渠道「' . $channelName . '」业绩口径(与部门表该渠道业绩列一致)。被指派数与部门表同口径。接诊率 = 诊金 ÷ 进线(元/进线)。'
. '「二中心」医助在该渠道下诊金进线与被指派计 0。')
: '排行诊金 = 处方订单列表 assistant_id 口径(诊单医助业绩,与列表按医助筛选一致)。被指派数为指派至该医助的次数。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。';
$leaderboards = [];
foreach ($tableRowDeptIds as $deptId) {
$leafName = $deptNameMap[$deptId] ?? ('部门#' . $deptId);
$deptName = self::formatYejiDeptRowDisplayName($deptId, $deptById);
$deptAbbr = self::deptDisplayAbbr($leafName);
$isErCenterLb = isset($erCenterDeptSet[$deptId]);
$rows = [];
foreach (array_keys($assistantSet) as $aid) {
if (($adminToPrimary[$aid] ?? 0) !== $deptId) {
@@ -616,21 +687,37 @@ class YejiStatsLogic
$lead = (int) ($leadsByAdmin[$aid] ?? 0);
$consult = (int) ($consultsByAdmin[$aid] ?? 0);
$dealOrders = (int) ($dealOrderByAdmin[$aid] ?? 0);
if ($fee < 0.005 && $lead === 0 && $consult === 0 && $dealOrders === 0) {
$assignCnt = (int) ($assignsByAdmin[$aid] ?? 0);
$rv = $isErCenterLb ? (int) ($revisitByAssistant['totals'][$aid] ?? 0) : 0;
$rvSlots = $isErCenterLb ? ($revisitByAssistant['slots'][$aid] ?? []) : [];
if (
$fee < 0.005
&& $lead === 0
&& $consult === 0
&& $dealOrders === 0
&& $assignCnt === 0
&& $rv === 0
) {
continue;
}
$rawFee = (float) ($feeByAdmin[$aid] ?? 0.0);
$rate = $lead > 0 ? round($rawFee / $lead, 1) : 0.0;
$rows[] = [
$row = [
'admin_id' => $aid,
'name' => $adminNames[$aid] ?? ('#' . $aid),
'fee_amount' => $fee,
'lead_count' => $lead,
'assign_count' => $assignCnt,
'consult_count' => $consult,
'deal_order_count' => $dealOrders,
'consult_rate' => $rate,
'dept_abbr' => $deptAbbr,
];
if ($isErCenterLb) {
$row['revisit_count'] = $rv;
$row['revisit_slots'] = $rvSlots;
}
$rows[] = $row;
}
usort($rows, static function (array $a, array $b): int {
if ($a['fee_amount'] != $b['fee_amount']) {
@@ -648,6 +735,7 @@ class YejiStatsLogic
'dept_id' => $deptId,
'dept_name' => $deptName,
'dept_abbr' => $deptAbbr,
'er_center_subtree' => $isErCenterLb,
'rows' => $rows,
];
}
@@ -658,7 +746,7 @@ class YejiStatsLogic
'channel_code' => $channelCode,
'channel_name' => $channelName,
'channel_filter_note' => $channelCode !== ''
? '医助榜:名称含「二中心」的部门在选定渠道下诊金、进线计 0,与部门看板一致。'
? '医助榜:名称含「二中心」的部门在选定渠道下诊金、进线、被指派数计 0,与部门看板一致。'
: '',
'range_note' => $rangeNote,
'leaderboards' => $leaderboards,
@@ -1174,6 +1262,181 @@ class YejiStatsLogic
return $byDept;
}
/**
* 被指派数:诊单指派日志(to_assistant_id>0)按被指派医助归属展示部门;选定渠道时收窄与「{渠道}业绩」同源。
*
* @param array<int, int> $adminToPrimary
* @param int[]|null $tagDiagIds
* @param array<int, int>|null $tagAssistantIds tag 退化口径下按 to_assistant_id 过滤(与 aggregatePerformance 一致)
*
* @return array<int, int>
*/
private static function countDiagnosisAssignsByDept(
int $startTs,
int $endTs,
array $adminToPrimary,
?array $tagDiagIds,
?array $tagAssistantIds,
array $appointmentChannelValues,
bool $channelFilterActive,
?array $dataScopeAdminIds = null
): array {
if ($adminToPrimary === []) {
return [];
}
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$query = Db::name('tcm_diagnosis_assign_log')
->alias('lg')
->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->where('lg.create_time', 'between', [$startTs, $endTs])
->where('lg.to_assistant_id', '>', 0);
if ($channelFilterActive) {
if ($appointmentChannelValues !== []) {
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
if ($norm === []) {
return [];
}
$apTable = self::tableWithPrefix('doctor_appointment');
$adminRoleTable = self::tableWithPrefix('admin_role');
$strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $norm)));
$ph = implode(',', array_fill(0, count($strVals), '?'));
$channelCond = "ap.channels IN ({$ph})";
$existsSql = "EXISTS (SELECT 1 FROM {$apTable} ap INNER JOIN {$adminRoleTable} ar "
. "ON ar.admin_id = ap.assistant_id AND ar.role_id = 2 WHERE ap.patient_id = dg.id "
. "AND ap.status = 3 AND {$channelCond})";
$query->whereRaw($existsSql, $strVals);
} elseif ($tagDiagIds !== null || $tagAssistantIds !== null) {
if ($tagDiagIds !== null && $tagDiagIds === []) {
return [];
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return [];
}
if ($tagDiagIds !== null) {
$query->whereIn('lg.diagnosis_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$query->whereIn('lg.to_assistant_id', array_map('intval', array_keys($tagAssistantIds)));
}
} else {
return [];
}
}
$query->field(['lg.to_assistant_id', Db::raw('COUNT(*) AS cnt')])
->group('lg.to_assistant_id');
$rows = $query->select()->toArray();
if ($rows === []) {
return [];
}
$visFlip = $dataScopeAdminIds !== null && $dataScopeAdminIds !== []
? array_flip($dataScopeAdminIds)
: null;
$byDept = [];
foreach ($rows as $r) {
$aid = (int) ($r['to_assistant_id'] ?? 0);
if ($aid <= 0) {
continue;
}
if ($visFlip !== null && !isset($visFlip[$aid])) {
continue;
}
$primary = (int) ($adminToPrimary[$aid] ?? 0);
if ($primary <= 0) {
continue;
}
$byDept[$primary] = ($byDept[$primary] ?? 0) + (int) ($r['cnt'] ?? 0);
}
return $byDept;
}
/**
* 医助维度被指派次数:与 countDiagnosisAssignsByDept 同过滤与区间,按 to_assistant_id 汇总(供排行榜)。
*
* @return array<int, int>
*/
private static function countDiagnosisAssignsByAdmin(
int $startTs,
int $endTs,
?array $tagDiagIds,
?array $tagAssistantIds,
array $appointmentChannelValues,
bool $channelFilterActive,
?array $dataScopeAdminIds = null
): array {
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$query = Db::name('tcm_diagnosis_assign_log')
->alias('lg')
->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->where('lg.create_time', 'between', [$startTs, $endTs])
->where('lg.to_assistant_id', '>', 0);
if ($channelFilterActive) {
if ($appointmentChannelValues !== []) {
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
if ($norm === []) {
return [];
}
$apTable = self::tableWithPrefix('doctor_appointment');
$adminRoleTable = self::tableWithPrefix('admin_role');
$strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $norm)));
$ph = implode(',', array_fill(0, count($strVals), '?'));
$channelCond = "ap.channels IN ({$ph})";
$existsSql = "EXISTS (SELECT 1 FROM {$apTable} ap INNER JOIN {$adminRoleTable} ar "
. "ON ar.admin_id = ap.assistant_id AND ar.role_id = 2 WHERE ap.patient_id = dg.id "
. "AND ap.status = 3 AND {$channelCond})";
$query->whereRaw($existsSql, $strVals);
} elseif ($tagDiagIds !== null || $tagAssistantIds !== null) {
if ($tagDiagIds !== null && $tagDiagIds === []) {
return [];
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return [];
}
if ($tagDiagIds !== null) {
$query->whereIn('lg.diagnosis_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$query->whereIn('lg.to_assistant_id', array_map('intval', array_keys($tagAssistantIds)));
}
} else {
return [];
}
}
$query->field(['lg.to_assistant_id', Db::raw('COUNT(*) AS cnt')])
->group('lg.to_assistant_id');
$rows = $query->select()->toArray();
if ($rows === []) {
return [];
}
$visFlip = $dataScopeAdminIds !== null && $dataScopeAdminIds !== []
? array_flip($dataScopeAdminIds)
: null;
$byAdmin = [];
foreach ($rows as $r) {
$aid = (int) ($r['to_assistant_id'] ?? 0);
if ($aid <= 0) {
continue;
}
if ($visFlip !== null && !isset($visFlip[$aid])) {
continue;
}
$byAdmin[$aid] = ($byAdmin[$aid] ?? 0) + (int) ($r['cnt'] ?? 0);
}
return $byAdmin;
}
/**
* 接诊诊单聚合:返回两组按部门归属的接诊计数。
* - all: 区间内 status=3 挂号条数按部门归属(与列表计数一致),不含「仅 role_id=2」等额外限制。
@@ -1642,6 +1905,100 @@ class YejiStatsLogic
];
}
/**
* 业绩看板:二中心复诊按部门行下钻 —— 医助姓名与业务订单笔数(与表格「复诊」列同口径)。
*
* @param array{
* start_date?:string,end_date?:string,dept_ids?:int[]|string,channel_code?:string,tag_id?:string,
* dept_id?:int|string,revisit_slot?:int|string
* } $params
* revisit_slot0=复诊合计(全部复诊笔),≥2=对应分项
*
* @return array{
* start_date:string,end_date:string,dept_id:int,dept_name:string,revisit_slot:int,revisit_slot_label:string,
* rows:list<array{admin_id:int,name:string,order_count:int}>,note:string
* }
*/
public static function revisitDeptAssistantBreakdown(
array $params,
int $viewerAdminId = 0,
array $viewerAdminInfo = []
): array {
$c = self::resolveYejiContext($params);
if ($viewerAdminId > 0) {
self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo);
}
$deptId = (int) ($params['dept_id'] ?? 0);
$revisitSlot = (int) ($params['revisit_slot'] ?? 0);
$deptName = $deptId > 0 ? self::formatYejiDeptRowDisplayName($deptId, $c['deptById']) : '';
$slotLabel = '复诊合计';
if ($revisitSlot >= 2) {
if ($revisitSlot === 2) {
$slotLabel = '复诊2';
} elseif ($revisitSlot >= 3 && $revisitSlot <= 10) {
$cn = ['三', '四', '五', '六', '七', '八', '九', '十'];
$slotLabel = '复诊' . ($cn[$revisitSlot - 3] ?? (string) $revisitSlot);
} else {
$slotLabel = '复诊' . (string) $revisitSlot;
}
}
$out = [
'start_date' => (string) $c['startDate'],
'end_date' => (string) $c['endDate'],
'dept_id' => $deptId,
'dept_name' => $deptName,
'revisit_slot' => $revisitSlot,
'revisit_slot_label' => $slotLabel,
'rows' => [],
'note' => '',
];
if ($deptId <= 0) {
$out['note'] = '请选择有效部门行。';
return $out;
}
if (!in_array($deptId, $c['tableRowDeptIds'], true)) {
$out['note'] = '该部门不在当前展示部门或数据权限范围内。';
return $out;
}
if ($revisitSlot < 0 || $revisitSlot === 1) {
$out['note'] = '无效的复诊分项。';
return $out;
}
$pack = DeptLogic::getErCenterRevisitAssistantBreakdownForDept(
$deptId,
$revisitSlot,
(int) $c['startTs'],
(int) $c['endTs']
);
$rows = $pack['rows'];
if (!empty($c['dataScopeRestricted']) && isset($c['dataScopeVisibleAdminIds'])) {
$flip = array_flip($c['dataScopeVisibleAdminIds']);
$filtered = [];
foreach ($rows as $rw) {
if (isset($flip[(int) ($rw['admin_id'] ?? 0)])) {
$filtered[] = $rw;
}
}
$rows = $filtered;
}
$out['rows'] = $rows;
if ($rows === []) {
$out['note'] = '当前部门与区间下无符合条件的复诊业务订单(口径与看板「二中心复诊」列一致)。';
}
return $out;
}
/**
* 与 sumPerformance 对应,按 admin 维度返回全量/渠道业绩金额(不按部门折叠)。
*
@@ -1931,13 +2288,15 @@ class YejiStatsLogic
*
* @param array<int, float> $feeByAdmin
* @param array<int, int> $leadsByAdmin
* @param array<int, int>|null $assignsByAdmin
*/
private static function excludeErCenterFromAdminLeaderboard(
array $adminToPrimary,
array $deptNameMap,
array &$feeByAdmin,
array &$leadsByAdmin,
?array &$dealOrderByAdmin = null
?array &$dealOrderByAdmin = null,
?array &$assignsByAdmin = null
): void {
foreach ($adminToPrimary as $aid => $pid) {
if (!self::isErCenterDisplayPrimaryName($deptNameMap[$pid] ?? '')) {
@@ -1948,6 +2307,9 @@ class YejiStatsLogic
if ($dealOrderByAdmin !== null) {
$dealOrderByAdmin[$aid] = 0;
}
if ($assignsByAdmin !== null) {
$assignsByAdmin[$aid] = 0;
}
}
}
@@ -240,6 +240,31 @@ class PrescriptionOrderLogic
}
}
/**
* 菜单权限:查看/编辑业务订单「备注」(对内 remark_extra
*
* perms: tcm.prescriptionOrder/editRemarkExtra
*/
public static function canEditOrderRemarkExtra(array $adminInfo): bool
{
if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) {
return true;
}
$perms = AuthLogic::getAuthByAdminId((int) ($adminInfo['admin_id'] ?? 0));
return in_array('tcm.prescriptionOrder/editRemarkExtra', $perms, true);
}
/**
* @param array<string,mixed> $row
*/
public static function maskRemarkExtraIfNeeded(array &$row, array $adminInfo): void
{
if (!self::canEditOrderRemarkExtra($adminInfo)) {
unset($row['remark_extra']);
}
}
/** 定金门槛(元),0 表示不校验 */
public static function depositMinAmount(): float
{
@@ -613,7 +638,10 @@ class PrescriptionOrderLogic
$order->fee_type = (int) $params['fee_type'];
$order->amount = round((float) $params['amount'], 2);
$order->internal_cost = $internalCost;
$order->remark_extra = (string) ($params['remark_extra'] ?? '');
$order->remark_extra = self::canEditOrderRemarkExtra($adminInfo)
? (string) ($params['remark_extra'] ?? '')
: '';
$order->remark_assistant = (string) ($params['remark_assistant'] ?? '');
$order->linked_pay_order_id = $payOrderIds !== [] ? $payOrderIds[0] : null;
$order->prescription_audit_status = 0;
$order->prescription_audit_remark = '';
@@ -637,6 +665,7 @@ class PrescriptionOrderLogic
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::maskRemarkExtraIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
@@ -658,6 +687,7 @@ class PrescriptionOrderLogic
}
$arr = $row->toArray();
self::maskInternalCostIfNeeded($arr, $adminInfo);
self::maskRemarkExtraIfNeeded($arr, $adminInfo);
self::attachLinkedPayOrders($arr);
// 添加创建人姓名
@@ -1050,7 +1080,10 @@ class PrescriptionOrderLogic
}
$order->fee_type = (int) $params['fee_type'];
$order->amount = round((float) $params['amount'], 2);
$order->remark_extra = (string) ($params['remark_extra'] ?? '');
if (self::canEditOrderRemarkExtra($adminInfo)) {
$order->remark_extra = (string) ($params['remark_extra'] ?? '');
}
$order->remark_assistant = (string) ($params['remark_assistant'] ?? '');
$diagId = (int) $order->diagnosis_id;
@@ -1141,6 +1174,7 @@ class PrescriptionOrderLogic
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::maskRemarkExtraIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
@@ -1219,6 +1253,7 @@ class PrescriptionOrderLogic
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::maskRemarkExtraIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
@@ -1287,6 +1322,7 @@ class PrescriptionOrderLogic
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::maskRemarkExtraIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
@@ -1365,6 +1401,7 @@ class PrescriptionOrderLogic
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::maskRemarkExtraIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
@@ -1503,6 +1540,7 @@ class PrescriptionOrderLogic
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::maskRemarkExtraIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
@@ -1589,6 +1627,7 @@ class PrescriptionOrderLogic
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::maskRemarkExtraIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
@@ -1659,6 +1698,7 @@ class PrescriptionOrderLogic
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::maskRemarkExtraIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
@@ -1775,6 +1815,7 @@ class PrescriptionOrderLogic
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::maskRemarkExtraIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
@@ -1885,6 +1926,7 @@ class PrescriptionOrderLogic
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::maskRemarkExtraIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;
@@ -1972,6 +2014,7 @@ class PrescriptionOrderLogic
$out = $order->toArray();
self::maskInternalCostIfNeeded($out, $adminInfo);
self::maskRemarkExtraIfNeeded($out, $adminInfo);
self::attachLinkedPayOrders($out);
return $out;