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:
@ -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')
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
],
|
||||
|
||||
@ -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()
|
||||
|
||||
Reference in New Issue
Block a user