from flask import Blueprint, jsonify, request from app.extensions import db # ★★★ 修复点:必须引入 datetime,否则下方更新时间时会报错 500 ★★★ from datetime import datetime from app.utils.decorators import permission_required import uuid as uuid_module # 导入模型 from app.models.inbound.buy import StockBuy from app.models.inbound.stocktake import StocktakeDraft from app.models.transaction import TransBorrow def _normalize_user_id(user_id): """规范化 user_id,确保是有效字符串""" if not user_id or not isinstance(user_id, str) or len(user_id) > 100: return 'admin' return user_id.strip() # 尝试导入半成品和成品 try: from app.models.inbound.semi import StockSemi except ImportError: StockSemi = None try: from app.models.inbound.product import StockProduct except ImportError: StockProduct = None from app.services.print.network_print_service import NetworkPrintService bp = Blueprint('stock_ops', __name__) # ============================================================ # 辅助函数:获取库存记录 # ============================================================ def get_stock_record(source_table, stock_id): """根据库存类型和ID获取库存记录""" if source_table == 'stock_buy' and StockBuy: return StockBuy.query.get(stock_id) elif source_table == 'stock_semi' and StockSemi: return StockSemi.query.get(stock_id) elif source_table == 'stock_product' and StockProduct: return StockProduct.query.get(stock_id) return None def get_stock_info(uuid_or_barcode): """ 根据 uuid 或 barcode 查询库存信息 返回: (item, source_table, stock_id) """ # 1. 成品 if StockProduct: item = StockProduct.query.filter( db.or_(StockProduct.barcode == uuid_or_barcode, StockProduct.sku == uuid_or_barcode) ).first() if item: return (item, 'stock_product', item.id) # 2. 半成品 if StockSemi: item = StockSemi.query.filter( db.or_(StockSemi.barcode == uuid_or_barcode, StockSemi.sku == uuid_or_barcode) ).first() if item: return (item, 'stock_semi', item.id) # 3. 采购件 if StockBuy: item = StockBuy.query.filter( db.or_(StockBuy.barcode == uuid_or_barcode, StockBuy.sku == uuid_or_barcode) ).first() if item: return (item, 'stock_buy', item.id) return (None, None, None) @bp.route('/all', methods=['GET']) @permission_required('inventory_stocktake') def get_all_stock(): """ 获取所有库存 > 0 的物品 """ try: # 1. 采购件 materials = [] if StockBuy: materials = StockBuy.query.filter(StockBuy.stock_quantity > 0).all() # 2. 半成品 semis = [] if StockSemi: try: semis = StockSemi.query.filter(StockSemi.stock_quantity > 0).all() except Exception: semis = [] # 3. 成品 products = [] if StockProduct: try: products = StockProduct.query.filter(StockProduct.stock_quantity > 0).all() except Exception: products = [] return jsonify({ "materials": [item.to_dict() for item in materials], "semis": [item.to_dict() for item in semis], "products": [item.to_dict() for item in products] }), 200 except Exception as e: print(f"Error: {e}") return jsonify({"message": f"查询库存失败: {str(e)}"}), 500 # --- 草稿箱接口 --- @bp.route('/draft/list', methods=['GET']) @permission_required('inventory_stocktake') def get_drafts(): """ 获取当前用户的盘点进度 支持过滤: session_id, is_finished, is_processed """ # user_id 现在由 Token 解析,忽略前端传来的参数以避免类型错误 session_id = request.args.get('session_id') is_finished_str = request.args.get('is_finished') is_processed_str = request.args.get('is_processed') query = StocktakeDraft.query if session_id: query = query.filter_by(session_id=session_id) # ★ 修复:必须将字符串转换为 Python 布尔值 if is_finished_str is not None: is_finished_bool = is_finished_str.lower() in ('true', '1', 'yes') query = query.filter_by(is_finished=is_finished_bool) if is_processed_str is not None: is_processed_bool = is_processed_str.lower() in ('true', '1', 'yes') query = query.filter_by(is_processed=is_processed_bool) drafts = query.order_by(StocktakeDraft.scan_time.desc()).all() return jsonify([d.to_dict() for d in drafts]), 200 @bp.route('/draft/add', methods=['POST']) @permission_required('inventory_stocktake:operation') def add_draft(): """ 扫码同步 (支持更新数量) 如果 session_id 不存在则创建新的会话 """ try: data = request.json user_id = _normalize_user_id(data.get('user_id', 'admin')) uuid = data.get('uuid') quantity = float(data.get('quantity', 1)) session_id = data.get('session_id') if not uuid: return jsonify({"message": "UUID不能为空"}), 400 # 如果没有 session_id,创建新的 if not session_id: session_id = f"STK-{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid_module.uuid4().hex[:6]}" # 获取库存信息 item, source_table, stock_id = get_stock_info(uuid) if not item: return jsonify({"message": "未找到对应的库存记录"}), 404 stock_qty = float(item.stock_quantity) if item.stock_quantity else 0 # 查找是否已存在 draft = StocktakeDraft.query.filter_by(user_id=user_id, uuid=uuid, session_id=session_id).first() if draft: # 如果已存在,更新数量和时间 draft.quantity = quantity draft.scan_time = datetime.now() draft.stock_qty = stock_qty draft.diff_qty = quantity - stock_qty draft.source_table = source_table draft.stock_id = stock_id else: # 如果不存在,创建新的 draft = StocktakeDraft( user_id=user_id, uuid=uuid, quantity=quantity, session_id=session_id, stock_qty=stock_qty, diff_qty=quantity - stock_qty, source_table=source_table, stock_id=stock_id, is_finished=False, is_processed=False ) db.session.add(draft) db.session.commit() return jsonify({ "message": "Saved", "session_id": session_id, "draft_id": draft.id }), 200 except Exception as e: print(f"Add Draft Error: {e}") db.session.rollback() return jsonify({"message": str(e)}), 500 @bp.route('/draft/clear', methods=['POST']) @permission_required('inventory_stocktake:operation') def clear_draft(): """ 清除盘点草稿 支持清除指定 session_id 的记录,或清除所有未完成的记录 """ data = request.json user_id = _normalize_user_id(data.get('user_id', 'admin')) session_id = data.get('session_id') try: query = StocktakeDraft.query.filter_by(user_id=user_id) if session_id: # 清除指定会话 query = query.filter_by(session_id=session_id) else: # 默认只清除未完成的记录 query = query.filter_by(is_finished=False) count = query.delete() db.session.commit() return jsonify({"message": f"已清除 {count} 条记录", "count": count}), 200 except Exception as e: db.session.rollback() return jsonify({"message": str(e)}), 500 @bp.route('/draft/start-new', methods=['POST']) @permission_required('inventory_stocktake:operation') def start_new_session(): """ 开始新一轮盘点 1. 先清除用户所有未处理的旧盘点数据 2. 返回新的 session_id """ data = request.json user_id = _normalize_user_id(data.get('user_id', 'admin')) try: # 清除旧的未处理盘点数据 old_count = StocktakeDraft.query.filter_by( user_id=user_id, is_processed=False ).delete() db.session.commit() # 生成新的 session_id new_session_id = f"STK-{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid_module.uuid4().hex[:6]}" return jsonify({ "message": f"已清除 {old_count} 条旧记录", "session_id": new_session_id, "cleared_count": old_count }), 200 except Exception as e: db.session.rollback() return jsonify({"message": str(e)}), 500 # --- 盘点结束与差异报告 --- @bp.route('/finish', methods=['POST']) @permission_required('inventory_stocktake:operation') def finish_stocktake(): """ 结束盘点 1. 将指定 session 的草稿标记为 is_finished=True 2. 计算差异数量 3. 不删除任何草稿数据,保留历史 """ data = request.json user_id = _normalize_user_id(data.get('user_id', 'admin')) session_id = data.get('session_id') if not session_id: return jsonify({"message": "session_id 不能为空"}), 400 try: # 查找该 session 下所有未结束的草稿 drafts = StocktakeDraft.query.filter_by( user_id=user_id, session_id=session_id, is_finished=False ).all() if not drafts: return jsonify({"message": "没有找到未结束的盘点记录"}), 404 # 更新每个草稿的状态 now = datetime.now() for draft in drafts: draft.is_finished = True draft.finish_time = now # 重新计算差异(以防库存已变化) if draft.stock_id and draft.source_table: stock = get_stock_record(draft.source_table, draft.stock_id) if stock: current_stock = float(stock.stock_quantity) if stock.stock_quantity else 0 draft.stock_qty = current_stock draft.diff_qty = float(draft.quantity) - current_stock db.session.commit() return jsonify({ "message": f"已结束盘点,共处理 {len(drafts)} 条记录", "finished_count": len(drafts), "session_id": session_id }), 200 except Exception as e: db.session.rollback() return jsonify({"message": str(e)}), 500 @bp.route('/variance-report', methods=['GET']) @permission_required('inventory_stocktake') def get_variance_report(): """ 获取盘点差异报告 返回所有 is_finished=True 且 is_processed=False 的记录 即:已结束盘点但尚未手动平账的差异记录 """ user_id = _normalize_user_id(request.args.get('user_id', 'admin')) session_id = request.args.get('session_id') try: query = StocktakeDraft.query.filter_by( user_id=user_id, is_finished=True, is_processed=False ) if session_id: query = query.filter_by(session_id=session_id) # 只返回有差异的记录 drafts = query.filter(StocktakeDraft.diff_qty != 0).order_by( StocktakeDraft.finish_time.desc(), StocktakeDraft.diff_qty.desc() ).all() # 补充库存详情 result = [] for draft in drafts: draft_dict = draft.to_dict() # 获取库存详情 if draft.stock_id and draft.source_table: stock = get_stock_record(draft.source_table, draft.stock_id) if stock: draft_dict['stock_name'] = getattr(stock, 'material_name', None) or \ getattr(stock, 'product_name', None) or '' draft_dict['stock_spec'] = getattr(stock, 'spec_model', '') or \ getattr(stock, 'standard', '') or '' draft_dict['stock_location'] = getattr(stock, 'warehouse_location', '') or '' draft_dict['stock_unit'] = getattr(stock, 'unit', '个') result.append(draft_dict) return jsonify({ "list": result, "total": len(result) }), 200 except Exception as e: print(f"Error: {e}") return jsonify({"message": str(e)}), 500 # --- 单条库存调整 (手动平账) --- @bp.route('/adjust', methods=['POST']) @permission_required('inventory_stocktake:operation') def adjust_stock(): """ 单条库存调整接口 接收指定的草稿 ID,执行以下操作: 1. 根据 diff_qty 调整库存 (stock_quantity 和 available_quantity) 2. 生成流水账记录 (盘盈入库 / 盘亏出库) 3. 标记草稿为 is_processed=True """ data = request.json draft_id = data.get('draft_id') operator_name = data.get('operator_name', 'System') remark = data.get('remark', '') if not draft_id: return jsonify({"message": "draft_id 不能为空"}), 400 try: # 1. 获取草稿记录 draft = StocktakeDraft.query.get(draft_id) if not draft: return jsonify({"message": "草稿记录不存在"}), 404 if not draft.is_finished: return jsonify({"message": "该记录尚未结束盘点,无法调整"}), 400 if draft.is_processed: return jsonify({"message": "该记录已处理过平账"}), 400 # 2. 获取库存记录 if not draft.stock_id or not draft.source_table: return jsonify({"message": "草稿记录缺少库存关联信息"}), 400 stock = get_stock_record(draft.source_table, draft.stock_id) if not stock: return jsonify({"message": "库存记录不存在"}), 404 diff_qty = float(draft.diff_qty) # 3. 计算调整 if diff_qty > 0: # 盘盈:增加库存 new_stock_qty = float(stock.stock_quantity or 0) + diff_qty new_avail_qty = float(stock.available_quantity or 0) + diff_qty action_type = '盘盈入库' elif diff_qty < 0: # 盘亏:减少库存 abs_diff = abs(diff_qty) current_avail = float(stock.available_quantity or 0) if current_avail < abs_diff: return jsonify({ "message": f"可用库存不足,当前可用: {current_avail},需要减少: {abs_diff}" }), 400 new_stock_qty = float(stock.stock_quantity or 0) - abs_diff new_avail_qty = current_avail - abs_diff action_type = '盘亏出库' else: return jsonify({"message": "差异为0,无需调整"}), 400 # 4. 执行库存调整 stock.stock_quantity = new_stock_qty stock.available_quantity = new_avail_qty # 5. 生成流水账记录 # 导入流水账模型 from app.models.outbound import TransOutbound trans_record = TransOutbound( outbound_no=f"STKADJ-{datetime.now().strftime('%Y%m%d%H%M%S')}-{draft.id:04d}", sku=stock.sku, source_table=draft.source_table, stock_id=draft.stock_id, barcode=getattr(stock, 'barcode', ''), quantity=abs(diff_qty), unit_price=getattr(stock, 'pre_tax_unit_price', 0) or getattr(stock, 'manual_cost', 0) or 0, outbound_type=action_type, # 盘盈入库 / 盘亏出库 consumer_name='盘点调整', operator_name=operator_name, remark=f"{action_type} - 盘点差异调整,备注: {remark}", outbound_time=datetime.now() ) db.session.add(trans_record) # 6. 标记草稿为已处理 draft.is_processed = True draft.processed_time = datetime.now() db.session.commit() return jsonify({ "message": f"{action_type}成功", "action_type": action_type, "diff_qty": diff_qty, "new_stock_qty": new_stock_qty, "new_avail_qty": new_avail_qty, "draft_id": draft_id }), 200 except Exception as e: db.session.rollback() print(f"Adjust Stock Error: {e}") return jsonify({"message": str(e)}), 500 @bp.route('/borrowed-quantities', methods=['POST']) @permission_required('inventory_stocktake') def get_borrowed_quantities(): """批量获取借出未还数量""" data = request.json.get('items', []) result = {} for item in data: source = item.get('source_table') stock_id = item.get('stock_id') if source and stock_id is not None: qty = TransBorrow.get_borrowed_quantity(source, stock_id) result[f"{source}_{stock_id}"] = qty return jsonify(result), 200 # --- 打印接口 --- @bp.route('/print/selection', methods=['POST']) @permission_required('inventory_stocktake:operation') def print_selection(): try: data = request.json items = data.get('items', []) if not items: return jsonify({"message": "未选择任何物品"}), 400 printer = NetworkPrintService() success, msg = printer.print_outbound_selection(items) return jsonify({"message": "打印指令已发送" if success else msg}), 200 if success else 500 except Exception as e: return jsonify({"message": str(e)}), 500 @bp.route('/print/stocktake', methods=['POST']) @permission_required('inventory_stocktake:operation') def print_stocktake(): try: data = request.json printer = NetworkPrintService() success, msg = printer.print_stocktake_report(data) return jsonify({"message": "盘点报告已发送" if success else msg}), 200 if success else 500 except Exception as e: return jsonify({"message": str(e)}), 500