From 06ba2d75630523fb5d7f8ccf2fbb5a9f31c35d36 Mon Sep 17 00:00:00 2001 From: dxc Date: Thu, 29 Jan 2026 09:27:56 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=87=E8=B4=AD=E4=BB=B6=EF=BC=8C=E5=8D=8A?= =?UTF-8?q?=E6=88=90=E5=93=81=EF=BC=8C=E4=BA=A7=E5=93=81=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E5=88=9D=E6=AD=A5=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- inventory-backend/app/__init__.py | 26 +- .../app/api/v1/inbound/__init__.py | 18 +- inventory-backend/app/api/v1/inbound/base.py | 111 +++--- .../app/api/v1/inbound/product.py | 50 +++ inventory-backend/app/models/inbound/buy.py | 71 ++-- .../app/models/inbound/product.py | 102 ++++++ inventory-backend/app/models/inbound/semi.py | 94 ++--- inventory-backend/app/models/material.py | 54 +-- .../app/services/inbound/product_service.py | 173 +++++++++ inventory-web/src/api/inbound/product.ts | 42 +++ .../src/views/stock/inbound/product.vue | 339 +++++++++++++++++- 11 files changed, 836 insertions(+), 244 deletions(-) create mode 100644 inventory-backend/app/models/inbound/product.py diff --git a/inventory-backend/app/__init__.py b/inventory-backend/app/__init__.py index b08a057..b2a08f0 100644 --- a/inventory-backend/app/__init__.py +++ b/inventory-backend/app/__init__.py @@ -1,4 +1,5 @@ # 文件路径: inventory-backend/app/__init__.py + from flask import Flask from config import Config from app.extensions import db, migrate, cors @@ -22,15 +23,18 @@ def create_app(): # 注册入库聚合模块 (Inbound) try: # 指向聚合文件: app/api/v1/inbound/__init__.py + # 该文件里应该包含了 buy, semi, base, product 的聚合逻辑 from app.api.v1.inbound import inbound_bp # 注册父蓝图,路由前缀为 /api/v1/inbound # 最终路由效果: - # /api/v1/inbound + /buy/list -> /api/v1/inbound/buy/list - # /api/v1/inbound + /semi/list -> /api/v1/inbound/semi/list + # /api/v1/inbound + /buy/list -> /api/v1/inbound/buy/list + # /api/v1/inbound + /semi/list -> /api/v1/inbound/semi/list + # /api/v1/inbound + /product/list -> /api/v1/inbound/product/list + # /api/v1/inbound + /base/search -> /api/v1/inbound/base/search app.register_blueprint(inbound_bp, url_prefix='/api/v1/inbound') - print("✅ Inbound (Buy & Semi) 模块注册成功") + print("✅ Inbound (Buy, Semi, Product, Base) 模块注册成功") except ImportError as e: print(f"❌ 错误: Inbound 模块导入失败: {e}") @@ -40,14 +44,22 @@ def create_app(): # ========================================================= with app.app_context(): try: - # ✅ 修正点:引用新路径 (不再引用 app.models.stock) - from app.models.inbound.buy import StockBuy - from app.models.inbound.semi import StockSemi + # 1. 基础物料 from app.models.material import MaterialBase + # 2. 采购入库 + from app.models.inbound.buy import StockBuy + # 3. 半成品入库 + from app.models.inbound.semi import StockSemi + # 4. 成品入库 (新增) + from app.models.inbound.product import StockProduct - # 如果是开发环境且没有迁移文件,可以取消注释下面这行来创建表 + # 开发环境如果需要自动建表,可以取消注释 # db.create_all() + except ImportError as e: + # 建议打印错误,防止因为文件名拼写错误导致静默失败 print(f"⚠️ 模型预加载失败: {e}") + except Exception as e: + print(f"⚠️ 模型预加载发生未知错误: {e}") return app \ No newline at end of file diff --git a/inventory-backend/app/api/v1/inbound/__init__.py b/inventory-backend/app/api/v1/inbound/__init__.py index a2c8d31..c14f385 100644 --- a/inventory-backend/app/api/v1/inbound/__init__.py +++ b/inventory-backend/app/api/v1/inbound/__init__.py @@ -1,22 +1,14 @@ from flask import Blueprint - -# 1. 导入子模块蓝图 -# 注意:确保 .buy, .semi, .base 文件在同级目录下真实存在 from .buy import inbound_buy_bp from .semi import inbound_semi_bp - -# 如果你还有 base.py 文件,就取消注释下面这行 from .base import inbound_base_bp +# 导入 product +from .product import inbound_product_bp -# 2. 创建父级聚合蓝图 inbound_bp = Blueprint('inbound', __name__) -# 3. 挂载子蓝图 -# 访问地址: /api/v1/inbound/buy/list inbound_bp.register_blueprint(inbound_buy_bp, url_prefix='/buy') - -# 访问地址: /api/v1/inbound/semi/list inbound_bp.register_blueprint(inbound_semi_bp, url_prefix='/semi') - -# 如果有 base -inbound_bp.register_blueprint(inbound_base_bp, url_prefix='/base') \ No newline at end of file +inbound_bp.register_blueprint(inbound_base_bp, url_prefix='/base') +# 挂载 product,前缀改为 /product +inbound_bp.register_blueprint(inbound_product_bp, url_prefix='/product') \ No newline at end of file diff --git a/inventory-backend/app/api/v1/inbound/base.py b/inventory-backend/app/api/v1/inbound/base.py index 6eba4d9..eaf4b34 100644 --- a/inventory-backend/app/api/v1/inbound/base.py +++ b/inventory-backend/app/api/v1/inbound/base.py @@ -1,51 +1,55 @@ +# 文件路径: app/api/v1/inbound/base.py from flask import Blueprint, request, jsonify -# 修改为这一行,指向 app/services/inbound/base_service.py from app.services.inbound.base_service import MaterialBaseService import traceback -# 定义蓝图 -# name='inbound_base' 确保全局唯一,防止和其他蓝图重名 inbound_base_bp = Blueprint('inbound_base', __name__) -# ------------------------------------------------------------------ -# 1. 获取基础信息列表 (GET) -# 路由: /api/v1/inbound/base/list -# ------------------------------------------------------------------ -@inbound_base_bp.route('/list', methods=['GET']) -def get_list(): +# ============================================================================== +# 1. 搜索接口 (GET /api/v1/inbound/base/search) +# ============================================================================== +@inbound_base_bp.route('/search', methods=['GET']) +def search_base(): try: - # 获取分页参数 - page = request.args.get('pageNum', 1, type=int) - limit = request.args.get('pageSize', 10, type=int) - - # 获取筛选参数 - filters = { - "keyword": request.args.get('keyword'), - "category": request.args.get('category'), - "type": request.args.get('type'), - "isEnabled": request.args.get('isEnabled') - } - - # 调用 Service 层逻辑 - result = MaterialBaseService.get_list(page, limit, filters) - - return jsonify({ - "code": 200, - "msg": "success", - "data": result - }) + keyword = request.args.get('keyword', '') + data = MaterialBaseService.search_material(keyword) + return jsonify({"code": 200, "msg": "success", "data": data}) except Exception as e: traceback.print_exc() return jsonify({"code": 500, "msg": str(e)}), 500 -# ------------------------------------------------------------------ -# 2. 新增基础信息 (POST) -# 路由: /api/v1/inbound/base/ -# ------------------------------------------------------------------ +# ============================================================================== +# 2. 列表接口 (GET /api/v1/inbound/base/list) +# ============================================================================== +@inbound_base_bp.route('/list', methods=['GET']) +def get_list(): + try: + page = request.args.get('pageNum', 1, type=int) # 前端传的是 pageNum + limit = request.args.get('pageSize', 10, type=int) + + # 构造筛选条件 + filters = { + 'keyword': request.args.get('keyword', ''), + 'category': request.args.get('category', ''), + 'type': request.args.get('type', ''), + 'isEnabled': request.args.get('isEnabled', None) + } + + result = MaterialBaseService.get_list(page, limit, filters) + return jsonify({"code": 200, "msg": "success", "data": result}) + except Exception as e: + traceback.print_exc() + return jsonify({"code": 500, "msg": str(e)}), 500 + + +# ============================================================================== +# 3. 新增接口 (POST /api/v1/inbound/base/) +# 注意:前端 material_base.ts 可能会请求 / 或 /add,这里统一匹配 +# ============================================================================== @inbound_base_bp.route('/', methods=['POST']) -def add_material(): +def create(): try: data = request.get_json() if not data: @@ -53,44 +57,37 @@ def add_material(): MaterialBaseService.create_material(data) return jsonify({"code": 200, "msg": "新增成功"}) - except ValueError as ve: - # 捕获业务逻辑验证错误(如名称重复) - return jsonify({"code": 400, "msg": str(ve)}), 400 + except ValueError as e: + # 捕获业务逻辑验证错误 (如名称为空) + return jsonify({"code": 400, "msg": str(e)}), 400 except Exception as e: + # 捕获系统错误 traceback.print_exc() - return jsonify({"code": 500, "msg": "系统错误"}), 500 + return jsonify({"code": 500, "msg": f"系统错误: {str(e)}"}), 500 -# ------------------------------------------------------------------ -# 3. 修改基础信息 (PUT) -# 路由: /api/v1/inbound/base/ -# ------------------------------------------------------------------ -@inbound_base_bp.route('/', methods=['PUT']) -def update_material(): +# ============================================================================== +# 4. 修改接口 (PUT /api/v1/inbound/base/) +# ============================================================================== +@inbound_base_bp.route('/', methods=['PUT']) +def update(id): try: data = request.get_json() - if not data or not data.get('id'): - return jsonify({"code": 400, "msg": "ID不能为空"}), 400 - - MaterialBaseService.update_material(data.get('id'), data) - return jsonify({"code": 200, "msg": "更新成功"}) + MaterialBaseService.update_material(id, data) + return jsonify({"code": 200, "msg": "修改成功"}) except Exception as e: traceback.print_exc() return jsonify({"code": 500, "msg": str(e)}), 500 -# ------------------------------------------------------------------ -# 4. 删除基础信息 (DELETE) -# 路由: /api/v1/inbound/base/ -# ------------------------------------------------------------------ +# ============================================================================== +# 5. 删除接口 (DELETE /api/v1/inbound/base/) +# ============================================================================== @inbound_base_bp.route('/', methods=['DELETE']) -def delete_material(id): +def delete(id): try: MaterialBaseService.delete_material(id) return jsonify({"code": 200, "msg": "删除成功"}) - except ValueError as ve: - # 捕获依赖检查错误(如已被库存引用) - return jsonify({"code": 400, "msg": str(ve)}), 400 except Exception as e: traceback.print_exc() return jsonify({"code": 500, "msg": str(e)}), 500 \ No newline at end of file diff --git a/inventory-backend/app/api/v1/inbound/product.py b/inventory-backend/app/api/v1/inbound/product.py index e69de29..fd54ae1 100644 --- a/inventory-backend/app/api/v1/inbound/product.py +++ b/inventory-backend/app/api/v1/inbound/product.py @@ -0,0 +1,50 @@ +from flask import Blueprint, request, jsonify +# 引用更名后的服务 +from app.services.inbound.product_service import ProductInboundService +import traceback + +# 蓝图命名改为 inbound_product_bp +inbound_product_bp = Blueprint('inbound_product', __name__) + +@inbound_product_bp.route('/search-base', methods=['GET']) +def search_base(): + try: + data = ProductInboundService.search_base_material(request.args.get('keyword', '')) + return jsonify({"code": 200, "msg": "success", "data": data}) + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}), 500 + +@inbound_product_bp.route('/list', methods=['GET']) +def get_list(): + try: + page = request.args.get('page', 1, type=int) + limit = request.args.get('pageSize', 15, type=int) + keyword = request.args.get('keyword', '') + result = ProductInboundService.get_list(page, limit, keyword) + return jsonify({"code": 200, "msg": "success", "data": result}) + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}), 500 + +@inbound_product_bp.route('/submit', methods=['POST']) +def submit(): + try: + ProductInboundService.handle_inbound(request.get_json()) + return jsonify({"code": 200, "msg": "入库成功"}) + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}), 500 + +@inbound_product_bp.route('/', methods=['PUT']) +def update(id): + try: + ProductInboundService.update_inbound(id, request.get_json()) + return jsonify({"code": 200, "msg": "更新成功"}) + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}), 500 + +@inbound_product_bp.route('/', methods=['DELETE']) +def delete(id): + try: + ProductInboundService.delete_inbound(id) + return jsonify({"code": 200, "msg": "删除成功"}) + except Exception as e: + return jsonify({"code": 500, "msg": str(e)}), 500 \ No newline at end of file diff --git a/inventory-backend/app/models/inbound/buy.py b/inventory-backend/app/models/inbound/buy.py index d1c6ad0..bd5fc66 100644 --- a/inventory-backend/app/models/inbound/buy.py +++ b/inventory-backend/app/models/inbound/buy.py @@ -1,6 +1,5 @@ -# app/models/buy.py +# app/models/inbound/buy.py from app.extensions import db -from datetime import datetime class StockBuy(db.Model): @@ -10,74 +9,54 @@ class StockBuy(db.Model): """ __tablename__ = 'stock_buy' - # 主键 id = db.Column(db.Integer, primary_key=True) - - # 【核心关联】外键关联 material_base 表 base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False) - # --- 身份标识 --- + # 身份标识 sku = db.Column(db.String(100)) in_date = db.Column(db.Date) - barcode = db.Column(db.String(100)) # 条码 - serial_number = db.Column(db.String(100)) # 序列号 - batch_number = db.Column(db.String(100)) # 批号 + barcode = db.Column(db.String(100)) + serial_number = db.Column(db.String(100)) + batch_number = db.Column(db.String(100)) - # --- 数量 --- + # 状态 + status = db.Column(db.String(50)) + inspection_status = db.Column(db.String(50)) + warehouse_location = db.Column(db.String(100)) + + # 数量 in_quantity = db.Column(db.Numeric(19, 4), default=0) stock_quantity = db.Column(db.Numeric(19, 4), default=0) available_quantity = db.Column(db.Numeric(19, 4), default=0) - # --- 状态与位置 --- - status = db.Column(db.String(50)) # 在库/出库/损耗 - inspection_status = db.Column(db.String(50)) # 未检/合格/不合格 - warehouse_location = db.Column(db.String(100)) - - # --- 财务与商务 --- + # 财务与商务 unit_price = db.Column(db.Numeric(19, 4), default=0) total_price = db.Column(db.Numeric(19, 4), default=0) currency = db.Column(db.String(20), default='CNY') exchange_rate = db.Column(db.Numeric(15, 6), default=1.0) supplier_name = db.Column(db.String(255)) - - # [关键映射区]:Python属性名 = DB列名 - # 前端传 purchaser -> 存入 buyer_name - buyer_name = db.Column(db.String(100)) - - # 前端传 purchaser_email -> 存入 buyer_email - buyer_email = db.Column(db.String(100)) - - # 前端传 source_link -> 存入 original_link - original_link = db.Column(db.Text) - + buyer_name = db.Column(db.String(100)) # 对应 SQL: buyer_name + buyer_email = db.Column(db.String(100)) # 对应 SQL: buyer_email + original_link = db.Column(db.Text) # 对应 SQL: original_link detail_link = db.Column(db.Text) arrival_photo = db.Column(db.Text) - # [这就是报错缺失的字段],请确保执行了 ALTER TABLE - remark = db.Column(db.Text) + # 注意:SQL 中没有 remark 字段,这里已移除 - # 【关系定义】 - # 建立与 MaterialBase 的关系,方便通过 stock.material 访问基础信息 + # 关系定义 material = db.relationship('MaterialBase', back_populates='stock_buys') def to_dict(self): - """ - 序列化:将模型转换为字典,主要用于单条查询或内部调用 - 列表查询主要依赖 Service 层的手动构建以提高性能 - """ return { 'id': self.id, 'base_id': self.base_id, - - # 级联基础信息 (防止 None 报错) 'material_name': self.material.name if self.material else '', 'spec_model': self.material.spec_model if self.material else '', 'category': self.material.category if self.material else '', 'unit': self.material.unit if self.material else '', 'material_type': self.material.material_type if self.material else '', - # 实体信息 'sku': self.sku, 'inbound_date': self.in_date.strftime('%Y-%m-%d') if self.in_date else '', 'barcode': self.barcode, @@ -86,27 +65,23 @@ class StockBuy(db.Model): 'warehouse_loc': self.warehouse_location, 'status': self.status, 'inspection_status': self.inspection_status, - 'remark': self.remark, - # 数量 (转为float防止json序列化报错) 'in_quantity': float(self.in_quantity or 0), - 'qty_inbound': float(self.in_quantity or 0), # 兼容字段 + 'qty_inbound': float(self.in_quantity or 0), 'stock_quantity': float(self.stock_quantity or 0), - 'qty_stock': float(self.stock_quantity or 0), # 兼容字段 + 'qty_stock': float(self.stock_quantity or 0), 'available_quantity': float(self.available_quantity or 0), - 'qty_available': float(self.available_quantity or 0), # 兼容字段 + 'qty_available': float(self.available_quantity or 0), - # 财务 'unit_price': float(self.unit_price or 0), 'total_price': float(self.total_price or 0), 'currency': self.currency, 'exchange_rate': float(self.exchange_rate or 1.0), - # 商务 (字段映射) 'supplier_name': self.supplier_name, - 'purchaser': self.buyer_name, # 映射回前端 - 'purchaser_email': self.buyer_email, # 映射回前端 - 'source_link': self.original_link, # 映射回前端 + 'purchaser': self.buyer_name, + 'purchaser_email': self.buyer_email, + 'source_link': self.original_link, 'detail_link': self.detail_link, 'arrival_photo': self.arrival_photo } \ No newline at end of file diff --git a/inventory-backend/app/models/inbound/product.py b/inventory-backend/app/models/inbound/product.py new file mode 100644 index 0000000..a5d386d --- /dev/null +++ b/inventory-backend/app/models/inbound/product.py @@ -0,0 +1,102 @@ +# app/models/inbound/product.py +from app.extensions import db + + +class StockProduct(db.Model): + """ + 成品入库库存表 + 对应数据库表: stock_product + """ + __tablename__ = 'stock_product' + + id = db.Column(db.Integer, primary_key=True) + base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False) + + sku = db.Column(db.String(100)) + production_date = db.Column(db.Date) + barcode = db.Column(db.String(100)) + serial_number = db.Column(db.String(100)) + # SQL 无 batch_number + + # 数量 + in_quantity = db.Column(db.Numeric(19, 4), default=0) + stock_quantity = db.Column(db.Numeric(19, 4), default=0) + available_quantity = db.Column(db.Numeric(19, 4), default=0) + + # 状态与位置 + status = db.Column(db.String(50)) + warehouse_location = db.Column(db.String(100)) + + # 生产与成本 + bom_code = db.Column('bom_id', db.String(100)) # 映射 SQL: bom_id + bom_version = db.Column(db.String(50)) + work_order_code = db.Column('work_order_id', db.String(100)) # 映射 SQL: work_order_id + + raw_material_cost = db.Column(db.Numeric(19, 4), default=0) + manual_cost = db.Column(db.Numeric(19, 4), default=0) + + production_manager = db.Column('producer_name', db.String(100)) # 映射 SQL: producer_name + production_time_range = db.Column(db.String(255)) + + # 质量与链接 + quality_status = db.Column(db.String(50)) + quality_report_link = db.Column(db.Text) + detail_link = db.Column(db.Text) + + # 成品特有字段 + sale_price = db.Column(db.Numeric(19, 4), default=0) + inspection_report_link = db.Column(db.Text) + order_id = db.Column(db.String(100)) + + # 关系定义 + material = db.relationship('MaterialBase', back_populates='stock_products') + + def to_dict(self): + raw_val = float(self.raw_material_cost or 0) + man_val = float(self.manual_cost or 0) + unit_total = raw_val + man_val + + return { + 'id': self.id, + 'base_id': self.base_id, + 'material_name': self.material.name if self.material else '', + 'spec_model': self.material.spec_model if self.material else '', + 'category': self.material.category if self.material else '', + 'unit': self.material.unit if self.material else '', + 'material_type': self.material.material_type if self.material else '', + + 'sku': self.sku, + 'inbound_date': self.production_date.strftime('%Y-%m-%d') if self.production_date else '', + 'barcode': self.barcode, + 'serial_number': self.serial_number, + 'warehouse_loc': self.warehouse_location, + 'status': self.status, + + 'in_quantity': float(self.in_quantity or 0), + 'qty_inbound': float(self.in_quantity or 0), + 'stock_quantity': float(self.stock_quantity or 0), + 'qty_stock': float(self.stock_quantity or 0), + 'available_quantity': float(self.available_quantity or 0), + 'qty_available': float(self.available_quantity or 0), + + 'bom_code': self.bom_code, + 'bom_version': self.bom_version, + 'work_order_code': self.work_order_code, + 'raw_material_cost': raw_val, + 'manual_cost': man_val, + 'unit_total_cost': unit_total, + 'production_manager': self.production_manager, + 'production_time_range': self.production_time_range, + 'production_start_time': self.production_time_range.split(' ~ ')[ + 0] if self.production_time_range and ' ~ ' in self.production_time_range else '', + 'production_end_time': self.production_time_range.split(' ~ ')[ + 1] if self.production_time_range and ' ~ ' in self.production_time_range else '', + + 'quality_status': self.quality_status, + 'quality_report_link': self.quality_report_link, + 'detail_link': self.detail_link, + + 'sale_price': float(self.sale_price or 0), + 'inspection_report_link': self.inspection_report_link, + 'order_id': self.order_id + } \ No newline at end of file diff --git a/inventory-backend/app/models/inbound/semi.py b/inventory-backend/app/models/inbound/semi.py index 9d8ad1b..7dd138f 100644 --- a/inventory-backend/app/models/inbound/semi.py +++ b/inventory-backend/app/models/inbound/semi.py @@ -1,6 +1,5 @@ # app/models/inbound/semi.py from app.extensions import db -from datetime import datetime class StockSemi(db.Model): @@ -10,80 +9,43 @@ class StockSemi(db.Model): """ __tablename__ = 'stock_semi' - # ========================================================= - # 1. 基础字段 (Strictly matching SQL Schema) - # ========================================================= - - # 主键 id = db.Column(db.Integer, primary_key=True) - - # 外键关联 material_base 表 base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False) - # 身份标识 sku = db.Column(db.String(100)) - - # SQL字段名为 production_date, 对应前端的 "入库日期/生产日期" production_date = db.Column(db.Date) + barcode = db.Column(db.String(100)) + serial_number = db.Column(db.String(100)) + # SQL 无 batch_number,此处移除 - barcode = db.Column(db.String(100)) # 条码 - serial_number = db.Column(db.String(100)) # 序列号 - - # 注意:提供的 SQL 中 stock_semi 没有 batch_number 字段,这里不定义,以免报错。 - # 如果后续数据库加上了该字段,请取消下方注释: - # batch_number = db.Column(db.String(100)) - - # --- 数量 --- + # 数量 in_quantity = db.Column(db.Numeric(19, 4), default=0) stock_quantity = db.Column(db.Numeric(19, 4), default=0) available_quantity = db.Column(db.Numeric(19, 4), default=0) - # --- 状态与位置 --- - status = db.Column(db.String(50)) # 在库/出库/损耗 - warehouse_location = db.Column(db.String(100)) # 仓库位 + # 状态与位置 + status = db.Column(db.String(50)) + warehouse_location = db.Column(db.String(100)) - # ========================================================= - # 2. 半成品特有字段 (SQL 字段映射) - # ========================================================= - - # BOM 相关 - # 数据库列名: bom_id, Python属性: bom_code (为了适配前端习惯) - bom_code = db.Column('bom_id', db.String(100)) + # 半成品特有字段 (SQL 字段映射) + bom_code = db.Column('bom_id', db.String(100)) # 映射 SQL: bom_id bom_version = db.Column(db.String(50)) + work_order_code = db.Column('work_order_id', db.String(100)) # 映射 SQL: work_order_id - # 工单 相关 - # 数据库列名: work_order_id, Python属性: work_order_code - work_order_code = db.Column('work_order_id', db.String(100)) + raw_material_cost = db.Column(db.Numeric(19, 4), default=0) + manual_cost = db.Column(db.Numeric(19, 4), default=0) - # 成本 相关 - raw_material_cost = db.Column(db.Numeric(19, 4), default=0) # 原材料成本 - manual_cost = db.Column(db.Numeric(19, 4), default=0) # 手动/人工成本 - - # 生产信息 - # 数据库列名: producer_name, Python属性: production_manager - production_manager = db.Column('producer_name', db.String(100)) - - # 生产起止时间 (SQL定义为 VARCHAR(255)) + production_manager = db.Column('producer_name', db.String(100)) # 映射 SQL: producer_name production_time_range = db.Column(db.String(255)) - # 质量与链接 - quality_status = db.Column(db.String(50)) # 质量状态 - quality_report_link = db.Column(db.Text) # 质量报告链接 - detail_link = db.Column(db.Text) # 详细信息链接 + quality_status = db.Column(db.String(50)) + quality_report_link = db.Column(db.Text) + detail_link = db.Column(db.Text) - # ========================================================= - # 3. 关系定义 - # ========================================================= - # 建立与 MaterialBase 的关系 - # 注意:确保 MaterialBase 模型中定义了 back_populates='stock_semis' + # 关系定义 material = db.relationship('MaterialBase', back_populates='stock_semis') def to_dict(self): - """ - 序列化:将模型转换为字典,供API返回JSON使用 - 在这里处理字段名称转换,确保前端能正确显示数据 - """ - # 计算单件总成本 (原料 + 人工) raw_val = float(self.raw_material_cost or 0) man_val = float(self.manual_cost or 0) unit_total = raw_val + man_val @@ -91,47 +53,35 @@ class StockSemi(db.Model): return { 'id': self.id, 'base_id': self.base_id, - - # --- 级联基础信息 (防止 None 报错) --- 'material_name': self.material.name if self.material else '', 'spec_model': self.material.spec_model if self.material else '', 'category': self.material.category if self.material else '', 'unit': self.material.unit if self.material else '', 'material_type': self.material.material_type if self.material else '', - # --- 实体信息 --- 'sku': self.sku, - # 将 production_date 映射回前端通用的 inbound_date 'inbound_date': self.production_date.strftime('%Y-%m-%d') if self.production_date else '', 'barcode': self.barcode, 'serial_number': self.serial_number, - # 'batch_number': self.batch_number, # SQL无此字段,暂不返回 'warehouse_loc': self.warehouse_location, 'status': self.status, - # --- 数量 (转为float防止json序列化报错) --- 'in_quantity': float(self.in_quantity or 0), - 'qty_inbound': float(self.in_quantity or 0), # 兼容字段 + 'qty_inbound': float(self.in_quantity or 0), 'stock_quantity': float(self.stock_quantity or 0), - 'qty_stock': float(self.stock_quantity or 0), # 兼容字段 + 'qty_stock': float(self.stock_quantity or 0), 'available_quantity': float(self.available_quantity or 0), - 'qty_available': float(self.available_quantity or 0), # 兼容字段 + 'qty_available': float(self.available_quantity or 0), - # --- 半成品特有数据 --- 'bom_code': self.bom_code, 'bom_version': self.bom_version, 'work_order_code': self.work_order_code, - 'raw_material_cost': raw_val, 'manual_cost': man_val, - 'unit_total_cost': unit_total, # 前端展示总成本用 - + 'unit_total_cost': unit_total, 'production_manager': self.production_manager, - - # 时间范围 (SQL存的是字符串,直接返回即可,或者根据需要拆分) - # 如果 service 层存的是 "Start ~ End",这里直接返回 'production_time_range': self.production_time_range, - # 为了兼容前端分开的时间字段(如果有): + # 简单的时间拆分逻辑,防止 split 报错 'production_start_time': self.production_time_range.split(' ~ ')[ 0] if self.production_time_range and ' ~ ' in self.production_time_range else '', 'production_end_time': self.production_time_range.split(' ~ ')[ diff --git a/inventory-backend/app/models/material.py b/inventory-backend/app/models/material.py index 17f6aa8..4facd17 100644 --- a/inventory-backend/app/models/material.py +++ b/inventory-backend/app/models/material.py @@ -1,7 +1,5 @@ # app/models/material.py from app.extensions import db -from datetime import datetime - class MaterialBase(db.Model): """ @@ -10,78 +8,50 @@ class MaterialBase(db.Model): """ __tablename__ = 'material_base' - # 1. 基础字段 (保持不变) + # 1. 基础字段 id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(255), nullable=False, comment='基础信息名称') - - # 类别 (对应 SQL: category) category = db.Column(db.String(100), comment='类别') - - # 类型 (对应 SQL: material_type) -> 前端 prop="type" material_type = db.Column(db.String(100), comment='类型') - - # 规格型号 (对应 SQL: spec_model) -> 前端 prop="spec" spec_model = db.Column(db.String(255), comment='规格型号') - unit = db.Column(db.String(50), comment='计量单位') # 可见等级 visibility_level = db.Column(db.Integer, default=0, comment='信息可见等级') # 链接与图片 - manual_link = db.Column(db.Text, comment='通用说明书链接') - product_image = db.Column(db.Text, comment='通用产品图链接') + manual_link = db.Column(db.Text, comment='通用说明书') + product_image = db.Column(db.Text, comment='通用产品图') # 启用状态 is_enabled = db.Column(db.Boolean, default=True, comment='是否启用') # ============================================================ - # 时间字段 (保持你原本的注释状态,以免报错) - # ============================================================ - # create_time = db.Column(db.DateTime, default=datetime.utcnow) - # update_time = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - - # ============================================================ - # 关联关系区域 (修改重点) + # 关联关系区域 # ============================================================ - # 1. 关联采购库存 (StockBuy) - 保持不变 - # 注意:确保 app/models/inbound/buy.py 中的 StockBuy 定义了 back_populates='material' + # 1. 关联采购库存 (StockBuy) stock_buys = db.relationship('StockBuy', back_populates='material', lazy='dynamic') - # 2. 【新增】关联半成品库存 (StockSemi) - # 注意:确保 app/models/inbound/semi.py 中的 StockSemi 定义了 back_populates='material' - # 这样以后可以通过 material.stock_semis 来访问该物料下的所有半成品库存记录 + # 2. 关联半成品库存 (StockSemi) stock_semis = db.relationship('StockSemi', back_populates='material', lazy='dynamic') + # 3. 关联成品库存 (StockProduct) + stock_products = db.relationship('StockProduct', back_populates='material', lazy='dynamic') + def to_dict(self): """ - 序列化方法:将模型转换为字典,供API返回JSON使用 + 序列化方法 """ return { 'id': self.id, 'name': self.name, 'category': self.category, - - # ========================================= - # 关键映射区 (保持不变) - # ========================================= - # 数据库叫 material_type -> 前端叫 type - 'type': self.material_type, - - # 数据库叫 spec_model -> 前端叫 spec - 'spec': self.spec_model, - + 'type': self.material_type, # 前端字段映射 + 'spec': self.spec_model, # 前端字段映射 'unit': self.unit, - - # 驼峰命名适配 'visibilityLevel': self.visibility_level, 'generalManual': self.manual_link, 'generalImage': self.product_image, - - # 状态处理 'isEnabled': 1 if self.is_enabled else 0, - - # 时间字段保持注释 - # 'createTime': self.create_time.strftime('%Y-%m-%d %H:%M:%S') if hasattr(self, 'create_time') and self.create_time else None } \ No newline at end of file diff --git a/inventory-backend/app/services/inbound/product_service.py b/inventory-backend/app/services/inbound/product_service.py index e69de29..092abb0 100644 --- a/inventory-backend/app/services/inbound/product_service.py +++ b/inventory-backend/app/services/inbound/product_service.py @@ -0,0 +1,173 @@ +from app.extensions import db +from app.models.material import MaterialBase +# 引用新的 product 路径 +from app.models.inbound.product import StockProduct +from datetime import datetime +from sqlalchemy import or_, func +import traceback + + +class ProductInboundService: + @staticmethod + def search_base_material(keyword): + try: + if not keyword: return [] + query = MaterialBase.query.filter( + MaterialBase.is_enabled == True, + or_(MaterialBase.name.ilike(f'%{keyword}%'), MaterialBase.spec_model.ilike(f'%{keyword}%')) + ).limit(20) + results = [] + for item in query.all(): + results.append({ + 'id': item.id, 'name': item.name, 'spec': item.spec_model, + 'category': item.category, 'unit': item.unit, 'type': item.material_type + }) + return results + except: + return [] + + @staticmethod + def handle_inbound(data): + try: + base_id = data.get('base_id') + if not base_id: raise ValueError("必须选择基础物料") + material = MaterialBase.query.get(base_id) + if not material: raise ValueError("物料不存在") + + in_date_val = datetime.utcnow().date() + if data.get('in_date'): + try: + in_date_val = datetime.strptime(str(data['in_date'])[:10], '%Y-%m-%d').date() + except: + pass + + in_qty = float(data.get('in_quantity') or 0) + + p_start = data.get('production_start_time', '') + p_end = data.get('production_end_time', '') + time_range = f"{p_start} ~ {p_end}" if p_start or p_end else None + + new_stock = StockProduct( + base_id=material.id, + sku=data.get('sku'), + production_date=in_date_val, + barcode=data.get('barcode'), + serial_number=data.get('serial_number'), + + status='在库', + warehouse_location=data.get('warehouse_location'), + + in_quantity=in_qty, + stock_quantity=in_qty, + available_quantity=in_qty, + + bom_code=data.get('bom_code'), + bom_version=data.get('bom_version'), + work_order_code=data.get('work_order_code'), + production_manager=data.get('production_manager'), + production_time_range=time_range, + + raw_material_cost=float(data.get('raw_material_cost') or 0), + manual_cost=float(data.get('manual_cost') or 0), + + quality_status=data.get('quality_status', '合格'), + quality_report_link=data.get('quality_report_link'), + detail_link=data.get('detail_link'), + + sale_price=float(data.get('sale_price') or 0), + inspection_report_link=data.get('inspection_report_link'), + order_id=data.get('order_id') + ) + + db.session.add(new_stock) + db.session.commit() + return new_stock + except Exception as e: + db.session.rollback() + raise e + + @staticmethod + def update_inbound(stock_id, data): + try: + stock = StockProduct.query.get(stock_id) + if not stock: raise ValueError("记录不存在") + + fields = [ + 'sku', 'barcode', 'serial_number', 'warehouse_location', + 'status', 'quality_status', 'bom_code', 'bom_version', + 'work_order_code', 'production_manager', 'quality_report_link', + 'detail_link', 'inspection_report_link', 'order_id' + ] + for f in fields: + if f in data: setattr(stock, f, data[f]) + + if 'sale_price' in data: stock.sale_price = float(data['sale_price']) + if 'raw_material_cost' in data: stock.raw_material_cost = float(data['raw_material_cost']) + if 'manual_cost' in data: stock.manual_cost = float(data['manual_cost']) + + if 'in_quantity' in data: + new_qty = float(data['in_quantity']) + diff = new_qty - float(stock.in_quantity) + stock.in_quantity = new_qty + stock.stock_quantity = float(stock.stock_quantity) + diff + stock.available_quantity = float(stock.available_quantity) + diff + + if 'production_start_time' in data or 'production_end_time' in data: + old_range = stock.production_time_range or " ~ " + parts = old_range.split(' ~ ') + start = data.get('production_start_time', parts[0] if len(parts) > 0 else '') + end = data.get('production_end_time', parts[1] if len(parts) > 1 else '') + stock.production_time_range = f"{start} ~ {end}" + + db.session.commit() + return stock + except Exception as e: + db.session.rollback() + raise e + + @staticmethod + def delete_inbound(stock_id): + try: + stock = StockProduct.query.get(stock_id) + if stock: + db.session.delete(stock) + db.session.commit() + except Exception as e: + db.session.rollback() + raise e + + @staticmethod + def get_list(page, limit, keyword=None): + try: + query = db.session.query(StockProduct).outerjoin(MaterialBase, StockProduct.base_id == MaterialBase.id) + if keyword: + query = query.filter(or_( + MaterialBase.name.ilike(f'%{keyword}%'), + StockProduct.serial_number.ilike(f'%{keyword}%'), + StockProduct.work_order_code.ilike(f'%{keyword}%'), + StockProduct.order_id.ilike(f'%{keyword}%') + )) + pagination = query.order_by(StockProduct.id.desc()).paginate(page=page, per_page=limit, error_out=False) + + base_ids = list(set([i.base_id for i in pagination.items])) + stock_map = {} + if base_ids: + aggs = db.session.query( + StockProduct.base_id, + func.sum(StockProduct.stock_quantity).label('s'), + func.sum(StockProduct.available_quantity).label('a') + ).filter(StockProduct.base_id.in_(base_ids)).group_by(StockProduct.base_id).all() + for a in aggs: stock_map[a.base_id] = {'s': float(a.s or 0), 'a': float(a.a or 0)} + + items = [] + for item in pagination.items: + d = item.to_dict() + stats = stock_map.get(item.base_id, {'s': 0, 'a': 0}) + d['sum_stock'] = stats['s'] + d['sum_available'] = stats['a'] + items.append(d) + + return {"total": pagination.total, "items": items} + except: + traceback.print_exc() + return {"total": 0, "items": []} \ No newline at end of file diff --git a/inventory-web/src/api/inbound/product.ts b/inventory-web/src/api/inbound/product.ts index e69de29..b5dad64 100644 --- a/inventory-web/src/api/inbound/product.ts +++ b/inventory-web/src/api/inbound/product.ts @@ -0,0 +1,42 @@ +import request from '@/utils/request' + +// 注意 URL 已变为 /inbound/product/... + +export function getProductList(params: any) { + return request({ + url: '/inbound/product/list', + method: 'get', + params + }) +} + +export function createProductInbound(data: any) { + return request({ + url: '/inbound/product/submit', + method: 'post', + data + }) +} + +export function updateProductInbound(id: number, data: any) { + return request({ + url: `/inbound/product/${id}`, + method: 'put', + data + }) +} + +export function deleteProductInbound(id: number) { + return request({ + url: `/inbound/product/${id}`, + method: 'delete' + }) +} + +export function searchMaterialBase(keyword: string) { + return request({ + url: '/inbound/base/search', + method: 'get', + params: { keyword } + }) +} \ No newline at end of file diff --git a/inventory-web/src/views/stock/inbound/product.vue b/inventory-web/src/views/stock/inbound/product.vue index 96c0baf..a0b5e5f 100644 --- a/inventory-web/src/views/stock/inbound/product.vue +++ b/inventory-web/src/views/stock/inbound/product.vue @@ -1,11 +1,340 @@ - - - \ No newline at end of file