Files
KCGL/inventory-backend/app/services/scrap_approval_service.py
yueli 58fa42bff3 feat(scrap): 报废全链路收口到审批流
系统自陈的规则是「报废一律需审批」(SCRAP_ALWAYS_REQUIRES_APPROVAL = True),
但实际有 4 条写 TransScrap 的路径,其中 3 条绕过审批。本提交把三条旁路
全部收口,只保留「申请 → 审批 → 执行」一条写入路径。

【删除】直接报废 POST /api/v1/scrap
  同时移除 ScrapService.process_scrap()。该路径的一个连带影响是
  「维修件报废」能力随之消失 —— process_scrap 的 trans_repair 分支是
  repair_status='报废转出' 的唯一写入点。实测该能力零使用(报废转出 0 条、
  trans_scrap 来源 0 条),且早已半死:审批流的扫码校验会拒绝 trans_*
  来源。repair_service.py 的误导文案(原文指引操作员「前往报废管理进行
  扫码操作」,而那条路根本不通)已改为「维修件报废暂未开放」。
  权限元素 scrap_create:operation 保留不删 —— add_scrap_perm.sql 以它为
  scrap_apply/scrap_execute 的授权来源。

【改造】借库转报废 POST /borrow/scrap → POST /borrow/scrap-request
  TransService.scrap_borrow() 删除,逻辑迁入 BorrowScrapAdapter。
  沿用 op_return:operation 权限(零授权变更)。已归还/已报废的记录改为
  **直接报错**,不再静默 continue 返回 count=0(原缺陷:用户以为成功)。

【改造】不良品报废 POST /defective/<id>/scrap → POST /defective/<id>/scrap-request
  申请时**不预占** remaining_qty(与报废模块「仅锁定意向,不扣库存」的
  既有哲学一致)。副作用:同一批坏件可重复提交多张申请单,执行期由适配器
  按 Fail-Closed 拒绝超额的那几张。已在该接口注释与前端提示中写明。

【服务层】ScrapApprovalService 接入来源适配层
  - submit_approval 改由 get_adapter() 分派,并新增整单 scrap_mode 一致性
    校验(混合「需扫码/免扫码」两类来源直接拒绝,让 execute 分流保持简单)
  - _build_scanned_index 的来源校验改为 is_scan_source():语义上是**收窄**
    而非放宽,扫码通道永远不接纳 trans_* 来源
  - execute 按模式分流:scan 项走原有扫码匹配(索引只由 scan 项构建),
    auto 项按批准量执行。签名与调用契约不变。
  - _match_key 加入来源表:不良品 SKU 是从原库存行复制的,不带来源时
    同一张单里的两者会**必然串键**,扫码量算到错误对象上。纯库存单两侧
    同源、键仍匹配,对既有流程零行为变更。

【修复】approve() 的 fail-open
  原实现 `if user_entries and str(operator_id) not in user_entries` ——
  allowed_approvers 为空时条件短路为假,**任何登录用户都能审批**。
  这与「报废一律需审批」直接矛盾,留这扇门等于没有审批。改为无名单即拒绝。
  已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。

【扫码通道】scrap/scan 的 trans_repair 分支改为 trans_defective_goods
  在管不良品按 SKU 匹配,在管量回填到既有字段形状,前端无需按来源分支。

【清理】scrap_approval_service 的 _stock_models 与 TransScrap 导入已移除
  (来源差异全部收敛到适配层);trans_service / inventory_reservation 中
  指向已删方法的注释已更新指向 BorrowScrapAdapter。
2026-09-16 17:14:17 +08:00

464 lines
21 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
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() 硬编码三张库存表。来源差异已全部收敛到
# app/services/scrap_sources.py 的来源适配层(它复用
# inventory_reservation.stock_model_map(),避免第四份重复定义),
# 本模块不再直接持有库存表映射。
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("报废明细不能为空")
# ★ 来源适配:三类来源(库存行 / 在管不良品 / 借出未还)各有不同的
# 可报废上限、扣减行为与快照字段,差异全部收敛在 scrap_sources 里。
# 原先此处硬编码「只认三张库存表」,导致借出未还与在管不良品只能
# 各走直报接口绕过审批。详见 app/services/scrap_sources.py 模块头。
from app.services.scrap_sources import get_adapter
normalized = []
for idx, it in enumerate(items):
st = (it.get('source_table') or '').strip()
sid = it.get('stock_id')
adapter = get_adapter(st)
if not adapter 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 = adapter.load(sid)
if not row:
raise ValueError(f"{idx + 1} 条对应的{adapter.label}记录不存在")
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")
cap = adapter.cap(row)
if qty > cap:
raise ValueError(
f"{idx + 1} 条报废数量({qty})超过{adapter.cap_label}({cap})"
)
# 提交期的额外约束(如已归还的借用记录不得再报废)
adapter.submit_guard(row, qty)
normalized.append(adapter.snapshot(row, qty, it))
# ★ 整单执行模式必须一致。
# 现有三个提交入口(报废申请页 / 借还记录页 / 不良品看板)各自只提交
# 单一来源UI 上产不出混合单;此处显式拒绝非法构造,让 execute() 的
# 分流逻辑保持简单可验证。
modes = {n.get('scrap_mode') for n in normalized}
if len(modes) > 1:
raise ValueError(
"同一张报废申请单不能混合「需扫码」与「免扫码」两类来源,请分开提交"
)
# ★ 报废一律需审批(见 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("当前状态不允许审批(仅待审批可操作)")
# 仅被指定的审批人可操作。
#
# ★ Fail-Closed原实现是 `if user_entries and str(operator_id) not in ...`
# —— 当 allowed_approvers 为空(或条目里没有 type='user')时 user_entries
# 为空列表,条件短路为假,**任何登录用户都能审批**。这与本模块自陈的
# 「报废一律需审批」直接矛盾:留一扇「无审批人则人人可审」的门,
# 等于没有审批。现改为无名单即拒绝。
# 已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。
allowed = req.get_allowed_approvers() or []
user_entries = [str(a.get('value')) for a in allowed if a.get('type') == 'user']
if not user_entries:
raise ValueError("该申请单未指定审批人,无法审批,请联系管理员处理")
if 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
# ------------------------------------------------------------------
# 撤回 / 作废(已通过但未执行 → 4-已撤回)
#
# 报废模块当前**尚未接入库存预占**(提交时不锁库存,扣减发生在执行阶段),
# 因此这里没有可释放的预占。仍显式调用 release_reserved()
# · 对存量单据无害(无 reserved 标记会被跳过);
# · 若将来报废接入预占,此处无需再改,自动生效。
# 状态守卫「仅 status==1 可撤回」同时充当执行守卫:
# 已执行(status=3)的单无法再次撤回。
# ------------------------------------------------------------------
WITHDRAWABLE_STATUS = (0, 1)
@staticmethod
def withdraw(request_id, operator_id, require_owner=True):
"""
撤回报废申请单(待审批 或 已通过但未执行)。
require_owner=True默认申请人路径
断言 applicant_id == operator_id否则拒绝特权角色可代撤
require_owner=False管理路径审批页调用
由调用方的权限装饰器scrap_approval负责鉴权。
与出库/借库保持一致的两条独立入口 + 共用释放底层。
"""
from app.utils.decorators import is_privileged_viewer
req = db.session.get(ScrapApproval, request_id)
if not req:
raise ValueError("报废申请不存在")
# ★ 归属校验(仅申请人路径)
if require_owner and not is_privileged_viewer():
if int(req.applicant_id) != int(operator_id):
raise PermissionError("无权撤回他人的申请单")
if req.status not in ScrapApprovalService.WITHDRAWABLE_STATUS:
status_map = {0: '待审批', 1: '已通过(待执行)', 2: '已驳回', 3: '已执行', 4: '已撤回'}
raise ValueError(
f"当前状态不可撤回:{status_map.get(req.status, req.status)}"
)
from app.services.inventory_reservation import release_reserved
restored = release_reserved(req.get_items())
req.status = 4 # 4-已撤回
req.reject_reason = '申请人撤回'
db.session.commit()
logger.info(f"[ScrapApproval] {req.request_no} 已撤回 by {operator_id},释放 {restored}")
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', s)其前提是「SKU 在三张库存表内唯一且跨表无重名」。
引入逆向物流来源后这个前提不再成立:在管不良品台账的 SKU 是从
原库存行**复制**的(退回时 sku=getattr(stock_row, 'sku', '')
故 `trans_defective_goods#N` 与其源 `stock_buy#M` 的 SKU **必然相同**。
若同一张报废单同时含两者,不带来源就会串键 —— 扫码量会算到错误对象上。
加入 source_table 后,纯库存单两侧同源、键仍匹配(对既有流程零行为
变更),而跨来源的同 SKU 行彻底隔离。
空 SKU 的历史行仍回退为 row 复合键,两种键的首元素不同
'sku' / 'row'),不会互相串键。
"""
st = (source_table or '').strip()
s = ScrapApprovalService._norm_sku(sku)
if s:
return ('sku', st, s)
return ('row', f"{st}#{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):
"""前端实扫明细 → {匹配键: 累计扫码数量}"""
from app.services.scrap_sources import is_scan_source
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")
# ★ 语义收窄而非放宽:扫码通道只接纳明确声明为 scan 的来源。
# 免扫码来源(借出未还)与未知来源一律拒绝 —— 后者能挡住
# 「扫码扫得到、执行却拒绝」的历史错配trans_repair 曾如此)。
if st and not is_scan_source(st):
raise ValueError(
f"{idx + 1} 条扫码来源不支持扫码提交:{st}"
f"(该来源为免扫码执行来源,或来源非法)"
)
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):
"""
按单执行报废:按来源的执行模式分流处理。
scanned_items: [{'sku', 'quantity', 'source_table', 'stock_id'(可选,空 SKU 时必填)}]
仅承载「实扫到的实物」。免扫码来源(借出未还)**不经过**这里,
由后端按批准量直接执行 —— 调用契约与改造前完全一致。
scan 来源(库存行 / 在管不良品):
· 以 SKU + 来源表为主校验键(来源表用于隔离同 SKU 的跨来源行);
· 同一键多次扫码累加,累计不得超过批准总量;
· 允许合法子集(少扫 = 本次不报废该行)。
auto 来源(借出未还):
· 按 items_json 中的 scrap_qty 全量执行,不参与扫码匹配。
"""
from app.services.scrap_sources import (
get_adapter, SCRAP_MODE_AUTO, SCRAP_MODE_SCAN,
)
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("报废明细为空,无法执行")
# ★ 按执行模式分流。存量单据(全部是库存来源)没有 scrap_mode 字段,
# 回落到适配器声明的模式 = scan行为与改造前逐字一致。
def _mode_of(item):
mode = (item.get('scrap_mode') or '').strip()
if mode in (SCRAP_MODE_SCAN, SCRAP_MODE_AUTO):
return mode
adapter = get_adapter(item.get('source_table'))
return adapter.scrap_mode if adapter else SCRAP_MODE_SCAN
scan_items = [it for it in approved_items if _mode_of(it) == SCRAP_MODE_SCAN]
auto_items = [it for it in approved_items if _mode_of(it) == SCRAP_MODE_AUTO]
# ★ 扫码仅在**本单存在需扫码明细**时强制。
# 纯免扫码单(借出未还)允许 scanned_items 为空 —— 实物在借用人
# 手上,要求扫码在物理上不可能。
if scan_items and not scanned_items:
raise ValueError("请先扫码并提交实际报废物料,再执行报废")
approved = {}
scanned = {}
if scan_items:
# 索引只由 scan 项构建auto 项不参与匹配,避免同 SKU 串键
approved = ScrapApprovalService._build_approved_index(scan_items)
scanned = ScrapApprovalService._build_scanned_index(scanned_items)
def _adapter_for(st):
adapter = get_adapter(st)
if adapter is None:
raise ValueError(f"报废来源不支持:{st or '(空)'}")
return adapter
# ★ 校验一:扫码物料必须在批准明细内
for key, acc in scanned.items():
if key not in approved:
raise ValueError(
f"物料【{acc['label']}】不在该报废申请单的批准明细中SKU 不匹配),禁止报废"
)
# ★ 校验二:同一物料的累计扫码量不得超过批准总量
for key, acc in scanned.items():
appr = approved[key]
if acc['qty'] > appr['qty']:
raise ValueError(
f"物料【{acc['label']}】扫码数量({acc['qty']})超出批准数量({appr['qty']}),禁止报废"
)
# ★ scan 项扣减:按批准单配对的 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
_adapter_for(ref['source_table']).deduct(
ref['stock_id'], take, req, operator_name,
)
remaining -= take
# ★ auto 项扣减:免扫码来源按批准量全量执行。
# 不参与上面的扫码匹配 —— 若把它塞进同一个索引,同 SKU 会与
# scan 项串键,扫码量被算到错误对象上。
for it in auto_items:
qty = float(it.get('scrap_qty') or 0)
if qty <= 0:
continue
_adapter_for(it.get('source_table')).deduct(
it.get('stock_id'), qty, req, operator_name,
)
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