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

25 KiB
Raw Blame History

报价单审批人设置 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: 写失败单测

# 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: 跑测确认失败
cd backend && source venv/bin/activate && pytest tests/test_quote_approver_setting.py -v

Expected: FAIL(模块/函数不存在)

  • Step 3: 实现 helpers
# 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: 再跑测确认通过
cd backend && source venv/bin/activate && pytest tests/test_quote_approver_setting.py -v

Expected: PASS

  • Step 5: Commit
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

-- 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(开发环境)
# 按本地 .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
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 模型

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
# 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 追加(要点):

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 未破坏)
cd backend && source venv/bin/activate && pytest tests/test_quote_approver_setting.py -v

Expected: PASS

  • Step 5: Commit
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"))
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
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: 类型

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
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
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: 路由

{
  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
  • 审批人允许空数组保存(清空后不再预填)

参考结构(精简):

<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
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 / 表单内提交):

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() 之后:

const prefillIds = await resolvePrefillApproverIds(userList.value)
submitForm.value = {
  id,
  approverIds: prefillIds,
  remark: ''
}
submitPrefillTip.value = prefillIds.length > 0
submitDialog.visible = true

模板中审批人表单项上方(有预填时):

<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
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 + 关键页面
cd backend && source venv/bin/activate && pytest tests/test_quote_approver_setting.py -v
  • Step 3: Commit
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 单测锁预填语义。