refactor(outbound): 出库单据与领用物料合并成一张表

这两者本来就是同一件事(这台设备对应 MOM 的哪些出库单、领了哪些料),
却因为粒度不同被拆成两张表、界面上两张卡:用户要面对两个入口两个删除按钮,
还会问「我在那边挂的怎么这边看不见」。更糟的是**单据级那张没有 mom_line_id,
挂上去的料根本报不了废**。

- 新建 product_outbound_materials,统一到**明细级**(只有它带 mom_line_id,
  而报废要用它定位)。单据级信息(申请单号/备注/撤回)作为冗余列落在每条明细上。
  task_id 改为可空 —— 任务只是溯源信息,不再是组织维度,展示/报废/删除按设备走。
- 接口从 7 个收敛成 3 个(GET/POST/DELETE /products/{id}/outbound-materials,
  外加整单删 by-order)。任务级那套连同 TaskResponse.outbound_materials 一起删掉:
  保留第二个入口只会让「同一个东西两个地方」重新长出来。
- MOM 回调存档改为按 outbound_no 去 MOM **现查明细**逐行落 —— 不查的话
  这台设备「领了什么料」永远是空的,也就报不了废。查不到时退化成单据级存档,
  宁可显示「有这张单但看不到明细」,也不要静默丢掉这张单。
- 扫码响应补 outbound_materials(附「谁挂上去的」中文名,服务端解析)。
  ⚠️ 依赖 task_tree_loader 的 selectinload —— 异步 session 下懒加载会
  MissingGreenlet。
- 前端两张卡合并成一张:按出库单号分组、点开看明细,明细行才有报废/删除。
This commit is contained in:
2026-09-23 15:18:06 +08:00
parent 551819e0e3
commit 5d5aea1015
15 changed files with 2030 additions and 15 deletions

View File

@ -0,0 +1,168 @@
"""unify_product_outbound_materials
Revision ID: q1r2s3t4u5v6
Revises: p1q2r3s4t5u6
Create Date: 2026-09-23
设备出库明细(product_outbound_materials)—— 合并原先的两张表
--------------------------------------------------------------------------
原先「设备对应 MOM 的哪些出库单」被拆在两张表里:
· product_outbounds —— 产品 ↔ 出库**单**(单据级)。人工「追加出库单」
或 MOM 回调存档写入。**没有 mom_line_id**。
· task_outbound_materials —— 任务 ↔ 出库**明细**(明细级)。建任务勾选 /
「+领料」/ 移动端领料写入。**有 mom_line_id**。
两者本来就是**同一件事**,却因为粒度不同被拆成两张、界面上两张卡 ——
用户要面对两个入口、两个删除按钮,还会问「我在那边挂的怎么这边看不见」。
更糟的是:**只有明细级那张带 mom_line_id,而报废必须靠它定位**,
所以走单据级挂的料根本报不了废。
本迁移把它们统一到**明细级**一张表:
· 单据级信息(request_no / applicant_name / remark / is_revoked)作为**冗余列**
落到每条明细上 —— 同单内必然一致;
· task_id 改为**可空**(webhook 存档不知道任务;任务只是溯源信息,
不再是组织维度,展示/报废/删除一律按**设备**走);
· mom_line_id 改为**可空** —— 从 product_outbounds 搬过来的存量行没有明细行 id,
去 MOM 现查既慢又可能查不到,宁可留空(这类行只能看、不能报废)。
⚠️ 旧表**不删**:一是出问题时能回滚,二是它们还是「这次合并到底搬了什么」的证据。
确认稳定后再单独一次迁移清掉(届时记得同步删掉模型与引用)。
数据搬迁用 ON CONFLICT DO NOTHING:部分唯一索引已保证「同设备同明细只一行」,
重跑本迁移不会插重复。
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID
revision: str = "q1r2s3t4u5v6"
down_revision: Union[str, None] = "p1q2r3s4t5u6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"product_outbound_materials",
sa.Column("id", sa.Integer(), autoincrement=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("task_id", UUID(as_uuid=True),
sa.ForeignKey("tasks.id"), nullable=True,
comment="人工挂载时选的任务(可空,仅溯源用,不参与展示/报废/删除)"),
sa.Column("mom_line_id", sa.Integer(), nullable=True,
comment="MOM 出库明细行ID(逻辑外键→MOM trans_outbound.id)。为空=MOM 查不到明细的单据存档"),
sa.Column("outbound_no", sa.String(100), nullable=False,
comment="MOM 出库单号(批量出库多商品共用,故本表按明细行成行)"),
sa.Column("request_no", sa.String(100), nullable=True, comment="MOM 出库申请单号"),
sa.Column("applicant_name", sa.String(100), nullable=True,
comment="申请人姓名(MOM 侧解析后传来,不做 ID 反查)"),
sa.Column("remark", sa.Text(), nullable=True, comment="出库单备注"),
sa.Column("sku", sa.String(100), nullable=True, comment="物料SKU(MOM 快照)"),
sa.Column("material_name", sa.String(255), nullable=True, comment="物料名称(快照)"),
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="领用人/客户(MOM 侧自由填写,非可靠标识)"),
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("source", sa.String(16), nullable=False, server_default="manual",
comment="来源: manual(人工挂载,可删) | webhook(MOM回调自动存档,不可删)"),
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("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"),
comment="设备出库明细 — 一行 = 设备上的一条 MOM 出库明细(合并原 product_outbounds 与 task_outbound_materials)",
)
# ---- 普通索引 ----
op.create_index("ix_pom_product_id", "product_outbound_materials", ["product_id"])
op.create_index("ix_pom_serial_number", "product_outbound_materials", ["serial_number"])
op.create_index("ix_pom_task_id", "product_outbound_materials", ["task_id"])
op.create_index("ix_pom_mom_line_id", "product_outbound_materials", ["mom_line_id"])
op.create_index("ix_pom_outbound_no", "product_outbound_materials", ["outbound_no"])
# ---- 唯一约束(部分索引)----
# 同一台设备上,同一条 MOM 出库明细只能出现一次。
# WHERE mom_line_id IS NOT NULL:NULL 之间不相等,带上它等于给「无明细存档」开重复后门。
op.create_index(
"uq_pom_product_line", "product_outbound_materials",
["product_id", "mom_line_id"], unique=True,
postgresql_where=sa.text("mom_line_id IS NOT NULL"),
)
# 无明细行的存档:一张单在一台设备上只留一行
op.create_index(
"uq_pom_product_no_noline", "product_outbound_materials",
["product_id", "outbound_no"], unique=True,
postgresql_where=sa.text("mom_line_id IS NULL"),
)
# =====================================================================
# 存量搬迁
# =====================================================================
# ① product_outbounds(单据级)→ 明细级。没有 mom_line_id,故留空:
# 这类行能看、能删(manual 的),但**不能报废** —— 报废要 mom_line_id 定位。
op.execute("""
INSERT INTO product_outbound_materials (
product_id, serial_number, task_id, mom_line_id, outbound_no,
request_no, applicant_name, remark,
sku, material_name, spec_model, quantity, unit_price,
outbound_type, consumer_name, operator_name, warehouse_location,
outbound_time, source, is_revoked, revoked_at, added_by, created_at
)
SELECT product_id, serial_number, NULL, NULL, outbound_no,
request_no, applicant_name, remark,
NULL, NULL, NULL, NULL, NULL,
outbound_type, consumer_name, operator, NULL,
outbound_time, source, is_revoked, revoked_at, NULL, created_at
FROM product_outbounds
ON CONFLICT DO NOTHING
""")
# ② task_outbound_materials(明细级)→ 明细级。product_id 从它所属任务带出。
# 任务查不到产品(理论上有外键不该发生)时跳过该行 —— 本表 product_id NOT NULL。
op.execute("""
INSERT INTO product_outbound_materials (
product_id, serial_number, task_id, mom_line_id, outbound_no,
request_no, applicant_name, remark,
sku, material_name, spec_model, quantity, unit_price,
outbound_type, consumer_name, operator_name, warehouse_location,
outbound_time, source, is_revoked, revoked_at, added_by, created_at
)
SELECT t.product_id, p.serial_number, m.task_id, m.mom_line_id, m.outbound_no,
NULL, NULL, NULL,
m.sku, m.material_name, m.spec_model, m.quantity, m.unit_price,
m.outbound_type, m.consumer_name, m.operator_name, m.warehouse_location,
m.outbound_time, 'manual', false, NULL, m.added_by, m.created_at
FROM task_outbound_materials m
JOIN tasks t ON t.id = m.task_id
LEFT JOIN products p ON p.id = t.product_id
ON CONFLICT DO NOTHING
""")
def downgrade() -> None:
# 只删新表。旧表与其数据自始至终没动过,所以回滚是干净的 ——
# 这也是当初决定「旧表不删」的原因之一。
for name in (
"uq_pom_product_no_noline", "uq_pom_product_line",
"ix_pom_outbound_no", "ix_pom_mom_line_id", "ix_pom_task_id",
"ix_pom_serial_number", "ix_pom_product_id",
):
op.drop_index(name, table_name="product_outbound_materials")
op.drop_table("product_outbound_materials")