From d8a57ab66eea1ebeb81341e1f28b9ca2bb3ea005 Mon Sep 17 00:00:00 2001 From: DXC Date: Thu, 19 Mar 2026 12:06:32 +0800 Subject: [PATCH] feat: initialize inventory profit and loss adjustment module --- inventory-backend/app/__init__.py | 12 +- .../app/api/v1/stock/adjustment.py | 223 +++++++++++++ .../app/models/stock/adjustment.py | 61 ++++ inventory-web/src/router/index.ts | 7 + .../src/views/stock/adjustment/index.vue | 307 ++++++++++++++++++ stock_adjustment.sql | 25 ++ 6 files changed, 634 insertions(+), 1 deletion(-) create mode 100644 inventory-backend/app/api/v1/stock/adjustment.py create mode 100644 inventory-backend/app/models/stock/adjustment.py create mode 100644 inventory-web/src/views/stock/adjustment/index.vue create mode 100644 stock_adjustment.sql diff --git a/inventory-backend/app/__init__.py b/inventory-backend/app/__init__.py index 299553b..8606da7 100644 --- a/inventory-backend/app/__init__.py +++ b/inventory-backend/app/__init__.py @@ -162,7 +162,17 @@ def create_app(): print(f"⚠️ 审计日志菜单初始化跳过: {e}") # ----------------------------------------------------- - # 2.10 注册库位管理模块 (Warehouse) + # 2.10 注册盘盈盘亏管理模块 (Stock Adjustment) + # ----------------------------------------------------- + try: + from app.api.v1.stock.adjustment import adjustment_bp + app.register_blueprint(adjustment_bp, url_prefix='/api/v1/stock/adjustment') + print("✅ Stock Adjustment 模块注册成功") + except ImportError as e: + print(f"❌ 错误: Stock Adjustment 模块导入失败: {e}") + + # ----------------------------------------------------- + # 2.11 注册库位管理模块 (Warehouse) # ----------------------------------------------------- try: from app.api.v1.warehouse import warehouse_bp diff --git a/inventory-backend/app/api/v1/stock/adjustment.py b/inventory-backend/app/api/v1/stock/adjustment.py new file mode 100644 index 0000000..6357ecf --- /dev/null +++ b/inventory-backend/app/api/v1/stock/adjustment.py @@ -0,0 +1,223 @@ +# inventory-backend/app/api/v1/stock/adjustment.py +from flask import Blueprint, request, jsonify +from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt +from app.utils.decorators import permission_required +from app.extensions import db +from app.models.stock.adjustment import StockAdjustment +from app.models.base import MaterialBase +from app.models.inbound.buy import StockBuy +from app.models.inbound.semi import StockSemi +from app.models.inbound.product import StockProduct +from datetime import datetime +import random +import string + +adjustment_bp = Blueprint('adjustment', __name__, url_prefix='/stock/adjustment') + + +def generate_order_no(): + """生成单号 ADJ-YYYYMMDD-XXXX""" + today = datetime.now().strftime('%Y%m%d') + suffix = ''.join(random.choices(string.digits, k=4)) + return f'ADJ-{today}-{suffix}' + + +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 + + +# -------------------------------------------------------- +# 1. 获取调整单列表 +# GET /api/v1/stock/adjustment/list +# -------------------------------------------------------- +@adjustment_bp.route('/list', methods=['GET']) +@jwt_required() +@permission_required('stock_adjustment:list') +def get_list(): + page = int(request.args.get('page', 1)) + limit = int(request.args.get('limit', 10)) + keyword = request.args.get('keyword', '') + adjust_type = request.args.get('adjust_type', '') + status = request.args.get('status', '') + + query = StockAdjustment.query + + if keyword: + query = query.filter( + db.or_( + StockAdjustment.order_no.ilike(f'%{keyword}%'), + StockAdjustment.sku.ilike(f'%{keyword}%'), + StockAdjustment.material_name.ilike(f'%{keyword}%') + ) + ) + + if adjust_type: + query = query.filter(StockAdjustment.adjust_type == adjust_type) + + if status: + query = query.filter(StockAdjustment.status == status) + + # 按创建时间降序 + query = query.order_by(StockAdjustment.create_time.desc()) + + pagination = query.paginate(page=page, per_page=limit, error_out=False) + + return jsonify({ + 'code': 200, + 'data': { + 'items': [item.to_dict() for item in pagination.items], + 'total': pagination.total, + 'page': page, + 'limit': limit + } + }) + + +# -------------------------------------------------------- +# 2. 创建调整单 +# POST /api/v1/stock/adjustment/create +# -------------------------------------------------------- +@adjustment_bp.route('/create', methods=['POST']) +@jwt_required() +@permission_required('stock_adjustment:operation') +def create(): + data = request.get_json() + if not data: + return jsonify({'code': 400, 'msg': '请求参数不能为空'}), 400 + + # 必填字段验证 + required_fields = ['source_table', 'stock_id', 'adjust_type', 'adjust_quantity', 'reason'] + for field in required_fields: + if field not in data or not data.get(field): + return jsonify({'code': 400, 'msg': f'{field} 为必填项'}), 400 + + source_table = data['source_table'] + stock_id = int(data['stock_id']) + adjust_type = data['adjust_type'] # 'profit' or 'loss' + adjust_quantity = float(data['adjust_quantity']) + reason = data['reason'] + operator = get_jwt_identity() or 'system' + + # 获取库存记录 + StockModel = get_stock_model(source_table) + if not StockModel: + return jsonify({'code': 400, 'msg': '无效的库存类型'}), 400 + + stock = StockModel.query.get(stock_id) + if not stock: + return jsonify({'code': 404, 'msg': '库存记录不存在'}), 404 + + # 获取物料信息 + base_id = getattr(stock, 'base_id', None) + material = MaterialBase.query.get(base_id) if base_id else None + + # 计算库存变动 + if adjust_type == 'profit': + # 盘盈:增加库存 + new_stock_qty = float(stock.stock_quantity or 0) + adjust_quantity + new_avail_qty = float(stock.available_quantity or 0) + adjust_quantity + elif adjust_type == 'loss': + # 盘亏:减少库存 + new_stock_qty = float(stock.stock_quantity or 0) - adjust_quantity + new_avail_qty = float(stock.available_quantity or 0) - adjust_quantity + if new_stock_qty < 0 or new_avail_qty < 0: + return jsonify({'code': 400, 'msg': '库存不足,无法盘亏'}), 400 + else: + return jsonify({'code': 400, 'msg': '无效的调整类型'}), 400 + + try: + # 创建调整单 + adjustment = StockAdjustment( + order_no=generate_order_no(), + base_id=base_id, + stock_id=stock_id, + source_table=source_table, + sku=getattr(stock, 'sku', None) or getattr(stock, 'SKU', None), + material_name=material.name if material else getattr(stock, 'sku', '未知'), + spec_model=getattr(material, 'spec_model', None) if material else None, + warehouse_location=getattr(stock, 'warehouse_location', None), + adjust_type=adjust_type, + adjust_quantity=adjust_quantity, + reason=reason, + status='completed', + operator=operator + ) + db.session.add(adjustment) + + # 更新库存 + stock.stock_quantity = new_stock_qty + stock.available_quantity = new_avail_qty + + db.session.commit() + + return jsonify({ + 'code': 200, + 'msg': '调整成功', + 'data': adjustment.to_dict() + }) + except Exception as e: + db.session.rollback() + return jsonify({'code': 500, 'msg': f'调整失败: {str(e)}'}), 500 + + +# -------------------------------------------------------- +# 3. 获取库存列表(用于选择物料) +# GET /api/v1/stock/adjustment/stocks +# -------------------------------------------------------- +@adjustment_bp.route('/stocks', methods=['GET']) +@jwt_required() +@permission_required('stock_adjustment:list') +def get_stocks(): + """获取可用于调整的库存列表""" + source_table = request.args.get('source_table', 'stock_buy') + keyword = request.args.get('keyword', '') + page = int(request.args.get('page', 1)) + limit = int(request.args.get('limit', 20)) + + StockModel = get_stock_model(source_table) + if not StockModel: + return jsonify({'code': 400, 'msg': '无效的库存类型'}), 400 + + query = StockModel.query.filter(StockModel.stock_quantity > 0) + + if keyword: + query = query.filter( + db.or_( + StockModel.sku.ilike(f'%{keyword}%'), + StockModel.barcode.ilike(f'%{keyword}%') + ) + ) + + pagination = query.paginate(page=page, per_page=limit, error_out=False) + + items = [] + for stock in pagination.items: + base_id = getattr(stock, 'base_id', None) + material = MaterialBase.query.get(base_id) if base_id else None + items.append({ + 'stock_id': stock.id, + 'source_table': source_table, + 'sku': getattr(stock, 'sku', None) or getattr(stock, 'SKU', None), + 'material_name': material.name if material else getattr(stock, 'sku', '未知'), + 'spec_model': getattr(material, 'spec_model', None) if material else None, + 'stock_quantity': float(stock.stock_quantity or 0), + 'available_quantity': float(stock.available_quantity or 0), + 'warehouse_location': getattr(stock, 'warehouse_location', None), + }) + + return jsonify({ + 'code': 200, + 'data': { + 'items': items, + 'total': pagination.total, + 'page': page, + 'limit': limit + } + }) diff --git a/inventory-backend/app/models/stock/adjustment.py b/inventory-backend/app/models/stock/adjustment.py new file mode 100644 index 0000000..92ac356 --- /dev/null +++ b/inventory-backend/app/models/stock/adjustment.py @@ -0,0 +1,61 @@ +# app/models/stock/adjustment.py +from app.extensions import db, beijing_time +from datetime import datetime + + +class StockAdjustment(db.Model): + """ + 盘盈盘亏调整表 + 用于记录财务/主管手动发起的库存修正 + """ + __tablename__ = 'stock_adjustment' + + id = db.Column(db.Integer, primary_key=True) + # 单号,如 ADJ-YYYYMMDD-XXXX + order_no = db.Column(db.String(50), unique=True, nullable=False, index=True) + # 关联物料基础表 + base_id = db.Column(db.Integer, db.ForeignKey('material_base.id')) + # 关联具体库存行ID + stock_id = db.Column(db.Integer) + # 库存类型 (stock_buy/stock_semi/stock_product) + source_table = db.Column(db.String(50)) + # 物料冗余信息 + sku = db.Column(db.String(100)) + material_name = db.Column(db.String(255)) + spec_model = db.Column(db.String(255)) + # 库位 + warehouse_location = db.Column(db.String(100)) + # 调整类型:'profit' 盘盈 / 'loss' 盘亏 + adjust_type = db.Column(db.String(20), nullable=False) + # 调整数量(绝对值) + adjust_quantity = db.Column(db.Numeric(19, 4), nullable=False) + # 原因说明(必填) + reason = db.Column(db.String(500), nullable=False) + # 状态:'pending' 待处理 / 'completed' 已完成 / 'cancelled' 已取消 + status = db.Column(db.String(20), default='pending') + # 操作人/经办人 + operator = db.Column(db.String(100)) + # 创建时间 + create_time = db.Column(db.DateTime, default=beijing_time) + # 更新时间 + update_time = db.Column(db.DateTime, default=beijing_time, onupdate=beijing_time) + + def to_dict(self): + return { + 'id': self.id, + 'order_no': self.order_no, + 'base_id': self.base_id, + 'stock_id': self.stock_id, + 'source_table': self.source_table, + 'sku': self.sku, + 'material_name': self.material_name, + 'spec_model': self.spec_model, + 'warehouse_location': self.warehouse_location, + 'adjust_type': self.adjust_type, + 'adjust_quantity': float(self.adjust_quantity or 0), + 'reason': self.reason, + 'status': self.status, + 'operator': self.operator, + 'create_time': self.create_time.strftime('%Y-%m-%d %H:%M:%S') if self.create_time else None, + 'update_time': self.update_time.strftime('%Y-%m-%d %H:%M:%S') if self.update_time else None, + } diff --git a/inventory-web/src/router/index.ts b/inventory-web/src/router/index.ts index c0ee141..43a9f16 100644 --- a/inventory-web/src/router/index.ts +++ b/inventory-web/src/router/index.ts @@ -99,6 +99,13 @@ const routes: Array = [ name: 'InventoryStocktake', component: () => import('@/views/stock/stocktake/index.vue'), meta: { title: '库存盘点' } + }, + // ★ [新增] 盘盈盘亏管理页面 + { + path: 'adjustment', + name: 'StockAdjustment', + component: () => import('@/views/stock/adjustment/index.vue'), + meta: { title: '盘盈盘亏管理' } } ] }, diff --git a/inventory-web/src/views/stock/adjustment/index.vue b/inventory-web/src/views/stock/adjustment/index.vue new file mode 100644 index 0000000..7a55712 --- /dev/null +++ b/inventory-web/src/views/stock/adjustment/index.vue @@ -0,0 +1,307 @@ + + + + + diff --git a/stock_adjustment.sql b/stock_adjustment.sql new file mode 100644 index 0000000..27654a9 --- /dev/null +++ b/stock_adjustment.sql @@ -0,0 +1,25 @@ +-- 盘盈盘亏调整表 +-- 用于记录财务/主管手动发起的库存修正 +CREATE TABLE IF NOT EXISTS `stock_adjustment` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `order_no` VARCHAR(50) NOT NULL COMMENT '单号,如 ADJ-YYYYMMDD-XXXX', + `base_id` INT DEFAULT NULL COMMENT '关联物料基础表', + `stock_id` INT DEFAULT NULL COMMENT '关联具体库存行ID', + `source_table` VARCHAR(50) DEFAULT NULL COMMENT '库存类型 (stock_buy/stock_semi/stock_product)', + `sku` VARCHAR(100) DEFAULT NULL COMMENT '物料SKU', + `material_name` VARCHAR(255) DEFAULT NULL COMMENT '物料名称', + `spec_model` VARCHAR(255) DEFAULT NULL COMMENT '规格型号', + `warehouse_location` VARCHAR(100) DEFAULT NULL COMMENT '库位', + `adjust_type` VARCHAR(20) NOT NULL COMMENT '调整类型:profit 盘盈 / loss 盘亏', + `adjust_quantity` DECIMAL(19,4) NOT NULL COMMENT '调整数量(绝对值)', + `reason` VARCHAR(500) NOT NULL COMMENT '原因说明(必填)', + `status` VARCHAR(20) DEFAULT 'pending' COMMENT '状态:pending 待处理 / completed 已完成 / cancelled 已取消', + `operator` VARCHAR(100) DEFAULT NULL COMMENT '操作人/经办人', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_order_no` (`order_no`), + KEY `idx_create_time` (`create_time`), + KEY `idx_adjust_type` (`adjust_type`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='盘盈盘亏调整表';