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=4;NULL 与其它状态计入,按 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** 映射到「中心」部门(与处方订单列表 assistant_dept_id / 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_ 或 tagname_;
* ① 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,
* deptToDescendants:array,
* adminToPrimary:array,
* tagDiagIds:int[]|null,
* tagAssistantIds:array|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,tableRowDeptIds:int[],
* appointmentChannelValues:int[],channelFilterActive:bool,
* tagDiagIds:int[]|null,tagAssistantIds:array|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
*/
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);
$assignsByDept = self::countDiagnosisAssignsByDept(
$startTs,
$endTs,
$adminToPrimary,
$tagDiagIds,
$tagFallback ? $tagAssistantIds : null,
$appointmentChannelValues,
$channelFilterActive,
$dataScopeAdminIds
);
// 接诊:
// - 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);
self::excludeErCenterFromLeads($primaryDeptIds, $deptNameMap, $assignsByDept);
}
$globalCost = round((float) self::sumGlobalCost($startDate, $endDate, $channelCode), 1);
$leadsForCost = self::mergeHiddenPrimaryLeadsForCostAllocation(
$primaryDeptIds,
$tableRowDeptIds,
$leads,
$deptToDescendants
);
$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 $totalRevisitSlots */
$totalRevisitSlots = [];
$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);
$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 相同
'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;
$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;
// 不再用各行成本叠加(避免父级未在展示列时丢失),合计直接用 $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,
'assign_count' => 0,
'revisit_count' => 0,
'revisit_slots' => [],
'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);
if ($totalRevisitSlots !== []) {
ksort($totalRevisitSlots, SORT_NUMERIC);
}
$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,
'assign_count' => $totalAssign,
'revisit_count' => $totalRevisit,
'revisit_slots' => $totalRevisitSlots,
'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 $ranges
* @return array>
*/
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。
* 被指派数 = 区间内诊单指派日志(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
*
* @return array
*/
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'];
$dataScopeRestricted = !empty($c['dataScopeRestricted']);
$dataScopeAdminIds = $dataScopeRestricted && isset($c['dataScopeVisibleAdminIds'])
? $c['dataScopeVisibleAdminIds']
: null;
$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);
$assignsByAdmin = self::countDiagnosisAssignsByAdmin(
$startTs,
$endTs,
$tagDiagIds,
$tagFallback ? $tagAssistantIds : null,
$appointmentChannelValues,
$channelFilterActive,
$dataScopeAdminIds
);
if ($channelFilterActive) {
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) {
$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);
$isErCenterLb = isset($erCenterDeptSet[$deptId]);
$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);
$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;
$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']) {
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,
'er_center_subtree' => $isErCenterLb,
'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|null null 表示不限制,[] 表示无可选
*/
private static function buildYejiDataScopeAllowedDeptIdMap(int $adminId, array $adminInfo): ?array
{
return DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
}
/**
* 列出所有可选部门
*
* @return array
*/
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
* }>,
* 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, 2: int[], 3: array>}
*/
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> $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 $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> $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
*/
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 $deptToDescendants
* @return array
*/
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 $adminToPrimary
* @return array
*/
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;
}
/**
* 被指派数:诊单指派日志(to_assistant_id>0)按被指派医助归属展示部门;选定渠道时收窄与「{渠道}业绩」同源。
*
* @param array $adminToPrimary
* @param int[]|null $tagDiagIds
* @param array|null $tagAssistantIds tag 退化口径下按 to_assistant_id 过滤(与 aggregatePerformance 一致)
*
* @return array
*/
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
*/
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」等额外限制。
* - scoped: 选定渠道时「{渠道}成交单」= **非取消业务订单条数**(与 {渠道}业绩同源):有 dict(channels) 走诊单医助+EXISTS 口径;否则走 tag(diagnosis_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,
* scoped: array
* }
*/
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
*/
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
*/
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
*/
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;
}
/**
* 进线事件查询公共条件(与 countLeads / countLeadsByAdmin 一致)。
*
* @param \think\db\BaseQuery $query
*/
private static function applyLeadEventBaseFilter($query, int $startTs, int $endTs, string $tagFilterId): void
{
$query->where('e.change_type', 'add_external_contact')
->where('e.event_time', 'between', [$startTs, $endTs])
->where('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]
);
}
}
/**
* 归属到指定展示「中心」行部门的接待成员企微 userid 列表。
*
* @param array $adminToPrimary
*
* @return list
*/
private static function collectWorkWxUserIdsForYejiDeptRow(int $deptRowId, array $adminToPrimary): array
{
$adminIds = [];
foreach ($adminToPrimary as $aid => $pid) {
if ((int) $pid === $deptRowId) {
$adminIds[] = (int) $aid;
}
}
if ($adminIds === []) {
return [];
}
$wids = Db::name('admin')
->whereIn('id', $adminIds)
->whereNull('delete_time')
->column('work_wechat_userid');
$out = [];
foreach ($wids as $w) {
$w = trim((string) $w);
if ($w !== '') {
$out[] = $w;
}
}
return array_values(array_unique($out));
}
/**
* 进线明细:与看板「进线数据」同口径的 add_external_contact 逐条列表。
*
* @param array{
* start_date?: string,
* end_date?: string,
* dept_ids?: int[]|string,
* channel_code?: string,
* tag_id?: string,
* dept_id?: int|string,
* page?: int|string,
* page_size?: int|string
* } $params
*
* @return array{
* start_date: string,
* end_date: string,
* dept_id: int,
* dept_name: string,
* channel_code: string,
* count: int,
* lists: list>,
* note: string
* }
*/
public static function leadLineList(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);
$empty = static function (array $c, int $deptId, string $deptName, string $note) {
return [
'start_date' => (string) $c['startDate'],
'end_date' => (string) $c['endDate'],
'dept_id' => $deptId,
'dept_name' => $deptName,
'channel_code' => (string) $c['channelCode'],
'count' => 0,
'lists' => [],
'note' => $note,
];
};
if ($deptId <= 0) {
return $empty($c, 0, '', '未归属中心无进线明细(看板该行进线为 0)。');
}
if (!in_array($deptId, $c['tableRowDeptIds'], true)) {
return $empty(
$c,
$deptId,
self::formatYejiDeptRowDisplayName($deptId, $c['deptById']),
'该部门不在当前展示部门筛选或数据权限范围内。'
);
}
$deptNameMap = $c['deptNameMap'];
$channelCode = (string) $c['channelCode'];
if ($channelCode !== '' && self::isErCenterDisplayPrimaryName((string) ($deptNameMap[$deptId] ?? ''))) {
return $empty(
$c,
$deptId,
self::formatYejiDeptRowDisplayName($deptId, $c['deptById']),
'与看板一致:名称含「二中心」的展示部门在选定渠道下进线计 0,无明细。'
);
}
$startTs = (int) $c['startTs'];
$endTs = (int) $c['endTs'];
$tagFilterId = (string) $c['tagFilterId'];
$page = max(1, (int) ($params['page'] ?? 1));
$limit = min(100, max(1, (int) ($params['page_size'] ?? 20)));
$offset = ($page - 1) * $limit;
$wxUserIds = self::collectWorkWxUserIdsForYejiDeptRow($deptId, $c['adminToPrimary']);
$deptName = self::formatYejiDeptRowDisplayName($deptId, $c['deptById']);
$ecTable = self::tableWithPrefix('qywx_external_contact');
if ($wxUserIds === []) {
return [
'start_date' => (string) $c['startDate'],
'end_date' => (string) $c['endDate'],
'dept_id' => $deptId,
'dept_name' => $deptName,
'channel_code' => $channelCode,
'count' => 0,
'lists' => [],
'note' => '当前部门下没有可映射到接待企微成员的管理员,无进线流水。',
];
}
$countQuery = Db::name('qywx_external_contact_event')->alias('e');
self::applyLeadEventBaseFilter($countQuery, $startTs, $endTs, $tagFilterId);
$countQuery->whereIn('e.user_id', $wxUserIds);
$count = (int) $countQuery->count();
$listQuery = Db::name('qywx_external_contact_event')->alias('e')
->leftJoin('admin a', 'a.work_wechat_userid = e.user_id AND a.delete_time IS NULL')
->leftJoin("{$ecTable} ec", 'ec.external_userid = e.external_userid AND ec.delete_time IS NULL');
self::applyLeadEventBaseFilter($listQuery, $startTs, $endTs, $tagFilterId);
$listQuery->whereIn('e.user_id', $wxUserIds);
$rawList = $listQuery
->field(
'e.id,e.event_time,e.external_userid,e.user_id,e.state,'
. 'a.name AS reception_admin_name,ec.name AS external_contact_name'
)
->order('e.event_time', 'desc')
->order('e.id', 'desc')
->limit($offset, $limit)
->select()
->toArray();
$lists = [];
foreach ($rawList as $rw) {
$ts = (int) ($rw['event_time'] ?? 0);
$lists[] = [
'id' => (int) ($rw['id'] ?? 0),
'event_time' => $ts,
'event_time_text' => $ts > 0 ? date('Y-m-d H:i:s', $ts) : '',
'external_userid' => (string) ($rw['external_userid'] ?? ''),
'user_id' => (string) ($rw['user_id'] ?? ''),
'state' => (string) ($rw['state'] ?? ''),
'reception_admin_name' => (string) ($rw['reception_admin_name'] ?? ''),
'external_contact_name' => (string) ($rw['external_contact_name'] ?? ''),
];
}
$note = '与看板「进线数据」同口径:企微 change_type=add_external_contact,按接待成员归属当前部门行;'
. ($tagFilterId !== '' ? '已按所选渠道标签过滤客户。' : '未选渠道时不限制客户标签。');
return [
'start_date' => (string) $c['startDate'],
'end_date' => (string) $c['endDate'],
'dept_id' => $deptId,
'dept_name' => $deptName,
'channel_code' => $channelCode,
'count' => $count,
'lists' => $lists,
'note' => $note,
];
}
/**
* 业绩看板:二中心复诊按部门行下钻 —— 医助姓名与业务订单笔数(与表格「复诊」列同口径)。
*
* @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_slot:0=复诊合计(全部复诊笔),≥2=对应分项
*
* @return array{
* start_date:string,end_date:string,dept_id:int,dept_name:string,revisit_slot:int,revisit_slot_label:string,
* rows:list,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 维度返回全量/渠道业绩金额(不按部门折叠)。
*
* @param int[] $appointmentChannelValues
*
* @return array{all: array, scoped: array}
*/
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, scoped: array}
*/
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
*/
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
*/
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
*/
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
*/
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 $feeByAdmin
* @param array $leadsByAdmin
* @param array|null $assignsByAdmin
*/
private static function excludeErCenterFromAdminLeaderboard(
array $adminToPrimary,
array $deptNameMap,
array &$feeByAdmin,
array &$leadsByAdmin,
?array &$dealOrderByAdmin = null,
?array &$assignsByAdmin = 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;
}
if ($assignsByAdmin !== null) {
$assignsByAdmin[$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,
* scoped: array
* }
*/
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
*/
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
*/
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
*/
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
*/
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 $leads
* @param array $deptToDescendants
*
* @return array
*/
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 $leads
* @return array
*/
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 $channelPerformance
* @param array $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 $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}
*/
/** @var array}> 同一请求内 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];
}
/**
* 「未归属中心」合计业绩按订单创建人拆解(与 aggregatePerformance /「合计业绩」列归属一致:仅 null,null 标签口径,不按渠道收窄)。
*
* @return array{
* start_date: string,
* end_date: string,
* channel_code: string,
* rows: list,
* note: string
* }
*/
public static function unassignedCenterBreakdown(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array
{
$c = self::resolveYejiContext($params);
if ($viewerAdminId > 0) {
self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo);
}
$startTs = (int) $c['startTs'];
$endTs = (int) $c['endTs'];
$adminToPrimary = $c['adminToPrimary'];
$visibleCreatorIds = null;
if (!empty($c['dataScopeRestricted'])) {
$visibleCreatorIds = $c['dataScopeVisibleAdminIds'] ?? [];
}
$rows = self::aggregateUnassignedByCreator(
$startTs,
$endTs,
$adminToPrimary,
null,
null,
$visibleCreatorIds
);
$ids = [];
foreach ($rows as $row) {
if ($row['admin_id'] > 0) {
$ids[] = $row['admin_id'];
}
}
$names = self::batchAdminNames(array_values(array_unique($ids)));
foreach ($rows as &$row) {
$aid = (int) $row['admin_id'];
if ($aid <= 0) {
$row['name'] = '(无创建人/0)';
} else {
$row['name'] = (string) ($names[$aid] ?? ('#' . $aid));
}
}
unset($row);
return [
'start_date' => (string) $c['startDate'],
'end_date' => (string) $c['endDate'],
'channel_code' => (string) $c['channelCode'],
'rows' => $rows,
'note' => '与看板「合计业绩」列口径一致:按订单创建时间、剔除履约已取消(4);创建人无法映射到任一展示「中心」部门的业绩计入未归属。账号数据权限下仅列出可见创建人相关订单。',
];
}
/**
* @param int[]|null $visibleCreatorIds 数据权限:仅统计这些创建人(null=不额外限制);须含 creator_id=0 时用 OR 条件已并入查询
*
* @return list
*/
private static function aggregateUnassignedByCreator(
int $startTs,
int $endTs,
array $adminToPrimary,
?array $tagDiagIds,
?array $tagAssistantIds,
?array $visibleCreatorIds
): 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);
if ($tagDiagIds !== null) {
$query->whereIn('o.diagnosis_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$query->whereIn('o.creator_id', array_keys($tagAssistantIds));
}
if ($visibleCreatorIds !== null) {
$visibleCreatorIds = array_values(array_unique(array_filter(array_map('intval', $visibleCreatorIds), static fn (int $v): bool => $v > 0)));
if ($visibleCreatorIds === []) {
return [];
}
$ph = implode(',', array_fill(0, count($visibleCreatorIds), '?'));
$query->whereRaw("(o.creator_id = 0 OR o.creator_id IN ({$ph}))", $visibleCreatorIds);
}
$query->field(['o.creator_id', 'COUNT(*) AS order_count', 'SUM(o.amount) AS amount'])
->group('o.creator_id');
$dbRows = $query->select()->toArray();
if ($dbRows === []) {
return [];
}
$out = [];
foreach ($dbRows as $r) {
$cid = (int) ($r['creator_id'] ?? 0);
$primary = (int) ($adminToPrimary[$cid] ?? 0);
if ($primary > 0) {
continue;
}
$out[] = [
'admin_id' => $cid,
'name' => '',
'order_count' => (int) ($r['order_count'] ?? 0),
'amount' => round((float) ($r['amount'] ?? 0), 2),
];
}
usort($out, static function (array $a, array $b): int {
$c = $b['amount'] <=> $a['amount'];
if ($c !== 0) {
return $c;
}
return $b['order_count'] <=> $a['order_count'];
});
return $out;
}
/**
* @param int[] $ids
*
* @return array
*/
private static function batchAdminNames(array $ids): array
{
if ($ids === []) {
return [];
}
$rows = Db::name('admin')
->whereIn('id', $ids)
->whereNull('delete_time')
->field(['id', 'name'])
->select()
->toArray();
$map = [];
foreach ($rows as $r) {
$map[(int) $r['id']] = (string) ($r['name'] ?? '');
}
return $map;
}
/**
* 表名 + 数据库前缀(用于 whereRaw 内嵌的 SQL,框架不会替换 raw 表名)
*/
private static function tableWithPrefix(string $table): string
{
$prefix = (string) (Db::getConfig('connections.mysql.prefix') ?: 'zyt_');
return $prefix . $table;
}
}