Files
KCGL/inventory-backend/app/services/outbound_service.py
yueli 91db54982c feat(track): 入库/出库 webhook 按公司路由到对应实例
三个触发点此前都不带 company_name,无从路由:
  · product_service / semi_service:入库通知补 material.company_name
  · outbound_service:track_notifications 元组加 company(取自
    stock_record.base.company_name;维修单走 repair.base,两处
    relationship 均已定义),发送时带进 payload

出库路径同时**去掉**了显式传入的 url=TRACK_OUTBOUND_WEBHOOK_URL ——
url 参数优先级最高,保留它会让出库永远打到默认实例,company 路由形同
虚设。url 参数本身保留,需要强制覆盖时仍可用。

实测:
  LICA 入库:待仓库收货 → 已入库,IRIS 库该 SN 0 行
  LICA 出库:已入库 → 已出库,IRIS 库该 SN 0 行
  IRIS 回归:对已入库设备重发入库通知,状态幂等不变
  精确隔离:发一条 IRIS 通知,lica_backend 日志 +0 行、track_backend +19 行

注意:不做"两个实例都推一遍"——两个 Track 的 webhook key 是同一把、
两边都会鉴权通过,而 Track 按 serial_number/sku 匹配,相同型号设备会
同时命中两库,把 IRIS 的设备误标成已入库,且产生 2 倍审计与流转节点。
2026-09-22 13:13:54 +08:00

1524 lines
68 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import uuid # .material -> .base refactor checked
from datetime import datetime, timezone, timedelta
from sqlalchemy import or_, func, desc, and_
from sqlalchemy.orm import joinedload
from app.extensions import db, beijing_time
from app.models.outbound import TransOutbound, OutboundApproval
# 引入所有库存模型以进行查询
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.models.transaction import TransRepair
# 引入系统用户表
from app.models.system import SysUser
# Track 联动通知(出库 → Track 标记"已出库"
from app.services.track_webhook_service import notify_track, get_current_operator
class OutboundService:
@staticmethod
def generate_outbound_no():
"""
生成出库单号: OUT-yyyyMMdd-HHmm-当日流水(4位)
例如: OUT-20260205-1558-0001
"""
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"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}"
@staticmethod
def get_stock_by_barcode(barcode, reserved_map=None):
"""
根据扫码内容查找对应的库存物品,并附带价格信息
reserved_map: {(source_table, stock_id): 本单预占量},可选。
传入后 available_quantity 返回「有效可用量」
= 实时值 + 本单预占),见 _format_scan_result。
"""
if not barcode:
return None
clean_code = barcode.strip()
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':
return float(item.pre_tax_unit_price) if item.pre_tax_unit_price else 0
return 0
# ★ 行级公司隔离:扫码出库/借库只能命中本公司的库存(超管/跨域不受限)
from app.utils.decorators import get_current_company_filter
from app.models.base import MaterialBase
company_limit = get_current_company_filter()
prod_q = StockProduct.query.filter(
or_(StockProduct.barcode == clean_code, StockProduct.sku == clean_code)
)
if company_limit is not None:
prod_q = prod_q.join(MaterialBase, StockProduct.base_id == MaterialBase.id) \
.filter(MaterialBase.company_name == company_limit)
prod = prod_q.first()
if prod:
OutboundService._assert_scan_allocatable(prod)
res = OutboundService._format_scan_result(prod, 'stock_product', reserved_map)
res['price'] = get_price(prod, 'stock_product')
return res
semi_q = StockSemi.query.filter(
or_(StockSemi.barcode == clean_code, StockSemi.sku == clean_code)
)
if company_limit is not None:
semi_q = semi_q.join(MaterialBase, StockSemi.base_id == MaterialBase.id) \
.filter(MaterialBase.company_name == company_limit)
semi = semi_q.first()
if semi:
OutboundService._assert_scan_allocatable(semi)
res = OutboundService._format_scan_result(semi, 'stock_semi', reserved_map)
res['price'] = 0
return res
buy_q = StockBuy.query.filter(
or_(StockBuy.barcode == clean_code, StockBuy.sku == clean_code)
)
if company_limit is not None:
buy_q = buy_q.join(MaterialBase, StockBuy.base_id == MaterialBase.id) \
.filter(MaterialBase.company_name == company_limit)
buy = buy_q.first()
if buy:
OutboundService._assert_scan_allocatable(buy)
res = OutboundService._format_scan_result(buy, 'stock_buy', reserved_map)
res['price'] = get_price(buy, 'stock_buy')
return res
# 查询维修单表 (按SKU或序列号查询)
# ★ 维修单不走库存 status 体系,用自身的 repair_status 做同等准入判断:
# 已出库(货已交回客户)、报废转出(实物已销毁)都不该再被扫出库。
# 原实现只排除 '已出库',报废转出的维修件仍可被扫走。
repair = TransRepair.query.filter(
or_(TransRepair.sku == clean_code, TransRepair.serial_number == clean_code)
).filter(
TransRepair.repair_status.notin_(['已出库', '报废转出'])
).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,
# 维修单不参与预占,有效可用量恒等于实时值;
# 字段形状与 _format_scan_result 对齐,避免前端拿到 undefined
'available_quantity': 1,
'raw_available_quantity': 1,
'reserved_quantity': 0,
'batch_number': repair.serial_number or '',
'serial_number': repair.serial_number or '',
'warehouse_location': repair.customer_location or '',
'barcode': repair.sku,
'price': float(repair.sale_price) if repair.sale_price else 0
}
return res
return None
@staticmethod
def _assert_scan_allocatable(item):
"""
扫码准入校验:状态异常的实物一律拦在扫码环节。
★ 为什么扫码就拦而不只在提交时拦verify_scanned 已有一道):
提交时的校验是整单粒度的,报错只说「物料 X 状态异常」,工人得自己
在一整单里找是哪一件。扫码即拦能立刻定位到手上这一件。
两道校验用同一个 is_allocatable() 判定,语义不会分叉。
★ 与分配器的关系:分配器只决定"申请时能不能把这行算进候选"
而冻结可能发生在「申请已通过、工人正在扫」的窗口内 ——
扫码这道是覆盖该窗口的必要补充。
"""
from app.services.inventory_reservation import is_allocatable
if not is_allocatable(item):
# status 为空时给出可读文案,避免报出 "当前为 " 这种半截话
status = (getattr(item, 'status', None) or '').strip() or '未设置'
raise ValueError(f"物料状态异常(当前为 {status}),禁止出库")
@staticmethod
def _format_scan_result(item, table_name, reserved_map=None):
"""
reserved_map: {(source_table, stock_id): 本单预占量},由调用方按
request_id 查得(见 api/v1/outbound.py 的 _own_reserved_index
★ available_quantity 返回的是「有效可用量」= 实时可用量 + 本单预占量。
本单自己锁掉的货当然应该能扫 —— 若直接返回实时值,被本单占满的行
会显示 0工人扫不进去。该值与后端执行阶段
restore_then_deduct() 释放预占后用于校验的数字精确相等。
实时原值另以 raw_available_quantity 返回备查。
"""
base_name = ""
base_spec = ""
base_cat = ""
base_type = ""
if hasattr(item, 'base') and item.base:
base_name = item.base.name
base_spec = item.base.spec_model
base_cat = item.base.category
base_type = item.base.material_type
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
base_cat = base_info.category
base_type = base_info.material_type
except Exception:
pass
if not base_name and hasattr(item, 'base') and item.base:
base_name = item.base.name
stock_qty = float(item.stock_quantity) if item.stock_quantity else 0
raw_avail = float(item.available_quantity) if item.available_quantity else 0
# ★ 本单预占回加(别人单子的预占不回加,防超卖能力不丢)
reserved = float((reserved_map or {}).get((table_name, item.id), 0) or 0)
return {
'id': item.id,
'sku': item.sku,
'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': raw_avail + reserved,
# 实时原值与本单预占量,备查/展示用
'raw_available_quantity': raw_avail,
'reserved_quantity': reserved,
'batch_number': getattr(item, 'batch_number', ''),
'warehouse_location': getattr(item, 'warehouse_location', ''),
'barcode': getattr(item, 'barcode', '')
}
@staticmethod
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'),
# ⚠ applicant_id 不在这里赋值 —— 此刻 approval 尚未查询(见下方
# 第 2 步「强制按单出库」),在这里引用它会直接 UnboundLocalError。
# 待 approval 取出并校验后再补进本字典。
}
beijing_tz = timezone(timedelta(hours=8))
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
# ==================================================================
# ★ 强制按单出库Fail-Closed所有出库必须关联有效的出库申请单
#
# 背景:改造前 request_id 为空时会跳过预占再平衡,落到所谓「散单」
# 分支,而该分支的库存扣减逻辑从未落地 —— 旧注释点名的
# _apply_reservation_override() 在全仓并不存在。实测后果:散单出库
# 只写 TransOutbound 台账stock_quantity / available_quantity
# 分毫不动,同一批货可被反复出库而不减库存。
#
# 前端已彻底移除散单入口views/outbound/create.vue 在未选单时把
# 扫码框整块 disabled但 HTTP 接口仍可被直接调用绕过 —— 故在此
# 后端硬阻断,防 API 级绕过。
#
# ★ 强制按单同时保证库存扣减路径唯一:
# 申请预占 → 扫码校验 → restore_then_deduct()
# status 硬隔离(仅有「在库」可被分配)也依赖这条路径才全程有效。
# ==================================================================
request_id = data.get('request_id')
if not request_id:
raise ValueError("非法操作:所有出库必须关联有效的出库申请单")
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"仅已通过的审批单方可执行出库"
)
# ★ 申请人从**关联审批单**带出。落这一列是为了让后续「退回 → 补发」能把
# 补发单挂回真正该拿东西的人名下 —— consumer_name 是扫码时自由填写的
# 领用人/客户名,不能作为依据。
# ⚠ 必须放在 approval 取出并校验**之后**common_data 在上面就已构造,
# 在那里引用 approval 会直接 UnboundLocalError上线时踩过
common_data['applicant_id'] = approval.applicant_id
model_map = {
'stock_buy': StockBuy,
'stock_semi': StockSemi,
'stock_product': StockProduct
}
# ★ Track 联动收集:(serial_number, source_table, quantity)
track_notifications = []
# ==================================================================
# ★ Phase 3预占再平衡 —— 库存扣减的唯一路径
#
# 申请阶段已把货预占在「申请时选定的批次」上。工人实际扫的可能是
# 同物料的**另一个批次**(物理覆盖),因此这里:
# 1. 校验实扫的身份/数量未超出批准范围base_id 主键,允许换批次)
# 2. 释放全部预占
# 3. 对实扫批次扣减 available_quantity 与 stock_quantity
# 之后主循环只写 TransOutbound 流水,不再重复扣库存。
#
# ★ 本段已改为**无条件执行**:上方强制 request_id 必填approval 恒非
# None。原「无关联审批单散单时跳过走原有逐行扣减逻辑」的分支
# 已删除 —— 它正是「散单出库不扣库存」的成因(被跳过的这里就是全仓
# 唯一的扣减入口,而所谓的"原有逐行扣减逻辑"并不存在)。
# ==================================================================
# ★ 自带 rollbackrestore_then_deduct() 先释放预占、后校验可用量,
# 中途抛错时 session 已带脏改动。它自身不 commit若此处不兜底
# 脏状态会一路带到请求结束。校验失败一律整单回滚,单据保持
# status=1 可重试(或走撤回/驳回),不会产生半扣留状态。
try:
from app.services.inventory_reservation import (
verify_scanned, restore_then_deduct,
)
_approved = approval.get_items()
# ★ Fail-Closed只认库存类来源。只要一条库存物料都没扫到就整单失败。
#
# 旧代码写作 `_scanned = [... if source_table != 'trans_repair']`
# 再 `if _scanned:` —— 一旦为假,校验与释放被整体跳过,而下方仍
# 无条件执行 approval.status = 3。此时申请阶段 reserve_for_items()
# 扣掉的 available_quantity 无人归还,且 status=3 没有任何释放入口
# /close 要求 status==1、/withdraw 要求 status∈(0,1)),预占即
# 永久锁死 —— 幽灵库存。
# 现在直接要求 _scanned 非空:维修单等非库存来源一并被挡在门外。
_scanned = [i for i in items if i.get('source_table') in model_map]
if not _scanned:
raise ValueError("未扫描到有效的库存物料,出库失败,请检查扫码内容")
verify_scanned(_scanned, _approved)
restore_then_deduct(_scanned, _approved)
except Exception:
db.session.rollback()
raise
try:
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")
# 处理维修单出库
if source_table == 'trans_repair':
repair = TransRepair.query.with_for_update().get(stock_id)
if not repair:
raise ValueError(f"维修单不存在 (ID: {stock_id})")
# 更新维修单状态为已出库
repair.repair_status = '已出库'
repair.shipping_date = current_time
# 收集 Track 联动信息(维修单带 serial_number
# ★ 连同 company_name 一起攒起来,发送时按公司路由到对应 Track 实例
track_notifications.append((
getattr(repair, 'serial_number', None), source_table, quantity,
getattr(getattr(repair, 'base', None), 'company_name', None),
))
# 创建出库记录
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
ModelClass = model_map.get(source_table)
if not ModelClass:
continue
# ==========================================================
# ★ Phase 3库存扣减已由「预占 + 再平衡」统一处理
#
# 流程(在下方 _apply_reservation_override 中完成):
# 1. 校验实扫身份/数量落在批准范围内base_id 主键匹配,允许换批次)
# 2. 释放申请时锁定的全部批次available_quantity 还回池子)
# 3. 对实扫批次扣减 available_quantity 与 stock_quantity
#
# 因此此处**不再**直接扣减库存,避免与再平衡逻辑重复扣两次。
# ==========================================================
stock_record = ModelClass.query.get(stock_id)
if not stock_record:
raise ValueError(f"库存记录不存在 (ID: {stock_id})")
# 收集 Track 联动信息(库存表 serial_number = Track 身份证)
# ★ 连同 company_name 一起攒起来,发送时按公司路由到对应 Track 实例
track_notifications.append((
getattr(stock_record, 'serial_number', None), source_table, quantity,
getattr(getattr(stock_record, 'base', None), 'company_name', None),
))
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,
# [新增] 记录出库时的库位快照
warehouse_location=getattr(stock_record, 'warehouse_location', None),
**common_data
)
db.session.add(new_record)
# ★ 出库成功后更新审批单状态为"已完成"
# approval 恒非 None上方已强制 request_id 必填),无需再判空
approval.status = 3 # 3-已完成
# updated_at 会在 commit 时由 SQLAlchemy 自动更新
# ★ 先提交事务,释放所有行锁,避免 SMTP 调用延长锁持有时间
db.session.commit()
# ★ 出库后通知 Track发货出库 → Track 标记"已出库"
# 按每条的 company_name 路由到对应实例IRIS / LICA
# 这里刻意**不**显式传 url显式 url 优先级最高,会一律打到默认实例,
# 导致 LICA 的出库永远收不到联动。notify_track 自身容错,不阻断业务。
try:
for sn, src_tbl, qty, company in track_notifications:
if not sn:
continue
notify_track({
'event': 'outbound.created',
'source_table': src_tbl,
'serial_number': str(sn),
'quantity': float(qty or 0),
'outbound_type': common_data['outbound_type'],
'company_name': company,
'operator': get_current_operator(),
})
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"⚠️ Track 出库通知失败: {e}")
# ★ 出库后检查低库存预警(移到 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}")
return outbound_no
except Exception as e:
db.session.rollback()
raise e
@staticmethod
def get_grouped_list(page=1, per_page=10, keyword=None, search_type='all', start_date=None, end_date=None, company=None, consumer_name=None, advanced_filters=None):
"""
查询出库记录(按出库单号分组),包含详细物品信息
支持跨表搜索单号、领用人、SKU、物料名称、规格型号
search_type: all, no, name, sku, material_name, spec_model
company: 可选的公司过滤参数
advanced_filters: 高级筛选条件列表 [{'field','operator','value'}, ...]
"""
# 日期补全:解决零点截断问题
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"
# 1. 构建基础查询
# 如果有关键词,需要联表搜索物料名称和规格型号
if keyword:
# 根据 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)
)
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
else:
keyword_conditions = None
# 【行级数据隔离】基于 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()
stmt = db.session.query(
TransOutbound.outbound_no,
func.max(TransOutbound.outbound_time).label('max_time')
).group_by(TransOutbound.outbound_no)
if keyword_conditions is not None:
stmt = stmt.filter(keyword_conditions)
# ====================================================================
# ★ 高级筛选父级字段直接过滤子级字段SKU/物料名称)走
# 「命中单号子查询 → 按单号 IN」的 EXISTS 语义。
#
# 绝不能写成 stmt.filter(TransOutbound.sku.ilike(...))
# 那样会在 GROUP BY 前收窄明细范围,展开行里的兄弟明细会丢失。
# ====================================================================
if advanced_filters:
from app.utils.advanced_filter import (
build_predicate, apply_child_condition,
)
# 父级字段:单号/操作人本身就在流水表上,直接 filter否定操作符
# 走标准 SQL != / NOT LIKE 即可,语义无歧义)
parent_field_map = {
'no': TransOutbound.outbound_no,
'operator': TransOutbound.operator_name,
'consumer_name': TransOutbound.consumer_name,
'outbound_type': TransOutbound.outbound_type,
}
# 子级字段SKU 直接列 + 物料名称(需三表联查)
child_field_map = {'sku': TransOutbound.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_field_map:
p = build_predicate(cond, parent_field_map)
if p is not None:
stmt = stmt.filter(p)
continue
if field in child_field_map or field == 'material_name':
# ★ 正/负操作符语义分派:否定 → 整单排除NOT IN
stmt = apply_child_condition(
stmt, TransOutbound.outbound_no, TransOutbound, cond,
child_field_map, material_stock_models,
)
continue
# ★ 日期边界分别判断:用 BETWEEN 时若只传一侧会整段静默失效
if start_date:
stmt = stmt.filter(TransOutbound.outbound_time >= start_date)
if end_date:
stmt = stmt.filter(TransOutbound.outbound_time <= end_date)
# 【行级数据隔离】应用公司过滤到主查询
if company_limit is not None:
stmt = stmt.filter(TransOutbound.outbound_no.in_(comp_all))
# ★ 数据权限:普通用户只看“领用人=本人姓名(不含账号前缀)”的出库记录;
# 同时兼容库里存成“姓名/xiaolongxia”全名的记录姓名 + '/' 前缀也命中)
if consumer_name:
# 注意or_ 已在模块顶部导入,此处绝不可再写 `from sqlalchemy import or_`
# —— 函数内出现对 or_ 的赋值import 即赋值)会让 Python 把 or_ 视为
# 整个函数的局部变量,导致本函数中**位于该行之前**的所有 or_ 调用
# keyword 搜索分支)抛 UnboundLocalError: referenced before assignment。
_own_out_nos = (
db.session.query(TransOutbound.outbound_no)
.filter(or_(
TransOutbound.consumer_name == consumer_name,
TransOutbound.consumer_name.like(f"{consumer_name}/%")
))
.distinct()
.subquery()
)
stmt = stmt.filter(TransOutbound.outbound_no.in_(_own_out_nos))
stmt = stmt.order_by(desc('max_time'))
# 使用 distinct 确保跨表查询不重复
stmt = stmt.distinct()
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
}
# ==========================================
# ★ 优化步骤 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第二遍循环纯内存拼装极速
# ==========================================
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': []
}
# --- 直接从内存字典中获取O(1) 复杂度,绝对不触发 SQL ---
item_name, item_spec, item_cat, item_type, batch_sn = "未知物品", "", "", "", "-"
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
# 计算金额
price = float(d.unit_price) if d.unit_price else 0
qty = float(d.quantity)
subtotal = price * qty
grouped_map[ono]['total_amount'] += subtotal
returned = float(d.returned_quantity or 0)
grouped_map[ono]['items'].append({
# ★ 退回功能所需:前端「退回」按钮要把本字段回传给
# POST /api/v1/inbound/stock/return-from-outbound 的 outbound_id。
# 注意它是**出库明细行**的主键,不是出库单号。
'id': d.id,
'sku': d.sku,
'name': item_name,
'spec_model': item_spec,
'category': item_cat,
'material_type': item_type,
'quantity': qty,
# ★ 退回额度三件套:前端据此显示「已退 / 可退」并置灰已退满的行
'returned_quantity': returned,
'returnable_quantity': qty - returned,
# ★ 原申请人ID退回弹窗勾选「需要补发」时据此**精确预填**「补发给谁」。
# 为 NULL 表示该出库产生于本列上线之前(历史无从回填),
# 前端不预填,由库管在选择器里明确指定。
'applicant_id': d.applicant_id,
'unit_price': price,
'subtotal': subtotal,
'batch_sn': batch_sn,
# [新增] 出库时的库位快照(展示用)
'warehouse_location': getattr(d, 'warehouse_location', None) or ''
})
# 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)
return {
'items': result_list,
'total': pagination.total,
'pages': pagination.pages,
'current_page': page
}
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 _items_require_approval(items):
"""
明细中任一物料命中“出库/借库需审批”→ 需要审批。
申请明细只存 name/spec_model故按 (name, spec_model) 反查启用物料判定。
"""
from app.models.base import MaterialBase
seen = set()
for item in items:
name = str(item.get('name') or '').strip()
spec = str(item.get('spec_model') or '').strip()
if not name:
continue
key = (name, spec)
if key in seen:
continue
seen.add(key)
hit = MaterialBase.query.filter(
MaterialBase.name == name,
MaterialBase.spec_model == spec,
MaterialBase.is_enabled == True,
MaterialBase.is_approval_required == True
).first()
if hit:
return True
return False
@staticmethod
def create_request(applicant_id, items, allowed_approvers, remark=None, approver_id=None, outbound_type=None, force_approval=False):
"""
创建出库审批单(申请阶段,直接存储前端传来的物料信息快照,不关联具体库存记录)
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)}")
# ★ 需审批判定:库管代建(force_approval)或明细含需审批物料 → 走审批;否则默认自动通过
from app.services.approval_control import resolve_approval_control
need_approval, flagged_materials = resolve_approval_control(items)
if force_approval:
need_approval = True
if need_approval and not approver_id:
if force_approval:
raise ValueError("库管代建出库申请必须选择审批人后再提交")
_names = "".join(f"{m['name']}{m['spec_model'] or '-'}" for m in flagged_materials)
raise ValueError(f"以下物料需审批出库/借库:{_names}。请选择审批人后再提交")
# ★ 指定审批人模式approver_id 覆盖 allowed_approvers
if approver_id:
allowed_approvers = [{"type": "user", "value": int(approver_id)}]
elif not need_approval:
allowed_approvers = [] # 免审批单不绑定审批人
request_no = OutboundApprovalService.generate_request_no()
if need_approval:
approval = OutboundApproval(
request_no=request_no,
applicant_id=applicant_id,
remark=remark,
outbound_type=outbound_type, # 申请时确定的出库类型,扫码出库时带出
status=0, # 待审批
)
else:
# 默认不审批:创建即已通过(status=1),直接进入“待库管执行”
# ★ approved_at 必须与 created_at(beijing_time) 同为 naive 本地时间:
# approved_at 列是 timestamp without time zone传 aware 时间会被驱动转成 UTC 存库,导致早 8 小时
approval = OutboundApproval(
request_no=request_no,
applicant_id=applicant_id,
remark=remark,
outbound_type=outbound_type,
status=1,
actual_approver_id=applicant_id,
approved_at=beijing_time(),
)
# ==================================================================
# ★ Phase 1库存预占
#
# 改造前此处只存「要什么、要多少」的快照,不绑库存行,真正的
# available_quantity 扣减留到执行阶段 —— 于是多张申请可以同时
# claim 同一批货,等到扫码时才发现已被领走(超卖)。
#
# 现在提交即预占:分配器把需求落到具体库存行、立即扣减
# available_quantity并把 (stock_id, source_table, allocated_qty)
# 连同 reserved 标记写进 items_json驳回/撤回时据此原样归还。
# ==================================================================
from app.services.inventory_reservation import reserve_for_items
from app.utils.decorators import get_current_company_filter
reserved_items, _shortages = reserve_for_items(
items, company_limit=get_current_company_filter(), strict=True,
)
approval.set_items(reserved_items)
if allowed_approvers:
approval.set_allowed_approvers(allowed_approvers)
else:
approval.allowed_approvers = '[]'
db.session.add(approval)
db.session.commit()
# 仅需审批单通知审批人;免审批单静默进入“待库管执行”
if need_approval:
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):
"""发送新申请通知邮件给审批人和申请人(静默处理,不阻断主流程)"""
try:
from flask import current_app
from app.utils.email_service import send_outbound_new_request_notify
from app.models.system import SysUser
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))
# 2. 收集审批人信息
approver_emails = []
if approver_id:
user = SysUser.query.get(int(approver_id))
if user and user.email:
approver_emails.append(user.email)
else:
# 兜底:按角色查询
approvers = approval.get_allowed_approvers()
role_codes = []
for a in approvers:
if a.get('type') == 'role':
role_codes.append(a.get('value', ''))
approver_emails = OutboundApprovalService._get_emails_by_identifiers(role_codes=role_codes)
# 去重
all_emails = list(set(applicant_emails + approver_emails))
if not all_emails:
current_app.logger.info(f"[Email] 审批单 {approval.request_no} 无收件人邮箱,跳过通知")
return
# 3. 获取物料明细
items = approval.get_items()
# 4. 分别发送邮件
if applicant_emails:
try:
send_outbound_new_request_notify(
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:
send_outbound_new_request_notify(
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}")
except Exception as e:
try:
from flask import current_app
current_app.logger.error(f"[Email] 发送新申请通知邮件失败: {e}")
except RuntimeError:
import logging
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
# 拥有 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
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
# ★ 统一取 naive 北京时间,与 created_at(beijing_time) 同口径
current_time = beijing_time()
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':
# ★ Phase 2驳回即释放预占把 available_quantity 还回池子,
# 否则这批货会被一张永远不会执行的单永久占住。
from app.services.inventory_reservation import release_reserved
release_reserved(approval.get_items())
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):
"""发送审批结果通知邮件(静默处理,不阻断主流程)"""
import logging
logger = logging.getLogger(__name__)
try:
from app.utils.email_service import send_outbound_approval_result_notify, send_outbound_dispatch_notify
from app.models.system import SysUser as SU
# 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. 提取物料明细(供通过分支使用)
items = approval.get_items() if approval else []
# 3. 分支逻辑
if action == 'approve':
# 3.1 通知库管(带明细)
warehouse_role_codes = ['WAREHOUSE_MGR', 'OUTBOUND']
warehouse_emails = OutboundApprovalService._get_emails_by_identifiers(role_codes=warehouse_role_codes)
if warehouse_emails:
try:
send_outbound_dispatch_notify(
to_emails=warehouse_emails,
request_no=approval.request_no,
applicant_name=applicant_name,
items=items
)
except Exception as e:
logger.error(f"[Email] 通知库管失败: {e}")
# 3.2 通知申请人(审批通过,带完整物料清单)
if applicant_emails:
try:
send_outbound_dispatch_notify(
to_emails=applicant_emails,
request_no=approval.request_no,
applicant_name=applicant_name,
items=items
)
except Exception as e:
logger.error(f"[Email] 通知申请人(通过)失败: {e}")
elif action == 'reject':
# 3.3 通知申请人(已驳回)
if applicant_emails:
try:
send_outbound_approval_result_notify(
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] 申请人无邮箱,无法发送驳回通知")
except Exception as e:
import traceback
traceback.print_exc()
logger.error(f"[Email] 外层发送异常: {e}")
@staticmethod
def close_request(request_id, user_id, user_role):
"""
撤回 / 作废审批单(状态 1-已通过 → 4-已完结)
适用场景:已通过但尚未(或未完整)执行的单据,申请人或管理者撤回,
使其从"已审批通过"列表中消失,并**释放预占的库存**。
★ 修复说明(两处严重缺陷):
1. 原先只改状态、不释放预占 —— 申请阶段 reserve_for_items() 扣掉的
available_quantity 就此永久泄漏,货被一张作废单锁死,谁也领不走。
现调用 release_reserved() 按 items_json 原样归还。
2. 原先无执行守卫 —— status=1 的单无论是否已出过货都能被"完结"
会把一张已部分执行的单标成"已完结"
现明确只允许「已通过且未执行」的单据撤回。
Args:
request_id: 审批单ID
user_id: 操作人ID
user_role: 操作人角色
Returns:
(success: bool, message: str, approval: OutboundApproval or None)
"""
from app.models.outbound import OutboundApproval
approval = OutboundApproval.query.get(request_id)
if not approval:
return False, "审批单不存在", None
# ★ 执行守卫:执行成功后 create_outbound_batch 会把 status 置为 3
# 故此处的「仅 status==1 可撤回」本身就是执行守卫 ——
# 已执行3或已撤回4的单都无法再次撤回。
# 不额外查流水表request_no(APR-OUT-…) 与 outbound_no(OUT-…) 格式不同、
# 无关联字段,按单号比对是无效的。
if approval.status != 1:
status_map = {0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成', 4: '已完结'}
return False, (
f"仅「已通过(待执行)」的申请单可撤回 "
f"(当前状态: {status_map.get(approval.status, approval.status)})"
), None
# 权限检查:超级管理员、审批人 或 拥有出库操作权限(库管/主管)
if not OutboundApprovalService.can_approve(approval, user_id, user_role):
# 放宽:拥有 outbound_create:operation 的用户(库管)也可撤回
from app.models.system import SysRolePermission
has_outbound_op = SysRolePermission.query.filter(
SysRolePermission.role_code == user_role,
SysRolePermission.target_code.in_(['outbound_create:operation', 'outbound_create:*'])
).first() is not None
if not has_outbound_op:
return False, "您没有撤回此单的权限", None
return OutboundApprovalService._release_and_close(approval, user_id)
# ------------------------------------------------------------------
# ★ 申请人撤回自己的申请单(严格职责分离)
#
# 与 close_request 的区别:
# · close_request —— 管理路径,需 outbound_approval 等权限,仅 status==1
# · withdraw_request —— 申请人路径仅校验「本人」status 0 或 1 均可
#
# 为什么单独开一条路径而不是在 close_request 里加 if 分支:
# 权限模型不同(管理角色 vs 单据归属),混在一个函数里容易在后续修改中
# 互相影响 —— 这正是需要避免的访问控制风险。两条路径共用底层释放逻辑
# _release_and_close实现上不重复。
#
# 状态说明:
# · status==0待审批提交时已预占库存撤回同样需要释放
# · status==1已通过待执行释放预占
# · status>=2已驳回/已完成/已撤回):无可撤回内容,拒绝。
# ------------------------------------------------------------------
WITHDRAWABLE_STATUS = (0, 1)
@staticmethod
def withdraw_request(request_id, user_id, user_role=None):
"""
申请人撤回自己的申请单。
权限:仅单据本人;库管/主管/超管is_privileged_viewer可代撤。
返回 (success, message, approval)
"""
from app.models.outbound import OutboundApproval
from app.utils.decorators import is_privileged_viewer
approval = OutboundApproval.query.get(request_id)
if not approval:
return False, "申请单不存在", None
# ★ 归属校验:非本人且非特权 → 拒绝(不漏出任何单据信息)
if not is_privileged_viewer() and int(approval.applicant_id) != int(user_id):
return False, "无权撤回他人的申请单", None
if approval.status not in OutboundApprovalService.WITHDRAWABLE_STATUS:
status_map = {0: '待审批', 1: '已通过(待执行)', 2: '已驳回', 3: '已完成', 4: '已撤回'}
return False, (
f"当前状态不可撤回:{status_map.get(approval.status, approval.status)}"
), None
return OutboundApprovalService._release_and_close(approval, user_id)
@staticmethod
def _release_and_close(approval, user_id):
"""
撤回的共用底层:释放预占 + 置为已撤回。
两条入口(管理路径 close_request / 申请人路径 withdraw_request
各自完成权限与状态校验后调用本函数,确保释放逻辑只有一份实现,
不会因修改其中一处而漏掉另一处。
"""
try:
from app.services.inventory_reservation import release_reserved
restored = release_reserved(approval.get_items())
# ★ 清理该单据的扫码草稿。
# 草稿按 (user_id, request_id) 隔离,撤回后单据状态变为 4
# 不会再出现在扫码页的单据下拉里(那里只拉 status=1
# 草稿从此成为永远读不到的死数据。此处一并清掉。
#
# 注意:要清**所有用户**在这张单上的草稿,而非仅撤回者自己的 ——
# 可能有多人扫过同一张单,只清自己的会留下他人的孤儿数据。
from app.models.scan_draft import ScanDraft
ScanDraft.query.filter_by(
biz_type='outbound', request_id=approval.id
).delete(synchronize_session=False)
approval.status = 4 # 4-已撤回/已完结
approval.actual_approver_id = user_id
approval.approved_at = None
db.session.commit()
return True, f"申请单已撤回,释放 {restored} 项预占库存", approval
except Exception as e:
db.session.rollback()
return False, f"撤回归还失败: {str(e)}", None
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
from app.models.system import SysUser
from app.utils.decorators import get_current_company_filter
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)
# 【行级数据隔离】通过申请人关联用户表过滤公司
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)
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)