2539 lines
99 KiB
PHP
2539 lines
99 KiB
PHP
<?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=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** 映射到「中心」部门(与处方订单列表 <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 口径;否则走 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<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;
|
||
}
|
||
}
|