Files
track/backend/app/api/v1/endpoints/mom_outbounds.py
duxingchen e45c97bd1f 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 现查,不接受前端传入,否则前端可伪造单据。
2026-09-23 15:18:06 +08:00

147 lines
6.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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)}",
)