Files
KCGL/inventory-backend/app/services/outbound_service.py

1498 lines
66 KiB
Python
Raw Normal View History

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():
2026-02-05 14:36:36 +08:00
"""
生成出库单号: OUT-yyyyMMdd-HHmm-当日流水(4位)
例如: OUT-20260205-1558-0001
2026-02-05 14:36:36 +08:00
"""
beijing_tz = timezone(timedelta(hours=8))
now = datetime.now(beijing_tz)
2026-02-05 14:36:36 +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}"
@staticmethod
fix(outbound): 扫码/备选库位回加本单预占,修正可用数重复计数 出库选单提交申请时 reserve_for_items() 会立即扣减 available_quantity(预占, 防超卖),但扫码页拿到的仍是这个已被本单扣过的值,并当作「本单能扫多少」的 上限。对本单而言它自己锁掉的货当然该能扫,于是同一批货被算了两次: · 某行被本单占满时 available=0,工人直接扫不进去,提示「库存不足或已出库」 · /alternatives 按 available_quantity > 0 过滤,被本单占满的行从列表消失 · 草稿恢复时刷新实时库存,误报「实际库存已少于你扫的数量」 改法:扫码阶段的可用量 = 实时可用量 + 本单在该行的预占量。别人单子的预占 不回加,防超卖能力不丢。该值与后端 restore_then_deduct() 释放预占后用于校验 的数字精确相等,是同一口径而非近似。 后端: - inventory_reservation.py 新增 reserved_index()/reserved_qty() 纯读工具 - outbound.py 新增 _own_reserved_index(),改造 /scan 与 /alternatives - outbound_service.py 的 _format_scan_result 返回归一化可用量 两道门禁: - biz_type 区分出库/借库两张审批单(独立表、ID 空间独立,而两个端点被 出库页与借库页共用),否则借库单 ID 会命中另一张出库单 - 单据状态仅放行 status ∈ {0,1}。set_items() 只在创建时调用,执行/驳回后 items_json 里的 reserved=True 仍原样保留而库存早已归还,门禁一松就会 二次回加 → 真超卖(现有 3 张已完成单据即属此形态) 不满足门禁时静默降级为不回加(fail-closed),并回传 reservation_applied。
2026-09-11 10:34:00 +08:00
def get_stock_by_barcode(barcode, reserved_map=None):
"""
根据扫码内容查找对应的库存物品,并附带价格信息
fix(outbound): 扫码/备选库位回加本单预占,修正可用数重复计数 出库选单提交申请时 reserve_for_items() 会立即扣减 available_quantity(预占, 防超卖),但扫码页拿到的仍是这个已被本单扣过的值,并当作「本单能扫多少」的 上限。对本单而言它自己锁掉的货当然该能扫,于是同一批货被算了两次: · 某行被本单占满时 available=0,工人直接扫不进去,提示「库存不足或已出库」 · /alternatives 按 available_quantity > 0 过滤,被本单占满的行从列表消失 · 草稿恢复时刷新实时库存,误报「实际库存已少于你扫的数量」 改法:扫码阶段的可用量 = 实时可用量 + 本单在该行的预占量。别人单子的预占 不回加,防超卖能力不丢。该值与后端 restore_then_deduct() 释放预占后用于校验 的数字精确相等,是同一口径而非近似。 后端: - inventory_reservation.py 新增 reserved_index()/reserved_qty() 纯读工具 - outbound.py 新增 _own_reserved_index(),改造 /scan 与 /alternatives - outbound_service.py 的 _format_scan_result 返回归一化可用量 两道门禁: - biz_type 区分出库/借库两张审批单(独立表、ID 空间独立,而两个端点被 出库页与借库页共用),否则借库单 ID 会命中另一张出库单 - 单据状态仅放行 status ∈ {0,1}。set_items() 只在创建时调用,执行/驳回后 items_json 里的 reserved=True 仍原样保留而库存早已归还,门禁一松就会 二次回加 → 真超卖(现有 3 张已完成单据即属此形态) 不满足门禁时静默降级为不回加(fail-closed),并回传 reservation_applied。
2026-09-11 10:34:00 +08:00
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)
fix(outbound): 扫码/备选库位回加本单预占,修正可用数重复计数 出库选单提交申请时 reserve_for_items() 会立即扣减 available_quantity(预占, 防超卖),但扫码页拿到的仍是这个已被本单扣过的值,并当作「本单能扫多少」的 上限。对本单而言它自己锁掉的货当然该能扫,于是同一批货被算了两次: · 某行被本单占满时 available=0,工人直接扫不进去,提示「库存不足或已出库」 · /alternatives 按 available_quantity > 0 过滤,被本单占满的行从列表消失 · 草稿恢复时刷新实时库存,误报「实际库存已少于你扫的数量」 改法:扫码阶段的可用量 = 实时可用量 + 本单在该行的预占量。别人单子的预占 不回加,防超卖能力不丢。该值与后端 restore_then_deduct() 释放预占后用于校验 的数字精确相等,是同一口径而非近似。 后端: - inventory_reservation.py 新增 reserved_index()/reserved_qty() 纯读工具 - outbound.py 新增 _own_reserved_index(),改造 /scan 与 /alternatives - outbound_service.py 的 _format_scan_result 返回归一化可用量 两道门禁: - biz_type 区分出库/借库两张审批单(独立表、ID 空间独立,而两个端点被 出库页与借库页共用),否则借库单 ID 会命中另一张出库单 - 单据状态仅放行 status ∈ {0,1}。set_items() 只在创建时调用,执行/驳回后 items_json 里的 reserved=True 仍原样保留而库存早已归还,门禁一松就会 二次回加 → 真超卖(现有 3 张已完成单据即属此形态) 不满足门禁时静默降级为不回加(fail-closed),并回传 reservation_applied。
2026-09-11 10:34:00 +08:00
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)
fix(outbound): 扫码/备选库位回加本单预占,修正可用数重复计数 出库选单提交申请时 reserve_for_items() 会立即扣减 available_quantity(预占, 防超卖),但扫码页拿到的仍是这个已被本单扣过的值,并当作「本单能扫多少」的 上限。对本单而言它自己锁掉的货当然该能扫,于是同一批货被算了两次: · 某行被本单占满时 available=0,工人直接扫不进去,提示「库存不足或已出库」 · /alternatives 按 available_quantity > 0 过滤,被本单占满的行从列表消失 · 草稿恢复时刷新实时库存,误报「实际库存已少于你扫的数量」 改法:扫码阶段的可用量 = 实时可用量 + 本单在该行的预占量。别人单子的预占 不回加,防超卖能力不丢。该值与后端 restore_then_deduct() 释放预占后用于校验 的数字精确相等,是同一口径而非近似。 后端: - inventory_reservation.py 新增 reserved_index()/reserved_qty() 纯读工具 - outbound.py 新增 _own_reserved_index(),改造 /scan 与 /alternatives - outbound_service.py 的 _format_scan_result 返回归一化可用量 两道门禁: - biz_type 区分出库/借库两张审批单(独立表、ID 空间独立,而两个端点被 出库页与借库页共用),否则借库单 ID 会命中另一张出库单 - 单据状态仅放行 status ∈ {0,1}。set_items() 只在创建时调用,执行/驳回后 items_json 里的 reserved=True 仍原样保留而库存早已归还,门禁一松就会 二次回加 → 真超卖(现有 3 张已完成单据即属此形态) 不满足门禁时静默降级为不回加(fail-closed),并回传 reservation_applied。
2026-09-11 10:34:00 +08:00
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)
fix(outbound): 扫码/备选库位回加本单预占,修正可用数重复计数 出库选单提交申请时 reserve_for_items() 会立即扣减 available_quantity(预占, 防超卖),但扫码页拿到的仍是这个已被本单扣过的值,并当作「本单能扫多少」的 上限。对本单而言它自己锁掉的货当然该能扫,于是同一批货被算了两次: · 某行被本单占满时 available=0,工人直接扫不进去,提示「库存不足或已出库」 · /alternatives 按 available_quantity > 0 过滤,被本单占满的行从列表消失 · 草稿恢复时刷新实时库存,误报「实际库存已少于你扫的数量」 改法:扫码阶段的可用量 = 实时可用量 + 本单在该行的预占量。别人单子的预占 不回加,防超卖能力不丢。该值与后端 restore_then_deduct() 释放预占后用于校验 的数字精确相等,是同一口径而非近似。 后端: - inventory_reservation.py 新增 reserved_index()/reserved_qty() 纯读工具 - outbound.py 新增 _own_reserved_index(),改造 /scan 与 /alternatives - outbound_service.py 的 _format_scan_result 返回归一化可用量 两道门禁: - biz_type 区分出库/借库两张审批单(独立表、ID 空间独立,而两个端点被 出库页与借库页共用),否则借库单 ID 会命中另一张出库单 - 单据状态仅放行 status ∈ {0,1}。set_items() 只在创建时调用,执行/驳回后 items_json 里的 reserved=True 仍原样保留而库存早已归还,门禁一松就会 二次回加 → 真超卖(现有 3 张已完成单据即属此形态) 不满足门禁时静默降级为不回加(fail-closed),并回传 reservation_applied。
2026-09-11 10:34:00 +08:00
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,
fix(outbound): 扫码/备选库位回加本单预占,修正可用数重复计数 出库选单提交申请时 reserve_for_items() 会立即扣减 available_quantity(预占, 防超卖),但扫码页拿到的仍是这个已被本单扣过的值,并当作「本单能扫多少」的 上限。对本单而言它自己锁掉的货当然该能扫,于是同一批货被算了两次: · 某行被本单占满时 available=0,工人直接扫不进去,提示「库存不足或已出库」 · /alternatives 按 available_quantity > 0 过滤,被本单占满的行从列表消失 · 草稿恢复时刷新实时库存,误报「实际库存已少于你扫的数量」 改法:扫码阶段的可用量 = 实时可用量 + 本单在该行的预占量。别人单子的预占 不回加,防超卖能力不丢。该值与后端 restore_then_deduct() 释放预占后用于校验 的数字精确相等,是同一口径而非近似。 后端: - inventory_reservation.py 新增 reserved_index()/reserved_qty() 纯读工具 - outbound.py 新增 _own_reserved_index(),改造 /scan 与 /alternatives - outbound_service.py 的 _format_scan_result 返回归一化可用量 两道门禁: - biz_type 区分出库/借库两张审批单(独立表、ID 空间独立,而两个端点被 出库页与借库页共用),否则借库单 ID 会命中另一张出库单 - 单据状态仅放行 status ∈ {0,1}。set_items() 只在创建时调用,执行/驳回后 items_json 里的 reserved=True 仍原样保留而库存早已归还,门禁一松就会 二次回加 → 真超卖(现有 3 张已完成单据即属此形态) 不满足门禁时静默降级为不回加(fail-closed),并回传 reservation_applied。
2026-09-11 10:34:00 +08:00
# 维修单不参与预占,有效可用量恒等于实时值;
# 字段形状与 _format_scan_result 对齐,避免前端拿到 undefined
'available_quantity': 1,
fix(outbound): 扫码/备选库位回加本单预占,修正可用数重复计数 出库选单提交申请时 reserve_for_items() 会立即扣减 available_quantity(预占, 防超卖),但扫码页拿到的仍是这个已被本单扣过的值,并当作「本单能扫多少」的 上限。对本单而言它自己锁掉的货当然该能扫,于是同一批货被算了两次: · 某行被本单占满时 available=0,工人直接扫不进去,提示「库存不足或已出库」 · /alternatives 按 available_quantity > 0 过滤,被本单占满的行从列表消失 · 草稿恢复时刷新实时库存,误报「实际库存已少于你扫的数量」 改法:扫码阶段的可用量 = 实时可用量 + 本单在该行的预占量。别人单子的预占 不回加,防超卖能力不丢。该值与后端 restore_then_deduct() 释放预占后用于校验 的数字精确相等,是同一口径而非近似。 后端: - inventory_reservation.py 新增 reserved_index()/reserved_qty() 纯读工具 - outbound.py 新增 _own_reserved_index(),改造 /scan 与 /alternatives - outbound_service.py 的 _format_scan_result 返回归一化可用量 两道门禁: - biz_type 区分出库/借库两张审批单(独立表、ID 空间独立,而两个端点被 出库页与借库页共用),否则借库单 ID 会命中另一张出库单 - 单据状态仅放行 status ∈ {0,1}。set_items() 只在创建时调用,执行/驳回后 items_json 里的 reserved=True 仍原样保留而库存早已归还,门禁一松就会 二次回加 → 真超卖(现有 3 张已完成单据即属此形态) 不满足门禁时静默降级为不回加(fail-closed),并回传 reservation_applied。
2026-09-11 10:34:00 +08:00
'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
fix(outbound): 扫码/备选库位回加本单预占,修正可用数重复计数 出库选单提交申请时 reserve_for_items() 会立即扣减 available_quantity(预占, 防超卖),但扫码页拿到的仍是这个已被本单扣过的值,并当作「本单能扫多少」的 上限。对本单而言它自己锁掉的货当然该能扫,于是同一批货被算了两次: · 某行被本单占满时 available=0,工人直接扫不进去,提示「库存不足或已出库」 · /alternatives 按 available_quantity > 0 过滤,被本单占满的行从列表消失 · 草稿恢复时刷新实时库存,误报「实际库存已少于你扫的数量」 改法:扫码阶段的可用量 = 实时可用量 + 本单在该行的预占量。别人单子的预占 不回加,防超卖能力不丢。该值与后端 restore_then_deduct() 释放预占后用于校验 的数字精确相等,是同一口径而非近似。 后端: - inventory_reservation.py 新增 reserved_index()/reserved_qty() 纯读工具 - outbound.py 新增 _own_reserved_index(),改造 /scan 与 /alternatives - outbound_service.py 的 _format_scan_result 返回归一化可用量 两道门禁: - biz_type 区分出库/借库两张审批单(独立表、ID 空间独立,而两个端点被 出库页与借库页共用),否则借库单 ID 会命中另一张出库单 - 单据状态仅放行 status ∈ {0,1}。set_items() 只在创建时调用,执行/驳回后 items_json 里的 reserved=True 仍原样保留而库存早已归还,门禁一松就会 二次回加 → 真超卖(现有 3 张已完成单据即属此形态) 不满足门禁时静默降级为不回加(fail-closed),并回传 reservation_applied。
2026-09-11 10:34:00 +08:00
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
fix(outbound): 扫码/备选库位回加本单预占,修正可用数重复计数 出库选单提交申请时 reserve_for_items() 会立即扣减 available_quantity(预占, 防超卖),但扫码页拿到的仍是这个已被本单扣过的值,并当作「本单能扫多少」的 上限。对本单而言它自己锁掉的货当然该能扫,于是同一批货被算了两次: · 某行被本单占满时 available=0,工人直接扫不进去,提示「库存不足或已出库」 · /alternatives 按 available_quantity > 0 过滤,被本单占满的行从列表消失 · 草稿恢复时刷新实时库存,误报「实际库存已少于你扫的数量」 改法:扫码阶段的可用量 = 实时可用量 + 本单在该行的预占量。别人单子的预占 不回加,防超卖能力不丢。该值与后端 restore_then_deduct() 释放预占后用于校验 的数字精确相等,是同一口径而非近似。 后端: - inventory_reservation.py 新增 reserved_index()/reserved_qty() 纯读工具 - outbound.py 新增 _own_reserved_index(),改造 /scan 与 /alternatives - outbound_service.py 的 _format_scan_result 返回归一化可用量 两道门禁: - biz_type 区分出库/借库两张审批单(独立表、ID 空间独立,而两个端点被 出库页与借库页共用),否则借库单 ID 会命中另一张出库单 - 单据状态仅放行 status ∈ {0,1}。set_items() 只在创建时调用,执行/驳回后 items_json 里的 reserved=True 仍原样保留而库存早已归还,门禁一松就会 二次回加 → 真超卖(现有 3 张已完成单据即属此形态) 不满足门禁时静默降级为不回加(fail-closed),并回传 reservation_applied。
2026-09-11 10:34:00 +08:00
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,
fix(outbound): 扫码/备选库位回加本单预占,修正可用数重复计数 出库选单提交申请时 reserve_for_items() 会立即扣减 available_quantity(预占, 防超卖),但扫码页拿到的仍是这个已被本单扣过的值,并当作「本单能扫多少」的 上限。对本单而言它自己锁掉的货当然该能扫,于是同一批货被算了两次: · 某行被本单占满时 available=0,工人直接扫不进去,提示「库存不足或已出库」 · /alternatives 按 available_quantity > 0 过滤,被本单占满的行从列表消失 · 草稿恢复时刷新实时库存,误报「实际库存已少于你扫的数量」 改法:扫码阶段的可用量 = 实时可用量 + 本单在该行的预占量。别人单子的预占 不回加,防超卖能力不丢。该值与后端 restore_then_deduct() 释放预占后用于校验 的数字精确相等,是同一口径而非近似。 后端: - inventory_reservation.py 新增 reserved_index()/reserved_qty() 纯读工具 - outbound.py 新增 _own_reserved_index(),改造 /scan 与 /alternatives - outbound_service.py 的 _format_scan_result 返回归一化可用量 两道门禁: - biz_type 区分出库/借库两张审批单(独立表、ID 空间独立,而两个端点被 出库页与借库页共用),否则借库单 ID 会命中另一张出库单 - 单据状态仅放行 status ∈ {0,1}。set_items() 只在创建时调用,执行/驳回后 items_json 里的 reserved=True 仍原样保留而库存早已归还,门禁一松就会 二次回加 → 真超卖(现有 3 张已完成单据即属此形态) 不满足门禁时静默降级为不回加(fail-closed),并回传 reservation_applied。
2026-09-11 10:34:00 +08:00
# 有效可用量:本单可扫的上限
'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')
}
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"仅已通过的审批单方可执行出库"
)
model_map = {
'stock_buy': StockBuy,
'stock_semi': StockSemi,
'stock_product': StockProduct
}
# ★ Track 联动收集:(serial_number, source_table, quantity)
track_notifications = []
feat(inventory): 库存预占生命周期,消除出库/借库超卖 问题:库存超卖 -------------- 改造前出库/借库申请只记录「要什么、要多少」,不绑定具体库存行, 真正的 available_quantity 扣减发生在执行阶段。于是多张申请可以同时 claim 同一批货,等到工人拿扫码枪时才发现货已被别人领走。 生命周期(三阶段) ------------------ 提交申请(预占) reserve_for_items() 用分配器把需求落到具体库存行,立即扣减 available_quantity, 并把 (stock_id, source_table, allocated_qty, reserved) 写回 items_json。 驳回(释放) release_reserved() 遍历 items_json 把预占量还回池子,避免货被永不执行的单永久占住。 扫码执行(覆盖) verify_scanned() + restore_then_deduct() 校验实扫身份/数量未超批准范围 → 释放全部预占 → 对实扫批次 同时扣减 available_quantity 与 stock_quantity。 身份键:base_id 主键 + SKU 兜底(重要设计决策) ----------------------------------------------- 本系统中 SKU 是**批次级**编号:同一 base_id 下每个入库批次各有不同的 SKU(实测 stock_buy 有 183 个物料是多批次的,如 base_id=2405 下有 0000001685 与 0000001974 两个 SKU)。 若以 SKU 作为身份主键,「申请时锁定 A 批、工人现场改扫 B 批」会被判为 身份不符而拒绝 —— 恰好否定了「物理覆盖」这个核心能力。 故改用 base_id(物料级、跨批次稳定,spec_model 由其唯一确定), 历史数据无 base_id 时降级为 (name, spec_model)。 可用量校验按物料汇总,而非按单批次 ---------------------------------- 开发中修正的一处缺陷:若逐行要求「该批次可用量 >= 该批次扫码量」, 工人改扫小批次时会被误拒。例如本单预占 A 批 5 件,改扫 B 批 2 件 + C 批 3 件,B 批自身只有 2 件可用,逐行校验即失败。实际这 5 件都是本单 锁定的货,理应允许。现按物料汇总校验可用量,按行校验实物库存。 改动文件 -------- · 新增 app/services/inventory_reservation.py(通用服务层) · outbound_service.create_request —— Phase 1 预占 · outbound_service.approve(reject) —— Phase 2 释放 · outbound_service.create_outbound_batch —— Phase 3 覆盖(移除原逐行扣减) · borrow_service.submit_approval —— Phase 1 · borrow_service.approve(reject) —— Phase 2 · trans_service.execute_dispatch —— Phase 3,并用统一身份键替换 原有的 (name, spec_model) 字符串匹配 实测(真实 HTTP 全链路) ------------------------ 初始 available=10 ① 提交申请(需5) → 200,available 10→5 预占生效 ② 审批通过 → available 仍为 5 预占保留 ③ 扫码执行(改扫另一批次 4 件) → 200 原批次恢复满额、实扫批次扣减(0,0),available=6, stock=6 单场景验证:预占 A 批改扫 B 批放行;驳回后可用量完全恢复; 扫其他物料被拒;批准 6 扫 8 被拒。
2026-09-10 14:59:10 +08:00
# ==================================================================
# ★ Phase 3:预占再平衡 —— 库存扣减的唯一路径
feat(inventory): 库存预占生命周期,消除出库/借库超卖 问题:库存超卖 -------------- 改造前出库/借库申请只记录「要什么、要多少」,不绑定具体库存行, 真正的 available_quantity 扣减发生在执行阶段。于是多张申请可以同时 claim 同一批货,等到工人拿扫码枪时才发现货已被别人领走。 生命周期(三阶段) ------------------ 提交申请(预占) reserve_for_items() 用分配器把需求落到具体库存行,立即扣减 available_quantity, 并把 (stock_id, source_table, allocated_qty, reserved) 写回 items_json。 驳回(释放) release_reserved() 遍历 items_json 把预占量还回池子,避免货被永不执行的单永久占住。 扫码执行(覆盖) verify_scanned() + restore_then_deduct() 校验实扫身份/数量未超批准范围 → 释放全部预占 → 对实扫批次 同时扣减 available_quantity 与 stock_quantity。 身份键:base_id 主键 + SKU 兜底(重要设计决策) ----------------------------------------------- 本系统中 SKU 是**批次级**编号:同一 base_id 下每个入库批次各有不同的 SKU(实测 stock_buy 有 183 个物料是多批次的,如 base_id=2405 下有 0000001685 与 0000001974 两个 SKU)。 若以 SKU 作为身份主键,「申请时锁定 A 批、工人现场改扫 B 批」会被判为 身份不符而拒绝 —— 恰好否定了「物理覆盖」这个核心能力。 故改用 base_id(物料级、跨批次稳定,spec_model 由其唯一确定), 历史数据无 base_id 时降级为 (name, spec_model)。 可用量校验按物料汇总,而非按单批次 ---------------------------------- 开发中修正的一处缺陷:若逐行要求「该批次可用量 >= 该批次扫码量」, 工人改扫小批次时会被误拒。例如本单预占 A 批 5 件,改扫 B 批 2 件 + C 批 3 件,B 批自身只有 2 件可用,逐行校验即失败。实际这 5 件都是本单 锁定的货,理应允许。现按物料汇总校验可用量,按行校验实物库存。 改动文件 -------- · 新增 app/services/inventory_reservation.py(通用服务层) · outbound_service.create_request —— Phase 1 预占 · outbound_service.approve(reject) —— Phase 2 释放 · outbound_service.create_outbound_batch —— Phase 3 覆盖(移除原逐行扣减) · borrow_service.submit_approval —— Phase 1 · borrow_service.approve(reject) —— Phase 2 · trans_service.execute_dispatch —— Phase 3,并用统一身份键替换 原有的 (name, spec_model) 字符串匹配 实测(真实 HTTP 全链路) ------------------------ 初始 available=10 ① 提交申请(需5) → 200,available 10→5 预占生效 ② 审批通过 → available 仍为 5 预占保留 ③ 扫码执行(改扫另一批次 4 件) → 200 原批次恢复满额、实扫批次扣减(0,0),available=6, stock=6 单场景验证:预占 A 批改扫 B 批放行;驳回后可用量完全恢复; 扫其他物料被拒;批准 6 扫 8 被拒。
2026-09-10 14:59:10 +08:00
#
# 申请阶段已把货预占在「申请时选定的批次」上。工人实际扫的可能是
# 同物料的**另一个批次**(物理覆盖),因此这里:
feat(inventory): 库存预占生命周期,消除出库/借库超卖 问题:库存超卖 -------------- 改造前出库/借库申请只记录「要什么、要多少」,不绑定具体库存行, 真正的 available_quantity 扣减发生在执行阶段。于是多张申请可以同时 claim 同一批货,等到工人拿扫码枪时才发现货已被别人领走。 生命周期(三阶段) ------------------ 提交申请(预占) reserve_for_items() 用分配器把需求落到具体库存行,立即扣减 available_quantity, 并把 (stock_id, source_table, allocated_qty, reserved) 写回 items_json。 驳回(释放) release_reserved() 遍历 items_json 把预占量还回池子,避免货被永不执行的单永久占住。 扫码执行(覆盖) verify_scanned() + restore_then_deduct() 校验实扫身份/数量未超批准范围 → 释放全部预占 → 对实扫批次 同时扣减 available_quantity 与 stock_quantity。 身份键:base_id 主键 + SKU 兜底(重要设计决策) ----------------------------------------------- 本系统中 SKU 是**批次级**编号:同一 base_id 下每个入库批次各有不同的 SKU(实测 stock_buy 有 183 个物料是多批次的,如 base_id=2405 下有 0000001685 与 0000001974 两个 SKU)。 若以 SKU 作为身份主键,「申请时锁定 A 批、工人现场改扫 B 批」会被判为 身份不符而拒绝 —— 恰好否定了「物理覆盖」这个核心能力。 故改用 base_id(物料级、跨批次稳定,spec_model 由其唯一确定), 历史数据无 base_id 时降级为 (name, spec_model)。 可用量校验按物料汇总,而非按单批次 ---------------------------------- 开发中修正的一处缺陷:若逐行要求「该批次可用量 >= 该批次扫码量」, 工人改扫小批次时会被误拒。例如本单预占 A 批 5 件,改扫 B 批 2 件 + C 批 3 件,B 批自身只有 2 件可用,逐行校验即失败。实际这 5 件都是本单 锁定的货,理应允许。现按物料汇总校验可用量,按行校验实物库存。 改动文件 -------- · 新增 app/services/inventory_reservation.py(通用服务层) · outbound_service.create_request —— Phase 1 预占 · outbound_service.approve(reject) —— Phase 2 释放 · outbound_service.create_outbound_batch —— Phase 3 覆盖(移除原逐行扣减) · borrow_service.submit_approval —— Phase 1 · borrow_service.approve(reject) —— Phase 2 · trans_service.execute_dispatch —— Phase 3,并用统一身份键替换 原有的 (name, spec_model) 字符串匹配 实测(真实 HTTP 全链路) ------------------------ 初始 available=10 ① 提交申请(需5) → 200,available 10→5 预占生效 ② 审批通过 → available 仍为 5 预占保留 ③ 扫码执行(改扫另一批次 4 件) → 200 原批次恢复满额、实扫批次扣减(0,0),available=6, stock=6 单场景验证:预占 A 批改扫 B 批放行;驳回后可用量完全恢复; 扫其他物料被拒;批准 6 扫 8 被拒。
2026-09-10 14:59:10 +08:00
# 1. 校验实扫的身份/数量未超出批准范围(base_id 主键,允许换批次)
# 2. 释放全部预占
# 3. 对实扫批次扣减 available_quantity 与 stock_quantity
# 之后主循环只写 TransOutbound 流水,不再重复扣库存。
#
# ★ 本段已改为**无条件执行**:上方强制 request_id 必填,approval 恒非
# None。原「无关联审批单(散单)时跳过,走原有逐行扣减逻辑」的分支
# 已删除 —— 它正是「散单出库不扣库存」的成因(被跳过的这里就是全仓
# 唯一的扣减入口,而所谓的"原有逐行扣减逻辑"并不存在)。
feat(inventory): 库存预占生命周期,消除出库/借库超卖 问题:库存超卖 -------------- 改造前出库/借库申请只记录「要什么、要多少」,不绑定具体库存行, 真正的 available_quantity 扣减发生在执行阶段。于是多张申请可以同时 claim 同一批货,等到工人拿扫码枪时才发现货已被别人领走。 生命周期(三阶段) ------------------ 提交申请(预占) reserve_for_items() 用分配器把需求落到具体库存行,立即扣减 available_quantity, 并把 (stock_id, source_table, allocated_qty, reserved) 写回 items_json。 驳回(释放) release_reserved() 遍历 items_json 把预占量还回池子,避免货被永不执行的单永久占住。 扫码执行(覆盖) verify_scanned() + restore_then_deduct() 校验实扫身份/数量未超批准范围 → 释放全部预占 → 对实扫批次 同时扣减 available_quantity 与 stock_quantity。 身份键:base_id 主键 + SKU 兜底(重要设计决策) ----------------------------------------------- 本系统中 SKU 是**批次级**编号:同一 base_id 下每个入库批次各有不同的 SKU(实测 stock_buy 有 183 个物料是多批次的,如 base_id=2405 下有 0000001685 与 0000001974 两个 SKU)。 若以 SKU 作为身份主键,「申请时锁定 A 批、工人现场改扫 B 批」会被判为 身份不符而拒绝 —— 恰好否定了「物理覆盖」这个核心能力。 故改用 base_id(物料级、跨批次稳定,spec_model 由其唯一确定), 历史数据无 base_id 时降级为 (name, spec_model)。 可用量校验按物料汇总,而非按单批次 ---------------------------------- 开发中修正的一处缺陷:若逐行要求「该批次可用量 >= 该批次扫码量」, 工人改扫小批次时会被误拒。例如本单预占 A 批 5 件,改扫 B 批 2 件 + C 批 3 件,B 批自身只有 2 件可用,逐行校验即失败。实际这 5 件都是本单 锁定的货,理应允许。现按物料汇总校验可用量,按行校验实物库存。 改动文件 -------- · 新增 app/services/inventory_reservation.py(通用服务层) · outbound_service.create_request —— Phase 1 预占 · outbound_service.approve(reject) —— Phase 2 释放 · outbound_service.create_outbound_batch —— Phase 3 覆盖(移除原逐行扣减) · borrow_service.submit_approval —— Phase 1 · borrow_service.approve(reject) —— Phase 2 · trans_service.execute_dispatch —— Phase 3,并用统一身份键替换 原有的 (name, spec_model) 字符串匹配 实测(真实 HTTP 全链路) ------------------------ 初始 available=10 ① 提交申请(需5) → 200,available 10→5 预占生效 ② 审批通过 → available 仍为 5 预占保留 ③ 扫码执行(改扫另一批次 4 件) → 200 原批次恢复满额、实扫批次扣减(0,0),available=6, stock=6 单场景验证:预占 A 批改扫 B 批放行;驳回后可用量完全恢复; 扫其他物料被拒;批准 6 扫 8 被拒。
2026-09-10 14:59:10 +08:00
# ==================================================================
# ★ 自带 rollback:restore_then_deduct() 先释放预占、后校验可用量,
# 中途抛错时 session 已带脏改动。它自身不 commit,若此处不兜底,
# 脏状态会一路带到请求结束。校验失败一律整单回滚,单据保持
# status=1 可重试(或走撤回/驳回),不会产生半扣留状态。
try:
feat(inventory): 库存预占生命周期,消除出库/借库超卖 问题:库存超卖 -------------- 改造前出库/借库申请只记录「要什么、要多少」,不绑定具体库存行, 真正的 available_quantity 扣减发生在执行阶段。于是多张申请可以同时 claim 同一批货,等到工人拿扫码枪时才发现货已被别人领走。 生命周期(三阶段) ------------------ 提交申请(预占) reserve_for_items() 用分配器把需求落到具体库存行,立即扣减 available_quantity, 并把 (stock_id, source_table, allocated_qty, reserved) 写回 items_json。 驳回(释放) release_reserved() 遍历 items_json 把预占量还回池子,避免货被永不执行的单永久占住。 扫码执行(覆盖) verify_scanned() + restore_then_deduct() 校验实扫身份/数量未超批准范围 → 释放全部预占 → 对实扫批次 同时扣减 available_quantity 与 stock_quantity。 身份键:base_id 主键 + SKU 兜底(重要设计决策) ----------------------------------------------- 本系统中 SKU 是**批次级**编号:同一 base_id 下每个入库批次各有不同的 SKU(实测 stock_buy 有 183 个物料是多批次的,如 base_id=2405 下有 0000001685 与 0000001974 两个 SKU)。 若以 SKU 作为身份主键,「申请时锁定 A 批、工人现场改扫 B 批」会被判为 身份不符而拒绝 —— 恰好否定了「物理覆盖」这个核心能力。 故改用 base_id(物料级、跨批次稳定,spec_model 由其唯一确定), 历史数据无 base_id 时降级为 (name, spec_model)。 可用量校验按物料汇总,而非按单批次 ---------------------------------- 开发中修正的一处缺陷:若逐行要求「该批次可用量 >= 该批次扫码量」, 工人改扫小批次时会被误拒。例如本单预占 A 批 5 件,改扫 B 批 2 件 + C 批 3 件,B 批自身只有 2 件可用,逐行校验即失败。实际这 5 件都是本单 锁定的货,理应允许。现按物料汇总校验可用量,按行校验实物库存。 改动文件 -------- · 新增 app/services/inventory_reservation.py(通用服务层) · outbound_service.create_request —— Phase 1 预占 · outbound_service.approve(reject) —— Phase 2 释放 · outbound_service.create_outbound_batch —— Phase 3 覆盖(移除原逐行扣减) · borrow_service.submit_approval —— Phase 1 · borrow_service.approve(reject) —— Phase 2 · trans_service.execute_dispatch —— Phase 3,并用统一身份键替换 原有的 (name, spec_model) 字符串匹配 实测(真实 HTTP 全链路) ------------------------ 初始 available=10 ① 提交申请(需5) → 200,available 10→5 预占生效 ② 审批通过 → available 仍为 5 预占保留 ③ 扫码执行(改扫另一批次 4 件) → 200 原批次恢复满额、实扫批次扣减(0,0),available=6, stock=6 单场景验证:预占 A 批改扫 B 批放行;驳回后可用量完全恢复; 扫其他物料被拒;批准 6 扫 8 被拒。
2026-09-10 14:59:10 +08:00
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
feat(inventory): 库存预占生命周期,消除出库/借库超卖 问题:库存超卖 -------------- 改造前出库/借库申请只记录「要什么、要多少」,不绑定具体库存行, 真正的 available_quantity 扣减发生在执行阶段。于是多张申请可以同时 claim 同一批货,等到工人拿扫码枪时才发现货已被别人领走。 生命周期(三阶段) ------------------ 提交申请(预占) reserve_for_items() 用分配器把需求落到具体库存行,立即扣减 available_quantity, 并把 (stock_id, source_table, allocated_qty, reserved) 写回 items_json。 驳回(释放) release_reserved() 遍历 items_json 把预占量还回池子,避免货被永不执行的单永久占住。 扫码执行(覆盖) verify_scanned() + restore_then_deduct() 校验实扫身份/数量未超批准范围 → 释放全部预占 → 对实扫批次 同时扣减 available_quantity 与 stock_quantity。 身份键:base_id 主键 + SKU 兜底(重要设计决策) ----------------------------------------------- 本系统中 SKU 是**批次级**编号:同一 base_id 下每个入库批次各有不同的 SKU(实测 stock_buy 有 183 个物料是多批次的,如 base_id=2405 下有 0000001685 与 0000001974 两个 SKU)。 若以 SKU 作为身份主键,「申请时锁定 A 批、工人现场改扫 B 批」会被判为 身份不符而拒绝 —— 恰好否定了「物理覆盖」这个核心能力。 故改用 base_id(物料级、跨批次稳定,spec_model 由其唯一确定), 历史数据无 base_id 时降级为 (name, spec_model)。 可用量校验按物料汇总,而非按单批次 ---------------------------------- 开发中修正的一处缺陷:若逐行要求「该批次可用量 >= 该批次扫码量」, 工人改扫小批次时会被误拒。例如本单预占 A 批 5 件,改扫 B 批 2 件 + C 批 3 件,B 批自身只有 2 件可用,逐行校验即失败。实际这 5 件都是本单 锁定的货,理应允许。现按物料汇总校验可用量,按行校验实物库存。 改动文件 -------- · 新增 app/services/inventory_reservation.py(通用服务层) · outbound_service.create_request —— Phase 1 预占 · outbound_service.approve(reject) —— Phase 2 释放 · outbound_service.create_outbound_batch —— Phase 3 覆盖(移除原逐行扣减) · borrow_service.submit_approval —— Phase 1 · borrow_service.approve(reject) —— Phase 2 · trans_service.execute_dispatch —— Phase 3,并用统一身份键替换 原有的 (name, spec_model) 字符串匹配 实测(真实 HTTP 全链路) ------------------------ 初始 available=10 ① 提交申请(需5) → 200,available 10→5 预占生效 ② 审批通过 → available 仍为 5 预占保留 ③ 扫码执行(改扫另一批次 4 件) → 200 原批次恢复满额、实扫批次扣减(0,0),available=6, stock=6 单场景验证:预占 A 批改扫 B 批放行;驳回后可用量完全恢复; 扫其他物料被拒;批准 6 扫 8 被拒。
2026-09-10 14:59:10 +08:00
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)
track_notifications.append((getattr(repair, 'serial_number', None), source_table, 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)
continue
ModelClass = model_map.get(source_table)
if not ModelClass:
continue
feat(inventory): 库存预占生命周期,消除出库/借库超卖 问题:库存超卖 -------------- 改造前出库/借库申请只记录「要什么、要多少」,不绑定具体库存行, 真正的 available_quantity 扣减发生在执行阶段。于是多张申请可以同时 claim 同一批货,等到工人拿扫码枪时才发现货已被别人领走。 生命周期(三阶段) ------------------ 提交申请(预占) reserve_for_items() 用分配器把需求落到具体库存行,立即扣减 available_quantity, 并把 (stock_id, source_table, allocated_qty, reserved) 写回 items_json。 驳回(释放) release_reserved() 遍历 items_json 把预占量还回池子,避免货被永不执行的单永久占住。 扫码执行(覆盖) verify_scanned() + restore_then_deduct() 校验实扫身份/数量未超批准范围 → 释放全部预占 → 对实扫批次 同时扣减 available_quantity 与 stock_quantity。 身份键:base_id 主键 + SKU 兜底(重要设计决策) ----------------------------------------------- 本系统中 SKU 是**批次级**编号:同一 base_id 下每个入库批次各有不同的 SKU(实测 stock_buy 有 183 个物料是多批次的,如 base_id=2405 下有 0000001685 与 0000001974 两个 SKU)。 若以 SKU 作为身份主键,「申请时锁定 A 批、工人现场改扫 B 批」会被判为 身份不符而拒绝 —— 恰好否定了「物理覆盖」这个核心能力。 故改用 base_id(物料级、跨批次稳定,spec_model 由其唯一确定), 历史数据无 base_id 时降级为 (name, spec_model)。 可用量校验按物料汇总,而非按单批次 ---------------------------------- 开发中修正的一处缺陷:若逐行要求「该批次可用量 >= 该批次扫码量」, 工人改扫小批次时会被误拒。例如本单预占 A 批 5 件,改扫 B 批 2 件 + C 批 3 件,B 批自身只有 2 件可用,逐行校验即失败。实际这 5 件都是本单 锁定的货,理应允许。现按物料汇总校验可用量,按行校验实物库存。 改动文件 -------- · 新增 app/services/inventory_reservation.py(通用服务层) · outbound_service.create_request —— Phase 1 预占 · outbound_service.approve(reject) —— Phase 2 释放 · outbound_service.create_outbound_batch —— Phase 3 覆盖(移除原逐行扣减) · borrow_service.submit_approval —— Phase 1 · borrow_service.approve(reject) —— Phase 2 · trans_service.execute_dispatch —— Phase 3,并用统一身份键替换 原有的 (name, spec_model) 字符串匹配 实测(真实 HTTP 全链路) ------------------------ 初始 available=10 ① 提交申请(需5) → 200,available 10→5 预占生效 ② 审批通过 → available 仍为 5 预占保留 ③ 扫码执行(改扫另一批次 4 件) → 200 原批次恢复满额、实扫批次扣减(0,0),available=6, stock=6 单场景验证:预占 A 批改扫 B 批放行;驳回后可用量完全恢复; 扫其他物料被拒;批准 6 扫 8 被拒。
2026-09-10 14:59:10 +08:00
# ==========================================================
# ★ 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 身份证)
track_notifications.append((getattr(stock_record, 'serial_number', None), source_table, 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,
# [新增] 记录出库时的库位快照
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 自动更新
perf: 系统级性能优化与并发安全修复 ## 并发安全修复 (4处) - scrap.py: 报废执行添加 SELECT FOR UPDATE 悲观锁,消除 TOCTOU 竞态 - stock.py (adjust_stock): 盘点调整添加 for_update=True 行锁 - outbound_service.py: 低库存预警 SMTP 调用移到 commit 之后,避免长事务 - trans_service.py: execute_dispatch 按 (source_table, id) 排序 items,消除死锁风险 ## N+1 查询优化 (2处) - inventory_task.py: _prefetch_inventory_map 单条 UNION ALL+GROUP BY 替代循环内逐条查询(N*4次→2次) - stock.py (export_stocktake): get_borrowed_qty 批量 GROUP BY 替代逐条 TransBorrow 查询(~18000次→1次) ## BOM 列表性能重构 - bom_service.py: get_bom_list 单条 GROUP BY+string_agg+分页,消除 N+1 循环查询 - bom_service.py: 新增 get_bom_summary (轻量 GROUP BY category+COUNT) - bom.py: 新增 /api/v1/bom/summary 路由,/list 支持 category 过滤 ## Odoo 基础信息懒加载 - base_service.py: 新增 get_odoo_summary (GROUP BY category+COUNT) - base.py: 新增 /api/v1/inbound/base/odoo-summary 路由 - buyOdoo.vue: 懒加载分组架构 (fetchOdooSummary + loadGroupItems) - material_base.ts: 新增 getOdooSummary API ## 前端 Bug 修复 - BomManage.vue: 懒加载分组 (fetchBomSummary + loadGroupItems + collapse) - BomManage.vue: 适配新 API 格式 (res.data.items 替代 res.data) - buyOdoo.vue: 移除 "点击展开加载" 文字 - Selection.vue + borrow/apply/index.vue: openBomSelect 适配新 API 格式
2026-07-15 17:37:57 +08:00
# ★ 先提交事务,释放所有行锁,避免 SMTP 调用延长锁持有时间
db.session.commit()
perf: 系统级性能优化与并发安全修复 ## 并发安全修复 (4处) - scrap.py: 报废执行添加 SELECT FOR UPDATE 悲观锁,消除 TOCTOU 竞态 - stock.py (adjust_stock): 盘点调整添加 for_update=True 行锁 - outbound_service.py: 低库存预警 SMTP 调用移到 commit 之后,避免长事务 - trans_service.py: execute_dispatch 按 (source_table, id) 排序 items,消除死锁风险 ## N+1 查询优化 (2处) - inventory_task.py: _prefetch_inventory_map 单条 UNION ALL+GROUP BY 替代循环内逐条查询(N*4次→2次) - stock.py (export_stocktake): get_borrowed_qty 批量 GROUP BY 替代逐条 TransBorrow 查询(~18000次→1次) ## BOM 列表性能重构 - bom_service.py: get_bom_list 单条 GROUP BY+string_agg+分页,消除 N+1 循环查询 - bom_service.py: 新增 get_bom_summary (轻量 GROUP BY category+COUNT) - bom.py: 新增 /api/v1/bom/summary 路由,/list 支持 category 过滤 ## Odoo 基础信息懒加载 - base_service.py: 新增 get_odoo_summary (GROUP BY category+COUNT) - base.py: 新增 /api/v1/inbound/base/odoo-summary 路由 - buyOdoo.vue: 懒加载分组架构 (fetchOdooSummary + loadGroupItems) - material_base.ts: 新增 getOdooSummary API ## 前端 Bug 修复 - BomManage.vue: 懒加载分组 (fetchBomSummary + loadGroupItems + collapse) - BomManage.vue: 适配新 API 格式 (res.data.items 替代 res.data) - buyOdoo.vue: 移除 "点击展开加载" 文字 - Selection.vue + borrow/apply/index.vue: openBomSelect 适配新 API 格式
2026-07-15 17:37:57 +08:00
# ★ 出库后通知 Track(发货出库 → Track 标记"已出库")
# 仅在配置 TRACK_OUTBOUND_WEBHOOK_URL 时生效;notify_track 自身容错不阻断业务
try:
from flask import current_app
outbound_url = current_app.config.get('TRACK_OUTBOUND_WEBHOOK_URL')
for sn, src_tbl, qty 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'],
'operator': get_current_operator(),
}, url=outbound_url)
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"⚠️ Track 出库通知失败: {e}")
perf: 系统级性能优化与并发安全修复 ## 并发安全修复 (4处) - scrap.py: 报废执行添加 SELECT FOR UPDATE 悲观锁,消除 TOCTOU 竞态 - stock.py (adjust_stock): 盘点调整添加 for_update=True 行锁 - outbound_service.py: 低库存预警 SMTP 调用移到 commit 之后,避免长事务 - trans_service.py: execute_dispatch 按 (source_table, id) 排序 items,消除死锁风险 ## N+1 查询优化 (2处) - inventory_task.py: _prefetch_inventory_map 单条 UNION ALL+GROUP BY 替代循环内逐条查询(N*4次→2次) - stock.py (export_stocktake): get_borrowed_qty 批量 GROUP BY 替代逐条 TransBorrow 查询(~18000次→1次) ## BOM 列表性能重构 - bom_service.py: get_bom_list 单条 GROUP BY+string_agg+分页,消除 N+1 循环查询 - bom_service.py: 新增 get_bom_summary (轻量 GROUP BY category+COUNT) - bom.py: 新增 /api/v1/bom/summary 路由,/list 支持 category 过滤 ## Odoo 基础信息懒加载 - base_service.py: 新增 get_odoo_summary (GROUP BY category+COUNT) - base.py: 新增 /api/v1/inbound/base/odoo-summary 路由 - buyOdoo.vue: 懒加载分组架构 (fetchOdooSummary + loadGroupItems) - material_base.ts: 新增 getOdooSummary API ## 前端 Bug 修复 - BomManage.vue: 懒加载分组 (fetchBomSummary + loadGroupItems + collapse) - BomManage.vue: 适配新 API 格式 (res.data.items 替代 res.data) - buyOdoo.vue: 移除 "点击展开加载" 文字 - Selection.vue + borrow/apply/index.vue: openBomSelect 适配新 API 格式
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}")
return outbound_no
except Exception as e:
db.session.rollback()
raise e
@staticmethod
feat(records): 高级筛选引擎 + 出库记录接入 一、新增共享工具 app/utils/advanced_filter.py 系统内已有该模式(material/list.vue、stock/inbound/buy.vue), 沿用其既有约定:参数名 advancedFilters、值为 JSON 字符串、 操作符 eq/ne/contains/not_contains/ge/le。 · parse_advanced_filters() 解析并规整,坏输入退化为空列表不影响主查询 · build_predicate() 单条件 → SQLAlchemy 谓词,未登记字段返回 None 杜绝列注入 · build_material_name_select() 物料名三表联查(buy/semi/product JOIN material_base) 二、★ 父子关系处理(本次核心) 记录接口返回的是**按单号分组的订单**,而用户筛选字段多落在**明细行**上。 若直接 .filter(TransOutbound.sku.ilike(...)),会在 GROUP BY 前收窄明细范围, 展开行里的兄弟明细会凭空消失。正确做法是先求「含匹配明细的单号集合」 再让主查询按单号 IN 过滤。 实测对照(单 OUT-20260811-1519-0003,21 条明细): 按其中一条 SKU 筛选 → 子查询法保住全部 21 条;直接 filter 只剩 1 条。 三、★ 否定操作符语义(NOT IN) 子级字段的 ne / not_contains 不能直接用 SQL != / NOT LIKE —— 那表达的是 「本单存在某条不等于 X 的明细」,多明细单几乎必然成立,等于筛选失效。 用户意图是**整单排除**,故 apply_child_condition() 统一: 肯定 → order_no IN (含匹配明细的单号) 否定 → order_no NOT IN (含匹配明细的单号) 两者子查询完全一致(都用肯定形式谓词),仅外层取反。 父级字段(单号/操作人)仍走标准 SQL 谓词,语义无歧义。 四、出库记录接入(前端弹窗 + 后端接线) 验证: eq 0000000002 → 1 单;material_name contains 白板 → 16 单 sku ne 0000000002 → 394 = 395-1,含该 SKU 的单被整体排除 material_name not_contains 白板 → 379 = 395-16
2026-09-10 13:05:59 +08:00
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: 可选的公司过滤参数
feat(records): 高级筛选引擎 + 出库记录接入 一、新增共享工具 app/utils/advanced_filter.py 系统内已有该模式(material/list.vue、stock/inbound/buy.vue), 沿用其既有约定:参数名 advancedFilters、值为 JSON 字符串、 操作符 eq/ne/contains/not_contains/ge/le。 · parse_advanced_filters() 解析并规整,坏输入退化为空列表不影响主查询 · build_predicate() 单条件 → SQLAlchemy 谓词,未登记字段返回 None 杜绝列注入 · build_material_name_select() 物料名三表联查(buy/semi/product JOIN material_base) 二、★ 父子关系处理(本次核心) 记录接口返回的是**按单号分组的订单**,而用户筛选字段多落在**明细行**上。 若直接 .filter(TransOutbound.sku.ilike(...)),会在 GROUP BY 前收窄明细范围, 展开行里的兄弟明细会凭空消失。正确做法是先求「含匹配明细的单号集合」 再让主查询按单号 IN 过滤。 实测对照(单 OUT-20260811-1519-0003,21 条明细): 按其中一条 SKU 筛选 → 子查询法保住全部 21 条;直接 filter 只剩 1 条。 三、★ 否定操作符语义(NOT IN) 子级字段的 ne / not_contains 不能直接用 SQL != / NOT LIKE —— 那表达的是 「本单存在某条不等于 X 的明细」,多明细单几乎必然成立,等于筛选失效。 用户意图是**整单排除**,故 apply_child_condition() 统一: 肯定 → order_no IN (含匹配明细的单号) 否定 → order_no NOT IN (含匹配明细的单号) 两者子查询完全一致(都用肯定形式谓词),仅外层取反。 父级字段(单号/操作人)仍走标准 SQL 谓词,语义无歧义。 四、出库记录接入(前端弹窗 + 后端接线) 验证: eq 0000000002 → 1 单;material_name contains 白板 → 16 单 sku ne 0000000002 → 394 = 395-1,含该 SKU 的单被整体排除 material_name not_contains 白板 → 379 = 395-16
2026-09-10 13:05:59 +08:00
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)
feat(records): 高级筛选引擎 + 出库记录接入 一、新增共享工具 app/utils/advanced_filter.py 系统内已有该模式(material/list.vue、stock/inbound/buy.vue), 沿用其既有约定:参数名 advancedFilters、值为 JSON 字符串、 操作符 eq/ne/contains/not_contains/ge/le。 · parse_advanced_filters() 解析并规整,坏输入退化为空列表不影响主查询 · build_predicate() 单条件 → SQLAlchemy 谓词,未登记字段返回 None 杜绝列注入 · build_material_name_select() 物料名三表联查(buy/semi/product JOIN material_base) 二、★ 父子关系处理(本次核心) 记录接口返回的是**按单号分组的订单**,而用户筛选字段多落在**明细行**上。 若直接 .filter(TransOutbound.sku.ilike(...)),会在 GROUP BY 前收窄明细范围, 展开行里的兄弟明细会凭空消失。正确做法是先求「含匹配明细的单号集合」 再让主查询按单号 IN 过滤。 实测对照(单 OUT-20260811-1519-0003,21 条明细): 按其中一条 SKU 筛选 → 子查询法保住全部 21 条;直接 filter 只剩 1 条。 三、★ 否定操作符语义(NOT IN) 子级字段的 ne / not_contains 不能直接用 SQL != / NOT LIKE —— 那表达的是 「本单存在某条不等于 X 的明细」,多明细单几乎必然成立,等于筛选失效。 用户意图是**整单排除**,故 apply_child_condition() 统一: 肯定 → order_no IN (含匹配明细的单号) 否定 → order_no NOT IN (含匹配明细的单号) 两者子查询完全一致(都用肯定形式谓词),仅外层取反。 父级字段(单号/操作人)仍走标准 SQL 谓词,语义无歧义。 四、出库记录接入(前端弹窗 + 后端接线) 验证: eq 0000000002 → 1 单;material_name contains 白板 → 16 单 sku ne 0000000002 → 394 = 395-1,含该 SKU 的单被整体排除 material_name not_contains 白板 → 379 = 395-16
2026-09-10 13:05:59 +08:00
# ====================================================================
# ★ 高级筛选:父级字段直接过滤,子级字段(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
if start_date and end_date:
stmt = stmt.filter(TransOutbound.outbound_time.between(start_date, end_date))
# 【行级数据隔离】应用公司过滤到主查询
if company_limit is not None:
stmt = stmt.filter(TransOutbound.outbound_no.in_(comp_all))
# ★ 数据权限:普通用户只看“领用人=本人姓名(不含账号前缀)”的出库记录;
# 同时兼容库里存成“姓名/xiaolongxia”全名的记录(姓名 + '/' 前缀也命中)
if consumer_name:
feat(inventory): 库存预占生命周期,消除出库/借库超卖 问题:库存超卖 -------------- 改造前出库/借库申请只记录「要什么、要多少」,不绑定具体库存行, 真正的 available_quantity 扣减发生在执行阶段。于是多张申请可以同时 claim 同一批货,等到工人拿扫码枪时才发现货已被别人领走。 生命周期(三阶段) ------------------ 提交申请(预占) reserve_for_items() 用分配器把需求落到具体库存行,立即扣减 available_quantity, 并把 (stock_id, source_table, allocated_qty, reserved) 写回 items_json。 驳回(释放) release_reserved() 遍历 items_json 把预占量还回池子,避免货被永不执行的单永久占住。 扫码执行(覆盖) verify_scanned() + restore_then_deduct() 校验实扫身份/数量未超批准范围 → 释放全部预占 → 对实扫批次 同时扣减 available_quantity 与 stock_quantity。 身份键:base_id 主键 + SKU 兜底(重要设计决策) ----------------------------------------------- 本系统中 SKU 是**批次级**编号:同一 base_id 下每个入库批次各有不同的 SKU(实测 stock_buy 有 183 个物料是多批次的,如 base_id=2405 下有 0000001685 与 0000001974 两个 SKU)。 若以 SKU 作为身份主键,「申请时锁定 A 批、工人现场改扫 B 批」会被判为 身份不符而拒绝 —— 恰好否定了「物理覆盖」这个核心能力。 故改用 base_id(物料级、跨批次稳定,spec_model 由其唯一确定), 历史数据无 base_id 时降级为 (name, spec_model)。 可用量校验按物料汇总,而非按单批次 ---------------------------------- 开发中修正的一处缺陷:若逐行要求「该批次可用量 >= 该批次扫码量」, 工人改扫小批次时会被误拒。例如本单预占 A 批 5 件,改扫 B 批 2 件 + C 批 3 件,B 批自身只有 2 件可用,逐行校验即失败。实际这 5 件都是本单 锁定的货,理应允许。现按物料汇总校验可用量,按行校验实物库存。 改动文件 -------- · 新增 app/services/inventory_reservation.py(通用服务层) · outbound_service.create_request —— Phase 1 预占 · outbound_service.approve(reject) —— Phase 2 释放 · outbound_service.create_outbound_batch —— Phase 3 覆盖(移除原逐行扣减) · borrow_service.submit_approval —— Phase 1 · borrow_service.approve(reject) —— Phase 2 · trans_service.execute_dispatch —— Phase 3,并用统一身份键替换 原有的 (name, spec_model) 字符串匹配 实测(真实 HTTP 全链路) ------------------------ 初始 available=10 ① 提交申请(需5) → 200,available 10→5 预占生效 ② 审批通过 → available 仍为 5 预占保留 ③ 扫码执行(改扫另一批次 4 件) → 200 原批次恢复满额、实扫批次扣减(0,0),available=6, stock=6 单场景验证:预占 A 批改扫 B 批放行;驳回后可用量完全恢复; 扫其他物料被拒;批准 6 扫 8 被拒。
2026-09-10 14:59:10 +08:00
# 注意: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,
'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(),
)
feat(inventory): 库存预占生命周期,消除出库/借库超卖 问题:库存超卖 -------------- 改造前出库/借库申请只记录「要什么、要多少」,不绑定具体库存行, 真正的 available_quantity 扣减发生在执行阶段。于是多张申请可以同时 claim 同一批货,等到工人拿扫码枪时才发现货已被别人领走。 生命周期(三阶段) ------------------ 提交申请(预占) reserve_for_items() 用分配器把需求落到具体库存行,立即扣减 available_quantity, 并把 (stock_id, source_table, allocated_qty, reserved) 写回 items_json。 驳回(释放) release_reserved() 遍历 items_json 把预占量还回池子,避免货被永不执行的单永久占住。 扫码执行(覆盖) verify_scanned() + restore_then_deduct() 校验实扫身份/数量未超批准范围 → 释放全部预占 → 对实扫批次 同时扣减 available_quantity 与 stock_quantity。 身份键:base_id 主键 + SKU 兜底(重要设计决策) ----------------------------------------------- 本系统中 SKU 是**批次级**编号:同一 base_id 下每个入库批次各有不同的 SKU(实测 stock_buy 有 183 个物料是多批次的,如 base_id=2405 下有 0000001685 与 0000001974 两个 SKU)。 若以 SKU 作为身份主键,「申请时锁定 A 批、工人现场改扫 B 批」会被判为 身份不符而拒绝 —— 恰好否定了「物理覆盖」这个核心能力。 故改用 base_id(物料级、跨批次稳定,spec_model 由其唯一确定), 历史数据无 base_id 时降级为 (name, spec_model)。 可用量校验按物料汇总,而非按单批次 ---------------------------------- 开发中修正的一处缺陷:若逐行要求「该批次可用量 >= 该批次扫码量」, 工人改扫小批次时会被误拒。例如本单预占 A 批 5 件,改扫 B 批 2 件 + C 批 3 件,B 批自身只有 2 件可用,逐行校验即失败。实际这 5 件都是本单 锁定的货,理应允许。现按物料汇总校验可用量,按行校验实物库存。 改动文件 -------- · 新增 app/services/inventory_reservation.py(通用服务层) · outbound_service.create_request —— Phase 1 预占 · outbound_service.approve(reject) —— Phase 2 释放 · outbound_service.create_outbound_batch —— Phase 3 覆盖(移除原逐行扣减) · borrow_service.submit_approval —— Phase 1 · borrow_service.approve(reject) —— Phase 2 · trans_service.execute_dispatch —— Phase 3,并用统一身份键替换 原有的 (name, spec_model) 字符串匹配 实测(真实 HTTP 全链路) ------------------------ 初始 available=10 ① 提交申请(需5) → 200,available 10→5 预占生效 ② 审批通过 → available 仍为 5 预占保留 ③ 扫码执行(改扫另一批次 4 件) → 200 原批次恢复满额、实扫批次扣减(0,0),available=6, stock=6 单场景验证:预占 A 批改扫 B 批放行;驳回后可用量完全恢复; 扫其他物料被拒;批准 6 扫 8 被拒。
2026-09-10 14:59:10 +08:00
# ==================================================================
# ★ 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
feat(inventory): 库存预占生命周期,消除出库/借库超卖 问题:库存超卖 -------------- 改造前出库/借库申请只记录「要什么、要多少」,不绑定具体库存行, 真正的 available_quantity 扣减发生在执行阶段。于是多张申请可以同时 claim 同一批货,等到工人拿扫码枪时才发现货已被别人领走。 生命周期(三阶段) ------------------ 提交申请(预占) reserve_for_items() 用分配器把需求落到具体库存行,立即扣减 available_quantity, 并把 (stock_id, source_table, allocated_qty, reserved) 写回 items_json。 驳回(释放) release_reserved() 遍历 items_json 把预占量还回池子,避免货被永不执行的单永久占住。 扫码执行(覆盖) verify_scanned() + restore_then_deduct() 校验实扫身份/数量未超批准范围 → 释放全部预占 → 对实扫批次 同时扣减 available_quantity 与 stock_quantity。 身份键:base_id 主键 + SKU 兜底(重要设计决策) ----------------------------------------------- 本系统中 SKU 是**批次级**编号:同一 base_id 下每个入库批次各有不同的 SKU(实测 stock_buy 有 183 个物料是多批次的,如 base_id=2405 下有 0000001685 与 0000001974 两个 SKU)。 若以 SKU 作为身份主键,「申请时锁定 A 批、工人现场改扫 B 批」会被判为 身份不符而拒绝 —— 恰好否定了「物理覆盖」这个核心能力。 故改用 base_id(物料级、跨批次稳定,spec_model 由其唯一确定), 历史数据无 base_id 时降级为 (name, spec_model)。 可用量校验按物料汇总,而非按单批次 ---------------------------------- 开发中修正的一处缺陷:若逐行要求「该批次可用量 >= 该批次扫码量」, 工人改扫小批次时会被误拒。例如本单预占 A 批 5 件,改扫 B 批 2 件 + C 批 3 件,B 批自身只有 2 件可用,逐行校验即失败。实际这 5 件都是本单 锁定的货,理应允许。现按物料汇总校验可用量,按行校验实物库存。 改动文件 -------- · 新增 app/services/inventory_reservation.py(通用服务层) · outbound_service.create_request —— Phase 1 预占 · outbound_service.approve(reject) —— Phase 2 释放 · outbound_service.create_outbound_batch —— Phase 3 覆盖(移除原逐行扣减) · borrow_service.submit_approval —— Phase 1 · borrow_service.approve(reject) —— Phase 2 · trans_service.execute_dispatch —— Phase 3,并用统一身份键替换 原有的 (name, spec_model) 字符串匹配 实测(真实 HTTP 全链路) ------------------------ 初始 available=10 ① 提交申请(需5) → 200,available 10→5 预占生效 ② 审批通过 → available 仍为 5 预占保留 ③ 扫码执行(改扫另一批次 4 件) → 200 原批次恢复满额、实扫批次扣减(0,0),available=6, stock=6 单场景验证:预占 A 批改扫 B 批放行;驳回后可用量完全恢复; 扫其他物料被拒;批准 6 扫 8 被拒。
2026-09-10 14:59:10 +08:00
# 通过后预占继续保留:货已被本单锁定,直到扫码执行时才释放并扣减
elif action == 'reject':
feat(inventory): 库存预占生命周期,消除出库/借库超卖 问题:库存超卖 -------------- 改造前出库/借库申请只记录「要什么、要多少」,不绑定具体库存行, 真正的 available_quantity 扣减发生在执行阶段。于是多张申请可以同时 claim 同一批货,等到工人拿扫码枪时才发现货已被别人领走。 生命周期(三阶段) ------------------ 提交申请(预占) reserve_for_items() 用分配器把需求落到具体库存行,立即扣减 available_quantity, 并把 (stock_id, source_table, allocated_qty, reserved) 写回 items_json。 驳回(释放) release_reserved() 遍历 items_json 把预占量还回池子,避免货被永不执行的单永久占住。 扫码执行(覆盖) verify_scanned() + restore_then_deduct() 校验实扫身份/数量未超批准范围 → 释放全部预占 → 对实扫批次 同时扣减 available_quantity 与 stock_quantity。 身份键:base_id 主键 + SKU 兜底(重要设计决策) ----------------------------------------------- 本系统中 SKU 是**批次级**编号:同一 base_id 下每个入库批次各有不同的 SKU(实测 stock_buy 有 183 个物料是多批次的,如 base_id=2405 下有 0000001685 与 0000001974 两个 SKU)。 若以 SKU 作为身份主键,「申请时锁定 A 批、工人现场改扫 B 批」会被判为 身份不符而拒绝 —— 恰好否定了「物理覆盖」这个核心能力。 故改用 base_id(物料级、跨批次稳定,spec_model 由其唯一确定), 历史数据无 base_id 时降级为 (name, spec_model)。 可用量校验按物料汇总,而非按单批次 ---------------------------------- 开发中修正的一处缺陷:若逐行要求「该批次可用量 >= 该批次扫码量」, 工人改扫小批次时会被误拒。例如本单预占 A 批 5 件,改扫 B 批 2 件 + C 批 3 件,B 批自身只有 2 件可用,逐行校验即失败。实际这 5 件都是本单 锁定的货,理应允许。现按物料汇总校验可用量,按行校验实物库存。 改动文件 -------- · 新增 app/services/inventory_reservation.py(通用服务层) · outbound_service.create_request —— Phase 1 预占 · outbound_service.approve(reject) —— Phase 2 释放 · outbound_service.create_outbound_batch —— Phase 3 覆盖(移除原逐行扣减) · borrow_service.submit_approval —— Phase 1 · borrow_service.approve(reject) —— Phase 2 · trans_service.execute_dispatch —— Phase 3,并用统一身份键替换 原有的 (name, spec_model) 字符串匹配 实测(真实 HTTP 全链路) ------------------------ 初始 available=10 ① 提交申请(需5) → 200,available 10→5 预占生效 ② 审批通过 → available 仍为 5 预占保留 ③ 扫码执行(改扫另一批次 4 件) → 200 原批次恢复满额、实扫批次扣减(0,0),available=6, stock=6 单场景验证:预占 A 批改扫 B 批放行;驳回后可用量完全恢复; 扫其他物料被拒;批准 6 扫 8 被拒。
2026-09-10 14:59:10 +08:00
# ★ 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):
"""
fix(inventory): 撤回/作废单据时释放预占库存,消除库存泄漏 问题 ---- 系统原本已有「完结」功能(出库/借库),但它只改状态、不释放预占: approval.status = 4 # 已完结 db.session.commit() # ← 库存没还回去 申请阶段 reserve_for_items() 扣掉的 available_quantity 就此永久泄漏 —— 货被一张永不执行的作废单锁死,谁也领不走。 核查存量 23 张 status=4 的单,所幸均为预占改造前提交(items_json 无 stock_id),尚未造成实际损失。但缺陷本身是真实的。 改动(三个模块统一) -------------------- 出库 outbound_service.close_request 借库 borrow_service.mark_completed · 调用 release_reserved(approval.get_items()) 按 items_json 原样归还; · 明确「仅 status==1(已通过待执行)可撤回」—— 执行成功后 create_outbound_batch / execute_dispatch 会把 status 置为 3, 故该状态判断本身即执行守卫,已执行或已撤回的单都进不来; · 返回消息带上释放条数,便于操作者确认。 报废 scrap_approval_service.withdraw(新增能力) · 报废原先只有 approve/reject,没有撤回入口,补齐; · 复用同一个 release_reserved():报废当前尚未接入预占,调用它会安全 跳过(无 reserved 标记),但将来报废接入预占时该段代码自动生效; · 新增端点 POST /api/v1/scrap/request/<id>/withdraw(权限 scrap_apply)。 未采用按流水表二次校验:request_no(APR-OUT-…) 与 outbound_no(OUT-…) 格式不同、无关联字段,按单号比对是无效的,状态判断已足够。 实测 ---- 决定性用例(证明释放真实生效,非账面功夫): A单预占5 → available=1 B单要5 → 400 拒绝(被A占住) 撤回A → available=6 B单再要5 → 200 成功,available=1 ★ 释放的库存真的可被复用 三模块: 出库 撤回后 4→10 完全恢复,stock 未变(货没动) 借库 撤回后 6→10 完全恢复 报废 撤回成功,重复撤回被正确拒绝 状态码说明:报废复用出库/借库已有的 4=已完结 作为「已撤回」, 而非引入 -1,避免同一系统出现两套编号(其 2 已被「已驳回」占用)。
2026-09-10 15:08:32 +08:00
撤回 / 作废审批单(状态 1-已通过 → 4-已完结)
fix(inventory): 撤回/作废单据时释放预占库存,消除库存泄漏 问题 ---- 系统原本已有「完结」功能(出库/借库),但它只改状态、不释放预占: approval.status = 4 # 已完结 db.session.commit() # ← 库存没还回去 申请阶段 reserve_for_items() 扣掉的 available_quantity 就此永久泄漏 —— 货被一张永不执行的作废单锁死,谁也领不走。 核查存量 23 张 status=4 的单,所幸均为预占改造前提交(items_json 无 stock_id),尚未造成实际损失。但缺陷本身是真实的。 改动(三个模块统一) -------------------- 出库 outbound_service.close_request 借库 borrow_service.mark_completed · 调用 release_reserved(approval.get_items()) 按 items_json 原样归还; · 明确「仅 status==1(已通过待执行)可撤回」—— 执行成功后 create_outbound_batch / execute_dispatch 会把 status 置为 3, 故该状态判断本身即执行守卫,已执行或已撤回的单都进不来; · 返回消息带上释放条数,便于操作者确认。 报废 scrap_approval_service.withdraw(新增能力) · 报废原先只有 approve/reject,没有撤回入口,补齐; · 复用同一个 release_reserved():报废当前尚未接入预占,调用它会安全 跳过(无 reserved 标记),但将来报废接入预占时该段代码自动生效; · 新增端点 POST /api/v1/scrap/request/<id>/withdraw(权限 scrap_apply)。 未采用按流水表二次校验:request_no(APR-OUT-…) 与 outbound_no(OUT-…) 格式不同、无关联字段,按单号比对是无效的,状态判断已足够。 实测 ---- 决定性用例(证明释放真实生效,非账面功夫): A单预占5 → available=1 B单要5 → 400 拒绝(被A占住) 撤回A → available=6 B单再要5 → 200 成功,available=1 ★ 释放的库存真的可被复用 三模块: 出库 撤回后 4→10 完全恢复,stock 未变(货没动) 借库 撤回后 6→10 完全恢复 报废 撤回成功,重复撤回被正确拒绝 状态码说明:报废复用出库/借库已有的 4=已完结 作为「已撤回」, 而非引入 -1,避免同一系统出现两套编号(其 2 已被「已驳回」占用)。
2026-09-10 15:08:32 +08:00
适用场景:已通过但尚未(或未完整)执行的单据,申请人或管理者撤回,
使其从"已审批通过"列表中消失,并**释放预占的库存**。
★ 修复说明(两处严重缺陷):
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
fix(inventory): 撤回/作废单据时释放预占库存,消除库存泄漏 问题 ---- 系统原本已有「完结」功能(出库/借库),但它只改状态、不释放预占: approval.status = 4 # 已完结 db.session.commit() # ← 库存没还回去 申请阶段 reserve_for_items() 扣掉的 available_quantity 就此永久泄漏 —— 货被一张永不执行的作废单锁死,谁也领不走。 核查存量 23 张 status=4 的单,所幸均为预占改造前提交(items_json 无 stock_id),尚未造成实际损失。但缺陷本身是真实的。 改动(三个模块统一) -------------------- 出库 outbound_service.close_request 借库 borrow_service.mark_completed · 调用 release_reserved(approval.get_items()) 按 items_json 原样归还; · 明确「仅 status==1(已通过待执行)可撤回」—— 执行成功后 create_outbound_batch / execute_dispatch 会把 status 置为 3, 故该状态判断本身即执行守卫,已执行或已撤回的单都进不来; · 返回消息带上释放条数,便于操作者确认。 报废 scrap_approval_service.withdraw(新增能力) · 报废原先只有 approve/reject,没有撤回入口,补齐; · 复用同一个 release_reserved():报废当前尚未接入预占,调用它会安全 跳过(无 reserved 标记),但将来报废接入预占时该段代码自动生效; · 新增端点 POST /api/v1/scrap/request/<id>/withdraw(权限 scrap_apply)。 未采用按流水表二次校验:request_no(APR-OUT-…) 与 outbound_no(OUT-…) 格式不同、无关联字段,按单号比对是无效的,状态判断已足够。 实测 ---- 决定性用例(证明释放真实生效,非账面功夫): A单预占5 → available=1 B单要5 → 400 拒绝(被A占住) 撤回A → available=6 B单再要5 → 200 成功,available=1 ★ 释放的库存真的可被复用 三模块: 出库 撤回后 4→10 完全恢复,stock 未变(货没动) 借库 撤回后 6→10 完全恢复 报废 撤回成功,重复撤回被正确拒绝 状态码说明:报废复用出库/借库已有的 4=已完结 作为「已撤回」, 而非引入 -1,避免同一系统出现两套编号(其 2 已被「已驳回」占用)。
2026-09-10 15:08:32 +08:00
# ★ 执行守卫:执行成功后 create_outbound_batch 会把 status 置为 3,
# 故此处的「仅 status==1 可撤回」本身就是执行守卫 ——
# 已执行(3)或已撤回(4)的单都无法再次撤回。
# 不额外查流水表:request_no(APR-OUT-…) 与 outbound_no(OUT-…) 格式不同、
# 无关联字段,按单号比对是无效的。
if approval.status != 1:
fix(inventory): 撤回/作废单据时释放预占库存,消除库存泄漏 问题 ---- 系统原本已有「完结」功能(出库/借库),但它只改状态、不释放预占: approval.status = 4 # 已完结 db.session.commit() # ← 库存没还回去 申请阶段 reserve_for_items() 扣掉的 available_quantity 就此永久泄漏 —— 货被一张永不执行的作废单锁死,谁也领不走。 核查存量 23 张 status=4 的单,所幸均为预占改造前提交(items_json 无 stock_id),尚未造成实际损失。但缺陷本身是真实的。 改动(三个模块统一) -------------------- 出库 outbound_service.close_request 借库 borrow_service.mark_completed · 调用 release_reserved(approval.get_items()) 按 items_json 原样归还; · 明确「仅 status==1(已通过待执行)可撤回」—— 执行成功后 create_outbound_batch / execute_dispatch 会把 status 置为 3, 故该状态判断本身即执行守卫,已执行或已撤回的单都进不来; · 返回消息带上释放条数,便于操作者确认。 报废 scrap_approval_service.withdraw(新增能力) · 报废原先只有 approve/reject,没有撤回入口,补齐; · 复用同一个 release_reserved():报废当前尚未接入预占,调用它会安全 跳过(无 reserved 标记),但将来报废接入预占时该段代码自动生效; · 新增端点 POST /api/v1/scrap/request/<id>/withdraw(权限 scrap_apply)。 未采用按流水表二次校验:request_no(APR-OUT-…) 与 outbound_no(OUT-…) 格式不同、无关联字段,按单号比对是无效的,状态判断已足够。 实测 ---- 决定性用例(证明释放真实生效,非账面功夫): A单预占5 → available=1 B单要5 → 400 拒绝(被A占住) 撤回A → available=6 B单再要5 → 200 成功,available=1 ★ 释放的库存真的可被复用 三模块: 出库 撤回后 4→10 完全恢复,stock 未变(货没动) 借库 撤回后 6→10 完全恢复 报废 撤回成功,重复撤回被正确拒绝 状态码说明:报废复用出库/借库已有的 4=已完结 作为「已撤回」, 而非引入 -1,避免同一系统出现两套编号(其 2 已被「已驳回」占用)。
2026-09-10 15:08:32 +08:00
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):
fix(inventory): 撤回/作废单据时释放预占库存,消除库存泄漏 问题 ---- 系统原本已有「完结」功能(出库/借库),但它只改状态、不释放预占: approval.status = 4 # 已完结 db.session.commit() # ← 库存没还回去 申请阶段 reserve_for_items() 扣掉的 available_quantity 就此永久泄漏 —— 货被一张永不执行的作废单锁死,谁也领不走。 核查存量 23 张 status=4 的单,所幸均为预占改造前提交(items_json 无 stock_id),尚未造成实际损失。但缺陷本身是真实的。 改动(三个模块统一) -------------------- 出库 outbound_service.close_request 借库 borrow_service.mark_completed · 调用 release_reserved(approval.get_items()) 按 items_json 原样归还; · 明确「仅 status==1(已通过待执行)可撤回」—— 执行成功后 create_outbound_batch / execute_dispatch 会把 status 置为 3, 故该状态判断本身即执行守卫,已执行或已撤回的单都进不来; · 返回消息带上释放条数,便于操作者确认。 报废 scrap_approval_service.withdraw(新增能力) · 报废原先只有 approve/reject,没有撤回入口,补齐; · 复用同一个 release_reserved():报废当前尚未接入预占,调用它会安全 跳过(无 reserved 标记),但将来报废接入预占时该段代码自动生效; · 新增端点 POST /api/v1/scrap/request/<id>/withdraw(权限 scrap_apply)。 未采用按流水表二次校验:request_no(APR-OUT-…) 与 outbound_no(OUT-…) 格式不同、无关联字段,按单号比对是无效的,状态判断已足够。 实测 ---- 决定性用例(证明释放真实生效,非账面功夫): A单预占5 → available=1 B单要5 → 400 拒绝(被A占住) 撤回A → available=6 B单再要5 → 200 成功,available=1 ★ 释放的库存真的可被复用 三模块: 出库 撤回后 4→10 完全恢复,stock 未变(货没动) 借库 撤回后 6→10 完全恢复 报废 撤回成功,重复撤回被正确拒绝 状态码说明:报废复用出库/借库已有的 4=已完结 作为「已撤回」, 而非引入 -1,避免同一系统出现两套编号(其 2 已被「已驳回」占用)。
2026-09-10 15:08:32 +08:00
# 放宽:拥有 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:
fix(inventory): 撤回/作废单据时释放预占库存,消除库存泄漏 问题 ---- 系统原本已有「完结」功能(出库/借库),但它只改状态、不释放预占: approval.status = 4 # 已完结 db.session.commit() # ← 库存没还回去 申请阶段 reserve_for_items() 扣掉的 available_quantity 就此永久泄漏 —— 货被一张永不执行的作废单锁死,谁也领不走。 核查存量 23 张 status=4 的单,所幸均为预占改造前提交(items_json 无 stock_id),尚未造成实际损失。但缺陷本身是真实的。 改动(三个模块统一) -------------------- 出库 outbound_service.close_request 借库 borrow_service.mark_completed · 调用 release_reserved(approval.get_items()) 按 items_json 原样归还; · 明确「仅 status==1(已通过待执行)可撤回」—— 执行成功后 create_outbound_batch / execute_dispatch 会把 status 置为 3, 故该状态判断本身即执行守卫,已执行或已撤回的单都进不来; · 返回消息带上释放条数,便于操作者确认。 报废 scrap_approval_service.withdraw(新增能力) · 报废原先只有 approve/reject,没有撤回入口,补齐; · 复用同一个 release_reserved():报废当前尚未接入预占,调用它会安全 跳过(无 reserved 标记),但将来报废接入预占时该段代码自动生效; · 新增端点 POST /api/v1/scrap/request/<id>/withdraw(权限 scrap_apply)。 未采用按流水表二次校验:request_no(APR-OUT-…) 与 outbound_no(OUT-…) 格式不同、无关联字段,按单号比对是无效的,状态判断已足够。 实测 ---- 决定性用例(证明释放真实生效,非账面功夫): A单预占5 → available=1 B单要5 → 400 拒绝(被A占住) 撤回A → available=6 B单再要5 → 200 成功,available=1 ★ 释放的库存真的可被复用 三模块: 出库 撤回后 4→10 完全恢复,stock 未变(货没动) 借库 撤回后 6→10 完全恢复 报废 撤回成功,重复撤回被正确拒绝 状态码说明:报废复用出库/借库已有的 4=已完结 作为「已撤回」, 而非引入 -1,避免同一系统出现两套编号(其 2 已被「已驳回」占用)。
2026-09-10 15:08:32 +08:00
return False, "您没有撤回此单的权限", None
feat(outbound): 申请人撤回自己的申请单 + 我的申请单端点 背景 ---- 出库审批页是管理视角(需 outbound_approval 权限),普通申请人提交后 **没有任何入口看回自己的单据**,更谈不上撤回。 服务层:抽出共用释放逻辑 ------------------------ 新增 OutboundApprovalService.withdraw_request(),与既有的 close_request() (管理路径)形成两条独立入口: close_request —— 管理路径,需 outbound_approval 等权限,仅 status==1 withdraw_request —— 申请人路径,仅校验「单据归属」,status 0 或 1 均可 两者各自完成权限与状态校验后,调用**同一个** _release_and_close()。 释放逻辑只有一份实现,不会因修改其中一处而漏掉另一处。 为什么单独开一条路径,而不是在 close_request 里加 if 分支: 权限模型不同(管理角色 vs 单据归属)。混在一个函数里,后续修改容易 互相影响 —— 这正是需要避免的访问控制风险。 API --- POST /outbound/request/<id>/withdraw 申请人撤回(仅 @jwt_required) · 归属断言:非本人且非特权 → 403「无权撤回他人的申请单」 · 状态守卫:仅 0/1 可撤回;执行成功后 status 会被置 3,故该判断 本身即执行守卫,已执行或已撤回的单都进不来 GET /outbound/my-requests 我的申请(仅 @jwt_required) · applicant_id 硬编码为当前登录用户,不接受任何入参覆盖 两者都**不做模块权限校验** —— 普通申请人无需持有 outbound_approval (那是管理权限)。与「给审批端点加 if 降级放行」是两条路:后者把管理 逻辑与用户逻辑混在一个端点里,一旦 is_privileged_viewer() 判定出错 即越权;本端点从设计上就没有「看别人」的分支。 安全实测 -------- 普通员工查我的申请(此前 403) → 200 B 查列表看不到 A 的单 → 39 单中无 A 的单 B 撤回 A 的单 → 403,且库存未被释放 A 撤回自己的单 → 200,库存 17→20 完全释放 主管代撤他人工单 → 200(特权路径) 待审批(status=0) 撤回 → 200,库存释放 重复撤回 → 400「当前状态不可撤回」
2026-09-10 15:36:59 +08:00
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:
fix(inventory): 撤回/作废单据时释放预占库存,消除库存泄漏 问题 ---- 系统原本已有「完结」功能(出库/借库),但它只改状态、不释放预占: approval.status = 4 # 已完结 db.session.commit() # ← 库存没还回去 申请阶段 reserve_for_items() 扣掉的 available_quantity 就此永久泄漏 —— 货被一张永不执行的作废单锁死,谁也领不走。 核查存量 23 张 status=4 的单,所幸均为预占改造前提交(items_json 无 stock_id),尚未造成实际损失。但缺陷本身是真实的。 改动(三个模块统一) -------------------- 出库 outbound_service.close_request 借库 borrow_service.mark_completed · 调用 release_reserved(approval.get_items()) 按 items_json 原样归还; · 明确「仅 status==1(已通过待执行)可撤回」—— 执行成功后 create_outbound_batch / execute_dispatch 会把 status 置为 3, 故该状态判断本身即执行守卫,已执行或已撤回的单都进不来; · 返回消息带上释放条数,便于操作者确认。 报废 scrap_approval_service.withdraw(新增能力) · 报废原先只有 approve/reject,没有撤回入口,补齐; · 复用同一个 release_reserved():报废当前尚未接入预占,调用它会安全 跳过(无 reserved 标记),但将来报废接入预占时该段代码自动生效; · 新增端点 POST /api/v1/scrap/request/<id>/withdraw(权限 scrap_apply)。 未采用按流水表二次校验:request_no(APR-OUT-…) 与 outbound_no(OUT-…) 格式不同、无关联字段,按单号比对是无效的,状态判断已足够。 实测 ---- 决定性用例(证明释放真实生效,非账面功夫): A单预占5 → available=1 B单要5 → 400 拒绝(被A占住) 撤回A → available=6 B单再要5 → 200 成功,available=1 ★ 释放的库存真的可被复用 三模块: 出库 撤回后 4→10 完全恢复,stock 未变(货没动) 借库 撤回后 6→10 完全恢复 报废 撤回成功,重复撤回被正确拒绝 状态码说明:报废复用出库/借库已有的 4=已完结 作为「已撤回」, 而非引入 -1,避免同一系统出现两套编号(其 2 已被「已驳回」占用)。
2026-09-10 15:08:32 +08:00
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)
feat(outbound): 申请人撤回自己的申请单 + 我的申请单端点 背景 ---- 出库审批页是管理视角(需 outbound_approval 权限),普通申请人提交后 **没有任何入口看回自己的单据**,更谈不上撤回。 服务层:抽出共用释放逻辑 ------------------------ 新增 OutboundApprovalService.withdraw_request(),与既有的 close_request() (管理路径)形成两条独立入口: close_request —— 管理路径,需 outbound_approval 等权限,仅 status==1 withdraw_request —— 申请人路径,仅校验「单据归属」,status 0 或 1 均可 两者各自完成权限与状态校验后,调用**同一个** _release_and_close()。 释放逻辑只有一份实现,不会因修改其中一处而漏掉另一处。 为什么单独开一条路径,而不是在 close_request 里加 if 分支: 权限模型不同(管理角色 vs 单据归属)。混在一个函数里,后续修改容易 互相影响 —— 这正是需要避免的访问控制风险。 API --- POST /outbound/request/<id>/withdraw 申请人撤回(仅 @jwt_required) · 归属断言:非本人且非特权 → 403「无权撤回他人的申请单」 · 状态守卫:仅 0/1 可撤回;执行成功后 status 会被置 3,故该判断 本身即执行守卫,已执行或已撤回的单都进不来 GET /outbound/my-requests 我的申请(仅 @jwt_required) · applicant_id 硬编码为当前登录用户,不接受任何入参覆盖 两者都**不做模块权限校验** —— 普通申请人无需持有 outbound_approval (那是管理权限)。与「给审批端点加 if 降级放行」是两条路:后者把管理 逻辑与用户逻辑混在一个端点里,一旦 is_privileged_viewer() 判定出错 即越权;本端点从设计上就没有「看别人」的分支。 安全实测 -------- 普通员工查我的申请(此前 403) → 200 B 查列表看不到 A 的单 → 39 单中无 A 的单 B 撤回 A 的单 → 403,且库存未被释放 A 撤回自己的单 → 200,库存 17→20 完全释放 主管代撤他人工单 → 200(特权路径) 待审批(status=0) 撤回 → 200,库存释放 重复撤回 → 400「当前状态不可撤回」
2026-09-10 15:36:59 +08:00
approval.status = 4 # 4-已撤回/已完结
approval.actual_approver_id = user_id
approval.approved_at = None
db.session.commit()
fix(inventory): 撤回/作废单据时释放预占库存,消除库存泄漏 问题 ---- 系统原本已有「完结」功能(出库/借库),但它只改状态、不释放预占: approval.status = 4 # 已完结 db.session.commit() # ← 库存没还回去 申请阶段 reserve_for_items() 扣掉的 available_quantity 就此永久泄漏 —— 货被一张永不执行的作废单锁死,谁也领不走。 核查存量 23 张 status=4 的单,所幸均为预占改造前提交(items_json 无 stock_id),尚未造成实际损失。但缺陷本身是真实的。 改动(三个模块统一) -------------------- 出库 outbound_service.close_request 借库 borrow_service.mark_completed · 调用 release_reserved(approval.get_items()) 按 items_json 原样归还; · 明确「仅 status==1(已通过待执行)可撤回」—— 执行成功后 create_outbound_batch / execute_dispatch 会把 status 置为 3, 故该状态判断本身即执行守卫,已执行或已撤回的单都进不来; · 返回消息带上释放条数,便于操作者确认。 报废 scrap_approval_service.withdraw(新增能力) · 报废原先只有 approve/reject,没有撤回入口,补齐; · 复用同一个 release_reserved():报废当前尚未接入预占,调用它会安全 跳过(无 reserved 标记),但将来报废接入预占时该段代码自动生效; · 新增端点 POST /api/v1/scrap/request/<id>/withdraw(权限 scrap_apply)。 未采用按流水表二次校验:request_no(APR-OUT-…) 与 outbound_no(OUT-…) 格式不同、无关联字段,按单号比对是无效的,状态判断已足够。 实测 ---- 决定性用例(证明释放真实生效,非账面功夫): A单预占5 → available=1 B单要5 → 400 拒绝(被A占住) 撤回A → available=6 B单再要5 → 200 成功,available=1 ★ 释放的库存真的可被复用 三模块: 出库 撤回后 4→10 完全恢复,stock 未变(货没动) 借库 撤回后 6→10 完全恢复 报废 撤回成功,重复撤回被正确拒绝 状态码说明:报废复用出库/借库已有的 4=已完结 作为「已撤回」, 而非引入 -1,避免同一系统出现两套编号(其 2 已被「已驳回」占用)。
2026-09-10 15:08:32 +08:00
feat(outbound): 申请人撤回自己的申请单 + 我的申请单端点 背景 ---- 出库审批页是管理视角(需 outbound_approval 权限),普通申请人提交后 **没有任何入口看回自己的单据**,更谈不上撤回。 服务层:抽出共用释放逻辑 ------------------------ 新增 OutboundApprovalService.withdraw_request(),与既有的 close_request() (管理路径)形成两条独立入口: close_request —— 管理路径,需 outbound_approval 等权限,仅 status==1 withdraw_request —— 申请人路径,仅校验「单据归属」,status 0 或 1 均可 两者各自完成权限与状态校验后,调用**同一个** _release_and_close()。 释放逻辑只有一份实现,不会因修改其中一处而漏掉另一处。 为什么单独开一条路径,而不是在 close_request 里加 if 分支: 权限模型不同(管理角色 vs 单据归属)。混在一个函数里,后续修改容易 互相影响 —— 这正是需要避免的访问控制风险。 API --- POST /outbound/request/<id>/withdraw 申请人撤回(仅 @jwt_required) · 归属断言:非本人且非特权 → 403「无权撤回他人的申请单」 · 状态守卫:仅 0/1 可撤回;执行成功后 status 会被置 3,故该判断 本身即执行守卫,已执行或已撤回的单都进不来 GET /outbound/my-requests 我的申请(仅 @jwt_required) · applicant_id 硬编码为当前登录用户,不接受任何入参覆盖 两者都**不做模块权限校验** —— 普通申请人无需持有 outbound_approval (那是管理权限)。与「给审批端点加 if 降级放行」是两条路:后者把管理 逻辑与用户逻辑混在一个端点里,一旦 is_privileged_viewer() 判定出错 即越权;本端点从设计上就没有「看别人」的分支。 安全实测 -------- 普通员工查我的申请(此前 403) → 200 B 查列表看不到 A 的单 → 39 单中无 A 的单 B 撤回 A 的单 → 403,且库存未被释放 A 撤回自己的单 → 200,库存 17→20 完全释放 主管代撤他人工单 → 200(特权路径) 待审批(status=0) 撤回 → 200,库存释放 重复撤回 → 400「当前状态不可撤回」
2026-09-10 15:36:59 +08:00
return True, f"申请单已撤回,释放 {restored} 项预占库存", approval
except Exception as e:
db.session.rollback()
fix(inventory): 撤回/作废单据时释放预占库存,消除库存泄漏 问题 ---- 系统原本已有「完结」功能(出库/借库),但它只改状态、不释放预占: approval.status = 4 # 已完结 db.session.commit() # ← 库存没还回去 申请阶段 reserve_for_items() 扣掉的 available_quantity 就此永久泄漏 —— 货被一张永不执行的作废单锁死,谁也领不走。 核查存量 23 张 status=4 的单,所幸均为预占改造前提交(items_json 无 stock_id),尚未造成实际损失。但缺陷本身是真实的。 改动(三个模块统一) -------------------- 出库 outbound_service.close_request 借库 borrow_service.mark_completed · 调用 release_reserved(approval.get_items()) 按 items_json 原样归还; · 明确「仅 status==1(已通过待执行)可撤回」—— 执行成功后 create_outbound_batch / execute_dispatch 会把 status 置为 3, 故该状态判断本身即执行守卫,已执行或已撤回的单都进不来; · 返回消息带上释放条数,便于操作者确认。 报废 scrap_approval_service.withdraw(新增能力) · 报废原先只有 approve/reject,没有撤回入口,补齐; · 复用同一个 release_reserved():报废当前尚未接入预占,调用它会安全 跳过(无 reserved 标记),但将来报废接入预占时该段代码自动生效; · 新增端点 POST /api/v1/scrap/request/<id>/withdraw(权限 scrap_apply)。 未采用按流水表二次校验:request_no(APR-OUT-…) 与 outbound_no(OUT-…) 格式不同、无关联字段,按单号比对是无效的,状态判断已足够。 实测 ---- 决定性用例(证明释放真实生效,非账面功夫): A单预占5 → available=1 B单要5 → 400 拒绝(被A占住) 撤回A → available=6 B单再要5 → 200 成功,available=1 ★ 释放的库存真的可被复用 三模块: 出库 撤回后 4→10 完全恢复,stock 未变(货没动) 借库 撤回后 6→10 完全恢复 报废 撤回成功,重复撤回被正确拒绝 状态码说明:报废复用出库/借库已有的 4=已完结 作为「已撤回」, 而非引入 -1,避免同一系统出现两套编号(其 2 已被「已驳回」占用)。
2026-09-10 15:08:32 +08:00
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)