From 183b6a1acf45ed62a0397491e2b75f13345a2f92 Mon Sep 17 00:00:00 2001 From: long <452591453@qq.com> Date: Fri, 22 May 2026 09:17:16 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(stats):=20=E6=96=B0=E5=A2=9E=E8=87=AA?= =?UTF-8?q?=E5=BD=95=E8=BD=AC=E5=8C=96=E7=BB=9F=E8=AE=A1=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支持后台用户手动录入个人业绩与账户消耗,独立计算转化指标,接入 DataScope 数据权限。 Co-authored-by: Cursor --- admin/src/api/self_input_stats.ts | 45 ++ .../views/stats/self_input/account_cost.vue | 168 ++++++++ .../src/views/stats/self_input/cost-edit.vue | 133 ++++++ admin/src/views/stats/self_input/index.vue | 393 ++++++++++++++++++ .../src/views/stats/self_input/yeji-edit.vue | 224 ++++++++++ .../stats/PersonalAccountCostController.php | 62 +++ .../stats/PersonalYejiController.php | 62 +++ .../controller/stats/SelfInputController.php | 18 + .../lists/stats/PersonalAccountCostLists.php | 65 +++ .../lists/stats/PersonalYejiLists.php | 60 +++ .../logic/stats/PersonalAccountCostLogic.php | 132 ++++++ .../logic/stats/PersonalStatsScopeTrait.php | 79 ++++ .../logic/stats/PersonalYejiLogic.php | 146 +++++++ .../adminapi/logic/stats/SelfInputLogic.php | 309 ++++++++++++++ .../stats/PersonalAccountCostValidate.php | 61 +++ .../validate/stats/PersonalYejiValidate.php | 68 +++ .../model/stats/PersonalAccountCost.php | 25 ++ .../app/common/model/stats/PersonalYeji.php | 25 ++ .../add_self_input_stats_menu.sql | 79 ++++ .../create_personal_account_cost_table.sql | 20 + .../create_personal_yeji_table.sql | 27 ++ 21 files changed, 2201 insertions(+) create mode 100644 admin/src/api/self_input_stats.ts create mode 100644 admin/src/views/stats/self_input/account_cost.vue create mode 100644 admin/src/views/stats/self_input/cost-edit.vue create mode 100644 admin/src/views/stats/self_input/index.vue create mode 100644 admin/src/views/stats/self_input/yeji-edit.vue create mode 100644 server/app/adminapi/controller/stats/PersonalAccountCostController.php create mode 100644 server/app/adminapi/controller/stats/PersonalYejiController.php create mode 100644 server/app/adminapi/controller/stats/SelfInputController.php create mode 100644 server/app/adminapi/lists/stats/PersonalAccountCostLists.php create mode 100644 server/app/adminapi/lists/stats/PersonalYejiLists.php create mode 100644 server/app/adminapi/logic/stats/PersonalAccountCostLogic.php create mode 100644 server/app/adminapi/logic/stats/PersonalStatsScopeTrait.php create mode 100644 server/app/adminapi/logic/stats/PersonalYejiLogic.php create mode 100644 server/app/adminapi/logic/stats/SelfInputLogic.php create mode 100644 server/app/adminapi/validate/stats/PersonalAccountCostValidate.php create mode 100644 server/app/adminapi/validate/stats/PersonalYejiValidate.php create mode 100644 server/app/common/model/stats/PersonalAccountCost.php create mode 100644 server/app/common/model/stats/PersonalYeji.php create mode 100644 server/sql/1.9.20260521/add_self_input_stats_menu.sql create mode 100644 server/sql/1.9.20260521/create_personal_account_cost_table.sql create mode 100644 server/sql/1.9.20260521/create_personal_yeji_table.sql diff --git a/admin/src/api/self_input_stats.ts b/admin/src/api/self_input_stats.ts new file mode 100644 index 00000000..2036e8de --- /dev/null +++ b/admin/src/api/self_input_stats.ts @@ -0,0 +1,45 @@ +import request from '@/utils/request' + +export function getSelfInputOverview(params: any) { + return request.get({ url: '/stats.self_input/overview', params }) +} + +export function personalYejiLists(params: any) { + return request.get({ url: '/stats.personal_yeji/lists', params }) +} + +export function personalYejiAdd(data: any) { + return request.post({ url: '/stats.personal_yeji/add', data }) +} + +export function personalYejiEdit(data: any) { + return request.post({ url: '/stats.personal_yeji/edit', data }) +} + +export function personalYejiDetail(params: any) { + return request.get({ url: '/stats.personal_yeji/detail', params }) +} + +export function personalYejiDelete(params: any) { + return request.post({ url: '/stats.personal_yeji/delete', params }) +} + +export function personalAccountCostLists(params: any) { + return request.get({ url: '/stats.personal_account_cost/lists', params }) +} + +export function personalAccountCostAdd(data: any) { + return request.post({ url: '/stats.personal_account_cost/add', data }) +} + +export function personalAccountCostEdit(data: any) { + return request.post({ url: '/stats.personal_account_cost/edit', data }) +} + +export function personalAccountCostDetail(params: any) { + return request.get({ url: '/stats.personal_account_cost/detail', params }) +} + +export function personalAccountCostDelete(params: any) { + return request.post({ url: '/stats.personal_account_cost/delete', params }) +} diff --git a/admin/src/views/stats/self_input/account_cost.vue b/admin/src/views/stats/self_input/account_cost.vue new file mode 100644 index 00000000..ea75e188 --- /dev/null +++ b/admin/src/views/stats/self_input/account_cost.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/admin/src/views/stats/self_input/cost-edit.vue b/admin/src/views/stats/self_input/cost-edit.vue new file mode 100644 index 00000000..dbee201d --- /dev/null +++ b/admin/src/views/stats/self_input/cost-edit.vue @@ -0,0 +1,133 @@ + + + diff --git a/admin/src/views/stats/self_input/index.vue b/admin/src/views/stats/self_input/index.vue new file mode 100644 index 00000000..fb81c777 --- /dev/null +++ b/admin/src/views/stats/self_input/index.vue @@ -0,0 +1,393 @@ + + + + + diff --git a/admin/src/views/stats/self_input/yeji-edit.vue b/admin/src/views/stats/self_input/yeji-edit.vue new file mode 100644 index 00000000..743f282d --- /dev/null +++ b/admin/src/views/stats/self_input/yeji-edit.vue @@ -0,0 +1,224 @@ + + + diff --git a/server/app/adminapi/controller/stats/PersonalAccountCostController.php b/server/app/adminapi/controller/stats/PersonalAccountCostController.php new file mode 100644 index 00000000..43ff292a --- /dev/null +++ b/server/app/adminapi/controller/stats/PersonalAccountCostController.php @@ -0,0 +1,62 @@ +dataLists(new PersonalAccountCostLists()); + } + + public function add() + { + $params = (new PersonalAccountCostValidate())->post()->goCheck('add'); + $result = PersonalAccountCostLogic::add($params, $this->adminId, (string) ($this->adminInfo['name'] ?? '')); + if ($result === false) { + return $this->fail(PersonalAccountCostLogic::getError()); + } + + return $this->success('添加成功', [], 1, 1); + } + + public function edit() + { + $params = (new PersonalAccountCostValidate())->post()->goCheck('edit'); + $result = PersonalAccountCostLogic::edit($params, $this->adminId, (string) ($this->adminInfo['name'] ?? ''), $this->adminInfo); + if ($result === false) { + return $this->fail(PersonalAccountCostLogic::getError()); + } + + return $this->success('编辑成功', [], 1, 1); + } + + public function detail() + { + $params = (new PersonalAccountCostValidate())->goCheck('detail'); + $detail = PersonalAccountCostLogic::detail((int) $params['id'], $this->adminId, $this->adminInfo); + if ($detail === []) { + return $this->fail('记录不存在或无权查看'); + } + + return $this->data($detail); + } + + public function delete() + { + $params = (new PersonalAccountCostValidate())->post()->goCheck('delete'); + $result = PersonalAccountCostLogic::delete((int) $params['id'], $this->adminId, $this->adminInfo); + if ($result === false) { + return $this->fail(PersonalAccountCostLogic::getError()); + } + + return $this->success('删除成功', [], 1, 1); + } +} diff --git a/server/app/adminapi/controller/stats/PersonalYejiController.php b/server/app/adminapi/controller/stats/PersonalYejiController.php new file mode 100644 index 00000000..6b7768f8 --- /dev/null +++ b/server/app/adminapi/controller/stats/PersonalYejiController.php @@ -0,0 +1,62 @@ +dataLists(new PersonalYejiLists()); + } + + public function add() + { + $params = (new PersonalYejiValidate())->post()->goCheck('add'); + $result = PersonalYejiLogic::add($params, $this->adminId, (string) ($this->adminInfo['name'] ?? '')); + if ($result === false) { + return $this->fail(PersonalYejiLogic::getError()); + } + + return $this->success('添加成功', [], 1, 1); + } + + public function edit() + { + $params = (new PersonalYejiValidate())->post()->goCheck('edit'); + $result = PersonalYejiLogic::edit($params, $this->adminId, (string) ($this->adminInfo['name'] ?? ''), $this->adminInfo); + if ($result === false) { + return $this->fail(PersonalYejiLogic::getError()); + } + + return $this->success('编辑成功', [], 1, 1); + } + + public function detail() + { + $params = (new PersonalYejiValidate())->goCheck('detail'); + $detail = PersonalYejiLogic::detail((int) $params['id'], $this->adminId, $this->adminInfo); + if ($detail === []) { + return $this->fail('记录不存在或无权查看'); + } + + return $this->data($detail); + } + + public function delete() + { + $params = (new PersonalYejiValidate())->post()->goCheck('delete'); + $result = PersonalYejiLogic::delete((int) $params['id'], $this->adminId, $this->adminInfo); + if ($result === false) { + return $this->fail(PersonalYejiLogic::getError()); + } + + return $this->success('删除成功', [], 1, 1); + } +} diff --git a/server/app/adminapi/controller/stats/SelfInputController.php b/server/app/adminapi/controller/stats/SelfInputController.php new file mode 100644 index 00000000..02419c4a --- /dev/null +++ b/server/app/adminapi/controller/stats/SelfInputController.php @@ -0,0 +1,18 @@ +request->get(), $this->adminId, $this->adminInfo); + + return $this->data($result); + } +} diff --git a/server/app/adminapi/lists/stats/PersonalAccountCostLists.php b/server/app/adminapi/lists/stats/PersonalAccountCostLists.php new file mode 100644 index 00000000..dd6c5e7b --- /dev/null +++ b/server/app/adminapi/lists/stats/PersonalAccountCostLists.php @@ -0,0 +1,65 @@ + ['media_source', 'creator_name', 'remark'], + ]; + } + + private function baseQuery() + { + $query = PersonalAccountCost::where($this->searchWhere); + $this->applyDataScopeByOwner($query, 'creator_id'); + + if (!empty($this->params['start_date']) && !empty($this->params['end_date'])) { + $query->whereBetween('cost_date', [$this->params['start_date'], $this->params['end_date']]); + } elseif (!empty($this->params['start_date'])) { + $query->where('cost_date', '>=', $this->params['start_date']); + } elseif (!empty($this->params['end_date'])) { + $query->where('cost_date', '<=', $this->params['end_date']); + } + + if (!empty($this->params['media_source'])) { + $query->whereLike('media_source', '%' . trim((string) $this->params['media_source']) . '%'); + } + + return $query; + } + + public function lists(): array + { + return $this->baseQuery() + ->order(['cost_date' => 'desc', 'id' => 'desc']) + ->limit($this->limitOffset, $this->limitLength) + ->select() + ->toArray(); + } + + public function count(): int + { + return (int) $this->baseQuery()->count(); + } + + public function extend(): array + { + return [ + 'total_amount' => round((float) $this->baseQuery()->sum('amount'), 2), + 'days_count' => (int) $this->baseQuery()->distinct(true)->count('cost_date'), + ]; + } +} diff --git a/server/app/adminapi/lists/stats/PersonalYejiLists.php b/server/app/adminapi/lists/stats/PersonalYejiLists.php new file mode 100644 index 00000000..b9038090 --- /dev/null +++ b/server/app/adminapi/lists/stats/PersonalYejiLists.php @@ -0,0 +1,60 @@ + ['media_source', 'creator_name', 'remark'], + ]; + } + + private function baseQuery() + { + $query = PersonalYeji::where($this->searchWhere); + $this->applyDataScopeByOwner($query, 'creator_id'); + + if (!empty($this->params['start_date']) && !empty($this->params['end_date'])) { + $query->whereBetween('yeji_date', [$this->params['start_date'], $this->params['end_date']]); + } elseif (!empty($this->params['start_date'])) { + $query->where('yeji_date', '>=', $this->params['start_date']); + } elseif (!empty($this->params['end_date'])) { + $query->where('yeji_date', '<=', $this->params['end_date']); + } + + if (!empty($this->params['media_source'])) { + $query->whereLike('media_source', '%' . trim((string) $this->params['media_source']) . '%'); + } + + if (!empty($this->params['creator_id'])) { + $query->where('creator_id', (int) $this->params['creator_id']); + } + + return $query; + } + + public function lists(): array + { + return $this->baseQuery() + ->order(['yeji_date' => 'desc', 'id' => 'desc']) + ->limit($this->limitOffset, $this->limitLength) + ->select() + ->toArray(); + } + + public function count(): int + { + return (int) $this->baseQuery()->count(); + } +} diff --git a/server/app/adminapi/logic/stats/PersonalAccountCostLogic.php b/server/app/adminapi/logic/stats/PersonalAccountCostLogic.php new file mode 100644 index 00000000..4b07a113 --- /dev/null +++ b/server/app/adminapi/logic/stats/PersonalAccountCostLogic.php @@ -0,0 +1,132 @@ + $costDate, + 'media_source' => $mediaSource, + 'amount' => round((float) ($params['amount'] ?? 0), 2), + 'remark' => (string) ($params['remark'] ?? ''), + 'creator_id' => $adminId, + 'creator_name' => $adminName, + 'updater_id' => $adminId, + 'updater_name' => $adminName, + 'dept_id' => self::resolvePrimaryDeptId($adminId), + ]); + + return true; + } catch (\Throwable $e) { + self::setError($e->getMessage()); + + return false; + } + } + + public static function edit(array $params, int $adminId, string $adminName, array $adminInfo): bool + { + try { + $model = PersonalAccountCost::find($params['id']); + if (!$model) { + self::setError('记录不存在'); + + return false; + } + + if (!self::assertRecordVisible($adminId, $adminInfo, (int) $model->creator_id)) { + self::setError('无权操作该记录'); + + return false; + } + + if (!self::canMutateRecord($adminId, $adminInfo, (int) $model->creator_id)) { + self::setError('仅可编辑本人录入的账户消耗'); + + return false; + } + + $model->amount = round((float) ($params['amount'] ?? 0), 2); + $model->remark = (string) ($params['remark'] ?? ''); + $model->updater_id = $adminId; + $model->updater_name = $adminName; + $model->save(); + + return true; + } catch (\Throwable $e) { + self::setError($e->getMessage()); + + return false; + } + } + + public static function delete(int $id, int $adminId, array $adminInfo): bool + { + try { + $model = PersonalAccountCost::find($id); + if (!$model) { + self::setError('记录不存在'); + + return false; + } + + if (!self::assertRecordVisible($adminId, $adminInfo, (int) $model->creator_id)) { + self::setError('无权操作该记录'); + + return false; + } + + if (!self::canMutateRecord($adminId, $adminInfo, (int) $model->creator_id)) { + self::setError('仅可删除本人录入的账户消耗'); + + return false; + } + + $model->delete(); + + return true; + } catch (\Throwable $e) { + self::setError($e->getMessage()); + + return false; + } + } + + public static function detail(int $id, int $adminId, array $adminInfo): array + { + $model = PersonalAccountCost::find($id); + if (!$model) { + return []; + } + + if (!self::assertRecordVisible($adminId, $adminInfo, (int) $model->creator_id)) { + return []; + } + + return $model->toArray(); + } +} diff --git a/server/app/adminapi/logic/stats/PersonalStatsScopeTrait.php b/server/app/adminapi/logic/stats/PersonalStatsScopeTrait.php new file mode 100644 index 00000000..c4abe25e --- /dev/null +++ b/server/app/adminapi/logic/stats/PersonalStatsScopeTrait.php @@ -0,0 +1,79 @@ +value('dept_id'); + + return (int) ($deptId ?: 0); + } + + protected static function normalizeMediaSource(string $mediaSource): string + { + return trim($mediaSource); + } + + /** + * @return array|null + */ + protected static function getVisibleCreatorIds(int $adminId, array $adminInfo): ?array + { + return DataScopeService::getVisibleAdminIds($adminId, $adminInfo); + } + + protected static function canMutateRecord(int $adminId, array $adminInfo, int $creatorId): bool + { + if ((int) ($adminInfo['root'] ?? 0) === 1) { + return true; + } + + return $adminId > 0 && $adminId === $creatorId; + } + + protected static function assertRecordVisible(int $adminId, array $adminInfo, int $creatorId): bool + { + $visibleIds = self::getVisibleCreatorIds($adminId, $adminInfo); + if ($visibleIds === null) { + return true; + } + + return in_array($creatorId, $visibleIds, true); + } + + protected static function isYejiDuplicate(int $creatorId, string $yejiDate, string $mediaSource, int $excludeId = 0): bool + { + $query = PersonalYeji::where('creator_id', $creatorId) + ->where('yeji_date', $yejiDate) + ->where('media_source', $mediaSource); + if ($excludeId > 0) { + $query->where('id', '<>', $excludeId); + } + + return $query->count() > 0; + } + + protected static function isCostDuplicate(int $creatorId, string $costDate, string $mediaSource, int $excludeId = 0): bool + { + $query = PersonalAccountCost::where('creator_id', $creatorId) + ->where('cost_date', $costDate) + ->where('media_source', $mediaSource); + if ($excludeId > 0) { + $query->where('id', '<>', $excludeId); + } + + return $query->count() > 0; + } +} diff --git a/server/app/adminapi/logic/stats/PersonalYejiLogic.php b/server/app/adminapi/logic/stats/PersonalYejiLogic.php new file mode 100644 index 00000000..aae9f884 --- /dev/null +++ b/server/app/adminapi/logic/stats/PersonalYejiLogic.php @@ -0,0 +1,146 @@ + $yejiDate, + 'media_source' => $mediaSource, + 'add_fans_count' => (int) ($params['add_fans_count'] ?? 0), + 'total_open_count' => (int) ($params['total_open_count'] ?? 0), + 'unreplied_count' => (int) ($params['unreplied_count'] ?? 0), + 'paid_appointment_count' => (int) ($params['paid_appointment_count'] ?? 0), + 'free_appointment_count' => (int) ($params['free_appointment_count'] ?? 0), + 'interview_count' => (int) ($params['interview_count'] ?? 0), + 'order_amount' => round((float) ($params['order_amount'] ?? 0), 2), + 'completed_order_count' => (int) ($params['completed_order_count'] ?? 0), + 'remark' => (string) ($params['remark'] ?? ''), + 'creator_id' => $adminId, + 'creator_name' => $adminName, + 'updater_id' => $adminId, + 'updater_name' => $adminName, + 'dept_id' => self::resolvePrimaryDeptId($adminId), + ]); + + return true; + } catch (\Throwable $e) { + self::setError($e->getMessage()); + + return false; + } + } + + public static function edit(array $params, int $adminId, string $adminName, array $adminInfo): bool + { + try { + $model = PersonalYeji::find($params['id']); + if (!$model) { + self::setError('记录不存在'); + + return false; + } + + if (!self::assertRecordVisible($adminId, $adminInfo, (int) $model->creator_id)) { + self::setError('无权操作该记录'); + + return false; + } + + if (!self::canMutateRecord($adminId, $adminInfo, (int) $model->creator_id)) { + self::setError('仅可编辑本人录入的业绩'); + + return false; + } + + $model->add_fans_count = (int) ($params['add_fans_count'] ?? 0); + $model->total_open_count = (int) ($params['total_open_count'] ?? 0); + $model->unreplied_count = (int) ($params['unreplied_count'] ?? 0); + $model->paid_appointment_count = (int) ($params['paid_appointment_count'] ?? 0); + $model->free_appointment_count = (int) ($params['free_appointment_count'] ?? 0); + $model->interview_count = (int) ($params['interview_count'] ?? 0); + $model->order_amount = round((float) ($params['order_amount'] ?? 0), 2); + $model->completed_order_count = (int) ($params['completed_order_count'] ?? 0); + $model->remark = (string) ($params['remark'] ?? ''); + $model->updater_id = $adminId; + $model->updater_name = $adminName; + $model->save(); + + return true; + } catch (\Throwable $e) { + self::setError($e->getMessage()); + + return false; + } + } + + public static function delete(int $id, int $adminId, array $adminInfo): bool + { + try { + $model = PersonalYeji::find($id); + if (!$model) { + self::setError('记录不存在'); + + return false; + } + + if (!self::assertRecordVisible($adminId, $adminInfo, (int) $model->creator_id)) { + self::setError('无权操作该记录'); + + return false; + } + + if (!self::canMutateRecord($adminId, $adminInfo, (int) $model->creator_id)) { + self::setError('仅可删除本人录入的业绩'); + + return false; + } + + $model->delete(); + + return true; + } catch (\Throwable $e) { + self::setError($e->getMessage()); + + return false; + } + } + + public static function detail(int $id, int $adminId, array $adminInfo): array + { + $model = PersonalYeji::find($id); + if (!$model) { + return []; + } + + if (!self::assertRecordVisible($adminId, $adminInfo, (int) $model->creator_id)) { + return []; + } + + return $model->toArray(); + } +} diff --git a/server/app/adminapi/logic/stats/SelfInputLogic.php b/server/app/adminapi/logic/stats/SelfInputLogic.php new file mode 100644 index 00000000..7d15fe86 --- /dev/null +++ b/server/app/adminapi/logic/stats/SelfInputLogic.php @@ -0,0 +1,309 @@ +whereLike('media_source', '%' . $mediaSource . '%'); + } + + $count = (int) (clone $yejiQuery)->count(); + $rows = (clone $yejiQuery) + ->order(['yeji_date' => 'desc', 'id' => 'desc']) + ->page($pageNo, $pageSize) + ->select() + ->toArray(); + + $costMap = self::loadAccountCostMap($startDate, $endDate, $adminId, $adminInfo, $mediaSource); + + $lists = []; + foreach ($rows as $row) { + $entity = self::normalizeYejiRow($row); + $costKey = self::buildCostKey( + (int) $entity['creator_id'], + (string) $entity['yeji_date'], + (string) $entity['media_source'] + ); + $entity['account_cost'] = round((float) ($costMap[$costKey] ?? 0), 2); + $lists[] = self::finalizeMetrics($entity); + } + + $allYejiRows = self::buildYejiQuery($startDate, $endDate, $adminId, $adminInfo); + if ($mediaSource !== '') { + $allYejiRows->whereLike('media_source', '%' . $mediaSource . '%'); + } + $allYeji = $allYejiRows->select()->toArray(); + + $summaryCostMap = $costMap; + $summaryBase = self::emptyMetrics(); + foreach ($allYeji as $row) { + $entity = self::normalizeYejiRow($row); + $costKey = self::buildCostKey( + (int) $entity['creator_id'], + (string) $entity['yeji_date'], + (string) $entity['media_source'] + ); + $entity['account_cost'] = round((float) ($summaryCostMap[$costKey] ?? 0), 2); + unset($summaryCostMap[$costKey]); + $entity = self::finalizeMetrics($entity); + $summaryBase = self::accumulateMetrics($summaryBase, $entity); + } + + foreach ($summaryCostMap as $amount) { + $summaryBase['account_cost'] += round((float) $amount, 2); + } + + $summary = self::finalizeMetrics($summaryBase); + + return [ + 'summary' => $summary, + 'lists' => $lists, + 'count' => $count, + 'page_no' => $pageNo, + 'page_size' => $pageSize, + 'extend' => [ + 'summary' => $summary, + 'date_range' => [$startDate, $endDate], + ], + ]; + } + + private static function buildYejiQuery(string $startDate, string $endDate, int $adminId, array $adminInfo) + { + $query = PersonalYeji::whereBetween('yeji_date', [$startDate, $endDate]); + $visibleIds = self::getVisibleCreatorIds($adminId, $adminInfo); + if ($visibleIds === []) { + $query->whereRaw('0 = 1'); + } elseif ($visibleIds !== null) { + $query->whereIn('creator_id', $visibleIds); + } + + return $query; + } + + /** + * @return array + */ + private static function loadAccountCostMap( + string $startDate, + string $endDate, + int $adminId, + array $adminInfo, + string $mediaSource + ): array { + $query = PersonalAccountCost::whereBetween('cost_date', [$startDate, $endDate]); + $visibleIds = self::getVisibleCreatorIds($adminId, $adminInfo); + if ($visibleIds === []) { + return []; + } + if ($visibleIds !== null) { + $query->whereIn('creator_id', $visibleIds); + } + if ($mediaSource !== '') { + $query->whereLike('media_source', '%' . $mediaSource . '%'); + } + + $rows = $query + ->fieldRaw('creator_id, cost_date, media_source, SUM(amount) AS total_amount') + ->group('creator_id, cost_date, media_source') + ->select() + ->toArray(); + + $map = []; + foreach ($rows as $row) { + $key = self::buildCostKey( + (int) $row['creator_id'], + (string) $row['cost_date'], + (string) $row['media_source'] + ); + $map[$key] = round((float) ($row['total_amount'] ?? 0), 2); + } + + return $map; + } + + private static function buildCostKey(int $creatorId, string $date, string $mediaSource): string + { + return $creatorId . '|' . $date . '|' . self::normalizeMediaSource($mediaSource); + } + + /** + * @param array $row + * @return array + */ + private static function normalizeYejiRow(array $row): array + { + return [ + 'id' => (int) ($row['id'] ?? 0), + 'yeji_date' => (string) ($row['yeji_date'] ?? ''), + 'media_source' => (string) ($row['media_source'] ?? ''), + 'creator_id' => (int) ($row['creator_id'] ?? 0), + 'creator_name' => (string) ($row['creator_name'] ?? ''), + 'remark' => (string) ($row['remark'] ?? ''), + 'add_fans_count' => (int) ($row['add_fans_count'] ?? 0), + 'total_open_count' => (int) ($row['total_open_count'] ?? 0), + 'unreplied_count' => (int) ($row['unreplied_count'] ?? 0), + 'paid_appointment_count' => (int) ($row['paid_appointment_count'] ?? 0), + 'free_appointment_count' => (int) ($row['free_appointment_count'] ?? 0), + 'interview_count' => (int) ($row['interview_count'] ?? 0), + 'order_amount' => round((float) ($row['order_amount'] ?? 0), 2), + 'completed_order_count' => (int) ($row['completed_order_count'] ?? 0), + 'account_cost' => 0.0, + ]; + } + + /** + * @return array + */ + private static function emptyMetrics(): array + { + return [ + '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, + 'order_amount' => 0.0, + 'completed_order_count' => 0, + 'account_cost' => 0.0, + ]; + } + + /** + * @param array $base + * @param array $row + * @return array + */ + private static function accumulateMetrics(array $base, array $row): array + { + $base['add_fans_count'] += (int) ($row['add_fans_count'] ?? 0); + $base['total_open_count'] += (int) ($row['total_open_count'] ?? 0); + $base['unreplied_count'] += (int) ($row['unreplied_count'] ?? 0); + $base['paid_appointment_count'] += (int) ($row['paid_appointment_count'] ?? 0); + $base['free_appointment_count'] += (int) ($row['free_appointment_count'] ?? 0); + $base['interview_count'] += (int) ($row['interview_count'] ?? 0); + $base['order_amount'] = round((float) $base['order_amount'] + (float) ($row['order_amount'] ?? 0), 2); + $base['completed_order_count'] += (int) ($row['completed_order_count'] ?? 0); + $base['account_cost'] = round((float) $base['account_cost'] + (float) ($row['account_cost'] ?? 0), 2); + + return $base; + } + + /** + * @param array $entity + * @return array + */ + private static function finalizeMetrics(array $entity): array + { + $paidAppointmentCount = (int) ($entity['paid_appointment_count'] ?? 0); + $freeAppointmentCount = (int) ($entity['free_appointment_count'] ?? 0); + $appointmentTotalCount = $paidAppointmentCount + $freeAppointmentCount; + $interviewCount = (int) ($entity['interview_count'] ?? 0); + $addFansCount = (int) ($entity['add_fans_count'] ?? 0); + $totalOpenCount = (int) ($entity['total_open_count'] ?? 0); + $completedOrderCount = (int) ($entity['completed_order_count'] ?? 0); + $orderAmount = round((float) ($entity['order_amount'] ?? 0), 2); + $accountCost = round((float) ($entity['account_cost'] ?? 0), 2); + + $entity['appointment_total_count'] = $appointmentTotalCount; + $entity['order_amount'] = $orderAmount; + $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($orderAmount, $completedOrderCount); + $entity['cash_cost'] = self::safeDivideMoney($accountCost, $addFansCount); + $entity['roi'] = self::safeDivideRatio($orderAmount, $accountCost); + + return $entity; + } + + /** + * @return array{0: string, 1: string} + */ + private static function resolveTimeRange(array $params): array + { + $today = date('Y-m-d'); + $timeType = (string) ($params['time_type'] ?? 'today'); + + switch ($timeType) { + case 'yesterday': + $startDate = date('Y-m-d', strtotime('-1 day')); + $endDate = $startDate; + break; + 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 === '') { + $startDate = $today; + $endDate = $today; + } elseif ($startDate > $endDate) { + [$startDate, $endDate] = [$endDate, $startDate]; + } + break; + case 'today': + default: + $startDate = $today; + $endDate = $today; + } + + return [$startDate, $endDate]; + } + + 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/app/adminapi/validate/stats/PersonalAccountCostValidate.php b/server/app/adminapi/validate/stats/PersonalAccountCostValidate.php new file mode 100644 index 00000000..01bc382a --- /dev/null +++ b/server/app/adminapi/validate/stats/PersonalAccountCostValidate.php @@ -0,0 +1,61 @@ + 'require|checkExists', + 'cost_date' => 'require|dateFormat:Y-m-d', + 'media_source' => 'require|max:120', + 'amount' => 'require|float|egt:0', + 'remark' => 'max:255', + ]; + + protected $message = [ + 'id.require' => '参数缺失', + 'cost_date.require' => '请选择日期', + 'cost_date.dateFormat' => '日期格式错误', + 'media_source.require' => '请填写自媒体来源', + 'media_source.max' => '自媒体来源不能超过120个字符', + 'amount.require' => '请输入账户消耗金额', + 'amount.float' => '账户消耗金额格式错误', + 'amount.egt' => '账户消耗金额不能小于0', + 'remark.max' => '备注不能超过255个字符', + ]; + + public function sceneAdd() + { + return $this->remove('id', true); + } + + public function sceneEdit() + { + return $this->remove('cost_date', true)->remove('media_source', true); + } + + public function sceneDetail() + { + return $this->only(['id']); + } + + public function sceneDelete() + { + return $this->only(['id']); + } + + public function checkExists($value) + { + $model = PersonalAccountCost::find($value); + if (!$model) { + return '记录不存在'; + } + + return true; + } +} diff --git a/server/app/adminapi/validate/stats/PersonalYejiValidate.php b/server/app/adminapi/validate/stats/PersonalYejiValidate.php new file mode 100644 index 00000000..63f7c774 --- /dev/null +++ b/server/app/adminapi/validate/stats/PersonalYejiValidate.php @@ -0,0 +1,68 @@ + 'require|checkExists', + 'yeji_date' => 'require|dateFormat:Y-m-d', + 'media_source' => 'require|max:120', + 'add_fans_count' => 'require|integer|egt:0', + 'total_open_count' => 'integer|egt:0', + 'unreplied_count' => 'integer|egt:0', + 'paid_appointment_count' => 'integer|egt:0', + 'free_appointment_count' => 'integer|egt:0', + 'interview_count' => 'integer|egt:0', + 'order_amount' => 'float|egt:0', + 'completed_order_count' => 'integer|egt:0', + 'remark' => 'max:255', + ]; + + protected $message = [ + 'id.require' => '参数缺失', + 'yeji_date.require' => '请选择业绩日期', + 'yeji_date.dateFormat' => '业绩日期格式错误', + 'media_source.require' => '请填写自媒体来源', + 'media_source.max' => '自媒体来源不能超过120个字符', + 'add_fans_count.require' => '请填写加粉数', + 'add_fans_count.integer' => '加粉数格式错误', + 'add_fans_count.egt' => '加粉数不能小于0', + 'remark.max' => '备注不能超过255个字符', + ]; + + public function sceneAdd() + { + return $this->remove('id', true); + } + + public function sceneEdit() + { + return $this->remove('yeji_date', true)->remove('media_source', true); + } + + public function sceneDetail() + { + return $this->only(['id']); + } + + public function sceneDelete() + { + return $this->only(['id']); + } + + public function checkExists($value) + { + $model = PersonalYeji::find($value); + if (!$model) { + return '记录不存在'; + } + + return true; + } +} diff --git a/server/app/common/model/stats/PersonalAccountCost.php b/server/app/common/model/stats/PersonalAccountCost.php new file mode 100644 index 00000000..aacf9d8e --- /dev/null +++ b/server/app/common/model/stats/PersonalAccountCost.php @@ -0,0 +1,25 @@ + Date: Fri, 22 May 2026 11:47:15 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(stats/self=5Finput):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E4=BF=9D=E5=AD=98=E4=B8=8D=E5=85=A5=E5=BA=93/?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=20404=EF=BC=8C=E8=A1=A5=E5=85=A8=E9=83=A8?= =?UTF-8?q?=E9=97=A8=E4=B8=8E=E8=87=AA=E5=AA=92=E4=BD=93=E6=9D=A5=E6=BA=90?= =?UTF-8?q?=E4=B8=8B=E6=8B=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 模型去掉 defaultSoftDelete=0,避免 ThinkPHP 软删过滤导致新增记录"隐形" - 菜单 paths 改相对路径并新增「自录数据统计」目录,迁移已有菜单 - Lists/Overview 补部门关联、支持按部门筛选 - 数据权限改由 self_input_stats_view_all_roles 白名单 + DataScope 控制, 编辑/删除完全交给按钮权限校验(移除"仅本人可改"硬限制) - 新增 /stats.self_input/mediaSourceOptions 接口,统计页/账户消耗页/录入弹窗 共用 useMediaSourceOptions + MediaSourceSelect,去重动态加载 Co-authored-by: Cursor --- admin/src/api/self_input_stats.ts | 5 + .../stats/self_input/MediaSourceSelect.vue | 55 ++++++++ .../views/stats/self_input/account_cost.vue | 114 ++++++++++++++--- .../src/views/stats/self_input/cost-edit.vue | 27 ++-- admin/src/views/stats/self_input/index.vue | 120 ++++++++++++++---- .../stats/self_input/useMediaSourceOptions.ts | 33 +++++ .../src/views/stats/self_input/yeji-edit.vue | 28 ++-- .../controller/stats/SelfInputController.php | 10 ++ .../http/middleware/AuthMiddleware.php | 13 ++ .../lists/stats/PersonalAccountCostLists.php | 18 ++- .../lists/stats/PersonalYejiLists.php | 18 ++- .../logic/stats/PersonalAccountCostLogic.php | 12 -- .../logic/stats/PersonalStatsScopeTrait.php | 117 +++++++++++++++-- .../logic/stats/PersonalYejiLogic.php | 12 -- .../adminapi/logic/stats/SelfInputLogic.php | 82 +++++++++--- .../model/stats/PersonalAccountCost.php | 2 - .../app/common/model/stats/PersonalYeji.php | 2 - server/config/project.php | 5 + .../add_self_input_stats_menu.sql | 78 +++++++++++- 19 files changed, 621 insertions(+), 130 deletions(-) create mode 100644 admin/src/views/stats/self_input/MediaSourceSelect.vue create mode 100644 admin/src/views/stats/self_input/useMediaSourceOptions.ts diff --git a/admin/src/api/self_input_stats.ts b/admin/src/api/self_input_stats.ts index 2036e8de..bea4cf1a 100644 --- a/admin/src/api/self_input_stats.ts +++ b/admin/src/api/self_input_stats.ts @@ -4,6 +4,11 @@ export function getSelfInputOverview(params: any) { return request.get({ url: '/stats.self_input/overview', params }) } +/** 自媒体来源下拉(业绩+账户消耗已录入值去重) */ +export function getSelfInputMediaSourceOptions() { + return request.get({ url: '/stats.self_input/mediaSourceOptions' }) +} + export function personalYejiLists(params: any) { return request.get({ url: '/stats.personal_yeji/lists', params }) } diff --git a/admin/src/views/stats/self_input/MediaSourceSelect.vue b/admin/src/views/stats/self_input/MediaSourceSelect.vue new file mode 100644 index 00000000..ef458473 --- /dev/null +++ b/admin/src/views/stats/self_input/MediaSourceSelect.vue @@ -0,0 +1,55 @@ + + + diff --git a/admin/src/views/stats/self_input/account_cost.vue b/admin/src/views/stats/self_input/account_cost.vue index ea75e188..7438aa7f 100644 --- a/admin/src/views/stats/self_input/account_cost.vue +++ b/admin/src/views/stats/self_input/account_cost.vue @@ -21,13 +21,31 @@ v-model:endTime="queryParams.end_date" /> - - + + + + @@ -41,9 +59,7 @@ 查询 重置 - - 返回统计 - + 返回统计 @@ -66,12 +82,17 @@ + + + diff --git a/admin/src/views/stats/self_input/cost-edit.vue b/admin/src/views/stats/self_input/cost-edit.vue index 7e1ad424..4a113a5a 100644 --- a/admin/src/views/stats/self_input/cost-edit.vue +++ b/admin/src/views/stats/self_input/cost-edit.vue @@ -23,8 +23,9 @@ @@ -63,13 +64,16 @@ import { import Popup from '@/components/popup/index.vue' import MediaSourceSelect from './MediaSourceSelect.vue' +import type { MediaSourceOption } from './useMediaSourceOptions' -const props = withDefaults( +withDefaults( defineProps<{ - mediaSourceOptions?: string[] + mediaSourceOptions?: MediaSourceOption[] + mediaSourceLoading?: boolean }>(), { mediaSourceOptions: () => [], + mediaSourceLoading: false, } ) diff --git a/admin/src/views/stats/self_input/index.vue b/admin/src/views/stats/self_input/index.vue index a0eeeede..349a4acc 100644 --- a/admin/src/views/stats/self_input/index.vue +++ b/admin/src/views/stats/self_input/index.vue @@ -62,7 +62,7 @@
-
+
{{ card.label }}
{{ renderMetric(card.key, overview.summary, card.type) }} @@ -105,14 +105,22 @@ - + @@ -210,8 +219,12 @@ const deptTreeProps = { const overview = reactive>({ date_range: [], summary: {}, + can_view_finance: false, }) +const FINANCE_KEYS = new Set(['account_cost', 'cash_cost', 'roi']) +const canViewFinance = computed(() => Boolean(overview.can_view_finance)) + const queryParams = reactive({ media_source: '', dept_id: undefined as number | undefined, @@ -311,6 +324,18 @@ const tableColumns: MetricColumn[] = [ { key: 'roi', label: 'ROI', type: 'ratio', minWidth: 80 }, ] +const visibleSummaryCards = computed(() => + canViewFinance.value + ? summaryCards + : summaryCards.filter((card) => !FINANCE_KEYS.has(card.key)) +) + +const visibleTableColumns = computed(() => + canViewFinance.value + ? tableColumns + : tableColumns.filter((col) => !FINANCE_KEYS.has(col.key)) +) + const dateRangeText = computed(() => { if (!overview.date_range?.length) return '未选择' return `${overview.date_range[0]} 至 ${overview.date_range[1]}` @@ -414,6 +439,11 @@ onMounted(async () => { box-shadow: 0 10px 24px rgba(74, 120, 255, 0.08); } +.dept-cell { + cursor: help; + border-bottom: 1px dashed #c0c4cc; +} + .stats-kpi-label { color: #6b7280; font-size: 13px; diff --git a/admin/src/views/stats/self_input/useMediaSourceOptions.ts b/admin/src/views/stats/self_input/useMediaSourceOptions.ts index b3f333eb..681c775a 100644 --- a/admin/src/views/stats/self_input/useMediaSourceOptions.ts +++ b/admin/src/views/stats/self_input/useMediaSourceOptions.ts @@ -1,12 +1,42 @@ import { getSelfInputMediaSourceOptions } from '@/api/self_input_stats' /** - * 自录转化统计:自媒体来源下拉(从已录入业绩/消耗去重,两页共用同一接口) + * 自录转化统计:自媒体来源选项(来自字典「推广渠道」channels)。 + * 业绩页与账户消耗页共用同一接口,下拉打开时刷新,保证两侧字典变更同步。 */ +export interface MediaSourceOption { + name: string + value: string +} + export function useMediaSourceOptions() { - const mediaSourceOptions = ref([]) + const mediaSourceOptions = ref([]) const mediaSourceLoading = ref(false) + const normalize = (raw: any): MediaSourceOption[] => { + if (!Array.isArray(raw)) return [] + const list: MediaSourceOption[] = [] + const seen = new Set() + for (const item of raw) { + if (typeof item === 'string') { + const name = item.trim() + if (name && !seen.has(name)) { + seen.add(name) + list.push({ name, value: '' }) + } + continue + } + if (item && typeof item === 'object') { + const name = String(item.name ?? '').trim() + if (name && !seen.has(name)) { + seen.add(name) + list.push({ name, value: String(item.value ?? '') }) + } + } + } + return list + } + const loadMediaSourceOptions = async (force = false) => { if (!force && mediaSourceOptions.value.length > 0) { return @@ -14,7 +44,7 @@ export function useMediaSourceOptions() { mediaSourceLoading.value = true try { const res = await getSelfInputMediaSourceOptions() - mediaSourceOptions.value = Array.isArray(res) ? res : [] + mediaSourceOptions.value = normalize(res) } catch { mediaSourceOptions.value = [] } finally { diff --git a/admin/src/views/stats/self_input/yeji-edit.vue b/admin/src/views/stats/self_input/yeji-edit.vue index aa354fc7..abb45c37 100644 --- a/admin/src/views/stats/self_input/yeji-edit.vue +++ b/admin/src/views/stats/self_input/yeji-edit.vue @@ -23,8 +23,9 @@ @@ -134,13 +135,16 @@ import { personalYejiAdd, personalYejiEdit } from '@/api/self_input_stats' import Popup from '@/components/popup/index.vue' import MediaSourceSelect from './MediaSourceSelect.vue' +import type { MediaSourceOption } from './useMediaSourceOptions' -const props = withDefaults( +withDefaults( defineProps<{ - mediaSourceOptions?: string[] + mediaSourceOptions?: MediaSourceOption[] + mediaSourceLoading?: boolean }>(), { mediaSourceOptions: () => [], + mediaSourceLoading: false, } ) diff --git a/server/app/adminapi/logic/stats/PersonalAccountCostLogic.php b/server/app/adminapi/logic/stats/PersonalAccountCostLogic.php index fec0d717..c6528f89 100644 --- a/server/app/adminapi/logic/stats/PersonalAccountCostLogic.php +++ b/server/app/adminapi/logic/stats/PersonalAccountCostLogic.php @@ -22,8 +22,10 @@ class PersonalAccountCostLogic extends BaseLogic return false; } - if (self::isCostDuplicate($adminId, $costDate, $mediaSource)) { - self::setError('该日期下该自媒体来源的账户消耗已存在,请直接编辑'); + $conflict = self::findCostConflict($costDate, $mediaSource); + if ($conflict['conflict']) { + $by = $conflict['creator_name'] !== '' ? '(录入人:' . $conflict['creator_name'] . ')' : ''; + self::setError('该日期下该渠道的账户消耗已存在' . $by . ',请直接编辑该记录,避免重复汇总'); return false; } diff --git a/server/app/adminapi/logic/stats/PersonalStatsScopeTrait.php b/server/app/adminapi/logic/stats/PersonalStatsScopeTrait.php index b1358da5..79fe8154 100644 --- a/server/app/adminapi/logic/stats/PersonalStatsScopeTrait.php +++ b/server/app/adminapi/logic/stats/PersonalStatsScopeTrait.php @@ -26,13 +26,14 @@ trait PersonalStatsScopeTrait /** * @param array $creatorIds - * @return array{0: array, 1: array} + * @return array{0: array, 1: array, 2: array} + * [adminId => deptId, deptId => name, deptId => "祖/父/当前"] */ protected static function loadAdminDeptMap(array $creatorIds): array { $creatorIds = array_values(array_unique(array_filter(array_map('intval', $creatorIds)))); if ($creatorIds === []) { - return [[], []]; + return [[], [], []]; } $rows = AdminDept::whereIn('admin_id', $creatorIds) ->field('admin_id, dept_id') @@ -51,14 +52,57 @@ trait PersonalStatsScopeTrait } } - $deptNameMap = []; - $deptIds = array_values(array_unique($adminToDeptId)); - if ($deptIds !== []) { - $deptNameMap = Dept::whereIn('id', $deptIds) - ->column('name', 'id'); + if ($adminToDeptId === []) { + return [[], [], []]; } - return [$adminToDeptId, $deptNameMap]; + $allDeptRows = Dept::field('id, pid, name')->select()->toArray(); + $deptIndex = []; + foreach ($allDeptRows as $row) { + $id = (int) ($row['id'] ?? 0); + if ($id <= 0) { + continue; + } + $deptIndex[$id] = [ + 'pid' => (int) ($row['pid'] ?? 0), + 'name' => (string) ($row['name'] ?? ''), + ]; + } + + $deptNameMap = []; + $deptPathMap = []; + foreach (array_unique(array_values($adminToDeptId)) as $deptId) { + $deptId = (int) $deptId; + if ($deptId <= 0 || !isset($deptIndex[$deptId])) { + continue; + } + $deptNameMap[$deptId] = $deptIndex[$deptId]['name']; + $deptPathMap[$deptId] = self::resolveDeptPath($deptId, $deptIndex); + } + + return [$adminToDeptId, $deptNameMap, $deptPathMap]; + } + + /** + * 从根节点到当前部门的完整链路(用 / 分隔)。 + * + * @param array $deptIndex + */ + private static function resolveDeptPath(int $deptId, array $deptIndex): string + { + $names = []; + $guard = 0; + $cursor = $deptId; + while ($cursor > 0 && isset($deptIndex[$cursor]) && $guard++ < 32) { + $node = $deptIndex[$cursor]; + $name = trim($node['name']); + if ($name !== '') { + array_unshift($names, $name); + } + $cursor = $node['pid']; + } + + return implode(' / ', $names); } /** @@ -71,12 +115,13 @@ trait PersonalStatsScopeTrait return $rows; } $creatorIds = array_map(static fn (array $row): int => (int) ($row['creator_id'] ?? 0), $rows); - [$adminToDeptId, $deptNameMap] = self::loadAdminDeptMap($creatorIds); + [$adminToDeptId, $deptNameMap, $deptPathMap] = self::loadAdminDeptMap($creatorIds); foreach ($rows as &$row) { $creatorId = (int) ($row['creator_id'] ?? 0); $deptId = $adminToDeptId[$creatorId] ?? 0; $row['dept_id'] = $deptId; $row['dept_name'] = $deptId > 0 ? (string) ($deptNameMap[$deptId] ?? '') : ''; + $row['dept_path'] = $deptId > 0 ? (string) ($deptPathMap[$deptId] ?? '') : ''; } unset($row); @@ -154,27 +199,44 @@ trait PersonalStatsScopeTrait return in_array($creatorId, $visibleIds, true); } - protected static function isYejiDuplicate(int $creatorId, string $yejiDate, string $mediaSource, int $excludeId = 0): bool + /** + * 同一天 + 同一渠道全局唯一(不分录入人):避免重复汇总。 + * 命中时返回首个已存在记录的录入人姓名,便于前端提示。 + * + * @return array{conflict: bool, creator_name: string} + */ + protected static function findYejiConflict(string $yejiDate, string $mediaSource, int $excludeId = 0): array { - $query = PersonalYeji::where('creator_id', $creatorId) - ->where('yeji_date', $yejiDate) + $query = PersonalYeji::where('yeji_date', $yejiDate) ->where('media_source', $mediaSource); if ($excludeId > 0) { $query->where('id', '<>', $excludeId); } + $row = $query->field('id, creator_name')->find(); + if (!$row) { + return ['conflict' => false, 'creator_name' => '']; + } - return $query->count() > 0; + return ['conflict' => true, 'creator_name' => (string) ($row['creator_name'] ?? '')]; } - protected static function isCostDuplicate(int $creatorId, string $costDate, string $mediaSource, int $excludeId = 0): bool + /** + * 同一天 + 同一渠道全局唯一(不分录入人):避免重复汇总。 + * + * @return array{conflict: bool, creator_name: string} + */ + protected static function findCostConflict(string $costDate, string $mediaSource, int $excludeId = 0): array { - $query = PersonalAccountCost::where('creator_id', $creatorId) - ->where('cost_date', $costDate) + $query = PersonalAccountCost::where('cost_date', $costDate) ->where('media_source', $mediaSource); if ($excludeId > 0) { $query->where('id', '<>', $excludeId); } + $row = $query->field('id, creator_name')->find(); + if (!$row) { + return ['conflict' => false, 'creator_name' => '']; + } - return $query->count() > 0; + return ['conflict' => true, 'creator_name' => (string) ($row['creator_name'] ?? '')]; } } diff --git a/server/app/adminapi/logic/stats/PersonalYejiLogic.php b/server/app/adminapi/logic/stats/PersonalYejiLogic.php index 8c5b9014..2fd4fe50 100644 --- a/server/app/adminapi/logic/stats/PersonalYejiLogic.php +++ b/server/app/adminapi/logic/stats/PersonalYejiLogic.php @@ -22,8 +22,10 @@ class PersonalYejiLogic extends BaseLogic return false; } - if (self::isYejiDuplicate($adminId, $yejiDate, $mediaSource)) { - self::setError('该日期下该自媒体来源的业绩已存在,请直接编辑'); + $conflict = self::findYejiConflict($yejiDate, $mediaSource); + if ($conflict['conflict']) { + $by = $conflict['creator_name'] !== '' ? '(录入人:' . $conflict['creator_name'] . ')' : ''; + self::setError('该日期下该渠道的业绩已存在' . $by . ',请直接编辑该记录,避免重复汇总'); return false; } diff --git a/server/app/adminapi/logic/stats/SelfInputLogic.php b/server/app/adminapi/logic/stats/SelfInputLogic.php index d2555427..22fd11a3 100644 --- a/server/app/adminapi/logic/stats/SelfInputLogic.php +++ b/server/app/adminapi/logic/stats/SelfInputLogic.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace app\adminapi\logic\stats; use app\common\logic\BaseLogic; +use app\common\model\dict\DictData; use app\common\model\stats\PersonalAccountCost; use app\common\model\stats\PersonalYeji; @@ -75,6 +76,15 @@ class SelfInputLogic extends BaseLogic } $summary = self::finalizeMetrics($summaryBase); + $canViewFinance = self::canViewAllSelfInputStats($adminInfo); + + if (!$canViewFinance) { + $summary = self::maskFinanceFields($summary); + foreach ($lists as &$item) { + $item = self::maskFinanceFields($item); + } + unset($item); + } return [ 'summary' => $summary, @@ -85,39 +95,56 @@ class SelfInputLogic extends BaseLogic 'extend' => [ 'summary' => $summary, 'date_range' => [$startDate, $endDate], + 'can_view_finance' => $canViewFinance, ], ]; } /** - * 从已录入的业绩/账户消耗中提取去重后的自媒体来源(与列表数据权限一致)。 + * 财务相关字段(账户消耗 / 现金成本 / ROI):非豁免角色不可见,剔除字段避免被嗅探。 * - * @return string[] + * @param array $entity + * @return array + */ + private static function maskFinanceFields(array $entity): array + { + foreach (['account_cost', 'cash_cost', 'roi'] as $key) { + unset($entity[$key]); + } + + return $entity; + } + + /** + * 自媒体来源选项:来自字典「推广渠道」(type_value=channels),按 sort/id 排序。 + * 用 dict_data.name 作为存储值(与历史录入兼容;保留 value 仅作展示标识)。 + * + * @return array */ public static function mediaSourceOptions(int $adminId, array $adminInfo): array { - $effectiveAdminIds = self::resolveEffectiveAdminIds($adminId, $adminInfo, 0); - if ($effectiveAdminIds === []) { - return []; - } + unset($adminId, $adminInfo); - $map = []; - foreach ([PersonalYeji::class, PersonalAccountCost::class] as $modelClass) { - $query = $modelClass::where('media_source', '<>', ''); - if ($effectiveAdminIds !== null) { - $query->whereIn('creator_id', $effectiveAdminIds); - } - $rows = $query->group('media_source')->column('media_source'); - foreach ($rows as $raw) { - $norm = self::normalizeMediaSource((string) $raw); - if ($norm !== '') { - $map[$norm] = true; - } - } - } + $rows = DictData::where('type_value', 'channels') + ->where('status', 1) + ->order(['sort' => 'desc', 'id' => 'asc']) + ->field('name, value') + ->select() + ->toArray(); - $list = array_keys($map); - sort($list, SORT_STRING); + $list = []; + $seen = []; + foreach ($rows as $row) { + $name = self::normalizeMediaSource((string) ($row['name'] ?? '')); + if ($name === '' || isset($seen[$name])) { + continue; + } + $seen[$name] = true; + $list[] = [ + 'name' => $name, + 'value' => (string) ($row['value'] ?? ''), + ]; + } return $list; } diff --git a/server/sql/1.9.20260521/alter_personal_unique_key.sql b/server/sql/1.9.20260521/alter_personal_unique_key.sql new file mode 100644 index 00000000..34443ccf --- /dev/null +++ b/server/sql/1.9.20260521/alter_personal_unique_key.sql @@ -0,0 +1,10 @@ +-- 业绩 & 账户消耗:将「同录入人+同日+同渠道」唯一改为「同日+同渠道」全局唯一,避免重复汇总。 +-- 注意:执行前请先清理同一天同一渠道下多录入人重复的数据,否则索引创建会失败。 + +-- zyt_personal_yeji +ALTER TABLE `zyt_personal_yeji` DROP INDEX `uk_creator_date_media`; +ALTER TABLE `zyt_personal_yeji` ADD UNIQUE KEY `uk_date_media` (`yeji_date`, `media_source`); + +-- zyt_personal_account_cost +ALTER TABLE `zyt_personal_account_cost` DROP INDEX `uk_creator_date_media`; +ALTER TABLE `zyt_personal_account_cost` ADD UNIQUE KEY `uk_date_media` (`cost_date`, `media_source`); diff --git a/server/sql/1.9.20260521/create_personal_account_cost_table.sql b/server/sql/1.9.20260521/create_personal_account_cost_table.sql index da0b07c5..a01e0797 100644 --- a/server/sql/1.9.20260521/create_personal_account_cost_table.sql +++ b/server/sql/1.9.20260521/create_personal_account_cost_table.sql @@ -13,7 +13,7 @@ CREATE TABLE IF NOT EXISTS `zyt_personal_account_cost` ( `update_time` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '更新时间', `delete_time` int(11) unsigned DEFAULT NULL COMMENT '删除时间', PRIMARY KEY (`id`), - UNIQUE KEY `uk_creator_date_media` (`creator_id`, `cost_date`, `media_source`), + UNIQUE KEY `uk_date_media` (`cost_date`, `media_source`), KEY `idx_cost_date` (`cost_date`), KEY `idx_creator_id` (`creator_id`), KEY `idx_dept_id` (`dept_id`) diff --git a/server/sql/1.9.20260521/create_personal_yeji_table.sql b/server/sql/1.9.20260521/create_personal_yeji_table.sql index 23a50b7c..f1194121 100644 --- a/server/sql/1.9.20260521/create_personal_yeji_table.sql +++ b/server/sql/1.9.20260521/create_personal_yeji_table.sql @@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS `zyt_personal_yeji` ( `update_time` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '更新时间', `delete_time` int(11) unsigned DEFAULT NULL COMMENT '删除时间', PRIMARY KEY (`id`), - UNIQUE KEY `uk_creator_date_media` (`creator_id`, `yeji_date`, `media_source`), + UNIQUE KEY `uk_date_media` (`yeji_date`, `media_source`), KEY `idx_yeji_date` (`yeji_date`), KEY `idx_creator_id` (`creator_id`), KEY `idx_dept_id` (`dept_id`)