2026-09-10 09:46:57 +08:00
|
|
|
|
import logging
|
|
|
|
|
|
from datetime import datetime, timezone, timedelta
|
|
|
|
|
|
|
|
|
|
|
|
from sqlalchemy import func
|
|
|
|
|
|
|
2026-09-10 10:14:31 +08:00
|
|
|
|
from app.extensions import db, beijing_time
|
2026-09-10 09:46:57 +08:00
|
|
|
|
from app.models.scrap_approval import ScrapApproval
|
|
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _beijing():
|
2026-09-10 10:14:31 +08:00
|
|
|
|
"""
|
|
|
|
|
|
统一时间口径:naive 北京时间。
|
|
|
|
|
|
|
|
|
|
|
|
★ scrap_approval 的时间列已统一为 timestamp without time zone
|
|
|
|
|
|
(见 db_migrations/unify_approval_timezone.sql),必须返回 naive 值,
|
|
|
|
|
|
否则 aware 值会被驱动转成 UTC 存库,比 created_at 早 8 小时。
|
|
|
|
|
|
"""
|
|
|
|
|
|
return beijing_time()
|
2026-09-10 09:46:57 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 11:32:51 +08:00
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# ★ 业务规则(单一事实来源):报废一律需审批
|
|
|
|
|
|
#
|
|
|
|
|
|
# 与出库/借库不同,报废不过滤 is_approval_required —— 无论物料是否命中该标记,
|
|
|
|
|
|
# 所有报废申请都必须由指定审批人审批通过后才能执行。
|
|
|
|
|
|
# 前端 apply/index.vue 的「审批人」必填项与此规则保持一致。
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
SCRAP_ALWAYS_REQUIRES_APPROVAL = True
|
|
|
|
|
|
|
2026-09-16 17:14:17 +08:00
|
|
|
|
# 注:原先此处有 _stock_models() 硬编码三张库存表。来源差异已全部收敛到
|
|
|
|
|
|
# app/services/scrap_sources.py 的来源适配层(它复用
|
|
|
|
|
|
# inventory_reservation.stock_model_map(),避免第四份重复定义),
|
|
|
|
|
|
# 本模块不再直接持有库存表映射。
|
2026-09-10 09:46:57 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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("报废明细不能为空")
|
|
|
|
|
|
|
2026-09-16 17:14:17 +08:00
|
|
|
|
# ★ 来源适配:三类来源(库存行 / 在管不良品 / 借出未还)各有不同的
|
|
|
|
|
|
# 可报废上限、扣减行为与快照字段,差异全部收敛在 scrap_sources 里。
|
|
|
|
|
|
# 原先此处硬编码「只认三张库存表」,导致借出未还与在管不良品只能
|
|
|
|
|
|
# 各走直报接口绕过审批。详见 app/services/scrap_sources.py 模块头。
|
|
|
|
|
|
from app.services.scrap_sources import get_adapter
|
|
|
|
|
|
|
2026-09-10 09:46:57 +08:00
|
|
|
|
normalized = []
|
|
|
|
|
|
for idx, it in enumerate(items):
|
|
|
|
|
|
st = (it.get('source_table') or '').strip()
|
|
|
|
|
|
sid = it.get('stock_id')
|
2026-09-16 17:14:17 +08:00
|
|
|
|
adapter = get_adapter(st)
|
|
|
|
|
|
if not adapter or not sid:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"第 {idx + 1} 条报废明细必须指定 source_table 与 stock_id(精准实物)"
|
|
|
|
|
|
)
|
2026-09-10 09:46:57 +08:00
|
|
|
|
try:
|
|
|
|
|
|
sid = int(sid)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
raise ValueError(f"第 {idx + 1} 条 stock_id 无效")
|
2026-09-16 17:14:17 +08:00
|
|
|
|
row = adapter.load(sid)
|
2026-09-10 09:46:57 +08:00
|
|
|
|
if not row:
|
2026-09-16 17:14:17 +08:00
|
|
|
|
raise ValueError(f"第 {idx + 1} 条对应的{adapter.label}记录不存在")
|
2026-09-10 09:46:57 +08:00
|
|
|
|
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")
|
2026-09-16 17:14:17 +08:00
|
|
|
|
|
|
|
|
|
|
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(
|
|
|
|
|
|
"同一张报废申请单不能混合「需扫码」与「免扫码」两类来源,请分开提交"
|
|
|
|
|
|
)
|
2026-09-10 09:46:57 +08:00
|
|
|
|
|
2026-09-10 11:32:51 +08:00
|
|
|
|
# ★ 报废一律需审批(见 SCRAP_ALWAYS_REQUIRES_APPROVAL)。
|
|
|
|
|
|
# resolve_approval_control 仍调用,但仅用于生成「哪些物料命中需审批」的提示文案,
|
|
|
|
|
|
# 不再用它决定是否需要审批。
|
2026-09-10 09:46:57 +08:00
|
|
|
|
from app.services.approval_control import resolve_approval_control
|
2026-09-10 11:32:51 +08:00
|
|
|
|
_, 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)}]
|
2026-09-10 09:46:57 +08:00
|
|
|
|
|
|
|
|
|
|
req = ScrapApproval(
|
|
|
|
|
|
request_no=ScrapApprovalService.generate_request_no(),
|
|
|
|
|
|
applicant_id=applicant_id,
|
|
|
|
|
|
remark=remark,
|
|
|
|
|
|
)
|
|
|
|
|
|
req.set_items(normalized)
|
|
|
|
|
|
req.set_allowed_approvers(allowed_approvers)
|
|
|
|
|
|
|
2026-09-10 11:32:51 +08:00
|
|
|
|
# ★ 恒为「待审批」,不再走免审批自动通过分支
|
|
|
|
|
|
req.status = 0
|
2026-09-10 09:46:57 +08:00
|
|
|
|
|
|
|
|
|
|
db.session.add(req)
|
|
|
|
|
|
db.session.commit()
|
2026-09-10 11:32:51 +08:00
|
|
|
|
logger.info(f"[ScrapApproval] 提交成功 {req.request_no} approver={approver_id}")
|
2026-09-10 09:46:57 +08:00
|
|
|
|
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("当前状态不允许审批(仅待审批可操作)")
|
|
|
|
|
|
|
2026-09-16 17:14:17 +08:00
|
|
|
|
# 仅被指定的审批人可操作。
|
|
|
|
|
|
#
|
|
|
|
|
|
# ★ Fail-Closed:原实现是 `if user_entries and str(operator_id) not in ...`
|
|
|
|
|
|
# —— 当 allowed_approvers 为空(或条目里没有 type='user')时 user_entries
|
|
|
|
|
|
# 为空列表,条件短路为假,**任何登录用户都能审批**。这与本模块自陈的
|
|
|
|
|
|
# 「报废一律需审批」直接矛盾:留一扇「无审批人则人人可审」的门,
|
|
|
|
|
|
# 等于没有审批。现改为无名单即拒绝。
|
|
|
|
|
|
# 已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。
|
2026-09-10 09:46:57 +08:00
|
|
|
|
allowed = req.get_allowed_approvers() or []
|
|
|
|
|
|
user_entries = [str(a.get('value')) for a in allowed if a.get('type') == 'user']
|
2026-09-16 17:14:17 +08:00
|
|
|
|
if not user_entries:
|
|
|
|
|
|
raise ValueError("该申请单未指定审批人,无法审批,请联系管理员处理")
|
|
|
|
|
|
if str(operator_id) not in user_entries:
|
2026-09-10 09:46:57 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-09-10 15:08:32 +08:00
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# 撤回 / 作废(已通过但未执行 → 4-已撤回)
|
|
|
|
|
|
#
|
|
|
|
|
|
# 报废模块当前**尚未接入库存预占**(提交时不锁库存,扣减发生在执行阶段),
|
|
|
|
|
|
# 因此这里没有可释放的预占。仍显式调用 release_reserved():
|
|
|
|
|
|
# · 对存量单据无害(无 reserved 标记会被跳过);
|
|
|
|
|
|
# · 若将来报废接入预占,此处无需再改,自动生效。
|
|
|
|
|
|
# 状态守卫「仅 status==1 可撤回」同时充当执行守卫:
|
|
|
|
|
|
# 已执行(status=3)的单无法再次撤回。
|
|
|
|
|
|
# ------------------------------------------------------------------
|
2026-09-10 15:46:55 +08:00
|
|
|
|
WITHDRAWABLE_STATUS = (0, 1)
|
|
|
|
|
|
|
2026-09-10 15:08:32 +08:00
|
|
|
|
@staticmethod
|
2026-09-10 15:46:55 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-09-10 15:08:32 +08:00
|
|
|
|
req = db.session.get(ScrapApproval, request_id)
|
|
|
|
|
|
if not req:
|
|
|
|
|
|
raise ValueError("报废申请不存在")
|
2026-09-10 15:46:55 +08:00
|
|
|
|
|
|
|
|
|
|
# ★ 归属校验(仅申请人路径)
|
|
|
|
|
|
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:
|
2026-09-10 15:08:32 +08:00
|
|
|
|
status_map = {0: '待审批', 1: '已通过(待执行)', 2: '已驳回', 3: '已执行', 4: '已撤回'}
|
|
|
|
|
|
raise ValueError(
|
2026-09-10 15:46:55 +08:00
|
|
|
|
f"当前状态不可撤回:{status_map.get(req.status, req.status)}"
|
2026-09-10 15:08:32 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2026-09-10 09:46:57 +08:00
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# 执行(按单报废:扣减实物库存 + 写报废流水)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
@staticmethod
|
2026-09-10 10:14:40 +08:00
|
|
|
|
def _to_int(v):
|
|
|
|
|
|
try:
|
|
|
|
|
|
return int(v)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
2026-09-10 10:21:50 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _norm_sku(sku):
|
|
|
|
|
|
"""SKU 归一化:去首尾空白。空 SKU 返回 '',由调用方回退到行级匹配键。"""
|
|
|
|
|
|
return str(sku or '').strip()
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _match_key(source_table, stock_id, sku):
|
|
|
|
|
|
"""
|
2026-09-16 17:14:17 +08:00
|
|
|
|
★ 扫码匹配键 —— SKU 优先,**且必须带来源表**。
|
|
|
|
|
|
|
|
|
|
|
|
原实现返回 ('sku', s),其前提是「SKU 在三张库存表内唯一且跨表无重名」。
|
|
|
|
|
|
引入逆向物流来源后这个前提不再成立:在管不良品台账的 SKU 是从
|
|
|
|
|
|
原库存行**复制**的(退回时 sku=getattr(stock_row, 'sku', '')),
|
|
|
|
|
|
故 `trans_defective_goods#N` 与其源 `stock_buy#M` 的 SKU **必然相同**。
|
|
|
|
|
|
若同一张报废单同时含两者,不带来源就会串键 —— 扫码量会算到错误对象上。
|
2026-09-10 10:21:50 +08:00
|
|
|
|
|
2026-09-16 17:14:17 +08:00
|
|
|
|
加入 source_table 后,纯库存单两侧同源、键仍匹配(对既有流程零行为
|
|
|
|
|
|
变更),而跨来源的同 SKU 行彻底隔离。
|
2026-09-10 10:21:50 +08:00
|
|
|
|
|
2026-09-16 17:14:17 +08:00
|
|
|
|
空 SKU 的历史行仍回退为 row 复合键,两种键的首元素不同
|
|
|
|
|
|
('sku' / 'row'),不会互相串键。
|
2026-09-10 10:21:50 +08:00
|
|
|
|
"""
|
2026-09-16 17:14:17 +08:00
|
|
|
|
st = (source_table or '').strip()
|
2026-09-10 10:21:50 +08:00
|
|
|
|
s = ScrapApprovalService._norm_sku(sku)
|
|
|
|
|
|
if s:
|
2026-09-16 17:14:17 +08:00
|
|
|
|
return ('sku', st, s)
|
|
|
|
|
|
return ('row', f"{st}#{stock_id}")
|
2026-09-10 10:21:50 +08:00
|
|
|
|
|
2026-09-10 10:14:40 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _build_approved_index(items):
|
2026-09-10 10:21:50 +08:00
|
|
|
|
"""
|
|
|
|
|
|
申请单明细 → {匹配键: 批准信息}
|
|
|
|
|
|
|
|
|
|
|
|
每项保留其 source_table + stock_id 清单(rows),执行时据此定位到
|
|
|
|
|
|
批准时指定的那条库存记录做加锁扣减。同一 SKU 若在批准单中出现多行,
|
|
|
|
|
|
数量累加、rows 按批准顺序保留。
|
|
|
|
|
|
"""
|
2026-09-10 10:14:40 +08:00
|
|
|
|
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
|
2026-09-10 10:21:50 +08:00
|
|
|
|
key = ScrapApprovalService._match_key(st, sid, it.get('sku'))
|
|
|
|
|
|
entry = index.setdefault(key, {
|
2026-09-10 10:14:40 +08:00
|
|
|
|
'qty': 0.0,
|
2026-09-10 10:21:50 +08:00
|
|
|
|
'label': ScrapApprovalService._norm_sku(it.get('sku'))
|
|
|
|
|
|
or it.get('name') or f"{st}#{sid}",
|
|
|
|
|
|
'rows': [],
|
2026-09-10 10:14:40 +08:00
|
|
|
|
})
|
2026-09-10 10:21:50 +08:00
|
|
|
|
qty = float(it.get('scrap_qty') or 0)
|
|
|
|
|
|
entry['qty'] += qty
|
|
|
|
|
|
entry['rows'].append({'source_table': st, 'stock_id': sid, 'qty': qty})
|
2026-09-10 10:14:40 +08:00
|
|
|
|
return index
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
2026-09-16 17:14:17 +08:00
|
|
|
|
def _build_scanned_index(scanned_items):
|
2026-09-10 10:21:50 +08:00
|
|
|
|
"""前端实扫明细 → {匹配键: 累计扫码数量}"""
|
2026-09-16 17:14:17 +08:00
|
|
|
|
from app.services.scrap_sources import is_scan_source
|
|
|
|
|
|
|
2026-09-10 10:14:40 +08:00
|
|
|
|
index = {}
|
|
|
|
|
|
for idx, s in enumerate(scanned_items):
|
2026-09-10 10:21:50 +08:00
|
|
|
|
sku = ScrapApprovalService._norm_sku(s.get('sku'))
|
2026-09-10 10:14:40 +08:00
|
|
|
|
st = str(s.get('source_table') or '').strip()
|
|
|
|
|
|
sid = ScrapApprovalService._to_int(s.get('stock_id'))
|
2026-09-10 10:21:50 +08:00
|
|
|
|
if not sku and (not st or sid is None):
|
|
|
|
|
|
raise ValueError(f"第 {idx + 1} 条扫码明细缺少 SKU,且无有效的 source_table / stock_id")
|
2026-09-16 17:14:17 +08:00
|
|
|
|
# ★ 语义收窄而非放宽:扫码通道只接纳明确声明为 scan 的来源。
|
|
|
|
|
|
# 免扫码来源(借出未还)与未知来源一律拒绝 —— 后者能挡住
|
|
|
|
|
|
# 「扫码扫得到、执行却拒绝」的历史错配(trans_repair 曾如此)。
|
|
|
|
|
|
if st and not is_scan_source(st):
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"第 {idx + 1} 条扫码来源不支持扫码提交:{st}"
|
|
|
|
|
|
f"(该来源为免扫码执行来源,或来源非法)"
|
|
|
|
|
|
)
|
2026-09-10 10:14:40 +08:00
|
|
|
|
qty = float(s.get('quantity') or 0)
|
|
|
|
|
|
if qty <= 0:
|
|
|
|
|
|
raise ValueError(f"第 {idx + 1} 条扫码数量必须大于 0")
|
2026-09-10 10:21:50 +08:00
|
|
|
|
key = ScrapApprovalService._match_key(st, sid, sku)
|
|
|
|
|
|
entry = index.setdefault(key, {
|
2026-09-10 10:14:40 +08:00
|
|
|
|
'qty': 0.0,
|
2026-09-10 10:21:50 +08:00
|
|
|
|
'label': sku or s.get('name') or f"{st}#{sid}",
|
2026-09-10 10:14:40 +08:00
|
|
|
|
})
|
|
|
|
|
|
entry['qty'] += qty
|
|
|
|
|
|
return index
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def execute(request_id, operator_name='System', scanned_items=None):
|
|
|
|
|
|
"""
|
2026-09-16 17:14:17 +08:00
|
|
|
|
按单执行报废:按来源的执行模式分流处理。
|
2026-09-10 10:14:40 +08:00
|
|
|
|
|
2026-09-10 10:21:50 +08:00
|
|
|
|
scanned_items: [{'sku', 'quantity', 'source_table', 'stock_id'(可选,空 SKU 时必填)}]
|
2026-09-16 17:14:17 +08:00
|
|
|
|
仅承载「实扫到的实物」。免扫码来源(借出未还)**不经过**这里,
|
|
|
|
|
|
由后端按批准量直接执行 —— 调用契约与改造前完全一致。
|
|
|
|
|
|
|
|
|
|
|
|
scan 来源(库存行 / 在管不良品):
|
|
|
|
|
|
· 以 SKU + 来源表为主校验键(来源表用于隔离同 SKU 的跨来源行);
|
|
|
|
|
|
· 同一键多次扫码累加,累计不得超过批准总量;
|
|
|
|
|
|
· 允许合法子集(少扫 = 本次不报废该行)。
|
|
|
|
|
|
|
|
|
|
|
|
auto 来源(借出未还):
|
|
|
|
|
|
· 按 items_json 中的 scrap_qty 全量执行,不参与扫码匹配。
|
2026-09-10 10:14:40 +08:00
|
|
|
|
"""
|
2026-09-16 17:14:17 +08:00
|
|
|
|
from app.services.scrap_sources import (
|
|
|
|
|
|
get_adapter, SCRAP_MODE_AUTO, SCRAP_MODE_SCAN,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-09-10 09:46:57 +08:00
|
|
|
|
req = db.session.get(ScrapApproval, request_id)
|
|
|
|
|
|
if not req:
|
|
|
|
|
|
raise ValueError("报废申请不存在")
|
|
|
|
|
|
if req.status != 1:
|
|
|
|
|
|
raise ValueError("仅“已通过(待执行)”的报废单可执行")
|
|
|
|
|
|
|
2026-09-10 10:14:40 +08:00
|
|
|
|
approved_items = req.get_items()
|
|
|
|
|
|
if not approved_items:
|
2026-09-10 09:46:57 +08:00
|
|
|
|
raise ValueError("报废明细为空,无法执行")
|
|
|
|
|
|
|
2026-09-16 17:14:17 +08:00
|
|
|
|
# ★ 按执行模式分流。存量单据(全部是库存来源)没有 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:
|
2026-09-10 10:14:40 +08:00
|
|
|
|
raise ValueError("请先扫码并提交实际报废物料,再执行报废")
|
|
|
|
|
|
|
2026-09-16 17:14:17 +08:00
|
|
|
|
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
|
2026-09-10 10:14:40 +08:00
|
|
|
|
|
2026-09-16 17:14:17 +08:00
|
|
|
|
# ★ 校验一:扫码物料必须在批准明细内
|
2026-09-10 10:14:40 +08:00
|
|
|
|
for key, acc in scanned.items():
|
2026-09-10 10:21:50 +08:00
|
|
|
|
if key not in approved:
|
2026-09-10 10:14:40 +08:00
|
|
|
|
raise ValueError(
|
2026-09-16 17:14:17 +08:00
|
|
|
|
f"物料【{acc['label']}】不在该报废申请单的批准明细中(SKU 不匹配),禁止报废"
|
2026-09-10 10:14:40 +08:00
|
|
|
|
)
|
2026-09-10 10:21:50 +08:00
|
|
|
|
|
2026-09-16 17:14:17 +08:00
|
|
|
|
# ★ 校验二:同一物料的累计扫码量不得超过批准总量
|
2026-09-10 10:21:50 +08:00
|
|
|
|
for key, acc in scanned.items():
|
|
|
|
|
|
appr = approved[key]
|
2026-09-10 10:14:40 +08:00
|
|
|
|
if acc['qty'] > appr['qty']:
|
|
|
|
|
|
raise ValueError(
|
2026-09-16 17:14:17 +08:00
|
|
|
|
f"物料【{acc['label']}】扫码数量({acc['qty']})超出批准数量({appr['qty']}),禁止报废"
|
2026-09-10 10:14:40 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-09-16 17:14:17 +08:00
|
|
|
|
# ★ scan 项扣减:按批准单配对的 source_table + stock_id 定位实物,逐行加锁扣减。
|
|
|
|
|
|
# 扣减行为由来源适配器自持(库存行双扣 / 在管不良品只动台账)。
|
2026-09-10 10:21:50 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-09-16 17:14:17 +08:00
|
|
|
|
_adapter_for(ref['source_table']).deduct(
|
|
|
|
|
|
ref['stock_id'], take, req, operator_name,
|
|
|
|
|
|
)
|
2026-09-10 10:21:50 +08:00
|
|
|
|
remaining -= take
|
2026-09-10 09:46:57 +08:00
|
|
|
|
|
2026-09-16 17:14:17 +08:00
|
|
|
|
# ★ 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,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-09-10 09:46:57 +08:00
|
|
|
|
req.status = 3
|
|
|
|
|
|
req.executed_at = _beijing()
|
|
|
|
|
|
req.executor_name = operator_name
|
|
|
|
|
|
db.session.commit()
|
2026-09-10 10:14:40 +08:00
|
|
|
|
logger.info(
|
|
|
|
|
|
f"[ScrapApproval] {req.request_no} 执行报废完成 by {operator_name} "
|
|
|
|
|
|
f"(批准 {len(approved)} 项 / 实扫 {len(scanned)} 项)"
|
|
|
|
|
|
)
|
2026-09-10 09:46:57 +08:00
|
|
|
|
return req
|