package com.erp.service; import com.erp.common.BaseService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.*; import java.util.stream.Collectors; @Service @RequiredArgsConstructor @Slf4j public class BomService extends BaseService { private static final String NS = "bom."; private final CommonService commonService; private final JdbcTemplate jdbcTemplate; // ─── 기본 CRUD ─── public Map getBomList(Map params) { commonService.applyCompanyCodeFilter(params); commonService.applyPagination(params); int totalCount = sqlSession.selectOne(NS + "getBomListCnt", params); List> list = sqlSession.selectList(NS + "getBomList", params); return commonService.buildListResponse(list, totalCount, params); } public Map getBomInfo(Map params) { commonService.applyCompanyCodeFilter(params); return sqlSession.selectOne(NS + "getBomInfo", params); } @Transactional public Map insertBom(Map params) { commonService.applyCompanyCodeFilter(params); sqlSession.insert(NS + "insertBom", params); return params; } @Transactional public Map updateBom(Map params) { commonService.applyCompanyCodeFilter(params); sqlSession.update(NS + "updateBom", params); return params; } @Transactional public Map deleteBom(Map params) { commonService.applyCompanyCodeFilter(params); sqlSession.delete(NS + "deleteBom", params); return params; } // ─── BOM 헤더 조회 (entity join 포함) ─── public Map getBomHeader(String bomId) { List> rows = jdbcTemplate.queryForList( "SELECT b.*, ii.item_name AS ii_item_name, ii.item_number, ii.division AS item_type," + " COALESCE(b.unit, ii.unit) AS unit, ii.unit AS item_unit, ii.division, ii.size, ii.material" + " FROM bom b LEFT JOIN item_info ii ON b.item_id = ii.id" + " WHERE b.id = ?", bomId ); return rows.isEmpty() ? null : rows.get(0); } // ─── 이력 ─── public List> getBomHistory(String bomId, String companyCode) { if ("*".equals(companyCode)) { return jdbcTemplate.queryForList( "SELECT * FROM bom_history WHERE bom_id = ? ORDER BY changed_date DESC", bomId ); } return jdbcTemplate.queryForList( "SELECT * FROM bom_history WHERE bom_id = ? AND company_code = ? ORDER BY changed_date DESC", bomId, companyCode ); } @Transactional public Map addBomHistory(String bomId, String companyCode, Map data) { List> rows = jdbcTemplate.queryForList( "INSERT INTO bom_history (bom_id, revision, version, change_type, change_description, changed_by, company_code)" + " VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *", bomId, data.get("revision"), data.get("version"), data.get("change_type"), data.get("change_description"), data.get("changed_by"), companyCode ); return rows.isEmpty() ? new HashMap<>() : rows.get(0); } // ─── 버전 관리 ─── public Map getBomVersions(String bomId, String companyCode) { List> versions; if ("*".equals(companyCode)) { versions = jdbcTemplate.queryForList( "SELECT v.*, (SELECT COUNT(*) FROM bom_detail d WHERE d.version_id = v.id) AS detail_count" + " FROM bom_version v WHERE v.bom_id = ? ORDER BY v.created_date DESC", bomId ); } else { versions = jdbcTemplate.queryForList( "SELECT v.*, (SELECT COUNT(*) FROM bom_detail d WHERE d.version_id = v.id) AS detail_count" + " FROM bom_version v WHERE v.bom_id = ? AND v.company_code = ? ORDER BY v.created_date DESC", bomId, companyCode ); } List> bomRows = jdbcTemplate.queryForList( "SELECT current_version_id FROM bom WHERE id = ?", bomId ); Object currentVersionId = bomRows.isEmpty() ? null : bomRows.get(0).get("current_version_id"); Map result = new LinkedHashMap<>(); result.put("versions", versions); result.put("current_version_id", currentVersionId); return result; } @Transactional public Map createBomVersion(String bomId, String companyCode, String createdBy, String versionName) { List> bomRows = jdbcTemplate.queryForList( "SELECT * FROM bom WHERE id = ?", bomId ); if (bomRows.isEmpty()) throw new IllegalArgumentException("BOM을 찾을 수 없습니다"); Map bomData = bomRows.get(0); String finalVersionName = (versionName != null && !versionName.trim().isEmpty()) ? versionName.trim() : null; if (finalVersionName == null) { List> countRows = jdbcTemplate.queryForList( "SELECT COUNT(*)::int AS cnt FROM bom_version WHERE bom_id = ?", bomId ); int cnt = countRows.isEmpty() ? 0 : ((Number) countRows.get(0).get("cnt")).intValue(); finalVersionName = (cnt + 1) + ".0"; } List> dupCheck = jdbcTemplate.queryForList( "SELECT id FROM bom_version WHERE bom_id = ? AND version_name = ?", bomId, finalVersionName ); if (!dupCheck.isEmpty()) throw new IllegalArgumentException("이미 존재하는 버전명입니다: " + finalVersionName); Object revision = bomData.get("revision"); int revInt = revision == null ? 0 : (revision instanceof Number ? ((Number) revision).intValue() : 0); List> newVersionRows = jdbcTemplate.queryForList( "INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code)" + " VALUES (?, ?, ?, 'developing', ?, ?) RETURNING *", bomId, finalVersionName, revInt, createdBy, companyCode ); if (newVersionRows.isEmpty()) throw new IllegalArgumentException("버전 생성 실패"); Map newVersion = newVersionRows.get(0); Object newVersionId = newVersion.get("id"); Object sourceVersionId = bomData.get("current_version_id"); if (sourceVersionId != null) { List> sourceDetails = jdbcTemplate.queryForList( "SELECT * FROM bom_detail WHERE bom_id = ? AND version_id = ? ORDER BY parent_detail_id NULLS FIRST, id", bomId, sourceVersionId ); Map oldToNew = new HashMap<>(); for (Map d : sourceDetails) { Object parentDetailId = d.get("parent_detail_id"); Object mappedParent = parentDetailId != null ? oldToNew.get(parentDetailId) : null; List> insertedRows = jdbcTemplate.queryForList( "INSERT INTO bom_detail (bom_id, version_id, parent_detail_id, child_item_id, quantity, unit," + " process_type, loss_rate, remark, level, base_qty, revision, seq_no, writer, company_code)" + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id", bomId, newVersionId, mappedParent, d.get("child_item_id"), d.get("quantity"), d.get("unit"), d.get("process_type"), d.get("loss_rate"), d.get("remark"), d.get("level"), d.get("base_qty"), d.get("revision"), d.get("seq_no"), d.get("writer"), companyCode ); if (!insertedRows.isEmpty()) { oldToNew.put(d.get("id"), insertedRows.get(0).get("id")); } } } jdbcTemplate.update( "UPDATE bom SET version = ?, current_version_id = ? WHERE id = ?", finalVersionName, newVersionId, bomId ); log.info("BOM 버전 생성 완료: bomId={}, versionName={}", bomId, finalVersionName); return newVersion; } @Transactional public Map loadBomVersion(String bomId, String versionId, String companyCode) { List> verRows = jdbcTemplate.queryForList( "SELECT * FROM bom_version WHERE id = ? AND bom_id = ?", versionId, bomId ); if (verRows.isEmpty()) throw new IllegalArgumentException("버전을 찾을 수 없습니다"); String versionName = (String) verRows.get(0).get("version_name"); jdbcTemplate.update( "UPDATE bom SET version = ?, current_version_id = ? WHERE id = ?", versionName, versionId, bomId ); Map result = new HashMap<>(); result.put("restored", true); result.put("version_name", versionName); return result; } @Transactional public Map activateBomVersion(String bomId, String versionId) { List> verRows = jdbcTemplate.queryForList( "SELECT version_name FROM bom_version WHERE id = ? AND bom_id = ?", versionId, bomId ); if (verRows.isEmpty()) throw new IllegalArgumentException("버전을 찾을 수 없습니다"); String versionName = (String) verRows.get(0).get("version_name"); jdbcTemplate.update( "UPDATE bom_version SET status = 'inactive' WHERE bom_id = ? AND status = 'active'", bomId ); jdbcTemplate.update("UPDATE bom_version SET status = 'active' WHERE id = ?", versionId); jdbcTemplate.update( "UPDATE bom SET version = ?, current_version_id = ? WHERE id = ?", versionName, versionId, bomId ); Map result = new HashMap<>(); result.put("activated", true); result.put("version_name", versionName); return result; } @Transactional public Map initializeBomVersion(String bomId, String companyCode, String createdBy) { List> bomRows = jdbcTemplate.queryForList("SELECT * FROM bom WHERE id = ?", bomId); if (bomRows.isEmpty()) throw new IllegalArgumentException("BOM을 찾을 수 없습니다"); Map bomData = bomRows.get(0); Object currentVersionId = bomData.get("current_version_id"); if (currentVersionId != null) { jdbcTemplate.update( "UPDATE bom_detail SET version_id = ? WHERE bom_id = ? AND version_id IS NULL", currentVersionId, bomId ); Map result = new HashMap<>(); result.put("version_id", currentVersionId); result.put("created", false); return result; } List> existingVersions = jdbcTemplate.queryForList( "SELECT id, version_name FROM bom_version WHERE bom_id = ? ORDER BY created_date ASC LIMIT 1", bomId ); if (!existingVersions.isEmpty()) { Object existId = existingVersions.get(0).get("id"); jdbcTemplate.update( "UPDATE bom_detail SET version_id = ? WHERE bom_id = ? AND version_id IS NULL", existId, bomId ); jdbcTemplate.update( "UPDATE bom SET current_version_id = ? WHERE id = ? AND current_version_id IS NULL", existId, bomId ); Map result = new HashMap<>(); result.put("version_id", existId); result.put("created", false); return result; } String versionName = bomData.get("version") != null ? String.valueOf(bomData.get("version")) : "1.0"; List> versionRows = jdbcTemplate.queryForList( "INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code)" + " VALUES (?, ?, 0, 'active', ?, ?) RETURNING id", bomId, versionName, createdBy, companyCode ); Object versionId = versionRows.isEmpty() ? null : versionRows.get(0).get("id"); jdbcTemplate.update( "UPDATE bom_detail SET version_id = ? WHERE bom_id = ? AND version_id IS NULL", versionId, bomId ); jdbcTemplate.update("UPDATE bom SET current_version_id = ? WHERE id = ?", versionId, bomId); Map result = new HashMap<>(); result.put("version_id", versionId); result.put("version_name", versionName); result.put("created", true); return result; } @Transactional public boolean deleteBomVersion(String bomId, String versionId) { List> checkRows = jdbcTemplate.queryForList( "SELECT status FROM bom_version WHERE id = ? AND bom_id = ?", versionId, bomId ); if (checkRows.isEmpty()) throw new IllegalArgumentException("버전을 찾을 수 없습니다"); if ("active".equals(checkRows.get(0).get("status"))) { throw new IllegalArgumentException("사용중인 버전은 삭제할 수 없습니다"); } jdbcTemplate.update("DELETE FROM bom_detail WHERE bom_id = ? AND version_id = ?", bomId, versionId); int deleted = jdbcTemplate.update("DELETE FROM bom_version WHERE id = ? AND bom_id = ?", versionId, bomId); return deleted > 0; } // ─── 엑셀 업로드/다운로드 ─── @Transactional public Map createBomFromExcel(String companyCode, String userId, List> rows) { Map result = new LinkedHashMap<>(); result.put("success", false); result.put("inserted_count", 0); result.put("skipped_count", 0); result.put("errors", new ArrayList()); result.put("unmatched_items", new ArrayList()); @SuppressWarnings("unchecked") List errors = (List) result.get("errors"); @SuppressWarnings("unchecked") List unmatchedItems = (List) result.get("unmatched_items"); if (rows == null || rows.isEmpty()) { errors.add("업로드할 데이터가 없습니다"); return result; } Map headerRow = rows.stream().filter(r -> toInt(r.get("level")) == 0).findFirst().orElse(null); List> detailRows = rows.stream().filter(r -> toInt(r.get("level")) > 0).collect(Collectors.toList()); if (headerRow == null) { errors.add("레벨 0(BOM 마스터) 행이 필요합니다"); return result; } String headerItemNumber = headerRow.get("item_number") != null ? String.valueOf(headerRow.get("item_number")).trim() : ""; if (headerItemNumber.isEmpty()) { errors.add("레벨 0(BOM 마스터)의 품번은 필수입니다"); return result; } if (detailRows.isEmpty()) { errors.add("하위품목이 없습니다"); return result; } // 레벨 유효성 검사 for (int i = 0; i < rows.size(); i++) { int level = toInt(rows.get(i).get("level")); if (level < 0) errors.add((i + 1) + "행: 레벨은 0 이상이어야 합니다"); if (i > 0 && level > toInt(rows.get(i - 1).get("level")) + 1) errors.add((i + 1) + "행: 레벨이 이전 행보다 2 이상 깊어질 수 없습니다"); if (level > 0 && (rows.get(i).get("item_number") == null || String.valueOf(rows.get(i).get("item_number")).trim().isEmpty())) errors.add((i + 1) + "행: 품번은 필수입니다"); } if (!errors.isEmpty()) return result; // 모든 품번 일괄 조회 Set allItemNumbers = new LinkedHashSet<>(); rows.stream().filter(r -> r.get("item_number") != null && !String.valueOf(r.get("item_number")).trim().isEmpty()) .forEach(r -> allItemNumbers.add(String.valueOf(r.get("item_number")).trim())); String placeholders = allItemNumbers.stream().map(i -> "?").collect(Collectors.joining(", ")); List lookupArgs = new ArrayList<>(); lookupArgs.add(companyCode); lookupArgs.addAll(allItemNumbers); List> itemLookup = jdbcTemplate.queryForList( "SELECT id, item_number, item_name, unit FROM item_info WHERE company_code = ? AND item_number IN (" + placeholders + ")", lookupArgs.toArray() ); Map> itemMap = new LinkedHashMap<>(); for (Map item : itemLookup) { itemMap.put(String.valueOf(item.get("item_number")), item); } for (String num : allItemNumbers) { if (!itemMap.containsKey(num)) unmatchedItems.add(num); } if (!unmatchedItems.isEmpty()) { errors.add("매칭되지 않는 품번이 있습니다: " + String.join(", ", unmatchedItems)); return result; } // BOM 마스터 생성 Map headerItemInfo = itemMap.get(headerItemNumber); List> dupCheck = jdbcTemplate.queryForList( "SELECT id FROM bom WHERE item_id = ? AND company_code = ? AND status = 'active'", headerItemInfo.get("id"), companyCode ); if (!dupCheck.isEmpty()) { errors.add("해당 품목(" + headerItemNumber + ")으로 등록된 BOM이 이미 존재합니다"); return result; } double headerQty = toDouble(headerRow.getOrDefault("quantity", 1)); Object headerUnit = headerRow.get("unit") != null ? headerRow.get("unit") : headerItemInfo.get("unit"); List> bomInsert = jdbcTemplate.queryForList( "INSERT INTO bom (item_id, item_code, item_name, base_qty, unit, version, status, remark, writer, company_code)" + " VALUES (?, ?, ?, ?, ?, '1.0', 'active', ?, ?, ?) RETURNING id", headerItemInfo.get("id"), headerItemNumber, headerItemInfo.get("item_name"), String.valueOf(headerQty), headerUnit, headerRow.get("remark"), userId, companyCode ); Object newBomId = bomInsert.isEmpty() ? null : bomInsert.get(0).get("id"); List> versionInsert = jdbcTemplate.queryForList( "INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code)" + " VALUES (?, '1.0', 0, 'active', ?, ?) RETURNING id", newBomId, userId, companyCode ); Object versionId = versionInsert.isEmpty() ? null : versionInsert.get(0).get("id"); jdbcTemplate.update("UPDATE bom SET current_version_id = ? WHERE id = ?", versionId, newBomId); // bom_detail INSERT List levelStack = new ArrayList<>(); Map seqByParent = new LinkedHashMap<>(); int insertedCount = 0; for (Map row : detailRows) { int dbLevel = toInt(row.get("level")) - 1; while (levelStack.size() > dbLevel) levelStack.remove(levelStack.size() - 1); Object parentDetailId = levelStack.isEmpty() ? null : levelStack.get(levelStack.size() - 1); Object parentKey = parentDetailId != null ? parentDetailId : "__root__"; int currentSeq = seqByParent.getOrDefault(parentKey, 0) + 1; seqByParent.put(parentKey, currentSeq); String rowItemNumber = String.valueOf(row.get("item_number")).trim(); Map itemInfo = itemMap.get(rowItemNumber); Object rowUnit = row.get("unit") != null ? row.get("unit") : itemInfo.get("unit"); double rowQty = toDouble(row.getOrDefault("quantity", 1)); List> detailInsert = jdbcTemplate.queryForList( "INSERT INTO bom_detail (bom_id, version_id, parent_detail_id, child_item_id, level, seq_no, quantity, unit, loss_rate, process_type, remark, writer, company_code)" + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, '0', ?, ?, ?, ?) RETURNING id", newBomId, versionId, parentDetailId, itemInfo.get("id"), String.valueOf(dbLevel), String.valueOf(currentSeq), String.valueOf(rowQty), rowUnit, row.get("process_type"), row.get("remark"), userId, companyCode ); if (!detailInsert.isEmpty()) levelStack.add(detailInsert.get(0).get("id")); insertedCount++; } jdbcTemplate.update( "INSERT INTO bom_history (bom_id, change_type, change_description, changed_by, company_code)" + " VALUES (?, 'excel_upload', ?, ?, ?)", newBomId, "엑셀 업로드로 BOM 생성 (하위품목 " + insertedCount + "건)", userId, companyCode ); result.put("success", true); result.put("inserted_count", insertedCount); result.put("created_bom_id", newBomId); return result; } @Transactional public Map createBomVersionFromExcel(String bomId, String companyCode, String userId, List> rows, String versionName) { Map result = new LinkedHashMap<>(); result.put("success", false); result.put("inserted_count", 0); result.put("skipped_count", 0); result.put("errors", new ArrayList()); result.put("unmatched_items", new ArrayList()); @SuppressWarnings("unchecked") List errors = (List) result.get("errors"); @SuppressWarnings("unchecked") List unmatchedItems = (List) result.get("unmatched_items"); if (rows == null || rows.isEmpty()) { errors.add("업로드할 데이터가 없습니다"); return result; } List> detailRows = rows.stream().filter(r -> toInt(r.get("level")) > 0).collect(Collectors.toList()); result.put("skipped_count", rows.size() - detailRows.size()); if (detailRows.isEmpty()) { errors.add("하위품목이 없습니다"); return result; } List> bomCheck = jdbcTemplate.queryForList( "SELECT id FROM bom WHERE id = ? AND company_code = ?", bomId, companyCode ); if (bomCheck.isEmpty()) { errors.add("BOM을 찾을 수 없습니다"); return result; } Set uniqueItemNumbers = detailRows.stream() .filter(r -> r.get("item_number") != null) .map(r -> String.valueOf(r.get("item_number")).trim()) .collect(Collectors.toCollection(LinkedHashSet::new)); String placeholders = uniqueItemNumbers.stream().map(i -> "?").collect(Collectors.joining(", ")); List lookupArgs = new ArrayList<>(); lookupArgs.add(companyCode); lookupArgs.addAll(uniqueItemNumbers); List> itemLookup = jdbcTemplate.queryForList( "SELECT id, item_number, item_name, unit FROM item_info WHERE company_code = ? AND item_number IN (" + placeholders + ")", lookupArgs.toArray() ); Map> itemMap = new LinkedHashMap<>(); for (Map item : itemLookup) itemMap.put(String.valueOf(item.get("item_number")), item); for (String num : uniqueItemNumbers) if (!itemMap.containsKey(num)) unmatchedItems.add(num); if (!unmatchedItems.isEmpty()) { errors.add("매칭되지 않는 품번이 있습니다: " + String.join(", ", unmatchedItems)); return result; } String finalVersionName = (versionName != null && !versionName.trim().isEmpty()) ? versionName.trim() : null; if (finalVersionName == null) { List> countRows = jdbcTemplate.queryForList( "SELECT COUNT(*)::int AS cnt FROM bom_version WHERE bom_id = ?", bomId ); int cnt = countRows.isEmpty() ? 0 : ((Number) countRows.get(0).get("cnt")).intValue(); finalVersionName = (cnt + 1) + ".0"; } List> dupCheck = jdbcTemplate.queryForList( "SELECT id FROM bom_version WHERE bom_id = ? AND version_name = ?", bomId, finalVersionName ); if (!dupCheck.isEmpty()) { errors.add("이미 존재하는 버전명입니다: " + finalVersionName); return result; } List> versionInsert = jdbcTemplate.queryForList( "INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code)" + " VALUES (?, ?, 0, 'developing', ?, ?) RETURNING id", bomId, finalVersionName, userId, companyCode ); Object newVersionId = versionInsert.isEmpty() ? null : versionInsert.get(0).get("id"); List levelStack = new ArrayList<>(); Map seqByParent = new LinkedHashMap<>(); int insertedCount = 0; for (Map row : detailRows) { int dbLevel = toInt(row.get("level")) - 1; while (levelStack.size() > dbLevel) levelStack.remove(levelStack.size() - 1); Object parentDetailId = levelStack.isEmpty() ? null : levelStack.get(levelStack.size() - 1); Object parentKey = parentDetailId != null ? parentDetailId : "__root__"; int currentSeq = seqByParent.getOrDefault(parentKey, 0) + 1; seqByParent.put(parentKey, currentSeq); String rowItemNumber = String.valueOf(row.get("item_number")).trim(); Map itemInfo = itemMap.get(rowItemNumber); Object rowUnit = row.get("unit") != null ? row.get("unit") : itemInfo.get("unit"); double rowQty = toDouble(row.getOrDefault("quantity", 1)); List> detailInsert = jdbcTemplate.queryForList( "INSERT INTO bom_detail (bom_id, version_id, parent_detail_id, child_item_id, level, seq_no, quantity, unit, loss_rate, process_type, remark, writer, company_code)" + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, '0', ?, ?, ?, ?) RETURNING id", bomId, newVersionId, parentDetailId, itemInfo.get("id"), String.valueOf(dbLevel), String.valueOf(currentSeq), String.valueOf(rowQty), rowUnit, row.get("process_type"), row.get("remark"), userId, companyCode ); if (!detailInsert.isEmpty()) levelStack.add(detailInsert.get(0).get("id")); insertedCount++; } jdbcTemplate.update( "UPDATE bom SET version = ?, current_version_id = ? WHERE id = ?", finalVersionName, newVersionId, bomId ); jdbcTemplate.update( "INSERT INTO bom_history (bom_id, change_type, change_description, changed_by, company_code)" + " VALUES (?, 'excel_upload', ?, ?, ?)", bomId, "엑셀 업로드로 새 버전 " + finalVersionName + " 생성 (하위품목 " + insertedCount + "건)", userId, companyCode ); result.put("success", true); result.put("inserted_count", insertedCount); result.put("created_bom_id", bomId); return result; } public List> downloadBomExcelData(String bomId, String companyCode) { List> bomRows = jdbcTemplate.queryForList( "SELECT b.*, ii.item_number, ii.item_name AS ii_item_name, ii.division, ii.unit AS item_unit" + " FROM bom b LEFT JOIN item_info ii ON b.item_id = ii.id" + " WHERE b.id = ? AND b.company_code = ?", bomId, companyCode ); if (bomRows.isEmpty()) return new ArrayList<>(); Map bomHeader = bomRows.get(0); List> flatList = new ArrayList<>(); Map headerEntry = new LinkedHashMap<>(); headerEntry.put("level", 0); headerEntry.put("item_number", bomHeader.getOrDefault("item_number", "")); headerEntry.put("item_name", bomHeader.getOrDefault("ii_item_name", bomHeader.getOrDefault("item_name", ""))); headerEntry.put("quantity", bomHeader.getOrDefault("base_qty", "1")); headerEntry.put("unit", bomHeader.getOrDefault("item_unit", bomHeader.getOrDefault("unit", ""))); headerEntry.put("process_type", ""); headerEntry.put("remark", bomHeader.getOrDefault("remark", "")); flatList.add(headerEntry); Object versionId = bomHeader.get("current_version_id"); List> details; if (versionId != null) { details = jdbcTemplate.queryForList( "SELECT bd.*, ii.item_number, ii.item_name, ii.division, ii.unit AS item_unit, ii.size, ii.material" + " FROM bom_detail bd LEFT JOIN item_info ii ON bd.child_item_id = ii.id" + " WHERE bd.bom_id = ? AND bd.company_code = ? AND bd.version_id = ?" + " ORDER BY bd.parent_detail_id NULLS FIRST, CAST(COALESCE(bd.seq_no, '0') AS int)", bomId, companyCode, versionId ); } else { details = jdbcTemplate.queryForList( "SELECT bd.*, ii.item_number, ii.item_name, ii.division, ii.unit AS item_unit, ii.size, ii.material" + " FROM bom_detail bd LEFT JOIN item_info ii ON bd.child_item_id = ii.id" + " WHERE bd.bom_id = ? AND bd.company_code = ? AND bd.version_id IS NULL" + " ORDER BY bd.parent_detail_id NULLS FIRST, CAST(COALESCE(bd.seq_no, '0') AS int)", bomId, companyCode ); } Map>> childrenMap = new LinkedHashMap<>(); List> roots = new ArrayList<>(); for (Map d : details) { Object parentId = d.get("parent_detail_id"); if (parentId == null) { roots.add(d); } else { childrenMap.computeIfAbsent(parentId, k -> new ArrayList<>()).add(d); } } dfsBomDetail(roots, 1, childrenMap, flatList); return flatList; } private void dfsBomDetail(List> nodes, int depth, Map>> childrenMap, List> flatList) { for (Map node : nodes) { Map entry = new LinkedHashMap<>(); entry.put("level", depth); entry.put("item_number", node.getOrDefault("item_number", "")); entry.put("item_name", node.getOrDefault("item_name", "")); entry.put("quantity", node.getOrDefault("quantity", "1")); entry.put("unit", node.get("unit") != null ? node.get("unit") : node.getOrDefault("item_unit", "")); entry.put("process_type", node.getOrDefault("process_type", "")); entry.put("remark", node.getOrDefault("remark", "")); flatList.add(entry); List> children = childrenMap.getOrDefault(node.get("id"), new ArrayList<>()); if (!children.isEmpty()) dfsBomDetail(children, depth + 1, childrenMap, flatList); } } // ─── 유틸리티 ─── private double toDouble(Object val) { if (val == null) return 0.0; if (val instanceof Number) return ((Number) val).doubleValue(); try { return Double.parseDouble(val.toString()); } catch (Exception e) { return 0.0; } } private int toInt(Object val) { if (val == null) return 0; if (val instanceof Number) return ((Number) val).intValue(); try { return Integer.parseInt(val.toString()); } catch (Exception e) { return 0; } } }