Files
track/backend/alembic/versions/m1n2o3p4q5r6_add_product_outbounds.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

94 lines
4.7 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.

"""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")