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:
@ -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,
|
||||
|
||||
146
backend/app/api/v1/endpoints/mom_outbounds.py
Normal file
146
backend/app/api/v1/endpoints/mom_outbounds.py
Normal 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 而非 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)}",
|
||||
)
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -2,8 +2,12 @@
|
||||
from app.models.base import Base
|
||||
from app.models.production_order import ProductionOrder
|
||||
from app.models.product import Product
|
||||
from app.models.product_outbound import ProductOutbound
|
||||
from app.models.product_outbound_material import ProductOutboundMaterial
|
||||
from app.models.product_scrap import ProductScrap
|
||||
from app.models.task import Task, TaskRecord
|
||||
from app.models.task_log import TaskLog
|
||||
from app.models.task_outbound_material import TaskOutboundMaterial
|
||||
from app.models.notification import Notification
|
||||
from app.models.app_version import AppVersion
|
||||
from app.models.message import ProductMessage
|
||||
@ -14,8 +18,12 @@ __all__ = [
|
||||
"Base",
|
||||
"ProductionOrder",
|
||||
"Product",
|
||||
"ProductOutbound",
|
||||
"ProductOutboundMaterial",
|
||||
"ProductScrap",
|
||||
"Task",
|
||||
"TaskRecord",
|
||||
"TaskOutboundMaterial",
|
||||
"TaskLog",
|
||||
"Notification",
|
||||
"AppVersion",
|
||||
|
||||
107
backend/app/models/product_outbound.py
Normal file
107
backend/app/models/product_outbound.py
Normal file
@ -0,0 +1,107 @@
|
||||
"""产品出库记录 — MOM 出库回调在 Track 侧留下的单据存档
|
||||
|
||||
每收到一次 MOM 的出库回调就落一行,因此同一台设备可以有多行
|
||||
(出库 → 撤回 → 再出库)。撤回**不删行**,只把 is_revoked 置真 ——
|
||||
「出过又撤了」本身就是要看得见的历史。
|
||||
|
||||
与 products.overall_status / status 的分工:
|
||||
· 那两列表达**当前事实**(这台设备此刻是不是已出库);
|
||||
· 本表回答**归属**(这次出库对应 MOM 的哪张单、给了谁、谁办的)。
|
||||
两者互不替代:设备被撤回回库后 overall_status 变回「已入库」,但那张出库单
|
||||
仍然挂在本表上,只是标了已撤回。
|
||||
|
||||
为什么不用 products 加列:加列只能保住最后一次,而一台设备可以出库多次。
|
||||
|
||||
字段全部来自出库回调载荷(MOM 侧 services/outbound_service.create_outbound_batch)。
|
||||
Track 只做存档,**不做任何基于 outbound_type 的业务判断** —— MOM 的码表尚未
|
||||
冻结(models/outbound.py 两处注释分别为 5 值和 3 值,且无白名单校验)。
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
from app.core.time_utils import get_beijing_time
|
||||
|
||||
|
||||
class ProductOutbound(Base):
|
||||
__tablename__ = "product_outbounds"
|
||||
# 防 MOM 重推产生重复行。不能只约束 outbound_no —— MOM 的批量出库是
|
||||
# 多个商品共用一个单号(见 MOM models/outbound.py 第 127 行注释)。
|
||||
__table_args__ = (
|
||||
UniqueConstraint("serial_number", "outbound_no", name="uq_product_outbound_sn_no"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
|
||||
)
|
||||
|
||||
# ---- 物理外键(关联本库 products) ----
|
||||
product_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("products.id"), nullable=False, index=True,
|
||||
comment="所属产品ID",
|
||||
)
|
||||
# 冗余序列号:按 SN 对账/排查时不必 join products
|
||||
serial_number: Mapped[str | None] = mapped_column(
|
||||
String(16), nullable=True, index=True, comment="产品序列号(冗余,便于按SN对账)",
|
||||
)
|
||||
|
||||
# ---- MOM 单据上下文(全部来自出库回调载荷) ----
|
||||
outbound_no: Mapped[str] = mapped_column(
|
||||
String(100), nullable=False, comment="MOM 出库单号(批量出库多商品共用)",
|
||||
)
|
||||
request_no: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True, comment="MOM 出库申请单号",
|
||||
)
|
||||
consumer_name: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True,
|
||||
comment="领用人/客户(MOM 侧扫码时自由填写,非可靠标识,刻意不做关联依据)",
|
||||
)
|
||||
applicant_name: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True, comment="申请人姓名(MOM 侧解析后传来,Track 不做 ID 反查)",
|
||||
)
|
||||
operator: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True, comment="MOM 侧实际扫码出库人",
|
||||
)
|
||||
outbound_type: Mapped[str | None] = mapped_column(
|
||||
String(50), nullable=True,
|
||||
comment="出库类型 SALES/USE/PRODUCTION(MOM 码表未冻结,本表只存不判)",
|
||||
)
|
||||
outbound_time: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, comment="MOM 记录的出库时间",
|
||||
)
|
||||
remark: Mapped[str | None] = mapped_column(
|
||||
Text, nullable=True, comment="出库单备注",
|
||||
)
|
||||
|
||||
# ---- 撤回(只置位不删行) ----
|
||||
is_revoked: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default="false",
|
||||
comment="该次出库是否已被 MOM 撤回",
|
||||
)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, comment="撤回时间",
|
||||
)
|
||||
|
||||
# ---- 来源 ----
|
||||
# 两条写入路径共用本表(同一语义:产品 ↔ 出库单),拆表会让产品详情要展示
|
||||
# 两张卡片,所以只标来源。
|
||||
# webhook —— MOM 出库回调自动存档(设备自己被发走时按 SN 匹配落一行)
|
||||
# manual —— 人工在界面挂的(创建产品时勾选 / 产品详情追加)
|
||||
source: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="webhook", server_default="webhook",
|
||||
comment="来源: webhook(MOM回调自动存档) | manual(人工挂载)",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=get_beijing_time, comment="本行写入时间",
|
||||
)
|
||||
|
||||
# ---- 关系 ----
|
||||
product: Mapped["Product"] = relationship("Product", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ProductOutbound {self.outbound_no} sn={self.serial_number}>"
|
||||
@ -89,6 +89,13 @@ class Task(Base):
|
||||
records: Mapped[list["TaskRecord"]] = relationship(
|
||||
"TaskRecord", back_populates="task", lazy="selectin", cascade="all, delete-orphan",
|
||||
)
|
||||
# 本任务挂载的 MOM 出库物料(明细级快照)。创建任务时选、之后可追加,
|
||||
# 见 models/task_outbound_material.py 的设计说明。
|
||||
outbound_materials: Mapped[list["TaskOutboundMaterial"]] = relationship(
|
||||
"TaskOutboundMaterial", back_populates="task", lazy="selectin",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="TaskOutboundMaterial.created_at",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Task {self.task_name}>"
|
||||
|
||||
103
backend/app/models/task_outbound_material.py
Normal file
103
backend/app/models/task_outbound_material.py
Normal file
@ -0,0 +1,103 @@
|
||||
"""任务挂载的 MOM 出库物料 — 明细级快照
|
||||
|
||||
回答「这个任务用了哪些 MOM 出库物料」。口径已与业务确认:
|
||||
· 选择粒度 = **整张出库单**(outbound_no),该单据的明细一并带入
|
||||
—— 不存在"单里混了无关物料"的情况
|
||||
· **纯引用,不记用量**:quantity 是出库单原值,不是"本任务用了多少"
|
||||
|
||||
为什么存明细级快照,而不是只存 outbound_no 现查:
|
||||
MOM 的 trans_outbound 是**明细行**(一行 = 一条物料),物料名/规格要经
|
||||
COALESCE 三表 JOIN(stock_buy/stock_semi/stock_product → material_base)
|
||||
才能解析出来。Track 跨库无法 JOIN,若只存单号,每次展示都要打 MOM ——
|
||||
MOM 挂了就看不到已挂内容。存快照后 Track 自包含,与既有的
|
||||
product_outbounds 是同一套存档哲学。
|
||||
成本实测可忽略:517 张单平均 2.64 条明细,65% 是单条明细。
|
||||
|
||||
⚠️ mom_line_id 是**跨库逻辑外键**(指向 MOM 库 trans_outbound.id),无物理
|
||||
约束 —— 与 assignee_id 指向 MOM sys_user 是同一类做法。MOM 库若重建会让
|
||||
自增 ID 错位,所以同时冗余 outbound_no 供人工核对。
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Numeric, String, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
from app.core.time_utils import get_beijing_time
|
||||
|
||||
|
||||
class TaskOutboundMaterial(Base):
|
||||
__tablename__ = "task_outbound_materials"
|
||||
# 防同一条出库明细被重复挂到同一任务(重复提交、前端重放、并发点击)
|
||||
__table_args__ = (
|
||||
UniqueConstraint("task_id", "mom_line_id",
|
||||
name="uq_task_outbound_materials_task_line"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
# ---- 物理外键(关联本库 tasks) ----
|
||||
task_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id"),
|
||||
nullable=False, index=True, comment="所属任务ID",
|
||||
)
|
||||
|
||||
# ---- 跨库逻辑外键(MOM 库 trans_outbound.id,无物理约束) ----
|
||||
mom_line_id: Mapped[int] = mapped_column(
|
||||
nullable=False, comment="MOM 出库明细行ID(逻辑外键→MOM trans_outbound.id)",
|
||||
)
|
||||
outbound_no: Mapped[str] = mapped_column(
|
||||
String(100), nullable=False, index=True,
|
||||
comment="MOM 出库单号(批量出库多商品共用,故本表按明细行成行)",
|
||||
)
|
||||
|
||||
# ---- MOM 物料/单据快照(挂载时从 MOM 拉取,之后 Track 自包含) ----
|
||||
sku: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True, comment="物料SKU(MOM trans_outbound.sku 快照)",
|
||||
)
|
||||
material_name: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, comment="物料名称(经 COALESCE 三表 JOIN 解析后快照)",
|
||||
)
|
||||
spec_model: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, comment="规格型号快照",
|
||||
)
|
||||
quantity: Mapped[Decimal | None] = mapped_column(
|
||||
Numeric(19, 4), nullable=True,
|
||||
comment="出库数量(出库单原值,**不是**本任务用量)",
|
||||
)
|
||||
unit_price: Mapped[Decimal | None] = mapped_column(
|
||||
Numeric(19, 2), nullable=True, comment="出库单价",
|
||||
)
|
||||
outbound_type: Mapped[str | None] = mapped_column(
|
||||
String(50), nullable=True,
|
||||
comment="出库类型 SALES/USE/PRODUCTION/LOSS/REPAIR(MOM 码表未冻结,只存不判)",
|
||||
)
|
||||
consumer_name: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True, comment="领用人/客户(MOM 侧自由填写,非可靠标识)",
|
||||
)
|
||||
operator_name: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True, comment="MOM 侧操作员",
|
||||
)
|
||||
warehouse_location: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True, comment="出库库位快照",
|
||||
)
|
||||
outbound_time: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
comment="MOM 记录的出库时间(写入时已按 +08:00 补全时区)",
|
||||
)
|
||||
|
||||
added_by: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True, comment="挂载人ID(逻辑外键→MOM sys_user)",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=get_beijing_time, comment="挂载时间",
|
||||
)
|
||||
|
||||
# ---- 关系 ----
|
||||
task: Mapped["Task"] = relationship("Task", back_populates="outbound_materials")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<TaskOutboundMaterial {self.outbound_no} sku={self.sku}>"
|
||||
@ -15,6 +15,7 @@ from app.core.security import (
|
||||
)
|
||||
from app.core.mom_database import MomSessionLocal
|
||||
from app.core.logging import user_var
|
||||
from app.core.roles import SUPER_ADMIN
|
||||
from app.schemas.user import LoginResponse, UserResponse
|
||||
|
||||
security = HTTPBearer()
|
||||
@ -24,15 +25,29 @@ def login(username: str, password: str) -> LoginResponse:
|
||||
"""登录 — 签发双 Token(Access + Refresh)"""
|
||||
db = MomSessionLocal()
|
||||
try:
|
||||
# 1. 普通用户:LIKE '%/username' 模糊匹配 MOM sys_user 表
|
||||
# 1. LIKE '%/username' 定位 MOM sys_user 账号,再按部门收敛 ——
|
||||
# 两套 Track 实例共用同一个 MOM 库,不加部门条件的话,另一个部门的
|
||||
# 普通账号也能登进来。
|
||||
#
|
||||
# ⚠️ 唯一的例外是 SUPER_ADMIN:超管**跨部门放行**,供运维/管理员在
|
||||
# 两个实例之间切换。其余角色(INBOUND / SUPERVISOR / WAREHOUSE_MGR /
|
||||
# SALES)必须严格属于本部门(settings.ORG_DEPARTMENT)。
|
||||
#
|
||||
# 不匹配时统一报「用户名或密码错误」,不区分「账号不存在」与「存在但
|
||||
# 不属于本部门」,避免给账号探测者提供线索。
|
||||
from sqlalchemy import text
|
||||
result = db.execute(
|
||||
text(
|
||||
"SELECT id, username, department, role, password_hash "
|
||||
"FROM sys_user "
|
||||
"WHERE username LIKE :pattern"
|
||||
"WHERE username LIKE :pattern "
|
||||
" AND (department = :dept OR role = :super_admin)"
|
||||
),
|
||||
{"pattern": f"%/{username}"},
|
||||
{
|
||||
"pattern": f"%/{username}",
|
||||
"dept": settings.ORG_DEPARTMENT,
|
||||
"super_admin": SUPER_ADMIN,
|
||||
},
|
||||
)
|
||||
row = result.fetchone()
|
||||
|
||||
|
||||
398
backend/app/services/mom_outbound_service.py
Normal file
398
backend/app/services/mom_outbound_service.py
Normal file
@ -0,0 +1,398 @@
|
||||
"""MOM 出库单只读查询 — 直连 MOM 库(跨库,无 ORM)
|
||||
|
||||
为什么直连库,而不是调 MOM 现成的 `GET /api/v1/outbound`:
|
||||
那个接口要 JWT + `permission_required`,且对非特权账号按 `consumer_name`
|
||||
做**行级隔离**(非特权用户只能看到自己名下的单)。Track 用服务账号去调只会
|
||||
拿到该账号名下的数据、不是全量。Track 已有只读 MOM 连接
|
||||
(`app/core/mom_database.py`,mom_cache 也在用),直连才是对的。
|
||||
|
||||
分页必须两段式(照抄 MOM `outbound_service.get_grouped_list` 的做法):
|
||||
1) 先 `GROUP BY outbound_no` 分页,拿到本页的**单据号**
|
||||
2) 再 `WHERE outbound_no IN (...)` 捞这些单的明细
|
||||
⚠️ 绝不能对 join 后的宽表直接分页 —— 那是**明细行数**不是单据数。
|
||||
`outbound_no` 不唯一(批量出库多商品共用),一张单最多 55 条明细,
|
||||
实测 517 张单平均 2.64 条、65% 是单条。
|
||||
|
||||
物料名解析(MOM 自己没有视图,是代码里硬拼的):
|
||||
`trans_outbound.(source_table, stock_id)` 多态指向三张库存表之一,再经它们的
|
||||
`base_id` 回到 `material_base`。用 COALESCE 三路 LEFT JOIN 一次拉全。
|
||||
⚠️ **不要用 `trans_outbound.sku` 做 JOIN** —— 它只是库存表 sku 的冗余快照,
|
||||
不唯一,历史还可能漂移。
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.mom_database import MomSessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# MOM 的 outbound_time 是 `timestamp without time zone`,存的是**北京墙上时间**。
|
||||
BEIJING_TZ = timezone(timedelta(hours=8))
|
||||
|
||||
# 跨部门例外领用人 —— 这几个领用人的出库单,即使物料分类不属于本部门,本实例
|
||||
# 也要能看见。定义与理由见 config.EXTRA_VISIBLE_CONSUMERS。
|
||||
# 本模块只负责**执行**这条例外、不负责判定;改范围请改配置,不要改这里。
|
||||
_EXTRA_CONSUMERS: list[str] = settings.EXTRA_VISIBLE_CONSUMERS_LIST
|
||||
|
||||
# 三张库存表 → material_base 的 JOIN 片段。
|
||||
# 抽成常量是因为「搜索命中」与「拉明细」两处都要用,写歪一处两边就不一致了。
|
||||
_STOCK_JOIN = """
|
||||
LEFT JOIN stock_buy sb ON o.source_table = 'stock_buy' AND sb.id = o.stock_id
|
||||
LEFT JOIN stock_semi ss ON o.source_table = 'stock_semi' AND ss.id = o.stock_id
|
||||
LEFT JOIN stock_product sp ON o.source_table = 'stock_product' AND sp.id = o.stock_id
|
||||
LEFT JOIN material_base b ON b.id = COALESCE(sb.base_id, ss.base_id, sp.base_id)
|
||||
"""
|
||||
|
||||
# 明细行的公共 SELECT 列表(两处查询共用,保证返回字段一致)
|
||||
_LINE_COLUMNS = """
|
||||
o.id AS line_id, o.outbound_no, o.sku,
|
||||
b.name AS material_name, b.spec_model,
|
||||
o.quantity, o.unit_price, o.returned_quantity,
|
||||
o.outbound_type, o.consumer_name, o.operator_name,
|
||||
o.warehouse_location, o.outbound_time,
|
||||
a.request_no
|
||||
"""
|
||||
|
||||
# `trans_outbound.outbound_type` 的中文名 —— 码表源头是 MOM 前端的
|
||||
# `inventory-web/src/views/outbound/index.vue::formatType`,这里照抄一份**统一
|
||||
# 下发**(响应里的 outbound_type_label),前端不再自建映射,否则两边会开始漂移。
|
||||
# ⚠️ 只用于展示,**不做任何业务判断** —— MOM 码表未冻结(`types/api.ts` 同注)。
|
||||
# 库里存的只有 SALES/USE/PRODUCTION 三种,其余是按 MOM 下拉预留的。
|
||||
_OUTBOUND_TYPE_LABELS = {
|
||||
"SALES": "销售出库",
|
||||
"USE": "内部领用",
|
||||
"PRODUCTION": "生产出库",
|
||||
"SCRAP": "报废",
|
||||
"LOSS": "盘亏出库",
|
||||
"REPAIR": "维修出库",
|
||||
}
|
||||
|
||||
|
||||
def describe_outbound_type(code: str | None) -> str:
|
||||
"""出库类型码 → 中文名。
|
||||
|
||||
码表里没有的码**原样返回**(不吞成空串、也不显示「未知」):宁可把英文码
|
||||
露在界面上让人一眼看出是漏配的码表,也不要静默成一句看不出问题的中文。
|
||||
"""
|
||||
raw = (code or "").strip()
|
||||
return _OUTBOUND_TYPE_LABELS.get(raw.upper(), raw)
|
||||
|
||||
|
||||
def _as_beijing(dt: datetime | None) -> datetime | None:
|
||||
"""把 MOM 的 naive 北京时间补上 +08:00 偏移。
|
||||
|
||||
⚠️ 少了这一步,上层(尤其写进 Track 的 timestamptz 列时)会把它当 **UTC**
|
||||
处理,前端在北京显示会整整差 8 小时。这个坑在 MOM 仓的 822f897 刚踩过
|
||||
一次,这里统一在数据出口处理,不留给调用方去记得。
|
||||
"""
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=BEIJING_TZ)
|
||||
return dt.astimezone(BEIJING_TZ)
|
||||
|
||||
|
||||
def _line_dict(row) -> dict:
|
||||
"""把一行查询结果转成明细 dict(字段名与 Track 侧快照列对齐)。"""
|
||||
return {
|
||||
"line_id": row.line_id,
|
||||
"sku": row.sku or "",
|
||||
"material_name": row.material_name or "",
|
||||
"spec_model": row.spec_model or "",
|
||||
"quantity": row.quantity,
|
||||
"unit_price": row.unit_price,
|
||||
"returned_quantity": row.returned_quantity,
|
||||
"outbound_type": row.outbound_type or "",
|
||||
"outbound_type_label": describe_outbound_type(row.outbound_type),
|
||||
"consumer_name": row.consumer_name or "",
|
||||
"operator_name": row.operator_name or "",
|
||||
"warehouse_location": row.warehouse_location or "",
|
||||
"outbound_time": _as_beijing(row.outbound_time),
|
||||
# MOM 的 request_id 是最近才加的列,存量 1364 条**全为 NULL**(无从回填,
|
||||
# 见该仓 models/outbound.py 的注释)。UI 要对空值显示「无关联申请单」。
|
||||
"request_no": row.request_no or "",
|
||||
}
|
||||
|
||||
|
||||
def _fetch_lines(db, outbound_nos: list[str]) -> dict[str, list[dict]]:
|
||||
"""捞出指定单据的明细,按出库单号分组。
|
||||
|
||||
申请单必须 LEFT JOIN —— trans_outbound.request_id 存量全为 NULL,用 INNER
|
||||
会把这批历史单的明细整批吞掉。
|
||||
"""
|
||||
if not outbound_nos:
|
||||
return {}
|
||||
rows = db.execute(
|
||||
text(f"""
|
||||
SELECT {_LINE_COLUMNS}
|
||||
FROM trans_outbound o
|
||||
{_STOCK_JOIN}
|
||||
LEFT JOIN outbound_approval a ON a.id = o.request_id
|
||||
WHERE o.outbound_no = ANY(:nos)
|
||||
ORDER BY o.outbound_time DESC, o.id
|
||||
"""),
|
||||
{"nos": list(outbound_nos)},
|
||||
).fetchall()
|
||||
|
||||
grouped: dict[str, list[dict]] = {}
|
||||
for r in rows:
|
||||
grouped.setdefault(r.outbound_no, []).append(_line_dict(r))
|
||||
return grouped
|
||||
|
||||
|
||||
def search_outbound_orders(
|
||||
keyword: str = "",
|
||||
start_date: str = "",
|
||||
end_date: str = "",
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
consumers: set[str] | None = None,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""按**单据**分页搜索 MOM 出库单,返回 `(单据列表, 单据总数)`。
|
||||
|
||||
keyword 命中范围:出库单号 / 物料SKU / 物料名称 / **规格型号** / 领用人。
|
||||
start_date、end_date 为 `YYYY-MM-DD`,**含端点的整日**。
|
||||
|
||||
consumers:界面筛选的**领用人姓名**集合(AND 语义,只收窄)。
|
||||
· `None` → 不限
|
||||
· 非空集合 → 只返回这些领用人的单
|
||||
· **空集合** → 一条都不返回(绝不退化成「不过滤」)
|
||||
|
||||
⚠️ 本函数只负责**执行**范围,不负责**判定**范围 —— 可见范围(公司前缀
|
||||
+ 跨部门例外)在本模块内以常量化形式钉死,避免出现第二份口径。
|
||||
"""
|
||||
kw = keyword.strip()
|
||||
# ⚠️ 日期参数用 None 而不是空串:写成 `:start = ''` 时 PostgreSQL **不保证
|
||||
# 短路求值**,规划器仍会去算 `CAST('' AS timestamp)` 并直接报
|
||||
# InvalidDatetimeFormat(实测踩到)。传 NULL + 显式 text 转换才安全。
|
||||
params = {
|
||||
"kw": kw,
|
||||
"kw_like": f"%{kw}%" if kw else "",
|
||||
"start": start_date.strip() or None,
|
||||
"end": end_date.strip() or None,
|
||||
# 公司隔离:与物料选择器同一套口径(material_base.category 前缀)。
|
||||
# ⚠️ 必须是**前缀** LIKE,不能写成 ILIKE '%IRIS%':MOM 里 LICA 的物料是
|
||||
# `LICA/<中文>`,而 IRIS 分类树里另有 `IRIS/成品/LICA/…`(171 条)——
|
||||
# 后者本就属于本部门,前缀匹配天然区分得开。
|
||||
"cat_prefix": f"{settings.MATERIAL_CATEGORY_PREFIX}%",
|
||||
}
|
||||
|
||||
# 可见范围 = 公司前缀(范围)∪ 跨部门例外(放行)。
|
||||
# · 前缀是**范围**:本部门物料开出去的单;
|
||||
# · 例外是 OR:那几个领用人跨部门领料,只按前缀过滤会把他们的单整批漏掉。
|
||||
# ⚠️ 例外为空时**整段不拼**:`= ANY(ARRAY[])` 虽然返回 false(不放大范围,
|
||||
# 语义安全),但留一个恒假子句只会让这条 SQL 更难排查。
|
||||
if _EXTRA_CONSUMERS:
|
||||
company_clause = (
|
||||
"(b.category LIKE :cat_prefix OR o.consumer_name = ANY(:extra_consumers))"
|
||||
)
|
||||
params["extra_consumers"] = _EXTRA_CONSUMERS
|
||||
else:
|
||||
company_clause = "b.category LIKE :cat_prefix"
|
||||
|
||||
where = f"""
|
||||
WHERE (:kw = '' OR o.outbound_no ILIKE :kw_like
|
||||
OR o.sku ILIKE :kw_like
|
||||
OR b.name ILIKE :kw_like
|
||||
OR b.spec_model ILIKE :kw_like
|
||||
OR o.consumer_name ILIKE :kw_like)
|
||||
AND {company_clause}
|
||||
AND (CAST(:start AS text) IS NULL
|
||||
OR o.outbound_time >= CAST(:start AS timestamp))
|
||||
AND (CAST(:end AS text) IS NULL
|
||||
OR o.outbound_time < CAST(:end AS timestamp) + interval '1 day')
|
||||
"""
|
||||
|
||||
if consumers is not None:
|
||||
if not consumers:
|
||||
# ⚠️ 空集合必须**显式** false。绝不能拼成 `= ANY(empty)` 或让条件消失 ——
|
||||
# 空范围退化成不过滤就是全量泄漏,这是本模块的底线。
|
||||
where += "\n AND false"
|
||||
else:
|
||||
where += "\n AND o.consumer_name = ANY(:consumers)"
|
||||
params["consumers"] = sorted(consumers)
|
||||
|
||||
db = MomSessionLocal()
|
||||
try:
|
||||
total = db.execute(
|
||||
text(f"""
|
||||
SELECT count(DISTINCT o.outbound_no)
|
||||
FROM trans_outbound o
|
||||
{_STOCK_JOIN}
|
||||
{where}
|
||||
"""),
|
||||
params,
|
||||
).scalar() or 0
|
||||
|
||||
if total == 0:
|
||||
return [], 0
|
||||
|
||||
order_rows = db.execute(
|
||||
text(f"""
|
||||
SELECT o.outbound_no,
|
||||
max(o.outbound_time) AS outbound_time,
|
||||
max(o.outbound_type) AS outbound_type,
|
||||
max(o.consumer_name) AS consumer_name,
|
||||
max(o.operator_name) AS operator_name,
|
||||
count(*) AS line_count,
|
||||
sum(o.quantity) AS total_quantity
|
||||
FROM trans_outbound o
|
||||
{_STOCK_JOIN}
|
||||
{where}
|
||||
GROUP BY o.outbound_no
|
||||
ORDER BY outbound_time DESC, o.outbound_no DESC
|
||||
LIMIT :lim OFFSET :off
|
||||
"""),
|
||||
{**params, "lim": limit, "off": skip},
|
||||
).fetchall()
|
||||
|
||||
lines_by_no = _fetch_lines(db, [r.outbound_no for r in order_rows])
|
||||
|
||||
orders = [
|
||||
{
|
||||
"outbound_no": r.outbound_no,
|
||||
"outbound_time": _as_beijing(r.outbound_time),
|
||||
"outbound_type": r.outbound_type or "",
|
||||
"outbound_type_label": describe_outbound_type(r.outbound_type),
|
||||
"consumer_name": r.consumer_name or "",
|
||||
"operator_name": r.operator_name or "",
|
||||
"line_count": r.line_count,
|
||||
"total_quantity": r.total_quantity,
|
||||
"lines": lines_by_no.get(r.outbound_no, []),
|
||||
}
|
||||
for r in order_rows
|
||||
]
|
||||
return orders, total
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_lines_by_ids(mom_line_ids: list[int]) -> list[dict]:
|
||||
"""按 MOM 明细行 ID 取快照,供挂载到任务时生成 Track 侧快照。
|
||||
|
||||
查不到的 ID 会被**静默跳过** —— 由调用方比对数量后决定要不要提示用户
|
||||
(MOM 库清理过数据时会命中这种情况)。
|
||||
"""
|
||||
if not mom_line_ids:
|
||||
return []
|
||||
db = MomSessionLocal()
|
||||
try:
|
||||
rows = db.execute(
|
||||
text(f"""
|
||||
SELECT {_LINE_COLUMNS}
|
||||
FROM trans_outbound o
|
||||
{_STOCK_JOIN}
|
||||
LEFT JOIN outbound_approval a ON a.id = o.request_id
|
||||
WHERE o.id = ANY(:ids)
|
||||
"""),
|
||||
{"ids": [int(i) for i in mom_line_ids]},
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = _line_dict(r)
|
||||
d["outbound_no"] = r.outbound_no
|
||||
out.append(d)
|
||||
return out
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_lines_by_outbound_no(outbound_no: str) -> list[dict]:
|
||||
"""按**出库单号**取该单的全部明细快照(供 MOM 出库回调存档用)。
|
||||
|
||||
为什么需要它:统一后的 `product_outbound_materials` 是**明细级**,而
|
||||
MOM 的出库回调只带单号、不带明细 —— 不现查的话,这台设备上「领了什么料」
|
||||
就永远是空的。
|
||||
|
||||
查不到返回空列表(调用方据此退化成单据级存档,而不是丢掉这张单)。
|
||||
⚠️ 批量出库多个商品共用一个单号,所以这里可能返回多行,也可能一行都没有。
|
||||
"""
|
||||
no = (outbound_no or "").strip()
|
||||
if not no:
|
||||
return []
|
||||
db = MomSessionLocal()
|
||||
try:
|
||||
rows = db.execute(
|
||||
text(f"""
|
||||
SELECT {_LINE_COLUMNS}
|
||||
FROM trans_outbound o
|
||||
{_STOCK_JOIN}
|
||||
LEFT JOIN outbound_approval a ON a.id = o.request_id
|
||||
WHERE o.outbound_no = :no
|
||||
ORDER BY o.outbound_time DESC, o.id
|
||||
"""),
|
||||
{"no": no},
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = _line_dict(r)
|
||||
d["outbound_no"] = r.outbound_no
|
||||
out.append(d)
|
||||
return out
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def list_consumer_names(consumers: set[str] | None = None) -> list[str]:
|
||||
"""本部门出库单里出现过的**领用人姓名**(去重、按出现次数降序)。
|
||||
|
||||
给前端下拉用。`consumers` 的含义与 search_outbound_orders 完全一致
|
||||
(None=不限 / 空集=空结果),**同样受可见范围约束** —— 下拉里不能出现
|
||||
用户本来就看不到的人名,否则等于把范围外的人员信息漏出去。
|
||||
可见范围的构造与 search_outbound_orders 保持一致(前缀 ∪ 跨部门例外)。
|
||||
"""
|
||||
if consumers is not None and not consumers:
|
||||
return []
|
||||
|
||||
params: dict = {"cat_prefix": f"{settings.MATERIAL_CATEGORY_PREFIX}%"}
|
||||
if _EXTRA_CONSUMERS:
|
||||
company_clause = (
|
||||
"(b.category LIKE :cat_prefix OR o.consumer_name = ANY(:extra_consumers))"
|
||||
)
|
||||
params["extra_consumers"] = _EXTRA_CONSUMERS
|
||||
else:
|
||||
company_clause = "b.category LIKE :cat_prefix"
|
||||
|
||||
sql = """
|
||||
SELECT o.consumer_name, count(*) AS cnt
|
||||
FROM trans_outbound o
|
||||
{stock_join}
|
||||
WHERE {company_clause}
|
||||
AND o.consumer_name IS NOT NULL
|
||||
AND o.consumer_name <> ''
|
||||
""".format(stock_join=_STOCK_JOIN, company_clause=company_clause)
|
||||
if consumers is not None:
|
||||
sql += " AND o.consumer_name = ANY(:consumers)"
|
||||
params["consumers"] = sorted(consumers)
|
||||
sql += " GROUP BY o.consumer_name ORDER BY cnt DESC, o.consumer_name"
|
||||
|
||||
db = MomSessionLocal()
|
||||
try:
|
||||
return [r[0] for r in db.execute(text(sql), params).fetchall()]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_orders_by_line_ids(mom_line_ids: list[int]) -> list[dict]:
|
||||
"""按明细行 ID 归并出**单据级**信息(同一张单只返回一条)。
|
||||
|
||||
用户是按整张出库单勾选的,而 product_outbounds 是单据级(一行 = 一张单),
|
||||
所以提交上来的明细 ID 要先归并回单据再落库。
|
||||
|
||||
单据头的字段(出库时间/类型/领用人/操作员/申请单号)同单内必然一致,
|
||||
取任意一条即可。
|
||||
"""
|
||||
merged: dict[str, dict] = {}
|
||||
for ln in get_lines_by_ids(mom_line_ids):
|
||||
merged.setdefault(ln["outbound_no"], {
|
||||
"outbound_no": ln["outbound_no"],
|
||||
"outbound_time": ln["outbound_time"],
|
||||
"outbound_type": ln["outbound_type"],
|
||||
"consumer_name": ln["consumer_name"],
|
||||
"operator_name": ln["operator_name"],
|
||||
"request_no": ln["request_no"],
|
||||
})
|
||||
return list(merged.values())
|
||||
Reference in New Issue
Block a user