11 Commits

Author SHA1 Message Date
cb1d385e77 V3.73,9.8推送 2026-09-08 11:27:53 +08:00
9fc6ff00f9 feat(bom): 出库/借库清单头部 BOM 号后显示父件物料名
- selectedBomLabel 追加父件名(优先候选列表 parent_name,回退最近详情 parent_name)
- 主清单与欠料待补清单两处头部同时生效;取不到名称时退回原格式
2026-09-08 11:13:42 +08:00
02f74278df feat(bom): 归档业务闭环——状态筛选+归档/取消归档入口
- get_bom_list/get_bom_summary 状态过滤支持 archived,enabled 语义改为“启用且未归档”
- get_bom_list 输出补 is_archived;新增 update_archived 与 POST /bom/archive(整组切换归档、清缓存、审计)
- “未指定版本自动取最新”硬化为仅认启用且未归档(get_bom_detail / get_bom_no_by_parent)
- 前端:状态按钮组加“归档”;列表状态列区分归档;操作列加“归档/取消归档”按钮
2026-09-08 11:13:38 +08:00
1daa60590c feat(bom): BOM 管理页 UI——子件版本下拉、只读启停、状态按钮组、公司筛选、手风琴、详情 loading
- 自制件子件行内版本下拉(必选,默认最新启用,外购件不显示);保存/草稿带引用版本
- 查看/只读态“是否启用”开关解耦,切换走独立启停接口
- 状态筛选改 el-radio-group 按钮组(默认启用)
- 顶部接入 CompanySelector,父件/子件物料搜索联动公司并标注公司
- 分类手风琴(全部展开临时关手风琴)与详情弹窗 v-loading;列表默认只显示启用
2026-09-08 10:14:51 +08:00
424df231d5 feat(bom): 前端 BOM API——候选版本、独立启停、物料搜索支持公司
- bom.ts 新增 getChildBomVersions(候选版本) 与 updateBomStatus(独立启停)
- inbound/buy.ts searchMaterialBase 增加可选 company 参数(跨域按公司过滤)
2026-09-08 10:14:47 +08:00
91fb6c1a03 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) 过滤
2026-09-08 10:14:44 +08:00
6250fa94cb feat(bom): BOM 子件引用版本与归档 数据库迁移
- bom_table/bom_draft_table 加 child_bom_no/child_bom_version(子件引用的自制BOM版本)
- bom_table 加 is_archived 归档标记(仍启用可查看/编辑,但不再作为子件版本候选)
- 回填 60 行自制件子件引用版本,4 个多版本自制件按业务口径(79/92/226/1905)
- 3 套占位/废弃配方改标归档并恢复启用
2026-09-08 10:14:40 +08:00
7d9828e403 feat: BOM套餐添加弹窗齐套清单显示自制件子件版本号 2026-09-07 18:03:43 +08:00
3dd8458de0 feat: BOM 子件展示自制件 BOM 版本号——子件列表与出库/借库 BOM 清单打印均显示 2026-09-07 18:01:16 +08:00
d182af7d2a feat: 采购申请支持整批修改重提——批次主行对有被驳回明细的批次一键载入全部被驳明细重提 2026-09-07 17:08:37 +08:00
d163a36117 feat: 采购申请被驳回单支持「修改重提」——预填原单内容改后重新提交 2026-09-07 17:02:04 +08:00
14 changed files with 759 additions and 63 deletions

View File

@ -0,0 +1,72 @@
-- =============================================================================
-- 一次性迁移:BOM 自制件子件"选版本"落库
-- -----------------------------------------------------------------------------
-- 目标:
-- 1. bom_table / bom_draft_table 增加 child_bom_no、child_bom_version 两列
-- (子件引用的自制 BOM 配方版本,外购件子件为 NULL)
-- 2. 停用 3 套"占位/废弃"配方(is_enabled=false),使其退出版本下拉候选:
-- - parent_id=79 (四代16线激光雷达安装套件V1J) ULR16-B2 V1.0 (仅1行: 侧面壳, 已被 ULR16-B2V1J V1.0 覆盖)
-- - parent_id=226 (物联智能电源控制器V1J) TF-A4 V1.0 (挂占位件 Too9999)
-- - parent_id=92 (机载SUC控制器-四代一体机V1J) UHRL-B8V1J V2.0 (挂占位件 OS9999, 未维护完)
-- 3. 回填 bom_table 中所有"自制件子件引用行"的 child_bom_* 两列,
-- 值 = 该子件作为父件时"最新启用配方"(version 倒序取第一条)。
-- 停用占位后: 79→ULR16-B2V1J V1.0, 226→TF-A4V1J V1.0, 92→UHRL-B8V1J V1.1;
-- 1905 两版本仍启用 → 取 V1.1 (业务确认: 父件 LTFJ1-A2 V1.0/V2.0 两处引用均用子件 V1.1)。
-- 随后以 4 个多版本自制件的人工确认值显式覆盖,作为兜底与可读性依据。
--
-- 执行方式: docker exec -i inventory_db psql -U test -d inventory_system < 本文件
-- 注意: 一次性迁移,勿在生产重复执行。若后端启用 Redis 缓存(bom:tree:*),
-- 部署后需清缓存或重启后端,避免 12h 内命中旧推导值。
-- =============================================================================
BEGIN;
-- 1. 加列(幂等,可容忍重复执行)
ALTER TABLE bom_table ADD COLUMN IF NOT EXISTS child_bom_no varchar(100);
ALTER TABLE bom_table ADD COLUMN IF NOT EXISTS child_bom_version varchar(50);
ALTER TABLE bom_draft_table ADD COLUMN IF NOT EXISTS child_bom_no varchar(100);
ALTER TABLE bom_draft_table ADD COLUMN IF NOT EXISTS child_bom_version varchar(50);
-- 2. 停用占位/废弃配方(仅退出启用,不删除数据)
UPDATE bom_table SET is_enabled = FALSE
WHERE is_enabled = TRUE AND (
(parent_id = 79 AND bom_no = 'ULR16-B2' AND version = 'V1.0')
OR (parent_id = 226 AND bom_no = 'TF-A4' AND version = 'V1.0')
OR (parent_id = 92 AND bom_no = 'UHRL-B8V1J' AND version = 'V2.0')
);
-- 3a. 通用回填:引用行(child_id 作为父件存在启用配方) → 该 child 的最新启用配方
UPDATE bom_table b
SET child_bom_no = t.bom_no,
child_bom_version = t.version
FROM (
SELECT parent_id, bom_no, version
FROM (
SELECT parent_id, bom_no, version,
row_number() OVER (
PARTITION BY parent_id
ORDER BY version DESC, bom_no DESC
) AS rn
FROM (
SELECT DISTINCT parent_id, bom_no, version
FROM bom_table
WHERE is_enabled = TRUE
) d
) x
WHERE rn = 1
) t
WHERE b.child_bom_no IS NULL
AND b.child_bom_version IS NULL
AND b.child_id = t.parent_id;
-- 3b. 人工确认值显式覆盖(业务口径,与 3a 结果一致,作兜底/留痕)
UPDATE bom_table SET child_bom_no = 'ULR16-B2V1J', child_bom_version = 'V1.0'
WHERE child_id = 79;
UPDATE bom_table SET child_bom_no = 'TF-A4V1J', child_bom_version = 'V1.0'
WHERE child_id = 226;
UPDATE bom_table SET child_bom_no = 'UHRL-B8V1J', child_bom_version = 'V1.1'
WHERE child_id = 92;
UPDATE bom_table SET child_bom_no = 'TFJ1-A2V1J', child_bom_version = 'V1.1'
WHERE child_id = 1905;
COMMIT;

View File

@ -0,0 +1,36 @@
-- =============================================================================
-- 修正:占位配方"停用"改为"归档"(恢复 is_enabled 的启停语义)
-- -----------------------------------------------------------------------------
-- 背景: 上一版把 3 套占位配方 is_enabled=false 以退出候选,但那会影响 BOM
-- 自身的启停/编辑体验。现改为独立"归档"标记 is_archived:
-- 1) 加 bom_table.is_archived 列(默认 false)
-- 2) 撤销对 3 套占位配方的停用(is_enabled 恢复 TRUE,可查看/编辑/启停)
-- 3) 将该 3 套标记 is_archived=TRUE:仍启用,但不再作为其它 BOM
-- 子件的"版本候选"与可引用版本(候选/校验/回退读取均排除归档)
-- 被归档配方:
-- parent_id=79 (四代16线激光雷达安装套件V1J) ULR16-B2 V1.0
-- parent_id=226 (物联智能电源控制器V1J) TF-A4 V1.0
-- parent_id=92 (机载SUC控制器-四代一体机V1J) UHRL-B8V1J V2.0
-- 执行: docker exec -i inventory_db psql -U test -d inventory_system < 本文件
-- =============================================================================
BEGIN;
-- 1) 加归档列(幂等)
ALTER TABLE bom_table ADD COLUMN IF NOT EXISTS is_archived boolean NOT NULL DEFAULT false;
-- 2) 撤销停用:恢复 is_enabled(仅作用于我们上次停用的这 3 行)
UPDATE bom_table SET is_enabled = TRUE
WHERE is_enabled = FALSE AND (
(parent_id = 79 AND bom_no = 'ULR16-B2' AND version = 'V1.0')
OR (parent_id = 226 AND bom_no = 'TF-A4' AND version = 'V1.0')
OR (parent_id = 92 AND bom_no = 'UHRL-B8V1J' AND version = 'V2.0')
);
-- 3) 标记归档(幂等,重复执行无副作用)
UPDATE bom_table SET is_archived = TRUE
WHERE (parent_id = 79 AND bom_no = 'ULR16-B2' AND version = 'V1.0')
OR (parent_id = 226 AND bom_no = 'TF-A4' AND version = 'V1.0')
OR (parent_id = 92 AND bom_no = 'UHRL-B8V1J' AND version = 'V2.0');
COMMIT;

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,72 @@ 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 归档/取消归档(仍启用可编辑,但不作为其它 BOM 子件引用候选) ====================
@bom_bp.route('/archive', 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_archive():
"""切换某 BOM 版本(整组)的归档状态。"""
try:
data = request.get_json() or {}
bom_no = data.get('bom_no')
version = data.get('version')
is_archived = data.get('is_archived')
if not bom_no or not version:
return jsonify({'code': 400, 'msg': 'bom_no 与 version 不能为空'}), 400
if not isinstance(is_archived, bool):
return jsonify({'code': 400, 'msg': 'is_archived 必须为布尔值'}), 400
BomService.update_archived(bom_no, version, is_archived)
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 +458,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 聚合 + 分页)
@ -133,6 +133,7 @@ class BomService:
MaterialBase.spec_model.label('parent_spec'),
MaterialBase.category.label('parent_category'),
BomTable.is_enabled,
BomTable.is_archived,
func.count(BomTable.child_id).label('child_count'),
func.string_agg(child_alias.name, ', ').label('child_names'),
func.string_agg(child_alias.spec_model, ', ').label('child_specs')
@ -143,11 +144,18 @@ class BomService:
).group_by(
BomTable.bom_no, BomTable.version, BomTable.parent_id,
MaterialBase.name, MaterialBase.spec_model, MaterialBase.category,
BomTable.is_enabled
BomTable.is_enabled, BomTable.is_archived
)
# 过滤禁用状态
if active_only:
# 状态过滤:status 优先于旧参数 active_only
# enabled=启用且未归档(在用);disabled=停用;archived=归档(保留可见/编辑,但不作子件引用候选)
if status == 'enabled':
query = query.filter(BomTable.is_enabled == True, BomTable.is_archived == False)
elif status == 'disabled':
query = query.filter(BomTable.is_enabled == False)
elif status == 'archived':
query = query.filter(BomTable.is_archived == True)
elif active_only:
query = query.filter(BomTable.is_enabled == True)
# 【行级数据隔离】基于 JWT 多租户公司过滤
@ -207,6 +215,7 @@ class BomService:
'parent_spec': row.parent_spec or '',
'parent_category': row.parent_category or '',
'is_enabled': row.is_enabled,
'is_archived': row.is_archived,
'child_count': row.child_count,
'child_names': row.child_names or '',
'child_specs': row.child_specs or ''
@ -220,7 +229,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 +258,15 @@ class BomService:
if company_limit is not None:
query = query.filter(MaterialBase.company_name == company_limit)
# 状态过滤(按 (bom_no, version) 维度计数,故过滤粒度到行即可,行状态同版本一致)
# enabled=启用且未归档;disabled=停用;archived=归档
if status == 'enabled':
query = query.filter(BomTable.is_enabled == True, BomTable.is_archived == False)
elif status == 'disabled':
query = query.filter(BomTable.is_enabled == False)
elif status == 'archived':
query = query.filter(BomTable.is_archived == True)
# 关键词搜索
if keyword:
kw = f'%{keyword.strip()}%'
@ -303,8 +321,12 @@ class BomService:
if version:
query = query.filter(BomTable.version == version)
else:
latest_ver = db.session.query(BomTable.version).filter_by(bom_no=bom_no) \
.order_by(BomTable.version.desc()).limit(1).scalar()
# ★ 自动"取最新"只认"启用且未归档",避免默认命中已停用/已归档版本
latest_ver = db.session.query(BomTable.version).filter(
BomTable.bom_no == bom_no,
BomTable.is_enabled == True,
BomTable.is_archived == False
).order_by(BomTable.version.desc()).limit(1).scalar()
if not latest_ver:
return None
query = query.filter(BomTable.version == latest_ver)
@ -320,13 +342,38 @@ class BomService:
parent_material = MaterialBase.query.get(parent_id)
children = []
# ★ 子件引用版本:优先读 bom_table 存储列(新建 BOM 时选定并落库)。
# 仅对"存储列为空但子件实为自制件"的旧数据/Excel 导入行,回退取其"最新启用配方"作展示兼容。
child_self_bom = {}
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),
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:
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 ''
'remark': bom.remark or '',
'child_bom_no': cb_no,
'child_bom_version': cb_ver
})
result = {
@ -361,18 +408,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 # 排除当前正在保存的版本
@ -380,17 +434,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()
@ -408,6 +505,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)
@ -421,6 +520,38 @@ 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 update_archived(bom_no, version, archived):
"""
仅更新某 BOM 版本(整组行)的归档标记。
归档=仍启用可查看/编辑,但不再作为其它 BOM 子件的引用候选;取消归档即恢复正常引用。
"""
rows = BomTable.query.filter_by(bom_no=bom_no, version=version).all()
if not rows:
raise ValueError('BOM 不存在')
for rec in rows:
rec.is_archived = bool(archived)
db.session.commit()
_cache_delete(bom_no, version)
logger.info(f"[BOM Cache] update_archived → 缓存已失效 bom_no={bom_no} version={version} archived={archived}")
return len(rows)
@staticmethod
def get_bom_with_stock_by_bom_no(bom_no, version=None):
"""
@ -477,9 +608,32 @@ class BomService:
# ====================== 兼容旧接口 ======================
@staticmethod
def get_bom_no_by_parent(parent_id):
row = BomTable.query.filter_by(parent_id=parent_id).order_by(BomTable.version.desc()).first()
# ★ 仅取"启用且未归档"的最新配方,避免默认命中已停用/已归档版本
row = BomTable.query.filter(
BomTable.parent_id == parent_id,
BomTable.is_enabled == True,
BomTable.is_archived == False
).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:
@ -495,7 +649,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()

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.72
当前版本:V3.73
</span>
</footer>

View File

@ -29,6 +29,15 @@ export function getBomWithStock(bomNo: string, version?: string) {
})
}
// 获取某物料作为父件时的启用 BOM 版本清单(自制件子件版本下拉数据源)
export function getChildBomVersions(childId: number) {
return request({
url: '/v1/bom/self-bom-versions',
method: 'get',
params: { child_id: childId }
})
}
// 获取BOM详情
export function getBomDetail(bomNo: string, version?: string) {
// 去除首尾斜杠,保留中间斜杠并进行 URL 编码
@ -43,6 +52,24 @@ export function getBomDetail(bomNo: string, version?: string) {
})
}
// 仅更新 BOM 启用/停用状态(查看/只读视图的开关专用,不触发整表保存)
export function updateBomStatus(data: { bom_no: string; version: string; is_enabled: boolean }) {
return request({
url: '/v1/bom/status',
method: 'post',
data
})
}
// 归档/取消归档(仍启用可编辑,但不再作为其它 BOM 子件的引用候选)
export function updateBomArchive(data: { bom_no: string; version: string; is_archived: boolean }) {
return request({
url: '/v1/bom/archive',
method: 'post',
data
})
}
// 保存BOM
export function saveBom(data: any) {
return request({

View File

@ -43,12 +43,14 @@ export function getFilterOptions() {
})
}
// 6. 搜索基础物料
export function searchMaterialBase(keyword: string, page: number = 1) {
// 6. 搜索基础物料(company 可选:跨域下按公司过滤;不传则由后端按当前用户公司隔离)
export function searchMaterialBase(keyword: string, page: number = 1, company?: string) {
const params: any = { keyword, page }
if (company) params.company_name = company
return request({
url: '/inbound/buy/search-base',
method: 'get',
params: { keyword, page }
params
})
}

View File

@ -17,6 +17,13 @@
<el-button :icon="Search" @click="handleSearch" />
</template>
</el-input>
<company-selector v-model="bomCompany" @change="handleSearch" style="margin-right: 10px;" />
<el-radio-group v-model="bomStatusFilter" @change="handleSearch" style="margin-right: 15px;">
<el-radio-button :label="''">全部</el-radio-button>
<el-radio-button label="enabled">启用</el-radio-button>
<el-radio-button label="disabled">停用</el-radio-button>
<el-radio-button label="archived">归档</el-radio-button>
</el-radio-group>
<el-button @click="expandAllGroups" size="small" style="margin-right: 6px;">全部展开</el-button>
<el-button @click="collapseAllGroups" size="small" style="margin-right: 10px;">全部折叠</el-button>
<el-button v-if="userStore.hasPermission('bom_manage:operation')" type="success" plain :icon="Upload" @click="showImportDialog = true" style="margin-right:10px">导入BOM</el-button>
@ -27,7 +34,7 @@
<el-skeleton :rows="8" animated v-if="loading && groupSummary.length === 0" />
<el-empty v-else-if="!loading && groupSummary.length === 0" description="暂无 BOM 数据" />
<el-collapse v-else v-model="activeCategories" class="bom-category-collapse" @change="handleCollapseChange">
<el-collapse v-else v-model="activeCategories" :accordion="isAccordion" class="bom-category-collapse" @change="handleCollapseChange">
<el-collapse-item
v-for="group in groupedData"
:key="group.category"
@ -49,13 +56,15 @@
</el-table-column>
<el-table-column v-if="hasColumnPermission('status')" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.is_enabled ? 'success' : 'danger'">{{ row.is_enabled ? '启用' : '禁用' }}</el-tag>
<el-tag v-if="row.is_archived" type="info">归档</el-tag>
<el-tag v-else :type="row.is_enabled ? 'success' : 'danger'">{{ row.is_enabled ? '启用' : '停用' }}</el-tag>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('child_count')" prop="child_count" label="子件数" width="80" align="center" />
<el-table-column v-if="userStore.hasPermission('bom_manage:operation')" label="操作" width="160" align="center" fixed="right">
<el-table-column v-if="userStore.hasPermission('bom_manage:operation')" label="操作" width="230" align="center" fixed="right">
<template #default="{ row }">
<el-button type="success" link @click="handleSaveAs(row)">另存为</el-button>
<el-button type="warning" link @click="handleArchive(row)">{{ row.is_archived ? '取消归档' : '归档' }}</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
@ -68,7 +77,7 @@
</el-card>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="850px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
<el-form :model="form" label-width="120px" ref="formRef" :rules="rules">
<el-form :model="form" label-width="120px" ref="formRef" :rules="rules" v-loading="detailLoading">
<el-row :gutter="20">
<el-col :span="16">
@ -93,6 +102,7 @@
<div class="option-row">
<span class="option-name">{{ item.name }}</span>
<span class="option-spec">{{ item.spec }}</span>
<span v-if="item.company_name" style="font-size:12px;color:#c45656;background:#fef0f0;border-radius:2px;padding:0 4px;margin-left:auto;flex-shrink:0;">{{ item.company_name }}</span>
</div>
</template>
</el-autocomplete>
@ -112,7 +122,15 @@
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="是否启用" prop="is_enabled" v-if="hasFormFieldPermission('is_enabled')">
<el-switch v-model="form.is_enabled" active-text="启用" inactive-text="禁用" :disabled="isReadOnlyMode || !userStore.hasPermission('bom_manage:operation')" />
<!-- ★ disabled 仅与操作权限绑定,不再随只读视图全局禁用:
查看/只读态下切换会走 updateBomStatus 单独更新状态(见 onEnabledStatusChange) -->
<el-switch
v-model="form.is_enabled"
active-text="启用"
inactive-text="禁用"
:disabled="!userStore.hasPermission('bom_manage:operation')"
@change="onEnabledStatusChange"
/>
</el-form-item>
</el-col>
<el-col :span="16"></el-col>
@ -184,6 +202,7 @@
<div class="option-row">
<span class="option-name">{{ item.name }}</span>
<span class="option-spec">{{ item.spec }}</span>
<span v-if="item.company_name" style="font-size:12px;color:#c45656;background:#fef0f0;border-radius:2px;padding:0 4px;margin-left:auto;flex-shrink:0;">{{ item.company_name }}</span>
</div>
</template>
</el-autocomplete>
@ -197,6 +216,29 @@
/>
</el-tooltip>
</div>
<!-- ★ 自制件子件:编辑态出现"版本"必选下拉;只读态显示其引用的 BOM 版本 -->
<div v-if="childIsSelfMade(row)" style="margin-top: 4px;">
<el-select
v-if="!isReadOnlyMode && row.bom_version_options && row.bom_version_options.length"
v-model="row.child_bom_key"
placeholder="请选择BOM版本"
style="width: 100%;"
size="small"
filterable
@change="() => onChildBomChange(row)"
>
<el-option
v-for="opt in row.bom_version_options"
:key="bomKeyOf(opt.bom_no, opt.version)"
:label="opt.bom_no + ' ' + opt.version"
:value="bomKeyOf(opt.bom_no, opt.version)"
/>
</el-select>
<el-tag v-else-if="row.child_bom_version" size="small" type="warning">
自制 {{ row.child_bom_no ? row.child_bom_no + ' ' : '' }}{{ row.child_bom_version }}
</el-tag>
<div v-else-if="!isReadOnlyMode" style="font-size: 12px; color: #E6A23C;">请选择该自制件的 BOM 版本</div>
</div>
</template>
</el-table-column>
@ -262,8 +304,9 @@ import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus'
import { Plus, Search, EditPen, Upload } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import { getBomList, getBomSummary, getBomDetail, saveBom, deleteBom, getDraftDetail, saveDraft, publishDraft } from '@/api/bom'
import { getBomList, getBomSummary, getBomDetail, saveBom, deleteBom, getDraftDetail, saveDraft, publishDraft, getChildBomVersions, updateBomStatus, updateBomArchive } from '@/api/bom'
import ImportDialog from '@/components/ImportDialog.vue'
import CompanySelector from '@/components/CompanySelector.vue'
import { searchMaterialBase } from '@/api/inbound/buy'
import { useUserStore } from '@/stores/user'
@ -276,6 +319,7 @@ interface BomItem {
parent_name: string
version: string
is_enabled: boolean
is_archived?: boolean
child_count: number
}
interface MaterialBase {
@ -290,6 +334,64 @@ interface ChildRow {
material_spec: string
dosage: number
remark: string
child_bom_no?: string // 子件引用的自制 BOM 编号(必选:该物料作父件的配方)
child_bom_version?: string // 子件引用的自制 BOM 版本(必选,见 bom_version_options)
child_bom_key?: string // 下拉选中项编码 `${bom_no}|${version}`
bom_version_options?: { bom_no: string; version: string }[] // 该物料作父件的启用配方候选
}
// ★ 自制件子件"版本"候选与选中项工具
const BOM_KEY_SEP = '|'
const bomKeyOf = (no?: string, ver?: string) => (no || '') + BOM_KEY_SEP + (ver || '')
const bomKeyTo = (key?: string) => {
const s = String(key || '')
const i = s.indexOf(BOM_KEY_SEP)
if (i < 0) return ['', '']
return [s.slice(0, i), s.slice(i + 1)]
}
const childIsSelfMade = (row: any) =>
!!(row && ((row.bom_version_options && row.bom_version_options.length) || row.child_bom_no || row.child_bom_version))
// 拉取某子件物料作父件的启用配方列表,并维护选中项。
// - 有候选: 若当前已有合法选中(编辑存量行)则保留,否则默认选"最新启用配方"(列表第一项)
// - 无候选(外购件/无现行BOM): 清空版本相关字段
const loadChildBomVersions = async (row: any) => {
if (!row.child_id) { row.bom_version_options = []; return }
let list: { bom_no: string; version: string }[] = []
try {
const res: any = await getChildBomVersions(row.child_id)
if (res?.code === 200 && Array.isArray(res.data)) list = res.data
} catch (e) { list = [] }
row.bom_version_options = list
if (!list.length) {
row.child_bom_no = ''
row.child_bom_version = ''
row.child_bom_key = ''
return
}
const existKey = bomKeyOf(row.child_bom_no, row.child_bom_version)
if (row.child_bom_no && row.child_bom_version && list.some(o => bomKeyOf(o.bom_no, o.version) === existKey)) {
row.child_bom_key = existKey
return
}
const first = list[0]
row.child_bom_no = first.bom_no
row.child_bom_version = first.version
row.child_bom_key = bomKeyOf(first.bom_no, first.version)
}
const onChildBomChange = (row: any) => {
const [no, ver] = bomKeyTo(row.child_bom_key)
row.child_bom_no = no
row.child_bom_version = ver
}
// 给现有 children(从详情/草稿/另存为载入的存量自制件子件)补齐版本候选,
// 使编辑态能渲染下拉;保留其已存储的引用版本。
const hydrateChildBomVersions = async () => {
for (const c of form.children) {
if (c.child_id) await loadChildBomVersions(c)
}
}
const originalDraftHash = ref('')
@ -298,7 +400,9 @@ const getDraftHash = () => {
const children = form.children.map(c => ({
child_id: c.child_id,
dosage: Number(c.dosage) || 0,
remark: c.remark || ''
remark: c.remark || '',
child_bom_no: c.child_bom_no || '',
child_bom_version: c.child_bom_version || ''
}))
return JSON.stringify({
bom_no: form.bom_no,
@ -326,6 +430,11 @@ let pendingDraftVersion = ''
const searchKeyword = ref('')
const childSearchKeyword = ref('')
const bomStatusFilter = ref('enabled') // 默认只看"启用",可手动切全部/停用;enabled=启用 | disabled=停用 | ''=全部
const bomCompany = ref('') // 跨域用户按公司(父件物料公司)过滤;普通用户由后端强制隔离,此控件不显示
// ★ 手风琴开关:默认单开;"全部展开"时临时关闭手风琴以允许多开,"全部折叠"后恢复
const isAccordion = ref(true)
const detailLoading = ref(false) // 弹窗查看详情 loading(防"假死"反馈)
// ★ 懒加载分组架构
interface GroupSummary { category: string; count: number }
@ -373,7 +482,7 @@ const fetchParentSuggestions = (query: string, cb: (results: any[]) => void) =>
const rawQuery = String(query || '')
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
searchLoading.value = true
searchMaterialBase(safeQuery).then((res: any) => {
searchMaterialBase(safeQuery, 1, bomCompany.value || '').then((res: any) => {
const items = res.data?.items || res.data || []
const formatted = items.map((i: any) => ({ ...i, name: i.name || i.material_name }))
cb(formatted)
@ -399,7 +508,7 @@ const fetchChildSuggestions = (row: any, query: string, cb: (results: any[]) =>
const rawQuery = String(query || '')
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
searchLoading.value = true
searchMaterialBase(safeQuery).then((res: any) => {
searchMaterialBase(safeQuery, 1, bomCompany.value || '').then((res: any) => {
const items = res.data?.items || res.data || []
const formatted = items.map((i: any) => ({ ...i, name: i.name || i.material_name }))
cb(formatted)
@ -411,6 +520,10 @@ const onChildClear = (row: any) => {
row.material_name = ''
row.material_spec = ''
row.dosage = 0
row.child_bom_no = ''
row.child_bom_version = ''
row.child_bom_key = ''
row.bom_version_options = []
}
const onChildSelected = (row: any, item: any) => {
@ -420,12 +533,19 @@ const onChildSelected = (row: any, item: any) => {
row.child_id = null
row.material_name = ''
row.dosage = 0
row.bom_version_options = []
return
}
row.child_id = item.id
row.material_name = item.name
row.material_spec = item.spec
row.dosage = 1
row.child_bom_no = ''
row.child_bom_version = ''
row.child_bom_key = ''
row.bom_version_options = []
// 选中物料后判定:若其为自制件(存在启用BOM),拉取候选版本并默认选最新,否则清空。
loadChildBomVersions(row)
}
const getChildSpec = (rowKey: number): string => {
const row = form.children.find(c => c.rowKey === rowKey)
@ -528,6 +648,7 @@ const dialogTitle = ref('新建 BOM')
const handleSearch = () => {
activeCategories.value = []
groupCache.value = new Map()
isAccordion.value = true // 搜索/筛选重置回默认手风琴模式
fetchBomSummary()
}
@ -537,6 +658,8 @@ const fetchBomSummary = async () => {
try {
const params: any = {}
if (searchKeyword.value) params.keyword = searchKeyword.value
if (bomStatusFilter.value) params.status = bomStatusFilter.value
if (bomCompany.value) params.company_name = bomCompany.value
const res: any = await getBomSummary(params)
if (res?.code === 200) {
groupSummary.value = res.data ?? []
@ -549,12 +672,17 @@ const fetchBomSummary = async () => {
finally { loading.value = false }
}
// ★ 默认全部展开(用户点击按钮时)
// ★ 全部展开:先临时关闭手风琴(允许多开),再一次性展开+懒加载各分类
const expandAllGroups = () => {
isAccordion.value = false
activeCategories.value = groupSummary.value.map(g => g.category)
groupSummary.value.forEach(g => loadGroupItems(g.category))
}
const collapseAllGroups = () => { activeCategories.value = [] }
// ★ 全部折叠:清空面板,并恢复手风琴模式
const collapseAllGroups = () => {
activeCategories.value = []
isAccordion.value = true
}
// ★ 展开分组时懒加载该分类下的 BOM
const loadGroupItems = async (category: string) => {
@ -569,6 +697,8 @@ const loadGroupItems = async (category: string) => {
pageSize: 9999 // 单分类内全量加载(分类内数据量可控)
}
if (searchKeyword.value) params.keyword = searchKeyword.value
if (bomStatusFilter.value) params.status = bomStatusFilter.value
if (bomCompany.value) params.company_name = bomCompany.value
const res: any = await getBomList(params)
if (res?.code === 200) {
groupCache.value.set(category, res.data?.items ?? [])
@ -649,7 +779,9 @@ const executeSaveDraftRequest = async (targetBomNo: string) => {
.map(child => ({
child_id: child.child_id,
dosage: child.dosage,
remark: child.remark || ''
remark: child.remark || '',
child_bom_no: child.child_bom_no || '',
child_bom_version: child.child_bom_version || ''
}))
saving.value = true
@ -683,7 +815,9 @@ const restoreDraftToForm = (draftData: any) => {
material_name: child.child_name || '',
material_spec: child.child_spec || '',
dosage: child.dosage,
remark: child.remark || ''
remark: child.remark || '',
child_bom_no: child.child_bom_no || '',
child_bom_version: child.child_bom_version || ''
}))
if (draftData.parent_id) {
@ -698,6 +832,8 @@ const restoreDraftToForm = (draftData: any) => {
pendingDraftVersion = draftData.version || pendingDraftVersion
originalDraftHash.value = getDraftHash()
// 草稿中存量自制件子件也要补齐版本候选(保留其已存版本),便于继续编辑
hydrateChildBomVersions()
}
const checkUserDraft = async (bomNo: string, version: string) => {
@ -766,12 +902,13 @@ const checkAndInterceptGlobalDraft = async (): Promise<boolean> => {
}
const handleView = async (row: BomItem) => {
await loadDetail(row.bom_no, row.version)
resetForm() // 清空旧内容,避免加载期间闪现上一条数据
dialogTitle.value = '查看 BOM'
isEditMode.value = false
isSaveAsMode.value = false
isReadOnlyMode.value = true
dialogVisible.value = true
dialogVisible.value = true // 先开窗,让 detailLoading 骨架屏可见
await loadDetail(row.bom_no, row.version)
}
const handleSaveAs = async (row: any) => {
@ -800,7 +937,9 @@ const handleSaveAs = async (row: any) => {
material_name: child.child_name || '未知物料',
material_spec: child.child_spec || '',
dosage: child.dosage,
remark: child.remark || ''
remark: child.remark || '',
child_bom_no: child.child_bom_no || '',
child_bom_version: child.child_bom_version || ''
}))
if (raw.parent_id) {
form.parent_id = raw.parent_id
@ -817,6 +956,9 @@ const handleSaveAs = async (row: any) => {
originalChildren = JSON.parse(JSON.stringify(form.children))
originalDraftHash.value = getDraftHash()
// 另存/升版编辑:存量自制件子件补齐版本候选(保留其已存储的引用版本)
hydrateChildBomVersions()
dialogTitle.value = '新增 BOM (版本升级)'
isEditMode.value = false
isSaveAsMode.value = true
@ -825,6 +967,7 @@ const handleSaveAs = async (row: any) => {
}
const loadDetail = async (bomNo: string, version: string) => {
detailLoading.value = true
try {
const res = await getBomDetail(bomNo, version)
if (res.code === 200) {
@ -836,7 +979,9 @@ const loadDetail = async (bomNo: string, version: string) => {
material_name: child.child_name || '未知物料',
material_spec: child.child_spec || '',
dosage: child.dosage,
remark: child.remark || ''
remark: child.remark || '',
child_bom_no: child.child_bom_no || '',
child_bom_version: child.child_bom_version || ''
}))
if (data.parent_id) {
@ -850,8 +995,66 @@ const loadDetail = async (bomNo: string, version: string) => {
form.bom_no = bomNo.split('/')[0].trim()
}
form.remark = data.remark || ''
// 查看/只读视图的独立启停需要版本与当前状态
form.version = version || data.version || 'V1.0'
form.is_enabled = data.is_enabled !== false
}
} catch (e) {}
} catch (e) {
// 网络/数据异常:仅结束 loading,不弹全局错误,保持弹窗可操作
} finally {
detailLoading.value = false
}
}
// ★ 启用/停用开关:查看/只读态下独立更新状态(不触发整表单保存);
// 新建/编辑/另存模式则仅记录到 form,随 submitForm 一并保存。
const onEnabledStatusChange = async () => {
if (!isReadOnlyMode.value) return
if (!pureBomNo.value || !form.version) {
ElMessage.warning('缺少 BOM 编号/版本,无法更新状态')
return
}
const target = !!form.is_enabled
saving.value = true
try {
const res: any = await updateBomStatus({ bom_no: pureBomNo.value, version: form.version, is_enabled: target })
if (res?.code === 200) {
ElMessage.success(target ? '已启用' : '已停用')
// 刷新左侧列表摘要(计数与状态列)
groupCache.value = new Map()
fetchBomSummary()
} else {
form.is_enabled = !target
ElMessage.error(res?.msg || '状态更新失败')
}
} catch (e) {
form.is_enabled = !target
ElMessage.error('状态更新失败')
} finally {
saving.value = false
}
}
// ★ 归档/取消归档:仍启用可编辑,但不再作为其它 BOM 子件的版本候选
const handleArchive = async (row: any) => {
const target = !row.is_archived
const tip = target
? `确定归档 ${row.bom_no} (${row.version}) 吗?\n归档后仍可查看/编辑/取消归档,但不再作为其它 BOM 子件的版本候选。`
: `确定取消归档 ${row.bom_no} (${row.version}) 吗?`
try {
await ElMessageBox.confirm(tip, '提示', { type: 'warning' })
} catch (e) { return }
try {
const res: any = await updateBomArchive({ bom_no: row.bom_no, version: row.version, is_archived: target })
if (res?.code === 200) {
ElMessage.success(target ? '已归档' : '已取消归档')
groupCache.value = new Map()
fetchBomSummary()
} else {
ElMessage.error(res?.msg || '操作失败')
}
} catch (e) { ElMessage.error('归档操作失败') }
}
const handleDelete = (row: BomItem) => {
@ -909,14 +1112,25 @@ const submitForm = async () => {
}
if (isSaveAsMode.value && originalChildren.length > 0) {
const currentSet = new Set(form.children.map(c => `${c.child_id}-${c.dosage}`))
const originalSet = new Set(originalChildren.map(c => `${c.child_id}-${c.dosage}`))
const childKey = (c: any) => `${c.child_id}-${c.dosage}-${c.child_bom_no || ''}-${c.child_bom_version || ''}`
const currentSet = new Set(form.children.map(childKey))
const originalSet = new Set(originalChildren.map(childKey))
const isIdentical = currentSet.size === originalSet.size && [...currentSet].every(item => originalSet.has(item))
if (isIdentical) {
return ElMessage.warning('您未修改任何子件,与原版本内容一致,请修改后再保存')
}
}
// 前置校验:自制件子件必须已选版本(后端仍会强校验兜底)
const missingVer = form.children.find(
(c: any) => c.child_id !== null &&
(c.child_bom_no || c.child_bom_version || (c.bom_version_options && c.bom_version_options.length)) &&
!(c.child_bom_no && c.child_bom_version)
)
if (missingVer) {
return ElMessage.warning(`子件「${missingVer.material_name || missingVer.child_id}」为自制件,请先选择其 BOM 版本`)
}
const payload = {
bom_no: pureBomNo.value,
remark: form.remark,
@ -928,7 +1142,9 @@ const submitForm = async () => {
.map((c: any) => ({
child_id: c.child_id,
dosage: c.dosage,
remark: c.remark || ''
remark: c.remark || '',
child_bom_no: c.child_bom_no || '',
child_bom_version: c.child_bom_version || ''
}))
}

View File

@ -223,7 +223,12 @@
</template>
<el-table :data="bomDetailList" size="small" border style="margin-top: 8px; width: 100%;">
<el-table-column prop="name" label="物料名称" min-width="120" />
<el-table-column prop="sku" label="SKU" width="100" />
<el-table-column label="SKU/规格" width="150">
<template #default="{ row }">
<span>{{ row.sku }}</span>
<span v-if="row.child_bom_version" style="color:#E6A23C; font-size:12px; margin-left:6px;">自制{{ row.child_bom_version }}</span>
</template>
</el-table-column>
<el-table-column label="需求量" width="80">
<template #default="{ row }">
<span>{{ row.need }}</span>
@ -274,7 +279,7 @@
<tr v-for="(row, idx) in bomDetailList" :key="idx">
<td>{{ idx + 1 }}</td>
<td>{{ row.name }}</td>
<td>{{ row.sku }}</td>
<td>{{ row.sku }}<span v-if="row.child_bom_version" style="color:#666; font-size:11px;">(自制 {{ row.child_bom_version }})</span></td>
<td>{{ row.need }}</td>
<td>{{ row.available }}</td>
<td>{{ row.shortage > 0 ? row.shortage : '-' }}</td>
@ -304,7 +309,7 @@
<tr v-for="(row, idx) in bomDetailList.filter(i => i.shortage > 0)" :key="idx">
<td>{{ idx + 1 }}</td>
<td>{{ row.name }}</td>
<td>{{ row.sku }}</td>
<td>{{ row.sku }}<span v-if="row.child_bom_version" style="color:#666; font-size:11px;">(自制 {{ row.child_bom_version }})</span></td>
<td>{{ row.shortage }}</td>
<td></td>
<td></td>
@ -569,6 +574,7 @@ const selectedBomNo = ref('')
const bomSets = ref(1)
const currentBomDetail = ref<any[]>([]) // 当前选中的BOM明细
const bomPrintTime = ref('')
const bomParentName = ref('') // 最近一次 BOM 详情的父件物料名(打印头部兜底)
// BOM 树形数据(将分组数据映射为 el-tree-select 需要的结构)
const treeData = computed(() => {
@ -610,7 +616,8 @@ const bomDetailList = computed(() => {
sku: bomItem.child_sku || bomItem.sku || '-',
need: totalNeed,
available: stock,
shortage
shortage,
child_bom_version: bomItem.child_bom_version || ''
}
})
.sort((a, b) => {
@ -624,10 +631,19 @@ const hasShortage = computed(() => bomDetailList.value.some((item: any) => item.
const bomAvailableCount = computed(() => bomDetailList.value.filter(i => i.shortage === 0).length)
const bomShortageCount = computed(() => bomDetailList.value.filter(i => i.shortage > 0).length)
// BOM 选择显示标签(bom_no###version → "bom_no (version)")
// BOM 选择显示标签:bom_no###version → "bom_no (version) · 父件物料名"
const selectedBomLabel = computed(() => {
const [no, ver] = (selectedBomNo.value || '').split('###')
return ver ? `${no} (${ver})` : no
if (!no) return ''
// 先从候选 BOM 列表匹配父件物料名,匹配不到再回退到最近一次详情加载到的名称
let parentName = ''
for (const grp of bomOptions.value || []) {
const hit = (grp.items || []).find((i: any) => i.bom_no === no && i.version === (ver || i.version))
if (hit) { parentName = hit.parent_name || ''; break }
}
if (!parentName) parentName = bomParentName.value || ''
const verText = ver ? ` (${ver})` : ''
return parentName ? `${no}${verText} · ${parentName}` : `${no}${verText}`
})
// 打印相关
@ -808,6 +824,7 @@ watch(selectedBomNo, async (newKey) => {
try {
const detailRes: any = await getBomWithStock(bomNo, version)
currentBomDetail.value = detailRes.data?.children || []
bomParentName.value = detailRes.data?.parent_name || ''
} catch (e) {
ElMessage.error('加载 BOM 明细失败')
currentBomDetail.value = []
@ -823,6 +840,7 @@ const confirmBomAdd = async () => {
const [bomNo, version] = (selectedBomNo.value || '').split('###')
const detailRes: any = await getBomWithStock(bomNo, version)
currentBomDetail.value = detailRes.data?.children || []
bomParentName.value = detailRes.data?.parent_name || ''
} catch (e) {
ElMessage.error('获取 BOM 详情失败')
return

View File

@ -223,7 +223,12 @@
</template>
<el-table :data="bomDetailList" size="small" border style="margin-top: 8px; width: 100%;">
<el-table-column prop="name" label="物料名称" min-width="120" />
<el-table-column prop="sku" label="规格型号" width="120" />
<el-table-column label="规格型号" width="170">
<template #default="{ row }">
<span>{{ row.sku }}</span>
<span v-if="row.child_bom_version" style="color:#E6A23C; font-size:12px; margin-left:6px;">自制{{ row.child_bom_version }}</span>
</template>
</el-table-column>
<el-table-column label="需求量" width="80">
<template #default="{ row }">
<span>{{ row.need }}</span>
@ -280,7 +285,7 @@
<tr v-for="(row, idx) in bomDetailList" :key="idx">
<td>{{ idx + 1 }}</td>
<td>{{ row.name }}</td>
<td>{{ row.sku }}</td>
<td>{{ row.sku }}<span v-if="row.child_bom_version" style="color:#666; font-size:11px;">(自制 {{ row.child_bom_version }})</span></td>
<td>{{ row.need }}</td>
<td>{{ row.available }}</td>
<td>{{ row.shortage > 0 ? row.shortage : '-' }}</td>
@ -310,7 +315,7 @@
<tr v-for="(row, idx) in bomDetailList.filter(i => i.shortage > 0)" :key="idx">
<td>{{ idx + 1 }}</td>
<td>{{ row.name }}</td>
<td>{{ row.sku }}</td>
<td>{{ row.sku }}<span v-if="row.child_bom_version" style="color:#666; font-size:11px;">(自制 {{ row.child_bom_version }})</span></td>
<td>{{ row.shortage }}</td>
<td></td>
<td></td>
@ -632,7 +637,8 @@ const bomDetailList = computed(() => {
sku: bomItem.child_spec || bomItem.spec_model || '-',
need: totalNeed,
available: stock,
shortage
shortage,
child_bom_version: bomItem.child_bom_version || ''
}
})
.sort((a, b) => {
@ -646,13 +652,23 @@ const hasShortage = computed(() => bomDetailList.value.some((item: any) => item.
// ★ BOM 打印统计
const bomPrintTime = ref('')
const bomParentName = ref('') // 最近一次 BOM 详情的父件物料名(打印头部兜底)
const bomAvailableCount = computed(() => bomDetailList.value.filter(i => i.shortage === 0).length)
const bomShortageCount = computed(() => bomDetailList.value.filter(i => i.shortage > 0).length)
// BOM 选择显示标签(bom_no###version → "bom_no (version)")
// BOM 选择显示标签:bom_no###version → "bom_no (version) · 父件物料名"
const selectedBomLabel = computed(() => {
const [no, ver] = (selectedBomNo.value || '').split('###')
return ver ? `${no} (${ver})` : no
if (!no) return ''
// 先从候选 BOM 列表匹配父件物料名,匹配不到再回退到最近一次详情加载到的名称
let parentName = ''
for (const grp of bomOptions.value || []) {
const hit = (grp.items || []).find((i: any) => i.bom_no === no && i.version === (ver || i.version))
if (hit) { parentName = hit.parent_name || ''; break }
}
if (!parentName) parentName = bomParentName.value || ''
const verText = ver ? ` (${ver})` : ''
return parentName ? `${no}${verText} · ${parentName}` : `${no}${verText}`
})
// --- 辅助方法 ---
@ -844,6 +860,7 @@ watch(selectedBomNo, async (newKey) => {
try {
const detailRes: any = await getBomWithStock(bomNo, version)
currentBomDetail.value = detailRes.data?.children || []
bomParentName.value = detailRes.data?.parent_name || ''
} catch (e) {
ElMessage.error('加载 BOM 明细失败')
currentBomDetail.value = []
@ -859,6 +876,7 @@ const confirmBomAdd = async () => {
const [bomNo, version] = (selectedBomNo.value || '').split('###')
const detailRes: any = await getBomWithStock(bomNo, version)
currentBomDetail.value = detailRes.data?.children || []
bomParentName.value = detailRes.data?.parent_name || ''
} catch (e) {
ElMessage.error('获取 BOM 详情失败')
return

View File

@ -54,6 +54,7 @@
<el-table-column label="操作" width="110" align="center">
<template #default="{ row: it }">
<el-button type="primary" link size="small" @click="openDetailDialog(it)">详情</el-button>
<el-button v-if="it.status === 2" type="warning" link size="small" @click="handleReapply(it)">修改重提</el-button>
<template v-if="it.status === 0 && canApprove">
<el-button type="success" link size="small" @click="handleApprove(it)">通过</el-button>
<el-button type="danger" link size="small" @click="openRejectDialog(it)">驳回</el-button>
@ -108,9 +109,11 @@
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right" align="center">
<el-table-column label="操作" width="250" fixed="right" align="center">
<template #default="{ row }">
<el-button type="info" link size="small" @click="openBatchDialog(row.batchKey)">查看整批</el-button>
<!-- ★ 整批修改重提:批次内有被驳回明细时提供 -->
<el-button v-if="batchHasRejected(row)" type="warning" link size="small" @click="handleReapplyBatch(row)">整批重提</el-button>
<!-- ★ 一键审批本批 -->
<template v-if="row.pendingCount > 0 && canApprove">
<el-button type="success" link size="small" :loading="batchLoading" @click="handleApproveBatch(row.batchKey)">审批本批</el-button>
@ -860,6 +863,53 @@ const openCreateDialog = (prefill?: { materialId?: number; name?: string; spec?:
}
// 获取当前用户上一次采购申请的审批人作为默认值
// ★ 被驳回单 → 构建预填行(保留物料/数量/日期/单价/税率/链接/备注/图片)
const buildFormItemFromRequest = (row: any) => {
let imgList: string[] = []
if (Array.isArray(row.images)) imgList = row.images
else if (row.images) { try { imgList = JSON.parse(row.images) } catch (e) { imgList = [] } }
const it = createEmptyFormItem()
it.materialBaseId = row.base_id ?? null
it.name = row.name || ''
it.spec_model = row.spec_model || ''
it.quantity = Number(row.quantity || 1)
it.purchase_date = row.purchase_date || new Date().toISOString().split('T')[0]
it.unit_price = row.unit_price != null ? row.unit_price : undefined
it.total_price = row.total_price != null ? row.total_price : undefined
it.tax_rate = row.tax_rate ?? 0
it.supplier_link = row.supplier_link || ''
it.remark = row.remark || ''
it.images = imgList
it.fileList = imgList.map((u: string) => ({ name: (u.split('/').pop() || '图片'), url: u }))
return it
}
// ★ 单条"修改重提":预填一条被驳回申请到新建弹窗
const handleReapply = (row: any) => {
dialogTitle.value = `重新申请(驳回单 ${row.request_no})`
form.value = { approver_id: row.approver_id ?? undefined, remark: '' }
materialOptions.value = row.base_id
? [{ id: row.base_id, name: row.name || '', spec_model: row.spec_model || '' }]
: []
formItems.value = [buildFormItemFromRequest(row)]
formDialogVisible.value = true
}
// 批次内是否有被驳回的明细(主行显示"整批重提")
const batchHasRejected = (row: any) => (row.items || []).some((it: any) => it.status === 2)
// ★ 整批"修改重提":把批次内所有被驳回的明细一起载入多行弹窗,改后重新提交
const handleReapplyBatch = (row: any) => {
const rejected = (row.items || []).filter((it: any) => it.status === 2)
if (!rejected.length) { ElMessage.warning('该批没有被驳回的申请'); return }
dialogTitle.value = `整批重新申请(批次 ${row.batchKey},${rejected.length} 项被驳回)`
form.value = { approver_id: rejected[0].approver_id ?? undefined, remark: '' }
materialOptions.value = []
formItems.value = rejected.map((it: any) => buildFormItemFromRequest(it))
formDialogVisible.value = true
}
const fetchDefaultApprover = async () => {
try {
const res: any = await getPurchaseList({ page: 1, limit: 1, status: undefined })