From e45c97bd1f0c3c8ef1ed00b02463a2ce7db365d4 Mon Sep 17 00:00:00 2001 From: duxingchen Date: Wed, 23 Sep 2026 15:18:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=84=E7=BB=87=E9=9A=94=E7=A6=BB?= =?UTF-8?q?=EF=BC=88IRIS=20=E5=8D=95=E5=AE=9E=E4=BE=8B=EF=BC=89=E4=B8=8E?= =?UTF-8?q?=E5=87=BA=E6=96=99=E5=8A=9F=E8=83=BD=E5=9F=BA=E7=A1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本轮之前累积的未提交工作,一并固化: - 组织隔离:同一份代码部署给不同部门只需改 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 现查,不接受前端传入,否则前端可伪造单据。 --- AGENTS.md | 73 ++++ .../m1n2o3p4q5r6_add_product_outbounds.py | 93 ++++ ...1o2p3q4r5s6_add_task_outbound_materials.py | 99 +++++ ...p2q3r4s5t6_add_product_outbounds_source.py | 48 ++ backend/app/api/v1/endpoints/materials.py | 48 +- backend/app/api/v1/endpoints/mom_outbounds.py | 146 +++++++ backend/app/api/v1/endpoints/users.py | 59 ++- backend/app/api/v1/router.py | 2 + backend/app/models/__init__.py | 8 + backend/app/models/product_outbound.py | 107 +++++ backend/app/models/task.py | 7 + backend/app/models/task_outbound_material.py | 103 +++++ backend/app/services/auth_service.py | 21 +- backend/app/services/mom_outbound_service.py | 398 +++++++++++++++++ .../src/components/admin/CreateTaskDialog.tsx | 232 ++++++++++ .../components/admin/MomOutboundPicker.tsx | 412 ++++++++++++++++++ frontend/src/components/scan/QueryResult.tsx | 4 + frontend/src/pages/admin/AdminTasksPage.tsx | 29 +- .../src/pages/admin/CreateProductDialog.tsx | 67 ++- frontend/src/services/momApi.ts | 48 ++ frontend/src/services/taskApi.ts | 23 + frontend/src/services/userApi.ts | 21 + 22 files changed, 2000 insertions(+), 48 deletions(-) create mode 100644 backend/alembic/versions/m1n2o3p4q5r6_add_product_outbounds.py create mode 100644 backend/alembic/versions/n1o2p3q4r5s6_add_task_outbound_materials.py create mode 100644 backend/alembic/versions/o1p2q3r4s5t6_add_product_outbounds_source.py create mode 100644 backend/app/api/v1/endpoints/mom_outbounds.py create mode 100644 backend/app/models/product_outbound.py create mode 100644 backend/app/models/task_outbound_material.py create mode 100644 backend/app/services/mom_outbound_service.py create mode 100644 frontend/src/components/admin/CreateTaskDialog.tsx create mode 100644 frontend/src/components/admin/MomOutboundPicker.tsx create mode 100644 frontend/src/services/momApi.ts create mode 100644 frontend/src/services/userApi.ts diff --git a/AGENTS.md b/AGENTS.md index c1227d9..e0d6f08 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,79 @@ `WHERE username LIKE '%/<账号>'` 匹配,`display_name` 由 `/` 拆解得到。 MOM 连接配置在 `app/core/mom_database.py`(同步 psycopg2 引擎)。 +## 组织隔离(2026-09 新增) + +本实例只服务 IRIS 部门,开关是 `app/core/config.py` 的两个值:`ORG_DEPARTMENT` +(对 MOM `sys_user.department`)与 `MATERIAL_CATEGORY_PREFIX`(对 MOM +`material_base.category` 前缀)。**同一份代码部署给别的部门只需改这两处。** + +过滤点共四处,全部服务端钉死,客户端传什么参数都不采纳: + +| 位置 | 过滤条件 | +|---|---| +| 登录 `auth_service.login` | `department = ORG_DEPARTMENT OR role = SUPER_ADMIN` | +| 人员列表 `endpoints/users.py` | `department = ORG_DEPARTMENT` | +| 物料 `endpoints/materials.py` | `category LIKE 'IRIS/%'`(`groups` 与 `items` 都要加) | +| MOM 出库单 `services/mom_outbound_service.py` | 同上前缀 + 跨部门领用人例外(见下) | + +- **登录是四处里唯一区分角色的**:`SUPER_ADMIN` 跨部门放行(IRIS 超管也能登 + LICA 实例,反之亦然),供运维在两个实例之间切换。其余角色(INBOUND / + SUPERVISOR / WAREHOUSE_MGR / SALES)必须严格属于本部门。 +- ⚠️ 物料必须用**前缀** `LIKE 'IRIS/%'`,不能反推成 `ILIKE '%IRIS%'`:MOM 里 + LICA 的物料是 `LICA/<中文>`(生产配件 687 / 销售产品 89 / 维修服务 16…), + 而本部门分类树里另有 `IRIS/成品/LICA/…`(野外便携 59 / 无人机 38 / + 实验室内 34 / 高塔监测 30,共 171 条)—— 那是**挂在 IRIS 名下、给 LICA 做的 + 成品**,本来就属于本部门。前缀匹配天然把前者排除、后者包含,不需要特例。 +- 刻意**不做**「查询失败退回全表」的降级 —— 那是跨部门数据泄漏。宁可查不出, + 不可查过头。 +- 已实测:人员列表 20 人(IRIS 部门)、物料 100 个分类 / 2248 条、 + `LICA/` 与 `IRIS/` 前缀交叉命中 0 条;4 个 LICA 普通账号全部登不进来, + 2 个超管(含 LICA 的)正常放行。 + +### 出库单的跨部门领用人例外 + +`config.EXTRA_VISIBLE_CONSUMERS`(默认 `依锐思,石利LICA`)里的领用人,其在 MOM +`trans_outbound.consumer_name` 名下的单据,**即使物料分类不属于本部门也放行** —— +他们跨两个部门领料,只按物料前缀过滤会把他们的单整批漏掉。 + +⚠️ 这是**放行**条件(SQL 里是 `OR`),与界面筛选(`AND`,只收窄)方向相反, +两者的集合运算在 `mom_outbound_service` 里必须分开写。名单为空时整段不拼, +退化成纯前缀过滤。已实测:空白名单 438 单 → 填入一个真实跨部门领用人后 440 单, +填入不存在的名字仍是 438 单(不放大范围)。 + +## 出料功能(2026-09 新增) + +回答两个问题:**这台设备对应 MOM 的哪张出库单**、**这个任务用了哪些出库物料**。 + +- `product_outbounds` —— 产品 ↔ 出库单存档。两条写入路径共用一张表,靠 `source` + 区分:`webhook`(MOM 出库回调自动存档)/ `manual`(人在界面上挂的)。 + 一次出库一行;**撤回只置 `is_revoked` 不删行**(「出过又撤了」也是历史)。 + 唯一约束是 `(serial_number, outbound_no)` 而非只约束单号 —— MOM 的批量出库 + 是多个商品共用一个单号,只约束单号会把正常的批量单误杀。 +- `task_outbound_materials` —— 任务挂载的出库物料,**明细级快照**(一行 = MOM + `trans_outbound` 的一行)。为什么存快照而不只存单号:MOM 的物料名/规格要经 + `COALESCE` 三表 JOIN(`stock_buy`/`stock_semi`/`stock_product` → `material_base`) + 才能解析,Track 跨库 JOIN 不了,只存单号则 MOM 一挂就看不到已挂内容。 + 纯引用**不记用量**(`quantity` 是出库单原值,**不是**本任务用量)。 +- `mom_outbounds.py` / `mom_outbound_service.py` —— 直连 MOM 库的只读查询,供选择器 + 搜索用。不走 MOM 现成的 `GET /api/v1/outbound`:那个接口要 JWT + + `permission_required`,且对非特权账号按 `consumer_name` 做行级隔离,Track 用 + 服务账号调只能拿到该账号名下的单,不是全量。分页必须**两段式**(先 + `GROUP BY outbound_no` 分页拿单号,再 `WHERE outbound_no IN (…)` 捞明细), + 绝不能对 join 后的宽表直接分页 —— 那是明细行数不是单据数。 +- ⚠️ 快照一律由后端拿 ID 去 MOM 现查,**不接受前端传入**,否则前端可伪造单据。 +- ⚠️ 写挂载行时**不要** `append` 到 ORM 集合(`task.outbound_materials`):集合在 + flush 后处于「未加载」态,碰它会触发懒加载,异步 session 下直接抛 + `MissingGreenlet`。只写 FK,响应构造前走一次真正的查询。 + +**与 LICA 实例(`~/track-lica`)的差异**:LICA 那边出库单还要按**业务分组数据 +范围**再收敛一层(范围 ∩ 组 ∩ 个人),本实例没有分组体系,故 `mom-outbounds` +**没有 `group_id` 参数**,挂载时也不做额外的可见性校验。不要为了「对齐」加回来 —— +那会引入一份没有数据支撑的过滤。 + +⚠️ 两套实例共用同一个 MOM 库,但**数据卷相互独立**。永远不要在 `/home/yueli/track` +下执行 `docker compose down -v`。 + ## 本地起环境(关键,踩过的坑都在这) 1. **本机没有 Postgres 时需要先装**(容器内 `sudo` 可用): diff --git a/backend/alembic/versions/m1n2o3p4q5r6_add_product_outbounds.py b/backend/alembic/versions/m1n2o3p4q5r6_add_product_outbounds.py new file mode 100644 index 0000000..93970f8 --- /dev/null +++ b/backend/alembic/versions/m1n2o3p4q5r6_add_product_outbounds.py @@ -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") diff --git a/backend/alembic/versions/n1o2p3q4r5s6_add_task_outbound_materials.py b/backend/alembic/versions/n1o2p3q4r5s6_add_task_outbound_materials.py new file mode 100644 index 0000000..09b727e --- /dev/null +++ b/backend/alembic/versions/n1o2p3q4r5s6_add_task_outbound_materials.py @@ -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") diff --git a/backend/alembic/versions/o1p2q3r4s5t6_add_product_outbounds_source.py b/backend/alembic/versions/o1p2q3r4s5t6_add_product_outbounds_source.py new file mode 100644 index 0000000..82722a2 --- /dev/null +++ b/backend/alembic/versions/o1p2q3r4s5t6_add_product_outbounds_source.py @@ -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") diff --git a/backend/app/api/v1/endpoints/materials.py b/backend/app/api/v1/endpoints/materials.py index 17971c6..81baf5d 100644 --- a/backend/app/api/v1/endpoints/materials.py +++ b/backend/app/api/v1/endpoints/materials.py @@ -1,12 +1,26 @@ -"""物料选择器 — 读 MOM material_base,按成品/半成品 category 手风琴分组""" +"""物料选择器 — 读 MOM material_base,按 category 手风琴分组(仅本部门)""" from fastapi import APIRouter, Query, HTTPException, status, Depends from pydantic import BaseModel +from app.core.config import settings from app.core.mom_database import MomSessionLocal from app.services.auth_service import get_current_user from sqlalchemy import text router = APIRouter(prefix="/materials", tags=["物料选择"]) +# 部门隔离:只放行本部门 category 前缀(IRIS/…)。 +# +# ⚠️ 必须用**前缀** LIKE,不能反推成 ILIKE '%IRIS%':两套实例共用同一个 MOM 库, +# LICA 的物料是 `LICA/<中文>`(LICA/生产配件 687、LICA/销售产品 89、 +# LICA/维修服务 16 …),而 IRIS 分类树里另有 `IRIS/成品/LICA/…` +# (野外便携 59 / 无人机 38 / 实验室内 34 / 高塔监测 30,共 171 条)—— +# 那是**挂在 IRIS 名下、给 LICA 做的成品**,本来就属于本部门。 +# 前缀匹配天然把前者排除、把后者包含,不需要再加特例。 +# +# 另注:IRIS 的分类是多段式(`IRIS/半成品/无人机U`、`IRIS/原材料/光学/光电Opt1`), +# 拿「成品/半成品」这类类型词过滤没有意义,一律走前缀。 +CATEGORY_PREFIX_LIKE = f"{settings.MATERIAL_CATEGORY_PREFIX}%" + # ============================================================ # 响应模型 @@ -38,7 +52,7 @@ def get_material_groups( ): """ 按 category 分组汇总,前端渲染手风琴外层。 - 只返回成品/半成品分类。 + 只返回本部门(ORG_DEPARTMENT)名下的分类。 """ db = MomSessionLocal() try: @@ -47,23 +61,29 @@ def get_material_groups( SELECT category, COUNT(*) AS count FROM material_base WHERE is_enabled = TRUE + AND category LIKE :cat_prefix AND (name ILIKE :kw OR spec_model ILIKE :kw) GROUP BY category ORDER BY category """) - result = db.execute(sql, {"kw": f"%{keyword.strip()}%"}) + result = db.execute( + sql, {"cat_prefix": CATEGORY_PREFIX_LIKE, "kw": f"%{keyword.strip()}%"} + ) else: sql = text(""" SELECT category, COUNT(*) AS count FROM material_base WHERE is_enabled = TRUE + AND category LIKE :cat_prefix GROUP BY category ORDER BY category """) - result = db.execute(sql) + result = db.execute(sql, {"cat_prefix": CATEGORY_PREFIX_LIKE}) rows = result.fetchall() return [MaterialGroup(category=row.category, count=row.count) for row in rows] + except HTTPException: + raise except Exception as e: raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, @@ -82,6 +102,10 @@ def get_material_items( ): """ 获取指定 category 下的物料条目,前端展开手风琴时懒加载。 + + 这里同样要加部门前缀条件(纵深防御):`category` 完全由客户端提供, + 只靠 `category = :cat` 精确匹配的话,构造一个跨部门的 category 就能 + 把别的部门的物料捞出来。 """ db = MomSessionLocal() try: @@ -91,13 +115,20 @@ def get_material_items( COALESCE(unit, '') AS unit, is_enabled FROM material_base WHERE is_enabled = TRUE + AND category LIKE :cat_prefix AND category = :cat AND (name ILIKE :kw OR spec_model ILIKE :kw) ORDER BY name LIMIT :lim """) result = db.execute( - sql, {"cat": category, "kw": f"%{keyword.strip()}%", "lim": limit} + sql, + { + "cat_prefix": CATEGORY_PREFIX_LIKE, + "cat": category, + "kw": f"%{keyword.strip()}%", + "lim": limit, + }, ) else: sql = text(""" @@ -105,11 +136,14 @@ def get_material_items( COALESCE(unit, '') AS unit, is_enabled FROM material_base WHERE is_enabled = TRUE + AND category LIKE :cat_prefix AND category = :cat ORDER BY name LIMIT :lim """) - result = db.execute(sql, {"cat": category, "lim": limit}) + result = db.execute( + sql, {"cat_prefix": CATEGORY_PREFIX_LIKE, "cat": category, "lim": limit} + ) rows = result.fetchall() return [ @@ -124,6 +158,8 @@ def get_material_items( ) for row in rows ] + except HTTPException: + raise except Exception as e: raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, diff --git a/backend/app/api/v1/endpoints/mom_outbounds.py b/backend/app/api/v1/endpoints/mom_outbounds.py new file mode 100644 index 0000000..6b6fdbd --- /dev/null +++ b/backend/app/api/v1/endpoints/mom_outbounds.py @@ -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)}", + ) diff --git a/backend/app/api/v1/endpoints/users.py b/backend/app/api/v1/endpoints/users.py index 7fb6ec6..505953e 100644 --- a/backend/app/api/v1/endpoints/users.py +++ b/backend/app/api/v1/endpoints/users.py @@ -1,6 +1,7 @@ """用户列表 — 对接 MOM sys_user""" from fastapi import APIRouter, Query, HTTPException, status from pydantic import BaseModel +from app.core.config import settings from app.core.mom_database import MomSessionLocal from sqlalchemy import text @@ -20,42 +21,32 @@ class UserOption(BaseModel): def list_users( keyword: str = Query("", description="按用户名/姓名模糊搜索"), limit: int = Query(100, ge=1, le=500), - dept: str = Query("IRIS", description="部门过滤"), + dept: str = Query("", description="已废弃:部门由服务端按 ORG_DEPARTMENT 钉死,此参数不参与过滤"), ): - """获取 MOM 系统用户列表,默认只返回 IRIS 部门""" + """获取 MOM 系统用户列表,只返回本部门(ORG_DEPARTMENT)人员""" + # 部门隔离由服务端钉死:无论客户端传什么(含旧版 App / 旧前端里写死的 + # dept=IRIS),一律只按 ORG_DEPARTMENT 过滤。这样同一份 App 源码不必按 + # 部门分叉。 + # + # 这里刻意【不做】「查询异常就退回全表」的降级:那等于把另一个部门的人员 + # 名单也列出来供本部门挑选,是跨部门数据泄漏。查不出来就报错 —— + # 宁可查不出,不可查过头。 db = MomSessionLocal() try: - # 尝试按部门过滤;若 sys_user 无 department 列则降级全量查询 - try: - base_sql = """ - SELECT id, username, - SPLIT_PART(username, '/', 1) AS full_name, - COALESCE(department, '') AS department - FROM sys_user - WHERE department = :dept - """ - params = {"dept": dept, "lim": limit} - if keyword.strip(): - sql = text(base_sql + " AND username ILIKE :kw ORDER BY username LIMIT :lim") - params["kw"] = f"%{keyword.strip()}%" - else: - sql = text(base_sql + " ORDER BY username LIMIT :lim") - rows = db.execute(sql, params).fetchall() - except Exception: - # 降级:不使用 department 列过滤 - fallback_sql = """ - SELECT id, username, - SPLIT_PART(username, '/', 1) AS full_name, - '' AS department - FROM sys_user - """ - params = {"lim": limit} - if keyword.strip(): - sql = text(fallback_sql + " WHERE username ILIKE :kw ORDER BY username LIMIT :lim") - params["kw"] = f"%{keyword.strip()}%" - else: - sql = text(fallback_sql + " ORDER BY username LIMIT :lim") - rows = db.execute(sql, params).fetchall() + base_sql = """ + SELECT id, username, + SPLIT_PART(username, '/', 1) AS full_name, + COALESCE(department, '') AS department + FROM sys_user + WHERE department = :dept + """ + params = {"dept": settings.ORG_DEPARTMENT, "lim": limit} + if keyword.strip(): + sql = text(base_sql + " AND username ILIKE :kw ORDER BY username LIMIT :lim") + params["kw"] = f"%{keyword.strip()}%" + else: + sql = text(base_sql + " ORDER BY username LIMIT :lim") + rows = db.execute(sql, params).fetchall() return [ UserOption( @@ -66,6 +57,8 @@ def list_users( ) for row in rows ] + except HTTPException: + raise except Exception as e: raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 9134603..b464bbf 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -7,6 +7,7 @@ from app.api.v1.endpoints.dashboard import router as dashboard_router from app.api.v1.endpoints.auth import router as auth_router from app.api.v1.endpoints.print import router as print_router from app.api.v1.endpoints.materials import router as materials_router +from app.api.v1.endpoints.mom_outbounds import router as mom_outbounds_router from app.api.v1.endpoints.users import router as users_router from app.api.v1.endpoints.upload import router as upload_router from app.api.v1.endpoints.records import router as records_router @@ -28,6 +29,7 @@ api_router.include_router(products_router) api_router.include_router(tasks_router) api_router.include_router(print_router) api_router.include_router(materials_router) +api_router.include_router(mom_outbounds_router) api_router.include_router(users_router) api_router.include_router(upload_router) api_router.include_router(records_router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 951d631..196d0fc 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,8 +2,12 @@ from app.models.base import Base from app.models.production_order import ProductionOrder from app.models.product import Product +from app.models.product_outbound import ProductOutbound +from app.models.product_outbound_material import ProductOutboundMaterial +from app.models.product_scrap import ProductScrap from app.models.task import Task, TaskRecord from app.models.task_log import TaskLog +from app.models.task_outbound_material import TaskOutboundMaterial from app.models.notification import Notification from app.models.app_version import AppVersion from app.models.message import ProductMessage @@ -14,8 +18,12 @@ __all__ = [ "Base", "ProductionOrder", "Product", + "ProductOutbound", + "ProductOutboundMaterial", + "ProductScrap", "Task", "TaskRecord", + "TaskOutboundMaterial", "TaskLog", "Notification", "AppVersion", diff --git a/backend/app/models/product_outbound.py b/backend/app/models/product_outbound.py new file mode 100644 index 0000000..774d46d --- /dev/null +++ b/backend/app/models/product_outbound.py @@ -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"" diff --git a/backend/app/models/task.py b/backend/app/models/task.py index 6e458e4..29ea585 100644 --- a/backend/app/models/task.py +++ b/backend/app/models/task.py @@ -89,6 +89,13 @@ class Task(Base): records: Mapped[list["TaskRecord"]] = relationship( "TaskRecord", back_populates="task", lazy="selectin", cascade="all, delete-orphan", ) + # 本任务挂载的 MOM 出库物料(明细级快照)。创建任务时选、之后可追加, + # 见 models/task_outbound_material.py 的设计说明。 + outbound_materials: Mapped[list["TaskOutboundMaterial"]] = relationship( + "TaskOutboundMaterial", back_populates="task", lazy="selectin", + cascade="all, delete-orphan", + order_by="TaskOutboundMaterial.created_at", + ) def __repr__(self) -> str: return f"" diff --git a/backend/app/models/task_outbound_material.py b/backend/app/models/task_outbound_material.py new file mode 100644 index 0000000..156e0ca --- /dev/null +++ b/backend/app/models/task_outbound_material.py @@ -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"" diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 7bb2203..51f5ed5 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -15,6 +15,7 @@ from app.core.security import ( ) from app.core.mom_database import MomSessionLocal from app.core.logging import user_var +from app.core.roles import SUPER_ADMIN from app.schemas.user import LoginResponse, UserResponse security = HTTPBearer() @@ -24,15 +25,29 @@ def login(username: str, password: str) -> LoginResponse: """登录 — 签发双 Token(Access + Refresh)""" db = MomSessionLocal() try: - # 1. 普通用户:LIKE '%/username' 模糊匹配 MOM sys_user 表 + # 1. LIKE '%/username' 定位 MOM sys_user 账号,再按部门收敛 —— + # 两套 Track 实例共用同一个 MOM 库,不加部门条件的话,另一个部门的 + # 普通账号也能登进来。 + # + # ⚠️ 唯一的例外是 SUPER_ADMIN:超管**跨部门放行**,供运维/管理员在 + # 两个实例之间切换。其余角色(INBOUND / SUPERVISOR / WAREHOUSE_MGR / + # SALES)必须严格属于本部门(settings.ORG_DEPARTMENT)。 + # + # 不匹配时统一报「用户名或密码错误」,不区分「账号不存在」与「存在但 + # 不属于本部门」,避免给账号探测者提供线索。 from sqlalchemy import text result = db.execute( text( "SELECT id, username, department, role, password_hash " "FROM sys_user " - "WHERE username LIKE :pattern" + "WHERE username LIKE :pattern " + " AND (department = :dept OR role = :super_admin)" ), - {"pattern": f"%/{username}"}, + { + "pattern": f"%/{username}", + "dept": settings.ORG_DEPARTMENT, + "super_admin": SUPER_ADMIN, + }, ) row = result.fetchone() diff --git a/backend/app/services/mom_outbound_service.py b/backend/app/services/mom_outbound_service.py new file mode 100644 index 0000000..ee546f1 --- /dev/null +++ b/backend/app/services/mom_outbound_service.py @@ -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()) diff --git a/frontend/src/components/admin/CreateTaskDialog.tsx b/frontend/src/components/admin/CreateTaskDialog.tsx new file mode 100644 index 0000000..0686077 --- /dev/null +++ b/frontend/src/components/admin/CreateTaskDialog.tsx @@ -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([]); + const [taskName, setTaskName] = useState(""); + const [assigneeId, setAssigneeId] = useState(""); + const [remark, setRemark] = useState(""); + const [pickedOrders, setPickedOrders] = useState([]); + 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 ( + <> + + {/* ---- 产品信息(只读) ---- */} +
+

+ + {product.serial_number} +

+

+ {product.material_name || "—"} + {product.overall_status ? ` · 当前状态 ${product.overall_status}` : ""} +

+
+ + {/* ---- 表单 ---- */} +
+
+ + +
+ +
+ + +
+ +
+ +