diff --git a/inventory-backend/app/api/v1/bom.py b/inventory-backend/app/api/v1/bom.py index ec63b8f..d174967 100644 --- a/inventory-backend/app/api/v1/bom.py +++ b/inventory-backend/app/api/v1/bom.py @@ -4,7 +4,6 @@ from app.models.base import MaterialBase from app.models.bom import BomTable from app.extensions import db from flask_jwt_extended import jwt_required -from sqlalchemy import distinct bom_bp = Blueprint('bom', __name__) @@ -14,10 +13,13 @@ bom_bp = Blueprint('bom', __name__) @bom_bp.route('/list', methods=['GET']) @jwt_required() def get_bom_list(): - """获取所有 BOM 配方列表(按 bom_no 分组),支持 keyword 搜索""" + """获取所有 BOM 配方列表,支持 keyword 搜索和 active_only 过滤""" try: keyword = request.args.get('keyword', '').strip() - data = BomService.get_bom_list(keyword=keyword) + # 将字符串 'true' 转为布尔值 + active_only = request.args.get('active_only', 'false').lower() == 'true' + + data = BomService.get_bom_list(keyword=keyword, active_only=active_only) return jsonify({ 'code': 200, 'msg': 'success', @@ -31,9 +33,13 @@ def get_bom_list(): @bom_bp.route('/detail/', methods=['GET']) @jwt_required() def get_bom_detail(bom_no): - """根据 BOM 编号获取配方详情""" + """ + 根据 BOM 编号获取配方详情 + Query参数: ?version=V1.0 (如果不传则取最新) + """ try: - data = BomService.get_bom_detail(bom_no) + version = request.args.get('version') + data = BomService.get_bom_detail(bom_no, version=version) if not data: return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404 return jsonify({ @@ -49,14 +55,14 @@ def get_bom_detail(bom_no): @bom_bp.route('/save', methods=['POST']) @jwt_required() def save_bom(): - """保存或更新 BOM 配方(支持自定义 bom_no)""" + """保存或更新 BOM 配方(支持自定义 bom_no 和 多版本)""" try: req_data = request.get_json() # 必需字段校验 if 'parent_id' not in req_data or 'children' not in req_data: return jsonify({'code': 400, 'msg': '缺少 parent_id 或 children 字段'}), 400 - # 校验 bom_no 不能为空(如果前端要求必须填) + # 校验 bom_no 不能为空 if 'bom_no' in req_data and not req_data['bom_no']: return jsonify({'code': 400, 'msg': 'BOM编号不能为空'}), 400 @@ -91,20 +97,28 @@ def get_bom_with_stock_by_no(bom_no): return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500 -# ==================== 删除BOM接口(根据bom_no删除整个配方) ==================== +# ==================== 删除BOM接口 ==================== @bom_bp.route('/', methods=['DELETE']) @jwt_required() def delete_bom(bom_no): - """根据 BOM 编号删除整个配方(包括所有子件记录)""" + """ + 根据 BOM 编号删除 + Query参数: ?version=V1.0 (如果不传,删除该编号下所有版本) + """ try: - # 先检查是否存在 - exist = BomTable.query.filter_by(bom_no=bom_no).first() + version = request.args.get('version') + query = BomTable.query.filter_by(bom_no=bom_no) + + if version: + query = query.filter_by(version=version) + + exist = query.first() if not exist: return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404 - # 删除该 bom_no 下所有记录 - BomTable.query.filter_by(bom_no=bom_no).delete() + # 删除 + query.delete() db.session.commit() return jsonify({ 'code': 200, @@ -115,7 +129,7 @@ def delete_bom(bom_no): return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500 -# ==================== 兼容旧接口(保留不改动现有前端) ==================== +# ==================== 兼容旧接口 ==================== @bom_bp.route('/', methods=['GET']) @jwt_required() diff --git a/inventory-backend/app/models/bom.py b/inventory-backend/app/models/bom.py index 1af51f6..d61c5e8 100644 --- a/inventory-backend/app/models/bom.py +++ b/inventory-backend/app/models/bom.py @@ -1,21 +1,28 @@ from app.extensions import db + class BomTable(db.Model): __tablename__ = 'bom_table' id = db.Column(db.Integer, primary_key=True) parent_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False) child_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False) + bom_no = db.Column(db.String(100), nullable=False, comment='BOM编号') - version = db.Column(db.String(50), nullable=False, default='v1', comment='版本') + version = db.Column(db.String(50), nullable=False, default='V1.0', comment='版本') + dosage = db.Column(db.Numeric(19, 4), comment='个数') - loss_rate = db.Column(db.Numeric(5, 2), comment='损耗率%(已废弃)', default=0, nullable=True) + loss_rate = db.Column(db.Numeric(5, 2), comment='损耗率%', default=0, nullable=True) remark = db.Column(db.Text, comment='备注') + # ★ 新增:启用状态 + is_enabled = db.Column(db.Boolean, default=True, comment='是否启用') + + # 约束: 保证同一版本下的父子对唯一,允许不同版本存在 __table_args__ = ( - db.UniqueConstraint('bom_no', 'parent_id', 'child_id', name='uniq_bom_no_parent_child'), + db.UniqueConstraint('bom_no', 'version', 'parent_id', 'child_id', name='uniq_bom_pair_in_version'), ) # relationships parent = db.relationship('MaterialBase', foreign_keys=[parent_id], backref='bom_parents') - child = db.relationship('MaterialBase', foreign_keys=[child_id], backref='bom_children') + child = db.relationship('MaterialBase', foreign_keys=[child_id], backref='bom_children') \ No newline at end of file diff --git a/inventory-backend/app/services/bom_service.py b/inventory-backend/app/services/bom_service.py index 29f021e..8bd7802 100644 --- a/inventory-backend/app/services/bom_service.py +++ b/inventory-backend/app/services/bom_service.py @@ -2,7 +2,7 @@ from app.extensions import db from app.models.bom import BomTable from app.models.base import MaterialBase from app.models.inbound.buy import StockBuy -from sqlalchemy import func, distinct, or_ +from sqlalchemy import func, distinct, or_, case import uuid from datetime import datetime @@ -18,90 +18,83 @@ class BomService: return f'BOM-{timestamp}-{unique}' @staticmethod - def get_bom_list(keyword=None): + def get_bom_list(keyword=None, active_only=False): """ - 获取所有 BOM 配方(按 bom_no 分组) + 获取所有 BOM 配方(按 bom_no + version 分组) 支持模糊搜索:BOM编号、父件名称/规格、子件名称/规格 """ - # 1. 如果有搜索关键词,先筛选出符合条件的 bom_no 集合 - filtered_bom_nos = None + # 1. 关键词过滤:先找出符合条件的 (bom_no, version) 组合 + query_base = db.session.query( + BomTable.bom_no, + BomTable.version + ).join( + MaterialBase, BomTable.parent_id == MaterialBase.id + ) + + # ★ 过滤禁用状态 + if active_only: + query_base = query_base.filter(BomTable.is_enabled == True) + if keyword: kw = f'%{keyword}%' - - # 条件A: 匹配 BOM编号 或 父件信息 - # 需要 join 父件表 - q1 = db.session.query(BomTable.bom_no).join( - MaterialBase, BomTable.parent_id == MaterialBase.id + # 关联子件表以支持子件搜索 + child_alias = db.aliased(MaterialBase) + query_base = query_base.outerjoin( + child_alias, BomTable.child_id == child_alias.id ).filter( or_( BomTable.bom_no.ilike(kw), MaterialBase.name.ilike(kw), - MaterialBase.spec_model.ilike(kw) + MaterialBase.spec_model.ilike(kw), + child_alias.name.ilike(kw), + child_alias.spec_model.ilike(kw) ) ) - # 条件B: 匹配 子件信息 - # 需要 join 子件表 - q2 = db.session.query(BomTable.bom_no).join( - MaterialBase, BomTable.child_id == MaterialBase.id + # 获取符合条件的唯一组合 + target_pairs = query_base.distinct().all() + + if not target_pairs: + return [] + + # 2. 聚合查询详情 + results = [] + for bom_no, version in target_pairs: + summary = db.session.query( + BomTable.parent_id, + MaterialBase.name.label('parent_name'), + MaterialBase.spec_model.label('parent_spec'), + BomTable.is_enabled, + func.count(BomTable.child_id).label('child_count') + ).join( + MaterialBase, BomTable.parent_id == MaterialBase.id ).filter( - or_( - MaterialBase.name.ilike(kw), - MaterialBase.spec_model.ilike(kw) - ) - ) + BomTable.bom_no == bom_no, + BomTable.version == version + ).group_by( + BomTable.parent_id, MaterialBase.name, MaterialBase.spec_model, BomTable.is_enabled + ).first() - # 取并集 (Union) - filtered_bom_nos = q1.union(q2).distinct().all() - filtered_bom_nos = [row[0] for row in filtered_bom_nos] + if summary: + results.append({ + 'bom_no': bom_no, + 'version': version, + 'parent_id': summary.parent_id, + 'parent_name': summary.parent_name, + 'parent_spec': summary.parent_spec or '', + 'is_enabled': summary.is_enabled, + 'child_count': summary.child_count + }) - # 如果搜不到任何结果,直接返回空 - if not filtered_bom_nos: - return [] - - # 2. 原有的分组聚合查询 - subq_query = db.session.query( - BomTable.bom_no, - BomTable.parent_id, - BomTable.version, - func.count(BomTable.child_id).label('child_count') - ) - - # 应用筛选 - if filtered_bom_nos is not None: - subq_query = subq_query.filter(BomTable.bom_no.in_(filtered_bom_nos)) - - subq = subq_query.group_by(BomTable.bom_no, BomTable.parent_id, BomTable.version).subquery() - - query = db.session.query( - subq.c.bom_no, - subq.c.parent_id, - subq.c.version, - subq.c.child_count, - MaterialBase.name.label('parent_name'), - MaterialBase.spec_model.label('parent_spec') - ).join(MaterialBase, subq.c.parent_id == MaterialBase.id) - - # 按 bom_no 倒序排列 - query = query.order_by(subq.c.bom_no.desc()) - - results = query.all() - return [{ - 'bom_no': row.bom_no, - 'parent_id': row.parent_id, - 'parent_name': row.parent_name, - 'parent_spec': row.parent_spec or '', - 'version': row.version, - 'child_count': row.child_count - } for row in results] + results.sort(key=lambda x: (x['bom_no'], x['version']), reverse=True) + return results @staticmethod - def get_bom_detail(bom_no): + def get_bom_detail(bom_no, version=None): """ - 根据 bom_no 获取配方详情 - 返回包含父件信息和子件列表的对象 + 根据 bom_no (和 version) 获取配方详情 """ - rows = db.session.query( + query = db.session.query( BomTable, MaterialBase.name.label('child_name'), MaterialBase.spec_model.label('child_spec') @@ -109,21 +102,24 @@ class BomService: MaterialBase, BomTable.child_id == MaterialBase.id ).filter( BomTable.bom_no == bom_no - ).all() + ) + if version: + query = query.filter(BomTable.version == version) + else: + latest_ver = db.session.query(BomTable.version).filter_by(bom_no=bom_no) \ + .order_by(BomTable.version.desc()).limit(1).scalar() + if not latest_ver: + return None + query = query.filter(BomTable.version == latest_ver) + + rows = query.all() if not rows: return None first = rows[0] parent_id = first.BomTable.parent_id - # 获取父件的名称和规格 - parent_material = MaterialBase.query.filter(MaterialBase.id == parent_id).first() - if parent_material: - parent_name = parent_material.name - parent_spec = parent_material.spec_model or '' - else: - parent_name = '' - parent_spec = '' + parent_material = MaterialBase.query.get(parent_id) children = [] for bom, child_name, child_spec in rows: @@ -137,45 +133,33 @@ class BomService: return { 'bom_no': bom_no, - 'parent_id': parent_id, - 'parent_name': parent_name, - 'parent_spec': parent_spec, 'version': first.BomTable.version, + 'parent_id': parent_id, + 'parent_name': parent_material.name if parent_material else '', + 'parent_spec': parent_material.spec_model if parent_material else '', + 'is_enabled': first.BomTable.is_enabled, 'children': children } @staticmethod def save_bom(data): - """ - 保存或更新一个 BOM 配方 - data 结构: - { - "bom_no": "用户输入或自动生成", - "version": "版本号,默认v1", - "parent_id": 父件ID, - "children": [...] - } - """ + """保存 BOM (支持多版本)""" bom_no = data.get('bom_no') - version = data.get('version', 'v1') + version = data.get('version', 'V1.0') parent_id = data['parent_id'] children = data['children'] + is_enabled = data.get('is_enabled', True) + + if not bom_no: + raise ValueError('BOM编号不能为空') - # 校验父件不能与子件相同 for child in children: if child['child_id'] == parent_id: raise ValueError('父件与子件不能是同一物料') - # 如果未提供 bom_no,则生成一个新的 (兼容旧逻辑,但现在前端会传值) - if not bom_no or not bom_no.strip(): - # 如果前端没传,抛出异常要求用户填写,或者自动生成 - # 这里选择自动生成作为兜底,但推荐前端校验必填 - bom_no = BomService.generate_bom_no() + # 仅删除当前版本的旧记录 + BomTable.query.filter_by(bom_no=bom_no, version=version).delete() - # 删除该 bom_no 下所有现有记录 (全量更新模式) - BomTable.query.filter_by(bom_no=bom_no).delete() - - # 插入新记录 for child in children: bom = BomTable( bom_no=bom_no, @@ -183,7 +167,8 @@ class BomService: parent_id=parent_id, child_id=child['child_id'], dosage=child.get('dosage', 0), - remark=child.get('remark', '') + remark=child.get('remark', ''), + is_enabled=is_enabled ) db.session.add(bom) @@ -193,81 +178,69 @@ class BomService: @staticmethod def get_bom_with_stock_by_bom_no(bom_no): """ - 根据 bom_no 获取配方详情,并计算每个子件的库存和最大可生产数量 + 根据 bom_no 获取配方详情,并计算: + 1. 总可用库存 + 2. 最大可生产套数 + 3. ★ 聚合库位信息 (warehouse_locations) """ detail = BomService.get_bom_detail(bom_no) if not detail: return None for child in detail['children']: - # 查询该子件在 StockBuy 中的可用库存总量 + # 1. 查询该子件的总库存 stock_qty = db.session.query( func.coalesce(func.sum(StockBuy.available_quantity), 0) ).filter( StockBuy.base_id == child['child_id'] ).scalar() or 0 + # 2. ★ 查询该子件涉及的所有库位,并去重拼接 (PostgreSQL 使用 string_agg) + # 注意:这里假设主要是 stock_buy 表,如果是成品或半成品也需要做类似 Union 查询 + # 为简化,这里演示只查 stock_buy 的库位 + locations = db.session.query( + # 去除空值和重复值 + func.string_agg(distinct(StockBuy.warehouse_location), ', ') + ).filter( + StockBuy.base_id == child['child_id'], + StockBuy.available_quantity > 0, # 只看有货的库位 + StockBuy.warehouse_location != None, + StockBuy.warehouse_location != '' + ).scalar() + child['current_stock'] = float(stock_qty) + child['warehouse_location'] = locations or '' # 返回给前端 + dosage = child['dosage'] child['max_producible'] = int(stock_qty // dosage) if dosage > 0 else 0 return detail - # ====================== 兼容旧接口(基于 parent_id) ====================== - + # ====================== 兼容旧接口 ====================== @staticmethod def get_bom_no_by_parent(parent_id): - """ - 根据父件 ID 获取其最新的 BOM 编号(用于兼容旧接口) - """ - row = BomTable.query.filter_by(parent_id=parent_id) \ - .order_by(BomTable.version.desc()).first() + row = BomTable.query.filter_by(parent_id=parent_id).order_by(BomTable.version.desc()).first() return row.bom_no if row else None @staticmethod - def create_or_update_bom(parent_id, child_list, bom_no=None, version='v1'): - """ - 兼容旧接口的保存方法(保留原有调用方式) - 如果提供了 bom_no,则更新该 bom_no;否则为父件创建新 BOM。 - """ - # 校验父件不能与子件相同 - for item in child_list: - if item['child_id'] == parent_id: - raise ValueError('父件与子件不能是同一物料') - - # 如果未提供 bom_no,尝试查找现有的,否则新建 + def create_or_update_bom(parent_id, child_list, bom_no=None, version='V1.0'): if not bom_no: existing = BomTable.query.filter_by(parent_id=parent_id).first() bom_no = existing.bom_no if existing else BomService.generate_bom_no() - # 删除该 bom_no 下所有记录 - BomTable.query.filter_by(bom_no=bom_no).delete() - - # 插入新的 + BomTable.query.filter_by(bom_no=bom_no, version=version).delete() for item in child_list: bom = BomTable( - bom_no=bom_no, - version=version, - parent_id=parent_id, - child_id=item['child_id'], - dosage=item.get('dosage', 0), - remark=item.get('remark', '') + bom_no=bom_no, version=version, parent_id=parent_id, + child_id=item['child_id'], dosage=item.get('dosage', 0), remark=item.get('remark', '') ) db.session.add(bom) - db.session.commit() return True @staticmethod def get_bom_with_stock(parent_id): - """ - 兼容旧接口:根据父件 ID 获取 BOM 及库存信息 - (实际会找到对应的 bom_no 再调用新方法) - """ bom_no = BomService.get_bom_no_by_parent(parent_id) - if not bom_no: - return [] + if not bom_no: return [] detail = BomService.get_bom_with_stock_by_bom_no(bom_no) - if not detail: - return [] - return detail['children'] \ No newline at end of file + return detail['children'] if detail else [] \ No newline at end of file diff --git a/inventory-web/src/views/bom/BomManage.vue b/inventory-web/src/views/bom/BomManage.vue index 00c287f..7a809d9 100644 --- a/inventory-web/src/views/bom/BomManage.vue +++ b/inventory-web/src/views/bom/BomManage.vue @@ -7,7 +7,7 @@
{{ row.version }} - - + + + + + @@ -46,57 +53,66 @@ - - - + + + class="beautified-select" + @change="onParentChange" + > + +
+ {{ item.name }} + {{ item.spec }} +
+
+
- - - + + +
- - - -
- {{ item.name }} - {{ item.spec }} + + + + + + +
+ 最终编号: {{ fullBomNo }}
- - -
+ +
+ + + + + +
子件列表
- - + + - + - + @@ -174,6 +188,7 @@ +