This commit is contained in:
Your Name
2026-08-05 11:08:02 +08:00
parent 57a7c415a1
commit c2b7018a22
429 changed files with 13316 additions and 350 deletions
@@ -0,0 +1,520 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\firstvisit;
use app\adminapi\logic\dept\DeptLogic;
use app\adminapi\logic\stats\ConversionLogic;
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 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((string) ($params['time_type'] ?? 'today'));
$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));
$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);
}
if ($selectedAssistantId > 0) {
$assistantValid = self::isActiveAssistant($selectedAssistantId)
&& ($effectiveAdminIds === null || in_array($selectedAssistantId, $effectiveAdminIds, true));
$effectiveAdminIds = $assistantValid ? [$selectedAssistantId] : [];
}
$costAllocationAdminIds = self::costAllocationAdminIds(
$effectiveAdminIds,
$scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0
);
$conversionParams = [
'dimension' => 'dept',
'time_type' => 'custom',
'start_date' => $startDate,
'end_date' => $endDate,
'include_filters' => 0,
'include_members' => 0,
'page_no' => 1,
'page_size' => 100,
];
if ($selectedDeptId > 0 && $deptSelectionValid) {
$conversionParams['dept_id'] = $selectedDeptId;
}
$conversion = ConversionLogic::overview(
$conversionParams,
$adminId,
$adminInfo,
$effectiveAdminIds,
$costAllocationAdminIds
);
$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);
$openDirect = self::loadOpenCountByDept(
$startDate,
$endDate,
$effectiveAdminIds,
array_fill_keys(array_keys($rowDeptIdSet), true)
);
self::applyOpenCounts($rows, $openDirect);
$summary = is_array($conversion['summary'] ?? null) ? $conversion['summary'] : [];
$summary['total_open_count'] = array_sum($openDirect);
$summary['open_receive_rate'] = self::percent(
(int) ($summary['completed_order_count'] ?? 0),
(int) $summary['total_open_count']
);
$rankingRows = self::rankingRows($rows);
// 目前只维护了部门月度目标;本人范围或筛选单个员工时不能拿整个部门目标冒充个人目标。
$targetDeptIds = ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0)
? []
: self::resolveTargetDeptIds($allowedDeptSet, $selectedDeptIds, $selectedDeptId);
$target = self::buildTargetProgress((int) date('Y'), $effectiveAdminIds, $targetDeptIds);
$selectedDeptName = $selectedDeptId > 0
? (string) (Db::name('dept')->where('id', $selectedDeptId)->whereNull('delete_time')->value('name') ?? '')
: '';
$selectedAssistantName = $selectedAssistantId > 0
? (string) (Admin::where('id', $selectedAssistantId)->whereNull('delete_time')->value('name') ?? '')
: '';
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),
'selected_dept_name' => $selectedDeptName,
'selected_assistant_name' => $selectedAssistantName,
'open_count_source' => '个人业绩录入',
],
'filters' => [
'departments' => DeptLogic::getAllDataScoped($adminId, $adminInfo),
'assistants' => self::assistantOptions($baseVisibleAdminIds, $selectedDeptIds, $selectedDeptId),
],
'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(string $timeType): array
{
$today = date('Y-m-d');
$timeType = in_array($timeType, ['today', 'week', 'month', 'quarter', 'year'], true)
? $timeType
: 'today';
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;
}
$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 @return array<int,int> */
private static function loadOpenCountByDept(string $startDate, string $endDate, ?array $effectiveAdminIds, array $rowDeptSet): array
{
if ($effectiveAdminIds === [] || $rowDeptSet === []) {
return [];
}
$query = PersonalYeji::whereBetween('yeji_date', [$startDate, $endDate]);
if ($effectiveAdminIds !== null) {
$query->whereIn('creator_id', $effectiveAdminIds);
}
$rows = $query
->fieldRaw('creator_id, SUM(total_open_count) AS open_count')
->group('creator_id')
->select()
->toArray();
if ($rows === []) {
return [];
}
$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'];
}
$direct = [];
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) {
$direct[$targetDeptId] = ($direct[$targetDeptId] ?? 0) + (int) ($row['open_count'] ?? 0);
}
}
return $direct;
}
/** @param array<int,array<string,mixed>> $rows @param array<int,int> $direct */
private static function applyOpenCounts(array &$rows, array $direct): int
{
$sum = 0;
foreach ($rows as &$row) {
$children = is_array($row['children'] ?? null) ? $row['children'] : [];
$childTotal = self::applyOpenCounts($children, $direct);
if ($children !== []) {
$row['children'] = $children;
}
$count = (int) ($direct[(int) ($row['id'] ?? 0)] ?? 0) + $childTotal;
$row['total_open_count'] = $count;
$row['open_receive_rate'] = self::percent((int) ($row['completed_order_count'] ?? 0), $count);
$sum += (int) ($direct[(int) ($row['id'] ?? 0)] ?? 0) + $childTotal;
}
unset($row);
return $sum;
}
/** @param array<int,array<string,mixed>> $rows @return array<int,array<string,mixed>> */
private static function rankingRows(array $rows): array
{
if (count($rows) === 1 && is_array($rows[0]['children'] ?? null) && $rows[0]['children'] !== []) {
return $rows[0]['children'];
}
return $rows;
}
/** @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 fn (array $row): bool => (int) ($row['id'] ?? 0) > 0));
usort($rows, static function (array $left, array $right) use ($metric): int {
return (float) ($right[$metric] ?? 0) <=> (float) ($left[$metric] ?? 0);
});
return array_map(static fn (array $row): array => [
'id' => (int) ($row['id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'value' => round((float) ($row[$metric] ?? 0), 2),
], array_slice($rows, 0, 6));
}
/** @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')
->leftJoin('tcm_prescription rx', 'rx.id = po.prescription_id')
->whereNull('po.delete_time')
->where('po.prescription_audit_status', 1)
->where('po.payment_slip_audit_status', 1)
->where('po.create_time', 'between', [
strtotime($year . '-01-01 00:00:00'),
strtotime($year . '-12-31 23:59:59'),
]);
if ($effectiveAdminIds !== null) {
$actualQuery->whereIn('rx.assistant_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,468 @@
<?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((string) ($params['time_type'] ?? 'month'));
$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
);
$summary = self::buildSummary($rows);
$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),
'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' => '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> */
private static function resolveRange(string $type): array
{
$today = date('Y-m-d');
if ($type === 'today') {
return ['type' => 'today', 'label' => '今日', 'start' => $today, 'end' => $today];
}
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): 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 [
'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,
];
}
/** @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::applyPrescriptionOrderNotCancelledForPerformanceQuery($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,628 @@
<?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;
/**
* 一诊「挂号统计」。
*
* 统计口径:
* - 挂号: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
);
$orderDaily = self::loadOrderDaily(
min($range['compare_start'], $range['start']),
$range['end'],
$assistantIds
);
$members = self::buildMemberRows(
$assistants,
$assistantIds,
$assignment,
$appointmentDaily,
$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),
'appointment_rule' => '预约日期在统计区间,状态为已预约、已完成或已过号,排除已取消',
'performance_rule' => '按订单创建时间和创建人统计,排除已取消、拒收和退款',
],
'filters' => [
'departments' => $departmentTree,
'assistants' => $assistants,
],
'summary' => $summary,
'employee_rows' => $groups,
'rankings' => [
'performance' => self::rankMembers($members, 'order_amount', 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 === '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,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 $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');
$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',
'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['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,
'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 (['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['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
{
$appointmentCount = 0;
$compareAppointmentCount = 0;
$orderCount = 0;
$orderAmount = 0.0;
foreach ($members as $member) {
$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 [
'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) {
$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' => $field === 'order_amount' ? (int) ($row['order_count'] ?? 0) : (int) ($row['appointment_count'] ?? 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,336 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\firstvisit;
use app\common\service\DataScope\DataScopeService;
use app\common\service\qywx\QywxPromotionOpenWorkService;
use RuntimeException;
use think\facade\Db;
/** 一诊 / 企业微信推广助手管理逻辑。 */
class WecomPromotionLogic
{
public static function overview(int $adminId, array $adminInfo, string $domain): array
{
$visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
$accountsQuery = Db::name('qywx_promotion_account')->alias('a')
->leftJoin('admin u', 'u.id = a.owner_admin_id')
->leftJoin('dept d', 'd.id = a.dept_id')
->whereNull('a.delete_time');
$accounts = $accountsQuery
->field('a.id,a.corp_id,a.corp_name,a.agent_id,a.auth_status,a.owner_admin_id,a.dept_id,a.authorized_at,a.last_refresh_at,a.create_time,u.name as owner_name,d.name as dept_name')
->order('a.auth_status', 'desc')
->order('a.id', 'desc')
->select()->toArray();
foreach ($accounts as &$account) {
$account['corp_id_masked'] = self::mask((string) ($account['corp_id'] ?? ''));
unset($account['corp_id']);
}
unset($account);
$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.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')
->leftJoin('qywx_promotion_account a', 'a.id = l.account_id AND a.delete_time IS NULL')
->whereNull('l.delete_time')
->whereIn('l.pool_id', $poolIds)
->field('l.id,l.pool_id,l.account_id,l.name,l.group_name,l.wecom_url,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,a.corp_name,a.auth_status')
->order('l.status', 'desc')
->order('l.weight', 'desc')
->order('l.id', 'desc')
->select()->toArray();
}
$domain = rtrim($domain, '/');
foreach ($pools as &$pool) {
$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="' . $scriptUrl . '" defer></script>';
$pool['trigger_code'] = '<a href="#" data-wecom-promotion="' . $key . '">添加企业微信</a>';
}
unset($pool);
$today = date('Y-m-d');
$todayClicks = 0;
$onlineLinks = 0;
foreach ($links as $link) {
if ((int) ($link['status'] ?? 0) === 1) {
$onlineLinks++;
}
if ((string) ($link['today_date'] ?? '') === $today) {
$todayClicks += (int) ($link['today_count'] ?? 0);
}
}
$config = QywxPromotionOpenWorkService::configurationStatus();
$config['provider_callback_url'] = $domain . '/api/qywx-promotion/provider/callback';
$config['auth_callback_url'] = QywxPromotionOpenWorkService::configuredRedirectUri(
$domain . '/api/qywx-promotion/auth/callback'
);
return [
'meta' => [
'scope_label' => DataScopeService::scopeLabel(DataScopeService::getEffectiveScope($adminInfo)),
'can_authorize' => self::canAuthorize($adminId, $adminInfo),
'generated_at' => date('Y-m-d H:i:s'),
],
'config' => $config,
'summary' => [
'authorized_accounts' => count(array_filter($accounts, static fn (array $row): bool => (int) ($row['auth_status'] ?? 0) === 1)),
'pool_count' => count($pools),
'online_links' => $onlineLinks,
'today_clicks' => $todayClicks,
],
'accounts' => $accounts,
'pools' => $pools,
'links' => $links,
'allowed_link_hosts' => array_values((array) config('qywx_promotion.allowed_link_hosts', [])),
];
}
public static function authorizationUrl(int $adminId, array $adminInfo, string $domain): array
{
if (!self::canAuthorize($adminId, $adminInfo)) {
throw new RuntimeException('只有系统管理员可以发起企业微信应用授权');
}
$redirectUri = rtrim($domain, '/') . '/api/qywx-promotion/auth/callback';
return ['url' => QywxPromotionOpenWorkService::authorizationUrl($adminId, $redirectUri)];
}
public static function verifyAccount(int $id, int $adminId, array $adminInfo): array
{
if (!self::canAuthorize($adminId, $adminInfo)) {
throw new RuntimeException('只有系统管理员可以验证企业微信授权凭证');
}
self::assertAuthorizedAccount($id, false);
return QywxPromotionOpenWorkService::verifyAccount($id);
}
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 (!QywxPromotionOpenWorkService::isAllowedPromotionUrl($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 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);
$name = trim((string) ($params['name'] ?? ''));
if ($name === '' || mb_strlen($name) > 80) {
throw new RuntimeException('请输入 1-80 个字符的推广链接名称');
}
$url = trim((string) ($params['wecom_url'] ?? ''));
if (!QywxPromotionOpenWorkService::isAllowedPromotionUrl($url)) {
throw new RuntimeException('推广链接必须是已允许的 HTTPS 企业微信链接');
}
$accountId = max(0, (int) ($params['account_id'] ?? 0));
if ($accountId > 0) {
self::assertAuthorizedAccount($accountId, true);
}
$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' => $accountId,
'name' => $name,
'group_name' => mb_substr(trim((string) ($params['group_name'] ?? '默认分组')), 0, 60),
'wecom_url' => $url,
'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 ($id > 0) {
self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
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,
];
$id = (int) Db::name('qywx_promotion_link')->insertGetId($data);
}
return ['id' => $id];
}
public static function toggleLink(int $id, int $status, int $adminId, array $adminInfo): void
{
self::assertScopedRow('qywx_promotion_link', $id, $adminId, $adminInfo);
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(),
]);
}
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 assertAuthorizedAccount(int $id, bool $requireActive): array
{
if ($id <= 0) {
throw new RuntimeException('授权企业不存在');
}
$query = Db::name('qywx_promotion_account')->where('id', $id)->whereNull('delete_time');
if ($requireActive) {
$query->where('auth_status', 1);
}
$row = $query->find();
if (!$row) {
throw new RuntimeException($requireActive ? '授权企业无效或已取消授权' : '授权企业不存在');
}
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 canAuthorize(int $adminId, array $adminInfo): bool
{
if ((int) ($adminInfo['root'] ?? 0) === 1) {
return true;
}
return Db::name('admin_role')->alias('ar')
->join('system_role r', 'r.id = ar.role_id AND r.delete_time IS NULL')
->where('ar.admin_id', $adminId)
->where('r.name', '管理员')
->count() > 0;
}
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);
}
}