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:
@ -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}>"
|
||||
Reference in New Issue
Block a user