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:
@ -0,0 +1,93 @@
|
||||
"""add_product_outbounds
|
||||
|
||||
Revision ID: m1n2o3p4q5r6
|
||||
Revises: k1l2m3n4o5p6
|
||||
Create Date: 2026-09-23
|
||||
|
||||
产品出库记录(product_outbounds)
|
||||
--------------------------------------------------------------------------
|
||||
把 MOM 出库回调里的单据上下文存档到 Track 侧,回答「这台设备这次出库对应
|
||||
MOM 的哪张单」。
|
||||
|
||||
背景:MOM 的出库 webhook 此前只发 7 个字段,出库单号/申请单号/领用人/申请人
|
||||
全都没带,Track 扫码只能看到「已出库」,不知道是为谁、凭什么出的库。
|
||||
MOM 侧已同步扩展 payload(见 projects 仓的对应提交),本表是接收端。
|
||||
|
||||
为什么单独一张表,而不是给 products 加几列:
|
||||
一台设备可能出库多次(出库 → 撤回 → 再出库),加列只能保住最后一次,
|
||||
而需求是「完整出库历史」。本表一次出库一行。
|
||||
|
||||
为什么撤回不删行:
|
||||
「出过又撤了」本身就是需要看得见的历史。撤回只把 is_revoked 置真、
|
||||
记下 revoked_at。
|
||||
|
||||
唯一约束 (serial_number, outbound_no):
|
||||
防 MOM 重推产生重复行。**不能只约束 outbound_no** —— MOM 的批量出库是多个
|
||||
商品共用一个单号(见 MOM models/outbound.py:127 的注释)。
|
||||
|
||||
与 products.overall_status / status 的分工:
|
||||
那两列是**当前事实**(此刻是否已出库),本表是**单据归属**。设备被撤回回库后
|
||||
overall_status 变回「已入库」,但出库单仍在,只是标了已撤回。
|
||||
|
||||
本迁移只建表,不写入任何数据。
|
||||
本次上线前已出库的设备没有存过单据,产品详情上不会显示出库记录 ——
|
||||
这是预期行为,历史无从回填(MOM 侧出库流水与申请单此前也没有关联)。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision: str = "m1n2o3p4q5r6"
|
||||
down_revision: Union[str, None] = "k1l2m3n4o5p6"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"product_outbounds",
|
||||
sa.Column("id", UUID(as_uuid=True), nullable=False, comment="主键"),
|
||||
sa.Column("product_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("products.id"), nullable=False, comment="所属产品ID"),
|
||||
sa.Column("serial_number", sa.String(16), nullable=True,
|
||||
comment="产品序列号(冗余,便于按SN对账)"),
|
||||
sa.Column("outbound_no", sa.String(100), nullable=False,
|
||||
comment="MOM 出库单号(批量出库多商品共用)"),
|
||||
sa.Column("request_no", sa.String(100), nullable=True,
|
||||
comment="MOM 出库申请单号"),
|
||||
sa.Column("consumer_name", sa.String(100), nullable=True,
|
||||
comment="领用人/客户(自由填写,非可靠标识)"),
|
||||
sa.Column("applicant_name", sa.String(100), nullable=True,
|
||||
comment="申请人姓名(MOM 侧解析后传来)"),
|
||||
sa.Column("operator", sa.String(64), nullable=True,
|
||||
comment="MOM 侧实际扫码出库人"),
|
||||
sa.Column("outbound_type", sa.String(50), nullable=True,
|
||||
comment="出库类型 SALES/USE/PRODUCTION(只存不判)"),
|
||||
sa.Column("outbound_time", sa.DateTime(timezone=True), nullable=True,
|
||||
comment="MOM 记录的出库时间"),
|
||||
sa.Column("remark", sa.Text(), nullable=True, comment="出库单备注"),
|
||||
sa.Column("is_revoked", sa.Boolean(), nullable=False, server_default="false",
|
||||
comment="该次出库是否已被 MOM 撤回"),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True,
|
||||
comment="撤回时间"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
comment="本行写入时间"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("serial_number", "outbound_no",
|
||||
name="uq_product_outbound_sn_no"),
|
||||
comment="产品出库记录 — MOM 出库回调的单据存档,一次出库一行",
|
||||
)
|
||||
# 产品详情按 product_id 拉历史
|
||||
op.create_index(op.f("ix_product_outbounds_product_id"),
|
||||
"product_outbounds", ["product_id"], unique=False)
|
||||
# 按 SN 排查/对账
|
||||
op.create_index(op.f("ix_product_outbounds_serial_number"),
|
||||
"product_outbounds", ["serial_number"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_product_outbounds_serial_number"), table_name="product_outbounds")
|
||||
op.drop_index(op.f("ix_product_outbounds_product_id"), table_name="product_outbounds")
|
||||
op.drop_table("product_outbounds")
|
||||
@ -0,0 +1,99 @@
|
||||
"""add_task_outbound_materials
|
||||
|
||||
Revision ID: n1o2p3q4r5s6
|
||||
Revises: m1n2o3p4q5r6
|
||||
Create Date: 2026-09-23
|
||||
|
||||
任务挂载的 MOM 出库物料(task_outbound_materials)
|
||||
--------------------------------------------------------------------------
|
||||
回答「这个任务用了哪些 MOM 出库物料」。此前 Track 里创建任务完全无法关联
|
||||
出库物料;任务进行中若发现第一次领的料不够,也没有地方追加后续出库单。
|
||||
|
||||
口径(已与业务确认):
|
||||
· 选择粒度 = **整张出库单**(outbound_no),该单的明细一并带入
|
||||
· **纯引用,不记用量** —— 不存"本任务用多少"
|
||||
|
||||
为什么是明细级(一行 = MOM trans_outbound 的一行)而不是单据级:
|
||||
MOM 的 trans_outbound 是明细行,物料名/规格要经 COALESCE 三表 JOIN
|
||||
(stock_buy/stock_semi/stock_product → material_base) 才能解析。Track 跨库
|
||||
无法 JOIN,只存单号的话每次展示都要打 MOM —— MOM 挂了就看不到已挂内容。
|
||||
存快照后 Track 自包含,与既有 product_outbounds 同一套存档哲学。
|
||||
成本实测可忽略:517 张单平均 2.64 条明细,65% 是单条明细。
|
||||
|
||||
⚠️ 与 product_outbounds 的区别(容易混淆):
|
||||
product_outbounds 是「MOM 出库回调 → 产品详情只读展示」,挂在 **产品** 上、
|
||||
单向存档;本表是「人在界面上选择 → 挂到 **任务** 上」、可增可删。
|
||||
|
||||
唯一约束 (task_id, mom_line_id):
|
||||
防同一条出库明细被重复挂到同一任务(重复提交 / 前端重放 / 并发点击)。
|
||||
|
||||
mom_line_id 是**跨库逻辑外键**(MOM 库 trans_outbound.id),无物理约束 ——
|
||||
与 assignee_id 指向 MOM sys_user 同一类做法。MOM 库若重建会让自增 ID 错位,
|
||||
故同时冗余 outbound_no 供人工核对。
|
||||
|
||||
本迁移只建表,不写入任何数据。存量任务没有挂载,任务详情上不会显示物料 ——
|
||||
预期行为。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision: str = "n1o2p3q4r5s6"
|
||||
down_revision: Union[str, None] = "m1n2o3p4q5r6"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"task_outbound_materials",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False,
|
||||
comment="主键"),
|
||||
sa.Column("task_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("tasks.id"), nullable=False, comment="所属任务ID"),
|
||||
sa.Column("mom_line_id", sa.Integer(), nullable=False,
|
||||
comment="MOM 出库明细行ID(逻辑外键→MOM trans_outbound.id)"),
|
||||
sa.Column("outbound_no", sa.String(100), nullable=False,
|
||||
comment="MOM 出库单号(批量出库多商品共用,故本表按明细行成行)"),
|
||||
sa.Column("sku", sa.String(100), nullable=True, comment="物料SKU"),
|
||||
sa.Column("material_name", sa.String(255), nullable=True,
|
||||
comment="物料名称(经 COALESCE 三表 JOIN 解析后快照)"),
|
||||
sa.Column("spec_model", sa.String(255), nullable=True, comment="规格型号快照"),
|
||||
sa.Column("quantity", sa.Numeric(19, 4), nullable=True,
|
||||
comment="出库数量(出库单原值,不是本任务用量)"),
|
||||
sa.Column("unit_price", sa.Numeric(19, 2), nullable=True, comment="出库单价"),
|
||||
sa.Column("outbound_type", sa.String(50), nullable=True,
|
||||
comment="出库类型 SALES/USE/PRODUCTION/LOSS/REPAIR(只存不判)"),
|
||||
sa.Column("consumer_name", sa.String(100), nullable=True,
|
||||
comment="领用人/客户(自由填写,非可靠标识)"),
|
||||
sa.Column("operator_name", sa.String(100), nullable=True,
|
||||
comment="MOM 侧操作员"),
|
||||
sa.Column("warehouse_location", sa.String(100), nullable=True,
|
||||
comment="出库库位快照"),
|
||||
sa.Column("outbound_time", sa.DateTime(timezone=True), nullable=True,
|
||||
comment="MOM 记录的出库时间(写入时已按 +08:00 补全时区)"),
|
||||
sa.Column("added_by", sa.String(64), nullable=True,
|
||||
comment="挂载人ID(逻辑外键→MOM sys_user)"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
comment="挂载时间"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("task_id", "mom_line_id",
|
||||
name="uq_task_outbound_materials_task_line"),
|
||||
comment="任务挂载的 MOM 出库物料 — 一行 = MOM 出库单的一条明细",
|
||||
)
|
||||
# 任务详情按 task_id 拉已挂物料
|
||||
op.create_index(op.f("ix_task_outbound_materials_task_id"),
|
||||
"task_outbound_materials", ["task_id"], unique=False)
|
||||
# 按出库单号分组展示 / 反查"这张单被哪些任务用过"
|
||||
op.create_index(op.f("ix_task_outbound_materials_outbound_no"),
|
||||
"task_outbound_materials", ["outbound_no"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_task_outbound_materials_outbound_no"),
|
||||
table_name="task_outbound_materials")
|
||||
op.drop_index(op.f("ix_task_outbound_materials_task_id"),
|
||||
table_name="task_outbound_materials")
|
||||
op.drop_table("task_outbound_materials")
|
||||
@ -0,0 +1,48 @@
|
||||
"""add_product_outbounds_source
|
||||
|
||||
Revision ID: o1p2q3r4s5t6
|
||||
Revises: n1o2p3q4r5s6
|
||||
Create Date: 2026-09-23
|
||||
|
||||
给 product_outbounds 加「来源」列
|
||||
--------------------------------------------------------------------------
|
||||
背景:product_outbounds 原先只有一条写入路径 —— MOM 出库回调自动存档(设备
|
||||
自己被发走时,按 SN 匹配后落一行)。
|
||||
|
||||
现在多了一条:**创建产品时人工从 MOM 出库单里勾选**并挂钩,让「这台设备对应
|
||||
MOM 的哪张出库单」可以在建档时就录进去,而不是只能等 MOM 推送。
|
||||
|
||||
两条路径写的是同一张表、同一个语义(产品 ↔ 出库单),所以不该拆表 —— 拆开会让
|
||||
产品详情要展示两张卡片。加一列标来源即可,排查时也能一眼看出这行是谁写的。
|
||||
|
||||
webhook —— MOM 出库回调自动存档(存量行全是这个)
|
||||
manual —— 人工在界面(创建产品 / 产品详情追加)挂的
|
||||
|
||||
存量行按 webhook 回填(该列上线前只有回调这一条路径,语义确定,不存在猜的问题)。
|
||||
|
||||
幂等:ADD COLUMN IF NOT EXISTS,可重复执行。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "o1p2q3r4s5t6"
|
||||
down_revision: Union[str, None] = "n1o2p3q4r5s6"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE product_outbounds "
|
||||
"ADD COLUMN IF NOT EXISTS source varchar(16) NOT NULL DEFAULT 'webhook'"
|
||||
)
|
||||
op.execute(
|
||||
"COMMENT ON COLUMN product_outbounds.source IS "
|
||||
"'来源: webhook(MOM回调自动存档) | manual(人工挂载)'"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE product_outbounds DROP COLUMN IF EXISTS source")
|
||||
Reference in New Issue
Block a user