fix:人员添加职级

This commit is contained in:
shijing 2026-07-21 14:55:40 +08:00
parent d8054366b1
commit 809cdbe3f1
13 changed files with 155 additions and 18 deletions

View File

@ -1,6 +1,6 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api import audit, auth, chat, documents, files, permissions, roles, stats, units, users from app.api import audit, auth, chat, documents, files, job_titles, permissions, roles, stats, units, users
api_router = APIRouter(prefix="/api") api_router = APIRouter(prefix="/api")
api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
@ -8,6 +8,7 @@ api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(roles.router, prefix="/roles", tags=["roles"]) api_router.include_router(roles.router, prefix="/roles", tags=["roles"])
api_router.include_router(permissions.router, prefix="/permissions", tags=["permissions"]) api_router.include_router(permissions.router, prefix="/permissions", tags=["permissions"])
api_router.include_router(units.router, prefix="/units", tags=["units"]) api_router.include_router(units.router, prefix="/units", tags=["units"])
api_router.include_router(job_titles.router, prefix="/job-titles", tags=["job-titles"])
api_router.include_router(chat.router, prefix="/chat", tags=["chat"]) api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
api_router.include_router(documents.router, prefix="/documents", tags=["documents"]) api_router.include_router(documents.router, prefix="/documents", tags=["documents"])
api_router.include_router(audit.router, prefix="/audit-logs", tags=["audit"]) api_router.include_router(audit.router, prefix="/audit-logs", tags=["audit"])

View File

@ -38,6 +38,7 @@ def _build_current_user(db: Session, user: User) -> CurrentUserResponse:
phone=user.phone, phone=user.phone,
name=user.name, name=user.name,
department=user.department, department=user.department,
job_title=user.job_title,
unit_id=user.unit_id, unit_id=user.unit_id,
unit_name=user.unit.name if user.unit else None, unit_name=user.unit.name if user.unit else None,
status=user.status, status=user.status,

View File

@ -25,6 +25,7 @@ from app.schemas.chat import (
) )
from app.services.audit_service import write_audit from app.services.audit_service import write_audit
from app.services.document_service import resolve_chat_doc_scope from app.services.document_service import resolve_chat_doc_scope
from app.services.user_service import JOB_TITLE_GROUP_ALIASES
def _attachment_data_url(att: Attachment) -> str | None: def _attachment_data_url(att: Attachment) -> str | None:
@ -179,11 +180,10 @@ def stream_chat(
) )
allowed_tags = [] if current_user.is_superuser else [role.code for role in current_user.roles] allowed_tags = [] if current_user.is_superuser else [role.code for role in current_user.roles]
if current_user.is_superuser: # role_hint 必须是业务岗位(如"总院中层副职""员工"),用来匹配制度里按职级分组的条款。
role_labels: list[str] = ["系统管理员"] # 只取 User.job_title权限角色employee/admin/system_admin和 is_superuser 不参与,
else: # 否则会把"系统管理员"塞进制度职级匹配, LLM 找不到对应分组就反问澄清。
role_labels = [role.name for role in current_user.roles if role.name] role_hint = (current_user.job_title or "").strip()
role_hint = "".join(role_labels)
# 按用户所属单位(含逐级向上回退)计算允许引用的文档 title 集合。 # 按用户所属单位(含逐级向上回退)计算允许引用的文档 title 集合。
# 超管/管理员或用户没绑定单位 -> 不做过滤allowed_titles=None # 超管/管理员或用户没绑定单位 -> 不做过滤allowed_titles=None
@ -249,14 +249,24 @@ def stream_chat(
raw_question = payload.question or "" raw_question = payload.question or ""
hint_lines: list[str] = [] hint_lines: list[str] = []
# 引用格式规则始终注入,与提问人职位无关。
# finance_assistant.md 系统 prompt 里写着"引用由系统自动展示,不用手写",这里覆盖它;
# 独立成条防止 job_title=None 的账号(如系统管理员)拿不到规则,导致回答漏"依据文档"。
hint_lines.append(
"【引用格式】不要在正文开头写\"根据《XX规定》...\"这类引用开场;"
"把制度出处放在整段回答的最末尾,另起一行以\"依据文档:《文档全名》(文号,如有)\"的格式列出;"
"引用多份文档时每份单独一行。"
"此规则最高优先级:即使命中 FAQ/标准答案,或已在正文其他位置提到制度名,末尾仍必须补上此行;"
"若知识库无对应制度可引(纯操作步骤、报错处置、闲聊等),则写\"依据文档:无\""
)
if role_hint: if role_hint:
aliases = JOB_TITLE_GROUP_ALIASES.get(role_hint, [role_hint])
alias_list = "".join(f"\"{a}\"" for a in aliases)
hint_lines.append( hint_lines.append(
f"【提问人职位:{role_hint}】请严格遵守以下输出规则:\n" f"【提问人职位:{role_hint}】请严格遵守以下输出规则:\n"
f"1) 若制度按职级/岗位分组列条款(如\"总院领导班子正职\"\"总院中层副职\"\"总助级\"等)," f"1) 若制度按职级/岗位分组列条款(如\"总院领导班子正职\"\"总院中层副职\"\"总助级\"等),"
f"只输出分组名中包含\"{role_hint}\"字样的分组细则;不匹配的分组一律不要列出,也不要用于对比。\n" f"只输出分组名中包含以下任一关键词的分组细则: {alias_list};不匹配的分组一律不要列出,也不要用于对比。\n"
f"2) 若制度不按职级分组,或该职位不涉及相关业务,请直接输出通用条款或明确说明\"该职位无相关规定\"\n" f"2) 若制度不按职级分组,或该职位不涉及相关业务,请直接输出通用条款或明确说明\"该职位无相关规定\""
f"3) 不要在正文开头写\"根据《XX规定》...\"这类引用开场;把制度出处放在整段回答的最末尾,"
f"另起一行以\"依据文档:《文档全名》(文号,如有)\"的格式列出;引用多份文档时每份单独一行。"
) )
own_unit = scope.get("own_unit") or "" own_unit = scope.get("own_unit") or ""
chosen_unit = scope.get("chosen_unit") or "" chosen_unit = scope.get("chosen_unit") or ""

View File

@ -0,0 +1,24 @@
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db import get_db
from app.deps import get_current_user
from app.models import JobTitle, User
from app.schemas.job_title import JobTitleResponse
router = APIRouter()
@router.get("", response_model=list[JobTitleResponse])
def list_job_titles(
_: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> list[JobTitleResponse]:
items = db.scalars(
select(JobTitle).where(JobTitle.is_active == True).order_by(JobTitle.sort_order, JobTitle.id) # noqa: E712
).all()
return [
JobTitleResponse(id=jt.id, name=jt.name, sort_order=jt.sort_order, is_active=jt.is_active)
for jt in items
]

View File

@ -23,6 +23,7 @@ def serialize_user(user: User) -> UserResponse:
phone=user.phone, phone=user.phone,
name=user.name, name=user.name,
department=user.department, department=user.department,
job_title=user.job_title,
unit_id=user.unit_id, unit_id=user.unit_id,
unit_name=user.unit.name if user.unit else None, unit_name=user.unit.name if user.unit else None,
status=user.status, status=user.status,
@ -72,6 +73,7 @@ def create_user(payload: UserCreate, db: Session = Depends(get_db), admin: User
phone=payload.phone, phone=payload.phone,
name=payload.name, name=payload.name,
department=payload.department, department=payload.department,
job_title=payload.job_title,
unit_id=payload.unit_id, unit_id=payload.unit_id,
is_superuser=payload.is_superuser, is_superuser=payload.is_superuser,
) )
@ -98,7 +100,7 @@ def update_user(
if "unit_id" in updates: if "unit_id" in updates:
_validate_unit_id(db, updates["unit_id"]) _validate_unit_id(db, updates["unit_id"])
user.unit_id = updates["unit_id"] user.unit_id = updates["unit_id"]
for field in ("name", "department", "status", "is_superuser"): for field in ("name", "department", "job_title", "status", "is_superuser"):
if field in updates and updates[field] is not None: if field in updates and updates[field] is not None:
setattr(user, field, updates[field]) setattr(user, field, updates[field])
if payload.role_codes is not None: if payload.role_codes is not None:
@ -160,6 +162,7 @@ def bulk_import(
phone = (row.get("phone") or "").strip() phone = (row.get("phone") or "").strip()
u_name = (row.get("name") or "").strip() u_name = (row.get("name") or "").strip()
dept = (row.get("department") or "").strip() or None dept = (row.get("department") or "").strip() or None
job_title = (row.get("job_title") or "").strip() or None
roles_raw = (row.get("role_codes") or "").strip() roles_raw = (row.get("role_codes") or "").strip()
if not phone or not u_name: if not phone or not u_name:
errors.append(f"{idx} 行:手机号或姓名为空") errors.append(f"{idx} 行:手机号或姓名为空")
@ -168,7 +171,7 @@ def bulk_import(
skipped += 1 skipped += 1
continue continue
role_codes = [c.strip() for c in roles_raw.replace("", ",").split(",") if c.strip()] role_codes = [c.strip() for c in roles_raw.replace("", ",").split(",") if c.strip()]
user = User(phone=phone, name=u_name, department=dept) user = User(phone=phone, name=u_name, department=dept, job_title=job_title)
db.add(user) db.add(user)
db.flush() db.flush()
apply_roles(db, user, role_codes) apply_roles(db, user, role_codes)
@ -187,6 +190,7 @@ def _parse_user_rows(raw: bytes, filename: str) -> list[dict]:
"手机号": "phone", "phone": "phone", "电话": "phone", "手机号": "phone", "phone": "phone", "电话": "phone",
"姓名": "name", "name": "name", "用户名": "name", "用户名称": "name", "姓名": "name", "name": "name", "用户名": "name", "用户名称": "name",
"部门": "department", "department": "department", "部门": "department", "department": "department",
"职级": "job_title", "岗位": "job_title", "职位": "job_title", "job_title": "job_title",
"角色": "role_codes", "role_codes": "role_codes", "角色代码": "role_codes", "角色": "role_codes", "role_codes": "role_codes", "角色代码": "role_codes",
} }

View File

@ -27,6 +27,7 @@ def get_db() -> Generator[Session, None, None]:
_LIGHTWEIGHT_ALTERS: list[tuple[str, str, str]] = [ _LIGHTWEIGHT_ALTERS: list[tuple[str, str, str]] = [
("attachments", "remote_url", "VARCHAR(1000)"), ("attachments", "remote_url", "VARCHAR(1000)"),
("users", "unit_id", "INTEGER"), ("users", "unit_id", "INTEGER"),
("users", "job_title", "VARCHAR(80)"),
("units", "parent_id", "INTEGER"), ("units", "parent_id", "INTEGER"),
("units", "category", "VARCHAR(20) DEFAULT 'company'"), ("units", "category", "VARCHAR(20) DEFAULT 'company'"),
] ]

View File

@ -1,13 +1,14 @@
from app.models.audit import AuditLog, ApiCallLog, UsageDaily from app.models.audit import AuditLog, ApiCallLog, UsageDaily
from app.models.chat import Attachment, Conversation, Feedback, Message, MessageReference from app.models.chat import Attachment, Conversation, Feedback, Message, MessageReference
from app.models.document import Document, DocumentCategory, DocumentTag, document_tag_relations from app.models.document import Document, DocumentCategory, DocumentTag, document_tag_relations
from app.models.user import Permission, Role, Unit, User, role_permissions, user_roles from app.models.user import JobTitle, Permission, Role, Unit, User, role_permissions, user_roles
all_models = ( all_models = (
User, User,
Role, Role,
Permission, Permission,
Unit, Unit,
JobTitle,
DocumentCategory, DocumentCategory,
Document, Document,
DocumentTag, DocumentTag,
@ -30,6 +31,7 @@ __all__ = [
"DocumentCategory", "DocumentCategory",
"DocumentTag", "DocumentTag",
"Feedback", "Feedback",
"JobTitle",
"Message", "Message",
"MessageReference", "MessageReference",
"Permission", "Permission",

View File

@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Table from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Table
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base from app.db import Base
@ -28,6 +28,7 @@ class User(Base):
phone: Mapped[str] = mapped_column(String(32), unique=True, index=True) phone: Mapped[str] = mapped_column(String(32), unique=True, index=True)
name: Mapped[str] = mapped_column(String(80)) name: Mapped[str] = mapped_column(String(80))
department: Mapped[str | None] = mapped_column(String(120)) department: Mapped[str | None] = mapped_column(String(120))
job_title: Mapped[str | None] = mapped_column(String(80))
unit_id: Mapped[int | None] = mapped_column(ForeignKey("units.id"), index=True) unit_id: Mapped[int | None] = mapped_column(ForeignKey("units.id"), index=True)
status: Mapped[str] = mapped_column(String(20), default="active", index=True) status: Mapped[str] = mapped_column(String(20), default="active", index=True)
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False) is_superuser: Mapped[bool] = mapped_column(Boolean, default=False)
@ -75,3 +76,16 @@ class Permission(Base):
module: Mapped[str] = mapped_column(String(60), index=True) module: Mapped[str] = mapped_column(String(60), index=True)
roles: Mapped[list[Role]] = relationship(secondary=role_permissions, back_populates="permissions") roles: Mapped[list[Role]] = relationship(secondary=role_permissions, back_populates="permissions")
class JobTitle(Base):
"""业务职级字典。User.job_title 存的是这里的 name自由文本外键
字典改名/删除不影响历史用户数据"""
__tablename__ = "job_titles"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(80), unique=True, index=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

View File

@ -24,6 +24,7 @@ class CurrentUserResponse(BaseModel):
phone: str phone: str
name: str name: str
department: str | None = None department: str | None = None
job_title: str | None = None
unit_id: int | None = None unit_id: int | None = None
unit_name: str | None = None unit_name: str | None = None
status: str status: str

View File

@ -0,0 +1,8 @@
from pydantic import BaseModel
class JobTitleResponse(BaseModel):
id: int
name: str
sort_order: int
is_active: bool

View File

@ -7,6 +7,7 @@ class UserCreate(BaseModel):
phone: str = Field(min_length=6, max_length=32) phone: str = Field(min_length=6, max_length=32)
name: str = Field(min_length=1, max_length=80) name: str = Field(min_length=1, max_length=80)
department: str | None = None department: str | None = None
job_title: str | None = None
unit_id: int | None = None unit_id: int | None = None
role_codes: list[str] = [] role_codes: list[str] = []
is_superuser: bool = False is_superuser: bool = False
@ -15,6 +16,7 @@ class UserCreate(BaseModel):
class UserUpdate(BaseModel): class UserUpdate(BaseModel):
name: str | None = None name: str | None = None
department: str | None = None department: str | None = None
job_title: str | None = None
unit_id: int | None = None unit_id: int | None = None
status: str | None = None status: str | None = None
role_codes: list[str] | None = None role_codes: list[str] | None = None
@ -26,6 +28,7 @@ class UserResponse(BaseModel):
phone: str phone: str
name: str name: str
department: str | None department: str | None
job_title: str | None = None
unit_id: int | None = None unit_id: int | None = None
unit_name: str | None = None unit_name: str | None = None
status: str status: str

View File

@ -3,7 +3,7 @@ from datetime import datetime
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models import Permission, Role, User from app.models import JobTitle, Permission, Role, User
DEFAULT_ROLES = { DEFAULT_ROLES = {
@ -12,6 +12,37 @@ DEFAULT_ROLES = {
"system_admin": "系统管理员", "system_admin": "系统管理员",
} }
# 业务职级预设。用户在个人资料里选的头衔。跟制度里的分组名不一定字面一致,
# 字面不一致时必须在 JOB_TITLE_GROUP_ALIASES 里加映射,否则 chat.py 拿 job_title
# 做子串匹配会全部落空LLM 反问澄清。
DEFAULT_JOB_TITLES: list[str] = [
"董事长",
"总经理",
"领导班子副职",
"总助级",
"中层干部正职及相当职级人员",
"中层干部副职及相当职级人员",
"教授级高工及相当职称人员",
"普通员工",
]
# 业务职级 -> 制度里"按职级分组"分组名的关键词列表。
# 制度里分组常写作"总院领导班子正职"这种复合词,用户选的却是"董事长"这种具体头衔,
# chat.py 会拿这里的关键词代替原 job_title 做子串规则("包含任一关键词")。
# 未在此表的 job_title 回退成拿它本身做子串匹配(老行为)。
# 加/改字典项时,同步维护此表;关键词要够特异,避免误命中相邻分组
# (例如"正职"太泛,会同时命中"领导班子正职"和"中层正职")。
JOB_TITLE_GROUP_ALIASES: dict[str, list[str]] = {
"董事长": ["领导班子正职"],
"总经理": ["领导班子正职"],
"领导班子副职": ["领导班子副职"],
"总助级": ["总助级"],
"中层干部正职及相当职级人员": ["中层正职", "中层干部正职"],
"中层干部副职及相当职级人员": ["中层副职", "中层干部副职"],
"教授级高工及相当职称人员": ["教授级高工"],
"普通员工": ["普通员工"],
}
# 页面级权限code -> (显示名, 模块) # 页面级权限code -> (显示名, 模块)
DEFAULT_PAGE_PERMISSIONS: dict[str, tuple[str, str]] = { DEFAULT_PAGE_PERMISSIONS: dict[str, tuple[str, str]] = {
"page.chat": ("智能问答", "page"), "page.chat": ("智能问答", "page"),
@ -62,8 +93,19 @@ def ensure_roles(db: Session) -> None:
db.flush() db.flush()
def ensure_job_titles(db: Session) -> None:
"""把 DEFAULT_JOB_TITLES 里没入库的补进去;已存在的保留,不覆盖启用状态/顺序。"""
existing = {jt.name for jt in db.scalars(select(JobTitle)).all()}
for idx, name in enumerate(DEFAULT_JOB_TITLES):
if name in existing:
continue
db.add(JobTitle(name=name, sort_order=idx, is_active=True))
db.flush()
def ensure_default_admin(db: Session) -> User: def ensure_default_admin(db: Session) -> User:
ensure_roles(db) ensure_roles(db)
ensure_job_titles(db)
user = db.scalar(select(User).where(User.phone == "13800000000")) user = db.scalar(select(User).where(User.phone == "13800000000"))
if user: if user:
return user return user

View File

@ -13,27 +13,39 @@ const store = useAppStore()
const users = ref([]) const users = ref([])
const roles = ref([]) const roles = ref([])
const units = ref([]) const units = ref([])
const jobTitles = ref([])
const loading = ref(false) const loading = ref(false)
const keyword = ref('') const keyword = ref('')
const editing = ref(null) const editing = ref(null)
const editForm = ref({ id: null, phone: '', name: '', department: '', unit_id: null, role_codes: [], is_superuser: false, status: 'active' }) const editForm = ref({ id: null, phone: '', name: '', department: '', job_title: '', unit_id: null, role_codes: [], is_superuser: false, status: 'active' })
const fileInput = ref(null) const fileInput = ref(null)
const roleOptions = computed(() => roles.value.map((r) => ({ label: r.name, value: r.code }))) const roleOptions = computed(() => roles.value.map((r) => ({ label: r.name, value: r.code })))
const unitOptions = computed(() => units.value.map((u) => ({ label: u.name, value: u.id }))) const unitOptions = computed(() => units.value.map((u) => ({ label: u.name, value: u.id })))
// +
const jobTitleOptions = computed(() => {
const opts = jobTitles.value.map((jt) => ({ label: jt.name, value: jt.name }))
const cur = (editForm.value.job_title || '').trim()
if (cur && !opts.some((o) => o.value === cur)) {
opts.push({ label: `${cur}(历史值,不在字典中)`, value: cur })
}
return opts
})
async function loadAll() { async function loadAll() {
loading.value = true loading.value = true
try { try {
const [u, r, un] = await Promise.all([ const [u, r, un, jt] = await Promise.all([
request(`/api/users${keyword.value ? `?q=${encodeURIComponent(keyword.value)}` : ''}`), request(`/api/users${keyword.value ? `?q=${encodeURIComponent(keyword.value)}` : ''}`),
request('/api/roles'), request('/api/roles'),
request('/api/units'), request('/api/units'),
request('/api/job-titles'),
]) ])
users.value = u || [] users.value = u || []
roles.value = r || [] roles.value = r || []
units.value = un || [] units.value = un || []
jobTitles.value = jt || []
} catch (err) { } catch (err) {
notify.error(`加载失败:${err.message}`) notify.error(`加载失败:${err.message}`)
} finally { } finally {
@ -42,7 +54,7 @@ async function loadAll() {
} }
function openAdd() { function openAdd() {
editForm.value = { id: null, phone: '', name: '', department: '', unit_id: null, role_codes: [], is_superuser: false, status: 'active' } editForm.value = { id: null, phone: '', name: '', department: '', job_title: '', unit_id: null, role_codes: [], is_superuser: false, status: 'active' }
editing.value = 'add' editing.value = 'add'
} }
@ -52,6 +64,7 @@ function openEdit(row) {
phone: row.phone, phone: row.phone,
name: row.name, name: row.name,
department: row.department || '', department: row.department || '',
job_title: row.job_title || '',
unit_id: row.unit_id ?? null, unit_id: row.unit_id ?? null,
role_codes: [...(row.role_codes || [])], role_codes: [...(row.role_codes || [])],
is_superuser: !!row.is_superuser, is_superuser: !!row.is_superuser,
@ -73,6 +86,7 @@ async function onSubmit() {
body: JSON.stringify({ body: JSON.stringify({
phone: form.phone, name: form.name, phone: form.phone, name: form.name,
department: form.department || null, department: form.department || null,
job_title: form.job_title || null,
unit_id: form.unit_id ?? null, unit_id: form.unit_id ?? null,
role_codes: form.role_codes, role_codes: form.role_codes,
is_superuser: form.is_superuser, is_superuser: form.is_superuser,
@ -85,6 +99,7 @@ async function onSubmit() {
body: JSON.stringify({ body: JSON.stringify({
name: form.name, name: form.name,
department: form.department || null, department: form.department || null,
job_title: form.job_title || null,
unit_id: form.unit_id ?? null, unit_id: form.unit_id ?? null,
role_codes: form.role_codes, role_codes: form.role_codes,
is_superuser: form.is_superuser, is_superuser: form.is_superuser,
@ -152,6 +167,7 @@ const columns = [
{ title: '用户名称', key: 'name' }, { title: '用户名称', key: 'name' },
{ title: '账号 ID', key: 'phone' }, { title: '账号 ID', key: 'phone' },
{ title: '单位', key: 'unit_name', render: (row) => row.unit_name || '—' }, { title: '单位', key: 'unit_name', render: (row) => row.unit_name || '—' },
{ title: '职级', key: 'job_title', render: (row) => row.job_title || '—' },
{ {
title: '角色', title: '角色',
key: 'role_codes', key: 'role_codes',
@ -267,6 +283,16 @@ onMounted(loadAll)
<span>部门</span> <span>部门</span>
<NInput v-model:value="editForm.department" placeholder="所在部门(可选)" /> <NInput v-model:value="editForm.department" placeholder="所在部门(可选)" />
</label> </label>
<label>
<span>职级</span>
<NSelect
v-model:value="editForm.job_title"
:options="jobTitleOptions"
placeholder="选择职级(用于问答时匹配制度条款)"
clearable
filterable
/>
</label>
<label> <label>
<span>角色</span> <span>角色</span>
<NSelect <NSelect