762 lines
25 KiB
Markdown
762 lines
25 KiB
Markdown
# 报价单审批人设置 Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 新增租户级「报价单审批人设置」,提交审批弹窗自动预填默认审批人(可改);未配置时行为与现网一致。
|
||
|
||
**Architecture:** 新表 `crm_quote_approver_setting` 存有序 `approver_ids` + 启用状态;CRM POST 风格 get/save API;前端设置页维护配置;报价提交弹窗打开时 GET 并预填。`submitQuote` 入参与审批链路不变。
|
||
|
||
**Tech Stack:** FastAPI + SQLAlchemy + MySQL;Vue 3 + Element Plus(web-admin);菜单权限 `crm:quote:approverSetting`
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-08-01-quote-approver-setting-design.md`
|
||
**UI Demo:** `docs/fresh-mint-quote-approver-setting-demo.html`
|
||
**UI 模式:** 单对象配置表单页(对照 Demo;非 P01 列表)。PR 注明「配置表单 + 提交预填」。
|
||
|
||
---
|
||
|
||
## File map
|
||
|
||
| File | Responsibility |
|
||
|------|----------------|
|
||
| `backend/scripts/migrate_20260801_crm_quote_approver_setting.sql` | 建表 + 菜单 |
|
||
| `docs/sql/shanxia2.0.sql` | 同步 CREATE TABLE / 菜单 INSERT(与 migrate 一致) |
|
||
| `backend/app/models/crm_models.py` | `CrmQuoteApproverSetting` |
|
||
| `backend/app/crud/crm/quote_approver_setting.py` | parse/join helpers + get/save upsert |
|
||
| `backend/app/schemas/crm/quote_approver_setting.py` | Save body |
|
||
| `backend/app/api/crm_quote_routes.py` | 注册 get/save 路由 |
|
||
| `backend/tests/test_quote_approver_setting.py` | 纯函数单测 |
|
||
| `web-admin/src/api/crm/quote/index.ts` + `types.ts` | 前端 API/类型 |
|
||
| `web-admin/src/views/crm/quote/approver-setting/index.vue` | 设置页 |
|
||
| `web-admin/src/router/modules/sqxp.ts` | 路由 |
|
||
| `web-admin/src/views/crm/quote/index.vue` | 提交弹窗预填 |
|
||
| `web-admin/src/views/crm/quote/components/QuoteFormPanel.vue` | 提交弹窗预填 |
|
||
| `web-admin/src/views/crm/quote/components/ApproverSelector.vue` | 打开时预填(若仍被调用) |
|
||
|
||
---
|
||
|
||
### Task 1: 纯函数与单测(TDD)
|
||
|
||
**Files:**
|
||
- Create: `backend/app/crud/crm/quote_approver_setting.py`(先只放 helpers)
|
||
- Create: `backend/tests/test_quote_approver_setting.py`
|
||
|
||
- [ ] **Step 1: 写失败单测**
|
||
|
||
```python
|
||
# backend/tests/test_quote_approver_setting.py
|
||
from app.crud.crm.quote_approver_setting import (
|
||
join_approver_ids,
|
||
parse_approver_ids,
|
||
prefill_approver_ids,
|
||
)
|
||
|
||
|
||
def test_parse_and_join_roundtrip():
|
||
assert parse_approver_ids("a,b,c") == ["a", "b", "c"]
|
||
assert parse_approver_ids(" a,, b ") == ["a", "b"]
|
||
assert parse_approver_ids("") == []
|
||
assert parse_approver_ids(None) == []
|
||
assert join_approver_ids(["a", "b", ""]) == "a,b"
|
||
|
||
|
||
def test_prefill_requires_enabled_and_nonempty():
|
||
assert prefill_approver_ids(None) == []
|
||
assert prefill_approver_ids({"status": "1", "approverIds": ["u1"]}) == []
|
||
assert prefill_approver_ids({"status": "0", "approverIds": []}) == []
|
||
assert prefill_approver_ids({"status": "0", "approverIds": ["u1", "u2"]}) == ["u1", "u2"]
|
||
|
||
|
||
def test_prefill_filters_against_valid_ids():
|
||
setting = {"status": "0", "approverIds": ["u1", "gone", "u2"]}
|
||
assert prefill_approver_ids(setting, valid_ids={"u1", "u2"}) == ["u1", "u2"]
|
||
assert prefill_approver_ids(setting, valid_ids=set()) == []
|
||
```
|
||
|
||
- [ ] **Step 2: 跑测确认失败**
|
||
|
||
```bash
|
||
cd backend && source venv/bin/activate && pytest tests/test_quote_approver_setting.py -v
|
||
```
|
||
|
||
Expected: FAIL(模块/函数不存在)
|
||
|
||
- [ ] **Step 3: 实现 helpers**
|
||
|
||
```python
|
||
# backend/app/crud/crm/quote_approver_setting.py
|
||
from __future__ import annotations
|
||
|
||
from typing import Any, Dict, Iterable, List, Optional, Set
|
||
|
||
|
||
def parse_approver_ids(raw: Optional[str]) -> List[str]:
|
||
if not raw:
|
||
return []
|
||
out: List[str] = []
|
||
for part in str(raw).split(","):
|
||
pid = part.strip()
|
||
if pid:
|
||
out.append(pid)
|
||
return out
|
||
|
||
|
||
def join_approver_ids(ids: Iterable[str]) -> str:
|
||
return ",".join([str(i).strip() for i in ids if str(i).strip()])
|
||
|
||
|
||
def prefill_approver_ids(
|
||
setting: Optional[Dict[str, Any]],
|
||
*,
|
||
valid_ids: Optional[Set[str]] = None,
|
||
) -> List[str]:
|
||
"""返回可预填的审批人 ID 列表;不可用则空列表。"""
|
||
if not setting:
|
||
return []
|
||
if str(setting.get("status") or "") != "0":
|
||
return []
|
||
ids = list(setting.get("approverIds") or [])
|
||
if valid_ids is not None:
|
||
ids = [i for i in ids if str(i) in valid_ids]
|
||
return [str(i) for i in ids if str(i).strip()]
|
||
```
|
||
|
||
- [ ] **Step 4: 再跑测确认通过**
|
||
|
||
```bash
|
||
cd backend && source venv/bin/activate && pytest tests/test_quote_approver_setting.py -v
|
||
```
|
||
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/app/crud/crm/quote_approver_setting.py backend/tests/test_quote_approver_setting.py
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(crm): add quote approver setting id helpers and unit tests
|
||
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: SQL 迁移(表 + 菜单)
|
||
|
||
**Files:**
|
||
- Create: `backend/scripts/migrate_20260801_crm_quote_approver_setting.sql`
|
||
- Modify: `docs/sql/shanxia2.0.sql`(在 `crm_quote_approval` 表段落后插入 CREATE;在报价单管理菜单附近插入菜单行)
|
||
|
||
- [ ] **Step 1: 写 migrate SQL**
|
||
|
||
```sql
|
||
-- backend/scripts/migrate_20260801_crm_quote_approver_setting.sql
|
||
|
||
CREATE TABLE IF NOT EXISTS `crm_quote_approver_setting` (
|
||
`id` varchar(64) NOT NULL COMMENT '主键ID(UUID)',
|
||
`approver_ids` varchar(1000) NOT NULL DEFAULT '' COMMENT '有序审批人用户ID,逗号分隔',
|
||
`status` char(1) NOT NULL DEFAULT '0' COMMENT '0启用 1停用',
|
||
`tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
|
||
`order_num` int(11) DEFAULT NULL COMMENT '排序号',
|
||
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
|
||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建者',
|
||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建部门',
|
||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新者',
|
||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||
`del_flag` smallint(6) NOT NULL DEFAULT '0' COMMENT '删除标志',
|
||
PRIMARY KEY (`id`),
|
||
KEY `idx_tenant_no` (`tenant_no`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CRM-报价单审批人设置(租户级)';
|
||
|
||
-- 说明:MySQL 5.7 无部分唯一索引;租户「有效一条」由 save_setting upsert 保证,不加 (tenant_no, del_flag) 唯一键。
|
||
|
||
-- 菜单:报价单审批人设置(CRM 根下,紧挨报价单管理)
|
||
INSERT INTO `sys_menu` (
|
||
`id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`,
|
||
`is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`,
|
||
`create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`
|
||
) VALUES (
|
||
'CRM3000000000000000000000000015',
|
||
'报价单审批人设置',
|
||
'CRM00000000000000000000000000001',
|
||
'C',
|
||
'quote-approver-setting',
|
||
'/crm/quote/approver-setting/index',
|
||
'crm:quote:approverSetting',
|
||
1, '1', '1', 'shanxia2.0', 55,
|
||
'租户级默认报价审批人',
|
||
'system', '', NOW(), NULL, NOW(), 0
|
||
)
|
||
ON DUPLICATE KEY UPDATE
|
||
`menu_name`=VALUES(`menu_name`),
|
||
`component`=VALUES(`component`),
|
||
`perms`=VALUES(`perms`),
|
||
`path`=VALUES(`path`);
|
||
```
|
||
|
||
- [ ] **Step 2: 同步 `docs/sql/shanxia2.0.sql`**
|
||
|
||
将相同 `CREATE TABLE` 与 `INSERT INTO sys_menu` 片段并入快照(位置:报价相关表/菜单附近),避免文档与库漂移。
|
||
|
||
- [ ] **Step 3: 在本地库执行 migrate**(开发环境)
|
||
|
||
```bash
|
||
# 按本地 .env 连接执行;示例:
|
||
mysql -h... -u... -p... your_db < backend/scripts/migrate_20260801_crm_quote_approver_setting.sql
|
||
```
|
||
|
||
Expected: 表与菜单创建成功。
|
||
|
||
- [ ] **Step 4: 给需要维护配置的角色勾选新菜单**
|
||
|
||
在「角色管理」中为管理员角色勾选「报价单审批人设置」(或按环境向 `sys_role_menu` 插入对应 `menu_id`)。`super_admin` 本身放行,可不依赖角色菜单。
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/scripts/migrate_20260801_crm_quote_approver_setting.sql docs/sql/shanxia2.0.sql
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(crm): add quote approver setting table and menu SQL
|
||
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Model + CRUD upsert + Schema
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/models/crm_models.py`(在 `CrmQuoteApproval` 前或后新增模型)
|
||
- Modify: `backend/app/crud/crm/quote_approver_setting.py`(补 get/save)
|
||
- Create: `backend/app/schemas/crm/quote_approver_setting.py`
|
||
|
||
- [ ] **Step 1: 增加 SQLAlchemy 模型**
|
||
|
||
```python
|
||
class CrmQuoteApproverSetting(Base):
|
||
"""CRM-报价单审批人设置(租户级)"""
|
||
|
||
__tablename__ = "crm_quote_approver_setting"
|
||
|
||
id = Column(String(64), primary_key=True, comment="主键ID(UUID)")
|
||
approver_ids = Column(String(1000), nullable=False, default="", comment="有序审批人用户ID,逗号分隔")
|
||
status = Column(String(1), nullable=False, default="0", comment="0启用 1停用")
|
||
tenant_no = Column(String(64), nullable=False, default="shanxia2.0", comment="租户编号")
|
||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||
remark = Column(String(500), nullable=True, comment="备注")
|
||
create_by = Column(String(64), nullable=True, comment="创建者")
|
||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建部门")
|
||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||
update_by = Column(String(64), nullable=True, comment="更新者")
|
||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||
```
|
||
|
||
- [ ] **Step 2: Schema**
|
||
|
||
```python
|
||
# backend/app/schemas/crm/quote_approver_setting.py
|
||
from typing import List, Optional
|
||
|
||
from pydantic import BaseModel, ConfigDict, Field
|
||
|
||
|
||
class QuoteApproverSettingSaveBody(BaseModel):
|
||
model_config = ConfigDict(populate_by_name=True)
|
||
|
||
approverIds: List[str] = Field(default_factory=list)
|
||
status: str = Field(default="0") # 0启用 1停用
|
||
```
|
||
|
||
- [ ] **Step 3: 实现 get_setting / save_setting**
|
||
|
||
在 `quote_approver_setting.py` 追加(要点):
|
||
|
||
```python
|
||
from datetime import datetime
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.utils import generate_uuid
|
||
from app.models.crm_models import CrmQuoteApproverSetting
|
||
from app.models.models import SysUser
|
||
|
||
|
||
def _row_to_dict(row: CrmQuoteApproverSetting) -> Dict[str, Any]:
|
||
return {
|
||
"id": row.id,
|
||
"approverIds": parse_approver_ids(row.approver_ids),
|
||
"status": row.status or "0",
|
||
"tenantNo": row.tenant_no,
|
||
"updateTime": row.update_time.isoformat(sep=" ") if row.update_time else None,
|
||
}
|
||
|
||
|
||
def get_setting(db: Session, *, tenant_no: str) -> Optional[Dict[str, Any]]:
|
||
row = (
|
||
db.query(CrmQuoteApproverSetting)
|
||
.filter(
|
||
CrmQuoteApproverSetting.tenant_no == tenant_no,
|
||
CrmQuoteApproverSetting.del_flag == 0,
|
||
)
|
||
.order_by(CrmQuoteApproverSetting.update_time.desc())
|
||
.first()
|
||
)
|
||
return _row_to_dict(row) if row else None
|
||
|
||
|
||
def _assert_users_exist(db: Session, *, tenant_no: str, approver_ids: List[str]) -> None:
|
||
ids = [str(i).strip() for i in approver_ids if str(i).strip()]
|
||
if not ids:
|
||
return
|
||
rows = (
|
||
db.query(SysUser.id)
|
||
.filter(SysUser.id.in_(ids), SysUser.del_flag == 0)
|
||
.all()
|
||
)
|
||
found = {str(r[0]) for r in rows}
|
||
missing = [i for i in ids if i not in found]
|
||
if missing:
|
||
raise ValueError(f"审批人无效或不存在:{','.join(missing)}")
|
||
|
||
|
||
def save_setting(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
approver_ids: List[str],
|
||
status: str,
|
||
user_id: str = None,
|
||
dept_id: str = "",
|
||
) -> Dict[str, Any]:
|
||
status_norm = "1" if str(status) == "1" else "0"
|
||
ids = [str(i).strip() for i in (approver_ids or []) if str(i).strip()]
|
||
_assert_users_exist(db, tenant_no=tenant_no, approver_ids=ids)
|
||
|
||
row = (
|
||
db.query(CrmQuoteApproverSetting)
|
||
.filter(
|
||
CrmQuoteApproverSetting.tenant_no == tenant_no,
|
||
CrmQuoteApproverSetting.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
now = datetime.now()
|
||
if row:
|
||
row.approver_ids = join_approver_ids(ids)
|
||
row.status = status_norm
|
||
row.update_by = user_id
|
||
row.update_time = now
|
||
else:
|
||
row = CrmQuoteApproverSetting(
|
||
id=generate_uuid(),
|
||
approver_ids=join_approver_ids(ids),
|
||
status=status_norm,
|
||
tenant_no=tenant_no,
|
||
create_by=user_id,
|
||
create_dept_id=dept_id or "",
|
||
create_time=now,
|
||
update_by=user_id,
|
||
update_time=now,
|
||
del_flag=0,
|
||
)
|
||
db.add(row)
|
||
db.commit()
|
||
db.refresh(row)
|
||
return _row_to_dict(row)
|
||
```
|
||
|
||
注意:若 `SysUser` 有租户字段且项目惯例按租户过滤用户,在 `_assert_users_exist` 中追加与现有用户查询一致的 `tenant_no` 条件。
|
||
|
||
- [ ] **Step 4: 再跑 Task 1 单测**(确保 helpers 未破坏)
|
||
|
||
```bash
|
||
cd backend && source venv/bin/activate && pytest tests/test_quote_approver_setting.py -v
|
||
```
|
||
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/app/models/crm_models.py backend/app/crud/crm/quote_approver_setting.py backend/app/schemas/crm/quote_approver_setting.py
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(crm): implement quote approver setting model and upsert CRUD
|
||
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: API 路由
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/api/crm_quote_routes.py`
|
||
|
||
- [ ] **Step 1: 在 `register_quote_routes` 末尾注册两个 POST**
|
||
|
||
风格对齐现有 `/quote/*`(POST + body),路径:
|
||
|
||
- `POST /quote-approver-setting/get` — `Depends(require_admin_or_super_admin)`(业务员可读以预填)
|
||
- `POST /quote-approver-setting/save` — `Depends(require_menu_code("crm:quote:approverSetting"))`
|
||
|
||
```python
|
||
from app.core.menu_guard import require_admin_or_super_admin, require_menu_code
|
||
from app.crud.crm.quote_approver_setting import get_setting, save_setting
|
||
from app.schemas.crm.quote_approver_setting import QuoteApproverSettingSaveBody
|
||
|
||
# 在 register_quote_routes 内:
|
||
|
||
@router.post("/quote-approver-setting/get")
|
||
async def quote_approver_setting_get(
|
||
_user: dict = Depends(require_admin_or_super_admin),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
try:
|
||
tenant_no = get_tenant_no(db, _user)
|
||
data = get_setting(db, tenant_no=tenant_no)
|
||
return success_response(data or {"approverIds": [], "status": "0"})
|
||
except Exception as e:
|
||
return error_response(500, str(e))
|
||
|
||
|
||
@router.post("/quote-approver-setting/save")
|
||
async def quote_approver_setting_save(
|
||
body: QuoteApproverSettingSaveBody,
|
||
_user: dict = Depends(require_menu_code("crm:quote:approverSetting")),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
try:
|
||
tenant_no = get_tenant_no(db, _user)
|
||
user_info = crm_user_info(_user)
|
||
data = save_setting(
|
||
db,
|
||
tenant_no=tenant_no,
|
||
approver_ids=body.approverIds or [],
|
||
status=body.status or "0",
|
||
user_id=user_info.get("user_id") or _user.get("user_id"),
|
||
dept_id=user_info.get("dept_id") or "",
|
||
)
|
||
return success_response(data, message="保存成功")
|
||
except ValueError as e:
|
||
return error_response(400, str(e))
|
||
except Exception as e:
|
||
return error_response(500, str(e))
|
||
```
|
||
|
||
未配置时 GET 返回 `{ approverIds: [], status: "0" }`(表单默认启用;`approverIds` 为空故预填仍为空)。勿用 `status:"1"` 作为空配置哨兵,否则设置页首次打开会落到「停用」。
|
||
|
||
- [ ] **Step 2: 手工用已登录 token 冒烟**(Swagger `/docs` 或 curl)
|
||
|
||
Expected: get 返回空配置;save 后 get 带回 `approverIds`;无菜单权限用户 save → 403。
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add backend/app/api/crm_quote_routes.py
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(crm): expose quote approver setting get/save APIs
|
||
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: 前端 API 与类型
|
||
|
||
**Files:**
|
||
- Modify: `web-admin/src/api/crm/quote/types.ts`
|
||
- Modify: `web-admin/src/api/crm/quote/index.ts`
|
||
|
||
- [ ] **Step 1: 类型**
|
||
|
||
```ts
|
||
export interface QuoteApproverSettingVO {
|
||
id?: string
|
||
approverIds: (string | number)[]
|
||
status: '0' | '1' | string
|
||
updateTime?: string
|
||
}
|
||
|
||
export interface QuoteApproverSettingForm {
|
||
approverIds: (string | number)[]
|
||
status: '0' | '1'
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: API**
|
||
|
||
```ts
|
||
const SETTING_BASE = '/web/crm/quote-approver-setting'
|
||
|
||
export async function getQuoteApproverSetting() {
|
||
const res = await request.post({ url: `${SETTING_BASE}/get`, data: {} })
|
||
const data = unwrapData(res) || {}
|
||
return {
|
||
data: {
|
||
id: data.id,
|
||
approverIds: (data.approverIds || []).map(String),
|
||
status: data.status ?? '1',
|
||
updateTime: data.updateTime
|
||
} as QuoteApproverSettingVO
|
||
}
|
||
}
|
||
|
||
export function saveQuoteApproverSetting(data: QuoteApproverSettingForm) {
|
||
return request.post({
|
||
url: `${SETTING_BASE}/save`,
|
||
data: {
|
||
approverIds: (data.approverIds || []).map(String),
|
||
status: data.status
|
||
}
|
||
})
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add web-admin/src/api/crm/quote/types.ts web-admin/src/api/crm/quote/index.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(web-admin): add quote approver setting API client
|
||
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: 设置页 + 路由
|
||
|
||
**Files:**
|
||
- Create: `web-admin/src/views/crm/quote/approver-setting/index.vue`
|
||
- Modify: `web-admin/src/router/modules/sqxp.ts`(在 `CrmQuote` 路由后插入)
|
||
|
||
- [ ] **Step 1: 路由**
|
||
|
||
```ts
|
||
{
|
||
id: 'CRM3000000000000000000000000015',
|
||
name: 'CrmQuoteApproverSetting',
|
||
path: 'quote-approver-setting',
|
||
component: '/crm/quote/approver-setting/index',
|
||
meta: {
|
||
title: '报价单审批人设置',
|
||
keepAlive: true,
|
||
menuCode: 'crm:quote:approverSetting'
|
||
}
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 2: 页面骨架(对齐 Demo / Fresh Mint)**
|
||
|
||
要点:
|
||
|
||
- `page-container` → `section.panel` → `panel-top` + `h2.title-bar-heading`「报价单审批人设置」
|
||
- `el-form`:`label-position="top"`,`form-grid-2`
|
||
- 字段:
|
||
- 默认审批人:`el-select` multiple(复用报价页 `fetchUserOptions` / 同款 label 格式)
|
||
- 状态:`el-radio-group` 启用 `0` / 停用 `1`
|
||
- 底栏:重置 + 保存(`el-icon` Close/RefreshLeft + Check;保存 `type="primary"`)
|
||
- `onMounted` 调 `getQuoteApproverSetting` 回填;保存调 `saveQuoteApproverSetting`
|
||
- 审批人允许空数组保存(清空后不再预填)
|
||
|
||
参考结构(精简):
|
||
|
||
```vue
|
||
<template>
|
||
<div class="page-container">
|
||
<section class="panel">
|
||
<div class="panel-top">
|
||
<h2 class="title-bar-heading">报价单审批人设置</h2>
|
||
</div>
|
||
<div class="panel-body" style="padding: 16px 20px 24px">
|
||
<el-form ref="formRef" :model="form" label-position="top" class="crm-dialog-form">
|
||
<div class="form-grid-2">
|
||
<el-form-item class="form-col-span-2">
|
||
<template #label>
|
||
默认审批人<span class="form-label-tip">支持多人,按选择顺序依次审批</span>
|
||
</template>
|
||
<el-select v-model="form.approverIds" multiple filterable placeholder="请选择审批人" style="width: 100%">
|
||
<el-option
|
||
v-for="user in userList"
|
||
:key="user.userId"
|
||
:label="formatApproverOptionLabel(user)"
|
||
:value="user.userId"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="状态">
|
||
<el-radio-group v-model="form.status">
|
||
<el-radio value="0">启用</el-radio>
|
||
<el-radio value="1">停用</el-radio>
|
||
</el-radio-group>
|
||
</el-form-item>
|
||
</div>
|
||
</el-form>
|
||
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:16px">
|
||
<el-button @click="loadSetting"><el-icon><RefreshLeft /></el-icon>重置</el-button>
|
||
<el-button type="primary" :loading="saving" @click="handleSave">
|
||
<el-icon><Check /></el-icon>保存设置
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</template>
|
||
```
|
||
|
||
(`el-radio` 的 `value`/`label` 写法与项目 Element Plus 版本保持一致。)
|
||
|
||
- [ ] **Step 3: 本地验证**
|
||
|
||
打开 `/#/crm/quote-approver-setting`(需菜单权限)→ 选人保存 → 刷新仍在。
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add web-admin/src/views/crm/quote/approver-setting/index.vue web-admin/src/router/modules/sqxp.ts
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(web-admin): add quote approver setting page and route
|
||
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: 提交审批弹窗预填
|
||
|
||
**Files:**
|
||
- Modify: `web-admin/src/views/crm/quote/index.vue`
|
||
- Modify: `web-admin/src/views/crm/quote/components/QuoteFormPanel.vue`
|
||
- Modify: `web-admin/src/views/crm/quote/components/ApproverSelector.vue`(若 `open` 仍被使用)
|
||
|
||
- [ ] **Step 1: 抽共用预填逻辑(可内联,避免过度抽象)**
|
||
|
||
在打开提交弹窗处(`handleSubmit` / 表单内提交):
|
||
|
||
```ts
|
||
import { getQuoteApproverSetting } from '@/api/crm/quote'
|
||
// 若后端 helpers 不在前端,前端等价:
|
||
async function resolvePrefillApproverIds(userList: { userId: string | number }[]) {
|
||
try {
|
||
const { data } = await getQuoteApproverSetting()
|
||
if (!data || String(data.status) !== '0') return [] as string[]
|
||
const valid = new Set(userList.map((u) => String(u.userId)))
|
||
return (data.approverIds || []).map(String).filter((id) => valid.has(id))
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: `index.vue` 的 `handleSubmit` / 详情内提交**
|
||
|
||
在 `loadUserList()` 之后:
|
||
|
||
```ts
|
||
const prefillIds = await resolvePrefillApproverIds(userList.value)
|
||
submitForm.value = {
|
||
id,
|
||
approverIds: prefillIds,
|
||
remark: ''
|
||
}
|
||
submitPrefillTip.value = prefillIds.length > 0
|
||
submitDialog.visible = true
|
||
```
|
||
|
||
模板中审批人表单项上方(有预填时):
|
||
|
||
```vue
|
||
<el-alert
|
||
v-if="submitPrefillTip"
|
||
type="success"
|
||
:closable="false"
|
||
show-icon
|
||
title="已按「报价单审批人设置」预填审批人,可修改后提交"
|
||
style="margin-bottom: 12px"
|
||
/>
|
||
```
|
||
|
||
- [ ] **Step 3: `QuoteFormPanel.vue` 同样改造**打开提交对话框的逻辑。
|
||
|
||
- [ ] **Step 4: `ApproverSelector.vue`**
|
||
|
||
在 `open()` 内 `loadUsers` 后预填 `form.approverIds`(类型与选项 `userId` 一致;若当前是 `number[]`,统一为 `string[]` 更稳妥)。
|
||
|
||
- [ ] **Step 5: 手工验收**
|
||
|
||
1. 设置页启用并保存张经理→李总
|
||
2. 报价列表提交审批:弹窗已预填,提示出现
|
||
3. 改人后提交成功
|
||
4. 设置停用或清空:弹窗为空,须手选
|
||
5. 断网/接口失败:弹窗仍可打开,审批人为空
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add web-admin/src/views/crm/quote/index.vue \
|
||
web-admin/src/views/crm/quote/components/QuoteFormPanel.vue \
|
||
web-admin/src/views/crm/quote/components/ApproverSelector.vue
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(web-admin): prefill quote submit approvers from tenant setting
|
||
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: 收尾
|
||
|
||
**Files:**
|
||
- Modify: `docs/superpowers/specs/2026-08-01-quote-approver-setting-design.md`(状态改为「已实现」)
|
||
- Optional: `docs/web-admin-ui-pattern-index.md` 追加一行业务页对照(报价审批人设置 → 配置表单)
|
||
- Run: `./scripts/update-graph.sh backend` 与 `./scripts/update-graph.sh frontend`(或 `all`),若本会话改了代码
|
||
|
||
- [ ] **Step 1: 更新设计文档状态**
|
||
|
||
将 **状态:** 改为 `已实现`。
|
||
|
||
- [ ] **Step 2: 回归 pytest + 关键页面**
|
||
|
||
```bash
|
||
cd backend && source venv/bin/activate && pytest tests/test_quote_approver_setting.py -v
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add docs/superpowers/specs/2026-08-01-quote-approver-setting-design.md docs/web-admin-ui-pattern-index.md
|
||
git commit -m "$(cat <<'EOF'
|
||
docs: mark quote approver setting design implemented
|
||
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Spec coverage checklist
|
||
|
||
| Spec 项 | Task |
|
||
|---------|------|
|
||
| 租户级设置表 | Task 2–3 |
|
||
| GET/PUT(实现为 POST get/save) | Task 4 |
|
||
| 提交可改预填 | Task 7 |
|
||
| 菜单权限维护 | Task 2 + 4 save 守卫 |
|
||
| 未配置=手选 | Task 4 空响应 + Task 7 |
|
||
| 不做默认备注 | 全任务未引入 |
|
||
| 无效用户过滤/校验 | Task 3 save + Task 7 prefill |
|
||
| 验收标准 1–5 | Task 7 Step 5 |
|
||
|
||
## Self-review notes
|
||
|
||
- API 与设计文档的 GET/PUT 字面差异:本计划按 CRM 既有 **POST** 风格落地,语义等价。
|
||
- 租户单条有效配置靠 `save_setting` upsert,不加部分唯一索引(MySQL 5.7)。
|
||
- 前端不强制单测文件(与仓库现状一致);后端用 helpers 单测锁预填语义。
|