Compare commits

...

4 Commits

Author SHA1 Message Date
0b982d192c feat(mobile): 移动端补齐出库单据页(领料 / 报废 / 删除)
PC 端早就能挂出库物料,但移动端一直没有入口 —— 只能看,一线的人(生产领料、
测试补料)反而够不着。

- 产品详情页的产品信息卡底部加「出库单据 N 张单 / M 条料」入口,常显不隐藏:
  以前没内容时整块消失,用户根本不知道有这功能。
- 新增「出库单据」页与「选择 MOM 出库物料」页,与 PC 端同一套数据源、
  同一套接口、同一形态(按出库单号分组 + 点开展开明细)。
- 挂载不需要先选任务:任务只是溯源(记 added_by),展示/报废/删除一律按设备走。
- 代挂确认:勾了不是自己领的单时先拦一道。★ 这是**提示**不是权限 ——
  料的归属是设备不是人,代挂是合理操作(测试替生产补挂、库管代录)。
  ⚠️ 判据是 MOM 的 consumer_name 与登录人姓名比对,比错也只是多让用户勾一下。
- navigateTo / navigateBack 失败在 uni 里是**静默**的(只留一行 warning),
  用户看到的就是「点了没反应」。全部补 fail 回调弹窗。
2026-09-23 15:18:06 +08:00
5d5aea1015 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。
- 前端两张卡合并成一张:按出库单号分组、点开看明细,明细行才有报废/删除。
2026-09-23 15:18:06 +08:00
551819e0e3 feat(scrap): Track 侧生产报废 —— 提交、回查、金额
料领到产线后在生产中报废,要在 MOM 里走报废流程并能统计金额。

- mom_scrap_client:Track **唯一**一处主动写 MOM 的通道。读仍走直连只读库
  (MOM 查询接口有权限与行级隔离),写必须走接口(跨库直写会绕过 MOM 的
  全部业务校验、权限与审批)。
- product_scrap_service:归属校验是关键 —— 可见范围是整台设备、不是「谁领的」,
  不能靠隐藏来防,必须在写入前确认这条 mom_line_id 就挂在这台设备上。
  申请人直接用当前登录人(Track 的 sub 就是 MOM sys_user.id),
  MOM 里显示的就是本人,不需要服务账号也不会串人。
- 幂等:track_ref 由前端在打开弹层时生成一次、重试复用;网络超时后重试
  不该在 MOM 里多报一张单。
- 状态与金额**实时回查 MOM**,不在本地存副本:报废没有回调,本地那份立刻
  就过期;且金额取决于执行时的实际扫码量(MOM 允许少扫),受理量 ≠ 执行量。
  ⚠️ 未执行时 total_loss 是 null 不是 0 —— 0 会让人以为「这东西不值钱」。
- MOM_INTERNAL_API_KEY 走环境变量且不给默认值:未配置时报废提交 503,
  而不是让一个写接口在生产上默默开着。
2026-09-23 15:18:06 +08:00
e45c97bd1f feat: 组织隔离(IRIS 单实例)与出料功能基础
本轮之前累积的未提交工作,一并固化:

- 组织隔离:同一份代码部署给不同部门只需改 config 的 ORG_DEPARTMENT 与
  MATERIAL_CATEGORY_PREFIX。过滤点在登录/人员列表/物料/MOM 出库单四处,
  全部服务端钉死,客户端传什么都放不大。
  ★ 物料必须用**前缀** LIKE,不能反推成 ILIKE '%IRIS%':MOM 里 LICA 的物料是
  `LICA/<中文>`,而本部门分类树里另有 `IRIS/成品/LICA/…`(本就属于本部门),
  前缀匹配天然区分得开。
- MOM 出库单只读查询(直连 MOM 库):不走 MOM 现成的 /outbound 接口 ——
  那个要 JWT + permission_required,且对非特权账号按 consumer_name 做行级
  隔离,服务账号只能拿到自己名下的单。分页必须两段式(先按单号 GROUP BY
  分页,再 IN 捞明细),对宽表直接分页会得到明细行数而不是单据数。
- 出料功能:产品 ↔ 出库单存档(product_outbounds)与任务 ↔ 出库明细
  (task_outbound_materials),供「这台设备对应 MOM 哪张单」的展示。
  ⚠️ 快照一律由后端拿 ID 去 MOM 现查,不接受前端传入,否则前端可伪造单据。
2026-09-23 15:18:06 +08:00
51 changed files with 6037 additions and 67 deletions

View File

@ -12,6 +12,79 @@
`WHERE username LIKE '%/<账号>'` 匹配,`display_name` 由 `/` 拆解得到。
MOM 连接配置在 `app/core/mom_database.py`(同步 psycopg2 引擎)。
## 组织隔离(2026-09 新增)
本实例只服务 IRIS 部门,开关是 `app/core/config.py` 的两个值:`ORG_DEPARTMENT`
(对 MOM `sys_user.department`)与 `MATERIAL_CATEGORY_PREFIX`(对 MOM
`material_base.category` 前缀)。**同一份代码部署给别的部门只需改这两处。**
过滤点共四处,全部服务端钉死,客户端传什么参数都不采纳:
| 位置 | 过滤条件 |
|---|---|
| 登录 `auth_service.login` | `department = ORG_DEPARTMENT OR role = SUPER_ADMIN` |
| 人员列表 `endpoints/users.py` | `department = ORG_DEPARTMENT` |
| 物料 `endpoints/materials.py` | `category LIKE 'IRIS/%'`(`groups` 与 `items` 都要加) |
| MOM 出库单 `services/mom_outbound_service.py` | 同上前缀 + 跨部门领用人例外(见下) |
- **登录是四处里唯一区分角色的**:`SUPER_ADMIN` 跨部门放行(IRIS 超管也能登
LICA 实例,反之亦然),供运维在两个实例之间切换。其余角色(INBOUND /
SUPERVISOR / WAREHOUSE_MGR / SALES)必须严格属于本部门。
- ⚠️ 物料必须用**前缀** `LIKE 'IRIS/%'`,不能反推成 `ILIKE '%IRIS%'`:MOM 里
LICA 的物料是 `LICA/<中文>`(生产配件 687 / 销售产品 89 / 维修服务 16…),
而本部门分类树里另有 `IRIS/成品/LICA/…`(野外便携 59 / 无人机 38 /
实验室内 34 / 高塔监测 30,共 171 条)—— 那是**挂在 IRIS 名下、给 LICA 做的
成品**,本来就属于本部门。前缀匹配天然把前者排除、后者包含,不需要特例。
- 刻意**不做**「查询失败退回全表」的降级 —— 那是跨部门数据泄漏。宁可查不出,
不可查过头。
- 已实测:人员列表 20 人(IRIS 部门)、物料 100 个分类 / 2248 条、
`LICA/` 与 `IRIS/` 前缀交叉命中 0 条;4 个 LICA 普通账号全部登不进来,
2 个超管(含 LICA 的)正常放行。
### 出库单的跨部门领用人例外
`config.EXTRA_VISIBLE_CONSUMERS`(默认 `依锐思,石利LICA`)里的领用人,其在 MOM
`trans_outbound.consumer_name` 名下的单据,**即使物料分类不属于本部门也放行** ——
他们跨两个部门领料,只按物料前缀过滤会把他们的单整批漏掉。
⚠️ 这是**放行**条件(SQL 里是 `OR`),与界面筛选(`AND`,只收窄)方向相反,
两者的集合运算在 `mom_outbound_service` 里必须分开写。名单为空时整段不拼,
退化成纯前缀过滤。已实测:空白名单 438 单 → 填入一个真实跨部门领用人后 440 单,
填入不存在的名字仍是 438 单(不放大范围)。
## 出料功能(2026-09 新增)
回答两个问题:**这台设备对应 MOM 的哪张出库单**、**这个任务用了哪些出库物料**。
- `product_outbounds` —— 产品 ↔ 出库单存档。两条写入路径共用一张表,靠 `source`
区分:`webhook`(MOM 出库回调自动存档)/ `manual`(人在界面上挂的)。
一次出库一行;**撤回只置 `is_revoked` 不删行**(「出过又撤了」也是历史)。
唯一约束是 `(serial_number, outbound_no)` 而非只约束单号 —— MOM 的批量出库
是多个商品共用一个单号,只约束单号会把正常的批量单误杀。
- `task_outbound_materials` —— 任务挂载的出库物料,**明细级快照**(一行 = MOM
`trans_outbound` 的一行)。为什么存快照而不只存单号:MOM 的物料名/规格要经
`COALESCE` 三表 JOIN(`stock_buy`/`stock_semi`/`stock_product` → `material_base`)
才能解析,Track 跨库 JOIN 不了,只存单号则 MOM 一挂就看不到已挂内容。
纯引用**不记用量**(`quantity` 是出库单原值,**不是**本任务用量)。
- `mom_outbounds.py` / `mom_outbound_service.py` —— 直连 MOM 库的只读查询,供选择器
搜索用。不走 MOM 现成的 `GET /api/v1/outbound`:那个接口要 JWT +
`permission_required`,且对非特权账号按 `consumer_name` 做行级隔离,Track 用
服务账号调只能拿到该账号名下的单,不是全量。分页必须**两段式**(先
`GROUP BY outbound_no` 分页拿单号,再 `WHERE outbound_no IN (…)` 捞明细),
绝不能对 join 后的宽表直接分页 —— 那是明细行数不是单据数。
- ⚠️ 快照一律由后端拿 ID 去 MOM 现查,**不接受前端传入**,否则前端可伪造单据。
- ⚠️ 写挂载行时**不要** `append` 到 ORM 集合(`task.outbound_materials`):集合在
flush 后处于「未加载」态,碰它会触发懒加载,异步 session 下直接抛
`MissingGreenlet`。只写 FK,响应构造前走一次真正的查询。
**与 LICA 实例(`~/track-lica`)的差异**:LICA 那边出库单还要按**业务分组数据
范围**再收敛一层(范围 ∩ 组 ∩ 个人),本实例没有分组体系,故 `mom-outbounds`
**没有 `group_id` 参数**,挂载时也不做额外的可见性校验。不要为了「对齐」加回来 ——
那会引入一份没有数据支撑的过滤。
⚠️ 两套实例共用同一个 MOM 库,但**数据卷相互独立**。永远不要在 `/home/yueli/track`
下执行 `docker compose down -v`。
## 本地起环境(关键,踩过的坑都在这)
1. **本机没有 Postgres 时需要先装**(容器内 `sudo` 可用):

View File

@ -0,0 +1,93 @@
"""add_product_outbounds
Revision ID: m1n2o3p4q5r6
Revises: k1l2m3n4o5p6
Create Date: 2026-09-23
产品出库记录(product_outbounds)
--------------------------------------------------------------------------
把 MOM 出库回调里的单据上下文存档到 Track 侧,回答「这台设备这次出库对应
MOM 的哪张单」。
背景:MOM 的出库 webhook 此前只发 7 个字段,出库单号/申请单号/领用人/申请人
全都没带,Track 扫码只能看到「已出库」,不知道是为谁、凭什么出的库。
MOM 侧已同步扩展 payload(见 projects 仓的对应提交),本表是接收端。
为什么单独一张表,而不是给 products 加几列:
一台设备可能出库多次(出库 → 撤回 → 再出库),加列只能保住最后一次,
而需求是「完整出库历史」。本表一次出库一行。
为什么撤回不删行:
「出过又撤了」本身就是需要看得见的历史。撤回只把 is_revoked 置真、
记下 revoked_at。
唯一约束 (serial_number, outbound_no):
防 MOM 重推产生重复行。**不能只约束 outbound_no** —— MOM 的批量出库是多个
商品共用一个单号(见 MOM models/outbound.py:127 的注释)。
与 products.overall_status / status 的分工:
那两列是**当前事实**(此刻是否已出库),本表是**单据归属**。设备被撤回回库后
overall_status 变回「已入库」,但出库单仍在,只是标了已撤回。
本迁移只建表,不写入任何数据。
本次上线前已出库的设备没有存过单据,产品详情上不会显示出库记录 ——
这是预期行为,历史无从回填(MOM 侧出库流水与申请单此前也没有关联)。
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID
revision: str = "m1n2o3p4q5r6"
down_revision: Union[str, None] = "k1l2m3n4o5p6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"product_outbounds",
sa.Column("id", UUID(as_uuid=True), nullable=False, comment="主键"),
sa.Column("product_id", UUID(as_uuid=True),
sa.ForeignKey("products.id"), nullable=False, comment="所属产品ID"),
sa.Column("serial_number", sa.String(16), nullable=True,
comment="产品序列号(冗余,便于按SN对账)"),
sa.Column("outbound_no", sa.String(100), nullable=False,
comment="MOM 出库单号(批量出库多商品共用)"),
sa.Column("request_no", sa.String(100), nullable=True,
comment="MOM 出库申请单号"),
sa.Column("consumer_name", sa.String(100), nullable=True,
comment="领用人/客户(自由填写,非可靠标识)"),
sa.Column("applicant_name", sa.String(100), nullable=True,
comment="申请人姓名(MOM 侧解析后传来)"),
sa.Column("operator", sa.String(64), nullable=True,
comment="MOM 侧实际扫码出库人"),
sa.Column("outbound_type", sa.String(50), nullable=True,
comment="出库类型 SALES/USE/PRODUCTION(只存不判)"),
sa.Column("outbound_time", sa.DateTime(timezone=True), nullable=True,
comment="MOM 记录的出库时间"),
sa.Column("remark", sa.Text(), nullable=True, comment="出库单备注"),
sa.Column("is_revoked", sa.Boolean(), nullable=False, server_default="false",
comment="该次出库是否已被 MOM 撤回"),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True,
comment="撤回时间"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
comment="本行写入时间"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("serial_number", "outbound_no",
name="uq_product_outbound_sn_no"),
comment="产品出库记录 — MOM 出库回调的单据存档,一次出库一行",
)
# 产品详情按 product_id 拉历史
op.create_index(op.f("ix_product_outbounds_product_id"),
"product_outbounds", ["product_id"], unique=False)
# 按 SN 排查/对账
op.create_index(op.f("ix_product_outbounds_serial_number"),
"product_outbounds", ["serial_number"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_product_outbounds_serial_number"), table_name="product_outbounds")
op.drop_index(op.f("ix_product_outbounds_product_id"), table_name="product_outbounds")
op.drop_table("product_outbounds")

View File

@ -0,0 +1,99 @@
"""add_task_outbound_materials
Revision ID: n1o2p3q4r5s6
Revises: m1n2o3p4q5r6
Create Date: 2026-09-23
任务挂载的 MOM 出库物料(task_outbound_materials)
--------------------------------------------------------------------------
回答「这个任务用了哪些 MOM 出库物料」。此前 Track 里创建任务完全无法关联
出库物料;任务进行中若发现第一次领的料不够,也没有地方追加后续出库单。
口径(已与业务确认):
· 选择粒度 = **整张出库单**(outbound_no),该单的明细一并带入
· **纯引用,不记用量** —— 不存"本任务用多少"
为什么是明细级(一行 = MOM trans_outbound 的一行)而不是单据级:
MOM 的 trans_outbound 是明细行,物料名/规格要经 COALESCE 三表 JOIN
(stock_buy/stock_semi/stock_product → material_base) 才能解析。Track 跨库
无法 JOIN,只存单号的话每次展示都要打 MOM —— MOM 挂了就看不到已挂内容。
存快照后 Track 自包含,与既有 product_outbounds 同一套存档哲学。
成本实测可忽略:517 张单平均 2.64 条明细,65% 是单条明细。
⚠️ 与 product_outbounds 的区别(容易混淆):
product_outbounds 是「MOM 出库回调 → 产品详情只读展示」,挂在 **产品** 上、
单向存档;本表是「人在界面上选择 → 挂到 **任务** 上」、可增可删。
唯一约束 (task_id, mom_line_id):
防同一条出库明细被重复挂到同一任务(重复提交 / 前端重放 / 并发点击)。
mom_line_id 是**跨库逻辑外键**(MOM 库 trans_outbound.id),无物理约束 ——
与 assignee_id 指向 MOM sys_user 同一类做法。MOM 库若重建会让自增 ID 错位,
故同时冗余 outbound_no 供人工核对。
本迁移只建表,不写入任何数据。存量任务没有挂载,任务详情上不会显示物料 ——
预期行为。
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID
revision: str = "n1o2p3q4r5s6"
down_revision: Union[str, None] = "m1n2o3p4q5r6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"task_outbound_materials",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False,
comment="主键"),
sa.Column("task_id", UUID(as_uuid=True),
sa.ForeignKey("tasks.id"), nullable=False, comment="所属任务ID"),
sa.Column("mom_line_id", sa.Integer(), nullable=False,
comment="MOM 出库明细行ID(逻辑外键→MOM trans_outbound.id)"),
sa.Column("outbound_no", sa.String(100), nullable=False,
comment="MOM 出库单号(批量出库多商品共用,故本表按明细行成行)"),
sa.Column("sku", sa.String(100), nullable=True, comment="物料SKU"),
sa.Column("material_name", sa.String(255), nullable=True,
comment="物料名称(经 COALESCE 三表 JOIN 解析后快照)"),
sa.Column("spec_model", sa.String(255), nullable=True, comment="规格型号快照"),
sa.Column("quantity", sa.Numeric(19, 4), nullable=True,
comment="出库数量(出库单原值,不是本任务用量)"),
sa.Column("unit_price", sa.Numeric(19, 2), nullable=True, comment="出库单价"),
sa.Column("outbound_type", sa.String(50), nullable=True,
comment="出库类型 SALES/USE/PRODUCTION/LOSS/REPAIR(只存不判)"),
sa.Column("consumer_name", sa.String(100), nullable=True,
comment="领用人/客户(自由填写,非可靠标识)"),
sa.Column("operator_name", sa.String(100), nullable=True,
comment="MOM 侧操作员"),
sa.Column("warehouse_location", sa.String(100), nullable=True,
comment="出库库位快照"),
sa.Column("outbound_time", sa.DateTime(timezone=True), nullable=True,
comment="MOM 记录的出库时间(写入时已按 +08:00 补全时区)"),
sa.Column("added_by", sa.String(64), nullable=True,
comment="挂载人ID(逻辑外键→MOM sys_user)"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
comment="挂载时间"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("task_id", "mom_line_id",
name="uq_task_outbound_materials_task_line"),
comment="任务挂载的 MOM 出库物料 — 一行 = MOM 出库单的一条明细",
)
# 任务详情按 task_id 拉已挂物料
op.create_index(op.f("ix_task_outbound_materials_task_id"),
"task_outbound_materials", ["task_id"], unique=False)
# 按出库单号分组展示 / 反查"这张单被哪些任务用过"
op.create_index(op.f("ix_task_outbound_materials_outbound_no"),
"task_outbound_materials", ["outbound_no"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_task_outbound_materials_outbound_no"),
table_name="task_outbound_materials")
op.drop_index(op.f("ix_task_outbound_materials_task_id"),
table_name="task_outbound_materials")
op.drop_table("task_outbound_materials")

View File

@ -0,0 +1,48 @@
"""add_product_outbounds_source
Revision ID: o1p2q3r4s5t6
Revises: n1o2p3q4r5s6
Create Date: 2026-09-23
给 product_outbounds 加「来源」列
--------------------------------------------------------------------------
背景:product_outbounds 原先只有一条写入路径 —— MOM 出库回调自动存档(设备
自己被发走时,按 SN 匹配后落一行)。
现在多了一条:**创建产品时人工从 MOM 出库单里勾选**并挂钩,让「这台设备对应
MOM 的哪张出库单」可以在建档时就录进去,而不是只能等 MOM 推送。
两条路径写的是同一张表、同一个语义(产品 ↔ 出库单),所以不该拆表 —— 拆开会让
产品详情要展示两张卡片。加一列标来源即可,排查时也能一眼看出这行是谁写的。
webhook —— MOM 出库回调自动存档(存量行全是这个)
manual —— 人工在界面(创建产品 / 产品详情追加)挂的
存量行按 webhook 回填(该列上线前只有回调这一条路径,语义确定,不存在猜的问题)。
幂等:ADD COLUMN IF NOT EXISTS,可重复执行。
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "o1p2q3r4s5t6"
down_revision: Union[str, None] = "n1o2p3q4r5s6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"ALTER TABLE product_outbounds "
"ADD COLUMN IF NOT EXISTS source varchar(16) NOT NULL DEFAULT 'webhook'"
)
op.execute(
"COMMENT ON COLUMN product_outbounds.source IS "
"'来源: webhook(MOM回调自动存档) | manual(人工挂载)'"
)
def downgrade() -> None:
op.execute("ALTER TABLE product_outbounds DROP COLUMN IF EXISTS source")

View File

@ -0,0 +1,120 @@
"""add_product_scraps
Revision ID: p1q2r3s4t5u6
Revises: o1p2q3r4s5t6
Create Date: 2026-09-23
生产报废记录(product_scraps)
--------------------------------------------------------------------------
Track 发起的「领用物料在生产中报废」。料一经出库领用,那条库存行的可用量
就已经扣掉了,所以走不了 MOM 的标准库存行报废;MOM 自己的答案是逆向物流的
「从出库单退回(不良品)」→ 在管不良品 → 报废。Track 侧通过 MOM 的内部接口
(/api/v1/internal/production-scrap)一次调用完成,本表存回执与关联。
为什么挂在**产品**维度而不是任务维度:
料是领给这台**设备**的,不是领给某个人的。一台设备会经历多个任务、多个人的
手(生产领料 → 装配 → 测试)。测试时摔坏的外壳是生产的人领的、挂在生产任务
下 —— 若本表挂任务维度,测试在自己的任务里根本看不到它,「谁发现谁报」就落
不了地。所以可见范围跟设备走,责任归属跟实际发生走(applicant 记在 MOM 单上)。
跨设备的防护不靠隐藏,靠写入前校验 mom_line_id 确实挂在这台设备上。
为什么存快照(outbound_no / material_name / spec_model / sku / consumer_name):
mom_line_id 是跨库逻辑外键(指向 MOM trans_outbound.id),MOM 侧数据被清理时
就查不到了;且列表页若每条都跨库查,慢且脆。快照让「报了什么」永远看得见。
为什么**不**存金额:
金额由 MOM 在执行报废时算(trans_scrap.total_loss = 单价 × 数量),且取决于
执行时**实际扫码量**(MOM 允许少扫,受理量 ≠ 执行量)。在 Track 侧另存一份
就是第二份口径,迟早对不上。展示/统计一律按 scrap_request_no 实时回查 MOM。
唯一约束 source_ref:
幂等锚点,格式 <公司>:<Track单据号>,与发给 MOM 的值同一口径。
用户点两下、或超时后重试,必须命中同一行而不是插出第二行。
本迁移只建表,不写入任何数据。
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID
revision: str = "p1q2r3s4t5u6"
down_revision: Union[str, None] = "o1p2q3r4s5t6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"product_scraps",
sa.Column("id", UUID(as_uuid=True), nullable=False, comment="主键"),
sa.Column("product_id", UUID(as_uuid=True),
sa.ForeignKey("products.id"), nullable=False, comment="所属产品ID"),
sa.Column("serial_number", sa.String(16), nullable=True,
comment="产品序列号(冗余,便于按SN对账)"),
sa.Column("task_id", UUID(as_uuid=True),
sa.ForeignKey("tasks.id"), nullable=True,
comment="料所属的Track任务(可空,仅溯源用,不参与可见性判断)"),
sa.Column("mom_line_id", sa.Integer(), nullable=False,
comment="报废对象:MOM trans_outbound.id(出库明细行)"),
sa.Column("outbound_no", sa.String(100), nullable=True,
comment="MOM 出库单号(快照)"),
sa.Column("material_name", sa.String(255), nullable=True, comment="物料名称(快照)"),
sa.Column("spec_model", sa.String(255), nullable=True, comment="规格型号(快照)"),
sa.Column("sku", sa.String(100), nullable=True, comment="SKU(快照)"),
sa.Column("consumer_name", sa.String(100), nullable=True,
comment="原领用人(快照)。前端据此判断「报别人的料要额外确认」"),
sa.Column("quantity", sa.Numeric(19, 4), nullable=False, comment="本次报废数量"),
sa.Column("reason_category", sa.String(50), nullable=False,
server_default="PRODUCTION",
comment="报废原因分类码。生产报废恒为 PRODUCTION(生产损耗)"),
sa.Column("reason", sa.Text(), nullable=True, comment="报废原因说明(用户填写)"),
sa.Column("scrap_request_no", sa.String(100), nullable=False,
comment="MOM 报废申请单号(APR-SCRAP-...)。状态与金额按它回查 MOM"),
sa.Column("defective_goods_id", sa.Integer(), nullable=True,
comment="MOM 在管不良品台账 id(退回时生成)"),
sa.Column("mom_status", sa.Integer(), nullable=False, server_default="0",
comment="MOM 报废单状态快照(0待审批/1已通过/2已驳回/3已执行/4已撤回),"
"展示时以实时回查为准"),
sa.Column("source_ref", sa.String(100), nullable=False,
comment="幂等锚点 <公司>:<Track单据号>,随请求发给 MOM,两边同一口径"),
sa.Column("submitted_by", sa.String(64), nullable=True,
comment="提交人 Track 用户名(即 MOM 账号),MOM 侧报废单的申请人就是他本人"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
comment="本行写入时间"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("source_ref", name="uq_product_scrap_source_ref"),
comment="生产报废记录 — Track 发起的领用物料报废,关联 MOM 报废申请单",
)
# 产品详情按 product_id 拉报废记录
op.create_index(op.f("ix_product_scraps_product_id"),
"product_scraps", ["product_id"], unique=False)
# 按 SN 排查/对账
op.create_index(op.f("ix_product_scraps_serial_number"),
"product_scraps", ["serial_number"], unique=False)
# 溯源到任务
op.create_index(op.f("ix_product_scraps_task_id"),
"product_scraps", ["task_id"], unique=False)
# 按报废对象反查:这条料什么时候报过废
op.create_index(op.f("ix_product_scraps_mom_line_id"),
"product_scraps", ["mom_line_id"], unique=False)
# 回查 MOM 状态/金额
op.create_index(op.f("ix_product_scraps_scrap_request_no"),
"product_scraps", ["scrap_request_no"], unique=False)
# 「我提交的报废」
op.create_index(op.f("ix_product_scraps_submitted_by"),
"product_scraps", ["submitted_by"], unique=False)
def downgrade() -> None:
for name in (
"ix_product_scraps_submitted_by",
"ix_product_scraps_scrap_request_no",
"ix_product_scraps_mom_line_id",
"ix_product_scraps_task_id",
"ix_product_scraps_serial_number",
"ix_product_scraps_product_id",
):
op.drop_index(op.f(name), table_name="product_scraps")
op.drop_table("product_scraps")

View File

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

View File

@ -1,12 +1,26 @@
"""物料选择器 — 读 MOM material_base,按成品/半成品 category 手风琴分组"""
"""物料选择器 — 读 MOM material_base,按 category 手风琴分组(仅本部门)"""
from fastapi import APIRouter, Query, HTTPException, status, Depends
from pydantic import BaseModel
from app.core.config import settings
from app.core.mom_database import MomSessionLocal
from app.services.auth_service import get_current_user
from sqlalchemy import text
router = APIRouter(prefix="/materials", tags=["物料选择"])
# 部门隔离:只放行本部门 category 前缀(IRIS/…)。
#
# ⚠️ 必须用**前缀** LIKE,不能反推成 ILIKE '%IRIS%':两套实例共用同一个 MOM 库,
# LICA 的物料是 `LICA/<中文>`(LICA/生产配件 687、LICA/销售产品 89、
# LICA/维修服务 16 …),而 IRIS 分类树里另有 `IRIS/成品/LICA/…`
# (野外便携 59 / 无人机 38 / 实验室内 34 / 高塔监测 30,共 171 条)——
# 那是**挂在 IRIS 名下、给 LICA 做的成品**,本来就属于本部门。
# 前缀匹配天然把前者排除、把后者包含,不需要再加特例。
#
# 另注:IRIS 的分类是多段式(`IRIS/半成品/无人机U`、`IRIS/原材料/光学/光电Opt1`),
# 拿「成品/半成品」这类类型词过滤没有意义,一律走前缀。
CATEGORY_PREFIX_LIKE = f"{settings.MATERIAL_CATEGORY_PREFIX}%"
# ============================================================
# 响应模型
@ -38,7 +52,7 @@ def get_material_groups(
):
"""
按 category 分组汇总,前端渲染手风琴外层。
只返回成品/半成品分类。
只返回本部门(ORG_DEPARTMENT)名下的分类。
"""
db = MomSessionLocal()
try:
@ -47,23 +61,29 @@ def get_material_groups(
SELECT category, COUNT(*) AS count
FROM material_base
WHERE is_enabled = TRUE
AND category LIKE :cat_prefix
AND (name ILIKE :kw OR spec_model ILIKE :kw)
GROUP BY category
ORDER BY category
""")
result = db.execute(sql, {"kw": f"%{keyword.strip()}%"})
result = db.execute(
sql, {"cat_prefix": CATEGORY_PREFIX_LIKE, "kw": f"%{keyword.strip()}%"}
)
else:
sql = text("""
SELECT category, COUNT(*) AS count
FROM material_base
WHERE is_enabled = TRUE
AND category LIKE :cat_prefix
GROUP BY category
ORDER BY category
""")
result = db.execute(sql)
result = db.execute(sql, {"cat_prefix": CATEGORY_PREFIX_LIKE})
rows = result.fetchall()
return [MaterialGroup(category=row.category, count=row.count) for row in rows]
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
@ -82,6 +102,10 @@ def get_material_items(
):
"""
获取指定 category 下的物料条目,前端展开手风琴时懒加载。
这里同样要加部门前缀条件(纵深防御):`category` 完全由客户端提供,
只靠 `category = :cat` 精确匹配的话,构造一个跨部门的 category 就能
把别的部门的物料捞出来。
"""
db = MomSessionLocal()
try:
@ -91,13 +115,20 @@ def get_material_items(
COALESCE(unit, '') AS unit, is_enabled
FROM material_base
WHERE is_enabled = TRUE
AND category LIKE :cat_prefix
AND category = :cat
AND (name ILIKE :kw OR spec_model ILIKE :kw)
ORDER BY name
LIMIT :lim
""")
result = db.execute(
sql, {"cat": category, "kw": f"%{keyword.strip()}%", "lim": limit}
sql,
{
"cat_prefix": CATEGORY_PREFIX_LIKE,
"cat": category,
"kw": f"%{keyword.strip()}%",
"lim": limit,
},
)
else:
sql = text("""
@ -105,11 +136,14 @@ def get_material_items(
COALESCE(unit, '') AS unit, is_enabled
FROM material_base
WHERE is_enabled = TRUE
AND category LIKE :cat_prefix
AND category = :cat
ORDER BY name
LIMIT :lim
""")
result = db.execute(sql, {"cat": category, "lim": limit})
result = db.execute(
sql, {"cat_prefix": CATEGORY_PREFIX_LIKE, "cat": category, "lim": limit}
)
rows = result.fetchall()
return [
@ -124,6 +158,8 @@ def get_material_items(
)
for row in rows
]
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,

View File

@ -0,0 +1,146 @@
"""MOM 出库单只读查询 — 供「产品/任务挂载出库物料」时搜索选择
直连 MOM 库,SQL 都在 `app/services/mom_outbound_service.py`。
为什么不用 MOM 现成的 `GET /api/v1/outbound`:那个接口要 JWT +
permission_required,且对非特权账号按 `consumer_name` 做行级隔离 —— Track 用
服务账号调只能拿到该账号名下的单,不是全量。详见服务模块顶部的说明。
═══ 可见范围(本文件的重点)═══
本实例**没有业务分组**,可见范围只有一层,且全部在
`mom_outbound_service` 里以常量化形式钉死:
1. **公司隔离**:物料分类前缀本公司(`IRIS/%`),与物料选择器同一套口径 ——
出库单归属哪个公司,由它开出去的那条物料挂在谁的分类树下决定。
2. **跨部门例外**:`config.EXTRA_VISIBLE_CONSUMERS` 里的领用人,跨部门领料时
他们的单不落在本公司前缀里,但仍要放行(否则整批漏掉)。
⚠️ 安全不变量:**界面筛选只能收窄,绝不能放大。**
`keyword` / `start_date` / `end_date` / `consumer` 一律拼成 AND 条件;
可见范围由服务层固定,客户端传什么参数都改不了它。
⚠️ 与 LICA 实例的差异:LICA 那边出库单还要按**业务分组**再收敛一层
(范围 ∩ 组 ∩ 个人),本实例没有分组体系,故 `group_id` 参数**不存在**
——不要为了「对齐」而加回来,那会引入一份没有数据支撑的过滤。
"""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel
from app.services import mom_outbound_service
from app.services.auth_service import get_current_user
router = APIRouter(prefix="/mom-outbounds", tags=["MOM出库单"])
# ============================================================
# 响应模型
# ============================================================
class MomOutboundLine(BaseModel):
"""出库单的一条物料明细。line_id 即挂载时提交的 mom_line_ids 元素。"""
line_id: int
sku: str = ""
material_name: str = ""
spec_model: str = ""
# 用 float 而非 Decimal:Pydantic v2 会把 Decimal 序列化成字符串,
# 前端拿到 "5.0000" 不好直接参与计算。数量量级很小(实测 1~186),float 足够。
quantity: float | None = None
unit_price: float | None = None
returned_quantity: float | None = None
outbound_type: str = ""
# 出库类型的中文名,由服务层按 MOM 码表下发(前端不再自建一份映射)
outbound_type_label: str = ""
consumer_name: str = ""
operator_name: str = ""
warehouse_location: str = ""
outbound_time: datetime | None = None
# ⚠️ MOM 的 request_id 是最近才加的列,存量单据**全为空**(无从回填)。
# 前端对空值应显示「无关联申请单」而不是留白。
request_no: str = ""
class MomOutboundOrder(BaseModel):
"""一张出库单(批量出库多商品共用一个单号,故带 N 条明细)。"""
outbound_no: str
outbound_time: datetime | None = None
outbound_type: str = ""
outbound_type_label: str = ""
consumer_name: str = ""
operator_name: str = ""
line_count: int = 0
total_quantity: float | None = None
lines: list[MomOutboundLine] = []
class MomOutboundSearchResponse(BaseModel):
orders: list[MomOutboundOrder]
total: int
# ============================================================
# 端点
# ============================================================
@router.get("", response_model=MomOutboundSearchResponse)
async def search_mom_outbounds(
keyword: str = Query("", description="搜索:出库单号 / 物料名称 / 规格型号 / SKU / 领用人"),
start_date: str = Query("", description="起始日期 YYYY-MM-DD(含当日)"),
end_date: str = Query("", description="截止日期 YYYY-MM-DD(含当日)"),
consumer: str = Query("", description="按领用人(中文名)过滤"),
skip: int = Query(0, ge=0, description="跳过**单据数**"),
limit: int = Query(20, ge=1, le=100, description="返回**单据数**"),
current_user: dict = Depends(get_current_user),
):
"""按**单据**分页搜索本部门(公司)的 MOM 出库单,同时带回每张单的明细。
结果受两层约束:可见范围(公司前缀 + 跨部门例外,服务层钉死)
+ 界面上的筛选条件。
⚠️ skip / limit 的粒度是**单据**不是明细行 —— 一张单最多 55 条明细,
实测平均 2.64 条。前端按单据展示、展开看明细。
⚠️ 本端点是 `async def` 是因为 MOM 查询走 `run_in_threadpool`(内部是同步
psycopg2,直连阻塞事件循环);本实例不做范围解析,故无需 AsyncSession。
"""
try:
# 界面筛选:多选不提供,单选即收窄;空串 = 不筛
picked = consumer.strip()
consumers = {picked} if picked else None
orders, total = await run_in_threadpool(
mom_outbound_service.search_outbound_orders,
keyword=keyword,
start_date=start_date,
end_date=end_date,
skip=skip,
limit=limit,
consumers=consumers,
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"MOM 出库单查询失败: {str(e)}",
)
return MomOutboundSearchResponse(orders=orders, total=total)
@router.get("/consumers", response_model=list[str])
async def list_mom_outbound_consumers(
current_user: dict = Depends(get_current_user),
):
"""本部门出库单里出现过的**领用人姓名**(去重、按出现次数降序),供前端下拉。
⚠️ **同样受可见范围约束** —— 下拉里绝不能出现用户本来就看不到的人名,
否则等于把范围外的人员信息漏出去。
"""
try:
return await run_in_threadpool(mom_outbound_service.list_consumer_names)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"MOM 领用人列表查询失败: {str(e)}",
)

View File

@ -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,

View File

@ -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(

View File

@ -1,6 +1,7 @@
"""用户列表 — 对接 MOM sys_user"""
from fastapi import APIRouter, Query, HTTPException, status
from pydantic import BaseModel
from app.core.config import settings
from app.core.mom_database import MomSessionLocal
from sqlalchemy import text
@ -20,42 +21,32 @@ class UserOption(BaseModel):
def list_users(
keyword: str = Query("", description="按用户名/姓名模糊搜索"),
limit: int = Query(100, ge=1, le=500),
dept: str = Query("IRIS", description="部门过滤"),
dept: str = Query("", description="已废弃:部门由服务端按 ORG_DEPARTMENT 钉死,此参数不参与过滤"),
):
"""获取 MOM 系统用户列表,默认只返回 IRIS 部门"""
"""获取 MOM 系统用户列表,只返回本部门(ORG_DEPARTMENT)人员"""
# 部门隔离由服务端钉死:无论客户端传什么(含旧版 App / 旧前端里写死的
# dept=IRIS),一律只按 ORG_DEPARTMENT 过滤。这样同一份 App 源码不必按
# 部门分叉。
#
# 这里刻意【不做】「查询异常就退回全表」的降级:那等于把另一个部门的人员
# 名单也列出来供本部门挑选,是跨部门数据泄漏。查不出来就报错 ——
# 宁可查不出,不可查过头。
db = MomSessionLocal()
try:
# 尝试按部门过滤;若 sys_user 无 department 列则降级全量查询
try:
base_sql = """
SELECT id, username,
SPLIT_PART(username, '/', 1) AS full_name,
COALESCE(department, '') AS department
FROM sys_user
WHERE department = :dept
"""
params = {"dept": dept, "lim": limit}
if keyword.strip():
sql = text(base_sql + " AND username ILIKE :kw ORDER BY username LIMIT :lim")
params["kw"] = f"%{keyword.strip()}%"
else:
sql = text(base_sql + " ORDER BY username LIMIT :lim")
rows = db.execute(sql, params).fetchall()
except Exception:
# 降级:不使用 department 列过滤
fallback_sql = """
SELECT id, username,
SPLIT_PART(username, '/', 1) AS full_name,
'' AS department
FROM sys_user
"""
params = {"lim": limit}
if keyword.strip():
sql = text(fallback_sql + " WHERE username ILIKE :kw ORDER BY username LIMIT :lim")
params["kw"] = f"%{keyword.strip()}%"
else:
sql = text(fallback_sql + " ORDER BY username LIMIT :lim")
rows = db.execute(sql, params).fetchall()
base_sql = """
SELECT id, username,
SPLIT_PART(username, '/', 1) AS full_name,
COALESCE(department, '') AS department
FROM sys_user
WHERE department = :dept
"""
params = {"dept": settings.ORG_DEPARTMENT, "lim": limit}
if keyword.strip():
sql = text(base_sql + " AND username ILIKE :kw ORDER BY username LIMIT :lim")
params["kw"] = f"%{keyword.strip()}%"
else:
sql = text(base_sql + " ORDER BY username LIMIT :lim")
rows = db.execute(sql, params).fetchall()
return [
UserOption(
@ -66,6 +57,8 @@ def list_users(
)
for row in rows
]
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,

View File

@ -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:

View File

@ -7,6 +7,7 @@ from app.api.v1.endpoints.dashboard import router as dashboard_router
from app.api.v1.endpoints.auth import router as auth_router
from app.api.v1.endpoints.print import router as print_router
from app.api.v1.endpoints.materials import router as materials_router
from app.api.v1.endpoints.mom_outbounds import router as mom_outbounds_router
from app.api.v1.endpoints.users import router as users_router
from app.api.v1.endpoints.upload import router as upload_router
from app.api.v1.endpoints.records import router as records_router
@ -28,6 +29,7 @@ api_router.include_router(products_router)
api_router.include_router(tasks_router)
api_router.include_router(print_router)
api_router.include_router(materials_router)
api_router.include_router(mom_outbounds_router)
api_router.include_router(users_router)
api_router.include_router(upload_router)
api_router.include_router(records_router)

View File

@ -33,6 +33,39 @@ class Settings(BaseSettings):
# ---- MOM 仓储系统回调 Webhook(Track 作为接收方,验签用) ----
TRACK_WEBHOOK_KEY: str | None = None # MOM 回调 POST 时 Header X-API-Key 须等于此值
# ---- MOM 内部接口(Track 作为**调用方**,发起生产报废) ----
# ⚠️ 这是 Track **唯一**一处主动写 MOM 的通道。
# 读数据一律继续走直连 MOM 库(app/core/mom_database.py)—— 不要因为有了
# 这个客户端就把「读」也搬过来:MOM 的查询接口要 JWT + permission_required,
# 且对非特权账号按 consumer_name 做行级隔离,服务账号只能拿到自己名下的数据。
# 而「写」必须走接口:跨库直写会绕过 MOM 的全部业务校验、权限与审批。
MOM_INTERNAL_API_URL: str = "http://inventory_api:8000"
# 请求头 X-API-Key 的值,须与 MOM 侧 config.MOM_INTERNAL_API_KEY 一致。
# ⚠️ 未配置 → 报废提交直接 503(Fail-Closed),**不静默降级**:报废是写操作,
# 静默失败会让用户以为报上去了,实际 MOM 里什么都没有。
MOM_INTERNAL_API_KEY: str | None = None
# ---- 组织隔离 ----
# 同一套代码部署给不同部门时,只需改这两个值(+ compose 里的项目名/容器名/端口)。
# 全仓库的部门过滤点只有四处:登录、人员列表、物料选择器、MOM 出库单查询。
ORG_DEPARTMENT: str = "IRIS" # MOM sys_user.department 的取值
MATERIAL_CATEGORY_PREFIX: str = "IRIS/" # MOM material_base.category 的部门前缀
# ---- MOM 出库单的跨部门领用人例外 ----
# 出库单的公司隔离靠 material_base.category 前缀(见上)。
# 但这几个领用人(MOM trans_outbound.consumer_name,**纯姓名**,不带账号后缀)
# 经手的单据,即使物料分类不属于本部门,本实例也要能看见 —— 他们跨两个部门
# 领料,只按物料前缀过滤会把他们的单整批漏掉。
# ⚠️ 这是**放行**条件(SQL 里是 OR),与界面筛选(AND,只收窄)方向相反,
# 两者的集合运算必须分开写,混在一起就变成范围放大。
# ⚠️ 留空即关闭该例外,退化成纯前缀过滤。
EXTRA_VISIBLE_CONSUMERS: str = "依锐思,石利LICA"
@property
def EXTRA_VISIBLE_CONSUMERS_LIST(self) -> list[str]:
"""将逗号分隔的字符串解析为姓名 list(去空白、丢空项)"""
return [n.strip() for n in self.EXTRA_VISIBLE_CONSUMERS.split(",") if n.strip()]
@property
def CORS_ORIGINS_LIST(self) -> list[str]:
"""将 JSON 字符串解析为 Python list,供 CORSMiddleware 使用"""

View File

@ -2,8 +2,12 @@
from app.models.base import Base
from app.models.production_order import ProductionOrder
from app.models.product import Product
from app.models.product_outbound import ProductOutbound
from app.models.product_outbound_material import ProductOutboundMaterial
from app.models.product_scrap import ProductScrap
from app.models.task import Task, TaskRecord
from app.models.task_log import TaskLog
from app.models.task_outbound_material import TaskOutboundMaterial
from app.models.notification import Notification
from app.models.app_version import AppVersion
from app.models.message import ProductMessage
@ -14,8 +18,12 @@ __all__ = [
"Base",
"ProductionOrder",
"Product",
"ProductOutbound",
"ProductOutboundMaterial",
"ProductScrap",
"Task",
"TaskRecord",
"TaskOutboundMaterial",
"TaskLog",
"Notification",
"AppVersion",

View File

@ -0,0 +1,107 @@
"""产品出库记录 — MOM 出库回调在 Track 侧留下的单据存档
每收到一次 MOM 的出库回调就落一行,因此同一台设备可以有多行
(出库 → 撤回 → 再出库)。撤回**不删行**,只把 is_revoked 置真 ——
「出过又撤了」本身就是要看得见的历史。
与 products.overall_status / status 的分工:
· 那两列表达**当前事实**(这台设备此刻是不是已出库);
· 本表回答**归属**(这次出库对应 MOM 的哪张单、给了谁、谁办的)。
两者互不替代:设备被撤回回库后 overall_status 变回「已入库」,但那张出库单
仍然挂在本表上,只是标了已撤回。
为什么不用 products 加列:加列只能保住最后一次,而一台设备可以出库多次。
字段全部来自出库回调载荷(MOM 侧 services/outbound_service.create_outbound_batch)。
Track 只做存档,**不做任何基于 outbound_type 的业务判断** —— MOM 的码表尚未
冻结(models/outbound.py 两处注释分别为 5 值和 3 值,且无白名单校验)。
"""
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint
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 ProductOutbound(Base):
__tablename__ = "product_outbounds"
# 防 MOM 重推产生重复行。不能只约束 outbound_no —— MOM 的批量出库是
# 多个商品共用一个单号(见 MOM models/outbound.py 第 127 行注释)。
__table_args__ = (
UniqueConstraint("serial_number", "outbound_no", name="uq_product_outbound_sn_no"),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
)
# ---- 物理外键(关联本库 products) ----
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对账)",
)
# ---- MOM 单据上下文(全部来自出库回调载荷) ----
outbound_no: Mapped[str] = mapped_column(
String(100), nullable=False, comment="MOM 出库单号(批量出库多商品共用)",
)
request_no: Mapped[str | None] = mapped_column(
String(100), nullable=True, comment="MOM 出库申请单号",
)
consumer_name: Mapped[str | None] = mapped_column(
String(100), nullable=True,
comment="领用人/客户(MOM 侧扫码时自由填写,非可靠标识,刻意不做关联依据)",
)
applicant_name: Mapped[str | None] = mapped_column(
String(100), nullable=True, comment="申请人姓名(MOM 侧解析后传来,Track 不做 ID 反查)",
)
operator: Mapped[str | None] = mapped_column(
String(64), nullable=True, comment="MOM 侧实际扫码出库人",
)
outbound_type: Mapped[str | None] = mapped_column(
String(50), nullable=True,
comment="出库类型 SALES/USE/PRODUCTION(MOM 码表未冻结,本表只存不判)",
)
outbound_time: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, comment="MOM 记录的出库时间",
)
remark: Mapped[str | None] = mapped_column(
Text, nullable=True, comment="出库单备注",
)
# ---- 撤回(只置位不删行) ----
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="撤回时间",
)
# ---- 来源 ----
# 两条写入路径共用本表(同一语义:产品 ↔ 出库单),拆表会让产品详情要展示
# 两张卡片,所以只标来源。
# webhook —— MOM 出库回调自动存档(设备自己被发走时按 SN 匹配落一行)
# manual —— 人工在界面挂的(创建产品时勾选 / 产品详情追加)
source: Mapped[str] = mapped_column(
String(16), nullable=False, default="webhook", server_default="webhook",
comment="来源: webhook(MOM回调自动存档) | manual(人工挂载)",
)
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"<ProductOutbound {self.outbound_no} sn={self.serial_number}>"

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

@ -0,0 +1,136 @@
"""生产报废记录 — Track 发起的「领用物料在生产中报废」
回答两个问题:**这台设备的哪条料报废了**、**在 MOM 里对应哪张报废单**。
与 `product_outbounds`(产品 ↔ MOM 出库单)的分工:
· 那张表回答「这台设备对应 MOM 的哪张出库单」(货从哪来);
· 本表回答「这台设备上的哪条料废了、废了多少、MOM 怎么处理的」(货怎么没的)。
═══ 为什么挂在**产品**维度,而不是任务维度 ═══
料是领给**这台设备**的,不是领给某个人的。一台设备会经历多个任务、多个人的手
(生产领料 → 装配 → 测试),测试时摔坏了外壳——那条外壳是生产的人领的,
挂在生产任务下。若本表挂任务维度,测试在自己的任务里根本看不到它,
「谁发现谁报」就无从落地。
所以:**可见范围跟设备走**(打开这台设备就能看到它全部的料),
**责任归属跟实际发生走**(谁发现谁报,applicant 记在 MOM 报废单上)。
跨设备的防护不靠"隐藏",靠写入前校验 `mom_line_id` 确实挂在这台设备上(见 service)。
═══ 为什么不存金额 ═══
报废金额由 MOM 在执行报废时算(`trans_scrap.total_loss` = 单价 × 数量),
且**取决于执行时的实际扫码量**(MOM 允许少扫,受理量 ≠ 执行量)。
在 Track 侧另存一份就是第二份口径,迟早对不上。展示/统计时按
`scrap_request_no` 实时回查 MOM(见 mom_scrap_service)。
"""
import uuid
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Numeric, String, Text, UniqueConstraint
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 ProductScrap(Base):
__tablename__ = "product_scraps"
# 幂等锚点:外部单据号(Track 生成,随请求发给 MOM)。同一个号重发必须命中
# 同一行,而不是插出第二行 —— 用户点两下提交、或网络超时后重试都会走到这里。
__table_args__ = (
UniqueConstraint("source_ref", name="uq_product_scrap_source_ref"),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
)
# ---- 物理外键(关联本库 products) ----
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对账)",
)
# 料挂在哪条 Track 任务上(可空:允许直接按设备报,不强制挂任务)。
# 只作溯源用,**不参与可见性判断** —— 见模块头「为什么挂在产品维度」。
task_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id"), nullable=True, index=True,
comment="料所属的Track任务(可空,仅溯源用,不参与可见性判断)",
)
# ---- 报废对象:MOM 出库明细行 ----
# = MOM trans_outbound.id,也就是 task_outbound_materials.mom_line_id。
# ★ 这是跨库逻辑外键(无物理约束),MOM 侧数据被清理时可能查不到。
mom_line_id: Mapped[int] = mapped_column(
nullable=False, index=True,
comment="报废对象:MOM trans_outbound.id(出库明细行)",
)
# ---- 快照(MOM 行被删也要能显示,且列表页不必跨库查询) ----
outbound_no: Mapped[str | None] = mapped_column(
String(100), nullable=True, comment="MOM 出库单号(快照)",
)
material_name: Mapped[str | None] = mapped_column(
String(255), nullable=True, comment="物料名称(快照)",
)
spec_model: Mapped[str | None] = mapped_column(
String(255), nullable=True, comment="规格型号(快照)",
)
sku: Mapped[str | None] = mapped_column(
String(100), nullable=True, comment="SKU(快照)",
)
consumer_name: Mapped[str | None] = mapped_column(
String(100), nullable=True,
comment="原领用人(快照)。前端据此判断「报别人的料要额外确认」",
)
quantity: Mapped[Decimal] = mapped_column(
Numeric(19, 4), nullable=False, comment="本次报废数量",
)
reason_category: Mapped[str] = mapped_column(
String(50), nullable=False, default="PRODUCTION", server_default="PRODUCTION",
comment="报废原因分类码。生产报废恒为 PRODUCTION(生产损耗),与 MOM 侧码表一致",
)
reason: Mapped[str | None] = mapped_column(
Text, nullable=True, comment="报废原因说明(用户填写)",
)
# ---- MOM 回执 ----
scrap_request_no: Mapped[str] = mapped_column(
String(100), nullable=False, index=True,
comment="MOM 报废申请单号(APR-SCRAP-...)。状态与金额都按它回查 MOM",
)
defective_goods_id: Mapped[int | None] = mapped_column(
nullable=True, comment="MOM 在管不良品台账 id(退回时生成)",
)
# MOM 报废单状态快照:0待审批 1已通过 2已驳回 3已执行 4已撤回。
# ⚠️ 这是**写入当时**的快照,会过期(MOM 里审批、执行后 Track 不知道)。
# 展示时以实时回查为准,本列只用于「MOM 暂时查不到时不至于没得显示」。
mom_status: Mapped[int] = mapped_column(
nullable=False, default=0, server_default="0",
comment="MOM 报废单状态快照(0待审批/1已通过/2已驳回/3已执行/4已撤回),展示时以实时回查为准",
)
# ---- 幂等与归属 ----
source_ref: Mapped[str] = mapped_column(
String(100), nullable=False,
comment="幂等锚点 <公司>:<Track单据号>,随请求发给 MOM,两边同一口径",
)
submitted_by: Mapped[str | None] = mapped_column(
String(64), nullable=True, index=True,
comment="提交人 Track 用户名(即 MOM 账号)。MOM 侧报废单的申请人就是他本人",
)
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"<ProductScrap {self.scrap_request_no} sn={self.serial_number} "
f"line={self.mom_line_id}>")

View File

@ -89,6 +89,13 @@ class Task(Base):
records: Mapped[list["TaskRecord"]] = relationship(
"TaskRecord", back_populates="task", lazy="selectin", cascade="all, delete-orphan",
)
# 本任务挂载的 MOM 出库物料(明细级快照)。创建任务时选、之后可追加,
# 见 models/task_outbound_material.py 的设计说明。
outbound_materials: Mapped[list["TaskOutboundMaterial"]] = relationship(
"TaskOutboundMaterial", back_populates="task", lazy="selectin",
cascade="all, delete-orphan",
order_by="TaskOutboundMaterial.created_at",
)
def __repr__(self) -> str:
return f"<Task {self.task_name}>"

View File

@ -0,0 +1,103 @@
"""任务挂载的 MOM 出库物料 — 明细级快照
回答「这个任务用了哪些 MOM 出库物料」。口径已与业务确认:
· 选择粒度 = **整张出库单**(outbound_no),该单据的明细一并带入
—— 不存在"单里混了无关物料"的情况
· **纯引用,不记用量**:quantity 是出库单原值,不是"本任务用了多少"
为什么存明细级快照,而不是只存 outbound_no 现查:
MOM 的 trans_outbound 是**明细行**(一行 = 一条物料),物料名/规格要经
COALESCE 三表 JOIN(stock_buy/stock_semi/stock_product → material_base)
才能解析出来。Track 跨库无法 JOIN,若只存单号,每次展示都要打 MOM ——
MOM 挂了就看不到已挂内容。存快照后 Track 自包含,与既有的
product_outbounds 是同一套存档哲学。
成本实测可忽略:517 张单平均 2.64 条明细,65% 是单条明细。
⚠️ mom_line_id 是**跨库逻辑外键**(指向 MOM 库 trans_outbound.id),无物理
约束 —— 与 assignee_id 指向 MOM sys_user 是同一类做法。MOM 库若重建会让
自增 ID 错位,所以同时冗余 outbound_no 供人工核对。
"""
import uuid
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Numeric, String, UniqueConstraint
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 TaskOutboundMaterial(Base):
__tablename__ = "task_outbound_materials"
# 防同一条出库明细被重复挂到同一任务(重复提交、前端重放、并发点击)
__table_args__ = (
UniqueConstraint("task_id", "mom_line_id",
name="uq_task_outbound_materials_task_line"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
# ---- 物理外键(关联本库 tasks) ----
task_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id"),
nullable=False, index=True, comment="所属任务ID",
)
# ---- 跨库逻辑外键(MOM 库 trans_outbound.id,无物理约束) ----
mom_line_id: Mapped[int] = mapped_column(
nullable=False, comment="MOM 出库明细行ID(逻辑外键→MOM trans_outbound.id)",
)
outbound_no: Mapped[str] = mapped_column(
String(100), nullable=False, index=True,
comment="MOM 出库单号(批量出库多商品共用,故本表按明细行成行)",
)
# ---- MOM 物料/单据快照(挂载时从 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 补全时区)",
)
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="挂载时间",
)
# ---- 关系 ----
task: Mapped["Task"] = relationship("Task", back_populates="outbound_materials")
def __repr__(self) -> str:
return f"<TaskOutboundMaterial {self.outbound_no} sku={self.sku}>"

View File

@ -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
# 幂等锚点:前端在**打开弹层时**生成一次,重试时复用同一个。
# 后端拼成 <公司>:<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):
"""扫码查询响应 — 产品信息 + 完整任务树(递归嵌套)"""
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}

View File

@ -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}

View File

@ -15,6 +15,7 @@ from app.core.security import (
)
from app.core.mom_database import MomSessionLocal
from app.core.logging import user_var
from app.core.roles import SUPER_ADMIN
from app.schemas.user import LoginResponse, UserResponse
security = HTTPBearer()
@ -24,15 +25,29 @@ def login(username: str, password: str) -> LoginResponse:
"""登录 — 签发双 Token(Access + Refresh)"""
db = MomSessionLocal()
try:
# 1. 普通用户:LIKE '%/username' 模糊匹配 MOM sys_user 表
# 1. LIKE '%/username' 定位 MOM sys_user 账号,再按部门收敛 ——
# 两套 Track 实例共用同一个 MOM 库,不加部门条件的话,另一个部门的
# 普通账号也能登进来。
#
# ⚠️ 唯一的例外是 SUPER_ADMIN:超管**跨部门放行**,供运维/管理员在
# 两个实例之间切换。其余角色(INBOUND / SUPERVISOR / WAREHOUSE_MGR /
# SALES)必须严格属于本部门(settings.ORG_DEPARTMENT)。
#
# 不匹配时统一报「用户名或密码错误」,不区分「账号不存在」与「存在但
# 不属于本部门」,避免给账号探测者提供线索。
from sqlalchemy import text
result = db.execute(
text(
"SELECT id, username, department, role, password_hash "
"FROM sys_user "
"WHERE username LIKE :pattern"
"WHERE username LIKE :pattern "
" AND (department = :dept OR role = :super_admin)"
),
{"pattern": f"%/{username}"},
{
"pattern": f"%/{username}",
"dept": settings.ORG_DEPARTMENT,
"super_admin": SUPER_ADMIN,
},
)
row = result.fetchone()

View File

@ -0,0 +1,398 @@
"""MOM 出库单只读查询 — 直连 MOM 库(跨库,无 ORM)
为什么直连库,而不是调 MOM 现成的 `GET /api/v1/outbound`:
那个接口要 JWT + `permission_required`,且对非特权账号按 `consumer_name`
做**行级隔离**(非特权用户只能看到自己名下的单)。Track 用服务账号去调只会
拿到该账号名下的数据、不是全量。Track 已有只读 MOM 连接
(`app/core/mom_database.py`,mom_cache 也在用),直连才是对的。
分页必须两段式(照抄 MOM `outbound_service.get_grouped_list` 的做法):
1) 先 `GROUP BY outbound_no` 分页,拿到本页的**单据号**
2) 再 `WHERE outbound_no IN (...)` 捞这些单的明细
⚠️ 绝不能对 join 后的宽表直接分页 —— 那是**明细行数**不是单据数。
`outbound_no` 不唯一(批量出库多商品共用),一张单最多 55 条明细,
实测 517 张单平均 2.64 条、65% 是单条。
物料名解析(MOM 自己没有视图,是代码里硬拼的):
`trans_outbound.(source_table, stock_id)` 多态指向三张库存表之一,再经它们的
`base_id` 回到 `material_base`。用 COALESCE 三路 LEFT JOIN 一次拉全。
⚠️ **不要用 `trans_outbound.sku` 做 JOIN** —— 它只是库存表 sku 的冗余快照,
不唯一,历史还可能漂移。
"""
import logging
from datetime import datetime, timedelta, timezone
from sqlalchemy import text
from app.core.config import settings
from app.core.mom_database import MomSessionLocal
logger = logging.getLogger(__name__)
# MOM 的 outbound_time 是 `timestamp without time zone`,存的是**北京墙上时间**。
BEIJING_TZ = timezone(timedelta(hours=8))
# 跨部门例外领用人 —— 这几个领用人的出库单,即使物料分类不属于本部门,本实例
# 也要能看见。定义与理由见 config.EXTRA_VISIBLE_CONSUMERS。
# 本模块只负责**执行**这条例外、不负责判定;改范围请改配置,不要改这里。
_EXTRA_CONSUMERS: list[str] = settings.EXTRA_VISIBLE_CONSUMERS_LIST
# 三张库存表 → material_base 的 JOIN 片段。
# 抽成常量是因为「搜索命中」与「拉明细」两处都要用,写歪一处两边就不一致了。
_STOCK_JOIN = """
LEFT JOIN stock_buy sb ON o.source_table = 'stock_buy' AND sb.id = o.stock_id
LEFT JOIN stock_semi ss ON o.source_table = 'stock_semi' AND ss.id = o.stock_id
LEFT JOIN stock_product sp ON o.source_table = 'stock_product' AND sp.id = o.stock_id
LEFT JOIN material_base b ON b.id = COALESCE(sb.base_id, ss.base_id, sp.base_id)
"""
# 明细行的公共 SELECT 列表(两处查询共用,保证返回字段一致)
_LINE_COLUMNS = """
o.id AS line_id, o.outbound_no, o.sku,
b.name AS material_name, b.spec_model,
o.quantity, o.unit_price, o.returned_quantity,
o.outbound_type, o.consumer_name, o.operator_name,
o.warehouse_location, o.outbound_time,
a.request_no
"""
# `trans_outbound.outbound_type` 的中文名 —— 码表源头是 MOM 前端的
# `inventory-web/src/views/outbound/index.vue::formatType`,这里照抄一份**统一
# 下发**(响应里的 outbound_type_label),前端不再自建映射,否则两边会开始漂移。
# ⚠️ 只用于展示,**不做任何业务判断** —— MOM 码表未冻结(`types/api.ts` 同注)。
# 库里存的只有 SALES/USE/PRODUCTION 三种,其余是按 MOM 下拉预留的。
_OUTBOUND_TYPE_LABELS = {
"SALES": "销售出库",
"USE": "内部领用",
"PRODUCTION": "生产出库",
"SCRAP": "报废",
"LOSS": "盘亏出库",
"REPAIR": "维修出库",
}
def describe_outbound_type(code: str | None) -> str:
"""出库类型码 → 中文名。
码表里没有的码**原样返回**(不吞成空串、也不显示「未知」):宁可把英文码
露在界面上让人一眼看出是漏配的码表,也不要静默成一句看不出问题的中文。
"""
raw = (code or "").strip()
return _OUTBOUND_TYPE_LABELS.get(raw.upper(), raw)
def _as_beijing(dt: datetime | None) -> datetime | None:
"""把 MOM 的 naive 北京时间补上 +08:00 偏移。
⚠️ 少了这一步,上层(尤其写进 Track 的 timestamptz 列时)会把它当 **UTC**
处理,前端在北京显示会整整差 8 小时。这个坑在 MOM 仓的 822f897 刚踩过
一次,这里统一在数据出口处理,不留给调用方去记得。
"""
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=BEIJING_TZ)
return dt.astimezone(BEIJING_TZ)
def _line_dict(row) -> dict:
"""把一行查询结果转成明细 dict(字段名与 Track 侧快照列对齐)。"""
return {
"line_id": row.line_id,
"sku": row.sku or "",
"material_name": row.material_name or "",
"spec_model": row.spec_model or "",
"quantity": row.quantity,
"unit_price": row.unit_price,
"returned_quantity": row.returned_quantity,
"outbound_type": row.outbound_type or "",
"outbound_type_label": describe_outbound_type(row.outbound_type),
"consumer_name": row.consumer_name or "",
"operator_name": row.operator_name or "",
"warehouse_location": row.warehouse_location or "",
"outbound_time": _as_beijing(row.outbound_time),
# MOM 的 request_id 是最近才加的列,存量 1364 条**全为 NULL**(无从回填,
# 见该仓 models/outbound.py 的注释)。UI 要对空值显示「无关联申请单」。
"request_no": row.request_no or "",
}
def _fetch_lines(db, outbound_nos: list[str]) -> dict[str, list[dict]]:
"""捞出指定单据的明细,按出库单号分组。
申请单必须 LEFT JOIN —— trans_outbound.request_id 存量全为 NULL,用 INNER
会把这批历史单的明细整批吞掉。
"""
if not outbound_nos:
return {}
rows = db.execute(
text(f"""
SELECT {_LINE_COLUMNS}
FROM trans_outbound o
{_STOCK_JOIN}
LEFT JOIN outbound_approval a ON a.id = o.request_id
WHERE o.outbound_no = ANY(:nos)
ORDER BY o.outbound_time DESC, o.id
"""),
{"nos": list(outbound_nos)},
).fetchall()
grouped: dict[str, list[dict]] = {}
for r in rows:
grouped.setdefault(r.outbound_no, []).append(_line_dict(r))
return grouped
def search_outbound_orders(
keyword: str = "",
start_date: str = "",
end_date: str = "",
skip: int = 0,
limit: int = 20,
consumers: set[str] | None = None,
) -> tuple[list[dict], int]:
"""按**单据**分页搜索 MOM 出库单,返回 `(单据列表, 单据总数)`。
keyword 命中范围:出库单号 / 物料SKU / 物料名称 / **规格型号** / 领用人。
start_date、end_date 为 `YYYY-MM-DD`,**含端点的整日**。
consumers:界面筛选的**领用人姓名**集合(AND 语义,只收窄)。
· `None` → 不限
· 非空集合 → 只返回这些领用人的单
· **空集合** → 一条都不返回(绝不退化成「不过滤」)
⚠️ 本函数只负责**执行**范围,不负责**判定**范围 —— 可见范围(公司前缀
+ 跨部门例外)在本模块内以常量化形式钉死,避免出现第二份口径。
"""
kw = keyword.strip()
# ⚠️ 日期参数用 None 而不是空串:写成 `:start = ''` 时 PostgreSQL **不保证
# 短路求值**,规划器仍会去算 `CAST('' AS timestamp)` 并直接报
# InvalidDatetimeFormat(实测踩到)。传 NULL + 显式 text 转换才安全。
params = {
"kw": kw,
"kw_like": f"%{kw}%" if kw else "",
"start": start_date.strip() or None,
"end": end_date.strip() or None,
# 公司隔离:与物料选择器同一套口径(material_base.category 前缀)。
# ⚠️ 必须是**前缀** LIKE,不能写成 ILIKE '%IRIS%':MOM 里 LICA 的物料是
# `LICA/<中文>`,而 IRIS 分类树里另有 `IRIS/成品/LICA/…`(171 条)——
# 后者本就属于本部门,前缀匹配天然区分得开。
"cat_prefix": f"{settings.MATERIAL_CATEGORY_PREFIX}%",
}
# 可见范围 = 公司前缀(范围)∪ 跨部门例外(放行)。
# · 前缀是**范围**:本部门物料开出去的单;
# · 例外是 OR:那几个领用人跨部门领料,只按前缀过滤会把他们的单整批漏掉。
# ⚠️ 例外为空时**整段不拼**:`= ANY(ARRAY[])` 虽然返回 false(不放大范围,
# 语义安全),但留一个恒假子句只会让这条 SQL 更难排查。
if _EXTRA_CONSUMERS:
company_clause = (
"(b.category LIKE :cat_prefix OR o.consumer_name = ANY(:extra_consumers))"
)
params["extra_consumers"] = _EXTRA_CONSUMERS
else:
company_clause = "b.category LIKE :cat_prefix"
where = f"""
WHERE (:kw = '' OR o.outbound_no ILIKE :kw_like
OR o.sku ILIKE :kw_like
OR b.name ILIKE :kw_like
OR b.spec_model ILIKE :kw_like
OR o.consumer_name ILIKE :kw_like)
AND {company_clause}
AND (CAST(:start AS text) IS NULL
OR o.outbound_time >= CAST(:start AS timestamp))
AND (CAST(:end AS text) IS NULL
OR o.outbound_time < CAST(:end AS timestamp) + interval '1 day')
"""
if consumers is not None:
if not consumers:
# ⚠️ 空集合必须**显式** false。绝不能拼成 `= ANY(empty)` 或让条件消失 ——
# 空范围退化成不过滤就是全量泄漏,这是本模块的底线。
where += "\n AND false"
else:
where += "\n AND o.consumer_name = ANY(:consumers)"
params["consumers"] = sorted(consumers)
db = MomSessionLocal()
try:
total = db.execute(
text(f"""
SELECT count(DISTINCT o.outbound_no)
FROM trans_outbound o
{_STOCK_JOIN}
{where}
"""),
params,
).scalar() or 0
if total == 0:
return [], 0
order_rows = db.execute(
text(f"""
SELECT o.outbound_no,
max(o.outbound_time) AS outbound_time,
max(o.outbound_type) AS outbound_type,
max(o.consumer_name) AS consumer_name,
max(o.operator_name) AS operator_name,
count(*) AS line_count,
sum(o.quantity) AS total_quantity
FROM trans_outbound o
{_STOCK_JOIN}
{where}
GROUP BY o.outbound_no
ORDER BY outbound_time DESC, o.outbound_no DESC
LIMIT :lim OFFSET :off
"""),
{**params, "lim": limit, "off": skip},
).fetchall()
lines_by_no = _fetch_lines(db, [r.outbound_no for r in order_rows])
orders = [
{
"outbound_no": r.outbound_no,
"outbound_time": _as_beijing(r.outbound_time),
"outbound_type": r.outbound_type or "",
"outbound_type_label": describe_outbound_type(r.outbound_type),
"consumer_name": r.consumer_name or "",
"operator_name": r.operator_name or "",
"line_count": r.line_count,
"total_quantity": r.total_quantity,
"lines": lines_by_no.get(r.outbound_no, []),
}
for r in order_rows
]
return orders, total
finally:
db.close()
def get_lines_by_ids(mom_line_ids: list[int]) -> list[dict]:
"""按 MOM 明细行 ID 取快照,供挂载到任务时生成 Track 侧快照。
查不到的 ID 会被**静默跳过** —— 由调用方比对数量后决定要不要提示用户
(MOM 库清理过数据时会命中这种情况)。
"""
if not mom_line_ids:
return []
db = MomSessionLocal()
try:
rows = db.execute(
text(f"""
SELECT {_LINE_COLUMNS}
FROM trans_outbound o
{_STOCK_JOIN}
LEFT JOIN outbound_approval a ON a.id = o.request_id
WHERE o.id = ANY(:ids)
"""),
{"ids": [int(i) for i in mom_line_ids]},
).fetchall()
out = []
for r in rows:
d = _line_dict(r)
d["outbound_no"] = r.outbound_no
out.append(d)
return out
finally:
db.close()
def get_lines_by_outbound_no(outbound_no: str) -> list[dict]:
"""按**出库单号**取该单的全部明细快照(供 MOM 出库回调存档用)。
为什么需要它:统一后的 `product_outbound_materials` 是**明细级**,而
MOM 的出库回调只带单号、不带明细 —— 不现查的话,这台设备上「领了什么料」
就永远是空的。
查不到返回空列表(调用方据此退化成单据级存档,而不是丢掉这张单)。
⚠️ 批量出库多个商品共用一个单号,所以这里可能返回多行,也可能一行都没有。
"""
no = (outbound_no or "").strip()
if not no:
return []
db = MomSessionLocal()
try:
rows = db.execute(
text(f"""
SELECT {_LINE_COLUMNS}
FROM trans_outbound o
{_STOCK_JOIN}
LEFT JOIN outbound_approval a ON a.id = o.request_id
WHERE o.outbound_no = :no
ORDER BY o.outbound_time DESC, o.id
"""),
{"no": no},
).fetchall()
out = []
for r in rows:
d = _line_dict(r)
d["outbound_no"] = r.outbound_no
out.append(d)
return out
finally:
db.close()
def list_consumer_names(consumers: set[str] | None = None) -> list[str]:
"""本部门出库单里出现过的**领用人姓名**(去重、按出现次数降序)。
给前端下拉用。`consumers` 的含义与 search_outbound_orders 完全一致
(None=不限 / 空集=空结果),**同样受可见范围约束** —— 下拉里不能出现
用户本来就看不到的人名,否则等于把范围外的人员信息漏出去。
可见范围的构造与 search_outbound_orders 保持一致(前缀 ∪ 跨部门例外)。
"""
if consumers is not None and not consumers:
return []
params: dict = {"cat_prefix": f"{settings.MATERIAL_CATEGORY_PREFIX}%"}
if _EXTRA_CONSUMERS:
company_clause = (
"(b.category LIKE :cat_prefix OR o.consumer_name = ANY(:extra_consumers))"
)
params["extra_consumers"] = _EXTRA_CONSUMERS
else:
company_clause = "b.category LIKE :cat_prefix"
sql = """
SELECT o.consumer_name, count(*) AS cnt
FROM trans_outbound o
{stock_join}
WHERE {company_clause}
AND o.consumer_name IS NOT NULL
AND o.consumer_name <> ''
""".format(stock_join=_STOCK_JOIN, company_clause=company_clause)
if consumers is not None:
sql += " AND o.consumer_name = ANY(:consumers)"
params["consumers"] = sorted(consumers)
sql += " GROUP BY o.consumer_name ORDER BY cnt DESC, o.consumer_name"
db = MomSessionLocal()
try:
return [r[0] for r in db.execute(text(sql), params).fetchall()]
finally:
db.close()
def get_orders_by_line_ids(mom_line_ids: list[int]) -> list[dict]:
"""按明细行 ID 归并出**单据级**信息(同一张单只返回一条)。
用户是按整张出库单勾选的,而 product_outbounds 是单据级(一行 = 一张单),
所以提交上来的明细 ID 要先归并回单据再落库。
单据头的字段(出库时间/类型/领用人/操作员/申请单号)同单内必然一致,
取任意一条即可。
"""
merged: dict[str, dict] = {}
for ln in get_lines_by_ids(mom_line_ids):
merged.setdefault(ln["outbound_no"], {
"outbound_no": ln["outbound_no"],
"outbound_time": ln["outbound_time"],
"outbound_type": ln["outbound_type"],
"consumer_name": ln["consumer_name"],
"operator_name": ln["operator_name"],
"request_no": ln["request_no"],
})
return list(merged.values())

View File

@ -0,0 +1,136 @@
"""MOM 内部接口客户端 —— Track 主动调用 MOM 的**唯一**通道
═══════════════════════════════════════════════════════════════════════════
为什么这里用 HTTP,而「读」却直连 MOM 库
═══════════════════════════════════════════════════════════════════════════
Track 读 MOM 一律走 `app/core/mom_database.py` 直连只读库(见 mom_outbound_service
的模块头论证:MOM 的查询接口要 JWT + permission_required,且对非特权账号按
`consumer_name` 做行级隔离,服务账号只能拿到自己名下的数据)。
但「写」不能直连库:跳过 MOM 的业务校验、权限与审批流,会写出 MOM 自己都不认的数据。
所以走 MOM 为此新开的内部接口(X-API-Key 鉴权,不走 JWT —— Track 没有也不需要
MOM 账号,申请人身份由请求体显式携带)。
⚠️ 别因为有了本模块就把「读」也搬过来。两条路各有各的理由,不要合并。
═══════════════════════════════════════════════════════════════════════════
失败语义(对用户要诚实)
═══════════════════════════════════════════════════════════════════════════
报废是**写**操作,静默失败最伤人 —— 用户以为报上去了,MOM 里其实什么都没有。
所以这里不吞任何错误:连不上、鉴权失败、被 MOM 拒绝,都以带中文原因的形式抛出去,
由端点转成用户看得懂的提示。
"""
import logging
import httpx
from app.core.config import settings
logger = logging.getLogger(__name__)
# 超时:MOM 侧要做「退回 + 建报废申请」两次写库,给宽一点。
# 但也不能无限等 —— 请求挂住时用户会一直转圈,宁可失败让他重试(有幂等兜底)。
_TIMEOUT = httpx.Timeout(30.0, connect=10.0)
_PATH = "/api/v1/internal/production-scrap"
class MomScrapError(Exception):
"""调 MOM 报废接口失败。
message 是**给用户看的中文原因**,端点直接把它转成响应 detail,
不要再包一层「报废失败: ...」——
MOM 返回的文案本身已经说清了(如「退回数量(9999)超出可退额度(5)」)。
"""
def __init__(self, message: str, *, mom_status_code: int | None = None,
mom_code: int | None = None):
super().__init__(message)
self.message = message
self.mom_status_code = mom_status_code
self.mom_code = mom_code
async def submit_production_scrap(*, outbound_id: int, return_qty: float,
track_ref: str, applicant_id: int,
reason: str | None = None,
operator: str = "Track系统") -> dict:
"""提交生产报废 → MOM 的 `POST /api/v1/internal/production-scrap`。
一次调用完成「退回(不良品) → 在管不良品 → 提交报废申请(待审批)」。
返回 MOM 的 `data` 段(含 `scrap_request_no` / `defective_goods_id` / `duplicate`)。
:param outbound_id: MOM `trans_outbound.id`,即 Track 侧的 `mom_line_id`
:param track_ref: Track 侧生成的唯一单据号(幂等锚点),重试必须传同一个
:param applicant_id: MOM `sys_user.id`。Track 的 `user.sub` 就是它,
所以 MOM 里显示的申请人就是**实际操作人本人**,不是服务账号
"""
api_key = (settings.MOM_INTERNAL_API_KEY or "").strip()
if not api_key:
# Fail-Closed:不静默降级成「假装成功」
raise MomScrapError(
"报废功能未启用:Track 未配置 MOM_INTERNAL_API_KEY,请联系管理员"
)
base_url = (settings.MOM_INTERNAL_API_URL or "").rstrip("/")
if not base_url:
raise MomScrapError("报废功能未启用:Track 未配置 MOM_INTERNAL_API_URL")
payload = {
# 公司 = 部门。MOM 会拿它跟出库物料实际所属公司强校验,不符直接拒绝。
'company_name': settings.ORG_DEPARTMENT,
'outbound_id': int(outbound_id),
'return_qty': float(return_qty),
# ★ 恒为 True:本流程 = 退回并提交报废申请。
# false 那个分支(只登记为在管不良品)留给以后按需开放。
'submit_scrap': True,
'track_ref': track_ref,
# 生产损耗。分类**必须显式传**,不能让 MOM 从来源推导 ——
# 生产报废与 MOM 手工报的不良品退回共用同一张 trans_defective_goods 表,
# 一推导就会把生产损失静默算成库存损失。
'reason_category': 'PRODUCTION',
'reason': (reason or '').strip() or None,
'applicant_id': int(applicant_id),
'operator': operator,
}
url = f"{base_url}{_PATH}"
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
resp = await client.post(url, json=payload, headers={'X-API-Key': api_key})
except httpx.TimeoutException:
logger.warning(f"[MomScrap] 调用 MOM 超时 url={url} track_ref={track_ref}")
raise MomScrapError("提交报废超时:MOM 未在 30 秒内响应,请稍后用同一单据重试")
except httpx.HTTPError as e:
logger.error(f"[MomScrap] 连接 MOM 失败 url={url}: {e}")
raise MomScrapError(f"无法连接 MOM 报废接口:{e}")
# MOM 统一信封 {code, msg, data};非 JSON 响应说明打到了别的东西(如 nginx 错误页)
try:
body = resp.json()
except ValueError:
logger.error(f"[MomScrap] MOM 返回非 JSON(HTTP {resp.status_code}):{resp.text[:200]}")
raise MomScrapError(f"MOM 报废接口返回异常(HTTP {resp.status_code})")
mom_code = body.get('code')
mom_msg = (body.get('msg') or '').strip()
if resp.status_code != 200 or mom_code != 200:
# MOM 的文案本身就是中文且具体(含数量、额度等),直接透传,
# 不要在前面再加一层「报废失败:」,那只会把真正的信息挤到后面。
logger.warning(
f"[MomScrap] MOM 拒绝 HTTP {resp.status_code} code={mom_code} "
f"track_ref={track_ref}: {mom_msg}"
)
raise MomScrapError(
mom_msg or f"MOM 报废接口返回 HTTP {resp.status_code}",
mom_status_code=resp.status_code, mom_code=mom_code,
)
data = body.get('data') or {}
logger.info(
f"[MomScrap] 受理成功 track_ref={track_ref} outbound={outbound_id} "
f"qty={return_qty} duplicate={data.get('duplicate')} "
f"request_no={(data.get('scrap') or {}).get('request_no')}"
)
return data

View File

@ -0,0 +1,159 @@
"""MOM 报废单只读查询 — 直连 MOM 库(跨库,无 ORM)
Track 侧只存**回执号**(`product_scraps.scrap_request_no`),状态与金额一律实时
回查 MOM。为什么不落一份到 Track:
· 状态会变。MOM 里审批、执行之后 Track 不会收到通知(报废没有回调),
本地存的那份立刻就过期,而「到底批没批、执行没执行」正是用户要看的。
· 金额**取决于执行时的实际扫码量**。MOM 允许少扫(合法子集),
所以「受理量」≠「执行量」,金额必须在 MOM 执行那一刻才算得准。
本地另算一份就是第二份口径,迟早对不上。
写法照 mom_outbound_service(同步 psycopg2 + text() + MomSessionLocal),
调用方一律 `run_in_threadpool` 包出去,别阻塞事件循环。
"""
import logging
from sqlalchemy import text
from app.core.mom_database import MomSessionLocal
logger = logging.getLogger(__name__)
# MOM scrap_approval.status → 中文。与 MOM 的 scrap_approval_service 逐字对齐,
# 不要自创说法(用户在 MOM 界面看到的和 Track 上看的不一致会让人怀疑数据错了)。
MOM_SCRAP_STATUS_LABELS = {
0: '待审批',
1: '已通过(待执行)',
2: '已驳回',
3: '已执行',
4: '已撤回',
}
def describe_status(code) -> str:
"""状态码 → 中文。未知/空值返回空串(前端兜底显示 '-')。"""
if code is None:
return ''
try:
return MOM_SCRAP_STATUS_LABELS.get(int(code), '')
except (TypeError, ValueError):
return ''
def fetch_scrap_status(request_nos: list[str]) -> dict[str, dict]:
"""按报废申请单号批量查 MOM 的审批状态与执行金额。
返回 `{request_no: {...}}`;查不到的**不出现在结果里**(调用方按「缺失 = MOM
侧还没有/已清理」处理,不要伪造成一个空状态)。
金额字段说明(都是 Decimal → float):
· `cost_at_scrap` 报废成本(单价)
· `total_loss` **报废损失 = 单价 × 实报废数量**,这是「统计生产报废金额」要的数
· `scrapped_quantity` 实际报废数量(执行时扫码量,可能小于受理量)
⚠️ 单号可能一张都没执行(还在待审批),此时 trans_scrap 里**毫无痕迹**,
金额是 0 而不是「缺失」—— 用 `executed` 标志区分这两种情况,
别让前端把「还没批」显示成「损失 0 元」。
"""
nos = [str(n).strip() for n in (request_nos or []) if str(n or '').strip()]
if not nos:
return {}
db = MomSessionLocal()
try:
# ---- 1. 申请单头:状态 / 审批人 / 执行人 ----
head_rows = db.execute(
text("""
SELECT request_no, status, applicant_id,
actual_approver_id, approved_at,
executor_name, executed_at, reject_reason,
remark, reason_category
FROM scrap_approval
WHERE request_no = ANY(:nos)
"""),
{"nos": nos},
).fetchall()
out: dict[str, dict] = {}
for r in head_rows:
status = r.status
out[r.request_no] = {
'request_no': r.request_no,
'status': status,
'status_label': describe_status(status),
'applicant_id': r.applicant_id,
'approver_name': _user_name(db, r.actual_approver_id),
'approved_at': _iso(r.approved_at),
'executor_name': r.executor_name or '',
'executed_at': _iso(r.executed_at),
'reject_reason': r.reject_reason or '',
'reason_category': r.reason_category or '',
# 只有 status==3 才真的执行过、台账里才有金额
'executed': status == 3,
'cost_at_scrap': None,
'total_loss': None,
'scrapped_quantity': None,
}
# ---- 2. 报废流水:实际报废量与损失金额(只有执行过才有行) ----
if out:
ledger_rows = db.execute(
text("""
SELECT scrap_request_no,
count(*) AS line_count,
sum(quantity) AS scrapped_quantity,
max(cost_at_scrap) AS cost_at_scrap,
sum(total_loss) AS total_loss
FROM trans_scrap
WHERE scrap_request_no = ANY(:nos)
GROUP BY scrap_request_no
"""),
{"nos": list(out.keys())},
).fetchall()
for r in ledger_rows:
item = out.get(r.scrap_request_no)
if item is None:
continue
item['scrapped_quantity'] = _f(r.scrapped_quantity)
item['cost_at_scrap'] = _f(r.cost_at_scrap)
item['total_loss'] = _f(r.total_loss)
return out
finally:
db.close()
def _f(v):
"""Decimal/None → float/None(JSON 友好)"""
return float(v) if v is not None else None
def _iso(dt):
"""MOM 的时间列是 naive 北京时间,补 +08:00 再交给上层。
⚠️ 少这一步,前端会按**本地时区**解释这个 naive 串,非北京时区的人看到的
时间就是错的。与 mom_outbound_service._as_beijing 同一处理。
"""
if dt is None:
return None
from datetime import timedelta, timezone
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone(timedelta(hours=8)))
return dt.isoformat()
def _user_name(db, user_id):
"""MOM sys_user.id → 中文姓名('张三/zhangsan01' 取 '/' 前那段)。"""
if not user_id:
return ''
try:
row = db.execute(
text("SELECT username FROM sys_user WHERE id = :uid"),
{"uid": int(user_id)},
).fetchone()
except Exception:
return ''
if not row or not row.username:
return ''
return row.username.split('/')[0] if '/' in row.username else row.username

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 连不上时姓名降级为空串,前端回落到显示
用户名。查一次是批量 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)

View File

@ -0,0 +1,248 @@
"""生产报废 — Track 侧业务逻辑
用户在产品详情页看到这台设备领用的料,对某一条发起报废;Track 转调 MOM 的
内部接口完成「退回(不良品) → 在管不良品 → 提交报废申请」,再把回执存下来。
═══ 授权模型(刻意的,不是漏掉的)═══
**可见范围跟设备走,责任归属跟实际发生走。**
料是领给这台设备的,不是领给某个人的。一台设备会经历多个任务、多个人的手
(生产领料 → 装配 → 测试)。测试时摔坏的外壳是生产的人领的、挂在生产任务下 ——
如果只允许「原领用人」报废,测试得回头找生产的人来提单,而生产的人压根不知道
这事,流程上讲不通。
所以:**任何能看到这台设备的人,都能报它上面任何一条料**。
跨设备的防护不靠隐藏,靠 `_load_mounted_material` 的归属校验 ——
`mom_line_id` 必须确实挂在这台设备上,报不了别的设备的料。
滥报由 MOM 侧的主管审批兜底(谁报的、报了谁的料,审批页全看得到)。
前端对「报别人的料」加一道确认(判据是 consumer_name ≠ 当前用户),
那是**防误操作的提示**,不是权限 —— 后端不会因为这条拒绝。
"""
import logging
import uuid
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.product import Product
from app.models.product_scrap import ProductScrap
from app.models.product_outbound_material import ProductOutboundMaterial
from app.schemas.product import ProductScrapResponse
logger = logging.getLogger(__name__)
# 生产报废恒用这个分类码(与 MOM 侧 scrap_approval.SCRAP_CATEGORY_LABELS 对齐)。
# ★ 必须显式传、不能由 MOM 从来源推导:生产报废与 MOM 手工报的不良品退回共用
# 同一张 trans_defective_goods 表,一推导就会把生产损失静默算成库存损失。
SCRAP_CATEGORY_PRODUCTION = "PRODUCTION"
def _source_ref(track_ref: str) -> str:
"""幂等锚点:`<公司>:<Track单据号>`,与发给 MOM 的值同一口径。
带公司前缀是因为 IRIS 与 LICA 各自独立跑一套 Track,工单号可能重号。
"""
return f"{settings.ORG_DEPARTMENT}:{track_ref.strip()}"
async def _load_mounted_material(
db: AsyncSession, product_id: uuid.UUID, mom_line_id: int,
) -> ProductOutboundMaterial:
"""取出该设备上挂载的这条出库明细,顺带完成**归属校验**。
这是跨设备乱报的唯一防线:可见范围是整台设备,不能靠「查不到」来防,
必须显式确认这条 `mom_line_id` 就挂在这台设备上。
⚠️ 不校验的话,前端随便改个数就能报废任意一台设备的料。
"""
row = (
await db.execute(
select(ProductOutboundMaterial)
.where(
ProductOutboundMaterial.product_id == product_id,
ProductOutboundMaterial.mom_line_id == mom_line_id,
)
.limit(1)
)
).scalars().first()
if row is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="这条出库物料没有挂在这台设备上,无法报废",
)
return row
async def list_product_scraps(
db: AsyncSession, product_id: uuid.UUID,
) -> list[ProductScrapResponse]:
"""列出该产品的生产报废记录(按提交时间倒序),并**实时回查 MOM** 补状态与金额。"""
rows = (
await db.execute(
select(ProductScrap)
.where(ProductScrap.product_id == product_id)
.order_by(ProductScrap.created_at.desc())
)
).scalars().all()
return await _enrich_with_mom(rows)
async def _enrich_with_mom(rows: list[ProductScrap]) -> list[ProductScrapResponse]:
"""把 MOM 的实时状态/金额贴到本地记录上。
★ 回查失败**不能让整个列表挂掉**:MOM 短暂不可用时,用户至少要能看到
「我报过什么」,只是状态暂时显示不出来。所以这里 catch 住、降级成本地快照。
"""
from fastapi.concurrency import run_in_threadpool
from app.services import mom_scrap_service
result: list[ProductScrapResponse] = []
live: dict[str, dict] = {}
if rows:
try:
live = await run_in_threadpool(
mom_scrap_service.fetch_scrap_status,
[r.scrap_request_no for r in rows],
)
except Exception as e:
# 降级:用本地快照,并在日志里留痕(静默降级会让人以为 MOM 没执行)
logger.warning(f"[ProductScrap] 回查 MOM 状态失败,降级用本地快照: {e}")
for r in rows:
item = ProductScrapResponse.model_validate(r)
info = live.get(r.scrap_request_no)
if info:
item.mom_status = info.get('status', r.mom_status)
item.mom_status_label = info.get('status_label') or ''
item.mom_approved_at = info.get('approved_at')
item.mom_executor_name = info.get('executor_name') or ''
item.mom_executed = bool(info.get('executed'))
item.total_loss = info.get('total_loss')
item.scrapped_quantity = info.get('scrapped_quantity')
else:
# MOM 里查不到这张单(被清理 / 回查失败降级)→ 用本地快照,
# 但**不伪造金额**:total_loss 保持 None,前端显示「—」而不是 0
item.mom_status = r.mom_status
item.mom_status_label = mom_scrap_service.describe_status(r.mom_status)
item.mom_executed = False
result.append(item)
return result
async def submit_product_scrap(
db: AsyncSession, product_id: uuid.UUID, *, mom_line_id: int, quantity: float,
track_ref: str, reason: str | None, current_user: dict,
) -> ProductScrapResponse:
"""提交一条生产报废。
幂等:同一个 `track_ref` 重发**不会**产生第二条 MOM 报废单,
命中已有记录直接返回(网络超时后重试是常态,用户不该为这付两次代价)。
"""
track_ref = (track_ref or '').strip()
if not track_ref:
raise HTTPException(status_code=400, detail="track_ref 为必填(幂等锚点)")
if not quantity or float(quantity) <= 0:
raise HTTPException(status_code=400, detail="报废数量必须大于 0")
product = (
await db.execute(select(Product).where(Product.id == product_id))
).scalars().first()
if product is None:
raise HTTPException(status_code=404, detail="产品不存在")
source_ref = _source_ref(track_ref)
# ---- 1. 幂等:这个单据号已经受理过 → 直接回已有的那条 ----
existing = (
await db.execute(
select(ProductScrap).where(ProductScrap.source_ref == source_ref)
)
).scalars().first()
if existing is not None:
logger.info(f"[ProductScrap] track_ref 重复提交,返回已有记录 {source_ref}")
return (await _enrich_with_mom([existing]))[0]
# ---- 2. 归属校验 + 取快照(快照只信后端自己查到的,不信前端传的) ----
material = await _load_mounted_material(db, product_id, int(mom_line_id))
# ---- 3. 申请人:当前登录人。Track 的 sub 就是 MOM sys_user.id,
# 所以 MOM 里显示的申请人就是本人,不需要服务账号、也不会串人 ----
try:
applicant_id = int(current_user.get("sub"))
except (TypeError, ValueError):
raise HTTPException(status_code=401, detail="登录状态异常,请重新登录")
operator = current_user.get("display_name") or current_user.get("username") or "Track系统"
# ---- 4. 调 MOM(唯一的写通道,失败直接抛,不静默吞) ----
from app.services.mom_scrap_client import MomScrapError, submit_production_scrap
try:
data = await submit_production_scrap(
outbound_id=int(mom_line_id),
return_qty=float(quantity),
track_ref=track_ref,
applicant_id=applicant_id,
reason=reason,
operator=operator,
)
except MomScrapError as e:
# MOM 的文案已经是中文且具体,直接转给用户
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=e.message)
scrap_info = data.get('scrap') or {}
request_no = scrap_info.get('request_no')
if not request_no:
# MOM 回 200 却没给单号 = 契约被破坏,必须炸出来而不是存一条空记录
logger.error(f"[ProductScrap] MOM 返回缺少 scrap.request_no: {data}")
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="MOM 已受理但未返回报废单号,请到 MOM 报废审批页确认",
)
# ---- 5. 落库 ----
row = ProductScrap(
product_id=product_id,
serial_number=product.serial_number,
task_id=material.task_id,
mom_line_id=int(mom_line_id),
# 快照取自 Track 已挂的出库物料(当初由后端查 MOM 写入),不是前端传的
outbound_no=material.outbound_no,
material_name=material.material_name,
spec_model=material.spec_model,
sku=material.sku,
consumer_name=material.consumer_name,
quantity=quantity,
reason_category=SCRAP_CATEGORY_PRODUCTION,
reason=(reason or '').strip() or None,
scrap_request_no=request_no,
defective_goods_id=data.get('defective_goods_id'),
mom_status=int(scrap_info.get('status') or 0),
source_ref=source_ref,
submitted_by=current_user.get("username"),
)
db.add(row)
try:
await db.commit()
except IntegrityError:
# 并发穿透了第 1 步的预检 —— 唯一约束兜底,回滚后返回已有那条
await db.rollback()
existing = (
await db.execute(
select(ProductScrap).where(ProductScrap.source_ref == source_ref)
)
).scalars().first()
if existing is not None:
return (await _enrich_with_mom([existing]))[0]
raise
await db.refresh(row)
logger.info(
f"[ProductScrap] 提交成功 {request_no} product={product.serial_number} "
f"line={mom_line_id} qty={quantity} by={row.submitted_by}"
)
return (await _enrich_with_mom([row]))[0]

View File

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

View File

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

View File

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

View File

@ -51,6 +51,13 @@ services:
MOM_DB_PORT: "5432"
# 🚀 MOM 仓储系统回调 Webhook 验签 Key(与 MOM 侧 TRACK_WEBHOOK_KEY 保持一致)
TRACK_WEBHOOK_KEY: 2ce5fedb48fde3fd7e0abf67472a5027b03e9ae6f19cf768
# 🚀 MOM 内部接口 —— Track **主动调 MOM** 发起生产报废(唯一一处主动写 MOM)
# ⚠️ 与上面的 TRACK_WEBHOOK_KEY 是**两把不同的钥匙**:
# 上面那把是「MOM 发给 Track 时的验签凭证」,这把是「Track 发给 MOM 的凭证」,
# 方向相反、权限不同(这把能发起报废审批),必须能独立轮换。
# ⚠️ 未配置 → 报废提交直接 503,**不静默降级**(写操作静默失败最伤人)
MOM_INTERNAL_API_URL: http://inventory_api:8000
MOM_INTERNAL_API_KEY: ${MOM_INTERNAL_API_KEY:-}
ports:
- "8011:8000"
volumes:

View File

@ -0,0 +1,232 @@
/**
* 创建任务弹窗 —— 产品已由调用方(任务列表的某一行)确定,这里选工序/接收人/备注,
* 并可从 MOM 出库单里勾选出库物料一并挂上。
*
* ⚠️ 表单重置的 useEffect 必须写在 `if (!product) return null` **之前** ——
* 常驻挂载的 memo 组件先条件返回再调 Hook 会违反 Rules of Hooks,产品从
* null 变非 null 时 Hook 数量错位直接崩(TaskTreeViewer.tsx 有同样的血泪注释)。
*/
import { useEffect, useMemo, useState } from "react";
import { Loader2, Package, Truck } from "lucide-react";
import { Modal } from "../TaskTree/TaskTreeViewer";
import { useToast } from "../ui/Toast";
import MomOutboundPicker from "./MomOutboundPicker";
import { createTask } from "../../services/taskApi";
import { listUsers, type UserOption } from "../../services/userApi";
import { extractErrorMessage } from "../../utils/errorMessage";
import { taskOptionsFor } from "../../constants/task";
import type { MomOutboundOrder } from "../../types/api";
import type { ProductResponse } from "../../types/admin";
interface Props {
open: boolean;
onClose: () => void;
/** 目标产品 —— 由任务列表里点的那一行确定 */
product: ProductResponse | null;
onCreated: () => void;
}
const INPUT_CLS =
"w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100 disabled:bg-gray-100 disabled:text-gray-400";
export default function CreateTaskDialog({ open, onClose, product, onCreated }: Props) {
const { toast } = useToast();
const [users, setUsers] = useState<UserOption[]>([]);
const [taskName, setTaskName] = useState("");
const [assigneeId, setAssigneeId] = useState("");
const [remark, setRemark] = useState("");
const [pickedOrders, setPickedOrders] = useState<MomOutboundOrder[]>([]);
const [pickerOpen, setPickerOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
// ⚠️ 必须在 `if (!product) return null` 之前
useEffect(() => {
if (!open) return;
setTaskName("");
setAssigneeId("");
setRemark("");
setPickedOrders([]);
setPickerOpen(false);
}, [open]);
useEffect(() => {
if (!open) return;
listUsers()
.then(setUsers)
.catch(() => toast("加载人员列表失败", "error"));
}, [open, toast]);
// 可选工序随产品的生命周期阶段/宏观状态变化
const stepOptions = useMemo(
() => taskOptionsFor(product?.lifecycle_phase, true, product?.overall_status),
[product?.lifecycle_phase, product?.overall_status],
);
if (!product) return null;
const pickedLineCount = pickedOrders.reduce((s, o) => s + o.lines.length, 0);
async function handleSubmit() {
if (!product) return;
if (!taskName) {
toast("请选择工序", "error");
return;
}
setSubmitting(true);
try {
await createTask({
product_id: product.id,
task_name: taskName,
assignee_id: assigneeId || null,
remark: remark.trim() || undefined,
mom_line_ids: pickedOrders.flatMap((o) => o.lines.map((l) => l.line_id)),
});
toast("任务已创建", "success");
onClose();
onCreated();
} catch (err) {
toast(extractErrorMessage(err, "创建任务失败"), "error");
} finally {
setSubmitting(false);
}
}
return (
<>
<Modal
open={open}
onClose={onClose}
title="创建任务"
widthClass="max-w-lg"
bodyClassName="max-h-[85vh] overflow-y-auto"
>
{/* ---- 产品信息(只读) ---- */}
<div className="mb-4 rounded-lg bg-blue-50 px-3 py-2.5 text-sm text-blue-700">
<p className="flex items-center gap-1.5 font-medium">
<Package className="h-4 w-4" />
<span className="font-mono">{product.serial_number}</span>
</p>
<p className="mt-0.5 text-xs text-blue-500">
{product.material_name || "—"}
{product.overall_status ? ` · 当前状态 ${product.overall_status}` : ""}
</p>
</div>
{/* ---- 表单 ---- */}
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-gray-600">
工序 <span className="text-red-500">*</span>
</label>
<select value={taskName} onChange={(e) => setTaskName(e.target.value)} className={INPUT_CLS}>
<option value="">请选择工序</option>
{stepOptions.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600">接收人</label>
<select value={assigneeId} onChange={(e) => setAssigneeId(e.target.value)} className={INPUT_CLS}>
<option value="">暂不指派(留在仓库)</option>
{users.map((u) => (
<option key={u.username} value={u.username}>
{u.full_name}({u.username})
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600">备注</label>
<textarea
value={remark}
onChange={(e) => setRemark(e.target.value)}
rows={2}
maxLength={2000}
placeholder="初始描述 / 交接备注"
className={`${INPUT_CLS} resize-none`}
/>
</div>
{/* ---- 出库物料 ---- */}
<div>
<label className="mb-1 block text-xs font-medium text-gray-600">
出库物料 <span className="text-gray-400">(可选,之后也能追加)</span>
</label>
{pickedOrders.length === 0 ? (
<button
type="button"
onClick={() => setPickerOpen(true)}
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-gray-300 px-3 py-2.5 text-sm text-gray-500 hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600"
>
<Truck className="h-4 w-4" />
从 MOM 出库单选择物料
</button>
) : (
<div className="space-y-1.5">
{pickedOrders.map((o) => (
<div key={o.outbound_no}
className="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-2.5 py-1.5 text-xs">
<span className="font-mono font-medium text-gray-800">{o.outbound_no}</span>
<span className="text-gray-500">{o.line_count} 条物料</span>
<button
type="button"
onClick={() => setPickedOrders((prev) => prev.filter((x) => x.outbound_no !== o.outbound_no))}
className="ml-auto rounded px-1.5 text-gray-400 hover:bg-white hover:text-red-500"
title="移除"
>
✕
</button>
</div>
))}
<button
type="button"
onClick={() => setPickerOpen(true)}
className="text-xs font-medium text-blue-600 hover:text-blue-700"
>
+ 继续添加出库单
</button>
</div>
)}
</div>
</div>
{/* ---- 底部操作 ---- */}
<div className="mt-5 flex items-center justify-between border-t border-gray-100 pt-3">
<span className="text-xs text-gray-400">
{pickedLineCount > 0 ? `将挂载 ${pickedLineCount} 条物料` : ""}
</span>
<div className="flex gap-2">
<button onClick={onClose} disabled={submitting}
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50">
取消
</button>
<button onClick={handleSubmit} disabled={submitting || !taskName}
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50">
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
创建任务
</button>
</div>
</div>
</Modal>
{/* 选择器叠在创建弹窗之上 */}
<MomOutboundPicker
open={pickerOpen}
onClose={() => setPickerOpen(false)}
onConfirm={(_ids, orders) => {
setPickedOrders((prev) => {
const seen = new Set(prev.map((o) => o.outbound_no));
return [...prev, ...orders.filter((o) => !seen.has(o.outbound_no))];
});
setPickerOpen(false);
}}
existingOrderNos={pickedOrders.map((o) => o.outbound_no)}
/>
</>
);
}

View File

@ -0,0 +1,412 @@
/**
* MOM 出库单选择器 — 从 MOM 搜索出库单,**按整张单**勾选
*
* 粒度说明:勾选的是单据(outbound_no),确认时把该单**全部明细行 ID** 一起提交
* (后端按明细行落快照)。业务上确认过「单据里的物料都是相关的」,不存在无关物料。
*
* 过滤维度(都是**收窄**,可见范围由后端强制,前端传什么都放不大):
* · 关键词 —— 出库单号 / 物料名称 / 规格型号 / SKU / 领用人
* · 出库时间区间
* · 领用人 —— **打开时默认筛成当前账号本人**(理由见 open 时的那个 effect)
*
* 已经挂过的单据会显示「已挂载」并禁止再选 —— 后端虽然有唯一约束兜底,但让用户
* 在这里就能看出来更省事。
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { DatePicker, Modal, Select } from "antd";
import { ChevronDown, ChevronRight, Loader2, Package, Search } from "lucide-react";
import { useAuth } from "../../contexts/AuthContext";
import { useToast } from "../ui/Toast";
import {
listMomOutboundConsumers,
searchMomOutbounds,
} from "../../services/momApi";
import { extractErrorMessage } from "../../utils/errorMessage";
import type { MomOutboundOrder } from "../../types/api";
const { RangePicker } = DatePicker;
interface Props {
open: boolean;
onClose: () => void;
/** 确认选择:回传选中的全部明细行 ID 与选中的单据(供调用方展示) */
onConfirm: (momLineIds: number[], orders: MomOutboundOrder[]) => void;
submitting?: boolean;
/** 该任务/产品已挂载的出库单号 —— 这些单在列表里标记「已挂载」且不可再选 */
existingOrderNos?: string[];
}
const PAGE_SIZE = 20;
/** 把当前筛选条件拼成一个字符串,用作「筛选是否变了」的比较键 */
function makeFilterKey(
kw: string, consumer?: string, start?: string, end?: string,
): string {
return [kw.trim(), consumer ?? "", start ?? "", end ?? ""].join("|");
}
const EMPTY_FILTER_KEY = makeFilterKey("");
/** 出库时间 → 本地可读格式 */
function formatTime(iso: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
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())}`;
}
export default function MomOutboundPicker({
open, onClose, onConfirm, submitting = false, existingOrderNos = [],
}: Props) {
const { toast } = useToast();
const { user } = useAuth();
/** 当前登录账号的姓名 —— 打开时默认拿它去筛领用人(未登录兜底为空串 = 不筛) */
const accountName = (user?.display_name ?? "").trim();
// ---- 筛选条件 ----
const [keyword, setKeyword] = useState("");
// 只留 YYYY-MM-DD 字符串,RangePicker 走非受控 + key 重挂载来重置,
// 这样不必引入 dayjs 的类型(它不是本项目的直接依赖)
const [range, setRange] = useState<[string, string] | null>(null);
const [pickerKey, setPickerKey] = useState(0);
const [consumer, setConsumer] = useState<string | undefined>();
// ---- 下拉数据 ----
const [consumerOptions, setConsumerOptions] = useState<string[]>([]);
// ---- 结果 ----
const [orders, setOrders] = useState<MomOutboundOrder[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [selected, setSelected] = useState<Set<string>>(new Set());
const existing = new Set(existingOrderNos);
const lastKeyRef = useRef<string>(EMPTY_FILTER_KEY);
const [start, end] = range ?? ["", ""];
const load = useCallback(async (
kw: string, c?: string, sd?: string, ed?: string,
) => {
setLoading(true);
try {
const res = await searchMomOutbounds({
keyword: kw.trim() || undefined,
consumer: c || undefined,
start_date: sd || undefined,
end_date: ed || undefined,
limit: PAGE_SIZE,
});
setOrders(res.orders);
setTotal(res.total);
} catch (err) {
toast(extractErrorMessage(err, "加载 MOM 出库单失败"), "error");
setOrders([]);
setTotal(0);
} finally {
setLoading(false);
}
}, [toast]);
// ---- 打开时重置 + 拉一次可选领用人,再**带着默认领用人**查一次 ----
//
// 为什么两件事合在一个 effect 里:默认领用人就是当前账号本人,而「本人在不在
// 可选列表里」得先拿到列表才知道(没领过料的人本来就不该被筛 —— 筛了会得到
// 一屏空白,用户还以为系统坏了)。拆成两个 effect 的话只能先空筛查一次、拿到
// 名单再改条件查第二次:既多打一次请求,还会先闪一屏别人的单据再被抽走。
//
// 拿不到名单(接口挂了)就退回「全部领用人」,不能卡住整个选择器。
useEffect(() => {
if (!open) return;
setKeyword("");
setRange(null);
setPickerKey((k) => k + 1);
setExpanded(new Set());
setSelected(new Set());
let cancelled = false;
(async () => {
let names: string[] = [];
try {
names = await listMomOutboundConsumers();
} catch {
names = [];
}
if (cancelled) return;
setConsumerOptions(names);
// MOM 的 consumer_name 存的就是人名,与登录态的 display_name 同一口径
// (sys_user.username 的「姓名/账号」前半段)。
const mine = accountName && names.includes(accountName) ? accountName : undefined;
setConsumer(mine);
// 先写 lastKeyRef 再 setState:防抖 effect 里靠它去重,不写就会在 300ms 后
// 用同一组条件再查一次。
lastKeyRef.current = makeFilterKey("", mine);
load("", mine);
})();
return () => {
cancelled = true;
};
}, [open, load, accountName]);
// ---- 筛选变化 → 防抖搜索(300ms)。靠 filterKey 去重,避免重置时多打一次 ----
const filterKey = makeFilterKey(keyword, consumer, start, end);
useEffect(() => {
if (!open) return;
if (filterKey === lastKeyRef.current) return;
const timer = setTimeout(() => {
lastKeyRef.current = filterKey;
load(keyword, consumer, start, end);
}, 300);
return () => clearTimeout(timer);
}, [filterKey, open, load, keyword, consumer, start, end]);
function toggleOrder(no: string) {
if (existing.has(no)) return;
setSelected((prev) => {
const next = new Set(prev);
if (next.has(no)) next.delete(no); else next.add(no);
return next;
});
}
function toggleExpand(no: string) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(no)) next.delete(no); else next.add(no);
return next;
});
}
/**
* 勾选里有没有**不是自己领的**单。
*
* 判据:出库单的领用人(MOM 侧自由填写的姓名)≠ 当前登录人姓名。
* ⚠️ 这是**提示**不是权限 —— 料的归属是设备不是人,代挂是合理操作
* (测试替生产补挂、库管代录都会发生)。拦一道只是防手滑勾错别人的单。
*/
function proxyOrders(picked: MomOutboundOrder[]) {
const myName = (user?.display_name ?? "").trim();
if (!myName) return [];
return picked.filter((o) => o.consumer_name && o.consumer_name !== myName);
}
function handleConfirm() {
const picked = orders.filter((o) => selected.has(o.outbound_no));
const proxy = proxyOrders(picked);
const submit = () =>
onConfirm(picked.flatMap((o) => o.lines.map((l) => l.line_id)), picked);
if (proxy.length === 0) return submit();
// 提交前拦一道,用户还能取消回去改勾选
const who = [...new Set(proxy.map((o) => o.consumer_name).filter(Boolean))].join("、");
Modal.confirm({
title: "确认代挂",
content: `选中里有 ${proxy.length} 张不是你自己领的单(领用人:${who})。`
+ "挂上去会记在你名下(挂载人),确认继续?",
okText: "确认代挂",
cancelText: "再看看",
onOk: submit,
});
}
const selectedLineCount = orders
.filter((o) => selected.has(o.outbound_no))
.reduce((sum, o) => sum + o.lines.length, 0);
const hasFilter = !!(keyword.trim() || consumer || range);
return (
// ⚠️ 这里用 antd 的 Modal,**不是**任务域那套自写 Modal。
// 本选择器既被自写 Modal 打开(创建任务),也被 antd Modal 打开(创建产品)。
// antd 弹窗走 portal 且默认 z-index 1000,自写 Modal 的 z-50 会被它整个
// 盖住 —— 只从下面露出一截列表,看起来像「内容被后置」。
<Modal
open={open}
onCancel={onClose}
title="选择 MOM 出库物料"
width={920}
footer={null}
destroyOnHidden
>
{/* ★ 只有中间的**单据列表**滚动,搜索筛选与底部操作条固定。
整块一起滚的话,滚到下面就看不到「共 N 张单据」、也够不着「确认选择」——
用户会以为没得选(实测反馈就是这么来的)。
用 flex 列布局 + min-h-0 让中间那块真正可滚(min-h-0 不能省:
flex 子项默认 min-height:auto,会撑开容器导致整页滚动)。 */}
<div className="flex max-h-[72vh] flex-col">
{/* ---- 搜索 ---- */}
<div className="relative mb-2 shrink-0">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索出库单号 / 物料名称 / 规格型号 / SKU / 领用人"
className="w-full rounded-lg border border-gray-200 py-2 pl-9 pr-8 text-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100"
/>
{loading && (
<Loader2 className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-blue-500" />
)}
</div>
{/* ---- 筛选行 ---- */}
<div className="mb-3 flex shrink-0 flex-wrap items-center gap-2">
<RangePicker
key={pickerKey}
size="small"
onChange={(_: unknown, strings: [string, string]) => {
const [s, e] = strings;
setRange(s && e ? [s, e] : null);
}}
placeholder={["开始日期", "结束日期"]}
/>
<Select
size="small"
allowClear
showSearch
placeholder="全部领用人"
style={{ minWidth: 130 }}
value={consumer}
onChange={setConsumer}
options={consumerOptions.map((n) => ({ value: n, label: n }))}
/>
{hasFilter && (
<button
onClick={() => {
setKeyword(""); setRange(null); setPickerKey((k) => k + 1);
setConsumer(undefined);
}}
className="text-xs text-gray-400 hover:text-gray-600"
>
清空筛选
</button>
)}
<span className="ml-auto text-xs text-gray-400">
共 <span className="font-semibold text-gray-600">{total}</span> 张单据
</span>
</div>
{/* ---- 单据列表(**只有这块滚动**)---- */}
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
{!loading && orders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-14 text-gray-400">
<Package className="mb-2 h-10 w-10" />
<p className="text-sm">{hasFilter ? "没有匹配的出库单" : "没有可选的出库单"}</p>
{hasFilter && (
<p className="mt-1 text-xs text-gray-300">试试放宽日期或清空筛选条件</p>
)}
</div>
) : (
<div className="space-y-1.5">
{orders.map((o) => {
const isExisting = existing.has(o.outbound_no);
const isSelected = selected.has(o.outbound_no);
const isOpen = expanded.has(o.outbound_no);
// 整行可点 = 勾选/取消(展开按钮自己 stopPropagation)。
// 复选框只是状态的「显示」,不再是唯一入口 —— 所以它
// readOnly + pointer-events-none,点它的事件穿透到整行上,
// 避免 onChange 与整行 onClick 各切一次、等于没切。
return (
<div
key={o.outbound_no}
onClick={() => toggleOrder(o.outbound_no)}
className={`rounded-lg border px-2.5 py-1.5 transition-colors ${
isExisting ? "cursor-not-allowed border-gray-200 bg-gray-50"
: isSelected ? "cursor-pointer border-blue-300 bg-blue-50"
: "cursor-pointer border-gray-100 hover:border-gray-300 hover:bg-gray-50"
}`}
>
{/* 单行放完:勾选 / 单号 / 类型 / 领用+经办+条数 / 时间 / 展开 */}
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={isSelected}
disabled={isExisting}
readOnly
tabIndex={-1}
className="pointer-events-none h-4 w-4 shrink-0"
/>
<span className={`shrink-0 font-mono text-[13px] font-medium ${isExisting ? "text-gray-400" : "text-gray-800"}`}>
{o.outbound_no}
</span>
{isExisting && (
<span className="shrink-0 rounded-full bg-gray-200 px-1.5 py-0.5 text-[10px] font-bold text-gray-600">
已挂载
</span>
)}
{o.outbound_type && (
<span className="shrink-0 rounded-full bg-purple-100 px-1.5 py-0.5 text-[10px] font-medium text-purple-700">
{/* 中文名由后端按 MOM 码表下发;万一没下发就退回原始码,
免得整块徽标凭空消失 */}
{o.outbound_type_label || o.outbound_type}
</span>
)}
<span className="min-w-0 truncate text-xs text-gray-500">
{o.consumer_name && <>领用 {o.consumer_name}</>}
{o.operator_name && <span className="ml-2 text-gray-400">经办 {o.operator_name}</span>}
<span className="ml-2 text-gray-400">
<span className="font-medium text-gray-600">{o.line_count}</span> 条物料
{o.total_quantity != null && <> · 合计 {o.total_quantity}</>}
</span>
</span>
<span className="ml-auto shrink-0 text-xs text-gray-400">
{formatTime(o.outbound_time)}
</span>
{/* 看物料明细的按钮 —— 带边框才看得出是个按钮,别只给个裸图标 */}
<button
onClick={(e) => {
e.stopPropagation();
toggleExpand(o.outbound_no);
}}
className="flex shrink-0 items-center gap-0.5 rounded-md border border-gray-200 p-1 text-gray-500 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:text-gray-700"
title={isOpen ? "收起物料明细" : `查看物料明细(${o.line_count} 条)`}
>
<Package className="h-4 w-4" />
{isOpen ? <ChevronDown className="h-5 w-5" /> : <ChevronRight className="h-5 w-5" />}
</button>
</div>
{isOpen && (
<div className="mt-1.5 space-y-0.5 border-t border-gray-100 pt-1.5 pl-6">
{o.lines.map((l) => (
<div key={l.line_id} className="flex flex-wrap items-baseline gap-x-3 text-xs">
<span className="font-medium text-gray-700">{l.material_name || "(未命名物料)"}</span>
{l.spec_model && <span className="text-gray-400">{l.spec_model}</span>}
<span className="text-gray-500">× {l.quantity}</span>
{l.warehouse_location && <span className="text-gray-400">库位 {l.warehouse_location}</span>}
{l.request_no && <span className="text-gray-400">申请单 {l.request_no}</span>}
</div>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
{/* ---- 底部操作(固定,不随列表滚动)---- */}
<div className="mt-3 flex shrink-0 items-center justify-between border-t border-gray-100 pt-3">
<span className="text-xs text-gray-500">
已选 <span className="font-semibold text-gray-700">{selected.size}</span> 张单
(<span className="font-semibold text-gray-700">{selectedLineCount}</span> 条物料)
</span>
<div className="flex gap-2">
<button onClick={onClose} disabled={submitting}
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50">
取消
</button>
<button onClick={handleConfirm} disabled={submitting || selected.size === 0}
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50">
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
确认选择
</button>
</div>
</div>
</div>
</Modal>
);
}

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

@ -3,6 +3,7 @@ import { Loader2, AlertCircle } from "lucide-react";
import type { ProductScanResponse } from "../../types/api";
import ProductCard from "./ProductCard";
import TaskListCard from "./TaskListCard";
import OutboundRecordsCard from "./OutboundRecordsCard";
interface QueryResultProps {
loading: boolean;
@ -33,6 +34,9 @@ export default function QueryResult({ loading, error, product }: QueryResultProp
return (
<div className="space-y-4">
<ProductCard product={product} />
{/* 出库单据 —— 卡片按 productId 自取数据(与「产品管理 → 编辑产品」共用
同一个组件)。无记录时显示空态 + 「追加出库单」入口。 */}
<OutboundRecordsCard productId={product.id} />
<TaskListCard tasks={product.task_tree || product.top_level_tasks} />
</div>
);

View File

@ -8,6 +8,7 @@ import {
import api from "../../services/api";
import type { ProductResponse } from "../../types/admin";
import CreateProductDialog from "./CreateProductDialog";
import OutboundRecordsCard from "../../components/scan/OutboundRecordsCard";
import {
getLabelPreview, executePrint,
} from "../../services/printApi";
@ -353,13 +354,24 @@ export default function AdminProductsPage() {
{editTarget && (
<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="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>
<p className="mb-4 font-mono text-sm text-gray-500">产品ID: {editTarget.serial_number}</p>
<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={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>
{/* 出库单据 —— **只有这一张卡**。
它合并了原先的「出库单据」与「领用物料」两张:那两者本来就是
同一件事(这台设备对应 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>
</div>

View File

@ -3,7 +3,7 @@ import { useState, useEffect, useMemo, useCallback } from "react";
import { useSearchParams } from "react-router-dom";
import {
Search, Loader2, Package, ChevronDown, ChevronRight,
Warehouse, GitBranch, X, ArrowUp, ArrowDown, ArrowUpDown, Filter, Columns,
Warehouse, GitBranch, X, ArrowUp, ArrowDown, ArrowUpDown, Filter, Columns, Plus,
} from "lucide-react";
import { Tooltip, Popover, Checkbox, Input, Button } from "antd";
import api from "../../services/api";
@ -14,6 +14,7 @@ import {
import { TaskFlowView } from "../../components/TaskTree/TaskFlowView";
import type { ModalTarget } from "../../components/TaskTree/TaskTreeViewer";
import { Modal, ReceiveConfirmModal, RejectModal, TransferModal } from "../../components/TaskTree/TaskTreeViewer";
import CreateTaskDialog from "../../components/admin/CreateTaskDialog";
import type { ProductResponse } from "../../types/admin";
import type { ProductScanResponse } from "../../types/api";
import { useToast } from "../../components/ui/Toast";
@ -92,6 +93,9 @@ export default function AdminTasksPage() {
const [submitting, setSubmitting] = useState(false);
const [qrSerial, setQrSerial] = useState<string | null>(null); // 🔧 QR弹窗
// 🔧 创建任务弹窗 —— 产品由所点的那一行确定,弹窗里再选工序/接收人/出库物料
const [createTarget, setCreateTarget] = useState<ProductResponse | null>(null);
// ---- 列配置(10列)----
const columns: ColumnDef[] = [
{
@ -207,10 +211,17 @@ export default function AdminTasksPage() {
render: (p) => {
const isTreeLoading = treeLoading[p.serial_number];
return (
<button onClick={() => openTreeModal(p.serial_number)} className="flex items-center gap-1 rounded border border-blue-200 px-2.5 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50 transition-colors">
{isTreeLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : <GitBranch className="h-3 w-3" />}
流转树
</button>
<div className="flex items-center gap-1.5">
<button onClick={() => openTreeModal(p.serial_number)} className="flex items-center gap-1 rounded border border-blue-200 px-2.5 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50 transition-colors">
{isTreeLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : <GitBranch className="h-3 w-3" />}
流转树
</button>
{/* 创建任务:产品由本行确定,弹窗里再选工序/接收人/出库物料 */}
<button onClick={() => setCreateTarget(p)} className="flex items-center gap-1 rounded border border-emerald-200 px-2.5 py-1 text-xs font-medium text-emerald-600 hover:bg-emerald-50 transition-colors">
<Plus className="h-3 w-3" />
创建任务
</button>
</div>
);
},
},
@ -924,6 +935,14 @@ export default function AdminTasksPage() {
onClose={() => setModalTarget(null)}
onSubmit={handleTransfer}
/>
{/* 🔧 创建任务弹窗(含从 MOM 出库单选物料) */}
<CreateTaskDialog
open={createTarget !== null}
product={createTarget}
onClose={() => setCreateTarget(null)}
onCreated={() => { loadProducts(keyword); }}
/>
</div>
);
}

View File

@ -1,9 +1,11 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Modal, Collapse, Table, Input, Button, Space, Tag, App, Spin, Empty } from "antd";
import { SearchOutlined, PlusOutlined, MinusOutlined } from "@ant-design/icons";
import { SearchOutlined, PlusOutlined, MinusOutlined, TruckOutlined } from "@ant-design/icons";
import api from "../../services/api";
import { fetchMaterialGroups, fetchMaterialItems, type MaterialGroup, type MaterialItem } from "../../services/materialApi";
import MomOutboundPicker from "../../components/admin/MomOutboundPicker";
import { extractErrorMessage } from "../../utils/errorMessage";
import type { MomOutboundOrder } from "../../types/api";
// ============================================================
// 类型
@ -51,6 +53,10 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
const [submitting, setSubmitting] = useState(false);
const [createdSn, setCreatedSn] = useState<string | null>(null);
// ---- 建档时挂钩的 MOM 出库单(可选) ----
const [pickedOrders, setPickedOrders] = useState<MomOutboundOrder[]>([]);
const [pickerOpen, setPickerOpen] = useState(false);
// ---- 初始化 ----
useEffect(() => {
if (open) {
@ -62,6 +68,8 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
setCreatedSn(null);
groupCache.current.clear();
groupLoadingMap.current.clear();
setPickedOrders([]);
setPickerOpen(false);
loadSummary();
}
}, [open]);
@ -182,6 +190,10 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
material_type: selected.material_type,
external_serial: externalSerial.trim() || null,
order_no: orderNo.trim() || null,
// 挂钩的出库单:用户是按**整张单**勾选的,所以提交该单全部明细行 ID,
// 后端归并回单据后写进 product_outbounds(source=manual)。
// 不选就是空数组,后端不挂载。
mom_line_ids: pickedOrders.flatMap((o) => o.lines.map((l) => l.line_id)),
});
setCreatedSn(data.serial_number);
onCreated();
@ -332,6 +344,7 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
// ============================================================
return (
<>
<Modal
title="创建产品"
open={open}
@ -493,6 +506,43 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
/>
</div>
{/* MOM 出库单挂钩(选填) */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
出库单挂钩 <span className="text-xs text-gray-400">(选填,建档后也能补挂)</span>
</label>
{pickedOrders.length === 0 ? (
<button
type="button"
onClick={() => setPickerOpen(true)}
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-gray-300 px-3 py-2.5 text-sm text-gray-500 hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600"
>
<TruckOutlined />
从 MOM 出库单选择
</button>
) : (
<div className="space-y-1.5">
{pickedOrders.map((o) => (
<div key={o.outbound_no}
className="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-2.5 py-1.5 text-xs">
<span className="font-mono font-medium text-gray-800">{o.outbound_no}</span>
<span className="text-gray-500">{o.line_count} 条物料</span>
<Button
type="text" size="small" danger
className="ml-auto"
onClick={() => setPickedOrders((prev) => prev.filter((x) => x.outbound_no !== o.outbound_no))}
>
移除
</Button>
</div>
))}
<Button type="link" size="small" onClick={() => setPickerOpen(true)}>
+ 继续添加出库单
</Button>
</div>
)}
</div>
{/* 提交 */}
<Button
type="primary"
@ -507,5 +557,20 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
</div>
)}
</Modal>
{/* MOM 出库单选择器 —— 叠在创建产品弹窗之上 */}
<MomOutboundPicker
open={pickerOpen}
onClose={() => setPickerOpen(false)}
onConfirm={(_ids, orders) => {
setPickedOrders((prev) => {
const seen = new Set(prev.map((o) => o.outbound_no));
return [...prev, ...orders.filter((o) => !seen.has(o.outbound_no))];
});
setPickerOpen(false);
}}
existingOrderNos={pickedOrders.map((o) => o.outbound_no)}
/>
</>
);
}

View File

@ -0,0 +1,48 @@
/** MOM 出库单相关接口 —— 任务挂载出库物料时搜索选择用 */
import api from "./api";
import type { MomOutboundSearchResponse } from "../types/api";
export interface MomOutboundSearchParams {
/** 出库单号 / 物料名称 / 规格型号 / SKU / 领用人,任一命中 */
keyword?: string;
/** YYYY-MM-DD,含当日 */
start_date?: string;
/** YYYY-MM-DD,含当日 */
end_date?: string;
/** 按领用人(中文名)过滤 */
consumer?: string;
/** 跳过**单据数**(不是明细行数) */
skip?: number;
/** 返回**单据数**,后端上限 100 */
limit?: number;
}
/**
* 搜索 MOM 出库单(按单据分页,带回每张单的明细)。
* 对应后端 GET /api/v1/mom-outbounds
*
* ⚠️ 可见范围(公司隔离 + 跨部门例外)由后端服务层钉死,本接口**没有任何**
* 能放大范围的参数 —— 界面筛选只能收窄。
*/
export async function searchMomOutbounds(
params: MomOutboundSearchParams = {},
): Promise<MomOutboundSearchResponse> {
const { data } = await api.get<MomOutboundSearchResponse>("/mom-outbounds", { params });
return data;
}
/**
* 本部门出库单里出现过的**领用人姓名**(去重,按出现次数降序)。
* 对应后端 GET /api/v1/mom-outbounds/consumers
*
* ⚠️ 后端**已按权限范围过滤** —— 下拉里不会出现用户看不到的人名。
*/
export async function listMomOutboundConsumers(): Promise<string[]> {
const { data } = await api.get<string[]>("/mom-outbounds/consumers");
return data;
}
// 注:原先这里有 addTaskOutboundMaterials / removeTaskOutboundMaterial
// 两个**任务级**的读写函数。物料已统一为**设备级**,任务级的三个端点连同
// 实现一起删除 —— 挂载/查看/删除/报废一律走 productApi 里的
// mountProductOutboundMaterials / removeProductOutboundMaterial。

View File

@ -1,5 +1,9 @@
import api from "./api";
import type { ProductScanResponse } from "../types/api";
import type {
ProductOutboundMaterial,
ProductScanResponse,
ProductScrap,
} from "../types/api";
/** 扫码查询 — 根据 16 位产品身份证查产品 + 顶层任务 */
export async function scanProduct(serialNumber: string): Promise<ProductScanResponse> {
@ -8,3 +12,114 @@ export async function scanProduct(serialNumber: string): Promise<ProductScanResp
);
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

@ -26,6 +26,19 @@ export interface TaskCompletePayload {
remark: string | null;
}
export interface TaskCreatePayload {
product_id: string;
task_name: string;
assignee_id?: string | null;
remark?: string;
/**
* 创建时一并挂载的 MOM 出库**明细行** ID(trans_outbound.id)。
* 前端按整张出库单勾选,提交时把该单全部明细 ID 带过来。
* 不传 = 不挂载。
*/
mom_line_ids?: number[];
}
// ============================================================
// API 方法
// ============================================================
@ -43,6 +56,16 @@ export async function listTasks(productId?: string): Promise<TaskListResponse> {
return data;
}
/**
* 创建任务 — 对应后端 POST /api/v1/tasks/
* payload.mom_line_ids 非空时,后端会在**同一事务**里把对应的 MOM 出库明细
* 挂到新任务上,不存在「任务建好了但物料没挂上」的中间态。
*/
export async function createTask(payload: TaskCreatePayload): Promise<TaskResponse> {
const { data } = await api.post<TaskResponse>("/tasks/", payload);
return data;
}
/** 创建子任务 */
export async function createSubtask(
parentTaskId: string,

View File

@ -0,0 +1,21 @@
/** 用户列表 —— 对接 MOM sys_user,只返回本部门人员 */
import api from "./api";
export interface UserOption {
id: string;
/** 登录账号,即任务里的 assignee_id 口径 */
username: string;
/** 中文姓名 */
full_name: string;
department: string;
}
/**
* 获取本部门人员列表。
* 对应后端 GET /api/v1/users/ —— 部门隔离由服务端按 ORG_DEPARTMENT 钉死,
* 客户端传什么都没用。
*/
export async function listUsers(keyword = "", limit = 200): Promise<UserOption[]> {
const { data } = await api.get<UserOption[]>("/users/", { params: { keyword, limit } });
return data;
}

View File

@ -48,6 +48,80 @@ export interface TaskResponse extends TaskSummary {
records: TaskRecordResponse[];
/** 🔧 任务创建人(追溯谁转交/发起该工序),如"谁转入在库" */
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 {
@ -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 {
id: string;
serial_number: string;
@ -132,4 +284,8 @@ export interface ProductScanResponse {
task_tree: TaskResponse[];
/** 🔧 username→中文姓名映射 */
assignee_names: Record<string, string>;
/** 🔧 出库单据存档(来自 MOM 出库回调),按出库时间倒序。
* 本功能上线前出库的设备这里是空数组,不是错误。
* 可选是为了兼容尚未升级的后端。 */
outbound_records?: ProductOutboundMaterial[];
}

View File

@ -0,0 +1,74 @@
/**
* 领用物料 API —— 「产品详情 → 领用物料 → + 领料」
*
* 背景:PC 端早就能挂出库物料(`MomOutboundPicker`),但**移动端一直没有入口** ——
* 只能在别处挂好、这边看。一线的人(生产领料、测试补料)反而够不着,
* 所以补上这条链。
*
* ⚠️ 挂载粒度是**任务**,不是产品:
* `product_outbound_materials` 才带 `mom_line_id`(MOM trans_outbound.id),
* 而产品级那张 `product_outbounds` 是单据级、没有行 id。
* 报废必须靠 mom_line_id 定位到具体哪条出库明细,所以这里的入口一律挂任务。
* 这也贴合业务:生产领生产任务的料,测试领测试任务的料,各挂各的。
*/
import { get, post, del } from "../utils/request";
/**
* 搜索 MOM 出库单(按单据分页,带回每张单的明细)。
*
* ⚠️ 可见范围(公司隔离 + 跨部门例外)由后端钉死,这里传什么都放不大 ——
* 界面筛选只能收窄。
*/
export function searchMomOutbounds(params = {}) {
return get("/mom-outbounds", params);
}
/** 本部门出库单里出现过的领用人姓名(后端已按可见范围过滤) */
export function listMomOutboundConsumers() {
return get("/mom-outbounds/consumers");
}
/**
* 把选中的 MOM 出库**明细行**挂到设备上。
*
* @param {string} productId
* @param {number[]} momLineIds - MOM trans_outbound.id 列表
* (勾的是一整张出库单,提交时展开成该单的全部明细行 id)
* @param {string} [taskId] - 可选,仅作溯源
* ⚠️ 只传 id,物料名/数量由后端现查 MOM —— 前端传快照会被后端拒绝。
* 幂等:已挂过的明细会被后端跳过。返回该设备当前**全部**出库明细。
*/
export function mountProductOutboundMaterials(productId, momLineIds, taskId) {
return post(`/products/${productId}/outbound-materials`, {
mom_line_ids: momLineIds,
// 任务只是**溯源信息**(这条料挂在哪条任务上),可以不给 ——
// 展示、报废、删除一律按设备走
task_id: taskId || null,
});
}
/**
* 从设备上摘掉一条出库明细(挂错了要能撤)。
*
* @param {string} productId
* @param {number} materialId - **Track 侧那条记录的 id**(不是 mom_line_id)
* ⚠️ 别传错:`id` 是本表主键、只在 Track 库里有;`mom_line_id` 是 MOM
* 那边的出库明细行 id。传反了会删掉另一条料。
*
* 只摘掉 Track 这边的挂载关系,**不动 MOM 里的出库单本身**。
* 已提交的报废记录也不受影响(它的物料信息是快照,独立存在)。
* ⚠️ MOM 回调自动存档的行删不掉(后端 409),那是系统事实。
*/
export function removeProductOutboundMaterial(productId, materialId) {
return del(`/products/${productId}/outbound-materials/${materialId}`);
}
/**
* 整张出库单一起摘掉(挂错了要能一次撤)。
*
* 界面上更容易碰到的是「这一单整个挂错了」,逐条删要删好几下。
* 规则与逐条删**一致**:含 MOM 回调自动存档记录的单整单删不掉(后端 409)。
*/
export function removeProductOutboundOrder(productId, outboundNo) {
return del(`/products/${productId}/outbound-materials/by-order/${encodeURIComponent(outboundNo)}`);
}

View File

@ -0,0 +1,48 @@
/**
* 生产报废 API —— 「产品详情 → 领用物料 → 报废」
*
* 链路:本文件 → Track 后端 /products/{id}/scraps
* → MOM 内部接口(退回不良品 + 建报废申请)
* → MOM 里主管审批 → 库管扫码执行 → 才算得出损失金额
*
* ⚠️ 金额与状态是**后端实时回查 MOM** 的,前端不要缓存、不要自己算:
* `mom_executed=false` 时 `total_loss` 是 null(还没执行),
* 而不是 0 —— 显示成「损失 0 元」会让用户以为东西没价值。
*/
import { get, post } from "../utils/request";
/** 列出该产品的生产报废记录(按提交时间倒序,含实时 MOM 状态与金额) */
export function listProductScraps(productId) {
return get(`/products/${productId}/scraps`);
}
/**
* 提交一条生产报废。
*
* @param {string} productId
* @param {object} payload
* @param {number} payload.mom_line_id - 报废对象:MOM 出库明细行 id
* (就是任务挂载的 outbound_materials[].mom_line_id)
* @param {number} payload.quantity - 报废数量
* @param {string} payload.track_ref - 幂等锚点,**弹层打开时生成一次**,
* 重试复用同一个;换了它就会在 MOM 里多出一张报废单
* @param {string} [payload.reason] - 原因说明
*/
export function submitProductScrap(productId, payload) {
return post(`/products/${productId}/scraps`, payload);
}
/**
* 生成幂等锚点。
*
* ⚠️ 必须在**打开弹层时**生成一次并保存在弹层状态里,提交失败重试要复用同一个 ——
* 每次提交都新生成的话,用户重试就会在 MOM 里多报一张单(重复报废)。
* 用时间戳 + 随机串,在单机范围内足够唯一,且人能看懂大概是什么时候报的。
*/
export function makeTrackRef() {
const d = new Date();
const p = (n) => 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())}`;
const rand = Math.random().toString(36).slice(2, 8);
return `SCRAP-${ts}-${rand}`;
}

View File

@ -31,6 +31,22 @@
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/material/index",
"style": {
"navigationBarTitleText": "领用物料",
"navigationBarBackgroundColor": "#2563EB",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/material/pick",
"style": {
"navigationBarTitleText": "选择 MOM 出库物料",
"navigationBarBackgroundColor": "#2563EB",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/tasks/index",
"style": {

View File

@ -0,0 +1,505 @@
<template>
<view class="page">
<view v-if="loading" class="hint">加载中...</view>
<template v-else>
<!-- 设备抬头:先明确「这是哪台设备的料」,避免看串设备 -->
<view class="card">
<view class="head-line">
<text class="head-sn">{{ product.serial_number }}</text>
<text class="head-name">{{ product.material_name || '—' }}</text>
</view>
<text v-if="product.spec_model" class="head-spec">{{ product.spec_model }}</text>
</view>
<!-- 🚚 出库单据:**按出库单号分组**,一行一张单,点开看明细。
★ 与网页端同一形态(同一张表、同一个接口):单号是主信息,
「领了什么」收在展开区里。
★ 显示这台设备的**全部**出库明细,不按「我领的」过滤 ——
料是领给设备的,不是领给某个人的:生产领的外壳装在这台设备上,
测试时摔坏了就该由测试来报,不能让测试看不见它。
★ 不展示出库类型(用途),也不问「挂到哪条任务」:
现场只需要知道「挂了哪张单、谁挂的、谁出的库」。 -->
<view class="card">
<view class="card-header">
<text class="card-title">🚚 出库单据</text>
<text class="card-add" @tap="goPick">+ 领料</text>
</view>
<text v-if="materials.length" class="count">
共 {{ orders.length }} 张单 / {{ materials.length }} 条料
</text>
<view v-if="!materials.length" class="empty">
<text>暂无关联的出库单。</text>
<text class="empty-sub">点右上角「+ 领料」,选对应的出库单挂到这台设备上。</text>
</view>
<view v-for="o in orders" :key="o.outbound_no" class="order">
<view class="order-head" @tap="toggleExpand(o.outbound_no)">
<!-- 不展示出库类型(用途)—— 现场只关心「这台设备挂了哪张单、
谁挂的、谁出的库」,多一个「内部领用」徽标只是噪音 -->
<view class="order-line1">
<text class="order-no">{{ o.outbound_no }}</text>
</view>
<view class="order-line2">
<text class="order-count">{{ o.items.length }} 条物料</text>
<!-- 谁挂上去的 —— 现场要能追责/问人,只记在库里不显示等于没记 -->
<text v-if="o.addedByName" class="order-by">挂载 {{ o.addedByName }}</text>
<text class="order-time">{{ fmtTime(o.outbound_time) }}</text>
<!-- 整单删除:挂错了要能一次摘掉。只在**全部**是人工挂的时显示 ——
含 MOM 回调存档的单不给删(后端也拦)。
做成小按钮而不是裸文字:裸文字在窄屏上会被时间和箭头挤没,
现场根本看不出这里能点 -->
<view v-if="o.allManual" class="order-del" @tap.stop="confirmRemoveOrder(o)">
<text>删除</text>
</view>
<text class="chev">{{ expanded[o.outbound_no] ? '▲' : '▼' }}</text>
</view>
</view>
<view v-if="expanded[o.outbound_no]" class="lines">
<view v-for="m in o.items" :key="m.mom_line_id" class="line">
<view class="line-info">
<text class="line-name">{{ m.material_name || '(未命名物料)' }}</text>
<text class="line-meta">
<text v-if="m.spec_model">{{ m.spec_model }} · </text>×{{ m.quantity }}
<text v-if="m.consumer_name"> · 领用 {{ formatName(m.consumer_name) }}</text>
</text>
</view>
<view class="row-btns">
<view class="btn-scrap" @tap.stop="openScrapDialog(m)"><text>报废</text></view>
<view class="btn-del" @tap.stop="confirmRemove(m)"><text>删除</text></view>
</view>
</view>
</view>
</view>
</view>
<!-- ♻️ 报废记录:状态与金额由后端实时回查 MOM -->
<view class="card" v-if="scrapRecords.length">
<view class="card-header">
<text class="card-title">♻️ 报废记录</text>
<text class="count">共 {{ scrapRecords.length }} 条</text>
</view>
<view v-for="s in scrapRecords" :key="s.id" class="scrap-row">
<view class="row-line1">
<text class="scrap-name">{{ s.material_name || '(未命名物料)' }}</text>
<text :class="['badge', badgeClass(s)]">{{ s.mom_status_label || '状态未知' }}</text>
<text class="scrap-qty">×{{ s.quantity }}</text>
</view>
<view class="row-line2">
<text class="scrap-meta">报废单 {{ s.scrap_request_no }}</text>
<text v-if="s.submitted_by" class="scrap-meta">提交人 {{ formatName(s.submitted_by) }}</text>
<!-- ★ 只有执行过才有金额。未执行显示「—」不显示 0 ——
0 会让人以为「这东西不值钱」,其实是「还没扫码执行」 -->
<text v-if="s.mom_executed" class="scrap-meta">损失 ¥{{ Number(s.total_loss).toFixed(2) }}</text>
</view>
<text v-if="s.reason" class="scrap-reason">{{ s.reason }}</text>
</view>
</view>
</template>
<!-- 报废弹层:数量 + 说明。分类不让人选 ——
走这条路进来的料都是「已领用到产线」,按定义就是生产损耗,
后端固定按 PRODUCTION 提交,少一个会选错的地方。 -->
<view v-if="dlg.visible" class="overlay" @tap="closeDlg">
<view class="popup" @tap.stop>
<text class="popup-title">报废 · {{ dlg.materialName }}</text>
<text class="popup-hint">{{ dlg.specModel || '—' }} | 原领用人 {{ dlg.consumerName || '—' }}</text>
<view class="field-label">报废数量 <text class="required">*</text></view>
<input v-model="dlg.quantity" type="digit" class="popup-input" :placeholder="'最多 ' + dlg.maxQty" />
<view class="field-label">原因说明</view>
<textarea v-model="dlg.reason" class="popup-textarea" placeholder="例如:测试时跌落,外壳磕裂" maxlength="200" />
<!-- ★ 代报确认:报的不是自己领的料时多一道。这是**防误操作**不是权限 ——
后端不会因为这条拒绝(料的归属是设备不是人,谁发现谁报),
真正的把关在 MOM 侧主管审批。 -->
<view v-if="dlg.isProxy" class="proxy" @tap="dlg.confirmed = !dlg.confirmed">
<text class="proxy-icon">{{ dlg.confirmed ? '☑' : '☐' }}</text>
<text class="proxy-text">这条料不是你领的({{ dlg.consumerName }} 领用),确认代报?</text>
</view>
<view class="popup-btns">
<button class="btn-cancel" @tap="closeDlg">取消</button>
<button class="btn-primary" :disabled="submitting || !dlgReady" @tap="doSubmit">
{{ submitting ? '提交中...' : '提交报废' }}
</button>
</view>
</view>
</view>
</view>
</template>
<script>
import { get } from "../../utils/request";
import { formatUserName } from "../../utils/format";
import { listProductScraps, submitProductScrap, makeTrackRef } from "../../api/scrap";
import { removeProductOutboundMaterial, removeProductOutboundOrder } from "../../api/material";
export default {
data() {
return {
productId: "",
serial: "",
product: {},
loading: true,
scrapRecords: [],
submitting: false,
// 展开的单据号集合:{ outbound_no: true }。
// 默认全收起 —— 一台设备可能领了很多单,全摊开要滑很久才看得完
expanded: {},
dlg: {
visible: false, momLineId: null, materialName: "", specModel: "",
consumerName: "", maxQty: 0, quantity: "", reason: "",
isProxy: false, confirmed: false, trackRef: "",
},
// 当前登录人姓名,用于判断「代报」
me: null,
};
},
computed: {
/** 这台设备挂载的全部 MOM 出库明细 */
materials() {
// 读**统一后**的设备出库明细(outbound_records)。
// 以前读 task_tree[].outbound_materials —— 那是被合并掉的第二个数据源,
// 也正是「网页端看不到移动端挂的料」的根因。
return (this.product && this.product.outbound_records) || [];
},
/** 按**出库单号**分组,与网页端同一形态:一行一张单,点开看明细 */
orders() {
const map = {};
const out = [];
this.materials.forEach((m) => {
const no = m.outbound_no || "(无单号)";
if (!map[no]) {
map[no] = {
outbound_no: no,
outbound_time: m.outbound_time,
// 谁挂的:同一张单的明细是同一次挂载写入的,取第一条即可
addedByName: m.added_by_name || m.added_by || "",
items: [],
// 整单是否全部人工挂的 —— 决定「整单删除」按不按得动
allManual: true,
};
out.push(map[no]);
}
map[no].items.push(m);
if (m.source !== "manual") map[no].allManual = false;
});
return out;
},
/** 代报时必须勾选确认才能提交 */
dlgReady() {
return !this.dlg.isProxy || this.dlg.confirmed;
},
},
onLoad(options) {
this.productId = options.productId || "";
this.serial = options.serial || "";
// 登录用户存在 "user" 里(JSON 字符串),与 profile / login 页同一口径。
// 只用它来判断「这条料是不是我领的」——判错也只是多让用户勾一下确认,
// 真正的防线在 MOM 侧主管审批
try {
const raw = uni.getStorageSync("user");
this.me = raw ? (typeof raw === "string" ? JSON.parse(raw) : raw) : null;
} catch (e) {
this.me = null;
}
this.load();
},
onShow() {
// 从「选择出库物料」页返回时重新拉一次,把刚挂上的料显示出来
if (!this.loading) this.load();
},
methods: {
formatName: formatUserName,
async load() {
this.loading = true;
try {
// 用扫码接口:它一次带回 product.outbound_records(设备出库明细,
// 统一后的唯一来源)与 task_tree(只在「+ 领料」时用来带个默认任务作溯源)。
// 没另开专用接口:这份数据产品详情页本来就在拉,语义一致,复用即可
this.product = await get(`/products/scan/${this.serial}`);
await this.fetchScrapRecords();
} catch (e) {
uni.showToast({ title: e?.data?.detail || "加载失败", icon: "none" });
} finally {
this.loading = false;
}
},
async fetchScrapRecords() {
try {
this.scrapRecords = (await listProductScraps(this.productId)) || [];
} catch (e) {
// 静默:报废记录是附加信息,拉不到不该挡住物料列表
console.warn("[material] 拉报废记录失败:", e?.data?.detail || e);
this.scrapRecords = [];
}
},
/** 展开/收起某张出库单的明细 */
toggleExpand(no) {
this.expanded = { ...this.expanded, [no]: !this.expanded[no] };
},
fmtTime(iso) {
if (!iso) return "—";
const d = new Date(iso);
if (isNaN(d.getTime())) return "—";
const p = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
},
/**
* 领料:先定「挂到哪条任务」,再进选择器。
*
* ★ 为什么必须先选任务:料在 Track 侧是挂在**任务**上的
* (`product_outbound_materials`),只有它带 `mom_line_id`,
* 而报废必须靠它定位到具体哪条出库明细。所以挂载动作离不开任务。
* 但任务不该像之前那样当成列表分组抬头(现场看不懂「生产·小龙虾」),
* 所以改成点「+ 领料」时才选一次。
*/
goPick() {
// 统一到**设备级**后任务变成可选(只作溯源),所以不再让用户先选任务 ——
// 少一步操作,也少一处「到底挂到哪条任务」的困惑
this.toPick();
},
toPick() {
// 把**这台设备已挂过的单号**带给选择器,让那边把「已挂载」标出来并禁止再选。
// 后端本身是幂等的(已挂的明细会跳过),标出来只是省得用户白勾一遍
const mounted = [...new Set(this.materials.map((m) => m.outbound_no).filter(Boolean))];
// 不带任务 —— 挂载不需要挂到某条任务上(任务只是溯源,可空)。
// 「谁挂上去的」记在 added_by、「谁出库的」由 MOM 快照带过来,够了。
uni.navigateTo({
url: `/pages/material/pick?productId=${this.productId}`
+ `&serial=${encodeURIComponent(this.serial || "")}`
+ `&mounted=${encodeURIComponent(mounted.join(","))}`,
// navigateTo 失败是静默的(只在控制台留一行),必须弹出来,
// 多半是 pages.json 没重新读 —— HBuilderX 只认启动时的那份
fail: (err) => {
console.error("[material] 打不开选择器:", err);
uni.showModal({
title: "打不开「选择出库物料」",
content: "页面未注册或未编译:" + (err && err.errMsg ? err.errMsg : err)
+ "\n\n请完全关闭并重启 HBuilderX 后重新运行",
showCancel: false,
});
},
});
},
openScrapDialog(material) {
const myName = (this.me && (this.me.display_name || this.me.username)) || "";
const consumer = material.consumer_name || "";
this.dlg = {
visible: true,
momLineId: material.mom_line_id,
materialName: material.material_name || "(未命名物料)",
specModel: material.spec_model || "",
consumerName: consumer,
maxQty: material.quantity,
quantity: String(material.quantity || ""),
reason: "",
isProxy: !!consumer && !!myName && consumer !== myName,
confirmed: false,
// ★ 打开时生成一次,重试复用 —— 每次提交都换新的话,
// 用户重试会在 MOM 里多报一张报废单
trackRef: makeTrackRef(),
};
},
closeDlg() {
if (this.submitting) return; // 提交中不许关,避免用户以为没提交
this.dlg.visible = false;
},
/** 整张出库单一起摘掉(挂错了要能一次撤)。规则与逐条删一致:含 MOM
* 回调存档的单删不掉(后端 409),那是系统事实 */
confirmRemoveOrder(order) {
uni.showModal({
title: '删除整张出库单',
content: '把出库单「' + order.outbound_no + '」从这台设备上整张摘掉?\n\n'
+ '只解除 Track 这边的挂载关系,不会动 MOM 里的出库单本身,'
+ '已提交的报废记录也不受影响。',
confirmText: '删除',
confirmColor: '#dc2626',
success: (res) => { if (res.confirm) this.doRemoveOrder(order.outbound_no); },
});
},
async doRemoveOrder(outboundNo) {
try {
await removeProductOutboundOrder(this.productId, outboundNo);
uni.showToast({ title: '已删除整张出库单', icon: 'success' });
await this.load();
} catch (e) {
uni.showModal({
title: '删除失败',
content: String(e?.data?.detail || e?.errMsg || '删除失败'),
showCancel: false,
});
}
},
/** 摘掉一条挂错的领用物料(与 PC 端「出库单据」卡的删除同一语义) */
confirmRemove(material) {
// 二次确认必须把「删的是什么、不删什么」说清楚 ——
// 用户最怕的是「我删了会不会把 MOM 里的出库单也搞没了」
uni.showModal({
title: '删除出库明细',
content: '把「' + (material.material_name || '此物料') + '」从这台设备上摘掉?\n\n'
+ '只解除 Track 这边的挂载关系,不会动 MOM 里的出库单本身,'
+ '已提交的报废记录也不受影响。',
confirmText: '删除',
confirmColor: '#dc2626',
success: (res) => { if (res.confirm) this.doRemove(material); },
});
},
async doRemove(material) {
try {
// ⚠️ 传的是**记录 id**(material.id,Track 侧主键),不是 mom_line_id
await removeProductOutboundMaterial(this.productId, material.id);
uni.showToast({ title: '已删除', icon: 'success' });
await this.load();
} catch (e) {
uni.showModal({
title: '删除失败',
content: String(e?.data?.detail || e?.errMsg || '删除失败'),
showCancel: false,
});
}
},
async doSubmit() {
const d = this.dlg;
const qty = Number(d.quantity);
if (!qty || qty <= 0) return uni.showToast({ title: "请填写报废数量", icon: "none" });
if (qty > Number(d.maxQty)) {
return uni.showToast({ title: `不能超过 ${d.maxQty}`, icon: "none" });
}
if (d.isProxy && !d.confirmed) {
return uni.showToast({ title: "请先确认代报", icon: "none" });
}
this.submitting = true;
try {
await submitProductScrap(this.productId, {
mom_line_id: d.momLineId,
quantity: qty,
reason: (d.reason || "").trim() || null,
track_ref: d.trackRef,
});
uni.showToast({ title: "已提交,待主管审批", icon: "success" });
d.visible = false;
await this.fetchScrapRecords();
} catch (e) {
// ★ 不关弹层、不换 trackRef:用户改完数量或稍后重试走的是同一个幂等键,
// 不会在 MOM 里多报一张单
uni.showModal({
title: "报废提交失败",
content: String(e?.data?.detail || e?.errMsg || "提交失败"),
showCancel: false,
});
} finally {
this.submitting = false;
}
},
badgeClass(s) {
if (s.mom_executed) return "badge-done";
if (s.mom_status === 2 || s.mom_status === 4) return "badge-off";
return "badge-wait";
},
},
};
</script>
<style scoped>
.page { padding: 12px; padding-bottom: 40px; }
.hint { text-align: center; padding: 40px 0; color: #6b7280; font-size: 13px; }
.card { background: #fff; border-radius: 12px; padding: 14px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.card-title { font-size: 15px; font-weight: 700; }
.count { font-size: 12px; color: #9ca3af; }
.head-line { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.head-sn { font-family: monospace; font-size: 14px; font-weight: 700; color: #1f2937; }
.head-name { font-size: 13px; color: #4b5563; }
.head-spec { font-size: 11px; color: #9ca3af; display: block; margin-top: 2px; }
.empty { padding: 16px 0; text-align: center; }
.empty text { display: block; font-size: 13px; color: #6b7280; }
.empty-sub { font-size: 11px; color: #9ca3af; margin-top: 4px; }
/* 领料按钮:给足点击面积(工地上戴手套点,小了容易点不中) */
.card-add { font-size: 12px; font-weight: 600; color: #2563eb; padding: 4px 10px; border: 1px solid #bfdbfe; border-radius: 8px; background: #eff6ff; flex-shrink: 0; }
/* 出库单:一行一张单,点标题行展开明细 */
.order { border: 1px solid #f3f4f6; border-radius: 8px; margin-bottom: 8px; overflow: hidden; }
.order:last-child { margin-bottom: 0; }
.order-head { padding: 10px; background: #fafafa; }
.order-line1 { display: flex; align-items: center; gap: 6px; }
.order-no { font-family: monospace; font-size: 13px; font-weight: 700; color: #1f2937; word-break: break-all; }
/* flex-wrap:窄屏上「条数 + 时间 + 删除 + 箭头」可能放不下,
换行总比把删除按钮挤没强 */
.order-line2 { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 4px; }
.order-count { font-size: 11px; color: #6b7280; }
.order-time { font-size: 11px; color: #9ca3af; }
/* 谁挂上去的:比时间略深一点,便于一眼看到 */
.order-by { font-size: 11px; color: #6b7280; }
/* 整单删除:做成与明细行删除同样的小按钮 —— 裸文字在窄屏上会被时间和箭头
挤没,现场看不出这里能点。flex-shrink:0 保证再挤也不会消失 */
.order-del { margin-left: auto; flex-shrink: 0; padding: 3px 10px; border: 1px solid #fecaca; border-radius: 8px; background: #fef2f2; color: #dc2626; font-size: 12px; }
.chev { font-size: 11px; color: #9ca3af; flex-shrink: 0; }
/* 展开区:明细行 + 每条自己的操作按钮 */
.lines { padding: 8px 10px; border-top: 1px solid #f3f4f6; }
.line { display: flex; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 1px solid #f9fafb; }
.line:last-child { border-bottom: none; padding-bottom: 0; }
.line-info { flex: 1; min-width: 0; }
.line-name { font-size: 13px; font-weight: 600; color: #1f2937; display: block; }
.line-meta { font-size: 11px; color: #9ca3af; display: block; margin-top: 2px; }
/* 两个操作并排。都给足点击面积 —— 工地上戴手套点,小了容易点不中 */
.row-btns { display: flex; gap: 6px; flex-shrink: 0; }
.btn-scrap { padding: 6px 12px; border: 1px solid #fecaca; border-radius: 8px; background: #fef2f2; color: #dc2626; font-size: 12px; font-weight: 600; }
.btn-del { padding: 6px 12px; border: 1px solid #e5e7eb; border-radius: 8px; background: #fff; color: #6b7280; font-size: 12px; }
.scrap-row { border: 1px solid #f3f4f6; border-radius: 8px; padding: 8px; margin-bottom: 6px; }
.scrap-row:last-child { margin-bottom: 0; }
.row-line1 { display: flex; align-items: center; gap: 6px; }
.scrap-name { font-size: 13px; font-weight: 600; color: #1f2937; flex: 1; min-width: 0; }
.scrap-qty { font-size: 11px; color: #9ca3af; flex-shrink: 0; }
.row-line2 { display: flex; flex-wrap: wrap; gap: 4px 10px; margin-top: 4px; }
.scrap-meta { font-size: 11px; color: #6b7280; }
.scrap-reason { font-size: 11px; color: #9ca3af; margin-top: 3px; display: block; }
.badge { font-size: 10px; font-weight: 700; border-radius: 10px; padding: 1px 6px; flex-shrink: 0; }
.badge-wait { color: #b45309; background: #fef3c7; }
.badge-done { color: #047857; background: #d1fae5; }
.badge-off { color: #6b7280; background: #e5e7eb; }
.overlay { position: fixed; inset: 0; z-index: 999; background: rgba(0,0,0,0.45); display: flex; align-items: flex-end; justify-content: center; }
.popup { width: 100%; max-width: 480px; background: #fff; border-radius: 16px 16px 0 0; padding: 20px 16px 32px; max-height: 80vh; overflow-y: auto; }
.popup-title { font-size: 16px; font-weight: 700; display: block; text-align: center; margin-bottom: 6px; }
.popup-hint { font-size: 12px; color: #9ca3af; display: block; text-align: center; }
.popup-input { width: 100%; height: 42px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 0 10px; font-size: 14px; margin: 8px 0; box-sizing: border-box; }
.popup-textarea { width: 100%; height: 80px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 10px; font-size: 14px; margin: 10px 0; box-sizing: border-box; }
.field-label { font-size: 14px; font-weight: 600; color: #374151; margin-top: 10px; margin-bottom: 4px; }
.required { color: #ef4444; }
.popup-btns { display: flex; gap: 10px; margin-top: 16px; }
.btn-cancel { flex: 1; height: 42px; border: 1px solid #e5e7eb; border-radius: 10px; background: #fff; color: #6b7280; font-size: 14px; line-height: 42px; }
.btn-primary { flex: 1; height: 42px; border: none; border-radius: 10px; background: #2563eb; color: #fff; font-size: 14px; font-weight: 600; line-height: 42px; }
.btn-primary[disabled] { opacity: 0.5; }
.proxy { display: flex; align-items: flex-start; gap: 6px; margin-top: 12px; padding: 8px 10px; border: 1px solid #fde68a; border-radius: 8px; background: #fffbeb; }
.proxy-icon { font-size: 14px; color: #b45309; flex-shrink: 0; }
.proxy-text { font-size: 12px; color: #92400e; line-height: 1.4; }
</style>

View File

@ -0,0 +1,354 @@
<template>
<view class="page">
<!-- 挂到这台设备。统一后任务只是溯源信息、不再需要用户先选,
所以这里只提示「挂到哪台设备」,不再显示(也不要求)任务名 -->
<view class="target-bar">
<text class="target-label">挂到设备</text>
<text class="target-name">{{ serial || productId }}</text>
</view>
<!-- 搜索 -->
<view class="search-bar">
<input v-model="keyword" class="search-input" confirm-type="search"
placeholder="出库单号 / 物料名称 / 规格 / SKU / 领用人"
@confirm="reload" @input="onKeywordInput" />
<text v-if="keyword" class="search-clear" @tap="clearKeyword">✕</text>
</view>
<!-- 筛选:日期区间 + 领用人。都是**收窄**条件,可见范围由后端钉死 -->
<view class="filter-bar">
<picker mode="date" :value="startDate" @change="(e) => { startDate = e.detail.value; reload(); }">
<view class="date-chip">{{ startDate || '开始日期' }}</view>
</picker>
<text class="date-sep">→</text>
<picker mode="date" :value="endDate" @change="(e) => { endDate = e.detail.value; reload(); }">
<view class="date-chip">{{ endDate || '结束日期' }}</view>
</picker>
<picker :range="consumerRange" @change="onConsumerChange">
<view class="date-chip">{{ consumer || '全部领用人' }}</view>
</picker>
<text v-if="hasFilter" class="filter-clear" @tap="clearFilter">清空</text>
</view>
<text class="total-line">共 {{ total }} 张单据</text>
<!-- 结果 -->
<view v-if="loading" class="hint">加载中...</view>
<view v-else-if="!orders.length" class="hint">
{{ hasFilter ? '没有匹配的出库单,试试放宽条件' : '没有可选的出库单' }}
</view>
<view v-else class="list">
<view v-for="o in orders" :key="o.outbound_no"
:class="['order', isMounted(o) ? 'order-mounted' : (selected[o.outbound_no] ? 'order-selected' : '')]"
@tap="toggleOrder(o)">
<view class="order-head">
<view :class="['tick', selected[o.outbound_no] ? 'tick-on' : '']">
<text v-if="selected[o.outbound_no]">✓</text>
</view>
<text :class="['order-no', isMounted(o) ? 'order-no-muted' : '']">{{ o.outbound_no }}</text>
<text v-if="isMounted(o)" class="tag tag-muted">已挂载</text>
<text v-else-if="o.outbound_type_label" class="tag">{{ o.outbound_type_label }}</text>
<text class="order-time">{{ fmtTime(o.outbound_time) }}</text>
</view>
<view class="order-meta">
<text v-if="o.consumer_name">领用 {{ o.consumer_name }}</text>
<text v-if="o.operator_name">经办 {{ o.operator_name }}</text>
<text>{{ o.line_count }} 条物料<text v-if="o.total_quantity != null"> · 合计 {{ o.total_quantity }}</text></text>
</view>
<view class="order-foot">
<text class="expand" @tap.stop="toggleExpand(o.outbound_no)">
{{ expanded[o.outbound_no] ? '收起明细 ▲' : '查看物料明细 ▼' }}
</text>
</view>
<view v-if="expanded[o.outbound_no]" class="lines">
<view v-for="l in o.lines" :key="l.line_id" class="line">
<text class="line-name">{{ l.material_name || '(未命名物料)' }}</text>
<text v-if="l.spec_model" class="line-spec">{{ l.spec_model }}</text>
<text class="line-qty">×{{ l.quantity }}</text>
</view>
</view>
</view>
</view>
<!-- 底部固定操作条 -->
<view class="footer">
<text class="footer-info">已选 {{ selectedCount }} 张单({{ selectedLineCount }} 条物料)</text>
<button class="footer-btn" :disabled="!selectedCount || submitting" @tap="doConfirm">
{{ submitting ? '挂载中...' : '确认挂载' }}
</button>
</view>
</view>
</template>
<script>
import { searchMomOutbounds, listMomOutboundConsumers, mountProductOutboundMaterials } from "../../api/material";
export default {
data() {
return {
productId: "",
serial: "",
taskId: "", // 可空,仅作溯源
keyword: "",
startDate: "",
endDate: "",
consumer: "",
consumerOptions: [""],
// 当前登录人(定默认领用人筛选用),与 PC 端同一口径
me: null,
orders: [],
total: 0,
loading: false,
selected: {}, // { outbound_no: true }
expanded: {},
mountedNos: [], // 已挂过的单号(后端幂等会跳过,这里标出来让用户看得见)
submitting: false,
debounceTimer: null,
};
},
computed: {
consumerRange() { return ["全部领用人"].concat(this.consumerOptions.filter(Boolean)); },
hasFilter() { return !!(this.keyword.trim() || this.consumer || this.startDate || this.endDate); },
selectedOrders() { return this.orders.filter((o) => this.selected[o.outbound_no]); },
selectedCount() { return this.selectedOrders.length; },
selectedLineCount() {
return this.selectedOrders.reduce((n, o) => n + (o.lines || []).length, 0);
},
},
async onLoad(options) {
this.productId = options.productId || "";
this.serial = decodeURIComponent(options.serial || "");
// 任务可空:只有设备下恰好一条任务时上游才会带,仅作溯源
this.taskId = options.taskId || "";
// 上游把单号列表做了 encodeURIComponent(逗号会变成 %2C),这里必须解回来再切 ——
// 不解的话整个列表会当成**一个**单号,已挂载一个都标不出来
const rawMounted = decodeURIComponent(options.mounted || "");
this.mountedNos = rawMounted.split(",").map((s) => s.trim()).filter(Boolean);
// 当前登录人:用来定默认领用人筛选(与 PC 端 MomOutboundPicker 同一口径)
try {
const raw = uni.getStorageSync("user");
this.me = raw ? (typeof raw === "string" ? JSON.parse(raw) : raw) : null;
} catch (e) {
this.me = null;
}
// ★ 顺序不能反:必须先拿到领用人列表,才能判断「自己在不在里面」,
// 也才能带着默认条件只查一次。反过来会先查一次全部、再查一次,
// 不但多打一次 MOM,界面还会先闪一屏别人的单据再被抽走。
await this.loadConsumers();
this.reload();
},
methods: {
/** 已挂载:该单全部明细都已在任务上(后端幂等会跳过,这里只是标记) */
isMounted(o) {
return this.mountedNos.indexOf(o.outbound_no) >= 0;
},
fmtTime(iso) {
if (!iso) return "—";
const d = new Date(iso);
if (isNaN(d.getTime())) return "—";
const p = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
},
async loadConsumers() {
try {
this.consumerOptions = (await listMomOutboundConsumers()) || [];
} catch (e) {
// 拉不到名单就退回「全部领用人」——不能因为下拉挂了就卡住整个选择器
this.consumerOptions = [];
}
// ★ 默认筛成**当前账号本人**(与 PC 端 MomOutboundPicker 完全一致)。
// 一线的人绝大多数时候查的是自己领的单,默认筛上能省一次选择。
// ⚠️ 但本人在可选列表里**不存在时不硬筛**:筛了会得到一屏空白,
// 用户会以为系统坏了 —— 宁可先给他看全部。
const myName = (this.me && this.me.display_name) || "";
this.consumer = myName && this.consumerOptions.indexOf(myName) >= 0 ? myName : "";
// 兜底:万一账号里没有 display_name,至少用用户名试一次
if (!this.consumer && this.me && this.me.username
&& this.consumerOptions.indexOf(this.me.username) >= 0) {
this.consumer = this.me.username;
}
},
async reload() {
this.loading = true;
try {
const res = await searchMomOutbounds({
keyword: this.keyword.trim() || undefined,
start_date: this.startDate || undefined,
end_date: this.endDate || undefined,
consumer: this.consumer || undefined,
limit: 30,
});
this.orders = res.orders || [];
this.total = res.total || 0;
} catch (e) {
uni.showToast({ title: e?.data?.detail || "加载失败", icon: "none" });
this.orders = [];
this.total = 0;
} finally {
this.loading = false;
}
},
// 输入防抖 300ms:不防的话每敲一个字打一次 MOM,慢且刷屏
onKeywordInput() {
if (this.debounceTimer) clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => this.reload(), 300);
},
clearKeyword() {
this.keyword = "";
this.reload();
},
onConsumerChange(e) {
const idx = Number(e.detail.value) || 0;
this.consumer = idx === 0 ? "" : this.consumerRange[idx];
this.reload();
},
clearFilter() {
this.keyword = "";
this.startDate = "";
this.endDate = "";
this.consumer = "";
this.reload();
},
toggleOrder(o) {
if (this.isMounted(o)) return; // 已挂的不可再选(后端也会跳过)
const next = { ...this.selected };
if (next[o.outbound_no]) delete next[o.outbound_no];
else next[o.outbound_no] = true;
this.selected = next;
},
toggleExpand(no) {
this.expanded = { ...this.expanded, [no]: !this.expanded[no] };
},
/**
* 勾选里有没有**不是自己领的**单。
*
* 判据:出库单的领用人(MOM 侧自由填写的姓名)≠ 当前登录人姓名。
* ⚠️ 这是**提示**不是权限 —— 料的归属是设备不是人,代挂是合理操作
* (测试替生产补挂、库管代录都会发生)。拦一下只是防止「手滑勾错别人的单」。
*/
proxyOrders() {
const myName = (this.me && (this.me.display_name || this.me.username)) || "";
if (!myName) return [];
return this.selectedOrders.filter(
(o) => o.consumer_name && o.consumer_name !== myName);
},
async doConfirm() {
// 勾的是**整张单**:提交时展开成该单的全部明细行 id。
// 后端按明细行落库,且会跳过已挂过的(幂等)
const lineIds = this.selectedOrders.flatMap((o) => (o.lines || []).map((l) => l.line_id));
if (!lineIds.length) return uni.showToast({ title: "没有可挂载的明细", icon: "none" });
// ⚠️ 代挂确认:勾了不是自己领的单,先把话说清楚再提交。
// 放这里是**提交前**拦一道,用户还能取消回去改勾选。
const proxy = this.proxyOrders;
if (proxy.length) {
const who = [...new Set(proxy.map((o) => o.consumer_name).filter(Boolean))].join("、");
const ok = await new Promise((resolve) => {
uni.showModal({
title: "确认代挂",
content: `选中里有 ${proxy.length} 张不是你自己领的单(领用人:${who})。\n\n`
+ "挂上去会记在你名下(挂载人),确认继续?",
confirmText: "确认代挂",
success: (res) => resolve(res.confirm),
fail: () => resolve(false),
});
});
if (!ok) return;
}
this.submitting = true;
try {
await mountProductOutboundMaterials(this.productId, lineIds, this.taskId);
uni.showToast({ title: "已挂载", icon: "success" });
// 返回领用物料页;它的 onShow 会重新拉一次,把新挂的料显示出来。
// ⚠️ navigateBack 失败是**静默**的(只在控制台留一行),用户会以为
// 「报上去了但没反应」—— 必须弹出来,并给一条手动退路。
setTimeout(() => {
uni.navigateBack({
fail: (err) => {
console.error("[material] 返回失败:", err);
uni.showModal({
title: "已挂载,但没自动返回",
content: "请手动点左上角返回,列表会自动刷新。",
showCancel: false,
});
},
});
}, 600);
} catch (e) {
uni.showModal({
title: "挂载失败",
content: String(e?.data?.detail || e?.errMsg || "挂载失败"),
showCancel: false,
});
} finally {
this.submitting = false;
}
},
},
};
</script>
<style scoped>
/* 底部有固定操作条,留出高度避免最后一条被盖住 */
.page { padding: 12px; padding-bottom: 90px; }
.target-bar { display: flex; align-items: center; gap: 8px; background: #eff6ff; border-radius: 10px; padding: 8px 12px; margin-bottom: 10px; }
.target-label { font-size: 12px; color: #6b7280; }
.target-name { font-size: 13px; font-weight: 700; color: #2563eb; }
.search-bar { position: relative; margin-bottom: 8px; }
.search-input { width: 100%; height: 40px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 0 32px 0 12px; font-size: 13px; box-sizing: border-box; background: #fff; }
.search-clear { position: absolute; right: 10px; top: 12px; font-size: 14px; color: #9ca3af; }
.filter-bar { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
.date-chip { font-size: 12px; color: #4b5563; background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 5px 10px; }
.date-sep { font-size: 12px; color: #9ca3af; }
.filter-clear { font-size: 12px; color: #9ca3af; padding: 5px 4px; }
.total-line { font-size: 12px; color: #9ca3af; display: block; margin-bottom: 8px; }
.hint { text-align: center; padding: 40px 16px; color: #9ca3af; font-size: 13px; }
.list { display: block; }
.order { background: #fff; border: 1px solid #f3f4f6; border-radius: 10px; padding: 10px; margin-bottom: 8px; }
.order-selected { border-color: #bfdbfe; background: #eff6ff; }
.order-mounted { background: #f9fafb; border-color: #e5e7eb; }
.order-head { display: flex; align-items: center; gap: 6px; }
.tick { width: 18px; height: 18px; border: 1px solid #d1d5db; border-radius: 4px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 12px; color: #fff; }
.tick-on { background: #2563eb; border-color: #2563eb; }
.order-no { font-family: monospace; font-size: 12px; font-weight: 700; color: #1f2937; }
.order-no-muted { color: #9ca3af; }
.order-time { font-size: 11px; color: #9ca3af; margin-left: auto; flex-shrink: 0; }
.tag { font-size: 10px; font-weight: 600; color: #6d28d9; background: #ede9fe; border-radius: 10px; padding: 1px 6px; flex-shrink: 0; }
.tag-muted { color: #6b7280; background: #e5e7eb; }
.order-meta { display: flex; flex-wrap: wrap; gap: 4px 10px; margin-top: 6px; padding-left: 24px; }
.order-meta text { font-size: 11px; color: #6b7280; }
.order-foot { padding-left: 24px; margin-top: 4px; }
/* 「查看物料明细」给足点击面积:这是本页最主要的操作之一,太小点不中 */
.expand { font-size: 11px; color: #2563eb; padding: 6px 0; display: inline-block; }
.lines { margin-top: 4px; padding-left: 24px; border-top: 1px solid #f3f4f6; padding-top: 6px; }
.line { display: flex; flex-wrap: wrap; gap: 4px 8px; margin-bottom: 3px; }
.line-name { font-size: 12px; color: #374151; font-weight: 500; }
.line-spec { font-size: 11px; color: #9ca3af; }
.line-qty { font-size: 11px; color: #6b7280; }
.footer { position: fixed; left: 0; right: 0; bottom: 0; display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: #fff; border-top: 1px solid #e5e7eb; }
.footer-info { flex: 1; font-size: 12px; color: #4b5563; }
.footer-btn { width: 120px; height: 40px; line-height: 40px; border-radius: 10px; background: #2563eb; color: #fff; font-size: 14px; font-weight: 600; border: none; margin: 0; }
.footer-btn[disabled] { opacity: 0.5; }
</style>

View File

@ -35,6 +35,53 @@
<text :class="['value', product.current_location_id === 'virtual_warehouse' ? 'warehouse' : '']">{{ formatUserName(product.current_location_id) }}</text>
</view>
</view>
<!-- 🚚 出库单据入口。
★ 出库单是挂在**这台设备**上的、不是挂在某个人身上的,所以入口放在
「这台设备」的信息卡里最自然。点进去能看全部出库明细、能报废、能补挂。
★ 常显不隐藏:以前这里什么都没有时整块消失,用户根本不知道有这功能。
没料时也要看得见入口。 -->
<view class="mat-entry" @tap="goMaterialPage">
<text class="mat-entry-icon">🚚</text>
<text class="mat-entry-label">出库单据</text>
<!-- 张数与条数都给:只显示「N 条」看不出挂了几张单,反过来也一样。
没料时不显示计数,只留入口 -->
<text class="mat-entry-count" v-if="mountedMaterials.length">
{{ mountedOrderCount }} 张单 / {{ mountedMaterials.length }} 条料
</text>
<text class="mat-entry-count" v-else>未挂载</text>
<text class="mat-entry-arrow">›</text>
</view>
</view>
<!-- 注:原先这里还有一张独立的「出库单据」卡,与上面产品信息卡里的
入口按钮**重复**(两处都叫「出库单据」、说的是同一件事)。
详情页只留入口按钮,单据清单与物料明细都在那一页里 ——
详情页已经很长,没必要再铺一遍。 -->
<!-- ♻️ 报废记录:本设备报过的废。状态与金额由后端实时回查 MOM。
这里只**展示结果**;报案本身(选料、填数量)在「领用物料」页里做 ——
详情页已经很长,把操作挪出去,这里留一眼能看懂的进度。 -->
<view class="card" v-if="scrapRecords.length">
<view class="card-header">
<text class="card-title">♻️ 报废记录</text>
<text class="ob-count">共 {{ scrapRecords.length }} 条</text>
</view>
<view v-for="s in scrapRecords" :key="s.id" class="ob-row">
<view class="ob-line1">
<text class="ob-no">{{ s.material_name || '(未命名物料)' }}</text>
<text :class="['sc-badge', scrapBadgeClass(s)]">{{ s.mom_status_label || '状态未知' }}</text>
<text class="ob-time">×{{ s.quantity }}</text>
</view>
<view class="ob-line2">
<text class="ob-meta">报废单 {{ s.scrap_request_no }}</text>
<text v-if="s.submitted_by" class="ob-meta">提交人 {{ formatName(s.submitted_by) }}</text>
<!-- ★ 只有执行过才有金额。未执行显示「—」,不显示 0 ——
0 会让人以为「这东西不值钱」,其实是「还没扫码执行」 -->
<text class="ob-meta" v-if="s.mom_executed">损失 {{ formatLoss(s.total_loss) }}</text>
</view>
<text v-if="s.reason" class="ob-remark">{{ s.reason }}</text>
</view>
</view>
</view>
@ -231,6 +278,8 @@
import request, { get, post, patch, put, getBaseUrl } from "../../utils/request";
import { uploadImages, isUploadedUrl } from "../../utils/upload";
import { setUserNameMap, formatUserName, formatUserAvatar } from "../../utils/format";
// 本页只**读**报废记录;报案(选料/填数量/提交)在 pages/material/index
import { listProductScraps } from "../../api/scrap";
import { taskOptionsFor, overallOptionsFor, lifecycleBadge } from "../../utils/lifecycle";
import WorkspaceArea from "./components/WorkspaceArea.vue";
import TreeCanvas from "./components/TreeCanvas.vue";
@ -269,10 +318,27 @@ export default {
newMsgText: '',
bottomMsgId: '',
lastMsgSeenAt: '',
// ♻️ 报废记录(只读展示)。状态与金额由后端**实时回查 MOM** ——
// 报废没有回调,本地存的那份会过期,而「批没批、执行没执行」正是要看的东西。
// 报案入口在「领用物料」页(pages/material/index),不在本页。
scrapRecords: [],
};
},
computed: {
userLabels() { return this.users.map(u => `${u.full_name} (${u.username})`); },
// 🚚 这台设备挂的出库明细条数 —— 只用来在入口按钮上显示数量。
// 具体清单/报废/领料都在「出库单据」页里(pages/material/index)。
// ⚠️ 读的是 `outbound_records`(统一后的设备级出库明细)。
// 以前读 `task_tree[].outbound_materials` —— 那个字段连同它那张表
// 一起被合并掉了,后端已经不再返回,照着读**恒为 0 条**
// (界面上就表现为「明明有料却显示 0 条,点进去又看得见」)。
mountedMaterials() {
return (this.product && this.product.outbound_records) || [];
},
/** 挂了**几张单**(按出库单号去重)—— 与条数一起显示 */
mountedOrderCount() {
return new Set(this.mountedMaterials.map(m => m.outbound_no).filter(Boolean)).size;
},
canDeleteImage() { if (!this.recordPopup.task) return true; if (!this.currentUser) return true; const frozen = ["COMPLETED","REJECTED","ARCHIVED","CANCELED"]; if (frozen.includes(this.recordPopup.task.status)) return false; const assignee = this.recordPopup.task.assignee_id; return assignee == this.currentUserId || assignee == this.currentUsername || (this.currentUser && this.currentUser.id == assignee) || (this.currentUser && this.currentUser.username == assignee); },
transferUserName() { const u = this.userOptions.find(u => u.id === this.transferForm.selectedUserId); return u ? u.name : ""; },
// 🔒 直接完结入口仅超管/主管【可见】——普通人看不到,而不是点了才被后端 403。
@ -407,7 +473,15 @@ export default {
},
onLoad(options) { this.loadUsers(); this.loadCurrentUser(); const sn = options.serial || ""; if (sn) { this.doQuery(sn); return; } /* 🚀 兜底: 从 taskId 反查 product */ const tid = options.taskId || ""; if (tid) this.doQueryByTask(tid); },
// 🚀 onShow 生命周期:每次页面显示时刷新留言板(解决从聊天室退回不更新问题)
onShow() { if (this.product?.id) { this.fetchMessages(); } },
onShow() {
if (!this.product?.id) return;
this.fetchMessages();
// ⚠️ 必须**重新拉产品**,不只是刷新报废记录:
// 用户刚在「出库单据」页挂完料返回,入口上的「N 张单 / M 条料」靠的是
// product.outbound_records。只刷其它卡片的话计数一直是旧的,
// 用户会以为「我刚才那一下没挂上」。
this.refreshProductSilently();
},
// 🚀 页面卸载:清理确认倒计时定时器,避免泄漏
onUnload() { this.clearConfirm(); },
// ⚠️ 本页【刻意不开启】下拉刷新(pages.json 中已移除 enablePullDownRefresh)。
@ -418,11 +492,66 @@ export default {
// 状态纠偏不依赖下拉刷新:handleNetworkFailure 会自动静默拉取真实状态。
methods: {
formatUserName, formatUserAvatar,
// ==================== ♻️ 生产报废 ====================
/** 拉本设备的报废记录(状态与金额由后端实时回查 MOM) */
async fetchScrapRecords() {
if (!this.product?.id) return;
try {
this.scrapRecords = (await listProductScraps(this.product.id)) || [];
} catch (e) {
// 静默失败:报废记录是「附加信息」,拉不到不该挡住产品详情的主流程
console.warn('[scrap] 拉取报废记录失败:', e?.data?.detail || e);
this.scrapRecords = [];
}
},
/** 进「领用物料」页:看这台设备的料、报废、补领 */
goMaterialPage() {
// ⚠️ 不要写 `if (!id) return` —— 静默返回在界面上就是「点了没反应」,
// 现场根本没法判断是没加载完、还是页面没注册。有情况都要说出来
if (!this.product || !this.product.id) {
uni.showToast({ title: '产品还没加载完,稍后再试', icon: 'none' });
return;
}
uni.navigateTo({
url: `/pages/material/index?productId=${this.product.id}`
+ `&serial=${encodeURIComponent(this.product.serial_number || '')}`,
// ★ navigateTo 失败时 uni 是**静默**的(只在控制台留一行 warning),
// 用户只会觉得「点了没反应」。这里必须弹出来。
// 最常见的原因:新页面没进 pages.json —— HBuilderX 会缓存它,
// 必须**重启 HBuilderX** 才会重新读取,光重新运行不够。
fail: (err) => {
console.error('[material] 跳转失败:', err);
uni.showModal({
title: '打不开「领用物料」',
content: '页面未注册或未编译:' + (err && err.errMsg ? err.errMsg : err)
+ '\n\n请完全关闭并重启 HBuilderX 后重新运行',
showCancel: false,
});
},
});
},
/** 报废状态 → 徽标配色。已完成绿色、被驳回/撤回灰色、其余蓝色 */
scrapBadgeClass(s) {
if (s.mom_executed) return 'sc-badge-done';
if (s.mom_status === 2 || s.mom_status === 4) return 'sc-badge-off';
return 'sc-badge-wait';
},
/** 金额展示。未执行时后端给 null → 显示「—」,不显示 0 */
formatLoss(v) {
if (v === null || v === undefined) return '—';
return '¥' + Number(v).toFixed(2);
},
formatName(name) { if (!name) return ""; return name.length === 2 ? name[0] + " " + name[1] : name; },
// 出库时间 → 可读格式。MOM 的 outbound_time 缺失时回退到本行写入时间,
// 避免这一列空白(后端返回的是带 +00:00 偏移的 ISO 串,Date 能正确解析)
statusLabel(s) { return STATUS_MAP[s] || s; },
statusColor(s) { switch (s) { case "PENDING": return "s-yellow"; case "WIP": return "s-blue"; case "COMPLETED": return "s-green"; case "REJECTED": return "s-red"; default: return "s-gray"; } },
async doQuery(sn) { this.loading = true; this.error = ""; try { this.product = await get(`/products/scan/${sn}`); this.$nextTick(() => { this.currentMode = 'workspace'; this.autoLockTaskId = this.findMyImmersiveTask() || ''; if (!this.product.task_tree || !this.product.task_tree.length) { this.openCreateFirstTask(); } }); this.fetchMessages(); } catch (e) { this.error = e?.data?.detail || "查询失败"; } finally { this.loading = false; } },
async doQuery(sn) { this.loading = true; this.error = ""; try { this.product = await get(`/products/scan/${sn}`); this.$nextTick(() => { this.currentMode = 'workspace'; this.autoLockTaskId = this.findMyImmersiveTask() || ''; if (!this.product.task_tree || !this.product.task_tree.length) { this.openCreateFirstTask(); } }); this.fetchMessages(); this.fetchScrapRecords(); } catch (e) { this.error = e?.data?.detail || "查询失败"; } finally { this.loading = false; } },
// 🚀 从 taskId 反查 product_serial → 再 doQuery
async doQueryByTask(tid) { try { const task = await get(`/tasks/${tid}`); const sn = task?.product_sn || ""; if (sn) { this.doQuery(sn); } else { this.error = "未找到关联产品"; this.loading = false; } } catch { this.error = "任务查询失败"; this.loading = false; } },
findMyImmersiveTask() {
@ -611,7 +740,12 @@ export default {
async refreshProductSilently() {
const sn = this.product && this.product.serial_number;
if (!sn) return;
try { this.product = await get(`/products/scan/${sn}`); } catch (e) { console.error("[refresh] 静默刷新失败:", e); }
try {
this.product = await get(`/products/scan/${sn}`);
// 顺带刷新报废记录:MOM 里主管审批 / 库管扫码执行后状态与金额会变,
// 而报废没有回调,只能靠这类「顺手拉一次」让用户看到最新进度
this.fetchScrapRecords();
} catch (e) { console.error("[refresh] 静默刷新失败:", e); }
},
// ═══ 双重确认倒计时(防误触) ═══
@ -773,6 +907,36 @@ export default {
.value { font-size: 14px; color: #1f2937; font-weight: 600; word-break: break-all; }
.sn { font-family: monospace; }
.warehouse { color: #7c3aed; }
/* 🚚 出库单据(MOM 出库回调存档) */
.ob-count { font-size: 12px; color: #9ca3af; flex-shrink: 0; }
.ob-row { border: 1px solid #f3f4f6; border-radius: 8px; padding: 8px; margin-bottom: 6px; }
.ob-row:last-child { margin-bottom: 0; }
/* 已撤回:整行降调 + 单号删除线,但**不隐藏** ——「出过又撤了」也是历史 */
.ob-revoked { background: #f9fafb; border-color: #e5e7eb; }
.ob-line1 { display: flex; align-items: center; gap: 6px; }
.ob-no { font-family: monospace; font-size: 13px; font-weight: 700; color: #1f2937; word-break: break-all; }
.ob-no-revoked { color: #9ca3af; text-decoration: line-through; }
.ob-badge { font-size: 10px; font-weight: 700; color: #6b7280; background: #e5e7eb; border-radius: 10px; padding: 1px 6px; flex-shrink: 0; }
.ob-time { font-size: 11px; color: #9ca3af; margin-left: auto; flex-shrink: 0; }
.ob-line2 { display: flex; flex-wrap: wrap; gap: 4px 10px; margin-top: 4px; }
.ob-meta { font-size: 11px; color: #6b7280; }
.ob-remark { font-size: 11px; color: #9ca3af; margin-top: 3px; display: block; }
/* 📦 领用物料入口(产品信息卡底部)。
常显:以前这里没内容时整块消失,用户根本不知道有这功能 */
.mat-entry { display: flex; align-items: center; gap: 6px; margin-top: 12px; padding-top: 10px; border-top: 1px solid #f3f4f6; }
.mat-entry-icon { font-size: 15px; }
.mat-entry-label { font-size: 14px; font-weight: 600; color: #2563eb; }
.mat-entry-count { font-size: 12px; color: #9ca3af; }
.mat-entry-arrow { font-size: 16px; color: #9ca3af; margin-left: auto; }
/* ♻️ 报废记录状态徽标 */
.sc-badge { font-size: 10px; font-weight: 700; border-radius: 10px; padding: 1px 6px; flex-shrink: 0; }
.sc-badge-wait { color: #b45309; background: #fef3c7; } /* 待审批:琥珀 */
.sc-badge-done { color: #047857; background: #d1fae5; } /* 已执行:绿 */
.sc-badge-off { color: #6b7280; background: #e5e7eb; } /* 驳回/撤回:灰 */
/* 代报确认条的样式已随报废弹层移到 pages/material/index.vue */
.badge { font-size: 11px; padding: 2px 10px; border-radius: 20px; font-weight: 600; }
.s-yellow .badge, .s-yellow { color: #b45309; }
.s-blue .badge, .s-blue { color: #1d4ed8; }

View File

@ -210,3 +210,6 @@ export function get(url, params = {}) {
export function post(url, data = {}) { return request({ url, method: "POST", data }); }
export function patch(url, data = {}) { return request({ url, method: "PATCH", data }); }
export function put(url, data = {}) { return request({ url, method: "PUT", data }); }
// DELETE 原先漏了没封装:本模块 get/post/patch/put 都齐了就差它,
// 补上省得调用方各自用默认导出的 request() 去拼。命名用 del —— delete 是保留字。
export function del(url, data = {}) { return request({ url, method: "DELETE", data }); }