Files
track/backend/app/models/product_outbound.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

108 lines
4.9 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 出库回调在 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}>"