1
This commit is contained in:
@@ -0,0 +1,137 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Multi-client SaaS platform (基于 likeadmin 脚手架改造) centered on 中医在线问诊 — video consultations (TRTC), prescriptions, medicine library, 企微 (QYWX) integration, and payments. Single repo contains a PHP backend plus four independent frontend clients.
|
||||||
|
|
||||||
|
## Top-Level Layout
|
||||||
|
|
||||||
|
| Dir | Stack | Purpose |
|
||||||
|
|-----|-------|---------|
|
||||||
|
| `server/` | ThinkPHP 8 + PHP 8.0+ | REST API, the single source of truth |
|
||||||
|
| `admin/` | Vue 3 + Vite + Element Plus + Tailwind | 管理后台 (desktop) |
|
||||||
|
| `pc/` | Nuxt 3 + Element Plus | 公开 PC 站 (SSR/SSG) |
|
||||||
|
| `uniapp/` | uni-app (Vue 3, Vite) | H5 / 小程序 / App 多端客户端 |
|
||||||
|
| `wx/` | uni-app 微信小程序 | 独立 TRTC 微信小程序壳 |
|
||||||
|
| `TUICallKit*/` | Tencent TRTC UIKit | Vendor SDK source used by clients |
|
||||||
|
| `docker/` | docker-compose (nginx + php-fpm + mysql) | Local dev stack |
|
||||||
|
| `doc/`, `ps/` | Supporting docs / psd | Design & reference material |
|
||||||
|
|
||||||
|
Each frontend is deployed to `server/public/<client>/` (see `server/route/app.php` fallback routes). Don’t rely on a root-level workspace — each client has its own `package.json` and must be built separately.
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
|
### Backend (`server/`)
|
||||||
|
```
|
||||||
|
composer install # install deps
|
||||||
|
php think run --host 0.0.0.0 --port 8080 # dev server (if PHP CLI)
|
||||||
|
php think <command> # run CLI commands (see app/command/)
|
||||||
|
php think crontab # trigger cron tasks manually
|
||||||
|
```
|
||||||
|
- Docker alternative: `cd docker && docker-compose up -d` (exposes nginx on :80, mysql on :3306).
|
||||||
|
- SQL migrations live in `server/sql/<version>/*.sql` (versioned by date). Apply manually in order; there is no automatic migration runner. Current version directory: `server/sql/1.9.20260420/`.
|
||||||
|
|
||||||
|
### Admin (`admin/`)
|
||||||
|
```
|
||||||
|
npm install
|
||||||
|
npm run dev # Vite dev server
|
||||||
|
npm run build # production build + node scripts/release.mjs
|
||||||
|
npm run type-check # vue-tsc --noEmit
|
||||||
|
npm run lint # ESLint with --fix
|
||||||
|
npm run clean # node scripts/clean.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
### PC (`pc/`)
|
||||||
|
```
|
||||||
|
npm install
|
||||||
|
npm run dev # Nuxt dev server on :3000
|
||||||
|
npm run build # nuxt generate + scripts/build.mjs (static export)
|
||||||
|
npm run build:ssr # Nuxt SSR build
|
||||||
|
```
|
||||||
|
|
||||||
|
### uniapp (`uniapp/`)
|
||||||
|
```
|
||||||
|
npm install
|
||||||
|
npm run dev:h5 # H5
|
||||||
|
npm run dev:mp-weixin # 微信小程序
|
||||||
|
npm run dev:app # App
|
||||||
|
npm run build:h5 # build + scripts/release.mjs
|
||||||
|
# other targets: mp-alipay, mp-baidu, mp-qq, mp-toutiao, quickapp-webview...
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running a single test
|
||||||
|
There is no unit-test harness checked in (no Jest/Vitest/PHPUnit config). When adding tests, wire the framework first; don’t assume one exists.
|
||||||
|
|
||||||
|
## Backend Architecture (likeadmin conventions)
|
||||||
|
|
||||||
|
`server/app/` is a ThinkPHP multi-app project. Three apps with a shared `common/` layer:
|
||||||
|
|
||||||
|
- `adminapi/` — admin panel API (consumed by `admin/`)
|
||||||
|
- `api/` — public/end-user API (consumed by `pc/`, `uniapp/`, `wx/`)
|
||||||
|
- `index/` — default app fallback
|
||||||
|
- `common/` — shared models, services, enums, exceptions, listeners
|
||||||
|
|
||||||
|
Each app follows the layered pattern:
|
||||||
|
|
||||||
|
```
|
||||||
|
<app>/
|
||||||
|
├── controller/ # HTTP entry; extends BaseAdminController / BaseApiController
|
||||||
|
├── logic/ # Business logic; static methods invoked by controllers
|
||||||
|
├── lists/ # List-query objects (pagination/filter/export encapsulation)
|
||||||
|
├── validate/ # Request validation rules
|
||||||
|
├── service/ # App-scoped services
|
||||||
|
├── http/ # Middleware, route groups
|
||||||
|
└── config/ # App-specific config
|
||||||
|
```
|
||||||
|
|
||||||
|
Key conventions:
|
||||||
|
- **Controllers stay thin.** They pull `$this->request->get()/post()`, delegate to a `Logic` class, and wrap the result with `$this->data(...)` / `$this->success(...)` / `$this->fail(...)` from `BaseLikeAdminController` (in `common/controller`).
|
||||||
|
- **Logic returns plain arrays.** Throw `\app\common\exception\*` for failures — the global exception handler (`app/ExceptionHandle.php`) converts them to JSON envelopes.
|
||||||
|
- **Models extend `app\common\model\BaseModel`** which auto-completes image paths via `FileService`.
|
||||||
|
- **Route registration is menu-driven.** Admin routes are auto-resolved from menu entries (see `add_conversion_stats_menu.sql` as an example of wiring a new admin page). Public routes are declared in `app/api/route/` and `server/route/app.php`.
|
||||||
|
- **Payment / third-party callbacks** bypass auth — declared explicitly in `server/route/app.php` (e.g. wechat-notify, alipay-notify, TRTC recording-notify).
|
||||||
|
|
||||||
|
## Admin Architecture (`admin/src/`)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── api/ # One file per domain (stats.ts, order.ts, doctor.ts...)
|
||||||
|
├── views/ # Page components grouped by domain (mirrors api/)
|
||||||
|
├── router/
|
||||||
|
│ ├── index.ts # Dynamic route generation from backend menu
|
||||||
|
│ └── routes.ts # Static/constant routes + LAYOUT wrapper
|
||||||
|
├── stores/modules/ # Pinia stores (user, app, tagsView...)
|
||||||
|
├── components/ # Shared components
|
||||||
|
├── utils/request/ # Axios instance + interceptors
|
||||||
|
├── hooks/ # Composables
|
||||||
|
├── install/ # Global plugin registration (app.use(install))
|
||||||
|
└── permission.ts # Login/permission guard mounted on router
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Views are dynamically loaded** via `import.meta.glob('/src/views/**/*.vue')` in `router/index.ts`. A view path from the backend menu (e.g. `stats/conversion/index`) resolves to `src/views/stats/conversion/index.vue`. When adding a page, the corresponding menu record must be inserted via SQL (pattern: `server/sql/.../add_*_menu.sql`).
|
||||||
|
- **API calls go through `utils/request`** which normalizes the response envelope and handles auth/error toasts. Don’t use axios directly.
|
||||||
|
- **Auto-imports**: Element Plus components, `ref`/`reactive`/router composables are auto-imported via `unplugin-auto-import` — no need to import them explicitly (see `auto-imports.d.ts`).
|
||||||
|
|
||||||
|
## Cross-Cutting Concerns
|
||||||
|
|
||||||
|
- **Versioned SQL migrations.** New schema/menu changes go in `server/sql/<version>/*.sql`. When adding an admin view, pair it with an `add_*_menu.sql` file (see current WIP: `add_conversion_stats_menu.sql` + `admin/src/views/stats/conversion/`).
|
||||||
|
- **TRTC integration.** Video-call logic is split between `TUICallKit/` (vendor), `admin/src/utils/call-*.ts` and `src/utils/im-*.ts`, and the `api/controller/TrtcController`. Cloud recording is triggered server-side and written back via the `/api/trtc/recording-notify` callback.
|
||||||
|
- **企微 (QYWX).** `server/app/adminapi/controller/qywx/` + `admin/src/views/qywx/` + `pc/` pages exchange via `easywechat`. OAuth/bind flow uses `admin/src/utils/wecomOauthPostMessage.ts` and `wecomBindGuard.ts`.
|
||||||
|
- **Multi-cloud storage.** File uploads support Qiniu / Tencent COS / Aliyun OSS — configuration is driven by runtime admin settings, resolved in `FileService`.
|
||||||
|
|
||||||
|
## Trellis Workflow
|
||||||
|
|
||||||
|
This repo is Trellis-managed (see `.trellis/`). New work should go through:
|
||||||
|
1. `/trellis:start` — loads session context
|
||||||
|
2. `/trellis:brainstorm` for complex/vague asks (creates `prd.md`)
|
||||||
|
3. Research → `task.py init-context` → Implement → Check agents
|
||||||
|
4. `/trellis:finish-work` before committing
|
||||||
|
|
||||||
|
Code-spec files under `.trellis/spec/` are injected into sub-agent contexts automatically. Relevant thinking guides live in `.trellis/spec/guides/`.
|
||||||
|
|
||||||
|
## Language & Docs
|
||||||
|
|
||||||
|
All user-facing commentary, commit messages, and code comments in this repo use 简体中文 when addressing the product domain; identifiers, API paths, and this file stay in English. The backend still carries original likeadmin attribution headers — do not remove them on edit.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
export function getConversionStatsOverview(params: any) {
|
||||||
|
return request.get({ url: '/stats.conversion/overview', params })
|
||||||
|
}
|
||||||
@@ -27,9 +27,10 @@ export function filterAsyncRoutes(routes: any[], firstRoute = true) {
|
|||||||
|
|
||||||
// 创建一条路由记录
|
// 创建一条路由记录
|
||||||
export function createRouteRecord(route: any, firstRoute: boolean): RouteRecordRaw {
|
export function createRouteRecord(route: any, firstRoute: boolean): RouteRecordRaw {
|
||||||
|
const normalizedPath = normalizeMenuPath(route.paths)
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
const routeRecord: RouteRecordRaw = {
|
const routeRecord: RouteRecordRaw = {
|
||||||
path: isExternal(route.paths) ? route.paths : firstRoute ? `/${route.paths}` : route.paths,
|
path: isExternal(route.paths) ? route.paths : firstRoute ? `/${normalizedPath}` : normalizedPath,
|
||||||
name: Symbol(route.paths),
|
name: Symbol(route.paths),
|
||||||
meta: {
|
meta: {
|
||||||
hidden: !route.is_show,
|
hidden: !route.is_show,
|
||||||
@@ -59,8 +60,9 @@ export function createRouteRecord(route: any, firstRoute: boolean): RouteRecordR
|
|||||||
// 动态加载组件
|
// 动态加载组件
|
||||||
export function loadRouteView(component: string) {
|
export function loadRouteView(component: string) {
|
||||||
try {
|
try {
|
||||||
|
const normalizedComponent = normalizeMenuPath(component)
|
||||||
const key = Object.keys(modules).find((key) => {
|
const key = Object.keys(modules).find((key) => {
|
||||||
return key.includes(`/${component}.vue`)
|
return key.includes(`/${normalizedComponent}.vue`)
|
||||||
})
|
})
|
||||||
if (key) {
|
if (key) {
|
||||||
return modules[key]
|
return modules[key]
|
||||||
@@ -72,6 +74,12 @@ export function loadRouteView(component: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeMenuPath(path?: string) {
|
||||||
|
return String(path || '')
|
||||||
|
.replace(/^\/+/, '')
|
||||||
|
.replace(/\/+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
// 找到第一个有效的路由
|
// 找到第一个有效的路由
|
||||||
export function findFirstValidRoute(routes: RouteRecordRaw[]): string | undefined {
|
export function findFirstValidRoute(routes: RouteRecordRaw[]): string | undefined {
|
||||||
for (const route of routes) {
|
for (const route of routes) {
|
||||||
|
|||||||
@@ -0,0 +1,617 @@
|
|||||||
|
<template>
|
||||||
|
<div class="conversion-stats-page">
|
||||||
|
<el-card class="!border-none" shadow="never">
|
||||||
|
<el-form :inline="true" :model="queryParams" class="stats-filter-form">
|
||||||
|
<el-form-item label="统计维度">
|
||||||
|
<el-segmented
|
||||||
|
v-model="queryParams.dimension"
|
||||||
|
:options="dimensionOptions"
|
||||||
|
@change="handleDimensionChange"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item :label="entityLabel">
|
||||||
|
<el-tree-select
|
||||||
|
v-if="queryParams.dimension === 'dept'"
|
||||||
|
v-model="currentEntityId"
|
||||||
|
:data="deptTreeOptions"
|
||||||
|
:placeholder="`全部${entityLabel}`"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
node-key="id"
|
||||||
|
check-strictly
|
||||||
|
:default-expand-all="true"
|
||||||
|
:props="deptTreeProps"
|
||||||
|
style="width: 220px"
|
||||||
|
@change="handleEntityChange"
|
||||||
|
/>
|
||||||
|
<el-select
|
||||||
|
v-else
|
||||||
|
v-model="currentEntityId"
|
||||||
|
:placeholder="`全部${entityLabel}`"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
style="width: 220px"
|
||||||
|
@change="handleEntityChange"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in currentEntityOptions"
|
||||||
|
:key="item.id"
|
||||||
|
:label="item.name"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="时间范围">
|
||||||
|
<el-radio-group v-model="queryParams.time_type" @change="handleTimeTypeChange">
|
||||||
|
<el-radio-button label="today">今天</el-radio-button>
|
||||||
|
<el-radio-button label="week">最近7天</el-radio-button>
|
||||||
|
<el-radio-button label="month">最近30天</el-radio-button>
|
||||||
|
<el-radio-button label="custom">自定义</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item v-if="queryParams.time_type === 'custom'">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="dateRange"
|
||||||
|
type="daterange"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
style="width: 260px"
|
||||||
|
@change="handleCustomDateChange"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="pager.loading" @click="handleQuery">查询</el-button>
|
||||||
|
<el-button @click="handleReset">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<div class="stats-kpi-grid">
|
||||||
|
<div
|
||||||
|
v-for="card in summaryCards"
|
||||||
|
:key="card.key"
|
||||||
|
class="stats-kpi-card"
|
||||||
|
>
|
||||||
|
<div class="stats-kpi-label">{{ card.label }}</div>
|
||||||
|
<div class="stats-kpi-value" :class="{ 'is-money': card.type === 'money' }">
|
||||||
|
{{ renderMetric(card.key, overview.summary, card.type, card.placeholder) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-row :gutter="20" class="mt-4">
|
||||||
|
<el-col :xs="24" :lg="14">
|
||||||
|
<el-card class="!border-none" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>转化排行</span>
|
||||||
|
<span class="card-hint">{{ dateRangeText }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<v-charts
|
||||||
|
v-if="rankingChartHasData"
|
||||||
|
class="stats-chart"
|
||||||
|
:option="rankingChartOption"
|
||||||
|
autoresize
|
||||||
|
/>
|
||||||
|
<el-empty v-else description="暂无图表数据" />
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :lg="10">
|
||||||
|
<el-card class="!border-none" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>诊单金额占比</span>
|
||||||
|
<span class="card-hint">TOP 10</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<v-charts
|
||||||
|
v-if="amountPieHasData"
|
||||||
|
class="stats-chart"
|
||||||
|
:option="amountPieOption"
|
||||||
|
autoresize
|
||||||
|
/>
|
||||||
|
<el-empty v-else description="暂无金额数据" />
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="20" class="mt-4">
|
||||||
|
<el-col :xs="24" :lg="10">
|
||||||
|
<el-card class="!border-none" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>加粉占比</span>
|
||||||
|
<span class="card-hint">TOP 10</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<v-charts
|
||||||
|
v-if="fanPieHasData"
|
||||||
|
class="stats-chart"
|
||||||
|
:option="fanPieOption"
|
||||||
|
autoresize
|
||||||
|
/>
|
||||||
|
<el-empty v-else description="暂无加粉数据" />
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :lg="14">
|
||||||
|
<el-card class="!border-none" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>明细列表</span>
|
||||||
|
<span class="card-hint">{{ entityLabel }}维度</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-table
|
||||||
|
:data="pager.lists"
|
||||||
|
:row-key="queryParams.dimension === 'dept' ? 'id' : undefined"
|
||||||
|
:tree-props="queryParams.dimension === 'dept' ? treeProps : undefined"
|
||||||
|
:default-expand-all="false"
|
||||||
|
size="large"
|
||||||
|
style="width: 100%"
|
||||||
|
max-height="560"
|
||||||
|
>
|
||||||
|
<el-table-column
|
||||||
|
:label="entityLabel"
|
||||||
|
prop="name"
|
||||||
|
fixed="left"
|
||||||
|
min-width="280"
|
||||||
|
class-name="dept-name-column"
|
||||||
|
show-overflow-tooltip
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="dept-name-cell" :title="row.name">
|
||||||
|
{{ row.name }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
v-for="column in tableColumns"
|
||||||
|
:key="column.key"
|
||||||
|
:label="column.label"
|
||||||
|
:min-width="column.minWidth || 110"
|
||||||
|
:width="column.width"
|
||||||
|
:align="column.align || 'center'"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ renderMetric(column.key, row, column.type, column.placeholder) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div class="mt-4 flex justify-end">
|
||||||
|
<pagination v-model="pager" @change="getLists" />
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts" name="conversionStatsPage">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import vCharts from 'vue-echarts'
|
||||||
|
|
||||||
|
import { getConversionStatsOverview } from '@/api/stats'
|
||||||
|
import { usePaging } from '@/hooks/usePaging'
|
||||||
|
|
||||||
|
type StatsDimension = 'dept' | 'assistant' | 'doctor'
|
||||||
|
|
||||||
|
interface OptionItem {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricCard {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
type: 'count' | 'money' | 'percent' | 'ratio'
|
||||||
|
placeholder?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricColumn extends MetricCard {
|
||||||
|
minWidth?: number
|
||||||
|
width?: number
|
||||||
|
align?: 'left' | 'center' | 'right'
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateRange = ref<string[]>([])
|
||||||
|
|
||||||
|
const deptTreeOptions = ref<any[]>([])
|
||||||
|
const deptOptions = ref<OptionItem[]>([])
|
||||||
|
const assistantOptions = ref<OptionItem[]>([])
|
||||||
|
const doctorOptions = ref<OptionItem[]>([])
|
||||||
|
const filtersLoaded = ref(false)
|
||||||
|
const overview = reactive<Record<string, any>>({
|
||||||
|
dimension: 'dept',
|
||||||
|
date_range: [],
|
||||||
|
summary: {},
|
||||||
|
charts: {
|
||||||
|
ranking: {
|
||||||
|
names: [],
|
||||||
|
amounts: [],
|
||||||
|
order_counts: [],
|
||||||
|
fan_counts: [],
|
||||||
|
rois: []
|
||||||
|
},
|
||||||
|
amount_share: [],
|
||||||
|
fan_share: []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const queryParams = reactive({
|
||||||
|
dimension: 'dept' as StatsDimension,
|
||||||
|
dept_id: undefined as number | undefined,
|
||||||
|
assistant_id: undefined as number | undefined,
|
||||||
|
doctor_id: undefined as number | undefined,
|
||||||
|
time_type: 'today',
|
||||||
|
start_date: '',
|
||||||
|
end_date: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchOverviewList = async (params: Record<string, any>) => {
|
||||||
|
params.include_filters = filtersLoaded.value ? 0 : 1
|
||||||
|
if (queryParams.time_type === 'custom') {
|
||||||
|
params.start_date = dateRange.value[0] || ''
|
||||||
|
params.end_date = dateRange.value[1] || ''
|
||||||
|
}
|
||||||
|
const res = await getConversionStatsOverview(params)
|
||||||
|
Object.assign(overview, res?.extend || {})
|
||||||
|
applyFilterOptions(res?.extend?.filters)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pager, getLists } = usePaging({
|
||||||
|
fetchFun: fetchOverviewList,
|
||||||
|
params: queryParams,
|
||||||
|
firstLoading: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const dimensionOptions = [
|
||||||
|
{ label: '部门', value: 'dept' },
|
||||||
|
{ label: '医助', value: 'assistant' },
|
||||||
|
{ label: '医生', value: 'doctor' }
|
||||||
|
]
|
||||||
|
const deptTreeProps = {
|
||||||
|
value: 'id',
|
||||||
|
label: 'name',
|
||||||
|
children: 'children'
|
||||||
|
}
|
||||||
|
const treeProps = {
|
||||||
|
children: 'children'
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityLabel = computed(() => {
|
||||||
|
if (queryParams.dimension === 'assistant') return '医助'
|
||||||
|
if (queryParams.dimension === 'doctor') return '医生'
|
||||||
|
return '部门'
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentEntityOptions = computed(() => {
|
||||||
|
if (queryParams.dimension === 'assistant') return assistantOptions.value
|
||||||
|
if (queryParams.dimension === 'doctor') return doctorOptions.value
|
||||||
|
return deptOptions.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentEntityId = computed({
|
||||||
|
get: () => {
|
||||||
|
if (queryParams.dimension === 'assistant') return queryParams.assistant_id
|
||||||
|
if (queryParams.dimension === 'doctor') return queryParams.doctor_id
|
||||||
|
return queryParams.dept_id
|
||||||
|
},
|
||||||
|
set: (value: number | undefined) => {
|
||||||
|
if (queryParams.dimension === 'assistant') {
|
||||||
|
queryParams.assistant_id = value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (queryParams.dimension === 'doctor') {
|
||||||
|
queryParams.doctor_id = value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
queryParams.dept_id = value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const summaryCards: MetricCard[] = [
|
||||||
|
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
|
||||||
|
{ key: 'paid_appointment_count', label: '付费挂号', type: 'count' },
|
||||||
|
{ key: 'free_appointment_count', label: '免费挂号', type: 'count' },
|
||||||
|
{ key: 'interview_count', label: '面诊', type: 'count' },
|
||||||
|
{ key: 'completed_order_count', label: '接诊诊单', type: 'count' },
|
||||||
|
{ key: 'completed_order_amount', label: '诊单金额', type: 'money' },
|
||||||
|
{ key: 'account_cost', label: '账户消耗', type: 'money' },
|
||||||
|
{ key: 'roi', label: 'ROI', type: 'ratio' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const tableColumns: MetricColumn[] = [
|
||||||
|
{ key: 'add_fans_count', label: '加粉数', type: 'count' },
|
||||||
|
{ key: 'total_open_count', label: '总开口', type: 'count', placeholder: true },
|
||||||
|
{ key: 'unreplied_count', label: '未回复', type: 'count', placeholder: true },
|
||||||
|
{ key: 'paid_appointment_count', label: '付费挂号', type: 'count' },
|
||||||
|
{ key: 'free_appointment_count', label: '免费挂号', type: 'count' },
|
||||||
|
{ key: 'appointment_total_count', label: '挂号总数', type: 'count' },
|
||||||
|
{ key: 'interview_count', label: '面诊', type: 'count' },
|
||||||
|
{ key: 'completed_order_count', label: '接诊诊单', type: 'count' },
|
||||||
|
{ key: 'completed_order_amount', label: '诊单金额', type: 'money', minWidth: 120 },
|
||||||
|
{ key: 'paid_appointment_rate', label: '付费挂号率', type: 'percent', minWidth: 120 },
|
||||||
|
{ key: 'open_appointment_rate', label: '开口挂号率', type: 'percent', placeholder: true, minWidth: 120 },
|
||||||
|
{ key: 'interview_rate', label: '面诊率', type: 'percent', minWidth: 100 },
|
||||||
|
{ key: 'receive_rate', label: '接诊率', type: 'percent', minWidth: 100 },
|
||||||
|
{ key: 'interview_receive_rate', label: '面诊接诊率', type: 'percent', minWidth: 130 },
|
||||||
|
{ key: 'open_receive_rate', label: '开口接诊率', type: 'percent', placeholder: true, minWidth: 120 },
|
||||||
|
{ key: 'avg_unit_price', label: '平均单价', type: 'money', minWidth: 110 },
|
||||||
|
{ key: 'account_cost', label: '账户消耗', type: 'money', minWidth: 120 },
|
||||||
|
{ key: 'cash_cost', label: '现金成本', type: 'money', minWidth: 110 },
|
||||||
|
{ key: 'roi', label: 'ROI', type: 'ratio', minWidth: 90 }
|
||||||
|
]
|
||||||
|
|
||||||
|
const dateRangeText = computed(() => {
|
||||||
|
if (!overview.date_range?.length) return '未选择'
|
||||||
|
return `${overview.date_range[0]} 至 ${overview.date_range[1]}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const rankingChartHasData = computed(() => overview.charts.ranking.names.length > 0)
|
||||||
|
const amountPieHasData = computed(() => overview.charts.amount_share.length > 0)
|
||||||
|
const fanPieHasData = computed(() => overview.charts.fan_share.length > 0)
|
||||||
|
|
||||||
|
const rankingChartData = computed(() => {
|
||||||
|
const ranking = overview.charts?.ranking || {}
|
||||||
|
const names = Array.isArray(ranking.names) ? ranking.names : []
|
||||||
|
const normalize = (values: unknown[]) => names.map((_, index) => Number(values?.[index] ?? 0))
|
||||||
|
|
||||||
|
return {
|
||||||
|
names,
|
||||||
|
amounts: normalize(Array.isArray(ranking.amounts) ? ranking.amounts : []),
|
||||||
|
orderCounts: normalize(Array.isArray(ranking.order_counts) ? ranking.order_counts : []),
|
||||||
|
fanCounts: normalize(Array.isArray(ranking.fan_counts) ? ranking.fan_counts : []),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const rankingChartOption = computed(() => ({
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
legend: { data: ['诊单金额', '接诊诊单', '加粉数'] },
|
||||||
|
grid: { left: 48, right: 24, top: 48, bottom: 48 },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: rankingChartData.value.names,
|
||||||
|
axisLabel: {
|
||||||
|
interval: 0,
|
||||||
|
rotate: rankingChartData.value.names.length > 6 ? 25 : 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yAxis: [
|
||||||
|
{ type: 'value', name: '金额 / 数量' }
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '诊单金额',
|
||||||
|
type: 'bar',
|
||||||
|
barMaxWidth: 36,
|
||||||
|
data: rankingChartData.value.amounts,
|
||||||
|
itemStyle: { color: '#4a78ff' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '接诊诊单',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: true,
|
||||||
|
symbolSize: 8,
|
||||||
|
lineStyle: { width: 3 },
|
||||||
|
data: rankingChartData.value.orderCounts,
|
||||||
|
itemStyle: { color: '#15b8a6' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '加粉数',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: true,
|
||||||
|
symbolSize: 8,
|
||||||
|
lineStyle: { width: 3 },
|
||||||
|
data: rankingChartData.value.fanCounts,
|
||||||
|
itemStyle: { color: '#ff9f43' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
|
||||||
|
const amountPieOption = computed(() => ({
|
||||||
|
tooltip: { trigger: 'item' },
|
||||||
|
legend: { bottom: 0 },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '诊单金额',
|
||||||
|
type: 'pie',
|
||||||
|
radius: ['42%', '72%'],
|
||||||
|
data: overview.charts.amount_share
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
|
||||||
|
const fanPieOption = computed(() => ({
|
||||||
|
tooltip: { trigger: 'item' },
|
||||||
|
legend: { bottom: 0 },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '加粉数',
|
||||||
|
type: 'pie',
|
||||||
|
radius: ['42%', '72%'],
|
||||||
|
data: overview.charts.fan_share
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
|
||||||
|
const flattenDepts = (items: any[]): OptionItem[] => {
|
||||||
|
let result: OptionItem[] = []
|
||||||
|
items.forEach((item) => {
|
||||||
|
result.push({ id: Number(item.id), name: item.name })
|
||||||
|
if (Array.isArray(item.children) && item.children.length > 0) {
|
||||||
|
result = result.concat(flattenDepts(item.children))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyFilterOptions = (filters?: Record<string, any>) => {
|
||||||
|
if (!filters) return
|
||||||
|
filtersLoaded.value = true
|
||||||
|
const departments = Array.isArray(filters.departments) ? filters.departments : []
|
||||||
|
const assistants = Array.isArray(filters.assistants) ? filters.assistants : []
|
||||||
|
const doctors = Array.isArray(filters.doctors) ? filters.doctors : []
|
||||||
|
|
||||||
|
deptTreeOptions.value = departments
|
||||||
|
deptOptions.value = flattenDepts(departments)
|
||||||
|
assistantOptions.value = assistants.map((item: any) => ({
|
||||||
|
id: Number(item.id),
|
||||||
|
name: item.name,
|
||||||
|
}))
|
||||||
|
doctorOptions.value = doctors.map((item: any) => ({
|
||||||
|
id: Number(item.id),
|
||||||
|
name: item.name,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchOverview = async () => {
|
||||||
|
try {
|
||||||
|
await getLists()
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('获取统计概览失败:', error)
|
||||||
|
ElMessage.error(error?.msg || '获取统计数据失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDimensionChange = () => {
|
||||||
|
queryParams.dept_id = undefined
|
||||||
|
queryParams.assistant_id = undefined
|
||||||
|
queryParams.doctor_id = undefined
|
||||||
|
pager.page = 1
|
||||||
|
fetchOverview()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEntityChange = () => {
|
||||||
|
pager.page = 1
|
||||||
|
fetchOverview()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTimeTypeChange = () => {
|
||||||
|
if (queryParams.time_type !== 'custom') {
|
||||||
|
dateRange.value = []
|
||||||
|
pager.page = 1
|
||||||
|
fetchOverview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCustomDateChange = () => {
|
||||||
|
if (dateRange.value.length === 2) {
|
||||||
|
pager.page = 1
|
||||||
|
fetchOverview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleQuery = () => {
|
||||||
|
pager.page = 1
|
||||||
|
fetchOverview()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
queryParams.dimension = 'dept'
|
||||||
|
queryParams.dept_id = undefined
|
||||||
|
queryParams.assistant_id = undefined
|
||||||
|
queryParams.doctor_id = undefined
|
||||||
|
queryParams.time_type = 'today'
|
||||||
|
dateRange.value = []
|
||||||
|
pager.page = 1
|
||||||
|
pager.size = 15
|
||||||
|
fetchOverview()
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderMetric = (key: string, source: Record<string, any>, type = 'count', placeholder = false) => {
|
||||||
|
if (placeholder) return '—'
|
||||||
|
const value = source?.[key] ?? 0
|
||||||
|
if (type === 'money') return `¥${Number(value || 0).toFixed(2)}`
|
||||||
|
if (type === 'percent') return `${Number(value || 0).toFixed(2)}%`
|
||||||
|
if (type === 'ratio') return Number(value || 0).toFixed(2)
|
||||||
|
return String(value ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchOverview()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.conversion-stats-page {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-filter-form {
|
||||||
|
:deep(.el-form-item) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-card {
|
||||||
|
background: linear-gradient(145deg, #ffffff, #f5f8ff);
|
||||||
|
border: 1px solid #ebf1ff;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 18px 16px;
|
||||||
|
box-shadow: 0 10px 24px rgba(74, 120, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-label {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-value {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1.1;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-kpi-value.is-money {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hint {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dept-name-cell {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.dept-name-column .cell) {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-chart {
|
||||||
|
height: 360px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\adminapi\controller\stats;
|
||||||
|
|
||||||
|
use app\adminapi\controller\BaseAdminController;
|
||||||
|
use app\adminapi\logic\stats\ConversionLogic;
|
||||||
|
|
||||||
|
class ConversionController extends BaseAdminController
|
||||||
|
{
|
||||||
|
public function overview()
|
||||||
|
{
|
||||||
|
$result = ConversionLogic::overview($this->request->get());
|
||||||
|
|
||||||
|
return $this->data($result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,944 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\adminapi\logic\stats;
|
||||||
|
|
||||||
|
use app\adminapi\logic\dept\DeptLogic;
|
||||||
|
use app\adminapi\logic\tcm\DiagnosisLogic;
|
||||||
|
use think\facade\Cache;
|
||||||
|
use think\facade\Db;
|
||||||
|
|
||||||
|
class ConversionLogic
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function overview(array $params = []): array
|
||||||
|
{
|
||||||
|
$includeFilters = (int)($params['include_filters'] ?? 0) === 1;
|
||||||
|
$cacheParams = $params;
|
||||||
|
ksort($cacheParams);
|
||||||
|
$cacheKey = 'conversion_stats_overview_' . md5(json_encode($cacheParams, JSON_UNESCAPED_UNICODE));
|
||||||
|
$cached = Cache::get($cacheKey);
|
||||||
|
if (is_array($cached)) {
|
||||||
|
if ($includeFilters) {
|
||||||
|
$cached['extend']['filters'] = self::buildFilterOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dimension = self::normalizeDimension((string)($params['dimension'] ?? 'dept'));
|
||||||
|
[$startTimestamp, $endTimestamp, $startDate, $endDate] = self::resolveTimeRange($params);
|
||||||
|
$pageNo = max(1, (int)($params['page_no'] ?? 1));
|
||||||
|
$pageSize = max(1, min(100, (int)($params['page_size'] ?? 15)));
|
||||||
|
|
||||||
|
$entities = self::loadEntities($dimension, $params);
|
||||||
|
$entityIds = array_keys($entities);
|
||||||
|
|
||||||
|
if ($entityIds === []) {
|
||||||
|
$result = [
|
||||||
|
'dimension' => $dimension,
|
||||||
|
'date_range' => [$startDate, $endDate],
|
||||||
|
'summary' => self::buildSummary([]),
|
||||||
|
'charts' => self::buildCharts([]),
|
||||||
|
'lists' => [],
|
||||||
|
'count' => 0,
|
||||||
|
'page_no' => $pageNo,
|
||||||
|
'page_size' => $pageSize,
|
||||||
|
'extend' => [
|
||||||
|
'dimension' => $dimension,
|
||||||
|
'date_range' => [$startDate, $endDate],
|
||||||
|
'summary' => self::buildSummary([]),
|
||||||
|
'charts' => self::buildCharts([]),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
if ($includeFilters) {
|
||||||
|
$result['extend']['filters'] = self::buildFilterOptions();
|
||||||
|
}
|
||||||
|
Cache::set($cacheKey, $result, 60);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$adminToDeptIds = self::loadAdminDeptMap();
|
||||||
|
self::hydrateFanStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp);
|
||||||
|
self::hydrateAppointmentStats($entities, $dimension, $entityIds, $adminToDeptIds, $startDate, $endDate);
|
||||||
|
self::finalizeAppointmentStats($entities);
|
||||||
|
self::hydratePaidAuditAmountStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp);
|
||||||
|
self::hydrateCompletedOrderStats($entities, $dimension, $entityIds, $adminToDeptIds, $startTimestamp, $endTimestamp);
|
||||||
|
|
||||||
|
if ($dimension === 'dept') {
|
||||||
|
[$allRows, $pagedRows, $chartRows] = self::buildDeptTreeRows(
|
||||||
|
$entities,
|
||||||
|
isset($params['dept_id']) ? (int)$params['dept_id'] : 0,
|
||||||
|
$pageNo,
|
||||||
|
$pageSize
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = [
|
||||||
|
'dimension' => $dimension,
|
||||||
|
'date_range' => [$startDate, $endDate],
|
||||||
|
'summary' => self::buildSummary($allRows),
|
||||||
|
'charts' => self::buildCharts($chartRows),
|
||||||
|
'lists' => $pagedRows,
|
||||||
|
'count' => count($allRows),
|
||||||
|
'page_no' => $pageNo,
|
||||||
|
'page_size' => $pageSize,
|
||||||
|
'extend' => [
|
||||||
|
'dimension' => $dimension,
|
||||||
|
'date_range' => [$startDate, $endDate],
|
||||||
|
'summary' => self::buildSummary($allRows),
|
||||||
|
'charts' => self::buildCharts($chartRows),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
if ($includeFilters) {
|
||||||
|
$result['extend']['filters'] = self::buildFilterOptions();
|
||||||
|
}
|
||||||
|
Cache::set($cacheKey, $result, 60);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = self::finalizeRows($entities);
|
||||||
|
$count = count($rows);
|
||||||
|
$offset = ($pageNo - 1) * $pageSize;
|
||||||
|
|
||||||
|
$result = [
|
||||||
|
'dimension' => $dimension,
|
||||||
|
'date_range' => [$startDate, $endDate],
|
||||||
|
'summary' => self::buildSummary($rows),
|
||||||
|
'charts' => self::buildCharts($rows),
|
||||||
|
'lists' => array_slice($rows, $offset, $pageSize),
|
||||||
|
'count' => $count,
|
||||||
|
'page_no' => $pageNo,
|
||||||
|
'page_size' => $pageSize,
|
||||||
|
'extend' => [
|
||||||
|
'dimension' => $dimension,
|
||||||
|
'date_range' => [$startDate, $endDate],
|
||||||
|
'summary' => self::buildSummary($rows),
|
||||||
|
'charts' => self::buildCharts($rows),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
if ($includeFilters) {
|
||||||
|
$result['extend']['filters'] = self::buildFilterOptions();
|
||||||
|
}
|
||||||
|
Cache::set($cacheKey, $result, 60);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function buildFilterOptions(): array
|
||||||
|
{
|
||||||
|
$cacheKey = 'conversion_stats_filters';
|
||||||
|
$cached = Cache::get($cacheKey);
|
||||||
|
if (is_array($cached)) {
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filters = [
|
||||||
|
'departments' => DeptLogic::getAllData(),
|
||||||
|
'assistants' => DiagnosisLogic::getAssistants(),
|
||||||
|
'doctors' => DiagnosisLogic::getDoctors(),
|
||||||
|
];
|
||||||
|
Cache::set($cacheKey, $filters, 600);
|
||||||
|
|
||||||
|
return $filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normalizeDimension(string $dimension): string
|
||||||
|
{
|
||||||
|
return in_array($dimension, ['dept', 'assistant', 'doctor'], true) ? $dimension : 'dept';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0:int,1:int,2:string,3:string}
|
||||||
|
*/
|
||||||
|
private static function resolveTimeRange(array $params): array
|
||||||
|
{
|
||||||
|
$today = date('Y-m-d');
|
||||||
|
$timeType = (string)($params['time_type'] ?? 'today');
|
||||||
|
|
||||||
|
switch ($timeType) {
|
||||||
|
case 'week':
|
||||||
|
$startDate = date('Y-m-d', strtotime('-6 days'));
|
||||||
|
$endDate = $today;
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
$startDate = date('Y-m-d', strtotime('-29 days'));
|
||||||
|
$endDate = $today;
|
||||||
|
break;
|
||||||
|
case '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];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'today':
|
||||||
|
default:
|
||||||
|
$startDate = $today;
|
||||||
|
$endDate = $today;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
strtotime($startDate . ' 00:00:00'),
|
||||||
|
strtotime($endDate . ' 23:59:59'),
|
||||||
|
$startDate,
|
||||||
|
$endDate,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private static function loadEntities(string $dimension, array $params): array
|
||||||
|
{
|
||||||
|
return match ($dimension) {
|
||||||
|
'assistant' => self::loadAdminEntities(2, isset($params['assistant_id']) ? (int)$params['assistant_id'] : 0),
|
||||||
|
'doctor' => self::loadAdminEntities(1, isset($params['doctor_id']) ? (int)$params['doctor_id'] : 0),
|
||||||
|
default => self::loadDeptEntities(isset($params['dept_id']) ? (int)$params['dept_id'] : 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private static function loadDeptEntities(int $deptId = 0): array
|
||||||
|
{
|
||||||
|
$query = Db::name('dept')
|
||||||
|
->whereNull('delete_time')
|
||||||
|
->field('id, pid, name, sort');
|
||||||
|
|
||||||
|
$rows = $query->order('sort desc, id asc')->select()->toArray();
|
||||||
|
if ($deptId > 0) {
|
||||||
|
$allowedIds = self::collectDeptSubtreeIds($rows, $deptId);
|
||||||
|
$rows = array_values(array_filter($rows, static fn (array $row): bool => in_array((int)$row['id'], $allowedIds, true)));
|
||||||
|
}
|
||||||
|
|
||||||
|
$entities = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$id = (int)($row['id'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$entities[$id] = self::newEntityRow($id, (string)($row['name'] ?? ''));
|
||||||
|
$entities[$id]['pid'] = (int)($row['pid'] ?? 0);
|
||||||
|
$entities[$id]['sort'] = (int)($row['sort'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $rows
|
||||||
|
* @return int[]
|
||||||
|
*/
|
||||||
|
private static function collectDeptSubtreeIds(array $rows, int $rootId): array
|
||||||
|
{
|
||||||
|
$childrenByPid = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$pid = (int)($row['pid'] ?? 0);
|
||||||
|
$id = (int)($row['id'] ?? 0);
|
||||||
|
$childrenByPid[$pid] ??= [];
|
||||||
|
$childrenByPid[$pid][] = $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$queue = [$rootId];
|
||||||
|
$result = [];
|
||||||
|
while ($queue !== []) {
|
||||||
|
$current = array_shift($queue);
|
||||||
|
if (!$current || isset($result[$current])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$result[$current] = $current;
|
||||||
|
foreach ($childrenByPid[$current] ?? [] as $childId) {
|
||||||
|
$queue[] = (int)$childId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private static function loadAdminEntities(int $roleId, int $adminId = 0): array
|
||||||
|
{
|
||||||
|
$query = Db::name('admin')
|
||||||
|
->alias('a')
|
||||||
|
->join('admin_role ar', 'ar.admin_id = a.id')
|
||||||
|
->where('ar.role_id', $roleId)
|
||||||
|
->whereNull('a.delete_time')
|
||||||
|
->field('a.id, a.name');
|
||||||
|
|
||||||
|
if ($adminId > 0) {
|
||||||
|
$query->where('a.id', $adminId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $query->order('a.id asc')->select()->toArray();
|
||||||
|
$entities = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$id = (int)($row['id'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$entities[$id] = self::newEntityRow($id, (string)($row['name'] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function newEntityRow(int $id, string $name): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $id,
|
||||||
|
'name' => $name,
|
||||||
|
'add_fans_count' => 0,
|
||||||
|
'total_open_count' => 0,
|
||||||
|
'unreplied_count' => 0,
|
||||||
|
'paid_appointment_count' => 0,
|
||||||
|
'free_appointment_count' => 0,
|
||||||
|
'appointment_total_count' => 0,
|
||||||
|
'interview_count' => 0,
|
||||||
|
'completed_order_amount' => 0.0,
|
||||||
|
'completed_order_count' => 0,
|
||||||
|
'account_cost' => 0.0,
|
||||||
|
'_appointment_groups' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, int[]>
|
||||||
|
*/
|
||||||
|
private static function loadAdminDeptMap(): array
|
||||||
|
{
|
||||||
|
$rows = Db::name('admin_dept')->field('admin_id, dept_id')->select()->toArray();
|
||||||
|
$map = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$adminId = (int)($row['admin_id'] ?? 0);
|
||||||
|
$deptId = (int)($row['dept_id'] ?? 0);
|
||||||
|
if ($adminId <= 0 || $deptId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$map[$adminId] ??= [];
|
||||||
|
$map[$adminId][] = $deptId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $entities
|
||||||
|
* @param int[] $entityIds
|
||||||
|
* @param array<int, int[]> $adminToDeptIds
|
||||||
|
*/
|
||||||
|
private static function hydrateFanStats(
|
||||||
|
array &$entities,
|
||||||
|
string $dimension,
|
||||||
|
array $entityIds,
|
||||||
|
array $adminToDeptIds,
|
||||||
|
int $startTimestamp,
|
||||||
|
int $endTimestamp
|
||||||
|
): void {
|
||||||
|
$wxUserIdToAdminIds = self::loadWorkWechatAdminMap();
|
||||||
|
$rows = Db::name('qywx_external_contact')
|
||||||
|
->whereNull('delete_time')
|
||||||
|
->whereRaw(
|
||||||
|
'((external_first_add_time BETWEEN ? AND ?) OR (external_first_add_time = 0 AND create_time BETWEEN ? AND ?))',
|
||||||
|
[$startTimestamp, $endTimestamp, $startTimestamp, $endTimestamp]
|
||||||
|
)
|
||||||
|
->field('follow_admin_ids, follow_users')
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$adminIds = self::extractFollowAdminIds($row, $wxUserIdToAdminIds);
|
||||||
|
if ($adminIds === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($adminIds as $adminId) {
|
||||||
|
foreach (self::mapEntityIds($dimension, $adminId, $entityIds, $adminToDeptIds) as $entityId) {
|
||||||
|
$entities[$entityId]['add_fans_count']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, int[]>
|
||||||
|
*/
|
||||||
|
private static function loadWorkWechatAdminMap(): array
|
||||||
|
{
|
||||||
|
$rows = Db::name('admin')
|
||||||
|
->whereNull('delete_time')
|
||||||
|
->where('work_wechat_userid', '<>', '')
|
||||||
|
->field('id, work_wechat_userid')
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$map = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$userId = trim((string)($row['work_wechat_userid'] ?? ''));
|
||||||
|
$adminId = (int)($row['id'] ?? 0);
|
||||||
|
if ($userId === '' || $adminId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$map[$userId] ??= [];
|
||||||
|
$map[$userId][] = $adminId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $row
|
||||||
|
* @param array<string, int[]> $wxUserIdToAdminIds
|
||||||
|
* @return int[]
|
||||||
|
*/
|
||||||
|
private static function extractFollowAdminIds(array $row, array $wxUserIdToAdminIds): array
|
||||||
|
{
|
||||||
|
$adminIds = json_decode((string)($row['follow_admin_ids'] ?? '[]'), true);
|
||||||
|
if (is_array($adminIds)) {
|
||||||
|
$adminIds = array_values(array_unique(array_filter(array_map('intval', $adminIds), static fn (int $id): bool => $id > 0)));
|
||||||
|
} else {
|
||||||
|
$adminIds = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($adminIds !== []) {
|
||||||
|
return $adminIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
$followUsers = json_decode((string)($row['follow_users'] ?? '[]'), true);
|
||||||
|
if (!is_array($followUsers)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolved = [];
|
||||||
|
foreach ($followUsers as $followUser) {
|
||||||
|
if (!is_array($followUser)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$userId = trim((string)($followUser['userid'] ?? ''));
|
||||||
|
if ($userId === '' || !isset($wxUserIdToAdminIds[$userId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($wxUserIdToAdminIds[$userId] as $adminId) {
|
||||||
|
$resolved[$adminId] = $adminId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values($resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $entities
|
||||||
|
* @param int[] $entityIds
|
||||||
|
* @param array<int, int[]> $adminToDeptIds
|
||||||
|
*/
|
||||||
|
private static function hydrateAppointmentStats(
|
||||||
|
array &$entities,
|
||||||
|
string $dimension,
|
||||||
|
array $entityIds,
|
||||||
|
array $adminToDeptIds,
|
||||||
|
string $startDate,
|
||||||
|
string $endDate
|
||||||
|
): void {
|
||||||
|
$rows = Db::name('doctor_appointment')
|
||||||
|
->where('appointment_date', '>=', $startDate)
|
||||||
|
->where('appointment_date', '<=', $endDate)
|
||||||
|
->field('patient_id, doctor_id, assistant_id, status')
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$diagnosisId = (int)($row['patient_id'] ?? 0);
|
||||||
|
if ($diagnosisId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceAdminId = $dimension === 'doctor'
|
||||||
|
? (int)($row['doctor_id'] ?? 0)
|
||||||
|
: (int)($row['assistant_id'] ?? 0);
|
||||||
|
|
||||||
|
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds);
|
||||||
|
if ($mappedEntityIds === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($mappedEntityIds as $entityId) {
|
||||||
|
$entities[$entityId]['appointment_total_count']++;
|
||||||
|
if (!isset($entities[$entityId]['_appointment_groups'][$diagnosisId])) {
|
||||||
|
$entities[$entityId]['_appointment_groups'][$diagnosisId] = [
|
||||||
|
'count' => 0,
|
||||||
|
'has_completed' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$entities[$entityId]['_appointment_groups'][$diagnosisId]['count']++;
|
||||||
|
if ((int)($row['status'] ?? 0) === 3) {
|
||||||
|
$entities[$entityId]['_appointment_groups'][$diagnosisId]['has_completed'] = true;
|
||||||
|
$entities[$entityId]['interview_count']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $entities
|
||||||
|
*/
|
||||||
|
private static function finalizeAppointmentStats(array &$entities): void
|
||||||
|
{
|
||||||
|
foreach ($entities as &$entity) {
|
||||||
|
$groups = $entity['_appointment_groups'] ?? [];
|
||||||
|
$paid = 0;
|
||||||
|
$free = 0;
|
||||||
|
foreach ($groups as $group) {
|
||||||
|
$count = (int)($group['count'] ?? 0);
|
||||||
|
if ($count <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$paid += 1;
|
||||||
|
// 与问诊列表总数口径对齐:
|
||||||
|
// 同一诊单第一条记为付费挂号,其余挂号记录都记为免费挂号,
|
||||||
|
// 否则“重复挂号但未完成”的记录会被漏掉,导致付费+免费 < 挂号总数。
|
||||||
|
if ($count > 1) {
|
||||||
|
$free += ($count - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$entity['paid_appointment_count'] = $paid;
|
||||||
|
$entity['free_appointment_count'] = $free;
|
||||||
|
unset($entity['_appointment_groups']);
|
||||||
|
}
|
||||||
|
unset($entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $entities
|
||||||
|
* @param int[] $entityIds
|
||||||
|
* @param array<int, int[]> $adminToDeptIds
|
||||||
|
*/
|
||||||
|
private static function hydrateCompletedOrderStats(
|
||||||
|
array &$entities,
|
||||||
|
string $dimension,
|
||||||
|
array $entityIds,
|
||||||
|
array $adminToDeptIds,
|
||||||
|
int $startTimestamp,
|
||||||
|
int $endTimestamp
|
||||||
|
): void {
|
||||||
|
$rows = Db::name('tcm_prescription_order')
|
||||||
|
->alias('po')
|
||||||
|
->leftJoin('tcm_prescription rx', 'rx.id = po.prescription_id')
|
||||||
|
->whereNull('po.delete_time')
|
||||||
|
->where('po.fulfillment_status', 3)
|
||||||
|
->where('po.update_time', 'between', [$startTimestamp, $endTimestamp])
|
||||||
|
->field('po.amount, po.internal_cost, rx.assistant_id, rx.creator_id')
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$sourceAdminId = $dimension === 'doctor'
|
||||||
|
? (int)($row['creator_id'] ?? 0)
|
||||||
|
: (int)($row['assistant_id'] ?? 0);
|
||||||
|
|
||||||
|
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds);
|
||||||
|
if ($mappedEntityIds === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$internalCost = round((float)($row['internal_cost'] ?? 0), 2);
|
||||||
|
|
||||||
|
foreach ($mappedEntityIds as $entityId) {
|
||||||
|
$entities[$entityId]['completed_order_count']++;
|
||||||
|
$entities[$entityId]['account_cost'] = round($entities[$entityId]['account_cost'] + $internalCost, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊单金额:按支付单审核通过(payment_slip_audit_status=1)的业务订单 amount 汇总
|
||||||
|
*
|
||||||
|
* @param array<int, array<string, mixed>> $entities
|
||||||
|
* @param int[] $entityIds
|
||||||
|
* @param array<int, int[]> $adminToDeptIds
|
||||||
|
*/
|
||||||
|
private static function hydratePaidAuditAmountStats(
|
||||||
|
array &$entities,
|
||||||
|
string $dimension,
|
||||||
|
array $entityIds,
|
||||||
|
array $adminToDeptIds,
|
||||||
|
int $startTimestamp,
|
||||||
|
int $endTimestamp
|
||||||
|
): void {
|
||||||
|
$rows = Db::name('tcm_prescription_order')
|
||||||
|
->alias('po')
|
||||||
|
->leftJoin('tcm_prescription rx', 'rx.id = po.prescription_id')
|
||||||
|
->whereNull('po.delete_time')
|
||||||
|
->where('po.payment_slip_audit_status', 1)
|
||||||
|
->where('po.update_time', 'between', [$startTimestamp, $endTimestamp])
|
||||||
|
->field('po.amount, rx.assistant_id, rx.creator_id')
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$sourceAdminId = $dimension === 'doctor'
|
||||||
|
? (int)($row['creator_id'] ?? 0)
|
||||||
|
: (int)($row['assistant_id'] ?? 0);
|
||||||
|
|
||||||
|
$mappedEntityIds = self::mapEntityIds($dimension, $sourceAdminId, $entityIds, $adminToDeptIds);
|
||||||
|
if ($mappedEntityIds === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$amount = round((float)($row['amount'] ?? 0), 2);
|
||||||
|
foreach ($mappedEntityIds as $entityId) {
|
||||||
|
$entities[$entityId]['completed_order_amount'] = round($entities[$entityId]['completed_order_amount'] + $amount, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int[] $entityIds
|
||||||
|
* @param array<int, int[]> $adminToDeptIds
|
||||||
|
* @return int[]
|
||||||
|
*/
|
||||||
|
private static function mapEntityIds(string $dimension, int $adminId, array $entityIds, array $adminToDeptIds): array
|
||||||
|
{
|
||||||
|
if ($adminId <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dimension !== 'dept') {
|
||||||
|
return in_array($adminId, $entityIds, true) ? [$adminId] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$deptIds = $adminToDeptIds[$adminId] ?? [];
|
||||||
|
if ($deptIds === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_filter($deptIds, static fn (int $deptId): bool => in_array($deptId, $entityIds, true)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $entities
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private static function finalizeRows(array $entities): array
|
||||||
|
{
|
||||||
|
$rows = [];
|
||||||
|
foreach ($entities as $entity) {
|
||||||
|
$paidAppointmentCount = (int)($entity['paid_appointment_count'] ?? 0);
|
||||||
|
$freeAppointmentCount = (int)($entity['free_appointment_count'] ?? 0);
|
||||||
|
$appointmentTotalCount = (int)($entity['appointment_total_count'] ?? 0);
|
||||||
|
$interviewCount = (int)$entity['interview_count'];
|
||||||
|
$addFansCount = (int)$entity['add_fans_count'];
|
||||||
|
$totalOpenCount = (int)$entity['total_open_count'];
|
||||||
|
$completedOrderCount = (int)$entity['completed_order_count'];
|
||||||
|
$completedOrderAmount = round((float)$entity['completed_order_amount'], 2);
|
||||||
|
$accountCost = round((float)$entity['account_cost'], 2);
|
||||||
|
|
||||||
|
$entity['paid_appointment_count'] = $paidAppointmentCount;
|
||||||
|
$entity['free_appointment_count'] = $freeAppointmentCount;
|
||||||
|
$entity['appointment_total_count'] = $appointmentTotalCount;
|
||||||
|
$entity['completed_order_amount'] = $completedOrderAmount;
|
||||||
|
$entity['account_cost'] = $accountCost;
|
||||||
|
$entity['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
|
||||||
|
$entity['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
|
||||||
|
$entity['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
|
||||||
|
$entity['receive_rate'] = self::percent($completedOrderCount, $addFansCount);
|
||||||
|
$entity['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
|
||||||
|
$entity['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
|
||||||
|
$entity['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
|
||||||
|
$entity['cash_cost'] = self::safeDivideMoney($accountCost, $addFansCount);
|
||||||
|
$entity['roi'] = self::safeDivideRatio($completedOrderAmount, $accountCost);
|
||||||
|
|
||||||
|
$rows[] = $entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($rows, static function (array $left, array $right): int {
|
||||||
|
$order = $right['completed_order_amount'] <=> $left['completed_order_amount'];
|
||||||
|
if ($order !== 0) {
|
||||||
|
return $order;
|
||||||
|
}
|
||||||
|
$order = $right['completed_order_count'] <=> $left['completed_order_count'];
|
||||||
|
if ($order !== 0) {
|
||||||
|
return $order;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $right['add_fans_count'] <=> $left['add_fans_count'];
|
||||||
|
});
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $entities
|
||||||
|
* @return array{0: array<int, array<string, mixed>>, 1: array<int, array<string, mixed>>, 2: array<int, array<string, mixed>>}
|
||||||
|
*/
|
||||||
|
private static function buildDeptTreeRows(array $entities, int $selectedDeptId, int $pageNo, int $pageSize): array
|
||||||
|
{
|
||||||
|
$childrenByPid = [];
|
||||||
|
foreach ($entities as $entity) {
|
||||||
|
$pid = (int)($entity['pid'] ?? 0);
|
||||||
|
$childrenByPid[$pid] ??= [];
|
||||||
|
$childrenByPid[$pid][] = (int)$entity['id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($childrenByPid as &$ids) {
|
||||||
|
usort($ids, static function (int $left, int $right) use ($entities): int {
|
||||||
|
$sortCompare = ((int)($entities[$right]['sort'] ?? 0)) <=> ((int)($entities[$left]['sort'] ?? 0));
|
||||||
|
if ($sortCompare !== 0) {
|
||||||
|
return $sortCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $left <=> $right;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
unset($ids);
|
||||||
|
|
||||||
|
$buildNode = function (int $deptId) use (&$buildNode, &$entities, $childrenByPid): array {
|
||||||
|
$node = $entities[$deptId];
|
||||||
|
$children = [];
|
||||||
|
foreach ($childrenByPid[$deptId] ?? [] as $childId) {
|
||||||
|
$childNode = $buildNode((int)$childId);
|
||||||
|
$children[] = $childNode;
|
||||||
|
$node['add_fans_count'] += (int)$childNode['add_fans_count'];
|
||||||
|
$node['total_open_count'] += (int)$childNode['total_open_count'];
|
||||||
|
$node['unreplied_count'] += (int)$childNode['unreplied_count'];
|
||||||
|
$node['paid_appointment_count'] += (int)$childNode['paid_appointment_count'];
|
||||||
|
$node['free_appointment_count'] += (int)$childNode['free_appointment_count'];
|
||||||
|
$node['appointment_total_count'] += (int)$childNode['appointment_total_count'];
|
||||||
|
$node['interview_count'] += (int)$childNode['interview_count'];
|
||||||
|
$node['completed_order_count'] += (int)$childNode['completed_order_count'];
|
||||||
|
$node['completed_order_amount'] = round((float)$node['completed_order_amount'] + (float)$childNode['completed_order_amount'], 2);
|
||||||
|
$node['account_cost'] = round((float)$node['account_cost'] + (float)$childNode['account_cost'], 2);
|
||||||
|
}
|
||||||
|
$node['children'] = $children;
|
||||||
|
return $node;
|
||||||
|
};
|
||||||
|
|
||||||
|
$rootIds = [];
|
||||||
|
foreach ($entities as $entity) {
|
||||||
|
$pid = (int)($entity['pid'] ?? 0);
|
||||||
|
if (!isset($entities[$pid])) {
|
||||||
|
$rootIds[] = (int)$entity['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($rootIds, static function (int $left, int $right) use ($entities): int {
|
||||||
|
$sortCompare = ((int)($entities[$right]['sort'] ?? 0)) <=> ((int)($entities[$left]['sort'] ?? 0));
|
||||||
|
if ($sortCompare !== 0) {
|
||||||
|
return $sortCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $left <=> $right;
|
||||||
|
});
|
||||||
|
|
||||||
|
$rootRows = array_map(static fn (int $deptId): array => self::finalizeDeptNode($buildNode($deptId)), $rootIds);
|
||||||
|
$virtualRoot = count($rootRows) === 1 && !empty($rootRows[0]['children']) ? $rootRows[0] : null;
|
||||||
|
|
||||||
|
if ($selectedDeptId > 0) {
|
||||||
|
$selectedNode = self::findDeptNode($rootRows, $selectedDeptId);
|
||||||
|
if ($selectedNode !== null) {
|
||||||
|
if ($virtualRoot !== null && (int)$selectedNode['id'] === (int)$virtualRoot['id']) {
|
||||||
|
$allRows = $selectedNode['children'] ?? [];
|
||||||
|
$chartRows = $selectedNode['children'] ?? [];
|
||||||
|
} else {
|
||||||
|
$allRows = [$selectedNode];
|
||||||
|
$chartRows = !empty($selectedNode['children']) ? $selectedNode['children'] : [$selectedNode];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$allRows = $virtualRoot !== null ? ($virtualRoot['children'] ?? []) : $rootRows;
|
||||||
|
$chartRows = $allRows;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$allRows = $virtualRoot !== null ? ($virtualRoot['children'] ?? []) : $rootRows;
|
||||||
|
$chartRows = $allRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
$offset = ($pageNo - 1) * $pageSize;
|
||||||
|
|
||||||
|
return [$allRows, array_slice($allRows, $offset, $pageSize), $chartRows];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $rows
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private static function findDeptNode(array $rows, int $deptId): ?array
|
||||||
|
{
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if ((int)($row['id'] ?? 0) === $deptId) {
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
$children = $row['children'] ?? [];
|
||||||
|
if (!is_array($children) || $children === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$matched = self::findDeptNode($children, $deptId);
|
||||||
|
if ($matched !== null) {
|
||||||
|
return $matched;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $node
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function finalizeDeptNode(array $node): array
|
||||||
|
{
|
||||||
|
$node['children'] = array_map(static fn (array $child): array => self::finalizeDeptNode($child), $node['children'] ?? []);
|
||||||
|
$paidAppointmentCount = (int)($node['paid_appointment_count'] ?? 0);
|
||||||
|
$freeAppointmentCount = (int)($node['free_appointment_count'] ?? 0);
|
||||||
|
$appointmentTotalCount = (int)($node['appointment_total_count'] ?? 0);
|
||||||
|
$interviewCount = (int)$node['interview_count'];
|
||||||
|
$addFansCount = (int)$node['add_fans_count'];
|
||||||
|
$totalOpenCount = (int)$node['total_open_count'];
|
||||||
|
$completedOrderCount = (int)$node['completed_order_count'];
|
||||||
|
$completedOrderAmount = round((float)$node['completed_order_amount'], 2);
|
||||||
|
$accountCost = round((float)$node['account_cost'], 2);
|
||||||
|
|
||||||
|
$node['paid_appointment_count'] = $paidAppointmentCount;
|
||||||
|
$node['free_appointment_count'] = $freeAppointmentCount;
|
||||||
|
$node['appointment_total_count'] = $appointmentTotalCount;
|
||||||
|
$node['completed_order_amount'] = $completedOrderAmount;
|
||||||
|
$node['account_cost'] = $accountCost;
|
||||||
|
$node['paid_appointment_rate'] = self::percent($paidAppointmentCount, $addFansCount);
|
||||||
|
$node['open_appointment_rate'] = self::percent($paidAppointmentCount, $totalOpenCount);
|
||||||
|
$node['interview_rate'] = self::percent($interviewCount, $appointmentTotalCount);
|
||||||
|
$node['receive_rate'] = self::percent($completedOrderCount, $addFansCount);
|
||||||
|
$node['interview_receive_rate'] = self::percent($completedOrderCount, $interviewCount);
|
||||||
|
$node['open_receive_rate'] = self::percent($completedOrderCount, $totalOpenCount);
|
||||||
|
$node['avg_unit_price'] = self::safeDivideMoney($completedOrderAmount, $completedOrderCount);
|
||||||
|
$node['cash_cost'] = self::safeDivideMoney($accountCost, $addFansCount);
|
||||||
|
$node['roi'] = self::safeDivideRatio($completedOrderAmount, $accountCost);
|
||||||
|
unset($node['sort'], $node['pid']);
|
||||||
|
|
||||||
|
return $node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $rows
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function buildSummary(array $rows): array
|
||||||
|
{
|
||||||
|
$summary = [
|
||||||
|
'add_fans_count' => 0,
|
||||||
|
'total_open_count' => 0,
|
||||||
|
'unreplied_count' => 0,
|
||||||
|
'paid_appointment_count' => 0,
|
||||||
|
'free_appointment_count' => 0,
|
||||||
|
'appointment_total_count' => 0,
|
||||||
|
'interview_count' => 0,
|
||||||
|
'completed_order_amount' => 0.0,
|
||||||
|
'completed_order_count' => 0,
|
||||||
|
'account_cost' => 0.0,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$summary['add_fans_count'] += (int)$row['add_fans_count'];
|
||||||
|
$summary['total_open_count'] += (int)$row['total_open_count'];
|
||||||
|
$summary['unreplied_count'] += (int)$row['unreplied_count'];
|
||||||
|
$summary['paid_appointment_count'] += (int)$row['paid_appointment_count'];
|
||||||
|
$summary['free_appointment_count'] += (int)$row['free_appointment_count'];
|
||||||
|
$summary['appointment_total_count'] += (int)$row['appointment_total_count'];
|
||||||
|
$summary['interview_count'] += (int)$row['interview_count'];
|
||||||
|
$summary['completed_order_count'] += (int)$row['completed_order_count'];
|
||||||
|
$summary['completed_order_amount'] = round($summary['completed_order_amount'] + (float)$row['completed_order_amount'], 2);
|
||||||
|
$summary['account_cost'] = round($summary['account_cost'] + (float)$row['account_cost'], 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary['paid_appointment_rate'] = self::percent($summary['paid_appointment_count'], $summary['add_fans_count']);
|
||||||
|
$summary['open_appointment_rate'] = self::percent($summary['paid_appointment_count'], $summary['total_open_count']);
|
||||||
|
$summary['interview_rate'] = self::percent($summary['interview_count'], $summary['appointment_total_count']);
|
||||||
|
$summary['receive_rate'] = self::percent($summary['completed_order_count'], $summary['add_fans_count']);
|
||||||
|
$summary['interview_receive_rate'] = self::percent($summary['completed_order_count'], $summary['interview_count']);
|
||||||
|
$summary['open_receive_rate'] = self::percent($summary['completed_order_count'], $summary['total_open_count']);
|
||||||
|
$summary['avg_unit_price'] = self::safeDivideMoney($summary['completed_order_amount'], $summary['completed_order_count']);
|
||||||
|
$summary['cash_cost'] = self::safeDivideMoney($summary['account_cost'], $summary['add_fans_count']);
|
||||||
|
$summary['roi'] = self::safeDivideRatio($summary['completed_order_amount'], $summary['account_cost']);
|
||||||
|
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $rows
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private static function buildCharts(array $rows): array
|
||||||
|
{
|
||||||
|
$topRows = array_slice($rows, 0, 10);
|
||||||
|
|
||||||
|
$ranking = [
|
||||||
|
'names' => [],
|
||||||
|
'amounts' => [],
|
||||||
|
'order_counts' => [],
|
||||||
|
'fan_counts' => [],
|
||||||
|
'rois' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($topRows as $row) {
|
||||||
|
$ranking['names'][] = $row['name'];
|
||||||
|
$ranking['amounts'][] = $row['completed_order_amount'];
|
||||||
|
$ranking['order_counts'][] = $row['completed_order_count'];
|
||||||
|
$ranking['fan_counts'][] = $row['add_fans_count'];
|
||||||
|
$ranking['rois'][] = $row['roi'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ranking' => $ranking,
|
||||||
|
'amount_share' => array_map(
|
||||||
|
static fn (array $row): array => ['name' => $row['name'], 'value' => $row['completed_order_amount']],
|
||||||
|
array_filter($topRows, static fn (array $row): bool => (float)$row['completed_order_amount'] > 0)
|
||||||
|
),
|
||||||
|
'fan_share' => array_map(
|
||||||
|
static fn (array $row): array => ['name' => $row['name'], 'value' => $row['add_fans_count']],
|
||||||
|
array_filter($topRows, static fn (array $row): bool => (int)$row['add_fans_count'] > 0)
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function percent(int $numerator, int $denominator): float
|
||||||
|
{
|
||||||
|
if ($denominator <= 0) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round(($numerator / $denominator) * 100, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function safeDivideMoney(float $numerator, int $denominator): float
|
||||||
|
{
|
||||||
|
if ($denominator <= 0) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round($numerator / $denominator, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function safeDivideRatio(float $numerator, float $denominator): float
|
||||||
|
{
|
||||||
|
if ($denominator <= 0) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round($numerator / $denominator, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- 新增综合转化统计菜单
|
||||||
|
-- 页面路径: /stats/conversion
|
||||||
|
-- 前端组件: /stats/conversion/index
|
||||||
|
-- 查询权限: stats.conversion/overview
|
||||||
|
|
||||||
|
INSERT INTO `zyt_system_menu`
|
||||||
|
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`)
|
||||||
|
VALUES
|
||||||
|
(0, 'M', '数据统计', 'el-icon-DataAnalysis', 160, '', '/stats', '', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||||
|
|
||||||
|
SET @stats_root_id = LAST_INSERT_ID();
|
||||||
|
|
||||||
|
INSERT INTO `zyt_system_menu`
|
||||||
|
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`)
|
||||||
|
VALUES
|
||||||
|
(@stats_root_id, 'C', '综合转化统计', '', 1, 'stats.conversion/overview', '/stats/conversion', '/stats/conversion/index', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
|
||||||
Reference in New Issue
Block a user