from flask import Blueprint, jsonify, request, send_file, current_app from app.extensions import db, beijing_time from datetime import datetime, timedelta from flask_jwt_extended import jwt_required from app.utils.decorators import permission_required import uuid as uuid_module import io from openpyxl import Workbook from openpyxl.styles import Font, Alignment, Border, Side, PatternFill # 导入模型 from app.models.inbound.buy import StockBuy from app.models.inbound.stocktake import StocktakeDraft from app.models.transaction import TransBorrow from app.models.base import MaterialBase # 尝试导入用户模型 try: from app.models.system import SysUser except ImportError: SysUser = None # 尝试导入半成品和成品 try: from app.models.inbound.semi import StockSemi except ImportError: StockSemi = None try: from app.models.inbound.product import StockProduct except ImportError: StockProduct = None 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() def get_stock_model(source_table): """根据source_table获取对应的库存模型""" if source_table == 'stock_buy': return StockBuy elif source_table == 'stock_semi': return StockSemi elif source_table == 'stock_product': return StockProduct return 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']) @jwt_required() 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('/list', methods=['GET']) @jwt_required() def get_stock_list(): """ 分页获取库存列表(stock_quantity > 0) 参数: page - 页码(默认 1) pageSize - 每页条数(默认 20) keyword - 搜索关键字(模糊匹配名称/规格/SKU) """ try: page = request.args.get('page', 1, type=int) pageSize = request.args.get('pageSize', 20, type=int) keyword = request.args.get('keyword', '', type=str).strip() if page < 1: page = 1 if pageSize < 1 or pageSize > 200: pageSize = 20 all_items = [] # 1. 采购件 if StockBuy: q = StockBuy.query.filter(StockBuy.stock_quantity > 0) if keyword: q = q.filter( db.or_( StockBuy.material_name.ilike(f'%{keyword}%'), StockBuy.spec_model.ilike(f'%{keyword}%'), StockBuy.sku.ilike(f'%{keyword}%') ) ) rows = q.all() for item in rows: d = item.to_dict() d['stock_type'] = 'material' d['type'] = 'material' d['typeLabel'] = '采购件' d['name'] = d.get('material_name', d.get('name', '')) d['standard'] = d.get('spec_model', d.get('standard', '')) d['available_quantity'] = d.get('qty_available', d.get('available_quantity', 0)) all_items.append(d) # 2. 半成品 if StockSemi: try: q = StockSemi.query.filter(StockSemi.stock_quantity > 0) if keyword: q = q.filter( db.or_( StockSemi.material_name.ilike(f'%{keyword}%'), StockSemi.spec_model.ilike(f'%{keyword}%'), StockSemi.sku.ilike(f'%{keyword}%') ) ) rows = q.all() for item in rows: d = item.to_dict() d['stock_type'] = 'semi' d['type'] = 'semi' d['typeLabel'] = '半成品' d['name'] = d.get('material_name', d.get('name', '')) d['standard'] = d.get('spec_model', d.get('standard', '')) d['available_quantity'] = d.get('qty_available', d.get('available_quantity', 0)) all_items.append(d) except Exception: pass # 3. 成品 if StockProduct: try: q = StockProduct.query.filter(StockProduct.stock_quantity > 0) if keyword: q = q.filter( db.or_( StockProduct.product_name.ilike(f'%{keyword}%'), StockProduct.spec_model.ilike(f'%{keyword}%'), StockProduct.sku.ilike(f'%{keyword}%') ) ) rows = q.all() for item in rows: d = item.to_dict() d['stock_type'] = 'product' d['type'] = 'product' d['typeLabel'] = '成品' d['name'] = d.get('product_name', d.get('name', '')) d['standard'] = d.get('spec_model', d.get('standard', '')) d['available_quantity'] = d.get('qty_available', d.get('available_quantity', 0)) all_items.append(d) except Exception: pass total = len(all_items) start = (page - 1) * pageSize end = start + pageSize paged = all_items[start:end] return jsonify({ 'msg': '获取成功', 'data': { 'list': paged, 'total': total, 'page': page, 'pageSize': pageSize } }), 200 except Exception as e: current_app.logger.error(f"Get Stock List Failed: {str(e)}") return jsonify({'msg': f'获取库存列表失败: {str(e)}'}), 500 # --- 草稿箱接口 --- @bp.route('/draft/list', methods=['GET']) @permission_required('inventory_stocktake') def get_drafts(): """ 获取盘点草稿列表 支持分页、搜索(SKU)和排序 """ # 获取分页参数 page = request.args.get('page', 1, type=int) limit = request.args.get('limit', 20, type=int) keyword = request.args.get('keyword', '', type=str) session_id = request.args.get('session_id') query = StocktakeDraft.query if session_id: query = query.filter_by(session_id=session_id) # 先执行查询获取所有记录 drafts = query.all() items = [] for draft in drafts: # 获取 SKU 信息 sku = '' material_name = '' spec_model = '' # 根据source_table获取对应的库存记录 stock_model = get_stock_model(draft.source_table) if stock_model and draft.stock_id: stock = stock_model.query.get(draft.stock_id) if stock: sku = getattr(stock, 'sku', None) or getattr(stock, 'SKU', '') # 如果有关键词,进行 SKU 模糊匹配 if keyword and sku: if keyword.lower() not in sku.lower(): continue # 获取物料基础信息 base_id = getattr(stock, 'base_id', None) if base_id: material = MaterialBase.query.get(base_id) if material: material_name = material.name spec_model = material.spec_model item = draft.to_dict() item['sku'] = sku item['material_name'] = material_name item['spec_model'] = spec_model items.append(item) # 按 SKU 升序排序 items.sort(key=lambda x: (x['sku'] or '').lower()) # 手动分页 total = len(items) start = (page - 1) * limit end = start + limit paginated_items = items[start:end] return jsonify({ 'items': paginated_items, 'total': total, 'page': page, 'limit': limit }), 200 @bp.route('/draft/add', methods=['POST']) @permission_required('inventory_stocktake:operation') def add_draft(): """ 扫码同步 (支持更新数量) 如果 session_id 不存在则创建新的会话 差异计算逻辑调整: - adjusted_stock_qty = 账面总库存 - 借出未还数量 - diff_qty = 实盘数量 - adjusted_stock_qty """ 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') # ★ 新增: 提取备注字段 remark = data.get('remark') 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 # 计算借出未还数量 (quantity - returned_quantity) borrowed_result = db.session.query( db.func.sum(db.func.coalesce(TransBorrow.quantity, 0) - db.func.coalesce(TransBorrow.returned_quantity, 0)) ).filter( TransBorrow.source_table == source_table, TransBorrow.stock_id == stock_id, TransBorrow.is_returned == False ).scalar() total_borrowed = float(borrowed_result) if borrowed_result else 0 # 调整后的账面可用库存 = 账面总库存 - 借出未还数量 adjusted_stock_qty = stock_qty - total_borrowed # 查找是否已存在 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 = adjusted_stock_qty draft.diff_qty = quantity - adjusted_stock_qty draft.source_table = source_table draft.stock_id = stock_id # ★ 新增: 保存备注 if remark is not None: draft.remark = remark.strip() if isinstance(remark, str) else remark else: # 如果不存在,创建新的 draft = StocktakeDraft( user_id=user_id, uuid=uuid, quantity=quantity, session_id=session_id, stock_qty=adjusted_stock_qty, diff_qty=quantity - adjusted_stock_qty, source_table=source_table, stock_id=stock_id, # ★ 新增: 保存备注 remark=remark.strip() if isinstance(remark, str) and remark else (remark if remark else None) ) db.session.add(draft) db.session.commit() return jsonify({ "message": "Saved", "session_id": session_id, "draft_id": draft.id, "adjusted_stock_qty": adjusted_stock_qty, "total_borrowed": total_borrowed }), 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 session_id = data.get('session_id') try: query = StocktakeDraft.query if session_id: # 清除指定会话 query = query.filter_by(session_id=session_id) 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(): """ 开始新一轮盘点 清空整张草稿表,返回新的 session_id """ try: # 清空整张草稿表 deleted_count = StocktakeDraft.query.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"已清除 {deleted_count} 条旧记录", "session_id": new_session_id, "cleared_count": deleted_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(): """ 结束盘点 直接返回成功,前端会跳转到差异列表 草稿数据保留在表中 """ return jsonify({ "message": "盘点已结束", "code": 200 }), 200 @bp.route('/variance-report', methods=['GET']) @permission_required('inventory_stocktake') def get_variance_report(): """ 获取盘点差异报告 返回所有有差异的记录(diff_qty != 0) """ session_id = request.args.get('session_id') try: query = StocktakeDraft.query if session_id: query = query.filter_by(session_id=session_id) # 只返回有差异的记录 drafts = query.filter(StocktakeDraft.diff_qty != 0).order_by( StocktakeDraft.scan_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 支持两种模式: - 有草稿模式:通过 draft_id 或 stock_id+source_table 查找草稿 - 无草稿模式:直接传入 stock_id + diff_qty + source_table(未扫码直接盘亏) """ data = request.json draft_id = data.get('draft_id') stock_id = data.get('stock_id') diff_qty = data.get('diff_qty') source_table = data.get('source_table') operator_name = data.get('operator_name', 'System') remark = data.get('remark', '') if not draft_id and not stock_id: return jsonify({"message": "draft_id 或 stock_id 不能同时为空"}), 400 try: # 1. 尝试获取草稿 draft = StocktakeDraft.query.get(draft_id) if draft_id else None if not draft and stock_id and source_table: draft = StocktakeDraft.query.filter_by( stock_id=stock_id, source_table=source_table ).first() elif not draft and stock_id: draft = StocktakeDraft.query.filter_by(stock_id=stock_id).first() # 2. 核心逻辑分支 if draft: # 有草稿模式 stock_id = draft.stock_id source_table = draft.source_table diff_qty = float(draft.diff_qty) else: # 无草稿模式(未扫码直接盘亏) if diff_qty is None or source_table is None or not stock_id: return jsonify({"message": "未扫码物资平账缺失必要参数(需提供 diff_qty 和 source_table)"}), 400 diff_qty = float(diff_qty) # 3. 获取并校验真实的库存记录 stock = get_stock_record(source_table, stock_id) if not stock: return jsonify({"message": "平账失败:物理库存记录已不存在"}), 404 # 4. 计算调整 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 # 5. 执行库存调整 stock.stock_quantity = new_stock_qty stock.available_quantity = new_avail_qty # 6. 生成流水账记录 from app.models.outbound import TransOutbound # 生成唯一单号 adj_no_suffix = draft.id if draft else f"MANUAL-{datetime.now().strftime('%Y%m%d%H%M%S')}" trans_record = TransOutbound( outbound_no=f"STKADJ-{datetime.now().strftime('%Y%m%d%H%M%S')}-{adj_no_suffix}", sku=stock.sku, source_table=source_table, stock_id=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) # 7. 删除草稿记录(仅在有草稿时) if draft: db.session.delete(draft) 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 if draft_id else (draft.id if draft else None) }), 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 @bp.route('/export-stocktake', methods=['GET']) @permission_required('inventory_stocktake:operation') def export_stocktake(): """ 导出盘点报告 Excel 包含3个Sheet: 1. 盘点差异明细 (diff_qty != 0) 2. 账实相符明细 (diff_qty == 0) 3. 外借在用资产明细 (未归还的借出记录) """ try: # 创建工作簿 wb = Workbook() wb.remove(wb.active) # 定义样式 header_font = Font(bold=True, size=11, color="FFFFFF") header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") header_alignment = Alignment(horizontal="center", vertical="center") thin_border = Border( left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin') ) def get_material_info(source_table, stock_id): """获取物料基本信息""" if source_table == 'stock_buy': stock = StockBuy.query.get(stock_id) elif source_table == 'stock_semi': stock = StockSemi.query.get(stock_id) if StockSemi else None elif source_table == 'stock_product': stock = StockProduct.query.get(stock_id) if StockProduct else None else: return {'name': '-', 'sku': '-', 'spec': '-', 'unit': '-', 'location': '-'} if not stock: return {'name': '-', 'sku': '-', 'spec': '-', 'unit': '-', 'location': '-'} # 安全获取 sku stock_sku = getattr(stock, 'sku', None) or getattr(stock, 'SKU', None) or '-' # 使用 base_id 关联查询物料基础表 material = None base_id = getattr(stock, 'base_id', None) if base_id: material = MaterialBase.query.get(base_id) # 规格型号:从 MaterialBase 的 spec_model 字段获取 spec = getattr(material, 'spec_model', None) if material else '-' if not spec or spec == '-': spec = getattr(stock, 'spec_model', None) or getattr(stock, 'standard', None) or '-' # 库位:从库存表的 warehouse_location 字段获取 location = getattr(stock, 'warehouse_location', None) or '-' return { 'name': material.name if material else stock_sku, 'sku': stock_sku, 'spec': spec, 'unit': getattr(stock, 'unit', None) or '个', 'location': location } def get_user_name(user_id): """获取用户真实姓名 SysUser.username 存储格式为 "真实姓名/登录账号" (例如: 张三/zhangsan01) """ if not SysUser or not user_id: return str(user_id) if user_id else '-' try: user = None # 尝试通过ID或用户名查找 if str(user_id).isdigit(): user = SysUser.query.get(int(user_id)) if not user: user = SysUser.query.filter(SysUser.username.like(f"%/{user_id}")).first() if not user: user = SysUser.query.filter_by(username=str(user_id)).first() if not user: return str(user_id) # 解析 username 格式: "张三/zhangsan01" -> 取前面的真实姓名 raw_username = getattr(user, 'username', None) or str(user_id) if '/' in raw_username: return raw_username.split('/')[0] return raw_username except: return str(user_id) def to_beijing_time(dt): """转换为北京时间(+8小时)""" if not dt: return '' try: if isinstance(dt, str): return dt[:19] return (dt + timedelta(hours=8)).strftime('%Y-%m-%d %H:%M:%S') except: return str(dt)[:19] def set_header_row(ws, headers): for col, header in enumerate(headers, 1): cell = ws.cell(row=1, column=col, value=header) cell.font = header_font cell.fill = header_fill cell.alignment = header_alignment cell.border = thin_border # ===== Sheet 1: 盘点全景汇总表 (放在最前面) ===== ws1 = wb.create_sheet("盘点全景汇总表", 0) summary_headers = ["物料名称", "SKU", "规格型号", "库位", "调整后账面数", "实盘数", "差异数", "盘点状态", "盘点人", "盘点时间", "备注"] set_header_row(ws1, summary_headers) master_row_idx = 2 # 汇总表行计数器 # ===== Sheet 2: 盘点差异明细 ===== ws2 = wb.create_sheet("盘点差异明细") diff_headers = ["物料名称", "SKU", "规格型号", "库位", "调整后账面数", "实盘数", "差异数", "盘点人", "盘点时间", "备注"] set_header_row(ws2, diff_headers) # 按 SKU 排序:先获取全部数据,再在 Python 中按 SKU 排序 diff_drafts = StocktakeDraft.query.filter(StocktakeDraft.diff_qty != 0).all() diff_drafts_with_sku = [] for draft in diff_drafts: mat_info = get_material_info(draft.source_table, draft.stock_id) diff_drafts_with_sku.append((mat_info.get('sku', ''), draft)) diff_drafts_with_sku.sort(key=lambda x: x[0] if x[0] else '') diff_drafts = [d[1] for d in diff_drafts_with_sku] for row_idx, draft in enumerate(diff_drafts, 2): mat_info = get_material_info(draft.source_table, draft.stock_id) # 写入 Sheet 2 (差异明细) ws2.cell(row=row_idx, column=1, value=mat_info['name']).border = thin_border ws2.cell(row=row_idx, column=2, value=mat_info['sku']).border = thin_border ws2.cell(row=row_idx, column=3, value=mat_info['spec']).border = thin_border ws2.cell(row=row_idx, column=4, value=mat_info['location']).border = thin_border ws2.cell(row=row_idx, column=5, value=float(draft.stock_qty or 0)).border = thin_border ws2.cell(row=row_idx, column=6, value=float(draft.quantity or 0)).border = thin_border ws2.cell(row=row_idx, column=7, value=float(draft.diff_qty or 0)).border = thin_border ws2.cell(row=row_idx, column=8, value=get_user_name(draft.user_id)).border = thin_border ws2.cell(row=row_idx, column=9, value=to_beijing_time(draft.scan_time)).border = thin_border ws2.cell(row=row_idx, column=10, value=draft.remark or '').border = thin_border # 同时写入 Sheet 1 (汇总表) ws1.cell(row=master_row_idx, column=1, value=mat_info['name']).border = thin_border ws1.cell(row=master_row_idx, column=2, value=mat_info['sku']).border = thin_border ws1.cell(row=master_row_idx, column=3, value=mat_info['spec']).border = thin_border ws1.cell(row=master_row_idx, column=4, value=mat_info['location']).border = thin_border ws1.cell(row=master_row_idx, column=5, value=float(draft.stock_qty or 0)).border = thin_border ws1.cell(row=master_row_idx, column=6, value=float(draft.quantity or 0)).border = thin_border ws1.cell(row=master_row_idx, column=7, value=float(draft.diff_qty or 0)).border = thin_border ws1.cell(row=master_row_idx, column=8, value="有差异").border = thin_border ws1.cell(row=master_row_idx, column=9, value=get_user_name(draft.user_id)).border = thin_border ws1.cell(row=master_row_idx, column=10, value=to_beijing_time(draft.scan_time)).border = thin_border ws1.cell(row=master_row_idx, column=11, value=draft.remark or '').border = thin_border master_row_idx += 1 # ===== Sheet 3: 账实相符明细 ===== ws3 = wb.create_sheet("账实相符明细") normal_headers = ["物料名称", "SKU", "规格型号", "库位", "调整后账面数", "实盘数", "差异数", "盘点人", "盘点时间", "备注"] set_header_row(ws3, normal_headers) # 按 SKU 排序 normal_drafts = StocktakeDraft.query.filter(StocktakeDraft.diff_qty == 0).all() normal_drafts_with_sku = [] for draft in normal_drafts: mat_info = get_material_info(draft.source_table, draft.stock_id) normal_drafts_with_sku.append((mat_info.get('sku', ''), draft)) normal_drafts_with_sku.sort(key=lambda x: x[0] if x[0] else '') normal_drafts = [d[1] for d in normal_drafts_with_sku] for row_idx, draft in enumerate(normal_drafts, 2): mat_info = get_material_info(draft.source_table, draft.stock_id) # 写入 Sheet 3 (账实相符) ws3.cell(row=row_idx, column=1, value=mat_info['name']).border = thin_border ws3.cell(row=row_idx, column=2, value=mat_info['sku']).border = thin_border ws3.cell(row=row_idx, column=3, value=mat_info['spec']).border = thin_border ws3.cell(row=row_idx, column=4, value=mat_info['location']).border = thin_border ws3.cell(row=row_idx, column=5, value=float(draft.stock_qty or 0)).border = thin_border ws3.cell(row=row_idx, column=6, value=float(draft.quantity or 0)).border = thin_border ws3.cell(row=row_idx, column=7, value=float(draft.diff_qty or 0)).border = thin_border ws3.cell(row=row_idx, column=8, value=get_user_name(draft.user_id)).border = thin_border ws3.cell(row=row_idx, column=9, value=to_beijing_time(draft.scan_time)).border = thin_border ws3.cell(row=row_idx, column=10, value=draft.remark or '').border = thin_border # 同时写入 Sheet 1 (汇总表) ws1.cell(row=master_row_idx, column=1, value=mat_info['name']).border = thin_border ws1.cell(row=master_row_idx, column=2, value=mat_info['sku']).border = thin_border ws1.cell(row=master_row_idx, column=3, value=mat_info['spec']).border = thin_border ws1.cell(row=master_row_idx, column=4, value=mat_info['location']).border = thin_border ws1.cell(row=master_row_idx, column=5, value=float(draft.stock_qty or 0)).border = thin_border ws1.cell(row=master_row_idx, column=6, value=float(draft.quantity or 0)).border = thin_border ws1.cell(row=master_row_idx, column=7, value=float(draft.diff_qty or 0)).border = thin_border ws1.cell(row=master_row_idx, column=8, value="正常").border = thin_border ws1.cell(row=master_row_idx, column=9, value=get_user_name(draft.user_id)).border = thin_border ws1.cell(row=master_row_idx, column=10, value=to_beijing_time(draft.scan_time)).border = thin_border ws1.cell(row=master_row_idx, column=11, value=draft.remark or '').border = thin_border master_row_idx += 1 # ===== Sheet 4: 外借在用资产明细 ===== ws4 = wb.create_sheet("外借在用资产明细") borrow_headers = ["借出单号", "借用人", "物料名称", "SKU", "规格型号", "借出总数", "已还数量", "待还数量", "借出时间", "预计归还时间"] set_header_row(ws4, borrow_headers) # 查询未归还的借出记录 unreturned_borrows = TransBorrow.query.filter(TransBorrow.is_returned == False).all() for row_idx, borrow in enumerate(unreturned_borrows, 2): mat_info = get_material_info(borrow.source_table, borrow.stock_id) total_qty = float(borrow.quantity or 0) returned_qty = float(borrow.returned_quantity or 0) pending_qty = total_qty - returned_qty ws4.cell(row=row_idx, column=1, value=borrow.borrow_no or '').border = thin_border ws4.cell(row=row_idx, column=2, value=borrow.borrower_name or '').border = thin_border ws4.cell(row=row_idx, column=3, value=mat_info['name']).border = thin_border ws4.cell(row=row_idx, column=4, value=mat_info['sku']).border = thin_border ws4.cell(row=row_idx, column=5, value=mat_info['spec']).border = thin_border ws4.cell(row=row_idx, column=6, value=total_qty).border = thin_border ws4.cell(row=row_idx, column=7, value=returned_qty).border = thin_border ws4.cell(row=row_idx, column=8, value=pending_qty).border = thin_border ws4.cell(row=row_idx, column=9, value=to_beijing_time(borrow.borrow_time)).border = thin_border ws4.cell(row=row_idx, column=10, value='无限期' if not borrow.expected_return_time else to_beijing_time(borrow.expected_return_time)).border = thin_border # ===== Sheet 5: 未盘点明细(疑似漏盘) ===== # 逻辑:获取已盘点的集合,遍历库存表,找出未盘点且有库存的物资 ws5 = wb.create_sheet("未盘点明细(疑似漏盘)") unscanned_headers = ["物料名称", "SKU", "规格型号", "库位", "调整后账面数", "实盘数", "差异数", "状态"] set_header_row(ws5, unscanned_headers) # 获取已盘点的 (source_table, stock_id) 集合 all_drafts = StocktakeDraft.query.all() scanned_set = {(d.source_table, d.stock_id) for d in all_drafts} def get_borrowed_qty(source_table, stock_id): """获取某库存的借出未还数量""" try: borrowed = TransBorrow.query.filter( TransBorrow.source_table == source_table, TransBorrow.stock_id == stock_id, TransBorrow.is_returned == False ).all() total = sum(float(b.quantity or 0) - float(b.returned_quantity or 0) for b in borrowed) return total except: return 0 unscanned_items = [] # 遍历 StockBuy for stock in StockBuy.query.all(): key = ('stock_buy', stock.id) if key in scanned_set: continue stock_qty = float(stock.stock_quantity or 0) if stock_qty <= 0: continue # 扣除外借数量 borrowed_qty = get_borrowed_qty('stock_buy', stock.id) expected_qty = stock_qty - borrowed_qty if expected_qty > 0: mat_info = get_material_info('stock_buy', stock.id) unscanned_items.append({ 'name': mat_info['name'], 'sku': mat_info['sku'], 'spec': mat_info['spec'], 'location': mat_info['location'], 'stock_qty': expected_qty, 'actual_qty': 0, 'diff_qty': -expected_qty, 'status': '未盘点' }) # 遍历 StockSemi if StockSemi: for stock in StockSemi.query.all(): key = ('stock_semi', stock.id) if key in scanned_set: continue stock_qty = float(stock.stock_quantity or 0) if stock_qty <= 0: continue borrowed_qty = get_borrowed_qty('stock_semi', stock.id) expected_qty = stock_qty - borrowed_qty if expected_qty > 0: mat_info = get_material_info('stock_semi', stock.id) unscanned_items.append({ 'name': mat_info['name'], 'sku': mat_info['sku'], 'spec': mat_info['spec'], 'location': mat_info['location'], 'stock_qty': expected_qty, 'actual_qty': 0, 'diff_qty': -expected_qty, 'status': '未盘点' }) # 遍历 StockProduct if StockProduct: for stock in StockProduct.query.all(): key = ('stock_product', stock.id) if key in scanned_set: continue stock_qty = float(stock.stock_quantity or 0) if stock_qty <= 0: continue borrowed_qty = get_borrowed_qty('stock_product', stock.id) expected_qty = stock_qty - borrowed_qty if expected_qty > 0: mat_info = get_material_info('stock_product', stock.id) unscanned_items.append({ 'name': mat_info['name'], 'sku': mat_info['sku'], 'spec': mat_info['spec'], 'location': mat_info['location'], 'stock_qty': expected_qty, 'actual_qty': 0, 'diff_qty': -expected_qty, 'status': '未盘点' }) # 写入未盘点明细 for row_idx, item in enumerate(unscanned_items, 2): # 写入 Sheet 5 (未盘点明细) ws5.cell(row=row_idx, column=1, value=item['name']).border = thin_border ws5.cell(row=row_idx, column=2, value=item['sku']).border = thin_border ws5.cell(row=row_idx, column=3, value=item['spec']).border = thin_border ws5.cell(row=row_idx, column=4, value=item['location']).border = thin_border ws5.cell(row=row_idx, column=5, value=float(item['stock_qty'])).border = thin_border ws5.cell(row=row_idx, column=6, value=float(item['actual_qty'])).border = thin_border ws5.cell(row=row_idx, column=7, value=float(item['diff_qty'])).border = thin_border ws5.cell(row=row_idx, column=8, value=item['status']).border = thin_border # 同时写入 Sheet 1 (汇总表) - 盘点人和时间留空 ws1.cell(row=master_row_idx, column=1, value=item['name']).border = thin_border ws1.cell(row=master_row_idx, column=2, value=item['sku']).border = thin_border ws1.cell(row=master_row_idx, column=3, value=item['spec']).border = thin_border ws1.cell(row=master_row_idx, column=4, value=item['location']).border = thin_border ws1.cell(row=master_row_idx, column=5, value=float(item['stock_qty'])).border = thin_border ws1.cell(row=master_row_idx, column=6, value=float(item['actual_qty'])).border = thin_border ws1.cell(row=master_row_idx, column=7, value=float(item['diff_qty'])).border = thin_border ws1.cell(row=master_row_idx, column=8, value="未盘点").border = thin_border ws1.cell(row=master_row_idx, column=9, value="").border = thin_border ws1.cell(row=master_row_idx, column=10, value="").border = thin_border master_row_idx += 1 # 调整列宽 for ws in [ws1, ws2, ws3, ws4, ws5]: for col in ws.columns: max_length = 0 col_letter = col[0].column_letter for cell in col: try: if len(str(cell.value)) > max_length: max_length = len(str(cell.value)) except: pass ws.column_dimensions[col_letter].width = min(max_length + 2, 30) # 生成文件 output = io.BytesIO() wb.save(output) output.seek(0) filename = f"盘点报告_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" return send_file( output, mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', as_attachment=True, download_name=filename ) except Exception as e: print(f"Export Stocktake Error: {e}") import traceback traceback.print_exc() return jsonify({"message": f"导出失败: {str(e)}"}), 500 # -------------------------------------------------------- # 生成漏盘数据 - 将未扫描的库存标记为全额盘亏 # POST /api/v1/inbound/stocktake/generate-missing # -------------------------------------------------------- @bp.route('/stocktake/generate-missing', methods=['POST']) @permission_required('inventory_stocktake:operation') def generate_missing_stocktake(): """ 生成漏盘数据: 找出所有真实库存 > 0,但未被盘点扫描到的物料, 自动生成盘点草稿,标记为盘亏(实盘=0,差异=-库存数) """ try: # 1. 获取所有已有盘点记录的 (source_table, stock_id) 集合 existing_records = db.session.query( StocktakeDraft.source_table, StocktakeDraft.stock_id ).distinct().all() scanned_keys = set() for src_table, stock_id in existing_records: if stock_id: scanned_keys.add((src_table, stock_id)) # 2. 获取所有真实库存 > 0 的记录 all_stock = [] # 采购库存 for item in StockBuy.query.filter(StockBuy.stock_quantity > 0).all(): all_stock.append({ 'source_table': 'stock_buy', 'stock_id': item.id, 'base_id': item.base_id, 'stock_qty': float(item.stock_quantity or 0) }) # 半成品库存 if StockSemi: for item in StockSemi.query.filter(StockSemi.stock_quantity > 0).all(): all_stock.append({ 'source_table': 'stock_semi', 'stock_id': item.id, 'base_id': item.base_id, 'stock_qty': float(item.stock_quantity or 0) }) # 成品库存 if StockProduct: for item in StockProduct.query.filter(StockProduct.stock_quantity > 0).all(): all_stock.append({ 'source_table': 'stock_product', 'stock_id': item.id, 'base_id': item.base_id, 'stock_qty': float(item.stock_quantity or 0) }) # 3. 找出漏盘记录(库存中有但盘点中没有的) missing_count = 0 for stock in all_stock: key = (stock['source_table'], stock['stock_id']) if key not in scanned_keys: # 生成漏盘草稿 draft = StocktakeDraft( user_id='system', uuid=f'MISSING-{stock["source_table"]}-{stock["stock_id"]}', quantity=0, # 实盘数为0 scan_time=beijing_time(), session_id='AUTO_GENERATED', source_table=stock['source_table'], stock_id=stock['stock_id'], stock_qty=stock['stock_qty'], diff_qty=-stock['stock_qty'], # 差异 = 0 - 库存数 = 负数 remark='未盘点到,系统自动标记为盘亏' ) db.session.add(draft) missing_count += 1 db.session.commit() return jsonify({ 'code': 200, 'msg': f'成功生成 {missing_count} 条漏盘记录', 'data': {'count': missing_count} }) except Exception as e: db.session.rollback() import traceback traceback.print_exc() return jsonify({'code': 500, 'msg': f'生成漏盘数据失败: {str(e)}'}), 500