Files
zyt/server/app/adminapi/logic/stats/YejiStatsLogic.php
T
2026-05-05 13:28:45 +08:00

2539 lines
99 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\adminapi\logic\dept\DeptLogic;
use app\common\service\DataScope\DataScopeService;
use app\common\service\qywx\MediaChannelService;
use think\facade\Db;
/**
* 业绩看板(部门 × 时间区间 × 渠道来源)
*
* - 进线数据 = 区间内 add_external_contact 事件,按接待员工(user_id) 归属部门
* · 与 qywx.customer/todayArrival、客户列表同源事件表;选定渠道时客户须带对应企微标签(contact_tag);**二中心展示部门在选定渠道下计 0**
* - 接诊诊单 = 已完成挂号 status=3,**与 admin 挂号列表条数同口径**appointment_date、诊单未软删),不限医助角色、不要求 assistant_id 非 0
* 部门归属:优先 COALESCE(挂号.assistant_id, 诊单.assistant_id) 映射到展示「中心」,否则回退接诊医生 doctor_id;仍无法归属则计入「未归属中心」行。
* - {渠道}成交单 = 选定渠道时与 {渠道}业绩同源:区间内 **非取消业务订单条数**(仅剔除 fulfillment_status=4NULL 与其它状态计入,按 create_time 归日);dict(channels) 与 tag/creator 两套口径均按订单计,非挂号条数。
* - 合计业绩 = 区间内 zyt_tcm_prescription_order.amount 求和(排除已取消(4) 与软删),按 dg.assistant_id 归属到「中心」展示部门;
* 无法归属到任一展示部门的金额单列「未归属中心」行;合计行 = 与列表同条件的全表 SUM(不限部门)
* · 时间窗与业绩条件与列表 stats_order_amount_performance 一致;列表若另有患者/单号等筛选或账号数据域收窄,金额可能仍不同
* · 选定渠道时「{渠道}业绩」列仍按比例展示;合计列全量与渠道列含未归属补差;**名称含「二中心」的展示部门不参与该渠道进线/业绩/成交单/ROI 分子**
* - {渠道}业绩 = 与「处方订单列表」业绩同口径(≠4):按 **o.create_time** 落入区间,按「诊单医助 dg.assistant_id」归部门(与处方列表 assistant_dept 一致);
* 渠道判定仅用 EXISTS:"患者有任一已完成挂号 (status=3) 且 channels 命中字典",**不限挂号时点**;无字典映射时回退 tag/creator 口径
* - 成交单数 = 区间内非取消业务订单 **条数**;**按诊单医助 dg.assistant_id** 映射到「中心」部门(与处方订单列表 <b>assistant_dept_id</b> / assistant_id 筛选同源,非订单 creator_id);无法归属计入「未归属中心」;合计=全表 COUNT。
* - 投放成本 = zyt_account_cost 在日期区间内 **amount** 合计(选定渠道时 `media_channel_code = channel_code` 过滤)。
* · **各部门行投放成本** = 总投放成本 × (该部门进线 ÷ 各展示部门进线合计)。相当于先算 **单进线成本** = 总成本 ÷ 总进线,再乘部门进线。
* · 选定渠道下二中心部门进线为 0,故该行分摊成本为 0;合计行成本 = 账务汇总总额(部门行分摊之和与之对齐,尾差归入进线最多的部门)。
* · ROI = 渠道业绩合计 ÷ 总投放成本(选定渠道时分子不含二中心渠道业绩)。
* - ROI = 选定渠道时为 **{渠道}业绩合计** ÷ 投放成本(与表格「渠道业绩」列合计一致;未选渠道时分子为全表业绩合计;成本为 0 时返回 null)
*
* 「业绩/接诊标签匹配」:
* 优先 tcm_diagnosis.external_userid = 客户 external_userid
* 若为空则 patient_id → zyt_user → zyt_user_auth.unionid = qywx_external_contact.unionid(再验 contact_tag),
* 与列表客户标签一致,避免因未回填 external_userid 导致业绩/接诊恒为 0。
*
* 「部门归属」算法:
* 配置 $primaryDeptIds(前端可指定;默认所有「含『中心』字样」的二级 / 三级部门),
* 每个 admin 经 admin_dept → dept 沿 pid 上溯,命中第一个 $primaryDeptIds 即归属之;都不命中归到 0=未分配。
*
* 「渠道」 = 「企微标签」:
* `zyt_qywx_media_channel.channel_code` 形如 tag_<tagid> 或 tagname_<hex>
* ① tag_ 前缀:剥前缀得到企业 tag_id,参与 zyt_qywx_external_contact_tag 过滤;
* ② tagname_ 前缀(个人标签):当前不参与进线/业绩/诊单过滤(contact_tag 表只跟踪企业标签),仅按成本过滤。
*/
class YejiStatsLogic
{
/**
* overview / assistantLeaderboards 共用的筛选与时间上下文。
*
* @param array{start_date?:string,end_date?:string,dept_ids?:int[]|string,channel_code?:string,tag_id?:string} $params
*
* @return array{
* startDate:string,
* endDate:string,
* startTs:int,
* endTs:int,
* channelCode:string,
* tagFilterId:string,
* channelInfo:array|null,
* primaryDeptIds:int[],
* deptNameMap:array<int,string>,
* deptToDescendants:array<int,int[]>,
* adminToPrimary:array<int,int>,
* tagDiagIds:int[]|null,
* tagAssistantIds:array<int,int>|null,
* tagFallback:bool,
* appointmentChannelValues:int[],
* channelFilterActive:bool
* }
*/
private static function resolveYejiContext(array $params): array
{
$startDate = self::normalizeDate($params['start_date'] ?? date('Y-m-d'));
$endDate = self::normalizeDate($params['end_date'] ?? $startDate);
$startTs = (int) strtotime($startDate . ' 00:00:00');
$endTs = (int) strtotime($endDate . ' 23:59:59');
$channelCode = trim((string) ($params['channel_code'] ?? ''));
if ($channelCode === '' && isset($params['tag_id']) && $params['tag_id'] !== '') {
$rawTagId = trim((string) $params['tag_id']);
$channelCode = str_starts_with($rawTagId, 'tag_') || str_starts_with($rawTagId, 'tagname_')
? $rawTagId
: ('tag_' . $rawTagId);
}
$tagFilterId = '';
$channelInfo = null;
if ($channelCode !== '') {
$channelInfo = Db::name('qywx_media_channel')
->where('channel_code', $channelCode)
->find();
if ($channelInfo && str_starts_with($channelCode, 'tag_')) {
$tagFilterId = (string) substr($channelCode, 4);
}
}
$explicitDeptFilter = self::hasExplicitDeptIdsParam($params['dept_ids'] ?? null);
[$primaryDeptIds, $deptNameMap, $tableRowDeptIds, $deptById] = self::resolvePrimaryDepts($params['dept_ids'] ?? null);
$deptToDescendants = self::buildDeptDescendants($primaryDeptIds);
$adminToPrimary = self::buildAdminToPrimaryDept($primaryDeptIds, $deptToDescendants);
$tagDiagIds = null;
$tagAssistantIds = null;
$tagFallback = false;
if ($tagFilterId !== '') {
[$tagDiagIds, $tagAssistantIds] = self::resolveTagScopedDiagnosisAndAssistants($tagFilterId);
if ($tagDiagIds === [] && $tagAssistantIds !== []) {
$tagDiagIds = null;
$tagFallback = true;
}
}
$appointmentChannelValues = [];
if ($channelInfo !== null) {
$appointmentChannelValues = MediaChannelService::getLegacyAppointmentChannelValues($channelInfo);
}
return [
'startDate' => $startDate,
'endDate' => $endDate,
'startTs' => $startTs,
'endTs' => $endTs,
'channelCode' => $channelCode,
'tagFilterId' => $tagFilterId,
'channelInfo' => $channelInfo,
'primaryDeptIds' => $primaryDeptIds,
'tableRowDeptIds' => $tableRowDeptIds,
'deptNameMap' => $deptNameMap,
'deptById' => $deptById,
'deptToDescendants' => $deptToDescendants,
'adminToPrimary' => $adminToPrimary,
'tagDiagIds' => $tagDiagIds,
'tagAssistantIds' => $tagAssistantIds,
'tagFallback' => $tagFallback,
'appointmentChannelValues' => $appointmentChannelValues,
'channelFilterActive' => ($channelCode !== ''),
'explicitDeptFilter' => $explicitDeptFilter,
'dataScopeRestricted' => false,
];
}
/**
* 按角色「数据范围」收窄业绩看板上下文(adminToPrimary、tableRowDeptIds)。
* 与 DataScopeService / 列表 HasDataScopeFilter 一致:ALL 不处理;SELF/本部门/本部门及下级 仅保留可见 admin_id。
*/
private static function applyYejiDataScope(array &$c, int $adminId, array $adminInfo): void
{
if ($adminId <= 0 || !DataScopeService::isEnabled()) {
return;
}
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
if ($visibleIds === null) {
return;
}
$c['dataScopeRestricted'] = true;
$visibleIds = array_values(array_unique(array_filter(array_map('intval', $visibleIds), static function (int $v): bool {
return $v > 0;
})));
$c['dataScopeVisibleAdminIds'] = $visibleIds;
if ($visibleIds === []) {
$c['adminToPrimary'] = [];
$c['tableRowDeptIds'] = [];
return;
}
$flip = array_flip($visibleIds);
$filtered = [];
foreach ($c['adminToPrimary'] as $aid => $pid) {
if (isset($flip[$aid])) {
$filtered[$aid] = $pid;
}
}
$c['adminToPrimary'] = $filtered;
$allowedPrimaries = array_values(array_unique(array_values($filtered)));
if ($allowedPrimaries === []) {
$c['tableRowDeptIds'] = [];
return;
}
$allowFlip = array_flip($allowedPrimaries);
$c['tableRowDeptIds'] = array_values(array_filter(
$c['tableRowDeptIds'],
static function (int $did) use ($allowFlip): bool {
return $did > 0 && isset($allowFlip[$did]);
}
));
}
/**
* 与业绩看板相同的日期、部门树、渠道、标签与数据范围上下文,供医生日统计等复用。
*
* @param array{start_date?:string,end_date?:string,dept_ids?:int[]|string,channel_code?:string,tag_id?:string} $params
*
* @return array{
* startDate:string,endDate:string,startTs:int,endTs:int,channelCode:string,
* adminToPrimary:array<int,int>,tableRowDeptIds:int[],
* appointmentChannelValues:int[],channelFilterActive:bool,
* tagDiagIds:int[]|null,tagAssistantIds:array<int,int>|null,tagFallback:bool
* }
*/
public static function resolveSharedYejiFilterContext(
array $params,
int $viewerAdminId = 0,
array $viewerAdminInfo = []
): array {
$c = self::resolveYejiContext($params);
if ($viewerAdminId > 0) {
self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo);
}
return [
'startDate' => $c['startDate'],
'endDate' => $c['endDate'],
'startTs' => $c['startTs'],
'endTs' => $c['endTs'],
'channelCode' => $c['channelCode'],
'adminToPrimary' => $c['adminToPrimary'],
'tableRowDeptIds' => $c['tableRowDeptIds'],
'appointmentChannelValues' => $c['appointmentChannelValues'],
'channelFilterActive' => $c['channelFilterActive'],
'tagDiagIds' => $c['tagDiagIds'],
'tagAssistantIds' => $c['tagAssistantIds'],
'tagFallback' => $c['tagFallback'],
];
}
/**
* @param array{
* start_date?: string,
* end_date?: string,
* dept_ids?: int[]|string,
* channel_code?: string,
* tag_id?: string,
* } $params
*
* @return array<string, mixed>
*/
public static function overview(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array
{
$c = self::resolveYejiContext($params);
if ($viewerAdminId > 0) {
self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo);
}
$startDate = $c['startDate'];
$endDate = $c['endDate'];
$startTs = $c['startTs'];
$endTs = $c['endTs'];
$channelCode = $c['channelCode'];
$tagFilterId = $c['tagFilterId'];
$channelInfo = $c['channelInfo'];
$primaryDeptIds = $c['primaryDeptIds'];
$tableRowDeptIds = $c['tableRowDeptIds'];
$deptToDescendants = $c['deptToDescendants'];
$deptNameMap = $c['deptNameMap'];
$deptById = $c['deptById'];
$adminToPrimary = $c['adminToPrimary'];
$tagDiagIds = $c['tagDiagIds'];
$tagAssistantIds = $c['tagAssistantIds'];
$tagFallback = $c['tagFallback'];
$appointmentChannelValues = $c['appointmentChannelValues'];
$channelFilterActive = $c['channelFilterActive'];
$explicitDeptFilter = $c['explicitDeptFilter'];
$dataScopeRestricted = !empty($c['dataScopeRestricted']);
$narrowTotalsToTable = $explicitDeptFilter || $dataScopeRestricted;
$dataScopeAdminIds = $dataScopeRestricted && isset($c['dataScopeVisibleAdminIds'])
? $c['dataScopeVisibleAdminIds']
: null;
$leads = self::countLeads($startTs, $endTs, $adminToPrimary, $tagFilterId);
// 接诊:
// - consults(全量) → "接诊诊单" 列;不限渠道
// - channelConsults(渠道) → 「{渠道}成交单」列;未选渠道时与 consults 相同
$consultsBoth = self::countConsults(
$startTs,
$endTs,
$adminToPrimary,
$tagDiagIds,
$tagAssistantIds,
$appointmentChannelValues,
$channelFilterActive,
$dataScopeAdminIds
);
$consults = $consultsBoth['all'];
$channelConsults = $consultsBoth['scoped'];
// 业绩:与 /tcm.prescriptionOrder/lists 同口径(仅剔除已取消=4,含 NULL)
// - performance(全量) → "合计业绩" 列;不受所选渠道影响
// - channelPerformance(渠道) → "{渠道}业绩" 列 + ROI 分子;未选渠道时与 performance 相同
$perfBoth = self::sumPerformance(
$startTs,
$endTs,
$adminToPrimary,
$tagDiagIds,
$tagFallback ? $tagAssistantIds : null,
$appointmentChannelValues,
$channelFilterActive
);
$performance = $perfBoth['all'];
$channelPerformance = $perfBoth['scoped'];
// 成交单数(全量):与合计业绩同 WHERE,按 creator_id 归属部门,合计用全表 COUNT
$dealOrderByDept = self::aggregateDealOrderCount($startTs, $endTs, $adminToPrimary, null, null);
$globalDealOrderCount = self::countGlobalDealOrders($startTs, $endTs);
/** 选定渠道时从全量渠道 SUM 与部门行中同步扣减「二中心」展示部门上的渠道业绩 */
$erCenterChannelStrip = 0.0;
if ($channelCode !== '') {
$erCenterChannelStrip = self::excludeErCenterFromChannelMetrics(
$primaryDeptIds,
$deptNameMap,
$channelPerformance,
$channelConsults
);
self::excludeErCenterFromLeads($primaryDeptIds, $deptNameMap, $leads);
}
$globalCost = round((float) self::sumGlobalCost($startDate, $endDate, $channelCode), 1);
$leadsForCost = self::mergeHiddenPrimaryLeadsForCostAllocation(
$primaryDeptIds,
$tableRowDeptIds,
$leads,
$deptToDescendants
);
$costs = self::allocateCostByLeads($globalCost, $tableRowDeptIds, $leadsForCost);
// ── 5) 组装每行 ──────────────────────────────────────────────
$rows = [];
$totalLead = 0;
$totalChannelConsult = 0;
$totalPerf = 0.0;
$totalChannelPerf = 0.0;
$totalCost = 0.0;
$unmappedConsult = (int) ($consults[0] ?? 0);
$consultSumInTable = 0;
foreach ($tableRowDeptIds as $deptId) {
$lead = (int) ($leads[$deptId] ?? 0);
$cnt = (int) ($consults[$deptId] ?? 0);
$consultSumInTable += $cnt;
$cCnt = (int) ($channelConsults[$deptId] ?? 0);
// 合计业绩:部门下所有订单业绩(≠4),不限渠道
$perf = round((float) ($performance[$deptId] ?? 0), 1);
// {渠道}业绩:部门 × 选定渠道下业绩(≠4)
$cperf = round((float) ($channelPerformance[$deptId] ?? 0), 1);
$cost = round((float) ($costs[$deptId] ?? 0), 1);
$rowCost = $cost;
$rowRoi = $rowCost > 0 ? round($cperf / $rowCost, 1) : null;
$dDeal = (int) ($dealOrderByDept[$deptId] ?? 0);
$rows[] = [
'dept_id' => $deptId,
'dept_name' => self::formatYejiDeptRowDisplayName($deptId, $deptById),
'lead_count' => $lead,
'consult_count' => $cnt,
'deal_order_count' => $dDeal,
// {渠道}成交单:选定渠道时与 completed_performance 同源;未选渠道时与 consult_count 相同
'channel_consult_count' => $cCnt,
'performance_amount' => $perf,
'completed_performance_amount' => $cperf,
'avg_price' => $cnt > 0 ? round($perf / $cnt, 1) : null,
// {渠道}客单价 = {渠道}业绩 / {渠道}成交单
'channel_avg_price' => $cCnt > 0 ? round($cperf / $cCnt, 1) : null,
'cost_amount' => round($rowCost, 1),
'roi' => $rowRoi,
];
$totalLead += $lead;
$totalChannelConsult += $cCnt;
$totalPerf += $perf;
$totalChannelPerf += $cperf;
// 不再用各行成本叠加(避免父级未在展示列时丢失),合计直接用 $globalCost
}
$allocatedPerf = round($totalPerf, 1);
$allocatedChannelPerf = round($totalChannelPerf, 1);
$globalPerfAll = self::sumGlobalPerformanceAmount($startTs, $endTs);
$globalPerfChannel = $globalPerfAll;
if ($channelFilterActive) {
$globalPerfChannel = self::sumGlobalChannelScopedPerformance(
$startTs,
$endTs,
$appointmentChannelValues,
$tagDiagIds,
$tagFallback ? $tagAssistantIds : null
);
$globalPerfChannel = round(max(0.0, $globalPerfChannel - $erCenterChannelStrip), 1);
}
$allocatedDealOrderSum = 0;
foreach ($tableRowDeptIds as $rid) {
$allocatedDealOrderSum += (int) ($dealOrderByDept[$rid] ?? 0);
}
if ($narrowTotalsToTable) {
$totalConsult = $consultSumInTable;
$unmappedDealOrder = 0;
$unassignedPerf = 0.0;
$unassignedChannelPerf = 0.0;
$showUnassignedRow = false;
} else {
$totalConsult = array_sum($consults);
$unmappedDealOrder = max(0, $globalDealOrderCount - $allocatedDealOrderSum);
$unassignedPerf = round($globalPerfAll - $allocatedPerf, 1);
$unassignedChannelPerf = round($globalPerfChannel - $allocatedChannelPerf, 1);
$showUnassignedRow = abs($unassignedPerf) >= 0.01 || abs($unassignedChannelPerf) >= 0.01 || $unmappedConsult > 0 || $unmappedDealOrder > 0;
}
if ($showUnassignedRow) {
$uPerf = $unassignedPerf;
$uCperf = $channelFilterActive ? $unassignedChannelPerf : $uPerf;
$rows[] = [
'dept_id' => 0,
'dept_name' => '未归属中心',
'lead_count' => 0,
'consult_count' => $unmappedConsult,
'deal_order_count' => $unmappedDealOrder,
'channel_consult_count' => 0,
'performance_amount' => $uPerf,
'completed_performance_amount' => $uCperf,
'avg_price' => null,
'channel_avg_price' => null,
'cost_amount' => 0.0,
'roi' => null,
];
}
if ($narrowTotalsToTable) {
$totalPerf = $allocatedPerf;
$totalChannelPerf = $allocatedChannelPerf;
$totalDealOrderCount = $allocatedDealOrderSum;
} else {
$totalPerf = $globalPerfAll;
$totalChannelPerf = $globalPerfChannel;
$totalDealOrderCount = $globalDealOrderCount;
}
$totalCost = round($globalCost, 1);
// ROI 合计 = 渠道业绩合计(与「completed」列一致,**不含二中心**)÷ 投放成本合计
$totalRoiNumerator = round($totalChannelPerf, 1);
$scopedTableNote = ($explicitDeptFilter
? ' 已筛选展示部门时,表格合计仅含所选部门树内数据,不出现「全站未归属」补差行(避免把其他中心业绩/单量算入未归属)。若勾选的是带下级的部门(如某「中心」),表格只列出其下级部门行,不单独占一行父级。'
: '')
. ($dataScopeRestricted
? ' 当前账号受角色「数据范围」限制,仅统计可见成员及其归属展示中心;合计与表格一致,不含权限范围外数据。'
: '');
$baseNoteNoChannel = '业绩口径:除履约已取消(4)外全部计入(与订单列表 stats_order_amount_performance 一致)。无法归入「中心」展示部门的创建人业绩计入「未归属中心」行;合计=全表 SUM。**投放成本**按进线占比分摊至各部门。';
return [
'start_date' => $startDate,
'end_date' => $endDate,
'dept_ids' => array_values($tableRowDeptIds),
'channel_code' => $channelCode,
'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)的订单条数(非挂号条数)。'
. ' 「接诊诊单」与已完成挂号列表同条件(appointment_date、status=3、诊单未软删);部门列按医助→接诊医生归属,无法归属计入「未归属中心」。名称含「二中心」的部门**不计入**该渠道进线、业绩与成交单。**投放成本**按各部门进线条数占展示部门进线合计的比例,从账务总投放金额中分摊(总成本÷总进线=单进线成本)。ROI = 渠道业绩(不含二中心)÷ 总投放成本。进线按所选标签客户精确过滤。'
. ($tagFallback
? ' ⚠ 当前业务尚未回填 tcm_diagnosis.external_userid 与 user_auth.unionid,已自动退化为「该标签客户的跟进医助 → 区间内接诊/业绩」口径,仅用于趋势观察。'
: '')
. $scopedTableNote)
: ($baseNoteNoChannel . $scopedTableNote),
'rows' => $rows,
'total' => [
'lead_count' => $totalLead,
'consult_count' => $totalConsult,
'deal_order_count' => $totalDealOrderCount,
'channel_consult_count' => $totalChannelConsult,
'performance_amount' => $totalPerf,
'completed_performance_amount' => $totalChannelPerf,
'avg_price' => $totalConsult > 0 ? round($totalPerf / $totalConsult, 1) : null,
'channel_avg_price' => $totalChannelConsult > 0 ? round($totalChannelPerf / $totalChannelConsult, 1) : null,
'cost_amount' => $totalCost,
'cost_per_lead' => $totalLead > 0 && $totalCost > 0 ? round($totalCost / $totalLead, 1) : null,
'roi' => $totalCost > 0 ? round($totalRoiNumerator / $totalCost, 1) : null,
],
];
}
/**
* 多组时间区间一次性返回(前端"月/周/日"四张表共用一次请求)。
*
* @param array<int, array{label?:string, start_date?:string, end_date?:string}> $ranges
* @return array<int, array<string, mixed>>
*/
public static function overviewBatch(array $ranges, array $base, int $viewerAdminId = 0, array $viewerAdminInfo = []): array
{
$out = [];
foreach ($ranges as $r) {
$params = array_merge($base, [
'start_date' => (string) ($r['start_date'] ?? date('Y-m-d')),
'end_date' => (string) ($r['end_date'] ?? date('Y-m-d')),
]);
$stat = self::overview($params, $viewerAdminId, $viewerAdminInfo);
$stat['label'] = (string) ($r['label'] ?? ($stat['start_date'] === $stat['end_date'] ? $stat['start_date'] : ($stat['start_date'] . '~' . $stat['end_date'])));
$out[] = $stat;
}
return $out;
}
/**
* 医助排行榜:按展示部门分表,每表内按诊金降序排名。
* 诊金口径:无渠道=诊单 dg.assistant_id 业绩(与列表 assistant_id 筛选一致);有渠道={渠道}业绩/dict 诊单医助;二中心在选渠道下注 0。
* 接诊率 = 排行用诊金 ÷ 进线条数(元/进线,1 位小数;进线为 0 时为 0)。
*
* @param array{start_date?:string,end_date?:string,dept_ids?:int[]|string,channel_code?:string,tag_id?:string} $params
*
* @return array<string, mixed>
*/
public static function assistantLeaderboards(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array
{
$c = self::resolveYejiContext($params);
if ($viewerAdminId > 0) {
self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo);
}
$startDate = $c['startDate'];
$endDate = $c['endDate'];
$startTs = $c['startTs'];
$endTs = $c['endTs'];
$channelCode = $c['channelCode'];
$channelInfo = $c['channelInfo'];
$tagFilterId = $c['tagFilterId'];
$primaryDeptIds = $c['primaryDeptIds'];
$tableRowDeptIds = $c['tableRowDeptIds'];
$deptNameMap = $c['deptNameMap'];
$deptById = $c['deptById'];
$adminToPrimary = $c['adminToPrimary'];
$tagDiagIds = $c['tagDiagIds'];
$tagAssistantIds = $c['tagAssistantIds'];
$tagFallback = $c['tagFallback'];
$appointmentChannelValues = $c['appointmentChannelValues'];
$channelFilterActive = $c['channelFilterActive'];
$channelName = $channelInfo ? (string) $channelInfo['channel_name'] : '';
if ($adminToPrimary === []) {
return [
'start_date' => $startDate,
'end_date' => $endDate,
'channel_code' => $channelCode,
'channel_name' => $channelName,
'channel_filter_note' => '',
'range_note' => '',
'leaderboards' => [],
];
}
$scopedAssistants = $tagFallback ? $tagAssistantIds : null;
$perfBoth = self::sumPerformanceByAdmin(
$startTs,
$endTs,
$tagDiagIds,
$scopedAssistants,
$appointmentChannelValues,
$channelFilterActive
);
$feeByAdmin = $channelFilterActive ? $perfBoth['scoped'] : $perfBoth['all'];
$dealBoth = self::sumDealOrderByAdmin(
$startTs,
$endTs,
$tagDiagIds,
$scopedAssistants,
$appointmentChannelValues,
$channelFilterActive
);
$dealOrderByAdmin = $channelFilterActive ? $dealBoth['scoped'] : $dealBoth['all'];
$leadsByAdmin = self::countLeadsByAdmin($startTs, $endTs, $tagFilterId);
/** 与部门表接诊同口径;标签/医助穿透时与业绩同源收窄,避免榜上证务与接诊脱节 */
$consultsByAdmin = self::aggregateConsultsByAdmin($startTs, $endTs, $tagDiagIds, $scopedAssistants);
if ($channelFilterActive) {
self::excludeErCenterFromAdminLeaderboard($adminToPrimary, $deptNameMap, $feeByAdmin, $leadsByAdmin, $dealOrderByAdmin);
}
$assistantIdRows = Db::name('admin_role')->where('role_id', 2)->column('admin_id');
$assistantSet = [];
foreach ($assistantIdRows as $rid) {
$assistantSet[(int) $rid] = true;
}
$nameIds = [];
foreach (array_keys($assistantSet) as $aid) {
if (isset($adminToPrimary[$aid])) {
$nameIds[] = $aid;
}
}
$adminNames = [];
if ($nameIds !== []) {
$nameRows = Db::name('admin')
->whereIn('id', $nameIds)
->whereNull('delete_time')
->column('name', 'id');
foreach ($nameRows as $id => $name) {
$adminNames[(int) $id] = (string) $name;
}
}
$rangeNote = $channelFilterActive
? ('排行诊金 = 选定渠道「' . $channelName . '」业绩口径(与部门表该渠道业绩列一致)。接诊率 = 诊金 ÷ 进线(元/进线)。'
. '「二中心」医助在该渠道下诊金与进线计 0。')
: '排行诊金 = 处方订单列表 assistant_id 口径(诊单医助业绩,与列表按医助筛选一致)。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。';
$leaderboards = [];
foreach ($tableRowDeptIds as $deptId) {
$leafName = $deptNameMap[$deptId] ?? ('部门#' . $deptId);
$deptName = self::formatYejiDeptRowDisplayName($deptId, $deptById);
$deptAbbr = self::deptDisplayAbbr($leafName);
$rows = [];
foreach (array_keys($assistantSet) as $aid) {
if (($adminToPrimary[$aid] ?? 0) !== $deptId) {
continue;
}
$fee = round((float) ($feeByAdmin[$aid] ?? 0.0), 1);
$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) {
continue;
}
$rawFee = (float) ($feeByAdmin[$aid] ?? 0.0);
$rate = $lead > 0 ? round($rawFee / $lead, 1) : 0.0;
$rows[] = [
'admin_id' => $aid,
'name' => $adminNames[$aid] ?? ('#' . $aid),
'fee_amount' => $fee,
'lead_count' => $lead,
'consult_count' => $consult,
'deal_order_count' => $dealOrders,
'consult_rate' => $rate,
'dept_abbr' => $deptAbbr,
];
}
usort($rows, static function (array $a, array $b): int {
if ($a['fee_amount'] != $b['fee_amount']) {
return $b['fee_amount'] <=> $a['fee_amount'];
}
return $b['consult_rate'] <=> $a['consult_rate'];
});
foreach ($rows as $i => $row) {
$rows[$i]['rank'] = $i + 1;
}
$rows = array_slice($rows, 0, 50);
$leaderboards[] = [
'dept_id' => $deptId,
'dept_name' => $deptName,
'dept_abbr' => $deptAbbr,
'rows' => $rows,
];
}
return [
'start_date' => $startDate,
'end_date' => $endDate,
'channel_code' => $channelCode,
'channel_name' => $channelName,
'channel_filter_note' => $channelCode !== ''
? '医助榜:名称含「二中心」的部门在选定渠道下诊金、进线计 0,与部门看板一致。'
: '',
'range_note' => $rangeNote,
'leaderboards' => $leaderboards,
];
}
/**
* 数据范围下:可见成员所在部门及其下级部门 id(用于部门下拉收窄)。
*
* @return array<int, true>|null null 表示不限制,[] 表示无可选
*/
private static function buildYejiDataScopeAllowedDeptIdMap(int $adminId, array $adminInfo): ?array
{
if ($adminId <= 0 || !DataScopeService::isEnabled()) {
return null;
}
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
if ($visibleIds === null) {
return null;
}
if ($visibleIds === []) {
return [];
}
$set = [];
foreach ($visibleIds as $aid) {
$deptRows = Db::name('admin_dept')->where('admin_id', (int) $aid)->column('dept_id');
foreach ($deptRows as $d) {
$d = (int) $d;
if ($d <= 0) {
continue;
}
foreach (DeptLogic::getSelfAndDescendantIds($d) as $x) {
$x = (int) $x;
if ($x > 0) {
$set[$x] = true;
}
}
}
}
return $set;
}
/**
* 列出所有可选部门
*
* @return array<int, array{id:int, name:string, pid:int, path:string}>
*/
public static function deptOptions(int $viewerAdminId = 0, array $viewerAdminInfo = []): array
{
$rows = Db::name('dept')->whereNull('delete_time')->order('pid asc, id asc')->select()->toArray();
if ($rows === []) {
return [];
}
$byId = [];
foreach ($rows as $r) {
$byId[(int) $r['id']] = $r;
}
$buildPath = function (int $id) use ($byId): string {
$names = [];
$cur = $id;
for ($i = 0; $i < 50; $i++) {
if (!isset($byId[$cur])) {
break;
}
array_unshift($names, (string) $byId[$cur]['name']);
$pid = (int) $byId[$cur]['pid'];
if ($pid <= 0) {
break;
}
$cur = $pid;
}
return implode(' / ', array_filter($names, static fn($s): bool => $s !== ''));
};
$out = [];
foreach ($rows as $r) {
$id = (int) $r['id'];
$out[] = [
'id' => $id,
'name' => (string) $r['name'],
'pid' => (int) $r['pid'],
'path' => $buildPath($id),
];
}
$allowed = self::buildYejiDataScopeAllowedDeptIdMap($viewerAdminId, $viewerAdminInfo);
if ($allowed !== null) {
if ($allowed === []) {
return [];
}
$out = array_values(array_filter(
$out,
static function (array $item) use ($allowed): bool {
return isset($allowed[(int) ($item['id'] ?? 0)]);
}
));
}
return $out;
}
/**
* 渠道下拉(按 source_group_name 分组),同时附带客户/成本统计便于排序展示
*
* @return array{
* groups: array<int, array{
* group_name: string,
* channels: array<int, array{
* channel_code: string, channel_name: string,
* source_tag_id: string, source_group_name: string,
* customer_count: int
* }>
* }>,
* total: int
* }
*/
public static function channelOptions(): array
{
$rows = Db::name('qywx_media_channel')
->where('status', 1)
->order('source_group_name asc, id asc')
->select()
->toArray();
if ($rows === []) {
return ['groups' => [], 'total' => 0];
}
// 顺路给 tag_* 渠道带上「打了该标签的客户数」(直接用 contact_tag 表)
$tagIds = [];
foreach ($rows as $r) {
$code = (string) $r['channel_code'];
if (str_starts_with($code, 'tag_')) {
$tagIds[] = substr($code, 4);
}
}
$tagToCount = [];
if ($tagIds !== []) {
$cntRows = Db::name('qywx_external_contact_tag')
->whereIn('tag_id', $tagIds)
->fieldRaw('tag_id, COUNT(DISTINCT external_userid) AS c')
->group('tag_id')
->select()
->toArray();
foreach ($cntRows as $cr) {
$tagToCount[(string) $cr['tag_id']] = (int) $cr['c'];
}
}
$groups = [];
foreach ($rows as $r) {
$g = (string) ($r['source_group_name'] ?: '其它');
$groups[$g] ??= ['group_name' => $g, 'channels' => []];
$code = (string) $r['channel_code'];
$custCount = 0;
if (str_starts_with($code, 'tag_')) {
$tid = substr($code, 4);
$custCount = (int) ($tagToCount[$tid] ?? 0);
}
$groups[$g]['channels'][] = [
'channel_code' => $code,
'channel_name' => (string) $r['channel_name'],
'source_tag_id' => (string) $r['source_tag_id'],
'source_group_name' => $g,
'customer_count' => $custCount,
];
}
// 「自媒体」类组排前
$sortedGroups = array_values($groups);
usort($sortedGroups, static function (array $a, array $b): int {
$aMedia = mb_strpos($a['group_name'], '自媒体') !== false ? 0 : 1;
$bMedia = mb_strpos($b['group_name'], '自媒体') !== false ? 0 : 1;
if ($aMedia !== $bMedia) {
return $aMedia <=> $bMedia;
}
return strcmp($a['group_name'], $b['group_name']);
});
return ['groups' => $sortedGroups, 'total' => count($rows)];
}
// ─────────────────────────────────────────────────────────────────
// 内部
// ─────────────────────────────────────────────────────────────────
private static function normalizeDate(string $s): string
{
$s = trim($s);
$ts = strtotime($s);
return $ts === false ? date('Y-m-d') : date('Y-m-d', $ts);
}
/**
* 请求是否显式带了部门筛选(非空 dept_ids),用于与「默认全部中心」区分。
*/
private static function hasExplicitDeptIdsParam($raw): bool
{
if (\is_string($raw) && trim($raw) !== '') {
foreach (explode(',', $raw) as $p) {
if ((int) trim($p) > 0) {
return true;
}
}
}
if (\is_array($raw)) {
foreach ($raw as $v) {
if ((int) $v > 0) {
return true;
}
}
}
return false;
}
/**
* 用户传入 dept_ids 时:展开为「所选部门 + 全子孙」作为 primary(用于归属与聚合);表格行序同序。
* 若用户勾选的是含下级部门的父节点(如「一中心」),则表格**仅展示其下级部门行**,不重复展示该中心父级行;其它中心不会出现在展开集合中。
* 默认(未传):primary 与表格行均为所有 name 含「中心」且 pid>0 的节点(父子同存时去掉父)。
*
* @return array{0: int[], 1: array<int,string>, 2: int[], 3: array<int, array<string, mixed>>}
*/
private static function resolvePrimaryDepts($input): array
{
$rows = Db::name('dept')->whereNull('delete_time')->field(['id', 'name', 'pid'])->order('pid asc, id asc')->select()->toArray();
$byId = [];
foreach ($rows as $r) {
$byId[(int) $r['id']] = $r;
}
$childrenOf = [];
foreach ($rows as $r) {
$pid = (int) $r['pid'];
$childrenOf[$pid][] = (int) $r['id'];
}
$userRoots = [];
if (\is_string($input) && $input !== '') {
$input = explode(',', $input);
}
if (\is_array($input)) {
foreach ($input as $v) {
$iv = (int) $v;
if ($iv > 0) {
$userRoots[] = $iv;
}
}
$userRoots = array_values(array_unique($userRoots));
}
if ($userRoots !== []) {
$deptIds = self::expandDeptRootIdsWithDescendants($userRoots, $childrenOf);
$deptIds = self::sortYejiDeptIdsAncestorsBeforeDescendants($deptIds, $byId);
$inExpanded = array_flip($deptIds);
$tableRowDeptIds = [];
foreach ($deptIds as $id) {
$isUserRoot = \in_array($id, $userRoots, true);
$hasChildInExpanded = false;
foreach (($childrenOf[$id] ?? []) as $cid) {
if (isset($inExpanded[$cid])) {
$hasChildInExpanded = true;
break;
}
}
if ($isUserRoot && $hasChildInExpanded) {
continue;
}
$tableRowDeptIds[] = $id;
}
} else {
$candidates = [];
foreach ($rows as $r) {
if (mb_strpos((string) $r['name'], '中心') !== false && (int) $r['pid'] > 0) {
$candidates[(int) $r['id']] = true;
}
}
// 父子都在 → 去掉父
foreach (array_keys($candidates) as $id) {
$cur = (int) $byId[$id]['pid'];
while ($cur > 0) {
if (isset($candidates[$cur])) {
unset($candidates[$cur]);
}
$cur = (int) ($byId[$cur]['pid'] ?? 0);
}
}
$deptIds = array_keys($candidates);
sort($deptIds);
$tableRowDeptIds = $deptIds;
}
$names = [];
foreach ($deptIds as $id) {
$names[$id] = (string) ($byId[$id]['name'] ?? ('部门#' . $id));
}
return [$deptIds, $names, $tableRowDeptIds, $byId];
}
/**
* 表格展示用部门名:自根到本级的全路径「父 / … / 子」;顶级「甄养堂互联网医院」不展示(与业务筛选里常见根节点重复冗长)。
*
* @param array<int, array<string, mixed>> $byId
*/
private static function formatYejiDeptRowDisplayName(int $deptId, array $byId): string
{
if ($deptId <= 0 || !isset($byId[$deptId])) {
return '部门#' . $deptId;
}
$leafLabel = (string) $byId[$deptId]['name'];
$names = [];
$cur = $deptId;
for ($i = 0; $i < 50; $i++) {
if (!isset($byId[$cur])) {
break;
}
array_unshift($names, (string) $byId[$cur]['name']);
$pid = (int) $byId[$cur]['pid'];
if ($pid <= 0) {
break;
}
$cur = $pid;
}
$names = array_values(array_filter($names, static fn (string $s): bool => $s !== ''));
if ($names !== [] && $names[0] === '甄养堂互联网医院') {
array_shift($names);
}
if ($names === []) {
return $leafLabel !== '' ? $leafLabel : ('部门#' . $deptId);
}
return implode(' / ', $names);
}
/**
* @param array<int, int[]> $childrenOf 父 id => 子部门 id 列表(来自未删除部门表)
*
* @return int[]
*/
private static function expandDeptRootIdsWithDescendants(array $rootIds, array $childrenOf): array
{
$set = [];
$stack = array_values(array_filter(array_map('intval', $rootIds), static fn (int $id): bool => $id > 0));
while ($stack !== []) {
$id = (int) array_pop($stack);
if ($id <= 0 || isset($set[$id])) {
continue;
}
$set[$id] = true;
foreach (($childrenOf[$id] ?? []) as $cid) {
$cid = (int) $cid;
if ($cid > 0 && !isset($set[$cid])) {
$stack[] = $cid;
}
}
}
return array_keys($set);
}
/**
* 同一展开集合内:深度小的 id 在前,保证其后 buildDeptDescendants 写入 deptToPrimary 时子部门覆盖父部门。
*
* @param array<int, array<string, mixed>> $byId
*
* @return int[]
*/
private static function sortYejiDeptIdsAncestorsBeforeDescendants(array $deptIds, array $byId): array
{
$deptIds = array_values(array_unique(array_map('intval', $deptIds)));
if ($deptIds === []) {
return [];
}
$idSet = array_flip($deptIds);
$memo = [];
$depthFn = static function (int $id) use (&$depthFn, &$memo, $byId, $idSet): int {
if (isset($memo[$id])) {
return $memo[$id];
}
if (!isset($idSet[$id])) {
return $memo[$id] = -1;
}
$pid = (int) ($byId[$id]['pid'] ?? 0);
if ($pid <= 0 || !isset($idSet[$pid])) {
return $memo[$id] = 0;
}
return $memo[$id] = $depthFn($pid) + 1;
};
usort($deptIds, static function (int $a, int $b) use ($depthFn): int {
$da = $depthFn($a);
$db = $depthFn($b);
if ($da !== $db) {
return $da <=> $db;
}
return $a <=> $b;
});
return $deptIds;
}
/**
* 每个 primary dept_id 的所有子孙(含自身)id 集合
*
* @param int[] $primaryDeptIds
* @return array<int, int[]>
*/
private static function buildDeptDescendants(array $primaryDeptIds): array
{
$rows = Db::name('dept')->whereNull('delete_time')->field(['id', 'pid'])->select()->toArray();
$childrenOf = [];
foreach ($rows as $r) {
$pid = (int) $r['pid'];
$childrenOf[$pid][] = (int) $r['id'];
}
$result = [];
foreach ($primaryDeptIds as $rootId) {
$stack = [$rootId];
$set = [];
while ($stack !== []) {
$cur = (int) array_pop($stack);
if (isset($set[$cur])) {
continue;
}
$set[$cur] = true;
foreach (($childrenOf[$cur] ?? []) as $child) {
if (!isset($set[$child])) {
$stack[] = $child;
}
}
}
$result[$rootId] = array_keys($set);
}
return $result;
}
/**
* admin_id → primary dept_id 归属
*
* @param int[] $primaryDeptIds
* @param array<int, int[]> $deptToDescendants
* @return array<int, int>
*/
private static function buildAdminToPrimaryDept(array $primaryDeptIds, array $deptToDescendants): array
{
if ($primaryDeptIds === []) {
return [];
}
$deptToPrimary = [];
foreach ($deptToDescendants as $primary => $descs) {
foreach ($descs as $d) {
$deptToPrimary[$d] = $primary;
}
}
if ($deptToPrimary === []) {
return [];
}
$links = Db::name('admin_dept')
->whereIn('dept_id', array_keys($deptToPrimary))
->field(['admin_id', 'dept_id'])
->select()
->toArray();
$adminToPrimary = [];
foreach ($links as $l) {
$adminId = (int) $l['admin_id'];
$deptId = (int) $l['dept_id'];
if (!isset($deptToPrimary[$deptId])) {
continue;
}
if (!isset($adminToPrimary[$adminId])) {
$adminToPrimary[$adminId] = $deptToPrimary[$deptId];
}
}
return $adminToPrimary;
}
/**
* 进线数据:add_external_contact 事件按 user_id(work_wechat_userid) → admin → primary dept
*
* @param array<int, int> $adminToPrimary
* @return array<int, int>
*/
private static function countLeads(int $startTs, int $endTs, array $adminToPrimary, string $tagFilterId): array
{
if ($adminToPrimary === []) {
return [];
}
$query = Db::name('qywx_external_contact_event')
->alias('e')
->where('e.change_type', 'add_external_contact')
->where('e.event_time', 'between', [$startTs, $endTs])
->where('e.user_id', '<>', '')
->field(['e.user_id', 'COUNT(*) AS cnt'])
->group('e.user_id');
if ($tagFilterId !== '') {
// 客户身上有该标签(带前缀以避免 raw SQL 不被框架 prefix 替换)
$tagTable = self::tableWithPrefix('qywx_external_contact_tag');
$query->whereRaw(
"EXISTS (SELECT 1 FROM {$tagTable} t WHERE t.external_userid = e.external_userid AND t.tag_id = ?)",
[$tagFilterId]
);
}
$rows = $query->select()->toArray();
if ($rows === []) {
return [];
}
$userIds = array_column($rows, 'user_id');
$adminIdByWx = Db::name('admin')
->whereIn('work_wechat_userid', $userIds)
->whereNull('delete_time')
->column('id', 'work_wechat_userid');
$byDept = [];
foreach ($rows as $r) {
$wx = (string) $r['user_id'];
$adminId = (int) ($adminIdByWx[$wx] ?? 0);
if ($adminId <= 0) {
continue;
}
$primary = (int) ($adminToPrimary[$adminId] ?? 0);
if ($primary <= 0) {
continue;
}
$byDept[$primary] = ($byDept[$primary] ?? 0) + (int) $r['cnt'];
}
return $byDept;
}
/**
* 接诊诊单聚合:返回两组按部门归属的接诊计数。
* - all: 区间内 status=3 挂号条数按部门归属(与列表计数一致),不含「仅 role_id=2」等额外限制。
* - scoped: 选定渠道时「{渠道}成交单」= **非取消业务订单条数**(与 {渠道}业绩同源):有 dict(channels) 走诊单医助+EXISTS 口径;否则走 tagdiagnosis_id IN / creator_id IN)与 aggregatePerformance 完全一致。
* 未选渠道时 scoped == all(不再额外查一次)。
*
* @param int[] $appointmentChannelValues dict_data value 整型列表(通常 1=自媒体1)
* @param int $startTs 区间起点日 00:00:00 对应日期(用于取 start_date
* @param int $endTs 区间终点日 23:59:59 对应日期(用于取 end_date
*
* @return array{
* all: array<int, int>,
* scoped: array<int, int>
* }
*/
private static function countConsults(
int $startTs,
int $endTs,
array $adminToPrimary,
?array $tagDiagIds,
?array $tagAssistantIds,
array $appointmentChannelValues = [],
bool $channelFilterActive = false,
?array $dataScopeAdminIds = null
): array {
if ($adminToPrimary === []) {
return ['all' => [], 'scoped' => []];
}
$allByDept = self::aggregateConsults($startTs, $endTs, $adminToPrimary, null, null, $dataScopeAdminIds);
if (!$channelFilterActive) {
return ['all' => $allByDept, 'scoped' => $allByDept];
}
if ($appointmentChannelValues !== []) {
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
if ($norm === []) {
$scopedByDept = [];
} else {
// 字典映射渠道:「{渠道}接诊」= 成交业务单数(非取消订单),与 aggregatePerformanceByAppointmentChannels 同源,非挂号条数
$scopedByDept = self::aggregateDealOrderCountByAppointmentChannels(
$startTs,
$endTs,
$adminToPrimary,
$norm
);
}
} elseif ($tagDiagIds !== null || $tagAssistantIds !== null) {
$scopedByDept = self::aggregateDealOrderCountByTagScope(
$startTs,
$endTs,
$adminToPrimary,
$tagDiagIds,
$tagAssistantIds
);
} else {
$scopedByDept = [];
}
return ['all' => $allByDept, 'scoped' => $scopedByDept];
}
/**
* 单次接诊聚合查询:status=3 + 可选 tag;与 admin 挂号列表计数条件一致(不限医助角色、不要求 assistant_id)。
* 按 (有效医助 id, doctor_id) 分组后,再映射到展示「中心」:先医助、再接诊医生;无法归属则 dept_key=0。
*
* @return array<int, int>
*/
private static function aggregateConsults(
int $startTs,
int $endTs,
array $adminToPrimary,
?array $tagDiagIds,
?array $tagAssistantIds,
?array $dataScopeAdminIds = null
): array {
if ($tagDiagIds !== null && $tagDiagIds === []) {
return [];
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return [];
}
$eff = self::consultEffectiveAssistantSql();
$query = Db::name('doctor_appointment')->alias('a');
self::applyConsultFiltersAlignedWithAppointmentLists($query, $startTs, $endTs);
if ($tagDiagIds !== null) {
$query->whereIn('a.patient_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$ids = array_map('intval', array_keys($tagAssistantIds));
$query->whereRaw("({$eff}) IN (" . implode(',', $ids) . ')');
}
$query
->field([
Db::raw("({$eff}) AS eff_a"),
'a.doctor_id',
Db::raw('COUNT(*) AS cnt'),
])
->group([$eff, 'a.doctor_id']);
$rows = $query->select()->toArray();
if ($rows === []) {
return [];
}
$visFlip = $dataScopeAdminIds !== null && $dataScopeAdminIds !== []
? array_flip($dataScopeAdminIds)
: null;
$byDept = [];
foreach ($rows as $r) {
$effId = (int) ($r['eff_a'] ?? 0);
$docId = (int) ($r['doctor_id'] ?? 0);
$cnt = (int) ($r['cnt'] ?? 0);
if ($cnt <= 0) {
continue;
}
// 数据范围:仅计有效医助/接诊医生在可见名单内的挂号,避免 invisible 医助挂到 visible 医生上造成跨范围泄露
if ($visFlip !== null) {
if ($effId > 0) {
if (!isset($visFlip[$effId])) {
continue;
}
} elseif ($docId > 0) {
if (!isset($visFlip[$docId])) {
continue;
}
} else {
continue;
}
}
$primary = 0;
if ($effId > 0) {
$primary = (int) ($adminToPrimary[$effId] ?? 0);
}
if ($primary <= 0 && $docId > 0) {
$primary = (int) ($adminToPrimary[$docId] ?? 0);
}
if ($primary > 0) {
$byDept[$primary] = ($byDept[$primary] ?? 0) + $cnt;
} else {
$byDept[0] = ($byDept[0] ?? 0) + $cnt;
}
}
return $byDept;
}
/**
* 接诊按医助汇总(不按部门归并)。
*
* @return array<int, int>
*/
private static function aggregateConsultsByAdmin(
int $startTs,
int $endTs,
?array $tagDiagIds,
?array $tagAssistantIds
): array {
if ($tagDiagIds !== null && $tagDiagIds === []) {
return [];
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return [];
}
$eff = self::consultEffectiveAssistantSql();
$query = Db::name('doctor_appointment')->alias('a');
self::applyConsultFiltersAlignedWithAppointmentLists($query, $startTs, $endTs);
if ($tagDiagIds !== null) {
$query->whereIn('a.patient_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$ids = array_map('intval', array_keys($tagAssistantIds));
$query->whereRaw("({$eff}) IN (" . implode(',', $ids) . ')');
}
$query->field([Db::raw("({$eff}) AS eff_a"), Db::raw('COUNT(*) AS cnt')])
->group($eff);
$rows = $query->select()->toArray();
if ($rows === []) {
return [];
}
$byAdmin = [];
foreach ($rows as $r) {
$aid = (int) ($r['eff_a'] ?? 0);
if ($aid <= 0) {
continue;
}
$byAdmin[$aid] = ($byAdmin[$aid] ?? 0) + (int) $r['cnt'];
}
return $byAdmin;
}
/**
* 与挂号列表一致的有效医助:优先挂号表创建人 assistant_id,为 0 时用诊单医助(列表 join 展示字段)。
*/
private static function consultEffectiveAssistantSql(): string
{
return 'COALESCE(NULLIF(a.assistant_id, 0), NULLIF(u.assistant_id, 0))';
}
/**
* doctor_appointment 别名 a:与 AppointmentLists 已完成挂号 **计数** 对齐(诊单软删、appointment_date、status=3)。
* 不按医助角色或 assistant_id 再收窄(与列表 tab 总数一致)。
*
* @param \think\db\Query $query
*/
private static function applyConsultFiltersAlignedWithAppointmentLists($query, int $startTs, int $endTs): void
{
$startDate = date('Y-m-d', $startTs);
$endDate = date('Y-m-d', $endTs);
$query->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
->where('a.status', 3)
->whereBetween('a.appointment_date', [$startDate, $endDate])
->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)');
}
/**
* 进线按接待人(admin)汇总;不按展示部门截断,后续按 adminToPrimary 归属。
*
* @return array<int, int>
*/
private static function countLeadsByAdmin(int $startTs, int $endTs, string $tagFilterId): array
{
$query = Db::name('qywx_external_contact_event')
->alias('e')
->where('e.change_type', 'add_external_contact')
->where('e.event_time', 'between', [$startTs, $endTs])
->where('e.user_id', '<>', '')
->field(['e.user_id', 'COUNT(*) AS cnt'])
->group('e.user_id');
if ($tagFilterId !== '') {
$tagTable = self::tableWithPrefix('qywx_external_contact_tag');
$query->whereRaw(
"EXISTS (SELECT 1 FROM {$tagTable} t WHERE t.external_userid = e.external_userid AND t.tag_id = ?)",
[$tagFilterId]
);
}
$rows = $query->select()->toArray();
if ($rows === []) {
return [];
}
$userIds = array_column($rows, 'user_id');
$adminIdByWx = Db::name('admin')
->whereIn('work_wechat_userid', $userIds)
->whereNull('delete_time')
->column('id', 'work_wechat_userid');
$byAdmin = [];
foreach ($rows as $r) {
$wx = (string) $r['user_id'];
$adminId = (int) ($adminIdByWx[$wx] ?? 0);
if ($adminId <= 0) {
continue;
}
$byAdmin[$adminId] = ($byAdmin[$adminId] ?? 0) + (int) $r['cnt'];
}
return $byAdmin;
}
/**
* 与 sumPerformance 对应,按 admin 维度返回全量/渠道业绩金额(不按部门折叠)。
*
* @param int[] $appointmentChannelValues
*
* @return array{all: array<int, float>, scoped: array<int, float>}
*/
private static function sumPerformanceByAdmin(
int $startTs,
int $endTs,
?array $tagDiagIds,
?array $tagAssistantIdsForScoped,
array $appointmentChannelValues,
bool $channelFilterActive
): array {
$all = self::aggregatePerformanceAmountsByAdmin($startTs, $endTs, null, null);
if (!$channelFilterActive) {
return ['all' => $all, 'scoped' => $all];
}
if ($appointmentChannelValues !== []) {
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
if ($norm === []) {
$scoped = [];
} else {
$scoped = self::aggregatePerformanceAmountsByAssistantChannel($startTs, $endTs, $norm);
}
} elseif ($tagDiagIds !== null || $tagAssistantIdsForScoped !== null) {
$scoped = self::aggregatePerformanceAmountsByAdmin($startTs, $endTs, $tagDiagIds, $tagAssistantIdsForScoped);
} else {
$scoped = [];
}
return ['all' => $all, 'scoped' => $scoped];
}
/**
* 与 sumPerformanceByAdmin 对应:按诊单医助统计成交单数(非取消业务订单条数),全量/渠道分支一致。
*
* @param int[] $appointmentChannelValues
*
* @return array{all: array<int, int>, scoped: array<int, int>}
*/
private static function sumDealOrderByAdmin(
int $startTs,
int $endTs,
?array $tagDiagIds,
?array $tagAssistantIdsForScoped,
array $appointmentChannelValues,
bool $channelFilterActive
): array {
$all = self::aggregateDealOrderCountGroupedByAssistant($startTs, $endTs, null, null);
if (!$channelFilterActive) {
return ['all' => $all, 'scoped' => $all];
}
if ($appointmentChannelValues !== []) {
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
if ($norm === []) {
$scoped = [];
} else {
$scoped = self::aggregateDealOrderCountByAssistantChannel($startTs, $endTs, $norm);
}
} elseif ($tagDiagIds !== null || $tagAssistantIdsForScoped !== null) {
$scoped = self::aggregateDealOrderCountGroupedByAssistant($startTs, $endTs, $tagDiagIds, $tagAssistantIdsForScoped);
} else {
$scoped = [];
}
return ['all' => $all, 'scoped' => $scoped];
}
/**
* 成交单数按 dg.assistant_id 聚合(与 aggregatePerformanceAmountsByAdmin 同一 WHERE,计条数非金额)。
*
* @return array<int, int>
*/
private static function aggregateDealOrderCountGroupedByAssistant(
int $startTs,
int $endTs,
?array $tagDiagIds,
?array $tagAssistantIds
): array {
if ($tagDiagIds !== null && $tagDiagIds === []) {
return [];
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return [];
}
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$query = Db::name('tcm_prescription_order')
->alias('o')
->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->whereNull('o.delete_time')
->where('o.create_time', 'between', [$startTs, $endTs])
->where('o.diagnosis_id', '>', 0)
->where('dg.assistant_id', '>', 0);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$query->field(['dg.assistant_id', 'COUNT(*) AS cnt'])
->group('dg.assistant_id');
if ($tagDiagIds !== null) {
$query->whereIn('o.diagnosis_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$query->whereIn('dg.assistant_id', array_keys($tagAssistantIds));
}
$rows = $query->select()->toArray();
if ($rows === []) {
return [];
}
$byAdmin = [];
foreach ($rows as $r) {
$aid = (int) ($r['assistant_id'] ?? 0);
if ($aid <= 0) {
continue;
}
$byAdmin[$aid] = ($byAdmin[$aid] ?? 0) + (int) $r['cnt'];
}
return $byAdmin;
}
/**
* dict(channels) 渠道成交单数按诊单医助聚合(与 aggregatePerformanceAmountsByAssistantChannel 同源)。
*
* @param int[] $appointmentChannelValues
*
* @return array<int, int>
*/
private static function aggregateDealOrderCountByAssistantChannel(
int $startTs,
int $endTs,
array $appointmentChannelValues
): array {
if ($appointmentChannelValues === []) {
return [];
}
$apTable = self::tableWithPrefix('doctor_appointment');
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$adminRoleTable = self::tableWithPrefix('admin_role');
$strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $appointmentChannelValues)));
$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 = o.diagnosis_id "
. "AND ap.status = 3 AND {$channelCond})";
$existsBindings = $strVals;
$query = Db::name('tcm_prescription_order')
->alias('o')
->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->whereNull('o.delete_time')
->where('o.create_time', 'between', [$startTs, $endTs]);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$query->where('o.diagnosis_id', '>', 0)
->where('dg.assistant_id', '>', 0)
->whereRaw($existsSql, $existsBindings)
->field(['dg.assistant_id', 'COUNT(*) AS cnt'])
->group('dg.assistant_id');
$rows = $query->select()->toArray();
if ($rows === []) {
return [];
}
$byAdmin = [];
foreach ($rows as $r) {
$aid = (int) ($r['assistant_id'] ?? 0);
if ($aid <= 0) {
continue;
}
$byAdmin[$aid] = ($byAdmin[$aid] ?? 0) + (int) $r['cnt'];
}
return $byAdmin;
}
/**
* 业绩按 dg.assistant_id 聚合到 admin(与处方订单列表 assistant_id 筛选口径一致)。
*
* @return array<int, float>
*/
private static function aggregatePerformanceAmountsByAdmin(
int $startTs,
int $endTs,
?array $tagDiagIds,
?array $tagAssistantIds
): array {
if ($tagDiagIds !== null && $tagDiagIds === []) {
return [];
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return [];
}
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$query = Db::name('tcm_prescription_order')
->alias('o')
->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->whereNull('o.delete_time')
->where('o.create_time', 'between', [$startTs, $endTs])
->where('o.diagnosis_id', '>', 0)
->where('dg.assistant_id', '>', 0);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$query->field(['dg.assistant_id', 'SUM(o.amount) AS amt'])
->group('dg.assistant_id');
if ($tagDiagIds !== null) {
$query->whereIn('o.diagnosis_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$query->whereIn('dg.assistant_id', array_keys($tagAssistantIds));
}
$rows = $query->select()->toArray();
if ($rows === []) {
return [];
}
$byAdmin = [];
foreach ($rows as $r) {
$aid = (int) ($r['assistant_id'] ?? 0);
if ($aid <= 0) {
continue;
}
$byAdmin[$aid] = ($byAdmin[$aid] ?? 0) + (float) $r['amt'];
}
return $byAdmin;
}
/**
* dict(channels) 渠道业绩按诊单医助 assistant_id 聚合(与 aggregatePerformanceByAppointmentChannels 同源)。
*
* @param int[] $appointmentChannelValues
*
* @return array<int, float>
*/
private static function aggregatePerformanceAmountsByAssistantChannel(
int $startTs,
int $endTs,
array $appointmentChannelValues
): array {
if ($appointmentChannelValues === []) {
return [];
}
$apTable = self::tableWithPrefix('doctor_appointment');
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$adminRoleTable = self::tableWithPrefix('admin_role');
$strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $appointmentChannelValues)));
$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 = o.diagnosis_id "
. "AND ap.status = 3 AND {$channelCond})";
$existsBindings = $strVals;
$query = Db::name('tcm_prescription_order')
->alias('o')
->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->whereNull('o.delete_time')
->where('o.create_time', 'between', [$startTs, $endTs]);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$query->where('o.diagnosis_id', '>', 0)
->where('dg.assistant_id', '>', 0)
->whereRaw($existsSql, $existsBindings)
->field(['dg.assistant_id', 'SUM(o.amount) AS amt'])
->group('dg.assistant_id');
$rows = $query->select()->toArray();
if ($rows === []) {
return [];
}
$byAdmin = [];
foreach ($rows as $r) {
$aid = (int) ($r['assistant_id'] ?? 0);
if ($aid <= 0) {
continue;
}
$byAdmin[$aid] = ($byAdmin[$aid] ?? 0) + (float) $r['amt'];
}
return $byAdmin;
}
/**
* 选定渠道时:二中心展示部门下属医助的渠道诊金、进线清零(与部门行一致)。
*
* @param array<int, float> $feeByAdmin
* @param array<int, int> $leadsByAdmin
*/
private static function excludeErCenterFromAdminLeaderboard(
array $adminToPrimary,
array $deptNameMap,
array &$feeByAdmin,
array &$leadsByAdmin,
?array &$dealOrderByAdmin = null
): void {
foreach ($adminToPrimary as $aid => $pid) {
if (!self::isErCenterDisplayPrimaryName($deptNameMap[$pid] ?? '')) {
continue;
}
$feeByAdmin[$aid] = 0.0;
$leadsByAdmin[$aid] = 0;
if ($dealOrderByAdmin !== null) {
$dealOrderByAdmin[$aid] = 0;
}
}
}
/** 排行榜部门列短名:取名称首字符 */
private static function deptDisplayAbbr(string $deptName): string
{
$t = trim($deptName);
if ($t === '') {
return '—';
}
$ch = mb_substr($t, 0, 1, 'UTF-8');
return $ch !== '' ? $ch : substr($t, 0, 1);
}
/**
* 业绩/成交单统计:仅排除履约已取消 fulfillment_status = 4,其余(含 NULL 与任意其它状态)全部计入。
* 与处方订单列表 stats_order_amount_performance(总业绩 已取消业绩)一致;避免 SQL 中 `<> 4` 把 NULL 行误排除。
*
* @param \think\db\Query $query
*/
private static function applyPrescriptionOrderNotCancelledForPerformance($query, string $tableAlias = 'o'): void
{
$query->whereRaw("NOT ({$tableAlias}.fulfillment_status <=> ?)", [4]);
}
/**
* 处方订单业绩全量合计(不按部门归属截断),与订单列表在**同一 create_time 区间、同一业绩条件**下的 SUM(amount) 对齐。
*/
private static function sumGlobalPerformanceAmount(int $startTs, int $endTs): float
{
$query = Db::name('tcm_prescription_order')
->alias('o')
->whereNull('o.delete_time')
->where('o.create_time', 'between', [$startTs, $endTs]);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
return round((float) $query->sum('o.amount'), 1);
}
/**
* 标签/医助兜底口径下的渠道向业绩全量 SUM(与 sumPerformance 的 scoped 分支同一组 WHERE,无部门归属过滤)。
*/
private static function sumGlobalPerformanceTagScoped(
int $startTs,
int $endTs,
?array $tagDiagIds,
?array $tagAssistantIds
): float {
if ($tagDiagIds !== null && $tagDiagIds === []) {
return 0.0;
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return 0.0;
}
$query = Db::name('tcm_prescription_order')
->alias('o')
->whereNull('o.delete_time')
->where('o.create_time', 'between', [$startTs, $endTs]);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
if ($tagDiagIds !== null) {
$query->whereIn('o.diagnosis_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$query->whereIn('o.creator_id', array_keys($tagAssistantIds));
}
return round((float) $query->sum('o.amount'), 1);
}
/**
* dict(channels) 映射下渠道业绩全量 SUM(与 aggregatePerformanceByAppointmentChannels 同源,不按 primary dept 汇总)。
*/
private static function sumGlobalPerformanceAppointmentChannels(int $startTs, int $endTs, array $appointmentChannelValues): float
{
if ($appointmentChannelValues === []) {
return 0.0;
}
$apTable = self::tableWithPrefix('doctor_appointment');
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$adminRoleTable = self::tableWithPrefix('admin_role');
$strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $appointmentChannelValues)));
$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 = o.diagnosis_id "
. "AND ap.status = 3 AND {$channelCond})";
$existsBindings = $strVals;
$query = Db::name('tcm_prescription_order')
->alias('o')
->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->whereNull('o.delete_time')
->where('o.create_time', 'between', [$startTs, $endTs]);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$query->where('o.diagnosis_id', '>', 0)
->where('dg.assistant_id', '>', 0)
->whereRaw($existsSql, $existsBindings);
return round((float) $query->sum('o.amount'), 1);
}
/**
* 选定渠道时:与 overview 中 sumPerformance「scoped」同规则的全局业绩金额(用于未归属补差)。
*/
private static function sumGlobalChannelScopedPerformance(
int $startTs,
int $endTs,
array $appointmentChannelValues,
?array $tagDiagIds,
?array $tagAssistantIdsForScoped
): float {
if ($appointmentChannelValues !== []) {
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
if ($norm === []) {
return 0.0;
}
return self::sumGlobalPerformanceAppointmentChannels($startTs, $endTs, $norm);
}
if ($tagDiagIds !== null || $tagAssistantIdsForScoped !== null) {
return self::sumGlobalPerformanceTagScoped($startTs, $endTs, $tagDiagIds, $tagAssistantIdsForScoped);
}
return 0.0;
}
/** @param int[] $raw */
private static function normalizeAppointmentChannelValues(array $raw): array
{
$out = [];
foreach ($raw as $v) {
$i = (int) $v;
if ($i > 0) {
$out[$i] = true;
}
}
return array_keys($out);
}
/**
* 业绩聚合:返回两组按部门归属的业绩金额。
* 口径与 /tcm.prescriptionOrder/lists 的 stats_*_performance 完全一致:
* 业绩 = SUM(amount) WHERE NOT (fulfillment_status <=> 4) AND delete_time IS NULL
*
* - all: 部门全量业绩(不限渠道)。用于「合计业绩」列展示。
* - scoped: 「{渠道}业绩」与 ROI 分子:优先挂号 channels(dict);否则 tag;已选渠道但两者皆不可用时为空。
* 未选渠道时 scoped == all(不再额外查一次)。
*
* @return array{
* all: array<int, float>,
* scoped: array<int, float>
* }
*/
private static function sumPerformance(
int $startTs,
int $endTs,
array $adminToPrimary,
?array $tagDiagIds,
?array $tagAssistantIds = null,
array $appointmentChannelValues = [],
bool $channelFilterActive = false
): array {
if ($adminToPrimary === []) {
return ['all' => [], 'scoped' => []];
}
// 1) 部门全量业绩(≠4),任何场景都跑一次 → "合计业绩"
$allByDept = self::aggregatePerformance($startTs, $endTs, $adminToPrimary, null, null);
if (!$channelFilterActive) {
return ['all' => $allByDept, 'scoped' => $allByDept];
}
if ($appointmentChannelValues !== []) {
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
if ($norm === []) {
$scopedByDept = [];
} else {
$scopedByDept = self::aggregatePerformanceByAppointmentChannels(
$startTs,
$endTs,
$adminToPrimary,
$norm
);
}
} elseif ($tagDiagIds !== null || $tagAssistantIds !== null) {
$scopedByDept = self::aggregatePerformance(
$startTs,
$endTs,
$adminToPrimary,
$tagDiagIds,
$tagAssistantIds
);
} else {
$scopedByDept = [];
}
return ['all' => $allByDept, 'scoped' => $scopedByDept];
}
/**
* 渠道业绩:与「处方订单列表」业绩完全同口径 —— 按 o.create_time 归日、仅剔除已取消(4)(含 NULL);
* EXISTS 仅用于"该患者属于此渠道"的判定(有任一挂号 channels 命中 + status=3**不限挂号时点**);
* 部门归属按「诊单医助 dg.assistant_id」(与处方列表 assistant_dept 过滤条件一致)。
*/
private static function aggregatePerformanceByAppointmentChannels(
int $startTs,
int $endTs,
array $adminToPrimary,
array $appointmentChannelValues
): array {
if ($appointmentChannelValues === [] || $adminToPrimary === []) {
return [];
}
$apTable = self::tableWithPrefix('doctor_appointment');
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$adminRoleTable = self::tableWithPrefix('admin_role');
$strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $appointmentChannelValues)));
$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 = o.diagnosis_id "
. "AND ap.status = 3 AND {$channelCond})";
$existsBindings = $strVals;
$query = Db::name('tcm_prescription_order')
->alias('o')
->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->whereNull('o.delete_time')
->where('o.create_time', 'between', [$startTs, $endTs]);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$query->where('o.diagnosis_id', '>', 0)
->where('dg.assistant_id', '>', 0)
->whereRaw($existsSql, $existsBindings)
->field(['dg.assistant_id', 'SUM(o.amount) AS amt'])
->group('dg.assistant_id');
$rows = $query->select()->toArray();
if ($rows === []) {
return [];
}
$byDept = [];
foreach ($rows as $r) {
$aid = (int) ($r['assistant_id'] ?? 0);
if ($aid <= 0) {
continue;
}
$primary = (int) ($adminToPrimary[$aid] ?? 0);
if ($primary <= 0) {
continue;
}
$byDept[$primary] = ($byDept[$primary] ?? 0) + (float) $r['amt'];
}
return $byDept;
}
/**
* 「{渠道}接诊」在字典映射到 channels 时:按**成交单数**(非取消业务订单条数)计,
* 与 aggregatePerformanceByAppointmentChannels 同一套 JOIN / EXISTS / 订单 create_time 窗口。
*
* @return array<int, int>
*/
private static function aggregateDealOrderCountByAppointmentChannels(
int $startTs,
int $endTs,
array $adminToPrimary,
array $appointmentChannelValues
): array {
if ($appointmentChannelValues === [] || $adminToPrimary === []) {
return [];
}
$apTable = self::tableWithPrefix('doctor_appointment');
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$adminRoleTable = self::tableWithPrefix('admin_role');
$strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $appointmentChannelValues)));
$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 = o.diagnosis_id "
. "AND ap.status = 3 AND {$channelCond})";
$existsBindings = $strVals;
$query = Db::name('tcm_prescription_order')
->alias('o')
->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->whereNull('o.delete_time')
->where('o.create_time', 'between', [$startTs, $endTs]);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$query->where('o.diagnosis_id', '>', 0)
->where('dg.assistant_id', '>', 0)
->whereRaw($existsSql, $existsBindings)
->field(['dg.assistant_id', 'COUNT(o.id) AS cnt'])
->group('dg.assistant_id');
$rows = $query->select()->toArray();
if ($rows === []) {
return [];
}
$byDept = [];
foreach ($rows as $r) {
$aid = (int) ($r['assistant_id'] ?? 0);
if ($aid <= 0) {
continue;
}
$primary = (int) ($adminToPrimary[$aid] ?? 0);
if ($primary <= 0) {
continue;
}
$byDept[$primary] = ($byDept[$primary] ?? 0) + (int) $r['cnt'];
}
return $byDept;
}
/**
* 标签口径下的「{渠道}成交单」计数:与 aggregatePerformance 同一套 WHERE(仅剔除已取消=4,含 NULL),
* 按 o.creator_id 分组 COUNT 后映射到 primary 部门,用于 tag 路径的「成交单」列展示。
*
* @return array<int, int>
*/
private static function aggregateDealOrderCountByTagScope(
int $startTs,
int $endTs,
array $adminToPrimary,
?array $tagDiagIds,
?array $tagAssistantIds
): array {
if ($tagDiagIds !== null && $tagDiagIds === []) {
return [];
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return [];
}
$query = Db::name('tcm_prescription_order')
->alias('o')
->whereNull('o.delete_time')
->where('o.create_time', 'between', [$startTs, $endTs]);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$query->field(['o.creator_id', 'COUNT(o.id) AS cnt'])
->group('o.creator_id');
if ($tagDiagIds !== null) {
$query->whereIn('o.diagnosis_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$query->whereIn('o.creator_id', array_keys($tagAssistantIds));
}
$rows = $query->select()->toArray();
if ($rows === []) {
return [];
}
$byDept = [];
foreach ($rows as $r) {
$cid = (int) ($r['creator_id'] ?? 0);
if ($cid <= 0) {
continue;
}
$primary = (int) ($adminToPrimary[$cid] ?? 0);
if ($primary <= 0) {
continue;
}
$byDept[$primary] = ($byDept[$primary] ?? 0) + (int) $r['cnt'];
}
return $byDept;
}
/**
* 单次业绩聚合查询。WHERE: delete_time IS NULL AND NOT(fulfillment <=> 4) AND create_time BETWEEN ...
* 可选 diagnosis_id IN tagDiagIds、creator_id IN tagAssistantIds。
*
* @return array<int, float>
*/
private static function aggregatePerformance(int $startTs, int $endTs, array $adminToPrimary, ?array $tagDiagIds, ?array $tagAssistantIds): array
{
if ($tagDiagIds !== null && $tagDiagIds === []) {
return [];
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return [];
}
$query = Db::name('tcm_prescription_order')
->alias('o')
->whereNull('o.delete_time')
->where('o.create_time', 'between', [$startTs, $endTs]);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$query->field(['o.creator_id', 'SUM(o.amount) AS amt'])
->group('o.creator_id');
if ($tagDiagIds !== null) {
$query->whereIn('o.diagnosis_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
// 兜底口径:限定订单创建人为「跟进过该标签客户的医助」
$query->whereIn('o.creator_id', array_keys($tagAssistantIds));
}
$rows = $query->select()->toArray();
if ($rows === []) {
return [];
}
$byDept = [];
foreach ($rows as $r) {
$cid = (int) ($r['creator_id'] ?? 0);
if ($cid <= 0) {
continue;
}
$primary = (int) ($adminToPrimary[$cid] ?? 0);
if ($primary <= 0) {
continue;
}
$byDept[$primary] = ($byDept[$primary] ?? 0) + (float) $r['amt'];
}
return $byDept;
}
/**
* 全量成交单数:与 sumGlobalPerformanceAmount 同一 WHERE/COUNT(非取消订单条数)。
*/
private static function countGlobalDealOrders(int $startTs, int $endTs): int
{
$query = Db::name('tcm_prescription_order')
->alias('o')
->whereNull('o.delete_time')
->where('o.create_time', 'between', [$startTs, $endTs]);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
return (int) $query->count();
}
/**
* 成交单数按部门:关联诊单,按 dg.assistant_id 分组后映射到展示「中心」(与 PrescriptionOrderLists::applyDoctorAssistantFilters assistant_dept 一致)。
* 仅统计诊单医助 > 0 的订单行;诊单无医助或医助无法映射的不计入各部门(由 global − sum 归入未归属中心)。
*
* @return array<int, int>
*/
private static function aggregateDealOrderCount(
int $startTs,
int $endTs,
array $adminToPrimary,
?array $tagDiagIds,
?array $tagAssistantIds
): array {
if ($tagDiagIds !== null && $tagDiagIds === []) {
return [];
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return [];
}
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$query = Db::name('tcm_prescription_order')
->alias('o')
->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->whereNull('o.delete_time')
->where('o.create_time', 'between', [$startTs, $endTs])
->where('o.diagnosis_id', '>', 0)
->where('dg.assistant_id', '>', 0);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$query->field(['dg.assistant_id', 'COUNT(*) AS cnt'])
->group('dg.assistant_id');
if ($tagDiagIds !== null) {
$query->whereIn('o.diagnosis_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$query->whereIn('dg.assistant_id', array_keys($tagAssistantIds));
}
$rows = $query->select()->toArray();
if ($rows === []) {
return [];
}
$byDept = [];
foreach ($rows as $r) {
$aid = (int) ($r['assistant_id'] ?? 0);
if ($aid <= 0) {
continue;
}
$primary = (int) ($adminToPrimary[$aid] ?? 0);
if ($primary <= 0) {
continue;
}
$byDept[$primary] = ($byDept[$primary] ?? 0) + (int) $r['cnt'];
}
return $byDept;
}
/**
* 表格不展示「有下级的勾选根部门」行时,仍把该行对应进线数并入首个可见下级,避免投放成本分摊分母偏小。
*
* @param int[] $primaryDeptIds
* @param int[] $tableRowDeptIds
* @param array<int, int> $leads
* @param array<int, int[]> $deptToDescendants
*
* @return array<int, int>
*/
private static function mergeHiddenPrimaryLeadsForCostAllocation(
array $primaryDeptIds,
array $tableRowDeptIds,
array $leads,
array $deptToDescendants
): array {
if (array_diff($primaryDeptIds, $tableRowDeptIds) === [] && array_diff($tableRowDeptIds, $primaryDeptIds) === []) {
return $leads;
}
$out = $leads;
$hidden = array_values(array_diff($primaryDeptIds, $tableRowDeptIds));
if ($hidden === []) {
return $out;
}
foreach ($hidden as $h) {
$add = (int) ($out[$h] ?? 0);
if ($add <= 0) {
continue;
}
$descFlip = array_flip($deptToDescendants[$h] ?? []);
$target = null;
foreach ($tableRowDeptIds as $tid) {
if ($tid !== $h && isset($descFlip[$tid])) {
$target = $tid;
break;
}
}
if ($target !== null) {
$out[$target] = (int) ($out[$target] ?? 0) + $add;
}
}
return $out;
}
/**
* 将总投放成本按展示各部门「进线」条数比例分摊:部门成本 = 总成本 × (部门进线 / 进线合计)。
* 四舍五入尾差并入进线条数最多的部门。
*
* @param array<int, int> $leads
* @return array<int, float>
*/
private static function allocateCostByLeads(float $globalCost, array $primaryDeptIds, array $leads): array
{
$out = [];
foreach ($primaryDeptIds as $pid) {
$out[$pid] = 0.0;
}
if ($globalCost <= 0 || $primaryDeptIds === []) {
return $out;
}
$totalLeads = 0;
foreach ($primaryDeptIds as $pid) {
$totalLeads += (int) ($leads[$pid] ?? 0);
}
if ($totalLeads <= 0) {
return $out;
}
$allocated = 0.0;
$bestPid = null;
$bestLv = -1;
foreach ($primaryDeptIds as $pid) {
$lv = (int) ($leads[$pid] ?? 0);
if ($lv > $bestLv) {
$bestLv = $lv;
$bestPid = $pid;
}
$share = $globalCost * $lv / $totalLeads;
$out[$pid] = round($share, 1);
$allocated += $out[$pid];
}
$diff = round($globalCost - $allocated, 1);
if ($diff !== 0.0 && $bestPid !== null) {
$out[$bestPid] = round($out[$bestPid] + $diff, 1);
}
return $out;
}
/**
* 渠道总成本(合计行 & 进线分摊基数):account_cost 在日期区间内 SUM(amount),选定渠道时按 media_channel_code 过滤。
*/
private static function sumGlobalCost(string $startDate, string $endDate, string $channelCode): float
{
$query = Db::name('account_cost')->whereBetween('cost_date', [$startDate, $endDate]);
if ($channelCode !== '') {
$query->where('media_channel_code', $channelCode);
}
return (float) $query->sum('amount');
}
/**
* 展示部门名称是否属「二中心」系:选定渠道时该部门不参与该渠道进线/业绩/成交单/ROI 分子。
*/
private static function isErCenterDisplayPrimaryName(string $name): bool
{
return mb_strpos($name, '二中心') !== false;
}
/**
* 选定渠道时:名称含「二中心」的展示部门渠道业绩与渠道成交单置 0,并从后续全量渠道 SUM 中扣减同等金额。
*
* @param array<int, float|string|int> $channelPerformance
* @param array<int, int> $channelConsults
* @return float 扣减金额(与 sumGlobalChannelScopedPerformance 对齐)
*/
private static function excludeErCenterFromChannelMetrics(
array $primaryDeptIds,
array $deptNameMap,
array &$channelPerformance,
array &$channelConsults
): float {
$stripped = 0.0;
foreach ($primaryDeptIds as $pid) {
if (!self::isErCenterDisplayPrimaryName($deptNameMap[$pid] ?? '')) {
continue;
}
$stripped += (float) ($channelPerformance[$pid] ?? 0);
$channelPerformance[$pid] = 0.0;
$channelConsults[$pid] = 0;
}
return round($stripped, 1);
}
/**
* 选定渠道时:名称含「二中心」的展示部门进线数置 0(与同渠道向业绩/成交单一致)。
*
* @param array<int, int> $leads
*/
private static function excludeErCenterFromLeads(array $primaryDeptIds, array $deptNameMap, array &$leads): void
{
foreach ($primaryDeptIds as $pid) {
if (!self::isErCenterDisplayPrimaryName($deptNameMap[$pid] ?? '')) {
continue;
}
$leads[$pid] = 0;
}
}
/**
* 渠道 tag → 1) 该标签下客户对应的全部诊单 id 集合(含 external_userid 直挂 + unionid 兜底)
* 2) 这些客户的"跟进医助 admin id"集合(与 follow_users 中的医助 work_wechat_userid 对齐)
*
* 一次性算好,后续业绩/接诊用 whereIn 收敛,避免 EXISTS+LIKE 全表扫超时。
*
* @return array{0: int[], 1: array<int, true>}
*/
/** @var array<string, array{0: int[], 1: array<int,int>}> 同一请求内 tag → [diagIds, assistantIds] 缓存 */
private static array $tagScopeCache = [];
private static function resolveTagScopedDiagnosisAndAssistants(string $tagFilterId): array
{
if (isset(self::$tagScopeCache[$tagFilterId])) {
return self::$tagScopeCache[$tagFilterId];
}
// 1. 该标签下的客户:external_userid + unionid 集合
$rows = Db::name('qywx_external_contact_tag')
->alias('t')
->join((string) self::tableWithPrefix('qywx_external_contact') . ' ec', 'ec.external_userid = t.external_userid AND ec.delete_time IS NULL', 'INNER')
->where('t.tag_id', $tagFilterId)
->field(['t.external_userid', 'ec.unionid', 'ec.follow_users'])
->select()
->toArray();
if ($rows === []) {
return self::$tagScopeCache[$tagFilterId] = [[], []];
}
$extIds = [];
$unionIds = [];
$followWxUserids = [];
foreach ($rows as $r) {
$ext = (string) ($r['external_userid'] ?? '');
if ($ext !== '') {
$extIds[$ext] = true;
}
$u = (string) ($r['unionid'] ?? '');
if ($u !== '') {
$unionIds[$u] = true;
}
$fu = json_decode((string) ($r['follow_users'] ?? '[]'), true);
if (!is_array($fu)) {
continue;
}
foreach ($fu as $f) {
if (!is_array($f)) {
continue;
}
$wx = trim((string) ($f['userid'] ?? ''));
if ($wx !== '') {
$followWxUserids[$wx] = true;
}
}
}
// 2. 诊单:external_userid 直挂 unionid 兜底
$diagIds = [];
if ($extIds !== []) {
$ids = Db::name('tcm_diagnosis')
->whereNull('delete_time')
->whereIn('external_userid', array_keys($extIds))
->column('id');
foreach ($ids as $id) {
$diagIds[(int) $id] = true;
}
}
if ($unionIds !== []) {
$userIds = Db::name('user_auth')
->whereIn('unionid', array_keys($unionIds))
->column('user_id');
$userIds = array_values(array_unique(array_filter(array_map('intval', $userIds), static fn (int $v) => $v > 0)));
if ($userIds !== []) {
$ids = Db::name('tcm_diagnosis')
->whereNull('delete_time')
->whereIn('patient_id', $userIds)
->column('id');
foreach ($ids as $id) {
$diagIds[(int) $id] = true;
}
}
}
// 3. 跟进医助:follow_users 中的 wx userid → admin.id(仅医助 role_id=2
$assistantIds = [];
if ($followWxUserids !== []) {
$rows = Db::name('admin')
->alias('a')
->join((string) self::tableWithPrefix('admin_role') . ' ar', 'ar.admin_id = a.id AND ar.role_id = 2', 'INNER')
->whereIn('a.work_wechat_userid', array_keys($followWxUserids))
->whereNull('a.delete_time')
->column('a.id');
foreach ($rows as $aid) {
$assistantIds[(int) $aid] = true;
}
}
return self::$tagScopeCache[$tagFilterId] = [array_keys($diagIds), $assistantIds];
}
/**
* 表名 + 数据库前缀(用于 whereRaw 内嵌的 SQL,框架不会替换 raw 表名)
*/
private static function tableWithPrefix(string $table): string
{
$prefix = (string) (Db::getConfig('connections.mysql.prefix') ?: 'zyt_');
return $prefix . $table;
}
}