34 Commits

Author SHA1 Message Date
50232bb58f V3.95,9.28推送 2026-09-28 15:29:46 +08:00
9c1a9886a5 feat(outbound): 领用人改为可搜索下拉,选中申请单自动带出申请人
背景:出库的「领用人/客户」一直是自由文本框(create.vue 的 el-input),
后端 consumer_name 必填但内容不限。实测 1497 条出库里有 7 条领用人对不上
任何在职人员,其中就有把姓名打成「刘」这种错字 —— 手输必然产生这类问题。
而同一个表单里紧挨着的「经办人(库管)」已经是下拉选择了。

改动:

前端 outbound/create.vue
  · 领用人由 el-input 改为 el-select(filterable + allow-create +
    default-first-option),数据源复用统一的在职人员接口 getActiveUsers()
    (与出库补发的「补发给谁」同一份,公司隔离由后端负责)。
  · 选中审批单时自动带出申请人(fillConsumerFromRequest),仍可改成别人。
    ★ 按 applicant_id 在在职名单里**反查**姓名,而不是直接用接口返回的
      applicant_name —— 后者是 '高雪/gaoxue'(后端 _get_user_name 返回完整
      username),直接落库会把台账的姓名口径搞乱(实测 1497 行无一带 '/')。
  · 动态模糊匹配:姓名与**拼音**都能搜。默认 filterable 只匹配 label(姓名),
    输 gaoxue 匹配不到高雪,故自定义 filter-method 一并比对账号段。
  · 软提示而非硬拦截:填了不在名单里的名字会显示
    「「X」不在在职人员名单中,请确认没写错」。保留 allow-create 是因为
    销售出库的客户可能是外部人员、没有账号,封死会让这类出库没法登记。

后端 common/users.py
  · active_user_options() 增加 account 字段(username 里 '/' 之后那段拼音),
    供前端拼音搜索。仍是 id/姓名/账号三项,不含邮箱、角色、部门。

★ 没有做后端强校验(拒绝非系统人员):用户明确选择「下拉为主,保留手输兜底」,
  硬拦会挡住外部客户。错字的防线是"默认从名单里选 + 不在名单时给提示"。

验证(12 + 6 项断言全过):
  · 39 个在职人员,每项含 id/name/account;name 是裸姓名、account 非空
  · 拼音搜索:输 gaoxue / GAOXUE → 高雪;输 gao → 高前赐/高闯/高雪;输 高雪 → 高雪
  · 在职人员无重名(按姓名绑定才无歧义)
  · 已通过的审批单能反查到在职申请人(单 514/513/512/511/509 全部命中)
  · 公司隔离:IRIS 用户 20 人只看到「石利」、LICA 用户 19 人只看到
    「石利LICA」、超管 39 人 —— 跨公司同名在各自视角下不会同时出现
  · 借库人员选择器(共用同一实现)仍正常,新增字段向后兼容
  · 日报三天附件 490.5K/193.4K/92.3K 与 6 列结构未变;出库列表接口未受影响
  · vue-tsc 与 vite build exit=0

注意:
  · 历史 7 条不匹配的领用人**未改动**(其中「常国玺」疑似离职人员)。
  · 前端只做了编译验证,未在浏览器实际点过 —— 下拉交互与提示样式需人工确认。
2026-09-28 15:18:43 +08:00
d12435a801 V3.94,9.28推送 2026-09-28 11:37:50 +08:00
52a3c4079c feat(audit): 操作人筛选改为下拉选择,过滤由模糊改为精确
原来的「操作人」是自由文本输入,后端按 `LIKE %值%` 模糊匹配。

后端:
  · 新增 GET /audit/operators,返回 [{value: 账号, label: '名(账号)'}],
    按记录数降序(最活跃的排最前),排除 system;/logs 也内联一份省一次请求。
  · username 过滤由 LIKE 改**精确匹配**。选出来的是完整账号,再模糊匹配就是错的:
    实测 `LIKE '%gao%'` 会多带出 2439 条非 gaoxue 的记录,将来出现
    gaoxue / gaoxue2 两个账号时,选前者会连带查出后者的记录。
    部分匹配的需求由下拉的 filterable(在选项里搜)承担,不落到 SQL。

★ 选项从 **audit_logs** 取,不是从 sys_user:审计记的是操作发生时的账号,
  用户被删/改名后 sys_user 就查不到,而历史审计仍需能按他筛选 ——
  实测 32 个操作人里有 1 个在 sys_user 中已不存在。
  display_name 本来就在审计行上('杜邢宸(duxingchen)'),不必 join。

★ 排除 system:它不是人,「操作来源」那组单选(真实用户/系统操作/全部)
  已经专门管它,混进下拉会让两个控件语义打架。

★ /operators 加了 system_audit 权限码,与 /modules(仅 JWT)**故意不同**:
  /modules 给的是模块名,这个给的是**人员账号清单**。它唯一的消费者就是
  审计页,而审计页本身要 system_audit —— 没道理让人绕开页面直接拉全员名单。

前端 AuditLog.vue:
  · 操作人由 el-input 改为 el-select(clearable + filterable),
    选项由后端下发,前端不硬编。
  · 拉取失败(无权限 403 等)时 catch 住,下拉优雅退化为空,不抛未捕获异常。

验证(21 + 20 项断言全过):
  · 32 个操作人全覆盖、排除 system、label 带中文名、按记录数降序
  · 精确匹配与 SQL count 逐一核对(gaoxue 2519 / duxingchen 16863 / liuqi 1236),
    且结果里不含他人
  · 导出口径在与列表一致的 4 组筛选下逐一对齐(含操作人筛选)
  · 权限矩阵:/logs 与 /export 无 token 401、无权限 403;/operators 无权限 403;
    /labels 保持仅 JWT(纯静态标签)
  · 日报三天附件 490.5K/193.4K/92.3K 与 6 列结构未变;聚合行仍可读
  · vue-tsc 与 vite build exit=0
2026-09-28 11:15:15 +08:00
0b87a72a41 feat(audit): 级联操作聚合 —— 一次业务点击只产生一条审计
问题:BOM 归档/启停这类接口一次请求改动整组数据,ORM 监听器逐行写审计,
审计页被刷屏。**实测问题范围远大于 BOM**(同一秒、同接口、同操作人的条数):

    /api/v1/inbound/stock/draft/start-new             924 条
    /api/v1/inbound/stock/stocktake/generate-missing  898 条
    /api/v1/permissions/assign                        381 条
    /api/v1/outbound                                  110 条
    /api/v1/bom/save                                   83 条
    /api/v1/bom/archive                                58 条

全库共 311 次「单请求 ≥20 条」的突发,累计 4 万余行。

实现:URL 白名单 + 同事务合并(方案 A 的收敛版)
  · AGGREGATE_PATH_MARKERS 按**路径段前缀**匹配(不是子串):
    /api/v1/bom_draft_x 不会命中 bom,/api/v1/somebom/thing 也不会。
  · 命中白名单的变更在 flush 期间累加到 session.info,由 Session
    after_flush 钩子合并成**一条** AuditLog。
  · details = 各行的**深度公共子集** + aggregate{count, targets}。

★ 为什么用白名单而不是全局默认合并:合并会改变审计的**语义粒度**
  (日报里「修改 354 条」可能变成几十条),全局改会让业务方以为日志坏了。
  白名单的失败模式也更安全 —— 新接口忘了登记只是"仍然刷屏",
  而不会把两个不相干的业务动作错误合并(错误合并 = 把 A 的改动记到 B 头上,
  是审计里最危险的一类错)。

★ 只取公共子集,不用某一行的值代表整批 —— 那是编造。BOM 的级联批次各行
  改动完全一致(实测 /bom/archive 400 条只有 3 种签名、349 条同一个),
  故合并**无损**;各行不一致时 changes 只留共同字段,其余由 targets 交代
  "动过哪些对象"。

★★ 钩子必须挂 after_flush,不能挂 before_commit —— 踩过的坑:
   Session.commit() 的顺序是 before_commit → flush → after_flush → COMMIT,
   而累加发生在 flush **期间**。挂 before_commit 时钩子跑在累加之前,
   缓冲还是空的;等 flush 填满后没人再写 —— 结果是**审计整批丢失**
   (实测归档请求产出 0 条日志,业务却已提交,正是最危险的"改了但没记录")。
   首版就是这个错,靠真实请求打 /bom/archive 数日志条数才抓出来。

★ 为什么不用 after_request/teardown:那跑在业务事务之外,业务回滚也会留下
  一条"成功"的审计 —— 假账。after_flush 仍属同一事务,聚合日志与业务改动
  同生共死(实测回滚后不留日志)。

消费端:
  · changes_summary 优先识别 aggregate,读作
    「批量更新 58 条;是否启用:是→否;是否归档:否→是(来源:/bom/archive)」
  · SNAPSHOT_VIEWS 增加 aggregate,抽屉据此渲染「变更次数 + 受影响对象表」
  · 前端把「变更对比」与「快照/汇总」从二选一改为各自独立渲染 ——
    聚合日志两者都有,原先的 v-else 会让汇总块显示不出来

验证(26 + 18 项断言全过):
  · 真实请求 POST /api/v1/bom/archive 打一个 58 行的 BOM:
    归档前 0 条 → 归档后**恰好 1 条**,count=58、targets 58 条、
    共同变更无损、业务改动同时生效(58/58 行已归档)
  · 回滚后不留下聚合日志(59996 → 59996)
  · 路径匹配 15 个边界(含 bom_draft_x / somebom 两个反向用例)
  · 非白名单接口行为不变;日报附件 490.5K/193.4K/92.3K 与 6 列结构未变;
    导出口径与列表一致;权限与凭据过滤未松动;字典 181 键零缺失
  · vue-tsc 与 vite build exit=0

⚠️ 仅对**改动之后**的请求生效,历史 4 万行突发数据不变。
⚠️ 验证过程在库里留下 4 条真实审计记录(id 60278/60279 等,均是本人对
   SF-9000-9 V2.2 的归档/取消归档操作,业务数据已精确还原为原状)。
   审计记录未删除 —— 删审计要单独决策。
2026-09-28 11:07:10 +08:00
89db1d14d3 feat(audit): BOM 摘要带出子件,消除批量插入的刷屏
问题:BOM 一次新建会批量插入几十条子件关联记录,同一秒出现几十行
target_name 与 summary 完全相同的日志。实测同一 bom_no 同一秒最多 58 条,
整页 200 条只折叠出 16 种摘要。UPDATE 更严重 —— 批量归档时一种摘要
(「是否启用:是→否;是否归档:否→是」)重复了 135 次。

改动:changes_summary 增加子件分支,load_child_lookup 解析子件。

两条解析路径:
  1. 快照里的 child_id —— CREATE/DELETE(实测 BOM CREATE 覆盖 100%)
  2. 否则若 module 是 BOM,用 target_id 反查 bom_table —— UPDATE
     (实测 584/634 可解)

★ 为什么必须按 module 门控,不能靠「target_id 命中 bom_table」这个特征:
  后者实测**大量误命中** —— 入库管理 3655 条、系统管理的 /permissions/assign
  1415 条、image_embeddings 1187 条、出库管理 726 条,它们的 target_id 都会
  撞上某条 bom_table 记录。照着补子件名就是把毫不相干的零件安到别的记录上。
  而 module 由**表名**推得(audit_listener: bom_table → 'BOM管理'),
  按 module 判定等价于按表判定,是可靠的。

★ 子件名直接可用,不必退回 #ID:child_id 已在 load_ref_maps 的批量解析
  范围内,且 load_child_lookup 自身也只做一次 MaterialBase 批量查
  (实测 200 行 1 次查询、20 行 1 次查询,与行数无关,无 N+1)。

摘要格式:
  新增(BOM管理):添加子件 SF-9000 机加工配件(用量 1)
  删除(BOM管理):移除子件 四代一体-侧面壳(用量 1)
  子件 9-36V输入5V输出隔离模块15W:是否启用:是→否;是否归档:否→是(来源:/bom/archive)

用量只在快照里有;UPDATE 行拿不到就不显示 —— 宁可不显示,也不去猜。

效果(实测):
  · 同一秒 58 条 → 58 种不同摘要
  · 整页 200 条 CREATE:改造前 16 种 → 改造后 76 种
  · UPDATE:改造前 300 条折叠成 5 种(最多重复 135)→ 去重种类显著提升
  · 非 BOM 记录零污染(系统管理/入库管理/出库管理 各 200 条,含「子件」0 条)

验证:16 + 18 项断言全过;日报三天附件 490.5K / 193.4K / 10.3K 与 6 列结构未变;
导出口径在 4 组筛选下与列表一致;权限与凭据过滤未松动;
字典 182 个键零缺失;全库 0 条退化到原始 JSON。vue-tsc 与 vite build exit=0。
2026-09-28 10:52:25 +08:00
956d4acb5f feat(audit): 快照嵌套分层渲染 + 凭据字段从接口剥离(安全修复)
起因:借库管理等单据的详情页仍显示原始 JSON 代码块。
根因:details 有**第三种**存法 {'payload': {...}},且内部嵌着对象数组
(items 明细行),而旧实现只认平铺的 created/deleted_snapshot,
遇到非平铺结构就退化成原始 JSON。

1. 快照键收敛(后端)
   created / deleted_snapshot / payload 三种键与展示标题收进
   audit_labels.SNAPSHOT_VIEWS,经 /audit/labels 下发 snapshotViews,
   前端不再硬编键名 —— 将来加第四种只改一处。
   audit_export_service 与 daily_report_service 改为引用同一份。
   changes_summary 也改用 snapshot_of_any:payload 型记录的新增摘要
   此前整列为空,现在有内容了(如「新增(借库管理):物料明细 1 项、
   备注、借用人、签名、预计归还时间」)。

2. 分层渲染(前端)
   · 标量字段 → el-descriptions(保留字典翻译与空值/技术字段过滤)
   · 数组字段 → el-table:列取**所有元素键的并集**(同一数组内元素键
     并不完全一致,只取首个元素会漏字段),跳过技术字段,列头走 fieldLabel
   · 标量数组(arrival_photo 等图片 URL 列表)→ 单列表格
   · details / payload 本身是数组或标量的情况也一并处理
   · 只有确实无可识别结构时才回落到原始 JSON

   实测全库 58728 条:**0 条**会退化到原始 JSON 兜底
   (payload 1495 条全部拆出结构)。

3. ★ 安全修复:审计快照里存有**明文密码**,本次从所有出口剥离
   实测 `用户管理/新增` 的 payload 快照记着哈希前的原始密码(27 条,
   形如 '123456'、'shili0823'),因为写入路径把请求体整包记进了审计。

   · sanitize_details():递归剥掉凭据类字段,应用于列表与详情接口
   · changes_of / snapshot_of 在**源头**滤掉凭据:逐个出口去补必然漏掉
     某一个,而漏掉的那个就是泄漏点(日报附件、导出、摘要都走这两个函数)
   · 凭据判定用**关键词**(password/passwd/secret/token/private_key)
     而非逐个登记 —— 今天漏的是 payload 里的 password,明天可能是
     reset_token。新表加凭据字段也自动被挡住。

   ★ 第一版我只做了前端隐藏(hiddenFields),被测试抓出来:原始响应里
     密码照样在,打开 devtools 就读得到。**要挡的数据必须在接口出口剥掉,
     前端隐藏不是防线。**

验证:14 + 15 项断言全过,含「列表/详情响应无密码原文与 password 键」
「全量导出的表头与单元格均无凭据」「日报三天附件无凭据」
「sanitize_details 递归进嵌套数组且不原地改原对象」。
6 列结构与附件大小未变(490.5K / 193.4K / 10.3K);vue-tsc 与 vite build exit=0。

⚠️ 数据库中仍存有明文密码(16 条非空),本次只挡展示与导出,**未改数据** ——
   改数据不可逆,需单独立项决定。
2026-09-28 10:40:00 +08:00
91672214c8 feat(audit): 操作对象补全改为按特征全局解析,摘要联动物料名
此前补全只覆盖「入库/采购」,报废、退回、BOM 仍只显示光秃秃的 SKU 或 bom_no。
根因是解析走的是「快照 base_id / target_id 命中股票表」两条硬路径,
而这几类记录的可用线索完全不同(报废快照有 sku 无 base_id、BOM 只有
parent_id、UPDATE 行两样都没有)。

改为**按特征五级解析,不看 module 名单**(同一 module 内不同记录的线索
本来就不一样,按 module 判断必然覆盖不全):

  1. 快照 base_id            —— 库存行的权威线索
  2. 快照 sku → SKU 索引     —— 报废/退回/借还(单据表有 sku、无 base_id)
  3. target_name 是 SKU      —— 库存/退回/报废 的 UPDATE 行(无快照)
  4. 快照 parent_id          —— BOM 关系行
  5. target_id 唯一命中股票表 —— target_name 为 'stock_buy ID:N' 的入库行

★ 「按 SKU 匹配」不能直接查 material_base —— 那张表**没有 sku 列**,
  SKU 在 7 张流水/库存表上(stock_buy/semi/product、stock_adjustment、
  stock_service、trans_defective_goods、trans_repair)。故建 SKU→base_id
  索引:把这些表 UNION 后要求一个 SKU 只对应一个物料。
  实测并集 2178 个 SKU、0 歧义;审计里 432/438 个 SKU 型 target_name 可解。
  表名从 information_schema 动态枚举并缓存,**不硬编** —— 硬编清单在加表时
  会静默漏掉(本项目反复踩过的漂移)。

★ SKU 形状护栏 ^\d{10}$:实测库里 2178 个 SKU **全部**是 10 位纯数字,
  而字母开头的 target_name(bom_no/单号/角色码)与 SKU 集合命中数为 0 ——
  护栏既不误伤也不漏,挡掉了"拿单号当 SKU 查"的误配风险。

★ 第 4 条取 parent_id 而非 child_id:BOM 行的 bom_no(操作对象)属于父件,
  实测同一 bom_no 的所有子行 parent_id 一致('IH-L2V1J' → parent 44)。
  用子件会显示成只在 BOM 里出现一次的小零件名。

★ 编码段优先用库里的 target_name:单号(BOR-…)、bom_no(IH-L2V1J)本身
  就是业务标识,比 SKU 更能定位记录 —— 把「BOR-…」换成 SKU 是**换掉**了
  有用信息。只有 target_name 是内部兜底形态('stock_buy ID:1667')时才用 SKU。

★ 批量查询:先扫一遍收集线索再批量查,全程恒为 5 次查询(SKU 索引 1 +
  股票表 3 + material_base 1),与页大小无关。实测 20 条与 200 条都是 5 次。

效果(真实数据):
  OUT-20260915-0909-0001 - SIF金属箱门包边 (061工)
  BOR-20260922-0001 - 防水余弦接收器 (FOV0008/FOV0008)
  IH-L2V1J - 穹顶光源V1J (IH-L2V1J/类A)
  0000001180 - 华为路由AX3 wifi6路由器3000M (Det0001/Det0001)
覆盖率 972/4000 → 2985/4000,与快照 base_id **零冲突**。

摘要联动:CREATE/DELETE 摘要的「物料」段取自解析结果(快照里只有 sku,
名称在 material_base 上),形如
  「新增(报废管理):华为路由AX3 wifi6路由器3000M、数量 1 件」。
物料名排在数量/位置之前;短名如「防水余弦接收器」不加截断。

验证:24 + 16 项断言全过(五级路径逐条覆盖、错误率为 0、业务单号保留为
编码段、查询次数恒定、导出口径与列表一致、权限 401/403/200、字典零遗漏);
vue-tsc --noEmit 与 vite build 均 exit=0。
日报回归:三天附件 490.5K / 193.4K / 10.3K,6 列结构未变。
2026-09-28 10:25:55 +08:00
46aec0b954 feat(audit): 提升可读性 —— 操作对象补全物料名、详情降噪、字段字典补齐
业务方反馈两点:操作对象太干瘪、详情快照噪音太多。

1. 操作对象补全为「SKU - 物料名称 (规格型号)」

   库里 target_name 大多只存了 SKU('0000002270'),入库类甚至存的是内部
   标识('stock_buy ID:1667'),业务人员完全看不懂。

   补全走两条路径,**正确性优先**:
     · 快照里的 base_id(CREATE/DELETE 的库存行)—— 权威,零歧义
     · target_id 唯一命中一张股票表 —— 实测与上一条 1230/1230 完全一致

   ★ 撞号一律放弃:target_id 同时命中 2 张股票表时解出的 base_id
     24/27 是错的、命中 3 张时 44/44 全错。补一个**错的**物料名比不补更糟 ——
     那是"看起来完全可信的错误答案",业务方会照着它去找不相干的物料。
     实测 300 条撞号行 0 条被补,300 条唯一命中行全部补上。

   target_name 原值保留(target_keyword 搜索仍按它匹配),新增 target_display
   供展示。前端去掉灰显的 #target_id —— 业务人员不需要看数据库主键。

2. 详情快照降噪

   · 前端过滤空值:null / '' / [] / {} / '-'。判据**严格**:false 和 0
     不算空(`is_returned: false`、`quantity: 0` 是明确的业务事实,
     用真值判断会把它们一起吃掉,那是在篡改数据)。
   · 过滤纯技术字段:id / created_at / updated_at / target_id / module_name
     及 pgvector 的 `*embedding`(单条可达数 KB)。清单由后端下发
     (labels.hiddenFields),前端不硬编 —— 它会随新表增长,再存一份必然漂移。
     embedding 类按后缀拦截,新表加向量列不必改代码。
   · 变更对比表过滤「等于没改」的行(null ↔ 空串)。

3. 字段字典补齐:168 项

   实测快照里出现过但字典没有的字段全部补上(buyer_email / currency /
   in_date / exchange_rate / dosage / loss_rate / child / parent /
   production_* / return_* / *_threshold 等 60+ 个)。
   现在快照字段缺中文名的数量为 **0** —— 详情页不会再裸露英文。

4. 摘要优化

   · changes_of 丢掉无意义变更(null ↔ 空串)。库里 98 条记录带这种变更,
     写进摘要就是「备注:空→空」,纯噪音还挤占截断长度。
   · CREATE/DELETE 摘要改为核心属性**按槽位取值**:
         新增(入库):入库数量 10 件、库位 ZZTEST
     而不是「新增:SKU、base、状态… 等 29 个字段」。
   · 槽位只有数量与位置两个,**不含物料** —— 物料由「操作对象」列承担,
     同一屏里再来一遍是重复。数量字段也按槽位只取一个:
     in_quantity / stock_quantity / available_quantity 值往往相同,
     取三个会得到三个一样的数字。
   · 数字去掉无意义的 .0(10.0 → 10);单位取自物料主数据,
     纯数字的占位单位(库里有一批 unit='1')丢弃。

验证:33 + 16 项断言全过,含「补全的物料与快照 base_id 零冲突」「撞号行
一律不补」「快照字段缺中文名 0 个」「摘要无空→空」「导出口径与列表一致」;
vue-tsc --noEmit 与 vite build 均 exit=0。
日报回归:三天附件 490.5K / 193.4K / 10.3K,6 列结构未变。
2026-09-28 10:19:58 +08:00
6a9e41f53c feat(audit-ui): 审计页 UI 重构 —— 干净模块下拉、变更摘要、抽屉详情、触发来源
后端:GET /audit/modules 与 /logs 的 modules 改为下发最终选项 [{value,label}]
  · 历史命名(入库管理/采购入库/成品入库/库存管理)折叠进「入库」一项且
    **不再单独列出**,下拉里看不到历史包袱。展开仍只由后端 expand_modules
    负责 —— 前端若自己再拼一份成员表,将来后端加成员会静默失效(本项目
    已经踩过两次这种漂移)。
  · 英文历史 module(image_embeddings 2719 条 / purchase_request 75 条 /
    sys_element 6 条)在这里翻成中文 label,value 保留英文原值否则筛选
    匹配不上。映射表 MODULE_LABELS 从**前端** AuditLog.vue 的 moduleMap
    收进 audit_labels.py —— 那份硬编副本历史上已经漂移过一次。
  · /audit/labels 新增 moduleDisplay {原始值 → 展示名},同时覆盖聚合成员与
    英文历史值:只覆盖后者会出现「下拉显示『入库』、表格显示『库存管理』」,
    用户会以为筛选没生效。

后端:新增「触发来源」
  · 摘要追加 (来源:/outbound/request) 形式的 URL 尾段(去掉 /api/vN 前缀)。
    解决一个具体误解:业务方看到「入库/库存管理」里有大量非库管人员的
    UPDATE,以为越权改库存,实际是出库/借还/盘点等单据流转触发的自动扣减。
    实测该类 UPDATE 的来源:outbound 1582、outbound/request 424、
    borrow/dispatch 102、stocktake/update-quantity 95 —— 光看"谁改的"永远
    解释不清,必须能看出"哪个流程触发的"。
  · 来源拼在**截断之后**,保证这条关键信息永远不会被截掉。
  · 列表与导出共用 changes_summary,故 Excel 台账同样带来源。

后端:其余
  · 新增 target_keyword(ilike 同时匹配 target_id / target_name),
    放进共用的 _build_audit_query,导出自动支持。
  · action 在响应里归一化为 CREATE/UPDATE/DELETE(复用 audit_labels
    .canon_action,覆盖批量删除/分配/归还等全部历史别名)。库里还有 1573 条
    中文 action,归一后前端不必再为每种历史写法兜底。
  · 新增 summary 字段。不算在模型 @property 上:摘要要把 user_id/base_id
    翻成人名/物料名,需要查库,模型属性里发查询就是 N+1;改为整页一次
    load_ref_maps 批量解析。
  · 过滤对象 repr 脏值(<MaterialBase 3030>)。快照收集早期漏跳关系属性,
    库里留了 1529 条这种值 —— 不是业务数据,真正的值在对应 _id 字段里。
    判据收窄到「<类名 空格 内容>」,避免误伤备注里的「<急件>」。

前端 AuditLog.vue
  · 模块下拉改平铺 [{value,label}],删除硬编的 moduleMap
  · 新增「操作对象」模糊搜索
  · 合并「操作人」「姓名」两列(判据 username==='system' —— 响应里没有
    operator_type 字段,那是请求参数名,照搬会永远不显示系统标签)
  · 新增「变更摘要」列;操作对象显示为「名称 #id」
  · 操作时间改为恒显示北京时间,不再按浏览器时区换算,与数据库/日报/导出一致
  · 详情弹窗改 el-drawer,按 action 分支渲染:UPDATE 出对比表,
    CREATE/DELETE 出快照属性表,并跳过对象 repr
  · 抽屉顶部高亮展示触发来源(METHOD + URL 告警条)

验证:后端 18 + 22 + 10 项断言全过(模块选项无历史名且「入库」=四值之和、
英文 value 仍可筛选、url_source 边界、来源不被截断、导出口径与列表一致、
权限 401/403/200 矩阵);vue-tsc --noEmit 与 vite build 均 exit=0。
日报回归:三天附件 490.5K/193.5K/10.3K,6 列结构与改动前一致。
2026-09-28 10:10:05 +08:00
7d203b20c1 feat(audit): 按条件导出审计日志,并修 module 口径断裂与详情接口权限漏洞
需求是"想看某天或某段时间的入库的修改,支持下载或当场查看"。
核查后:筛选与查看审计页早已具备,缺的是导出 —— 但直接加导出会撞上两个
既有问题,故一并处理。

1. 「入库」的 module 口径在 2026-09-10 断过一次

   那天旧的全局监听器 app/utils/audit_events.py 被现行的
   app/core/audit_listener.py 取代(旧模块现已无人 import,是死代码),
   新监听器把入库三表 stock_buy/stock_semi/stock_product 统一归为
   「库存管理」,不再产出「入库管理」:

       入库管理  16118 条  2026-04-22 ~ 2026-09-10
       采购入库   1196 条  2026-03-18 ~ 2026-04-20
       成品入库      8 条  2026-03-23 ~ 2026-04-17
       库存管理   1371 条  2026-09-10 ~

   后果:按单个 module 值筛「入库」会正好在那天断掉,现象是"入库记录突然
   没了"。解法是**查询期别名展开**(audit_labels.MODULE_GROUPS),不迁移
   历史数据 —— 改数据不可逆,而聚合查询无损。

2. GET /audit/logs/<id> 缺权限校验,也没有公司隔离

   此前只有 @jwt_required(),任何登录用户改一下 URL 里的 id 就能读到全部
   审计明细(含 details 里的完整快照)。列表接口有 system_audit 把关,
   详情提供的信息是列表的超集,没道理比列表更宽松。
   现补权限码 + 与列表同一个公司判据;越权与不存在**统一返回 404**,
   区分开来等于告诉探测者"这个 id 存在,只是你没权限"。

3. 筛选逻辑只写一份

   抽出 _build_audit_query(),/logs 与 /logs/export 共用。两处各写一份迟早
   出现"页面上 300 条、导出来 280 条",而报表对不上比没有报表更糟。

导出实现:
- GET /audit/logs/export,权限码同 /logs;忽略分页参数,导全量。
- 三个工作表:汇总(含本次生效的筛选条件,收件人看不到页面上的筛选框)、
  审计日志(一行一条)、变更明细(一行一个变更字段,可对「字段」列筛选)。
- EXPORT_LIMIT=100000 作熔断。实测全库 5.8 万条导出 2.5MB / 约 3 秒,
  故走同步返回,不引入 export_service 那套异步框架(它还会落盘且无清理)。
  触顶时在文件名与汇总表显式写明,不静默丢弃。
- 顺带补 /audit/modules 的公司隔离(下拉选项此前会跨公司)。

前端(AuditLog.vue / api/audit.ts):
- 模块下拉分「业务聚合 / 具体模块」两组,聚合项由后端下发,前端不硬编成员;
- 操作类型改多选,提交时 join(',')(后端 split(',') 接收);
- 导出按钮沿用仓库既有的 blob 下载写法,文件名前端自定。

验证:后端 21 项断言全过(口径断裂、多选别名归一、日期闭区间、公司隔离、
401/403/200 权限矩阵、导出口径与列表 total 一致、附件为合法 xlsx);
vue-tsc --noEmit 与 vite build 均 exit=0。
2026-09-28 09:46:34 +08:00
1d6beaa376 feat(daily-report): 正文保持摘要,新增 Excel 附件给全量明细
日报此前把明细压到极简(修改只列变更字段≥3 的记录、新增只给模块计数),
"199 条新增只看到 3 个模块名",查不到某条具体记录改了什么。

但也不能全塞进正文:实测某日 354 条修改展开近 3000 行,客户端渲染卡顿,
部分企业邮件网关会把超长自动邮件判为垃圾或截断 —— 发件方照样收到 250,
表面看一切正常,这比"信息少"更难排查。

故拆成两路:正文保持摘要(render),附件给全量明细(render_excel),
六个工作表不设 DETAIL_MIN_FIELDS 之类的阈值。

- email_service: send_email/send_email_async 支持 attachments。数据是内存
  字节、不落盘;中文文件名走 RFC 2231;.xlsx 用官方 MIME 类型,写错会让
  Outlook 当成未知二进制、附件无法直接打开。
- audit_export_service: 新增。审计日志 → 可读值/Excel 的共用层。
  「同一条「实际审批人ID: 7」一边翻了人名一边没翻」这类漂移,比不翻译更
  难发现,故取值/翻译/排版逻辑集中在此,由日报与审计页导出共用。
- daily_report_service: 私有副本改为消费上述共用层(纯重构)。
  附件大小与重构前逐字节一致(490.5K / 193.9K / 10.3K),行为未变。

附件生成失败时降级为纯文本正文继续发送,但记 error 级日志 —— 降级后邮件
看起来完全正常,不留显眼日志的话附件静默丢失可以持续几个月没人发现。
2026-09-28 09:46:25 +08:00
0614597dbe V3.93,9.23推送 2026-09-23 17:42:17 +08:00
b2a2f513ae fix(email): 补齐 Date 与 Message-ID 邮件头
问题:本项目发出的所有邮件都缺少 Date 与 Message-ID —— 这两个头
RFC 5322 要求(Date)或强烈建议(Message-ID)携带,而 smtplib
**不会自动补**,构造 MIMEMultipart 时必须自己加。

为什么值得修:缺这两个头是反垃圾引擎的经典扣分项("来源不明的
自动化邮件"特征)。而它是**静默失败** —— 发件服务器照样回
250 Data Ok,客户端完全看不出邮件在收件方被降权。

诊断依据:抓取完整的 SMTP 会话(set_debuglevel(1))后,报文里
只有 Content-Type / MIME-Version / From / To / Subject 五个头。

★ 需要说明的是:**无法证明这就是此前邮件丢失的原因**。用户实测
  未加此头的邮件也能收到,加了之后仍有一封未送达。此改动是补上
  一个确定存在的规范缺陷,不等同于修复了投递问题。

影响面:send_email() 是全局共用的发信函数,库存预警、出库/借库
审批通知等所有通知邮件一并受益。
2026-09-23 17:41:35 +08:00
4ed8abcc6d fix(scheduler): 调度日志补行缓冲,让后台任务的执行结果可见
问题:gunicorn 的 stdout 是管道,Python 默认对它做**块缓冲** ——
print() 的内容会攒在缓冲区里,docker logs 里看不到。

实测 2026-09-23 17:30 的日报任务:8 个 worker 里只刷出 2 条
"本轮跳过",连抢到锁那个 worker 的成败都没出现,完全无从判断
那封日报到底发了没有。事后排查时只能靠猜。

这不是偶发 —— 定时任务跑在后台线程,出问题本来就不容易发现,
再叠加输出缓冲就等于"瞎跑"。补齐后同一条路径 8 条日志全部刷出,
7 个 worker 跳过、1 个执行,一目了然。

改动:
  · run.py 顶部 sys.stdout.reconfigure(line_buffering=True),全局生效;
  · 调度任务的 print 一律再加 flush=True 作为双保险 —— 这几行输出是
    出问题时唯一的线索,不能依赖缓冲策略。
2026-09-23 17:41:27 +08:00
446c064836 V3.92,9.23推送 2026-09-23 17:32:00 +08:00
ded5dfd7b3 feat(report): MOM 系统日报(纯模板,不接 AI)
每天 17:30(北京时间)自动汇总当日 新增/修改/删除、出库、借库,
渲染为纯文本邮件发送。

为什么不用 AI
--------------
此前那份日报由 Dify(外部 AI 服务,见 api/v1/ai_proxy.py)生成,
好处是能写人话,代价是:
  · 外部依赖一断,整份日报就没了 —— 这正是"AI 坏了"之后发生的事;
  · 按次计费、走网络、要管密钥;
  · **会在报表里编数字**,而日报是给人做决策用的,数字必须可信。
日报是固定格式的结构化统计(计数 + 清单),本来也不需要创造力。
数据全在库里:audit_logs / trans_outbound / trans_borrow。

口径与审计页保持一致
--------------------
  · action 经 canon_action() 归一化(历史数据里 CREATE 与「新增」混用,
    不归一会漏掉早期数据);
  · 默认排除 system 占位账号(与审计页默认的"真实用户"视图一致)。
  · 凡有上限处一律显式写明「另有 N 条」,不静默截断。
  · 变更值全部翻译成人类可读:状态码按模块查表、审批人/物料 ID → 名称、
    布尔 → 是/否、人名串规整。翻不动就原样显示 —— 报表里的错译比裸数字
    更难被发现,也更危险。

★ 顺带修掉两个既有缺陷(日报每天都会走这条路径,不修会一直中招):

1) 定时任务被 8 个 worker 重复执行
   gunicorn.conf.py 配了 8 worker,而 run.py 在**模块级**启动 APScheduler
   —— gunicorn 未开 preload_app,每个 worker 独立 import 一次 run.py,
   于是每个 worker 各起一份调度器,同一 cron 任务被并发执行 8 次。
   现有库存预警邮件因此每天重复发 8 封。
   新增 app/utils/job_lock.py:用 PostgreSQL 会话级咨询锁做跨进程互斥,
   抢到锁的 worker 才执行,其余静默跳过。不引入新组件(compose 里没有
   redis,redis_client 恒为 None,相关装饰器全程 fail-open,指望不上)。
   实测 8 线程并发抢锁恰好 1 个成功。

2) 邮箱授权码被打印进日志
   email_service.py 的 print(f"[DEBUG send_email] cfg = {cfg}") 会把含
   password 的整个配置打到 stdout → docker logs。改为只打非敏感字段。

新增配置:MAIL_DAILY_REPORT_RECIPIENTS(逗号分隔,默认 duxingchen@iris-rs.cn)
2026-09-23 17:16:26 +08:00
ad2d27cd72 refactor(audit): 审计中文映射收敛为后端唯一来源
问题:同一套「字段名 → 中文」映射存了三份手工同步的副本 ——
前端 AuditLog.vue 的 fieldMap(约 95 个字段)、后端 api/v1/audit.py 的
ACTION_ALIASES,另有若干内联的 status 映射散落在 service 层。
改一边漏一边就会漂移,页面上冒出英文列名或对不上的中文。

改动:
  · 新增 app/utils/audit_labels.py 作为**唯一来源**,统一提供
    操作类型归一化(canon_action)、字段名中文化(field_label)、
    码值中文化(enum_label / bool_label / person_name_label)、
    ID 指向登记(id_ref_of)。
  · api/v1/audit.py 改为从该模块 import,删掉本地副本。
  · 新增 GET /audit/labels 下发映射(静态标签,免权限码;审计页本身
    已由 system_audit 把关)。
  · 前端 AuditLog.vue 删除本地 95 行 fieldMap,改从接口拉取;
    拉取失败时未命中项原样显示,是可控降级。

★ 码值必须**按模块**翻译:同名 status 在出库/借还/报废里含义完全不同
  (出库的 3 是「已出库」,报废的 3 是「已执行(已报废)」),
  不区分模块会把报废单显示成已出库。借还模块下还混着字符串状态
  (borrowed/returned),因其两张表共用 status 列名。
  故映射按 (模块, 字段) 匹配,未登记再退回字段名。

★ ID 字段同理:parent_id 在「系统管理」里是菜单的上级菜单,在「BOM管理」
  里是物料节点 —— 指向完全不同的表。

验证:前端 vue-tsc 改前改后均为 565 个错误、去除行号后**报错集合完全相同**,
未引入新错误;GET /audit/labels 实测返回 200。
2026-09-23 17:16:20 +08:00
69d9f010a9 fix(deploy): 生产 compose 补上 MOM_INTERNAL_API_KEY
问题:c7f8588「新增对内接口,让 Track 能提交生产报废」已经提交了
config.py 的 MOM_INTERNAL_API_KEY 读取与 api/v1/internal.py 的实现,
但 docker-compose.prod.yml 忘了同步 —— 生产环境压根不会注入这个变量。

后果:不是"悄悄放行",而是 fail-closed 一直 503。内部接口在生产上
根本不可用,Track 提交生产报废会全部失败。

★ 刻意**不给默认值**(${MOM_INTERNAL_API_KEY:-}):未配置时内部接口返回
  503,而不是让一个写接口用着众所周知的密钥在生产上悄悄打开。
  部署时必须在服务器 .env 里显式配置,且与 Track 侧配同一个值。

注:开发用的 docker-compose.yml 早已包含此项,本次只补齐生产侧。
2026-09-23 17:16:12 +08:00
c5356a27ed fix(scrap): 补上遗漏的 phase14 迁移(不良品报废的库位/批次快照)
问题:前面几个报废提交里,inventory-backend/app/models/transaction.py 已经
引用了 db_migrations/phase14_defective_goods_location_snapshot.sql(两处注释
指向它),但**该文件本身没被提交** —— 代码与迁移对不上,别人拉下来跑不起来。

内容:为 trans_defective_goods 增加 warehouse_location / batch_number 两列,
并从源库存行回填存量。

为什么需要这两列:报废台账页与报废申请单明细上,凡是不良品来源的行,库位与
批次此前一律显示 "-" —— 因为应用层硬编码了空串,理由是「源库存行可能已被
物理删除」。该担忧对 material_name/spec_model 成立(所以它们有冗余快照),
但库位/批次当时**没有**快照列,一律置空等于放弃了「源行还在」这个绝大多数
情形。补上快照后,退回那一刻 stock_row 已 with_for_update() 锁在手里,
库位/批次随手可取,落库后即使源行日后被删除,报表依然有值。

口径:存「原库位」(这批货最初在哪),与三张库存表来源的报废记录一致;
不良品区的位置本系统未记录。batch_number 对 stock_product 来源存的是
serial_number(成品表无 batch_number 列),与 scrap.py 的 _from_stock 对齐。

安全性:纯追加式 DDL,无 NOT NULL、无 DEFAULT,幂等可重复执行。
已在本机执行验证:存量 1 行全部回填,源行已删取不到 0 行。
2026-09-23 17:16:08 +08:00
3e311048ef chore(git): 忽略大体积产物与本机环境文件
这些内容一旦误提交,仓库会瞬间膨胀且**无法通过后续提交瘦身** —— 二进制
会永久留在历史里,只能靠 filter-repo 重写。故在此显式拦住。

新增忽略:
  · base_images.tar(465MB 的 docker 镜像导出包)
  · pgdata_docker_backup_alpine/(Postgres 数据目录副本)
  · inventory_backup.dump / *.dump
  · Odoo_Archive/ / *.pptx
  · uploads/(根目录上传物)/ .backup_migration/ / .qwen/
  · .gitconfig(本机 git 配置,随机器不同)
  · inventory-backend/nul(Windows `> NUL` 重定向误产生的文件)
  · inventory-web/tsc-out.txt(构建产物)

注:*.tar / *.xlsx 这类规则只影响未跟踪文件,已被跟踪的
deploy_full.tar.gz / product.template.xlsx 不受影响。
2026-09-23 17:16:03 +08:00
4c7f0ae47c feat(scrap): 报废记录按原因分类筛选 + Excel 导出 + 显示分类
- 记录页与审批页展示「原因分类」:分类数据一直有下发,只是前端没渲染,
  页面上一个地方都看不到。
- 记录页加分类筛选。★ 选「库存报废」时 SQL 一并兜住空值:本列上线前的
  历史台账没有分类,而它们全是 MOM 自身流程产生的;不兜会漏掉全部历史单,
  用户会以为数据丢了。
- 新增 GET /scrap/records/export 导出 xlsx:与列表同一套筛选,但导**全部
  匹配结果**而不是当前页 —— 只导当前页会让人以为数据被截断。
  金额按 scrap_list:loss_amount 权限决定可不可见(与列表同口径),
  否则导出就成了绕过字段级权限的后门。行数上限 20000 且截断会写进文件名。
2026-09-23 15:17:44 +08:00
c7f85880a9 feat(scrap): 新增对内接口,让 Track 能提交生产报废
料一经出库领用,那条库存行的可用量就扣掉了,走不了标准库存行报废。
本接口内部做两件事:① 走逆向物流「从出库单退回(不良品)」→ 在管不良品;
② 对这笔在管量提交报废申请。两者在**同一个事务**里,要么都成要么都不成。

- 鉴权用 X-API-Key(config.MOM_INTERNAL_API_KEY),与 TRACK_WEBHOOK_KEY
  刻意分离:方向相反、权限不同,独立轮换不连坐。未配置一律 503(Fail-Closed),
  不静默放行 —— 一个默认开着的写接口比没配好的更危险。
- 刻意收紧:is_defective 恒为 true、need_reissue 恒为 false,都不由请求体
  控制。良品分支会往库存行加数量,一个泄漏的密钥就能凭空造库存。
- track_ref 必填:Redis 未部署,唯一索引是唯一的并发防线。
- 退回逻辑从 inbound/stock.py 抽到 services/return_service.py:内部接口没有
  JWT,而视图里夹着 get_current_company_filter/_normalize_user_id,不抽没法复用。
  API 层保留薄包装,restock 等既有调用方一行不用改。
2026-09-23 15:17:44 +08:00
bfd0db791c feat(scrap): 报废原因分类 + 角色级审批
报废要回答「这笔损失出在哪个环节」,并让主管审批不再依赖逐个指定人。

- 新增「报废原因分类」字段(scrap_approval + trans_scrap 各一列),
  只有两个互斥口径:生产报废(走 Track 的)/ 库存报废(MOM 自身流程的)。
  不传即库存报废 —— 这条二分法在写入那一刻就成立,不依赖任何推导。
  ⚠️ 不能从 source_table 推导:Track 的生产报废与手工的不良品退回共用
     同一张 trans_defective_goods 表,推导会把生产损失算成库存损失。
- scrap_approval 加 company_name / source_ref:前者是公司隔离快照,
  后者是外部单据的幂等锚点(Redis 未部署,prevent_double_submit 全程
  fail-open,唯一索引是唯一防线)。
- 审批从「只认 type=user」放宽到「user 或 role」——主管角色都能审,
  谁审就记谁。★ 空名单依然拒绝所有人(Fail-Closed),这是历史
  「名单为空则人人可审」漏洞的修复点,不得改回 fail-open。
- 角色级放行必须配公司隔离:6 个主管里 IRIS 5 个、LICA 1 个,
  不隔离就是跨公司审批通道。
- trans_return 加 source_ref(幂等锚点)。

迁移:db_migrations/phase12(建列)+ phase13(存量空分类回填为库存报废)。
2026-09-23 15:17:44 +08:00
822f8976a9 fix(track): 出库回调的时间戳带 +08:00 偏移,修 8 小时时差
current_time = datetime.now(beijing_tz).replace(tzinfo=None) 是**去掉时区的北京
墙上时间**。MOM 自己的库里 outbound_time/outbound_time 都是 naive timestamp,
本地这么存没问题;但它一旦以无偏移形式("2026-09-23T15:00:00")发给 Track,
Track 的 timestamptz 列会按 **UTC** 解释 —— 实测发 15:00 存成 15:00+00,
前端在北京渲染成 23:00,整整差 8 小时。

用 replace(tzinfo=beijing_tz) 把真实时刻装回去,发出去即
"2026-09-23T15:00:00+08:00" —— 语义明确,不依赖接收方去猜。

⚠️ 只改发给 Track 的这一处,**不动 current_time 本身**:它还要写进 MOM 自己的
   naive 列(TransOutbound.outbound_time、TransRepair.shipping_date),在上面挂
   tzinfo 会改变那些写入的行为。
⚠️ 原有代码把 inbound_time / outbound_time 只拼进 remark 字符串、从不落库,所以
   这个歧义此前一直没有暴露 —— 本次开始落 timestamptz 列才浮出来。

实测(Track 侧 lica_production):
  发 "2026-09-23T15:00:00+08:00" → 存 07:00+00
  AT TIME ZONE 'Asia/Shanghai' 读回 = 2026-09-23 15:00:00 ✅
2026-09-23 09:16:29 +08:00
c272c01388 feat(track): 出库回调补发单据上下文,让 Track 能展示「对应哪张单」
MOM 出库 webhook 此前只发 7 个字段(event/source_table/serial_number/quantity/
outbound_type/company_name/operator),单据信息在 MOM 侧全都有、但从来没被塞进
payload —— Track 侧扫码只能看到「已出库」,不知道是为谁、凭什么出的库。

补 6 个键:outbound_no / request_no / consumer_name / applicant_name /
outbound_time / remark。这些字段整批共用,直接读 common_data 与 approval
(两者在 notify_track 处都在作用域内),track_notifications 元组不必改结构。

同时补 common_data['request_id'] = approval.id —— 这是**表里真实存在的列**
(见上一个提交的迁移),让出库流水能反查回来源申请单。

⚠️ request_no / applicant_name 刻意**不**写进 common_data:它会被 ** 展开进
   TransOutbound(**common_data),多一个不在表里的键直接 TypeError。只有真实
   列才进 common_data,纯展示字段只在 payload 字典里现算。
⚠️ 两者都必须在 approval 取出并校验**之后**赋值 —— common_data 在该行之前就已
   构造,提前引用会 UnboundLocalError(上线时踩过,源码有注释记着)。
⚠️ outbound_time 必须 .isoformat():current_time 是 naive datetime,直接进 JSON
   会序列化失败。
⚠️ applicant_name 复用 OutboundApproval._get_user_name()(to_dict 已在用),
   在 MOM 侧解析成人名再发出去,避免 Track 拿着 applicant_id 跨库反查 sys_user
   —— Track 的 mom_cache 是按 username 索引的,对整型 ID 无能为力。

notify_track() 本身不动 —— 它不做字段白名单,payload 原样 httpx.post(json=...)。
2026-09-23 09:11:26 +08:00
7c3b67bf53 feat(outbound): 出库明细补记来源申请单 request_id
trans_outbound 原先只落了 applicant_id(申请人,phase8 加的),没有任何指回
outbound_approval 的外键。出库时 request_id 是强制必填、approval 对象也一直
在手上,但只把 applicant_id 复制过来就丢弃了 —— 于是从一条出库明细无法回答
「这是哪张申请单出的库」:单号 request_no、申请说明、明细快照 items_json 都在
申请单上,同一张单分几次扫码出库的明细也串不起来。

与 applicant_id 语义正交:applicant_id 是「人」(退回补发要挂回真正该拿东西
的人),request_id 是「那张单」(单据追溯用)。两者都由同一个 approval 带出。

存量行留 NULL 不回填 —— 与 phase8 同一个理由:历史出库与其来源审批单之间没有
任何可用关联,按单号/时间猜会重蹈「重名错绑」的覆辙。NULL 表示「产生于本列
上线之前」。

DDL 见 db_migrations/phase11_trans_outbound_request_link.sql —— 幂等
(ADD COLUMN IF NOT EXISTS),可重复执行,文件尾部自带核对 SELECT 与回滚段。
执行:docker exec -i inventory_db psql -U test -d inventory_system < 该文件
2026-09-23 09:10:55 +08:00
0b4a2b29d6 V3.91,9.22推送 2026-09-22 14:21:00 +08:00
3851ebd7bc fix(inbound): 导入采购单改为单价优先,修复多发/少发时单价被摊薄
原逻辑是总价优先(hasTotalPrice 分支在前):用「申请单总价 ÷ 本次入库数量」
反算单价。但申请单的 total_price 是「申请数量」的钱,实际入库数量未必相同
—— 厂家怕出问题多发(申请 100 个、实发 104 个)是常态,用总价除以实际数量
会把单价摊薄或抬高。

改为单价优先、总价仅作兜底,总价由「单价 × 实际入库数量」得出。与后端
buy_service 的补价口径一致。

顺带修一处隐患:导入新单前清掉 postTaxTotalManuallySet / postTaxTotalInput。
此前若上一单手工改过含税总价,这两个 ref 会残留,updatePrices 末尾会拿着
旧总价回头覆盖新导入的价格 —— 连续导入两张单时必现。

影响面:库管本就拿不到价格(接口剥离),此段对其一直是空转;受影响的是有
价格权限的角色在页面上导入采购单时的计算口径。
2026-09-22 14:09:09 +08:00
d99396ecb1 fix(inbound): 按单入库时从采购申请单补回成本价
库管角色没有 inbound_purchase:unit_price / total_price 权限,采购单接口
(api/v1/purchase.py:46-53)会把价格字段整个 pop 掉,前端导入时因此带不出价
—— buy.vue 的 hasUnitPrice / hasTotalPrice 双双落空,两个分支都不进,入库
成本被记成 0。

而前端 buy.vue:1936 的注释写得很清楚:"无论前端是否对当前角色显示价格,导入
都应把采购单价格带入(隐藏字段,用于成本)"。前端想带、后端不给,价格就断在
中间——不是谁写错了,是两边对"隐藏"的理解不同。

改为在落库前从采购申请单补回:价格不经过前端,库管依旧看不到采购价,权限
边界不变,但成本可追溯。仅在前端未带价时补,有权限的角色手动填过价则尊重
提交值。采购申请单的 unit_price 存的是**含税**单价(见 purchase/index.vue
列名"含税单价"),需反算不含税。

顺带清理 buy_service.py:221 的函数内局部 import —— 局部 import 会让 Python
把 PurchaseRequest 视为本函数的局部变量,导致位于其之前的引用抛
UnboundLocalError(本次新增的补价逻辑正踩到这个,测试时暴露)。改用文件顶部
的全局导入。

实测:测试申请单 含税 12.5 / 税率 13%,以库管身份提交且**故意不带任何价格
字段**,落库结果 税前 11.0619 (=12.5/1.13) / 含税 12.5 / 总价 110.6195 /
税率 13.00,全部正确;响应体经 filter_item_by_permissions 后仍不含价格字段,
权限边界未变。

另:已有 6 条历史入库单(走采购申请但单价为 0)已按「单价 × 实际入库数量」
回填,不回填总价。
2026-09-22 14:09:08 +08:00
91db54982c feat(track): 入库/出库 webhook 按公司路由到对应实例
三个触发点此前都不带 company_name,无从路由:
  · product_service / semi_service:入库通知补 material.company_name
  · outbound_service:track_notifications 元组加 company(取自
    stock_record.base.company_name;维修单走 repair.base,两处
    relationship 均已定义),发送时带进 payload

出库路径同时**去掉**了显式传入的 url=TRACK_OUTBOUND_WEBHOOK_URL ——
url 参数优先级最高,保留它会让出库永远打到默认实例,company 路由形同
虚设。url 参数本身保留,需要强制覆盖时仍可用。

实测:
  LICA 入库:待仓库收货 → 已入库,IRIS 库该 SN 0 行
  LICA 出库:已入库 → 已出库,IRIS 库该 SN 0 行
  IRIS 回归:对已入库设备重发入库通知,状态幂等不变
  精确隔离:发一条 IRIS 通知,lica_backend 日志 +0 行、track_backend +19 行

注意:不做"两个实例都推一遍"——两个 Track 的 webhook key 是同一把、
两边都会鉴权通过,而 Track 按 serial_number/sku 匹配,相同型号设备会
同时命中两库,把 IRIS 的设备误标成已入库,且产生 2 倍审计与流转节点。
2026-09-22 13:13:54 +08:00
0f929468a8 fix(track): track-lookup 按公司路由,修复 LICA 扫码误报"物料不存在"
lookup_product() 此前写死读全局 TRACK_API_URL(指向 IRIS)。LICA 员工在
LICA 业务里扫码 → 请求打到 IRIS 库 → 查不到 → MOM 报"物料不存在"。

改用 get_current_company_filter() + resolve_track_route(…, 'api') 选实例,
复用既有工具而非另写一套。无请求/JWT 上下文时(脚本、后台任务)包一层
异常保护回落默认实例,不阻断查询。

实测(身份证 0000000000000001 只存在于 LICA 库):
  LICA 员工 → company_filter='LICA' → 命中 sn='0000000000000001'
  IRIS 员工 → company_filter='IRIS' → 未命中
改造前两者都会打到 IRIS,LICA 员工必然查不到。
2026-09-22 13:13:53 +08:00
b85be2dfdf feat(track): 新增 resolve_track_route,按公司解析 Track 实例地址
resolve_track_route(company_name, channel) 支持 api / inbound / outbound
三个通道。未命中一律回落到扁平变量,**绝不返回空**——漏配一个 key 就
静默断链,比配置写错更难排查。

非空但不在路由表内的值(含 get_current_company_filter() 的
'__NO_COMPANY__' 哨兵、拼写异常的公司名)会打 WARNING 暴露出来,但不
阻断业务——这类值属于主数据问题,交给业务确认,代码不猜。

notify_track() 相应扩展:新增 channel 参数(不传则按 payload['event']
推断 inbound/outbound),url 仍为最高优先级(显式指定时不做公司路由)。

实测各分支:IRIS/LICA 各通道命中正确;None/空串/未知公司/缺通道/空路由表
全部按预期回落。
2026-09-22 13:13:53 +08:00
de34a939d0 feat(track): 新增 TRACK_ROUTES 多实例路由表配置
Track 现在有两个独立实例(IRIS / LICA),MOM 此前所有 Track 配置都写死
指向 IRIS,LICA 的业务拿不到入库/出库联动,且 /track-lookup 对 LICA
员工是坏的。

新增 TRACK_ROUTES 环境变量(JSON),按 material_base.company_name 决定
业务落到哪个实例:
  {"IRIS": {"api":..., "inbound":..., "outbound":...}, "LICA": {...}}

原有三个扁平变量(TRACK_API_URL / TRACK_WEBHOOK_URL /
TRACK_OUTBOUND_WEBHOOK_URL)保留为「未命中路由表」时的回落默认值;
TRACK_ROUTES 缺省为空表时,行为与改造前逐字节一致(向后兼容)。

⚠️ JSON 解析失败直接抛异常让进程起不来(fail fast)——路由表写坏却静默
回落,会造成"某些公司悄悄断链",比启动报错难排查得多。

docker-compose.yml 用容器名互访(inventory_api / track_backend /
lica_backend 三个容器同属 projects_default 网络,不走宿主机端口)。

判定维度用 company_name 而非 category:实测 category 两个方向都会错
(10 条 LICA 物料挂在 IRIS/ 分类下;IRIS 分类里含 "IRIS/成品/LICA/..."
那是产品线名不是部门名,共 171 条)。
2026-09-22 13:13:52 +08:00
42 changed files with 6802 additions and 843 deletions

23
.gitignore vendored
View File

@ -20,3 +20,26 @@ inventory-web/*.local
pgdata_docker/ pgdata_docker/
inventory-backend/uploads/ inventory-backend/uploads/
.aider* .aider*
# --- 大体积二进制与数据产物 ---
# 这些一旦误提交,仓库会瞬间膨胀且**无法通过后续提交瘦身**(历史里永久保留),
# 只能靠 filter-repo 重写历史。故在此显式拦住,宁可有遗漏再补。
base_images.tar # 465MB 的 docker 镜像导出包
pgdata_docker_backup_alpine/ # Postgres 数据目录副本(pgdata_docker/ 已有同样规则)
inventory_backup.dump # 数据库转储
*.dump
*.tar
*.tar.gz
Odoo_Archive/ # Odoo 侧归档
*.pptx # 培训材料(二进制,且体积大)
*.xlsx
# --- 上传与本地环境 ---
uploads/ # 根目录上传物(inventory-backend/uploads/ 已有规则)
.backup_migration/
.qwen/
.gitconfig # 本机 git 配置,随机器不同而不同
inventory-backend/nul # Windows `> NUL` 重定向误产生的文件
# --- 构建产物 ---
inventory-web/tsc-out.txt

View File

@ -0,0 +1,72 @@
-- =============================================================================
-- 出库明细补记「来源申请单」,让出库流水能反查回它属于哪张单
--
-- 问题
-- trans_outbound 只有 applicant_id(申请人,见 phase8),没有任何指回
-- outbound_approval 的外键。出库时 request_id 是**强制必填**的,approval
-- 对象在服务层也一直在手上,但只把 applicant_id 复制过来就丢弃了 —— 于是
-- 从一条出库明细无法回答「这是哪张申请单出的库」:
-- · 申请单号 request_no 查不到
-- · 申请说明 / 明细快照 items_json 查不到
-- · 同一张申请单分几次扫码出库时,这几条明细也串不起来
--
-- 本次改动
-- trans_outbound 新增 request_id,创建出库时从关联审批单带出。
-- 创建出库时 approval 恒非 None(request_id 已强制必填),故新单据必然有值。
--
-- ---------------------------------------------------------------------------
-- ★ 为什么是 request_id(主键)而不是 request_no(单号)
-- 与 phase8 同一个理由:主键没有歧义,单号是业务字段、将来格式可能变。
-- 要展示单号时 join 一次 outbound_approval 即可。
--
-- ★ 为什么不回填存量行
-- 与 phase8 完全一样:存量出库明细与其来源审批单之间**没有任何可用的关联**
-- (创建时只把审批单状态置为 3,没落任何外键),无从回填。刻意留 NULL 而不是
-- 按单号/时间猜 —— NULL 表示「这张单产生于本列上线之前」。
--
-- ★ 与 applicant_id 的关系
-- 两者都由 approval 带出,但语义正交:applicant_id 是「人」,request_id 是
-- 「那张单」。退回补发用前者(挂回真正该拿东西的人),单据追溯用后者。
--
-- 幂等:带 IF NOT EXISTS,可重复执行。不含 psql 元命令,DataGrip 可直接执行。
-- =============================================================================
BEGIN;
ALTER TABLE trans_outbound
ADD COLUMN IF NOT EXISTS request_id integer;
COMMENT ON COLUMN trans_outbound.request_id IS
'来源出库申请单ID(outbound_approval.id),创建出库时从关联审批单带出。该列上线前的历史行为 NULL(无从回填)';
-- 支撑「某张申请单出过哪些明细」的反查,以及按单聚合对账
CREATE INDEX IF NOT EXISTS ix_trans_outbound_request
ON trans_outbound (request_id);
COMMIT;
-- =============================================================================
-- 执行后核对
-- =============================================================================
SELECT '=== 1) 新列已就位 ===' AS "核对项";
SELECT column_name, data_type FROM information_schema.columns
WHERE table_name = 'trans_outbound' AND column_name = 'request_id';
SELECT '=== 2) 索引已就位 ===' AS "核对项";
SELECT indexname FROM pg_indexes
WHERE tablename = 'trans_outbound' AND indexname = 'ix_trans_outbound_request';
SELECT '=== 3) 存量行应全部为 NULL(历史无从回填)===' AS "核对项";
SELECT count(*) AS 出库明细总数,
count(request_id) AS 已记录来源单
FROM trans_outbound;
-- =============================================================================
-- 回滚段
-- =============================================================================
-- BEGIN;
-- DROP INDEX IF EXISTS ix_trans_outbound_request;
-- ALTER TABLE trans_outbound DROP COLUMN IF EXISTS request_id;
-- COMMIT;

View File

@ -0,0 +1,155 @@
-- =============================================================================
-- 生产导致报废:Track → MOM 复用逆向物流 + 报废审批
--
-- 问题
-- Track 要把「生产领用的物料已经领到产线、之后在生产中报废」提交进 MOM,
-- 并复用 MOM 现有的报废流程(申请→审批→执行),最终能统计报废金额。
-- 但料**已经出库**,那条库存行的 available_quantity / stock_quantity 在出库
-- 时就已扣掉,走不了标准库存行报废(cap=可用库存)。MOM 自己的答案是走
-- 「从出库单退回(不良品)」→ trans_defective_goods 在管台账(库存表分毫不动)。
--
-- 本次改动(全部为可空列,纯增量)
-- 1) scrap_approval.company_name —— 公司隔离快照。角色级审批的前提:
-- 6 个主管里 IRIS 5 个、LICA 1 个,不隔离公司就是跨公司审批通道。
-- 2) scrap_approval.reason_category —— 报废原因分类,标记「生产导致」。
-- 3) scrap_approval.source_ref —— 外部系统唯一引用(幂等 + 追溯)。
-- 4) trans_scrap.reason_category —— 台账侧同名字段,供报废金额分组统计。
-- 5) trans_return.source_ref —— 幂等锚点。
--
-- ---------------------------------------------------------------------------
-- ★ 为什么 source_ref 是 '<company>:<track_ref>' 而不是裸的单号
-- IRIS 与 LICA 各自独立跑一套 Track,工单号可能重号。唯一索引必须带公司维度,
-- 否则两家会互相挡住对方的首次受理。
--
-- ★ 为什么幂等要靠唯一索引,而不是靠 prevent_double_submit
-- 该装饰器依赖 Redis,而 docker-compose 里**没有 redis 服务** ——
-- app/extensions.py 的 redis_client 恒为 None,装饰器全程 fail-open、
-- 实际不生效(已实测)。所以 DB 唯一索引是唯一的并发防线。
--
-- ★ 为什么不回填 reason_category
-- 本库 scrap_approval / trans_scrap 均为 0 行,无存量;生产库若有历史行,
-- 留 NULL 表示「产生于本列上线之前」,不猜。company_name 可安全回填
-- (申请人所属部门即公司,是确定性映射)。
--
-- ★ 时区提醒
-- 本文件不新增时间列。注意 scrap_approval 的时间列在**本库**仍是 timestamptz
-- (unify_approval_timezone.sql 尚未在本库执行,实测库 TimeZone=Etc/UTC),
-- 与该脚本的预期不一致。日后再加时间列时请先确认该脚本已执行,
-- 不要照抄 add_scrap_approval.sql 的 timestamptz 写法。
--
-- 幂等:带 IF NOT EXISTS,可重复执行。不含 psql 元命令,DataGrip 可直接执行。
-- 执行: docker exec -i inventory_db psql -U test -d inventory_system < 本文件
-- =============================================================================
BEGIN;
-- ---------------------------------------------------------------------------
-- 1) 报废申请单:公司快照 + 原因分类 + 外部引用
-- ---------------------------------------------------------------------------
ALTER TABLE scrap_approval
ADD COLUMN IF NOT EXISTS company_name varchar(255);
COMMENT ON COLUMN scrap_approval.company_name IS
'申请所属公司(= sys_user.department)。角色级审批据此做公司隔离,防止跨公司审批';
CREATE INDEX IF NOT EXISTS ix_scrap_approval_company
ON scrap_approval (company_name);
ALTER TABLE scrap_approval
ADD COLUMN IF NOT EXISTS reason_category varchar(50);
COMMENT ON COLUMN scrap_approval.reason_category IS
'报废原因分类码:PRODUCTION(生产损耗=好料领用后变坏) / STOCK(库存/采购=在库或买来就有问题) / OTHER(其他)。'
'★必须显式记录,不能从 source_table 推导:Track 的生产报废与 MOM 手工报的不良品退回共用 trans_defective_goods,'
'推导会把生产损失静默算成库存损失。中文名见 scrap_approval.SCRAP_CATEGORY_LABELS';
ALTER TABLE scrap_approval
ADD COLUMN IF NOT EXISTS source_ref varchar(100);
COMMENT ON COLUMN scrap_approval.source_ref IS
'外部系统唯一引用,格式 <company>:<外部单号>。用于幂等与追溯';
-- 唯一索引:同一外部单据不得重复受理(部分索引,NULL 不参与唯一性)
CREATE UNIQUE INDEX IF NOT EXISTS uk_scrap_approval_source_ref
ON scrap_approval (source_ref) WHERE source_ref IS NOT NULL;
-- ---------------------------------------------------------------------------
-- 2) 报废流水台账:原因分类(统计按它分组)
-- ---------------------------------------------------------------------------
ALTER TABLE trans_scrap
ADD COLUMN IF NOT EXISTS reason_category varchar(50);
COMMENT ON COLUMN trans_scrap.reason_category IS
'报废原因分类码,执行报废时从 scrap_approval.reason_category 带出。统计「生产报废金额」按此分组';
CREATE INDEX IF NOT EXISTS ix_trans_scrap_category
ON trans_scrap (reason_category);
-- ---------------------------------------------------------------------------
-- 3) 退回流水:幂等锚点
-- ---------------------------------------------------------------------------
ALTER TABLE trans_return
ADD COLUMN IF NOT EXISTS source_ref varchar(100);
COMMENT ON COLUMN trans_return.source_ref IS
'外部系统唯一引用,格式 <company>:<外部单号>。外部接口据此判重(Redis 未部署,唯一索引是唯一防线)';
CREATE UNIQUE INDEX IF NOT EXISTS uk_trans_return_source_ref
ON trans_return (source_ref) WHERE source_ref IS NOT NULL;
-- ---------------------------------------------------------------------------
-- 4) 存量回填:申请人所属部门即公司(确定性映射)
-- 查不到(申请人已删)保持 NULL —— 应用层对 company_name 为空的旧单
-- **跳过**公司校验而非拒绝,避免把历史在途单卡死。
-- ---------------------------------------------------------------------------
UPDATE scrap_approval sa
SET company_name = u.department
FROM sys_user u
WHERE u.id = sa.applicant_id
AND sa.company_name IS NULL
AND COALESCE(u.department, '') <> '';
COMMIT;
-- =============================================================================
-- 执行后核对
-- =============================================================================
SELECT '=== 1) scrap_approval 三个新列已就位 ===' AS "核对项";
SELECT column_name, data_type FROM information_schema.columns
WHERE table_name = 'scrap_approval'
AND column_name IN ('company_name', 'reason_category', 'source_ref')
ORDER BY column_name;
SELECT '=== 2) trans_scrap / trans_return 新列已就位 ===' AS "核对项";
SELECT table_name, column_name, data_type FROM information_schema.columns
WHERE (table_name = 'trans_scrap' AND column_name = 'reason_category')
OR (table_name = 'trans_return' AND column_name = 'source_ref')
ORDER BY table_name;
SELECT '=== 3) 索引已就位(三个普通 + 两个唯一)===' AS "核对项";
SELECT tablename, indexname FROM pg_indexes
WHERE indexname IN ('ix_scrap_approval_company', 'ix_trans_scrap_category',
'uk_scrap_approval_source_ref', 'uk_trans_return_source_ref')
ORDER BY indexname;
SELECT '=== 4) 存量:本库应均为 0 行 ===' AS "核对项";
SELECT (SELECT count(*) FROM scrap_approval) AS 报废申请单数,
(SELECT count(*) FROM trans_scrap) AS 报废流水数,
(SELECT count(*) FROM scrap_approval WHERE company_name IS NULL) AS 公司为空的申请单;
-- =============================================================================
-- 回滚段
-- =============================================================================
-- BEGIN;
-- DROP INDEX IF EXISTS uk_trans_return_source_ref;
-- ALTER TABLE trans_return DROP COLUMN IF EXISTS source_ref;
-- DROP INDEX IF EXISTS ix_trans_scrap_category;
-- ALTER TABLE trans_scrap DROP COLUMN IF EXISTS reason_category;
-- DROP INDEX IF EXISTS uk_scrap_approval_source_ref;
-- DROP INDEX IF EXISTS ix_scrap_approval_company;
-- ALTER TABLE scrap_approval DROP COLUMN IF EXISTS source_ref;
-- ALTER TABLE scrap_approval DROP COLUMN IF EXISTS reason_category;
-- ALTER TABLE scrap_approval DROP COLUMN IF EXISTS company_name;
-- COMMIT;

View File

@ -0,0 +1,78 @@
-- =============================================================================
-- 报废原因分类:存量归位 + 口径定型
--
-- 背景(2026-09-23 与业务确认)
-- 分类只有两种,是**互斥二分**:
-- · 生产报废(PRODUCTION)—— **所有走 Track 的**。料领到产线之后在生产中
-- 变坏,经 Track 提交、退回不良品、走 MOM 报废审批。
-- · 库存报废(STOCK) —— **MOM 自身流程走下来的**。不看 Track 的、
-- 直接在 MOM 界面选库存行报废、不良品看板、借还记录那几条路。
--
-- 中文标签同时由「生产损耗 / 库存采购」改为「生产报废 / 库存报废」——
-- 旧名字里的「损耗」「采购」让现场以为分了四个类,实际只有两个。
--
-- 本次改动
-- 1) 存量 scrap_approval.reason_category 为空的 → 一律归 STOCK(库存报废)。
-- 理由:本列上线时只有 Track 那条链会写值(显式 PRODUCTION),
-- 其余全是 MOM 自身流程产生的 —— 空值就是「不是 Track 报的」。
-- 2) trans_scrap.reason_category 为空的 → 同样归 STOCK,口径保持一致
-- (台账与申请单对不上会让金额统计出现两个数)。
-- 3) 写入侧的默认值已同步改到应用层(submit_approval 不传分类即 STOCK),
-- 本文件只负责把上线前的存量补齐。
--
-- ⚠️ 这是**回填**不是推导:值是按「这单是不是 Track 报的」定的,而 Track 报的
-- 一定有 source_ref(<公司>:<Track单据号>)。有 source_ref 却分类为空的
-- 属于异常数据,本脚本**不动**它们,留给人工看 —— 宁可留着空,
-- 也不要把它错归成库存报废把生产损失算没了。
--
-- 幂等:带 WHERE ... IS NULL,可重复执行。不含 psql 元命令,DataGrip 可直接执行。
-- 执行: docker exec -i inventory_db psql -U test -d inventory_system < 本文件
-- =============================================================================
BEGIN;
-- 1) 报废申请单:空分类且**没有** Track 来源引用 → 库存报废
UPDATE scrap_approval
SET reason_category = 'STOCK'
WHERE reason_category IS NULL
AND (source_ref IS NULL OR source_ref = '');
-- 2) 报废流水台账:同上,口径与申请单保持一致
UPDATE trans_scrap
SET reason_category = 'STOCK'
WHERE reason_category IS NULL
AND (scrap_request_no IS NULL
OR scrap_request_no NOT IN (
SELECT request_no FROM scrap_approval WHERE source_ref IS NOT NULL
));
COMMIT;
-- =============================================================================
-- 执行后核对
-- =============================================================================
SELECT '=== 1) 申请单分类分布(应只剩生产报废/库存报废两类)===' AS "核对项";
SELECT COALESCE(reason_category, '(空)') AS 分类, count(*) AS 单数
FROM scrap_approval GROUP BY reason_category ORDER BY 2 DESC;
SELECT '=== 2) ★ 异常数据:有 Track 来源引用却没有分类(应为 0,需人工看)===' AS "核对项";
SELECT count(*) AS 异常单数
FROM scrap_approval
WHERE (source_ref IS NOT NULL AND source_ref <> '')
AND reason_category IS NULL;
SELECT '=== 3) 台账分类分布 ===' AS "核对项";
SELECT COALESCE(reason_category, '(空)') AS 分类, count(*) AS 行数
FROM trans_scrap GROUP BY reason_category ORDER BY 2 DESC;
-- =============================================================================
-- 回滚段(把回填过的空值还原 —— 只能还原成「空」,值本身无法区分)
-- =============================================================================
-- BEGIN;
-- UPDATE scrap_approval SET reason_category = NULL
-- WHERE reason_category = 'STOCK' AND (source_ref IS NULL OR source_ref = '');
-- UPDATE trans_scrap SET reason_category = NULL
-- WHERE reason_category = 'STOCK';
-- COMMIT;

View File

@ -0,0 +1,108 @@
-- =============================================================================
-- 不良品在管台账:补「原库位 / 批次(序列号)」快照列
--
-- 背景
-- trans_defective_goods 只冗余了 material_name / spec_model,却没有存库位与
-- 批次。于是报废台账页、报废申请单明细上,凡是不良品来源的行,库位与
-- 批次一律显示 "-" —— 而它们的源库存行往往**还在**,本该取得到。
--
-- 应用层原先的处置是在 _resolve_materials 与 DefectiveScrapAdapter.snapshot
-- 里直接硬编码空串,注释理由是「源库存行可能已被物理删除」。这个担忧对
-- material_name/spec_model 成立(所以它们有冗余快照),但库位/批次**没有**
-- 快照列,一律置空等于放弃了「源行还在」这个绝大多数情形。
--
-- 本次改动(A+B)
-- A. 应用层改为「快照优先 + 回查源库存行兜底」(本次只加列,逻辑在代码侧)
-- B. 本文件补两列,把「源行可能被删」这个担忧真正解决掉:退回那一刻
-- stock_row 已 with_for_update() 锁在手里,库位/批次随手可取,
-- 落库后即使源行日后被物理删除,报表依然有值。
--
-- 口径(2026-09-23 与业务确认)
-- 存的是「**原库位**」—— 这批货最初在哪,而不是坏件当前所在的不良品区。
-- 与三张库存表来源的报废记录口径一致。不良品区的位置本系统未记录。
--
-- ★ batch_number 列对 stock_product 来源存的是 serial_number:
-- 成品表没有 batch_number 列(见 information_schema),取值口径沿用
-- 应用层的 COALESCE(batch_number, serial_number),与 _from_stock 一致。
-- 故本列语义为「批次或序列号」,与前端列名「批次/序列号」对齐。
--
-- 安全性:纯追加式 DDL,无 NOT NULL、无 DEFAULT,既有行取值 NULL。
-- 幂等:IF NOT EXISTS + 带 WHERE 的 UPDATE,可重复执行。
--
-- 执行
-- docker exec -i inventory_db psql -U test -d inventory_system < 本文件
-- =============================================================================
BEGIN;
-- ---------------------------------------------------------------------------
-- 1) 快照列(长度与三张库存表对齐,均为 varchar(100))
-- ---------------------------------------------------------------------------
ALTER TABLE trans_defective_goods
ADD COLUMN IF NOT EXISTS warehouse_location varchar(100),
ADD COLUMN IF NOT EXISTS batch_number varchar(100);
COMMENT ON COLUMN trans_defective_goods.warehouse_location IS
'原库位快照(退回时取自源库存行)。源行被物理删除时此处仍有值,故报表优先用它';
COMMENT ON COLUMN trans_defective_goods.batch_number IS
'批次/序列号快照(退回时取自源库存行的 batch_number,成品表缺失时取 serial_number)';
-- ---------------------------------------------------------------------------
-- 2) 存量回填:按 source_table + stock_id 回查源库存行
-- 三张表分别 UPDATE(多态关联,无法用一条语句表达)。
-- WHERE warehouse_location IS NULL 保证幂等,且不覆盖已回填过的值。
--
-- 回填不到的(源行已被物理删除)保持 NULL —— 刻意不写成空串,
-- 这样下面的核对能数出「真正取不到」的行数,与「已确认无库位」区分开。
-- ---------------------------------------------------------------------------
UPDATE trans_defective_goods g
SET warehouse_location = COALESCE(b.warehouse_location, ''),
batch_number = COALESCE(NULLIF(b.batch_number, ''), b.serial_number, '')
FROM stock_buy b
WHERE g.source_table = 'stock_buy'
AND g.stock_id = b.id
AND g.warehouse_location IS NULL;
UPDATE trans_defective_goods g
SET warehouse_location = COALESCE(s.warehouse_location, ''),
batch_number = COALESCE(NULLIF(s.batch_number, ''), s.serial_number, '')
FROM stock_semi s
WHERE g.source_table = 'stock_semi'
AND g.stock_id = s.id
AND g.warehouse_location IS NULL;
-- ★ 成品表无 batch_number 列,只取 serial_number
UPDATE trans_defective_goods g
SET warehouse_location = COALESCE(p.warehouse_location, ''),
batch_number = COALESCE(p.serial_number, '')
FROM stock_product p
WHERE g.source_table = 'stock_product'
AND g.stock_id = p.id
AND g.warehouse_location IS NULL;
COMMIT;
-- =============================================================================
-- 执行后核对
-- =============================================================================
SELECT '=== 1) 回填结果:已回填 / 源行已删取不到 / 无需回填 ===' AS "核对项";
SELECT count(*) FILTER (WHERE warehouse_location IS NOT NULL) AS 已回填,
count(*) FILTER (WHERE warehouse_location IS NULL) AS 源行已删_取不到,
count(*) AS 台账总行数
FROM trans_defective_goods;
SELECT '=== 2) ★ 源行已删的明细(取不到库位属预期,应逐条确认是否为真)===' AS "核对项";
SELECT id, source_table, stock_id, sku, status
FROM trans_defective_goods
WHERE warehouse_location IS NULL
ORDER BY id;
-- =============================================================================
-- 回滚段(仅撤销本迁移;回填值与「原本就是空」无法区分)
-- =============================================================================
-- BEGIN;
-- ALTER TABLE trans_defective_goods DROP COLUMN IF EXISTS warehouse_location;
-- ALTER TABLE trans_defective_goods DROP COLUMN IF EXISTS batch_number;
-- COMMIT;

View File

@ -44,6 +44,12 @@ services:
TRACK_WEBHOOK_KEY: ${TRACK_WEBHOOK_KEY:-2ce5fedb48fde3fd7e0abf67472a5027b03e9ae6f19cf768} TRACK_WEBHOOK_KEY: ${TRACK_WEBHOOK_KEY:-2ce5fedb48fde3fd7e0abf67472a5027b03e9ae6f19cf768}
TRACK_API_URL: http://track_backend_prod:8000 TRACK_API_URL: http://track_backend_prod:8000
TRACK_OUTBOUND_WEBHOOK_URL: http://track_backend_prod:8000/api/v1/external/webhooks/mom-outbound TRACK_OUTBOUND_WEBHOOK_URL: http://track_backend_prod:8000/api/v1/external/webhooks/mom-outbound
# ★ 对内接口(Track → MOM 生产报废受理)共享密钥,请求头 X-API-Key。
# 与 TRACK_WEBHOOK_KEY 刻意分离(方向相反、权限不同),独立轮换。
# ★ 刻意**不给默认值** —— 未配置时内部接口 503(Fail-Closed),
# 而不是让一个写接口用着众所周知的密钥在生产上悄悄打开。
# 部署时必须在服务器 .env 里显式配置,且与 Track 侧配同一个值。
MOM_INTERNAL_API_KEY: ${MOM_INTERNAL_API_KEY:-}
depends_on: depends_on:
- db - db
# 加入 mom_net 外部网络,与 Track 生产容器互通 # 加入 mom_net 外部网络,与 Track 生产容器互通

View File

@ -34,6 +34,8 @@ services:
MAIL_DEFAULT_SENDER: wms@iris-rs.cn MAIL_DEFAULT_SENDER: wms@iris-rs.cn
MAIL_USE_SSL: "true" MAIL_USE_SSL: "true"
MAIL_USE_TLS: "false" MAIL_USE_TLS: "false"
# MOM 系统日报收件人(逗号分隔可配多个)。每天 17:30 北京时间发送。
MAIL_DAILY_REPORT_RECIPIENTS: ${MAIL_DAILY_REPORT_RECIPIENTS:-duxingchen@iris-rs.cn}
# Track 系统 Webhook 通知(未配置则后端静默跳过,不通知) # Track 系统 Webhook 通知(未配置则后端静默跳过,不通知)
TRACK_WEBHOOK_URL: ${TRACK_WEBHOOK_URL:-} TRACK_WEBHOOK_URL: ${TRACK_WEBHOOK_URL:-}
TRACK_WEBHOOK_KEY: ${TRACK_WEBHOOK_KEY:-} TRACK_WEBHOOK_KEY: ${TRACK_WEBHOOK_KEY:-}
@ -41,6 +43,20 @@ services:
TRACK_API_URL: ${TRACK_API_URL:-} TRACK_API_URL: ${TRACK_API_URL:-}
# Track 出库 webhook(发货出库时通知 Track 标记"已出库") # Track 出库 webhook(发货出库时通知 Track 标记"已出库")
TRACK_OUTBOUND_WEBHOOK_URL: ${TRACK_OUTBOUND_WEBHOOK_URL:-} TRACK_OUTBOUND_WEBHOOK_URL: ${TRACK_OUTBOUND_WEBHOOK_URL:-}
# ★ 多 Track 实例路由表:按 material_base.company_name 决定走哪个实例。
# 上面三个扁平变量保留为「未命中路由表」时的回落默认值(向后兼容)。
# ★ 用容器名互访(同属 projects_default 网络),不走宿主机端口。
TRACK_ROUTES: >-
{"IRIS":{"api":"http://track_backend:8000",
"inbound":"http://track_backend:8000/api/v1/external/webhooks/mom-inbound",
"outbound":"http://track_backend:8000/api/v1/external/webhooks/mom-outbound"},
"LICA":{"api":"http://lica_backend:8000",
"inbound":"http://lica_backend:8000/api/v1/external/webhooks/mom-inbound",
"outbound":"http://lica_backend:8000/api/v1/external/webhooks/mom-outbound"}}
# ★ 对内接口(Track → MOM)共享密钥,请求头 X-API-Key。
# 与 TRACK_WEBHOOK_KEY 刻意分离:方向相反、权限不同(能发起报废审批),
# 独立轮换不连坐。未配置则内部接口一律 503(Fail-Closed),不静默放行。
MOM_INTERNAL_API_KEY: ${MOM_INTERNAL_API_KEY:-}
depends_on: depends_on:
- db - db

View File

@ -135,6 +135,20 @@ def create_app():
except ImportError as e: except ImportError as e:
print(f"❌ 错误: Scrap 模块导入失败: {e}") print(f"❌ 错误: Scrap 模块导入失败: {e}")
# -----------------------------------------------------
# 2.6b 注册对内接口模块(Track → MOM)
#
# ★ 鉴权走 X-API-Key(config.MOM_INTERNAL_API_KEY),**不走 JWT** ——
# Track 没有 MOM 账号,也不该有:申请人身份由请求体显式携带。
# ★ 只注册 /api/v1 一条路径,**不做 legacy 双注册**:内部写接口不该多一个入口。
# -----------------------------------------------------
try:
from app.api.v1.internal import internal_bp
app.register_blueprint(internal_bp, url_prefix='/api/v1/internal')
print("✅ Internal 模块注册成功")
except ImportError as e:
print(f"❌ 错误: Internal 模块导入失败: {e}")
# ----------------------------------------------------- # -----------------------------------------------------
# 2.8 注册采购管理模块 # 2.8 注册采购管理模块
# ----------------------------------------------------- # -----------------------------------------------------

View File

@ -1,47 +1,312 @@
# inventory-backend/app/api/v1/audit.py # inventory-backend/app/api/v1/audit.py
from flask import Blueprint, request, jsonify, current_app import io
from flask_jwt_extended import jwt_required, get_jwt from datetime import datetime, timedelta
from app.utils.decorators import permission_required
from app.models.audit import AuditLog from flask import Blueprint, current_app, jsonify, request, send_file
from flask_jwt_extended import jwt_required
from sqlalchemy import func, or_
from app.extensions import db from app.extensions import db
from sqlalchemy import or_ from app.models.audit import AuditLog
from datetime import datetime from app.models.system import SysUser
import json from app.services.audit_export_service import (
SYSTEM_USERNAME,
build_audit_workbook,
changes_summary,
format_target_display,
load_child_lookup,
load_material_context,
load_ref_maps,
sanitize_details,
)
from app.utils.decorators import get_current_company_filter, permission_required
audit_bp = Blueprint('audit', __name__) audit_bp = Blueprint('audit', __name__)
# ============================================================================= # =============================================================================
# 操作类型归一化 # 操作类型归一化 / 中文化标签
# #
# 问题背景:历史数据里 action 有两套写法 —— 早期装饰器(已废弃)写入中文 # ★ 映射表已统一收敛到 app/utils/audit_labels.py(**唯一来源**)。
# (新增/修改/删除/批量删除…),现行监听器写入大写英文(CREATE/UPDATE/DELETE)。 # 本模块与日报服务共用同一份,前端经 GET /audit/labels 拉取同一份 ——
# 前端下拉框直接取 DISTINCT action,于是同时出现「CREATE」和「新增」两个选项, # 改一处即可,不会再出现"两边手工同步、改一边漏一边"的漂移。
# 而表格里二者又都显示为「新增」(actionMap 做了映射),用户无法分辨。
# #
# 后果:用户选了看得懂的中文项,只能搜到 3-4 月的历史数据,误以为"没有最近的内容"。 # 历史问题(迁移前):前端 AuditLog.vue 自带一份 fieldMap,后端这里自带一份
# # ACTION_ALIASES,两份副本各自演化。
# 处理:对外只暴露规范值(CREATE/UPDATE/DELETE),筛选时自动展开到全部别名,
# 历史数据无需迁移即可被正确检索。
# ============================================================================= # =============================================================================
ACTION_ALIASES = { from app.utils.audit_labels import ( # noqa: E402
'CREATE': ('CREATE', 'create', 'INSERT', 'insert', '新增', '批量生成'), ACTION_ALIASES,
'UPDATE': ('UPDATE', 'update', '修改', '分配', '归还'), canon_action,
'DELETE': ('DELETE', 'delete', '删除', '批量删除'), expand_modules,
} labels_payload,
module_options,
)
# 反向索引:任意别名 → 规范值 # 列表页「变更摘要」列的截断长度。太长会把表格撑变形且扫读性差 ——
_ALIAS_TO_CANON = { # 完整逐字段对比在详情抽屉里,摘要只负责"一眼看出改了啥"。
alias: canon SUMMARY_LIMIT = 120
for canon, aliases in ACTION_ALIASES.items()
for alias in aliases # 单次导出的记录数上限。
} #
# ★ 这是**熔断**,不是"为了让报表好看"的截断:
# · Excel 单表硬上限 1,048,576 行,长表展开后会成倍放大;
# · 同步请求跑太久会超时,用户只看到"失败",不知道是数据量的问题。
# 实测当前全库共 5.8 万条、全量导出 3.3 秒 / 2MB,正常情况下永远碰不到这个值。
# 触顶时会**显式告知**(写进文件名和汇总工作表),不静默丢弃 ——
# 报表的读法就是"看到的就是全部",偷偷砍掉比报错更危险。
EXPORT_LIMIT = 100000
def canon_action(action): # =============================================================================
"""把任意写法的 action 归一化为规范值;无法识别时原样返回""" # 查询构造 —— /logs 与 /logs/export 的**唯一**入口
return _ALIAS_TO_CANON.get((action or '').strip(), (action or '').strip()) # =============================================================================
def _build_audit_query(args):
"""
按请求参数构造审计日志查询,返回 (query, company_limit)。
★ 列表与导出共用本函数。两处各写一份筛选逻辑,迟早会出现
"页面上看到 300 条、导出来 280 条"这种对不上的情况 ——
而报表对不上,比没有报表更糟(人会照着错的数字做决定)。
这正是 audit_labels.py 开头警告过的"两份手工同步的副本必然漂移"。
★ 可多选的参数用逗号分隔(与仓库既有惯例一致:semi.vue 的 statuses
用 join(',') 传参、inbound/product.py 用 split(',') 接收)。
"""
query = AuditLog.query
# ---------- 操作来源:all(默认) / user(仅真实用户) / system(仅系统) ----------
# 背景:改造前的全局监听器没有请求上下文守卫,系统初始化与后台定时任务
# 产生了大量 username='system' 的日志(历史存量约 1.8 万条),会把列表刷屏。
operator_type = (args.get('operator_type') or 'all').strip().lower()
if operator_type not in ('all', 'user', 'system'):
operator_type = 'all'
if operator_type == 'user':
query = query.filter(AuditLog.username != SYSTEM_USERNAME)
elif operator_type == 'system':
query = query.filter(AuditLog.username == SYSTEM_USERNAME)
# ---------- 操作人(精确匹配)----------
# ★ 由模糊(LIKE %值%)改为精确:前端的操作人已从自由输入改为**下拉选择**,
# 选出来的是完整账号,再模糊匹配就是错的 —— 将来出现 `gaoxue` 与
# `gaoxue2` 两个账号时,选前者会连带把后者的记录一起查出来。
# 部分匹配的需求由下拉的 filterable(在选项里搜)承担,不需要落到 SQL。
username = (args.get('username') or '').strip()
if username:
query = query.filter(AuditLog.username == username)
# ---------- 模块(支持多选 + 聚合别名)----------
# ★ 经 expand_modules 展开:「入库(全部)」会变成它名下的全部成员值。
# 这一步是必需的 —— 审计的 module 在 2026-09-10 换过一次口径
# (旧的「入库管理」→ 新的「库存管理」),不展开就会在换口径那天
# 断掉,用户看到"入库记录突然没了"。
# 详见 audit_labels.MODULE_GROUPS 的说明。
modules = [m for m in (args.get('module') or '').split(',') if m.strip()]
if modules:
query = query.filter(AuditLog.module.in_(expand_modules(modules)))
# ---------- 操作类型(支持多选)----------
actions = [a for a in (args.get('action') or '').split(',') if a.strip()]
if actions:
# ★ 逐个归一化再合并别名集合,不能只处理第一个:
# 历史数据里 CREATE 与「新增」混用,多选时若只对首个值展开别名,
# 其余选项就搜不到早期数据(4 月前的中文 action)。
aliases = set()
for a in actions:
canon = canon_action(a)
aliases.update(ACTION_ALIASES.get(canon, (a,)))
query = query.filter(AuditLog.action.in_(aliases))
target_id = (args.get('target_id') or '').strip()
if target_id:
query = query.filter(AuditLog.target_id == target_id)
# ---------- 操作对象模糊搜索 ----------
# 与 target_id 的分工:target_id 是**精确**匹配(程序化调用、追单条记录用),
# target_keyword 是给人在搜索框里用的模糊匹配,同时命中单号/编码/名称。
#
# ★ 用 ILIKE '%kw%' 走不了 btree 索引,会顺序扫描。当前全库 5.8 万条实测
# 毫秒级,可接受;若将来量级上去了,再加 pg_trgm 的 GIN 索引即可
# (不需要改这里的写法)。
target_keyword = (args.get('target_keyword') or '').strip()
if target_keyword:
query = query.filter(or_(
AuditLog.target_id.ilike(f'%{target_keyword}%'),
AuditLog.target_name.ilike(f'%{target_keyword}%'),
))
# ---------- 日期区间 ----------
# 语义是**闭区间**:end_date 当天 23:59:59 的数据也要包含进来,
# 故上界取次日 00:00 的严格小于。写成 <= end_date 会静默丢掉当天。
start_date = (args.get('start_date') or '').strip()
if start_date:
try:
query = query.filter(
AuditLog.created_at >= datetime.strptime(start_date, '%Y-%m-%d'))
except ValueError:
pass
end_date = (args.get('end_date') or '').strip()
if end_date:
try:
query = query.filter(
AuditLog.created_at < datetime.strptime(end_date, '%Y-%m-%d') + timedelta(days=1))
except ValueError:
pass
# ---------- 【行级数据隔离】----------
# AuditLog 表**没有** company_name 字段,公司信息只能经 SysUser.department 反查。
# 审计日志的 username 存的是账号('gaoxue'),而 sys_user.username 存的是
# '高雪/gaoxue',故取 '/' 之后的账号部分去 join。
#
# ★ 导出接口必须带同样的隔离,否则"导出"就成了绕过数据隔离的后门 ——
# 页面按公司过滤、导出却给全量,这个洞比页面越权更隐蔽。
company_limit = get_current_company_filter()
if company_limit is not None:
query = query.join(
SysUser, func.split_part(SysUser.username, '/', 2) == AuditLog.username
).filter(SysUser.department == company_limit)
return query, company_limit
def _filter_summary_rows(args):
"""
把本次生效的筛选条件写成汇总表的几行。
★ 收到 Excel 的人看不到页面上的筛选框,没有这段说明就无从判断
"这份表是全量还是某个子集"。
"""
rows = []
operator_type = (args.get('operator_type') or '').strip().lower()
if operator_type:
rows.append(('筛选条件', '操作来源',
{'user': '仅真实用户', 'system': '仅系统操作'}.get(operator_type, '全部')))
labels = [
('username', '操作人(模糊匹配)'),
('module', '模块'),
('action', '操作类型'),
('target_id', '目标ID'),
('target_keyword', '操作对象(模糊匹配)'),
]
for key, label in labels:
val = (args.get(key) or '').strip()
if val:
rows.append(('筛选条件', label, val))
start_date = (args.get('start_date') or '').strip()
end_date = (args.get('end_date') or '').strip()
if start_date or end_date:
rows.append(('筛选条件', '日期区间',
f"{start_date or '不限'} ~ {end_date or '不限'}"))
return rows
def _serialize_logs(rows):
"""
审计记录 → 响应 dict(列表与详情共用)。
在 to_dict() 之上补两件事:
★ **action 归一化**。历史数据里 CREATE 与「新增」混用(早期装饰器写中文,
现行监听器写英文)。归一后前端不必再为每种历史写法兜底 —— 否则每个
新消费者都要记得再兜一次,漏一个就静默显示英文/中文原值。
归一用 audit_labels.canon_action(**唯一来源**,覆盖全部历史别名),
不在模型上另建一份映射:那份只认识 3 个中文词,`批量删除`/`分配`/`归还`
这类别名会漏掉。
★ **变更摘要**。这里算而不是做成模型 @property:摘要要把 user_id / base_id
翻成人名 / 物料名,需要查库;模型属性里发查询就是 N+1。
改为整页一次性批量解析(load_ref_maps 按 ref 类型各查一次),
50 条的页面对应 2~3 次查询,与逐行查库是两回事。
"""
if not rows:
return []
ref_maps = load_ref_maps(rows)
# ★ 操作对象补全:库里 target_name 大多只存了 SKU('0000002270')甚至内部
# 标识('stock_buy ID:1667'),业务人员看不懂。补成
# 「SKU - 物料名称 (规格型号)」;解不出物料的行不补,前端回落原始值。
# 见 load_material_context —— 撞号的 target_id 一律放弃,宁可留空不补错。
materials = load_material_context(rows)
# ★ 父子关系记录(BOM)的子件:批量解析,无 N+1。见 load_child_lookup。
children = load_child_lookup(rows)
out = []
for r in rows:
d = r.to_dict()
# ★ 在**接口出口**剥掉凭据类字段。只靠前端隐藏不够 ——
# 原始响应里照样有明文密码,打开 devtools 就读得到。
# 见 audit_export_service.sanitize_details。
d['details'] = sanitize_details(d.get('details'))
d['action'] = canon_action(d.get('action'))
mat = materials.get(r.id)
# 保留原始 target_name(搜索仍按它匹配),展示用 target_display
d['target_display'] = format_target_display(mat)
d['summary'] = changes_summary(
r, ref_maps, limit=SUMMARY_LIMIT, material=mat,
child=children.get(r.id))
out.append(d)
return out
def _operator_options(company_limit):
"""
操作人下拉选项:[{'value': 账号, 'label': '中文名(账号)'}],按记录数降序。
★ 从 **audit_logs** 取而不是从 sys_user:审计里记的是操作发生时的账号,
用户被删/改名后 sys_user 就查不到了,而历史审计仍需要能按他筛选。
实测 32 个操作人里有 1 个在 sys_user 中已不存在。
★ display_name 本来就在审计行上('杜邢宸(duxingchen)'),不必 join。
★ 按记录数降序:最活跃的人排最前,下拉一打开就是常用的那几个。
★ 排除 system:它不是人,且「操作来源」那组单选已经专门管它
(真实用户 / 系统操作 / 全部)。混进来只会让两个控件语义打架。
"""
q = db.session.query(
AuditLog.username,
func.max(AuditLog.display_name),
func.count().label('n'),
).filter(AuditLog.username != SYSTEM_USERNAME)
if company_limit is not None:
q = q.join(SysUser, func.split_part(SysUser.username, '/', 2) == AuditLog.username) \
.filter(SysUser.department == company_limit)
q = q.group_by(AuditLog.username).order_by(func.count().desc())
out = []
for name, display, _n in q.all():
if not name:
continue
dn = (display or '').strip()
# display_name 库里存法不统一('高雪(gaoxue)' / '高闯/gaochuang' / 空),
# 统一成「名(账号)」;拿不到名字就只显示账号。
label = name
for sep in ('(', '/'):
if sep in dn:
dn = dn.split(sep)[0].strip()
if dn and dn != name:
label = f"{dn}({name})"
out.append({'value': name, 'label': label})
return out
def _distinct_with_company(column, company_limit):
"""
取某列的去重值(带公司隔离)。
★ 下拉选项也要隔离:不带的话,A 公司的用户在筛选框里能看到 B 公司
才有的模块名,属于低危但确实存在的信息泄露。
"""
q = db.session.query(column).distinct()
if company_limit is not None:
q = q.join(SysUser, func.split_part(SysUser.username, '/', 2) == AuditLog.username) \
.filter(SysUser.department == company_limit)
return [v[0] for v in q.all() if v[0]]
# =============================================================================
# 接口
# =============================================================================
@audit_bp.route('/logs', methods=['GET']) @audit_bp.route('/logs', methods=['GET'])
@jwt_required() @jwt_required()
@ -49,100 +314,25 @@ def canon_action(action):
def get_audit_logs(): def get_audit_logs():
"""获取审计日志列表(分页)""" """获取审计日志列表(分页)"""
try: try:
# 分页参数
page = request.args.get('page', 1, type=int) page = request.args.get('page', 1, type=int)
page_size = request.args.get('pageSize', 50, type=int) page_size = request.args.get('pageSize', 50, type=int)
# 筛选参数 query, company_limit = _build_audit_query(request.args)
username = request.args.get('username', '').strip()
module = request.args.get('module', '').strip()
action = request.args.get('action', '').strip()
target_id = request.args.get('target_id', '').strip()
start_date = request.args.get('start_date', '').strip()
end_date = request.args.get('end_date', '').strip()
# ★ 操作人类型:all(默认) / user(仅真实用户) / system(仅系统)
#
# 背景:改造前的全局监听器没有请求上下文守卫,系统初始化与后台定时任务
# 产生了大量 username='system' 的日志(历史存量约 1.8 万条),会把列表刷屏。
# 新监听器已加守卫不再产生此类记录,但存量数据仍需要能筛掉。
operator_type = request.args.get('operator_type', 'all').strip().lower()
if operator_type not in ('all', 'user', 'system'):
operator_type = 'all'
# 构建查询
query = AuditLog.query
if operator_type == 'user':
# 真实用户:排除 system 占位账号
query = query.filter(AuditLog.username != 'system')
elif operator_type == 'system':
query = query.filter(AuditLog.username == 'system')
if username:
query = query.filter(AuditLog.username.like(f'%{username}%'))
if module:
query = query.filter(AuditLog.module == module)
if action:
# ★ 兼容历史别名:先把任意写法(中文/小写)归一化为规范值,
# 再展开为该值的全部等价写法一起匹配。
# 否则选「新增」只能搜到早期中文 action 的数据(约 3-4 月),
# 会让用户误以为"没有最近的内容"。
canon = canon_action(action)
if canon in ACTION_ALIASES:
query = query.filter(AuditLog.action.in_(ACTION_ALIASES[canon]))
else:
query = query.filter(AuditLog.action == action)
if target_id:
query = query.filter(AuditLog.target_id == target_id)
if start_date:
try:
start_dt = datetime.strptime(start_date, '%Y-%m-%d')
query = query.filter(AuditLog.created_at >= start_dt)
except ValueError:
pass
if end_date:
try:
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
# 包含当天结束时间
from datetime import timedelta
end_dt = end_dt + timedelta(days=1)
query = query.filter(AuditLog.created_at < end_dt)
except ValueError:
pass
# 【行级数据隔离】通过操作人 username 关联用户表过滤公司
from app.utils.decorators import get_current_company_filter
from app.models.system import SysUser
from sqlalchemy import func
company_limit = get_current_company_filter()
if company_limit is not None:
query = query.join(SysUser, func.split_part(SysUser.username, '/', 2) == AuditLog.username) \
.filter(SysUser.department == company_limit)
# 排序 # 排序
query = query.order_by(AuditLog.created_at.desc()) query = query.order_by(AuditLog.created_at.desc())
# 分页 # 分页
pagination = query.paginate(page=page, per_page=page_size, error_out=False) pagination = query.paginate(page=page, per_page=page_size, error_out=False)
logs = pagination.items data = _serialize_logs(pagination.items)
# 序列化
data = [log.to_dict() for log in logs]
# 获取可用的模块和操作类型(同公司范围内) # 获取可用的模块和操作类型(同公司范围内)
modules_query = db.session.query(AuditLog.module).distinct() raw_modules = _distinct_with_company(AuditLog.module, company_limit)
actions_query = db.session.query(AuditLog.action).distinct() actions_raw = _distinct_with_company(AuditLog.action, company_limit)
if company_limit is not None:
modules_query = modules_query.join(SysUser, func.split_part(SysUser.username, '/', 2) == AuditLog.username) \
.filter(SysUser.department == company_limit)
actions_query = actions_query.join(SysUser, func.split_part(SysUser.username, '/', 2) == AuditLog.username) \
.filter(SysUser.department == company_limit)
modules = [m[0] for m in modules_query.all() if m[0]]
# ★ action 下拉项归一化:把 CREATE/create/新增 等别名合并为一个规范值, # ★ action 下拉项归一化:把 CREATE/create/新增 等别名合并为一个规范值,
# 避免下拉框出现「CREATE」与「新增」两个语义重复的选项。 # 避免下拉框出现「CREATE」与「新增」两个语义重复的选项。
actions = sorted({canon_action(a[0]) for a in actions_query.all() if a[0]}) actions = sorted({canon_action(a) for a in actions_raw})
return jsonify({ return jsonify({
'code': 200, 'code': 200,
@ -152,8 +342,12 @@ def get_audit_logs():
'total': pagination.total, 'total': pagination.total,
'page': page, 'page': page,
'pageSize': page_size, 'pageSize': page_size,
'modules': modules, # ★ [{value,label}],历史命名已折叠进聚合项且不再单独列出 ——
'actions': actions # 规则由后端 module_options() 统一决定,前端零硬编。
'modules': module_options(raw_modules),
'actions': actions,
# 操作人下拉选项(与 /operators 同源,顺带回一份省一次请求)
'operators': _operator_options(company_limit),
} }
}), 200 }), 200
@ -162,19 +356,94 @@ def get_audit_logs():
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500 return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
@audit_bp.route('/logs/export', methods=['GET'])
@jwt_required()
@permission_required('system_audit')
def export_audit_logs():
"""
按筛选条件导出审计日志为 Excel(同步返回文件流)。
★ 筛选条件与 GET /logs **完全一致**(共用 _build_audit_query),
且**忽略分页参数** —— 导的是符合条件的全量,不是当前页。
「只导当前页 50 条」是导出功能最常见的误解,故这里明确不支持。
★ 为什么同步返回而不是走 export_service 那套异步任务:
实测全库 5.8 万条导出耗时 3.3 秒、2MB,同步完全够用;
而异步那套要落盘(uploads/exports/)且**没有清理机制**,
日积月累会把磁盘吃掉 —— 见 export_service/excel_task.py。
"""
try:
query, _ = _build_audit_query(request.args)
# 台账按时间倒序:导出的人多半想看"最近的"(与列表页一致)
query = query.order_by(AuditLog.created_at.desc())
total = query.count()
truncated = total > EXPORT_LIMIT
rows = query.limit(EXPORT_LIMIT).all() if truncated else query.all()
note = None
if truncated:
note = (f"匹配记录共 {total} 条,超过单次导出上限 {EXPORT_LIMIT} 条,"
f"本次仅导出前 {EXPORT_LIMIT} 条。请缩小日期范围后分批导出。")
data = build_audit_workbook(
rows,
summary_rows=_filter_summary_rows(request.args),
note=note,
)
stamp = datetime.now().strftime('%Y%m%d_%H%M%S')
# 文件名里带上「部分」—— 用户不解压也能看出这不是全量
name = (f"审计日志_部分{EXPORT_LIMIT}条_{stamp}.xlsx" if truncated
else f"审计日志_{stamp}.xlsx")
return send_file(
io.BytesIO(data),
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=name,
)
except Exception as e:
current_app.logger.error(f"导出审计日志失败: {str(e)}")
return jsonify({'code': 500, 'msg': f'导出失败: {str(e)}'}), 500
@audit_bp.route('/logs/<int:log_id>', methods=['GET']) @audit_bp.route('/logs/<int:log_id>', methods=['GET'])
@jwt_required() @jwt_required()
@permission_required('system_audit')
def get_audit_log_detail(log_id): def get_audit_log_detail(log_id):
"""获取单条审计日志详情""" """
获取单条审计日志详情。
★ 补权限码与公司隔离。此前这里**只有 @jwt_required()**,任何登录用户
改一下 URL 里的 id 就能读到全部审计明细(含 details 里的完整快照,
那是被操作记录的原始字段值)。列表接口有 system_audit 把关,
详情接口提供的信息是列表的超集,没道理比列表更宽松。
★ 越权与不存在**统一返回 404**,不区分二者 —— 区分开来就等于告诉
探测者"这个 id 是存在的,只是你没权限",反而泄露了数据规模。
"""
try: try:
log = AuditLog.query.get(log_id) query = AuditLog.query.filter(AuditLog.id == log_id)
# 与列表用同一个公司判据(SysUser.department 反查),保证两处一致:
# 列表里看不到的记录,详情也不该看得到。
company_limit = get_current_company_filter()
if company_limit is not None:
query = query.join(
SysUser, func.split_part(SysUser.username, '/', 2) == AuditLog.username
).filter(SysUser.department == company_limit)
log = query.first()
if not log: if not log:
return jsonify({'code': 404, 'msg': '日志不存在'}), 404 return jsonify({'code': 404, 'msg': '日志不存在'}), 404
return jsonify({ return jsonify({
'code': 200, 'code': 200,
'msg': '获取成功', 'msg': '获取成功',
'data': log.to_dict() # 与列表同一套序列化:action 归一 + 摘要(详情页也用得上同一条摘要)
'data': _serialize_logs([log])[0]
}), 200 }), 200
except Exception as e: except Exception as e:
@ -185,11 +454,57 @@ def get_audit_log_detail(log_id):
@audit_bp.route('/modules', methods=['GET']) @audit_bp.route('/modules', methods=['GET'])
@jwt_required() @jwt_required()
def get_modules(): def get_modules():
"""获取所有模块列表(用于筛选)""" """
获取模块下拉选项(用于筛选)。
返回 [{value, label}] 而不是裸的 module 字符串 —— 历史命名已被折叠进
「入库」聚合项,规则由 audit_labels.module_options() 统一决定。
★ 只挂 @jwt_required()、不加权限码:这里返回的是模块**名称**(不含任何
业务记录),且已做公司隔离;审计页本身由 /logs 的权限码把关,
与 /labels 的处理一致。
"""
try: try:
modules = db.session.query(AuditLog.module).distinct().all() company_limit = get_current_company_filter()
modules = [m[0] for m in modules if m[0]] raw_modules = _distinct_with_company(AuditLog.module, company_limit)
return jsonify({'code': 200, 'data': modules}), 200 return jsonify({'code': 200, 'data': module_options(raw_modules)}), 200
except Exception as e: except Exception as e:
current_app.logger.error(f"获取模块列表失败: {str(e)}") current_app.logger.error(f"获取模块列表失败: {str(e)}")
return jsonify({'code': 500, 'msg': str(e)}), 500 return jsonify({'code': 500, 'msg': str(e)}), 500
@audit_bp.route('/operators', methods=['GET'])
@jwt_required()
@permission_required('system_audit')
def get_operators():
"""
获取操作人下拉选项(用于筛选)。
返回 [{value: 账号, label: '名(账号)'}],按记录数降序,已排除 system。
★ 加权限码,与 /modules(仅 JWT)**故意不同**:/modules 给的是模块名,
这个给的是**人员账号清单**。它的唯一消费者就是审计页,而审计页本身
要 system_audit —— 没道理让人绕开页面直接拉全员名单。
"""
try:
company_limit = get_current_company_filter()
return jsonify({'code': 200, 'data': _operator_options(company_limit)}), 200
except Exception as e:
current_app.logger.error(f"获取操作人列表失败: {str(e)}")
return jsonify({'code': 500, 'msg': str(e)}), 500
@audit_bp.route('/labels', methods=['GET'])
@jwt_required()
def get_labels():
"""
下发操作类型与字段名的中文映射。
★ 后端是**唯一来源**,前端不再自带副本 —— 否则两份手工同步的映射必然漂移。
前端拉到之前(或拉取失败时)对未命中字段原样显示字段名,
是可控的降级,不会让页面崩掉。
免权限码:纯静态标签,不含任何业务数据;审计页本身已由
@permission_required('system_audit') 把关(见 /logs)。
"""
return jsonify({'code': 200, 'data': labels_payload()}), 200

View File

@ -7,7 +7,7 @@ from . import common_bp
def active_user_options(): def active_user_options():
""" """
在职人员名单(id + 姓名)——「选择某人」类下拉框的**共用实现**。 在职人员名单(id + 姓名 + 账号)——「选择某人」类下拉框的**共用实现**。
★ 为什么单独开一条中性路径,而不是复用 /transactions/borrow/users: ★ 为什么单独开一条中性路径,而不是复用 /transactions/borrow/users:
那条路径在语义上属于借库模块,出库补发、报废执行等处若直接复用, 那条路径在语义上属于借库模块,出库补发、报废执行等处若直接复用,
@ -17,7 +17,13 @@ def active_user_options():
★ 公司隔离与业务台账同口径(get_current_company_filter): ★ 公司隔离与业务台账同口径(get_current_company_filter):
否则 A 公司的人能在选择器里看到 B 公司人员。 否则 A 公司的人能在选择器里看到 B 公司人员。
★ 只返回 id 与姓名:不含邮箱 / 角色 / 部门,最小披露。 ★ 只返回 id / 姓名 / 账号:不含邮箱、角色、部门 —— 仍是最小披露。
账号(username 里 '/' 之后那一段,如 'gaoxue')是**拼音**,
选择器靠它支持「输拼音找人」(输 gaoxue 匹配到高雪)——
这是加它的唯一原因,不是要把登录名铺开。
★ 姓名口径用 user_display_name('高雪/gaoxue' → '高雪'),
与出库/借库台账里存姓名的写法一致;否则同一人在台账里会出现两种写法。
""" """
from app.utils.decorators import get_current_company_filter from app.utils.decorators import get_current_company_filter
from app.models.system import SysUser from app.models.system import SysUser
@ -29,8 +35,15 @@ def active_user_options():
# 与 borrow_service.get_request_list 一致:SysUser.department 即公司维度 # 与 borrow_service.get_request_list 一致:SysUser.department 即公司维度
query = query.filter(SysUser.department == company_limit) query = query.filter(SysUser.department == company_limit)
return [{'id': u.id, 'name': user_display_name(u)} out = []
for u in query.order_by(SysUser.username).all()] for u in query.order_by(SysUser.username).all():
raw = str(u.username or '')
out.append({
'id': u.id,
'name': user_display_name(u),
'account': raw.split('/')[-1] if '/' in raw else '',
})
return out
@common_bp.route('/active-users', methods=['GET']) @common_bp.route('/active-users', methods=['GET'])

View File

@ -24,11 +24,9 @@ from app.models.inbound.stocktake import (
) )
from app.models.transaction import ( from app.models.transaction import (
TransBorrow, TransBorrow,
TransReturn, # 注:TransReturn / RETURN_TYPE_* / DEFECTIVE_STATUS_PENDING 曾在此使用,
# 退回逻辑抽到 return_service 后本模块不再直接碰它们,故已移出导入。
TransDefectiveGoods, TransDefectiveGoods,
RETURN_TYPE_GOOD,
RETURN_TYPE_DEFECTIVE,
DEFECTIVE_STATUS_PENDING,
DEFECTIVE_STATUS_IN_PROGRESS, DEFECTIVE_STATUS_IN_PROGRESS,
RESTOCKABLE_DEFECTIVE_STATUSES, RESTOCKABLE_DEFECTIVE_STATUSES,
SCRAPPABLE_DEFECTIVE_STATUSES, SCRAPPABLE_DEFECTIVE_STATUSES,
@ -37,6 +35,13 @@ from app.models.transaction import (
) )
from app.models.outbound import TransOutbound from app.models.outbound import TransOutbound
from app.models.base import MaterialBase from app.models.base import MaterialBase
# 退回业务逻辑已抽到服务层(内部接口 Track → MOM 要复用同一段)。
# 别名带 _service 后缀,避免与本模块的视图函数 return_from_outbound 撞名。
from app.services.return_service import (
lock_source_stock_row,
assert_company_owns,
return_from_outbound as return_from_outbound_service,
)
# 库存状态语义的单一事实来源(与分配器共用同一套常量,避免两处定义漂移) # 库存状态语义的单一事实来源(与分配器共用同一套常量,避免两处定义漂移)
from app.services.inventory_reservation import ( from app.services.inventory_reservation import (
@ -2606,46 +2611,21 @@ def _lock_source_stock_row(source_table, stock_id):
""" """
解析并锁定退回目标的**原库存行**。业务不满足即抛 ValueError。 解析并锁定退回目标的**原库存行**。业务不满足即抛 ValueError。
三条 Fail-Closed 规则: ★ 实现已搬到 `app/services/return_service.py`(内部接口要复用,而服务层
1. source_table 必须是三张库存表之一 —— 维修单等非库存来源没有可退回的行; 不得反向 import API 层)。这里保留薄包装:调用方(restock 等)一行不用改。
2. 库存行必须仍然存在 —— 入库模块会物理删除库存行(见
buy/semi/product_service 的 db.session.delete(stock)),实测 1077 条
出库记录中已有 7 条指向不存在的行;
3. 调用方拿到行后还需自行做公司隔离与状态校验(见 _assert_company_owns)。
★ 为什么必须加锁:本行随后会被加减数量,且与出库/报废/状态变更并发。
不加锁会出现「读-改-写」丢失更新(lost update)。
""" """
model = get_stock_model(source_table) return lock_source_stock_row(source_table, stock_id)
if model is None:
raise ValueError(
f'来源「{source_table or "(空)"}」不支持退回,'
f'仅支持 stock_buy / stock_semi / stock_product'
)
row = model.query.with_for_update().get(stock_id) if stock_id else None
if not row:
raise ValueError(
f'原库存行已不存在({source_table}#{stock_id}),无法自动退回,'
f'请改走入库流程手工登记这批实物'
)
return row
def _assert_company_owns(row): def _assert_company_owns(row):
""" """
行级多租户隔离:非跨域用户只能操作本公司库存。不满足即抛 PermissionError。 行级多租户隔离:非跨域用户只能操作本公司库存。不满足即抛 PermissionError。
口径与扫码出库(OutboundService.get_stock_by_barcode)、状态变更接口完全一致 ★ 实现已搬到 `app/services/return_service.py`,那里收显式 company_limit
—— 都走 MaterialBase.company_name,避免三处隔离逻辑分叉。 (内部接口没有 JWT,不能在里面读 get_current_company_filter)。
这里保留薄包装,把 JWT 依赖收在 API 层。
""" """
company_limit = get_current_company_filter() return assert_company_owns(row, get_current_company_filter())
if company_limit is None:
return
base = getattr(row, 'base', None)
if (company_limit == '__NO_COMPANY__' or base is None
or (base.company_name or '') != company_limit):
raise PermissionError('无权操作其他公司的库存')
@bp.route('/defective', methods=['GET']) @bp.route('/defective', methods=['GET'])
@ -2786,226 +2766,42 @@ def return_from_outbound():
operator_name = _normalize_user_id() operator_name = _normalize_user_id()
outbound_id = data.get('outbound_id') outbound_id = data.get('outbound_id')
is_defective = data.get('is_defective') # ---- 入参校验(脏值一律挡在入口)----
reason = (data.get('reason') or '').strip() or None # 只留这一条在视图层:其余校验的文案由 service 原样抛出,见其注释
# 补发(可选):退回后申请人往往仍需这件东西。勾选则自动生成一张免审批出库单。
need_reissue = bool(data.get('need_reissue'))
reissue_qty = data.get('reissue_qty')
# 补发给谁:不传则回退为当前操作人(见下方补发块)
reissue_applicant_id = data.get('reissue_applicant_id')
# ---- 1. 入参校验(脏值一律挡在入口)----
if not outbound_id: if not outbound_id:
return jsonify({'code': 400, 'msg': 'outbound_id 为必填'}), 400 return jsonify({'code': 400, 'msg': 'outbound_id 为必填'}), 400
if is_defective is None:
return jsonify({
'code': 400,
'msg': 'is_defective 为必填(true=不良品退回,false=良品退回)',
}), 400
is_defective = bool(is_defective)
try: try:
return_qty = float(data.get('return_qty') or 0) # ★ 业务逻辑全部在 app/services/return_service.py —— 内部接口
except (TypeError, ValueError): # (Track → MOM 生产报废)要复用同一段逻辑,而视图里夹着 JWT 依赖,
return jsonify({'code': 400, 'msg': 'return_qty 无效'}), 400 # 服务层不能反向依赖它。本视图只负责取请求、转响应。
if return_qty <= 0: result = return_from_outbound_service(
return jsonify({'code': 400, 'msg': '退回数量必须大于 0'}), 400 outbound_id=outbound_id,
return_qty=data.get('return_qty'),
try: is_defective=data.get('is_defective'),
# ---- 2. 锁定原出库明细并校验退回额度 ---- reason=(data.get('reason') or '').strip() or None,
# ★ 行锁不可省:并发两笔退回若各自读到相同的 returned_quantity,会双双 need_reissue=bool(data.get('need_reissue')),
# 通过额度校验,合计退回量超过出库量 —— 凭空多出库存。 reissue_qty=data.get('reissue_qty'),
outbound = TransOutbound.query.with_for_update().get(outbound_id) reissue_applicant_id=data.get('reissue_applicant_id'),
if not outbound: operator_name=operator_name,
raise ValueError(f'出库记录不存在(ID: {outbound_id})')
shipped = float(outbound.quantity or 0)
returned = float(outbound.returned_quantity or 0)
returnable = shipped - returned
if return_qty > returnable:
raise ValueError(
f'退回数量({return_qty})超出可退额度({returnable}):'
f'原出库 {shipped},已退回 {returned}'
)
# ---- 3. 锁定原库存行 + 多租户隔离 ----
stock_row = _lock_source_stock_row(outbound.source_table, outbound.stock_id)
_assert_company_owns(stock_row)
# 公司快照:退回看板的隔离判定不能依赖 join 链 —— 源库存行会被入库模块
# 物理删除,届时链路断裂会让记录对普通用户静默消失。见 TransReturn 注释。
_base = getattr(stock_row, 'base', None)
snapshot_company = ((_base.company_name if _base else '') or '').strip() or None
goods = None
if is_defective:
# ================= 不良品分支 =================
# ★ 原库存表**分毫不动**:坏件全程存放于独立在管台账,既不占用库存
# 数量、也不改库存行 status,从根上杜绝「坏件混进可分配池」。
base = getattr(stock_row, 'base', None)
goods = TransDefectiveGoods(
outbound_id=outbound.id,
source_table=outbound.source_table,
stock_id=outbound.stock_id,
base_id=getattr(stock_row, 'base_id', None),
sku=getattr(stock_row, 'sku', '') or '',
material_name=(base.name if base else '') or '',
spec_model=(base.spec_model if base else '') or '',
quantity=return_qty,
remaining_qty=return_qty,
status=DEFECTIVE_STATUS_PENDING,
company_name=(base.company_name if base else '') or '',
reason=reason,
operator=operator_name,
)
db.session.add(goods)
outcome = '不良品已转入在管台账'
else:
# ================= 良品分支 =================
# ★ 状态防呆:把良品加回一个已冻结/不良品的行,会让良品被该行的状态
# 连带隔离(status 是行级属性)—— 静默造成良品不可用。宁可报错让
# 人先决定该行的归属。
current = (stock_row.status or '').strip()
if current != STOCK_STATUS_IN_STOCK:
raise ValueError(
f'原库存行当前状态为「{current or "未设置"}」,'
f'良品退回要求该行处于「{STOCK_STATUS_IN_STOCK}」状态'
)
stock_row.stock_quantity = float(stock_row.stock_quantity or 0) + return_qty
stock_row.available_quantity = float(stock_row.available_quantity or 0) + return_qty
outcome = '良品已加回原库存'
# ---- 4. 累加退回额度 + 写退回流水 ----
outbound.returned_quantity = returned + return_qty
ledger = TransReturn(
outbound_id=outbound.id,
stock_id=outbound.stock_id,
source_table=outbound.source_table,
sku=outbound.sku,
return_qty=return_qty,
return_type=RETURN_TYPE_DEFECTIVE if is_defective else RETURN_TYPE_GOOD,
reason=reason,
operator=operator_name,
company_name=snapshot_company,
)
db.session.add(ledger)
db.session.flush() # 先拿到 ledger.id,供在管台账回填
# 在管台账回填来源流水 id,形成「出库 → 退回流水 → 在管台账」的追溯闭环
if goods is not None:
goods.return_id = ledger.id
# ==================================================================
# ---- 5. 补发(可选)----
# 退回后申请人往往**仍然需要这件东西**(尤其是坏件 —— 原需求并未
# 被满足)。勾选即自动生成一张**免审批**的出库单并关联回本笔退回,
# 使「退回 → 补发」形成闭环;否则现场只能靠人记住再手建一张单,
# 而那张单与原单看不出任何关系。
#
# ★ 库存不足时**整笔回滚**(下面的 reserve_for_items 会抛错)。
# 若只让补发静默失败,「需要补发」的意图就丢了 —— 那正是本功能
# 要解决的问题。回滚后库管会看到明确提示,可取消勾选重试。
# ==================================================================
reissue = None
if need_reissue:
# 单号生成器在 OutboundApprovalService 上(不在 OutboundService)
from app.services.outbound_service import OutboundApprovalService
from app.services.inventory_reservation import reserve_for_items
from app.models.outbound import OutboundApproval
if reissue_qty is None:
reissue_qty = return_qty # 默认与本次退回量一致
try:
reissue_qty = float(reissue_qty)
except (TypeError, ValueError):
raise ValueError('补发数量格式无效')
if reissue_qty <= 0:
raise ValueError('补发数量必须大于 0')
if reissue_qty > return_qty:
raise ValueError(
f'补发数量({reissue_qty})不能大于本次退回数量({return_qty})'
)
base = getattr(stock_row, 'base', None)
if base is None:
raise ValueError('原库存行的物料主数据已不存在,无法生成补发单')
# 提交即预占,strict=True —— 与出库申请同一口径,不足即整单失败
reserved_items, _shortages = reserve_for_items(
[{
'base_id': base.id,
'name': base.name or '',
'spec_model': base.spec_model or '',
'quantity': reissue_qty,
}],
company_limit=get_current_company_filter(), company_limit=get_current_company_filter(),
strict=True,
) )
# ★ 申请人(补发给谁)优先级: reissue = result['reissue']
# ① 前端显式指定 reissue_applicant_id —— 现场最清楚该给谁;
# ② 回退到原出库明细记录的 applicant_id(创建出库时从审批单带出的
# 真实原申请人);
# ③ 两者都没有 → **报错要求指定**。
#
# ★ 绝不回退为「当前操作人」:补发是**原申请人的需求**,挂到办理
# 退回的库管名下逻辑不通 —— 那张单会出现在库管的「我的申请」里,
# 而真正该拿东西的人什么也看不到。
# ⚠ 存量出库明细的 applicant_id 为 NULL(历史无从回填),此时必须由
# 库管在选择器里明确指定 —— 宁可多一步,也不猜错人。
if reissue_applicant_id:
try:
_applicant = int(reissue_applicant_id)
except (TypeError, ValueError):
raise ValueError('补发申请人ID格式无效')
from app.models.system import SysUser
if not SysUser.query.get(_applicant):
raise ValueError(f'补发申请人不存在(ID:{reissue_applicant_id})')
elif outbound.applicant_id:
_applicant = int(outbound.applicant_id)
else:
raise ValueError(
'无法确定补发单申请人:这张出库单产生于「申请人」字段上线之前,'
'请在上方选择「补发给谁」'
)
reissue = OutboundApproval(
request_no=OutboundApprovalService.generate_request_no(),
applicant_id=_applicant,
outbound_type=outbound.outbound_type,
# 免审批:原需求已经批过一次,补发只是兑现它,重复审批是负担
status=1,
approved_at=beijing_time(),
source_return_id=ledger.id,
remark=(f'原单退回补发(原出库单 {outbound.outbound_no or outbound.id}'
f',原领用人 {outbound.consumer_name or "未知"})'),
)
reissue.set_items(reserved_items)
reissue.allowed_approvers = '[]'
db.session.add(reissue)
db.session.flush()
db.session.commit()
return jsonify({ return jsonify({
'code': 200, 'code': 200,
'msg': f'退回成功,{outcome}' 'msg': f"退回成功,{result['outcome']}"
+ (f';已生成补发单 {reissue.request_no}' if reissue else ''), + (f";已生成补发单 {reissue['request_no']}" if reissue else ''),
'data': { 'data': {
'outbound_id': outbound.id, 'outbound_id': result['outbound_id'],
'return_id': ledger.id, 'return_id': result['return_id'],
'return_type': ledger.return_type, 'return_type': result['return_type'],
'return_qty': return_qty, 'return_qty': result['return_qty'],
'returned_quantity': float(outbound.returned_quantity), 'returned_quantity': result['returned_quantity'],
'returnable_quantity': shipped - float(outbound.returned_quantity), 'returnable_quantity': result['returnable_quantity'],
'defective_goods_id': goods.id if goods is not None else None, 'defective_goods_id': result['defective_goods_id'],
# 补发单(未勾选时为 null) # 补发单(未勾选时为 null)
'reissue': ({ 'reissue': reissue,
'id': reissue.id,
'request_no': reissue.request_no,
'quantity': reissue_qty,
} if reissue else None),
}, },
}), 200 }), 200

View File

@ -0,0 +1,360 @@
"""对内接口(Track → MOM)— 共享密钥鉴权,不走 JWT
═══════════════════════════════════════════════════════════════════════════
为什么需要这个模块
═══════════════════════════════════════════════════════════════════════════
生产领用的料已经出库到产线,之后在生产中报废 —— 要把它提交进 MOM,复用 MOM
现有的报废流程(申请 → 审批 → 执行),并标记「生产导致」。
但料已出库,那条库存行的可用量在出库时就扣掉了,走不了标准库存行报废。
MOM 自己的答案是逆向物流的「从出库单退回(不良品)」:坏件转进
`trans_defective_goods` 在管台账,原库存表分毫不动。所以本接口内部做两件事:
① 退回(is_defective=true)→ 在管不良品
② 对这笔在管量提交报废申请(待审批,角色级审批人)
两件事在**同一个事务**里,要么都成要么都不成 —— 见 return_service 的编排函数。
═══════════════════════════════════════════════════════════════════════════
鉴权
═══════════════════════════════════════════════════════════════════════════
MOM 此前**没有任何在用的非 JWT 接口**(ai_proxy 那个 DIFY_INTERNAL_SECRET 是
从未 register_blueprint 的死代码,且密钥硬编码 —— 反面教材,不要照抄)。
本模块新建共享密钥校验:
· 密钥走 config.MOM_INTERNAL_API_KEY(环境变量),**不硬编码**;
· 请求头 `X-API-Key`,与 Track 侧接收端同一字段名(track_query_service 注释里
明确要求「鉴权字段名必须为 X-API-Key,与 Track 接收端严格对齐」);
· 未配置密钥 → 503(Fail-Closed),不是放行;
· 用 hmac.compare_digest 做常量时间比较,避免时序侧信道。
═══════════════════════════════════════════════════════════════════════════
刻意收紧的地方(实现时不要「顺手放开」)
═══════════════════════════════════════════════════════════════════════════
· `is_defective` **恒为 True**,请求体不暴露该字段 —— 良品分支会往库存行加数量,
一个泄漏的密钥就能凭空造库存。内部接口只开放不良品分支,这是爆炸半径控制。
· `need_reissue` **恒为 False**,请求体不暴露 —— 补发会生成出库单,
与「报废」无关,不该由外部系统触发。
· `track_ref` 必填 —— prevent_double_submit 依赖 Redis,而 compose 里没有 redis
服务(redis_client 恒为 None),该装饰器**全程 fail-open**。唯一索引是唯一的
并发防线,没有稳定的外部单据号就无从判重。
· 只注册 `/api/v1/internal` 一条路径,不做 legacy 双注册。
"""
import hmac
import logging
import traceback
from functools import wraps
from flask import Blueprint, current_app, jsonify, request
from sqlalchemy.exc import IntegrityError
from app.extensions import db
from app.models.system import SysUser
from app.models.transaction import TransReturn
from app.services import return_service
logger = logging.getLogger(__name__)
internal_bp = Blueprint('internal', __name__)
# =============================================================================
# 鉴权
# =============================================================================
def require_internal_key(fn):
"""共享密钥校验。Fail-Closed:未配置密钥 → 503,不是放行。"""
@wraps(fn)
def decorator(*args, **kwargs):
configured = (current_app.config.get('MOM_INTERNAL_API_KEY') or '').strip()
if not configured:
logger.warning('[Internal] 请求被拒:服务端未配置 MOM_INTERNAL_API_KEY')
return jsonify({
'code': 503,
'msg': '内部接口未启用:服务端未配置访问密钥',
}), 503
provided = (request.headers.get('X-API-Key') or '').strip()
if not provided:
return jsonify({'code': 401, 'msg': '缺少 X-API-Key 请求头'}), 401
# ★ 必须先 encode:compare_digest 传 str 且含非 ASCII 会抛 TypeError
if not hmac.compare_digest(provided.encode('utf-8'), configured.encode('utf-8')):
logger.warning('[Internal] 请求被拒:X-API-Key 不匹配')
return jsonify({'code': 403, 'msg': 'X-API-Key 无效'}), 403
return fn(*args, **kwargs)
return decorator
# =============================================================================
# 入参解析
# =============================================================================
def _parse_bool(raw, field):
"""严格布尔解析:只认真正的 bool。缺省(None)报错,字符串一律报错。
不做 'true'/'1'/'yes' 之类的宽松转换 —— 该字段决定「要不要提交报废申请」,
解析歧义会导致静默的半截行为(只退回、不提申请)。
"""
if isinstance(raw, bool):
return raw
if raw is None:
raise ValueError(
f'{field} 为必填(true=退回并提交报废申请;false=仅登记为在管不良品)'
)
raise ValueError(f'{field} 必须为布尔值 true / false')
def _parse_int(raw, field, required=True, max_len=None):
if raw is None or (isinstance(raw, str) and not raw.strip()):
if required:
raise ValueError(f'{field} 为必填')
return None
try:
value = int(raw)
except (TypeError, ValueError):
raise ValueError(f'{field} 无效')
if value <= 0:
raise ValueError(f'{field} 必须为正整数')
return value
def _parse_text(raw, field, max_len, required=True):
text = str(raw or '').strip()
if not text:
if required:
raise ValueError(f'{field} 为必填({max_len} 字符以内)')
return None
if len(text) > max_len:
raise ValueError(f'{field} 超长(最多 {max_len} 字符)')
return text
def _resolve_applicant(data):
"""解析申请人 → (applicant_id, company_name)。
★ 为什么**必须**由调用方提供,不能从出库行推导:
trans_outbound.applicant_id 大部分为 NULL(实测 1364 行中 1127 行为 NULL,
PRODUCTION 类型 457/656)—— 该列是后来才加的,存量历史无从回填。
回退到「原出库申请人」在八成场景下会失败。
优先 applicant_id;其次 applicant_account(sys_user.username 里 '/' 后的账号)。
"""
applicant_id = data.get('applicant_id')
account = (data.get('applicant_account') or '').strip()
if applicant_id is not None and str(applicant_id).strip() != '':
try:
applicant_id = int(applicant_id)
except (TypeError, ValueError):
raise ValueError('applicant_id 无效')
user = SysUser.query.get(applicant_id)
if not user:
raise ValueError(f'申请人不存在(ID: {applicant_id})')
return user.id, (user.department or '').strip() or None
if not account:
raise ValueError('applicant_id 与 applicant_account 至少提供一个')
# username 约定为「真实姓名/登录账号」,账号是 '/' 之后那段
users = SysUser.query.filter(SysUser.username.like(f'%/{account}')).all()
if not users:
raise ValueError(f'申请人账号不存在:{account}')
if len(users) > 1:
# Fail-Closed:同名账号跨公司时不能猜
raise ValueError(f'申请人账号 {account} 在多公司重复,请改用 applicant_id 指定')
return users[0].id, (users[0].department or '').strip() or None
# =============================================================================
# 生产报废受理
# =============================================================================
@internal_bp.route('/production-scrap', methods=['POST'])
@require_internal_key
def create_production_scrap():
"""受理生产报废:退回(不良品) + 提交报废申请(待审批)。
Body(JSON):
{
"company_name": "IRIS", # 必填,调用方所属公司/实例
"outbound_id": 1364, # 必填,trans_outbound.id(出库明细行)
"return_qty": 2, # 必填,>0
"submit_scrap": true, # 必填,false=仅登记为在管不良品
"track_ref": "WO-2026-001234", # 必填,Track 侧唯一单据号(幂等锚点)
"scrap_qty": 2, # 可选,默认 = return_qty
"reason_category": "PRODUCTION", # 可选,默认 PRODUCTION
"reason": "生产装配时压坏", # 可选
"applicant_id": 8, # 与 applicant_account 至少一个
"applicant_account": "zhangsan01", # 同上
"operator": "Track系统" # 可选,写入台账的操作人名
}
"""
data = request.get_json(silent=True) or {}
try:
company_name = _parse_text(data.get('company_name'), 'company_name', 255)
outbound_id = _parse_int(data.get('outbound_id'), 'outbound_id')
track_ref = _parse_text(data.get('track_ref'), 'track_ref', 100)
submit_scrap = _parse_bool(data.get('submit_scrap'), 'submit_scrap')
reason = _parse_text(data.get('reason'), 'reason', 500, required=False)
operator_name = _parse_text(data.get('operator'), 'operator', 100,
required=False) or 'Track系统'
reason_category = _parse_text(data.get('reason_category'), 'reason_category',
50, required=False) or 'PRODUCTION'
# 幂等锚点带公司前缀:IRIS 与 LICA 各自独立跑一套 Track,工单号可能重号,
# 裸用 track_ref 做唯一索引会让两家互相挡住对方的首次受理。
source_ref = f'{company_name}:{track_ref}'
if not submit_scrap and data.get('scrap_qty') not in (None, ''):
raise ValueError('submit_scrap=false 时不应传 scrap_qty(本次不生成报废申请)')
# ---- 入参数量 ----
try:
return_qty = float(data.get('return_qty') or 0)
except (TypeError, ValueError):
raise ValueError('return_qty 无效')
if return_qty <= 0:
raise ValueError('return_qty 为必填且必须大于 0')
scrap_qty = None
if submit_scrap:
raw_qty = data.get('scrap_qty')
if raw_qty is None or raw_qty == '':
scrap_qty = return_qty
else:
try:
scrap_qty = float(raw_qty)
except (TypeError, ValueError):
raise ValueError('scrap_qty 无效')
if scrap_qty <= 0:
raise ValueError('scrap_qty 必须大于 0')
if scrap_qty > return_qty:
raise ValueError(
f'scrap_qty({scrap_qty})不能大于本次退回数量({return_qty})'
)
applicant_id, applicant_company = _resolve_applicant(data)
# 申请人公司必须与调用方声明的公司一致 —— 否则可以借别人公司的身份提交
if applicant_company and applicant_company != company_name:
raise ValueError(
f'申请人不属于 {company_name}(实际 {applicant_company}),禁止跨公司提交'
)
# ---- 幂等:先按 source_ref 查重 ----
existing = TransReturn.query.filter_by(source_ref=source_ref).first()
if existing is not None:
return _duplicate_response(existing, data, company_name, track_ref,
return_qty, source_ref, submit_scrap)
try:
ret, scrap = return_service.return_and_submit_production_scrap(
outbound_id=outbound_id,
return_qty=return_qty,
applicant_id=applicant_id,
operator_name=operator_name,
# 公司隔离:内部接口没有 JWT,显式传公司限制。
# 但业务上要允许「本实例处理本公司物料」,故直接用 company_name。
company_limit=company_name,
reason=reason,
reason_category=reason_category,
source_ref=source_ref,
submit_scrap=submit_scrap,
scrap_qty=scrap_qty,
)
except IntegrityError:
# 并发穿透了上面的预检 —— 唯一索引兜底
db.session.rollback()
existing = TransReturn.query.filter_by(source_ref=source_ref).first()
if existing is not None:
return _duplicate_response(existing, data, company_name, track_ref,
return_qty, source_ref, submit_scrap)
raise
# ⚠️ 跨公司校验**不在这里做**:company_limit=company_name 已经传进
# return_service,由 assert_company_owns 在**写之前**拦下
# (文案:「无权操作其他公司的库存」)。放到这里就成了「先提交再检查」——
# 事务已经 commit,rollback 撤不回来,等于没检查。
return jsonify({
'code': 200,
'msg': ('生产报废已受理:已退回登记为在管不良品,并提交报废申请(待审批)'
if submit_scrap else
'已受理:料已退回登记为在管不良品(本次未提交报废申请)'),
'data': _build_data(ret, scrap, company_name, track_ref, source_ref),
}), 200
except PermissionError as e:
# ★ 必须显式捕获:PermissionError 是 OSError 的子类,不是 ValueError,
# 而 app/__init__.py 的全局 errorhandler(Exception) 会把未捕获异常
# 变成一条没有信息的 500,把 403 吞掉。
db.session.rollback()
return jsonify({'code': 403, 'msg': str(e)}), 403
except ValueError as e:
db.session.rollback()
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
db.session.rollback()
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'生产报废受理失败: {str(e)}'}), 500
def _build_data(ret, scrap, company_name, track_ref, source_ref, duplicate=False):
"""响应体。submit_scrap=false 时 scrap 为 {'submitted': false, ...}。"""
return {
'duplicate': duplicate,
'company_name': company_name,
'track_ref': track_ref,
'source_ref': source_ref,
'outbound_id': ret.get('outbound_id'),
'outbound_no': ret.get('outbound_no') or '',
'return_id': ret.get('return_id'),
'defective_goods_id': ret.get('defective_goods_id'),
'return_qty': ret.get('return_qty'),
'returned_quantity': ret.get('returned_quantity'),
'returnable_quantity': ret.get('returnable_quantity'),
'scrap': ({
'submitted': True,
'request_id': scrap.id,
'request_no': scrap.request_no,
'status': scrap.status,
'status_text': '待审批',
'reason_category': scrap.reason_category or '',
'reason_category_label': scrap.to_dict().get('reason_category_label', ''),
'allowed_approvers': scrap.get_allowed_approvers(),
} if scrap is not None else {'submitted': False}),
}
def _duplicate_response(existing, data, company_name, track_ref, return_qty,
source_ref, submit_scrap):
"""重复受理:回显首次结果,不写任何数据。
★ 已知语义边界(明确告知,不掩盖):若首次 submit_scrap=false,
之后用同一 track_ref 重试 submit_scrap=true,会走这里短路、
**不会补提报废申请**。要报废需在 MOM 不良品台账另发申请。
"""
# 请求内容与原单不一致 → 409,避免把它当成「幂等重试」而掩盖真实冲突
if abs(float(existing.return_qty or 0) - float(return_qty)) > 1e-9:
return jsonify({
'code': 409,
'msg': (f'track_ref 已受理但请求内容不一致'
f'(原 outbound_id={existing.outbound_id}, '
f'原 return_qty={float(existing.return_qty or 0)})'),
}), 409
from app.models.transaction import TransDefectiveGoods
goods = TransDefectiveGoods.query.filter_by(return_id=existing.id).first()
ret = {
'outbound_id': existing.outbound_id,
'outbound_no': '',
'return_id': existing.id,
'defective_goods_id': goods.id if goods else None,
'return_qty': float(existing.return_qty or 0),
'returned_quantity': None,
'returnable_quantity': None,
}
return jsonify({
'code': 200,
'msg': '该 track_ref 已受理,本次为重复请求,未重复受理',
'data': _build_data(ret, None, company_name, track_ref, source_ref,
duplicate=True),
}), 200

View File

@ -1,9 +1,10 @@
# inventory-backend/app/api/v1/scrap.py # inventory-backend/app/api/v1/scrap.py
from flask import Blueprint, request, jsonify, current_app from flask import Blueprint, request, jsonify, current_app, send_file
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
from app.utils.decorators import permission_required, get_current_company_filter from app.utils.decorators import permission_required, get_current_company_filter
from app.services.auth_service import AuthService from app.services.auth_service import AuthService
from app.extensions import db from app.extensions import db
from app.models.scrap_approval import scrap_category_label
from app.models.transaction import ( from app.models.transaction import (
TransScrap, TransRepair, TransDefectiveGoods, OPEN_DEFECTIVE_STATUSES, TransScrap, TransRepair, TransDefectiveGoods, OPEN_DEFECTIVE_STATUSES,
) )
@ -13,6 +14,8 @@ from app.models.inbound.product import StockProduct
from app.models.base import MaterialBase from app.models.base import MaterialBase
from app.models.system import SysUser from app.models.system import SysUser
import traceback import traceback
import io
from datetime import datetime
import math import math
scrap_bp = Blueprint('scrap', __name__, url_prefix='/scrap') scrap_bp = Blueprint('scrap', __name__, url_prefix='/scrap')
@ -166,6 +169,8 @@ def get_scrap_records():
end_date = request.args.get('end_date', '') end_date = request.args.get('end_date', '')
keyword = request.args.get('keyword', '') keyword = request.args.get('keyword', '')
search_type = request.args.get('search_type', 'all') search_type = request.args.get('search_type', 'all')
# 原因分类筛选:'' / PRODUCTION(生产报废) / STOCK(库存报废)
reason_category = request.args.get('reason_category', '')
# ★ 高级筛选:JSON 字符串 → 条件列表 # ★ 高级筛选:JSON 字符串 → 条件列表
from app.utils.advanced_filter import parse_advanced_filters from app.utils.advanced_filter import parse_advanced_filters
@ -181,6 +186,7 @@ def get_scrap_records():
keyword=keyword, keyword=keyword,
search_type=search_type, search_type=search_type,
advanced_filters=advanced_filters, advanced_filters=advanced_filters,
reason_category=reason_category,
) )
# 损失金额按 scrap_list:loss_amount 权限决定可见性(原为无条件剥离, # 损失金额按 scrap_list:loss_amount 权限决定可见性(原为无条件剥离,
# 会让持有该权限的角色也看不到金额,与权限元素的存在相矛盾) # 会让持有该权限的角色也看不到金额,与权限元素的存在相矛盾)
@ -194,6 +200,110 @@ def get_scrap_records():
return jsonify({'code': 500, 'msg': str(e)}), 500 return jsonify({'code': 500, 'msg': str(e)}), 500
@scrap_bp.route('/records/export', methods=['GET'])
@jwt_required()
@permission_required('scrap_list')
def export_scrap_records():
"""导出报废记录为 Excel(.xlsx)。
与列表页**同一套筛选条件**(关键词/日期/分类/高级筛选),但导出的是
**全部匹配结果**而不是当前页 —— 用户点「导出」要的就是全量,
只导当前页会让他以为数据被截断了。
⚠️ 损失金额按 `scrap_list:loss_amount` 权限决定可不可见(与列表页同一口径)。
没权限的人导出的表里该列为空,而不是偷偷带上 —— 否则导出就成了绕过
字段级权限的后门。
⚠️ 行数上限 20000:防止有人不加筛选把整个台账导出来把内存打满。
截断时在表头写明「仅导出前 N 行」,**不静默截断**。
"""
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill
start_date = request.args.get('start_date', '')
end_date = request.args.get('end_date', '')
keyword = request.args.get('keyword', '')
search_type = request.args.get('search_type', 'all')
sku = request.args.get('sku', '')
reason_category = request.args.get('reason_category', '')
from app.utils.advanced_filter import parse_advanced_filters
advanced_filters = parse_advanced_filters(request.args.get('advancedFilters', ''))
EXPORT_LIMIT = 20000
try:
result = ScrapService.query_records(
page=1, page_size=EXPORT_LIMIT,
sku=sku, start_date=start_date, end_date=end_date,
keyword=keyword, search_type=search_type,
advanced_filters=advanced_filters,
reason_category=reason_category,
)
orders = result.get('list') or []
total = result.get('total') or len(orders)
perms = get_current_user_permissions()
can_view_loss = 'scrap_list:*' in perms
wb = Workbook()
ws = wb.active
ws.title = '报废记录'
headers = ['报废单号', '申请人', '操作人', '报废时间', '原因分类',
'SKU', '物料名称', '规格型号', '库位', '批次/序列号',
'报废数量', '损失金额', '报废原因', '审批状态']
if not can_view_loss:
headers.remove('损失金额')
ws.append(headers)
head_fill = PatternFill('solid', fgColor='DDEBF7')
for c in ws[1]:
c.font = Font(bold=True)
c.fill = head_fill
c.alignment = Alignment(horizontal='center', vertical='center')
for o in orders:
items = o.get('items') or [{}]
for it in items:
row = [
o.get('scrap_request_no', ''), o.get('applicant_name', ''),
o.get('operator_name', ''), o.get('scrap_time', ''),
o.get('reason_category_label', '') or '-',
it.get('sku', ''), it.get('material_name', ''), it.get('spec_model', ''),
it.get('warehouse_location', ''), it.get('batch_number', ''),
it.get('quantity', ''), it.get('loss_amount', ''),
it.get('reason', ''), o.get('approval_status', ''),
]
if not can_view_loss:
del row[11]
ws.append(row)
# 列宽按内容自适应(与 stock.py 的盘点导出同一做法)
for col in ws.columns:
width = 0
letter = col[0].column_letter
for cell in col:
try:
width = max(width, len(str(cell.value)))
except Exception:
pass
ws.column_dimensions[letter].width = min(width + 2, 30)
output = io.BytesIO()
wb.save(output)
output.seek(0)
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
note = '' if total <= EXPORT_LIMIT else f'_仅前{EXPORT_LIMIT}条(共{total})'
return send_file(
output,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=f'报废记录_{ts}{note}.xlsx',
)
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'导出失败: {str(e)}'}), 500
# ============================================================ # ============================================================
# Service 层:报废核心逻辑 # Service 层:报废核心逻辑
# ============================================================ # ============================================================
@ -405,20 +515,47 @@ class ScrapService:
if r.source_table == 'trans_defective_goods' and r.stock_id} if r.source_table == 'trans_defective_goods' and r.stock_id}
if tdg_ids: if tdg_ids:
from app.models.transaction import TransDefectiveGoods from app.models.transaction import TransDefectiveGoods
for g in TransDefectiveGoods.query.filter( goods_rows = TransDefectiveGoods.query.filter(
TransDefectiveGoods.id.in_(tdg_ids)).all(): TransDefectiveGoods.id.in_(tdg_ids)).all()
# ★ 库位/批次:**快照优先,回查兜底**(口径与其他来源一致 = 原库位)。
#
# 原实现把这两列硬编码成空串,理由是「源库存行可能已被物理删除」。
# 该理由对 material_name/spec_model 成立(所以它们有冗余快照),
# 但库位/批次当时没有快照列,一律置空等于放弃了「源行还在」这个
# 绝大多数情形 —— 报表上明明取得到也显示 "-"。
#
# 现在两级:① phase14 新增的台账快照(退回时取自源行,免疫删除);
# ② 存量行没有快照,回查源库存行(与 trans_borrow 分支同一做法,
# _from_stock 本就容忍行不存在)。两级都落空才置空串。
src_ids = {}
for g in goods_rows:
if g.source_table in model_map and g.stock_id:
src_ids.setdefault(g.source_table, set()).add(g.stock_id)
src_map = {}
for table, ids in src_ids.items():
model = model_map[table]
for obj in (model.query.options(joinedload(model.base))
.filter(model.id.in_(ids)).all()):
src_map[(table, obj.id)] = _from_stock(obj)
for g in goods_rows:
fallback = src_map.get((g.source_table, g.stock_id)) or {}
resolved[('trans_defective_goods', g.id)] = { resolved[('trans_defective_goods', g.id)] = {
'material_name': g.material_name or '', 'material_name': g.material_name or '',
'spec_model': g.spec_model or '', 'spec_model': g.spec_model or '',
'warehouse_location': '', 'warehouse_location': (g.warehouse_location
'batch_number': '', or fallback.get('warehouse_location') or ''),
'batch_number': (g.batch_number
or fallback.get('batch_number') or ''),
} }
return resolved return resolved
@staticmethod @staticmethod
def query_records(page=1, page_size=50, sku='', start_date='', end_date='', def query_records(page=1, page_size=50, sku='', start_date='', end_date='',
keyword='', search_type='all', advanced_filters=None): keyword='', search_type='all', advanced_filters=None,
reason_category=''):
""" """
分页查询报废记录 —— ★ 按报废申请单号分组,返回「订单级」结果。 分页查询报废记录 —— ★ 按报废申请单号分组,返回「订单级」结果。
@ -432,6 +569,18 @@ class ScrapService:
if sku: if sku:
query = query.filter(TransScrap.sku.like(f'%{sku}%')) query = query.filter(TransScrap.sku.like(f'%{sku}%'))
# ★ 原因分类筛选:两种互斥口径 ——
# 生产报废 = 所有走 Track 的;库存报废 = MOM 自身流程走下来的。
# 'STOCK' 要同时**兜住空值**:本列上线前的历史台账没有分类,而它们
# 全都是 MOM 自身流程产生的(Track 那条链一定会写 PRODUCTION)。
# 不兜的话「筛库存报废」会漏掉全部历史单,用户会以为数据丢了。
rc = (reason_category or '').strip().upper()
if rc == 'PRODUCTION':
query = query.filter(TransScrap.reason_category == 'PRODUCTION')
elif rc == 'STOCK':
query = query.filter(db.or_(TransScrap.reason_category == 'STOCK',
TransScrap.reason_category.is_(None)))
if start_date: if start_date:
query = query.filter(TransScrap.operation_time >= start_date) query = query.filter(TransScrap.operation_time >= start_date)
if end_date: if end_date:
@ -672,6 +821,8 @@ class ScrapService:
'total_loss': 0.0, 'total_loss': 0.0,
'total_quantity': 0.0, 'total_quantity': 0.0,
'reason': r.reason or '', 'reason': r.reason or '',
'reason_category': r.reason_category or '',
'reason_category_label': scrap_category_label(r.reason_category),
'items': [], 'items': [],
} }
groups[gkey] = g groups[gkey] = g
@ -690,6 +841,8 @@ class ScrapService:
'batch_number': info.get('batch_number', ''), 'batch_number': info.get('batch_number', ''),
'quantity': qty, 'quantity': qty,
'reason': r.reason or '', 'reason': r.reason or '',
'reason_category': r.reason_category or '',
'reason_category_label': scrap_category_label(r.reason_category),
'source_table': r.source_table or '', 'source_table': r.source_table or '',
'loss_amount': round(loss, 2), 'loss_amount': round(loss, 2),
}) })
@ -791,13 +944,20 @@ def scrap_check_approval():
@jwt_required() @jwt_required()
@permission_required('scrap_apply') @permission_required('scrap_apply')
def create_scrap_request(): def create_scrap_request():
"""提交报废申请(不扣库存;扣减在库管执行时)""" """提交报废申请(不扣库存;扣减在库管执行时)
审批人有两条路(都不传则回落默认审批角色,见 scrap_approval_service):
· approver_id —— 指定具体某个人
· allowed_approvers —— 角色级名单,如 [{"type":"role","value":"SUPERVISOR"}]
"""
try: try:
from app.services.scrap_approval_service import ScrapApprovalService from app.services.scrap_approval_service import ScrapApprovalService
identity = get_jwt_identity() identity = get_jwt_identity()
if not identity: if not identity:
return jsonify({'code': 401, 'msg': '用户未登录'}), 401 return jsonify({'code': 401, 'msg': '用户未登录'}), 401
data = request.get_json() or {} data = request.get_json() or {}
# 公司快照:角色级审批据此做公司隔离(见 assert_same_company)。
# 申请人所属公司即「部门」,从 JWT 取;取不到留空(旧单口径,跳过公司校验)。
req = ScrapApprovalService.submit_approval( req = ScrapApprovalService.submit_approval(
applicant_id=int(identity), applicant_id=int(identity),
items=data.get('items', []), items=data.get('items', []),
@ -805,6 +965,8 @@ def create_scrap_request():
remark=data.get('remark'), remark=data.get('remark'),
approver_id=data.get('approver_id'), approver_id=data.get('approver_id'),
force_approval=(_current_user_role() == 'WAREHOUSE_MGR'), force_approval=(_current_user_role() == 'WAREHOUSE_MGR'),
reason_category=data.get('reason_category'),
company_name=((get_jwt() or {}).get('company_name') or '').strip() or None,
) )
return jsonify({'code': 200, 'msg': '报废申请已提交', 'data': req.to_dict()}), 200 return jsonify({'code': 200, 'msg': '报废申请已提交', 'data': req.to_dict()}), 200
except ValueError as e: except ValueError as e:
@ -835,10 +997,18 @@ def list_scrap_requests():
status = int(status) if status not in (None, '', 'all') else None status = int(status) if status not in (None, '', 'all') else None
priv = is_privileged_viewer() priv = is_privileged_viewer()
kwargs = {'page': page, 'limit': limit, 'status': status} # 公司隔离:主管共 6 人(IRIS 5 / LICA 1),不按公司收敛就是跨公司可见。
# 超管/跨域返回 None → 不过滤(与 get_current_company_filter 口径一致)。
company_limit = get_current_company_filter()
kwargs = {'page': page, 'limit': limit, 'status': status,
'company_name': company_limit}
if scope == 'pending': if scope == 'pending':
kwargs['status'] = 0 kwargs['status'] = 0
# ★ 待办要同时按「指名的」和「我这个角色的」捞 —— 角色级审批下,
# 单据通常只写角色不写人。两者在 get_list 里是 OR 关系。
kwargs['approver_id'] = identity kwargs['approver_id'] = identity
kwargs['approver_role'] = _current_user_role()
elif scope == 'executable': elif scope == 'executable':
kwargs['status'] = 1 kwargs['status'] = 1
elif scope == 'all': elif scope == 'all':
@ -865,9 +1035,11 @@ def get_scrap_request_detail(request_id):
if not req: if not req:
return jsonify({'code': 404, 'msg': '报废申请不存在'}), 404 return jsonify({'code': 404, 'msg': '报废申请不存在'}), 404
if not is_privileged_viewer() and int(req.applicant_id) != int(get_jwt_identity()): if not is_privileged_viewer() and int(req.applicant_id) != int(get_jwt_identity()):
# 被指定审批人也可查看 # 被指定审批人(或审批角色)也可查看。
allowed = req.get_allowed_approvers() or [] # ★ 走 can_operator_approve 这个单一事实来源,不要在这里再抄一份名单判定 ——
if str(get_jwt_identity()) not in [str(a.get('value')) for a in allowed if a.get('type') == 'user']: # 抄出去就开始漂移,而且空名单的 Fail-Closed 语义容易抄漏。
from app.services.scrap_approval_service import ScrapApprovalService
if not ScrapApprovalService.can_operator_approve(req, int(get_jwt_identity())):
return jsonify({'code': 403, 'msg': '无权查看该报废申请'}), 403 return jsonify({'code': 403, 'msg': '无权查看该报废申请'}), 403
return jsonify({'code': 200, 'msg': '获取成功', 'data': req.to_dict()}), 200 return jsonify({'code': 200, 'msg': '获取成功', 'data': req.to_dict()}), 200
except Exception as e: except Exception as e:
@ -890,6 +1062,12 @@ def approve_scrap_request(request_id):
return jsonify({'code': 200, 'msg': '操作成功', 'data': req.to_dict()}), 200 return jsonify({'code': 200, 'msg': '操作成功', 'data': req.to_dict()}), 200
except ValueError as e: except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400 return jsonify({'code': 400, 'msg': str(e)}), 400
except PermissionError as e:
# ★ 必须显式捕获:PermissionError 是 OSError 的子类,**不是** ValueError。
# 漏了这一条,「跨公司审批」会落到下面的兜底分支变成 500 —— 权限拒绝
# 被伪装成服务端故障,排查时会往完全错误的方向找。
# (assert_same_company 抛的就是它)
return jsonify({'code': 403, 'msg': str(e)}), 403
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
return jsonify({'code': 500, 'msg': f'审批报废申请失败: {str(e)}'}), 500 return jsonify({'code': 500, 'msg': f'审批报废申请失败: {str(e)}'}), 500

View File

@ -115,6 +115,69 @@ IGNORE_FIELDS = {
IGNORE_FIELD_KEYWORDS = ('embedding', 'image_data', 'photo_blob') IGNORE_FIELD_KEYWORDS = ('embedding', 'image_data', 'photo_blob')
# =============================================================================
# 级联操作聚合
# =============================================================================
# 「一次业务点击 → 一条审计记录」。
#
# 背景:BOM 归档/启停、权限分配这类接口一次请求会改动整组数据,ORM 监听器
# 遂逐行写审计。实测的突发规模(同一秒、同接口、同操作人):
#
# /api/v1/inbound/stock/draft/start-new 924 条
# /api/v1/inbound/stock/stocktake/generate-missing 898 条
# /api/v1/permissions/assign 381 条
# /api/v1/outbound 110 条
# /api/v1/bom/save 83 条
# /api/v1/bom/archive 58 条
#
# 全库共 311 次「单请求 ≥20 条」的突发。逐行审计在本该表达"一次业务动作"的
# 地方产生了成百条重复日志,审计页被刷屏、日报也被灌水。
#
# ★ 为什么用**白名单**而不是全局默认合并:
# 合并会改变审计的**语义粒度**(日报里的"修改 354 条"可能变成几十条),
# 全局改会让业务方以为日志坏了。白名单的失败模式也更安全 ——
# 新接口忘了登记只是"仍然刷屏",而不会把两个不相干的业务动作错误合并
# (错误合并 = 把 A 的改动记到 B 头上,是审计里最危险的一类错)。
#
# ★ 匹配规则:按**路径段前缀**,不是子串。`/api/v1/bom/SF-3500-9` 命中
# `bom`,而 `/api/v1/bom_draft_x` 不会(段名不等)。版本前缀
# (/api、/api/v1)先剥掉,兼容 /api/... 的老路由。
AGGREGATE_PATH_MARKERS = (
'bom', # save/archive/status/draft/* 与 DELETE /bom/<no>
'permissions/assign',
'inbound/stock/draft/start-new',
'inbound/stock/stocktake/generate-missing',
'outbound',
'import/execute',
)
# 聚合日志里保留的受影响对象清单上限。
# ★ 超出时**显式标注** targets_truncated 与真实总数,不静默丢弃 ——
# target_id 逐个都进清单的话,924 条的突发会生成几十 KB 的 details,
# 而列表接口一页 50 条会把它翻 50 倍带出去。
AGGREGATE_TARGETS_LIMIT = 200
# 累加器挂在 session.info 上。
# ★ 不能用 connection.info:连接来自池子,会跨请求残留 ——
# 那会把上一个请求的变更算进下一个请求的日志里。
AGGREGATE_INFO_KEY = '_audit_aggregate'
def _is_aggregate_path(url):
"""该请求路径是否命中级联聚合白名单"""
segments = [s for s in str(url or '').split('?')[0].split('/') if s]
# 剥掉 api 与版本前缀(/api/v1/bom/x → bom/x;/api/bom/x → bom/x)
if segments and segments[0].lower() == 'api':
segments = segments[1:]
if segments and len(segments[0]) > 1 and segments[0][0] in 'vV' and segments[0][1:].isdigit():
segments = segments[1:]
for marker in AGGREGATE_PATH_MARKERS:
want = marker.split('/')
if segments[:len(want)] == want:
return True
return False
# ============================================================================= # =============================================================================
# 序列化 # 序列化
# ============================================================================= # =============================================================================
@ -300,6 +363,150 @@ def _get_request_user_info():
# 核心:写入审计日志 # 核心:写入审计日志
# ============================================================================= # =============================================================================
_INSERT_AUDIT_SQL = text("""
INSERT INTO audit_logs
(user_id, username, display_name, action, module,
target_id, target_name, details, ip_address, method, url, created_at)
VALUES
(:user_id, :username, :display_name, :action, :module,
:target_id, :target_name, cast(:details AS jsonb), :ip_address,
:method, :url, :created_at)
""")
_MISSING = object()
def _common_part(a, b):
"""
两个 details 的**公共部分**:键都在、且值相等;dict 递归。
用于把一批变更合并成"所有行都成立"的那部分。
★ 只取公共部分,绝不用某一行的值代表整批 —— 那是**编造**:
把 A 行的改动说成整批的改动,读者会照着它去追一个错误的结论。
不公共的部分由 aggregate.targets 交代"动过哪些对象"。
"""
if isinstance(a, dict) and isinstance(b, dict):
out = {}
for key in a.keys() & b.keys():
v = _common_part(a[key], b[key])
if v is not _MISSING:
out[key] = v
# 空 dict 是"没有任何公共字段",留着会渲染成空的变更列表
return out if out else _MISSING
if a == b:
return a
return _MISSING
def _accumulate_aggregate(target, action, module, target_id, target_name,
details):
"""
把这一次变更累加到当前 session 的聚合缓冲里(不写库)。
返回 True 表示已接管(调用方不要再逐行 INSERT)。
★ 缓冲挂在 session.info:session 是请求级的,随请求销毁;
连接是池化的,挂在 connection.info 上会跨请求残留。
"""
try:
from sqlalchemy.orm import object_session
session = object_session(target)
if session is None:
return False
except Exception:
return False
user = _get_request_user_info()
key = (module, action, user.get('url') or '')
buf = session.info.setdefault(AGGREGATE_INFO_KEY, {})
slot = buf.get(key)
if slot is None:
slot = buf[key] = {
'module': module,
'action': action,
'user': user,
'count': 0,
'targets': [],
'seen': set(),
'first_id': target_id,
'first_name': target_name,
'common': None, # None = 还没有任何一行参与比较
}
slot['count'] += 1
if target_id not in slot['seen']:
slot['seen'].add(target_id)
slot['targets'].append({'id': target_id, 'name': target_name})
slot['common'] = (details if slot['common'] is None
else _common_part(slot['common'], details))
return True
def _flush_aggregated_audit(session, flush_context=None):
"""
Session after_flush 钩子:把聚合缓冲写成**一条** AuditLog。
★★ 钩子必须挂在 after_flush,不能挂 before_commit —— 这是个踩过的坑:
Session.commit() 的顺序是「before_commit → flush → after_flush → COMMIT」,
而累加**发生在 flush 期间**(ORM 的 before_update 回调)。挂在
before_commit 上,钩子跑的时候缓冲还是空的,等 flush 把它填满后
再没人来写 —— 结果是**审计整批丢失**(实测归档请求产出 0 条日志,
业务却已提交,正是最危险的"改了但没记录")。
★ 为什么不用 after_request / teardown:它们跑在业务事务之外,
业务回滚也会留下一条"成功"的审计 —— 那是假账。
after_flush 仍在同一事务内,故聚合日志与业务改动**同生共死**。
★ 幂等:一次 commit 可能触发多次 flush(自动 flush)。取出即清空,
后续 flush 若有新变更会再写一条 —— 那对应另一个原子单元,是对的。
★ 没有缓冲 = 不是白名单请求 / 无变更,直接返回(绝大多数请求走这里)。
"""
slots = session.info.pop(AGGREGATE_INFO_KEY, None)
if not slots:
return
try:
conn = session.connection()
for slot in slots.values():
common = slot['common'] if isinstance(slot['common'], dict) else {}
targets_total = len(slot['targets'])
targets = slot['targets'][:AGGREGATE_TARGETS_LIMIT]
agg = {
'count': slot['count'],
'targets': targets,
}
# 只在「变更次数 ≠ 对象数」时才带 targets_total:多数批次两者相等
# (一次变更一行),多一个恒等于 count 的字段只会让抽屉多一行噪声
if targets_total != slot['count']:
agg['targets_total'] = targets_total
if targets_total > len(targets):
agg['targets_truncated'] = True
details = dict(common)
details['aggregate'] = agg
user = slot['user']
conn.execute(_INSERT_AUDIT_SQL, {
'user_id': user.get('user_id'),
'username': user.get('username') or 'system',
'display_name': user.get('display_name') or '',
'action': slot['action'],
'module': slot['module'],
'target_id': slot['first_id'],
'target_name': (slot['first_name'] or '')[:200],
'details': json.dumps(details, cls=_AuditJSONEncoder),
'ip_address': user.get('ip'),
'method': user.get('method'),
'url': user.get('url'),
'created_at': beijing_time(),
})
except Exception as e:
# 与逐行写入一致:审计失败不影响业务,但必须留下痕迹
try:
current_app.logger.error(f"Audit aggregate flush failed: {e}")
except Exception:
pass
def _create_audit_log(connection, mapper, target, action, details): def _create_audit_log(connection, mapper, target, action, details):
""" """
使用事件回调传入的 connection 直接 INSERT。 使用事件回调传入的 connection 直接 INSERT。
@ -320,6 +527,14 @@ def _create_audit_log(connection, mapper, target, action, details):
user = _get_request_user_info() user = _get_request_user_info()
module = _get_module_name(mapper) module = _get_module_name(mapper)
# ★ 级联聚合白名单:命中则只累加,不逐行写库。
# 真正的写入发生在 Session before_commit(见 _flush_aggregated_audit),
# 一次业务点击最终只留一条日志。
if _is_aggregate_path(user.get('url')):
if _accumulate_aggregate(target, action, module, target_id,
target_name, details):
return
sql = text(""" sql = text("""
INSERT INTO audit_logs INSERT INTO audit_logs
(user_id, username, display_name, action, module, (user_id, username, display_name, action, module,
@ -457,10 +672,34 @@ def after_insert_listener(mapper, connection, target):
_BOUND_TABLES = set() _BOUND_TABLES = set()
_AGGREGATE_HOOK_BOUND = False
def _bind_aggregate_hook():
"""
注册 Session after_flush 钩子(幂等)。
★ 用 after_flush 而不是 before_commit / after_commit,理由见
_flush_aggregated_audit 的说明(前者太早、缓冲还空着;后者已在
业务事务之外)。
"""
global _AGGREGATE_HOOK_BOUND
if _AGGREGATE_HOOK_BOUND:
return
from sqlalchemy.orm import Session
event.listen(Session, 'after_flush', _flush_aggregated_audit)
_AGGREGATE_HOOK_BOUND = True
def register_audit_listeners(db): def register_audit_listeners(db):
""" """
按白名单向已完成映射的模型注册事件监听器,返回本次**新增**绑定的模型数。 按白名单向已完成映射的模型注册事件监听器,返回本次**新增**绑定的模型数。
★ 务必要连带注册级联聚合的 Session 钩子(见 _bind_aggregate_hook):
白名单接口的变更只累加、不逐行写库,钩子缺席就永远不落地 ——
那是**丢审计**,比刷屏严重得多。故绑在这里而不是靠 import 副作用。
★ 为什么需要"惰性补绑"(见 ensure_audit_listeners): ★ 为什么需要"惰性补绑"(见 ensure_audit_listeners):
本项目大量模型是在**函数体内延迟导入**的(31 处,例如 本项目大量模型是在**函数体内延迟导入**的(31 处,例如
app/api/v1/scrap.py 内部 `from app.models.scrap_approval import ScrapApproval`), app/api/v1/scrap.py 内部 `from app.models.scrap_approval import ScrapApproval`),
@ -476,6 +715,7 @@ def register_audit_listeners(db):
写日志必然抛 AttributeError 并被静默吞掉。 写日志必然抛 AttributeError 并被静默吞掉。
现改为按 **表名** 从 db.metadata 取模型,不依赖 app.models 的导出。 现改为按 **表名** 从 db.metadata 取模型,不依赖 app.models 的导出。
""" """
_bind_aggregate_hook()
count = 0 count = 0
for tablename in list(WHITELIST_TABLES): for tablename in list(WHITELIST_TABLES):
if tablename in _BOUND_TABLES: if tablename in _BOUND_TABLES:
@ -508,6 +748,7 @@ def ensure_audit_listeners():
调用开销:一次集合差集运算;已全部绑定后立即返回。 调用开销:一次集合差集运算;已全部绑定后立即返回。
""" """
_bind_aggregate_hook()
if _BOUND_TABLES >= WHITELIST_TABLES: if _BOUND_TABLES >= WHITELIST_TABLES:
return 0 return 0
try: try:

View File

@ -148,6 +148,15 @@ class TransOutbound(db.Model):
# ⚠ 该列上线前的历史行为 NULL —— 存量出库与其来源审批单之间没有任何可用 # ⚠ 该列上线前的历史行为 NULL —— 存量出库与其来源审批单之间没有任何可用
# 关联,无从回填;退回时由库管在选择器里明确指定。 # 关联,无从回填;退回时由库管在选择器里明确指定。
applicant_id = db.Column(db.Integer, index=True) applicant_id = db.Column(db.Integer, index=True)
# ★ 来源申请单ID:创建出库时从**关联审批单**带出(出库时 request_id 已强制
# 必填,approval 恒非 None)。与上面的 applicant_id 语义正交 ——
# applicant_id 是「人」,本列是「那张单」。
# 为什么要它:原先只落了 applicant_id,从一条出库明细反查不到它属于哪张
# 申请单(单号 request_no、申请说明、明细快照 items_json 都在申请单上)。
# ⚠ 该列上线前的历史行为 NULL —— 存量出库与来源审批单之间没有任何可用关联,
# 无从回填。
# DDL 见 db_migrations/phase11_trans_outbound_request_link.sql
request_id = db.Column(db.Integer, index=True)
signature_path = db.Column(db.Text) # 电子签名图片路径 signature_path = db.Column(db.Text) # 电子签名图片路径
outbound_time = db.Column(db.DateTime, default=beijing_time) outbound_time = db.Column(db.DateTime, default=beijing_time)
operator_name = db.Column(db.String(100)) # 操作员 operator_name = db.Column(db.String(100)) # 操作员
@ -179,6 +188,7 @@ class TransOutbound(db.Model):
'returnable_quantity': qty - returned, 'returnable_quantity': qty - returned,
'unit_price': float(self.unit_price) if self.unit_price else 0, 'unit_price': float(self.unit_price) if self.unit_price else 0,
'consumer_name': self.consumer_name, 'consumer_name': self.consumer_name,
'request_id': self.request_id,
'signature_path': self.signature_path, 'signature_path': self.signature_path,
'outbound_time': self.outbound_time.strftime('%Y-%m-%d %H:%M:%S') if self.outbound_time else None, 'outbound_time': self.outbound_time.strftime('%Y-%m-%d %H:%M:%S') if self.outbound_time else None,
'operator_name': self.operator_name, 'operator_name': self.operator_name,

View File

@ -2,6 +2,85 @@ from app.extensions import db, beijing_time
import json import json
# =============================================================================
# 报废原因分类 —— 用来回答「这笔损失出在哪个环节」
#
# 业务口径(2026-09 与业务确认):
# PRODUCTION 生产环节 —— **好料领到产线之后**,在生产中变坏了。
# STOCK 库存/采购 —— 东西还在仓库里就有问题、买来就坏、或售后从客户
# 退回后发现的不良。凡「不是生产过程中弄坏的」都归这里。
# OTHER 其他 —— 兜底,给以后说不清的情况留个位置。
#
# 大前提:这几类都发生在**发货之前**,都属于「生产未发货前的损失」,
# 与「已发货后客户退货产生的损失」不是一回事。
#
# ★★★ 为什么分类**必须显式记录**,绝不能从 source_table 推导 ★★★
# Track 提交的生产报废走的是「出库领用 → 退回(不良品) → 在管不良品 → 报废」,
# 而 MOM 界面手工报的不良品退回**走的是同一条路、落进同一张
# trans_defective_goods 表**。两条路在 source_table 上长得一模一样:
# · Track 提交的 → 料是好的,生产时弄坏的 → PRODUCTION
# · MOM 手工报的 → 料本来就有问题 → STOCK
# 一旦写成「trans_defective_goods 就推成 STOCK」,Track 报的生产损失会被
# 静默算成库存损失,「统计生产报废金额」这个目标当场失效。
# 所以:**提交时谁传什么就是什么,推导是错的。**
#
# ★ 单一事实来源:模型定义码表,service / API / 前端文案都从这里取,
# 不要再抄一份 —— 抄出去就开始漂移。
# ★ 加新分类只需扩这个 dict,**不需要 DDL**(列是 varchar(50))。
# 刻意不预置「质量/运输/存储」等更多分类:那是给 UI 的承诺,没有业务确认不要先造。
# =============================================================================
SCRAP_CATEGORY_PRODUCTION = 'PRODUCTION' # 生产报废:**所有走 Track 的**
SCRAP_CATEGORY_STOCK = 'STOCK' # 库存报废:MOM 自身流程走下来的
SCRAP_CATEGORY_OTHER = 'OTHER' # 其他(兜底,暂未使用)
SCRAP_CATEGORY_LABELS = {
SCRAP_CATEGORY_PRODUCTION: '生产报废',
SCRAP_CATEGORY_STOCK: '库存报废',
SCRAP_CATEGORY_OTHER: '其他',
}
# 中文别名 → 码。外部系统/前端传中文时用得上(码本身大小写不敏感,此处只列中文)
# 老叫法(生产损耗 / 库存采购)保留兼容 —— Track 侧可能已经在传旧名字
_SCRAP_CATEGORY_ALIASES = {
'生产报废': SCRAP_CATEGORY_PRODUCTION,
'生产损耗': SCRAP_CATEGORY_PRODUCTION, # 旧标签
'生产导致': SCRAP_CATEGORY_PRODUCTION, # 更早的叫法
'生产': SCRAP_CATEGORY_PRODUCTION,
'库存报废': SCRAP_CATEGORY_STOCK,
'库存/采购': SCRAP_CATEGORY_STOCK, # 旧标签
'库存': SCRAP_CATEGORY_STOCK,
'采购': SCRAP_CATEGORY_STOCK,
'其他': SCRAP_CATEGORY_OTHER,
}
def normalize_scrap_category(raw, default=None):
"""报废原因分类入参归一化 → 分类码。
接受分类码(大小写不敏感)或中文别名。空值返回 `default`。
⚠️ 未知值**抛 ValueError** 而不是静默回落 —— 白名单外的东西一律挡在入口,
否则会写进库里一个谁也看不懂的码,统计时才发现分类是脏的。
"""
text = str(raw or '').strip()
if not text:
return default
upper = text.upper()
if upper in SCRAP_CATEGORY_LABELS:
return upper
if text in _SCRAP_CATEGORY_ALIASES:
return _SCRAP_CATEGORY_ALIASES[text]
raise ValueError(
'报废原因分类不支持:{},仅支持 {}'.format(
text, '、'.join(sorted(SCRAP_CATEGORY_LABELS)),
)
)
def scrap_category_label(code):
"""分类码 → 中文名。未知/空值返回空串(前端自行兜底 '-')。"""
return SCRAP_CATEGORY_LABELS.get(str(code or '').strip().upper(), '')
def _beijing_now(): def _beijing_now():
""" """
统一时间口径:naive 北京时间,与 created_at / OutboundApproval / BorrowApproval 一致。 统一时间口径:naive 北京时间,与 created_at / OutboundApproval / BorrowApproval 一致。
@ -29,6 +108,20 @@ class ScrapApproval(db.Model):
applicant_id = db.Column(db.Integer, nullable=False, index=True) applicant_id = db.Column(db.Integer, nullable=False, index=True)
remark = db.Column(db.Text) # 报废原因 remark = db.Column(db.Text) # 报废原因
# 申请所属公司(= 申请人 sys_user.department)。
# ★ 这是**角色级审批**能做公司隔离的前提:主管共 6 人(IRIS 5 / LICA 1),
# 只按角色放行而不比公司,LICA 主管就能审 IRIS 的报废单。
# 存快照而不联表查 sys_user:申请人被删后仍要能定性这张单属于谁。
company_name = db.Column(db.String(255), index=True)
# 报废原因分类码(SCRAP_CATEGORY_*)。执行时经 _ledger_kwargs 带到 trans_scrap。
reason_category = db.Column(db.String(50))
# 外部系统唯一引用,格式 <company>:<外部单号>。
# 幂等与追溯的锚点(Redis 未部署,prevent_double_submit 全程 fail-open,
# 唯一索引是唯一防线;DDL 见 db_migrations/phase12_production_scrap.sql)。
source_ref = db.Column(db.String(100), index=True)
# 状态: 0-待审批, 1-已通过(待执行), 2-已驳回, 3-已执行(已报废) # 状态: 0-待审批, 1-已通过(待执行), 2-已驳回, 3-已执行(已报废)
status = db.Column(db.Integer, default=0, nullable=False, index=True) status = db.Column(db.Integer, default=0, nullable=False, index=True)
@ -83,6 +176,10 @@ class ScrapApproval(db.Model):
'applicant_id': self.applicant_id, 'applicant_id': self.applicant_id,
'applicant_name': self._user_name(self.applicant_id), 'applicant_name': self._user_name(self.applicant_id),
'remark': self.remark or '', 'remark': self.remark or '',
'company_name': self.company_name or '',
'reason_category': self.reason_category or '',
'reason_category_label': scrap_category_label(self.reason_category),
'source_ref': self.source_ref or '',
'status': self.status, 'status': self.status,
'allowed_approvers': self.get_allowed_approvers(), 'allowed_approvers': self.get_allowed_approvers(),
'actual_approver_id': self.actual_approver_id, 'actual_approver_id': self.actual_approver_id,

View File

@ -385,6 +385,10 @@ class TransScrap(db.Model):
total_loss = db.Column(db.Numeric(19, 4)) total_loss = db.Column(db.Numeric(19, 4))
# ★ 关联报废申请单号(审批流写台账用;DDL 见 db_migrations/add_scrap_approval.sql) # ★ 关联报废申请单号(审批流写台账用;DDL 见 db_migrations/add_scrap_approval.sql)
scrap_request_no = db.Column(db.String(100), index=True) scrap_request_no = db.Column(db.String(100), index=True)
# 报废原因分类码,执行时从 scrap_approval.reason_category 带出(见 scrap_sources._ledger_kwargs)。
# 「统计生产报废金额」按此列分组,不要靠 reason 自由文本去匹配。
# DDL 见 db_migrations/phase12_production_scrap.sql
reason_category = db.Column(db.String(50), index=True)
def to_dict(self): def to_dict(self):
return { return {
@ -394,6 +398,7 @@ class TransScrap(db.Model):
'stock_id': self.stock_id, 'stock_id': self.stock_id,
'quantity': float(self.quantity) if self.quantity is not None else None, 'quantity': float(self.quantity) if self.quantity is not None else None,
'reason': self.reason, 'reason': self.reason,
'reason_category': self.reason_category,
'operator_name': self.operator_name, 'operator_name': self.operator_name,
'operation_time': self.operation_time.strftime('%Y-%m-%d %H:%M:%S') if self.operation_time else None, 'operation_time': self.operation_time.strftime('%Y-%m-%d %H:%M:%S') if self.operation_time else None,
'approver_name': self.approver_name, 'approver_name': self.approver_name,
@ -461,6 +466,13 @@ class TransReturn(db.Model):
# DDL 见 db_migrations/add_return_view_support.sql # DDL 见 db_migrations/add_return_view_support.sql
company_name = db.Column(db.String(255), index=True) company_name = db.Column(db.String(255), index=True)
# ★ 外部系统唯一引用,格式 <company>:<外部单号>。
# 外部接口(Track → MOM 生产报废)据此判重。之所以不靠 prevent_double_submit:
# 它依赖 Redis,而 compose 里没有 redis 服务 → redis_client 恒为 None →
# 装饰器全程 fail-open。唯一索引是唯一的并发防线。
# DDL 见 db_migrations/phase12_production_scrap.sql
source_ref = db.Column(db.String(100), index=True)
def to_dict(self): def to_dict(self):
return { return {
'id': self.id, 'id': self.id,
@ -474,6 +486,7 @@ class TransReturn(db.Model):
'operator': self.operator, 'operator': self.operator,
'return_time': self.return_time.strftime('%Y-%m-%d %H:%M:%S') if self.return_time else None, 'return_time': self.return_time.strftime('%Y-%m-%d %H:%M:%S') if self.return_time else None,
'company_name': self.company_name, 'company_name': self.company_name,
'source_ref': self.source_ref or '',
} }
@ -557,6 +570,17 @@ class TransDefectiveGoods(db.Model):
sku = db.Column(db.String(100)) sku = db.Column(db.String(100))
material_name = db.Column(db.String(200)) material_name = db.Column(db.String(200))
spec_model = db.Column(db.String(255)) spec_model = db.Column(db.String(255))
# 原库位 / 批次(序列号) 快照 —— 退回时取自源库存行
# (DDL 见 db_migrations/phase14_defective_goods_location_snapshot.sql)
#
# ★ 口径是「**原库位**」:这批货最初在哪,而不是坏件当前所在的不良品区
# (不良品区位置本系统未记录)。与三张库存表来源的报废记录口径一致。
# ★ batch_number 对 stock_product 来源存的是 serial_number(成品表无
# batch_number 列),与 scrap.py 的 _from_stock 取值口径一致。
# ★ 加这两列是为了让报表**不必**依赖源库存行存活 —— 源行会被入库模块
# 物理删除,届时回查落空,只能靠这里的快照。存量行由上述迁移回填。
warehouse_location = db.Column(db.String(100))
batch_number = db.Column(db.String(100))
# --- 数量 --- # --- 数量 ---
# ★ 不变式:restocked_qty + scrapped_qty + remaining_qty = quantity # ★ 不变式:restocked_qty + scrapped_qty + remaining_qty = quantity
@ -592,6 +616,8 @@ class TransDefectiveGoods(db.Model):
'sku': self.sku, 'sku': self.sku,
'material_name': self.material_name, 'material_name': self.material_name,
'spec_model': self.spec_model, 'spec_model': self.spec_model,
'warehouse_location': self.warehouse_location or '',
'batch_number': self.batch_number or '',
'quantity': qty, 'quantity': qty,
'remaining_qty': remain, 'remaining_qty': remain,
# ★ 三个去向列各自独立取值。改造前 restocked_qty 由 # ★ 三个去向列各自独立取值。改造前 restocked_qty 由

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,669 @@
"""
MOM 系统日报 —— 采集当日数据 → 渲染纯文本 → 发送邮件。
设计取舍
--------
★ **纯代码统计,不接 AI**。
日报是固定格式的结构化统计(计数 + 清单),不是创造性任务。此前那份日报由
Dify(外部 AI 服务,见 api/v1/ai_proxy.py)生成,好处是能写人话,代价是:
· 外部依赖一断,整份日报就没了(这正是"AI 坏了"之后发生的事情);
· 按次计费、要走网络、要管密钥;
· **会在报表里编数字** —— 日报是给人做决策用的,数字必须可信。
换成确定性模板后,这些代价全部消失,而日报本来也不需要创造力。
★ 口径与前端「操作审计日志」页保持一致,避免两处对同一天给出不同数字:
· action 一律经 canon_action() 归一化(历史数据里 CREATE 与「新增」混用,
不归一会漏掉早期数据);
· 默认排除 system 占位账号(与审计页默认的"真实用户"视图一致)。
注意这是**占位账号**,不是"系统自动产生的日志" —— 判据是 username。
★ 凡有上限的地方一律显式写明「另有 N 条」,不静默截断。报表的读法就是
"看到的就是全部",偷偷砍掉会让人对数据产生错误信心。
★ **正文摘要 + Excel 附件全量**(2026-09 起)。
此前正文把明细压到极简(只列变更字段≥3 的记录、新增只给模块计数),
好处是一屏看完,代价是"199 条新增只看到 3 个模块名"—— 想查某条具体
记录改了什么,正文根本给不出来。
但也不能把这些全塞进正文:实测某日 354 条修改展开近 3000 行,邮件客户端
渲染卡顿,部分企业邮件网关直接把超长自动邮件判为垃圾或截断 —— 这是比
"信息少"更难排查的故障(发件方照样收到 250,看起来一切正常)。
故拆成两路:**正文保持摘要**(`render()`,一眼看完发生了什么),
**附件给全量明细**(`render_excel()`,每类操作一个工作表,不设阈值)。
★ 取值/翻译/Excel 排版逻辑**不在这里**,在 `audit_export_service`。
审计页的按条件导出要用同一套(否则会出现"日报里是人名、导出里是裸 ID"),
故那一层由两个消费者共用,本模块只负责「取哪些行、怎么组织成日报」。
时间口径
--------
audit_logs.created_at 由 beijing_time() 写入,是 naive 北京时间。
本模块按**北京时间的自然日**切分 [00:00, 次日 00:00)。
"""
import logging
from collections import Counter, defaultdict
from datetime import datetime, timedelta
from app.models.audit import AuditLog
from app.models.base import MaterialBase
from app.models.outbound import TransOutbound
from app.models.transaction import TransBorrow
from app.services.audit_export_service import (
DAILY_REPORT_HEAD,
EXCEL_SNAPSHOT_COL_LIMIT,
SYSTEM_USERNAME,
UNKNOWN,
cell,
changes_of,
daily_report_head_values,
fill_sheet,
fmt_name,
fmt_value,
load_ref_maps,
operator_of,
resolved_changes,
snapshot_headers,
snapshot_of,
)
from app.utils.audit_labels import (
SNAPSHOT_CREATED,
SNAPSHOT_DELETED,
canon_action,
)
from app.utils.constants import OutboundType
logger = logging.getLogger(__name__)
class DailyReportService:
"""日报采集与渲染。所有方法均为无状态,便于手动触发与测试。"""
# 【修改】只详列「变更字段数 >= 该值」的记录 —— 全列会把日报冲成一屏流水
DETAIL_MIN_FIELDS = 3
# 【修改】详列条数上限(超出部分显式报数,不静默丢弃)
DETAIL_LIMIT = 30
# 【新增】模块清单条数上限
MODULE_LIMIT = 20
# 【出库】【借库】单据条数上限
DOC_LIMIT = 50
# ------------------------------------------------------------------
# 时间边界
# ------------------------------------------------------------------
@staticmethod
def _day_bounds(report_date=None):
"""返回 [当天 00:00, 次日 00:00)。report_date 为 date 对象,缺省取今天。"""
if report_date is None:
report_date = datetime.now().date()
start = datetime(report_date.year, report_date.month, report_date.day)
return start, start + timedelta(days=1)
# ------------------------------------------------------------------
# 物料信息解析(出库/借库明细要显示名称/规格/分类)
# ------------------------------------------------------------------
@staticmethod
def _load_material_index(pairs):
"""
批量解析 {(source_table, stock_id)} → {'name','spec','category'}。
★ 批量而非逐条:一张日报可能涉及上百行明细,逐行查库就是 N+1。
★ 取不到就跳过(源库存行可能已被物理删除),由渲染层显示占位符 ——
不因为缺一个物料名就让整份日报发不出去。
"""
from app.models.inbound.buy import StockBuy
from app.models.inbound.product import StockProduct
from app.models.inbound.semi import StockSemi
model_map = {
'stock_buy': StockBuy,
'stock_semi': StockSemi,
'stock_product': StockProduct,
}
by_table = defaultdict(set)
for st, sid in pairs:
st = (st or '').strip()
if st in model_map and sid:
try:
by_table[st].add(int(sid))
except (TypeError, ValueError):
continue
index = {}
for table, ids in by_table.items():
model = model_map[table]
rows = model.query.filter(model.id.in_(ids)).all()
base_ids = {r.base_id for r in rows if getattr(r, 'base_id', None)}
bases = {}
if base_ids:
bases = {
b.id: b
for b in MaterialBase.query.filter(MaterialBase.id.in_(base_ids)).all()
}
for r in rows:
b = bases.get(getattr(r, 'base_id', None))
if b is None:
continue
# 分类:material_base.category 本身**已经是完整路径**
#(实测 'IRIS/原材料/结构/非标OS'、'LICA/生产配件'),
# company_name 是它的首段、material_type 是它的末段 ——
# 再拼一遍会得到 'LICA/LICA/生产配件/生产配件' 这种重复。
# 故直接取用,不拼接。
index[(table, r.id)] = {
'name': (b.name or '').strip(),
'spec': (b.spec_model or '').strip(),
'category': (b.category or '').strip(),
}
return index
# ------------------------------------------------------------------
# 采集
# ------------------------------------------------------------------
@staticmethod
def collect(report_date=None):
"""
采集指定日期的日报数据。
Returns: dict(结构见下方各处注释),供 render() 使用,也可单独用于调试。
"""
start, end = DailyReportService._day_bounds(report_date)
# ---------- 审计日志 ----------
audit_rows = AuditLog.query.filter(
AuditLog.created_at >= start,
AuditLog.created_at < end,
AuditLog.username != SYSTEM_USERNAME,
).all()
by_action = defaultdict(list)
for r in audit_rows:
by_action[canon_action(r.action)].append(r)
create_by_module = Counter((r.module or '未知模块') for r in by_action['CREATE'])
# 【修改】按操作人分组,只保留「变更字段数 >= 阈值」的记录供详列
update_by_operator = defaultdict(list)
for r in by_action['UPDATE']:
update_by_operator[operator_of(r)].append(r)
# ---------- 变更值 / 快照里的 ID 批量解析 ----------
# 「实际审批人ID: 空 → 7」这种显示没有意义,要变成「杜邢宸」。
# 逐条查库是 N+1,load_ref_maps 会按 ref 类型批量查一次。
ref_maps = load_ref_maps(audit_rows)
# ---------- 出库 ----------
outbound_rows = TransOutbound.query.filter(
TransOutbound.outbound_time >= start,
TransOutbound.outbound_time < end,
).order_by(TransOutbound.outbound_time).all()
# ---------- 借库 ----------
borrow_rows = TransBorrow.query.filter(
TransBorrow.borrow_time >= start,
TransBorrow.borrow_time < end,
).order_by(TransBorrow.borrow_time).all()
# ---------- 物料信息批量解析 ----------
pairs = [(r.source_table, r.stock_id) for r in outbound_rows]
pairs += [(r.source_table, r.stock_id) for r in borrow_rows]
material_index = DailyReportService._load_material_index(pairs)
def _item_of(row):
info = material_index.get(
((row.source_table or '').strip(), row.stock_id), {}
)
return {
'sku': row.sku or '',
'name': info.get('name') or '',
'spec': info.get('spec') or '',
'category': info.get('category') or '',
'quantity': float(row.quantity or 0),
}
# 出库按单号聚合
outbound_docs = defaultdict(list)
for r in outbound_rows:
outbound_docs[r.outbound_no or f"#{r.id}"].append(r)
outbound_list = []
for no, rows in outbound_docs.items():
head = rows[0]
outbound_list.append({
'no': no,
'operator': fmt_name(head.operator_name),
'consumer': head.consumer_name or '',
'type': OutboundType.label(head.outbound_type),
'time': head.outbound_time.strftime('%H:%M') if head.outbound_time else '',
'items': [_item_of(r) for r in rows],
})
outbound_list.sort(key=lambda d: d['time'])
# 借库按单号聚合
borrow_docs = defaultdict(list)
for r in borrow_rows:
borrow_docs[r.borrow_no or f"#{r.id}"].append(r)
borrow_list = []
for no, rows in borrow_docs.items():
head = rows[0]
borrow_list.append({
'no': no,
'borrower': fmt_name(head.borrower_name),
'operator': fmt_name(head.dispatch_operator),
'returned': bool(head.is_returned),
'time': head.borrow_time.strftime('%H:%M') if head.borrow_time else '',
'items': [_item_of(r) for r in rows],
})
borrow_list.sort(key=lambda d: d['time'])
# 附件要按时间顺序展开全量明细,故快照式地保留原始行
# (正文只用到下面的计数字段,用不到这些行)。
audit_rows_sorted = sorted(audit_rows, key=lambda r: r.created_at or start)
by_action_sorted = defaultdict(list)
for r in audit_rows_sorted:
by_action_sorted[canon_action(r.action)].append(r)
return {
'report_date': start.strftime('%Y-%m-%d'),
'audit': {
'create_total': len(by_action['CREATE']),
'update_total': len(by_action['UPDATE']),
'delete_total': len(by_action['DELETE']),
# 附件明细用的原始行(按时间升序);正文只用上面的计数
'create_rows': by_action_sorted['CREATE'],
'update_rows': by_action_sorted['UPDATE'],
'delete_rows': by_action_sorted['DELETE'],
'create_by_module': create_by_module.most_common(),
'update_by_operator': {
k: v for k, v in sorted(
update_by_operator.items(), key=lambda kv: -len(kv[1])
)
},
# 变更值里 ID → 实体的解析结果(key 为 ref 类型),渲染时用
'ref_maps': ref_maps,
},
'outbound': outbound_list,
'borrow': borrow_list,
}
# ------------------------------------------------------------------
# 渲染
# ------------------------------------------------------------------
@staticmethod
def _render_changes(lines, module, changes, ref_maps):
"""
输出一组字段变更:值先翻译,翻不动才显示原值(见 resolved_changes)。
正文用 fmt_value(空值显示「空」、超长截断 40 字);附表走 audit_export
的 cell(空值留空、截断 2000 字)—— 两处对格式化的要求相反。
"""
for label, old, new in resolved_changes(module, changes, ref_maps):
lines.append(
f" · {label}: {fmt_value(old)} → {fmt_value(new)}"
)
@staticmethod
def _render_audit(lines, audit):
# ---------- 新增 ----------
lines.append("【新增】")
total = audit['create_total']
lines.append(f" 总量: {total} 条")
if total == 0:
lines.append(" 今日无新增记录")
else:
mods = audit['create_by_module']
for name, cnt in mods[:DailyReportService.MODULE_LIMIT]:
lines.append(f" · {name}: {cnt} 条")
if len(mods) > DailyReportService.MODULE_LIMIT:
rest = len(mods) - DailyReportService.MODULE_LIMIT
lines.append(f" (另有 {rest} 个模块未列出)")
lines.append("")
# ---------- 修改 ----------
lines.append("【修改】")
total = audit['update_total']
lines.append(f" 总量: {total} 条")
if total == 0:
lines.append(" 今日无修改记录")
lines.append("")
return
# ★ 只详列「有实质变更」的操作人;其余合并成一行报数。
# 否则每天几十位操作人各占两行「无变更字段≥3的记录」,日报一半是废话。
quiet_ops = 0
quiet_records = 0
for operator, rows in audit['update_by_operator'].items():
detailed = [r for r in rows
if len(changes_of(r)) >= DailyReportService.DETAIL_MIN_FIELDS]
if not detailed:
quiet_ops += 1
quiet_records += len(rows)
continue
lines.append(f" 【{operator}】共 {len(rows)} 条")
lines.append(f" 以下记录变更字段≥{DailyReportService.DETAIL_MIN_FIELDS}:")
for r in detailed[:DailyReportService.DETAIL_LIMIT]:
lines.append(f" · [{r.module or '未知模块'}] {r.url or ''}")
DailyReportService._render_changes(
lines, r.module, changes_of(r),
audit.get('ref_maps') or {},
)
if len(detailed) > DailyReportService.DETAIL_LIMIT:
rest = len(detailed) - DailyReportService.DETAIL_LIMIT
lines.append(f" (另有 {rest} 条未列出)")
lines.append("")
if quiet_ops:
lines.append(
f" 另有 {quiet_ops} 位操作人(共 {quiet_records} 条)"
f"无变更字段≥{DailyReportService.DETAIL_MIN_FIELDS}的记录,未逐条列出"
)
lines.append("")
@staticmethod
def _render_docs(lines, title, docs, head_fields, empty_text):
lines.append(f"【{title}】")
lines.append(f" 总量: {len(docs)} 条")
if not docs:
lines.append(f" {empty_text}")
lines.append("")
return
for d in docs[:DailyReportService.DOC_LIMIT]:
lines.append(f" · {d['no']}")
lines.append(" " + " | ".join(f"{k}: {d[v]}" for k, v in head_fields))
for it in d['items']:
lines.append(
f" 物料: {it['name']} | 规格: {it['spec'] or UNKNOWN} | "
f"数量: {it['quantity']} | 分类: {it['category'] or UNKNOWN}"
)
if len(docs) > DailyReportService.DOC_LIMIT:
rest = len(docs) - DailyReportService.DOC_LIMIT
lines.append(f" (另有 {rest} 张单据未列出)")
lines.append("")
@staticmethod
def render(stats):
"""把 collect() 的结果渲染成纯文本正文。返回 (subject, body)。"""
date_str = stats['report_date']
lines = [f"📋 MOM系统日报 — {date_str}", "=" * 60, ""]
DailyReportService._render_audit(lines, stats['audit'])
lines.append("【删除】")
lines.append(f" 总量: {stats['audit']['delete_total']} 条")
lines.append("")
DailyReportService._render_docs(
lines, "出库", stats['outbound'],
head_fields=[('操作员', 'operator'), ('客户', 'consumer'), ('类型', 'type')],
empty_text="今日无出库记录",
)
DailyReportService._render_docs(
lines, "借库", stats['borrow'],
head_fields=[('借用人', 'borrower'), ('库管', 'operator')],
empty_text="今日无借库记录",
)
# ★ 必须显式告知附件的存在与文件名。正文里的数字是**被截断后**的
# (每个操作人最多 DETAIL_LIMIT 条、新增只有模块计数),不看附件
# 会把摘要误当成全部 —— 这正是「正文摘要 + 附件全量」最容易踩的坑。
lines.append("=" * 60)
lines.append(
f"📎 以上为摘要。全量明细(每条新增/修改/删除记录、全部变更字段、"
f"所有出库借库单)见附件:{DailyReportService.excel_filename(date_str)}"
)
lines.append("此邮件由 MOM 系统自动发送,请勿回复。")
subject = f"MOM系统日报 {date_str}"
return subject, '\n'.join(lines)
# ------------------------------------------------------------------
# Excel 附件
# ------------------------------------------------------------------
@staticmethod
def excel_filename(date_str):
"""附件文件名。集中在此,避免正文里的提示与实际附件名对不上。"""
return f"MOM系统日报_{date_str}.xlsx"
@staticmethod
def _snapshot_columns(rows, which):
"""
一批记录的快照字段并集 → 列名列表(按出现频次降序)。
频次降序而不是首次出现顺序:同一个模块的记录字段一致,高频字段排在
左边,跨模块时才排到右侧,人一眼就能从左往右找到想要的列。
返回 (列名列表, 被丢弃的列数) —— 丢弃数交给调用方显式写进汇总表。
"""
counts = Counter()
for r in rows:
for k in snapshot_of(r, which):
counts[k] += 1
cols = [k for k, _ in counts.most_common()]
if len(cols) > EXCEL_SNAPSHOT_COL_LIMIT:
return cols[:EXCEL_SNAPSHOT_COL_LIMIT], len(cols) - EXCEL_SNAPSHOT_COL_LIMIT
return cols, 0
@staticmethod
def render_excel(stats):
"""
把 collect() 的结果渲染成 .xlsx 字节流。
工作表:
汇总 —— 各操作总量、新增按模块、修改按操作人
新增明细 —— 每条新增记录 + 它的完整字段快照(列=字段并集)
修改明细 —— 每条修改记录的每个变更字段占一行(长表,便于筛选透视)
删除明细 —— 每条删除记录 + 删除前快照
出库明细 —— 每个物料行占一行,单据级字段重复填充
借库明细 —— 同上
★ 全程在内存(BytesIO)里构建,**不落盘**。日报是每天一封的定时任务,
一旦落盘就得额外写清理逻辑,否则磁盘会被日复一日地慢慢吃掉;
而这份附件对系统本身没有任何留存价值(收件人邮箱里已有一份)。
★ 「修改明细」用长表而不是宽表:宽表要取所有记录的变更字段并集,
而不同模块改的字段完全不同,并集轻松上百列,且绝大多数格子是空的。
长表(一条变更一行)的列固定 10 个,想按字段筛"今天所有改过状态的
记录"只需对「字段」列做一次筛选。
"""
import io
from openpyxl import Workbook
audit = stats['audit']
ref_maps = audit.get('ref_maps') or {}
date_str = stats['report_date']
create_rows = audit.get('create_rows') or []
update_rows = audit.get('update_rows') or []
delete_rows = audit.get('delete_rows') or []
notes = [] # 显式记录附件里做过的取舍,写进汇总表
sheets = [] # [(标题, 表头, 数据行, 换行列号)]
# ---------- 新增明细 ----------
create_cols, create_dropped = DailyReportService._snapshot_columns(
create_rows, SNAPSHOT_CREATED)
if create_dropped:
notes.append(
f"「新增明细」快照字段过多,按出现频次省略了 {create_dropped} 个低频列"
)
create_data = []
for r in create_rows:
snap = snapshot_of(r, SNAPSHOT_CREATED)
create_data.append(
daily_report_head_values(r)
+ [cell(snap.get(c), r.module, c, ref_maps) for c in create_cols]
)
sheets.append((
'新增明细',
DAILY_REPORT_HEAD + snapshot_headers(create_cols, DAILY_REPORT_HEAD),
create_data,
(6,), # URL 列自动换行
))
# ---------- 修改明细(长表:一条变更一行)----------
update_data = []
for r in update_rows:
changes = resolved_changes(r.module, changes_of(r), ref_maps)
if not changes:
# ★ 一条记录不能因为"改的全是图片/链接等噪声字段"就在附件里
# 凭空消失 —— 它确实发生过。占位一行,并说明原因。
update_data.append(
daily_report_head_values(r)
+ [0, '(仅变更了图片/链接/更新时间等噪声字段)', '', '']
)
continue
head = daily_report_head_values(r)
for label, old, new in changes:
# old / new 已由 resolved_changes 翻译过,故这里只做格式化,
# 不再传 module —— 重复解析一次不但浪费,还会让读者以为
# 这列的值是原始值。
update_data.append(head + [len(changes), label, cell(old), cell(new)])
sheets.append((
'修改明细',
DAILY_REPORT_HEAD + ['变更字段数', '字段', '旧值', '新值'],
update_data,
(6, 9, 10),
))
# ---------- 删除明细 ----------
delete_cols, delete_dropped = DailyReportService._snapshot_columns(
delete_rows, SNAPSHOT_DELETED)
if delete_dropped:
notes.append(
f"「删除明细」快照字段过多,按出现频次省略了 {delete_dropped} 个低频列"
)
delete_data = []
for r in delete_rows:
snap = snapshot_of(r, SNAPSHOT_DELETED)
delete_data.append(
daily_report_head_values(r)
+ [cell(snap.get(c), r.module, c, ref_maps) for c in delete_cols]
)
sheets.append((
'删除明细',
DAILY_REPORT_HEAD + snapshot_headers(delete_cols, DAILY_REPORT_HEAD),
delete_data,
(6,),
))
# ---------- 出库明细 ----------
outbound_data = []
for d in stats['outbound']:
for it in d['items']:
outbound_data.append([
d['no'], d['time'], d['operator'], d['consumer'], d['type'],
it['name'], it['spec'], it['quantity'], it['category'],
])
sheets.append((
'出库明细',
['出库单号', '时间', '操作员', '客户', '出库类型',
'物料名称', '规格型号', '数量', '分类'],
outbound_data, (),
))
# ---------- 借库明细 ----------
borrow_data = []
for d in stats['borrow']:
for it in d['items']:
borrow_data.append([
d['no'], d['time'], d['borrower'], d['operator'],
'是' if d['returned'] else '否',
it['name'], it['spec'], it['quantity'], it['category'],
])
sheets.append((
'借库明细',
['借出单号', '时间', '借用人', '库管', '是否已归还',
'物料名称', '规格型号', '数量', '分类'],
borrow_data, (),
))
# ---------- 汇总(含上面收集到的 notes)----------
summary_rows = [
('总量', '新增记录', audit['create_total']),
('总量', '修改记录', audit['update_total']),
('总量', '删除记录', audit['delete_total']),
('出库', '单据数', len(stats['outbound'])),
('出库', '物料行数', len(outbound_data)),
('借库', '单据数', len(stats['borrow'])),
('借库', '物料行数', len(borrow_data)),
]
for name, cnt in audit['create_by_module']:
summary_rows.append(('新增·按模块', name, cnt))
for operator, rows in audit['update_by_operator'].items():
summary_rows.append(('修改·按操作人', operator, len(rows)))
# ★ 附件的取舍写在这里,而不是只打日志:收件人看到的是附件,
# 日志他看不到。多一列"说明"专放这类提示。
summary_rows.append(('数据口径', '统计日期', date_str))
summary_rows.append((
'数据口径', '统计范围',
'北京时间当日 00:00–24:00;已排除 system 占位账号',
))
for note in notes:
summary_rows.append(('数据口径', note, ''))
wb = Workbook()
ws = wb.active
ws.title = '汇总'
fill_sheet(ws, ['分类', '项目', '数值/说明'], summary_rows, wrap_cols=(3,))
for title, headers, rows, wrap_cols in sheets:
fill_sheet(wb.create_sheet(title=title), headers, rows, wrap_cols)
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
# ------------------------------------------------------------------
# 发送
# ------------------------------------------------------------------
@staticmethod
def build_report(report_date=None):
"""采集 + 渲染,但不发送 —— 用于预览与调试。返回 (subject, body, stats)。"""
stats = DailyReportService.collect(report_date)
subject, body = DailyReportService.render(stats)
return subject, body, stats
@staticmethod
def send_daily_report(report_date=None, recipients=None):
"""
采集 → 渲染 → 发送。供定时任务与手动触发共用。
recipients 缺省时取 config.MAIL_DAILY_REPORT_RECIPIENTS。
返回 {'subject','recipients','stats'},便于调用方打日志。
"""
from flask import current_app
from app.utils.email_service import send_email
if recipients is None:
raw = current_app.config.get('MAIL_DAILY_REPORT_RECIPIENTS', '') or ''
recipients = [e.strip() for e in raw.split(',') if e.strip()]
if isinstance(recipients, str):
recipients = [e.strip() for e in recipients.split(',') if e.strip()]
if not recipients:
logger.warning("[日报] 未配置收件人(MAIL_DAILY_REPORT_RECIPIENTS),跳过发送")
return {'subject': None, 'recipients': [], 'stats': None}
subject, body, stats = DailyReportService.build_report(report_date)
# ★ 附件失败不连累正文:正文摘要本身是有效信息,发出去远好过整封不发。
# 但**必须记 error**(不是 warning)—— 降级后邮件看起来完全正常,
# 不留下显眼日志的话,附件静默丢失可以持续几个月没人发现。
attachments = None
try:
data = DailyReportService.render_excel(stats)
attachments = [{
'filename': DailyReportService.excel_filename(stats['report_date']),
'data': data,
}]
except Exception as e:
logger.error(f"[日报] Excel 附件生成失败,本次降级为纯文本正文: {e}",
exc_info=True)
send_email(recipients, subject, body, attachments=attachments)
logger.info(
f"[日报] 已发送 {stats['report_date']} → {recipients}"
f"(附件 {len(attachments[0]['data']) if attachments else 0} 字节)"
)
return {'subject': subject, 'recipients': recipients, 'stats': stats}

View File

@ -4,6 +4,7 @@ from app.extensions import db
from app.models.inbound.buy import StockBuy from app.models.inbound.buy import StockBuy
from app.models.inbound.product import StockProduct from app.models.inbound.product import StockProduct
from app.models.base import MaterialBase from app.models.base import MaterialBase
from app.models.purchase import PurchaseRequest
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from sqlalchemy import or_, func, text, and_ from sqlalchemy import or_, func, text, and_
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@ -153,9 +154,27 @@ class BuyInboundService:
in_qty = float(data.get('in_quantity') or 0) in_qty = float(data.get('in_quantity') or 0)
u_price = float(data.get('unit_price') or 0) u_price = float(data.get('unit_price') or 0)
tax_rate = float(data.get('tax_rate') or 0) tax_rate = float(data.get('tax_rate') or 0)
# 计算税后单价
post_tax_price = float(data.get('post_tax_unit_price') or 0) post_tax_price = float(data.get('post_tax_unit_price') or 0)
# [新增] 按单入库:关联采购申请单
request_id = data.get('request_id')
# ★ 补价:库管角色没有 inbound_purchase:unit_price / total_price 权限,
# 采购单接口(api/v1/purchase.py)会把价格字段整个 pop 掉,前端导入时
# 因此带不出价(buy.vue 的 hasUnitPrice/hasTotalPrice 双双落空,
# 两个分支都不进)→ 入库成本被记成 0。
# 这里在落库前从采购申请单补回:价格**不经过前端**,库管依旧看不到
# 采购价,权限边界不变,但成本可追溯。
# 注:采购申请单的 unit_price 存的是**含税**单价(见 purchase/index.vue
# 的列名"含税单价"),需要反算不含税单价。
if request_id and u_price <= 0 and post_tax_price <= 0:
_req = PurchaseRequest.query.get(request_id)
if _req and float(_req.unit_price or 0) > 0:
tax_rate = float(_req.tax_rate or 0)
post_tax_price = float(_req.unit_price)
u_price = post_tax_price / (1 + tax_rate / 100)
# 计算税后单价(保底:只给了不含税价时反推)
if post_tax_price == 0 and u_price > 0: if post_tax_price == 0 and u_price > 0:
tax_multiplier = 1 + (tax_rate / 100) tax_multiplier = 1 + (tax_rate / 100)
post_tax_price = u_price * tax_multiplier post_tax_price = u_price * tax_multiplier
@ -170,9 +189,6 @@ class BuyInboundService:
generated_sku = str(next_global_id).zfill(10) if next_global_id else datetime.now().strftime('%Y%m%d%H%M%S') generated_sku = str(next_global_id).zfill(10) if next_global_id else datetime.now().strftime('%Y%m%d%H%M%S')
final_barcode = data.get('barcode') or generated_sku final_barcode = data.get('barcode') or generated_sku
# [新增] 按单入库:关联采购申请单
request_id = data.get('request_id')
new_stock = StockBuy( new_stock = StockBuy(
base_id=material.id, global_print_id=next_global_id, sku=generated_sku, barcode=final_barcode, base_id=material.id, global_print_id=next_global_id, sku=generated_sku, barcode=final_barcode,
in_date=in_date_val, serial_number=data.get('serial_number'), batch_number=data.get('batch_number'), in_date=in_date_val, serial_number=data.get('serial_number'), batch_number=data.get('batch_number'),
@ -201,8 +217,10 @@ class BuyInboundService:
db.session.flush() # 获取 new_stock.id db.session.flush() # 获取 new_stock.id
# [新增] 按单入库:反写采购申请单状态为"已完成" # [新增] 按单入库:反写采购申请单状态为"已完成"
# 注:原先此处有一句局部 `from app.models.purchase import PurchaseRequest`,
# 它会让 Python 把 PurchaseRequest 视为本函数的局部变量,导致上方补价
# 逻辑(在 import 之前使用)抛 UnboundLocalError。已改用文件顶部的全局导入。
if request_id: if request_id:
from app.models.purchase import PurchaseRequest
purchase_req = db.session.get(PurchaseRequest, request_id) purchase_req = db.session.get(PurchaseRequest, request_id)
if purchase_req: if purchase_req:
if purchase_req.status != 1: if purchase_req.status != 1:

View File

@ -226,6 +226,8 @@ class ProductInboundService:
'sku': material.spec_model or material.name, 'sku': material.spec_model or material.name,
'serial_number': new_stock.serial_number, 'serial_number': new_stock.serial_number,
'quantity': float(new_stock.in_quantity or 0), 'quantity': float(new_stock.in_quantity or 0),
# ★ 公司名决定通知哪个 Track 实例(IRIS / LICA),缺失则回落默认
'company_name': getattr(material, 'company_name', None),
'operator': get_current_operator(), 'operator': get_current_operator(),
}) })
return new_stock return new_stock

View File

@ -265,6 +265,8 @@ class SemiInboundService:
'sku': material.spec_model or material.name, 'sku': material.spec_model or material.name,
'serial_number': webhook_sn, 'serial_number': webhook_sn,
'quantity': float(new_stock.in_quantity or 0), 'quantity': float(new_stock.in_quantity or 0),
# ★ 公司名决定通知哪个 Track 实例(IRIS / LICA),缺失则回落默认
'company_name': getattr(material, 'company_name', None),
'operator': get_current_operator(), 'operator': get_current_operator(),
}) })
return new_stock return new_stock

View File

@ -285,6 +285,13 @@ class OutboundService:
# 在那里引用 approval 会直接 UnboundLocalError(上线时踩过)。 # 在那里引用 approval 会直接 UnboundLocalError(上线时踩过)。
common_data['applicant_id'] = approval.applicant_id common_data['applicant_id'] = approval.applicant_id
# ★ 来源申请单也一并记下(同样必须在 approval 取出**之后**)。只落
# applicant_id 的话,从一条出库明细反查不到它属于哪张单 —— 单号
# request_no、申请说明、明细快照 items_json 全都在申请单上。
# ⚠ 这是**表里真实存在的列**,可以进 common_data;下面 notify_track
# 里那些只发给 Track 的展示字段(request_no / applicant_name)不行。
common_data['request_id'] = approval.id
model_map = { model_map = {
'stock_buy': StockBuy, 'stock_buy': StockBuy,
'stock_semi': StockSemi, 'stock_semi': StockSemi,
@ -359,7 +366,11 @@ class OutboundService:
repair.shipping_date = current_time repair.shipping_date = current_time
# 收集 Track 联动信息(维修单带 serial_number) # 收集 Track 联动信息(维修单带 serial_number)
track_notifications.append((getattr(repair, 'serial_number', None), source_table, quantity)) # ★ 连同 company_name 一起攒起来,发送时按公司路由到对应 Track 实例
track_notifications.append((
getattr(repair, 'serial_number', None), source_table, quantity,
getattr(getattr(repair, 'base', None), 'company_name', None),
))
# 创建出库记录 # 创建出库记录
new_record = TransOutbound( new_record = TransOutbound(
@ -394,7 +405,11 @@ class OutboundService:
raise ValueError(f"库存记录不存在 (ID: {stock_id})") raise ValueError(f"库存记录不存在 (ID: {stock_id})")
# 收集 Track 联动信息(库存表 serial_number = Track 身份证) # 收集 Track 联动信息(库存表 serial_number = Track 身份证)
track_notifications.append((getattr(stock_record, 'serial_number', None), source_table, quantity)) # ★ 连同 company_name 一起攒起来,发送时按公司路由到对应 Track 实例
track_notifications.append((
getattr(stock_record, 'serial_number', None), source_table, quantity,
getattr(getattr(stock_record, 'base', None), 'company_name', None),
))
new_record = TransOutbound( new_record = TransOutbound(
sku=item.get('sku'), sku=item.get('sku'),
@ -419,11 +434,11 @@ class OutboundService:
db.session.commit() db.session.commit()
# ★ 出库后通知 Track(发货出库 → Track 标记"已出库") # ★ 出库后通知 Track(发货出库 → Track 标记"已出库")
# 仅在配置 TRACK_OUTBOUND_WEBHOOK_URL 时生效;notify_track 自身容错不阻断业务 # 按每条的 company_name 路由到对应实例(IRIS / LICA)。
# 这里刻意**不**显式传 url:显式 url 优先级最高,会一律打到默认实例,
# 导致 LICA 的出库永远收不到联动。notify_track 自身容错,不阻断业务。
try: try:
from flask import current_app for sn, src_tbl, qty, company in track_notifications:
outbound_url = current_app.config.get('TRACK_OUTBOUND_WEBHOOK_URL')
for sn, src_tbl, qty in track_notifications:
if not sn: if not sn:
continue continue
notify_track({ notify_track({
@ -432,8 +447,29 @@ class OutboundService:
'serial_number': str(sn), 'serial_number': str(sn),
'quantity': float(qty or 0), 'quantity': float(qty or 0),
'outbound_type': common_data['outbound_type'], 'outbound_type': common_data['outbound_type'],
'company_name': company,
'operator': get_current_operator(), 'operator': get_current_operator(),
}, url=outbound_url) # ↓ 单据上下文:Track 侧据此在产品详情展示「这台设备对应
# MOM 的哪张出库单」。这几个字段整批共用,直接读
# common_data / approval(两者在 notify_track 处都在作用域内)。
# ⚠ 只发给 Track 的展示字段,**不要**写进 common_data ——
# 它会被 ** 展开进 TransOutbound(**common_data),
# 多一个不在表里的键会直接抛 TypeError。
'outbound_no': common_data['outbound_no'],
'request_no': approval.request_no,
'consumer_name': common_data['consumer_name'],
'applicant_name': approval._get_user_name(approval.applicant_id),
# ⚠️ 必须带 +08:00 偏移,不能直接发 current_time。
# current_time 是 datetime.now(beijing_tz) 去掉 tzinfo 后的
# **北京墙上时间**(MOM 自己的库是 naive 的 timestamp,所以
# 本地这么存没问题)。但它一旦以无偏移的形式发出去,
# Track 的 timestamptz 列会按 **UTC** 解释,实测整整差 8
# 小时(发 15:00 存成 15:00+00,前端在北京渲染成 23:00)。
# 这里把 tzinfo 装回去还原真实时刻,发出去就是明确的
# "2026-09-23T15:00:00+08:00"。
'outbound_time': current_time.replace(tzinfo=beijing_tz).isoformat(),
'remark': common_data['remark'],
})
except Exception as e: except Exception as e:
import logging import logging
logging.getLogger(__name__).warning(f"⚠️ Track 出库通知失败: {e}") logging.getLogger(__name__).warning(f"⚠️ Track 出库通知失败: {e}")

View File

@ -0,0 +1,422 @@
"""原单退回(逆向物流)— 业务逻辑层
从 `app/api/v1/inbound/stock.py` 抽出来的。**抽出来的唯一动机**是给内部接口
(Track → MOM 生产报废)复用:那条链要「退回(不良品) + 提报废申请」在一个事务里
完成,而视图函数夹着 JWT 依赖(`get_current_company_filter()` / `_normalize_user_id()`),
内部接口没有 JWT,直接调会抛错。
抽的时候刻意**不改行为**,只做两件事:
· 把 JWT 依赖变成**显式入参**(`company_limit` / `operator_name`);
· 把 `commit` 变成开关,让调用方能接管提交点。
═══════════════════════════════════════════════════════════════════════════
为什么「已出库的料要报废」必须走退回
═══════════════════════════════════════════════════════════════════════════
料一经出库,那条库存行的 available_quantity / stock_quantity 在出库时就已扣掉
(见 outbound_service 的 restore_then_deduct)。而报废的上限正是可用库存,
所以对同一批料**发起不了**标准库存行报废。
本模块的 `is_defective=True` 分支就是这个矛盾的解:坏件转进独立的
`trans_defective_goods` 在管台账,**原库存表分毫不动** —— 既不重复扣减,
也不把坏件混回可分配池(status 是行级属性,加回原行会让整行良品被连坐隔离)。
⚠️ 因此:**不要**为了「让报废更直接」而在这里给库存行加数量。那是重复扣减。
"""
import logging
from app.extensions import db, beijing_time
from app.models.outbound import TransOutbound
from app.models.transaction import (
TransReturn,
TransDefectiveGoods,
RETURN_TYPE_GOOD,
RETURN_TYPE_DEFECTIVE,
DEFECTIVE_STATUS_PENDING,
)
from app.services.inventory_reservation import (
STOCK_STATUS_IN_STOCK,
stock_model_map,
)
logger = logging.getLogger(__name__)
def lock_source_stock_row(source_table, stock_id):
"""解析并锁定退回目标的**原库存行**。业务不满足即抛 ValueError。
三条 Fail-Closed 规则:
1. source_table 必须是三张库存表之一 —— 维修单等非库存来源没有可退回的行;
2. 库存行必须仍然存在 —— 入库模块会物理删除库存行(见
buy/semi/product_service 的 db.session.delete(stock)),实测 1077 条
出库记录中已有 7 条指向不存在的行;
3. 调用方拿到行后还需自行做公司隔离与状态校验(见 assert_company_owns)。
★ 为什么必须加锁:本行随后会被加减数量,且与出库/报废/状态变更并发。
不加锁会出现「读-改-写」丢失更新(lost update)。
★ 与原 API 层 `get_stock_model` 的差异:这里用 `inventory_reservation.stock_model_map()`。
服务层不得反向 import API 层(会成循环依赖)。
"""
model = stock_model_map().get(source_table)
if model is None:
raise ValueError(
f'来源「{source_table or "(空)"}」不支持退回,'
f'仅支持 stock_buy / stock_semi / stock_product'
)
row = model.query.with_for_update().get(stock_id) if stock_id else None
if not row:
raise ValueError(
f'原库存行已不存在({source_table}#{stock_id}),无法自动退回,'
f'请改走入库流程手工登记这批实物'
)
return row
def assert_company_owns(row, company_limit):
"""行级多租户隔离:非跨域用户只能操作本公司库存。不满足即抛 PermissionError。
口径与扫码出库(OutboundService.get_stock_by_barcode)、状态变更接口完全一致
—— 都走 MaterialBase.company_name,避免多处隔离逻辑分叉。
★ `company_limit` 是**显式入参**,不在函数内读 JWT:内部接口(Track → MOM)
没有 JWT,`get_current_company_filter()` 里的 `get_jwt()` 会抛 RuntimeError,
被全局 errorhandler 吞成一条没有信息的 500,排查极难。
"""
if company_limit is None:
return
base = getattr(row, 'base', None)
if (company_limit == '__NO_COMPANY__' or base is None
or (base.company_name or '') != company_limit):
raise PermissionError('无权操作其他公司的库存')
def return_from_outbound(*, outbound_id, return_qty, is_defective, reason=None,
need_reissue=False, reissue_qty=None,
reissue_applicant_id=None, operator_name='System',
company_limit=None, source_ref=None, commit=True):
"""原单退回。返回 dict(形状与 API 响应的 data 字段一致)。
:param company_limit: 调用方所在公司(超管/内部接口传 None = 不限)
:param source_ref: 外部系统唯一引用(`<company>:<外部单号>`),供判重
:param commit: False 时只 flush,提交权交给调用方(组合操作用)
⚠️ 调用方负责处理异常与 rollback;本函数不吞异常。
"""
try:
return_qty = float(return_qty or 0)
except (TypeError, ValueError):
raise ValueError('return_qty 无效')
if return_qty <= 0:
raise ValueError('退回数量必须大于 0')
if is_defective is None:
raise ValueError('is_defective 为必填(true=不良品退回,false=良品退回)')
is_defective = bool(is_defective)
# ---- 1. 锁定原出库明细并校验退回额度 ----
# ★ 行锁不可省:并发两笔退回若各自读到相同的 returned_quantity,会双双
# 通过额度校验,合计退回量超过出库量 —— 凭空多出库存。
outbound = TransOutbound.query.with_for_update().get(outbound_id)
if not outbound:
raise ValueError(f'出库记录不存在(ID: {outbound_id})')
shipped = float(outbound.quantity or 0)
returned = float(outbound.returned_quantity or 0)
returnable = shipped - returned
if return_qty > returnable:
raise ValueError(
f'退回数量({return_qty})超出可退额度({returnable}):'
f'原出库 {shipped},已退回 {returned}'
)
# ---- 2. 锁定原库存行 + 多租户隔离 ----
stock_row = lock_source_stock_row(outbound.source_table, outbound.stock_id)
assert_company_owns(stock_row, company_limit)
# 公司快照:退回看板的隔离判定不能依赖 join 链 —— 源库存行会被入库模块
# 物理删除,届时链路断裂会让记录对普通用户静默消失。见 TransReturn 注释。
_base = getattr(stock_row, 'base', None)
snapshot_company = ((_base.company_name if _base else '') or '').strip() or None
goods = None
if is_defective:
# ================= 不良品分支 =================
# ★ 原库存表**分毫不动**:坏件全程存放于独立在管台账,既不占用库存
# 数量、也不改库存行 status,从根上杜绝「坏件混进可分配池」。
base = getattr(stock_row, 'base', None)
goods = TransDefectiveGoods(
outbound_id=outbound.id,
source_table=outbound.source_table,
stock_id=outbound.stock_id,
base_id=getattr(stock_row, 'base_id', None),
sku=getattr(stock_row, 'sku', '') or '',
material_name=(base.name if base else '') or '',
spec_model=(base.spec_model if base else '') or '',
# ★ 原库位/批次快照:此刻 stock_row 已 with_for_update() 锁在手里,
# 是**唯一**能可靠取到这两个值的时机 —— 源库存行日后会被入库模块
# 物理删除,届时回查落空,报表上就只剩 "-"。
# 取值口径与 scrap.py 的 _from_stock 一致(成品表无 batch_number,
# 回退到 serial_number)。
warehouse_location=getattr(stock_row, 'warehouse_location', '') or '',
batch_number=(getattr(stock_row, 'batch_number', '')
or getattr(stock_row, 'serial_number', '') or ''),
quantity=return_qty,
remaining_qty=return_qty,
status=DEFECTIVE_STATUS_PENDING,
company_name=(base.company_name if base else '') or '',
reason=reason,
operator=operator_name,
)
db.session.add(goods)
outcome = '不良品已转入在管台账'
else:
# ================= 良品分支 =================
# ★ 状态防呆:把良品加回一个已冻结/不良品的行,会让良品被该行的状态
# 连带隔离(status 是行级属性)—— 静默造成良品不可用。宁可报错让
# 人先决定该行的归属。
current = (stock_row.status or '').strip()
if current != STOCK_STATUS_IN_STOCK:
raise ValueError(
f'原库存行当前状态为「{current or "未设置"}」,'
f'良品退回要求该行处于「{STOCK_STATUS_IN_STOCK}」状态'
)
stock_row.stock_quantity = float(stock_row.stock_quantity or 0) + return_qty
stock_row.available_quantity = float(stock_row.available_quantity or 0) + return_qty
outcome = '良品已加回原库存'
# ---- 3. 累加退回额度 + 写退回流水 ----
outbound.returned_quantity = returned + return_qty
ledger = TransReturn(
outbound_id=outbound.id,
stock_id=outbound.stock_id,
source_table=outbound.source_table,
sku=outbound.sku,
return_qty=return_qty,
return_type=RETURN_TYPE_DEFECTIVE if is_defective else RETURN_TYPE_GOOD,
reason=reason,
operator=operator_name,
company_name=snapshot_company,
source_ref=(source_ref or '').strip() or None,
)
db.session.add(ledger)
# ★ 这个 flush 不能省:要拿 ledger.id 回填在管台账,补发单也要 source_return_id
db.session.flush()
# 在管台账回填来源流水 id,形成「出库 → 退回流水 → 在管台账」的追溯闭环
if goods is not None:
goods.return_id = ledger.id
# ==================================================================
# ---- 4. 补发(可选)----
# 退回后申请人往往**仍然需要这件东西**(尤其是坏件 —— 原需求并未
# 被满足)。勾选即自动生成一张**免审批**的出库单并关联回本笔退回,
# 使「退回 → 补发」形成闭环;否则现场只能靠人记住再手建一张单,
# 而那张单与原单看不出任何关系。
#
# ★ 库存不足时**整笔回滚**(下面的 reserve_for_items 会抛错)。
# 若只让补发静默失败,「需要补发」的意图就丢了 —— 那正是本功能
# 要解决的问题。回滚后库管会看到明确提示,可取消勾选重试。
# ==================================================================
reissue = None
if need_reissue:
reissue, reissue_qty = _create_reissue(
outbound=outbound, stock_row=stock_row, ledger=ledger,
return_qty=return_qty, reissue_qty=reissue_qty,
reissue_applicant_id=reissue_applicant_id,
company_limit=company_limit,
)
if commit:
db.session.commit()
else:
db.session.flush()
return {
'outbound_id': outbound.id,
'outbound_no': outbound.outbound_no or '',
'return_id': ledger.id,
'return_type': ledger.return_type,
'return_qty': return_qty,
'returned_quantity': float(outbound.returned_quantity),
'returnable_quantity': shipped - float(outbound.returned_quantity),
'defective_goods_id': goods.id if goods is not None else None,
'company_name': snapshot_company or '',
'product_id': getattr(stock_row, 'base_id', None),
'outcome': outcome,
'reissue': ({
'id': reissue.id,
'request_no': reissue.request_no,
'quantity': reissue_qty,
} if reissue else None),
}
def _create_reissue(*, outbound, stock_row, ledger, return_qty, reissue_qty,
reissue_applicant_id, company_limit):
"""生成免审批补发单。返回 (reissue, reissue_qty)。失败即抛错(整笔回滚)。"""
# 单号生成器在 OutboundApprovalService 上(不在 OutboundService)
from app.services.outbound_service import OutboundApprovalService
from app.services.inventory_reservation import reserve_for_items
from app.models.outbound import OutboundApproval
if reissue_qty is None:
reissue_qty = return_qty # 默认与本次退回量一致
try:
reissue_qty = float(reissue_qty)
except (TypeError, ValueError):
raise ValueError('补发数量格式无效')
if reissue_qty <= 0:
raise ValueError('补发数量必须大于 0')
if reissue_qty > return_qty:
raise ValueError(
f'补发数量({reissue_qty})不能大于本次退回数量({return_qty})'
)
base = getattr(stock_row, 'base', None)
if base is None:
raise ValueError('原库存行的物料主数据已不存在,无法生成补发单')
# 提交即预占,strict=True —— 与出库申请同一口径,不足即整单失败
reserved_items, _shortages = reserve_for_items(
[{
'base_id': base.id,
'name': base.name or '',
'spec_model': base.spec_model or '',
'quantity': reissue_qty,
}],
company_limit=company_limit,
strict=True,
)
# ★ 申请人(补发给谁)优先级:
# ① 前端显式指定 reissue_applicant_id —— 现场最清楚该给谁;
# ② 回退到原出库明细记录的 applicant_id(创建出库时从审批单带出的
# 真实原申请人);
# ③ 两者都没有 → **报错要求指定**。
#
# ★ 绝不回退为「当前操作人」:补发是**原申请人的需求**,挂到办理
# 退回的库管名下逻辑不通 —— 那张单会出现在库管的「我的申请」里,
# 而真正该拿东西的人什么也看不到。
# ⚠ 存量出库明细的 applicant_id 为 NULL(实测 1364 行中 1127 行为 NULL,
# 历史无从回填),此时必须由调用方明确指定 —— 宁可多一步,也不猜错人。
if reissue_applicant_id:
try:
_applicant = int(reissue_applicant_id)
except (TypeError, ValueError):
raise ValueError('补发申请人ID格式无效')
from app.models.system import SysUser
if not SysUser.query.get(_applicant):
raise ValueError(f'补发申请人不存在(ID:{reissue_applicant_id})')
elif outbound.applicant_id:
_applicant = int(outbound.applicant_id)
else:
raise ValueError(
'无法确定补发单申请人:这张出库单产生于「申请人」字段上线之前,'
'请指定「补发给谁」'
)
reissue = OutboundApproval(
request_no=OutboundApprovalService.generate_request_no(),
applicant_id=_applicant,
outbound_type=outbound.outbound_type,
# 免审批:原需求已经批过一次,补发只是兑现它,重复审批是负担
status=1,
approved_at=beijing_time(),
source_return_id=ledger.id,
remark=(f'原单退回补发(原出库单 {outbound.outbound_no or outbound.id}'
f',原领用人 {outbound.consumer_name or "未知"})'),
)
reissue.set_items(reserved_items)
reissue.allowed_approvers = '[]'
db.session.add(reissue)
db.session.flush()
return reissue, reissue_qty
# =============================================================================
# 生产报废:退回(不良品) + 提交报废申请 —— **一个原子操作**
# =============================================================================
def return_and_submit_production_scrap(*, outbound_id, return_qty, applicant_id,
operator_name='Track系统',
company_limit=None, reason=None,
reason_category='PRODUCTION',
source_ref=None, submit_scrap=True,
scrap_qty=None):
"""生产领用的料报废:先退回登记为在管不良品,(可选)再提交报废申请。
:param submit_scrap: True = 退回并提报废申请(一步到位);
False = 只退回登记为在管不良品(以后可能修好回库)
:param source_ref: `<company>:<外部单号>`,幂等锚点
★ 本函数是**唯一提交点**。退回段与报废申请段共用同一个 db.session,
任一步抛错 → 调用方 rollback() → 整笔回滚。绝不会出现
「料已退回成在管不良品、但报废申请没提交」这种半截状态。
★ 为什么 scrap_qty 允许小于 return_qty:本次未必全废(部分修好、
部分继续用)。但**不能大于** —— 那必然生成一张执行不了的申请单
(在管台账的 cap 就是本次退回量)。
⚠️ 只走不良品分支(is_defective 恒 True)。良品分支会往库存行加数量,
对外部接口来说那等于凭空造库存,爆炸半径太大,不开放。
"""
from app.services.scrap_approval_service import ScrapApprovalService
if submit_scrap and scrap_qty is None:
scrap_qty = return_qty
if scrap_qty is not None:
try:
scrap_qty = float(scrap_qty)
except (TypeError, ValueError):
raise ValueError('scrap_qty 无效')
if scrap_qty <= 0:
raise ValueError('scrap_qty 必须大于 0')
if scrap_qty > float(return_qty):
raise ValueError(
f'scrap_qty({scrap_qty})不能大于本次退回数量({return_qty})'
)
# ---- 第 1 段:退回(只 flush,不 commit)----
ret = return_from_outbound(
outbound_id=outbound_id,
return_qty=return_qty,
is_defective=True, # ★ 恒为不良品,不开放良品分支
reason=reason,
need_reissue=False, # ★ 补发不开放给外部接口
operator_name=operator_name,
company_limit=company_limit,
source_ref=source_ref,
commit=False,
)
if not ret.get('defective_goods_id'):
# 走到这里说明撤回分支出了问题(is_defective=True 必然产出在管行)
raise ValueError('内部错误:未生成在管不良品记录')
# ---- 第 2 段:报废申请(只 flush,不 commit)----
scrap = None
if submit_scrap:
scrap = ScrapApprovalService.submit_approval(
applicant_id=applicant_id,
items=[{
'source_table': 'trans_defective_goods',
'stock_id': ret['defective_goods_id'],
'scrap_qty': scrap_qty,
}],
remark=reason,
reason_category=reason_category,
company_name=ret.get('company_name') or None,
source_ref=source_ref,
commit=False,
)
# ---- 唯一提交点 ----
db.session.commit()
logger.info(
f"[ProductionScrap] 受理完成 source_ref={source_ref} "
f"outbound={outbound_id} qty={return_qty} submit_scrap={submit_scrap} "
f"defective_goods={ret.get('defective_goods_id')} "
f"request_no={getattr(scrap, 'request_no', None)}"
)
return ret, scrap

View File

@ -29,6 +29,22 @@ def _beijing():
# ============================================================================= # =============================================================================
SCRAP_ALWAYS_REQUIRES_APPROVAL = True SCRAP_ALWAYS_REQUIRES_APPROVAL = True
# =============================================================================
# ★ 默认审批角色(角色级审批)
#
# 提交时若不指定具体审批人,名单回落成这两个角色 —— 「有主管权限的都能看到、
# 都能审批」,谁审就记谁(actual_approver_id)。
#
# ★ 刻意**不含 WAREHOUSE_MGR**:库管是报废的执行人(scrap_execute),
# 让他同时能审批,就把「申请 → 审批 → 执行」的职责分离塌缩成一个人,
# 本模块反复强调的那道关卡就没了。与出库/借库的先例一致
# (outbound.py 的 _default_approvers = SUPERVISOR + SUPER_ADMIN)。
# =============================================================================
DEFAULT_SCRAP_APPROVER_ROLES = ('SUPERVISOR', 'SUPER_ADMIN')
# allowed_approvers 条目里允许出现的 type 值
_APPROVER_TYPES = ('user', 'role')
# 注:原先此处有 _stock_models() 硬编码三张库存表。来源差异已全部收敛到 # 注:原先此处有 _stock_models() 硬编码三张库存表。来源差异已全部收敛到
# app/services/scrap_sources.py 的来源适配层(它复用 # app/services/scrap_sources.py 的来源适配层(它复用
# inventory_reservation.stock_model_map(),避免第四份重复定义), # inventory_reservation.stock_model_map(),避免第四份重复定义),
@ -37,6 +53,139 @@ SCRAP_ALWAYS_REQUIRES_APPROVAL = True
class ScrapApprovalService: class ScrapApprovalService:
# ------------------------------------------------------------------
# 审批人判定(★ 单一事实来源:approve / 详情查看 / 列表 scope=pending 共用)
# ------------------------------------------------------------------
@staticmethod
def _approver_entries(allowed):
"""allowed_approvers JSON → (user_ids:set, roles:set)。
★ 四处判定(approve / 详情查看 / 列表 pending / 提交)都走这一个解析,
避免四份逻辑各自漂移。脏条目(非 dict、无 value)直接跳过。
"""
users, roles = set(), set()
for a in (allowed or []):
if not isinstance(a, dict):
continue
atype = str(a.get('type') or '').strip().lower()
value = a.get('value')
if value is None or str(value).strip() == '':
continue
if atype == 'user':
users.add(str(value).strip())
elif atype == 'role':
roles.add(str(value).strip().upper())
return users, roles
@staticmethod
def resolve_operator_role(operator_id):
"""按 id 反查操作人角色(SysUser.role,大写)。查不到返回 ''。
★ 以**数据库**为准,不用 JWT 里的 role claim —— 角色刚被改过时两者会不一致,
而审批放行必须以「此刻这个人是什么角色」为准。
"""
try:
from app.models.system import SysUser
u = SysUser.query.get(int(operator_id))
return (u.role or '').upper() if u else ''
except Exception:
return ''
@staticmethod
def can_operator_approve(req, operator_id, operator_role=None):
"""操作人是否有权审批这张单(名单匹配 user 或 role)。
★ Fail-Closed:名单为空(或全是无 value 的脏条目)→ **拒绝所有人**。
这是历史上「名单为空则人人可审」漏洞的修复点,不得改回
`if entries and x not in entries` 那种短路写法 —— 那种写法在名单为空时
条件恒假,等于没审批。
★ 刻意**不做 SUPER_ADMIN 无条件旁路**(出库的 can_approve 有这条)。
理由:新单的默认名单已含 SUPER_ADMIN 角色,超管本来就能审;
再加一条无条件旁路,等于在「必须被列名」这条唯一规则上开洞。
"""
users, roles = ScrapApprovalService._approver_entries(req.get_allowed_approvers())
if not users and not roles:
return False
if str(operator_id) in users:
return True
role = operator_role
if role is None:
role = ScrapApprovalService.resolve_operator_role(operator_id)
return bool(role) and str(role).upper() in roles
@staticmethod
def assert_same_company(req, operator_id):
"""角色级审批的**必要配套**:公司隔离。
主管共 6 人(IRIS 5 / LICA 1)。只按角色放行而不比公司,
LICA 的主管就能审 IRIS 的报废单 —— 这是真实的跨公司越权通道。
口径:申请人所属公司(req.company_name,提交时的快照)vs 操作人公司
(SysUser.department,「部门」在 MOM 里就是公司名)。
· req.company_name 为空(本列上线前的旧单 / 申请人已被删)→ **放行**。
这是刻意的 fail-open:只对存量生效,而且单据仍受名单约束,
不会把历史在途单永久卡死。
· 操作人是超管 / 跨域(无公司归属)→ 放行。
· 两侧都有值且不等 → PermissionError。
"""
req_company = (req.company_name or '').strip()
if not req_company:
return
try:
from app.models.system import SysUser
op = SysUser.query.get(int(operator_id))
except Exception:
return
if not op:
return
op_company = (op.department or '').strip()
role = (op.role or '').upper()
# 超管跨域放行(与 get_current_company_filter 对超管返回 None 同一口径)
if role == 'SUPER_ADMIN' or not op_company:
return
if op_company != req_company:
raise PermissionError("无权审批其他公司的报废申请")
@staticmethod
def _sanitize_approvers(raw):
"""校验并清洗外部传入的 allowed_approvers,返回规范化列表。
Fail-Closed 规则:
· 只接受 type ∈ {user, role} 且 value 非空的 dict,其余条目**丢弃并告警**;
· type=user 的值必须能转 int 且**用户在库中存在** —— 否则丢弃。
不校验会造出一张「指定的审批人根本不存在」的死单,谁审不了、只能找管理员;
· type=role 的值 strip().upper(),未知角色码只告警不拒绝
(库里有 PURCHASE 这类 UserRole 常量里没有的历史角色码,硬校验会误伤)。
"""
cleaned = []
for a in (raw or []):
if not isinstance(a, dict):
logger.warning(f"[ScrapApproval] 忽略非法审批人条目(非对象): {a!r}")
continue
atype = str(a.get('type') or '').strip().lower()
value = a.get('value')
if atype not in _APPROVER_TYPES or value is None or str(value).strip() == '':
logger.warning(f"[ScrapApproval] 忽略非法审批人条目: {a!r}")
continue
if atype == 'user':
try:
uid = int(value)
except (TypeError, ValueError):
logger.warning(f"[ScrapApproval] 忽略无效的审批人ID: {value!r}")
continue
from app.models.system import SysUser
if not SysUser.query.get(uid):
logger.warning(f"[ScrapApproval] 忽略不存在的审批人ID: {uid}")
continue
cleaned.append({'type': 'user', 'value': uid})
else:
role_code = str(value).strip().upper()[:50]
logger.warning(f"[ScrapApproval] 使用角色级审批人: {role_code}(未校验角色码是否存在)")
cleaned.append({'type': 'role', 'value': role_code})
return cleaned
@staticmethod @staticmethod
def generate_request_no(): def generate_request_no():
now = _beijing() now = _beijing()
@ -50,15 +199,39 @@ class ScrapApprovalService:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@staticmethod @staticmethod
def submit_approval(applicant_id, items, allowed_approvers=None, remark=None, def submit_approval(applicant_id, items, allowed_approvers=None, remark=None,
approver_id=None, force_approval=False): approver_id=None, force_approval=False,
reason_category=None, company_name=None, source_ref=None,
commit=True):
""" """
提交报废申请(仅锁定“意向”,不扣库存;扣减在库管执行时进行) 提交报废申请(仅锁定“意向”,不扣库存;扣减在库管执行时进行)
items 每项必须包含 source_table + stock_id(精准实物),可带 scrap_qty / 快照字段。 items 每项必须包含 source_table + stock_id(精准实物),可带 scrap_qty / 快照字段。
审批人有两条路,优先看 approver_id:
· approver_id 有值 → 名单 = [{"type":"user","value":N}](旧路径,行为不变);
· approver_id 为空 → 用 allowed_approvers;它也为空则回落
DEFAULT_SCRAP_APPROVER_ROLES(角色级审批:有主管权限的都能审)。
reason_category / company_name / source_ref:
生产报废(Track → MOM)用。company_name 是角色级审批做公司隔离的前提。
commit=False:只 flush,把提交权交给调用方 —— 供「退回 + 提报废申请」
组合成一个原子操作(见 app/services/return_service.py)。
⚠️ 调用方拿到的是**未提交**的对象,必须在自己的事务里 commit/rollback。
""" """
from app.models.scrap_approval import normalize_scrap_category
if not items: if not items:
raise ValueError("报废明细不能为空") raise ValueError("报废明细不能为空")
# 分类码在入口就归一化:未知值直接抛,别把脏码写进库里等统计时才发现。
# ★ 不传 = **库存报废**:MOM 自身流程(界面选库存行报废、不良品看板、
# 借还记录)走下来的都算库存报废;只有 Track 那条链会显式传 PRODUCTION
# (生产报废)。这样「走 Track 的 = 生产报废、其余的 = 库存报废」这条
# 二分法在写入那一刻就成立,不依赖任何推导。
from app.models.scrap_approval import SCRAP_CATEGORY_STOCK
category = normalize_scrap_category(reason_category, default=SCRAP_CATEGORY_STOCK)
# ★ 来源适配:三类来源(库存行 / 在管不良品 / 借出未还)各有不同的 # ★ 来源适配:三类来源(库存行 / 在管不良品 / 借出未还)各有不同的
# 可报废上限、扣减行为与快照字段,差异全部收敛在 scrap_sources 里。 # 可报废上限、扣减行为与快照字段,差异全部收敛在 scrap_sources 里。
# 原先此处硬编码「只认三张库存表」,导致借出未还与在管不良品只能 # 原先此处硬编码「只认三张库存表」,导致借出未还与在管不良品只能
@ -114,42 +287,96 @@ class ScrapApprovalService:
from app.services.approval_control import resolve_approval_control from app.services.approval_control import resolve_approval_control
_, flagged_materials = resolve_approval_control(normalized) _, flagged_materials = resolve_approval_control(normalized)
if not approver_id: if approver_id:
# 旧路径:指定了具体审批人,名单钉死为这一人。行为与改造前逐字一致。
final_approvers = [{"type": "user", "value": int(approver_id)}]
else:
final_approvers = ScrapApprovalService._sanitize_approvers(allowed_approvers)
if not final_approvers:
# ★ 角色级审批下**永远有合法审批人**,不再报「必须选择审批人」。
# Fail-Closed 由「必须持 scrap_approval 权限码 且 命中角色」两道门保证
# (见 can_operator_approve 与 app/api/v1/scrap.py 的装饰器)。
final_approvers = [
{"type": "role", "value": r} for r in DEFAULT_SCRAP_APPROVER_ROLES
]
# flagged_materials 此时只剩「提示文案」价值,记日志便于回溯
if flagged_materials: if flagged_materials:
_names = ";".join(f"{m['name']}({m['spec_model'] or '-'})" for m in flagged_materials) _names = ";".join(
raise ValueError(f"以下物料需审批报废:{_names}。请选择审批人后再提交") f"{m['name']}({m['spec_model'] or '-'})" for m in flagged_materials
raise ValueError("报废申请必须选择审批人后再提交") )
logger.info(f"[ScrapApproval] 命中需审批物料(角色级审批):{_names}")
allowed_approvers = [{"type": "user", "value": int(approver_id)}]
req = ScrapApproval( req = ScrapApproval(
request_no=ScrapApprovalService.generate_request_no(), request_no=ScrapApprovalService.generate_request_no(),
applicant_id=applicant_id, applicant_id=applicant_id,
remark=remark, remark=remark,
company_name=(company_name or '').strip() or None,
reason_category=category,
source_ref=(source_ref or '').strip() or None,
) )
req.set_items(normalized) req.set_items(normalized)
req.set_allowed_approvers(allowed_approvers) req.set_allowed_approvers(final_approvers)
# ★ 恒为「待审批」,不再走免审批自动通过分支 # ★ 恒为「待审批」,不再走免审批自动通过分支
req.status = 0 req.status = 0
db.session.add(req) db.session.add(req)
if commit:
db.session.commit() db.session.commit()
logger.info(f"[ScrapApproval] 提交成功 {req.request_no} approver={approver_id}") else:
# ★ 只 flush:把 req 交给调用方的事务,让「退回 + 提报废申请」原子化。
# flush 后 req.id / req.request_no 均可用(request_no 是 SQL 计数生成,
# 事务内可见自己的写入)。
db.session.flush()
logger.info(
f"[ScrapApproval] 提交成功 {req.request_no} "
f"approvers={final_approvers} category={category} commit={commit}"
)
return req return req
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 列表 # 列表
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@staticmethod @staticmethod
def get_list(page=1, limit=10, status=None, applicant_id=None, approver_id=None): def get_list(page=1, limit=10, status=None, applicant_id=None,
approver_id=None, approver_role=None, company_name=None):
"""申请表分页。
approver_id / approver_role:只看「指定给我的」或「属于我这个角色的」
待办单(scope=pending)。两者是 **OR** 关系 —— 一张单可能既指名某人、
又对某角色开放,任一命中就该出现。
⚠️ SQL 层只能用**宽松 LIKE** 匹配 JSON 文本(items_json 是 text 不是 jsonb,
allowed_approvers 同样是 text,没有 JSON 包含查询可用)。
已知会误命中:`"value": 12` 会匹配到 `"value": 123`。
本轮**不收紧** —— 误命中只让某人多看到一条待办,点审批会被 approve() 的
精确集合判定拦下,不构成越权;而收紧成 `12,`/`12}` 一旦遇到分隔符或空格
变体就会**漏命、待办静默消失**。审批待办场景:漏 > 多。
"""
query = ScrapApproval.query query = ScrapApproval.query
if status is not None: if status is not None:
query = query.filter(ScrapApproval.status == status) query = query.filter(ScrapApproval.status == status)
if applicant_id is not None: if applicant_id is not None:
query = query.filter(ScrapApproval.applicant_id == applicant_id) query = query.filter(ScrapApproval.applicant_id == applicant_id)
approver_conds = []
if approver_id is not None: if approver_id is not None:
query = query.filter(ScrapApproval.allowed_approvers.like(f'%"value": {approver_id}%')) approver_conds.append(
ScrapApproval.allowed_approvers.like(f'%"value": {int(approver_id)}%')
)
if approver_role:
role_code = str(approver_role).strip().upper()
if role_code:
approver_conds.append(
ScrapApproval.allowed_approvers.like(f'%"value": "{role_code}"%')
)
if approver_conds:
query = query.filter(db.or_(*approver_conds))
# 公司隔离:只对带了公司的单生效,company_name 为空的旧单不受限
if company_name:
query = query.filter(ScrapApproval.company_name == company_name)
query = query.order_by(ScrapApproval.created_at.desc()) query = query.order_by(ScrapApproval.created_at.desc())
pg = query.paginate(page=page, per_page=limit, error_out=False) pg = query.paginate(page=page, per_page=limit, error_out=False)
return { return {
@ -170,7 +397,7 @@ class ScrapApprovalService:
if req.status != 0: if req.status != 0:
raise ValueError("当前状态不允许审批(仅待审批可操作)") raise ValueError("当前状态不允许审批(仅待审批可操作)")
# 仅被指定的审批人可操作。 # 仅被指定的审批人(或审批角色)可操作。
# #
# ★ Fail-Closed:原实现是 `if user_entries and str(operator_id) not in ...` # ★ Fail-Closed:原实现是 `if user_entries and str(operator_id) not in ...`
# —— 当 allowed_approvers 为空(或条目里没有 type='user')时 user_entries # —— 当 allowed_approvers 为空(或条目里没有 type='user')时 user_entries
@ -178,12 +405,18 @@ class ScrapApprovalService:
# 「报废一律需审批」直接矛盾:留一扇「无审批人则人人可审」的门, # 「报废一律需审批」直接矛盾:留一扇「无审批人则人人可审」的门,
# 等于没有审批。现改为无名单即拒绝。 # 等于没有审批。现改为无名单即拒绝。
# 已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。 # 已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。
#
# ★ 角色级审批(2026-09):名单里现在还可以是 {"type":"role","value":"SUPERVISOR"}。
# 放宽的只是「条目类型」,**空名单依然拒绝所有人** ——
# 这条不变量在 can_operator_approve 里,任何改动都不得把它改回 fail-open。
allowed = req.get_allowed_approvers() or [] allowed = req.get_allowed_approvers() or []
user_entries = [str(a.get('value')) for a in allowed if a.get('type') == 'user'] user_entries, role_entries = ScrapApprovalService._approver_entries(allowed)
if not user_entries: if not user_entries and not role_entries:
raise ValueError("该申请单未指定审批人,无法审批,请联系管理员处理") raise ValueError("该申请单未指定审批人或审批角色,无法审批,请联系管理员处理")
if str(operator_id) not in user_entries: if not ScrapApprovalService.can_operator_approve(req, operator_id):
raise ValueError("只有被指定的审批人可以审批该申请") raise ValueError("只有被指定的审批人或审批角色可以审批该申请")
# ★ 角色级审批的必要配套:不隔离公司,LICA 主管就能审 IRIS 的单
ScrapApprovalService.assert_same_company(req, operator_id)
if action == 'approve': if action == 'approve':
req.status = 1 req.status = 1

View File

@ -115,9 +115,17 @@ class ScrapSourceAdapter:
# --- 台账公共字段 --- # --- 台账公共字段 ---
@staticmethod @staticmethod
def _ledger_kwargs(req, operator_name): def _ledger_kwargs(req, operator_name):
"""写 TransScrap 的公共字段 —— **三种来源适配器全部经过这里**。
★ 这是报废原因分类落到台账的**唯一出口**,分类只在这里带一次,
三个 adapter(库存行 / 在管不良品 / 借出未还)就都通了。
「统计生产报废金额」按 trans_scrap.reason_category 分组,不要靠
reason 自由文本去匹配。
"""
from app.models.scrap_approval import ScrapApproval from app.models.scrap_approval import ScrapApproval
return { return {
'reason': req.remark or '', 'reason': req.remark or '',
'reason_category': getattr(req, 'reason_category', None),
'operator_name': operator_name, 'operator_name': operator_name,
'approver_name': ScrapApproval._user_name(req.actual_approver_id), 'approver_name': ScrapApproval._user_name(req.actual_approver_id),
'approval_status': 'executed', 'approval_status': 'executed',
@ -238,9 +246,39 @@ class DefectiveScrapAdapter(ScrapSourceAdapter):
def cap(self, row): def cap(self, row):
return float(getattr(row, 'remaining_qty', 0) or 0) return float(getattr(row, 'remaining_qty', 0) or 0)
@staticmethod
def _resolve_location(goods):
"""
取「原库位 / 批次(序列号)」:台账快照优先,回查源库存行兜底。
★ 口径 = **原库位**(这批货最初在哪),与三张库存表来源一致。
★ 原实现此处硬编码空串(注释「台账无库位字段」),导致申请单/审批单
明细里凡是不良品来源的行,库位与批次恒显示 "-"。但台账自带
source_table + stock_id 溯源指针,源行还在时本来取得到。
★ 两级都落空(源行已被入库模块物理删除,且台账也无快照)时返回空串
—— 不中断报废:实物已销毁,台账必须先记上,位置缺失是可接受的
降级,记录丢失不是。
★ 取值口径与 scrap.py 的 _from_stock 一致:成品表无 batch_number 列,
回退到 serial_number。
"""
loc = getattr(goods, 'warehouse_location', '') or ''
batch = getattr(goods, 'batch_number', '') or ''
if loc and batch:
return loc, batch
model = _stock_model_map().get(getattr(goods, 'source_table', ''))
if model is not None and getattr(goods, 'stock_id', None):
row = model.query.get(goods.stock_id)
if row:
loc = loc or getattr(row, 'warehouse_location', '') or ''
batch = batch or (getattr(row, 'batch_number', '')
or getattr(row, 'serial_number', '') or '')
return loc, batch
def snapshot(self, row, qty, raw): def snapshot(self, row, qty, raw):
# 物料名/规格取自台账自身的冗余快照,**不联表 MaterialBase** —— # 物料名/规格取自台账自身的冗余快照,**不联表 MaterialBase** ——
# 原库存行可能已被物理删除,联表会取到空值。 # 原库存行可能已被物理删除,联表会取到空值。
location, batch_number = self._resolve_location(row)
return { return {
'source_table': self.source_table, 'source_table': self.source_table,
'stock_id': row.id, 'stock_id': row.id,
@ -248,8 +286,8 @@ class DefectiveScrapAdapter(ScrapSourceAdapter):
'sku': getattr(row, 'sku', '') or '', 'sku': getattr(row, 'sku', '') or '',
'name': getattr(row, 'material_name', '') or raw.get('name') or '', 'name': getattr(row, 'material_name', '') or raw.get('name') or '',
'spec_model': getattr(row, 'spec_model', '') or raw.get('spec_model') or '', 'spec_model': getattr(row, 'spec_model', '') or raw.get('spec_model') or '',
'location': '', # 台账无库位字段 'location': location,
'batch_number': '', 'batch_number': batch_number,
'scrap_qty': qty, 'scrap_qty': qty,
'available_at_apply': self.cap(row), 'available_at_apply': self.cap(row),
'scrap_mode': self.scrap_mode, 'scrap_mode': self.scrap_mode,

View File

@ -29,15 +29,28 @@ def lookup_product(code):
sku/material_name/spec_model/material_type/order_no),未命中或异常返回 None。 sku/material_name/spec_model/material_type/order_no),未命中或异常返回 None。
""" """
try: try:
base_url = (current_app.config.get('TRACK_API_URL') or '').strip()
api_key = (current_app.config.get('TRACK_WEBHOOK_KEY') or '').strip() api_key = (current_app.config.get('TRACK_WEBHOOK_KEY') or '').strip()
if not base_url:
logger.info("[TrackQuery] 未配置 TRACK_API_URL,跳过查询")
return None
if not api_key: if not api_key:
logger.info("[TrackQuery] 未配置 TRACK_WEBHOOK_KEY,跳过查询") logger.info("[TrackQuery] 未配置 TRACK_WEBHOOK_KEY,跳过查询")
return None return None
# ★ 按当前用户所属公司路由到对应的 Track 实例(IRIS / LICA)。
# 复用 get_current_company_filter():超管/跨域用户返回 None → 回落默认实例。
# 改造前这里写死读 TRACK_API_URL,LICA 员工在 LICA 业务里扫码会打到 IRIS 库,
# 查不到 → MOM 误报"物料不存在"。
from app.services.track_webhook_service import resolve_track_route
from app.utils.decorators import get_current_company_filter
try:
company = get_current_company_filter()
except Exception:
# 无请求/JWT 上下文(脚本、后台任务)时回落默认实例,不阻断查询
company = None
base_url = resolve_track_route(company, 'api')
if not base_url:
logger.info("[TrackQuery] 未解析到 Track API 地址,跳过查询")
return None
url = f'{base_url}/api/v1/external/products/lookup' url = f'{base_url}/api/v1/external/products/lookup'
headers = { headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@ -37,6 +37,54 @@ def _post_to_track(payload, url, api_key):
logger.error("[TrackWebhook] 发送失败 url=%s, err=%s", url, e) logger.error("[TrackWebhook] 发送失败 url=%s, err=%s", url, e)
# 各通道未命中路由表时回落到哪个扁平配置变量(保持改造前的单实例行为)
_TRACK_ROUTE_FALLBACK = {
'api': 'TRACK_API_URL',
'inbound': 'TRACK_WEBHOOK_URL',
'outbound': 'TRACK_OUTBOUND_WEBHOOK_URL',
}
def resolve_track_route(company_name, channel):
"""按公司名解析 Track 实例地址。
channel: 'api' | 'inbound' | 'outbound'
⚠️ 未命中必须**回落到扁平变量**,不能返回空 —— 漏配一个 key 就静默断链,
比配置写错更难排查。
"""
routes = current_app.config.get('TRACK_ROUTES') or {}
if company_name and company_name in routes:
url = ((routes.get(company_name) or {}).get(channel) or '').strip()
if url:
return url
# 公司在表里但该通道没配 —— 这是配置漏项,务必暴露出来
logger.warning(
"[TrackRoute] 公司 %s 的 %s 通道未配置,回落到全局默认地址",
company_name, channel,
)
elif company_name:
# 非空但不在路由表内:可能是拼写异常的公司名,也可能是
# get_current_company_filter() 的 '__NO_COMPANY__' 哨兵(用户 JWT 无公司)。
# 不猜、不阻断,只暴露给业务确认。
logger.warning(
"[TrackRoute] 公司 '%s' 不在 TRACK_ROUTES 中,回落到全局默认地址(channel=%s)。"
"若该值不是合法部门名,请业务确认主数据",
company_name, channel,
)
fallback_key = _TRACK_ROUTE_FALLBACK.get(channel)
if not fallback_key:
return ''
return (current_app.config.get(fallback_key) or '').strip()
def _channel_from_event(event):
"""从事件名推断路由通道;缺省按入库处理。"""
return 'outbound' if 'outbound' in (event or '').lower() else 'inbound'
def get_current_operator(): def get_current_operator():
"""从 JWT 中安全获取当前操作人姓名(失败返回空字符串,不抛异常)""" """从 JWT 中安全获取当前操作人姓名(失败返回空字符串,不抛异常)"""
try: try:
@ -50,7 +98,7 @@ def get_current_operator():
return '' return ''
def notify_track(payload, url=None): def notify_track(payload, url=None, channel=None):
""" """
异步通知 Track 系统业务事件(入库/出库等)。 异步通知 Track 系统业务事件(入库/出库等)。
@ -58,10 +106,15 @@ def notify_track(payload, url=None):
- 未配置 URL / Key 为空 -> 静默跳过 - 未配置 URL / Key 为空 -> 静默跳过
- 网络异常 / 超时 -> 仅记录日志 - 网络异常 / 超时 -> 仅记录日志
:param url: 指定 Track webhook 地址;不传则用默认 TRACK_WEBHOOK_URL(入库) :param url: 显式指定 Track webhook 地址,优先级最高(指定后不再做公司路由)
:param channel: 路由通道 'inbound' | 'outbound';不传则按 payload['event'] 推断。
仅在不传 url 时生效——实例由 payload['company_name'] 决定。
""" """
try: try:
url = (url or current_app.config.get('TRACK_WEBHOOK_URL') or '').strip() if not url:
channel = channel or _channel_from_event(payload.get('event'))
url = resolve_track_route(payload.get('company_name'), channel)
url = (url or '').strip()
api_key = (current_app.config.get('TRACK_WEBHOOK_KEY') or '').strip() api_key = (current_app.config.get('TRACK_WEBHOOK_KEY') or '').strip()
if not url: if not url:
logger.info("[TrackWebhook] 未配置 webhook URL,跳过通知") logger.info("[TrackWebhook] 未配置 webhook URL,跳过通知")

View File

@ -0,0 +1,814 @@
"""
审计日志的中文化标签 —— **唯一来源**。
为什么要有这个模块
------------------
同一套「字段名 → 中文」映射原先在两个地方各存一份:前端
`views/system/AuditLog.vue` 的 fieldMap,以及后端 `api/v1/audit.py` 里的
ACTION_ALIASES。两份手工同步的副本必然漂移 —— 改一边漏一边,页面上就会
冒出英文列名或对不上的中文。
现在统一收在这里:
· 前端经 `GET /api/v1/audit/labels` 拉取,不再自带副本;
· 后端 API 层与日报服务直接 import,不重复定义。
★ 加新字段时**只改本文件**。
三层映射
--------
1. ACTION_ALIASES / canon_action —— 操作类型归一化。
历史数据里 action 有两套写法:早期装饰器写中文(新增/修改/批量删除…),
现行监听器写大写英文(CREATE/UPDATE/DELETE)。归一到规范值后,
筛选与统计才不会漏掉历史数据。
2. ACTION_LABELS —— 规范值 → 中文显示名。
3. FIELD_LABELS —— 数据库列名 → 中文列名。未命中时由 field_label()
兜底为原字段名(宁可显示英文,也不要显示空白)。
"""
# =============================================================================
# 1. 操作类型归一化
# =============================================================================
# 问题背景(沿用 api/v1/audit.py 的原始注释):
# 前端下拉框直接取 DISTINCT action,于是同时出现「CREATE」和「新增」两个
# 选项,而表格里二者又都显示为「新增」,用户无法分辨。用户选了看得懂的中文项,
# 只能搜到 3-4 月的历史数据,误以为"没有最近的内容"。
#
# 处理:对外只暴露规范值(CREATE/UPDATE/DELETE),筛选时自动展开到全部别名,
# 历史数据无需迁移即可被正确检索。
ACTION_ALIASES = {
'CREATE': ('CREATE', 'create', 'INSERT', 'insert', '新增', '批量生成'),
'UPDATE': ('UPDATE', 'update', '修改', '分配', '归还'),
'DELETE': ('DELETE', 'delete', '删除', '批量删除'),
}
# 反向索引:任意别名 → 规范值
_ALIAS_TO_CANON = {
alias: canon
for canon, aliases in ACTION_ALIASES.items()
for alias in aliases
}
def canon_action(action):
"""把任意写法的 action 归一化为规范值;无法识别时原样返回"""
return _ALIAS_TO_CANON.get((action or '').strip(), (action or '').strip())
# =============================================================================
# 2. 操作类型显示名
# =============================================================================
ACTION_LABELS = {
'CREATE': '新增',
'UPDATE': '修改',
'DELETE': '删除',
'EXPORT': '导出',
'IMPORT': '导入',
'LOGIN': '登录',
'LOGOUT': '登出',
}
def action_label(action):
"""操作类型 → 中文;先归一化再查表,未命中时返回归一化后的原值"""
canon = canon_action(action)
return ACTION_LABELS.get(canon, canon)
# =============================================================================
# 3. 字段名中文化映射
#
# ★ 覆盖范围:审批单 / 流水 / 库存 / 主数据 / 系统管理 五类核心业务表。
# 未命中的字段由 field_label() 兜底为「原字段名」,不会再出现大面积英文列名。
# ★ 用法:变更对比、新增详情、删除快照三个区块统一经 field_label() 取值。
# (改造前仅"变更对比"区用了映射,另外两区直接渲染原始 key,
# 这是"详情里一堆英文列名"的直接原因。)
# =============================================================================
FIELD_LABELS = {
# --- 通用 ---
'id': 'ID',
'name': '名称',
'title': '标题',
'remark': '备注',
'reason': '原因',
'reason_category': '原因分类',
'status': '状态',
'created_at': '创建时间',
'updated_at': '更新时间',
'is_active': '是否启用',
'is_enabled': '是否启用',
'company_name': '所属公司',
'operator_name': '操作人',
'operator': '操作人',
# --- 物料主数据 ---
'material_name': '物料名称',
'spec_model': '规格型号',
'category': '类别',
'material_type': '物料类型',
'unit': '单位',
'base_id': '物料ID',
'sku': 'SKU',
'batch_number': '批次号',
'serial_number': '序列号',
'barcode': '条码',
'warehouse_location': '库位',
'warehouse_loc': '库位',
'reference_price': '参考价格',
'is_approval_required': '是否需审批',
'is_inspection_required': '是否需质检',
# --- 库存数量 ---
'in_quantity': '入库数量',
'stock_quantity': '总库存',
'available_quantity': '可用库存',
'out_quantity': '出库数量',
'quantity': '数量',
'pre_tax_unit_price': '不含税单价',
'post_tax_unit_price': '含税单价',
'unit_price': '单价',
'total_price': '总价',
'tax_rate': '税率',
'supplier_name': '供应商',
'buyer_name': '采购员',
# --- 审批单 ---
'request_no': '申请单号',
'applicant_id': '申请人ID',
'allowed_approvers': '允许审批人',
'actual_approver_id': '实际审批人ID',
'approved_at': '审批时间',
'reject_reason': '驳回原因',
'items_json': '物料明细',
'outbound_type': '出库类型',
'consumer_name': '领用人/客户',
'borrower_name': '借用人',
'executed_at': '执行时间',
'executor_name': '执行人',
# --- 流水 ---
'outbound_no': '出库单号',
'outbound_time': '出库时间',
'borrow_no': '借出单号',
'borrow_time': '借出时间',
'expected_return_time': '预计归还时间',
'return_time': '归还时间',
'return_operator': '归还操作人',
'returned_quantity': '已归还数量',
'is_returned': '是否已归还',
'scrap_request_no': '报废申请单号',
'operation_time': '操作时间',
'source_table': '来源表',
'source_ref': '外部单号',
'stock_id': '库存ID',
'cost_at_scrap': '报废成本',
'total_loss': '损失金额',
# --- 逆向物流(退不良/在管不良品)---
'remaining_qty': '在管数量',
'restocked_qty': '累计已回库',
'scrapped_qty': '累计已报废',
'return_qty': '退回数量',
'return_type': '退回类型',
'return_id': '退回流水ID',
'outbound_id': '出库记录ID',
'signature_path': '签名',
'reissue_qty': '补发数量',
# --- 采购 / BOM ---
'purchase_date': '采购日期',
'requester_id': '申请人ID',
'approver_id': '审批人ID',
'bom_no': 'BOM编号',
'bom_version': 'BOM版本',
'parent_id': '父级ID',
'child_id': '子级ID',
# --- 系统管理 ---
'username': '用户名',
'display_name': '显示名',
'email': '邮箱',
'role': '角色',
'department': '部门',
'password_hash': '密码哈希',
'code': '编码',
'path': '路径',
'sort_order': '排序',
'is_visible': '是否可见',
'menu_code': '菜单编码',
'element_type': '元素类型',
'role_code': '角色编码',
'target_code': '目标编码',
'user_agent': '浏览器标识',
'ip_address': 'IP地址',
'visibility_level': '可见级别',
'is_archived': '是否归档',
# --- 采购 / 入库(stock_* 表)---
# 这几个是实测缺失最多的:采购入库快照整表展开,缺一个就漏一排英文。
'buyer_email': '采购邮箱',
'in_date': '入库日期',
'currency': '币种',
'exchange_rate': '汇率',
'inspection_status': '质检状态',
'inspection_report': '质检报告',
'inspection_report_link': '质检报告链接',
'quality_status': '质检状态',
'quality_report_link': '质检报告链接',
'arrival_photo': '到货照片',
'product_photo': '成品照片',
'global_print_id': '全局打印ID',
'base': '基础物料',
'detail_link': '详情链接',
'original_link': '原始链接',
'supplier_link': '供应商链接',
'request_id': '申请ID',
'purchase_request': '采购申请',
'order_id': '订单ID',
'is_ordered': '是否已下单',
'sale_price': '销售价',
'location': '库位',
'common_name': '通用名',
'material': '物料',
'images': '图片',
# --- 生产(半成品/成品)---
'work_order_code': '工单号',
'bom_code': 'BOM编号',
'production_manager': '生产负责人',
'production_date': '生产日期',
'production_start_time': '生产开始时间',
'production_end_time': '生产结束时间',
'production_time_range': '生产时间段',
'raw_material_cost': '原材料成本',
'manual_cost': '人工成本',
# --- BOM ---
'loss_rate': '损耗率',
'dosage': '用量',
'parent': '父件',
'child': '子件',
'version': '版本',
'bom_remark': 'BOM备注',
'child_bom_no': '子BOM编号',
'child_bom_version': '子BOM版本',
# --- 退回 / 借还 ---
'return_signature': '归还签名',
'return_location': '归还库位',
'borrow_signature': '借出签名',
'current_holder_id': '当前持有人ID',
'current_holder_name': '当前持有人',
'borrower_id': '借用人ID',
'dispatch_operator': '发放操作人',
'source_return_id': '来源退回ID',
# --- 预警设置 ---
'yellow_threshold': '黄色阈值',
'red_threshold': '红色阈值',
'yellow_emails': '黄色预警邮箱',
'red_emails': '红色预警邮箱',
'last_notified_at': '上次通知时间',
# --- 审批 ---
'approver_name': '审批人',
'approval_status': '审批状态',
# --- 盘点 / 扫码(长尾,实测仅个位数条记录,但不补就会在详情里裸露英文)---
'diff_qty': '差异数量',
'stock_qty': '库存数量',
'scan_time': '扫码时间',
'session_id': '会话标识',
'uuid': '唯一标识',
'image_url': '图片地址',
'user_id': '用户ID',
'type': '类型',
# --- 快照里的数组字段 ---
# ★ 这些在详情抽屉里会**直接当表格标题**用(见前端的分层渲染),
# 漏一个就是在表头上裸露英文。
'items': '物料明细',
'rules': '规则',
'children': '子项',
'arrival_photo': '到货照片',
'generalImage': '通用图片',
'generalManual': '通用手册',
'permissions': '权限',
'signature_path': '签名',
# --- payload 型快照的字段 ---
# ★ 来源:接口层手工记录的请求体整包(借库/出库/采购入库/用户管理/预警设置)。
# 它的命名与 ORM 快照**不一致**,同一概念有两种写法:
# qty_stock / stock_quantity —— 都是"库存数量"
# commonName / common_name —— 都是"通用名"
# companyName / company_name —— 都是"所属公司"
# 这是历史遗留,改数据不可逆,故两种写法都登记。
'qty_stock': '库存数量',
'qty_available': '可用数量',
'qty_inbound': '入库数量',
'inventoryCount': '库存数量',
'availableCount': '可用数量',
'pending_quantity': '待处理数量',
'inbound_date': '入库日期',
'cn_name': '中文名',
'commonName': '通用名',
'companyName': '所属公司',
'spec': '规格型号',
'price': '价格',
'purchaser': '采购员',
'purchaser_email': '采购邮箱',
'print_copies': '打印份数',
'global_print_id_str': '全局打印ID(文本)',
'source_link': '来源链接',
'unit_total_cost': '单位总成本',
'current_location': '当前位置',
'isEnabled': '是否启用',
'isInspectionRequired': '是否需质检',
'visibilityLevel': '可见级别',
'warningEnabled': '是否启用预警',
'warningStatus': '预警状态',
'warningRed': '红色预警',
'warningYellow': '黄色预警',
'manual_link': '说明书链接',
'manual_link_remark': '说明书链接备注',
'product_image': '产品图片',
'product_image_remark': '产品图片备注',
'purchase_link': '采购链接',
# --- 级联聚合日志的汇总字段 ---
# ★ 这些是 audit_listener 写聚合日志时自造的元字段(见其 AGGREGATE_PATH_MARKERS),
# 不是任何业务表上的列。名字都很通用(count/targets),
# 目前业务快照里没有同名键;将来若出现,要改成按 details 结构区分而非按字段名。
'count': '变更次数',
'targets': '受影响对象',
'targets_total': '对象总数',
'targets_truncated': '对象清单已截断',
# 早期版本的聚合载荷里带过 action(与日志自身的 action 重复,已不再写入),
# 但历史行里还在,留个标签免得它在详情里裸露英文
'action': '操作类型',
}
# =============================================================================
# 6. 详情快照里**不展示**的纯技术字段
#
# ★ 判据:这些字段对业务人员零信息量,且常常极端冗长。
# 典型是 image_embeddings 表的 `embedding`(1536 维向量),一条快照里
# 塞进去就是几 KB 的乱码;`target_id` / `module_name` 则是审计层的元数据,
# 被早期快照采集误带进了业务对象。
#
# ★ 只收「纯技术」的。链接/图片**不收**:那是业务内容,用户点进去是有用的;
# 它们在"变更对比"里已经被 IGNORED_CHANGE_FIELDS 单独处理了。
#
# ★ 这份清单放在后端而不是前端硬编:它会随新表增长,前端再存一份必然漂移。
# 前端经 GET /audit/labels 的 hiddenFields 取。
# =============================================================================
HIDDEN_SNAPSHOT_FIELDS = frozenset({
'id',
'created_at',
'updated_at',
'tenant_id',
'target_id', # 审计层元数据,不是业务字段
'module_name',
'embedding', # pgvector 向量列,单条可达数 KB
'img_embedding',
'arrival_image_embedding',
'qc_report_image_embedding',
'image_embedding',
'password', # ★ 见下方凭据关键词的说明
'password_hash',
})
# 凭据类字段的**关键词**拦截。
#
# ★★ 这不是洁癖,是实测出来的洞:`用户管理/新增` 的 payload 快照里存着
# **明文密码**(27 条,实测 pw_len 6/8/11,形如 '1234…'、以用户名开头…)——
# 写入路径把请求体整包记进了审计,而请求体里是哈希前的原始密码。
# 这些内容会直接显示在详情抽屉里,并随导出进 Excel。
#
# ★ 用关键词而不是逐个登记:今天漏的是 payload 里的 password,
# 明天可能是 reset_token、api_secret。凡是键名沾这些词的都不展示 ——
# 新表加了凭据字段也自动被挡住。
_CREDENTIAL_KEYWORDS = ('password', 'passwd', 'secret', 'token', 'private_key')
# =============================================================================
# 7. 快照(details)的键与展示标题
#
# ★ 历史上有**三种**互不相同的存法,由不同时期的写入路径产生:
# {'created': {...}} —— ORM 监听器的 INSERT 快照
# {'deleted_snapshot': {...}} —— ORM 监听器的 DELETE 快照
# {'payload': {...}} —— 接口层手工记录的业务数据整包
# (借库/出库/采购入库等,内部还嵌 items 数组)
# `changes` 是第四种,但语义是"变更对比"而非快照,不走这套渲染。
#
# ★ 键名与标题都收在这里、由 GET /audit/labels 下发:前端不该硬编这三行 ——
# 将来再加第四种存法,只改这里即可(本项目已多次栽在"前端存一份副本"上)。
#
# ★ 顺序即优先级:一条记录同时有多个键时取第一个能用的。
# =============================================================================
SNAPSHOT_CREATED = 'created'
SNAPSHOT_DELETED = 'deleted_snapshot'
SNAPSHOT_PAYLOAD = 'payload'
CHANGES_KEY = 'changes'
# 聚合键:级联接口的批量变更被合并成一条日志时,受影响对象清单存在这里
# (见 audit_listener 的级联聚合)。它不是"快照",但走同一套分层渲染,
# 故一并列在 VIEWS 里 —— 抽屉里会把它渲染成一张对象清单表。
SNAPSHOT_AGGREGATE = 'aggregate'
SNAPSHOT_VIEWS = (
(SNAPSHOT_CREATED, '新增数据快照'),
(SNAPSHOT_DELETED, '删除前数据快照'),
(SNAPSHOT_PAYLOAD, '业务数据'),
(SNAPSHOT_AGGREGATE, '批量汇总'),
)
def is_hidden_credential_field(key):
"""
该字段是否属于**凭据类**(密码/令牌/密钥)—— 这类字段不仅要"不显示",
还要在接口出口**整键剥掉**(见 audit_export_service.sanitize_details)。
★ 单列一个函数而不是复用 is_hidden_snapshot_field:两者处置方式不同。
技术字段(id/embedding)只是不该展示;凭据字段是**泄漏**,
必须连响应体里都不能有。混在一起会让"要不要剥掉"的语义变含糊。
"""
k = str(key or '').strip().lower()
return any(w in k for w in _CREDENTIAL_KEYWORDS)
def is_hidden_snapshot_field(key):
"""
该字段是否属于「不该在详情快照里展示」的字段。
三类:
· 名单内的纯技术字段(id / 时间戳 / 审计元数据)
· `*embedding` 向量列 —— 按**后缀**拦截:各表命名不一
(img_embedding / arrival_image_embedding / qc_report_image_embedding…),
逐个登记必然漏。新表加向量列不必再改这里。
· 凭据类字段 —— 见 is_hidden_credential_field
★ 前端同一套规则再判一次(AuditLog.vue 的 isHiddenField):
后端名单管"点名"的,关键词两端各自判,语义一致。
"""
k = str(key or '').strip().lower()
if k in HIDDEN_SNAPSHOT_FIELDS or k.endswith('embedding'):
return True
return is_hidden_credential_field(k)
def field_label(key):
"""字段名 → 中文;未命中时原样返回字段名"""
k = str(key)
return FIELD_LABELS.get(k, k)
# =============================================================================
# 4. 枚举值中文化 —— ★ 必须按模块区分
#
# 同一个字段名在不同模块里是**不同的东西**:
# · 出库管理.status 的 3 表示「已完成(已出库)」
# · 借还管理.status 的 3 表示「已完成(已借出)」
# · 报废管理.status 的 3 表示「已执行(已报废)」
# · 借还管理.status 还可能是字符串 'borrowed'/'returned'(那是 trans_borrow,
# 不是审批单 —— 这个模块下两张表共用了 status 这个列名)
# · 退回管理.status 本来就是中文(待处理/已报废),无需翻译
#
# 不区分模块就会出现「报废单显示成已出库」这种错。
# =============================================================================
# 审批单通用取值(出库/借还/报废/采购四条审批流共用同一套编码)
_APPROVAL_STATUS = {
0: '待审批',
1: '已通过',
2: '已驳回',
3: '已完成',
4: '已撤回',
}
STATUS_LABELS_BY_MODULE = {
# 出库审批流(OutboundApproval)
'出库管理': {**_APPROVAL_STATUS, 3: '已完成(已出库)', 4: '已完结'},
# 借还模块下有两张表共用 status:审批单(数字)与 trans_borrow(字符串)
'借还管理': {
**_APPROVAL_STATUS,
3: '已完成(已借出)',
4: '已撤回',
'borrowed': '借出中',
'returned': '已归还',
'scrapped': '已报废',
},
# 报废审批流(ScrapApproval):4 是「已撤回」,不是「已完结」
'报废管理': {**_APPROVAL_STATUS, 3: '已执行(已报废)'},
# 采购申请 / 采购单(PurchaseRequest)
'采购管理': {**_APPROVAL_STATUS, 3: '已完成', 4: '已完结'},
'purchase_request': {**_APPROVAL_STATUS, 3: '已完成', 4: '已完结'},
}
# 未登记的模块退回通用编码 —— 比显示裸数字强,也比乱猜安全
DEFAULT_STATUS_LABELS = _APPROVAL_STATUS
# 走枚举翻译的字段(目前只有 status;将来有别的码值列在此追加)
ENUM_FIELDS = frozenset({'status'})
# 布尔列 → 是/否
BOOLEAN_FIELDS = frozenset({
'is_enabled', 'is_active', 'is_visible', 'is_archived',
'is_returned', 'is_approval_required', 'is_inspection_required',
})
# 引用 sys_user.id 的列 —— 值需解析成姓名才有意义
USER_ID_FIELDS = frozenset({
'actual_approver_id', 'approver_id', 'applicant_id',
'requester_id', 'user_id', 'returner_id',
'from_user_id', 'to_user_id',
})
# 引用 material_base.id 的列 —— 值需解析成物料名才有意义
MATERIAL_ID_FIELDS = frozenset({'base_id'})
# ---------------------------------------------------------------------------
# ID 字段指向什么实体
#
# ★ 同名 ID 在不同模块指向**完全不同的表** —— 这是必须按模块区分的原因:
# parent_id 在「系统管理」里是菜单的上级菜单(sys_menu),
# 在「BOM管理」里却是材质/物料节点(material_base)。
# 故 (模块, 字段) 优先,未命中再退回按字段名匹配。
#
# ★ 登记原则:**只登记能真正查到实体的**。查不到的 ID 宁可显示原值,
# 也不要硬编一个可能错的中文。
# ---------------------------------------------------------------------------
ID_REF_BY_MODULE = {
('系统管理', 'parent_id'): 'menu',
('系统管理', 'menu_id'): 'menu',
}
ID_REF_BY_FIELD = {
'base_id': 'material',
'parent_id': 'material', # BOM 结构里的父节点(非「系统管理」模块时)
'child_id': 'material',
'return_id': 'return_ledger',
}
def id_ref_of(module, field):
"""该 (模块, 字段) 的 ID 指向什么实体;未登记返回 None。"""
mod = (module or '').strip()
hit = ID_REF_BY_MODULE.get((mod, field))
if hit:
return hit
return ID_REF_BY_FIELD.get(field)
def enum_label(module, field, value):
"""
把枚举码值翻成中文。返回 None 表示「不适用/未登记」,由调用方原样显示。
★ 只对数值型/已登记的值做翻译,**不猜** —— 未登记的模块用通用表兜底,
通用表也查不到就返回 None(显示原值),绝不硬编一个可能错的中文。
"""
if field not in ENUM_FIELDS:
return None
mapping = STATUS_LABELS_BY_MODULE.get((module or '').strip(), DEFAULT_STATUS_LABELS)
if isinstance(value, bool): # bool 是 int 的子类,先挡掉
return None
if isinstance(value, (int, float)) and float(value).is_integer():
return mapping.get(int(value))
if isinstance(value, str):
return mapping.get(value.strip())
return None
def bool_label(value):
"""布尔值 → 是/否;非布尔返回 None"""
if isinstance(value, bool):
return '是' if value else '否'
return None
# 人名类**字符串**字段:库里存法不统一('杜邢宸/duxingchen'),需规整成「名(账号)」。
#
# ★★ 必须按字段名限定,**绝不能**对所有含 '/' 的字符串做规整:
# 规格型号的值就长这样('Det0001/Det0001'),一刀切会把它变成
# 'Det0001(Det0001)' —— 那是静默篡改业务数据,比不翻译危险得多。
PERSON_NAME_FIELDS = frozenset({
'executor_name', 'operator_name', 'operator',
'return_operator', 'approver_name', 'applicant_name',
'borrower_name', 'dispatch_operator', 'purchaser',
'production_manager', 'from_user_name', 'to_user_name',
})
def person_name_label(value):
"""
'杜邢宸/duxingchen' → '杜邢宸(duxingchen)'。
不是字符串、空串、或不含 '/' 时返回 None(由调用方原样显示)——
只做规整不做兜底,避免把空值变成 '-' 之类的占位符。
"""
if not isinstance(value, str):
return None
s = value.strip()
if not s or '/' not in s:
return None
name, _, acct = s.partition('/')
name, acct = name.strip(), acct.strip()
if name and acct:
return f"{name}({acct})"
return None
# =============================================================================
# 5. 模块聚合别名 —— 让一个业务概念跨越历史上的口径变更
#
# ★ 背景:审计日志的 module 是**监听器写死的字符串**,而监听器换过一次。
# `app/utils/audit_events.py`(旧的全局监听器)产出「入库管理」;
# 现行的 `app/core/audit_listener.py` 把入库三表
# stock_buy / stock_semi / stock_product 统一归为「库存管理」
# (见 audit_listener.py 的 _get_module_name),不再产出「入库管理」。
#
# 实测的分界点是 2026-09-10:
# 入库管理 16118 条 2026-04-22 ~ 2026-09-10
# 库存管理 1371 条 2026-09-10 ~ 至今
#
# 后果:只按单个 module 值筛选「入库」,会正好在 9-10 那天断掉 ——
# 选「入库管理」看不到 9-10 之后的,选「库存管理」看不到之前的。
# 报表读起来像是"入库记录突然没了",极难排查。
#
# ★ 解法只在**查询期**做别名展开,不迁移历史数据:改数据不可逆,
# 而聚合查询是无损的。新增值只需往元组里追加。
#
# ★ 与 STATUS_LABELS_BY_MODULE 一样,这份表是**唯一来源**:
# 前端经 GET /audit/labels 取(前端 AuditLog.vue 自带的 moduleMap
# 已经是手工同步的副本,不能再塞第二份进去)。
# =============================================================================
MODULE_GROUPS = {
'入库': ('入库管理', '采购入库', '成品入库', '库存管理'),
}
# 历史遗留的**英文** module 值 → 中文展示名。
#
# ★ 来源:早期全局监听器(app/utils/audit_events.py,现已停用)没有表白名单,
# 系统表、草稿表、向量表都会被审计,而 _infer_module_name 对未登记的类名
# 直接回退成**表名**,于是这些英文值混进了 module 列。
# 实测存量:image_embeddings 2719 条、purchase_request 75 条、sys_element 6 条;
# 表里其余的(stock_buy 等)当前没有数据,但换个环境可能出现,一并留着。
#
# ★ 这份表原本硬编在前端 AuditLog.vue 的 moduleMap 里,**已经漂移过一次**:
# 前端加了表名后端不知道、后端加了前端不显示。收进本文件后,前端经
# GET /audit/labels 的 moduleDisplay 取,和字段名/状态码一样只有一份。
MODULE_LABELS = {
'image_embeddings': '图像特征(历史)',
'purchase_request': '采购申请',
'sys_element': '系统元素',
'sys_menu': '系统菜单',
'sys_user': '系统用户',
'sys_role': '系统角色',
'sys_role_permission': '角色权限',
'sys_warehouse_location': '库位设置',
'material_base': '物料主数据',
'material_warning_settings': '物料预警设置',
'stock_buy': '采购库存',
'stock_semi': '半成品库存',
'stock_product': '成品库存',
'stock_adjustment': '库存调整',
'trans_outbound': '出库流水',
'trans_borrow': '借还流水',
'trans_scrap': '报废流水',
'trans_repair': '维修单',
'bom_table': 'BOM配方',
'bom_draft_table': 'BOM草稿',
'stocktake_draft': '盘点草稿',
}
def module_display(module):
"""
单个 module 值 → 展示名。
三级解析,顺序不能换:
1. 聚合成员 → 聚合名(「入库管理」→「入库」)
2. 英文历史值 → 中文(「image_embeddings」→「图像特征(历史)」)
3. 其余原样(现行监听器写的就是中文,无需翻译)
★ 第 1 级必须在第 2 级之前:聚合名优先,否则「库存管理」会被
MODULE_LABELS 之类的表抢先命中而显示成别的。
"""
m = (module or '').strip()
for group, members in MODULE_GROUPS.items():
if m in members:
return group
return MODULE_LABELS.get(m, m)
def module_display_map():
"""
完整展示映射 {原始值: 展示名},下发给前端。
★ 必须把**聚合成员**也算进来(不只是 MODULE_LABELS):表格与详情里拿到
的是原始 module 值,不映射的话会出现「下拉显示『入库』、表格显示
『库存管理』」——用户会以为筛选没生效。
"""
out = dict(MODULE_LABELS)
for group, members in MODULE_GROUPS.items():
for m in members:
out[m] = group
return out
def module_options(raw_modules):
"""
库里的原始 module 值 → 前端「模块」下拉的最终选项 [{value, label}]。
★ 为什么由后端算,而不是前端拉原始值自己拼:
分组规则(哪些历史值属于同一个业务概念)由 MODULE_GROUPS 管。前端再算
一遍就是第二份副本 —— 将来往组里加成员,前端会**静默失效且没有任何
报错**。这是本项目反复踩过的坑(见本文件开头、以及前端 AuditLog.vue
那个已经漂移过一次的 moduleMap)。
★ 已归入聚合项的历史值**不再单独列出**:`入库管理`/`采购入库`/`成品入库`
与 `库存管理` 是被改名的同一批数据。同时列出会让用户以为它们是不同的
东西,选了历史值又只看得到断裂前的一半 —— 那正是 2026-09-10 的口径
断裂在界面上的表现。
代价:无法只查 `库存管理`(09-10 之后的 1371 条)。这是有意取舍 ——
那几个名字本身就是历史包袱,把选择权从用户手里拿走比让他选错更安全。
"""
grouped = {m for members in MODULE_GROUPS.values() for m in members}
# 聚合项置顶,其余按名称排序(DISTINCT 返回顺序不保证,排序后位置稳定)
opts = [{'value': name, 'label': name} for name in MODULE_GROUPS]
# ★ label 走 module_display,英文历史值在这里就变成中文 ——
# value 保持原始字符串,否则筛选匹配不上(后端按 module 列的原值过滤)
opts += [{'value': m, 'label': module_display(m)}
for m in sorted(set(raw_modules or [])) if m and m not in grouped]
return opts
def expand_modules(values):
"""
筛选值 → 实际要匹配的 module 列表。
接受三种输入并混合使用:
· 聚合名('入库(全部)')→ 展开为其全部成员值
· 真实 module 值('入库管理')→ 原样保留
· 未登记的任意值 → 原样保留(不猜、不丢弃,查不到就是查不到)
返回**去重后**的列表 —— 用户同时选了「入库(全部)」与「入库管理」时,
展开会出现重复值,虽然 .in_() 去重与否结果相同,但重复值会让
SQL 参数列表无谓变长。
"""
out = []
for v in values:
v = (v or '').strip()
if not v:
continue
out.extend(MODULE_GROUPS.get(v, (v,)))
# dict.fromkeys 去重且保序(Python 3.7+ 的 dict 有序)
return list(dict.fromkeys(out))
def _stringify_keys(mapping):
"""
把映射的键统一转成字符串。
★ 两重必要性:
1. JSON 对象的键本来就只能是字符串;
2. Flask 的 jsonify 默认开启 sort_keys,而「借还管理」的状态表里
int 键(0-4)与 str 键('borrowed')混用 —— 排序时会直接抛
TypeError: '<' not supported between instances of 'str' and 'int'。
实测这个错会让接口 500,且被全局错误处理器吞掉、日志里看不到堆栈。
前端取值时用 String(value) 归一化,正好对上字符串键。
"""
return {str(k): v for k, v in mapping.items()}
def labels_payload():
"""
下发给前端的合并映射。
前端替换数据来源即可,取用方式不用改。除字段名外还带上**码值**映射,
这样审计页的详情弹窗也不必再显示「状态: 1 → 3」。
"""
return {
'action': ACTION_LABELS,
'field': FIELD_LABELS,
'status': {
mod: _stringify_keys(m) for mod, m in STATUS_LABELS_BY_MODULE.items()
},
'defaultStatus': _stringify_keys(DEFAULT_STATUS_LABELS),
'booleanFields': sorted(BOOLEAN_FIELDS),
'enumFields': sorted(ENUM_FIELDS),
# 模块聚合规则及其成员。前端**不需要**它来渲染下拉(下拉的选项由
# /audit/modules 下发),保留是为了让"规则"本身可被查看/调试。
'moduleGroups': {k: list(v) for k, v in MODULE_GROUPS.items()},
# {原始 module 值: 展示名} —— 前端用它把表格/详情里的原始值翻成中文,
# 否则会「下拉显示『入库』、表格显示『库存管理』」。
# ★ 含英文历史值,前端不再自带一份 moduleMap。
'moduleDisplay': module_display_map(),
# 详情快照里不展示的纯技术字段(前端过滤用,见 HIDDEN_SNAPSHOT_FIELDS)
'hiddenFields': sorted(HIDDEN_SNAPSHOT_FIELDS),
# 快照键 → 展示标题,按优先级排列(见 SNAPSHOT_VIEWS)。
# 前端据此把 created / deleted_snapshot / payload 三种存法抹平,
# 不硬编键名。
'snapshotViews': [{'key': k, 'title': t} for k, t in SNAPSHOT_VIEWS],
}

View File

@ -25,3 +25,33 @@ class UserRole:
PURCHASER: '采购员', PURCHASER: '采购员',
SALES: '销售' SALES: '销售'
} }
class OutboundType:
"""
出库类型(trans_outbound.outbound_type)。
★ 前端 views/outbound/index.vue 的 formatType() 里另有一份同名映射,
两份是手工同步的副本。日报(后端)需要同一份口径,故在此定义;
**改这里时请一并改前端**,或后续像审计标签那样改为接口下发。
"""
SALES = 'SALES' # 销售出库
USE = 'USE' # 内部领用
PRODUCTION = 'PRODUCTION' # 生产出库
SCRAP = 'SCRAP' # 报废
LOSS = 'LOSS' # 盘亏出库
REPAIR = 'REPAIR' # 维修出库
LABELS = {
SALES: '销售出库',
USE: '内部领用',
PRODUCTION: '生产出库',
SCRAP: '报废',
LOSS: '盘亏出库',
REPAIR: '维修出库',
}
@classmethod
def label(cls, code):
"""未命中时原样返回,避免报表上出现空白"""
return cls.LABELS.get((code or '').strip(), (code or '').strip())

View File

@ -7,13 +7,48 @@ import os
import smtplib import smtplib
import ssl import ssl
import logging import logging
from email.mime.application import MIMEApplication
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.header import Header from email.header import Header
from email.utils import formatdate, make_msgid
from typing import List, Union from typing import List, Union
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 附件扩展名 → MIME 子类型。
#
# ★ .xlsx 必须用官方类型,不能图省事写 'xlsx':那样 Content-Type 会变成
# application/xlsx,Outlook 等客户端会当成未知二进制,附件无法双击直接
# 打开(得先存盘再手动改后缀),用户只会反馈"附件打不开"。
_XLSX_SUBTYPE = 'vnd.openxmlformats-officedocument.spreadsheetml.sheet'
_ATTACHMENT_SUBTYPES = {
'.xlsx': _XLSX_SUBTYPE,
'.xls': 'vnd.ms-excel',
'.csv': 'vnd.ms-excel',
'.pdf': 'pdf',
'.zip': 'zip',
}
def _attachment_subtype(filename: str) -> str:
"""按扩展名给出 MIME 子类型;未登记的回落到 octet-stream(即默认值)"""
ext = os.path.splitext(filename or '')[1].lower()
return _ATTACHMENT_SUBTYPES.get(ext, 'octet-stream')
def _normalize_attachment(att):
"""
附件入参 → (文件名, 字节)。接受两种写法,避免调用方纠结:
{'filename': '日报.xlsx', 'data': b'...'}
('日报.xlsx', b'...')
"""
if isinstance(att, dict):
return att.get('filename') or 'attachment', att.get('data') or b''
filename, data = att
return filename or 'attachment', data or b''
def _get_config(): def _get_config():
""" """
@ -45,7 +80,8 @@ def _get_config():
} }
def send_email(to_email: Union[str, List[str]], subject: str, content: str, cfg: dict = None): def send_email(to_email: Union[str, List[str]], subject: str, content: str,
cfg: dict = None, attachments: list = None):
""" """
通用邮件发送函数 通用邮件发送函数
@ -54,13 +90,24 @@ def send_email(to_email: Union[str, List[str]], subject: str, content: str, cfg:
subject: 邮件主题 subject: 邮件主题
content: 邮件正文(纯文本) content: 邮件正文(纯文本)
cfg: 可选,预先获取的配置字典(用于异步线程传参,避免丢失 Flask context) cfg: 可选,预先获取的配置字典(用于异步线程传参,避免丢失 Flask context)
attachments: 可选,附件列表。每项为
{'filename': '日报.xlsx', 'data': b'...'} 或 ('日报.xlsx', b'...')。
数据是**字节**而非路径 —— 调用方在内存里生成,不落盘。
发送失败时打印日志,不抛出异常 发送失败时打印日志,不抛出异常
""" """
if cfg is None: if cfg is None:
cfg = _get_config() cfg = _get_config()
print(f"[DEBUG send_email] cfg = {cfg}") # ★ 不要把 cfg 整个打出来 —— 它含 'password'(邮箱授权码),
# 一 print 就落到 stdout → docker logs,任何能看日志的人都拿得到发信凭证。
# 只打非敏感字段,密码只报"有没有设"。
_pw_state = '已设置' if cfg.get('password') else '空'
print(
f"[DEBUG send_email] server={cfg.get('server')} port={cfg.get('port')} "
f"sender={cfg.get('sender')} ssl={cfg.get('use_ssl')} tls={cfg.get('use_tls')} "
f"enabled={cfg.get('enabled')} password={_pw_state}"
)
# 发送总开关 # 发送总开关
if not cfg.get('enabled'): if not cfg.get('enabled'):
@ -82,13 +129,41 @@ def send_email(to_email: Union[str, List[str]], subject: str, content: str, cfg:
return return
try: try:
msg = MIMEMultipart() # ★ 显式 'mixed':虽然 MIMEMultipart() 默认就是 mixed,但正文+附件
# 的语义要求就在这里,写出来才不会被后人"顺手"改成 related/alternative。
msg = MIMEMultipart('mixed')
msg['From'] = cfg['sender'] msg['From'] = cfg['sender']
msg['To'] = ', '.join(recipients) msg['To'] = ', '.join(recipients)
msg['Subject'] = Header(subject, 'utf-8') msg['Subject'] = Header(subject, 'utf-8')
# ★ Date 与 Message-ID 是 RFC 5322 的**必需/强烈建议**头,
# smtplib 不会自动补。缺了它们,收件方的反垃圾引擎会显著加分
# ("来源不明的自动化邮件"),很容易被判成垃圾或进隔离区 ——
# 而发件服务器照样回 250,客户端完全看不出来。
# 实测本项目的邮件此前两个头都没有。
msg['Date'] = formatdate(localtime=True)
msg['Message-ID'] = make_msgid(domain=(cfg['sender'].split('@')[-1] or None))
msg.attach(MIMEText(content, 'plain', 'utf-8')) msg.attach(MIMEText(content, 'plain', 'utf-8'))
print(f"DEBUG: 准备向服务器提交发信请求,收件人: {recipients} 发件人: {cfg['username']}") # 附件:内容已在内存里,直接 base64 编码挂上,不经过文件系统
attached = 0
for att in (attachments or []):
filename, data = _normalize_attachment(att)
if not data:
logger.warning(f"[Email] 附件 {filename} 内容为空,已跳过")
continue
part = MIMEApplication(data, _subtype=_attachment_subtype(filename))
# ★ 中文文件名必须走 RFC 2231(('utf-8','',name) 三元组),
# 直接塞原始中文会生成非法的 Content-Disposition 头,
# 客户端显示成乱码或一串 =?utf-8?b?...?= 原文。
part.add_header('Content-Disposition', 'attachment',
filename=('utf-8', '', filename))
msg.attach(part)
attached += 1
print(
f"DEBUG: 准备向服务器提交发信请求,收件人: {recipients} "
f"发件人: {cfg['username']} 附件: {attached} 个"
)
if cfg.get('use_ssl'): if cfg.get('use_ssl'):
context = ssl.create_default_context() context = ssl.create_default_context()
@ -120,7 +195,8 @@ def send_email(to_email: Union[str, List[str]], subject: str, content: str, cfg:
logger.error(f"[Email] 发送邮件时发生未知异常: {e}") logger.error(f"[Email] 发送邮件时发生未知异常: {e}")
def send_email_async(to_email: Union[str, List[str]], subject: str, content: str): def send_email_async(to_email: Union[str, List[str]], subject: str, content: str,
attachments: list = None):
""" """
异步发送邮件(守护线程,不阻塞主请求线程)。 异步发送邮件(守护线程,不阻塞主请求线程)。
@ -133,7 +209,7 @@ def send_email_async(to_email: Union[str, List[str]], subject: str, content: str
t = threading.Thread( t = threading.Thread(
target=send_email, target=send_email,
args=(to_email, subject, content), args=(to_email, subject, content),
kwargs={'cfg': cfg}, kwargs={'cfg': cfg, 'attachments': attachments},
daemon=True daemon=True
) )
t.start() t.start()

View File

@ -0,0 +1,70 @@
"""
跨进程的定时任务互斥锁。
为什么需要这个
--------------
`gunicorn.conf.py` 配了 8 个 worker(`workers = min(cpu*2+1, 8)`),而 `run.py`
是在**模块级**启动 APScheduler 的 —— gunicorn 未开 preload_app,每个 worker
都会独立 import 一次 `run.py`,于是**每个 worker 各起一份调度器**,同一个
cron 任务在相同时刻被并发执行 8 次。
实测:容器里跑着 8 个 worker 进程,`create_app()` 与"调度器已启动"两条日志
的出现次数完全相等 —— 每个 worker 都配了一份。
后果:库存预警邮件每天实际会重复发 8 封。
APScheduler 自带的 `max_instances` 只在**单个调度器实例内**生效,管不了
跨进程;`replace_existing` 同理。这里用 PostgreSQL 的**会话级咨询锁**
(`pg_try_advisory_lock`)做真正的跨进程互斥:抢到锁的那个 worker 才执行,
其余直接跳过。好处是不用引入 Redis 之类的新组件(compose 里也没有 redis,
`redis_client` 恒为 None,相关装饰器全程 fail-open,指望不上)。
★ 会话级锁绑定在**连接**上,必须在同一条连接上解锁 —— 所以这里显式取一条
专用连接,用完归还,不复用请求上下文里的 session。
"""
import logging
from contextlib import contextmanager
from sqlalchemy import text
from app.extensions import db
logger = logging.getLogger(__name__)
# 锁标识:PostgreSQL 咨询锁是 bigint,取固定值便于排查(不要用随机数)。
LOCK_INVENTORY_WARNING = 891001001 # 库存预警每日邮件
LOCK_DAILY_REPORT = 891001002 # MOM 系统日报
@contextmanager
def advisory_lock(key):
"""
尝试获取会话级咨询锁,产出「是否抢到」。
用法::
with advisory_lock(LOCK_DAILY_REPORT) as acquired:
if not acquired:
return # 别的 worker 正在跑,本轮跳过
...实际干活...
★ 抢不到锁是**正常路径**,不是错误 —— 说明另一个 worker 正在执行同一任务。
调用方应当静默跳过,而不是报警。
★ 拿锁失败(数据库不可用等)会让异常向上抛:宁可让调度器记一次失败,
也不要"没抢到锁"和"抢锁时数据库挂了"两种截然不同的情况被混为一谈。
"""
conn = db.engine.connect()
acquired = False
try:
acquired = bool(
conn.execute(text("SELECT pg_try_advisory_lock(:k)"), {"k": key}).scalar()
)
yield acquired
finally:
if acquired:
try:
conn.execute(text("SELECT pg_advisory_unlock(:k)"), {"k": key})
except Exception as e: # noqa: BLE001
# 解锁失败不应盖过业务异常;连接关闭时锁也会随之释放
logger.warning(f"[JobLock] 释放咨询锁 {key} 失败: {e}")
conn.close()

View File

@ -1,3 +1,4 @@
import json
import os import os
from datetime import timedelta from datetime import timedelta
@ -69,6 +70,10 @@ class Config:
MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER', 'wms@iris-rs.cn') MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER', 'wms@iris-rs.cn')
# 是否启用邮件发送功能(开发环境可设为 false 禁用) # 是否启用邮件发送功能(开发环境可设为 false 禁用)
MAIL_ENABLED = os.getenv('MAIL_ENABLED', 'true').lower() in ('true', '1', 'yes') MAIL_ENABLED = os.getenv('MAIL_ENABLED', 'true').lower() in ('true', '1', 'yes')
# MOM 系统日报收件人(逗号分隔多个)。留空则日报任务跳过发送并在日志告警。
MAIL_DAILY_REPORT_RECIPIENTS = os.getenv(
'MAIL_DAILY_REPORT_RECIPIENTS', 'duxingchen@iris-rs.cn'
)
# ========================================================= # =========================================================
# 7. Track 系统 Webhook 通知配置 (发送侧) # 7. Track 系统 Webhook 通知配置 (发送侧)
@ -85,3 +90,31 @@ class Config:
TRACK_API_URL = os.getenv('TRACK_API_URL', 'http://track_backend:8000') TRACK_API_URL = os.getenv('TRACK_API_URL', 'http://track_backend:8000')
# Track 出库 webhook 接收地址(发货出库时通知 Track 标记"已出库") # Track 出库 webhook 接收地址(发货出库时通知 Track 标记"已出库")
TRACK_OUTBOUND_WEBHOOK_URL = os.getenv('TRACK_OUTBOUND_WEBHOOK_URL', '') TRACK_OUTBOUND_WEBHOOK_URL = os.getenv('TRACK_OUTBOUND_WEBHOOK_URL', '')
# =========================================================
# 9. 多 Track 实例路由表 (IRIS / LICA)
# =========================================================
# JSON 结构: {"IRIS": {"api": "...", "inbound": "...", "outbound": "..."}, "LICA": {...}}
# 由 material_base.company_name 决定业务落到哪个 Track 实例。
# 缺省为空字典 → 全部回落到上面第 7/8 节的扁平变量,行为与改造前一致。
#
# ⚠️ 解析失败直接让进程起不来(fail fast)——路由表写坏却静默回落,
# 会造成"某些公司悄悄断链",比启动报错难排查得多。
try:
TRACK_ROUTES = json.loads(os.getenv('TRACK_ROUTES', '{}') or '{}')
except ValueError as e:
raise RuntimeError(f'TRACK_ROUTES 不是合法 JSON,请检查环境变量: {e}')
# =========================================================
# 10. 对内接口配置 (接收侧:Track → MOM)
# =========================================================
# Track 调 MOM 内部接口(如生产报废受理)的共享密钥,请求头 X-API-Key。
#
# ★ 与 TRACK_WEBHOOK_KEY 刻意分离,不复用:
# · 方向相反 —— 那个是 MOM 发给 Track 的凭证,这个是 Track 发给 MOM 的;
# · 权限不同 —— 本密钥能发起报废审批(间接影响台账与成本),
# 万一泄漏,两边各自轮换即可,不会连坐。
#
# ★ 未配置时内部接口一律返回 503(Fail-Closed),**不做静默放行** ——
# 一个默认开着的写接口,比一个没配好的接口危险得多。
MOM_INTERNAL_API_KEY = os.getenv('MOM_INTERNAL_API_KEY', '')

View File

@ -1,10 +1,31 @@
# inventory-backend/run.py # inventory-backend/run.py
import sys
from app import create_app from app import create_app
# ★ stdout 行缓冲。
# gunicorn 的 stdout 是管道,Python 默认对它做**块缓冲** —— print() 的内容
# 会攒在缓冲区里,docker logs 里看不到。实测 17:30 的日报任务:8 个 worker
# 里只刷出 2 条日志,连抢到锁那个 worker 的成败都没出现,完全无从排查。
# 调度任务是后台线程,出问题本来就不容易发现,再叠加缓冲就等于瞎跑。
if hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(line_buffering=True)
app = create_app() app = create_app()
# ========================================================= # =========================================================
# 启动时注册库存预警定时任务(每天 9:30 北京时) # 定时任务注册
#
# ★★ 每个任务都必须套 advisory_lock,否则会被执行 **8 次**。
#
# gunicorn.conf.py 配了 8 个 worker,且未开 preload_app —— 每个 worker
# 都会独立 import 一次本文件,于是在模块级启动的这份调度器会**每个
# worker 各起一份**,同一个 cron 任务在相同时刻被并发执行 8 次。
# (库存预警此前正是如此:5 条预警配置,每天实际发 8 封重复邮件。
# APScheduler 的 max_instances 只管单个调度器实例内,管不了跨进程。)
#
# advisory_lock 用 PostgreSQL 会话级咨询锁做真正的跨进程互斥,
# 抢到锁的 worker 才执行,其余静默跳过。详见 app/utils/job_lock.py。
# ========================================================= # =========================================================
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
@ -13,14 +34,39 @@ import pytz
beijing_tz = pytz.timezone('Asia/Shanghai') beijing_tz = pytz.timezone('Asia/Shanghai')
# 调度日志一律再加一层 flush=True(理由见文件头部的行缓冲说明)——
# 定时任务跑在后台线程,出问题时唯一的线索就是这几行输出,不能丢。
def _run_warning_job(): def _run_warning_job():
"""库存预警扫描与邮件发送(每天 9:30 北京时间)"""
with app.app_context(): with app.app_context():
from app.utils.job_lock import advisory_lock, LOCK_INVENTORY_WARNING
with advisory_lock(LOCK_INVENTORY_WARNING) as acquired:
if not acquired:
# 正常路径:另一个 worker 正在执行同一任务
print("[Scheduler] 库存预警:另一 worker 正在执行,本轮跳过", flush=True)
return
try: try:
from app.services.inventory_task import InventoryWarningService from app.services.inventory_task import InventoryWarningService
result = InventoryWarningService.check_and_send_warning_emails() result = InventoryWarningService.check_and_send_warning_emails()
print(f"[Scheduler] 库存预警扫描完成: red={result['red_count']}, yellow={result['yellow_count']}") print(f"[Scheduler] 库存预警扫描完成: red={result['red_count']}, yellow={result['yellow_count']}", flush=True)
except Exception as e: except Exception as e:
print(f"[Scheduler] 库存预警任务失败: {e}") print(f"[Scheduler] 库存预警任务失败: {e}", flush=True)
def _run_daily_report_job():
"""MOM 系统日报(每天 17:30 北京时间)"""
with app.app_context():
from app.utils.job_lock import advisory_lock, LOCK_DAILY_REPORT
with advisory_lock(LOCK_DAILY_REPORT) as acquired:
if not acquired:
print("[Scheduler] 系统日报:另一 worker 正在执行,本轮跳过", flush=True)
return
try:
from app.services.daily_report_service import DailyReportService
r = DailyReportService.send_daily_report()
print(f"[Scheduler] 系统日报已发送: {r['subject']} -> {r['recipients']}", flush=True)
except Exception as e:
print(f"[Scheduler] 系统日报任务失败: {e}", flush=True)
scheduler = BackgroundScheduler(timezone=beijing_tz) scheduler = BackgroundScheduler(timezone=beijing_tz)
@ -31,8 +77,15 @@ scheduler.add_job(
name='库存预警每日邮件发送', name='库存预警每日邮件发送',
replace_existing=True replace_existing=True
) )
scheduler.add_job(
func=_run_daily_report_job,
trigger=CronTrigger(hour=17, minute=30, timezone=beijing_tz),
id='daily_report',
name='MOM系统日报',
replace_existing=True
)
scheduler.start() scheduler.start()
print("✅ 库存预警定时任务已启动(每天 9:30 北京时间执行)") print("✅ 定时任务已启动:库存预警 9:30 / MOM系统日报 17:30(北京时间)", flush=True)
if __name__ == '__main__': if __name__ == '__main__':
# ================================================= # =================================================

View File

@ -239,7 +239,7 @@ const handleLogout = () => {
<footer v-if="!isLoginPage" class="app-footer"> <footer v-if="!isLoginPage" class="app-footer">
<span class="version-tag"> <span class="version-tag">
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon> <el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
当前版本:V3.90 当前版本:V3.95
</span> </span>
</footer> </footer>

View File

@ -24,3 +24,40 @@ export function getAuditModules() {
method: 'get' method: 'get'
}) })
} }
// 获取可选操作人列表
//
// ★ 返回 [{value: 账号, label: '名(账号)'}],按记录数降序,已排除 system。
// 从审计日志本身取而非用户表:用户被删/改名后仍能按历史账号筛选。
export function getAuditOperators() {
return request({
url: '/audit/operators',
method: 'get'
})
}
// 获取操作类型 / 字段名的中文映射
// ★ 后端为唯一来源,前端不再自带副本(见 views/system/AuditLog.vue 的说明)
export function getAuditLabels() {
return request({
url: '/audit/labels',
method: 'get'
})
}
// 按筛选条件导出审计日志 Excel
//
// ★ 与列表页**同一套筛选参数**,但后端导出的是**全部匹配结果**而不是当前页 ——
// 用户点「导出」要的就是全量,只导当前页会让他以为数据被截断了。
// ★ responseType: 'blob' 不能省,否则 axios 会把二进制当文本解析,文件打不开。
export function exportAuditLogs(params: any) {
return request({
url: '/audit/logs/export',
method: 'get',
params,
responseType: 'blob' as any,
headers: {
'Accept': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
}
})
}

View File

@ -29,6 +29,25 @@ export function getScrapRecords(params: any) {
}) })
} }
// 4. 报废记录导出 Excel
//
// ★ 与列表页**同一套筛选参数**,但后端导出的是**全部匹配结果**而不是当前页 ——
// 用户点「导出」要的就是全量,只导当前页会让他以为数据被截断了。
// ★ responseType: 'blob' 不能省,否则 axios 会把二进制当文本解析,文件打不开。
// ★ 损失金额由后端按 scrap_list:loss_amount 权限决定可不可见(与列表页同口径),
// 前端不做二次处理 —— 否则导出会变成绕过字段级权限的后门。
export function exportScrapRecords(params: any) {
return request({
url: '/v1/scrap/records/export',
method: 'get',
params,
responseType: 'blob' as any,
headers: {
'Accept': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
}
})
}
// 3.1 报废申请专用库存列表(独立于出库选单权限,Fail-Closed 剥离价格) // 3.1 报废申请专用库存列表(独立于出库选单权限,Fail-Closed 剥离价格)
export function getScrapStockList(params: { page?: number; pageSize?: number; keyword?: string }) { export function getScrapStockList(params: { page?: number; pageSize?: number; keyword?: string }) {
return request({ return request({

View File

@ -73,6 +73,19 @@
<template #default="{ row }">{{ row.applicant_name || getApplicantName(row.applicant_id) }}</template> <template #default="{ row }">{{ row.applicant_name || getApplicantName(row.applicant_id) }}</template>
</el-table-column> </el-table-column>
<!-- 原因分类:审批人得知道批的是哪个环节的损失。
「生产损耗」= 好料领用后变坏;「库存/采购」= 在库或买来就有问题。
存量单没有分类(列是后加的),显示 '-' 而不是猜一个 -->
<el-table-column label="原因分类" width="110" align="center">
<template #default="{ row }">
<el-tag v-if="row.reason_category_label" size="small"
:type="row.reason_category === 'PRODUCTION' ? 'warning' : 'info'">
{{ row.reason_category_label }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="remark" label="申请原因" min-width="180" show-overflow-tooltip /> <el-table-column prop="remark" label="申请原因" min-width="180" show-overflow-tooltip />
<el-table-column label="报废种类" width="100" align="center"> <el-table-column label="报废种类" width="100" align="center">

View File

@ -35,9 +35,24 @@
/> />
</el-form-item> </el-form-item>
<!-- 原因分类:两种互斥口径 ——
生产报废 = **所有走 Track 的**(料领到产线后在生产中变坏);
库存报废 = **MOM 自身流程走下来的**(不看 Track、直接选库存行报废等)。
⚠️ 选「库存报废」时后端会**一并兜住空值**:本列上线前的历史台账没有
分类,而它们全都是 MOM 自身流程产生的,不兜会漏掉全部历史单 -->
<el-form-item label="原因分类">
<el-select v-model="listQuery.reason_category" clearable
placeholder="全部" style="width: 130px">
<el-option label="生产报废" value="PRODUCTION" />
<el-option label="库存报废" value="STOCK" />
</el-select>
</el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" @click="fetchData">查询</el-button> <el-button type="primary" @click="fetchData">查询</el-button>
<el-button @click="resetFilter">重置</el-button> <el-button @click="resetFilter">重置</el-button>
<!-- 导出:与当前筛选条件一致,但导的是**全部匹配结果**而不是当前页 -->
<el-button type="success" :loading="exporting" @click="handleExport">导出 Excel</el-button>
<!-- ★ 高级筛选:对齐 material/list.vue 既有模式 --> <!-- ★ 高级筛选:对齐 material/list.vue 既有模式 -->
<el-popover <el-popover
@ -114,6 +129,21 @@
<span v-else>-</span> <span v-else>-</span>
</template> </template>
</el-table-column> </el-table-column>
<!-- 报废原因分类:回答「这笔损失出在哪个环节」。
生产损耗(好料领用后变坏)vs 库存/采购(在库或买来就有问题)。
★ 只展示,不做任何业务判断 —— 分类由提交方**显式**写入,
不能从来源推导(Track 的生产报废与手工的不良品退回共用
同一张 trans_defective_goods 表,推导会把生产损失算成库存损失)。
⚠️ 存量单没有分类(列是后加的),显示 '-' 而不是猜一个 -->
<el-table-column label="原因分类" width="110" align="center">
<template #default="{ row }">
<el-tag v-if="row.reason_category_label" size="small"
:type="row.reason_category === 'PRODUCTION' ? 'warning' : 'info'">
{{ row.reason_category_label }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="reason" label="报废原因" min-width="160" show-overflow-tooltip> <el-table-column prop="reason" label="报废原因" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.reason || '-' }}</template> <template #default="{ row }">{{ row.reason || '-' }}</template>
</el-table-column> </el-table-column>
@ -156,6 +186,17 @@
</template> </template>
</el-table-column> </el-table-column>
<!-- 原因分类也放主行:不用展开就能一眼看出这笔损失出在哪个环节 -->
<el-table-column label="原因分类" width="110" align="center">
<template #default="{ row }">
<el-tag v-if="row.reason_category_label" size="small"
:type="row.reason_category === 'PRODUCTION' ? 'warning' : 'info'">
{{ row.reason_category_label }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="审批状态" width="110" align="center"> <el-table-column label="审批状态" width="110" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="getStatusType(row.approval_status)"> <el-tag :type="getStatusType(row.approval_status)">
@ -180,7 +221,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue' import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { getScrapRecords } from '@/api/scrap' import { getScrapRecords, exportScrapRecords } from '@/api/scrap'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
const userStore = useUserStore() const userStore = useUserStore()
@ -197,6 +238,8 @@ const listQuery = reactive({
search_type: 'all', search_type: 'all',
dateRange: [] as string[], dateRange: [] as string[],
advancedFilters: [] as any[], advancedFilters: [] as any[],
// '' = 全部;PRODUCTION = 生产报废;STOCK = 库存报废
reason_category: '',
}) })
// --- ★ 高级筛选 --- // --- ★ 高级筛选 ---
@ -255,6 +298,8 @@ const fetchData = async () => {
search_type: listQuery.search_type, search_type: listQuery.search_type,
// ★ 高级筛选:后端约定参数名为 advancedFilters,值为 JSON 字符串 // ★ 高级筛选:后端约定参数名为 advancedFilters,值为 JSON 字符串
advancedFilters: JSON.stringify(listQuery.advancedFilters || []), advancedFilters: JSON.stringify(listQuery.advancedFilters || []),
// 原因分类:空串 = 不筛
reason_category: listQuery.reason_category || '',
} }
if (listQuery.dateRange && listQuery.dateRange.length === 2) { if (listQuery.dateRange && listQuery.dateRange.length === 2) {
params.start_date = listQuery.dateRange[0] params.start_date = listQuery.dateRange[0]
@ -294,12 +339,53 @@ const resetFilter = () => {
listQuery.search_type = 'all' listQuery.search_type = 'all'
listQuery.dateRange = [] listQuery.dateRange = []
listQuery.advancedFilters = [] listQuery.advancedFilters = []
listQuery.reason_category = ''
advancedConditions.value = [{ field: '', operator: '', value: '' }] advancedConditions.value = [{ field: '', operator: '', value: '' }]
appliedConditions.value = [] appliedConditions.value = []
listQuery.page = 1 listQuery.page = 1
fetchData() fetchData()
} }
// ---- 导出 Excel ----
// 与列表**同一套筛选条件**,但导的是全部匹配结果(后端不限当前页)。
// 下载是浏览器的静默行为,页面不会跳转 —— 所以要给一条「导出中」的反馈,
// 否则大表导出时用户会以为按钮没反应。
const exporting = ref(false)
const handleExport = async () => {
exporting.value = true
try {
const params: any = {
keyword: listQuery.keyword,
search_type: listQuery.search_type,
advancedFilters: JSON.stringify(listQuery.advancedFilters || []),
reason_category: listQuery.reason_category || '',
}
if (listQuery.dateRange && listQuery.dateRange.length === 2) {
params.start_date = listQuery.dateRange[0]
params.end_date = listQuery.dateRange[1]
}
const res: any = await exportScrapRecords(params)
const blob = new Blob([res], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
})
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
const ymd = new Date().toISOString().split('T')[0]
link.download = `报废记录_${ymd}.xlsx`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success('导出成功')
} catch (e) {
console.error('导出报废记录失败', e)
ElMessage.error('导出失败,请重试')
} finally {
exporting.value = false
}
}
// 报废台账写在执行/提交时,approval_status 的取值为这两个 // 报废台账写在执行/提交时,approval_status 的取值为这两个
const getStatusType = (status: string) => { const getStatusType = (status: string) => {
const map: Record<string, string> = { const map: Record<string, string> = {

View File

@ -235,8 +235,41 @@
</el-col> </el-col>
<el-col :span="24" :md="8"> <el-col :span="24" :md="8">
<!--
★ 领用人改为**下拉选择**(此前是自由文本框)。
为什么:实测 1497 条出库里有 7 条领用人对不上任何在职人员,
其中就有把姓名打成「刘」这种错字 —— 手输必然会有这类问题。
现在从人员名单里选,与「经办人(库管)」的交互也统一了。
★ 选了审批单会自动带出申请人(见 handleRequestChange),
仍可改成别人。
★ 保留 allow-create:销售出库的客户可能是外部人员、没有账号,
封死会让这类出库没法登记。但**手输的名字会给出提示**
(见下方 consumerUnknown 的告警),既不挡业务也不放任错字。
-->
<el-form-item label="领用人/客户" prop="consumer_name"> <el-form-item label="领用人/客户" prop="consumer_name">
<el-input v-model="form.consumer_name" placeholder="请输入姓名" /> <el-select
v-model="form.consumer_name"
filterable
allow-create
default-first-option
:filter-method="filterUsers"
placeholder="选择或输入姓名"
style="width: 100%"
>
<el-option
v-for="u in filteredUsers"
:key="u.id"
:label="u.name"
:value="u.name"
/>
</el-select>
<!-- ★ 软提示而非硬拦截:不在名单里也能提交(外部客户),
但要让库管看见"这个名字系统里没有" —— 错字就是这么挡住的 -->
<div v-if="consumerUnknown" class="field-warn">
「{{ form.consumer_name }}」不在在职人员名单中,请确认没写错
</div>
</el-form-item> </el-form-item>
</el-col> </el-col>
@ -385,6 +418,8 @@ import {
getScanDraft, saveScanDraft, clearScanDraft, getScanDraftOverview, getScanDraft, saveScanDraft, clearScanDraft, getScanDraftOverview,
} from '@/api/outbound' } from '@/api/outbound'
import { uploadFile } from '@/api/common/upload' import { uploadFile } from '@/api/common/upload'
// 在职人员名单 —— 领用人下拉的数据源(与出库补发的「补发给谁」同一份)
import { getActiveUsers } from '@/api/common/users'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
const userStore = useUserStore() const userStore = useUserStore()
@ -416,6 +451,76 @@ const lastY = ref(0)
const operatorOptions = ref<string[]>([]) const operatorOptions = ref<string[]>([])
// ============================================================================
// 领用人选择器
//
// 数据源是统一的在职人员接口(与「补发给谁」同一个),公司隔离由后端负责。
// ============================================================================
const activeUsers = ref<Array<{ id: number; name: string; account: string }>>([])
const userQuery = ref('')
// 动态模糊匹配:姓名与**拼音**都能搜。
// ★ 默认的 filterable 只匹配 label(也就是姓名),输 gaoxue 匹配不到高雪;
// 账号字段正是那段拼音,这里一并比对。
const filterUsers = (query: string) => {
userQuery.value = query || ''
}
const filteredUsers = computed(() => {
const q = userQuery.value.trim().toLowerCase()
if (!q) return activeUsers.value
return activeUsers.value.filter(u =>
u.name.toLowerCase().includes(q) ||
(u.account || '').toLowerCase().includes(q)
)
})
// 当前填的领用人是否**不在**在职名单里。
// ★ 只做提示不做拦截:外部客户确实可能没有账号,但错字必须让人看见。
const consumerUnknown = computed(() => {
const v = String(form.consumer_name || '').trim()
if (!v || !activeUsers.value.length) return false
return !activeUsers.value.some(u => u.name === v)
})
const loadActiveUsers = async () => {
try {
const res: any = await getActiveUsers()
if (res.code === 200 && res.data) {
activeUsers.value = res.data
}
} catch (e) {
console.warn('[出库] 在职人员名单拉取失败,领用人需手动输入', e)
}
}
/**
* 领用人默认取所选审批单的**申请人**。
*
* ★ 优先按 applicant_id 在在职名单里反查,而不是直接用接口返回的
* applicant_name:后者是 '高雪/gaoxue'(后端 _get_user_name 返回完整
* username),直接落库会把台账里存姓名的口径搞乱(实测 1497 行里
* 没有一条带 '/' 的领用人)。按 id 反查拿到的姓名与下拉选项**同源**。
*/
const fillConsumerFromRequest = () => {
const req = selectedRequest.value
if (!req) return
const hit = activeUsers.value.find(u => u.id === req.applicant_id)
if (hit) {
form.consumer_name = hit.name
return
}
// 回落:申请人已离职(不在在职名单)或名单还没加载完
const raw = String(req.applicant_name || '')
const nm = raw.split('/')[0].trim()
// 后端查不到用户时会返回 '未知用户(7)' / '用户(7)',这种不能当姓名用
if (nm && !nm.startsWith('未知用户') && !nm.startsWith('用户(')) {
form.consumer_name = nm
}
}
const form = reactive({ const form = reactive({
outbound_type: '', outbound_type: '',
consumer_name: '', consumer_name: '',
@ -566,6 +671,8 @@ const handleRequestChange = async (val: number | null) => {
if (selectedRequest.value?.outbound_type) { if (selectedRequest.value?.outbound_type) {
form.outbound_type = selectedRequest.value.outbound_type form.outbound_type = selectedRequest.value.outbound_type
} }
// ★ 领用人默认就是申请人(仍可改成别人)
fillConsumerFromRequest()
} }
cartItems.value = [] cartItems.value = []
@ -805,6 +912,8 @@ onMounted(() => {
operatorOptions.value.push(userStore.username) operatorOptions.value.push(userStore.username)
} }
loadHistoryOperators() loadHistoryOperators()
// 领用人下拉的数据源(与「补发给谁」同一个接口)
loadActiveUsers()
}) })
const loadHistoryOperators = async () => { const loadHistoryOperators = async () => {
@ -1298,6 +1407,8 @@ onUnmounted(() => {
/* ★ 审批单选择 */ /* ★ 审批单选择 */
.approval-request-select { margin-bottom: 16px; } .approval-request-select { margin-bottom: 16px; }
.select-tip { margin: 6px 0 0 0; color: #909399; font-size: 12px; } .select-tip { margin: 6px 0 0 0; color: #909399; font-size: 12px; }
/* 领用人不在在职名单时的软提示(外部客户不算错,但要让人看见) */
.field-warn { margin-top: 4px; color: #E6A23C; font-size: 12px; line-height: 1.4; }
/* ★ 计划清单 */ /* ★ 计划清单 */
.planned-items-section { .planned-items-section {

View File

@ -1943,14 +1943,21 @@ const confirmPurchaseImport = () => {
form.tax_rate = Number(po.tax_rate) form.tax_rate = Number(po.tax_rate)
} }
if (hasTotalPrice) { // 导入新单前清掉上一单残留的"手工总价"标记,否则 updatePrices 末尾会用旧值回头覆盖
// 优先: 含税总价 → 调用 post_total 逻辑反算单价 postTaxTotalManuallySet.value = false
postTaxTotalInput.value = Number(po.total_price) postTaxTotalInput.value = undefined
onPostTaxTotalChange(postTaxTotalInput.value)
} else if (hasUnitPrice) { if (hasUnitPrice) {
// 备选: 含税单价 → 反算不含税 // ★ 单价优先。申请单的 total_price 是「申请数量」的钱,而实际入库数量未必相同
// —— 厂家怕出问题多发(申请 100 个、实发 104 个)是常态。用总价去除以实际
// 入库数量会把单价摊薄或抬高,必须一律以单价为准,总价由「单价 × 实际入库数量」得出。
// 含税单价 → 反算不含税
form.post_tax_unit_price = Number(po.unit_price) form.post_tax_unit_price = Number(po.unit_price)
updatePrices('post') updatePrices('post')
} else if (hasTotalPrice) {
// 兜底:申请单只有总价、没有单价时,才按总价反算
postTaxTotalInput.value = Number(po.total_price)
onPostTaxTotalChange(postTaxTotalInput.value)
} }
if (po.supplier_link) { if (po.supplier_link) {
form.detail_link = po.supplier_link form.detail_link = po.supplier_link

File diff suppressed because it is too large Load Diff