Files
KCGL/inventory-backend/app/services/trans_service.py
yueli 58fa42bff3 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

833 lines
40 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
from app.models.transaction import TransBorrow
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
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):
"""
执行借库扣减(审批通过后调用)
流程:锁审批单 → 构建审批上限字典 → 锁库存行 → 名称规格校验 → 扣减库存 → 生成 TransBorrow 记录 → 标记审批单完成
★ 关键设计:审批维度是 (name, spec_model) 而非 SKU
借库申请是按【名称 + 规格型号】发起的borrow_service 强制要求 name/spec_model/quantity 三字段),
申请时尚未绑定具体库存行;扫码出库时通过锁定 stock 行回查 material_base 表,
用 (name, spec_model) 与审批单做物料维度聚合比对,避免 sku 维度坍塌或绕过。
"""
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)}】,无法执行借库")
# ★ borrower_name 兜底:优先用前端传参,其次从审批单读取(申请时填写的姓名)
if not borrower_name:
borrower_name = approval.borrower_name
if not borrower_name:
raise ValueError("审批单中未记录借库人姓名,请联系管理员补录")
# ==============================================
# ★ 防线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_name=borrower_name,
borrow_signature=signature,
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'),
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):
"""
还库逻辑(支持部分归还)- 已优化,消除 N+1 和长事务死锁风险
四步走策略:
1. 收集所有 borrow_id
2. 批量锁定借用记录
3. 收集库存ID并批量锁定库存
4. 内存中完成业务逻辑
"""
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内存中完成业务逻辑
# ==========================================
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
# 计算待还数量
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
# 更新归还数量和状态
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'
else:
record.is_returned = False
record.status = 'partial_returned'
record.return_time = datetime.now()
record.return_operator = operator_name
record.return_signature = signature
if final_location:
record.return_location = final_location
db.session.commit()
except Exception as e:
db.session.rollback()
raise e
@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
}