Compare commits
21 Commits
1.0应用
...
0b982d192c
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b982d192c | |||
| 5d5aea1015 | |||
| 551819e0e3 | |||
| e45c97bd1f | |||
| edd43fec29 | |||
| 3c94faf078 | |||
| 817183062d | |||
| 42f6e242b4 | |||
| 192c8ee9cc | |||
| 3f34652b07 | |||
| df3f914eb1 | |||
| c3667fe00d | |||
| 39697ca3ad | |||
| 1fea30b03b | |||
| 5290d83463 | |||
| 17b2fab5ca | |||
| 42a67bfa3a | |||
| 14f707461d | |||
| 7635802a42 | |||
| 04eb87b091 | |||
| 4454047ce3 |
136
AGENTS.md
Normal file
136
AGENTS.md
Normal file
@ -0,0 +1,136 @@
|
||||
# AGENTS.md
|
||||
|
||||
本仓库(Track 生产流转系统)的工作笔记。仅在验证过之后才写入,避免传谣。
|
||||
|
||||
## 架构速览
|
||||
|
||||
- `backend/` FastAPI + SQLAlchemy 2.x(async) + Alembic,PostgreSQL。
|
||||
- `frontend/` React 19 + Vite + antd + Tailwind。路由见 `src/App.tsx`,
|
||||
管理端菜单见 `src/components/layout/AdminLayout.tsx`(`MENU` 数组)。
|
||||
- 登录不走 Track 自己的用户表,而是**只读** MOM(KCGL) 的 `sys_user`:
|
||||
`sys_user.username` 存 `"真实姓名/登录账号"`,`login()` 用
|
||||
`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` 可用):
|
||||
`sudo -n apt-get install -y --fix-missing postgresql postgresql-contrib`
|
||||
然后 `sudo -n pg_ctlcluster 17 main start`。
|
||||
本仓库不使用 pgvector,无需额外扩展。
|
||||
2. **数据库端口与生产默认值不同**,必须用环境变量覆盖:
|
||||
- `DATABASE_URL=postgresql+asyncpg://track:track_prod_2026@127.0.0.1:5432/track_production`
|
||||
- `MOM_DB_HOST=127.0.0.1`、`MOM_DB_PORT=5432`
|
||||
- `SECRET_KEY=<≥32 字符>`:`DEBUG=false` 时配置项会**拒绝**默认 SECRET_KEY
|
||||
(见 `app/core/config.py` 的校验),不设会直接 import 失败。
|
||||
3. 迁移:`cd backend && python3 -m alembic upgrade head`(没有全局 `alembic` 命令,
|
||||
要用 `python3 -m alembic`)。校验纯 SQL 用 `alembic upgrade head --sql`。
|
||||
4. 前端 proxy 指向 Docker 服务名 `backend:8000`。本机跑要么把
|
||||
`127.0.0.1 backend` 写进 `/etc/hosts`,要么直接给
|
||||
`VITE_API_BASE_URL=http://localhost:<port>/api/v1` 绕过 proxy。
|
||||
注意 dev server 由 `basicSsl` 起 HTTPS,跨域需要后端
|
||||
`CORS_ORIGINS` 加上 `https://localhost:1420`。
|
||||
|
||||
## 测试的坑(重要)
|
||||
|
||||
- **不要用 `starlette.testclient.TestClient` 测异步 SQLAlchemy 应用。**
|
||||
它每个请求新建事件循环,而引擎是模块级单例、池里挂着 asyncpg 连接,
|
||||
跨循环复用会报 `got Future attached to a different loop`,表现为随机 500。
|
||||
正确做法:`httpx.AsyncClient(transport=httpx.ASGITransport(app=app))`
|
||||
并在单个 `asyncio.run()` 里跑完全部请求。生产 uvicorn 单循环无此问题。
|
||||
- 仓库目前**没有** pytest 基建,也没有前端测试脚本。
|
||||
|
||||
## 已知的待修问题(截至 1.0应用 分支)
|
||||
|
||||
- **读接口大面积未鉴权**(已实测,非推测):无 token 直接 200 的包括
|
||||
`/api/v1/users/`、全部 `/api/v1/dashboard/*`(含
|
||||
`people-history/export` —— 匿名即可批量导出个人工时台账)、
|
||||
`/api/v1/analytics/*`、`/api/v1/screen/*`、`/api/v1/orders/`。
|
||||
写操作和 `/api/v1/tasks`、`/api/v1/products` 是有鉴权的。
|
||||
新增接口请统一用 `app/core/deps.py` 的 `require_admin` / `require_roles`。
|
||||
- 「管理员角色」这份规则此前散在 4 处(后端 `task_service`、`products.py` 内联、
|
||||
前端 `constants/task.ts`、`AdminProductsPage` 内联),已因此出过事故。
|
||||
**后端唯一事实来源是 `app/core/roles.py`,前端用 `constants/task.ts::isAdminRole`。**
|
||||
新增判断不要手写 `===` 比较。
|
||||
- `task_logs.task_id` 是 NOT NULL 外键,只能挂任务,不是通用审计。通用审计是
|
||||
`audit_logs`(本轮新增,由 `app/core/audit_middleware.py` 自动采集)。
|
||||
|
||||
## 约定
|
||||
|
||||
- 时间统一北京时间(`app/core/time_utils.py`),库里存 timestamptz。
|
||||
- 中文枚举标签尽量由服务端下发(如审计接口的 `module_label`/`action_label`),
|
||||
避免前端再抄一份映射开始漂移。
|
||||
- 本仓库的提交信息用中文,说明「为什么」而非「改了什么」。
|
||||
@ -3,3 +3,11 @@ SECRET_KEY=change-me-to-a-random-secret-key-in-production
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
DEBUG=true
|
||||
CORS_ORIGINS='["http://localhost:1420", "tauri://localhost"]'
|
||||
|
||||
# 日志:LOG_JSON=true 输出单行 JSON(便于采集),本地调试可设 false 换可读格式
|
||||
LOG_LEVEL=INFO
|
||||
LOG_JSON=true
|
||||
|
||||
# 错误追踪(可选):填了 DSN 且已安装 sentry-sdk 才会启用,否则自动跳过
|
||||
# SENTRY_DSN=
|
||||
# SENTRY_TRACES_SAMPLE_RATE=0.1
|
||||
|
||||
81
backend/alembic/versions/j1k2l3m4n5o6_add_audit_logs.py
Normal file
81
backend/alembic/versions/j1k2l3m4n5o6_add_audit_logs.py
Normal file
@ -0,0 +1,81 @@
|
||||
"""add_audit_logs
|
||||
|
||||
Revision ID: j1k2l3m4n5o6
|
||||
Revises: i1j2k3l4m5n6
|
||||
Create Date: 2026-09-21
|
||||
|
||||
操作审计日志表(audit_logs)
|
||||
--------------------------
|
||||
新增一张独立的审计表,用于记录 task_logs 覆盖不到的操作:
|
||||
登录、导出、产品增删改、收口、权限/配置变更等与单个任务无关的动作。
|
||||
|
||||
为什么另起一张表而不复用 task_logs:
|
||||
task_logs.task_id 是 NOT NULL 外键,只能挂在任务上,无法表达「张三导出了
|
||||
产品清单」这类动作;且缺少来源 IP / UA / 结果状态等审计必需字段。
|
||||
|
||||
存量数据无需回填(本表从上线时刻开始记录)。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "j1k2l3m4n5o6"
|
||||
down_revision: Union[str, None] = "i1j2k3l4m5n6"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"audit_logs",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False),
|
||||
# 操作人
|
||||
sa.Column("user_id", sa.String(64), nullable=True, comment="操作人账号(逻辑外键→MOM)"),
|
||||
sa.Column("display_name", sa.String(100), nullable=True, comment="操作人显示名"),
|
||||
sa.Column("role", sa.String(50), nullable=True, comment="操作时角色快照"),
|
||||
# 业务语义
|
||||
sa.Column("action", sa.String(50), nullable=False, comment="动作"),
|
||||
sa.Column("module", sa.String(50), nullable=False, comment="业务模块"),
|
||||
sa.Column("target_type", sa.String(50), nullable=True),
|
||||
sa.Column("target_id", sa.String(100), nullable=True),
|
||||
sa.Column("target_name", sa.String(200), nullable=True),
|
||||
sa.Column("details", postgresql.JSONB(), nullable=True, comment="变更详情"),
|
||||
# 请求上下文
|
||||
sa.Column("ip_address", sa.String(50), nullable=True),
|
||||
sa.Column("user_agent", sa.String(500), nullable=True),
|
||||
sa.Column("method", sa.String(10), nullable=True),
|
||||
sa.Column("url", sa.String(500), nullable=True),
|
||||
sa.Column("status_code", sa.Integer(), nullable=True),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
# 与结构化日志对账
|
||||
sa.Column("request_id", sa.String(64), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
|
||||
# 索引:按审计页最常用的检索维度建
|
||||
op.create_index("ix_audit_logs_created_at", "audit_logs", ["created_at"])
|
||||
op.create_index("ix_audit_logs_user_id", "audit_logs", ["user_id"])
|
||||
op.create_index("ix_audit_logs_module", "audit_logs", ["module"])
|
||||
op.create_index("ix_audit_logs_action", "audit_logs", ["action"])
|
||||
op.create_index("ix_audit_logs_target_id", "audit_logs", ["target_id"])
|
||||
op.create_index("ix_audit_logs_request_id", "audit_logs", ["request_id"])
|
||||
# 组合索引:审计页默认「按时间倒序 + 按模块/动作过滤」
|
||||
op.create_index("ix_audit_logs_module_created", "audit_logs", ["module", "created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_audit_logs_module_created", table_name="audit_logs")
|
||||
op.drop_index("ix_audit_logs_request_id", table_name="audit_logs")
|
||||
op.drop_index("ix_audit_logs_target_id", table_name="audit_logs")
|
||||
op.drop_index("ix_audit_logs_action", table_name="audit_logs")
|
||||
op.drop_index("ix_audit_logs_module", table_name="audit_logs")
|
||||
op.drop_index("ix_audit_logs_user_id", table_name="audit_logs")
|
||||
op.drop_index("ix_audit_logs_created_at", table_name="audit_logs")
|
||||
op.drop_table("audit_logs")
|
||||
53
backend/alembic/versions/k1l2m3n4o5p6_add_user_daily_seen.py
Normal file
53
backend/alembic/versions/k1l2m3n4o5p6_add_user_daily_seen.py
Normal file
@ -0,0 +1,53 @@
|
||||
"""add_user_daily_seen
|
||||
|
||||
Revision ID: k1l2m3n4o5p6
|
||||
Revises: j1k2l3m4n5o6
|
||||
Create Date: 2026-09-21
|
||||
|
||||
每日用户活动表(user_daily_seen)
|
||||
--------------------------------
|
||||
一天一人一行,记录当天首次 / 末次活动时刻,供日活报表计算
|
||||
「上线时间 / 下线时间」。
|
||||
|
||||
为什么不复用 audit_logs:
|
||||
· 上线/下线时间不能取登录时间 —— Refresh Token 有效期 7 天,用户不必每天
|
||||
重新登录,「登录次数 0 却操作 35 次」的报表没有意义。
|
||||
· 也不能只取写操作时间 —— 审计中间件只记写操作,普通 GET 不入账,
|
||||
当天只翻看的人会被漏掉。
|
||||
· 更不能把活动写进审计表 —— 「末次活动」是需要不断 UPDATE 的状态,
|
||||
而审计流水必须只增不改;能改的审计记录等于没有审计价值。
|
||||
|
||||
存量数据无需回填:本表从上线时刻开始记录;日活接口对更早的日期会自动
|
||||
回退到审计表的写操作时间去推算。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "k1l2m3n4o5p6"
|
||||
down_revision: Union[str, None] = "j1k2l3m4n5o6"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"user_daily_seen",
|
||||
sa.Column("user_id", sa.String(64), primary_key=True,
|
||||
comment="操作人账号(逻辑外键→MOM)"),
|
||||
sa.Column("day", sa.Date(), primary_key=True,
|
||||
comment="北京时间自然日"),
|
||||
sa.Column("first_seen_at", sa.DateTime(timezone=True), nullable=False,
|
||||
comment="当天首次活动时刻"),
|
||||
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False,
|
||||
comment="当天末次活动时刻"),
|
||||
)
|
||||
# 日活查询按日期区间扫,给 day 单独建索引。
|
||||
# (主键是 (user_id, day),前缀是 user_id,按 day 过滤用不上,故需补一条)
|
||||
op.create_index("ix_user_daily_seen_day", "user_daily_seen", ["day"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_user_daily_seen_day", table_name="user_daily_seen")
|
||||
op.drop_table("user_daily_seen")
|
||||
@ -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")
|
||||
120
backend/alembic/versions/p1q2r3s4t5u6_add_product_scraps.py
Normal file
120
backend/alembic/versions/p1q2r3s4t5u6_add_product_scraps.py
Normal file
@ -0,0 +1,120 @@
|
||||
"""add_product_scraps
|
||||
|
||||
Revision ID: p1q2r3s4t5u6
|
||||
Revises: o1p2q3r4s5t6
|
||||
Create Date: 2026-09-23
|
||||
|
||||
生产报废记录(product_scraps)
|
||||
--------------------------------------------------------------------------
|
||||
Track 发起的「领用物料在生产中报废」。料一经出库领用,那条库存行的可用量
|
||||
就已经扣掉了,所以走不了 MOM 的标准库存行报废;MOM 自己的答案是逆向物流的
|
||||
「从出库单退回(不良品)」→ 在管不良品 → 报废。Track 侧通过 MOM 的内部接口
|
||||
(/api/v1/internal/production-scrap)一次调用完成,本表存回执与关联。
|
||||
|
||||
为什么挂在**产品**维度而不是任务维度:
|
||||
料是领给这台**设备**的,不是领给某个人的。一台设备会经历多个任务、多个人的
|
||||
手(生产领料 → 装配 → 测试)。测试时摔坏的外壳是生产的人领的、挂在生产任务
|
||||
下 —— 若本表挂任务维度,测试在自己的任务里根本看不到它,「谁发现谁报」就落
|
||||
不了地。所以可见范围跟设备走,责任归属跟实际发生走(applicant 记在 MOM 单上)。
|
||||
跨设备的防护不靠隐藏,靠写入前校验 mom_line_id 确实挂在这台设备上。
|
||||
|
||||
为什么存快照(outbound_no / material_name / spec_model / sku / consumer_name):
|
||||
mom_line_id 是跨库逻辑外键(指向 MOM trans_outbound.id),MOM 侧数据被清理时
|
||||
就查不到了;且列表页若每条都跨库查,慢且脆。快照让「报了什么」永远看得见。
|
||||
|
||||
为什么**不**存金额:
|
||||
金额由 MOM 在执行报废时算(trans_scrap.total_loss = 单价 × 数量),且取决于
|
||||
执行时**实际扫码量**(MOM 允许少扫,受理量 ≠ 执行量)。在 Track 侧另存一份
|
||||
就是第二份口径,迟早对不上。展示/统计一律按 scrap_request_no 实时回查 MOM。
|
||||
|
||||
唯一约束 source_ref:
|
||||
幂等锚点,格式 <公司>:<Track单据号>,与发给 MOM 的值同一口径。
|
||||
用户点两下、或超时后重试,必须命中同一行而不是插出第二行。
|
||||
|
||||
本迁移只建表,不写入任何数据。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision: str = "p1q2r3s4t5u6"
|
||||
down_revision: Union[str, None] = "o1p2q3r4s5t6"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"product_scraps",
|
||||
sa.Column("id", UUID(as_uuid=True), nullable=False, comment="主键"),
|
||||
sa.Column("product_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("products.id"), nullable=False, comment="所属产品ID"),
|
||||
sa.Column("serial_number", sa.String(16), nullable=True,
|
||||
comment="产品序列号(冗余,便于按SN对账)"),
|
||||
sa.Column("task_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("tasks.id"), nullable=True,
|
||||
comment="料所属的Track任务(可空,仅溯源用,不参与可见性判断)"),
|
||||
sa.Column("mom_line_id", sa.Integer(), nullable=False,
|
||||
comment="报废对象:MOM trans_outbound.id(出库明细行)"),
|
||||
sa.Column("outbound_no", sa.String(100), nullable=True,
|
||||
comment="MOM 出库单号(快照)"),
|
||||
sa.Column("material_name", sa.String(255), nullable=True, comment="物料名称(快照)"),
|
||||
sa.Column("spec_model", sa.String(255), nullable=True, comment="规格型号(快照)"),
|
||||
sa.Column("sku", sa.String(100), nullable=True, comment="SKU(快照)"),
|
||||
sa.Column("consumer_name", sa.String(100), nullable=True,
|
||||
comment="原领用人(快照)。前端据此判断「报别人的料要额外确认」"),
|
||||
sa.Column("quantity", sa.Numeric(19, 4), nullable=False, comment="本次报废数量"),
|
||||
sa.Column("reason_category", sa.String(50), nullable=False,
|
||||
server_default="PRODUCTION",
|
||||
comment="报废原因分类码。生产报废恒为 PRODUCTION(生产损耗)"),
|
||||
sa.Column("reason", sa.Text(), nullable=True, comment="报废原因说明(用户填写)"),
|
||||
sa.Column("scrap_request_no", sa.String(100), nullable=False,
|
||||
comment="MOM 报废申请单号(APR-SCRAP-...)。状态与金额按它回查 MOM"),
|
||||
sa.Column("defective_goods_id", sa.Integer(), nullable=True,
|
||||
comment="MOM 在管不良品台账 id(退回时生成)"),
|
||||
sa.Column("mom_status", sa.Integer(), nullable=False, server_default="0",
|
||||
comment="MOM 报废单状态快照(0待审批/1已通过/2已驳回/3已执行/4已撤回),"
|
||||
"展示时以实时回查为准"),
|
||||
sa.Column("source_ref", sa.String(100), nullable=False,
|
||||
comment="幂等锚点 <公司>:<Track单据号>,随请求发给 MOM,两边同一口径"),
|
||||
sa.Column("submitted_by", sa.String(64), nullable=True,
|
||||
comment="提交人 Track 用户名(即 MOM 账号),MOM 侧报废单的申请人就是他本人"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
comment="本行写入时间"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("source_ref", name="uq_product_scrap_source_ref"),
|
||||
comment="生产报废记录 — Track 发起的领用物料报废,关联 MOM 报废申请单",
|
||||
)
|
||||
# 产品详情按 product_id 拉报废记录
|
||||
op.create_index(op.f("ix_product_scraps_product_id"),
|
||||
"product_scraps", ["product_id"], unique=False)
|
||||
# 按 SN 排查/对账
|
||||
op.create_index(op.f("ix_product_scraps_serial_number"),
|
||||
"product_scraps", ["serial_number"], unique=False)
|
||||
# 溯源到任务
|
||||
op.create_index(op.f("ix_product_scraps_task_id"),
|
||||
"product_scraps", ["task_id"], unique=False)
|
||||
# 按报废对象反查:这条料什么时候报过废
|
||||
op.create_index(op.f("ix_product_scraps_mom_line_id"),
|
||||
"product_scraps", ["mom_line_id"], unique=False)
|
||||
# 回查 MOM 状态/金额
|
||||
op.create_index(op.f("ix_product_scraps_scrap_request_no"),
|
||||
"product_scraps", ["scrap_request_no"], unique=False)
|
||||
# 「我提交的报废」
|
||||
op.create_index(op.f("ix_product_scraps_submitted_by"),
|
||||
"product_scraps", ["submitted_by"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for name in (
|
||||
"ix_product_scraps_submitted_by",
|
||||
"ix_product_scraps_scrap_request_no",
|
||||
"ix_product_scraps_mom_line_id",
|
||||
"ix_product_scraps_task_id",
|
||||
"ix_product_scraps_serial_number",
|
||||
"ix_product_scraps_product_id",
|
||||
):
|
||||
op.drop_index(op.f(name), table_name="product_scraps")
|
||||
op.drop_table("product_scraps")
|
||||
@ -0,0 +1,168 @@
|
||||
"""unify_product_outbound_materials
|
||||
|
||||
Revision ID: q1r2s3t4u5v6
|
||||
Revises: p1q2r3s4t5u6
|
||||
Create Date: 2026-09-23
|
||||
|
||||
设备出库明细(product_outbound_materials)—— 合并原先的两张表
|
||||
--------------------------------------------------------------------------
|
||||
原先「设备对应 MOM 的哪些出库单」被拆在两张表里:
|
||||
|
||||
· product_outbounds —— 产品 ↔ 出库**单**(单据级)。人工「追加出库单」
|
||||
或 MOM 回调存档写入。**没有 mom_line_id**。
|
||||
· task_outbound_materials —— 任务 ↔ 出库**明细**(明细级)。建任务勾选 /
|
||||
「+领料」/ 移动端领料写入。**有 mom_line_id**。
|
||||
|
||||
两者本来就是**同一件事**,却因为粒度不同被拆成两张、界面上两张卡 ——
|
||||
用户要面对两个入口、两个删除按钮,还会问「我在那边挂的怎么这边看不见」。
|
||||
更糟的是:**只有明细级那张带 mom_line_id,而报废必须靠它定位**,
|
||||
所以走单据级挂的料根本报不了废。
|
||||
|
||||
本迁移把它们统一到**明细级**一张表:
|
||||
· 单据级信息(request_no / applicant_name / remark / is_revoked)作为**冗余列**
|
||||
落到每条明细上 —— 同单内必然一致;
|
||||
· task_id 改为**可空**(webhook 存档不知道任务;任务只是溯源信息,
|
||||
不再是组织维度,展示/报废/删除一律按**设备**走);
|
||||
· mom_line_id 改为**可空** —— 从 product_outbounds 搬过来的存量行没有明细行 id,
|
||||
去 MOM 现查既慢又可能查不到,宁可留空(这类行只能看、不能报废)。
|
||||
|
||||
⚠️ 旧表**不删**:一是出问题时能回滚,二是它们还是「这次合并到底搬了什么」的证据。
|
||||
确认稳定后再单独一次迁移清掉(届时记得同步删掉模型与引用)。
|
||||
|
||||
数据搬迁用 ON CONFLICT DO NOTHING:部分唯一索引已保证「同设备同明细只一行」,
|
||||
重跑本迁移不会插重复。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision: str = "q1r2s3t4u5v6"
|
||||
down_revision: Union[str, None] = "p1q2r3s4t5u6"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"product_outbound_materials",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False, comment="主键"),
|
||||
sa.Column("product_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("products.id"), nullable=False, comment="所属设备ID"),
|
||||
sa.Column("serial_number", sa.String(16), nullable=True,
|
||||
comment="设备序列号(冗余,便于按SN对账)"),
|
||||
sa.Column("task_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("tasks.id"), nullable=True,
|
||||
comment="人工挂载时选的任务(可空,仅溯源用,不参与展示/报废/删除)"),
|
||||
sa.Column("mom_line_id", sa.Integer(), nullable=True,
|
||||
comment="MOM 出库明细行ID(逻辑外键→MOM trans_outbound.id)。为空=MOM 查不到明细的单据存档"),
|
||||
sa.Column("outbound_no", sa.String(100), nullable=False,
|
||||
comment="MOM 出库单号(批量出库多商品共用,故本表按明细行成行)"),
|
||||
sa.Column("request_no", sa.String(100), nullable=True, comment="MOM 出库申请单号"),
|
||||
sa.Column("applicant_name", sa.String(100), nullable=True,
|
||||
comment="申请人姓名(MOM 侧解析后传来,不做 ID 反查)"),
|
||||
sa.Column("remark", sa.Text(), nullable=True, comment="出库单备注"),
|
||||
sa.Column("sku", sa.String(100), nullable=True, comment="物料SKU(MOM 快照)"),
|
||||
sa.Column("material_name", sa.String(255), nullable=True, comment="物料名称(快照)"),
|
||||
sa.Column("spec_model", sa.String(255), nullable=True, comment="规格型号快照"),
|
||||
sa.Column("quantity", sa.Numeric(19, 4), nullable=True,
|
||||
comment="出库数量(出库单原值,不是本设备用量)"),
|
||||
sa.Column("unit_price", sa.Numeric(19, 2), nullable=True, comment="出库单价"),
|
||||
sa.Column("outbound_type", sa.String(50), nullable=True,
|
||||
comment="出库类型 SALES/USE/PRODUCTION/LOSS/REPAIR(只存不判)"),
|
||||
sa.Column("consumer_name", sa.String(100), nullable=True,
|
||||
comment="领用人/客户(MOM 侧自由填写,非可靠标识)"),
|
||||
sa.Column("operator_name", sa.String(100), nullable=True, comment="MOM 侧操作员"),
|
||||
sa.Column("warehouse_location", sa.String(100), nullable=True, comment="出库库位快照"),
|
||||
sa.Column("outbound_time", sa.DateTime(timezone=True), nullable=True,
|
||||
comment="MOM 记录的出库时间(已按 +08:00 补全时区)"),
|
||||
sa.Column("source", sa.String(16), nullable=False, server_default="manual",
|
||||
comment="来源: manual(人工挂载,可删) | webhook(MOM回调自动存档,不可删)"),
|
||||
sa.Column("is_revoked", sa.Boolean(), nullable=False, server_default="false",
|
||||
comment="该次出库是否已被 MOM 撤回"),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True, comment="撤回时间"),
|
||||
sa.Column("added_by", sa.String(64), nullable=True,
|
||||
comment="挂载人ID(逻辑外键→MOM sys_user)"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
comment="本行写入时间"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
comment="设备出库明细 — 一行 = 设备上的一条 MOM 出库明细(合并原 product_outbounds 与 task_outbound_materials)",
|
||||
)
|
||||
|
||||
# ---- 普通索引 ----
|
||||
op.create_index("ix_pom_product_id", "product_outbound_materials", ["product_id"])
|
||||
op.create_index("ix_pom_serial_number", "product_outbound_materials", ["serial_number"])
|
||||
op.create_index("ix_pom_task_id", "product_outbound_materials", ["task_id"])
|
||||
op.create_index("ix_pom_mom_line_id", "product_outbound_materials", ["mom_line_id"])
|
||||
op.create_index("ix_pom_outbound_no", "product_outbound_materials", ["outbound_no"])
|
||||
|
||||
# ---- 唯一约束(部分索引)----
|
||||
# 同一台设备上,同一条 MOM 出库明细只能出现一次。
|
||||
# WHERE mom_line_id IS NOT NULL:NULL 之间不相等,带上它等于给「无明细存档」开重复后门。
|
||||
op.create_index(
|
||||
"uq_pom_product_line", "product_outbound_materials",
|
||||
["product_id", "mom_line_id"], unique=True,
|
||||
postgresql_where=sa.text("mom_line_id IS NOT NULL"),
|
||||
)
|
||||
# 无明细行的存档:一张单在一台设备上只留一行
|
||||
op.create_index(
|
||||
"uq_pom_product_no_noline", "product_outbound_materials",
|
||||
["product_id", "outbound_no"], unique=True,
|
||||
postgresql_where=sa.text("mom_line_id IS NULL"),
|
||||
)
|
||||
|
||||
# =====================================================================
|
||||
# 存量搬迁
|
||||
# =====================================================================
|
||||
# ① product_outbounds(单据级)→ 明细级。没有 mom_line_id,故留空:
|
||||
# 这类行能看、能删(manual 的),但**不能报废** —— 报废要 mom_line_id 定位。
|
||||
op.execute("""
|
||||
INSERT INTO product_outbound_materials (
|
||||
product_id, serial_number, task_id, mom_line_id, outbound_no,
|
||||
request_no, applicant_name, remark,
|
||||
sku, material_name, spec_model, quantity, unit_price,
|
||||
outbound_type, consumer_name, operator_name, warehouse_location,
|
||||
outbound_time, source, is_revoked, revoked_at, added_by, created_at
|
||||
)
|
||||
SELECT product_id, serial_number, NULL, NULL, outbound_no,
|
||||
request_no, applicant_name, remark,
|
||||
NULL, NULL, NULL, NULL, NULL,
|
||||
outbound_type, consumer_name, operator, NULL,
|
||||
outbound_time, source, is_revoked, revoked_at, NULL, created_at
|
||||
FROM product_outbounds
|
||||
ON CONFLICT DO NOTHING
|
||||
""")
|
||||
|
||||
# ② task_outbound_materials(明细级)→ 明细级。product_id 从它所属任务带出。
|
||||
# 任务查不到产品(理论上有外键不该发生)时跳过该行 —— 本表 product_id NOT NULL。
|
||||
op.execute("""
|
||||
INSERT INTO product_outbound_materials (
|
||||
product_id, serial_number, task_id, mom_line_id, outbound_no,
|
||||
request_no, applicant_name, remark,
|
||||
sku, material_name, spec_model, quantity, unit_price,
|
||||
outbound_type, consumer_name, operator_name, warehouse_location,
|
||||
outbound_time, source, is_revoked, revoked_at, added_by, created_at
|
||||
)
|
||||
SELECT t.product_id, p.serial_number, m.task_id, m.mom_line_id, m.outbound_no,
|
||||
NULL, NULL, NULL,
|
||||
m.sku, m.material_name, m.spec_model, m.quantity, m.unit_price,
|
||||
m.outbound_type, m.consumer_name, m.operator_name, m.warehouse_location,
|
||||
m.outbound_time, 'manual', false, NULL, m.added_by, m.created_at
|
||||
FROM task_outbound_materials m
|
||||
JOIN tasks t ON t.id = m.task_id
|
||||
LEFT JOIN products p ON p.id = t.product_id
|
||||
ON CONFLICT DO NOTHING
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 只删新表。旧表与其数据自始至终没动过,所以回滚是干净的 ——
|
||||
# 这也是当初决定「旧表不删」的原因之一。
|
||||
for name in (
|
||||
"uq_pom_product_no_noline", "uq_pom_product_line",
|
||||
"ix_pom_outbound_no", "ix_pom_mom_line_id", "ix_pom_task_id",
|
||||
"ix_pom_serial_number", "ix_pom_product_id",
|
||||
):
|
||||
op.drop_index(name, table_name="product_outbound_materials")
|
||||
op.drop_table("product_outbound_materials")
|
||||
292
backend/app/api/v1/endpoints/audit.py
Normal file
292
backend/app/api/v1/endpoints/audit.py
Normal file
@ -0,0 +1,292 @@
|
||||
"""审计日志 API —— 查看系统操作审计记录
|
||||
|
||||
与 MOM(KCGL) /audit/logs 的接口保持同构的筛选维度(操作人/模块/动作/目标/
|
||||
时间区间),便于两端运维习惯统一;额外提供 request_id 筛选,可凭它直接跳到
|
||||
结构化日志里的那一次请求。
|
||||
|
||||
另提供两个 CSV 导出端点(审计明细 / 日活统计),均支持按列导出。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime, time, timedelta
|
||||
from typing import Any, Callable
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import require_admin
|
||||
from app.core.time_utils import BEIJING_TZ, get_beijing_time
|
||||
from app.schemas.audit import (
|
||||
AuditLogListResponse,
|
||||
AuditLogResponse,
|
||||
AuditOption,
|
||||
AuditOptionsResponse,
|
||||
DailyUsageResponse,
|
||||
DailyUsageRow,
|
||||
)
|
||||
from app.services import audit_service
|
||||
from app.services.audit_service import ACTION_LABELS, MODULE_LABELS
|
||||
|
||||
router = APIRouter(prefix="/audit", tags=["审计日志"])
|
||||
|
||||
|
||||
def _parse_day(value: str | None, *, end_of_day: bool = False) -> datetime | None:
|
||||
"""解析 YYYY-MM-DD 为北京时间。
|
||||
|
||||
结束日期取次日 00:00 作为上界(配合 < 判断)—— 直接取当天 23:59:59 会
|
||||
漏掉该秒内的记录,是日期区间筛选最常见的差一错误。
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
day = datetime.strptime(value, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
return None
|
||||
if end_of_day:
|
||||
return datetime.combine(day + timedelta(days=1), time.min, tzinfo=BEIJING_TZ)
|
||||
return datetime.combine(day, time.min, tzinfo=BEIJING_TZ)
|
||||
|
||||
|
||||
@router.get("/logs", response_model=AuditLogListResponse)
|
||||
async def get_audit_logs(
|
||||
user_id: str | None = Query(None, description="操作人账号(模糊匹配)"),
|
||||
module: str | None = Query(None, description="业务模块"),
|
||||
action: str | None = Query(None, description="动作类型"),
|
||||
target_id: str | None = Query(None, description="目标ID"),
|
||||
request_id: str | None = Query(None, description="请求ID(与接口日志对账)"),
|
||||
status_code: int | None = Query(None, description="响应状态码"),
|
||||
start_date: str | None = Query(None, description="起始日期 YYYY-MM-DD"),
|
||||
end_date: str | None = Query(None, description="结束日期 YYYY-MM-DD(含当天)"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
) -> AuditLogListResponse:
|
||||
"""审计日志分页查询(按时间倒序)"""
|
||||
start = _parse_day(start_date)
|
||||
# 结束日期用「次日 00:00」作为开区间上界,避免漏掉当天最后几条
|
||||
end_exclusive = _parse_day(end_date, end_of_day=True)
|
||||
|
||||
rows, total = await audit_service.list_audit_logs(
|
||||
db,
|
||||
user_id=user_id,
|
||||
module=module,
|
||||
action=action,
|
||||
target_id=target_id,
|
||||
request_id=request_id,
|
||||
status_code=status_code,
|
||||
start=start,
|
||||
end=end_exclusive - timedelta(microseconds=1) if end_exclusive else None,
|
||||
skip=(page - 1) * page_size,
|
||||
limit=page_size,
|
||||
)
|
||||
|
||||
items = []
|
||||
for row in rows:
|
||||
item = AuditLogResponse.model_validate(row)
|
||||
# 中文标签由服务端补,避免前端为每个枚举再维护一份映射
|
||||
item.module_label = MODULE_LABELS.get(row.module, row.module)
|
||||
item.action_label = ACTION_LABELS.get(row.action, row.action)
|
||||
items.append(item)
|
||||
|
||||
return AuditLogListResponse(items=items, total=total)
|
||||
|
||||
|
||||
@router.get("/options", response_model=AuditOptionsResponse)
|
||||
async def get_audit_options(
|
||||
current_user: dict = Depends(require_admin),
|
||||
) -> AuditOptionsResponse:
|
||||
"""筛选项:模块与动作的中文下拉;顺带下发导出可选列"""
|
||||
return AuditOptionsResponse(
|
||||
modules=[AuditOption(value=k, label=v) for k, v in MODULE_LABELS.items()],
|
||||
actions=[AuditOption(value=k, label=v) for k, v in ACTION_LABELS.items()],
|
||||
log_export_columns=[
|
||||
AuditOption(value=k, label=v[0]) for k, v in _AUDIT_LOG_COLUMNS.items()
|
||||
],
|
||||
usage_export_columns=[
|
||||
AuditOption(value=k, label=v[0]) for k, v in _DAILY_USAGE_COLUMNS.items()
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# CSV 导出
|
||||
# ============================================================
|
||||
|
||||
def _bj(dt: datetime | None) -> str:
|
||||
"""时间列统一按北京时间输出(与列表页、日活分日口径一致)。
|
||||
|
||||
直接输出 UTC 会让导出文件里 01:00 的操作显示成前一天 17:00,
|
||||
与网页上看到的对不上 —— 导出与页面不一致是最容易被质疑的那种问题。
|
||||
"""
|
||||
if dt is None:
|
||||
return ""
|
||||
return dt.astimezone(BEIJING_TZ).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def _actor(log) -> str:
|
||||
"""操作人:优先中文名,退化为账号(与列表页的展示规则一致)"""
|
||||
if not log.user_id and not log.display_name:
|
||||
return "未认证"
|
||||
return f"{log.display_name}({log.user_id})" if log.display_name else (log.user_id or "")
|
||||
|
||||
|
||||
# 列定义:key → (表头, 取值函数)。
|
||||
# 前端只传 key 列表,中文表头与取值口径都由后端统一维护,
|
||||
# 避免两端各写一份导致"导出的列和页面上的对不上"。
|
||||
_AUDIT_LOG_COLUMNS: dict[str, tuple[str, Callable[[Any], Any]]] = {
|
||||
"time": ("时间", lambda r: _bj(r.created_at)),
|
||||
"user": ("操作人", _actor),
|
||||
"role": ("角色", lambda r: r.role or ""),
|
||||
"module": ("模块", lambda r: MODULE_LABELS.get(r.module, r.module)),
|
||||
"action": ("动作", lambda r: ACTION_LABELS.get(r.action, r.action)),
|
||||
"method": ("方法", lambda r: r.method or ""),
|
||||
"url": ("请求路径", lambda r: r.url or ""),
|
||||
"status": ("结果", lambda r: r.status_code if r.status_code is not None else ""),
|
||||
"ip": ("来源IP", lambda r: r.ip_address or ""),
|
||||
"target": ("目标", lambda r: f"{r.target_type or ''}:{r.target_id or ''}".strip(":")),
|
||||
"error": ("错误信息", lambda r: r.error_message or ""),
|
||||
"request_id": ("请求ID", lambda r: r.request_id or ""),
|
||||
"user_agent": ("User-Agent", lambda r: r.user_agent or ""),
|
||||
}
|
||||
|
||||
_DAILY_USAGE_COLUMNS: dict[str, tuple[str, Callable[[dict], Any]]] = {
|
||||
"day": ("日期", lambda r: r["day"]),
|
||||
"user": ("操作人", lambda r: f"{r['display_name']}({r['user_id']})" if r["display_name"] else (r["user_id"] or "")),
|
||||
"role": ("角色", lambda r: r["role"] or ""),
|
||||
# 上线/下线时间 = 当天首次/末次活动(非登录时间),
|
||||
# 登录/登出次数单独成列,两者不再混为一谈
|
||||
"first_active": ("上线时间", lambda r: _bj(r["first_active_at"])),
|
||||
"last_active": ("下线时间", lambda r: _bj(r["last_active_at"])),
|
||||
"login_count": ("登录次数", lambda r: r["login_count"]),
|
||||
"logout_count": ("登出次数", lambda r: r["logout_count"]),
|
||||
"op_count": ("操作次数", lambda r: r["op_count"]),
|
||||
}
|
||||
|
||||
|
||||
def _csv_response(
|
||||
columns: dict[str, tuple[str, Callable]], keys: list[str], rows: list, filename: str,
|
||||
) -> Response:
|
||||
"""把行数据渲染成 CSV 响应。
|
||||
|
||||
⚠️ 必须带 UTF-8 BOM:Excel 靠它识别编码,否则中文表头与内容全是乱码。
|
||||
这是 CSV 导出最常见、也最容易被忽略的坑。
|
||||
"""
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow([columns[k][0] for k in keys])
|
||||
for row in rows:
|
||||
writer.writerow([columns[k][1](row) for k in keys])
|
||||
|
||||
return Response(
|
||||
content=b"\xef\xbb\xbf" + buf.getvalue().encode("utf-8"),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
# 文件名用纯 ASCII:中文文件名要走 RFC 5987,各浏览器行为不一致,
|
||||
# 内部系统没必要为它引入兼容成本。
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
def _resolve_keys(raw: str | None, columns: dict) -> list[str]:
|
||||
"""解析前端传来的列 key。缺省 = 全部列;未知 key 直接忽略(不报错)。"""
|
||||
if not raw:
|
||||
return list(columns)
|
||||
keys = [k.strip() for k in raw.split(",") if k.strip() in columns]
|
||||
return keys or list(columns)
|
||||
|
||||
|
||||
@router.get("/logs/export")
|
||||
async def export_audit_logs(
|
||||
user_id: str | None = Query(None, description="操作人账号(模糊匹配)"),
|
||||
module: str | None = Query(None, description="业务模块"),
|
||||
action: str | None = Query(None, description="动作类型"),
|
||||
target_id: str | None = Query(None, description="目标ID"),
|
||||
request_id: str | None = Query(None, description="请求ID"),
|
||||
status_code: int | None = Query(None, description="响应状态码"),
|
||||
start_date: str | None = Query(None, description="起始日期 YYYY-MM-DD"),
|
||||
end_date: str | None = Query(None, description="结束日期 YYYY-MM-DD(含当天)"),
|
||||
columns: str | None = Query(None, description="导出列,逗号分隔;缺省=全部"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
) -> Response:
|
||||
"""审计明细 CSV 导出 —— 筛选维度与 /logs 完全一致,保证"看到什么就能导出什么"。"""
|
||||
start = _parse_day(start_date)
|
||||
end_exclusive = _parse_day(end_date, end_of_day=True)
|
||||
|
||||
rows, truncated = await audit_service.export_audit_logs(
|
||||
db,
|
||||
user_id=user_id, module=module, action=action, target_id=target_id,
|
||||
request_id=request_id, status_code=status_code,
|
||||
start=start,
|
||||
end=end_exclusive - timedelta(microseconds=1) if end_exclusive else None,
|
||||
)
|
||||
|
||||
keys = _resolve_keys(columns, _AUDIT_LOG_COLUMNS)
|
||||
resp = _csv_response(_AUDIT_LOG_COLUMNS, keys, rows, "audit_logs.csv")
|
||||
if truncated:
|
||||
# 用响应头传递"已截断",前端据此提示用户收窄筛选条件
|
||||
resp.headers["X-Export-Truncated"] = "1"
|
||||
resp.headers["X-Export-Max-Rows"] = str(audit_service.EXPORT_MAX_ROWS)
|
||||
resp.headers["Access-Control-Expose-Headers"] = "X-Export-Truncated, X-Export-Max-Rows"
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/daily-usage/export")
|
||||
async def export_daily_usage(
|
||||
start_date: str | None = Query(None, description="起始日期 YYYY-MM-DD(北京时间),默认今天"),
|
||||
end_date: str | None = Query(None, description="结束日期 YYYY-MM-DD(北京时间),默认同起始日"),
|
||||
columns: str | None = Query(None, description="导出列,逗号分隔;缺省=全部"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
) -> Response:
|
||||
"""日活统计 CSV 导出 —— 每人一行:上线/下线次数与时间、操作次数。"""
|
||||
start = _parse_day(start_date) or datetime.combine(
|
||||
get_beijing_time().date(), time.min, tzinfo=BEIJING_TZ,
|
||||
)
|
||||
end = _parse_day(end_date, end_of_day=True) or (start + timedelta(days=1))
|
||||
|
||||
items = await audit_service.get_daily_usage(db, start=start, end=end)
|
||||
keys = _resolve_keys(columns, _DAILY_USAGE_COLUMNS)
|
||||
return _csv_response(_DAILY_USAGE_COLUMNS, keys, items, "daily_usage.csv")
|
||||
|
||||
|
||||
@router.get("/daily-usage", response_model=DailyUsageResponse)
|
||||
async def get_daily_usage(
|
||||
start_date: str | None = Query(None, description="起始日期 YYYY-MM-DD(北京时间),默认今天"),
|
||||
end_date: str | None = Query(None, description="结束日期 YYYY-MM-DD(北京时间),默认同起始日"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
) -> DailyUsageResponse:
|
||||
"""日活 / 使用统计 —— 按【北京时间自然日 × 操作人】聚合。
|
||||
|
||||
回答的是「每天有哪些人用了系统、用了多少」:
|
||||
· 上线时间 / 下线时间:当天**首次 / 末次活动**时间(任意审计记录)
|
||||
· 操作次数:当天该用户的全部审计记录数(使用深度)
|
||||
· 登录次数 / 登出次数:真实的手动登录 / 登出行为计数
|
||||
|
||||
⚠️ 上线时间【不取登录时间】:token 有效期内(refresh 7 天)用户不重新登录,
|
||||
按登录算会让「周一登录、周二继续用」的周二变成"登录次数 0、上线时间空,
|
||||
但操作次数 35"——报表自相矛盾。改用活动口径后,当天的第一次操作即上线时间。
|
||||
|
||||
⚠️ 登出次数天然小于登录次数:用户直接关浏览器、断网、token 过期都不会
|
||||
产生登出记录。这是真实情况,不做任何"补齐"推算。
|
||||
"""
|
||||
# 起始日:未传则取北京的今天。_parse_day 返回的是北京时间当日 00:00。
|
||||
start = _parse_day(start_date) or datetime.combine(
|
||||
get_beijing_time().date(), time.min, tzinfo=BEIJING_TZ,
|
||||
)
|
||||
# 结束日:_parse_day(end_of_day=True) 已给出「次日 00:00」,正好当作半开上界。
|
||||
# 未传则默认单日查询(= 起始日当天)。
|
||||
end = _parse_day(end_date, end_of_day=True) or (start + timedelta(days=1))
|
||||
|
||||
items = await audit_service.get_daily_usage(db, start=start, end=end)
|
||||
|
||||
return DailyUsageResponse(
|
||||
start_date=start.astimezone(BEIJING_TZ).strftime("%Y-%m-%d"),
|
||||
end_date=(end - timedelta(days=1)).astimezone(BEIJING_TZ).strftime("%Y-%m-%d"),
|
||||
items=[DailyUsageRow(**row) for row in items],
|
||||
total=len(items),
|
||||
)
|
||||
@ -1,5 +1,5 @@
|
||||
"""认证 API — 对接 MOM sys_user + 双 Token 刷新"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from app.schemas.user import (
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
@ -7,23 +7,68 @@ from app.schemas.user import (
|
||||
RefreshResponse,
|
||||
UserResponse,
|
||||
)
|
||||
from app.core.security import peek_token_identity
|
||||
from app.services.auth_service import login, refresh_access_token, get_current_user
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
def login_endpoint(data: LoginRequest):
|
||||
def login_endpoint(data: LoginRequest, request: Request):
|
||||
"""登录 — 验证 MOM sys_user 表,返回 Access + Refresh 双 Token"""
|
||||
return login(data.username, data.password)
|
||||
# 登录请求本身尚未认证,中间件拿不到操作人。但「谁在尝试登录、失败了多少次」
|
||||
# 恰恰是审计里最该有的信息,所以在校验之前就把尝试的账号写进 state:
|
||||
# 登录失败时同样留痕,且能按账号追踪暴力破解。
|
||||
# 注意:绝不把 data.password 写进 state / 审计,密码不落库。
|
||||
request.state.audit_user = data.username
|
||||
result = login(data.username, data.password)
|
||||
|
||||
# 登录成功后补上显示名 / 角色 —— 否则审计里这条记录的「操作人」会退化成账号
|
||||
# (前端按 display_name || user_id 渲染,见 AdminAuditLogPage)。
|
||||
# 能在这里补的原因:中间件是在 call_next 返回【之后】才落库的,此刻写入
|
||||
# request.state 依然会被采集到。
|
||||
# 失败登录走不到这里,保持「只有账号可追责」——这正是想要的语义。
|
||||
if result.user:
|
||||
request.state.audit_display_name = result.user.display_name
|
||||
request.state.audit_role = result.user.role
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=RefreshResponse)
|
||||
def refresh_endpoint(data: RefreshRequest):
|
||||
def refresh_endpoint(data: RefreshRequest, request: Request):
|
||||
"""刷新 Access Token — 使用 Refresh Token 换取新的 Access Token"""
|
||||
# 本接口刻意不挂 get_current_user:能用到这里,正是因为 access token 已经
|
||||
# 过期/缺失,请求里没有 Authorization 头,JWT 依赖不会执行 → 审计拿不到操作人,
|
||||
# 记录只能显示「未认证」。
|
||||
# 但 refresh token 里本来就带着完整身份(sub/username/display_name/role),
|
||||
# 解出来写进 state,审计才能记到人 —— 而"谁在何时尝试刷新"正是要留痕的。
|
||||
# 注意 peek 只用于审计标注,鉴权判断一律走 get_current_user。
|
||||
identity = peek_token_identity(data.refresh_token)
|
||||
if identity:
|
||||
request.state.audit_user = identity.get("username") or identity.get("sub")
|
||||
request.state.audit_display_name = identity.get("display_name") or ""
|
||||
request.state.audit_role = identity.get("role") or ""
|
||||
return refresh_access_token(data.refresh_token)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout_endpoint(current_user: dict = Depends(get_current_user)):
|
||||
"""登出 —— 仅用于审计留痕。
|
||||
|
||||
JWT 是无状态的,服务端没有可吊销的会话,因此本接口**不做任何令牌失效**
|
||||
(客户端清掉本地 token 即为登出),返回体也没有实际语义。
|
||||
|
||||
它存在的唯一目的:让审计中间件记下「谁在何时退出了系统」。
|
||||
没有这个端点时,前端「退出」只清本地存储、不产生任何请求,
|
||||
退出动作在审计里完全不可见 —— 而"谁在什么时候退掉了系统"
|
||||
在追责场景下和"谁登录了"同等重要。
|
||||
|
||||
挂 Depends(get_current_user) 是为了让 JWT 依赖把操作人写进 request.state
|
||||
(见 auth_service.get_current_user),记录到真实姓名而非「未认证」。
|
||||
"""
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def get_me(current_user: dict = Depends(get_current_user)):
|
||||
"""获取当前用户信息(从 Access Token 解析)"""
|
||||
|
||||
@ -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,
|
||||
|
||||
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)}",
|
||||
)
|
||||
@ -19,6 +19,7 @@ async def list_orders(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(ProductionOrder).offset(skip).limit(limit).order_by(ProductionOrder.created_at.desc())
|
||||
|
||||
@ -11,10 +11,18 @@ from app.models.message import ProductMessage
|
||||
from app.schemas.product import (
|
||||
ProductCreate,
|
||||
ProductUpdate,
|
||||
ProductOutboundMaterialResponse,
|
||||
ProductResponse,
|
||||
ProductScanResponse,
|
||||
ProductScrapCreate,
|
||||
ProductScrapResponse,
|
||||
)
|
||||
from app.services import (
|
||||
product_service,
|
||||
product_finalize_service,
|
||||
product_scrap_service,
|
||||
product_outbound_material_service,
|
||||
)
|
||||
from app.services import product_service, product_finalize_service
|
||||
from app.services.auth_service import get_current_user
|
||||
from app.services.qrcode_service import generate_qrcode_png
|
||||
|
||||
@ -29,6 +37,18 @@ router = APIRouter(prefix="/products", tags=["产品管理"])
|
||||
async def get_product_qrcode(serial_number: str):
|
||||
"""
|
||||
生成产品二维码(PNG 图片)。
|
||||
|
||||
⚠️ 本接口【刻意不加鉴权】:
|
||||
前端以 `<img src="/api/v1/products/qrcode/{sn}">` 引用它,而 <img>
|
||||
无法携带 Authorization 头 —— 加了鉴权会让所有二维码图片加载失败,
|
||||
并在审计里刷出大量 401。
|
||||
|
||||
不加鉴权是安全的:本函数**不查数据库**,只校验长度并把这个字符串渲染成
|
||||
二维码,没有任何业务数据泄露面(序列号本身就是调用方提供的)。
|
||||
|
||||
也刻意不支持 ?token= 兜底:把 JWT 放进 URL 会渗进访问日志、浏览器历史
|
||||
与 Referer,比它想解决的问题更糟。
|
||||
|
||||
内容为 16 位序列号,扫描后可调用 /scan/{serial_number} 查询产品。
|
||||
尺寸:300×300 px,用于 PC 端打印或嵌入标签。
|
||||
"""
|
||||
@ -50,6 +70,7 @@ async def get_product_qrcode(serial_number: str):
|
||||
async def scan_product(
|
||||
serial_number: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
扫码接口:根据 16 位序列号查询产品及其当前进度。
|
||||
@ -69,6 +90,7 @@ async def list_products(
|
||||
keyword: str | None = Query(None, description="多维搜索: 产品身份证/订单号/规格型号"),
|
||||
status: str | None = Query(None, description="产品状态筛选: PENDING/WIP/COMPLETED/ARCHIVED"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""获取产品列表 — 支持 keyword 搜索 + 状态筛选"""
|
||||
return await product_service.get_all_products(
|
||||
@ -80,6 +102,7 @@ async def list_products(
|
||||
async def get_product(
|
||||
product_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""获取单个产品详情"""
|
||||
import uuid
|
||||
@ -92,11 +115,169 @@ async def create_product_endpoint(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""创建产品 — 初始位置自动设为当前登录用户"""
|
||||
"""创建产品 — 初始位置自动设为当前登录用户
|
||||
|
||||
`mom_line_ids` 非空时,会在**同一事务**里把对应的 MOM 出库单挂到这个新产品上,
|
||||
所以不存在「产品建好了但出库单没挂上」的中间态。
|
||||
"""
|
||||
creator_username = current_user.get("username", "")
|
||||
return await product_service.create_product(db, data, creator_username)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 设备的 MOM 出库明细(统一后的唯一一组接口)
|
||||
#
|
||||
# 原先这里是两套并存的接口:
|
||||
# · /outbound-orders —— 产品 ↔ 出库**单**(单据级,product_outbounds)
|
||||
# · /materials —— 任务 ↔ 出库**明细**(明细级,task_outbound_materials)
|
||||
# 两者本来就是同一个概念,却因为粒度不同被拆开:用户要面对两个入口、两张卡,
|
||||
# 而且走单据级挂的料**没有明细行 id,报不了废**。现已合并 ——
|
||||
# 一张表 product_outbound_materials、一组接口、界面上只有一张卡。
|
||||
# ============================================================
|
||||
|
||||
class ProductOutboundMaterialsAdd(BaseModel):
|
||||
"""挂载 MOM 出库明细的请求体"""
|
||||
# 提交的是 MOM 出库**明细行** ID(trans_outbound.id)。前端按整张出库单勾选,
|
||||
# 提交时把该单全部明细 ID 带过来 —— 本表按明细行成行,一张单展开成 N 行。
|
||||
# ⚠️ 只传 ID,物料快照由后端现查 MOM —— 不接受前端传快照,否则可伪造。
|
||||
mom_line_ids: list[int] = Field(default_factory=list, description="MOM 出库明细行ID")
|
||||
# 挂到哪条任务(**可空**)。一线按任务领料,所以人工挂载时会给;
|
||||
# 但任务只是溯源信息,不参与展示/报废/删除 —— 那些一律按设备走。
|
||||
task_id: str | None = Field(None, description="所属任务ID(可空,仅溯源)")
|
||||
|
||||
|
||||
@router.get("/{product_id}/outbound-materials",
|
||||
response_model=list[ProductOutboundMaterialResponse])
|
||||
async def get_product_outbound_materials_endpoint(
|
||||
product_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""列出该设备挂载的全部 MOM 出库明细(按出库时间倒序)。
|
||||
|
||||
回答「这台设备对应 MOM 的哪些出库单、领了哪些料」——
|
||||
网页端编辑产品弹窗与移动端「领用物料」页读的都是它,两端同源。
|
||||
"""
|
||||
import uuid
|
||||
return await product_outbound_material_service.list_product_materials(
|
||||
db, uuid.UUID(product_id))
|
||||
|
||||
|
||||
@router.post("/{product_id}/outbound-materials",
|
||||
response_model=list[ProductOutboundMaterialResponse])
|
||||
async def add_product_outbound_materials_endpoint(
|
||||
product_id: str,
|
||||
data: ProductOutboundMaterialsAdd,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""给设备挂载 MOM 出库明细(网页端/移动端的「+ 领料」都走这里)。
|
||||
|
||||
幂等:已挂过的明细会被跳过。返回该设备当前**全部**出库明细。
|
||||
"""
|
||||
import uuid
|
||||
pid = uuid.UUID(product_id)
|
||||
product = await product_service.get_product(db, pid) # 不存在则 404
|
||||
added = await product_outbound_material_service.link_outbound_lines(
|
||||
db, product, data.mom_line_ids,
|
||||
task_id=uuid.UUID(data.task_id) if data.task_id else None,
|
||||
added_by=current_user.get("username"),
|
||||
)
|
||||
if added:
|
||||
await db.commit()
|
||||
return await product_outbound_material_service.list_product_materials(db, pid)
|
||||
|
||||
|
||||
@router.delete("/{product_id}/outbound-materials/by-order/{outbound_no}",
|
||||
response_model=list[ProductOutboundMaterialResponse])
|
||||
async def remove_product_outbound_order_endpoint(
|
||||
product_id: str,
|
||||
outbound_no: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""整张出库单一起摘掉(挂错了要能撤)。
|
||||
|
||||
界面上更容易碰到的是「这一单整个挂错了」,逐条删要删好几下。
|
||||
规则与逐条删**完全一致**:只要这张单在本设备上有一行来自 MOM 回调
|
||||
自动存档(`source='webhook'`),整单就返回 409 —— 那是系统事实,
|
||||
要撤得去 MOM 撤回。这样整单删不会变成绕过单行规则的后门。
|
||||
|
||||
⚠️ 路径放在 `/{material_id}` **之前**注册:`by-order` 是固定段,
|
||||
但要避免被 `{material_id}` 抢先匹配(Starlette 按注册顺序匹配)。
|
||||
"""
|
||||
import uuid
|
||||
return await product_outbound_material_service.remove_product_order(
|
||||
db, uuid.UUID(product_id), outbound_no)
|
||||
|
||||
|
||||
@router.delete("/{product_id}/outbound-materials/{material_id}",
|
||||
response_model=list[ProductOutboundMaterialResponse])
|
||||
async def remove_product_outbound_material_endpoint(
|
||||
product_id: str,
|
||||
material_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""摘掉一条**人工挂载**的出库明细(挂错了要能撤)。
|
||||
|
||||
⚠️ 只允许删人工挂的(`source='manual'`):MOM 出库回调自动存档的行返回 409
|
||||
—— 那是系统事实,要撤得去 MOM 撤回,由回调置「已撤回」留痕。
|
||||
|
||||
返回该设备**剩余**的全部出库明细,前端整体覆盖即可。
|
||||
"""
|
||||
import uuid
|
||||
return await product_outbound_material_service.remove_product_material(
|
||||
db, uuid.UUID(product_id), material_id)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 生产报废 —— Track 发起,MOM 走「退回(不良品) → 报废申请 → 审批 → 执行」
|
||||
# ============================================================
|
||||
|
||||
@router.get("/{product_id}/scraps", response_model=list[ProductScrapResponse])
|
||||
async def list_product_scraps_endpoint(
|
||||
product_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""列出该产品的生产报废记录(按提交时间倒序)。
|
||||
|
||||
状态与金额是**实时回查 MOM** 的:报废没有回调,本地存的那份会过期,
|
||||
而「到底批没批、执行没执行」正是用户要看的。
|
||||
MOM 暂时查不到时降级用本地快照,但**不伪造金额**(未执行时 total_loss 是 null)。
|
||||
"""
|
||||
import uuid
|
||||
return await product_scrap_service.list_product_scraps(db, uuid.UUID(product_id))
|
||||
|
||||
|
||||
@router.post("/{product_id}/scraps", response_model=ProductScrapResponse)
|
||||
async def create_product_scrap_endpoint(
|
||||
product_id: str,
|
||||
data: ProductScrapCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""提交一条生产报废(领用的料在生产中损坏)。
|
||||
|
||||
- 只传 `mom_line_id` + 数量 + `track_ref`,物料信息由后端从本产品已挂的
|
||||
出库物料里取 —— 不接受前端传快照。
|
||||
- 后端校验 `mom_line_id` **确实挂在本产品上**:可见范围是整台设备,
|
||||
跨设备防护只能靠这道校验(不靠隐藏)。
|
||||
- 申请人 = 当前登录人(Track 的 sub 就是 MOM sys_user.id)。
|
||||
- 幂等:同一个 `track_ref` 重发不会产生第二张 MOM 报废单。
|
||||
"""
|
||||
import uuid
|
||||
return await product_scrap_service.submit_product_scrap(
|
||||
db, uuid.UUID(product_id),
|
||||
mom_line_id=data.mom_line_id,
|
||||
quantity=data.quantity,
|
||||
track_ref=data.track_ref,
|
||||
reason=data.reason,
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{product_id}", response_model=ProductResponse)
|
||||
async def update_product_endpoint(
|
||||
product_id: str,
|
||||
@ -196,6 +377,7 @@ class MessageCreate(BaseModel):
|
||||
async def get_product_messages(
|
||||
product_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""获取某产品的所有留言(按时间正序)"""
|
||||
result = await db.execute(
|
||||
|
||||
@ -38,6 +38,7 @@ async def list_tasks(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""获取任务列表,可按产品/负责人筛选(只返回顶层任务)"""
|
||||
pid = uuid.UUID(product_id) if product_id else None
|
||||
@ -48,6 +49,7 @@ async def list_tasks(
|
||||
async def get_task(
|
||||
task_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
获取任务详情 — 递归包含所有层级的子任务。
|
||||
@ -62,9 +64,23 @@ async def create_task_endpoint(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""创建任务"""
|
||||
return await task_service.create_task(db, data)
|
||||
"""创建任务
|
||||
|
||||
`mom_line_ids` 非空时,会在**同一事务**里把对应的 MOM 出库明细挂到新任务上,
|
||||
所以不存在「任务建好了但物料没挂上」的中间态。
|
||||
"""
|
||||
return await task_service.create_task(
|
||||
db, data, operator_id=current_user.get("username"),
|
||||
)
|
||||
|
||||
|
||||
# 注:原先这里有「任务挂载 MOM 出库物料」的三个端点
|
||||
# (GET/POST /tasks/{id}/outbound-materials、DELETE .../{material_id})。
|
||||
# 物料已统一为**设备级**,这三个端点连同 task_service 里的实现一起删除 ——
|
||||
# 挂载/查看/删除/报废一律走:
|
||||
# GET/POST /products/{id}/outbound-materials
|
||||
# DELETE /products/{id}/outbound-materials/{material_id}
|
||||
# 保留任务级入口只会让「同一个东西两个地方」重新长出来。
|
||||
|
||||
@router.patch("/{task_id}", response_model=TaskResponse)
|
||||
async def update_task_endpoint(
|
||||
@ -299,6 +315,7 @@ async def create_subtask_endpoint(
|
||||
async def get_tasks_by_product(
|
||||
product_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""获取指定产品的顶层任务列表(不含子任务嵌套)"""
|
||||
return await task_service.get_top_level_tasks(db, uuid.UUID(product_id))
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -2,90 +2,118 @@
|
||||
|
||||
MOM 仓储系统确认接收产品入库后,回调本接口,将 Track 中该产品的状态
|
||||
真正标记为"已入库闭环"(更新宏观状态 + 记录 task_logs 证明仓库已接收)。
|
||||
|
||||
同一条入站通道还承担【撤回出库】的强制回滚:MOM 把误点出库的设备物理
|
||||
回滚到仓库时,Track 必须被动跟随 MOM 的权威物理状态(详见
|
||||
_mom_inbound_revoke 上方的特权通道说明)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.core.lifecycle import sync_product_status
|
||||
from app.core.time_utils import get_beijing_time
|
||||
from app.models.product import Product
|
||||
from app.models.task import Task, TaskRecord
|
||||
from app.models.task_log import TaskLog
|
||||
from app.services import product_outbound_material_service
|
||||
|
||||
router = APIRouter(prefix="/external/webhooks", tags=["外部回调"])
|
||||
|
||||
|
||||
class MomInboundPayload(BaseModel):
|
||||
"""MOM 仓储系统确认接收入库的回调载荷"""
|
||||
"""MOM 仓储系统确认接收入库 / 撤回出库的回调载荷"""
|
||||
serial_number: str | None = None # 产品 16 位身份证(可空,优先匹配)
|
||||
sku: str | None = None # 规格型号 spec_model(serial 缺失时的兜底匹配)
|
||||
operator: str | None = None # 入库操作人(写入 task_logs.operator_id)
|
||||
inbound_time: datetime | None = None # 入库确认时间
|
||||
# ↓ MOM 侧一直在发、此前被 Pydantic 静默丢弃的字段。撤回信号靠它们识别。
|
||||
event: str | None = None # 事件名,如 inbound.created / outbound.revoked
|
||||
action: str | None = None # 显式动作指令,如 revoke_outbound
|
||||
source_table: str | None = None # stock_product / stock_semi
|
||||
company_name: str | None = None # 目标公司(IRIS / LICA),MOM 据此分流到不同 Track 实例
|
||||
|
||||
|
||||
@router.post("/mom-inbound")
|
||||
async def mom_inbound_webhook(
|
||||
payload: MomInboundPayload,
|
||||
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""MOM 仓储系统确认接收产品入库后回调本接口。
|
||||
# 「撤回出库」信号词 —— 只在 action / event 里做子串匹配。
|
||||
# MOM 侧的字段命名尚未冻结,故刻意宽松:revoke_outbound / outbound.revoked /
|
||||
# rollback_outbound 都能命中,避免因对方改个词就整条链路失联。
|
||||
_OUTBOUND_REVOKE_TOKENS = ("revoke", "rollback", "revert", "cancel")
|
||||
|
||||
- 鉴权:Header X-API-Key 必须等于环境变量 TRACK_WEBHOOK_KEY。
|
||||
- 用 serial_number(优先)或 sku 查询当前位于 virtual_warehouse 的产品;
|
||||
命中则标记"已实收"(overall_status=已入库 + 记录 task_logs)。
|
||||
- 未命中返回 200(MOM 可能入库了非 Track 生产的物料,直接忽略)。
|
||||
|
||||
# ── 公司归属分流 ──────────────────────────────────────────────────────────
|
||||
# MOM 现在会在载荷里带 company_name,同一套物理库可能同时向多个 Track 实例
|
||||
# (IRIS / LICA)回调。本实例服务的是 IRIS,故只放行 IRIS 与空白值。
|
||||
#
|
||||
# ⚠️ 判定刻意做成「只排除已知的外来公司」,而非「白名单只认 IRIS」:
|
||||
# MOM 在无法确定公司归属时会回落到扁平配置,该配置指向本实例 —— 这类
|
||||
# 消息的 company_name 会是空 / 缺失。若此处按白名单把空白也拒掉,它们
|
||||
# 就彻底丢了:MOM 那边已收到 200、认为投递成功,不会再重推。
|
||||
# 同理,未见过的新值(不是 IRIS 也不是 LICA)也一律照常处理。
|
||||
_FOREIGN_COMPANIES = {"LICA"}
|
||||
|
||||
|
||||
def _is_foreign_company(company_name: str | None) -> bool:
|
||||
"""载荷是否属于本实例不该处理的其它公司。
|
||||
|
||||
返回 True 表示应原样忽略(仍回 200,避免 MOM 反复重推)。
|
||||
"""
|
||||
# ── 鉴权 ──
|
||||
if not settings.TRACK_WEBHOOK_KEY or x_api_key != settings.TRACK_WEBHOOK_KEY:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized: invalid X-API-Key")
|
||||
# 大小写 / 首尾空白都容忍:MOM 侧常量书写方式未必冻结,误判的代价是
|
||||
# 一条消息被错误地当成本公司处理(有唯一匹配约束,最坏是 matched=False)。
|
||||
return (company_name or "").strip().upper() in _FOREIGN_COMPANIES
|
||||
|
||||
# ── 按 serial_number / external_serial(双字段联合)或 sku 匹配"当前位于仓库"的产品 ──
|
||||
product = None
|
||||
if payload.serial_number:
|
||||
product = (
|
||||
await db.execute(
|
||||
select(Product).where(
|
||||
or_(
|
||||
Product.serial_number == payload.serial_number,
|
||||
Product.external_serial == payload.serial_number,
|
||||
),
|
||||
Product.current_location_id == "virtual_warehouse",
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
elif payload.sku:
|
||||
product = (
|
||||
await db.execute(
|
||||
select(Product)
|
||||
.where(
|
||||
Product.spec_model == payload.sku,
|
||||
Product.current_location_id == "virtual_warehouse",
|
||||
)
|
||||
.order_by(Product.created_at.desc())
|
||||
)
|
||||
).scalars().first()
|
||||
|
||||
# ── 未命中:可能是非 Track 生产的物料,直接忽略 ──
|
||||
if product is None:
|
||||
return {"ok": True, "matched": False}
|
||||
def _attribute_audit_to_mom_operator(request: Request, operator: str | None) -> None:
|
||||
"""把外部回调归因到 MOM 侧的实际操作人。
|
||||
|
||||
# ── 标记"已实收"闭环 ──
|
||||
changed = False
|
||||
if product.overall_status != "已入库":
|
||||
product.overall_status = "已入库"
|
||||
# 双字段同步:整体状态与产品状态保持一致(前端徽标依赖 status)
|
||||
product.status = "ARCHIVED"
|
||||
changed = True
|
||||
外部回调走 X-API-Key 鉴权、没有 JWT,所以 JWT 依赖不执行,
|
||||
审计中间件读到的 request.state.audit_user 永远是空 ——
|
||||
操作审计里就出现一堆没有归属的「外部系统对接」记录。
|
||||
|
||||
# 记录仓库接收日志(优先"在库"任务,其次该产品最新任务;无任务则仅更新状态)
|
||||
inbound_task = (
|
||||
但 MOM 载荷里本来就带着实际操作人(operator,即 MOM 侧扫码的那位),
|
||||
写进 request.state 即可让审计归因到人。
|
||||
|
||||
⚠️ 必须在 X-API-Key 校验【之后】调用:密钥不对说明载荷本身就不可信,
|
||||
此时把 operator 写进审计等于允许伪造人。
|
||||
"""
|
||||
who = (operator or "").strip()
|
||||
if not who:
|
||||
# 取不到操作人时留一个明确的系统标记,而不是继续显示「未认证」——
|
||||
# 「MOM系统」至少说明这是一次机器回调,不是"一个匿名的人"。
|
||||
request.state.audit_user = "MOM系统"
|
||||
return
|
||||
request.state.audit_user = who
|
||||
try:
|
||||
# 尽力而为:查不到中文名也不影响审计(前端会回退显示账号)
|
||||
from app.services.mom_cache import get_display_names
|
||||
request.state.audit_display_name = get_display_names([who]).get(who) or ""
|
||||
except Exception: # noqa: BLE001 —— 姓名解析失败绝不能影响回调处理
|
||||
pass
|
||||
|
||||
|
||||
def _is_outbound_revoke(payload: MomInboundPayload) -> bool:
|
||||
"""payload 是否携带**显式**的撤回出库信号。
|
||||
|
||||
注意:返回 False 不代表「不是撤回」——MOM 也可能不加任何标记、直接以
|
||||
常规 inbound.created 重推。那种隐式信号由调用方用「产品此刻是否处于
|
||||
已出库」兜底判定(见 mom_inbound_webhook 里的 was_outbound)。
|
||||
"""
|
||||
for raw in (payload.action, payload.event):
|
||||
token = (raw or "").strip().lower()
|
||||
if token and any(word in token for word in _OUTBOUND_REVOKE_TOKENS):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def _pick_warehouse_log_task(db: AsyncSession, product: Product) -> Task | None:
|
||||
"""挑一条挂日志的任务:优先「在库」任务,其次该产品最新任务,都没有则 None。"""
|
||||
task = (
|
||||
await db.execute(
|
||||
select(Task)
|
||||
.where(Task.product_id == product.id, Task.task_name.ilike("%在库%"))
|
||||
@ -93,8 +121,8 @@ async def mom_inbound_webhook(
|
||||
.limit(1)
|
||||
)
|
||||
).scalars().first()
|
||||
if inbound_task is None:
|
||||
inbound_task = (
|
||||
if task is None:
|
||||
task = (
|
||||
await db.execute(
|
||||
select(Task)
|
||||
.where(Task.product_id == product.id)
|
||||
@ -102,25 +130,204 @@ async def mom_inbound_webhook(
|
||||
.limit(1)
|
||||
)
|
||||
).scalars().first()
|
||||
return task
|
||||
|
||||
|
||||
async def _match_inbound_product(
|
||||
db: AsyncSession, payload: MomInboundPayload, *, allow_outbound: bool,
|
||||
) -> Product | None:
|
||||
"""按 serial_number(优先)或 sku 匹配产品。
|
||||
|
||||
allow_outbound=False:只认「当前挂在虚拟仓库池」的产品(常规入库的既有语义)。
|
||||
allow_outbound=True :额外放行「已出库」产品 —— 出库回调会把 current_location_id
|
||||
置为 None,若仍用原条件,撤回信号必然失配并静默 return matched=False,
|
||||
造成 MOM 认为货已回库、Track 却永远停在「已出库」的数据脑裂。
|
||||
"""
|
||||
location_cond = Product.current_location_id == "virtual_warehouse"
|
||||
where_cond = (
|
||||
or_(
|
||||
location_cond,
|
||||
Product.overall_status == "已出库",
|
||||
Product.status == "OUTBOUND",
|
||||
)
|
||||
if allow_outbound
|
||||
else location_cond
|
||||
)
|
||||
|
||||
if payload.serial_number:
|
||||
return (
|
||||
await db.execute(
|
||||
select(Product).where(
|
||||
or_(
|
||||
Product.serial_number == payload.serial_number,
|
||||
Product.external_serial == payload.serial_number,
|
||||
),
|
||||
where_cond,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if payload.sku:
|
||||
return (
|
||||
await db.execute(
|
||||
select(Product)
|
||||
.where(Product.spec_model == payload.sku, where_cond)
|
||||
.order_by(Product.created_at.desc())
|
||||
)
|
||||
).scalars().first()
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/mom-inbound")
|
||||
async def mom_inbound_webhook(
|
||||
payload: MomInboundPayload,
|
||||
request: Request,
|
||||
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""MOM 确认接收入库 / 撤回出库后回调本接口。
|
||||
|
||||
- 鉴权:Header X-API-Key 必须等于环境变量 TRACK_WEBHOOK_KEY。
|
||||
- 常规入库:用 serial_number(优先)或 sku 匹配「当前位于 virtual_warehouse」
|
||||
的产品,命中则标记"已实收"(overall_status=已入库 + 记录 task_logs)。
|
||||
- 撤回出库:MOM 把误出库的设备物理回滚到仓库 → 本接口强制执行特权回滚。
|
||||
- 公司归属:company_name 明确写着其它公司(LICA)时原样忽略;空白 / 缺失
|
||||
一律照常处理(见 _is_foreign_company 的说明)。
|
||||
- 未命中返回 200(MOM 可能操作了非 Track 生产的物料,直接忽略)。
|
||||
"""
|
||||
# ── 鉴权 ──
|
||||
if not settings.TRACK_WEBHOOK_KEY or x_api_key != settings.TRACK_WEBHOOK_KEY:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized: invalid X-API-Key")
|
||||
|
||||
# ── 公司归属:不是本实例的消息原样忽略(仍回 200,避免 MOM 当作失败而重推) ──
|
||||
# ⚠️ 键名 reason / 取值 "ignored_company" 与 LICA 实例(~/track-lica)保持一致:
|
||||
# MOM 侧不解析它,但排查时两边日志对着看,字段名不一致会白白浪费时间。
|
||||
if _is_foreign_company(payload.company_name):
|
||||
return {"ok": True, "matched": False, "reason": "ignored_company"}
|
||||
|
||||
# 归因到 MOM 侧实际扫码的人(必须在鉴权通过之后,见函数注释)
|
||||
_attribute_audit_to_mom_operator(request, payload.operator)
|
||||
|
||||
explicit_revoke = _is_outbound_revoke(payload)
|
||||
|
||||
# ── 匹配产品 ──
|
||||
# 常规入库保持严格匹配;撤回(显式标记,或带 serial 可精确定位)才放宽到已出库产品。
|
||||
# 刻意不给 sku 兜底也无条件放宽:同型号可能有多台,放宽后可能误标到别的设备。
|
||||
product = await _match_inbound_product(
|
||||
db, payload, allow_outbound=explicit_revoke or bool(payload.serial_number),
|
||||
)
|
||||
|
||||
# ── 未命中:可能是非 Track 生产的物料,直接忽略 ──
|
||||
if product is None:
|
||||
return {"ok": True, "matched": False}
|
||||
|
||||
# 隐式撤回:payload 没带任何标记,但产品此刻正处于「已出库」。
|
||||
# 对一台已发货的设备来说,任何入库回调都只能意味着「货回来了」。
|
||||
was_outbound = (
|
||||
(product.overall_status or "").strip() == "已出库"
|
||||
or (product.status or "").strip().upper() == "OUTBOUND"
|
||||
)
|
||||
is_revoke = explicit_revoke or was_outbound
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# ★ 特权通道 — MOM 的物理状态同步优先级最高,强制覆写、不受任何内部守卫约束
|
||||
#
|
||||
# 与 task_service.py 的【绝对物理终态保护】(PHYSICAL_TERMINAL_OVERALL,
|
||||
# task_service.py:115-127) 方向刻意相反:那套保护约束的是「车间内部流转
|
||||
# 不许用工序名抹掉物理终态」;而本接口是物理事实的**权威来源**——MOM 说
|
||||
# 货已回到仓库,Track 必须无条件跟随。
|
||||
#
|
||||
# ⚠️ 后续维护者:不要在此处添加 _is_physical_terminal / 状态互斥 / 仅当
|
||||
# 状态为 X 才允许覆写 之类的校验。那会让设备永远卡在「已出库」,
|
||||
# 与 MOM 账面对不上——正是本次要消灭的数据脑裂。
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
changed = False
|
||||
|
||||
# 1) 宏观状态强制覆写为「已入库」(撤回时从「已出库」拉回)
|
||||
if product.overall_status != "已入库":
|
||||
product.overall_status = "已入库"
|
||||
changed = True
|
||||
|
||||
# 2) 物理位置强制回滚到虚拟仓库池(出库回调曾把它置为 None)
|
||||
if product.current_location_id != "virtual_warehouse":
|
||||
product.current_location_id = "virtual_warehouse"
|
||||
changed = True
|
||||
|
||||
# 3) 双字段同步:lifecycle.py 约定凡改写 overall_status 必调一次。
|
||||
# (原实现在这里硬编码 product.status="ARCHIVED",绕过了约定,一并纠正)
|
||||
# ⚠️ 必须把 status 的变化也计入 changed:否则当 overall_status / location
|
||||
# 本来就已经正确时,这一处纠偏会因为 changed 保持 False 而永远不提交。
|
||||
prev_status = product.status
|
||||
sync_product_status(product)
|
||||
if product.status != prev_status:
|
||||
changed = True
|
||||
|
||||
# 4) 把最近一次出库单标记为已撤回。
|
||||
# ⚠️ 只置位、**不删行** ——「出过又撤了」本身就是要看得见的历史(建表时的
|
||||
# 取舍,见 models/product_outbound.py)。产品详情会把撤回的单据照常画
|
||||
# 出来并打「已撤回」,而不是让它凭空消失。
|
||||
# ⚠️ 只标最近一条未撤回的:一批里同一台设备理论上不该出现两条未撤回的
|
||||
# 出库单(出库后设备已不在仓库池,再出库匹配不到),但真出现时标错
|
||||
# 一条也好过把历史全标脏。
|
||||
if is_revoke:
|
||||
# 「标哪一张单」的取舍写在服务层里(见 mark_revoked 的 docstring)
|
||||
if await product_outbound_material_service.mark_revoked(db, product.id):
|
||||
changed = True
|
||||
|
||||
# ── 记录日志(优先"在库"任务,其次该产品最新任务;无任务则仅更新状态) ──
|
||||
log_task = await _pick_warehouse_log_task(db, product)
|
||||
if log_task is not None:
|
||||
if is_revoke:
|
||||
signal = payload.action or payload.event or "inbound.created(隐式)"
|
||||
remark = (
|
||||
f"MOM 撤回出库 → 强制回滚:宏观状态已入库、"
|
||||
f"位置已回到 virtual_warehouse(信号: {signal})"
|
||||
)
|
||||
action_type = "warehouse_outbound_revoked"
|
||||
else:
|
||||
time_str = payload.inbound_time.isoformat() if payload.inbound_time else "—"
|
||||
remark = f"MOM 仓储系统确认接收入库(inbound_time: {time_str})"
|
||||
action_type = "warehouse_inbound"
|
||||
|
||||
if inbound_task is not None:
|
||||
time_str = payload.inbound_time.isoformat() if payload.inbound_time else "—"
|
||||
db.add(TaskLog(
|
||||
task_id=inbound_task.id,
|
||||
task_id=log_task.id,
|
||||
operator_id=(payload.operator or "virtual_warehouse")[:64],
|
||||
action_type="warehouse_inbound",
|
||||
remark=f"MOM 仓储系统确认接收入库(inbound_time: {time_str})",
|
||||
action_type=action_type,
|
||||
remark=remark,
|
||||
))
|
||||
changed = True
|
||||
|
||||
# ── 动态生成"扫码入库"主线任务节点 + 操作日志(流转树最底部长出入库节点) ──
|
||||
if await _append_warehouse_task(db, product, "扫码入库", "通过 MOM 系统扫码入库完成"):
|
||||
# ── 动态生成主线任务节点 + 操作日志(流转树最底部长出节点) ──
|
||||
if is_revoke:
|
||||
# 撤回必须留痕:否则流转树末节点仍是「扫码出库」,而产品徽标已是
|
||||
# 「已入库」,这种可见的自相矛盾会让车间不敢信这套数据。
|
||||
#
|
||||
# ⚠️ 节点名里的「(重新入库)」不是装饰,是 [必须保留] 的契约:
|
||||
# product_service.py:166-174 的 _has_warehouse_task() 用**子串**判定
|
||||
# 仓库节点("在库" in task_name or "入库" in task_name)。而
|
||||
# 「撤回出库」四个字里只有"出库"、不含"入库",会让它判定为"无仓库任务",
|
||||
# 进而给 location==virtual_warehouse 的产品注入一个假的「已完成 /
|
||||
# 待仓库扫码」虚拟节点(product_service.py:237-242 的情况 A)——
|
||||
# 该设备明明已入库且在仓库里,树尾却显示待收货。
|
||||
# 补上「重新入库」后关键字命中,虚拟节点不再注入。
|
||||
appended = await _append_warehouse_task(
|
||||
db, product, "撤回出库(重新入库)", "MOM 撤回出库,设备已物理回滚至仓库",
|
||||
)
|
||||
else:
|
||||
appended = await _append_warehouse_task(
|
||||
db, product, "扫码入库", "通过 MOM 系统扫码入库完成",
|
||||
)
|
||||
if appended:
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
await db.commit()
|
||||
|
||||
return {"ok": True, "matched": True, "serial_number": product.serial_number}
|
||||
return {
|
||||
"ok": True,
|
||||
"matched": True,
|
||||
"serial_number": product.serial_number,
|
||||
"revoked": is_revoke,
|
||||
}
|
||||
|
||||
|
||||
async def _append_warehouse_task(
|
||||
@ -199,11 +406,26 @@ class MomOutboundPayload(BaseModel):
|
||||
sku: str | None = None # 规格型号 spec_model(serial 缺失时的兜底匹配)
|
||||
operator: str | None = None # 出库操作人(写入 task_logs.operator_id)
|
||||
outbound_time: datetime | None = None # 出库时间
|
||||
company_name: str | None = None # 目标公司(IRIS / LICA),MOM 据此分流到不同 Track 实例
|
||||
# ↓ 2026-09 新增:MOM 一直在发、此前被 Pydantic 静默丢弃。
|
||||
# 先接住是为了与 LICA 实例(~/track-lica)对同一载荷的解析结果保持一致 ——
|
||||
# 否则将来谁写了读这个字段的代码,会在 LICA 拿到值、在本实例拿到 None。
|
||||
outbound_type: str | None = None # SALES / USE / PRODUCTION
|
||||
# ↓ 2026-09 新增:单据上下文,落进 product_outbounds 供产品详情展示
|
||||
# ⚠️ 不在这里声明的字段会被 Pydantic **静默丢弃**、且不报任何错 ——
|
||||
# MOM 那边发了也等于没发。这是本功能最容易踩的坑(company_name 当初
|
||||
# 也是这么丢的)。
|
||||
outbound_no: str | None = None # MOM 出库单号(批量出库多商品共用)
|
||||
request_no: str | None = None # MOM 出库申请单号
|
||||
consumer_name: str | None = None # 领用人/客户(自由填写,非可靠标识)
|
||||
applicant_name: str | None = None # 申请人姓名(MOM 侧解析后传来)
|
||||
remark: str | None = None # 出库单备注
|
||||
|
||||
|
||||
@router.post("/mom-outbound")
|
||||
async def mom_outbound_webhook(
|
||||
payload: MomOutboundPayload,
|
||||
request: Request,
|
||||
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
@ -212,12 +434,23 @@ async def mom_outbound_webhook(
|
||||
- 鉴权:Header X-API-Key 必须等于环境变量 TRACK_WEBHOOK_KEY。
|
||||
- 用 serial_number(优先)或 sku 匹配"在仓库/已入库"的产品;
|
||||
命中则标记"已出库"(overall_status=已出库 + status=OUTBOUND + 记录 task_logs)。
|
||||
- 公司归属:company_name 明确写着其它公司(LICA)时原样忽略;空白 / 缺失
|
||||
一律照常处理(见 _is_foreign_company 的说明)。
|
||||
- 未命中返回 200(MOM 出库的可能是非 Track 生产的物料,直接忽略)。
|
||||
"""
|
||||
# ── 鉴权 ──
|
||||
if not settings.TRACK_WEBHOOK_KEY or x_api_key != settings.TRACK_WEBHOOK_KEY:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized: invalid X-API-Key")
|
||||
|
||||
# ── 公司归属:不是本实例的消息原样忽略(仍回 200,避免 MOM 当作失败而重推) ──
|
||||
# ⚠️ 键名 reason / 取值 "ignored_company" 与 LICA 实例(~/track-lica)保持一致:
|
||||
# MOM 侧不解析它,但排查时两边日志对着看,字段名不一致会白白浪费时间。
|
||||
if _is_foreign_company(payload.company_name):
|
||||
return {"ok": True, "matched": False, "reason": "ignored_company"}
|
||||
|
||||
# 归因到 MOM 侧实际出库的人(必须在鉴权通过之后,见函数注释)
|
||||
_attribute_audit_to_mom_operator(request, payload.operator)
|
||||
|
||||
# ── 按 serial_number(优先)或 sku 匹配"在仓库/已入库"的产品 ──
|
||||
product = None
|
||||
where_cond = or_(
|
||||
@ -265,23 +498,7 @@ async def mom_outbound_webhook(
|
||||
changed = True
|
||||
|
||||
# 记录出库日志(优先"在库"任务,其次该产品最新任务)
|
||||
outbound_task = (
|
||||
await db.execute(
|
||||
select(Task)
|
||||
.where(Task.product_id == product.id, Task.task_name.ilike("%在库%"))
|
||||
.order_by(Task.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalars().first()
|
||||
if outbound_task is None:
|
||||
outbound_task = (
|
||||
await db.execute(
|
||||
select(Task)
|
||||
.where(Task.product_id == product.id)
|
||||
.order_by(Task.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalars().first()
|
||||
outbound_task = await _pick_warehouse_log_task(db, product)
|
||||
|
||||
if outbound_task is not None:
|
||||
time_str = payload.outbound_time.isoformat() if payload.outbound_time else "—"
|
||||
@ -293,8 +510,34 @@ async def mom_outbound_webhook(
|
||||
))
|
||||
changed = True
|
||||
|
||||
# ── 存档 MOM 单据 ──
|
||||
# 一次出库一行(**明细级**,与人工挂载同一张表),产品详情据此回答
|
||||
# 「这台设备对应 MOM 的哪张单、领了哪些料」。
|
||||
#
|
||||
# 幂等:服务层按 (product_id, outbound_no) 查重后再写(MOM 的 notify_track
|
||||
# 走守护线程且不重试,但同一条回调仍可能因运维手工重放而重入 —— 重复写入会
|
||||
# 让产品详情出现两张一模一样的单据)。表上另有部分唯一索引兜底。
|
||||
#
|
||||
# ⚠️ outbound_no 为空则整段跳过(表里该列 NOT NULL)。这是与旧版 MOM 的
|
||||
# 向前兼容:MOM 没升级时本来就不发这些字段,此时静默不存档,其余逻辑
|
||||
# 照常 —— 不要因为缺字段就 4xx,那会让 MOM 把正常出库当故障。
|
||||
#
|
||||
# ⚠️ 明细是靠 outbound_no 去 MOM **现查**的(回调载荷里没有明细)——
|
||||
# 不查的话这台设备「领了哪些料」永远是空的,也就报不了废。
|
||||
if payload.outbound_no:
|
||||
if await product_outbound_material_service.archive_from_webhook(
|
||||
db, product, payload,
|
||||
):
|
||||
changed = True
|
||||
|
||||
# ── 动态生成"扫码出库"主线任务节点 + 操作日志(流转树最底部长出出库节点) ──
|
||||
if await _append_warehouse_task(db, product, "扫码出库", "通过 MOM 系统扫码出库完成"):
|
||||
# 单号拼进备注,不查子表也能在流转树里看出是哪张单出的库。
|
||||
# ⚠️ 只动 remark,**不要**往 task_name 里塞 —— task_name 会被直接写成
|
||||
# product.overall_status(services/task_service.py:435,782)。
|
||||
outbound_note = f"(单号 {payload.outbound_no})" if payload.outbound_no else ""
|
||||
if await _append_warehouse_task(
|
||||
db, product, "扫码出库", f"通过 MOM 系统扫码出库完成{outbound_note}",
|
||||
):
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
|
||||
@ -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
|
||||
@ -17,6 +18,7 @@ from app.api.v1.endpoints.holidays import router as holidays_router
|
||||
from app.api.v1.endpoints.webhooks import router as webhooks_router
|
||||
from app.api.v1.endpoints.external_products import router as external_products_router
|
||||
from app.api.v1.endpoints.screen import router as screen_router
|
||||
from app.api.v1.endpoints.audit import router as audit_router
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@ -27,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)
|
||||
@ -37,3 +40,4 @@ api_router.include_router(holidays_router)
|
||||
api_router.include_router(webhooks_router)
|
||||
api_router.include_router(external_products_router)
|
||||
api_router.include_router(screen_router)
|
||||
api_router.include_router(audit_router)
|
||||
|
||||
243
backend/app/core/audit_middleware.py
Normal file
243
backend/app/core/audit_middleware.py
Normal file
@ -0,0 +1,243 @@
|
||||
"""审计采集中间件
|
||||
|
||||
在响应生成后,把「谁 / 何时 / 从哪来 / 调了哪个接口 / 做了什么 / 结果如何」
|
||||
落进 audit_logs。
|
||||
|
||||
为什么用中间件自动采集,而不是在每个业务函数里手写 record_audit
|
||||
------------------------------------------------------------------
|
||||
1. 手写必然漏。新加的端点很容易忘记补审计,而审计的价值恰恰建立在「完整」上。
|
||||
现状可佐证:task_logs 全项目只有 4 处写入点,凡是不挂在任务上的动作
|
||||
(登录、导出、改产品)全都没有留痕。
|
||||
2. 中间件能拿到业务函数拿不到的事实:真实来源 IP、UA、最终状态码、
|
||||
以及与结构化日志对齐的 request_id。
|
||||
3. 业务语义(module / target)由路径推导,不如手写精确,但对「谁动了什么」
|
||||
的追责场景已经够用;关键动作后续可再调 record_audit 补 details 做增强。
|
||||
|
||||
采集范围
|
||||
--------
|
||||
- 所有写操作(POST/PUT/PATCH/DELETE)
|
||||
- 少数**读但敏感**的操作:导出、下载、打印(本项目 GET /people-history/export
|
||||
就是导出,只按方法过滤会漏掉)
|
||||
|
||||
明确不采集:GET /health*、/docs、/openapi.json —— 探针与文档的噪声没有审计价值。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.services.audit_service import record_audit
|
||||
|
||||
logger = logging.getLogger("track.audit")
|
||||
|
||||
# 写操作一律采集
|
||||
_MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
||||
|
||||
# 读操作里需要留痕的(导出/下载/打印属于「读」,但把数据带出了系统)
|
||||
_SENSITIVE_READ_KEYWORDS = frozenset({"export", "download", "print"})
|
||||
|
||||
# 核心业务模块 —— 这些前缀下的「查看详情」GET 也采集,
|
||||
# 用于回答「谁在什么时候看过哪条业务数据」,而不只是「谁改过」。
|
||||
#
|
||||
# ⚠️ 只覆盖【核心业务实体】:
|
||||
# products —— 移动端扫码查询 GET /products/scan/{sn} 是车间最高频的读操作
|
||||
# tasks —— 查看任务详情 /tasks/{id}
|
||||
# records —— 任务记录
|
||||
# notifications / orders —— 见下方 _is_bare_list 的说明
|
||||
_TRACKED_READ_PREFIXES = (
|
||||
"/api/v1/notifications",
|
||||
"/api/v1/tasks",
|
||||
"/api/v1/orders",
|
||||
"/api/v1/products",
|
||||
"/api/v1/records",
|
||||
)
|
||||
|
||||
# 永久忽略的路径前缀
|
||||
#
|
||||
# 两类内容:
|
||||
# 1. 探针与文档(/health、/docs…)—— 噪声没有审计价值
|
||||
# 2. 图片类端点(/api/v1/products/qrcode)—— 走 <img src> 加载,
|
||||
# 一次列表页渲染就会并发拉几十张图,逐条留痕会把审计日志塞满,
|
||||
# 真正有价值的操作反而被淹没。它也不含业务数据(只渲染二维码图片)。
|
||||
_IGNORED_PREFIXES = (
|
||||
"/health", "/docs", "/redoc", "/openapi.json",
|
||||
"/api/v1/products/qrcode",
|
||||
)
|
||||
|
||||
# 路径段 → 审计模块
|
||||
_PATH_MODULE: dict[str, str] = {
|
||||
"products": "product",
|
||||
"tasks": "task",
|
||||
"orders": "order",
|
||||
"records": "record",
|
||||
"print": "print",
|
||||
"materials": "material",
|
||||
"users": "user",
|
||||
"upload": "upload",
|
||||
"notifications": "notification",
|
||||
"app-version": "app",
|
||||
"analytics": "analytics",
|
||||
"dashboard": "dashboard",
|
||||
"holidays": "holiday",
|
||||
"screen": "screen",
|
||||
"webhooks": "external",
|
||||
"external": "external",
|
||||
"audit": "audit",
|
||||
"auth": "auth",
|
||||
}
|
||||
|
||||
# 路径段 → 动作(优先于按 HTTP 方法推断)
|
||||
_SEGMENT_ACTION: dict[str, str] = {
|
||||
"login": "login",
|
||||
"logout": "logout",
|
||||
"refresh": "refresh",
|
||||
"export": "export",
|
||||
"download": "export",
|
||||
"print": "print",
|
||||
"upload": "upload",
|
||||
"finalize": "finalize",
|
||||
"receive": "receive",
|
||||
"transfer": "transfer",
|
||||
"reject": "reject",
|
||||
"recall": "recall",
|
||||
"spawn": "spawn",
|
||||
"complete": "complete",
|
||||
"end": "end",
|
||||
# 消息已读:PUT /notifications/{id}/read。
|
||||
# 没有这一条时会回退到 _METHOD_ACTION(PUT → update → "修改"),
|
||||
# 把"点开一条通知"记成"修改了某样东西",语义完全走样。
|
||||
"read": "mark_read",
|
||||
}
|
||||
|
||||
_METHOD_ACTION: dict[str, str] = {
|
||||
"POST": "create",
|
||||
"PUT": "update",
|
||||
"PATCH": "update",
|
||||
"DELETE": "delete",
|
||||
"GET": "read",
|
||||
}
|
||||
|
||||
# 不可能是业务 ID 的路径段,避免把动作词误当成 target_id
|
||||
_NON_ID_SEGMENTS = frozenset(
|
||||
set(_SEGMENT_ACTION) | {"api", "v1", "me", "options", "export", "lookup", "batch"}
|
||||
)
|
||||
|
||||
|
||||
def _is_bare_list(path: str) -> bool:
|
||||
"""判断是否只是「拉整个列表」(如 GET /api/v1/tasks/)。
|
||||
|
||||
这类请求【不采集】,理由:
|
||||
· 列表接口被前端高频轮询(消息、任务列表尤其明显),逐条留痕会让
|
||||
audit_logs 迅速膨胀,真正有价值的操作反而被淹没;
|
||||
· 「查看详情」(/tasks/{id}) 才代表用户真的点开了某条业务数据。
|
||||
|
||||
判定用「去掉末尾斜杠后是否恰好等于某个受跟踪前缀」,
|
||||
比正则更直观,也天然把查询串排除在外(request.url.path 不含 ?query)。
|
||||
"""
|
||||
return path.rstrip("/") in _TRACKED_READ_PREFIXES
|
||||
|
||||
|
||||
def _derive_module_and_action(path: str, method: str) -> tuple[str, str, str | None]:
|
||||
"""由请求路径与 HTTP 方法推导 (module, action, target_id)"""
|
||||
parts = [p for p in path.split("/") if p]
|
||||
|
||||
module = "other"
|
||||
module_idx = -1
|
||||
for i, seg in enumerate(parts):
|
||||
if seg in _PATH_MODULE:
|
||||
module = _PATH_MODULE[seg]
|
||||
module_idx = i
|
||||
break
|
||||
|
||||
action = None
|
||||
for seg in reversed(parts):
|
||||
if seg in _SEGMENT_ACTION:
|
||||
action = _SEGMENT_ACTION[seg]
|
||||
break
|
||||
if action is None:
|
||||
action = _METHOD_ACTION.get(method, method.lower())
|
||||
|
||||
target_id = None
|
||||
if module_idx >= 0 and module_idx + 1 < len(parts):
|
||||
candidate = parts[module_idx + 1]
|
||||
if candidate not in _NON_ID_SEGMENTS:
|
||||
target_id = candidate
|
||||
|
||||
return module, action, target_id
|
||||
|
||||
|
||||
class AuditMiddleware(BaseHTTPMiddleware):
|
||||
"""写操作审计采集。
|
||||
|
||||
必须注册在 RequestContextMiddleware **内层**,因为它依赖后者写入
|
||||
request.state 的 request_id 才能与结构化日志对账。
|
||||
"""
|
||||
|
||||
def _should_audit(self, request: Request) -> bool:
|
||||
path = request.url.path
|
||||
if path.startswith(_IGNORED_PREFIXES):
|
||||
return False
|
||||
if request.method in _MUTATING_METHODS:
|
||||
return True
|
||||
if request.method == "GET":
|
||||
lowered = path.lower()
|
||||
if any(kw in lowered for kw in _SENSITIVE_READ_KEYWORDS):
|
||||
return True
|
||||
# 核心业务数据的「查看详情」也留痕(证明用户在真的使用系统)
|
||||
if path.startswith(_TRACKED_READ_PREFIXES):
|
||||
return not _is_bare_list(path)
|
||||
return False
|
||||
return False
|
||||
|
||||
async def dispatch(
|
||||
self, request: Request, call_next: RequestResponseEndpoint
|
||||
) -> Response:
|
||||
if not self._should_audit(request):
|
||||
return await call_next(request)
|
||||
|
||||
status_code = 500
|
||||
error_message: str | None = None
|
||||
try:
|
||||
response = await call_next(request)
|
||||
status_code = response.status_code
|
||||
return response
|
||||
except Exception as exc:
|
||||
# 异常最终由 ServerErrorMiddleware 转成 500;这里先标记,
|
||||
# 保证「失败的操作也有审计」——这正是选用独立 session 的目的
|
||||
error_message = f"{type(exc).__name__}: {exc}"[:1000]
|
||||
raise
|
||||
finally:
|
||||
await self._write(request, status_code, error_message)
|
||||
|
||||
async def _write(
|
||||
self, request: Request, status_code: int, error_message: str | None
|
||||
) -> None:
|
||||
try:
|
||||
module, action, target_id = _derive_module_and_action(
|
||||
request.url.path, request.method
|
||||
)
|
||||
client = request.client
|
||||
await record_audit(
|
||||
action=action,
|
||||
module=module,
|
||||
user_id=getattr(request.state, "audit_user", None),
|
||||
display_name=getattr(request.state, "audit_display_name", None),
|
||||
role=getattr(request.state, "audit_role", None),
|
||||
target_type=module,
|
||||
target_id=target_id,
|
||||
# 对产品而言路径里的 ID 就是身份证号,本身即人可读的标识
|
||||
target_name=target_id if module == "product" else None,
|
||||
ip_address=client.host if client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
method=request.method,
|
||||
url=request.url.path,
|
||||
status_code=status_code,
|
||||
error_message=error_message,
|
||||
request_id=getattr(request.state, "request_id", None),
|
||||
)
|
||||
except Exception:
|
||||
# record_audit 内部已兜底;这里再兜一层,确保审计绝不冒泡成 500
|
||||
logger.exception("审计采集失败(已忽略)")
|
||||
@ -16,12 +16,56 @@ class Settings(BaseSettings):
|
||||
# ---- 调试 ----
|
||||
DEBUG: bool = True
|
||||
|
||||
# ---- 应用元信息 ----
|
||||
APP_VERSION: str = "1.0.0"
|
||||
|
||||
# ---- 日志 ----
|
||||
LOG_LEVEL: str = "INFO"
|
||||
LOG_JSON: bool = True # 生产保持 True(便于采集);本地调试可设 False 换可读格式
|
||||
|
||||
# ---- 错误追踪(可选,不装 sentry-sdk 则自动跳过)----
|
||||
SENTRY_DSN: str | None = None
|
||||
SENTRY_TRACES_SAMPLE_RATE: float = 0.0
|
||||
|
||||
# ---- CORS 跨域白名单(JSON 数组字符串,直接从 .env 的 CORS_ORIGINS 读取) ----
|
||||
CORS_ORIGINS: str = '["http://localhost:1420", "tauri://localhost"]'
|
||||
|
||||
# ---- MOM 仓储系统回调 Webhook(Track 作为接收方,验签用) ----
|
||||
TRACK_WEBHOOK_KEY: str | None = None # MOM 回调 POST 时 Header X-API-Key 须等于此值
|
||||
|
||||
# ---- MOM 内部接口(Track 作为**调用方**,发起生产报废) ----
|
||||
# ⚠️ 这是 Track **唯一**一处主动写 MOM 的通道。
|
||||
# 读数据一律继续走直连 MOM 库(app/core/mom_database.py)—— 不要因为有了
|
||||
# 这个客户端就把「读」也搬过来:MOM 的查询接口要 JWT + permission_required,
|
||||
# 且对非特权账号按 consumer_name 做行级隔离,服务账号只能拿到自己名下的数据。
|
||||
# 而「写」必须走接口:跨库直写会绕过 MOM 的全部业务校验、权限与审批。
|
||||
MOM_INTERNAL_API_URL: str = "http://inventory_api:8000"
|
||||
# 请求头 X-API-Key 的值,须与 MOM 侧 config.MOM_INTERNAL_API_KEY 一致。
|
||||
# ⚠️ 未配置 → 报废提交直接 503(Fail-Closed),**不静默降级**:报废是写操作,
|
||||
# 静默失败会让用户以为报上去了,实际 MOM 里什么都没有。
|
||||
MOM_INTERNAL_API_KEY: str | None = None
|
||||
|
||||
# ---- 组织隔离 ----
|
||||
# 同一套代码部署给不同部门时,只需改这两个值(+ compose 里的项目名/容器名/端口)。
|
||||
# 全仓库的部门过滤点只有四处:登录、人员列表、物料选择器、MOM 出库单查询。
|
||||
ORG_DEPARTMENT: str = "IRIS" # MOM sys_user.department 的取值
|
||||
MATERIAL_CATEGORY_PREFIX: str = "IRIS/" # MOM material_base.category 的部门前缀
|
||||
|
||||
# ---- MOM 出库单的跨部门领用人例外 ----
|
||||
# 出库单的公司隔离靠 material_base.category 前缀(见上)。
|
||||
# 但这几个领用人(MOM trans_outbound.consumer_name,**纯姓名**,不带账号后缀)
|
||||
# 经手的单据,即使物料分类不属于本部门,本实例也要能看见 —— 他们跨两个部门
|
||||
# 领料,只按物料前缀过滤会把他们的单整批漏掉。
|
||||
# ⚠️ 这是**放行**条件(SQL 里是 OR),与界面筛选(AND,只收窄)方向相反,
|
||||
# 两者的集合运算必须分开写,混在一起就变成范围放大。
|
||||
# ⚠️ 留空即关闭该例外,退化成纯前缀过滤。
|
||||
EXTRA_VISIBLE_CONSUMERS: str = "依锐思,石利LICA"
|
||||
|
||||
@property
|
||||
def EXTRA_VISIBLE_CONSUMERS_LIST(self) -> list[str]:
|
||||
"""将逗号分隔的字符串解析为姓名 list(去空白、丢空项)"""
|
||||
return [n.strip() for n in self.EXTRA_VISIBLE_CONSUMERS.split(",") if n.strip()]
|
||||
|
||||
@property
|
||||
def CORS_ORIGINS_LIST(self) -> list[str]:
|
||||
"""将 JSON 字符串解析为 Python list,供 CORSMiddleware 使用"""
|
||||
|
||||
35
backend/app/core/deps.py
Normal file
35
backend/app/core/deps.py
Normal file
@ -0,0 +1,35 @@
|
||||
"""通用 FastAPI 依赖"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
|
||||
from app.core.roles import ADMIN_ROLES
|
||||
from app.services.auth_service import get_current_user
|
||||
|
||||
|
||||
def require_roles(*roles: str):
|
||||
"""生成「限定角色」依赖,避免同一个内联判断被复制到每个端点。
|
||||
|
||||
用法::
|
||||
|
||||
@router.get("/x")
|
||||
async def x(current_user: dict = Depends(require_admin)):
|
||||
...
|
||||
|
||||
失败一律 403 且不透露允许的角色集合(避免给探测者提供线索)。
|
||||
"""
|
||||
allowed = frozenset(roles)
|
||||
|
||||
async def _guard(current_user: dict = Depends(get_current_user)) -> dict:
|
||||
if (current_user or {}).get("role") not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="当前角色无权访问该接口",
|
||||
)
|
||||
return current_user
|
||||
|
||||
return _guard
|
||||
|
||||
|
||||
# 审计日志等高权限接口复用同一实例
|
||||
require_admin = require_roles(*ADMIN_ROLES)
|
||||
93
backend/app/core/health.py
Normal file
93
backend/app/core/health.py
Normal file
@ -0,0 +1,93 @@
|
||||
"""健康检查 — 存活探针与就绪探针分离
|
||||
|
||||
为什么必须拆开:
|
||||
- 存活探针(liveness)只回答「进程还活着吗」,绝不能探测外部依赖。
|
||||
否则数据库抖一下,编排系统会判定进程已死并反复重启容器,
|
||||
把一次依赖故障放大成全站雪崩。
|
||||
- 就绪探针(readiness)回答「现在能对外服务吗」。依赖不可用时返回 503,
|
||||
由负载均衡把该实例摘掉,依赖恢复后自动回来。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import anyio
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.core.mom_database import mom_engine
|
||||
|
||||
logger = logging.getLogger("track.health")
|
||||
|
||||
router = APIRouter(tags=["健康检查"])
|
||||
|
||||
|
||||
async def _probe_primary_db() -> bool:
|
||||
"""主库探活 — 业务强依赖,失败即不就绪"""
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
await session.execute(text("SELECT 1"))
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("主库探活失败")
|
||||
return False
|
||||
|
||||
|
||||
def _probe_mom_db_sync() -> bool:
|
||||
try:
|
||||
with mom_engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("MOM 库探活失败")
|
||||
return False
|
||||
|
||||
|
||||
async def _probe_mom_db() -> bool:
|
||||
# MOM 用的是同步引擎,放线程池执行,避免阻塞事件循环
|
||||
return await anyio.to_thread.run_sync(_probe_mom_db_sync)
|
||||
|
||||
|
||||
async def _collect() -> tuple[bool, dict[str, str]]:
|
||||
primary_ok = await _probe_primary_db()
|
||||
mom_ok = await _probe_mom_db()
|
||||
checks = {
|
||||
"database": "ok" if primary_ok else "fail",
|
||||
# MOM 是外部只读依赖:挂掉时登录/选料降级,但扫码、流转、看板仍可用。
|
||||
# 因此只标记 degraded、不摘流量 —— 否则 MOM 一抖就让在产车间全线停摆。
|
||||
"mom_database": "ok" if mom_ok else "degraded",
|
||||
}
|
||||
return primary_ok, checks
|
||||
|
||||
|
||||
@router.get("/health/live", include_in_schema=False)
|
||||
async def liveness() -> dict:
|
||||
"""存活探针:不触碰任何依赖,恒定快速返回"""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/health/ready", include_in_schema=False)
|
||||
async def readiness() -> JSONResponse:
|
||||
"""就绪探针:主库不可用时返回 503,让负载均衡摘流量"""
|
||||
ready, checks = await _collect()
|
||||
return JSONResponse(
|
||||
{"status": "ready" if ready else "not_ready", "checks": checks},
|
||||
status_code=200 if ready else 503,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/health", include_in_schema=False)
|
||||
async def health() -> JSONResponse:
|
||||
"""兼容旧监控脚本:语义等同就绪探针,并附带版本号"""
|
||||
ready, checks = await _collect()
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "ok" if ready else "unavailable",
|
||||
"version": settings.APP_VERSION,
|
||||
"checks": checks,
|
||||
},
|
||||
status_code=200 if ready else 503,
|
||||
)
|
||||
89
backend/app/core/logging.py
Normal file
89
backend/app/core/logging.py
Normal file
@ -0,0 +1,89 @@
|
||||
"""结构化日志 — 单行 JSON 输出 + 请求上下文注入
|
||||
|
||||
设计要点:
|
||||
1. 零第三方依赖,只用 stdlib(logging + json + contextvars)。
|
||||
2. 业务代码通过 `extra={"extra_fields": {...}}` 附加结构化字段,
|
||||
不要把可检索的字段拼进 msg 字符串 —— 拼进去就只能靠正则捞了。
|
||||
3. request_id / user 走 contextvar。contextvar 在 asyncio 下按任务隔离,
|
||||
并发请求之间不会串号;由 RequestContextMiddleware 与 get_current_user 写入。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from contextvars import ContextVar
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# 请求级上下文
|
||||
request_id_var: ContextVar[str | None] = ContextVar("request_id", default=None)
|
||||
user_var: ContextVar[str | None] = ContextVar("user", default=None)
|
||||
|
||||
|
||||
class _ContextFilter(logging.Filter):
|
||||
"""把 contextvar 注入每条 record,使 JSON 自带 request_id / user"""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
record.request_id = request_id_var.get()
|
||||
record.user = user_var.get()
|
||||
return True
|
||||
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
"""单行 JSON — 便于 Loki / ELK / CloudWatch 直接解析,无需正则"""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
payload: dict = {
|
||||
"ts": datetime.fromtimestamp(record.created, timezone.utc).isoformat(),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"msg": record.getMessage(),
|
||||
}
|
||||
if getattr(record, "request_id", None):
|
||||
payload["request_id"] = record.request_id
|
||||
if getattr(record, "user", None):
|
||||
payload["user"] = record.user
|
||||
payload.update(getattr(record, "extra_fields", None) or {})
|
||||
if record.exc_info:
|
||||
payload["exc"] = self.formatException(record.exc_info)
|
||||
return json.dumps(payload, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
class TextFormatter(logging.Formatter):
|
||||
"""本地开发可读格式(LOG_JSON=false 时启用)"""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
line = (
|
||||
f"{self.formatTime(record, '%H:%M:%S')} "
|
||||
f"{record.levelname:<5} {record.name} - {record.getMessage()}"
|
||||
)
|
||||
extras = getattr(record, "extra_fields", None)
|
||||
if extras:
|
||||
line += " | " + " ".join(f"{k}={v}" for k, v in extras.items())
|
||||
if record.exc_info:
|
||||
line += "\n" + self.formatException(record.exc_info)
|
||||
return line
|
||||
|
||||
|
||||
def setup_logging(level: str = "INFO", json_output: bool = True) -> None:
|
||||
"""配置根 logger。必须在应用启动前调用一次。"""
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(JsonFormatter() if json_output else TextFormatter())
|
||||
handler.addFilter(_ContextFilter())
|
||||
|
||||
root = logging.getLogger()
|
||||
# 清空既有 handler:uvicorn --reload / 多 worker 下模块可能被重复导入,
|
||||
# 不清会看到每条日志打印 N 遍
|
||||
root.handlers.clear()
|
||||
root.addHandler(handler)
|
||||
root.setLevel(level.upper())
|
||||
|
||||
# uvicorn 自带 handler 会绕过上面的 formatter,必须清掉并让它向根传播
|
||||
for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
|
||||
lg = logging.getLogger(name)
|
||||
lg.handlers.clear()
|
||||
lg.propagate = True
|
||||
|
||||
# 访问日志统一由 RequestContextMiddleware 输出(含耗时 / 用户 / request_id),
|
||||
# 故关闭 uvicorn 自带的访问日志,避免重复
|
||||
logging.getLogger("uvicorn.access").disabled = True
|
||||
103
backend/app/core/middleware.py
Normal file
103
backend/app/core/middleware.py
Normal file
@ -0,0 +1,103 @@
|
||||
"""请求上下文中间件 — request_id 生成/透传 + 结构化访问日志"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.core.logging import request_id_var, user_var
|
||||
from app.services.audit_service import touch_daily_seen
|
||||
|
||||
access_log = logging.getLogger("track.access")
|
||||
|
||||
# 探针被高频轮询,降级为 DEBUG 避免把有价值的信息淹掉
|
||||
_QUIET_PATHS = frozenset({"/health", "/health/live", "/health/ready"})
|
||||
|
||||
|
||||
class RequestContextMiddleware(BaseHTTPMiddleware):
|
||||
"""为每个请求建立可追踪上下文。
|
||||
|
||||
- request_id:优先沿用上游网关传来的 X-Request-ID,实现全链路追踪;
|
||||
没有就生成一个。响应头回写该 ID,前端报错时可直接带上,
|
||||
运维拿 ID 就能在日志里精确定位到这一次请求。
|
||||
- 访问日志:method / path / status / duration_ms / client / user。
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex
|
||||
# 同时写入 request.state:它由 ASGI scope 承载,作用域比 contextvar 更长。
|
||||
# FastAPI 把 Exception 处理器交给 ServerErrorMiddleware(位于本中间件外层),
|
||||
# 异常传播到那里时 contextvar 已在 finally 中被重置,只有 state 还留着 ID。
|
||||
request.state.request_id = request_id
|
||||
rid_token = request_id_var.set(request_id)
|
||||
user_token = user_var.set(None)
|
||||
started = time.perf_counter()
|
||||
logged = False
|
||||
status_code = 500
|
||||
try:
|
||||
response = await call_next(request)
|
||||
status_code = response.status_code
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
self._log_access(request, status_code, started)
|
||||
logged = True
|
||||
await self._touch_activity(request)
|
||||
return response
|
||||
finally:
|
||||
# 异常路径也要留下访问记录,否则接口 500 时日志里反而没有痕迹
|
||||
if not logged:
|
||||
self._log_access(request, status_code, started)
|
||||
await self._touch_activity(request)
|
||||
request_id_var.reset(rid_token)
|
||||
user_var.reset(user_token)
|
||||
|
||||
async def _touch_activity(self, request: Request) -> None:
|
||||
"""记录「该用户今天活动过」,供日活报表算上线/下线时间。
|
||||
|
||||
为什么挂在这一层:本中间件是最外层,能覆盖**所有**请求 ——
|
||||
包括不被审计的普通 GET。而审计中间件只记写操作,当天只翻看、
|
||||
没做写操作的人会被日活完全漏掉。
|
||||
|
||||
user 同样只能从 request.state 取:本中间件在独立 task 中执行,
|
||||
路由内写的 contextvar 不会回流(详见 _log_access 的说明)。
|
||||
未认证请求取不到 user,自然跳过。
|
||||
"""
|
||||
await touch_daily_seen(getattr(request.state, "audit_user", None))
|
||||
|
||||
def _log_access(self, request: Request, status_code: int, started: float) -> None:
|
||||
path = request.url.path
|
||||
duration_ms = round((time.perf_counter() - started) * 1000, 1)
|
||||
|
||||
# user 必须从 request.state 取:本中间件在独立 task 中执行,路由内
|
||||
# 写入的 contextvar 不会回流到这里(详见 get_current_user 的说明)。
|
||||
user = getattr(request.state, "audit_user", None) or user_var.get()
|
||||
|
||||
if status_code >= 500:
|
||||
level = logging.ERROR
|
||||
elif status_code >= 400:
|
||||
level = logging.WARNING
|
||||
elif path in _QUIET_PATHS:
|
||||
level = logging.DEBUG
|
||||
else:
|
||||
level = logging.INFO
|
||||
|
||||
access_log.log(
|
||||
level,
|
||||
"%s %s -> %s (%.1fms)",
|
||||
request.method,
|
||||
path,
|
||||
status_code,
|
||||
duration_ms,
|
||||
extra={
|
||||
"extra_fields": {
|
||||
"method": request.method,
|
||||
"path": path,
|
||||
"status": status_code,
|
||||
"duration_ms": duration_ms,
|
||||
"client": request.client.host if request.client else None,
|
||||
"user": user,
|
||||
}
|
||||
},
|
||||
)
|
||||
31
backend/app/core/roles.py
Normal file
31
backend/app/core/roles.py
Normal file
@ -0,0 +1,31 @@
|
||||
"""角色定义与管理员判定 —— 单一事实来源
|
||||
|
||||
背景:角色字符串此前散落在至少三处 —— task_service.ADMIN_ROLES、
|
||||
products.py 的内联判断、以及前端 constants/task.ts。同一份规则抄多份的后果
|
||||
已经发生过:前端 constants/task.ts:233 的注释记录了一次「移动端只判了
|
||||
SUPER_ADMIN、漏了 SUPERVISOR,导致主管被误挡」的事故。
|
||||
|
||||
本模块把**角色常量与管理员判定**先收敛到一处,供后端统一引用。
|
||||
完整的「角色 × 权限点」可配置矩阵是后续工作;但任何推进都应从这里出发,
|
||||
不要再新增第四份副本。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
SUPER_ADMIN = "SUPER_ADMIN"
|
||||
SUPERVISOR = "SUPERVISOR"
|
||||
# 注意:MOM 登录返回的默认角色是小写 operator(见 auth_service.login)
|
||||
OPERATOR = "OPERATOR"
|
||||
|
||||
# 管理员角色:可执行收口、审计查看等高权限动作
|
||||
ADMIN_ROLES: frozenset[str] = frozenset({SUPER_ADMIN, SUPERVISOR})
|
||||
|
||||
ROLE_LABELS: dict[str, str] = {
|
||||
SUPER_ADMIN: "超级管理员",
|
||||
SUPERVISOR: "主管",
|
||||
OPERATOR: "操作员",
|
||||
}
|
||||
|
||||
|
||||
def is_admin(role: str | None) -> bool:
|
||||
"""role 为 None / 未知值一律视为无权限(fail-closed,不做兜底放行)"""
|
||||
return role in ADMIN_ROLES
|
||||
@ -39,6 +39,28 @@ def decode_token(token: str) -> dict:
|
||||
return jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||
|
||||
|
||||
def peek_token_identity(token: str) -> dict | None:
|
||||
"""读出令牌里的用户身份 —— **仅供审计标注,绝不可用于授权**。
|
||||
|
||||
与 decode_token 的唯一区别:**关闭过期校验**。
|
||||
|
||||
为什么需要它:刷新令牌接口正是"access token 过期了才来"的场景,
|
||||
请求里不带 Authorization 头,JWT 依赖根本不执行,审计只能记成
|
||||
「未认证」—— 而"谁在什么时候尝试刷新"恰恰是该留痕的信息。
|
||||
签名校验照常进行,伪造的令牌解不出任何东西。
|
||||
|
||||
⚠️ 返回值只允许写进 request.state 的审计字段;
|
||||
任何鉴权判断一律走 get_current_user,不要用本函数。
|
||||
"""
|
||||
try:
|
||||
return jwt.decode(
|
||||
token, settings.SECRET_KEY, algorithms=[ALGORITHM],
|
||||
options={"verify_exp": False},
|
||||
)
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""验证明文密码 vs 哈希密码"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
@ -1,22 +1,64 @@
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from app.core.config import settings
|
||||
from app.core.audit_middleware import AuditMiddleware
|
||||
from app.core.health import router as health_router
|
||||
from app.core.logging import request_id_var, setup_logging
|
||||
from app.core.middleware import RequestContextMiddleware
|
||||
from app.api.v1.router import api_router
|
||||
|
||||
# 日志必须在任何模块开始产日志之前配置好,故放模块顶层而非 lifespan 内
|
||||
setup_logging(level=settings.LOG_LEVEL, json_output=settings.LOG_JSON)
|
||||
|
||||
logger = logging.getLogger("track.main")
|
||||
|
||||
|
||||
def _init_error_tracking() -> None:
|
||||
"""可选错误追踪:未配置 DSN,或未安装 sentry-sdk 时静默跳过"""
|
||||
if not settings.SENTRY_DSN:
|
||||
return
|
||||
try:
|
||||
import sentry_sdk
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"已配置 SENTRY_DSN 但未安装 sentry-sdk,错误追踪未启用;"
|
||||
"需要时执行 pip install sentry-sdk"
|
||||
)
|
||||
return
|
||||
sentry_sdk.init(
|
||||
dsn=settings.SENTRY_DSN,
|
||||
traces_sample_rate=settings.SENTRY_TRACES_SAMPLE_RATE,
|
||||
environment="production" if not settings.DEBUG else "development",
|
||||
release=settings.APP_VERSION,
|
||||
)
|
||||
logger.info("错误追踪已启用 (Sentry)")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期:启动时初始化连接,关闭时释放资源"""
|
||||
# 启动:验证数据库连接等
|
||||
_init_error_tracking()
|
||||
logger.info(
|
||||
"服务启动",
|
||||
extra={
|
||||
"extra_fields": {
|
||||
"version": settings.APP_VERSION,
|
||||
"debug": settings.DEBUG,
|
||||
"cors_origins": settings.CORS_ORIGINS_LIST,
|
||||
}
|
||||
},
|
||||
)
|
||||
yield
|
||||
# 关闭:清理资源
|
||||
logger.info("服务关闭")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Track Production API",
|
||||
description="工厂生产流转管理系统 API",
|
||||
version="0.1.0",
|
||||
version=settings.APP_VERSION,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
@ -27,12 +69,45 @@ app.add_middleware(
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
# 暴露给浏览器 JS 读取:前端报错时才能把 request_id 一起带上便于对账
|
||||
expose_headers=["X-Request-ID"],
|
||||
)
|
||||
|
||||
# Starlette 的 add_middleware 是「后添加者在外层」。执行顺序(由外到内):
|
||||
# RequestContextMiddleware -> AuditMiddleware -> CORS -> 路由
|
||||
# AuditMiddleware 必须在 RequestContext 内层,才能读到后者写入 request.state
|
||||
# 的 request_id,从而把审计记录与结构化日志对上。
|
||||
app.add_middleware(AuditMiddleware)
|
||||
app.add_middleware(RequestContextMiddleware)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""兜底异常处理。
|
||||
|
||||
完整堆栈只进日志;响应体仅返回 request_id —— 既不把内部实现泄露给客户端,
|
||||
又让用户报障时能凭这个 ID 在日志里精确定位到本次失败。
|
||||
"""
|
||||
# 优先取 request.state(见 RequestContextMiddleware 的说明):
|
||||
# 本处理器由 ServerErrorMiddleware 调用,此时 contextvar 已被重置
|
||||
request_id = getattr(request.state, "request_id", None) or request_id_var.get()
|
||||
logger.exception(
|
||||
"未处理异常: %s %s",
|
||||
request.method,
|
||||
request.url.path,
|
||||
extra={"extra_fields": {"method": request.method, "path": request.url.path}},
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "服务器内部错误", "request_id": request_id},
|
||||
# 该响应由 ServerErrorMiddleware(位于 RequestContextMiddleware 外层)
|
||||
# 生成,中间件没机会再往响应头写 X-Request-ID,故在此显式补上,
|
||||
# 保证报障时前端从响应头就能拿到可对账的 ID。
|
||||
headers={"X-Request-ID": request_id} if request_id else None,
|
||||
)
|
||||
|
||||
|
||||
# ---- 注册路由 ----
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "ok", "version": "0.1.0"}
|
||||
# 健康检查挂在根路径(/health*),运维探针不经过 /api/v1
|
||||
app.include_router(health_router)
|
||||
|
||||
@ -2,21 +2,33 @@
|
||||
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
|
||||
from app.models.holiday import Holiday
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.user_daily_seen import UserDailySeen
|
||||
__all__ = [
|
||||
"Base",
|
||||
"ProductionOrder",
|
||||
"Product",
|
||||
"ProductOutbound",
|
||||
"ProductOutboundMaterial",
|
||||
"ProductScrap",
|
||||
"Task",
|
||||
"TaskRecord",
|
||||
"TaskOutboundMaterial",
|
||||
"TaskLog",
|
||||
"Notification",
|
||||
"AppVersion",
|
||||
"ProductMessage",
|
||||
"Holiday",
|
||||
"AuditLog",
|
||||
"UserDailySeen",
|
||||
]
|
||||
|
||||
83
backend/app/models/audit_log.py
Normal file
83
backend/app/models/audit_log.py
Normal file
@ -0,0 +1,83 @@
|
||||
"""操作审计日志模型
|
||||
|
||||
设计参考 MOM(KCGL) 的 audit_logs,但按 Track 的技术栈与诉求做了取舍:
|
||||
|
||||
- 主键用 UUID(与库内其它表一致),而非 MOM 的自增 int。
|
||||
- 增加 request_id:与 core/logging.py 的结构化日志打通 —— 凭一个 ID 就能把
|
||||
「接口访问日志」和「审计记录」对上,排障时不用再猜。MOM 无此字段。
|
||||
- 保留 module / action / target_* 的业务语义,使审计能按业务维度检索,
|
||||
而不是只能按时间翻。
|
||||
- 绝不记录请求体:登录等接口 body 含明文密码,一旦落库就成了长期泄露面。
|
||||
|
||||
与既有 task_logs 的分工:task_logs 是「任务流转轨迹」(有 task_id 非空约束,
|
||||
只能挂在任务上,供流转树渲染);本表是「操作审计」,覆盖登录、导出、
|
||||
产品增删改、权限变更等与单个任务无关的动作,且额外记录来源 IP / UA / 耗时结果。
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
from app.core.time_utils import get_beijing_time
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
|
||||
)
|
||||
|
||||
# ---- 操作人(逻辑外键 → MOM sys_user,仅存账号,无物理约束)----
|
||||
user_id: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True, index=True, comment="操作人账号(逻辑外键→MOM)",
|
||||
)
|
||||
display_name: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True, comment="操作人显示名",
|
||||
)
|
||||
role: Mapped[str | None] = mapped_column(
|
||||
String(50), nullable=True, comment="操作时角色快照",
|
||||
)
|
||||
|
||||
# ---- 业务语义 ----
|
||||
action: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, index=True, comment="动作: create/update/delete/export/login/...",
|
||||
)
|
||||
module: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, index=True, comment="业务模块: product/task/order/auth/print/...",
|
||||
)
|
||||
target_type: Mapped[str | None] = mapped_column(
|
||||
String(50), nullable=True, comment="目标类型(表名或实体名)",
|
||||
)
|
||||
target_id: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True, index=True, comment="目标ID",
|
||||
)
|
||||
target_name: Mapped[str | None] = mapped_column(
|
||||
String(200), nullable=True, comment="目标显示名(如产品身份证/工单号)",
|
||||
)
|
||||
details: Mapped[dict | None] = mapped_column(
|
||||
JSONB, nullable=True, comment="变更详情 {old:{}, new:{}};禁止写入密码等敏感字段",
|
||||
)
|
||||
|
||||
# ---- 请求上下文(由中间件自动填充)----
|
||||
ip_address: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="来源IP")
|
||||
user_agent: Mapped[str | None] = mapped_column(String(500), nullable=True, comment="浏览器UA")
|
||||
method: Mapped[str | None] = mapped_column(String(10), nullable=True, comment="HTTP方法")
|
||||
url: Mapped[str | None] = mapped_column(String(500), nullable=True, comment="请求路径")
|
||||
status_code: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="响应状态码")
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True, comment="错误信息(如有)")
|
||||
|
||||
# ---- 与结构化日志对账用 ----
|
||||
request_id: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True, index=True, comment="关联 core/logging 的 request_id",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=get_beijing_time, index=True, comment="操作时间",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AuditLog {self.action} {self.module} by {self.user_id}>"
|
||||
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}>"
|
||||
157
backend/app/models/product_outbound_material.py
Normal file
157
backend/app/models/product_outbound_material.py
Normal file
@ -0,0 +1,157 @@
|
||||
"""设备出库明细 — 一台设备对应 MOM 的**每一条**出库明细
|
||||
|
||||
一行 = 设备上的一条 MOM 出库明细(`trans_outbound` 的一行)。
|
||||
|
||||
═══ 为什么是这张表(合并了原先的两张)═══
|
||||
本表合并了原来的 `product_outbounds`(产品 ↔ 出库**单**,单据级)与
|
||||
`task_outbound_materials`(任务 ↔ 出库**明细**,明细级)。
|
||||
|
||||
它们本来就是**同一件事**——「这台设备对应 MOM 的哪些出库单、领了哪些料」——
|
||||
却因为粒度不同被拆成两张表、界面上显示成两张卡,用户要面对两个入口、
|
||||
两个删除按钮,还要猜「我刚才在那边挂的怎么这边看不见」。那是设计失误。
|
||||
|
||||
统一到**明细级**,因为只有明细级带 `mom_line_id`(MOM `trans_outbound.id`),
|
||||
而报废必须靠它定位到具体哪一条出库明细。单据级的信息(申请单号、备注、撤回)
|
||||
作为**冗余列**落在每一条明细上 —— 同单内必然一致,多存几份换取单表自包含。
|
||||
|
||||
═══ 两种来源(source)═══
|
||||
· `manual` —— 人在界面上挂的(网页端/移动端选 MOM 出库单)
|
||||
· `webhook` —— MOM 出库回调自动存档(按 SN 匹配到设备后写入)
|
||||
两者语义不同、删除规则也不同(本表的 `manual` 可删;`webhook` 是系统事实,
|
||||
要撤得去 MOM 撤回,由回调置 `is_revoked`),所以保留 `source` 区分。
|
||||
|
||||
═══ task_id 为什么可空 ═══
|
||||
人工挂载时要选「挂到哪条任务」(一线按任务领料),webhook 存档则**不知道任务**。
|
||||
但任务只是**溯源信息**,不再是组织维度 —— 展示、报废、删除一律按**设备**维度走。
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean, DateTime, ForeignKey, Index, Numeric, String, Text, text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
from app.core.time_utils import get_beijing_time
|
||||
|
||||
|
||||
class ProductOutboundMaterial(Base):
|
||||
__tablename__ = "product_outbound_materials"
|
||||
__table_args__ = (
|
||||
# 同一条 MOM 出库明细不能在一台设备上出现两次(重复提交、前端重放、并发点击、
|
||||
# webhook 重推)。**部分索引**:mom_line_id 为空的行(MOM 查不到明细的存档)
|
||||
# 不参与 —— NULL 之间不相等,带上它等于给这类行开了后门。
|
||||
Index("uq_pom_product_line", "product_id", "mom_line_id",
|
||||
unique=True, postgresql_where=text("mom_line_id IS NOT NULL")),
|
||||
# 没有明细行的存档:一张单在一台设备上只留一行
|
||||
Index("uq_pom_product_no_noline", "product_id", "outbound_no",
|
||||
unique=True, postgresql_where=text("mom_line_id IS NULL")),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
|
||||
# ---- 物理外键:设备(组织维度,展示/报废/删除都按它走) ----
|
||||
product_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("products.id"),
|
||||
nullable=False, index=True, comment="所属设备ID",
|
||||
)
|
||||
# 冗余序列号:按 SN 对账/排查时不必 join products
|
||||
serial_number: Mapped[str | None] = mapped_column(
|
||||
String(16), nullable=True, index=True, comment="设备序列号(冗余,便于按SN对账)",
|
||||
)
|
||||
# ---- 物理外键:任务(可空,仅溯源用,不参与展示与权限) ----
|
||||
task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id"),
|
||||
nullable=True, index=True,
|
||||
comment="人工挂载时选的任务(可空,仅溯源用,不参与展示/报废/删除)",
|
||||
)
|
||||
|
||||
# ---- 跨库逻辑外键(MOM 库 trans_outbound.id,无物理约束) ----
|
||||
mom_line_id: Mapped[int | None] = mapped_column(
|
||||
nullable=True, index=True,
|
||||
comment="MOM 出库明细行ID(逻辑外键→MOM trans_outbound.id)。为空=MOM 查不到明细的单据存档",
|
||||
)
|
||||
outbound_no: Mapped[str] = mapped_column(
|
||||
String(100), nullable=False, index=True,
|
||||
comment="MOM 出库单号(批量出库多商品共用,故本表按明细行成行)",
|
||||
)
|
||||
|
||||
# ---- 单据级信息(同单内一致,冗余在每条明细上换取单表自包含) ----
|
||||
request_no: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True, comment="MOM 出库申请单号",
|
||||
)
|
||||
applicant_name: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True, comment="申请人姓名(MOM 侧解析后传来,不做 ID 反查)",
|
||||
)
|
||||
remark: Mapped[str | None] = mapped_column(
|
||||
Text, nullable=True, comment="出库单备注",
|
||||
)
|
||||
|
||||
# ---- 明细级快照(挂载/回调时从 MOM 拉取,之后 Track 自包含) ----
|
||||
sku: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True, comment="物料SKU(MOM trans_outbound.sku 快照)",
|
||||
)
|
||||
material_name: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, comment="物料名称(经 COALESCE 三表 JOIN 解析后快照)",
|
||||
)
|
||||
spec_model: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, comment="规格型号快照",
|
||||
)
|
||||
quantity: Mapped[Decimal | None] = mapped_column(
|
||||
Numeric(19, 4), nullable=True,
|
||||
comment="出库数量(出库单原值,**不是**本设备用量)",
|
||||
)
|
||||
unit_price: Mapped[Decimal | None] = mapped_column(
|
||||
Numeric(19, 2), nullable=True, comment="出库单价",
|
||||
)
|
||||
outbound_type: Mapped[str | None] = mapped_column(
|
||||
String(50), nullable=True,
|
||||
comment="出库类型 SALES/USE/PRODUCTION/LOSS/REPAIR(MOM 码表未冻结,只存不判)",
|
||||
)
|
||||
consumer_name: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True, comment="领用人/客户(MOM 侧自由填写,非可靠标识)",
|
||||
)
|
||||
operator_name: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True, comment="MOM 侧操作员",
|
||||
)
|
||||
warehouse_location: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True, comment="出库库位快照",
|
||||
)
|
||||
outbound_time: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
comment="MOM 记录的出库时间(写入时已按 +08:00 补全时区)",
|
||||
)
|
||||
|
||||
# ---- 来源 ----
|
||||
# manual —— 人在界面上挂的(网页端/移动端选 MOM 出库单)→ **可删**
|
||||
# webhook —— MOM 出库回调自动存档 → 系统事实,要撤得去 MOM 撤回,**不可删**
|
||||
source: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="manual", server_default="manual",
|
||||
comment="来源: manual(人工挂载,可删) | webhook(MOM回调自动存档,不可删)",
|
||||
)
|
||||
|
||||
# ---- 撤回(只置位不删行)----
|
||||
is_revoked: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default="false",
|
||||
comment="该次出库是否已被 MOM 撤回",
|
||||
)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, comment="撤回时间",
|
||||
)
|
||||
|
||||
added_by: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True, comment="挂载人ID(逻辑外键→MOM sys_user)",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=get_beijing_time, comment="本行写入时间",
|
||||
)
|
||||
|
||||
# ---- 关系 ----
|
||||
product: Mapped["Product"] = relationship("Product", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (f"<ProductOutboundMaterial {self.outbound_no} "
|
||||
f"line={self.mom_line_id} sku={self.sku}>")
|
||||
136
backend/app/models/product_scrap.py
Normal file
136
backend/app/models/product_scrap.py
Normal file
@ -0,0 +1,136 @@
|
||||
"""生产报废记录 — Track 发起的「领用物料在生产中报废」
|
||||
|
||||
回答两个问题:**这台设备的哪条料报废了**、**在 MOM 里对应哪张报废单**。
|
||||
|
||||
与 `product_outbounds`(产品 ↔ MOM 出库单)的分工:
|
||||
· 那张表回答「这台设备对应 MOM 的哪张出库单」(货从哪来);
|
||||
· 本表回答「这台设备上的哪条料废了、废了多少、MOM 怎么处理的」(货怎么没的)。
|
||||
|
||||
═══ 为什么挂在**产品**维度,而不是任务维度 ═══
|
||||
料是领给**这台设备**的,不是领给某个人的。一台设备会经历多个任务、多个人的手
|
||||
(生产领料 → 装配 → 测试),测试时摔坏了外壳——那条外壳是生产的人领的,
|
||||
挂在生产任务下。若本表挂任务维度,测试在自己的任务里根本看不到它,
|
||||
「谁发现谁报」就无从落地。
|
||||
|
||||
所以:**可见范围跟设备走**(打开这台设备就能看到它全部的料),
|
||||
**责任归属跟实际发生走**(谁发现谁报,applicant 记在 MOM 报废单上)。
|
||||
跨设备的防护不靠"隐藏",靠写入前校验 `mom_line_id` 确实挂在这台设备上(见 service)。
|
||||
|
||||
═══ 为什么不存金额 ═══
|
||||
报废金额由 MOM 在执行报废时算(`trans_scrap.total_loss` = 单价 × 数量),
|
||||
且**取决于执行时的实际扫码量**(MOM 允许少扫,受理量 ≠ 执行量)。
|
||||
在 Track 侧另存一份就是第二份口径,迟早对不上。展示/统计时按
|
||||
`scrap_request_no` 实时回查 MOM(见 mom_scrap_service)。
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Numeric, String, Text, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
from app.core.time_utils import get_beijing_time
|
||||
|
||||
|
||||
class ProductScrap(Base):
|
||||
__tablename__ = "product_scraps"
|
||||
# 幂等锚点:外部单据号(Track 生成,随请求发给 MOM)。同一个号重发必须命中
|
||||
# 同一行,而不是插出第二行 —— 用户点两下提交、或网络超时后重试都会走到这里。
|
||||
__table_args__ = (
|
||||
UniqueConstraint("source_ref", name="uq_product_scrap_source_ref"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
|
||||
)
|
||||
|
||||
# ---- 物理外键(关联本库 products) ----
|
||||
product_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("products.id"), nullable=False, index=True,
|
||||
comment="所属产品ID",
|
||||
)
|
||||
# 冗余序列号:按 SN 对账/排查时不必 join products
|
||||
serial_number: Mapped[str | None] = mapped_column(
|
||||
String(16), nullable=True, index=True, comment="产品序列号(冗余,便于按SN对账)",
|
||||
)
|
||||
# 料挂在哪条 Track 任务上(可空:允许直接按设备报,不强制挂任务)。
|
||||
# 只作溯源用,**不参与可见性判断** —— 见模块头「为什么挂在产品维度」。
|
||||
task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id"), nullable=True, index=True,
|
||||
comment="料所属的Track任务(可空,仅溯源用,不参与可见性判断)",
|
||||
)
|
||||
|
||||
# ---- 报废对象:MOM 出库明细行 ----
|
||||
# = MOM trans_outbound.id,也就是 task_outbound_materials.mom_line_id。
|
||||
# ★ 这是跨库逻辑外键(无物理约束),MOM 侧数据被清理时可能查不到。
|
||||
mom_line_id: Mapped[int] = mapped_column(
|
||||
nullable=False, index=True,
|
||||
comment="报废对象:MOM trans_outbound.id(出库明细行)",
|
||||
)
|
||||
# ---- 快照(MOM 行被删也要能显示,且列表页不必跨库查询) ----
|
||||
outbound_no: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True, comment="MOM 出库单号(快照)",
|
||||
)
|
||||
material_name: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, comment="物料名称(快照)",
|
||||
)
|
||||
spec_model: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, comment="规格型号(快照)",
|
||||
)
|
||||
sku: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True, comment="SKU(快照)",
|
||||
)
|
||||
consumer_name: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True,
|
||||
comment="原领用人(快照)。前端据此判断「报别人的料要额外确认」",
|
||||
)
|
||||
|
||||
quantity: Mapped[Decimal] = mapped_column(
|
||||
Numeric(19, 4), nullable=False, comment="本次报废数量",
|
||||
)
|
||||
reason_category: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, default="PRODUCTION", server_default="PRODUCTION",
|
||||
comment="报废原因分类码。生产报废恒为 PRODUCTION(生产损耗),与 MOM 侧码表一致",
|
||||
)
|
||||
reason: Mapped[str | None] = mapped_column(
|
||||
Text, nullable=True, comment="报废原因说明(用户填写)",
|
||||
)
|
||||
|
||||
# ---- MOM 回执 ----
|
||||
scrap_request_no: Mapped[str] = mapped_column(
|
||||
String(100), nullable=False, index=True,
|
||||
comment="MOM 报废申请单号(APR-SCRAP-...)。状态与金额都按它回查 MOM",
|
||||
)
|
||||
defective_goods_id: Mapped[int | None] = mapped_column(
|
||||
nullable=True, comment="MOM 在管不良品台账 id(退回时生成)",
|
||||
)
|
||||
# MOM 报废单状态快照:0待审批 1已通过 2已驳回 3已执行 4已撤回。
|
||||
# ⚠️ 这是**写入当时**的快照,会过期(MOM 里审批、执行后 Track 不知道)。
|
||||
# 展示时以实时回查为准,本列只用于「MOM 暂时查不到时不至于没得显示」。
|
||||
mom_status: Mapped[int] = mapped_column(
|
||||
nullable=False, default=0, server_default="0",
|
||||
comment="MOM 报废单状态快照(0待审批/1已通过/2已驳回/3已执行/4已撤回),展示时以实时回查为准",
|
||||
)
|
||||
|
||||
# ---- 幂等与归属 ----
|
||||
source_ref: Mapped[str] = mapped_column(
|
||||
String(100), nullable=False,
|
||||
comment="幂等锚点 <公司>:<Track单据号>,随请求发给 MOM,两边同一口径",
|
||||
)
|
||||
submitted_by: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True, index=True,
|
||||
comment="提交人 Track 用户名(即 MOM 账号)。MOM 侧报废单的申请人就是他本人",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=get_beijing_time, comment="本行写入时间",
|
||||
)
|
||||
|
||||
# ---- 关系 ----
|
||||
product: Mapped["Product"] = relationship("Product", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (f"<ProductScrap {self.scrap_request_no} sn={self.serial_number} "
|
||||
f"line={self.mom_line_id}>")
|
||||
@ -89,6 +89,13 @@ class Task(Base):
|
||||
records: Mapped[list["TaskRecord"]] = relationship(
|
||||
"TaskRecord", back_populates="task", lazy="selectin", cascade="all, delete-orphan",
|
||||
)
|
||||
# 本任务挂载的 MOM 出库物料(明细级快照)。创建任务时选、之后可追加,
|
||||
# 见 models/task_outbound_material.py 的设计说明。
|
||||
outbound_materials: Mapped[list["TaskOutboundMaterial"]] = relationship(
|
||||
"TaskOutboundMaterial", back_populates="task", lazy="selectin",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="TaskOutboundMaterial.created_at",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Task {self.task_name}>"
|
||||
|
||||
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}>"
|
||||
47
backend/app/models/user_daily_seen.py
Normal file
47
backend/app/models/user_daily_seen.py
Normal file
@ -0,0 +1,47 @@
|
||||
"""每日用户活动表 —— 一天一人一行,只记录"今天来过"这件事。
|
||||
|
||||
为什么需要它(而不是复用 audit_logs)
|
||||
--------------------------------------
|
||||
日活报表要的「上线时间 / 下线时间」,两个都不能从审计表直接得出:
|
||||
|
||||
1. **上线/下线时间不能取登录时间**:Refresh Token 有效期 7 天,用户不必每天
|
||||
重新登录。按登录算会出现「登录次数 0、上线时间空,但操作次数 35」的
|
||||
自相矛盾报表。
|
||||
|
||||
2. **也不能只取写操作时间**:审计中间件只记录写操作(及导出/打印这类敏感读),
|
||||
普通 GET 不入账。当天只翻看、没做写操作的人会被整条漏掉。
|
||||
|
||||
3. **更不能把活动记录写进 audit_logs**:
|
||||
· 「上线时间」是**事件**(INSERT 一次即可),但「下线时间」是**状态**
|
||||
(每次活动都要刷新同一个值)。往审计流水里做 UPDATE,等于承认审计记录
|
||||
可以被改写 —— 那审计本身就失去可信度了。
|
||||
· 若改为每个请求 INSERT 一条,表会随访问量线性膨胀。
|
||||
|
||||
于是单开一张"可变的小状态表":一人一天一行,首见 INSERT、其后只
|
||||
UPDATE last_seen_at。50 人 × 365 天 ≈ 1.8 万行/年,可忽略。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class UserDailySeen(Base):
|
||||
"""用户在某个北京时间自然日的首末活动时刻"""
|
||||
|
||||
__tablename__ = "user_daily_seen"
|
||||
|
||||
# 联合主键即 UPSERT 的冲突目标,也是"一天一人一行"的保证
|
||||
user_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
day: Mapped[date] = mapped_column(Date, primary_key=True, comment="北京时间自然日")
|
||||
|
||||
first_seen_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, comment="当天首次活动时刻",
|
||||
)
|
||||
last_seen_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, comment="当天末次活动时刻",
|
||||
)
|
||||
88
backend/app/schemas/audit.py
Normal file
88
backend/app/schemas/audit.py
Normal file
@ -0,0 +1,88 @@
|
||||
"""审计日志 Pydantic Schema"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AuditLogResponse(BaseModel):
|
||||
"""单条审计记录"""
|
||||
id: uuid.UUID
|
||||
user_id: str | None = None
|
||||
display_name: str | None = None
|
||||
role: str | None = None
|
||||
|
||||
action: str
|
||||
action_label: str | None = None # 服务端补的中文标签,避免前端各处硬编码
|
||||
module: str
|
||||
module_label: str | None = None
|
||||
|
||||
target_type: str | None = None
|
||||
target_id: str | None = None
|
||||
target_name: str | None = None
|
||||
details: dict | None = None
|
||||
|
||||
ip_address: str | None = None
|
||||
user_agent: str | None = None
|
||||
method: str | None = None
|
||||
url: str | None = None
|
||||
status_code: int | None = None
|
||||
error_message: str | None = None
|
||||
|
||||
# 与结构化日志对账用:拿着它就能捞到对应的接口日志
|
||||
request_id: str | None = None
|
||||
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AuditLogListResponse(BaseModel):
|
||||
"""审计日志分页列表"""
|
||||
items: list[AuditLogResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class DailyUsageRow(BaseModel):
|
||||
"""某个操作人在某一天的用量汇总(北京时间自然日)"""
|
||||
day: str # YYYY-MM-DD(北京时间)
|
||||
user_id: str | None = None
|
||||
display_name: str | None = None
|
||||
role: str | None = None
|
||||
|
||||
login_count: int = 0 # 登录次数(当天成功登录)
|
||||
logout_count: int = 0 # 登出次数(当天成功登出)
|
||||
op_count: int = 0 # 操作次数(当天全部审计记录数)
|
||||
|
||||
# ⚠️ 上线/下线时间取【当天首次/末次活动】,不是登录/登出时间:
|
||||
# token 有效期内(refresh 7 天)用户不会重新登录,按登录算会导致
|
||||
# 「登录次数 0 但操作 35 次」这种自相矛盾。
|
||||
first_active_at: datetime | None = None # 上线时间(当天首次活动)
|
||||
last_active_at: datetime | None = None # 下线时间(当天末次活动)
|
||||
|
||||
|
||||
class DailyUsageResponse(BaseModel):
|
||||
"""日活 / 使用统计"""
|
||||
start_date: str
|
||||
end_date: str
|
||||
items: list[DailyUsageRow]
|
||||
total: int # 行数(= 天数 × 人数),不是审计记录数
|
||||
|
||||
|
||||
class AuditOption(BaseModel):
|
||||
"""筛选项(value/label 结构,直接喂给前端下拉)"""
|
||||
value: str
|
||||
label: str
|
||||
|
||||
|
||||
class AuditOptionsResponse(BaseModel):
|
||||
"""筛选项集合"""
|
||||
modules: list[AuditOption]
|
||||
actions: list[AuditOption]
|
||||
# 导出可选的列(value=后端列 key,label=中文表头)。
|
||||
# 由后端下发而非前端硬编码:列的中文名与取值口径都在后端,
|
||||
# 两端各写一份迟早会出现"导出的列和页面上的对不上"。
|
||||
log_export_columns: list[AuditOption] = []
|
||||
usage_export_columns: list[AuditOption] = []
|
||||
@ -2,7 +2,7 @@
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class ProductCreate(BaseModel):
|
||||
@ -18,6 +18,14 @@ class ProductCreate(BaseModel):
|
||||
order_id: uuid.UUID | None = Field(None, description="所属订单ID(选填)")
|
||||
order_no: str | None = Field(None, max_length=64, description="订单号(自由键入,选填)")
|
||||
parent_product_id: uuid.UUID | None = Field(None, description="父产品ID")
|
||||
# 建档时一并挂钩的 MOM 出库**明细行** ID(trans_outbound.id)。
|
||||
# 前端按整张出库单勾选,提交时把该单全部明细 ID 带过来;后端归并回单据后
|
||||
# 写进 product_outbounds(一行 = 一张单,source=manual)。
|
||||
# ⚠️ 默认空列表:其它调用方(旧前端、脚本)不带该字段,必须保持行为不变。
|
||||
mom_line_ids: list[int] = Field(
|
||||
default_factory=list,
|
||||
description="建档时挂钩的 MOM 出库明细行ID(trans_outbound.id)",
|
||||
)
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@ -80,6 +88,139 @@ class ProductResponse(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ProductOutboundResponse(BaseModel):
|
||||
"""产品出库记录 — 来自 MOM 出库回调的单据存档
|
||||
|
||||
一次出库一行,按出库时间倒序返回。**已撤回的记录照常留在列表里**
|
||||
(is_revoked=True),由前端打标记 —— 不在后端过滤掉,「出过又撤了」
|
||||
本身就是要看得见的历史。
|
||||
"""
|
||||
id: uuid.UUID
|
||||
outbound_no: str # MOM 出库单号
|
||||
request_no: str | None = None # MOM 出库申请单号
|
||||
consumer_name: str | None = None # 领用人/客户
|
||||
applicant_name: str | None = None # 申请人姓名
|
||||
operator: str | None = None # MOM 侧实际扫码出库人
|
||||
outbound_type: str | None = None # SALES / USE / PRODUCTION(只展示,不做业务判断)
|
||||
outbound_time: datetime | None = None # MOM 记录的出库时间
|
||||
remark: str | None = None
|
||||
is_revoked: bool = False
|
||||
revoked_at: datetime | None = None
|
||||
# 这一行怎么来的:webhook(MOM 回调自动存档) | manual(人工在界面挂的)
|
||||
source: str = "webhook"
|
||||
created_at: datetime # 本行写入时间
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ProductOutboundMaterialResponse(BaseModel):
|
||||
"""设备的一条 MOM 出库明细(合并后的统一形态)
|
||||
|
||||
一行 = 设备上的一条出库明细。`mom_line_id` 为空表示这条是**单据级存档**
|
||||
(MOM 回调时查不到明细),能看、能标撤回,但**不能报废** —— 报废要用它定位。
|
||||
"""
|
||||
id: int
|
||||
product_id: uuid.UUID
|
||||
serial_number: str | None = None
|
||||
task_id: uuid.UUID | None = None # 仅溯源,不参与展示/报废/删除
|
||||
mom_line_id: int | None = None # MOM trans_outbound.id;为空=无明细的存档
|
||||
outbound_no: str
|
||||
# ---- 单据级(同单内一致,冗余在每条明细上) ----
|
||||
request_no: str | None = None
|
||||
applicant_name: str | None = None
|
||||
remark: str | None = None
|
||||
# ---- 明细级快照 ----
|
||||
sku: str | None = None
|
||||
material_name: str | None = None
|
||||
spec_model: str | None = None
|
||||
quantity: float | None = None # 出库单原值,**不是**本设备用量
|
||||
unit_price: float | None = None
|
||||
outbound_type: str | None = None
|
||||
outbound_type_label: str = "" # 服务端下发的中文名
|
||||
consumer_name: str | None = None # 领用人/客户
|
||||
operator_name: str | None = None
|
||||
warehouse_location: str | None = None
|
||||
outbound_time: datetime | None = None
|
||||
# ---- 来源与撤回 ----
|
||||
source: str = "manual" # manual(可删) | webhook(系统事实,不可删)
|
||||
is_revoked: bool = False
|
||||
revoked_at: datetime | None = None
|
||||
added_by: str | None = None # 谁挂上去的(Track 用户名)
|
||||
# 谁挂上去的(中文姓名)。由服务端解析下发 —— 前端不做 username→姓名映射,
|
||||
# 否则移动端/网页端各抄一份,迟早漂移。
|
||||
added_by_name: str = ""
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _fill_type_label(self):
|
||||
"""出库类型码 → 中文名。统一在 schema 派生,避免各构造点漏填。"""
|
||||
if not self.outbound_type_label and self.outbound_type:
|
||||
from app.services.mom_outbound_service import describe_outbound_type
|
||||
self.outbound_type_label = describe_outbound_type(self.outbound_type)
|
||||
return self
|
||||
|
||||
|
||||
class ProductScrapCreate(BaseModel):
|
||||
"""提交生产报废的请求体。
|
||||
|
||||
⚠️ 只传 `mom_line_id`,物料快照一律由后端从 Track 已挂的出库物料里取 ——
|
||||
不接受前端传快照,否则前端可以伪造「报废了什么」。
|
||||
"""
|
||||
# MOM trans_outbound.id,也就是 Track 侧 task_outbound_materials.mom_line_id。
|
||||
# 后端会校验它**确实挂在本产品上** —— 这是跨设备乱报的唯一防线
|
||||
# (可见范围是整台设备、不是「谁领的」,所以不能靠隐藏来防)。
|
||||
mom_line_id: int
|
||||
# 本次报废数量。上限由 MOM 判(不能超过该出库明细的可退额度),
|
||||
# 这里不重复校验,避免两处口径漂移。
|
||||
quantity: float
|
||||
# 用户填的原因说明(选填)
|
||||
reason: str | None = None
|
||||
# 幂等锚点:前端在**打开弹层时**生成一次,重试时复用同一个。
|
||||
# 后端拼成 <公司>:<track_ref> 发给 MOM,两边同一口径。
|
||||
track_ref: str
|
||||
|
||||
|
||||
class ProductScrapResponse(BaseModel):
|
||||
"""生产报废记录 — Track 发起、MOM 受理的报废单。
|
||||
|
||||
`mom_status` / `total_loss` 是**实时回查 MOM** 的结果,不是本地快照
|
||||
(本地那列只在 MOM 暂时查不到时兜底)。
|
||||
· `mom_status_label`:「待审批 / 已通过(待执行)/ 已执行 / 已驳回 / 已撤回」
|
||||
· `mom_executed=False` 时 `total_loss` 是 **None 而不是 0** ——
|
||||
区分「还没执行」和「执行了但损失为 0」,别让用户把未审批看成 0 元损失
|
||||
"""
|
||||
id: uuid.UUID
|
||||
product_id: uuid.UUID
|
||||
serial_number: str | None = None
|
||||
task_id: uuid.UUID | None = None
|
||||
mom_line_id: int
|
||||
# 快照:MOM 侧数据被清理后仍要能显示「报了什么」
|
||||
outbound_no: str | None = None
|
||||
material_name: str | None = None
|
||||
spec_model: str | None = None
|
||||
sku: str | None = None
|
||||
consumer_name: str | None = None # 原领用人,前端据此提示「代报」
|
||||
quantity: float
|
||||
reason_category: str = "PRODUCTION"
|
||||
reason: str | None = None
|
||||
scrap_request_no: str
|
||||
defective_goods_id: int | None = None
|
||||
submitted_by: str | None = None
|
||||
created_at: datetime
|
||||
# ---- 以下为实时回查 MOM 的结果 ----
|
||||
mom_status: int = 0
|
||||
mom_status_label: str = ""
|
||||
mom_approved_at: str | None = None
|
||||
mom_executor_name: str = ""
|
||||
mom_executed: bool = False
|
||||
total_loss: float | None = None # 报废损失(单价 × 实报废数量)
|
||||
scrapped_quantity: float | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ProductScanResponse(BaseModel):
|
||||
"""扫码查询响应 — 产品信息 + 完整任务树(递归嵌套)"""
|
||||
id: uuid.UUID
|
||||
@ -102,6 +243,9 @@ class ProductScanResponse(BaseModel):
|
||||
top_level_tasks: list[TaskSummaryResponse] = []
|
||||
task_tree: list[TaskResponse] = []
|
||||
assignee_names: dict[str, str] = {} # 🔧 username→中文姓名映射
|
||||
# 🔧 出库单据存档(来自 MOM 出库回调),按出库时间倒序。
|
||||
# 本次功能上线前出库的设备没有存档,这里是空列表 —— 不是错误。
|
||||
outbound_records: list[ProductOutboundMaterialResponse] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@ -19,6 +19,15 @@ class TaskCreate(BaseModel):
|
||||
notify_parent_on_complete: bool = Field(False, description="完成后是否通知父任务")
|
||||
is_rework: bool = Field(False, description="是否为返工任务")
|
||||
remark: str | None = Field(None, max_length=2000, description="初始描述/交接备注")
|
||||
# 创建时一并挂载的 MOM 出库明细行 ID(MOM trans_outbound.id)。
|
||||
# 粒度是**明细行**,但前端是按整张出库单勾选的 —— 提交时把该单的全部明细
|
||||
# ID 一起带过来。
|
||||
# ⚠️ 默认空列表:移动端的 doCreateFirstTask 仍在调本接口且不带该字段,
|
||||
# 必须保持「不传就等同于不挂载」的行为不变。
|
||||
mom_line_ids: list[int] = Field(
|
||||
default_factory=list,
|
||||
description="创建时挂载的 MOM 出库明细行ID(trans_outbound.id)",
|
||||
)
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@ -153,6 +162,55 @@ class TaskRecordResponse(BaseModel):
|
||||
# 响应模型
|
||||
# ============================================================
|
||||
|
||||
class TaskOutboundMaterialResponse(BaseModel):
|
||||
"""任务挂载的一条 MOM 出库物料明细(挂载时从 MOM 取的快照)
|
||||
|
||||
一次挂载会展开成多行(挂一张出库单 = 该单的全部明细各一行),
|
||||
前端按 outbound_no 分组展示。
|
||||
"""
|
||||
id: int
|
||||
# ★ 料挂在哪条任务上。前端按任务分组展示时必须拿它做 key ——
|
||||
# 不能用 task_name:同一台设备可能有两个同名任务(例如两道「生产」),
|
||||
# 按名字分会把它们并成一组,看起来像一条任务领了两遍料。
|
||||
task_id: uuid.UUID
|
||||
mom_line_id: int # MOM trans_outbound.id,供反查比对
|
||||
outbound_no: str # MOM 出库单号
|
||||
sku: str | None = None
|
||||
material_name: str | None = None
|
||||
spec_model: str | None = None
|
||||
# 用 float 而非 Decimal:Pydantic v2 会把 Decimal 序列化成字符串,
|
||||
# 前端拿到 "5.0000" 不好直接用。数量量级很小(实测 1~186),float 足够。
|
||||
quantity: float | None = None # 出库单原值,**不是**本任务用量
|
||||
unit_price: float | None = None
|
||||
outbound_type: str | None = None
|
||||
# 出库类型中文名。与 MOM 出库单查询(mom_outbounds)同一套码表、同一份实现,
|
||||
# 由服务端下发 —— 前端不再自建映射,否则两边会开始漂移。
|
||||
# 这里用 model_validator 自动派生而不是每个构造点手填:构造点有 3 处
|
||||
# (task_service 两处 + product_service 一处),漏一个就是空白徽标。
|
||||
outbound_type_label: str = ""
|
||||
consumer_name: str | None = None # 领用人/客户
|
||||
operator_name: str | None = None
|
||||
warehouse_location: str | None = None
|
||||
outbound_time: datetime | None = None
|
||||
added_by: str | None = None # 挂载人(逻辑外键→MOM sys_user)
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _fill_outbound_type_label(self):
|
||||
"""出库类型码 → 中文名(PRODUCTION→生产出库 等)。
|
||||
|
||||
在 schema 上统一派生,而不是让 3 个构造点各自记得填 ——
|
||||
漏一个就是空白徽标,而且不会报错,只能靠肉眼发现。
|
||||
延迟 import:schemas 被 services 依赖,模块级 import 会形成环。
|
||||
"""
|
||||
if not self.outbound_type_label and self.outbound_type:
|
||||
from app.services.mom_outbound_service import describe_outbound_type
|
||||
self.outbound_type_label = describe_outbound_type(self.outbound_type)
|
||||
return self
|
||||
|
||||
|
||||
class TaskSummaryResponse(BaseModel):
|
||||
"""任务摘要 — 扫码时用,不含嵌套子任务"""
|
||||
id: uuid.UUID
|
||||
@ -195,6 +253,9 @@ class TaskResponse(BaseModel):
|
||||
child_tasks: list[TaskResponse] = []
|
||||
records: list[TaskRecordResponse] = []
|
||||
created_by: str | None = None # 谁创建的(从task_logs追溯)
|
||||
# 本任务挂载的 MOM 出库物料(明细级快照)。创建任务时可选、之后可追加,
|
||||
# 见 models/task_outbound_material.py。
|
||||
outbound_materials: list[TaskOutboundMaterialResponse] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
458
backend/app/services/audit_service.py
Normal file
458
backend/app/services/audit_service.py
Normal file
@ -0,0 +1,458 @@
|
||||
"""审计服务 — 写入与检索
|
||||
|
||||
写入方案的取舍(与 MOM/KCGL 不同,理由如下)
|
||||
--------------------------------------------------
|
||||
MOM 用 SQLAlchemy event listener + **同事务**写入:优点是全自动、业务代码零改动;
|
||||
缺点是业务事务回滚时审计记录一起被回滚掉 —— 而失败/被拒的操作恰恰是最需要
|
||||
留痕的(比如越权尝试、参数错误导致的 4xx)。
|
||||
|
||||
Track 改为:响应生成后,用**独立 session** 写入审计。
|
||||
- 业务回滚不影响审计,失败操作照样留痕
|
||||
- 审计写入失败也不影响业务(全包裹 try/except,仅记日志)
|
||||
- 代价:审计与业务不是原子提交,极端情况(响应后进程立即被 kill)可能丢一条。
|
||||
对内部系统的操作审计,这个取舍划算。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.core.time_utils import get_beijing_time
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.user_daily_seen import UserDailySeen
|
||||
|
||||
logger = logging.getLogger("track.audit")
|
||||
|
||||
# 绝不落库的敏感字段名(命中即替换为 ***)
|
||||
# 登录请求体含明文密码,一旦进审计表就成了长期泄露面
|
||||
_SENSITIVE_KEYS = frozenset(
|
||||
{"password", "passwd", "pwd", "token", "access_token", "refresh_token",
|
||||
"secret", "api_key", "authorization", "password_hash"}
|
||||
)
|
||||
|
||||
# 模块 / 动作 的中文标签(前端下拉与列表展示用)
|
||||
MODULE_LABELS: dict[str, str] = {
|
||||
"auth": "认证登录",
|
||||
"product": "产品管理",
|
||||
"task": "任务流转",
|
||||
"order": "订单管理",
|
||||
"record": "任务记录",
|
||||
"print": "标签打印",
|
||||
"material": "物料",
|
||||
"user": "用户",
|
||||
"notification": "消息通知",
|
||||
"upload": "文件上传",
|
||||
"dashboard": "看板统计",
|
||||
"analytics": "效能分析",
|
||||
"screen": "数据大屏",
|
||||
"holiday": "节假日配置",
|
||||
"app": "App版本",
|
||||
"external": "外部系统对接",
|
||||
"audit": "审计日志",
|
||||
"other": "其它",
|
||||
}
|
||||
|
||||
ACTION_LABELS: dict[str, str] = {
|
||||
"create": "新增",
|
||||
"update": "修改",
|
||||
"delete": "删除",
|
||||
# 只用于被采集的 GET(核心业务详情 / 敏感读)。
|
||||
# 叫「查看详情」而不是「查询」:前者说明用户确实点开了某条业务数据,
|
||||
# 后者容易被误解成"随便搜了一下"。
|
||||
"read": "查看详情",
|
||||
"export": "导出",
|
||||
"login": "登录",
|
||||
"logout": "登出",
|
||||
# 刷新令牌 = 用户重新开始使用系统(token 2 小时一换,7 天免登录),
|
||||
# 业务上视作一次「上线」,比"刷新令牌"这种技术词更贴近车间口径
|
||||
"refresh": "上线",
|
||||
"print": "打印",
|
||||
"upload": "上传",
|
||||
"finalize": "收口",
|
||||
"receive": "接收",
|
||||
"transfer": "转交",
|
||||
"reject": "驳回",
|
||||
"recall": "撤回",
|
||||
"spawn": "派发",
|
||||
"end": "结束分支",
|
||||
"complete": "完结",
|
||||
"mark_read": "标为已读",
|
||||
}
|
||||
|
||||
|
||||
def sanitize_details(details: dict | None) -> dict | None:
|
||||
"""递归剔除敏感字段,避免密码/令牌落库"""
|
||||
if not details:
|
||||
return details
|
||||
|
||||
def _clean(value):
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
k: ("***" if str(k).lower() in _SENSITIVE_KEYS else _clean(v))
|
||||
for k, v in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [_clean(v) for v in value]
|
||||
return value
|
||||
|
||||
return _clean(details)
|
||||
|
||||
|
||||
async def record_audit(
|
||||
*,
|
||||
action: str,
|
||||
module: str,
|
||||
user_id: str | None = None,
|
||||
display_name: str | None = None,
|
||||
role: str | None = None,
|
||||
target_type: str | None = None,
|
||||
target_id: str | None = None,
|
||||
target_name: str | None = None,
|
||||
details: dict | None = None,
|
||||
ip_address: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
method: str | None = None,
|
||||
url: str | None = None,
|
||||
status_code: int | None = None,
|
||||
error_message: str | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> None:
|
||||
"""写入一条审计记录。**绝不抛异常**:审计失败不能影响业务。"""
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
session.add(
|
||||
AuditLog(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user_id,
|
||||
display_name=display_name,
|
||||
role=role,
|
||||
action=action,
|
||||
module=module,
|
||||
target_type=target_type,
|
||||
target_id=str(target_id) if target_id is not None else None,
|
||||
target_name=target_name,
|
||||
details=sanitize_details(details),
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent[:500] if user_agent else None,
|
||||
method=method,
|
||||
url=url[:500] if url else None,
|
||||
status_code=status_code,
|
||||
error_message=error_message,
|
||||
request_id=request_id,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
# 用 exception 级别但吞掉异常:保证调用方业务流程不受影响
|
||||
logger.exception(
|
||||
"审计写入失败(已忽略,不影响业务)",
|
||||
extra={"extra_fields": {"action": action, "module": module, "url": url}},
|
||||
)
|
||||
|
||||
|
||||
async def list_audit_logs(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
module: str | None = None,
|
||||
action: str | None = None,
|
||||
target_id: str | None = None,
|
||||
request_id: str | None = None,
|
||||
status_code: int | None = None,
|
||||
start: datetime | None = None,
|
||||
end: datetime | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> tuple[list[AuditLog], int]:
|
||||
"""审计日志检索(按时间倒序)。返回 (当前页, 真实总数)。
|
||||
|
||||
真实总数走独立 COUNT —— 前端分页器依赖它,不能用 len(当前页)。
|
||||
"""
|
||||
filters = _log_filters(
|
||||
user_id=user_id, module=module, action=action, target_id=target_id,
|
||||
request_id=request_id, status_code=status_code, start=start, end=end,
|
||||
)
|
||||
|
||||
total = await db.scalar(
|
||||
select(func.count()).select_from(AuditLog).where(*filters)
|
||||
) or 0
|
||||
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(AuditLog)
|
||||
.where(*filters)
|
||||
.order_by(AuditLog.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
return list(rows), total
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 导出
|
||||
# ============================================================
|
||||
|
||||
# 单次导出的行数上限。审计表只增不减,全量导出迟早会撑爆内存与浏览器,
|
||||
# 故设硬上限;超出时向上层返回 truncated=True,由前端明确提示「已截断」——
|
||||
# 静默截断会让使用者以为导全了,比报错更危险。
|
||||
EXPORT_MAX_ROWS = 50000
|
||||
|
||||
|
||||
def _log_filters(
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
module: str | None = None,
|
||||
action: str | None = None,
|
||||
target_id: str | None = None,
|
||||
request_id: str | None = None,
|
||||
status_code: int | None = None,
|
||||
start: datetime | None = None,
|
||||
end: datetime | None = None,
|
||||
) -> list:
|
||||
"""审计日志的筛选条件 —— list_audit_logs 与 export_audit_logs 共用。
|
||||
|
||||
抽出来的唯一目的:保证「列表看到的」和「导出出去的」永远是同一批数据。
|
||||
两处各写一份迟早会漂移,而导出与列表不一致是最让人不信任的那种 bug。
|
||||
"""
|
||||
filters = []
|
||||
if user_id:
|
||||
filters.append(AuditLog.user_id.ilike(f"%{user_id}%"))
|
||||
if module:
|
||||
filters.append(AuditLog.module == module)
|
||||
if action:
|
||||
filters.append(AuditLog.action == action)
|
||||
if target_id:
|
||||
filters.append(AuditLog.target_id == target_id)
|
||||
if request_id:
|
||||
filters.append(AuditLog.request_id == request_id)
|
||||
if status_code is not None:
|
||||
filters.append(AuditLog.status_code == status_code)
|
||||
if start:
|
||||
filters.append(AuditLog.created_at >= start)
|
||||
if end:
|
||||
filters.append(AuditLog.created_at <= end)
|
||||
return filters
|
||||
|
||||
|
||||
async def export_audit_logs(
|
||||
db: AsyncSession, *, limit: int = EXPORT_MAX_ROWS, **kwargs,
|
||||
) -> tuple[list[AuditLog], bool]:
|
||||
"""导出用:按筛选条件取全部记录(不分页)。返回 (rows, truncated)。
|
||||
|
||||
多取一行来判断是否被截断 —— 比再跑一次 COUNT 便宜。
|
||||
"""
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(AuditLog)
|
||||
.where(*_log_filters(**kwargs))
|
||||
.order_by(AuditLog.created_at.desc())
|
||||
.limit(limit + 1)
|
||||
)
|
||||
).scalars().all()
|
||||
truncated = len(rows) > limit
|
||||
return list(rows[:limit]), truncated
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 每日活动打点(日活报表的「上线时间 / 下线时间」来源)
|
||||
# ============================================================
|
||||
|
||||
# 同一用户两次落盘之间的最小间隔(秒)。
|
||||
#
|
||||
# 打点挂在「每个请求」上,但不希望每个请求都写一次数据库 —— 那会把
|
||||
# user_daily_seen 变成热点。这里用进程内缓存做节流:同一用户 2 分钟内
|
||||
# 只落盘一次。代价是「末次活动时间」最多落后真实值 2 分钟,
|
||||
# 对"日活统计"这个精度要求完全够用。
|
||||
#
|
||||
# 多 worker 部署时每个进程各持一份缓存,实际写库频率最多放大到 worker 数倍
|
||||
# (4 worker × 每人每 2 分钟 1 次),依然可忽略。
|
||||
_TOUCH_INTERVAL_S = 120.0
|
||||
_touch_cache: dict[str, float] = {}
|
||||
|
||||
# 缓存只增不减会缓慢泄漏(键是 user_id,量级 = 用户数,实际很小)。
|
||||
# 超过阈值就整体清空 —— 代价只是多写几次库,换来内存有界。
|
||||
_TOUCH_CACHE_MAX = 5000
|
||||
|
||||
|
||||
async def touch_daily_seen(user_id: str | None) -> None:
|
||||
"""记录「该用户此刻活动过」。首次 INSERT、其后只刷新 last_seen_at。
|
||||
|
||||
唯一的消费方是日活报表的上线/下线时间(见 get_daily_usage)。
|
||||
刻意不写进 audit_logs:那是只增不改的审计流水,而本表是需要不断
|
||||
UPDATE 的状态(详见 UserDailySeen 模型注释)。
|
||||
|
||||
任何异常都吞掉 —— 活动打点失败绝不能影响业务请求本身。
|
||||
"""
|
||||
if not user_id:
|
||||
return
|
||||
|
||||
now_mono = time.monotonic()
|
||||
last = _touch_cache.get(user_id)
|
||||
if last is not None and now_mono - last < _TOUCH_INTERVAL_S:
|
||||
return # 节流窗口内,跳过
|
||||
if len(_touch_cache) > _TOUCH_CACHE_MAX:
|
||||
_touch_cache.clear()
|
||||
# 先占位再写库:同一用户的并发请求不会同时打进来
|
||||
_touch_cache[user_id] = now_mono
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
now = get_beijing_time()
|
||||
day = now.date() # 北京时间自然日(与报表分日口径一致)
|
||||
async with AsyncSessionLocal() as db:
|
||||
await db.execute(
|
||||
pg_insert(UserDailySeen)
|
||||
.values(user_id=user_id, day=day, first_seen_at=now, last_seen_at=now)
|
||||
# 冲突时只刷新 last_seen_at,first_seen_at 保持当天首次值不变
|
||||
.on_conflict_do_update(
|
||||
index_elements=["user_id", "day"],
|
||||
set_={"last_seen_at": now},
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
except Exception: # noqa: BLE001 —— 打点失败不影响业务
|
||||
logger.exception("记录每日活动失败(已忽略)")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 日活 / 使用统计
|
||||
# ============================================================
|
||||
|
||||
# 成功 = 2xx/3xx。登录失败(401)也要留痕,但不应计入"上线次数"。
|
||||
_OK_STATUS_UPPER = 400
|
||||
|
||||
|
||||
async def get_daily_usage(
|
||||
db: AsyncSession, *, start: datetime, end: datetime,
|
||||
) -> list[dict]:
|
||||
"""按【北京时间自然日 × 操作人】聚合用量 —— 日活报表的数据源。
|
||||
|
||||
start/end 为半开区间 [start, end),调用方按北京时间日界传入。
|
||||
|
||||
全部指标由**一个 GROUP BY 查询**算出,不用窗口函数:
|
||||
· 登录/登出次数 = 成功登录 / 成功登出数(最终凭证是 login_count,不是"上线次数")
|
||||
· 操作频次 = 当天该用户的全部审计记录数(代表系统使用深度)
|
||||
· 登录/登出次数 = 成功登录 / 成功登出数
|
||||
· 上线/下线时间 = 当天**首次 / 末次活动**(优先取 user_daily_seen)
|
||||
|
||||
⚠️ 上线/下线时间【不能】取登录/登出时间。
|
||||
Access/Refresh Token 有效期内(refresh 7 天)用户无需重新登录,
|
||||
于是"周一登录、周二到周日继续用"会导致周二~周日:
|
||||
登录次数=0、登录时间=空,但操作次数却是几十 —— 报表自相矛盾。
|
||||
|
||||
⚠️ 也不能只取审计表的写操作时间:审计中间件只记写操作,普通 GET 不入账,
|
||||
当天只翻看、没做写操作的人会被整条漏掉。
|
||||
故上线/下线时间优先取 user_daily_seen(挂在每个请求上打点),
|
||||
仅对本表上线前的历史数据回退到审计表的写操作时间。
|
||||
|
||||
为什么用 `count(*) FILTER (WHERE ...)`:分组内一次扫描同时算出多个条件计数,
|
||||
比多次子查询或 UNION 简单得多,且语义一眼可读。Postgres 原生支持。
|
||||
|
||||
⚠️ 按【北京时间】分日:created_at 是 timestamptz(实存 UTC),
|
||||
直接按 UTC 分日会让 00:00~08:00 的早班操作掉到前一天。
|
||||
"""
|
||||
day_col = func.date(func.timezone("Asia/Shanghai", AuditLog.created_at))
|
||||
|
||||
login_ok = and_(
|
||||
AuditLog.action == "login", AuditLog.status_code < _OK_STATUS_UPPER,
|
||||
)
|
||||
logout_ok = and_(
|
||||
AuditLog.action == "logout", AuditLog.status_code < _OK_STATUS_UPPER,
|
||||
)
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
day_col.label("day"),
|
||||
AuditLog.user_id.label("user_id"),
|
||||
# 同一用户的 display_name / role 是一致的,取 max 只是为了
|
||||
# 在 GROUP BY 下拿到一个非空代表值(避免再套一层 DISTINCT ON)
|
||||
func.max(AuditLog.display_name).label("display_name"),
|
||||
func.max(AuditLog.role).label("role"),
|
||||
func.count().filter(login_ok).label("login_count"),
|
||||
func.count().filter(logout_ok).label("logout_count"),
|
||||
func.count().label("op_count"),
|
||||
# 上线/下线时间取「任意记录」的首末,而不是登录/登出的首末(原因见 docstring)
|
||||
func.min(AuditLog.created_at).label("first_active_at"),
|
||||
func.max(AuditLog.created_at).label("last_active_at"),
|
||||
)
|
||||
.where(
|
||||
AuditLog.created_at >= start,
|
||||
AuditLog.created_at < end,
|
||||
# 只统计"人":未认证请求(如登录前的探测、refresh)没有操作人,
|
||||
# 混进来会让"日活人数"虚高。若要排查匿名异常流量,走日志列表页按
|
||||
# 结果/来源 IP 过滤更合适。
|
||||
AuditLog.user_id.isnot(None),
|
||||
)
|
||||
.group_by(day_col, AuditLog.user_id)
|
||||
.order_by(day_col.desc(), func.count().desc())
|
||||
)
|
||||
|
||||
rows = (await db.execute(stmt)).all()
|
||||
|
||||
# ── 活动表:当天首次/末次活动(覆盖"只翻看不操作"的人)──
|
||||
# day 列是北京时间 DATE,与上面的 day_col 口径一致,可直接按 (user_id, day) 对齐。
|
||||
# 取 start.date() ~ end.date()(end 是次日 00:00 的半开上界,故用 <)。
|
||||
seen_rows = (
|
||||
await db.execute(
|
||||
select(
|
||||
UserDailySeen.user_id, UserDailySeen.day,
|
||||
UserDailySeen.first_seen_at, UserDailySeen.last_seen_at,
|
||||
).where(
|
||||
UserDailySeen.day >= start.date(),
|
||||
UserDailySeen.day < end.date(),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
seen = {
|
||||
(s.user_id, s.day.strftime("%Y-%m-%d")): (s.first_seen_at, s.last_seen_at)
|
||||
for s in seen_rows
|
||||
}
|
||||
|
||||
audit = {
|
||||
(r.user_id, r.day.strftime("%Y-%m-%d") if hasattr(r.day, "strftime") else str(r.day)): r
|
||||
for r in rows
|
||||
}
|
||||
|
||||
# ── 合并 ──
|
||||
# 并集:只有审计记录的人(本表上线前的历史数据)和只有活动记录的人
|
||||
# (当天只翻看、没做写操作)都要出现,各自缺的部分留空/计 0。
|
||||
items: list[dict] = []
|
||||
for key in set(audit) | set(seen):
|
||||
user_id, day = key
|
||||
a = audit.get(key)
|
||||
first_seen, last_seen = seen.get(key, (None, None))
|
||||
|
||||
# 取「两者的最早/最晚」,而不是简单地"活动表优先":
|
||||
# 活动表靠请求触发且有 2 分钟节流,极端情况(跨零点被节流、
|
||||
# 打点写库失败被吞掉)可能晚于当天第一次写操作。
|
||||
# 取 min/max 后,结果永远不会比任一来源更差,也不需要为兜底写分支逻辑。
|
||||
audit_first = a.first_active_at if a else None
|
||||
audit_last = a.last_active_at if a else None
|
||||
first_candidates = [t for t in (first_seen, audit_first) if t is not None]
|
||||
last_candidates = [t for t in (last_seen, audit_last) if t is not None]
|
||||
|
||||
items.append({
|
||||
"day": day,
|
||||
"user_id": user_id,
|
||||
# 姓名字段只有审计记录里有(活动表为了轻量刻意不冗余存)
|
||||
"display_name": a.display_name if a else None,
|
||||
"role": a.role if a else None,
|
||||
"login_count": (a.login_count or 0) if a else 0,
|
||||
"logout_count": (a.logout_count or 0) if a else 0,
|
||||
"op_count": (a.op_count or 0) if a else 0,
|
||||
"first_active_at": min(first_candidates) if first_candidates else None,
|
||||
"last_active_at": max(last_candidates) if last_candidates else None,
|
||||
})
|
||||
|
||||
# 与 SQL 里的排序保持一致:日期倒序 → 操作次数倒序
|
||||
items.sort(key=lambda x: (x["day"], x["op_count"]), reverse=True)
|
||||
return items
|
||||
@ -1,5 +1,5 @@
|
||||
"""认证服务 — 对接 MOM 系统 sys_user 表 + Track 自有 JWT(双 Token 架构)"""
|
||||
from fastapi import HTTPException, status, Depends
|
||||
from fastapi import HTTPException, status, Depends, Request
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from jose import JWTError, jwt
|
||||
from werkzeug.security import check_password_hash
|
||||
@ -14,6 +14,8 @@ from app.core.security import (
|
||||
TOKEN_TYPE_REFRESH,
|
||||
)
|
||||
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()
|
||||
@ -23,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()
|
||||
|
||||
@ -112,6 +128,7 @@ def refresh_access_token(refresh_token: str) -> dict:
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
) -> dict:
|
||||
"""从 Bearer Token 解析当前用户(仅接受 Access Token)"""
|
||||
@ -129,6 +146,19 @@ async def get_current_user(
|
||||
detail="请使用 Access Token 访问 API,Refresh Token 仅用于刷新",
|
||||
)
|
||||
|
||||
# 操作人身份要写两处,用途不同,缺一不可:
|
||||
# 1) contextvar —— 供本请求任务内的业务/service 日志使用;
|
||||
# 2) request.state —— 中间件在独立 task 中执行(Starlette 的
|
||||
# BaseHTTPMiddleware 用 anyio start_soon 起新 task,而 asyncio
|
||||
# 每个 Task 会复制 context),因此中间件读不到路由内改的
|
||||
# contextvar,只能通过 ASGI scope 承载的 state 拿到。
|
||||
# username 即 assignee_id 口径,比数字 id 直观得多。
|
||||
user_label = payload.get("username") or user_id
|
||||
user_var.set(user_label)
|
||||
request.state.audit_user = user_label
|
||||
request.state.audit_display_name = payload.get("display_name") or ""
|
||||
request.state.audit_role = payload.get("role") or ""
|
||||
|
||||
return payload
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=401, detail="无效的 Token")
|
||||
|
||||
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())
|
||||
136
backend/app/services/mom_scrap_client.py
Normal file
136
backend/app/services/mom_scrap_client.py
Normal file
@ -0,0 +1,136 @@
|
||||
"""MOM 内部接口客户端 —— Track 主动调用 MOM 的**唯一**通道
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════
|
||||
为什么这里用 HTTP,而「读」却直连 MOM 库
|
||||
═══════════════════════════════════════════════════════════════════════════
|
||||
Track 读 MOM 一律走 `app/core/mom_database.py` 直连只读库(见 mom_outbound_service
|
||||
的模块头论证:MOM 的查询接口要 JWT + permission_required,且对非特权账号按
|
||||
`consumer_name` 做行级隔离,服务账号只能拿到自己名下的数据)。
|
||||
|
||||
但「写」不能直连库:跳过 MOM 的业务校验、权限与审批流,会写出 MOM 自己都不认的数据。
|
||||
所以走 MOM 为此新开的内部接口(X-API-Key 鉴权,不走 JWT —— Track 没有也不需要
|
||||
MOM 账号,申请人身份由请求体显式携带)。
|
||||
|
||||
⚠️ 别因为有了本模块就把「读」也搬过来。两条路各有各的理由,不要合并。
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════
|
||||
失败语义(对用户要诚实)
|
||||
═══════════════════════════════════════════════════════════════════════════
|
||||
报废是**写**操作,静默失败最伤人 —— 用户以为报上去了,MOM 里其实什么都没有。
|
||||
所以这里不吞任何错误:连不上、鉴权失败、被 MOM 拒绝,都以带中文原因的形式抛出去,
|
||||
由端点转成用户看得懂的提示。
|
||||
"""
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 超时:MOM 侧要做「退回 + 建报废申请」两次写库,给宽一点。
|
||||
# 但也不能无限等 —— 请求挂住时用户会一直转圈,宁可失败让他重试(有幂等兜底)。
|
||||
_TIMEOUT = httpx.Timeout(30.0, connect=10.0)
|
||||
|
||||
_PATH = "/api/v1/internal/production-scrap"
|
||||
|
||||
|
||||
class MomScrapError(Exception):
|
||||
"""调 MOM 报废接口失败。
|
||||
|
||||
message 是**给用户看的中文原因**,端点直接把它转成响应 detail,
|
||||
不要再包一层「报废失败: ...」——
|
||||
MOM 返回的文案本身已经说清了(如「退回数量(9999)超出可退额度(5)」)。
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, *, mom_status_code: int | None = None,
|
||||
mom_code: int | None = None):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.mom_status_code = mom_status_code
|
||||
self.mom_code = mom_code
|
||||
|
||||
|
||||
async def submit_production_scrap(*, outbound_id: int, return_qty: float,
|
||||
track_ref: str, applicant_id: int,
|
||||
reason: str | None = None,
|
||||
operator: str = "Track系统") -> dict:
|
||||
"""提交生产报废 → MOM 的 `POST /api/v1/internal/production-scrap`。
|
||||
|
||||
一次调用完成「退回(不良品) → 在管不良品 → 提交报废申请(待审批)」。
|
||||
返回 MOM 的 `data` 段(含 `scrap_request_no` / `defective_goods_id` / `duplicate`)。
|
||||
|
||||
:param outbound_id: MOM `trans_outbound.id`,即 Track 侧的 `mom_line_id`
|
||||
:param track_ref: Track 侧生成的唯一单据号(幂等锚点),重试必须传同一个
|
||||
:param applicant_id: MOM `sys_user.id`。Track 的 `user.sub` 就是它,
|
||||
所以 MOM 里显示的申请人就是**实际操作人本人**,不是服务账号
|
||||
"""
|
||||
api_key = (settings.MOM_INTERNAL_API_KEY or "").strip()
|
||||
if not api_key:
|
||||
# Fail-Closed:不静默降级成「假装成功」
|
||||
raise MomScrapError(
|
||||
"报废功能未启用:Track 未配置 MOM_INTERNAL_API_KEY,请联系管理员"
|
||||
)
|
||||
|
||||
base_url = (settings.MOM_INTERNAL_API_URL or "").rstrip("/")
|
||||
if not base_url:
|
||||
raise MomScrapError("报废功能未启用:Track 未配置 MOM_INTERNAL_API_URL")
|
||||
|
||||
payload = {
|
||||
# 公司 = 部门。MOM 会拿它跟出库物料实际所属公司强校验,不符直接拒绝。
|
||||
'company_name': settings.ORG_DEPARTMENT,
|
||||
'outbound_id': int(outbound_id),
|
||||
'return_qty': float(return_qty),
|
||||
# ★ 恒为 True:本流程 = 退回并提交报废申请。
|
||||
# false 那个分支(只登记为在管不良品)留给以后按需开放。
|
||||
'submit_scrap': True,
|
||||
'track_ref': track_ref,
|
||||
# 生产损耗。分类**必须显式传**,不能让 MOM 从来源推导 ——
|
||||
# 生产报废与 MOM 手工报的不良品退回共用同一张 trans_defective_goods 表,
|
||||
# 一推导就会把生产损失静默算成库存损失。
|
||||
'reason_category': 'PRODUCTION',
|
||||
'reason': (reason or '').strip() or None,
|
||||
'applicant_id': int(applicant_id),
|
||||
'operator': operator,
|
||||
}
|
||||
|
||||
url = f"{base_url}{_PATH}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
resp = await client.post(url, json=payload, headers={'X-API-Key': api_key})
|
||||
except httpx.TimeoutException:
|
||||
logger.warning(f"[MomScrap] 调用 MOM 超时 url={url} track_ref={track_ref}")
|
||||
raise MomScrapError("提交报废超时:MOM 未在 30 秒内响应,请稍后用同一单据重试")
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"[MomScrap] 连接 MOM 失败 url={url}: {e}")
|
||||
raise MomScrapError(f"无法连接 MOM 报废接口:{e}")
|
||||
|
||||
# MOM 统一信封 {code, msg, data};非 JSON 响应说明打到了别的东西(如 nginx 错误页)
|
||||
try:
|
||||
body = resp.json()
|
||||
except ValueError:
|
||||
logger.error(f"[MomScrap] MOM 返回非 JSON(HTTP {resp.status_code}):{resp.text[:200]}")
|
||||
raise MomScrapError(f"MOM 报废接口返回异常(HTTP {resp.status_code})")
|
||||
|
||||
mom_code = body.get('code')
|
||||
mom_msg = (body.get('msg') or '').strip()
|
||||
|
||||
if resp.status_code != 200 or mom_code != 200:
|
||||
# MOM 的文案本身就是中文且具体(含数量、额度等),直接透传,
|
||||
# 不要在前面再加一层「报废失败:」,那只会把真正的信息挤到后面。
|
||||
logger.warning(
|
||||
f"[MomScrap] MOM 拒绝 HTTP {resp.status_code} code={mom_code} "
|
||||
f"track_ref={track_ref}: {mom_msg}"
|
||||
)
|
||||
raise MomScrapError(
|
||||
mom_msg or f"MOM 报废接口返回 HTTP {resp.status_code}",
|
||||
mom_status_code=resp.status_code, mom_code=mom_code,
|
||||
)
|
||||
|
||||
data = body.get('data') or {}
|
||||
logger.info(
|
||||
f"[MomScrap] 受理成功 track_ref={track_ref} outbound={outbound_id} "
|
||||
f"qty={return_qty} duplicate={data.get('duplicate')} "
|
||||
f"request_no={(data.get('scrap') or {}).get('request_no')}"
|
||||
)
|
||||
return data
|
||||
159
backend/app/services/mom_scrap_service.py
Normal file
159
backend/app/services/mom_scrap_service.py
Normal file
@ -0,0 +1,159 @@
|
||||
"""MOM 报废单只读查询 — 直连 MOM 库(跨库,无 ORM)
|
||||
|
||||
Track 侧只存**回执号**(`product_scraps.scrap_request_no`),状态与金额一律实时
|
||||
回查 MOM。为什么不落一份到 Track:
|
||||
|
||||
· 状态会变。MOM 里审批、执行之后 Track 不会收到通知(报废没有回调),
|
||||
本地存的那份立刻就过期,而「到底批没批、执行没执行」正是用户要看的。
|
||||
· 金额**取决于执行时的实际扫码量**。MOM 允许少扫(合法子集),
|
||||
所以「受理量」≠「执行量」,金额必须在 MOM 执行那一刻才算得准。
|
||||
本地另算一份就是第二份口径,迟早对不上。
|
||||
|
||||
写法照 mom_outbound_service(同步 psycopg2 + text() + MomSessionLocal),
|
||||
调用方一律 `run_in_threadpool` 包出去,别阻塞事件循环。
|
||||
"""
|
||||
import logging
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.mom_database import MomSessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# MOM scrap_approval.status → 中文。与 MOM 的 scrap_approval_service 逐字对齐,
|
||||
# 不要自创说法(用户在 MOM 界面看到的和 Track 上看的不一致会让人怀疑数据错了)。
|
||||
MOM_SCRAP_STATUS_LABELS = {
|
||||
0: '待审批',
|
||||
1: '已通过(待执行)',
|
||||
2: '已驳回',
|
||||
3: '已执行',
|
||||
4: '已撤回',
|
||||
}
|
||||
|
||||
|
||||
def describe_status(code) -> str:
|
||||
"""状态码 → 中文。未知/空值返回空串(前端兜底显示 '-')。"""
|
||||
if code is None:
|
||||
return ''
|
||||
try:
|
||||
return MOM_SCRAP_STATUS_LABELS.get(int(code), '')
|
||||
except (TypeError, ValueError):
|
||||
return ''
|
||||
|
||||
|
||||
def fetch_scrap_status(request_nos: list[str]) -> dict[str, dict]:
|
||||
"""按报废申请单号批量查 MOM 的审批状态与执行金额。
|
||||
|
||||
返回 `{request_no: {...}}`;查不到的**不出现在结果里**(调用方按「缺失 = MOM
|
||||
侧还没有/已清理」处理,不要伪造成一个空状态)。
|
||||
|
||||
金额字段说明(都是 Decimal → float):
|
||||
· `cost_at_scrap` 报废成本(单价)
|
||||
· `total_loss` **报废损失 = 单价 × 实报废数量**,这是「统计生产报废金额」要的数
|
||||
· `scrapped_quantity` 实际报废数量(执行时扫码量,可能小于受理量)
|
||||
|
||||
⚠️ 单号可能一张都没执行(还在待审批),此时 trans_scrap 里**毫无痕迹**,
|
||||
金额是 0 而不是「缺失」—— 用 `executed` 标志区分这两种情况,
|
||||
别让前端把「还没批」显示成「损失 0 元」。
|
||||
"""
|
||||
nos = [str(n).strip() for n in (request_nos or []) if str(n or '').strip()]
|
||||
if not nos:
|
||||
return {}
|
||||
|
||||
db = MomSessionLocal()
|
||||
try:
|
||||
# ---- 1. 申请单头:状态 / 审批人 / 执行人 ----
|
||||
head_rows = db.execute(
|
||||
text("""
|
||||
SELECT request_no, status, applicant_id,
|
||||
actual_approver_id, approved_at,
|
||||
executor_name, executed_at, reject_reason,
|
||||
remark, reason_category
|
||||
FROM scrap_approval
|
||||
WHERE request_no = ANY(:nos)
|
||||
"""),
|
||||
{"nos": nos},
|
||||
).fetchall()
|
||||
|
||||
out: dict[str, dict] = {}
|
||||
for r in head_rows:
|
||||
status = r.status
|
||||
out[r.request_no] = {
|
||||
'request_no': r.request_no,
|
||||
'status': status,
|
||||
'status_label': describe_status(status),
|
||||
'applicant_id': r.applicant_id,
|
||||
'approver_name': _user_name(db, r.actual_approver_id),
|
||||
'approved_at': _iso(r.approved_at),
|
||||
'executor_name': r.executor_name or '',
|
||||
'executed_at': _iso(r.executed_at),
|
||||
'reject_reason': r.reject_reason or '',
|
||||
'reason_category': r.reason_category or '',
|
||||
# 只有 status==3 才真的执行过、台账里才有金额
|
||||
'executed': status == 3,
|
||||
'cost_at_scrap': None,
|
||||
'total_loss': None,
|
||||
'scrapped_quantity': None,
|
||||
}
|
||||
|
||||
# ---- 2. 报废流水:实际报废量与损失金额(只有执行过才有行) ----
|
||||
if out:
|
||||
ledger_rows = db.execute(
|
||||
text("""
|
||||
SELECT scrap_request_no,
|
||||
count(*) AS line_count,
|
||||
sum(quantity) AS scrapped_quantity,
|
||||
max(cost_at_scrap) AS cost_at_scrap,
|
||||
sum(total_loss) AS total_loss
|
||||
FROM trans_scrap
|
||||
WHERE scrap_request_no = ANY(:nos)
|
||||
GROUP BY scrap_request_no
|
||||
"""),
|
||||
{"nos": list(out.keys())},
|
||||
).fetchall()
|
||||
for r in ledger_rows:
|
||||
item = out.get(r.scrap_request_no)
|
||||
if item is None:
|
||||
continue
|
||||
item['scrapped_quantity'] = _f(r.scrapped_quantity)
|
||||
item['cost_at_scrap'] = _f(r.cost_at_scrap)
|
||||
item['total_loss'] = _f(r.total_loss)
|
||||
|
||||
return out
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _f(v):
|
||||
"""Decimal/None → float/None(JSON 友好)"""
|
||||
return float(v) if v is not None else None
|
||||
|
||||
|
||||
def _iso(dt):
|
||||
"""MOM 的时间列是 naive 北京时间,补 +08:00 再交给上层。
|
||||
|
||||
⚠️ 少这一步,前端会按**本地时区**解释这个 naive 串,非北京时区的人看到的
|
||||
时间就是错的。与 mom_outbound_service._as_beijing 同一处理。
|
||||
"""
|
||||
if dt is None:
|
||||
return None
|
||||
from datetime import timedelta, timezone
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone(timedelta(hours=8)))
|
||||
return dt.isoformat()
|
||||
|
||||
|
||||
def _user_name(db, user_id):
|
||||
"""MOM sys_user.id → 中文姓名('张三/zhangsan01' 取 '/' 前那段)。"""
|
||||
if not user_id:
|
||||
return ''
|
||||
try:
|
||||
row = db.execute(
|
||||
text("SELECT username FROM sys_user WHERE id = :uid"),
|
||||
{"uid": int(user_id)},
|
||||
).fetchone()
|
||||
except Exception:
|
||||
return ''
|
||||
if not row or not row.username:
|
||||
return ''
|
||||
return row.username.split('/')[0] if '/' in row.username else row.username
|
||||
356
backend/app/services/product_outbound_material_service.py
Normal file
356
backend/app/services/product_outbound_material_service.py
Normal file
@ -0,0 +1,356 @@
|
||||
"""设备出库明细 — 业务逻辑层(合并后的唯一入口)
|
||||
|
||||
「这台设备对应 MOM 的哪些出库单、领了哪些料」在本模块只有一个概念、
|
||||
一张表(`product_outbound_materials`)、一组函数。原先那两张表
|
||||
(`product_outbounds` 单据级 / `task_outbound_materials` 明细级)已停止写入,
|
||||
只作回滚备份保留 —— 详见该模型的模块注释。
|
||||
|
||||
═══ 两条写入路径 ═══
|
||||
· `link_outbound_lines` —— 人在界面上挂(网页端/移动端选 MOM 出库单)
|
||||
→ source='manual',可删
|
||||
· `archive_from_webhook` —— MOM 出库回调自动存档(按 SN 匹配到设备)
|
||||
→ source='webhook',系统事实,不可删(要撤得去 MOM 撤回)
|
||||
|
||||
两条路径共用同一张表、同一套幂等约束,但**来源不同、删除规则不同** ——
|
||||
这是它们唯一的差别,`source` 列把它记下来。
|
||||
"""
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.product import Product
|
||||
from app.models.product_outbound_material import ProductOutboundMaterial
|
||||
from app.schemas.product import ProductOutboundMaterialResponse
|
||||
from app.services import mom_outbound_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def list_product_materials(
|
||||
db: AsyncSession, product_id: uuid.UUID,
|
||||
) -> list[ProductOutboundMaterialResponse]:
|
||||
"""列出某设备挂载的全部出库明细。
|
||||
|
||||
排序:先按 MOM 出库时间倒序,没有时间的沉底,再按写入时间兜底 ——
|
||||
避免 outbound_time 为空的行插在最前面。
|
||||
"""
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(ProductOutboundMaterial)
|
||||
.where(ProductOutboundMaterial.product_id == product_id)
|
||||
.order_by(
|
||||
ProductOutboundMaterial.outbound_time.desc().nullslast(),
|
||||
ProductOutboundMaterial.created_at.desc(),
|
||||
ProductOutboundMaterial.id.desc(),
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
items = [ProductOutboundMaterialResponse.model_validate(r) for r in rows]
|
||||
fill_added_by_names(items)
|
||||
return items
|
||||
|
||||
|
||||
def fill_added_by_names(
|
||||
items: list[ProductOutboundMaterialResponse],
|
||||
) -> list[ProductOutboundMaterialResponse]:
|
||||
"""就地补上 `added_by_name`(谁挂上去的,中文姓名),并**返回同一个列表**。
|
||||
|
||||
返回列表是为了能写成 `fill(x)` 直接当值用 —— 就地修改却返回 None 的话,
|
||||
`fill(x) or []` 会静默变成空数组(写这行时就差点踩到)。
|
||||
|
||||
界面要显示「谁挂的」——`added_by` 存的是 Track 用户名(如 `zhangsan01`),
|
||||
直接摆出来现场看不懂。解析交给服务端:移动端与网页端各抄一份 username→姓名
|
||||
的映射迟早会漂移,而且 MOM 的 username 是「姓名/账号」格式,规则不止一条。
|
||||
|
||||
⚠️ 解析失败**不能影响列表**:MOM 连不上时姓名降级为空串,前端回落到显示
|
||||
用户名。查一次是批量 SQL(mom_cache 还带 2h TTL),不给每行单独打库。
|
||||
"""
|
||||
from app.services.mom_cache import get_display_names
|
||||
|
||||
names = [i.added_by for i in items if i.added_by]
|
||||
if not names:
|
||||
return items
|
||||
try:
|
||||
mapping = get_display_names(list(dict.fromkeys(names)))
|
||||
except Exception as e:
|
||||
logger.warning(f"[OutboundMaterial] 解析挂载人姓名失败,降级显示用户名: {e}")
|
||||
return items
|
||||
for i in items:
|
||||
if i.added_by:
|
||||
i.added_by_name = mapping.get(i.added_by, "")
|
||||
return items
|
||||
|
||||
|
||||
async def link_outbound_lines(
|
||||
db: AsyncSession, product: Product, mom_line_ids: list[int],
|
||||
*, task_id: uuid.UUID | None = None, added_by: str | None = None,
|
||||
) -> int:
|
||||
"""把 MOM 出库**明细行**挂到设备上(人工路径)。返回实际新增行数。
|
||||
|
||||
用户勾的是**整张出库单**,提交时把该单全部明细行 id 一起带过来 ——
|
||||
本表按明细行成行,所以一张单会展开成 N 行。
|
||||
|
||||
⚠️ 只接受 `mom_line_ids`,物料快照一律由后端拿 id 去 MOM 现查,
|
||||
否则前端可以伪造「挂的是什么」。
|
||||
⚠️ MOM 查询是同步 psycopg2,用 run_in_threadpool 扔出去,别阻塞事件循环。
|
||||
⚠️ 调用方负责 commit —— 本函数只 flush,好让挂载与产品创建同事务。
|
||||
|
||||
幂等:已挂过的明细跳过(部分唯一索引兜底)。
|
||||
"""
|
||||
ids = list(dict.fromkeys(int(i) for i in mom_line_ids)) # 去重且保持顺序
|
||||
if not ids:
|
||||
return 0
|
||||
|
||||
lines = await run_in_threadpool(mom_outbound_service.get_lines_by_ids, ids)
|
||||
if not lines:
|
||||
# MOM 侧查不到(数据被清理 / ID 传错)—— 静默返回 0,由调用方比对数量
|
||||
return 0
|
||||
|
||||
existing = set(
|
||||
(
|
||||
await db.execute(
|
||||
select(ProductOutboundMaterial.mom_line_id).where(
|
||||
ProductOutboundMaterial.product_id == product.id,
|
||||
ProductOutboundMaterial.mom_line_id.in_(
|
||||
[ln["line_id"] for ln in lines]),
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
|
||||
added = 0
|
||||
for ln in lines:
|
||||
if ln["line_id"] in existing:
|
||||
continue
|
||||
db.add(ProductOutboundMaterial(
|
||||
product_id=product.id,
|
||||
serial_number=product.serial_number,
|
||||
task_id=task_id,
|
||||
mom_line_id=ln["line_id"],
|
||||
outbound_no=ln["outbound_no"],
|
||||
request_no=ln.get("request_no") or None,
|
||||
applicant_name=None, # 人工挂载拿不到申请人:MOM 的 request_id 存量全为 NULL
|
||||
remark=None,
|
||||
sku=ln.get("sku") or None,
|
||||
material_name=ln.get("material_name") or None,
|
||||
spec_model=ln.get("spec_model") or None,
|
||||
quantity=ln.get("quantity"),
|
||||
unit_price=ln.get("unit_price"),
|
||||
outbound_type=ln.get("outbound_type") or None,
|
||||
consumer_name=ln.get("consumer_name") or None,
|
||||
operator_name=ln.get("operator_name") or None,
|
||||
warehouse_location=ln.get("warehouse_location") or None,
|
||||
outbound_time=ln.get("outbound_time"),
|
||||
source="manual",
|
||||
added_by=added_by,
|
||||
))
|
||||
added += 1
|
||||
await db.flush()
|
||||
return added
|
||||
|
||||
|
||||
async def archive_from_webhook(
|
||||
db: AsyncSession, product: Product, payload, *, company_name: str | None = None,
|
||||
) -> bool:
|
||||
"""MOM 出库回调 → 存档到本表(webhook 路径)。返回是否有新写入。
|
||||
|
||||
按 `payload.outbound_no` 去 MOM **现查明细**,逐行落 —— 本表是明细级,
|
||||
只写一条单据级信息的话,这台设备上「领了什么料」就永远是空的。
|
||||
|
||||
⚠️ 查不到明细(MOM 数据被清理、或该单确实没有可解析的明细)时,
|
||||
退化成写**一行 `mom_line_id=NULL` 的存档**:宁可显示「有这张单但看不到明细」,
|
||||
也不要静默丢掉这张单 —— 用户会以为出库记录丢了。
|
||||
⚠️ 幂等:已有存档就跳过(webhook 可能因运维重放而重入)。
|
||||
"""
|
||||
outbound_no = (getattr(payload, "outbound_no", "") or "").strip()
|
||||
if not outbound_no:
|
||||
return False
|
||||
|
||||
# 已有该单的存档 → 不重复写。**按 outbound_no 判**(不是按明细行),
|
||||
# 因为一张单的所有明细必然一起写入,任一行存在即整单已存过。
|
||||
already = (
|
||||
await db.execute(
|
||||
select(ProductOutboundMaterial.id).where(
|
||||
ProductOutboundMaterial.product_id == product.id,
|
||||
ProductOutboundMaterial.outbound_no == outbound_no,
|
||||
).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if already is not None:
|
||||
return False
|
||||
|
||||
lines = await run_in_threadpool(
|
||||
mom_outbound_service.get_lines_by_outbound_no, outbound_no)
|
||||
|
||||
if not lines:
|
||||
# 退化成单据级存档:能看、能标撤回,但没有明细行 id,**不能报废**
|
||||
db.add(ProductOutboundMaterial(
|
||||
product_id=product.id,
|
||||
serial_number=product.serial_number,
|
||||
task_id=None,
|
||||
mom_line_id=None,
|
||||
outbound_no=outbound_no,
|
||||
request_no=getattr(payload, "request_no", None) or None,
|
||||
applicant_name=getattr(payload, "applicant_name", None) or None,
|
||||
remark=getattr(payload, "remark", None) or None,
|
||||
outbound_type=getattr(payload, "outbound_type", None) or None,
|
||||
consumer_name=getattr(payload, "consumer_name", None) or None,
|
||||
operator_name=(getattr(payload, "operator", None) or "")[:100] or None,
|
||||
outbound_time=getattr(payload, "outbound_time", None),
|
||||
source="webhook",
|
||||
))
|
||||
logger.info(f"[OutboundMaterial] {outbound_no} MOM 查不到明细,退化为单据级存档")
|
||||
return True
|
||||
|
||||
for ln in lines:
|
||||
db.add(ProductOutboundMaterial(
|
||||
product_id=product.id,
|
||||
serial_number=product.serial_number,
|
||||
task_id=None,
|
||||
mom_line_id=ln["line_id"],
|
||||
outbound_no=outbound_no,
|
||||
request_no=getattr(payload, "request_no", None) or None,
|
||||
applicant_name=getattr(payload, "applicant_name", None) or None,
|
||||
remark=getattr(payload, "remark", None) or None,
|
||||
sku=ln.get("sku") or None,
|
||||
material_name=ln.get("material_name") or None,
|
||||
spec_model=ln.get("spec_model") or None,
|
||||
quantity=ln.get("quantity"),
|
||||
unit_price=ln.get("unit_price"),
|
||||
outbound_type=(getattr(payload, "outbound_type", None)
|
||||
or ln.get("outbound_type") or None),
|
||||
consumer_name=(getattr(payload, "consumer_name", None)
|
||||
or ln.get("consumer_name") or None),
|
||||
operator_name=(getattr(payload, "operator", None)
|
||||
or ln.get("operator_name") or None),
|
||||
warehouse_location=ln.get("warehouse_location") or None,
|
||||
outbound_time=(getattr(payload, "outbound_time", None)
|
||||
or ln.get("outbound_time")),
|
||||
source="webhook",
|
||||
))
|
||||
return True
|
||||
|
||||
|
||||
async def mark_revoked(
|
||||
db: AsyncSession, product_id: uuid.UUID, outbound_no: str | None = None,
|
||||
) -> bool:
|
||||
"""MOM 撤回回调 → 把该单在本设备上的存档标为已撤回。返回是否有改动。
|
||||
|
||||
**只置位不删行** ——「出过又撤了」本身就是要看得见的历史。
|
||||
|
||||
:param outbound_no: 指定单号则只标那一单;为空则标**最近一条未撤回的**。
|
||||
MOM 的撤回载荷不保证带单号,而「最近一条未撤回的」是这批里最可能被撤的那张
|
||||
—— 一批里同一台设备理论上不该出现两条未撤回的出库单(出库后设备已不在
|
||||
仓库池,再出库匹配不到)。真出现时标错一条,也好过把历史全标脏。
|
||||
"""
|
||||
from app.core.time_utils import get_beijing_time
|
||||
|
||||
q = select(ProductOutboundMaterial).where(
|
||||
ProductOutboundMaterial.product_id == product_id,
|
||||
ProductOutboundMaterial.is_revoked.is_(False),
|
||||
)
|
||||
if outbound_no:
|
||||
q = q.where(ProductOutboundMaterial.outbound_no == outbound_no)
|
||||
rows = (await db.execute(q)).scalars().all()
|
||||
else:
|
||||
# 先定位到「哪一张单」,再把那张单的**全部明细行**一起标 ——
|
||||
# 只标一行的会让同一张单呈现「一半撤回一半没撤」的鬼状态
|
||||
latest = (
|
||||
await db.execute(
|
||||
q.order_by(
|
||||
ProductOutboundMaterial.outbound_time.desc().nullslast(),
|
||||
ProductOutboundMaterial.created_at.desc(),
|
||||
).limit(1)
|
||||
)
|
||||
).scalars().first()
|
||||
if latest is None:
|
||||
return False
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(ProductOutboundMaterial).where(
|
||||
ProductOutboundMaterial.product_id == product_id,
|
||||
ProductOutboundMaterial.outbound_no == latest.outbound_no,
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
changed = False
|
||||
for r in rows:
|
||||
if not r.is_revoked:
|
||||
r.is_revoked = True
|
||||
r.revoked_at = get_beijing_time()
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
async def remove_product_order(
|
||||
db: AsyncSession, product_id: uuid.UUID, outbound_no: str,
|
||||
) -> list[ProductOutboundMaterialResponse]:
|
||||
"""整张出库单一起摘掉(挂错了要能撤)。
|
||||
|
||||
界面上更容易碰到的是「这一单整个挂错了」,逐条删要删好几下,所以补这个。
|
||||
规则与逐条删**完全一致**:只要这张单在本设备上有一行是 `webhook`
|
||||
(MOM 回调自动存档的系统事实),整单就不给删 —— 要撤得去 MOM 撤回。
|
||||
这样「整单删」不会成为绕过单行规则的后门。
|
||||
"""
|
||||
no = (outbound_no or "").strip()
|
||||
if not no:
|
||||
raise HTTPException(status_code=400, detail="出库单号不能为空")
|
||||
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(ProductOutboundMaterial).where(
|
||||
ProductOutboundMaterial.product_id == product_id,
|
||||
ProductOutboundMaterial.outbound_no == no,
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
if not rows:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="这张出库单没有挂在这台设备上")
|
||||
if any(r.source != "manual" for r in rows):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="该出库单含 MOM 回调自动存档的记录,不能在 Track 里删除;如需撤销请在 MOM 中撤回",
|
||||
)
|
||||
|
||||
for r in rows:
|
||||
await db.delete(r)
|
||||
await db.commit()
|
||||
return await list_product_materials(db, product_id)
|
||||
|
||||
|
||||
async def remove_product_material(
|
||||
db: AsyncSession, product_id: uuid.UUID, material_id: int,
|
||||
) -> list[ProductOutboundMaterialResponse]:
|
||||
"""摘掉一条**人工挂载**的出库明细(挂错了要能撤)。
|
||||
|
||||
只允许删 `source='manual'`:webhook 存档是 MOM 出库回调留下的系统事实,
|
||||
删了 Track 与 MOM 就对不上(MOM 那边单还在)。要撤该去 MOM 撤回,
|
||||
由回调置 `is_revoked` 留痕。
|
||||
"""
|
||||
row = (
|
||||
await db.execute(
|
||||
select(ProductOutboundMaterial).where(
|
||||
ProductOutboundMaterial.id == material_id,
|
||||
ProductOutboundMaterial.product_id == product_id,
|
||||
)
|
||||
)
|
||||
).scalars().first()
|
||||
if row is None:
|
||||
# 不属于这台设备的一律按「查不到」处理,不泄漏别的设备挂了什么
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="这条出库明细没有挂在这台设备上")
|
||||
if row.source != "manual":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="该出库单由 MOM 回调自动存档,不能在 Track 里删除;如需撤销请在 MOM 中撤回",
|
||||
)
|
||||
|
||||
await db.delete(row)
|
||||
await db.commit()
|
||||
return await list_product_materials(db, product_id)
|
||||
248
backend/app/services/product_scrap_service.py
Normal file
248
backend/app/services/product_scrap_service.py
Normal file
@ -0,0 +1,248 @@
|
||||
"""生产报废 — Track 侧业务逻辑
|
||||
|
||||
用户在产品详情页看到这台设备领用的料,对某一条发起报废;Track 转调 MOM 的
|
||||
内部接口完成「退回(不良品) → 在管不良品 → 提交报废申请」,再把回执存下来。
|
||||
|
||||
═══ 授权模型(刻意的,不是漏掉的)═══
|
||||
**可见范围跟设备走,责任归属跟实际发生走。**
|
||||
|
||||
料是领给这台设备的,不是领给某个人的。一台设备会经历多个任务、多个人的手
|
||||
(生产领料 → 装配 → 测试)。测试时摔坏的外壳是生产的人领的、挂在生产任务下 ——
|
||||
如果只允许「原领用人」报废,测试得回头找生产的人来提单,而生产的人压根不知道
|
||||
这事,流程上讲不通。
|
||||
|
||||
所以:**任何能看到这台设备的人,都能报它上面任何一条料**。
|
||||
跨设备的防护不靠隐藏,靠 `_load_mounted_material` 的归属校验 ——
|
||||
`mom_line_id` 必须确实挂在这台设备上,报不了别的设备的料。
|
||||
滥报由 MOM 侧的主管审批兜底(谁报的、报了谁的料,审批页全看得到)。
|
||||
|
||||
前端对「报别人的料」加一道确认(判据是 consumer_name ≠ 当前用户),
|
||||
那是**防误操作的提示**,不是权限 —— 后端不会因为这条拒绝。
|
||||
"""
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.product import Product
|
||||
from app.models.product_scrap import ProductScrap
|
||||
from app.models.product_outbound_material import ProductOutboundMaterial
|
||||
from app.schemas.product import ProductScrapResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 生产报废恒用这个分类码(与 MOM 侧 scrap_approval.SCRAP_CATEGORY_LABELS 对齐)。
|
||||
# ★ 必须显式传、不能由 MOM 从来源推导:生产报废与 MOM 手工报的不良品退回共用
|
||||
# 同一张 trans_defective_goods 表,一推导就会把生产损失静默算成库存损失。
|
||||
SCRAP_CATEGORY_PRODUCTION = "PRODUCTION"
|
||||
|
||||
|
||||
def _source_ref(track_ref: str) -> str:
|
||||
"""幂等锚点:`<公司>:<Track单据号>`,与发给 MOM 的值同一口径。
|
||||
|
||||
带公司前缀是因为 IRIS 与 LICA 各自独立跑一套 Track,工单号可能重号。
|
||||
"""
|
||||
return f"{settings.ORG_DEPARTMENT}:{track_ref.strip()}"
|
||||
|
||||
|
||||
async def _load_mounted_material(
|
||||
db: AsyncSession, product_id: uuid.UUID, mom_line_id: int,
|
||||
) -> ProductOutboundMaterial:
|
||||
"""取出该设备上挂载的这条出库明细,顺带完成**归属校验**。
|
||||
|
||||
这是跨设备乱报的唯一防线:可见范围是整台设备,不能靠「查不到」来防,
|
||||
必须显式确认这条 `mom_line_id` 就挂在这台设备上。
|
||||
⚠️ 不校验的话,前端随便改个数就能报废任意一台设备的料。
|
||||
"""
|
||||
row = (
|
||||
await db.execute(
|
||||
select(ProductOutboundMaterial)
|
||||
.where(
|
||||
ProductOutboundMaterial.product_id == product_id,
|
||||
ProductOutboundMaterial.mom_line_id == mom_line_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
).scalars().first()
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="这条出库物料没有挂在这台设备上,无法报废",
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
async def list_product_scraps(
|
||||
db: AsyncSession, product_id: uuid.UUID,
|
||||
) -> list[ProductScrapResponse]:
|
||||
"""列出该产品的生产报废记录(按提交时间倒序),并**实时回查 MOM** 补状态与金额。"""
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(ProductScrap)
|
||||
.where(ProductScrap.product_id == product_id)
|
||||
.order_by(ProductScrap.created_at.desc())
|
||||
)
|
||||
).scalars().all()
|
||||
return await _enrich_with_mom(rows)
|
||||
|
||||
|
||||
async def _enrich_with_mom(rows: list[ProductScrap]) -> list[ProductScrapResponse]:
|
||||
"""把 MOM 的实时状态/金额贴到本地记录上。
|
||||
|
||||
★ 回查失败**不能让整个列表挂掉**:MOM 短暂不可用时,用户至少要能看到
|
||||
「我报过什么」,只是状态暂时显示不出来。所以这里 catch 住、降级成本地快照。
|
||||
"""
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
from app.services import mom_scrap_service
|
||||
|
||||
result: list[ProductScrapResponse] = []
|
||||
live: dict[str, dict] = {}
|
||||
if rows:
|
||||
try:
|
||||
live = await run_in_threadpool(
|
||||
mom_scrap_service.fetch_scrap_status,
|
||||
[r.scrap_request_no for r in rows],
|
||||
)
|
||||
except Exception as e:
|
||||
# 降级:用本地快照,并在日志里留痕(静默降级会让人以为 MOM 没执行)
|
||||
logger.warning(f"[ProductScrap] 回查 MOM 状态失败,降级用本地快照: {e}")
|
||||
|
||||
for r in rows:
|
||||
item = ProductScrapResponse.model_validate(r)
|
||||
info = live.get(r.scrap_request_no)
|
||||
if info:
|
||||
item.mom_status = info.get('status', r.mom_status)
|
||||
item.mom_status_label = info.get('status_label') or ''
|
||||
item.mom_approved_at = info.get('approved_at')
|
||||
item.mom_executor_name = info.get('executor_name') or ''
|
||||
item.mom_executed = bool(info.get('executed'))
|
||||
item.total_loss = info.get('total_loss')
|
||||
item.scrapped_quantity = info.get('scrapped_quantity')
|
||||
else:
|
||||
# MOM 里查不到这张单(被清理 / 回查失败降级)→ 用本地快照,
|
||||
# 但**不伪造金额**:total_loss 保持 None,前端显示「—」而不是 0
|
||||
item.mom_status = r.mom_status
|
||||
item.mom_status_label = mom_scrap_service.describe_status(r.mom_status)
|
||||
item.mom_executed = False
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
async def submit_product_scrap(
|
||||
db: AsyncSession, product_id: uuid.UUID, *, mom_line_id: int, quantity: float,
|
||||
track_ref: str, reason: str | None, current_user: dict,
|
||||
) -> ProductScrapResponse:
|
||||
"""提交一条生产报废。
|
||||
|
||||
幂等:同一个 `track_ref` 重发**不会**产生第二条 MOM 报废单,
|
||||
命中已有记录直接返回(网络超时后重试是常态,用户不该为这付两次代价)。
|
||||
"""
|
||||
track_ref = (track_ref or '').strip()
|
||||
if not track_ref:
|
||||
raise HTTPException(status_code=400, detail="track_ref 为必填(幂等锚点)")
|
||||
if not quantity or float(quantity) <= 0:
|
||||
raise HTTPException(status_code=400, detail="报废数量必须大于 0")
|
||||
|
||||
product = (
|
||||
await db.execute(select(Product).where(Product.id == product_id))
|
||||
).scalars().first()
|
||||
if product is None:
|
||||
raise HTTPException(status_code=404, detail="产品不存在")
|
||||
|
||||
source_ref = _source_ref(track_ref)
|
||||
|
||||
# ---- 1. 幂等:这个单据号已经受理过 → 直接回已有的那条 ----
|
||||
existing = (
|
||||
await db.execute(
|
||||
select(ProductScrap).where(ProductScrap.source_ref == source_ref)
|
||||
)
|
||||
).scalars().first()
|
||||
if existing is not None:
|
||||
logger.info(f"[ProductScrap] track_ref 重复提交,返回已有记录 {source_ref}")
|
||||
return (await _enrich_with_mom([existing]))[0]
|
||||
|
||||
# ---- 2. 归属校验 + 取快照(快照只信后端自己查到的,不信前端传的) ----
|
||||
material = await _load_mounted_material(db, product_id, int(mom_line_id))
|
||||
|
||||
# ---- 3. 申请人:当前登录人。Track 的 sub 就是 MOM sys_user.id,
|
||||
# 所以 MOM 里显示的申请人就是本人,不需要服务账号、也不会串人 ----
|
||||
try:
|
||||
applicant_id = int(current_user.get("sub"))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=401, detail="登录状态异常,请重新登录")
|
||||
|
||||
operator = current_user.get("display_name") or current_user.get("username") or "Track系统"
|
||||
|
||||
# ---- 4. 调 MOM(唯一的写通道,失败直接抛,不静默吞) ----
|
||||
from app.services.mom_scrap_client import MomScrapError, submit_production_scrap
|
||||
|
||||
try:
|
||||
data = await submit_production_scrap(
|
||||
outbound_id=int(mom_line_id),
|
||||
return_qty=float(quantity),
|
||||
track_ref=track_ref,
|
||||
applicant_id=applicant_id,
|
||||
reason=reason,
|
||||
operator=operator,
|
||||
)
|
||||
except MomScrapError as e:
|
||||
# MOM 的文案已经是中文且具体,直接转给用户
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=e.message)
|
||||
|
||||
scrap_info = data.get('scrap') or {}
|
||||
request_no = scrap_info.get('request_no')
|
||||
if not request_no:
|
||||
# MOM 回 200 却没给单号 = 契约被破坏,必须炸出来而不是存一条空记录
|
||||
logger.error(f"[ProductScrap] MOM 返回缺少 scrap.request_no: {data}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="MOM 已受理但未返回报废单号,请到 MOM 报废审批页确认",
|
||||
)
|
||||
|
||||
# ---- 5. 落库 ----
|
||||
row = ProductScrap(
|
||||
product_id=product_id,
|
||||
serial_number=product.serial_number,
|
||||
task_id=material.task_id,
|
||||
mom_line_id=int(mom_line_id),
|
||||
# 快照取自 Track 已挂的出库物料(当初由后端查 MOM 写入),不是前端传的
|
||||
outbound_no=material.outbound_no,
|
||||
material_name=material.material_name,
|
||||
spec_model=material.spec_model,
|
||||
sku=material.sku,
|
||||
consumer_name=material.consumer_name,
|
||||
quantity=quantity,
|
||||
reason_category=SCRAP_CATEGORY_PRODUCTION,
|
||||
reason=(reason or '').strip() or None,
|
||||
scrap_request_no=request_no,
|
||||
defective_goods_id=data.get('defective_goods_id'),
|
||||
mom_status=int(scrap_info.get('status') or 0),
|
||||
source_ref=source_ref,
|
||||
submitted_by=current_user.get("username"),
|
||||
)
|
||||
db.add(row)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
# 并发穿透了第 1 步的预检 —— 唯一约束兜底,回滚后返回已有那条
|
||||
await db.rollback()
|
||||
existing = (
|
||||
await db.execute(
|
||||
select(ProductScrap).where(ProductScrap.source_ref == source_ref)
|
||||
)
|
||||
).scalars().first()
|
||||
if existing is not None:
|
||||
return (await _enrich_with_mom([existing]))[0]
|
||||
raise
|
||||
|
||||
await db.refresh(row)
|
||||
logger.info(
|
||||
f"[ProductScrap] 提交成功 {request_no} product={product.serial_number} "
|
||||
f"line={mom_line_id} qty={quantity} by={row.submitted_by}"
|
||||
)
|
||||
return (await _enrich_with_mom([row]))[0]
|
||||
@ -15,10 +15,23 @@ from app.core.lifecycle import (
|
||||
sync_product_status,
|
||||
)
|
||||
from app.models.product import Product
|
||||
from app.models.product_outbound_material import ProductOutboundMaterial
|
||||
from app.models.production_order import ProductionOrder
|
||||
from app.models.task import Task
|
||||
from app.schemas.product import ProductCreate, ProductUpdate, ProductResponse, ProductScanResponse
|
||||
from app.schemas.task import TaskSummaryResponse, TaskResponse, TaskRecordResponse
|
||||
from app.schemas.product import (
|
||||
ProductCreate,
|
||||
ProductUpdate,
|
||||
ProductOutboundMaterialResponse,
|
||||
ProductResponse,
|
||||
ProductScanResponse,
|
||||
)
|
||||
from app.schemas.task import (
|
||||
TaskSummaryResponse,
|
||||
TaskResponse,
|
||||
TaskRecordResponse,
|
||||
)
|
||||
# 设备出库明细:扫码响应要附「谁挂上去的」中文名(见 fill_added_by_names)
|
||||
from app.services import product_outbound_material_service
|
||||
|
||||
|
||||
def _task_to_response(task: Task) -> TaskResponse:
|
||||
@ -51,6 +64,10 @@ def _task_to_response(task: Task) -> TaskResponse:
|
||||
child_tasks=[_task_to_response(c) for c in task.child_tasks],
|
||||
records=[TaskRecordResponse.model_validate(r) for r in (task.records or [])],
|
||||
created_by=getattr(task, "created_by", None),
|
||||
# ⚠️ 这里**不再**带 outbound_materials:物料已统一为**设备级**
|
||||
# (product_outbound_materials),挂在任务上只会变成第二个数据源 ——
|
||||
# 正是这次要消除的「同一件事两个地方」。设备出库明细看扫码响应的
|
||||
# `outbound_records`(已改为读新表)。
|
||||
)
|
||||
|
||||
|
||||
@ -272,6 +289,23 @@ async def get_product_by_serial(db: AsyncSession, serial_number: str) -> Product
|
||||
# 🔧 中文名映射(负责人 + 创建人,供前端显示"谁转入在库"等)
|
||||
assignee_names = _lookup_display_names(list(assignee_ids))
|
||||
|
||||
# 🔧 设备的 MOM 出库明细(统一后的唯一来源)—— 产品详情据此回答「这台设备
|
||||
# 对应 MOM 的哪些出库单、领了哪些料」。撤回的记录照常返回、由前端打
|
||||
# 「已撤回」标记,不在后端过滤掉:「出过又撤了」也是历史。
|
||||
# 排序:先按 MOM 记录的出库时间,没有的(旧数据/字段缺失)沉底,再按写入
|
||||
# 时间兜底 —— 避免 outbound_time 为空的行插在最前面。
|
||||
outbound_rows = (
|
||||
await db.execute(
|
||||
select(ProductOutboundMaterial)
|
||||
.where(ProductOutboundMaterial.product_id == product.id)
|
||||
.order_by(
|
||||
ProductOutboundMaterial.outbound_time.desc().nullslast(),
|
||||
ProductOutboundMaterial.created_at.desc(),
|
||||
ProductOutboundMaterial.id.desc(),
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
return ProductScanResponse(
|
||||
id=product.id,
|
||||
serial_number=product.serial_number,
|
||||
@ -294,9 +328,40 @@ async def get_product_by_serial(db: AsyncSession, serial_number: str) -> Product
|
||||
],
|
||||
task_tree=task_tree,
|
||||
assignee_names=assignee_names, # 🔧 username→中文姓名
|
||||
# ⚠️ Product 模型没有 to_dict(),本响应是逐字段手工构造的 —— 漏赋值不会
|
||||
# 报错,只会永远返回默认值(空列表)。
|
||||
# 附「谁挂上去的」中文名(服务端解析,两端共用;MOM 挂了就降级显示用户名)
|
||||
outbound_records=product_outbound_material_service.fill_added_by_names(
|
||||
[ProductOutboundMaterialResponse.model_validate(r) for r in outbound_rows]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _link_mom_outbound_orders(
|
||||
db: AsyncSession, product: Product, mom_line_ids: list[int],
|
||||
) -> int:
|
||||
"""建产品时勾选的 MOM 出库明细 —— 转交给统一后的设备出库明细服务。
|
||||
|
||||
合并后只有一张表(`product_outbound_materials`)、一套挂载逻辑。
|
||||
这里保留薄封装是因为「建产品时勾选」这条路仍然是产品发起的:
|
||||
存储、幂等、快照一律由那个服务负责 —— 不再有第二份实现。
|
||||
|
||||
返回实际新增行数。
|
||||
"""
|
||||
from app.services import product_outbound_material_service
|
||||
return await product_outbound_material_service.link_outbound_lines(
|
||||
db, product, mom_line_ids,
|
||||
)
|
||||
|
||||
|
||||
# 注:原先这里还有 get_product_materials / get_product_outbound_orders /
|
||||
# add_product_outbound_orders / remove_product_outbound_order 四个函数 ——
|
||||
# 它们服务的是「单据级」的 product_outbounds 与「任务级」的
|
||||
# task_outbound_materials。两张表已统一到 product_outbound_materials,
|
||||
# 读写一律走 product_outbound_material_service,故一并删除。
|
||||
# 旧表与其数据仍在库里(只停写),需要时可按迁移的 downgrade 回滚。
|
||||
|
||||
|
||||
async def get_product(db: AsyncSession, product_id: uuid.UUID) -> Product:
|
||||
"""获取产品,不存在则 404"""
|
||||
result = await db.execute(
|
||||
@ -349,6 +414,10 @@ async def create_product(db: AsyncSession, data: ProductCreate, creator_username
|
||||
current_location_id=creator_username or None, # 谁创建,初始位置就是谁
|
||||
)
|
||||
db.add(product)
|
||||
# 挂钩建档时选中的 MOM 出库单 —— 与产品**同事务**:产品建失败时不会留下
|
||||
# 孤立的挂载行。此前 commit 一次就够,现在多这一步在 commit 之前。
|
||||
if data.mom_line_ids:
|
||||
await _link_mom_outbound_orders(db, product, data.mom_line_ids)
|
||||
await db.commit()
|
||||
await db.refresh(product, ["order"])
|
||||
|
||||
|
||||
@ -3,7 +3,8 @@ from __future__ import annotations
|
||||
import json
|
||||
import uuid
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select, delete
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from sqlalchemy import select, delete, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@ -22,6 +23,7 @@ from app.core.lifecycle import (
|
||||
)
|
||||
from app.models.product import Product
|
||||
from app.models.task_log import TaskLog
|
||||
from app.core.roles import ADMIN_ROLES
|
||||
from app.schemas.task import (
|
||||
TaskCreate,
|
||||
TaskUpdate,
|
||||
@ -37,12 +39,12 @@ from app.schemas.task import (
|
||||
TaskSummaryResponse,
|
||||
TaskListResponse,
|
||||
)
|
||||
|
||||
# 特殊位置常量
|
||||
VIRTUAL_WAREHOUSE = "virtual_warehouse"
|
||||
|
||||
# 管理员/主管角色白名单 — 拥有上帝视角操作权限
|
||||
ADMIN_ROLES = {"SUPER_ADMIN", "SUPERVISOR"}
|
||||
# 定义已收敛到 app.core.roles(单一事实来源);本模块继续以同名导出,
|
||||
# 兼容 products.py 等处 `from app.services.task_service import ADMIN_ROLES` 的既有引用
|
||||
|
||||
|
||||
async def _recalc_product_location(
|
||||
@ -330,6 +332,10 @@ def _to_response(task: Task) -> TaskResponse:
|
||||
created_at=task.created_at,
|
||||
child_tasks=[_to_response(c) for c in task.child_tasks],
|
||||
records=[TaskRecordResponse.model_validate(r) for r in (task.records or [])],
|
||||
# ⚠️ 这里**不再**带 outbound_materials:物料已统一为**设备级**
|
||||
# (product_outbound_materials),挂在任务上只会变成第二个数据源 ——
|
||||
# 正是这次要消除的「同一件事两个地方」。设备出库明细走
|
||||
# GET /products/{id}/outbound-materials(扫码响应里也有)。
|
||||
)
|
||||
|
||||
|
||||
@ -407,9 +413,50 @@ async def get_top_level_tasks(db: AsyncSession, product_id: uuid.UUID) -> list[T
|
||||
return [TaskSummaryResponse.model_validate(t) for t in tasks]
|
||||
|
||||
|
||||
async def create_task(db: AsyncSession, data: TaskCreate) -> TaskResponse:
|
||||
"""创建任务,并同步产品宏观状态"""
|
||||
task = Task(**data.model_dump())
|
||||
# ============================================================
|
||||
# 任务挂载 MOM 出库物料
|
||||
# ============================================================
|
||||
|
||||
async def _mount_outbound_lines(
|
||||
db: AsyncSession,
|
||||
task: Task,
|
||||
mom_line_ids: list[int],
|
||||
operator_id: str | None,
|
||||
) -> int:
|
||||
"""建任务时勾选的 MOM 出库明细 —— 转交给统一后的设备出库明细服务。
|
||||
|
||||
合并后物料不再是「任务的」而是「**设备的**」,只有一张表
|
||||
(`product_outbound_materials`)、一套挂载逻辑。这里保留薄封装是因为
|
||||
「建任务时勾选」这条路仍然是任务发起的:任务 id 作为**溯源信息**传下去
|
||||
(这条料挂在哪条任务上),而存储、幂等、快照一律由那个服务负责 ——
|
||||
不再有第二份实现。
|
||||
|
||||
返回实际新增行数。
|
||||
"""
|
||||
from app.models.product import Product
|
||||
from app.services import product_outbound_material_service
|
||||
|
||||
product = await db.get(Product, task.product_id)
|
||||
if product is None:
|
||||
# 任务必然有产品(外键约束),走到这里说明数据被绕过改过。
|
||||
# 静默跳过:不能因为挂料失败而让整个建任务事务炸掉
|
||||
logger.warning(f"[Task] 任务 {task.id} 的产品不存在,跳过出库明细挂载")
|
||||
return 0
|
||||
return await product_outbound_material_service.link_outbound_lines(
|
||||
db, product, mom_line_ids, task_id=task.id, added_by=operator_id,
|
||||
)
|
||||
|
||||
|
||||
async def create_task(
|
||||
db: AsyncSession, data: TaskCreate, operator_id: str | None = None,
|
||||
) -> TaskResponse:
|
||||
"""创建任务,并同步产品宏观状态
|
||||
|
||||
operator_id: 创建人。用于记录是谁把出库物料挂上来的。
|
||||
"""
|
||||
# ⚠️ 必须 exclude 掉 mom_line_ids:它不是 Task 的列,展开进去会直接
|
||||
# TypeError('mom_line_ids' is an invalid keyword argument for Task)。
|
||||
task = Task(**data.model_dump(exclude={"mom_line_ids"}))
|
||||
db.add(task)
|
||||
|
||||
# 同步产品宏观状态 + 当前位置
|
||||
@ -436,11 +483,22 @@ async def create_task(db: AsyncSession, data: TaskCreate) -> TaskResponse:
|
||||
if data.assignee_id and data.assignee_id != VIRTUAL_WAREHOUSE:
|
||||
product.current_location_id = data.assignee_id
|
||||
|
||||
# 挂载出库物料 —— 放在所有校验之后、commit 之前,与任务**同事务**:
|
||||
# 校验失败时不会留下「任务没建成、物料却挂上了」的残留。
|
||||
if data.mom_line_ids:
|
||||
await _mount_outbound_lines(db, task, data.mom_line_ids, operator_id)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
return _to_response(task)
|
||||
|
||||
|
||||
# 注:原先这里有 add_task_outbound_materials / remove_task_outbound_material
|
||||
# (任务级增删出库物料)。物料统一为**设备级**后已删除 —— 挂载/删除一律走
|
||||
# product_outbound_material_service,界面上也只有「设备」一个维度,
|
||||
# 不再提供任务级的第二套读写入口。
|
||||
|
||||
|
||||
async def update_task(db: AsyncSession, task_id: uuid.UUID, data: TaskUpdate) -> TaskResponse:
|
||||
"""更新任务"""
|
||||
task = await _get_task_or_404(db, task_id)
|
||||
@ -457,22 +515,37 @@ async def get_all_tasks(
|
||||
assignee_id: str | None = None, skip: int = 0, limit: int = 50
|
||||
) -> TaskListResponse:
|
||||
"""获取任务列表,可按产品/负责人筛选"""
|
||||
stmt = select(Task).options(
|
||||
selectinload(Task.records),
|
||||
selectinload(Task.product),
|
||||
)
|
||||
filters = []
|
||||
if product_id:
|
||||
stmt = stmt.where(Task.product_id == product_id)
|
||||
filters.append(Task.product_id == product_id)
|
||||
if assignee_id:
|
||||
stmt = stmt.where(Task.assignee_id == assignee_id)
|
||||
stmt = stmt.offset(skip).limit(limit).order_by(Task.created_at.desc())
|
||||
filters.append(Task.assignee_id == assignee_id)
|
||||
|
||||
# 总数必须独立 COUNT:移动端「我的任务」用 total 判断 hasMore
|
||||
# (tasks.length < total),若 total 取当前页条数,首页满员时
|
||||
# hasMore 恒为 false,列表永远停在第一页。
|
||||
total = await db.scalar(
|
||||
select(func.count()).select_from(Task).where(*filters)
|
||||
) or 0
|
||||
|
||||
stmt = (
|
||||
select(Task)
|
||||
.options(
|
||||
selectinload(Task.records),
|
||||
selectinload(Task.product),
|
||||
)
|
||||
.where(*filters)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.order_by(Task.created_at.desc())
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
tasks = result.scalars().all()
|
||||
|
||||
# 返回扁平列表(不递归 children,避免 MissingGreenlet)
|
||||
flat_tasks = [_to_flat_response(t) for t in tasks]
|
||||
return TaskListResponse(tasks=flat_tasks, total=len(flat_tasks))
|
||||
return TaskListResponse(tasks=flat_tasks, total=total)
|
||||
|
||||
|
||||
# ============================================================
|
||||
|
||||
@ -96,6 +96,10 @@ async def load_task_tree_by_root(
|
||||
noload(Task.parent_task), # 组装树不需要 parent 引用
|
||||
selectinload(Task.records), # 🔥 一次性预加载所有进度记录
|
||||
selectinload(Task.product), # 🔥 一次性预加载产品引用
|
||||
# 🔥 预加载挂载的出库物料:扫码响应要带它(移动端「领用物料」靠它渲染)。
|
||||
# ★ 必须在这里预加载,不能等 _task_to_response 里现取 ——
|
||||
# 异步 session 下懒加载会抛 MissingGreenlet(本仓踩过的坑)。
|
||||
selectinload(Task.outbound_materials),
|
||||
)
|
||||
.where(Task.id.in_(select(task_tree_cte.c.id)))
|
||||
)
|
||||
@ -157,6 +161,8 @@ async def load_task_trees_by_product(
|
||||
noload(Task.parent_task),
|
||||
selectinload(Task.records),
|
||||
selectinload(Task.product),
|
||||
# 同上:扫码响应要带挂载的出库物料,必须预加载(懒加载会 MissingGreenlet)
|
||||
selectinload(Task.outbound_materials),
|
||||
)
|
||||
.where(Task.id.in_(select(task_tree_cte.c.id)))
|
||||
)
|
||||
|
||||
@ -51,6 +51,13 @@ services:
|
||||
MOM_DB_PORT: "5432"
|
||||
# 🚀 MOM 仓储系统回调 Webhook 验签 Key(与 MOM 侧 TRACK_WEBHOOK_KEY 保持一致)
|
||||
TRACK_WEBHOOK_KEY: 2ce5fedb48fde3fd7e0abf67472a5027b03e9ae6f19cf768
|
||||
# 🚀 MOM 内部接口 —— Track **主动调 MOM** 发起生产报废(唯一一处主动写 MOM)
|
||||
# ⚠️ 与上面的 TRACK_WEBHOOK_KEY 是**两把不同的钥匙**:
|
||||
# 上面那把是「MOM 发给 Track 时的验签凭证」,这把是「Track 发给 MOM 的凭证」,
|
||||
# 方向相反、权限不同(这把能发起报废审批),必须能独立轮换。
|
||||
# ⚠️ 未配置 → 报废提交直接 503,**不静默降级**(写操作静默失败最伤人)
|
||||
MOM_INTERNAL_API_URL: http://inventory_api:8000
|
||||
MOM_INTERNAL_API_KEY: ${MOM_INTERNAL_API_KEY:-}
|
||||
ports:
|
||||
- "8011:8000"
|
||||
volumes:
|
||||
|
||||
@ -41,6 +41,7 @@ const AdminProductsPage = lazy(() => import("./pages/admin/AdminProductsPage"));
|
||||
const AdminTasksPage = lazy(() => import("./pages/admin/AdminTasksPage"));
|
||||
const AdminPeoplePage = lazy(() => import("./pages/admin/AdminPeoplePage"));
|
||||
const AdminPrintConfigPage = lazy(() => import("./pages/admin/AdminPrintConfigPage"));
|
||||
const AdminAuditLogPage = lazy(() => import("./pages/admin/AdminAuditLogPage"));
|
||||
const AnalyticsDashboard = lazy(() => import("./pages/admin/AnalyticsDashboard"));
|
||||
const MatrixBoard = lazy(() => import("./pages/MatrixBoard"));
|
||||
const ScreenDashboard = lazy(() => import("./pages/admin/ScreenDashboard"));
|
||||
@ -79,6 +80,7 @@ export default function App() {
|
||||
<Route path="/admin/print-config" element={<AdminPrintConfigPage />} />
|
||||
<Route path="/admin/analytics" element={<AnalyticsDashboard />} />
|
||||
<Route path="/admin/matrix" element={<MatrixBoard />} />
|
||||
<Route path="/admin/audit" element={<AdminAuditLogPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
|
||||
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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
75
frontend/src/components/admin/ExportColumnsModal.tsx
Normal file
75
frontend/src/components/admin/ExportColumnsModal.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 导出列选择弹窗 —— 勾选要写进 CSV 的列。
|
||||
*
|
||||
* 列清单由后端 /audit/options 下发(value=后端列 key,label=中文表头),
|
||||
* 前端不硬编码表头:否则两端各维护一份,迟早出现「导出的列和页面对不上」。
|
||||
*
|
||||
* 默认全选 —— 大多数人只是想"全部导出来",不该逼他们先勾一遍。
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { Modal, Checkbox, Button } from "antd";
|
||||
import type { AuditOption } from "../../services/auditApi";
|
||||
|
||||
export default function ExportColumnsModal({
|
||||
open,
|
||||
columns,
|
||||
submitting,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
columns: AuditOption[];
|
||||
submitting?: boolean;
|
||||
onCancel: () => void;
|
||||
/** 传出当前勾选的列 key(顺序 = 后端下发顺序,保证表头稳定) */
|
||||
onConfirm: (keys: string[]) => void;
|
||||
}) {
|
||||
const [checked, setChecked] = useState<string[]>([]);
|
||||
|
||||
// 每次打开都重置为全选:上一次的勾选残留会让用户莫名少导几列
|
||||
useEffect(() => {
|
||||
if (open) setChecked(columns.map((c) => c.value));
|
||||
}, [open, columns]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="选择导出列"
|
||||
onCancel={onCancel}
|
||||
width={520}
|
||||
footer={
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2">
|
||||
<Button size="small" onClick={() => setChecked(columns.map((c) => c.value))}>
|
||||
全选
|
||||
</Button>
|
||||
<Button size="small" onClick={() => setChecked([])}>
|
||||
全不选
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={onCancel}>取消</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={submitting}
|
||||
disabled={checked.length === 0}
|
||||
onClick={() => onConfirm(checked)}
|
||||
>
|
||||
导出 ({checked.length} 列)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{checked.length === 0 && (
|
||||
<p className="mb-2 text-xs text-amber-600">至少勾选一列才能导出。</p>
|
||||
)}
|
||||
<Checkbox.Group
|
||||
value={checked}
|
||||
onChange={(v) => setChecked(v as string[])}
|
||||
className="grid grid-cols-3 gap-y-2"
|
||||
options={columns.map((c) => ({ value: c.value, label: c.label }))}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { NavLink, Outlet, useLocation, useNavigate, Navigate } from "react-router-dom";
|
||||
import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User, Users, BarChart3, Table2, Tv } from "lucide-react";
|
||||
import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User, Users, BarChart3, Table2, Tv, ScrollText } from "lucide-react";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
|
||||
const MENU = [
|
||||
@ -39,6 +39,12 @@ const MENU = [
|
||||
icon: Table2,
|
||||
description: "规格型号 × 人员/工序 在制品透视表",
|
||||
},
|
||||
{
|
||||
title: "操作审计",
|
||||
path: "/admin/audit",
|
||||
icon: ScrollText,
|
||||
description: "谁在何时操作了什么 · 含失败与被拒请求",
|
||||
},
|
||||
{
|
||||
title: "管理层大屏",
|
||||
path: "/admin/screen",
|
||||
@ -66,8 +72,10 @@ export default function AdminLayout() {
|
||||
return <Navigate to="/admin/login" replace />;
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
logout();
|
||||
async function handleLogout() {
|
||||
// 必须 await:logout() 要先完成审计上报再清 token,
|
||||
// 提前 navigate 会把请求掐断,退出就留不下痕
|
||||
await logout();
|
||||
navigate("/admin/login", { replace: true });
|
||||
}
|
||||
|
||||
|
||||
486
frontend/src/components/scan/OutboundRecordsCard.tsx
Normal file
486
frontend/src/components/scan/OutboundRecordsCard.tsx
Normal file
@ -0,0 +1,486 @@
|
||||
/** 出库单据卡 — 这台设备对应 MOM 的哪些出库单、领了哪些料
|
||||
*
|
||||
* ⚠️ 这里曾经是**两张卡**:一张「出库单据」(读 product_outbounds,单据级)
|
||||
* 加一张「领用物料」(读 task_outbound_materials,明细级)。它们本来就是
|
||||
* 同一件事,拆成两张的后果是:用户要面对两个入口、两个删除按钮,还会问
|
||||
* 「我在那边挂的怎么这边看不见」;更糟的是**单据级那张没有 mom_line_id,
|
||||
* 挂上去的料报不了废**。
|
||||
* 后端已把两张表合并成 product_outbound_materials,本组件随之合并成一张。
|
||||
* 不要因为「单据」和「物料」听起来不同就再拆开 —— 它们是同一件事。
|
||||
*
|
||||
* 展示形态(与移动端 pages/material/index.vue 保持一致):
|
||||
* 按**出库单号**分组,一行一张单,点开看明细。不按任务分组 ——
|
||||
* 任务名当分组抬头在现场看不懂,而且任务只是溯源信息。
|
||||
*
|
||||
* 已撤回的记录**照常显示**(灰底 + 删除线 + 「已撤回」),因为「出过又撤了」
|
||||
* 本身就是要看得见的历史 —— 后端也不过滤掉。
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Modal } from "antd";
|
||||
import { ChevronDown, ChevronRight, Loader2, Package, Plus, Trash2, Truck, Undo2 } from "lucide-react";
|
||||
|
||||
import MomOutboundPicker from "../admin/MomOutboundPicker";
|
||||
import { useToast } from "../ui/Toast";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import {
|
||||
getProductOutboundMaterials,
|
||||
listProductScraps,
|
||||
mountProductOutboundMaterials,
|
||||
removeProductOutboundMaterial,
|
||||
removeProductOutboundOrder,
|
||||
submitProductScrap,
|
||||
} from "../../services/productApi";
|
||||
import { extractErrorMessage } from "../../utils/errorMessage";
|
||||
import type { ProductOutboundMaterial, ProductScrap } from "../../types/api";
|
||||
|
||||
interface OutboundRecordsCardProps {
|
||||
productId: string;
|
||||
}
|
||||
|
||||
/** 时间 → 本地可读格式。MOM 的 outbound_time 缺失时回退到本行写入时间 */
|
||||
function fmtTime(iso: string | null, fallback: string): string {
|
||||
const d = new Date(iso || fallback);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** 幂等锚点:**打开弹窗时生成一次**,重试复用 —— 换新的会在 MOM 里多报一张单 */
|
||||
function makeTrackRef(): string {
|
||||
const d = new Date();
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
const ts = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
||||
return `SCRAP-${ts}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
/** 按出库单号分组,保持后端给的顺序(已按出库时间倒序) */
|
||||
function groupByOrder(list: ProductOutboundMaterial[]) {
|
||||
const map = new Map<string, ProductOutboundMaterial[]>();
|
||||
for (const m of list) {
|
||||
const no = m.outbound_no || "(无单号)";
|
||||
if (!map.has(no)) map.set(no, []);
|
||||
map.get(no)!.push(m);
|
||||
}
|
||||
return [...map.entries()];
|
||||
}
|
||||
|
||||
export default function OutboundRecordsCard({ productId }: OutboundRecordsCardProps) {
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [records, setRecords] = useState<ProductOutboundMaterial[] | null>(null);
|
||||
const [scraps, setScraps] = useState<ProductScrap[]>([]);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
// 挂载不需要先选任务:任务只是溯源信息,展示/报废/删除都按设备走。
|
||||
// 「谁挂上去的」记在 added_by、「谁出库的」由 MOM 快照带过来 —— 够了。
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [appending, setAppending] = useState(false);
|
||||
|
||||
const [removeTarget, setRemoveTarget] = useState<ProductOutboundMaterial | null>(null);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
// 整单删除:界面上更容易碰到的是「这一单整个挂错了」,逐条删要删好几下
|
||||
const [removeOrder, setRemoveOrder] = useState<string | null>(null);
|
||||
|
||||
const [scrapTarget, setScrapTarget] = useState<ProductOutboundMaterial | null>(null);
|
||||
const [scrapForm, setScrapForm] = useState({ quantity: "", reason: "", confirmed: false, trackRef: "" });
|
||||
const [scrapSubmitting, setScrapSubmitting] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [mats, sc] = await Promise.all([
|
||||
getProductOutboundMaterials(productId),
|
||||
listProductScraps(productId).catch(() => [] as ProductScrap[]),
|
||||
]);
|
||||
setRecords(mats);
|
||||
setScraps(sc);
|
||||
} catch (err) {
|
||||
toast(extractErrorMessage(err, "加载出库单据失败"), "error");
|
||||
setRecords([]); // 失败也要脱离加载态,否则一直转圈
|
||||
}
|
||||
}, [productId, toast]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const list = records ?? [];
|
||||
|
||||
async function handleAppend(momLineIds: number[]) {
|
||||
if (momLineIds.length === 0) {
|
||||
setPickerOpen(false);
|
||||
return;
|
||||
}
|
||||
setAppending(true);
|
||||
try {
|
||||
// 接口返回该设备当前**全部**出库明细,直接整体覆盖。
|
||||
// 不传 task_id —— 挂载不需要挂在某条任务上(任务只是溯源,可空)。
|
||||
setRecords(await mountProductOutboundMaterials(productId, momLineIds));
|
||||
toast("已添加出库单", "success");
|
||||
setPickerOpen(false);
|
||||
} catch (err) {
|
||||
toast(extractErrorMessage(err, "添加出库单失败"), "error");
|
||||
} finally {
|
||||
setAppending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function doRemoveOrder() {
|
||||
if (!removeOrder) return;
|
||||
setRemoving(true);
|
||||
try {
|
||||
setRecords(await removeProductOutboundOrder(productId, removeOrder));
|
||||
toast("已删除整张出库单", "success");
|
||||
setRemoveOrder(null);
|
||||
} catch (err) {
|
||||
toast(extractErrorMessage(err, "删除失败"), "error");
|
||||
} finally {
|
||||
setRemoving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function doRemove() {
|
||||
if (!removeTarget) return;
|
||||
setRemoving(true);
|
||||
try {
|
||||
setRecords(await removeProductOutboundMaterial(productId, removeTarget.id));
|
||||
toast("已删除", "success");
|
||||
setRemoveTarget(null);
|
||||
} catch (err) {
|
||||
toast(extractErrorMessage(err, "删除失败"), "error");
|
||||
} finally {
|
||||
setRemoving(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 报废 ----
|
||||
const myName = (user?.display_name ?? "").trim();
|
||||
const openScrap = (m: ProductOutboundMaterial) => {
|
||||
setScrapTarget(m);
|
||||
setScrapForm({
|
||||
quantity: String(m.quantity ?? ""),
|
||||
reason: "",
|
||||
confirmed: false,
|
||||
trackRef: makeTrackRef(), // 打开时生成一次,重试复用
|
||||
});
|
||||
};
|
||||
|
||||
// 代报判据:这条料的领用人不是当前登录人。
|
||||
// ⚠️ 这是**防误操作**不是权限 —— 料的归属是设备不是人,后端不会因此拒绝
|
||||
const isProxy = !!scrapTarget?.consumer_name && !!myName
|
||||
&& scrapTarget.consumer_name !== myName;
|
||||
|
||||
const doScrap = async () => {
|
||||
const m = scrapTarget;
|
||||
if (!m || m.mom_line_id == null) return;
|
||||
const qty = Number(scrapForm.quantity);
|
||||
if (!qty || qty <= 0) return toast("请填写报废数量", "error");
|
||||
if (m.quantity != null && qty > m.quantity) return toast(`不能超过 ${m.quantity}`, "error");
|
||||
if (isProxy && !scrapForm.confirmed) return toast("请先勾选确认代报", "error");
|
||||
|
||||
setScrapSubmitting(true);
|
||||
try {
|
||||
await submitProductScrap(productId, {
|
||||
mom_line_id: m.mom_line_id,
|
||||
quantity: qty,
|
||||
reason: scrapForm.reason.trim() || null,
|
||||
track_ref: scrapForm.trackRef,
|
||||
});
|
||||
toast("已提交,待主管审批", "success");
|
||||
setScrapTarget(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
// 不关弹窗、不换 trackRef:改完数量重试走的是同一个幂等键
|
||||
toast(extractErrorMessage(err, "报废提交失败"), "error");
|
||||
} finally {
|
||||
setScrapSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const groups = groupByOrder(list);
|
||||
const toggle = (no: string) => setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(no)) next.delete(no); else next.add(no);
|
||||
return next;
|
||||
});
|
||||
|
||||
// 直接开选择器 —— 不再先问「挂到哪条任务」。
|
||||
// 「谁挂上去的」记在 added_by、「谁出库的」由 MOM 快照带过来,已经够了。
|
||||
const openPicker = () => setPickerOpen(true);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-xl bg-white p-4 shadow-sm">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Truck className="h-5 w-5 text-blue-600" />
|
||||
<h3 className="font-semibold text-gray-800">出库单据</h3>
|
||||
{list.length > 0 && (
|
||||
<span className="text-xs text-gray-400">{groups.length} 张单 / {list.length} 条料</span>
|
||||
)}
|
||||
<button
|
||||
onClick={openPicker}
|
||||
className="ml-auto flex items-center gap-1 rounded-lg border border-blue-200 px-2.5 py-1 text-xs font-medium text-blue-600 transition-colors hover:bg-blue-50"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
添加出库单
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{records === null ? (
|
||||
<p className="flex items-center justify-center gap-2 py-4 text-xs text-gray-400">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />加载中…
|
||||
</p>
|
||||
) : list.length === 0 ? (
|
||||
<p className="py-4 text-center text-xs text-gray-400">
|
||||
暂无关联的出库单。建档时没挂、或本功能上线前出库的设备都属于这种情况,
|
||||
可用右上角「添加出库单」补挂。
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{groups.map(([no, items]) => {
|
||||
const open = expanded.has(no);
|
||||
const revoked = items[0]?.is_revoked;
|
||||
return (
|
||||
<div key={no} className={`overflow-hidden rounded-lg border ${revoked ? "border-gray-200 bg-gray-50" : "border-gray-100"}`}>
|
||||
<div onClick={() => toggle(no)} className="cursor-pointer px-2.5 py-2 transition-colors hover:bg-gray-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-mono text-sm font-medium break-all ${revoked ? "text-gray-400 line-through" : "text-gray-800"}`}>
|
||||
{no}
|
||||
</span>
|
||||
{/* 撤回标记:与后端「撤回只置位不删行」一致,不让它凭空消失 */}
|
||||
{revoked && (
|
||||
<span className="flex shrink-0 items-center gap-0.5 rounded-full bg-gray-200 px-2 py-0.5 text-[10px] font-bold text-gray-600">
|
||||
<Undo2 className="h-3 w-3" />已撤回
|
||||
</span>
|
||||
)}
|
||||
{/* 不展示出库类型(用途)—— 现场只关心「这台设备挂了哪张单、
|
||||
谁挂的、谁出的库」,多一个「内部领用」徽标只是噪音 */}
|
||||
<span className="ml-auto shrink-0 text-xs text-gray-400">
|
||||
{fmtTime(items[0]?.outbound_time ?? null, items[0]?.created_at ?? "")}
|
||||
</span>
|
||||
{/* 整单删除:挂错了要能一次摘掉。只在**全部**是人工挂的时显示 ——
|
||||
含 MOM 回调存档的单不给删(后端也拦),那是系统事实 */}
|
||||
{items.every((m) => m.source === "manual") && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setRemoveOrder(no); }}
|
||||
title="删除整张出库单"
|
||||
className="shrink-0 rounded p-1 text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-3.5 text-xs text-gray-400">
|
||||
{items[0]?.consumer_name && <span>领用 {items[0].consumer_name}</span>}
|
||||
{items[0]?.operator_name && <span>经办 {items[0].operator_name}</span>}
|
||||
{/* 谁挂上去的 —— 现场要能追责/问人;只记在库里不显示等于没记 */}
|
||||
{(items[0]?.added_by_name || items[0]?.added_by) && (
|
||||
<span className="text-gray-500">
|
||||
挂载 {items[0].added_by_name || items[0].added_by}
|
||||
</span>
|
||||
)}
|
||||
<span>{items.length} 条物料</span>
|
||||
<span className="ml-auto flex items-center gap-0.5 text-blue-600">
|
||||
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
{open ? "收起明细" : "物料明细"}
|
||||
</span>
|
||||
</div>
|
||||
{items[0]?.remark && <p className="mt-1 text-xs text-gray-400">{items[0].remark}</p>}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="divide-y divide-gray-50 border-t border-gray-100 px-2.5">
|
||||
{items.map((m) => {
|
||||
// 没有明细行 id = MOM 回调只存了单据、查不到明细 → 报不了废
|
||||
const canScrap = m.mom_line_id != null;
|
||||
return (
|
||||
<div key={m.id} className="flex items-center gap-2 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[13px] font-medium text-gray-800">
|
||||
{m.material_name || (canScrap ? "(未命名物料)" : "MOM 出库回调存档")}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-gray-400">
|
||||
{m.spec_model && <span>{m.spec_model} · </span>}
|
||||
{canScrap ? `×${m.quantity}` : "无明细"}
|
||||
{m.warehouse_location && <span> · 库位 {m.warehouse_location}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{/* 只有带 mom_line_id 的才能报废 —— MOM 要用它定位到具体明细行 */}
|
||||
{canScrap && (
|
||||
<button
|
||||
onClick={() => openScrap(m)}
|
||||
className="shrink-0 rounded-lg border border-red-200 bg-red-50 px-2.5 py-1 text-xs font-medium text-red-600 transition-colors hover:bg-red-100"
|
||||
>
|
||||
报废
|
||||
</button>
|
||||
)}
|
||||
{/* 只有人工挂的可删:webhook 存档是系统事实,要撤得去 MOM 撤回 */}
|
||||
{m.source === "manual" && (
|
||||
<button
|
||||
onClick={() => setRemoveTarget(m)}
|
||||
title="删除这条出库明细(挂错了)"
|
||||
className="shrink-0 rounded-lg border border-gray-200 p-1.5 text-gray-400 transition-colors hover:border-gray-300 hover:bg-gray-50 hover:text-gray-600"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ♻️ 报废记录:状态与金额由后端实时回查 MOM */}
|
||||
{scraps.length > 0 && (
|
||||
<div className="mt-3 border-t border-gray-100 pt-3">
|
||||
<p className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-gray-600">
|
||||
<Package className="h-3.5 w-3.5" />报废记录({scraps.length})
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{scraps.map((s) => (
|
||||
<div key={s.id} className="rounded-lg border border-gray-100 px-2.5 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-gray-800">
|
||||
{s.material_name || "(未命名物料)"}
|
||||
</span>
|
||||
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold ${
|
||||
s.mom_executed ? "bg-emerald-100 text-emerald-700"
|
||||
: (s.mom_status === 2 || s.mom_status === 4) ? "bg-gray-200 text-gray-600"
|
||||
: "bg-amber-100 text-amber-700"
|
||||
}`}>
|
||||
{s.mom_status_label || "状态未知"}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-gray-400">×{s.quantity}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-x-3.5 text-xs text-gray-400">
|
||||
<span>报废单 {s.scrap_request_no}</span>
|
||||
{s.submitted_by && <span>提交人 {s.submitted_by}</span>}
|
||||
{/* ★ 只有执行过才有金额。未执行显示「—」不显示 0 ——
|
||||
0 会让人以为「这东西不值钱」,其实是「还没扫码执行」 */}
|
||||
{s.mom_executed && (
|
||||
<span className="text-gray-600">损失 ¥{Number(s.total_loss ?? 0).toFixed(2)}</span>
|
||||
)}
|
||||
</div>
|
||||
{s.reason && <p className="mt-1 text-xs text-gray-400">{s.reason}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 选择器:已挂过的单据会在里面显示「已挂载」且不可再选。
|
||||
提交期间选择器保持打开(按钮转圈),成功后由 handleAppend 关闭 ——
|
||||
比先关弹窗再等结果更不容易让人以为没生效。 */}
|
||||
<MomOutboundPicker
|
||||
open={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onConfirm={(ids) => handleAppend(ids)}
|
||||
submitting={appending}
|
||||
existingOrderNos={list.map((m) => m.outbound_no)}
|
||||
/>
|
||||
|
||||
{/* 整单删除确认 */}
|
||||
<Modal
|
||||
open={!!removeOrder}
|
||||
title="删除整张出库单"
|
||||
centered
|
||||
onCancel={() => !removing && setRemoveOrder(null)}
|
||||
onOk={doRemoveOrder}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true, loading: removing }}
|
||||
cancelButtonProps={{ disabled: removing }}
|
||||
>
|
||||
<p className="text-sm text-gray-700">
|
||||
把出库单 <span className="font-mono font-medium">{removeOrder}</span> 从这台设备上整张摘掉?
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
只解除 Track 这边的挂载关系,<span className="font-medium">不会动 MOM 里的出库单本身</span>,
|
||||
已提交的报废记录也不受影响。
|
||||
</p>
|
||||
</Modal>
|
||||
|
||||
{/* 报废:数量 + 说明。分类不让人选 —— 走这条路的料按定义就是生产损耗 */}
|
||||
<Modal
|
||||
open={!!scrapTarget}
|
||||
title="报废"
|
||||
centered
|
||||
onCancel={() => !scrapSubmitting && setScrapTarget(null)}
|
||||
onOk={doScrap}
|
||||
okText="提交报废"
|
||||
cancelText="取消"
|
||||
confirmLoading={scrapSubmitting}
|
||||
okButtonProps={{ disabled: isProxy && !scrapForm.confirmed }}
|
||||
>
|
||||
<p className="text-sm text-gray-700">
|
||||
{scrapTarget?.material_name || "(未命名物料)"}
|
||||
<span className="ml-2 text-xs text-gray-400">
|
||||
{scrapTarget?.spec_model} | 原领用人 {scrapTarget?.consumer_name || "—"}
|
||||
</span>
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">
|
||||
报废数量 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
value={scrapForm.quantity}
|
||||
onChange={(e) => setScrapForm((f) => ({ ...f, quantity: e.target.value }))}
|
||||
placeholder={`最多 ${scrapTarget?.quantity ?? ""}`}
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-blue-400 focus:outline-none"
|
||||
disabled={scrapSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">原因说明</label>
|
||||
<textarea
|
||||
value={scrapForm.reason}
|
||||
onChange={(e) => setScrapForm((f) => ({ ...f, reason: e.target.value }))}
|
||||
placeholder="例如:测试时跌落,外壳磕裂"
|
||||
rows={3}
|
||||
maxLength={200}
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-blue-400 focus:outline-none"
|
||||
disabled={scrapSubmitting}
|
||||
/>
|
||||
</div>
|
||||
{/* 代报确认:报的不是自己领的料时多一道(防误操作,不是权限) */}
|
||||
{isProxy && (
|
||||
<label className="mt-3 flex cursor-pointer items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scrapForm.confirmed}
|
||||
onChange={(e) => setScrapForm((f) => ({ ...f, confirmed: e.target.checked }))}
|
||||
className="mt-0.5 accent-amber-600"
|
||||
/>
|
||||
<span className="text-xs text-amber-800">
|
||||
这条料不是你领的({scrapTarget?.consumer_name} 领用),确认代报?
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* 删除确认 */}
|
||||
<Modal
|
||||
open={!!removeTarget}
|
||||
title="删除出库明细"
|
||||
centered
|
||||
onCancel={() => !removing && setRemoveTarget(null)}
|
||||
onOk={doRemove}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true, loading: removing }}
|
||||
cancelButtonProps={{ disabled: removing }}
|
||||
>
|
||||
<p className="text-sm text-gray-700">
|
||||
把「{removeTarget?.material_name || "此物料"}」从这台设备上摘掉?
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
只解除 Track 这边的挂载关系,<span className="font-medium">不会动 MOM 里的出库单本身</span>,
|
||||
已提交的报废记录也不受影响。
|
||||
</p>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -3,6 +3,7 @@ import { Loader2, AlertCircle } from "lucide-react";
|
||||
import type { ProductScanResponse } from "../../types/api";
|
||||
import ProductCard from "./ProductCard";
|
||||
import TaskListCard from "./TaskListCard";
|
||||
import OutboundRecordsCard from "./OutboundRecordsCard";
|
||||
|
||||
interface QueryResultProps {
|
||||
loading: boolean;
|
||||
@ -33,6 +34,9 @@ export default function QueryResult({ loading, error, product }: QueryResultProp
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ProductCard product={product} />
|
||||
{/* 出库单据 —— 卡片按 productId 自取数据(与「产品管理 → 编辑产品」共用
|
||||
同一个组件)。无记录时显示空态 + 「追加出库单」入口。 */}
|
||||
<OutboundRecordsCard productId={product.id} />
|
||||
<TaskListCard tasks={product.task_tree || product.top_level_tasks} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -227,7 +227,8 @@ export function overallOptionsFor(
|
||||
|
||||
/** 列表筛选枚举 — 两阶段并集(用于筛选,不是录入项) */
|
||||
/**
|
||||
* 管理角色 — 必须与后端 task_service.ADMIN_ROLES 保持一致。
|
||||
* 管理角色 — 必须与后端 app/core/roles.py 的 ADMIN_ROLES 保持一致
|
||||
* (后端那份已从 task_service 收敛到 core.roles,是全项目唯一事实来源)。
|
||||
*
|
||||
* ⚠️ 收敛到这里的理由:此前这段判断散落在多处(TaskFlowView / AdminProductsPage),
|
||||
* 而移动端那份只判了 SUPER_ADMIN、漏了 SUPERVISOR,导致主管被前端误挡。
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
useEffect,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { login as loginApi, getMe } from "../services/authApi";
|
||||
import { login as loginApi, getMe, logout as logoutApi } from "../services/authApi";
|
||||
|
||||
// ============================================================
|
||||
// 类型
|
||||
@ -28,7 +28,8 @@ interface AuthState {
|
||||
|
||||
interface AuthContextValue extends AuthState {
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
/** 登出。async 是因为必须先 await 审计上报、再清 token —— 顺序反了会丢日志 */
|
||||
logout: () => Promise<void>;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
@ -113,7 +114,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setState({ user, token: accessToken, loading: false });
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
const logout = useCallback(async () => {
|
||||
// ⚠️ 必须【先 await 上报、再清 token】。两边顺序反了或不等,退出就留不下痕:
|
||||
// 1) axios 的请求拦截器是在微任务里执行的,它去 localStorage 读 token 时,
|
||||
// 同步的 logoutInternal() 早已把 token 清掉 → 请求不带 Authorization
|
||||
// → 后端只能记成「未认证」,退出归因不到人;
|
||||
// 2) 调用方点完退出还会立刻 navigate 到登录页,进一步压缩执行窗口。
|
||||
// 所以这里(async) + 调用方(await) 两处都得改,只改一处等于没改。
|
||||
// 失败绝不影响退出:JWT 无状态,服务端本就不需要它成功。
|
||||
try {
|
||||
await logoutApi();
|
||||
} catch {
|
||||
/* 静默:断网/超时也照退不误 */
|
||||
}
|
||||
logoutInternal();
|
||||
}, []);
|
||||
|
||||
|
||||
@ -15,8 +15,10 @@ export default function ProfilePage() {
|
||||
const displayName = user?.display_name || user?.username || "未知用户";
|
||||
const avatarChar = displayName.charAt(0);
|
||||
|
||||
function handleLogout() {
|
||||
logout();
|
||||
async function handleLogout() {
|
||||
// 必须 await:logout() 要先完成审计上报再清 token,
|
||||
// 提前 navigate 会把请求掐断,退出就留不下痕
|
||||
await logout();
|
||||
navigate("/admin/login", { replace: true });
|
||||
}
|
||||
|
||||
|
||||
424
frontend/src/pages/admin/AdminAuditLogPage.tsx
Normal file
424
frontend/src/pages/admin/AdminAuditLogPage.tsx
Normal file
@ -0,0 +1,424 @@
|
||||
/** 操作审计日志 — 谁 / 何时 / 从哪 / 对什么 / 做了什么事 / 结果如何 */
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ScrollText, Loader2, AlertCircle, RefreshCw, Search, X, Download, BarChart3 } from "lucide-react";
|
||||
import { Table, Tag, Input, Select, DatePicker, Button, Tooltip, Drawer, Descriptions } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import dayjs, { type Dayjs } from "dayjs";
|
||||
import {
|
||||
fetchAuditLogs, fetchAuditOptions, exportAuditLogsCsv,
|
||||
type AuditLogItem, type AuditOption,
|
||||
} from "../../services/auditApi";
|
||||
import { extractErrorMessage } from "../../utils/errorMessage";
|
||||
import { useToast } from "../../components/ui/Toast";
|
||||
import AuditUsagePanel from "./AuditUsagePanel";
|
||||
import ExportColumnsModal from "../../components/admin/ExportColumnsModal";
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
/** HTTP 方法配色 —— 让「这是读还是写」一眼可辨 */
|
||||
const METHOD_CLS: Record<string, string> = {
|
||||
GET: "bg-slate-100 text-slate-600",
|
||||
POST: "bg-emerald-100 text-emerald-700",
|
||||
PUT: "bg-amber-100 text-amber-700",
|
||||
PATCH: "bg-amber-100 text-amber-700",
|
||||
DELETE: "bg-red-100 text-red-700",
|
||||
};
|
||||
|
||||
/** 结果状态:2xx 正常 / 4xx 被拒 / 5xx 服务异常 */
|
||||
function statusCls(code: number | null): string {
|
||||
if (code === null) return "bg-slate-100 text-slate-500";
|
||||
if (code >= 500) return "bg-red-100 text-red-700";
|
||||
if (code >= 400) return "bg-orange-100 text-orange-700";
|
||||
return "bg-emerald-100 text-emerald-700";
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export default function AdminAuditLogPage() {
|
||||
const [rows, setRows] = useState<AuditLogItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<AuditLogItem | null>(null);
|
||||
|
||||
const { toast } = useToast();
|
||||
const [modules, setModules] = useState<AuditOption[]>([]);
|
||||
const [actions, setActions] = useState<AuditOption[]>([]);
|
||||
/** 导出可选列 —— 由后端下发,前端不硬编码表头 */
|
||||
const [logColumns, setLogColumns] = useState<AuditOption[]>([]);
|
||||
|
||||
const [usageOpen, setUsageOpen] = useState(false); // 人员统计抽屉
|
||||
const [exportPickerOpen, setExportPickerOpen] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
// 筛选条件(user_id 用受控输入,其余即时生效)
|
||||
const [userInput, setUserInput] = useState("");
|
||||
const [userId, setUserId] = useState("");
|
||||
const [module, setModule] = useState<string | undefined>();
|
||||
const [action, setAction] = useState<string | undefined>();
|
||||
const [statusCode, setStatusCode] = useState<number | undefined>();
|
||||
const [range, setRange] = useState<[Dayjs, Dayjs] | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetchAuditLogs({
|
||||
user_id: userId || undefined,
|
||||
module,
|
||||
action,
|
||||
status_code: statusCode,
|
||||
start_date: range?.[0]?.format("YYYY-MM-DD"),
|
||||
// 后端按「含当天」处理结束日期,这里直接传所选日期即可
|
||||
end_date: range?.[1]?.format("YYYY-MM-DD"),
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
});
|
||||
setRows(res.items);
|
||||
// total 取自后端 count 查询的真实总数,而非当前页条数
|
||||
setTotal(res.total);
|
||||
} catch (e) {
|
||||
setError(extractErrorMessage(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId, module, action, statusCode, range, page]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAuditOptions()
|
||||
.then((o) => {
|
||||
setModules(o.modules);
|
||||
setActions(o.actions);
|
||||
setLogColumns(o.log_export_columns || []);
|
||||
})
|
||||
.catch(() => {
|
||||
/* 筛选项拉取失败不影响列表本身 */
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 导出当前筛选条件下的明细。
|
||||
* 刻意复用与列表完全相同的筛选参数 —— 导出与"看到的"必须是同一批数据,
|
||||
* 否则使用者会怀疑到底哪份才是真的。
|
||||
*/
|
||||
async function handleExport(columns: string[]) {
|
||||
setExporting(true);
|
||||
try {
|
||||
const truncated = await exportAuditLogsCsv({
|
||||
user_id: userId || undefined,
|
||||
module,
|
||||
action,
|
||||
status_code: statusCode,
|
||||
start_date: range?.[0]?.format("YYYY-MM-DD"),
|
||||
end_date: range?.[1]?.format("YYYY-MM-DD"),
|
||||
columns,
|
||||
});
|
||||
setExportPickerOpen(false);
|
||||
// 截断必须显式告知:静默少几万行比报错更危险
|
||||
toast(
|
||||
truncated ? "已导出,但数据超上限已被截断,请收窄筛选条件" : "已导出 CSV",
|
||||
truncated ? "error" : "success",
|
||||
);
|
||||
} catch (e) {
|
||||
toast(extractErrorMessage(e, "导出失败"), "error");
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const hasFilter = !!(userId || module || action || statusCode || range);
|
||||
|
||||
const resetFilters = () => {
|
||||
setUserInput("");
|
||||
setUserId("");
|
||||
setModule(undefined);
|
||||
setAction(undefined);
|
||||
setStatusCode(undefined);
|
||||
setRange(null);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const columns: ColumnsType<AuditLogItem> = [
|
||||
{
|
||||
title: "时间",
|
||||
dataIndex: "created_at",
|
||||
width: 165,
|
||||
render: (v: string) => (
|
||||
<span className="whitespace-nowrap text-gray-600">{dayjs(v).format("YYYY-MM-DD HH:mm:ss")}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "操作人",
|
||||
dataIndex: "user_id",
|
||||
width: 140,
|
||||
render: (_, r) =>
|
||||
r.user_id ? (
|
||||
<div className="leading-tight">
|
||||
<div className="text-gray-900">{r.display_name || r.user_id}</div>
|
||||
<div className="text-xs text-gray-400">{r.user_id}</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400">未认证</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "模块",
|
||||
dataIndex: "module_label",
|
||||
width: 110,
|
||||
render: (v, r) => <span>{v || r.module}</span>,
|
||||
},
|
||||
{
|
||||
title: "动作",
|
||||
dataIndex: "action_label",
|
||||
width: 100,
|
||||
render: (v, r) => <Tag color="blue">{v || r.action}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "请求",
|
||||
dataIndex: "method",
|
||||
width: 210,
|
||||
render: (_, r) => (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`rounded px-1.5 py-0.5 text-xs font-mono ${METHOD_CLS[r.method || ""] || "bg-slate-100 text-slate-600"}`}>
|
||||
{r.method}
|
||||
</span>
|
||||
<span className="truncate font-mono text-xs text-gray-500" title={r.url || ""}>
|
||||
{r.url}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "结果",
|
||||
dataIndex: "status_code",
|
||||
width: 80,
|
||||
render: (v: number | null) => <span className={`rounded px-2 py-0.5 text-xs font-mono ${statusCls(v)}`}>{v ?? "-"}</span>,
|
||||
},
|
||||
{
|
||||
title: "来源 IP",
|
||||
dataIndex: "ip_address",
|
||||
width: 130,
|
||||
render: (v: string | null) => <span className="font-mono text-xs text-gray-500">{v || "-"}</span>,
|
||||
},
|
||||
{
|
||||
title: "",
|
||||
key: "op",
|
||||
width: 70,
|
||||
render: (_, r) => (
|
||||
<Button type="link" size="small" onClick={() => setDetail(r)}>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 text-xl font-semibold text-gray-900">
|
||||
<ScrollText className="h-5 w-5 text-blue-600" />
|
||||
操作审计
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
所有写操作(含被拒绝的请求)自动留痕,共 {total} 条
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 人员统计刻意做成抽屉而不是标签页:两个视图的粒度不同
|
||||
(一行一次操作 vs 一人一天一行),并列成 Tab 会让筛选状态互相干扰 */}
|
||||
<Button icon={<BarChart3 className="h-4 w-4" />} onClick={() => setUsageOpen(true)}>
|
||||
人员统计
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Download className="h-4 w-4" />}
|
||||
disabled={total === 0}
|
||||
onClick={() => setExportPickerOpen(true)}
|
||||
>
|
||||
导出 CSV
|
||||
</Button>
|
||||
<Button icon={<RefreshCw className="h-4 w-4" />} onClick={() => void load()} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 筛选区 */}
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
placeholder="操作人账号"
|
||||
prefix={<Search className="h-4 w-4 text-gray-400" />}
|
||||
value={userInput}
|
||||
allowClear
|
||||
style={{ width: 180 }}
|
||||
onChange={(e) => setUserInput(e.target.value)}
|
||||
onPressEnter={() => {
|
||||
setPage(1);
|
||||
setUserId(userInput.trim());
|
||||
}}
|
||||
onBlur={() => {
|
||||
setPage(1);
|
||||
setUserId(userInput.trim());
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
placeholder="模块"
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
value={module}
|
||||
options={modules}
|
||||
onChange={(v) => {
|
||||
setPage(1);
|
||||
setModule(v);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
placeholder="动作"
|
||||
allowClear
|
||||
style={{ width: 130 }}
|
||||
value={action}
|
||||
options={actions}
|
||||
onChange={(v) => {
|
||||
setPage(1);
|
||||
setAction(v);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
placeholder="结果"
|
||||
allowClear
|
||||
style={{ width: 130 }}
|
||||
value={statusCode}
|
||||
options={[
|
||||
{ value: 200, label: "成功 (2xx/3xx)" },
|
||||
{ value: 401, label: "未认证 401" },
|
||||
{ value: 403, label: "无权限 403" },
|
||||
{ value: 422, label: "参数错误 422" },
|
||||
{ value: 500, label: "服务异常 500" },
|
||||
]}
|
||||
onChange={(v) => {
|
||||
setPage(1);
|
||||
setStatusCode(v);
|
||||
}}
|
||||
/>
|
||||
<RangePicker
|
||||
value={range}
|
||||
onChange={(v) => {
|
||||
setPage(1);
|
||||
setRange(v as [Dayjs, Dayjs] | null);
|
||||
}}
|
||||
/>
|
||||
{hasFilter && (
|
||||
<Button icon={<X className="h-4 w-4" />} onClick={resetFilters}>
|
||||
清空
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 flex items-start gap-2 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table<AuditLogItem>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
loading={loading && { indicator: <Loader2 className="h-5 w-5 animate-spin text-blue-600" /> }}
|
||||
size="small"
|
||||
scroll={{ x: 1000 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: PAGE_SIZE,
|
||||
total,
|
||||
showSizeChanger: false,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: setPage,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 详情抽屉:完整 URL / UA / request_id / error_message 都在这里 */}
|
||||
<Drawer
|
||||
title="审计详情"
|
||||
width={560}
|
||||
open={!!detail}
|
||||
onClose={() => setDetail(null)}
|
||||
>
|
||||
{detail && (
|
||||
<Descriptions column={1} size="small" bordered>
|
||||
<Descriptions.Item label="时间">
|
||||
{dayjs(detail.created_at).format("YYYY-MM-DD HH:mm:ss")}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="操作人">
|
||||
{detail.user_id ? `${detail.display_name || ""} (${detail.user_id})` : "未认证"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="角色">{detail.role || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="模块 / 动作">
|
||||
{detail.module_label || detail.module} / {detail.action_label || detail.action}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="目标">
|
||||
{detail.target_id ? (
|
||||
<>
|
||||
{detail.target_name || detail.target_id}
|
||||
<span className="ml-1 text-xs text-gray-400">({detail.target_type})</span>
|
||||
</>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="请求">
|
||||
<span className="font-mono text-xs">
|
||||
{detail.method} {detail.url}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="结果">{detail.status_code ?? "-"}</Descriptions.Item>
|
||||
{detail.error_message && (
|
||||
<Descriptions.Item label="错误">
|
||||
<span className="break-all font-mono text-xs text-red-600">{detail.error_message}</span>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="来源 IP">
|
||||
<span className="font-mono text-xs">{detail.ip_address || "-"}</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="User-Agent">
|
||||
<span className="break-all text-xs text-gray-500">{detail.user_agent || "-"}</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Request ID">
|
||||
{detail.request_id ? (
|
||||
<Tooltip title="可在后端结构化日志中用它定位同一次请求">
|
||||
<span className="break-all font-mono text-xs">{detail.request_id}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
{detail.details && (
|
||||
<Descriptions.Item label="变更详情">
|
||||
<pre className="max-h-60 overflow-auto rounded bg-gray-50 p-2 text-xs">
|
||||
{JSON.stringify(detail.details, null, 2)}
|
||||
</pre>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
{/* 人员统计:独立抽屉,本页表格与筛选完全不受影响 */}
|
||||
<AuditUsagePanel open={usageOpen} onClose={() => setUsageOpen(false)} />
|
||||
|
||||
<ExportColumnsModal
|
||||
open={exportPickerOpen}
|
||||
columns={logColumns}
|
||||
submitting={exporting}
|
||||
onCancel={() => setExportPickerOpen(false)}
|
||||
onConfirm={handleExport}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -8,12 +8,13 @@ import {
|
||||
import api from "../../services/api";
|
||||
import type { ProductResponse } from "../../types/admin";
|
||||
import CreateProductDialog from "./CreateProductDialog";
|
||||
import OutboundRecordsCard from "../../components/scan/OutboundRecordsCard";
|
||||
import {
|
||||
getLabelPreview, executePrint,
|
||||
} from "../../services/printApi";
|
||||
import { useToast } from "../../components/ui/Toast";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { getStatusConfig, lifecycleBadge } from "../../constants/task";
|
||||
import { getStatusConfig, lifecycleBadge, isAdminRole } from "../../constants/task";
|
||||
import { extractErrorMessage } from "../../utils/errorMessage";
|
||||
|
||||
const QR_BASE = "/api/v1/products/qrcode";
|
||||
@ -31,7 +32,7 @@ interface ProductGroup { groupKey: string; products: ProductResponse[]; allInWar
|
||||
export default function AdminProductsPage() {
|
||||
const { toast } = useToast();
|
||||
const { user: authUser } = useAuth();
|
||||
const isAdmin = authUser?.role === "SUPER_ADMIN" || authUser?.role === "SUPERVISOR";
|
||||
const isAdmin = isAdminRole(authUser?.role);
|
||||
const [products, setProducts] = useState<ProductResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -353,13 +354,24 @@ export default function AdminProductsPage() {
|
||||
{editTarget && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => !editSaving && setEditTarget(null)} />
|
||||
<div className="relative z-10 mx-4 w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl">
|
||||
{/* 放宽到 max-w-lg 并限高:下面要嵌「出库单据」卡,max-w-sm 装不下 */}
|
||||
<div className="relative z-10 mx-4 max-h-[85vh] w-full max-w-lg overflow-y-auto rounded-xl bg-white p-6 shadow-2xl">
|
||||
<div className="mb-5 flex items-center justify-between"><h3 className="text-base font-bold text-gray-800">编辑产品</h3><button onClick={() => setEditTarget(null)} disabled={editSaving} className="rounded-lg p-1 text-gray-400 hover:bg-gray-100"><X className="h-4 w-4" /></button></div>
|
||||
<p className="mb-4 font-mono text-sm text-gray-500">产品ID: {editTarget.serial_number}</p>
|
||||
<div className="space-y-4">
|
||||
<div><label className="mb-1 block text-sm font-medium text-gray-700">订单编号</label><input value={editOrderNo} onChange={e => setEditOrderNo(e.target.value)} className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:border-blue-400 focus:outline-none" /></div>
|
||||
<div><label className="mb-1 block text-sm font-medium text-gray-700">产品序列号</label><input value={editExternalSerial} onChange={e => setEditExternalSerial(e.target.value)} className="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm focus:border-blue-400 focus:outline-none" /></div>
|
||||
</div>
|
||||
|
||||
{/* 出库单据 —— **只有这一张卡**。
|
||||
它合并了原先的「出库单据」与「领用物料」两张:那两者本来就是
|
||||
同一件事(这台设备对应 MOM 的哪些单、领了哪些料),拆开只会让人
|
||||
对着两个入口两个删除按钮发懵。卡内自带「添加出库单」入口,
|
||||
展开明细可报废/删除。 */}
|
||||
<div className="mt-5">
|
||||
<OutboundRecordsCard productId={editTarget.id} />
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2"><button onClick={() => setEditTarget(null)} disabled={editSaving} className="rounded-lg border px-4 py-2 text-sm text-gray-600">取消</button><button onClick={handleSaveEdit} disabled={editSaving} className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white">{editSaving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}<Save className="h-3.5 w-3.5" />保存</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -3,7 +3,7 @@ import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
Search, Loader2, Package, ChevronDown, ChevronRight,
|
||||
Warehouse, GitBranch, X, ArrowUp, ArrowDown, ArrowUpDown, Filter, Columns,
|
||||
Warehouse, GitBranch, X, ArrowUp, ArrowDown, ArrowUpDown, Filter, Columns, Plus,
|
||||
} from "lucide-react";
|
||||
import { Tooltip, Popover, Checkbox, Input, Button } from "antd";
|
||||
import api from "../../services/api";
|
||||
@ -14,6 +14,7 @@ import {
|
||||
import { TaskFlowView } from "../../components/TaskTree/TaskFlowView";
|
||||
import type { ModalTarget } from "../../components/TaskTree/TaskTreeViewer";
|
||||
import { Modal, ReceiveConfirmModal, RejectModal, TransferModal } from "../../components/TaskTree/TaskTreeViewer";
|
||||
import CreateTaskDialog from "../../components/admin/CreateTaskDialog";
|
||||
import type { ProductResponse } from "../../types/admin";
|
||||
import type { ProductScanResponse } from "../../types/api";
|
||||
import { useToast } from "../../components/ui/Toast";
|
||||
@ -92,6 +93,9 @@ export default function AdminTasksPage() {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [qrSerial, setQrSerial] = useState<string | null>(null); // 🔧 QR弹窗
|
||||
|
||||
// 🔧 创建任务弹窗 —— 产品由所点的那一行确定,弹窗里再选工序/接收人/出库物料
|
||||
const [createTarget, setCreateTarget] = useState<ProductResponse | null>(null);
|
||||
|
||||
// ---- 列配置(10列)----
|
||||
const columns: ColumnDef[] = [
|
||||
{
|
||||
@ -207,10 +211,17 @@ export default function AdminTasksPage() {
|
||||
render: (p) => {
|
||||
const isTreeLoading = treeLoading[p.serial_number];
|
||||
return (
|
||||
<button onClick={() => openTreeModal(p.serial_number)} className="flex items-center gap-1 rounded border border-blue-200 px-2.5 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50 transition-colors">
|
||||
{isTreeLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : <GitBranch className="h-3 w-3" />}
|
||||
流转树
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button onClick={() => openTreeModal(p.serial_number)} className="flex items-center gap-1 rounded border border-blue-200 px-2.5 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50 transition-colors">
|
||||
{isTreeLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : <GitBranch className="h-3 w-3" />}
|
||||
流转树
|
||||
</button>
|
||||
{/* 创建任务:产品由本行确定,弹窗里再选工序/接收人/出库物料 */}
|
||||
<button onClick={() => setCreateTarget(p)} className="flex items-center gap-1 rounded border border-emerald-200 px-2.5 py-1 text-xs font-medium text-emerald-600 hover:bg-emerald-50 transition-colors">
|
||||
<Plus className="h-3 w-3" />
|
||||
创建任务
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
@ -924,6 +935,14 @@ export default function AdminTasksPage() {
|
||||
onClose={() => setModalTarget(null)}
|
||||
onSubmit={handleTransfer}
|
||||
/>
|
||||
|
||||
{/* 🔧 创建任务弹窗(含从 MOM 出库单选物料) */}
|
||||
<CreateTaskDialog
|
||||
open={createTarget !== null}
|
||||
product={createTarget}
|
||||
onClose={() => setCreateTarget(null)}
|
||||
onCreated={() => { loadProducts(keyword); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
207
frontend/src/pages/admin/AuditUsagePanel.tsx
Normal file
207
frontend/src/pages/admin/AuditUsagePanel.tsx
Normal file
@ -0,0 +1,207 @@
|
||||
/**
|
||||
* 人员统计(日活报表)—— 以抽屉形式挂在操作审计页旁边。
|
||||
*
|
||||
* 回答的是「每天有哪些人用了系统、用了多少」:
|
||||
* 上线次数 / 上线时间、下线次数 / 下线时间、操作次数。
|
||||
*
|
||||
* 刻意不复用审计明细页的表格:两者的粒度不同(一个是一行一次操作,
|
||||
* 一个是一人一天一行),合在一起筛选状态会互相干扰。
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Drawer, Table, DatePicker, Button, Alert, Tag, Empty } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { Download, Loader2, RefreshCw } from "lucide-react";
|
||||
import dayjs, { type Dayjs } from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import {
|
||||
fetchDailyUsage, fetchAuditOptions, exportDailyUsageCsv,
|
||||
type DailyUsageRow, type AuditOption,
|
||||
} from "../../services/auditApi";
|
||||
import { extractErrorMessage } from "../../utils/errorMessage";
|
||||
import { useToast } from "../../components/ui/Toast";
|
||||
import ExportColumnsModal from "../../components/admin/ExportColumnsModal";
|
||||
|
||||
// 后端返回的是 UTC,而统计按【北京时间自然日】分组。
|
||||
// 必须显式按 +08:00 渲染 —— 依赖浏览器本地时区的话,一旦有人机器不在东八区,
|
||||
// 时间就会和「日期」列对不上(比如显示 17:00 而日期是次日)。
|
||||
dayjs.extend(utc);
|
||||
const BJ_OFFSET_MIN = 8 * 60;
|
||||
|
||||
function bjTime(v: string | null): string {
|
||||
if (!v) return "—";
|
||||
return dayjs.utc(v).utcOffset(BJ_OFFSET_MIN).format("HH:mm");
|
||||
}
|
||||
|
||||
/** 「上线 vs 下线」次数配色:有记录就显眼,0 就淡化 */
|
||||
function countTag(n: number, cls: string) {
|
||||
if (!n) return <span className="text-gray-300">0</span>;
|
||||
return <Tag className={`${cls} border-0 font-semibold`}>{n}</Tag>;
|
||||
}
|
||||
|
||||
export default function AuditUsagePanel({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [rows, setRows] = useState<DailyUsageRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs(), dayjs()]);
|
||||
|
||||
const [usageColumns, setUsageColumns] = useState<AuditOption[]>([]);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetchDailyUsage({
|
||||
start_date: range[0].format("YYYY-MM-DD"),
|
||||
end_date: range[1].format("YYYY-MM-DD"),
|
||||
});
|
||||
setRows(res.items);
|
||||
} catch (err: unknown) {
|
||||
setError(extractErrorMessage(err, "加载使用统计失败"));
|
||||
setRows([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [range]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) load();
|
||||
}, [open, load]);
|
||||
|
||||
// 列清单只需拉一次;失败不阻断表格本身
|
||||
useEffect(() => {
|
||||
if (!open || usageColumns.length) return;
|
||||
fetchAuditOptions()
|
||||
.then((o) => setUsageColumns(o.usage_export_columns || []))
|
||||
.catch(() => { /* 拉不到列清单只影响导出,不影响查看 */ });
|
||||
}, [open, usageColumns.length]);
|
||||
|
||||
async function handleExport(columns: string[]) {
|
||||
setExporting(true);
|
||||
try {
|
||||
const truncated = await exportDailyUsageCsv({
|
||||
start_date: range[0].format("YYYY-MM-DD"),
|
||||
end_date: range[1].format("YYYY-MM-DD"),
|
||||
columns,
|
||||
});
|
||||
setPickerOpen(false);
|
||||
toast(truncated ? "已导出(数据超上限,已截断)" : "已导出 CSV", truncated ? "error" : "success");
|
||||
} catch (err: unknown) {
|
||||
toast(extractErrorMessage(err, "导出失败"), "error");
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const multiDay = range[0].format("YYYY-MM-DD") !== range[1].format("YYYY-MM-DD");
|
||||
|
||||
const columns: ColumnsType<DailyUsageRow> = [
|
||||
// 单日查询时日期列是冗余的,自动隐藏,少一列噪音
|
||||
...(multiDay
|
||||
? [{ title: "日期", dataIndex: "day", width: 110,
|
||||
sorter: (a: DailyUsageRow, b: DailyUsageRow) => a.day.localeCompare(b.day) }]
|
||||
: []),
|
||||
{
|
||||
title: "操作人", dataIndex: "display_name", width: 160,
|
||||
render: (_: unknown, r: DailyUsageRow) => (
|
||||
<div className="leading-tight">
|
||||
<div className="text-gray-900">{r.display_name || r.user_id || "—"}</div>
|
||||
{r.display_name && <div className="text-xs text-gray-400">{r.user_id}</div>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
// 上线/下线时间 = 当天首次/末次【活动】。token 有效期内用户不重新登录,
|
||||
// 若取登录时间会得出"登录 0 次却操作 35 次"的矛盾数据(见后端 docstring)
|
||||
{ title: "上线时间", dataIndex: "first_active_at", width: 100, align: "center",
|
||||
render: (v: string | null) => <span className="font-mono text-gray-700">{bjTime(v)}</span> },
|
||||
{ title: "下线时间", dataIndex: "last_active_at", width: 100, align: "center",
|
||||
render: (v: string | null) => <span className="font-mono text-gray-700">{bjTime(v)}</span> },
|
||||
{ title: "操作次数", dataIndex: "op_count", width: 110, align: "center",
|
||||
render: (v: number) => <span className="font-bold text-blue-600">{v}</span>,
|
||||
sorter: (a: DailyUsageRow, b: DailyUsageRow) => a.op_count - b.op_count,
|
||||
defaultSortOrder: "descend" as const },
|
||||
// 登录/登出次数是真实的手动行为计数,与上面的活动时间并列展示,不混为一谈
|
||||
{ title: "登录次数", dataIndex: "login_count", width: 100, align: "center",
|
||||
render: (v: number) => countTag(v, "bg-emerald-100 text-emerald-700"),
|
||||
sorter: (a: DailyUsageRow, b: DailyUsageRow) => a.login_count - b.login_count },
|
||||
{ title: "登出次数", dataIndex: "logout_count", width: 100, align: "center",
|
||||
render: (v: number) => countTag(v, "bg-blue-100 text-blue-700"),
|
||||
sorter: (a: DailyUsageRow, b: DailyUsageRow) => a.logout_count - b.logout_count },
|
||||
];
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width={1000}
|
||||
title="📊 人员统计(日活)"
|
||||
extra={
|
||||
<Button icon={<RefreshCw className="h-3.5 w-3.5" />} onClick={load} disabled={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<DatePicker.RangePicker
|
||||
value={range}
|
||||
allowClear={false}
|
||||
onChange={(v) => { if (v?.[0] && v?.[1]) setRange([v[0], v[1]]); }}
|
||||
presets={[
|
||||
{ label: "今天", value: [dayjs(), dayjs()] },
|
||||
{ label: "昨天", value: [dayjs().subtract(1, "day"), dayjs().subtract(1, "day")] },
|
||||
{ label: "近 7 天", value: [dayjs().subtract(6, "day"), dayjs()] },
|
||||
{ label: "本月", value: [dayjs().startOf("month"), dayjs()] },
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Download className="h-3.5 w-3.5" />}
|
||||
disabled={rows.length === 0}
|
||||
onClick={() => setPickerOpen(true)}
|
||||
>
|
||||
导出 CSV
|
||||
</Button>
|
||||
<span className="text-xs text-gray-400">
|
||||
{rows.length > 0 && `共 ${rows.length} 人·天`} | 时间均为北京时间
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert type="error" showIcon className="mb-3" message={error} />
|
||||
)}
|
||||
|
||||
{/* 两处口径容易被误读,直接写在表格上方 */}
|
||||
<p className="mb-3 text-xs text-gray-400">
|
||||
ⓘ 「上线/下线时间」= 当天首次/末次<strong>活动</strong>时间,不是登录时间 ——
|
||||
登录状态可保持 7 天,当天不登录也会正常统计。
|
||||
「登录/登出次数」是真实的手动登录行为计数,登出通常少于登录(关浏览器、断网不产生登出记录)。
|
||||
</p>
|
||||
|
||||
<Table<DailyUsageRow>
|
||||
rowKey={(r) => `${r.day}|${r.user_id ?? ""}`}
|
||||
size="small"
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
loading={{ spinning: loading, indicator: <Loader2 className="h-5 w-5 animate-spin text-blue-500" /> }}
|
||||
pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (t) => `共 ${t} 条` }}
|
||||
locale={{ emptyText: <Empty description="该时段没有使用记录" /> }}
|
||||
/>
|
||||
|
||||
<ExportColumnsModal
|
||||
open={pickerOpen}
|
||||
columns={usageColumns}
|
||||
submitting={exporting}
|
||||
onCancel={() => setPickerOpen(false)}
|
||||
onConfirm={handleExport}
|
||||
/>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@ -1,9 +1,11 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Modal, Collapse, Table, Input, Button, Space, Tag, App, Spin, Empty } from "antd";
|
||||
import { SearchOutlined, PlusOutlined, MinusOutlined } from "@ant-design/icons";
|
||||
import { SearchOutlined, PlusOutlined, MinusOutlined, TruckOutlined } from "@ant-design/icons";
|
||||
import api from "../../services/api";
|
||||
import { fetchMaterialGroups, fetchMaterialItems, type MaterialGroup, type MaterialItem } from "../../services/materialApi";
|
||||
import MomOutboundPicker from "../../components/admin/MomOutboundPicker";
|
||||
import { extractErrorMessage } from "../../utils/errorMessage";
|
||||
import type { MomOutboundOrder } from "../../types/api";
|
||||
|
||||
// ============================================================
|
||||
// 类型
|
||||
@ -51,6 +53,10 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [createdSn, setCreatedSn] = useState<string | null>(null);
|
||||
|
||||
// ---- 建档时挂钩的 MOM 出库单(可选) ----
|
||||
const [pickedOrders, setPickedOrders] = useState<MomOutboundOrder[]>([]);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
// ---- 初始化 ----
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@ -62,6 +68,8 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
|
||||
setCreatedSn(null);
|
||||
groupCache.current.clear();
|
||||
groupLoadingMap.current.clear();
|
||||
setPickedOrders([]);
|
||||
setPickerOpen(false);
|
||||
loadSummary();
|
||||
}
|
||||
}, [open]);
|
||||
@ -182,6 +190,10 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
|
||||
material_type: selected.material_type,
|
||||
external_serial: externalSerial.trim() || null,
|
||||
order_no: orderNo.trim() || null,
|
||||
// 挂钩的出库单:用户是按**整张单**勾选的,所以提交该单全部明细行 ID,
|
||||
// 后端归并回单据后写进 product_outbounds(source=manual)。
|
||||
// 不选就是空数组,后端不挂载。
|
||||
mom_line_ids: pickedOrders.flatMap((o) => o.lines.map((l) => l.line_id)),
|
||||
});
|
||||
setCreatedSn(data.serial_number);
|
||||
onCreated();
|
||||
@ -332,6 +344,7 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
|
||||
// ============================================================
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title="创建产品"
|
||||
open={open}
|
||||
@ -493,6 +506,43 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* MOM 出库单挂钩(选填) */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
出库单挂钩 <span className="text-xs text-gray-400">(选填,建档后也能补挂)</span>
|
||||
</label>
|
||||
{pickedOrders.length === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-gray-300 px-3 py-2.5 text-sm text-gray-500 hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600"
|
||||
>
|
||||
<TruckOutlined />
|
||||
从 MOM 出库单选择
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{pickedOrders.map((o) => (
|
||||
<div key={o.outbound_no}
|
||||
className="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-2.5 py-1.5 text-xs">
|
||||
<span className="font-mono font-medium text-gray-800">{o.outbound_no}</span>
|
||||
<span className="text-gray-500">{o.line_count} 条物料</span>
|
||||
<Button
|
||||
type="text" size="small" danger
|
||||
className="ml-auto"
|
||||
onClick={() => setPickedOrders((prev) => prev.filter((x) => x.outbound_no !== o.outbound_no))}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button type="link" size="small" onClick={() => setPickerOpen(true)}>
|
||||
+ 继续添加出库单
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 提交 */}
|
||||
<Button
|
||||
type="primary"
|
||||
@ -507,5 +557,20 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* MOM 出库单选择器 —— 叠在创建产品弹窗之上 */}
|
||||
<MomOutboundPicker
|
||||
open={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onConfirm={(_ids, orders) => {
|
||||
setPickedOrders((prev) => {
|
||||
const seen = new Set(prev.map((o) => o.outbound_no));
|
||||
return [...prev, ...orders.filter((o) => !seen.has(o.outbound_no))];
|
||||
});
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
existingOrderNos={pickedOrders.map((o) => o.outbound_no)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
171
frontend/src/services/auditApi.ts
Normal file
171
frontend/src/services/auditApi.ts
Normal file
@ -0,0 +1,171 @@
|
||||
/** 操作审计日志 API */
|
||||
import api from "./api";
|
||||
|
||||
export interface AuditLogItem {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
display_name: string | null;
|
||||
role: string | null;
|
||||
|
||||
action: string;
|
||||
/** 服务端补的中文标签,前端不再各自维护枚举映射 */
|
||||
action_label: string | null;
|
||||
module: string;
|
||||
module_label: string | null;
|
||||
|
||||
target_type: string | null;
|
||||
target_id: string | null;
|
||||
target_name: string | null;
|
||||
details: Record<string, unknown> | null;
|
||||
|
||||
ip_address: string | null;
|
||||
user_agent: string | null;
|
||||
method: string | null;
|
||||
url: string | null;
|
||||
status_code: number | null;
|
||||
error_message: string | null;
|
||||
|
||||
/** 拿着它可在后端结构化日志中定位同一次请求 */
|
||||
request_id: string | null;
|
||||
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AuditLogListResponse {
|
||||
items: AuditLogItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface AuditOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface AuditOptionsResponse {
|
||||
modules: AuditOption[];
|
||||
actions: AuditOption[];
|
||||
/** 导出可选列(value=后端列 key,label=中文表头)—— 由后端下发,前端不再硬编码 */
|
||||
log_export_columns: AuditOption[];
|
||||
usage_export_columns: AuditOption[];
|
||||
}
|
||||
|
||||
/** 日活统计的单行(某人在某一天的用量) */
|
||||
export interface DailyUsageRow {
|
||||
day: string;
|
||||
user_id: string | null;
|
||||
display_name: string | null;
|
||||
role: string | null;
|
||||
login_count: number;
|
||||
logout_count: number;
|
||||
op_count: number;
|
||||
/**
|
||||
* 上线 / 下线时间 = 当天首次 / 末次【活动】时间(不是登录时间)。
|
||||
* token 有效期内用户不重新登录,按登录算会得出"登录 0 次却操作 35 次"的矛盾数据。
|
||||
* ISO(UTC),展示前必须转北京时间,否则会和 day 列对不上。
|
||||
*/
|
||||
first_active_at: string | null;
|
||||
last_active_at: string | null;
|
||||
}
|
||||
|
||||
export interface DailyUsageResponse {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
items: DailyUsageRow[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface AuditLogQuery {
|
||||
user_id?: string;
|
||||
module?: string;
|
||||
action?: string;
|
||||
target_id?: string;
|
||||
request_id?: string;
|
||||
status_code?: number;
|
||||
/** YYYY-MM-DD */
|
||||
start_date?: string;
|
||||
/** YYYY-MM-DD(含当天) */
|
||||
end_date?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
/** 分页查询审计日志(按时间倒序) */
|
||||
export async function fetchAuditLogs(q: AuditLogQuery = {}): Promise<AuditLogListResponse> {
|
||||
const params = Object.fromEntries(
|
||||
Object.entries(q).filter(([, v]) => v !== undefined && v !== null && v !== "")
|
||||
);
|
||||
const { data } = await api.get<AuditLogListResponse>("/audit/logs", { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 获取模块/动作筛选项(含导出可选列) */
|
||||
export async function fetchAuditOptions(): Promise<AuditOptionsResponse> {
|
||||
const { data } = await api.get<AuditOptionsResponse>("/audit/options");
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 日活 / 使用统计 —— 按【北京时间自然日 × 操作人】聚合 */
|
||||
export async function fetchDailyUsage(params: {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
} = {}): Promise<DailyUsageResponse> {
|
||||
const { data } = await api.get<DailyUsageResponse>("/audit/daily-usage", { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发浏览器下载一个 CSV。
|
||||
*
|
||||
* ⚠️ 不能直接用 <a href="/api/..."> 或 window.open:本项目是 Bearer Token 鉴权
|
||||
* (token 在 localStorage,不在 Cookie),普通链接带不上 Authorization 头,
|
||||
* 后端会直接 401。必须先经 axios 取回 blob 再本地落盘。
|
||||
*
|
||||
* @returns 是否因超出后端行数上限而被截断(调用方据此提示用户,不要静默)
|
||||
*/
|
||||
async function downloadCsv(
|
||||
path: string,
|
||||
params: Record<string, unknown>,
|
||||
filename: string,
|
||||
): Promise<boolean> {
|
||||
const resp = await api.get(path, { params, responseType: "blob" });
|
||||
const url = URL.createObjectURL(resp.data as Blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
return resp.headers["x-export-truncated"] === "1";
|
||||
}
|
||||
|
||||
/** 导出审计明细(列可自定义,columns 为后端列 key 数组;不传=全部列) */
|
||||
export function exportAuditLogsCsv(
|
||||
q: AuditLogQuery & { columns?: string[] },
|
||||
): Promise<boolean> {
|
||||
const { columns, ...rest } = q;
|
||||
return downloadCsv(
|
||||
"/audit/logs/export",
|
||||
{ ...clean(rest), columns: columns?.join(",") },
|
||||
"audit_logs.csv",
|
||||
);
|
||||
}
|
||||
|
||||
/** 导出日活统计(每人一行,列可自定义) */
|
||||
export function exportDailyUsageCsv(
|
||||
params: { start_date?: string; end_date?: string; columns?: string[] },
|
||||
): Promise<boolean> {
|
||||
const { columns, ...rest } = params;
|
||||
return downloadCsv(
|
||||
"/audit/daily-usage/export",
|
||||
{ ...clean(rest), columns: columns?.join(",") },
|
||||
"daily_usage.csv",
|
||||
);
|
||||
}
|
||||
|
||||
/** 去掉 undefined / null / 空串,避免拼出 ?a=&b= 这类空参数 */
|
||||
function clean(o: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(o).filter(([, v]) => v !== undefined && v !== null && v !== "")
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
/** 认证 API — 登录、刷新 Token、获取用户信息 */
|
||||
/** 认证 API — 登录、刷新 Token、获取用户信息、登出留痕 */
|
||||
import api from "./api";
|
||||
import type { UserInfo } from "../contexts/AuthContext";
|
||||
import { extractErrorMessage } from "../utils/errorMessage";
|
||||
|
||||
@ -52,3 +53,18 @@ export async function getMe(token: string): Promise<UserInfo> {
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出上报 —— 唯一目的是【审计留痕】。
|
||||
*
|
||||
* JWT 无状态,服务端不会(也无法)吊销令牌,本地清 token 就是登出。
|
||||
* 但没有这个请求,前端的「退出」动作在审计里完全不可见,所以必须上报一次。
|
||||
*
|
||||
* ⚠️ 调用方必须 **await 本函数之后**才清 localStorage 与跳转:
|
||||
* axios 的请求拦截器在微任务里执行、需要现读 localStorage 取 token。
|
||||
* 若不等就同步清空并 navigate,请求会不带 Authorization(或直接被掐断),
|
||||
* 后端只能记成「未认证」,退出归因不到人 —— 实测审计里 logout 记录为 0。
|
||||
*/
|
||||
export async function logout(): Promise<void> {
|
||||
await api.post("/auth/logout");
|
||||
}
|
||||
|
||||
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。
|
||||
@ -1,5 +1,9 @@
|
||||
import api from "./api";
|
||||
import type { ProductScanResponse } from "../types/api";
|
||||
import type {
|
||||
ProductOutboundMaterial,
|
||||
ProductScanResponse,
|
||||
ProductScrap,
|
||||
} from "../types/api";
|
||||
|
||||
/** 扫码查询 — 根据 16 位产品身份证查产品 + 顶层任务 */
|
||||
export async function scanProduct(serialNumber: string): Promise<ProductScanResponse> {
|
||||
@ -8,3 +12,114 @@ export async function scanProduct(serialNumber: string): Promise<ProductScanResp
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 设备的 MOM 出库明细(统一后只有这一组)
|
||||
//
|
||||
// 原先这里是两组接口,对应两张表:
|
||||
// · outbound-orders —— 产品 ↔ 出库**单**(单据级 product_outbounds)
|
||||
// · materials —— 任务 ↔ 出库**明细**(明细级 task_outbound_materials)
|
||||
// 两者是同一个概念,却因为粒度不同被拆开:用户要面对两个入口两张卡,
|
||||
// 而且走单据级挂的料**没有明细行 id,报不了废**。
|
||||
// 现已合并成一张表、一组接口、界面上只有一张卡。
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 列出该设备挂载的全部 MOM 出库明细(按出库时间倒序)。
|
||||
* 对应后端 GET /api/v1/products/{productId}/outbound-materials
|
||||
*
|
||||
* 一行 = 一条出库明细。`mom_line_id` 为空表示这条是**单据级存档**
|
||||
* (MOM 回调时查不到明细),能看、能标撤回,但**不能报废**。
|
||||
*/
|
||||
export async function getProductOutboundMaterials(
|
||||
productId: string,
|
||||
): Promise<ProductOutboundMaterial[]> {
|
||||
const { data } = await api.get<ProductOutboundMaterial[]>(
|
||||
`/products/${productId}/outbound-materials`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 给设备挂载 MOM 出库明细(网页端/移动端的「+ 领料」都走这里)。
|
||||
* 对应后端 POST /api/v1/products/{productId}/outbound-materials
|
||||
*
|
||||
* ⚠️ 只传 `mom_line_ids`,物料快照由后端现查 MOM —— 前端不传快照。
|
||||
* ⚠️ `taskId` 可空,仅作溯源(这条料挂在哪条任务上),不参与展示/报废/删除。
|
||||
* 幂等:已挂过的明细会被后端跳过。返回该设备当前**全部**出库明细。
|
||||
*/
|
||||
export async function mountProductOutboundMaterials(
|
||||
productId: string,
|
||||
momLineIds: number[],
|
||||
taskId?: string | null,
|
||||
): Promise<ProductOutboundMaterial[]> {
|
||||
const { data } = await api.post<ProductOutboundMaterial[]>(
|
||||
`/products/${productId}/outbound-materials`,
|
||||
{ mom_line_ids: momLineIds, task_id: taskId || null },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 摘掉一条**人工挂载**的出库明细(挂错了要能撤)。
|
||||
* 对应后端 DELETE /api/v1/products/{productId}/outbound-materials/{materialId}
|
||||
*
|
||||
* ⚠️ 只能删 `source='manual'` 的。MOM 回调自动存档的行后端返回 409 ——
|
||||
* 那是系统事实,要撤得去 MOM 撤回。返回该设备**剩余**的全部出库明细。
|
||||
*/
|
||||
export async function removeProductOutboundMaterial(
|
||||
productId: string,
|
||||
materialId: number,
|
||||
): Promise<ProductOutboundMaterial[]> {
|
||||
const { data } = await api.delete<ProductOutboundMaterial[]>(
|
||||
`/products/${productId}/outbound-materials/${materialId}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 整张出库单一起摘掉(挂错了要能撤)。
|
||||
* 对应后端 DELETE /api/v1/products/{productId}/outbound-materials/by-order/{outboundNo}
|
||||
*
|
||||
* 界面上更容易碰到的是「这一单整个挂错了」,逐条删要删好几下。
|
||||
* 规则与逐条删一致:含 webhook 存档记录的单整单删不掉(后端 409)。
|
||||
*/
|
||||
export async function removeProductOutboundOrder(
|
||||
productId: string,
|
||||
outboundNo: string,
|
||||
): Promise<ProductOutboundMaterial[]> {
|
||||
const { data } = await api.delete<ProductOutboundMaterial[]>(
|
||||
`/products/${productId}/outbound-materials/by-order/${encodeURIComponent(outboundNo)}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 生产报废
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 列出该设备的报废记录(含实时回查 MOM 的状态与金额)。
|
||||
* 对应后端 GET /api/v1/products/{productId}/scraps
|
||||
*/
|
||||
export async function listProductScraps(productId: string): Promise<ProductScrap[]> {
|
||||
const { data } = await api.get<ProductScrap[]>(`/products/${productId}/scraps`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交一条生产报废(领用的料在生产中损坏)。
|
||||
* 对应后端 POST /api/v1/products/{productId}/scraps
|
||||
*
|
||||
* ⚠️ `track_ref` 必须在**打开弹窗时生成一次**并在重试时复用 ——
|
||||
* 每次提交都换新的话,用户重试会在 MOM 里多报一张报废单。
|
||||
*/
|
||||
export async function submitProductScrap(
|
||||
productId: string,
|
||||
payload: { mom_line_id: number; quantity: number; track_ref: string; reason?: string | null },
|
||||
): Promise<ProductScrap> {
|
||||
const { data } = await api.post<ProductScrap>(
|
||||
`/products/${productId}/scraps`, payload,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
@ -26,6 +26,19 @@ export interface TaskCompletePayload {
|
||||
remark: string | null;
|
||||
}
|
||||
|
||||
export interface TaskCreatePayload {
|
||||
product_id: string;
|
||||
task_name: string;
|
||||
assignee_id?: string | null;
|
||||
remark?: string;
|
||||
/**
|
||||
* 创建时一并挂载的 MOM 出库**明细行** ID(trans_outbound.id)。
|
||||
* 前端按整张出库单勾选,提交时把该单全部明细 ID 带过来。
|
||||
* 不传 = 不挂载。
|
||||
*/
|
||||
mom_line_ids?: number[];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API 方法
|
||||
// ============================================================
|
||||
@ -43,6 +56,16 @@ export async function listTasks(productId?: string): Promise<TaskListResponse> {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建任务 — 对应后端 POST /api/v1/tasks/
|
||||
* payload.mom_line_ids 非空时,后端会在**同一事务**里把对应的 MOM 出库明细
|
||||
* 挂到新任务上,不存在「任务建好了但物料没挂上」的中间态。
|
||||
*/
|
||||
export async function createTask(payload: TaskCreatePayload): Promise<TaskResponse> {
|
||||
const { data } = await api.post<TaskResponse>("/tasks/", payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 创建子任务 */
|
||||
export async function createSubtask(
|
||||
parentTaskId: string,
|
||||
|
||||
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;
|
||||
}
|
||||
@ -48,6 +48,80 @@ export interface TaskResponse extends TaskSummary {
|
||||
records: TaskRecordResponse[];
|
||||
/** 🔧 任务创建人(追溯谁转交/发起该工序),如"谁转入在库" */
|
||||
created_by?: string | null;
|
||||
/** 🔧 本任务挂载的 MOM 出库物料(创建时选、之后可追加) */
|
||||
outbound_materials?: TaskOutboundMaterial[];
|
||||
}
|
||||
|
||||
/** 任务挂载的一条 MOM 出库物料明细(挂载时从 MOM 取的快照)
|
||||
* 一次挂载会展开成多行(挂一张出库单 = 该单全部明细各一行),按 outbound_no 分组展示。 */
|
||||
export interface TaskOutboundMaterial {
|
||||
id: number;
|
||||
/** 料挂在哪条任务上。按任务分组/删除都要用它(不能用任务名,同名会并组) */
|
||||
task_id: string;
|
||||
/** MOM trans_outbound.id,供反查比对 */
|
||||
mom_line_id: number;
|
||||
/** MOM 出库单号 */
|
||||
outbound_no: string;
|
||||
sku: string | null;
|
||||
material_name: string | null;
|
||||
spec_model: string | null;
|
||||
/** 出库单原值,**不是**本任务用量 */
|
||||
quantity: number | null;
|
||||
unit_price: number | null;
|
||||
/** SALES/USE/PRODUCTION/LOSS/REPAIR —— 只展示不判断(MOM 码表未冻结) */
|
||||
outbound_type: string | null;
|
||||
/** 出库类型的中文名(服务端按 MOM 码表下发),直接展示,前端不自建映射 */
|
||||
outbound_type_label: string;
|
||||
/** 领用人/客户(MOM 侧自由填写,非可靠标识) */
|
||||
consumer_name: string | null;
|
||||
operator_name: string | null;
|
||||
warehouse_location: string | null;
|
||||
outbound_time: string | null;
|
||||
/** 挂载人(逻辑外键→MOM sys_user) */
|
||||
added_by: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ---- MOM 出库单(任务挂载物料时的搜索选择器用) ----
|
||||
|
||||
/** MOM 出库单的一条物料明细。line_id 即挂载时提交的 mom_line_ids 元素 */
|
||||
export interface MomOutboundLine {
|
||||
line_id: number;
|
||||
sku: string;
|
||||
material_name: string;
|
||||
spec_model: string;
|
||||
quantity: number | null;
|
||||
unit_price: number | null;
|
||||
returned_quantity: number | null;
|
||||
outbound_type: string;
|
||||
/** 出库类型的中文名(服务端按 MOM 码表下发),直接展示,前端不自建映射 */
|
||||
outbound_type_label: string;
|
||||
consumer_name: string;
|
||||
operator_name: string;
|
||||
warehouse_location: string;
|
||||
outbound_time: string | null;
|
||||
/** ⚠️ MOM 存量单据该字段全为空(列是后加的,无从回填),空表示「无关联申请单」 */
|
||||
request_no: string;
|
||||
}
|
||||
|
||||
/** 一张 MOM 出库单(批量出库多商品共用一个单号,故带 N 条明细) */
|
||||
export interface MomOutboundOrder {
|
||||
outbound_no: string;
|
||||
outbound_time: string | null;
|
||||
outbound_type: string;
|
||||
/** 出库类型的中文名(服务端按 MOM 码表下发),直接展示,前端不自建映射 */
|
||||
outbound_type_label: string;
|
||||
consumer_name: string;
|
||||
operator_name: string;
|
||||
line_count: number;
|
||||
total_quantity: number | null;
|
||||
lines: MomOutboundLine[];
|
||||
}
|
||||
|
||||
export interface MomOutboundSearchResponse {
|
||||
orders: MomOutboundOrder[];
|
||||
/** 命中的**单据**总数(不是明细行数) */
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface TaskRecordResponse {
|
||||
@ -110,6 +184,84 @@ export interface TaskTransferPayload {
|
||||
// 产品
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 设备的一条 MOM 出库明细(统一形态)
|
||||
*
|
||||
* 一行 = 设备上的一条出库明细。`mom_line_id` 为空表示这条是**单据级存档**
|
||||
* (MOM 回调时查不到明细),能看、能标撤回,但**不能报废** —— 报废要用它定位。
|
||||
*/
|
||||
export interface ProductOutboundMaterial {
|
||||
id: number;
|
||||
product_id: string;
|
||||
serial_number: string | null;
|
||||
/** 仅溯源(这条料挂在哪条任务上),不参与展示/报废/删除 */
|
||||
task_id: string | null;
|
||||
/** MOM trans_outbound.id;为空 = 无明细的存档 */
|
||||
mom_line_id: number | null;
|
||||
outbound_no: string;
|
||||
// ---- 单据级(同单内一致,冗余在每条明细上) ----
|
||||
request_no: string | null;
|
||||
applicant_name: string | null;
|
||||
remark: string | null;
|
||||
// ---- 明细级快照 ----
|
||||
sku: string | null;
|
||||
material_name: string | null;
|
||||
spec_model: string | null;
|
||||
/** 出库单原值,**不是**本设备用量 */
|
||||
quantity: number | null;
|
||||
unit_price: number | null;
|
||||
outbound_type: string | null;
|
||||
/** 服务端下发的中文名,直接展示 */
|
||||
outbound_type_label: string;
|
||||
consumer_name: string | null;
|
||||
operator_name: string | null;
|
||||
warehouse_location: string | null;
|
||||
outbound_time: string | null;
|
||||
// ---- 来源与撤回 ----
|
||||
/** manual(人工挂载,可删) | webhook(MOM 回调自动存档,不可删) */
|
||||
source: string;
|
||||
is_revoked: boolean;
|
||||
revoked_at: string | null;
|
||||
/** 谁挂上去的(Track 用户名) */
|
||||
added_by: string | null;
|
||||
/** 谁挂上去的(中文姓名,服务端解析下发),直接展示 */
|
||||
added_by_name: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** 生产报废记录 —— Track 发起、MOM 受理的报废单 */
|
||||
export interface ProductScrap {
|
||||
id: string;
|
||||
product_id: string;
|
||||
serial_number: string | null;
|
||||
task_id: string | null;
|
||||
/** 报废对象:MOM trans_outbound.id */
|
||||
mom_line_id: number;
|
||||
/** 快照:MOM 侧数据被清理后仍要能显示「报了什么」 */
|
||||
outbound_no: string | null;
|
||||
material_name: string | null;
|
||||
spec_model: string | null;
|
||||
sku: string | null;
|
||||
/** 原领用人。前端据此判断「报别人的料要额外确认」 */
|
||||
consumer_name: string | null;
|
||||
quantity: number;
|
||||
reason_category: string;
|
||||
reason: string | null;
|
||||
scrap_request_no: string;
|
||||
defective_goods_id: number | null;
|
||||
submitted_by: string | null;
|
||||
created_at: string;
|
||||
// ---- 以下为后端实时回查 MOM 的结果 ----
|
||||
mom_status: number;
|
||||
mom_status_label: string;
|
||||
mom_approved_at: string | null;
|
||||
mom_executor_name: string;
|
||||
mom_executed: boolean;
|
||||
/** 报废损失。**未执行时是 null 不是 0** —— 0 会让人以为「这东西不值钱」 */
|
||||
total_loss: number | null;
|
||||
scrapped_quantity: number | null;
|
||||
}
|
||||
|
||||
export interface ProductScanResponse {
|
||||
id: string;
|
||||
serial_number: string;
|
||||
@ -132,4 +284,8 @@ export interface ProductScanResponse {
|
||||
task_tree: TaskResponse[];
|
||||
/** 🔧 username→中文姓名映射 */
|
||||
assignee_names: Record<string, string>;
|
||||
/** 🔧 出库单据存档(来自 MOM 出库回调),按出库时间倒序。
|
||||
* 本功能上线前出库的设备这里是空数组,不是错误。
|
||||
* 可选是为了兼容尚未升级的后端。 */
|
||||
outbound_records?: ProductOutboundMaterial[];
|
||||
}
|
||||
|
||||
74
track-uniapp/src/api/material.js
Normal file
74
track-uniapp/src/api/material.js
Normal file
@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 领用物料 API —— 「产品详情 → 领用物料 → + 领料」
|
||||
*
|
||||
* 背景:PC 端早就能挂出库物料(`MomOutboundPicker`),但**移动端一直没有入口** ——
|
||||
* 只能在别处挂好、这边看。一线的人(生产领料、测试补料)反而够不着,
|
||||
* 所以补上这条链。
|
||||
*
|
||||
* ⚠️ 挂载粒度是**任务**,不是产品:
|
||||
* `product_outbound_materials` 才带 `mom_line_id`(MOM trans_outbound.id),
|
||||
* 而产品级那张 `product_outbounds` 是单据级、没有行 id。
|
||||
* 报废必须靠 mom_line_id 定位到具体哪条出库明细,所以这里的入口一律挂任务。
|
||||
* 这也贴合业务:生产领生产任务的料,测试领测试任务的料,各挂各的。
|
||||
*/
|
||||
import { get, post, del } from "../utils/request";
|
||||
|
||||
/**
|
||||
* 搜索 MOM 出库单(按单据分页,带回每张单的明细)。
|
||||
*
|
||||
* ⚠️ 可见范围(公司隔离 + 跨部门例外)由后端钉死,这里传什么都放不大 ——
|
||||
* 界面筛选只能收窄。
|
||||
*/
|
||||
export function searchMomOutbounds(params = {}) {
|
||||
return get("/mom-outbounds", params);
|
||||
}
|
||||
|
||||
/** 本部门出库单里出现过的领用人姓名(后端已按可见范围过滤) */
|
||||
export function listMomOutboundConsumers() {
|
||||
return get("/mom-outbounds/consumers");
|
||||
}
|
||||
|
||||
/**
|
||||
* 把选中的 MOM 出库**明细行**挂到设备上。
|
||||
*
|
||||
* @param {string} productId
|
||||
* @param {number[]} momLineIds - MOM trans_outbound.id 列表
|
||||
* (勾的是一整张出库单,提交时展开成该单的全部明细行 id)
|
||||
* @param {string} [taskId] - 可选,仅作溯源
|
||||
* ⚠️ 只传 id,物料名/数量由后端现查 MOM —— 前端传快照会被后端拒绝。
|
||||
* 幂等:已挂过的明细会被后端跳过。返回该设备当前**全部**出库明细。
|
||||
*/
|
||||
export function mountProductOutboundMaterials(productId, momLineIds, taskId) {
|
||||
return post(`/products/${productId}/outbound-materials`, {
|
||||
mom_line_ids: momLineIds,
|
||||
// 任务只是**溯源信息**(这条料挂在哪条任务上),可以不给 ——
|
||||
// 展示、报废、删除一律按设备走
|
||||
task_id: taskId || null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 从设备上摘掉一条出库明细(挂错了要能撤)。
|
||||
*
|
||||
* @param {string} productId
|
||||
* @param {number} materialId - **Track 侧那条记录的 id**(不是 mom_line_id)
|
||||
* ⚠️ 别传错:`id` 是本表主键、只在 Track 库里有;`mom_line_id` 是 MOM
|
||||
* 那边的出库明细行 id。传反了会删掉另一条料。
|
||||
*
|
||||
* 只摘掉 Track 这边的挂载关系,**不动 MOM 里的出库单本身**。
|
||||
* 已提交的报废记录也不受影响(它的物料信息是快照,独立存在)。
|
||||
* ⚠️ MOM 回调自动存档的行删不掉(后端 409),那是系统事实。
|
||||
*/
|
||||
export function removeProductOutboundMaterial(productId, materialId) {
|
||||
return del(`/products/${productId}/outbound-materials/${materialId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 整张出库单一起摘掉(挂错了要能一次撤)。
|
||||
*
|
||||
* 界面上更容易碰到的是「这一单整个挂错了」,逐条删要删好几下。
|
||||
* 规则与逐条删**一致**:含 MOM 回调自动存档记录的单整单删不掉(后端 409)。
|
||||
*/
|
||||
export function removeProductOutboundOrder(productId, outboundNo) {
|
||||
return del(`/products/${productId}/outbound-materials/by-order/${encodeURIComponent(outboundNo)}`);
|
||||
}
|
||||
48
track-uniapp/src/api/scrap.js
Normal file
48
track-uniapp/src/api/scrap.js
Normal file
@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 生产报废 API —— 「产品详情 → 领用物料 → 报废」
|
||||
*
|
||||
* 链路:本文件 → Track 后端 /products/{id}/scraps
|
||||
* → MOM 内部接口(退回不良品 + 建报废申请)
|
||||
* → MOM 里主管审批 → 库管扫码执行 → 才算得出损失金额
|
||||
*
|
||||
* ⚠️ 金额与状态是**后端实时回查 MOM** 的,前端不要缓存、不要自己算:
|
||||
* `mom_executed=false` 时 `total_loss` 是 null(还没执行),
|
||||
* 而不是 0 —— 显示成「损失 0 元」会让用户以为东西没价值。
|
||||
*/
|
||||
import { get, post } from "../utils/request";
|
||||
|
||||
/** 列出该产品的生产报废记录(按提交时间倒序,含实时 MOM 状态与金额) */
|
||||
export function listProductScraps(productId) {
|
||||
return get(`/products/${productId}/scraps`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交一条生产报废。
|
||||
*
|
||||
* @param {string} productId
|
||||
* @param {object} payload
|
||||
* @param {number} payload.mom_line_id - 报废对象:MOM 出库明细行 id
|
||||
* (就是任务挂载的 outbound_materials[].mom_line_id)
|
||||
* @param {number} payload.quantity - 报废数量
|
||||
* @param {string} payload.track_ref - 幂等锚点,**弹层打开时生成一次**,
|
||||
* 重试复用同一个;换了它就会在 MOM 里多出一张报废单
|
||||
* @param {string} [payload.reason] - 原因说明
|
||||
*/
|
||||
export function submitProductScrap(productId, payload) {
|
||||
return post(`/products/${productId}/scraps`, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成幂等锚点。
|
||||
*
|
||||
* ⚠️ 必须在**打开弹层时**生成一次并保存在弹层状态里,提交失败重试要复用同一个 ——
|
||||
* 每次提交都新生成的话,用户重试就会在 MOM 里多报一张单(重复报废)。
|
||||
* 用时间戳 + 随机串,在单机范围内足够唯一,且人能看懂大概是什么时候报的。
|
||||
*/
|
||||
export function makeTrackRef() {
|
||||
const d = new Date();
|
||||
const p = (n) => String(n).padStart(2, "0");
|
||||
const ts = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
||||
const rand = Math.random().toString(36).slice(2, 8);
|
||||
return `SCRAP-${ts}-${rand}`;
|
||||
}
|
||||
@ -31,6 +31,22 @@
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/material/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "领用物料",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/material/pick",
|
||||
"style": {
|
||||
"navigationBarTitleText": "选择 MOM 出库物料",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/tasks/index",
|
||||
"style": {
|
||||
|
||||
505
track-uniapp/src/pages/material/index.vue
Normal file
505
track-uniapp/src/pages/material/index.vue
Normal file
@ -0,0 +1,505 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view v-if="loading" class="hint">加载中...</view>
|
||||
|
||||
<template v-else>
|
||||
<!-- 设备抬头:先明确「这是哪台设备的料」,避免看串设备 -->
|
||||
<view class="card">
|
||||
<view class="head-line">
|
||||
<text class="head-sn">{{ product.serial_number }}</text>
|
||||
<text class="head-name">{{ product.material_name || '—' }}</text>
|
||||
</view>
|
||||
<text v-if="product.spec_model" class="head-spec">{{ product.spec_model }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 🚚 出库单据:**按出库单号分组**,一行一张单,点开看明细。
|
||||
★ 与网页端同一形态(同一张表、同一个接口):单号是主信息,
|
||||
「领了什么」收在展开区里。
|
||||
★ 显示这台设备的**全部**出库明细,不按「我领的」过滤 ——
|
||||
料是领给设备的,不是领给某个人的:生产领的外壳装在这台设备上,
|
||||
测试时摔坏了就该由测试来报,不能让测试看不见它。
|
||||
★ 不展示出库类型(用途),也不问「挂到哪条任务」:
|
||||
现场只需要知道「挂了哪张单、谁挂的、谁出的库」。 -->
|
||||
<view class="card">
|
||||
<view class="card-header">
|
||||
<text class="card-title">🚚 出库单据</text>
|
||||
<text class="card-add" @tap="goPick">+ 领料</text>
|
||||
</view>
|
||||
<text v-if="materials.length" class="count">
|
||||
共 {{ orders.length }} 张单 / {{ materials.length }} 条料
|
||||
</text>
|
||||
|
||||
<view v-if="!materials.length" class="empty">
|
||||
<text>暂无关联的出库单。</text>
|
||||
<text class="empty-sub">点右上角「+ 领料」,选对应的出库单挂到这台设备上。</text>
|
||||
</view>
|
||||
|
||||
<view v-for="o in orders" :key="o.outbound_no" class="order">
|
||||
<view class="order-head" @tap="toggleExpand(o.outbound_no)">
|
||||
<!-- 不展示出库类型(用途)—— 现场只关心「这台设备挂了哪张单、
|
||||
谁挂的、谁出的库」,多一个「内部领用」徽标只是噪音 -->
|
||||
<view class="order-line1">
|
||||
<text class="order-no">{{ o.outbound_no }}</text>
|
||||
</view>
|
||||
<view class="order-line2">
|
||||
<text class="order-count">{{ o.items.length }} 条物料</text>
|
||||
<!-- 谁挂上去的 —— 现场要能追责/问人,只记在库里不显示等于没记 -->
|
||||
<text v-if="o.addedByName" class="order-by">挂载 {{ o.addedByName }}</text>
|
||||
<text class="order-time">{{ fmtTime(o.outbound_time) }}</text>
|
||||
<!-- 整单删除:挂错了要能一次摘掉。只在**全部**是人工挂的时显示 ——
|
||||
含 MOM 回调存档的单不给删(后端也拦)。
|
||||
做成小按钮而不是裸文字:裸文字在窄屏上会被时间和箭头挤没,
|
||||
现场根本看不出这里能点 -->
|
||||
<view v-if="o.allManual" class="order-del" @tap.stop="confirmRemoveOrder(o)">
|
||||
<text>删除</text>
|
||||
</view>
|
||||
<text class="chev">{{ expanded[o.outbound_no] ? '▲' : '▼' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="expanded[o.outbound_no]" class="lines">
|
||||
<view v-for="m in o.items" :key="m.mom_line_id" class="line">
|
||||
<view class="line-info">
|
||||
<text class="line-name">{{ m.material_name || '(未命名物料)' }}</text>
|
||||
<text class="line-meta">
|
||||
<text v-if="m.spec_model">{{ m.spec_model }} · </text>×{{ m.quantity }}
|
||||
<text v-if="m.consumer_name"> · 领用 {{ formatName(m.consumer_name) }}</text>
|
||||
</text>
|
||||
</view>
|
||||
<view class="row-btns">
|
||||
<view class="btn-scrap" @tap.stop="openScrapDialog(m)"><text>报废</text></view>
|
||||
<view class="btn-del" @tap.stop="confirmRemove(m)"><text>删除</text></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ♻️ 报废记录:状态与金额由后端实时回查 MOM -->
|
||||
<view class="card" v-if="scrapRecords.length">
|
||||
<view class="card-header">
|
||||
<text class="card-title">♻️ 报废记录</text>
|
||||
<text class="count">共 {{ scrapRecords.length }} 条</text>
|
||||
</view>
|
||||
<view v-for="s in scrapRecords" :key="s.id" class="scrap-row">
|
||||
<view class="row-line1">
|
||||
<text class="scrap-name">{{ s.material_name || '(未命名物料)' }}</text>
|
||||
<text :class="['badge', badgeClass(s)]">{{ s.mom_status_label || '状态未知' }}</text>
|
||||
<text class="scrap-qty">×{{ s.quantity }}</text>
|
||||
</view>
|
||||
<view class="row-line2">
|
||||
<text class="scrap-meta">报废单 {{ s.scrap_request_no }}</text>
|
||||
<text v-if="s.submitted_by" class="scrap-meta">提交人 {{ formatName(s.submitted_by) }}</text>
|
||||
<!-- ★ 只有执行过才有金额。未执行显示「—」不显示 0 ——
|
||||
0 会让人以为「这东西不值钱」,其实是「还没扫码执行」 -->
|
||||
<text v-if="s.mom_executed" class="scrap-meta">损失 ¥{{ Number(s.total_loss).toFixed(2) }}</text>
|
||||
</view>
|
||||
<text v-if="s.reason" class="scrap-reason">{{ s.reason }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 报废弹层:数量 + 说明。分类不让人选 ——
|
||||
走这条路进来的料都是「已领用到产线」,按定义就是生产损耗,
|
||||
后端固定按 PRODUCTION 提交,少一个会选错的地方。 -->
|
||||
<view v-if="dlg.visible" class="overlay" @tap="closeDlg">
|
||||
<view class="popup" @tap.stop>
|
||||
<text class="popup-title">报废 · {{ dlg.materialName }}</text>
|
||||
<text class="popup-hint">{{ dlg.specModel || '—' }} | 原领用人 {{ dlg.consumerName || '—' }}</text>
|
||||
|
||||
<view class="field-label">报废数量 <text class="required">*</text></view>
|
||||
<input v-model="dlg.quantity" type="digit" class="popup-input" :placeholder="'最多 ' + dlg.maxQty" />
|
||||
|
||||
<view class="field-label">原因说明</view>
|
||||
<textarea v-model="dlg.reason" class="popup-textarea" placeholder="例如:测试时跌落,外壳磕裂" maxlength="200" />
|
||||
|
||||
<!-- ★ 代报确认:报的不是自己领的料时多一道。这是**防误操作**不是权限 ——
|
||||
后端不会因为这条拒绝(料的归属是设备不是人,谁发现谁报),
|
||||
真正的把关在 MOM 侧主管审批。 -->
|
||||
<view v-if="dlg.isProxy" class="proxy" @tap="dlg.confirmed = !dlg.confirmed">
|
||||
<text class="proxy-icon">{{ dlg.confirmed ? '☑' : '☐' }}</text>
|
||||
<text class="proxy-text">这条料不是你领的({{ dlg.consumerName }} 领用),确认代报?</text>
|
||||
</view>
|
||||
|
||||
<view class="popup-btns">
|
||||
<button class="btn-cancel" @tap="closeDlg">取消</button>
|
||||
<button class="btn-primary" :disabled="submitting || !dlgReady" @tap="doSubmit">
|
||||
{{ submitting ? '提交中...' : '提交报废' }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { get } from "../../utils/request";
|
||||
import { formatUserName } from "../../utils/format";
|
||||
import { listProductScraps, submitProductScrap, makeTrackRef } from "../../api/scrap";
|
||||
import { removeProductOutboundMaterial, removeProductOutboundOrder } from "../../api/material";
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
productId: "",
|
||||
serial: "",
|
||||
product: {},
|
||||
loading: true,
|
||||
scrapRecords: [],
|
||||
submitting: false,
|
||||
// 展开的单据号集合:{ outbound_no: true }。
|
||||
// 默认全收起 —— 一台设备可能领了很多单,全摊开要滑很久才看得完
|
||||
expanded: {},
|
||||
dlg: {
|
||||
visible: false, momLineId: null, materialName: "", specModel: "",
|
||||
consumerName: "", maxQty: 0, quantity: "", reason: "",
|
||||
isProxy: false, confirmed: false, trackRef: "",
|
||||
},
|
||||
// 当前登录人姓名,用于判断「代报」
|
||||
me: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
/** 这台设备挂载的全部 MOM 出库明细 */
|
||||
materials() {
|
||||
// 读**统一后**的设备出库明细(outbound_records)。
|
||||
// 以前读 task_tree[].outbound_materials —— 那是被合并掉的第二个数据源,
|
||||
// 也正是「网页端看不到移动端挂的料」的根因。
|
||||
return (this.product && this.product.outbound_records) || [];
|
||||
},
|
||||
/** 按**出库单号**分组,与网页端同一形态:一行一张单,点开看明细 */
|
||||
orders() {
|
||||
const map = {};
|
||||
const out = [];
|
||||
this.materials.forEach((m) => {
|
||||
const no = m.outbound_no || "(无单号)";
|
||||
if (!map[no]) {
|
||||
map[no] = {
|
||||
outbound_no: no,
|
||||
outbound_time: m.outbound_time,
|
||||
// 谁挂的:同一张单的明细是同一次挂载写入的,取第一条即可
|
||||
addedByName: m.added_by_name || m.added_by || "",
|
||||
items: [],
|
||||
// 整单是否全部人工挂的 —— 决定「整单删除」按不按得动
|
||||
allManual: true,
|
||||
};
|
||||
out.push(map[no]);
|
||||
}
|
||||
map[no].items.push(m);
|
||||
if (m.source !== "manual") map[no].allManual = false;
|
||||
});
|
||||
return out;
|
||||
},
|
||||
/** 代报时必须勾选确认才能提交 */
|
||||
dlgReady() {
|
||||
return !this.dlg.isProxy || this.dlg.confirmed;
|
||||
},
|
||||
},
|
||||
onLoad(options) {
|
||||
this.productId = options.productId || "";
|
||||
this.serial = options.serial || "";
|
||||
// 登录用户存在 "user" 里(JSON 字符串),与 profile / login 页同一口径。
|
||||
// 只用它来判断「这条料是不是我领的」——判错也只是多让用户勾一下确认,
|
||||
// 真正的防线在 MOM 侧主管审批
|
||||
try {
|
||||
const raw = uni.getStorageSync("user");
|
||||
this.me = raw ? (typeof raw === "string" ? JSON.parse(raw) : raw) : null;
|
||||
} catch (e) {
|
||||
this.me = null;
|
||||
}
|
||||
this.load();
|
||||
},
|
||||
onShow() {
|
||||
// 从「选择出库物料」页返回时重新拉一次,把刚挂上的料显示出来
|
||||
if (!this.loading) this.load();
|
||||
},
|
||||
methods: {
|
||||
formatName: formatUserName,
|
||||
|
||||
async load() {
|
||||
this.loading = true;
|
||||
try {
|
||||
// 用扫码接口:它一次带回 product.outbound_records(设备出库明细,
|
||||
// 统一后的唯一来源)与 task_tree(只在「+ 领料」时用来带个默认任务作溯源)。
|
||||
// 没另开专用接口:这份数据产品详情页本来就在拉,语义一致,复用即可
|
||||
this.product = await get(`/products/scan/${this.serial}`);
|
||||
await this.fetchScrapRecords();
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e?.data?.detail || "加载失败", icon: "none" });
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async fetchScrapRecords() {
|
||||
try {
|
||||
this.scrapRecords = (await listProductScraps(this.productId)) || [];
|
||||
} catch (e) {
|
||||
// 静默:报废记录是附加信息,拉不到不该挡住物料列表
|
||||
console.warn("[material] 拉报废记录失败:", e?.data?.detail || e);
|
||||
this.scrapRecords = [];
|
||||
}
|
||||
},
|
||||
|
||||
/** 展开/收起某张出库单的明细 */
|
||||
toggleExpand(no) {
|
||||
this.expanded = { ...this.expanded, [no]: !this.expanded[no] };
|
||||
},
|
||||
|
||||
fmtTime(iso) {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "—";
|
||||
const p = (n) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* 领料:先定「挂到哪条任务」,再进选择器。
|
||||
*
|
||||
* ★ 为什么必须先选任务:料在 Track 侧是挂在**任务**上的
|
||||
* (`product_outbound_materials`),只有它带 `mom_line_id`,
|
||||
* 而报废必须靠它定位到具体哪条出库明细。所以挂载动作离不开任务。
|
||||
* 但任务不该像之前那样当成列表分组抬头(现场看不懂「生产·小龙虾」),
|
||||
* 所以改成点「+ 领料」时才选一次。
|
||||
*/
|
||||
goPick() {
|
||||
// 统一到**设备级**后任务变成可选(只作溯源),所以不再让用户先选任务 ——
|
||||
// 少一步操作,也少一处「到底挂到哪条任务」的困惑
|
||||
this.toPick();
|
||||
},
|
||||
|
||||
toPick() {
|
||||
// 把**这台设备已挂过的单号**带给选择器,让那边把「已挂载」标出来并禁止再选。
|
||||
// 后端本身是幂等的(已挂的明细会跳过),标出来只是省得用户白勾一遍
|
||||
const mounted = [...new Set(this.materials.map((m) => m.outbound_no).filter(Boolean))];
|
||||
// 不带任务 —— 挂载不需要挂到某条任务上(任务只是溯源,可空)。
|
||||
// 「谁挂上去的」记在 added_by、「谁出库的」由 MOM 快照带过来,够了。
|
||||
uni.navigateTo({
|
||||
url: `/pages/material/pick?productId=${this.productId}`
|
||||
+ `&serial=${encodeURIComponent(this.serial || "")}`
|
||||
+ `&mounted=${encodeURIComponent(mounted.join(","))}`,
|
||||
// navigateTo 失败是静默的(只在控制台留一行),必须弹出来,
|
||||
// 多半是 pages.json 没重新读 —— HBuilderX 只认启动时的那份
|
||||
fail: (err) => {
|
||||
console.error("[material] 打不开选择器:", err);
|
||||
uni.showModal({
|
||||
title: "打不开「选择出库物料」",
|
||||
content: "页面未注册或未编译:" + (err && err.errMsg ? err.errMsg : err)
|
||||
+ "\n\n请完全关闭并重启 HBuilderX 后重新运行",
|
||||
showCancel: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
openScrapDialog(material) {
|
||||
const myName = (this.me && (this.me.display_name || this.me.username)) || "";
|
||||
const consumer = material.consumer_name || "";
|
||||
this.dlg = {
|
||||
visible: true,
|
||||
momLineId: material.mom_line_id,
|
||||
materialName: material.material_name || "(未命名物料)",
|
||||
specModel: material.spec_model || "",
|
||||
consumerName: consumer,
|
||||
maxQty: material.quantity,
|
||||
quantity: String(material.quantity || ""),
|
||||
reason: "",
|
||||
isProxy: !!consumer && !!myName && consumer !== myName,
|
||||
confirmed: false,
|
||||
// ★ 打开时生成一次,重试复用 —— 每次提交都换新的话,
|
||||
// 用户重试会在 MOM 里多报一张报废单
|
||||
trackRef: makeTrackRef(),
|
||||
};
|
||||
},
|
||||
|
||||
closeDlg() {
|
||||
if (this.submitting) return; // 提交中不许关,避免用户以为没提交
|
||||
this.dlg.visible = false;
|
||||
},
|
||||
|
||||
/** 整张出库单一起摘掉(挂错了要能一次撤)。规则与逐条删一致:含 MOM
|
||||
* 回调存档的单删不掉(后端 409),那是系统事实 */
|
||||
confirmRemoveOrder(order) {
|
||||
uni.showModal({
|
||||
title: '删除整张出库单',
|
||||
content: '把出库单「' + order.outbound_no + '」从这台设备上整张摘掉?\n\n'
|
||||
+ '只解除 Track 这边的挂载关系,不会动 MOM 里的出库单本身,'
|
||||
+ '已提交的报废记录也不受影响。',
|
||||
confirmText: '删除',
|
||||
confirmColor: '#dc2626',
|
||||
success: (res) => { if (res.confirm) this.doRemoveOrder(order.outbound_no); },
|
||||
});
|
||||
},
|
||||
|
||||
async doRemoveOrder(outboundNo) {
|
||||
try {
|
||||
await removeProductOutboundOrder(this.productId, outboundNo);
|
||||
uni.showToast({ title: '已删除整张出库单', icon: 'success' });
|
||||
await this.load();
|
||||
} catch (e) {
|
||||
uni.showModal({
|
||||
title: '删除失败',
|
||||
content: String(e?.data?.detail || e?.errMsg || '删除失败'),
|
||||
showCancel: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/** 摘掉一条挂错的领用物料(与 PC 端「出库单据」卡的删除同一语义) */
|
||||
confirmRemove(material) {
|
||||
// 二次确认必须把「删的是什么、不删什么」说清楚 ——
|
||||
// 用户最怕的是「我删了会不会把 MOM 里的出库单也搞没了」
|
||||
uni.showModal({
|
||||
title: '删除出库明细',
|
||||
content: '把「' + (material.material_name || '此物料') + '」从这台设备上摘掉?\n\n'
|
||||
+ '只解除 Track 这边的挂载关系,不会动 MOM 里的出库单本身,'
|
||||
+ '已提交的报废记录也不受影响。',
|
||||
confirmText: '删除',
|
||||
confirmColor: '#dc2626',
|
||||
success: (res) => { if (res.confirm) this.doRemove(material); },
|
||||
});
|
||||
},
|
||||
|
||||
async doRemove(material) {
|
||||
try {
|
||||
// ⚠️ 传的是**记录 id**(material.id,Track 侧主键),不是 mom_line_id
|
||||
await removeProductOutboundMaterial(this.productId, material.id);
|
||||
uni.showToast({ title: '已删除', icon: 'success' });
|
||||
await this.load();
|
||||
} catch (e) {
|
||||
uni.showModal({
|
||||
title: '删除失败',
|
||||
content: String(e?.data?.detail || e?.errMsg || '删除失败'),
|
||||
showCancel: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async doSubmit() {
|
||||
const d = this.dlg;
|
||||
const qty = Number(d.quantity);
|
||||
if (!qty || qty <= 0) return uni.showToast({ title: "请填写报废数量", icon: "none" });
|
||||
if (qty > Number(d.maxQty)) {
|
||||
return uni.showToast({ title: `不能超过 ${d.maxQty}`, icon: "none" });
|
||||
}
|
||||
if (d.isProxy && !d.confirmed) {
|
||||
return uni.showToast({ title: "请先确认代报", icon: "none" });
|
||||
}
|
||||
|
||||
this.submitting = true;
|
||||
try {
|
||||
await submitProductScrap(this.productId, {
|
||||
mom_line_id: d.momLineId,
|
||||
quantity: qty,
|
||||
reason: (d.reason || "").trim() || null,
|
||||
track_ref: d.trackRef,
|
||||
});
|
||||
uni.showToast({ title: "已提交,待主管审批", icon: "success" });
|
||||
d.visible = false;
|
||||
await this.fetchScrapRecords();
|
||||
} catch (e) {
|
||||
// ★ 不关弹层、不换 trackRef:用户改完数量或稍后重试走的是同一个幂等键,
|
||||
// 不会在 MOM 里多报一张单
|
||||
uni.showModal({
|
||||
title: "报废提交失败",
|
||||
content: String(e?.data?.detail || e?.errMsg || "提交失败"),
|
||||
showCancel: false,
|
||||
});
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
},
|
||||
|
||||
badgeClass(s) {
|
||||
if (s.mom_executed) return "badge-done";
|
||||
if (s.mom_status === 2 || s.mom_status === 4) return "badge-off";
|
||||
return "badge-wait";
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { padding: 12px; padding-bottom: 40px; }
|
||||
.hint { text-align: center; padding: 40px 0; color: #6b7280; font-size: 13px; }
|
||||
|
||||
.card { background: #fff; border-radius: 12px; padding: 14px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
||||
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
|
||||
.card-title { font-size: 15px; font-weight: 700; }
|
||||
.count { font-size: 12px; color: #9ca3af; }
|
||||
|
||||
.head-line { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
|
||||
.head-sn { font-family: monospace; font-size: 14px; font-weight: 700; color: #1f2937; }
|
||||
.head-name { font-size: 13px; color: #4b5563; }
|
||||
.head-spec { font-size: 11px; color: #9ca3af; display: block; margin-top: 2px; }
|
||||
|
||||
.empty { padding: 16px 0; text-align: center; }
|
||||
.empty text { display: block; font-size: 13px; color: #6b7280; }
|
||||
.empty-sub { font-size: 11px; color: #9ca3af; margin-top: 4px; }
|
||||
|
||||
/* 领料按钮:给足点击面积(工地上戴手套点,小了容易点不中) */
|
||||
.card-add { font-size: 12px; font-weight: 600; color: #2563eb; padding: 4px 10px; border: 1px solid #bfdbfe; border-radius: 8px; background: #eff6ff; flex-shrink: 0; }
|
||||
|
||||
/* 出库单:一行一张单,点标题行展开明细 */
|
||||
.order { border: 1px solid #f3f4f6; border-radius: 8px; margin-bottom: 8px; overflow: hidden; }
|
||||
.order:last-child { margin-bottom: 0; }
|
||||
.order-head { padding: 10px; background: #fafafa; }
|
||||
.order-line1 { display: flex; align-items: center; gap: 6px; }
|
||||
.order-no { font-family: monospace; font-size: 13px; font-weight: 700; color: #1f2937; word-break: break-all; }
|
||||
/* flex-wrap:窄屏上「条数 + 时间 + 删除 + 箭头」可能放不下,
|
||||
换行总比把删除按钮挤没强 */
|
||||
.order-line2 { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 4px; }
|
||||
.order-count { font-size: 11px; color: #6b7280; }
|
||||
.order-time { font-size: 11px; color: #9ca3af; }
|
||||
/* 谁挂上去的:比时间略深一点,便于一眼看到 */
|
||||
.order-by { font-size: 11px; color: #6b7280; }
|
||||
/* 整单删除:做成与明细行删除同样的小按钮 —— 裸文字在窄屏上会被时间和箭头
|
||||
挤没,现场看不出这里能点。flex-shrink:0 保证再挤也不会消失 */
|
||||
.order-del { margin-left: auto; flex-shrink: 0; padding: 3px 10px; border: 1px solid #fecaca; border-radius: 8px; background: #fef2f2; color: #dc2626; font-size: 12px; }
|
||||
.chev { font-size: 11px; color: #9ca3af; flex-shrink: 0; }
|
||||
|
||||
/* 展开区:明细行 + 每条自己的操作按钮 */
|
||||
.lines { padding: 8px 10px; border-top: 1px solid #f3f4f6; }
|
||||
.line { display: flex; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 1px solid #f9fafb; }
|
||||
.line:last-child { border-bottom: none; padding-bottom: 0; }
|
||||
.line-info { flex: 1; min-width: 0; }
|
||||
.line-name { font-size: 13px; font-weight: 600; color: #1f2937; display: block; }
|
||||
.line-meta { font-size: 11px; color: #9ca3af; display: block; margin-top: 2px; }
|
||||
|
||||
/* 两个操作并排。都给足点击面积 —— 工地上戴手套点,小了容易点不中 */
|
||||
.row-btns { display: flex; gap: 6px; flex-shrink: 0; }
|
||||
.btn-scrap { padding: 6px 12px; border: 1px solid #fecaca; border-radius: 8px; background: #fef2f2; color: #dc2626; font-size: 12px; font-weight: 600; }
|
||||
.btn-del { padding: 6px 12px; border: 1px solid #e5e7eb; border-radius: 8px; background: #fff; color: #6b7280; font-size: 12px; }
|
||||
|
||||
.scrap-row { border: 1px solid #f3f4f6; border-radius: 8px; padding: 8px; margin-bottom: 6px; }
|
||||
.scrap-row:last-child { margin-bottom: 0; }
|
||||
.row-line1 { display: flex; align-items: center; gap: 6px; }
|
||||
.scrap-name { font-size: 13px; font-weight: 600; color: #1f2937; flex: 1; min-width: 0; }
|
||||
.scrap-qty { font-size: 11px; color: #9ca3af; flex-shrink: 0; }
|
||||
.row-line2 { display: flex; flex-wrap: wrap; gap: 4px 10px; margin-top: 4px; }
|
||||
.scrap-meta { font-size: 11px; color: #6b7280; }
|
||||
.scrap-reason { font-size: 11px; color: #9ca3af; margin-top: 3px; display: block; }
|
||||
|
||||
.badge { font-size: 10px; font-weight: 700; border-radius: 10px; padding: 1px 6px; flex-shrink: 0; }
|
||||
.badge-wait { color: #b45309; background: #fef3c7; }
|
||||
.badge-done { color: #047857; background: #d1fae5; }
|
||||
.badge-off { color: #6b7280; background: #e5e7eb; }
|
||||
|
||||
.overlay { position: fixed; inset: 0; z-index: 999; background: rgba(0,0,0,0.45); display: flex; align-items: flex-end; justify-content: center; }
|
||||
.popup { width: 100%; max-width: 480px; background: #fff; border-radius: 16px 16px 0 0; padding: 20px 16px 32px; max-height: 80vh; overflow-y: auto; }
|
||||
.popup-title { font-size: 16px; font-weight: 700; display: block; text-align: center; margin-bottom: 6px; }
|
||||
.popup-hint { font-size: 12px; color: #9ca3af; display: block; text-align: center; }
|
||||
.popup-input { width: 100%; height: 42px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 0 10px; font-size: 14px; margin: 8px 0; box-sizing: border-box; }
|
||||
.popup-textarea { width: 100%; height: 80px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 10px; font-size: 14px; margin: 10px 0; box-sizing: border-box; }
|
||||
.field-label { font-size: 14px; font-weight: 600; color: #374151; margin-top: 10px; margin-bottom: 4px; }
|
||||
.required { color: #ef4444; }
|
||||
.popup-btns { display: flex; gap: 10px; margin-top: 16px; }
|
||||
.btn-cancel { flex: 1; height: 42px; border: 1px solid #e5e7eb; border-radius: 10px; background: #fff; color: #6b7280; font-size: 14px; line-height: 42px; }
|
||||
.btn-primary { flex: 1; height: 42px; border: none; border-radius: 10px; background: #2563eb; color: #fff; font-size: 14px; font-weight: 600; line-height: 42px; }
|
||||
.btn-primary[disabled] { opacity: 0.5; }
|
||||
|
||||
.proxy { display: flex; align-items: flex-start; gap: 6px; margin-top: 12px; padding: 8px 10px; border: 1px solid #fde68a; border-radius: 8px; background: #fffbeb; }
|
||||
.proxy-icon { font-size: 14px; color: #b45309; flex-shrink: 0; }
|
||||
.proxy-text { font-size: 12px; color: #92400e; line-height: 1.4; }
|
||||
</style>
|
||||
354
track-uniapp/src/pages/material/pick.vue
Normal file
354
track-uniapp/src/pages/material/pick.vue
Normal file
@ -0,0 +1,354 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 挂到这台设备。统一后任务只是溯源信息、不再需要用户先选,
|
||||
所以这里只提示「挂到哪台设备」,不再显示(也不要求)任务名 -->
|
||||
<view class="target-bar">
|
||||
<text class="target-label">挂到设备</text>
|
||||
<text class="target-name">{{ serial || productId }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 搜索 -->
|
||||
<view class="search-bar">
|
||||
<input v-model="keyword" class="search-input" confirm-type="search"
|
||||
placeholder="出库单号 / 物料名称 / 规格 / SKU / 领用人"
|
||||
@confirm="reload" @input="onKeywordInput" />
|
||||
<text v-if="keyword" class="search-clear" @tap="clearKeyword">✕</text>
|
||||
</view>
|
||||
|
||||
<!-- 筛选:日期区间 + 领用人。都是**收窄**条件,可见范围由后端钉死 -->
|
||||
<view class="filter-bar">
|
||||
<picker mode="date" :value="startDate" @change="(e) => { startDate = e.detail.value; reload(); }">
|
||||
<view class="date-chip">{{ startDate || '开始日期' }}</view>
|
||||
</picker>
|
||||
<text class="date-sep">→</text>
|
||||
<picker mode="date" :value="endDate" @change="(e) => { endDate = e.detail.value; reload(); }">
|
||||
<view class="date-chip">{{ endDate || '结束日期' }}</view>
|
||||
</picker>
|
||||
<picker :range="consumerRange" @change="onConsumerChange">
|
||||
<view class="date-chip">{{ consumer || '全部领用人' }}</view>
|
||||
</picker>
|
||||
<text v-if="hasFilter" class="filter-clear" @tap="clearFilter">清空</text>
|
||||
</view>
|
||||
|
||||
<text class="total-line">共 {{ total }} 张单据</text>
|
||||
|
||||
<!-- 结果 -->
|
||||
<view v-if="loading" class="hint">加载中...</view>
|
||||
<view v-else-if="!orders.length" class="hint">
|
||||
{{ hasFilter ? '没有匹配的出库单,试试放宽条件' : '没有可选的出库单' }}
|
||||
</view>
|
||||
|
||||
<view v-else class="list">
|
||||
<view v-for="o in orders" :key="o.outbound_no"
|
||||
:class="['order', isMounted(o) ? 'order-mounted' : (selected[o.outbound_no] ? 'order-selected' : '')]"
|
||||
@tap="toggleOrder(o)">
|
||||
<view class="order-head">
|
||||
<view :class="['tick', selected[o.outbound_no] ? 'tick-on' : '']">
|
||||
<text v-if="selected[o.outbound_no]">✓</text>
|
||||
</view>
|
||||
<text :class="['order-no', isMounted(o) ? 'order-no-muted' : '']">{{ o.outbound_no }}</text>
|
||||
<text v-if="isMounted(o)" class="tag tag-muted">已挂载</text>
|
||||
<text v-else-if="o.outbound_type_label" class="tag">{{ o.outbound_type_label }}</text>
|
||||
<text class="order-time">{{ fmtTime(o.outbound_time) }}</text>
|
||||
</view>
|
||||
<view class="order-meta">
|
||||
<text v-if="o.consumer_name">领用 {{ o.consumer_name }}</text>
|
||||
<text v-if="o.operator_name">经办 {{ o.operator_name }}</text>
|
||||
<text>{{ o.line_count }} 条物料<text v-if="o.total_quantity != null"> · 合计 {{ o.total_quantity }}</text></text>
|
||||
</view>
|
||||
<view class="order-foot">
|
||||
<text class="expand" @tap.stop="toggleExpand(o.outbound_no)">
|
||||
{{ expanded[o.outbound_no] ? '收起明细 ▲' : '查看物料明细 ▼' }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="expanded[o.outbound_no]" class="lines">
|
||||
<view v-for="l in o.lines" :key="l.line_id" class="line">
|
||||
<text class="line-name">{{ l.material_name || '(未命名物料)' }}</text>
|
||||
<text v-if="l.spec_model" class="line-spec">{{ l.spec_model }}</text>
|
||||
<text class="line-qty">×{{ l.quantity }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部固定操作条 -->
|
||||
<view class="footer">
|
||||
<text class="footer-info">已选 {{ selectedCount }} 张单({{ selectedLineCount }} 条物料)</text>
|
||||
<button class="footer-btn" :disabled="!selectedCount || submitting" @tap="doConfirm">
|
||||
{{ submitting ? '挂载中...' : '确认挂载' }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { searchMomOutbounds, listMomOutboundConsumers, mountProductOutboundMaterials } from "../../api/material";
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
productId: "",
|
||||
serial: "",
|
||||
taskId: "", // 可空,仅作溯源
|
||||
keyword: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
consumer: "",
|
||||
consumerOptions: [""],
|
||||
// 当前登录人(定默认领用人筛选用),与 PC 端同一口径
|
||||
me: null,
|
||||
orders: [],
|
||||
total: 0,
|
||||
loading: false,
|
||||
selected: {}, // { outbound_no: true }
|
||||
expanded: {},
|
||||
mountedNos: [], // 已挂过的单号(后端幂等会跳过,这里标出来让用户看得见)
|
||||
submitting: false,
|
||||
debounceTimer: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
consumerRange() { return ["全部领用人"].concat(this.consumerOptions.filter(Boolean)); },
|
||||
hasFilter() { return !!(this.keyword.trim() || this.consumer || this.startDate || this.endDate); },
|
||||
selectedOrders() { return this.orders.filter((o) => this.selected[o.outbound_no]); },
|
||||
selectedCount() { return this.selectedOrders.length; },
|
||||
selectedLineCount() {
|
||||
return this.selectedOrders.reduce((n, o) => n + (o.lines || []).length, 0);
|
||||
},
|
||||
},
|
||||
async onLoad(options) {
|
||||
this.productId = options.productId || "";
|
||||
this.serial = decodeURIComponent(options.serial || "");
|
||||
// 任务可空:只有设备下恰好一条任务时上游才会带,仅作溯源
|
||||
this.taskId = options.taskId || "";
|
||||
// 上游把单号列表做了 encodeURIComponent(逗号会变成 %2C),这里必须解回来再切 ——
|
||||
// 不解的话整个列表会当成**一个**单号,已挂载一个都标不出来
|
||||
const rawMounted = decodeURIComponent(options.mounted || "");
|
||||
this.mountedNos = rawMounted.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
|
||||
// 当前登录人:用来定默认领用人筛选(与 PC 端 MomOutboundPicker 同一口径)
|
||||
try {
|
||||
const raw = uni.getStorageSync("user");
|
||||
this.me = raw ? (typeof raw === "string" ? JSON.parse(raw) : raw) : null;
|
||||
} catch (e) {
|
||||
this.me = null;
|
||||
}
|
||||
|
||||
// ★ 顺序不能反:必须先拿到领用人列表,才能判断「自己在不在里面」,
|
||||
// 也才能带着默认条件只查一次。反过来会先查一次全部、再查一次,
|
||||
// 不但多打一次 MOM,界面还会先闪一屏别人的单据再被抽走。
|
||||
await this.loadConsumers();
|
||||
this.reload();
|
||||
},
|
||||
methods: {
|
||||
/** 已挂载:该单全部明细都已在任务上(后端幂等会跳过,这里只是标记) */
|
||||
isMounted(o) {
|
||||
return this.mountedNos.indexOf(o.outbound_no) >= 0;
|
||||
},
|
||||
|
||||
fmtTime(iso) {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "—";
|
||||
const p = (n) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
},
|
||||
|
||||
async loadConsumers() {
|
||||
try {
|
||||
this.consumerOptions = (await listMomOutboundConsumers()) || [];
|
||||
} catch (e) {
|
||||
// 拉不到名单就退回「全部领用人」——不能因为下拉挂了就卡住整个选择器
|
||||
this.consumerOptions = [];
|
||||
}
|
||||
|
||||
// ★ 默认筛成**当前账号本人**(与 PC 端 MomOutboundPicker 完全一致)。
|
||||
// 一线的人绝大多数时候查的是自己领的单,默认筛上能省一次选择。
|
||||
// ⚠️ 但本人在可选列表里**不存在时不硬筛**:筛了会得到一屏空白,
|
||||
// 用户会以为系统坏了 —— 宁可先给他看全部。
|
||||
const myName = (this.me && this.me.display_name) || "";
|
||||
this.consumer = myName && this.consumerOptions.indexOf(myName) >= 0 ? myName : "";
|
||||
// 兜底:万一账号里没有 display_name,至少用用户名试一次
|
||||
if (!this.consumer && this.me && this.me.username
|
||||
&& this.consumerOptions.indexOf(this.me.username) >= 0) {
|
||||
this.consumer = this.me.username;
|
||||
}
|
||||
},
|
||||
|
||||
async reload() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await searchMomOutbounds({
|
||||
keyword: this.keyword.trim() || undefined,
|
||||
start_date: this.startDate || undefined,
|
||||
end_date: this.endDate || undefined,
|
||||
consumer: this.consumer || undefined,
|
||||
limit: 30,
|
||||
});
|
||||
this.orders = res.orders || [];
|
||||
this.total = res.total || 0;
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e?.data?.detail || "加载失败", icon: "none" });
|
||||
this.orders = [];
|
||||
this.total = 0;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 输入防抖 300ms:不防的话每敲一个字打一次 MOM,慢且刷屏
|
||||
onKeywordInput() {
|
||||
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = setTimeout(() => this.reload(), 300);
|
||||
},
|
||||
clearKeyword() {
|
||||
this.keyword = "";
|
||||
this.reload();
|
||||
},
|
||||
onConsumerChange(e) {
|
||||
const idx = Number(e.detail.value) || 0;
|
||||
this.consumer = idx === 0 ? "" : this.consumerRange[idx];
|
||||
this.reload();
|
||||
},
|
||||
clearFilter() {
|
||||
this.keyword = "";
|
||||
this.startDate = "";
|
||||
this.endDate = "";
|
||||
this.consumer = "";
|
||||
this.reload();
|
||||
},
|
||||
|
||||
toggleOrder(o) {
|
||||
if (this.isMounted(o)) return; // 已挂的不可再选(后端也会跳过)
|
||||
const next = { ...this.selected };
|
||||
if (next[o.outbound_no]) delete next[o.outbound_no];
|
||||
else next[o.outbound_no] = true;
|
||||
this.selected = next;
|
||||
},
|
||||
toggleExpand(no) {
|
||||
this.expanded = { ...this.expanded, [no]: !this.expanded[no] };
|
||||
},
|
||||
|
||||
/**
|
||||
* 勾选里有没有**不是自己领的**单。
|
||||
*
|
||||
* 判据:出库单的领用人(MOM 侧自由填写的姓名)≠ 当前登录人姓名。
|
||||
* ⚠️ 这是**提示**不是权限 —— 料的归属是设备不是人,代挂是合理操作
|
||||
* (测试替生产补挂、库管代录都会发生)。拦一下只是防止「手滑勾错别人的单」。
|
||||
*/
|
||||
proxyOrders() {
|
||||
const myName = (this.me && (this.me.display_name || this.me.username)) || "";
|
||||
if (!myName) return [];
|
||||
return this.selectedOrders.filter(
|
||||
(o) => o.consumer_name && o.consumer_name !== myName);
|
||||
},
|
||||
|
||||
async doConfirm() {
|
||||
// 勾的是**整张单**:提交时展开成该单的全部明细行 id。
|
||||
// 后端按明细行落库,且会跳过已挂过的(幂等)
|
||||
const lineIds = this.selectedOrders.flatMap((o) => (o.lines || []).map((l) => l.line_id));
|
||||
if (!lineIds.length) return uni.showToast({ title: "没有可挂载的明细", icon: "none" });
|
||||
|
||||
// ⚠️ 代挂确认:勾了不是自己领的单,先把话说清楚再提交。
|
||||
// 放这里是**提交前**拦一道,用户还能取消回去改勾选。
|
||||
const proxy = this.proxyOrders;
|
||||
if (proxy.length) {
|
||||
const who = [...new Set(proxy.map((o) => o.consumer_name).filter(Boolean))].join("、");
|
||||
const ok = await new Promise((resolve) => {
|
||||
uni.showModal({
|
||||
title: "确认代挂",
|
||||
content: `选中里有 ${proxy.length} 张不是你自己领的单(领用人:${who})。\n\n`
|
||||
+ "挂上去会记在你名下(挂载人),确认继续?",
|
||||
confirmText: "确认代挂",
|
||||
success: (res) => resolve(res.confirm),
|
||||
fail: () => resolve(false),
|
||||
});
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
this.submitting = true;
|
||||
try {
|
||||
await mountProductOutboundMaterials(this.productId, lineIds, this.taskId);
|
||||
uni.showToast({ title: "已挂载", icon: "success" });
|
||||
// 返回领用物料页;它的 onShow 会重新拉一次,把新挂的料显示出来。
|
||||
// ⚠️ navigateBack 失败是**静默**的(只在控制台留一行),用户会以为
|
||||
// 「报上去了但没反应」—— 必须弹出来,并给一条手动退路。
|
||||
setTimeout(() => {
|
||||
uni.navigateBack({
|
||||
fail: (err) => {
|
||||
console.error("[material] 返回失败:", err);
|
||||
uni.showModal({
|
||||
title: "已挂载,但没自动返回",
|
||||
content: "请手动点左上角返回,列表会自动刷新。",
|
||||
showCancel: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
}, 600);
|
||||
} catch (e) {
|
||||
uni.showModal({
|
||||
title: "挂载失败",
|
||||
content: String(e?.data?.detail || e?.errMsg || "挂载失败"),
|
||||
showCancel: false,
|
||||
});
|
||||
} finally {
|
||||
this.submitting = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 底部有固定操作条,留出高度避免最后一条被盖住 */
|
||||
.page { padding: 12px; padding-bottom: 90px; }
|
||||
|
||||
.target-bar { display: flex; align-items: center; gap: 8px; background: #eff6ff; border-radius: 10px; padding: 8px 12px; margin-bottom: 10px; }
|
||||
.target-label { font-size: 12px; color: #6b7280; }
|
||||
.target-name { font-size: 13px; font-weight: 700; color: #2563eb; }
|
||||
|
||||
.search-bar { position: relative; margin-bottom: 8px; }
|
||||
.search-input { width: 100%; height: 40px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 0 32px 0 12px; font-size: 13px; box-sizing: border-box; background: #fff; }
|
||||
.search-clear { position: absolute; right: 10px; top: 12px; font-size: 14px; color: #9ca3af; }
|
||||
|
||||
.filter-bar { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.date-chip { font-size: 12px; color: #4b5563; background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 5px 10px; }
|
||||
.date-sep { font-size: 12px; color: #9ca3af; }
|
||||
.filter-clear { font-size: 12px; color: #9ca3af; padding: 5px 4px; }
|
||||
|
||||
.total-line { font-size: 12px; color: #9ca3af; display: block; margin-bottom: 8px; }
|
||||
.hint { text-align: center; padding: 40px 16px; color: #9ca3af; font-size: 13px; }
|
||||
|
||||
.list { display: block; }
|
||||
.order { background: #fff; border: 1px solid #f3f4f6; border-radius: 10px; padding: 10px; margin-bottom: 8px; }
|
||||
.order-selected { border-color: #bfdbfe; background: #eff6ff; }
|
||||
.order-mounted { background: #f9fafb; border-color: #e5e7eb; }
|
||||
|
||||
.order-head { display: flex; align-items: center; gap: 6px; }
|
||||
.tick { width: 18px; height: 18px; border: 1px solid #d1d5db; border-radius: 4px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 12px; color: #fff; }
|
||||
.tick-on { background: #2563eb; border-color: #2563eb; }
|
||||
.order-no { font-family: monospace; font-size: 12px; font-weight: 700; color: #1f2937; }
|
||||
.order-no-muted { color: #9ca3af; }
|
||||
.order-time { font-size: 11px; color: #9ca3af; margin-left: auto; flex-shrink: 0; }
|
||||
.tag { font-size: 10px; font-weight: 600; color: #6d28d9; background: #ede9fe; border-radius: 10px; padding: 1px 6px; flex-shrink: 0; }
|
||||
.tag-muted { color: #6b7280; background: #e5e7eb; }
|
||||
|
||||
.order-meta { display: flex; flex-wrap: wrap; gap: 4px 10px; margin-top: 6px; padding-left: 24px; }
|
||||
.order-meta text { font-size: 11px; color: #6b7280; }
|
||||
|
||||
.order-foot { padding-left: 24px; margin-top: 4px; }
|
||||
/* 「查看物料明细」给足点击面积:这是本页最主要的操作之一,太小点不中 */
|
||||
.expand { font-size: 11px; color: #2563eb; padding: 6px 0; display: inline-block; }
|
||||
|
||||
.lines { margin-top: 4px; padding-left: 24px; border-top: 1px solid #f3f4f6; padding-top: 6px; }
|
||||
.line { display: flex; flex-wrap: wrap; gap: 4px 8px; margin-bottom: 3px; }
|
||||
.line-name { font-size: 12px; color: #374151; font-weight: 500; }
|
||||
.line-spec { font-size: 11px; color: #9ca3af; }
|
||||
.line-qty { font-size: 11px; color: #6b7280; }
|
||||
|
||||
.footer { position: fixed; left: 0; right: 0; bottom: 0; display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: #fff; border-top: 1px solid #e5e7eb; }
|
||||
.footer-info { flex: 1; font-size: 12px; color: #4b5563; }
|
||||
.footer-btn { width: 120px; height: 40px; line-height: 40px; border-radius: 10px; background: #2563eb; color: #fff; font-size: 14px; font-weight: 600; border: none; margin: 0; }
|
||||
.footer-btn[disabled] { opacity: 0.5; }
|
||||
</style>
|
||||
@ -38,6 +38,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { checkAppUpdate } from "../../utils/ota";
|
||||
import { post } from "../../utils/request";
|
||||
|
||||
// ============================================================
|
||||
// 缓存清理策略 —— 黑名单式:只删「明确登记过的业务缓存」
|
||||
@ -167,15 +168,31 @@ async function handleCheckUpdate() {
|
||||
await checkAppUpdate({ manual: true });
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
async function handleLogout() {
|
||||
// 退出是不可逆的(要重新输账号密码),按车间使用场景加一道确认防误触
|
||||
uni.showModal({
|
||||
title: "退出登录",
|
||||
content: "退出后需要重新输入账号密码,确定退出吗?",
|
||||
confirmText: "退出",
|
||||
cancelText: "取消",
|
||||
success: (res) => {
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return;
|
||||
|
||||
// 🔴 必须【先 await 上报、再清 token】——两者顺序反了或不等,退出就留不下痕:
|
||||
// 1) uni.reLaunch 会销毁页面上下文,直接掐断尚未发出的 uni.request;
|
||||
// 2) 而 request.js 是在发送时才从 storage 读 access_token,
|
||||
// 先清 storage 的话请求会不带 Authorization,后端只能记成「未认证」。
|
||||
// 这里刻意 try/catch 兜住:上报失败(断网/超时)也绝不能挡住用户退出。
|
||||
uni.showLoading({ title: "退出中...", mask: true });
|
||||
try {
|
||||
await post("/auth/logout");
|
||||
} catch (e) {
|
||||
// 静默:JWT 无状态,服务端本就不需要它成功
|
||||
console.warn("[logout] 上报失败(不影响退出)", e);
|
||||
} finally {
|
||||
uni.hideLoading();
|
||||
}
|
||||
|
||||
try {
|
||||
uni.removeStorageSync("token");
|
||||
uni.removeStorageSync("access_token");
|
||||
|
||||
@ -35,6 +35,53 @@
|
||||
<text :class="['value', product.current_location_id === 'virtual_warehouse' ? 'warehouse' : '']">{{ formatUserName(product.current_location_id) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 🚚 出库单据入口。
|
||||
★ 出库单是挂在**这台设备**上的、不是挂在某个人身上的,所以入口放在
|
||||
「这台设备」的信息卡里最自然。点进去能看全部出库明细、能报废、能补挂。
|
||||
★ 常显不隐藏:以前这里什么都没有时整块消失,用户根本不知道有这功能。
|
||||
没料时也要看得见入口。 -->
|
||||
<view class="mat-entry" @tap="goMaterialPage">
|
||||
<text class="mat-entry-icon">🚚</text>
|
||||
<text class="mat-entry-label">出库单据</text>
|
||||
<!-- 张数与条数都给:只显示「N 条」看不出挂了几张单,反过来也一样。
|
||||
没料时不显示计数,只留入口 -->
|
||||
<text class="mat-entry-count" v-if="mountedMaterials.length">
|
||||
{{ mountedOrderCount }} 张单 / {{ mountedMaterials.length }} 条料
|
||||
</text>
|
||||
<text class="mat-entry-count" v-else>未挂载</text>
|
||||
<text class="mat-entry-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 注:原先这里还有一张独立的「出库单据」卡,与上面产品信息卡里的
|
||||
入口按钮**重复**(两处都叫「出库单据」、说的是同一件事)。
|
||||
详情页只留入口按钮,单据清单与物料明细都在那一页里 ——
|
||||
详情页已经很长,没必要再铺一遍。 -->
|
||||
|
||||
<!-- ♻️ 报废记录:本设备报过的废。状态与金额由后端实时回查 MOM。
|
||||
这里只**展示结果**;报案本身(选料、填数量)在「领用物料」页里做 ——
|
||||
详情页已经很长,把操作挪出去,这里留一眼能看懂的进度。 -->
|
||||
<view class="card" v-if="scrapRecords.length">
|
||||
<view class="card-header">
|
||||
<text class="card-title">♻️ 报废记录</text>
|
||||
<text class="ob-count">共 {{ scrapRecords.length }} 条</text>
|
||||
</view>
|
||||
<view v-for="s in scrapRecords" :key="s.id" class="ob-row">
|
||||
<view class="ob-line1">
|
||||
<text class="ob-no">{{ s.material_name || '(未命名物料)' }}</text>
|
||||
<text :class="['sc-badge', scrapBadgeClass(s)]">{{ s.mom_status_label || '状态未知' }}</text>
|
||||
<text class="ob-time">×{{ s.quantity }}</text>
|
||||
</view>
|
||||
<view class="ob-line2">
|
||||
<text class="ob-meta">报废单 {{ s.scrap_request_no }}</text>
|
||||
<text v-if="s.submitted_by" class="ob-meta">提交人 {{ formatName(s.submitted_by) }}</text>
|
||||
<!-- ★ 只有执行过才有金额。未执行显示「—」,不显示 0 ——
|
||||
0 会让人以为「这东西不值钱」,其实是「还没扫码执行」 -->
|
||||
<text class="ob-meta" v-if="s.mom_executed">损失 {{ formatLoss(s.total_loss) }}</text>
|
||||
</view>
|
||||
<text v-if="s.reason" class="ob-remark">{{ s.reason }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@ -231,6 +278,8 @@
|
||||
import request, { get, post, patch, put, getBaseUrl } from "../../utils/request";
|
||||
import { uploadImages, isUploadedUrl } from "../../utils/upload";
|
||||
import { setUserNameMap, formatUserName, formatUserAvatar } from "../../utils/format";
|
||||
// 本页只**读**报废记录;报案(选料/填数量/提交)在 pages/material/index
|
||||
import { listProductScraps } from "../../api/scrap";
|
||||
import { taskOptionsFor, overallOptionsFor, lifecycleBadge } from "../../utils/lifecycle";
|
||||
import WorkspaceArea from "./components/WorkspaceArea.vue";
|
||||
import TreeCanvas from "./components/TreeCanvas.vue";
|
||||
@ -269,10 +318,27 @@ export default {
|
||||
newMsgText: '',
|
||||
bottomMsgId: '',
|
||||
lastMsgSeenAt: '',
|
||||
// ♻️ 报废记录(只读展示)。状态与金额由后端**实时回查 MOM** ——
|
||||
// 报废没有回调,本地存的那份会过期,而「批没批、执行没执行」正是要看的东西。
|
||||
// 报案入口在「领用物料」页(pages/material/index),不在本页。
|
||||
scrapRecords: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
userLabels() { return this.users.map(u => `${u.full_name} (${u.username})`); },
|
||||
// 🚚 这台设备挂的出库明细条数 —— 只用来在入口按钮上显示数量。
|
||||
// 具体清单/报废/领料都在「出库单据」页里(pages/material/index)。
|
||||
// ⚠️ 读的是 `outbound_records`(统一后的设备级出库明细)。
|
||||
// 以前读 `task_tree[].outbound_materials` —— 那个字段连同它那张表
|
||||
// 一起被合并掉了,后端已经不再返回,照着读**恒为 0 条**
|
||||
// (界面上就表现为「明明有料却显示 0 条,点进去又看得见」)。
|
||||
mountedMaterials() {
|
||||
return (this.product && this.product.outbound_records) || [];
|
||||
},
|
||||
/** 挂了**几张单**(按出库单号去重)—— 与条数一起显示 */
|
||||
mountedOrderCount() {
|
||||
return new Set(this.mountedMaterials.map(m => m.outbound_no).filter(Boolean)).size;
|
||||
},
|
||||
canDeleteImage() { if (!this.recordPopup.task) return true; if (!this.currentUser) return true; const frozen = ["COMPLETED","REJECTED","ARCHIVED","CANCELED"]; if (frozen.includes(this.recordPopup.task.status)) return false; const assignee = this.recordPopup.task.assignee_id; return assignee == this.currentUserId || assignee == this.currentUsername || (this.currentUser && this.currentUser.id == assignee) || (this.currentUser && this.currentUser.username == assignee); },
|
||||
transferUserName() { const u = this.userOptions.find(u => u.id === this.transferForm.selectedUserId); return u ? u.name : ""; },
|
||||
// 🔒 直接完结入口仅超管/主管【可见】——普通人看不到,而不是点了才被后端 403。
|
||||
@ -407,7 +473,15 @@ export default {
|
||||
},
|
||||
onLoad(options) { this.loadUsers(); this.loadCurrentUser(); const sn = options.serial || ""; if (sn) { this.doQuery(sn); return; } /* 🚀 兜底: 从 taskId 反查 product */ const tid = options.taskId || ""; if (tid) this.doQueryByTask(tid); },
|
||||
// 🚀 onShow 生命周期:每次页面显示时刷新留言板(解决从聊天室退回不更新问题)
|
||||
onShow() { if (this.product?.id) { this.fetchMessages(); } },
|
||||
onShow() {
|
||||
if (!this.product?.id) return;
|
||||
this.fetchMessages();
|
||||
// ⚠️ 必须**重新拉产品**,不只是刷新报废记录:
|
||||
// 用户刚在「出库单据」页挂完料返回,入口上的「N 张单 / M 条料」靠的是
|
||||
// product.outbound_records。只刷其它卡片的话计数一直是旧的,
|
||||
// 用户会以为「我刚才那一下没挂上」。
|
||||
this.refreshProductSilently();
|
||||
},
|
||||
// 🚀 页面卸载:清理确认倒计时定时器,避免泄漏
|
||||
onUnload() { this.clearConfirm(); },
|
||||
// ⚠️ 本页【刻意不开启】下拉刷新(pages.json 中已移除 enablePullDownRefresh)。
|
||||
@ -418,11 +492,66 @@ export default {
|
||||
// 状态纠偏不依赖下拉刷新:handleNetworkFailure 会自动静默拉取真实状态。
|
||||
methods: {
|
||||
formatUserName, formatUserAvatar,
|
||||
|
||||
// ==================== ♻️ 生产报废 ====================
|
||||
/** 拉本设备的报废记录(状态与金额由后端实时回查 MOM) */
|
||||
async fetchScrapRecords() {
|
||||
if (!this.product?.id) return;
|
||||
try {
|
||||
this.scrapRecords = (await listProductScraps(this.product.id)) || [];
|
||||
} catch (e) {
|
||||
// 静默失败:报废记录是「附加信息」,拉不到不该挡住产品详情的主流程
|
||||
console.warn('[scrap] 拉取报废记录失败:', e?.data?.detail || e);
|
||||
this.scrapRecords = [];
|
||||
}
|
||||
},
|
||||
|
||||
/** 进「领用物料」页:看这台设备的料、报废、补领 */
|
||||
goMaterialPage() {
|
||||
// ⚠️ 不要写 `if (!id) return` —— 静默返回在界面上就是「点了没反应」,
|
||||
// 现场根本没法判断是没加载完、还是页面没注册。有情况都要说出来
|
||||
if (!this.product || !this.product.id) {
|
||||
uni.showToast({ title: '产品还没加载完,稍后再试', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
uni.navigateTo({
|
||||
url: `/pages/material/index?productId=${this.product.id}`
|
||||
+ `&serial=${encodeURIComponent(this.product.serial_number || '')}`,
|
||||
// ★ navigateTo 失败时 uni 是**静默**的(只在控制台留一行 warning),
|
||||
// 用户只会觉得「点了没反应」。这里必须弹出来。
|
||||
// 最常见的原因:新页面没进 pages.json —— HBuilderX 会缓存它,
|
||||
// 必须**重启 HBuilderX** 才会重新读取,光重新运行不够。
|
||||
fail: (err) => {
|
||||
console.error('[material] 跳转失败:', err);
|
||||
uni.showModal({
|
||||
title: '打不开「领用物料」',
|
||||
content: '页面未注册或未编译:' + (err && err.errMsg ? err.errMsg : err)
|
||||
+ '\n\n请完全关闭并重启 HBuilderX 后重新运行',
|
||||
showCancel: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/** 报废状态 → 徽标配色。已完成绿色、被驳回/撤回灰色、其余蓝色 */
|
||||
scrapBadgeClass(s) {
|
||||
if (s.mom_executed) return 'sc-badge-done';
|
||||
if (s.mom_status === 2 || s.mom_status === 4) return 'sc-badge-off';
|
||||
return 'sc-badge-wait';
|
||||
},
|
||||
|
||||
/** 金额展示。未执行时后端给 null → 显示「—」,不显示 0 */
|
||||
formatLoss(v) {
|
||||
if (v === null || v === undefined) return '—';
|
||||
return '¥' + Number(v).toFixed(2);
|
||||
},
|
||||
|
||||
formatName(name) { if (!name) return ""; return name.length === 2 ? name[0] + " " + name[1] : name; },
|
||||
// 出库时间 → 可读格式。MOM 的 outbound_time 缺失时回退到本行写入时间,
|
||||
// 避免这一列空白(后端返回的是带 +00:00 偏移的 ISO 串,Date 能正确解析)
|
||||
statusLabel(s) { return STATUS_MAP[s] || s; },
|
||||
statusColor(s) { switch (s) { case "PENDING": return "s-yellow"; case "WIP": return "s-blue"; case "COMPLETED": return "s-green"; case "REJECTED": return "s-red"; default: return "s-gray"; } },
|
||||
|
||||
async doQuery(sn) { this.loading = true; this.error = ""; try { this.product = await get(`/products/scan/${sn}`); this.$nextTick(() => { this.currentMode = 'workspace'; this.autoLockTaskId = this.findMyImmersiveTask() || ''; if (!this.product.task_tree || !this.product.task_tree.length) { this.openCreateFirstTask(); } }); this.fetchMessages(); } catch (e) { this.error = e?.data?.detail || "查询失败"; } finally { this.loading = false; } },
|
||||
async doQuery(sn) { this.loading = true; this.error = ""; try { this.product = await get(`/products/scan/${sn}`); this.$nextTick(() => { this.currentMode = 'workspace'; this.autoLockTaskId = this.findMyImmersiveTask() || ''; if (!this.product.task_tree || !this.product.task_tree.length) { this.openCreateFirstTask(); } }); this.fetchMessages(); this.fetchScrapRecords(); } catch (e) { this.error = e?.data?.detail || "查询失败"; } finally { this.loading = false; } },
|
||||
// 🚀 从 taskId 反查 product_serial → 再 doQuery
|
||||
async doQueryByTask(tid) { try { const task = await get(`/tasks/${tid}`); const sn = task?.product_sn || ""; if (sn) { this.doQuery(sn); } else { this.error = "未找到关联产品"; this.loading = false; } } catch { this.error = "任务查询失败"; this.loading = false; } },
|
||||
findMyImmersiveTask() {
|
||||
@ -611,7 +740,12 @@ export default {
|
||||
async refreshProductSilently() {
|
||||
const sn = this.product && this.product.serial_number;
|
||||
if (!sn) return;
|
||||
try { this.product = await get(`/products/scan/${sn}`); } catch (e) { console.error("[refresh] 静默刷新失败:", e); }
|
||||
try {
|
||||
this.product = await get(`/products/scan/${sn}`);
|
||||
// 顺带刷新报废记录:MOM 里主管审批 / 库管扫码执行后状态与金额会变,
|
||||
// 而报废没有回调,只能靠这类「顺手拉一次」让用户看到最新进度
|
||||
this.fetchScrapRecords();
|
||||
} catch (e) { console.error("[refresh] 静默刷新失败:", e); }
|
||||
},
|
||||
|
||||
// ═══ 双重确认倒计时(防误触) ═══
|
||||
@ -773,6 +907,36 @@ export default {
|
||||
.value { font-size: 14px; color: #1f2937; font-weight: 600; word-break: break-all; }
|
||||
.sn { font-family: monospace; }
|
||||
.warehouse { color: #7c3aed; }
|
||||
/* 🚚 出库单据(MOM 出库回调存档) */
|
||||
.ob-count { font-size: 12px; color: #9ca3af; flex-shrink: 0; }
|
||||
.ob-row { border: 1px solid #f3f4f6; border-radius: 8px; padding: 8px; margin-bottom: 6px; }
|
||||
.ob-row:last-child { margin-bottom: 0; }
|
||||
/* 已撤回:整行降调 + 单号删除线,但**不隐藏** ——「出过又撤了」也是历史 */
|
||||
.ob-revoked { background: #f9fafb; border-color: #e5e7eb; }
|
||||
.ob-line1 { display: flex; align-items: center; gap: 6px; }
|
||||
.ob-no { font-family: monospace; font-size: 13px; font-weight: 700; color: #1f2937; word-break: break-all; }
|
||||
.ob-no-revoked { color: #9ca3af; text-decoration: line-through; }
|
||||
.ob-badge { font-size: 10px; font-weight: 700; color: #6b7280; background: #e5e7eb; border-radius: 10px; padding: 1px 6px; flex-shrink: 0; }
|
||||
.ob-time { font-size: 11px; color: #9ca3af; margin-left: auto; flex-shrink: 0; }
|
||||
.ob-line2 { display: flex; flex-wrap: wrap; gap: 4px 10px; margin-top: 4px; }
|
||||
.ob-meta { font-size: 11px; color: #6b7280; }
|
||||
.ob-remark { font-size: 11px; color: #9ca3af; margin-top: 3px; display: block; }
|
||||
|
||||
/* 📦 领用物料入口(产品信息卡底部)。
|
||||
常显:以前这里没内容时整块消失,用户根本不知道有这功能 */
|
||||
.mat-entry { display: flex; align-items: center; gap: 6px; margin-top: 12px; padding-top: 10px; border-top: 1px solid #f3f4f6; }
|
||||
.mat-entry-icon { font-size: 15px; }
|
||||
.mat-entry-label { font-size: 14px; font-weight: 600; color: #2563eb; }
|
||||
.mat-entry-count { font-size: 12px; color: #9ca3af; }
|
||||
.mat-entry-arrow { font-size: 16px; color: #9ca3af; margin-left: auto; }
|
||||
|
||||
/* ♻️ 报废记录状态徽标 */
|
||||
.sc-badge { font-size: 10px; font-weight: 700; border-radius: 10px; padding: 1px 6px; flex-shrink: 0; }
|
||||
.sc-badge-wait { color: #b45309; background: #fef3c7; } /* 待审批:琥珀 */
|
||||
.sc-badge-done { color: #047857; background: #d1fae5; } /* 已执行:绿 */
|
||||
.sc-badge-off { color: #6b7280; background: #e5e7eb; } /* 驳回/撤回:灰 */
|
||||
|
||||
/* 代报确认条的样式已随报废弹层移到 pages/material/index.vue */
|
||||
.badge { font-size: 11px; padding: 2px 10px; border-radius: 20px; font-weight: 600; }
|
||||
.s-yellow .badge, .s-yellow { color: #b45309; }
|
||||
.s-blue .badge, .s-blue { color: #1d4ed8; }
|
||||
|
||||
@ -210,3 +210,6 @@ export function get(url, params = {}) {
|
||||
export function post(url, data = {}) { return request({ url, method: "POST", data }); }
|
||||
export function patch(url, data = {}) { return request({ url, method: "PATCH", data }); }
|
||||
export function put(url, data = {}) { return request({ url, method: "PUT", data }); }
|
||||
// DELETE 原先漏了没封装:本模块 get/post/patch/put 都齐了就差它,
|
||||
// 补上省得调用方各自用默认导出的 request() 去拼。命名用 del —— delete 是保留字。
|
||||
export function del(url, data = {}) { return request({ url, method: "DELETE", data }); }
|
||||
|
||||
Reference in New Issue
Block a user