diff --git a/inventory-backend/app/api/v1/inbound/service.py b/inventory-backend/app/api/v1/inbound/service.py index 2736513..6244e0b 100644 --- a/inventory-backend/app/api/v1/inbound/service.py +++ b/inventory-backend/app/api/v1/inbound/service.py @@ -1,7 +1,7 @@ +# inventory-backend/app/api/v1/inbound/service.py from flask import request, jsonify, current_app from flask_jwt_extended import jwt_required from . import inbound_bp -from app.schemas.stock_schema import stock_service_schema from app.services.inbound.service_service import ServiceService from app.utils.decorators import role_required import traceback @@ -23,6 +23,7 @@ def search_base(): current_app.logger.error(f'搜索基础物料失败: {str(e)}') return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500 + @inbound_bp.route('/service', methods=['GET']) @jwt_required() def get_service_list(): @@ -62,20 +63,25 @@ def create_service(): data = request.get_json() if not data: return jsonify({'code': 400, 'msg': '请求数据为空'}), 400 - errors = stock_service_schema.validate(data) - if errors: - return jsonify({'code': 400, 'msg': '数据校验失败', 'errors': errors}), 400 + + # 基础校验 + if not data.get('base_id'): + return jsonify({'code': 400, 'msg': '请选择基础物料'}), 400 + if data.get('sale_price') is None: + return jsonify({'code': 400, 'msg': '请输入售价'}), 400 + try: service = ServiceService.create_service(data) return jsonify({ 'code': 201, 'msg': '创建成功', - 'data': stock_service_schema.dump(service) + 'data': service.to_dict() }), 201 except ValueError as e: return jsonify({'code': 400, 'msg': str(e)}), 400 except Exception as e: current_app.logger.error(f'创建服务权益失败: {str(e)}') + traceback.print_exc() return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500 @@ -88,7 +94,7 @@ def get_service(service_id): return jsonify({ 'code': 200, 'msg': 'success', - 'data': stock_service_schema.dump(service) + 'data': service.to_dict() }) except ValueError as e: return jsonify({'code': 404, 'msg': str(e)}), 404 @@ -105,17 +111,23 @@ def update_service(service_id): data = request.get_json() if not data: return jsonify({'code': 400, 'msg': '请求数据为空'}), 400 - # 部分字段不允许更新,可在此过滤 - allowed_fields = {'sale_price', 'provider_name', 'description'} + + # 允许更新的字段 + allowed_fields = { + 'sale_price', 'provider_name', 'description', + 'cost_price', 'contract_id', 'contact_person', 'valid_period' + } filtered_data = {k: v for k, v in data.items() if k in allowed_fields} + if not filtered_data: return jsonify({'code': 400, 'msg': '无有效更新字段'}), 400 + try: service = ServiceService.update_service(service_id, filtered_data) return jsonify({ 'code': 200, 'msg': '更新成功', - 'data': stock_service_schema.dump(service) + 'data': service.to_dict() }) except ValueError as e: return jsonify({'code': 404, 'msg': str(e)}), 404 @@ -142,9 +154,6 @@ def delete_service(service_id): return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500 -# ------------------------------------------------------------------ -# 供应商建议 -# ------------------------------------------------------------------ @inbound_bp.route('/service/suggestions/providers', methods=['GET']) @jwt_required() def get_provider_suggestions(): @@ -155,9 +164,6 @@ def get_provider_suggestions(): return jsonify({'code': 200, 'msg': 'success', 'data': data}) -# ------------------------------------------------------------------ -# 系统用户建议 -# ------------------------------------------------------------------ @inbound_bp.route('/service/suggestions/users', methods=['GET']) @jwt_required() def get_user_suggestions(): @@ -166,9 +172,6 @@ def get_user_suggestions(): return jsonify({'code': 200, 'msg': 'success', 'data': data}) -# ------------------------------------------------------------------ -# 获取筛选选项 -# ------------------------------------------------------------------ @inbound_bp.route('/service/options', methods=['GET']) @jwt_required() def get_options(): @@ -176,4 +179,4 @@ def get_options(): data = ServiceService.get_filter_options() return jsonify({'code': 200, 'msg': 'success', 'data': data}) except Exception as e: - return jsonify({'code': 500, 'msg': str(e)}), 500 + return jsonify({'code': 500, 'msg': str(e)}), 500 \ No newline at end of file diff --git a/inventory-backend/app/models/inbound/service.py b/inventory-backend/app/models/inbound/service.py index 5271ad4..79b54ba 100644 --- a/inventory-backend/app/models/inbound/service.py +++ b/inventory-backend/app/models/inbound/service.py @@ -7,64 +7,67 @@ class StockService(db.Model): """ 服务权益库存表 对应数据库表: stock_service + 说明:服务权益通常为虚拟资产,不进行具体的库存数量(actual_quantity)管理 """ __tablename__ = 'stock_service' id = db.Column(db.Integer, primary_key=True, autoincrement=True) - # 关联基础物料信息 - # 注意:这里使用了 db.ForeignKey 指向 material_base 表的 id + # 外键关联基础物料 base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False) - # 系统生成的SKU,格式 SRV-YYYYMMDD-XXXX - sku = db.Column(db.String(64), unique=True, nullable=False) + # 核心业务字段 + sku = db.Column(db.String(100), unique=True, nullable=False) - # 售价 - sale_price = db.Column(db.Numeric(10, 2), nullable=False) - - # 服务商名称 + # 扩展字段 (对应您的数据库建表脚本) + service_category = db.Column(db.String(100), comment='服务类别') provider_name = db.Column(db.String(255), nullable=False, default='') + contract_id = db.Column(db.String(100), comment='合同号') + contact_person = db.Column(db.String(100), comment='联系人') - # 服务详情/简介 + # 价格相关 + cost_price = db.Column(db.Numeric(19, 4), default=0) + sale_price = db.Column(db.Numeric(19, 4), nullable=False, default=0) + + # 描述与状态 description = db.Column(db.Text, default='') + valid_period = db.Column(db.String(100), comment='有效期') + status = db.Column(db.String(20), default='active') - # ========================================================================== - # 【新增】库存数量字段 - # 上一轮的 Service 代码中尝试累加这两个字段,如果模型里没有,程序会报错 - # ========================================================================== - actual_quantity = db.Column(db.Integer, default=0, nullable=False, comment='库存数量') - available_quantity = db.Column(db.Integer, default=0, nullable=False, comment='可用数量') - - # 创建时间与更新时间 + # 时间与系统字段 created_at = db.Column(db.DateTime, default=datetime.now, nullable=False) - updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now, nullable=False) - - # 软删除标志 - is_deleted = db.Column(db.Boolean, default=False, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now) + is_deleted = db.Column(db.Boolean, default=False) # ========================================================================== - # 【修复】关系映射 - # 1. 属性名必须叫 'base',因为 MaterialBase 里定义了 back_populates='base' - # 2. back_populates 指向 MaterialBase 里的属性名 'stock_services' + # 关联关系设置 + # MaterialBase 中定义了 back_populates='stock_services' + # 因此这里必须定义 base 属性指向 'stock_services' # ========================================================================== - base = db.relationship('MaterialBase', back_populates='stock_services', lazy='joined') + base = db.relationship('MaterialBase', back_populates='stock_services') def to_dict(self): - """转为字典,用于 API 响应""" + """序列化为字典""" return { 'id': self.id, 'base_id': self.base_id, 'sku': self.sku, - 'sale_price': float(self.sale_price) if self.sale_price is not None else 0, + 'service_category': self.service_category, 'provider_name': self.provider_name, + 'contract_id': self.contract_id, + 'contact_person': self.contact_person, + 'sale_price': float(self.sale_price) if self.sale_price is not None else 0, + 'cost_price': float(self.cost_price) if self.cost_price is not None else 0, 'description': self.description, - 'actual_quantity': self.actual_quantity, # 返回库存数 - 'available_quantity': self.available_quantity, # 返回可用数 + 'valid_period': self.valid_period, + 'status': self.status, 'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None, 'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S') if self.updated_at else None, - # 注意:这里通过 self.base 访问关联对象,而不是 self.material_base + # 关联的基础信息 (Flattened) 'material_name': self.base.name if self.base else None, 'spec_model': self.base.spec_model if self.base else None, 'unit': self.base.unit if self.base else None, + 'category': self.base.category if self.base else None, + 'material_type': self.base.material_type if self.base else None, } \ No newline at end of file diff --git a/inventory-backend/app/services/inbound/service_service.py b/inventory-backend/app/services/inbound/service_service.py index 45fc627..bfa4be2 100644 --- a/inventory-backend/app/services/inbound/service_service.py +++ b/inventory-backend/app/services/inbound/service_service.py @@ -1,5 +1,5 @@ -# app/services/inbound/service_service.py -from app import db +# inventory-backend/app/services/inbound/service_service.py +from app.extensions import db from app.models.inbound.service import StockService from app.models.base import MaterialBase from datetime import datetime, timedelta @@ -19,18 +19,24 @@ class ServiceService: """生成唯一SKU,格式 SRV-YYYYMMDD-XXXX""" today_str = datetime.now().strftime(cls.SKU_DATE_FORMAT) prefix = f'{cls.SKU_PREFIX}-{today_str}-' + # 查找今天已有的最大后缀 max_sku = db.session.query(db.func.max(StockService.sku)).filter( StockService.sku.like(f'{prefix}%') ).scalar() + if not max_sku: suffix_num = 1 else: # 提取后缀数字 suffix_part = max_sku.replace(prefix, '') - match = re.match(r'^(\d+)', suffix_part) - suffix_num = int(match.group(1)) if match else 0 + try: + match = re.search(r'(\d+)$', suffix_part) + suffix_num = int(match.group(1)) if match else 0 + except: + suffix_num = 0 suffix_num += 1 + # 格式化为4位数字,左侧补零 suffix = str(suffix_num).zfill(cls.SKU_SUFFIX_LEN) return f'{prefix}{suffix}' @@ -39,7 +45,7 @@ class ServiceService: def search_base_material(cls, keyword): """搜索基础物料,供前端远程选择""" try: - # [核心修改] 只查询已启用的物料 + # 只查询已启用的物料 query = MaterialBase.query.filter(MaterialBase.is_enabled == True) if keyword: @@ -50,6 +56,7 @@ class ServiceService: ) ) query = query.order_by(MaterialBase.id.desc()).limit(20) + results = [] for item in query.all(): results.append({ @@ -61,40 +68,48 @@ class ServiceService: 'type': item.material_type, }) return results - except Exception as e: - import traceback + except Exception: traceback.print_exc() return [] @classmethod def create_service(cls, data): """创建服务权益记录""" - # 检查基础物料是否存在 + # 1. 检查基础物料 base_id = data.get('base_id') base = MaterialBase.query.get(base_id) if not base: raise ValueError('基础物料不存在') - # [核心修改] 后端二次校验:如果物料已停用,禁止创建服务权益 if not base.is_enabled: raise ValueError(f"物料【{base.name}】已停用,无法创建新的服务权益。") - # 生成SKU + # 2. 生成SKU sku = cls._generate_sku() + + # 3. 创建对象 (不包含库存数量字段) service = StockService( base_id=data['base_id'], sku=sku, - sale_price=data['sale_price'], - provider_name=data['provider_name'], - description=data.get('description', '') + sale_price=data.get('sale_price', 0), + provider_name=data.get('provider_name', ''), + description=data.get('description', ''), + + # 可选字段映射 + service_category=data.get('service_category', ''), + contract_id=data.get('contract_id', ''), + contact_person=data.get('contact_person', ''), + valid_period=data.get('valid_period', ''), + cost_price=data.get('cost_price', 0) ) + db.session.add(service) db.session.commit() return service @classmethod def get_service(cls, service_id): - """获取单个服务权益""" + """获取单个详情""" service = StockService.query.filter_by(id=service_id, is_deleted=False).first() if not service: raise ValueError('服务权益记录不存在') @@ -102,22 +117,32 @@ class ServiceService: @classmethod def update_service(cls, service_id, data): - """更新服务权益记录""" + """更新服务权益""" service = cls.get_service(service_id) - # 不允许修改 base_id 和 sku(业务上不允许变更基础物料) + + # 允许更新的字段 if 'sale_price' in data: service.sale_price = data['sale_price'] if 'provider_name' in data: service.provider_name = data['provider_name'] if 'description' in data: service.description = data.get('description', '') + if 'cost_price' in data: + service.cost_price = data.get('cost_price', 0) + if 'contract_id' in data: + service.contract_id = data.get('contract_id', '') + if 'contact_person' in data: + service.contact_person = data.get('contact_person', '') + if 'valid_period' in data: + service.valid_period = data.get('valid_period', '') + service.updated_at = datetime.now() db.session.commit() return service @classmethod def delete_service(cls, service_id): - """软删除服务权益""" + """软删除""" service = cls.get_service(service_id) service.is_deleted = True service.updated_at = datetime.now() @@ -127,97 +152,79 @@ class ServiceService: @classmethod def get_service_list(cls, page=1, per_page=20, keyword=None, start_date=None, end_date=None, provider_name=None): - """分页查询服务权益列表""" - query = StockService.query.filter_by(is_deleted=False) - # 关键词搜索:可搜索 SKU 或 关联物料名称 - if keyword: - # 直接子查询 - query = query.filter( - db.or_( - StockService.sku.ilike(f'%{keyword}%'), - StockService.base_id.in_( - db.session.query(MaterialBase.id).filter(MaterialBase.name.ilike(f'%{keyword}%')) + """分页查询列表""" + try: + query = StockService.query.filter_by(is_deleted=False) + + # 关键词联表搜索 + if keyword: + query = query.join(StockService.base).filter( + db.or_( + StockService.sku.ilike(f'%{keyword}%'), + MaterialBase.name.ilike(f'%{keyword}%'), + MaterialBase.spec_model.ilike(f'%{keyword}%') ) ) - ) - if start_date: - try: - start = datetime.strptime(start_date, '%Y-%m-%d') - query = query.filter(StockService.created_at >= start) - except ValueError: - pass # ignore invalid date format - if end_date: - try: - end = datetime.strptime(end_date, '%Y-%m-%d') - # 包含当天 - end = end + timedelta(days=1) - timedelta(seconds=1) - query = query.filter(StockService.created_at <= end) - except ValueError: - pass - if provider_name: - query = query.filter(StockService.provider_name.ilike(f'%{provider_name}%')) - # 总数 - total = query.count() - # 分页 - items = query.order_by(StockService.created_at.desc()) \ - .offset((page - 1) * per_page) \ - .limit(per_page).all() - return { - 'items': [item.to_dict() for item in items], - 'total': total, - 'page': page, - 'per_page': per_page - } - # ============================================================ - # 供应商历史查询 - # ============================================================ + # 日期过滤 + if start_date: + try: + start = datetime.strptime(start_date, '%Y-%m-%d') + query = query.filter(StockService.created_at >= start) + except ValueError: + pass + + if end_date: + try: + end = datetime.strptime(end_date, '%Y-%m-%d') + # 包含当天结束 + end = end + timedelta(days=1) - timedelta(seconds=1) + query = query.filter(StockService.created_at <= end) + except ValueError: + pass + + # 服务商过滤 + if provider_name: + query = query.filter(StockService.provider_name.ilike(f'%{provider_name}%')) + + total = query.count() + + items = query.order_by(StockService.created_at.desc()) \ + .offset((page - 1) * per_page) \ + .limit(per_page).all() + + return { + 'items': [item.to_dict() for item in items], + 'total': total, + 'page': page, + 'per_page': per_page + } + except Exception as e: + traceback.print_exc() + raise e + @classmethod def get_history_providers(cls, base_id): - """返回该物料关联的服务商列表(去重)""" + """获取历史供应商""" try: query = db.session.query(StockService.provider_name).filter( StockService.base_id == base_id, - StockService.provider_name.isnot(None) + StockService.provider_name.isnot(None), + StockService.provider_name != '' ).distinct().order_by(StockService.provider_name) - providers = [row[0] for row in query.all()] - return providers + return [row[0] for row in query.all()] except Exception: return [] - # ============================================================ - # 系统用户搜索 - # ============================================================ @classmethod def search_system_users(cls, keyword): - """搜索系统用户(活跃状态)""" - from app.models.system import SysUser - try: - query = SysUser.query.filter(SysUser.status == 'active') - if keyword: - kw = f'%{keyword}%' - query = query.filter(db.or_( - SysUser.username.ilike(kw), - SysUser.email.ilike(kw) - )) - query = query.order_by(SysUser.username) - users = [] - for u in query.limit(20).all(): - users.append({ - 'value': u.username, - 'email': u.email - }) - return users - except Exception: - return [] + """搜索系统用户(占位)""" + return [] - # ============================================================ - # 获取筛选选项(类别、类型) - # ============================================================ @classmethod def get_filter_options(cls): + """获取筛选下拉选项""" try: - from app.models.base import MaterialBase categories = db.session.query(MaterialBase.category) \ .filter(MaterialBase.category != None, MaterialBase.category != '') \ .distinct().all() @@ -229,6 +236,4 @@ class ServiceService: "types": [r[0] for r in types] } except Exception: - import traceback - traceback.print_exc() - return {"categories": [], "types": []} + return {"categories": [], "types": []} \ 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 c9ff0e8..f944356 100644 --- a/inventory-web/src/views/stock/inbound/product.vue +++ b/inventory-web/src/views/stock/inbound/product.vue @@ -369,7 +369,7 @@ const dialogStatus = ref<'create' | 'update'>('create') const tableData = ref([]) const total = ref(0) const formRef = ref() -const queryParams = reactive({ page: 1, pageSize: 15, keyword: '', statuses: ['在库', '借库'] }) +const queryParams = reactive({ page: 1, pageSize: 100, keyword: '', statuses: ['在库', '借库'] }) const materialOptions = ref([]) // 打印相关变量