Files
KCGL/inventory-backend/app/services/scrap_approval_service.py
yueli bfd0db791c feat(scrap): 报废原因分类 + 角色级审批
报废要回答「这笔损失出在哪个环节」,并让主管审批不再依赖逐个指定人。

- 新增「报废原因分类」字段(scrap_approval + trans_scrap 各一列),
  只有两个互斥口径:生产报废(走 Track 的)/ 库存报废(MOM 自身流程的)。
  不传即库存报废 —— 这条二分法在写入那一刻就成立,不依赖任何推导。
  ⚠️ 不能从 source_table 推导:Track 的生产报废与手工的不良品退回共用
     同一张 trans_defective_goods 表,推导会把生产损失算成库存损失。
- scrap_approval 加 company_name / source_ref:前者是公司隔离快照,
  后者是外部单据的幂等锚点(Redis 未部署,prevent_double_submit 全程
  fail-open,唯一索引是唯一防线)。
- 审批从「只认 type=user」放宽到「user 或 role」——主管角色都能审,
  谁审就记谁。★ 空名单依然拒绝所有人(Fail-Closed),这是历史
  「名单为空则人人可审」漏洞的修复点,不得改回 fail-open。
- 角色级放行必须配公司隔离:6 个主管里 IRIS 5 个、LICA 1 个,
  不隔离就是跨公司审批通道。
- trans_return 加 source_ref(幂等锚点)。

迁移:db_migrations/phase12(建列)+ phase13(存量空分类回填为库存报废)。
2026-09-23 15:17:44 +08:00

697 lines
33 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
# =============================================================================
# ★ 默认审批角色(角色级审批)
#
# 提交时若不指定具体审批人,名单回落成这两个角色 —— 「有主管权限的都能看到、
# 都能审批」,谁审就记谁(actual_approver_id)。
#
# ★ 刻意**不含 WAREHOUSE_MGR**:库管是报废的执行人(scrap_execute),
# 让他同时能审批,就把「申请 → 审批 → 执行」的职责分离塌缩成一个人,
# 本模块反复强调的那道关卡就没了。与出库/借库的先例一致
# (outbound.py 的 _default_approvers = SUPERVISOR + SUPER_ADMIN)。
# =============================================================================
DEFAULT_SCRAP_APPROVER_ROLES = ('SUPERVISOR', 'SUPER_ADMIN')
# allowed_approvers 条目里允许出现的 type 值
_APPROVER_TYPES = ('user', 'role')
# 注:原先此处有 _stock_models() 硬编码三张库存表。来源差异已全部收敛到
# app/services/scrap_sources.py 的来源适配层(它复用
# inventory_reservation.stock_model_map(),避免第四份重复定义),
# 本模块不再直接持有库存表映射。
class ScrapApprovalService:
# ------------------------------------------------------------------
# 审批人判定(★ 单一事实来源:approve / 详情查看 / 列表 scope=pending 共用)
# ------------------------------------------------------------------
@staticmethod
def _approver_entries(allowed):
"""allowed_approvers JSON → (user_ids:set, roles:set)。
★ 四处判定(approve / 详情查看 / 列表 pending / 提交)都走这一个解析,
避免四份逻辑各自漂移。脏条目(非 dict、无 value)直接跳过。
"""
users, roles = set(), set()
for a in (allowed or []):
if not isinstance(a, dict):
continue
atype = str(a.get('type') or '').strip().lower()
value = a.get('value')
if value is None or str(value).strip() == '':
continue
if atype == 'user':
users.add(str(value).strip())
elif atype == 'role':
roles.add(str(value).strip().upper())
return users, roles
@staticmethod
def resolve_operator_role(operator_id):
"""按 id 反查操作人角色(SysUser.role,大写)。查不到返回 ''。
★ 以**数据库**为准,不用 JWT 里的 role claim —— 角色刚被改过时两者会不一致,
而审批放行必须以「此刻这个人是什么角色」为准。
"""
try:
from app.models.system import SysUser
u = SysUser.query.get(int(operator_id))
return (u.role or '').upper() if u else ''
except Exception:
return ''
@staticmethod
def can_operator_approve(req, operator_id, operator_role=None):
"""操作人是否有权审批这张单(名单匹配 user 或 role)。
★ Fail-Closed:名单为空(或全是无 value 的脏条目)→ **拒绝所有人**。
这是历史上「名单为空则人人可审」漏洞的修复点,不得改回
`if entries and x not in entries` 那种短路写法 —— 那种写法在名单为空时
条件恒假,等于没审批。
★ 刻意**不做 SUPER_ADMIN 无条件旁路**(出库的 can_approve 有这条)。
理由:新单的默认名单已含 SUPER_ADMIN 角色,超管本来就能审;
再加一条无条件旁路,等于在「必须被列名」这条唯一规则上开洞。
"""
users, roles = ScrapApprovalService._approver_entries(req.get_allowed_approvers())
if not users and not roles:
return False
if str(operator_id) in users:
return True
role = operator_role
if role is None:
role = ScrapApprovalService.resolve_operator_role(operator_id)
return bool(role) and str(role).upper() in roles
@staticmethod
def assert_same_company(req, operator_id):
"""角色级审批的**必要配套**:公司隔离。
主管共 6 人(IRIS 5 / LICA 1)。只按角色放行而不比公司,
LICA 的主管就能审 IRIS 的报废单 —— 这是真实的跨公司越权通道。
口径:申请人所属公司(req.company_name,提交时的快照)vs 操作人公司
(SysUser.department,「部门」在 MOM 里就是公司名)。
· req.company_name 为空(本列上线前的旧单 / 申请人已被删)→ **放行**。
这是刻意的 fail-open:只对存量生效,而且单据仍受名单约束,
不会把历史在途单永久卡死。
· 操作人是超管 / 跨域(无公司归属)→ 放行。
· 两侧都有值且不等 → PermissionError。
"""
req_company = (req.company_name or '').strip()
if not req_company:
return
try:
from app.models.system import SysUser
op = SysUser.query.get(int(operator_id))
except Exception:
return
if not op:
return
op_company = (op.department or '').strip()
role = (op.role or '').upper()
# 超管跨域放行(与 get_current_company_filter 对超管返回 None 同一口径)
if role == 'SUPER_ADMIN' or not op_company:
return
if op_company != req_company:
raise PermissionError("无权审批其他公司的报废申请")
@staticmethod
def _sanitize_approvers(raw):
"""校验并清洗外部传入的 allowed_approvers,返回规范化列表。
Fail-Closed 规则:
· 只接受 type ∈ {user, role} 且 value 非空的 dict,其余条目**丢弃并告警**;
· type=user 的值必须能转 int 且**用户在库中存在** —— 否则丢弃。
不校验会造出一张「指定的审批人根本不存在」的死单,谁审不了、只能找管理员;
· type=role 的值 strip().upper(),未知角色码只告警不拒绝
(库里有 PURCHASE 这类 UserRole 常量里没有的历史角色码,硬校验会误伤)。
"""
cleaned = []
for a in (raw or []):
if not isinstance(a, dict):
logger.warning(f"[ScrapApproval] 忽略非法审批人条目(非对象): {a!r}")
continue
atype = str(a.get('type') or '').strip().lower()
value = a.get('value')
if atype not in _APPROVER_TYPES or value is None or str(value).strip() == '':
logger.warning(f"[ScrapApproval] 忽略非法审批人条目: {a!r}")
continue
if atype == 'user':
try:
uid = int(value)
except (TypeError, ValueError):
logger.warning(f"[ScrapApproval] 忽略无效的审批人ID: {value!r}")
continue
from app.models.system import SysUser
if not SysUser.query.get(uid):
logger.warning(f"[ScrapApproval] 忽略不存在的审批人ID: {uid}")
continue
cleaned.append({'type': 'user', 'value': uid})
else:
role_code = str(value).strip().upper()[:50]
logger.warning(f"[ScrapApproval] 使用角色级审批人: {role_code}(未校验角色码是否存在)")
cleaned.append({'type': 'role', 'value': role_code})
return cleaned
@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,
reason_category=None, company_name=None, source_ref=None,
commit=True):
"""
提交报废申请(仅锁定“意向”,不扣库存;扣减在库管执行时进行)
items 每项必须包含 source_table + stock_id(精准实物),可带 scrap_qty / 快照字段。
审批人有两条路,优先看 approver_id:
· approver_id 有值 → 名单 = [{"type":"user","value":N}](旧路径,行为不变);
· approver_id 为空 → 用 allowed_approvers;它也为空则回落
DEFAULT_SCRAP_APPROVER_ROLES(角色级审批:有主管权限的都能审)。
reason_category / company_name / source_ref:
生产报废(Track → MOM)用。company_name 是角色级审批做公司隔离的前提。
commit=False:只 flush,把提交权交给调用方 —— 供「退回 + 提报废申请」
组合成一个原子操作(见 app/services/return_service.py)。
⚠️ 调用方拿到的是**未提交**的对象,必须在自己的事务里 commit/rollback。
"""
from app.models.scrap_approval import normalize_scrap_category
if not items:
raise ValueError("报废明细不能为空")
# 分类码在入口就归一化:未知值直接抛,别把脏码写进库里等统计时才发现。
# ★ 不传 = **库存报废**:MOM 自身流程(界面选库存行报废、不良品看板、
# 借还记录)走下来的都算库存报废;只有 Track 那条链会显式传 PRODUCTION
# (生产报废)。这样「走 Track 的 = 生产报废、其余的 = 库存报废」这条
# 二分法在写入那一刻就成立,不依赖任何推导。
from app.models.scrap_approval import SCRAP_CATEGORY_STOCK
category = normalize_scrap_category(reason_category, default=SCRAP_CATEGORY_STOCK)
# ★ 来源适配:三类来源(库存行 / 在管不良品 / 借出未还)各有不同的
# 可报废上限、扣减行为与快照字段,差异全部收敛在 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 approver_id:
# 旧路径:指定了具体审批人,名单钉死为这一人。行为与改造前逐字一致。
final_approvers = [{"type": "user", "value": int(approver_id)}]
else:
final_approvers = ScrapApprovalService._sanitize_approvers(allowed_approvers)
if not final_approvers:
# ★ 角色级审批下**永远有合法审批人**,不再报「必须选择审批人」。
# Fail-Closed 由「必须持 scrap_approval 权限码 且 命中角色」两道门保证
# (见 can_operator_approve 与 app/api/v1/scrap.py 的装饰器)。
final_approvers = [
{"type": "role", "value": r} for r in DEFAULT_SCRAP_APPROVER_ROLES
]
# flagged_materials 此时只剩「提示文案」价值,记日志便于回溯
if flagged_materials:
_names = ";".join(
f"{m['name']}({m['spec_model'] or '-'})" for m in flagged_materials
)
logger.info(f"[ScrapApproval] 命中需审批物料(角色级审批):{_names}")
req = ScrapApproval(
request_no=ScrapApprovalService.generate_request_no(),
applicant_id=applicant_id,
remark=remark,
company_name=(company_name or '').strip() or None,
reason_category=category,
source_ref=(source_ref or '').strip() or None,
)
req.set_items(normalized)
req.set_allowed_approvers(final_approvers)
# ★ 恒为「待审批」,不再走免审批自动通过分支
req.status = 0
db.session.add(req)
if commit:
db.session.commit()
else:
# ★ 只 flush:把 req 交给调用方的事务,让「退回 + 提报废申请」原子化。
# flush 后 req.id / req.request_no 均可用(request_no 是 SQL 计数生成,
# 事务内可见自己的写入)。
db.session.flush()
logger.info(
f"[ScrapApproval] 提交成功 {req.request_no} "
f"approvers={final_approvers} category={category} commit={commit}"
)
return req
# ------------------------------------------------------------------
# 列表
# ------------------------------------------------------------------
@staticmethod
def get_list(page=1, limit=10, status=None, applicant_id=None,
approver_id=None, approver_role=None, company_name=None):
"""申请表分页。
approver_id / approver_role:只看「指定给我的」或「属于我这个角色的」
待办单(scope=pending)。两者是 **OR** 关系 —— 一张单可能既指名某人、
又对某角色开放,任一命中就该出现。
⚠️ SQL 层只能用**宽松 LIKE** 匹配 JSON 文本(items_json 是 text 不是 jsonb,
allowed_approvers 同样是 text,没有 JSON 包含查询可用)。
已知会误命中:`"value": 12` 会匹配到 `"value": 123`。
本轮**不收紧** —— 误命中只让某人多看到一条待办,点审批会被 approve() 的
精确集合判定拦下,不构成越权;而收紧成 `12,`/`12}` 一旦遇到分隔符或空格
变体就会**漏命、待办静默消失**。审批待办场景:漏 > 多。
"""
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)
approver_conds = []
if approver_id is not None:
approver_conds.append(
ScrapApproval.allowed_approvers.like(f'%"value": {int(approver_id)}%')
)
if approver_role:
role_code = str(approver_role).strip().upper()
if role_code:
approver_conds.append(
ScrapApproval.allowed_approvers.like(f'%"value": "{role_code}"%')
)
if approver_conds:
query = query.filter(db.or_(*approver_conds))
# 公司隔离:只对带了公司的单生效,company_name 为空的旧单不受限
if company_name:
query = query.filter(ScrapApproval.company_name == company_name)
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 张,不会卡死历史数据。
#
# ★ 角色级审批(2026-09):名单里现在还可以是 {"type":"role","value":"SUPERVISOR"}。
# 放宽的只是「条目类型」,**空名单依然拒绝所有人** ——
# 这条不变量在 can_operator_approve 里,任何改动都不得把它改回 fail-open。
allowed = req.get_allowed_approvers() or []
user_entries, role_entries = ScrapApprovalService._approver_entries(allowed)
if not user_entries and not role_entries:
raise ValueError("该申请单未指定审批人或审批角色,无法审批,请联系管理员处理")
if not ScrapApprovalService.can_operator_approve(req, operator_id):
raise ValueError("只有被指定的审批人或审批角色可以审批该申请")
# ★ 角色级审批的必要配套:不隔离公司,LICA 主管就能审 IRIS 的单
ScrapApprovalService.assert_same_company(req, operator_id)
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