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(存量空分类回填为库存报废)。
This commit is contained in:
yueli
2026-09-23 15:17:44 +08:00
parent 822f8976a9
commit bfd0db791c
6 changed files with 648 additions and 21 deletions

View File

@ -29,6 +29,22 @@ def _beijing():
# =============================================================================
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(),避免第四份重复定义),
@ -37,6 +53,139 @@ SCRAP_ALWAYS_REQUIRES_APPROVAL = True
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()
@ -50,15 +199,39 @@ class ScrapApprovalService:
# ------------------------------------------------------------------
@staticmethod
def submit_approval(applicant_id, items, allowed_approvers=None, remark=None,
approver_id=None, force_approval=False):
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 里。
# 原先此处硬编码「只认三张库存表」,导致借出未还与在管不良品只能
@ -114,42 +287,96 @@ class ScrapApprovalService:
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)}]
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(allowed_approvers)
req.set_allowed_approvers(final_approvers)
# ★ 恒为「待审批」,不再走免审批自动通过分支
req.status = 0
db.session.add(req)
db.session.commit()
logger.info(f"[ScrapApproval] 提交成功 {req.request_no} approver={approver_id}")
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):
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:
query = query.filter(ScrapApproval.allowed_approvers.like(f'%"value": {approver_id}%'))
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 {
@ -170,7 +397,7 @@ class ScrapApprovalService:
if req.status != 0:
raise ValueError("当前状态不允许审批(仅待审批可操作)")
# 仅被指定的审批人可操作。
# 仅被指定的审批人(或审批角色)可操作。
#
# ★ Fail-Closed:原实现是 `if user_entries and str(operator_id) not in ...`
# —— 当 allowed_approvers 为空(或条目里没有 type='user')时 user_entries
@ -178,12 +405,18 @@ class ScrapApprovalService:
# 「报废一律需审批」直接矛盾:留一扇「无审批人则人人可审」的门,
# 等于没有审批。现改为无名单即拒绝。
# 已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。
#
# ★ 角色级审批(2026-09):名单里现在还可以是 {"type":"role","value":"SUPERVISOR"}。
# 放宽的只是「条目类型」,**空名单依然拒绝所有人** ——
# 这条不变量在 can_operator_approve 里,任何改动都不得把它改回 fail-open。
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("只有被指定的审批人可以审批该申请")
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

View File

@ -115,9 +115,17 @@ class ScrapSourceAdapter:
# --- 台账公共字段 ---
@staticmethod
def _ledger_kwargs(req, operator_name):
"""写 TransScrap 的公共字段 —— **三种来源适配器全部经过这里**。
★ 这是报废原因分类落到台账的**唯一出口**,分类只在这里带一次,
三个 adapter(库存行 / 在管不良品 / 借出未还)就都通了。
「统计生产报废金额」按 trans_scrap.reason_category 分组,不要靠
reason 自由文本去匹配。
"""
from app.models.scrap_approval import ScrapApproval
return {
'reason': req.remark or '',
'reason_category': getattr(req, 'reason_category', None),
'operator_name': operator_name,
'approver_name': ScrapApproval._user_name(req.actual_approver_id),
'approval_status': 'executed',
@ -238,9 +246,39 @@ class DefectiveScrapAdapter(ScrapSourceAdapter):
def cap(self, row):
return float(getattr(row, 'remaining_qty', 0) or 0)
@staticmethod
def _resolve_location(goods):
"""
取「原库位 / 批次(序列号)」:台账快照优先,回查源库存行兜底。
★ 口径 = **原库位**(这批货最初在哪),与三张库存表来源一致。
★ 原实现此处硬编码空串(注释「台账无库位字段」),导致申请单/审批单
明细里凡是不良品来源的行,库位与批次恒显示 "-"。但台账自带
source_table + stock_id 溯源指针,源行还在时本来取得到。
★ 两级都落空(源行已被入库模块物理删除,且台账也无快照)时返回空串
—— 不中断报废:实物已销毁,台账必须先记上,位置缺失是可接受的
降级,记录丢失不是。
★ 取值口径与 scrap.py 的 _from_stock 一致:成品表无 batch_number 列,
回退到 serial_number。
"""
loc = getattr(goods, 'warehouse_location', '') or ''
batch = getattr(goods, 'batch_number', '') or ''
if loc and batch:
return loc, batch
model = _stock_model_map().get(getattr(goods, 'source_table', ''))
if model is not None and getattr(goods, 'stock_id', None):
row = model.query.get(goods.stock_id)
if row:
loc = loc or getattr(row, 'warehouse_location', '') or ''
batch = batch or (getattr(row, 'batch_number', '')
or getattr(row, 'serial_number', '') or '')
return loc, batch
def snapshot(self, row, qty, raw):
# 物料名/规格取自台账自身的冗余快照,**不联表 MaterialBase** ——
# 原库存行可能已被物理删除,联表会取到空值。
location, batch_number = self._resolve_location(row)
return {
'source_table': self.source_table,
'stock_id': row.id,
@ -248,8 +286,8 @@ class DefectiveScrapAdapter(ScrapSourceAdapter):
'sku': getattr(row, 'sku', '') or '',
'name': getattr(row, 'material_name', '') or raw.get('name') or '',
'spec_model': getattr(row, 'spec_model', '') or raw.get('spec_model') or '',
'location': '', # 台账无库位字段
'batch_number': '',
'location': location,
'batch_number': batch_number,
'scrap_qty': qty,
'available_at_apply': self.cap(row),
'scrap_mode': self.scrap_mode,