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,12 +1,26 @@
"""物料选择器 — 读 MOM material_base成品/半成品 category 手风琴分组"""
"""物料选择器 — 读 MOM material_base按 category 手风琴分组(仅本部门)"""
from fastapi import APIRouter, Query, HTTPException, status, Depends
from pydantic import BaseModel
from app.core.config import settings
from app.core.mom_database import MomSessionLocal
from app.services.auth_service import get_current_user
from sqlalchemy import text
router = APIRouter(prefix="/materials", tags=["物料选择"])
# 部门隔离:只放行本部门 category 前缀IRIS/…)。
#
# ⚠️ 必须用**前缀** LIKE不能反推成 ILIKE '%IRIS%':两套实例共用同一个 MOM 库,
# LICA 的物料是 `LICA/<中文>`LICA/生产配件 687、LICA/销售产品 89、
# LICA/维修服务 16 …),而 IRIS 分类树里另有 `IRIS/成品/LICA/…`
# (野外便携 59 / 无人机 38 / 实验室内 34 / 高塔监测 30共 171 条)——
# 那是**挂在 IRIS 名下、给 LICA 做的成品**,本来就属于本部门。
# 前缀匹配天然把前者排除、把后者包含,不需要再加特例。
#
# 另注IRIS 的分类是多段式(`IRIS/半成品/无人机U`、`IRIS/原材料/光学/光电Opt1`
# 拿「成品/半成品」这类类型词过滤没有意义,一律走前缀。
CATEGORY_PREFIX_LIKE = f"{settings.MATERIAL_CATEGORY_PREFIX}%"
# ============================================================
# 响应模型
@ -38,7 +52,7 @@ def get_material_groups(
):
"""
按 category 分组汇总,前端渲染手风琴外层。
只返回成品/半成品分类。
只返回本部门ORG_DEPARTMENT名下的分类。
"""
db = MomSessionLocal()
try:
@ -47,23 +61,29 @@ def get_material_groups(
SELECT category, COUNT(*) AS count
FROM material_base
WHERE is_enabled = TRUE
AND category LIKE :cat_prefix
AND (name ILIKE :kw OR spec_model ILIKE :kw)
GROUP BY category
ORDER BY category
""")
result = db.execute(sql, {"kw": f"%{keyword.strip()}%"})
result = db.execute(
sql, {"cat_prefix": CATEGORY_PREFIX_LIKE, "kw": f"%{keyword.strip()}%"}
)
else:
sql = text("""
SELECT category, COUNT(*) AS count
FROM material_base
WHERE is_enabled = TRUE
AND category LIKE :cat_prefix
GROUP BY category
ORDER BY category
""")
result = db.execute(sql)
result = db.execute(sql, {"cat_prefix": CATEGORY_PREFIX_LIKE})
rows = result.fetchall()
return [MaterialGroup(category=row.category, count=row.count) for row in rows]
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
@ -82,6 +102,10 @@ def get_material_items(
):
"""
获取指定 category 下的物料条目,前端展开手风琴时懒加载。
这里同样要加部门前缀条件(纵深防御):`category` 完全由客户端提供,
只靠 `category = :cat` 精确匹配的话,构造一个跨部门的 category 就能
把别的部门的物料捞出来。
"""
db = MomSessionLocal()
try:
@ -91,13 +115,20 @@ def get_material_items(
COALESCE(unit, '') AS unit, is_enabled
FROM material_base
WHERE is_enabled = TRUE
AND category LIKE :cat_prefix
AND category = :cat
AND (name ILIKE :kw OR spec_model ILIKE :kw)
ORDER BY name
LIMIT :lim
""")
result = db.execute(
sql, {"cat": category, "kw": f"%{keyword.strip()}%", "lim": limit}
sql,
{
"cat_prefix": CATEGORY_PREFIX_LIKE,
"cat": category,
"kw": f"%{keyword.strip()}%",
"lim": limit,
},
)
else:
sql = text("""
@ -105,11 +136,14 @@ def get_material_items(
COALESCE(unit, '') AS unit, is_enabled
FROM material_base
WHERE is_enabled = TRUE
AND category LIKE :cat_prefix
AND category = :cat
ORDER BY name
LIMIT :lim
""")
result = db.execute(sql, {"cat": category, "lim": limit})
result = db.execute(
sql, {"cat_prefix": CATEGORY_PREFIX_LIKE, "cat": category, "lim": limit}
)
rows = result.fetchall()
return [
@ -124,6 +158,8 @@ def get_material_items(
)
for row in rows
]
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,

View File

@ -0,0 +1,146 @@
"""MOM 出库单只读查询 — 供「产品/任务挂载出库物料」时搜索选择
直连 MOM 库SQL 都在 `app/services/mom_outbound_service.py`。
为什么不用 MOM 现成的 `GET /api/v1/outbound`:那个接口要 JWT +
permission_required且对非特权账号按 `consumer_name` 做行级隔离 —— Track 用
服务账号调只能拿到该账号名下的单,不是全量。详见服务模块顶部的说明。
═══ 可见范围(本文件的重点)═══
本实例**没有业务分组**,可见范围只有一层,且全部在
`mom_outbound_service` 里以常量化形式钉死:
1. **公司隔离**:物料分类前缀本公司(`IRIS/%`),与物料选择器同一套口径 ——
出库单归属哪个公司,由它开出去的那条物料挂在谁的分类树下决定。
2. **跨部门例外**`config.EXTRA_VISIBLE_CONSUMERS` 里的领用人,跨部门领料时
他们的单不落在本公司前缀里,但仍要放行(否则整批漏掉)。
⚠️ 安全不变量:**界面筛选只能收窄,绝不能放大。**
`keyword` / `start_date` / `end_date` / `consumer` 一律拼成 AND 条件;
可见范围由服务层固定,客户端传什么参数都改不了它。
⚠️ 与 LICA 实例的差异LICA 那边出库单还要按**业务分组**再收敛一层
(范围 ∩ 组 ∩ 个人),本实例没有分组体系,故 `group_id` 参数**不存在**
——不要为了「对齐」而加回来,那会引入一份没有数据支撑的过滤。
"""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel
from app.services import mom_outbound_service
from app.services.auth_service import get_current_user
router = APIRouter(prefix="/mom-outbounds", tags=["MOM出库单"])
# ============================================================
# 响应模型
# ============================================================
class MomOutboundLine(BaseModel):
"""出库单的一条物料明细。line_id 即挂载时提交的 mom_line_ids 元素。"""
line_id: int
sku: str = ""
material_name: str = ""
spec_model: str = ""
# 用 float 而非 DecimalPydantic v2 会把 Decimal 序列化成字符串,
# 前端拿到 "5.0000" 不好直接参与计算。数量量级很小(实测 1~186float 足够。
quantity: float | None = None
unit_price: float | None = None
returned_quantity: float | None = None
outbound_type: str = ""
# 出库类型的中文名,由服务层按 MOM 码表下发(前端不再自建一份映射)
outbound_type_label: str = ""
consumer_name: str = ""
operator_name: str = ""
warehouse_location: str = ""
outbound_time: datetime | None = None
# ⚠️ MOM 的 request_id 是最近才加的列,存量单据**全为空**(无从回填)。
# 前端对空值应显示「无关联申请单」而不是留白。
request_no: str = ""
class MomOutboundOrder(BaseModel):
"""一张出库单(批量出库多商品共用一个单号,故带 N 条明细)。"""
outbound_no: str
outbound_time: datetime | None = None
outbound_type: str = ""
outbound_type_label: str = ""
consumer_name: str = ""
operator_name: str = ""
line_count: int = 0
total_quantity: float | None = None
lines: list[MomOutboundLine] = []
class MomOutboundSearchResponse(BaseModel):
orders: list[MomOutboundOrder]
total: int
# ============================================================
# 端点
# ============================================================
@router.get("", response_model=MomOutboundSearchResponse)
async def search_mom_outbounds(
keyword: str = Query("", description="搜索:出库单号 / 物料名称 / 规格型号 / SKU / 领用人"),
start_date: str = Query("", description="起始日期 YYYY-MM-DD含当日"),
end_date: str = Query("", description="截止日期 YYYY-MM-DD含当日"),
consumer: str = Query("", description="按领用人(中文名)过滤"),
skip: int = Query(0, ge=0, description="跳过**单据数**"),
limit: int = Query(20, ge=1, le=100, description="返回**单据数**"),
current_user: dict = Depends(get_current_user),
):
"""按**单据**分页搜索本部门(公司)的 MOM 出库单,同时带回每张单的明细。
结果受两层约束:可见范围(公司前缀 + 跨部门例外,服务层钉死)
+ 界面上的筛选条件。
⚠️ skip / limit 的粒度是**单据**不是明细行 —— 一张单最多 55 条明细,
实测平均 2.64 条。前端按单据展示、展开看明细。
⚠️ 本端点是 `async def` 是因为 MOM 查询走 `run_in_threadpool`(内部是同步
psycopg2直连阻塞事件循环本实例不做范围解析故无需 AsyncSession。
"""
try:
# 界面筛选:多选不提供,单选即收窄;空串 = 不筛
picked = consumer.strip()
consumers = {picked} if picked else None
orders, total = await run_in_threadpool(
mom_outbound_service.search_outbound_orders,
keyword=keyword,
start_date=start_date,
end_date=end_date,
skip=skip,
limit=limit,
consumers=consumers,
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"MOM 出库单查询失败: {str(e)}",
)
return MomOutboundSearchResponse(orders=orders, total=total)
@router.get("/consumers", response_model=list[str])
async def list_mom_outbound_consumers(
current_user: dict = Depends(get_current_user),
):
"""本部门出库单里出现过的**领用人姓名**(去重、按出现次数降序),供前端下拉。
⚠️ **同样受可见范围约束** —— 下拉里绝不能出现用户本来就看不到的人名,
否则等于把范围外的人员信息漏出去。
"""
try:
return await run_in_threadpool(mom_outbound_service.list_consumer_names)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"MOM 领用人列表查询失败: {str(e)}",
)

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,

View File

@ -7,6 +7,7 @@ from app.api.v1.endpoints.dashboard import router as dashboard_router
from app.api.v1.endpoints.auth import router as auth_router
from app.api.v1.endpoints.print import router as print_router
from app.api.v1.endpoints.materials import router as materials_router
from app.api.v1.endpoints.mom_outbounds import router as mom_outbounds_router
from app.api.v1.endpoints.users import router as users_router
from app.api.v1.endpoints.upload import router as upload_router
from app.api.v1.endpoints.records import router as records_router
@ -28,6 +29,7 @@ api_router.include_router(products_router)
api_router.include_router(tasks_router)
api_router.include_router(print_router)
api_router.include_router(materials_router)
api_router.include_router(mom_outbounds_router)
api_router.include_router(users_router)
api_router.include_router(upload_router)
api_router.include_router(records_router)