diff --git a/inventory-backend/app/api/v1/inbound/stock.py b/inventory-backend/app/api/v1/inbound/stock.py index 89a81ab..4d36530 100644 --- a/inventory-backend/app/api/v1/inbound/stock.py +++ b/inventory-backend/app/api/v1/inbound/stock.py @@ -315,6 +315,7 @@ def _do_get_stock_list(permission_prefix=None): rows = q.all() for item in rows: d = item.to_dict() + d['source_table'] = 'stock_buy' # ★ 报废等按单流程需要精准定位来源表 d['stock_type'] = 'material' d['type'] = 'material' d['typeLabel'] = '采购件' @@ -341,6 +342,7 @@ def _do_get_stock_list(permission_prefix=None): rows = q.all() for item in rows: d = item.to_dict() + d['source_table'] = 'stock_semi' # ★ 精准来源表 d['stock_type'] = 'semi' d['type'] = 'semi' d['typeLabel'] = '半成品' @@ -370,6 +372,7 @@ def _do_get_stock_list(permission_prefix=None): rows = q.all() for item in rows: d = item.to_dict() + d['source_table'] = 'stock_product' # ★ 精准来源表 d['stock_type'] = 'product' d['type'] = 'product' d['typeLabel'] = '成品' diff --git a/inventory-backend/app/api/v1/scrap.py b/inventory-backend/app/api/v1/scrap.py index 6d8ba3f..f3e6f36 100644 --- a/inventory-backend/app/api/v1/scrap.py +++ b/inventory-backend/app/api/v1/scrap.py @@ -466,3 +466,159 @@ class ScrapService: 'page': page, 'pageSize': page_size } + + +# ============================================================================== +# 报废审批流(申请 → 审批 → 按单执行)—— 镜像 借库/出库 审批框架 +# 与旧 /scrap(直接报废)并存,旧入口不动 +# ============================================================================== + +def _current_user_role(): + try: + claims = get_jwt() + return (claims.get('role') or '').upper() + except Exception: + return '' + + +@scrap_bp.route('/request/check-approval', methods=['POST']) +@jwt_required() +@permission_required('scrap_apply') +def scrap_check_approval(): + """提交前预检:判断所选库存(物料)是否命中“需审批”""" + try: + from app.services.approval_control import resolve_approval_control + data = request.get_json() or {} + items = data.get('items', []) or [] + need, flagged = resolve_approval_control(items) + return jsonify({'code': 200, 'msg': 'success', + 'data': {'need_approval': need, 'materials': flagged}}), 200 + except Exception as e: + traceback.print_exc() + return jsonify({'code': 500, 'msg': f'预检失败: {str(e)}'}), 500 + + +@scrap_bp.route('/request', methods=['POST']) +@jwt_required() +@permission_required('scrap_apply') +def create_scrap_request(): + """提交报废申请(不扣库存;扣减在库管执行时)""" + try: + from app.services.scrap_approval_service import ScrapApprovalService + identity = get_jwt_identity() + if not identity: + return jsonify({'code': 401, 'msg': '用户未登录'}), 401 + data = request.get_json() or {} + req = ScrapApprovalService.submit_approval( + applicant_id=int(identity), + items=data.get('items', []), + allowed_approvers=data.get('allowed_approvers'), + remark=data.get('remark'), + approver_id=data.get('approver_id'), + force_approval=(_current_user_role() == 'WAREHOUSE_MGR'), + ) + return jsonify({'code': 200, 'msg': '报废申请已提交', 'data': req.to_dict()}), 200 + except ValueError as e: + return jsonify({'code': 400, 'msg': str(e)}), 400 + except Exception as e: + traceback.print_exc() + return jsonify({'code': 500, 'msg': f'提交报废申请失败: {str(e)}'}), 500 + + +@scrap_bp.route('/request', methods=['GET']) +@jwt_required() +@permission_required('scrap_approval') +def list_scrap_requests(): + """ + 报废申请列表。 + scope: mine(我提交的,默认) / pending(待我审批) / executable(待执行) / all(管理者) + Query: page, limit, status(可选覆盖) + """ + try: + from app.services.scrap_approval_service import ScrapApprovalService + from app.utils.decorators import is_privileged_viewer + identity = int(get_jwt_identity()) + + scope = (request.args.get('scope') or 'mine').lower() + page = int(request.args.get('page', 1)) + limit = int(request.args.get('limit', 10)) + status = request.args.get('status') + status = int(status) if status not in (None, '', 'all') else None + priv = is_privileged_viewer() + + kwargs = {'page': page, 'limit': limit, 'status': status} + if scope == 'pending': + kwargs['status'] = 0 + kwargs['approver_id'] = identity + elif scope == 'executable': + kwargs['status'] = 1 + elif scope == 'all': + if not priv: + kwargs['applicant_id'] = identity + else: # mine + kwargs['applicant_id'] = identity + + data = ScrapApprovalService.get_list(**kwargs) + return jsonify({'code': 200, 'msg': '获取成功', 'data': data}), 200 + except Exception as e: + traceback.print_exc() + return jsonify({'code': 500, 'msg': f'获取报废申请列表失败: {str(e)}'}), 500 + + +@scrap_bp.route('/request/', methods=['GET']) +@jwt_required() +@permission_required('scrap_approval') +def get_scrap_request_detail(request_id): + try: + from app.models.scrap_approval import ScrapApproval + from app.utils.decorators import is_privileged_viewer + req = db.session.get(ScrapApproval, request_id) + if not req: + return jsonify({'code': 404, 'msg': '报废申请不存在'}), 404 + if not is_privileged_viewer() and int(req.applicant_id) != int(get_jwt_identity()): + # 被指定审批人也可查看 + allowed = req.get_allowed_approvers() or [] + if str(get_jwt_identity()) not in [str(a.get('value')) for a in allowed if a.get('type') == 'user']: + return jsonify({'code': 403, 'msg': '无权查看该报废申请'}), 403 + return jsonify({'code': 200, 'msg': '获取成功', 'data': req.to_dict()}), 200 + except Exception as e: + traceback.print_exc() + return jsonify({'code': 500, 'msg': f'获取报废申请详情失败: {str(e)}'}), 500 + + +@scrap_bp.route('/request//approve', methods=['PATCH']) +@jwt_required() +@permission_required('scrap_approval') +def approve_scrap_request(request_id): + """审批报废申请(仅被指定审批人)""" + try: + from app.services.scrap_approval_service import ScrapApprovalService + data = request.get_json() or {} + req = ScrapApprovalService.approve( + request_id, int(get_jwt_identity()), + data.get('action'), data.get('reject_reason', '') + ) + return jsonify({'code': 200, 'msg': '操作成功', 'data': req.to_dict()}), 200 + except ValueError as e: + return jsonify({'code': 400, 'msg': str(e)}), 400 + except Exception as e: + traceback.print_exc() + return jsonify({'code': 500, 'msg': f'审批报废申请失败: {str(e)}'}), 500 + + +@scrap_bp.route('/request//execute', methods=['POST']) +@jwt_required() +@permission_required('scrap_execute') +def execute_scrap_request(request_id): + """按单报废:有 scrap_execute 权限者执行,扣减实物库存并写报废流水""" + try: + from app.services.scrap_approval_service import ScrapApprovalService + identity = int(get_jwt_identity()) + u = SysUser.query.get(identity) + req = ScrapApprovalService.execute(request_id, operator_name=(u.username if u else str(identity))) + return jsonify({'code': 200, 'msg': '已执行报废并扣减库存', 'data': req.to_dict()}), 200 + except ValueError as e: + return jsonify({'code': 400, 'msg': str(e)}), 400 + except Exception as e: + traceback.print_exc() + return jsonify({'code': 500, 'msg': f'执行报废失败: {str(e)}'}), 500 diff --git a/inventory-backend/app/models/scrap_approval.py b/inventory-backend/app/models/scrap_approval.py new file mode 100644 index 0000000..e834bca --- /dev/null +++ b/inventory-backend/app/models/scrap_approval.py @@ -0,0 +1,90 @@ +from app.extensions import db +import json + + +def _beijing_now(): + from datetime import datetime, timezone, timedelta + return datetime.now(timezone(timedelta(hours=8))) + + +class ScrapApproval(db.Model): + """ + 报废申请/审批单(镜像 BorrowApproval / OutboundApproval) + + 与出/借库的关键差异:items_json 里每条明细必须携带 + source_table + stock_id(精准定位到某条实物库存记录), + 因为报废从“申请那一刻”就要求指明具体要报废的东西。 + """ + __tablename__ = 'scrap_approval' + + id = db.Column(db.Integer, primary_key=True) + request_no = db.Column(db.String(100), unique=True, nullable=False, index=True) + + applicant_id = db.Column(db.Integer, nullable=False, index=True) + remark = db.Column(db.Text) # 报废原因 + + # 状态: 0-待审批, 1-已通过(待执行), 2-已驳回, 3-已执行(已报废) + status = db.Column(db.Integer, default=0, nullable=False, index=True) + + allowed_approvers = db.Column(db.Text) # JSON: [{"type":"user","value":123}, ...] + actual_approver_id = db.Column(db.Integer, index=True) + approved_at = db.Column(db.DateTime) + reject_reason = db.Column(db.Text) + + # 明细快照: [{"source_table","stock_id","sku","name","spec_model","location","batch_number","scrap_qty"}] + items_json = db.Column(db.Text, nullable=False) + + # 执行信息 + executed_at = db.Column(db.DateTime) + executor_name = db.Column(db.String(100)) + + created_at = db.Column(db.DateTime, default=_beijing_now, nullable=False) + updated_at = db.Column(db.DateTime, default=_beijing_now, onupdate=_beijing_now, nullable=False) + + def set_items(self, items): + self.items_json = json.dumps(items or [], ensure_ascii=False) + + def get_items(self): + try: + return json.loads(self.items_json) if self.items_json else [] + except Exception: + return [] + + def set_allowed_approvers(self, approvers): + self.allowed_approvers = json.dumps(approvers or [], ensure_ascii=False) + + def get_allowed_approvers(self): + try: + return json.loads(self.allowed_approvers) if self.allowed_approvers else [] + except Exception: + return [] + + @staticmethod + def _user_name(user_id): + if not user_id: + return '' + try: + from app.models.system import SysUser + u = SysUser.query.get(user_id) + return u.username if u else '' + except Exception: + return '' + + def to_dict(self): + return { + 'id': self.id, + 'request_no': self.request_no, + 'applicant_id': self.applicant_id, + 'applicant_name': self._user_name(self.applicant_id), + 'remark': self.remark or '', + 'status': self.status, + 'allowed_approvers': self.get_allowed_approvers(), + 'actual_approver_id': self.actual_approver_id, + 'actual_approver_name': self._user_name(self.actual_approver_id), + 'approved_at': self.approved_at.strftime('%Y-%m-%d %H:%M:%S') if self.approved_at else None, + 'reject_reason': self.reject_reason or '', + 'items': self.get_items(), + 'executed_at': self.executed_at.strftime('%Y-%m-%d %H:%M:%S') if self.executed_at else None, + 'executor_name': self.executor_name or '', + 'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None, + } diff --git a/inventory-backend/app/services/scrap_approval_service.py b/inventory-backend/app/services/scrap_approval_service.py new file mode 100644 index 0000000..23b69c0 --- /dev/null +++ b/inventory-backend/app/services/scrap_approval_service.py @@ -0,0 +1,237 @@ +import logging +from datetime import datetime, timezone, timedelta + +from sqlalchemy import func + +from app.extensions import db +from app.models.scrap_approval import ScrapApproval +from app.models.transaction import TransScrap + +logger = logging.getLogger(__name__) + + +def _beijing(): + return datetime.now(timezone(timedelta(hours=8))) + + +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