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