5963 lines
236 KiB
PHP
5963 lines
236 KiB
PHP
<?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=4;NULL 与其它状态计入,按 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_slot:0=复诊合计(全部复诊笔),≥2=对应分项
|
||
*
|
||
* @return array{
|
||
* start_date:string,end_date:string,dept_id:int,dept_name:string,revisit_slot:int,revisit_slot_label:string,
|
||
* rows:list<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 处方 rx(rx.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;
|
||
* 可选 tag:diagnosis_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;
|
||
}
|
||
|
||
/**
|
||
* 挂号字典 channels:value(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.channels(dict_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),
|
||
];
|
||
}
|
||
}
|