业务规则变更:所有报废申请都必须由指定审批人审批通过后才能执行。
原逻辑走 resolve_approval_control 判定是否需审批,而 material_base 表中
仅 1/3012 个物料标记了 is_approval_required,意味着 99.97% 的报废申请会
走免审批分支——status 直接置 1、actual_approver_id 被赋为申请人自己、
审批人参数被静默丢弃。前端即便做了必填也只是摆设。
改动(规则收敛到单一来源):
· 新增 SCRAP_ALWAYS_REQUIRES_APPROVAL = True,作为唯一开关;
· submit_approval() 无审批人一律拒绝;恒置 status=0(待审批);
删除免审批自动通过分支;
· resolve_approval_control 仍调用,但仅用于生成提示文案,
不再参与是否审批的判定;
· /request/check-approval 返回 need_approval=SCRAP_ALWAYS_REQUIRES_APPROVAL,
否则该接口会继续返回 false,导致前端预检结果失真。
实测:不传审批人 → 拒绝;传审批人 → status=0 且 actual_approver_id 为空。
⚠ 注意:此前自动通过的单据今后一律进入审批队列。
377 lines
16 KiB
Python
377 lines
16 KiB
Python
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()
|
||
|
||
|
||
# =============================================================================
|
||
# ★ 业务规则(单一事实来源):报废一律需审批
|
||
#
|
||
# 与出库/借库不同,报废不过滤 is_approval_required —— 无论物料是否命中该标记,
|
||
# 所有报废申请都必须由指定审批人审批通过后才能执行。
|
||
# 前端 apply/index.vue 的「审批人」必填项与此规则保持一致。
|
||
# =============================================================================
|
||
SCRAP_ALWAYS_REQUIRES_APPROVAL = True
|
||
|
||
|
||
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,
|
||
})
|
||
|
||
# ★ 报废一律需审批(见 SCRAP_ALWAYS_REQUIRES_APPROVAL)。
|
||
# resolve_approval_control 仍调用,但仅用于生成「哪些物料命中需审批」的提示文案,
|
||
# 不再用它决定是否需要审批。
|
||
from app.services.approval_control import resolve_approval_control
|
||
_, flagged_materials = resolve_approval_control(normalized)
|
||
|
||
if not approver_id:
|
||
if flagged_materials:
|
||
_names = ";".join(f"{m['name']}({m['spec_model'] or '-'})" for m in flagged_materials)
|
||
raise ValueError(f"以下物料需审批报废:{_names}。请选择审批人后再提交")
|
||
raise ValueError("报废申请必须选择审批人后再提交")
|
||
|
||
allowed_approvers = [{"type": "user", "value": int(approver_id)}]
|
||
|
||
req = ScrapApproval(
|
||
request_no=ScrapApprovalService.generate_request_no(),
|
||
applicant_id=applicant_id,
|
||
remark=remark,
|
||
)
|
||
req.set_items(normalized)
|
||
req.set_allowed_approvers(allowed_approvers)
|
||
|
||
# ★ 恒为「待审批」,不再走免审批自动通过分支
|
||
req.status = 0
|
||
|
||
db.session.add(req)
|
||
db.session.commit()
|
||
logger.info(f"[ScrapApproval] 提交成功 {req.request_no} approver={approver_id}")
|
||
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 _norm_sku(sku):
|
||
"""SKU 归一化:去首尾空白。空 SKU 返回 '',由调用方回退到行级匹配键。"""
|
||
return str(sku or '').strip()
|
||
|
||
@staticmethod
|
||
def _match_key(source_table, stock_id, sku):
|
||
"""
|
||
★ 扫码匹配键 —— SKU 优先。
|
||
|
||
已核验:SKU 在同一库存表内唯一,且不存在跨表重名(stock_buy /
|
||
stock_semi / stock_product 三表交叉无冲突),故 SKU 可安全作为
|
||
跨来源的稳定标识。
|
||
|
||
例外:个别历史库存行的 SKU 为空,这类行无法用 SKU 标识,回退为
|
||
source_table + stock_id 复合键。前缀区分('sku:' / 'row:')保证
|
||
空 SKU 行绝不会与任何正常 SKU 串键。
|
||
"""
|
||
s = ScrapApprovalService._norm_sku(sku)
|
||
if s:
|
||
return ('sku', s)
|
||
return ('row', f"{source_table}#{stock_id}")
|
||
|
||
@staticmethod
|
||
def _build_approved_index(items):
|
||
"""
|
||
申请单明细 → {匹配键: 批准信息}
|
||
|
||
每项保留其 source_table + stock_id 清单(rows),执行时据此定位到
|
||
批准时指定的那条库存记录做加锁扣减。同一 SKU 若在批准单中出现多行,
|
||
数量累加、rows 按批准顺序保留。
|
||
"""
|
||
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
|
||
key = ScrapApprovalService._match_key(st, sid, it.get('sku'))
|
||
entry = index.setdefault(key, {
|
||
'qty': 0.0,
|
||
'label': ScrapApprovalService._norm_sku(it.get('sku'))
|
||
or it.get('name') or f"{st}#{sid}",
|
||
'rows': [],
|
||
})
|
||
qty = float(it.get('scrap_qty') or 0)
|
||
entry['qty'] += qty
|
||
entry['rows'].append({'source_table': st, 'stock_id': sid, 'qty': qty})
|
||
return index
|
||
|
||
@staticmethod
|
||
def _build_scanned_index(scanned_items, models):
|
||
"""前端实扫明细 → {匹配键: 累计扫码数量}"""
|
||
index = {}
|
||
for idx, s in enumerate(scanned_items):
|
||
sku = ScrapApprovalService._norm_sku(s.get('sku'))
|
||
st = str(s.get('source_table') or '').strip()
|
||
sid = ScrapApprovalService._to_int(s.get('stock_id'))
|
||
if not sku and (not st or sid is None):
|
||
raise ValueError(f"第 {idx + 1} 条扫码明细缺少 SKU,且无有效的 source_table / stock_id")
|
||
if st and 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")
|
||
key = ScrapApprovalService._match_key(st, sid, sku)
|
||
entry = index.setdefault(key, {
|
||
'qty': 0.0,
|
||
'label': sku or s.get('name') or f"{st}#{sid}",
|
||
})
|
||
entry['qty'] += qty
|
||
return index
|
||
|
||
@staticmethod
|
||
def execute(request_id, operator_name='System', scanned_items=None):
|
||
"""
|
||
按单执行报废:以「实际扫码明细」为准,按 SKU 匹配批准明细后扣减库存。
|
||
|
||
scanned_items: [{'sku', 'quantity', 'source_table', 'stock_id'(可选,空 SKU 时必填)}]
|
||
· 以 SKU 为主校验键:扫码 SKU 必须在申请单 items_json 中存在;
|
||
· 同一 SKU 多次扫码累加,累计不得超过该 SKU 的批准总量;
|
||
· 允许合法子集(少扫 = 本次不报废该行);
|
||
· 扣减时以批准单配对的 source_table + stock_id 定位库存行加锁。
|
||
"""
|
||
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)
|
||
|
||
# ★ 校验一:扫码 SKU 必须在批准明细内
|
||
for key, acc in scanned.items():
|
||
if key not in approved:
|
||
raise ValueError(
|
||
f"SKU【{acc['label']}】不在该报废申请单的批准明细中(SKU 不匹配),禁止报废"
|
||
)
|
||
|
||
# ★ 校验二:同一 SKU 的累计扫码量不得超过批准总量
|
||
for key, acc in scanned.items():
|
||
appr = approved[key]
|
||
if acc['qty'] > appr['qty']:
|
||
raise ValueError(
|
||
f"SKU【{acc['label']}】扫码数量({acc['qty']})超出批准数量({appr['qty']}),禁止报废"
|
||
)
|
||
|
||
# ★ 扣减:按批准单配对的 source_table + stock_id 定位库存行,逐行加锁扣减
|
||
for key, acc in scanned.items():
|
||
appr = approved[key]
|
||
remaining = acc['qty']
|
||
|
||
for ref in appr['rows']:
|
||
if remaining <= 0:
|
||
break
|
||
take = min(remaining, ref['qty'])
|
||
if take <= 0:
|
||
continue
|
||
|
||
st, sid = ref['source_table'], ref['stock_id']
|
||
row = models[st].query.with_for_update().get(sid)
|
||
if not row:
|
||
raise ValueError(f"库存记录已不存在({acc['label']})")
|
||
|
||
avail = float(getattr(row, 'available_quantity', 0) or 0)
|
||
stock = float(getattr(row, 'stock_quantity', 0) or 0)
|
||
if take > avail:
|
||
raise ValueError(f"库存 SKU【{acc['label']}】可用不足(剩 {avail}),无法报废 {take}")
|
||
if take > stock:
|
||
raise ValueError(f"库存 SKU【{acc['label']}】实物不足(剩 {stock}),无法报废 {take}")
|
||
|
||
# ★ 真正扣减:报废 = 实物销毁,实物库存与可用库存需同时扣减
|
||
row.available_quantity = avail - take
|
||
row.stock_quantity = stock - take
|
||
db.session.flush()
|
||
|
||
# 写报废流水(台账)
|
||
db.session.add(TransScrap(
|
||
sku=getattr(row, 'sku', '') or acc['label'],
|
||
source_table=st,
|
||
stock_id=sid,
|
||
quantity=take,
|
||
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,
|
||
))
|
||
|
||
remaining -= take
|
||
|
||
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
|