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
@@ -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;
}
}
}