21 Commits

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

- 产品详情页的产品信息卡底部加「出库单据 N 张单 / M 条料」入口,常显不隐藏:
  以前没内容时整块消失,用户根本不知道有这功能。
- 新增「出库单据」页与「选择 MOM 出库物料」页,与 PC 端同一套数据源、
  同一套接口、同一形态(按出库单号分组 + 点开展开明细)。
- 挂载不需要先选任务:任务只是溯源(记 added_by),展示/报废/删除一律按设备走。
- 代挂确认:勾了不是自己领的单时先拦一道。★ 这是**提示**不是权限 ——
  料的归属是设备不是人,代挂是合理操作(测试替生产补挂、库管代录)。
  ⚠️ 判据是 MOM 的 consumer_name 与登录人姓名比对,比错也只是多让用户勾一下。
- navigateTo / navigateBack 失败在 uni 里是**静默**的(只留一行 warning),
  用户看到的就是「点了没反应」。全部补 fail 回调弹窗。
2026-09-23 15:18:06 +08:00
5d5aea1015 refactor(outbound): 出库单据与领用物料合并成一张表
这两者本来就是同一件事(这台设备对应 MOM 的哪些出库单、领了哪些料),
却因为粒度不同被拆成两张表、界面上两张卡:用户要面对两个入口两个删除按钮,
还会问「我在那边挂的怎么这边看不见」。更糟的是**单据级那张没有 mom_line_id,
挂上去的料根本报不了废**。

- 新建 product_outbound_materials,统一到**明细级**(只有它带 mom_line_id,
  而报废要用它定位)。单据级信息(申请单号/备注/撤回)作为冗余列落在每条明细上。
  task_id 改为可空 —— 任务只是溯源信息,不再是组织维度,展示/报废/删除按设备走。
- 接口从 7 个收敛成 3 个(GET/POST/DELETE /products/{id}/outbound-materials,
  外加整单删 by-order)。任务级那套连同 TaskResponse.outbound_materials 一起删掉:
  保留第二个入口只会让「同一个东西两个地方」重新长出来。
- MOM 回调存档改为按 outbound_no 去 MOM **现查明细**逐行落 —— 不查的话
  这台设备「领了什么料」永远是空的,也就报不了废。查不到时退化成单据级存档,
  宁可显示「有这张单但看不到明细」,也不要静默丢掉这张单。
- 扫码响应补 outbound_materials(附「谁挂上去的」中文名,服务端解析)。
  ⚠️ 依赖 task_tree_loader 的 selectinload —— 异步 session 下懒加载会
  MissingGreenlet。
- 前端两张卡合并成一张:按出库单号分组、点开看明细,明细行才有报废/删除。
2026-09-23 15:18:06 +08:00
551819e0e3 feat(scrap): Track 侧生产报废 —— 提交、回查、金额
料领到产线后在生产中报废,要在 MOM 里走报废流程并能统计金额。

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

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

当前没有任何代码读它,补上不影响行为;目的是让两个实例对同一载荷的解析结果
一致 —— 否则将来谁写了读这个字段的代码,会在 LICA 拿到值、在本实例拿到 None,
而且这种不一致是静默的,不会报错。
2026-09-22 13:46:11 +08:00
3c94faf078 feat(audit): MOM 回调归因到实际操作人,不再显示「未认证」
外部回调走 X-API-Key 鉴权、没有 JWT,JWT 依赖不执行,审计中间件读到的
request.state.audit_user 永远是空 —— 操作审计里就出现一堆没有归属的
「外部系统对接」记录,看不出是谁扫的码。

MOM 载荷里本来就带着实际操作人(operator,即 MOM 侧扫码的那位),写进
request.state 即可让审计归因到人;顺手解析中文姓名(查不到也不影响审计,
前端会回退显示账号)。

⚠️ 调用位置必须在 X-API-Key 校验【之后】:密钥不对说明载荷本身就不可信,
   此时把 operator 写进审计等于允许伪造人。放在部门校验之后同样有意为之 ——
   被拦下的外来消息不该留下任何归属痕迹。

取不到操作人时写 "MOM系统" 而非留空:「MOM系统」至少说明这是一次机器回调,
比继续显示「未认证」(读起来像"一个匿名的人")更准确。

实测:MOM 回调后审计记录显示实际操作人姓名;无 operator 时显示「MOM系统」。
2026-09-22 13:44:05 +08:00
817183062d feat(webhook): MOM 回调的部门归属分流 —— 只放行 IRIS 与空白值
MOM 现在同时对接 IRIS 与 LICA 两个 Track 实例,按载荷里的 company_name 分流。

实现:
  · MomInboundPayload / MomOutboundPayload 补 company_name 字段
    (不补的话会被 Pydantic 静默丢弃,分流无从谈起)
  · 新增 _is_foreign_company(),在**鉴权之后、匹配产品之前**拦截
  · 拦截时返回 200 + matched=False + reason="ignored_company" —— 与「未命中」
    保持同一契约,避免 MOM 侧把它当成故障反复重推

⚠️ 判定刻意做成「只排除已知的外来公司」(黑名单),而非「白名单只认 IRIS」:
   MOM 在无法确定公司归属时会回落到扁平配置,该配置指向本实例 —— 这类消息的
   company_name 会是空 / 缺失。若按白名单把空白也拒掉,它们就彻底丢了:MOM
   那边已收到 200、认为投递成功,不会再重推。同理,未见过的新值也一律照常处理。

reason 的取值 "ignored_company" 与 LICA 实例(~/track-lica)保持一致 —— MOM 侧
不解析它,但排查时两边日志要对着看,字段名不一致会白白浪费时间。

实测:
  · LICA 载荷      → {"ok":true,"matched":false,"reason":"ignored_company"}
  · IRIS/空串/缺失 → 照常处理
  · 无 X-API-Key 仍返回 401(鉴权未被绕过)
2026-09-22 13:43:52 +08:00
42f6e242b4 fix(qrcode): 二维码接口去鉴权,并把 qrcode 路径排除出审计
前端以 <img src="/api/v1/products/qrcode/{sn}"> 引用该端点,而 <img> 无法携带
Authorization 头 —— 加了鉴权会让所有二维码图片加载失败(页面显示成破图),
并在审计里刷出大量 401。

去鉴权是安全的:本函数不查数据库,只校验长度并把这个字符串渲染成二维码,
没有任何业务数据泄露面(序列号本身就是调用方提供的)。也刻意不支持 ?token=
兜底 —— 把 JWT 放进 URL 会渗进访问日志、浏览器历史与 Referer,比它想解决的
问题更糟。

随之而来的副作用必须一并处理:qrcode 路径命中 _TRACKED_READ_PREFIXES 里的
/api/v1/products 前缀、又不是 bare list,会被判成「查看详情」逐条留痕。列表页
一次渲染就并发拉几十张图,逐条留痕会把审计日志塞满,真正有价值的操作反而被
淹没。故把 /api/v1/products/qrcode 加进 _IGNORED_PREFIXES。

实测:二维码正常加载;审计中不再出现 qrcode 记录。
2026-09-22 13:43:36 +08:00
192c8ee9cc feat(audit): 扩大 GET 采集范围 + 修正动作文案
【扩大采集:核心业务数据的「查看详情」】
新增 _TRACKED_READ_PREFIXES(notifications / tasks / orders /
products / records)—— products 是补的:GET /products/scan/{sn}(扫码查询)
是整个车间最高频的读操作,不采它等于没采"活跃度"。

_should_audit 的 GET 分支改为三级判断:
  敏感读(export/download/print) → 受跟踪前缀且非裸列表 → 否则不采

用 _is_bare_list 跳过「拉整个列表」:
  · 列表接口被前端高频轮询(消息、任务列表尤其明显),逐条留痕会让
    audit_logs 迅速膨胀,真正有价值的操作反而被淹没;
  · 只有「查看详情」(/tasks/{id}) 才代表用户真的点开了某条业务数据。
  判定用「去掉末尾斜杠后是否恰好等于某前缀」,天然排除查询串。

【修正文案】
- "read": "查询" → "查看详情"(前者易被误解成"随便搜了一下")
- 新增 "mark_read": "标为已读",并在 _SEGMENT_ACTION 补 "read" 映射 ——
  否则 PUT /notifications/{id}/read 会回退到 _METHOD_ACTION(PUT→update),
  把"点开一条通知"记成"修改了某样东西"
- "refresh": "刷新令牌" → "上线"(token 2 小时一换,业务上视作一次上线)

⚠️ 两点须知:
1. notifications / orders 目录下【只有列表路由】,而列表按规则不采集,
   故这两个模块不会产生查看记录 —— 后端没有"查看单条消息"的接口。
   且消息列表被前端轮询,采集它反而会造成日志爆炸,跳过是正确的。
2. products 被纳入后,工人每扫一次码就多一条记录(50 人 × 每天数百次
   ≈ 上万条/天)。若嫌吵,去掉该前缀一行即可。

注:读接口鉴权已在上一提交补齐,故这些"查看详情"记录能正确挂上操作人。
2026-09-21 13:47:44 +08:00
3f34652b07 fix(security): 核心读接口补齐鉴权(P0 — 无 token 可直接拉取业务数据)
现象:不带任何 Token 请求 /api/v1/products/scan/{sn} 等接口直接 200,
业务数据(产品、任务、订单)可被匿名读取。

同时这也是审计「操作人恒为未认证」的根因:
这些路由只挂了 Depends(get_db),JWT 依赖不执行 →
request.state.audit_user 从未写入 → 中间件只能记成「未认证」。

修复:为 3 个文件共 9 条 GET 路由统一补上 Depends(get_current_user)

  products.py  GET /qrcode/{serial_number}
               GET /scan/{serial_number}      ← 匿名可读产品数据
               GET /                          ← 列表
               GET /{product_id}
               GET /{product_id}/messages
  tasks.py     GET /
               GET /{task_id}
               GET /by-product/{product_id}
  orders.py    GET /

未改动:notifications.py 本就有鉴权;records.py 无 GET 路由。
三个文件原本已 import get_current_user,未新增导入。

落地前已排查「是否有意免登」:
- /products/qrcode/{sn} 是 PC 打印标签用,非免登场景
- 外部系统调用走独立通道 /external/products/lookup(X-API-Key 鉴权),
  与内部 /products/* 完全分离
故内部读接口本就应要求登录。

⚠️ 连带影响:二维码标签编码的是前端页面地址(/sn/{序列号}),
   此前任何人用手机相机扫码即可查看产品状态,现在会要求登录。
   若业务需要免登查询,应走带 API-Key 的 /external/products/lookup,
   而不是让内部接口裸奔。
2026-09-21 13:47:37 +08:00
df3f914eb1 fix(audit): 修复「退出登录」无日志(PC + 移动端)
现象:审计日志里 logout 记录数恒为 0,退出动作完全不可见。

根因有两处,缺一不可:

1. 移动端(App)根本没有发起过上报
   settings.vue 的 handleLogout 只做 removeStorageSync + reLaunch,
   一个请求都没发。而 uni.reLaunch 会销毁页面上下文、直接掐断未完成的
   uni.request —— 所以必须「先 await 上报、再清 token 与跳转」。

2. PC 端存在时序竞态
   axios 的请求拦截器在微任务里执行、现读 localStorage 取 token;
   而原实现同步清空 localStorage 并立刻 navigate,拦截器跑到时 token
   已经没了 → 请求不带 Authorization → 后端只能记成「未认证」。
   注:只改 AuthContext 不够,调用方 AdminLayout / ProfilePage 原来是
   `logout(); navigate(...)`,不等就跳转照样会掐断请求 —— 三处都得改。

改动:
- 移动端 handleLogout 改 async,await post("/auth/logout") 后再清 token
- AuthContext.logout 改 async(类型同步为 () => Promise<void>),
  先 await 上报再清 token;失败静默,绝不阻断退出
- AdminLayout / ProfilePage 两个调用点补 await
- 全部用 try-catch 兜住:断网/超时也照退不误(JWT 无状态,
  服务端本就不需要它成功)
2026-09-21 13:47:31 +08:00
c3667fe00d fix(audit): 刷新令牌记录不再显示「未认证」
问题:审计列表里 POST /auth/refresh 的操作人恒为「未认证」。
根因:本接口刻意不挂 get_current_user —— 能用到这里,正是因为 access token
已过期、请求里没有 Authorization 头,JWT 依赖不执行,request.state 里
从未写入操作人。而"谁在何时尝试刷新"恰恰是该留痕的信息。

修复:
- core/security.py 新增 peek_token_identity(),与 decode_token 的唯一区别是
  关闭过期校验(刷新场景令牌本就过期,若因过期解不出来还是会漏记)。
  签名校验照常进行,伪造令牌解不出任何东西。
  ⚠️ docstring 中明确:该函数只许用于写审计字段,鉴权一律走 get_current_user
- refresh 接口解码 refresh token 取得 sub/username/display_name/role 写入 state。
  refresh token 的载荷与 access token 完全一致,只有 type 字段不同。

附带效果:活动打点读的正是 request.state.audit_user,修复后刷新请求也会
被计为一次活动 —— 语义正确(会刷新说明用户正在使用)。

验证(7/7):正常刷新记到中文人名与角色;过期令牌虽被拒 401 但仍能记到人;
伪造令牌不认人、显示未认证。历史记录不追溯。
2026-09-21 13:05:41 +08:00
39697ca3ad feat(audit): 每日活动打点表 —— 修正日活「上线/下线时间」口径
【问题】
上线/下线时间此前取自审计记录(写操作)时间,当天只翻看、没做写操作的人
会被整条漏掉;而取登录时间更错(Refresh Token 有效期 7 天,用户不必每天登录,
会出现「登录次数 0 却操作 35 次」的自相矛盾报表)。

【方案 C+:一天一人一行的小状态表】
- 新增 user_daily_seen(user_id, day, first_seen_at, last_seen_at),
  迁移 k1l2m3n4o5p6(紧接 j1k2l3m4n5o6)
- 打点挂在 RequestContextMiddleware —— 它是最外层,能覆盖【所有】请求,
  含不被审计的普通 GET。挂审计中间件没用:那里只记写操作,正是漏人的原因
- 节流:进程内缓存,同一用户 2 分钟内只落盘一次,把"每请求一次写库"
  压到"每人每 2 分钟一次";代价是末次活动时间最多落后 2 分钟
- UPSERT on_conflict_do_update 只刷 last_seen_at,first_seen_at 保持当天首次值
- 打点失败全部吞掉并记日志,绝不影响业务请求

【为什么不复用 audit_logs】
「末次活动」是需要不断 UPDATE 的状态,而审计流水必须只增不改 ——
能改的审计记录等于没有审计价值。写进审计表还会让表随访问量线性膨胀。

【查询合并】
get_daily_usage 改为「活动表 ∪ 审计表」并集:只有活动记录的(只看不操作)
和只有审计记录的(本表上线前的历史数据)都会出现。
上线/下线时间取两者的【最早 / 最晚】而非"活动表优先"——打点有 2 分钟节流、
跨零点或写库失败时可能晚于当天第一次写操作,取 min/max 后结果永不劣于任一来源。

零前端改动、零移动端发版:打点在服务端,PC 与移动端同一套口径、同一张表。
2026-09-21 13:05:35 +08:00
1fea30b03b feat(audit): 日活使用统计 + 双 CSV 导出(审计/上下线,列可自定义)
【日活统计:GET /audit/daily-usage】
按【北京时间自然日 × 操作人】聚合,单个 GROUP BY 完成(count(*) FILTER),
不用窗口函数。指标:上线/下线时间、操作次数、登录/登出次数。

⚠️ 上线/下线时间取【当天首次/末次活动】,刻意不取登录时间:
   Refresh Token 有效期 7 天,用户不必每天重新登录。按登录算会出现
   「登录次数 0、上线时间空,但操作次数 35」——报表自相矛盾。
   时间一律按 +08:00 分日与渲染,否则早班(00:00~08:00)操作会掉到前一天。

【CSV 导出:两个端点 + 列自定义】
- /audit/logs/export        整体审计导出,筛选维度与列表页完全一致
- /audit/daily-usage/export 上下线导出,每人一行
- 列清单由后端统一维护并经 /audit/options 下发(log_export_columns /
  usage_export_columns),前端不硬编码表头,避免两端漂移
- ⚠️ 响应带 UTF-8 BOM:Excel 靠它识别编码,否则中文表头全乱码
- 单次上限 5 万行,超出经 X-Export-Truncated 头告知前端明确提示
  (静默截断比报错更危险)
- list_audit_logs 与 export_audit_logs 共用 _log_filters,
  保证「看到的」与「导出的」永远是同一批数据

【前端】
- 审计页页头新增「人员统计」「导出 CSV」两个按钮,现有表格与筛选零改动
- 人员统计走抽屉(AuditUsagePanel):日期范围+快捷键、日活表格、
  上下线次数彩色标签、北京时间渲染、导出前弹列勾选面板
- ExportColumnsModal 为两处导出共用,默认全选
2026-09-21 12:06:43 +08:00
5290d83463 feat: MOM 撤回出库强制回滚通道
MOM 把误点出库的设备物理回滚到仓库时,Track 被动跟随 MOM 的权威物理状态。

【撤回信号识别】
- action / event 里的 revoke / rollback / revert / cancel 子串匹配
  (MOM 侧字段命名尚未冻结,刻意宽松,避免对方改词就整条链路失联)
- 无显式标记但产品正处于「已出库」时,按"已发货设备收到入库回调 = 货回来了"
  隐式判定

【匹配放宽】
- 常规入库仍严格要求 current_location_id == virtual_warehouse
- 撤回信号、或 payload 带 serial 时才放行「已出库」产品 —— 出库回调已把
  location 置为 None,不放宽则撤回必然失配、静默返回 matched=False
- 刻意不给 sku 兜底也无条件放宽:同型号可能多台,放宽会误标到别的设备

【特权通道】
- 强制覆写 overall_status=已入库 + 位置回滚 virtual_warehouse,优先级高于
  task_service 的【绝对物理终态保护】。两者方向刻意相反:那套约束的是
  "车间内部流转不许用工序名抹掉物理终态",而本接口是物理事实的权威来源。
  代码内已留醒目注释,防止后续维护者误加状态互斥校验
- 改用 sync_product_status 统一双字段同步(原先硬编码 status="ARCHIVED"
  绕过了 lifecycle.py 的约定),并把 status 变化一并计入 changed,
  避免"状态与位置本就正确时纠偏不提交"

【撤回留痕】
- 追加「撤回出库(重新入库)」主线节点。名称里的「入库」二字是必须保留的契约:
  product_service._has_warehouse_task 用子串判定仓库节点,若只有"出库"会让
  location==virtual_warehouse 的产品被注入假的「待仓库收货」虚拟节点,
  出现"已入库却在等收货"的自相矛盾

【重构】
- 抽出 _pick_warehouse_log_task / _match_inbound_product 复用,出库回调同步简化
2026-09-21 11:21:03 +08:00
17b2fab5ca fix(audit): 补齐「退出」留痕 + 登录记录显示中文操作人
【问题①:退出动作在审计里完全不可见】
根因:后端没有 logout 接口。前端「退出」只清本地 localStorage、不产生任何
请求,中间件自然无从采集(中间件里早已预留 "logout" 动作映射,但端点没做)。

- 新增 POST /auth/logout,【仅用于留痕】。JWT 无状态,服务端本就没有可吊销
  的会话,该接口不做任何令牌失效动作 —— 它存在的唯一目的,是让审计记下
  「谁在何时退出了系统」。这点在 docstring 里写明,免得日后被误当安全边界。
- 挂 Depends(get_current_user),让 JWT 依赖把操作人写进 request.state,
  从而记录到真实姓名而非「未认证」
- 前端 logout 改为【先上报、后清 token】。顺序不能反:api.ts 的请求拦截器
  是从 localStorage 取 token 的,清掉后就发不出这个请求了。
  刻意不 await、失败也不阻断 —— 用户点「退出」必须退得掉。

【问题②:登录记录的「操作人」显示英文账号而非中文名】
根因:登录接口只写了 request.state.audit_user(账号),没写 audit_display_name
—— 那一刻还不知道显示名。前端按 display_name || user_id 渲染,于是退化成账号。

- 改为登录成功后补写 display_name / role。可行的原因是中间件在 call_next
  返回【之后】才落库,此刻写 request.state 依然能被采集到。
- 失败登录走不到这一步,保持「只有账号可追责」(密码绝不落库),语义不变。
2026-09-21 11:20:59 +08:00
42a67bfa3a docs: 新增 AGENTS.md —— 记录本地起环境、测试踩坑与已知待修问题
刻意只写验证过的事实:
- 本机装 PG + 必须覆盖 DATABASE_URL/MOM_DB_*/SECRET_KEY 才能起服务
- TestClient 与异步引擎跨事件循环冲突会伪装成随机 500,
  改用 httpx.AsyncClient + ASGITransport 单循环
- 实测确认读接口大面积未鉴权(含匿名可导出个人工时台账)
- 「管理员角色」规则的后端唯一事实来源是 app/core/roles.py
2026-09-21 02:28:47 +00:00
14f707461d feat(admin): 新增「操作审计」页面 + 收敛前端角色判断
- pages/admin/AdminAuditLogPage.tsx:审计日志查询页。支持操作人/模块/动作/
  结果状态/日期区间筛选,表格按时间倒序,行内「详情」抽屉展示完整 URL、
  UA、request_id、错误信息与变更详情。
  中文标签(模块/动作)由服务端下发,前端不维护枚举映射 —— 与
  constants/task.ts 里状态映射的既有做法一致,避免两端各写一份开始漂移。
- services/auditApi.ts:审计接口客户端与类型定义。
- 路由 /admin/audit + 侧边栏「操作审计」入口。
- AdminProductsPage 里手写的角色判断改为复用 isAdminRole():这是同一份
  「管理员角色」规则的第 4 处副本,本次一并对齐(另一处 TaskFlowView 已在用)。
  constants/task.ts 的注释同步指向后端新位置 core/roles.py。

验证:tsc --noEmit 通过;vite build 通过(产出独立 chunk
AdminAuditLogPage-*.js);对接真实后端校验响应字段与 TS 接口定义逐字段一致。
2026-09-21 02:28:17 +00:00
7635802a42 feat(audit): 新增操作审计日志(表/中间件/查询接口)+ 角色常量收敛
背景:系统此前没有操作审计。task_logs 的 task_id 是 NOT NULL 外键,只能挂在
任务上,且全项目仅 4 处写入点 —— 登录、导出、产品增删改、收编完全不留痕。
需求方整理的问题清单里「无审计日志查看页」正源于此:不是没有页面,是没数据。

设计参考 MOM(KCGL) 的 audit_logs / audit_listener,但按 Track 栈做了取舍:

1) 写入时机:MOM 用 SQLAlchemy event listener + 同事务写入,优点是零侵入,
   缺点是**业务回滚时审计一起消失**,而失败/被拒的操作(越权尝试、参数错误)
   恰恰最需要留痕。Track 改为响应生成后用**独立 session** 写入:
   - 业务回滚不影响审计(已验证 422/401 失败操作同样落库)
   - 审计写入失败也不影响业务(全包裹 try/except)
   - 代价:非原子提交,响应后进程立即被 kill 可能丢一条(已注释说明取舍)

2) 采集方式:中间件自动采集写操作 + 导出/下载/打印这类「读但敏感」的 GET。
   路径段推导 module/action/target_id。不做手写埋点,因为手写必然漏 ——
   task_logs 只有 4 处写入点就是前车之鉴。

3) 增量价值:新增 request_id 字段,与 core/logging.py 的结构化日志打通,
   凭一个 ID 就能从审计记录直接跳到那一次接口日志。MOM 无此字段。

4) 敏感信息:details 经 sanitize_details 递归剔除 password/token/secret 等键;
   中间件不读请求体,登录明文密码不会落库(已断言表内无密码痕迹)。

配套改动:
- core/roles.py:角色常量与 is_admin 收敛为单一事实来源。此前同一份
  「管理员角色」规则散在 task_service、products.py 内联判断和前端
  constants/task.ts 三处,已因此发生过「移动端漏判 SUPERVISOR 误挡主管」。
  task_service 改为从 core.roles 导入同名常量,保持既有引用可用。
- core/deps.py:抽出 require_roles/require_admin 可复用依赖,替代内联判断。
- main.py:500 响应显式补 X-Request-ID 头 —— 该响应由 ServerErrorMiddleware
  生成,位于 RequestContextMiddleware 外层,中间件没机会写头。
- auth.py:登录校验前把「尝试的账号」写入 request.state,使登录事件
  (含失败登录)可归属到人,可用于追踪暴力破解。

验证:本地起 PostgreSQL 17 + 迁移后跑端到端测试,32/32 通过
(TestClient 每个请求新建事件循环,与模块级 asyncpg 连接池冲突会报
 "got Future attached to a different loop",故改用 httpx.AsyncClient +
 ASGITransport 单循环;生产 uvicorn 单循环无此问题)。
2026-09-21 02:23:19 +00:00
04eb87b091 fix: 访问日志的 user 字段恒为 null —— 改用 request.state 跨 task 传递
上一提交(4454047)的 RequestContextMiddleware 通过 contextvar 读取 user,
但 Starlette 的 BaseHTTPMiddleware 用 anyio start_soon 把下游放进新 task
执行,而 asyncio 每个 Task 创建时会复制 context —— 路由内 set 的
contextvar 不会回流到中间件,导致访问日志的 user 永远是 null。

修复:
- get_current_user 同时写 contextvar(供请求任务内业务日志用)与
  request.state(由 ASGI scope 承载,跨 task 可见),并带上
  display_name / role 备用。
- 中间件 _log_access 改为优先读 request.state.audit_user。

验证(TestClient + 解析最终 JSON 输出,而非读 record 属性):
- track.access 日志 user=zhangsan01(经 request.state)
- 请求任务内 track.service 日志 user=zhangsan01(经 contextvar)
- 两者 request_id 一致;X-Request-ID 透传正常
2026-09-21 02:11:41 +00:00
4454047ce3 fix: 修复任务分页总数错误 + 补齐可观测性基建
分页总数(#5):
- get_all_tasks 的 total 原为 len(flat_tasks)(当前页条数),移动端
  「我的任务」用 tasks.length < total 判断 hasMore,首页满员时恒为
  false,列表永远停在第一页 20 条。改为独立 COUNT 查询(与
  notifications.py 已有写法保持一致)。

可观测性(#9):
- 新增 core/logging.py:单行 JSON 结构化日志 + request_id/user 上下文
  注入;零第三方依赖;接管 uvicorn 自带 handler 避免格式绕过。
- 新增 core/middleware.py:RequestContextMiddleware 生成/透传
  X-Request-ID 并回写响应头,输出含耗时/用户的结构化访问日志。
- 新增 core/health.py:拆分存活/就绪探针。/health/live 不触依赖;
  /health/ready 探主库,不可用返回 503;MOM 挂掉仅降级不摘流量。
- main.py:全局异常处理器只把堆栈写日志,响应体仅回 request_id;
  接入可选 Sentry(未装 SDK 时静默跳过)。
- auth_service:解析 Token 后写入 user 上下文,日志自动带操作人。

注:异常处理器由 ServerErrorMiddleware 调用,此时 contextvar 已被重置,
故 request_id 同时写入 request.state(由 ASGI scope 承载)再读取。

已用 TestClient 验证:探针状态码/检查项、X-Request-ID 透传与生成、
500 响应携带可对账的 request_id 且不泄露堆栈。
2026-09-21 01:57:20 +00:00
80 changed files with 9262 additions and 184 deletions

136
AGENTS.md Normal file
View 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`),
避免前端再抄一份映射开始漂移。
- 本仓库的提交信息用中文,说明「为什么」而非「改了什么」。

View File

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

View 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")

View 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")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 解析)"""

View File

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

View File

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

View File

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

View File

@ -11,10 +11,18 @@ from app.models.message import ProductMessage
from app.schemas.product import (
ProductCreate,
ProductUpdate,
ProductOutboundMaterialResponse,
ProductResponse,
ProductScanResponse,
ProductScrapCreate,
ProductScrapResponse,
)
from app.services import (
product_service,
product_finalize_service,
product_scrap_service,
product_outbound_material_service,
)
from app.services import product_service, product_finalize_service
from app.services.auth_service import get_current_user
from app.services.qrcode_service import generate_qrcode_png
@ -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(

View File

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

View File

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

View File

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

View File

@ -7,6 +7,7 @@ from app.api.v1.endpoints.dashboard import router as dashboard_router
from app.api.v1.endpoints.auth import router as auth_router
from app.api.v1.endpoints.print import router as print_router
from app.api.v1.endpoints.materials import router as materials_router
from app.api.v1.endpoints.mom_outbounds import router as mom_outbounds_router
from app.api.v1.endpoints.users import router as users_router
from app.api.v1.endpoints.upload import router as upload_router
from app.api.v1.endpoints.records import router as records_router
@ -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)

View 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("审计采集失败(已忽略)")

View File

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

View 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,
)

View 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

View 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
View 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

View File

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

View File

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

View File

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

View 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}>"

View File

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

View File

@ -0,0 +1,157 @@
"""设备出库明细 — 一台设备对应 MOM 的**每一条**出库明细
一行 = 设备上的一条 MOM 出库明细(`trans_outbound` 的一行)。
═══ 为什么是这张表(合并了原先的两张)═══
本表合并了原来的 `product_outbounds`(产品 ↔ 出库**单**,单据级)与
`task_outbound_materials`(任务 ↔ 出库**明细**,明细级)。
它们本来就是**同一件事**——「这台设备对应 MOM 的哪些出库单、领了哪些料」——
却因为粒度不同被拆成两张表、界面上显示成两张卡,用户要面对两个入口、
两个删除按钮,还要猜「我刚才在那边挂的怎么这边看不见」。那是设计失误。
统一到**明细级**,因为只有明细级带 `mom_line_id`(MOM `trans_outbound.id`),
而报废必须靠它定位到具体哪一条出库明细。单据级的信息(申请单号、备注、撤回)
作为**冗余列**落在每一条明细上 —— 同单内必然一致,多存几份换取单表自包含。
═══ 两种来源(source)═══
· `manual` —— 人在界面上挂的(网页端/移动端选 MOM 出库单)
· `webhook` —— MOM 出库回调自动存档(按 SN 匹配到设备后写入)
两者语义不同、删除规则也不同(本表的 `manual` 可删;`webhook` 是系统事实,
要撤得去 MOM 撤回,由回调置 `is_revoked`),所以保留 `source` 区分。
═══ task_id 为什么可空 ═══
人工挂载时要选「挂到哪条任务」(一线按任务领料),webhook 存档则**不知道任务**。
但任务只是**溯源信息**,不再是组织维度 —— 展示、报废、删除一律按**设备**维度走。
"""
import uuid
from datetime import datetime
from decimal import Decimal
from sqlalchemy import (
Boolean, DateTime, ForeignKey, Index, Numeric, String, Text, text,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
from app.core.time_utils import get_beijing_time
class ProductOutboundMaterial(Base):
__tablename__ = "product_outbound_materials"
__table_args__ = (
# 同一条 MOM 出库明细不能在一台设备上出现两次(重复提交、前端重放、并发点击、
# webhook 重推)。**部分索引**:mom_line_id 为空的行(MOM 查不到明细的存档)
# 不参与 —— NULL 之间不相等,带上它等于给这类行开了后门。
Index("uq_pom_product_line", "product_id", "mom_line_id",
unique=True, postgresql_where=text("mom_line_id IS NOT NULL")),
# 没有明细行的存档:一张单在一台设备上只留一行
Index("uq_pom_product_no_noline", "product_id", "outbound_no",
unique=True, postgresql_where=text("mom_line_id IS NULL")),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
# ---- 物理外键:设备(组织维度,展示/报废/删除都按它走) ----
product_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("products.id"),
nullable=False, index=True, comment="所属设备ID",
)
# 冗余序列号:按 SN 对账/排查时不必 join products
serial_number: Mapped[str | None] = mapped_column(
String(16), nullable=True, index=True, comment="设备序列号(冗余,便于按SN对账)",
)
# ---- 物理外键:任务(可空,仅溯源用,不参与展示与权限) ----
task_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id"),
nullable=True, index=True,
comment="人工挂载时选的任务(可空,仅溯源用,不参与展示/报废/删除)",
)
# ---- 跨库逻辑外键(MOM 库 trans_outbound.id,无物理约束) ----
mom_line_id: Mapped[int | None] = mapped_column(
nullable=True, index=True,
comment="MOM 出库明细行ID(逻辑外键→MOM trans_outbound.id)。为空=MOM 查不到明细的单据存档",
)
outbound_no: Mapped[str] = mapped_column(
String(100), nullable=False, index=True,
comment="MOM 出库单号(批量出库多商品共用,故本表按明细行成行)",
)
# ---- 单据级信息(同单内一致,冗余在每条明细上换取单表自包含) ----
request_no: Mapped[str | None] = mapped_column(
String(100), nullable=True, comment="MOM 出库申请单号",
)
applicant_name: Mapped[str | None] = mapped_column(
String(100), nullable=True, comment="申请人姓名(MOM 侧解析后传来,不做 ID 反查)",
)
remark: Mapped[str | None] = mapped_column(
Text, nullable=True, comment="出库单备注",
)
# ---- 明细级快照(挂载/回调时从 MOM 拉取,之后 Track 自包含) ----
sku: Mapped[str | None] = mapped_column(
String(100), nullable=True, comment="物料SKU(MOM trans_outbound.sku 快照)",
)
material_name: Mapped[str | None] = mapped_column(
String(255), nullable=True, comment="物料名称(经 COALESCE 三表 JOIN 解析后快照)",
)
spec_model: Mapped[str | None] = mapped_column(
String(255), nullable=True, comment="规格型号快照",
)
quantity: Mapped[Decimal | None] = mapped_column(
Numeric(19, 4), nullable=True,
comment="出库数量(出库单原值,**不是**本设备用量)",
)
unit_price: Mapped[Decimal | None] = mapped_column(
Numeric(19, 2), nullable=True, comment="出库单价",
)
outbound_type: Mapped[str | None] = mapped_column(
String(50), nullable=True,
comment="出库类型 SALES/USE/PRODUCTION/LOSS/REPAIR(MOM 码表未冻结,只存不判)",
)
consumer_name: Mapped[str | None] = mapped_column(
String(100), nullable=True, comment="领用人/客户(MOM 侧自由填写,非可靠标识)",
)
operator_name: Mapped[str | None] = mapped_column(
String(100), nullable=True, comment="MOM 侧操作员",
)
warehouse_location: Mapped[str | None] = mapped_column(
String(100), nullable=True, comment="出库库位快照",
)
outbound_time: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True,
comment="MOM 记录的出库时间(写入时已按 +08:00 补全时区)",
)
# ---- 来源 ----
# manual —— 人在界面上挂的(网页端/移动端选 MOM 出库单)→ **可删**
# webhook —— MOM 出库回调自动存档 → 系统事实,要撤得去 MOM 撤回,**不可删**
source: Mapped[str] = mapped_column(
String(16), nullable=False, default="manual", server_default="manual",
comment="来源: manual(人工挂载,可删) | webhook(MOM回调自动存档,不可删)",
)
# ---- 撤回(只置位不删行)----
is_revoked: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="false",
comment="该次出库是否已被 MOM 撤回",
)
revoked_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, comment="撤回时间",
)
added_by: Mapped[str | None] = mapped_column(
String(64), nullable=True, comment="挂载人ID(逻辑外键→MOM sys_user)",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=get_beijing_time, comment="本行写入时间",
)
# ---- 关系 ----
product: Mapped["Product"] = relationship("Product", lazy="selectin")
def __repr__(self) -> str:
return (f"<ProductOutboundMaterial {self.outbound_no} "
f"line={self.mom_line_id} sku={self.sku}>")

View File

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

View File

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

View File

@ -0,0 +1,103 @@
"""任务挂载的 MOM 出库物料 — 明细级快照
回答「这个任务用了哪些 MOM 出库物料」。口径已与业务确认:
· 选择粒度 = **整张出库单**(outbound_no),该单据的明细一并带入
—— 不存在"单里混了无关物料"的情况
· **纯引用,不记用量**:quantity 是出库单原值,不是"本任务用了多少"
为什么存明细级快照,而不是只存 outbound_no 现查:
MOM 的 trans_outbound 是**明细行**(一行 = 一条物料),物料名/规格要经
COALESCE 三表 JOIN(stock_buy/stock_semi/stock_product → material_base)
才能解析出来。Track 跨库无法 JOIN,若只存单号,每次展示都要打 MOM ——
MOM 挂了就看不到已挂内容。存快照后 Track 自包含,与既有的
product_outbounds 是同一套存档哲学。
成本实测可忽略:517 张单平均 2.64 条明细,65% 是单条明细。
⚠️ mom_line_id 是**跨库逻辑外键**(指向 MOM 库 trans_outbound.id),无物理
约束 —— 与 assignee_id 指向 MOM sys_user 是同一类做法。MOM 库若重建会让
自增 ID 错位,所以同时冗余 outbound_no 供人工核对。
"""
import uuid
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Numeric, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
from app.core.time_utils import get_beijing_time
class TaskOutboundMaterial(Base):
__tablename__ = "task_outbound_materials"
# 防同一条出库明细被重复挂到同一任务(重复提交、前端重放、并发点击)
__table_args__ = (
UniqueConstraint("task_id", "mom_line_id",
name="uq_task_outbound_materials_task_line"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
# ---- 物理外键(关联本库 tasks) ----
task_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id"),
nullable=False, index=True, comment="所属任务ID",
)
# ---- 跨库逻辑外键(MOM 库 trans_outbound.id,无物理约束) ----
mom_line_id: Mapped[int] = mapped_column(
nullable=False, comment="MOM 出库明细行ID(逻辑外键→MOM trans_outbound.id)",
)
outbound_no: Mapped[str] = mapped_column(
String(100), nullable=False, index=True,
comment="MOM 出库单号(批量出库多商品共用,故本表按明细行成行)",
)
# ---- MOM 物料/单据快照(挂载时从 MOM 拉取,之后 Track 自包含) ----
sku: Mapped[str | None] = mapped_column(
String(100), nullable=True, comment="物料SKU(MOM trans_outbound.sku 快照)",
)
material_name: Mapped[str | None] = mapped_column(
String(255), nullable=True, comment="物料名称(经 COALESCE 三表 JOIN 解析后快照)",
)
spec_model: Mapped[str | None] = mapped_column(
String(255), nullable=True, comment="规格型号快照",
)
quantity: Mapped[Decimal | None] = mapped_column(
Numeric(19, 4), nullable=True,
comment="出库数量(出库单原值,**不是**本任务用量)",
)
unit_price: Mapped[Decimal | None] = mapped_column(
Numeric(19, 2), nullable=True, comment="出库单价",
)
outbound_type: Mapped[str | None] = mapped_column(
String(50), nullable=True,
comment="出库类型 SALES/USE/PRODUCTION/LOSS/REPAIR(MOM 码表未冻结,只存不判)",
)
consumer_name: Mapped[str | None] = mapped_column(
String(100), nullable=True, comment="领用人/客户(MOM 侧自由填写,非可靠标识)",
)
operator_name: Mapped[str | None] = mapped_column(
String(100), nullable=True, comment="MOM 侧操作员",
)
warehouse_location: Mapped[str | None] = mapped_column(
String(100), nullable=True, comment="出库库位快照",
)
outbound_time: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True,
comment="MOM 记录的出库时间(写入时已按 +08:00 补全时区)",
)
added_by: Mapped[str | None] = mapped_column(
String(64), nullable=True, comment="挂载人ID(逻辑外键→MOM sys_user)",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=get_beijing_time, comment="挂载时间",
)
# ---- 关系 ----
task: Mapped["Task"] = relationship("Task", back_populates="outbound_materials")
def __repr__(self) -> str:
return f"<TaskOutboundMaterial {self.outbound_no} sku={self.sku}>"

View File

@ -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="当天末次活动时刻",
)

View 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] = []

View File

@ -2,7 +2,7 @@
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
class ProductCreate(BaseModel):
@ -18,6 +18,14 @@ class ProductCreate(BaseModel):
order_id: uuid.UUID | None = Field(None, description="所属订单ID(选填)")
order_no: str | None = Field(None, max_length=64, description="订单号(自由键入,选填)")
parent_product_id: uuid.UUID | None = Field(None, description="父产品ID")
# 建档时一并挂钩的 MOM 出库**明细行** ID(trans_outbound.id)。
# 前端按整张出库单勾选,提交时把该单全部明细 ID 带过来;后端归并回单据后
# 写进 product_outbounds(一行 = 一张单,source=manual)。
# ⚠️ 默认空列表:其它调用方(旧前端、脚本)不带该字段,必须保持行为不变。
mom_line_ids: list[int] = Field(
default_factory=list,
description="建档时挂钩的 MOM 出库明细行ID(trans_outbound.id)",
)
model_config = {"from_attributes": True}
@ -80,6 +88,139 @@ class ProductResponse(BaseModel):
model_config = {"from_attributes": True}
class ProductOutboundResponse(BaseModel):
"""产品出库记录 — 来自 MOM 出库回调的单据存档
一次出库一行,按出库时间倒序返回。**已撤回的记录照常留在列表里**
(is_revoked=True),由前端打标记 —— 不在后端过滤掉,「出过又撤了」
本身就是要看得见的历史。
"""
id: uuid.UUID
outbound_no: str # MOM 出库单号
request_no: str | None = None # MOM 出库申请单号
consumer_name: str | None = None # 领用人/客户
applicant_name: str | None = None # 申请人姓名
operator: str | None = None # MOM 侧实际扫码出库人
outbound_type: str | None = None # SALES / USE / PRODUCTION(只展示,不做业务判断)
outbound_time: datetime | None = None # MOM 记录的出库时间
remark: str | None = None
is_revoked: bool = False
revoked_at: datetime | None = None
# 这一行怎么来的:webhook(MOM 回调自动存档) | manual(人工在界面挂的)
source: str = "webhook"
created_at: datetime # 本行写入时间
model_config = {"from_attributes": True}
class ProductOutboundMaterialResponse(BaseModel):
"""设备的一条 MOM 出库明细(合并后的统一形态)
一行 = 设备上的一条出库明细。`mom_line_id` 为空表示这条是**单据级存档**
(MOM 回调时查不到明细),能看、能标撤回,但**不能报废** —— 报废要用它定位。
"""
id: int
product_id: uuid.UUID
serial_number: str | None = None
task_id: uuid.UUID | None = None # 仅溯源,不参与展示/报废/删除
mom_line_id: int | None = None # MOM trans_outbound.id;为空=无明细的存档
outbound_no: str
# ---- 单据级(同单内一致,冗余在每条明细上) ----
request_no: str | None = None
applicant_name: str | None = None
remark: str | None = None
# ---- 明细级快照 ----
sku: str | None = None
material_name: str | None = None
spec_model: str | None = None
quantity: float | None = None # 出库单原值,**不是**本设备用量
unit_price: float | None = None
outbound_type: str | None = None
outbound_type_label: str = "" # 服务端下发的中文名
consumer_name: str | None = None # 领用人/客户
operator_name: str | None = None
warehouse_location: str | None = None
outbound_time: datetime | None = None
# ---- 来源与撤回 ----
source: str = "manual" # manual(可删) | webhook(系统事实,不可删)
is_revoked: bool = False
revoked_at: datetime | None = None
added_by: str | None = None # 谁挂上去的(Track 用户名)
# 谁挂上去的(中文姓名)。由服务端解析下发 —— 前端不做 username→姓名映射,
# 否则移动端/网页端各抄一份,迟早漂移。
added_by_name: str = ""
created_at: datetime
model_config = {"from_attributes": True}
@model_validator(mode="after")
def _fill_type_label(self):
"""出库类型码 → 中文名。统一在 schema 派生,避免各构造点漏填。"""
if not self.outbound_type_label and self.outbound_type:
from app.services.mom_outbound_service import describe_outbound_type
self.outbound_type_label = describe_outbound_type(self.outbound_type)
return self
class ProductScrapCreate(BaseModel):
"""提交生产报废的请求体。
⚠️ 只传 `mom_line_id`,物料快照一律由后端从 Track 已挂的出库物料里取 ——
不接受前端传快照,否则前端可以伪造「报废了什么」。
"""
# MOM trans_outbound.id,也就是 Track 侧 task_outbound_materials.mom_line_id。
# 后端会校验它**确实挂在本产品上** —— 这是跨设备乱报的唯一防线
# (可见范围是整台设备、不是「谁领的」,所以不能靠隐藏来防)。
mom_line_id: int
# 本次报废数量。上限由 MOM 判(不能超过该出库明细的可退额度),
# 这里不重复校验,避免两处口径漂移。
quantity: float
# 用户填的原因说明(选填)
reason: str | None = None
# 幂等锚点:前端在**打开弹层时**生成一次,重试时复用同一个。
# 后端拼成 <公司>:<track_ref> 发给 MOM,两边同一口径。
track_ref: str
class ProductScrapResponse(BaseModel):
"""生产报废记录 — Track 发起、MOM 受理的报废单。
`mom_status` / `total_loss` 是**实时回查 MOM** 的结果,不是本地快照
(本地那列只在 MOM 暂时查不到时兜底)。
· `mom_status_label`:「待审批 / 已通过(待执行)/ 已执行 / 已驳回 / 已撤回」
· `mom_executed=False` 时 `total_loss` 是 **None 而不是 0** ——
区分「还没执行」和「执行了但损失为 0」,别让用户把未审批看成 0 元损失
"""
id: uuid.UUID
product_id: uuid.UUID
serial_number: str | None = None
task_id: uuid.UUID | None = None
mom_line_id: int
# 快照:MOM 侧数据被清理后仍要能显示「报了什么」
outbound_no: str | None = None
material_name: str | None = None
spec_model: str | None = None
sku: str | None = None
consumer_name: str | None = None # 原领用人,前端据此提示「代报」
quantity: float
reason_category: str = "PRODUCTION"
reason: str | None = None
scrap_request_no: str
defective_goods_id: int | None = None
submitted_by: str | None = None
created_at: datetime
# ---- 以下为实时回查 MOM 的结果 ----
mom_status: int = 0
mom_status_label: str = ""
mom_approved_at: str | None = None
mom_executor_name: str = ""
mom_executed: bool = False
total_loss: float | None = None # 报废损失(单价 × 实报废数量)
scrapped_quantity: float | None = None
model_config = {"from_attributes": True}
class ProductScanResponse(BaseModel):
"""扫码查询响应 — 产品信息 + 完整任务树(递归嵌套)"""
id: uuid.UUID
@ -102,6 +243,9 @@ class ProductScanResponse(BaseModel):
top_level_tasks: list[TaskSummaryResponse] = []
task_tree: list[TaskResponse] = []
assignee_names: dict[str, str] = {} # 🔧 username→中文姓名映射
# 🔧 出库单据存档(来自 MOM 出库回调),按出库时间倒序。
# 本次功能上线前出库的设备没有存档,这里是空列表 —— 不是错误。
outbound_records: list[ProductOutboundMaterialResponse] = []
model_config = {"from_attributes": True}

View File

@ -19,6 +19,15 @@ class TaskCreate(BaseModel):
notify_parent_on_complete: bool = Field(False, description="完成后是否通知父任务")
is_rework: bool = Field(False, description="是否为返工任务")
remark: str | None = Field(None, max_length=2000, description="初始描述/交接备注")
# 创建时一并挂载的 MOM 出库明细行 ID(MOM trans_outbound.id)。
# 粒度是**明细行**,但前端是按整张出库单勾选的 —— 提交时把该单的全部明细
# ID 一起带过来。
# ⚠️ 默认空列表:移动端的 doCreateFirstTask 仍在调本接口且不带该字段,
# 必须保持「不传就等同于不挂载」的行为不变。
mom_line_ids: list[int] = Field(
default_factory=list,
description="创建时挂载的 MOM 出库明细行ID(trans_outbound.id)",
)
model_config = {"from_attributes": True}
@ -153,6 +162,55 @@ class TaskRecordResponse(BaseModel):
# 响应模型
# ============================================================
class TaskOutboundMaterialResponse(BaseModel):
"""任务挂载的一条 MOM 出库物料明细(挂载时从 MOM 取的快照)
一次挂载会展开成多行(挂一张出库单 = 该单的全部明细各一行),
前端按 outbound_no 分组展示。
"""
id: int
# ★ 料挂在哪条任务上。前端按任务分组展示时必须拿它做 key ——
# 不能用 task_name:同一台设备可能有两个同名任务(例如两道「生产」),
# 按名字分会把它们并成一组,看起来像一条任务领了两遍料。
task_id: uuid.UUID
mom_line_id: int # MOM trans_outbound.id,供反查比对
outbound_no: str # MOM 出库单号
sku: str | None = None
material_name: str | None = None
spec_model: str | None = None
# 用 float 而非 Decimal:Pydantic v2 会把 Decimal 序列化成字符串,
# 前端拿到 "5.0000" 不好直接用。数量量级很小(实测 1~186),float 足够。
quantity: float | None = None # 出库单原值,**不是**本任务用量
unit_price: float | None = None
outbound_type: str | None = None
# 出库类型中文名。与 MOM 出库单查询(mom_outbounds)同一套码表、同一份实现,
# 由服务端下发 —— 前端不再自建映射,否则两边会开始漂移。
# 这里用 model_validator 自动派生而不是每个构造点手填:构造点有 3 处
# (task_service 两处 + product_service 一处),漏一个就是空白徽标。
outbound_type_label: str = ""
consumer_name: str | None = None # 领用人/客户
operator_name: str | None = None
warehouse_location: str | None = None
outbound_time: datetime | None = None
added_by: str | None = None # 挂载人(逻辑外键→MOM sys_user)
created_at: datetime
model_config = {"from_attributes": True}
@model_validator(mode="after")
def _fill_outbound_type_label(self):
"""出库类型码 → 中文名(PRODUCTION→生产出库 等)。
在 schema 上统一派生,而不是让 3 个构造点各自记得填 ——
漏一个就是空白徽标,而且不会报错,只能靠肉眼发现。
延迟 import:schemas 被 services 依赖,模块级 import 会形成环。
"""
if not self.outbound_type_label and self.outbound_type:
from app.services.mom_outbound_service import describe_outbound_type
self.outbound_type_label = describe_outbound_type(self.outbound_type)
return self
class TaskSummaryResponse(BaseModel):
"""任务摘要 — 扫码时用,不含嵌套子任务"""
id: uuid.UUID
@ -195,6 +253,9 @@ class TaskResponse(BaseModel):
child_tasks: list[TaskResponse] = []
records: list[TaskRecordResponse] = []
created_by: str | None = None # 谁创建的(从task_logs追溯)
# 本任务挂载的 MOM 出库物料(明细级快照)。创建任务时可选、之后可追加,
# 见 models/task_outbound_material.py。
outbound_materials: list[TaskOutboundMaterialResponse] = []
model_config = {"from_attributes": True}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,356 @@
"""设备出库明细 — 业务逻辑层(合并后的唯一入口)
「这台设备对应 MOM 的哪些出库单、领了哪些料」在本模块只有一个概念、
一张表(`product_outbound_materials`)、一组函数。原先那两张表
(`product_outbounds` 单据级 / `task_outbound_materials` 明细级)已停止写入,
只作回滚备份保留 —— 详见该模型的模块注释。
═══ 两条写入路径 ═══
· `link_outbound_lines` —— 人在界面上挂(网页端/移动端选 MOM 出库单)
→ source='manual',可删
· `archive_from_webhook` —— MOM 出库回调自动存档(按 SN 匹配到设备)
→ source='webhook',系统事实,不可删(要撤得去 MOM 撤回)
两条路径共用同一张表、同一套幂等约束,但**来源不同、删除规则不同** ——
这是它们唯一的差别,`source` 列把它记下来。
"""
import logging
import uuid
from fastapi.concurrency import run_in_threadpool
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.product import Product
from app.models.product_outbound_material import ProductOutboundMaterial
from app.schemas.product import ProductOutboundMaterialResponse
from app.services import mom_outbound_service
logger = logging.getLogger(__name__)
async def list_product_materials(
db: AsyncSession, product_id: uuid.UUID,
) -> list[ProductOutboundMaterialResponse]:
"""列出某设备挂载的全部出库明细。
排序:先按 MOM 出库时间倒序,没有时间的沉底,再按写入时间兜底 ——
避免 outbound_time 为空的行插在最前面。
"""
rows = (
await db.execute(
select(ProductOutboundMaterial)
.where(ProductOutboundMaterial.product_id == product_id)
.order_by(
ProductOutboundMaterial.outbound_time.desc().nullslast(),
ProductOutboundMaterial.created_at.desc(),
ProductOutboundMaterial.id.desc(),
)
)
).scalars().all()
items = [ProductOutboundMaterialResponse.model_validate(r) for r in rows]
fill_added_by_names(items)
return items
def fill_added_by_names(
items: list[ProductOutboundMaterialResponse],
) -> list[ProductOutboundMaterialResponse]:
"""就地补上 `added_by_name`(谁挂上去的,中文姓名),并**返回同一个列表**。
返回列表是为了能写成 `fill(x)` 直接当值用 —— 就地修改却返回 None 的话,
`fill(x) or []` 会静默变成空数组(写这行时就差点踩到)。
界面要显示「谁挂的」——`added_by` 存的是 Track 用户名(如 `zhangsan01`),
直接摆出来现场看不懂。解析交给服务端:移动端与网页端各抄一份 username→姓名
的映射迟早会漂移,而且 MOM 的 username 是「姓名/账号」格式,规则不止一条。
⚠️ 解析失败**不能影响列表**:MOM 连不上时姓名降级为空串,前端回落到显示
用户名。查一次是批量 SQL(mom_cache 还带 2h TTL),不给每行单独打库。
"""
from app.services.mom_cache import get_display_names
names = [i.added_by for i in items if i.added_by]
if not names:
return items
try:
mapping = get_display_names(list(dict.fromkeys(names)))
except Exception as e:
logger.warning(f"[OutboundMaterial] 解析挂载人姓名失败,降级显示用户名: {e}")
return items
for i in items:
if i.added_by:
i.added_by_name = mapping.get(i.added_by, "")
return items
async def link_outbound_lines(
db: AsyncSession, product: Product, mom_line_ids: list[int],
*, task_id: uuid.UUID | None = None, added_by: str | None = None,
) -> int:
"""把 MOM 出库**明细行**挂到设备上(人工路径)。返回实际新增行数。
用户勾的是**整张出库单**,提交时把该单全部明细行 id 一起带过来 ——
本表按明细行成行,所以一张单会展开成 N 行。
⚠️ 只接受 `mom_line_ids`,物料快照一律由后端拿 id 去 MOM 现查,
否则前端可以伪造「挂的是什么」。
⚠️ MOM 查询是同步 psycopg2,用 run_in_threadpool 扔出去,别阻塞事件循环。
⚠️ 调用方负责 commit —— 本函数只 flush,好让挂载与产品创建同事务。
幂等:已挂过的明细跳过(部分唯一索引兜底)。
"""
ids = list(dict.fromkeys(int(i) for i in mom_line_ids)) # 去重且保持顺序
if not ids:
return 0
lines = await run_in_threadpool(mom_outbound_service.get_lines_by_ids, ids)
if not lines:
# MOM 侧查不到(数据被清理 / ID 传错)—— 静默返回 0,由调用方比对数量
return 0
existing = set(
(
await db.execute(
select(ProductOutboundMaterial.mom_line_id).where(
ProductOutboundMaterial.product_id == product.id,
ProductOutboundMaterial.mom_line_id.in_(
[ln["line_id"] for ln in lines]),
)
)
).scalars().all()
)
added = 0
for ln in lines:
if ln["line_id"] in existing:
continue
db.add(ProductOutboundMaterial(
product_id=product.id,
serial_number=product.serial_number,
task_id=task_id,
mom_line_id=ln["line_id"],
outbound_no=ln["outbound_no"],
request_no=ln.get("request_no") or None,
applicant_name=None, # 人工挂载拿不到申请人:MOM 的 request_id 存量全为 NULL
remark=None,
sku=ln.get("sku") or None,
material_name=ln.get("material_name") or None,
spec_model=ln.get("spec_model") or None,
quantity=ln.get("quantity"),
unit_price=ln.get("unit_price"),
outbound_type=ln.get("outbound_type") or None,
consumer_name=ln.get("consumer_name") or None,
operator_name=ln.get("operator_name") or None,
warehouse_location=ln.get("warehouse_location") or None,
outbound_time=ln.get("outbound_time"),
source="manual",
added_by=added_by,
))
added += 1
await db.flush()
return added
async def archive_from_webhook(
db: AsyncSession, product: Product, payload, *, company_name: str | None = None,
) -> bool:
"""MOM 出库回调 → 存档到本表(webhook 路径)。返回是否有新写入。
按 `payload.outbound_no` 去 MOM **现查明细**,逐行落 —— 本表是明细级,
只写一条单据级信息的话,这台设备上「领了什么料」就永远是空的。
⚠️ 查不到明细(MOM 数据被清理、或该单确实没有可解析的明细)时,
退化成写**一行 `mom_line_id=NULL` 的存档**:宁可显示「有这张单但看不到明细」,
也不要静默丢掉这张单 —— 用户会以为出库记录丢了。
⚠️ 幂等:已有存档就跳过(webhook 可能因运维重放而重入)。
"""
outbound_no = (getattr(payload, "outbound_no", "") or "").strip()
if not outbound_no:
return False
# 已有该单的存档 → 不重复写。**按 outbound_no 判**(不是按明细行),
# 因为一张单的所有明细必然一起写入,任一行存在即整单已存过。
already = (
await db.execute(
select(ProductOutboundMaterial.id).where(
ProductOutboundMaterial.product_id == product.id,
ProductOutboundMaterial.outbound_no == outbound_no,
).limit(1)
)
).scalar_one_or_none()
if already is not None:
return False
lines = await run_in_threadpool(
mom_outbound_service.get_lines_by_outbound_no, outbound_no)
if not lines:
# 退化成单据级存档:能看、能标撤回,但没有明细行 id,**不能报废**
db.add(ProductOutboundMaterial(
product_id=product.id,
serial_number=product.serial_number,
task_id=None,
mom_line_id=None,
outbound_no=outbound_no,
request_no=getattr(payload, "request_no", None) or None,
applicant_name=getattr(payload, "applicant_name", None) or None,
remark=getattr(payload, "remark", None) or None,
outbound_type=getattr(payload, "outbound_type", None) or None,
consumer_name=getattr(payload, "consumer_name", None) or None,
operator_name=(getattr(payload, "operator", None) or "")[:100] or None,
outbound_time=getattr(payload, "outbound_time", None),
source="webhook",
))
logger.info(f"[OutboundMaterial] {outbound_no} MOM 查不到明细,退化为单据级存档")
return True
for ln in lines:
db.add(ProductOutboundMaterial(
product_id=product.id,
serial_number=product.serial_number,
task_id=None,
mom_line_id=ln["line_id"],
outbound_no=outbound_no,
request_no=getattr(payload, "request_no", None) or None,
applicant_name=getattr(payload, "applicant_name", None) or None,
remark=getattr(payload, "remark", None) or None,
sku=ln.get("sku") or None,
material_name=ln.get("material_name") or None,
spec_model=ln.get("spec_model") or None,
quantity=ln.get("quantity"),
unit_price=ln.get("unit_price"),
outbound_type=(getattr(payload, "outbound_type", None)
or ln.get("outbound_type") or None),
consumer_name=(getattr(payload, "consumer_name", None)
or ln.get("consumer_name") or None),
operator_name=(getattr(payload, "operator", None)
or ln.get("operator_name") or None),
warehouse_location=ln.get("warehouse_location") or None,
outbound_time=(getattr(payload, "outbound_time", None)
or ln.get("outbound_time")),
source="webhook",
))
return True
async def mark_revoked(
db: AsyncSession, product_id: uuid.UUID, outbound_no: str | None = None,
) -> bool:
"""MOM 撤回回调 → 把该单在本设备上的存档标为已撤回。返回是否有改动。
**只置位不删行** ——「出过又撤了」本身就是要看得见的历史。
:param outbound_no: 指定单号则只标那一单;为空则标**最近一条未撤回的**。
MOM 的撤回载荷不保证带单号,而「最近一条未撤回的」是这批里最可能被撤的那张
—— 一批里同一台设备理论上不该出现两条未撤回的出库单(出库后设备已不在
仓库池,再出库匹配不到)。真出现时标错一条,也好过把历史全标脏。
"""
from app.core.time_utils import get_beijing_time
q = select(ProductOutboundMaterial).where(
ProductOutboundMaterial.product_id == product_id,
ProductOutboundMaterial.is_revoked.is_(False),
)
if outbound_no:
q = q.where(ProductOutboundMaterial.outbound_no == outbound_no)
rows = (await db.execute(q)).scalars().all()
else:
# 先定位到「哪一张单」,再把那张单的**全部明细行**一起标 ——
# 只标一行的会让同一张单呈现「一半撤回一半没撤」的鬼状态
latest = (
await db.execute(
q.order_by(
ProductOutboundMaterial.outbound_time.desc().nullslast(),
ProductOutboundMaterial.created_at.desc(),
).limit(1)
)
).scalars().first()
if latest is None:
return False
rows = (
await db.execute(
select(ProductOutboundMaterial).where(
ProductOutboundMaterial.product_id == product_id,
ProductOutboundMaterial.outbound_no == latest.outbound_no,
)
)
).scalars().all()
changed = False
for r in rows:
if not r.is_revoked:
r.is_revoked = True
r.revoked_at = get_beijing_time()
changed = True
return changed
async def remove_product_order(
db: AsyncSession, product_id: uuid.UUID, outbound_no: str,
) -> list[ProductOutboundMaterialResponse]:
"""整张出库单一起摘掉(挂错了要能撤)。
界面上更容易碰到的是「这一单整个挂错了」,逐条删要删好几下,所以补这个。
规则与逐条删**完全一致**:只要这张单在本设备上有一行是 `webhook`
(MOM 回调自动存档的系统事实),整单就不给删 —— 要撤得去 MOM 撤回。
这样「整单删」不会成为绕过单行规则的后门。
"""
no = (outbound_no or "").strip()
if not no:
raise HTTPException(status_code=400, detail="出库单号不能为空")
rows = (
await db.execute(
select(ProductOutboundMaterial).where(
ProductOutboundMaterial.product_id == product_id,
ProductOutboundMaterial.outbound_no == no,
)
)
).scalars().all()
if not rows:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail="这张出库单没有挂在这台设备上")
if any(r.source != "manual" for r in rows):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="该出库单含 MOM 回调自动存档的记录,不能在 Track 里删除;如需撤销请在 MOM 中撤回",
)
for r in rows:
await db.delete(r)
await db.commit()
return await list_product_materials(db, product_id)
async def remove_product_material(
db: AsyncSession, product_id: uuid.UUID, material_id: int,
) -> list[ProductOutboundMaterialResponse]:
"""摘掉一条**人工挂载**的出库明细(挂错了要能撤)。
只允许删 `source='manual'`:webhook 存档是 MOM 出库回调留下的系统事实,
删了 Track 与 MOM 就对不上(MOM 那边单还在)。要撤该去 MOM 撤回,
由回调置 `is_revoked` 留痕。
"""
row = (
await db.execute(
select(ProductOutboundMaterial).where(
ProductOutboundMaterial.id == material_id,
ProductOutboundMaterial.product_id == product_id,
)
)
).scalars().first()
if row is None:
# 不属于这台设备的一律按「查不到」处理,不泄漏别的设备挂了什么
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail="这条出库明细没有挂在这台设备上")
if row.source != "manual":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="该出库单由 MOM 回调自动存档,不能在 Track 里删除;如需撤销请在 MOM 中撤回",
)
await db.delete(row)
await db.commit()
return await list_product_materials(db, product_id)

View File

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

View File

@ -15,10 +15,23 @@ from app.core.lifecycle import (
sync_product_status,
)
from app.models.product import Product
from app.models.product_outbound_material import ProductOutboundMaterial
from app.models.production_order import ProductionOrder
from app.models.task import Task
from app.schemas.product import ProductCreate, ProductUpdate, ProductResponse, ProductScanResponse
from app.schemas.task import TaskSummaryResponse, TaskResponse, TaskRecordResponse
from app.schemas.product import (
ProductCreate,
ProductUpdate,
ProductOutboundMaterialResponse,
ProductResponse,
ProductScanResponse,
)
from app.schemas.task import (
TaskSummaryResponse,
TaskResponse,
TaskRecordResponse,
)
# 设备出库明细:扫码响应要附「谁挂上去的」中文名(见 fill_added_by_names)
from app.services import product_outbound_material_service
def _task_to_response(task: Task) -> TaskResponse:
@ -51,6 +64,10 @@ def _task_to_response(task: Task) -> TaskResponse:
child_tasks=[_task_to_response(c) for c in task.child_tasks],
records=[TaskRecordResponse.model_validate(r) for r in (task.records or [])],
created_by=getattr(task, "created_by", None),
# ⚠️ 这里**不再**带 outbound_materials:物料已统一为**设备级**
# (product_outbound_materials),挂在任务上只会变成第二个数据源 ——
# 正是这次要消除的「同一件事两个地方」。设备出库明细看扫码响应的
# `outbound_records`(已改为读新表)。
)
@ -272,6 +289,23 @@ async def get_product_by_serial(db: AsyncSession, serial_number: str) -> Product
# 🔧 中文名映射(负责人 + 创建人,供前端显示"谁转入在库"等)
assignee_names = _lookup_display_names(list(assignee_ids))
# 🔧 设备的 MOM 出库明细(统一后的唯一来源)—— 产品详情据此回答「这台设备
# 对应 MOM 的哪些出库单、领了哪些料」。撤回的记录照常返回、由前端打
# 「已撤回」标记,不在后端过滤掉:「出过又撤了」也是历史。
# 排序:先按 MOM 记录的出库时间,没有的(旧数据/字段缺失)沉底,再按写入
# 时间兜底 —— 避免 outbound_time 为空的行插在最前面。
outbound_rows = (
await db.execute(
select(ProductOutboundMaterial)
.where(ProductOutboundMaterial.product_id == product.id)
.order_by(
ProductOutboundMaterial.outbound_time.desc().nullslast(),
ProductOutboundMaterial.created_at.desc(),
ProductOutboundMaterial.id.desc(),
)
)
).scalars().all()
return ProductScanResponse(
id=product.id,
serial_number=product.serial_number,
@ -294,9 +328,40 @@ async def get_product_by_serial(db: AsyncSession, serial_number: str) -> Product
],
task_tree=task_tree,
assignee_names=assignee_names, # 🔧 username→中文姓名
# ⚠️ Product 模型没有 to_dict(),本响应是逐字段手工构造的 —— 漏赋值不会
# 报错,只会永远返回默认值(空列表)。
# 附「谁挂上去的」中文名(服务端解析,两端共用;MOM 挂了就降级显示用户名)
outbound_records=product_outbound_material_service.fill_added_by_names(
[ProductOutboundMaterialResponse.model_validate(r) for r in outbound_rows]
),
)
async def _link_mom_outbound_orders(
db: AsyncSession, product: Product, mom_line_ids: list[int],
) -> int:
"""建产品时勾选的 MOM 出库明细 —— 转交给统一后的设备出库明细服务。
合并后只有一张表(`product_outbound_materials`)、一套挂载逻辑。
这里保留薄封装是因为「建产品时勾选」这条路仍然是产品发起的:
存储、幂等、快照一律由那个服务负责 —— 不再有第二份实现。
返回实际新增行数。
"""
from app.services import product_outbound_material_service
return await product_outbound_material_service.link_outbound_lines(
db, product, mom_line_ids,
)
# 注:原先这里还有 get_product_materials / get_product_outbound_orders /
# add_product_outbound_orders / remove_product_outbound_order 四个函数 ——
# 它们服务的是「单据级」的 product_outbounds 与「任务级」的
# task_outbound_materials。两张表已统一到 product_outbound_materials,
# 读写一律走 product_outbound_material_service,故一并删除。
# 旧表与其数据仍在库里(只停写),需要时可按迁移的 downgrade 回滚。
async def get_product(db: AsyncSession, product_id: uuid.UUID) -> Product:
"""获取产品,不存在则 404"""
result = await db.execute(
@ -349,6 +414,10 @@ async def create_product(db: AsyncSession, data: ProductCreate, creator_username
current_location_id=creator_username or None, # 谁创建,初始位置就是谁
)
db.add(product)
# 挂钩建档时选中的 MOM 出库单 —— 与产品**同事务**:产品建失败时不会留下
# 孤立的挂载行。此前 commit 一次就够,现在多这一步在 commit 之前。
if data.mom_line_ids:
await _link_mom_outbound_orders(db, product, data.mom_line_ids)
await db.commit()
await db.refresh(product, ["order"])

View File

@ -3,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)
# ============================================================

View File

@ -96,6 +96,10 @@ async def load_task_tree_by_root(
noload(Task.parent_task), # 组装树不需要 parent 引用
selectinload(Task.records), # 🔥 一次性预加载所有进度记录
selectinload(Task.product), # 🔥 一次性预加载产品引用
# 🔥 预加载挂载的出库物料:扫码响应要带它(移动端「领用物料」靠它渲染)。
# ★ 必须在这里预加载,不能等 _task_to_response 里现取 ——
# 异步 session 下懒加载会抛 MissingGreenlet(本仓踩过的坑)。
selectinload(Task.outbound_materials),
)
.where(Task.id.in_(select(task_tree_cte.c.id)))
)
@ -157,6 +161,8 @@ async def load_task_trees_by_product(
noload(Task.parent_task),
selectinload(Task.records),
selectinload(Task.product),
# 同上:扫码响应要带挂载的出库物料,必须预加载(懒加载会 MissingGreenlet)
selectinload(Task.outbound_materials),
)
.where(Task.id.in_(select(task_tree_cte.c.id)))
)

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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>
);
}

View File

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

View File

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

View File

@ -0,0 +1,486 @@
/** 出库单据卡 — 这台设备对应 MOM 的哪些出库单、领了哪些料
*
* ⚠️ 这里曾经是**两张卡**:一张「出库单据」(读 product_outbounds,单据级)
* 加一张「领用物料」(读 task_outbound_materials,明细级)。它们本来就是
* 同一件事,拆成两张的后果是:用户要面对两个入口、两个删除按钮,还会问
* 「我在那边挂的怎么这边看不见」;更糟的是**单据级那张没有 mom_line_id,
* 挂上去的料报不了废**。
* 后端已把两张表合并成 product_outbound_materials,本组件随之合并成一张。
* 不要因为「单据」和「物料」听起来不同就再拆开 —— 它们是同一件事。
*
* 展示形态(与移动端 pages/material/index.vue 保持一致):
* 按**出库单号**分组,一行一张单,点开看明细。不按任务分组 ——
* 任务名当分组抬头在现场看不懂,而且任务只是溯源信息。
*
* 已撤回的记录**照常显示**(灰底 + 删除线 + 「已撤回」),因为「出过又撤了」
* 本身就是要看得见的历史 —— 后端也不过滤掉。
*/
import { useCallback, useEffect, useState } from "react";
import { Modal } from "antd";
import { ChevronDown, ChevronRight, Loader2, Package, Plus, Trash2, Truck, Undo2 } from "lucide-react";
import MomOutboundPicker from "../admin/MomOutboundPicker";
import { useToast } from "../ui/Toast";
import { useAuth } from "../../contexts/AuthContext";
import {
getProductOutboundMaterials,
listProductScraps,
mountProductOutboundMaterials,
removeProductOutboundMaterial,
removeProductOutboundOrder,
submitProductScrap,
} from "../../services/productApi";
import { extractErrorMessage } from "../../utils/errorMessage";
import type { ProductOutboundMaterial, ProductScrap } from "../../types/api";
interface OutboundRecordsCardProps {
productId: string;
}
/** 时间 → 本地可读格式。MOM 的 outbound_time 缺失时回退到本行写入时间 */
function fmtTime(iso: string | null, fallback: string): string {
const d = new Date(iso || fallback);
if (Number.isNaN(d.getTime())) return "—";
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
/** 幂等锚点:**打开弹窗时生成一次**,重试复用 —— 换新的会在 MOM 里多报一张单 */
function makeTrackRef(): string {
const d = new Date();
const p = (n: number) => String(n).padStart(2, "0");
const ts = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
return `SCRAP-${ts}-${Math.random().toString(36).slice(2, 8)}`;
}
/** 按出库单号分组,保持后端给的顺序(已按出库时间倒序) */
function groupByOrder(list: ProductOutboundMaterial[]) {
const map = new Map<string, ProductOutboundMaterial[]>();
for (const m of list) {
const no = m.outbound_no || "(无单号)";
if (!map.has(no)) map.set(no, []);
map.get(no)!.push(m);
}
return [...map.entries()];
}
export default function OutboundRecordsCard({ productId }: OutboundRecordsCardProps) {
const { toast } = useToast();
const { user } = useAuth();
const [records, setRecords] = useState<ProductOutboundMaterial[] | null>(null);
const [scraps, setScraps] = useState<ProductScrap[]>([]);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
// 挂载不需要先选任务:任务只是溯源信息,展示/报废/删除都按设备走。
// 「谁挂上去的」记在 added_by、「谁出库的」由 MOM 快照带过来 —— 够了。
const [pickerOpen, setPickerOpen] = useState(false);
const [appending, setAppending] = useState(false);
const [removeTarget, setRemoveTarget] = useState<ProductOutboundMaterial | null>(null);
const [removing, setRemoving] = useState(false);
// 整单删除:界面上更容易碰到的是「这一单整个挂错了」,逐条删要删好几下
const [removeOrder, setRemoveOrder] = useState<string | null>(null);
const [scrapTarget, setScrapTarget] = useState<ProductOutboundMaterial | null>(null);
const [scrapForm, setScrapForm] = useState({ quantity: "", reason: "", confirmed: false, trackRef: "" });
const [scrapSubmitting, setScrapSubmitting] = useState(false);
const load = useCallback(async () => {
try {
const [mats, sc] = await Promise.all([
getProductOutboundMaterials(productId),
listProductScraps(productId).catch(() => [] as ProductScrap[]),
]);
setRecords(mats);
setScraps(sc);
} catch (err) {
toast(extractErrorMessage(err, "加载出库单据失败"), "error");
setRecords([]); // 失败也要脱离加载态,否则一直转圈
}
}, [productId, toast]);
useEffect(() => { load(); }, [load]);
const list = records ?? [];
async function handleAppend(momLineIds: number[]) {
if (momLineIds.length === 0) {
setPickerOpen(false);
return;
}
setAppending(true);
try {
// 接口返回该设备当前**全部**出库明细,直接整体覆盖。
// 不传 task_id —— 挂载不需要挂在某条任务上(任务只是溯源,可空)。
setRecords(await mountProductOutboundMaterials(productId, momLineIds));
toast("已添加出库单", "success");
setPickerOpen(false);
} catch (err) {
toast(extractErrorMessage(err, "添加出库单失败"), "error");
} finally {
setAppending(false);
}
}
async function doRemoveOrder() {
if (!removeOrder) return;
setRemoving(true);
try {
setRecords(await removeProductOutboundOrder(productId, removeOrder));
toast("已删除整张出库单", "success");
setRemoveOrder(null);
} catch (err) {
toast(extractErrorMessage(err, "删除失败"), "error");
} finally {
setRemoving(false);
}
}
async function doRemove() {
if (!removeTarget) return;
setRemoving(true);
try {
setRecords(await removeProductOutboundMaterial(productId, removeTarget.id));
toast("已删除", "success");
setRemoveTarget(null);
} catch (err) {
toast(extractErrorMessage(err, "删除失败"), "error");
} finally {
setRemoving(false);
}
}
// ---- 报废 ----
const myName = (user?.display_name ?? "").trim();
const openScrap = (m: ProductOutboundMaterial) => {
setScrapTarget(m);
setScrapForm({
quantity: String(m.quantity ?? ""),
reason: "",
confirmed: false,
trackRef: makeTrackRef(), // 打开时生成一次,重试复用
});
};
// 代报判据:这条料的领用人不是当前登录人。
// ⚠️ 这是**防误操作**不是权限 —— 料的归属是设备不是人,后端不会因此拒绝
const isProxy = !!scrapTarget?.consumer_name && !!myName
&& scrapTarget.consumer_name !== myName;
const doScrap = async () => {
const m = scrapTarget;
if (!m || m.mom_line_id == null) return;
const qty = Number(scrapForm.quantity);
if (!qty || qty <= 0) return toast("请填写报废数量", "error");
if (m.quantity != null && qty > m.quantity) return toast(`不能超过 ${m.quantity}`, "error");
if (isProxy && !scrapForm.confirmed) return toast("请先勾选确认代报", "error");
setScrapSubmitting(true);
try {
await submitProductScrap(productId, {
mom_line_id: m.mom_line_id,
quantity: qty,
reason: scrapForm.reason.trim() || null,
track_ref: scrapForm.trackRef,
});
toast("已提交,待主管审批", "success");
setScrapTarget(null);
await load();
} catch (err) {
// 不关弹窗、不换 trackRef:改完数量重试走的是同一个幂等键
toast(extractErrorMessage(err, "报废提交失败"), "error");
} finally {
setScrapSubmitting(false);
}
};
const groups = groupByOrder(list);
const toggle = (no: string) => setExpanded((prev) => {
const next = new Set(prev);
if (next.has(no)) next.delete(no); else next.add(no);
return next;
});
// 直接开选择器 —— 不再先问「挂到哪条任务」。
// 「谁挂上去的」记在 added_by、「谁出库的」由 MOM 快照带过来,已经够了。
const openPicker = () => setPickerOpen(true);
return (
<>
<div className="rounded-xl bg-white p-4 shadow-sm">
<div className="mb-3 flex items-center gap-2">
<Truck className="h-5 w-5 text-blue-600" />
<h3 className="font-semibold text-gray-800">出库单据</h3>
{list.length > 0 && (
<span className="text-xs text-gray-400">{groups.length} 张单 / {list.length} 条料</span>
)}
<button
onClick={openPicker}
className="ml-auto flex items-center gap-1 rounded-lg border border-blue-200 px-2.5 py-1 text-xs font-medium text-blue-600 transition-colors hover:bg-blue-50"
>
<Plus className="h-3 w-3" />
添加出库单
</button>
</div>
{records === null ? (
<p className="flex items-center justify-center gap-2 py-4 text-xs text-gray-400">
<Loader2 className="h-3.5 w-3.5 animate-spin" />加载中…
</p>
) : list.length === 0 ? (
<p className="py-4 text-center text-xs text-gray-400">
暂无关联的出库单。建档时没挂、或本功能上线前出库的设备都属于这种情况,
可用右上角「添加出库单」补挂。
</p>
) : (
<div className="space-y-2">
{groups.map(([no, items]) => {
const open = expanded.has(no);
const revoked = items[0]?.is_revoked;
return (
<div key={no} className={`overflow-hidden rounded-lg border ${revoked ? "border-gray-200 bg-gray-50" : "border-gray-100"}`}>
<div onClick={() => toggle(no)} className="cursor-pointer px-2.5 py-2 transition-colors hover:bg-gray-100">
<div className="flex items-center gap-2">
<span className={`font-mono text-sm font-medium break-all ${revoked ? "text-gray-400 line-through" : "text-gray-800"}`}>
{no}
</span>
{/* 撤回标记:与后端「撤回只置位不删行」一致,不让它凭空消失 */}
{revoked && (
<span className="flex shrink-0 items-center gap-0.5 rounded-full bg-gray-200 px-2 py-0.5 text-[10px] font-bold text-gray-600">
<Undo2 className="h-3 w-3" />已撤回
</span>
)}
{/* 不展示出库类型(用途)—— 现场只关心「这台设备挂了哪张单、
谁挂的、谁出的库」,多一个「内部领用」徽标只是噪音 */}
<span className="ml-auto shrink-0 text-xs text-gray-400">
{fmtTime(items[0]?.outbound_time ?? null, items[0]?.created_at ?? "")}
</span>
{/* 整单删除:挂错了要能一次摘掉。只在**全部**是人工挂的时显示 ——
含 MOM 回调存档的单不给删(后端也拦),那是系统事实 */}
{items.every((m) => m.source === "manual") && (
<button
onClick={(e) => { e.stopPropagation(); setRemoveOrder(no); }}
title="删除整张出库单"
className="shrink-0 rounded p-1 text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
<div className="mt-1 flex items-center gap-3.5 text-xs text-gray-400">
{items[0]?.consumer_name && <span>领用 {items[0].consumer_name}</span>}
{items[0]?.operator_name && <span>经办 {items[0].operator_name}</span>}
{/* 谁挂上去的 —— 现场要能追责/问人;只记在库里不显示等于没记 */}
{(items[0]?.added_by_name || items[0]?.added_by) && (
<span className="text-gray-500">
挂载 {items[0].added_by_name || items[0].added_by}
</span>
)}
<span>{items.length} 条物料</span>
<span className="ml-auto flex items-center gap-0.5 text-blue-600">
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
{open ? "收起明细" : "物料明细"}
</span>
</div>
{items[0]?.remark && <p className="mt-1 text-xs text-gray-400">{items[0].remark}</p>}
</div>
{open && (
<div className="divide-y divide-gray-50 border-t border-gray-100 px-2.5">
{items.map((m) => {
// 没有明细行 id = MOM 回调只存了单据、查不到明细 → 报不了废
const canScrap = m.mom_line_id != null;
return (
<div key={m.id} className="flex items-center gap-2 py-2">
<div className="min-w-0 flex-1">
<div className="text-[13px] font-medium text-gray-800">
{m.material_name || (canScrap ? "(未命名物料)" : "MOM 出库回调存档")}
</div>
<div className="mt-0.5 text-xs text-gray-400">
{m.spec_model && <span>{m.spec_model} · </span>}
{canScrap ? `×${m.quantity}` : "无明细"}
{m.warehouse_location && <span> · 库位 {m.warehouse_location}</span>}
</div>
</div>
{/* 只有带 mom_line_id 的才能报废 —— MOM 要用它定位到具体明细行 */}
{canScrap && (
<button
onClick={() => openScrap(m)}
className="shrink-0 rounded-lg border border-red-200 bg-red-50 px-2.5 py-1 text-xs font-medium text-red-600 transition-colors hover:bg-red-100"
>
报废
</button>
)}
{/* 只有人工挂的可删:webhook 存档是系统事实,要撤得去 MOM 撤回 */}
{m.source === "manual" && (
<button
onClick={() => setRemoveTarget(m)}
title="删除这条出库明细(挂错了)"
className="shrink-0 rounded-lg border border-gray-200 p-1.5 text-gray-400 transition-colors hover:border-gray-300 hover:bg-gray-50 hover:text-gray-600"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
{/* ♻️ 报废记录:状态与金额由后端实时回查 MOM */}
{scraps.length > 0 && (
<div className="mt-3 border-t border-gray-100 pt-3">
<p className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-gray-600">
<Package className="h-3.5 w-3.5" />报废记录({scraps.length})
</p>
<div className="space-y-1.5">
{scraps.map((s) => (
<div key={s.id} className="rounded-lg border border-gray-100 px-2.5 py-2">
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-gray-800">
{s.material_name || "(未命名物料)"}
</span>
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold ${
s.mom_executed ? "bg-emerald-100 text-emerald-700"
: (s.mom_status === 2 || s.mom_status === 4) ? "bg-gray-200 text-gray-600"
: "bg-amber-100 text-amber-700"
}`}>
{s.mom_status_label || "状态未知"}
</span>
<span className="shrink-0 text-xs text-gray-400">×{s.quantity}</span>
</div>
<div className="mt-1 flex flex-wrap gap-x-3.5 text-xs text-gray-400">
<span>报废单 {s.scrap_request_no}</span>
{s.submitted_by && <span>提交人 {s.submitted_by}</span>}
{/* ★ 只有执行过才有金额。未执行显示「—」不显示 0 ——
0 会让人以为「这东西不值钱」,其实是「还没扫码执行」 */}
{s.mom_executed && (
<span className="text-gray-600">损失 ¥{Number(s.total_loss ?? 0).toFixed(2)}</span>
)}
</div>
{s.reason && <p className="mt-1 text-xs text-gray-400">{s.reason}</p>}
</div>
))}
</div>
</div>
)}
</div>
{/* 选择器:已挂过的单据会在里面显示「已挂载」且不可再选。
提交期间选择器保持打开(按钮转圈),成功后由 handleAppend 关闭 ——
比先关弹窗再等结果更不容易让人以为没生效。 */}
<MomOutboundPicker
open={pickerOpen}
onClose={() => setPickerOpen(false)}
onConfirm={(ids) => handleAppend(ids)}
submitting={appending}
existingOrderNos={list.map((m) => m.outbound_no)}
/>
{/* 整单删除确认 */}
<Modal
open={!!removeOrder}
title="删除整张出库单"
centered
onCancel={() => !removing && setRemoveOrder(null)}
onOk={doRemoveOrder}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true, loading: removing }}
cancelButtonProps={{ disabled: removing }}
>
<p className="text-sm text-gray-700">
把出库单 <span className="font-mono font-medium">{removeOrder}</span> 从这台设备上整张摘掉?
</p>
<p className="mt-2 text-xs text-gray-500">
只解除 Track 这边的挂载关系,<span className="font-medium">不会动 MOM 里的出库单本身</span>,
已提交的报废记录也不受影响。
</p>
</Modal>
{/* 报废:数量 + 说明。分类不让人选 —— 走这条路的料按定义就是生产损耗 */}
<Modal
open={!!scrapTarget}
title="报废"
centered
onCancel={() => !scrapSubmitting && setScrapTarget(null)}
onOk={doScrap}
okText="提交报废"
cancelText="取消"
confirmLoading={scrapSubmitting}
okButtonProps={{ disabled: isProxy && !scrapForm.confirmed }}
>
<p className="text-sm text-gray-700">
{scrapTarget?.material_name || "(未命名物料)"}
<span className="ml-2 text-xs text-gray-400">
{scrapTarget?.spec_model} | 原领用人 {scrapTarget?.consumer_name || "—"}
</span>
</p>
<div className="mt-3">
<label className="mb-1 block text-xs font-medium text-gray-600">
报废数量 <span className="text-red-500">*</span>
</label>
<input
value={scrapForm.quantity}
onChange={(e) => setScrapForm((f) => ({ ...f, quantity: e.target.value }))}
placeholder={`最多 ${scrapTarget?.quantity ?? ""}`}
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-blue-400 focus:outline-none"
disabled={scrapSubmitting}
/>
</div>
<div className="mt-3">
<label className="mb-1 block text-xs font-medium text-gray-600">原因说明</label>
<textarea
value={scrapForm.reason}
onChange={(e) => setScrapForm((f) => ({ ...f, reason: e.target.value }))}
placeholder="例如:测试时跌落,外壳磕裂"
rows={3}
maxLength={200}
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-blue-400 focus:outline-none"
disabled={scrapSubmitting}
/>
</div>
{/* 代报确认:报的不是自己领的料时多一道(防误操作,不是权限) */}
{isProxy && (
<label className="mt-3 flex cursor-pointer items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2">
<input
type="checkbox"
checked={scrapForm.confirmed}
onChange={(e) => setScrapForm((f) => ({ ...f, confirmed: e.target.checked }))}
className="mt-0.5 accent-amber-600"
/>
<span className="text-xs text-amber-800">
这条料不是你领的({scrapTarget?.consumer_name} 领用),确认代报?
</span>
</label>
)}
</Modal>
{/* 删除确认 */}
<Modal
open={!!removeTarget}
title="删除出库明细"
centered
onCancel={() => !removing && setRemoveTarget(null)}
onOk={doRemove}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true, loading: removing }}
cancelButtonProps={{ disabled: removing }}
>
<p className="text-sm text-gray-700">
把「{removeTarget?.material_name || "此物料"}」从这台设备上摘掉?
</p>
<p className="mt-2 text-xs text-gray-500">
只解除 Track 这边的挂载关系,<span className="font-medium">不会动 MOM 里的出库单本身</span>,
已提交的报废记录也不受影响。
</p>
</Modal>
</>
);
}

View File

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

View File

@ -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,导致主管被前端误挡。

View File

@ -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();
}, []);

View File

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

View 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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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 !== "")
);
}

View File

@ -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");
}

View File

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

View File

@ -1,5 +1,9 @@
import api from "./api";
import type { ProductScanResponse } from "../types/api";
import type {
ProductOutboundMaterial,
ProductScanResponse,
ProductScrap,
} from "../types/api";
/** 扫码查询 — 根据 16 位产品身份证查产品 + 顶层任务 */
export async function scanProduct(serialNumber: string): Promise<ProductScanResponse> {
@ -8,3 +12,114 @@ export async function scanProduct(serialNumber: string): Promise<ProductScanResp
);
return data;
}
// ============================================================
// 设备的 MOM 出库明细(统一后只有这一组)
//
// 原先这里是两组接口,对应两张表:
// · outbound-orders —— 产品 ↔ 出库**单**(单据级 product_outbounds)
// · materials —— 任务 ↔ 出库**明细**(明细级 task_outbound_materials)
// 两者是同一个概念,却因为粒度不同被拆开:用户要面对两个入口两张卡,
// 而且走单据级挂的料**没有明细行 id,报不了废**。
// 现已合并成一张表、一组接口、界面上只有一张卡。
// ============================================================
/**
* 列出该设备挂载的全部 MOM 出库明细(按出库时间倒序)。
* 对应后端 GET /api/v1/products/{productId}/outbound-materials
*
* 一行 = 一条出库明细。`mom_line_id` 为空表示这条是**单据级存档**
* (MOM 回调时查不到明细),能看、能标撤回,但**不能报废**。
*/
export async function getProductOutboundMaterials(
productId: string,
): Promise<ProductOutboundMaterial[]> {
const { data } = await api.get<ProductOutboundMaterial[]>(
`/products/${productId}/outbound-materials`,
);
return data;
}
/**
* 给设备挂载 MOM 出库明细(网页端/移动端的「+ 领料」都走这里)。
* 对应后端 POST /api/v1/products/{productId}/outbound-materials
*
* ⚠️ 只传 `mom_line_ids`,物料快照由后端现查 MOM —— 前端不传快照。
* ⚠️ `taskId` 可空,仅作溯源(这条料挂在哪条任务上),不参与展示/报废/删除。
* 幂等:已挂过的明细会被后端跳过。返回该设备当前**全部**出库明细。
*/
export async function mountProductOutboundMaterials(
productId: string,
momLineIds: number[],
taskId?: string | null,
): Promise<ProductOutboundMaterial[]> {
const { data } = await api.post<ProductOutboundMaterial[]>(
`/products/${productId}/outbound-materials`,
{ mom_line_ids: momLineIds, task_id: taskId || null },
);
return data;
}
/**
* 摘掉一条**人工挂载**的出库明细(挂错了要能撤)。
* 对应后端 DELETE /api/v1/products/{productId}/outbound-materials/{materialId}
*
* ⚠️ 只能删 `source='manual'` 的。MOM 回调自动存档的行后端返回 409 ——
* 那是系统事实,要撤得去 MOM 撤回。返回该设备**剩余**的全部出库明细。
*/
export async function removeProductOutboundMaterial(
productId: string,
materialId: number,
): Promise<ProductOutboundMaterial[]> {
const { data } = await api.delete<ProductOutboundMaterial[]>(
`/products/${productId}/outbound-materials/${materialId}`,
);
return data;
}
/**
* 整张出库单一起摘掉(挂错了要能撤)。
* 对应后端 DELETE /api/v1/products/{productId}/outbound-materials/by-order/{outboundNo}
*
* 界面上更容易碰到的是「这一单整个挂错了」,逐条删要删好几下。
* 规则与逐条删一致:含 webhook 存档记录的单整单删不掉(后端 409)。
*/
export async function removeProductOutboundOrder(
productId: string,
outboundNo: string,
): Promise<ProductOutboundMaterial[]> {
const { data } = await api.delete<ProductOutboundMaterial[]>(
`/products/${productId}/outbound-materials/by-order/${encodeURIComponent(outboundNo)}`,
);
return data;
}
// ============================================================
// 生产报废
// ============================================================
/**
* 列出该设备的报废记录(含实时回查 MOM 的状态与金额)。
* 对应后端 GET /api/v1/products/{productId}/scraps
*/
export async function listProductScraps(productId: string): Promise<ProductScrap[]> {
const { data } = await api.get<ProductScrap[]>(`/products/${productId}/scraps`);
return data;
}
/**
* 提交一条生产报废(领用的料在生产中损坏)。
* 对应后端 POST /api/v1/products/{productId}/scraps
*
* ⚠️ `track_ref` 必须在**打开弹窗时生成一次**并在重试时复用 ——
* 每次提交都换新的话,用户重试会在 MOM 里多报一张报废单。
*/
export async function submitProductScrap(
productId: string,
payload: { mom_line_id: number; quantity: number; track_ref: string; reason?: string | null },
): Promise<ProductScrap> {
const { data } = await api.post<ProductScrap>(
`/products/${productId}/scraps`, payload,
);
return data;
}

View File

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

View File

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

View File

@ -48,6 +48,80 @@ export interface TaskResponse extends TaskSummary {
records: TaskRecordResponse[];
/** 🔧 任务创建人(追溯谁转交/发起该工序),如"谁转入在库" */
created_by?: string | null;
/** 🔧 本任务挂载的 MOM 出库物料(创建时选、之后可追加) */
outbound_materials?: TaskOutboundMaterial[];
}
/** 任务挂载的一条 MOM 出库物料明细(挂载时从 MOM 取的快照)
* 一次挂载会展开成多行(挂一张出库单 = 该单全部明细各一行),按 outbound_no 分组展示。 */
export interface TaskOutboundMaterial {
id: number;
/** 料挂在哪条任务上。按任务分组/删除都要用它(不能用任务名,同名会并组) */
task_id: string;
/** MOM trans_outbound.id,供反查比对 */
mom_line_id: number;
/** MOM 出库单号 */
outbound_no: string;
sku: string | null;
material_name: string | null;
spec_model: string | null;
/** 出库单原值,**不是**本任务用量 */
quantity: number | null;
unit_price: number | null;
/** SALES/USE/PRODUCTION/LOSS/REPAIR —— 只展示不判断(MOM 码表未冻结) */
outbound_type: string | null;
/** 出库类型的中文名(服务端按 MOM 码表下发),直接展示,前端不自建映射 */
outbound_type_label: string;
/** 领用人/客户(MOM 侧自由填写,非可靠标识) */
consumer_name: string | null;
operator_name: string | null;
warehouse_location: string | null;
outbound_time: string | null;
/** 挂载人(逻辑外键→MOM sys_user) */
added_by: string | null;
created_at: string;
}
// ---- MOM 出库单(任务挂载物料时的搜索选择器用) ----
/** MOM 出库单的一条物料明细。line_id 即挂载时提交的 mom_line_ids 元素 */
export interface MomOutboundLine {
line_id: number;
sku: string;
material_name: string;
spec_model: string;
quantity: number | null;
unit_price: number | null;
returned_quantity: number | null;
outbound_type: string;
/** 出库类型的中文名(服务端按 MOM 码表下发),直接展示,前端不自建映射 */
outbound_type_label: string;
consumer_name: string;
operator_name: string;
warehouse_location: string;
outbound_time: string | null;
/** ⚠️ MOM 存量单据该字段全为空(列是后加的,无从回填),空表示「无关联申请单」 */
request_no: string;
}
/** 一张 MOM 出库单(批量出库多商品共用一个单号,故带 N 条明细) */
export interface MomOutboundOrder {
outbound_no: string;
outbound_time: string | null;
outbound_type: string;
/** 出库类型的中文名(服务端按 MOM 码表下发),直接展示,前端不自建映射 */
outbound_type_label: string;
consumer_name: string;
operator_name: string;
line_count: number;
total_quantity: number | null;
lines: MomOutboundLine[];
}
export interface MomOutboundSearchResponse {
orders: MomOutboundOrder[];
/** 命中的**单据**总数(不是明细行数) */
total: number;
}
export interface TaskRecordResponse {
@ -110,6 +184,84 @@ export interface TaskTransferPayload {
// 产品
// ============================================================
/**
* 设备的一条 MOM 出库明细(统一形态)
*
* 一行 = 设备上的一条出库明细。`mom_line_id` 为空表示这条是**单据级存档**
* (MOM 回调时查不到明细),能看、能标撤回,但**不能报废** —— 报废要用它定位。
*/
export interface ProductOutboundMaterial {
id: number;
product_id: string;
serial_number: string | null;
/** 仅溯源(这条料挂在哪条任务上),不参与展示/报废/删除 */
task_id: string | null;
/** MOM trans_outbound.id;为空 = 无明细的存档 */
mom_line_id: number | null;
outbound_no: string;
// ---- 单据级(同单内一致,冗余在每条明细上) ----
request_no: string | null;
applicant_name: string | null;
remark: string | null;
// ---- 明细级快照 ----
sku: string | null;
material_name: string | null;
spec_model: string | null;
/** 出库单原值,**不是**本设备用量 */
quantity: number | null;
unit_price: number | null;
outbound_type: string | null;
/** 服务端下发的中文名,直接展示 */
outbound_type_label: string;
consumer_name: string | null;
operator_name: string | null;
warehouse_location: string | null;
outbound_time: string | null;
// ---- 来源与撤回 ----
/** manual(人工挂载,可删) | webhook(MOM 回调自动存档,不可删) */
source: string;
is_revoked: boolean;
revoked_at: string | null;
/** 谁挂上去的(Track 用户名) */
added_by: string | null;
/** 谁挂上去的(中文姓名,服务端解析下发),直接展示 */
added_by_name: string;
created_at: string;
}
/** 生产报废记录 —— Track 发起、MOM 受理的报废单 */
export interface ProductScrap {
id: string;
product_id: string;
serial_number: string | null;
task_id: string | null;
/** 报废对象:MOM trans_outbound.id */
mom_line_id: number;
/** 快照:MOM 侧数据被清理后仍要能显示「报了什么」 */
outbound_no: string | null;
material_name: string | null;
spec_model: string | null;
sku: string | null;
/** 原领用人。前端据此判断「报别人的料要额外确认」 */
consumer_name: string | null;
quantity: number;
reason_category: string;
reason: string | null;
scrap_request_no: string;
defective_goods_id: number | null;
submitted_by: string | null;
created_at: string;
// ---- 以下为后端实时回查 MOM 的结果 ----
mom_status: number;
mom_status_label: string;
mom_approved_at: string | null;
mom_executor_name: string;
mom_executed: boolean;
/** 报废损失。**未执行时是 null 不是 0** —— 0 会让人以为「这东西不值钱」 */
total_loss: number | null;
scrapped_quantity: number | null;
}
export interface ProductScanResponse {
id: string;
serial_number: string;
@ -132,4 +284,8 @@ export interface ProductScanResponse {
task_tree: TaskResponse[];
/** 🔧 username→中文姓名映射 */
assignee_names: Record<string, string>;
/** 🔧 出库单据存档(来自 MOM 出库回调),按出库时间倒序。
* 本功能上线前出库的设备这里是空数组,不是错误。
* 可选是为了兼容尚未升级的后端。 */
outbound_records?: ProductOutboundMaterial[];
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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