first commit
This commit is contained in:
@@ -0,0 +1,776 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\firstvisit;
|
||||
|
||||
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;
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function overview(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] : [];
|
||||
}
|
||||
$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)
|
||||
: '';
|
||||
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 === ''
|
||||
? '个人业绩录入'
|
||||
: '个人业绩录入(按渠道名称匹配)',
|
||||
'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{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 [];
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter(array_map(
|
||||
static fn ($value): string => trim((string) $value),
|
||||
[
|
||||
$channelCode,
|
||||
$channel['channel_name'] ?? '',
|
||||
$channel['source_tag_name'] ?? '',
|
||||
$channel['legacy_channel_name'] ?? '',
|
||||
$channel['legacy_source_tag_name'] ?? '',
|
||||
]
|
||||
), static fn (string $value): bool => $value !== '')));
|
||||
}
|
||||
|
||||
/** 根据生效数据范围返回排行榜展示维度,不能把 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\firstvisit;
|
||||
|
||||
use app\adminapi\logic\dept\DeptLogic;
|
||||
use app\adminapi\logic\stats\DoctorDailyStatsLogic;
|
||||
use app\adminapi\logic\stats\YejiStatsLogic;
|
||||
use app\common\model\auth\AdminDept;
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 一诊「医生看板」。
|
||||
*
|
||||
* 医生是最终展示维度;部门权限通过实际经手医助下推到预约、诊单与业绩:
|
||||
* - 医生 SELF:只看本人医生数据,不限制经手医助;
|
||||
* - 医助 SELF:只看本人经手患者关联的医生数据;
|
||||
* - 组长/经理:只看数据范围内医助经手患者关联的医生数据;
|
||||
* - 管理员/ALL:全部医生,可再选择部门收窄。
|
||||
*/
|
||||
class FirstVisitDoctorDashboardLogic
|
||||
{
|
||||
private const DOCTOR_ROLE_ID = 1;
|
||||
private const ASSISTANT_ROLE_ID = 2;
|
||||
private const TREND_DAYS = 30;
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function overview(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
$range = self::resolveRange($params);
|
||||
$scopeValue = DataScopeService::getEffectiveScope($adminInfo);
|
||||
$roleIds = self::normalizeIds(Db::name('admin_role')->where('admin_id', $adminId)->column('role_id'));
|
||||
$isRoot = (int) ($adminInfo['root'] ?? 0) === 1;
|
||||
$doctorSelf = !$isRoot
|
||||
&& $scopeValue === DataScopeService::SCOPE_SELF
|
||||
&& in_array(self::DOCTOR_ROLE_ID, $roleIds, true);
|
||||
$activeOnly = (int) ($params['active_only'] ?? 1) !== 0;
|
||||
$selectedDeptId = $doctorSelf ? 0 : max(0, (int) ($params['dept_id'] ?? 0));
|
||||
$selectedDoctorId = max(0, (int) ($params['doctor_id'] ?? 0));
|
||||
$threshold = min(100.0, max(1.0, (float) ($params['alert_threshold'] ?? 15)));
|
||||
|
||||
$allDoctorOptions = self::doctorOptions($activeOnly, $doctorSelf ? $adminId : 0);
|
||||
$doctorIds = self::normalizeIds(array_column($allDoctorOptions, 'id'));
|
||||
if ($selectedDoctorId > 0) {
|
||||
$doctorIds = in_array($selectedDoctorId, $doctorIds, true) ? [$selectedDoctorId] : [];
|
||||
}
|
||||
|
||||
$allowedDeptSet = DataScopeService::getAllowedDeptIdSet($adminId, $adminInfo);
|
||||
[$selectedDeptIds, $deptSelectionValid] = self::resolveSelectedDeptIds(
|
||||
$selectedDeptId,
|
||||
$allowedDeptSet
|
||||
);
|
||||
$assistantIds = self::resolveAssistantScope(
|
||||
$adminId,
|
||||
$adminInfo,
|
||||
$doctorSelf,
|
||||
$selectedDeptId,
|
||||
$selectedDeptIds,
|
||||
$deptSelectionValid
|
||||
);
|
||||
|
||||
$stats = DoctorDailyStatsLogic::overview(
|
||||
[
|
||||
'start_date' => $range['start'],
|
||||
'end_date' => $range['end'],
|
||||
],
|
||||
$adminId,
|
||||
$adminInfo,
|
||||
$doctorIds,
|
||||
$assistantIds
|
||||
);
|
||||
|
||||
$doctorDeptNames = self::doctorDepartmentNames($doctorIds);
|
||||
$doctorStatus = self::doctorStatusMap($doctorIds);
|
||||
$rows = self::enrichRows(
|
||||
is_array($stats['rows'] ?? null) ? $stats['rows'] : [],
|
||||
$doctorDeptNames,
|
||||
$doctorStatus
|
||||
);
|
||||
// 支付单没有医生字段,当前数据中的低额支付单也未关联患者;挂号只能按创建人及权限范围汇总,
|
||||
// 不能为了医生排行而将医助创建的支付单虚构分摊给某位医生。
|
||||
$registrationCreatorIds = $doctorSelf ? [$adminId] : $assistantIds;
|
||||
$registrationTotal = self::loadRegistrationTotal(
|
||||
$range['start'],
|
||||
$range['end'],
|
||||
$registrationCreatorIds
|
||||
);
|
||||
$summary = self::buildSummary($rows, $registrationTotal);
|
||||
$trend = self::buildAmountTrend($doctorIds, $assistantIds);
|
||||
|
||||
$selectedDeptName = $selectedDeptId > 0
|
||||
? (string) (Db::name('dept')->where('id', $selectedDeptId)->whereNull('delete_time')->value('name') ?? '')
|
||||
: '';
|
||||
$selectedDoctorName = '';
|
||||
if ($selectedDoctorId > 0) {
|
||||
foreach ($allDoctorOptions as $doctor) {
|
||||
if ((int) ($doctor['id'] ?? 0) === $selectedDoctorId) {
|
||||
$selectedDoctorName = (string) ($doctor['name'] ?? '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'meta' => [
|
||||
'time_type' => $range['type'],
|
||||
'time_label' => $range['label'],
|
||||
'start_date' => $range['start'],
|
||||
'end_date' => $range['end'],
|
||||
'generated_at' => date('Y-m-d H:i:s'),
|
||||
'scope_value' => $scopeValue,
|
||||
'scope_label' => $doctorSelf ? '医生本人' : DataScopeService::scopeLabel($scopeValue),
|
||||
'scope_kind' => $doctorSelf ? 'doctor_self' : ($assistantIds === null ? 'all' : 'assistant_scope'),
|
||||
'selected_dept_name' => $selectedDeptName,
|
||||
'selected_doctor_name' => $selectedDoctorName,
|
||||
'doctor_count' => count($rows),
|
||||
'registration_rule' => '总挂号按支付时间统计已支付且实收金额大于 0、低于 10 元的订单,每笔计 1 个,并按订单创建人及当前权限范围归属',
|
||||
'appointment_rule' => '总预约包含已预约、已取消、已完成和已过号;面诊取状态为已完成的预约',
|
||||
'performance_rule' => '诊单按订单创建时间统计,排除已取消、拒收、全额退款及部分退款,金额归属处方开方医生',
|
||||
],
|
||||
'filters' => [
|
||||
'departments' => $doctorSelf ? [] : DeptLogic::getAllDataScoped($adminId, $adminInfo),
|
||||
'doctors' => $allDoctorOptions,
|
||||
'can_filter_department' => !$doctorSelf,
|
||||
],
|
||||
'summary' => $summary,
|
||||
'rankings' => [
|
||||
'amounts' => self::ranking($rows, 'deal_amount', 8),
|
||||
'conversion' => self::ranking($rows, 'receive_conversion_rate', 8),
|
||||
],
|
||||
'funnel' => [
|
||||
['key' => 'registration', 'label' => '挂号', 'value' => (int) $summary['registration_total']],
|
||||
['key' => 'appointment', 'label' => '预约', 'value' => (int) $summary['appointment_total']],
|
||||
['key' => 'interview', 'label' => '面诊', 'value' => (int) $summary['interview_count']],
|
||||
['key' => 'receive', 'label' => '接诊', 'value' => (int) $summary['order_count']],
|
||||
['key' => 'deal', 'label' => '成交', 'value' => (int) $summary['order_count']],
|
||||
],
|
||||
'trend' => $trend,
|
||||
'alerts' => self::alertRows($rows, $threshold),
|
||||
'alert_threshold' => $threshold,
|
||||
'rows' => $rows,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
/** @return array{type:string,label:string,start:string,end:string} */
|
||||
private static function resolveRange(array $params): array
|
||||
{
|
||||
$today = date('Y-m-d');
|
||||
$type = (string) ($params['time_type'] ?? 'month');
|
||||
if (!in_array($type, ['today', 'yesterday', 'week', 'month', 'custom'], true)) {
|
||||
$type = 'month';
|
||||
}
|
||||
|
||||
if ($type === 'custom') {
|
||||
$start = trim((string) ($params['start_date'] ?? ''));
|
||||
$end = trim((string) ($params['end_date'] ?? ''));
|
||||
if ($start === '' || $end === '' || strtotime($start) === false || strtotime($end) === false) {
|
||||
$start = date('Y-m-01');
|
||||
$end = $today;
|
||||
}
|
||||
if ($start > $end) {
|
||||
[$start, $end] = [$end, $start];
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'custom',
|
||||
'label' => $start . ' 至 ' . $end,
|
||||
'start' => $start,
|
||||
'end' => $end,
|
||||
];
|
||||
}
|
||||
if ($type === 'today') {
|
||||
return ['type' => 'today', 'label' => '今日', 'start' => $today, 'end' => $today];
|
||||
}
|
||||
if ($type === 'yesterday') {
|
||||
$yesterday = date('Y-m-d', strtotime('-1 day'));
|
||||
|
||||
return ['type' => 'yesterday', 'label' => '昨天', 'start' => $yesterday, 'end' => $yesterday];
|
||||
}
|
||||
if ($type === 'week') {
|
||||
return [
|
||||
'type' => 'week', 'label' => '本周',
|
||||
'start' => date('Y-m-d', strtotime('monday this week')), 'end' => $today,
|
||||
];
|
||||
}
|
||||
|
||||
return ['type' => 'month', 'label' => '本月', 'start' => date('Y-m-01'), 'end' => $today];
|
||||
}
|
||||
|
||||
/** @return array<int,array{id:int,name:string,disable:int}> */
|
||||
private static function doctorOptions(bool $activeOnly, int $selfDoctorId = 0): array
|
||||
{
|
||||
$query = Db::name('admin')->alias('a')
|
||||
->join('admin_role ar', 'ar.admin_id = a.id')
|
||||
->where('ar.role_id', self::DOCTOR_ROLE_ID)
|
||||
->whereNull('a.delete_time');
|
||||
if ($activeOnly) {
|
||||
$query->where('a.disable', 0);
|
||||
}
|
||||
if ($selfDoctorId > 0) {
|
||||
$query->where('a.id', $selfDoctorId);
|
||||
}
|
||||
|
||||
return $query->field('a.id, a.name, a.disable')
|
||||
->distinct(true)
|
||||
->order('a.disable', 'asc')
|
||||
->order('a.name', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/** @param array<int,true>|null $allowedSet @return array{0:array<int>,1:bool} */
|
||||
private static function resolveSelectedDeptIds(int $selectedDeptId, ?array $allowedSet): array
|
||||
{
|
||||
if ($selectedDeptId <= 0) {
|
||||
return [[], true];
|
||||
}
|
||||
$ids = self::normalizeIds(DeptLogic::getSelfAndDescendantIds($selectedDeptId));
|
||||
if ($allowedSet !== null) {
|
||||
$ids = array_values(array_filter($ids, static fn (int $id): bool => isset($allowedSet[$id])));
|
||||
}
|
||||
|
||||
return [$ids, $ids !== []];
|
||||
}
|
||||
|
||||
/**
|
||||
* null 表示医生本人或 ALL,不附加医助过滤;数组表示必须按这些医助经手的数据收窄。
|
||||
*
|
||||
* @param int[] $selectedDeptIds
|
||||
* @return int[]|null
|
||||
*/
|
||||
private static function resolveAssistantScope(
|
||||
int $adminId,
|
||||
array $adminInfo,
|
||||
bool $doctorSelf,
|
||||
int $selectedDeptId,
|
||||
array $selectedDeptIds,
|
||||
bool $deptSelectionValid
|
||||
): ?array {
|
||||
if ($doctorSelf) {
|
||||
return null;
|
||||
}
|
||||
if (!$deptSelectionValid) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
$assistantIds = self::activeAssistantIds($visibleIds);
|
||||
if ($selectedDeptId <= 0) {
|
||||
return $visibleIds === null ? null : $assistantIds;
|
||||
}
|
||||
|
||||
$deptAssistantIds = self::activeAssistantIdsByDepartment($selectedDeptIds);
|
||||
if ($visibleIds === null) {
|
||||
return $deptAssistantIds;
|
||||
}
|
||||
|
||||
return array_values(array_intersect($assistantIds, $deptAssistantIds));
|
||||
}
|
||||
|
||||
/** @param int[]|null $visibleIds @return int[] */
|
||||
private static function activeAssistantIds(?array $visibleIds): array
|
||||
{
|
||||
if ($visibleIds === []) {
|
||||
return [];
|
||||
}
|
||||
$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 ($visibleIds !== null) {
|
||||
$query->whereIn('a.id', $visibleIds);
|
||||
}
|
||||
|
||||
return self::normalizeIds($query->column('a.id'));
|
||||
}
|
||||
|
||||
/** @param int[] $deptIds @return int[] */
|
||||
private static function activeAssistantIdsByDepartment(array $deptIds): array
|
||||
{
|
||||
if ($deptIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return self::normalizeIds(Db::name('admin')->alias('a')
|
||||
->join('admin_role ar', 'ar.admin_id = a.id')
|
||||
->join('admin_dept ad', 'ad.admin_id = a.id')
|
||||
->where('ar.role_id', self::ASSISTANT_ROLE_ID)
|
||||
->whereIn('ad.dept_id', $deptIds)
|
||||
->where('a.disable', 0)
|
||||
->whereNull('a.delete_time')
|
||||
->distinct(true)
|
||||
->column('a.id'));
|
||||
}
|
||||
|
||||
/** @param int[] $doctorIds @return array<int,string> */
|
||||
private static function doctorDepartmentNames(array $doctorIds): array
|
||||
{
|
||||
if ($doctorIds === []) {
|
||||
return [];
|
||||
}
|
||||
$rows = AdminDept::alias('ad')
|
||||
->join('dept d', 'd.id = ad.dept_id AND d.delete_time IS NULL')
|
||||
->whereIn('ad.admin_id', $doctorIds)
|
||||
->field('ad.admin_id, d.name')
|
||||
->order('d.sort', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$id = (int) ($row['admin_id'] ?? 0);
|
||||
$name = trim((string) ($row['name'] ?? ''));
|
||||
if ($id > 0 && $name !== '' && !isset($out[$id])) {
|
||||
$out[$id] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @param int[] $doctorIds @return array<int,int> */
|
||||
private static function doctorStatusMap(array $doctorIds): array
|
||||
{
|
||||
if ($doctorIds === []) {
|
||||
return [];
|
||||
}
|
||||
$rows = Db::name('admin')->whereIn('id', $doctorIds)->field('id, disable')->select()->toArray();
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$out[(int) $row['id']] = (int) ($row['disable'] ?? 0);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
|
||||
private static function enrichRows(array $rows, array $deptNames, array $statusMap): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$id = (int) ($row['admin_id'] ?? 0);
|
||||
$appointmentTotal = (int) ($row['appointment_total'] ?? 0);
|
||||
$interviewCount = (int) ($row['appointment_completed'] ?? 0);
|
||||
$orderCount = (int) ($row['deal_order_count'] ?? 0);
|
||||
$out[] = array_merge($row, [
|
||||
'doctor_id' => $id,
|
||||
'department_name' => (string) ($deptNames[$id] ?? '未分配部门'),
|
||||
'interview_count' => $interviewCount,
|
||||
'order_count' => $orderCount,
|
||||
'appointment_completion_rate' => $appointmentTotal > 0
|
||||
? round($interviewCount / $appointmentTotal * 100, 2)
|
||||
: null,
|
||||
'receive_conversion_rate' => $interviewCount > 0
|
||||
? round($orderCount / $interviewCount * 100, 2)
|
||||
: null,
|
||||
'status' => (int) ($statusMap[$id] ?? 0) === 0 ? 'active' : 'disabled',
|
||||
]);
|
||||
}
|
||||
usort($out, static fn (array $a, array $b): int => (($b['deal_amount'] ?? 0) <=> ($a['deal_amount'] ?? 0)) ?: strcmp((string) $a['doctor_name'], (string) $b['doctor_name']));
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @return array<string,mixed> */
|
||||
private static function buildSummary(array $rows, int $registrationTotal): array
|
||||
{
|
||||
$appointmentTotal = 0;
|
||||
$interviewCount = 0;
|
||||
$orderCount = 0;
|
||||
$dealAmount = 0.0;
|
||||
$missed = 0;
|
||||
$cancelled = 0;
|
||||
foreach ($rows as $row) {
|
||||
$appointmentTotal += (int) ($row['appointment_total'] ?? 0);
|
||||
$interviewCount += (int) ($row['interview_count'] ?? 0);
|
||||
$orderCount += (int) ($row['order_count'] ?? 0);
|
||||
$dealAmount += (float) ($row['deal_amount'] ?? 0);
|
||||
$missed += (int) ($row['appointment_missed'] ?? 0);
|
||||
$cancelled += (int) ($row['appointment_cancelled'] ?? 0);
|
||||
}
|
||||
|
||||
return [
|
||||
'registration_total' => $registrationTotal,
|
||||
'appointment_total' => $appointmentTotal,
|
||||
'interview_count' => $interviewCount,
|
||||
'order_count' => $orderCount,
|
||||
'deal_amount' => round($dealAmount, 2),
|
||||
'avg_order_amount' => $orderCount > 0 ? round($dealAmount / $orderCount, 2) : null,
|
||||
'appointment_completion_rate' => $appointmentTotal > 0
|
||||
? round($interviewCount / $appointmentTotal * 100, 2)
|
||||
: null,
|
||||
'receive_conversion_rate' => $interviewCount > 0
|
||||
? round($orderCount / $interviewCount * 100, 2)
|
||||
: null,
|
||||
'missed_count' => $missed,
|
||||
'cancelled_count' => $cancelled,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 新挂号口径:支付时间位于筛选区间、状态为已支付、0 < 实收金额 < 10 元。
|
||||
* null 表示全部创建人,空数组表示当前权限范围没有可统计创建人。
|
||||
*
|
||||
* @param int[]|null $creatorIds
|
||||
*/
|
||||
private static function loadRegistrationTotal(
|
||||
string $startDate,
|
||||
string $endDate,
|
||||
?array $creatorIds
|
||||
): int {
|
||||
if ($creatorIds === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$query = Db::name('order')
|
||||
->whereNull('delete_time')
|
||||
->where('status', 2)
|
||||
->where('amount', '>', 0)
|
||||
->where('amount', '<', 10)
|
||||
// payment_time 是 DATETIME NULL;MySQL 8 严格模式下不能与空字符串比较。
|
||||
->whereNotNull('payment_time')
|
||||
->whereBetweenTime(
|
||||
'payment_time',
|
||||
$startDate . ' 00:00:00',
|
||||
$endDate . ' 23:59:59'
|
||||
);
|
||||
if ($creatorIds !== null) {
|
||||
$query->whereIn('creator_id', $creatorIds);
|
||||
}
|
||||
|
||||
return (int) $query->count();
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
|
||||
private static function ranking(array $rows, string $field, int $limit): array
|
||||
{
|
||||
$ranked = $rows;
|
||||
usort($ranked, static fn (array $a, array $b): int => (($b[$field] ?? 0) <=> ($a[$field] ?? 0)) ?: strcmp((string) $a['doctor_name'], (string) $b['doctor_name']));
|
||||
$out = [];
|
||||
foreach (array_slice($ranked, 0, $limit) as $row) {
|
||||
$out[] = [
|
||||
'doctor_id' => (int) ($row['doctor_id'] ?? 0),
|
||||
'name' => (string) ($row['doctor_name'] ?? ''),
|
||||
'value' => round((float) ($row[$field] ?? 0), 2),
|
||||
'interview_count' => (int) ($row['interview_count'] ?? 0),
|
||||
'order_count' => (int) ($row['order_count'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @param int[] $doctorIds @param int[]|null $assistantIds @return array<string,mixed> */
|
||||
private static function buildAmountTrend(array $doctorIds, ?array $assistantIds): array
|
||||
{
|
||||
$endDate = date('Y-m-d');
|
||||
$startDate = date('Y-m-d', strtotime('-' . (self::TREND_DAYS - 1) . ' days'));
|
||||
$amountByDate = [];
|
||||
if ($doctorIds !== [] && $assistantIds !== []) {
|
||||
$query = Db::name('tcm_prescription_order')->alias('o')
|
||||
->join('tcm_prescription rx', 'rx.id = o.prescription_id AND rx.delete_time IS NULL', 'INNER')
|
||||
->whereNull('o.delete_time')
|
||||
->whereIn('rx.creator_id', $doctorIds)
|
||||
->where('o.diagnosis_id', '>', 0)
|
||||
->where('o.create_time', 'between', [
|
||||
strtotime($startDate . ' 00:00:00'),
|
||||
strtotime($endDate . ' 23:59:59'),
|
||||
]);
|
||||
YejiStatsLogic::applyPrescriptionOrderEffectiveAmountQuery($query, 'o');
|
||||
if ($assistantIds !== null) {
|
||||
$query->whereIn('o.creator_id', $assistantIds);
|
||||
}
|
||||
$rows = $query
|
||||
->fieldRaw("FROM_UNIXTIME(o.create_time, '%Y-%m-%d') AS date_label, SUM(o.amount) AS amount_sum")
|
||||
->group('date_label')
|
||||
->order('date_label', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
foreach ($rows as $row) {
|
||||
$date = (string) ($row['date_label'] ?? '');
|
||||
if ($date !== '') {
|
||||
$amountByDate[$date] = round((float) ($row['amount_sum'] ?? 0), 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
$dates = [];
|
||||
$labels = [];
|
||||
$amounts = [];
|
||||
for ($offset = 0; $offset < self::TREND_DAYS; $offset++) {
|
||||
$date = date('Y-m-d', strtotime($startDate . ' +' . $offset . ' days'));
|
||||
$dates[] = $date;
|
||||
$labels[] = date('m-d', strtotime($date));
|
||||
$amounts[] = (float) ($amountByDate[$date] ?? 0);
|
||||
}
|
||||
|
||||
return [
|
||||
'start_date' => $startDate,
|
||||
'end_date' => $endDate,
|
||||
'dates' => $dates,
|
||||
'labels' => $labels,
|
||||
'amounts' => $amounts,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
|
||||
private static function alertRows(array $rows, float $threshold): array
|
||||
{
|
||||
$alerts = array_values(array_filter($rows, static function (array $row) use ($threshold): bool {
|
||||
$interviews = (int) ($row['interview_count'] ?? 0);
|
||||
$rate = $row['receive_conversion_rate'] ?? null;
|
||||
|
||||
return $interviews > 0 && ($rate === null || (float) $rate < $threshold);
|
||||
}));
|
||||
usort($alerts, static fn (array $a, array $b): int => (($a['receive_conversion_rate'] ?? -1) <=> ($b['receive_conversion_rate'] ?? -1)) ?: (($b['interview_count'] ?? 0) <=> ($a['interview_count'] ?? 0)));
|
||||
|
||||
return array_map(static function (array $row) use ($threshold): array {
|
||||
$rate = (float) ($row['receive_conversion_rate'] ?? 0);
|
||||
return [
|
||||
'doctor_id' => (int) ($row['doctor_id'] ?? 0),
|
||||
'doctor_name' => (string) ($row['doctor_name'] ?? ''),
|
||||
'department_name' => (string) ($row['department_name'] ?? ''),
|
||||
'interview_count' => (int) ($row['interview_count'] ?? 0),
|
||||
'order_count' => (int) ($row['order_count'] ?? 0),
|
||||
'rate' => round($rate, 2),
|
||||
'severity' => $rate < $threshold / 2 ? 'high' : 'medium',
|
||||
'suggestion' => (int) ($row['order_count'] ?? 0) === 0
|
||||
? '当前有面诊但无接诊诊单,建议核对诊单及跟进记录'
|
||||
: '接诊转化低于预警线,建议复盘患者需求与沟通记录',
|
||||
];
|
||||
}, $alerts);
|
||||
}
|
||||
|
||||
/** @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)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\firstvisit;
|
||||
|
||||
use app\adminapi\logic\dept\DeptLogic;
|
||||
use app\adminapi\logic\stats\YejiStatsLogic;
|
||||
use app\common\model\auth\AdminDept;
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 一诊「挂号统计」。
|
||||
*
|
||||
* 统计口径:
|
||||
* - 挂号:order.payment_time,已支付且 0 < amount < 10,每笔支付订单计 1 个;
|
||||
* 按支付订单 creator_id 归属员工。
|
||||
* - 预约:doctor_appointment.appointment_date,状态 1/3/4,排除已取消 2;
|
||||
* 归属优先挂号医助 assistant_id,再回退诊单医助 assistant_id。
|
||||
* - 诊单:tcm_prescription_order.create_time,归属订单 creator_id,排除履约 4/9/10。
|
||||
* - 所有部门和员工筛选都只能收窄 DataScope,不允许 HTTP 参数扩大当前账号范围。
|
||||
*/
|
||||
class FirstVisitRegistrationStatsLogic
|
||||
{
|
||||
private const ASSISTANT_ROLE_ID = 2;
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function overview(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
$range = self::resolveRange((string) ($params['time_type'] ?? 'today'));
|
||||
$baseVisibleIds = 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));
|
||||
|
||||
[$selectedDeptIds, $deptSelectionValid] = self::resolveSelectedDeptIds(
|
||||
$selectedDeptId,
|
||||
$allowedDeptSet
|
||||
);
|
||||
$assistants = $deptSelectionValid
|
||||
? self::assistantOptions($baseVisibleIds, $selectedDeptIds, $selectedDeptId)
|
||||
: [];
|
||||
$assistantIds = self::normalizeIds(array_column($assistants, 'id'));
|
||||
if ($selectedAssistantId > 0) {
|
||||
$assistantIds = in_array($selectedAssistantId, $assistantIds, true)
|
||||
? [$selectedAssistantId]
|
||||
: [];
|
||||
}
|
||||
|
||||
$departmentTree = DeptLogic::getAllDataScoped($adminId, $adminInfo);
|
||||
$departmentIndex = [];
|
||||
self::flattenDepartmentTree($departmentTree, $departmentIndex, 0);
|
||||
$assignment = self::buildAssistantDepartmentMap(
|
||||
$assistantIds,
|
||||
$departmentIndex,
|
||||
$selectedDeptIds,
|
||||
$selectedDeptId
|
||||
);
|
||||
|
||||
$appointmentDaily = self::loadAppointmentDaily(
|
||||
min($range['compare_start'], $range['start']),
|
||||
$range['day_after_tomorrow'],
|
||||
$assistantIds
|
||||
);
|
||||
$registrationDaily = self::loadRegistrationDaily(
|
||||
min($range['compare_start'], $range['start']),
|
||||
$range['end'],
|
||||
$assistantIds
|
||||
);
|
||||
$orderDaily = self::loadOrderDaily(
|
||||
min($range['compare_start'], $range['start']),
|
||||
$range['end'],
|
||||
$assistantIds
|
||||
);
|
||||
|
||||
$members = self::buildMemberRows(
|
||||
$assistants,
|
||||
$assistantIds,
|
||||
$assignment,
|
||||
$appointmentDaily,
|
||||
$registrationDaily,
|
||||
$orderDaily,
|
||||
$range
|
||||
);
|
||||
$groups = self::buildDepartmentGroups($members, $departmentIndex);
|
||||
$summary = self::buildSummary($members, $range);
|
||||
$targetDeptIds = self::resolveTargetDeptIds(
|
||||
$adminId,
|
||||
$scopeValue,
|
||||
$selectedAssistantId,
|
||||
$selectedDeptIds,
|
||||
$selectedDeptId
|
||||
);
|
||||
$target = self::buildTarget((int) date('Y'), $assistantIds, $targetDeptIds);
|
||||
|
||||
$selectedDeptName = $selectedDeptId > 0
|
||||
? (string) ($departmentIndex[$selectedDeptId]['name'] ?? '')
|
||||
: '';
|
||||
$selectedAssistantName = '';
|
||||
if ($selectedAssistantId > 0) {
|
||||
foreach ($assistants as $assistant) {
|
||||
if ((int) ($assistant['id'] ?? 0) === $selectedAssistantId) {
|
||||
$selectedAssistantName = (string) ($assistant['name'] ?? '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'meta' => [
|
||||
'time_type' => $range['type'],
|
||||
'time_label' => $range['label'],
|
||||
'start_date' => $range['start'],
|
||||
'end_date' => $range['end'],
|
||||
'generated_at' => date('Y-m-d H:i:s'),
|
||||
'scope_value' => $scopeValue,
|
||||
'scope_label' => DataScopeService::scopeLabel($scopeValue),
|
||||
'selected_dept_name' => $selectedDeptName,
|
||||
'selected_assistant_name' => $selectedAssistantName,
|
||||
'member_count' => count($assistantIds),
|
||||
'registration_rule' => '支付时间在统计区间,状态为已支付且实收金额低于 10 元(大于 0 元),每笔支付订单计 1 个挂号',
|
||||
'appointment_rule' => '预约日期在统计区间,状态为已预约、已完成或已过号,排除已取消',
|
||||
'performance_rule' => '按订单创建时间和创建人统计,排除已取消、拒收和退款',
|
||||
],
|
||||
'filters' => [
|
||||
'departments' => $departmentTree,
|
||||
'assistants' => $assistants,
|
||||
],
|
||||
'summary' => $summary,
|
||||
'employee_rows' => $groups,
|
||||
'rankings' => [
|
||||
'performance' => self::rankMembers($members, 'order_amount', 10),
|
||||
'registrations' => self::rankMembers($members, 'registration_count', 10),
|
||||
'appointments' => self::rankMembers($members, 'appointment_count', 10),
|
||||
],
|
||||
'departments' => self::departmentSummaryRows($groups),
|
||||
'target' => $target,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
private static function resolveRange(string $type): array
|
||||
{
|
||||
$today = date('Y-m-d');
|
||||
$tomorrow = date('Y-m-d', strtotime('+1 day'));
|
||||
$dayAfterTomorrow = date('Y-m-d', strtotime('+2 days'));
|
||||
if ($type === 'yesterday') {
|
||||
$yesterday = date('Y-m-d', strtotime('-1 day'));
|
||||
$dayBefore = date('Y-m-d', strtotime('-2 days'));
|
||||
|
||||
return [
|
||||
'type' => 'yesterday', 'label' => '昨天', 'start' => $yesterday, 'end' => $yesterday,
|
||||
'compare_start' => $dayBefore,
|
||||
'compare_end' => $dayBefore,
|
||||
'tomorrow' => $tomorrow, 'day_after_tomorrow' => $dayAfterTomorrow,
|
||||
];
|
||||
}
|
||||
if ($type === 'week') {
|
||||
$start = date('Y-m-d', strtotime('monday this week'));
|
||||
|
||||
return [
|
||||
'type' => 'week', 'label' => '本周', 'start' => $start, 'end' => $today,
|
||||
'compare_start' => date('Y-m-d', strtotime($start . ' -7 days')),
|
||||
'compare_end' => date('Y-m-d', strtotime($today . ' -7 days')),
|
||||
'tomorrow' => $tomorrow, 'day_after_tomorrow' => $dayAfterTomorrow,
|
||||
];
|
||||
}
|
||||
if ($type === 'month') {
|
||||
$start = date('Y-m-01');
|
||||
$previousStart = date('Y-m-01', strtotime('first day of previous month'));
|
||||
$previousLastDay = (int) date('t', strtotime($previousStart));
|
||||
$day = min((int) date('j'), $previousLastDay);
|
||||
|
||||
return [
|
||||
'type' => 'month', 'label' => '本月', 'start' => $start, 'end' => $today,
|
||||
'compare_start' => $previousStart,
|
||||
'compare_end' => date('Y-m-d', strtotime($previousStart . ' +' . max(0, $day - 1) . ' days')),
|
||||
'tomorrow' => $tomorrow, 'day_after_tomorrow' => $dayAfterTomorrow,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'today', 'label' => '今日', 'start' => $today, 'end' => $today,
|
||||
'compare_start' => date('Y-m-d', strtotime('-1 day')),
|
||||
'compare_end' => date('Y-m-d', strtotime('-1 day')),
|
||||
'tomorrow' => $tomorrow, 'day_after_tomorrow' => $dayAfterTomorrow,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<int,true>|null $allowedSet @return array{0:array<int>,1:bool} */
|
||||
private static function resolveSelectedDeptIds(int $selectedDeptId, ?array $allowedSet): array
|
||||
{
|
||||
if ($selectedDeptId <= 0) {
|
||||
return [[], true];
|
||||
}
|
||||
$ids = self::normalizeIds(DeptLogic::getSelfAndDescendantIds($selectedDeptId));
|
||||
if ($allowedSet !== null) {
|
||||
$ids = array_values(array_filter($ids, static fn (int $id): bool => isset($allowedSet[$id])));
|
||||
}
|
||||
|
||||
return [$ids, $ids !== []];
|
||||
}
|
||||
|
||||
/** @param int[]|null $visibleIds @param int[] $selectedDeptIds @return array<int,array{id:int,name:string}> */
|
||||
private static function assistantOptions(?array $visibleIds, array $selectedDeptIds, int $selectedDeptId): array
|
||||
{
|
||||
if ($visibleIds === []) {
|
||||
return [];
|
||||
}
|
||||
$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 ($visibleIds !== null) {
|
||||
$query->whereIn('a.id', $visibleIds);
|
||||
}
|
||||
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,array<string,mixed>> $nodes @param array<int,array<string,mixed>> $index */
|
||||
private static function flattenDepartmentTree(array $nodes, array &$index, int $depth): void
|
||||
{
|
||||
foreach ($nodes as $node) {
|
||||
$id = (int) ($node['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
continue;
|
||||
}
|
||||
$index[$id] = [
|
||||
'id' => $id,
|
||||
'pid' => (int) ($node['pid'] ?? 0),
|
||||
'name' => (string) ($node['name'] ?? '未命名部门'),
|
||||
'sort' => (int) ($node['sort'] ?? 0),
|
||||
'depth' => $depth,
|
||||
];
|
||||
self::flattenDepartmentTree(
|
||||
is_array($node['children'] ?? null) ? $node['children'] : [],
|
||||
$index,
|
||||
$depth + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param int[] $assistantIds @param array<int,array<string,mixed>> $deptIndex @param int[] $selectedDeptIds @return array<int,int> */
|
||||
private static function buildAssistantDepartmentMap(
|
||||
array $assistantIds,
|
||||
array $deptIndex,
|
||||
array $selectedDeptIds,
|
||||
int $selectedDeptId
|
||||
): array {
|
||||
if ($assistantIds === []) {
|
||||
return [];
|
||||
}
|
||||
$allowed = $selectedDeptId > 0 ? array_fill_keys($selectedDeptIds, true) : null;
|
||||
$rows = AdminDept::whereIn('admin_id', $assistantIds)
|
||||
->field('admin_id, dept_id')
|
||||
->select()
|
||||
->toArray();
|
||||
$candidates = [];
|
||||
foreach ($rows as $row) {
|
||||
$aid = (int) ($row['admin_id'] ?? 0);
|
||||
$deptId = (int) ($row['dept_id'] ?? 0);
|
||||
if (!isset($deptIndex[$deptId]) || ($allowed !== null && !isset($allowed[$deptId]))) {
|
||||
continue;
|
||||
}
|
||||
$candidates[$aid][] = $deptId;
|
||||
}
|
||||
$out = [];
|
||||
foreach ($assistantIds as $aid) {
|
||||
$ids = $candidates[$aid] ?? [];
|
||||
usort($ids, static function (int $left, int $right) use ($deptIndex): int {
|
||||
$depthCompare = (int) ($deptIndex[$right]['depth'] ?? 0) <=> (int) ($deptIndex[$left]['depth'] ?? 0);
|
||||
if ($depthCompare !== 0) {
|
||||
return $depthCompare;
|
||||
}
|
||||
|
||||
return (int) ($deptIndex[$right]['sort'] ?? 0) <=> (int) ($deptIndex[$left]['sort'] ?? 0);
|
||||
});
|
||||
$out[$aid] = (int) ($ids[0] ?? 0);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @param int[] $assistantIds @return array<int,array<string,array{count:int}>> */
|
||||
private static function loadAppointmentDaily(string $startDate, string $endDate, array $assistantIds): array
|
||||
{
|
||||
if ($assistantIds === []) {
|
||||
return [];
|
||||
}
|
||||
$effective = 'COALESCE(NULLIF(a.assistant_id, 0), NULLIF(u.assistant_id, 0))';
|
||||
$query = Db::name('doctor_appointment')->alias('a')
|
||||
->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id')
|
||||
->where('a.appointment_date', 'between', [$startDate, $endDate])
|
||||
->whereIn('a.status', [1, 3, 4])
|
||||
->whereRaw('(u.id IS NULL OR u.delete_time IS NULL)')
|
||||
->whereRaw("({$effective}) IN (" . implode(',', $assistantIds) . ')');
|
||||
$rows = $query
|
||||
->field([
|
||||
'a.appointment_date AS date_label',
|
||||
Db::raw("({$effective}) AS assistant_id"),
|
||||
Db::raw('COUNT(*) AS item_count'),
|
||||
])
|
||||
->group(['a.appointment_date', $effective])
|
||||
->select()
|
||||
->toArray();
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$aid = (int) ($row['assistant_id'] ?? 0);
|
||||
$date = (string) ($row['date_label'] ?? '');
|
||||
if ($aid > 0 && $date !== '') {
|
||||
$out[$aid][$date] = ['count' => (int) ($row['item_count'] ?? 0)];
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @param int[] $assistantIds @return array<int,array<string,array{count:int}>> */
|
||||
private static function loadRegistrationDaily(string $startDate, string $endDate, array $assistantIds): array
|
||||
{
|
||||
if ($assistantIds === []) {
|
||||
return [];
|
||||
}
|
||||
$rows = Db::name('order')->alias('o')
|
||||
->whereNull('o.delete_time')
|
||||
->where('o.status', 2)
|
||||
->where('o.amount', '>', 0)
|
||||
->where('o.amount', '<', 10)
|
||||
->whereBetweenTime(
|
||||
'o.payment_time',
|
||||
$startDate . ' 00:00:00',
|
||||
$endDate . ' 23:59:59'
|
||||
)
|
||||
->whereIn('o.creator_id', $assistantIds)
|
||||
->fieldRaw('o.creator_id AS assistant_id, DATE(o.payment_time) AS date_label, COUNT(*) AS item_count')
|
||||
->group(['o.creator_id', 'date_label'])
|
||||
->select()
|
||||
->toArray();
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$aid = (int) ($row['assistant_id'] ?? 0);
|
||||
$date = (string) ($row['date_label'] ?? '');
|
||||
if ($aid > 0 && $date !== '') {
|
||||
$out[$aid][$date] = ['count' => (int) ($row['item_count'] ?? 0)];
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @param int[] $assistantIds @return array<int,array<string,array{count:int,amount:float}>> */
|
||||
private static function loadOrderDaily(string $startDate, string $endDate, array $assistantIds): array
|
||||
{
|
||||
if ($assistantIds === []) {
|
||||
return [];
|
||||
}
|
||||
$query = Db::name('tcm_prescription_order')->alias('po')
|
||||
->whereNull('po.delete_time')
|
||||
->where('po.create_time', 'between', [
|
||||
strtotime($startDate . ' 00:00:00'),
|
||||
strtotime($endDate . ' 23:59:59'),
|
||||
])
|
||||
->whereIn('po.creator_id', $assistantIds);
|
||||
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, 'po');
|
||||
$rows = $query
|
||||
->fieldRaw("po.creator_id AS assistant_id, FROM_UNIXTIME(po.create_time, '%Y-%m-%d') AS date_label, COUNT(*) AS item_count, SUM(po.amount) AS amount_sum")
|
||||
->group(['po.creator_id', 'date_label'])
|
||||
->select()
|
||||
->toArray();
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$aid = (int) ($row['assistant_id'] ?? 0);
|
||||
$date = (string) ($row['date_label'] ?? '');
|
||||
if ($aid > 0 && $date !== '') {
|
||||
$out[$aid][$date] = [
|
||||
'count' => (int) ($row['item_count'] ?? 0),
|
||||
'amount' => round((float) ($row['amount_sum'] ?? 0), 2),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
private static function buildMemberRows(
|
||||
array $assistants,
|
||||
array $assistantIds,
|
||||
array $assignment,
|
||||
array $appointmentDaily,
|
||||
array $registrationDaily,
|
||||
array $orderDaily,
|
||||
array $range
|
||||
): array {
|
||||
$assistantIndex = [];
|
||||
foreach ($assistants as $assistant) {
|
||||
$assistantIndex[(int) ($assistant['id'] ?? 0)] = (string) ($assistant['name'] ?? '未命名员工');
|
||||
}
|
||||
$rows = [];
|
||||
foreach ($assistantIds as $aid) {
|
||||
$appointmentCount = self::sumDaily($appointmentDaily[$aid] ?? [], $range['start'], $range['end'], 'count');
|
||||
$compareAppointmentCount = self::sumDaily($appointmentDaily[$aid] ?? [], $range['compare_start'], $range['compare_end'], 'count');
|
||||
$registrationCount = self::sumDaily($registrationDaily[$aid] ?? [], $range['start'], $range['end'], 'count');
|
||||
$compareRegistrationCount = self::sumDaily($registrationDaily[$aid] ?? [], $range['compare_start'], $range['compare_end'], 'count');
|
||||
$orderCount = self::sumDaily($orderDaily[$aid] ?? [], $range['start'], $range['end'], 'count');
|
||||
$orderAmount = self::sumDaily($orderDaily[$aid] ?? [], $range['start'], $range['end'], 'amount');
|
||||
$rows[] = [
|
||||
'id' => 'admin-' . $aid,
|
||||
'admin_id' => $aid,
|
||||
'dept_id' => (int) ($assignment[$aid] ?? 0),
|
||||
'name' => (string) ($assistantIndex[$aid] ?? '未命名员工'),
|
||||
'row_type' => 'employee',
|
||||
'registration_count' => (int) $registrationCount,
|
||||
'compare_registration_count' => (int) $compareRegistrationCount,
|
||||
'registration_compare_rate' => self::relativeChange($registrationCount, $compareRegistrationCount),
|
||||
'appointment_count' => (int) $appointmentCount,
|
||||
'compare_appointment_count' => (int) $compareAppointmentCount,
|
||||
'appointment_compare_rate' => self::relativeChange($appointmentCount, $compareAppointmentCount),
|
||||
'tomorrow_count' => (int) ($appointmentDaily[$aid][$range['tomorrow']]['count'] ?? 0),
|
||||
'day_after_count' => (int) ($appointmentDaily[$aid][$range['day_after_tomorrow']]['count'] ?? 0),
|
||||
'order_count' => (int) $orderCount,
|
||||
'order_amount' => round((float) $orderAmount, 2),
|
||||
'status' => 'normal',
|
||||
];
|
||||
}
|
||||
usort($rows, static fn (array $a, array $b): int => ($b['registration_count'] <=> $a['registration_count']) ?: ($b['appointment_count'] <=> $a['appointment_count']) ?: ($b['order_amount'] <=> $a['order_amount']));
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $members @param array<int,array<string,mixed>> $deptIndex @return array<int,array<string,mixed>> */
|
||||
private static function buildDepartmentGroups(array $members, array $deptIndex): array
|
||||
{
|
||||
$groups = [];
|
||||
foreach ($members as $member) {
|
||||
$deptId = (int) ($member['dept_id'] ?? 0);
|
||||
$key = $deptId > 0 ? $deptId : -2;
|
||||
if (!isset($groups[$key])) {
|
||||
$groups[$key] = [
|
||||
'id' => 'dept-' . $key,
|
||||
'dept_id' => $key,
|
||||
'name' => $key > 0 ? (string) ($deptIndex[$key]['name'] ?? '未命名部门') : '未分配部门',
|
||||
'row_type' => 'department',
|
||||
'member_count' => 0,
|
||||
'registration_count' => 0,
|
||||
'compare_registration_count' => 0,
|
||||
'appointment_count' => 0,
|
||||
'compare_appointment_count' => 0,
|
||||
'tomorrow_count' => 0,
|
||||
'day_after_count' => 0,
|
||||
'order_count' => 0,
|
||||
'order_amount' => 0.0,
|
||||
'children' => [],
|
||||
'_sort' => $key > 0 ? (int) ($deptIndex[$key]['sort'] ?? 0) : -1,
|
||||
];
|
||||
}
|
||||
$groups[$key]['children'][] = $member;
|
||||
$groups[$key]['member_count']++;
|
||||
foreach (['registration_count', 'compare_registration_count', 'appointment_count', 'compare_appointment_count', 'tomorrow_count', 'day_after_count', 'order_count'] as $field) {
|
||||
$groups[$key][$field] += (int) ($member[$field] ?? 0);
|
||||
}
|
||||
$groups[$key]['order_amount'] += (float) ($member['order_amount'] ?? 0);
|
||||
}
|
||||
foreach ($groups as &$group) {
|
||||
$group['order_amount'] = round((float) $group['order_amount'], 2);
|
||||
$group['registration_compare_rate'] = self::relativeChange(
|
||||
(float) $group['registration_count'],
|
||||
(float) $group['compare_registration_count']
|
||||
);
|
||||
$group['appointment_compare_rate'] = self::relativeChange(
|
||||
(float) $group['appointment_count'],
|
||||
(float) $group['compare_appointment_count']
|
||||
);
|
||||
$group['status'] = 'normal';
|
||||
}
|
||||
unset($group);
|
||||
$out = array_values($groups);
|
||||
usort($out, static fn (array $a, array $b): int => ($b['_sort'] <=> $a['_sort']) ?: strcmp((string) $a['name'], (string) $b['name']));
|
||||
foreach ($out as &$row) {
|
||||
unset($row['_sort']);
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $members @return array<string,mixed> */
|
||||
private static function buildSummary(array $members, array $range): array
|
||||
{
|
||||
$registrationCount = 0;
|
||||
$compareRegistrationCount = 0;
|
||||
$appointmentCount = 0;
|
||||
$compareAppointmentCount = 0;
|
||||
$orderCount = 0;
|
||||
$orderAmount = 0.0;
|
||||
foreach ($members as $member) {
|
||||
$registrationCount += (int) ($member['registration_count'] ?? 0);
|
||||
$compareRegistrationCount += (int) ($member['compare_registration_count'] ?? 0);
|
||||
$appointmentCount += (int) ($member['appointment_count'] ?? 0);
|
||||
$compareAppointmentCount += (int) ($member['compare_appointment_count'] ?? 0);
|
||||
$orderCount += (int) ($member['order_count'] ?? 0);
|
||||
$orderAmount += (float) ($member['order_amount'] ?? 0);
|
||||
}
|
||||
|
||||
return [
|
||||
'registration_count' => $registrationCount,
|
||||
'registration_compare_count' => $compareRegistrationCount,
|
||||
'registration_compare_rate' => self::relativeChange($registrationCount, $compareRegistrationCount),
|
||||
'appointment_count' => $appointmentCount,
|
||||
'appointment_compare_count' => $compareAppointmentCount,
|
||||
'appointment_compare_rate' => self::relativeChange($appointmentCount, $compareAppointmentCount),
|
||||
'order_count' => $orderCount,
|
||||
'order_amount' => round($orderAmount, 2),
|
||||
'range_label' => $range['label'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $members @return array<int,array<string,mixed>> */
|
||||
private static function rankMembers(array $members, string $field, int $limit): array
|
||||
{
|
||||
$rows = $members;
|
||||
usort($rows, static fn (array $a, array $b): int => (($b[$field] ?? 0) <=> ($a[$field] ?? 0)) ?: strcmp((string) $a['name'], (string) $b['name']));
|
||||
$out = [];
|
||||
foreach (array_slice($rows, 0, $limit) as $row) {
|
||||
$countField = match ($field) {
|
||||
'order_amount' => 'order_count',
|
||||
'registration_count' => 'registration_count',
|
||||
default => 'appointment_count',
|
||||
};
|
||||
$out[] = [
|
||||
'admin_id' => (int) ($row['admin_id'] ?? 0),
|
||||
'name' => (string) ($row['name'] ?? ''),
|
||||
'value' => $field === 'order_amount'
|
||||
? round((float) ($row[$field] ?? 0), 2)
|
||||
: (int) ($row[$field] ?? 0),
|
||||
'count' => (int) ($row[$countField] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $groups @return array<int,array<string,mixed>> */
|
||||
private static function departmentSummaryRows(array $groups): array
|
||||
{
|
||||
$rows = [];
|
||||
foreach ($groups as $group) {
|
||||
$copy = $group;
|
||||
unset($copy['children']);
|
||||
$rows[] = $copy;
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @param int[] $selectedDeptIds @return int[]|null */
|
||||
private static function resolveTargetDeptIds(
|
||||
int $adminId,
|
||||
int $scopeValue,
|
||||
int $selectedAssistantId,
|
||||
array $selectedDeptIds,
|
||||
int $selectedDeptId
|
||||
): ?array {
|
||||
if ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$scopeDeptIds = null;
|
||||
if ($scopeValue !== DataScopeService::SCOPE_ALL) {
|
||||
$ownDeptIds = self::normalizeIds(AdminDept::where('admin_id', $adminId)->column('dept_id'));
|
||||
if ($scopeValue === DataScopeService::SCOPE_DEPT) {
|
||||
$scopeDeptIds = $ownDeptIds;
|
||||
} else {
|
||||
$set = [];
|
||||
foreach ($ownDeptIds as $deptId) {
|
||||
foreach (DeptLogic::getSelfAndDescendantIds($deptId) as $id) {
|
||||
$id = (int) $id;
|
||||
if ($id > 0) {
|
||||
$set[$id] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
$scopeDeptIds = array_map('intval', array_keys($set));
|
||||
}
|
||||
}
|
||||
|
||||
if ($selectedDeptId <= 0) {
|
||||
return $scopeDeptIds;
|
||||
}
|
||||
if ($scopeDeptIds === null) {
|
||||
return $selectedDeptIds;
|
||||
}
|
||||
|
||||
return array_values(array_intersect($scopeDeptIds, $selectedDeptIds));
|
||||
}
|
||||
|
||||
/** @param int[] $assistantIds @param int[]|null $targetDeptIds @return array<string,mixed> */
|
||||
private static function buildTarget(int $year, array $assistantIds, ?array $targetDeptIds): array
|
||||
{
|
||||
$targetRows = [];
|
||||
if ($targetDeptIds !== []) {
|
||||
$query = Db::name('dept_performance_target')->whereLike('year_month', $year . '-%');
|
||||
if ($targetDeptIds !== null) {
|
||||
$query->whereIn('dept_id', $targetDeptIds);
|
||||
}
|
||||
$targetRows = $query
|
||||
->fieldRaw('`year_month`, SUM(`target_amount`) AS target_amount, COUNT(DISTINCT `dept_id`) AS dept_count')
|
||||
->group('`year_month`')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
$actualRows = [];
|
||||
if ($assistantIds !== []) {
|
||||
$query = Db::name('tcm_prescription_order')->alias('po')
|
||||
->whereNull('po.delete_time')
|
||||
->whereIn('po.creator_id', $assistantIds)
|
||||
->where('po.create_time', 'between', [
|
||||
strtotime($year . '-01-01 00:00:00'),
|
||||
strtotime($year . '-12-31 23:59:59'),
|
||||
]);
|
||||
YejiStatsLogic::applyPrescriptionOrderNotCancelledForPerformanceQuery($query, 'po');
|
||||
$actualRows = $query
|
||||
->fieldRaw("DATE_FORMAT(FROM_UNIXTIME(po.create_time), '%m') AS month_no, SUM(po.amount) AS actual_amount")
|
||||
->group('month_no')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
$monthlyTarget = array_fill(1, 12, 0.0);
|
||||
$monthlyActual = array_fill(1, 12, 0.0);
|
||||
$deptCount = 0;
|
||||
foreach ($targetRows as $row) {
|
||||
$month = (int) substr((string) ($row['year_month'] ?? ''), 5, 2);
|
||||
if ($month >= 1 && $month <= 12) {
|
||||
$monthlyTarget[$month] = round((float) ($row['target_amount'] ?? 0), 2);
|
||||
$deptCount = max($deptCount, (int) ($row['dept_count'] ?? 0));
|
||||
}
|
||||
}
|
||||
foreach ($actualRows as $row) {
|
||||
$month = (int) ($row['month_no'] ?? 0);
|
||||
if ($month >= 1 && $month <= 12) {
|
||||
$monthlyActual[$month] = round((float) ($row['actual_amount'] ?? 0), 2);
|
||||
}
|
||||
}
|
||||
$targetCumulative = [];
|
||||
$actualCumulative = [];
|
||||
$targetTotal = 0.0;
|
||||
$actualTotal = 0.0;
|
||||
for ($month = 1; $month <= 12; $month++) {
|
||||
$targetTotal = round($targetTotal + $monthlyTarget[$month], 2);
|
||||
$actualTotal = round($actualTotal + $monthlyActual[$month], 2);
|
||||
$targetCumulative[] = $targetTotal;
|
||||
$actualCumulative[] = $actualTotal;
|
||||
}
|
||||
|
||||
return [
|
||||
'year' => $year,
|
||||
'target_amount' => $targetTotal,
|
||||
'actual_amount' => $actualTotal,
|
||||
'completion_rate' => $targetTotal > 0 ? round($actualTotal / $targetTotal * 100, 2) : null,
|
||||
'department_count' => $deptCount,
|
||||
'scope_note' => $targetDeptIds === [] ? '当前为本人或单个员工范围,未设置个人目标' : '按当前可见部门汇总',
|
||||
'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<string,array<string,int|float>> $daily */
|
||||
private static function sumDaily(array $daily, string $start, string $end, string $field): float
|
||||
{
|
||||
$sum = 0.0;
|
||||
foreach ($daily as $date => $values) {
|
||||
if ($date >= $start && $date <= $end) {
|
||||
$sum += (float) ($values[$field] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return $sum;
|
||||
}
|
||||
|
||||
private static function relativeChange(float $current, float $previous): ?float
|
||||
{
|
||||
if (abs($previous) < 0.00001) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return round(($current - $previous) / $previous * 100, 2);
|
||||
}
|
||||
|
||||
/** @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)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\firstvisit;
|
||||
|
||||
use app\common\model\auth\AdminRole;
|
||||
use app\common\model\doctor\Appointment;
|
||||
use app\common\model\tcm\Diagnosis;
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use think\db\Query;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* “我的患者”统一数据范围。
|
||||
*
|
||||
* 角色语义:医生只看本人接诊患者,医助只看本人归属患者;经理、
|
||||
* 诊室组长和管理员按系统 DataScope 查看团队患者;root 查看全部。
|
||||
*/
|
||||
class MyPatientLogic
|
||||
{
|
||||
private const DOCTOR_ROLE_ID = 1;
|
||||
private const ASSISTANT_ROLE_ID = 2;
|
||||
private const TEAM_ROLE_IDS = [3, 7, 8];
|
||||
private const EFFECTIVE_APPOINTMENT_STATUSES = [1, 3, 4];
|
||||
|
||||
/**
|
||||
* @param Query $query 以 d 作为 zyt_tcm_diagnosis 别名的查询
|
||||
*/
|
||||
public static function applyScope(Query $query, int $adminId, array $adminInfo): void
|
||||
{
|
||||
if ($adminId <= 0) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int) ($adminInfo['root'] ?? 0) === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$roleIds = self::roleIds($adminId);
|
||||
$appointmentTable = (new Appointment())->getTable();
|
||||
$statusList = implode(',', self::EFFECTIVE_APPOINTMENT_STATUSES);
|
||||
|
||||
// 管理角色按系统的数据范围查看“范围内医助归属或医生接诊”的患者。
|
||||
if (array_intersect($roleIds, self::TEAM_ROLE_IDS) !== []) {
|
||||
$visibleAdminIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
if ($visibleAdminIds === null) {
|
||||
return;
|
||||
}
|
||||
$visibleAdminIds = self::normalizeIds($visibleAdminIds);
|
||||
if ($visibleAdminIds === []) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return;
|
||||
}
|
||||
$ids = implode(',', $visibleAdminIds);
|
||||
$query->whereRaw(
|
||||
"(CAST(d.assistant_id AS UNSIGNED) IN ({$ids})"
|
||||
. " OR EXISTS (SELECT 1 FROM {$appointmentTable} scope_apt"
|
||||
. ' WHERE scope_apt.patient_id = d.id'
|
||||
. " AND scope_apt.status IN ({$statusList})"
|
||||
. " AND scope_apt.doctor_id IN ({$ids})))"
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 一线角色始终只取“本人关系”,不受数据库中医生角色 ALL 配置影响。
|
||||
$conditions = [];
|
||||
if (in_array(self::ASSISTANT_ROLE_ID, $roleIds, true)) {
|
||||
$conditions[] = 'CAST(d.assistant_id AS UNSIGNED) = ' . $adminId;
|
||||
}
|
||||
if (in_array(self::DOCTOR_ROLE_ID, $roleIds, true)) {
|
||||
$conditions[] = "EXISTS (SELECT 1 FROM {$appointmentTable} scope_apt"
|
||||
. ' WHERE scope_apt.patient_id = d.id'
|
||||
. " AND scope_apt.status IN ({$statusList})"
|
||||
. " AND scope_apt.doctor_id = {$adminId})";
|
||||
}
|
||||
|
||||
// 未知/异常角色按本人医助或本人医生关系收窄,拒绝意外放大全库。
|
||||
if ($conditions === []) {
|
||||
$conditions[] = 'CAST(d.assistant_id AS UNSIGNED) = ' . $adminId;
|
||||
$conditions[] = "EXISTS (SELECT 1 FROM {$appointmentTable} scope_apt"
|
||||
. ' WHERE scope_apt.patient_id = d.id'
|
||||
. " AND scope_apt.status IN ({$statusList})"
|
||||
. " AND scope_apt.doctor_id = {$adminId})";
|
||||
}
|
||||
|
||||
$query->whereRaw('(' . implode(' OR ', $conditions) . ')');
|
||||
}
|
||||
|
||||
public static function canAccessDiagnosis(int $diagnosisId, int $adminId, array $adminInfo): bool
|
||||
{
|
||||
if ($diagnosisId <= 0 || $adminId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$diagnosisTable = (new Diagnosis())->getTable();
|
||||
$query = Db::table($diagnosisTable)
|
||||
->alias('d')
|
||||
->where('d.id', $diagnosisId)
|
||||
->whereNull('d.delete_time')
|
||||
->where('d.status', 1);
|
||||
self::applyScope($query, $adminId, $adminInfo);
|
||||
|
||||
return (int) $query->count() > 0;
|
||||
}
|
||||
|
||||
/** @return array{mode:string,label:string} */
|
||||
public static function scopeMeta(int $adminId, array $adminInfo): array
|
||||
{
|
||||
if ((int) ($adminInfo['root'] ?? 0) === 1) {
|
||||
return ['mode' => 'all', 'label' => '全部数据'];
|
||||
}
|
||||
|
||||
$roleIds = self::roleIds($adminId);
|
||||
if (array_intersect($roleIds, self::TEAM_ROLE_IDS) !== []) {
|
||||
$scope = DataScopeService::getEffectiveScope($adminInfo);
|
||||
$labels = [
|
||||
DataScopeService::SCOPE_ALL => '全部数据',
|
||||
DataScopeService::SCOPE_DEPT_AND_CHILD => '本部门及下级',
|
||||
DataScopeService::SCOPE_DEPT => '本部门',
|
||||
DataScopeService::SCOPE_SELF => '仅本人',
|
||||
];
|
||||
|
||||
return [
|
||||
'mode' => $scope === DataScopeService::SCOPE_ALL ? 'all' : 'team',
|
||||
'label' => $labels[$scope] ?? '仅本人',
|
||||
];
|
||||
}
|
||||
|
||||
$isDoctor = in_array(self::DOCTOR_ROLE_ID, $roleIds, true);
|
||||
$isAssistant = in_array(self::ASSISTANT_ROLE_ID, $roleIds, true);
|
||||
if ($isDoctor && $isAssistant) {
|
||||
return ['mode' => 'self', 'label' => '本人归属及接诊'];
|
||||
}
|
||||
if ($isDoctor) {
|
||||
return ['mode' => 'self', 'label' => '本人接诊'];
|
||||
}
|
||||
|
||||
return ['mode' => 'self', 'label' => '本人归属'];
|
||||
}
|
||||
|
||||
/** @return int[] */
|
||||
private static function roleIds(int $adminId): array
|
||||
{
|
||||
return self::normalizeIds(AdminRole::where('admin_id', $adminId)->column('role_id'));
|
||||
}
|
||||
|
||||
/** @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 function (int $id): bool {
|
||||
return $id > 0;
|
||||
})));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\firstvisit;
|
||||
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use app\common\service\qywx\QywxCustomerAcquisitionCustomerService;
|
||||
use RuntimeException;
|
||||
use think\facade\Db;
|
||||
|
||||
/** 获客客户同步与数据权限统计。 */
|
||||
class WecomAcquisitionCustomerLogic
|
||||
{
|
||||
/** @return array<string,mixed> */
|
||||
public static function sync(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
$localLinkId = max(0, (int) ($params['promotion_link_id'] ?? $params['id'] ?? 0));
|
||||
$query = Db::name('qywx_promotion_link')->alias('l')
|
||||
->whereNull('l.delete_time')
|
||||
->where('l.remote_link_id', '<>', '')
|
||||
->where('l.remote_status', 1);
|
||||
self::applyScope($query, 'l', DataScopeService::getVisibleAdminIds($adminId, $adminInfo));
|
||||
if ($localLinkId > 0) {
|
||||
$query->where('l.id', $localLinkId);
|
||||
}
|
||||
$links = $query->field('l.id,l.remote_link_id')->order('l.id', 'asc')->limit(200)->select()->toArray();
|
||||
if ($localLinkId > 0 && $links === []) {
|
||||
throw new RuntimeException('获客链接不存在、已失效,或超出当前权限范围');
|
||||
}
|
||||
if ($links === []) {
|
||||
throw new RuntimeException('当前数据范围内没有可同步的有效官方获客链接;已删除和历史手工链接不会参与客户同步,请先创建官方获客链接');
|
||||
}
|
||||
$service = new QywxCustomerAcquisitionCustomerService();
|
||||
$result = ['links' => count($links), 'scanned' => 0, 'created' => 0, 'updated' => 0, 'failed' => 0, 'errors' => []];
|
||||
foreach ($links as $link) {
|
||||
try {
|
||||
$one = $service->syncLink((string) $link['remote_link_id']);
|
||||
$result['scanned'] += $one['scanned'];
|
||||
$result['created'] += $one['created'];
|
||||
$result['updated'] += $one['updated'];
|
||||
} catch (\Throwable $e) {
|
||||
$result['failed']++;
|
||||
if (count($result['errors']) < 10) {
|
||||
$result['errors'][] = (string) $link['remote_link_id'] . ':' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function statistics(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
$page = max(1, (int) ($params['page_no'] ?? $params['page'] ?? 1));
|
||||
$pageSize = min(100, max(1, (int) ($params['page_size'] ?? 20)));
|
||||
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
$base = self::customerQuery($params, $visibleIds);
|
||||
$total = (int) (clone $base)->count();
|
||||
$rows = $base
|
||||
->field('c.id,c.promotion_link_id,c.link_id,c.external_userid,c.userid,c.owner_admin_id,c.dept_id,c.state,c.chat_status,c.recv_msg_cnt,c.message_count_known,c.first_acquired_time,c.last_chat_time,c.last_sync_time,c.create_time,c.update_time,a.name as owner_name,d.name as dept_name,l.name as link_name,p.name as pool_name')
|
||||
->order('c.last_chat_time', 'desc')->order('c.id', 'desc')
|
||||
->page($page, $pageSize)->select()->toArray();
|
||||
foreach ($rows as &$row) {
|
||||
$row['external_userid_masked'] = self::maskIdentifier((string) ($row['external_userid'] ?? ''));
|
||||
unset($row['external_userid']);
|
||||
$row['has_messaged'] = (int) ($row['chat_status'] ?? 0) === 1;
|
||||
$row['message_count_known'] = (int) ($row['message_count_known'] ?? 0);
|
||||
$row['received_message_count'] = (int) ($row['recv_msg_cnt'] ?? 0);
|
||||
}
|
||||
unset($row);
|
||||
|
||||
$summaryQuery = self::customerQuery($params, $visibleIds);
|
||||
$summaryRow = $summaryQuery->fieldRaw(
|
||||
'COUNT(*) AS customer_count, '
|
||||
. 'COALESCE(SUM(CASE WHEN c.message_count_known = 1 THEN c.recv_msg_cnt ELSE 0 END),0) AS recv_msg_cnt, '
|
||||
. 'SUM(CASE WHEN c.chat_status = 1 THEN 1 ELSE 0 END) AS started_chat_count, '
|
||||
. 'SUM(CASE WHEN c.message_count_known = 1 THEN 1 ELSE 0 END) AS message_count_known_count'
|
||||
)->find() ?: [];
|
||||
|
||||
return [
|
||||
'meta' => [
|
||||
'scope_label' => DataScopeService::scopeLabel(DataScopeService::getEffectiveScope($adminInfo)),
|
||||
'generated_at' => date('Y-m-d H:i:s'),
|
||||
],
|
||||
'summary' => [
|
||||
'customer_count' => (int) ($summaryRow['customer_count'] ?? 0),
|
||||
'started_chat_count' => (int) ($summaryRow['started_chat_count'] ?? 0),
|
||||
'recv_msg_cnt' => (int) ($summaryRow['recv_msg_cnt'] ?? 0),
|
||||
'received_message_count' => (int) ($summaryRow['recv_msg_cnt'] ?? 0),
|
||||
'message_count_known_count' => (int) ($summaryRow['message_count_known_count'] ?? 0),
|
||||
],
|
||||
'lists' => $rows,
|
||||
'count' => $total,
|
||||
'page_no' => $page,
|
||||
'page_size' => $pageSize,
|
||||
];
|
||||
}
|
||||
|
||||
private static function customerQuery(array $params, ?array $visibleIds)
|
||||
{
|
||||
$query = Db::name('qywx_customer_acquisition_customer')->alias('c')
|
||||
->leftJoin('admin a', 'a.id = c.owner_admin_id AND a.delete_time IS NULL')
|
||||
->leftJoin('dept d', 'd.id = c.dept_id')
|
||||
->leftJoin('qywx_promotion_link l', 'l.id = c.promotion_link_id')
|
||||
->leftJoin('qywx_promotion_pool p', 'p.id = l.pool_id');
|
||||
self::applyScope($query, 'c', $visibleIds);
|
||||
$localLinkId = max(0, (int) ($params['promotion_link_id'] ?? 0));
|
||||
if ($localLinkId > 0) {
|
||||
$query->where('c.promotion_link_id', $localLinkId);
|
||||
}
|
||||
$userId = trim((string) ($params['userid'] ?? ''));
|
||||
if ($userId !== '') {
|
||||
$query->where('c.userid', $userId);
|
||||
}
|
||||
if (isset($params['chat_status']) && $params['chat_status'] !== '') {
|
||||
$query->where('c.chat_status', max(0, (int) $params['chat_status']));
|
||||
}
|
||||
$keyword = trim((string) ($params['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
$query->whereLike('c.external_userid|c.userid|a.name|l.name', '%' . $keyword . '%');
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private static function applyScope($query, string $alias, ?array $visibleIds): void
|
||||
{
|
||||
if ($visibleIds === null) {
|
||||
return;
|
||||
}
|
||||
if ($visibleIds === []) {
|
||||
$query->whereRaw('1 = 0');
|
||||
return;
|
||||
}
|
||||
$query->whereIn($alias . '.owner_admin_id', array_values(array_unique(array_map('intval', $visibleIds))));
|
||||
}
|
||||
|
||||
private static function maskIdentifier(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
$length = mb_strlen($value);
|
||||
if ($length <= 0) {
|
||||
return '-';
|
||||
}
|
||||
if ($length <= 4) {
|
||||
return mb_substr($value, 0, 1) . '***';
|
||||
}
|
||||
if ($length <= 8) {
|
||||
return mb_substr($value, 0, 2) . '***' . mb_substr($value, -1);
|
||||
}
|
||||
|
||||
return mb_substr($value, 0, 4) . '****' . mb_substr($value, -4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,747 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\firstvisit;
|
||||
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use app\common\service\qywx\QywxCustomerAcquisitionApiService;
|
||||
use app\common\service\qywx\QywxCustomerAcquisitionLinkService;
|
||||
use app\common\service\qywx\QywxPromotionWidgetService;
|
||||
use RuntimeException;
|
||||
use think\facade\Db;
|
||||
|
||||
/** 一诊 / 企业微信获客助手管理逻辑。 */
|
||||
class WecomPromotionLogic
|
||||
{
|
||||
public static function overview(int $adminId, array $adminInfo, string $domain): array
|
||||
{
|
||||
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
$poolsQuery = Db::name('qywx_promotion_pool')->alias('p')
|
||||
->leftJoin('admin u', 'u.id = p.owner_admin_id')
|
||||
->leftJoin('dept d', 'd.id = p.dept_id')
|
||||
->whereNull('p.delete_time');
|
||||
self::applyOwnerScope($poolsQuery, 'p', $visibleIds);
|
||||
$pools = $poolsQuery
|
||||
->field('p.id,p.name,p.public_key,p.status,p.fallback_url,p.widget_config_json,p.click_count,p.owner_admin_id,p.dept_id,p.create_time,p.update_time,u.name as owner_name,d.name as dept_name')
|
||||
->order('p.id', 'desc')
|
||||
->select()->toArray();
|
||||
|
||||
$poolIds = array_values(array_filter(array_map('intval', array_column($pools, 'id'))));
|
||||
$links = [];
|
||||
if ($poolIds !== []) {
|
||||
$links = Db::name('qywx_promotion_link')->alias('l')
|
||||
->whereNull('l.delete_time')
|
||||
->whereIn('l.pool_id', $poolIds)
|
||||
->field('l.id,l.pool_id,l.name,l.group_name,l.wecom_url,l.remote_link_id,l.remote_status,l.remote_create_time,l.range_user_json,l.range_department_json,l.skip_verify,l.priority_option_json,l.last_sync_time,l.sync_error,l.weight,l.status,l.daily_limit,l.today_count,l.today_date,l.active_start,l.active_end,l.click_count,l.last_click_time,l.remark,l.create_time,l.update_time')
|
||||
->order('l.status', 'desc')
|
||||
->order('l.weight', 'desc')
|
||||
->order('l.id', 'desc')
|
||||
->select()->toArray();
|
||||
}
|
||||
|
||||
$domain = self::publicDomain($domain);
|
||||
foreach ($pools as &$pool) {
|
||||
$pool['widget_config'] = QywxPromotionWidgetService::decode($pool['widget_config_json'] ?? null);
|
||||
unset($pool['widget_config_json']);
|
||||
$key = (string) $pool['public_key'];
|
||||
$scriptUrl = $domain . '/api/qywx-promotion/js/' . $key;
|
||||
$goUrl = $domain . '/api/qywx-promotion/go/' . $key;
|
||||
$pool['script_url'] = $scriptUrl;
|
||||
$pool['go_url'] = $goUrl;
|
||||
$pool['install_code'] = '<script src="'
|
||||
. htmlspecialchars($scriptUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
||||
. '" defer></script>';
|
||||
$pool['trigger_code'] = '<a href="'
|
||||
. htmlspecialchars($goUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
||||
. '" data-wecom-promotion="' . $key . '">添加企业微信</a>';
|
||||
}
|
||||
unset($pool);
|
||||
|
||||
$today = date('Y-m-d');
|
||||
$todayClicks = 0;
|
||||
$onlineLinks = 0;
|
||||
foreach ($links as &$link) {
|
||||
$link['range_userids'] = self::decodeStringList($link['range_user_json'] ?? null);
|
||||
$link['range_department_ids'] = self::decodeStringList($link['range_department_json'] ?? null);
|
||||
$link['priority_option'] = self::decodeObject($link['priority_option_json'] ?? null);
|
||||
$link['is_official'] = trim((string) ($link['remote_link_id'] ?? '')) !== '';
|
||||
$link['valid_customer_acquisition_link'] = QywxCustomerAcquisitionLinkService::isAllowed((string) ($link['wecom_url'] ?? ''));
|
||||
if ((int) ($link['status'] ?? 0) === 1 && $link['valid_customer_acquisition_link']) {
|
||||
$onlineLinks++;
|
||||
}
|
||||
if ((string) ($link['today_date'] ?? '') === $today) {
|
||||
$todayClicks += (int) ($link['today_count'] ?? 0);
|
||||
}
|
||||
}
|
||||
unset($link);
|
||||
|
||||
$config = self::internalApplicationStatus($domain);
|
||||
|
||||
return [
|
||||
'meta' => [
|
||||
'scope_label' => DataScopeService::scopeLabel(DataScopeService::getEffectiveScope($adminInfo)),
|
||||
'generated_at' => date('Y-m-d H:i:s'),
|
||||
],
|
||||
'config' => $config,
|
||||
'summary' => [
|
||||
'configured_apps' => $config['ready'] ? 1 : 0,
|
||||
'pool_count' => count($pools),
|
||||
'online_links' => $onlineLinks,
|
||||
'today_clicks' => $todayClicks,
|
||||
],
|
||||
'pools' => $pools,
|
||||
'links' => $links,
|
||||
'member_options' => self::memberOptions($adminId, $adminInfo),
|
||||
'customer_acquisition_link_example' => QywxCustomerAcquisitionLinkService::example(),
|
||||
];
|
||||
}
|
||||
|
||||
public static function savePool(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
$id = max(0, (int) ($params['id'] ?? 0));
|
||||
$name = trim((string) ($params['name'] ?? ''));
|
||||
if ($name === '' || mb_strlen($name) > 60) {
|
||||
throw new RuntimeException('请输入 1-60 个字符的分流方案名称');
|
||||
}
|
||||
$fallback = trim((string) ($params['fallback_url'] ?? ''));
|
||||
if (!QywxCustomerAcquisitionLinkService::isAllowed($fallback, true)) {
|
||||
throw new RuntimeException('兜底链接必须是企业微信获客助手生成的 HTTPS 链接');
|
||||
}
|
||||
$now = time();
|
||||
$data = [
|
||||
'name' => $name,
|
||||
'status' => (int) ($params['status'] ?? 1) === 1 ? 1 : 0,
|
||||
'fallback_url' => $fallback,
|
||||
'update_time' => $now,
|
||||
];
|
||||
if ($id > 0) {
|
||||
self::assertScopedRow('qywx_promotion_pool', $id, $adminId, $adminInfo);
|
||||
Db::name('qywx_promotion_pool')->where('id', $id)->update($data);
|
||||
} else {
|
||||
$data += [
|
||||
'public_key' => bin2hex(random_bytes(16)),
|
||||
'owner_admin_id' => $adminId,
|
||||
'dept_id' => self::primaryDeptId($adminId),
|
||||
'click_count' => 0,
|
||||
'create_time' => $now,
|
||||
];
|
||||
$id = (int) Db::name('qywx_promotion_pool')->insertGetId($data);
|
||||
}
|
||||
|
||||
return ['id' => $id];
|
||||
}
|
||||
|
||||
public static function saveWidget(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
$id = max(0, (int) ($params['pool_id'] ?? $params['id'] ?? 0));
|
||||
self::assertScopedRow('qywx_promotion_pool', $id, $adminId, $adminInfo);
|
||||
|
||||
$input = $params['widget_config'] ?? $params;
|
||||
$config = QywxPromotionWidgetService::fromInput($input);
|
||||
Db::name('qywx_promotion_pool')->where('id', $id)->update([
|
||||
'widget_config_json' => QywxPromotionWidgetService::encode($config),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
|
||||
return ['id' => $id, 'widget_config' => $config];
|
||||
}
|
||||
|
||||
public static function deletePool(int $id, int $adminId, array $adminInfo): void
|
||||
{
|
||||
self::assertScopedRow('qywx_promotion_pool', $id, $adminId, $adminInfo);
|
||||
$now = time();
|
||||
Db::transaction(function () use ($id, $now): void {
|
||||
Db::name('qywx_promotion_pool')->where('id', $id)->update(['delete_time' => $now, 'update_time' => $now]);
|
||||
Db::name('qywx_promotion_link')->where('pool_id', $id)->whereNull('delete_time')->update(['delete_time' => $now, 'update_time' => $now]);
|
||||
});
|
||||
}
|
||||
|
||||
public static function saveLink(array $params, int $adminId, array $adminInfo): array
|
||||
{
|
||||
$id = max(0, (int) ($params['id'] ?? 0));
|
||||
$poolId = max(0, (int) ($params['pool_id'] ?? 0));
|
||||
$pool = self::assertScopedRow('qywx_promotion_pool', $poolId, $adminId, $adminInfo);
|
||||
$existing = $id > 0 ? self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo) : null;
|
||||
$name = trim((string) ($params['name'] ?? ''));
|
||||
if ($name === '' || mb_strlen($name) > 80) {
|
||||
throw new RuntimeException('请输入 1-80 个字符的获客链接名称');
|
||||
}
|
||||
$startAt = self::parseTime($params['active_start'] ?? null);
|
||||
$endAt = self::parseTime($params['active_end'] ?? null);
|
||||
if ($startAt > 0 && $endAt > 0 && $startAt >= $endAt) {
|
||||
throw new RuntimeException('生效结束时间必须晚于开始时间');
|
||||
}
|
||||
$now = time();
|
||||
$data = [
|
||||
'pool_id' => $poolId,
|
||||
'account_id' => 0,
|
||||
'name' => $name,
|
||||
'group_name' => mb_substr(trim((string) ($params['group_name'] ?? '默认分组')), 0, 60),
|
||||
'weight' => min(100, max(1, (int) ($params['weight'] ?? 1))),
|
||||
'status' => (int) ($params['status'] ?? 1) === 1 ? 1 : 0,
|
||||
'daily_limit' => min(1000000, max(0, (int) ($params['daily_limit'] ?? 0))),
|
||||
'active_start' => $startAt,
|
||||
'active_end' => $endAt,
|
||||
'remark' => mb_substr(trim((string) ($params['remark'] ?? '')), 0, 255),
|
||||
'update_time' => $now,
|
||||
];
|
||||
|
||||
// 历史手工链接只维护本地分流规则,不会在企业微信端创建重复链接。
|
||||
if ($existing !== null && trim((string) ($existing['remote_link_id'] ?? '')) === '') {
|
||||
$url = trim((string) ($params['wecom_url'] ?? $existing['wecom_url'] ?? ''));
|
||||
if (!QywxCustomerAcquisitionLinkService::isAllowed($url)) {
|
||||
throw new RuntimeException('历史链接必须是 https://work.weixin.qq.com/ca/... 格式');
|
||||
}
|
||||
$data['wecom_url'] = $url;
|
||||
Db::name('qywx_promotion_link')->where('id', $id)->update($data);
|
||||
|
||||
return ['id' => $id, 'mode' => 'legacy'];
|
||||
}
|
||||
|
||||
$userIds = self::resolveMemberUserIds((array) ($params['member_admin_ids'] ?? []), $adminId, $adminInfo);
|
||||
$skipVerify = (int) ($params['skip_verify'] ?? 0) === 1 ? 1 : 0;
|
||||
$payload = [
|
||||
'link_name' => $name,
|
||||
'range' => ['user_list' => $userIds],
|
||||
'skip_verify' => $skipVerify === 1,
|
||||
];
|
||||
|
||||
$api = new QywxCustomerAcquisitionApiService();
|
||||
if ($existing !== null) {
|
||||
$remoteLinkId = trim((string) ($existing['remote_link_id'] ?? ''));
|
||||
$payload['link_id'] = $remoteLinkId;
|
||||
$api->updateLink($payload);
|
||||
} else {
|
||||
$created = $api->createLink($payload);
|
||||
$remoteLinkId = self::extractRemoteLinkId($created);
|
||||
if ($remoteLinkId === '') {
|
||||
throw new RuntimeException('企业微信已创建链接,但接口未返回 link_id,请先执行“同步企业微信”确认结果');
|
||||
}
|
||||
}
|
||||
|
||||
$remote = self::normaliseRemoteLink($api->getLink($remoteLinkId), $remoteLinkId);
|
||||
$data += self::remoteColumns($remote, $now);
|
||||
if ($existing !== null) {
|
||||
Db::name('qywx_promotion_link')->where('id', $id)->update($data);
|
||||
} else {
|
||||
$data += [
|
||||
'owner_admin_id' => (int) ($pool['owner_admin_id'] ?? 0) ?: $adminId,
|
||||
'dept_id' => (int) ($pool['dept_id'] ?? 0) ?: self::primaryDeptId($adminId),
|
||||
'click_count' => 0,
|
||||
'today_count' => 0,
|
||||
'today_date' => null,
|
||||
'last_click_time' => 0,
|
||||
'create_time' => $now,
|
||||
];
|
||||
try {
|
||||
$id = (int) Db::name('qywx_promotion_link')->insertGetId($data);
|
||||
} catch (\Throwable $e) {
|
||||
try {
|
||||
$api->deleteLink($remoteLinkId);
|
||||
} catch (\Throwable) {
|
||||
// 远端补偿失败时保留原始异常,管理员可通过“同步企业微信”找回链接。
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
return ['id' => $id, 'remote_link_id' => $remoteLinkId, 'mode' => 'official'];
|
||||
}
|
||||
|
||||
/** 验证 CorpID、应用 Secret、可信 IP 与获客助手接口权限。 */
|
||||
public static function checkApiPermission(): array
|
||||
{
|
||||
return (new QywxCustomerAcquisitionApiService())->checkPermission();
|
||||
}
|
||||
|
||||
/**
|
||||
* 将企业微信端获客链接同步进指定分流方案。
|
||||
* 非全量权限账号仅导入 range.user_list 与其可见成员有交集的链接,未知部门映射时严格隐藏。
|
||||
*/
|
||||
public static function syncRemoteLinks(int $poolId, int $adminId, array $adminInfo): array
|
||||
{
|
||||
$pool = self::assertScopedRow('qywx_promotion_pool', $poolId, $adminId, $adminInfo);
|
||||
$legacyCount = (int) Db::name('qywx_promotion_link')
|
||||
->where('pool_id', $poolId)
|
||||
->whereNull('delete_time')
|
||||
->whereRaw("(remote_link_id IS NULL OR remote_link_id = '')")
|
||||
->count();
|
||||
$visibleAdminIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
$visibleUserIds = null;
|
||||
if ($visibleAdminIds !== null) {
|
||||
$visibleUserIds = array_fill_keys(array_column(self::memberOptions($adminId, $adminInfo), 'userid'), true);
|
||||
}
|
||||
|
||||
$api = new QywxCustomerAcquisitionApiService();
|
||||
$cursor = '';
|
||||
$seen = 0;
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
$errors = [];
|
||||
|
||||
do {
|
||||
$page = $api->listLinks($cursor, 100);
|
||||
foreach ($page['link_id_list'] as $remoteLinkId) {
|
||||
if ($seen >= 500) {
|
||||
break 2;
|
||||
}
|
||||
$seen++;
|
||||
try {
|
||||
$remote = self::normaliseRemoteLink($api->getLink($remoteLinkId), $remoteLinkId);
|
||||
if (!self::canSeeRemoteLink($remote, $visibleUserIds)) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
$result = self::upsertRemoteLink($remote, $pool, $adminId, $adminInfo);
|
||||
$result === 'created' ? $created++ : $updated++;
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
if (count($errors) < 5) {
|
||||
$errors[] = $remoteLinkId . ':' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
$cursor = (string) ($page['next_cursor'] ?? '');
|
||||
} while ($cursor !== '');
|
||||
|
||||
return [
|
||||
'scanned' => $seen,
|
||||
'created' => $created,
|
||||
'updated' => $updated,
|
||||
'skipped' => $skipped,
|
||||
'failed' => $failed,
|
||||
'legacy_count' => $legacyCount,
|
||||
'empty_reason' => $seen === 0
|
||||
? '当前获客助手可调用应用没有通过 API 创建的官方获客链接;历史手工链接及其他应用创建的链接不会出现在该应用的同步列表中。'
|
||||
: '',
|
||||
'suggestion' => $seen === 0
|
||||
? '请点击“创建官方获客链接”通过当前应用创建。历史手工链接仍可参与本地分流,但无法同步官方 link_id 和官方获客数据。'
|
||||
: '',
|
||||
'truncated' => $cursor !== '',
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
/** 获取并刷新单条企业微信官方详情。 */
|
||||
public static function remoteLinkDetail(int $id, int $adminId, array $adminInfo): array
|
||||
{
|
||||
$row = self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
|
||||
$remoteLinkId = trim((string) ($row['remote_link_id'] ?? ''));
|
||||
if ($remoteLinkId === '') {
|
||||
throw new RuntimeException('这是历史手工链接,没有企业微信 link_id');
|
||||
}
|
||||
$api = new QywxCustomerAcquisitionApiService();
|
||||
$remote = self::normaliseRemoteLink($api->getLink($remoteLinkId), $remoteLinkId);
|
||||
$visibleUserIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo) === null
|
||||
? null
|
||||
: array_fill_keys(array_column(self::memberOptions($adminId, $adminInfo), 'userid'), true);
|
||||
if (!self::canSeeRemoteLink($remote, $visibleUserIds)) {
|
||||
throw new RuntimeException('该获客链接已不在当前角色或部门的数据范围内');
|
||||
}
|
||||
Db::name('qywx_promotion_link')->where('id', $id)->update(self::remoteColumns($remote, time()));
|
||||
|
||||
return self::remotePublicPayload($remote);
|
||||
}
|
||||
|
||||
/** 永久删除企业微信端获客链接,本地保留审计记录并停止分流。 */
|
||||
public static function deleteRemoteLink(int $id, int $adminId, array $adminInfo): void
|
||||
{
|
||||
$row = self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
|
||||
$remoteLinkId = trim((string) ($row['remote_link_id'] ?? ''));
|
||||
if ($remoteLinkId === '') {
|
||||
throw new RuntimeException('历史手工链接只能从本地移除');
|
||||
}
|
||||
(new QywxCustomerAcquisitionApiService())->deleteLink($remoteLinkId);
|
||||
Db::name('qywx_promotion_link')->where('id', $id)->update([
|
||||
'status' => 0,
|
||||
'remote_status' => 2,
|
||||
'last_sync_time' => time(),
|
||||
'sync_error' => '',
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function toggleLink(int $id, int $status, int $adminId, array $adminInfo): void
|
||||
{
|
||||
$row = self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
|
||||
if ($status === 1 && (int) ($row['remote_status'] ?? 0) === 2) {
|
||||
throw new RuntimeException('企业微信端已永久删除该链接,不能重新上线');
|
||||
}
|
||||
Db::name('qywx_promotion_link')->where('id', $id)->update([
|
||||
'status' => $status === 1 ? 1 : 0,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function deleteLink(int $id, int $adminId, array $adminInfo): void
|
||||
{
|
||||
self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
|
||||
Db::name('qywx_promotion_link')->where('id', $id)->update([
|
||||
'delete_time' => time(),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return list<array{id:int,name:string,userid:string,dept_ids:list<int>,dept_names:list<string>}> */
|
||||
private static function memberOptions(int $adminId, array $adminInfo): array
|
||||
{
|
||||
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
if ($visibleIds === []) {
|
||||
return [];
|
||||
}
|
||||
$query = Db::name('admin')->alias('a')
|
||||
->whereNull('a.delete_time')
|
||||
->where('a.work_wechat_userid', '<>', '');
|
||||
if ($visibleIds !== null) {
|
||||
$query->whereIn('a.id', $visibleIds);
|
||||
}
|
||||
$admins = $query->field('a.id,a.name,a.work_wechat_userid')->order('a.id', 'asc')->select()->toArray();
|
||||
if ($admins === []) {
|
||||
return [];
|
||||
}
|
||||
$adminIds = array_map('intval', array_column($admins, 'id'));
|
||||
$deptRows = Db::name('admin_dept')->alias('ad')
|
||||
->leftJoin('dept d', 'd.id = ad.dept_id')
|
||||
->whereIn('ad.admin_id', $adminIds)
|
||||
->field('ad.admin_id,ad.dept_id,d.name as dept_name')
|
||||
->order('ad.dept_id', 'asc')->select()->toArray();
|
||||
$departments = [];
|
||||
foreach ($deptRows as $row) {
|
||||
$aid = (int) ($row['admin_id'] ?? 0);
|
||||
$departments[$aid]['ids'][] = (int) ($row['dept_id'] ?? 0);
|
||||
if (trim((string) ($row['dept_name'] ?? '')) !== '') {
|
||||
$departments[$aid]['names'][] = (string) $row['dept_name'];
|
||||
}
|
||||
}
|
||||
|
||||
$result = [];
|
||||
$seenUserIds = [];
|
||||
foreach ($admins as $admin) {
|
||||
$userId = trim((string) ($admin['work_wechat_userid'] ?? ''));
|
||||
if ($userId === '' || isset($seenUserIds[$userId])) {
|
||||
continue;
|
||||
}
|
||||
$seenUserIds[$userId] = true;
|
||||
$aid = (int) $admin['id'];
|
||||
$result[] = [
|
||||
'id' => $aid,
|
||||
'name' => (string) ($admin['name'] ?? $userId),
|
||||
'userid' => $userId,
|
||||
'dept_ids' => array_values(array_unique(array_filter($departments[$aid]['ids'] ?? []))),
|
||||
'dept_names' => array_values(array_unique($departments[$aid]['names'] ?? [])),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private static function resolveMemberUserIds(array $adminIds, int $adminId, array $adminInfo): array
|
||||
{
|
||||
$requested = array_values(array_unique(array_filter(array_map('intval', $adminIds))));
|
||||
if ($requested === []) {
|
||||
throw new RuntimeException('请至少选择一名当前角色或部门范围内的获客成员');
|
||||
}
|
||||
$available = [];
|
||||
foreach (self::memberOptions($adminId, $adminInfo) as $member) {
|
||||
$available[$member['id']] = $member['userid'];
|
||||
}
|
||||
$userIds = [];
|
||||
foreach ($requested as $requestedId) {
|
||||
if (!isset($available[$requestedId])) {
|
||||
throw new RuntimeException('选择的获客成员超出当前角色或部门的数据范围,或尚未绑定企业微信 userid');
|
||||
}
|
||||
$userIds[] = $available[$requestedId];
|
||||
}
|
||||
if (count($userIds) > 500) {
|
||||
throw new RuntimeException('单个获客链接最多配置 500 名成员');
|
||||
}
|
||||
|
||||
return array_values(array_unique($userIds));
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private static function normaliseRemoteLink(array $response, string $fallbackId = ''): array
|
||||
{
|
||||
$link = isset($response['link']) && is_array($response['link']) ? $response['link'] : $response;
|
||||
$linkId = trim((string) ($link['link_id'] ?? $response['link_id'] ?? $fallbackId));
|
||||
$url = trim((string) ($link['url'] ?? $link['link_url'] ?? $response['url'] ?? ''));
|
||||
if ($linkId === '') {
|
||||
throw new RuntimeException('企业微信获客链接详情缺少 link_id');
|
||||
}
|
||||
if (!QywxCustomerAcquisitionLinkService::isAllowed($url)) {
|
||||
throw new RuntimeException('企业微信获客链接详情未返回有效的 https://work.weixin.qq.com/ca/... 地址');
|
||||
}
|
||||
$range = isset($link['range']) && is_array($link['range']) ? $link['range'] : [];
|
||||
|
||||
return [
|
||||
'link_id' => $linkId,
|
||||
'link_name' => trim((string) ($link['link_name'] ?? $link['name'] ?? $linkId)),
|
||||
'url' => $url,
|
||||
'create_time' => max(0, (int) ($link['create_time'] ?? 0)),
|
||||
'range_userids' => self::normaliseScalarList($range['user_list'] ?? []),
|
||||
'range_department_ids' => self::normaliseScalarList($range['department_list'] ?? []),
|
||||
'skip_verify' => !empty($link['skip_verify']),
|
||||
'priority_option' => isset($link['priority_option']) && is_array($link['priority_option']) ? $link['priority_option'] : [],
|
||||
'snapshot' => $link,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private static function remoteColumns(array $remote, int $now): array
|
||||
{
|
||||
return [
|
||||
'name' => mb_substr((string) ($remote['link_name'] ?? ''), 0, 80),
|
||||
'wecom_url' => (string) ($remote['url'] ?? ''),
|
||||
'remote_link_id' => (string) ($remote['link_id'] ?? ''),
|
||||
'remote_status' => 1,
|
||||
'remote_create_time' => (int) ($remote['create_time'] ?? 0),
|
||||
'range_user_json' => self::encodeJson($remote['range_userids'] ?? []),
|
||||
'range_department_json' => self::encodeJson($remote['range_department_ids'] ?? []),
|
||||
'skip_verify' => !empty($remote['skip_verify']) ? 1 : 0,
|
||||
'priority_option_json' => self::encodeJson($remote['priority_option'] ?? []),
|
||||
'remote_snapshot' => self::encodeJson($remote['snapshot'] ?? []),
|
||||
'last_sync_time' => $now,
|
||||
'sync_error' => '',
|
||||
'update_time' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
private static function upsertRemoteLink(array $remote, array $pool, int $adminId, array $adminInfo): string
|
||||
{
|
||||
$remoteLinkId = (string) $remote['link_id'];
|
||||
$now = time();
|
||||
$existing = Db::name('qywx_promotion_link')->where('remote_link_id', $remoteLinkId)->find();
|
||||
$remoteData = self::remoteColumns($remote, $now);
|
||||
if ($existing) {
|
||||
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
if ($visibleIds !== null && !in_array((int) ($existing['owner_admin_id'] ?? 0), $visibleIds, true)) {
|
||||
throw new RuntimeException('该链接已归属其他数据范围');
|
||||
}
|
||||
$remoteData['delete_time'] = null;
|
||||
Db::name('qywx_promotion_link')->where('id', (int) $existing['id'])->update($remoteData);
|
||||
|
||||
return 'updated';
|
||||
}
|
||||
|
||||
Db::name('qywx_promotion_link')->insert($remoteData + [
|
||||
'pool_id' => (int) $pool['id'],
|
||||
'account_id' => 0,
|
||||
'group_name' => '企业微信同步',
|
||||
'weight' => 1,
|
||||
'status' => 1,
|
||||
'daily_limit' => 0,
|
||||
'today_count' => 0,
|
||||
'today_date' => null,
|
||||
'active_start' => 0,
|
||||
'active_end' => 0,
|
||||
'click_count' => 0,
|
||||
'last_click_time' => 0,
|
||||
'owner_admin_id' => (int) ($pool['owner_admin_id'] ?? 0) ?: $adminId,
|
||||
'dept_id' => (int) ($pool['dept_id'] ?? 0) ?: self::primaryDeptId($adminId),
|
||||
'remark' => '',
|
||||
'create_time' => $now,
|
||||
'delete_time' => null,
|
||||
]);
|
||||
|
||||
return 'created';
|
||||
}
|
||||
|
||||
private static function canSeeRemoteLink(array $remote, ?array $visibleUserIds): bool
|
||||
{
|
||||
if ($visibleUserIds === null) {
|
||||
return true;
|
||||
}
|
||||
foreach ((array) ($remote['range_userids'] ?? []) as $userId) {
|
||||
if (isset($visibleUserIds[(string) $userId])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private static function remotePublicPayload(array $remote): array
|
||||
{
|
||||
return [
|
||||
'link_id' => (string) ($remote['link_id'] ?? ''),
|
||||
'link_name' => (string) ($remote['link_name'] ?? ''),
|
||||
'url' => (string) ($remote['url'] ?? ''),
|
||||
'create_time' => (int) ($remote['create_time'] ?? 0),
|
||||
'range_userids' => (array) ($remote['range_userids'] ?? []),
|
||||
'range_department_ids' => (array) ($remote['range_department_ids'] ?? []),
|
||||
'skip_verify' => !empty($remote['skip_verify']),
|
||||
'priority_option' => (array) ($remote['priority_option'] ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
private static function extractRemoteLinkId(array $response): string
|
||||
{
|
||||
if (isset($response['link']) && is_array($response['link'])) {
|
||||
return trim((string) ($response['link']['link_id'] ?? ''));
|
||||
}
|
||||
|
||||
return trim((string) ($response['link_id'] ?? ''));
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private static function normaliseScalarList(mixed $value): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter(array_map(
|
||||
static fn (mixed $item): string => trim((string) $item),
|
||||
$value
|
||||
), static fn (string $item): bool => $item !== '')));
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private static function decodeStringList(mixed $value): array
|
||||
{
|
||||
if (!is_string($value) || $value === '') {
|
||||
return [];
|
||||
}
|
||||
$decoded = json_decode($value, true);
|
||||
|
||||
return self::normaliseScalarList(is_array($decoded) ? $decoded : []);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private static function decodeObject(mixed $value): array
|
||||
{
|
||||
if (!is_string($value) || $value === '') {
|
||||
return [];
|
||||
}
|
||||
$decoded = json_decode($value, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
private static function encodeJson(mixed $value): string
|
||||
{
|
||||
$encoded = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return $encoded === false ? '[]' : $encoded;
|
||||
}
|
||||
|
||||
private static function assertScopedRow(string $table, int $id, int $adminId, array $adminInfo): array
|
||||
{
|
||||
if ($id <= 0) {
|
||||
throw new RuntimeException('数据不存在');
|
||||
}
|
||||
$query = Db::name($table)->where('id', $id)->whereNull('delete_time');
|
||||
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
if ($visibleIds !== null) {
|
||||
if ($visibleIds === []) {
|
||||
throw new RuntimeException('无权访问该数据');
|
||||
}
|
||||
$query->whereIn('owner_admin_id', $visibleIds);
|
||||
}
|
||||
$row = $query->find();
|
||||
if (!$row) {
|
||||
throw new RuntimeException('数据不存在或超出当前权限范围');
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
private static function applyOwnerScope($query, string $alias, ?array $visibleIds): void
|
||||
{
|
||||
if ($visibleIds === null) {
|
||||
return;
|
||||
}
|
||||
if ($visibleIds === []) {
|
||||
$query->whereRaw('1 = 0');
|
||||
return;
|
||||
}
|
||||
$query->whereIn($alias . '.owner_admin_id', $visibleIds);
|
||||
}
|
||||
|
||||
private static function primaryDeptId(int $adminId): int
|
||||
{
|
||||
return (int) (Db::name('admin_dept')->where('admin_id', $adminId)->order('dept_id', 'asc')->value('dept_id') ?? 0);
|
||||
}
|
||||
|
||||
private static function parseTime(mixed $value): int
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return 0;
|
||||
}
|
||||
if (is_numeric($value)) {
|
||||
return max(0, (int) $value);
|
||||
}
|
||||
$time = strtotime((string) $value);
|
||||
|
||||
return $time === false ? 0 : $time;
|
||||
}
|
||||
|
||||
private static function mask(string $value): string
|
||||
{
|
||||
$length = strlen($value);
|
||||
if ($length <= 8) {
|
||||
return $value === '' ? '' : str_repeat('*', $length);
|
||||
}
|
||||
|
||||
return substr($value, 0, 4) . str_repeat('*', max(4, $length - 8)) . substr($value, -4);
|
||||
}
|
||||
|
||||
private static function publicDomain(string $requestDomain): string
|
||||
{
|
||||
$configuredDomain = trim((string) config('app.app_host', ''));
|
||||
foreach ([$configuredDomain, trim($requestDomain)] as $candidate) {
|
||||
if ($candidate === '') {
|
||||
continue;
|
||||
}
|
||||
$parts = parse_url($candidate);
|
||||
if (!is_array($parts)) {
|
||||
continue;
|
||||
}
|
||||
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
|
||||
$host = (string) ($parts['host'] ?? '');
|
||||
if (!in_array($scheme, ['http', 'https'], true) || $host === '') {
|
||||
continue;
|
||||
}
|
||||
$port = isset($parts['port']) ? ':' . (int) $parts['port'] : '';
|
||||
|
||||
return $scheme . '://' . $host . $port;
|
||||
}
|
||||
|
||||
throw new RuntimeException('未配置有效的应用访问域名');
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部应用直接复用项目现有 work_wechat 配置,不经过第三方服务商授权。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function internalApplicationStatus(string $domain): array
|
||||
{
|
||||
$corpId = trim((string) config('qywx_customer_acquisition.corp_id', ''));
|
||||
$agentId = trim((string) env('WECHAT_WORK_AGENT_ID', ''));
|
||||
if ($agentId === '') {
|
||||
$agentId = trim((string) env('work_wechat.agent_id', ''));
|
||||
}
|
||||
$apiStatus = QywxCustomerAcquisitionApiService::configurationStatus();
|
||||
$callbackTokenConfigured = trim((string) config('pay.wechat_work.contact_callback_token', '')) !== '';
|
||||
$callbackAesConfigured = trim((string) config('pay.wechat_work.contact_callback_aes_key', '')) !== '';
|
||||
|
||||
return [
|
||||
'mode' => 'internal',
|
||||
'configured' => $apiStatus['configured'],
|
||||
'ready' => $apiStatus['configured'],
|
||||
'missing' => $apiStatus['missing'],
|
||||
'corp_id_masked' => self::mask($corpId),
|
||||
'agent_id' => $agentId,
|
||||
'secret_configured' => trim((string) config('qywx_customer_acquisition.secret', '')) !== '',
|
||||
'callback_ready' => $callbackTokenConfigured && $callbackAesConfigured,
|
||||
'callback_url' => rtrim($domain, '/') . '/api/qywx/external-contact/notify',
|
||||
'official_doc' => 'https://developer.work.weixin.qq.com/document/path/97297',
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user