feat(bom): 自制件子件选版本落库/强校验 + 独立启停 + 状态筛选

- save_bom 落库 child_bom_no/child_bom_version;自制件必选且须属于启用未归档配方集,外购件置空;跨版本查重纳入引用版本
- get_bom_detail 改为读存储列,空则回退最新启用配方;候选/校验/回退读取均排除 is_archived
- 新增 GET /bom/self-bom-versions(子件候选版本);新增 POST /bom/status 独立启停(update_enabled 清缓存)
- 草稿链路 save_draft/get_draft_detail/publish 持久化引用版本
- get_bom_list/get_bom_summary 支持 status(enabled/disabled) 过滤
This commit is contained in:
yueli
2026-09-08 10:14:44 +08:00
parent 6250fa94cb
commit 91fb6c1a03
5 changed files with 206 additions and 26 deletions

View File

@ -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/<path:bom_no>', 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')

View File

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

View File

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

View File

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

View File

@ -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_onlyenabled/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()