|
|
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 |
|
|
|
a252013573
|
feat(perm): 逆向物流专用权限码与菜单授权
为逆向物流的每个动作拆出独立权限码,终止此前复用其它模块权限的做法。
权限码一览(全部为无冒号形式,见下方说明):
outbound_return 出库退回
defective_list 不良品台账查看
defective_restock 不良品回库
defective_scrap 不良品报废
stock_change_status 库存状态变更
outbound_return_list 退回记录查看
另补建两个菜单:defective_goods(不良品在管台账)、outbound_returns(退回记录),
前者原先只有前端路由、sys_menu 中无对应行,导致页面在权限管理界面不可见、
其下权限也无从授予(sys_element.menu_code 有指向 sys_menu 的外键)。
默认授予:SUPER_ADMIN、SUPERVISOR、WAREHOUSE_MGR。
刻意不含 OUTBOUND —— 业务确认出库员只负责正向拣货发货,不参与逆向物流。
★ 为什么全部用无冒号形式,而不是 <menu>:<action>:
后端 _expand_operation_perms() 对带冒号的权限码做**前缀桥接** —— 只要用户
持有该菜单下任一以 :operation/:edit/:delete/... 结尾的权限就被放行。
实测验证过这个放大效应:
outbound_list:return <- 持有 outbound_list:operation -> True
outbound_return <- 持有 outbound_list:operation -> False
且前端 hasPermission() 是精确匹配,用冒号码会出现「接口能调、按钮却看不到」
的错位。无冒号码不触发桥接,前后端判定完全一致。
★ add_return_view_support.sql 另含一处 schema 变更:trans_return 补
company_name 快照列。退回流水的多租户隔离原先只能靠 join 链推
(trans_return → trans_outbound → 库存表 → 物料主表),而库存行会被入库
模块物理删除(实测 1077 条出库记录中已有 7 条悬空),链路一断该记录就会
对普通用户静默消失。审计视图静默丢数据不可接受,故落快照。
表当前为空,无需回填。
三个脚本均为幂等,含预检、回滚段与执行后核对。
|
2026-09-16 16:44:46 +08:00 |
|
|
|
fd99d33a0d
|
feat(return): 前端退回入口与不良品在管台账看板
出库记录页(views/outbound/index.vue):
- 明细行新增「退回」列,returnable_quantity <= 0 时按钮置灰,行内显示「已退 N」
- 对话框展示 原出库数量 / 已退回数量 / 本次可退最大,默认带入可退最大值
- 退回类型用下拉单选:良品(加回库存)/ 不良品(转入异常待处理)
- 退回原因必填,提交前三重校验;按钮 :loading + 函数内 submitting 双保险防抖
- 成功后刷新列表。错误提示不重复弹——request 拦截器对 HTTP 400 已取
data.msg 展示,对话框 catch 只收尾
配套后端(services/outbound_service.py):
- get_grouped_list 的出库明细补 id / returned_quantity / returnable_quantity。
原先明细不带 id,退回接口无从指定 outbound_id
新增页面(views/stock/defective/index.vue,路由 /inventory/defective):
- 展示 物料名称/规格/SKU/退回时间/操作人/退回总数/剩余待处理/状态
- 顶部 alert 提示在管总量,并明确「这批实物不在库存表中,盘点请以本台账
为准」——在管坏件对盘点不可见是本方案的固有盲区,必须在页面上主动提醒
- 仅对 remaining_qty > 0 的行提供「修复回库」「报废销毁」,终态行显示已结案
- 两个弹窗均带数量上限约束与 loading 防抖,成功后刷新
新增 api/inbound/return.ts 承载四个逆向物流接口。
侧边栏由 router/index.ts 驱动,加路由即入菜单;注意侧边栏只过滤
meta.hidden、不看 meta.permission,故菜单对所有角色可见,访问控制由后端
接口负责(与既有「维修管理」一致)。
|
2026-09-16 15:45:40 +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 |
|
|
|
69c38a1bf7
|
feat(return): 逆向物流数据模型与迁移
新增原单退回与不良品在管的持久化结构。
- TransOutbound 增 returned_quantity(numeric(19,4),非 float):该值参与
「return_qty <= quantity - returned_quantity」判等,浮点误差会让反复部分
退回后出现「已退满却判定未退满」的错判
- 新增 TransReturn:退回流水,每次退回写一条而非覆盖式更新。刻意与
trans_borrow 划清界限——后者部分归还时会覆盖 return_time/operator,
导致归还历史永久丢失
- 新增 TransDefectiveGoods:不良品在管台账。坏件全程不入库存表,因为
status 是行级属性而质量是件级属性,把坏件加回原行只能整行打不良
(实测 stock_buy 单行最大 4789 件、中位 8 件,整行打不良会凭空损失良品)
- 状态机:待处理 → 处理中 → {已回库|已报废|已闭环}。终态由累计去向推导
而非「最后一次动作」——一批坏件可能既回库过又报废过,按最后动作定状态
会产生误导
- restocked_qty/scrapped_qty 两列:二期用 quantity-remaining_qty 反推回库量,
三期加入报废出口后该反推失效
- 审计白名单与模型预加载同步登记(监听器绑定 18 → 20 个模型)
迁移脚本均为纯追加式 DDL,含预检、回滚段与执行后核对。首个脚本用
COALESCE 包裹数量列——库存表允许数量为 NULL,而「NULL 大于 0」求值为
NULL 而非真,裸写会让脏行在预览与诊断两次查询里凭空消失。
|
2026-09-16 15:45:22 +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 |
|
|
|
ffd3e2652c
|
V3.80,9.15推送
|
2026-09-15 13:27:42 +08:00 |
|
|
|
fe54d01391
|
fix(outbound): 序列号物料出库被误判超出计划数量
现象:申请单里同一个物料有 2 个,扫第 2 个时报
「超出计划数量(计划: 1,已扫: 1,本次: 1)」。
根因:计划侧与已扫侧用了**不同的聚合方式**。
已扫侧 .filter().reduce() → 把【所有】匹配行加总
计划侧 .find() → 只取【第一条】匹配行
序列号物料在申请单里是「一物一行、每行 quantity=1」(因为每个序列号对应
一条独立库存行),于是 planQty 只拿到 1,而 alreadyScanned 是全部之和,
1 + 1 > 1 直接判超发。普通数量制物料计划单只有一行,两边一致,所以一直
没暴露 —— 只有序列号物料会踩中。
同一缺陷还有第二处:unscannedList 逐行比较 planQty 与「累计已扫」,
导致两行各自都被同一笔扫码"满足",扫 1 个就把整个物料判为扫完、未扫清单漏报。
修复:
- validateAgainstPlan:计划侧改为 .filter().reduce() 汇总所有匹配行
- unscannedList:先按「名称+规格」合并计划行,再与同口径汇总的已扫数比较
用申请单 410(Opt1025 两行各 1 个)的真实数据模拟验证:
修复前 扫第2个 → 超出计划数量(计划:1,已扫:1,本次:1) ← 与现场报错逐字一致
修复后 扫第2个 → 通过
修复后 扫第3个 → 仍被拦(计划:2,已扫:2,本次:1),超发保护未失效
|
2026-09-15 13:21:36 +08:00 |
|
|
|
b55119acbc
|
V3.79,9.14推送
|
2026-09-14 17:35:07 +08:00 |
|
|
|
0481f917ee
|
fix(stocktake): 修正页面容器高度 —— 真正解决按钮被裁的真根因
前几轮都在 .idle-card 内层打转(min-height:0 / overflow / padding / 去居中),
全都没打到点上。真根因在页面容器**之外**:
.app-wrapper { height:100vh; overflow:hidden } App.vue
.app-content { flex:1; min-height:0; overflow:hidden } ← 高度 = 100vh − 90px
.app-main { position:relative } ← 无高度
.app-container { height:100vh } ← 就是本页
App.vue 里 .app-header 是 60px、.app-footer 是 30px,所以 .app-content 的实际
高度是 100vh − 90px。而本页容器写死 height:100vh,**比父容器高出整整 90px**,
超出的那一截被 .app-content 的 overflow:hidden 裁掉 —— 【开始新盘点】按钮
恰好就在那一截里,所以看不见也滚不到(滚动条属于被裁掉的那部分)。
改法:height 改为 calc(100vh - 60px - 30px),两个高度提成 CSS 变量并注明
对应 App.vue 的哪个类,便于外壳改动时同步。
为何不用 height:100%:.app-main 是 auto 高度,百分比无从解析,会退化成 auto。
同时保留前几轮的成果(它们各自都是对的,只是不足以单独解决问题):
- .idle-card { min-height:0; display:block; overflow:hidden auto }
- .idle-content { margin:0 auto; padding:20px 20px 60px }
- .scope-columns 高度 380 → 260,再省 120px
|
2026-09-14 11:10:42 +08:00 |
|
|
|
66e0728957
|
fix(stocktake): 欢迎页补足上下安全边距,显式压过 el-card 的 overflow:hidden
在 b7cdd5e 的 min-height:0 基础上补两处:
1. 顶部安全边距缺失:内容超高时 .idle-content 的 margin:auto 会塌缩为 0,
文字直接贴着屏幕顶端。改用**容器自身的 padding: 40px 0** —— padding
不参与 auto 塌缩,滚到顶/底都稳定保留 40px 呼吸空间。
2. 显式声明两轴 overflow:Element Plus 的 .el-card 默认 `overflow: hidden`
(实测其 dist/index.css 确有该声明)。原先只写 overflow-y: auto,
虽然 scoped 选择器(.idle-card[data-v-x],特异性 0,2,0)能压过
.el-card(0,1,0),但意图不够显式。改为 `overflow: hidden auto` 一次写清
「横向裁掉、纵向可滚」。
实测 style 块编译通过(12799 字符),min-height:0 / padding:40px 0 /
overflow:hidden auto / margin:auto 均已进入产物。
|
2026-09-14 11:05:12 +08:00 |
|
|
|
b7cdd5e58b
|
fix(stocktake): 补 min-height:0,真正解决欢迎页按钮被裁不可达
上一轮只加了 overflow-y: auto 和 padding-bottom,没有生效。完整根因链:
html,body { height:100%; overflow:hidden } ← src/style.css:61 全局裁剪
#app { height:100% }
.app-container { height:100vh; flex column } ← 自身无 overflow
.idle-card { flex:1; overflow-y:auto } ← 缺 min-height:0
flex 子项默认 min-height: auto,会**拒绝收缩到内容高度以下**。于是卡片被内容
撑破 100vh 容器,向上冒泡到 body 的 overflow:hidden 被裁掉 —— 卡片自己的
overflow-y 永远不会触发(它从没比内容矮过),所以按钮既看不见也滚不到。
补上 min-height: 0 后,卡片被约束为「容器剩余高度」,内部滚动才真正生效;
配合子元素的 margin:auto,内容不高时仍居中,超高时从顶部排下且保留
40px 底部安全边距。另加 -webkit-overflow-scrolling: touch 让平板惯性滚动。
注意:没有采用「把 .app-container 改成 overflow-y: auto」的方案 ——
该容器与扫码页的 .active-card 共用,改动会波及扫码态的整屏布局。
本次把修复严格限制在 .idle-card 内。
校验: style 块编译通过;min-height:0 / padding-bottom:40px / margin:auto
四条关键规则均已进入产物。
|
2026-09-14 11:04:06 +08:00 |
|
|
|
996ec43360
|
fix(stocktake): 欢迎页可滚动、明细列合并、弹窗备注组件统一
【1. 修小屏按钮被截断】
根因不只是缺 overflow —— .idle-card 用了 justify-content: center,
flex 居中在内容超出容器时会把上下两端**同时裁掉**,这才是按钮只剩一半的原因。
改为:容器 overflow-y: auto + padding-bottom: 40px,居中交给子元素的
margin: auto(不高时正常居中,超高时 auto 塌缩为 0,可完整滚动到顶与底)。
【2. 批次/序列号合并为一列】
「批次 / 序列号」min-width=120,两者都有显示「批次 / SN」,只有一个只显示它,
都没有显示 '-'。
【3. 规格列压缩】min-width=120 → width=100 show-overflow-tooltip,给备注让位。
【4. 录入实盘数量弹窗的备注统一为 el-select】
textarea 换成与明细抽屉完全相同的 filterable allow-create
default-first-option 组件,共用同一份 REMARK_OPTIONS。
保存链路不变:inputRemark → syncToBackend → /draft/add(后端本就存 remark)。
★ 顺带修一个数据正确性 bug:openQtyDialog 从不重置 inputRemark,
工人扫 A 填了备注后**取消**弹窗,再扫 B 时 A 的备注会残留并可能被写进 B。
改成下拉选项后这个陷阱更隐蔽(离散值不像 textarea 那样扎眼)。
现两处开窗分支都先清空 inputRemark。
|
2026-09-14 10:57:29 +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 |
|
|
|
af7be6a051
|
fix(stocktake): 明细里改实盘数时「差异」列实时联动
原 handleQuantityChange 只发请求,不回写 diff_qty,差异列要重新打开抽屉
才会刷新。
现改为在发请求的同时本地计算:
row.diff_qty = val - row.stock_qty (保留 4 位小数,与后端 round 一致,
避免浮点尾差显示成 4.999999999)
★ 盲盘保护:后端在 blind 模式下把 stock_qty 置为 null(绝不下发账面数),
此时**完全跳过**减法,保持 diff_qty 原状(null → 渲染为「—」)。
若不判断,null 参与运算会算出错误差异,等于从 UI 侧绕过了盲盘限制。
请求失败时原有的 fetchInventoryList() 会拉回服务端数据,本地算出的差异
随之被覆盖,不会留下错误值。
|
2026-09-11 15:21:45 +08:00 |
|
|
|
0e9941a2aa
|
feat(stocktake): 明细抽屉补充「未扫需先扫码」的规则说明
平板上 title 悬浮提示不生效,只在单元格里显示灰色的「未扫」二字,
工人未必知道下一步该做什么。在抽屉搜索栏下方加一行明文字:
💡 未扫的行需先扫描条码才能填数
|
2026-09-11 15:16:36 +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 |
|
|
|
d1694bf245
|
perf(stocktake): 去掉库位树的全展开渲染,并为推荐查询补索引
业务反馈切到抽盘面板与点【获取推荐】时卡顿。实测定位:
【真正的原因在前端】先跑 EXPLAIN ANALYZE 排除了后端 ——
推荐查询在当前数据量下仅耗时 1.3ms,全走 Seq Scan + Hash Join,
Buffers shared hit=161 全部命中缓存。所以卡顿来自 DOM:
el-tree 原先带 default-expand-all,会把全库 3371 个库位节点一次性铺进 DOM。
去掉 default-expand-all 后初始只渲染根节点。勾选状态与 getCheckedNodes
不受影响 —— el-tree 的 Node store 会预先构建全树,展开与否只影响 DOM。
【索引仍补上,但说明白】实测 pg_indexes:stock_* 三表的 warehouse_location
已有索引,但 trans_outbound / trans_borrow 只有主键,时间字段与
(source_table, stock_id) JOIN 键都缺;三张库存表的时间字段也缺。
新增迁移 add_active_location_indexes.sql 补 7 个索引。
诚实说明写在迁移头部:当前数据量下加索引后本查询**仍会走 Seq Scan**,
这是小表下的正确计划,索引是为 90 天/半年后的数据增长做前瞻,不是修当前的慢。
【loading 状态】treeLoading / recLoading 已在既有实现中就位并闭环在
try/finally 中,分别绑在 el-tree 的 v-loading 与【获取推荐】按钮的 :loading 上,
本轮复核确认,未做无谓改名。
实测: 索引后执行计划仍为 Seq Scan,Execution Time 0.769ms(预期内)
|
2026-09-11 14:38:40 +08:00 |
|
|
|
0b82609c55
|
fix(stocktake): 抽盘配置区定高不跳动,勾选只计末级库位
【固定高度】.scope-columns 设 height: 380px + display: flex,左右两栏各自
flex:1 + overflow 独立滚动。关键点是给 flex 子项加 min-height: 0 —— 不写它
flex 子项默认不收缩,overflow 不会生效,高度照样被内容撑开。
原先树用 max-height: 300px、右栏用 height: 260px 各写各的,仍会随内容跳动。
【只计末级库位】syncSelectedPaths / buildScopeConfig 均改用
getCheckedNodes(true)(leafOnly)。勾选父级货架时右侧计数与提交给后端的
scope_config.locations 都只含末级库位,不再把父节点自身算进去 ——
符合现场「货架是容器、库位才是盘点对象」的心智模型。
上一轮我曾以「非末级库位上有库存,leafOnly 会漏」为由建议不做,该判断
是错的:我把「层级」当成了「非末级」。实测这棵库位树深度不均匀,很多
level 1/2 的节点本身就是叶子 —— 真正存放在非末级库位上的库存,
IRIS 1126 行里仅 1 行(0.1%)、LICA 690 行里 0 行,排除面可忽略。
(活跃天数可编辑已在 709aeef 完成:recommendDays 默认 90、动态传参。)
|
2026-09-11 14:33:23 +08:00 |
|
|
|
709aeefeda
|
feat(stocktake): 活跃天数可编辑(默认 90 天)并补充勾选提示
- 把写死的 30 天改为响应式 recommendDays(默认 90,范围 1~365),
界面上是一个 el-input-number;【获取推荐】按 ?days=<recommendDays>&top_n=<N>
动态传参,不再固定 30。
- 推荐条数默认由 50 调整为 10(与「前 N 个」的实际使用节奏一致)。
- el-tree 上方加一行灰色小字提示:
「💡 提示:勾选父级货架,将自动涵盖其下属的所有末级库位及库存。」
对应已确认的决策——保持 el-tree 原生级联、后端精确匹配,
勾父节点时其全部子孙 full_path 都会进入提交范围,故提示属实。
已确认的两项决策(本轮无代码改动,记录备查):
- 不使用 check-strictly,保留原生级联;右侧计数已足以提示规模
- 后端保持精确匹配,不做前缀匹配
|
2026-09-11 14:29:13 +08:00 |
|
|
|
80bd45d5d4
|
feat(stocktake): 抽盘库位选择改为左右双栏,已选明细可逐个移除
原库位树是单列、层级很深,用户勾完看不出到底选了哪些库位。
现改为左右双栏:
- 左 60%:el-tree(勾选),@check 触发 syncSelectedPaths
- 右 40%:已选明细面板 —— 顶部「已选 N 个库位」+ 清空按钮,
el-scrollbar 内平铺 el-tag 展示完整路径(等宽字体)
双向互动:点击右侧标签的 × 会调 treeRef.setChecked(path, false, true)
同步取消左侧勾选后重算列表。注意 el-tree 的 setCheckedKeys / setChecked
都**不会**触发 @check 事件,因此这两处都显式调用了 syncSelectedPaths(),
保证左右状态强同步(获取推荐后右侧也会立刻渲染)。
★ 右侧刻意**不过滤非末级节点**,展示的就是提交给后端的 scope_config.locations
全集。原因:后端按 warehouse_location 精确匹配,而库存的库位可能落在层级 1/2
(实测 level1 有 9 个、level2 有 57 个),只提交叶子路径会把这些库存静默排除
在抽盘范围之外。面板所见 = 实际范围,才谈得上「强同步」。
配套:抽盘配置区是双栏、400px 窄容器装不下,故在展开该配置时把欢迎页
放宽到 780px(.idle-content.is-wide),其他状态维持原宽度。
|
2026-09-11 14:22:14 +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 |
|
|
|
325cc44469
|
fix(stocktake): 拉开主副按钮层级防误触,明细增加库位列
【按钮层级】场景 A(已有进行中的盘点)
原实现主按钮是「加入当前盘点」,次入口是个不起眼的文字链接
「开启新一轮盘点」—— 二者视觉权重接近,而后者会终结本司其他 PDA
正在使用的会话,误触代价高。
现改为:
- 主按钮:绿色「继续当前盘点」,高度放到 66px、字号 19px
(新增 .action-btn-hero),平板大屏上一眼可辨、一按即中
- 次按钮:红色镂空「结束当前并开启新盘点」,点击走 10 秒倒计时强确认
- 确认弹窗措辞由「原有数据将保留」改为明确告知**会话将被终结**:
「该会话将标记为已结束,其他设备不能再加入」
- 倒计时确认通过后展开配置表单(而非直接开会话),让用户仍能选模式/范围;
表单提交不再二次弹窗,避免重复确认
【库位列】明细抽屉新增「库位」列,紧跟规格之后 —— 找货刚需。
后端 merged-list 已下发该字段,实测单行返回含 warehouse_location='Y2/4/3/4',
无需改动。
|
2026-09-11 13:53:53 +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 |
|
|
|
091847596e
|
feat(stocktake): 欢迎页改为创建盘点任务表单
无活跃会话时不再只有一个孤零零的按钮,而是先配置再开始:
- 盘点模式:明盘 / 盲盘,默认明盘
- 盘点范围:全仓盘点 / 活跃库位抽盘
有活跃会话时表单隐藏,显示绿色「加入当前盘点」,提示语带出该会话的
模式与范围(如「盲盘 · 全仓盘点,已扫 12 件,发起人:韩善龙」)。
次要入口「开启新一轮盘点」改为展开配置表单,并在表单内提示
「开启新一轮会把当前进行中的盘点置为已结束(历史数据仍保留)」,
提交时仍走原有的 10 秒确认弹窗。
「活跃库位抽盘」选项置灰并注明原因:范围过滤尚未实现,此时若放开,
结束盘点会把抽盘范围外的全部库存批量标成盘亏。后端同样以 400 拦截,
前后端一致,避免绕过 UI 直接调接口。
activeSession 类型与 checkServerDraft 同步补上 mode / scope_type。
|
2026-09-11 13:28:53 +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 |
|
|
|
aeb852c64d
|
feat(stocktake): 新增盘点会话表 StocktakeSession
盘点原本只有 stocktake_draft(草稿行),没有会话实体,导致三个问题:
1. 盲盘/明盘、全盘/抽盘这类**会话级配置**无处存放,塞进草稿表就要每行
冗余一份,改一次配置得 UPDATE 上万行;
2. 「空会话」无法表示 —— 会话开了但还没扫码时草稿表里没有行,旧实现只能
靠 max(scan_time) 猜哪个会话活跃,于是新开的空会话会被其他 PDA 忽略、
反而加入上一轮的旧会话,多端协同直接分裂;
3. 没有状态字段,generate-missing 跑完后旧会话仍被当成活跃。
本表把活跃会话判定变成 company_name + status='active' 的精确查询。
迁移的要点:
- 唯一部分索引 uq_stocktake_session_one_active 保证「一个公司同时只有一个
活跃会话」,从数据库层挡住多台 PDA 并发开启导致的进度分裂;
因此 /draft/start-new 必须先把原有活跃会话置为 finished 再插入新记录。
- CHECK 约束限定 mode/scope_type/status 的取值域。
- 不回填存量:旧草稿没有对应会话行,部署后显示「无进行中的盘点」,数据不丢。
执行: docker exec -i inventory_db psql -U test -d inventory_system < db_migrations/add_stocktake_session.sql
|
2026-09-11 13:28:43 +08:00 |
|
|
|
cc176f6e8b
|
fix(stocktake): 扫码落库改为确认后反馈,并增加最近扫码记录
【修复静默丢数据】
原 syncToBackend 是乐观更新:addDraft 还没返回就先弹「已记录实盘」,
UI 提示早于落库确认。网络抖动时工人以为扫进去了,这条实盘记录实际已丢,
而状态标签还可能被后续成功的请求覆盖成绿色,失败也无人重试。
现改为:
- 只有 addDraft 真正 resolve 后才提示成功、记流水、刷统计
- catch 时用 10 秒停留 + 可关闭的错误提示,明确告知
「网络异常,上一笔条码 [XXX] 录入失败,请重试!」
【新增最近扫码记录】
统计看板下方增加 recentScans(最多 10 条),落库成功后 unshift
物料名称/规格/数量/时间,用轻量列表展示并高亮最新一条,
供安卓平板大屏即时确认。切换/加入会话时清空,避免看到上一轮残留。
【统一统计口径】
totalScannedCount 原被 all-items 与 merged-list 两处写入。核对后端
两个接口的 SQL 实际完全一致(均为 COUNT(DISTINCT (source_table, stock_id))
且都包含 system 自动漏盘记录),故数值本身不会分歧;但仍去掉 all-items
那一处写入,统一由 merged-list(fetchInventoryList)作为唯一来源。
【清理死代码】
删除从未使用的 allScannedDrafts 与 listTotal。
|
2026-09-11 13:20:15 +08:00 |
|
|
|
5b0c7c1382
|
feat(stocktake): 欢迎页识别跨域角色,完善场景 A 提示
- 公司选择器原先只对 SUPER_ADMIN 解锁,漏掉了同样持有 crossDomain
权限的 WAREHOUSE_MGR —— 它同样不受后端公司隔离约束,盘点范围完全
由所选公司决定,必须一并要求显式选择。
注意 SUPER_ADMIN 的权限是通配符 ['*'],permissions.includes('crossDomain')
对其恒为 false,故角色与权限码两个条件都要判。
- 场景 A 提示改为「当前公司已有盘点在进行中,已扫 XXX 件,发起人:XXX」,
使用后端新增的 scanned / initiator 字段(scanned 已排除系统自动漏盘记录)。
按钮文案统一为「加入当前盘点」。
- 清理 serverDraftCount:提示语改用 activeSession.scanned 后它只写不读,
属死状态,一并移除。
- 确认弹窗文案原先指向已不存在的「继续上次盘点」入口,改为说明开新会话后
「加入当前盘点」只会进入最新会话。
|
2026-09-11 12:44:10 +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 |
|
|
|
d69a77cec1
|
fix(export): 补上 export_excel 缺失的表头定义
GET /api/v1/inbound/base/export(物料页「导出库存统计」)在
base_service.py 里执行 ws.append(headers),而 headers 全文件从未赋值,
每次调用必抛 NameError,导出功能 100% 不可用。
按 _write_stock_rows 实际写入的 22 列顺序补齐表头,命名对齐同仓库
inbound_summary_service 与 stock.py 的既有报表。
实测: 容器内导出成功 203282 字节,1817 行(含表头),表头与数据行
均为 22 列,未出现串列。
|
2026-09-11 11:44:35 +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 |
|
|
|
f7cae9d84e
|
fix(stocktake): 探测会话失败时不再静默降级为「无盘点」
checkServerDraft 原本在请求异常时把 activeSession 置空,界面于是从绿色
「加入正在进行的盘点」退回蓝色「开始新盘点」—— 网络抖动一下,库管点下去
就会新建会话,把多人协同拆开。
现把「探测失败」独立成 probeFailed 状态:失败时保持 activeSession 不变,
界面切到「无法确认是否有进行中的盘点 + 重新检测」,不提供进入按钮。
宁可挡住操作,也不能让库管误开新会话。
|
2026-09-11 11:39:35 +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 |
|
|
|
32cb5469c4
|
feat(stocktake): 欢迎页改为公司维度状态驱动,多设备共享盘点会话
原入口只有一个「开始新盘点」,多台 PDA 各开各的会话,无法协同。
- 新增公司选择器:普通用户锁定并默认本公司(后端本就强制隔离),
超管必须显式选择,未选时入口按钮禁用;本公司若不在下拉列表中
则补进去,避免 el-select 显示为空。
- 选定公司后调用 /draft/active-session 探测该公司是否已有进行中的
盘点。服务端为唯一事实来源,localStorage 仅作缓存。
- 状态驱动渲染入口按钮:
场景 A(已有活跃会话)→ 绿色「加入正在进行的盘点」,并提示当前
进度项数;同时保留次要入口「开启新一轮盘点」,否则公司一旦有过
草稿就永远开不了新一轮,点击走 10 秒确认弹窗。
场景 B(无活跃会话)→ 蓝色「开始新盘点」。
- 切换公司时清空本地会话并重新探测,避免跨公司串会话。
- 移除此前「刷新后请求 /draft/list 取第一条草稿猜 session_id」的逻辑:
该列表无会话/用户过滤,取到的是全库 SKU 字典序最小的那条草稿,
与当前用户和公司都无关。
- 所有盘点请求携带当前公司;导出、生成漏盘、改实盘数等 POST 请求
亦经 query string 传递公司。
|
2026-09-11 11:33:26 +08:00 |
|
|
|
1d1ca1b89e
|
feat(web): userStore 暴露所属公司,盘点相关 API 支持按公司传参
多设备协同盘点需要前端明确知道「在盘哪个公司」:
- userStore 从 JWT claim company_name 解出 companyName 并暴露,登录、
刷新 token、登出时同步维护。后端 get_current_company_filter() 读的
也是这个 claim,两侧口径一致。
- 新增 getStocktakeCompanies(),对应盘点页的公司下拉接口。
- 盘点 API 增加公司维度:scanStockByBarcode 加可选 company;
getDraftMergedList / getAllStocktakeItems 加 company_name;
updateStocktakeQuantity 加第二个参数。
公司标识一律走 query string 而非 body:后端 get_current_company_filter()
只读 request.args,POST 请求 body 里的 company_name 会被直接忽略。
|
2026-09-11 11:33:19 +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 |
|
|
|
6586d3cb75
|
feat(stocktake): 盘点草稿表增加公司隔离字段
stocktake_draft 原本没有任何公司维度,导致开启新盘点清空整表时会误删
其他公司的进度,扫码时也无法区分不同公司的同码物料。
- StocktakeDraft 增加 company_name(带索引),to_dict 同步输出
- 新增迁移 add_stocktake_draft_company_name.sql:加列 + (company_name,
session_id) 复合索引;存量行保持 NULL(超管仍可见),不做删除
执行: docker exec -i inventory_db psql -U test -d inventory_system < db_migrations/add_stocktake_draft_company_name.sql
|
2026-09-11 11:33:04 +08:00 |
|
|
|
c6694043d4
|
V3.78,9.11推送
|
2026-09-11 10:35:34 +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 |
|
|
|
b313fefdfa
|
fix(web): 扫码页与备选库位带单据ID,按有效可用量限流
配合后端预占回加:扫码/借库/审批页面调用 /outbound/scan 与 /outbound/alternatives
时带上本单 id 与 biz_type,拿到的 available_quantity 即为「实时可用量 + 本单
预占」,本单锁定的行不会再因实时可用量为 0 而被拦住或从列表消失。
后端已在响应边界完成归一化,故前端模板与校验逻辑(:max、库存列展示、超量
警告、草稿存取)一律不动,只多传参数 —— 避免把同一语义散落到多个读写点。
- api/outbound.ts:getStockByBarcode / getStockAlternatives 加 requestId 与
bizType 参数,导出 ScanBizType,ScanResult 补 reserved_quantity 等字段
- api/transaction.ts:同名的历史副本同步签名(当前无调用方,加注释指向唯一来源)
- views/outbound/create.vue、views/transaction/borrow.vue:各 3 处调用点带上
单据 id;refreshStockFromDraft 改为显式透传 id,不依赖 computed 已解析
- views/borrow/approval/index.vue:审批页也带上本单 id,否则待审批单
(预占已生效)锁定的行会因实时可用量为 0 而从备选列表消失
借库侧 request_id 是 BorrowApproval.id,靠 biz_type='borrow' 与出库单区分 ——
两张表 ID 空间独立,不可省。
|
2026-09-11 10:34:23 +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 |
|
|
|
b8561f69f7
|
V3.77,9.11推送
|
2026-09-11 09:39:59 +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 |
|
|
|
e5a7cb2736
|
V3.76,9.11推送
|
2026-09-11 09:07:14 +08:00 |
|