# System Permission Org Mind Map 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:** Build `系统管理 -> 系统权限管理`, including organization mind-map management, role maintenance, system-user creation from existing personnel, and menu permission control without affecting miniapp personnel logic. **Architecture:** Reuse existing `sys_user/sys_role/sys_permission/sys_user_role/sys_role_permission/sys_department/hr_employee` tables. Add only the missing organization fields needed for a stable tree (`sys_department.org_node_type`, `sys_department.manager_employee_id`), expose a new admin-only `/system-permissions` backend module, and add a dedicated Vue page plus reusable orthogonal org-mind-map component. The mind map is deterministic and non-draggable: nodes are laid out by level and subtree order, connectors are SVG orthogonal 90-degree segments rendered in gutters, and overlap avoidance is a hard acceptance rule. **Tech Stack:** FastAPI, SQLAlchemy 2, Pydantic, MySQL 8 / SQLite tests, Vue 3, Vite, plain SVG/CSS for the mind map. --- ## Scope And Non-Negotiables - `基础资料 -> 人员管理` remains the miniapp-linked personnel master page. Do not merge it with this new permission page. - `系统权限管理` may select existing `hr_employee` rows, but must not edit miniapp attendance points, miniapp person roles, temporary-worker flags, or miniapp personnel records. - Phone number from `hr_employee.mobile` is the ERP login account. - Password is required when creating a system user and must be at least 6 characters. - Roles are maintained inside `系统权限管理`; standard first-version roles are `超级管理员`, `采购专员`, `仓库管理人员`, `销售人员`. - Existing role codes stay compatible with current auth logic: - `ADMIN` -> `超级管理员` - `PURCHASER` -> `采购专员` - `WAREHOUSE` -> `仓库管理人员` - `SALES` -> `销售人员` - Other existing roles such as `PLANNER`, `SUPERVISOR`, `OPERATOR`, `FINANCE`, `EQUIP_ADMIN` remain visible as extended roles; do not delete them. - First version is menu-level permissions only. Do not add button-level or row-level data-scope permissions in this iteration. - Brain map must not support free dragging. There must be no free-position state stored in database or local storage. - Brain map connectors must be straight orthogonal connectors with 90-degree corners. No bezier curves, no diagonal lines. - Standard brain map rendering must not show node overlap, connector overlap, or connector crossing through node cards. ## File Structure ### Backend - Modify `backend/app/models/org.py` - Add `Department.parent_id`, `Department.org_node_type`, `Department.manager_employee_id`, `Department.remark`. - Keep existing fields and role/user models. - Create `backend/app/schemas/system_permissions.py` - Pydantic contracts for organization tree, role maintenance, system users, menu permissions. - Create `backend/app/services/system_permissions.py` - All business logic for org tree building, role permission replacement, system-user creation, password reset, and validations. - Create `backend/app/api/routes/system_permissions.py` - Admin-only endpoints under `/system-permissions`. - Modify `backend/app/api/router.py` - Include the new router. - Modify `backend/app/services/auth.py` - Ensure `ADMIN` or super admin also receives `MENU_SYSTEM_PERMISSION`. - Update admin-denied message from `仅系统管理员可访问系统拓展` to `仅系统管理员可访问系统管理`. - Create `backend/sql/add_system_permission_management_patch.sql` - Add missing department columns safely. - Seed `MENU_SYSTEM_PERMISSION`. - Grant `MENU_SYSTEM_PERMISSION` and `MENU_SYSTEM_EXTENSION` to `ADMIN`. - Normalize the four standard role display names. - Modify `backend/sql/jiaheng_erp_schema.sql` - Add new columns to fresh installs. - Add `MENU_SYSTEM_PERMISSION` seed in the auth seed file if this repository keeps seeds separate. - Test `backend/tests/test_system_permission_management.py` - SQLite service/API-level coverage for org tree, roles, user creation, and admin-only behavior. ### Frontend - Create `frontend/src/components/OrgMindMap.vue` - Deterministic non-draggable org chart with 90-degree SVG connectors. - Create `frontend/src/views/SystemPermissionView.vue` - Main page for org tree, role maintenance, system user management, and drawers/modals. - Modify `frontend/src/router/index.js` - Add `/system-permissions`. - Add a system-management redirect route. - Update route permission ordering. - Modify `frontend/src/App.vue` - Rename existing `系统拓展` sidebar stage to `系统管理`. - Add two child menu entries: `系统权限管理` and `系统拓展`. - Modify `frontend/src/styles/main.css` - Add shared org-mind-map and permission-management styles if component-scoped CSS is insufficient. - Test `frontend/scripts/test-system-permission-management-ui.mjs` - Static assertions for menu rename, route wiring, non-draggable mind-map source, and required labels. --- ## Data Contract ### Organization Node ```json { "node_id": 1, "node_type": "DEPARTMENT", "node_label": "采购部", "parent_id": null, "manager_employee_id": 8, "manager_name": "张三", "status": "ACTIVE", "sort_no": 30, "children": [], "employees": [ { "employee_id": 12, "employee_name": "李四", "mobile": "13800000000", "job_title": "采购专员", "username": "13800000000", "role_names": ["采购专员"], "status": "ACTIVE" } ] } ``` ### Mind Map Visual Rules - Organization card text is vertical two-line content: ```text 分公司A 张三 ``` - Department card text is the same style: ```text 采购部 李四 ``` - Employee cards are leaf nodes and have no add-child action. - Layout algorithm: - Convert the tree into levels. - Assign each leaf one fixed row slot. - Assign each parent the vertical midpoint of its child range. - Assign each depth one fixed column. - Draw connectors only in the gap between columns. - Connector algorithm: - For one parent with multiple children, draw one horizontal parent stem, one vertical trunk, and one child horizontal line per child. - Do not draw duplicated paths over the same segment. - No connector may pass through a node rectangle. - No connector may use diagonal or curved segments. --- ## Task 1: Database Patch And ORM Alignment **Files:** - Create: `backend/sql/add_system_permission_management_patch.sql` - Modify: `backend/sql/jiaheng_erp_schema.sql` - Modify: `backend/sql/seed_auth_accounts.sql` - Modify: `backend/app/models/org.py` - Test: `backend/tests/test_system_permission_management.py` - [ ] **Step 1: Write the failing ORM/schema test** Create `backend/tests/test_system_permission_management.py` with this initial content: ```python from __future__ import annotations import unittest from datetime import datetime from sqlalchemy import BigInteger, create_engine, select from sqlalchemy.ext.compiler import compiles from sqlalchemy.orm import Session, sessionmaker @compiles(BigInteger, "sqlite") def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str: _ = type_, compiler, kw return "INTEGER" import app.models.master_data # noqa: E402,F401 import app.models.miniapp # noqa: E402,F401 import app.models.operations # noqa: E402,F401 import app.models.org # noqa: E402,F401 import app.models.planning # noqa: E402,F401 import app.models.sales # noqa: E402,F401 from app.models.base import Base # noqa: E402 from app.models.org import Department, Employee, Permission, Role, RolePermission, User, UserRole # noqa: E402 from app.services.auth import hash_password # noqa: E402 class SystemPermissionManagementTest(unittest.TestCase): def setUp(self) -> None: engine = create_engine("sqlite+pysqlite:///:memory:", future=True) Base.metadata.create_all(engine) self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) self.db: Session = self.SessionLocal() now = datetime(2026, 6, 8, 9, 0, 0) self.company = Department( dept_code="JH", dept_name="嘉恒总公司", parent_id=None, org_node_type="COMPANY", dept_type="ADMIN", manager_name="王总", manager_employee_id=None, status="ACTIVE", sort_no=1, remark="总公司节点", created_at=now, updated_at=now, ) self.db.add(self.company) self.db.flush() def tearDown(self) -> None: self.db.close() def test_department_model_exposes_tree_and_manager_fields(self) -> None: row = self.db.scalar(select(Department).where(Department.dept_code == "JH")) self.assertIsNotNone(row) self.assertEqual(row.parent_id, None) self.assertEqual(row.org_node_type, "COMPANY") self.assertEqual(row.manager_name, "王总") self.assertEqual(row.manager_employee_id, None) self.assertEqual(row.remark, "总公司节点") if __name__ == "__main__": unittest.main() ``` - [ ] **Step 2: Run the failing test** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend .venv/bin/python -m pytest tests/test_system_permission_management.py -q ``` Expected: FAIL because `Department.parent_id`, `Department.org_node_type`, `Department.manager_employee_id`, or `Department.remark` is not mapped yet. - [ ] **Step 3: Update ORM model** Modify `backend/app/models/org.py` `Department` class to include these fields: ```python class Department(Base): __tablename__ = "sys_department" id: Mapped[int] = mapped_column(BigInteger, primary_key=True) dept_code: Mapped[str] = mapped_column(String(50)) dept_name: Mapped[str] = mapped_column(String(100)) parent_id: Mapped[int | None] = mapped_column(ForeignKey("sys_department.id"), nullable=True) org_node_type: Mapped[str] = mapped_column(String(32), server_default=text("'DEPARTMENT'")) dept_type: Mapped[str] = mapped_column(String(32)) manager_name: Mapped[str | None] = mapped_column(String(100), nullable=True) manager_employee_id: Mapped[int | None] = mapped_column(ForeignKey("hr_employee.id"), nullable=True) status: Mapped[str] = mapped_column(String(32)) sort_no: Mapped[int] = mapped_column(Integer, server_default=text("0")) remark: Mapped[str | None] = mapped_column(String(255), nullable=True) created_at: Mapped[object] = mapped_column(DateTime) updated_at: Mapped[object] = mapped_column(DateTime) ``` - [ ] **Step 4: Create SQL patch** Create `backend/sql/add_system_permission_management_patch.sql`: ```sql USE jiaheng_erp; SET NAMES utf8mb4; START TRANSACTION; SET @schema_name := DATABASE(); SET @sql := ( SELECT IF( EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = @schema_name AND table_name = 'sys_department' AND column_name = 'org_node_type' ), 'SELECT 1', 'ALTER TABLE sys_department ADD COLUMN org_node_type VARCHAR(32) NOT NULL DEFAULT ''DEPARTMENT'' COMMENT ''组织节点类型:COMPANY/BRANCH/DEPARTMENT'' AFTER parent_id' ) ); PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @sql := ( SELECT IF( EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = @schema_name AND table_name = 'sys_department' AND column_name = 'manager_employee_id' ), 'SELECT 1', 'ALTER TABLE sys_department ADD COLUMN manager_employee_id BIGINT NULL COMMENT ''负责人员工ID'' AFTER manager_name' ) ); PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @sql := ( SELECT IF( EXISTS ( SELECT 1 FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'sys_department' AND index_name = 'idx_department_parent_sort' ), 'SELECT 1', 'CREATE INDEX idx_department_parent_sort ON sys_department(parent_id, sort_no, id)' ) ); PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; INSERT INTO sys_permission (permission_code, permission_name, module_code, action_code, status) SELECT 'MENU_SYSTEM_PERMISSION', '系统权限管理菜单', 'SYSTEM_PERMISSION', 'VIEW', 'ACTIVE' FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_permission WHERE permission_code = 'MENU_SYSTEM_PERMISSION'); UPDATE sys_role SET role_name = '超级管理员', remark = '系统全局权限、角色、人员账号维护' WHERE role_code = 'ADMIN'; UPDATE sys_role SET role_name = '采购专员', remark = '供应商、采购订单、到货和采购相关库存查询' WHERE role_code = 'PURCHASER'; UPDATE sys_role SET role_name = '仓库管理人员', remark = '到货、质检、嘉恒仓库、发货台账相关作业' WHERE role_code = 'WAREHOUSE'; UPDATE sys_role SET role_name = '销售人员', remark = '客户、销售订单、发货台账查询与协同' WHERE role_code = 'SALES'; INSERT INTO sys_role_permission (role_id, permission_id) SELECT r.id, p.id FROM sys_role r JOIN sys_permission p ON p.permission_code IN ('MENU_SYSTEM_PERMISSION', 'MENU_SYSTEM_EXTENSION') LEFT JOIN sys_role_permission existing ON existing.role_id = r.id AND existing.permission_id = p.id WHERE r.role_code = 'ADMIN' AND existing.id IS NULL; COMMIT; ``` - [ ] **Step 5: Update fresh schema** In `backend/sql/jiaheng_erp_schema.sql`, update the `sys_department` table definition: ```sql parent_id BIGINT NULL COMMENT '上级ID', org_node_type VARCHAR(32) NOT NULL DEFAULT 'DEPARTMENT' COMMENT '组织节点类型:COMPANY/BRANCH/DEPARTMENT', dept_type VARCHAR(32) NOT NULL COMMENT '部门类型:ADMIN/SALES/PURCHASE/WAREHOUSE/PRODUCTION/FINANCE/EQUIPMENT', manager_name VARCHAR(100) NULL COMMENT '负责人姓名', manager_employee_id BIGINT NULL COMMENT '负责人员工ID', ``` Also add this foreign key near the existing `parent_id` foreign key: ```sql FOREIGN KEY (manager_employee_id) REFERENCES hr_employee(id) ``` If the self-reference and employee-reference ordering creates a MySQL circular creation problem, keep `manager_employee_id` without the foreign key in the fresh schema and rely on application validation. Do not block execution on circular DDL. - [ ] **Step 6: Update auth seed permissions** In `backend/sql/seed_auth_accounts.sql`, add `MENU_SYSTEM_PERMISSION` to the permission union: ```sql UNION ALL SELECT 'MENU_SYSTEM_PERMISSION', '系统权限管理菜单', 'SYSTEM_PERMISSION', 'VIEW' ``` Also add admin mapping in the role-permission union: ```sql UNION ALL SELECT 'ADMIN', 'MENU_SYSTEM_PERMISSION' ``` - [ ] **Step 7: Run ORM test again** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend .venv/bin/python -m pytest tests/test_system_permission_management.py -q ``` Expected: PASS. --- ## Task 2: Backend System Permission Schemas And Service **Files:** - Create: `backend/app/schemas/system_permissions.py` - Create: `backend/app/services/system_permissions.py` - Modify: `backend/tests/test_system_permission_management.py` - [ ] **Step 1: Extend failing tests for organization tree and user creation** Append these tests to `SystemPermissionManagementTest` in `backend/tests/test_system_permission_management.py`: ```python def _seed_role_permission_user_data(self) -> None: now = datetime(2026, 6, 8, 9, 0, 0) purchase = Department( dept_code="PURCHASE", dept_name="采购部", parent_id=self.company.id, org_node_type="DEPARTMENT", dept_type="PURCHASE", manager_name="采购主管", manager_employee_id=None, status="ACTIVE", sort_no=10, remark=None, created_at=now, updated_at=now, ) self.db.add(purchase) self.db.flush() manager = Employee( employee_code="EMP-001", employee_name="张三", dept_id=purchase.id, mobile="13800000011", gender=None, hire_date=None, job_title="采购负责人", shift_code=None, manager_employee_id=None, is_operator=0, is_workshop_staff=0, status="ACTIVE", remark="来自人员主数据", created_at=now, updated_at=now, ) buyer = Employee( employee_code="EMP-002", employee_name="李四", dept_id=purchase.id, mobile="13800000012", gender=None, hire_date=None, job_title="采购专员", shift_code=None, manager_employee_id=None, is_operator=0, is_workshop_staff=0, status="ACTIVE", remark=None, created_at=now, updated_at=now, ) role = Role( role_code="PURCHASER", role_name="采购专员", role_scope="SYSTEM", status="ACTIVE", remark=None, created_at=now, updated_at=now, ) permission = Permission( permission_code="MENU_PURCHASE_ORDER", permission_name="采购订单菜单", module_code="PURCHASE_ORDER", action_code="VIEW", status="ACTIVE", created_at=now, updated_at=now, ) self.db.add_all([manager, buyer, role, permission]) self.db.flush() purchase.manager_employee_id = manager.id purchase.manager_name = manager.employee_name self.db.add(purchase) self.db.commit() self.purchase_dept_id = purchase.id self.manager_employee_id = manager.id self.buyer_employee_id = buyer.id self.purchaser_role_id = role.id self.purchase_permission_id = permission.id def test_build_org_tree_embeds_manager_and_employee_leaf_nodes(self) -> None: from app.services.system_permissions import build_org_tree self._seed_role_permission_user_data() tree = build_org_tree(self.db) self.assertEqual(len(tree), 1) self.assertEqual(tree[0].node_label, "嘉恒总公司") purchase = tree[0].children[0] self.assertEqual(purchase.node_label, "采购部") self.assertEqual(purchase.manager_employee_id, self.manager_employee_id) self.assertEqual(purchase.manager_name, "张三") self.assertEqual([employee.employee_name for employee in purchase.employees], ["李四", "张三"]) def test_create_system_user_uses_employee_phone_as_account_and_hashes_password(self) -> None: from app.schemas.system_permissions import SystemUserCreate from app.services.auth import verify_password from app.services.system_permissions import create_system_user self._seed_role_permission_user_data() result = create_system_user( self.db, SystemUserCreate( employee_id=self.buyer_employee_id, role_ids=[self.purchaser_role_id], password="secret123", status="ACTIVE", ), ) self.assertEqual(result.username, "13800000012") self.assertEqual(result.employee_name, "李四") self.assertEqual(result.role_names, ["采购专员"]) user = self.db.scalar(select(User).where(User.username == "13800000012")) self.assertIsNotNone(user) self.assertTrue(verify_password("secret123", user.password_hash)) links = self.db.scalars(select(UserRole).where(UserRole.user_id == user.id)).all() self.assertEqual(len(links), 1) self.assertEqual(links[0].role_id, self.purchaser_role_id) def test_create_system_user_rejects_employee_without_phone(self) -> None: from fastapi import HTTPException from app.schemas.system_permissions import SystemUserCreate from app.services.system_permissions import create_system_user self._seed_role_permission_user_data() now = datetime(2026, 6, 8, 9, 0, 0) employee = Employee( employee_code="EMP-NOPHONE", employee_name="无手机号人员", dept_id=self.purchase_dept_id, mobile=None, gender=None, hire_date=None, job_title=None, shift_code=None, manager_employee_id=None, is_operator=0, is_workshop_staff=0, status="ACTIVE", remark=None, created_at=now, updated_at=now, ) self.db.add(employee) self.db.commit() with self.assertRaises(HTTPException) as ctx: create_system_user( self.db, SystemUserCreate( employee_id=employee.id, role_ids=[self.purchaser_role_id], password="secret123", status="ACTIVE", ), ) self.assertEqual(ctx.exception.status_code, 400) self.assertIn("人员没有电话号码", str(ctx.exception.detail)) ``` - [ ] **Step 2: Run tests to verify they fail** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend .venv/bin/python -m pytest tests/test_system_permission_management.py -q ``` Expected: FAIL because `app.schemas.system_permissions` and `app.services.system_permissions` do not exist. - [ ] **Step 3: Create Pydantic schemas** Create `backend/app/schemas/system_permissions.py`: ```python from __future__ import annotations from datetime import datetime from pydantic import BaseModel, ConfigDict, Field class PermissionRead(BaseModel): permission_id: int permission_code: str permission_name: str module_code: str action_code: str status: str class MenuPermissionNode(BaseModel): permission_code: str permission_name: str children: list["MenuPermissionNode"] = Field(default_factory=list) class OrgEmployeeNode(BaseModel): employee_id: int employee_name: str mobile: str | None = None job_title: str | None = None username: str | None = None role_names: list[str] = Field(default_factory=list) status: str class OrgNodeRead(BaseModel): node_id: int node_type: str node_label: str parent_id: int | None = None dept_code: str dept_type: str manager_employee_id: int | None = None manager_name: str | None = None status: str sort_no: int = 0 remark: str | None = None employees: list[OrgEmployeeNode] = Field(default_factory=list) children: list["OrgNodeRead"] = Field(default_factory=list) class OrgNodeCreate(BaseModel): parent_id: int | None = None node_type: str = Field(pattern="^(COMPANY|BRANCH|DEPARTMENT)$") node_label: str = Field(min_length=1, max_length=100) dept_code: str | None = Field(default=None, max_length=50) dept_type: str = Field(default="ADMIN", max_length=32) manager_employee_id: int | None = None sort_no: int = 0 remark: str | None = Field(default=None, max_length=255) status: str = Field(default="ACTIVE", pattern="^(ACTIVE|INACTIVE)$") class OrgNodeUpdate(BaseModel): node_label: str = Field(min_length=1, max_length=100) dept_type: str = Field(default="ADMIN", max_length=32) manager_employee_id: int | None = None sort_no: int = 0 remark: str | None = Field(default=None, max_length=255) status: str = Field(default="ACTIVE", pattern="^(ACTIVE|INACTIVE)$") class RoleRead(BaseModel): role_id: int role_code: str role_name: str role_scope: str status: str remark: str | None = None permission_codes: list[str] = Field(default_factory=list) user_count: int = 0 created_at: datetime | None = None updated_at: datetime | None = None class RoleCreate(BaseModel): role_code: str = Field(min_length=2, max_length=50, pattern="^[A-Z][A-Z0-9_]*$") role_name: str = Field(min_length=1, max_length=100) role_scope: str = Field(default="SYSTEM", max_length=32) status: str = Field(default="ACTIVE", pattern="^(ACTIVE|INACTIVE)$") remark: str | None = Field(default=None, max_length=500) permission_codes: list[str] = Field(default_factory=list) class RoleUpdate(BaseModel): role_name: str = Field(min_length=1, max_length=100) role_scope: str = Field(default="SYSTEM", max_length=32) status: str = Field(default="ACTIVE", pattern="^(ACTIVE|INACTIVE)$") remark: str | None = Field(default=None, max_length=500) permission_codes: list[str] = Field(default_factory=list) class RolePermissionUpdate(BaseModel): permission_codes: list[str] = Field(default_factory=list) class SystemUserRead(BaseModel): user_id: int username: str employee_id: int employee_name: str dept_id: int dept_name: str | None = None mobile: str | None = None nickname: str | None = None is_super_admin: bool = False status: str role_ids: list[int] = Field(default_factory=list) role_names: list[str] = Field(default_factory=list) last_login_at: datetime | None = None created_at: datetime | None = None updated_at: datetime | None = None class SystemUserCreate(BaseModel): employee_id: int = Field(gt=0) role_ids: list[int] = Field(min_length=1) password: str = Field(min_length=6, max_length=128) status: str = Field(default="ACTIVE", pattern="^(ACTIVE|INACTIVE)$") class SystemUserRoleUpdate(BaseModel): role_ids: list[int] = Field(min_length=1) class SystemUserStatusUpdate(BaseModel): status: str = Field(pattern="^(ACTIVE|INACTIVE)$") class SystemUserPasswordReset(BaseModel): password: str = Field(min_length=6, max_length=128) class EmployeeOption(BaseModel): employee_id: int employee_name: str dept_id: int dept_name: str | None = None mobile: str | None = None job_title: str | None = None has_system_user: bool = False ``` - [ ] **Step 4: Create service implementation** Create `backend/app/services/system_permissions.py`: ```python from __future__ import annotations import re from datetime import datetime from fastapi import HTTPException from sqlalchemy import delete, func, select from sqlalchemy.orm import Session from app.models.org import Department, Employee, Permission, Role, RolePermission, User, UserRole from app.schemas.system_permissions import ( EmployeeOption, MenuPermissionNode, OrgEmployeeNode, OrgNodeCreate, OrgNodeRead, OrgNodeUpdate, PermissionRead, RoleCreate, RoleRead, RoleUpdate, SystemUserCreate, SystemUserRead, ) from app.services.auth import hash_password MENU_PERMISSION_TREE = [ ("MENU_DASHBOARD", "经营驾驶舱", []), ( "MENU_MASTER_DATA", "基础资料", [ ("MENU_SUPPLIER_MANAGEMENT", "供应商名录", []), ("MENU_MATERIAL_MANAGEMENT", "原材料名录", []), ("MENU_SALES_PLANNING", "客户名录/销售订单", []), ("MENU_PRODUCT_MANAGEMENT", "产品管理", []), ("MENU_BOM_MANAGEMENT", "BOM管理", []), ("MENU_ROUTE_MANAGEMENT", "工艺路线管理", []), ("MENU_EMPLOYEE_MANAGEMENT", "人员管理", []), ], ), ( "MENU_PROCUREMENT", "采购与库存", [ ("MENU_PURCHASE_ORDER", "采购订单", []), ("MENU_PURCHASE_RECEIPT", "到货入库", []), ("MENU_QUALITY_INSPECTION", "质量检验", []), ("MENU_INVENTORY_LEDGER", "嘉恒仓库", []), ], ), ( "MENU_PRODUCTION", "生产执行", [ ("MENU_WORK_ORDER", "工单台账", []), ("MENU_OPERATION_REPORT", "工序报工", []), ], ), ("MENU_DELIVERY_MANAGEMENT", "发货概览", []), ("MENU_FINANCE_REPORT", "财务分析", []), ( "MENU_SYSTEM_MANAGEMENT", "系统管理", [ ("MENU_SYSTEM_PERMISSION", "系统权限管理", []), ("MENU_SYSTEM_EXTENSION", "系统拓展", []), ], ), ] def _now() -> datetime: return datetime.now() def _normalize_phone(value: str | None) -> str: return re.sub(r"\s+", "", str(value or "").strip()) def _menu_node(raw: tuple[str, str, list]) -> MenuPermissionNode: code, name, children = raw return MenuPermissionNode( permission_code=code, permission_name=name, children=[_menu_node(child) for child in children], ) def list_menu_permission_tree() -> list[MenuPermissionNode]: return [_menu_node(item) for item in MENU_PERMISSION_TREE] def list_permissions(db: Session) -> list[PermissionRead]: rows = db.scalars(select(Permission).order_by(Permission.module_code, Permission.id)).all() return [ PermissionRead( permission_id=row.id, permission_code=row.permission_code, permission_name=row.permission_name, module_code=row.module_code, action_code=row.action_code, status=row.status, ) for row in rows ] def _employee_role_map(db: Session, employee_ids: list[int]) -> tuple[dict[int, str], dict[int, list[str]]]: if not employee_ids: return {}, {} rows = db.execute( select(User.employee_id, User.username, Role.role_name) .select_from(User) .outerjoin(UserRole, UserRole.user_id == User.id) .outerjoin(Role, Role.id == UserRole.role_id) .where(User.employee_id.in_(employee_ids)) .order_by(User.employee_id, Role.id) ).all() username_map: dict[int, str] = {} role_map: dict[int, list[str]] = {} for employee_id, username, role_name in rows: if employee_id is None: continue username_map.setdefault(employee_id, username) if role_name: role_map.setdefault(employee_id, []) if role_name not in role_map[employee_id]: role_map[employee_id].append(role_name) return username_map, role_map def build_org_tree(db: Session) -> list[OrgNodeRead]: departments = db.scalars(select(Department).order_by(Department.parent_id.is_not(None), Department.sort_no, Department.id)).all() employees = db.scalars(select(Employee).where(Employee.status == "ACTIVE").order_by(Employee.employee_name, Employee.id)).all() employee_ids = [employee.id for employee in employees] username_map, role_map = _employee_role_map(db, employee_ids) employees_by_dept: dict[int, list[OrgEmployeeNode]] = {} for employee in employees: employees_by_dept.setdefault(employee.dept_id, []).append( OrgEmployeeNode( employee_id=employee.id, employee_name=employee.employee_name, mobile=employee.mobile, job_title=employee.job_title, username=username_map.get(employee.id), role_names=role_map.get(employee.id, []), status=employee.status, ) ) nodes: dict[int, OrgNodeRead] = { dept.id: OrgNodeRead( node_id=dept.id, node_type=getattr(dept, "org_node_type", None) or "DEPARTMENT", node_label=dept.dept_name, parent_id=dept.parent_id, dept_code=dept.dept_code, dept_type=dept.dept_type, manager_employee_id=dept.manager_employee_id, manager_name=dept.manager_name, status=dept.status, sort_no=dept.sort_no, remark=dept.remark, employees=employees_by_dept.get(dept.id, []), children=[], ) for dept in departments } roots: list[OrgNodeRead] = [] for dept in departments: node = nodes[dept.id] if dept.parent_id and dept.parent_id in nodes: nodes[dept.parent_id].children.append(node) else: roots.append(node) return roots def _next_department_code(db: Session, node_type: str) -> str: prefix = {"COMPANY": "COMP", "BRANCH": "BR", "DEPARTMENT": "DEPT"}.get(node_type, "DEPT") count = db.scalar(select(func.count(Department.id)).where(Department.dept_code.like(f"{prefix}-%"))) or 0 return f"{prefix}-{int(count) + 1:03d}" def _manager_name(db: Session, employee_id: int | None) -> str | None: if not employee_id: return None employee = db.get(Employee, employee_id) if not employee or employee.status != "ACTIVE": raise HTTPException(status_code=400, detail="负责人不存在或已停用") return employee.employee_name def create_org_node(db: Session, payload: OrgNodeCreate) -> OrgNodeRead: if payload.parent_id and not db.get(Department, payload.parent_id): raise HTTPException(status_code=404, detail="上级组织节点不存在") dept_code = payload.dept_code or _next_department_code(db, payload.node_type) if db.scalar(select(Department).where(Department.dept_code == dept_code)): raise HTTPException(status_code=400, detail="组织编码已存在") now = _now() row = Department( dept_code=dept_code, dept_name=payload.node_label, parent_id=payload.parent_id, org_node_type=payload.node_type, dept_type=payload.dept_type, manager_name=_manager_name(db, payload.manager_employee_id), manager_employee_id=payload.manager_employee_id, status=payload.status, sort_no=payload.sort_no, remark=payload.remark, created_at=now, updated_at=now, ) db.add(row) db.commit() return build_org_tree(db)[0] if row.parent_id is None else next(node for node in _flatten_org_nodes(build_org_tree(db)) if node.node_id == row.id) def update_org_node(db: Session, node_id: int, payload: OrgNodeUpdate) -> OrgNodeRead: row = db.get(Department, node_id) if not row: raise HTTPException(status_code=404, detail="组织节点不存在") row.dept_name = payload.node_label row.dept_type = payload.dept_type row.manager_employee_id = payload.manager_employee_id row.manager_name = _manager_name(db, payload.manager_employee_id) row.sort_no = payload.sort_no row.remark = payload.remark row.status = payload.status row.updated_at = _now() db.add(row) db.commit() return next(node for node in _flatten_org_nodes(build_org_tree(db)) if node.node_id == row.id) def _flatten_org_nodes(nodes: list[OrgNodeRead]) -> list[OrgNodeRead]: result: list[OrgNodeRead] = [] for node in nodes: result.append(node) result.extend(_flatten_org_nodes(node.children)) return result def list_roles(db: Session) -> list[RoleRead]: roles = db.scalars(select(Role).order_by(Role.id)).all() if not roles: return [] permission_rows = db.execute( select(RolePermission.role_id, Permission.permission_code) .join(Permission, Permission.id == RolePermission.permission_id) .where(RolePermission.role_id.in_([role.id for role in roles])) .order_by(RolePermission.role_id, Permission.permission_code) ).all() user_counts = dict( db.execute( select(UserRole.role_id, func.count(UserRole.user_id)) .where(UserRole.role_id.in_([role.id for role in roles])) .group_by(UserRole.role_id) ).all() ) permission_map: dict[int, list[str]] = {} for role_id, code in permission_rows: permission_map.setdefault(role_id, []).append(code) return [ RoleRead( role_id=role.id, role_code=role.role_code, role_name=role.role_name, role_scope=role.role_scope, status=role.status, remark=role.remark, permission_codes=permission_map.get(role.id, []), user_count=int(user_counts.get(role.id, 0)), created_at=role.created_at, updated_at=role.updated_at, ) for role in roles ] def _replace_role_permissions(db: Session, role_id: int, permission_codes: list[str]) -> None: unique_codes = list(dict.fromkeys(permission_codes)) permissions = db.scalars(select(Permission).where(Permission.permission_code.in_(unique_codes))).all() if unique_codes else [] missing = sorted(set(unique_codes) - {row.permission_code for row in permissions}) if missing: raise HTTPException(status_code=400, detail=f"权限不存在:{'、'.join(missing)}") db.execute(delete(RolePermission).where(RolePermission.role_id == role_id)) for permission in permissions: db.add(RolePermission(role_id=role_id, permission_id=permission.id, created_at=_now())) def create_role(db: Session, payload: RoleCreate) -> RoleRead: if db.scalar(select(Role).where(Role.role_code == payload.role_code)): raise HTTPException(status_code=400, detail="角色编码已存在") now = _now() row = Role( role_code=payload.role_code, role_name=payload.role_name, role_scope=payload.role_scope, status=payload.status, remark=payload.remark, created_at=now, updated_at=now, ) db.add(row) db.flush() _replace_role_permissions(db, row.id, payload.permission_codes) db.commit() return next(role for role in list_roles(db) if role.role_id == row.id) def update_role(db: Session, role_id: int, payload: RoleUpdate) -> RoleRead: row = db.get(Role, role_id) if not row: raise HTTPException(status_code=404, detail="角色不存在") row.role_name = payload.role_name row.role_scope = payload.role_scope row.status = payload.status row.remark = payload.remark row.updated_at = _now() db.add(row) _replace_role_permissions(db, row.id, payload.permission_codes) db.commit() return next(role for role in list_roles(db) if role.role_id == row.id) def _system_user_read(db: Session, user: User) -> SystemUserRead: employee = db.get(Employee, user.employee_id) department = db.get(Department, user.dept_id) role_rows = db.execute( select(Role.id, Role.role_name) .join(UserRole, UserRole.role_id == Role.id) .where(UserRole.user_id == user.id) .order_by(Role.id) ).all() return SystemUserRead( user_id=user.id, username=user.username, employee_id=user.employee_id, employee_name=employee.employee_name if employee else user.nickname or user.username, dept_id=user.dept_id, dept_name=department.dept_name if department else None, mobile=employee.mobile if employee else None, nickname=user.nickname, is_super_admin=bool(user.is_super_admin), status=user.status, role_ids=[row[0] for row in role_rows], role_names=[row[1] for row in role_rows], last_login_at=user.last_login_at, created_at=user.created_at, updated_at=user.updated_at, ) def list_system_users(db: Session) -> list[SystemUserRead]: rows = db.scalars(select(User).order_by(User.status, User.id)).all() return [_system_user_read(db, row) for row in rows] def list_employee_options(db: Session) -> list[EmployeeOption]: employees = db.scalars(select(Employee).where(Employee.status == "ACTIVE").order_by(Employee.employee_name, Employee.id)).all() dept_map = {row.id: row.dept_name for row in db.scalars(select(Department)).all()} user_employee_ids = set(db.scalars(select(User.employee_id)).all()) return [ EmployeeOption( employee_id=employee.id, employee_name=employee.employee_name, dept_id=employee.dept_id, dept_name=dept_map.get(employee.dept_id), mobile=employee.mobile, job_title=employee.job_title, has_system_user=employee.id in user_employee_ids, ) for employee in employees ] def _replace_user_roles(db: Session, user_id: int, role_ids: list[int]) -> None: unique_role_ids = list(dict.fromkeys(role_ids)) roles = db.scalars(select(Role).where(Role.id.in_(unique_role_ids), Role.status == "ACTIVE")).all() missing = sorted(set(unique_role_ids) - {row.id for row in roles}) if missing: raise HTTPException(status_code=400, detail=f"角色不存在或已停用:{missing}") db.execute(delete(UserRole).where(UserRole.user_id == user_id)) for role in roles: db.add(UserRole(user_id=user_id, role_id=role.id, created_at=_now())) def create_system_user(db: Session, payload: SystemUserCreate) -> SystemUserRead: employee = db.get(Employee, payload.employee_id) if not employee or employee.status != "ACTIVE": raise HTTPException(status_code=400, detail="人员不存在或已停用") username = _normalize_phone(employee.mobile) if not username: raise HTTPException(status_code=400, detail="人员没有电话号码,不能创建系统账号") if db.scalar(select(User).where(User.employee_id == employee.id)): raise HTTPException(status_code=400, detail="该人员已经有系统账号") if db.scalar(select(User).where(User.username == username)): raise HTTPException(status_code=400, detail="该电话号码已经作为系统账号存在") now = _now() user = User( username=username, password_hash=hash_password(payload.password), employee_id=employee.id, dept_id=employee.dept_id, nickname=employee.employee_name, email=None, is_super_admin=0, last_login_at=None, status=payload.status, created_at=now, updated_at=now, ) db.add(user) db.flush() _replace_user_roles(db, user.id, payload.role_ids) db.commit() return _system_user_read(db, user) def update_system_user_roles(db: Session, user_id: int, role_ids: list[int]) -> SystemUserRead: user = db.get(User, user_id) if not user: raise HTTPException(status_code=404, detail="系统用户不存在") _replace_user_roles(db, user.id, role_ids) user.updated_at = _now() db.add(user) db.commit() return _system_user_read(db, user) def update_system_user_status(db: Session, user_id: int, status: str, current_user_id: int) -> SystemUserRead: user = db.get(User, user_id) if not user: raise HTTPException(status_code=404, detail="系统用户不存在") if user.id == current_user_id and status != "ACTIVE": raise HTTPException(status_code=400, detail="不能停用当前登录账号") user.status = status user.updated_at = _now() db.add(user) db.commit() return _system_user_read(db, user) def reset_system_user_password(db: Session, user_id: int, password: str) -> SystemUserRead: user = db.get(User, user_id) if not user: raise HTTPException(status_code=404, detail="系统用户不存在") user.password_hash = hash_password(password) user.updated_at = _now() db.add(user) db.commit() return _system_user_read(db, user) ``` - [ ] **Step 5: Run backend service tests** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend .venv/bin/python -m pytest tests/test_system_permission_management.py -q ``` Expected: PASS for model and service tests. --- ## Task 3: Backend API Routes And Auth Integration **Files:** - Create: `backend/app/api/routes/system_permissions.py` - Modify: `backend/app/api/router.py` - Modify: `backend/app/services/auth.py` - Modify: `backend/tests/test_system_permission_management.py` - [ ] **Step 1: Add failing auth behavior test** Append this test to `SystemPermissionManagementTest`: ```python def test_admin_context_gets_system_permission_code(self) -> None: from app.services.auth import get_auth_context self._seed_role_permission_user_data() now = datetime(2026, 6, 8, 9, 0, 0) admin_role = Role( role_code="ADMIN", role_name="超级管理员", role_scope="SYSTEM", status="ACTIVE", remark=None, created_at=now, updated_at=now, ) admin_permission = Permission( permission_code="MENU_SYSTEM_PERMISSION", permission_name="系统权限管理菜单", module_code="SYSTEM_PERMISSION", action_code="VIEW", status="ACTIVE", created_at=now, updated_at=now, ) admin_employee = Employee( employee_code="EMP-ADMIN", employee_name="系统管理员", dept_id=self.purchase_dept_id, mobile="13800000099", gender=None, hire_date=None, job_title=None, shift_code=None, manager_employee_id=None, is_operator=0, is_workshop_staff=0, status="ACTIVE", remark=None, created_at=now, updated_at=now, ) self.db.add_all([admin_role, admin_permission, admin_employee]) self.db.flush() admin_user = User( username="admin", password_hash=hash_password("secret123"), employee_id=admin_employee.id, dept_id=self.purchase_dept_id, nickname="系统管理员", email=None, is_super_admin=1, last_login_at=None, status="ACTIVE", created_at=now, updated_at=now, ) self.db.add(admin_user) self.db.flush() self.db.add(UserRole(user_id=admin_user.id, role_id=admin_role.id, created_at=now)) self.db.commit() context = get_auth_context(self.db, "admin") self.assertIn("MENU_SYSTEM_PERMISSION", context.permission_codes) ``` - [ ] **Step 2: Run failing auth test** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend .venv/bin/python -m pytest tests/test_system_permission_management.py::SystemPermissionManagementTest::test_admin_context_gets_system_permission_code -q ``` Expected: FAIL if `get_auth_context` does not append `MENU_SYSTEM_PERMISSION` for admin/super admin. - [ ] **Step 3: Update auth service** Modify `backend/app/services/auth.py`: ```python if user.is_super_admin or "ADMIN" in role_codes: for admin_permission_code in ("MENU_SYSTEM_EXTENSION", "MENU_SYSTEM_PERMISSION"): if admin_permission_code not in permission_codes: permission_codes.append(admin_permission_code) ``` Modify `require_system_admin` message: ```python raise HTTPException(status_code=403, detail="仅系统管理员可访问系统管理") ``` - [ ] **Step 4: Create API route module** Create `backend/app/api/routes/system_permissions.py`: ```python from __future__ import annotations from fastapi import APIRouter, Depends from sqlalchemy.orm import Session from app.db.session import get_db from app.schemas.system_permissions import ( EmployeeOption, MenuPermissionNode, OrgNodeCreate, OrgNodeRead, OrgNodeUpdate, PermissionRead, RoleCreate, RolePermissionUpdate, RoleRead, RoleUpdate, SystemUserCreate, SystemUserPasswordReset, SystemUserRead, SystemUserRoleUpdate, SystemUserStatusUpdate, ) from app.services.auth import AuthContext, require_system_admin from app.services.system_permissions import ( build_org_tree, create_org_node, create_role, create_system_user, list_employee_options, list_menu_permission_tree, list_permissions, list_roles, list_system_users, reset_system_user_password, update_org_node, update_role, update_system_user_roles, update_system_user_status, ) router = APIRouter(dependencies=[Depends(require_system_admin)]) @router.get("/org-tree", response_model=list[OrgNodeRead]) def get_org_tree(db: Session = Depends(get_db)) -> list[OrgNodeRead]: return build_org_tree(db) @router.post("/org-nodes", response_model=OrgNodeRead) def post_org_node(payload: OrgNodeCreate, db: Session = Depends(get_db)) -> OrgNodeRead: return create_org_node(db, payload) @router.put("/org-nodes/{node_id}", response_model=OrgNodeRead) def put_org_node(node_id: int, payload: OrgNodeUpdate, db: Session = Depends(get_db)) -> OrgNodeRead: return update_org_node(db, node_id, payload) @router.get("/permissions", response_model=list[PermissionRead]) def get_permissions(db: Session = Depends(get_db)) -> list[PermissionRead]: return list_permissions(db) @router.get("/menu-permission-tree", response_model=list[MenuPermissionNode]) def get_menu_permission_tree() -> list[MenuPermissionNode]: return list_menu_permission_tree() @router.get("/roles", response_model=list[RoleRead]) def get_roles(db: Session = Depends(get_db)) -> list[RoleRead]: return list_roles(db) @router.post("/roles", response_model=RoleRead) def post_role(payload: RoleCreate, db: Session = Depends(get_db)) -> RoleRead: return create_role(db, payload) @router.put("/roles/{role_id}", response_model=RoleRead) def put_role(role_id: int, payload: RoleUpdate, db: Session = Depends(get_db)) -> RoleRead: return update_role(db, role_id, payload) @router.put("/roles/{role_id}/permissions", response_model=RoleRead) def put_role_permissions(role_id: int, payload: RolePermissionUpdate, db: Session = Depends(get_db)) -> RoleRead: role = next((item for item in list_roles(db) if item.role_id == role_id), None) if not role: from fastapi import HTTPException raise HTTPException(status_code=404, detail="角色不存在") return update_role( db, role_id, RoleUpdate( role_name=role.role_name, role_scope=role.role_scope, status=role.status, remark=role.remark, permission_codes=payload.permission_codes, ), ) @router.get("/employee-options", response_model=list[EmployeeOption]) def get_employee_options(db: Session = Depends(get_db)) -> list[EmployeeOption]: return list_employee_options(db) @router.get("/users", response_model=list[SystemUserRead]) def get_users(db: Session = Depends(get_db)) -> list[SystemUserRead]: return list_system_users(db) @router.post("/users", response_model=SystemUserRead) def post_user(payload: SystemUserCreate, db: Session = Depends(get_db)) -> SystemUserRead: return create_system_user(db, payload) @router.put("/users/{user_id}/roles", response_model=SystemUserRead) def put_user_roles(user_id: int, payload: SystemUserRoleUpdate, db: Session = Depends(get_db)) -> SystemUserRead: return update_system_user_roles(db, user_id, payload.role_ids) @router.put("/users/{user_id}/status", response_model=SystemUserRead) def put_user_status( user_id: int, payload: SystemUserStatusUpdate, context: AuthContext = Depends(require_system_admin), db: Session = Depends(get_db), ) -> SystemUserRead: return update_system_user_status(db, user_id, payload.status, context.user.id) @router.post("/users/{user_id}/reset-password", response_model=SystemUserRead) def post_user_password_reset( user_id: int, payload: SystemUserPasswordReset, db: Session = Depends(get_db), ) -> SystemUserRead: return reset_system_user_password(db, user_id, payload.password) ``` - [ ] **Step 5: Include the router** Modify `backend/app/api/router.py` imports: ```python system_permissions, ``` Add this include after `system_extension` or before dashboard: ```python api_router.include_router(system_permissions.router, prefix="/system-permissions", tags=["system-permissions"]) ``` - [ ] **Step 6: Run backend tests** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend .venv/bin/python -m pytest tests/test_system_permission_management.py -q ``` Expected: PASS. --- ## Task 4: Frontend Menu, Router, And Static UI Tests **Files:** - Modify: `frontend/src/router/index.js` - Modify: `frontend/src/App.vue` - Create: `frontend/src/views/SystemPermissionView.vue` - Create: `frontend/src/components/OrgMindMap.vue` - Create: `frontend/scripts/test-system-permission-management-ui.mjs` - [ ] **Step 1: Write failing frontend static test** Create `frontend/scripts/test-system-permission-management-ui.mjs`: ```javascript import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; const appSource = readFileSync(new URL("../src/App.vue", import.meta.url), "utf8"); const routerSource = readFileSync(new URL("../src/router/index.js", import.meta.url), "utf8"); const permissionView = readFileSync(new URL("../src/views/SystemPermissionView.vue", import.meta.url), "utf8"); const orgMindMap = readFileSync(new URL("../src/components/OrgMindMap.vue", import.meta.url), "utf8"); assert.match(appSource, /label:\s*"系统管理"/, "sidebar parent module must be 系统管理"); assert.match(appSource, /label:\s*"系统权限管理"/, "sidebar must include 系统权限管理 child menu"); assert.match(appSource, /label:\s*"系统拓展"/, "existing 系统拓展 must remain under 系统管理"); assert.match(appSource, /permissionsAny:\s*\[[\s\S]*"MENU_SYSTEM_PERMISSION"[\s\S]*"MENU_SYSTEM_EXTENSION"[\s\S]*\]/, "system parent must accept both system permissions"); assert.match(routerSource, /SystemPermissionView/, "router must import SystemPermissionView"); assert.match(routerSource, /path:\s*"\/system-management"/, "router must include system-management redirect"); assert.match(routerSource, /path:\s*"\/system-permissions"/, "router must include system-permissions route"); assert.match(routerSource, /permission:\s*"MENU_SYSTEM_PERMISSION"/, "system permission route must require MENU_SYSTEM_PERMISSION"); assert.match(permissionView, /系统权限管理/, "permission page title must be visible"); assert.match(permissionView, /新增系统使用人员/, "permission page must have create-user action"); assert.match(permissionView, /角色维护/, "permission page must include role maintenance"); assert.match(permissionView, /人员.*电话号码.*账号/s, "permission page must explain phone-as-account flow"); assert.match(permissionView, /密码/, "permission page must include password field"); assert.match(permissionView, /最少6位/, "permission page must show min password rule"); assert.match(orgMindMap, /defineProps/, "org mind map must be a Vue component"); assert.match(orgMindMap, /orthogonal/i, "mind map source must name orthogonal connector logic"); assert.match(orgMindMap, /H \\$\\{connector\.midX\\} V \\$\\{connector\.toY\\} H \\$\\{connector\.toX\\}/, "connectors must be 90-degree SVG path commands"); assert.doesNotMatch(orgMindMap, /draggable\s*=\s*["']true["']/, "mind map nodes must not be draggable"); assert.doesNotMatch(orgMindMap, /@drag|dragstart|dragend|pointermove|mousemove/, "mind map must not implement free dragging"); assert.doesNotMatch(orgMindMap, /C\s*\$|Q\s*\$/, "mind map must not use curved SVG connectors"); console.log("system permission management UI checks passed"); ``` - [ ] **Step 2: Run failing frontend test** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend node scripts/test-system-permission-management-ui.mjs ``` Expected: FAIL because the new view/component/routes do not exist yet. - [ ] **Step 3: Create the route-safe first version of the view and component** Create `frontend/src/components/OrgMindMap.vue`: ```vue ``` - [ ] **Step 4: Create main view** Create `frontend/src/views/SystemPermissionView.vue`: ```vue ``` The temporary alert actions in this first version are removed in Task 6 and replaced with real drawers and forms. - [ ] **Step 5: Update router** Modify `frontend/src/router/index.js`: ```javascript import SystemPermissionView from "../views/SystemPermissionView.vue"; ``` Add a system permission order: ```javascript const systemPermissionOrder = [ { routeName: "system-permissions", permission: "MENU_SYSTEM_PERMISSION" }, { routeName: "system-extension", permission: "MENU_SYSTEM_EXTENSION" } ]; ``` Add helper: ```javascript function getFirstAllowedSystemRoute(permissionCodes = readSession()?.permission_codes || [], roleCodes = readSession()?.role_codes || []) { const matched = systemPermissionOrder.find((item) => { return permissionCodes.includes(item.permission) || roleCodes.includes("ADMIN"); }); return matched?.routeName || "dashboard"; } ``` Update `navPermissionOrder` system entry: ```javascript { routeName: "system-management", permissionsAny: ["MENU_SYSTEM_PERMISSION", "MENU_SYSTEM_EXTENSION"] } ``` Update `getFirstAllowedRoute()`: ```javascript if (matched?.routeName === "system-management") { return getFirstAllowedSystemRoute(permissionCodes, roleCodes); } ``` Add routes before `/system-extension`: ```javascript { path: "/system-management", name: "system-management", redirect: () => ({ name: getFirstAllowedSystemRoute() }), meta: { title: "系统管理", requiresAuth: true, permissionsAny: ["MENU_SYSTEM_PERMISSION", "MENU_SYSTEM_EXTENSION"] } }, { path: "/system-permissions", name: "system-permissions", component: SystemPermissionView, meta: { title: "系统权限管理", requiresAuth: true, permission: "MENU_SYSTEM_PERMISSION" } }, ``` - [ ] **Step 6: Update sidebar navigation** In `frontend/src/App.vue`, replace the current single `system-extension` stage with: ```javascript { key: "system-management", step: "08", label: "系统管理", desc: "权限、组织、角色账号和平台拓展统一管理。", tail: "系统", iconPaths: [ "M12 4L20 8V16L12 20L4 16V8L12 4Z", "M12 8V12L15 14", "M8 10H9", "M16 10H17" ], to: { name: "system-management" }, routeNames: ["system-management", "system-permissions", "system-extension"], permissionsAny: ["MENU_SYSTEM_PERMISSION", "MENU_SYSTEM_EXTENSION"], children: [ { key: "system-permissions", label: "系统权限管理", to: { name: "system-permissions" }, routeNames: ["system-permissions"], permission: "MENU_SYSTEM_PERMISSION", iconPaths: [ "M6 19V5H18V19H6Z", "M9 9H15", "M9 13H15", "M9 17H12" ] }, { key: "system-extension", label: "系统拓展", to: { name: "system-extension" }, routeNames: ["system-extension"], permission: "MENU_SYSTEM_EXTENSION", iconPaths: [ "M5 12H19", "M12 5V19", "M7 7L17 17", "M17 7L7 17" ] } ] } ``` Update fallback permission checks so admin sees both system child menus: ```javascript return permissionCodes.includes(item.permission) || (item.permission?.startsWith("MENU_SYSTEM_") && roleCodes.includes("ADMIN")); ``` - [ ] **Step 7: Run frontend static test** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend node scripts/test-system-permission-management-ui.mjs ``` Expected: PASS. --- ## Task 5: Production-Quality Org Mind Map Component **Files:** - Modify: `frontend/src/components/OrgMindMap.vue` - Modify: `frontend/scripts/test-system-permission-management-ui.mjs` - [ ] **Step 1: Add source-level overlap acceptance checks** Extend `frontend/scripts/test-system-permission-management-ui.mjs`: ```javascript assert.match(orgMindMap, /function measureSubtree/, "mind map must measure subtree leaf spans before layout"); assert.match(orgMindMap, /function layoutTree/, "mind map must use deterministic tree layout"); assert.match(orgMindMap, /connectorSegments/, "mind map must deduplicate connector segments"); assert.match(orgMindMap, /COLUMN_GAP/, "mind map must reserve a column gutter for connector lines"); assert.match(orgMindMap, /NODE_WIDTH/, "mind map must know node width for line avoidance"); ``` - [ ] **Step 2: Run failing frontend test** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend node scripts/test-system-permission-management-ui.mjs ``` Expected: FAIL until the component has deterministic subtree measurement and deduped connectors. - [ ] **Step 3: Replace `OrgMindMap.vue` script with deterministic layout** Keep the template from Task 4 and replace the script with: ```vue ``` Keep the template path binding from Task 4. Deduplicated segments are mapped into `layout.connectors`, so the template remains: ```vue ``` For a horizontal segment, the `V` command is a no-op; for a vertical segment, the first or last `H` command is a no-op. This keeps the source-level orthogonal check while rendering deduplicated segments. - [ ] **Step 4: Add component styles** Add scoped styles in `frontend/src/components/OrgMindMap.vue`: ```vue ``` - [ ] **Step 5: Run frontend static test** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend node scripts/test-system-permission-management-ui.mjs ``` Expected: PASS. --- ## Task 6: Complete System Permission Page Forms **Files:** - Modify: `frontend/src/views/SystemPermissionView.vue` - Modify: `frontend/scripts/test-system-permission-management-ui.mjs` - [ ] **Step 1: Add failing test assertions for real forms** Extend `frontend/scripts/test-system-permission-management-ui.mjs`: ```javascript assert.match(permissionView, /createUserForm/, "page must have real system user create form state"); assert.match(permissionView, /employee-options/, "page must load employee options from backend"); assert.match(permissionView, /role_ids/, "page must submit role ids"); assert.match(permissionView, /\/system-permissions\/users/, "page must create system users through backend"); assert.match(permissionView, /reset-password/, "page must support password reset"); assert.match(permissionView, /menu-permission-tree/, "page must load menu permission tree"); assert.match(permissionView, /permission_codes/, "role form must submit permission codes"); assert.doesNotMatch(permissionView, /window\.alert\("新增系统使用人员抽屉将在实现任务中接入/, "temporary user alert must be removed"); assert.doesNotMatch(permissionView, /window\.alert\("新增角色抽屉将在实现任务中接入/, "temporary role alert must be removed"); ``` - [ ] **Step 2: Run failing frontend test** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend node scripts/test-system-permission-management-ui.mjs ``` Expected: FAIL because forms are still temporary alerts. - [ ] **Step 3: Implement create user drawer state and submit** In `frontend/src/views/SystemPermissionView.vue` script, add: ```javascript import { computed, onMounted, reactive, ref } from "vue"; import { fetchResource, postResource, putResource } from "../services/api"; import { notifyError, notifySuccess } from "../utils/notifications"; const employeeOptions = ref([]); const showUserDrawer = ref(false); const createUserForm = reactive({ employee_id: "", role_ids: [], phone: "", password: "", status: "ACTIVE" }); const selectedEmployee = computed(() => employeeOptions.value.find((employee) => String(employee.employee_id) === String(createUserForm.employee_id)) ); function syncSelectedEmployeePhone() { createUserForm.phone = selectedEmployee.value?.mobile || ""; } function openUserDrawer() { createUserForm.employee_id = ""; createUserForm.role_ids = []; createUserForm.phone = ""; createUserForm.password = ""; createUserForm.status = "ACTIVE"; showUserDrawer.value = true; } async function submitCreateUser() { if (!createUserForm.employee_id) { notifyError("请选择人员"); return; } if (!createUserForm.phone) { notifyError("该人员没有电话号码,不能作为账号"); return; } if (!createUserForm.role_ids.length) { notifyError("请选择角色"); return; } if (!createUserForm.password || createUserForm.password.length < 6) { notifyError("密码最少6位"); return; } await postResource("/system-permissions/users", { employee_id: Number(createUserForm.employee_id), role_ids: createUserForm.role_ids.map(Number), password: createUserForm.password, status: createUserForm.status }); notifySuccess("系统使用人员已新增"); showUserDrawer.value = false; await loadAll(); } ``` In `loadAll()`, fetch employee options: ```javascript const [nextOrgTree, nextRoles, nextUsers, nextEmployeeOptions, nextPermissionTree] = await Promise.all([ fetchResource("/system-permissions/org-tree", []), fetchResource("/system-permissions/roles", []), fetchResource("/system-permissions/users", []), fetchResource("/system-permissions/employee-options", []), fetchResource("/system-permissions/menu-permission-tree", []) ]); employeeOptions.value = nextEmployeeOptions; permissionTree.value = nextPermissionTree; ``` Add drawer template: ```vue
``` - [ ] **Step 4: Implement role maintenance form** Add role state: ```javascript const permissionTree = ref([]); const showRoleDrawer = ref(false); const editingRole = ref(null); const roleForm = reactive({ role_code: "", role_name: "", role_scope: "SYSTEM", status: "ACTIVE", remark: "", permission_codes: [] }); function openRoleDrawer(role = null) { editingRole.value = role; roleForm.role_code = role?.role_code || ""; roleForm.role_name = role?.role_name || ""; roleForm.role_scope = role?.role_scope || "SYSTEM"; roleForm.status = role?.status || "ACTIVE"; roleForm.remark = role?.remark || ""; roleForm.permission_codes = [...(role?.permission_codes || [])]; showRoleDrawer.value = true; } function togglePermission(code, checked) { const next = new Set(roleForm.permission_codes); if (checked) { next.add(code); } else { next.delete(code); } roleForm.permission_codes = Array.from(next); } async function submitRole() { if (!roleForm.role_name.trim()) { notifyError("请输入角色名称"); return; } if (!editingRole.value && !roleForm.role_code.trim()) { notifyError("请输入角色编码"); return; } const payload = { role_name: roleForm.role_name, role_scope: roleForm.role_scope, status: roleForm.status, remark: roleForm.remark, permission_codes: roleForm.permission_codes }; if (editingRole.value) { await putResource(`/system-permissions/roles/${editingRole.value.role_id}`, payload); } else { await postResource("/system-permissions/roles", { role_code: roleForm.role_code, ...payload }); } notifySuccess("角色已保存"); showRoleDrawer.value = false; await loadAll(); } ``` Add permission tree template helper block: ```vue
``` - [ ] **Step 5: Add reset password and user role update** Add: ```javascript async function resetPassword(user) { const password = window.prompt(`请输入 ${user.employee_name} 的新密码,最少6位`); if (!password) { return; } if (password.length < 6) { notifyError("密码最少6位"); return; } await postResource(`/system-permissions/users/${user.user_id}/reset-password`, { password }); notifySuccess("密码已重置"); } async function toggleUserStatus(user) { await putResource(`/system-permissions/users/${user.user_id}/status`, { status: user.status === "ACTIVE" ? "INACTIVE" : "ACTIVE" }); notifySuccess("账号状态已更新"); await loadAll(); } ``` Add user card buttons: ```vue
``` - [ ] **Step 6: Run frontend static test** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend node scripts/test-system-permission-management-ui.mjs ``` Expected: PASS. --- ## Task 7: Organization Node Editing **Files:** - Modify: `frontend/src/views/SystemPermissionView.vue` - Modify: `backend/app/services/system_permissions.py` - Test: `frontend/scripts/test-system-permission-management-ui.mjs` - Test: `backend/tests/test_system_permission_management.py` - [ ] **Step 1: Add backend test for creating child department** Append: ```python def test_create_org_node_adds_child_under_parent(self) -> None: from app.schemas.system_permissions import OrgNodeCreate from app.services.system_permissions import create_org_node, build_org_tree payload = OrgNodeCreate( parent_id=self.company.id, node_type="BRANCH", node_label="分公司A", dept_code="BR-A", dept_type="ADMIN", manager_employee_id=None, sort_no=1, remark="分公司节点", status="ACTIVE", ) create_org_node(self.db, payload) tree = build_org_tree(self.db) self.assertEqual(tree[0].children[0].node_label, "分公司A") self.assertEqual(tree[0].children[0].parent_id, self.company.id) self.assertEqual(tree[0].children[0].node_type, "BRANCH") ``` - [ ] **Step 2: Run backend test** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend .venv/bin/python -m pytest tests/test_system_permission_management.py::SystemPermissionManagementTest::test_create_org_node_adds_child_under_parent -q ``` Expected: PASS if Task 2 service is complete. - [ ] **Step 3: Add frontend static assertions** Extend frontend test: ```javascript assert.match(permissionView, /openOrgDrawer/, "page must support adding or editing org nodes"); assert.match(permissionView, /\/system-permissions\/org-nodes/, "page must persist org nodes through backend"); assert.match(permissionView, /node_type/, "org form must capture node type"); assert.match(permissionView, /负责人/, "org form must support manager binding"); assert.match(permissionView, /员工节点不能添加下级/, "page must explicitly prevent employee-node children"); ``` - [ ] **Step 4: Add org drawer state and actions** In `SystemPermissionView.vue`, add: ```javascript const showOrgDrawer = ref(false); const editingOrgNode = ref(null); const orgForm = reactive({ parent_id: null, node_type: "DEPARTMENT", node_label: "", dept_code: "", dept_type: "ADMIN", manager_employee_id: "", sort_no: 0, remark: "", status: "ACTIVE" }); function openOrgDrawer(parent = null, node = null) { if (parent?.employee_id) { notifyError("员工节点不能添加下级"); return; } editingOrgNode.value = node; orgForm.parent_id = parent?.node_id || node?.parent_id || null; orgForm.node_type = node?.node_type || "DEPARTMENT"; orgForm.node_label = node?.node_label || ""; orgForm.dept_code = node?.dept_code || ""; orgForm.dept_type = node?.dept_type || "ADMIN"; orgForm.manager_employee_id = node?.manager_employee_id || ""; orgForm.sort_no = node?.sort_no || 0; orgForm.remark = node?.remark || ""; orgForm.status = node?.status || "ACTIVE"; showOrgDrawer.value = true; } async function submitOrgNode() { if (!orgForm.node_label.trim()) { notifyError("请输入组织名称"); return; } const payload = { parent_id: orgForm.parent_id, node_type: orgForm.node_type, node_label: orgForm.node_label, dept_code: orgForm.dept_code || null, dept_type: orgForm.dept_type, manager_employee_id: orgForm.manager_employee_id ? Number(orgForm.manager_employee_id) : null, sort_no: Number(orgForm.sort_no || 0), remark: orgForm.remark, status: orgForm.status }; if (editingOrgNode.value) { await putResource(`/system-permissions/org-nodes/${editingOrgNode.value.node_id}`, payload); } else { await postResource("/system-permissions/org-nodes", payload); } notifySuccess("组织节点已保存"); showOrgDrawer.value = false; await loadAll(); } ``` Add node-detail action buttons: ```vue

员工节点不能添加下级,只能在系统人员中维护角色。

``` - [ ] **Step 5: Run frontend static test** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend node scripts/test-system-permission-management-ui.mjs ``` Expected: PASS. --- ## Task 8: Final Verification **Files:** - All touched files from previous tasks. - [ ] **Step 1: Run backend focused tests** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend .venv/bin/python -m pytest tests/test_system_permission_management.py -q ``` Expected: all tests PASS. - [ ] **Step 2: Run full backend test suite** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend .venv/bin/python -m pytest -q ``` Expected: all tests PASS. Existing suite recently had more than 200 tests; if failures appear outside this feature, inspect before claiming success. - [ ] **Step 3: Run frontend static tests** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend node scripts/test-system-permission-management-ui.mjs ``` Expected: `system permission management UI checks passed`. - [ ] **Step 4: Run frontend build** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend npm run build ``` Expected: Vite build succeeds. Chunk-size warning is acceptable if it matches current project behavior. - [ ] **Step 5: Run backend import check** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend .venv/bin/python - <<'PY' from app.api.router import api_router paths = sorted(route.path for route in api_router.routes) assert "/system-permissions/org-tree" in paths assert "/system-permissions/users" in paths print("system-permissions router registered") PY ``` Expected: `system-permissions router registered`. - [ ] **Step 6: Manual browser verification** Start backend and frontend if they are not running: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend .venv/bin/uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 ``` ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend npm run dev -- --host 0.0.0.0 --port 5173 ``` Open: ```text http://127.0.0.1:5173/system-permissions ``` Verify: - Sidebar shows `系统管理` as the parent. - `系统管理` contains `系统权限管理` and `系统拓展`. - `系统权限管理` loads only for admin users. - Brain map nodes show organization name and responsible person as vertical two lines. - Employee nodes are leaves and do not show add-child behavior. - Brain map cannot be dragged. - Connectors are orthogonal 90-degree lines. - No nodes overlap. - No connector passes through node cards. - Create system user drawer selects an existing person, phone is read-only account, password enforces 6 characters, and save creates a login account. - Role maintenance can change menu permissions and the route filter respects the updated permissions after re-login. --- ## Self-Review ### Spec Coverage - `系统拓展` renamed to parent `系统管理`: Task 4. - Existing `系统拓展` page moved under `系统管理`: Task 4. - New `系统权限管理`: Tasks 3, 4, 6, 7. - New user creation from `基础资料 -> 人员管理`: Tasks 2, 3, 6. - Phone as account: Tasks 2 and 6. - Password required, min 6: Tasks 2 and 6. - Roles maintainable on page: Tasks 2, 3, 6. - Role assignment to people: Tasks 2 and 6. - Tree hierarchy `总公司 -> 分公司 -> 部门 -> 人员`: Tasks 1, 2, 5, 7. - Responsible person displayed vertically under org name, not as side text: Data Contract and Task 5. - Employee node has no child node: Data Contract and Task 7. - Brain map non-draggable: Task 5 static and manual checks. - 90-degree orthogonal connectors: Task 5. - No overlap/crossing standard: Data Contract, Task 5 layout, manual verification. - Do not affect miniapp personnel usage: Scope and service design. ### Placeholder Scan - Plan has no unresolved implementation markers. - The only temporary alert logic exists intentionally in Task 4 and is explicitly removed by Task 6 test. - All new endpoint paths and permission codes are concrete. ### Type Consistency - Backend uses `node_id`, `node_type`, `node_label`, `manager_employee_id`, `role_ids`, `permission_codes` consistently across schema, service, route, and frontend plan. - Existing role codes remain English internally for compatibility, but user-facing labels are Chinese. - New menu permission code is consistently `MENU_SYSTEM_PERMISSION`. --- ## Execution Recommendation Use **Subagent-Driven** execution for this plan. The work splits cleanly into backend schema/service/API, frontend navigation/mind-map, and page-form polish. A fresh subagent per task reduces context bleed and makes review checkpoints safer, especially because this touches login permissions.