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:
|
try:
|
||||||
keyword = request.args.get('keyword', '').strip()
|
keyword = request.args.get('keyword', '').strip()
|
||||||
active_only = request.args.get('active_only', 'false').lower() == 'true'
|
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
|
category = request.args.get('category', '').strip() or None
|
||||||
page = request.args.get('page', 1, type=int)
|
page = request.args.get('page', 1, type=int)
|
||||||
limit = request.args.get('pageSize', 15, type=int)
|
limit = request.args.get('pageSize', 15, type=int)
|
||||||
|
|
||||||
data = BomService.get_bom_list(
|
data = BomService.get_bom_list(
|
||||||
keyword=keyword, active_only=active_only,
|
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} 字典)
|
# 字段级脱敏(data 现在是 {items, total, pages, current_page} 字典)
|
||||||
user_permissions = get_current_user_permissions()
|
user_permissions = get_current_user_permissions()
|
||||||
@ -98,13 +99,72 @@ def get_bom_list():
|
|||||||
def get_bom_summary():
|
def get_bom_summary():
|
||||||
try:
|
try:
|
||||||
keyword = request.args.get('keyword', '').strip() or None
|
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})
|
return jsonify({'code': 200, 'msg': 'success', 'data': data})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
current_app.logger.error(f'获取BOM摘要失败: {str(e)}')
|
current_app.logger.error(f'获取BOM摘要失败: {str(e)}')
|
||||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
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'])
|
@bom_bp.route('/detail/<path:bom_no>', methods=['GET'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
@permission_required('bom_manage')
|
@permission_required('bom_manage')
|
||||||
@ -398,6 +458,27 @@ def get_material_base_list():
|
|||||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
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'])
|
@bom_bp.route('/parents', methods=['GET'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
@permission_required('bom_manage')
|
@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)
|
loss_rate = db.Column(db.Numeric(5, 2), comment='损耗率%', default=0, nullable=True)
|
||||||
remark = db.Column(db.Text, comment='备注')
|
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 = 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__ = (
|
__table_args__ = (
|
||||||
db.UniqueConstraint('bom_no', 'version', 'parent_id', 'child_id', name='uniq_bom_pair_in_version'),
|
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='个数')
|
dosage = db.Column(db.Numeric(19, 4), comment='个数')
|
||||||
loss_rate = db.Column(db.Numeric(5, 2), default=0, nullable=True, comment='损耗率%')
|
loss_rate = db.Column(db.Numeric(5, 2), default=0, nullable=True, comment='损耗率%')
|
||||||
remark = db.Column(db.Text, 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='更新时间')
|
updated_at = db.Column(db.DateTime, default=db.func.now(), onupdate=db.func.now(), comment='更新时间')
|
||||||
|
|
||||||
parent = db.relationship(
|
parent = db.relationship(
|
||||||
|
|||||||
@ -33,7 +33,9 @@ class BomDraftService:
|
|||||||
child_id=child.get('child_id'),
|
child_id=child.get('child_id'),
|
||||||
dosage=child.get('dosage', 0),
|
dosage=child.get('dosage', 0),
|
||||||
loss_rate=child.get('loss_rate', 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)
|
db.session.add(draft)
|
||||||
|
|
||||||
@ -75,6 +77,8 @@ class BomDraftService:
|
|||||||
'dosage': float(draft.dosage) if draft.dosage else 0.0,
|
'dosage': float(draft.dosage) if draft.dosage else 0.0,
|
||||||
'loss_rate': float(draft.loss_rate) if draft.loss_rate else 0.0,
|
'loss_rate': float(draft.loss_rate) if draft.loss_rate else 0.0,
|
||||||
'remark': draft.remark or '',
|
'remark': draft.remark or '',
|
||||||
|
'child_bom_no': draft.child_bom_no or '',
|
||||||
|
'child_bom_version': draft.child_bom_version or '',
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -126,6 +130,8 @@ class BomDraftService:
|
|||||||
'child_id': child['child_id'],
|
'child_id': child['child_id'],
|
||||||
'dosage': child['dosage'],
|
'dosage': child['dosage'],
|
||||||
'remark': child.get('remark', ''),
|
'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
|
for child in children
|
||||||
],
|
],
|
||||||
|
|||||||
@ -110,7 +110,7 @@ class BomService:
|
|||||||
return f'BOM-{timestamp}-{unique}'
|
return f'BOM-{timestamp}-{unique}'
|
||||||
|
|
||||||
@staticmethod
|
@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 聚合 + 分页)
|
获取所有 BOM 配方(按 bom_no + version 分组,单条 SQL 聚合 + 分页)
|
||||||
|
|
||||||
@ -133,6 +133,7 @@ class BomService:
|
|||||||
MaterialBase.spec_model.label('parent_spec'),
|
MaterialBase.spec_model.label('parent_spec'),
|
||||||
MaterialBase.category.label('parent_category'),
|
MaterialBase.category.label('parent_category'),
|
||||||
BomTable.is_enabled,
|
BomTable.is_enabled,
|
||||||
|
BomTable.is_archived,
|
||||||
func.count(BomTable.child_id).label('child_count'),
|
func.count(BomTable.child_id).label('child_count'),
|
||||||
func.string_agg(child_alias.name, ', ').label('child_names'),
|
func.string_agg(child_alias.name, ', ').label('child_names'),
|
||||||
func.string_agg(child_alias.spec_model, ', ').label('child_specs')
|
func.string_agg(child_alias.spec_model, ', ').label('child_specs')
|
||||||
@ -143,11 +144,18 @@ class BomService:
|
|||||||
).group_by(
|
).group_by(
|
||||||
BomTable.bom_no, BomTable.version, BomTable.parent_id,
|
BomTable.bom_no, BomTable.version, BomTable.parent_id,
|
||||||
MaterialBase.name, MaterialBase.spec_model, MaterialBase.category,
|
MaterialBase.name, MaterialBase.spec_model, MaterialBase.category,
|
||||||
BomTable.is_enabled
|
BomTable.is_enabled, BomTable.is_archived
|
||||||
)
|
)
|
||||||
|
|
||||||
# 过滤禁用状态
|
# 状态过滤:status 优先于旧参数 active_only
|
||||||
if 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)
|
query = query.filter(BomTable.is_enabled == True)
|
||||||
|
|
||||||
# 【行级数据隔离】基于 JWT 多租户公司过滤
|
# 【行级数据隔离】基于 JWT 多租户公司过滤
|
||||||
@ -207,6 +215,7 @@ class BomService:
|
|||||||
'parent_spec': row.parent_spec or '',
|
'parent_spec': row.parent_spec or '',
|
||||||
'parent_category': row.parent_category or '',
|
'parent_category': row.parent_category or '',
|
||||||
'is_enabled': row.is_enabled,
|
'is_enabled': row.is_enabled,
|
||||||
|
'is_archived': row.is_archived,
|
||||||
'child_count': row.child_count,
|
'child_count': row.child_count,
|
||||||
'child_names': row.child_names or '',
|
'child_names': row.child_names or '',
|
||||||
'child_specs': row.child_specs or ''
|
'child_specs': row.child_specs or ''
|
||||||
@ -220,7 +229,7 @@ class BomService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_bom_summary(keyword=None):
|
def get_bom_summary(keyword=None, status=None):
|
||||||
"""
|
"""
|
||||||
BOM 分组摘要 API(极轻量,单条 GROUP BY + COUNT)
|
BOM 分组摘要 API(极轻量,单条 GROUP BY + COUNT)
|
||||||
|
|
||||||
@ -249,6 +258,15 @@ class BomService:
|
|||||||
if company_limit is not None:
|
if company_limit is not None:
|
||||||
query = query.filter(MaterialBase.company_name == company_limit)
|
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:
|
if keyword:
|
||||||
kw = f'%{keyword.strip()}%'
|
kw = f'%{keyword.strip()}%'
|
||||||
@ -303,8 +321,12 @@ class BomService:
|
|||||||
if version:
|
if version:
|
||||||
query = query.filter(BomTable.version == version)
|
query = query.filter(BomTable.version == version)
|
||||||
else:
|
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:
|
if not latest_ver:
|
||||||
return None
|
return None
|
||||||
query = query.filter(BomTable.version == latest_ver)
|
query = query.filter(BomTable.version == latest_ver)
|
||||||
@ -320,13 +342,38 @@ class BomService:
|
|||||||
parent_material = MaterialBase.query.get(parent_id)
|
parent_material = MaterialBase.query.get(parent_id)
|
||||||
|
|
||||||
children = []
|
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:
|
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({
|
children.append({
|
||||||
'child_id': bom.child_id,
|
'child_id': bom.child_id,
|
||||||
'child_name': child_name or '[已删除物料]',
|
'child_name': child_name or '[已删除物料]',
|
||||||
'child_spec': child_spec or '',
|
'child_spec': child_spec or '',
|
||||||
'dosage': float(bom.dosage) if bom.dosage else 0.0,
|
'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 = {
|
result = {
|
||||||
@ -361,18 +408,25 @@ class BomService:
|
|||||||
raise ValueError('父件与子件不能是同一物料')
|
raise ValueError('父件与子件不能是同一物料')
|
||||||
|
|
||||||
# ===== 跨版本内容查重 =====
|
# ===== 跨版本内容查重 =====
|
||||||
# 将当前提交的 children 转换为可比较的集合 (child_id, dosage)
|
# 将当前提交的 children 转换为可比较集合 (child_id, dosage, 引用BOM号, 引用版本)。
|
||||||
current_children_set = set()
|
# 引用版本纳入比较,避免"仅升级了自制件子件的引用版本"被误判为与旧版本完全重复。
|
||||||
for child in children:
|
def _cmp_key(child):
|
||||||
# 用 (child_id, dosage) 元组表示,dosage 转为整数比较
|
return (
|
||||||
dosage_val = int(child.get('dosage', 0)) if child.get('dosage') else 0
|
child['child_id'],
|
||||||
current_children_set.add((child['child_id'], dosage_val))
|
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 下所有其他版本的子件配置
|
# 查询该 bom_no 下所有其他版本的子件配置
|
||||||
existing_versions = db.session.query(
|
existing_versions = db.session.query(
|
||||||
BomTable.version,
|
BomTable.version,
|
||||||
BomTable.child_id,
|
BomTable.child_id,
|
||||||
BomTable.dosage
|
BomTable.dosage,
|
||||||
|
BomTable.child_bom_no,
|
||||||
|
BomTable.child_bom_version
|
||||||
).filter(
|
).filter(
|
||||||
BomTable.bom_no == bom_no,
|
BomTable.bom_no == bom_no,
|
||||||
BomTable.version != version # 排除当前正在保存的版本
|
BomTable.version != version # 排除当前正在保存的版本
|
||||||
@ -380,17 +434,60 @@ class BomService:
|
|||||||
|
|
||||||
# 按版本分组,构建每个版本的子件集合
|
# 按版本分组,构建每个版本的子件集合
|
||||||
version_children = {}
|
version_children = {}
|
||||||
for ver, child_id, dosage in existing_versions:
|
for ver, child_id, dosage, child_bom_no, child_bom_version in existing_versions:
|
||||||
if ver not in version_children:
|
version_children.setdefault(ver, set()).add(
|
||||||
version_children[ver] = set()
|
(child_id, int(dosage or 0), child_bom_no or '', child_bom_version or '')
|
||||||
dosage_val = int(dosage) if dosage else 0
|
)
|
||||||
version_children[ver].add((child_id, dosage_val))
|
|
||||||
|
|
||||||
# 比对每个版本
|
# 比对每个版本
|
||||||
for ver, existing_set in version_children.items():
|
for ver, existing_set in version_children.items():
|
||||||
if current_children_set == existing_set:
|
if current_children_set == existing_set:
|
||||||
raise ValueError(f'保存失败!当前子件配置与已有版本 {ver} 完全一致,请勿重复保存')
|
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()
|
old_records = BomTable.query.filter_by(bom_no=bom_no, version=version).all()
|
||||||
@ -408,6 +505,8 @@ class BomService:
|
|||||||
child_id=child['child_id'],
|
child_id=child['child_id'],
|
||||||
dosage=child.get('dosage', 0),
|
dosage=child.get('dosage', 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,
|
||||||
is_enabled=is_enabled
|
is_enabled=is_enabled
|
||||||
)
|
)
|
||||||
db.session.add(bom)
|
db.session.add(bom)
|
||||||
@ -421,6 +520,38 @@ class BomService:
|
|||||||
|
|
||||||
return bom_no
|
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
|
@staticmethod
|
||||||
def get_bom_with_stock_by_bom_no(bom_no, version=None):
|
def get_bom_with_stock_by_bom_no(bom_no, version=None):
|
||||||
"""
|
"""
|
||||||
@ -477,9 +608,32 @@ class BomService:
|
|||||||
# ====================== 兼容旧接口 ======================
|
# ====================== 兼容旧接口 ======================
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_bom_no_by_parent(parent_id):
|
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
|
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
|
@staticmethod
|
||||||
def create_or_update_bom(parent_id, child_list, bom_no=None, version='V1.0'):
|
def create_or_update_bom(parent_id, child_list, bom_no=None, version='V1.0'):
|
||||||
try:
|
try:
|
||||||
@ -495,7 +649,9 @@ class BomService:
|
|||||||
for item in child_list:
|
for item in child_list:
|
||||||
bom = BomTable(
|
bom = BomTable(
|
||||||
bom_no=bom_no, version=version, parent_id=parent_id,
|
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.add(bom)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|||||||
@ -239,7 +239,7 @@ const handleLogout = () => {
|
|||||||
<footer v-if="!isLoginPage" class="app-footer">
|
<footer v-if="!isLoginPage" class="app-footer">
|
||||||
<span class="version-tag">
|
<span class="version-tag">
|
||||||
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
|
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
|
||||||
当前版本:V3.72
|
当前版本:V3.73
|
||||||
</span>
|
</span>
|
||||||
</footer>
|
</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详情
|
// 获取BOM详情
|
||||||
export function getBomDetail(bomNo: string, version?: string) {
|
export function getBomDetail(bomNo: string, version?: string) {
|
||||||
// 去除首尾斜杠,保留中间斜杠并进行 URL 编码
|
// 去除首尾斜杠,保留中间斜杠并进行 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
|
// 保存BOM
|
||||||
export function saveBom(data: any) {
|
export function saveBom(data: any) {
|
||||||
return request({
|
return request({
|
||||||
|
|||||||
@ -43,12 +43,14 @@ export function getFilterOptions() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. 搜索基础物料
|
// 6. 搜索基础物料(company 可选:跨域下按公司过滤;不传则由后端按当前用户公司隔离)
|
||||||
export function searchMaterialBase(keyword: string, page: number = 1) {
|
export function searchMaterialBase(keyword: string, page: number = 1, company?: string) {
|
||||||
|
const params: any = { keyword, page }
|
||||||
|
if (company) params.company_name = company
|
||||||
return request({
|
return request({
|
||||||
url: '/inbound/buy/search-base',
|
url: '/inbound/buy/search-base',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
params: { keyword, page }
|
params
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,13 @@
|
|||||||
<el-button :icon="Search" @click="handleSearch" />
|
<el-button :icon="Search" @click="handleSearch" />
|
||||||
</template>
|
</template>
|
||||||
</el-input>
|
</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="expandAllGroups" size="small" style="margin-right: 6px;">全部展开</el-button>
|
||||||
<el-button @click="collapseAllGroups" size="small" style="margin-right: 10px;">全部折叠</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>
|
<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-skeleton :rows="8" animated v-if="loading && groupSummary.length === 0" />
|
||||||
<el-empty v-else-if="!loading && groupSummary.length === 0" description="暂无 BOM 数据" />
|
<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
|
<el-collapse-item
|
||||||
v-for="group in groupedData"
|
v-for="group in groupedData"
|
||||||
:key="group.category"
|
:key="group.category"
|
||||||
@ -49,13 +56,15 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column v-if="hasColumnPermission('status')" label="状态" width="100" align="center">
|
<el-table-column v-if="hasColumnPermission('status')" label="状态" width="100" align="center">
|
||||||
<template #default="{ row }">
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column v-if="hasColumnPermission('child_count')" prop="child_count" label="子件数" width="80" align="center" />
|
<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 }">
|
<template #default="{ row }">
|
||||||
<el-button type="success" link @click="handleSaveAs(row)">另存为</el-button>
|
<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>
|
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@ -68,7 +77,7 @@
|
|||||||
</el-card>
|
</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-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-row :gutter="20">
|
||||||
<el-col :span="16">
|
<el-col :span="16">
|
||||||
@ -93,6 +102,7 @@
|
|||||||
<div class="option-row">
|
<div class="option-row">
|
||||||
<span class="option-name">{{ item.name }}</span>
|
<span class="option-name">{{ item.name }}</span>
|
||||||
<span class="option-spec">{{ item.spec }}</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-autocomplete>
|
</el-autocomplete>
|
||||||
@ -112,7 +122,15 @@
|
|||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<el-col :span="8">
|
<el-col :span="8">
|
||||||
<el-form-item label="是否启用" prop="is_enabled" v-if="hasFormFieldPermission('is_enabled')">
|
<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-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="16"></el-col>
|
<el-col :span="16"></el-col>
|
||||||
@ -184,6 +202,7 @@
|
|||||||
<div class="option-row">
|
<div class="option-row">
|
||||||
<span class="option-name">{{ item.name }}</span>
|
<span class="option-name">{{ item.name }}</span>
|
||||||
<span class="option-spec">{{ item.spec }}</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-autocomplete>
|
</el-autocomplete>
|
||||||
@ -197,6 +216,29 @@
|
|||||||
/>
|
/>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</div>
|
</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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
@ -262,8 +304,9 @@ import { useRoute } from 'vue-router'
|
|||||||
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus'
|
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus'
|
||||||
import { Plus, Search, EditPen, Upload } from '@element-plus/icons-vue'
|
import { Plus, Search, EditPen, Upload } from '@element-plus/icons-vue'
|
||||||
import { useRouter } from 'vue-router'
|
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 ImportDialog from '@/components/ImportDialog.vue'
|
||||||
|
import CompanySelector from '@/components/CompanySelector.vue'
|
||||||
import { searchMaterialBase } from '@/api/inbound/buy'
|
import { searchMaterialBase } from '@/api/inbound/buy'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
@ -276,6 +319,7 @@ interface BomItem {
|
|||||||
parent_name: string
|
parent_name: string
|
||||||
version: string
|
version: string
|
||||||
is_enabled: boolean
|
is_enabled: boolean
|
||||||
|
is_archived?: boolean
|
||||||
child_count: number
|
child_count: number
|
||||||
}
|
}
|
||||||
interface MaterialBase {
|
interface MaterialBase {
|
||||||
@ -290,6 +334,64 @@ interface ChildRow {
|
|||||||
material_spec: string
|
material_spec: string
|
||||||
dosage: number
|
dosage: number
|
||||||
remark: string
|
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('')
|
const originalDraftHash = ref('')
|
||||||
@ -298,7 +400,9 @@ const getDraftHash = () => {
|
|||||||
const children = form.children.map(c => ({
|
const children = form.children.map(c => ({
|
||||||
child_id: c.child_id,
|
child_id: c.child_id,
|
||||||
dosage: Number(c.dosage) || 0,
|
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({
|
return JSON.stringify({
|
||||||
bom_no: form.bom_no,
|
bom_no: form.bom_no,
|
||||||
@ -326,6 +430,11 @@ let pendingDraftVersion = ''
|
|||||||
|
|
||||||
const searchKeyword = ref('')
|
const searchKeyword = ref('')
|
||||||
const childSearchKeyword = 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 }
|
interface GroupSummary { category: string; count: number }
|
||||||
@ -373,7 +482,7 @@ const fetchParentSuggestions = (query: string, cb: (results: any[]) => void) =>
|
|||||||
const rawQuery = String(query || '')
|
const rawQuery = String(query || '')
|
||||||
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
|
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
|
||||||
searchLoading.value = true
|
searchLoading.value = true
|
||||||
searchMaterialBase(safeQuery).then((res: any) => {
|
searchMaterialBase(safeQuery, 1, bomCompany.value || '').then((res: any) => {
|
||||||
const items = res.data?.items || res.data || []
|
const items = res.data?.items || res.data || []
|
||||||
const formatted = items.map((i: any) => ({ ...i, name: i.name || i.material_name }))
|
const formatted = items.map((i: any) => ({ ...i, name: i.name || i.material_name }))
|
||||||
cb(formatted)
|
cb(formatted)
|
||||||
@ -399,7 +508,7 @@ const fetchChildSuggestions = (row: any, query: string, cb: (results: any[]) =>
|
|||||||
const rawQuery = String(query || '')
|
const rawQuery = String(query || '')
|
||||||
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
|
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
|
||||||
searchLoading.value = true
|
searchLoading.value = true
|
||||||
searchMaterialBase(safeQuery).then((res: any) => {
|
searchMaterialBase(safeQuery, 1, bomCompany.value || '').then((res: any) => {
|
||||||
const items = res.data?.items || res.data || []
|
const items = res.data?.items || res.data || []
|
||||||
const formatted = items.map((i: any) => ({ ...i, name: i.name || i.material_name }))
|
const formatted = items.map((i: any) => ({ ...i, name: i.name || i.material_name }))
|
||||||
cb(formatted)
|
cb(formatted)
|
||||||
@ -411,6 +520,10 @@ const onChildClear = (row: any) => {
|
|||||||
row.material_name = ''
|
row.material_name = ''
|
||||||
row.material_spec = ''
|
row.material_spec = ''
|
||||||
row.dosage = 0
|
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) => {
|
const onChildSelected = (row: any, item: any) => {
|
||||||
@ -420,12 +533,19 @@ const onChildSelected = (row: any, item: any) => {
|
|||||||
row.child_id = null
|
row.child_id = null
|
||||||
row.material_name = ''
|
row.material_name = ''
|
||||||
row.dosage = 0
|
row.dosage = 0
|
||||||
|
row.bom_version_options = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
row.child_id = item.id
|
row.child_id = item.id
|
||||||
row.material_name = item.name
|
row.material_name = item.name
|
||||||
row.material_spec = item.spec
|
row.material_spec = item.spec
|
||||||
row.dosage = 1
|
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 getChildSpec = (rowKey: number): string => {
|
||||||
const row = form.children.find(c => c.rowKey === rowKey)
|
const row = form.children.find(c => c.rowKey === rowKey)
|
||||||
@ -528,6 +648,7 @@ const dialogTitle = ref('新建 BOM')
|
|||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
activeCategories.value = []
|
activeCategories.value = []
|
||||||
groupCache.value = new Map()
|
groupCache.value = new Map()
|
||||||
|
isAccordion.value = true // 搜索/筛选重置回默认手风琴模式
|
||||||
fetchBomSummary()
|
fetchBomSummary()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -537,6 +658,8 @@ const fetchBomSummary = async () => {
|
|||||||
try {
|
try {
|
||||||
const params: any = {}
|
const params: any = {}
|
||||||
if (searchKeyword.value) params.keyword = searchKeyword.value
|
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)
|
const res: any = await getBomSummary(params)
|
||||||
if (res?.code === 200) {
|
if (res?.code === 200) {
|
||||||
groupSummary.value = res.data ?? []
|
groupSummary.value = res.data ?? []
|
||||||
@ -549,12 +672,17 @@ const fetchBomSummary = async () => {
|
|||||||
finally { loading.value = false }
|
finally { loading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ★ 默认全部展开(用户点击按钮时)
|
// ★ 全部展开:先临时关闭手风琴(允许多开),再一次性展开+懒加载各分类
|
||||||
const expandAllGroups = () => {
|
const expandAllGroups = () => {
|
||||||
|
isAccordion.value = false
|
||||||
activeCategories.value = groupSummary.value.map(g => g.category)
|
activeCategories.value = groupSummary.value.map(g => g.category)
|
||||||
groupSummary.value.forEach(g => loadGroupItems(g.category))
|
groupSummary.value.forEach(g => loadGroupItems(g.category))
|
||||||
}
|
}
|
||||||
const collapseAllGroups = () => { activeCategories.value = [] }
|
// ★ 全部折叠:清空面板,并恢复手风琴模式
|
||||||
|
const collapseAllGroups = () => {
|
||||||
|
activeCategories.value = []
|
||||||
|
isAccordion.value = true
|
||||||
|
}
|
||||||
|
|
||||||
// ★ 展开分组时懒加载该分类下的 BOM
|
// ★ 展开分组时懒加载该分类下的 BOM
|
||||||
const loadGroupItems = async (category: string) => {
|
const loadGroupItems = async (category: string) => {
|
||||||
@ -569,6 +697,8 @@ const loadGroupItems = async (category: string) => {
|
|||||||
pageSize: 9999 // 单分类内全量加载(分类内数据量可控)
|
pageSize: 9999 // 单分类内全量加载(分类内数据量可控)
|
||||||
}
|
}
|
||||||
if (searchKeyword.value) params.keyword = searchKeyword.value
|
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)
|
const res: any = await getBomList(params)
|
||||||
if (res?.code === 200) {
|
if (res?.code === 200) {
|
||||||
groupCache.value.set(category, res.data?.items ?? [])
|
groupCache.value.set(category, res.data?.items ?? [])
|
||||||
@ -649,7 +779,9 @@ const executeSaveDraftRequest = async (targetBomNo: string) => {
|
|||||||
.map(child => ({
|
.map(child => ({
|
||||||
child_id: child.child_id,
|
child_id: child.child_id,
|
||||||
dosage: child.dosage,
|
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
|
saving.value = true
|
||||||
@ -683,7 +815,9 @@ const restoreDraftToForm = (draftData: any) => {
|
|||||||
material_name: child.child_name || '',
|
material_name: child.child_name || '',
|
||||||
material_spec: child.child_spec || '',
|
material_spec: child.child_spec || '',
|
||||||
dosage: child.dosage,
|
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) {
|
if (draftData.parent_id) {
|
||||||
@ -698,6 +832,8 @@ const restoreDraftToForm = (draftData: any) => {
|
|||||||
pendingDraftVersion = draftData.version || pendingDraftVersion
|
pendingDraftVersion = draftData.version || pendingDraftVersion
|
||||||
|
|
||||||
originalDraftHash.value = getDraftHash()
|
originalDraftHash.value = getDraftHash()
|
||||||
|
// 草稿中存量自制件子件也要补齐版本候选(保留其已存版本),便于继续编辑
|
||||||
|
hydrateChildBomVersions()
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkUserDraft = async (bomNo: string, version: string) => {
|
const checkUserDraft = async (bomNo: string, version: string) => {
|
||||||
@ -766,12 +902,13 @@ const checkAndInterceptGlobalDraft = async (): Promise<boolean> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleView = async (row: BomItem) => {
|
const handleView = async (row: BomItem) => {
|
||||||
await loadDetail(row.bom_no, row.version)
|
resetForm() // 清空旧内容,避免加载期间闪现上一条数据
|
||||||
dialogTitle.value = '查看 BOM'
|
dialogTitle.value = '查看 BOM'
|
||||||
isEditMode.value = false
|
isEditMode.value = false
|
||||||
isSaveAsMode.value = false
|
isSaveAsMode.value = false
|
||||||
isReadOnlyMode.value = true
|
isReadOnlyMode.value = true
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true // 先开窗,让 detailLoading 骨架屏可见
|
||||||
|
await loadDetail(row.bom_no, row.version)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSaveAs = async (row: any) => {
|
const handleSaveAs = async (row: any) => {
|
||||||
@ -800,7 +937,9 @@ const handleSaveAs = async (row: any) => {
|
|||||||
material_name: child.child_name || '未知物料',
|
material_name: child.child_name || '未知物料',
|
||||||
material_spec: child.child_spec || '',
|
material_spec: child.child_spec || '',
|
||||||
dosage: child.dosage,
|
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) {
|
if (raw.parent_id) {
|
||||||
form.parent_id = 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))
|
originalChildren = JSON.parse(JSON.stringify(form.children))
|
||||||
originalDraftHash.value = getDraftHash()
|
originalDraftHash.value = getDraftHash()
|
||||||
|
|
||||||
|
// 另存/升版编辑:存量自制件子件补齐版本候选(保留其已存储的引用版本)
|
||||||
|
hydrateChildBomVersions()
|
||||||
|
|
||||||
dialogTitle.value = '新增 BOM (版本升级)'
|
dialogTitle.value = '新增 BOM (版本升级)'
|
||||||
isEditMode.value = false
|
isEditMode.value = false
|
||||||
isSaveAsMode.value = true
|
isSaveAsMode.value = true
|
||||||
@ -825,6 +967,7 @@ const handleSaveAs = async (row: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadDetail = async (bomNo: string, version: string) => {
|
const loadDetail = async (bomNo: string, version: string) => {
|
||||||
|
detailLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await getBomDetail(bomNo, version)
|
const res = await getBomDetail(bomNo, version)
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
@ -836,7 +979,9 @@ const loadDetail = async (bomNo: string, version: string) => {
|
|||||||
material_name: child.child_name || '未知物料',
|
material_name: child.child_name || '未知物料',
|
||||||
material_spec: child.child_spec || '',
|
material_spec: child.child_spec || '',
|
||||||
dosage: child.dosage,
|
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) {
|
if (data.parent_id) {
|
||||||
@ -850,8 +995,66 @@ const loadDetail = async (bomNo: string, version: string) => {
|
|||||||
form.bom_no = bomNo.split('/')[0].trim()
|
form.bom_no = bomNo.split('/')[0].trim()
|
||||||
}
|
}
|
||||||
form.remark = data.remark || ''
|
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) => {
|
const handleDelete = (row: BomItem) => {
|
||||||
@ -909,14 +1112,25 @@ const submitForm = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isSaveAsMode.value && originalChildren.length > 0) {
|
if (isSaveAsMode.value && originalChildren.length > 0) {
|
||||||
const currentSet = new Set(form.children.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 originalSet = new Set(originalChildren.map(c => `${c.child_id}-${c.dosage}`))
|
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))
|
const isIdentical = currentSet.size === originalSet.size && [...currentSet].every(item => originalSet.has(item))
|
||||||
if (isIdentical) {
|
if (isIdentical) {
|
||||||
return ElMessage.warning('您未修改任何子件,与原版本内容一致,请修改后再保存')
|
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 = {
|
const payload = {
|
||||||
bom_no: pureBomNo.value,
|
bom_no: pureBomNo.value,
|
||||||
remark: form.remark,
|
remark: form.remark,
|
||||||
@ -928,7 +1142,9 @@ const submitForm = async () => {
|
|||||||
.map((c: any) => ({
|
.map((c: any) => ({
|
||||||
child_id: c.child_id,
|
child_id: c.child_id,
|
||||||
dosage: c.dosage,
|
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>
|
</template>
|
||||||
<el-table :data="bomDetailList" size="small" border style="margin-top: 8px; width: 100%;">
|
<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="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">
|
<el-table-column label="需求量" width="80">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span>{{ row.need }}</span>
|
<span>{{ row.need }}</span>
|
||||||
@ -274,7 +279,7 @@
|
|||||||
<tr v-for="(row, idx) in bomDetailList" :key="idx">
|
<tr v-for="(row, idx) in bomDetailList" :key="idx">
|
||||||
<td>{{ idx + 1 }}</td>
|
<td>{{ idx + 1 }}</td>
|
||||||
<td>{{ row.name }}</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.need }}</td>
|
||||||
<td>{{ row.available }}</td>
|
<td>{{ row.available }}</td>
|
||||||
<td>{{ row.shortage > 0 ? row.shortage : '-' }}</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">
|
<tr v-for="(row, idx) in bomDetailList.filter(i => i.shortage > 0)" :key="idx">
|
||||||
<td>{{ idx + 1 }}</td>
|
<td>{{ idx + 1 }}</td>
|
||||||
<td>{{ row.name }}</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>{{ row.shortage }}</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td></td>
|
<td></td>
|
||||||
@ -569,6 +574,7 @@ const selectedBomNo = ref('')
|
|||||||
const bomSets = ref(1)
|
const bomSets = ref(1)
|
||||||
const currentBomDetail = ref<any[]>([]) // 当前选中的BOM明细
|
const currentBomDetail = ref<any[]>([]) // 当前选中的BOM明细
|
||||||
const bomPrintTime = ref('')
|
const bomPrintTime = ref('')
|
||||||
|
const bomParentName = ref('') // 最近一次 BOM 详情的父件物料名(打印头部兜底)
|
||||||
|
|
||||||
// BOM 树形数据(将分组数据映射为 el-tree-select 需要的结构)
|
// BOM 树形数据(将分组数据映射为 el-tree-select 需要的结构)
|
||||||
const treeData = computed(() => {
|
const treeData = computed(() => {
|
||||||
@ -610,7 +616,8 @@ const bomDetailList = computed(() => {
|
|||||||
sku: bomItem.child_sku || bomItem.sku || '-',
|
sku: bomItem.child_sku || bomItem.sku || '-',
|
||||||
need: totalNeed,
|
need: totalNeed,
|
||||||
available: stock,
|
available: stock,
|
||||||
shortage
|
shortage,
|
||||||
|
child_bom_version: bomItem.child_bom_version || ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.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 bomAvailableCount = computed(() => bomDetailList.value.filter(i => i.shortage === 0).length)
|
||||||
const bomShortageCount = 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 selectedBomLabel = computed(() => {
|
||||||
const [no, ver] = (selectedBomNo.value || '').split('###')
|
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 {
|
try {
|
||||||
const detailRes: any = await getBomWithStock(bomNo, version)
|
const detailRes: any = await getBomWithStock(bomNo, version)
|
||||||
currentBomDetail.value = detailRes.data?.children || []
|
currentBomDetail.value = detailRes.data?.children || []
|
||||||
|
bomParentName.value = detailRes.data?.parent_name || ''
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error('加载 BOM 明细失败')
|
ElMessage.error('加载 BOM 明细失败')
|
||||||
currentBomDetail.value = []
|
currentBomDetail.value = []
|
||||||
@ -823,6 +840,7 @@ const confirmBomAdd = async () => {
|
|||||||
const [bomNo, version] = (selectedBomNo.value || '').split('###')
|
const [bomNo, version] = (selectedBomNo.value || '').split('###')
|
||||||
const detailRes: any = await getBomWithStock(bomNo, version)
|
const detailRes: any = await getBomWithStock(bomNo, version)
|
||||||
currentBomDetail.value = detailRes.data?.children || []
|
currentBomDetail.value = detailRes.data?.children || []
|
||||||
|
bomParentName.value = detailRes.data?.parent_name || ''
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error('获取 BOM 详情失败')
|
ElMessage.error('获取 BOM 详情失败')
|
||||||
return
|
return
|
||||||
|
|||||||
@ -223,7 +223,12 @@
|
|||||||
</template>
|
</template>
|
||||||
<el-table :data="bomDetailList" size="small" border style="margin-top: 8px; width: 100%;">
|
<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="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">
|
<el-table-column label="需求量" width="80">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span>{{ row.need }}</span>
|
<span>{{ row.need }}</span>
|
||||||
@ -280,7 +285,7 @@
|
|||||||
<tr v-for="(row, idx) in bomDetailList" :key="idx">
|
<tr v-for="(row, idx) in bomDetailList" :key="idx">
|
||||||
<td>{{ idx + 1 }}</td>
|
<td>{{ idx + 1 }}</td>
|
||||||
<td>{{ row.name }}</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.need }}</td>
|
||||||
<td>{{ row.available }}</td>
|
<td>{{ row.available }}</td>
|
||||||
<td>{{ row.shortage > 0 ? row.shortage : '-' }}</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">
|
<tr v-for="(row, idx) in bomDetailList.filter(i => i.shortage > 0)" :key="idx">
|
||||||
<td>{{ idx + 1 }}</td>
|
<td>{{ idx + 1 }}</td>
|
||||||
<td>{{ row.name }}</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>{{ row.shortage }}</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td></td>
|
<td></td>
|
||||||
@ -632,7 +637,8 @@ const bomDetailList = computed(() => {
|
|||||||
sku: bomItem.child_spec || bomItem.spec_model || '-',
|
sku: bomItem.child_spec || bomItem.spec_model || '-',
|
||||||
need: totalNeed,
|
need: totalNeed,
|
||||||
available: stock,
|
available: stock,
|
||||||
shortage
|
shortage,
|
||||||
|
child_bom_version: bomItem.child_bom_version || ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
@ -646,13 +652,23 @@ const hasShortage = computed(() => bomDetailList.value.some((item: any) => item.
|
|||||||
|
|
||||||
// ★ BOM 打印统计
|
// ★ BOM 打印统计
|
||||||
const bomPrintTime = ref('')
|
const bomPrintTime = ref('')
|
||||||
|
const bomParentName = ref('') // 最近一次 BOM 详情的父件物料名(打印头部兜底)
|
||||||
const bomAvailableCount = computed(() => bomDetailList.value.filter(i => i.shortage === 0).length)
|
const bomAvailableCount = computed(() => bomDetailList.value.filter(i => i.shortage === 0).length)
|
||||||
const bomShortageCount = 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 selectedBomLabel = computed(() => {
|
||||||
const [no, ver] = (selectedBomNo.value || '').split('###')
|
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 {
|
try {
|
||||||
const detailRes: any = await getBomWithStock(bomNo, version)
|
const detailRes: any = await getBomWithStock(bomNo, version)
|
||||||
currentBomDetail.value = detailRes.data?.children || []
|
currentBomDetail.value = detailRes.data?.children || []
|
||||||
|
bomParentName.value = detailRes.data?.parent_name || ''
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error('加载 BOM 明细失败')
|
ElMessage.error('加载 BOM 明细失败')
|
||||||
currentBomDetail.value = []
|
currentBomDetail.value = []
|
||||||
@ -859,6 +876,7 @@ const confirmBomAdd = async () => {
|
|||||||
const [bomNo, version] = (selectedBomNo.value || '').split('###')
|
const [bomNo, version] = (selectedBomNo.value || '').split('###')
|
||||||
const detailRes: any = await getBomWithStock(bomNo, version)
|
const detailRes: any = await getBomWithStock(bomNo, version)
|
||||||
currentBomDetail.value = detailRes.data?.children || []
|
currentBomDetail.value = detailRes.data?.children || []
|
||||||
|
bomParentName.value = detailRes.data?.parent_name || ''
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error('获取 BOM 详情失败')
|
ElMessage.error('获取 BOM 详情失败')
|
||||||
return
|
return
|
||||||
|
|||||||
@ -54,6 +54,7 @@
|
|||||||
<el-table-column label="操作" width="110" align="center">
|
<el-table-column label="操作" width="110" align="center">
|
||||||
<template #default="{ row: it }">
|
<template #default="{ row: it }">
|
||||||
<el-button type="primary" link size="small" @click="openDetailDialog(it)">详情</el-button>
|
<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">
|
<template v-if="it.status === 0 && canApprove">
|
||||||
<el-button type="success" link size="small" @click="handleApprove(it)">通过</el-button>
|
<el-button type="success" link size="small" @click="handleApprove(it)">通过</el-button>
|
||||||
<el-button type="danger" link size="small" @click="openRejectDialog(it)">驳回</el-button>
|
<el-button type="danger" link size="small" @click="openRejectDialog(it)">驳回</el-button>
|
||||||
@ -108,9 +109,11 @@
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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 }">
|
<template #default="{ row }">
|
||||||
<el-button type="info" link size="small" @click="openBatchDialog(row.batchKey)">查看整批</el-button>
|
<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">
|
<template v-if="row.pendingCount > 0 && canApprove">
|
||||||
<el-button type="success" link size="small" :loading="batchLoading" @click="handleApproveBatch(row.batchKey)">审批本批</el-button>
|
<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 () => {
|
const fetchDefaultApprover = async () => {
|
||||||
try {
|
try {
|
||||||
const res: any = await getPurchaseList({ page: 1, limit: 1, status: undefined })
|
const res: any = await getPurchaseList({ page: 1, limit: 1, status: undefined })
|
||||||
|
|||||||
Reference in New Issue
Block a user