Files
zyt/server/app/adminapi/logic/stats/YejiStatsLogic.php
T

5963 lines
236 KiB
PHP
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\adminapi\logic\dept\DeptLogic;
use app\common\model\auth\AdminDept;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\PrescriptionOrder;
use app\common\service\DataScope\DataScopeService;
use app\common\service\qywx\MediaChannelService;
use think\db\Query;
use think\facade\Db;
/**
* 业绩看板(部门 × 时间区间 × 渠道来源)
*
* - 进线数据 = 区间内 add_external_contact 事件,按接待员工(user_id) 归属部门
* · 与 qywx.customer/todayArrival、客户列表同源事件表;选定渠道时客户须带对应企微标签(contact_tag);**二中心及其组织下级在选定渠道下进线计 0**
* - 被指派数 = 区间内 `tcm_diagnosis_assign_log` 且 **to_assistant_id>0**,且 **related_po_create_time>0** 并落在区间(**一律按快照字段统计**,与履约释放写入一致;诊单后台指派时会回填该字段,见 DiagnosisLogic::assign
* · **二中心及其组织下级在选定渠道下计 0**(与进线一致)
* - 复诊统计 = 仅**名称含「二中心」的部门及其组织架构下级**:区间内业务单按患者 create_time 排序,第 1 笔不计,第 2 笔起分列「复诊2」「复诊3」…;口径同业绩单条件;按**订单创建人**所在部门在组织树内 rollup(含下级),与排行榜复诊一致
* - 已完成挂号 = status=3 挂号条数(appointment_date),列「已完成挂号」;部门归属:有效医助→接诊医生→展示行映射。
* - 接诊诊单 = 列名对应**业务订单条数**(非取消、create_time 区间),按**订单创建人**人事部门落在该行子树即计入;多行命中时取**最深的展示部门行**。与处方列表按 assistant_dept_id 筛选不同(列表保留旧口径:诊单医助 ∪ 创建人)。
* - {渠道}成交单 = 选定渠道时与 {渠道}业绩同源:区间内 **非取消业务订单条数**(仅剔除 fulfillment_status=4NULL 与其它状态计入,按 create_time 归日);dict(channels) 与 tag 口径均按订单计(按订单创建人归属),非挂号条数。
* - 合计业绩 = 区间内业务订单 amount 求和(排除已取消(4) 与软删),**部门列**与「接诊诊单」一致:**订单创建人**人事部门落在该展示行子树则计入,多行命中取最深展示行;与排行榜诊金同口径。
* 无法归入任一展示部门的金额单列「未归属中心」行;**底栏「合计业绩」「接诊诊单」= 当前表内各行业绩列相加**(含未归属行),与弹窗摊分口径一致。
* · 时间窗与 stats_order_amount_performance 一致;列表若另有患者筛选或账号数据域收窄,金额可能仍不同
* · 选定渠道时「{渠道}业绩」列仍按比例展示;合计列全量与渠道列含未归属补差;**二中心及其组织下级展示部门不参与该渠道进线/业绩/成交单/ROI 分子**(与同模块 DeptLogic::getErCenterSubtreeDeptIdSet 一致)
* - {渠道}业绩 = 与「处方订单列表」业绩同口径(≠4):按 **o.create_time** 落入区间,按**订单创建人**归部门;
* 渠道判定仅用 EXISTS:"患者有任一已完成挂号 (status=3) 且 channels 命中字典",**不限挂号时点**;无字典映射时回退 tag/creator 口径
* - 成交单数 = 区间内非取消业务订单 **条数**;按**订单创建人**映射到「中心」;无法归属计入「未归属中心」;合计=全表 COUNT。
* - 投放成本 = zyt_account_cost 在日期区间内 **amount** 合计(选定渠道时 `media_channel_code = channel_code` 过滤)。
* · **各部门行投放成本** = 总投放成本 × (该部门进线 ÷ 各展示部门进线合计)。相当于先算 **单进线成本** = 总成本 ÷ 总进线,再乘部门进线。
* · **二中心及其组织下级**不参与均摊投放成本(进线分摊分母对其计 0,与选定渠道下进线清零规则独立);其余部门按进线占比分摊。合计行成本 = 账务汇总总额。
* · ROI = (渠道业绩合计或全量业绩合计,**均不含二中心子树各部门的对应业绩**)÷ 总投放成本(成本为 0 时返回 null)
* - ROI = **不含二中心子树**后的 **{渠道}业绩合计**(或未选渠道时为全量业绩口径下同一套「部门行之和」)÷ 投放成本;部门行在四舍五入前若分摊成本为 0 则无 ROI
*
* 「业绩/接诊标签匹配」:
* 优先 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);
$erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet();
$assignsByDept = self::countDiagnosisAssignsByDept(
$startTs,
$endTs,
$adminToPrimary,
$tagDiagIds,
$tagFallback ? $tagAssistantIds : null,
$appointmentChannelValues,
$channelFilterActive,
$dataScopeAdminIds,
$channelInfo
);
// 接诊:
// - consults(全量) → "接诊诊单" 列;不限渠道
// - channelConsults(渠道) → 「{渠道}成交单」列;未选渠道时与 consults 相同
$consultsBoth = self::countConsults(
$startTs,
$endTs,
$adminToPrimary,
$tagDiagIds,
$tagAssistantIds,
$appointmentChannelValues,
$channelFilterActive,
$dataScopeAdminIds,
$tableRowDeptIds,
$deptById,
$channelInfo
);
$consults = $consultsBoth['all'];
$channelConsults = $consultsBoth['scoped'];
$bookedByDept = self::aggregateBookedAppointmentsByDept(
$startTs,
$endTs,
$adminToPrimary,
$tagDiagIds,
$tagAssistantIds,
$dataScopeAdminIds
);
$listAlignedPack = ($tableRowDeptIds !== [] && $deptById !== [])
? self::aggregateListAlignedOrderMetricsByDept(
$startTs,
$endTs,
$adminToPrimary,
null,
null,
$tableRowDeptIds,
$deptById
)
: null;
// 业绩:部门行「合计业绩」与列表部门摊行一致;「{渠道}业绩」同列表摊行 + 渠道 EXISTS(与抽屉一致)
$perfBoth = self::sumPerformance(
$startTs,
$endTs,
$adminToPrimary,
$tagDiagIds,
$tagFallback ? $tagAssistantIds : null,
$appointmentChannelValues,
$channelFilterActive,
$tableRowDeptIds,
$deptById,
$listAlignedPack !== null ? $listAlignedPack['amount'] : null,
$channelInfo
);
$performance = $perfBoth['all'];
$channelPerformance = $perfBoth['scoped'];
/** 二中心子树展示行「计入 ROI 分子的业绩」合计({渠道}业绩或与合计业绩相同),供未选渠道时从全表分子里扣除 */
$sumCompletedPerfErCenter = 0.0;
foreach ($tableRowDeptIds as $erDeptId) {
if (!isset($erCenterDeptSet[(int) $erDeptId])) {
continue;
}
$sumCompletedPerfErCenter += round((float) ($channelPerformance[$erDeptId] ?? 0), 1);
}
// 成交单数(全量):与合计业绩列同列表部门口径
$dealOrderByDept = $listAlignedPack !== null
? $listAlignedPack['count']
: self::aggregateDealOrderCount(
$startTs,
$endTs,
$adminToPrimary,
null,
null,
$tableRowDeptIds,
$deptById
);
$globalDealOrderCount = self::countGlobalDealOrders($startTs, $endTs);
/** 选定渠道时从全量渠道 SUM 与部门行中同步扣减二中心子树部门的渠道业绩/成交单;进线、被指派同清零 */
$erCenterChannelStrip = 0.0;
if ($channelCode !== '') {
$erCenterChannelStrip = self::excludeErCenterSubtreeFromChannelMetrics(
$tableRowDeptIds,
$erCenterDeptSet,
$channelPerformance,
$channelConsults
);
}
$globalCost = round((float) self::sumGlobalCost($startDate, $endDate, $channelCode), 1);
$leadsForCost = self::mergeHiddenPrimaryLeadsForCostAllocation(
$primaryDeptIds,
$tableRowDeptIds,
$leads,
$deptToDescendants
);
self::zeroIntKeyedDeptMetricsInErCenterSubtree($leadsForCost, $erCenterDeptSet);
$costs = self::allocateCostByLeads($globalCost, $tableRowDeptIds, $leadsForCost);
if ($channelCode !== '') {
self::zeroIntKeyedDeptMetricsInErCenterSubtree($leads, $erCenterDeptSet);
self::zeroIntKeyedDeptMetricsInErCenterSubtree($assignsByDept, $erCenterDeptSet);
}
@set_time_limit(120);
$revisitPack = DeptLogic::getErCenterRevisitRollupIndexedByDept($startTs, $endTs);
$revisitTotals = $revisitPack['totals'];
$revisitSlotsByDept = $revisitPack['slots'];
// ── 5) 组装每行 ──────────────────────────────────────────────
$rows = [];
$totalLead = 0;
$totalAssign = 0;
$totalRevisit = 0;
/** @var array<int, int> $totalRevisitSlots */
$totalRevisitSlots = [];
$totalChannelConsult = 0;
$totalPerf = 0.0;
$totalChannelPerf = 0.0;
$totalCost = 0.0;
$unmappedConsult = (int) ($consults[0] ?? 0);
$consultSumInTable = 0;
$bookedSumInTable = 0;
foreach ($tableRowDeptIds as $deptId) {
$lead = (int) ($leads[$deptId] ?? 0);
$cnt = (int) ($consults[$deptId] ?? 0);
$consultSumInTable += $cnt;
$bookedSumInTable += (int) ($bookedByDept[$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;
$dDeal = (int) ($dealOrderByDept[$deptId] ?? 0);
$assignCnt = (int) ($assignsByDept[$deptId] ?? 0);
$rv = (int) ($revisitTotals[$deptId] ?? 0);
$rvSlots = $revisitSlotsByDept[$deptId] ?? [];
$rows[] = [
'dept_id' => $deptId,
'dept_name' => self::formatYejiDeptRowDisplayName($deptId, $deptById),
'lead_count' => $lead,
'assign_count' => $assignCnt,
'revisit_count' => $rv,
'revisit_slots' => $rvSlots,
'appointment_booked_count' => (int) ($bookedByDept[$deptId] ?? 0),
'consult_count' => $cnt,
'deal_order_count' => $dDeal,
// {渠道}成交单:选定渠道时与 completed_performance 同源;未选渠道时与 consult_count 相同
'channel_consult_count' => $cCnt,
'performance_amount' => $perf,
'completed_performance_amount' => $cperf,
'avg_price' => $cnt > 0 ? round($perf / $cnt, 1) : null,
// {渠道}客单价 = {渠道}业绩 / {渠道}成交单
'channel_avg_price' => $cCnt > 0 ? round($cperf / $cCnt, 1) : null,
'cost_amount' => round($rowCost, 1),
'roi' => $rowRoi,
];
$totalLead += $lead;
$totalAssign += $assignCnt;
$totalRevisit += $rv;
foreach ($rvSlots as $slot => $cntRv) {
$slot = (int) $slot;
$totalRevisitSlots[$slot] = ($totalRevisitSlots[$slot] ?? 0) + (int) $cntRv;
}
$totalChannelConsult += $cCnt;
$totalPerf += $perf;
$totalChannelPerf += $cperf;
// 不再用各行成本叠加(避免父级未在展示列时丢失),合计直接用 $globalCost
}
$allocatedPerf = round($totalPerf, 1);
$allocatedChannelPerf = round($totalChannelPerf, 1);
$globalPerfAll = self::sumGlobalPerformanceAmount($startTs, $endTs);
$globalPerfChannel = $globalPerfAll;
if ($channelFilterActive) {
$globalPerfChannel = self::sumGlobalChannelScopedPerformance(
$startTs,
$endTs,
$appointmentChannelValues,
$tagDiagIds,
$tagFallback ? $tagAssistantIds : null,
$channelInfo
);
$globalPerfChannel = round(max(0.0, $globalPerfChannel - $erCenterChannelStrip), 1);
}
$allocatedDealOrderSum = 0;
foreach ($tableRowDeptIds as $rid) {
$allocatedDealOrderSum += (int) ($dealOrderByDept[$rid] ?? 0);
}
$totalBooked = $narrowTotalsToTable ? $bookedSumInTable : array_sum($bookedByDept);
if ($narrowTotalsToTable) {
$totalConsult = $consultSumInTable;
$unmappedDealOrder = 0;
$unassignedPerf = 0.0;
$unassignedChannelPerf = 0.0;
$showUnassignedRow = false;
} else {
$totalConsult = array_sum($consults);
$unmappedDealOrder = max(0, $globalDealOrderCount - $allocatedDealOrderSum);
$unassignedPerf = round($globalPerfAll - $allocatedPerf, 1);
$unassignedChannelPerf = round($globalPerfChannel - $allocatedChannelPerf, 1);
$showUnassignedRow = abs($unassignedPerf) >= 0.01 || abs($unassignedChannelPerf) >= 0.01 || $unmappedConsult > 0 || $unmappedDealOrder > 0;
}
if ($showUnassignedRow) {
$uPerf = $unassignedPerf;
$uCperf = $channelFilterActive ? $unassignedChannelPerf : $uPerf;
$rows[] = [
'dept_id' => 0,
'dept_name' => '未归属中心',
'lead_count' => 0,
'assign_count' => 0,
'revisit_count' => 0,
'revisit_slots' => [],
'appointment_booked_count' => (int) ($bookedByDept[0] ?? 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,
];
}
// 底栏「合计业绩」「{渠道}业绩」「接诊诊单」= 当前表格全部数据行相加(含「未归属中心」),
// 与各行展示口径一致;不再使用全表 SUM(会与仅含展示部门的摊分行脱钩)。
$sumPerfRows = 0.0;
$sumChannelPerfRows = 0.0;
$sumDealRows = 0;
foreach ($rows as $rw) {
$sumPerfRows += (float) ($rw['performance_amount'] ?? 0);
$sumChannelPerfRows += (float) ($rw['completed_performance_amount'] ?? 0);
$sumDealRows += (int) ($rw['deal_order_count'] ?? 0);
}
$totalPerf = round($sumPerfRows, 1);
$totalChannelPerf = round($sumChannelPerfRows, 1);
$totalDealOrderCount = $sumDealRows;
$totalCost = round($globalCost, 1);
// ROI 合计:选渠道时底栏「{渠道}业绩合计」已扣除二中心子树($erCenterChannelStrip);未选渠道时用全表业绩并减去各二中心子树展示行之和
$totalRoiNumerator = round(
max(
0.0,
$channelFilterActive
? $totalChannelPerf
: ($totalChannelPerf - $sumCompletedPerfErCenter)
),
1
);
if ($totalRevisitSlots !== []) {
ksort($totalRevisitSlots, SORT_NUMERIC);
}
$scopedTableNote = ($explicitDeptFilter
? ' 已筛选展示部门时,表格仅展示所选树中的部门行;底栏「合计业绩」「接诊诊单」与当前表内各行业绩列相加一致(含「未归属中心」行若展示)。'
: '')
. ($dataScopeRestricted
? ' 当前账号受数据范围限制时,底栏合计业绩与列表同域。'
: '');
$baseNoteNoChannel = '业绩口径:除履约已取消(4)外全部计入。部门行「合计业绩」「接诊诊单」「复诊」均按**订单创建人人事部门 ∈ 该展示行子树**归属(多行命中取最深展示行),与医助排行榜诊金一致;与处方订单列表 assistant_dept_id 默认筛选(诊单医助 ∪ 创建人)不同——侧栏点击会自动收窄为创建人口径。多表「本月」区间为当月1日至服务器当日。**投放成本**按进线占比分摊,**二中心子树展示部门进线在分摊中计 0**(与部门 ErCenter 子树一致)。';
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)的订单条数(非挂号条数),按订单创建人归部门。'
. ' 「已完成挂号」列同 admin 挂号已完成列表;「接诊诊单」列为业务订单条数(非取消),同样按订单创建人归部门。**二中心及其组织下级**(与部门模块 ErCenter 子树一致)**不计入**该渠道进线、业绩与成交单。**投放成本**按各部门进线条数占展示部门进线合计的比例分摊,**二中心子树进线在分摊中计 0**(未选渠道时仍显示进线数,仅不参与均摊)。ROI = 渠道业绩(不含二中心子树)÷ 总投放成本。进线按所选标签客户精确过滤。'
. ($tagFallback
? ' ⚠ 当前业务尚未回填 tcm_diagnosis.external_userid 与 user_auth.unionid,已自动退化为「该标签客户的跟进医助 → 区间内接诊/业绩」口径,仅用于趋势观察。'
: '')
. $scopedTableNote)
: ($baseNoteNoChannel . $scopedTableNote),
'rows' => $rows,
'total' => [
'lead_count' => $totalLead,
'assign_count' => $totalAssign,
'revisit_count' => $totalRevisit,
'revisit_slots' => $totalRevisitSlots,
'appointment_booked_count' => $totalBooked,
'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;
}
/**
* 医助排行榜:按展示部门分表,每表内按诊金降序排名。
* 诊金口径:无渠道=业绩归属医助(与列表医助筛选一致);有渠道={渠道}业绩/dict;二中心在选渠道下注 0。
* 被指派数 = 区间内诊单指派日志(to_assistant_id>0)按**被指派医助**计数,与部门表「被指派数」同收窄/二中心规则。
* 接诊率 = 排行用诊金 ÷ 进线条数(元/进线,1 位小数;进线为 0 时为 0)。
* 复诊(属二中心组织架构的展示部门):同一诊单下业务单按 create_time 排序第 2 笔起分项,与部门业绩表「二中心复诊」同源;前端点击数字可下钻对应业务订单。
*
* @param array{start_date?:string,end_date?:string,dept_ids?:int[]|string,channel_code?:string,tag_id?:string} $params
*
* @return array<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'];
$dataScopeRestricted = !empty($c['dataScopeRestricted']);
$dataScopeAdminIds = $dataScopeRestricted && isset($c['dataScopeVisibleAdminIds'])
? $c['dataScopeVisibleAdminIds']
: null;
$channelName = $channelInfo ? (string) $channelInfo['channel_name'] : '';
if ($adminToPrimary === []) {
return [
'start_date' => $startDate,
'end_date' => $endDate,
'channel_code' => $channelCode,
'channel_name' => $channelName,
'channel_filter_note' => '',
'range_note' => '',
'leaderboards' => [],
];
}
$scopedAssistants = $tagFallback ? $tagAssistantIds : null;
$perfBoth = self::sumPerformanceByAdmin(
$startTs,
$endTs,
$tagDiagIds,
$scopedAssistants,
$appointmentChannelValues,
$channelFilterActive,
$channelInfo
);
$feeByAdmin = $channelFilterActive ? $perfBoth['scoped'] : $perfBoth['all'];
$dealBoth = self::sumDealOrderByAdmin(
$startTs,
$endTs,
$tagDiagIds,
$scopedAssistants,
$appointmentChannelValues,
$channelFilterActive,
$channelInfo
);
$dealOrderByAdmin = $channelFilterActive ? $dealBoth['scoped'] : $dealBoth['all'];
$leadsByAdmin = self::countLeadsByAdmin($startTs, $endTs, $tagFilterId);
/** 与部门表接诊同口径;标签/医助穿透时与业绩同源收窄,避免榜上证务与接诊脱节 */
$consultsByAdmin = self::aggregateConsultsByAdmin($startTs, $endTs, $tagDiagIds, $scopedAssistants);
$assignsByAdmin = self::countDiagnosisAssignsByAdmin(
$startTs,
$endTs,
$tagDiagIds,
$tagFallback ? $tagAssistantIds : null,
$appointmentChannelValues,
$channelFilterActive,
$dataScopeAdminIds,
$channelInfo
);
$appointmentByAdmin = self::countBookedAppointmentsByAdminForLeaderboard(
$startTs,
$endTs,
$tagDiagIds,
$tagFallback ? $tagAssistantIds : null,
$dataScopeAdminIds
);
if ($channelFilterActive) {
$erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet();
self::excludeErCenterFromAdminLeaderboard(
$adminToPrimary,
$erCenterDeptSet,
$feeByAdmin,
$leadsByAdmin,
$dealOrderByAdmin,
$assignsByAdmin
);
}
$erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet();
/** 医助排行榜复诊:按订单创建人归属(与诊金 / 接诊诊单同口径),不再用 dg.assistant_id */
$revisitByAssistant = DeptLogic::getErCenterRevisitCountsByCreator($startTs, $endTs);
$assistantIdRows = Db::name('admin_role')->where('role_id', 2)->column('admin_id');
$assistantSet = [];
foreach ($assistantIdRows as $rid) {
$assistantSet[(int) $rid] = true;
}
$nameIds = [];
foreach (array_keys($assistantSet) as $aid) {
if (isset($adminToPrimary[$aid])) {
$nameIds[] = $aid;
}
}
$adminNames = [];
if ($nameIds !== []) {
$nameRows = Db::name('admin')
->whereIn('id', $nameIds)
->whereNull('delete_time')
->column('name', 'id');
foreach ($nameRows as $id => $name) {
$adminNames[(int) $id] = (string) $name;
}
}
$rangeNote = $channelFilterActive
? ('排行诊金 = 选定渠道「' . $channelName . '」业绩中「订单创建人 = 该医助」的部分;与部门表该渠道业绩列同口径(按订单创建人归属)。被指派数与部门表同口径。接诊率 = 诊金 ÷ 进线(元/进线)。'
. '「二中心」及其组织下级医助在该渠道下诊金、进线与被指派计 0。'
. ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。')
: '排行诊金 = 业务订单中「订单创建人 = 该医助」的金额合计;与部门表「合计业绩」同口径(按订单创建人归属)。被指派数为指派至该医助的次数。接诊率 = 诊金 ÷ 进线(元/进线;进线为 0 时为 0)。'
. ' 预约诊单:预约表按「预约日期」落在区间内、有效医助口径同接诊,状态含已预约/已完成/已过号(不含已取消);科室下医助全量展示。';
$leaderboards = [];
foreach ($tableRowDeptIds as $deptId) {
$leafName = $deptNameMap[$deptId] ?? ('部门#' . $deptId);
$deptName = self::formatYejiDeptRowDisplayName($deptId, $deptById);
$deptAbbr = self::deptDisplayAbbr($leafName);
$isErCenterLb = isset($erCenterDeptSet[$deptId]);
$rows = [];
foreach (array_keys($assistantSet) as $aid) {
if (($adminToPrimary[$aid] ?? 0) !== $deptId) {
continue;
}
$fee = round((float) ($feeByAdmin[$aid] ?? 0.0), 1);
$lead = (int) ($leadsByAdmin[$aid] ?? 0);
$consult = (int) ($consultsByAdmin[$aid] ?? 0);
$dealOrders = (int) ($dealOrderByAdmin[$aid] ?? 0);
$assignCnt = (int) ($assignsByAdmin[$aid] ?? 0);
$apptCnt = (int) ($appointmentByAdmin[$aid] ?? 0);
$rv = $isErCenterLb ? (int) ($revisitByAssistant['totals'][$aid] ?? 0) : 0;
$rvSlots = $isErCenterLb ? ($revisitByAssistant['slots'][$aid] ?? []) : [];
$rawFee = (float) ($feeByAdmin[$aid] ?? 0.0);
$rate = $lead > 0 ? round($rawFee / $lead, 1) : 0.0;
$row = [
'admin_id' => $aid,
'name' => $adminNames[$aid] ?? ('#' . $aid),
'fee_amount' => $fee,
'lead_count' => $lead,
'assign_count' => $assignCnt,
'consult_count' => $consult,
'deal_order_count' => $dealOrders,
'appointment_count' => $apptCnt,
'consult_rate' => $rate,
'dept_abbr' => $deptAbbr,
];
if ($isErCenterLb) {
$row['revisit_count'] = $rv;
$row['revisit_slots'] = $rvSlots;
}
$rows[] = $row;
}
usort($rows, static function (array $a, array $b): int {
if ($a['fee_amount'] != $b['fee_amount']) {
return $b['fee_amount'] <=> $a['fee_amount'];
}
return $b['consult_rate'] <=> $a['consult_rate'];
});
foreach ($rows as $i => $row) {
$rows[$i]['rank'] = $i + 1;
}
$leaderboards[] = [
'dept_id' => $deptId,
'dept_name' => $deptName,
'dept_abbr' => $deptAbbr,
'er_center_subtree' => $isErCenterLb,
'rows' => $rows,
];
}
return [
'start_date' => $startDate,
'end_date' => $endDate,
'channel_code' => $channelCode,
'channel_name' => $channelName,
'channel_filter_note' => $channelCode !== ''
? '医助榜:**二中心及其组织下级**在选定渠道下诊金、进线、被指派数计 0,与部门看板一致。'
: '',
'range_note' => $rangeNote,
'leaderboards' => $leaderboards,
];
}
/**
* 数据范围下:可见成员所在部门及其下级部门 id(用于部门下拉收窄)。
*
* @return array<int, true>|null null 表示不限制,[] 表示无可选
*/
private static function buildYejiDataScopeAllowedDeptIdMap(int $adminId, array $adminInfo): ?array
{
return DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
}
/**
* 列出所有可选部门
*
* @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 分组),同时附带客户/成本统计便于排序展示
*
* @param bool $includeTagCustomerCounts 为 false 时跳过标签客户数统计(qywx_external_contact_tag 大表聚合较慢);提成结算等仅需筛选项时可关闭以提速。
*
* @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(bool $includeTagCustomerCounts = true): 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_* 渠道带上「打了该标签的客户数」(qywx_external_contact_tag 数据量大时聚合较慢,可按需关闭)
$tagToCount = [];
if ($includeTagCustomerCounts) {
$tagIds = [];
foreach ($rows as $r) {
$code = (string) $r['channel_code'];
if (str_starts_with($code, 'tag_')) {
$tagIds[] = substr($code, 4);
}
}
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;
}
/**
* 业绩/成交单按「表格展示部门行」归类:先自 admin 的部门链向上命中第一个 $tableRowDeptIds
* 否则再向下取「位于该部门子树内的首个表格行」(与勾选父部门时隐藏父行、业绩需落到可见子行一致)。
*
* @param int[] $adminIds
* @param int[] $tableRowDeptIds
* @param array<int, array<string, mixed>> $deptById
*
* @return array<int, int> admin_id => table_row_dept_id
*/
private static function batchMapPerformanceAdminsToTableDept(array $adminIds, array $tableRowDeptIds, array $deptById): array
{
$adminIds = array_values(array_unique(array_filter(array_map('intval', $adminIds), static fn (int $id): bool => $id > 0)));
if ($adminIds === [] || $tableRowDeptIds === []) {
return [];
}
$tableRowSet = array_flip(array_map('intval', $tableRowDeptIds));
$links = Db::name('admin_dept')
->whereIn('admin_id', $adminIds)
->field(['admin_id', 'dept_id'])
->select()
->toArray();
$byAdmin = [];
foreach ($links as $l) {
$aid = (int) $l['admin_id'];
$did = (int) $l['dept_id'];
if ($aid > 0 && $did > 0) {
$byAdmin[$aid][$did] = true;
}
}
$out = [];
foreach ($adminIds as $aid) {
$deptIds = array_keys($byAdmin[$aid] ?? []);
if ($deptIds === []) {
continue;
}
$mapped = self::mapAdminDeptLinksToTableRow($deptIds, $tableRowDeptIds, $tableRowSet, $deptById);
if ($mapped > 0) {
$out[$aid] = $mapped;
}
}
return $out;
}
/**
* @param int[] $deptIdsForAdmin
* @param array<int, true> $tableRowSet
* @param array<int, array<string, mixed>> $deptById
*/
private static function mapAdminDeptLinksToTableRow(array $deptIdsForAdmin, array $tableRowDeptIds, array $tableRowSet, array $deptById): int
{
$best = 0;
$bestDepth = -1;
foreach ($deptIdsForAdmin as $d) {
$d = (int) $d;
if ($d <= 0) {
continue;
}
$hit = self::resolveDeptToNearestTableRowUpward($d, $tableRowSet, $deptById);
if ($hit > 0) {
$depth = self::deptDepthFromRoot($hit, $deptById);
if ($depth > $bestDepth || ($depth === $bestDepth && ($best === 0 || $hit < $best))) {
$best = $hit;
$bestDepth = $depth;
}
}
}
if ($best > 0) {
return $best;
}
foreach ($deptIdsForAdmin as $d) {
$d = (int) $d;
if ($d <= 0) {
continue;
}
$hit = self::resolveFirstTableRowUnderDept($d, $tableRowDeptIds, $deptById);
if ($hit > 0) {
return $hit;
}
}
return 0;
}
private static function deptDepthFromRoot(int $deptId, array $deptById): int
{
$n = 0;
$cur = $deptId;
for ($i = 0; $i < 50 && $cur > 0; $i++) {
$n++;
$cur = (int) ($deptById[$cur]['pid'] ?? 0);
}
return $n;
}
/**
* @param array<int, true> $tableRowSet
* @param array<int, array<string, mixed>> $deptById
*/
private static function resolveDeptToNearestTableRowUpward(int $deptId, array $tableRowSet, array $deptById): int
{
$cur = $deptId;
for ($i = 0; $i < 50 && $cur > 0; $i++) {
if (isset($tableRowSet[$cur])) {
return $cur;
}
$cur = (int) ($deptById[$cur]['pid'] ?? 0);
}
return 0;
}
/**
* $ancestorId 是否为 $deptId 的祖先(含自身相等)。
*
* @param array<int, array<string, mixed>> $deptById
*/
private static function deptIsAncestorOf(int $ancestorId, int $deptId, array $deptById): bool
{
$cur = $deptId;
for ($i = 0; $i < 50 && $cur > 0; $i++) {
if ($cur === $ancestorId) {
return true;
}
$cur = (int) ($deptById[$cur]['pid'] ?? 0);
}
return false;
}
/**
* @param int[] $tableRowDeptIds
* @param array<int, array<string, mixed>> $deptById
*/
private static function resolveFirstTableRowUnderDept(int $adminDeptId, array $tableRowDeptIds, array $deptById): int
{
foreach ($tableRowDeptIds as $tid) {
$tid = (int) $tid;
if ($tid > 0 && self::deptIsAncestorOf($adminDeptId, $tid, $deptById)) {
return $tid;
}
}
return 0;
}
/**
* 将聚合结果行映射到展示部门:优先 batchMap(父部门仅挂在表格外时仍能落到子行),否则退回 adminToPrimary。
*
* @param array<int, array<string, mixed>> $rows query 结果,含 assistant_id
* @param array<int, int> $adminToPrimary
* @param int[] $tableRowDeptIds
* @param array<int, array<string, mixed>> $deptById
* @param string $amtKey 数值字段名(金额 amt 或条数 cnt)
*
* @return array<int, float|int>
*/
private static function foldPerformanceRowsToDeptByTable(
array $rows,
array $adminToPrimary,
array $tableRowDeptIds,
array $deptById,
string $amtKey
): array {
if ($rows === []) {
return [];
}
$aids = [];
foreach ($rows as $r) {
$aid = (int) ($r['assistant_id'] ?? 0);
if ($aid > 0) {
$aids[] = $aid;
}
}
$adminToTable = ($tableRowDeptIds !== [] && $deptById !== [])
? self::batchMapPerformanceAdminsToTableDept($aids, $tableRowDeptIds, $deptById)
: [];
$byDept = [];
foreach ($rows as $r) {
$aid = (int) ($r['assistant_id'] ?? 0);
if ($aid <= 0) {
continue;
}
$primary = (int) ($adminToTable[$aid] ?? 0);
if ($primary <= 0) {
$primary = (int) ($adminToPrimary[$aid] ?? 0);
}
if ($primary <= 0) {
continue;
}
$byDept[$primary] = ($byDept[$primary] ?? 0) + ($amtKey === 'cnt' ? (int) ($r['cnt'] ?? 0) : (float) ($r['amt'] ?? 0));
}
return $byDept;
}
/**
* 接诊挂号 aggregateConsults 结果行 → 表格展示部门:规则同业绩 fold(优先 batchMap 到 tableRow,再 adminToPrimary;先有效医助再接诊医生)。
*
* @param array<int, array<string, mixed>> $rows eff_a, doctor_id, cnt
*
* @return array<int, int>
*/
private static function foldConsultCountRowsToDeptByTable(
array $rows,
array $adminToPrimary,
array $tableRowDeptIds,
array $deptById,
?array $dataScopeAdminIds
): array {
if ($rows === []) {
return [];
}
$visFlip = $dataScopeAdminIds !== null && $dataScopeAdminIds !== []
? array_flip($dataScopeAdminIds)
: null;
$aids = [];
foreach ($rows as $r) {
$e = (int) ($r['eff_a'] ?? 0);
$d = (int) ($r['doctor_id'] ?? 0);
if ($e > 0) {
$aids[] = $e;
}
if ($d > 0) {
$aids[] = $d;
}
}
$adminToTable = ($tableRowDeptIds !== [] && $deptById !== [])
? self::batchMapPerformanceAdminsToTableDept($aids, $tableRowDeptIds, $deptById)
: [];
$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;
}
if ($visFlip !== null) {
if ($effId > 0) {
if (!isset($visFlip[$effId])) {
continue;
}
} elseif ($docId > 0) {
if (!isset($visFlip[$docId])) {
continue;
}
} else {
continue;
}
}
$primary = 0;
foreach ([$effId, $docId] as $aid) {
if ($aid <= 0) {
continue;
}
$p = (int) ($adminToTable[$aid] ?? 0);
if ($p <= 0) {
$p = (int) ($adminToPrimary[$aid] ?? 0);
}
if ($p > 0) {
$primary = $p;
break;
}
}
if ($primary > 0) {
$byDept[$primary] = ($byDept[$primary] ?? 0) + $cnt;
} else {
$byDept[0] = ($byDept[0] ?? 0) + $cnt;
}
}
return $byDept;
}
/**
* 进线数据: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;
}
/**
* 被指派数:统计 `tcm_diagnosis_assign_log`to_assistant_id>0),**仅**按 **related_po_create_time** 落入区间(须 >0)。
* 选定字典渠道时 INNER JOIN 诊单以满足挂号 channels EXISTS;tag 退化口径只过滤日志字段,不强制挂诊单。
*
* @param array<int, int> $adminToPrimary
* @param int[]|null $tagDiagIds
* @param array<int, int>|null $tagAssistantIds tag 退化口径下按 to_assistant_id 过滤(与 aggregatePerformance 一致)
*
* @return array<int, int>
*/
private static function countDiagnosisAssignsByDept(
int $startTs,
int $endTs,
array $adminToPrimary,
?array $tagDiagIds,
?array $tagAssistantIds,
array $appointmentChannelValues,
bool $channelFilterActive,
?array $dataScopeAdminIds = null,
?array $channelInfo = null
): array {
if ($adminToPrimary === []) {
return [];
}
$query = Db::name('tcm_diagnosis_assign_log')
->alias('lg')
->where('lg.related_po_create_time', '>', 0)
->where('lg.related_po_create_time', 'between', [$startTs, $endTs])
->where('lg.to_assistant_id', '>', 0);
if ($channelFilterActive) {
if ($appointmentChannelValues !== []) {
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
if ($norm === []) {
return [];
}
$pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('dg.id', $norm, $channelInfo);
if ($pack === null) {
return [];
}
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$query->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER');
$query->whereRaw($pack[0], $pack[1]);
} elseif ($tagDiagIds !== null || $tagAssistantIds !== null) {
if ($tagDiagIds !== null && $tagDiagIds === []) {
return [];
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return [];
}
if ($tagDiagIds !== null) {
$query->whereIn('lg.diagnosis_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$query->whereIn('lg.to_assistant_id', array_map('intval', array_keys($tagAssistantIds)));
}
} else {
return [];
}
}
$query->field(['lg.to_assistant_id', Db::raw('COUNT(*) AS cnt')])
->group('lg.to_assistant_id');
$rows = $query->select()->toArray();
if ($rows === []) {
return [];
}
$visFlip = $dataScopeAdminIds !== null && $dataScopeAdminIds !== []
? array_flip($dataScopeAdminIds)
: null;
$byDept = [];
foreach ($rows as $r) {
$aid = (int) ($r['to_assistant_id'] ?? 0);
if ($aid <= 0) {
continue;
}
if ($visFlip !== null && !isset($visFlip[$aid])) {
continue;
}
$primary = (int) ($adminToPrimary[$aid] ?? 0);
if ($primary <= 0) {
continue;
}
$byDept[$primary] = ($byDept[$primary] ?? 0) + (int) ($r['cnt'] ?? 0);
}
return $byDept;
}
/**
* 医助维度被指派次数:与 countDiagnosisAssignsByDept 同过滤与区间,按 to_assistant_id 汇总(供排行榜)。
*
* @return array<int, int>
*/
private static function countDiagnosisAssignsByAdmin(
int $startTs,
int $endTs,
?array $tagDiagIds,
?array $tagAssistantIds,
array $appointmentChannelValues,
bool $channelFilterActive,
?array $dataScopeAdminIds = null,
?array $channelInfo = null
): array {
$query = Db::name('tcm_diagnosis_assign_log')
->alias('lg')
->where('lg.related_po_create_time', '>', 0)
->where('lg.related_po_create_time', 'between', [$startTs, $endTs])
->where('lg.to_assistant_id', '>', 0);
if ($channelFilterActive) {
if ($appointmentChannelValues !== []) {
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
if ($norm === []) {
return [];
}
$pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('dg.id', $norm, $channelInfo);
if ($pack === null) {
return [];
}
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$query->join("{$diagTable} dg", 'dg.id = lg.diagnosis_id AND dg.delete_time IS NULL', 'INNER');
$query->whereRaw($pack[0], $pack[1]);
} elseif ($tagDiagIds !== null || $tagAssistantIds !== null) {
if ($tagDiagIds !== null && $tagDiagIds === []) {
return [];
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return [];
}
if ($tagDiagIds !== null) {
$query->whereIn('lg.diagnosis_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$query->whereIn('lg.to_assistant_id', array_map('intval', array_keys($tagAssistantIds)));
}
} else {
return [];
}
}
$query->field(['lg.to_assistant_id', Db::raw('COUNT(*) AS cnt')])
->group('lg.to_assistant_id');
$rows = $query->select()->toArray();
if ($rows === []) {
return [];
}
$visFlip = $dataScopeAdminIds !== null && $dataScopeAdminIds !== []
? array_flip($dataScopeAdminIds)
: null;
$byAdmin = [];
foreach ($rows as $r) {
$aid = (int) ($r['to_assistant_id'] ?? 0);
if ($aid <= 0) {
continue;
}
if ($visFlip !== null && !isset($visFlip[$aid])) {
continue;
}
$byAdmin[$aid] = ($byAdmin[$aid] ?? 0) + (int) ($r['cnt'] ?? 0);
}
return $byAdmin;
}
/**
* 接诊诊单聚合:返回两组按部门归属的接诊计数。
* - all: 区间内 status=3 挂号条数按部门归属(与列表计数一致),不含「仅 role_id=2」等额外限制。
* - scoped: 选定渠道时「{渠道}成交单」= **非取消业务订单条数**(与 {渠道}业绩同源):dict 下 EXISTS 与侧栏一致(channels channel_source);有展示行时按列表部门摊行;否则走归因折叠。
* 未选渠道时 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 $tableRowDeptIds = [],
array $deptById = [],
?array $channelInfo = null
): array {
if ($adminToPrimary === []) {
return ['all' => [], 'scoped' => []];
}
$allByDept = self::aggregateConsults(
$startTs,
$endTs,
$adminToPrimary,
null,
null,
$dataScopeAdminIds,
$tableRowDeptIds,
$deptById
);
if (!$channelFilterActive) {
return ['all' => $allByDept, 'scoped' => $allByDept];
}
if ($appointmentChannelValues !== []) {
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
if ($norm === []) {
$scopedByDept = [];
} else {
$pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('o.diagnosis_id', $norm, $channelInfo);
if ($pack === null) {
$scopedByDept = [];
} elseif ($tableRowDeptIds !== [] && $deptById !== []) {
$scopedAligned = self::aggregateListAlignedOrderMetricsByDept(
$startTs,
$endTs,
$adminToPrimary,
null,
null,
$tableRowDeptIds,
$deptById,
$pack[0],
$pack[1]
);
$scopedByDept = $scopedAligned['count'];
} else {
$scopedByDept = self::aggregateDealOrderCountByAppointmentChannels(
$startTs,
$endTs,
$adminToPrimary,
$norm,
$tableRowDeptIds,
$deptById,
$channelInfo
);
}
}
} elseif ($tagDiagIds !== null || $tagAssistantIds !== null) {
$scopedByDept = self::aggregateDealOrderCount(
$startTs,
$endTs,
$adminToPrimary,
$tagDiagIds,
$tagAssistantIds,
$tableRowDeptIds,
$deptById
);
} 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 $tableRowDeptIds = [],
array $deptById = []
): 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 [];
}
if ($tableRowDeptIds !== [] && $deptById !== []) {
return self::foldConsultCountRowsToDeptByTable($rows, $adminToPrimary, $tableRowDeptIds, $deptById, $dataScopeAdminIds);
}
$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;
}
/**
* 业绩表「预约诊单」列:status∈{1,3,4};部门归属规则与 {@see aggregateConsults} 一致。
*
* @return array<int, int> 展示部门 id => 条数(键 0 为无法归入展示「中心」)
*/
private static function aggregateBookedAppointmentsByDept(
int $startTs,
int $endTs,
array $adminToPrimary,
?array $tagDiagIds,
?array $tagAssistantIds,
?array $dataScopeAdminIds = null
): array {
if ($tagDiagIds !== null && $tagDiagIds === []) {
return [];
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return [];
}
$startDate = date('Y-m-d', $startTs);
$endDate = date('Y-m-d', $endTs);
$eff = self::consultEffectiveAssistantSql();
$query = Db::name('doctor_appointment')->alias('a')
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
->whereBetween('a.appointment_date', [$startDate, $endDate])
->whereIn('a.status', [1, 3, 4])
->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)');
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;
}
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.appointment_date` 落在统计区间内;
* 状态为已预约(1)、已完成(3)、已过号(4),不含已取消(2);
* 有效医助与 {@see consultEffectiveAssistantSql()} 一致。
*
* @return array<int, int>
*/
private static function countBookedAppointmentsByAdminForLeaderboard(
int $startTs,
int $endTs,
?array $tagDiagIds,
?array $tagAssistantIds,
?array $dataScopeAdminIds
): array {
if ($tagDiagIds !== null && $tagDiagIds === []) {
return [];
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return [];
}
$startDate = date('Y-m-d', $startTs);
$endDate = date('Y-m-d', $endTs);
$eff = self::consultEffectiveAssistantSql();
$query = Db::name('doctor_appointment')->alias('a')
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
->whereBetween('a.appointment_date', [$startDate, $endDate])
->whereIn('a.status', [1, 3, 4])
->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)');
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 [];
}
$visFlip = $dataScopeAdminIds !== null && $dataScopeAdminIds !== []
? array_flip($dataScopeAdminIds)
: null;
$byAdmin = [];
foreach ($rows as $r) {
$aid = (int) ($r['eff_a'] ?? 0);
if ($aid <= 0) {
continue;
}
if ($visFlip !== null && !isset($visFlip[$aid])) {
continue;
}
$byAdmin[$aid] = ($byAdmin[$aid] ?? 0) + (int) ($r['cnt'] ?? 0);
}
return $byAdmin;
}
/**
* 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;
}
/**
* 进线事件查询公共条件(与 countLeads / countLeadsByAdmin 一致)。
*
* @param \think\db\BaseQuery $query
*/
private static function applyLeadEventBaseFilter($query, int $startTs, int $endTs, string $tagFilterId): void
{
$query->where('e.change_type', 'add_external_contact')
->where('e.event_time', 'between', [$startTs, $endTs])
->where('e.user_id', '<>', '');
if ($tagFilterId !== '') {
$tagTable = self::tableWithPrefix('qywx_external_contact_tag');
$query->whereRaw(
"EXISTS (SELECT 1 FROM {$tagTable} t WHERE t.external_userid = e.external_userid AND t.tag_id = ?)",
[$tagFilterId]
);
}
}
/**
* 归属到指定展示「中心」行部门的接待成员企微 userid 列表。
*
* @param array<int, int> $adminToPrimary
*
* @return list<string>
*/
private static function collectWorkWxUserIdsForYejiDeptRow(int $deptRowId, array $adminToPrimary): array
{
$adminIds = [];
foreach ($adminToPrimary as $aid => $pid) {
if ((int) $pid === $deptRowId) {
$adminIds[] = (int) $aid;
}
}
if ($adminIds === []) {
return [];
}
$wids = Db::name('admin')
->whereIn('id', $adminIds)
->whereNull('delete_time')
->column('work_wechat_userid');
$out = [];
foreach ($wids as $w) {
$w = trim((string) $w);
if ($w !== '') {
$out[] = $w;
}
}
return array_values(array_unique($out));
}
/**
* 进线明细:与看板「进线数据」同口径的 add_external_contact 逐条列表。
*
* @param array{
* start_date?: string,
* end_date?: string,
* dept_ids?: int[]|string,
* channel_code?: string,
* tag_id?: string,
* dept_id?: int|string,
* page?: int|string,
* page_size?: int|string
* } $params
*
* @return array{
* start_date: string,
* end_date: string,
* dept_id: int,
* dept_name: string,
* channel_code: string,
* count: int,
* lists: list<array<string, mixed>>,
* note: string
* }
*/
public static function leadLineList(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array
{
$c = self::resolveYejiContext($params);
if ($viewerAdminId > 0) {
self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo);
}
$deptId = (int) ($params['dept_id'] ?? 0);
$empty = static function (array $c, int $deptId, string $deptName, string $note) {
return [
'start_date' => (string) $c['startDate'],
'end_date' => (string) $c['endDate'],
'dept_id' => $deptId,
'dept_name' => $deptName,
'channel_code' => (string) $c['channelCode'],
'count' => 0,
'lists' => [],
'note' => $note,
];
};
if ($deptId <= 0) {
return $empty($c, 0, '', '未归属中心无进线明细(看板该行进线为 0)。');
}
if (!in_array($deptId, $c['tableRowDeptIds'], true)) {
return $empty(
$c,
$deptId,
self::formatYejiDeptRowDisplayName($deptId, $c['deptById']),
'该部门不在当前展示部门筛选或数据权限范围内。'
);
}
$channelCode = (string) $c['channelCode'];
$erCenterDeptSet = DeptLogic::getErCenterSubtreeDeptIdSet();
if ($channelCode !== '' && isset($erCenterDeptSet[$deptId])) {
return $empty(
$c,
$deptId,
self::formatYejiDeptRowDisplayName($deptId, $c['deptById']),
'与看板一致:二中心及其组织下级展示部门在选定渠道下进线计 0,无明细。'
);
}
$startTs = (int) $c['startTs'];
$endTs = (int) $c['endTs'];
$tagFilterId = (string) $c['tagFilterId'];
$page = max(1, (int) ($params['page'] ?? 1));
$limit = min(100, max(1, (int) ($params['page_size'] ?? 20)));
$offset = ($page - 1) * $limit;
$wxUserIds = self::collectWorkWxUserIdsForYejiDeptRow($deptId, $c['adminToPrimary']);
$deptName = self::formatYejiDeptRowDisplayName($deptId, $c['deptById']);
$ecTable = self::tableWithPrefix('qywx_external_contact');
if ($wxUserIds === []) {
return [
'start_date' => (string) $c['startDate'],
'end_date' => (string) $c['endDate'],
'dept_id' => $deptId,
'dept_name' => $deptName,
'channel_code' => $channelCode,
'count' => 0,
'lists' => [],
'note' => '当前部门下没有可映射到接待企微成员的管理员,无进线流水。',
];
}
$countQuery = Db::name('qywx_external_contact_event')->alias('e');
self::applyLeadEventBaseFilter($countQuery, $startTs, $endTs, $tagFilterId);
$countQuery->whereIn('e.user_id', $wxUserIds);
$count = (int) $countQuery->count();
$listQuery = Db::name('qywx_external_contact_event')->alias('e')
->leftJoin('admin a', 'a.work_wechat_userid = e.user_id AND a.delete_time IS NULL')
->leftJoin("{$ecTable} ec", 'ec.external_userid = e.external_userid AND ec.delete_time IS NULL');
self::applyLeadEventBaseFilter($listQuery, $startTs, $endTs, $tagFilterId);
$listQuery->whereIn('e.user_id', $wxUserIds);
$rawList = $listQuery
->field(
'e.id,e.event_time,e.external_userid,e.user_id,e.state,'
. 'a.name AS reception_admin_name,ec.name AS external_contact_name'
)
->order('e.event_time', 'desc')
->order('e.id', 'desc')
->limit($offset, $limit)
->select()
->toArray();
$lists = [];
foreach ($rawList as $rw) {
$ts = (int) ($rw['event_time'] ?? 0);
$lists[] = [
'id' => (int) ($rw['id'] ?? 0),
'event_time' => $ts,
'event_time_text' => $ts > 0 ? date('Y-m-d H:i:s', $ts) : '',
'external_userid' => (string) ($rw['external_userid'] ?? ''),
'user_id' => (string) ($rw['user_id'] ?? ''),
'state' => (string) ($rw['state'] ?? ''),
'reception_admin_name' => (string) ($rw['reception_admin_name'] ?? ''),
'external_contact_name' => (string) ($rw['external_contact_name'] ?? ''),
];
}
$note = '与看板「进线数据」同口径:企微 change_type=add_external_contact,按接待成员归属当前部门行;'
. ($tagFilterId !== '' ? '已按所选渠道标签过滤客户。' : '未选渠道时不限制客户标签。');
return [
'start_date' => (string) $c['startDate'],
'end_date' => (string) $c['endDate'],
'dept_id' => $deptId,
'dept_name' => $deptName,
'channel_code' => $channelCode,
'count' => $count,
'lists' => $lists,
'note' => $note,
];
}
/**
* admin → 展示「中心」映射:用于挂号明细按部门筛选(与 aggregateConsults 映射同源)。
*/
private static function buildYejiAdminPrimaryMapUnionSql(array $adminToPrimary): string
{
if ($adminToPrimary === []) {
return 'SELECT 0 AS map_aid, 0 AS map_pid WHERE 0 = 1';
}
$parts = [];
foreach ($adminToPrimary as $aid => $pid) {
$parts[] = 'SELECT ' . (int) $aid . ' AS map_aid,' . (int) $pid . ' AS map_pid';
}
return implode(' UNION ALL ', $parts);
}
/**
* @return list<array<string, mixed>>
*/
private static function formatYejiAppointmentLinePayloadRows(array $rawList, bool $hasChSrc): array
{
$statusMap = [
1 => '已预约',
2 => '已取消',
3 => '已完成',
4 => '已过号',
];
$typeMap = [
'video' => '视频问诊',
'text' => '图文问诊',
'phone' => '电话问诊',
];
$lists = [];
foreach ($rawList as $rw) {
$st = (int) ($rw['status'] ?? 0);
$tp = (string) ($rw['appointment_type'] ?? '');
$lists[] = [
'id' => (int) ($rw['id'] ?? 0),
'appointment_date' => (string) ($rw['appointment_date'] ?? ''),
'appointment_time' => (string) ($rw['appointment_time'] ?? ''),
'status' => $st,
'status_desc' => $statusMap[$st] ?? '未知',
'appointment_type' => $tp,
'appointment_type_desc' => $typeMap[$tp] ?? '未知',
'diagnosis_id' => (int) ($rw['patient_id'] ?? 0),
'patient_name' => (string) ($rw['patient_name'] ?? ''),
'patient_phone' => (string) ($rw['patient_phone'] ?? ''),
'doctor_name' => (string) ($rw['doctor_name'] ?? ''),
'channel_source' => $hasChSrc ? (string) ($rw['channel_source'] ?? '') : '',
];
}
return $lists;
}
/**
* 业绩表部门行「预约诊单」挂号明细(与 {@see aggregateBookedAppointmentsByDept} 同口径)。
*
* @param array<string, mixed> $c resolveYejiContext + applyYejiDataScope 后的上下文
*
* @return array<string, mixed>
*/
private static function yejiDeptAppointmentLinesInner(array $params, array $c): array
{
$deptIdParam = (int) ($params['dept_id'] ?? 0);
$channelCode = (string) $c['channelCode'];
$deptName = $deptIdParam === 0
? '未归属中心'
: self::formatYejiDeptRowDisplayName($deptIdParam, $c['deptById']);
$empty = static function (string $note) use ($c, $channelCode, $deptIdParam, $deptName): array {
return [
'start_date' => (string) $c['startDate'],
'end_date' => (string) $c['endDate'],
'assistant_id' => 0,
'assistant_name' => '',
'dept_id' => $deptIdParam,
'dept_name' => $deptName,
'channel_code' => $channelCode,
'count' => 0,
'lists' => [],
'note' => $note,
];
};
if ($deptIdParam < 0) {
return $empty('请指定有效部门。');
}
if ($deptIdParam > 0 && !in_array($deptIdParam, $c['tableRowDeptIds'], true)) {
return $empty('该部门不在当前展示部门筛选或数据权限范围内。');
}
$tagDiagIds = $c['tagDiagIds'];
$tagAssistantIds = $c['tagAssistantIds'];
if ($tagDiagIds !== null && $tagDiagIds === []) {
return $empty('当前渠道标签下无关联诊单,无挂号明细。');
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return $empty('当前渠道标签下无关联医助,无挂号明细。');
}
$startTs = (int) $c['startTs'];
$endTs = (int) $c['endTs'];
$startDate = date('Y-m-d', $startTs);
$endDate = date('Y-m-d', $endTs);
$eff = self::consultEffectiveAssistantSql();
$page = max(1, (int) ($params['page'] ?? 1));
$limit = min(100, max(1, (int) ($params['page_size'] ?? 20)));
$offset = ($page - 1) * $limit;
$hasChSrc = self::doctorAppointmentHasChannelSourceColumn();
$mapWrapped = '(' . self::buildYejiAdminPrimaryMapUnionSql($c['adminToPrimary']) . ')';
$dataScopeRestricted = !empty($c['dataScopeRestricted']);
$dataScopeAdminIds = $dataScopeRestricted && isset($c['dataScopeVisibleAdminIds'])
? $c['dataScopeVisibleAdminIds']
: null;
$buildBaseQuery = static function () use (
$eff,
$startDate,
$endDate,
$tagDiagIds,
$tagAssistantIds,
$mapWrapped,
$deptIdParam,
$dataScopeAdminIds
) {
$q = Db::name('doctor_appointment')->alias('a')
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
->leftJoin('admin doc', 'a.doctor_id = doc.id AND doc.delete_time IS NULL')
->whereBetween('a.appointment_date', [$startDate, $endDate])
->whereIn('a.status', [1, 3, 4])
->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)');
if ($tagDiagIds !== null) {
$q->whereIn('a.patient_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$ids = array_map('intval', array_keys($tagAssistantIds));
if ($ids !== []) {
$q->whereRaw("({$eff}) IN (" . implode(',', $ids) . ')');
}
}
$primaryPred = 'COALESCE('
. "IF(({$eff}) > 0, (SELECT m.map_pid FROM {$mapWrapped} m WHERE m.map_aid = ({$eff}) LIMIT 1), NULL),"
. " IF(a.doctor_id > 0, (SELECT m2.map_pid FROM {$mapWrapped} m2 WHERE m2.map_aid = a.doctor_id LIMIT 1), NULL),"
. ' 0)';
$q->whereRaw("{$primaryPred} = ?", [$deptIdParam]);
if ($dataScopeAdminIds !== null && $dataScopeAdminIds !== []) {
$visIn = implode(',', array_map('intval', $dataScopeAdminIds));
$q->whereRaw(
"(({$eff}) > 0 AND ({$eff}) IN ({$visIn})) OR (({$eff}) <= 0 AND a.doctor_id > 0 AND a.doctor_id IN ({$visIn}))"
);
}
return $q;
};
$count = (int) $buildBaseQuery()->count();
$fieldParts = [
'a.id',
'a.appointment_date',
'a.appointment_time',
'a.status',
'a.appointment_type',
'a.patient_id',
'u.patient_name',
'u.phone AS patient_phone',
'doc.name AS doctor_name',
];
if ($hasChSrc) {
$fieldParts[] = 'a.channel_source';
}
$rawList = $buildBaseQuery()
->field(implode(',', $fieldParts))
->order('a.appointment_date', 'asc')
->order('a.appointment_time', 'asc')
->order('a.id', 'asc')
->limit($offset, $limit)
->select()
->toArray();
$lists = self::formatYejiAppointmentLinePayloadRows($rawList, $hasChSrc);
$note = '与业绩表「预约诊单」列同口径:预约日期在统计区间内,状态为已预约 / 已完成 / 已过号(不含已取消);部门归属医助优先,否则接诊医生。';
return [
'start_date' => (string) $c['startDate'],
'end_date' => (string) $c['endDate'],
'assistant_id' => 0,
'assistant_name' => '',
'dept_id' => $deptIdParam,
'dept_name' => $deptName,
'channel_code' => $channelCode,
'count' => $count,
'lists' => $lists,
'note' => $note,
];
}
/**
* 医助排行榜「预约诊单」逐条挂号明细(与 {@see countBookedAppointmentsByAdminForLeaderboard} 同口径)。
*
* @param array{
* start_date?: string,
* end_date?: string,
* dept_ids?: int[]|string,
* channel_code?: string,
* tag_id?: string,
* assistant_id?: int|string,
* admin_id?: int|string,
* dept_id?: int|string,
* page?: int|string,
* page_size?: int|string
* } $params
*
* @return array{
* start_date: string,
* end_date: string,
* assistant_id: int,
* assistant_name: string,
* dept_id: int,
* dept_name: string,
* channel_code: string,
* count: int,
* lists: list<array<string, mixed>>,
* note: string
* }
*/
public static function leaderboardAppointmentLines(
array $params,
int $viewerAdminId = 0,
array $viewerAdminInfo = []
): array {
$c = self::resolveYejiContext($params);
if ($viewerAdminId > 0) {
self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo);
}
$assistantId = (int) ($params['assistant_id'] ?? $params['admin_id'] ?? 0);
if ($assistantId <= 0 && array_key_exists('dept_id', $params)) {
return self::yejiDeptAppointmentLinesInner($params, $c);
}
$channelCode = (string) $c['channelCode'];
$assistantName = '';
if ($assistantId > 0) {
$assistantName = (string) (Db::name('admin')->where('id', $assistantId)->whereNull('delete_time')->value('name') ?: '');
}
$baseOut = static function (array $c, string $note) use ($assistantId, $assistantName, $channelCode): array {
return [
'start_date' => (string) $c['startDate'],
'end_date' => (string) $c['endDate'],
'assistant_id' => $assistantId,
'assistant_name' => $assistantName,
'dept_id' => 0,
'dept_name' => '',
'channel_code' => $channelCode,
'count' => 0,
'lists' => [],
'note' => $note,
];
};
if ($assistantId <= 0) {
return $baseOut($c, '请指定有效医助,或传入 dept_id 查看部门汇总明细。');
}
$dataScopeRestricted = !empty($c['dataScopeRestricted']);
$dataScopeAdminIds = $dataScopeRestricted && isset($c['dataScopeVisibleAdminIds'])
? $c['dataScopeVisibleAdminIds']
: null;
if ($dataScopeRestricted && $dataScopeAdminIds !== null) {
$flip = array_flip($dataScopeAdminIds);
if (!isset($flip[$assistantId])) {
return $baseOut($c, '当前账号数据权限下不可查看该医助挂号明细。');
}
}
if (!isset($c['adminToPrimary'][$assistantId])) {
return $baseOut($c, '该医助不在当前业绩看板展示范围内。');
}
$tagDiagIds = $c['tagDiagIds'];
$tagAssistantIds = $c['tagAssistantIds'];
$tagFallback = $c['tagFallback'];
$scopedAssistants = $tagFallback ? $tagAssistantIds : null;
if ($tagDiagIds !== null && $tagDiagIds === []) {
return $baseOut($c, '当前渠道标签下无关联诊单,无挂号明细。');
}
if ($scopedAssistants !== null && $scopedAssistants === []) {
return $baseOut($c, '当前渠道标签下无关联医助,无挂号明细。');
}
if ($scopedAssistants !== null && !isset($scopedAssistants[$assistantId])) {
return $baseOut($c, '当前渠道标签口径下该医助无归属挂号(与排行榜「预约诊单」一致)。');
}
$startTs = (int) $c['startTs'];
$endTs = (int) $c['endTs'];
$startDate = date('Y-m-d', $startTs);
$endDate = date('Y-m-d', $endTs);
$eff = self::consultEffectiveAssistantSql();
$page = max(1, (int) ($params['page'] ?? 1));
$limit = min(100, max(1, (int) ($params['page_size'] ?? 20)));
$offset = ($page - 1) * $limit;
$hasChSrc = self::doctorAppointmentHasChannelSourceColumn();
$buildBaseQuery = static function () use (
$eff,
$assistantId,
$startDate,
$endDate,
$tagDiagIds,
$scopedAssistants
) {
$q = Db::name('doctor_appointment')->alias('a')
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
->leftJoin('admin doc', 'a.doctor_id = doc.id AND doc.delete_time IS NULL')
->whereBetween('a.appointment_date', [$startDate, $endDate])
->whereIn('a.status', [1, 3, 4])
->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)')
->whereRaw("({$eff}) = ?", [$assistantId]);
if ($tagDiagIds !== null) {
$q->whereIn('a.patient_id', $tagDiagIds);
}
if ($scopedAssistants !== null) {
$ids = array_map('intval', array_keys($scopedAssistants));
if ($ids !== []) {
$q->whereRaw("({$eff}) IN (" . implode(',', $ids) . ')');
}
}
return $q;
};
$count = (int) $buildBaseQuery()->count();
$fieldParts = [
'a.id',
'a.appointment_date',
'a.appointment_time',
'a.status',
'a.appointment_type',
'a.patient_id',
'u.patient_name',
'u.phone AS patient_phone',
'doc.name AS doctor_name',
];
if ($hasChSrc) {
$fieldParts[] = 'a.channel_source';
}
$rawList = $buildBaseQuery()
->field(implode(',', $fieldParts))
->order('a.appointment_date', 'asc')
->order('a.appointment_time', 'asc')
->order('a.id', 'asc')
->limit($offset, $limit)
->select()
->toArray();
$lists = self::formatYejiAppointmentLinePayloadRows($rawList, $hasChSrc);
$note = '与排行榜「预约诊单」同口径:预约日期在统计区间内,状态为已预约 / 已完成 / 已过号(不含已取消);'
. '有效医助优先取挂号表创建医助,否则取诊单医助。';
return [
'start_date' => (string) $c['startDate'],
'end_date' => (string) $c['endDate'],
'assistant_id' => $assistantId,
'assistant_name' => $assistantName,
'dept_id' => 0,
'dept_name' => '',
'channel_code' => $channelCode,
'count' => $count,
'lists' => $lists,
'note' => $note,
];
}
/**
* 业绩看板:二中心复诊按部门行下钻 —— 医助姓名与业务订单笔数(与表格「复诊」列同口径)。
*
* @param array{
* start_date?:string,end_date?:string,dept_ids?:int[]|string,channel_code?:string,tag_id?:string,
* dept_id?:int|string,revisit_slot?:int|string
* } $params
* revisit_slot0=复诊合计(全部复诊笔),≥2=对应分项
*
* @return array{
* start_date:string,end_date:string,dept_id:int,dept_name:string,revisit_slot:int,revisit_slot_label:string,
* rows:list<array{admin_id:int,name:string,order_count:int}>,note:string
* }
*/
public static function revisitDeptAssistantBreakdown(
array $params,
int $viewerAdminId = 0,
array $viewerAdminInfo = []
): array {
$c = self::resolveYejiContext($params);
if ($viewerAdminId > 0) {
self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo);
}
$deptId = (int) ($params['dept_id'] ?? 0);
$revisitSlot = (int) ($params['revisit_slot'] ?? 0);
$deptName = $deptId > 0 ? self::formatYejiDeptRowDisplayName($deptId, $c['deptById']) : '';
$slotLabel = '复诊合计';
if ($revisitSlot >= 2) {
if ($revisitSlot === 2) {
$slotLabel = '复诊2';
} elseif ($revisitSlot >= 3 && $revisitSlot <= 10) {
$cn = ['三', '四', '五', '六', '七', '八', '九', '十'];
$slotLabel = '复诊' . ($cn[$revisitSlot - 3] ?? (string) $revisitSlot);
} else {
$slotLabel = '复诊' . (string) $revisitSlot;
}
}
$out = [
'start_date' => (string) $c['startDate'],
'end_date' => (string) $c['endDate'],
'dept_id' => $deptId,
'dept_name' => $deptName,
'revisit_slot' => $revisitSlot,
'revisit_slot_label' => $slotLabel,
'rows' => [],
'note' => '',
];
if ($deptId <= 0) {
$out['note'] = '请选择有效部门行。';
return $out;
}
if (!in_array($deptId, $c['tableRowDeptIds'], true)) {
$out['note'] = '该部门不在当前展示部门或数据权限范围内。';
return $out;
}
if ($revisitSlot < 0 || $revisitSlot === 1) {
$out['note'] = '无效的复诊分项。';
return $out;
}
$pack = DeptLogic::getErCenterRevisitAssistantBreakdownForDept(
$deptId,
$revisitSlot,
(int) $c['startTs'],
(int) $c['endTs']
);
$rows = $pack['rows'];
if (!empty($c['dataScopeRestricted']) && isset($c['dataScopeVisibleAdminIds'])) {
$flip = array_flip($c['dataScopeVisibleAdminIds']);
$filtered = [];
foreach ($rows as $rw) {
if (isset($flip[(int) ($rw['admin_id'] ?? 0)])) {
$filtered[] = $rw;
}
}
$rows = $filtered;
}
$out['rows'] = $rows;
if ($rows === []) {
$out['note'] = '当前部门与区间下无符合条件的复诊业务订单(口径与看板「二中心复诊」列一致)。';
}
return $out;
}
/**
* 与 sumPerformance 对应,按 admin 维度返回全量/渠道业绩金额(不按部门折叠)。
* 归属固定为 sqlPerformanceAttributionAdminExpr()——按订单创建人。
*
* @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 $channelInfo = null
): 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, $channelInfo);
}
} 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 $channelInfo = null
): 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, $channelInfo);
}
} elseif ($tagDiagIds !== null || $tagAssistantIdsForScoped !== null) {
$scoped = self::aggregateDealOrderCountGroupedByAssistant($startTs, $endTs, $tagDiagIds, $tagAssistantIdsForScoped);
} else {
$scoped = [];
}
return ['all' => $all, 'scoped' => $scoped];
}
/**
* 成交单数按业绩归属医助聚合(与 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');
$att = self::sqlPerformanceAttributionAdminExpr();
$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);
self::joinPrescriptionForPerformanceAttribution($query);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$query->whereRaw("({$att}) > 0", []);
if ($tagDiagIds !== null) {
$query->whereIn('o.diagnosis_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$ids = array_map('intval', array_keys($tagAssistantIds));
$query->whereRaw("({$att}) IN (" . implode(',', $ids) . ')');
}
$query->field([Db::raw("({$att}) AS assistant_id"), Db::raw('COUNT(*) AS cnt')])
->group($att);
$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 $channelInfo = null
): array {
if ($appointmentChannelValues === []) {
return [];
}
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
$pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('o.diagnosis_id', $norm, $channelInfo);
if ($pack === null) {
return [];
}
$existsSql = $pack[0];
$existsBindings = $pack[1];
$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]);
self::joinPrescriptionForPerformanceAttribution($query);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$att = self::sqlPerformanceAttributionAdminExpr();
$query->where('o.diagnosis_id', '>', 0)
->whereRaw("({$att}) > 0", [])
->whereRaw($existsSql, $existsBindings)
->field([Db::raw("({$att}) AS assistant_id"), Db::raw('COUNT(*) AS cnt')])
->group($att);
$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(统一按订单创建人 o.creator_id;与 sqlPerformanceAttributionAdminExpr 一致)。
*
* @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');
$att = self::sqlPerformanceAttributionAdminExpr();
$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);
self::joinPrescriptionForPerformanceAttribution($query);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$query->whereRaw("({$att}) > 0", []);
if ($tagDiagIds !== null) {
$query->whereIn('o.diagnosis_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$ids = array_map('intval', array_keys($tagAssistantIds));
$query->whereRaw("({$att}) IN (" . implode(',', $ids) . ')');
}
$query->field([Db::raw("({$att}) AS assistant_id"), Db::raw('SUM(o.amount) AS amt')])
->group($att);
$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) 渠道业绩按业绩归属医助聚合(与 aggregatePerformanceByAppointmentChannels 同源)。
*
* @param int[] $appointmentChannelValues
*
* @return array<int, float>
*/
private static function aggregatePerformanceAmountsByAssistantChannel(
int $startTs,
int $endTs,
array $appointmentChannelValues,
?array $channelInfo = null
): array {
if ($appointmentChannelValues === []) {
return [];
}
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
$pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('o.diagnosis_id', $norm, $channelInfo);
if ($pack === null) {
return [];
}
$existsSql = $pack[0];
$existsBindings = $pack[1];
$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]);
self::joinPrescriptionForPerformanceAttribution($query);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$att = self::sqlPerformanceAttributionAdminExpr();
$query->where('o.diagnosis_id', '>', 0)
->whereRaw("({$att}) > 0", [])
->whereRaw($existsSql, $existsBindings)
->field([Db::raw("({$att}) AS assistant_id"), Db::raw('SUM(o.amount) AS amt')])
->group($att);
$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;
}
/**
* 选定渠道时:二中心子树(DeptLogic::getErCenterSubtreeDeptIdSet)下属医助的渠道诊金、进线清零(与部门行一致)。
*
* @param array<int, int> $adminToPrimary
* @param array<int, true> $erCenterDeptSet
* @param array<int, float> $feeByAdmin
* @param array<int, int> $leadsByAdmin
* @param array<int, int>|null $dealOrderByAdmin
* @param array<int, int>|null $assignsByAdmin
*/
private static function excludeErCenterFromAdminLeaderboard(
array $adminToPrimary,
array $erCenterDeptSet,
array &$feeByAdmin,
array &$leadsByAdmin,
?array &$dealOrderByAdmin = null,
?array &$assignsByAdmin = null
): void {
foreach ($adminToPrimary as $aid => $pid) {
$pid = (int) $pid;
if ($pid <= 0 || !isset($erCenterDeptSet[$pid])) {
continue;
}
$feeByAdmin[$aid] = 0.0;
$leadsByAdmin[$aid] = 0;
if ($dealOrderByAdmin !== null) {
$dealOrderByAdmin[$aid] = 0;
}
if ($assignsByAdmin !== null) {
$assignsByAdmin[$aid] = 0;
}
}
}
/** 排行榜部门列短名:取名称首字符 */
private static function deptDisplayAbbr(string $deptName): string
{
$t = trim($deptName);
if ($t === '') {
return '—';
}
$ch = mb_substr($t, 0, 1, 'UTF-8');
return $ch !== '' ? $ch : substr($t, 0, 1);
}
/**
* 业绩/成交单统计:仅排除履约已取消 fulfillment_status = 4,其余(含 NULL 与任意其它状态)全部计入。
* 与看板「合计业绩」、处方列表业绩 extend 口径一致;勿用 `<> 4`,以免误排除 NULL。
*
* @param \think\db\BaseQuery|\think\Model $query Db::name / Model::where 等查询实例
* @param string $tableAlias 主表别名(如 Db 侧 `alias('o')` 传 `o`);无别名时传空字符串
*/
public static function applyPrescriptionOrderNotCancelledForPerformanceQuery($query, string $tableAlias = ''): void
{
if ($tableAlias !== '') {
$query->whereRaw("NOT ({$tableAlias}.fulfillment_status <=> ?)", [4]);
} else {
$query->whereRaw('NOT (fulfillment_status <=> ?)', [4]);
}
}
/**
* @param \think\db\BaseQuery|\think\Model $query
*/
private static function applyPrescriptionOrderNotCancelledForPerformance($query, string $tableAlias = 'o'): void
{
self::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, $tableAlias);
}
/**
* 业绩按医助归属:须 alias 订单 o、诊单 dg,并调用本方法 left join 处方 rxrx.id = o.prescription_id
*/
private static function joinPrescriptionForPerformanceAttribution($query): void
{
$rxTable = self::tableWithPrefix('tcm_prescription');
$query->leftJoin("{$rxTable} rx", 'rx.id = o.prescription_id AND rx.delete_time IS NULL');
}
/**
* 业绩归属医助 admin_id SQL 表达式(表别名固定为 o / dg / rx)。
* 业绩看板(部门表 / 医助排行榜 / 渠道列 / ROI / 复诊 / 未归属中心)统一按订单创建人 o.creator_id 归属。
* 即:诊金、接诊诊单、复诊、渠道业绩等所有金额/条数仅记给实际下单的医助;不再回退到诊单 assistant_id。
* 处方订单列表侧栏(yeji_order_drawer=1)的「医助」/「部门」筛选与本归属对齐;其它管理端的 assistant_id /
* assistant_dept_id 列表过滤保留旧口径(创建人 ∪ 诊单医助),不受本表达式影响。
*/
private static function sqlPerformanceAttributionAdminExpr(): string
{
return 'o.creator_id';
}
/**
* 处方订单业绩全量合计(不按部门归属截断),与订单列表在**同一 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);
}
/**
* 看板「合计业绩」某部门行对应的业绩归属医助 id(与 overview 中 foldPerformanceRowsToDeptByTable 摊行一致)。
* $yejiDeptIdsParam 与看板本次请求的 dept_ids 一致(未选则为 null)。
*
* @param int|string|int[]|string[]|null $yejiDeptIdsParam
*
* @return int[]
*/
public static function collectAdminIdsAttributedToYejiTableRow(int $tableRowDeptId, $yejiDeptIdsParam): array
{
if ($tableRowDeptId <= 0) {
return [];
}
[$primaryDeptIds, , $tableRowDeptIds, $deptById] = self::resolvePrimaryDepts($yejiDeptIdsParam);
if ($tableRowDeptIds === []) {
return [];
}
$deptToDescendants = self::buildDeptDescendants($primaryDeptIds);
$adminToPrimary = self::buildAdminToPrimaryDept($primaryDeptIds, $deptToDescendants);
$links = Db::name('admin_dept')->field(['admin_id', 'dept_id'])->select()->toArray();
$byAdmin = [];
foreach ($links as $l) {
$aid = (int) $l['admin_id'];
$did = (int) $l['dept_id'];
if ($aid > 0 && $did > 0) {
$byAdmin[$aid][$did] = true;
}
}
$candidateIds = array_values(array_unique(array_merge(array_keys($byAdmin), array_keys($adminToPrimary))));
$tableRowSet = array_flip(array_map('intval', $tableRowDeptIds));
$out = [];
foreach ($candidateIds as $aid) {
$aid = (int) $aid;
if ($aid <= 0) {
continue;
}
$deptIdsForAdmin = array_keys($byAdmin[$aid] ?? []);
$mapped = 0;
if ($deptIdsForAdmin !== []) {
$mapped = self::mapAdminDeptLinksToTableRow($deptIdsForAdmin, $tableRowDeptIds, $tableRowSet, $deptById);
}
if ($mapped <= 0) {
$mapped = (int) ($adminToPrimary[$aid] ?? 0);
}
if ($mapped === $tableRowDeptId) {
$out[] = $aid;
}
}
return array_values(array_unique($out));
}
/**
* 标签/医助兜底口径下的渠道向业绩全量 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;
}
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$att = self::sqlPerformanceAttributionAdminExpr();
$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);
self::joinPrescriptionForPerformanceAttribution($query);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$query->whereRaw("({$att}) > 0", []);
if ($tagDiagIds !== null) {
$query->whereIn('o.diagnosis_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$ids = array_map('intval', array_keys($tagAssistantIds));
$query->whereRaw("({$att}) IN (" . implode(',', $ids) . ')');
}
return round((float) $query->sum('o.amount'), 1);
}
/**
* dict(channels) 映射下渠道业绩全量 SUM(与 aggregatePerformanceByAppointmentChannels 同源,不按 primary dept 汇总)。
*/
private static function sumGlobalPerformanceAppointmentChannels(
int $startTs,
int $endTs,
array $appointmentChannelValues,
?array $channelInfo = null
): float {
if ($appointmentChannelValues === []) {
return 0.0;
}
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
if ($norm === []) {
return 0.0;
}
$pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('o.diagnosis_id', $norm, $channelInfo);
if ($pack === null) {
return 0.0;
}
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$existsSql = $pack[0];
$existsBindings = $pack[1];
$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::joinPrescriptionForPerformanceAttribution($query);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$att = self::sqlPerformanceAttributionAdminExpr();
$query->where('o.diagnosis_id', '>', 0)
->whereRaw("({$att}) > 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,
?array $channelInfo = null
): float {
if ($appointmentChannelValues !== []) {
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
if ($norm === []) {
return 0.0;
}
return self::sumGlobalPerformanceAppointmentChannels($startTs, $endTs, $norm, $channelInfo);
}
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: 「{渠道}业绩」:患者渠道 EXISTS 与侧栏/抽屉一致(channels channel_source);有展示行时部门列与「合计业绩」同为列表部门摊行。
*
* @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 $tableRowDeptIds = [],
array $deptById = [],
?array $precomputedListAlignedAmountByDept = null,
?array $channelInfo = null
): array {
if ($adminToPrimary === []) {
return ['all' => [], 'scoped' => []];
}
// 1) 部门「合计业绩」:有展示行时与「接诊诊单」同属 {@see aggregateListAlignedOrderMetricsByDept}(仅订单创建人摊行)
if ($precomputedListAlignedAmountByDept !== null) {
$allByDept = [];
foreach ($precomputedListAlignedAmountByDept as $deptId => $v) {
$allByDept[(int) $deptId] = round((float) $v, 1);
}
} elseif ($tableRowDeptIds !== [] && $deptById !== []) {
$aligned = self::aggregateListAlignedOrderMetricsByDept(
$startTs,
$endTs,
$adminToPrimary,
null,
null,
$tableRowDeptIds,
$deptById
);
$allByDept = [];
foreach ($aligned['amount'] as $deptId => $v) {
$allByDept[(int) $deptId] = round((float) $v, 1);
}
} else {
$allByDept = self::aggregatePerformance(
$startTs,
$endTs,
$adminToPrimary,
null,
null,
$tableRowDeptIds,
$deptById
);
}
if (!$channelFilterActive) {
return ['all' => $allByDept, 'scoped' => $allByDept];
}
if ($appointmentChannelValues !== []) {
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
if ($norm === []) {
$scopedByDept = [];
} else {
$pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('o.diagnosis_id', $norm, $channelInfo);
if ($pack === null) {
$scopedByDept = [];
} elseif ($tableRowDeptIds !== [] && $deptById !== []) {
$scopedAligned = self::aggregateListAlignedOrderMetricsByDept(
$startTs,
$endTs,
$adminToPrimary,
null,
null,
$tableRowDeptIds,
$deptById,
$pack[0],
$pack[1]
);
$scopedByDept = [];
foreach ($scopedAligned['amount'] as $deptId => $v) {
$scopedByDept[(int) $deptId] = round((float) $v, 1);
}
} else {
$scopedByDept = self::aggregatePerformanceByAppointmentChannels(
$startTs,
$endTs,
$adminToPrimary,
$norm,
$tableRowDeptIds,
$deptById,
$channelInfo
);
}
}
} elseif ($tagDiagIds !== null || $tagAssistantIds !== null) {
$scopedByDept = self::aggregatePerformance(
$startTs,
$endTs,
$adminToPrimary,
$tagDiagIds,
$tagAssistantIds,
$tableRowDeptIds,
$deptById
);
} else {
$scopedByDept = [];
}
return ['all' => $allByDept, 'scoped' => $scopedByDept];
}
/**
* 渠道业绩:与「处方订单列表」业绩完全同口径 —— 按 o.create_time 归日、仅剔除已取消(4)(含 NULL);
* EXISTS 仅用于"该患者属于此渠道"的判定(有任一挂号 channels 命中 + status=3**不限挂号时点**);
* 部门归属按业绩归属表达式(订单创建人 o.creator_id)。
*/
private static function aggregatePerformanceByAppointmentChannels(
int $startTs,
int $endTs,
array $adminToPrimary,
array $appointmentChannelValues,
array $tableRowDeptIds = [],
array $deptById = [],
?array $channelInfo = null
): array {
if ($appointmentChannelValues === [] || $adminToPrimary === []) {
return [];
}
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
$pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('o.diagnosis_id', $norm, $channelInfo);
if ($pack === null) {
return [];
}
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$att = self::sqlPerformanceAttributionAdminExpr();
$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::joinPrescriptionForPerformanceAttribution($query);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$query->where('o.diagnosis_id', '>', 0)
->whereRaw("({$att}) > 0", [])
->whereRaw($pack[0], $pack[1])
->field([Db::raw("({$att}) AS assistant_id"), Db::raw('SUM(o.amount) AS amt')])
->group($att);
$rows = $query->select()->toArray();
return self::foldPerformanceRowsToDeptByTable($rows, $adminToPrimary, $tableRowDeptIds, $deptById, 'amt');
}
/**
* 「{渠道}接诊」在字典映射到 channels 时:按**成交单数**(非取消业务订单条数)计,
* 与 aggregatePerformanceByAppointmentChannels 同一套 JOIN / EXISTS / 订单 create_time 窗口。
*
* @return array<int, int>
*/
private static function aggregateDealOrderCountByAppointmentChannels(
int $startTs,
int $endTs,
array $adminToPrimary,
array $appointmentChannelValues,
array $tableRowDeptIds = [],
array $deptById = [],
?array $channelInfo = null
): array {
if ($appointmentChannelValues === [] || $adminToPrimary === []) {
return [];
}
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
$pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('o.diagnosis_id', $norm, $channelInfo);
if ($pack === null) {
return [];
}
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$att = self::sqlPerformanceAttributionAdminExpr();
$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::joinPrescriptionForPerformanceAttribution($query);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$query->where('o.diagnosis_id', '>', 0)
->whereRaw("({$att}) > 0", [])
->whereRaw($pack[0], $pack[1])
->field([Db::raw("({$att}) AS assistant_id"), Db::raw('COUNT(o.id) AS cnt')])
->group($att);
$rows = $query->select()->toArray();
return self::foldPerformanceRowsToDeptByTable($rows, $adminToPrimary, $tableRowDeptIds, $deptById, 'cnt');
}
/**
* 单次业绩聚合:与 aggregatePerformanceAmountsByAdmin / aggregateDealOrderCount 同源 ——
* INNER 诊单,按业绩归属医助 SUM(amount) 后映射到展示「中心」。
* WHERE: delete_time IS NULL、NOT(fulfillment <=> 4)、create_time 区间、diagnosis_id>0、归属医助>0
* 可选 tagdiagnosis_id IN、归属医助 IN。
*
* @return array<int, float>
*/
private static function aggregatePerformance(
int $startTs,
int $endTs,
array $adminToPrimary,
?array $tagDiagIds,
?array $tagAssistantIds,
array $tableRowDeptIds = [],
array $deptById = []
): array {
if ($tagDiagIds !== null && $tagDiagIds === []) {
return [];
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return [];
}
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$att = self::sqlPerformanceAttributionAdminExpr();
$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);
self::joinPrescriptionForPerformanceAttribution($query);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$query->whereRaw("({$att}) > 0", []);
if ($tagDiagIds !== null) {
$query->whereIn('o.diagnosis_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$ids = array_map('intval', array_keys($tagAssistantIds));
$query->whereRaw("({$att}) IN (" . implode(',', $ids) . ')');
}
$query->field([Db::raw("({$att}) AS assistant_id"), Db::raw('SUM(o.amount) AS amt')])
->group($att);
$rows = $query->select()->toArray();
return self::foldPerformanceRowsToDeptByTable($rows, $adminToPrimary, $tableRowDeptIds, $deptById, 'amt');
}
/**
* 全量成交单数:与 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();
}
/**
* 全量部门 org 树:pid => 子部门 id 列表(缓存一次请求内多次 aggregate 复用)。
*
* @return array<int, int[]>
*/
private static function yejiFullDeptChildrenOf(): array
{
static $cache = null;
if ($cache !== null) {
return $cache;
}
$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'];
}
return $cache = $childrenOf;
}
/**
* @return array<int, true>
*/
private static function yejiDeptSubtreeIdFlip(int $rootDeptId, array $childrenOf): array
{
$flip = [];
$stack = [$rootDeptId];
while ($stack !== []) {
$cur = (int) array_pop($stack);
if ($cur <= 0 || isset($flip[$cur])) {
continue;
}
$flip[$cur] = true;
foreach ($childrenOf[$cur] ?? [] as $cid) {
$stack[] = (int) $cid;
}
}
return $flip;
}
/**
* 与业绩看板部门列一致:仅按订单创建人候选,将其人事部门落在展示行子树即命中;命中多行时取组织深度最大的展示行。
* (处方订单列表默认 assistant_dept_id 仍为「诊单医助 ∪ 创建人」并集,与看板有别。)
*
* @param array<int, true> $candidates admin_id => true
* @param array<int, int[]> $adminDeptIdList
* @param array<int, array<int, true>> $rowSubtreeFlips
* @param int[] $tableRowDeptIds
* @param array<int, array<string, mixed>> $deptById
*
* @return int 表格展示部门 id,无法归属则为 0
*/
private static function resolveDeepestYejiTableRowForListDeptOr(
array $candidates,
array $adminDeptIdList,
array $rowSubtreeFlips,
array $tableRowDeptIds,
array $deptById,
array $adminToPrimary
): int {
if ($candidates === []) {
return 0;
}
/** @var int[] $matching */
$matching = [];
foreach ($tableRowDeptIds as $rid) {
$rid = (int) $rid;
if ($rid <= 0) {
continue;
}
$flip = $rowSubtreeFlips[$rid] ?? [];
if ($flip === []) {
continue;
}
foreach ($candidates as $aid => $_) {
$aid = (int) $aid;
if ($aid <= 0) {
continue;
}
foreach ($adminDeptIdList[$aid] ?? [] as $d) {
$d = (int) $d;
if ($d > 0 && isset($flip[$d])) {
$matching[] = $rid;
break 2;
}
}
}
}
if ($matching === []) {
foreach ($candidates as $aid => $_) {
$p = (int) ($adminToPrimary[(int) $aid] ?? 0);
if ($p > 0) {
foreach ($tableRowDeptIds as $rid) {
if ((int) $rid === $p) {
return $p;
}
}
}
}
return 0;
}
$best = 0;
$bestDepth = -1;
foreach ($matching as $rid) {
$depth = self::deptDepthFromRoot($rid, $deptById);
if ($depth > $bestDepth || ($depth === $bestDepth && ($best === 0 || $rid < $best))) {
$best = $rid;
$bestDepth = $depth;
}
}
return $best;
}
/**
* 看板「合计业绩 / 接诊诊单」列用的部门摊行:仅按订单创建人 o.creator_id 的人事部门映射到展示行子树(命中多行取最深),
* 与侧栏 yeji_order_drawer=1、{@see sqlPerformanceAttributionAdminExpr} 一致。不再把诊单医助纳入归属候选。
*
* @return array{count: array<int, int>, amount: array<int, float>}
*/
private static function aggregateListAlignedOrderMetricsByDept(
int $startTs,
int $endTs,
array $adminToPrimary,
?array $tagDiagIds,
?array $tagAssistantIds,
array $tableRowDeptIds,
array $deptById,
?string $channelScopedExistsSql = null,
array $channelScopedExistsBindings = []
): array {
if ($tagDiagIds !== null && $tagDiagIds === []) {
return ['count' => [], 'amount' => []];
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return ['count' => [], 'amount' => []];
}
if ($tableRowDeptIds === [] || $deptById === []) {
return ['count' => [], 'amount' => []];
}
$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);
self::joinPrescriptionForPerformanceAttribution($query);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$query->where('o.creator_id', '>', 0);
if ($tagDiagIds !== null) {
$query->whereIn('o.diagnosis_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$ids = array_map('intval', array_keys($tagAssistantIds));
$in = implode(',', $ids);
$query->whereRaw("o.creator_id IN ({$in})");
}
if ($channelScopedExistsSql !== null && $channelScopedExistsSql !== '') {
$query->whereRaw($channelScopedExistsSql, $channelScopedExistsBindings);
}
$orderRows = $query->field([
'o.creator_id',
'o.amount',
])->select()->toArray();
if ($orderRows === []) {
return ['count' => [], 'amount' => []];
}
$childrenOf = self::yejiFullDeptChildrenOf();
/** @var array<int, array<int, true>> $rowSubtreeFlips */
$rowSubtreeFlips = [];
foreach ($tableRowDeptIds as $rid) {
$rid = (int) $rid;
if ($rid > 0) {
$rowSubtreeFlips[$rid] = self::yejiDeptSubtreeIdFlip($rid, $childrenOf);
}
}
$adminIds = [];
foreach ($orderRows as $r) {
$c = (int) ($r['creator_id'] ?? 0);
if ($c > 0) {
$adminIds[] = $c;
} elseif ($a > 0) {
$adminIds[] = $a;
}
}
$adminIds = array_values(array_unique(array_filter($adminIds, static fn (int $x): bool => $x > 0)));
/** @var array<int, int[]> $adminDeptIdList */
$adminDeptIdList = [];
if ($adminIds !== []) {
$links = Db::name('admin_dept')->whereIn('admin_id', $adminIds)->field(['admin_id', 'dept_id'])->select()->toArray();
foreach ($links as $l) {
$aid = (int) $l['admin_id'];
$did = (int) $l['dept_id'];
if ($aid > 0 && $did > 0) {
$adminDeptIdList[$aid][] = $did;
}
}
}
$byCnt = [];
$byAmt = [];
foreach ($orderRows as $r) {
$cr = (int) ($r['creator_id'] ?? 0);
if ($cr <= 0) {
continue;
}
$cand = [$cr => true];
$deptKey = self::resolveDeepestYejiTableRowForListDeptOr(
$cand,
$adminDeptIdList,
$rowSubtreeFlips,
$tableRowDeptIds,
$deptById,
$adminToPrimary
);
if ($deptKey <= 0) {
continue;
}
$amt = (float) ($r['amount'] ?? 0);
$byCnt[$deptKey] = ($byCnt[$deptKey] ?? 0) + 1;
$byAmt[$deptKey] = ($byAmt[$deptKey] ?? 0.0) + $amt;
}
return ['count' => $byCnt, 'amount' => $byAmt];
}
/**
* 成交单数按部门:有展示行时与 {@see aggregateListAlignedOrderMetricsByDept} 同口径(仅订单创建人归属)。
* 无展示行时按 sqlPerformanceAttributionAdminExpr 分组再 fold(同样为创建人)。
* 选定医助 tag 时收窄为 o.creator_id ∈ 集。
*
* @return array<int, int>
*/
private static function aggregateDealOrderCount(
int $startTs,
int $endTs,
array $adminToPrimary,
?array $tagDiagIds,
?array $tagAssistantIds,
array $tableRowDeptIds = [],
array $deptById = []
): array {
if ($tagDiagIds !== null && $tagDiagIds === []) {
return [];
}
if ($tagAssistantIds !== null && $tagAssistantIds === []) {
return [];
}
if ($tableRowDeptIds !== [] && $deptById !== []) {
return self::aggregateListAlignedOrderMetricsByDept(
$startTs,
$endTs,
$adminToPrimary,
$tagDiagIds,
$tagAssistantIds,
$tableRowDeptIds,
$deptById
)['count'];
}
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$att = self::sqlPerformanceAttributionAdminExpr();
$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);
self::joinPrescriptionForPerformanceAttribution($query);
self::applyPrescriptionOrderNotCancelledForPerformance($query);
$query->where('o.creator_id', '>', 0);
if ($tagDiagIds !== null) {
$query->whereIn('o.diagnosis_id', $tagDiagIds);
}
if ($tagAssistantIds !== null) {
$ids = array_map('intval', array_keys($tagAssistantIds));
$in = implode(',', $ids);
$query->whereRaw("o.creator_id IN ({$in})");
}
$query->whereRaw("({$att}) > 0", []);
$query->field([Db::raw("({$att}) AS assistant_id"), Db::raw('COUNT(*) AS cnt')])
->group($att);
$rows = $query->select()->toArray();
return self::foldPerformanceRowsToDeptByTable($rows, $adminToPrimary, $tableRowDeptIds, $deptById, 'cnt');
}
/**
* 表格不展示「有下级的勾选根部门」行时,仍把该行对应进线数并入首个可见下级,避免投放成本分摊分母偏小。
*
* @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');
}
/**
* 选定渠道时:二中心子树展示行渠道业绩与渠道成交单置 0,并返回扣减金额(与 sumGlobalChannelScopedPerformance 对齐)。
*
* @param int[] $tableRowDeptIds
* @param array<int, true> $erCenterDeptSet
* @param array<int, float|string|int> $channelPerformance
* @param array<int, int> $channelConsults
*/
private static function excludeErCenterSubtreeFromChannelMetrics(
array $tableRowDeptIds,
array $erCenterDeptSet,
array &$channelPerformance,
array &$channelConsults
): float {
$stripped = 0.0;
foreach ($tableRowDeptIds as $tid) {
$tid = (int) $tid;
if (!isset($erCenterDeptSet[$tid])) {
continue;
}
$stripped += (float) ($channelPerformance[$tid] ?? 0);
$channelPerformance[$tid] = 0.0;
$channelConsults[$tid] = 0;
}
return round($stripped, 1);
}
/**
* 将 map 中落在二中心子树(DeptLogic)的部门 key 清零,用于进线分摊或渠道清零后的展示列。
*
* @param array<int|string, int> $map
* @param array<int, true> $erCenterDeptSet
*/
private static function zeroIntKeyedDeptMetricsInErCenterSubtree(array &$map, array $erCenterDeptSet): void
{
foreach (array_keys($map) as $k) {
$id = (int) $k;
if ($id > 0 && isset($erCenterDeptSet[$id])) {
$map[$k] = 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];
}
/**
* 「未归属中心」业绩下钻:按订单创建人汇总无法映射到展示「中心」的金额(与 aggregatePerformance 部门行互补)。
*
* @return array{
* start_date: string,
* end_date: string,
* channel_code: string,
* rows: list<array{admin_id:int, name:string, order_count:int, amount:float}>,
* note: string
* }
*/
public static function unassignedCenterBreakdown(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array
{
$c = self::resolveYejiContext($params);
if ($viewerAdminId > 0) {
self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo);
}
$startTs = (int) $c['startTs'];
$endTs = (int) $c['endTs'];
$adminToPrimary = $c['adminToPrimary'];
$byAdminAmt = self::aggregatePerformanceAmountsByAdmin($startTs, $endTs, null, null);
$byAdminCnt = self::aggregateDealOrderCountGroupedByAssistant($startTs, $endTs, null, null);
$visFlip = null;
if (!empty($c['dataScopeRestricted']) && isset($c['dataScopeVisibleAdminIds'])) {
$visFlip = array_flip(array_values(array_unique(array_filter(
array_map('intval', $c['dataScopeVisibleAdminIds']),
static function (int $v): bool {
return $v > 0;
}
))));
}
$rows = [];
foreach ($byAdminAmt as $aid => $amt) {
$aid = (int) $aid;
if ($aid <= 0) {
continue;
}
if ($visFlip !== null && !isset($visFlip[$aid])) {
continue;
}
if ((int) ($adminToPrimary[$aid] ?? 0) > 0) {
continue;
}
$rows[] = [
'admin_id' => $aid,
'name' => '',
'order_count' => (int) ($byAdminCnt[$aid] ?? 0),
'amount' => round((float) $amt, 2),
];
}
$globalAmt = self::sumGlobalPerformanceAmount($startTs, $endTs);
$globalCnt = self::countGlobalDealOrders($startTs, $endTs);
$innerAmt = array_sum($byAdminAmt);
$innerCnt = array_sum($byAdminCnt);
$orphanAmt = round(max(0.0, $globalAmt - $innerAmt), 2);
$orphanCnt = max(0, $globalCnt - $innerCnt);
if ($orphanAmt >= 0.01 || $orphanCnt > 0) {
$rows[] = [
'admin_id' => 0,
'name' => '',
'order_count' => $orphanCnt,
'amount' => $orphanAmt,
];
}
usort($rows, static function (array $a, array $b): int {
$c = $b['amount'] <=> $a['amount'];
if ($c !== 0) {
return $c;
}
return $b['order_count'] <=> $a['order_count'];
});
$ids = [];
foreach ($rows as $row) {
if ($row['admin_id'] > 0) {
$ids[] = $row['admin_id'];
}
}
$names = self::batchAdminNames(array_values(array_unique($ids)));
foreach ($rows as &$row) {
$aid = (int) $row['admin_id'];
if ($aid <= 0) {
$row['name'] = '(无订单或创建人未填)';
} else {
$row['name'] = (string) ($names[$aid] ?? ('#' . $aid));
}
}
unset($row);
return [
'start_date' => (string) $c['startDate'],
'end_date' => (string) $c['endDate'],
'channel_code' => (string) $c['channelCode'],
'rows' => $rows,
'note' => '与看板「未归属中心」补差一致:按订单 create_time、剔除履约已取消(4);**订单创建人**人事部门无法映射到任一展示「中心」计入各行;其余(无诊单/创建人为空)汇总在首行。账号数据权限下仅列出可见医助。',
];
}
/**
* @param int[] $ids
*
* @return array<int, string>
*/
private static function batchAdminNames(array $ids): array
{
if ($ids === []) {
return [];
}
$rows = Db::name('admin')
->whereIn('id', $ids)
->whereNull('delete_time')
->field(['id', 'name'])
->select()
->toArray();
$map = [];
foreach ($rows as $r) {
$map[(int) $r['id']] = (string) ($r['name'] ?? '');
}
return $map;
}
/**
* 表名 + 数据库前缀(用于 whereRaw 内嵌的 SQL,框架不会替换 raw 表名)
*/
private static function tableWithPrefix(string $table): string
{
$prefix = (string) (Db::getConfig('connections.mysql.prefix') ?: 'zyt_');
return $prefix . $table;
}
/**
* 挂号字典 channels:与渠道配置名称匹配的 value 字符串列表(用于 ap.channel_source
*
* @return string[]
*/
private static function appointmentChannelDictStringValuesForDrawer(?array $channelInfo): array
{
if ($channelInfo === null) {
return [];
}
$names = array_values(array_unique(array_filter([
trim((string) ($channelInfo['channel_name'] ?? '')),
trim((string) ($channelInfo['source_tag_name'] ?? '')),
])));
if ($names === []) {
return [];
}
$vals = Db::name('dict_data')->where('type_value', 'channels')->whereIn('name', $names)->column('value');
$out = [];
foreach ($vals as $v) {
$s = trim((string) $v);
if ($s !== '') {
$out[$s] = true;
}
}
return array_keys($out);
}
/** @var bool|null 进程内缓存:doctor_appointment 是否已加 channel_source 列 */
private static ?bool $doctorAppointmentHasChannelSourceColumn = null;
private static function doctorAppointmentHasChannelSourceColumn(): bool
{
if (self::$doctorAppointmentHasChannelSourceColumn !== null) {
return self::$doctorAppointmentHasChannelSourceColumn;
}
try {
$fields = Db::name('doctor_appointment')->getTableFields();
self::$doctorAppointmentHasChannelSourceColumn = is_array($fields)
&& in_array('channel_source', $fields, true);
} catch (\Throwable) {
self::$doctorAppointmentHasChannelSourceColumn = false;
}
return self::$doctorAppointmentHasChannelSourceColumn;
}
/** @var bool|null 进程内缓存:doctor_appointment 是否存在 channels 列 */
private static ?bool $doctorAppointmentHasChannelsColumnCache = null;
private static function doctorAppointmentHasChannelsColumn(): bool
{
if (self::$doctorAppointmentHasChannelsColumnCache !== null) {
return self::$doctorAppointmentHasChannelsColumnCache;
}
try {
$fields = Db::name('doctor_appointment')->getTableFields();
self::$doctorAppointmentHasChannelsColumnCache = is_array($fields)
&& in_array('channels', $fields, true);
} catch (\Throwable) {
self::$doctorAppointmentHasChannelsColumnCache = false;
}
return self::$doctorAppointmentHasChannelsColumnCache;
}
/**
* 选定 dict(channels) 渠道时:患者存在已完成挂号,且挂号助理为医助(role_id=2),且 (ap.channels 或 ap.channel_source) 命中配置,
* 与 {@see self::applyYejiDrawerChannelFilterToPrescriptionOrderQuery} 一致。
*
* @param string $patientIdExpr 与 ap.patient_id 比较的 SQL 表达式,如 o.diagnosis_id、dg.id
*
* @return array{0: string, 1: array}|null 无可用工件列时返回 null
*/
private static function buildChannelScopedAppointmentExistsSqlAndBindings(
string $patientIdExpr,
array $normInts,
?array $channelInfo
): ?array {
$normInts = self::normalizeAppointmentChannelValues($normInts);
$strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $normInts)));
$srcFromInfo = self::appointmentChannelDictStringValuesForDrawer($channelInfo);
$allSrcStrings = array_values(array_unique(array_filter(
array_merge($strVals, $srcFromInfo),
static fn (string $s): bool => $s !== ''
)));
$hasCh = self::doctorAppointmentHasChannelsColumn();
$hasSrc = self::doctorAppointmentHasChannelSourceColumn();
$parts = [];
$bind = [];
if ($hasCh && $strVals !== []) {
$ph = implode(',', array_fill(0, count($strVals), '?'));
$parts[] = "ap.channels IN ({$ph})";
foreach ($strVals as $sv) {
$bind[] = $sv;
}
}
if ($hasSrc && $allSrcStrings !== []) {
$ph = implode(',', array_fill(0, count($allSrcStrings), '?'));
$parts[] = "ap.channel_source IN ({$ph})";
foreach ($allSrcStrings as $s) {
$bind[] = $s;
}
}
if ($parts === []) {
return null;
}
$apTable = self::tableWithPrefix('doctor_appointment');
$adminRoleTable = self::tableWithPrefix('admin_role');
$innerOr = implode(' OR ', $parts);
$sql = "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 = {$patientIdExpr} "
. "AND ap.status = 3 AND ({$innerOr}))";
return [$sql, $bind];
}
/**
* 业绩看板业务订单侧栏:按 channel_code 收窄(与 overview「{渠道}业绩」同患者判定;含 ap.channels 与 ap.channel_source);
* **二中心及其组织下级**展示部门与看板一致:选定渠道收窄时本条无渠道订单(表中渠道业绩已为 0)。
*
* @param Query $query PrescriptionOrder 主表查询
* @param array<string, mixed> $params
*/
public static function applyYejiDrawerChannelFilterToPrescriptionOrderQuery(Query $query, array $params): void
{
if ((int) ($params['yeji_order_drawer'] ?? 0) !== 1) {
return;
}
$channelCode = trim((string) ($params['channel_code'] ?? ''));
if ($channelCode === '') {
return;
}
/** @see excludeErCenterSubtreeFromChannelMetrics 看板选定渠道时对二中心子树渠道列置 0,侧栏须同步 */
$assistantDeptForDrawer = (int) ($params['assistant_dept_id'] ?? 0);
if ($assistantDeptForDrawer > 0) {
$erCenterSubtree = DeptLogic::getErCenterSubtreeDeptIdSet();
if (isset($erCenterSubtree[$assistantDeptForDrawer])) {
$query->whereRaw('0 = 1');
return;
}
}
$startDate = '';
$endDate = '';
$st = trim((string) ($params['start_time'] ?? ''));
if (preg_match('/^(\d{4}-\d{2}-\d{2})/', $st, $m)) {
$startDate = $m[1];
}
$et = trim((string) ($params['end_time'] ?? ''));
if (preg_match('/^(\d{4}-\d{2}-\d{2})/', $et, $m)) {
$endDate = $m[1];
}
if ($startDate === '' || $endDate === '') {
return;
}
$ctx = self::resolveYejiContext([
'start_date' => $startDate,
'end_date' => $endDate,
'channel_code' => $channelCode,
'dept_ids' => null,
]);
if (!$ctx['channelFilterActive'] || $ctx['channelInfo'] === null) {
$query->whereRaw('0 = 1');
return;
}
$poTbl = (new PrescriptionOrder())->getTable();
$apTable = self::tableWithPrefix('doctor_appointment');
$adminRoleTable = self::tableWithPrefix('admin_role');
$norm = self::normalizeAppointmentChannelValues($ctx['appointmentChannelValues']);
$srcStrings = self::appointmentChannelDictStringValuesForDrawer($ctx['channelInfo']);
if ($norm !== [] || $srcStrings !== []) {
$subParts = [];
$subBind = [];
if ($norm !== []) {
$strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $norm)));
$ph = implode(',', array_fill(0, count($strVals), '?'));
$subParts[] = "ap.channels IN ({$ph})";
$subBind = array_merge($subBind, $strVals);
}
if ($srcStrings !== [] && self::doctorAppointmentHasChannelSourceColumn()) {
$ph = implode(',', array_fill(0, count($srcStrings), '?'));
$subParts[] = "ap.channel_source IN ({$ph})";
$subBind = array_merge($subBind, $srcStrings);
}
if ($subParts !== []) {
$innerOr = implode(' OR ', $subParts);
$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 = `{$poTbl}`.`diagnosis_id` "
. "AND ap.status = 3 AND ({$innerOr}))";
$query->whereRaw($existsSql, $subBind);
return;
}
}
$tagDiagIds = $ctx['tagDiagIds'];
$tagAssistantIds = $ctx['tagAssistantIds'];
$tagFallback = $ctx['tagFallback'];
$tagFilterId = (string) ($ctx['tagFilterId'] ?? '');
if ($tagFilterId === '') {
$query->whereRaw('0 = 1');
return;
}
if ($tagDiagIds !== null && $tagDiagIds === []) {
$query->whereRaw('0 = 1');
return;
}
if ($tagAssistantIds !== null && $tagAssistantIds === [] && $tagFallback) {
$query->whereRaw('0 = 1');
return;
}
if ($tagDiagIds !== null) {
$query->whereIn('diagnosis_id', $tagDiagIds);
}
if ($tagFallback && $tagAssistantIds !== null && $tagAssistantIds !== []) {
$query->whereIn('creator_id', array_map('intval', array_keys($tagAssistantIds)));
}
if ($tagDiagIds === null && !($tagFallback && $tagAssistantIds !== null && $tagAssistantIds !== [])) {
$query->whereRaw('0 = 1');
}
}
/**
* 业绩看板业务订单侧栏:在「渠道筛选」模式下,为每个诊单挑一条用于「关联挂号」展示的预约行——
* 须为已完成(status=3)、挂号助理为医助(role_id=2),且 channels/channel_source 与当前 channel_code 同源命中(与 {@see applyYejiDrawerChannelFilterToPrescriptionOrderQuery} 的 dict 收窄一致)。
* 若为 tag 等未走 channels 表达式的情况则返回空图,交由列表沿用处方/最近挂号。
*
* @param int[] $diagnosisIds doctor_appointment.patient_id(与本系统诊单 id 一致)
* @param array<string, mixed> $params 与列表同源:yeji_order_drawer、channel_code、start_time、end_time
*
* @return array<int, int> diagnosis_id => appointment_id
*/
public static function batchResolveYejiChannelDrawerHighlightAppointmentIds(array $diagnosisIds, array $params): array
{
$diagnosisIds = array_values(array_unique(array_filter(array_map('intval', $diagnosisIds), static fn (int $x): bool => $x > 0)));
if ($diagnosisIds === []) {
return [];
}
if ((int) ($params['yeji_order_drawer'] ?? 0) !== 1) {
return [];
}
$channelCode = trim((string) ($params['channel_code'] ?? ''));
if ($channelCode === '') {
return [];
}
$startDate = '';
$endDate = '';
$st = trim((string) ($params['start_time'] ?? ''));
if (preg_match('/^(\d{4}-\d{2}-\d{2})/', $st, $m)) {
$startDate = $m[1];
}
$et = trim((string) ($params['end_time'] ?? ''));
if (preg_match('/^(\d{4}-\d{2}-\d{2})/', $et, $m)) {
$endDate = $m[1];
}
if ($startDate === '' || $endDate === '') {
return [];
}
$ctx = self::resolveYejiContext([
'start_date' => $startDate,
'end_date' => $endDate,
'channel_code' => $channelCode,
'dept_ids' => null,
]);
if (!$ctx['channelFilterActive'] || $ctx['channelInfo'] === null) {
return [];
}
$norm = self::normalizeAppointmentChannelValues($ctx['appointmentChannelValues']);
$srcStrings = self::appointmentChannelDictStringValuesForDrawer($ctx['channelInfo']);
if ($norm === [] && $srcStrings === []) {
return [];
}
$apTable = self::tableWithPrefix('doctor_appointment');
$arTable = self::tableWithPrefix('admin_role');
$hasCh = self::doctorAppointmentHasChannelsColumn();
$orParts = [];
$channelBind = [];
if ($norm !== [] && $hasCh) {
$strVals = array_values(array_unique(array_map(static fn (int $v): string => (string) $v, $norm)));
$ph = implode(',', array_fill(0, count($strVals), '?'));
$orParts[] = "ap.`channels` IN ({$ph})";
foreach ($strVals as $sv) {
$channelBind[] = $sv;
}
}
if ($srcStrings !== [] && self::doctorAppointmentHasChannelSourceColumn()) {
$ph = implode(',', array_fill(0, count($srcStrings), '?'));
$orParts[] = "ap.`channel_source` IN ({$ph})";
foreach ($srcStrings as $s) {
$channelBind[] = $s;
}
}
if ($orParts === []) {
return [];
}
$inPh = implode(',', array_fill(0, count($diagnosisIds), '?'));
$bind = array_merge($diagnosisIds, $channelBind);
$orSql = '(' . implode(' OR ', $orParts) . ')';
$sql = "SELECT ap.`patient_id` AS dg, MAX(ap.`id`) AS aid FROM `{$apTable}` ap "
. "INNER JOIN `{$arTable}` ar ON ar.`admin_id` = ap.`assistant_id` AND ar.`role_id` = 2 "
. "WHERE ap.`patient_id` IN ({$inPh}) AND ap.`status` = 3 AND {$orSql} "
. 'GROUP BY ap.`patient_id`';
try {
$rows = Db::query($sql, $bind);
} catch (\Throwable) {
return [];
}
$out = [];
foreach ($rows as $r) {
$dg = (int) ($r['dg'] ?? $r['DG'] ?? 0);
$aid = (int) ($r['aid'] ?? $r['AID'] ?? 0);
if ($dg > 0 && $aid > 0) {
$out[$dg] = $aid;
}
}
return $out;
}
/**
* 提成结算统计:渠道收窄条件应用到业务订单查询(与 sumPerformance「scoped」同源)。
*/
private static function applyCommissionSettlementChannelFilter(Query $query, array $c): void
{
if (empty($c['channelFilterActive'])) {
return;
}
/** @var array<int, mixed> $appointmentChannelValues */
$appointmentChannelValues = $c['appointmentChannelValues'] ?? [];
$tagDiagIds = $c['tagDiagIds'] ?? null;
$tagAssistantIds = $c['tagAssistantIds'] ?? null;
$tagFallback = !empty($c['tagFallback']);
$channelInfo = $c['channelInfo'] ?? null;
if ($appointmentChannelValues !== []) {
$norm = self::normalizeAppointmentChannelValues($appointmentChannelValues);
$pack = self::buildChannelScopedAppointmentExistsSqlAndBindings('o.diagnosis_id', $norm, $channelInfo);
if ($pack === null) {
$query->whereRaw('0 = 1');
return;
}
$query->whereRaw($pack[0], $pack[1]);
return;
}
if ($tagDiagIds !== null && $tagDiagIds === []) {
$query->whereRaw('0 = 1');
return;
}
if ($tagAssistantIds !== null && $tagAssistantIds === [] && $tagFallback) {
$query->whereRaw('0 = 1');
return;
}
$att = self::sqlPerformanceAttributionAdminExpr();
$narrowed = false;
if ($tagDiagIds !== null) {
$idsSan = array_values(array_unique(array_filter(array_map('intval', $tagDiagIds), static fn (int $x): bool => $x > 0)));
if ($idsSan === []) {
$query->whereRaw('0 = 1');
return;
}
$narrowed = true;
$chunks = array_chunk($idsSan, 800);
if (\count($chunks) === 1) {
$query->whereIn('o.diagnosis_id', $chunks[0]);
} else {
$query->where(function ($qq) use ($chunks): void {
foreach ($chunks as $i => $chunk) {
if ($i === 0) {
$qq->whereIn('o.diagnosis_id', $chunk);
} else {
$qq->whereOr(function ($q2) use ($chunk): void {
$q2->whereIn('o.diagnosis_id', $chunk);
});
}
}
});
}
}
if ($tagAssistantIds !== null) {
$ids = array_map('intval', array_keys($tagAssistantIds));
if ($ids === []) {
$query->whereRaw('0 = 1');
return;
}
$in = implode(',', $ids);
$query->whereRaw("({$att}) IN ({$in})");
$narrowed = true;
}
if (!$narrowed) {
$query->whereRaw('0 = 1');
}
}
/**
* 提成结算:部门筛选规范化键(与确认/结转 scope 一致)。
*
* @param int[]|string|null $deptIds
*/
private static function commissionSettlementDeptIdsKey($deptIds): string
{
if ($deptIds === null || $deptIds === '' || $deptIds === []) {
return '*';
}
if (is_array($deptIds)) {
$ids = array_map('intval', $deptIds);
} else {
$ids = array_map('intval', explode(',', (string) $deptIds));
}
$ids = array_values(array_unique(array_filter($ids, static function (int $x): bool {
return $x > 0;
})));
sort($ids);
return $ids === [] ? '*' : implode(',', $ids);
}
/**
* @return int[]
*/
private static function commissionSettlementCarryOrderIdsForTarget(string $targetSettlementMonth, string $scopeChannelCode, string $scopeDeptKey): array
{
$ids = Db::name('commission_settlement_carry')
->where('target_settlement_month', $targetSettlementMonth)
->where('scope_channel_code', $scopeChannelCode)
->where('scope_dept_ids_key', $scopeDeptKey)
->column('prescription_order_id');
return array_values(array_unique(array_filter(array_map('intval', $ids), static function (int $x): bool {
return $x > 0;
})));
}
private static function commissionSettlementNextMonth(string $monthRaw): string
{
$t = strtotime($monthRaw . '-01 12:00:00');
return date('Y-m', strtotime('+1 month', $t !== false ? $t : time()));
}
/**
* @return array<string, mixed>|null
*/
private static function commissionSettlementGetConfirmRow(string $settlementMonth, string $scopeChannelCode, string $scopeDeptKey): ?array
{
$row = Db::name('commission_settlement_confirm')
->where('settlement_month', $settlementMonth)
->where('scope_channel_code', $scopeChannelCode)
->where('scope_dept_ids_key', $scopeDeptKey)
->find();
if (!$row) {
return null;
}
$r = is_array($row) ? $row : $row->toArray();
return [
'status' => (int) ($r['status'] ?? 0),
'reconcile_note' => (string) ($r['reconcile_note'] ?? ''),
'confirmed_admin_id' => (int) ($r['confirmed_admin_id'] ?? 0),
'confirmed_admin_name' => (string) ($r['confirmed_admin_name'] ?? ''),
'confirmed_at' => (int) ($r['confirmed_at'] ?? 0),
'confirmed_at_text' => !empty($r['confirmed_at']) ? date('Y-m-d H:i:s', (int) $r['confirmed_at']) : '',
'totals_json' => (string) ($r['totals_json'] ?? ''),
];
}
/**
* @return array<string, mixed>
*/
private static function commissionSettlementResolveBasePack(array $params, int $viewerAdminId, array $viewerAdminInfo): array
{
$monthRaw = trim((string) ($params['settlement_month'] ?? ''));
if (!preg_match('/^\d{4}-\d{2}$/', $monthRaw)) {
throw new \think\Exception('请传入有效的结算月 settlement_month(格式 YYYY-MM');
}
$anchor = strtotime($monthRaw . '-01 12:00:00');
if ($anchor === false) {
throw new \think\Exception('结算月日期无效');
}
$prevMonthAnchor = strtotime('-1 month', $anchor);
$orderCreateStartTs = (int) strtotime(date('Y-m-01 00:00:00', $prevMonthAnchor));
$orderCreateEndTs = (int) strtotime(date('Y-m-t 23:59:59', $prevMonthAnchor));
$cutoffTs = (int) strtotime(date('Y-m-07 23:59:59', $anchor));
$orderMonthLabel = date('Y-m', $prevMonthAnchor);
$ctxParams = array_merge($params, [
'start_date' => date('Y-m-d', $orderCreateStartTs),
'end_date' => date('Y-m-d', $orderCreateEndTs),
]);
$c = self::resolveYejiContext($ctxParams);
if ($viewerAdminId > 0) {
self::applyYejiDataScope($c, $viewerAdminId, $viewerAdminInfo);
}
$deptKey = self::commissionSettlementDeptIdsKey($params['dept_ids'] ?? null);
$scopeChannelCode = (string) ($c['channelCode'] ?? '');
$carryOrderIds = self::commissionSettlementCarryOrderIdsForTarget($monthRaw, $scopeChannelCode, $deptKey);
$diagTable = self::tableWithPrefix('tcm_diagnosis');
$rxTable = self::tableWithPrefix('tcm_prescription');
$att = self::sqlPerformanceAttributionAdminExpr();
return [
'monthRaw' => $monthRaw,
'cutoffTs' => $cutoffTs,
'orderCreateStartTs' => $orderCreateStartTs,
'orderCreateEndTs' => $orderCreateEndTs,
'orderMonthLabel' => $orderMonthLabel,
'nextSettlementMonth' => self::commissionSettlementNextMonth($monthRaw),
'deptKey' => $deptKey,
'scopeChannelCode' => $scopeChannelCode,
'carryOrderIds' => $carryOrderIds,
'c' => $c,
'tableRowDeptIds' => $c['tableRowDeptIds'],
'deptById' => $c['deptById'],
'adminToPrimary' => $c['adminToPrimary'],
'diagTable' => $diagTable,
'rxTable' => $rxTable,
'att' => $att,
];
}
/**
* @param int[] $ids
*
* @return array<int, string>
*/
private static function commissionSettlementBatchAdminNames(array $ids): array
{
$ids = array_values(array_unique(array_filter(array_map('intval', $ids))));
if ($ids === []) {
return [];
}
$rows = Db::name('admin')
->whereIn('id', $ids)
->whereNull('delete_time')
->column('name', 'id');
$out = [];
foreach ($rows as $id => $name) {
$out[(int) $id] = (string) $name;
}
return $out;
}
/**
* 挂号渠道分组:根据诊单 id 列表,反查每个诊单对应的「挂号渠道值」(doctor_appointment.channels
* 与 dict_data(type_value=channels).value 对齐)。一个诊单理论上 1:1 关联一条挂号,多挂号场景取 id 最大者。
*
* @param int[] $diagnosisIds
*
* @return array<int, int> diagnosis_id => channels (>0)
*/
private static function commissionSettlementDiagnosisLatestApptChannel(array $diagnosisIds): array
{
$ids = array_values(array_unique(array_filter(array_map('intval', $diagnosisIds), static fn (int $x): bool => $x > 0)));
if ($ids === []) {
return [];
}
$rows = Db::name('doctor_appointment')
->whereIn('patient_id', $ids)
->where('status', 3)
->where('channels', '>', 0)
->order('patient_id asc, id desc')
->field(['patient_id', 'channels'])
->select()
->toArray();
$out = [];
foreach ($rows as $r) {
$dg = (int) ($r['patient_id'] ?? 0);
if ($dg <= 0 || isset($out[$dg])) {
continue;
}
$ch = (int) ($r['channels'] ?? 0);
if ($ch > 0) {
$out[$dg] = $ch;
}
}
return $out;
}
/**
* 挂号字典 channelsvalue(int) => name(string)。仅取本次涉及到的 value 集合,减少 dict 全量读取。
*
* @param int[] $values
*
* @return array<int, string>
*/
private static function commissionSettlementApptChannelsDictMap(array $values): array
{
$values = array_values(array_unique(array_filter(array_map('intval', $values), static fn (int $x): bool => $x > 0)));
if ($values === []) {
return [];
}
$rows = Db::name('dict_data')
->where('type_value', 'channels')
->whereIn('value', array_map('strval', $values))
->field(['value', 'name'])
->select()
->toArray();
$out = [];
foreach ($rows as $r) {
$v = (int) ($r['value'] ?? 0);
$n = trim((string) ($r['name'] ?? ''));
if ($v > 0 && $n !== '') {
$out[$v] = $n;
}
}
return $out;
}
/**
* 渠道值 → 展示标签。未关联 / 字典缺失时统一标签为「未匹配挂号渠道」。
*
* @param array<int, string> $dictMap
*/
private static function commissionSettlementApptChannelLabel(int $channelValue, array $dictMap): string
{
if ($channelValue <= 0) {
return '未匹配挂号渠道';
}
$name = trim((string) ($dictMap[$channelValue] ?? ''));
if ($name === '') {
return '未匹配挂号渠道·#' . $channelValue;
}
return $name;
}
/**
* 签收 / 尾款时间:对物流与支付关联做 GROUP BY 后 LEFT JOIN,避免 SELECT 中逐行相关子查询导致 overview 超时。
*/
private static function commissionSettlementAttachExpressPayAggregates(Query $query): void
{
$etTable = self::tableWithPrefix('express_tracking');
$linkTbl = self::tableWithPrefix('tcm_prescription_order_pay_order');
$payTbl = self::tableWithPrefix('order');
$etAggSql = "(SELECT et.order_id, MAX(et.sign_time) AS cs_sign_ts FROM {$etTable} et "
. "WHERE et.order_type = 'prescription' AND et.delete_time IS NULL GROUP BY et.order_id) cs_et_sig";
$payAggSql = "(SELECT l.prescription_order_id AS cs_pay_oid, "
. 'MAX(CASE WHEN pay.order_type IN (5, 7) THEN UNIX_TIMESTAMP(pay.payment_time) END) AS cs_pay_tail_ts, '
. 'MAX(UNIX_TIMESTAMP(pay.payment_time)) AS cs_pay_any_ts '
. "FROM {$linkTbl} l INNER JOIN {$payTbl} pay ON pay.id = l.pay_order_id AND pay.delete_time IS NULL "
. 'WHERE pay.status = 2 GROUP BY l.prescription_order_id) cs_pay_agg';
// 须传入带子查询别名的字符串;ThinkPHP parseJoin 对 Db::raw 整段 JOIN 表会直接传给 parseTable,触发类型错误
$query->leftJoin($etAggSql, 'cs_et_sig.order_id = o.id');
$query->leftJoin($payAggSql, 'cs_pay_agg.cs_pay_oid = o.id');
}
/**
* @return array<int, mixed>
*/
private static function commissionSettlementSignPayFieldRawList(): array
{
return [
Db::raw('IFNULL(cs_et_sig.cs_sign_ts, 0) AS sign_ts'),
Db::raw('COALESCE(cs_pay_agg.cs_pay_tail_ts, cs_pay_agg.cs_pay_any_ts, 0) AS pay_ts'),
];
}
/**
* @param array<string, mixed> $pack commissionSettlementResolveBasePack 返回值
*/
private static function commissionSettlementBuildFilteredOrderQuery(array $pack): Query
{
$c = $pack['c'];
$diagTable = $pack['diagTable'];
$rxTable = $pack['rxTable'];
$att = $pack['att'];
$query = Db::name('tcm_prescription_order')
->alias('o')
->join("{$diagTable} dg", 'dg.id = o.diagnosis_id AND dg.delete_time IS NULL', 'INNER')
->join("{$rxTable} rx", 'rx.id = o.prescription_id AND rx.delete_time IS NULL AND IFNULL(rx.is_system_auto, 0) = 1', 'INNER');
self::commissionSettlementAttachExpressPayAggregates($query);
$query->whereNull('o.delete_time')
->where('o.fulfillment_status', 3)
->where('o.diagnosis_id', '>', 0)
->whereRaw("({$att}) > 0", []);
$query->where(function ($qq) use ($pack) {
$qq->whereBetween('o.create_time', [$pack['orderCreateStartTs'], $pack['orderCreateEndTs']]);
if ($pack['carryOrderIds'] !== []) {
$qq->whereOr(function ($q2) use ($pack) {
$q2->whereIn('o.id', $pack['carryOrderIds']);
});
}
});
self::applyCommissionSettlementChannelFilter($query, $c);
return $query;
}
/**
* 提成结算业绩看板:上月(相对结算月)系统处方对应的已完成业务单,按签收与尾款支付是否在结算月 7 日 24 点前拆分「计入本期提成 / 顺延下期」。
*
* 规则摘要:
* - 订单池:结算月上一自然月内创建;关联处方 is_system_auto=1(系统代开);履约已完成 fulfillment_status=3;未软删。
* - 签收时间:express_tracking.order_type=prescription 下 MAX(sign_time)。
* - 尾款支付时间:关联支付单 status=2,优先 order_type∈(5,7),否则回退为任意已支付关联单的 MAX(payment_time)。
* - 计入本期:签收与尾款时间均已成立且不晚于结算月 7 日 23:59:59。
* - 其余已完成单计入「顺延下期」(含签收或支付缺失、或晚于截止)。
*
* @param array{settlement_month?: string, dept_ids?: int[]|string, channel_code?: string, tag_id?: string} $params
*
* @return array<string, mixed>
*/
public static function commissionSettlementOverview(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array
{
$pack = self::commissionSettlementResolveBasePack($params, $viewerAdminId, $viewerAdminInfo);
$monthRaw = $pack['monthRaw'];
$cutoffTs = $pack['cutoffTs'];
$orderMonthLabel = $pack['orderMonthLabel'];
$c = $pack['c'];
$tableRowDeptIds = $pack['tableRowDeptIds'];
$deptById = $pack['deptById'];
$adminToPrimary = $pack['adminToPrimary'];
$carryFlip = array_flip($pack['carryOrderIds']);
$confirm = self::commissionSettlementGetConfirmRow($monthRaw, $pack['scopeChannelCode'], $pack['deptKey']);
$emptyTotal = [
'completed_order_count' => 0,
'completed_order_amount' => 0.0,
'current_period_count' => 0,
'current_period_amount' => 0.0,
'deferred_period_count' => 0,
'deferred_period_amount' => 0.0,
'carry_in_order_count' => 0,
];
if ($tableRowDeptIds === [] || $adminToPrimary === []) {
return [
'settlement_month' => $monthRaw,
'order_month' => $orderMonthLabel,
'cutoff_end' => date('Y-m-d H:i:s', $cutoffTs),
'rule_note' => '订单创建月为结算月的上一自然月;统计系统代开处方对应的已完成业务订单;签收与尾款均在结算月 7 日 24 点前完成的金额计入本期提成,否则计入顺延下期。上期「确定业绩」写入的顺延订单会在本期并入统计。',
'channel_code' => $c['channelCode'],
'channel_name' => $c['channelInfo'] ? (string) $c['channelInfo']['channel_name'] : '',
'scope_dept_ids_key' => $pack['deptKey'],
'carry_in_order_count' => 0,
'confirm' => $confirm,
'rows' => [],
'assistant_rows' => [],
'doctor_rows' => [],
'assistant_channel_rows' => [],
'doctor_channel_rows' => [],
'total' => $emptyTotal,
];
}
$query = self::commissionSettlementBuildFilteredOrderQuery($pack);
$att = $pack['att'];
$signPayFields = self::commissionSettlementSignPayFieldRawList();
$rowsRaw = $query->field(array_merge([
'o.id',
'o.order_no',
'o.diagnosis_id',
'o.create_time',
'o.amount',
Db::raw("({$att}) AS assistant_id"),
Db::raw('IFNULL(rx.creator_id, 0) AS doctor_id'),
], $signPayFields))->select()->toArray();
$diagIdSet = [];
foreach ($rowsRaw as $r) {
$dg = (int) ($r['diagnosis_id'] ?? 0);
if ($dg > 0) {
$diagIdSet[$dg] = true;
}
}
$diagToApptChannel = self::commissionSettlementDiagnosisLatestApptChannel(array_keys($diagIdSet));
$apptChannelsDictMap = self::commissionSettlementApptChannelsDictMap(array_values(array_unique(array_map('intval', $diagToApptChannel))));
$curAgg = [];
$defAgg = [];
$totAgg = [];
$cntByAsst = [];
$curCntByAsst = [];
$defCntByAsst = [];
$carryInCnt = 0;
$carryInByAssist = [];
$carryInByDoctor = [];
$totByDoctor = [];
$curByDoctor = [];
$defByDoctor = [];
$cntDoctorTot = [];
$cntDoctorCur = [];
$cntDoctorDef = [];
/** 按 (业绩归属医助 × 挂号渠道值) / (开方医生 × 挂号渠道值) 拆分聚合,渠道值取该订单对应诊单的最新已完成挂号 channels */
$assistChTot = [];
$assistChCur = [];
$assistChDef = [];
$assistChCnt = [];
$assistChCurCnt = [];
$assistChDefCnt = [];
$assistChCarry = [];
$doctorChTot = [];
$doctorChCur = [];
$doctorChDef = [];
$doctorChCnt = [];
$doctorChCurCnt = [];
$doctorChDefCnt = [];
$doctorChCarry = [];
foreach ($rowsRaw as $r) {
$oid = (int) ($r['id'] ?? 0);
$isCarry = $oid > 0 && isset($carryFlip[$oid]);
if ($isCarry) {
$carryInCnt++;
}
$aid = (int) ($r['assistant_id'] ?? 0);
$doctorId = (int) ($r['doctor_id'] ?? 0);
$signTs = (int) ($r['sign_ts'] ?? 0);
$payTs = (int) ($r['pay_ts'] ?? 0);
$amt = round((float) ($r['amount'] ?? 0), 2);
$dgId = (int) ($r['diagnosis_id'] ?? 0);
$apCh = (int) ($diagToApptChannel[$dgId] ?? 0);
if ($aid <= 0 || $amt < 0) {
continue;
}
$totAgg[$aid] = ($totAgg[$aid] ?? 0.0) + $amt;
$cntByAsst[$aid] = ($cntByAsst[$aid] ?? 0) + 1;
$eligible = $signTs > 0 && $payTs > 0 && $signTs <= $cutoffTs && $payTs <= $cutoffTs;
if ($eligible) {
$curAgg[$aid] = ($curAgg[$aid] ?? 0.0) + $amt;
$curCntByAsst[$aid] = ($curCntByAsst[$aid] ?? 0) + 1;
} else {
$defAgg[$aid] = ($defAgg[$aid] ?? 0.0) + $amt;
$defCntByAsst[$aid] = ($defCntByAsst[$aid] ?? 0) + 1;
}
if ($isCarry) {
$carryInByAssist[$aid] = ($carryInByAssist[$aid] ?? 0) + 1;
$carryInByDoctor[$doctorId] = ($carryInByDoctor[$doctorId] ?? 0) + 1;
}
$totByDoctor[$doctorId] = ($totByDoctor[$doctorId] ?? 0.0) + $amt;
$cntDoctorTot[$doctorId] = ($cntDoctorTot[$doctorId] ?? 0) + 1;
if ($eligible) {
$curByDoctor[$doctorId] = ($curByDoctor[$doctorId] ?? 0.0) + $amt;
$cntDoctorCur[$doctorId] = ($cntDoctorCur[$doctorId] ?? 0) + 1;
} else {
$defByDoctor[$doctorId] = ($defByDoctor[$doctorId] ?? 0.0) + $amt;
$cntDoctorDef[$doctorId] = ($cntDoctorDef[$doctorId] ?? 0) + 1;
}
$assistChTot[$aid][$apCh] = ($assistChTot[$aid][$apCh] ?? 0.0) + $amt;
$assistChCnt[$aid][$apCh] = ($assistChCnt[$aid][$apCh] ?? 0) + 1;
if ($eligible) {
$assistChCur[$aid][$apCh] = ($assistChCur[$aid][$apCh] ?? 0.0) + $amt;
$assistChCurCnt[$aid][$apCh] = ($assistChCurCnt[$aid][$apCh] ?? 0) + 1;
} else {
$assistChDef[$aid][$apCh] = ($assistChDef[$aid][$apCh] ?? 0.0) + $amt;
$assistChDefCnt[$aid][$apCh] = ($assistChDefCnt[$aid][$apCh] ?? 0) + 1;
}
if ($isCarry) {
$assistChCarry[$aid][$apCh] = ($assistChCarry[$aid][$apCh] ?? 0) + 1;
}
$doctorChTot[$doctorId][$apCh] = ($doctorChTot[$doctorId][$apCh] ?? 0.0) + $amt;
$doctorChCnt[$doctorId][$apCh] = ($doctorChCnt[$doctorId][$apCh] ?? 0) + 1;
if ($eligible) {
$doctorChCur[$doctorId][$apCh] = ($doctorChCur[$doctorId][$apCh] ?? 0.0) + $amt;
$doctorChCurCnt[$doctorId][$apCh] = ($doctorChCurCnt[$doctorId][$apCh] ?? 0) + 1;
} else {
$doctorChDef[$doctorId][$apCh] = ($doctorChDef[$doctorId][$apCh] ?? 0.0) + $amt;
$doctorChDefCnt[$doctorId][$apCh] = ($doctorChDefCnt[$doctorId][$apCh] ?? 0) + 1;
}
if ($isCarry) {
$doctorChCarry[$doctorId][$apCh] = ($doctorChCarry[$doctorId][$apCh] ?? 0) + 1;
}
}
$foldAmt = static function (array $byAssistant): array {
$outRows = [];
foreach ($byAssistant as $aid => $amt) {
$aid = (int) $aid;
if ($aid <= 0) {
continue;
}
$outRows[] = ['assistant_id' => $aid, 'amt' => round((float) $amt, 2)];
}
return $outRows;
};
$foldCnt = static function (array $byAssistant): array {
$outRows = [];
foreach ($byAssistant as $aid => $v) {
$aid = (int) $aid;
if ($aid <= 0) {
continue;
}
$outRows[] = ['assistant_id' => $aid, 'cnt' => (int) $v];
}
return $outRows;
};
$byDeptAmtTot = self::foldPerformanceRowsToDeptByTable($foldAmt($totAgg), $adminToPrimary, $tableRowDeptIds, $deptById, 'amt');
$byDeptAmtCur = self::foldPerformanceRowsToDeptByTable($foldAmt($curAgg), $adminToPrimary, $tableRowDeptIds, $deptById, 'amt');
$byDeptAmtDef = self::foldPerformanceRowsToDeptByTable($foldAmt($defAgg), $adminToPrimary, $tableRowDeptIds, $deptById, 'amt');
$byDeptCntTot = self::foldPerformanceRowsToDeptByTable($foldCnt($cntByAsst), $adminToPrimary, $tableRowDeptIds, $deptById, 'cnt');
$byDeptCntCur = self::foldPerformanceRowsToDeptByTable($foldCnt($curCntByAsst), $adminToPrimary, $tableRowDeptIds, $deptById, 'cnt');
$byDeptCntDef = self::foldPerformanceRowsToDeptByTable($foldCnt($defCntByAsst), $adminToPrimary, $tableRowDeptIds, $deptById, 'cnt');
$rows = [];
$sumCompletedCnt = 0;
$sumCompletedAmt = 0.0;
$sumCurCnt = 0;
$sumCurAmt = 0.0;
$sumDefCnt = 0;
$sumDefAmt = 0.0;
foreach ($tableRowDeptIds as $deptId) {
$deptId = (int) $deptId;
$cCnt = (int) ($byDeptCntTot[$deptId] ?? 0);
$cAmt = round((float) ($byDeptAmtTot[$deptId] ?? 0), 2);
$kCnt = (int) ($byDeptCntCur[$deptId] ?? 0);
$kAmt = round((float) ($byDeptAmtCur[$deptId] ?? 0), 2);
$dCnt = (int) ($byDeptCntDef[$deptId] ?? 0);
$dAmt = round((float) ($byDeptAmtDef[$deptId] ?? 0), 2);
$rows[] = [
'dept_id' => $deptId,
'dept_name' => self::formatYejiDeptRowDisplayName($deptId, $deptById),
'completed_order_count' => $cCnt,
'completed_order_amount' => $cAmt,
'current_period_count' => $kCnt,
'current_period_amount' => $kAmt,
'deferred_period_count' => $dCnt,
'deferred_period_amount' => $dAmt,
];
$sumCompletedCnt += $cCnt;
$sumCompletedAmt += $cAmt;
$sumCurCnt += $kCnt;
$sumCurAmt += $kAmt;
$sumDefCnt += $dCnt;
$sumDefAmt += $dAmt;
}
$assistantRows = [];
foreach (array_keys($totAgg) as $aid) {
$aid = (int) $aid;
if ($aid <= 0) {
continue;
}
$pid = (int) ($adminToPrimary[$aid] ?? 0);
$assistantRows[] = [
'assistant_id' => $aid,
'assistant_name' => '',
'primary_dept_id' => $pid,
'primary_dept_name' => $pid > 0 ? self::formatYejiDeptRowDisplayName($pid, $deptById) : '',
'carry_in_order_count' => (int) ($carryInByAssist[$aid] ?? 0),
'completed_order_count' => (int) ($cntByAsst[$aid] ?? 0),
'completed_order_amount' => round((float) ($totAgg[$aid] ?? 0), 2),
'current_period_count' => (int) ($curCntByAsst[$aid] ?? 0),
'current_period_amount' => round((float) ($curAgg[$aid] ?? 0), 2),
'deferred_period_count' => (int) ($defCntByAsst[$aid] ?? 0),
'deferred_period_amount' => round((float) ($defAgg[$aid] ?? 0), 2),
];
}
$assistNameMap = self::commissionSettlementBatchAdminNames(array_column($assistantRows, 'assistant_id'));
foreach ($assistantRows as &$ar) {
$aid = (int) ($ar['assistant_id'] ?? 0);
$ar['assistant_name'] = (string) ($assistNameMap[$aid] ?? ('#' . $aid));
}
unset($ar);
usort($assistantRows, static function (array $a, array $b): int {
$cmp = ($b['completed_order_amount'] ?? 0) <=> ($a['completed_order_amount'] ?? 0);
return $cmp !== 0 ? $cmp : (($b['assistant_id'] ?? 0) <=> ($a['assistant_id'] ?? 0));
});
$doctorRows = [];
foreach (array_keys($totByDoctor) as $did) {
$did = (int) $did;
$pid = $did > 0 ? (int) ($adminToPrimary[$did] ?? 0) : 0;
$doctorRows[] = [
'doctor_id' => $did,
'doctor_name' => '',
'primary_dept_id' => $pid,
'primary_dept_name' => $pid > 0 ? self::formatYejiDeptRowDisplayName($pid, $deptById) : '',
'carry_in_order_count' => (int) ($carryInByDoctor[$did] ?? 0),
'completed_order_count' => (int) ($cntDoctorTot[$did] ?? 0),
'completed_order_amount' => round((float) ($totByDoctor[$did] ?? 0), 2),
'current_period_count' => (int) ($cntDoctorCur[$did] ?? 0),
'current_period_amount' => round((float) ($curByDoctor[$did] ?? 0), 2),
'deferred_period_count' => (int) ($cntDoctorDef[$did] ?? 0),
'deferred_period_amount' => round((float) ($defByDoctor[$did] ?? 0), 2),
];
}
$doctorNameIds = array_values(array_filter(array_column($doctorRows, 'doctor_id'), static fn ($id) => (int) $id > 0));
$doctorNameMap = self::commissionSettlementBatchAdminNames($doctorNameIds);
foreach ($doctorRows as &$dr) {
$did = (int) ($dr['doctor_id'] ?? 0);
$dr['doctor_name'] = $did > 0 ? (string) ($doctorNameMap[$did] ?? ('#' . $did)) : '未关联开方人';
}
unset($dr);
usort($doctorRows, static function (array $a, array $b): int {
$za = (int) ($a['doctor_id'] ?? 0) === 0 ? 1 : 0;
$zb = (int) ($b['doctor_id'] ?? 0) === 0 ? 1 : 0;
if ($za !== $zb) {
return $za <=> $zb;
}
$cmp = ($b['completed_order_amount'] ?? 0) <=> ($a['completed_order_amount'] ?? 0);
return $cmp !== 0 ? $cmp : (($b['doctor_id'] ?? 0) <=> ($a['doctor_id'] ?? 0));
});
/** 按 (业绩归属医助 × 挂号渠道) 拆分行:复诊2 / 复诊3 / 自媒体1 等每个 channels 值各自独立一行 */
$assistantChannelRows = [];
foreach ($assistChTot as $aid => $byCh) {
$aid = (int) $aid;
if ($aid <= 0) {
continue;
}
$pid = (int) ($adminToPrimary[$aid] ?? 0);
foreach ($byCh as $ch => $amt) {
$ch = (int) $ch;
$assistantChannelRows[] = [
'assistant_id' => $aid,
'assistant_name' => (string) ($assistNameMap[$aid] ?? ('#' . $aid)),
'primary_dept_id' => $pid,
'primary_dept_name' => $pid > 0 ? self::formatYejiDeptRowDisplayName($pid, $deptById) : '',
'appt_channel_value' => $ch,
'appt_channel_name' => self::commissionSettlementApptChannelLabel($ch, $apptChannelsDictMap),
'carry_in_order_count' => (int) ($assistChCarry[$aid][$ch] ?? 0),
'completed_order_count' => (int) ($assistChCnt[$aid][$ch] ?? 0),
'completed_order_amount' => round((float) $amt, 2),
'current_period_count' => (int) ($assistChCurCnt[$aid][$ch] ?? 0),
'current_period_amount' => round((float) ($assistChCur[$aid][$ch] ?? 0), 2),
'deferred_period_count' => (int) ($assistChDefCnt[$aid][$ch] ?? 0),
'deferred_period_amount' => round((float) ($assistChDef[$aid][$ch] ?? 0), 2),
];
}
}
/** 同医助按总业绩 desc 排序,组内按当前行金额 desc;保证同医助行连续,方便前端合并单元格 */
usort($assistantChannelRows, static function (array $a, array $b) use ($totAgg): int {
$aa = (int) ($a['assistant_id'] ?? 0);
$bb = (int) ($b['assistant_id'] ?? 0);
if ($aa !== $bb) {
$aTot = (float) ($totAgg[$aa] ?? 0);
$bTot = (float) ($totAgg[$bb] ?? 0);
if ($aTot !== $bTot) {
return $bTot <=> $aTot;
}
return $bb <=> $aa;
}
return ($b['completed_order_amount'] ?? 0) <=> ($a['completed_order_amount'] ?? 0);
});
/** 按 (开方医生 × 挂号渠道) 拆分行;未关联开方人(0) 行也保留,沉到末尾 */
$doctorChannelRows = [];
foreach ($doctorChTot as $did => $byCh) {
$did = (int) $did;
$pid = $did > 0 ? (int) ($adminToPrimary[$did] ?? 0) : 0;
foreach ($byCh as $ch => $amt) {
$ch = (int) $ch;
$doctorChannelRows[] = [
'doctor_id' => $did,
'doctor_name' => $did > 0 ? (string) ($doctorNameMap[$did] ?? ('#' . $did)) : '未关联开方人',
'primary_dept_id' => $pid,
'primary_dept_name' => $pid > 0 ? self::formatYejiDeptRowDisplayName($pid, $deptById) : '',
'appt_channel_value' => $ch,
'appt_channel_name' => self::commissionSettlementApptChannelLabel($ch, $apptChannelsDictMap),
'carry_in_order_count' => (int) ($doctorChCarry[$did][$ch] ?? 0),
'completed_order_count' => (int) ($doctorChCnt[$did][$ch] ?? 0),
'completed_order_amount' => round((float) $amt, 2),
'current_period_count' => (int) ($doctorChCurCnt[$did][$ch] ?? 0),
'current_period_amount' => round((float) ($doctorChCur[$did][$ch] ?? 0), 2),
'deferred_period_count' => (int) ($doctorChDefCnt[$did][$ch] ?? 0),
'deferred_period_amount' => round((float) ($doctorChDef[$did][$ch] ?? 0), 2),
];
}
}
usort($doctorChannelRows, static function (array $a, array $b) use ($totByDoctor): int {
$aa = (int) ($a['doctor_id'] ?? 0);
$bb = (int) ($b['doctor_id'] ?? 0);
/** 「未关联开方人」(doctor_id=0) 一律置末尾 */
$za = $aa === 0 ? 1 : 0;
$zb = $bb === 0 ? 1 : 0;
if ($za !== $zb) {
return $za <=> $zb;
}
if ($aa !== $bb) {
$aTot = (float) ($totByDoctor[$aa] ?? 0);
$bTot = (float) ($totByDoctor[$bb] ?? 0);
if ($aTot !== $bTot) {
return $bTot <=> $aTot;
}
return $bb <=> $aa;
}
return ($b['completed_order_amount'] ?? 0) <=> ($a['completed_order_amount'] ?? 0);
});
return [
'settlement_month' => $monthRaw,
'order_month' => $orderMonthLabel,
'cutoff_end' => date('Y-m-d H:i:s', $cutoffTs),
'rule_note' => '订单创建月为结算月的上一自然月;仅统计系统代开处方(is_system_auto=1)对应的履约已完成业务订单。签收时间取物流表签收时间最大值;尾款时间取关联支付单已支付记录(优先类型为尾款/全部)。签收与尾款均不晚于结算月 7 日 24 点的计入「本期提成」,其余已完成订单计入「顺延下期」。上期同渠道同部门范围内「确定业绩」产生的顺延订单,会计入本期订单池。「按医助下发」「按医生下发」按 (人 × 挂号渠道) 拆分;挂号渠道取该订单对应诊单的最新已完成挂号 doctor_appointment.channelsdict_data type_value=channels),如「复诊2」「自媒体1」等保持各自独立一行,未关联挂号统一展示为「未匹配挂号渠道」。',
'channel_code' => $c['channelCode'],
'channel_name' => $c['channelInfo'] ? (string) $c['channelInfo']['channel_name'] : '',
'scope_dept_ids_key' => $pack['deptKey'],
'carry_in_order_count' => $carryInCnt,
'confirm' => $confirm,
'rows' => $rows,
'assistant_rows' => $assistantRows,
'doctor_rows' => $doctorRows,
'assistant_channel_rows' => $assistantChannelRows,
'doctor_channel_rows' => $doctorChannelRows,
'total' => [
'completed_order_count' => $sumCompletedCnt,
'completed_order_amount' => round($sumCompletedAmt, 2),
'current_period_count' => $sumCurCnt,
'current_period_amount' => round($sumCurAmt, 2),
'deferred_period_count' => $sumDefCnt,
'deferred_period_amount' => round($sumDefAmt, 2),
'carry_in_order_count' => $carryInCnt,
],
];
}
/**
* 提成核对:订单明细(分页)。
*
* @param array{settlement_month?: string, dept_ids?: int[]|string, channel_code?: string, page?: int|string, page_size?: int|string, bucket?: string} $params bucket: 空|current|deferred
*
* @return array<string, mixed>
*/
public static function commissionSettlementOrderLines(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array
{
$pack = self::commissionSettlementResolveBasePack($params, $viewerAdminId, $viewerAdminInfo);
$cutoffTs = $pack['cutoffTs'];
$tableRowDeptIds = $pack['tableRowDeptIds'];
$deptById = $pack['deptById'];
$adminToPrimary = $pack['adminToPrimary'];
$carryFlip = array_flip($pack['carryOrderIds']);
$bucket = trim((string) ($params['bucket'] ?? ''));
$page = max(1, (int) ($params['page'] ?? 1));
$pageSize = min(100, max(10, (int) ($params['page_size'] ?? 20)));
if ($tableRowDeptIds === [] || $adminToPrimary === []) {
return ['list' => [], 'count' => 0, 'page' => $page, 'page_size' => $pageSize];
}
$query = self::commissionSettlementBuildFilteredOrderQuery($pack);
$att = $pack['att'];
$signPayFields = self::commissionSettlementSignPayFieldRawList();
$baseRows = $query->field(array_merge([
'o.id',
'o.order_no',
'o.diagnosis_id',
'o.create_time',
'o.amount',
Db::raw("({$att}) AS assistant_id"),
Db::raw('IFNULL(rx.creator_id, 0) AS doctor_id'),
], $signPayFields))->order('o.id', 'desc')->select()->toArray();
$mapped = [];
foreach ($baseRows as $r) {
$signTs = (int) ($r['sign_ts'] ?? 0);
$payTs = (int) ($r['pay_ts'] ?? 0);
$eligible = $signTs > 0 && $payTs > 0 && $signTs <= $cutoffTs && $payTs <= $cutoffTs;
$b = $eligible ? 'current' : 'deferred';
if ($bucket === 'current' && $b !== 'current') {
continue;
}
if ($bucket === 'deferred' && $b !== 'deferred') {
continue;
}
$oid = (int) ($r['id'] ?? 0);
$aid = (int) ($r['assistant_id'] ?? 0);
$doctorId = (int) ($r['doctor_id'] ?? 0);
$foldRows = [['assistant_id' => $aid, 'cnt' => 1]];
$byDept = self::foldPerformanceRowsToDeptByTable($foldRows, $adminToPrimary, $tableRowDeptIds, $deptById, 'cnt');
$primaryDeptId = 0;
foreach ($byDept as $did => $_c) {
$primaryDeptId = (int) $did;
break;
}
$mapped[] = [
'id' => $oid,
'order_no' => (string) ($r['order_no'] ?? ''),
'diagnosis_id' => (int) ($r['diagnosis_id'] ?? 0),
'amount' => round((float) ($r['amount'] ?? 0), 2),
'create_time' => (int) ($r['create_time'] ?? 0),
'create_time_text' => !empty($r['create_time']) ? date('Y-m-d H:i:s', (int) $r['create_time']) : '',
'sign_ts' => $signTs,
'sign_time_text' => $signTs > 0 ? date('Y-m-d H:i:s', $signTs) : '',
'pay_ts' => $payTs,
'pay_time_text' => $payTs > 0 ? date('Y-m-d H:i:s', $payTs) : '',
'assistant_id' => $aid,
'doctor_id' => $doctorId,
'assistant_name' => '',
'doctor_name' => '',
'bucket' => $b,
'is_carry_in' => $oid > 0 && isset($carryFlip[$oid]) ? 1 : 0,
'primary_dept_id' => $primaryDeptId,
'primary_dept_name' => $primaryDeptId > 0 ? self::formatYejiDeptRowDisplayName($primaryDeptId, $deptById) : '',
];
}
$nameIds = [];
foreach ($mapped as $row) {
$na = (int) ($row['assistant_id'] ?? 0);
if ($na > 0) {
$nameIds[$na] = true;
}
$nd = (int) ($row['doctor_id'] ?? 0);
if ($nd > 0) {
$nameIds[$nd] = true;
}
}
$adminNames = self::commissionSettlementBatchAdminNames(array_map('intval', array_keys($nameIds)));
foreach ($mapped as &$row) {
$na = (int) ($row['assistant_id'] ?? 0);
$nd = (int) ($row['doctor_id'] ?? 0);
$row['assistant_name'] = $na > 0 ? (string) ($adminNames[$na] ?? ('#' . $na)) : '—';
$row['doctor_name'] = $nd > 0 ? (string) ($adminNames[$nd] ?? ('#' . $nd)) : '未关联开方人';
}
unset($row);
$total = count($mapped);
$slice = array_slice($mapped, ($page - 1) * $pageSize, $pageSize);
return [
'list' => $slice,
'count' => $total,
'page' => $page,
'page_size' => $pageSize,
];
}
/**
* 当前筛选条件下的核对 / 确定状态。
*
* @param array{settlement_month?: string, dept_ids?: int[]|string, channel_code?: string} $params
*
* @return array<string, mixed>
*/
public static function commissionSettlementConfirmStatus(array $params, int $viewerAdminId = 0, array $viewerAdminInfo = []): array
{
$pack = self::commissionSettlementResolveBasePack($params, $viewerAdminId, $viewerAdminInfo);
$row = self::commissionSettlementGetConfirmRow($pack['monthRaw'], $pack['scopeChannelCode'], $pack['deptKey']);
return [
'settlement_month' => $pack['monthRaw'],
'scope_channel_code' => $pack['scopeChannelCode'],
'scope_dept_ids_key' => $pack['deptKey'],
'confirm' => $row,
];
}
/**
* 保存核对备注(未确定前可反复保存)。
*
* @param array{settlement_month?: string, dept_ids?: int[]|string, channel_code?: string, reconcile_note?: string} $params
*/
public static function commissionSettlementSaveReconcile(array $params, int $viewerAdminId, array $viewerAdminInfo): array
{
if ($viewerAdminId <= 0) {
throw new \think\Exception('未登录');
}
$pack = self::commissionSettlementResolveBasePack($params, $viewerAdminId, $viewerAdminInfo);
$note = mb_substr(trim((string) ($params['reconcile_note'] ?? '')), 0, 500);
$t = time();
$exists = Db::name('commission_settlement_confirm')
->where('settlement_month', $pack['monthRaw'])
->where('scope_channel_code', $pack['scopeChannelCode'])
->where('scope_dept_ids_key', $pack['deptKey'])
->find();
if ($exists) {
$ex = is_array($exists) ? $exists : $exists->toArray();
if ((int) ($ex['status'] ?? 0) === 1) {
throw new \think\Exception('已确定业绩的记录不可修改核对备注,如需更正请联系管理员处理数据');
}
Db::name('commission_settlement_confirm')->where('id', (int) $ex['id'])->update([
'reconcile_note' => $note,
'update_time' => $t,
]);
} else {
Db::name('commission_settlement_confirm')->insert([
'settlement_month' => $pack['monthRaw'],
'scope_channel_code' => $pack['scopeChannelCode'],
'scope_dept_ids_key' => $pack['deptKey'],
'status' => 0,
'reconcile_note' => $note,
'totals_json' => '',
'confirmed_admin_id' => 0,
'confirmed_admin_name' => '',
'confirmed_at' => 0,
'create_time' => $t,
'update_time' => $t,
]);
}
return ['ok' => true];
}
/**
* 确定本期业绩:写入快照,并将「顺延下期」订单结转至下一结算月同 scope。
*
* @param array{settlement_month?: string, dept_ids?: int[]|string, channel_code?: string, reconcile_note?: string} $params
*/
public static function commissionSettlementConfirmFinalize(array $params, int $viewerAdminId, array $viewerAdminInfo): array
{
if ($viewerAdminId <= 0) {
throw new \think\Exception('未登录');
}
$pack = self::commissionSettlementResolveBasePack($params, $viewerAdminId, $viewerAdminInfo);
$tableRowDeptIds = $pack['tableRowDeptIds'];
$adminToPrimary = $pack['adminToPrimary'];
if ($tableRowDeptIds === [] || $adminToPrimary === []) {
throw new \think\Exception('当前账号无可操作的展示部门,无法确定业绩');
}
$exists = Db::name('commission_settlement_confirm')
->where('settlement_month', $pack['monthRaw'])
->where('scope_channel_code', $pack['scopeChannelCode'])
->where('scope_dept_ids_key', $pack['deptKey'])
->find();
if ($exists) {
$ex = is_array($exists) ? $exists : $exists->toArray();
if ((int) ($ex['status'] ?? 0) === 1) {
throw new \think\Exception('该结算月在同渠道同部门筛选下已确定业绩,请勿重复提交');
}
}
$overview = self::commissionSettlementOverview($params, $viewerAdminId, $viewerAdminInfo);
$total = $overview['total'] ?? [];
$query = self::commissionSettlementBuildFilteredOrderQuery($pack);
$att = $pack['att'];
$signPayFields = self::commissionSettlementSignPayFieldRawList();
$cutoffTs = $pack['cutoffTs'];
$rowsRaw = $query->field(array_merge([
'o.id',
'o.create_time',
Db::raw("({$att}) AS assistant_id"),
], $signPayFields))->select()->toArray();
$deferredIds = [];
foreach ($rowsRaw as $r) {
$signTs = (int) ($r['sign_ts'] ?? 0);
$payTs = (int) ($r['pay_ts'] ?? 0);
$eligible = $signTs > 0 && $payTs > 0 && $signTs <= $cutoffTs && $payTs <= $cutoffTs;
if (!$eligible) {
$deferredIds[] = (int) ($r['id'] ?? 0);
}
}
$deferredIds = array_values(array_unique(array_filter($deferredIds, static fn (int $x): bool => $x > 0)));
$adminName = (string) (Db::name('admin')->where('id', $viewerAdminId)->whereNull('delete_time')->value('name') ?: '');
$note = mb_substr(trim((string) ($params['reconcile_note'] ?? '')), 0, 500);
$t = time();
$next = $pack['nextSettlementMonth'];
$totalsJson = json_encode([
'total' => $total,
'deferred_ids' => $deferredIds,
'next_carry_month' => $next,
], JSON_UNESCAPED_UNICODE);
Db::startTrans();
try {
Db::name('commission_settlement_carry')
->where('source_settlement_month', $pack['monthRaw'])
->where('scope_channel_code', $pack['scopeChannelCode'])
->where('scope_dept_ids_key', $pack['deptKey'])
->delete();
foreach ($deferredIds as $poId) {
$dup = Db::name('commission_settlement_carry')
->where('prescription_order_id', $poId)
->where('target_settlement_month', $next)
->where('scope_channel_code', $pack['scopeChannelCode'])
->where('scope_dept_ids_key', $pack['deptKey'])
->find();
if ($dup) {
continue;
}
Db::name('commission_settlement_carry')->insert([
'prescription_order_id' => $poId,
'target_settlement_month' => $next,
'source_settlement_month' => $pack['monthRaw'],
'scope_channel_code' => $pack['scopeChannelCode'],
'scope_dept_ids_key' => $pack['deptKey'],
'create_time' => $t,
]);
}
if ($exists) {
$ex = is_array($exists) ? $exists : $exists->toArray();
Db::name('commission_settlement_confirm')->where('id', (int) $ex['id'])->update([
'status' => 1,
'reconcile_note' => $note !== '' ? $note : (string) ($ex['reconcile_note'] ?? ''),
'totals_json' => (string) $totalsJson,
'confirmed_admin_id' => $viewerAdminId,
'confirmed_admin_name' => mb_substr($adminName, 0, 64),
'confirmed_at' => $t,
'update_time' => $t,
]);
} else {
Db::name('commission_settlement_confirm')->insert([
'settlement_month' => $pack['monthRaw'],
'scope_channel_code' => $pack['scopeChannelCode'],
'scope_dept_ids_key' => $pack['deptKey'],
'status' => 1,
'reconcile_note' => $note,
'totals_json' => (string) $totalsJson,
'confirmed_admin_id' => $viewerAdminId,
'confirmed_admin_name' => mb_substr($adminName, 0, 64),
'confirmed_at' => $t,
'create_time' => $t,
'update_time' => $t,
]);
}
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
return [
'ok' => true,
'next_settlement_month' => $next,
'deferred_carry_count' => count($deferredIds),
];
}
}