1155 lines
48 KiB
PHP
1155 lines
48 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace app\adminapi\logic\firstvisit;
|
||
|
||
use app\adminapi\logic\auth\AuthLogic;
|
||
use app\adminapi\logic\dept\DeptLogic;
|
||
use app\adminapi\logic\stats\ConversionLogic;
|
||
use app\adminapi\logic\stats\YejiStatsLogic;
|
||
use app\common\model\auth\Admin;
|
||
use app\common\model\auth\AdminDept;
|
||
use app\common\model\stats\PersonalYeji;
|
||
use app\common\service\DataScope\DataScopeService;
|
||
use app\common\service\qywx\MediaChannelService;
|
||
use think\facade\Db;
|
||
|
||
/**
|
||
* 一诊「综合数据转化」。
|
||
*
|
||
* 自动指标复用 ConversionLogic;开口数来自个人业绩录入。所有筛选先与 DataScope
|
||
* 可见管理员集合取交集,HTTP 参数不能扩大当前账号的数据范围。
|
||
*/
|
||
class FirstVisitConversionLogic
|
||
{
|
||
private const ASSISTANT_ROLE_ID = 2;
|
||
private const FINANCE_PERMISSION = 'firstvisit.conversion/viewFinance';
|
||
private const FINANCE_ALWAYS_ROLE_NAMES = ['经理', '管理员', '系统管理员'];
|
||
private const FINANCE_FIELD_KEYS = ['account_cost', 'cash_cost', 'roi'];
|
||
|
||
/** @return array<string,mixed> */
|
||
public static function overview(array $params, int $adminId, array $adminInfo): array
|
||
{
|
||
$context = self::resolveScopeContext($params, $adminId, $adminInfo);
|
||
$startDate = $context['start_date'];
|
||
$endDate = $context['end_date'];
|
||
$timeType = $context['time_type'];
|
||
$timeLabel = $context['time_label'];
|
||
$allowedDeptSet = $context['allowed_dept_set'];
|
||
$baseVisibleAdminIds = $context['base_visible_admin_ids'];
|
||
$scopeValue = $context['scope_value'];
|
||
$selectedDeptId = $context['selected_dept_id'];
|
||
$selectedAssistantId = $context['selected_assistant_id'];
|
||
$selectedMediaChannel = $context['selected_media_channel'];
|
||
$selectedMediaChannelCode = $context['selected_media_channel_code'];
|
||
$deptSelectionValid = $context['dept_selection_valid'];
|
||
$selectedDeptIds = $context['selected_dept_ids'];
|
||
$effectiveAdminIds = $context['effective_admin_ids'];
|
||
$selectedAssistantValid = $context['selected_assistant_valid'];
|
||
$costAllocationAdminIds = self::costAllocationAdminIds(
|
||
$effectiveAdminIds,
|
||
$scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0
|
||
);
|
||
|
||
$conversionParams = [
|
||
'dimension' => 'dept',
|
||
'time_type' => 'custom',
|
||
'start_date' => $startDate,
|
||
'end_date' => $endDate,
|
||
// 一诊筛选项在本层按自身权限和“当前企微标签”口径生成,不再让通用
|
||
// Conversion 额外加载一套包含历史渠道的筛选器。
|
||
'include_filters' => 0,
|
||
'include_members' => 1,
|
||
'exclude_cancelled_appointments' => 1,
|
||
'order_metric_mode' => 'performance',
|
||
'page_no' => 1,
|
||
'page_size' => 100,
|
||
];
|
||
if ($selectedDeptId > 0 && $deptSelectionValid) {
|
||
$conversionParams['dept_id'] = $selectedDeptId;
|
||
}
|
||
if ($selectedMediaChannelCode !== '') {
|
||
$conversionParams['media_channel_code'] = $selectedMediaChannelCode;
|
||
}
|
||
|
||
$conversion = ConversionLogic::overview(
|
||
$conversionParams,
|
||
$adminId,
|
||
$adminInfo,
|
||
$effectiveAdminIds,
|
||
$costAllocationAdminIds,
|
||
$selectedMediaChannel
|
||
);
|
||
$rows = is_array($conversion['lists'] ?? null) ? $conversion['lists'] : [];
|
||
$rowAllowedDeptIds = self::visibleRowDeptIds($effectiveAdminIds);
|
||
if ($rowAllowedDeptIds !== null) {
|
||
$rows = self::filterDeptRows($rows, array_fill_keys($rowAllowedDeptIds, true));
|
||
}
|
||
|
||
$rowDeptIdSet = [];
|
||
self::collectRowDeptIds($rows, $rowDeptIdSet);
|
||
$openCounts = self::loadOpenCounts(
|
||
$startDate,
|
||
$endDate,
|
||
$effectiveAdminIds,
|
||
array_fill_keys(array_keys($rowDeptIdSet), true),
|
||
self::personalYejiMediaSources($selectedMediaChannelCode, $selectedMediaChannel)
|
||
);
|
||
$openDirect = $openCounts['dept'];
|
||
self::applyOpenCounts($rows, $openDirect, $openCounts['admin']);
|
||
|
||
$summary = is_array($conversion['summary'] ?? null) ? $conversion['summary'] : [];
|
||
$summary['total_open_count'] = array_sum($openDirect);
|
||
$summary['total_open_rate'] = self::percent(
|
||
(int) $summary['total_open_count'],
|
||
(int) ($summary['add_fans_count'] ?? 0)
|
||
);
|
||
$summary['open_appointment_rate'] = self::percent(
|
||
(int) ($summary['paid_appointment_count'] ?? 0),
|
||
(int) $summary['total_open_count']
|
||
);
|
||
$summary['open_receive_rate'] = self::percent(
|
||
(int) ($summary['completed_order_count'] ?? 0),
|
||
(int) $summary['total_open_count']
|
||
);
|
||
|
||
$rankingKind = self::rankingKind($scopeValue, $selectedAssistantId);
|
||
$rankingRows = self::rankingRows($rows, $rankingKind);
|
||
// 目前只维护了部门月度目标;本人范围或筛选单个员工时不能拿整个部门目标冒充个人目标。
|
||
$targetDeptIds = ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0)
|
||
? []
|
||
: self::resolveTargetDeptIds($allowedDeptSet, $selectedDeptIds, $selectedDeptId);
|
||
$target = self::buildTargetProgress((int) date('Y'), $effectiveAdminIds, $targetDeptIds);
|
||
|
||
$selectedDeptName = $selectedDeptId > 0 && $deptSelectionValid
|
||
? (string) (Db::name('dept')->where('id', $selectedDeptId)->whereNull('delete_time')->value('name') ?? '')
|
||
: '';
|
||
$selectedAssistantName = $selectedAssistantId > 0 && $selectedAssistantValid
|
||
? (string) (Admin::where('id', $selectedAssistantId)->whereNull('delete_time')->value('name') ?? '')
|
||
: '';
|
||
$selectedMediaChannelName = $selectedMediaChannelCode !== ''
|
||
? (string) ($selectedMediaChannel['channel_name'] ?? $selectedMediaChannelCode)
|
||
: '';
|
||
if ($selectedMediaChannelName !== '' && !empty($selectedMediaChannel['is_group'])) {
|
||
$selectedMediaChannelName .= '(全部)';
|
||
}
|
||
$canViewFinance = self::canViewFinance($adminId, $adminInfo);
|
||
if (!$canViewFinance) {
|
||
$summary = self::maskFinanceFields($summary);
|
||
foreach ($rows as &$row) {
|
||
if (is_array($row)) {
|
||
$row = self::maskFinanceFields($row);
|
||
}
|
||
}
|
||
unset($row);
|
||
}
|
||
return [
|
||
'meta' => [
|
||
'time_type' => $timeType,
|
||
'time_label' => $timeLabel,
|
||
'start_date' => $startDate,
|
||
'end_date' => $endDate,
|
||
'generated_at' => date('Y-m-d H:i:s'),
|
||
'scope_value' => $scopeValue,
|
||
'scope_label' => DataScopeService::scopeLabel($scopeValue),
|
||
'ranking_kind' => $rankingKind,
|
||
'selected_dept_name' => $selectedDeptName,
|
||
'selected_assistant_name' => $selectedAssistantName,
|
||
'selected_media_channel_code' => $selectedMediaChannelCode,
|
||
'selected_media_channel_name' => $selectedMediaChannelName,
|
||
'open_count_source' => $selectedMediaChannelCode === ''
|
||
? '个人业绩录入'
|
||
: '个人业绩录入(按渠道名称匹配)',
|
||
'can_view_finance' => $canViewFinance,
|
||
'appointment_rule' => '按预约日期统计,归属优先挂号医助、再回退诊单医助;仅含已预约、已完成和已过号',
|
||
'registration_rule' => '按支付时间统计已支付且实收金额大于 0、低于 10 元的订单,每笔计 1 个挂号并按订单创建人归属',
|
||
'performance_rule' => '按业务订单创建时间和创建人统计,排除取消、拒收、退款及已发生退款的订单',
|
||
],
|
||
'filters' => [
|
||
'departments' => DeptLogic::getAllDataScoped($adminId, $adminInfo),
|
||
'assistants' => self::assistantOptions($baseVisibleAdminIds, $selectedDeptIds, $selectedDeptId),
|
||
'media_channels' => MediaChannelService::getCurrentTagOptions(),
|
||
],
|
||
'summary' => $summary,
|
||
'rankings' => [
|
||
'orders' => self::topRows($rankingRows, 'completed_order_count'),
|
||
'amounts' => self::topRows($rankingRows, 'completed_order_amount'),
|
||
],
|
||
'rows' => $rows,
|
||
'target' => $target,
|
||
];
|
||
}
|
||
|
||
/** @return array<string,mixed> */
|
||
public static function fansDetail(array $params, int $adminId, array $adminInfo): array
|
||
{
|
||
$context = self::resolveScopeContext($params, $adminId, $adminInfo);
|
||
$context['channel_dept_ids'] = ConversionLogic::fanDetailChannelDeptIds(
|
||
$context['selected_media_channel']
|
||
);
|
||
$pageNo = max(1, (int) ($params['page_no'] ?? 1));
|
||
$pageSize = max(1, min(100, (int) ($params['page_size'] ?? 20)));
|
||
$empty = [
|
||
'lists' => [],
|
||
'count' => 0,
|
||
'page_no' => $pageNo,
|
||
'page_size' => $pageSize,
|
||
'date_range' => [$context['start_date'], $context['end_date']],
|
||
'entity' => null,
|
||
];
|
||
|
||
$entityType = strtolower(trim((string) ($params['entity_type'] ?? '')));
|
||
if (!in_array($entityType, ['dept', 'member'], true)) {
|
||
return $empty;
|
||
}
|
||
|
||
$entityId = trim((string) ($params['entity_id'] ?? ''));
|
||
$requestedAdminId = max(0, (int) ($params['admin_id'] ?? 0));
|
||
$resolved = self::resolveFanDetailEntity(
|
||
$entityType,
|
||
$entityId,
|
||
$requestedAdminId,
|
||
$context
|
||
);
|
||
if ($resolved === null) {
|
||
return $empty;
|
||
}
|
||
$target = $resolved['target'];
|
||
|
||
$conversionParams = [
|
||
'dimension' => 'dept',
|
||
'time_type' => 'custom',
|
||
'start_date' => $context['start_date'],
|
||
'end_date' => $context['end_date'],
|
||
'page_no' => $pageNo,
|
||
'page_size' => $pageSize,
|
||
];
|
||
if ((int) $context['selected_dept_id'] > 0 && !empty($context['dept_selection_valid'])) {
|
||
$conversionParams['dept_id'] = (int) $context['selected_dept_id'];
|
||
}
|
||
if ((string) $context['selected_media_channel_code'] !== '') {
|
||
$conversionParams['media_channel_code'] = (string) $context['selected_media_channel_code'];
|
||
}
|
||
|
||
$result = ConversionLogic::fanDetails(
|
||
$conversionParams,
|
||
$target,
|
||
$adminId,
|
||
$adminInfo,
|
||
$context['effective_admin_ids'],
|
||
$context['selected_media_channel']
|
||
);
|
||
if ((int) ($result['count'] ?? 0) <= 0) {
|
||
return $empty;
|
||
}
|
||
$result['entity'] = [
|
||
'type' => $entityType,
|
||
'id' => $resolved['id'],
|
||
'admin_id' => (int) $resolved['admin_id'],
|
||
'name' => (string) $resolved['name'],
|
||
'add_fans_count' => (int) $result['count'],
|
||
'deleted_fans_count' => (int) ($result['deleted_count'] ?? 0),
|
||
];
|
||
unset($result['deleted_count']);
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* Resolve only the clicked entity and its authorized target range. This is
|
||
* deliberately structural: it avoids recomputing all overview metrics,
|
||
* while still rejecting ids that do not exist in the current DataScope.
|
||
*
|
||
* @param array<string,mixed> $context
|
||
* @return array{id:string|int,admin_id:int,name:string,target:array<string,mixed>}|null
|
||
*/
|
||
private static function resolveFanDetailEntity(
|
||
string $entityType,
|
||
string $entityId,
|
||
int $requestedAdminId,
|
||
array $context
|
||
): ?array {
|
||
$visibleAdminIds = $context['effective_admin_ids'];
|
||
$selectedDeptId = (int) $context['selected_dept_id'];
|
||
$channelDeptIds = $context['channel_dept_ids'] ?? null;
|
||
|
||
if ($entityType === 'member' && str_starts_with($entityId, 'U_')) {
|
||
$wecomUserId = trim(substr($entityId, 2));
|
||
if ($wecomUserId === '' || $requestedAdminId > 0 || $visibleAdminIds !== null || $selectedDeptId > 0) {
|
||
return null;
|
||
}
|
||
$isBound = Db::name('admin')
|
||
->whereNull('delete_time')
|
||
->where('work_wechat_userid', $wecomUserId)
|
||
->count() > 0;
|
||
if ($isBound) {
|
||
return null;
|
||
}
|
||
$historicalName = trim((string) (Db::name('admin')
|
||
->where('work_wechat_userid', $wecomUserId)
|
||
->order('id', 'desc')
|
||
->value('name') ?? ''));
|
||
|
||
return [
|
||
'id' => $entityId,
|
||
'admin_id' => 0,
|
||
'name' => $historicalName !== ''
|
||
? $historicalName . '(' . $wecomUserId . ')'
|
||
: $wecomUserId,
|
||
'target' => ['type' => 'wecom_user', 'wecom_userid' => $wecomUserId],
|
||
];
|
||
}
|
||
|
||
if ($entityType === 'member') {
|
||
if (!preg_match('/^M([1-9]\d*)_(-?\d+)$/', $entityId, $matches)) {
|
||
return null;
|
||
}
|
||
$targetAdminId = (int) $matches[1];
|
||
$rowDeptId = (int) $matches[2];
|
||
if (($requestedAdminId > 0 && $requestedAdminId !== $targetAdminId)
|
||
|| ($visibleAdminIds !== null && !in_array($targetAdminId, $visibleAdminIds, true))
|
||
) {
|
||
return null;
|
||
}
|
||
$admin = Db::name('admin')
|
||
->where('id', $targetAdminId)
|
||
->whereNull('delete_time')
|
||
->where('work_wechat_userid', '<>', '')
|
||
->field('id,name')
|
||
->find();
|
||
$resolvedDeptId = self::resolveFanDetailMemberDeptId($targetAdminId, $context);
|
||
if (!$admin
|
||
|| $resolvedDeptId !== $rowDeptId
|
||
|| ($resolvedDeptId === -2 && $selectedDeptId > 0)
|
||
) {
|
||
return null;
|
||
}
|
||
|
||
return [
|
||
'id' => $entityId,
|
||
'admin_id' => $targetAdminId,
|
||
'name' => trim((string) ($admin['name'] ?? '')),
|
||
'target' => ['type' => 'member', 'admin_id' => $targetAdminId],
|
||
];
|
||
}
|
||
|
||
if ($entityType !== 'dept' || !preg_match('/^-?\d+$/', $entityId)) {
|
||
return null;
|
||
}
|
||
$deptId = (int) $entityId;
|
||
if (in_array($deptId, [-1, -2], true)) {
|
||
if ($selectedDeptId > 0 || $visibleAdminIds !== null) {
|
||
return null;
|
||
}
|
||
$name = $deptId === -1 ? '未绑定后台账号' : '未分配部门';
|
||
|
||
return [
|
||
'id' => $deptId,
|
||
'admin_id' => 0,
|
||
'name' => $name,
|
||
'target' => ['type' => 'dept', 'dept_ids' => [$deptId]],
|
||
];
|
||
}
|
||
if ($deptId <= 0) {
|
||
return null;
|
||
}
|
||
|
||
$dept = Db::name('dept')->where('id', $deptId)->whereNull('delete_time')->field('id,name')->find();
|
||
if (!$dept) {
|
||
return null;
|
||
}
|
||
$universeDeptIds = $selectedDeptId > 0
|
||
? self::normalizeIds(DeptLogic::getSelfAndDescendantIds($selectedDeptId))
|
||
: self::normalizeIds(Db::name('dept')->whereNull('delete_time')->column('id'));
|
||
if ($channelDeptIds !== null) {
|
||
$universeDeptIds = array_values(array_intersect($universeDeptIds, $channelDeptIds));
|
||
}
|
||
$rowDeptIds = self::visibleRowDeptIds($visibleAdminIds);
|
||
if (!in_array($deptId, $universeDeptIds, true)
|
||
|| ($rowDeptIds !== null && !in_array($deptId, $rowDeptIds, true))
|
||
) {
|
||
return null;
|
||
}
|
||
$targetDeptIds = array_values(array_intersect(
|
||
self::normalizeIds(DeptLogic::getSelfAndDescendantIds($deptId)),
|
||
$universeDeptIds,
|
||
$rowDeptIds ?? $universeDeptIds
|
||
));
|
||
if ($targetDeptIds === []) {
|
||
return null;
|
||
}
|
||
|
||
return [
|
||
'id' => $deptId,
|
||
'admin_id' => 0,
|
||
'name' => trim((string) ($dept['name'] ?? '')),
|
||
'target' => ['type' => 'dept', 'dept_ids' => $targetDeptIds],
|
||
];
|
||
}
|
||
|
||
/** @param array<string,mixed> $context */
|
||
private static function resolveFanDetailMemberDeptId(int $adminId, array $context): int
|
||
{
|
||
// Other roles are rendered by ConversionLogic in the unassigned bucket.
|
||
$hasMedicalRole = Db::name('admin_role')
|
||
->where('admin_id', $adminId)
|
||
->whereIn('role_id', [1, self::ASSISTANT_ROLE_ID])
|
||
->count() > 0;
|
||
if (!$hasMedicalRole) {
|
||
return -2;
|
||
}
|
||
|
||
$selectedDeptId = (int) $context['selected_dept_id'];
|
||
$validDeptIds = $selectedDeptId > 0
|
||
? self::normalizeIds(DeptLogic::getSelfAndDescendantIds($selectedDeptId))
|
||
: self::normalizeIds(Db::name('dept')->whereNull('delete_time')->column('id'));
|
||
if (($context['channel_dept_ids'] ?? null) !== null) {
|
||
$validDeptIds = array_values(array_intersect($validDeptIds, $context['channel_dept_ids']));
|
||
}
|
||
$validDeptSet = array_fill_keys($validDeptIds, true);
|
||
$deptRows = Db::name('dept')->whereNull('delete_time')->field('id,pid,sort')->select()->toArray();
|
||
$deptMeta = [];
|
||
foreach ($deptRows as $deptRow) {
|
||
$id = (int) ($deptRow['id'] ?? 0);
|
||
if ($id > 0) {
|
||
$deptMeta[$id] = ['pid' => (int) ($deptRow['pid'] ?? 0), 'sort' => (int) ($deptRow['sort'] ?? 0)];
|
||
}
|
||
}
|
||
$depthCache = [];
|
||
$depthOf = static function (int $id) use (&$depthOf, &$depthCache, $deptMeta): int {
|
||
if ($id <= 0 || !isset($deptMeta[$id])) {
|
||
return 0;
|
||
}
|
||
if (isset($depthCache[$id])) {
|
||
return $depthCache[$id];
|
||
}
|
||
$pid = (int) ($deptMeta[$id]['pid'] ?? 0);
|
||
if ($pid <= 0 || $pid === $id || !isset($deptMeta[$pid])) {
|
||
return $depthCache[$id] = 0;
|
||
}
|
||
|
||
return $depthCache[$id] = $depthOf($pid) + 1;
|
||
};
|
||
$adminDeptIds = self::normalizeIds(AdminDept::where('admin_id', $adminId)->column('dept_id'));
|
||
usort($adminDeptIds, static function (int $left, int $right) use ($depthOf, $deptMeta): int {
|
||
return ($depthOf($right) <=> $depthOf($left))
|
||
?: ((int) ($deptMeta[$right]['sort'] ?? 0) <=> (int) ($deptMeta[$left]['sort'] ?? 0))
|
||
?: ($left <=> $right);
|
||
});
|
||
foreach ($adminDeptIds as $adminDeptId) {
|
||
if (isset($validDeptSet[$adminDeptId])) {
|
||
return $adminDeptId;
|
||
}
|
||
}
|
||
|
||
return -2;
|
||
}
|
||
|
||
/** @return array<string,mixed> */
|
||
private static function resolveScopeContext(array $params, int $adminId, array $adminInfo): array
|
||
{
|
||
[$startDate, $endDate, $timeType, $timeLabel] = self::resolveTimeRange($params);
|
||
$baseVisibleAdminIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||
$allowedDeptSet = DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
|
||
$scopeValue = DataScopeService::getEffectiveScope($adminInfo);
|
||
$selectedDeptId = max(0, (int) ($params['dept_id'] ?? 0));
|
||
$selectedAssistantId = max(0, (int) ($params['assistant_id'] ?? 0));
|
||
$requestedMediaChannelCode = trim((string) ($params['media_channel_code'] ?? ''));
|
||
$selectedMediaChannel = $requestedMediaChannelCode !== ''
|
||
? MediaChannelService::getCurrentTagChannelByCode($requestedMediaChannelCode)
|
||
: null;
|
||
$selectedMediaChannelCode = $selectedMediaChannel !== null ? $requestedMediaChannelCode : '';
|
||
|
||
$deptSelectionValid = $selectedDeptId <= 0
|
||
|| $allowedDeptSet === null
|
||
|| isset($allowedDeptSet[$selectedDeptId]);
|
||
$selectedDeptIds = [];
|
||
if ($selectedDeptId > 0 && $deptSelectionValid) {
|
||
$selectedDeptIds = array_values(array_unique(array_filter(array_map(
|
||
'intval',
|
||
DeptLogic::getSelfAndDescendantIds($selectedDeptId)
|
||
), static fn (int $id): bool => $id > 0)));
|
||
if ($allowedDeptSet !== null) {
|
||
$selectedDeptIds = array_values(array_filter(
|
||
$selectedDeptIds,
|
||
static fn (int $id): bool => isset($allowedDeptSet[$id])
|
||
));
|
||
}
|
||
}
|
||
|
||
$effectiveAdminIds = $deptSelectionValid ? $baseVisibleAdminIds : [];
|
||
if ($selectedDeptId > 0 && $deptSelectionValid) {
|
||
$deptAdminIds = $selectedDeptIds === []
|
||
? []
|
||
: self::normalizeIds(AdminDept::whereIn('dept_id', $selectedDeptIds)->column('admin_id'));
|
||
$effectiveAdminIds = self::intersectVisibleIds($effectiveAdminIds, $deptAdminIds);
|
||
}
|
||
|
||
$selectedAssistantValid = $selectedAssistantId <= 0;
|
||
if ($selectedAssistantId > 0) {
|
||
$selectedAssistantValid = self::isActiveAssistant($selectedAssistantId)
|
||
&& ($effectiveAdminIds === null || in_array($selectedAssistantId, $effectiveAdminIds, true));
|
||
$effectiveAdminIds = $selectedAssistantValid ? [$selectedAssistantId] : [];
|
||
}
|
||
|
||
return [
|
||
'start_date' => $startDate,
|
||
'end_date' => $endDate,
|
||
'time_type' => $timeType,
|
||
'time_label' => $timeLabel,
|
||
'base_visible_admin_ids' => $baseVisibleAdminIds,
|
||
'allowed_dept_set' => $allowedDeptSet,
|
||
'scope_value' => $scopeValue,
|
||
'selected_dept_id' => $selectedDeptId,
|
||
'selected_assistant_id' => $selectedAssistantId,
|
||
'selected_media_channel' => $selectedMediaChannel,
|
||
'selected_media_channel_code' => $selectedMediaChannelCode,
|
||
'dept_selection_valid' => $deptSelectionValid,
|
||
'selected_dept_ids' => $selectedDeptIds,
|
||
'effective_admin_ids' => $effectiveAdminIds,
|
||
'selected_assistant_valid' => $selectedAssistantValid,
|
||
];
|
||
}
|
||
|
||
/** @return array{0:string,1:string,2:string,3:string} */
|
||
private static function resolveTimeRange(array $params): array
|
||
{
|
||
$today = date('Y-m-d');
|
||
$timeType = (string) ($params['time_type'] ?? 'today');
|
||
if (!in_array($timeType, ['today', 'yesterday', 'week', 'month', 'quarter', 'year', 'custom'], true)) {
|
||
$timeType = 'today';
|
||
}
|
||
|
||
if ($timeType === 'custom') {
|
||
$startDate = trim((string) ($params['start_date'] ?? ''));
|
||
$endDate = trim((string) ($params['end_date'] ?? ''));
|
||
if ($startDate === '' || $endDate === '' || strtotime($startDate) === false || strtotime($endDate) === false) {
|
||
$startDate = $today;
|
||
$endDate = $today;
|
||
}
|
||
if ($startDate > $endDate) {
|
||
[$startDate, $endDate] = [$endDate, $startDate];
|
||
}
|
||
|
||
return [$startDate, $endDate, 'custom', $startDate . ' 至 ' . $endDate];
|
||
}
|
||
if ($timeType === 'yesterday') {
|
||
$yesterday = date('Y-m-d', strtotime('-1 day'));
|
||
|
||
return [$yesterday, $yesterday, $timeType, '昨天'];
|
||
}
|
||
if ($timeType === 'week') {
|
||
return [date('Y-m-d', strtotime('monday this week')), $today, $timeType, '本周'];
|
||
}
|
||
if ($timeType === 'month') {
|
||
return [date('Y-m-01'), $today, $timeType, '本月'];
|
||
}
|
||
if ($timeType === 'quarter') {
|
||
$quarterMonth = ((int) floor(((int) date('n') - 1) / 3) * 3) + 1;
|
||
|
||
return [date('Y-' . str_pad((string) $quarterMonth, 2, '0', STR_PAD_LEFT) . '-01'), $today, $timeType, '本季度'];
|
||
}
|
||
if ($timeType === 'year') {
|
||
return [date('Y-01-01'), $today, $timeType, '本年'];
|
||
}
|
||
|
||
return [$today, $today, 'today', '今日'];
|
||
}
|
||
|
||
/** @param int[]|null $visibleIds @param int[] $candidateIds @return int[]|null */
|
||
private static function intersectVisibleIds(?array $visibleIds, array $candidateIds): ?array
|
||
{
|
||
if ($visibleIds === null) {
|
||
return $candidateIds;
|
||
}
|
||
|
||
return array_values(array_intersect($visibleIds, $candidateIds));
|
||
}
|
||
|
||
private static function isActiveAssistant(int $adminId): bool
|
||
{
|
||
if ($adminId <= 0) {
|
||
return false;
|
||
}
|
||
|
||
return Db::name('admin')
|
||
->alias('a')
|
||
->join('admin_role ar', 'ar.admin_id = a.id')
|
||
->where('a.id', $adminId)
|
||
->where('ar.role_id', self::ASSISTANT_ROLE_ID)
|
||
->where('a.disable', 0)
|
||
->whereNull('a.delete_time')
|
||
->count() > 0;
|
||
}
|
||
|
||
/** @param int[]|null $visibleAdminIds @return int[]|null */
|
||
private static function visibleRowDeptIds(?array $visibleAdminIds): ?array
|
||
{
|
||
if ($visibleAdminIds === null) {
|
||
return null;
|
||
}
|
||
if ($visibleAdminIds === []) {
|
||
return [];
|
||
}
|
||
|
||
return self::normalizeIds(AdminDept::whereIn('admin_id', $visibleAdminIds)->column('dept_id'));
|
||
}
|
||
|
||
/**
|
||
* 个人指标仍只查本人;成本按本人所在部门全员的加粉占比分摊。
|
||
*
|
||
* @param int[]|null $effectiveAdminIds
|
||
* @return int[]|null null 表示使用默认分摊范围
|
||
*/
|
||
private static function costAllocationAdminIds(?array $effectiveAdminIds, bool $personalScope): ?array
|
||
{
|
||
if (!$personalScope) {
|
||
return null;
|
||
}
|
||
if ($effectiveAdminIds === []) {
|
||
return [];
|
||
}
|
||
$deptIds = self::visibleRowDeptIds($effectiveAdminIds);
|
||
if ($deptIds === null || $deptIds === []) {
|
||
return $effectiveAdminIds ?? [];
|
||
}
|
||
|
||
$ids = self::normalizeIds(AdminDept::whereIn('dept_id', $deptIds)->column('admin_id'));
|
||
|
||
return $ids !== [] ? $ids : ($effectiveAdminIds ?? []);
|
||
}
|
||
|
||
/** @param array<int,array<string,mixed>> $rows @param array<int,true> $allowedSet @return array<int,array<string,mixed>> */
|
||
private static function filterDeptRows(array $rows, array $allowedSet): array
|
||
{
|
||
if ($allowedSet === []) {
|
||
return [];
|
||
}
|
||
$out = [];
|
||
foreach ($rows as $row) {
|
||
if (!is_array($row)) {
|
||
continue;
|
||
}
|
||
if (in_array((string) ($row['type'] ?? ''), ['member', 'unbound'], true)) {
|
||
$out[] = $row;
|
||
continue;
|
||
}
|
||
$children = self::filterDeptRows(is_array($row['children'] ?? null) ? $row['children'] : [], $allowedSet);
|
||
$id = (int) ($row['id'] ?? 0);
|
||
if (isset($allowedSet[$id])) {
|
||
$row['children'] = $children;
|
||
if ($children === []) {
|
||
unset($row['children']);
|
||
}
|
||
$out[] = $row;
|
||
continue;
|
||
}
|
||
foreach ($children as $child) {
|
||
$out[] = $child;
|
||
}
|
||
}
|
||
|
||
return $out;
|
||
}
|
||
|
||
/** @param array<int,array<string,mixed>> $rows @param array<int,true> $set */
|
||
private static function collectRowDeptIds(array $rows, array &$set): void
|
||
{
|
||
foreach ($rows as $row) {
|
||
$id = (int) ($row['id'] ?? 0);
|
||
if ($id !== 0) {
|
||
$set[$id] = true;
|
||
}
|
||
self::collectRowDeptIds(is_array($row['children'] ?? null) ? $row['children'] : [], $set);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param int[]|null $effectiveAdminIds
|
||
* @param array<int,true> $rowDeptSet
|
||
* @param string[]|null $mediaSources null=全部渠道;空数组=所选渠道没有可匹配的手工来源
|
||
* @return array{dept:array<int,int>,admin:array<int,int>}
|
||
*/
|
||
private static function loadOpenCounts(
|
||
string $startDate,
|
||
string $endDate,
|
||
?array $effectiveAdminIds,
|
||
array $rowDeptSet,
|
||
?array $mediaSources = null
|
||
): array
|
||
{
|
||
if ($effectiveAdminIds === [] || $rowDeptSet === [] || $mediaSources === []) {
|
||
return ['dept' => [], 'admin' => []];
|
||
}
|
||
$query = PersonalYeji::whereBetween('yeji_date', [$startDate, $endDate]);
|
||
if ($effectiveAdminIds !== null) {
|
||
$query->whereIn('creator_id', $effectiveAdminIds);
|
||
}
|
||
if ($mediaSources !== null) {
|
||
$query->whereIn('media_source', $mediaSources);
|
||
}
|
||
$rows = $query
|
||
->fieldRaw('creator_id, SUM(total_open_count) AS open_count')
|
||
->group('creator_id')
|
||
->select()
|
||
->toArray();
|
||
if ($rows === []) {
|
||
return ['dept' => [], 'admin' => []];
|
||
}
|
||
|
||
$creatorIds = self::normalizeIds(array_column($rows, 'creator_id'));
|
||
$deptRows = $creatorIds === [] ? [] : AdminDept::whereIn('admin_id', $creatorIds)
|
||
->field('admin_id, dept_id')
|
||
->order('admin_id', 'asc')
|
||
->order('dept_id', 'asc')
|
||
->select()
|
||
->toArray();
|
||
$adminDeptMap = [];
|
||
foreach ($deptRows as $deptRow) {
|
||
$adminDeptMap[(int) $deptRow['admin_id']][] = (int) $deptRow['dept_id'];
|
||
}
|
||
$deptMetaRows = Db::name('dept')
|
||
->whereNull('delete_time')
|
||
->field('id, pid, sort')
|
||
->select()
|
||
->toArray();
|
||
$deptMeta = [];
|
||
foreach ($deptMetaRows as $deptMetaRow) {
|
||
$deptId = (int) ($deptMetaRow['id'] ?? 0);
|
||
if ($deptId > 0) {
|
||
$deptMeta[$deptId] = [
|
||
'pid' => (int) ($deptMetaRow['pid'] ?? 0),
|
||
'sort' => (int) ($deptMetaRow['sort'] ?? 0),
|
||
];
|
||
}
|
||
}
|
||
$depthCache = [];
|
||
$depthOf = static function (int $deptId) use (&$depthOf, &$depthCache, $deptMeta): int {
|
||
if ($deptId <= 0 || !isset($deptMeta[$deptId])) {
|
||
return 0;
|
||
}
|
||
if (isset($depthCache[$deptId])) {
|
||
return $depthCache[$deptId];
|
||
}
|
||
$parentId = (int) ($deptMeta[$deptId]['pid'] ?? 0);
|
||
if ($parentId <= 0 || $parentId === $deptId || !isset($deptMeta[$parentId])) {
|
||
return $depthCache[$deptId] = 0;
|
||
}
|
||
|
||
return $depthCache[$deptId] = $depthOf($parentId) + 1;
|
||
};
|
||
foreach ($adminDeptMap as &$deptIds) {
|
||
usort($deptIds, static function (int $left, int $right) use ($depthOf, $deptMeta): int {
|
||
$depthCompare = $depthOf($right) <=> $depthOf($left);
|
||
if ($depthCompare !== 0) {
|
||
return $depthCompare;
|
||
}
|
||
$sortCompare = (int) ($deptMeta[$right]['sort'] ?? 0) <=> (int) ($deptMeta[$left]['sort'] ?? 0);
|
||
if ($sortCompare !== 0) {
|
||
return $sortCompare;
|
||
}
|
||
|
||
return $left <=> $right;
|
||
});
|
||
}
|
||
unset($deptIds);
|
||
|
||
$direct = [];
|
||
$adminDirect = [];
|
||
foreach ($rows as $row) {
|
||
$adminId = (int) ($row['creator_id'] ?? 0);
|
||
$targetDeptId = 0;
|
||
foreach ($adminDeptMap[$adminId] ?? [] as $deptId) {
|
||
if (isset($rowDeptSet[$deptId])) {
|
||
$targetDeptId = $deptId;
|
||
break;
|
||
}
|
||
}
|
||
if ($targetDeptId === 0 && isset($rowDeptSet[-2])) {
|
||
$targetDeptId = -2;
|
||
}
|
||
if ($targetDeptId !== 0) {
|
||
$openCount = (int) ($row['open_count'] ?? 0);
|
||
$direct[$targetDeptId] = ($direct[$targetDeptId] ?? 0) + $openCount;
|
||
$adminDirect[$adminId] = ($adminDirect[$adminId] ?? 0) + $openCount;
|
||
}
|
||
}
|
||
|
||
return ['dept' => $direct, 'admin' => $adminDirect];
|
||
}
|
||
|
||
/**
|
||
* @param array<int,array<string,mixed>> $rows
|
||
* @param array<int,int> $deptDirect
|
||
* @param array<int,int> $adminDirect
|
||
*/
|
||
private static function applyOpenCounts(array &$rows, array $deptDirect, array $adminDirect): int
|
||
{
|
||
$sum = 0;
|
||
foreach ($rows as &$row) {
|
||
$rowType = (string) ($row['type'] ?? '');
|
||
if (in_array($rowType, ['member', 'unbound'], true)) {
|
||
$count = $rowType === 'member'
|
||
? (int) ($adminDirect[(int) ($row['admin_id'] ?? 0)] ?? 0)
|
||
: 0;
|
||
$row['total_open_count'] = $count;
|
||
$row['total_open_rate'] = self::percent($count, (int) ($row['add_fans_count'] ?? 0));
|
||
$row['open_appointment_rate'] = self::percent(
|
||
(int) ($row['paid_appointment_count'] ?? 0),
|
||
$count
|
||
);
|
||
$row['open_receive_rate'] = self::percent(
|
||
(int) ($row['completed_order_count'] ?? 0),
|
||
$count
|
||
);
|
||
continue;
|
||
}
|
||
$children = is_array($row['children'] ?? null) ? $row['children'] : [];
|
||
$childTotal = self::applyOpenCounts($children, $deptDirect, $adminDirect);
|
||
if ($children !== []) {
|
||
$row['children'] = $children;
|
||
}
|
||
$directCount = (int) ($deptDirect[(int) ($row['id'] ?? 0)] ?? 0);
|
||
$count = $directCount + $childTotal;
|
||
$row['total_open_count'] = $count;
|
||
$row['total_open_rate'] = self::percent($count, (int) ($row['add_fans_count'] ?? 0));
|
||
$row['open_appointment_rate'] = self::percent(
|
||
(int) ($row['paid_appointment_count'] ?? 0),
|
||
$count
|
||
);
|
||
$row['open_receive_rate'] = self::percent((int) ($row['completed_order_count'] ?? 0), $count);
|
||
$sum += $directCount + $childTotal;
|
||
}
|
||
unset($row);
|
||
|
||
return $sum;
|
||
}
|
||
|
||
/**
|
||
* 手工开口按 personal_yeji.media_source 保存;渠道筛选时仅匹配该渠道自身的稳定标识和名称。
|
||
* 不使用 source_group_name,避免同组多个渠道的开口数被重复计入每个渠道。
|
||
*
|
||
* @param array<string,mixed>|null $channel
|
||
* @return string[]|null
|
||
*/
|
||
private static function personalYejiMediaSources(string $channelCode, ?array $channel): ?array
|
||
{
|
||
if ($channelCode === '') {
|
||
return null;
|
||
}
|
||
if ($channel === null) {
|
||
return [];
|
||
}
|
||
|
||
$values = [
|
||
$channelCode,
|
||
$channel['channel_name'] ?? '',
|
||
$channel['source_tag_name'] ?? '',
|
||
$channel['legacy_channel_name'] ?? '',
|
||
$channel['legacy_source_tag_name'] ?? '',
|
||
];
|
||
foreach (['channel_codes', 'channel_names', 'source_tag_names'] as $listKey) {
|
||
if (!isset($channel[$listKey]) || !is_array($channel[$listKey])) {
|
||
continue;
|
||
}
|
||
foreach ($channel[$listKey] as $item) {
|
||
$values[] = $item;
|
||
}
|
||
}
|
||
|
||
return array_values(array_unique(array_filter(array_map(
|
||
static fn ($value): string => trim((string) $value),
|
||
$values
|
||
), static fn (string $value): bool => $value !== '' && !str_starts_with($value, MediaChannelService::GROUP_CODE_PREFIX))));
|
||
}
|
||
|
||
private static function canViewFinance(int $adminId, array $adminInfo): bool
|
||
{
|
||
if ((int) ($adminInfo['root'] ?? 0) === 1) {
|
||
return true;
|
||
}
|
||
foreach (self::roleNamesFromAdminInfo($adminInfo) as $roleName) {
|
||
if (in_array($roleName, self::FINANCE_ALWAYS_ROLE_NAMES, true)) {
|
||
return true;
|
||
}
|
||
}
|
||
if ($adminId <= 0) {
|
||
return false;
|
||
}
|
||
|
||
return in_array(self::FINANCE_PERMISSION, AuthLogic::getAuthByAdminId($adminId), true);
|
||
}
|
||
|
||
/** @return string[] */
|
||
private static function roleNamesFromAdminInfo(array $adminInfo): array
|
||
{
|
||
$names = preg_split('/[\/,,、]/u', (string) ($adminInfo['role_name'] ?? '')) ?: [];
|
||
|
||
return array_values(array_filter(array_map('trim', $names), static fn (string $name): bool => $name !== ''));
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $entity
|
||
* @return array<string, mixed>
|
||
*/
|
||
private static function maskFinanceFields(array $entity): array
|
||
{
|
||
foreach (self::FINANCE_FIELD_KEYS as $key) {
|
||
unset($entity[$key]);
|
||
}
|
||
if (isset($entity['children']) && is_array($entity['children'])) {
|
||
foreach ($entity['children'] as &$child) {
|
||
if (is_array($child)) {
|
||
$child = self::maskFinanceFields($child);
|
||
}
|
||
}
|
||
unset($child);
|
||
}
|
||
|
||
return $entity;
|
||
}
|
||
|
||
/** 根据生效数据范围返回排行榜展示维度,不能把 scope_value 当作角色枚举。 */
|
||
private static function rankingKind(int $scopeValue, int $selectedAssistantId = 0): string
|
||
{
|
||
if ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0) {
|
||
return 'hidden';
|
||
}
|
||
|
||
return $scopeValue === DataScopeService::SCOPE_DEPT ? 'member' : 'group';
|
||
}
|
||
|
||
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
|
||
private static function rankingRows(array $rows, string $rankingKind): array
|
||
{
|
||
if ($rankingKind === 'hidden') {
|
||
return [];
|
||
}
|
||
|
||
// “仅本部门”范围使用可见成员维度;更大范围使用当前可见组织根节点的
|
||
// 直属下级,避免父子汇总同时参与占比。
|
||
if ($rankingKind === 'member') {
|
||
$members = [];
|
||
self::collectRankingMembers($rows, $members);
|
||
|
||
return array_values($members);
|
||
}
|
||
|
||
// lists 里可能同时存在“未绑定/未分配部门”等虚拟根节点。它们会让顶层节点数量
|
||
// 大于 1,导致原逻辑无法展开唯一的真实组织根节点,图表最终只显示医院汇总行。
|
||
$visibleRows = array_values(array_filter($rows, static function (array $row): bool {
|
||
return (int) ($row['id'] ?? 0) > 0 && !((bool) ($row['_virtual_bucket'] ?? false));
|
||
}));
|
||
|
||
// 每个可见顶层分支只展示同一层级:有权限看到下级时展示直属子部门;没有可见
|
||
// 下级时保留当前部门。这样既能按角色/DataScope 展示子部门,也不会把父子汇总
|
||
// 同时放进占比图造成重复计算。
|
||
$chartRows = [];
|
||
foreach ($visibleRows as $row) {
|
||
$children = array_values(array_filter(
|
||
is_array($row['children'] ?? null) ? $row['children'] : [],
|
||
static fn (array $child): bool => (int) ($child['id'] ?? 0) > 0
|
||
&& !((bool) ($child['_virtual_bucket'] ?? false))
|
||
));
|
||
if ($children !== []) {
|
||
foreach ($children as $child) {
|
||
$chartRows[] = $child;
|
||
}
|
||
continue;
|
||
}
|
||
$chartRows[] = $row;
|
||
}
|
||
|
||
return $chartRows;
|
||
}
|
||
|
||
/**
|
||
* @param array<int,array<string,mixed>> $rows
|
||
* @param array<int,array<string,mixed>> $members
|
||
*/
|
||
private static function collectRankingMembers(array $rows, array &$members): void
|
||
{
|
||
foreach ($rows as $row) {
|
||
if ((string) ($row['type'] ?? '') === 'member') {
|
||
$adminId = (int) ($row['admin_id'] ?? 0);
|
||
if ($adminId > 0) {
|
||
$members[$adminId] = $row;
|
||
}
|
||
continue;
|
||
}
|
||
self::collectRankingMembers(
|
||
is_array($row['children'] ?? null) ? $row['children'] : [],
|
||
$members
|
||
);
|
||
}
|
||
}
|
||
|
||
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
|
||
private static function topRows(array $rows, string $metric): array
|
||
{
|
||
$rows = array_values(array_filter($rows, static function (array $row): bool {
|
||
if ((string) ($row['type'] ?? '') === 'member') {
|
||
return (int) ($row['admin_id'] ?? 0) > 0;
|
||
}
|
||
|
||
return (int) ($row['id'] ?? 0) > 0;
|
||
}));
|
||
usort($rows, static function (array $left, array $right) use ($metric): int {
|
||
$valueCompare = (float) ($right[$metric] ?? 0) <=> (float) ($left[$metric] ?? 0);
|
||
if ($valueCompare !== 0) {
|
||
return $valueCompare;
|
||
}
|
||
$nameCompare = strnatcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
|
||
if ($nameCompare !== 0) {
|
||
return $nameCompare;
|
||
}
|
||
|
||
return strcmp((string) ($left['id'] ?? ''), (string) ($right['id'] ?? ''));
|
||
});
|
||
|
||
return array_map(static fn (array $row): array => [
|
||
'id' => $row['id'] ?? 0,
|
||
'name' => (string) ($row['name'] ?? ''),
|
||
'value' => round((float) ($row[$metric] ?? 0), 2),
|
||
], $rows);
|
||
}
|
||
|
||
/** @param int[]|null $baseVisibleAdminIds @param int[] $selectedDeptIds @return array<int,array{id:int,name:string}> */
|
||
private static function assistantOptions(?array $baseVisibleAdminIds, array $selectedDeptIds, int $selectedDeptId): array
|
||
{
|
||
$query = Db::name('admin')
|
||
->alias('a')
|
||
->join('admin_role ar', 'ar.admin_id = a.id')
|
||
->where('ar.role_id', self::ASSISTANT_ROLE_ID)
|
||
->where('a.disable', 0)
|
||
->whereNull('a.delete_time');
|
||
if ($baseVisibleAdminIds !== null) {
|
||
if ($baseVisibleAdminIds === []) {
|
||
return [];
|
||
}
|
||
$query->whereIn('a.id', $baseVisibleAdminIds);
|
||
}
|
||
if ($selectedDeptId > 0) {
|
||
if ($selectedDeptIds === []) {
|
||
return [];
|
||
}
|
||
$query->join('admin_dept ad', 'ad.admin_id = a.id')->whereIn('ad.dept_id', $selectedDeptIds);
|
||
}
|
||
|
||
return $query->field('a.id, a.name')->distinct(true)->order('a.name', 'asc')->select()->toArray();
|
||
}
|
||
|
||
/** @param array<int,true>|null $allowedDeptSet @param int[] $selectedDeptIds @return int[]|null */
|
||
private static function resolveTargetDeptIds(?array $allowedDeptSet, array $selectedDeptIds, int $selectedDeptId): ?array
|
||
{
|
||
if ($selectedDeptId > 0) {
|
||
return $selectedDeptIds;
|
||
}
|
||
if ($allowedDeptSet === null) {
|
||
return null;
|
||
}
|
||
|
||
return array_map('intval', array_keys($allowedDeptSet));
|
||
}
|
||
|
||
/** @param int[]|null $effectiveAdminIds @param int[]|null $targetDeptIds @return array<string,mixed> */
|
||
private static function buildTargetProgress(int $year, ?array $effectiveAdminIds, ?array $targetDeptIds): array
|
||
{
|
||
$targetQuery = Db::name('dept_performance_target')->whereLike('year_month', $year . '-%');
|
||
if ($targetDeptIds !== null) {
|
||
if ($targetDeptIds === []) {
|
||
$targetRows = [];
|
||
} else {
|
||
$targetRows = $targetQuery->whereIn('dept_id', $targetDeptIds)
|
||
->fieldRaw('`year_month`, SUM(`target_amount`) AS target_amount, COUNT(DISTINCT `dept_id`) AS dept_count')
|
||
->group('`year_month`')
|
||
->select()
|
||
->toArray();
|
||
}
|
||
} else {
|
||
$targetRows = $targetQuery
|
||
->fieldRaw('`year_month`, SUM(`target_amount`) AS target_amount, COUNT(DISTINCT `dept_id`) AS dept_count')
|
||
->group('`year_month`')
|
||
->select()
|
||
->toArray();
|
||
}
|
||
|
||
$actualRows = [];
|
||
if ($effectiveAdminIds !== []) {
|
||
$actualQuery = Db::name('tcm_prescription_order')
|
||
->alias('po')
|
||
->whereNull('po.delete_time')
|
||
->where('po.create_time', 'between', [
|
||
strtotime($year . '-01-01 00:00:00'),
|
||
strtotime($year . '-12-31 23:59:59'),
|
||
]);
|
||
YejiStatsLogic::applyPrescriptionOrderEffectiveAmountQuery($actualQuery, 'po');
|
||
if ($effectiveAdminIds !== null) {
|
||
$actualQuery->whereIn('po.creator_id', $effectiveAdminIds);
|
||
}
|
||
$actualRows = $actualQuery
|
||
->fieldRaw("DATE_FORMAT(FROM_UNIXTIME(po.create_time), '%m') AS month_no, SUM(po.amount) AS actual_amount")
|
||
->group('month_no')
|
||
->select()
|
||
->toArray();
|
||
}
|
||
|
||
$targets = array_fill(1, 12, 0.0);
|
||
$actuals = array_fill(1, 12, 0.0);
|
||
$deptCountSet = [];
|
||
foreach ($targetRows as $row) {
|
||
$month = (int) substr((string) ($row['year_month'] ?? ''), 5, 2);
|
||
if ($month >= 1 && $month <= 12) {
|
||
$targets[$month] = round((float) ($row['target_amount'] ?? 0), 2);
|
||
$deptCountSet[$month] = (int) ($row['dept_count'] ?? 0);
|
||
}
|
||
}
|
||
foreach ($actualRows as $row) {
|
||
$month = (int) ($row['month_no'] ?? 0);
|
||
if ($month >= 1 && $month <= 12) {
|
||
$actuals[$month] = round((float) ($row['actual_amount'] ?? 0), 2);
|
||
}
|
||
}
|
||
|
||
$targetCumulative = [];
|
||
$actualCumulative = [];
|
||
$targetRunning = 0.0;
|
||
$actualRunning = 0.0;
|
||
for ($month = 1; $month <= 12; $month++) {
|
||
$targetRunning = round($targetRunning + $targets[$month], 2);
|
||
$actualRunning = round($actualRunning + $actuals[$month], 2);
|
||
$targetCumulative[] = $targetRunning;
|
||
$actualCumulative[] = $actualRunning;
|
||
}
|
||
$currentMonth = (int) date('n');
|
||
|
||
return [
|
||
'year' => $year,
|
||
'target_amount' => $targetRunning,
|
||
'actual_amount' => $actualRunning,
|
||
'completion_rate' => $targetRunning > 0 ? round($actualRunning / $targetRunning * 100, 2) : null,
|
||
'current_month_target' => $targets[$currentMonth],
|
||
'current_month_actual' => $actuals[$currentMonth],
|
||
'current_month_rate' => $targets[$currentMonth] > 0
|
||
? round($actuals[$currentMonth] / $targets[$currentMonth] * 100, 2)
|
||
: null,
|
||
'department_count' => max($deptCountSet ?: [0]),
|
||
'months' => array_map(static fn (int $month): string => str_pad((string) $month, 2, '0', STR_PAD_LEFT) . '月', range(1, 12)),
|
||
'target_cumulative' => $targetCumulative,
|
||
'actual_cumulative' => $actualCumulative,
|
||
];
|
||
}
|
||
|
||
/** @param array<int|string,mixed> $ids @return int[] */
|
||
private static function normalizeIds(array $ids): array
|
||
{
|
||
return array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $id): bool => $id > 0)));
|
||
}
|
||
|
||
private static function percent(int $numerator, int $denominator): float
|
||
{
|
||
return $denominator > 0 ? round($numerator / $denominator * 100, 2) : 0.0;
|
||
}
|
||
}
|