|
|
cccd6f6081
|
feat(borrow): 转交接口、身份ID锚点与流转时间线后端
责任链收口
----
· execute_dispatch:强制 borrower_id,姓名一律由 sys_user 反查,**不回退**到
申请单姓名 —— 申请单上的姓名是「申请意向」,与扫码时实际来领的人本就可能
不同(库管代建场景尤甚),回退会让 current_holder 从第一刻就记错人。
同时落库 dispatch_operator,补齐「谁经手发货」。
· process_return:新增 returner_id,记录有 current_holder_id 时强校验二者相等,
不符即整单回滚(原实现只要持有 op_return:operation 即可归还任何人借出的
物品,函数内从不读取 borrower_name,归还环节责任链是断的);
每次归还写 trans_borrow_return 流水;全量归还清空 current_holder
(borrower_id 保留作历史)。
· transfer_borrow(新):整单全量转交,写转交流水 + 推进主表 current_holder。
为什么一期只允许整单全量转交
----
trans_borrow 是单行模型,只能存一个 current_holder_id。部分转交(借 10 转 5)
会让这一行同时表示两个人,语义直接撕裂,且归还时无法判定该由谁还。
若未来需支持,必须改为按数量**拆行**(新建一条承接转出量、原行扣减),
而不是在单行上加字段打补丁。
为什么转交绝不触碰库存
----
借出期间 available 已冻结、stock 仍含借出未还量(deduct_stock=False)。
转交若动库存,会同时破坏两个既有假设:「归还只加 available」会算多,
「借库转报废在确认损失时扣 stock」(scrap_sources.BorrowScrapAdapter)
会重复扣减。
接口
----
· POST /borrow/<id>/transfer 转交(borrow_transfer 权限 + 防抖锁 + 行锁)
· GET /borrow/<id>/history 单品流转历史(供精确追溯)
· GET /borrow/slip/<no>/history 整单时间线(实测单号最多 21 条明细,
逐条调用会产生 21 个请求,故聚合返回)
· GET /borrow/users 人员名单(借出/转交/归还共用,公司隔离)
一个隐蔽缺陷(本轮发现并修复)
----
整单时间线初版按展示用的时间字符串排序,而该字符串截断到**秒** —— 同一秒内的
连续动作(如「转交 A→B 紧接着转交 B→C」)会退化为并列,顺序取决于数据库返回
顺序,时间线随机错乱。已改为按真实 datetime 排序,并加回归用例锁定。
|
2026-09-17 09:17:57 +08:00 |
|
|
|
58fa42bff3
|
feat(scrap): 报废全链路收口到审批流
系统自陈的规则是「报废一律需审批」(SCRAP_ALWAYS_REQUIRES_APPROVAL = True),
但实际有 4 条写 TransScrap 的路径,其中 3 条绕过审批。本提交把三条旁路
全部收口,只保留「申请 → 审批 → 执行」一条写入路径。
【删除】直接报废 POST /api/v1/scrap
同时移除 ScrapService.process_scrap()。该路径的一个连带影响是
「维修件报废」能力随之消失 —— process_scrap 的 trans_repair 分支是
repair_status='报废转出' 的唯一写入点。实测该能力零使用(报废转出 0 条、
trans_scrap 来源 0 条),且早已半死:审批流的扫码校验会拒绝 trans_*
来源。repair_service.py 的误导文案(原文指引操作员「前往报废管理进行
扫码操作」,而那条路根本不通)已改为「维修件报废暂未开放」。
权限元素 scrap_create:operation 保留不删 —— add_scrap_perm.sql 以它为
scrap_apply/scrap_execute 的授权来源。
【改造】借库转报废 POST /borrow/scrap → POST /borrow/scrap-request
TransService.scrap_borrow() 删除,逻辑迁入 BorrowScrapAdapter。
沿用 op_return:operation 权限(零授权变更)。已归还/已报废的记录改为
**直接报错**,不再静默 continue 返回 count=0(原缺陷:用户以为成功)。
【改造】不良品报废 POST /defective/<id>/scrap → POST /defective/<id>/scrap-request
申请时**不预占** remaining_qty(与报废模块「仅锁定意向,不扣库存」的
既有哲学一致)。副作用:同一批坏件可重复提交多张申请单,执行期由适配器
按 Fail-Closed 拒绝超额的那几张。已在该接口注释与前端提示中写明。
【服务层】ScrapApprovalService 接入来源适配层
- submit_approval 改由 get_adapter() 分派,并新增整单 scrap_mode 一致性
校验(混合「需扫码/免扫码」两类来源直接拒绝,让 execute 分流保持简单)
- _build_scanned_index 的来源校验改为 is_scan_source():语义上是**收窄**
而非放宽,扫码通道永远不接纳 trans_* 来源
- execute 按模式分流:scan 项走原有扫码匹配(索引只由 scan 项构建),
auto 项按批准量执行。签名与调用契约不变。
- _match_key 加入来源表:不良品 SKU 是从原库存行复制的,不带来源时
同一张单里的两者会**必然串键**,扫码量算到错误对象上。纯库存单两侧
同源、键仍匹配,对既有流程零行为变更。
【修复】approve() 的 fail-open
原实现 `if user_entries and str(operator_id) not in user_entries` ——
allowed_approvers 为空时条件短路为假,**任何登录用户都能审批**。
这与「报废一律需审批」直接矛盾,留这扇门等于没有审批。改为无名单即拒绝。
已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。
【扫码通道】scrap/scan 的 trans_repair 分支改为 trans_defective_goods
在管不良品按 SKU 匹配,在管量回填到既有字段形状,前端无需按来源分支。
【清理】scrap_approval_service 的 _stock_models 与 TransScrap 导入已移除
(来源差异全部收敛到适配层);trans_service / inventory_reservation 中
指向已删方法的注释已更新指向 BorrowScrapAdapter。
|
2026-09-16 17:14:17 +08:00 |
|
|
|
06cb5f1cac
|
fix(reservation): 实扫来源不可识别时整单失败,避免预占永久锁死
出库与借库两条执行路径原先写作:
_scanned = [i for i in items if i.get('source_table') != 'trans_repair']
if _scanned:
verify_scanned(...); restore_then_deduct(...)
`if _scanned:` 为假时校验与释放被整体跳过,而下方仍无条件把单据置为
status=3(已完成)。此时申请阶段 reserve_for_items() 扣掉的 available_quantity
无人归还,且 status=3 之后 /close(要求 status==1)与 /withdraw(要求
status∈(0,1))都拒绝再释放 —— 预占就此永久锁死,成为谁也领不走的幽灵库存,
且没有流水可供追溯。
改为 Fail-Closed:实扫明细的来源必须全部落在 model_map 内,否则整单失败。
事务回滚后单据保持 status=1、预占原样保留,库管可重试执行,或走驳回/撤回 ——
任一路径都能正常归还预占。
- outbound_service.create_outbound_batch:以 model_map 白名单过滤,
并要求 _scanned 非空
- trans_service.execute_dispatch:先显式校验来源合法性并列出非法值,
再要求 _scanned 非空
|
2026-09-11 10:34:55 +08:00 |
|
|
|
f702a70fe5
|
fix(borrow): 借出只冻结可用数,不再扣减实物库存
问题
----
上一轮库存预占改造(b57c21a)把借库执行改用了 restore_then_deduct(),
而该函数是按**出库语义**设计的(物品永久离开仓库),会同时扣减
available_quantity 与 stock_quantity。借库是可逆的,于是产生两个缺陷:
1) 借出再归还后,实物库存永久少一份
初始 (stock=20, avail=20)
借出5 (stock=15, avail=15)
归还5 (stock=15, avail=20) ← 实物没回来,丢了 5
2) 借出后转报废会重复扣减
scrap_borrow 的注释明确写着「扣减总库存(该物品确认损失);
可用库存已在借出时冻结,无需重复扣」—— 它假设借出**没动实物**。
借出已扣 stock 后,转报废再扣一次:
借出5 stock=15 → 转报废 stock=10(正确应为 15)
修复
----
restore_then_deduct 增加 deduct_stock 参数:
出库 deduct_stock=True (默认)—— 可用数、实物数同时扣
借库 deduct_stock=False —— 只冻结可用数,实物数不动
★ 这是**恢复原有设计**,不是新设计。git 历史显示从最初的
04ee938 借库逻辑实现 起,历经 7ef22a3 / 83b3db6 / b79b0f9 /
2556b77 / 1527d55 六次提交,一直是:
stock.available_quantity = float(stock.available_quantity) - qty
(只减可用,不动实物)。b57c21a 把它替换掉了。
盘点的差异计算也印证了该口径的正确性:
adjusted_stock_qty = stock_quantity - 借出未还
diff_qty = 实盘数 - adjusted_stock_qty
这段逻辑要求 stock_quantity **包含**借出未还的实物,否则会重复扣减。
最终语义
--------
stock_quantity = 账面实物总数(含借出未还)
available_quantity = 实际可取用数
出库申请(预占) — available ↓
出库执行 stock ↓ available ↓
借库申请(预占) — available ↓
借库执行 — available ↓
借库归还 — available ↑
借库转报废 stock ↓ —
实测(stock, available)
------------------------
借还循环:初始(20,20) → 借出5(20,15) → 归还5(20,20) 完全可逆
出库: 预占4(20,16) → 执行(16,16) 实物确实减
转报废: 借出3(20,17) → 转报废(17,17) 实物减一次,不重复
|
2026-09-11 09:39:20 +08:00 |
|
|
|
b57c21a4cd
|
feat(inventory): 库存预占生命周期,消除出库/借库超卖
问题:库存超卖
--------------
改造前出库/借库申请只记录「要什么、要多少」,不绑定具体库存行,
真正的 available_quantity 扣减发生在执行阶段。于是多张申请可以同时
claim 同一批货,等到工人拿扫码枪时才发现货已被别人领走。
生命周期(三阶段)
------------------
提交申请(预占) reserve_for_items()
用分配器把需求落到具体库存行,立即扣减 available_quantity,
并把 (stock_id, source_table, allocated_qty, reserved) 写回 items_json。
驳回(释放) release_reserved()
遍历 items_json 把预占量还回池子,避免货被永不执行的单永久占住。
扫码执行(覆盖) verify_scanned() + restore_then_deduct()
校验实扫身份/数量未超批准范围 → 释放全部预占 → 对实扫批次
同时扣减 available_quantity 与 stock_quantity。
身份键:base_id 主键 + SKU 兜底(重要设计决策)
-----------------------------------------------
本系统中 SKU 是**批次级**编号:同一 base_id 下每个入库批次各有不同的
SKU(实测 stock_buy 有 183 个物料是多批次的,如 base_id=2405 下有
0000001685 与 0000001974 两个 SKU)。
若以 SKU 作为身份主键,「申请时锁定 A 批、工人现场改扫 B 批」会被判为
身份不符而拒绝 —— 恰好否定了「物理覆盖」这个核心能力。
故改用 base_id(物料级、跨批次稳定,spec_model 由其唯一确定),
历史数据无 base_id 时降级为 (name, spec_model)。
可用量校验按物料汇总,而非按单批次
----------------------------------
开发中修正的一处缺陷:若逐行要求「该批次可用量 >= 该批次扫码量」,
工人改扫小批次时会被误拒。例如本单预占 A 批 5 件,改扫 B 批 2 件 +
C 批 3 件,B 批自身只有 2 件可用,逐行校验即失败。实际这 5 件都是本单
锁定的货,理应允许。现按物料汇总校验可用量,按行校验实物库存。
改动文件
--------
· 新增 app/services/inventory_reservation.py(通用服务层)
· outbound_service.create_request —— Phase 1 预占
· outbound_service.approve(reject) —— Phase 2 释放
· outbound_service.create_outbound_batch —— Phase 3 覆盖(移除原逐行扣减)
· borrow_service.submit_approval —— Phase 1
· borrow_service.approve(reject) —— Phase 2
· trans_service.execute_dispatch —— Phase 3,并用统一身份键替换
原有的 (name, spec_model) 字符串匹配
实测(真实 HTTP 全链路)
------------------------
初始 available=10
① 提交申请(需5) → 200,available 10→5 预占生效
② 审批通过 → available 仍为 5 预占保留
③ 扫码执行(改扫另一批次 4 件) → 200
原批次恢复满额、实扫批次扣减(0,0),available=6, stock=6
单场景验证:预占 A 批改扫 B 批放行;驳回后可用量完全恢复;
扫其他物料被拒;批准 6 扫 8 被拒。
|
2026-09-10 14:59:10 +08:00 |
|
|
|
fd0bfd3d9c
|
feat(records): 借还记录接入高级筛选
复用 app/utils/advanced_filter.py 的解析与谓词逻辑:
· 父级字段(单号 borrow_no、借用人 borrower_name)走标准 SQL 谓词
· 子级字段(SKU、物料名称)经单号子查询过滤,否定操作符走 NOT IN 整单排除
· 物料名经三表联查(buy/semi/product JOIN material_base)
借还的单号维度查询基于 order_subq 子查询分页,故此处把过滤条件施加在
order_subq.c.borrow_no 上,与既有的状态/日期/公司隔离过滤保持同一层次。
前端 records.vue 新增「高级筛选」el-popover(字段/操作符/值 + 添加条件/
应用筛选/重置),序列化为 advancedFilters JSON 字符串随查询下发。
验证:sku ne 0000000002 → 52 单(库中无单含该 SKU,正确不减);
sku contains 0000 → 52 单(52 单的 SKU 全部含 0000,数据巧合)。
|
2026-09-10 13:06:03 +08:00 |
|
|
|
c35ec9a659
|
feat(records): 出库/借还记录统一高级搜索版式
三个记录页(出库/借还/报废)此前搜索区形态各异:出库用裸 div + 分散样式,
借还缺日期范围,报废只有 SKU 输入框。现统一为 el-form :inline="true" +
.filter-form 版式,并补齐缺失的过滤维度。
借还记录(新增日期范围能力):
· 后端 get_records 增加 start_date/end_date 参数,按「借出时间」过滤;
· 边界补全时分秒(YYYY-MM-DD → 当日 00:00:00 / 23:59:59),
与出库记录同口径,解决零点截断导致当天记录漏查的问题;
· API 层透传两个新参数。
出库记录:
· 版式统一为 inline 表单,日期选择器加 label;
· 新增「重置」按钮;
· 搜索类型切换由 500ms 防抖改为立即查询。
借还记录:
· 新增「重置」按钮;搜索类型/状态切换改为立即查询(取消防抖)。
实测(SUPER_ADMIN):
报废 全部/单号/SKU/操作人/物料名/日期 六类过滤均 200 且命中正确;
借还 无过滤 52 单 / 本月 18 单 / 空区间 0 单(日期过滤生效);
出库 无过滤 395 单 / SKU 277 / 姓名 7 / 物料名 16 / 单号 OUT-2026 命中 395。
|
2026-09-10 12:06:08 +08:00 |
|
|
|
b4b79c68a9
|
fix(borrow): 借库转报废实物不足时报错回滚,不再夹断到 0
原逻辑在 stock_quantity 减为负数时直接置 0。但借出时已扣减过
available_quantity,夹断会使 available_quantity > stock_quantity,
凭空多出可用库存,后续出库/借库可继续占用这批并不存在的实物。
改为不足即抛 ValueError,与同一事务内其余校验一致,整单回滚。
|
2026-09-10 10:14:36 +08:00 |
|
|
|
eff9b62224
|
fix(borrow,outbound): 普通用户记录只看本人——按领用人/借用人姓名(不含账号前缀)匹配
- 出库记录/借还记录:非管理者视角时按 领用人(consumer_name)/借用人(borrower_name) 过滤
- 匹配取登录名 username.split(/)[0] 的姓名;兼容库里存“姓名/xiaolongxia”全名(姓名+/前缀)
- 修复:管理员替员工创建、领用人=员工 的单,员工登录可见
|
2026-09-09 11:19:15 +08:00 |
|
|
|
73510d3a71
|
feat: 借还记录默认排序重构——有限期单按预计归还时间升序靠前,无限期单按借出时间升序靠后(优先关注快到期/逾期)
|
2026-09-04 16:17:42 +08:00 |
|
|
|
1527d552d4
|
feat: 出库/借库记录记录并展示库位快照(DB加列+后端写入+记录页展示)
|
2026-09-04 11:42:44 +08:00 |
|
|
|
36e9f500aa
|
feat(borrow): 借库未归还直接报废功能(关联报废单流程)
后端:
- trans_service 新增 scrap_borrow 方法:未归还借用标记为 scrapped,
扣减总库存 stock_quantity(可用已在借出时冻结),生成 TransScrap 报废记录
- transactions.py 新增 POST /borrow/scrap 接口(复用 op_return:operation 权限,库管/主管可操作)
- scrap.py query_records 支持 trans_borrow 来源的物料名解析 + 行级隔离
前端:
- records.vue 未归还记录新增「报废」按钮(库管/主管可见)
- 报废弹窗:勾选明细 + 填报废原因 + 二次确认
- 状态显示新增「已报废」标签;整单所有明细报废时标记为 scrapped
|
2026-08-31 10:21:28 +08:00 |
|
|
|
2556b77530
|
perf: 系统级性能优化与并发安全修复
## 并发安全修复 (4处)
- scrap.py: 报废执行添加 SELECT FOR UPDATE 悲观锁,消除 TOCTOU 竞态
- stock.py (adjust_stock): 盘点调整添加 for_update=True 行锁
- outbound_service.py: 低库存预警 SMTP 调用移到 commit 之后,避免长事务
- trans_service.py: execute_dispatch 按 (source_table, id) 排序 items,消除死锁风险
## N+1 查询优化 (2处)
- inventory_task.py: _prefetch_inventory_map 单条 UNION ALL+GROUP BY 替代循环内逐条查询(N*4次→2次)
- stock.py (export_stocktake): get_borrowed_qty 批量 GROUP BY 替代逐条 TransBorrow 查询(~18000次→1次)
## BOM 列表性能重构
- bom_service.py: get_bom_list 单条 GROUP BY+string_agg+分页,消除 N+1 循环查询
- bom_service.py: 新增 get_bom_summary (轻量 GROUP BY category+COUNT)
- bom.py: 新增 /api/v1/bom/summary 路由,/list 支持 category 过滤
## Odoo 基础信息懒加载
- base_service.py: 新增 get_odoo_summary (GROUP BY category+COUNT)
- base.py: 新增 /api/v1/inbound/base/odoo-summary 路由
- buyOdoo.vue: 懒加载分组架构 (fetchOdooSummary + loadGroupItems)
- material_base.ts: 新增 getOdooSummary API
## 前端 Bug 修复
- BomManage.vue: 懒加载分组 (fetchBomSummary + loadGroupItems + collapse)
- BomManage.vue: 适配新 API 格式 (res.data.items 替代 res.data)
- buyOdoo.vue: 移除 "点击展开加载" 文字
- Selection.vue + borrow/apply/index.vue: openBomSelect 适配新 API 格式
|
2026-07-15 17:37:57 +08:00 |
|
|
|
e1417d740a
|
feat: 全模块公司隔离 + crossDomain权限码动态跨域控制
- get_current_company_filter: 新增_has_cross_domain_permission, 权限码替代硬编码
- 补全11个Service的get_current_company_filter调用(semi/product/service/outbound/bom/trans/scrap/summary)
- base/search修复: search_material此前无隔离, 已补全
- get_current_company_filter兜底: JWT缺company_name时返回__NO_COMPANY__防止放行
- permission.py: _get_operator_company补全返回值, 修复权限页保存逻辑
- 新增crossDomain迁移脚本, element_type=element挂system_mgmt下
|
2026-07-15 11:11:40 +08:00 |
|
|
|
1450e6c1de
|
fix(借还记录列表): 按 borrow_no 单号维度分页 + 修 SQLAlchemy Row 适配错误
- 分页基准从明细行改为单号:21 项单号不再被拆到 3 页
- 步骤 1a 构造 GROUP BY borrow_no 的 subquery(sort_key + status 聚合)
- 步骤 2 主查询 SELECT order_subq.c.borrow_no 一列,避免触发 PG GROUP BY 严格模式 (f405)
- 步骤 3 用 page_borrow_nos 拉明细,保留前端 groupMap 期望的 items 形态
- pagination.items 用 isinstance + hasattr(_mapping) 兜底提取纯字符串(修 psycopg2 can't adapt type 'Row')
- service 加 try-except,路由层识别 500 透传 traceback
- status 过滤改为单号聚合(borrowed=至少一条未还,returned=全部归还)
|
2026-06-16 14:53:42 +08:00 |
|
|
|
b79b0f99af
|
fix(借库扫码出库): 撤销 joinedload 修复 PG "FOR UPDATE cannot be applied to nullable side of outer join"
- 83b3db6 引入的 joinedload(ModelClass.base) 触发 LEFT OUTER JOIN,
而 with_for_update() 会被 SQLAlchemy 透传到 join 的 nullable 侧,
PG 直接抛 FeatureNotSupported,且连表加锁有死锁风险
- 退回最安全的单表 FOR UPDATE 模式,接受 N+1 lazy 加载的代价
- 在 防线3 上方加防回归注释,明确禁止未来再加 joinedload
- process_return 中的另两处 joinedload 不带 FOR UPDATE,不受 PG 限制,保留
|
2026-06-16 13:56:11 +08:00 |
|
|
|
83b3db693a
|
fix(借库扫码出库): 校验 key 从 (source_table, sku) 改为 (name, spec_model) + N+1 修复
- 借库申请按 (name, spec_model) 发起,审批明细无 sku 字段;
旧代码用 sku 做 key 会导致所有条目坍塌到同一桶,校验形同虚设
- 改为在扫码循环内即时累加、即时拦截:
防线4 锁定 stock 行后从 material_base 取真实 (name, spec_model),
与审批单按 strip 后的 (name, spec_model) 聚合比对
- 新增 joinedload(ModelClass.base) 一次 JOIN 加载 base,
避免循环内 stock.base 触发 N+1
- 修正 dispatch_borrow docstring 中"sku 用于超额交叉校验"的错误描述
|
2026-06-16 13:50:49 +08:00 |
|
|
|
7ef22a3830
|
feat(借库审批流): 完整前后端实现
|
2026-06-12 14:08:19 +08:00 |
|
|
|
9a5e3ee6b0
|
TransService.get_records: 追加 material_name 字段 + SKU 兜底查询解决数据孤岛问题
|
2026-06-12 11:06:34 +08:00 |
|
|
|
48651ffd01
|
perf: 消除出库列表和还库操作的 N+1 查询,改用批量 IN + joinedload
|
2026-05-19 09:49:30 +08:00 |
|
|
|
71e5f075d2
|
feat: implement composite debounced search with prepended select and wipe out duplicate root permission nodes
|
2026-03-20 10:26:45 +08:00 |
|
|
|
3bb3975022
|
fix: use .c to access SQLAlchemy subquery columns correctly
|
2026-03-20 10:15:11 +08:00 |
|
|
|
34629b432a
|
fix: correct SQLAlchemy join condition to resolve MaterialBase AttributeError
|
2026-03-20 10:06:22 +08:00 |
|
|
|
990399a408
|
feat: implement cross-table search and debounced dynamic search for borrow and return records
|
2026-03-20 09:58:42 +08:00 |
|
|
|
8db1015f99
|
fix: implement traffic-light color warning and correct ascending sort for overdue borrow records
|
2026-03-19 11:45:27 +08:00 |
|
|
|
b74464df6b
|
feat: add descending sort by return date and color-coded warning for impending returns
|
2026-03-19 11:40:38 +08:00 |
|
|
|
79d4a365e0
|
feat: add partial return support with returned_quantity tracking
|
2026-03-18 10:41:19 +08:00 |
|
|
|
b98f89bfe4
|
chore: add .material->.base refactor check comments
Co-authored-by: aider (openai/DeepSeek-V3.2-Thinking) <aider@aider.chat>
|
2026-02-10 11:34:50 +08:00 |
|
|
|
04ee938cd1
|
借库逻辑实现
|
2026-02-06 17:11:47 +08:00 |
|
|
|
ee9f4aed3e
|
修正git管理关系
|
2026-01-26 13:47:53 +08:00 |
|