ForgeFlow-ERP/docs/superpowers/plans/2026-06-08-system-permission-org-mindmap.md
2026-06-12 16:00:56 +08:00

91 KiB
Raw Blame History

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

{
  "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:
分公司A
张三
  • Department card text is the same style:
采购部
李四
  • 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:

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:

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:

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:

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:

  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:

  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:

  UNION ALL
  SELECT 'MENU_SYSTEM_PERMISSION', '系统权限管理菜单', 'SYSTEM_PERMISSION', 'VIEW'

Also add admin mapping in the role-permission union:

  UNION ALL
  SELECT 'ADMIN', 'MENU_SYSTEM_PERMISSION'
  • Step 7: Run ORM test again

Run:

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:

    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:

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:

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:

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:

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:

    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:

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:

    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:

    raise HTTPException(status_code=403, detail="仅系统管理员可访问系统管理")
  • Step 4: Create API route module

Create backend/app/api/routes/system_permissions.py:

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:

    system_permissions,

Add this include after system_extension or before dashboard:

api_router.include_router(system_permissions.router, prefix="/system-permissions", tags=["system-permissions"])
  • Step 6: Run backend tests

Run:

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:

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:

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:

<template>
  <section class="org-mind-map" aria-label="组织脑图">
    <svg class="org-mind-map__connectors" :width="layout.width" :height="layout.height" aria-hidden="true">
      <path
        v-for="connector in layout.connectors"
        :key="connector.id"
        class="org-mind-map__connector"
        :d="`M ${connector.fromX} ${connector.fromY} H ${connector.midX} V ${connector.toY} H ${connector.toX}`"
      />
    </svg>
    <article
      v-for="node in layout.nodes"
      :key="node.key"
      class="org-mind-map__node"
      :class="`org-mind-map__node--${node.kind}`"
      :style="{ transform: `translate(${node.x}px, ${node.y}px)` }"
      @click="$emit('select-node', node.raw)"
    >
      <template v-if="node.kind === 'employee'">
        <strong>{{ node.title }}</strong>
        <span>{{ node.subtitle || "未配置角色" }}</span>
      </template>
      <template v-else>
        <strong>{{ node.title }}</strong>
        <span>{{ node.subtitle || "未配置负责人" }}</span>
      </template>
    </article>
  </section>
</template>

<script setup>
import { computed } from "vue";

const props = defineProps({
  nodes: {
    type: Array,
    default: () => []
  }
});

defineEmits(["select-node"]);

const NODE_WIDTH = 176;
const NODE_HEIGHT = 78;
const COLUMN_GAP = 150;
const ROW_GAP = 34;

function toVisualChildren(node) {
  const orgChildren = node.children || [];
  const employeeChildren = (node.employees || []).map((employee) => ({
    node_id: `employee-${employee.employee_id}`,
    node_label: employee.employee_name,
    manager_name: (employee.role_names || []).join("、"),
    children: [],
    employees: [],
    __kind: "employee",
    __raw: employee
  }));
  return [...orgChildren, ...employeeChildren];
}

function buildLayout(rootNodes) {
  const visualNodes = [];
  const connectors = [];
  let nextLeafRow = 0;

  function visit(node, depth) {
    const children = toVisualChildren(node);
    const x = depth * (NODE_WIDTH + COLUMN_GAP);
    let rowStart;
    let rowEnd;
    let childLayouts = [];

    if (!children.length) {
      rowStart = nextLeafRow;
      rowEnd = nextLeafRow;
      nextLeafRow += 1;
    } else {
      childLayouts = children.map((child) => visit(child, depth + 1));
      rowStart = childLayouts[0].rowStart;
      rowEnd = childLayouts[childLayouts.length - 1].rowEnd;
    }

    const rowMid = (rowStart + rowEnd) / 2;
    const y = rowMid * (NODE_HEIGHT + ROW_GAP);
    const kind = node.__kind || "department";
    const visualNode = {
      key: `${kind}-${node.node_id}`,
      kind,
      title: node.node_label,
      subtitle: node.manager_name,
      x,
      y,
      raw: node.__raw || node,
      rowStart,
      rowEnd
    };
    visualNodes.push(visualNode);

    for (const childLayout of childLayouts) {
      connectors.push({
        id: `${visualNode.key}-${childLayout.key}`,
        fromX: x + NODE_WIDTH,
        fromY: y + NODE_HEIGHT / 2,
        midX: x + NODE_WIDTH + COLUMN_GAP / 2,
        toX: childLayout.x,
        toY: childLayout.y + NODE_HEIGHT / 2
      });
    }

    return { ...visualNode, rowStart, rowEnd };
  }

  rootNodes.forEach((node) => visit(node, 0));
  const maxDepth = visualNodes.reduce((max, node) => Math.max(max, Math.round(node.x / (NODE_WIDTH + COLUMN_GAP))), 0);
  return {
    nodes: visualNodes,
    connectors,
    width: (maxDepth + 1) * NODE_WIDTH + maxDepth * COLUMN_GAP + 80,
    height: Math.max(nextLeafRow, 1) * (NODE_HEIGHT + ROW_GAP) + 40
  };
}

const layout = computed(() => buildLayout(props.nodes));
</script>
  • Step 4: Create main view

Create frontend/src/views/SystemPermissionView.vue:

<template>
  <section class="page-shell system-permission-page">
    <div class="page-header">
      <div>
        <p class="eyebrow">系统管理</p>
        <h1>系统权限管理</h1>
        <p class="muted">
          组织架构角色权限系统使用人员集中管理人员来自基础资料的人员管理电话号码自动作为账号
        </p>
      </div>
      <button class="primary-button" type="button" @click="openUserDrawer">新增系统使用人员</button>
    </div>

    <section class="system-permission-grid">
      <article class="panel-card org-panel">
        <div class="section-head">
          <div>
            <p class="eyebrow">组织脑图</p>
            <h2>总公司 -> 分公司 -> 部门 -> 人员</h2>
          </div>
          <button class="ghost-button" type="button" @click="loadAll">刷新</button>
        </div>
        <OrgMindMap :nodes="orgTree" @select-node="selectNode" />
      </article>

      <aside class="panel-card permission-side-panel">
        <div class="tab-row">
          <button :class="{ active: activeTab === 'detail' }" type="button" @click="activeTab = 'detail'">节点详情</button>
          <button :class="{ active: activeTab === 'roles' }" type="button" @click="activeTab = 'roles'">角色维护</button>
          <button :class="{ active: activeTab === 'users' }" type="button" @click="activeTab = 'users'">系统人员</button>
        </div>

        <section v-if="activeTab === 'detail'" class="detail-stack">
          <p class="muted">选择脑图节点后,可维护组织负责人;人员节点只能维护角色,不能加下级。</p>
          <div v-if="selectedNode" class="detail-card">
            <span>当前选择</span>
            <strong>{{ selectedNode.node_label || selectedNode.employee_name }}</strong>
          </div>
          <div v-else class="empty-state">请选择左侧脑图节点</div>
        </section>

        <section v-if="activeTab === 'roles'" class="detail-stack">
          <div class="section-head compact">
            <strong>角色维护</strong>
            <button class="ghost-button" type="button" @click="openRoleDrawer">新增角色</button>
          </div>
          <article v-for="role in roles" :key="role.role_id" class="role-card">
            <strong>{{ role.role_name }}</strong>
            <span>{{ role.role_code }} · {{ role.status === "ACTIVE" ? "启用" : "停用" }}</span>
          </article>
        </section>

        <section v-if="activeTab === 'users'" class="detail-stack">
          <p class="muted">人员选择后电话号码会带出并作为账号密码必填最少6位。</p>
          <article v-for="user in users" :key="user.user_id" class="user-card">
            <strong>{{ user.employee_name }}</strong>
            <span>{{ user.username }} · {{ user.role_names.join("、") || "未配置角色" }}</span>
          </article>
        </section>
      </aside>
    </section>
  </section>
</template>

<script setup>
import { onMounted, ref } from "vue";

import OrgMindMap from "../components/OrgMindMap.vue";
import { fetchResource, postResource } from "../services/api";

const orgTree = ref([]);
const roles = ref([]);
const users = ref([]);
const selectedNode = ref(null);
const activeTab = ref("detail");

async function loadAll() {
  const [nextOrgTree, nextRoles, nextUsers] = await Promise.all([
    fetchResource("/system-permissions/org-tree", []),
    fetchResource("/system-permissions/roles", []),
    fetchResource("/system-permissions/users", [])
  ]);
  orgTree.value = nextOrgTree;
  roles.value = nextRoles;
  users.value = nextUsers;
}

function selectNode(node) {
  selectedNode.value = node;
  activeTab.value = "detail";
}

function openUserDrawer() {
  window.alert("新增系统使用人员抽屉将在实现任务中接入:人员、角色、电话号码、密码。");
}

function openRoleDrawer() {
  window.alert("新增角色抽屉将在实现任务中接入:角色、权限树、状态、备注。");
}

onMounted(loadAll);
</script>

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:

import SystemPermissionView from "../views/SystemPermissionView.vue";

Add a system permission order:

const systemPermissionOrder = [
  {
    routeName: "system-permissions",
    permission: "MENU_SYSTEM_PERMISSION"
  },
  {
    routeName: "system-extension",
    permission: "MENU_SYSTEM_EXTENSION"
  }
];

Add helper:

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:

{
  routeName: "system-management",
  permissionsAny: ["MENU_SYSTEM_PERMISSION", "MENU_SYSTEM_EXTENSION"]
}

Update getFirstAllowedRoute():

  if (matched?.routeName === "system-management") {
    return getFirstAllowedSystemRoute(permissionCodes, roleCodes);
  }

Add routes before /system-extension:

  {
    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:

{
  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:

return permissionCodes.includes(item.permission) || (item.permission?.startsWith("MENU_SYSTEM_") && roleCodes.includes("ADMIN"));
  • Step 7: Run frontend static test

Run:

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:

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:

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:

<script setup>
import { computed } from "vue";

const props = defineProps({
  nodes: {
    type: Array,
    default: () => []
  }
});

defineEmits(["select-node"]);

const NODE_WIDTH = 176;
const NODE_HEIGHT = 78;
const COLUMN_GAP = 150;
const ROW_GAP = 34;
const CANVAS_PADDING = 24;

function toVisualChildren(node) {
  const orgChildren = node.children || [];
  const employeeChildren = (node.employees || []).map((employee) => ({
    node_id: `employee-${employee.employee_id}`,
    node_label: employee.employee_name,
    manager_name: (employee.role_names || []).join("、") || employee.job_title || "",
    children: [],
    employees: [],
    __kind: "employee",
    __raw: employee
  }));
  return [...orgChildren, ...employeeChildren];
}

function measureSubtree(node) {
  const children = toVisualChildren(node);
  if (!children.length) {
    return 1;
  }
  return children.reduce((sum, child) => sum + measureSubtree(child), 0);
}

function segmentKey(segment) {
  return `${segment.x1},${segment.y1},${segment.x2},${segment.y2}`;
}

function connectorSegments(parent, child) {
  const laneX = parent.x + NODE_WIDTH + COLUMN_GAP / 2;
  const fromX = parent.x + NODE_WIDTH;
  const fromY = parent.y + NODE_HEIGHT / 2;
  const toX = child.x;
  const toY = child.y + NODE_HEIGHT / 2;
  return [
    { x1: fromX, y1: fromY, x2: laneX, y2: fromY },
    { x1: laneX, y1: Math.min(fromY, toY), x2: laneX, y2: Math.max(fromY, toY) },
    { x1: laneX, y1: toY, x2: toX, y2: toY }
  ];
}

function layoutTree(rootNodes) {
  const nodes = [];
  const segmentMap = new Map();
  let cursor = 0;

  function visit(node, depth, startRow) {
    const span = measureSubtree(node);
    const row = startRow + (span - 1) / 2;
    const visualNode = {
      key: `${node.__kind || "department"}-${node.node_id}`,
      kind: node.__kind || "department",
      title: node.node_label,
      subtitle: node.manager_name || "",
      x: CANVAS_PADDING + depth * (NODE_WIDTH + COLUMN_GAP),
      y: CANVAS_PADDING + row * (NODE_HEIGHT + ROW_GAP),
      raw: node.__raw || node,
      span
    };
    nodes.push(visualNode);

    let childStartRow = startRow;
    for (const child of toVisualChildren(node)) {
      const childVisual = visit(child, depth + 1, childStartRow);
      for (const segment of connectorSegments(visualNode, childVisual)) {
        segmentMap.set(segmentKey(segment), segment);
      }
      childStartRow += childVisual.span;
    }
    return visualNode;
  }

  for (const rootNode of rootNodes) {
    const rootSpan = measureSubtree(rootNode);
    visit(rootNode, 0, cursor);
    cursor += rootSpan + 1;
  }

  const maxX = nodes.reduce((max, node) => Math.max(max, node.x), 0);
  const maxY = nodes.reduce((max, node) => Math.max(max, node.y), 0);
  return {
    nodes,
    connectorLines: Array.from(segmentMap.values()),
    connectors: Array.from(segmentMap.values()).map((segment, index) => ({
      id: `segment-${index}`,
      fromX: segment.x1,
      fromY: segment.y1,
      midX: segment.x2,
      toX: segment.x2,
      toY: segment.y2
    })),
    width: maxX + NODE_WIDTH + CANVAS_PADDING * 2,
    height: maxY + NODE_HEIGHT + CANVAS_PADDING * 2
  };
}

const layout = computed(() => layoutTree(props.nodes));
</script>

Keep the template path binding from Task 4. Deduplicated segments are mapped into layout.connectors, so the template remains:

<path
  v-for="connector in layout.connectors"
  :key="connector.id"
  class="org-mind-map__connector"
  :d="`M ${connector.fromX} ${connector.fromY} H ${connector.midX} V ${connector.toY} H ${connector.toX}`"
/>

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:

<style scoped>
.org-mind-map {
  position: relative;
  min-height: 520px;
  overflow: auto;
  border-radius: 24px;
  border: 1px solid rgba(30, 41, 59, 0.1);
  background:
    linear-gradient(90deg, rgba(148, 163, 184, 0.14) 1px, transparent 1px),
    linear-gradient(180deg, rgba(148, 163, 184, 0.14) 1px, transparent 1px),
    linear-gradient(135deg, #f8fafc 0%, #eef6ff 100%);
  background-size: 28px 28px, 28px 28px, auto;
}

.org-mind-map__connectors {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

.org-mind-map__connector {
  fill: none;
  stroke: rgba(37, 99, 235, 0.38);
  stroke-width: 2;
  stroke-linecap: square;
  stroke-linejoin: miter;
}

.org-mind-map__node {
  position: absolute;
  width: 176px;
  height: 78px;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 6px;
  padding: 12px 14px;
  border-radius: 18px;
  border: 1px solid rgba(15, 23, 42, 0.12);
  background: rgba(255, 255, 255, 0.94);
  box-shadow: 0 16px 36px rgba(15, 23, 42, 0.12);
  cursor: pointer;
  user-select: none;
}

.org-mind-map__node:hover {
  border-color: rgba(37, 99, 235, 0.42);
  box-shadow: 0 18px 42px rgba(37, 99, 235, 0.18);
}

.org-mind-map__node strong {
  color: #0f172a;
  font-size: 15px;
  line-height: 1.25;
}

.org-mind-map__node span {
  color: #64748b;
  font-size: 13px;
  line-height: 1.2;
}

.org-mind-map__node--employee {
  background: #fff8eb;
  border-color: rgba(245, 158, 11, 0.28);
}
</style>
  • Step 5: Run frontend static test

Run:

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:

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:

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:

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:

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:

<div v-if="showUserDrawer" class="drawer-mask" @click.self="showUserDrawer = false">
  <aside class="form-drawer">
    <div class="drawer-header">
      <div>
        <p class="eyebrow">系统账号</p>
        <h2>新增系统使用人员</h2>
      </div>
      <button class="ghost-button" type="button" @click="showUserDrawer = false">关闭</button>
    </div>
    <label class="form-field">
      <span>人员</span>
      <select v-model="createUserForm.employee_id" @change="syncSelectedEmployeePhone">
        <option value="">请选择人员</option>
        <option
          v-for="employee in employeeOptions"
          :key="employee.employee_id"
          :value="employee.employee_id"
          :disabled="employee.has_system_user"
        >
          {{ employee.employee_name }} · {{ employee.dept_name || "未分部门" }} · {{ employee.mobile || "无电话" }}{{ employee.has_system_user ? " · 已有账号" : "" }}
        </option>
      </select>
    </label>
    <label class="form-field">
      <span>电话号码 / 账号</span>
      <input v-model="createUserForm.phone" type="text" readonly placeholder="从人员中自动带出" />
    </label>
    <label class="form-field">
      <span>角色</span>
      <select v-model="createUserForm.role_ids" multiple>
        <option v-for="role in roles" :key="role.role_id" :value="role.role_id">
          {{ role.role_name }}
        </option>
      </select>
    </label>
    <label class="form-field">
      <span>密码</span>
      <input v-model="createUserForm.password" type="password" placeholder="必填最少6位" />
    </label>
    <div class="drawer-actions">
      <button class="primary-button" type="button" @click="submitCreateUser">保存</button>
      <button class="ghost-button" type="button" @click="showUserDrawer = false">取消</button>
    </div>
  </aside>
</div>
  • Step 4: Implement role maintenance form

Add role state:

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:

<div class="permission-tree">
  <template v-for="group in permissionTree" :key="group.permission_code">
    <label class="permission-check">
      <input
        type="checkbox"
        :checked="roleForm.permission_codes.includes(group.permission_code)"
        @change="togglePermission(group.permission_code, $event.target.checked)"
      />
      <strong>{{ group.permission_name }}</strong>
    </label>
    <label
      v-for="child in group.children"
      :key="child.permission_code"
      class="permission-check child"
    >
      <input
        type="checkbox"
        :checked="roleForm.permission_codes.includes(child.permission_code)"
        @change="togglePermission(child.permission_code, $event.target.checked)"
      />
      <span>{{ child.permission_name }}</span>
    </label>
  </template>
</div>
  • Step 5: Add reset password and user role update

Add:

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:

<div class="row-actions">
  <button class="ghost-button" type="button" @click="resetPassword(user)">重置密码</button>
  <button class="ghost-button" type="button" @click="toggleUserStatus(user)">
    {{ user.status === "ACTIVE" ? "停用" : "启用" }}
  </button>
</div>
  • Step 6: Run frontend static test

Run:

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:

    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:

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:

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:

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:

<div v-if="selectedNode && !selectedNode.employee_id" class="row-actions">
  <button class="ghost-button" type="button" @click="openOrgDrawer(selectedNode)">添加下级节点</button>
  <button class="ghost-button" type="button" @click="openOrgDrawer(null, selectedNode)">编辑当前节点</button>
</div>
<p v-if="selectedNode?.employee_id" class="muted">员工节点不能添加下级只能在系统人员中维护角色</p>
  • Step 5: Run frontend static test

Run:

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:

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:

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:

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:

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:

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:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run dev -- --host 0.0.0.0 --port 5173

Open:

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.