feat(scrap): 报废申请审批流 + 按单报废(后端)

- ScrapApproval 模型 + ScrapApprovalService:提交/列表/审批/执行(锁库存扣 available_quantity 写 TransScrap)
- 新路由 POST /scrap/request、/request/check-approval、GET /request、PATCH /request/<id>/approve、POST /request/<id>/execute;权限码 scrap_apply/scrap_approval/scrap_execute(无角色硬编码)
- 旧 /scrap 直接报废入口保持不变
- /inbound/stock/list 每项返回 source_table,供按单流程精准选库存
This commit is contained in:
yueli
2026-09-10 09:46:57 +08:00
parent 3d8fb198c5
commit ffbb2199f0
4 changed files with 486 additions and 0 deletions

View File

@ -0,0 +1,237 @@
import logging
from datetime import datetime, timezone, timedelta
from sqlalchemy import func
from app.extensions import db
from app.models.scrap_approval import ScrapApproval
from app.models.transaction import TransScrap
logger = logging.getLogger(__name__)
def _beijing():
return datetime.now(timezone(timedelta(hours=8)))
STOCK_MODELS = {}
def _stock_models():
"""延迟导入三张实物库存表,避免循环依赖"""
if not STOCK_MODELS:
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
STOCK_MODELS.update({
'stock_buy': StockBuy,
'stock_semi': StockSemi,
'stock_product': StockProduct,
})
return STOCK_MODELS
class ScrapApprovalService:
@staticmethod
def generate_request_no():
now = _beijing()
prefix = f"APR-SCRAP-{now.strftime('%Y%m%d-%H%M')}-"
n = db.session.query(func.count(func.distinct(ScrapApproval.request_no))) \
.filter(ScrapApproval.request_no.like(f"{prefix}%")).scalar() or 0
return f"{prefix}{(n + 1):04d}"
# ------------------------------------------------------------------
# 提交申请
# ------------------------------------------------------------------
@staticmethod
def submit_approval(applicant_id, items, allowed_approvers=None, remark=None,
approver_id=None, force_approval=False):
"""
提交报废申请(仅锁定“意向”,不扣库存;扣减在库管执行时进行)
items 每项必须包含 source_table + stock_id精准实物可带 scrap_qty / 快照字段。
"""
if not items:
raise ValueError("报废明细不能为空")
models = _stock_models()
normalized = []
for idx, it in enumerate(items):
st = (it.get('source_table') or '').strip()
model = models.get(st)
sid = it.get('stock_id')
if not model or not sid:
raise ValueError(f"{idx + 1} 条报废明细必须指定 source_table 与 stock_id精准库存行")
try:
sid = int(sid)
except (TypeError, ValueError):
raise ValueError(f"{idx + 1} 条 stock_id 无效")
row = model.query.get(sid)
if not row:
raise ValueError(f"{idx + 1} 条对应的库存记录不存在")
try:
qty = float(it.get('scrap_qty') or 0)
except (TypeError, ValueError):
raise ValueError(f"{idx + 1} 条报废数量无效")
if qty <= 0:
raise ValueError(f"{idx + 1} 条报废数量必须大于 0")
avail = float(getattr(row, 'available_quantity', 0) or 0)
if qty > avail:
raise ValueError(f"{idx + 1} 条报废数量({qty})超过可用库存({avail})")
base = getattr(row, 'base', None)
normalized.append({
'source_table': st,
'stock_id': sid,
'base_id': getattr(row, 'base_id', None),
'sku': getattr(row, 'sku', '') or '',
'name': (base.name if base else '') or it.get('name') or '',
'spec_model': (base.spec_model if base else '') or it.get('spec_model') or '',
'location': getattr(row, 'warehouse_location', '') or '',
'batch_number': getattr(row, 'batch_number', '') or getattr(row, 'serial_number', '') or '',
'scrap_qty': qty,
'available_at_apply': avail,
})
# ★ 是否需审批:命中需审批物料 或 库管代建(force_approval)
from app.services.approval_control import resolve_approval_control
need_approval, flagged_materials = resolve_approval_control(normalized)
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}。请选择审批人后再提交")
if approver_id:
allowed_approvers = [{"type": "user", "value": int(approver_id)}]
elif not need_approval:
allowed_approvers = []
req = ScrapApproval(
request_no=ScrapApprovalService.generate_request_no(),
applicant_id=applicant_id,
remark=remark,
)
req.set_items(normalized)
req.set_allowed_approvers(allowed_approvers)
if need_approval:
req.status = 0 # 待审批
else:
# 默认不审批:直接进入“待执行”
req.status = 1
req.actual_approver_id = applicant_id
req.approved_at = _beijing()
db.session.add(req)
db.session.commit()
logger.info(f"[ScrapApproval] 提交成功 {req.request_no} need_approval={need_approval}")
return req
# ------------------------------------------------------------------
# 列表
# ------------------------------------------------------------------
@staticmethod
def get_list(page=1, limit=10, status=None, applicant_id=None, approver_id=None):
query = ScrapApproval.query
if status is not None:
query = query.filter(ScrapApproval.status == status)
if applicant_id is not None:
query = query.filter(ScrapApproval.applicant_id == applicant_id)
if approver_id is not None:
query = query.filter(ScrapApproval.allowed_approvers.like(f'%"value": {approver_id}%'))
query = query.order_by(ScrapApproval.created_at.desc())
pg = query.paginate(page=page, per_page=limit, error_out=False)
return {
'items': [r.to_dict() for r in pg.items],
'total': pg.total,
'pages': pg.pages,
'current_page': page,
}
# ------------------------------------------------------------------
# 审批
# ------------------------------------------------------------------
@staticmethod
def approve(request_id, operator_id, action, reject_reason=None):
req = db.session.get(ScrapApproval, request_id)
if not req:
raise ValueError("报废申请不存在")
if req.status != 0:
raise ValueError("当前状态不允许审批(仅待审批可操作)")
# 仅被指定的审批人可操作
allowed = req.get_allowed_approvers() or []
user_entries = [str(a.get('value')) for a in allowed if a.get('type') == 'user']
if user_entries and str(operator_id) not in user_entries:
raise ValueError("只有被指定的审批人可以审批该申请")
if action == 'approve':
req.status = 1
req.actual_approver_id = operator_id
req.approved_at = _beijing()
req.reject_reason = None
elif action == 'reject':
req.status = 2
req.actual_approver_id = operator_id
req.reject_reason = reject_reason or '未说明原因'
else:
raise ValueError("无效的审批动作")
db.session.commit()
logger.info(f"[ScrapApproval] {req.request_no} 审批 {action} by {operator_id}")
return req
# ------------------------------------------------------------------
# 执行(按单报废:扣减实物库存 + 写报废流水)
# ------------------------------------------------------------------
@staticmethod
def execute(request_id, operator_name='System'):
models = _stock_models()
req = db.session.get(ScrapApproval, request_id)
if not req:
raise ValueError("报废申请不存在")
if req.status != 1:
raise ValueError("仅“已通过(待执行)”的报废单可执行")
items = req.get_items()
if not items:
raise ValueError("报废明细为空,无法执行")
for it in items:
model = models.get(it.get('source_table'))
if not model:
raise ValueError(f"不支持的报废来源:{it.get('source_table')}")
row = model.query.with_for_update().get(int(it['stock_id']))
if not row:
raise ValueError(f"库存记录已不存在({it.get('sku')}")
qty = float(it.get('scrap_qty') or 0)
avail = float(getattr(row, 'available_quantity', 0) or 0)
if qty <= 0 or qty > avail:
raise ValueError(f"库存 {it.get('sku') or ''} 可用不足(剩 {avail}),无法报废 {qty}")
# ★ 真正扣减可用库存
row.available_quantity = avail - qty
db.session.flush()
# 写报废流水(台账)
db.session.add(TransScrap(
sku=it.get('sku') or getattr(row, 'sku', '') or '',
source_table=it.get('source_table'),
stock_id=int(it['stock_id']),
quantity=qty,
reason=req.remark or '',
operator_name=operator_name,
approver_name=ScrapApproval._user_name(req.actual_approver_id),
approval_status='executed',
scrap_request_no=req.request_no,
))
req.status = 3
req.executed_at = _beijing()
req.executor_name = operator_name
db.session.commit()
logger.info(f"[ScrapApproval] {req.request_no} 执行报废完成 by {operator_name}")
return req