Files
shanxia2.0/docs/superpowers/plans/2026-08-01-quote-approver-setting.md
T
2026-08-01 17:29:37 +08:00

762 lines
25 KiB
Markdown
Raw 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.
# 报价单审批人设置 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 单测锁预填语义。