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()
# 汇总前端传来的实际出库量(按 source_table+sku 聚合)
dispatch_qty = {}
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}"
) )
approval_limits[key] = approval_limits.get(key, 0) + float(ai.get('quantity', 0))
# 累计本次扫码出库量key 与 approval_limits 完全一致)
dispatch_acc = {}
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,9 +325,51 @@ 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
# 如果有关键词,需要联表搜索物料名称和规格型号 实现思路(三步走):
步骤 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
"""
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')
)
.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: if keyword:
# 根据 search_type 构建不同的搜索条件 # 根据 search_type 构建不同的搜索条件
if search_type == 'all': if search_type == 'all':
@ -448,30 +513,82 @@ class TransService:
keyword_conditions = TransBorrow.id.in_(all_matches) keyword_conditions = TransBorrow.id.in_(all_matches)
else: # 把"命中的单号"独立成 subquery供主查询做 IN 过滤
keyword_conditions = None keyword_borrow_nos_subq = None
else:
keyword_conditions = None
if keyword_conditions is not None: if keyword_conditions is not None:
q = q.filter(keyword_conditions) keyword_borrow_nos_subq = (
db.session.query(TransBorrow.borrow_no)
.filter(keyword_conditions)
.distinct()
.subquery()
)
# ====================================================================
# 步骤 2纯净列查询分页SELECT 只有 order_subq.c.borrow_no 一列)
# ====================================================================
borrow_no_q = db.session.query(order_subq.c.borrow_no)
# 关键词过滤
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 status == 'borrowed': if status == 'borrowed':
q = q.filter(TransBorrow.is_returned == False) # 单号下至少一条未还
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': elif status == 'returned':
q = q.filter(TransBorrow.is_returned == True) # 单号下所有明细都已归还
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)
)
)
# 使用 distinct 防止跨表查询产生重复记录 # 排序(单号维度的 sort_key ASC
q = q.distinct() borrow_no_q = borrow_no_q.order_by(nullslast(asc(order_subq.c.sort_key)))
q = q.order_by(nullslast(asc(TransBorrow.expected_return_time))) # 分页(基准 = borrow_no 单号数)
pagination = q.paginate(page=page, per_page=limit, error_out=False) 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 → 内存拼装 # ★ 批量预加载物料名称(收集ID → 批量JOIN → SKU兜底
# ============================================================ # ============================================================
items_with_names = [] items_with_names = []
items = pagination.items items = detail_records
if items: if items:
# 步骤 1收集所有 (source_table, stock_id) 对 # 步骤 1收集所有 (source_table, stock_id) 对
stock_ids_by_table = {'stock_buy': set(), 'stock_semi': set(), 'stock_product': set()} stock_ids_by_table = {'stock_buy': set(), 'stock_semi': set(), 'stock_product': set()}
@ -530,13 +647,21 @@ class TransService:
item_dict['material_name'] = material_name item_dict['material_name'] = material_name
items_with_names.append(item_dict) items_with_names.append(item_dict)
items_data = items_with_names
else:
items_data = []
return { return {
'items': items_data, 'items': items_with_names,
'total': pagination.total, '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, 'page': page,
'limit': limit '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) : '',