问题
----
列表里那一列标着「归还人」,读的却是 trans_borrow.return_operator —— 而该字段
存的是**办理还库的库管**,不是来还东西的人。两者本就是不同的人:
· returner_id(trans_borrow_return)—— 把东西交回窗口的人,已校验 == 当时持有人
· return_operator —— 经手办理的库管
实测(借用行 120):return_operator = 杜邢宸/duxingchen(库管),
而实际归还人是 returner_id = 21(测试)—— 页面却显示成了「杜邢宸」。
改动
----
一、后端 get_records 增补 returners:从 trans_borrow_return 取 returner_id 并
反查 sys_user 得到姓名(去重按明细挂回)。批量查一次,不做 N+1。
二、前端拆成两列,各自名副其实:
「归还人」 ← returners(后端新增)
「经手库管」 ← return_operators(原列改为正确标签)
三、顺带修展示口径:return_operator 存的是**完整 username**(高闯/gaochuang),
未按全站口径截断。_display_borrow_operator 现统一归一到展示名
(数字 id 反查 / 姓名、斜杠前段 / 已是展示名 原样),并修正其 docstring
—— 它原本也把该字段称作「归还人」,是同一个误解的源头。
★ 历史数据的现实
实际归还人流水是二期才建的,**历史归还没有这个记录**。这部分行的「归还人」
显示为空并挂 tooltip 说明「该笔归还发生在实际归还人记录上线之前」——
刻意不拿库管的名字顶上,那正是本次要修的错。
验证
BOR-20260918-0001 → 归还人=['测试']、经手库管='杜邢宸' ✓ 两者分开
BOR-20260914-0001 → 归还人=[](历史)、经手库管='高闯' ✓
归一化:'高闯/gaochuang'→'高闯'、'21'→'测试' ✓
前端 vite build 通过;本次无需 DB 迁移。
1660 lines
83 KiB
Python
1660 lines
83 KiB
Python
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,
|
||
TRANSFER_STATUS_PENDING, TRANSFER_STATUS_ACCEPTED, TRANSFER_STATUS_REJECTED,
|
||
)
|
||
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_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_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
|
||
|
||
# ==========================================================================
|
||
# 借库转交(双向握手 + 明细行粒度)
|
||
#
|
||
# 状态机:
|
||
# PENDING ──accept──> ACCEPTED (主表 current_holder 正式转移)
|
||
# └───reject──> REJECTED (主表不动,责任仍在原持有人)
|
||
#
|
||
# ★ 转交粒度 = **明细行**(trans_borrow.id),不是整张单。
|
||
# 物理现场经常只转交部分工具(借了 2 件、只把 1 件给别人),
|
||
# 一张单下的不同明细归属不同持有人是**正常业务形态**,不是需要修复的
|
||
# 「单内撕裂」。前端按单号聚合时需自行处理「多人持有」的展示。
|
||
#
|
||
# (唯一性约束也随之从「单号至多一条 PENDING」下沉为「明细行至多一条」,
|
||
# 故同单的其他明细可以同时各自挂着待接收,互不阻塞。)
|
||
# ==========================================================================
|
||
@staticmethod
|
||
def transfer_borrow(borrow_id, to_user_id, transfer_qty=None, operator_name='System', remark=None,
|
||
caller_user_id=None):
|
||
"""
|
||
发起转交(双向握手第一步):只落一条 PENDING 流水,**不动物权**。
|
||
|
||
东西还没到接收人手上,责任仍由原持有人承担 —— 这是与旧实现最本质的
|
||
区别:旧实现「发起即生效」,接收人在毫不知情的情况下被强塞了资产责任。
|
||
|
||
参数
|
||
----
|
||
borrow_id : 该单**任一明细行**的 ID,仅用于解析单据身份(borrow_no)
|
||
to_user_id : 接收人ID
|
||
transfer_qty : 可选。传入时须等于该明细待还量,仅作一致性校验
|
||
operator_name: 操作人展示名(写入流水备查)
|
||
remark : 转交备注
|
||
caller_user_id: **调用者本人ID**,强校验其必须是该明细的当前持有人。
|
||
传 None 一律拒绝,不做「系统内部调用」的隐式放行。
|
||
|
||
返回已 commit 的 TransBorrowTransfer
|
||
异常 ValueError
|
||
"""
|
||
from app.models.system import SysUser
|
||
|
||
if to_user_id is None:
|
||
raise ValueError("缺少接收人 to_user_id")
|
||
try:
|
||
to_user_id = int(to_user_id)
|
||
except (TypeError, ValueError):
|
||
raise ValueError("接收人 to_user_id 格式无效,应为数字ID")
|
||
|
||
# ★ 转交粒度 = **明细行**(trans_borrow.id),不是整张单。
|
||
# 物理现场经常只转交部分工具(借了 2 件、只把 1 件给别人),
|
||
# 一张单下的不同明细本就允许归属不同持有人 —— 那是正常业务形态,
|
||
# 不是需要修复的「撕裂」。
|
||
record = TransBorrow.query.with_for_update().get(borrow_id)
|
||
if not record:
|
||
raise ValueError("借出记录不存在")
|
||
borrow_no = record.borrow_no
|
||
|
||
# --- 1. 状态准入:这一行必须还在外 ---
|
||
if record.is_returned:
|
||
raise ValueError("该明细已归还,无可转交的实物")
|
||
if record.status == 'scrapped':
|
||
raise ValueError("该明细已转入报废流程,不可转交")
|
||
|
||
# --- 2. 数量:整行转交 ---
|
||
# 一行只能有一个 current_holder_id,故不支持「同一行只转一部分」——
|
||
# 那需要把这行拆成两行。经业务确认,现场场景中「借 2 件转 1 件」
|
||
# 的两件本就是两条明细行,故此限制不影响实际使用。
|
||
pending_qty = float(record.quantity or 0) - float(record.returned_quantity or 0)
|
||
if pending_qty <= 0:
|
||
raise ValueError("该明细待还数量为 0,无可转交的实物")
|
||
if transfer_qty is not None:
|
||
try:
|
||
transfer_qty = float(transfer_qty)
|
||
except (TypeError, ValueError):
|
||
raise ValueError("转交数量格式无效,应为数字")
|
||
if abs(transfer_qty - pending_qty) > 1e-6:
|
||
raise ValueError(
|
||
f"转交粒度是整条明细:该明细待还 {pending_qty},"
|
||
f"本次填写 {transfer_qty}。若需转交其中一部分,"
|
||
f"该部分应为另一条明细行。"
|
||
)
|
||
|
||
# --- 3. 转出方 = 该明细当前的持有人 ---
|
||
if record.current_holder_id is None:
|
||
raise ValueError("该明细的当前持有人未锚定(历史数据),无法转交,请先办理归还")
|
||
from_id = int(record.current_holder_id)
|
||
from_name = record.current_holder_name or user_display_name(SysUser.query.get(from_id))
|
||
|
||
# --- 3.5 责任链隔离:只有当前持有人本人可以发起转交 ---
|
||
# 物品在谁手上,就只能由谁把它交出去 —— 否则任何人都能把别人保管的
|
||
# 资产「转」给第三方,责任链形同虚设。
|
||
# ★ 前端隐藏按钮只是降噪,**这里才是真正的边界**:接口可被直接调用。
|
||
if caller_user_id is None or int(caller_user_id) != from_id:
|
||
raise ValueError(
|
||
f"只有该物品的当前持有人【{from_name}】本人可以发起转交"
|
||
)
|
||
|
||
# --- 4. 接收人校验 ---
|
||
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 == from_id:
|
||
raise ValueError(f"接收人与当前持有人同为【{to_user_name}】,无需转交")
|
||
|
||
# --- 5. 唯一性下沉到明细行:同一行至多一条待接收 ---
|
||
# (同一张单的**其他**明细可以同时各自挂一条,互不影响 ——
|
||
# 这正是部分转交要表达的语义)
|
||
pending = TransBorrowTransfer.query.filter(
|
||
TransBorrowTransfer.borrow_id == record.id,
|
||
TransBorrowTransfer.status == TRANSFER_STATUS_PENDING,
|
||
).first()
|
||
if pending:
|
||
raise ValueError(
|
||
f"该物品已有一条待接收的转交(接收人:"
|
||
f"{pending.to_user_name or pending.to_user_id}),请等待对方处理"
|
||
)
|
||
|
||
# --- 6. 行级公司隔离(Fail-Closed)---
|
||
_assert_borrow_company_visible(record)
|
||
|
||
# ==================================================================
|
||
# ★ 只写台账,主表 current_holder **保持不变** —— 双向握手的关键。
|
||
# 库存字段更是一律不碰(转交是纯持有权变更,实物不出入库)。
|
||
# ==================================================================
|
||
transfer = TransBorrowTransfer(
|
||
borrow_id=record.id,
|
||
borrow_no=borrow_no,
|
||
status=TRANSFER_STATUS_PENDING,
|
||
from_user_id=from_id,
|
||
from_user_name=from_name,
|
||
to_user_id=to_user_id,
|
||
to_user_name=to_user_name,
|
||
transfer_qty=pending_qty,
|
||
transfer_time=beijing_time(),
|
||
operator_name=operator_name,
|
||
remark=remark,
|
||
)
|
||
db.session.add(transfer)
|
||
try:
|
||
db.session.commit()
|
||
except Exception as e:
|
||
db.session.rollback()
|
||
raise e
|
||
return transfer
|
||
|
||
@staticmethod
|
||
def accept_transfer(transfer_id, user_id):
|
||
"""
|
||
接收转交(双向握手第二步):流水置 ACCEPTED,并**正式转移持有权**。
|
||
|
||
覆盖范围 = 该转交指向的**单条明细行**。同一张单的其他明细不受影响 ——
|
||
「借 2 件只转 1 件」时,那 1 件到接收人名下,另 1 件仍在原持有人手上,
|
||
这是正常业务形态。
|
||
|
||
权限:仅 to_user_id 本人(这是员工对自己名下资产的确认,不是库管权限)。
|
||
返回 (transfer, 被转移的明细行)
|
||
"""
|
||
transfer = TransBorrowTransfer.query.with_for_update().get(transfer_id)
|
||
if not transfer:
|
||
raise ValueError("转交记录不存在")
|
||
if transfer.status != TRANSFER_STATUS_PENDING:
|
||
raise ValueError(f"该转交已【{transfer.to_dict()['status_text']}】,无法重复处理")
|
||
if transfer.to_user_id is None or int(transfer.to_user_id) != int(user_id):
|
||
raise ValueError("只有该转交的接收人本人可以确认接收")
|
||
if not transfer.borrow_no:
|
||
raise ValueError("该转交记录缺少单号(历史数据),无法确认接收")
|
||
|
||
# ★ 只转移 transfer.borrow_id 指向的**那一行**:
|
||
# 转交粒度是明细行,同单的其他明细可能挂在别人名下(部分转交),
|
||
# 整批改写会把别人手上的东西一并抢过来。
|
||
record = TransBorrow.query.with_for_update().get(transfer.borrow_id)
|
||
if not record:
|
||
raise ValueError("转交目标明细已不存在(可能已被删除)")
|
||
if record.is_returned:
|
||
raise ValueError("该明细已归还,无需接收")
|
||
|
||
to_name = transfer.to_user_name
|
||
if not to_name:
|
||
from app.models.system import SysUser
|
||
to_name = user_display_name(SysUser.query.get(transfer.to_user_id))
|
||
|
||
record.current_holder_id = int(transfer.to_user_id)
|
||
record.current_holder_name = to_name
|
||
|
||
transfer.status = TRANSFER_STATUS_ACCEPTED
|
||
try:
|
||
db.session.commit()
|
||
except Exception as e:
|
||
db.session.rollback()
|
||
raise e
|
||
return transfer, record
|
||
|
||
@staticmethod
|
||
def reject_transfer(transfer_id, user_id, reason=None):
|
||
"""
|
||
拒绝转交:流水置 REJECTED,**主表不动** —— 责任仍在原持有人。
|
||
权限:仅 to_user_id 本人。
|
||
"""
|
||
transfer = TransBorrowTransfer.query.with_for_update().get(transfer_id)
|
||
if not transfer:
|
||
raise ValueError("转交记录不存在")
|
||
if transfer.status != TRANSFER_STATUS_PENDING:
|
||
raise ValueError(f"该转交已【{transfer.to_dict()['status_text']}】,无法重复处理")
|
||
if transfer.to_user_id is None or int(transfer.to_user_id) != int(user_id):
|
||
raise ValueError("只有该转交的接收人本人可以拒绝")
|
||
|
||
transfer.status = TRANSFER_STATUS_REJECTED
|
||
# ★ 原因写独立列,不再拼进 remark:
|
||
# 拼接会让前端拿到「3333\n[拒绝原因] 5555」这样一坨,
|
||
# 分不清哪句是发起备注、哪句是拒收原因;而且用户自己在备注里
|
||
# 打出同样字样时,任何按标记切分的解析都会误判。
|
||
transfer.reject_reason = (reason or '').strip() or None
|
||
try:
|
||
db.session.commit()
|
||
except Exception as e:
|
||
db.session.rollback()
|
||
raise e
|
||
return transfer
|
||
|
||
@staticmethod
|
||
def count_pending_transfers(user_id):
|
||
"""
|
||
「待我接收」的转交数量 —— 供全局待办提醒在初始化与轮询时调用。
|
||
|
||
★ 刻意做成极轻量:一次 count,不联表、不解析物料名。
|
||
轮询接口必须便宜,否则会从「提醒」变成「后台噪音」。
|
||
"""
|
||
if user_id is None:
|
||
return 0
|
||
try:
|
||
return TransBorrowTransfer.query.filter(
|
||
TransBorrowTransfer.to_user_id == int(user_id),
|
||
TransBorrowTransfer.status == TRANSFER_STATUS_PENDING,
|
||
).count()
|
||
except (TypeError, ValueError):
|
||
return 0
|
||
|
||
@staticmethod
|
||
def get_unseen_rejects(user_id, limit=20):
|
||
"""
|
||
「我发起、被对方拒绝、且尚未告知我」的转交 —— 供全局提醒使用。
|
||
|
||
为什么必须告知发起方
|
||
-------------------
|
||
双向握手补上了「接收人确认」,但只做了单向告知:接收人能看到待办,
|
||
发起方却对结果一无所知。**被拒绝时物品责任仍在发起方手上** ——
|
||
他若不主动查列表,就会误以为已经交接出去,责任链出现静默断点。
|
||
(ACCEPTED 不需要告知:东西已经交出去了,发起方无需动作。)
|
||
|
||
★ 为什么用持久标记而不是前端去重:
|
||
换台电脑、换个浏览器就会重新提醒;而这条信息的分量(责任归属)
|
||
值得一个持久标记。前端确认后调 ack_rejects 写 reject_seen_at。
|
||
|
||
★ 同时解析出物料名:只说「某笔转交被拒」发起方仍不知是哪件东西还在
|
||
自己手上,必须让他一眼认出来。批量查一次,不做 N+1。
|
||
"""
|
||
if user_id is None:
|
||
return []
|
||
try:
|
||
uid = int(user_id)
|
||
except (TypeError, ValueError):
|
||
return []
|
||
|
||
rows = (TransBorrowTransfer.query
|
||
.filter(TransBorrowTransfer.from_user_id == uid,
|
||
TransBorrowTransfer.status == TRANSFER_STATUS_REJECTED,
|
||
TransBorrowTransfer.reject_seen_at.is_(None))
|
||
.order_by(TransBorrowTransfer.id.asc())
|
||
.limit(limit)
|
||
.all())
|
||
if not rows:
|
||
return []
|
||
|
||
# 批量解析物料名(含 SKU 兜底),与列表页同口径
|
||
records = {r.id: r for r in TransBorrow.query.filter(
|
||
TransBorrow.id.in_({t.borrow_id for t in rows if t.borrow_id})
|
||
).all()}
|
||
stock_ids_by_table = {}
|
||
for r in records.values():
|
||
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}
|
||
name_map = {}
|
||
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():
|
||
name_map[(table_name, stock.id)] = stock.base.name if stock.base else ''
|
||
|
||
out = []
|
||
for t in rows:
|
||
d = t.to_dict()
|
||
rec = records.get(t.borrow_id)
|
||
d['sku'] = rec.sku if rec else None
|
||
d['material_name'] = (
|
||
name_map.get((rec.source_table, rec.stock_id), '') if rec else ''
|
||
) or (rec.sku if rec else '')
|
||
out.append(d)
|
||
return out
|
||
|
||
@staticmethod
|
||
def ack_rejects(user_id, ids=None):
|
||
"""
|
||
标记「被拒绝」提醒已告知 —— 由发起方在前端确认后调用。
|
||
|
||
ids 为空则标记该用户全部待告知的拒绝(前端一次确认通常就是全部)。
|
||
返回本次标记的条数。不做「未告知就重复弹」以外的任何副作用。
|
||
"""
|
||
if user_id is None:
|
||
return 0
|
||
try:
|
||
uid = int(user_id)
|
||
except (TypeError, ValueError):
|
||
return 0
|
||
|
||
q = TransBorrowTransfer.query.filter(
|
||
TransBorrowTransfer.from_user_id == uid,
|
||
TransBorrowTransfer.status == TRANSFER_STATUS_REJECTED,
|
||
TransBorrowTransfer.reject_seen_at.is_(None),
|
||
)
|
||
if ids:
|
||
q = q.filter(TransBorrowTransfer.id.in_(ids))
|
||
|
||
now = beijing_time()
|
||
marked = 0
|
||
for t in q.all():
|
||
t.reject_seen_at = now
|
||
marked += 1
|
||
try:
|
||
db.session.commit()
|
||
except Exception as e:
|
||
db.session.rollback()
|
||
raise e
|
||
return marked
|
||
|
||
@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,
|
||
# ★ 转交状态:不带出来的话,被拒绝的转交在时间线上与成功的
|
||
# 长得一模一样,发起方翻记录时会以为已经交接出去。
|
||
'status': t.status,
|
||
'status_text': t.to_dict().get('status_text'),
|
||
# 拒收原因独立带出,前端才能与「转交备注」分行展示
|
||
'reject_reason': t.reject_reason,
|
||
'_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, viewer_user_id=None, current_user_id=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'),
|
||
# 「已归还」页签的排序键:单号内**最晚**一次归还时间
|
||
# (多明细分批归还时,整单结清的那一刻才是有意义的节点)
|
||
func.max(TransBorrow.return_time).label('max_return_time')
|
||
)
|
||
.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)
|
||
|
||
# ====================================================================
|
||
# ★ 数据权限:普通用户能看到「与自己有关」的借还记录,三种关系任一成立:
|
||
# ① 我是借用人
|
||
# ② 我是**当前持有人** —— 转交接收后东西在我手上,此前只比对
|
||
# borrower_name,接收人在自己的列表里根本看不到该单
|
||
# ③ 有一条**待我接收**的转交(PENDING)—— 东西还在对方手上、
|
||
# 主表尚未转移,②匹配不到,必须单独并入,否则接收人看不到
|
||
# 待办、无从确认
|
||
# ID 与姓名双口径并存:新数据有 ID 锚点,历史行只有姓名。
|
||
# ====================================================================
|
||
if borrower_name or viewer_user_id:
|
||
own_conds = []
|
||
if borrower_name:
|
||
# 兼容库里存成「姓名/xiaolongxia」全名的情况
|
||
own_conds.append(TransBorrow.borrower_name == borrower_name)
|
||
own_conds.append(TransBorrow.borrower_name.like(f"{borrower_name}/%"))
|
||
own_conds.append(TransBorrow.current_holder_name == borrower_name)
|
||
own_conds.append(TransBorrow.current_holder_name.like(f"{borrower_name}/%"))
|
||
if viewer_user_id:
|
||
own_conds.append(TransBorrow.borrower_id == viewer_user_id)
|
||
own_conds.append(TransBorrow.current_holder_id == viewer_user_id)
|
||
|
||
own_borrow_nos_subq = (
|
||
db.session.query(TransBorrow.borrow_no)
|
||
.filter(or_(*own_conds))
|
||
.distinct()
|
||
.subquery()
|
||
)
|
||
|
||
if viewer_user_id:
|
||
pending_to_me_subq = (
|
||
db.session.query(TransBorrowTransfer.borrow_no)
|
||
.filter(
|
||
TransBorrowTransfer.to_user_id == viewer_user_id,
|
||
TransBorrowTransfer.status == TRANSFER_STATUS_PENDING,
|
||
TransBorrowTransfer.borrow_no.isnot(None),
|
||
)
|
||
.distinct()
|
||
.subquery()
|
||
)
|
||
borrow_no_q = borrow_no_q.filter(or_(
|
||
order_subq.c.borrow_no.in_(own_borrow_nos_subq),
|
||
order_subq.c.borrow_no.in_(pending_to_me_subq),
|
||
))
|
||
else:
|
||
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)
|
||
)
|
||
)
|
||
|
||
# ★ 排序按页签分开:
|
||
#
|
||
# 「已归还」页签 —— 按**归还时间倒序**(从近到远)。
|
||
# 这张列表此时回答的是「最近还了哪几笔」,而不是「哪笔快到期」,
|
||
# 所以不能沿用未归还那套「逾期优先」的排序,否则最近刚还的
|
||
# 反而排在最后。取单号内**最晚**一次归还时间:多明细分批归还时,
|
||
# 整单结清的那一刻才是有意义的节点,也与主行「归还时间」列的
|
||
# 展示口径一致(前端同样取 latest)。
|
||
#
|
||
# 其余页签(全部 / 未归还)—— 沿用「优先关注快到期/逾期」:
|
||
# 1) 有限期单(含 expected_return_time)排前,无限期单排后
|
||
# 2) 有限期内按最早预计归还时间 ASC(越快到期/逾期越久越靠前)
|
||
# 3) 无限期内按最早借出时间 DESC(从近到远)
|
||
_order_by = (
|
||
[nullslast(desc(order_subq.c.max_return_time)),
|
||
desc(order_subq.c.borrow_no)] # 同一时刻的稳定兜底
|
||
if status == 'returned' else
|
||
[case((order_subq.c.has_finite == 0, 1), else_=0).asc(),
|
||
nullslast(asc(order_subq.c.sort_key)),
|
||
# ★ 无限期梯队内按借出时间**从近到远**(desc)。
|
||
# 原实现是 asc「借出越久越靠前」,设计意图是暴露呆滞借用;
|
||
# 业务方明确要求改为从近到远,故反转。
|
||
desc(order_subq.c.min_borrow_time)]
|
||
)
|
||
borrow_no_q = borrow_no_q.order_by(*_order_by)
|
||
|
||
# 分页(基准 = 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)
|
||
|
||
# ====================================================================
|
||
# ★ 恢复业务排序(此前被静默丢弃)
|
||
#
|
||
# detail_records 是按 `borrow_no ASC` 重新拉取的,而单号形如
|
||
# BOR-YYYYMMDD-NNNN —— 它的**字母序恰好等于借出日期序**。
|
||
# 于是上面步骤 2 辛苦算出的「逾期优先」分页顺序(page_borrow_nos)
|
||
# 被这次重排**整套覆盖**:无限期单排到了最前,有限期里 10-01 排在
|
||
# 11-01 之后,看起来完全随机。那份 ORDER BY 一直是死代码。
|
||
#
|
||
# 这里按 page_borrow_nos 的顺序还原输出。明细内部仍按 id 升序
|
||
# (同一次发货写入的明细,id 序即扫码顺序,便于阅读)。
|
||
# ====================================================================
|
||
_order_idx = {bn: i for i, bn in enumerate(page_borrow_nos)}
|
||
items_with_names.sort(
|
||
key=lambda d: (_order_idx.get(d.get('borrow_no'), len(_order_idx)), d.get('id') or 0)
|
||
)
|
||
|
||
# ====================================================================
|
||
# ★ 附加「待接收的转交」:前端据此渲染【接收转交】【拒绝】按钮与
|
||
# 「转交待确认」状态。批量查一次,避免逐单 N+1。
|
||
# 应用层保证同一单号最多一条 PENDING,故 borrow_no 可直接作键。
|
||
# ====================================================================
|
||
if items_with_names:
|
||
# ★ 按**明细行**(borrow_id)关联,不是单号:转交粒度已下沉到明细,
|
||
# 同一张单可能只有其中一件挂着待接收,其余仍是原持有人。
|
||
_ids = [d.get('id') for d in items_with_names if d.get('id')]
|
||
_pending = TransBorrowTransfer.query.filter(
|
||
TransBorrowTransfer.borrow_id.in_(_ids),
|
||
TransBorrowTransfer.status == TRANSFER_STATUS_PENDING,
|
||
).all() if _ids else []
|
||
_pending_map = {t.borrow_id: t.to_dict() for t in _pending}
|
||
# ============================================================
|
||
# ★ 实际归还人(trans_borrow_return.returner_id)
|
||
#
|
||
# 与主表的 return_operator(**经手库管**)是**两个不同的人**:
|
||
# · returner_id —— 把东西交回窗口的人(已校验 == 当时持有人)
|
||
# · return_operator —— 办理还库的库管
|
||
# 列表原先把后者标成「归还人」展示,属标签错误;此处补上真正
|
||
# 的归还人,供前端分列展示。
|
||
# ⚠ 本表是二期才建的,**历史归还没有这个记录** —— 那部分行的
|
||
# returners 为空,前端显示为空并提示「历史数据未记录」,
|
||
# 而不是拿库管的名字顶上(那正是本次要修的错)。
|
||
# ============================================================
|
||
_ret_rows = TransBorrowReturn.query.filter(
|
||
TransBorrowReturn.borrow_id.in_(_ids)
|
||
).all() if _ids else []
|
||
_ret_uids = {t.returner_id for t in _ret_rows if t.returner_id}
|
||
_ret_names = {}
|
||
if _ret_uids:
|
||
from app.models.system import SysUser
|
||
for _u in SysUser.query.filter(SysUser.id.in_(_ret_uids)).all():
|
||
_ret_names[_u.id] = user_display_name(_u)
|
||
_ret_map = {}
|
||
for _t in _ret_rows:
|
||
_nm = _ret_names.get(_t.returner_id)
|
||
if _nm:
|
||
_ret_map.setdefault(_t.borrow_id, set()).add(_nm)
|
||
for d in items_with_names:
|
||
d['returners'] = sorted(_ret_map.get(d.get('id'), set()))
|
||
|
||
for d in items_with_names:
|
||
_pt = _pending_map.get(d.get('id'))
|
||
if _pt is not None:
|
||
# ★ is_mine 由后端判定:前端 localStorage 里只有 username
|
||
# 没有 user_id,靠姓名比对既有歧义又不可靠。
|
||
# (viewer_user_id 对管理者为 None,故另取 current_user_id)
|
||
_pt['is_mine'] = (
|
||
current_user_id is not None
|
||
and _pt.get('to_user_id') is not None
|
||
and int(_pt['to_user_id']) == int(current_user_id)
|
||
)
|
||
d['pending_transfer'] = _pt
|
||
# ★ 谁能发起转交:**只有该明细当前的持有人本人**。
|
||
# 前端据此显示【转交】,后端 transfer_borrow 做同样的强校验 ——
|
||
# 界面遮挡不是安全边界,两处必须同口径。
|
||
# 同样由后端判定:前端 localStorage 里没有 user_id。
|
||
d['can_transfer'] = (
|
||
current_user_id is not None
|
||
and d.get('current_holder_id') is not None
|
||
and int(d['current_holder_id']) == int(current_user_id)
|
||
)
|
||
else:
|
||
_pending_map = {}
|
||
|
||
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
|
||
}
|