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:
73
AGENTS.md
73
AGENTS.md
@ -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` 可用):
|
||||||
|
|||||||
@ -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")
|
||||||
@ -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")
|
||||||
@ -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")
|
||||||
@ -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,
|
||||||
|
|||||||
146
backend/app/api/v1/endpoints/mom_outbounds.py
Normal file
146
backend/app/api/v1/endpoints/mom_outbounds.py
Normal 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)}",
|
||||||
|
)
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
107
backend/app/models/product_outbound.py
Normal file
107
backend/app/models/product_outbound.py
Normal 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}>"
|
||||||
@ -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}>"
|
||||||
|
|||||||
103
backend/app/models/task_outbound_material.py
Normal file
103
backend/app/models/task_outbound_material.py
Normal 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}>"
|
||||||
@ -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()
|
||||||
|
|
||||||
|
|||||||
398
backend/app/services/mom_outbound_service.py
Normal file
398
backend/app/services/mom_outbound_service.py
Normal 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())
|
||||||
232
frontend/src/components/admin/CreateTaskDialog.tsx
Normal file
232
frontend/src/components/admin/CreateTaskDialog.tsx
Normal 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)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
412
frontend/src/components/admin/MomOutboundPicker.tsx
Normal file
412
frontend/src/components/admin/MomOutboundPicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
48
frontend/src/services/momApi.ts
Normal file
48
frontend/src/services/momApi.ts
Normal 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。
|
||||||
@ -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,
|
||||||
|
|||||||
21
frontend/src/services/userApi.ts
Normal file
21
frontend/src/services/userApi.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user