系统自陈的规则是「报废一律需审批」(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。
833 lines
40 KiB
Python
833 lines
40 KiB
Python
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_key(base_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.5(Fail-Closed):实扫明细的 source_table 必须全部可识别。
|
||
#
|
||
# 原先此处写作 `if _scanned_for_check:` —— 不在 model_map 的明细会被
|
||
# 静默过滤。若整批明细都不可识别,校验与释放被整体跳过,下方主循环
|
||
# 也逐条 `continue`,末尾却照常把 approval.status 置为 3,于是:
|
||
# · 申请阶段预占的 available_quantity 无人释放 → 幽灵库存永久锁死;
|
||
# · 单据已非 status=1,/close 与 /withdraw 都拒绝再释放;
|
||
# · 且没有任何 TransBorrow 流水可供追溯。
|
||
# 现在改为整单失败:事务回滚,单据保持 status=1、预占原样保留,
|
||
# 库管可重试执行,或走驳回/撤回 —— 任一路径都能正常归还预占。
|
||
# ==============================================================
|
||
unknown_source = sorted({
|
||
str(i.get('source_table')) for i in items
|
||
if not isinstance(i.get('source_table'), str)
|
||
or i.get('source_table') not in model_map
|
||
})
|
||
if unknown_source:
|
||
raise ValueError(
|
||
f"实扫明细的库存来源不合法:{'、'.join(unknown_source)},"
|
||
f"仅支持 {'、'.join(sorted(model_map))}"
|
||
)
|
||
|
||
# 经上方校验后 source_table 必然合法,故本列表与 items 一一对应,
|
||
# 不存在「被过滤掉的明细」。空列表由 verify_scanned 兜底报错。
|
||
_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
|
||
}
|