From 7e23141870db4ade742a737a5fe703bae3d3adc1 Mon Sep 17 00:00:00 2001 From: DXC Date: Fri, 13 Mar 2026 09:59:01 +0800 Subject: [PATCH] refactor: redesign stocktake flow to require manual discrepancy audit and individual adjustments --- inventory-backend/app/api/v1/inbound/stock.py | 394 +++++++++++++++++- .../app/models/inbound/stocktake.py | 39 +- .../src/views/stock/stocktake/index.vue | 277 ++++++++++-- 3 files changed, 654 insertions(+), 56 deletions(-) diff --git a/inventory-backend/app/api/v1/inbound/stock.py b/inventory-backend/app/api/v1/inbound/stock.py index 3e53e59..c2f6fbf 100644 --- a/inventory-backend/app/api/v1/inbound/stock.py +++ b/inventory-backend/app/api/v1/inbound/stock.py @@ -3,10 +3,12 @@ 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 # 尝试导入半成品和成品 try: @@ -24,6 +26,52 @@ 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(): @@ -67,59 +115,379 @@ def get_all_stock(): @bp.route('/draft/list', methods=['GET']) @permission_required('inventory_stocktake') def get_drafts(): - """获取当前用户的盘点进度""" + """ + 获取当前用户的盘点进度 + 支持过滤: session_id, is_finished, is_processed + """ user_id = request.args.get('user_id', 'admin') - drafts = StocktakeDraft.query.filter_by(user_id=user_id).all() + session_id = request.args.get('session_id') + is_finished = request.args.get('is_finished') + is_processed = request.args.get('is_processed') + + query = StocktakeDraft.query.filter_by(user_id=user_id) + + if session_id: + query = query.filter_by(session_id=session_id) + if is_finished is not None: + query = query.filter_by(is_finished=is_finished.lower() in ['true', '1', 'yes']) + if is_processed is not None: + query = query.filter_by(is_processed=is_processed.lower() in ['true', '1', 'yes']) + + 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 = data.get('user_id', 'admin') uuid = data.get('uuid') - quantity = data.get('quantity', 1) + 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).first() + draft = StocktakeDraft.query.filter_by(user_id=user_id, uuid=uuid, session_id=session_id).first() if draft: # 如果已存在,更新数量和时间 draft.quantity = quantity - # ★ 修复点:这里需要 datetime 对象 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) + 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"}), 200 + 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 = 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 = data.get('user_id', 'admin') - StocktakeDraft.query.filter_by(user_id=user_id).delete() - db.session.commit() - return jsonify({"message": "Cleared"}), 200 + 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 = 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 = 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(): """批量获取借出未还数量""" - from app.models.transaction import TransBorrow data = request.json.get('items', []) result = {} for item in data: diff --git a/inventory-backend/app/models/inbound/stocktake.py b/inventory-backend/app/models/inbound/stocktake.py index 03c1864..4f593f6 100644 --- a/inventory-backend/app/models/inbound/stocktake.py +++ b/inventory-backend/app/models/inbound/stocktake.py @@ -1,22 +1,55 @@ from app.extensions import db, beijing_time # .material -> .base refactor checked from datetime import datetime + class StocktakeDraft(db.Model): + """ + 盘点草稿表 + 支持多轮盘点,保留历史记录 + """ __tablename__ = 'stocktake_draft' id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.String(100), default='admin') + # 关联的库存UUID (sku/barcode) uuid = db.Column(db.String(100)) - # ★ 新增 quantity 字段 + # 实际盘点数量 quantity = db.Column(db.Numeric(19, 4), default=1) scan_time = db.Column(db.DateTime, default=beijing_time) + # ★ 新增: 盘点会话标识 (用于区分不同批次的盘点) + session_id = db.Column(db.String(100)) + # ★ 新增: 是否已结束盘点 + is_finished = db.Column(db.Boolean, default=False) + # ★ 新增: 盘点结束时间 + finish_time = db.Column(db.DateTime) + # ★ 新增: 是否已处理差异 (手动平账) + is_processed = db.Column(db.Boolean, default=False) + # ★ 新增: 处理时间 + processed_time = db.Column(db.DateTime) + # ★ 新增: 差异数量 (实盘 - 账面, 正=盘盈, 负=盘亏) + diff_qty = db.Column(db.Numeric(19, 4), default=0) + # ★ 新增: 账面库存数量 + stock_qty = db.Column(db.Numeric(19, 4), default=0) + # ★ 新增: 关联的库存类型 (stock_buy/stock_semi/stock_product) + source_table = db.Column(db.String(50)) + # ★ 新增: 关联的库存ID + stock_id = db.Column(db.Integer) + def to_dict(self): return { 'id': self.id, 'user_id': self.user_id, 'uuid': self.uuid, - # ★ 返回 quantity 'quantity': float(self.quantity or 1), - 'scan_time': self.scan_time.strftime('%Y-%m-%d %H:%M:%S') + 'scan_time': self.scan_time.strftime('%Y-%m-%d %H:%M:%S') if self.scan_time else None, + 'session_id': self.session_id, + 'is_finished': self.is_finished, + 'finish_time': self.finish_time.strftime('%Y-%m-%d %H:%M:%S') if self.finish_time else None, + 'is_processed': self.is_processed, + 'processed_time': self.processed_time.strftime('%Y-%m-%d %H:%M:%S') if self.processed_time else None, + 'diff_qty': float(self.diff_qty or 0), + 'stock_qty': float(self.stock_qty or 0), + 'source_table': self.source_table, + 'stock_id': self.stock_id } diff --git a/inventory-web/src/views/stock/stocktake/index.vue b/inventory-web/src/views/stock/stocktake/index.vue index 502e05c..2ec5cf8 100644 --- a/inventory-web/src/views/stock/stocktake/index.vue +++ b/inventory-web/src/views/stock/stocktake/index.vue @@ -25,6 +25,17 @@ > 继续上次盘点 ({{ serverDraftCount }}项) + + + + 📋 差异审核 (查看历史差异) +
@@ -247,37 +258,96 @@
- -
-
截止时间:{{ new Date().toLocaleString() }}
-
-
{{ stats.total }}
总数
-
{{ stats.scanned }}
已盘
-
{{ stats.total - stats.scanned }}
未盘
-
-
- -
差异/未盘预览
- - - - -