|
|
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 |
|
|
|
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 |
|
|
|
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 |
|
|
|
d1337d12e1
|
fix(scrap): 修复扫码时库存行遮蔽在管不良品导致的「不在批准明细中」
现象
----
报废申请单指向在管不良品时,待执行清单里明明能看到该 SKU,扫码却报
「不在该报废申请单的批准明细中,禁止报废」。
根因
----
在管不良品的 SKU 是从原库存行**复制**的(退回时 sku=getattr(stock_row,'sku','')),
因此同一个条码可能同时命中 stock_buy#M 与 trans_defective_goods#N —— 这是
**两个不同的实物**。
而 ScrapService.get_stock_by_barcode 原实现按固定顺序
(stock_product → stock_semi → stock_buy → trans_defective_goods)返回
**首个命中**。原库存行只要还在,就永远遮蔽在管不良品,扫码结果恒为
stock_buy。改造前双方都用 SKU 作为匹配键,遮蔽不暴露问题;上一轮给匹配键
加上来源表后,`sku:trans_defective_goods:X` ≠ `sku:stock_buy:X`,
问题才浮出水面。
实测复现(业务报障的 SKU 0000000590):
stock_buy#589 在库 6 件
trans_defective_goods#24 在管 1 件(同一 SKU)
批准明细期望 -> ('trans_defective_goods', 24)
扫码实际返回 -> stock_buy#589 → 键不匹配 → 报错
修复
----
条码本身无法区分这两个实物,**只有单据上下文能决定该扫到哪一个**,故把
单据上下文引入扫码解析:
- get_stock_by_barcode(barcode, prefer_pairs=None):改为**收集全部候选**,
再按 prefer_pairs(当前申请单批准明细的 (source_table, stock_id) 集合)
优先命中;无上下文时退回固定顺序,行为与改造前一致
- GET /scrap/scan 新增可选 request_id:据此加载该单的批准明细构造优先集。
优先集构造失败不阻断扫码,仅告警并回退默认选路
- 前端 scanBarcode(barcode, requestId) 与 create.vue 调用处带上当前申请单 id
验证(隔离数据端到端,非仅单元):
同一 SKU 建于 stock_buy#2202 与 trans_defective_goods#25
不带上下文扫码 -> stock_buy#2202 (命中批准明细? False ← 旧行为)
带上下文扫码 -> trans_defective_goods#25(命中批准明细? True)
执行后:在管 2→0、状态=已报废;**库存行 10/10 分毫未动**(未误扣错误实物)
|
2026-09-16 17:22:51 +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 |
|
|
|
9eb4792d4a
|
feat(return): 退回流水看板接口与权限收口
新增只读台账接口:
- GET /api/v1/outbound/returns 退回流水(分页 + 关键词 + 类型 + 时间过滤)
返回 原出库单号 / 物料名称 / 规格 / SKU / 退回类型 / 退回数量 / 原因 /
操作人 / 退回时间 / 公司。出库单号经 trans_outbound 批量补齐,物料名按
多态来源批量解析,均为批量查询无 N+1。
权限收口(配合 db_migrations 里的三个权限码):
- return-from-outbound inventory_stocktake:operation -> outbound_return
- GET /stock/defective inventory_stocktake -> defective_list
- restock inventory_stocktake:operation -> defective_restock
- scrap inventory_stocktake:operation -> defective_scrap
- change-status inventory_stocktake:operation -> stock_change_status
原先这四个接口搭的是「盲盘作业」权限的便车,职责错配、审计不合规。
实测 SALES(销售)角色持有 inventory_stocktake,意味着销售人员能读整份
不良品台账——与业务对台账可见性的要求不符。全部改用无冒号专用码后,
实测「只授予 inventory_stocktake:operation」对四个接口均返回 403,便车已封。
trans_return 补 company_name 快照:
退回流水的隔离判定原先只能靠 join 链推,而库存行会被入库模块物理删除
(实测 1077 条出库记录中已有 7 条悬空),链路一断记录就会对普通用户
静默消失。改由退回时落快照,隔离不再依赖任何 join。
|
2026-09-16 16:45:52 +08:00 |
|
|
|
dda6e4c787
|
feat(return): 退回、回库、报废与在管台账接口
打通逆向物流的全部后端入口。
新增接口(app/api/v1/inbound/stock.py):
- POST /stock/<id>/change-status 库存状态变更(在库/冻结/不良品)
- POST /stock/return-from-outbound 通用原单退回
- POST /stock/defective/<id>/restock 不良品修好回库(支持部分回库)
- POST /stock/defective/<id>/scrap 不良品报废销毁
- GET /stock/defective 在管台账分页查询
设计要点:
- 良品退回加回原库存行;不良品退回则库存表分毫不动,只写独立在管台账。
这样坏件从根上不会混进可分配池
- 全链路 Fail-Closed 守卫:良品退回到非「在库」行会被拒(status 是行级
属性,加回已冻结/不良品的行会让良品被连带隔离);原库存行已不存在会被
拒(入库模块会物理删除库存行,实测 1077 条出库记录中已有 7 条悬空)
- 三个写接口均加 with_for_update 行锁 + prevent_double_submit 幂等锁。
装饰器顺序为 permission_required → prevent_double_submit,顺序颠倒会因
JWT 未验证而抛错、被自身 except 捕获后 fail-open 降级
- 报废同时写 trans_scrap 台账(source_table 用 trans_defective_goods 并
存台账自身主键,与 trans_borrow/trans_repair 作来源时的约定一致),
成本按原库存行 best-effort 取价,取不到记 0 而不中断报废
报废报表集成(app/api/v1/scrap.py):
- _resolve_materials 补 trans_defective_goods 分支。物料名已在台账冗余存储,
不联表——坏件的原库存行可能已被删除,联表取名称会得到空值
- 公司隔离补 defective_subq 分支。原先按 source_table 逐个构造子查询,
未知来源会被整体过滤,导致这类记录对普通用户静默消失
- 无审批单号的分组前缀按来源分流:不良品直报不再套用 LEGACY-(它是新业务
记录,不是历史脏数据)。分组键同步带上来源标记,且 _order_key_pred 的
SQL 谓词改为同口径,否则页面分组与「按单筛选」结果会对不上
|
2026-09-16 15:45:31 +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 |
|
|
|
b695592178
|
feat(stocktake): 明细去 SKU 改显批号/序列号,备注可选可填,列宽适配平板
【1. 去 SKU】明细表删除 SKU 列,新增「批次号」「序列号」。
SKU 是账务口径的物料主键,现场照着它抄数等于把账面信息递给盘点人。
(搜索框仍支持按 SKU 搜,只是列表不展示。)
后端 merged-list 的 UNION ALL 需要小心:stock_product 表**没有
batch_number 列**(只有 serial_number),故该分支必须写 '' AS batch_number
占位,否则列数不齐 / column does not exist。实测成品行 batch='' serial='205'。
【2. 列宽】名称/规格改 min-width=120(超长省略+tooltip);库位固定 120;
账面数/实盘数/差异固定 100,保证平板上输入框有足够触控面积;备注 min-width=160。
【3. 备注升级为可选项】el-input 换成 el-select(filterable allow-create
default-first-option),预设 ['包装破损','找不到实物','标签丢失','账实不符','实物变质'],
可点选也可自行输入。
★ 顺带修一个真 bug:handleRemarkChange 原本是个空函数,只 console.log,
备注填了**根本没落库**。现复用 update-quantity 接口保存,后端新增可选的
remark 字段(用 `remark is not None` 判断,只改数量时不会清空备注)。
未扫过的行没有草稿记录可挂,备注框同样禁用。
实测: 备注落库='包装破损';只改数量后备注仍在(未被清空)。
|
2026-09-14 09:45:38 +08:00 |
|
|
|
04ed489ab5
|
fix(stocktake): 未扫码的行不允许直接填实盘数
【问题】盘点明细抽屉列出全部在范围内的物料,其中尚未扫码的行也可以
直接在「实盘数」列填数字。填完前端发 POST /stocktake/update-quantity,
后端只会 UPDATE 已存在的草稿行、找不到就返回 404「未找到盘点记录」,
于是工人填的数字根本没保存,只弹一句「更新失败」。
【为什么不改成 UPSERT】那等于允许不扫码就填数 —— 任何人照账面抄一遍
就能"完成"盘点,扫码这道工序形同虚设,盘盈盘亏也无从查起。
所以这里保持"必须先有草稿行"的语义,改为在 UI 上把这条路堵住。
【改动】
- 前端:draft_id 为空的行不再渲染 el-input-number,改显示灰色「未扫」;
已扫行照常可改。失败时提示改为透传后端 msg,并 fetchInventoryList()
回滚本地被 v-model 改动的值。
- 后端:404 文案由「未找到盘点记录」改为
「该物料尚未扫码,请先扫描条码后再修改实盘数」,作为绕过前端时的兜底。
实测:
draft_id 已扫行=11379 → 可编辑;未扫行=None → 不可编辑
绕过前端直接改未扫行 → 404 该物料尚未扫码,请先扫描条码后再修改实盘数
|
2026-09-11 15:15:05 +08:00 |
|
|
|
e573185ea4
|
perf(stocktake): 库位树按公司前缀后端裁剪,树与推荐并行请求
【后端】/tree 支持 ?prefixes=Y 或 ?prefixes=C,L(逗号分隔)
只在**顶层**按 name / full_path 前缀过滤,命中即整棵子树保留 ——
不递归裁剪,避免把子树打散导致前端勾选语义错乱。不传则全量。
刻意不用懒加载:setCheckedKeys / getCheckedNodes 依赖全树已构建。
实测节点数(含子树):
全量 3371 → IRIS (Y) 500(↓85%)→ LICA (C,L) 2871(↓15%)
IRIS 收益很大;LICA 的前缀覆盖了树的大部分分支,故提升有限。
【前端】
- getWarehouseTree(prefixes?) 透传前缀,loadLocationTree 从
getAllowedLocPrefixes(selectedCompany) 取;后端已做过滤,
前端不再重复过滤,删掉冗余的 filterTreeByCompany。
- fetchRecommendLocations 改为 Promise.all 并行拉树与推荐,
取代原来的串行 await(两段网络等待不再叠加);
setCheckedKeys 前仍保留 await nextTick() 等树渲染完。
【顺带修一个上一轮引入的 bug】
右侧自 leafOnly 改造后只存末级路径,而推荐返回的是库存级路径、可能是
非末级,原来的逐字比对必然对不上,会把正常勾选的库位误报成
「未能勾选」。改为按「自身或其祖先」判定覆盖。
实测: prefixes 过滤正确(IRIS 8 个顶层 / LICA 25 个 / 不传 33 个)
|
2026-09-11 14:45:23 +08:00 |
|
|
|
e9468673c0
|
feat(stocktake): 抽盘范围改为「系统推荐 + 人工在库位树上勾选」
【后端】
- 新增 GET /stocktake/recommend-locations?days=30&top_n=50
调 get_active_locations 返回推荐库位 full_path 列表 + moves/sku_count/
last_move 明细。只推荐不落库,days/top_n 做范围钳制(1~365 / 1~500)。
- /draft/start-new 在 scope_type=active 时不再自行计算活跃库位,改为读取
payload 的 scope_config.locations 并校验归一化(去空白、去重、保序、
空列表 400、上限 2000)。范围决定权交还前端 UI —— 否则用户手改的勾选
会被后端覆盖。
【前端】欢迎页选中「活跃库位抽盘」时展开配置区:
- 「近 30 天最活跃的前 [N] 个库位」+【获取推荐】
- el-tree(show-checkbox)数据取自 /v1/warehouse/tree,按公司前缀过滤
(IRIS 只留 Y*,LICA 只留 C*/L*,复用 getAllowedLocPrefixes)
- 获取推荐后 setCheckedKeys 自动勾选;用户可自由增删
- 提交时 getCheckedNodes().map(n => n.full_path) 打包进 scope_config.locations
两个实现细节:
- 推荐里有、但树上勾不到的库位(不在当前公司前缀内等)会明确告警并打印,
不让它们静默落选 —— 否则工人以为盘到了、实际没进范围。
- 已选库位数实时显示,因为 el-tree 默认级联:勾一个父节点会连带勾中整棵
子树,规模可能远超推荐数量,需要让用户看得见。
实测:
recommend-locations(top_n=10) → 10 个库位 + 明细
start-new 传 3 个库位 → 落库正是这 3 个,总品项 36(全仓 1126)
不传 locations → 400;勾选为空 → 400
公司前缀过滤: IRIS→8 个(Y1~Y8),LICA→25 个,无越界
|
2026-09-11 14:13:10 +08:00 |
|
|
|
fc95357662
|
feat(stocktake): 跨公司扫码给精准报错,0 库存盘盈在明细中可见
【跨公司拦截】
get_stock_info 带 company_name 过滤后,扫到别家公司的货会直接查不到,
返回 404「未找到物料」—— 与「条码根本不存在」完全无法区分,现场人员
既不知道是扫错了还是扫了别人的货。
新增 find_stock_owner_company():不施加公司过滤地全局查条码归属;
get_stock_info 未命中时由 _classify_missing_stock() 复核,区分两种情形:
· 条码存在但属于别家公司 → 403「条码 [X] 属于【LICA】,请勿跨公司盘点」
· 条码不存在 → 404「未找到该物料库存: X」
该方案的前提「SKU/条码全系统唯一」已核验成立:三张库存表的表内重复、
跨公司重复、跨表重复六项检查均为 0,故归属至多命中一条,无歧义。
/scan 与 /draft/add 两个入口均已接入。
【0 库存盘盈可见性】
merged-list 的 union_sql 原本严格要求 stock_quantity > 0,导致账面为 0、
但已被扫入的盘盈物料不出现在明细抽屉里,而 total_scanned 却把它计入
「已盘」—— 工人看到已盘 +1 却在明细里找不到,以为系统丢了数据。
现改为 stock_quantity > 0 OR id IN (本会话该表的草稿 stock_id)。
条件严格限定在**本会话**的草稿,不会把全库 0 库存物料都放出来。
实测:
IRIS 扫 LICA 条码 → 403「条码 [0000002097] 属于【LICA】,请勿跨公司盘点」
扫不存在条码 → 404「未找到该物料库存: NOPE-99999」
已扫的 0 库存物料 → 明细可见(账面 0 / 实盘 5 / 差异 +5 / 库位 Y3/3/3/4)
未扫的 0 库存物料 → 明细 0 行(范围正确)
|
2026-09-11 14:05:59 +08:00 |
|
|
|
eabe70b00a
|
feat(stocktake): 落地动盘(活跃库位抽盘)与导出盲盘保护
【导出收尾】/export-stocktake
会话 active 且 mode=blind 时,汇总表/差异明细/账实相符/未盘点四张 Sheet 的
账面数与差异数一律输出 '—',实盘数照常输出。会话 finished 后自动解锁 ——
盘点结束后核对差异是正常流程,否则报告本身没法用。
批量替换时误伤了 update_stocktake_quantity 里的差异计算,被静态检查脚本
抓出(会拿占位符 '—' 做减法),已改回按真实账面值计算。
【动盘算法】get_active_locations(company_name, days=30, top_n=50)
近 N 天异动最频繁的库位。出库经 (source_table, stock_id) 回查库存表取库位
(trans_outbound.warehouse_location 历史上 900 条里 829 条为空,实测回查
覆盖率 298/299);入库以库存表自身的 in_date / production_date 计时;
借出用 trans_borrow.location。报废/维修表无库位字段,未纳入。
【范围冻结】/draft/start-new
移除 scope_type=active 的 400 拦截,改为在开单时算出活跃库位并**冻结**进
scope_config。必须开单时算好 —— 若各接口实时计算,两次调用之间活跃度变化
会导致范围漂移,结束盘点时把已掉出范围的库存误判成盘亏。
【同源过滤】all-items / merged-list / generate-missing
三者都读同一份冻结的 scope_config。generate-missing 是重中之重:漏盘比对
只针对范围内库存,绝不把范围外未盘库存标成盘亏。
merged-list 一并加了过滤(原指令未提),否则抽屉仍会列出范围外物资,
与「总品项」对不上。
【前端】移除「活跃库位抽盘」的 disabled。
实测(IRIS,全仓 1126 → 抽盘 537):
生成盘亏 537 条 | 落在抽盘范围内 537 | 落在范围外 0
导出: 盲盘 active → 账面数/差异数 = '—';盲盘 finished → 76 / -76(解锁)
|
2026-09-11 13:41:56 +08:00 |
|
|
|
97c1a9131c
|
feat(stocktake): 明/盲盘由后端强制驱动,前端解除屏蔽
原实现是前端把「账面数」「差异」两列用 HTML 注释藏掉 —— 数据仍在
HTTP 响应里明文下发,抓包或用改过的客户端即可看到,等于没有屏蔽。
【后端】/draft/merged-list 先查该 session_id 的 StocktakeSession.mode:
- blind:stock_qty 与 diff_qty 一律置 None,真实账面数不出现在响应中
- open :正常下发
差异必须一并置空 —— diff_qty 由账面数算出,下发差异等于变相泄漏账面数。
响应新增 data.mode 供前端提示。
Fail-Safe:查不到会话行时按盲盘处理。宁可界面少显示两列,也不能因为
会话记录缺失就把账面数泄漏出去。
【前端】删除屏蔽用的 HTML 注释,恢复两列正常渲染,不再承担数据屏蔽职责。
但 null 必须渲染为「—」而非 0:原 差异 模板用 `row.diff_qty || 0`,
会把「未下发」显示成「无差异」,工人会误以为账实相符。
另在抽屉表头加模式标签,便于核对。
实测:
盲盘 → 账面数=None 差异=None mode=blind
明盘 → 账面数=1.0 差异=-1.0 mode=open
未知 session_id → 同盲盘(Fail-Safe 生效)
|
2026-09-11 13:34:36 +08:00 |
|
|
|
3f964614e3
|
feat(stocktake): start-new 落库会话配置,active-session 改查会话表
- /draft/start-new 接收 mode / scope_type / scope_config,插入一条
StocktakeSession(status='active')。同一公司原有活跃会话先置为 finished
(同一事务),否则会撞 uq_stocktake_session_one_active。
公司取值:普通用户强制本公司;跨域角色必须显式指定,否则 400。
scope_type 目前只放行 'full' —— 抽盘的范围过滤尚未实现,此时放行会导致
结束盘点时 generate-missing 把抽盘范围外的库存批量标成盘亏。
- /draft/active-session 改为直接查 stocktake_session
(company_name + status='active'),并返回 mode / scope_type 供前端展示。
旧实现靠 max(scan_time) 从草稿行猜,空会话识别不了。
- /stocktake/generate-missing 漏盘比对完成后把会话置为 finished,
否则 active-session 会继续把它当活跃会话,其他 PDA 会加入一个已结束的盘点。
实测:
① 超管+IRIS 创建 blind/full 会话 → 200
② SALES/IRIS 查活跃会话 → 拿到同一 session_id 与 mode=blind
(scanned=0 的空会话也能查到,旧实现此处返回 null)
③ 再开一轮 → superseded_count=1,旧的转 finished
④ scope_type=active → 400「活跃库位抽盘尚未实现」
⑤ DB 层验证唯一索引拒绝重复活跃会话
|
2026-09-11 13:28:49 +08:00 |
|
|
|
f8403d2fb2
|
feat(stocktake): 活跃会话接口补充已扫件数与发起人
多人协同场景下,加入者需要知道「谁开的、盘到哪了」,原接口只返回会话ID和
行数,信息不足以提示。
- /draft/active-session 新增 scanned(按 source_table+stock_id 去重,
且排除 user_id='system' 的自动漏盘记录,避免进度虚高)
与 initiator(该会话最早一条人工扫码记录的操作人姓名)
- 把 export_stocktake 内嵌的 get_user_name 提到模块级 _resolve_user_name
供两处复用,并补齐它漏掉的 user_id 格式:
盘点/流水里实际存的是 JWT 的 display_name,形如 "孙霞(sunxia)",
而原实现只认 SysUser.username 的 "姓名/账号" 格式,导致解析不出来时
原样返回。现兼容 "姓名(账号)" / "姓名/账号" / 纯数字ID 三种。
实测: SUPER_ADMIN 无参数 → scanned=1117, initiator='韩善龙'
SALES + IRIS → 会话 company_name 为 NULL(迁移前遗留),正确返回空
|
2026-09-11 12:42:55 +08:00 |
|
|
|
97ff63523c
|
fix(export): 公司隔离移出 if filters 判定,确保无条件执行
export_excel 的行级公司隔离原本嵌套在 `if filters:` 内部:
只要调用方不传或传空筛选条件,整段隔离会被跳过且不抛错 —— 静默失效。
(实测: 修复前 export_excel({}, None) 会导出跨公司全部 1816 行。)
现把 get_current_company_filter() 及其 filter_conditions.append 提到
if filters: 之前,无论有无筛选条件都绝对执行。
同时清理路由里 filters 字典的 'company' 死键 —— export_excel 从不消费它,
真正生效的是 get_current_company_filter() 直接读 request.args 上的公司标识。
注意:前端 handleExport 的 company 参数必须保留(已在代码中加注说明)。
实测 WAREHOUSE_MGR 带 ?company=IRIS 导出 1126 行仅 IRIS,不带则 1816 行
涵盖 IRIS+LICA —— 跨域角色按公司收窄范围完全依赖这个 query 参数。
实测(修复后):
SALES/IRIS filters={} → 1126 行, 仅 IRIS
SALES/IRIS filters={keyword:''}→ 1126 行, 仅 IRIS
WAREHOUSE_MGR ?company=IRIS → 1126 行, 仅 IRIS
WAREHOUSE_MGR (无参数) → 1816 行, IRIS+LICA
|
2026-09-11 12:41:27 +08:00 |
|
|
|
f4d97b6c4f
|
fix(image-search): 成品库存回填误取 batch_number,导致以图搜图必 500
POST /api/v1/common/image-search 在回填 StockProduct 业务数据时读取
r.batch_number,但 stock_product 表没有该列(只有采购件、半成品有),
AttributeError 把整个检索打成 500 —— 即便 stock_buy / stock_semi 两路
已经查好也一起丢掉。
改为与 stock.py /stocktake/all-items 的既有约定一致,用 serial_number 兜底,
保持三个模块回填的字段形状不变。
实测: StockProduct 样例 hasattr(batch_number)=False,serial_number='205'。
|
2026-09-11 11:44:31 +08:00 |
|
|
|
02e03e6fb6
|
fix(stocktake): 补上模块级 traceback 导入,修复异常分支的 NameError
stock.py 模块级从未 import traceback,却有 3 处裸调 traceback.print_exc():
- scan_stock_by_barcode (/scan) —— 本次改动之前就存在
- get_stocktake_companies (新增)
- get_active_session (新增)
后果是真正的错误被 NameError 盖住:例如 company_name 列不存在时,
日志里只剩「name 'traceback' is not defined」,看不到根因。
其余 4 处在 except 块内写了局部 import 因而一直正常,现统一由模块级提供。
|
2026-09-11 11:34:23 +08:00 |
|
|
|
b8d18c71d8
|
fix(stocktake): 盘点链路按公司隔离,开启新盘点不再清空整表
修复三处跨公司数据污染:
1. /draft/start-new 原本执行 StocktakeDraft.query.all() 后逐条 delete,
任一库管点「开启新盘点」就会物理删除全公司所有人的盘点进度。
改为只签发新 session_id,历史数据原样保留;清理走 /draft/clear,
且强制要求 session_id(不传直接 400),杜绝误清整表。
2. get_stock_info() 全局按 barcode/sku 匹配三张库存表,不同公司的同码
物料互相串货。新增 company_name 参数,精确与模糊两段查询均经由 base
关系按 material_base.company_name 过滤;草稿去重键同步改为
(uuid, session_id, company_name)。
3. 盘点相关读写接口统一施加公司隔离:/draft/list、/draft/add、/draft/clear、
/variance-report、/draft/merged-list、/stocktake/all-items、
/stocktake/generate-missing、/stocktake/update-quantity、/export-stocktake、
/adjust、/scan。
顺带修复 /export-stocktake:差异与相符两张 Sheet 原本不按 session 过滤,
过去依赖 start-new 清空整表才恰好等价于当前会话,现显式按 session_id 过滤,
否则历史会话会被一并导出。
新增接口:
- GET /stocktake/companies 盘点页公司下拉(普通用户只返回本公司,
避免复用 /inbound/buy/options 时因缺 inbound_buy 权限而 403)
- GET /draft/active-session 该公司最近一次活跃会话,供多设备加入
|
2026-09-11 11:33:12 +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 |
|
|
|
e93107458a
|
feat(purchase): 采购列表支持关键词搜索与日期范围
背景
----
采购管理此前只有状态筛选,无法按单号/物料/申请人检索,也无法按采购
日期收窄范围。现对齐出库/报废记录的搜索语义,便于用户迁移使用习惯。
后端
----
PurchaseService.get_purchase_list 新增参数:
keyword / search_type / start_date / end_date
search_type 取值(与出库、报废一致):
all 单号 | 物料名称 | 规格型号 | 备注 | 申请人
no 单号
name 物料名称
spec_model 规格型号
requester 申请人
实现要点
--------
· 申请人姓名存在 SysUser.username,格式「姓名/账号」(如 韩善龙/hanshanlong),
ilike 直接匹配整串即可命中;
· 该类字段只在 SysUser 上,故**按需 join** —— 单号/名称/规格分支不联表,
避免无谓开销;
· 公司隔离分支也需 join SysUser,与关键词分支可能重复 join 同一目标,
会产生笛卡尔积导致结果翻倍,故按需补 join 并用 distinct 兜底;
· 日期范围按 purchase_date 过滤(该列是 Date,无需补时分秒)。
实测:名称'充电器'→1 单,申请人'韩善龙'→21 单,日期 05-13→2 单,
关键词不存在→0 单,状态+搜索组合→14 单。
|
2026-09-10 17:41:22 +08:00 |
|
|
|
ec66c33b06
|
feat(scan-draft): 扫码草稿表与接口,支持暂停后继续扫码
场景
----
扫码出库/借库的作业可能很长(一张单几十项),工人常需中途暂停去处理
更紧急的单据。改造前切换单据会清空已扫内容,刷新/退出页面则全部丢失。
由于库存在申请审批通过时已**预占**,暂停期间货不会被他人抢走 ——
因此草稿只记录「扫到哪了」,**不涉及任何库存操作**。即使草稿丢失也只是
需要重扫,不会造成库存错乱。
隔离粒度
--------
按 (user_id, biz_type, request_id) 一人一单:每个人扫自己的草稿,互不影响;
同一人可同时持有多张单据的草稿(正是「暂停 A 去出 B」的场景)。
user_id 一律取自 JWT,不接受入参覆盖,故不可能读写他人草稿。
为什么整单存一个 JSON(而非每条明细一行)
------------------------------------------
1. 保存是「全量覆盖」语义,逐行存无增量更新的收益;
2. 恢复时需要物料名称/规格/库位等展示字段,逐行方案只能回查申请单的
items_json —— 而历史单据的 items_json 不含 stock_id,回查会错配。
整单快照把展示字段一并存下,恢复零依赖,对老单据同样可靠。
接口
----
GET /api/v1/scan-draft 读取草稿
POST /api/v1/scan-draft 保存(全量覆盖;空清单则删除)
DELETE /api/v1/scan-draft 清除(提交成功后调用)
GET /api/v1/scan-draft/overview 各单据进度,供下拉徽标
实现要点
--------
· items_json 列是 jsonb,模型必须用 db.JSON —— 用 db.Text 会让 psycopg2
拿到 Python list/dict 时无法适配,报 "can't adapt type 'dict'",而异常
被接口的 except 吞掉后 POST 仍返回"成功",问题极难发现(开发中实际踩到);
· 概览接口做防御:残留的空草稿不参与展示。
实测:保存/读回、跨单据隔离(B 读 A 的草稿为 0 项)、全量覆盖、
提交后清除,全部符合预期。
|
2026-09-10 17:21:14 +08:00 |
|
|
|
29fc00a818
|
fix(my-requests): 修正 withdraw_endpoint 路径前缀导致撤回 404
现象
----
在「我的申请单」页面点撤回,前端控制台报:
POST /api/api/v1/outbound/request/351/withdraw 404
根因
----
aggregate 端点在每条记录上回传 withdraw_endpoint,但拼接时多写了 /api:
返回的: /api/v1/outbound/request/351/withdraw
axios baseURL: /api
实际请求: /api + /api/v1/... = /api/api/v1/... → 404
后端路由本身是对的(/api/v1/outbound/request/<id>/withdraw 已正确注册),
问题纯在前端拼接。其它 API 封装的正确写法是只传 baseURL 之后的部分
(如 url: '/v1/outbound/my-requests'),本处是唯一破坏该约定的地方。
修复
----
返回 /v1/... 而非 /api/v1/...,并加注释说明约定。
借库项同时从 close 改为 withdraw(配合上一提交的申请人端点)。
验证方式
--------
按**前端真实的拼接方式**验证('/api' + endpoint),而非直接调后端路由:
[outbound] /v1/outbound/request/381/withdraw → 200
[borrow ] /v1/transactions/borrow/request/44/withdraw → 200
[scrap ] /v1/scrap/request/5/withdraw → 200
教训:上一轮用 test_client 直接调后端路由验证,绕过了 axios 的 baseURL
拼接,所以后端测试全绿、前端一跑就 404。验证「前端能否调通」必须模拟
前端的请求方式。
|
2026-09-10 15:47:02 +08:00 |
|
|
|
077fd2f2cf
|
feat(borrow,scrap): 补齐申请人撤回端点,与出库对齐
背景
----
出库已有独立的申请人撤回端点(仅 @jwt_required + 服务层归属断言),
但借库/报废没有:
· 借库撤回复用 close 端点,权限是 op_borrow_approval(管理路径),
普通员工调用返回 403;
· 报废撤回带 @permission_required('scrap_apply'),同样挡住普通申请人
(scrap_apply 只授予 INBOUND/OUTBOUND/SUPERVISOR/SUPER_ADMIN)。
结果是「我的申请单」页面里,借库/报废的撤回按钮对普通员工点了报错。
借库
----
服务层拆成与管理路径并列的两条入口(与出库同构):
mark_completed —— 管理路径,需 op_borrow_approval,仅 status==1
withdraw_request —— 申请人路径,仅校验单据归属,status 0 或 1
两者共用 _release_and_close(),释放逻辑只有一份实现。
新增 POST /transactions/borrow/request/<id>/withdraw(仅 @jwt_required)。
报废
----
withdraw() 增加 require_owner 参数区分两条路径:
require_owner=True (默认,申请人路径)→ 断言 applicant_id == operator_id
require_owner=False(管理路径) → 由调用方权限装饰器鉴权
WITHDRAWABLE_STATUS 从 (1,) 放宽到 (0, 1),与出库/借库对齐。
移除端点上的 @permission_required('scrap_apply'),改走归属校验。
安全实测
--------
借库 B 撤 A 的单 → 403,库存仍是 27(未被释放)
借库 A 撤自己的单 → 200,30 完全释放
报废 B 撤 A 的单 → 403
报废 A 撤自己的单 → 200
主管代撤员工的单(特权路径)→ 200
|
2026-09-10 15:46:55 +08:00 |
|
|
|
b67d577616
|
feat(my-requests): 跨模块聚合端点,一个页面看全部申请
动机
----
出库/借库/报废三个模块各有一套审批流,申请人此前没有统一入口。
新增只读聚合视图,把三类单据合并返回。
为什么单独建蓝图
----------------
权限模型不同:审批端点是「管理视角」,本端点是「申请人视角」。
把两者塞进同一端点(if not privileged: applicant_id = me)会让管理逻辑
与用户逻辑混流,一旦 is_privileged_viewer() 判定出错即越权。
本模块从设计上就没有「看别人」的分支 —— applicant_id 硬编码为当前用户。
只读保证
--------
本模块只做查询,不修改任何数据。撤回等写操作仍由各模块自己的端点承担
(因为三者释放逻辑不同:出库/借库已接入预占,报废尚未接入)。
把风险锁在只读层,即使聚合逻辑有 bug 也不会破坏业务数据。
字段归一化
----------
三个模块的 items_json 存在差异,统一在服务端抹平:
· 数量字段:报废用 scrap_qty,出库/借库用 quantity → 统一为 quantity
· 库位字段:报废用 location → 统一为 warehouse_location
这专门避免「报废行的数量列显示空白」这类不报错的隐性 bug。
健壮性
------
单个模块查询失败时记日志并跳过,其余模块照常返回
(例如某张表尚未迁移时,其它两类仍可用)。
响应中附带 withdraw_endpoint 字段,前端据此分发撤回请求,
无需硬编码三个模块的 URL 映射。
实测
----
普通员工(INBOUND 角色,无任何审批权限)访问 → 200,18 单
三类单据齐全,type_label 正确
报废明细数量字段归一化成功(quantity=1.0)
B 看不到 A 的单 → 0 条
type=scrap 过滤 → 全部为报废
|
2026-09-10 15:37:05 +08:00 |
|
|
|
bcbee8a194
|
feat(outbound): 申请人撤回自己的申请单 + 我的申请单端点
背景
----
出库审批页是管理视角(需 outbound_approval 权限),普通申请人提交后
**没有任何入口看回自己的单据**,更谈不上撤回。
服务层:抽出共用释放逻辑
------------------------
新增 OutboundApprovalService.withdraw_request(),与既有的 close_request()
(管理路径)形成两条独立入口:
close_request —— 管理路径,需 outbound_approval 等权限,仅 status==1
withdraw_request —— 申请人路径,仅校验「单据归属」,status 0 或 1 均可
两者各自完成权限与状态校验后,调用**同一个** _release_and_close()。
释放逻辑只有一份实现,不会因修改其中一处而漏掉另一处。
为什么单独开一条路径,而不是在 close_request 里加 if 分支:
权限模型不同(管理角色 vs 单据归属)。混在一个函数里,后续修改容易
互相影响 —— 这正是需要避免的访问控制风险。
API
---
POST /outbound/request/<id>/withdraw 申请人撤回(仅 @jwt_required)
· 归属断言:非本人且非特权 → 403「无权撤回他人的申请单」
· 状态守卫:仅 0/1 可撤回;执行成功后 status 会被置 3,故该判断
本身即执行守卫,已执行或已撤回的单都进不来
GET /outbound/my-requests 我的申请(仅 @jwt_required)
· applicant_id 硬编码为当前登录用户,不接受任何入参覆盖
两者都**不做模块权限校验** —— 普通申请人无需持有 outbound_approval
(那是管理权限)。与「给审批端点加 if 降级放行」是两条路:后者把管理
逻辑与用户逻辑混在一个端点里,一旦 is_privileged_viewer() 判定出错
即越权;本端点从设计上就没有「看别人」的分支。
安全实测
--------
普通员工查我的申请(此前 403) → 200
B 查列表看不到 A 的单 → 39 单中无 A 的单
B 撤回 A 的单 → 403,且库存未被释放
A 撤回自己的单 → 200,库存 17→20 完全释放
主管代撤他人工单 → 200(特权路径)
待审批(status=0) 撤回 → 200,库存释放
重复撤回 → 400「当前状态不可撤回」
|
2026-09-10 15:36:59 +08:00 |
|
|
|
9925bf2b99
|
fix(inventory): 撤回/作废单据时释放预占库存,消除库存泄漏
问题
----
系统原本已有「完结」功能(出库/借库),但它只改状态、不释放预占:
approval.status = 4 # 已完结
db.session.commit() # ← 库存没还回去
申请阶段 reserve_for_items() 扣掉的 available_quantity 就此永久泄漏 ——
货被一张永不执行的作废单锁死,谁也领不走。
核查存量 23 张 status=4 的单,所幸均为预占改造前提交(items_json 无
stock_id),尚未造成实际损失。但缺陷本身是真实的。
改动(三个模块统一)
--------------------
出库 outbound_service.close_request
借库 borrow_service.mark_completed
· 调用 release_reserved(approval.get_items()) 按 items_json 原样归还;
· 明确「仅 status==1(已通过待执行)可撤回」—— 执行成功后
create_outbound_batch / execute_dispatch 会把 status 置为 3,
故该状态判断本身即执行守卫,已执行或已撤回的单都进不来;
· 返回消息带上释放条数,便于操作者确认。
报废 scrap_approval_service.withdraw(新增能力)
· 报废原先只有 approve/reject,没有撤回入口,补齐;
· 复用同一个 release_reserved():报废当前尚未接入预占,调用它会安全
跳过(无 reserved 标记),但将来报废接入预占时该段代码自动生效;
· 新增端点 POST /api/v1/scrap/request/<id>/withdraw(权限 scrap_apply)。
未采用按流水表二次校验:request_no(APR-OUT-…) 与 outbound_no(OUT-…)
格式不同、无关联字段,按单号比对是无效的,状态判断已足够。
实测
----
决定性用例(证明释放真实生效,非账面功夫):
A单预占5 → available=1
B单要5 → 400 拒绝(被A占住)
撤回A → available=6
B单再要5 → 200 成功,available=1 ★ 释放的库存真的可被复用
三模块:
出库 撤回后 4→10 完全恢复,stock 未变(货没动)
借库 撤回后 6→10 完全恢复
报废 撤回成功,重复撤回被正确拒绝
状态码说明:报废复用出库/借库已有的 4=已完结 作为「已撤回」,
而非引入 -1,避免同一系统出现两套编号(其 2 已被「已驳回」占用)。
|
2026-09-10 15:08:32 +08:00 |
|
|
|
209b29c10f
|
feat(outbound): 备选库位可见性,让「物理覆盖」不再盲扫
问题
----
预占会把货锁定在某个库位,但工人到现场可能进不去/找不到该库位,
需要改扫同物料的其它批次。后端执行端已支持按 base_id 校验、允许换批次,
但系统从不告诉他「还有哪些库位有货」—— 工人只能凭记忆或挨个翻。
后端:新增 GET /api/v1/outbound/alternatives
--------------------------------------------
入参 base_id(必填)、source_table/stock_id(可选,用于标注推荐行)
返回该物料全部可用库存行 + 合计可用量,推荐行置顶、其余按可用量降序。
为什么不复用 stock/list 或 bom-match-stock 的查询模式:
那两处按 stock_quantity > 0 过滤,会把「有货但已被别单全部预占」的库位
也列出来,工人跑过去才发现拿不到。实测库中有 14 行处于该状态。
本接口按 available_quantity > 0 过滤,只给真正能拿的库位。
前端:计划清单库位列加图标 + popover
------------------------------------
[推荐] Y1/2/1 可用 5 ← 本单锁定行(来自 items_json 的 stock_id)
[备选] Y2/3/4 可用 10
[备选] Z1/1/1 可用 2
三处取舍:
· trigger="click" 而非 hover —— 车间用扫码枪/触摸屏,hover 在触屏不可用
· @show 时才发请求 —— 计划清单可能几十行,渲染即请求会打出一片并发
· 附提示文案「现场取不到推荐库位时可直接扫备选库位条码出库」
注:历史单据的 items_json 无 stock_id,此时所有库位显示为「备选」
(不影响可用性,仅少了推荐标记);预占改造后新提交的单可正确标注。
实测:造 3 批次 Y1/2/1(5) Y2/3/4(10) Z1/1/1(2),预占首个后其 available=0,
接口正确排除该库位,返回两个备选、合计可用 12。
|
2026-09-10 14:59:17 +08:00 |
|
|
|
a910a6ea72
|
feat(bom): 后端承接 BOM 库存分配,消除多批次物料只能加 1 件的缺陷
问题现象
--------
BOM 选单中物料显示需求 10、聚合可用 839,加入购物车却只剩 1 件,
并提示库存不足。
根因
----
两个接口口径不一致:
· GET /bom/stock/<bom_no> 按 base_id 聚合 → current_stock=839
· POST /outbound/bom-match-stock 不聚合,每批次一行 → 某行只有 1
前端用 stockList.find(s => s.base_id == child_id) 只取第一条库存行,
若首行恰好只剩 1 件,需求量又被 Math.min 压到 1,现象即如此。
为何必须放在后端
----------------
前端 stockList 由多个入口写入(手动选单/搜索/BOM),随时可能被覆盖;
且 base_id 与 stock_id 的类型差异会让匹配静默落空,表现同样是「库存不足」。
更关键的是:分配需要「该 base_id 全部可用库存行」的完整视图,
而这必须与出库扣减(create_outbound_batch 按 stock_id 逐行加锁扣减)
使用同一份数据源。
改动
----
bom-match-stock 新增分配模式:
请求 { requirements: [{base_id, required_qty, name, spec_model}] }
响应 { items: [...已分配行], shortages: [...缺料明细] }
_allocate_bom_requirements() 在 DB 层完成:
1. 三张库存表按 base_id 一次性取全部 available_quantity > 0 的行
(带公司隔离,join base 取名称规格);
2. 可用量降序排序 —— 优先进大行,减少购物车拆分行数;
3. 逐物料扣减 required_qty,产出真实 stock_id + source_table + allocated_qty;
4. 分配不足记录 shortage 但不阻断其它物料。
每行仍携带 uniqueKey,前端可直接入购物车。
返回前剥离价格成本字段(Fail-Closed)。
旧查询模式(child_ids)保留,兼容未改造的调用方。
顺带修复一处静默失败
--------------------
查询块的 except 原为直接 continue,会把 NameError 等错误吞成「该物料无库存」。
改为 logger.error 输出,避免同类问题再次以业务结论的形式出现。
实测(base_id=2405,聚合 841,需 10):分配 1 行 stock_id=1961 分配 10,无短缺;
base_id=2963(9 行各 1),需 5 → 5 行各 1 合计 5;
需 20(聚合仅 9)→ 9 行合计 9,短缺 11。
|
2026-09-10 14:16:47 +08:00 |
|
|
|
6068625562
|
feat(audit): 审计日志业务化——对象名与字段中文化、操作来源与类型筛选
一、target_name 业务化(后端 audit_listener.py)
优先级:业务标识(request_no/outbound_no/borrow_no/sku/bom_no…)→
名称字段 → 关联物料名 → 「中文表名 - 业务号或ID」。
新增 TABLE_LABELS 表名到中文的映射(18 张白名单表全覆盖)。
用户看到的从 scrap_approval ID:371 变为
「出库申请单 - APR-OUT-20260805-1550-0005」这类可理解的对象描述。
二、前端字段中文化(AuditLog.vue)
· fieldMap 由 11 项扩充到 110+ 项,覆盖审批单/流水/库存/主数据/系统管理五类;
· 修复关键 bug:fieldMap 原先只作用于「变更对比」区,「删除快照」与
「新增详情」两区直接渲染原始 key(label 直接取 String(key)),
这正是详情里满是英文列名的直接原因。现三区统一经 fieldLabel() 取值。
三、时间显示修正
后端已改写北京时间,前端原先补 Z 当 UTC 解析会造成二次 +8 小时,
改为按 +08:00 理解该字符串。
四、新增两类筛选
· 操作来源(真实用户 / 系统操作 / 全部),默认「真实用户」。
历史存量含约 1.8 万条 username=system 的噪声日志,会把列表刷屏。
· 操作类型别名归一:历史数据中 action 有两套写法(早期装饰器写中文
新增/修改/删除,现行监听器写大写 CREATE/UPDATE/DELETE),导致下拉
同时出现二者、且选中文项只能搜到 3-4 月的老数据。现 ACTION_ALIASES
把任意写法归一化后展开匹配,下拉只暴露 3 个规范值,
历史数据无需迁移即可被正确检索。
|
2026-09-10 14:16:39 +08:00 |
|
|
|
4808a48594
|
refactor(audit): 审计架构清理——复活白名单监听器、停用噪声监听器、清除僵尸装饰器
一、统一为单一监听器实现
原先两套 SQLAlchemy 事件监听器并存:
· app/utils/audit_events.py —— 全局监听 db.Model、无白名单、无请求上下文守卫(实际在跑)
· app/core/audit_listener.py —— 白名单制、有守卫、有模型级开关(从未生效)
后者失效的根因:注册代码写在 extensions.py 的 init_extensions() 内,
而该函数全仓库只有定义、没有任何调用(create_app 直接内联调用 db.init_app 等)。
现统一由 app/core/audit_listener.py 承担,并在 create_app() 中显式注册。
extensions.py 的死函数 init_extensions 整体删除,避免后人误以为它是有效入口。
二、修复监听器三处致命缺陷(此前注册了也写不进数据)
1. 事件回调第二个参数是 Connection,原代码却调用 Connection.add()(不存在),
每次写日志都抛 AttributeError 并被 except 吞掉 → 改为 connection.execute()
2. register_audit_listeners 从 app.models 批量 import 多个未导出的模型,
ImportError 被上层 try/except 吞掉 → 改为按表名从 db.metadata 取模型
3. 本项目有 31 处函数体内延迟导入模型(如 scrap.py 内部才 import ScrapApproval),
一次性注册会静默漏表 → 增加 ensure_audit_listeners() 惰性补绑,
并在模型预加载段补全审批单/BOM/采购等模型
三、强约束
· WHITELIST_TABLES:仅 18 张核心业务表,系统表/草稿表/向量表不再自审
· has_request_context() 守卫:系统初始化与后台定时任务不再产生 username=system 噪声
· IGNORE_FIELDS 增加 password/password_hash/salt/token/secret/api_key(安全红线)
· created_at 显式写 beijing_time(),与全系统时间口径一致
四、清除僵尸装饰器
@audit_log 早已退化为直接透传的空壳(module/action 参数全被忽略,
数据库中零星的中文 action 即其历史遗留产物),却仍挂在 38 处路由上。
连同 13 个文件的 import 一并移除;audit_events.register_audit_events 改为空操作。
验证:应用上下文中的写操作不产生日志;HTTP 请求产生 5 条日志,
对象为业务单号(APR-SCRAP-... / SKU),模块中文,操作人真实,时间为北京时间。
|
2026-09-10 14:16:27 +08:00 |
|
|
|
1ef9ae4ad9
|
feat(records): 报废记录接入高级筛选
复用 app/utils/advanced_filter.py,但报废有两处与出库/借还不同,需特殊处理:
一、scrap_request_no 可为 NULL(历史直接报废)
出库/借还可直接对单号列做 IN,报废不行——「单号 IN」永远匹配不上 NULL 行,
会把历史单整体漏掉。此处统一用「行级等价键」表达同单关系:
有单号 → scrap_request_no 相等
无单号 → 同一分钟(date_trunc) + 同一操作人(与 Python 侧分组逻辑完全一致)
二、物料名不能用单号传递结果
物料名需三表联查,但联查结果若用「单号 IN」回接,同样会漏掉 NULL 单号行。
改用 tuple_(source_table, stock_id).in_(pairs) 元组定位报废行。
三、否定操作符
命中行 → 折算同单等价键谓词 → 否定时整体取反(~pred),实现整单排除。
前端 scrap/index.vue 新增「高级筛选」el-popover,与出库/借还版式一致。
验证(库中当前唯一报废单含 SKU 0000000272):
sku contains 0000000272 → 1 单
sku ne 0000000272 → 0 单(整单排除,符合预期)
material_name not_contains 白板 → 0 单(该单物料名含白板,被排除)
单号 ne(父级) → 1 单(父级否定语义不变)
|
2026-09-10 13:06:07 +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 |
|
|
|
e437b3cece
|
feat(records): 高级筛选引擎 + 出库记录接入
一、新增共享工具 app/utils/advanced_filter.py
系统内已有该模式(material/list.vue、stock/inbound/buy.vue),
沿用其既有约定:参数名 advancedFilters、值为 JSON 字符串、
操作符 eq/ne/contains/not_contains/ge/le。
· parse_advanced_filters() 解析并规整,坏输入退化为空列表不影响主查询
· build_predicate() 单条件 → SQLAlchemy 谓词,未登记字段返回 None 杜绝列注入
· build_material_name_select() 物料名三表联查(buy/semi/product JOIN material_base)
二、★ 父子关系处理(本次核心)
记录接口返回的是**按单号分组的订单**,而用户筛选字段多落在**明细行**上。
若直接 .filter(TransOutbound.sku.ilike(...)),会在 GROUP BY 前收窄明细范围,
展开行里的兄弟明细会凭空消失。正确做法是先求「含匹配明细的单号集合」
再让主查询按单号 IN 过滤。
实测对照(单 OUT-20260811-1519-0003,21 条明细):
按其中一条 SKU 筛选 → 子查询法保住全部 21 条;直接 filter 只剩 1 条。
三、★ 否定操作符语义(NOT IN)
子级字段的 ne / not_contains 不能直接用 SQL != / NOT LIKE —— 那表达的是
「本单存在某条不等于 X 的明细」,多明细单几乎必然成立,等于筛选失效。
用户意图是**整单排除**,故 apply_child_condition() 统一:
肯定 → order_no IN (含匹配明细的单号)
否定 → order_no NOT IN (含匹配明细的单号)
两者子查询完全一致(都用肯定形式谓词),仅外层取反。
父级字段(单号/操作人)仍走标准 SQL 谓词,语义无歧义。
四、出库记录接入(前端弹窗 + 后端接线)
验证:
eq 0000000002 → 1 单;material_name contains 白板 → 16 单
sku ne 0000000002 → 394 = 395-1,含该 SKU 的单被整体排除
material_name not_contains 白板 → 379 = 395-16
|
2026-09-10 13:05:59 +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 |
|
|
|
23a7fc65f1
|
feat(scrap): 报废记录按单号分组 + 可展开明细 + 高级搜索
一、后端按「订单」分组(原为平铺明细列表)
有 scrap_request_no → 按单号分组;
无单号(历史直接报废)→ 按 操作时间(精确到分钟) + 操作人 虚拟分组,
生成 LEGACY-<yyyyMMddHHmm>-<操作人> 形式的虚拟单号,避免历史数据成为无主记录。
每单返回 items 明细数组、损失合计、报废总数、申请人(取自 ScrapApproval)。
二、物料解析改为批量预加载
原实现逐条记录 N+1 查询,且 5 条来源路径(stock_buy/semi/product、
trans_borrow、trans_repair)各自 query.get()。改为按 (source_table, stock_id)
收集 ID → 批量 joinedload 查询 → 内存拼装。
三、损失金额改为按权限可见
原 /records 无条件剥离 total_loss,导致持有 scrap_list:loss_amount 的角色
也看不到金额,与权限元素的存在相矛盾。改为对齐 outbound 的
filter_item_by_permissions:无权限置 None,前端 v-if 隐藏整列。
四、新增 keyword / search_type 高级搜索参数
(单号/SKU 在 SQL 层过滤,操作人/申请人/物料名在分组后过滤)
五、修复申请人显示
ScrapApproval._user_name() 返回 '杜邢宸/duxingchen' 全名,统一经
_display_name() 去掉 '/账号' 后缀。
前端 scrap/index.vue 重写为 el-table type=expand 嵌套表格,对齐出库记录版式,
搜索区改用统一 el-form inline 版式(含重置按钮)。
实测:3 单(1 张申请单 2 明细 + 1 组 legacy 合并 2 条),申请人/损失合计/
虚拟单号均正确。
|
2026-09-10 12:06:04 +08:00 |
|
|
|
e152f16ebc
|
feat(scrap): 报废一律需审批,不再按物料标记区分
业务规则变更:所有报废申请都必须由指定审批人审批通过后才能执行。
原逻辑走 resolve_approval_control 判定是否需审批,而 material_base 表中
仅 1/3012 个物料标记了 is_approval_required,意味着 99.97% 的报废申请会
走免审批分支——status 直接置 1、actual_approver_id 被赋为申请人自己、
审批人参数被静默丢弃。前端即便做了必填也只是摆设。
改动(规则收敛到单一来源):
· 新增 SCRAP_ALWAYS_REQUIRES_APPROVAL = True,作为唯一开关;
· submit_approval() 无审批人一律拒绝;恒置 status=0(待审批);
删除免审批自动通过分支;
· resolve_approval_control 仍调用,但仅用于生成提示文案,
不再参与是否审批的判定;
· /request/check-approval 返回 need_approval=SCRAP_ALWAYS_REQUIRES_APPROVAL,
否则该接口会继续返回 false,导致前端预检结果失真。
实测:不传审批人 → 拒绝;传审批人 → status=0 且 actual_approver_id 为空。
⚠ 注意:此前自动通过的单据今后一律进入审批队列。
|
2026-09-10 11:32:51 +08:00 |
|
|
|
f589962e5c
|
feat(scrap): 新增报废专用库存查询端点,解耦出库选单权限
报废申请页原复用 /inbound/stock/list,该端点权限是 outbound_selection。
持有 scrap_apply 的角色目前恰好也都持有该权限,故能跑通,但属隐性耦合:
任一角色授权脱钩就会静默 403,被前端 catch 掩盖成「加载库存失败」。
照抄借库既有先例(transactions.py 的 /borrow/stock-list),新增:
GET /v1/scrap/stock-list @permission_required('scrap_apply')
★ 同步把 'scrap_apply' 登记进 stock.py 的 SELECTION_PREFIXES。
_make_price_stripper 仅当前缀为 None 或已在集合中时才剥离价格,
漏登记会导致价格/成本字段泄露给报废申请人(Fail-Closed 失效)。
实测:OUTBOUND/WAREHOUSE_MGR 均 200,无 token 401,
响应中 unit_price/total_price/sale_price 等价格字段零泄漏。
|
2026-09-10 11:32:44 +08:00 |
|
|
|
7719943779
|
feat(scrap): 报废执行改为按单扫码校验,废弃盲执行
execute() 原先只吃 request_id,按申请单快照全量扣减,用户反馈「扫码与报废脱节」。
现改为接收实扫明细 scanned_items:
· 强制按单:未提交实扫明细一律拒绝执行;
· 键为 (source_table, stock_id),与申请单 items_json 同口径,
同一物品多次扫码自动累加,兼容「逐件扫」与「输数量」两种操作;
· 校验实扫项必须落在批准明细内,且累计量不得超批准量,否则整单拒绝;
· 允许合法子集(少扫 = 本次不报废该行);
· 扣减以实扫量为准,同时扣 available_quantity 与 stock_quantity
(报废即实物销毁,上一版只扣可用库存会让总库存虚挂)。
接口 POST /scrap/request/<id>/execute 由无 body 改为接收 {items:[...]}。
|
2026-09-10 10:14:40 +08:00 |
|
|
|
1dbf74b7bc
|
feat(borrow): 借库审批补「完结」能力(对齐出库 status=4)
- mark_completed 改为 1→4 已完结(真正扫码借出完成仍为 status=3)
- 前端审批页新增「已完结」筛选项与状态映射;审批信息列对 3/4 显示审批人
|
2026-09-10 09:47:07 +08:00 |
|
|
|
ffbb2199f0
|
feat(scrap): 报废申请审批流 + 按单报废(后端)
- ScrapApproval 模型 + ScrapApprovalService:提交/列表/审批/执行(锁库存扣 available_quantity 写 TransScrap)
- 新路由 POST /scrap/request、/request/check-approval、GET /request、PATCH /request/<id>/approve、POST /request/<id>/execute;权限码 scrap_apply/scrap_approval/scrap_execute(无角色硬编码)
- 旧 /scrap 直接报废入口保持不变
- /inbound/stock/list 每项返回 source_table,供按单流程精准选库存
|
2026-09-10 09:46:57 +08:00 |
|
|
|
044e6dbd98
|
feat(borrow,outbound): 库管(WAREHOUSE_MGR)代建申请强制审批
- submit/create 新增 force_approval:库管建单无视物料是否需审批,一律走审批
- 路由按当前角色为 WAREHOUSE_MGR 传 true;未选审批人返回明确业务错误
|
2026-09-09 16:19:38 +08:00 |
|
|
|
cd600f9fc2
|
fix(auth): 审批人列表按公司收窄——本公司主管 + 所有超管
- get_approvers:普通/主管仅见 department=本人公司 的 SUPERVISOR 与全部 SUPER_ADMIN;超管本人不受限
|
2026-09-09 15:23:51 +08:00 |
|