Commit Graph

414 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
42c883fe7d feat: 移动端直接完结入口、权限与派发解绑
【直接完结入口(双重门槛)】
- 转交弹窗新增「🏁 直接完结」选项,与「入库」互斥
- 角色门槛:仅超管 / 主管可见可操作(与后端 ADMIN_ROLES 对齐,两个角色都判 ——
  本文件既有的 canEditOverallStatus 只判了 SUPER_ADMIN 漏了 SUPERVISOR)
- 场景门槛:新增 showFinishDirect,要求产品确实是「已出库」。普通生产设备若被
  直接完结,既无下游任务、又不在仓库池,会变成卡在工人名下的孤儿数据
- transferMode 的 direct 分支同步校验双门槛,防残留选中态把 finish_directly 发出去

【修复售后"鸡生蛋"死锁】
- 已出库设备的下拉工序改走售后词表:taskOptionsFor 增加 overall_status 入参,
  已出库与 AFTER_SALES 同等待遇。此前只认 lifecycle_phase,而上一轮已禁止后端
  因"正常已出库做质检"翻转该阶段 → 工人选不到工序 → 进不了售后 → 更选不到

【派发入口与仓库位置解绑】
- 原条件硬性要求 current_location_id === 'virtual_warehouse',直接完结后的设备
  位置停在最后经手人名下,派发入口彻底消失,产品再也无法流转
- 新增豁免:产品空闲(无活跃主线任务)+ 超管/主管即可派发;新增 hasActiveMainTask
  只算主干,协助分支(SPAWN)不阻塞派发
- 文案与图标按场景动态化(在仓库 / 任务已完结)

【顺带修复】
- isWarehouseTransfer 死标志位:openWarehouseTransfer 先置 true,紧接着调用的
  openCreateFirstTask 又立刻重置为 false,导致弹窗标题永远显示「发起首道工序」,
  仓库转出文案从未生效。改为在 openCreateFirstTask 内按产品实际位置推导
2026-09-17 17:06:00 +08:00
ee25b8fd23 feat: PC 端直接完结入口与当前工序口径(前端)
【直接完结入口】
- 共享 TransferModal 新增「🏁 直接完结」开关,与「入库」互斥;选中后下游字段
  禁用(保留输入便于反悔)、标题/预览/按钮切换为独立视觉
- 入口仅超管/主管可见(useAuth + isAdminRole),并加防御性 useEffect 清掉
  非管理员的残留选中态,避免提交出 finish_directly
- 表单重置改为「打开时重置」:TransferModal 被 memo 后常驻挂载会跨次残留状态,
  且原实现于提交时清空会让等待期主题从「直接完结」闪回普通转交

【角色判断收口】
- 新增 ADMIN_ROLES / isAdminRole(与后端 task_service.ADMIN_ROLES 对齐)
  此前该判断散落在 TaskFlowView / AdminProductsPage 各处,移动端那份还只判了
  SUPER_ADMIN 漏了 SUPERVISOR,导致主管被前端误挡

【当前工序口径】
- 「当前工序」列改用后端派生的 current_step,不再用 overall_status ——
  后者会被"最新主干任务名"覆盖,让已 COMPLETED 的历史工序(扫码出库/测试…)
  冒充当前工序。显示与筛选值一并切换以保持一致
- ProductResponse 类型补齐 current_step
2026-09-17 17:05:51 +08:00
d666bbd4ed feat: 直接完结通道与报表口径收口(后端)
【直接完结:保留动作,剥离状态】
- transfer_task 权限硬拦截:仅 SUPER_ADMIN / SUPERVISOR。判据取签名保护的
  operator_role,刻意不用客户端可通过 ?operator_id= 伪造的 operator_id
- 直接完结【不再改写】overall_status —— 它只是任务闭环动作,不改变物理状态。
  已出库设备完结后依然是「已出库」,回归 WIP 矩阵的已出库列
- 物理终态保护:create_task / receive_task / transfer_task 三处写入点统一加
  _is_physical_terminal 守卫,禁止用任务名覆写 已入库/在库/已出库。
  历史缺陷:「发货测试」会把设备的「已出库」标识静默抹掉,跨报表口径随之打架

【位置与工序口径】
- _recalc_product_location:已出库且闲置 → 位置清空(货发走就离场)。
  只清「已出库」;已入库/在库的设备确实还在仓库里,位置必须保留
- MOM 出库回调同步清空 current_location_id
- 新增 ProductResponse.current_step:只认活跃主干任务,无活跃任务时返回
  宏观终态或空。不再让已 COMPLETED 的历史工序(扫码出库/测试…)冒充"当前工序"

【售后烙印去死锁】
- 拆分 OUTBOUND_QC_STEPS(发货测试) / AFTER_SALES_REPAIR_STEPS(售后维修)
- resolve_phase_for_step 与 _mark_after_sales_if_reactivated 双双改为仅
  「售后维修」触发 AFTER_SALES,解除「已出库 + 发货测试」被永久烙印的死锁
- PRODUCTION_OVERALL_STEPS 补入「发货测试」,使出厂质检在生产阶段合法
  (否则 _enforce_step_isolation 会把已出库设备的发货测试直接 400)

【游魂收口】
- get_wip_matrix / get_wip_matrix_detail 的 is_terminal 增加"名下再无任何活跃
  任务则视同终结态",让被直接完结的生产设备接受时间筛选,不再恒挂在看板上
  冒充在制。两处必须一字不差同步,否则会出现"矩阵有数、下钻为空"
2026-09-17 17:05:42 +08:00
e707f3673d feat: PC 端补齐直接完结通道
- taskApi.transferTask 增 finishDirectly 参数,按新 Schema 发送
  { next_tasks: [], finish_directly: true, note }
- TaskTransferPayload 支持 next_tasks / finish_directly,旧版单线字段降为可选
  (后端仍兼容 next_assignees / next_task_name,存量调用不受影响)
- 共享 TransferModal 新增「🏁 直接完结(无下游,不入库)」开关:
  与入库互斥,选中后下游字段禁用(保留输入便于反悔切回)、
  切换独立标题/提示/预览/提交按钮,不再套用「将创建 N 个任务」模板
- 表单重置改为「展开时重置」:TransferModal 被 memo 后常驻挂载会跨次残留状态,
  且原实现于提交时清空会让等待期主题从「直接完结」闪回普通转交
- TaskTreeViewer 与 AdminTasksPage 两个入口的 handleTransfer 签名对齐
2026-09-17 14:08:30 +08:00
1e6c006360 feat: MES/MOM 出入库状态解绑与直接完结通道(后端契约 + 移动端)
- TaskTransferRequest 新增 finish_directly 开关:闭环当前任务但不产生任何下游任务,
  产品 overall_status 保持原样,工人无需再借道「入库(virtual_warehouse)」来关闭任务,
  从根源上避免产品被误标「待仓库收货」而卡住 MOM 对账
- 空 assignees 分支加防呆校验:想直接完结必须显式传 finish_directly=true,
  杜绝漏填导致「转交」静默退化成「直接完结」的丢件级隐患
- 直接完结跳过 _mark_after_sales_if_reactivated:不产生新任务即不构成「回流返厂」信号,
  否则已出库设备的正常收官会把产品误翻成售后机(该标志单向不可回退)
- 补齐直接完结的独立响应文案,不再拼出「已创建 0 个下一道工序任务「None」」
- 移动端转交弹窗改为「转交个人 / 入库 / 直接完结」三选一互斥
2026-09-17 14:07:02 +08:00
2539bede3c chore: 看板页头副标题改用车间口语措辞
原标题「产品 = 物理实体(身份证)| 任务 = 工序节点」偏学术抽象,车间管理人员
不易一眼建立对应关系。改为按「产品盯物、任务盯人」的直觉表述:

    产品 = 车间里的物理设备 | 任务 = 派发给工人的生产指令(派工单)

配合卡片上已加的「待流转 / 待接收」口径 Tooltip,共同消除「产品数 > 任务数」
带来的误判。
2026-09-16 14:41:33 +08:00
dcc92b223e feat: 生产看板多维度重构(全量在制品、时间维度联动、口径提示)
本次只含后端看板接口与 PC 端页面。移动端的体验优化已在之前的 12 个提交中
入库,不在本提交范围内。

后端 · 服务层 dashboard_service.py
- 在制品列表不再按 20 条静默截断:此前 `.limit(limit*2)` 预取再排序切片,
  前端又拿数组长度当总数,表现为「卡片 23、列表却写共 20 个」的数据被吞。
  现默认返回全量,limit 降级为防御性上限
- 产品流转「已完结」接入右上角时间筛选(since/until),废弃硬编码「本月」。
  此前选「今天」也会看到整月产出,业务语义分裂;待流转/流转中仍为实时快照。
  Product 表无 completed_at,以名下工序的完成时间锚定「完结时刻」
- 返工任务可见性:未完结卡点永远展示,已了结的仅限本月,且与卡片数字共用
  同一份条件,避免「按钮显示 2、点开共 0 条」
- 驳回明细的「返工给」改为真实溯源:直接查派生出的返工任务当前 assignee_id,
  不再复刻 reject_task 的推算逻辑(返工任务事后被转交时会显示旧名字)

后端 · 接口层 dashboard.py
- /wip-tasks 的 limit 默认 20→500、上限 100→1000
- /my-stats 支持 since/until,新增接收/转交/上传备注口径(与 PC 人员操作统计
  逐字段交叉验证一致)

PC 端
- AdminDashboard: 在制品计数绑定真实总数并加截断提示、列表改为容器内滚动;
  驳回明细只展示「已驳回」行(返工行不再独立成行,其信息已由「返工给」承载);
  「待流转」「待接收」图例新增口径 Tooltip,消除「产品数 > 任务数」的误解;
  移除已失真的时间筛选提示条
- App.tsx: dayjs 中文 locale 上提到应用根。此前散落在 AdminPeoplePage /
  AnalyticsDashboard 两个页面里,而路由是 lazy 分割的 —— 直接打开概览页时
  那两个模块未加载,日期面板就露出 Sep/Su/Mo
- AdminPeoplePage / AnalyticsDashboard: 移除重复的页面级 locale 设置
- dashboardApi.ts: fetchWipTasks 默认值同步为 500
2026-09-16 14:29:56 +08:00
6bc6531e6a chore: 开发环境 CORS 补齐 uniapp H5 调试来源
原先只放行 PC 管理端(8010),移动端 H5 调试的服务起不来联调。
- 8010 = PC 管理端;5173/8020 = track-uniapp 的 H5 调试服务
- HBuilderX「运行到浏览器」起在 5173,直接跑 vite(按 track-uniapp/vite.config.ts)
  是 8020
- 漏了 uniapp 来源时,浏览器会在 CORS 预检阶段就掐掉请求,
  表现是「点登录没反应」,且控制台只报一句笼统的 404/400
2026-09-15 15:52:09 +08:00
a5fa789657 chore: 移除已废弃的工序树组件 FlowTree / TaskTreeNode
两者已被 TreeCanvas 取代:detail.vue 的 components 只注册
WorkspaceArea / TreeCanvas / TaskSwipeCards,全项目对 FlowTree 与
TaskTreeNode 已无任何 import(仅 detail.vue 内一句注释还提及后者)。

uni-app 不会编译未被引用的 .vue,留着不报错但会持续误导后续排查,
故清理。
2026-09-15 15:52:08 +08:00
1e46d6edd9 fix: 沉浸工作区不再自建滚动容器,滚动统一交还原生页面
is-locked 时锁定 height:100vh + overflow:hidden,会造出「卡片内部滚动 +
原生页面滚动」两个滚动容器并存的局面:卡片滚到顶之后再往下拉,手势会穿透
到页面层,误触发下拉刷新,工人根本没法往上翻内容。

- .workspace-area.is-locked 去掉 height:100vh + overflow:hidden,高度交还
  给内容自然撑开
- .focus-card 去掉 overflow-y:auto,随页面一起滚;
  padding-bottom 仍保留 160rpx,用来让内容避开底部固定操作栏
2026-09-15 15:52:08 +08:00
5cd08b551a fix: 扫码取码兼容带域名的完整 URL 二维码
二维码内容有两种可能,旧逻辑只做「去掉非字母数字后截前 16 位」,扫到带域名
的 URL 时会把 "https"、主机名一起当成 SN 的前半段,截出来是个完全不存在的
号,永远提示「未找到该产品」。

- 先把 "协议://主机名" 整段剥掉,否则域名会被当成候选 SN
  (如 trackbackirisrscn 这类 17 位串,恰好能通过长度校验)
- 在剩余内容里找长度 >= 8 的连续字母数字串,取最长的一段;
  等长时取靠后的 —— URL 里 SN 通常在路径末尾
2026-09-15 15:52:02 +08:00
a7d1c53b1f feat: 消息与任务列表改为分页加载
此前一次性拉全量(硬编码 limit:100),任务/通知多了会拖慢首屏。

- 分页状态:page / pageSize(20) / hasMore / loadingMore,触底加载下一页
- 下拉刷新回到第 1 页并清空重载 —— 分页后必须重置,否则新旧页会错位
- 任务列表按 id 去重后追加:翻页期间若有新任务插入,分页边界会错位导致
  重复项
- hasMore 优先以响应里的 total 为准,缺失时退回「本页是否满员」判断
- 底部新增状态提示(加载中 / 没有更多了 / 上拉加载更多),
  让工人知道是「到底了」还是「还在拉」
2026-09-15 15:52:02 +08:00
c7f046a993 feat: 个人中心补齐工作统计/设置/帮助与反馈三个模块
原先三个菜单都是空壳,点「帮助与反馈」只弹「敬请期待」。

工作统计(statistics.vue)
- 时段切换:今日 / 本周 / 本月 / 自定义(含起止日期选择器)
- 「生产战绩」完成 / 被驳回 / 参与产品;「操作统计」接收 / 转交 / 上传备注
  后者的口径与 PC「人员操作统计」严格同源,已逐字段交叉验证(4 人 × 4 指标
  全部相等),工人自查的数与主管看到的面板对得上
- 时段边界显式拼 "+08:00":车间按北京时间作息,发裸字符串会被后端按服务器
  本地时间解释,边界整体偏 8 小时

设置(settings.vue)
- 清理缓存:黑名单式,只删登记过的业务缓存(当前为 msg_seen_<product_id>,
  即留言抽屉的已读位点,随扫过的设备数无上限增长)。未登记的 key 一律保留,
  新增插件天然免疫;另有 NEVER_DELETE 兜底,任何情况下不动登录凭证与环境
  配置。副标题实时显示待清理条数,卡片下方说明「不动什么」
- 检查更新:调用 utils/ota.js 的 checkAppUpdate({ manual: true })
- 退出登录:由 profile/index 迁入,加确认弹窗防误触

帮助与反馈(feedback.vue)
- 问题类型(系统问题/设备故障/其他)、描述文本域(500 字计数)、
  图片上传最多 4 张,复用 utils/upload.js 的并发上传池
- api/feedback.js 采用「先真后假」:先按最终契约 POST /feedback/,
  仅当 404(端点尚未实现)才回退本地 mock。后端补上接口后前端零改动即可
  接上;而 400/422/断网仍照常抛出,不会被 mock 悄悄吞掉

路由
- pages.json 注册三个新页面(工作统计开启下拉刷新)
- profile/index.vue 用 MENU_ROUTES 映射 uni.navigateTo,移除「敬请期待」拦截
  与退出登录按钮
2026-09-15 15:51:50 +08:00
7fca315ff4 feat: 图片上传抽离公共模块,统一成功角标并修复记录拍照无法删除
上传模块 utils/upload.js(新增)
此前 detail.vue 与 records.vue 各写一份 uni.uploadFile 封装,超时与并发策略
容易漂移,且都是串行上传 —— 9 张图排队一张张传完,弱网下工人要干等半分钟。
- uploadImage:单张上传,UPLOAD_TIMEOUT 防弱网永久挂起;非 2xx 时后端返回的
  是 {detail:...} 而非 {url},必须当失败处理,否则图片被静默丢弃
- uploadImages:有界并发池(并发 3),onEachDone 对每张图恰好回调一次,
  成功与失败都精确回收一个  占位符
- isUploadedUrl:判定「是否已真正上传成功」。成功 resolve 的是后端 URL
  (/api/v1/upload/files/… 或 http(s)://),未完成的拿到的是本地临时地址
  (blob: / file:// / _doc/ / wxfile://)

上传成功绿勾角标
- 应用到 detail.vue(记录表单 + 驳回表单)与 records.vue(编辑记录弹窗)
- 角标仅在 isUploadedUrl 判定为真时渲染; 占位与失败项结构上不可能出现
  角标,因为角标只存在于「已上传」的那个循环里
- 圆角与裁剪改由 .success-badge-wrapper 负责,图片只负责填满

修复:记录/拍照里已选图片无法删除
删除按钮原为 v-if="canDeleteImage",而该项在「任务已定稿
(COMPLETED/REJECTED/ARCHIVED/CANCELED)」或「非本人任务」时返回 false。
但 handleTaskAction 里 type === "record" 是无条件打开弹窗的,工人完全可能
在已完成任务上打开「记录/拍照」,于是刚选、尚未提交的图也被一并锁死 ——
那些图只存在于内存里,删掉不产生任何服务端影响。

- 新增 recordForm.savedCount(打开弹窗时已落库的张数),
  新增 canDeleteRecordImage(i) = canDeleteImage || i >= savedCount:
  本次新选的图永远可删,已落库的才受「任务已定稿」约束
- 未落库的图免去 5 秒倒计时 —— 该机制本是为不可逆的服务器删除设计的,
  刚选错的图重拍一张即可,没必要卡 5 秒
- openEditRecord 由 images: record.images || [] 改为 saved.slice():
  原先按引用赋值,删图会直接改动底层记录对象,此时点「取消」被删的图
  在本地列表里也已经没了
2026-09-15 15:51:44 +08:00
eef4e5b72d refactor: OTA 热更新抽离为公共模块 utils/ota.js
原先 checkUpdate / parseVersionCode / downloadAndInstall / downloadWgt /
installWgt / otaFailed 整个长在 App.vue 的 methods 里,个人中心「设置 →
检查更新」想手动触发一次根本做不到。抽到 utils/ota.js 后两处共用同一份
实现,改版本比对规则或下载策略只需改一处。

- 节流状态从 this._lastUpdateCheck 改为模块级单例
- 新增 checkAppUpdate({ manual }),manual 模式跳过节流、显示「检查中」、
  并在「已是最新 / 检查失败」时给出明确反馈 —— 用户主动点的按钮必须有回音,
  自动检查才静默收场
- App.vue 的 onLaunch/onShow 改为调用同一函数,自动模式行为不变

同时 App.vue 的全局样式新增 .success-badge-wrapper / .success-badge
(上传成功绿勾角标,供各页面图片列表复用):
- 直角三角形用 border-top(实色) + border-left(透明),直角落在右上;
  写成 border-top + border-right 会得到左上角的三角,方向是反的
- 白勾用两条边框旋转 -45° 画成,不用 "✓" 字符 —— 该字形在安卓与 iOS 上
  的字形和基线差异很大,对不齐
- 容器 overflow:hidden 只包图片本身,不能包住单元格:各页面的删除按钮
  .img-del 定位在 -12rpx 处(单元格之外),包进去会被一并裁掉
2026-09-15 15:51:38 +08:00
44ca09ae22 feat: 新增个人效能统计接口 /dashboard/my-stats(支持自选时段)
移动端「个人中心 → 工作统计」的数据源。现有接口拿不到这份数据:
/dashboard/people-history 支持 assignee_id 但只返回 WIP/PENDING/COMPLETED,
不含 REJECTED;/dashboard/rejected-tasks 则根本没有 assignee_id 参数 ——
「今日被驳回数」按现有接口无论如何都过滤不到个人。故补一个薄桥接端点。

服务端 get_my_stats(db, assignee_id, since, until) 返回两组指标:

生产战绩(按 Task.assignee_id 归因)
- tasks_completed / tasks_rejected:status 判定 + completed_at 落在区间,
  与 get_dashboard_stats 的 t_done_q 同一口径,只多了 assignee_id 过滤
- products_touched:按 product_id 去重,同一台设备做多道工序只算一台

操作统计(与 PC /dashboard/user-operations 严格同口径)
- receive/transfer:task_logs 的 receive / complete,按 operator_id 归因
- record:task_records 按 Task.assignee_id 归因,排除 '[' 开头的系统自动备注
- 前两项归因 operator_id、第三项归因 assignee_id 是 PC 端既有口径,
  此处刻意保持一致,便于工人自查的数与主管看到的面板对得上

端点侧新增 _parse_bound():裸时间字符串(无时区偏移)按北京时间解释,
否则会被当作服务器本地时间,边界整体偏 8 小时,出现「选了今日却统计到
昨天下午」。since/until 缺省为「本月 1 日 ~ 此刻」。
2026-09-15 15:51:34 +08:00
242a6d5463 refactor: 环境地址收口到 utils/config.js,请求层新增 silent 静默模式
config.js(新增)作为后端域名/接口地址的唯一来源。此前 request.js 与 App.vue
各硬编码一份生产域名,改域名要改两处,漏一处就会出现「接口通了但 OTA 检查
更新接不上」这类极难排查的偏差。

request.js 改动:
- PROD_URL / LOCAL_URL 改为从 ./config 引入,不再就地硬编码
- 新增 options.silent:调用方自行兜底提示时跳过通用错误 toast。
  用于 feedback 的 404 回退 mock —— 否则用户会先看到「请求失败 (404)」
  再看到「感谢反馈」,两句自相矛盾。HTTP 错误与 fail 两个分支都受其控制
- fail 回调保留原始 errMsg 并打上 isNetworkError / isTimeout 标记:
  fail 表示压根没拿到后端响应(超时/断网),与 4xx/5xx 有本质区别 ——
  后端可能已执行成功只是响应没回来,上层需要据此打断用户的连点重试
2026-09-15 15:51:30 +08:00
93e4714019 chore: 修正 sync-watch.sh 同步方向为 src/ → HBuilderX 项目根
原脚本按 track-uniapp/ ↔ track-app/track/ 双向对拷,方向完全错误:
HBuilderX 是以 G:\Track\track-app\track 为项目根打开的(经典布局,
pages.json/App.vue/manifest.json 都在根目录),该目录是 track-uniapp/src/
的扁平镜像。证据:test_wgt_local.bat 写明 "build WGT in HBuilderX first",
且两侧文件时间戳逐项吻合。

按旧规则运行会造成三处破坏:
- 正向把 src/ 整个塞成 track/src/,并用 WSL 的 337B index.html 覆盖
  HBuilderX 的 637B 入口文件
- 反向把 Windows 项目根的文件搬回 WSL 根目录,让刚清理的幽灵文件复活
- 用 src/manifest.json(T1.0.8) 覆盖 HBuilderX 里维护的 T1.0.44 + 存储权限

改动:
- 改为单向 src/ → 项目根,并显式排除 13 类仅在 Windows 侧存在的项
  (manifest.json / index.html / static/ / uni.scss / unpackage/ / *.bak-* 等),
  --delete 因此不会误伤它们,同时能让 WSL 侧的删除传播过去
- 不用 -a:/mnt/g 是 drvfs,POSIX 权限/属主恒为 777,-p/-o/-g 每次都判定为有差异,
  产生无谓 chmod 与刷屏的 p 标记;改用 -rlt --no-perms --no-owner --no-group
- inotifywait 在 WSL 里并未安装,旧脚本 2>/dev/null 把 command not found 吞掉了
  (表现为「脚本像在跑但 G 盘不更新」);改为明确提示 + 3 秒轮询兜底
- 不再吞 rsync 报错,失败时打印退出码;新增 once / dry 子命令
2026-09-15 15:51:24 +08:00
ab75b9613f chore: 删除 track-uniapp 根目录与 src/ 重复的陈旧副本
构建实际读取的是 track-uniapp/src/ —— @dcloudio/vite-plugin-uni 的
UNI_INPUT_DIR 默认取 path.resolve(cwd, 'src'),检测到 src/manifest.json
即以其为输入目录。根目录那套 pages.json / App.vue / main.js / manifest.json
停留在 2026-08-04,是早期 HBuilderX 布局的遗留,早已不参与构建。

危害在于「同名同结构」:IDE 搜索 pages/profile/index.vue 会命中两个结果,
改到根目录那份既不编译也不报错,表现为「代码明明改了,App 里没变化」,
排查成本极高。

- 删除 pages/(4 个 .vue)、pages.json、App.vue、main.js、manifest.json
- 根 manifest.json 版本号为 T1.0.0(字符串),src/ 已是 T1.0.8(108),
  且缺 Barcode/Camera 模块与图标配置
- 保留 index.html:它是生效的 H5 入口模板,内容引用 /src/main.js,
  uni-app CLI 布局中本就在项目根
- 保留 static/:src/ 下无同名目录,非重复项
2026-09-15 15:51:19 +08:00
7b8084a43d fix: 前端统一解析后端 detail 报错,杜绝 [object Object]
各处 catch 里直接 `toast(err?.response?.data?.detail ?? "xx失败")` 有坑:
请求体校验失败(422)时 detail 是数组,React 会把每个对象渲染成 [object Object]。

- utils/errorMessage.ts(新增): extractErrorMessage 统一解析三种形态 ——
  字符串 / FastAPI 校验错误数组 / 对象;数组取 msg 并剥掉 Pydantic 的
  "Value error, " 前缀、带上字段名、同字段去重后用「;」拼接;解析不出内容
  则回落到调用方给的兜底文案。行为与移动端 extractErrorDetail 对齐
- 19 处调用点全部替换:AdminTasksPage(4) / AdminProductsPage(5) /
  TaskTreeViewer(4) / AdminPrintConfigPage(2) / ScanPage / CreateProductDialog /
  ImageUploader / authApi
- TaskRejectPayload.images 与 taskApi.rejectTask 的注释同步为「选填,支持空数组」
- 新增 components/ui/ImageUploader.tsx(驳回弹窗选图上传组件),
  RejectModal 同步放开无图拦截:去掉 images 非空校验、提交按钮不再因
  无图禁用、标签由必填星号改为「(选填,最多 9 张)」,并移除因此失效的
  error 状态
- AdminTasksPage.tsx: 支持大屏下钻的 ?search / ?status / ?stage 预设参数,
  新增「售后流转中」状态 Tab(按 lifecycle_phase 判定)与全局重置;
  ?stage 只在跳入时生效一次,用户手动改条件即解除锁定
- 杂项清理: TaskFlowView 移除透传的 assigneeNames、TaskListCard/MyTasksPage
  移除未使用导入、QrScanner 关闭 verbose 日志
2026-09-15 10:57:56 +08:00
6b3f14e8fd fix: 移动端驳回支持传图、422 报错可读化、修复详情页图片破图
车间的驳回操作不再强制拍照(编号错误等场景无需照片),移动端同步放开。

- utils/request.js: 新增 extractErrorDetail 统一解析后端 detail 的三种形态
  (字符串 / FastAPI 校验错误数组 / 对象),新增 422 拦截分支,并把
  400/403/409 及兜底分支的 `res.data?.detail || x` 全部换掉 —— 原写法在
  detail 是数组时会把整个数组丢给 showToast,弹出 [object Object]
- pages/scan/detail.vue: 驳回弹窗新增与「追加记录」一致的选图/传图 UI,
  doReject 携带 { reason, images }(允许空数组);选图上传流程抽成通用的
  pickAndUploadImages,与追加记录共用
- pages/scan/detail.vue: 新增 imageUrl() 补全后端返回的相对路径
  (/api/v1/upload/files/xxx.jpg 直接给 <image> 会破图),缩略图渲染与
  previewImage 预览均已接入;逻辑与 records.vue / TaskTreeNode.vue 对齐
2026-09-15 10:57:49 +08:00
0d1e45e3fb feat: 管理层数据大屏(后端聚合接口 + 前端全屏页面)
PC 管理端新增独立全屏数据大屏,供管理层查看直通率/产量趋势/不良分布,
替代原 AdminDashboard 上零散的手工下钻。

后端:
- endpoints/screen.py + services/screen_service.py: 大屏聚合接口
  (复用 lifecycle 的售后工序归一,保证统计口径与展示一致)
- router.py: 注册 screen_router

前端:
- pages/admin/ScreenDashboard.tsx: 全屏大屏页(自带鉴权守卫,无侧边栏)
- services/screenApi.ts: 大屏数据接口封装
- components/admin/UserOperationDetailDrawer.tsx: 人员操作明细抽屉,
  由 AdminDashboard 的原生 state 抽成独立组件(含命令式 handle)
- AdminDashboard.tsx: 改为使用该抽屉组件,移除内联的下钻状态
- MatrixBoard.tsx / App.tsx / AdminLayout.tsx: 挂载路由与导航入口
- BaseEChart.tsx: 注册 GaugeChart 与 GraphicComponent
  (graphic 需显式注册,否则饼图中心文字静默不渲染)
2026-09-15 10:57:46 +08:00
71e660b428 feat: 驳回图片改为选填,驳回原因/转交备注落库留痕
业务调整:车间驳回不必须拍照(编号错误、选错工序等场景无需照片举证)。

- schemas/task.py: TaskRejectRequest.images 由必填(min_length=1)改为
  default_factory=list,保留 max_length=9 与 URL 总长保护;reason 增加
  mode="before" 的 strip 校验器,堵住纯空格凑长度绕过 min_length 的口子
  (顺带保证落库的 reject_reason 不带首尾空白)
- task_service.py: 驳回时把原因+图片写入 TaskRecord 挂到被驳回任务下,
  修复前端时间线看不到驳回详情的问题;转交时把交接备注也挂一条记录到
  下家任务,补齐接手人的上下文视角
- task_service.py: create_task / receive_task 写入 overall_status 后调用
  sync_product_status 对齐 status 字段
2026-09-15 10:57:35 +08:00
f22eae315f fix: 售后回流口径统一 — 状态双字段同步、工序名归一、标签配色
产品存在 overall_status 与 status 两个状态字段,此前各写入点各写一份映射、
甚至只改 overall_status 不改 status,导致回流设备(product.status 停留在
OUTBOUND)污染看板统计口径。

- lifecycle.py: 把映射表收敛为单一来源 overall_to_product_status /
  sync_product_status;新增 normalize_after_sales_step,将售后设备沿用生产
  阶段写法的历史工序名(测试/维修)折算到售后区独立工序名
- product_service.py: 删除本地 _OVERALL_TO_STATUS 副本,改用共享函数
- dashboard_service.py: WIP 矩阵补出 lifecycle_phase 列,活跃任务判定
  (is_active) 提前到所有终结态判定之前,避免残留 OUTBOUND 被误判为完结
- scripts/fix_product_status.py: 历史数据修复脚本(一次性)
- constants/task.ts: 售后工序标签由红色改紫色 —— 红色在本系统是「驳回/危险」
  语义色,售后只是另一条流转支线,用红色会让操作员误以为设备报错
2026-09-15 10:57:29 +08:00
31d085881b feat: 移动端工序选项隔离与售后标签 2026-09-14 14:49:38 +08:00
3238e4819a feat: 网页端渲染售后回流标签(发货测试/售后维修) 2026-09-14 14:49:38 +08:00
db3bafcc66 feat: 前端补充生命周期类型定义与工序词表 2026-09-14 14:49:38 +08:00
2de95799c6 feat: 售后回流判定与工序选项隔离守卫(建单/接收/转交) 2026-09-14 14:49:34 +08:00
b40340ea55 feat: 产品接口暴露lifecycle_phase并做阶段感知状态校验 2026-09-14 14:49:34 +08:00