From c7b84ff3c64b4cf9e1f57f1b116b579b31f4c720 Mon Sep 17 00:00:00 2001 From: DXC Date: Wed, 10 Jun 2026 11:30:07 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20BOM=E8=8D=89=E7=A8=BF=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E7=BC=BA=E9=99=B7=E4=BF=AE=E5=A4=8D=EF=BC=88=E4=BA=8B=E5=8A=A1?= =?UTF-8?q?=E5=9B=9E=E6=BB=9A=20+=20=E5=A4=96=E9=94=AE=E7=BA=A6=E6=9D=9F?= =?UTF-8?q?=20+=20=E5=89=8D=E7=AB=AF=E7=8A=B6=E6=80=81=E6=B8=85=E7=90=86?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- inventory-backend/app/api/v1/bom.py | 59 +++++ inventory-backend/app/models/bom.py | 28 ++- inventory-backend/app/models/bom_draft.py | 38 ++++ .../app/services/bom_draft_service.py | 145 ++++++++++++ inventory-backend/app/services/bom_service.py | 40 ++-- inventory-web/src/api/bom.ts | 19 ++ inventory-web/src/views/bom/BomManage.vue | 213 ++++++++++++++++-- 7 files changed, 507 insertions(+), 35 deletions(-) create mode 100644 inventory-backend/app/models/bom_draft.py create mode 100644 inventory-backend/app/services/bom_draft_service.py diff --git a/inventory-backend/app/api/v1/bom.py b/inventory-backend/app/api/v1/bom.py index b2c2832..43c197f 100644 --- a/inventory-backend/app/api/v1/bom.py +++ b/inventory-backend/app/api/v1/bom.py @@ -1,6 +1,7 @@ from flask import Blueprint, request, jsonify, current_app from sqlalchemy import or_ from app.services.bom_service import BomService, _cache_delete +from app.services.bom_draft_service import BomDraftService from app.models.base import MaterialBase from app.models.bom import BomTable from app.extensions import db @@ -420,3 +421,61 @@ def get_cascade_inventory(): except Exception as e: current_app.logger.error(f'级联库存计算失败: {str(e)}') return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500 + + +# ==================== BOM 草稿接口 ==================== + +@bom_bp.route('/draft/save', methods=['POST']) +@jwt_required() +def save_draft(): + """暂存草稿""" + data = request.get_json() + bom_no = data.get('bom_no') + version = data.get('version', 'V1.0') + parent_id = data.get('parent_id') + children = data.get('children', []) + + if not bom_no: + return jsonify({'code': 400, 'msg': 'bom_no 不能为空'}), 400 + if not parent_id: + return jsonify({'code': 400, 'msg': 'parent_id 不能为空'}), 400 + + bom_draft_no = BomDraftService.save_draft(bom_no, version, parent_id, children) + return jsonify({'code': 200, 'msg': '草稿暂存成功', 'data': {'bom_no': bom_draft_no}}) + + +@bom_bp.route('/draft/detail', methods=['GET']) +@jwt_required() +def get_draft_detail(): + """读取草稿详情""" + bom_no = request.args.get('bom_no') + version = request.args.get('version', 'V1.0') + + if not bom_no: + return jsonify({'code': 400, 'msg': 'bom_no 不能为空'}), 400 + + draft = BomDraftService.get_draft_detail(bom_no, version) + + # 【核心修改】:查不到草稿是正常现象,返回 HTTP 200 即可 + if draft is None: + return jsonify({'code': 200, 'msg': '无草稿', 'data': None}), 200 + + return jsonify({'code': 200, 'msg': '查询成功', 'data': draft}) + + +@bom_bp.route('/draft/publish', methods=['POST']) +@jwt_required() +def publish_draft(): + """发布草稿为正式 BOM""" + data = request.get_json() + bom_no = data.get('bom_no') + version = data.get('version', 'V1.0') + + if not bom_no: + return jsonify({'code': 400, 'msg': 'bom_no 不能为空'}), 400 + + try: + bom_draft_no = BomDraftService.publish_draft(bom_no, version) + return jsonify({'code': 200, 'msg': 'BOM 发布成功', 'data': {'bom_no': bom_draft_no}}) + except ValueError as e: + return jsonify({'code': 400, 'msg': str(e)}), 400 diff --git a/inventory-backend/app/models/bom.py b/inventory-backend/app/models/bom.py index 74a1f02..f437815 100644 --- a/inventory-backend/app/models/bom.py +++ b/inventory-backend/app/models/bom.py @@ -5,8 +5,18 @@ 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, index=True) # ★ 父子件关联高频列 - child_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False, index=True) # ★ 子件过滤高频列 + parent_id = db.Column( + db.Integer, + db.ForeignKey('material_base.id', ondelete='SET NULL'), + nullable=False, + index=True + ) + child_id = db.Column( + db.Integer, + db.ForeignKey('material_base.id', ondelete='SET NULL'), + nullable=False, + index=True + ) bom_no = db.Column(db.String(100), nullable=False, index=True, comment='BOM编号') # ★ Redis 缓存 Key + 列表查询核心列 version = db.Column(db.String(50), nullable=False, default='V1.0', index=True, comment='版本') # ★ 配合 bom_no 做唯一性约束 @@ -24,5 +34,15 @@ class BomTable(db.Model): ) # relationships - parent = db.relationship('MaterialBase', foreign_keys=[parent_id], backref='bom_parents') - child = db.relationship('MaterialBase', foreign_keys=[child_id], backref='bom_children') \ No newline at end of file + parent = db.relationship( + 'MaterialBase', + foreign_keys=[parent_id], + backref='bom_parents', + passive_deletes=True + ) + child = db.relationship( + 'MaterialBase', + foreign_keys=[child_id], + backref='bom_children', + passive_deletes=True + ) \ No newline at end of file diff --git a/inventory-backend/app/models/bom_draft.py b/inventory-backend/app/models/bom_draft.py new file mode 100644 index 0000000..e2085ea --- /dev/null +++ b/inventory-backend/app/models/bom_draft.py @@ -0,0 +1,38 @@ +from app.extensions import db + + +class BomDraftTable(db.Model): + __tablename__ = 'bom_draft_table' + + id = db.Column(db.Integer, primary_key=True) + bom_no = db.Column(db.String(100), nullable=False, index=True, comment='BOM编号') + version = db.Column(db.String(50), nullable=False, default='V1.0', index=True, comment='版本') + parent_id = db.Column( + db.Integer, + db.ForeignKey('material_base.id', ondelete='SET NULL'), + nullable=True, + comment='父件物料ID' + ) + child_id = db.Column( + db.Integer, + db.ForeignKey('material_base.id', ondelete='SET NULL'), + nullable=True, + comment='子件物料ID' + ) + 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='备注') + updated_at = db.Column(db.DateTime, default=db.func.now(), onupdate=db.func.now(), comment='更新时间') + + parent = db.relationship( + 'MaterialBase', + foreign_keys=[parent_id], + backref='bom_draft_parents', + passive_deletes=True + ) + child = db.relationship( + 'MaterialBase', + foreign_keys=[child_id], + backref='bom_draft_children', + passive_deletes=True + ) \ No newline at end of file diff --git a/inventory-backend/app/services/bom_draft_service.py b/inventory-backend/app/services/bom_draft_service.py new file mode 100644 index 0000000..7077244 --- /dev/null +++ b/inventory-backend/app/services/bom_draft_service.py @@ -0,0 +1,145 @@ +from app.extensions import db +from app.models.bom_draft import BomDraftTable +from app.models.base import MaterialBase +from app.services.bom_service import BomService +import logging + +logger = logging.getLogger(__name__) + + +class BomDraftService: + + @staticmethod + def save_draft(bom_no, version, parent_id, children): + try: + # 1. 删除旧草稿 + old = BomDraftTable.query.filter_by(bom_no=bom_no, version=version).all() + for rec in old: + db.session.delete(rec) + db.session.flush() + + # 2. 如果没有任何子件,必须插入一条只包含 parent_id 的占位头数据 + if not children: + dummy_draft = BomDraftTable( + bom_no=bom_no, version=version, parent_id=parent_id, + child_id=None, dosage=0, loss_rate=0, remark='' + ) + db.session.add(dummy_draft) + else: + # 正常批量插入新草稿行 + for child in children: + draft = BomDraftTable( + bom_no=bom_no, version=version, parent_id=parent_id, + child_id=child.get('child_id'), + dosage=child.get('dosage', 0), + loss_rate=child.get('loss_rate', 0), + remark=child.get('remark', '') + ) + db.session.add(draft) + + db.session.commit() + except Exception as e: + db.session.rollback() + logger.error(f"[BomDraft] save_draft 失败 bom_no={bom_no}: {e}") + raise + return bom_no + + @staticmethod + def get_draft_detail(bom_no, version): + rows = db.session.query( + BomDraftTable, + MaterialBase.name.label('child_name'), + MaterialBase.spec_model.label('child_spec') + ).outerjoin( + MaterialBase, BomDraftTable.child_id == MaterialBase.id + ).filter( + BomDraftTable.bom_no == bom_no, + BomDraftTable.version == version + ).all() + + if not rows: + return None + + first = rows[0].BomDraftTable + parent_id = first.parent_id + parent_material = MaterialBase.query.get(parent_id) if parent_id else None + + children = [] + for draft, child_name, child_spec in rows: + # 过滤掉保存 BOM 头时插入的占位空行 + if draft.child_id is not None: + children.append({ + 'child_id': draft.child_id, + 'child_name': child_name or '', + 'child_spec': child_spec or '', + '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 '', + }) + + return { + 'bom_no': bom_no, + 'version': first.version, + 'parent_id': parent_id, + 'parent_name': parent_material.name if parent_material else '', + 'parent_spec': parent_material.spec_model if parent_material else '', + 'children': children, + } + + @staticmethod + def publish_draft(bom_no, version): + """ + 发布草稿为正式 BOM: + 1. 获取草稿数据 + 2. 强校验(父件不为空、子件列表非空、所有子件 ID>0、用量>0) + 3. 调用 BomService.save_bom 写入正式 bom_table + 4. 清空草稿数据 + """ + try: + # 步骤 1 + draft = BomDraftService.get_draft_detail(bom_no, version) + if not draft: + raise ValueError('草稿不存在') + + # 步骤 2:强校验 + if not draft.get('parent_id'): + raise ValueError('发布失败:父件不能为空') + + children = draft.get('children', []) + if not children: + raise ValueError('发布失败:子件列表不能为空') + + for child in children: + if not child.get('child_id') or child['child_id'] <= 0: + raise ValueError('发布失败:子件ID必须大于0') + dosage = child.get('dosage') + if not dosage or dosage <= 0: + raise ValueError('发布失败:子件用量必须大于0') + + # 步骤 3:复用正式 BOM 的写入逻辑(跨版本查重 + 缓存清理均在 save_bom 内完成) + publish_data = { + 'bom_no': bom_no, + 'version': version, + 'parent_id': draft['parent_id'], + 'children': [ + { + 'child_id': child['child_id'], + 'dosage': child['dosage'], + 'remark': child.get('remark', ''), + } + for child in children + ], + } + BomService.save_bom(publish_data) + + # 步骤 4:清空草稿数据 + old_rows = BomDraftTable.query.filter_by(bom_no=bom_no, version=version).all() + for rec in old_rows: + db.session.delete(rec) + db.session.commit() + logger.info(f"[BomDraft] publish_draft bom_no={bom_no} version={version} -> 已发布并清空草稿") + except Exception as e: + db.session.rollback() + logger.error(f"[BomDraft] publish_draft 失败 bom_no={bom_no}: {e}") + raise + return bom_no \ 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 db2d857..7f565b9 100644 --- a/inventory-backend/app/services/bom_service.py +++ b/inventory-backend/app/services/bom_service.py @@ -431,27 +431,31 @@ class BomService: @staticmethod 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() + try: + 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() - # 改为对象级删除以触发审计事件 - old_records = BomTable.query.filter_by(bom_no=bom_no, version=version).all() - for rec in old_records: - db.session.delete(rec) + # 改为对象级删除以触发审计事件 + old_records = BomTable.query.filter_by(bom_no=bom_no, version=version).all() + for rec in old_records: + db.session.delete(rec) - 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', '') - ) - db.session.add(bom) - db.session.commit() - - # ===== 写入后立刻清除缓存(Cache Invalidation) ===== - _cache_delete(bom_no, version) - logger.info(f"[BOM Cache] create_or_update_bom → 缓存已失效 bom_no={bom_no} version={version}") + 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', '') + ) + db.session.add(bom) + db.session.commit() + # ===== 写入后立刻清除缓存(Cache Invalidation) ===== + _cache_delete(bom_no, version) + logger.info(f"[BOM Cache] create_or_update_bom → 缓存已失效 bom_no={bom_no} version={version}") + except Exception as e: + db.session.rollback() + logger.error(f"[BOM] create_or_update_bom 失败 bom_no={bom_no}: {e}") + raise return True @staticmethod diff --git a/inventory-web/src/api/bom.ts b/inventory-web/src/api/bom.ts index 85482bd..6dd2e98 100644 --- a/inventory-web/src/api/bom.ts +++ b/inventory-web/src/api/bom.ts @@ -42,3 +42,22 @@ export function deleteBom(bomNo: string, version: string) { method: 'delete' }) } + +// ========================================== +// BOM 草稿相关接口 +// ========================================== + +// 1. 暂存草稿 +export function saveDraft(data: any) { + return request({ url: '/v1/bom/draft/save', method: 'post', data }) +} + +// 2. 读取草稿详情 +export function getDraftDetail(params: { bom_no: string; version?: string }) { + return request({ url: '/v1/bom/draft/detail', method: 'get', params }) +} + +// 3. 发布草稿 +export function publishDraft(data: { bom_no: string; version: string }) { + return request({ url: '/v1/bom/draft/publish', method: 'post', data }) +} diff --git a/inventory-web/src/views/bom/BomManage.vue b/inventory-web/src/views/bom/BomManage.vue index 60be6df..9cfb83d 100644 --- a/inventory-web/src/views/bom/BomManage.vue +++ b/inventory-web/src/views/bom/BomManage.vue @@ -223,7 +223,25 @@ @@ -236,7 +254,7 @@ import { useRoute } from 'vue-router' import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus' import { Plus, Search, EditPen } from '@element-plus/icons-vue' import { useRouter } from 'vue-router' -import { getBomList, getBomDetail, saveBom, deleteBom } from '@/api/bom' +import { getBomList, getBomDetail, saveBom, deleteBom, getDraftDetail, saveDraft, publishDraft } from '@/api/bom' import { searchMaterialBase } from '@/api/inbound/buy' import { useUserStore } from '@/stores/user' @@ -265,6 +283,22 @@ interface ChildRow { remark: string } +const originalDraftHash = ref('') + +const getDraftHash = () => { + const children = form.children.map(c => ({ + child_id: c.child_id, + dosage: Number(c.dosage) || 0, + remark: c.remark || '' + })) + return JSON.stringify({ + bom_no: form.bom_no, + version: form.version, + parent_id: form.parent_id, + children + }) +} + const userStore = useUserStore() const route = useRoute() const router = useRouter() @@ -277,6 +311,8 @@ const isReadOnlyMode = ref(false) let originalVersion = '' let currentBomNo = '' let originalChildren: ChildRow[] = [] +let pendingDraftBomNo = '' +let pendingDraftVersion = '' const bomGroups = ref([]) // 分组结构: [{category, count, items[]}] const activeCategories = ref([]) // 默认全部展开 @@ -493,13 +529,152 @@ const onChildChange = (val: number | null, index: number) => { } } -const handleCreate = () => { - resetForm() - dialogTitle.value = '新建 BOM' - isEditMode.value = false - isSaveAsMode.value = false - isReadOnlyMode.value = false - dialogVisible.value = true +const getOrGenerateTempBomNo = (): string => { + let storedBomNo = localStorage.getItem('pending_bom_draft_no') + if (storedBomNo) { + return storedBomNo + } + const ts = new Date().toISOString().replace(/[-T:.Z]/g, '').slice(0, 14) + const uid = Math.random().toString(36).slice(2, 10) + const newBomNo = `DRAFT-TEMP-${ts}-${uid}` + localStorage.setItem('pending_bom_draft_no', newBomNo) + return newBomNo +} + +const handleCreate = async () => { + const tempBomNo = getOrGenerateTempBomNo() + pendingDraftBomNo = tempBomNo + pendingDraftVersion = 'V1.0' + + try { + const res = await getDraftDetail({ bom_no: pendingDraftBomNo, version: pendingDraftVersion }) + if (res.code === 200 && res.data) { + const confirm = await ElMessageBox.confirm( + '检测到未发布的草稿,是否恢复继续编辑?', + '草稿恢复', + { confirmButtonText: '恢复草稿', cancelButtonText: '放弃草稿', type: 'info' } + ).catch(() => null) + + if (confirm) { + restoreDraftToForm(res.data) + dialogTitle.value = '新建 BOM' + isEditMode.value = false + isSaveAsMode.value = false + isReadOnlyMode.value = false + dialogVisible.value = true + return + } else { + resetForm() + } + } + } catch (e) {} + + resetForm() + dialogTitle.value = '新建 BOM' + isEditMode.value = false + isSaveAsMode.value = false + isReadOnlyMode.value = false + dialogVisible.value = true +} + +const saveDraftData = async () => { + const draftBomNo = form.bom_no || getOrGenerateTempBomNo() + const currentHash = getDraftHash() + + // 场景 A:之前已经存过/恢复过草稿,且没有任何改动 + if (originalDraftHash.value && currentHash === originalDraftHash.value) { + return ElMessage.warning('草稿数据无变动,无需重复暂存') + } + + // 场景 B:已经存过草稿,且发生了改动 -> 询问覆盖还是新建 + if (originalDraftHash.value && currentHash !== originalDraftHash.value) { + try { + const action = await ElMessageBox.confirm( + '检测到草稿内容已发生变动,请选择保存方式:', + '草稿变动提示', + { + confirmButtonText: '直接覆盖', + cancelButtonText: '另存为新草稿', + distinguishCancelAndClose: true, + type: 'warning' + } + ) + if (action === 'confirm') { + // 选择覆盖原草稿 + await executeSaveDraftRequest(draftBomNo) + } + } catch (action) { + if (action === 'cancel') { + // 选择另存为新草稿:生成新单号并保存 + const ts = new Date().toISOString().replace(/[-T:.Z]/g, '').slice(0, 14) + const uid = Math.random().toString(36).slice(2, 6) + const newTempNo = `DRAFT-TEMP-${ts}-${uid}` + form.bom_no = newTempNo + await executeSaveDraftRequest(newTempNo) + } + } + return + } + + // 场景 C:第一次暂存 + await executeSaveDraftRequest(draftBomNo) +} + +const executeSaveDraftRequest = async (targetBomNo: string) => { + const draftVersion = form.version || 'V1.0' + const children = form.children + .filter(child => child.child_id !== null || child.dosage > 0) + .map(child => ({ + child_id: child.child_id, + dosage: child.dosage, + remark: child.remark || '' + })) + + saving.value = true + try { + const res = await saveDraft({ + bom_no: targetBomNo, + version: draftVersion, + parent_id: form.parent_id, + children + }) + if (res.code === 200) { + ElMessage.success('草稿暂存成功') + form.bom_no = targetBomNo + localStorage.setItem('pending_bom_draft_no', targetBomNo) + originalDraftHash.value = getDraftHash() + } else { + ElMessage.error(res.msg || '暂存失败') + } + } catch (e) { + ElMessage.error('暂存异常') + } finally { + saving.value = false + } +} + +const restoreDraftToForm = (draftData: any) => { + form.children = (draftData.children || []).map((child: any, idx: number) => ({ + rowKey: Date.now() + idx, + child_id: child.child_id, + material_name: child.child_name || '', + material_spec: child.child_spec || '', + dosage: child.dosage, + remark: child.remark || '' + })) + + if (draftData.parent_id) { + form.parent_id = draftData.parent_id + parentNameInput.value = draftData.parent_name || '' + } + + form.bom_no = draftData.bom_no || '' + form.version = draftData.version || 'V1.0' + form.remark = draftData.remark || '' + pendingDraftBomNo = draftData.bom_no || pendingDraftBomNo + pendingDraftVersion = draftData.version || pendingDraftVersion + + originalDraftHash.value = getDraftHash() } const handleView = async (row: BomItem) => { @@ -616,6 +791,9 @@ const resetForm = () => { currentBomNo = '' childSearchKeyword.value = '' parentNameInput.value = '' + originalDraftHash.value = '' + pendingDraftBomNo = '' + pendingDraftVersion = '' if (formRef.value) formRef.value.resetFields() } @@ -662,10 +840,19 @@ const submitForm = async () => { saving.value = true try { - const res = await saveBom(payload) - if (res.code === 200) { ElMessage.success('保存成功'); dialogVisible.value = false; fetchBomList() } - else ElMessage.error(res.msg || '保存失败') - } finally { saving.value = false } + const res = await publishDraft({ bom_no: pureBomNo.value, version: form.version }) + if (res.code === 200) { + ElMessage.success('发布成功') + localStorage.removeItem('pending_bom_draft_no') + originalDraftHash.value = '' + dialogVisible.value = false + fetchBomList() + } else { + ElMessage.error(res.msg || '发布失败') + } + } finally { + saving.value = false + } }) }