Compare commits
11 Commits
fd55b7adfe
...
cb1d385e77
| Author | SHA1 | Date | |
|---|---|---|---|
| cb1d385e77 | |||
| 9fc6ff00f9 | |||
| 02f74278df | |||
| 1daa60590c | |||
| 424df231d5 | |||
| 91fb6c1a03 | |||
| 6250fa94cb | |||
| 7d9828e403 | |||
| 3dd8458de0 | |||
| d182af7d2a | |||
| d163a36117 |
72
db_migrations/add_bom_child_version_columns.sql
Normal file
72
db_migrations/add_bom_child_version_columns.sql
Normal 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;
|
||||
36
db_migrations/archive_bom_placeholder_recipes.sql
Normal file
36
db_migrations/archive_bom_placeholder_recipes.sql
Normal 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;
|
||||
@ -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')
|
||||
|
||||
@ -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 聚合 + 分页)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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 || ''
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user