本轮之前累积的未提交工作,一并固化:
- 组织隔离:同一份代码部署给不同部门只需改 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 现查,不接受前端传入,否则前端可伪造单据。
147 lines
6.3 KiB
Python
147 lines
6.3 KiB
Python
"""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 而非 Decimal:Pydantic v2 会把 Decimal 序列化成字符串,
|
||
# 前端拿到 "5.0000" 不好直接参与计算。数量量级很小(实测 1~186),float 足够。
|
||
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)}",
|
||
)
|