405 lines
16 KiB
Python
405 lines
16 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 sqlalchemy import desc, func, nullslast, asc, or_, and_
|
||
|
||
|
||
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 create_borrow(data, operator_name='System'):
|
||
"""
|
||
借库逻辑:减少可用库存,不减总库存
|
||
"""
|
||
items = data.get('items', [])
|
||
borrower_name = data.get('borrower_name')
|
||
signature = data.get('signature_path') # 借用人签字
|
||
|
||
if not items: raise ValueError("物品列表为空")
|
||
if not borrower_name: raise ValueError("请输入借用人")
|
||
if not signature: raise ValueError("借用人必须签字")
|
||
|
||
borrow_no = TransService.generate_borrow_no()
|
||
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
|
||
|
||
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
|
||
|
||
stock = ModelClass.query.with_for_update().get(stock_id)
|
||
if not stock: raise ValueError(f"库存不存在 ID:{stock_id}")
|
||
|
||
if float(stock.available_quantity) < qty:
|
||
raise ValueError(f"SKU {stock.sku} 可用库存不足")
|
||
|
||
# 1. 冻结库存 (只减可用)
|
||
stock.available_quantity = float(stock.available_quantity) - qty
|
||
|
||
# 2. 创建借用单
|
||
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=data.get('remark'),
|
||
expected_return_time=data.get('expected_return_time'),
|
||
status='borrowed',
|
||
is_returned=False
|
||
)
|
||
db.session.add(record)
|
||
|
||
db.session.commit()
|
||
return borrow_no
|
||
except Exception as e:
|
||
db.session.rollback()
|
||
raise e
|
||
|
||
@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'):
|
||
q = TransBorrow.query
|
||
|
||
# 如果有关键词,需要联表搜索物料名称和规格型号
|
||
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)
|
||
|
||
else:
|
||
keyword_conditions = None
|
||
else:
|
||
keyword_conditions = None
|
||
|
||
if keyword_conditions is not None:
|
||
q = q.filter(keyword_conditions)
|
||
|
||
if status == 'borrowed':
|
||
q = q.filter(TransBorrow.is_returned == False)
|
||
elif status == 'returned':
|
||
q = q.filter(TransBorrow.is_returned == True)
|
||
|
||
# 使用 distinct 防止跨表查询产生重复记录
|
||
q = q.distinct()
|
||
|
||
q = q.order_by(nullslast(asc(TransBorrow.expected_return_time)))
|
||
pagination = q.paginate(page=page, per_page=limit, error_out=False)
|
||
|
||
return {
|
||
'items': [r.to_dict() for r in pagination.items],
|
||
'total': pagination.total,
|
||
'page': page,
|
||
'limit': limit
|
||
}
|