41 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
3b777af87d V3.90,9.21推送 2026-09-21 14:26:07 +08:00
2319a053df fix(bom): 备注框移到子件列表上方,改为 textarea
原位置在编号/版本号那一行的 span=6 里,850px 弹窗扣掉 label-width:120px
和 gutter 后,输入框实际只剩 60 余像素,稍长一点的备注根本看不清。

改为独立整行 textarea(rows=2, maxlength=500, show-word-limit),放在
「子件列表」标题上方;编号行改为 span 10+14 填满。

连带修两处相关缺陷:
  · 暂存草稿的 payload 补 remark —— 此前根本没往后端传
  · getDraftHash 补 remark —— 否则"只改了备注"会被防呆逻辑拦成
    "草稿数据无变动,无需重复暂存"
2026-09-21 14:13:15 +08:00
6a7761abbb fix(bom): 草稿链路透传 BOM 级备注,发布后不丢失
save_draft / get_draft_detail / publish_draft 三处此前都不带 BOM 级备注,
暂存后再恢复、或草稿直接发布,备注都会丢。

同时给 /save 的权限清洗表补上顶级 remark(与子件级 remark 共用
bom_manage:remark 权限码)。

注意:运行时真正生效的草稿入口是 bom.py:521 的 /draft/save。
app/api/v1/bom_draft.py 里的 bom_draft_bp 从未在 app/__init__.py 注册,
是死代码(两份都调同一个 BomDraftService,本次两处都改以保持同步)。

实测(临时 BOM,验证后已清理):
  草稿暂存带备注 → 读回 '草稿BOM级备注-测试'
  草稿 → 发布 → 正式表 读回 '草稿备注-发布后应保留'
2026-09-21 14:13:14 +08:00
0abc5850d8 fix(bom): save_bom 落库 BOM 级备注,get_bom_detail 改读 bom_remark
save_bom() 此前从未读取 data['remark'],父件级备注被静默丢弃;
get_bom_detail() 则用 first.BomTable.remark(第一个子件行的备注)冒充
主表备注——所以填了保存后再打开必为空,偶尔还会显示出某个子件的备注
(库里 1196 行只有 1 行有 remark,值是"白色")。

这正是"BOM 备注看不见"的根因。
2026-09-21 14:13:14 +08:00
cc80fdd9c9 fix(bom): 增加 bom_remark 列,为 BOM 级备注提供存储位置
bom_table 是子件明细表(一行 = 一个子件),表里只有子件级的 remark 列,
BOM 级备注没有任何地方可存。前端一直有备注输入框、提交时也确实放进了
payload,但后端 save_bom 收到后无处可写,只能静默丢弃。

沿用 parent_id / is_enabled 既有的冗余写法:同一 BOM 版本的每一行存同一
份值,读取时取首行。bom_draft_table 同步加列,保证草稿暂存与发布期间
不丢失。与既有 remark(子件级)语义分离,互不干扰。

存量数据无法回填——历史上填过的备注从未入库,只能从本迁移之后开始记录。

执行方式: docker exec -i inventory_db psql -U test -d inventory_system < db_migrations/add_bom_remark.sql
2026-09-21 14:13:13 +08:00
b6ce754ba7 V3.89,9.21推送 2026-09-21 12:03:07 +08:00
e0ea946e08 fix(outbound): 出库列表补传日期参数,修复日期筛选静默失效
前端 el-date-picker 一直传 start_date/end_date(outbound/index.vue:472),
service 层 get_grouped_list() 也早有完整实现(签名接收 + 10 位日期补全
时分秒 + outbound_time 过滤),但中间 API 层从未读取这两个参数:

  outbound.py: get_outbound_list() 只读 page/limit/keyword/search_type/
  company/advancedFilters,调用 get_grouped_list() 时也没传日期。

get_grouped_list 全项目仅此一个调用方,因此 start_date/end_date 恒为
None,service 层 `if start_date and end_date:` 永远为假——用户在页面上
选了日期,后端直接忽略,结果集与不筛选时完全一致,且不报错。

修复:
  · outbound.py 读取 start_date/end_date 并透传给 service
  · outbound_service.py 把 BETWEEN 改为分别判断(原写法只传一侧时
    整段条件静默失效,与 /returns 等接口的边界处理对齐)

实测验证(SUPER_ADMIN 全量口径,与 SQL count(distinct outbound_no) 逐项对齐):
  无日期            494  (基准)
  2026-08-24 单日     3  SQL=3
  2026-05-11 单日     9  SQL=9
  2026-08 整月       88  SQL=88
  只传 start_date   229  SQL=229
修复前上述四项均恒等于 494。运行中容器经 --reload 重载后实测一致。

影响范围:仅出库主列表。其余带日期筛选的接口(/returns、transactions、
scrap、purchase、audit、inbound_summary)均已正确读取参数,未受影响。
2026-09-21 10:33:47 +08:00
50 changed files with 6897 additions and 855 deletions

23
.gitignore vendored
View File

@ -20,3 +20,26 @@ inventory-web/*.local
pgdata_docker/
inventory-backend/uploads/
.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,36 @@
-- =============================================================================
-- 一次性迁移:BOM 级备注落库
-- -----------------------------------------------------------------------------
-- 背景:
-- 前端 BomManage.vue 一直有"备注"输入框(v-model="form.remark"),提交时也
-- 确实放进了 payload(BomManage.vue:1273),但后端 BomService.save_bom() 从
-- 未读取 data['remark'],父件级备注被**静默丢弃**——填了、保存、再打开必为空。
--
-- 根因是数据模型缺位:bom_table 是子件明细表(一行 = 一个子件),表里只有
-- 子件级的 remark 列,没有任何位置存放 BOM 级备注。
-- 连带 bug:读取侧 get_bom_detail() 用 first.BomTable.remark(第一个子件行的
-- 备注)冒充主表备注,所以查看时会显示某个子件的备注(如"白色")或空。
--
-- 方案:
-- bom_table / bom_draft_table 各增加 bom_remark 列,沿用 parent_id、is_enabled
-- 既有的冗余写法:同一 BOM 版本的每一行存同一份值,读取时取首行。
-- 与既有 remark(子件级)语义分离,互不干扰。
--
-- 存量数据:
-- 无法回填 —— 历史上填过的备注从未入库,任何位置都找不到,只能从本迁移
-- 之后开始记录。这是本次修复的已知代价。
--
-- 执行方式: docker exec -i inventory_db psql -U test -d inventory_system < 本文件
-- 注意: 若后端启用 Redis 缓存(bom:tree:*),部署后需清缓存或重启后端,
-- 避免 12h 内命中旧值。
-- =============================================================================
BEGIN;
ALTER TABLE bom_table ADD COLUMN IF NOT EXISTS bom_remark text;
ALTER TABLE bom_draft_table ADD COLUMN IF NOT EXISTS bom_remark text;
COMMENT ON COLUMN bom_table.bom_remark IS 'BOM级备注(父件级,冗余存于每行,读取取首行)';
COMMENT ON COLUMN bom_draft_table.bom_remark IS 'BOM级备注(父件级,冗余存于每行,读取取首行)';
COMMIT;

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_API_URL: http://track_backend_prod:8000
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:
- db
# 加入 mom_net 外部网络,与 Track 生产容器互通

View File

@ -34,6 +34,8 @@ services:
MAIL_DEFAULT_SENDER: wms@iris-rs.cn
MAIL_USE_SSL: "true"
MAIL_USE_TLS: "false"
# MOM 系统日报收件人(逗号分隔可配多个)。每天 17:30 北京时间发送。
MAIL_DAILY_REPORT_RECIPIENTS: ${MAIL_DAILY_REPORT_RECIPIENTS:-duxingchen@iris-rs.cn}
# Track 系统 Webhook 通知(未配置则后端静默跳过,不通知)
TRACK_WEBHOOK_URL: ${TRACK_WEBHOOK_URL:-}
TRACK_WEBHOOK_KEY: ${TRACK_WEBHOOK_KEY:-}
@ -41,6 +43,20 @@ services:
TRACK_API_URL: ${TRACK_API_URL:-}
# Track 出库 webhook(发货出库时通知 Track 标记"已出库")
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:
- db

View File

@ -135,6 +135,20 @@ def create_app():
except ImportError as 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 注册采购管理模块
# -----------------------------------------------------

View File

@ -1,47 +1,312 @@
# inventory-backend/app/api/v1/audit.py
from flask import Blueprint, request, jsonify, current_app
from flask_jwt_extended import jwt_required, get_jwt
from app.utils.decorators import permission_required
from app.models.audit import AuditLog
import io
from datetime import datetime, timedelta
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 sqlalchemy import or_
from datetime import datetime
import json
from app.models.audit import AuditLog
from app.models.system import SysUser
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__)
# =============================================================================
# 操作类型归一化
# 操作类型归一化 / 中文化标签
#
# 问题背景:历史数据里 action 有两套写法 —— 早期装饰器(已废弃)写入中文
# (新增/修改/删除/批量删除…),现行监听器写入大写英文(CREATE/UPDATE/DELETE)。
# 前端下拉框直接取 DISTINCT action,于是同时出现「CREATE」和「新增」两个选项,
# 而表格里二者又都显示为「新增」(actionMap 做了映射),用户无法分辨。
# ★ 映射表已统一收敛到 app/utils/audit_labels.py(**唯一来源**)。
# 本模块与日报服务共用同一份,前端经 GET /audit/labels 拉取同一份 ——
# 改一处即可,不会再出现"两边手工同步、改一边漏一边"的漂移。
#
# 后果:用户选了看得懂的中文项,只能搜到 3-4 月的历史数据,误以为"没有最近的内容"。
#
# 处理:对外只暴露规范值(CREATE/UPDATE/DELETE),筛选时自动展开到全部别名,
# 历史数据无需迁移即可被正确检索。
# 历史问题(迁移前):前端 AuditLog.vue 自带一份 fieldMap,后端这里自带一份
# ACTION_ALIASES,两份副本各自演化。
# =============================================================================
ACTION_ALIASES = {
'CREATE': ('CREATE', 'create', 'INSERT', 'insert', '新增', '批量生成'),
'UPDATE': ('UPDATE', 'update', '修改', '分配', '归还'),
'DELETE': ('DELETE', 'delete', '删除', '批量删除'),
}
from app.utils.audit_labels import ( # noqa: E402
ACTION_ALIASES,
canon_action,
expand_modules,
labels_payload,
module_options,
)
# 反向索引:任意别名 → 规范值
_ALIAS_TO_CANON = {
alias: canon
for canon, aliases in ACTION_ALIASES.items()
for alias in aliases
}
# 列表页「变更摘要」列的截断长度。太长会把表格撑变形且扫读性差 ——
# 完整逐字段对比在详情抽屉里,摘要只负责"一眼看出改了啥"。
SUMMARY_LIMIT = 120
# 单次导出的记录数上限。
#
# ★ 这是**熔断**,不是"为了让报表好看"的截断:
# · Excel 单表硬上限 1,048,576 行,长表展开后会成倍放大;
# · 同步请求跑太久会超时,用户只看到"失败",不知道是数据量的问题。
# 实测当前全库共 5.8 万条、全量导出 3.3 秒 / 2MB,正常情况下永远碰不到这个值。
# 触顶时会**显式告知**(写进文件名和汇总工作表),不静默丢弃 ——
# 报表的读法就是"看到的就是全部",偷偷砍掉比报错更危险。
EXPORT_LIMIT = 100000
def canon_action(action):
"""把任意写法的 action 归一化为规范值;无法识别时原样返回"""
return _ALIAS_TO_CANON.get((action or '').strip(), (action or '').strip())
# =============================================================================
# 查询构造 —— /logs 与 /logs/export 的**唯一**入口
# =============================================================================
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'])
@jwt_required()
@ -49,100 +314,25 @@ def canon_action(action):
def get_audit_logs():
"""获取审计日志列表(分页)"""
try:
# 分页参数
page = request.args.get('page', 1, type=int)
page_size = request.args.get('pageSize', 50, type=int)
# 筛选参数
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, company_limit = _build_audit_query(request.args)
# 排序
query = query.order_by(AuditLog.created_at.desc())
# 分页
pagination = query.paginate(page=page, per_page=page_size, error_out=False)
logs = pagination.items
# 序列化
data = [log.to_dict() for log in logs]
data = _serialize_logs(pagination.items)
# 获取可用的模块和操作类型(同公司范围内)
modules_query = db.session.query(AuditLog.module).distinct()
actions_query = db.session.query(AuditLog.action).distinct()
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]]
raw_modules = _distinct_with_company(AuditLog.module, company_limit)
actions_raw = _distinct_with_company(AuditLog.action, company_limit)
# ★ action 下拉项归一化:把 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({
'code': 200,
@ -152,8 +342,12 @@ def get_audit_logs():
'total': pagination.total,
'page': page,
'pageSize': page_size,
'modules': modules,
'actions': actions
# ★ [{value,label}],历史命名已折叠进聚合项且不再单独列出 ——
# 规则由后端 module_options() 统一决定,前端零硬编。
'modules': module_options(raw_modules),
'actions': actions,
# 操作人下拉选项(与 /operators 同源,顺带回一份省一次请求)
'operators': _operator_options(company_limit),
}
}), 200
@ -162,19 +356,94 @@ def get_audit_logs():
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'])
@jwt_required()
@permission_required('system_audit')
def get_audit_log_detail(log_id):
"""获取单条审计日志详情"""
"""
获取单条审计日志详情。
★ 补权限码与公司隔离。此前这里**只有 @jwt_required()**,任何登录用户
改一下 URL 里的 id 就能读到全部审计明细(含 details 里的完整快照,
那是被操作记录的原始字段值)。列表接口有 system_audit 把关,
详情接口提供的信息是列表的超集,没道理比列表更宽松。
★ 越权与不存在**统一返回 404**,不区分二者 —— 区分开来就等于告诉
探测者"这个 id 是存在的,只是你没权限",反而泄露了数据规模。
"""
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:
return jsonify({'code': 404, 'msg': '日志不存在'}), 404
return jsonify({
'code': 200,
'msg': '获取成功',
'data': log.to_dict()
# 与列表同一套序列化:action 归一 + 摘要(详情页也用得上同一条摘要)
'data': _serialize_logs([log])[0]
}), 200
except Exception as e:
@ -185,11 +454,57 @@ def get_audit_log_detail(log_id):
@audit_bp.route('/modules', methods=['GET'])
@jwt_required()
def get_modules():
"""获取所有模块列表(用于筛选)"""
"""
获取模块下拉选项(用于筛选)。
返回 [{value, label}] 而不是裸的 module 字符串 —— 历史命名已被折叠进
「入库」聚合项,规则由 audit_labels.module_options() 统一决定。
★ 只挂 @jwt_required()、不加权限码:这里返回的是模块**名称**(不含任何
业务记录),且已做公司隔离;审计页本身由 /logs 的权限码把关,
与 /labels 的处理一致。
"""
try:
modules = db.session.query(AuditLog.module).distinct().all()
modules = [m[0] for m in modules if m[0]]
return jsonify({'code': 200, 'data': modules}), 200
company_limit = get_current_company_filter()
raw_modules = _distinct_with_company(AuditLog.module, company_limit)
return jsonify({'code': 200, 'data': module_options(raw_modules)}), 200
except Exception as e:
current_app.logger.error(f"获取模块列表失败: {str(e)}")
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

@ -198,6 +198,8 @@ def save_bom():
'version': 'bom_manage:version',
'is_enabled': 'bom_manage:status',
'bom_no': 'bom_manage:bom_no',
# ★ 顶级 remark = BOM 级备注,与子件级 remark 共用同一权限码
'remark': 'bom_manage:remark',
}
# 清洗顶级字段
for field in list(req_data.keys()):
@ -531,7 +533,12 @@ def save_draft():
if not parent_id:
return jsonify({'code': 400, 'msg': 'parent_id 不能为空'}), 400
bom_draft_no = BomDraftService.save_draft(bom_no, version, parent_id, children)
# ★ 这是运行时真正生效的草稿入口(bom_draft.py 的 bom_draft_bp 未在
# app/__init__.py 注册,是死代码),BOM 级备注必须在这里透传
bom_draft_no = BomDraftService.save_draft(
bom_no, version, parent_id, children,
bom_remark=data.get('remark', '') or ''
)
return jsonify({'code': 200, 'msg': '草稿暂存成功', 'data': {'bom_no': bom_draft_no}})

View File

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

View File

@ -24,11 +24,9 @@ from app.models.inbound.stocktake import (
)
from app.models.transaction import (
TransBorrow,
TransReturn,
# 注:TransReturn / RETURN_TYPE_* / DEFECTIVE_STATUS_PENDING 曾在此使用,
# 退回逻辑抽到 return_service 后本模块不再直接碰它们,故已移出导入。
TransDefectiveGoods,
RETURN_TYPE_GOOD,
RETURN_TYPE_DEFECTIVE,
DEFECTIVE_STATUS_PENDING,
DEFECTIVE_STATUS_IN_PROGRESS,
RESTOCKABLE_DEFECTIVE_STATUSES,
SCRAPPABLE_DEFECTIVE_STATUSES,
@ -37,6 +35,13 @@ from app.models.transaction import (
)
from app.models.outbound import TransOutbound
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 (
@ -2606,46 +2611,21 @@ 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)。
★ 实现已搬到 `app/services/return_service.py`(内部接口要复用,而服务层
不得反向 import API 层)。这里保留薄包装:调用方(restock 等)一行不用改。
"""
model = get_stock_model(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
return lock_source_stock_row(source_table, stock_id)
def _assert_company_owns(row):
"""
行级多租户隔离:非跨域用户只能操作本公司库存。不满足即抛 PermissionError。
口径与扫码出库(OutboundService.get_stock_by_barcode)、状态变更接口完全一致
—— 都走 MaterialBase.company_name,避免三处隔离逻辑分叉。
★ 实现已搬到 `app/services/return_service.py`,那里收显式 company_limit
(内部接口没有 JWT,不能在里面读 get_current_company_filter)。
这里保留薄包装,把 JWT 依赖收在 API 层。
"""
company_limit = 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('无权操作其他公司的库存')
return assert_company_owns(row, get_current_company_filter())
@bp.route('/defective', methods=['GET'])
@ -2786,226 +2766,42 @@ def return_from_outbound():
operator_name = _normalize_user_id()
outbound_id = data.get('outbound_id')
is_defective = data.get('is_defective')
reason = (data.get('reason') or '').strip() or None
# 补发(可选):退回后申请人往往仍需这件东西。勾选则自动生成一张免审批出库单。
need_reissue = bool(data.get('need_reissue'))
reissue_qty = data.get('reissue_qty')
# 补发给谁:不传则回退为当前操作人(见下方补发块)
reissue_applicant_id = data.get('reissue_applicant_id')
# ---- 1. 入参校验(脏值一律挡在入口)----
# ---- 入参校验(脏值一律挡在入口)----
# 只留这一条在视图层:其余校验的文案由 service 原样抛出,见其注释
if not outbound_id:
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:
return_qty = float(data.get('return_qty') or 0)
except (TypeError, ValueError):
return jsonify({'code': 400, 'msg': 'return_qty 无效'}), 400
if return_qty <= 0:
return jsonify({'code': 400, 'msg': '退回数量必须大于 0'}), 400
try:
# ---- 2. 锁定原出库明细并校验退回额度 ----
# ★ 行锁不可省:并发两笔退回若各自读到相同的 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}'
)
# ---- 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,
# ★ 业务逻辑全部在 app/services/return_service.py —— 内部接口
# (Track → MOM 生产报废)要复用同一段逻辑,而视图里夹着 JWT 依赖,
# 服务层不能反向依赖它。本视图只负责取请求、转响应。
result = return_from_outbound_service(
outbound_id=outbound_id,
return_qty=data.get('return_qty'),
is_defective=data.get('is_defective'),
reason=(data.get('reason') or '').strip() or None,
need_reissue=bool(data.get('need_reissue')),
reissue_qty=data.get('reissue_qty'),
reissue_applicant_id=data.get('reissue_applicant_id'),
operator_name=operator_name,
company_limit=get_current_company_filter(),
)
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(),
strict=True,
)
# ★ 申请人(补发给谁)优先级:
# ① 前端显式指定 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()
reissue = result['reissue']
return jsonify({
'code': 200,
'msg': f'退回成功,{outcome}'
+ (f';已生成补发单 {reissue.request_no}' if reissue else ''),
'msg': f"退回成功,{result['outcome']}"
+ (f";已生成补发单 {reissue['request_no']}" if reissue else ''),
'data': {
'outbound_id': outbound.id,
'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,
'outbound_id': result['outbound_id'],
'return_id': result['return_id'],
'return_type': result['return_type'],
'return_qty': result['return_qty'],
'returned_quantity': result['returned_quantity'],
'returnable_quantity': result['returnable_quantity'],
'defective_goods_id': result['defective_goods_id'],
# 补发单(未勾选时为 null)
'reissue': ({
'id': reissue.id,
'request_no': reissue.request_no,
'quantity': reissue_qty,
} if reissue else None),
'reissue': reissue,
},
}), 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

@ -238,6 +238,10 @@ def get_outbound_list():
keyword = request.args.get('keyword', '')
search_type = request.args.get('search_type', 'all')
company = request.args.get('company', '')
# ★ 出库日期范围:前端 el-date-picker 传 10 位 YYYY-MM-DD,
# 时分秒补全在 service 层完成(与 /returns 等处口径一致)
start_date = (request.args.get('start_date') or '').strip()
end_date = (request.args.get('end_date') or '').strip()
# ★ 高级筛选:JSON 字符串 → 条件列表(解析失败退化为空,不影响主查询)
from app.utils.advanced_filter import parse_advanced_filters
@ -259,6 +263,7 @@ def get_outbound_list():
# ★ [修改] 调用分组查询服务,支持搜索类型
result = OutboundService.get_grouped_list(
page, limit, keyword, search_type=search_type,
start_date=start_date, end_date=end_date,
company=company, consumer_name=consumer_name,
advanced_filters=advanced_filters,
)

View File

@ -1,9 +1,10 @@
# 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 app.utils.decorators import permission_required, get_current_company_filter
from app.services.auth_service import AuthService
from app.extensions import db
from app.models.scrap_approval import scrap_category_label
from app.models.transaction import (
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.system import SysUser
import traceback
import io
from datetime import datetime
import math
scrap_bp = Blueprint('scrap', __name__, url_prefix='/scrap')
@ -166,6 +169,8 @@ def get_scrap_records():
end_date = request.args.get('end_date', '')
keyword = request.args.get('keyword', '')
search_type = request.args.get('search_type', 'all')
# 原因分类筛选:'' / PRODUCTION(生产报废) / STOCK(库存报废)
reason_category = request.args.get('reason_category', '')
# ★ 高级筛选:JSON 字符串 → 条件列表
from app.utils.advanced_filter import parse_advanced_filters
@ -181,6 +186,7 @@ def get_scrap_records():
keyword=keyword,
search_type=search_type,
advanced_filters=advanced_filters,
reason_category=reason_category,
)
# 损失金额按 scrap_list:loss_amount 权限决定可见性(原为无条件剥离,
# 会让持有该权限的角色也看不到金额,与权限元素的存在相矛盾)
@ -194,6 +200,110 @@ def get_scrap_records():
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 层:报废核心逻辑
# ============================================================
@ -405,20 +515,47 @@ class ScrapService:
if r.source_table == 'trans_defective_goods' and r.stock_id}
if tdg_ids:
from app.models.transaction import TransDefectiveGoods
for g in TransDefectiveGoods.query.filter(
TransDefectiveGoods.id.in_(tdg_ids)).all():
goods_rows = TransDefectiveGoods.query.filter(
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)] = {
'material_name': g.material_name or '',
'spec_model': g.spec_model or '',
'warehouse_location': '',
'batch_number': '',
'warehouse_location': (g.warehouse_location
or fallback.get('warehouse_location') or ''),
'batch_number': (g.batch_number
or fallback.get('batch_number') or ''),
}
return resolved
@staticmethod
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:
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:
query = query.filter(TransScrap.operation_time >= start_date)
if end_date:
@ -672,6 +821,8 @@ class ScrapService:
'total_loss': 0.0,
'total_quantity': 0.0,
'reason': r.reason or '',
'reason_category': r.reason_category or '',
'reason_category_label': scrap_category_label(r.reason_category),
'items': [],
}
groups[gkey] = g
@ -690,6 +841,8 @@ class ScrapService:
'batch_number': info.get('batch_number', ''),
'quantity': qty,
'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 '',
'loss_amount': round(loss, 2),
})
@ -791,13 +944,20 @@ def scrap_check_approval():
@jwt_required()
@permission_required('scrap_apply')
def create_scrap_request():
"""提交报废申请(不扣库存;扣减在库管执行时)"""
"""提交报废申请(不扣库存;扣减在库管执行时)
审批人有两条路(都不传则回落默认审批角色,见 scrap_approval_service):
· approver_id —— 指定具体某个人
· allowed_approvers —— 角色级名单,如 [{"type":"role","value":"SUPERVISOR"}]
"""
try:
from app.services.scrap_approval_service import ScrapApprovalService
identity = get_jwt_identity()
if not identity:
return jsonify({'code': 401, 'msg': '用户未登录'}), 401
data = request.get_json() or {}
# 公司快照:角色级审批据此做公司隔离(见 assert_same_company)。
# 申请人所属公司即「部门」,从 JWT 取;取不到留空(旧单口径,跳过公司校验)。
req = ScrapApprovalService.submit_approval(
applicant_id=int(identity),
items=data.get('items', []),
@ -805,6 +965,8 @@ def create_scrap_request():
remark=data.get('remark'),
approver_id=data.get('approver_id'),
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
except ValueError as e:
@ -835,10 +997,18 @@ def list_scrap_requests():
status = int(status) if status not in (None, '', 'all') else None
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':
kwargs['status'] = 0
# ★ 待办要同时按「指名的」和「我这个角色的」捞 —— 角色级审批下,
# 单据通常只写角色不写人。两者在 get_list 里是 OR 关系。
kwargs['approver_id'] = identity
kwargs['approver_role'] = _current_user_role()
elif scope == 'executable':
kwargs['status'] = 1
elif scope == 'all':
@ -865,9 +1035,11 @@ def get_scrap_request_detail(request_id):
if not req:
return jsonify({'code': 404, 'msg': '报废申请不存在'}), 404
if not is_privileged_viewer() and int(req.applicant_id) != int(get_jwt_identity()):
# 被指定审批人也可查看
allowed = req.get_allowed_approvers() or []
if str(get_jwt_identity()) not in [str(a.get('value')) for a in allowed if a.get('type') == 'user']:
# 被指定审批人(或审批角色)也可查看。
# ★ 走 can_operator_approve 这个单一事实来源,不要在这里再抄一份名单判定 ——
# 抄出去就开始漂移,而且空名单的 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': 200, 'msg': '获取成功', 'data': req.to_dict()}), 200
except Exception as e:
@ -890,6 +1062,12 @@ def approve_scrap_request(request_id):
return jsonify({'code': 200, 'msg': '操作成功', 'data': req.to_dict()}), 200
except ValueError as e:
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:
traceback.print_exc()
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')
# =============================================================================
# 级联操作聚合
# =============================================================================
# 「一次业务点击 → 一条审计记录」。
#
# 背景: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):
"""
使用事件回调传入的 connection 直接 INSERT。
@ -320,6 +527,14 @@ def _create_audit_log(connection, mapper, target, action, details):
user = _get_request_user_info()
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("""
INSERT INTO audit_logs
(user_id, username, display_name, action, module,
@ -457,10 +672,34 @@ def after_insert_listener(mapper, connection, target):
_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):
"""
按白名单向已完成映射的模型注册事件监听器,返回本次**新增**绑定的模型数。
★ 务必要连带注册级联聚合的 Session 钩子(见 _bind_aggregate_hook):
白名单接口的变更只累加、不逐行写库,钩子缺席就永远不落地 ——
那是**丢审计**,比刷屏严重得多。故绑在这里而不是靠 import 副作用。
★ 为什么需要"惰性补绑"(见 ensure_audit_listeners):
本项目大量模型是在**函数体内延迟导入**的(31 处,例如
app/api/v1/scrap.py 内部 `from app.models.scrap_approval import ScrapApproval`),
@ -476,6 +715,7 @@ def register_audit_listeners(db):
写日志必然抛 AttributeError 并被静默吞掉。
现改为按 **表名** 从 db.metadata 取模型,不依赖 app.models 的导出。
"""
_bind_aggregate_hook()
count = 0
for tablename in list(WHITELIST_TABLES):
if tablename in _BOUND_TABLES:
@ -508,6 +748,7 @@ def ensure_audit_listeners():
调用开销:一次集合差集运算;已全部绑定后立即返回。
"""
_bind_aggregate_hook()
if _BOUND_TABLES >= WHITELIST_TABLES:
return 0
try:

View File

@ -25,6 +25,10 @@ class BomTable(db.Model):
loss_rate = db.Column(db.Numeric(5, 2), comment='损耗率%', default=0, nullable=True)
remark = db.Column(db.Text, comment='备注')
# ★ BOM 级备注(父件级):与 parent_id/is_enabled 一样冗余存于本版本每一行,读取取首行。
# 上面的 remark 是**子件级**备注,语义不同,勿混用。
bom_remark = db.Column(db.Text, comment='BOM级备注(父件级,冗余存于每行,读取取首行)')
# ★ 子件引用的"自制 BOM"版本:子件物料若本身是自制件(作为其它 BOM 的父件),
# 此列记录该子件实际引用的配方版本((bom_no, version) 共同定位),由新建/编辑 BOM 时选版本保存。
# 外购件/无下级 BOM 的子件这两列为 NULL。

View File

@ -23,6 +23,10 @@ class BomDraftTable(db.Model):
loss_rate = db.Column(db.Numeric(5, 2), default=0, nullable=True, comment='损耗率%')
remark = db.Column(db.Text, comment='备注')
# ★ BOM 级备注(父件级):与 bom_table.bom_remark 对齐,草稿暂存/发布期间不丢失。
# 上面的 remark 是**子件级**备注。占位头行(child_id IS NULL)同样携带该值。
bom_remark = db.Column(db.Text, comment='BOM级备注(父件级,冗余存于每行,读取取首行)')
# ★ 子件引用的"自制 BOM"版本:与 bom_table 的 child_bom_no/child_bom_version 对齐,
# 保证草稿暂存/载入期间不丢失所选版本。
child_bom_no = db.Column(db.String(100), nullable=True, index=True, comment='子件引用的自制BOM编号')

View File

@ -148,6 +148,15 @@ class TransOutbound(db.Model):
# ⚠ 该列上线前的历史行为 NULL —— 存量出库与其来源审批单之间没有任何可用
# 关联,无从回填;退回时由库管在选择器里明确指定。
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) # 电子签名图片路径
outbound_time = db.Column(db.DateTime, default=beijing_time)
operator_name = db.Column(db.String(100)) # 操作员
@ -179,6 +188,7 @@ class TransOutbound(db.Model):
'returnable_quantity': qty - returned,
'unit_price': float(self.unit_price) if self.unit_price else 0,
'consumer_name': self.consumer_name,
'request_id': self.request_id,
'signature_path': self.signature_path,
'outbound_time': self.outbound_time.strftime('%Y-%m-%d %H:%M:%S') if self.outbound_time else None,
'operator_name': self.operator_name,

View File

@ -2,6 +2,85 @@ from app.extensions import db, beijing_time
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():
"""
统一时间口径:naive 北京时间,与 created_at / OutboundApproval / BorrowApproval 一致。
@ -29,6 +108,20 @@ class ScrapApproval(db.Model):
applicant_id = db.Column(db.Integer, nullable=False, index=True)
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-已执行(已报废)
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_name': self._user_name(self.applicant_id),
'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,
'allowed_approvers': self.get_allowed_approvers(),
'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))
# ★ 关联报废申请单号(审批流写台账用;DDL 见 db_migrations/add_scrap_approval.sql)
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):
return {
@ -394,6 +398,7 @@ class TransScrap(db.Model):
'stock_id': self.stock_id,
'quantity': float(self.quantity) if self.quantity is not None else None,
'reason': self.reason,
'reason_category': self.reason_category,
'operator_name': self.operator_name,
'operation_time': self.operation_time.strftime('%Y-%m-%d %H:%M:%S') if self.operation_time else None,
'approver_name': self.approver_name,
@ -461,6 +466,13 @@ class TransReturn(db.Model):
# DDL 见 db_migrations/add_return_view_support.sql
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):
return {
'id': self.id,
@ -474,6 +486,7 @@ class TransReturn(db.Model):
'operator': self.operator,
'return_time': self.return_time.strftime('%Y-%m-%d %H:%M:%S') if self.return_time else None,
'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))
material_name = db.Column(db.String(200))
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
@ -592,6 +616,8 @@ class TransDefectiveGoods(db.Model):
'sku': self.sku,
'material_name': self.material_name,
'spec_model': self.spec_model,
'warehouse_location': self.warehouse_location or '',
'batch_number': self.batch_number or '',
'quantity': qty,
'remaining_qty': remain,
# ★ 三个去向列各自独立取值。改造前 restocked_qty 由

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,7 @@ logger = logging.getLogger(__name__)
class BomDraftService:
@staticmethod
def save_draft(bom_no, version, parent_id, children):
def save_draft(bom_no, version, parent_id, children, bom_remark=''):
try:
# 1. 删除旧草稿
old = BomDraftTable.query.filter_by(bom_no=bom_no, version=version).all()
@ -22,7 +22,8 @@ class BomDraftService:
if not children:
dummy_draft = BomDraftTable(
bom_no=bom_no, version=version, parent_id=parent_id,
child_id=None, dosage=0, loss_rate=0, remark=''
child_id=None, dosage=0, loss_rate=0, remark='',
bom_remark=bom_remark
)
db.session.add(dummy_draft)
else:
@ -34,6 +35,7 @@ class BomDraftService:
dosage=child.get('dosage', 0),
loss_rate=child.get('loss_rate', 0),
remark=child.get('remark', ''),
bom_remark=bom_remark,
child_bom_no=child.get('child_bom_no') or None,
child_bom_version=child.get('child_bom_version') or None
)
@ -87,6 +89,7 @@ class BomDraftService:
'parent_id': parent_id,
'parent_name': parent_material.name if parent_material else '',
'parent_spec': parent_material.spec_model if parent_material else '',
'remark': first.bom_remark or '', # ★ BOM 级备注
'children': children,
}
@ -125,6 +128,8 @@ class BomDraftService:
'bom_no': bom_no,
'version': version,
'parent_id': draft['parent_id'],
# ★ 草稿的 BOM 级备注要带到正式表,否则发布即丢失
'remark': draft.get('remark', '') or '',
'children': [
{
'child_id': child['child_id'],

View File

@ -404,7 +404,9 @@ class BomService:
'parent_name': parent_material.name if parent_material else '',
'parent_spec': parent_material.spec_model if parent_material else '',
'is_enabled': first.BomTable.is_enabled,
'remark': first.BomTable.remark or '', # ★ 主表备注
# ★ BOM 级备注:改取 bom_remark。此前误取 first.BomTable.remark
# (第一个子件行的备注)冒充主表备注,是本次一并修掉的连带 bug
'remark': first.BomTable.bom_remark or '',
'children': children
}
@ -421,6 +423,8 @@ class BomService:
parent_id = data['parent_id']
children = data['children']
is_enabled = data.get('is_enabled', True)
# ★ BOM 级备注:写入本版本每一行(与 is_enabled 等父件级字段同口径)
bom_remark = data.get('remark', '') or ''
if not bom_no:
raise ValueError('BOM编号不能为空')
@ -527,6 +531,7 @@ class BomService:
child_id=child['child_id'],
dosage=child.get('dosage', 0),
remark=child.get('remark', ''),
bom_remark=bom_remark,
child_bom_no=child.get('child_bom_no') or None,
child_bom_version=child.get('child_bom_version') or None,
is_enabled=is_enabled

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.product import StockProduct
from app.models.base import MaterialBase
from app.models.purchase import PurchaseRequest
from datetime import datetime, timedelta, timezone
from sqlalchemy import or_, func, text, and_
from sqlalchemy.exc import IntegrityError
@ -153,9 +154,27 @@ class BuyInboundService:
in_qty = float(data.get('in_quantity') or 0)
u_price = float(data.get('unit_price') or 0)
tax_rate = float(data.get('tax_rate') 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:
tax_multiplier = 1 + (tax_rate / 100)
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')
final_barcode = data.get('barcode') or generated_sku
# [新增] 按单入库:关联采购申请单
request_id = data.get('request_id')
new_stock = StockBuy(
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'),
@ -201,8 +217,10 @@ class BuyInboundService:
db.session.flush() # 获取 new_stock.id
# [新增] 按单入库:反写采购申请单状态为"已完成"
# 注:原先此处有一句局部 `from app.models.purchase import PurchaseRequest`,
# 它会让 Python 把 PurchaseRequest 视为本函数的局部变量,导致上方补价
# 逻辑(在 import 之前使用)抛 UnboundLocalError。已改用文件顶部的全局导入。
if request_id:
from app.models.purchase import PurchaseRequest
purchase_req = db.session.get(PurchaseRequest, request_id)
if purchase_req:
if purchase_req.status != 1:

View File

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

View File

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

View File

@ -285,6 +285,13 @@ class OutboundService:
# 在那里引用 approval 会直接 UnboundLocalError(上线时踩过)。
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 = {
'stock_buy': StockBuy,
'stock_semi': StockSemi,
@ -359,7 +366,11 @@ class OutboundService:
repair.shipping_date = current_time
# 收集 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(
@ -394,7 +405,11 @@ class OutboundService:
raise ValueError(f"库存记录不存在 (ID: {stock_id})")
# 收集 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(
sku=item.get('sku'),
@ -419,11 +434,11 @@ class OutboundService:
db.session.commit()
# ★ 出库后通知 Track(发货出库 → Track 标记"已出库")
# 仅在配置 TRACK_OUTBOUND_WEBHOOK_URL 时生效;notify_track 自身容错不阻断业务
# 按每条的 company_name 路由到对应实例(IRIS / LICA)。
# 这里刻意**不**显式传 url:显式 url 优先级最高,会一律打到默认实例,
# 导致 LICA 的出库永远收不到联动。notify_track 自身容错,不阻断业务。
try:
from flask import current_app
outbound_url = current_app.config.get('TRACK_OUTBOUND_WEBHOOK_URL')
for sn, src_tbl, qty in track_notifications:
for sn, src_tbl, qty, company in track_notifications:
if not sn:
continue
notify_track({
@ -432,8 +447,29 @@ class OutboundService:
'serial_number': str(sn),
'quantity': float(qty or 0),
'outbound_type': common_data['outbound_type'],
'company_name': company,
'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:
import logging
logging.getLogger(__name__).warning(f"⚠️ Track 出库通知失败: {e}")
@ -708,8 +744,11 @@ class OutboundService:
)
continue
if start_date and end_date:
stmt = stmt.filter(TransOutbound.outbound_time.between(start_date, end_date))
# ★ 日期边界分别判断:用 BETWEEN 时若只传一侧会整段静默失效
if start_date:
stmt = stmt.filter(TransOutbound.outbound_time >= start_date)
if end_date:
stmt = stmt.filter(TransOutbound.outbound_time <= end_date)
# 【行级数据隔离】应用公司过滤到主查询
if company_limit is not None:

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
# =============================================================================
# ★ 默认审批角色(角色级审批)
#
# 提交时若不指定具体审批人,名单回落成这两个角色 —— 「有主管权限的都能看到、
# 都能审批」,谁审就记谁(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() 硬编码三张库存表。来源差异已全部收敛到
# app/services/scrap_sources.py 的来源适配层(它复用
# inventory_reservation.stock_model_map(),避免第四份重复定义),
@ -37,6 +53,139 @@ SCRAP_ALWAYS_REQUIRES_APPROVAL = True
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
def generate_request_no():
now = _beijing()
@ -50,15 +199,39 @@ class ScrapApprovalService:
# ------------------------------------------------------------------
@staticmethod
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 / 快照字段。
审批人有两条路,优先看 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:
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 里。
# 原先此处硬编码「只认三张库存表」,导致借出未还与在管不良品只能
@ -114,42 +287,96 @@ class ScrapApprovalService:
from app.services.approval_control import resolve_approval_control
_, flagged_materials = resolve_approval_control(normalized)
if not approver_id:
if flagged_materials:
_names = ";".join(f"{m['name']}({m['spec_model'] or '-'})" for m in flagged_materials)
raise ValueError(f"以下物料需审批报废:{_names}。请选择审批人后再提交")
raise ValueError("报废申请必须选择审批人后再提交")
allowed_approvers = [{"type": "user", "value": int(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:
_names = ";".join(
f"{m['name']}({m['spec_model'] or '-'})" for m in flagged_materials
)
logger.info(f"[ScrapApproval] 命中需审批物料(角色级审批):{_names}")
req = ScrapApproval(
request_no=ScrapApprovalService.generate_request_no(),
applicant_id=applicant_id,
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_allowed_approvers(allowed_approvers)
req.set_allowed_approvers(final_approvers)
# ★ 恒为「待审批」,不再走免审批自动通过分支
req.status = 0
db.session.add(req)
db.session.commit()
logger.info(f"[ScrapApproval] 提交成功 {req.request_no} approver={approver_id}")
if commit:
db.session.commit()
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
# ------------------------------------------------------------------
# 列表
# ------------------------------------------------------------------
@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
if status is not None:
query = query.filter(ScrapApproval.status == status)
if applicant_id is not None:
query = query.filter(ScrapApproval.applicant_id == applicant_id)
approver_conds = []
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())
pg = query.paginate(page=page, per_page=limit, error_out=False)
return {
@ -170,7 +397,7 @@ class ScrapApprovalService:
if req.status != 0:
raise ValueError("当前状态不允许审批(仅待审批可操作)")
# 仅被指定的审批人可操作。
# 仅被指定的审批人(或审批角色)可操作。
#
# ★ Fail-Closed:原实现是 `if user_entries and str(operator_id) not in ...`
# —— 当 allowed_approvers 为空(或条目里没有 type='user')时 user_entries
@ -178,12 +405,18 @@ class ScrapApprovalService:
# 「报废一律需审批」直接矛盾:留一扇「无审批人则人人可审」的门,
# 等于没有审批。现改为无名单即拒绝。
# 已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。
#
# ★ 角色级审批(2026-09):名单里现在还可以是 {"type":"role","value":"SUPERVISOR"}。
# 放宽的只是「条目类型」,**空名单依然拒绝所有人** ——
# 这条不变量在 can_operator_approve 里,任何改动都不得把它改回 fail-open。
allowed = req.get_allowed_approvers() or []
user_entries = [str(a.get('value')) for a in allowed if a.get('type') == 'user']
if not user_entries:
raise ValueError("该申请单未指定审批人,无法审批,请联系管理员处理")
if str(operator_id) not in user_entries:
raise ValueError("只有被指定的审批人可以审批该申请")
user_entries, role_entries = ScrapApprovalService._approver_entries(allowed)
if not user_entries and not role_entries:
raise ValueError("该申请单未指定审批人或审批角色,无法审批,请联系管理员处理")
if not ScrapApprovalService.can_operator_approve(req, operator_id):
raise ValueError("只有被指定的审批人或审批角色可以审批该申请")
# ★ 角色级审批的必要配套:不隔离公司,LICA 主管就能审 IRIS 的单
ScrapApprovalService.assert_same_company(req, operator_id)
if action == 'approve':
req.status = 1

View File

@ -115,9 +115,17 @@ class ScrapSourceAdapter:
# --- 台账公共字段 ---
@staticmethod
def _ledger_kwargs(req, operator_name):
"""写 TransScrap 的公共字段 —— **三种来源适配器全部经过这里**。
★ 这是报废原因分类落到台账的**唯一出口**,分类只在这里带一次,
三个 adapter(库存行 / 在管不良品 / 借出未还)就都通了。
「统计生产报废金额」按 trans_scrap.reason_category 分组,不要靠
reason 自由文本去匹配。
"""
from app.models.scrap_approval import ScrapApproval
return {
'reason': req.remark or '',
'reason_category': getattr(req, 'reason_category', None),
'operator_name': operator_name,
'approver_name': ScrapApproval._user_name(req.actual_approver_id),
'approval_status': 'executed',
@ -238,9 +246,39 @@ class DefectiveScrapAdapter(ScrapSourceAdapter):
def cap(self, row):
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):
# 物料名/规格取自台账自身的冗余快照,**不联表 MaterialBase** ——
# 原库存行可能已被物理删除,联表会取到空值。
location, batch_number = self._resolve_location(row)
return {
'source_table': self.source_table,
'stock_id': row.id,
@ -248,8 +286,8 @@ class DefectiveScrapAdapter(ScrapSourceAdapter):
'sku': getattr(row, 'sku', '') or '',
'name': getattr(row, 'material_name', '') or raw.get('name') or '',
'spec_model': getattr(row, 'spec_model', '') or raw.get('spec_model') or '',
'location': '', # 台账无库位字段
'batch_number': '',
'location': location,
'batch_number': batch_number,
'scrap_qty': qty,
'available_at_apply': self.cap(row),
'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。
"""
try:
base_url = (current_app.config.get('TRACK_API_URL') 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:
logger.info("[TrackQuery] 未配置 TRACK_WEBHOOK_KEY,跳过查询")
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'
headers = {
'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)
# 各通道未命中路由表时回落到哪个扁平配置变量(保持改造前的单实例行为)
_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():
"""从 JWT 中安全获取当前操作人姓名(失败返回空字符串,不抛异常)"""
try:
@ -50,7 +98,7 @@ def get_current_operator():
return ''
def notify_track(payload, url=None):
def notify_track(payload, url=None, channel=None):
"""
异步通知 Track 系统业务事件(入库/出库等)。
@ -58,10 +106,15 @@ def notify_track(payload, url=None):
- 未配置 URL / Key 为空 -> 静默跳过
- 网络异常 / 超时 -> 仅记录日志
:param url: 指定 Track webhook 地址;不传则用默认 TRACK_WEBHOOK_URL(入库)
:param url: 显式指定 Track webhook 地址,优先级最高(指定后不再做公司路由)
:param channel: 路由通道 'inbound' | 'outbound';不传则按 payload['event'] 推断。
仅在不传 url 时生效——实例由 payload['company_name'] 决定。
"""
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()
if not 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

@ -24,4 +24,34 @@ class UserRole:
OUTBOUND: '出库员',
PURCHASER: '采购员',
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 ssl
import logging
from email.mime.application import MIMEApplication
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.header import Header
from email.utils import formatdate, make_msgid
from typing import List, Union
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():
"""
@ -45,22 +80,34 @@ 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):
"""
通用邮件发送函数
Args:
to_email: 收件人,单个邮箱字符串或列表
subject: 邮件主题
content: 邮件正文(纯文本)
cfg: 可选,预先获取的配置字典(用于异步线程传参,避免丢失 Flask context)
to_email: 收件人,单个邮箱字符串或列表
subject: 邮件主题
content: 邮件正文(纯文本)
cfg: 可选,预先获取的配置字典(用于异步线程传参,避免丢失 Flask context)
attachments: 可选,附件列表。每项为
{'filename': '日报.xlsx', 'data': b'...'} 或 ('日报.xlsx', b'...')。
数据是**字节**而非路径 —— 调用方在内存里生成,不落盘。
发送失败时打印日志,不抛出异常
"""
if cfg is None:
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'):
@ -82,13 +129,41 @@ def send_email(to_email: Union[str, List[str]], subject: str, content: str, cfg:
return
try:
msg = MIMEMultipart()
# ★ 显式 'mixed':虽然 MIMEMultipart() 默认就是 mixed,但正文+附件
# 的语义要求就在这里,写出来才不会被后人"顺手"改成 related/alternative。
msg = MIMEMultipart('mixed')
msg['From'] = cfg['sender']
msg['To'] = ', '.join(recipients)
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'))
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'):
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}")
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(
target=send_email,
args=(to_email, subject, content),
kwargs={'cfg': cfg},
kwargs={'cfg': cfg, 'attachments': attachments},
daemon=True
)
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
from datetime import timedelta
@ -69,6 +70,10 @@ class Config:
MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER', 'wms@iris-rs.cn')
# 是否启用邮件发送功能(开发环境可设为 false 禁用)
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 通知配置 (发送侧)
@ -85,3 +90,31 @@ class Config:
TRACK_API_URL = os.getenv('TRACK_API_URL', 'http://track_backend:8000')
# Track 出库 webhook 接收地址(发货出库时通知 Track 标记"已出库")
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
import sys
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()
# =========================================================
# 启动时注册库存预警定时任务(每天 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.triggers.cron import CronTrigger
@ -13,14 +34,39 @@ import pytz
beijing_tz = pytz.timezone('Asia/Shanghai')
# 调度日志一律再加一层 flush=True(理由见文件头部的行缓冲说明)——
# 定时任务跑在后台线程,出问题时唯一的线索就是这几行输出,不能丢。
def _run_warning_job():
"""库存预警扫描与邮件发送(每天 9:30 北京时间)"""
with app.app_context():
try:
from app.services.inventory_task import InventoryWarningService
result = InventoryWarningService.check_and_send_warning_emails()
print(f"[Scheduler] 库存预警扫描完成: red={result['red_count']}, yellow={result['yellow_count']}")
except Exception as e:
print(f"[Scheduler] 库存预警任务失败: {e}")
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:
from app.services.inventory_task import InventoryWarningService
result = InventoryWarningService.check_and_send_warning_emails()
print(f"[Scheduler] 库存预警扫描完成: red={result['red_count']}, yellow={result['yellow_count']}", flush=True)
except Exception as 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)
@ -31,8 +77,15 @@ scheduler.add_job(
name='库存预警每日邮件发送',
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()
print("✅ 库存预警定时任务已启动(每天 9:30 北京时间执行)")
print("✅ 定时任务已启动:库存预警 9:30 / MOM系统日报 17:30(北京时间)", flush=True)
if __name__ == '__main__':
# =================================================

View File

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

View File

@ -24,3 +24,40 @@ export function getAuditModules() {
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 剥离价格)
export function getScrapStockList(params: { page?: number; pageSize?: number; keyword?: string }) {
return request({

View File

@ -145,12 +145,7 @@
</div>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="备注" v-if="hasFormFieldPermission('remark')">
<el-input v-model="form.remark" placeholder="备注信息(可选)" :disabled="isReadOnlyMode" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-col :span="14">
<el-form-item label="版本号" prop="version" v-if="hasFormFieldPermission('version')">
<template v-if="isSaveAsMode">
<el-radio-group v-model="form.versionUpgradeType" :disabled="isReadOnlyMode" @change="onVersionUpgradeTypeChange">
@ -165,6 +160,21 @@
</el-col>
</el-row>
<!-- ★ BOM 级备注:独立整行,放在子件列表上方。
原位置在编号/版本号那一行的 span=6 里,扣掉 120px 的 label 后
输入框实际只剩 60 余像素,稍长一点的备注根本看不清。 -->
<el-form-item label="备注" v-if="hasFormFieldPermission('remark')" style="margin-bottom: 5px;">
<el-input
v-model="form.remark"
type="textarea"
:rows="2"
maxlength="500"
show-word-limit
placeholder="BOM 级备注(可选),如适用场景、工艺要求等"
:disabled="isReadOnlyMode"
/>
</el-form-item>
<div style="font-weight: bold; margin: 15px 0 10px 0; border-left: 4px solid #409EFF; padding-left: 10px;">
子件列表
<span style="font-weight: normal; font-size: 12px; color: #909399; margin-left: 10px;">(已选 {{ filteredChildren.length }} 条)</span>
@ -408,6 +418,9 @@ const getDraftHash = () => {
bom_no: form.bom_no,
version: form.version,
parent_id: form.parent_id,
// ★ BOM 级备注也要参与比对,否则"只改了备注"会被防呆逻辑
// 拦成"草稿数据无变动,无需重复暂存"
remark: form.remark || '',
children
})
}
@ -852,6 +865,7 @@ const executeSaveDraftRequest = async (targetBomNo: string) => {
bom_no: targetBomNo,
version: draftVersion,
parent_id: form.parent_id,
remark: form.remark || '',
children
})
if (res.code === 200) {

View File

@ -73,6 +73,19 @@
<template #default="{ row }">{{ row.applicant_name || getApplicantName(row.applicant_id) }}</template>
</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 label="报废种类" width="100" align="center">

View File

@ -35,9 +35,24 @@
/>
</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-button type="primary" @click="fetchData">查询</el-button>
<el-button @click="resetFilter">重置</el-button>
<!-- 导出:与当前筛选条件一致,但导的是**全部匹配结果**而不是当前页 -->
<el-button type="success" :loading="exporting" @click="handleExport">导出 Excel</el-button>
<!-- ★ 高级筛选:对齐 material/list.vue 既有模式 -->
<el-popover
@ -114,6 +129,21 @@
<span v-else>-</span>
</template>
</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>
<template #default="{ row }">{{ row.reason || '-' }}</template>
</el-table-column>
@ -156,6 +186,17 @@
</template>
</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">
<template #default="{ row }">
<el-tag :type="getStatusType(row.approval_status)">
@ -180,7 +221,7 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { getScrapRecords } from '@/api/scrap'
import { getScrapRecords, exportScrapRecords } from '@/api/scrap'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
@ -197,6 +238,8 @@ const listQuery = reactive({
search_type: 'all',
dateRange: [] as string[],
advancedFilters: [] as any[],
// '' = 全部;PRODUCTION = 生产报废;STOCK = 库存报废
reason_category: '',
})
// --- ★ 高级筛选 ---
@ -255,6 +298,8 @@ const fetchData = async () => {
search_type: listQuery.search_type,
// ★ 高级筛选:后端约定参数名为 advancedFilters,值为 JSON 字符串
advancedFilters: JSON.stringify(listQuery.advancedFilters || []),
// 原因分类:空串 = 不筛
reason_category: listQuery.reason_category || '',
}
if (listQuery.dateRange && listQuery.dateRange.length === 2) {
params.start_date = listQuery.dateRange[0]
@ -294,12 +339,53 @@ const resetFilter = () => {
listQuery.search_type = 'all'
listQuery.dateRange = []
listQuery.advancedFilters = []
listQuery.reason_category = ''
advancedConditions.value = [{ field: '', operator: '', value: '' }]
appliedConditions.value = []
listQuery.page = 1
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 的取值为这两个
const getStatusType = (status: string) => {
const map: Record<string, string> = {

View File

@ -235,8 +235,41 @@
</el-col>
<el-col :span="24" :md="8">
<!--
★ 领用人改为**下拉选择**(此前是自由文本框)。
为什么:实测 1497 条出库里有 7 条领用人对不上任何在职人员,
其中就有把姓名打成「刘」这种错字 —— 手输必然会有这类问题。
现在从人员名单里选,与「经办人(库管)」的交互也统一了。
★ 选了审批单会自动带出申请人(见 handleRequestChange),
仍可改成别人。
★ 保留 allow-create:销售出库的客户可能是外部人员、没有账号,
封死会让这类出库没法登记。但**手输的名字会给出提示**
(见下方 consumerUnknown 的告警),既不挡业务也不放任错字。
-->
<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-col>
@ -385,6 +418,8 @@ import {
getScanDraft, saveScanDraft, clearScanDraft, getScanDraftOverview,
} from '@/api/outbound'
import { uploadFile } from '@/api/common/upload'
// 在职人员名单 —— 领用人下拉的数据源(与出库补发的「补发给谁」同一份)
import { getActiveUsers } from '@/api/common/users'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
@ -416,6 +451,76 @@ const lastY = ref(0)
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({
outbound_type: '',
consumer_name: '',
@ -566,6 +671,8 @@ const handleRequestChange = async (val: number | null) => {
if (selectedRequest.value?.outbound_type) {
form.outbound_type = selectedRequest.value.outbound_type
}
// ★ 领用人默认就是申请人(仍可改成别人)
fillConsumerFromRequest()
}
cartItems.value = []
@ -805,6 +912,8 @@ onMounted(() => {
operatorOptions.value.push(userStore.username)
}
loadHistoryOperators()
// 领用人下拉的数据源(与「补发给谁」同一个接口)
loadActiveUsers()
})
const loadHistoryOperators = async () => {
@ -1298,6 +1407,8 @@ onUnmounted(() => {
/* ★ 审批单选择 */
.approval-request-select { margin-bottom: 16px; }
.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 {

View File

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

File diff suppressed because it is too large Load Diff