# Permission-Filtered Employee Selectors 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:** Make every business form personnel selector show only people whose system account has permission to access the related page, add the missing sales-person selector to sales order creation, and keep admin organization/personnel maintenance selectors unrestricted.

**Architecture:** Add one backend service for permission-filtered employee options and one backend validator for personnel fields on business saves. Business pages call a non-admin authenticated endpoint under `/master-data`, while the system permission management page keeps its admin-only full employee option endpoint. Frontend pages share one small employee-option service so later forms cannot accidentally fall back to the full employee list.

**Tech Stack:** FastAPI, SQLAlchemy, Pydantic, Vue 3 Composition API, Vite, existing SQLite-compatible backend unit tests, existing static frontend assertion scripts.

---

## File Structure

**Backend service and schemas**

- Modify `backend/app/schemas/system_permissions.py`
  - Extend `EmployeeOption` so both admin and business selectors can display account/role context consistently.
- Modify `backend/app/services/system_permissions.py`
  - Add permission-tree consistency for `MENU_EQUIPMENT_MANAGEMENT`.
  - Add `list_employee_options(db, permission_code=None, limit=500)`.
  - Add `employee_has_permission(...)` and `ensure_employee_has_permission(...)`.
- Modify `backend/app/api/routes/master_data.py`
  - Add authenticated non-admin business endpoint `GET /master-data/employee-options`.
- Modify `backend/app/api/routes/system_permissions.py`
  - Keep admin endpoint, but pass through the same service without a permission filter.
- Modify `backend/app/schemas/database.py`
  - Add `employee_id` and `mobile` to login session.
  - Add `sales_employee_id` and `sales_employee_name` to sales order read/create schemas.
- Modify `backend/app/services/auth.py`
  - Return the current login employee ID and mobile in `LoginUser`.

**Backend business validation**

- Modify `backend/app/api/routes/sales.py`
  - Sales order creation requires and validates `sales_employee_id` against `MENU_SALES_PLANNING`.
- Modify `backend/app/services/sales_planning.py`
  - Sales order list joins sales employee and returns name.
- Modify `backend/app/api/routes/purchase.py`
  - Validate `purchaser_employee_id` against `MENU_PURCHASE_ORDER`.
  - Validate `receiver_employee_id` on purchase receipts against `MENU_PURCHASE_RECEIPT`.
- Modify `backend/app/api/routes/inventory.py`
  - Validate warehouse form personnel fields against `MENU_INVENTORY_LEDGER`.
- Modify `backend/app/api/routes/production.py`
  - Validate completion/production inbound receiver fields against `MENU_INVENTORY_LEDGER` when the UI flow belongs to 嘉恒仓库.
- Modify `backend/app/api/routes/sales.py`
  - Validate delivery shipper fields against `MENU_DELIVERY_MANAGEMENT` for 发货概览 delivery endpoints.
- Modify `backend/app/api/routes/returns.py`
  - Validate return handler fields against the page permission used by that route. Current return-management route redirects to 嘉恒仓库, so warehouse return flows use `MENU_INVENTORY_LEDGER`.
- Modify `backend/app/api/routes/equipment.py`
  - Validate maintenance owner fields against `MENU_EQUIPMENT_MANAGEMENT`.

**Frontend shared helper**

- Create `frontend/src/services/employeeOptions.js`
  - Centralize permission-code constants, option normalization, current-user default selection, and selected historical option merging.

**Frontend pages**

- Modify `frontend/src/views/SalesPlanningView.vue`
  - Add `销售人员` selector to sales order drawer and send `sales_employee_id`.
- Modify `frontend/src/views/PurchaseOrderView.vue`
  - Replace full employee list with purchase-order permission employees.
- Modify `frontend/src/views/PurchaseReceiptView.vue`
  - Replace full employee list with purchase-receipt permission employees.
- Modify `frontend/src/views/InventoryLedgerView.vue`
  - Replace full employee list with inventory-ledger permission employees for warehouse personnel fields.
- Modify `frontend/src/views/CompletionReceiptView.vue`
  - Replace full employee list with inventory-ledger permission employees.
- Modify `frontend/src/views/ReturnManagementView.vue`
  - Replace full employee list with inventory-ledger permission employees because this page currently redirects/aligns with warehouse return handling.
- Modify `frontend/src/views/EquipmentManagementView.vue`
  - Replace full employee list with equipment-management permission employees.
- Do not modify `frontend/src/views/SystemPermissionView.vue` for filtering. It must continue to see full active personnel for organization binding and authorization.

**Tests**

- Modify `backend/tests/test_system_permission_management.py`
  - Add service-level tests for filtered employee options and validator behavior.
- Modify `backend/tests/test_sales_order_delivery_trace.py`
  - Add sales employee seed data to sales order creation calls.
- Create `frontend/scripts/test-permission-employee-selectors.mjs`
  - Static checks that business views call the permission-filtered endpoint and sales order has a sales-person field.

---

### Task 1: Backend Tests For Permission-Filtered Employee Options

**Files:**
- Modify: `backend/tests/test_system_permission_management.py`

- [ ] **Step 1: Add test helpers and failing tests**

Append these methods inside `SystemPermissionManagementTest` before `if __name__ == "__main__":`.

```python
    def _seed_permission_employee_selector_data(self) -> dict[str, int]:
        from app.services.auth import hash_password

        self._seed_role_permission_user_data()
        now = datetime(2026, 6, 8, 9, 0, 0)
        sales_permission = Permission(
            permission_code="MENU_SALES_PLANNING",
            permission_name="客户名录/销售订单菜单",
            module_code="SALES_PLANNING",
            action_code="VIEW",
            status="ACTIVE",
            created_at=now,
            updated_at=now,
        )
        sales_role = Role(
            role_code="SALES",
            role_name="销售人员",
            role_scope="SYSTEM",
            status="ACTIVE",
            remark=None,
            created_at=now,
            updated_at=now,
        )
        sales_employee = Employee(
            employee_code="EMP-SALES",
            employee_name="赵销售",
            dept_id=self.purchase_dept_id,
            mobile="13800000021",
            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,
        )
        inactive_employee = Employee(
            employee_code="EMP-INACTIVE",
            employee_name="停用员工",
            dept_id=self.purchase_dept_id,
            mobile="13800000022",
            gender=None,
            hire_date=None,
            job_title="停用",
            shift_code=None,
            manager_employee_id=None,
            is_operator=0,
            is_workshop_staff=0,
            status="INACTIVE",
            remark=None,
            created_at=now,
            updated_at=now,
        )
        self.db.add_all([sales_permission, sales_role, sales_employee, inactive_employee])
        self.db.flush()
        self.db.add_all(
            [
                RolePermission(role_id=self.purchaser_role_id, permission_id=self.purchase_permission_id, created_at=now),
                RolePermission(role_id=sales_role.id, permission_id=sales_permission.id, created_at=now),
                User(
                    username="13800000012",
                    password_hash=hash_password("secret123"),
                    employee_id=self.buyer_employee_id,
                    dept_id=self.purchase_dept_id,
                    nickname="李四",
                    email=None,
                    is_super_admin=0,
                    last_login_at=None,
                    status="ACTIVE",
                    created_at=now,
                    updated_at=now,
                ),
                User(
                    username="13800000021",
                    password_hash=hash_password("secret123"),
                    employee_id=sales_employee.id,
                    dept_id=self.purchase_dept_id,
                    nickname="赵销售",
                    email=None,
                    is_super_admin=0,
                    last_login_at=None,
                    status="ACTIVE",
                    created_at=now,
                    updated_at=now,
                ),
                User(
                    username="13800000022",
                    password_hash=hash_password("secret123"),
                    employee_id=inactive_employee.id,
                    dept_id=self.purchase_dept_id,
                    nickname="停用员工",
                    email=None,
                    is_super_admin=0,
                    last_login_at=None,
                    status="ACTIVE",
                    created_at=now,
                    updated_at=now,
                ),
            ]
        )
        self.db.flush()
        buyer_user = self.db.scalar(select(User).where(User.employee_id == self.buyer_employee_id))
        sales_user = self.db.scalar(select(User).where(User.employee_id == sales_employee.id))
        inactive_user = self.db.scalar(select(User).where(User.employee_id == inactive_employee.id))
        self.db.add_all(
            [
                UserRole(user_id=buyer_user.id, role_id=self.purchaser_role_id, created_at=now),
                UserRole(user_id=sales_user.id, role_id=sales_role.id, created_at=now),
                UserRole(user_id=inactive_user.id, role_id=sales_role.id, created_at=now),
            ]
        )
        self.db.commit()
        return {
            "sales_employee_id": sales_employee.id,
            "inactive_employee_id": inactive_employee.id,
        }

    def test_list_employee_options_filters_by_permission_code(self) -> None:
        from app.services.system_permissions import list_employee_options

        ids = self._seed_permission_employee_selector_data()

        purchase_options = list_employee_options(self.db, permission_code="MENU_PURCHASE_ORDER")
        sales_options = list_employee_options(self.db, permission_code="MENU_SALES_PLANNING")
        full_options = list_employee_options(self.db)

        self.assertEqual([option.employee_name for option in purchase_options], ["李四"])
        self.assertEqual([option.employee_name for option in sales_options], ["赵销售"])
        self.assertIn("销售人员", sales_options[0].role_names)
        self.assertEqual(sales_options[0].username, "13800000021")
        self.assertNotIn(ids["inactive_employee_id"], [option.employee_id for option in full_options])

    def test_ensure_employee_has_permission_rejects_unqualified_employee(self) -> None:
        from fastapi import HTTPException
        from app.services.system_permissions import ensure_employee_has_permission

        ids = self._seed_permission_employee_selector_data()

        ensure_employee_has_permission(
            self.db,
            ids["sales_employee_id"],
            "MENU_SALES_PLANNING",
            "销售人员",
        )
        with self.assertRaises(HTTPException) as ctx:
            ensure_employee_has_permission(
                self.db,
                self.buyer_employee_id,
                "MENU_SALES_PLANNING",
                "销售人员",
            )
        self.assertEqual(ctx.exception.status_code, 400)
        self.assertIn("销售人员必须选择有“客户名录/销售订单菜单”权限的人员", str(ctx.exception.detail))

    def test_login_session_exposes_employee_id_for_default_selectors(self) -> None:
        from app.services.auth import get_auth_context, hash_password

        self._seed_permission_employee_selector_data()
        context = get_auth_context(self.db, "13800000012")

        self.assertEqual(context.employee.id, self.buyer_employee_id)
        self.assertEqual(context.employee.mobile, "13800000012")
```

- [ ] **Step 2: Run tests and verify failure**

Run:

```bash
cd backend
.venv/bin/python -m pytest tests/test_system_permission_management.py::SystemPermissionManagementTest::test_list_employee_options_filters_by_permission_code -q
.venv/bin/python -m pytest tests/test_system_permission_management.py::SystemPermissionManagementTest::test_ensure_employee_has_permission_rejects_unqualified_employee -q
```

Expected:

```text
FAILED ... TypeError: list_employee_options() got an unexpected keyword argument 'permission_code'
FAILED ... ImportError or AttributeError for ensure_employee_has_permission
```

- [ ] **Step 3: Checkpoint**

Do not commit if the current workspace is not a git repository. Record that the backend permission tests fail for the expected missing implementation.

---

### Task 2: Backend Permission Option Service And Login Session Fields

**Files:**
- Modify: `backend/app/schemas/system_permissions.py`
- Modify: `backend/app/services/system_permissions.py`
- Modify: `backend/app/api/routes/master_data.py`
- Modify: `backend/app/api/routes/system_permissions.py`
- Modify: `backend/app/schemas/database.py`
- Modify: `backend/app/services/auth.py`

- [ ] **Step 1: Extend schemas**

In `backend/app/schemas/system_permissions.py`, replace `EmployeeOption` with this shape:

```python
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
    username: str | None = None
    role_names: list[str] = Field(default_factory=list)
    permission_codes: list[str] = Field(default_factory=list)
    has_system_user: bool = False
```

In `backend/app/schemas/database.py`, replace `LoginUser` with:

```python
class LoginUser(BaseModel):
    user_id: int
    username: str
    employee_id: int
    mobile: str | None = None
    display_name: str
    title: str
    dept_name: str | None = None
    role_codes: list[str]
    role_names: list[str]
    permission_codes: list[str]
```

- [ ] **Step 2: Add service helpers**

In `backend/app/services/system_permissions.py`, update imports:

```python
from sqlalchemy import delete, exists, func, or_, select
```

Add `MENU_EQUIPMENT_MANAGEMENT` to `MENU_PERMISSION_TREE` under a suitable module. The current route treats it as a standalone page; place it near production/operations-facing pages:

```python
    (
        "MENU_PRODUCTION",
        "生产执行",
        [
            ("MENU_WORK_ORDER", "工单台账", []),
            ("MENU_OPERATION_REPORT", "工序报工", []),
            ("MENU_EQUIPMENT_MANAGEMENT", "设备管理", []),
        ],
    ),
```

Add these helpers near the existing employee/role helper functions:

```python
def _permission_by_code(db: Session, permission_code: str) -> Permission:
    permission = db.scalar(
        select(Permission).where(
            Permission.permission_code == permission_code,
            Permission.status == "ACTIVE",
        )
    )
    if not permission:
        raise HTTPException(status_code=400, detail=f"权限码不存在或已停用：{permission_code}")
    return permission


def _employee_role_permission_maps(
    db: Session,
    employee_ids: list[int],
) -> tuple[dict[int, User], dict[int, list[str]], dict[int, list[str]]]:
    if not employee_ids:
        return {}, {}, {}
    users = db.scalars(
        select(User).where(
            User.employee_id.in_(employee_ids),
            User.status == "ACTIVE",
        )
    ).all()
    user_by_employee_id = {user.employee_id: user for user in users}
    if not users:
        return {}, {}, {}
    rows = db.execute(
        select(User.employee_id, Role.role_name, Permission.permission_code)
        .join(UserRole, UserRole.user_id == User.id)
        .join(Role, Role.id == UserRole.role_id)
        .outerjoin(RolePermission, RolePermission.role_id == Role.id)
        .outerjoin(Permission, Permission.id == RolePermission.permission_id)
        .where(
            User.id.in_([user.id for user in users]),
            Role.status == "ACTIVE",
        )
        .order_by(User.employee_id, Role.id, Permission.permission_code)
    ).all()
    role_map: dict[int, list[str]] = {}
    permission_map: dict[int, list[str]] = {}
    for employee_id, role_name, permission_code in rows:
        if role_name and role_name not in role_map.setdefault(employee_id, []):
            role_map[employee_id].append(role_name)
        if permission_code and permission_code not in permission_map.setdefault(employee_id, []):
            permission_map[employee_id].append(permission_code)
    return user_by_employee_id, role_map, permission_map


def employee_has_permission(db: Session, employee_id: int, permission_code: str) -> bool:
    permission = _permission_by_code(db, permission_code)
    user = db.scalar(
        select(User).where(
            User.employee_id == employee_id,
            User.status == "ACTIVE",
        )
    )
    if not user:
        return False
    employee = db.get(Employee, employee_id)
    if not employee or employee.status != "ACTIVE":
        return False
    if bool(user.is_super_admin):
        return True
    return bool(
        db.scalar(
            select(
                exists()
                .where(UserRole.user_id == user.id)
                .where(UserRole.role_id == Role.id)
                .where(Role.status == "ACTIVE")
                .where(RolePermission.role_id == Role.id)
                .where(RolePermission.permission_id == permission.id)
            )
        )
    )


def ensure_employee_has_permission(
    db: Session,
    employee_id: int | None,
    permission_code: str,
    field_label: str,
    *,
    required: bool = False,
) -> None:
    permission = _permission_by_code(db, permission_code)
    if not employee_id:
        if required:
            raise HTTPException(status_code=400, detail=f"{field_label}必填")
        return
    employee = db.get(Employee, employee_id)
    if not employee or employee.status != "ACTIVE":
        raise HTTPException(status_code=400, detail=f"{field_label}不存在或已停用")
    if not employee_has_permission(db, employee_id, permission_code):
        raise HTTPException(
            status_code=400,
            detail=f"{field_label}必须选择有“{permission.permission_name}”权限的人员",
        )
```

Replace the existing `list_employee_options` with:

```python
def list_employee_options(
    db: Session,
    permission_code: str | None = None,
    limit: int = 500,
) -> list[EmployeeOption]:
    permission = _permission_by_code(db, permission_code) if permission_code else None
    stmt = select(Employee).where(Employee.status == "ACTIVE")
    if permission:
        permitted_user_exists = (
            exists()
            .where(User.employee_id == Employee.id)
            .where(User.status == "ACTIVE")
            .where(
                or_(
                    User.is_super_admin == 1,
                    exists()
                    .where(UserRole.user_id == User.id)
                    .where(UserRole.role_id == Role.id)
                    .where(Role.status == "ACTIVE")
                    .where(RolePermission.role_id == Role.id)
                    .where(RolePermission.permission_id == permission.id),
                )
            )
        )
        stmt = stmt.where(permitted_user_exists)
    employees = sorted(
        db.scalars(stmt.order_by(Employee.id).limit(limit)).all(),
        key=_employee_name_sort_key,
    )
    dept_map = {row.id: row.dept_name for row in db.scalars(select(Department)).all()}
    employee_ids = [employee.id for employee in employees]
    user_map, role_map, permission_map = _employee_role_permission_maps(db, employee_ids)
    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,
            username=user_map.get(employee.id).username if user_map.get(employee.id) else None,
            role_names=role_map.get(employee.id, []),
            permission_codes=permission_map.get(employee.id, []),
            has_system_user=employee.id in user_map,
        )
        for employee in employees
    ]
```

- [ ] **Step 3: Expose authenticated business endpoint**

In `backend/app/api/routes/master_data.py`, add the schema import:

```python
from app.schemas.system_permissions import EmployeeOption
from app.services.system_permissions import list_employee_options
```

Add this route near the existing `list_employees` route:

```python
@router.get("/employee-options", response_model=list[EmployeeOption])
def list_business_employee_options(
    permission_code: str | None = Query(default=None),
    limit: int = Query(default=500, ge=1, le=500),
    db: Session = Depends(get_db),
) -> list[EmployeeOption]:
    return list_employee_options(db, permission_code=permission_code, limit=limit)
```

In `backend/app/api/routes/system_permissions.py`, update imports:

```python
from fastapi import APIRouter, Depends, HTTPException, Query
```

Update the admin endpoint to keep full-list behavior:

```python
@router.get("/employee-options", response_model=list[EmployeeOption])
def get_employee_options(
    limit: int = Query(default=500, ge=1, le=500),
    db: Session = Depends(get_db),
) -> list[EmployeeOption]:
    return list_employee_options(db, limit=limit)
```

- [ ] **Step 4: Return employee ID in login session**

In `backend/app/services/auth.py`, update `build_login_user`:

```python
def build_login_user(context: AuthContext) -> LoginUser:
    title = ROLE_TITLE_MAP.get(context.role_codes[0], "工业风管理台") if context.role_codes else "工业风管理台"
    return LoginUser(
        user_id=context.user.id,
        username=context.user.username,
        employee_id=context.employee.id,
        mobile=context.employee.mobile,
        display_name=context.user.nickname or context.employee.employee_name,
        title=title,
        dept_name=context.department.dept_name if context.department else None,
        role_codes=context.role_codes,
        role_names=context.role_names,
        permission_codes=context.permission_codes,
    )
```

- [ ] **Step 5: Run backend permission tests**

Run:

```bash
cd backend
.venv/bin/python -m pytest tests/test_system_permission_management.py::SystemPermissionManagementTest::test_list_employee_options_filters_by_permission_code -q
.venv/bin/python -m pytest tests/test_system_permission_management.py::SystemPermissionManagementTest::test_ensure_employee_has_permission_rejects_unqualified_employee -q
```

Expected:

```text
2 passed
```

- [ ] **Step 6: Run broader system permission tests**

Run:

```bash
cd backend
.venv/bin/python -m pytest tests/test_system_permission_management.py -q
```

Expected:

```text
passed
```

- [ ] **Step 7: Checkpoint**

If git is available, commit:

```bash
git add backend/app/schemas/system_permissions.py backend/app/services/system_permissions.py backend/app/api/routes/master_data.py backend/app/api/routes/system_permissions.py backend/app/schemas/database.py backend/app/services/auth.py backend/tests/test_system_permission_management.py
git commit -m "feat: add permission-filtered employee options"
```

If git is not available in this mounted workspace, report the verified files instead of committing.

---

### Task 3: Sales Order Sales-Person Backend Support

**Files:**
- Modify: `backend/app/schemas/database.py`
- Modify: `backend/app/services/sales_planning.py`
- Modify: `backend/app/api/routes/sales.py`
- Modify: `backend/tests/test_sales_order_delivery_trace.py`

- [ ] **Step 1: Add failing backend test for sales employee persistence**

In `backend/tests/test_sales_order_delivery_trace.py`, add a sales permission/user seed in the test setup. Reuse the existing department/user pattern and add this helper to the test class:

```python
    def _seed_sales_employee_permission(self) -> Employee:
        from app.models.org import Permission, Role, RolePermission, UserRole

        now = datetime(2026, 6, 8, 9, 0, 0)
        permission = Permission(
            permission_code="MENU_SALES_PLANNING",
            permission_name="客户名录/销售订单菜单",
            module_code="SALES_PLANNING",
            action_code="VIEW",
            status="ACTIVE",
            created_at=now,
            updated_at=now,
        )
        role = Role(
            role_code="SALES",
            role_name="销售人员",
            role_scope="SYSTEM",
            status="ACTIVE",
            remark=None,
            created_at=now,
            updated_at=now,
        )
        self.db.add_all([permission, role])
        self.db.flush()
        self.db.add(RolePermission(role_id=role.id, permission_id=permission.id, created_at=now))
        self.db.add(UserRole(user_id=self.user.id, role_id=role.id, created_at=now))
        self.db.commit()
        return self.employee
```

Add a test:

```python
    def test_create_sales_order_persists_sales_employee(self) -> None:
        sales_employee = self._seed_sales_employee_permission()

        order_result = create_sales_order(
            SalesOrderCreate(
                customer_id=self.customer.id,
                sales_employee_id=sales_employee.id,
                promised_date=date.today(),
                delivery_address=self.customer.address,
                tax_rate=0,
                items=[
                    SalesOrderItemCreate(
                        product_item_id=self.product.id,
                        order_qty=3,
                        unit_price=12.5,
                        customer_part_no="C-PART-001",
                    )
                ],
            ),
            db=self.db,
        )

        order = self.db.get(SalesOrder, order_result.order_id)
        self.assertEqual(order.sales_employee_id, sales_employee.id)
```

- [ ] **Step 2: Run test and verify failure**

Run:

```bash
cd backend
.venv/bin/python -m pytest tests/test_sales_order_delivery_trace.py::SalesOrderDeliveryTraceTest::test_create_sales_order_persists_sales_employee -q
```

Expected:

```text
FAILED ... SalesOrderCreate got an unexpected keyword argument 'sales_employee_id'
```

- [ ] **Step 3: Extend sales schemas**

In `backend/app/schemas/database.py`, add fields to `SalesOrderRead`:

```python
    sales_employee_id: int | None = None
    sales_employee_name: str | None = None
```

Add field to `SalesOrderCreate`:

```python
    sales_employee_id: int | None = None
```

- [ ] **Step 4: Return sales employee in sales order query**

In `backend/app/services/sales_planning.py`, import `Employee`:

```python
from app.models.org import Employee
```

Inside `get_sales_order_query`, add an alias and select fields:

```python
    sales_employee = aliased(Employee)
```

Add these selected labels:

```python
            SalesOrder.sales_employee_id.label("sales_employee_id"),
            sales_employee.employee_name.label("sales_employee_name"),
```

Add this join:

```python
        .outerjoin(sales_employee, sales_employee.id == SalesOrder.sales_employee_id)
```

Ensure `aliased` is imported:

```python
from sqlalchemy.orm import aliased
```

- [ ] **Step 5: Validate and persist sales employee**

In `backend/app/api/routes/sales.py`, import:

```python
from app.services.system_permissions import ensure_employee_has_permission
```

In `create_sales_order`, after customer validation and before creating `SalesOrder`, add:

```python
    ensure_employee_has_permission(
        db,
        payload.sales_employee_id,
        "MENU_SALES_PLANNING",
        "销售人员",
        required=True,
    )
```

Set the field on `SalesOrder`:

```python
        sales_employee_id=payload.sales_employee_id,
```

- [ ] **Step 6: Update existing sales-order backend tests**

Every existing `SalesOrderCreate(...)` in `backend/tests/test_sales_order_delivery_trace.py` must pass:

```python
sales_employee_id=self._seed_sales_employee_permission().id,
```

If multiple tests need it, call `_seed_sales_employee_permission()` once per test before creating the order:

```python
sales_employee = self._seed_sales_employee_permission()
```

Then use:

```python
sales_employee_id=sales_employee.id,
```

- [ ] **Step 7: Run sales tests**

Run:

```bash
cd backend
.venv/bin/python -m pytest tests/test_sales_order_delivery_trace.py -q
```

Expected:

```text
passed
```

- [ ] **Step 8: Checkpoint**

If git is available, commit:

```bash
git add backend/app/schemas/database.py backend/app/services/sales_planning.py backend/app/api/routes/sales.py backend/tests/test_sales_order_delivery_trace.py
git commit -m "feat: record sales person on sales orders"
```

If git is not available, report the verified files.

---

### Task 4: Backend Business Personnel Validation

**Files:**
- Modify: `backend/app/api/routes/purchase.py`
- Modify: `backend/app/api/routes/inventory.py`
- Modify: `backend/app/api/routes/production.py`
- Modify: `backend/app/api/routes/sales.py`
- Modify: `backend/app/api/routes/returns.py`
- Modify: `backend/app/api/routes/equipment.py`
- Test: `backend/tests/test_system_permission_management.py`

- [ ] **Step 1: Add validator unit test for optional fields**

In `backend/tests/test_system_permission_management.py`, add:

```python
    def test_optional_personnel_validator_allows_empty_value(self) -> None:
        from app.services.system_permissions import ensure_employee_has_permission

        self._seed_permission_employee_selector_data()

        ensure_employee_has_permission(
            self.db,
            None,
            "MENU_PURCHASE_ORDER",
            "采购员",
            required=False,
        )
```

- [ ] **Step 2: Run test**

Run:

```bash
cd backend
.venv/bin/python -m pytest tests/test_system_permission_management.py::SystemPermissionManagementTest::test_optional_personnel_validator_allows_empty_value -q
```

Expected:

```text
passed
```

- [ ] **Step 3: Validate purchase personnel**

In `backend/app/api/routes/purchase.py`, import:

```python
from app.services.system_permissions import ensure_employee_has_permission
```

In purchase order create and update paths, before saving `purchaser_employee_id`, add:

```python
    ensure_employee_has_permission(
        db,
        payload.purchaser_employee_id,
        "MENU_PURCHASE_ORDER",
        "采购员",
    )
```

In purchase receipt create path, before saving `receiver_employee_id`, add:

```python
    ensure_employee_has_permission(
        db,
        payload.receiver_employee_id,
        "MENU_PURCHASE_RECEIPT",
        "接收人",
    )
```

- [ ] **Step 4: Validate warehouse personnel**

In `backend/app/api/routes/inventory.py`, import:

```python
from app.services.system_permissions import ensure_employee_has_permission
```

For warehouse return inbound handlers:

```python
    ensure_employee_has_permission(
        db,
        payload.handler_employee_id,
        "MENU_INVENTORY_LEDGER",
        "经办人",
    )
```

For warehouse completion receiver:

```python
    ensure_employee_has_permission(
        db,
        payload.receiver_employee_id,
        "MENU_INVENTORY_LEDGER",
        "接收人",
    )
```

For warehouse sales-out shipper:

```python
    ensure_employee_has_permission(
        db,
        payload.shipper_employee_id,
        "MENU_INVENTORY_LEDGER",
        "发货人",
    )
```

- [ ] **Step 5: Validate production completion receiver**

In `backend/app/api/routes/production.py`, import:

```python
from app.services.system_permissions import ensure_employee_has_permission
```

Before creating completion/production inbound records with `receiver_employee_id`, add:

```python
    ensure_employee_has_permission(
        db,
        payload.receiver_employee_id,
        "MENU_INVENTORY_LEDGER",
        "接收人",
    )
```

- [ ] **Step 6: Validate delivery overview personnel**

In `backend/app/api/routes/sales.py`, before creating delivery with `shipper_employee_id`, add:

```python
    ensure_employee_has_permission(
        db,
        payload.shipper_employee_id,
        "MENU_DELIVERY_MANAGEMENT",
        "发货人",
    )
```

- [ ] **Step 7: Validate return handler**

In `backend/app/api/routes/returns.py`, import:

```python
from app.services.system_permissions import ensure_employee_has_permission
```

For return handler fields that are currently operated through warehouse return flows, add:

```python
    ensure_employee_has_permission(
        db,
        payload.handler_employee_id,
        "MENU_INVENTORY_LEDGER",
        "经办人",
    )
```

- [ ] **Step 8: Validate equipment owner**

In `backend/app/api/routes/equipment.py`, import:

```python
from app.services.system_permissions import ensure_employee_has_permission
```

Before saving `owner_employee_id`, add:

```python
    ensure_employee_has_permission(
        db,
        payload.owner_employee_id,
        "MENU_EQUIPMENT_MANAGEMENT",
        "负责人",
    )
```

- [ ] **Step 9: Run backend regression tests**

Run:

```bash
cd backend
.venv/bin/python -m pytest tests/test_system_permission_management.py -q
.venv/bin/python -m pytest tests/test_sales_order_delivery_trace.py -q
```

Expected:

```text
passed
```

- [ ] **Step 10: Checkpoint**

If git is available, commit:

```bash
git add backend/app/api/routes/purchase.py backend/app/api/routes/inventory.py backend/app/api/routes/production.py backend/app/api/routes/sales.py backend/app/api/routes/returns.py backend/app/api/routes/equipment.py backend/tests/test_system_permission_management.py
git commit -m "feat: validate personnel fields by page permission"
```

If git is not available, report the verified files.

---

### Task 5: Frontend Shared Employee Option Service

**Files:**
- Create: `frontend/src/services/employeeOptions.js`
- Create: `frontend/scripts/test-permission-employee-selectors.mjs`

- [ ] **Step 1: Write failing frontend selector test**

Create `frontend/scripts/test-permission-employee-selectors.mjs`:

```javascript
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";

function read(file) {
  return fs.readFileSync(path.resolve(file), "utf8");
}

const service = read("src/services/employeeOptions.js");
const sales = read("src/views/SalesPlanningView.vue");
const purchaseOrder = read("src/views/PurchaseOrderView.vue");
const purchaseReceipt = read("src/views/PurchaseReceiptView.vue");
const inventory = read("src/views/InventoryLedgerView.vue");
const completion = read("src/views/CompletionReceiptView.vue");
const returns = read("src/views/ReturnManagementView.vue");
const equipment = read("src/views/EquipmentManagementView.vue");

assert.match(service, /PERSONNEL_PERMISSION_CODES/, "employee option service should define permission constants");
assert.match(service, /\/master-data\/employee-options\?/, "business selectors should use authenticated master-data option endpoint");
assert.match(sales, />销售人员</, "sales order drawer should contain a sales person field");
assert.match(sales, /sales_employee_id/, "sales order form should submit sales_employee_id");
assert.match(purchaseOrder, /MENU_PURCHASE_ORDER/, "purchase order purchaser selector should use purchase order permission");
assert.match(purchaseReceipt, /MENU_PURCHASE_RECEIPT/, "purchase receipt receiver selector should use purchase receipt permission");
assert.match(inventory, /MENU_INVENTORY_LEDGER/, "warehouse personnel selectors should use inventory ledger permission");
assert.match(completion, /MENU_INVENTORY_LEDGER/, "completion receipt receiver should use inventory ledger permission");
assert.match(returns, /MENU_INVENTORY_LEDGER/, "return handler selector should use inventory ledger permission");
assert.match(equipment, /MENU_EQUIPMENT_MANAGEMENT/, "equipment owner selector should use equipment management permission");

for (const [name, source] of [
  ["PurchaseOrderView", purchaseOrder],
  ["PurchaseReceiptView", purchaseReceipt],
  ["InventoryLedgerView", inventory],
  ["CompletionReceiptView", completion],
  ["ReturnManagementView", returns],
  ["EquipmentManagementView", equipment],
]) {
  assert.doesNotMatch(
    source,
    /fetchResource\("\/master-data\/employees/,
    `${name} should not fetch the unfiltered full employee list for business personnel selectors`
  );
}

console.log("permission-filtered employee selector checks passed");
```

- [ ] **Step 2: Run script and verify failure**

Run:

```bash
cd frontend
node scripts/test-permission-employee-selectors.mjs
```

Expected:

```text
ENOENT: no such file or directory ... src/services/employeeOptions.js
```

- [ ] **Step 3: Create shared employee option service**

Create `frontend/src/services/employeeOptions.js`:

```javascript
import { fetchResource, readSession } from "./api";

export const PERSONNEL_PERMISSION_CODES = {
  SALES_ORDER: "MENU_SALES_PLANNING",
  PURCHASE_ORDER: "MENU_PURCHASE_ORDER",
  PURCHASE_RECEIPT: "MENU_PURCHASE_RECEIPT",
  INVENTORY_LEDGER: "MENU_INVENTORY_LEDGER",
  DELIVERY_MANAGEMENT: "MENU_DELIVERY_MANAGEMENT",
  EQUIPMENT_MANAGEMENT: "MENU_EQUIPMENT_MANAGEMENT"
};

export function normalizeEmployeeOption(row) {
  return {
    id: Number(row.employee_id ?? row.id),
    employee_id: Number(row.employee_id ?? row.id),
    employee_name: row.employee_name || row.name || "",
    mobile: row.mobile || "",
    dept_name: row.dept_name || "",
    job_title: row.job_title || "",
    username: row.username || "",
    role_names: Array.isArray(row.role_names) ? row.role_names : [],
    permission_codes: Array.isArray(row.permission_codes) ? row.permission_codes : [],
    has_system_user: Boolean(row.has_system_user)
  };
}

export async function fetchPermissionEmployeeOptions(permissionCode, limit = 500) {
  const params = new URLSearchParams({
    permission_code: permissionCode,
    limit: String(limit)
  });
  const rows = await fetchResource(`/master-data/employee-options?${params.toString()}`, []);
  return Array.isArray(rows) ? rows.map(normalizeEmployeeOption) : [];
}

export function defaultEmployeeIdFromSession(options) {
  const currentEmployeeId = readSession()?.employee_id;
  if (!currentEmployeeId) {
    return "";
  }
  const matched = options.find((employee) => String(employee.employee_id) === String(currentEmployeeId));
  return matched ? matched.employee_id : "";
}

export function employeeOptionLabel(employee) {
  const extra = [employee.dept_name, employee.role_names?.join("、")].filter(Boolean).join(" · ");
  return extra ? `${employee.employee_name} · ${extra}` : employee.employee_name;
}
```

- [ ] **Step 4: Run script and verify remaining failures**

Run:

```bash
cd frontend
node scripts/test-permission-employee-selectors.mjs
```

Expected:

```text
AssertionError ... sales order drawer should contain a sales person field
```

- [ ] **Step 5: Checkpoint**

If git is available, commit:

```bash
git add frontend/src/services/employeeOptions.js frontend/scripts/test-permission-employee-selectors.mjs
git commit -m "feat: add permission employee option frontend service"
```

If git is not available, report the verified files.

---

### Task 6: Frontend Sales Order Personnel Selector

**Files:**
- Modify: `frontend/src/views/SalesPlanningView.vue`

- [ ] **Step 1: Import helper and add state**

In the script section, import:

```javascript
import {
  PERSONNEL_PERMISSION_CODES,
  defaultEmployeeIdFromSession,
  employeeOptionLabel,
  fetchPermissionEmployeeOptions
} from "../services/employeeOptions";
```

Add state near other refs:

```javascript
const salesEmployeeOptions = ref([]);
```

Add `sales_employee_id` to `buildEmptySalesOrderForm()`:

```javascript
    sales_employee_id: "",
```

- [ ] **Step 2: Load sales employee options**

In the existing data-loading function that fetches customers/products/orders, add:

```javascript
const salesEmployeeRows = await fetchPermissionEmployeeOptions(PERSONNEL_PERMISSION_CODES.SALES_ORDER);
salesEmployeeOptions.value = salesEmployeeRows;
if (!salesOrderForm.sales_employee_id) {
  salesOrderForm.sales_employee_id = defaultEmployeeIdFromSession(salesEmployeeRows);
}
```

If the load function already uses `Promise.all`, add `fetchPermissionEmployeeOptions(PERSONNEL_PERMISSION_CODES.SALES_ORDER)` as one promise and assign it beside the other rows.

- [ ] **Step 3: Add sales-person field to drawer**

In the sales order drawer header fields, change the first `triple-field` to support one more selector by either using the existing responsive grid class or splitting into two rows. Add:

```vue
<label class="form-field">
  <span>销售人员</span>
  <select v-model.number="salesOrderForm.sales_employee_id" required>
    <option disabled value="">请选择销售人员</option>
    <option v-for="employee in salesEmployeeOptions" :key="employee.employee_id" :value="employee.employee_id">
      {{ employeeOptionLabel(employee) }}
    </option>
  </select>
</label>
```

- [ ] **Step 4: Submit sales employee ID**

In `submitSalesOrder`, add to the payload:

```javascript
sales_employee_id: salesOrderForm.sales_employee_id ? Number(salesOrderForm.sales_employee_id) : null,
```

- [ ] **Step 5: Show sales employee in sales order list**

Add a `销售人员` column after `客户`:

```vue
<th>销售人员</th>
```

Add the cell:

```vue
<td>{{ order.sales_employee_name || "-" }}</td>
```

Increase the empty-row colspan by 1.

- [ ] **Step 6: Run frontend selector script**

Run:

```bash
cd frontend
node scripts/test-permission-employee-selectors.mjs
```

Expected:

```text
AssertionError ... PurchaseOrderView should not fetch the unfiltered full employee list
```

- [ ] **Step 7: Checkpoint**

If git is available, commit:

```bash
git add frontend/src/views/SalesPlanningView.vue
git commit -m "feat: add sales person selector to sales orders"
```

If git is not available, report the verified file.

---

### Task 7: Frontend Business Selector Migration

**Files:**
- Modify: `frontend/src/views/PurchaseOrderView.vue`
- Modify: `frontend/src/views/PurchaseReceiptView.vue`
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- Modify: `frontend/src/views/CompletionReceiptView.vue`
- Modify: `frontend/src/views/ReturnManagementView.vue`
- Modify: `frontend/src/views/EquipmentManagementView.vue`

- [ ] **Step 1: Migrate purchase order purchaser**

In `frontend/src/views/PurchaseOrderView.vue`, import:

```javascript
import {
  PERSONNEL_PERMISSION_CODES,
  employeeOptionLabel,
  fetchPermissionEmployeeOptions
} from "../services/employeeOptions";
```

Replace the employee fetch:

```javascript
fetchResource("/master-data/employees?limit=200", [])
```

with:

```javascript
fetchPermissionEmployeeOptions(PERSONNEL_PERMISSION_CODES.PURCHASE_ORDER, 200)
```

Replace the option display:

```vue
{{ employee.employee_name }}
```

with:

```vue
{{ employeeOptionLabel(employee) }}
```

- [ ] **Step 2: Migrate purchase receipt receiver**

In `frontend/src/views/PurchaseReceiptView.vue`, import:

```javascript
import {
  PERSONNEL_PERMISSION_CODES,
  employeeOptionLabel,
  fetchPermissionEmployeeOptions
} from "../services/employeeOptions";
```

Replace:

```javascript
fetchResource("/master-data/employees", [])
```

with:

```javascript
fetchPermissionEmployeeOptions(PERSONNEL_PERMISSION_CODES.PURCHASE_RECEIPT)
```

Replace receiver option label with:

```vue
{{ employeeOptionLabel(employee) }}
```

- [ ] **Step 3: Migrate inventory ledger personnel fields**

In `frontend/src/views/InventoryLedgerView.vue`, import:

```javascript
import {
  PERSONNEL_PERMISSION_CODES,
  employeeOptionLabel,
  fetchPermissionEmployeeOptions
} from "../services/employeeOptions";
```

Replace:

```javascript
fetchResource("/master-data/employees?limit=500", [])
```

with:

```javascript
fetchPermissionEmployeeOptions(PERSONNEL_PERMISSION_CODES.INVENTORY_LEDGER, 500)
```

Replace every business personnel option label in this file:

```vue
{{ employee.employee_name }}
```

with:

```vue
{{ employeeOptionLabel(employee) }}
```

Only replace labels inside the existing personnel selects using `employees`. Do not change material/product/lot option labels.

- [ ] **Step 4: Migrate completion receipt receiver**

In `frontend/src/views/CompletionReceiptView.vue`, import:

```javascript
import {
  PERSONNEL_PERMISSION_CODES,
  employeeOptionLabel,
  fetchPermissionEmployeeOptions
} from "../services/employeeOptions";
```

Replace:

```javascript
fetchResource("/master-data/employees", [])
```

with:

```javascript
fetchPermissionEmployeeOptions(PERSONNEL_PERMISSION_CODES.INVENTORY_LEDGER)
```

Replace receiver option label with:

```vue
{{ employeeOptionLabel(employee) }}
```

- [ ] **Step 5: Migrate return handler**

In `frontend/src/views/ReturnManagementView.vue`, import:

```javascript
import {
  PERSONNEL_PERMISSION_CODES,
  employeeOptionLabel,
  fetchPermissionEmployeeOptions
} from "../services/employeeOptions";
```

Replace:

```javascript
fetchResource("/master-data/employees", [])
```

with:

```javascript
fetchPermissionEmployeeOptions(PERSONNEL_PERMISSION_CODES.INVENTORY_LEDGER)
```

Replace handler option label with:

```vue
{{ employeeOptionLabel(employee) }}
```

- [ ] **Step 6: Migrate equipment owner**

In `frontend/src/views/EquipmentManagementView.vue`, import:

```javascript
import {
  PERSONNEL_PERMISSION_CODES,
  employeeOptionLabel,
  fetchPermissionEmployeeOptions
} from "../services/employeeOptions";
```

Replace:

```javascript
fetchResource("/master-data/employees", [])
```

with:

```javascript
fetchPermissionEmployeeOptions(PERSONNEL_PERMISSION_CODES.EQUIPMENT_MANAGEMENT)
```

Replace owner option label with:

```vue
{{ employeeOptionLabel(employee) }}
```

- [ ] **Step 7: Run frontend selector script**

Run:

```bash
cd frontend
node scripts/test-permission-employee-selectors.mjs
```

Expected:

```text
permission-filtered employee selector checks passed
```

- [ ] **Step 8: Check system permission page remains unrestricted**

Run:

```bash
cd frontend
node scripts/test-system-permission-management-ui.mjs
```

Expected:

```text
system permission management ui checks passed
```

- [ ] **Step 9: Checkpoint**

If git is available, commit:

```bash
git add frontend/src/views/PurchaseOrderView.vue frontend/src/views/PurchaseReceiptView.vue frontend/src/views/InventoryLedgerView.vue frontend/src/views/CompletionReceiptView.vue frontend/src/views/ReturnManagementView.vue frontend/src/views/EquipmentManagementView.vue frontend/scripts/test-permission-employee-selectors.mjs
git commit -m "feat: filter business personnel selectors by page permission"
```

If git is not available, report the verified files.

---

### Task 8: Final Verification And Manual QA

**Files:**
- No planned code edits.

- [ ] **Step 1: Run backend focused tests**

Run:

```bash
cd backend
.venv/bin/python -m pytest tests/test_system_permission_management.py tests/test_sales_order_delivery_trace.py -q
```

Expected:

```text
passed
```

- [ ] **Step 2: Run frontend static checks**

Run:

```bash
cd frontend
node scripts/test-permission-employee-selectors.mjs
node scripts/test-system-permission-management-ui.mjs
```

Expected:

```text
permission-filtered employee selector checks passed
system permission management ui checks passed
```

- [ ] **Step 3: Build frontend**

Run:

```bash
cd frontend
npm run build
```

Expected:

```text
✓ built
```

- [ ] **Step 4: Manual browser QA**

Start or use the existing local app, log in as an admin, and verify:

```text
1. 系统权限管理 -> 角色维护：设备管理权限能看到并勾选。
2. 系统权限管理 -> 组织架构：新增人员、绑定主管仍然能看到基础资料人员全集。
3. 销售订单新增：能看到“销售人员”，选项只包含有客户名录/销售订单权限的人。
4. 采购订单新增：采购员只包含有采购订单权限的人。
5. 到货入库：接收人只包含有到货入库权限的人。
6. 嘉恒仓库内接收人/经办人/发货人只包含有嘉恒仓库权限的人。
7. 设备管理负责人只包含有设备管理权限的人。
8. 尝试用接口提交一个无权限人员 ID，后端返回中文错误，不保存单据。
```

- [ ] **Step 5: Final checkpoint**

If git is available, commit any remaining test updates:

```bash
git status --short
git add backend frontend
git commit -m "test: verify permission-filtered personnel selectors"
```

If git is not available, report:

```text
当前挂载目录不是 git 仓库，已跳过提交。已完成测试命令和结果见本次执行记录。
```

---

## Self-Review

**Spec coverage**

- Business personnel selectors are filtered by page permission: Tasks 2, 5, 6, 7.
- Sales order creation gets a sales-person dropdown: Tasks 3 and 6.
- Purchase, warehouse, receipt, delivery, return, equipment personnel fields are included: Tasks 4 and 7.
- System permission management keeps full personnel list: Tasks 2 and 8.
- Backend does not rely on frontend-only filtering: Tasks 2, 3, and 4.
- Equipment permission inconsistency is handled: Task 2.

**Placeholder scan**

- The plan contains no deferred behavior. Each business page and backend route has an explicit permission mapping.

**Type consistency**

- Backend employee option uses `employee_id`, `employee_name`, `role_names`, and `permission_codes`.
- Frontend normalizes options to both `id` and `employee_id`, so existing `<option :value="employee.id">` and new `employee.employee_id` usages can both work during migration.
- Login session now contains `employee_id`, matching `defaultEmployeeIdFromSession`.

