Files
KCGL/inventory-backend/app/services/trans_service.py
yueli cccd6f6081 feat(borrow): 转交接口、身份ID锚点与流转时间线后端
责任链收口
----
· execute_dispatch:强制 borrower_id,姓名一律由 sys_user 反查,**不回退**到
  申请单姓名 —— 申请单上的姓名是「申请意向」,与扫码时实际来领的人本就可能
  不同(库管代建场景尤甚),回退会让 current_holder 从第一刻就记错人。
  同时落库 dispatch_operator,补齐「谁经手发货」。
· process_return:新增 returner_id,记录有 current_holder_id 时强校验二者相等,
  不符即整单回滚(原实现只要持有 op_return:operation 即可归还任何人借出的
  物品,函数内从不读取 borrower_name,归还环节责任链是断的);
  每次归还写 trans_borrow_return 流水;全量归还清空 current_holder
  (borrower_id 保留作历史)。
· transfer_borrow(新):整单全量转交,写转交流水 + 推进主表 current_holder。

为什么一期只允许整单全量转交
----
trans_borrow 是单行模型,只能存一个 current_holder_id。部分转交(借 10 转 5)
会让这一行同时表示两个人,语义直接撕裂,且归还时无法判定该由谁还。
若未来需支持,必须改为按数量**拆行**(新建一条承接转出量、原行扣减),
而不是在单行上加字段打补丁。

为什么转交绝不触碰库存
----
借出期间 available 已冻结、stock 仍含借出未还量(deduct_stock=False)。
转交若动库存,会同时破坏两个既有假设:「归还只加 available」会算多,
「借库转报废在确认损失时扣 stock」(scrap_sources.BorrowScrapAdapter)
会重复扣减。

接口
----
· POST /borrow/<id>/transfer      转交(borrow_transfer 权限 + 防抖锁 + 行锁)
· GET  /borrow/<id>/history       单品流转历史(供精确追溯)
· GET  /borrow/slip/<no>/history  整单时间线(实测单号最多 21 条明细,
                                  逐条调用会产生 21 个请求,故聚合返回)
· GET  /borrow/users              人员名单(借出/转交/归还共用,公司隔离)

一个隐蔽缺陷(本轮发现并修复)
----
整单时间线初版按展示用的时间字符串排序,而该字符串截断到**秒** —— 同一秒内的
连续动作(如「转交 A→B 紧接着转交 B→C」)会退化为并列,顺序取决于数据库返回
顺序,时间线随机错乱。已改为按真实 datetime 排序,并加回归用例锁定。
2026-09-17 09:17:57 +08:00

1332 lines
65 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import uuid # .material -> .base refactor checked
from datetime import datetime
from app.extensions import db, beijing_time
from app.models.transaction import TransBorrow, TransBorrowTransfer, TransBorrowReturn
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
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("无权操作其他公司的借用记录")
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,
signature=None, remark=None, expected_return_time=None,
borrower_id=None):
"""
执行借库扣减(审批通过后调用)
流程:锁审批单 → 构建审批上限字典 → 锁库存行 → 名称规格校验 → 扣减库存 → 生成 TransBorrow 记录 → 标记审批单完成
★ 关键设计:审批维度是 (name, spec_model) 而非 SKU
借库申请是按【名称 + 规格型号】发起的borrow_service 强制要求 name/spec_model/quantity 三字段),
申请时尚未绑定具体库存行;扫码出库时通过锁定 stock 行回查 material_base 表,
用 (name, spec_model) 与审批单做物料维度聚合比对,避免 sku 维度坍塌或绕过。
★ borrower_id一期转交改造后为**必填**
实际借用人ID。borrower_name 参数仅为向后兼容而保留 —— 落库时一律
以 borrower_id 反查 sys_user 得到的姓名为准,传参中的姓名被忽略。
"""
from app.models.borrow import BorrowApproval
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)}】,无法执行借库")
# ==============================================
# ★ 借用人身份锚定(一期转交改造):强制 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:
raise ValueError(f"借用人ID:{borrower_id})用户名为空,无法生成台账快照")
# ==============================================
# ★ 防线2构建审批上限字典
#
# 改造说明:原先以 (name, spec_model) 聚合,但本系统中 SKU 是**批次级**
# 编号(同一物料不同批次 SKU 不同),而 name+spec 又是字符串比较,
# 易受空格/别名影响。现统一改用 identity_keybase_id 主键 +
# name/spec 兜底),与出库、报废三个模块共用同一套身份语义。
# ==============================================
approved_items = approval.get_items()
if not approved_items:
raise ValueError("审批单中无物料明细,请联系管理员检查")
from app.services.inventory_reservation import (
build_approval_index, verify_scanned, restore_then_deduct, identity_label,
)
approval_idx = build_approval_index(approved_items)
borrow_no = TransService.generate_borrow_no()
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
# ★ 防止死锁:按 (source_table, id) 排序,保证所有并发请求以相同顺序获取行锁
items.sort(key=lambda x: (x.get('source_table', ''), x.get('id', 0)))
# ==============================================================
# ★ Phase 3预占再平衡
# 借库申请阶段已预占具体批次;工人实扫的可能是同物料的另一批次。
# 1. 校验实扫身份/数量未超批准范围base_id 主键,允许换批次)
# 2. 释放全部预占
# 3. 对实扫批次扣减 available_quantity★ 不动 stock_quantity
# 下方主循环只写 TransBorrow 流水,不再重复扣库存。
#
# ★ deduct_stock=False借出是**可逆的**(会归还),只冻结可用数。
# 若此处扣了实物,会与两处冲突:
# · 归还process_return只加 available —— 一借一还后 stock 永久少一份;
# · 借库转报废在确认损失时扣 stock其实现明确假设「可用库存已在
# 借出时冻结」—— 若借出已扣会重复扣减。
# (该实现原为 TransService.scrap_borrow现已迁入
# app/services/scrap_sources.py 的 BorrowScrapAdapter.deduct
# ==============================================================
# ★ 防线 2.5Fail-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 兜底报错。
_scanned_for_check = [
{'source_table': i.get('source_table'), 'stock_id': i.get('id'),
'quantity': i.get('out_quantity')}
for i in items
]
verify_scanned(_scanned_for_check, approved_items)
restore_then_deduct(_scanned_for_check, approved_items, deduct_stock=False)
# 累计本次扫码出库量用于下方防线4的二次校验
dispatch_acc = {}
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)
if not stock: raise ValueError(f"库存不存在 ID:{stock_id}")
# ==============================================
# ★ 身份与数量校验已由上方 verify_scanned() 统一完成
# base_id 主键匹配,允许同物料换批次;累计量不得超批准量)
#
# 此处仅做一次「本次扫码累计」的防御性复核,防止并发下
# 同一请求内重复 stock_id 被重复计数。
# 库存扣减也已在 restore_then_deduct() 完成 ——
# 下方**不再**扣减 available_quantity否则会扣两次。
# ==============================================
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
# 创建借用记录
record = TransBorrow(
borrow_no=borrow_no,
sku=stock.sku,
source_table=source_table,
stock_id=stock.id,
barcode=stock.barcode,
quantity=qty,
# ★ 借出即由借用人持有borrower_id 为初始借用人(写一次不再变),
# current_holder_* 为当前持有人 —— 转交会在此之上继续推进。
borrower_id=int(borrower_id),
borrower_name=borrower_name,
current_holder_id=int(borrower_id),
current_holder_name=borrower_name,
# ★ 显式置 0归还逻辑按 returned_quantity 累加并判断是否还清,
# 不依赖 DB 列默认值(避免 ORM/DB 默认值口径不一致时算错待还量)。
returned_quantity=0,
borrow_signature=signature,
# ★ 发货操作人执行本次借出的库管。operator_name 此前被接收
# 却从未落库,责任链上「谁经手发货」一直缺失,此处补齐。
dispatch_operator=operator_name,
remark=remark,
expected_return_time=expected_return_time,
# [新增] 记录借出时的库位快照
location=getattr(stock, 'warehouse_location', None),
status='borrowed',
is_returned=False
)
db.session.add(record)
# ★ 3. 标记审批单为已完成
approval.status = 3
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'),
borrower_id=data.get('borrower_id'),
signature=data.get('signature_path'),
remark=data.get('remark'),
expected_return_time=data.get('expected_return_time')
)
@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
def process_return(data, operator_name, returner_id=None):
"""
还库逻辑(支持部分归还)- 已优化,消除 N+1 和长事务死锁风险
四步走策略:
1. 收集所有 borrow_id
2. 批量锁定借用记录
3. 收集库存ID并批量锁定库存
4. 内存中完成业务逻辑
参数
----
operator_name : 经手办理还库的**库管**姓名(窗口操作人)
returner_id : 实际把物品交回窗口的**归还人**ID一期转交改造新增
记录有 current_holder_id 时强制校验二者一致 ——
详见循环内的持有人校验块。
"""
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
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内存中完成业务逻辑
# ==========================================
# ★ 时间口径修复:原实现用 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:
continue
# ==========================================
# ★ 持有人强校验(一期转交改造)
#
# 原实现只要持有 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
# ==========================================
# 更新归还数量和状态
#
# ★ 主表字段的定位(一期转交改造后):
# 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'
# ★ 已全部还清:物品回到仓库,无人持有。
# 清空 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'
record.return_time = return_now
record.return_operator = operator_name
record.return_signature = signature
if final_location:
record.return_location = final_location
# ★ 逐次归还落流水(治失忆症)
# 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_holderID
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:
db.session.commit()
except Exception as e:
db.session.rollback()
raise e
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),
}
@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)
)
)
# ★ 默认排序(多级复合,符合"优先关注快到期/逾期"业务):
# 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
}