|
|
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 |
|
|
|
fbc9296056
|
feat(stock): status 硬隔离与出库通道封堵
激活库存表长期「只写不读」的 status 列,使其成为分配准入的硬门槛。
- inventory_reservation: 新增状态语义单一事实来源(STOCK_STATUS_*/
allocatable_filter/is_allocatable);verify_scanned 增加状态准入校验
(覆盖出库与借库两条提交路径)
- outbound: _allocate_bom_requirements 过滤条件加 allocatable_filter,
非「在库」一律不进入候选集;扫码路由把 ValueError 转为 400
- outbound_service: create_outbound_batch 强制 request_id 必填。原先无单
时会跳到所谓「散单」分支,而该分支的扣减逻辑从未落地(注释点名的
_apply_reservation_override 全仓不存在),实测只写台账不扣库存,
同一批货可被反复出库;get_stock_by_barcode 接入扫码准入;维修单扫码
补排除「报废转出」;预占再平衡块补 rollback 兜底
- fill_missing_stock_status.sql: 把存量行刷回「在库」。该过滤是
Fail-Closed 的,不先洗刷会让全库无法出库
破坏性变更:不传 request_id 的出库请求现在会被拒绝。
|
2026-09-16 15:45:02 +08:00 |
|
|
|
a8a3c82331
|
fix(stock): 补行级防穿仓与入库改量下限,杜绝可用数变负
available_quantity 一旦为负,预占/释放/盘点的全部算术都会失真。此前有两处
缺口,本次一并堵上,使 available_quantity >= 0 成为不变量。
1) 执行阶段逐行扣减无下限校验(inventory_reservation.restore_then_deduct)
物料级校验只保证「Σ实扫 ≤ Σ可用」这一总量关系,拦不住「总量守恒但单行
穿仓」:同物料下 A 批可用 2、B 批可用 8,工人把 5 件全压在 A 批上,总量
5 ≤ 10 通过,A 批却被扣成 -3。借库走 deduct_stock=False,连实物数校验都
跳过,是裸扣。
已实测复现:借库与出库路径均可把单行扣成 -3。
校验放在 release_reserved() 之后,故不会误拒合法的换批次(物理覆盖):
释放后每行 available 已含本单预占,扫自己预占过的批次时 raw >= 0 保证
必然放行;改扫其它批次时,该批次实时可用量就是它自己的上限。
2) 下调入库数量可把可用数压成负(buy/product/semi 三处 update_inbound)
按 diff 同步增减 stock/available,但无任何下限检查。该批次若已有部分被
预占/出库/借出,向下调整即产生负可用数。现在下调前校验可用数是否够扣。
正常数据下 stock >= available 恒成立,故守住 available 同时守住 stock。
配套:三个 update_* 端点此前只捕获 Exception → 500,没有 ValueError 分支
(同文件的 delete_* 早就有)。补上 400 分支,使业务校验失败不再被记成
服务端故障。
验证(事务内执行并回滚,未落库):跨批次穿仓被拦、合法换批次放行、全额执行
本单预占批次放行、下调击穿被拦(API 返回 400,三个端点一致)、上调不受影响、
真实借库单 52 行全额执行正常、全库无负可用数。
|
2026-09-11 10:34:15 +08:00 |
|
|
|
cbcedecba2
|
fix(outbound): 扫码/备选库位回加本单预占,修正可用数重复计数
出库选单提交申请时 reserve_for_items() 会立即扣减 available_quantity(预占,
防超卖),但扫码页拿到的仍是这个已被本单扣过的值,并当作「本单能扫多少」的
上限。对本单而言它自己锁掉的货当然该能扫,于是同一批货被算了两次:
· 某行被本单占满时 available=0,工人直接扫不进去,提示「库存不足或已出库」
· /alternatives 按 available_quantity > 0 过滤,被本单占满的行从列表消失
· 草稿恢复时刷新实时库存,误报「实际库存已少于你扫的数量」
改法:扫码阶段的可用量 = 实时可用量 + 本单在该行的预占量。别人单子的预占
不回加,防超卖能力不丢。该值与后端 restore_then_deduct() 释放预占后用于校验
的数字精确相等,是同一口径而非近似。
后端:
- inventory_reservation.py 新增 reserved_index()/reserved_qty() 纯读工具
- outbound.py 新增 _own_reserved_index(),改造 /scan 与 /alternatives
- outbound_service.py 的 _format_scan_result 返回归一化可用量
两道门禁:
- biz_type 区分出库/借库两张审批单(独立表、ID 空间独立,而两个端点被
出库页与借库页共用),否则借库单 ID 会命中另一张出库单
- 单据状态仅放行 status ∈ {0,1}。set_items() 只在创建时调用,执行/驳回后
items_json 里的 reserved=True 仍原样保留而库存早已归还,门禁一松就会
二次回加 → 真超卖(现有 3 张已完成单据即属此形态)
不满足门禁时静默降级为不回加(fail-closed),并回传 reservation_applied。
|
2026-09-11 10:34:00 +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 |
|