diff --git a/inventory-backend/app/api/v1/bom.py b/inventory-backend/app/api/v1/bom.py index b92462a..c5c723c 100644 --- a/inventory-backend/app/api/v1/bom.py +++ b/inventory-backend/app/api/v1/bom.py @@ -66,13 +66,14 @@ def get_bom_list(): try: keyword = request.args.get('keyword', '').strip() active_only = request.args.get('active_only', 'false').lower() == 'true' + status = request.args.get('status', '').strip() or None category = request.args.get('category', '').strip() or None page = request.args.get('page', 1, type=int) limit = request.args.get('pageSize', 15, type=int) data = BomService.get_bom_list( keyword=keyword, active_only=active_only, - category=category, page=page, limit=limit + category=category, page=page, limit=limit, status=status ) # 字段级脱敏(data 现在是 {items, total, pages, current_page} 字典) user_permissions = get_current_user_permissions() @@ -98,13 +99,43 @@ def get_bom_list(): def get_bom_summary(): try: keyword = request.args.get('keyword', '').strip() or None - data = BomService.get_bom_summary(keyword=keyword) + status = request.args.get('status', '').strip() or None + data = BomService.get_bom_summary(keyword=keyword, status=status) return jsonify({'code': 200, 'msg': 'success', 'data': data}) except Exception as e: current_app.logger.error(f'获取BOM摘要失败: {str(e)}') return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500 +# ==================== BOM 独立启停(仅更新状态,不触发整表保存) ==================== +@bom_bp.route('/status', methods=['POST']) +@jwt_required() +@permission_required('bom_manage:operation') +@audit_log( + module='BOM管理', + action='更新状态', + get_target_name_fn=lambda: (request.get_json() or {}).get('bom_no') +) +def update_bom_status(): + """仅切换某 BOM 版本(整组)的启用/停用状态。""" + try: + data = request.get_json() or {} + bom_no = data.get('bom_no') + version = data.get('version') + is_enabled = data.get('is_enabled') + if not bom_no or not version: + return jsonify({'code': 400, 'msg': 'bom_no 与 version 不能为空'}), 400 + if not isinstance(is_enabled, bool): + return jsonify({'code': 400, 'msg': 'is_enabled 必须为布尔值'}), 400 + BomService.update_enabled(bom_no, version, is_enabled) + return jsonify({'code': 200, 'msg': '更新成功'}) + except ValueError as e: + return jsonify({'code': 400, 'msg': str(e)}), 400 + except Exception as e: + current_app.logger.error(f'更新BOM状态失败: {str(e)}') + return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500 + + @bom_bp.route('/detail/', methods=['GET']) @jwt_required() @permission_required('bom_manage') @@ -398,6 +429,27 @@ def get_material_base_list(): return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500 +@bom_bp.route('/self-bom-versions', methods=['GET']) +@jwt_required() +@permission_required('bom_manage') +def get_child_bom_versions(): + """ + 获取某物料作为父件时的"启用配方版本清单"(bom_no, version)。 + 前端在编辑/新建 BOM、选中自制件子件后调用,用于生成必选版本下拉。 + Query参数: child_id (必填, 物料ID) + 返回 data: [{bom_no, version}, ...];空数组 = 该物料非自制件(无现行 BOM),无需选版本。 + """ + try: + child_id = request.args.get('child_id', type=int) + if not child_id: + return jsonify({'code': 400, 'msg': 'child_id 不能为空'}), 400 + data = BomService.get_child_bom_versions(child_id) + return jsonify({'code': 200, 'msg': 'success', 'data': data}) + except Exception as e: + current_app.logger.error(f'获取子件BOM版本失败: {str(e)}') + return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500 + + @bom_bp.route('/parents', methods=['GET']) @jwt_required() @permission_required('bom_manage') diff --git a/inventory-backend/app/models/bom.py b/inventory-backend/app/models/bom.py index ffc11ee..655642e 100644 --- a/inventory-backend/app/models/bom.py +++ b/inventory-backend/app/models/bom.py @@ -25,9 +25,18 @@ class BomTable(db.Model): loss_rate = db.Column(db.Numeric(5, 2), comment='损耗率%', default=0, nullable=True) remark = db.Column(db.Text, comment='备注') + # ★ 子件引用的"自制 BOM"版本:子件物料若本身是自制件(作为其它 BOM 的父件), + # 此列记录该子件实际引用的配方版本((bom_no, version) 共同定位),由新建/编辑 BOM 时选版本保存。 + # 外购件/无下级 BOM 的子件这两列为 NULL。 + child_bom_no = db.Column(db.String(100), nullable=True, index=True, comment='子件引用的自制BOM编号') + child_bom_version = db.Column(db.String(50), nullable=True, index=True, comment='子件引用的自制BOM版本') + # ★ 新增:启用状态 is_enabled = db.Column(db.Boolean, default=True, index=True, comment='是否启用') # ★ 状态过滤高频列 + # ★ 归档标记:仍保持 is_enabled 语义(可查看/编辑/启停),但归档配方不再作为"其它 BOM 子件"的版本候选与可引用版本 + is_archived = db.Column(db.Boolean, default=False, index=True, comment='是否归档(不作为子件引用候选)') + # 约束: 保证同一版本下的父子对唯一,允许不同版本存在 __table_args__ = ( db.UniqueConstraint('bom_no', 'version', 'parent_id', 'child_id', name='uniq_bom_pair_in_version'), diff --git a/inventory-backend/app/models/bom_draft.py b/inventory-backend/app/models/bom_draft.py index e2085ea..4ca14dd 100644 --- a/inventory-backend/app/models/bom_draft.py +++ b/inventory-backend/app/models/bom_draft.py @@ -22,6 +22,11 @@ class BomDraftTable(db.Model): dosage = db.Column(db.Numeric(19, 4), comment='个数') loss_rate = db.Column(db.Numeric(5, 2), default=0, nullable=True, comment='损耗率%') remark = db.Column(db.Text, comment='备注') + + # ★ 子件引用的"自制 BOM"版本:与 bom_table 的 child_bom_no/child_bom_version 对齐, + # 保证草稿暂存/载入期间不丢失所选版本。 + child_bom_no = db.Column(db.String(100), nullable=True, index=True, comment='子件引用的自制BOM编号') + child_bom_version = db.Column(db.String(50), nullable=True, index=True, comment='子件引用的自制BOM版本') updated_at = db.Column(db.DateTime, default=db.func.now(), onupdate=db.func.now(), comment='更新时间') parent = db.relationship( diff --git a/inventory-backend/app/services/bom_draft_service.py b/inventory-backend/app/services/bom_draft_service.py index 7077244..d48d861 100644 --- a/inventory-backend/app/services/bom_draft_service.py +++ b/inventory-backend/app/services/bom_draft_service.py @@ -33,7 +33,9 @@ class BomDraftService: child_id=child.get('child_id'), dosage=child.get('dosage', 0), loss_rate=child.get('loss_rate', 0), - remark=child.get('remark', '') + remark=child.get('remark', ''), + child_bom_no=child.get('child_bom_no') or None, + child_bom_version=child.get('child_bom_version') or None ) db.session.add(draft) @@ -75,6 +77,8 @@ class BomDraftService: 'dosage': float(draft.dosage) if draft.dosage else 0.0, 'loss_rate': float(draft.loss_rate) if draft.loss_rate else 0.0, 'remark': draft.remark or '', + 'child_bom_no': draft.child_bom_no or '', + 'child_bom_version': draft.child_bom_version or '', }) return { @@ -126,6 +130,8 @@ class BomDraftService: 'child_id': child['child_id'], 'dosage': child['dosage'], 'remark': child.get('remark', ''), + 'child_bom_no': child.get('child_bom_no') or '', + 'child_bom_version': child.get('child_bom_version') or '', } for child in children ], diff --git a/inventory-backend/app/services/bom_service.py b/inventory-backend/app/services/bom_service.py index f6a5b05..0d21125 100644 --- a/inventory-backend/app/services/bom_service.py +++ b/inventory-backend/app/services/bom_service.py @@ -110,7 +110,7 @@ class BomService: return f'BOM-{timestamp}-{unique}' @staticmethod - def get_bom_list(keyword=None, active_only=False, category=None, page=1, limit=15): + def get_bom_list(keyword=None, active_only=False, category=None, page=1, limit=15, status=None): """ 获取所有 BOM 配方(按 bom_no + version 分组,单条 SQL 聚合 + 分页) @@ -146,8 +146,12 @@ class BomService: BomTable.is_enabled ) - # 过滤禁用状态 - if active_only: + # 状态过滤:status 优先于旧参数 active_only(enabled/disabled 均为精确过滤) + if status == 'enabled': + query = query.filter(BomTable.is_enabled == True) + elif status == 'disabled': + query = query.filter(BomTable.is_enabled == False) + elif active_only: query = query.filter(BomTable.is_enabled == True) # 【行级数据隔离】基于 JWT 多租户公司过滤 @@ -220,7 +224,7 @@ class BomService: } @staticmethod - def get_bom_summary(keyword=None): + def get_bom_summary(keyword=None, status=None): """ BOM 分组摘要 API(极轻量,单条 GROUP BY + COUNT) @@ -249,6 +253,12 @@ class BomService: if company_limit is not None: query = query.filter(MaterialBase.company_name == company_limit) + # 状态过滤(按 (bom_no, version) 维度计数,故过滤粒度到行即可,行状态同版本一致) + if status == 'enabled': + query = query.filter(BomTable.is_enabled == True) + elif status == 'disabled': + query = query.filter(BomTable.is_enabled == False) + # 关键词搜索 if keyword: kw = f'%{keyword.strip()}%' @@ -321,27 +331,37 @@ class BomService: children = [] - # ★ 子件若本身是"自制件"(也是某 BOM 的父件),带出它的 BOM 编号与最新版本号(仅展示识别用) + # ★ 子件引用版本:优先读 bom_table 存储列(新建 BOM 时选定并落库)。 + # 仅对"存储列为空但子件实为自制件"的旧数据/Excel 导入行,回退取其"最新启用配方"作展示兼容。 child_self_bom = {} - self_ids = [bom.child_id for bom, _, _ in rows if bom.child_id] + self_ids = [ + bom.child_id for bom, _, _ in rows + if bom.child_id and not (bom.child_bom_no and bom.child_bom_version) + ] if self_ids: _self_q = (db.session.query(BomTable.parent_id, BomTable.bom_no, BomTable.version) - .filter(BomTable.parent_id.in_(self_ids)) + .filter(BomTable.parent_id.in_(self_ids), + BomTable.is_enabled == True, + BomTable.is_archived == False) .order_by(BomTable.parent_id, BomTable.id.desc()).all()) for _pid, _no, _ver in _self_q: if _pid not in child_self_bom: child_self_bom[_pid] = {'bom_no': _no, 'version': _ver} for bom, child_name, child_spec in rows: - _ref = child_self_bom.get(bom.child_id) or {} + if bom.child_bom_no and bom.child_bom_version: + cb_no, cb_ver = bom.child_bom_no, bom.child_bom_version + else: + _ref = child_self_bom.get(bom.child_id) or {} + cb_no, cb_ver = _ref.get('bom_no', ''), _ref.get('version', '') children.append({ 'child_id': bom.child_id, 'child_name': child_name or '[已删除物料]', 'child_spec': child_spec or '', 'dosage': float(bom.dosage) if bom.dosage else 0.0, 'remark': bom.remark or '', - 'child_bom_no': _ref.get('bom_no', ''), - 'child_bom_version': _ref.get('version', '') + 'child_bom_no': cb_no, + 'child_bom_version': cb_ver }) result = { @@ -376,18 +396,25 @@ class BomService: raise ValueError('父件与子件不能是同一物料') # ===== 跨版本内容查重 ===== - # 将当前提交的 children 转换为可比较的集合 (child_id, dosage) - current_children_set = set() - for child in children: - # 用 (child_id, dosage) 元组表示,dosage 转为整数比较 - dosage_val = int(child.get('dosage', 0)) if child.get('dosage') else 0 - current_children_set.add((child['child_id'], dosage_val)) + # 将当前提交的 children 转换为可比较集合 (child_id, dosage, 引用BOM号, 引用版本)。 + # 引用版本纳入比较,避免"仅升级了自制件子件的引用版本"被误判为与旧版本完全重复。 + def _cmp_key(child): + return ( + child['child_id'], + int(child.get('dosage', 0) or 0), + child.get('child_bom_no') or '', + child.get('child_bom_version') or '', + ) + + current_children_set = {_cmp_key(c) for c in children} # 查询该 bom_no 下所有其他版本的子件配置 existing_versions = db.session.query( BomTable.version, BomTable.child_id, - BomTable.dosage + BomTable.dosage, + BomTable.child_bom_no, + BomTable.child_bom_version ).filter( BomTable.bom_no == bom_no, BomTable.version != version # 排除当前正在保存的版本 @@ -395,17 +422,60 @@ class BomService: # 按版本分组,构建每个版本的子件集合 version_children = {} - for ver, child_id, dosage in existing_versions: - if ver not in version_children: - version_children[ver] = set() - dosage_val = int(dosage) if dosage else 0 - version_children[ver].add((child_id, dosage_val)) + for ver, child_id, dosage, child_bom_no, child_bom_version in existing_versions: + version_children.setdefault(ver, set()).add( + (child_id, int(dosage or 0), child_bom_no or '', child_bom_version or '') + ) # 比对每个版本 for ver, existing_set in version_children.items(): if current_children_set == existing_set: raise ValueError(f'保存失败!当前子件配置与已有版本 {ver} 完全一致,请勿重复保存') + # ===== 自制件子件"选版本"校验 ===== + # 判定口径: 子件若存在"启用的下级 BOM"(即它作为父件有 is_enabled 配方)→ 视为自制件, 必须选定引用版本; + # 否则视为外购件/无现行配方, 引用版本字段不保存(置 NULL)。 + # 与读取侧(get_bom_detail)、停用占位配方后的启用集合保持一致,避免录入不在启用集的版本。 + all_child_ids = [c.get('child_id') for c in children if c.get('child_id')] + self_recipe_map = {} + if all_child_ids: + _rows = db.session.query( + BomTable.parent_id, BomTable.bom_no, BomTable.version + ).filter( + BomTable.parent_id.in_(all_child_ids), + BomTable.is_enabled == True, + BomTable.is_archived == False + ).distinct().all() + for _pid, _bn, _ver in _rows: + self_recipe_map.setdefault(_pid, set()).add((_bn, _ver)) + + self_names = {} + if self_recipe_map: + self_names = dict( + db.session.query(MaterialBase.id, MaterialBase.name) + .filter(MaterialBase.id.in_(list(self_recipe_map.keys()))).all() + ) + + for child in children: + cid = child.get('child_id') + if cid is None: + continue + ref_no = (child.get('child_bom_no') or '').strip() + ref_ver = (child.get('child_bom_version') or '').strip() + recipes = self_recipe_map.get(cid) + if recipes: + # 自制件: 版本为必选, 且必须落在其启用配方集内 + if not ref_no or not ref_ver: + raise ValueError( + f"子件「{self_names.get(cid, cid)}」为自制件,请选择其 BOM 版本后才可保存") + if (ref_no, ref_ver) not in recipes: + raise ValueError( + f"子件「{self_names.get(cid, cid)}」所选版本 {ref_no} {ref_ver} 不存在或已停用,请重新选择") + else: + # 外购件/无现行 BOM: 清除版本字段 + child['child_bom_no'] = None + child['child_bom_version'] = None + # ===== 执行保存 ===== # 仅删除当前版本的旧记录(改为对象级删除以触发审计事件) old_records = BomTable.query.filter_by(bom_no=bom_no, version=version).all() @@ -423,6 +493,8 @@ class BomService: child_id=child['child_id'], dosage=child.get('dosage', 0), remark=child.get('remark', ''), + child_bom_no=child.get('child_bom_no') or None, + child_bom_version=child.get('child_bom_version') or None, is_enabled=is_enabled ) db.session.add(bom) @@ -436,6 +508,22 @@ class BomService: return bom_no + @staticmethod + def update_enabled(bom_no, version, is_enabled): + """ + 仅更新某 BOM 版本(整组行)的启用状态,供列表/只读视图"启用/停用"开关使用。 + 不触碰子件结构;成功后清除该 bom_no 的缓存。 + """ + rows = BomTable.query.filter_by(bom_no=bom_no, version=version).all() + if not rows: + raise ValueError('BOM 不存在') + for rec in rows: + rec.is_enabled = bool(is_enabled) + db.session.commit() + _cache_delete(bom_no, version) + logger.info(f"[BOM Cache] update_enabled → 缓存已失效 bom_no={bom_no} version={version} enabled={is_enabled}") + return len(rows) + @staticmethod def get_bom_with_stock_by_bom_no(bom_no, version=None): """ @@ -495,6 +583,24 @@ class BomService: 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 get_child_bom_versions(child_id): + """ + 返回某物料作为父件时的"启用配方版本清单"(bom_no, version), + 供前端在选中自制件子件后生成必选版本下拉。空列表 = 非自制件/无现行 BOM,无需选版本。 + 排序: 版本倒序在前(最新优先,作为下拉默认项)。注意 version 为字符串,跨 V10 需另行规范。 + """ + if not child_id: + return [] + rows = (db.session.query(BomTable.bom_no, BomTable.version) + .filter(BomTable.parent_id == child_id, + BomTable.is_enabled == True, + BomTable.is_archived == False) + .distinct() + .order_by(BomTable.version.desc(), BomTable.bom_no.desc()) + .all()) + return [{'bom_no': bn, 'version': ver} for bn, ver in rows] + @staticmethod def create_or_update_bom(parent_id, child_list, bom_no=None, version='V1.0'): try: @@ -510,7 +616,9 @@ class BomService: 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', '') + child_id=item['child_id'], dosage=item.get('dosage', 0), remark=item.get('remark', ''), + child_bom_no=item.get('child_bom_no') or None, + child_bom_version=item.get('child_bom_version') or None ) db.session.add(bom) db.session.commit()