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 现查,不接受前端传入,否则前端可伪造单据。
This commit is contained in:
2026-09-23 15:18:06 +08:00
parent edd43fec29
commit e45c97bd1f
22 changed files with 2000 additions and 48 deletions

View File

@ -12,6 +12,79 @@
`WHERE username LIKE '%/<账号>'` 匹配,`display_name` 由 `/` 拆解得到。 `WHERE username LIKE '%/<账号>'` 匹配,`display_name` 由 `/` 拆解得到。
MOM 连接配置在 `app/core/mom_database.py`(同步 psycopg2 引擎)。 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` 可用): 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

@ -1,12 +1,26 @@
"""物料选择器 — 读 MOM material_base,按成品/半成品 category 手风琴分组""" """物料选择器 — 读 MOM material_base,按 category 手风琴分组(仅本部门)"""
from fastapi import APIRouter, Query, HTTPException, status, Depends from fastapi import APIRouter, Query, HTTPException, status, Depends
from pydantic import BaseModel from pydantic import BaseModel
from app.core.config import settings
from app.core.mom_database import MomSessionLocal from app.core.mom_database import MomSessionLocal
from app.services.auth_service import get_current_user from app.services.auth_service import get_current_user
from sqlalchemy import text from sqlalchemy import text
router = APIRouter(prefix="/materials", tags=["物料选择"]) 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 分组汇总,前端渲染手风琴外层。 按 category 分组汇总,前端渲染手风琴外层。
只返回成品/半成品分类。 只返回本部门(ORG_DEPARTMENT)名下的分类。
""" """
db = MomSessionLocal() db = MomSessionLocal()
try: try:
@ -47,23 +61,29 @@ def get_material_groups(
SELECT category, COUNT(*) AS count SELECT category, COUNT(*) AS count
FROM material_base FROM material_base
WHERE is_enabled = TRUE WHERE is_enabled = TRUE
AND category LIKE :cat_prefix
AND (name ILIKE :kw OR spec_model ILIKE :kw) AND (name ILIKE :kw OR spec_model ILIKE :kw)
GROUP BY category GROUP BY category
ORDER 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: else:
sql = text(""" sql = text("""
SELECT category, COUNT(*) AS count SELECT category, COUNT(*) AS count
FROM material_base FROM material_base
WHERE is_enabled = TRUE WHERE is_enabled = TRUE
AND category LIKE :cat_prefix
GROUP BY category GROUP BY category
ORDER BY category ORDER BY category
""") """)
result = db.execute(sql) result = db.execute(sql, {"cat_prefix": CATEGORY_PREFIX_LIKE})
rows = result.fetchall() rows = result.fetchall()
return [MaterialGroup(category=row.category, count=row.count) for row in rows] return [MaterialGroup(category=row.category, count=row.count) for row in rows]
except HTTPException:
raise
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, status_code=status.HTTP_502_BAD_GATEWAY,
@ -82,6 +102,10 @@ def get_material_items(
): ):
""" """
获取指定 category 下的物料条目,前端展开手风琴时懒加载。 获取指定 category 下的物料条目,前端展开手风琴时懒加载。
这里同样要加部门前缀条件(纵深防御):`category` 完全由客户端提供,
只靠 `category = :cat` 精确匹配的话,构造一个跨部门的 category 就能
把别的部门的物料捞出来。
""" """
db = MomSessionLocal() db = MomSessionLocal()
try: try:
@ -91,13 +115,20 @@ def get_material_items(
COALESCE(unit, '') AS unit, is_enabled COALESCE(unit, '') AS unit, is_enabled
FROM material_base FROM material_base
WHERE is_enabled = TRUE WHERE is_enabled = TRUE
AND category LIKE :cat_prefix
AND category = :cat AND category = :cat
AND (name ILIKE :kw OR spec_model ILIKE :kw) AND (name ILIKE :kw OR spec_model ILIKE :kw)
ORDER BY name ORDER BY name
LIMIT :lim LIMIT :lim
""") """)
result = db.execute( 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: else:
sql = text(""" sql = text("""
@ -105,11 +136,14 @@ def get_material_items(
COALESCE(unit, '') AS unit, is_enabled COALESCE(unit, '') AS unit, is_enabled
FROM material_base FROM material_base
WHERE is_enabled = TRUE WHERE is_enabled = TRUE
AND category LIKE :cat_prefix
AND category = :cat AND category = :cat
ORDER BY name ORDER BY name
LIMIT :lim 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() rows = result.fetchall()
return [ return [
@ -124,6 +158,8 @@ def get_material_items(
) )
for row in rows for row in rows
] ]
except HTTPException:
raise
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, 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

@ -1,6 +1,7 @@
"""用户列表 — 对接 MOM sys_user""" """用户列表 — 对接 MOM sys_user"""
from fastapi import APIRouter, Query, HTTPException, status from fastapi import APIRouter, Query, HTTPException, status
from pydantic import BaseModel from pydantic import BaseModel
from app.core.config import settings
from app.core.mom_database import MomSessionLocal from app.core.mom_database import MomSessionLocal
from sqlalchemy import text from sqlalchemy import text
@ -20,42 +21,32 @@ class UserOption(BaseModel):
def list_users( def list_users(
keyword: str = Query("", description="按用户名/姓名模糊搜索"), keyword: str = Query("", description="按用户名/姓名模糊搜索"),
limit: int = Query(100, ge=1, le=500), 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() db = MomSessionLocal()
try: try:
# 尝试按部门过滤;若 sys_user 无 department 列则降级全量查询 base_sql = """
try: SELECT id, username,
base_sql = """ SPLIT_PART(username, '/', 1) AS full_name,
SELECT id, username, COALESCE(department, '') AS department
SPLIT_PART(username, '/', 1) AS full_name, FROM sys_user
COALESCE(department, '') AS department WHERE department = :dept
FROM sys_user """
WHERE department = :dept params = {"dept": settings.ORG_DEPARTMENT, "lim": limit}
""" if keyword.strip():
params = {"dept": dept, "lim": limit} sql = text(base_sql + " AND username ILIKE :kw ORDER BY username LIMIT :lim")
if keyword.strip(): params["kw"] = f"%{keyword.strip()}%"
sql = text(base_sql + " AND username ILIKE :kw ORDER BY username LIMIT :lim") else:
params["kw"] = f"%{keyword.strip()}%" sql = text(base_sql + " ORDER BY username LIMIT :lim")
else: rows = db.execute(sql, params).fetchall()
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()
return [ return [
UserOption( UserOption(
@ -66,6 +57,8 @@ def list_users(
) )
for row in rows for row in rows
] ]
except HTTPException:
raise
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, status_code=status.HTTP_502_BAD_GATEWAY,

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.auth import router as auth_router
from app.api.v1.endpoints.print import router as print_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.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.users import router as users_router
from app.api.v1.endpoints.upload import router as upload_router from app.api.v1.endpoints.upload import router as upload_router
from app.api.v1.endpoints.records import router as records_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(tasks_router)
api_router.include_router(print_router) api_router.include_router(print_router)
api_router.include_router(materials_router) api_router.include_router(materials_router)
api_router.include_router(mom_outbounds_router)
api_router.include_router(users_router) api_router.include_router(users_router)
api_router.include_router(upload_router) api_router.include_router(upload_router)
api_router.include_router(records_router) api_router.include_router(records_router)

View File

@ -2,8 +2,12 @@
from app.models.base import Base from app.models.base import Base
from app.models.production_order import ProductionOrder from app.models.production_order import ProductionOrder
from app.models.product import Product 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 import Task, TaskRecord
from app.models.task_log import TaskLog from app.models.task_log import TaskLog
from app.models.task_outbound_material import TaskOutboundMaterial
from app.models.notification import Notification from app.models.notification import Notification
from app.models.app_version import AppVersion from app.models.app_version import AppVersion
from app.models.message import ProductMessage from app.models.message import ProductMessage
@ -14,8 +18,12 @@ __all__ = [
"Base", "Base",
"ProductionOrder", "ProductionOrder",
"Product", "Product",
"ProductOutbound",
"ProductOutboundMaterial",
"ProductScrap",
"Task", "Task",
"TaskRecord", "TaskRecord",
"TaskOutboundMaterial",
"TaskLog", "TaskLog",
"Notification", "Notification",
"AppVersion", "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

@ -89,6 +89,13 @@ class Task(Base):
records: Mapped[list["TaskRecord"]] = relationship( records: Mapped[list["TaskRecord"]] = relationship(
"TaskRecord", back_populates="task", lazy="selectin", cascade="all, delete-orphan", "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: def __repr__(self) -> str:
return f"<Task {self.task_name}>" 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

@ -15,6 +15,7 @@ from app.core.security import (
) )
from app.core.mom_database import MomSessionLocal from app.core.mom_database import MomSessionLocal
from app.core.logging import user_var from app.core.logging import user_var
from app.core.roles import SUPER_ADMIN
from app.schemas.user import LoginResponse, UserResponse from app.schemas.user import LoginResponse, UserResponse
security = HTTPBearer() security = HTTPBearer()
@ -24,15 +25,29 @@ def login(username: str, password: str) -> LoginResponse:
"""登录 — 签发双 Token(Access + Refresh)""" """登录 — 签发双 Token(Access + Refresh)"""
db = MomSessionLocal() db = MomSessionLocal()
try: 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 from sqlalchemy import text
result = db.execute( result = db.execute(
text( text(
"SELECT id, username, department, role, password_hash " "SELECT id, username, department, role, password_hash "
"FROM sys_user " "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() 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,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

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

View File

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

View File

@ -1,9 +1,11 @@
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { Modal, Collapse, Table, Input, Button, Space, Tag, App, Spin, Empty } from "antd"; 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 api from "../../services/api";
import { fetchMaterialGroups, fetchMaterialItems, type MaterialGroup, type MaterialItem } from "../../services/materialApi"; import { fetchMaterialGroups, fetchMaterialItems, type MaterialGroup, type MaterialItem } from "../../services/materialApi";
import MomOutboundPicker from "../../components/admin/MomOutboundPicker";
import { extractErrorMessage } from "../../utils/errorMessage"; 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 [submitting, setSubmitting] = useState(false);
const [createdSn, setCreatedSn] = useState<string | null>(null); const [createdSn, setCreatedSn] = useState<string | null>(null);
// ---- 建档时挂钩的 MOM 出库单(可选) ----
const [pickedOrders, setPickedOrders] = useState<MomOutboundOrder[]>([]);
const [pickerOpen, setPickerOpen] = useState(false);
// ---- 初始化 ---- // ---- 初始化 ----
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@ -62,6 +68,8 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
setCreatedSn(null); setCreatedSn(null);
groupCache.current.clear(); groupCache.current.clear();
groupLoadingMap.current.clear(); groupLoadingMap.current.clear();
setPickedOrders([]);
setPickerOpen(false);
loadSummary(); loadSummary();
} }
}, [open]); }, [open]);
@ -182,6 +190,10 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
material_type: selected.material_type, material_type: selected.material_type,
external_serial: externalSerial.trim() || null, external_serial: externalSerial.trim() || null,
order_no: orderNo.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); setCreatedSn(data.serial_number);
onCreated(); onCreated();
@ -332,6 +344,7 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
// ============================================================ // ============================================================
return ( return (
<>
<Modal <Modal
title="创建产品" title="创建产品"
open={open} open={open}
@ -493,6 +506,43 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
/> />
</div> </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 <Button
type="primary" type="primary"
@ -507,5 +557,20 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
</div> </div>
)} )}
</Modal> </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

@ -26,6 +26,19 @@ export interface TaskCompletePayload {
remark: string | null; 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 方法 // API 方法
// ============================================================ // ============================================================
@ -43,6 +56,16 @@ export async function listTasks(productId?: string): Promise<TaskListResponse> {
return data; 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( export async function createSubtask(
parentTaskId: string, 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;
}