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 NULLNULL 之间不相等,带上它等于给「无明细存档」开重复后门。
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")

View File

@ -11,10 +11,18 @@ from app.models.message import ProductMessage
from app.schemas.product import ( from app.schemas.product import (
ProductCreate, ProductCreate,
ProductUpdate, ProductUpdate,
ProductOutboundMaterialResponse,
ProductResponse, ProductResponse,
ProductScanResponse, ProductScanResponse,
ProductScrapCreate,
ProductScrapResponse,
)
from app.services import (
product_service,
product_finalize_service,
product_scrap_service,
product_outbound_material_service,
) )
from app.services import product_service, product_finalize_service
from app.services.auth_service import get_current_user from app.services.auth_service import get_current_user
from app.services.qrcode_service import generate_qrcode_png from app.services.qrcode_service import generate_qrcode_png
@ -107,11 +115,169 @@ async def create_product_endpoint(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
): ):
"""创建产品 — 初始位置自动设为当前登录用户""" """创建产品 — 初始位置自动设为当前登录用户
`mom_line_ids` 非空时,会在**同一事务**里把对应的 MOM 出库单挂到这个新产品上,
所以不存在「产品建好了但出库单没挂上」的中间态。
"""
creator_username = current_user.get("username", "") creator_username = current_user.get("username", "")
return await product_service.create_product(db, data, creator_username) return await product_service.create_product(db, data, creator_username)
# ============================================================
# 设备的 MOM 出库明细(统一后的唯一一组接口)
#
# 原先这里是两套并存的接口:
# · /outbound-orders —— 产品 ↔ 出库**单**单据级product_outbounds
# · /materials —— 任务 ↔ 出库**明细**明细级task_outbound_materials
# 两者本来就是同一个概念,却因为粒度不同被拆开:用户要面对两个入口、两张卡,
# 而且走单据级挂的料**没有明细行 id报不了废**。现已合并 ——
# 一张表 product_outbound_materials、一组接口、界面上只有一张卡。
# ============================================================
class ProductOutboundMaterialsAdd(BaseModel):
"""挂载 MOM 出库明细的请求体"""
# 提交的是 MOM 出库**明细行** IDtrans_outbound.id。前端按整张出库单勾选
# 提交时把该单全部明细 ID 带过来 —— 本表按明细行成行,一张单展开成 N 行。
# ⚠️ 只传 ID物料快照由后端现查 MOM —— 不接受前端传快照,否则可伪造。
mom_line_ids: list[int] = Field(default_factory=list, description="MOM 出库明细行ID")
# 挂到哪条任务(**可空**)。一线按任务领料,所以人工挂载时会给;
# 但任务只是溯源信息,不参与展示/报废/删除 —— 那些一律按设备走。
task_id: str | None = Field(None, description="所属任务ID(可空,仅溯源)")
@router.get("/{product_id}/outbound-materials",
response_model=list[ProductOutboundMaterialResponse])
async def get_product_outbound_materials_endpoint(
product_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""列出该设备挂载的全部 MOM 出库明细(按出库时间倒序)。
回答「这台设备对应 MOM 的哪些出库单、领了哪些料」——
网页端编辑产品弹窗与移动端「领用物料」页读的都是它,两端同源。
"""
import uuid
return await product_outbound_material_service.list_product_materials(
db, uuid.UUID(product_id))
@router.post("/{product_id}/outbound-materials",
response_model=list[ProductOutboundMaterialResponse])
async def add_product_outbound_materials_endpoint(
product_id: str,
data: ProductOutboundMaterialsAdd,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""给设备挂载 MOM 出库明细(网页端/移动端的「+ 领料」都走这里)。
幂等:已挂过的明细会被跳过。返回该设备当前**全部**出库明细。
"""
import uuid
pid = uuid.UUID(product_id)
product = await product_service.get_product(db, pid) # 不存在则 404
added = await product_outbound_material_service.link_outbound_lines(
db, product, data.mom_line_ids,
task_id=uuid.UUID(data.task_id) if data.task_id else None,
added_by=current_user.get("username"),
)
if added:
await db.commit()
return await product_outbound_material_service.list_product_materials(db, pid)
@router.delete("/{product_id}/outbound-materials/by-order/{outbound_no}",
response_model=list[ProductOutboundMaterialResponse])
async def remove_product_outbound_order_endpoint(
product_id: str,
outbound_no: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""整张出库单一起摘掉(挂错了要能撤)。
界面上更容易碰到的是「这一单整个挂错了」,逐条删要删好几下。
规则与逐条删**完全一致**:只要这张单在本设备上有一行来自 MOM 回调
自动存档(`source='webhook'`),整单就返回 409 —— 那是系统事实,
要撤得去 MOM 撤回。这样整单删不会变成绕过单行规则的后门。
⚠️ 路径放在 `/{material_id}` **之前**注册:`by-order` 是固定段,
但要避免被 `{material_id}` 抢先匹配Starlette 按注册顺序匹配)。
"""
import uuid
return await product_outbound_material_service.remove_product_order(
db, uuid.UUID(product_id), outbound_no)
@router.delete("/{product_id}/outbound-materials/{material_id}",
response_model=list[ProductOutboundMaterialResponse])
async def remove_product_outbound_material_endpoint(
product_id: str,
material_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""摘掉一条**人工挂载**的出库明细(挂错了要能撤)。
⚠️ 只允许删人工挂的(`source='manual'`MOM 出库回调自动存档的行返回 409
—— 那是系统事实,要撤得去 MOM 撤回,由回调置「已撤回」留痕。
返回该设备**剩余**的全部出库明细,前端整体覆盖即可。
"""
import uuid
return await product_outbound_material_service.remove_product_material(
db, uuid.UUID(product_id), material_id)
# ============================================================
# 生产报废 —— Track 发起MOM 走「退回(不良品) → 报废申请 → 审批 → 执行」
# ============================================================
@router.get("/{product_id}/scraps", response_model=list[ProductScrapResponse])
async def list_product_scraps_endpoint(
product_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""列出该产品的生产报废记录(按提交时间倒序)。
状态与金额是**实时回查 MOM** 的:报废没有回调,本地存的那份会过期,
而「到底批没批、执行没执行」正是用户要看的。
MOM 暂时查不到时降级用本地快照,但**不伪造金额**(未执行时 total_loss 是 null
"""
import uuid
return await product_scrap_service.list_product_scraps(db, uuid.UUID(product_id))
@router.post("/{product_id}/scraps", response_model=ProductScrapResponse)
async def create_product_scrap_endpoint(
product_id: str,
data: ProductScrapCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""提交一条生产报废(领用的料在生产中损坏)。
- 只传 `mom_line_id` + 数量 + `track_ref`,物料信息由后端从本产品已挂的
出库物料里取 —— 不接受前端传快照。
- 后端校验 `mom_line_id` **确实挂在本产品上**:可见范围是整台设备,
跨设备防护只能靠这道校验(不靠隐藏)。
- 申请人 = 当前登录人Track 的 sub 就是 MOM sys_user.id
- 幂等:同一个 `track_ref` 重发不会产生第二张 MOM 报废单。
"""
import uuid
return await product_scrap_service.submit_product_scrap(
db, uuid.UUID(product_id),
mom_line_id=data.mom_line_id,
quantity=data.quantity,
track_ref=data.track_ref,
reason=data.reason,
current_user=current_user,
)
@router.patch("/{product_id}", response_model=ProductResponse) @router.patch("/{product_id}", response_model=ProductResponse)
async def update_product_endpoint( async def update_product_endpoint(
product_id: str, product_id: str,

View File

@ -64,9 +64,23 @@ async def create_task_endpoint(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
): ):
"""创建任务""" """创建任务
return await task_service.create_task(db, data)
`mom_line_ids` 非空时,会在**同一事务**里把对应的 MOM 出库明细挂到新任务上,
所以不存在「任务建好了但物料没挂上」的中间态。
"""
return await task_service.create_task(
db, data, operator_id=current_user.get("username"),
)
# 注:原先这里有「任务挂载 MOM 出库物料」的三个端点
# GET/POST /tasks/{id}/outbound-materials、DELETE .../{material_id})。
# 物料已统一为**设备级**,这三个端点连同 task_service 里的实现一起删除 ——
# 挂载/查看/删除/报废一律走:
# GET/POST /products/{id}/outbound-materials
# DELETE /products/{id}/outbound-materials/{material_id}
# 保留任务级入口只会让「同一个东西两个地方」重新长出来。
@router.patch("/{task_id}", response_model=TaskResponse) @router.patch("/{task_id}", response_model=TaskResponse)
async def update_task_endpoint( async def update_task_endpoint(

View File

@ -19,9 +19,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings from app.core.config import settings
from app.core.database import get_db from app.core.database import get_db
from app.core.lifecycle import sync_product_status from app.core.lifecycle import sync_product_status
from app.core.time_utils import get_beijing_time
from app.models.product import Product from app.models.product import Product
from app.models.task import Task, TaskRecord from app.models.task import Task, TaskRecord
from app.models.task_log import TaskLog from app.models.task_log import TaskLog
from app.services import product_outbound_material_service
router = APIRouter(prefix="/external/webhooks", tags=["外部回调"]) router = APIRouter(prefix="/external/webhooks", tags=["外部回调"])
@ -259,6 +261,18 @@ async def mom_inbound_webhook(
if product.status != prev_status: if product.status != prev_status:
changed = True changed = True
# 4) 把最近一次出库单标记为已撤回。
# ⚠️ 只置位、**不删行** ——「出过又撤了」本身就是要看得见的历史(建表时的
# 取舍,见 models/product_outbound.py。产品详情会把撤回的单据照常画
# 出来并打「已撤回」,而不是让它凭空消失。
# ⚠️ 只标最近一条未撤回的:一批里同一台设备理论上不该出现两条未撤回的
# 出库单(出库后设备已不在仓库池,再出库匹配不到),但真出现时标错
# 一条也好过把历史全标脏。
if is_revoke:
# 「标哪一张单」的取舍写在服务层里(见 mark_revoked 的 docstring
if await product_outbound_material_service.mark_revoked(db, product.id):
changed = True
# ── 记录日志(优先"在库"任务,其次该产品最新任务;无任务则仅更新状态) ── # ── 记录日志(优先"在库"任务,其次该产品最新任务;无任务则仅更新状态) ──
log_task = await _pick_warehouse_log_task(db, product) log_task = await _pick_warehouse_log_task(db, product)
if log_task is not None: if log_task is not None:
@ -393,10 +407,19 @@ class MomOutboundPayload(BaseModel):
operator: str | None = None # 出库操作人(写入 task_logs.operator_id operator: str | None = None # 出库操作人(写入 task_logs.operator_id
outbound_time: datetime | None = None # 出库时间 outbound_time: datetime | None = None # 出库时间
company_name: str | None = None # 目标公司IRIS / LICAMOM 据此分流到不同 Track 实例 company_name: str | None = None # 目标公司IRIS / LICAMOM 据此分流到不同 Track 实例
# ↓ 2026-09 新增MOM 一直在发、此前被 Pydantic 静默丢弃。当前无人读取, # ↓ 2026-09 新增MOM 一直在发、此前被 Pydantic 静默丢弃。
# 先接住是为了与 LICA 实例(~/track-lica对同一载荷的解析结果保持一致 —— # 先接住是为了与 LICA 实例(~/track-lica对同一载荷的解析结果保持一致 ——
# 否则将来谁写了读这个字段的代码,会在 LICA 拿到值、在本实例拿到 None。 # 否则将来谁写了读这个字段的代码,会在 LICA 拿到值、在本实例拿到 None。
outbound_type: str | None = None # SALES / USE / PRODUCTION outbound_type: str | None = None # SALES / USE / PRODUCTION
# ↓ 2026-09 新增:单据上下文,落进 product_outbounds 供产品详情展示
# ⚠️ 不在这里声明的字段会被 Pydantic **静默丢弃**、且不报任何错 ——
# MOM 那边发了也等于没发。这是本功能最容易踩的坑company_name 当初
# 也是这么丢的)。
outbound_no: str | None = None # MOM 出库单号(批量出库多商品共用)
request_no: str | None = None # MOM 出库申请单号
consumer_name: str | None = None # 领用人/客户(自由填写,非可靠标识)
applicant_name: str | None = None # 申请人姓名MOM 侧解析后传来)
remark: str | None = None # 出库单备注
@router.post("/mom-outbound") @router.post("/mom-outbound")
@ -487,8 +510,34 @@ async def mom_outbound_webhook(
)) ))
changed = True changed = True
# ── 存档 MOM 单据 ──
# 一次出库一行(**明细级**,与人工挂载同一张表),产品详情据此回答
# 「这台设备对应 MOM 的哪张单、领了哪些料」。
#
# 幂等:服务层按 (product_id, outbound_no) 查重后再写MOM 的 notify_track
# 走守护线程且不重试,但同一条回调仍可能因运维手工重放而重入 —— 重复写入会
# 让产品详情出现两张一模一样的单据)。表上另有部分唯一索引兜底。
#
# ⚠️ outbound_no 为空则整段跳过(表里该列 NOT NULL。这是与旧版 MOM 的
# 向前兼容MOM 没升级时本来就不发这些字段,此时静默不存档,其余逻辑
# 照常 —— 不要因为缺字段就 4xx那会让 MOM 把正常出库当故障。
#
# ⚠️ 明细是靠 outbound_no 去 MOM **现查**的(回调载荷里没有明细)——
# 不查的话这台设备「领了哪些料」永远是空的,也就报不了废。
if payload.outbound_no:
if await product_outbound_material_service.archive_from_webhook(
db, product, payload,
):
changed = True
# ── 动态生成"扫码出库"主线任务节点 + 操作日志(流转树最底部长出出库节点) ── # ── 动态生成"扫码出库"主线任务节点 + 操作日志(流转树最底部长出出库节点) ──
if await _append_warehouse_task(db, product, "扫码出库", "通过 MOM 系统扫码出库完成"): # 单号拼进备注,不查子表也能在流转树里看出是哪张单出的库。
# ⚠️ 只动 remark**不要**往 task_name 里塞 —— task_name 会被直接写成
# product.overall_statusservices/task_service.py:435,782
outbound_note = f"(单号 {payload.outbound_no}" if payload.outbound_no else ""
if await _append_warehouse_task(
db, product, "扫码出库", f"通过 MOM 系统扫码出库完成{outbound_note}",
):
changed = True changed = True
if changed: if changed:

View File

@ -0,0 +1,157 @@
"""设备出库明细 — 一台设备对应 MOM 的**每一条**出库明细
一行 = 设备上的一条 MOM 出库明细(`trans_outbound` 的一行)。
═══ 为什么是这张表(合并了原先的两张)═══
本表合并了原来的 `product_outbounds`(产品 ↔ 出库**单**,单据级)与
`task_outbound_materials`(任务 ↔ 出库**明细**,明细级)。
它们本来就是**同一件事**——「这台设备对应 MOM 的哪些出库单、领了哪些料」——
却因为粒度不同被拆成两张表、界面上显示成两张卡,用户要面对两个入口、
两个删除按钮,还要猜「我刚才在那边挂的怎么这边看不见」。那是设计失误。
统一到**明细级**,因为只有明细级带 `mom_line_id`MOM `trans_outbound.id`
而报废必须靠它定位到具体哪一条出库明细。单据级的信息(申请单号、备注、撤回)
作为**冗余列**落在每一条明细上 —— 同单内必然一致,多存几份换取单表自包含。
═══ 两种来源source═══
· `manual` —— 人在界面上挂的(网页端/移动端选 MOM 出库单)
· `webhook` —— MOM 出库回调自动存档(按 SN 匹配到设备后写入)
两者语义不同、删除规则也不同(本表的 `manual` 可删;`webhook` 是系统事实,
要撤得去 MOM 撤回,由回调置 `is_revoked`),所以保留 `source` 区分。
═══ task_id 为什么可空 ═══
人工挂载时要选「挂到哪条任务」一线按任务领料webhook 存档则**不知道任务**。
但任务只是**溯源信息**,不再是组织维度 —— 展示、报废、删除一律按**设备**维度走。
"""
import uuid
from datetime import datetime
from decimal import Decimal
from sqlalchemy import (
Boolean, DateTime, ForeignKey, Index, Numeric, String, Text, text,
)
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 ProductOutboundMaterial(Base):
__tablename__ = "product_outbound_materials"
__table_args__ = (
# 同一条 MOM 出库明细不能在一台设备上出现两次(重复提交、前端重放、并发点击、
# webhook 重推)。**部分索引**mom_line_id 为空的行MOM 查不到明细的存档)
# 不参与 —— NULL 之间不相等,带上它等于给这类行开了后门。
Index("uq_pom_product_line", "product_id", "mom_line_id",
unique=True, postgresql_where=text("mom_line_id IS NOT NULL")),
# 没有明细行的存档:一张单在一台设备上只留一行
Index("uq_pom_product_no_noline", "product_id", "outbound_no",
unique=True, postgresql_where=text("mom_line_id IS NULL")),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
# ---- 物理外键:设备(组织维度,展示/报废/删除都按它走) ----
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对账)",
)
# ---- 物理外键:任务(可空,仅溯源用,不参与展示与权限) ----
task_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id"),
nullable=True, index=True,
comment="人工挂载时选的任务(可空,仅溯源用,不参与展示/报废/删除)",
)
# ---- 跨库逻辑外键MOM 库 trans_outbound.id无物理约束 ----
mom_line_id: Mapped[int | None] = mapped_column(
nullable=True, index=True,
comment="MOM 出库明细行ID(逻辑外键→MOM trans_outbound.id)。为空=MOM 查不到明细的单据存档",
)
outbound_no: Mapped[str] = mapped_column(
String(100), nullable=False, index=True,
comment="MOM 出库单号(批量出库多商品共用,故本表按明细行成行)",
)
# ---- 单据级信息(同单内一致,冗余在每条明细上换取单表自包含) ----
request_no: Mapped[str | None] = mapped_column(
String(100), nullable=True, comment="MOM 出库申请单号",
)
applicant_name: Mapped[str | None] = mapped_column(
String(100), nullable=True, comment="申请人姓名(MOM 侧解析后传来,不做 ID 反查)",
)
remark: Mapped[str | None] = mapped_column(
Text, nullable=True, comment="出库单备注",
)
# ---- 明细级快照(挂载/回调时从 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 补全时区)",
)
# ---- 来源 ----
# manual —— 人在界面上挂的(网页端/移动端选 MOM 出库单)→ **可删**
# webhook —— MOM 出库回调自动存档 → 系统事实,要撤得去 MOM 撤回,**不可删**
source: Mapped[str] = mapped_column(
String(16), nullable=False, default="manual", server_default="manual",
comment="来源: manual(人工挂载,可删) | webhook(MOM回调自动存档不可删)",
)
# ---- 撤回(只置位不删行)----
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="撤回时间",
)
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="本行写入时间",
)
# ---- 关系 ----
product: Mapped["Product"] = relationship("Product", lazy="selectin")
def __repr__(self) -> str:
return (f"<ProductOutboundMaterial {self.outbound_no} "
f"line={self.mom_line_id} sku={self.sku}>")

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from datetime import datetime from datetime import datetime
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, model_validator
class ProductCreate(BaseModel): class ProductCreate(BaseModel):
@ -18,6 +18,14 @@ class ProductCreate(BaseModel):
order_id: uuid.UUID | None = Field(None, description="所属订单ID(选填)") order_id: uuid.UUID | None = Field(None, description="所属订单ID(选填)")
order_no: str | None = Field(None, max_length=64, description="订单号(自由键入,选填)") order_no: str | None = Field(None, max_length=64, description="订单号(自由键入,选填)")
parent_product_id: uuid.UUID | None = Field(None, description="父产品ID") parent_product_id: uuid.UUID | None = Field(None, description="父产品ID")
# 建档时一并挂钩的 MOM 出库**明细行** IDtrans_outbound.id
# 前端按整张出库单勾选,提交时把该单全部明细 ID 带过来;后端归并回单据后
# 写进 product_outbounds一行 = 一张单source=manual
# ⚠️ 默认空列表:其它调用方(旧前端、脚本)不带该字段,必须保持行为不变。
mom_line_ids: list[int] = Field(
default_factory=list,
description="建档时挂钩的 MOM 出库明细行ID(trans_outbound.id)",
)
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@ -80,6 +88,139 @@ class ProductResponse(BaseModel):
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
class ProductOutboundResponse(BaseModel):
"""产品出库记录 — 来自 MOM 出库回调的单据存档
一次出库一行,按出库时间倒序返回。**已撤回的记录照常留在列表里**
is_revoked=True由前端打标记 —— 不在后端过滤掉,「出过又撤了」
本身就是要看得见的历史。
"""
id: uuid.UUID
outbound_no: str # MOM 出库单号
request_no: str | None = None # MOM 出库申请单号
consumer_name: str | None = None # 领用人/客户
applicant_name: str | None = None # 申请人姓名
operator: str | None = None # MOM 侧实际扫码出库人
outbound_type: str | None = None # SALES / USE / PRODUCTION只展示不做业务判断
outbound_time: datetime | None = None # MOM 记录的出库时间
remark: str | None = None
is_revoked: bool = False
revoked_at: datetime | None = None
# 这一行怎么来的webhook(MOM 回调自动存档) | manual(人工在界面挂的)
source: str = "webhook"
created_at: datetime # 本行写入时间
model_config = {"from_attributes": True}
class ProductOutboundMaterialResponse(BaseModel):
"""设备的一条 MOM 出库明细(合并后的统一形态)
一行 = 设备上的一条出库明细。`mom_line_id` 为空表示这条是**单据级存档**
MOM 回调时查不到明细),能看、能标撤回,但**不能报废** —— 报废要用它定位。
"""
id: int
product_id: uuid.UUID
serial_number: str | None = None
task_id: uuid.UUID | None = None # 仅溯源,不参与展示/报废/删除
mom_line_id: int | None = None # MOM trans_outbound.id为空=无明细的存档
outbound_no: str
# ---- 单据级(同单内一致,冗余在每条明细上) ----
request_no: str | None = None
applicant_name: str | None = None
remark: str | None = None
# ---- 明细级快照 ----
sku: str | None = None
material_name: str | None = None
spec_model: str | None = None
quantity: float | None = None # 出库单原值,**不是**本设备用量
unit_price: float | None = None
outbound_type: str | None = None
outbound_type_label: str = "" # 服务端下发的中文名
consumer_name: str | None = None # 领用人/客户
operator_name: str | None = None
warehouse_location: str | None = None
outbound_time: datetime | None = None
# ---- 来源与撤回 ----
source: str = "manual" # manual(可删) | webhook(系统事实,不可删)
is_revoked: bool = False
revoked_at: datetime | None = None
added_by: str | None = None # 谁挂上去的Track 用户名)
# 谁挂上去的(中文姓名)。由服务端解析下发 —— 前端不做 username→姓名映射
# 否则移动端/网页端各抄一份,迟早漂移。
added_by_name: str = ""
created_at: datetime
model_config = {"from_attributes": True}
@model_validator(mode="after")
def _fill_type_label(self):
"""出库类型码 → 中文名。统一在 schema 派生,避免各构造点漏填。"""
if not self.outbound_type_label and self.outbound_type:
from app.services.mom_outbound_service import describe_outbound_type
self.outbound_type_label = describe_outbound_type(self.outbound_type)
return self
class ProductScrapCreate(BaseModel):
"""提交生产报废的请求体。
⚠️ 只传 `mom_line_id`,物料快照一律由后端从 Track 已挂的出库物料里取 ——
不接受前端传快照,否则前端可以伪造「报废了什么」。
"""
# MOM trans_outbound.id也就是 Track 侧 task_outbound_materials.mom_line_id。
# 后端会校验它**确实挂在本产品上** —— 这是跨设备乱报的唯一防线
# (可见范围是整台设备、不是「谁领的」,所以不能靠隐藏来防)。
mom_line_id: int
# 本次报废数量。上限由 MOM 判(不能超过该出库明细的可退额度),
# 这里不重复校验,避免两处口径漂移。
quantity: float
# 用户填的原因说明(选填)
reason: str | None = None
# 幂等锚点:前端在**打开弹层时**生成一次,重试时复用同一个。
# 后端拼成 <公司>:<track_ref> 发给 MOM两边同一口径。
track_ref: str
class ProductScrapResponse(BaseModel):
"""生产报废记录 — Track 发起、MOM 受理的报废单。
`mom_status` / `total_loss` 是**实时回查 MOM** 的结果,不是本地快照
(本地那列只在 MOM 暂时查不到时兜底)。
· `mom_status_label`:「待审批 / 已通过(待执行)/ 已执行 / 已驳回 / 已撤回」
· `mom_executed=False` 时 `total_loss` 是 **None 而不是 0** ——
区分「还没执行」和「执行了但损失为 0」别让用户把未审批看成 0 元损失
"""
id: uuid.UUID
product_id: uuid.UUID
serial_number: str | None = None
task_id: uuid.UUID | None = None
mom_line_id: int
# 快照MOM 侧数据被清理后仍要能显示「报了什么」
outbound_no: str | None = None
material_name: str | None = None
spec_model: str | None = None
sku: str | None = None
consumer_name: str | None = None # 原领用人,前端据此提示「代报」
quantity: float
reason_category: str = "PRODUCTION"
reason: str | None = None
scrap_request_no: str
defective_goods_id: int | None = None
submitted_by: str | None = None
created_at: datetime
# ---- 以下为实时回查 MOM 的结果 ----
mom_status: int = 0
mom_status_label: str = ""
mom_approved_at: str | None = None
mom_executor_name: str = ""
mom_executed: bool = False
total_loss: float | None = None # 报废损失(单价 × 实报废数量)
scrapped_quantity: float | None = None
model_config = {"from_attributes": True}
class ProductScanResponse(BaseModel): class ProductScanResponse(BaseModel):
"""扫码查询响应 — 产品信息 + 完整任务树(递归嵌套)""" """扫码查询响应 — 产品信息 + 完整任务树(递归嵌套)"""
id: uuid.UUID id: uuid.UUID
@ -102,6 +243,9 @@ class ProductScanResponse(BaseModel):
top_level_tasks: list[TaskSummaryResponse] = [] top_level_tasks: list[TaskSummaryResponse] = []
task_tree: list[TaskResponse] = [] task_tree: list[TaskResponse] = []
assignee_names: dict[str, str] = {} # 🔧 username→中文姓名映射 assignee_names: dict[str, str] = {} # 🔧 username→中文姓名映射
# 🔧 出库单据存档(来自 MOM 出库回调),按出库时间倒序。
# 本次功能上线前出库的设备没有存档,这里是空列表 —— 不是错误。
outbound_records: list[ProductOutboundMaterialResponse] = []
model_config = {"from_attributes": True} model_config = {"from_attributes": True}

View File

@ -19,6 +19,15 @@ class TaskCreate(BaseModel):
notify_parent_on_complete: bool = Field(False, description="完成后是否通知父任务") notify_parent_on_complete: bool = Field(False, description="完成后是否通知父任务")
is_rework: bool = Field(False, description="是否为返工任务") is_rework: bool = Field(False, description="是否为返工任务")
remark: str | None = Field(None, max_length=2000, description="初始描述/交接备注") remark: str | None = Field(None, max_length=2000, description="初始描述/交接备注")
# 创建时一并挂载的 MOM 出库明细行 IDMOM trans_outbound.id
# 粒度是**明细行**,但前端是按整张出库单勾选的 —— 提交时把该单的全部明细
# ID 一起带过来。
# ⚠️ 默认空列表:移动端的 doCreateFirstTask 仍在调本接口且不带该字段,
# 必须保持「不传就等同于不挂载」的行为不变。
mom_line_ids: list[int] = Field(
default_factory=list,
description="创建时挂载的 MOM 出库明细行ID(trans_outbound.id)",
)
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@ -153,6 +162,55 @@ class TaskRecordResponse(BaseModel):
# 响应模型 # 响应模型
# ============================================================ # ============================================================
class TaskOutboundMaterialResponse(BaseModel):
"""任务挂载的一条 MOM 出库物料明细(挂载时从 MOM 取的快照)
一次挂载会展开成多行(挂一张出库单 = 该单的全部明细各一行),
前端按 outbound_no 分组展示。
"""
id: int
# ★ 料挂在哪条任务上。前端按任务分组展示时必须拿它做 key ——
# 不能用 task_name同一台设备可能有两个同名任务例如两道「生产」
# 按名字分会把它们并成一组,看起来像一条任务领了两遍料。
task_id: uuid.UUID
mom_line_id: int # MOM trans_outbound.id供反查比对
outbound_no: str # MOM 出库单号
sku: str | None = None
material_name: str | None = None
spec_model: str | None = None
# 用 float 而非 DecimalPydantic v2 会把 Decimal 序列化成字符串,
# 前端拿到 "5.0000" 不好直接用。数量量级很小(实测 1~186float 足够。
quantity: float | None = None # 出库单原值,**不是**本任务用量
unit_price: float | None = None
outbound_type: str | None = None
# 出库类型中文名。与 MOM 出库单查询mom_outbounds同一套码表、同一份实现
# 由服务端下发 —— 前端不再自建映射,否则两边会开始漂移。
# 这里用 model_validator 自动派生而不是每个构造点手填:构造点有 3 处
# task_service 两处 + product_service 一处),漏一个就是空白徽标。
outbound_type_label: str = ""
consumer_name: str | None = None # 领用人/客户
operator_name: str | None = None
warehouse_location: str | None = None
outbound_time: datetime | None = None
added_by: str | None = None # 挂载人逻辑外键→MOM sys_user
created_at: datetime
model_config = {"from_attributes": True}
@model_validator(mode="after")
def _fill_outbound_type_label(self):
"""出库类型码 → 中文名PRODUCTION→生产出库 等)。
在 schema 上统一派生,而不是让 3 个构造点各自记得填 ——
漏一个就是空白徽标,而且不会报错,只能靠肉眼发现。
延迟 importschemas 被 services 依赖,模块级 import 会形成环。
"""
if not self.outbound_type_label and self.outbound_type:
from app.services.mom_outbound_service import describe_outbound_type
self.outbound_type_label = describe_outbound_type(self.outbound_type)
return self
class TaskSummaryResponse(BaseModel): class TaskSummaryResponse(BaseModel):
"""任务摘要 — 扫码时用,不含嵌套子任务""" """任务摘要 — 扫码时用,不含嵌套子任务"""
id: uuid.UUID id: uuid.UUID
@ -195,6 +253,9 @@ class TaskResponse(BaseModel):
child_tasks: list[TaskResponse] = [] child_tasks: list[TaskResponse] = []
records: list[TaskRecordResponse] = [] records: list[TaskRecordResponse] = []
created_by: str | None = None # 谁创建的从task_logs追溯 created_by: str | None = None # 谁创建的从task_logs追溯
# 本任务挂载的 MOM 出库物料(明细级快照)。创建任务时可选、之后可追加,
# 见 models/task_outbound_material.py。
outbound_materials: list[TaskOutboundMaterialResponse] = []
model_config = {"from_attributes": True} model_config = {"from_attributes": True}

View File

@ -0,0 +1,356 @@
"""设备出库明细 — 业务逻辑层(合并后的唯一入口)
「这台设备对应 MOM 的哪些出库单、领了哪些料」在本模块只有一个概念、
一张表(`product_outbound_materials`)、一组函数。原先那两张表
`product_outbounds` 单据级 / `task_outbound_materials` 明细级)已停止写入,
只作回滚备份保留 —— 详见该模型的模块注释。
═══ 两条写入路径 ═══
· `link_outbound_lines` —— 人在界面上挂(网页端/移动端选 MOM 出库单)
→ source='manual',可删
· `archive_from_webhook` —— MOM 出库回调自动存档(按 SN 匹配到设备)
→ source='webhook',系统事实,不可删(要撤得去 MOM 撤回)
两条路径共用同一张表、同一套幂等约束,但**来源不同、删除规则不同** ——
这是它们唯一的差别,`source` 列把它记下来。
"""
import logging
import uuid
from fastapi.concurrency import run_in_threadpool
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.product import Product
from app.models.product_outbound_material import ProductOutboundMaterial
from app.schemas.product import ProductOutboundMaterialResponse
from app.services import mom_outbound_service
logger = logging.getLogger(__name__)
async def list_product_materials(
db: AsyncSession, product_id: uuid.UUID,
) -> list[ProductOutboundMaterialResponse]:
"""列出某设备挂载的全部出库明细。
排序:先按 MOM 出库时间倒序,没有时间的沉底,再按写入时间兜底 ——
避免 outbound_time 为空的行插在最前面。
"""
rows = (
await db.execute(
select(ProductOutboundMaterial)
.where(ProductOutboundMaterial.product_id == product_id)
.order_by(
ProductOutboundMaterial.outbound_time.desc().nullslast(),
ProductOutboundMaterial.created_at.desc(),
ProductOutboundMaterial.id.desc(),
)
)
).scalars().all()
items = [ProductOutboundMaterialResponse.model_validate(r) for r in rows]
fill_added_by_names(items)
return items
def fill_added_by_names(
items: list[ProductOutboundMaterialResponse],
) -> list[ProductOutboundMaterialResponse]:
"""就地补上 `added_by_name`(谁挂上去的,中文姓名),并**返回同一个列表**。
返回列表是为了能写成 `fill(x)` 直接当值用 —— 就地修改却返回 None 的话,
`fill(x) or []` 会静默变成空数组(写这行时就差点踩到)。
界面要显示「谁挂的」——`added_by` 存的是 Track 用户名(如 `zhangsan01`
直接摆出来现场看不懂。解析交给服务端:移动端与网页端各抄一份 username→姓名
的映射迟早会漂移,而且 MOM 的 username 是「姓名/账号」格式,规则不止一条。
⚠️ 解析失败**不能影响列表**MOM 连不上时姓名降级为空串,前端回落到显示
用户名。查一次是批量 SQLmom_cache 还带 2h TTL不给每行单独打库。
"""
from app.services.mom_cache import get_display_names
names = [i.added_by for i in items if i.added_by]
if not names:
return items
try:
mapping = get_display_names(list(dict.fromkeys(names)))
except Exception as e:
logger.warning(f"[OutboundMaterial] 解析挂载人姓名失败,降级显示用户名: {e}")
return items
for i in items:
if i.added_by:
i.added_by_name = mapping.get(i.added_by, "")
return items
async def link_outbound_lines(
db: AsyncSession, product: Product, mom_line_ids: list[int],
*, task_id: uuid.UUID | None = None, added_by: str | None = None,
) -> int:
"""把 MOM 出库**明细行**挂到设备上(人工路径)。返回实际新增行数。
用户勾的是**整张出库单**,提交时把该单全部明细行 id 一起带过来 ——
本表按明细行成行,所以一张单会展开成 N 行。
⚠️ 只接受 `mom_line_ids`,物料快照一律由后端拿 id 去 MOM 现查,
否则前端可以伪造「挂的是什么」。
⚠️ MOM 查询是同步 psycopg2用 run_in_threadpool 扔出去,别阻塞事件循环。
⚠️ 调用方负责 commit —— 本函数只 flush好让挂载与产品创建同事务。
幂等:已挂过的明细跳过(部分唯一索引兜底)。
"""
ids = list(dict.fromkeys(int(i) for i in mom_line_ids)) # 去重且保持顺序
if not ids:
return 0
lines = await run_in_threadpool(mom_outbound_service.get_lines_by_ids, ids)
if not lines:
# MOM 侧查不到(数据被清理 / ID 传错)—— 静默返回 0由调用方比对数量
return 0
existing = set(
(
await db.execute(
select(ProductOutboundMaterial.mom_line_id).where(
ProductOutboundMaterial.product_id == product.id,
ProductOutboundMaterial.mom_line_id.in_(
[ln["line_id"] for ln in lines]),
)
)
).scalars().all()
)
added = 0
for ln in lines:
if ln["line_id"] in existing:
continue
db.add(ProductOutboundMaterial(
product_id=product.id,
serial_number=product.serial_number,
task_id=task_id,
mom_line_id=ln["line_id"],
outbound_no=ln["outbound_no"],
request_no=ln.get("request_no") or None,
applicant_name=None, # 人工挂载拿不到申请人MOM 的 request_id 存量全为 NULL
remark=None,
sku=ln.get("sku") or None,
material_name=ln.get("material_name") or None,
spec_model=ln.get("spec_model") or None,
quantity=ln.get("quantity"),
unit_price=ln.get("unit_price"),
outbound_type=ln.get("outbound_type") or None,
consumer_name=ln.get("consumer_name") or None,
operator_name=ln.get("operator_name") or None,
warehouse_location=ln.get("warehouse_location") or None,
outbound_time=ln.get("outbound_time"),
source="manual",
added_by=added_by,
))
added += 1
await db.flush()
return added
async def archive_from_webhook(
db: AsyncSession, product: Product, payload, *, company_name: str | None = None,
) -> bool:
"""MOM 出库回调 → 存档到本表webhook 路径)。返回是否有新写入。
按 `payload.outbound_no` 去 MOM **现查明细**,逐行落 —— 本表是明细级,
只写一条单据级信息的话,这台设备上「领了什么料」就永远是空的。
⚠️ 查不到明细MOM 数据被清理、或该单确实没有可解析的明细)时,
退化成写**一行 `mom_line_id=NULL` 的存档**:宁可显示「有这张单但看不到明细」,
也不要静默丢掉这张单 —— 用户会以为出库记录丢了。
⚠️ 幂等已有存档就跳过webhook 可能因运维重放而重入)。
"""
outbound_no = (getattr(payload, "outbound_no", "") or "").strip()
if not outbound_no:
return False
# 已有该单的存档 → 不重复写。**按 outbound_no 判**(不是按明细行),
# 因为一张单的所有明细必然一起写入,任一行存在即整单已存过。
already = (
await db.execute(
select(ProductOutboundMaterial.id).where(
ProductOutboundMaterial.product_id == product.id,
ProductOutboundMaterial.outbound_no == outbound_no,
).limit(1)
)
).scalar_one_or_none()
if already is not None:
return False
lines = await run_in_threadpool(
mom_outbound_service.get_lines_by_outbound_no, outbound_no)
if not lines:
# 退化成单据级存档:能看、能标撤回,但没有明细行 id**不能报废**
db.add(ProductOutboundMaterial(
product_id=product.id,
serial_number=product.serial_number,
task_id=None,
mom_line_id=None,
outbound_no=outbound_no,
request_no=getattr(payload, "request_no", None) or None,
applicant_name=getattr(payload, "applicant_name", None) or None,
remark=getattr(payload, "remark", None) or None,
outbound_type=getattr(payload, "outbound_type", None) or None,
consumer_name=getattr(payload, "consumer_name", None) or None,
operator_name=(getattr(payload, "operator", None) or "")[:100] or None,
outbound_time=getattr(payload, "outbound_time", None),
source="webhook",
))
logger.info(f"[OutboundMaterial] {outbound_no} MOM 查不到明细,退化为单据级存档")
return True
for ln in lines:
db.add(ProductOutboundMaterial(
product_id=product.id,
serial_number=product.serial_number,
task_id=None,
mom_line_id=ln["line_id"],
outbound_no=outbound_no,
request_no=getattr(payload, "request_no", None) or None,
applicant_name=getattr(payload, "applicant_name", None) or None,
remark=getattr(payload, "remark", None) or None,
sku=ln.get("sku") or None,
material_name=ln.get("material_name") or None,
spec_model=ln.get("spec_model") or None,
quantity=ln.get("quantity"),
unit_price=ln.get("unit_price"),
outbound_type=(getattr(payload, "outbound_type", None)
or ln.get("outbound_type") or None),
consumer_name=(getattr(payload, "consumer_name", None)
or ln.get("consumer_name") or None),
operator_name=(getattr(payload, "operator", None)
or ln.get("operator_name") or None),
warehouse_location=ln.get("warehouse_location") or None,
outbound_time=(getattr(payload, "outbound_time", None)
or ln.get("outbound_time")),
source="webhook",
))
return True
async def mark_revoked(
db: AsyncSession, product_id: uuid.UUID, outbound_no: str | None = None,
) -> bool:
"""MOM 撤回回调 → 把该单在本设备上的存档标为已撤回。返回是否有改动。
**只置位不删行** ——「出过又撤了」本身就是要看得见的历史。
:param outbound_no: 指定单号则只标那一单;为空则标**最近一条未撤回的**。
MOM 的撤回载荷不保证带单号,而「最近一条未撤回的」是这批里最可能被撤的那张
—— 一批里同一台设备理论上不该出现两条未撤回的出库单(出库后设备已不在
仓库池,再出库匹配不到)。真出现时标错一条,也好过把历史全标脏。
"""
from app.core.time_utils import get_beijing_time
q = select(ProductOutboundMaterial).where(
ProductOutboundMaterial.product_id == product_id,
ProductOutboundMaterial.is_revoked.is_(False),
)
if outbound_no:
q = q.where(ProductOutboundMaterial.outbound_no == outbound_no)
rows = (await db.execute(q)).scalars().all()
else:
# 先定位到「哪一张单」,再把那张单的**全部明细行**一起标 ——
# 只标一行的会让同一张单呈现「一半撤回一半没撤」的鬼状态
latest = (
await db.execute(
q.order_by(
ProductOutboundMaterial.outbound_time.desc().nullslast(),
ProductOutboundMaterial.created_at.desc(),
).limit(1)
)
).scalars().first()
if latest is None:
return False
rows = (
await db.execute(
select(ProductOutboundMaterial).where(
ProductOutboundMaterial.product_id == product_id,
ProductOutboundMaterial.outbound_no == latest.outbound_no,
)
)
).scalars().all()
changed = False
for r in rows:
if not r.is_revoked:
r.is_revoked = True
r.revoked_at = get_beijing_time()
changed = True
return changed
async def remove_product_order(
db: AsyncSession, product_id: uuid.UUID, outbound_no: str,
) -> list[ProductOutboundMaterialResponse]:
"""整张出库单一起摘掉(挂错了要能撤)。
界面上更容易碰到的是「这一单整个挂错了」,逐条删要删好几下,所以补这个。
规则与逐条删**完全一致**:只要这张单在本设备上有一行是 `webhook`
MOM 回调自动存档的系统事实),整单就不给删 —— 要撤得去 MOM 撤回。
这样「整单删」不会成为绕过单行规则的后门。
"""
no = (outbound_no or "").strip()
if not no:
raise HTTPException(status_code=400, detail="出库单号不能为空")
rows = (
await db.execute(
select(ProductOutboundMaterial).where(
ProductOutboundMaterial.product_id == product_id,
ProductOutboundMaterial.outbound_no == no,
)
)
).scalars().all()
if not rows:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail="这张出库单没有挂在这台设备上")
if any(r.source != "manual" for r in rows):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="该出库单含 MOM 回调自动存档的记录,不能在 Track 里删除;如需撤销请在 MOM 中撤回",
)
for r in rows:
await db.delete(r)
await db.commit()
return await list_product_materials(db, product_id)
async def remove_product_material(
db: AsyncSession, product_id: uuid.UUID, material_id: int,
) -> list[ProductOutboundMaterialResponse]:
"""摘掉一条**人工挂载**的出库明细(挂错了要能撤)。
只允许删 `source='manual'`webhook 存档是 MOM 出库回调留下的系统事实,
删了 Track 与 MOM 就对不上MOM 那边单还在)。要撤该去 MOM 撤回,
由回调置 `is_revoked` 留痕。
"""
row = (
await db.execute(
select(ProductOutboundMaterial).where(
ProductOutboundMaterial.id == material_id,
ProductOutboundMaterial.product_id == product_id,
)
)
).scalars().first()
if row is None:
# 不属于这台设备的一律按「查不到」处理,不泄漏别的设备挂了什么
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail="这条出库明细没有挂在这台设备上")
if row.source != "manual":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="该出库单由 MOM 回调自动存档,不能在 Track 里删除;如需撤销请在 MOM 中撤回",
)
await db.delete(row)
await db.commit()
return await list_product_materials(db, product_id)

View File

@ -15,10 +15,23 @@ from app.core.lifecycle import (
sync_product_status, sync_product_status,
) )
from app.models.product import Product from app.models.product import Product
from app.models.product_outbound_material import ProductOutboundMaterial
from app.models.production_order import ProductionOrder from app.models.production_order import ProductionOrder
from app.models.task import Task from app.models.task import Task
from app.schemas.product import ProductCreate, ProductUpdate, ProductResponse, ProductScanResponse from app.schemas.product import (
from app.schemas.task import TaskSummaryResponse, TaskResponse, TaskRecordResponse ProductCreate,
ProductUpdate,
ProductOutboundMaterialResponse,
ProductResponse,
ProductScanResponse,
)
from app.schemas.task import (
TaskSummaryResponse,
TaskResponse,
TaskRecordResponse,
)
# 设备出库明细:扫码响应要附「谁挂上去的」中文名(见 fill_added_by_names
from app.services import product_outbound_material_service
def _task_to_response(task: Task) -> TaskResponse: def _task_to_response(task: Task) -> TaskResponse:
@ -51,6 +64,10 @@ def _task_to_response(task: Task) -> TaskResponse:
child_tasks=[_task_to_response(c) for c in task.child_tasks], child_tasks=[_task_to_response(c) for c in task.child_tasks],
records=[TaskRecordResponse.model_validate(r) for r in (task.records or [])], records=[TaskRecordResponse.model_validate(r) for r in (task.records or [])],
created_by=getattr(task, "created_by", None), created_by=getattr(task, "created_by", None),
# ⚠️ 这里**不再**带 outbound_materials物料已统一为**设备级**
# product_outbound_materials挂在任务上只会变成第二个数据源 ——
# 正是这次要消除的「同一件事两个地方」。设备出库明细看扫码响应的
# `outbound_records`(已改为读新表)。
) )
@ -272,6 +289,23 @@ async def get_product_by_serial(db: AsyncSession, serial_number: str) -> Product
# 🔧 中文名映射(负责人 + 创建人,供前端显示"谁转入在库"等) # 🔧 中文名映射(负责人 + 创建人,供前端显示"谁转入在库"等)
assignee_names = _lookup_display_names(list(assignee_ids)) assignee_names = _lookup_display_names(list(assignee_ids))
# 🔧 设备的 MOM 出库明细(统一后的唯一来源)—— 产品详情据此回答「这台设备
# 对应 MOM 的哪些出库单、领了哪些料」。撤回的记录照常返回、由前端打
# 「已撤回」标记,不在后端过滤掉:「出过又撤了」也是历史。
# 排序:先按 MOM 记录的出库时间,没有的(旧数据/字段缺失)沉底,再按写入
# 时间兜底 —— 避免 outbound_time 为空的行插在最前面。
outbound_rows = (
await db.execute(
select(ProductOutboundMaterial)
.where(ProductOutboundMaterial.product_id == product.id)
.order_by(
ProductOutboundMaterial.outbound_time.desc().nullslast(),
ProductOutboundMaterial.created_at.desc(),
ProductOutboundMaterial.id.desc(),
)
)
).scalars().all()
return ProductScanResponse( return ProductScanResponse(
id=product.id, id=product.id,
serial_number=product.serial_number, serial_number=product.serial_number,
@ -294,9 +328,40 @@ async def get_product_by_serial(db: AsyncSession, serial_number: str) -> Product
], ],
task_tree=task_tree, task_tree=task_tree,
assignee_names=assignee_names, # 🔧 username→中文姓名 assignee_names=assignee_names, # 🔧 username→中文姓名
# ⚠️ Product 模型没有 to_dict(),本响应是逐字段手工构造的 —— 漏赋值不会
# 报错,只会永远返回默认值(空列表)。
# 附「谁挂上去的」中文名服务端解析两端共用MOM 挂了就降级显示用户名)
outbound_records=product_outbound_material_service.fill_added_by_names(
[ProductOutboundMaterialResponse.model_validate(r) for r in outbound_rows]
),
) )
async def _link_mom_outbound_orders(
db: AsyncSession, product: Product, mom_line_ids: list[int],
) -> int:
"""建产品时勾选的 MOM 出库明细 —— 转交给统一后的设备出库明细服务。
合并后只有一张表(`product_outbound_materials`)、一套挂载逻辑。
这里保留薄封装是因为「建产品时勾选」这条路仍然是产品发起的:
存储、幂等、快照一律由那个服务负责 —— 不再有第二份实现。
返回实际新增行数。
"""
from app.services import product_outbound_material_service
return await product_outbound_material_service.link_outbound_lines(
db, product, mom_line_ids,
)
# 注:原先这里还有 get_product_materials / get_product_outbound_orders /
# add_product_outbound_orders / remove_product_outbound_order 四个函数 ——
# 它们服务的是「单据级」的 product_outbounds 与「任务级」的
# task_outbound_materials。两张表已统一到 product_outbound_materials
# 读写一律走 product_outbound_material_service故一并删除。
# 旧表与其数据仍在库里(只停写),需要时可按迁移的 downgrade 回滚。
async def get_product(db: AsyncSession, product_id: uuid.UUID) -> Product: async def get_product(db: AsyncSession, product_id: uuid.UUID) -> Product:
"""获取产品,不存在则 404""" """获取产品,不存在则 404"""
result = await db.execute( result = await db.execute(
@ -349,6 +414,10 @@ async def create_product(db: AsyncSession, data: ProductCreate, creator_username
current_location_id=creator_username or None, # 谁创建,初始位置就是谁 current_location_id=creator_username or None, # 谁创建,初始位置就是谁
) )
db.add(product) db.add(product)
# 挂钩建档时选中的 MOM 出库单 —— 与产品**同事务**:产品建失败时不会留下
# 孤立的挂载行。此前 commit 一次就够,现在多这一步在 commit 之前。
if data.mom_line_ids:
await _link_mom_outbound_orders(db, product, data.mom_line_ids)
await db.commit() await db.commit()
await db.refresh(product, ["order"]) await db.refresh(product, ["order"])

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import json import json
import uuid import uuid
from fastapi import HTTPException, status from fastapi import HTTPException, status
from fastapi.concurrency import run_in_threadpool
from sqlalchemy import select, delete, func from sqlalchemy import select, delete, func
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@ -38,7 +39,6 @@ from app.schemas.task import (
TaskSummaryResponse, TaskSummaryResponse,
TaskListResponse, TaskListResponse,
) )
# 特殊位置常量 # 特殊位置常量
VIRTUAL_WAREHOUSE = "virtual_warehouse" VIRTUAL_WAREHOUSE = "virtual_warehouse"
@ -332,6 +332,10 @@ def _to_response(task: Task) -> TaskResponse:
created_at=task.created_at, created_at=task.created_at,
child_tasks=[_to_response(c) for c in task.child_tasks], child_tasks=[_to_response(c) for c in task.child_tasks],
records=[TaskRecordResponse.model_validate(r) for r in (task.records or [])], records=[TaskRecordResponse.model_validate(r) for r in (task.records or [])],
# ⚠️ 这里**不再**带 outbound_materials物料已统一为**设备级**
# product_outbound_materials挂在任务上只会变成第二个数据源 ——
# 正是这次要消除的「同一件事两个地方」。设备出库明细走
# GET /products/{id}/outbound-materials扫码响应里也有
) )
@ -409,9 +413,50 @@ async def get_top_level_tasks(db: AsyncSession, product_id: uuid.UUID) -> list[T
return [TaskSummaryResponse.model_validate(t) for t in tasks] return [TaskSummaryResponse.model_validate(t) for t in tasks]
async def create_task(db: AsyncSession, data: TaskCreate) -> TaskResponse: # ============================================================
"""创建任务,并同步产品宏观状态""" # 任务挂载 MOM 出库物料
task = Task(**data.model_dump()) # ============================================================
async def _mount_outbound_lines(
db: AsyncSession,
task: Task,
mom_line_ids: list[int],
operator_id: str | None,
) -> int:
"""建任务时勾选的 MOM 出库明细 —— 转交给统一后的设备出库明细服务。
合并后物料不再是「任务的」而是「**设备的**」,只有一张表
`product_outbound_materials`)、一套挂载逻辑。这里保留薄封装是因为
「建任务时勾选」这条路仍然是任务发起的:任务 id 作为**溯源信息**传下去
(这条料挂在哪条任务上),而存储、幂等、快照一律由那个服务负责 ——
不再有第二份实现。
返回实际新增行数。
"""
from app.models.product import Product
from app.services import product_outbound_material_service
product = await db.get(Product, task.product_id)
if product is None:
# 任务必然有产品(外键约束),走到这里说明数据被绕过改过。
# 静默跳过:不能因为挂料失败而让整个建任务事务炸掉
logger.warning(f"[Task] 任务 {task.id} 的产品不存在,跳过出库明细挂载")
return 0
return await product_outbound_material_service.link_outbound_lines(
db, product, mom_line_ids, task_id=task.id, added_by=operator_id,
)
async def create_task(
db: AsyncSession, data: TaskCreate, operator_id: str | None = None,
) -> TaskResponse:
"""创建任务,并同步产品宏观状态
operator_id: 创建人。用于记录是谁把出库物料挂上来的。
"""
# ⚠️ 必须 exclude 掉 mom_line_ids它不是 Task 的列,展开进去会直接
# TypeError'mom_line_ids' is an invalid keyword argument for Task
task = Task(**data.model_dump(exclude={"mom_line_ids"}))
db.add(task) db.add(task)
# 同步产品宏观状态 + 当前位置 # 同步产品宏观状态 + 当前位置
@ -438,11 +483,22 @@ async def create_task(db: AsyncSession, data: TaskCreate) -> TaskResponse:
if data.assignee_id and data.assignee_id != VIRTUAL_WAREHOUSE: if data.assignee_id and data.assignee_id != VIRTUAL_WAREHOUSE:
product.current_location_id = data.assignee_id product.current_location_id = data.assignee_id
# 挂载出库物料 —— 放在所有校验之后、commit 之前,与任务**同事务**
# 校验失败时不会留下「任务没建成、物料却挂上了」的残留。
if data.mom_line_ids:
await _mount_outbound_lines(db, task, data.mom_line_ids, operator_id)
await db.commit() await db.commit()
await db.refresh(task) await db.refresh(task)
return _to_response(task) return _to_response(task)
# 注:原先这里有 add_task_outbound_materials / remove_task_outbound_material
# (任务级增删出库物料)。物料统一为**设备级**后已删除 —— 挂载/删除一律走
# product_outbound_material_service界面上也只有「设备」一个维度
# 不再提供任务级的第二套读写入口。
async def update_task(db: AsyncSession, task_id: uuid.UUID, data: TaskUpdate) -> TaskResponse: async def update_task(db: AsyncSession, task_id: uuid.UUID, data: TaskUpdate) -> TaskResponse:
"""更新任务""" """更新任务"""
task = await _get_task_or_404(db, task_id) task = await _get_task_or_404(db, task_id)

View File

@ -96,6 +96,10 @@ async def load_task_tree_by_root(
noload(Task.parent_task), # 组装树不需要 parent 引用 noload(Task.parent_task), # 组装树不需要 parent 引用
selectinload(Task.records), # 🔥 一次性预加载所有进度记录 selectinload(Task.records), # 🔥 一次性预加载所有进度记录
selectinload(Task.product), # 🔥 一次性预加载产品引用 selectinload(Task.product), # 🔥 一次性预加载产品引用
# 🔥 预加载挂载的出库物料:扫码响应要带它(移动端「领用物料」靠它渲染)。
# ★ 必须在这里预加载,不能等 _task_to_response 里现取 ——
# 异步 session 下懒加载会抛 MissingGreenlet本仓踩过的坑
selectinload(Task.outbound_materials),
) )
.where(Task.id.in_(select(task_tree_cte.c.id))) .where(Task.id.in_(select(task_tree_cte.c.id)))
) )
@ -157,6 +161,8 @@ async def load_task_trees_by_product(
noload(Task.parent_task), noload(Task.parent_task),
selectinload(Task.records), selectinload(Task.records),
selectinload(Task.product), selectinload(Task.product),
# 同上:扫码响应要带挂载的出库物料,必须预加载(懒加载会 MissingGreenlet
selectinload(Task.outbound_materials),
) )
.where(Task.id.in_(select(task_tree_cte.c.id))) .where(Task.id.in_(select(task_tree_cte.c.id)))
) )

View File

@ -0,0 +1,486 @@
/** 出库单据卡 — 这台设备对应 MOM 的哪些出库单、领了哪些料
*
* ⚠️ 这里曾经是**两张卡**:一张「出库单据」(读 product_outbounds单据级
* 加一张「领用物料」(读 task_outbound_materials明细级。它们本来就是
* 同一件事,拆成两张的后果是:用户要面对两个入口、两个删除按钮,还会问
* 「我在那边挂的怎么这边看不见」;更糟的是**单据级那张没有 mom_line_id
* 挂上去的料报不了废**。
* 后端已把两张表合并成 product_outbound_materials本组件随之合并成一张。
* 不要因为「单据」和「物料」听起来不同就再拆开 —— 它们是同一件事。
*
* 展示形态(与移动端 pages/material/index.vue 保持一致):
* 按**出库单号**分组,一行一张单,点开看明细。不按任务分组 ——
* 任务名当分组抬头在现场看不懂,而且任务只是溯源信息。
*
* 已撤回的记录**照常显示**(灰底 + 删除线 + 「已撤回」),因为「出过又撤了」
* 本身就是要看得见的历史 —— 后端也不过滤掉。
*/
import { useCallback, useEffect, useState } from "react";
import { Modal } from "antd";
import { ChevronDown, ChevronRight, Loader2, Package, Plus, Trash2, Truck, Undo2 } from "lucide-react";
import MomOutboundPicker from "../admin/MomOutboundPicker";
import { useToast } from "../ui/Toast";
import { useAuth } from "../../contexts/AuthContext";
import {
getProductOutboundMaterials,
listProductScraps,
mountProductOutboundMaterials,
removeProductOutboundMaterial,
removeProductOutboundOrder,
submitProductScrap,
} from "../../services/productApi";
import { extractErrorMessage } from "../../utils/errorMessage";
import type { ProductOutboundMaterial, ProductScrap } from "../../types/api";
interface OutboundRecordsCardProps {
productId: string;
}
/** 时间 → 本地可读格式。MOM 的 outbound_time 缺失时回退到本行写入时间 */
function fmtTime(iso: string | null, fallback: string): string {
const d = new Date(iso || fallback);
if (Number.isNaN(d.getTime())) return "—";
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
/** 幂等锚点:**打开弹窗时生成一次**,重试复用 —— 换新的会在 MOM 里多报一张单 */
function makeTrackRef(): string {
const d = new Date();
const p = (n: number) => String(n).padStart(2, "0");
const ts = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
return `SCRAP-${ts}-${Math.random().toString(36).slice(2, 8)}`;
}
/** 按出库单号分组,保持后端给的顺序(已按出库时间倒序) */
function groupByOrder(list: ProductOutboundMaterial[]) {
const map = new Map<string, ProductOutboundMaterial[]>();
for (const m of list) {
const no = m.outbound_no || "(无单号)";
if (!map.has(no)) map.set(no, []);
map.get(no)!.push(m);
}
return [...map.entries()];
}
export default function OutboundRecordsCard({ productId }: OutboundRecordsCardProps) {
const { toast } = useToast();
const { user } = useAuth();
const [records, setRecords] = useState<ProductOutboundMaterial[] | null>(null);
const [scraps, setScraps] = useState<ProductScrap[]>([]);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
// 挂载不需要先选任务:任务只是溯源信息,展示/报废/删除都按设备走。
// 「谁挂上去的」记在 added_by、「谁出库的」由 MOM 快照带过来 —— 够了。
const [pickerOpen, setPickerOpen] = useState(false);
const [appending, setAppending] = useState(false);
const [removeTarget, setRemoveTarget] = useState<ProductOutboundMaterial | null>(null);
const [removing, setRemoving] = useState(false);
// 整单删除:界面上更容易碰到的是「这一单整个挂错了」,逐条删要删好几下
const [removeOrder, setRemoveOrder] = useState<string | null>(null);
const [scrapTarget, setScrapTarget] = useState<ProductOutboundMaterial | null>(null);
const [scrapForm, setScrapForm] = useState({ quantity: "", reason: "", confirmed: false, trackRef: "" });
const [scrapSubmitting, setScrapSubmitting] = useState(false);
const load = useCallback(async () => {
try {
const [mats, sc] = await Promise.all([
getProductOutboundMaterials(productId),
listProductScraps(productId).catch(() => [] as ProductScrap[]),
]);
setRecords(mats);
setScraps(sc);
} catch (err) {
toast(extractErrorMessage(err, "加载出库单据失败"), "error");
setRecords([]); // 失败也要脱离加载态,否则一直转圈
}
}, [productId, toast]);
useEffect(() => { load(); }, [load]);
const list = records ?? [];
async function handleAppend(momLineIds: number[]) {
if (momLineIds.length === 0) {
setPickerOpen(false);
return;
}
setAppending(true);
try {
// 接口返回该设备当前**全部**出库明细,直接整体覆盖。
// 不传 task_id —— 挂载不需要挂在某条任务上(任务只是溯源,可空)。
setRecords(await mountProductOutboundMaterials(productId, momLineIds));
toast("已添加出库单", "success");
setPickerOpen(false);
} catch (err) {
toast(extractErrorMessage(err, "添加出库单失败"), "error");
} finally {
setAppending(false);
}
}
async function doRemoveOrder() {
if (!removeOrder) return;
setRemoving(true);
try {
setRecords(await removeProductOutboundOrder(productId, removeOrder));
toast("已删除整张出库单", "success");
setRemoveOrder(null);
} catch (err) {
toast(extractErrorMessage(err, "删除失败"), "error");
} finally {
setRemoving(false);
}
}
async function doRemove() {
if (!removeTarget) return;
setRemoving(true);
try {
setRecords(await removeProductOutboundMaterial(productId, removeTarget.id));
toast("已删除", "success");
setRemoveTarget(null);
} catch (err) {
toast(extractErrorMessage(err, "删除失败"), "error");
} finally {
setRemoving(false);
}
}
// ---- 报废 ----
const myName = (user?.display_name ?? "").trim();
const openScrap = (m: ProductOutboundMaterial) => {
setScrapTarget(m);
setScrapForm({
quantity: String(m.quantity ?? ""),
reason: "",
confirmed: false,
trackRef: makeTrackRef(), // 打开时生成一次,重试复用
});
};
// 代报判据:这条料的领用人不是当前登录人。
// ⚠️ 这是**防误操作**不是权限 —— 料的归属是设备不是人,后端不会因此拒绝
const isProxy = !!scrapTarget?.consumer_name && !!myName
&& scrapTarget.consumer_name !== myName;
const doScrap = async () => {
const m = scrapTarget;
if (!m || m.mom_line_id == null) return;
const qty = Number(scrapForm.quantity);
if (!qty || qty <= 0) return toast("请填写报废数量", "error");
if (m.quantity != null && qty > m.quantity) return toast(`不能超过 ${m.quantity}`, "error");
if (isProxy && !scrapForm.confirmed) return toast("请先勾选确认代报", "error");
setScrapSubmitting(true);
try {
await submitProductScrap(productId, {
mom_line_id: m.mom_line_id,
quantity: qty,
reason: scrapForm.reason.trim() || null,
track_ref: scrapForm.trackRef,
});
toast("已提交,待主管审批", "success");
setScrapTarget(null);
await load();
} catch (err) {
// 不关弹窗、不换 trackRef改完数量重试走的是同一个幂等键
toast(extractErrorMessage(err, "报废提交失败"), "error");
} finally {
setScrapSubmitting(false);
}
};
const groups = groupByOrder(list);
const toggle = (no: string) => setExpanded((prev) => {
const next = new Set(prev);
if (next.has(no)) next.delete(no); else next.add(no);
return next;
});
// 直接开选择器 —— 不再先问「挂到哪条任务」。
// 「谁挂上去的」记在 added_by、「谁出库的」由 MOM 快照带过来,已经够了。
const openPicker = () => setPickerOpen(true);
return (
<>
<div className="rounded-xl bg-white p-4 shadow-sm">
<div className="mb-3 flex items-center gap-2">
<Truck className="h-5 w-5 text-blue-600" />
<h3 className="font-semibold text-gray-800"></h3>
{list.length > 0 && (
<span className="text-xs text-gray-400">{groups.length} / {list.length} </span>
)}
<button
onClick={openPicker}
className="ml-auto flex items-center gap-1 rounded-lg border border-blue-200 px-2.5 py-1 text-xs font-medium text-blue-600 transition-colors hover:bg-blue-50"
>
<Plus className="h-3 w-3" />
</button>
</div>
{records === null ? (
<p className="flex items-center justify-center gap-2 py-4 text-xs text-gray-400">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
</p>
) : list.length === 0 ? (
<p className="py-4 text-center text-xs text-gray-400">
线
</p>
) : (
<div className="space-y-2">
{groups.map(([no, items]) => {
const open = expanded.has(no);
const revoked = items[0]?.is_revoked;
return (
<div key={no} className={`overflow-hidden rounded-lg border ${revoked ? "border-gray-200 bg-gray-50" : "border-gray-100"}`}>
<div onClick={() => toggle(no)} className="cursor-pointer px-2.5 py-2 transition-colors hover:bg-gray-100">
<div className="flex items-center gap-2">
<span className={`font-mono text-sm font-medium break-all ${revoked ? "text-gray-400 line-through" : "text-gray-800"}`}>
{no}
</span>
{/* 撤回标记:与后端「撤回只置位不删行」一致,不让它凭空消失 */}
{revoked && (
<span className="flex shrink-0 items-center gap-0.5 rounded-full bg-gray-200 px-2 py-0.5 text-[10px] font-bold text-gray-600">
<Undo2 className="h-3 w-3" />
</span>
)}
{/* 不展示出库类型(用途)—— 现场只关心「这台设备挂了哪张单、
谁挂的、谁出的库」,多一个「内部领用」徽标只是噪音 */}
<span className="ml-auto shrink-0 text-xs text-gray-400">
{fmtTime(items[0]?.outbound_time ?? null, items[0]?.created_at ?? "")}
</span>
{/* 整单删除:挂错了要能一次摘掉。只在**全部**是人工挂的时显示 ——
含 MOM 回调存档的单不给删(后端也拦),那是系统事实 */}
{items.every((m) => m.source === "manual") && (
<button
onClick={(e) => { e.stopPropagation(); setRemoveOrder(no); }}
title="删除整张出库单"
className="shrink-0 rounded p-1 text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
<div className="mt-1 flex items-center gap-3.5 text-xs text-gray-400">
{items[0]?.consumer_name && <span> {items[0].consumer_name}</span>}
{items[0]?.operator_name && <span> {items[0].operator_name}</span>}
{/* 谁挂上去的 —— 现场要能追责/问人;只记在库里不显示等于没记 */}
{(items[0]?.added_by_name || items[0]?.added_by) && (
<span className="text-gray-500">
{items[0].added_by_name || items[0].added_by}
</span>
)}
<span>{items.length} </span>
<span className="ml-auto flex items-center gap-0.5 text-blue-600">
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
{open ? "收起明细" : "物料明细"}
</span>
</div>
{items[0]?.remark && <p className="mt-1 text-xs text-gray-400">{items[0].remark}</p>}
</div>
{open && (
<div className="divide-y divide-gray-50 border-t border-gray-100 px-2.5">
{items.map((m) => {
// 没有明细行 id = MOM 回调只存了单据、查不到明细 → 报不了废
const canScrap = m.mom_line_id != null;
return (
<div key={m.id} className="flex items-center gap-2 py-2">
<div className="min-w-0 flex-1">
<div className="text-[13px] font-medium text-gray-800">
{m.material_name || (canScrap ? "(未命名物料)" : "MOM 出库回调存档")}
</div>
<div className="mt-0.5 text-xs text-gray-400">
{m.spec_model && <span>{m.spec_model} · </span>}
{canScrap ? `×${m.quantity}` : "无明细"}
{m.warehouse_location && <span> · {m.warehouse_location}</span>}
</div>
</div>
{/* 只有带 mom_line_id 的才能报废 —— MOM 要用它定位到具体明细行 */}
{canScrap && (
<button
onClick={() => openScrap(m)}
className="shrink-0 rounded-lg border border-red-200 bg-red-50 px-2.5 py-1 text-xs font-medium text-red-600 transition-colors hover:bg-red-100"
>
</button>
)}
{/* 只有人工挂的可删webhook 存档是系统事实,要撤得去 MOM 撤回 */}
{m.source === "manual" && (
<button
onClick={() => setRemoveTarget(m)}
title="删除这条出库明细(挂错了)"
className="shrink-0 rounded-lg border border-gray-200 p-1.5 text-gray-400 transition-colors hover:border-gray-300 hover:bg-gray-50 hover:text-gray-600"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
{/* ♻️ 报废记录:状态与金额由后端实时回查 MOM */}
{scraps.length > 0 && (
<div className="mt-3 border-t border-gray-100 pt-3">
<p className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-gray-600">
<Package className="h-3.5 w-3.5" />{scraps.length}
</p>
<div className="space-y-1.5">
{scraps.map((s) => (
<div key={s.id} className="rounded-lg border border-gray-100 px-2.5 py-2">
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-gray-800">
{s.material_name || "(未命名物料)"}
</span>
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold ${
s.mom_executed ? "bg-emerald-100 text-emerald-700"
: (s.mom_status === 2 || s.mom_status === 4) ? "bg-gray-200 text-gray-600"
: "bg-amber-100 text-amber-700"
}`}>
{s.mom_status_label || "状态未知"}
</span>
<span className="shrink-0 text-xs text-gray-400">×{s.quantity}</span>
</div>
<div className="mt-1 flex flex-wrap gap-x-3.5 text-xs text-gray-400">
<span> {s.scrap_request_no}</span>
{s.submitted_by && <span> {s.submitted_by}</span>}
{/* ★ 只有执行过才有金额。未执行显示「—」不显示 0 ——
0 会让人以为「这东西不值钱」,其实是「还没扫码执行」 */}
{s.mom_executed && (
<span className="text-gray-600"> ¥{Number(s.total_loss ?? 0).toFixed(2)}</span>
)}
</div>
{s.reason && <p className="mt-1 text-xs text-gray-400">{s.reason}</p>}
</div>
))}
</div>
</div>
)}
</div>
{/* 选择器:已挂过的单据会在里面显示「已挂载」且不可再选。
提交期间选择器保持打开(按钮转圈),成功后由 handleAppend 关闭 ——
比先关弹窗再等结果更不容易让人以为没生效。 */}
<MomOutboundPicker
open={pickerOpen}
onClose={() => setPickerOpen(false)}
onConfirm={(ids) => handleAppend(ids)}
submitting={appending}
existingOrderNos={list.map((m) => m.outbound_no)}
/>
{/* 整单删除确认 */}
<Modal
open={!!removeOrder}
title="删除整张出库单"
centered
onCancel={() => !removing && setRemoveOrder(null)}
onOk={doRemoveOrder}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true, loading: removing }}
cancelButtonProps={{ disabled: removing }}
>
<p className="text-sm text-gray-700">
<span className="font-mono font-medium">{removeOrder}</span>
</p>
<p className="mt-2 text-xs text-gray-500">
Track <span className="font-medium"> MOM </span>
</p>
</Modal>
{/* 报废:数量 + 说明。分类不让人选 —— 走这条路的料按定义就是生产损耗 */}
<Modal
open={!!scrapTarget}
title="报废"
centered
onCancel={() => !scrapSubmitting && setScrapTarget(null)}
onOk={doScrap}
okText="提交报废"
cancelText="取消"
confirmLoading={scrapSubmitting}
okButtonProps={{ disabled: isProxy && !scrapForm.confirmed }}
>
<p className="text-sm text-gray-700">
{scrapTarget?.material_name || "(未命名物料)"}
<span className="ml-2 text-xs text-gray-400">
{scrapTarget?.spec_model} {scrapTarget?.consumer_name || "—"}
</span>
</p>
<div className="mt-3">
<label className="mb-1 block text-xs font-medium text-gray-600">
<span className="text-red-500">*</span>
</label>
<input
value={scrapForm.quantity}
onChange={(e) => setScrapForm((f) => ({ ...f, quantity: e.target.value }))}
placeholder={`最多 ${scrapTarget?.quantity ?? ""}`}
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-blue-400 focus:outline-none"
disabled={scrapSubmitting}
/>
</div>
<div className="mt-3">
<label className="mb-1 block text-xs font-medium text-gray-600"></label>
<textarea
value={scrapForm.reason}
onChange={(e) => setScrapForm((f) => ({ ...f, reason: e.target.value }))}
placeholder="例如:测试时跌落,外壳磕裂"
rows={3}
maxLength={200}
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-blue-400 focus:outline-none"
disabled={scrapSubmitting}
/>
</div>
{/* 代报确认:报的不是自己领的料时多一道(防误操作,不是权限) */}
{isProxy && (
<label className="mt-3 flex cursor-pointer items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2">
<input
type="checkbox"
checked={scrapForm.confirmed}
onChange={(e) => setScrapForm((f) => ({ ...f, confirmed: e.target.checked }))}
className="mt-0.5 accent-amber-600"
/>
<span className="text-xs text-amber-800">
{scrapTarget?.consumer_name}
</span>
</label>
)}
</Modal>
{/* 删除确认 */}
<Modal
open={!!removeTarget}
title="删除出库明细"
centered
onCancel={() => !removing && setRemoveTarget(null)}
onOk={doRemove}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true, loading: removing }}
cancelButtonProps={{ disabled: removing }}
>
<p className="text-sm text-gray-700">
{removeTarget?.material_name || "此物料"}
</p>
<p className="mt-2 text-xs text-gray-500">
Track <span className="font-medium"> MOM </span>
</p>
</Modal>
</>
);
}

View File

@ -8,6 +8,7 @@ import {
import api from "../../services/api"; import api from "../../services/api";
import type { ProductResponse } from "../../types/admin"; import type { ProductResponse } from "../../types/admin";
import CreateProductDialog from "./CreateProductDialog"; import CreateProductDialog from "./CreateProductDialog";
import OutboundRecordsCard from "../../components/scan/OutboundRecordsCard";
import { import {
getLabelPreview, executePrint, getLabelPreview, executePrint,
} from "../../services/printApi"; } from "../../services/printApi";
@ -353,13 +354,24 @@ export default function AdminProductsPage() {
{editTarget && ( {editTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => !editSaving && setEditTarget(null)} /> <div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => !editSaving && setEditTarget(null)} />
<div className="relative z-10 mx-4 w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl"> {/* 放宽到 max-w-lg 并限高下面要嵌「出库单据」卡max-w-sm 装不下 */}
<div className="relative z-10 mx-4 max-h-[85vh] w-full max-w-lg overflow-y-auto rounded-xl bg-white p-6 shadow-2xl">
<div className="mb-5 flex items-center justify-between"><h3 className="text-base font-bold text-gray-800"></h3><button onClick={() => setEditTarget(null)} disabled={editSaving} className="rounded-lg p-1 text-gray-400 hover:bg-gray-100"><X className="h-4 w-4" /></button></div> <div className="mb-5 flex items-center justify-between"><h3 className="text-base font-bold text-gray-800"></h3><button onClick={() => setEditTarget(null)} disabled={editSaving} className="rounded-lg p-1 text-gray-400 hover:bg-gray-100"><X className="h-4 w-4" /></button></div>
<p className="mb-4 font-mono text-sm text-gray-500">ID: {editTarget.serial_number}</p> <p className="mb-4 font-mono text-sm text-gray-500">ID: {editTarget.serial_number}</p>
<div className="space-y-4"> <div className="space-y-4">
<div><label className="mb-1 block text-sm font-medium text-gray-700"></label><input value={editOrderNo} onChange={e => setEditOrderNo(e.target.value)} className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:border-blue-400 focus:outline-none" /></div> <div><label className="mb-1 block text-sm font-medium text-gray-700"></label><input value={editOrderNo} onChange={e => setEditOrderNo(e.target.value)} className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:border-blue-400 focus:outline-none" /></div>
<div><label className="mb-1 block text-sm font-medium text-gray-700"></label><input value={editExternalSerial} onChange={e => setEditExternalSerial(e.target.value)} className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:border-blue-400 focus:outline-none" /></div> <div><label className="mb-1 block text-sm font-medium text-gray-700"></label><input value={editExternalSerial} onChange={e => setEditExternalSerial(e.target.value)} className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:border-blue-400 focus:outline-none" /></div>
</div> </div>
{/* 出库单据 —— **只有这一张卡**。
它合并了原先的「出库单据」与「领用物料」两张:那两者本来就是
同一件事(这台设备对应 MOM 的哪些单、领了哪些料),拆开只会让人
对着两个入口两个删除按钮发懵。卡内自带「添加出库单」入口,
展开明细可报废/删除。 */}
<div className="mt-5">
<OutboundRecordsCard productId={editTarget.id} />
</div>
<div className="mt-6 flex justify-end gap-2"><button onClick={() => setEditTarget(null)} disabled={editSaving} className="rounded-lg border px-4 py-2 text-sm text-gray-600"></button><button onClick={handleSaveEdit} disabled={editSaving} className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white">{editSaving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}<Save className="h-3.5 w-3.5" /></button></div> <div className="mt-6 flex justify-end gap-2"><button onClick={() => setEditTarget(null)} disabled={editSaving} className="rounded-lg border px-4 py-2 text-sm text-gray-600"></button><button onClick={handleSaveEdit} disabled={editSaving} className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white">{editSaving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}<Save className="h-3.5 w-3.5" /></button></div>
</div> </div>
</div> </div>

View File

@ -1,5 +1,9 @@
import api from "./api"; import api from "./api";
import type { ProductScanResponse } from "../types/api"; import type {
ProductOutboundMaterial,
ProductScanResponse,
ProductScrap,
} from "../types/api";
/** 扫码查询 — 根据 16 位产品身份证查产品 + 顶层任务 */ /** 扫码查询 — 根据 16 位产品身份证查产品 + 顶层任务 */
export async function scanProduct(serialNumber: string): Promise<ProductScanResponse> { export async function scanProduct(serialNumber: string): Promise<ProductScanResponse> {
@ -8,3 +12,114 @@ export async function scanProduct(serialNumber: string): Promise<ProductScanResp
); );
return data; return data;
} }
// ============================================================
// 设备的 MOM 出库明细(统一后只有这一组)
//
// 原先这里是两组接口,对应两张表:
// · outbound-orders —— 产品 ↔ 出库**单**(单据级 product_outbounds
// · materials —— 任务 ↔ 出库**明细**(明细级 task_outbound_materials
// 两者是同一个概念,却因为粒度不同被拆开:用户要面对两个入口两张卡,
// 而且走单据级挂的料**没有明细行 id报不了废**。
// 现已合并成一张表、一组接口、界面上只有一张卡。
// ============================================================
/**
* 列出该设备挂载的全部 MOM 出库明细(按出库时间倒序)。
* 对应后端 GET /api/v1/products/{productId}/outbound-materials
*
* 一行 = 一条出库明细。`mom_line_id` 为空表示这条是**单据级存档**
* MOM 回调时查不到明细),能看、能标撤回,但**不能报废**。
*/
export async function getProductOutboundMaterials(
productId: string,
): Promise<ProductOutboundMaterial[]> {
const { data } = await api.get<ProductOutboundMaterial[]>(
`/products/${productId}/outbound-materials`,
);
return data;
}
/**
* 给设备挂载 MOM 出库明细(网页端/移动端的「+ 领料」都走这里)。
* 对应后端 POST /api/v1/products/{productId}/outbound-materials
*
* ⚠️ 只传 `mom_line_ids`,物料快照由后端现查 MOM —— 前端不传快照。
* ⚠️ `taskId` 可空,仅作溯源(这条料挂在哪条任务上),不参与展示/报废/删除。
* 幂等:已挂过的明细会被后端跳过。返回该设备当前**全部**出库明细。
*/
export async function mountProductOutboundMaterials(
productId: string,
momLineIds: number[],
taskId?: string | null,
): Promise<ProductOutboundMaterial[]> {
const { data } = await api.post<ProductOutboundMaterial[]>(
`/products/${productId}/outbound-materials`,
{ mom_line_ids: momLineIds, task_id: taskId || null },
);
return data;
}
/**
* 摘掉一条**人工挂载**的出库明细(挂错了要能撤)。
* 对应后端 DELETE /api/v1/products/{productId}/outbound-materials/{materialId}
*
* ⚠️ 只能删 `source='manual'` 的。MOM 回调自动存档的行后端返回 409 ——
* 那是系统事实,要撤得去 MOM 撤回。返回该设备**剩余**的全部出库明细。
*/
export async function removeProductOutboundMaterial(
productId: string,
materialId: number,
): Promise<ProductOutboundMaterial[]> {
const { data } = await api.delete<ProductOutboundMaterial[]>(
`/products/${productId}/outbound-materials/${materialId}`,
);
return data;
}
/**
* 整张出库单一起摘掉(挂错了要能撤)。
* 对应后端 DELETE /api/v1/products/{productId}/outbound-materials/by-order/{outboundNo}
*
* 界面上更容易碰到的是「这一单整个挂错了」,逐条删要删好几下。
* 规则与逐条删一致:含 webhook 存档记录的单整单删不掉(后端 409
*/
export async function removeProductOutboundOrder(
productId: string,
outboundNo: string,
): Promise<ProductOutboundMaterial[]> {
const { data } = await api.delete<ProductOutboundMaterial[]>(
`/products/${productId}/outbound-materials/by-order/${encodeURIComponent(outboundNo)}`,
);
return data;
}
// ============================================================
// 生产报废
// ============================================================
/**
* 列出该设备的报废记录(含实时回查 MOM 的状态与金额)。
* 对应后端 GET /api/v1/products/{productId}/scraps
*/
export async function listProductScraps(productId: string): Promise<ProductScrap[]> {
const { data } = await api.get<ProductScrap[]>(`/products/${productId}/scraps`);
return data;
}
/**
* 提交一条生产报废(领用的料在生产中损坏)。
* 对应后端 POST /api/v1/products/{productId}/scraps
*
* ⚠️ `track_ref` 必须在**打开弹窗时生成一次**并在重试时复用 ——
* 每次提交都换新的话,用户重试会在 MOM 里多报一张报废单。
*/
export async function submitProductScrap(
productId: string,
payload: { mom_line_id: number; quantity: number; track_ref: string; reason?: string | null },
): Promise<ProductScrap> {
const { data } = await api.post<ProductScrap>(
`/products/${productId}/scraps`, payload,
);
return data;
}

View File

@ -48,6 +48,80 @@ export interface TaskResponse extends TaskSummary {
records: TaskRecordResponse[]; records: TaskRecordResponse[];
/** 🔧 任务创建人(追溯谁转交/发起该工序),如"谁转入在库" */ /** 🔧 任务创建人(追溯谁转交/发起该工序),如"谁转入在库" */
created_by?: string | null; created_by?: string | null;
/** 🔧 本任务挂载的 MOM 出库物料(创建时选、之后可追加) */
outbound_materials?: TaskOutboundMaterial[];
}
/** 任务挂载的一条 MOM 出库物料明细(挂载时从 MOM 取的快照)
* 一次挂载会展开成多行(挂一张出库单 = 该单全部明细各一行),按 outbound_no 分组展示。 */
export interface TaskOutboundMaterial {
id: number;
/** 料挂在哪条任务上。按任务分组/删除都要用它(不能用任务名,同名会并组) */
task_id: string;
/** MOM trans_outbound.id供反查比对 */
mom_line_id: number;
/** MOM 出库单号 */
outbound_no: string;
sku: string | null;
material_name: string | null;
spec_model: string | null;
/** 出库单原值,**不是**本任务用量 */
quantity: number | null;
unit_price: number | null;
/** SALES/USE/PRODUCTION/LOSS/REPAIR —— 只展示不判断MOM 码表未冻结) */
outbound_type: string | null;
/** 出库类型的中文名(服务端按 MOM 码表下发),直接展示,前端不自建映射 */
outbound_type_label: string;
/** 领用人/客户MOM 侧自由填写,非可靠标识) */
consumer_name: string | null;
operator_name: string | null;
warehouse_location: string | null;
outbound_time: string | null;
/** 挂载人逻辑外键→MOM sys_user */
added_by: string | null;
created_at: string;
}
// ---- MOM 出库单(任务挂载物料时的搜索选择器用) ----
/** MOM 出库单的一条物料明细。line_id 即挂载时提交的 mom_line_ids 元素 */
export interface MomOutboundLine {
line_id: number;
sku: string;
material_name: string;
spec_model: string;
quantity: number | null;
unit_price: number | null;
returned_quantity: number | null;
outbound_type: string;
/** 出库类型的中文名(服务端按 MOM 码表下发),直接展示,前端不自建映射 */
outbound_type_label: string;
consumer_name: string;
operator_name: string;
warehouse_location: string;
outbound_time: string | null;
/** ⚠️ MOM 存量单据该字段全为空(列是后加的,无从回填),空表示「无关联申请单」 */
request_no: string;
}
/** 一张 MOM 出库单(批量出库多商品共用一个单号,故带 N 条明细) */
export interface MomOutboundOrder {
outbound_no: string;
outbound_time: string | null;
outbound_type: string;
/** 出库类型的中文名(服务端按 MOM 码表下发),直接展示,前端不自建映射 */
outbound_type_label: string;
consumer_name: string;
operator_name: string;
line_count: number;
total_quantity: number | null;
lines: MomOutboundLine[];
}
export interface MomOutboundSearchResponse {
orders: MomOutboundOrder[];
/** 命中的**单据**总数(不是明细行数) */
total: number;
} }
export interface TaskRecordResponse { export interface TaskRecordResponse {
@ -110,6 +184,84 @@ export interface TaskTransferPayload {
// 产品 // 产品
// ============================================================ // ============================================================
/**
* 设备的一条 MOM 出库明细(统一形态)
*
* 一行 = 设备上的一条出库明细。`mom_line_id` 为空表示这条是**单据级存档**
* MOM 回调时查不到明细),能看、能标撤回,但**不能报废** —— 报废要用它定位。
*/
export interface ProductOutboundMaterial {
id: number;
product_id: string;
serial_number: string | null;
/** 仅溯源(这条料挂在哪条任务上),不参与展示/报废/删除 */
task_id: string | null;
/** MOM trans_outbound.id为空 = 无明细的存档 */
mom_line_id: number | null;
outbound_no: string;
// ---- 单据级(同单内一致,冗余在每条明细上) ----
request_no: string | null;
applicant_name: string | null;
remark: string | null;
// ---- 明细级快照 ----
sku: string | null;
material_name: string | null;
spec_model: string | null;
/** 出库单原值,**不是**本设备用量 */
quantity: number | null;
unit_price: number | null;
outbound_type: string | null;
/** 服务端下发的中文名,直接展示 */
outbound_type_label: string;
consumer_name: string | null;
operator_name: string | null;
warehouse_location: string | null;
outbound_time: string | null;
// ---- 来源与撤回 ----
/** manual(人工挂载,可删) | webhook(MOM 回调自动存档,不可删) */
source: string;
is_revoked: boolean;
revoked_at: string | null;
/** 谁挂上去的Track 用户名) */
added_by: string | null;
/** 谁挂上去的(中文姓名,服务端解析下发),直接展示 */
added_by_name: string;
created_at: string;
}
/** 生产报废记录 —— Track 发起、MOM 受理的报废单 */
export interface ProductScrap {
id: string;
product_id: string;
serial_number: string | null;
task_id: string | null;
/** 报废对象MOM trans_outbound.id */
mom_line_id: number;
/** 快照MOM 侧数据被清理后仍要能显示「报了什么」 */
outbound_no: string | null;
material_name: string | null;
spec_model: string | null;
sku: string | null;
/** 原领用人。前端据此判断「报别人的料要额外确认」 */
consumer_name: string | null;
quantity: number;
reason_category: string;
reason: string | null;
scrap_request_no: string;
defective_goods_id: number | null;
submitted_by: string | null;
created_at: string;
// ---- 以下为后端实时回查 MOM 的结果 ----
mom_status: number;
mom_status_label: string;
mom_approved_at: string | null;
mom_executor_name: string;
mom_executed: boolean;
/** 报废损失。**未执行时是 null 不是 0** —— 0 会让人以为「这东西不值钱」 */
total_loss: number | null;
scrapped_quantity: number | null;
}
export interface ProductScanResponse { export interface ProductScanResponse {
id: string; id: string;
serial_number: string; serial_number: string;
@ -132,4 +284,8 @@ export interface ProductScanResponse {
task_tree: TaskResponse[]; task_tree: TaskResponse[];
/** 🔧 username→中文姓名映射 */ /** 🔧 username→中文姓名映射 */
assignee_names: Record<string, string>; assignee_names: Record<string, string>;
/** 🔧 出库单据存档(来自 MOM 出库回调),按出库时间倒序。
* 本功能上线前出库的设备这里是空数组,不是错误。
* 可选是为了兼容尚未升级的后端。 */
outbound_records?: ProductOutboundMaterial[];
} }