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 }}项)
+
+
+