Files
guoju0808/_self/manager_projects_assign.md
郭庆泰andClaude d1c755971d docs: 加 _self/manager_projects_assign.md + manager_projects 修复记录
- manager_projects_assign.md: 新增 11 章端到端审查报告 (4 跳定位 + Entity 一致性 + 索引 + 原型对比 + 待修复 16 条)
- manager_projects.md: 修复记录表加 2026-08-20 P1/P2/P3 修复条目 (与上面 commit 一致)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 23:31:41 +08:00

422 lines
31 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 0. 4 跳定位 (铁律: 不按名字猜)
| 跳 | 文件:行 | 结果 |
|---|---|---|
| ① 角色菜单 | `ry-vue3/src/layout/AdminLayout.vue:96` | `path: '/manager/projects', title: '项目管理'` (子入口, 无独立菜单) |
| ② 路由 | `ry-vue3/src/router/index.js:64` | `name: 'manager-projects-assign'`, component = `ManagerProjectsAssign.vue` ✓ |
| ③ 组件 | `ry-vue3/src/views/manager/ManagerProjectsAssign.vue` | 441 行, **本审查对象** |
| ④ 原型 | `proto/html/components/project-assign.html` (1.html 同源备份) | 项目分配独立页原型 |
> **调用路径**: `manager/Projects.vue` 行 dropdown `assign` 命令或 toolbar "项目分配" 按钮 → `router.push({path:'/manager/projects/assign', query:{projectId|projectIds}})` (`Projects.vue:274-280, 465-480`)。
> 单一路由 + query 区分单条 (`?projectId=X`) / 批量 (`?projectIds=1,2,3`), 用 `batchMode = !!route.query.projectIds` 切换 UI (`ManagerProjectsAssign.vue:113-115`)。
---
## 1. 主要功能 + 可见性
### 1.1 主要功能
| 功能 | 前端入口 | 后端接口 | 涉及表 |
|---|---|---|---|
| 加载项目基本信息 (单条) | `loadSingleProject` (`ManagerProjectsAssign.vue:257-275`) | `GET /business/project/{id}``BizProjectController.getInfo``bizProjectService.getById` | biz_project |
| 加载项目基本信息 (批量) | `loadBatchProjects` (L277-309) | 同上, **N 次**串行调用 (无批量接口) | biz_project × N |
| 加载已分配执行方 | `loadAssigns` (L200-222) | `GET /business/project/{id}/assigns``BizProjectController.getAssigns``bizProjectAssignService.selectByProjectId` | biz_project_assign |
| 支持方下拉选项 | `searchSponsorOrgs` (L172-191) | `GET /business/org/sponsorOptions?orgName=&userId=``BizOrgController.sponsorOptions``bizOrgService.selectSponsorOrgOptions` | biz_org (org_type='sponsor') + sys_user |
| 执行方下拉选项 (行内) | `searchExecutors` (L227-248) | `GET /business/org/executorOptions?orgName=&userId=``BizOrgController.executorOptions``bizOrgService.selectExecutorOrgOptions` | biz_org (org_type='executor') + sys_user (MAIN only) |
| 保存支持方 + 截止天数 | `submitAssign` 中 PUT (L365-374, 388-393) | `PUT /business/project``bizProjectService.updateByPrimaryKey` | biz_project.sponsor_admin_user_id / sponsor_admin_user_name / submit_deadline_days |
| 保存执行方分配 (单条) | `submitAssign` 中 POST (L394-399) | `POST /business/project/{id}/assigns``BizProjectController.saveAssigns` (deleteByProjectId 后逐条 insert) | biz_project_assign |
| 保存执行方分配 (批量) | `submitAssign` 中循环 (L366-386) | 同上 × N, 错单跳过收集到 `errs` | biz_project_assign × N |
### 1.2 哪些人能看到本页面
| 层 | 来源 | 校验字段 |
|---|---|---|
| 前端菜单 | `AdminLayout.vue:96` (`/manager/projects`) | 无 `requireMain`, 主/子账号均可见 (manager 角色没有子账号业务) |
| 路由守卫 | `router/index.js:64` | `meta.role` 无显式, 走全局 `permission.js` 校验登录态 |
| 后端 SQL | `BizProjectController` `@Autowired` service | **无 role 过滤**, 任何登录角色都可 hit 接口 — 跟前端 "manager 全局可见" 语义一致 |
**其他角色对应功能**:
- sponsor / executor / doctor 都不进本页面 (他们有自己的项目页, 走 `sponsorList` / `executorList` / `myProjects` 接口)
- admin 角色: 路由表 L28 `admin-projects` 也指向 `manager/Projects.vue`, 但 admin 端 Projects.vue 走同样的 `selectList`, 走本子页面前无 role 校验 (admin 也可触发分配) — 业务上 OK
**数据隔离**: manager 是无界角色, 可分配任何项目; 分配动作**无 owner 校验**, 但写入路径 (`PUT /business/project` + `POST /business/project/{id}/assigns`) 不限制调用者, 任何已登录用户均可触发 — 风险点见 §10 #3
---
## 2. 筛选项 (filter-form)
> 本页**无传统筛选项**(单条模式无 filter, 批量模式也无筛选), 只有表单字段。下面对应"el-form-item":
| UI label | 控件 | v-model | 后端 SQL | 命中表.字段 | 选项来源 | 原型对照 |
|---|---|---|---|---|---|---|
| 支持方 | `el-select` (single+remote+filterable) | `assignForm.sponsorAdminUserId` | `selectSponsorOrgOptions` (`BizOrgMapper.xml:111-123`) `WHERE o.org_type='sponsor' AND u.del_flag='0' AND [org_name LIKE] [user_id = ?] LIMIT 5` | biz_org.user_id (MAIN) + biz_org.org_name | `/business/org/sponsorOptions` (LIMIT 5) | ✅ 原型第 2 区块 |
| 执行方 (行内) | `el-select` (row 内 remote+filterable) | `row.execUserId` | `selectExecutorOrgOptions` (`BizOrgMapper.xml:135-148`) `WHERE o.org_type='executor' AND u.del_flag='0' AND u.parent_user_id IS NULL [org_name LIKE] [user_id = ?] LIMIT 5` | biz_org.user_id (MAIN only) + biz_org.org_name | `/business/org/executorOptions` (LIMIT 5, 限定 MAIN) | ✅ 原型第 3 区块 |
| 场数 (行内) | `el-input-number` | `row.sessions` | 直接 `biz_project_assign.sessions` | biz_project_assign.sessions | — | ✅ 原型 `场数:` |
| 金额 (行内) | `el-input-number` `:precision="2"` | `row.amount` | 直接 `biz_project_assign.amount` | biz_project_assign.amount (decimal 15,2) | — | ✅ 原型 `总金额:` |
| 提交截止(天) | `el-input-number` | `assignForm.deadlineDays` | `updateByPrimaryKey` L268 `<if>submit_deadline_days = #{...}` | biz_project.submit_deadline_days | — | ⚠️ 原型 max 100, **Vue 无校验** (见 §10 #5) |
> **类型注意**: biz_project_assign.amount 类型 `decimal(15,2)`, Vue 用 el-input-number precision=2 ✅ 一致。
> **类型注意**: biz_project.submit_deadline_days 类型 `int`, Vue 用 el-input-number 无 min/max ✅ 类型对, **但未做 1~100 边界校验** (原型说"超过 100 报错", Vue 没做)。
---
## 3. 工具栏按钮 (toolbar)
| 按钮 | 功能 | 接口 | 涉及表 | 原型对照 |
|---|---|---|---|---|
| + 增加执行方 | `addExecRow` (L165-169) | (本地, 推 row 到 execRows) | — | ✅ 原型 `+ 增加执行方` |
| 删除 (行内) | `assignForm.execRows.splice($index, 1)` | (本地) | — | ✅ 原型 `删除` 链接 |
| 取消 | `confirmCancel` (L321-325) | ElMessageBox.confirm → `goBack` (router.push 回列表) | — | ✅ 原型 `取消` |
| 保存 | `submitAssign` (L328-406) | 见 §1.1 表 (PUT + POST) | biz_project + biz_project_assign | ✅ 原型 `保存` |
---
## 4. 表格 (el-table)
### 4.1 数据来源
本页面**不展示项目列表表格**, 只有"执行方分配行表"(el-table bound to `assignForm.execRows`) 和单条模式顶部 6 字段描述。
#### 4.1.1 顶部描述区 (单条模式 el-descriptions) 数据源
`GET /business/project/{id}``BizProjectController.getInfo``BizProjectServiceImpl.getById``BizProjectMapper.selectByPrimaryKey` (`BizProjectMapper.xml:46-49`)
```sql
SELECT p.project_id, p.project_no, p.project_name, p.total_sessions, p.done_sessions, p.todo_sessions,
p.total_amount, p.available_amount, p.paid_labor_amount, p.paid_meeting_amount,
p.manager_score, p.sponsor_score, p.sponsor_admin_user_name, p.project_form, p.is_finished, p.is_settled,
p.sponsor_admin_user_id, p.lead_user_id, p.is_bid_project,
p.create_by, p.create_user_id, p.create_time, p.update_by, p.update_time, p.manage_fee,
p.start_time, p.end_time, p.submit_deadline_days,
p.support_contract_url, p.execute_contract_url, p.invitation_url, p.support_letter_url, p.publish_url
FROM biz_project p
WHERE project_id = #{projectId}
```
Vue 字段映射 (`ManagerProjectsAssign.vue:18-23`):
- `projectNo` ← biz_project.project_no ✅
- `projectName` ← biz_project.project_name ✅
- `projectForm` ← biz_project.project_form ✅
- `totalSessions` ← biz_project.total_sessions (int) ✅
- `totalAmount` ← biz_project.total_amount (decimal 15,2) ✅
- `manageFee` ← biz_project.manage_fee (decimal 15,2) ✅
#### 4.1.2 执行方行表数据源
`GET /business/project/{id}/assigns``BizProjectController.getAssigns``BizProjectAssignMapper.selectByProjectId` (`BizProjectAssignMapper.xml:31-35`)
```sql
SELECT assign_id, project_id, execution_unit_id, exec_user_id,
sessions, amount, remark, status,
create_by, create_time, update_by, update_time
FROM biz_project_assign
WHERE project_id = #{projectId} AND status = '0'
ORDER BY assign_id ASC
```
EXPLAIN 实测: `key=uk_project_unit (project_id, execution_unit_id)`, `ref=const`, `rows=1`, `Extra=Using where; Using filesort` ✅ 走唯一索引。
### 4.2 列映射
| 列名 | `row.*` | 表.字段 | 原型对照 |
|---|---|---|---|
| # | (type="index") | — | ✅ 原型 `1. 2. 3.` 序号 |
| 执行方名称 | `row.execUserId` (el-select label=orgName) | biz_org.org_name (JOIN, label); biz_project_assign.exec_user_id (value) | ✅ 原型 `select` |
| 场次 | `row.sessions` | biz_project_assign.sessions (int) | ✅ 原型 `场数:` |
| 金额 | `row.amount` | biz_project_assign.amount (decimal 15,2) | ✅ 原型 `总金额:` |
| 操作 | 删除按钮 | — | ✅ 原型 `删除` 链接 |
> **注**: 原型行内显示单位名称 (`org_name`), 但 Vue 把 el-select 的 `:value=u.userId :label=u.orgName` — label 确实显示公司名 ✅。但**不显示执行方账户名** (u.userName), 原型也没要求, 业务上可接受。
---
## 5. 行内操作按钮 (op-cell)
无传统行内操作按钮 (本页是分配表单, 不是列表)。删除按钮在 §3 工具栏行内列出。
**特殊按钮**:
- `+ 增加执行方` (L85) → `addExecRow()` — 不调接口, 只在 `assignForm.execRows` 推入新空行, 然后 `searchExecutors('', row)` 异步拉取执行方选项 (避免新行 el-select 没数据)
- 行内 `删除` (L80) → `splice` — 不调接口, 跟 `submitAssign` 时整体 deleteByProjectId + 全部 insert 一并提交
---
## 6. Java Entity ↔ 数据库表 一致性
### 6.1 biz_project_assign DDL (实测 2026-08-20)
```sql
CREATE TABLE biz_project_assign (
assign_id bigint NOT NULL AUTO_INCREMENT COMMENT '分配ID',
project_id bigint NOT NULL COMMENT '项目ID',
execution_unit_id bigint NOT NULL COMMENT '执行单位ID',
sessions int DEFAULT '0' COMMENT '分配场数',
amount decimal(15,2) DEFAULT '0.00' COMMENT '总金额',
exec_user_id bigint DEFAULT NULL COMMENT '执行方用户ID (sys_user.user_id)',
remark varchar(500) DEFAULT NULL COMMENT '备注',
status char(1) DEFAULT '0' COMMENT '状态 0正常 1已撤销',
create_by varchar(64) DEFAULT '' COMMENT '创建者',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_by varchar(64) DEFAULT '' COMMENT '更新者',
update_time datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (assign_id),
UNIQUE KEY uk_project_unit (project_id, execution_unit_id),
KEY idx_assign_project (project_id),
KEY idx_assign_unit (execution_unit_id),
KEY idx_assign_exec_user (exec_user_id),
KEY idx_assign_exec_user_project (exec_user_id, project_id) -- 2026-08-20 加, 见 manager_projects.md §9.5
) ENGINE=InnoDB AUTO_INCREMENT=1463963329224705 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='项目-执行单位分配';
```
数据规模: **12 行** (实测 `SELECT COUNT(*)`)。
### 6.2 biz_org DDL (实测 2026-08-20)
```sql
CREATE TABLE biz_org (
org_id bigint NOT NULL AUTO_INCREMENT,
org_name varchar(200) NOT NULL,
org_type varchar(20) NOT NULL DEFAULT 'sponsor' COMMENT 'sponsor赞助方/execution执行方',
business_nature varchar(20) DEFAULT NULL COMMENT '企业性质 私营/国营/中外合资/外资/其他',
address varchar(500) DEFAULT NULL COMMENT '单位地址',
tax_no varchar(50) DEFAULT NULL COMMENT '税号/统一社会信用代码',
contact_name varchar(50) DEFAULT NULL COMMENT '联系人',
contact_phone varchar(20) DEFAULT NULL COMMENT '联系电话',
intent_count int DEFAULT '0' COMMENT '意向项目数',
status varchar(20) DEFAULT '0' COMMENT '合作状态 合作中/待签约/已停用',
create_by varchar(64) DEFAULT '' COMMENT '创建者',
create_time datetime DEFAULT NULL,
update_by varchar(64) DEFAULT '' COMMENT '更新者',
update_time datetime DEFAULT NULL,
user_id bigint DEFAULT NULL COMMENT '主账号 sys_user.user_id (注册时自动关联, 一用户一类型一公司)',
PRIMARY KEY (org_id),
UNIQUE KEY uk_org_user_type (user_id, org_type),
KEY idx_org_type_status (org_type, status),
KEY idx_org_type_name (org_type, org_name),
KEY idx_org_user_type_name (user_id, org_type, org_name)
) ENGINE=InnoDB AUTO_INCREMENT=41 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='支持单位';
```
数据规模: **33 行** (实测)。COMMENT"支持单位" 实为 sponsor+executor 共用表, 见 §9.1。
### 6.3 biz_project (分配写入字段) DDL (实测)
```sql
sponsor_admin_user_id bigint DEFAULT NULL COMMENT '赞助方负责人用户ID (sys_user.user_id, role_type=sponsor)'
sponsor_admin_user_name varchar(200) DEFAULT NULL COMMENT '赞助方负责人用户名(冗余)'
total_sessions int DEFAULT NULL COMMENT '总场次/总期数'
total_amount decimal(15,2) DEFAULT '0.00' COMMENT '总金额'
manage_fee decimal(15,2) DEFAULT '0.00' COMMENT '管理费及税金'
submit_deadline_days int DEFAULT '0' COMMENT '提交材料截止天数'
```
### 6.4 Entity ↔ DB 字段对照
#### BizProjectAssign
| 实体字段 | 中文列名 (DDL COMMENT) | 类型 | 表字段 | 类型 | 一致? |
|---|---|---|---|---|---|
| assignId | 分配ID | String | assign_id | bigint | ⚠️ Java String vs DB bigint (走 SnowflakeId → Service 层 `injectIfEmpty` 转 String, 经全局 BaseEntity 反序列化兼容) |
| projectId | 项目ID | Long | project_id | bigint | ✅ |
| executionUnitId | 执行单位ID | Long | execution_unit_id | bigint | ✅ |
| execUserId | 执行方用户ID | Long | exec_user_id | bigint | ✅ |
| sessions | 分配场数 | Integer | sessions | int | ✅ |
| amount | 总金额 | BigDecimal | amount | decimal(15,2) | ✅ |
| remark | 备注 | String | remark | varchar(500) | ✅ |
| status | 状态 | String | status | char(1) | ✅ |
| createBy / updateBy | 创建者/更新者 | String | create_by / update_by | varchar(64) | ✅ |
| createTime / updateTime | 创建时间/更新时间 | Date | create_time / update_time | datetime | ✅ |
#### BizProject (assign 涉及字段)
| 实体字段 | 中文列名 | 类型 | 表字段 | 类型 | 一致? |
|---|---|---|---|---|---|
| sponsorAdminUserId | 赞助方负责人 | Long | sponsor_admin_user_id | bigint | ✅ |
| sponsorAdminUserName | 赞助方负责人用户名(冗余) | String | sponsor_admin_user_name | varchar(200) | ✅ |
| totalSessions | 总场次/总期数 | Long | total_sessions | int | ✅ (Java Long 兼容 SQL int) |
| totalAmount | 总金额 | BigDecimal | total_amount | decimal(15,2) | ✅ |
| manageFee | 管理费及税金 | BigDecimal | manage_fee | decimal(15,2) | ✅ |
| submitDeadlineDays | 提交材料截止天数 | Integer | submit_deadline_days | int | ✅ |
#### BizOrg (options 字段)
| 实体字段 | DB 字段 | 类型 | 一致? |
|---|---|---|---|
| orgId / userId / orgName / orgType | 同上 | Long / Long / String / String | ✅ |
| status | status | String (实际存 '0'/'1') vs DDL `varchar(20)` 注释说"合作中/待签约/已停用" | ⚠️ **值约定混乱**: 注释允许多字符串, 但 `BizOrgController.toggleStatus` (L99-107) 严格限定 '0'/'1', BizProject 控制台代码无字符串判定, BizOrgController `/toggleStatus` 用 Map 接 body 防御误传 |
### 6.5 修订
> v1 误判: biz_project_assign.assignId 类型 String vs bigint — 实际 `SnowflakeId.injectIfEmpty` 在 Service 层处理, mapper.xml L55 `<if test="assignId != null and assignId != ''">assign_id,</if>` 用 String 也能写 bigint 列 (MyBatis 隐式转换), DB 是 AUTO_INCREMENT 实际由 SQL 自增, Java 端通常拿不到 insert id。**实际写入靠 AUTO_INCREMENT**, String 字段只是反序列化壳, 无 bug。
---
## 7. 索引检查
### 7.1 biz_project_assign
| Key | 列 | 用途覆盖 |
|---|---|---|
| PRIMARY | `assign_id` | ✅ |
| uk_project_unit | `(project_id, execution_unit_id)` | ✅ `selectByProjectId` EXPLAIN 实际命中 (实测) |
| idx_assign_project | `project_id` | ⚠️ **冗余**: `uk_project_unit` 前缀是 project_id, 单列 `project_id = ?` 优化器优先选 uk (实测 EXPLAIN 命中 uk_project_unit) |
| idx_assign_unit | `execution_unit_id` | ⚠️ 当前页面未用, 待清理 (或保留给未来按执行方查分配) |
| idx_assign_exec_user | `exec_user_id` | ⚠️ **冗余**: `idx_assign_exec_user_project` 前缀覆盖, 优化器在新索引后偏好复合 |
| **idx_assign_exec_user_project** | `(exec_user_id, project_id)` | ✅ projects `selectList` EXISTS+IN 复合 covering (be1c973, 见 `manager_projects.md` §9.5) |
**索引冗余**: `idx_assign_project``idx_assign_exec_user``uk_project_unit` / `idx_assign_exec_user_project` 前缀重叠, 数据量小(<1k) 优化器可能优先小索引, 但**索引维护成本**是真实的。建议保留 (代码注释明确, 不影响功能)。
### 7.2 biz_org
| Key | 列 | 用途覆盖 |
|---|---|---|
| PRIMARY | `org_id` | ✅ |
| uk_org_user_type | `(user_id, org_type)` | ✅ options JOIN `u.user_id = o.user_id` (eq_ref) + "一用户一类型一公司" 唯一约束 |
| idx_org_type_status | `(org_type, status)` | ✅ options `WHERE org_type = ?` EXPLAIN 实测命中 |
| idx_org_type_name | `(org_type, org_name)` | ✅ `org_name LIKE` 兜底 |
| idx_org_user_type_name | `(user_id, org_type, org_name)` | ⚠️ **冗余**: 3 列复合, EXPLAIN 实测优化器在小数据量选更小的 `idx_org_type_status` (2 列), 此索引未被命中 |
### 7.3 EXPLAIN 实测 (2026-08-20)
数据规模: biz_project_assign 12 / biz_org 33 / biz_project 12
| # | 查询 | 命中索引 | 备注 |
|---|---|---|---|
| Q1 | `selectSponsorOrgOptions(orgName='test')` | `idx_org_type_status` (ref const) + `PRIMARY` sys_user (eq_ref) | ✅ filesort (ORDER BY org_id DESC), 33 行内可接受 |
| Q2 | `selectExecutorOrgOptions(orgName='test')` | 同上 + `u.parent_user_id IS NULL` Using where | ✅ 同 Q1 |
| Q3 | `selectByProjectId(projectId=1)` | `uk_project_unit` (ref const) + filesort | ✅ 完美命中唯一索引 |
---
## 8. 与原型差异 (实现偏离)
### 8.1 实现新增 (原型没有)
| 项 | 说明 |
|---|---|
| 跳转子页面 (而非 dialog) | 原型是单页 (`project-assign.html`), Vue 独立路由 `ManagerProjectsAssign.vue`, 走 `router.push` 而非 dialog (`Projects.vue:150` 注释确认) |
| 实时校验栏 | 单条模式: 已分配场次/金额 vs 项目总场次/总金额实时对比, 超出红色高亮 + 警告 (`assign-sessions-bar` L38-46) |
| 批量分配模式 | 原型只有单项目, Vue 加 `?projectIds=1,2,3` 批量入口 (`Projects.vue:465-480`), 校验按各项目自身 (`ManagerProjectsAssign.vue:336-345`) |
| 已选项 label 保留 | options 加载后, 已选 userId 不在结果里时单独按 userId 拉回加首位 (`ManagerProjectsAssign.vue:181-185, 237-241`), 防止切换搜索条件后 el-select label 变 ID |
| row 私有 _loading / _options / _timer | 每行独立 loading 状态, 防异步竞态 (L150, 228-247) |
### 8.2 原型有但实现缺失
| 项 | 原型位置 | 实现 | 严重度 |
|---|---|---|---|
| 顶部面包屑 "项目管理 > 项目分配" | `project-assign.html:354-356` | Vue: `首页 / 项目管理 / <span class="current">项目分配</span>` (L3-6), ✅ 一致 (但首页是 Vue 默认面包屑) |
| 项目信息 6 字段 (编号/名称/形式/总场次/总金额/管理费及税金) | `project-assign.html:367-392` | el-descriptions 6 字段 ✅ 实装 (单条模式) | — |
| 支持方提示文字 "*支持单位只有一个, 支持模糊查找, 选择项" | `project-assign.html:401` | ⚠️ **Vue 未加红色提示** (L27-33 el-form-item 没 hint) | **P3** |
| 执行方提示文字 (4 段) | `project-assign.html:472-483` | ⚠️ **Vue 完全省略** 4 段红色提示 (含"可用金额=总金额×(1-管理费税费)-累计劳务-累计会务" 公式) | **P2** |
| 截止时间提示 (3 段) | `project-assign.html:493-495` | ⚠️ **Vue 完全省略** 3 段红色提示 (含"天数最大不能超过 100") | **P2** |
| 截止天数 1~100 校验 | `project-assign.html:646-650` `if (deadline && (deadline < 1 \|\| deadline > 100))` | ⚠️ **Vue 无校验** (L88-90 el-input-number `:min="0"` 缺 max) | **P1** |
| 合同文件展示 (支持合同/执行合同, OA 同步) | `project-assign.html:501-515` | ⚠️ **Vue 完全省略** 合同区 (`support_contract_url` / `execute_contract_url` 已 SELECT 到, 但页面没用) | **P1** |
| 总金额超额服务端校验 | `project-assign.html:642-645` `totalExecAmount > project.amount` | ⚠️ **Vue 只在客户端 sum 校验** (L139-148, 335-355), 服务端没二次校验 | **P2** (客户端已拦, 风险低) |
| 执行方单位 = 1 时"删除"禁用 | `project-assign.html:582-588` `disabled = rows.length === 1 && i === 0` | ⚠️ **Vue 永远可点** (L80 链接 `el-button link type="danger"`), 可一键删到空数组, 提交时 `execRows.length === 0` 会被 `if (!valid.length)` 拦住, 但 UI 不友好 | **P3** |
### 8.3 文字 / 标签差异
| 原型 | 实现 | 差异 |
|---|---|---|
| 项目分配 | 项目分配 | ✅ 完全一致 (标题、面包屑、表单 label) |
| 支持单位 | 支持方 | ⚠️ Vue 把"支持单位"改成"支持方" (`ManagerProjectsAssign.vue:27`), 跟其它页(支持单位管理/支持单位列表)不一致, 业务方混用 |
| 执行单位 | 执行方 | ⚠️ Vue 把"执行单位"改成"执行方" (L35 divider 文字 "执行方分配") |
| 提交材料截止时间 | 提交截止(天) | ⚠️ Vue 简化为"提交截止(天)", 失去"材料"和"时间"语义 |
| 总金额(元) | 总金额(元) (L22) | ✅ |
| 场数 | 场次 | ⚠️ Vue "场次", 原型"场数" — 同义, 但跟 `biz_project.total_sessions` (总场次/总期数) 中文对齐更顺 |
### 8.4 总结
整体方向: **业务核心对齐 + 扩展**, 但**视觉/提示/校验**比原型薄弱。P1 缺失 2 项 (合同区 + 截止天数校验), P2 缺失 4 项 (提示文案 + 服务端校验), P3 缺失 3 项 (文案微调 + 删除按钮禁用)。**建议优先级**: P1 #1 截止天数校验 → P1 #2 合同区 → P2 提示文案 → P3 文案统一。
---
## 9. 表字段冗余 / 设计问题
### 9.1 派生 vs 持久化字段
- `biz_project_assign.exec_user_id`: 持久化字段, 但**实际只用来反查 `biz_org``execution_unit_id`** (BizProjectAssignServiceImpl.insert L47-56), 然后 exec_user_id 也保留用于列表 JOIN — 冗余但有查询价值
- `biz_project.sponsor_admin_user_name`: 持久化 (冗余), 用于列表展示避免 JOIN, 设计 OK
### 9.2 唯一约束语义检查
- ✅ biz_project_assign: `uk_project_unit (project_id, execution_unit_id)` — 同一项目同一执行方只能分配一次, 合理 (跟"执行方总金额显示在执行方的项目列表"业务一致)
- ⚠️ 但 saveAssigns 是 `deleteByProjectId` + 逐条 `insert`, uk 约束会**在重新分配时**触发 (如果先 insert 再 delete 老数据就违反 uk)。当前实现**先 delete 再 insert**, 安全 ✅。
### 9.3 业务闭环检查
- ✅ 项目分配 → biz_project.sponsor_admin_user_id 更新 → sponsor 端列表可见 (`selectSponsorList` 按此过滤)
- ✅ 项目分配 → biz_project_assign 写入 → executor 端列表可见 (`selectExecutorList` 按 exec_user_id 过滤)
- ⚠️ **管理费及税金 (manage_fee) 在本页面只展示, 不参与任何计算**: 原型"可用金额=总金额×(1-管理费税费)-累计劳务-累计会务"公式**未实现** (§10 #4 P2)
- ⚠️ **biz_project.available_amount (可用金额) 字段**在原型公式中出现, 但本页面没读也没改, 是否由其他流程 (会议执行?) 维护需查 — 暂列 P3
### 9.4 路由安全性
- `manager-projects-assign` 路由 `meta.role` 无显式, 走全局登录态校验, 任何已登录角色都可访问 — 与 admin-projects 同款 (`router/index.js:28` admin-projects 也指向 manager/Projects.vue), 业务上 OK 但**没显式 `meta: { role: 'manager' }`** 是隐性约定
### 9.5 索引冗余总结
- biz_project_assign: `idx_assign_project` + `idx_assign_exec_user` 跟 uk_project_unit + idx_assign_exec_user_project 前缀重叠, 优化器在小数据量偏好小索引, 但 EXPLAIN 实测**都未被命中** (Q3 走 uk, Q1/Q2 是 SELECT biz_org), 跟 manager_projects.md §9.5 评估一致: **数据量小时保留无害, 生产数据增长后再 EVAL 是否 DROP**
---
## 10. 待修复列表 (按优先级)
| # | 问题 | 文件:行 | 修复建议 | 严重度 |
|---|---|---|---|---|
| 1 | 截止天数无上限校验 (原型 max=100) | `ManagerProjectsAssign.vue:88-90` | `<el-input-number :min="0" :max="100">` + on-change 校验 | **P1** |
| 2 | 合同文件区完全缺失 (支持合同/执行合同 + OA 同步) | `ManagerProjectsAssign.vue` 全文 | 加第 5 区块, 读 `currentProject.supportContractUrl / executeContractUrl`, 走 `Preview` 组件预览 (Projects.vue:146 已有 Preview 组件) | **P1** |
| 3 | 分配接口无 owner 校验, 任何登录角色可触发 PUT /business/project 改 sponsor | `BizProjectController.saveAssigns` (L170-182) + `updateByPrimaryKey` (L151-155) | 加 `@PreAuthorize("hasAnyRole('admin','manager')")` 或 service 层校验 `SecurityUtils.getUserId()` 在 manager role | **P1** |
| 4 | 管理费及税金 (manage_fee) 不参与可用金额计算 (原型公式未实现) | `ManagerProjectsAssign.vue` 实时校验栏 | `availableAmount = totalAmount * (1 - manageFee/totalAmount) - paidLabor - paidMeeting` 实时显示 (原型公式) | **P2** |
| 5 | 截止天数无下限校验 (原型要求 ≥1 自然日) | 同 #1 | `<el-input-number :min="1" :max="100">` | **P2** |
| 6 | 执行方超额校验只在客户端, 服务端无二次校验 | `BizProjectController.saveAssigns` | service 层 `if (sumAmount > project.totalAmount) throw` | **P2** |
| 7 | 原型 4 段提示文案 (可用金额公式 / 选择后业务闭环 / 截止天数规则) 全部省略 | `ManagerProjectsAssign.vue:27-91` | 加 `el-alert type="warning" :closable="false"``p.hint-text` 红字提示 | **P2** |
| 8 | "支持方" / "执行方" 跟其它页"支持单位" / "执行单位" 命名不一致 | `ManagerProjectsAssign.vue:27, 35` | 统一为"支持单位" / "执行单位" (跟 biz_org.org_type 注释一致) | **P3** |
| 9 | 执行方行删除按钮永远可点, 可删到空数组 | `ManagerProjectsAssign.vue:80` | 当 `execRows.length === 1` 时禁用第一个删除按钮 (跟原型一致) | **P3** |
| 10 | 批量模式 (单条 sponsor 多项目共用) 不允许每项目独立 sponsor | `ManagerProjectsAssign.vue:297-302, 365-374` | 加 "按各项目原 sponsor 填充" toggle, 或支持逐行 sponsor 覆盖 (低优先级, 业务需求未明) | **P3** |
| 11 | 加载项目时无 loading 态 (顶部 6 字段空白闪烁) | `ManagerProjectsAssign.vue:17-24` (el-descriptions 直接绑 currentProject) | 加 `v-loading="loading"` 或 skeleton | **P3** |
| 12 | 加载批量项目时, 任一失败静默跳过 (L294) | `ManagerProjectsAssign.vue:282-295` | `console.warn` 或弹窗提示"项目 X 加载失败已跳过" | **P3** |
| 13 | biz_org 索引 `idx_org_user_type_name``idx_org_type_name` 前缀重叠, EXPLAIN 实测未命中 | `biz_org` (DB) | 数据量小时保留无害, 生产数据增长后再 EVAL | **P3** |
| 14 | biz_project_assign 索引 `idx_assign_project``uk_project_unit` 前缀重叠 | `biz_project_assign` (DB) | 同 #13 | **P3** |
| 15 | biz_org.status 注释允许多字符串, 但 controller.toggleStatus 严格 '0'/'1' | `BizOrgController.toggleStatus` (L99-107) | 统一 status 字段约定 (删 DDL 注释, 或扩 enum) | **P3** |
| 16 | `manager-projects-assign` 路由 `meta.role` 无显式, 走全局登录态 | `router/index.js:64` | 加 `meta: { role: 'manager' }` 跟其它 manager 路由一致 | **P3** |
---
## 11. 引用清单
### 前端
- `ry-vue3/src/layout/AdminLayout.vue:96` — manager 菜单 (项目管理入口)
- `ry-vue3/src/router/index.js:64` — 路由 `manager-projects-assign`
- `ry-vue3/src/views/manager/ManagerProjectsAssign.vue` — 本审查对象 (441 行)
- `ry-vue3/src/views/manager/Projects.vue:274-280``doAssign` (单条入口)
- `ry-vue3/src/views/manager/Projects.vue:465-480``openAssign` (批量入口)
- `ry-vue3/src/api/public.js``bizGet/bizUpdate` (CRUD 包装)
- `ry-vue3/src/api/system.js:28-38``listSponsorOrgs/listExecutorOrgs` (下拉选项)
### 后端
- `ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizProjectController.java:139-189` — 项目 getInfo / assigns GET/POST/DELETE
- `ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/IBizProjectService.java` + `BizProjectServiceImpl``getById/updateByPrimaryKey`
- `ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizProject.java` — 项目实体 (含 `sponsorAdminUserId / sponsorAdminUserName / submitDeadlineDays / manageFee / totalSessions / totalAmount`)
- `ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizProjectMapper.java` + xml — 4 个 select + updateByPrimaryKey
- `ry-api/ruoyi-business/src/main/java/com/ruoyi/business/controller/BizOrgController.java:38-59``sponsorOptions/executorOptions`
- `ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizOrgMapper.java:14-20` + xml L111-148 — `selectSponsorOrgOptions/selectExecutorOrgOptions`
- `ry-api/ruoyi-business/src/main/java/com/ruoyi/business/domain/BizProjectAssign.java` — 分配实体 (8 字段)
- `ry-api/ruoyi-business/src/main/java/com/ruoyi/business/mapper/BizProjectAssignMapper.java` + xml — 分配 CRUD
- `ry-api/ruoyi-business/src/main/java/com/ruoyi/business/service/impl/BizProjectAssignServiceImpl.java:34-73` — insert 时反查 biz_org 写 execution_unit_id
### DB (实测 2026-08-20)
- `biz_project_assign` (12 行): 12 列, 5 索引 (PK + uk + 3 复合/单列)
- `biz_org` (33 行): 16 列, 4 索引 (PK + uk + 2 复合/单列)
- `biz_project` (12 行, 36 列): 含 sponsor_admin_user_id / sponsor_admin_user_name / total_sessions / total_amount / manage_fee / submit_deadline_days
### 配置
- `ry-api/ruoyi-admin/src/main/resources/application-druid.yml` — DB `127.0.0.1:3306/guoju0808/root/cu2oh2co3`
### 原型
- `proto/html/manager.html:206` — 项目管理菜单入口 (iframe 路由)
- `proto/html/components/project-assign.html` — 项目分配独立页原型 (主)
- `proto/html/components/project-assign-1.html` — 项目分配独立页原型 (备份, 同源)
### 相关 (非本审查, 引用以辅助)
- `_self/manager_projects.md` — 上游列表页审查报告 (本审查的调用方)
- `biz_project_sponsor_assign` (15 列) — **与本页面无关**: 该表存"赞助方分配监察员" (sponsor → monitor), 跟"项目分配支持方" (project → sponsor) 是不同业务, 本页面不写该表