6 Commits

Author SHA1 Message Date
dxc
8ba1ff37f0 V3.49 2026-06-16 14:54:03 +08:00
DXC
1450e6c1de fix(借还记录列表): 按 borrow_no 单号维度分页 + 修 SQLAlchemy Row 适配错误
- 分页基准从明细行改为单号:21 项单号不再被拆到 3 页

- 步骤 1a 构造 GROUP BY borrow_no 的 subquery(sort_key + status 聚合)

- 步骤 2 主查询 SELECT order_subq.c.borrow_no 一列,避免触发 PG GROUP BY 严格模式 (f405)

- 步骤 3 用 page_borrow_nos 拉明细,保留前端 groupMap 期望的 items 形态

- pagination.items 用 isinstance + hasattr(_mapping) 兜底提取纯字符串(修 psycopg2 can't adapt type 'Row')

- service 加 try-except,路由层识别 500 透传 traceback

- status 过滤改为单号聚合(borrowed=至少一条未还,returned=全部归还)
2026-06-16 14:53:42 +08:00
dxc
b9c25ff4c5 V3.47 2026-06-16 13:57:23 +08:00
DXC
b79b0f99af fix(借库扫码出库): 撤销 joinedload 修复 PG "FOR UPDATE cannot be applied to nullable side of outer join"
- 83b3db6 引入的 joinedload(ModelClass.base) 触发 LEFT OUTER JOIN,
  而 with_for_update() 会被 SQLAlchemy 透传到 join 的 nullable 侧,
  PG 直接抛 FeatureNotSupported,且连表加锁有死锁风险
- 退回最安全的单表 FOR UPDATE 模式,接受 N+1 lazy 加载的代价
- 在 防线3 上方加防回归注释,明确禁止未来再加 joinedload
- process_return 中的另两处 joinedload 不带 FOR UPDATE,不受 PG 限制,保留
2026-06-16 13:56:11 +08:00
DXC
83b3db693a fix(借库扫码出库): 校验 key 从 (source_table, sku) 改为 (name, spec_model) + N+1 修复
- 借库申请按 (name, spec_model) 发起,审批明细无 sku 字段;
  旧代码用 sku 做 key 会导致所有条目坍塌到同一桶,校验形同虚设
- 改为在扫码循环内即时累加、即时拦截:
  防线4 锁定 stock 行后从 material_base 取真实 (name, spec_model),
  与审批单按 strip 后的 (name, spec_model) 聚合比对
- 新增 joinedload(ModelClass.base) 一次 JOIN 加载 base,
  避免循环内 stock.base 触发 N+1
- 修正 dispatch_borrow docstring 中"sku 用于超额交叉校验"的错误描述
2026-06-16 13:50:49 +08:00
DXC
bfeb397c4a fix(借库扫码出库): items 字段名 stock_id → id 修复 400 + dispatch_borrow docstring 补全 sku 字段说明 2026-06-16 13:38:51 +08:00
4 changed files with 387 additions and 244 deletions

View File

@ -131,6 +131,15 @@ def get_records():
search_type = request.args.get('search_type', 'all') search_type = request.args.get('search_type', 'all')
res = TransService.get_records(page=page, limit=10, status=status, keyword=keyword, search_type=search_type) res = TransService.get_records(page=page, limit=10, status=status, keyword=keyword, search_type=search_type)
# ★ service 层异常时code==500 的字典(带 traceback需要直通到前端便于排查
if isinstance(res, dict) and res.get('code') == 500:
return jsonify({
'code': 500,
'msg': res.get('msg', '服务内部错误'),
'trace': res.get('trace', '')
}), 500
# 字段级脱敏 # 字段级脱敏
user_permissions = get_current_user_permissions() user_permissions = get_current_user_permissions()
if res.get('items'): if res.get('items'):
@ -281,7 +290,16 @@ def dispatch_borrow():
执行借库扣减 执行借库扣减
请求体: { 请求体: {
approval_id: int, // 关联的审批单ID approval_id: int, // 关联的审批单ID
items: [...], // 扫码选中的库存物品(含 id, source_table, out_quantity items: [ // 扫码选中的库存物品
{
id: int, // 库存主键(按 source_table 路由到 StockBuy/StockSemi/StockProduct
source_table: str, // 'stock_buy' | 'stock_semi' | 'stock_product'
sku: str, // 可选;不参与审批上限校验
out_quantity: float
}
],
// ★ 审批上限校验在 service 层完成:以 (name, spec_model) 为物料维度聚合
// 锁定 stock 行后从 material_base 表取真实 (name, spec_model) 与审批单比对
borrower_name: str, borrower_name: str,
signature_path: str, signature_path: str,
remark: str, remark: str,

View File

@ -6,7 +6,7 @@ from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct from app.models.inbound.product import StockProduct
from app.models.base import MaterialBase from app.models.base import MaterialBase
from sqlalchemy import desc, func, nullslast, asc, or_, and_ from sqlalchemy import desc, func, nullslast, asc, or_, and_, case
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
@ -34,7 +34,12 @@ class TransService:
signature=None, remark=None, expected_return_time=None): signature=None, remark=None, expected_return_time=None):
""" """
执行借库扣减(审批通过后调用) 执行借库扣减(审批通过后调用)
流程:锁审批单 → 超额校验 → 锁库存行 → 扣减库存 → 生成 TransBorrow 记录 → 标记审批单完成 流程:锁审批单 → 构建审批上限字典 → 锁库存行 → 名称规格校验 → 扣减库存 → 生成 TransBorrow 记录 → 标记审批单完成
★ 关键设计:审批维度是 (name, spec_model) 而非 SKU
借库申请是按【名称 + 规格型号】发起的borrow_service 强制要求 name/spec_model/quantity 三字段),
申请时尚未绑定具体库存行;扫码出库时通过锁定 stock 行回查 material_base 表,
用 (name, spec_model) 与审批单做物料维度聚合比对,避免 sku 维度坍塌或绕过。
""" """
from app.models.borrow import BorrowApproval from app.models.borrow import BorrowApproval
@ -58,36 +63,23 @@ class TransService:
raise ValueError("审批单中未记录借库人姓名,请联系管理员补录") raise ValueError("审批单中未记录借库人姓名,请联系管理员补录")
# ============================================== # ==============================================
# ★ 防线2超额篡改校验 - 交叉比对前端传来的实际扣减量与审批上限 # ★ 防线2构建审批上限字典(按 名称+规格 聚合strip 防止匹配失败)
# Key = (name, spec_model)Value = 该物料累计允许借出数量
# ============================================== # ==============================================
approved_items = approval.get_items() approved_items = approval.get_items()
if not approved_items: if not approved_items:
raise ValueError("审批单中无物料明细,请联系管理员检查") raise ValueError("审批单中无物料明细,请联系管理员检查")
# 构建审批上限字典key=(source_table, sku) → approved_total_qty approval_limits = {}
approved_limit = {}
for ai in approved_items: for ai in approved_items:
key = (ai.get('source_table', ''), ai.get('sku', '')) key = (
qty = float(ai.get('quantity', 0)) (ai.get('name') or '').strip(),
approved_limit[key] = approved_limit.get(key, 0) + qty (ai.get('spec_model') or '').strip()
)
approval_limits[key] = approval_limits.get(key, 0) + float(ai.get('quantity', 0))
# 汇总前端传来的实际出库量(按 source_table+sku 聚合 # 累计本次扫码出库量key 与 approval_limits 完全一致
dispatch_qty = {} dispatch_acc = {}
for item in items:
key = (item.get('source_table', ''), item.get('sku', ''))
qty = float(item.get('out_quantity', 0))
dispatch_qty[key] = dispatch_qty.get(key, 0) + qty
# 逐条比对:任意一条实际出库量 > 审批上限 → 直接拒绝
for key, actual_qty in dispatch_qty.items():
limit_qty = approved_limit.get(key, 0)
if actual_qty > limit_qty:
source_table, sku = key
raise ValueError(
f"实际出库数量超出了审批单允许的上限: "
f"SKU={sku or '(无)'}({source_table}) "
f"审批上限={limit_qty}, 实际出库={actual_qty}"
)
borrow_no = TransService.generate_borrow_no() borrow_no = TransService.generate_borrow_no()
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct} model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
@ -103,12 +95,43 @@ class TransService:
# ============================================== # ==============================================
# ★ 防线3并发超卖与负库存 - 锁行后再查可用库存 # ★ 防线3并发超卖与负库存 - 锁行后再查可用库存
# ⚠️ 不要在此加 joinedload(ModelClass.base)PG 禁止 FOR UPDATE
# 应用到 outer join 的 nullable 侧,会报 FeatureNotSupported
# 并有死锁风险。stock.base 走单条 lazy 加载是已知取舍。
# ============================================== # ==============================================
stock = ModelClass.query.with_for_update().get(stock_id) stock = ModelClass.query.with_for_update().get(stock_id)
if not stock: raise ValueError(f"库存不存在 ID:{stock_id}") if not stock: raise ValueError(f"库存不存在 ID:{stock_id}")
# ==============================================
# ★ 防线4名称+规格 超额校验(动态累加、即时拦截)
# 库存表本身没有 name/spec_model 字段,通过 base 关联到 material_base
# ==============================================
if stock.base:
stock_name = (stock.base.name or '').strip()
stock_spec = (stock.base.spec_model or '').strip()
else:
stock_name = ''
stock_spec = ''
key = (stock_name, stock_spec)
limit = approval_limits.get(key)
if limit is None:
raise ValueError(
f"扫码物料【{stock_name} / {stock_spec}】不在审批单允许范围内,"
f"请检查审批单明细或重新发起申请"
)
dispatch_acc[key] = dispatch_acc.get(key, 0) + qty
current_total = dispatch_acc[key]
if current_total > limit:
raise ValueError(
f"实际出库数量超出了审批单允许的上限: "
f"物料={stock_name}({stock_spec}) "
f"审批上限={limit}, 实际扫码={current_total}"
)
if float(stock.available_quantity) < qty: if float(stock.available_quantity) < qty:
raise ValueError(f"SKU {stock.sku} 可用库存不足") raise ValueError(f"物料【{stock_name} / {stock_spec}可用库存不足")
# 1. 冻结库存 (只减可用) # 1. 冻结库存 (只减可用)
stock.available_quantity = float(stock.available_quantity) - qty stock.available_quantity = float(stock.available_quantity) - qty
@ -302,241 +325,343 @@ class TransService:
@staticmethod @staticmethod
def get_records(page=1, limit=10, status='all', keyword=None, search_type='all'): def get_records(page=1, limit=10, status='all', keyword=None, search_type='all'):
q = TransBorrow.query """
获取借还记录列表(按单号 borrow_no 维度分页,避免明细撑爆 pageSize
# 如果有关键词,需要联表搜索物料名称和规格型号 实现思路(三步走):
if keyword: 步骤 1: 构造 GROUP BY borrow_no 的"单号维度视图" subquery
# 根据 search_type 构建不同的搜索条件 (包含 borrow_no + sort_key + 状态聚合,全部聚合都在这里完成)
if search_type == 'all': 步骤 2: 用一个【纯净的列查询】从 subquery 中分页得到 page_borrow_nos
# 原有逻辑or_ 联表全局模糊搜索 → SELECT 只有 borrow_no 一列,【主查询无 GROUP BY】
# 查询 stock_buy 路径匹配的名称/规格 → 避免触发 PG "column must appear in GROUP BY" 严格模式
buy_match = db.session.query(TransBorrow.id).join( 步骤 3: 用 page_borrow_nos 拉明细 + 预加载 material_name
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( - borrowed: 单号下至少有一条 is_returned=False
StockSemi, and_( - returned: 单号下所有明细 is_returned=True
TransBorrow.stock_id == StockSemi.id, """
TransBorrow.source_table == 'stock_semi' try:
) # ====================================================================
).join( # 步骤 1a构造"单号维度"基础子查询GROUP BY borrow_no 在这里完成)
MaterialBase, StockSemi.base_id == MaterialBase.id # ====================================================================
).filter( # 单号 + 排序键(最早 expected_return_time—— 这一层只含 2 列 + GROUP BY
or_( order_subq = (
MaterialBase.name.ilike(f'%{keyword}%'), db.session.query(
MaterialBase.spec_model.ilike(f'%{keyword}%') TransBorrow.borrow_no.label('borrow_no'),
) func.min(TransBorrow.expected_return_time).label('sort_key')
).subquery() )
.group_by(TransBorrow.borrow_no)
.subquery()
)
# 查询 stock_product 路径匹配的名称/规格 # 状态聚合子查询(也是 GROUP BY borrow_no
product_match = db.session.query(TransBorrow.id).join( status_subq = (
StockProduct, and_( db.session.query(
TransBorrow.stock_id == StockProduct.id, TransBorrow.borrow_no.label('borrow_no'),
TransBorrow.source_table == 'stock_product' func.sum(
) case((TransBorrow.is_returned == False, 1), else_=0)
).join( ).label('unreturned_count')
MaterialBase, StockProduct.base_id == MaterialBase.id )
).filter( .group_by(TransBorrow.borrow_no)
or_( .subquery()
MaterialBase.name.ilike(f'%{keyword}%'), )
MaterialBase.spec_model.ilike(f'%{keyword}%')
)
).subquery()
# 合并三种来源的匹配 ID # ====================================================================
all_matches = db.session.query(buy_match.c.id).union( # 步骤 1b构造关键词命中单号子查询保留原全部 search_type 逻辑)
db.session.query(semi_match.c.id), # ====================================================================
db.session.query(product_match.c.id) keyword_conditions = None
).subquery() 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()
keyword_conditions = or_( # 查询 stock_semi 路径匹配的名称/规格
TransBorrow.borrower_name.ilike(f'%{keyword}%'), semi_match = db.session.query(TransBorrow.id).join(
TransBorrow.sku.ilike(f'%{keyword}%'), StockSemi, and_(
TransBorrow.borrow_no.ilike(f'%{keyword}%'), TransBorrow.stock_id == StockSemi.id,
TransBorrow.id.in_(all_matches) 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()
) )
elif search_type == 'no': # ====================================================================
keyword_conditions = TransBorrow.borrow_no.ilike(f'%{keyword}%') # 步骤 2纯净列查询分页SELECT 只有 order_subq.c.borrow_no 一列)
# ====================================================================
borrow_no_q = db.session.query(order_subq.c.borrow_no)
elif search_type == 'name': # 关键词过滤
keyword_conditions = TransBorrow.borrower_name.ilike(f'%{keyword}%') 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)
)
elif search_type == 'sku': # 状态过滤(按"单号聚合"判定)
keyword_conditions = TransBorrow.sku.ilike(f'%{keyword}%') if status == 'borrowed':
# 单号下至少一条未还
elif search_type == 'material_name': borrow_no_q = borrow_no_q.filter(
# 联表查询物料名称 order_subq.c.borrow_no.in_(
buy_match = db.session.query(TransBorrow.id).join( db.session.query(status_subq.c.borrow_no)
StockBuy, and_( .filter(status_subq.c.unreturned_count > 0)
TransBorrow.stock_id == StockBuy.id,
TransBorrow.source_table == 'stock_buy'
) )
).join( )
MaterialBase, StockBuy.base_id == MaterialBase.id elif status == 'returned':
).filter(MaterialBase.name.ilike(f'%{keyword}%')).subquery() # 单号下所有明细都已归还
borrow_no_q = borrow_no_q.filter(
semi_match = db.session.query(TransBorrow.id).join( order_subq.c.borrow_no.in_(
StockSemi, and_( db.session.query(status_subq.c.borrow_no)
TransBorrow.stock_id == StockSemi.id, .filter(status_subq.c.unreturned_count == 0)
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( # 排序(单号维度的 sort_key ASC
StockProduct, and_( borrow_no_q = borrow_no_q.order_by(nullslast(asc(order_subq.c.sort_key)))
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( # 分页(基准 = borrow_no 单号数)
db.session.query(semi_match.c.id), pagination = borrow_no_q.paginate(page=page, per_page=limit, error_out=False)
db.session.query(product_match.c.id) # ★ pagination.items 是 SQLAlchemy Row 对象psycopg2 无法直接 adapt Row
).subquery() # 用 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 # ★ 单号总数(修复前是明细数,分页错乱根因)
keyword_conditions = TransBorrow.id.in_(all_matches) if not page_borrow_nos:
return {
'items': [],
'total': total_orders,
'page': page,
'limit': limit
}
elif search_type == 'spec_model': # ====================================================================
# 联表查询规格型号 # 步骤 3按当前页 borrow_no 集合一次性拉出所有明细
buy_match = db.session.query(TransBorrow.id).join( # ====================================================================
StockBuy, and_( detail_records = (
TransBorrow.stock_id == StockBuy.id, TransBorrow.query
TransBorrow.source_table == 'stock_buy' .filter(TransBorrow.borrow_no.in_(page_borrow_nos))
) .order_by(TransBorrow.borrow_no.asc(), TransBorrow.id.asc())
).join( .all()
MaterialBase, StockBuy.base_id == MaterialBase.id )
).filter(MaterialBase.spec_model.ilike(f'%{keyword}%')).subquery()
semi_match = db.session.query(TransBorrow.id).join( # ============================================================
StockSemi, and_( # ★ 批量预加载物料名称三步收集ID → 批量JOIN → SKU兜底
TransBorrow.stock_id == StockSemi.id, # ============================================================
TransBorrow.source_table == 'stock_semi' items_with_names = []
) items = detail_records
).join( if items:
MaterialBase, StockSemi.base_id == MaterialBase.id # 步骤 1收集所有 (source_table, stock_id) 对
).filter(MaterialBase.spec_model.ilike(f'%{keyword}%')).subquery() 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)
product_match = db.session.query(TransBorrow.id).join( # 步骤 2批量查询库存表并 JOIN MaterialBase
StockProduct, and_( stock_map = {} # { ('stock_buy', 101): '物料名称', ... }
TransBorrow.stock_id == StockProduct.id, model_map = {
TransBorrow.source_table == 'stock_product' 'stock_buy': StockBuy,
) 'stock_semi': StockSemi,
).join( 'stock_product': StockProduct
MaterialBase, StockProduct.base_id == MaterialBase.id }
).filter(MaterialBase.spec_model.ilike(f'%{keyword}%')).subquery() for table_name, ids in stock_ids_by_table.items():
if not ids:
all_matches = db.session.query(buy_match.c.id).union( continue
db.session.query(semi_match.c.id), ModelClass = model_map.get(table_name)
db.session.query(product_match.c.id) if not ModelClass:
).subquery() continue
keyword_conditions = TransBorrow.id.in_(all_matches)
else:
keyword_conditions = None
else:
keyword_conditions = None
if keyword_conditions is not None:
q = q.filter(keyword_conditions)
if status == 'borrowed':
q = q.filter(TransBorrow.is_returned == False)
elif status == 'returned':
q = q.filter(TransBorrow.is_returned == True)
# 使用 distinct 防止跨表查询产生重复记录
q = q.distinct()
q = q.order_by(nullslast(asc(TransBorrow.expected_return_time)))
pagination = q.paginate(page=page, per_page=limit, error_out=False)
# ============================================================
# ★ 批量预加载物料名称两步收集ID → 批量 JOIN → 内存拼装)
# ============================================================
items_with_names = []
items = pagination.items
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( stocks = ModelClass.query.options(
joinedload(ModelClass.base) joinedload(ModelClass.base)
).filter( ).filter(ModelClass.id.in_(ids)).all()
ModelClass.sku.in_(empty_sku_set)
).all()
for stock in stocks: for stock in stocks:
if stock.sku not in sku_name_map and stock.base: name = stock.base.name if stock.base else ''
sku_name_map[stock.sku] = stock.base.name stock_map[(table_name, stock.id)] = name
# 步骤 3:为每条记录注入 material_name SKU 兜底 # 步骤 3(前置):收集 SKU 兜底候选集
for item in items: empty_sku_set = set()
item_dict = item.to_dict() for item in items:
material_name = stock_map.get((item.source_table, item.stock_id), '') name = stock_map.get((item.source_table, item.stock_id), '')
if not material_name and item.sku: if not name and item.sku:
material_name = sku_name_map.get(item.sku, '') empty_sku_set.add(item.sku)
item_dict['material_name'] = material_name
items_with_names.append(item_dict)
items_data = items_with_names # 步骤 3前置SKU 兜底批量查询
else: # 场景库存记录被跨表转移删旧建新trans_borrow.stock_id 指向孤立记录
items_data = [] # 通过 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
return { # 步骤 3为每条记录注入 material_name含 SKU 兜底)
'items': items_data, for item in items:
'total': pagination.total, item_dict = item.to_dict()
'page': page, material_name = stock_map.get((item.source_table, item.stock_id), '')
'limit': limit 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
}

View File

@ -239,7 +239,7 @@ const handleLogout = () => {
<footer v-if="!isLoginPage" class="app-footer"> <footer v-if="!isLoginPage" class="app-footer">
<span class="version-tag"> <span class="version-tag">
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon> <el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
当前版本:V3.47 当前版本:V3.49
</span> </span>
</footer> </footer>

View File

@ -556,7 +556,7 @@ const submitForm = async () => {
if (isNaN(safeQty) || safeQty <= 0) safeQty = 1 if (isNaN(safeQty) || safeQty <= 0) safeQty = 1
return { return {
stock_id: item.id || 0, id: item.id || 0,
source_table: item.source_table || '', source_table: item.source_table || '',
sku: item.sku ? String(item.sku) : (item.barcode ? String(item.barcode) : 'NO_SKU'), sku: item.sku ? String(item.sku) : (item.barcode ? String(item.barcode) : 'NO_SKU'),
barcode: item.barcode ? String(item.barcode) : '', barcode: item.barcode ? String(item.barcode) : '',