2026-02-10 11:34:50 +08:00
|
|
|
|
import uuid # .material -> .base refactor checked
|
2026-02-05 16:54:11 +08:00
|
|
|
|
from datetime import datetime, timezone, timedelta
|
2026-03-20 10:06:22 +08:00
|
|
|
|
from sqlalchemy import or_, func, desc, and_
|
2026-05-19 09:49:30 +08:00
|
|
|
|
from sqlalchemy.orm import joinedload
|
2026-02-04 17:22:20 +08:00
|
|
|
|
from app.extensions import db
|
2026-04-28 16:02:34 +08:00
|
|
|
|
from app.models.outbound import TransOutbound, OutboundApproval
|
2026-02-04 17:22:20 +08:00
|
|
|
|
|
2026-02-05 10:20:52 +08:00
|
|
|
|
# 引入所有库存模型以进行查询
|
2026-02-04 17:22:20 +08:00
|
|
|
|
from app.models.inbound.buy import StockBuy
|
|
|
|
|
|
from app.models.inbound.semi import StockSemi
|
|
|
|
|
|
from app.models.inbound.product import StockProduct
|
2026-02-05 16:54:11 +08:00
|
|
|
|
# 引入基础信息表
|
2026-02-05 10:20:52 +08:00
|
|
|
|
from app.models.base import MaterialBase
|
2026-04-09 08:38:48 +08:00
|
|
|
|
# 引入维修单表
|
|
|
|
|
|
from app.models.transaction import TransRepair
|
2026-04-28 16:02:34 +08:00
|
|
|
|
# 引入系统用户表
|
|
|
|
|
|
from app.models.system import SysUser
|
2026-02-04 17:22:20 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OutboundService:
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def generate_outbound_no():
|
2026-02-05 14:36:36 +08:00
|
|
|
|
"""
|
2026-02-05 16:54:11 +08:00
|
|
|
|
生成出库单号: OUT-yyyyMMdd-HHmm-当日流水(4位)
|
|
|
|
|
|
例如: OUT-20260205-1558-0001
|
2026-02-05 14:36:36 +08:00
|
|
|
|
"""
|
|
|
|
|
|
beijing_tz = timezone(timedelta(hours=8))
|
2026-02-05 16:54:11 +08:00
|
|
|
|
now = datetime.now(beijing_tz)
|
2026-02-05 14:36:36 +08:00
|
|
|
|
|
2026-02-05 16:54:11 +08:00
|
|
|
|
date_str = now.strftime('%Y%m%d')
|
|
|
|
|
|
time_str = now.strftime('%H%M')
|
|
|
|
|
|
|
|
|
|
|
|
prefix = f"OUT-{date_str}-"
|
|
|
|
|
|
|
|
|
|
|
|
existing_count = db.session.query(func.count(func.distinct(TransOutbound.outbound_no))) \
|
|
|
|
|
|
.filter(TransOutbound.outbound_no.like(f"{prefix}%")).scalar()
|
|
|
|
|
|
|
|
|
|
|
|
sequence = existing_count + 1
|
|
|
|
|
|
return f"OUT-{date_str}-{time_str}-{sequence:04d}"
|
2026-02-04 17:22:20 +08:00
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def get_stock_by_barcode(barcode):
|
|
|
|
|
|
"""
|
2026-02-05 16:54:11 +08:00
|
|
|
|
根据扫码内容查找对应的库存物品,并附带价格信息
|
2026-02-04 17:22:20 +08:00
|
|
|
|
"""
|
|
|
|
|
|
if not barcode:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
2026-02-05 10:20:52 +08:00
|
|
|
|
clean_code = barcode.strip()
|
|
|
|
|
|
|
2026-02-05 16:54:11 +08:00
|
|
|
|
def get_price(item, table_type):
|
|
|
|
|
|
if table_type == 'stock_product':
|
|
|
|
|
|
return float(item.sale_price) if item.sale_price else 0
|
|
|
|
|
|
elif table_type == 'stock_buy':
|
2026-02-27 16:43:30 +08:00
|
|
|
|
return float(item.pre_tax_unit_price) if item.pre_tax_unit_price else 0
|
2026-02-05 16:54:11 +08:00
|
|
|
|
return 0
|
|
|
|
|
|
|
2026-02-05 10:20:52 +08:00
|
|
|
|
prod = StockProduct.query.filter(
|
|
|
|
|
|
or_(StockProduct.barcode == clean_code, StockProduct.sku == clean_code)
|
|
|
|
|
|
).first()
|
2026-02-04 17:22:20 +08:00
|
|
|
|
if prod:
|
2026-02-05 16:54:11 +08:00
|
|
|
|
res = OutboundService._format_scan_result(prod, 'stock_product')
|
|
|
|
|
|
res['price'] = get_price(prod, 'stock_product')
|
|
|
|
|
|
return res
|
2026-02-04 17:22:20 +08:00
|
|
|
|
|
2026-02-05 10:20:52 +08:00
|
|
|
|
semi = StockSemi.query.filter(
|
|
|
|
|
|
or_(StockSemi.barcode == clean_code, StockSemi.sku == clean_code)
|
|
|
|
|
|
).first()
|
2026-02-04 17:22:20 +08:00
|
|
|
|
if semi:
|
2026-02-05 16:54:11 +08:00
|
|
|
|
res = OutboundService._format_scan_result(semi, 'stock_semi')
|
|
|
|
|
|
res['price'] = 0
|
|
|
|
|
|
return res
|
2026-02-04 17:22:20 +08:00
|
|
|
|
|
2026-02-05 10:20:52 +08:00
|
|
|
|
buy = StockBuy.query.filter(
|
|
|
|
|
|
or_(StockBuy.barcode == clean_code, StockBuy.sku == clean_code)
|
|
|
|
|
|
).first()
|
2026-02-04 17:22:20 +08:00
|
|
|
|
if buy:
|
2026-02-05 16:54:11 +08:00
|
|
|
|
res = OutboundService._format_scan_result(buy, 'stock_buy')
|
|
|
|
|
|
res['price'] = get_price(buy, 'stock_buy')
|
|
|
|
|
|
return res
|
2026-02-04 17:22:20 +08:00
|
|
|
|
|
2026-04-09 08:38:48 +08:00
|
|
|
|
# 查询维修单表 (按SKU或序列号查询,排除已出库状态)
|
|
|
|
|
|
repair = TransRepair.query.filter(
|
|
|
|
|
|
or_(TransRepair.sku == clean_code, TransRepair.serial_number == clean_code)
|
|
|
|
|
|
).filter(
|
|
|
|
|
|
TransRepair.repair_status != '已出库'
|
|
|
|
|
|
).first()
|
|
|
|
|
|
if repair:
|
|
|
|
|
|
res = {
|
|
|
|
|
|
'id': repair.id,
|
|
|
|
|
|
'sku': repair.sku,
|
|
|
|
|
|
'name': repair.material_name or "维修件",
|
|
|
|
|
|
'spec_model': "",
|
|
|
|
|
|
'category': "",
|
|
|
|
|
|
'material_type': "",
|
|
|
|
|
|
'source_table': 'trans_repair',
|
|
|
|
|
|
'stock_quantity': 1,
|
|
|
|
|
|
'available_quantity': 1,
|
2026-04-09 08:49:50 +08:00
|
|
|
|
'batch_number': repair.serial_number or '',
|
|
|
|
|
|
'serial_number': repair.serial_number or '',
|
2026-04-09 08:38:48 +08:00
|
|
|
|
'warehouse_location': repair.customer_location or '',
|
|
|
|
|
|
'barcode': repair.sku,
|
|
|
|
|
|
'price': float(repair.sale_price) if repair.sale_price else 0
|
|
|
|
|
|
}
|
|
|
|
|
|
return res
|
|
|
|
|
|
|
2026-02-04 17:22:20 +08:00
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
2026-02-05 10:20:52 +08:00
|
|
|
|
def _format_scan_result(item, table_name):
|
|
|
|
|
|
base_name = ""
|
|
|
|
|
|
base_spec = ""
|
2026-02-05 16:54:11 +08:00
|
|
|
|
base_cat = ""
|
|
|
|
|
|
base_type = ""
|
2026-02-05 10:20:52 +08:00
|
|
|
|
|
|
|
|
|
|
if hasattr(item, 'base') and item.base:
|
|
|
|
|
|
base_name = item.base.name
|
|
|
|
|
|
base_spec = item.base.spec_model
|
2026-02-05 16:54:11 +08:00
|
|
|
|
base_cat = item.base.category
|
|
|
|
|
|
base_type = item.base.material_type
|
2026-02-05 10:20:52 +08:00
|
|
|
|
|
|
|
|
|
|
if not base_name and hasattr(item, 'base_id') and item.base_id:
|
|
|
|
|
|
try:
|
|
|
|
|
|
base_info = MaterialBase.query.get(item.base_id)
|
|
|
|
|
|
if base_info:
|
|
|
|
|
|
base_name = base_info.name
|
|
|
|
|
|
base_spec = base_info.spec_model
|
2026-02-05 16:54:11 +08:00
|
|
|
|
base_cat = base_info.category
|
|
|
|
|
|
base_type = base_info.material_type
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
2026-02-05 10:20:52 +08:00
|
|
|
|
|
2026-03-26 17:14:26 +08:00
|
|
|
|
if not base_name and hasattr(item, 'base') and item.base:
|
|
|
|
|
|
base_name = item.base.name
|
2026-02-05 10:20:52 +08:00
|
|
|
|
|
|
|
|
|
|
stock_qty = float(item.stock_quantity) if item.stock_quantity else 0
|
|
|
|
|
|
avail_qty = float(item.available_quantity) if item.available_quantity else 0
|
2026-02-04 17:22:20 +08:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
'id': item.id,
|
2026-02-05 10:20:52 +08:00
|
|
|
|
'sku': item.sku,
|
2026-02-05 16:54:11 +08:00
|
|
|
|
'name': base_name or "未知物品",
|
|
|
|
|
|
'spec_model': base_spec or "",
|
|
|
|
|
|
'category': base_cat or "",
|
|
|
|
|
|
'material_type': base_type or "",
|
|
|
|
|
|
'source_table': table_name,
|
|
|
|
|
|
'stock_quantity': stock_qty,
|
|
|
|
|
|
'available_quantity': avail_qty,
|
2026-02-04 17:22:20 +08:00
|
|
|
|
'batch_number': getattr(item, 'batch_number', ''),
|
2026-02-05 10:20:52 +08:00
|
|
|
|
'warehouse_location': getattr(item, 'warehouse_location', ''),
|
|
|
|
|
|
'barcode': getattr(item, 'barcode', '')
|
2026-02-04 17:22:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
2026-02-05 16:54:11 +08:00
|
|
|
|
def create_outbound_batch(data, operator_name='System'):
|
|
|
|
|
|
items = data.get('items', [])
|
|
|
|
|
|
if not items:
|
|
|
|
|
|
raise ValueError("出库商品列表不能为空")
|
|
|
|
|
|
|
|
|
|
|
|
outbound_no = OutboundService.generate_outbound_no()
|
|
|
|
|
|
|
|
|
|
|
|
common_data = {
|
|
|
|
|
|
'outbound_no': outbound_no,
|
|
|
|
|
|
'consumer_name': data.get('consumer_name'),
|
|
|
|
|
|
'outbound_type': data.get('outbound_type', 'SALES'),
|
|
|
|
|
|
'signature_path': data.get('signature_path'),
|
|
|
|
|
|
'operator_name': operator_name,
|
|
|
|
|
|
'remark': data.get('remark')
|
|
|
|
|
|
}
|
2026-02-04 17:22:20 +08:00
|
|
|
|
|
2026-02-05 16:54:11 +08:00
|
|
|
|
beijing_tz = timezone(timedelta(hours=8))
|
|
|
|
|
|
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
|
2026-02-04 17:22:20 +08:00
|
|
|
|
|
2026-04-28 16:02:34 +08:00
|
|
|
|
# ★ 审批单相关逻辑
|
|
|
|
|
|
request_id = data.get('request_id')
|
|
|
|
|
|
approval = None
|
|
|
|
|
|
if request_id:
|
|
|
|
|
|
# 根据 request_id 查询审批单
|
|
|
|
|
|
approval = OutboundApproval.query.get(request_id)
|
|
|
|
|
|
if not approval:
|
|
|
|
|
|
raise ValueError(f"关联的审批单不存在 (ID: {request_id})")
|
|
|
|
|
|
if approval.status != 1:
|
|
|
|
|
|
status_map = {0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成'}
|
|
|
|
|
|
current_status = status_map.get(approval.status, str(approval.status))
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"关联的审批单状态不允许出库 (当前状态: {current_status}),"
|
|
|
|
|
|
f"仅已通过的审批单方可执行出库"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-04 17:22:20 +08:00
|
|
|
|
model_map = {
|
|
|
|
|
|
'stock_buy': StockBuy,
|
|
|
|
|
|
'stock_semi': StockSemi,
|
|
|
|
|
|
'stock_product': StockProduct
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
2026-02-05 16:54:11 +08:00
|
|
|
|
for item in items:
|
|
|
|
|
|
source_table = item.get('source_table')
|
|
|
|
|
|
stock_id = item.get('stock_id')
|
|
|
|
|
|
quantity = float(item.get('quantity', 0))
|
|
|
|
|
|
unit_price = float(item.get('price', 0))
|
|
|
|
|
|
|
|
|
|
|
|
if quantity <= 0:
|
|
|
|
|
|
raise ValueError(f"SKU {item.get('sku')} 的出库数量必须大于0")
|
|
|
|
|
|
|
2026-04-09 08:38:48 +08:00
|
|
|
|
# 处理维修单出库
|
|
|
|
|
|
if source_table == 'trans_repair':
|
|
|
|
|
|
repair = TransRepair.query.with_for_update().get(stock_id)
|
|
|
|
|
|
if not repair:
|
|
|
|
|
|
raise ValueError(f"维修单不存在 (ID: {stock_id})")
|
2026-04-28 16:02:34 +08:00
|
|
|
|
|
2026-04-09 08:38:48 +08:00
|
|
|
|
# 更新维修单状态为已出库
|
|
|
|
|
|
repair.repair_status = '已出库'
|
|
|
|
|
|
repair.shipping_date = current_time
|
2026-04-28 16:02:34 +08:00
|
|
|
|
|
2026-04-09 08:38:48 +08:00
|
|
|
|
# 创建出库记录
|
|
|
|
|
|
new_record = TransOutbound(
|
|
|
|
|
|
sku=item.get('sku'),
|
|
|
|
|
|
source_table=source_table,
|
|
|
|
|
|
stock_id=stock_id,
|
|
|
|
|
|
barcode=item.get('barcode'),
|
|
|
|
|
|
quantity=quantity,
|
|
|
|
|
|
unit_price=unit_price,
|
|
|
|
|
|
outbound_time=current_time,
|
|
|
|
|
|
**common_data
|
|
|
|
|
|
)
|
|
|
|
|
|
db.session.add(new_record)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
2026-02-05 16:54:11 +08:00
|
|
|
|
ModelClass = model_map.get(source_table)
|
|
|
|
|
|
if not ModelClass:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
stock_record = ModelClass.query.with_for_update().get(stock_id)
|
|
|
|
|
|
if not stock_record:
|
|
|
|
|
|
raise ValueError(f"库存记录不存在 (ID: {stock_id})")
|
|
|
|
|
|
|
|
|
|
|
|
if float(stock_record.available_quantity) < quantity:
|
|
|
|
|
|
raise ValueError(f"SKU {stock_record.sku} 库存不足,当前可用: {stock_record.available_quantity}")
|
|
|
|
|
|
|
|
|
|
|
|
stock_record.stock_quantity = float(stock_record.stock_quantity) - quantity
|
|
|
|
|
|
stock_record.available_quantity = float(stock_record.available_quantity) - quantity
|
|
|
|
|
|
|
|
|
|
|
|
new_record = TransOutbound(
|
|
|
|
|
|
sku=item.get('sku'),
|
|
|
|
|
|
source_table=source_table,
|
|
|
|
|
|
stock_id=stock_id,
|
|
|
|
|
|
barcode=item.get('barcode'),
|
|
|
|
|
|
quantity=quantity,
|
|
|
|
|
|
unit_price=unit_price,
|
|
|
|
|
|
outbound_time=current_time,
|
|
|
|
|
|
**common_data
|
|
|
|
|
|
)
|
|
|
|
|
|
db.session.add(new_record)
|
2026-02-04 17:22:20 +08:00
|
|
|
|
|
2026-04-28 16:02:34 +08:00
|
|
|
|
# ★ 如果关联了审批单,出库成功后更新审批单状态为"已完成"
|
|
|
|
|
|
if approval:
|
|
|
|
|
|
approval.status = 3 # 3-已完成
|
|
|
|
|
|
# updated_at 会在 commit 时由 SQLAlchemy 自动更新
|
|
|
|
|
|
|
2026-07-15 17:37:57 +08:00
|
|
|
|
# ★ 先提交事务,释放所有行锁,避免 SMTP 调用延长锁持有时间
|
2026-02-05 16:54:11 +08:00
|
|
|
|
db.session.commit()
|
2026-07-15 17:37:57 +08:00
|
|
|
|
|
|
|
|
|
|
# ★ 出库后检查低库存预警(移到 commit 之后,避免事务内网络调用)
|
|
|
|
|
|
try:
|
|
|
|
|
|
from app.services.inventory_task import InventoryWarningService
|
|
|
|
|
|
InventoryWarningService.check_and_send_warning_emails()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
import logging
|
|
|
|
|
|
logging.getLogger(__name__).warning(f"⚠️ 低库存预警检查失败: {e}")
|
|
|
|
|
|
|
2026-02-05 16:54:11 +08:00
|
|
|
|
return outbound_no
|
2026-02-04 17:22:20 +08:00
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
db.session.rollback()
|
|
|
|
|
|
raise e
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
2026-07-15 11:11:40 +08:00
|
|
|
|
def get_grouped_list(page=1, per_page=10, keyword=None, search_type='all', start_date=None, end_date=None, company=None):
|
2026-02-05 16:54:11 +08:00
|
|
|
|
"""
|
|
|
|
|
|
查询出库记录(按出库单号分组),包含详细物品信息
|
2026-03-20 09:53:32 +08:00
|
|
|
|
支持跨表搜索:单号、领用人、SKU、物料名称、规格型号
|
2026-03-20 10:26:45 +08:00
|
|
|
|
search_type: all, no, name, sku, material_name, spec_model
|
2026-07-15 11:11:40 +08:00
|
|
|
|
company: 可选的公司过滤参数
|
2026-02-05 16:54:11 +08:00
|
|
|
|
"""
|
2026-04-07 17:39:14 +08:00
|
|
|
|
# 日期补全:解决零点截断问题
|
|
|
|
|
|
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"
|
|
|
|
|
|
|
2026-03-20 10:06:22 +08:00
|
|
|
|
# 1. 构建基础查询
|
2026-03-20 09:53:32 +08:00
|
|
|
|
# 如果有关键词,需要联表搜索物料名称和规格型号
|
|
|
|
|
|
if keyword:
|
2026-03-20 10:26:45 +08:00
|
|
|
|
# 根据 search_type 构建不同的搜索条件
|
|
|
|
|
|
if search_type == 'all':
|
|
|
|
|
|
# 原有逻辑:or_ 联表全局模糊搜索
|
|
|
|
|
|
# 查询 stock_buy 路径匹配的名称/规格
|
|
|
|
|
|
buy_match = db.session.query(TransOutbound.outbound_no).join(
|
|
|
|
|
|
StockBuy, and_(
|
|
|
|
|
|
TransOutbound.stock_id == StockBuy.id,
|
|
|
|
|
|
TransOutbound.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(TransOutbound.outbound_no).join(
|
|
|
|
|
|
StockSemi, and_(
|
|
|
|
|
|
TransOutbound.stock_id == StockSemi.id,
|
|
|
|
|
|
TransOutbound.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(TransOutbound.outbound_no).join(
|
|
|
|
|
|
StockProduct, and_(
|
|
|
|
|
|
TransOutbound.stock_id == StockProduct.id,
|
|
|
|
|
|
TransOutbound.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()
|
|
|
|
|
|
|
|
|
|
|
|
# 合并三种来源的匹配单号
|
|
|
|
|
|
all_matches = db.session.query(buy_match.c.outbound_no).union(
|
|
|
|
|
|
db.session.query(semi_match.c.outbound_no),
|
|
|
|
|
|
db.session.query(product_match.c.outbound_no)
|
|
|
|
|
|
).subquery()
|
|
|
|
|
|
|
|
|
|
|
|
keyword_conditions = or_(
|
|
|
|
|
|
TransOutbound.outbound_no.ilike(f'%{keyword}%'),
|
|
|
|
|
|
TransOutbound.consumer_name.ilike(f'%{keyword}%'),
|
|
|
|
|
|
TransOutbound.sku.ilike(f'%{keyword}%'),
|
|
|
|
|
|
TransOutbound.outbound_no.in_(all_matches)
|
2026-03-20 10:06:22 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-20 10:26:45 +08:00
|
|
|
|
elif search_type == 'no':
|
|
|
|
|
|
keyword_conditions = TransOutbound.outbound_no.ilike(f'%{keyword}%')
|
|
|
|
|
|
|
|
|
|
|
|
elif search_type == 'name':
|
|
|
|
|
|
keyword_conditions = TransOutbound.consumer_name.ilike(f'%{keyword}%')
|
|
|
|
|
|
|
|
|
|
|
|
elif search_type == 'sku':
|
|
|
|
|
|
keyword_conditions = TransOutbound.sku.ilike(f'%{keyword}%')
|
|
|
|
|
|
|
|
|
|
|
|
elif search_type == 'material_name':
|
|
|
|
|
|
# 联表查询物料名称
|
|
|
|
|
|
buy_match = db.session.query(TransOutbound.outbound_no).join(
|
|
|
|
|
|
StockBuy, and_(
|
|
|
|
|
|
TransOutbound.stock_id == StockBuy.id,
|
|
|
|
|
|
TransOutbound.source_table == 'stock_buy'
|
|
|
|
|
|
)
|
|
|
|
|
|
).join(
|
|
|
|
|
|
MaterialBase, StockBuy.base_id == MaterialBase.id
|
|
|
|
|
|
).filter(MaterialBase.name.ilike(f'%{keyword}%')).subquery()
|
|
|
|
|
|
|
|
|
|
|
|
semi_match = db.session.query(TransOutbound.outbound_no).join(
|
|
|
|
|
|
StockSemi, and_(
|
|
|
|
|
|
TransOutbound.stock_id == StockSemi.id,
|
|
|
|
|
|
TransOutbound.source_table == 'stock_semi'
|
|
|
|
|
|
)
|
|
|
|
|
|
).join(
|
|
|
|
|
|
MaterialBase, StockSemi.base_id == MaterialBase.id
|
|
|
|
|
|
).filter(MaterialBase.name.ilike(f'%{keyword}%')).subquery()
|
|
|
|
|
|
|
|
|
|
|
|
product_match = db.session.query(TransOutbound.outbound_no).join(
|
|
|
|
|
|
StockProduct, and_(
|
|
|
|
|
|
TransOutbound.stock_id == StockProduct.id,
|
|
|
|
|
|
TransOutbound.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.outbound_no).union(
|
|
|
|
|
|
db.session.query(semi_match.c.outbound_no),
|
|
|
|
|
|
db.session.query(product_match.c.outbound_no)
|
|
|
|
|
|
).subquery()
|
|
|
|
|
|
|
|
|
|
|
|
keyword_conditions = TransOutbound.outbound_no.in_(all_matches)
|
|
|
|
|
|
|
|
|
|
|
|
elif search_type == 'spec_model':
|
|
|
|
|
|
# 联表查询规格型号
|
|
|
|
|
|
buy_match = db.session.query(TransOutbound.outbound_no).join(
|
|
|
|
|
|
StockBuy, and_(
|
|
|
|
|
|
TransOutbound.stock_id == StockBuy.id,
|
|
|
|
|
|
TransOutbound.source_table == 'stock_buy'
|
|
|
|
|
|
)
|
|
|
|
|
|
).join(
|
|
|
|
|
|
MaterialBase, StockBuy.base_id == MaterialBase.id
|
|
|
|
|
|
).filter(MaterialBase.spec_model.ilike(f'%{keyword}%')).subquery()
|
|
|
|
|
|
|
|
|
|
|
|
semi_match = db.session.query(TransOutbound.outbound_no).join(
|
|
|
|
|
|
StockSemi, and_(
|
|
|
|
|
|
TransOutbound.stock_id == StockSemi.id,
|
|
|
|
|
|
TransOutbound.source_table == 'stock_semi'
|
|
|
|
|
|
)
|
|
|
|
|
|
).join(
|
|
|
|
|
|
MaterialBase, StockSemi.base_id == MaterialBase.id
|
|
|
|
|
|
).filter(MaterialBase.spec_model.ilike(f'%{keyword}%')).subquery()
|
|
|
|
|
|
|
|
|
|
|
|
product_match = db.session.query(TransOutbound.outbound_no).join(
|
|
|
|
|
|
StockProduct, and_(
|
|
|
|
|
|
TransOutbound.stock_id == StockProduct.id,
|
|
|
|
|
|
TransOutbound.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.outbound_no).union(
|
|
|
|
|
|
db.session.query(semi_match.c.outbound_no),
|
|
|
|
|
|
db.session.query(product_match.c.outbound_no)
|
|
|
|
|
|
).subquery()
|
|
|
|
|
|
|
|
|
|
|
|
keyword_conditions = TransOutbound.outbound_no.in_(all_matches)
|
|
|
|
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
|
keyword_conditions = None
|
2026-03-20 09:53:32 +08:00
|
|
|
|
else:
|
|
|
|
|
|
keyword_conditions = None
|
|
|
|
|
|
|
2026-07-15 11:11:40 +08:00
|
|
|
|
# 【行级数据隔离】基于 JWT 多租户公司过滤
|
|
|
|
|
|
# 通过三个库存表路径,找到匹配公司的出库单号(排除 trans_repair,因其无 MaterialBase 关联)
|
|
|
|
|
|
from app.utils.decorators import get_current_company_filter
|
|
|
|
|
|
|
|
|
|
|
|
company_limit = get_current_company_filter()
|
|
|
|
|
|
if company_limit is not None:
|
|
|
|
|
|
buy_comp = db.session.query(TransOutbound.outbound_no).join(
|
|
|
|
|
|
StockBuy, and_(
|
|
|
|
|
|
TransOutbound.stock_id == StockBuy.id,
|
|
|
|
|
|
TransOutbound.source_table == 'stock_buy'
|
|
|
|
|
|
)
|
|
|
|
|
|
).join(MaterialBase, StockBuy.base_id == MaterialBase.id).filter(
|
|
|
|
|
|
MaterialBase.company_name == company_limit
|
|
|
|
|
|
).subquery()
|
|
|
|
|
|
|
|
|
|
|
|
semi_comp = db.session.query(TransOutbound.outbound_no).join(
|
|
|
|
|
|
StockSemi, and_(
|
|
|
|
|
|
TransOutbound.stock_id == StockSemi.id,
|
|
|
|
|
|
TransOutbound.source_table == 'stock_semi'
|
|
|
|
|
|
)
|
|
|
|
|
|
).join(MaterialBase, StockSemi.base_id == MaterialBase.id).filter(
|
|
|
|
|
|
MaterialBase.company_name == company_limit
|
|
|
|
|
|
).subquery()
|
|
|
|
|
|
|
|
|
|
|
|
prod_comp = db.session.query(TransOutbound.outbound_no).join(
|
|
|
|
|
|
StockProduct, and_(
|
|
|
|
|
|
TransOutbound.stock_id == StockProduct.id,
|
|
|
|
|
|
TransOutbound.source_table == 'stock_product'
|
|
|
|
|
|
)
|
|
|
|
|
|
).join(MaterialBase, StockProduct.base_id == MaterialBase.id).filter(
|
|
|
|
|
|
MaterialBase.company_name == company_limit
|
|
|
|
|
|
).subquery()
|
|
|
|
|
|
|
|
|
|
|
|
comp_all = db.session.query(buy_comp.c.outbound_no).union(
|
|
|
|
|
|
db.session.query(semi_comp.c.outbound_no),
|
|
|
|
|
|
db.session.query(prod_comp.c.outbound_no)
|
|
|
|
|
|
).subquery()
|
|
|
|
|
|
|
2026-02-05 16:54:11 +08:00
|
|
|
|
stmt = db.session.query(
|
|
|
|
|
|
TransOutbound.outbound_no,
|
|
|
|
|
|
func.max(TransOutbound.outbound_time).label('max_time')
|
|
|
|
|
|
).group_by(TransOutbound.outbound_no)
|
2026-02-04 17:22:20 +08:00
|
|
|
|
|
2026-03-20 09:53:32 +08:00
|
|
|
|
if keyword_conditions is not None:
|
|
|
|
|
|
stmt = stmt.filter(keyword_conditions)
|
2026-02-04 17:22:20 +08:00
|
|
|
|
|
|
|
|
|
|
if start_date and end_date:
|
2026-02-05 16:54:11 +08:00
|
|
|
|
stmt = stmt.filter(TransOutbound.outbound_time.between(start_date, end_date))
|
|
|
|
|
|
|
2026-07-15 11:11:40 +08:00
|
|
|
|
# 【行级数据隔离】应用公司过滤到主查询
|
|
|
|
|
|
if company_limit is not None:
|
|
|
|
|
|
stmt = stmt.filter(TransOutbound.outbound_no.in_(comp_all))
|
|
|
|
|
|
|
2026-02-05 16:54:11 +08:00
|
|
|
|
stmt = stmt.order_by(desc('max_time'))
|
|
|
|
|
|
|
2026-03-20 09:53:32 +08:00
|
|
|
|
# 使用 distinct 确保跨表查询不重复
|
|
|
|
|
|
stmt = stmt.distinct()
|
|
|
|
|
|
|
2026-02-05 16:54:11 +08:00
|
|
|
|
pagination = stmt.paginate(page=page, per_page=per_page, error_out=False)
|
|
|
|
|
|
outbound_nos = [row.outbound_no for row in pagination.items]
|
|
|
|
|
|
|
|
|
|
|
|
if not outbound_nos:
|
|
|
|
|
|
return {
|
|
|
|
|
|
'items': [],
|
|
|
|
|
|
'total': 0,
|
|
|
|
|
|
'pages': 0,
|
|
|
|
|
|
'current_page': page
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# 2. 查询详细记录
|
|
|
|
|
|
details = TransOutbound.query.filter(TransOutbound.outbound_no.in_(outbound_nos)).all()
|
|
|
|
|
|
|
|
|
|
|
|
# 3. 组装数据并查询物品详情
|
|
|
|
|
|
grouped_map = {}
|
|
|
|
|
|
|
|
|
|
|
|
# 映射表模型以便查询
|
|
|
|
|
|
model_map = {
|
|
|
|
|
|
'stock_buy': StockBuy,
|
|
|
|
|
|
'stock_semi': StockSemi,
|
|
|
|
|
|
'stock_product': StockProduct
|
|
|
|
|
|
}
|
2026-02-04 17:22:20 +08:00
|
|
|
|
|
2026-05-19 09:49:30 +08:00
|
|
|
|
# ==========================================
|
|
|
|
|
|
# ★ 优化步骤 1:第一遍循环,单纯收集所有的 stock_id
|
|
|
|
|
|
# ==========================================
|
|
|
|
|
|
stock_ids_by_table = {'stock_buy': set(), 'stock_semi': set(), 'stock_product': set()}
|
|
|
|
|
|
|
|
|
|
|
|
for d in details:
|
|
|
|
|
|
if d.source_table in stock_ids_by_table and d.stock_id:
|
|
|
|
|
|
stock_ids_by_table[d.source_table].add(d.stock_id)
|
|
|
|
|
|
|
|
|
|
|
|
# ==========================================
|
|
|
|
|
|
# ★ 优化步骤 2:发起批量查询,并强制 JOIN 基础物料表
|
|
|
|
|
|
# ==========================================
|
|
|
|
|
|
# 格式: { ('stock_buy', 101): stock_obj, ... }
|
|
|
|
|
|
preloaded_stocks = {}
|
|
|
|
|
|
|
|
|
|
|
|
for table_name, ids in stock_ids_by_table.items():
|
|
|
|
|
|
if not ids:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
ModelClass = model_map[table_name]
|
|
|
|
|
|
# 魔法在这里:in_() 一次性查出所有库存,joinedload 顺便把 base 表的数据一起拉回来
|
|
|
|
|
|
items = ModelClass.query.options(
|
|
|
|
|
|
joinedload(ModelClass.base)
|
|
|
|
|
|
).filter(ModelClass.id.in_(ids)).all()
|
|
|
|
|
|
|
|
|
|
|
|
for item in items:
|
|
|
|
|
|
preloaded_stocks[(table_name, item.id)] = item
|
|
|
|
|
|
|
|
|
|
|
|
# ==========================================
|
|
|
|
|
|
# ★ 优化步骤 3:第二遍循环,纯内存拼装(极速)
|
|
|
|
|
|
# ==========================================
|
2026-02-05 16:54:11 +08:00
|
|
|
|
for d in details:
|
|
|
|
|
|
ono = d.outbound_no
|
|
|
|
|
|
if ono not in grouped_map:
|
|
|
|
|
|
grouped_map[ono] = {
|
|
|
|
|
|
'outbound_no': ono,
|
|
|
|
|
|
'outbound_time': d.outbound_time.strftime('%Y-%m-%d %H:%M:%S'),
|
|
|
|
|
|
'outbound_type': d.outbound_type,
|
|
|
|
|
|
'consumer_name': d.consumer_name,
|
|
|
|
|
|
'operator_name': d.operator_name,
|
|
|
|
|
|
'signature_path': d.signature_path,
|
|
|
|
|
|
'remark': d.remark,
|
|
|
|
|
|
'total_amount': 0.0,
|
|
|
|
|
|
'items': []
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 09:49:30 +08:00
|
|
|
|
# --- 直接从内存字典中获取,O(1) 复杂度,绝对不触发 SQL ---
|
|
|
|
|
|
item_name, item_spec, item_cat, item_type, batch_sn = "未知物品", "", "", "", "-"
|
2026-02-05 16:54:11 +08:00
|
|
|
|
|
2026-05-19 09:49:30 +08:00
|
|
|
|
stock_item = preloaded_stocks.get((d.source_table, d.stock_id))
|
|
|
|
|
|
|
|
|
|
|
|
if stock_item:
|
|
|
|
|
|
batch_sn = getattr(stock_item, 'batch_number', None) or getattr(stock_item, 'serial_number', None) or '-'
|
|
|
|
|
|
|
|
|
|
|
|
# 因为前面用了 joinedload,这里调用 .base 瞬间返回,不会去查数据库
|
|
|
|
|
|
if stock_item.base:
|
|
|
|
|
|
item_name = stock_item.base.name
|
|
|
|
|
|
item_spec = stock_item.base.spec_model
|
|
|
|
|
|
item_cat = stock_item.base.category
|
|
|
|
|
|
item_type = stock_item.base.material_type
|
2026-02-05 16:54:11 +08:00
|
|
|
|
|
|
|
|
|
|
# 计算金额
|
|
|
|
|
|
price = float(d.unit_price) if d.unit_price else 0
|
|
|
|
|
|
qty = float(d.quantity)
|
|
|
|
|
|
subtotal = price * qty
|
|
|
|
|
|
|
|
|
|
|
|
grouped_map[ono]['total_amount'] += subtotal
|
|
|
|
|
|
|
|
|
|
|
|
grouped_map[ono]['items'].append({
|
|
|
|
|
|
'sku': d.sku,
|
|
|
|
|
|
'name': item_name,
|
|
|
|
|
|
'spec_model': item_spec,
|
|
|
|
|
|
'category': item_cat,
|
|
|
|
|
|
'material_type': item_type,
|
|
|
|
|
|
'quantity': qty,
|
|
|
|
|
|
'unit_price': price,
|
2026-04-07 16:41:53 +08:00
|
|
|
|
'subtotal': subtotal,
|
|
|
|
|
|
'batch_sn': batch_sn
|
2026-02-05 16:54:11 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
# 4. 排序输出
|
|
|
|
|
|
result_list = []
|
|
|
|
|
|
for ono in outbound_nos:
|
|
|
|
|
|
if ono in grouped_map:
|
|
|
|
|
|
obj = grouped_map[ono]
|
|
|
|
|
|
obj['items'].sort(key=lambda x: x['unit_price'], reverse=True)
|
|
|
|
|
|
obj['total_amount'] = round(obj['total_amount'], 2)
|
|
|
|
|
|
result_list.append(obj)
|
2026-02-05 10:20:52 +08:00
|
|
|
|
|
2026-02-04 17:22:20 +08:00
|
|
|
|
return {
|
2026-02-05 16:54:11 +08:00
|
|
|
|
'items': result_list,
|
2026-02-04 17:22:20 +08:00
|
|
|
|
'total': pagination.total,
|
|
|
|
|
|
'pages': pagination.pages,
|
|
|
|
|
|
'current_page': page
|
2026-02-10 11:34:50 +08:00
|
|
|
|
}
|
2026-04-28 16:02:34 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OutboundApprovalService:
|
|
|
|
|
|
"""出库审批服务"""
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def generate_request_no():
|
|
|
|
|
|
"""
|
|
|
|
|
|
生成审批单号: APR-OUT-yyyyMMdd-HHmm-当日流水(4位)
|
|
|
|
|
|
"""
|
|
|
|
|
|
beijing_tz = timezone(timedelta(hours=8))
|
|
|
|
|
|
now = datetime.now(beijing_tz)
|
|
|
|
|
|
|
|
|
|
|
|
date_str = now.strftime('%Y%m%d')
|
|
|
|
|
|
time_str = now.strftime('%H%M')
|
|
|
|
|
|
|
|
|
|
|
|
prefix = f"APR-OUT-{date_str}-"
|
|
|
|
|
|
|
|
|
|
|
|
from app.models.outbound import OutboundApproval
|
|
|
|
|
|
latest = db.session.query(OutboundApproval.request_no).filter(
|
|
|
|
|
|
OutboundApproval.request_no.like(f"{prefix}%")
|
|
|
|
|
|
).order_by(OutboundApproval.id.desc()).first()
|
|
|
|
|
|
|
|
|
|
|
|
if latest:
|
|
|
|
|
|
last_seq = int(latest[0].split('-')[-1])
|
|
|
|
|
|
sequence = last_seq + 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
sequence = 1
|
|
|
|
|
|
|
|
|
|
|
|
return f"APR-OUT-{date_str}-{time_str}-{sequence:04d}"
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def create_request(applicant_id, items, allowed_approvers, remark=None, approver_id=None):
|
|
|
|
|
|
"""
|
|
|
|
|
|
创建出库审批单(申请阶段,直接存储前端传来的物料信息快照,不关联具体库存记录)
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
applicant_id: 申请人ID
|
|
|
|
|
|
items: 出库物品明细列表,每个物品应包含:
|
|
|
|
|
|
- name: 物料名称 (必填)
|
|
|
|
|
|
- spec_model: 规格型号 (必填)
|
|
|
|
|
|
- quantity: 计划出库数量 (必填)
|
|
|
|
|
|
- warehouse_location: 库位 (可选)
|
|
|
|
|
|
- remark: 物品备注 (可选)
|
|
|
|
|
|
allowed_approvers: 允许审批的人员/角色列表
|
|
|
|
|
|
approver_id: 指定审批人ID(可选,传则覆盖 allowed_approvers)
|
|
|
|
|
|
remark: 申请说明
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
OutboundApproval 实例
|
|
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
|
ValueError: 当 items 为空或缺少必填字段时抛出
|
|
|
|
|
|
"""
|
|
|
|
|
|
from app.models.outbound import OutboundApproval
|
|
|
|
|
|
|
|
|
|
|
|
# 校验 items 非空
|
|
|
|
|
|
if not items:
|
|
|
|
|
|
raise ValueError("出库物品列表不能为空")
|
|
|
|
|
|
|
|
|
|
|
|
# 校验每个物品的宏观字段 (name, spec_model, quantity)
|
|
|
|
|
|
required_fields = ['name', 'spec_model', 'quantity']
|
|
|
|
|
|
for idx, item in enumerate(items):
|
|
|
|
|
|
missing_fields = [f for f in required_fields if f not in item or str(item.get(f) or '').strip() == '']
|
|
|
|
|
|
if missing_fields:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"第 {idx + 1} 条物品缺少必填字段: {', '.join(missing_fields)}。"
|
|
|
|
|
|
f"必须包含: name, spec_model, quantity"
|
|
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
qty = float(item.get('quantity', 0))
|
|
|
|
|
|
if qty <= 0:
|
|
|
|
|
|
raise ValueError(f"第 {idx + 1} 条物品的出库数量必须大于0")
|
|
|
|
|
|
except (TypeError, ValueError) as e:
|
|
|
|
|
|
raise ValueError(f"第 {idx + 1} 条物品的 quantity 格式无效: {str(e)}")
|
|
|
|
|
|
|
|
|
|
|
|
# ★ 校验 allowed_approvers 非空
|
|
|
|
|
|
if not allowed_approvers:
|
|
|
|
|
|
raise ValueError("必须指定至少一位审批人")
|
|
|
|
|
|
|
|
|
|
|
|
# ★ 指定审批人模式:approver_id 覆盖 allowed_approvers
|
|
|
|
|
|
if approver_id:
|
|
|
|
|
|
allowed_approvers = [{"type": "user", "value": int(approver_id)}]
|
|
|
|
|
|
|
|
|
|
|
|
request_no = OutboundApprovalService.generate_request_no()
|
|
|
|
|
|
|
|
|
|
|
|
approval = OutboundApproval(
|
|
|
|
|
|
request_no=request_no,
|
|
|
|
|
|
applicant_id=applicant_id,
|
|
|
|
|
|
remark=remark,
|
|
|
|
|
|
status=0, # 待审批
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 直接存储前端传来的物料信息快照,不查询/不关联具体库存记录
|
|
|
|
|
|
approval.set_items(items)
|
|
|
|
|
|
approval.set_allowed_approvers(allowed_approvers)
|
|
|
|
|
|
|
|
|
|
|
|
db.session.add(approval)
|
|
|
|
|
|
db.session.commit()
|
|
|
|
|
|
|
|
|
|
|
|
# ★ 创建成功后,发送邮件通知审批人(精确通知 approver_id 对应的邮箱)
|
|
|
|
|
|
OutboundApprovalService._notify_new_request(approval, applicant_id, approver_id=approver_id)
|
|
|
|
|
|
|
|
|
|
|
|
return approval
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _get_emails_by_identifiers(applicant_id=None, role_codes=None):
|
|
|
|
|
|
"""
|
|
|
|
|
|
根据用户ID或角色列表查询邮箱地址
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
applicant_id: 用户ID (按 SysUser.id 查找)
|
|
|
|
|
|
role_codes: 角色代码列表,如 ['ADMIN', 'WAREHOUSE_ADMIN']
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
去重后的邮箱地址列表
|
|
|
|
|
|
"""
|
|
|
|
|
|
emails = []
|
|
|
|
|
|
|
|
|
|
|
|
if applicant_id:
|
|
|
|
|
|
user = SysUser.query.get(int(applicant_id))
|
|
|
|
|
|
if user and user.email:
|
|
|
|
|
|
emails.append(user.email)
|
|
|
|
|
|
|
|
|
|
|
|
if role_codes:
|
|
|
|
|
|
for code in role_codes:
|
|
|
|
|
|
users = SysUser.query.filter_by(role=code).all()
|
|
|
|
|
|
for u in users:
|
|
|
|
|
|
if u.email:
|
|
|
|
|
|
emails.append(u.email)
|
|
|
|
|
|
|
|
|
|
|
|
return list(set(emails))
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _notify_new_request(approval, applicant_id, approver_id=None):
|
2026-05-12 13:42:15 +08:00
|
|
|
|
"""发送新申请通知邮件给审批人和申请人(静默处理,不阻断主流程)"""
|
2026-04-28 16:02:34 +08:00
|
|
|
|
try:
|
|
|
|
|
|
from flask import current_app
|
2026-06-12 15:04:57 +08:00
|
|
|
|
from app.utils.email_service import send_outbound_new_request_notify
|
2026-05-12 13:42:15 +08:00
|
|
|
|
from app.models.system import SysUser
|
2026-04-28 16:02:34 +08:00
|
|
|
|
|
2026-05-12 13:42:15 +08:00
|
|
|
|
applicant_name = ''
|
|
|
|
|
|
applicant_emails = []
|
|
|
|
|
|
|
|
|
|
|
|
# 1. 收集申请人信息
|
|
|
|
|
|
if applicant_id:
|
|
|
|
|
|
user = SysUser.query.get(int(applicant_id))
|
|
|
|
|
|
if user and user.email:
|
|
|
|
|
|
applicant_emails.append(user.email)
|
|
|
|
|
|
applicant_name = str(user.username).split('/')[0] if '/' in (user.username or '') else (user.username or str(applicant_id))
|
2026-04-28 16:02:34 +08:00
|
|
|
|
|
2026-05-12 13:42:15 +08:00
|
|
|
|
# 2. 收集审批人信息
|
|
|
|
|
|
approver_emails = []
|
2026-04-28 16:02:34 +08:00
|
|
|
|
if approver_id:
|
|
|
|
|
|
user = SysUser.query.get(int(approver_id))
|
|
|
|
|
|
if user and user.email:
|
2026-05-12 13:42:15 +08:00
|
|
|
|
approver_emails.append(user.email)
|
2026-04-28 16:02:34 +08:00
|
|
|
|
else:
|
|
|
|
|
|
# 兜底:按角色查询
|
|
|
|
|
|
approvers = approval.get_allowed_approvers()
|
|
|
|
|
|
role_codes = []
|
|
|
|
|
|
for a in approvers:
|
|
|
|
|
|
if a.get('type') == 'role':
|
|
|
|
|
|
role_codes.append(a.get('value', ''))
|
2026-05-12 13:42:15 +08:00
|
|
|
|
approver_emails = OutboundApprovalService._get_emails_by_identifiers(role_codes=role_codes)
|
2026-04-28 16:02:34 +08:00
|
|
|
|
|
2026-05-12 13:42:15 +08:00
|
|
|
|
# 去重
|
|
|
|
|
|
all_emails = list(set(applicant_emails + approver_emails))
|
|
|
|
|
|
if not all_emails:
|
|
|
|
|
|
current_app.logger.info(f"[Email] 审批单 {approval.request_no} 无收件人邮箱,跳过通知")
|
2026-04-28 16:02:34 +08:00
|
|
|
|
return
|
|
|
|
|
|
|
2026-05-12 13:42:15 +08:00
|
|
|
|
# 3. 获取物料明细
|
2026-04-28 16:46:12 +08:00
|
|
|
|
items = approval.get_items()
|
2026-05-12 13:42:15 +08:00
|
|
|
|
|
|
|
|
|
|
# 4. 分别发送邮件
|
|
|
|
|
|
if applicant_emails:
|
|
|
|
|
|
try:
|
2026-06-12 15:04:57 +08:00
|
|
|
|
send_outbound_new_request_notify(
|
2026-05-12 13:42:15 +08:00
|
|
|
|
to_emails=applicant_emails,
|
|
|
|
|
|
request_no=approval.request_no,
|
|
|
|
|
|
applicant_name=applicant_name,
|
|
|
|
|
|
remark=f"您的出库申请已提交,等待审批。{approval.remark or ''}",
|
|
|
|
|
|
items=items,
|
|
|
|
|
|
is_applicant_notify=True
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
current_app.logger.error(f"[Email] 通知申请人失败: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
if approver_emails:
|
|
|
|
|
|
try:
|
2026-06-12 15:04:57 +08:00
|
|
|
|
send_outbound_new_request_notify(
|
2026-05-12 13:42:15 +08:00
|
|
|
|
to_emails=approver_emails,
|
|
|
|
|
|
request_no=approval.request_no,
|
|
|
|
|
|
applicant_name=applicant_name,
|
|
|
|
|
|
remark=approval.remark or '',
|
|
|
|
|
|
items=items,
|
|
|
|
|
|
is_applicant_notify=False
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
current_app.logger.error(f"[Email] 通知审批人失败: {e}")
|
2026-04-28 16:46:12 +08:00
|
|
|
|
|
2026-04-28 16:02:34 +08:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
try:
|
|
|
|
|
|
from flask import current_app
|
|
|
|
|
|
current_app.logger.error(f"[Email] 发送新申请通知邮件失败: {e}")
|
|
|
|
|
|
except RuntimeError:
|
2026-04-28 16:46:12 +08:00
|
|
|
|
import logging
|
2026-04-28 16:02:34 +08:00
|
|
|
|
logging.getLogger(__name__).error(f"[Email] 发送新申请通知邮件失败: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def can_approve(approval, user_id, user_role):
|
|
|
|
|
|
"""
|
|
|
|
|
|
检查用户是否有权限审批
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
approval: OutboundApproval 实例
|
|
|
|
|
|
user_id: 用户ID
|
|
|
|
|
|
user_role: 用户角色
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
bool, 是否有权限
|
|
|
|
|
|
"""
|
|
|
|
|
|
approvers = approval.get_allowed_approvers()
|
|
|
|
|
|
|
|
|
|
|
|
# 超级管理员可以直接审批
|
|
|
|
|
|
if user_role and user_role.upper() == 'SUPER_ADMIN':
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
2026-07-17 15:17:26 +08:00
|
|
|
|
# 拥有 outbound_approval:operation 权限的用户可以审批任意出库单
|
|
|
|
|
|
# (前端按钮以此权限控制显示,后端也必须对齐)
|
|
|
|
|
|
from app.models.system import SysRolePermission
|
|
|
|
|
|
has_op = SysRolePermission.query.filter(
|
|
|
|
|
|
SysRolePermission.role_code == user_role,
|
|
|
|
|
|
SysRolePermission.target_code.in_(['outbound_approval:operation', 'outbound_approval:*'])
|
|
|
|
|
|
).first() is not None
|
|
|
|
|
|
if has_op:
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
2026-04-28 16:02:34 +08:00
|
|
|
|
for approver in approvers:
|
|
|
|
|
|
approver_type = approver.get('type', '')
|
|
|
|
|
|
approver_value = approver.get('value', '')
|
|
|
|
|
|
|
|
|
|
|
|
if approver_type == 'user' and str(approver_value) == str(user_id):
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
if approver_type == 'role' and approver_value == user_role:
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def approve(request_id, user_id, user_role, action='approve', reject_reason=None):
|
|
|
|
|
|
"""
|
|
|
|
|
|
执行审批操作
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
request_id: 审批单ID
|
|
|
|
|
|
user_id: 审批人ID
|
|
|
|
|
|
user_role: 审批人角色
|
|
|
|
|
|
action: 'approve' 通过, 'reject' 驳回
|
|
|
|
|
|
reject_reason: 驳回原因
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
(success: bool, message: str, approval: OutboundApproval or None)
|
|
|
|
|
|
"""
|
|
|
|
|
|
from app.models.outbound import OutboundApproval
|
|
|
|
|
|
|
|
|
|
|
|
beijing_tz = timezone(timedelta(hours=8))
|
|
|
|
|
|
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
|
|
|
|
|
|
|
|
|
|
|
|
approval = OutboundApproval.query.get(request_id)
|
|
|
|
|
|
if not approval:
|
|
|
|
|
|
return False, "审批单不存在", None
|
|
|
|
|
|
|
|
|
|
|
|
if approval.status != 0:
|
|
|
|
|
|
return False, f"审批单状态已更新,无法重复审批 (当前状态: {approval.status})", None
|
|
|
|
|
|
|
|
|
|
|
|
if not OutboundApprovalService.can_approve(approval, user_id, user_role):
|
|
|
|
|
|
return False, "您没有审批此单的权限", None
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
if action == 'approve':
|
|
|
|
|
|
approval.status = 1 # 已通过
|
|
|
|
|
|
approval.actual_approver_id = user_id
|
|
|
|
|
|
approval.approved_at = current_time
|
|
|
|
|
|
elif action == 'reject':
|
|
|
|
|
|
approval.status = 2 # 已驳回
|
|
|
|
|
|
approval.reject_reason = reject_reason
|
|
|
|
|
|
else:
|
|
|
|
|
|
return False, "无效的审批操作", None
|
|
|
|
|
|
|
|
|
|
|
|
db.session.commit()
|
|
|
|
|
|
|
|
|
|
|
|
# ★ 审批成功后,发送邮件通知仓库管理员
|
|
|
|
|
|
OutboundApprovalService._notify_approval_result(approval, user_id, action)
|
|
|
|
|
|
|
|
|
|
|
|
return True, "审批成功", approval
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
db.session.rollback()
|
|
|
|
|
|
return False, f"审批失败: {str(e)}", None
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _notify_approval_result(approval, approver_id, action):
|
|
|
|
|
|
"""发送审批结果通知邮件(静默处理,不阻断主流程)"""
|
2026-04-28 16:46:12 +08:00
|
|
|
|
import logging
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
2026-04-28 16:02:34 +08:00
|
|
|
|
try:
|
2026-06-12 15:04:57 +08:00
|
|
|
|
from app.utils.email_service import send_outbound_approval_result_notify, send_outbound_dispatch_notify
|
2026-04-29 09:10:34 +08:00
|
|
|
|
from app.models.system import SysUser as SU
|
2026-04-28 16:02:34 +08:00
|
|
|
|
|
2026-04-29 09:10:34 +08:00
|
|
|
|
# 1. 提取申请人信息(供两个分支使用)
|
|
|
|
|
|
applicant_name = ''
|
|
|
|
|
|
applicant_emails = []
|
|
|
|
|
|
if approval.applicant_id:
|
|
|
|
|
|
user = SU.query.get(approval.applicant_id)
|
|
|
|
|
|
if user:
|
|
|
|
|
|
applicant_name = str(user.username).split('/')[0] if '/' in (user.username or '') else (user.username or '')
|
|
|
|
|
|
if user.email:
|
|
|
|
|
|
applicant_emails.append(user.email)
|
|
|
|
|
|
|
|
|
|
|
|
# 2. 提取物料明细(供通过分支使用)
|
2026-06-12 15:04:57 +08:00
|
|
|
|
items = approval.get_items() if approval else []
|
2026-04-29 09:10:34 +08:00
|
|
|
|
|
|
|
|
|
|
# 3. 分支逻辑
|
2026-04-28 16:46:12 +08:00
|
|
|
|
if action == 'approve':
|
2026-04-29 09:10:34 +08:00
|
|
|
|
# 3.1 通知库管(带明细)
|
2026-04-28 16:46:12 +08:00
|
|
|
|
warehouse_role_codes = ['WAREHOUSE_MGR', 'OUTBOUND']
|
2026-04-29 09:10:34 +08:00
|
|
|
|
warehouse_emails = OutboundApprovalService._get_emails_by_identifiers(role_codes=warehouse_role_codes)
|
|
|
|
|
|
|
|
|
|
|
|
if warehouse_emails:
|
|
|
|
|
|
try:
|
2026-06-12 15:04:57 +08:00
|
|
|
|
send_outbound_dispatch_notify(
|
2026-04-29 09:10:34 +08:00
|
|
|
|
to_emails=warehouse_emails,
|
|
|
|
|
|
request_no=approval.request_no,
|
|
|
|
|
|
applicant_name=applicant_name,
|
|
|
|
|
|
items=items
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"[Email] 通知库管失败: {e}")
|
|
|
|
|
|
|
2026-05-12 13:42:15 +08:00
|
|
|
|
# 3.2 通知申请人(审批通过,带完整物料清单)
|
2026-04-29 09:10:34 +08:00
|
|
|
|
if applicant_emails:
|
|
|
|
|
|
try:
|
2026-06-12 15:04:57 +08:00
|
|
|
|
send_outbound_dispatch_notify(
|
2026-04-29 09:10:34 +08:00
|
|
|
|
to_emails=applicant_emails,
|
|
|
|
|
|
request_no=approval.request_no,
|
2026-05-12 13:42:15 +08:00
|
|
|
|
applicant_name=applicant_name,
|
|
|
|
|
|
items=items
|
2026-04-29 09:10:34 +08:00
|
|
|
|
)
|
|
|
|
|
|
except Exception as e:
|
2026-05-12 13:42:15 +08:00
|
|
|
|
logger.error(f"[Email] 通知申请人(通过)失败: {e}")
|
2026-04-28 16:02:34 +08:00
|
|
|
|
|
2026-04-29 09:10:34 +08:00
|
|
|
|
elif action == 'reject':
|
|
|
|
|
|
# 3.3 通知申请人(已驳回)
|
|
|
|
|
|
if applicant_emails:
|
|
|
|
|
|
try:
|
2026-06-12 15:04:57 +08:00
|
|
|
|
send_outbound_approval_result_notify(
|
2026-04-29 09:10:34 +08:00
|
|
|
|
to_emails=applicant_emails,
|
|
|
|
|
|
request_no=approval.request_no,
|
|
|
|
|
|
is_passed=False,
|
|
|
|
|
|
reject_reason=approval.reject_reason or '未说明原因',
|
|
|
|
|
|
applicant_name=applicant_name
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"[Email] 通知申请人驳回失败: {e}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.warning("[Email] 申请人无邮箱,无法发送驳回通知")
|
2026-04-28 16:02:34 +08:00
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
2026-04-28 16:46:12 +08:00
|
|
|
|
import traceback
|
|
|
|
|
|
traceback.print_exc()
|
2026-04-29 09:10:34 +08:00
|
|
|
|
logger.error(f"[Email] 外层发送异常: {e}")
|
2026-04-28 16:02:34 +08:00
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def get_request_list(page=1, per_page=10, applicant_id=None, status=None):
|
|
|
|
|
|
"""
|
|
|
|
|
|
获取审批单列表
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
page: 页码
|
|
|
|
|
|
per_page: 每页数量
|
|
|
|
|
|
applicant_id: 按申请人筛选 (可选)
|
|
|
|
|
|
status: 按状态筛选 (可选)
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
分页结果
|
|
|
|
|
|
"""
|
|
|
|
|
|
from app.models.outbound import OutboundApproval
|
2026-07-15 11:49:53 +08:00
|
|
|
|
from app.models.system import SysUser
|
|
|
|
|
|
from app.utils.decorators import get_current_company_filter
|
2026-04-28 16:02:34 +08:00
|
|
|
|
from sqlalchemy import desc
|
|
|
|
|
|
|
|
|
|
|
|
query = OutboundApproval.query
|
|
|
|
|
|
|
|
|
|
|
|
if applicant_id:
|
|
|
|
|
|
query = query.filter(OutboundApproval.applicant_id == applicant_id)
|
|
|
|
|
|
|
|
|
|
|
|
if status is not None:
|
|
|
|
|
|
query = query.filter(OutboundApproval.status == status)
|
|
|
|
|
|
|
2026-07-15 11:49:53 +08:00
|
|
|
|
# 【行级数据隔离】通过申请人关联用户表过滤公司
|
|
|
|
|
|
company_limit = get_current_company_filter()
|
|
|
|
|
|
if company_limit is not None:
|
|
|
|
|
|
query = query.join(SysUser, OutboundApproval.applicant_id == SysUser.id) \
|
|
|
|
|
|
.filter(SysUser.department == company_limit)
|
|
|
|
|
|
|
2026-04-28 16:02:34 +08:00
|
|
|
|
query = query.order_by(desc(OutboundApproval.created_at))
|
|
|
|
|
|
|
|
|
|
|
|
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
'items': [item.to_dict() for item in pagination.items],
|
|
|
|
|
|
'total': pagination.total,
|
|
|
|
|
|
'pages': pagination.pages,
|
|
|
|
|
|
'current_page': page
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def get_request_by_id(request_id):
|
|
|
|
|
|
"""根据ID获取审批单"""
|
|
|
|
|
|
from app.models.outbound import OutboundApproval
|
|
|
|
|
|
return OutboundApproval.query.get(request_id)
|