fix:人员添加职级
This commit is contained in:
parent
d8054366b1
commit
809cdbe3f1
|
|
@ -1,6 +1,6 @@
|
|||
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.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(permissions.router, prefix="/permissions", tags=["permissions"])
|
||||
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(documents.router, prefix="/documents", tags=["documents"])
|
||||
api_router.include_router(audit.router, prefix="/audit-logs", tags=["audit"])
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ def _build_current_user(db: Session, user: User) -> CurrentUserResponse:
|
|||
phone=user.phone,
|
||||
name=user.name,
|
||||
department=user.department,
|
||||
job_title=user.job_title,
|
||||
unit_id=user.unit_id,
|
||||
unit_name=user.unit.name if user.unit else None,
|
||||
status=user.status,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ from app.schemas.chat import (
|
|||
)
|
||||
from app.services.audit_service import write_audit
|
||||
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:
|
||||
|
|
@ -179,11 +180,10 @@ def stream_chat(
|
|||
)
|
||||
allowed_tags = [] if current_user.is_superuser else [role.code for role in current_user.roles]
|
||||
|
||||
if current_user.is_superuser:
|
||||
role_labels: list[str] = ["系统管理员"]
|
||||
else:
|
||||
role_labels = [role.name for role in current_user.roles if role.name]
|
||||
role_hint = "、".join(role_labels)
|
||||
# role_hint 必须是业务岗位(如"总院中层副职""员工"),用来匹配制度里按职级分组的条款。
|
||||
# 只取 User.job_title;权限角色(employee/admin/system_admin)和 is_superuser 不参与,
|
||||
# 否则会把"系统管理员"塞进制度职级匹配, LLM 找不到对应分组就反问澄清。
|
||||
role_hint = (current_user.job_title or "").strip()
|
||||
|
||||
# 按用户所属单位(含逐级向上回退)计算允许引用的文档 title 集合。
|
||||
# 超管/管理员或用户没绑定单位 -> 不做过滤(allowed_titles=None)。
|
||||
|
|
@ -249,14 +249,24 @@ def stream_chat(
|
|||
|
||||
raw_question = payload.question or ""
|
||||
hint_lines: list[str] = []
|
||||
# 引用格式规则始终注入,与提问人职位无关。
|
||||
# finance_assistant.md 系统 prompt 里写着"引用由系统自动展示,不用手写",这里覆盖它;
|
||||
# 独立成条防止 job_title=None 的账号(如系统管理员)拿不到规则,导致回答漏"依据文档"。
|
||||
hint_lines.append(
|
||||
"【引用格式】不要在正文开头写\"根据《XX规定》...\"这类引用开场;"
|
||||
"把制度出处放在整段回答的最末尾,另起一行以\"依据文档:《文档全名》(文号,如有)\"的格式列出;"
|
||||
"引用多份文档时每份单独一行。"
|
||||
"此规则最高优先级:即使命中 FAQ/标准答案,或已在正文其他位置提到制度名,末尾仍必须补上此行;"
|
||||
"若知识库无对应制度可引(纯操作步骤、报错处置、闲聊等),则写\"依据文档:无\"。"
|
||||
)
|
||||
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(
|
||||
f"【提问人职位:{role_hint}】请严格遵守以下输出规则:\n"
|
||||
f"1) 若制度按职级/岗位分组列条款(如\"总院领导班子正职\"\"总院中层副职\"\"总助级\"等),"
|
||||
f"只输出分组名中包含\"{role_hint}\"字样的分组细则;不匹配的分组一律不要列出,也不要用于对比。\n"
|
||||
f"2) 若制度不按职级分组,或该职位不涉及相关业务,请直接输出通用条款或明确说明\"该职位无相关规定\"。\n"
|
||||
f"3) 不要在正文开头写\"根据《XX规定》...\"这类引用开场;把制度出处放在整段回答的最末尾,"
|
||||
f"另起一行以\"依据文档:《文档全名》(文号,如有)\"的格式列出;引用多份文档时每份单独一行。"
|
||||
f"只输出分组名中包含以下任一关键词的分组细则: {alias_list};不匹配的分组一律不要列出,也不要用于对比。\n"
|
||||
f"2) 若制度不按职级分组,或该职位不涉及相关业务,请直接输出通用条款或明确说明\"该职位无相关规定\"。"
|
||||
)
|
||||
own_unit = scope.get("own_unit") or ""
|
||||
chosen_unit = scope.get("chosen_unit") or ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
|
|
@ -23,6 +23,7 @@ def serialize_user(user: User) -> UserResponse:
|
|||
phone=user.phone,
|
||||
name=user.name,
|
||||
department=user.department,
|
||||
job_title=user.job_title,
|
||||
unit_id=user.unit_id,
|
||||
unit_name=user.unit.name if user.unit else None,
|
||||
status=user.status,
|
||||
|
|
@ -72,6 +73,7 @@ def create_user(payload: UserCreate, db: Session = Depends(get_db), admin: User
|
|||
phone=payload.phone,
|
||||
name=payload.name,
|
||||
department=payload.department,
|
||||
job_title=payload.job_title,
|
||||
unit_id=payload.unit_id,
|
||||
is_superuser=payload.is_superuser,
|
||||
)
|
||||
|
|
@ -98,7 +100,7 @@ def update_user(
|
|||
if "unit_id" in updates:
|
||||
_validate_unit_id(db, 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:
|
||||
setattr(user, field, updates[field])
|
||||
if payload.role_codes is not None:
|
||||
|
|
@ -160,6 +162,7 @@ def bulk_import(
|
|||
phone = (row.get("phone") or "").strip()
|
||||
u_name = (row.get("name") or "").strip()
|
||||
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()
|
||||
if not phone or not u_name:
|
||||
errors.append(f"第 {idx} 行:手机号或姓名为空")
|
||||
|
|
@ -168,7 +171,7 @@ def bulk_import(
|
|||
skipped += 1
|
||||
continue
|
||||
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.flush()
|
||||
apply_roles(db, user, role_codes)
|
||||
|
|
@ -187,6 +190,7 @@ def _parse_user_rows(raw: bytes, filename: str) -> list[dict]:
|
|||
"手机号": "phone", "phone": "phone", "电话": "phone",
|
||||
"姓名": "name", "name": "name", "用户名": "name", "用户名称": "name",
|
||||
"部门": "department", "department": "department",
|
||||
"职级": "job_title", "岗位": "job_title", "职位": "job_title", "job_title": "job_title",
|
||||
"角色": "role_codes", "role_codes": "role_codes", "角色代码": "role_codes",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ def get_db() -> Generator[Session, None, None]:
|
|||
_LIGHTWEIGHT_ALTERS: list[tuple[str, str, str]] = [
|
||||
("attachments", "remote_url", "VARCHAR(1000)"),
|
||||
("users", "unit_id", "INTEGER"),
|
||||
("users", "job_title", "VARCHAR(80)"),
|
||||
("units", "parent_id", "INTEGER"),
|
||||
("units", "category", "VARCHAR(20) DEFAULT 'company'"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
from app.models.audit import AuditLog, ApiCallLog, UsageDaily
|
||||
from app.models.chat import Attachment, Conversation, Feedback, Message, MessageReference
|
||||
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 = (
|
||||
User,
|
||||
Role,
|
||||
Permission,
|
||||
Unit,
|
||||
JobTitle,
|
||||
DocumentCategory,
|
||||
Document,
|
||||
DocumentTag,
|
||||
|
|
@ -30,6 +31,7 @@ __all__ = [
|
|||
"DocumentCategory",
|
||||
"DocumentTag",
|
||||
"Feedback",
|
||||
"JobTitle",
|
||||
"Message",
|
||||
"MessageReference",
|
||||
"Permission",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
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 app.db import Base
|
||||
|
|
@ -28,6 +28,7 @@ class User(Base):
|
|||
phone: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(80))
|
||||
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)
|
||||
status: Mapped[str] = mapped_column(String(20), default="active", index=True)
|
||||
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)
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ class CurrentUserResponse(BaseModel):
|
|||
phone: str
|
||||
name: str
|
||||
department: str | None = None
|
||||
job_title: str | None = None
|
||||
unit_id: int | None = None
|
||||
unit_name: str | None = None
|
||||
status: str
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class JobTitleResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
sort_order: int
|
||||
is_active: bool
|
||||
|
|
@ -7,6 +7,7 @@ class UserCreate(BaseModel):
|
|||
phone: str = Field(min_length=6, max_length=32)
|
||||
name: str = Field(min_length=1, max_length=80)
|
||||
department: str | None = None
|
||||
job_title: str | None = None
|
||||
unit_id: int | None = None
|
||||
role_codes: list[str] = []
|
||||
is_superuser: bool = False
|
||||
|
|
@ -15,6 +16,7 @@ class UserCreate(BaseModel):
|
|||
class UserUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
department: str | None = None
|
||||
job_title: str | None = None
|
||||
unit_id: int | None = None
|
||||
status: str | None = None
|
||||
role_codes: list[str] | None = None
|
||||
|
|
@ -26,6 +28,7 @@ class UserResponse(BaseModel):
|
|||
phone: str
|
||||
name: str
|
||||
department: str | None
|
||||
job_title: str | None = None
|
||||
unit_id: int | None = None
|
||||
unit_name: str | None = None
|
||||
status: str
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from datetime import datetime
|
|||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Permission, Role, User
|
||||
from app.models import JobTitle, Permission, Role, User
|
||||
|
||||
|
||||
DEFAULT_ROLES = {
|
||||
|
|
@ -12,6 +12,37 @@ DEFAULT_ROLES = {
|
|||
"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 -> (显示名, 模块)
|
||||
DEFAULT_PAGE_PERMISSIONS: dict[str, tuple[str, str]] = {
|
||||
"page.chat": ("智能问答", "page"),
|
||||
|
|
@ -62,8 +93,19 @@ def ensure_roles(db: Session) -> None:
|
|||
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:
|
||||
ensure_roles(db)
|
||||
ensure_job_titles(db)
|
||||
user = db.scalar(select(User).where(User.phone == "13800000000"))
|
||||
if user:
|
||||
return user
|
||||
|
|
|
|||
|
|
@ -13,27 +13,39 @@ const store = useAppStore()
|
|||
const users = ref([])
|
||||
const roles = ref([])
|
||||
const units = ref([])
|
||||
const jobTitles = ref([])
|
||||
const loading = ref(false)
|
||||
const keyword = ref('')
|
||||
|
||||
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 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 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() {
|
||||
loading.value = true
|
||||
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/roles'),
|
||||
request('/api/units'),
|
||||
request('/api/job-titles'),
|
||||
])
|
||||
users.value = u || []
|
||||
roles.value = r || []
|
||||
units.value = un || []
|
||||
jobTitles.value = jt || []
|
||||
} catch (err) {
|
||||
notify.error(`加载失败:${err.message}`)
|
||||
} finally {
|
||||
|
|
@ -42,7 +54,7 @@ async function loadAll() {
|
|||
}
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
|
|
@ -52,6 +64,7 @@ function openEdit(row) {
|
|||
phone: row.phone,
|
||||
name: row.name,
|
||||
department: row.department || '',
|
||||
job_title: row.job_title || '',
|
||||
unit_id: row.unit_id ?? null,
|
||||
role_codes: [...(row.role_codes || [])],
|
||||
is_superuser: !!row.is_superuser,
|
||||
|
|
@ -73,6 +86,7 @@ async function onSubmit() {
|
|||
body: JSON.stringify({
|
||||
phone: form.phone, name: form.name,
|
||||
department: form.department || null,
|
||||
job_title: form.job_title || null,
|
||||
unit_id: form.unit_id ?? null,
|
||||
role_codes: form.role_codes,
|
||||
is_superuser: form.is_superuser,
|
||||
|
|
@ -85,6 +99,7 @@ async function onSubmit() {
|
|||
body: JSON.stringify({
|
||||
name: form.name,
|
||||
department: form.department || null,
|
||||
job_title: form.job_title || null,
|
||||
unit_id: form.unit_id ?? null,
|
||||
role_codes: form.role_codes,
|
||||
is_superuser: form.is_superuser,
|
||||
|
|
@ -152,6 +167,7 @@ const columns = [
|
|||
{ title: '用户名称', key: 'name' },
|
||||
{ title: '账号 ID', key: 'phone' },
|
||||
{ title: '单位', key: 'unit_name', render: (row) => row.unit_name || '—' },
|
||||
{ title: '职级', key: 'job_title', render: (row) => row.job_title || '—' },
|
||||
{
|
||||
title: '角色',
|
||||
key: 'role_codes',
|
||||
|
|
@ -267,6 +283,16 @@ onMounted(loadAll)
|
|||
<span>部门</span>
|
||||
<NInput v-model:value="editForm.department" placeholder="所在部门(可选)" />
|
||||
</label>
|
||||
<label>
|
||||
<span>职级</span>
|
||||
<NSelect
|
||||
v-model:value="editForm.job_title"
|
||||
:options="jobTitleOptions"
|
||||
placeholder="选择职级(用于问答时匹配制度条款)"
|
||||
clearable
|
||||
filterable
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>角色</span>
|
||||
<NSelect
|
||||
|
|
|
|||
Loading…
Reference in New Issue