2 Commits

Author SHA1 Message Date
dxc
74bc751624 V3.43 2026-06-10 11:36:06 +08:00
DXC
c7b84ff3c6 fix: BOM草稿模块缺陷修复(事务回滚 + 外键约束 + 前端状态清理) 2026-06-10 11:30:07 +08:00
8 changed files with 508 additions and 36 deletions

View File

@ -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

View File

@ -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')
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
)

View File

@ -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
)

View File

@ -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

View File

@ -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

View File

@ -239,7 +239,7 @@ const handleLogout = () => {
<footer v-if="!isLoginPage" class="app-footer">
<span class="version-tag">
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
当前版本:V3.42
当前版本:V3.43
</span>
</footer>

View File

@ -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 })
}

View File

@ -223,7 +223,25 @@
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">{{ isReadOnlyMode ? '关闭' : '取消' }}</el-button>
<el-button v-if="!isReadOnlyMode" type="primary" :loading="saving" @click="submitForm">保存</el-button>
<el-button
v-if="!isReadOnlyMode"
type="warning"
plain
:loading="saving"
@click="saveDraftData"
>
暂存草稿
</el-button>
<el-button
v-if="!isReadOnlyMode"
type="primary"
:loading="saving"
@click="submitForm"
>
保 存
</el-button>
</span>
</template>
</el-dialog>
@ -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
}
})
}