Files
KCGL/inventory-backend/app/services/trans_service.py

1332 lines
65 KiB
Python
Raw Normal View History

import uuid # .material -> .base refactor checked
2026-02-06 17:11:47 +08:00
from datetime import datetime
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
from app.extensions import db, beijing_time
from app.models.transaction import TransBorrow, TransBorrowTransfer, TransBorrowReturn
2026-02-06 17:11:47 +08:00
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
from app.models.base import MaterialBase
from app.utils.decorators import get_current_company_filter
from sqlalchemy import desc, func, nullslast, asc, or_, and_, case
from sqlalchemy.orm import joinedload
2026-02-06 17:11:47 +08:00
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
def user_display_name(user):
"""
用户名展示口径:'姓名/拼音' → '姓名'。
★ 与迁移脚本 phase4_borrow_transfer.sql 的回填口径
(split_part(username,'/',1))及 borrow_service 的通知口径三处保持一致 ——
否则同一人在台账里会出现「石利」与「石利/shili」两种写法,按姓名检索即失准。
"""
if not user:
return ''
username = str(user.username or '')
return username.split('/')[0] if '/' in username else username
def _assert_borrow_company_visible(record):
"""
行级公司隔离:确认一条借用记录属于当前用户可见的公司。
★ 隔离链路与 get_records 完全一致:
trans_borrow → (source_table, stock_id) → 库存表 → material_base.company_name
不另起一套口径,避免列表能看到的单、接口却判定为越权(或反之)。
★ Fail-Closed:链路断裂(源库存行被入库模块物理删除,实测已有先例)时
**拒绝**而不是放行 —— 持有权变更直接改变责任归属,宁可让库管走人工,
也不在无法判定归属时越权操作。
"""
company_limit = get_current_company_filter()
if company_limit is None: # 超管 / crossDomain → 全量跨域
return
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
ModelClass = model_map.get(record.source_table)
stock = ModelClass.query.get(record.stock_id) if (ModelClass and record.stock_id) else None
base = stock.base if stock else None
if base is None:
raise ValueError(
"该借用记录的来源库存已不存在,无法完成公司隔离校验,请联系管理员处理"
)
if (base.company_name or '') != company_limit:
raise ValueError("无权操作其他公司的借用记录")
2026-02-06 17:11:47 +08:00
class TransService:
@staticmethod
def generate_borrow_no():
"""
生成借用单号: BOR-yyyyMMdd-0001 (按日流水)
逻辑:统计当天已存在的不同借用单号数量,+1 作为新序号
"""
now = datetime.now()
date_str = now.strftime('%Y%m%d')
prefix = f"BOR-{date_str}-"
# 使用 count distinct 来计算当天有多少个不同的借用单 (因为一单多货会占多行)
count = db.session.query(func.count(func.distinct(TransBorrow.borrow_no))) \
.filter(TransBorrow.borrow_no.like(f"{prefix}%")).scalar()
sequence = count + 1
return f"{prefix}{sequence:04d}"
@staticmethod
def execute_dispatch(approval_id, items, operator_name='System', borrower_name=None,
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
signature=None, remark=None, expected_return_time=None,
borrower_id=None):
2026-02-06 17:11:47 +08:00
"""
执行借库扣减(审批通过后调用)
流程:锁审批单 → 构建审批上限字典 → 锁库存行 → 名称规格校验 → 扣减库存 → 生成 TransBorrow 记录 → 标记审批单完成
★ 关键设计:审批维度是 (name, spec_model) 而非 SKU
借库申请是按【名称 + 规格型号】发起的(borrow_service 强制要求 name/spec_model/quantity 三字段),
申请时尚未绑定具体库存行;扫码出库时通过锁定 stock 行回查 material_base 表,
用 (name, spec_model) 与审批单做物料维度聚合比对,避免 sku 维度坍塌或绕过。
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
★ borrower_id(一期转交改造后为**必填**):
实际借用人ID。borrower_name 参数仅为向后兼容而保留 —— 落库时一律
以 borrower_id 反查 sys_user 得到的姓名为准,传参中的姓名被忽略。
2026-02-06 17:11:47 +08:00
"""
from app.models.borrow import BorrowApproval
2026-02-06 17:11:47 +08:00
if not items: raise ValueError("物品列表为空")
if not signature: raise ValueError("借用人必须签字")
# ==============================================
# ★ 防线1:并发防重复执行 - 用 SELECT FOR UPDATE 锁住审批单
# ==============================================
approval = BorrowApproval.query.with_for_update().get(approval_id)
if not approval:
raise ValueError("审批单不存在")
if approval.status != 1:
status_map = {0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成'}
raise ValueError(f"审批单状态为【{status_map.get(approval.status, approval.status)}】,无法执行借库")
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
# ==============================================
# ★ 借用人身份锚定(一期转交改造):强制 ID
#
# 原实现只收 borrower_name 字符串,责任链从落库那刻起就存在重名歧义
# (实测 85 行 / 18 个姓名,纯姓名无法唯一锚定一个人)。
# 现强制要求 borrower_id,并以 sys_user 为**唯一事实来源**反查姓名:
# · borrower_name 降级为展示快照,不再接受前端自由输入;
# · 传了 borrower_id 但用户不存在 → 直接拒绝,不静默放行。
#
# ★ 为什么不回退到 approval.borrower_name:
# 申请单上的姓名是「申请意向」,与「扫码时实际来领的人」本就可能不同
# (库管代建场景尤甚)。若回退,current_holder_id 会从第一刻就记错人,
# 转交与归还的整条责任链都会建立在错误的锚点上。宁可让库管重选。
# ==============================================
if not borrower_id:
raise ValueError("缺少借用人 borrower_id,请重新选择借用人后再提交")
from app.models.system import SysUser
borrower = SysUser.query.get(int(borrower_id))
if not borrower:
raise ValueError(f"借用人不存在(ID:{borrower_id}),请重新选择")
borrower_name = user_display_name(borrower)
if not borrower_name:
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
raise ValueError(f"借用人(ID:{borrower_id})用户名为空,无法生成台账快照")
# ==============================================
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
# ★ 防线2:构建审批上限字典
#
# 改造说明:原先以 (name, spec_model) 聚合,但本系统中 SKU 是**批次级**
# 编号(同一物料不同批次 SKU 不同),而 name+spec 又是字符串比较,
# 易受空格/别名影响。现统一改用 identity_key(base_id 主键 +
# name/spec 兜底),与出库、报废三个模块共用同一套身份语义。
# ==============================================
approved_items = approval.get_items()
if not approved_items:
raise ValueError("审批单中无物料明细,请联系管理员检查")
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
from app.services.inventory_reservation import (
build_approval_index, verify_scanned, restore_then_deduct, identity_label,
)
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
approval_idx = build_approval_index(approved_items)
2026-02-06 17:11:47 +08:00
borrow_no = TransService.generate_borrow_no()
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
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
# ★ 防止死锁:按 (source_table, id) 排序,保证所有并发请求以相同顺序获取行锁
items.sort(key=lambda x: (x.get('source_table', ''), x.get('id', 0)))
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
# ==============================================================
# ★ Phase 3:预占再平衡
# 借库申请阶段已预占具体批次;工人实扫的可能是同物料的另一批次。
# 1. 校验实扫身份/数量未超批准范围(base_id 主键,允许换批次)
# 2. 释放全部预占
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
# 3. 对实扫批次扣减 available_quantity(★ 不动 stock_quantity)
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
# 下方主循环只写 TransBorrow 流水,不再重复扣库存。
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
#
# ★ deduct_stock=False:借出是**可逆的**(会归还),只冻结可用数。
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
# 若此处扣了实物,会与两处冲突:
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
# · 归还(process_return)只加 available —— 一借一还后 stock 永久少一份;
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
# · 借库转报废在确认损失时扣 stock,其实现明确假设「可用库存已在
# 借出时冻结」—— 若借出已扣会重复扣减。
# (该实现原为 TransService.scrap_borrow,现已迁入
# app/services/scrap_sources.py 的 BorrowScrapAdapter.deduct)
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
# ==============================================================
# ★ 防线 2.5(Fail-Closed):实扫明细的 source_table 必须全部可识别。
#
# 原先此处写作 `if _scanned_for_check:` —— 不在 model_map 的明细会被
# 静默过滤。若整批明细都不可识别,校验与释放被整体跳过,下方主循环
# 也逐条 `continue`,末尾却照常把 approval.status 置为 3,于是:
# · 申请阶段预占的 available_quantity 无人释放 → 幽灵库存永久锁死;
# · 单据已非 status=1,/close 与 /withdraw 都拒绝再释放;
# · 且没有任何 TransBorrow 流水可供追溯。
# 现在改为整单失败:事务回滚,单据保持 status=1、预占原样保留,
# 库管可重试执行,或走驳回/撤回 —— 任一路径都能正常归还预占。
# ==============================================================
unknown_source = sorted({
str(i.get('source_table')) for i in items
if not isinstance(i.get('source_table'), str)
or i.get('source_table') not in model_map
})
if unknown_source:
raise ValueError(
f"实扫明细的库存来源不合法:{'、'.join(unknown_source)},"
f"仅支持 {'、'.join(sorted(model_map))}"
)
# 经上方校验后 source_table 必然合法,故本列表与 items 一一对应,
# 不存在「被过滤掉的明细」。空列表由 verify_scanned 兜底报错。
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
_scanned_for_check = [
{'source_table': i.get('source_table'), 'stock_id': i.get('id'),
'quantity': i.get('out_quantity')}
for i in items
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
]
verify_scanned(_scanned_for_check, approved_items)
restore_then_deduct(_scanned_for_check, approved_items, deduct_stock=False)
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
# 累计本次扫码出库量(用于下方防线4的二次校验)
dispatch_acc = {}
2026-02-06 17:11:47 +08:00
try:
for item in items:
source_table = item.get('source_table')
stock_id = item.get('id')
qty = float(item.get('out_quantity', 0))
ModelClass = model_map.get(source_table)
if not ModelClass: continue
# ==============================================
# ★ 防线3:并发超卖与负库存 - 锁行后再查可用库存
# ⚠️ 不要在此加 joinedload(ModelClass.base)!PG 禁止 FOR UPDATE
# 应用到 outer join 的 nullable 侧,会报 FeatureNotSupported
# 并有死锁风险。stock.base 走单条 lazy 加载是已知取舍。
# ==============================================
stock = ModelClass.query.with_for_update().get(stock_id)
2026-02-06 17:11:47 +08:00
if not stock: raise ValueError(f"库存不存在 ID:{stock_id}")
# ==============================================
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
# ★ 身份与数量校验已由上方 verify_scanned() 统一完成
# (base_id 主键匹配,允许同物料换批次;累计量不得超批准量)
#
# 此处仅做一次「本次扫码累计」的防御性复核,防止并发下
# 同一请求内重复 stock_id 被重复计数。
# 库存扣减也已在 restore_then_deduct() 完成 ——
# 下方**不再**扣减 available_quantity,否则会扣两次。
# ==============================================
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
stock_name = (stock.base.name or '').strip() if stock.base else ''
stock_spec = (stock.base.spec_model or '').strip() if stock.base else ''
key = (stock_name, stock_spec)
dispatch_acc[key] = dispatch_acc.get(key, 0) + qty
2026-02-06 17:11:47 +08:00
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
# 创建借用记录
2026-02-06 17:11:47 +08:00
record = TransBorrow(
borrow_no=borrow_no,
sku=stock.sku,
source_table=source_table,
stock_id=stock.id,
barcode=stock.barcode,
quantity=qty,
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
# ★ 借出即由借用人持有:borrower_id 为初始借用人(写一次不再变),
# current_holder_* 为当前持有人 —— 转交会在此之上继续推进。
borrower_id=int(borrower_id),
2026-02-06 17:11:47 +08:00
borrower_name=borrower_name,
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
current_holder_id=int(borrower_id),
current_holder_name=borrower_name,
# ★ 显式置 0:归还逻辑按 returned_quantity 累加并判断是否还清,
# 不依赖 DB 列默认值(避免 ORM/DB 默认值口径不一致时算错待还量)。
returned_quantity=0,
2026-02-06 17:11:47 +08:00
borrow_signature=signature,
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
# ★ 发货操作人:执行本次借出的库管。operator_name 此前被接收
# 却从未落库,责任链上「谁经手发货」一直缺失,此处补齐。
dispatch_operator=operator_name,
remark=remark,
expected_return_time=expected_return_time,
# [新增] 记录借出时的库位快照
location=getattr(stock, 'warehouse_location', None),
2026-02-06 17:11:47 +08:00
status='borrowed',
is_returned=False
)
db.session.add(record)
# ★ 3. 标记审批单为已完成
approval.status = 3
2026-02-06 17:11:47 +08:00
db.session.commit()
return borrow_no
except Exception as e:
db.session.rollback()
raise e
# ★ 兼容旧入口(不走审批流的直接借库,保留以便平滑过渡)
@staticmethod
def create_borrow(data, operator_name='System'):
"""
借库逻辑(兼容旧模式):减少可用库存,不减总库存
@deprecated 请优先使用 execute_dispatch 走审批流
"""
return TransService.execute_dispatch(
approval_id=0,
items=data.get('items', []),
operator_name=operator_name,
borrower_name=data.get('borrower_name'),
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
borrower_id=data.get('borrower_id'),
signature=data.get('signature_path'),
remark=data.get('remark'),
expected_return_time=data.get('expected_return_time')
)
2026-02-06 17:11:47 +08:00
@staticmethod
def scan_for_return(barcode):
"""
扫码还库:查找未归还记录,并返回当前物品的库位
"""
records = TransBorrow.query.filter_by(barcode=barcode, is_returned=False).all()
if not records:
return None
# 取第一条未还记录
record = records[0]
# 获取当前库存表中的实时库位
current_location = ""
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
ModelClass = model_map.get(record.source_table)
if ModelClass:
stock = ModelClass.query.get(record.stock_id)
if stock:
current_location = stock.warehouse_location
res_dict = record.to_dict()
res_dict['current_location'] = current_location # 用于前端对比和预填
return res_dict
@staticmethod
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
def process_return(data, operator_name, returner_id=None):
2026-02-06 17:11:47 +08:00
"""
还库逻辑(支持部分归还)- 已优化,消除 N+1 和长事务死锁风险
四步走策略:
1. 收集所有 borrow_id
2. 批量锁定借用记录
3. 收集库存ID并批量锁定库存
4. 内存中完成业务逻辑
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
参数
----
operator_name : 经手办理还库的**库管**姓名(窗口操作人)
returner_id : 实际把物品交回窗口的**归还人**ID(一期转交改造新增)。
记录有 current_holder_id 时强制校验二者一致 ——
详见循环内的持有人校验块。
2026-02-06 17:11:47 +08:00
"""
items = data.get('items', [])
signature = data.get('signature_path') # 库管签字
if not items: raise ValueError("还库列表为空")
if not signature: raise ValueError("库管必须签字确认")
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
try:
# ==========================================
# ★ 优化步骤 1:收集所有 borrow_id
# ==========================================
borrow_ids = []
item_map = {} # 存储原始 item 数据,key=borrow_id
2026-02-06 17:11:47 +08:00
for item in items:
borrow_id = item.get('id')
if borrow_id:
borrow_ids.append(borrow_id)
item_map[borrow_id] = {
'return_qty': float(item.get('return_qty', 0)),
'final_location': item.get('return_location')
}
if not borrow_ids:
raise ValueError("没有有效的归还记录")
# ==========================================
# ★ 优化步骤 2:批量锁定借用记录
# ==========================================
borrow_records = TransBorrow.query.with_for_update().filter(
TransBorrow.id.in_(borrow_ids)
).all()
borrow_map = {r.id: r for r in borrow_records}
# ==========================================
# ★ 优化步骤 3:收集库存ID并批量锁定库存
# ==========================================
stock_ids_by_table = {'stock_buy': set(), 'stock_semi': set(), 'stock_product': set()}
for borrow_id, record in borrow_map.items():
if record.source_table in stock_ids_by_table and record.stock_id:
stock_ids_by_table[record.source_table].add(record.stock_id)
stock_map = {} # 格式: { ('stock_buy', 101): stock_obj }
for table_name, ids in stock_ids_by_table.items():
if not ids:
continue
ModelClass = model_map[table_name]
stocks = ModelClass.query.with_for_update().filter(
ModelClass.id.in_(ids)
).all()
for stock in stocks:
stock_map[(table_name, stock.id)] = stock
# ==========================================
# ★ 优化步骤 4:内存中完成业务逻辑
# ==========================================
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
# ★ 时间口径修复:原实现用 datetime.now()(容器本地时间,Docker 下为
# UTC),而同一行的 borrow_time 由 beijing_time 写入 —— 两个字段
# 差 8 小时,台账时间线自相矛盾。统一取北京时间,并与下方归还流水
# 共用同一个时间戳,保证主表快照与流水完全对齐。
return_now = beijing_time()
for borrow_id, item_data in item_map.items():
return_qty = item_data['return_qty']
final_location = item_data['final_location']
record = borrow_map.get(borrow_id)
if not record:
2026-02-06 17:11:47 +08:00
continue
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
# ==========================================
# ★ 持有人强校验(一期转交改造)
#
# 原实现只要持有 op_return:operation 权限的库管即可归还**任何人**
# 借出的物品,函数内从不读取 record.borrower_name,归还环节的责任链
# 是断的。转交上线后物品会在 A→B→C 之间流转,若不校验归还人,
# 「谁还的」将与「谁该还」彻底脱钩。
#
# 规则:记录有 current_holder_id(= 物品仍在某人手上)时,
# 归还人必须**就是**该持有人。
#
# ★ 为什么 current_holder_id 为 NULL 时跳过:
# 仅迁移前无法锚定姓名的历史行会是 NULL,跳过以兼容存量数据、
# 不阻断其正常归还;新数据(execute_dispatch 落库)必有值,
# 即新流程**不存在**绕过校验的路径。
# ==========================================
if record.current_holder_id is not None:
holder_label = record.current_holder_name or f"用户({record.current_holder_id})"
if returner_id is None:
raise ValueError(
f"缺少归还人:物品【{record.sku}】当前由【{holder_label}】持有,"
f"请选择归还人后再提交"
)
if int(returner_id) != int(record.current_holder_id):
raise ValueError(
f"归还人与当前持有人不符,禁止归还:物品【{record.sku}】的"
f"当前持有人为【{holder_label}】,而非所选归还人"
)
# 计算待还数量
returned_qty = float(record.returned_quantity) if record.returned_quantity else 0
total_qty = float(record.quantity) if record.quantity else 0
pending_qty = total_qty - returned_qty
# 校验归还数量
if return_qty <= 0:
raise ValueError(f"归还数量必须大于0")
if return_qty > pending_qty:
raise ValueError(f"本次归还数量({return_qty})不能大于待还数量({pending_qty})")
# 更新库存
stock = stock_map.get((record.source_table, record.stock_id))
if stock:
# 恢复可用库存
stock.available_quantity = float(stock.available_quantity) + return_qty
# 更新库位
if final_location:
stock.warehouse_location = final_location
2026-02-06 17:11:47 +08:00
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
# ==========================================
# 更新归还数量和状态
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
#
# ★ 主表字段的定位(一期转交改造后):
# returned_quantity / is_returned / status 是**累计快照**,
# 聚合语义正确,列表页「未还/已还」判定依赖它们 → 继续维护;
# return_time / return_operator / return_signature 降级为
# 「最近一次归还」**展示快照**(records.vue 的归还人/归还时间列
# 依赖它们)→ 继续刷新;
# 逐次归还的**权威明细**改由 trans_borrow_return 承载 ——
# 原先只写主表时,部分归还下「谁在什么时候还了多少」会被
# 逐次覆盖而永久丢失(失忆症),流水表根治该问题。
# ==========================================
new_returned_qty = returned_qty + return_qty
record.returned_quantity = new_returned_qty
if new_returned_qty >= total_qty:
record.is_returned = True
record.status = 'returned'
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
# ★ 已全部还清:物品回到仓库,无人持有。
# 清空 current_holder 使「current_holder_id IS NOT NULL」
# 成为「仍在某人手上」的有效信号(与迁移脚本对已归还历史行
# 不回填 holder 的口径一致)。借款人仍留在 borrower_id 作历史。
record.current_holder_id = None
record.current_holder_name = None
else:
record.is_returned = False
record.status = 'partial_returned'
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
record.return_time = return_now
2026-02-06 17:11:47 +08:00
record.return_operator = operator_name
record.return_signature = signature
if final_location:
record.return_location = final_location
2026-02-06 17:11:47 +08:00
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
# ★ 逐次归还落流水(治失忆症)
# returner_id 与 operator_name 是两个人:前者是交回物品的人,
# 后者是经手办理的库管。上面已强校验前者 == current_holder_id。
db.session.add(TransBorrowReturn(
borrow_id=record.id,
returner_id=int(returner_id) if returner_id is not None else None,
return_qty=return_qty,
return_time=return_now,
operator_name=operator_name,
))
db.session.commit()
except Exception as e:
db.session.rollback()
raise e
# ==========================================================================
# 借库转交(一期)
# ==========================================================================
@staticmethod
def transfer_borrow(borrow_id, to_user_id, transfer_qty, operator_name='System', remark=None):
"""
把一张借出单的**持有权**从当前持有人整单转给另一人。
与 execute_dispatch / process_return 的根本区别
------------------------------------------------
转交是**纯持有权变更**:实物不出入库,库存账目分毫不动。
本方法全程不触碰 stock_buy / stock_semi / stock_product 的任何字段
(available_quantity 与 stock_quantity 都不动)。
理由见 restore_then_deduct 的 deduct_stock 说明:借出期间 available
已冻结、stock 仍含借出未还量。转交若去动库存,会同时破坏两个既有假设 ——
「归还只加 available」会算多,而「借库转报废在确认损失时扣 stock」
(scrap_sources.BorrowScrapAdapter)会重复扣减。
为什么一期只允许整单全量转交
----------------------------
trans_borrow 是**单行**模型,只能存一个 current_holder_id。
若允许部分转交(借 10 个转 5 个出去),这一行的 current_holder 就必须
同时表示两个人,语义直接撕裂,且归还时无法判定该由谁还。
故 transfer_qty 必须严格等于待还量(quantity - returned_quantity)。
★ 未来若需部分转交:应改为按数量**拆行**(新建一条 trans_borrow 承接
转出量、原行扣减),而不是在本行上加字段打补丁 —— 单行模型无论如何
扩展都无法同时表达两个持有人。
参数
----
borrow_id : trans_borrow.id
to_user_id : 接收人(转交后的 current_holder)ID
transfer_qty : 转交数量,一期必须等于待还量
operator_name: 执行转交操作的库管姓名
remark : 转交备注
返回
----
TransBorrowTransfer 实例(已 commit)
异常
----
ValueError: 任何校验不通过(调用方整单回滚,不会留下半转状态)
"""
from app.models.system import SysUser
if to_user_id is None:
raise ValueError("缺少接收人 to_user_id")
if transfer_qty is None:
raise ValueError("缺少转交数量 transfer_qty")
try:
transfer_qty = float(transfer_qty)
except (TypeError, ValueError):
raise ValueError("转交数量格式无效,应为数字")
# ==================================================================
# ★ 防线1:锁行 —— 并发下防止同一张单被同时转给两个人
# (后到的事务会阻塞在此,拿到锁后读到已推进的 current_holder_id,
# 从而在下方「接收人 == 当前持有人」校验处被拒绝)
# ==================================================================
record = TransBorrow.query.with_for_update().get(borrow_id)
if not record:
raise ValueError("借出记录不存在")
# --- 1. 状态准入 ---
total_qty = float(record.quantity or 0)
returned_qty = float(record.returned_quantity or 0)
pending_qty = total_qty - returned_qty
# ★ 顺序有意义:报废流程(scrap_sources)会同时置 is_returned=True 与
# status='scrapped',若先判 is_returned 会让报废单收到「已归还」的
# 误导性提示。故先判报废,给出准确原因。
if record.status == 'scrapped':
raise ValueError("该借出记录已转入报废流程,不可再转交")
if record.is_returned or pending_qty <= 0:
raise ValueError("该借出记录已全部归还,无可转交的实物")
# --- 2. 数量校验:一期必须整单全量转交 ---
if transfer_qty <= 0:
raise ValueError("转交数量必须大于0")
if transfer_qty > pending_qty:
raise ValueError(
f"转交数量({transfer_qty})不能大于待还数量({pending_qty})"
)
# 浮点容差:quantity/returned_quantity 是 numeric(19,4),差值应精确,
# 但仍用容差比较,避免二进制浮点表示误差造成误拒。
if abs(transfer_qty - pending_qty) > 1e-6:
raise ValueError(
f"目前仅支持整单全部转交:本单待还 {pending_qty},"
f"本次仅转交 {transfer_qty}。部分转交会导致当前持有人语义撕裂,"
f"请整单转交,或先办理部分归还后再转交。"
)
# --- 3. 转出方必须已锚定 ---
# 历史行(迁移前无法用姓名唯一映射到 sys_user 的)holder 为 NULL,
# 此时「从谁转出」无从确定,Fail-Closed 拒绝。
if record.current_holder_id is None:
raise ValueError(
"该借出记录的当前持有人未锚定(历史数据),无法转交,请先办理归还"
)
# --- 4. 接收人校验 ---
try:
to_user_id = int(to_user_id)
except (TypeError, ValueError):
# 不直接 int() 抛裸异常:原生报错信息(invalid literal for int()...)
# 会原样透给前端,对库管毫无指导意义。
raise ValueError("接收人 to_user_id 格式无效,应为数字ID")
to_user = SysUser.query.get(to_user_id)
if not to_user:
raise ValueError(f"接收人不存在(ID:{to_user_id})")
to_user_name = user_display_name(to_user)
if to_user_id == int(record.current_holder_id):
raise ValueError(f"接收人与当前持有人同为【{to_user_name}】,无需转交")
# --- 5. 行级公司隔离(Fail-Closed)---
_assert_borrow_company_visible(record)
# ==================================================================
# ★ 防线2:以下只写台账,绝不触碰任何库存字段
# ==================================================================
from_name = record.current_holder_name or user_display_name(
SysUser.query.get(record.current_holder_id)
)
from_id = record.current_holder_id
transfer = TransBorrowTransfer(
borrow_id=record.id,
from_user_id=from_id,
from_user_name=from_name,
to_user_id=to_user_id,
to_user_name=to_user_name,
transfer_qty=transfer_qty,
transfer_time=beijing_time(),
operator_name=operator_name,
remark=remark,
)
db.session.add(transfer)
# 推进当前持有人。borrower_id(初始借用人)保持不动 —— 它回答的是
# 「这单最初谁借的」,不应被转交改写。
record.current_holder_id = to_user_id
record.current_holder_name = to_user_name
try:
2026-02-06 17:11:47 +08:00
db.session.commit()
except Exception as e:
db.session.rollback()
raise e
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
return transfer
@staticmethod
def get_transfer_history(borrow_id):
"""某条借出记录的转交历史(按时间正序,便于还原 A→B→C 链路)"""
rows = TransBorrowTransfer.query.filter_by(borrow_id=borrow_id) \
.order_by(asc(TransBorrowTransfer.transfer_time), asc(TransBorrowTransfer.id)).all()
return [r.to_dict() for r in rows]
@staticmethod
def get_return_history(borrow_id):
"""某条借出记录的归还历史(按时间正序,替代被覆盖的主表字段)"""
rows = TransBorrowReturn.query.filter_by(borrow_id=borrow_id) \
.order_by(asc(TransBorrowReturn.return_time), asc(TransBorrowReturn.id)).all()
return [r.to_dict() for r in rows]
@staticmethod
def get_slip_history(borrow_no):
"""
整张借用单(borrow_no 维度)的完整生命周期事件流,按时间**倒序**返回。
事件来源
--------
borrow ← trans_borrow 自身(借出时间 / 借用人 / 发货操作人 / 数量)
transfer ← trans_borrow_transfer(转出人 → 接收人、经手库管、备注)
return ← trans_borrow_return(实际归还人、经手库管、数量)
scrap ← trans_borrow.status == 'scrapped'(报废是终态,没有独立流水表,
由主表的终态字段 + return_time/return_operator 还原)
★ 为什么整单聚合,而不是让前端逐条明细调用单品接口:
借用记录列表是 borrow_no 主子表结构,**实测单张单最多 21 条明细**,
逐条调用会产生 21 个请求,且各条时间线无法全局排序。此处一次合并。
★ 公司隔离:任一条明细不可见即整单拒绝(Fail-Closed),与转交同口径。
异常:ValueError(单据不存在 / 越权 / 隔离链路断裂)
"""
from app.models.system import SysUser
records = TransBorrow.query.filter_by(borrow_no=borrow_no).all()
if not records:
raise ValueError("借用单不存在")
# 任一条明细越权 → 整单拒绝(与转交同口径,避免两套可见性标准分叉)
for r in records:
_assert_borrow_company_visible(r)
record_ids = [r.id for r in records]
by_id = {r.id: r for r in records}
# --- 批量取流水,避免逐条查询 ---
transfers = TransBorrowTransfer.query.filter(
TransBorrowTransfer.borrow_id.in_(record_ids)
).all()
returns = TransBorrowReturn.query.filter(
TransBorrowReturn.borrow_id.in_(record_ids)
).all()
# --- 批量解析物料名(与 get_records 同口径,含 SKU 兜底) ---
material_map = {}
stock_ids_by_table = {}
for r in records:
if r.source_table and r.stock_id:
stock_ids_by_table.setdefault(r.source_table, set()).add(r.stock_id)
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
for table_name, ids in stock_ids_by_table.items():
ModelClass = model_map.get(table_name)
if not ModelClass:
continue
for stock in ModelClass.query.options(joinedload(ModelClass.base)).filter(
ModelClass.id.in_(ids)).all():
material_map[(table_name, stock.id)] = stock.base.name if stock.base else ''
empty_sku = {r.sku for r in records
if r.sku and not material_map.get((r.source_table, r.stock_id))}
sku_name_map = {}
if empty_sku:
for ModelClass in (StockProduct, StockSemi, StockBuy):
for stock in ModelClass.query.options(joinedload(ModelClass.base)).filter(
ModelClass.sku.in_(empty_sku)).all():
if stock.sku not in sku_name_map and stock.base:
sku_name_map[stock.sku] = stock.base.name
# --- 批量解析归还人姓名(归还流水只存 returner_id,无姓名快照) ---
returner_ids = {t.returner_id for t in returns if t.returner_id}
user_name_map = {}
if returner_ids:
for u in SysUser.query.filter(SysUser.id.in_(returner_ids)).all():
user_name_map[u.id] = user_display_name(u)
def _label(rec):
name = material_map.get((rec.source_table, rec.stock_id)) or sku_name_map.get(rec.sku, '')
return name or rec.sku or ''
def _sort_key(e):
# ★ 必须按**真实 datetime** 排序,不能用展示用的 '%Y-%m-%d %H:%M:%S' 字符串:
# 后者截断到秒,同一秒内发生的多个动作(库管连续操作的常见情形,
# 如「转交 A→B 紧接着转交 B→C」)会退化成并列,排序结果取决于
# 数据库返回顺序 —— 时间线会随机错乱。
# datetime.min 兜底:无时间的脏数据排到最后。
return (e['_dt'] or datetime.min, e['_seq'])
events = []
for r in records:
label = _label(r)
qty = float(r.quantity or 0)
# 借出事件
events.append({
'type': 'borrow',
'time': r.borrow_time.strftime('%Y-%m-%d %H:%M:%S') if r.borrow_time else None,
'_dt': r.borrow_time,
'borrow_id': r.id,
'sku': r.sku,
'material_name': label,
'quantity': qty,
'actor_name': r.borrower_name, # 借用人
'operator_name': r.dispatch_operator, # 发货库管
'remark': r.remark,
'_seq': 0,
})
# 报废事件(终态,无独立流水表)
if r.status == 'scrapped':
events.append({
'type': 'scrap',
'time': r.return_time.strftime('%Y-%m-%d %H:%M:%S') if r.return_time else None,
'_dt': r.return_time,
'borrow_id': r.id,
'sku': r.sku,
'material_name': label,
'quantity': qty - float(r.returned_quantity or 0),
'actor_name': r.borrower_name,
'operator_name': r.return_operator,
'remark': None,
'_seq': 3,
})
for t in transfers:
rec = by_id.get(t.borrow_id)
events.append({
'type': 'transfer',
'time': t.transfer_time.strftime('%Y-%m-%d %H:%M:%S') if t.transfer_time else None,
'_dt': t.transfer_time,
'borrow_id': t.borrow_id,
'sku': rec.sku if rec else None,
'material_name': _label(rec) if rec else '',
'quantity': float(t.transfer_qty or 0),
'actor_name': t.to_user_name, # 接收人(转交后的持有人)
'from_name': t.from_user_name, # 转出人
'operator_name': t.operator_name,
'remark': t.remark,
'_seq': 1,
})
for rt in returns:
rec = by_id.get(rt.borrow_id)
events.append({
'type': 'return',
'time': rt.return_time.strftime('%Y-%m-%d %H:%M:%S') if rt.return_time else None,
'_dt': rt.return_time,
'borrow_id': rt.borrow_id,
'sku': rec.sku if rec else None,
'material_name': _label(rec) if rec else '',
'quantity': float(rt.return_qty or 0),
'actor_name': user_name_map.get(rt.returner_id), # 实际归还人
'operator_name': rt.operator_name,
'remark': None,
'_seq': 2,
})
events.sort(key=_sort_key, reverse=True)
for e in events:
e.pop('_seq', None)
e.pop('_dt', None)
return {
'borrow_no': borrow_no,
'events': events,
'records': [r.to_dict() for r in records],
}
@staticmethod
def get_borrow_history(borrow_id):
"""
一张借出单的完整流转视图:主表快照 + 转交链 + 逐次归还明细。
★ 含行级公司隔离(Fail-Closed),与转交同口径 ——
否则「能看到哪条记录」与「能转交哪条记录」两套标准会分叉。
异常:ValueError(记录不存在 / 越权 / 隔离链路断裂),由调用方转成 4xx。
"""
record = TransBorrow.query.get(borrow_id)
if not record:
raise ValueError("借出记录不存在")
_assert_borrow_company_visible(record)
return {
'record': record.to_dict(),
'transfers': TransService.get_transfer_history(borrow_id),
'returns': TransService.get_return_history(borrow_id),
}
2026-02-06 17:11:47 +08:00
@staticmethod
def get_records(page=1, limit=10, status='all', keyword=None, search_type='all',
borrower_name=None, start_date=None, end_date=None,
advanced_filters=None):
"""
获取借还记录列表(按单号 borrow_no 维度分页,避免明细撑爆 pageSize)
实现思路(三步走):
步骤 1: 构造 GROUP BY borrow_no 的"单号维度视图" subquery
(包含 borrow_no + sort_key + 状态聚合,全部聚合都在这里完成)
步骤 2: 用一个【纯净的列查询】从 subquery 中分页得到 page_borrow_nos
→ SELECT 只有 borrow_no 一列,【主查询无 GROUP BY】
→ 避免触发 PG "column must appear in GROUP BY" 严格模式
步骤 3: 用 page_borrow_nos 拉明细 + 预加载 material_name
状态过滤按"单号聚合"判定:
- borrowed: 单号下至少有一条 is_returned=False
- returned: 单号下所有明细 is_returned=True
日期范围按「借出时间」过滤,边界补全时分秒以解决零点截断问题。
"""
# 日期补全:与出库记录同口径
if end_date and len(str(end_date).strip()) == 10:
end_date = f"{str(end_date).strip()} 23:59:59"
if start_date and len(str(start_date).strip()) == 10:
start_date = f"{str(start_date).strip()} 00:00:00"
try:
# ====================================================================
# 步骤 1a:构造"单号维度"基础子查询(GROUP BY borrow_no 在这里完成)
# ====================================================================
# 单号 + 排序键(最早 expected_return_time)—— 这一层只含 2 列 + GROUP BY
order_subq = (
db.session.query(
TransBorrow.borrow_no.label('borrow_no'),
# 有限期梯队内:单号内最早预计归还时间
func.min(TransBorrow.expected_return_time).label('sort_key'),
# 无限期梯队内:单号内最早借出时间(呆滞借用盘点)
func.min(TransBorrow.borrow_time).label('min_borrow_time'),
# 单内是否含"有限期"明细:任一 expected_return_time 非空 → 1(有限期单排前)
func.max(case((TransBorrow.expected_return_time.isnot(None), 1), else_=0)).label('has_finite')
)
.group_by(TransBorrow.borrow_no)
.subquery()
)
# 状态聚合子查询(也是 GROUP BY borrow_no)
status_subq = (
db.session.query(
TransBorrow.borrow_no.label('borrow_no'),
func.sum(
case((TransBorrow.is_returned == False, 1), else_=0)
).label('unreturned_count')
)
.group_by(TransBorrow.borrow_no)
.subquery()
)
# ====================================================================
# 步骤 1b:构造关键词命中单号子查询(保留原全部 search_type 逻辑)
# ====================================================================
keyword_conditions = None
if keyword:
# 根据 search_type 构建不同的搜索条件
if search_type == 'all':
# 原有逻辑:or_ 联表全局模糊搜索
# 查询 stock_buy 路径匹配的名称/规格
buy_match = db.session.query(TransBorrow.id).join(
StockBuy, and_(
TransBorrow.stock_id == StockBuy.id,
TransBorrow.source_table == 'stock_buy'
)
).join(
MaterialBase, StockBuy.base_id == MaterialBase.id
).filter(
or_(
MaterialBase.name.ilike(f'%{keyword}%'),
MaterialBase.spec_model.ilike(f'%{keyword}%')
)
).subquery()
# 查询 stock_semi 路径匹配的名称/规格
semi_match = db.session.query(TransBorrow.id).join(
StockSemi, and_(
TransBorrow.stock_id == StockSemi.id,
TransBorrow.source_table == 'stock_semi'
)
).join(
MaterialBase, StockSemi.base_id == MaterialBase.id
).filter(
or_(
MaterialBase.name.ilike(f'%{keyword}%'),
MaterialBase.spec_model.ilike(f'%{keyword}%')
)
).subquery()
# 查询 stock_product 路径匹配的名称/规格
product_match = db.session.query(TransBorrow.id).join(
StockProduct, and_(
TransBorrow.stock_id == StockProduct.id,
TransBorrow.source_table == 'stock_product'
)
).join(
MaterialBase, StockProduct.base_id == MaterialBase.id
).filter(
or_(
MaterialBase.name.ilike(f'%{keyword}%'),
MaterialBase.spec_model.ilike(f'%{keyword}%')
)
).subquery()
# 合并三种来源的匹配 ID
all_matches = db.session.query(buy_match.c.id).union(
db.session.query(semi_match.c.id),
db.session.query(product_match.c.id)
).subquery()
keyword_conditions = or_(
TransBorrow.borrower_name.ilike(f'%{keyword}%'),
TransBorrow.sku.ilike(f'%{keyword}%'),
TransBorrow.borrow_no.ilike(f'%{keyword}%'),
TransBorrow.id.in_(all_matches)
)
elif search_type == 'no':
keyword_conditions = TransBorrow.borrow_no.ilike(f'%{keyword}%')
elif search_type == 'name':
keyword_conditions = TransBorrow.borrower_name.ilike(f'%{keyword}%')
elif search_type == 'sku':
keyword_conditions = TransBorrow.sku.ilike(f'%{keyword}%')
elif search_type == 'material_name':
# 联表查询物料名称
buy_match = db.session.query(TransBorrow.id).join(
StockBuy, and_(
TransBorrow.stock_id == StockBuy.id,
TransBorrow.source_table == 'stock_buy'
)
).join(
MaterialBase, StockBuy.base_id == MaterialBase.id
).filter(MaterialBase.name.ilike(f'%{keyword}%')).subquery()
semi_match = db.session.query(TransBorrow.id).join(
StockSemi, and_(
TransBorrow.stock_id == StockSemi.id,
TransBorrow.source_table == 'stock_semi'
)
).join(
MaterialBase, StockSemi.base_id == MaterialBase.id
).filter(MaterialBase.name.ilike(f'%{keyword}%')).subquery()
product_match = db.session.query(TransBorrow.id).join(
StockProduct, and_(
TransBorrow.stock_id == StockProduct.id,
TransBorrow.source_table == 'stock_product'
)
).join(
MaterialBase, StockProduct.base_id == MaterialBase.id
).filter(MaterialBase.name.ilike(f'%{keyword}%')).subquery()
all_matches = db.session.query(buy_match.c.id).union(
db.session.query(semi_match.c.id),
db.session.query(product_match.c.id)
).subquery()
keyword_conditions = TransBorrow.id.in_(all_matches)
elif search_type == 'spec_model':
# 联表查询规格型号
buy_match = db.session.query(TransBorrow.id).join(
StockBuy, and_(
TransBorrow.stock_id == StockBuy.id,
TransBorrow.source_table == 'stock_buy'
)
).join(
MaterialBase, StockBuy.base_id == MaterialBase.id
).filter(MaterialBase.spec_model.ilike(f'%{keyword}%')).subquery()
semi_match = db.session.query(TransBorrow.id).join(
StockSemi, and_(
TransBorrow.stock_id == StockSemi.id,
TransBorrow.source_table == 'stock_semi'
)
).join(
MaterialBase, StockSemi.base_id == MaterialBase.id
).filter(MaterialBase.spec_model.ilike(f'%{keyword}%')).subquery()
product_match = db.session.query(TransBorrow.id).join(
StockProduct, and_(
TransBorrow.stock_id == StockProduct.id,
TransBorrow.source_table == 'stock_product'
)
).join(
MaterialBase, StockProduct.base_id == MaterialBase.id
).filter(MaterialBase.spec_model.ilike(f'%{keyword}%')).subquery()
all_matches = db.session.query(buy_match.c.id).union(
db.session.query(semi_match.c.id),
db.session.query(product_match.c.id)
).subquery()
keyword_conditions = TransBorrow.id.in_(all_matches)
# 把"命中的单号"独立成 subquery,供主查询做 IN 过滤
keyword_borrow_nos_subq = None
if keyword_conditions is not None:
keyword_borrow_nos_subq = (
db.session.query(TransBorrow.borrow_no)
.filter(keyword_conditions)
.distinct()
.subquery()
)
# ====================================================================
# 【行级数据隔离】基于 JWT 多租户公司过滤
# 通过 stock 表关联到 MaterialBase,确保只返回本公司借还记录
# ====================================================================
company_borrow_nos_subq = None
company_limit = get_current_company_filter()
if company_limit is not None:
buy_nos = db.session.query(TransBorrow.borrow_no).join(
StockBuy, and_(TransBorrow.stock_id == StockBuy.id,
TransBorrow.source_table == 'stock_buy')
).join(MaterialBase, StockBuy.base_id == MaterialBase.id).filter(
MaterialBase.company_name == company_limit
)
semi_nos = db.session.query(TransBorrow.borrow_no).join(
StockSemi, and_(TransBorrow.stock_id == StockSemi.id,
TransBorrow.source_table == 'stock_semi')
).join(MaterialBase, StockSemi.base_id == MaterialBase.id).filter(
MaterialBase.company_name == company_limit
)
product_nos = db.session.query(TransBorrow.borrow_no).join(
StockProduct, and_(TransBorrow.stock_id == StockProduct.id,
TransBorrow.source_table == 'stock_product')
).join(MaterialBase, StockProduct.base_id == MaterialBase.id).filter(
MaterialBase.company_name == company_limit
)
company_borrow_nos_subq = buy_nos.union(
semi_nos, product_nos
).subquery()
# ====================================================================
# 步骤 2:纯净列查询分页(SELECT 只有 order_subq.c.borrow_no 一列)
# ====================================================================
borrow_no_q = db.session.query(order_subq.c.borrow_no)
# ★ 数据权限:普通用户只看“借用人=本人姓名(不含账号前缀)”的借还记录;
# 兼容库里存成“姓名/xiaolongxia”全名(姓名 + '/' 前缀)的情况
if borrower_name:
own_borrow_nos_subq = (
db.session.query(TransBorrow.borrow_no)
.filter(or_(
TransBorrow.borrower_name == borrower_name,
TransBorrow.borrower_name.like(f"{borrower_name}/%")
))
.distinct()
.subquery()
)
borrow_no_q = borrow_no_q.filter(
order_subq.c.borrow_no.in_(own_borrow_nos_subq)
)
# 关键词过滤
if keyword_borrow_nos_subq is not None:
borrow_no_q = borrow_no_q.filter(
order_subq.c.borrow_no.in_(keyword_borrow_nos_subq)
)
# 公司隔离过滤
if company_borrow_nos_subq is not None:
borrow_no_q = borrow_no_q.filter(
order_subq.c.borrow_no.in_(company_borrow_nos_subq)
)
# ====================================================================
# ★ 高级筛选:父级字段直接过滤;子级字段(SKU/物料名称)走
# 「命中单号子查询 → 按单号 IN」的 EXISTS 语义,
# 避免在 GROUP BY 前收窄明细范围而丢失同单的兄弟明细。
# ====================================================================
if advanced_filters:
from app.utils.advanced_filter import (
build_predicate, apply_child_condition,
)
parent_map = {
'no': TransBorrow.borrow_no,
'operator': TransBorrow.borrower_name,
'borrower_name': TransBorrow.borrower_name,
}
child_map = {'sku': TransBorrow.sku}
material_stock_models = [
(StockBuy, 'stock_buy'),
(StockSemi, 'stock_semi'),
(StockProduct, 'stock_product'),
]
for cond in advanced_filters:
field = cond.get('field')
if field in parent_map:
# 父级字段:标准 SQL 谓词即可
p = build_predicate(cond, parent_map)
if p is not None:
borrow_no_q = borrow_no_q.filter(
order_subq.c.borrow_no.in_(
db.session.query(TransBorrow.borrow_no)
.filter(p).distinct()
)
)
continue
if field in child_map or field == 'material_name':
# ★ 子级字段:正/负操作符语义分派(否定 → 整单排除)
borrow_no_q = apply_child_condition(
borrow_no_q, order_subq.c.borrow_no, TransBorrow,
cond, child_map, material_stock_models,
)
continue
# 日期范围过滤(按借出时间;边界已在入口补全时分秒)
if start_date:
borrow_no_q = borrow_no_q.filter(order_subq.c.borrow_no.in_(
db.session.query(TransBorrow.borrow_no)
.filter(TransBorrow.borrow_time >= start_date)
.distinct()
))
if end_date:
borrow_no_q = borrow_no_q.filter(order_subq.c.borrow_no.in_(
db.session.query(TransBorrow.borrow_no)
.filter(TransBorrow.borrow_time <= end_date)
.distinct()
))
# 状态过滤(按"单号聚合"判定)
if status == 'borrowed':
# 单号下至少一条未还
borrow_no_q = borrow_no_q.filter(
order_subq.c.borrow_no.in_(
db.session.query(status_subq.c.borrow_no)
.filter(status_subq.c.unreturned_count > 0)
)
)
elif status == 'returned':
# 单号下所有明细都已归还
borrow_no_q = borrow_no_q.filter(
order_subq.c.borrow_no.in_(
db.session.query(status_subq.c.borrow_no)
.filter(status_subq.c.unreturned_count == 0)
)
)
2026-02-06 17:11:47 +08:00
# ★ 默认排序(多级复合,符合"优先关注快到期/逾期"业务):
# 1) 有限期单(含 expected_return_time)排前,无限期单排后
# 2) 有限期内按最早预计归还时间 ASC(越快到期/逾期越久越靠前)
# 3) 无限期内按最早借出时间 ASC(借出越久越靠前,暴露长期未还的呆滞借用)
borrow_no_q = borrow_no_q.order_by(
case((order_subq.c.has_finite == 0, 1), else_=0).asc(),
nullslast(asc(order_subq.c.sort_key)),
asc(order_subq.c.min_borrow_time)
)
# 分页(基准 = borrow_no 单号数)
pagination = borrow_no_q.paginate(page=page, per_page=limit, error_out=False)
# ★ pagination.items 是 SQLAlchemy Row 对象,psycopg2 无法直接 adapt Row
# 用 isinstance(row, tuple) 不够(2.x 的 Row 不一定继承 tuple)
# 用 hasattr(row, '_mapping') 兜底,强制提取 row[0] 拿到纯字符串
page_borrow_nos = [
row[0] if isinstance(row, tuple) or hasattr(row, '_mapping') else row
for row in pagination.items
]
total_orders = pagination.total # ★ 单号总数(修复前是明细数,分页错乱根因)
if not page_borrow_nos:
return {
'items': [],
'total': total_orders,
'page': page,
'limit': limit
}
# ====================================================================
# 步骤 3:按当前页 borrow_no 集合一次性拉出所有明细
# ====================================================================
detail_records = (
TransBorrow.query
.filter(TransBorrow.borrow_no.in_(page_borrow_nos))
.order_by(TransBorrow.borrow_no.asc(), TransBorrow.id.asc())
.all()
)
# ============================================================
# ★ 批量预加载物料名称(三步:收集ID → 批量JOIN → SKU兜底)
# ============================================================
items_with_names = []
items = detail_records
if items:
# 步骤 1:收集所有 (source_table, stock_id) 对
stock_ids_by_table = {'stock_buy': set(), 'stock_semi': set(), 'stock_product': set()}
for item in items:
if item.source_table in stock_ids_by_table and item.stock_id:
stock_ids_by_table[item.source_table].add(item.stock_id)
# 步骤 2:批量查询库存表并 JOIN MaterialBase
stock_map = {} # { ('stock_buy', 101): '物料名称', ... }
model_map = {
'stock_buy': StockBuy,
'stock_semi': StockSemi,
'stock_product': StockProduct
}
for table_name, ids in stock_ids_by_table.items():
if not ids:
continue
ModelClass = model_map.get(table_name)
if not ModelClass:
continue
stocks = ModelClass.query.options(
joinedload(ModelClass.base)
).filter(ModelClass.id.in_(ids)).all()
for stock in stocks:
name = stock.base.name if stock.base else ''
stock_map[(table_name, stock.id)] = name
# 步骤 3(前置):收集 SKU 兜底候选集
empty_sku_set = set()
for item in items:
name = stock_map.get((item.source_table, item.stock_id), '')
if not name and item.sku:
empty_sku_set.add(item.sku)
# 步骤 3(前置):SKU 兜底批量查询
# 场景:库存记录被跨表转移(删旧建新)时,trans_borrow.stock_id 指向孤立记录
# 通过 sku 在三张库存表中查找任意匹配,再通过 base_id 获取 MaterialBase.name
sku_name_map = {}
if empty_sku_set:
for ModelClass in [StockProduct, StockSemi, StockBuy]:
stocks = ModelClass.query.options(
joinedload(ModelClass.base)
).filter(
ModelClass.sku.in_(empty_sku_set)
).all()
for stock in stocks:
if stock.sku not in sku_name_map and stock.base:
sku_name_map[stock.sku] = stock.base.name
# 步骤 3:为每条记录注入 material_name(含 SKU 兜底)
for item in items:
item_dict = item.to_dict()
material_name = stock_map.get((item.source_table, item.stock_id), '')
if not material_name and item.sku:
material_name = sku_name_map.get(item.sku, '')
item_dict['material_name'] = material_name
items_with_names.append(item_dict)
return {
'items': items_with_names,
'total': total_orders,
'page': page,
'limit': limit
}
except Exception as e:
# ★ 捕鼠器:把任何 SQL/运行时错误以 500 + traceback 返回,避免静默吞噬
import traceback
return {
'code': 500,
'msg': str(e),
'trace': traceback.format_exc(),
'items': [],
'total': 0,
'page': page,
'limit': limit
}