diff --git a/backend/alembic/versions/q1r2s3t4u5v6_unify_product_outbound_materials.py b/backend/alembic/versions/q1r2s3t4u5v6_unify_product_outbound_materials.py new file mode 100644 index 0000000..bc4b633 --- /dev/null +++ b/backend/alembic/versions/q1r2s3t4u5v6_unify_product_outbound_materials.py @@ -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") diff --git a/backend/app/api/v1/endpoints/products.py b/backend/app/api/v1/endpoints/products.py index 016985d..2c30b68 100644 --- a/backend/app/api/v1/endpoints/products.py +++ b/backend/app/api/v1/endpoints/products.py @@ -11,10 +11,18 @@ from app.models.message import ProductMessage from app.schemas.product import ( ProductCreate, ProductUpdate, + ProductOutboundMaterialResponse, ProductResponse, 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.qrcode_service import generate_qrcode_png @@ -107,11 +115,169 @@ async def create_product_endpoint( db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): - """创建产品 — 初始位置自动设为当前登录用户""" + """创建产品 — 初始位置自动设为当前登录用户 + + `mom_line_ids` 非空时,会在**同一事务**里把对应的 MOM 出库单挂到这个新产品上, + 所以不存在「产品建好了但出库单没挂上」的中间态。 + """ creator_username = current_user.get("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 出库**明细行** ID(trans_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) async def update_product_endpoint( product_id: str, diff --git a/backend/app/api/v1/endpoints/tasks.py b/backend/app/api/v1/endpoints/tasks.py index c161e7e..a0fce19 100644 --- a/backend/app/api/v1/endpoints/tasks.py +++ b/backend/app/api/v1/endpoints/tasks.py @@ -64,9 +64,23 @@ async def create_task_endpoint( db: AsyncSession = Depends(get_db), 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) async def update_task_endpoint( diff --git a/backend/app/api/v1/endpoints/webhooks.py b/backend/app/api/v1/endpoints/webhooks.py index 119033c..2118563 100644 --- a/backend/app/api/v1/endpoints/webhooks.py +++ b/backend/app/api/v1/endpoints/webhooks.py @@ -19,9 +19,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings from app.core.database import get_db 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.task import Task, TaskRecord from app.models.task_log import TaskLog +from app.services import product_outbound_material_service router = APIRouter(prefix="/external/webhooks", tags=["外部回调"]) @@ -259,6 +261,18 @@ async def mom_inbound_webhook( if product.status != prev_status: 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) if log_task is not None: @@ -393,10 +407,19 @@ class MomOutboundPayload(BaseModel): operator: str | None = None # 出库操作人(写入 task_logs.operator_id) outbound_time: datetime | None = None # 出库时间 company_name: str | None = None # 目标公司(IRIS / LICA),MOM 据此分流到不同 Track 实例 - # ↓ 2026-09 新增:MOM 一直在发、此前被 Pydantic 静默丢弃。当前无人读取, + # ↓ 2026-09 新增:MOM 一直在发、此前被 Pydantic 静默丢弃。 # 先接住是为了与 LICA 实例(~/track-lica)对同一载荷的解析结果保持一致 —— # 否则将来谁写了读这个字段的代码,会在 LICA 拿到值、在本实例拿到 None。 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") @@ -487,8 +510,34 @@ async def mom_outbound_webhook( )) 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_status(services/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 if changed: diff --git a/backend/app/models/product_outbound_material.py b/backend/app/models/product_outbound_material.py new file mode 100644 index 0000000..0321947 --- /dev/null +++ b/backend/app/models/product_outbound_material.py @@ -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"") diff --git a/backend/app/schemas/product.py b/backend/app/schemas/product.py index e612deb..6f738c9 100644 --- a/backend/app/schemas/product.py +++ b/backend/app/schemas/product.py @@ -2,7 +2,7 @@ from __future__ import annotations import uuid from datetime import datetime -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator class ProductCreate(BaseModel): @@ -18,6 +18,14 @@ class ProductCreate(BaseModel): order_id: uuid.UUID | None = Field(None, description="所属订单ID(选填)") order_no: str | None = Field(None, max_length=64, description="订单号(自由键入,选填)") parent_product_id: uuid.UUID | None = Field(None, description="父产品ID") + # 建档时一并挂钩的 MOM 出库**明细行** ID(trans_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} @@ -80,6 +88,139 @@ class ProductResponse(BaseModel): 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 + # 幂等锚点:前端在**打开弹层时**生成一次,重试时复用同一个。 + # 后端拼成 <公司>: 发给 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): """扫码查询响应 — 产品信息 + 完整任务树(递归嵌套)""" id: uuid.UUID @@ -102,6 +243,9 @@ class ProductScanResponse(BaseModel): top_level_tasks: list[TaskSummaryResponse] = [] task_tree: list[TaskResponse] = [] assignee_names: dict[str, str] = {} # 🔧 username→中文姓名映射 + # 🔧 出库单据存档(来自 MOM 出库回调),按出库时间倒序。 + # 本次功能上线前出库的设备没有存档,这里是空列表 —— 不是错误。 + outbound_records: list[ProductOutboundMaterialResponse] = [] model_config = {"from_attributes": True} diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py index b6ab481..8295677 100644 --- a/backend/app/schemas/task.py +++ b/backend/app/schemas/task.py @@ -19,6 +19,15 @@ class TaskCreate(BaseModel): notify_parent_on_complete: bool = Field(False, description="完成后是否通知父任务") is_rework: bool = Field(False, description="是否为返工任务") remark: str | None = Field(None, max_length=2000, description="初始描述/交接备注") + # 创建时一并挂载的 MOM 出库明细行 ID(MOM 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} @@ -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 而非 Decimal:Pydantic v2 会把 Decimal 序列化成字符串, + # 前端拿到 "5.0000" 不好直接用。数量量级很小(实测 1~186),float 足够。 + 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 个构造点各自记得填 —— + 漏一个就是空白徽标,而且不会报错,只能靠肉眼发现。 + 延迟 import:schemas 被 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): """任务摘要 — 扫码时用,不含嵌套子任务""" id: uuid.UUID @@ -195,6 +253,9 @@ class TaskResponse(BaseModel): child_tasks: list[TaskResponse] = [] records: list[TaskRecordResponse] = [] created_by: str | None = None # 谁创建的(从task_logs追溯) + # 本任务挂载的 MOM 出库物料(明细级快照)。创建任务时可选、之后可追加, + # 见 models/task_outbound_material.py。 + outbound_materials: list[TaskOutboundMaterialResponse] = [] model_config = {"from_attributes": True} diff --git a/backend/app/services/product_outbound_material_service.py b/backend/app/services/product_outbound_material_service.py new file mode 100644 index 0000000..2e1f91b --- /dev/null +++ b/backend/app/services/product_outbound_material_service.py @@ -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 连不上时姓名降级为空串,前端回落到显示 + 用户名。查一次是批量 SQL(mom_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) diff --git a/backend/app/services/product_service.py b/backend/app/services/product_service.py index bbeadf6..e5388e6 100644 --- a/backend/app/services/product_service.py +++ b/backend/app/services/product_service.py @@ -15,10 +15,23 @@ from app.core.lifecycle import ( sync_product_status, ) from app.models.product import Product +from app.models.product_outbound_material import ProductOutboundMaterial from app.models.production_order import ProductionOrder from app.models.task import Task -from app.schemas.product import ProductCreate, ProductUpdate, ProductResponse, ProductScanResponse -from app.schemas.task import TaskSummaryResponse, TaskResponse, TaskRecordResponse +from app.schemas.product import ( + 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: @@ -51,6 +64,10 @@ def _task_to_response(task: Task) -> TaskResponse: child_tasks=[_task_to_response(c) for c in task.child_tasks], records=[TaskRecordResponse.model_validate(r) for r in (task.records or [])], 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)) + # 🔧 设备的 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( id=product.id, 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, 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: """获取产品,不存在则 404""" 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, # 谁创建,初始位置就是谁 ) 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.refresh(product, ["order"]) diff --git a/backend/app/services/task_service.py b/backend/app/services/task_service.py index 6da7220..68bd428 100644 --- a/backend/app/services/task_service.py +++ b/backend/app/services/task_service.py @@ -3,6 +3,7 @@ from __future__ import annotations import json import uuid from fastapi import HTTPException, status +from fastapi.concurrency import run_in_threadpool from sqlalchemy import select, delete, func from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -38,7 +39,6 @@ from app.schemas.task import ( TaskSummaryResponse, TaskListResponse, ) - # 特殊位置常量 VIRTUAL_WAREHOUSE = "virtual_warehouse" @@ -332,6 +332,10 @@ def _to_response(task: Task) -> TaskResponse: created_at=task.created_at, child_tasks=[_to_response(c) for c in task.child_tasks], 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] -async def create_task(db: AsyncSession, data: TaskCreate) -> TaskResponse: - """创建任务,并同步产品宏观状态""" - task = Task(**data.model_dump()) +# ============================================================ +# 任务挂载 MOM 出库物料 +# ============================================================ + +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) # 同步产品宏观状态 + 当前位置 @@ -438,11 +483,22 @@ async def create_task(db: AsyncSession, data: TaskCreate) -> TaskResponse: if data.assignee_id and data.assignee_id != VIRTUAL_WAREHOUSE: 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.refresh(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: """更新任务""" task = await _get_task_or_404(db, task_id) diff --git a/backend/app/services/task_tree_loader.py b/backend/app/services/task_tree_loader.py index 8ad0493..4025b88 100644 --- a/backend/app/services/task_tree_loader.py +++ b/backend/app/services/task_tree_loader.py @@ -96,6 +96,10 @@ async def load_task_tree_by_root( noload(Task.parent_task), # 组装树不需要 parent 引用 selectinload(Task.records), # 🔥 一次性预加载所有进度记录 selectinload(Task.product), # 🔥 一次性预加载产品引用 + # 🔥 预加载挂载的出库物料:扫码响应要带它(移动端「领用物料」靠它渲染)。 + # ★ 必须在这里预加载,不能等 _task_to_response 里现取 —— + # 异步 session 下懒加载会抛 MissingGreenlet(本仓踩过的坑)。 + selectinload(Task.outbound_materials), ) .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), selectinload(Task.records), selectinload(Task.product), + # 同上:扫码响应要带挂载的出库物料,必须预加载(懒加载会 MissingGreenlet) + selectinload(Task.outbound_materials), ) .where(Task.id.in_(select(task_tree_cte.c.id))) ) diff --git a/frontend/src/components/scan/OutboundRecordsCard.tsx b/frontend/src/components/scan/OutboundRecordsCard.tsx new file mode 100644 index 0000000..2483e77 --- /dev/null +++ b/frontend/src/components/scan/OutboundRecordsCard.tsx @@ -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(); + 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(null); + const [scraps, setScraps] = useState([]); + const [expanded, setExpanded] = useState>(new Set()); + // 挂载不需要先选任务:任务只是溯源信息,展示/报废/删除都按设备走。 + // 「谁挂上去的」记在 added_by、「谁出库的」由 MOM 快照带过来 —— 够了。 + const [pickerOpen, setPickerOpen] = useState(false); + const [appending, setAppending] = useState(false); + + const [removeTarget, setRemoveTarget] = useState(null); + const [removing, setRemoving] = useState(false); + // 整单删除:界面上更容易碰到的是「这一单整个挂错了」,逐条删要删好几下 + const [removeOrder, setRemoveOrder] = useState(null); + + const [scrapTarget, setScrapTarget] = useState(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 ( + <> +
+
+ +

出库单据

+ {list.length > 0 && ( + {groups.length} 张单 / {list.length} 条料 + )} + +
+ + {records === null ? ( +

+ 加载中… +

+ ) : list.length === 0 ? ( +

+ 暂无关联的出库单。建档时没挂、或本功能上线前出库的设备都属于这种情况, + 可用右上角「添加出库单」补挂。 +

+ ) : ( +
+ {groups.map(([no, items]) => { + const open = expanded.has(no); + const revoked = items[0]?.is_revoked; + return ( +
+
toggle(no)} className="cursor-pointer px-2.5 py-2 transition-colors hover:bg-gray-100"> +
+ + {no} + + {/* 撤回标记:与后端「撤回只置位不删行」一致,不让它凭空消失 */} + {revoked && ( + + 已撤回 + + )} + {/* 不展示出库类型(用途)—— 现场只关心「这台设备挂了哪张单、 + 谁挂的、谁出的库」,多一个「内部领用」徽标只是噪音 */} + + {fmtTime(items[0]?.outbound_time ?? null, items[0]?.created_at ?? "")} + + {/* 整单删除:挂错了要能一次摘掉。只在**全部**是人工挂的时显示 —— + 含 MOM 回调存档的单不给删(后端也拦),那是系统事实 */} + {items.every((m) => m.source === "manual") && ( + + )} +
+
+ {items[0]?.consumer_name && 领用 {items[0].consumer_name}} + {items[0]?.operator_name && 经办 {items[0].operator_name}} + {/* 谁挂上去的 —— 现场要能追责/问人;只记在库里不显示等于没记 */} + {(items[0]?.added_by_name || items[0]?.added_by) && ( + + 挂载 {items[0].added_by_name || items[0].added_by} + + )} + {items.length} 条物料 + + {open ? : } + {open ? "收起明细" : "物料明细"} + +
+ {items[0]?.remark &&

{items[0].remark}

} +
+ + {open && ( +
+ {items.map((m) => { + // 没有明细行 id = MOM 回调只存了单据、查不到明细 → 报不了废 + const canScrap = m.mom_line_id != null; + return ( +
+
+
+ {m.material_name || (canScrap ? "(未命名物料)" : "MOM 出库回调存档")} +
+
+ {m.spec_model && {m.spec_model} · } + {canScrap ? `×${m.quantity}` : "无明细"} + {m.warehouse_location && · 库位 {m.warehouse_location}} +
+
+ {/* 只有带 mom_line_id 的才能报废 —— MOM 要用它定位到具体明细行 */} + {canScrap && ( + + )} + {/* 只有人工挂的可删:webhook 存档是系统事实,要撤得去 MOM 撤回 */} + {m.source === "manual" && ( + + )} +
+ ); + })} +
+ )} +
+ ); + })} +
+ )} + + {/* ♻️ 报废记录:状态与金额由后端实时回查 MOM */} + {scraps.length > 0 && ( +
+

+ 报废记录({scraps.length}) +

+
+ {scraps.map((s) => ( +
+
+ + {s.material_name || "(未命名物料)"} + + + {s.mom_status_label || "状态未知"} + + ×{s.quantity} +
+
+ 报废单 {s.scrap_request_no} + {s.submitted_by && 提交人 {s.submitted_by}} + {/* ★ 只有执行过才有金额。未执行显示「—」不显示 0 —— + 0 会让人以为「这东西不值钱」,其实是「还没扫码执行」 */} + {s.mom_executed && ( + 损失 ¥{Number(s.total_loss ?? 0).toFixed(2)} + )} +
+ {s.reason &&

{s.reason}

} +
+ ))} +
+
+ )} +
+ + {/* 选择器:已挂过的单据会在里面显示「已挂载」且不可再选。 + 提交期间选择器保持打开(按钮转圈),成功后由 handleAppend 关闭 —— + 比先关弹窗再等结果更不容易让人以为没生效。 */} + setPickerOpen(false)} + onConfirm={(ids) => handleAppend(ids)} + submitting={appending} + existingOrderNos={list.map((m) => m.outbound_no)} + /> + + {/* 整单删除确认 */} + !removing && setRemoveOrder(null)} + onOk={doRemoveOrder} + okText="删除" + cancelText="取消" + okButtonProps={{ danger: true, loading: removing }} + cancelButtonProps={{ disabled: removing }} + > +

+ 把出库单 {removeOrder} 从这台设备上整张摘掉? +

+

+ 只解除 Track 这边的挂载关系,不会动 MOM 里的出库单本身, + 已提交的报废记录也不受影响。 +

+
+ + {/* 报废:数量 + 说明。分类不让人选 —— 走这条路的料按定义就是生产损耗 */} + !scrapSubmitting && setScrapTarget(null)} + onOk={doScrap} + okText="提交报废" + cancelText="取消" + confirmLoading={scrapSubmitting} + okButtonProps={{ disabled: isProxy && !scrapForm.confirmed }} + > +

+ {scrapTarget?.material_name || "(未命名物料)"} + + {scrapTarget?.spec_model} | 原领用人 {scrapTarget?.consumer_name || "—"} + +

+
+ + 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} + /> +
+
+ +