10 Commits

Author SHA1 Message Date
cac53f1ea8 feat(product): 产品序列号在同一规格型号下唯一
- external_serial 是用户手填的选填字段(区别于自动生成的 16 位
  serial_number),此前完全没有唯一性校验,填重复值静默通过
- 业务口径:同一 spec_model 下不允许重复,不同规格型号之间允许相同
- 迁移 u1v2w3x4y5z6:部分唯一索引
    UNIQUE (COALESCE(spec_model,''), btrim(external_serial))
    WHERE deleted_at IS NULL
      AND external_serial IS NOT NULL AND btrim(external_serial) <> ''
  空值不参与校验(不填即放行)、无规格型号归同一组、软删行不占位
- 服务层 4 个 helper 与索引逐字同口径 —— 口径分叉会让预检放过、
  再被索引拦成 500 而不是 409
- 新增 GET /products/external-serial/check 即时查重(get_current_user 鉴权,
  移动端工人也在编辑产品)
- 网页端:创建/编辑弹窗 400ms 防抖查重 + 提交前兜底 + 输入框标红
- 移动端:编辑弹窗失焦查重(silent,查重失败静默降级);
  空串归一成 null 落库,与后端同口径
2026-10-09 14:37:16 +08:00
c04e7cee4e feat(audit): 记录字段级变更(changes)
- 新增 core/audit_diff.py:在 ORM before_flush 钩子捕获 {实体: {字段:
  {old, new}}},拿到的是「库里真正改了什么」,含 Service 层的隐式改动
  (例如 sync_product_status 顺手改的 product.status,请求体里根本没有)
- audit_logs 新增独立列 changes,与 details 各司其职:
  details = 用户提交了什么,changes = 实际业务数据变更
- 中间件在 call_next 前后开启/收口收集(中间件与路由不在同一 Task,
  晚了就拿不到写入)
- 审计接口补 target_type_label(服务端下发实体名词,前端不硬编码)
- 前端审计页加字段对比视图
- 迁移 s1t2u3v4w5x6
2026-10-09 13:29:23 +08:00
dd4824b730 feat(holiday): 工作日历支持调休补班,补齐节假日管理页
- holidays 表加 kind 列区分 holiday(放假) / workday(调休补班):
  原判定 weekday()<5 and day not in holidays 只能「减」不能「加」,
  补班的周末被整天扣掉,工时系统性少算
- 新增 WorkCalendar(补班 → 放假 → 周末 的判定优先级)与
  work_calendar_service(全仓唯一的日历读取入口,不再各函数手拼集合)
- 9 处工作日计算调用点统一改用 load_work_calendar(db)
  (analytics / dashboard / product,含产品列表的工作日天数)
- 新增 PATCH /holidays/{id}(改类型或说明,日期不可改);列表按 kind 出参
- 前端新增「节假日管理」页与菜单入口
- 迁移 r1s2t3u4v5w6
2026-10-09 13:29:18 +08:00
3b5ca56375 feat(backend): 引入逻辑删除与设备还原
- 新增 SoftDeleteMixin(deleted_at + deleted_by)与 core/soft_delete.py 全局
  ORM 过滤,用 include_deleted 逃生口显式放行
- delete_product 由物理删除改为逻辑删除:不再切断父子关系,关联任务
  一并软删;还原时整棵子树原样恢复
- 新增 POST /products/{id}/restore,序列号被占用时返回 409(部分唯一
  索引允许「删掉重贴一张标」,冲突是正常业务分支)
- products.serial_number 唯一索引改为部分唯一(WHERE deleted_at IS NULL)
- 前端「产品管理」加还原入口与「包含已删除」开关
- 迁移 t1u2v3w4x5y6
2026-10-09 13:29:13 +08:00
7376e19cdf feat(backend): 设备出库明细统一为一张表,新增生产报废
- 合并单据级 product_outbounds 与明细级 task_outbound_materials 为
  product_outbound_materials:带 mom_line_id,挂上去的料才能报废
- 生产报废链路:Track 发起 → MOM 退回不良品/建申请 → 主管审批
  → 库管扫码执行;状态与损失金额一律实时回查 MOM,不用本地快照
- 出库类型中文名(outbound_type_label)由服务端按 MOM 码表统一下发,
  前端不再各抄一份映射
- 挂载人姓名(added_by_name)由服务端把 Track 用户名解析成中文姓名,
  界面不再裸露 duxingchen 这类账号名
- 报废原因说明改为必填(前后端双重校验,纯空白也拦)
- 新增 MOM 内部接口密钥配置与 compose 环境变量
2026-09-24 13:51:32 +08:00
37198ec06f feat(product): 创建产品时可从 MOM 出库单挂钩,产品详情可见
入口放在**创建产品**(不是创建任务):建档时就能把「这台设备对应 MOM 的哪张
出库单」录进去,之后还能补挂。

  · ProductCreate 加 mom_line_ids,create_product 在**同一事务**里落库
  · POST /products/{id}/outbound-orders   建档后中途补挂
  · product_outbounds 加 source 列:webhook(回调自动存档) | manual(人工挂载)

复用 product_outbounds 而不是新建表:两条路径是**同一语义**(产品 ↔ 出库单),
拆表会让产品详情要展示两张卡片。加一列标来源即可,排查时也能看出这行是谁写的。
存量行按 webhook 回填 —— 该列上线前只有回调这一条写入路径,语义确定,不存在
「猜」的问题。

⚠️ 快照一律由后端拿 ID 去 MOM 现查(run_in_threadpool —— MOM 是同步 psycopg2,
   写在 async 里会阻塞事件循环),**不接受前端传入**,否则可伪造单据信息。
   前端只提交明细行 ID。
⚠️ 用户是按**整张单**勾选的,而本表是单据级,所以提交上来的明细 ID 先经
   mom_outbound_service.get_orders_by_line_ids 归并回单据再落库。
⚠️ 人工挂载这条路径拿不到申请人姓名(MOM 的 trans_outbound.request_id 存量全为
   NULL,反查不到申请单),留空由前端隐去该行 —— 回调那条路径本来就有,不受影响。
⚠️ 幂等:落库前按 (product_id, outbound_no) 查重,表上另有唯一约束兜底。

实测(TestClient 走真实链路,仅覆盖 JWT 依赖):
  1) 建产品挂单 A+B      → 201,产品详情返回 2 条 source=manual,时区正确
  2) 追加单 C            → 3 条
  3) 重复提交 A+B+C      → 仍为 3 条(幂等)
  4) 不带 mom_line_ids   → 201,空记录(旧调用方回归)
  5) 传不存在的明细 ID   → 201,挂 0 条,不报错

测试数据已还原(测试产品与挂载行已删,product_hex_counter 已 setval 回原值 2)。
2026-09-23 09:47:33 +08:00
d0d559bf34 feat(task): 新建 task_outbound_materials 表,任务可挂 MOM 出库物料
此前 Track 创建任务完全无法关联出库物料;任务进行中若发现第一次领的料不够,
也没有地方追加后续出库单。本表是落地位置。

口径(已与业务确认):
  · 选择粒度 = **整张出库单**(outbound_no),该单的明细一并带入 ——
    不存在"单里混了无关物料"的情况
  · **纯引用,不记用量** —— quantity 是出库单原值,不是"本任务用了多少"

为什么是明细级(一行 = 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 出库回调 → 产品详情只读展示」,挂在**产品**上、
   单向存档;本表是「人在界面上选择 → 挂到**任务**上」、可增可删。

⚠️ mom_line_id 是**跨库逻辑外键**(MOM trans_outbound.id),无物理约束 ——
   与 assignee_id 指向 MOM sys_user 是同一类做法。MOM 库若重建会让自增 ID
   错位,故同时冗余 outbound_no 供人工核对。

唯一约束 (task_id, mom_line_id) 防同一条明细重复挂到同一任务
(重复提交 / 前端重放 / 并发点击)。Task 侧加了带 cascade 的 relationship。

已在 lica_backend 容器内执行 upgrade head。核对:16 列 + 2 索引 +
1 唯一约束 + 1 外键就位,alembic head = n1o2p3q4r5s6。
2026-09-23 09:32:34 +08:00
04e912de95 feat(outbound): 新建 product_outbounds 表,存档 MOM 出库单据
产品详情要回答「这台设备这次出库对应 MOM 的哪张单」。MOM 侧已扩展出库回调
载荷(见 projects 仓的对应提交),本表是接收端的落地位置。

为什么单独一张表,而不是给 products 加几列:
  一台设备可能出库多次(出库 → 撤回 → 再出库),加列只能保住最后一次,而需求
  是「完整出库历史」。本表一次出库一行。

为什么撤回不删行:
  「出过又撤了」本身就是需要看得见的历史。撤回只把 is_revoked 置真并记下
  revoked_at。

唯一约束 (serial_number, outbound_no):
  防 MOM 重推产生重复行。**不能只约束 outbound_no** —— MOM 的批量出库是多个
  商品共用一个单号(projects models/outbound.py:127 有注释)。

与 products.overall_status / status 的分工:
  那两列是**当前事实**(此刻是否已出库),本表是**单据归属**。设备被撤回回库后
  overall_status 变回「已入库」,但那张出库单仍在本表上,只是标了已撤回。

⚠️ outbound_type 只存不判 —— MOM 的码表尚未冻结(两处注释分别为 5 值和 3 值,
   无枚举、无白名单校验),要真用它得先冻结码表。

本迁移只建表、不写入任何数据。本次上线前已出库的设备没有存过单据,产品详情上
不会显示出库记录 —— 历史无从回填(MOM 侧出库流水与申请单此前也没有关联)。

已在 lica_backend 容器内执行 upgrade head。执行后核对:14 列 + 2 索引 +
1 唯一约束 + 1 外键全部就位,alembic head = m1n2o3p4q5r6。
2026-09-23 09:12:26 +08:00
cfcfca7269 feat(分组权限): 业务分组模型 + DataScope 判定 + 列表接口接入
第一阶段:模型 → 判定 → /auth/me → 列表。统计接口与前端管理页随后。

1) 模型与迁移(head 从 k1l2m3n4o5p6 推进到 l1m2n3o4p5q6)
   · business_groups        组定义,parent_id 表达「大组 > 小组」
   · business_group_phases  可见范围,独立成表以支持多选 —— 需求要求
                            「范围可配置、不要写死」,单列存不下多个 phase
   · business_group_members 成员,一人可属多组(这是「同时看生产+维修」的实现)
   只建表、不写种子数据,所以可以先部署代码再建组。

2) DataScope 判定模块(app/services/data_scope_service.py)
   全仓库唯一的权限谓词来源,业务代码里不准再出现 lifecycle_phase 过滤。
   两条红线照抄部门隔离的教训:
   · None(不限) 与 frozenset()(空) 语义相反,绝不共用一个哨兵值
   · 空集合必须显式 false() —— SQLAlchemy 对 in_(()) 生 成 IN (NULL),
     一旦退化成不过滤就是全量泄漏
   解析优先级:SUPER_ADMIN 硬放行(不可被分组覆盖)
             > 显式分组(分组优先于角色)
             > 未分组 SUPERVISOR 默认全厂
             > 未分组普通用户

3) 过渡期开关 DATA_SCOPE_UNGROUPED(默认 ALL)
   直接上严格模式会让所有未分组工人当场看不到自己的任务、现场停摆。
   默认 ALL 先放行并打 WARNING 记录「谁还没分组」,配好组后再改 NONE。

4) /auth/me 返回 scope,phase 中文标签由服务端下发
   —— 前端已有两份 phase 词表副本,不再加第三份。

5) 列表接口接入
   · get_all_products:过滤加在 offset/limit 之前(其下有 6 段基于 product_ids
     的批量预计算,过滤晚了等于算完再丢)
   · get_all_tasks:谓词进【共享 filters】,保证 count 与 select 两条独立语句
     同时生效,否则 total 与实际页不一致、移动端 hasMore 判断跟着错
   · 扫码 get_product_by_serial 刻意不过滤,理由写死在 docstring 里

实测:空 scope 生成 false、受限 scope 生成 JOIN + IN 谓词;
/products 返回 2 条、/tasks 的 total 与 returned 一致。
2026-09-21 17:07:46 +08:00
3286a11bc7 chore: fork from IRIS track 供 LICA 部门独立运行
- 复制来源: /home/yueli/track @ 192c8ee (feature/ai-audit-update)
- 组织隔离目标: LICA
- 端口规划: 前端 8030 / 后端 8031 / 数据库 8032
- 已排除 deploy.sh、deploy_full.sh、docker-compose.prod.yml(IRIS 生产发布脚本)
- 已排除工作区未提交改动,取干净的 192c8ee 状态
2026-09-21 15:56:52 +08:00