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() 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, }) # ★ 是否需审批:命中需审批物料 或 库管代建(force_approval) from app.services.approval_control import resolve_approval_control need_approval, flagged_materials = resolve_approval_control(normalized) if force_approval: need_approval = True if need_approval and not approver_id: if force_approval: raise ValueError("库管代建报废申请必须选择审批人后再提交") _names = ";".join(f"{m['name']}({m['spec_model'] or '-'})" for m in flagged_materials) raise ValueError(f"以下物料需审批报废:{_names}。请选择审批人后再提交") if approver_id: allowed_approvers = [{"type": "user", "value": int(approver_id)}] elif not need_approval: allowed_approvers = [] req = ScrapApproval( request_no=ScrapApprovalService.generate_request_no(), applicant_id=applicant_id, remark=remark, ) req.set_items(normalized) req.set_allowed_approvers(allowed_approvers) if need_approval: req.status = 0 # 待审批 else: # 默认不审批:直接进入“待执行” req.status = 1 req.actual_approver_id = applicant_id req.approved_at = _beijing() db.session.add(req) db.session.commit() logger.info(f"[ScrapApproval] 提交成功 {req.request_no} need_approval={need_approval}") 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 execute(request_id, operator_name='System'): models = _stock_models() req = db.session.get(ScrapApproval, request_id) if not req: raise ValueError("报废申请不存在") if req.status != 1: raise ValueError("仅“已通过(待执行)”的报废单可执行") items = req.get_items() if not items: raise ValueError("报废明细为空,无法执行") for it in items: model = models.get(it.get('source_table')) if not model: raise ValueError(f"不支持的报废来源:{it.get('source_table')}") row = model.query.with_for_update().get(int(it['stock_id'])) if not row: raise ValueError(f"库存记录已不存在({it.get('sku')})") qty = float(it.get('scrap_qty') or 0) avail = float(getattr(row, 'available_quantity', 0) or 0) if qty <= 0 or qty > avail: raise ValueError(f"库存 {it.get('sku') or ''} 可用不足(剩 {avail}),无法报废 {qty}") # ★ 真正扣减可用库存 row.available_quantity = avail - qty db.session.flush() # 写报废流水(台账) db.session.add(TransScrap( sku=it.get('sku') or getattr(row, 'sku', '') or '', source_table=it.get('source_table'), stock_id=int(it['stock_id']), quantity=qty, 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, )) 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}") return req