feat: 组织隔离(IRIS 单实例)与出料功能基础

本轮之前累积的未提交工作,一并固化:

- 组织隔离:同一份代码部署给不同部门只需改 config 的 ORG_DEPARTMENT 与
  MATERIAL_CATEGORY_PREFIX。过滤点在登录/人员列表/物料/MOM 出库单四处,
  全部服务端钉死,客户端传什么都放不大。
  ★ 物料必须用**前缀** LIKE,不能反推成 ILIKE '%IRIS%':MOM 里 LICA 的物料是
  `LICA/<中文>`,而本部门分类树里另有 `IRIS/成品/LICA/…`(本就属于本部门),
  前缀匹配天然区分得开。
- MOM 出库单只读查询(直连 MOM 库):不走 MOM 现成的 /outbound 接口 ——
  那个要 JWT + permission_required,且对非特权账号按 consumer_name 做行级
  隔离,服务账号只能拿到自己名下的单。分页必须两段式(先按单号 GROUP BY
  分页,再 IN 捞明细),对宽表直接分页会得到明细行数而不是单据数。
- 出料功能:产品 ↔ 出库单存档(product_outbounds)与任务 ↔ 出库明细
  (task_outbound_materials),供「这台设备对应 MOM 哪张单」的展示。
  ⚠️ 快照一律由后端拿 ID 去 MOM 现查,不接受前端传入,否则前端可伪造单据。
This commit is contained in:
2026-09-23 15:18:06 +08:00
parent edd43fec29
commit e45c97bd1f
22 changed files with 2000 additions and 48 deletions

View File

@ -1,6 +1,7 @@
"""用户列表 — 对接 MOM sys_user"""
from fastapi import APIRouter, Query, HTTPException, status
from pydantic import BaseModel
from app.core.config import settings
from app.core.mom_database import MomSessionLocal
from sqlalchemy import text
@ -20,42 +21,32 @@ class UserOption(BaseModel):
def list_users(
keyword: str = Query("", description="按用户名/姓名模糊搜索"),
limit: int = Query(100, ge=1, le=500),
dept: str = Query("IRIS", description="部门过滤"),
dept: str = Query("", description="已废弃:部门由服务端按 ORG_DEPARTMENT 钉死,此参数不参与过滤"),
):
"""获取 MOM 系统用户列表,默认只返回 IRIS 部门"""
"""获取 MOM 系统用户列表,只返回本部门(ORG_DEPARTMENT)人员"""
# 部门隔离由服务端钉死:无论客户端传什么(含旧版 App / 旧前端里写死的
# dept=IRIS),一律只按 ORG_DEPARTMENT 过滤。这样同一份 App 源码不必按
# 部门分叉。
#
# 这里刻意【不做】「查询异常就退回全表」的降级:那等于把另一个部门的人员
# 名单也列出来供本部门挑选,是跨部门数据泄漏。查不出来就报错 ——
# 宁可查不出,不可查过头。
db = MomSessionLocal()
try:
# 尝试按部门过滤;若 sys_user 无 department 列则降级全量查询
try:
base_sql = """
SELECT id, username,
SPLIT_PART(username, '/', 1) AS full_name,
COALESCE(department, '') AS department
FROM sys_user
WHERE department = :dept
"""
params = {"dept": dept, "lim": limit}
if keyword.strip():
sql = text(base_sql + " AND username ILIKE :kw ORDER BY username LIMIT :lim")
params["kw"] = f"%{keyword.strip()}%"
else:
sql = text(base_sql + " ORDER BY username LIMIT :lim")
rows = db.execute(sql, params).fetchall()
except Exception:
# 降级:不使用 department 列过滤
fallback_sql = """
SELECT id, username,
SPLIT_PART(username, '/', 1) AS full_name,
'' AS department
FROM sys_user
"""
params = {"lim": limit}
if keyword.strip():
sql = text(fallback_sql + " WHERE username ILIKE :kw ORDER BY username LIMIT :lim")
params["kw"] = f"%{keyword.strip()}%"
else:
sql = text(fallback_sql + " ORDER BY username LIMIT :lim")
rows = db.execute(sql, params).fetchall()
base_sql = """
SELECT id, username,
SPLIT_PART(username, '/', 1) AS full_name,
COALESCE(department, '') AS department
FROM sys_user
WHERE department = :dept
"""
params = {"dept": settings.ORG_DEPARTMENT, "lim": limit}
if keyword.strip():
sql = text(base_sql + " AND username ILIKE :kw ORDER BY username LIMIT :lim")
params["kw"] = f"%{keyword.strip()}%"
else:
sql = text(base_sql + " ORDER BY username LIMIT :lim")
rows = db.execute(sql, params).fetchall()
return [
UserOption(
@ -66,6 +57,8 @@ def list_users(
)
for row in rows
]
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,