1865 lines
73 KiB
PHP
1865 lines
73 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace app\adminapi\logic\stats;
|
||
|
||
use app\common\service\qywx\MediaChannelService;
|
||
use think\facade\Db;
|
||
|
||
/**
|
||
* 业绩看板(部门 × 时间区间 × 渠道来源)
|
||
*
|
||
* - 进线数据 = 区间内 add_external_contact 事件,按接待员工(user_id) 归属部门
|
||
* · 与 qywx.customer/todayArrival、客户列表同源事件表;选定渠道时客户须带对应企微标签(contact_tag);**二中心展示部门在选定渠道下计 0**
|
||
* - 接诊诊单 = 已完成挂号 status=3,医助 role_id=2;按 TIMESTAMP(appointment_date, appointment_time) 落入统计区间(挂号条数,非订单)。
|
||
* - {渠道}成交单 = 选定渠道时与 {渠道}业绩同源:区间内 **非取消业务订单条数**(仅剔除 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 口径
|
||
* - 平均单价 = 合计业绩 / 接诊诊单
|
||
* - 投放成本 = 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);
|
||
}
|
||
}
|
||
|
||
[$primaryDeptIds, $deptNameMap] = 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,
|
||
'deptNameMap' => $deptNameMap,
|
||
'deptToDescendants' => $deptToDescendants,
|
||
'adminToPrimary' => $adminToPrimary,
|
||
'tagDiagIds' => $tagDiagIds,
|
||
'tagAssistantIds' => $tagAssistantIds,
|
||
'tagFallback' => $tagFallback,
|
||
'appointmentChannelValues' => $appointmentChannelValues,
|
||
'channelFilterActive' => ($channelCode !== ''),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @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): array
|
||
{
|
||
$c = self::resolveYejiContext($params);
|
||
$startDate = $c['startDate'];
|
||
$endDate = $c['endDate'];
|
||
$startTs = $c['startTs'];
|
||
$endTs = $c['endTs'];
|
||
$channelCode = $c['channelCode'];
|
||
$tagFilterId = $c['tagFilterId'];
|
||
$channelInfo = $c['channelInfo'];
|
||
$primaryDeptIds = $c['primaryDeptIds'];
|
||
$deptNameMap = $c['deptNameMap'];
|
||
$adminToPrimary = $c['adminToPrimary'];
|
||
$tagDiagIds = $c['tagDiagIds'];
|
||
$tagAssistantIds = $c['tagAssistantIds'];
|
||
$tagFallback = $c['tagFallback'];
|
||
$appointmentChannelValues = $c['appointmentChannelValues'];
|
||
$channelFilterActive = $c['channelFilterActive'];
|
||
|
||
$leads = self::countLeads($startTs, $endTs, $adminToPrimary, $tagFilterId);
|
||
// 接诊:
|
||
// - consults(全量) → "接诊诊单" 列;不限渠道
|
||
// - channelConsults(渠道) → 「{渠道}成交单」列;未选渠道时与 consults 相同
|
||
$consultsBoth = self::countConsults(
|
||
$startTs,
|
||
$endTs,
|
||
$adminToPrimary,
|
||
$tagDiagIds,
|
||
$tagAssistantIds,
|
||
$appointmentChannelValues,
|
||
$channelFilterActive
|
||
);
|
||
$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'];
|
||
|
||
/** 选定渠道时从全量渠道 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);
|
||
$costs = self::allocateCostByLeads($globalCost, $primaryDeptIds, $leads);
|
||
|
||
// ── 5) 组装每行 ──────────────────────────────────────────────
|
||
$rows = [];
|
||
$totalLead = 0;
|
||
$totalConsult = 0;
|
||
$totalChannelConsult = 0;
|
||
$totalPerf = 0.0;
|
||
$totalChannelPerf = 0.0;
|
||
$totalCost = 0.0;
|
||
foreach ($primaryDeptIds as $deptId) {
|
||
$lead = (int) ($leads[$deptId] ?? 0);
|
||
$cnt = (int) ($consults[$deptId] ?? 0);
|
||
$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;
|
||
|
||
$rows[] = [
|
||
'dept_id' => $deptId,
|
||
'dept_name' => $deptNameMap[$deptId] ?? ('部门#' . $deptId),
|
||
'lead_count' => $lead,
|
||
'consult_count' => $cnt,
|
||
// {渠道}成交单:选定渠道时与 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;
|
||
$totalConsult += $cnt;
|
||
$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);
|
||
}
|
||
$unassignedPerf = round($globalPerfAll - $allocatedPerf, 1);
|
||
$unassignedChannelPerf = round($globalPerfChannel - $allocatedChannelPerf, 1);
|
||
if (abs($unassignedPerf) >= 0.01 || abs($unassignedChannelPerf) >= 0.01) {
|
||
$uPerf = $unassignedPerf;
|
||
$uCperf = $channelFilterActive ? $unassignedChannelPerf : $uPerf;
|
||
$rows[] = [
|
||
'dept_id' => 0,
|
||
'dept_name' => '未归属中心',
|
||
'lead_count' => 0,
|
||
'consult_count' => 0,
|
||
'channel_consult_count' => 0,
|
||
'performance_amount' => $uPerf,
|
||
'completed_performance_amount' => $uCperf,
|
||
'avg_price' => null,
|
||
'channel_avg_price' => null,
|
||
'cost_amount' => 0.0,
|
||
'roi' => null,
|
||
];
|
||
}
|
||
|
||
$totalPerf = $globalPerfAll;
|
||
$totalChannelPerf = $globalPerfChannel;
|
||
$totalCost = round($globalCost, 1);
|
||
|
||
// ROI 合计 = 渠道业绩合计(与「completed」列一致,**不含二中心**)÷ 投放成本合计
|
||
$totalRoiNumerator = round($totalChannelPerf, 1);
|
||
|
||
return [
|
||
'start_date' => $startDate,
|
||
'end_date' => $endDate,
|
||
'dept_ids' => array_values($primaryDeptIds),
|
||
'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)的订单条数(非挂号条数)。'
|
||
. ' 「接诊诊单」列仍按已完成挂号+预约时点统计。名称含「二中心」的部门**不计入**该渠道进线、业绩与成交单。**投放成本**按各部门进线条数占展示部门进线合计的比例,从账务总投放金额中分摊(总成本÷总进线=单进线成本)。ROI = 渠道业绩(不含二中心)÷ 总投放成本。进线按所选标签客户精确过滤。'
|
||
. ($tagFallback
|
||
? ' ⚠ 当前业务尚未回填 tcm_diagnosis.external_userid 与 user_auth.unionid,已自动退化为「该标签客户的跟进医助 → 区间内接诊/业绩」口径,仅用于趋势观察。'
|
||
: ''))
|
||
: '业绩口径:除履约已取消(4)外全部计入(与订单列表 stats_order_amount_performance 一致)。无法归入「中心」展示部门的创建人业绩计入「未归属中心」行;合计=全表 SUM。**投放成本**按进线占比分摊至各部门。',
|
||
'rows' => $rows,
|
||
'total' => [
|
||
'lead_count' => $totalLead,
|
||
'consult_count' => $totalConsult,
|
||
'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): 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);
|
||
$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): array
|
||
{
|
||
$c = self::resolveYejiContext($params);
|
||
$startDate = $c['startDate'];
|
||
$endDate = $c['endDate'];
|
||
$startTs = $c['startTs'];
|
||
$endTs = $c['endTs'];
|
||
$channelCode = $c['channelCode'];
|
||
$channelInfo = $c['channelInfo'];
|
||
$tagFilterId = $c['tagFilterId'];
|
||
$primaryDeptIds = $c['primaryDeptIds'];
|
||
$deptNameMap = $c['deptNameMap'];
|
||
$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'];
|
||
$leadsByAdmin = self::countLeadsByAdmin($startTs, $endTs, $tagFilterId);
|
||
$consultsByAdmin = self::aggregateConsultsByAdmin($startTs, $endTs, null, null);
|
||
|
||
if ($channelFilterActive) {
|
||
self::excludeErCenterFromAdminLeaderboard($adminToPrimary, $deptNameMap, $feeByAdmin, $leadsByAdmin);
|
||
}
|
||
|
||
$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 ($primaryDeptIds as $deptId) {
|
||
$deptName = $deptNameMap[$deptId] ?? ('部门#' . $deptId);
|
||
$deptAbbr = self::deptDisplayAbbr($deptName);
|
||
$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);
|
||
if ($fee < 0.005 && $lead === 0 && $consult === 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,
|
||
'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,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 列出所有可选部门
|
||
*
|
||
* @return array<int, array{id:int, name:string, pid:int, path:string}>
|
||
*/
|
||
public static function deptOptions(): 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),
|
||
];
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* 默认 primary 部门:所有未删除、name 含「中心」、且 pid > 0 的;父子同存时去掉父
|
||
*
|
||
* @return array{0: int[], 1: array<int,string>}
|
||
*/
|
||
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;
|
||
}
|
||
|
||
$deptIds = [];
|
||
if (is_string($input) && $input !== '') {
|
||
$input = explode(',', $input);
|
||
}
|
||
if (is_array($input)) {
|
||
foreach ($input as $v) {
|
||
$iv = (int) $v;
|
||
if ($iv > 0) {
|
||
$deptIds[] = $iv;
|
||
}
|
||
}
|
||
$deptIds = array_values(array_unique($deptIds));
|
||
}
|
||
if ($deptIds === []) {
|
||
$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);
|
||
}
|
||
|
||
$names = [];
|
||
foreach ($deptIds as $id) {
|
||
$names[$id] = (string) ($byId[$id]['name'] ?? ('部门#' . $id));
|
||
}
|
||
|
||
return [$deptIds, $names];
|
||
}
|
||
|
||
/**
|
||
* 每个 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: 医助 (role_id=2) 在区间内已完成挂号 (status=3) 的全部接诊数(不限渠道)。用于「接诊诊单」列。
|
||
* - 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 区间起点(含),与 overview 中 start_date 00:00:00 一致
|
||
* @param int $endTs 区间终点(含),与 overview 中 end_date 23:59:59 一致
|
||
*
|
||
* @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 {
|
||
if ($adminToPrimary === []) {
|
||
return ['all' => [], 'scoped' => []];
|
||
}
|
||
$allByDept = self::aggregateConsults($startTs, $endTs, $adminToPrimary, null, null);
|
||
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 + 医助角色 (role_id=2) + 可选 tag 过滤
|
||
* 时间:TIMESTAMP(appointment_date, appointment_time) ∈ [startTs, endTs]
|
||
*
|
||
* @return array<int, int>
|
||
*/
|
||
private static function aggregateConsults(
|
||
int $startTs,
|
||
int $endTs,
|
||
array $adminToPrimary,
|
||
?array $tagDiagIds,
|
||
?array $tagAssistantIds
|
||
): array {
|
||
if ($tagDiagIds !== null && $tagDiagIds === []) {
|
||
return [];
|
||
}
|
||
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
|
||
return [];
|
||
}
|
||
|
||
$query = Db::name('doctor_appointment')
|
||
->alias('a')
|
||
->where('a.status', 3)
|
||
->where('a.assistant_id', '>', 0)
|
||
->field(['a.assistant_id', 'COUNT(*) AS cnt'])
|
||
->group('a.assistant_id');
|
||
|
||
self::applyAppointmentTimestampBetween($query, 'a', $startTs, $endTs);
|
||
|
||
$adminRoleTable = self::tableWithPrefix('admin_role');
|
||
$query->whereRaw(
|
||
"EXISTS (SELECT 1 FROM {$adminRoleTable} ar WHERE ar.admin_id = a.assistant_id AND ar.role_id = 2)"
|
||
);
|
||
|
||
if ($tagDiagIds !== null) {
|
||
$query->whereIn('a.patient_id', $tagDiagIds);
|
||
}
|
||
if ($tagAssistantIds !== null) {
|
||
$query->whereIn('a.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;
|
||
}
|
||
|
||
/**
|
||
* 接诊按医助汇总(不按部门归并)。
|
||
*
|
||
* @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 [];
|
||
}
|
||
|
||
$query = Db::name('doctor_appointment')
|
||
->alias('a')
|
||
->where('a.status', 3)
|
||
->where('a.assistant_id', '>', 0)
|
||
->field(['a.assistant_id', 'COUNT(*) AS cnt'])
|
||
->group('a.assistant_id');
|
||
|
||
self::applyAppointmentTimestampBetween($query, 'a', $startTs, $endTs);
|
||
|
||
$adminRoleTable = self::tableWithPrefix('admin_role');
|
||
$query->whereRaw(
|
||
"EXISTS (SELECT 1 FROM {$adminRoleTable} ar WHERE ar.admin_id = a.assistant_id AND ar.role_id = 2)"
|
||
);
|
||
|
||
if ($tagDiagIds !== null) {
|
||
$query->whereIn('a.patient_id', $tagDiagIds);
|
||
}
|
||
if ($tagAssistantIds !== null) {
|
||
$query->whereIn('a.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;
|
||
}
|
||
|
||
/**
|
||
* 进线按接待人(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];
|
||
}
|
||
|
||
/**
|
||
* 业绩按 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
|
||
): void {
|
||
foreach ($adminToPrimary as $aid => $pid) {
|
||
if (!self::isErCenterDisplayPrimaryName($deptNameMap[$pid] ?? '')) {
|
||
continue;
|
||
}
|
||
$feeByAdmin[$aid] = 0.0;
|
||
$leadsByAdmin[$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);
|
||
}
|
||
|
||
/**
|
||
* 挂号记录按「预约日期 + 预约时间」落入区间 [startTs, endTs](与页面 start_date~end_date 推导的整日边界一致)
|
||
*/
|
||
private static function applyAppointmentTimestampBetween($query, string $alias, int $startTs, int $endTs): void
|
||
{
|
||
$a0 = date('Y-m-d H:i:s', $startTs);
|
||
$a1 = date('Y-m-d H:i:s', $endTs);
|
||
$query->whereRaw(
|
||
"TIMESTAMP({$alias}.appointment_date, {$alias}.appointment_time) BETWEEN ? AND ?",
|
||
[$a0, $a1]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 业绩/成交单统计:仅排除履约已取消 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;
|
||
}
|
||
|
||
/**
|
||
* 将总投放成本按展示各部门「进线」条数比例分摊:部门成本 = 总成本 × (部门进线 / 进线合计)。
|
||
* 四舍五入尾差并入进线条数最多的部门。
|
||
*
|
||
* @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;
|
||
}
|
||
}
|