From e2aaee324ec336c363c8a69df91f10dd7777bdca Mon Sep 17 00:00:00 2001 From: ShengLong <452591453@qq.com> Date: Tue, 21 Apr 2026 15:56:04 +0800 Subject: [PATCH] 1 --- CLAUDE.md | 137 +++ admin/src/api/stats.ts | 5 + admin/src/router/index.ts | 12 +- admin/src/views/stats/conversion/index.vue | 617 ++++++++++++ .../controller/stats/ConversionController.php | 18 + .../adminapi/logic/stats/ConversionLogic.php | 944 ++++++++++++++++++ .../add_conversion_stats_menu.sql | 16 + 7 files changed, 1747 insertions(+), 2 deletions(-) create mode 100644 CLAUDE.md create mode 100644 admin/src/api/stats.ts create mode 100644 admin/src/views/stats/conversion/index.vue create mode 100644 server/app/adminapi/controller/stats/ConversionController.php create mode 100644 server/app/adminapi/logic/stats/ConversionLogic.php create mode 100644 server/sql/1.9.20260420/add_conversion_stats_menu.sql diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..d44ce2cb --- /dev/null +++ b/CLAUDE.md @@ -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//` (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 # 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//*.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: + +``` +/ +├── 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//*.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. diff --git a/admin/src/api/stats.ts b/admin/src/api/stats.ts new file mode 100644 index 00000000..5dfb95bd --- /dev/null +++ b/admin/src/api/stats.ts @@ -0,0 +1,5 @@ +import request from '@/utils/request' + +export function getConversionStatsOverview(params: any) { + return request.get({ url: '/stats.conversion/overview', params }) +} diff --git a/admin/src/router/index.ts b/admin/src/router/index.ts index aa44f2ca..c545605d 100644 --- a/admin/src/router/index.ts +++ b/admin/src/router/index.ts @@ -27,9 +27,10 @@ export function filterAsyncRoutes(routes: any[], firstRoute = true) { // 创建一条路由记录 export function createRouteRecord(route: any, firstRoute: boolean): RouteRecordRaw { + const normalizedPath = normalizeMenuPath(route.paths) //@ts-ignore 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), meta: { hidden: !route.is_show, @@ -59,8 +60,9 @@ export function createRouteRecord(route: any, firstRoute: boolean): RouteRecordR // 动态加载组件 export function loadRouteView(component: string) { try { + const normalizedComponent = normalizeMenuPath(component) const key = Object.keys(modules).find((key) => { - return key.includes(`/${component}.vue`) + return key.includes(`/${normalizedComponent}.vue`) }) if (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 { for (const route of routes) { diff --git a/admin/src/views/stats/conversion/index.vue b/admin/src/views/stats/conversion/index.vue new file mode 100644 index 00000000..3b66c666 --- /dev/null +++ b/admin/src/views/stats/conversion/index.vue @@ -0,0 +1,617 @@ + + + + + diff --git a/server/app/adminapi/controller/stats/ConversionController.php b/server/app/adminapi/controller/stats/ConversionController.php new file mode 100644 index 00000000..e8935964 --- /dev/null +++ b/server/app/adminapi/controller/stats/ConversionController.php @@ -0,0 +1,18 @@ +request->get()); + + return $this->data($result); + } +} diff --git a/server/app/adminapi/logic/stats/ConversionLogic.php b/server/app/adminapi/logic/stats/ConversionLogic.php new file mode 100644 index 00000000..70f5e095 --- /dev/null +++ b/server/app/adminapi/logic/stats/ConversionLogic.php @@ -0,0 +1,944 @@ + + */ + 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 + */ + 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> + */ + 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> + */ + 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> $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> + */ + 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 + */ + 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 + */ + 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> $entities + * @param int[] $entityIds + * @param array $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 + */ + 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 $row + * @param array $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> $entities + * @param int[] $entityIds + * @param array $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> $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> $entities + * @param int[] $entityIds + * @param array $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> $entities + * @param int[] $entityIds + * @param array $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 $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> $entities + * @return array> + */ + 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> $entities + * @return array{0: array>, 1: array>, 2: array>} + */ + 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> $rows + * @return array|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 $node + * @return array + */ + 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> $rows + * @return array + */ + 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> $rows + * @return array + */ + 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); + } +} diff --git a/server/sql/1.9.20260420/add_conversion_stats_menu.sql b/server/sql/1.9.20260420/add_conversion_stats_menu.sql new file mode 100644 index 00000000..66592219 --- /dev/null +++ b/server/sql/1.9.20260420/add_conversion_stats_menu.sql @@ -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());