Files
KCGL/inventory-backend/app/services/scrap_approval_service.py
yueli 7719943779 feat(scrap): 报废执行改为按单扫码校验,废弃盲执行
execute() 原先只吃 request_id,按申请单快照全量扣减,用户反馈「扫码与报废脱节」。
现改为接收实扫明细 scanned_items:

  · 强制按单:未提交实扫明细一律拒绝执行;
  · 键为 (source_table, stock_id),与申请单 items_json 同口径,
    同一物品多次扫码自动累加,兼容「逐件扫」与「输数量」两种操作;
  · 校验实扫项必须落在批准明细内,且累计量不得超批准量,否则整单拒绝;
  · 允许合法子集(少扫 = 本次不报废该行);
  · 扣减以实扫量为准,同时扣 available_quantity 与 stock_quantity
    (报废即实物销毁,上一版只扣可用库存会让总库存虚挂)。

接口 POST /scrap/request/<id>/execute 由无 body 改为接收 {items:[...]}。
2026-09-10 10:14:40 +08:00

322 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

import logging
from datetime import datetime, timezone, timedelta
from sqlalchemy import func
from app.extensions import db, beijing_time
from app.models.scrap_approval import ScrapApproval
from app.models.transaction import TransScrap
logger = logging.getLogger(__name__)
def _beijing():
"""
统一时间口径:naive 北京时间。
★ scrap_approval 的时间列已统一为 timestamp without time zone
(见 db_migrations/unify_approval_timezone.sql),必须返回 naive 值,
否则 aware 值会被驱动转成 UTC 存库,比 created_at 早 8 小时。
"""
return beijing_time()
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 _to_int(v):
try:
return int(v)
except (TypeError, ValueError):
return None
@staticmethod
def _build_approved_index(items):
"""把申请单明细整理成 (source_table, stock_id) -> 批准数量 的索引"""
index = {}
for it in items:
sid = ScrapApprovalService._to_int(it.get('stock_id'))
st = str(it.get('source_table') or '').strip()
if not st or sid is None:
continue
entry = index.setdefault((st, sid), {
'qty': 0.0,
'label': it.get('name') or it.get('sku') or f"{st}#{sid}",
})
entry['qty'] += float(it.get('scrap_qty') or 0)
return index
@staticmethod
def _build_scanned_index(scanned_items, models):
"""把前端实际扫码明细整理成 (source_table, stock_id) -> 累计扫码数量 的索引"""
index = {}
for idx, s in enumerate(scanned_items):
st = str(s.get('source_table') or '').strip()
sid = ScrapApprovalService._to_int(s.get('stock_id'))
if not st or sid is None:
raise ValueError(f"第 {idx + 1} 条扫码明细缺少有效的 source_table / stock_id")
if st not in models:
raise ValueError(f"第 {idx + 1} 条扫码来源不支持:{st}")
qty = float(s.get('quantity') or 0)
if qty <= 0:
raise ValueError(f"第 {idx + 1} 条扫码数量必须大于 0")
entry = index.setdefault((st, sid), {
'qty': 0.0,
'label': s.get('name') or s.get('sku') or f"{st}#{sid}",
})
entry['qty'] += qty
return index
@staticmethod
def execute(request_id, operator_name='System', scanned_items=None):
"""
按单执行报废:以「实际扫码明细」为准扣减库存。
scanned_items: [{'source_table', 'stock_id', 'quantity', 'name'/'sku'(可选)}]
· 必须是申请单 items_json 的子集(允许少扫,不允许扫单外物品)
· 同一物品多次扫码会累加,累计不得超过批准数量
"""
models = _stock_models()
req = db.session.get(ScrapApproval, request_id)
if not req:
raise ValueError("报废申请不存在")
if req.status != 1:
raise ValueError("仅“已通过(待执行)”的报废单可执行")
approved_items = req.get_items()
if not approved_items:
raise ValueError("报废明细为空,无法执行")
# ★ 强制按单扫码:未提交实扫明细不允许执行
if not scanned_items:
raise ValueError("请先扫码并提交实际报废物料,再执行报废")
approved = ScrapApprovalService._build_approved_index(approved_items)
scanned = ScrapApprovalService._build_scanned_index(scanned_items, models)
# ★ 校验:扫码明细必须落在批准明细内,且累计量不得超批准量
for key, acc in scanned.items():
appr = approved.get(key)
if not appr:
raise ValueError(
f"扫码物品【{acc['label']}】不在该报废申请单的明细中,禁止报废"
)
if acc['qty'] > appr['qty']:
raise ValueError(
f"【{acc['label']}】扫码数量({acc['qty']})超出批准数量({appr['qty']}),禁止报废"
)
# ★ 扣减:只扣实际扫码量(未扫到的批准项视为本次不报废)
for (st, sid), acc in scanned.items():
model = models[st]
row = model.query.with_for_update().get(sid)
if not row:
raise ValueError(f"库存记录已不存在({acc['label']})")
qty = acc['qty']
avail = float(getattr(row, 'available_quantity', 0) or 0)
stock = float(getattr(row, 'stock_quantity', 0) or 0)
if qty > avail:
raise ValueError(f"库存 {acc['label']} 可用不足(剩 {avail}),无法报废 {qty}")
if qty > stock:
raise ValueError(f"库存 {acc['label']} 实物不足(剩 {stock}),无法报废 {qty}")
# ★ 真正扣减:报废 = 实物销毁,实物库存与可用库存需同时扣减
row.available_quantity = avail - qty
row.stock_quantity = stock - qty
db.session.flush()
# 写报废流水(台账)
db.session.add(TransScrap(
sku=getattr(row, 'sku', '') or '',
source_table=st,
stock_id=sid,
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} "
f"(批准 {len(approved)} 项 / 实扫 {len(scanned)} 项)"
)
return req