|
|
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 |
|
|
|
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 |
|
|
|
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 |
|
|
|
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 |
|
|
|
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 |
|
|
|
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 |
|
|
|
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 |
|
|
|
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 |
|
|
|
3bb5fc6eb8
|
fix(outbound): 执行校验兼容无 base_id 的历史单据,修复老单必然被拒
2026-09-10 预占改造(b57c21a)之前建的单,items_json 里没有 base_id。
这类单只要在改造上线时仍处于「已通过待执行」状态,就再也执行不了:
批准侧 build_approval_index() → identity_key(base_id, name, spec)
无 base_id → 退化成 ('name', 名称, 规格)
扫码侧 stock_identity() → ('base', id)
两侧键不同构,**永远不相等**,verify_scanned() 必然抛
「扫码物料【…】不在该申请单的批准明细中,禁止出库」——批的就是这件货。
identity_key() 的文档本就把 (name, spec_model) 写作「历史数据的兜底」,
只是校验时只有批准侧降了级、扫码侧没有,两侧因此错开。
实测(APR-OUT-20260909-1313-0007,建于 09-09,改造上线前一天):
批准侧 ('name', '派里肯安全箱1600(黑色)', 'PS-9640B001-black')
扫码侧 ('base', 3012)
修复:
· 新增 stock_name_spec() —— 库存行 → 名称型身份键,刻意丢掉 base_id
· 新增 build_legacy_approval_index() —— 无 base_id 的历史明细旁路索引
· verify_scanned() —— 主索引落空时用库存行名称+规格再比一次;命中则
改用**批准侧的键**继续,使 acc 累计与 approved_idx 的批准量同口径
安全边界(关键):降级只对真正来自老单据的名称型键放行,白名单是
legacy_idx 而非 approved_idx 本身——build_legacy_approval_index() 只收
identity_key() 产出名称型键的明细,有 base_id 的一律跳过;名称与规格皆空
的明细直接丢弃,绝不退化成「任意物料都能匹配」。无老单时该索引为空集,
降级分支恒不命中,行为与改造前逐字节一致。
影响范围:当时处于 status=1 的老单全库仅 1 张(即上述单号)。其余 358 张
老单(已完成 327 / 已驳回 9 / 已完结 22)均为终态,不受影响——它们在改造
上线前就已执行完毕。
实测验证:
T1 老单 360 + 扫批准范围内物料 修复前必拒 → 修复后通过
T2 老单 360 + 扫单外物料 仍拒绝
T3 老单 360 + 数量超批准 仍拒绝
T4 现代单 + 扫批准范围内物料 通过(旁路索引 0 键,未受影响)
T5 现代单 + 扫单外物料 仍拒绝
副作用改善:降级后标签改用批准侧的键,超量报错从「物料#3012」变为
「派里肯安全箱1600(黑色)(PS-9640B001-black)」,可读性提升。
|
2026-09-20 16:43:37 +08:00 |
|
|
|
d7f7548cee
|
fix(outbound): 库存分配按 base_id 合并需求,修复重复行导致的假性库存不足
_allocate_bom_requirements 在整轮 for req 循环中复用同一份可用量快照
(rows_by_base 建好后不再更新),而 for 循环本身不按 base_id 去重。同一
物料以多行进入时,每行都从同一份快照重新分配一遍,把同一个 stock_id
重复分配 N 次,累计分配量可超过该行真实可用量。
超配在分配阶段不会暴露,直到 reserve_for_items 的二次校验
(take > avail)才抛错,报「可用库存不足(需 X,实剩 Y)」,而实际库存
充足。实剩为 0.0 是当最大候选批次的可用量恰好等于单行需求时的收尾形态。
重复行来自正常业务,非脏数据:
- 购物车里同一物料的多批次就是多行(Selection.vue 提交时只带
base_id + quantity,stock_id/source_table 被丢弃);
- BOM 明细里同一子件被多处引用(前端 requirements 按 child_id 不去重);
- 调拨 / 补发等拆行场景。
修复:在函数入口按 base_id 合并 reqs、required_qty 求和。分配语义不变
—— 分配器本就按 base_id 拉全量批次行、降序分配,输入里的 stock_id 从来
就被忽略。
选在此处收口而非合并调用方的 items:_allocate_bom_requirements 是全系统
库存分配的唯一权威入口(出库与借库都经 reserve_for_items 走到这里),
一处修改同时覆盖两条链路。
实测(base_id=711,四个批次可用量合计 124):
三行各 30 修复前 ERROR(需30/实剩10) → 修复后 OK,2117 出 70 + 1182 出 20
两行各 30 修复前静默超配(两行都绑 2117) → 修复后 OK,合并为 2117 出 60
十行各 30 修复后报真实缺口「需 300,可用 124」(走正常缺料分支)
|
2026-09-20 15:09:14 +08:00 |
|
|
|
26a1857acb
|
fix(purchase): 待采购清单的参考价格改按 material_list:referencePrice 管控
修复一个权限旁路:待采购清单的「参考单价」原本后端无条件返回、前端列也没有
任何门控,而 material_list:referencePrice 只授予 SUPER_ADMIN 与 SUPERVISOR。
结果是 WAREHOUSE_MGR(库管) / INBOUND(入库员) / SALES(销售) 虽然都持有
inbound_purchase:pending_pool、能打开待采购清单,就会看到参考价格 —— 而这个
数字他们在物料列表里是被挡住的。等于本页面成了绕过该权限的后门。
参考价格来自 material_base.reference_price,与物料列表同源,因此复用同一个
权限码管控,不另造新码(同源数据用同码,口径才不会漂)。
改动:
- 后端 get_pending_purchase_pool 新增 include_reference_price 参数,
fail-closed 默认 False;无权限时**整个字段不返回**而不是给 None
- 新增 _has_material_reference_price_perm(),判定方式与既有的
_filter_purchase_prices 保持一致(超管/主管放行 + 逐个权限码比对)
- 前端「参考单价」列加 hasPermission 门控,TS 类型改为可选
★ 只挡前端等于没挡(接口仍然裸奔),前后端必须同时改。
|
2026-09-18 14:36:02 +08:00 |
|
|
|
b4445f07d9
|
feat(material): 物料列表新增「采购在途」自动标识,预警邮件改用活跃单静音
base_service.get_list:
新增 isPurchasing 字段,与待采购池共用同一份活跃单判定。放在预警权限
判断之外 —— 能看到物料列表的人都该知道这个物料已经在采购了。
inventory_task:
静音条件从人工标记 is_ordered 改为「该物料无活跃采购单」。
★ 为什么必须同时改邮件:预警邮件由出库执行流程触发(outbound_service 出库
完成后的调用),功能是活的;而它的静音入口(前端人工「标记已采购」)随本次
改造一并移除。若不改这里,用户会彻底失去静音能力,每个缺货物料在每次出库后
都会发信。自动判定在建单时静默、单据驳回或强制结案后自动恢复,比人工标记准。
两张表的判定口径都由 utils/purchase_activity.py 提供,不各写一遍。
|
2026-09-18 14:31:00 +08:00 |
|
|
|
dec33b685c
|
feat(purchase): 新增待采购池接口(防重复采购)
GET /api/v1/purchase/pending-pool:找出「有效供给仍低于预警线」的物料。
有效供给 = 物理总库存 + 在途量
过滤规则 = 有效供给 <= 红/黄阈值 才进池
建议采购量 = max(1, 目标阈值 - 有效供给)
★ 为什么不用「有活跃单就排除」:红线 10、库存 0、某采购员只建了一张
数量 5 的单时,该物料会立刻从池中消失,剩下 5 个缺口永远无人认领。
改为按量计算后它继续留池,suggested_qty 自动降到 5。
★ 用 <= 而非 < 是与业务方确认后刻意维持的口径(与物料列表预警、预警邮件
同源),勿擅自改成严格小于 —— 只改本接口会造成「列表亮黄灯、池子却排除」
的撕裂,真要改必须三处一起动。代价是恰好等于阈值时建议量落到保底的 1,
前端 tooltip 已单独说明,不展示算不成立的错误算式。
权限:新增 inbound_purchase:pending_pool(注册为 sys_element 而非新建
SysMenu)。因 ensure_default_permissions 在角色已有权限时整段跳过,另写了
存量角色自动迁移,并按源行镜像 company_name 避免跨公司作用域泄漏。
|
2026-09-18 14:30:53 +08:00 |
|
|
|
fc5e62c848
|
feat(purchase): 新增「活跃采购单」判定与在途量计算模块
防重复采购的唯一真相源。刻意回答两个不同的问题,别混用:
- active_purchase_exists() 有没有人在买? → 展示用
- in_transit_subquery() 已经买了多少还没到? → 算术用
活跃单定义:status 0/1,或 status=3 且累计入库 < 采购量。
status=4(强制结案)刻意不在其中 —— 短交不补发、来料拒收不补发这类
异常单永远达不到采购量,靠库管强制结案解锁,避免物料被永久占用。
「部分到货」无需改表即可算出:StockBuy.request_id 早已存在,且
StockBuy.in_quantity 是入库原始量(不被出库扣减,区别于 stock_quantity),
按 request_id 聚合即得每张单的累计到货量。
|
2026-09-18 14:30:40 +08:00 |
|
|
|
eafdf992c7
|
fix(borrow): 借还记录「归还人」列错标成了经手库管,拆成两列
问题
----
列表里那一列标着「归还人」,读的却是 trans_borrow.return_operator —— 而该字段
存的是**办理还库的库管**,不是来还东西的人。两者本就是不同的人:
· returner_id(trans_borrow_return)—— 把东西交回窗口的人,已校验 == 当时持有人
· return_operator —— 经手办理的库管
实测(借用行 120):return_operator = 杜邢宸/duxingchen(库管),
而实际归还人是 returner_id = 21(测试)—— 页面却显示成了「杜邢宸」。
改动
----
一、后端 get_records 增补 returners:从 trans_borrow_return 取 returner_id 并
反查 sys_user 得到姓名(去重按明细挂回)。批量查一次,不做 N+1。
二、前端拆成两列,各自名副其实:
「归还人」 ← returners(后端新增)
「经手库管」 ← return_operators(原列改为正确标签)
三、顺带修展示口径:return_operator 存的是**完整 username**(高闯/gaochuang),
未按全站口径截断。_display_borrow_operator 现统一归一到展示名
(数字 id 反查 / 姓名、斜杠前段 / 已是展示名 原样),并修正其 docstring
—— 它原本也把该字段称作「归还人」,是同一个误解的源头。
★ 历史数据的现实
实际归还人流水是二期才建的,**历史归还没有这个记录**。这部分行的「归还人」
显示为空并挂 tooltip 说明「该笔归还发生在实际归还人记录上线之前」——
刻意不拿库管的名字顶上,那正是本次要修的错。
验证
BOR-20260918-0001 → 归还人=['测试']、经手库管='杜邢宸' ✓ 两者分开
BOR-20260914-0001 → 归还人=[](历史)、经手库管='高闯' ✓
归一化:'高闯/gaochuang'→'高闯'、'21'→'测试' ✓
前端 vite build 通过;本次无需 DB 迁移。
|
2026-09-18 09:43:25 +08:00 |
|
|
|
e3fe1fc12a
|
feat(borrow): 借还记录「已归还」页签改为按归还时间倒序
问题
----
三个页签共用同一套排序(有限期单在前 → 最早应还时间 ASC → 最早借出时间 DESC),
这套「优先关注快到期/逾期」的逻辑对「未归还」是对的,但对「已归还」正好**
反了**:已归还列表要回答的是「最近还了哪几笔」,而按应还时间排会让最近刚还的
那几笔排到最后。
改动
----
order_subq 增加 max_return_time(单号内**最晚**一次归还时间)作为排序键,
并按页签分流:
· 已归还 → nullslast(max_return_time DESC),borrow_no DESC 兜底保证稳定
· 全部/未归还 → 原三级排序不变
★ 为什么取「最晚」而不是「最早」一次归还时间
多明细分批归还时,整单结清的那一刻才是有意义的节点;且与主行「归还时间」列
的展示口径一致(前端同样取 latest),排序依据与可见值不会打架。
验证(真实数据)
已归还页签:09-15 11:41 → 09-14 16:02 → 09-09 11:46 → 09-08 13:22 →
09-04 17:26 → 09-04 09:45 → 09-03 15:25,第 2 页续 08-27 → …,
严格递减且**跨页连续**;
未归还页签:排序与改动前完全一致(回归确认)。
|
2026-09-18 09:34:45 +08:00 |
|
|
|
ccdd6e7306
|
fix(purchase): 商家地址链接放宽为 text,修超长链接保存失败
现象
----
采购管理新建采购申请时粘贴电商商品链接报错。实测该链接 **516 字符**,而
purchase_request.supplier_link 是 varchar(500) —— 超出 16 个字符,
PostgreSQL 直接拒绝(value too long for type character varying(500))。
★ 为什么用 text 而不是把 500 调大
电商外链(淘宝/1688)普遍带很长的跟踪参数,长度没有稳定上界:本例已 516,
再叠加一轮营销参数就可能破千。任何有限的 varchar(N) 都只是把报错往后推,
而且**截断是静默的** —— 用户只会觉得「链接打不开」。
同 material_base.purchase_link 的处理(那边一开始就用 text)。
附带确认(全部扫描,无需改动)
从数据库层面扫了所有「有长度上限、且列名像 link/url/photo/image/
signature/path」的列:
· purchase_request.supplier_link(500) ← 本次唯一真正有问题的
· sys_menu.path(200)、sys_warehouse_location.full_path(500) 内部生成数据
· stock_adjustment.linked_sku(100)/linked_outbound_no(50) SKU 与单号
用户粘贴外链的列仅此一处。
迁移用 DO 块先判断当前类型,可重复执行;含核对段与回滚段。
验证:把那条 516 字符的真实链接写入再读回,长度一致、内容未截断。
|
2026-09-18 09:34:44 +08:00 |
|
|
|
191724a176
|
fix(outbound): 修复扫码出库 500 —— applicant_id 用了尚未赋值的 approval
现象
----
所有扫码出库报 500(前端 create.vue 提交即失败)。
根因
----
上一个提交把 'applicant_id': approval.applicant_id 写进了 common_data,
但 common_data 构造在第 234 行,而 approval 直到第 269 行(「强制按单出库」
那一段)才查询赋值 —— 典型的变量先用后赋,直接 UnboundLocalError。
它影响的是**每一条**出库请求,属于必然复现而非偶发。
修复
----
把赋值移到 approval 取出并校验**之后**:common_data['applicant_id'] = ...
并在原处留注释说明为什么不能写在字典字面量里,避免后人搬回去。
★ 我的测试为什么没抓到
上一轮只测了「退回 → 补发」这条路径,而 applicant_id 的写入在**扫码出库**
路径上 —— 两条路径不重合,所以漏了。本次补测了完整的
「出库申请(预占) → 扫码出库(扣减)」链路。
验证(6 项断言全通过,走真实接口链路)
申请单创建(status=1,申请人=12,预占 stock_buy#2058 两件)
→ 扫码出库不再抛 500
→ 出库明细生成且 applicant_id = 12(不再是 NULL)
→ consumer_name 照常写入
→ 审批单置为已完成
→ 库存实际扣减 2
清理后库存与数据零残留。
★ 顺带在真实数据上得到印证:生产库中已有一笔由业务方重试成功的出库
(OUT-20260917-1333-0001),其 applicant_id 正确等于所关联申请单的申请人。
|
2026-09-17 13:35:47 +08:00 |
|
|
|
0005a689dc
|
fix(outbound): 补发申请人取原申请人,绝不再回退为库管
问题
----
上一版在库管未指定「补发给谁」时,把补发单申请人回退成了**当前操作人(库管)**。
但补发是**原申请人的需求**,挂到库管名下逻辑不通 —— 那张单会出现在库管的
「我的申请」里,而真正该拿东西的人什么也看不到。
根因
----
trans_outbound 只有 consumer_name(**扫码时前端自由填写**的领用人/客户名,
既不可靠也可能是外部客户),没有任何指回原审批单的关联,所以当时只能退而求其次。
根治
----
一、trans_outbound 新增 applicant_id,**创建出库时从关联审批单带出**
(request_id 已强制必填、approval 恒非 None,故新单据必然有值)。
落这一列后,「退回 → 补发」即可自动找回真正的原申请人。
⚠ 存量行为 NULL —— 存量出库与其来源审批单之间没有任何可用关联,无从回填。
刻意留 NULL 而不是按姓名猜(consumer_name 是自由文本,会重名错绑),
与 dispatch_operator 同一取舍:宁可留空,也不猜。
二、退回接口的申请人优先级改为:
① 前端显式指定 reissue_applicant_id
② 原出库明细记录的 applicant_id(真实原申请人)
③ 都没有 → **报错要求指定**
★ 彻底移除「回退为当前操作人」—— 那正是本次要修的逻辑错误。
三、出库列表明细返回 applicant_id,供前端精确预填。
验证(9 项断言全通过)
· 新单据 → 申请人 = 原申请人(12),绝不是库管(7)
· 显式指定优先于原申请人
★ 历史单据未指定 → 接口拒绝、要求选择「补发给谁」、整笔回滚、
且**未生成任何挂在库管名下的补发单**
· 历史单据 + 显式指定 → 正常
库存与数据零残留。
|
2026-09-17 12:07:21 +08:00 |
|
|
|
27e5589a5e
|
feat(outbound,common): 补发可指定「补发给谁」+ 抽出通用人员名单接口
一、补发申请人可选择(原单退回)
退回接口新增 reissue_applicant_id:
① 前端指定 → 校验用户存在后落库;
② 未指定 → 回退为**当前操作人**(原行为不变,向后兼容)。
为何不自动推断原申请人:trans_outbound **没有申请人字段,也没有指回原审批单
的关联**(扫码出库时只把审批单状态置为 3),按 consumer_name 反查会重蹈
「重名错绑」的覆辙(借用人姓名回填那轮刚踩过)。故把选择权交给现场,不猜。
二、抽出中性人员名单 GET /api/v1/common/active-users
实现抽到 common.active_user_options(),借库的 /transactions/borrow/users
改为调同一函数 —— 实现只有一份,但出库补发走**中性路径**,不再出现
「出库为什么在调借库的接口」这种跨模块语义错位。
仅要求登录、只返回 id 与姓名(与 /auth/users/approvers 同一处理)。
★ 本次无需 DB 迁移:未新增任何列,补发申请人是复用已有的
outbound_approval.applicant_id。
验证(打桩/真实 token 直连接口,12 项断言全通过)
· 名单只含 id/name,无邮箱/角色/部门;借库原路径返回值与新路径完全一致
· 指定「补发给谁」→ 补发单申请人 = 指定的人;备注仍含原领用人
· 不指定 → 回退为当前操作人
★ 指定不存在的用户 → 被拒,且整笔退回回滚(流水未落库)
库存与数据零残留。
|
2026-09-17 12:01:11 +08:00 |
|
|
|
7071c89305
|
feat(outbound): 原单退回后可自动生成补发单
背景
----
原单退回只做两件事:良品加回库存 / 不良品转在管台账。但**申请人的需求并没有
被满足** —— 东西交回来了(甚至还是坏的),系统却不提醒任何人、无单据承载
「我要重新领一份」,退回与后续再出库之间也毫无关联。现场只能靠人记住再手建
一张出库申请,而那张单与原单看不出任何关系。
改动
----
· outbound_approval 新增 source_return_id(非空 = 补发单),把「退回 → 补发」
串成闭环。
· POST /inbound/stock/return-from-outbound 新增 need_reissue / reissue_qty:
勾选即自动生成一张**免审批**出库单(status=1,直接进入待执行),
沿用原单出库类型,并关联回本笔退回。
★ 为什么只存退回单 ID,不加 is_reissue 布尔列
「是不是补发」完全由来源是否存在决定,再加一列就是同一事实的两处存储,
必然有不同步的一天。也不冗余存原出库单 ID:trans_return 已有 outbound_id。
★ 库存不足 → 整笔回滚(关键取舍)
补发走 reserve_for_items(strict=True),与出库申请同一口径。不足时抛错,
退回也一并回滚 —— 若只让补发静默失败,「需要补发」的意图就丢了,
那正是本功能要解决的问题。库管看到提示后取消勾选即可只做退回过账。
★ 一个被发现的数据约束(改变了原设计)
原打算把补发单的申请人设为「原出库单的申请人」,但 **trans_outbound 既没有
申请人字段,也没有指回原审批单的关联**(扫码出库时只把审批单状态置为 3)。
按 consumer_name 反查会重蹈「重名错绑」的覆辙。故申请人取**当前操作人**,
原领用人写入备注供人工追溯。
⚠ 若业务要求补发单挂在原领用人名下,需要前端在退回弹窗里加一个「补发给谁」
的人员选择 —— 请确认是否需要。
验证(打桩 JWT 直连真实接口,22 项断言全通过)
勾选补发 → 免审批单生成、关联退回、预占库存、原领用人入备注;
不勾选 → 不生成补发单、良品正常回库;
★ 库存不足 → 接口拒绝且**退回流水/补发单/退回额度全部未落库**(整笔回滚);
补发量 > 退回量被拒;库存与数据零残留。
|
2026-09-17 11:56:38 +08:00 |
|
|
|
6cb30a21fd
|
feat(material): 采购链接接入读写映射与服务层
· models/base.py:新增 purchase_link 列(text)并加入 to_dict(purchaseLink)
· field_permissions.py:读过滤映射 purchaseLink → material_list:purchaseLink
· inbound/base.py:POST 与 PUT 两处 field_to_perm 同时补入(漏掉任一处,
新增/修改就会各缺一半)
· base_service.py:create_material 写入、update_material 按字段存在与否更新
★ 写侧刻意**显式纳入映射**而非依赖「不在映射中→默认允许」的兜底分支 ——
那个分支正是上一轮附件备注「谁都能写」的成因,新字段不再走它。
验证(6 个角色)
SUPER_ADMIN / SUPERVISOR / WAREHOUSE_MGR / INBOUND → 读✓ 写✓
OUTBOUND / SALES → 读✗ 写✗
读写逐角色一致;超管走 material_list:* 通配分支、过滤整段跳过(已单独复核)。
|
2026-09-17 11:26:36 +08:00 |
|
|
|
fd084bf8fd
|
fix(material): 附件备注纳入写权限管控,废止「不在映射中→默认允许」的兜底
问题
----
POST /inbound/base/ 与 PUT /inbound/base/<id> 的 field_to_perm 里**没有**
productImageRemark / manualLinkRemark,于是这两个字段落入
「不在映射中 → 默认允许」的兜底分支 —— 任何能调通该接口的角色都能改。
配合读侧引用幽灵权限码,形成「谁都能写、除超管没人能读」的错位。
改动
----
两处映射(创建 + 修改)同时显式补入:
'productImageRemark': 'material_list:remark_edit',
'manualLinkRemark': 'material_list:remark_edit',
无写权限者的请求不会携带该字段进入服务层 —— 是「丢弃本次修改」而非
「写成空值」,因此不会误清既有内容。
★ 超管不受影响:base.py 的 get_current_user_permissions() 对超管返回的
硬编码列表以 'material_list:*' 开头,命中通配符分支后整段过滤被跳过。
验证(6 个角色 × 读写,12 项断言全通过)
超管 / 主管 / 库管 → 读✓ 写✓
入库 / 出库 / 销售 → 读✓ 写✗
|
2026-09-17 11:07:57 +08:00 |
|
|
|
681607bd43
|
feat(borrow): 拒收原因独立成列,与转交备注彻底分离
背景
----
拒收原因此前是**拼进 remark** 的:
transfer.remark = f"{remark}\n[拒绝原因] {reason}"
前端拿到的是「3333\n[拒绝原因] 5555」这样一坨,时间线上两句挤在一起,
无法分辨哪句是发起备注、哪句是对方拒收的原因。
改动
----
· trans_borrow_transfer 新增 reject_reason text 列;
reject_transfer 改为写入该列,不再拼进 remark。
· 存量按 '[拒绝原因] ' 标记切分回填(实测仅 #22:
remark 3333 / reject_reason 5555)。
· 时间线事件带出 reject_reason,前端才能分行展示。
★ 为什么拆列而不是让前端解析字符串
1) 拼接格式是隐式契约:改分隔符或加前缀,前端解析就静默失效且难排查;
2) 用户完全可能在备注里自己打出 '[拒绝原因]' 字样,按标记切分必然误判 ——
已加测试用例锁定该场景;
3) 结构化字段才能参与查询与统计(如按拒收原因归类)。
存储层能表达的东西,不该靠字符串约定去还原。
★ 一个迁移期踩到的坑:btrim 默认只去空格、不去换行。
拼接留下的是 '3333\n',只写 btrim(x) 会残留换行;必须显式给出字符集
btrim(x, E' \t\r\n')。已修正脚本并对存量做了一次清理。
验证(7 项断言全通过)
备注不被污染、原因写独立列、无原因时为 None、
用户备注含同名标记也不误判、库存零副作用、数据零残留。
|
2026-09-17 10:46:31 +08:00 |
|
|
|
f24797ff1f
|
feat(borrow): 拒收须告知发起方(责任回到他手上,不能静默)
背景
----
双向握手补上了「接收人确认」,却只做了单向告知:接收人能看到待办,发起方却
对结果一无所知。**被拒绝时物品责任仍在发起方手上** —— 他若不主动查列表,
就会误以为已经交接出去,责任链出现静默断点。
(ACCEPTED 不需要告知:东西已经交出去了,发起方无需动作。)
改动
----
· trans_borrow_transfer 新增 reject_seen_at(NULL 且 REJECTED = 尚未告知)。
★ 为什么需要持久标记而不是前端去重:换台电脑、换个浏览器就会重新提醒;
而这条信息的分量(责任归属)值得一个持久标记。
★ 存量已拒绝的流水一律标记为已告知:它们产生于本功能上线之前,
追溯提醒只会打扰(实测仅 1 条:#22,验收时的测试数据)。
· get_unseen_rejects(user_id):返回「我发起、被拒、尚未告知我」的转交,
并批量解析物料名 —— 只说「某笔转交被拒」发起方仍不知是哪件东西还在
自己手上,必须让他一眼认出来。
· ack_rejects(user_id, ids):发起方确认后写 reject_seen_at,幂等。
· GET .../transfer/pending-count 的响应并入 rejects:与待接收数量共用同一次
轮询,前端不必多打一个请求。
· POST .../transfer/reject-ack:无 permission_required,同 accept/reject。
顺带补一处同源显示缺口
----
流转时间线里,被拒绝的转交与成功的长得一模一样 —— 发起方翻记录时同样会
误判。现将转交状态一并带出时间线事件。
验证(15 项断言全通过)
----
发起方收到待告知的拒绝(含物料名/接收人/拒绝原因);接收人与无关人看不到;
ack 后不再提醒且幂等;ACCEPTED 不产生告知;None/非法 user_id 均安全返回;
库存零副作用、数据零残留。
|
2026-09-17 10:44:00 +08:00 |
|
|
|
1c58789fd9
|
feat(borrow): 新增「待我接收」转交数量接口
GET /api/v1/transactions/borrow/transfer/pending-count -> { count: X }
用途:双向握手引入后,发起方提交了转交,接收人若不来借还记录页主动查看就
完全处于盲区 —— 物品挂着「待接收」,责任悬空。该接口供前端做全局强提醒。
设计
----
· 刻意做成极轻量:一次 count,不联表、不解析物料名。轮询接口必须便宜,
否则会从「提醒」变成「后台噪音」。
· 无 permission_required:接收人可能是普通员工,待办提醒必须人人可见
(与 accept/reject 同级 —— 员工处置自己名下资产,非库管职权)。
· user_id 为 None / 非法时返回 0 而不是抛错:提醒类接口不该因边界输入 500。
路由无冲突
----
「transfer」匹配不了 <int:borrow_id>,「pending-count」也匹配不了
<int:transfer_id>,Werkzeug 按转换器精确分派。实测:
GET /borrow/transfer/pending-count -> 401(已注册且受 JWT 保护)
GET /borrow/11/transfer -> 405(路径命中但方法不符,证无冲突)
验证(9 项断言全通过)
----
发起后接收人计数 +1、非接收人不变;拒绝/接收后均回落;
None 与非法 user_id 返回 0 不报错;库存零副作用、数据零残留。
|
2026-09-17 10:30:07 +08:00 |
|
|
|
4bd6765ab4
|
feat(borrow): 转交发起收紧为「仅当前持有人本人」(责任链隔离)
背景
----
此前【转交】只要持有 borrow_transfer 权限就可见可调,与「当前持有人」无关 ——
任何库管都能把别人保管的资产转给第三方,责任链形同虚设。业务方确认改为
**只有该物品的当前持有人本人可以发起**。
改动(前后端同改,缺一不可)
----
· service.transfer_borrow 新增 caller_user_id,强校验其 == 该明细
current_holder_id;传 None 一律拒绝,不做「系统内部调用」的隐式放行。
· get_records 为每条明细附加 can_transfer(当前持有人 == 我)—— 前端
localStorage 里只有 username 没有 user_id,故与 is_mine 一样由后端判定。
· 前端明细行【转交】改判 can_transfer;主行【转交】改为「该单下存在由我持有
的未还物品」时才出现;弹窗候选也过滤为「由我持有」,不是我的不列进来
(后端会拒,列出来只会误导)。
★ 连带调整:移除 route 上的 permission_required('borrow_transfer')
责任链规则既然是「持有人本人」,而持有人是普通员工、通常不持有库管权限,
再加一道库管权限,实际能发起的人变成「持有人 ∩ 库管」,绝大多数持有人
反而发不了 —— 功能形同虚设。这与 accept/reject 同级:员工处置自己名下资产。
真正的边界是 service 层的 caller_user_id 强校验,不是界面遮挡。
⚠ 由此 borrow_transfer 权限码已无任何代码引用(sys_element 中的定义与
4 个角色的授权仍在,属无害冗余)。若后续需要「管理员代办」入口,
可在此基础上加豁免;若确定不需要,该权限码可择期下线。
验证(13 项断言全通过)
----
· 非持有人发起被拒;未传调用者被拒;持有人转给自己被拒
· 持有人本人发起成功,from_user_id 正确记为持有人
· can_transfer:持有人 True / 接收人 False;接收转移后新持有人变 True
· 接收环节不受影响;库存零副作用、数据零残留
|
2026-09-17 10:20:34 +08:00 |
|
|
|
7d9cdeb297
|
feat(borrow): 转交粒度下沉到明细行,支持部分转交
背景(业务方推翻上一轮约束)
----
上一轮按「一张单同时只能有一个持有人」实现了**整单转交**,并把「单内出现多个
持有人」当作 bug 去修。业务方验收后明确纠正:
物理现场经常只转交部分工具(借了 2 件、只把 1 件转给别人),
单内多持有人才是符合现实的正常状态。
故转交粒度从 borrow_no 下沉回 trans_borrow.id(明细行)。
改动
----
· transfer_borrow:只操作传入的那**一行**明细,不再按单号整批覆盖。
转出方 = 该行当前持有人;数量 = 该行待还量。
· accept_transfer:只转移 transfer.borrow_id 指向的那一行 ——
整批改写会把别人手上的东西一并抢过来(部分转交下同单明细分属不同人)。
· 唯一性约束从「单号至多一条 PENDING」下沉为「明细行至多一条」:
同单的其他明细可以同时各自挂着待接收,互不阻塞 —— 这正是部分转交的语义。
· get_records 的 pending_transfer 改按 borrow_id 关联(原按 borrow_no),
否则同单多项待接收会互相覆盖。
· 删除已无用的 _load_slip_for_update。
★ 数量粒度:一行只支持**整行转交**。一行只能有一个 current_holder_id,
「同一行只转一部分」需要把这行拆成两行 —— 经业务确认,现场场景中
「借 2 件转 1 件」的两件本就是两条明细行,故该限制不影响实际使用;
接口对传入的非整行数量会明确提示「应另立一条明细行」。
数据层
----
无需改表结构:borrow_id 本就是流水的关联列,borrow_no 退化为单据归属与
分组展示用。仅补 (borrow_id, status) 复合索引支撑新的查询路径。
存量撕裂数据(BOR-20260917-0001 的「测试 / 杜邢宸」)按业务方选择**保留不动**
—— 它现在不再是 bug,而是部分转交的正常形态。
验证(合成 2 明细单,21 项断言全通过)
----
· 只转工具A:工具B 完全不受影响
· 同一张单可同时挂两条待接收,互不阻塞;同一明细重复发起被拒
· accept 工具A 后:A→测试,B 仍是杜邢宸(单内两个持有人)
· 两个持有人、以及待接收人,三方各自都能在列表中看到该单
· pending_transfer 挂在正确的明细行上,is_mine 判定正确
· reject 后主表持有人不变;非整行数量被拒并提示拆行
· 全程 available_quantity 无变化,库存精确还原、零残留数据
|
2026-09-17 10:12:31 +08:00 |
|
|
|
1a8e3e3dc0
|
feat(borrow): 转交改为整单覆盖 + 双向握手,并修复接收人可见性
一、整单覆盖(修复漏行 / 单内撕裂)
----
transfer_borrow 现在按 borrow_no 定位**整张单**,覆盖全部未还明细;accept 时
整批转移持有权。原先只改传入的那一行,2 明细的单转完会出现两个持有人。
新增 _load_slip_for_update():按单号锁整单,且按 id 升序取行锁 —— 并发下所有
事务以相同顺序加锁,避免与归还/转交交叉加锁死锁。
二、双向握手
----
· transfer_borrow(发起):只落一条 PENDING 流水,**不再改主表 current_holder**。
东西还没到对方手上,责任仍归原持有人 —— 这是与旧实现最本质的区别。
同一单号已有 PENDING 时拒绝再次发起,避免两个接收人争抢同一批实物。
· accept_transfer:流水置 ACCEPTED,把该单**全部未还明细**的持有人改为接收人。
. reject_transfer:流水置 REJECTED,主表不动。
两者都强校验「当前登录人 == to_user_id 本人」。
★ accept/reject 刻意**不加 permission_required**:这不是库管职权,而是员工对
自己名下资产的确认动作,加库管权限会把接收人挡在门外。
三、接收人可见性(OR 过滤)
----
get_records 普通用户过滤原先只比对 borrower_name,接收人在自己的列表里看不到
已经接收的东西。现改为三种关系任一成立:
① 我是借用人
② 我是**当前持有人**(转交接收后)
③ 有一条**待我接收**的 PENDING 转交 —— 东西还在对方手上、主表尚未转移,
② 匹配不到,必须单独并入,否则接收人看不到待办、无从确认
ID 与姓名双口径并存,兼容只有姓名没有 ID 的历史行。
列表项附加 pending_transfer(含后端判定的 is_mine)—— 前端 localStorage 里
只有 username 没有 user_id,靠姓名比对既有歧义又不可靠,故由后端标记。
四、验证(合成 2 明细单,25 项断言全通过)
----
· 发起后两条明细持有人均未变(责任未转移)
· 非接收人无法 accept / reject;重复发起被拒
· ★ accept 后**两条明细**持有人一并转移(漏行修复的核心)
· 接收前凭 PENDING 分支可见、接收后凭 current_holder 可见
· reject 后主表持有人不变
· 全程 available_quantity 无变化,库存精确还原、零残留数据
|
2026-09-17 10:04:12 +08:00 |
|