// REA popMainBlockCut, popMainBlockCopy, popMainBlockPaste, popMainBlockDel 및 블럭 범위 지정 (ActionBlockOneSet, 블럭시작/끝) // 및 popMainSetStr (산출근거 주석 지정/해제), TFRMWONGABASE popupTotal (공사비역산 자리수 맞추기) // 및 mnuLevelUp, mnuLevelDown, mnuCostLevelUp, mnuCostLevelDown (레벨 일괄 증가/감소) const EstBlockActions = (() => { let blockStart = null; let blockTable = null; function setBlockStart(state) { const rows = state.project.tables[state.view]; if (!rows) throw Error("현재 화면에서는 블럭을 지정할 수 없습니다."); const selected = [...state.selection]; if (!selected.length) throw Error("블럭 시작 위치로 지정할 행을 선택하세요."); blockStart = selected[0]; blockTable = state.view; state.notice = `${blockStart + 1}행이 블럭 시작점으로 설정되었습니다. (블럭 끝 행을 선택 후 '블럭 끝'을 누르세요)`; } function setBlockEnd(state) { const rows = state.project.tables[state.view]; if (!rows) throw Error("현재 화면에서는 블럭을 지정할 수 없습니다."); if (blockStart == null || blockTable !== state.view) { throw Error("먼저 '블럭 시작'을 지정하세요."); } const selected = [...state.selection]; if (!selected.length) throw Error("블럭 끝 위치로 지정할 행을 선택하세요."); const blockEnd = selected[0]; const start = Math.min(blockStart, blockEnd); const end = Math.max(blockStart, blockEnd); state.selection.clear(); for (let i = start; i <= end; i++) { state.selection.add(i); } state.notice = `${start + 1}행부터 ${end + 1}행까지 총 ${end - start + 1}개 행이 블럭으로 선택되었습니다.`; blockStart = null; blockTable = null; } function clearBlockSelection(state) { state.selection.clear(); blockStart = null; blockTable = null; state.notice = "선택이 해제되었습니다."; } function selectAllRows(state) { const rows = state.project.tables[state.view]; if (!rows) throw Error("현재 화면에서는 전체 선택을 할 수 없습니다."); state.selection.clear(); for (let i = 0; i < rows.length; i++) { state.selection.add(i); } state.notice = `전체 ${rows.length}개 행이 선택되었습니다.`; } function deleteBlockRows(state) { const rows = state.project.tables[state.view]; if (!rows) throw Error("현재 화면에서는 블럭 삭제를 할 수 없습니다."); const indexes = new Set([...state.selection]); if (!indexes.size) throw Error("삭제할 블럭 행을 먼저 선택하세요."); // 호표/기계경비 그룹이 있는 경우 호표 제목 행 포함 시 전체 호표 행 포함 if (["HeadMainDat", "CostMainDat", "MachMainDat"].includes(state.view)) { for (const group of EstNative.groups(rows).values()) { if (indexes.has(group.start)) { for (let i = group.start; i < group.start + group.rows.length; i++) { indexes.add(i); } } } } const codes = new Set( [...indexes] .map((i) => rows[i]?.Code) .filter(Boolean) .map((c) => String(c).trim()), ); const kind = Number( Object.entries(EstNative.sourceTables).find(([, table]) => table === state.view)?.[0] || { HeadMainDat: 103, CostMainDat: 104, MachMainDat: 105 }[state.view], ); if ( kind && Object.entries(state.project.tables).some(([table, list]) => list.some((row, i) => { if (table === state.view && indexes.has(i)) return false; const rLink = Number(row.LinkIndex); const rCode = row.Code ? String(row.Code).trim() : ""; return rLink === kind && rCode && codes.has(rCode); }), ) ) { throw Error("다른 항목에서 사용 중입니다. 연결을 먼저 해제하세요."); } if (!confirm(`${indexes.size}개 행을 블럭 삭제하시겠습니까?`)) return; EstApp.change(() => { [...indexes].sort((a, b) => b - a).forEach((i) => rows.splice(i, 1)); state.selection.clear(); blockStart = null; blockTable = null; }); } function toggleCostComment(state) { // REA popMainSetStr: 단가산출 산출근거 행을 주석(Index = -1)으로 지정 또는 일반 산출식으로 복원 const rows = state.project.tables.CostMainDat; const indices = [...state.selection].sort((a, b) => a - b); if (state.view !== "CostMainDat" || !rows) { throw Error("단가산출 화면에서만 산출근거 행을 주석으로 지정할 수 있습니다."); } if (!indices.length || indices.some((i) => !rows[i])) { throw Error("주석으로 지정하거나 해제할 산출근거 행을 선택하세요."); } const plans = []; for (const index of indices) { const row = rows[index]; const curIndex = Number(row.Index) || 0; if (curIndex > 0) { throw Error( `행 ${index + 1}: 호표 제목 행은 주석으로 지정할 수 없습니다. 상세 산출근거 행을 선택하세요.`, ); } if (curIndex === -20) { throw Error(`행 ${index + 1}: 소계 구분선 행은 주석으로 지정할 수 없습니다.`); } const curSCheck = Number(row.SCheck) || 0; const isComment = curIndex === -1 || curSCheck === -1; if (isComment) { // 이미 주석인 행 -> 복원 let restoreIndex = 0; if (row._origIndex !== undefined) { restoreIndex = Number(row._origIndex); } else if (row.Index_ !== undefined) { restoreIndex = Number(row.Index_); } let restoreSCheck = null; if (row._origSCheck !== undefined) { restoreSCheck = Number(row._origSCheck); } else if (row.SCheck_ !== undefined) { restoreSCheck = Number(row.SCheck_); } else if ([-1, -2, -3, -5].includes(Number(row.OCheck))) { restoreSCheck = Number(row.OCheck); } plans.push({ index, row, isComment: false, restoreIndex, restoreSCheck, }); } else { // 일반 상세 행 또는 비목 행(-2, -3, -5 등) -> 주석(-1)으로 전환 plans.push({ index, row, isComment: true, origIndex: curIndex, origSCheck: row.SCheck !== undefined ? Number(row.SCheck) : null, }); } } EstApp.change(() => { for (const p of plans) { if (p.isComment) { p.row._origIndex = p.origIndex; if (p.origSCheck !== null) { p.row._origSCheck = p.origSCheck; } p.row.Index = -1; p.row.SCheck = -1; } else { delete p.row._origIndex; delete p.row._origSCheck; p.row.Index = p.restoreIndex; if (p.restoreSCheck !== null) { p.row.SCheck = p.restoreSCheck; } else { delete p.row.SCheck; } } } }); } function alignWongaTotalAction(state) { // REA TFRMWONGABASE popupTotal: 공사비역산 자리수 맞추기 const rows = state.project.tables.Wonga; if (!rows || !rows.length) { throw Error("원가계산서 자료가 없습니다."); } const targetRow = rows.find((r) => Number(r.LinkIndex) === 55); const curTotal = targetRow ? Number(targetRow.Cost) || 0 : 0; EstActions.form( "공사비역산 자리수 맞추기", [ { name: "targetCost", label: "목표 도급공사비 (원)", type: "number", value: curTotal, }, { name: "unitDigits", label: "단단위 절하 기준", value: "3", choices: [ ["0", "원 단위 (절하 없음)"], ["1", "십원 단위 (10원 절하)"], ["2", "백원 단위 (100원 절하)"], ["3", "천원 단위 (1,000원 절하)"], ["4", "만원 단위 (10,000원 절하)"], ], }, ], ({ targetCost, unitDigits }) => { EstApp.change(() => { const direct = state.project.calculation?.direct || {}; const bases = { NC: Number(direct.NC) || 0, GC: Number(direct.GC) || 0, JC: Number(direct.JC) || 0, SC: Number(direct.SC) || 0, TC: Number(direct.TC) || 0, 노무비: Number(direct.NC) || 0, 재료비: Number(direct.GC) || 0, 경비: Number(direct.JC) || 0, 실적: Number(direct.SC) || 0, 직접공사비: Number(direct.TC) || 0, }; EstWonga.alignTotal(rows, targetCost, unitDigits, state.project.options, bases); state.project.options.TotalFix = true; }); }, ); } function resetChangeAction(state) { // REA mnuChangeInit: 설계변경 초기화 (확정된 당초 내역으로 완전 복원 및 변경 사본 제거) const project = state.project; // 당초 확정 정보(baseline)가 없는 불완전 상태에서의 복원 시도는 데이터 유실 위험이 있으므로 엄격 차단 if (!project.baseline || !project.baseline.tables) { throw Error( "확정된 당초 내역(baseline)이 없습니다. 완전 복원이 불가능하여 초기화를 진행할 수 없습니다.", ); } if (project.options.Mode !== "설계변경" && project.baseline == null) { throw Error("설계변경 모드가 아닙니다."); } // 1) 수량 연결(quantities, quantitySheets, excelQuantityLinks) 참조 무결성 사전 검사 const baselineEstMain = project.baseline.tables.EstMainDat || []; // 수량산출서 연결 검사 const qtyItems = []; if (Array.isArray(project.quantities)) { qtyItems.push(...project.quantities); } if (Array.isArray(project.quantitySheets)) { for (const s of project.quantitySheets) { if (Array.isArray(s.items)) qtyItems.push(...s.items); } } for (const item of qtyItems) { const code = item.TargetCode ?? item.LinkCode; const key = item.TargetKey; if (!code && !key) continue; const targets = baselineEstMain.filter( (row) => !Number(row.Level) && (key ? row.PkID != null && `id:${row.PkID}` === key : row.Code === code), ); if (targets.length !== 1) { throw Error( `수량산출 연결 무결성 오류: 신규 행을 참조하는 수량 연결(${key || code})이 존재하여 당초 내역 복원 시 연결이 끊어집니다. 해당 수량 연결을 먼저 해제하거나 변경하세요.`, ); } } // Excel 수량 연결 검사 (안정된 고유 식별자 PkID 및 항목 내용 동일성 엄격 검증) if (Array.isArray(project.excelQuantityLinks) && project.excelQuantityLinks.length > 0) { const currentEstMain = project.tables.EstMainDat || []; const currentRowsByPkID = new Map(); currentEstMain.forEach((r, idx) => { if (r.PkID != null) currentRowsByPkID.set(Number(r.PkID), { row: r, index: idx }); }); const baselineRowsByPkID = new Map(); baselineEstMain.forEach((r, idx) => { if (r.PkID != null) baselineRowsByPkID.set(Number(r.PkID), { row: r, index: idx }); }); for (const link of project.excelQuantityLinks) { if (!link || link.target == null) continue; const targetId = Number(link.target); // 1) 현재 작업본에 대상 행이 존재하는지 확인 const curInfo = currentRowsByPkID.get(targetId); if (!curInfo) { throw Error( `Excel 수량 연결 무결성 오류: 연결 대상 행(PkID ${targetId})이 현재 내역에 없습니다. 해당 연결을 먼저 정리하세요.`, ); } // 2) 당초 확정본(baseline)에 대상 행이 존재하는지 확인 (신규 행 참조 차단) const baseInfo = baselineRowsByPkID.get(targetId); if (!baseInfo) { throw Error( `Excel 수량 연결 무결성 오류: 신규 내역 행(PkID ${targetId}, ${curInfo.row.Code || curInfo.row.Name || ""})을 참조하는 Excel 연결(${link.file} ${link.cell})이 존재하여 당초 내역 복원 시 연결이 끊어집니다. 해당 연결을 먼저 해제하세요.`, ); } // 3) 동일성(Identity) 입증: Code, Name, Size, Unite 및 상세행 조건이 당초 행과 일치하는지 대조 const curRow = curInfo.row; const baseRow = baseInfo.row; const isSameIdentity = String(curRow.Code || "") === String(baseRow.Code || "") && String(curRow.Name || "") === String(baseRow.Name || "") && String(curRow.Size || "") === String(baseRow.Size || "") && String(curRow.Unite || "") === String(baseRow.Unite || "") && Number(curRow.Level || 0) === Number(baseRow.Level || 0); if (!isSameIdentity) { throw Error( `Excel 수량 연결 무결성 오류: 연결 대상 행(PkID ${targetId})의 내용(${curRow.Name})이 당초 행(${baseRow.Name})과 일치하지 않아 다른 항목을 가리키는 위험이 있습니다. 동일성이 입증되지 않아 초기화를 차단합니다.`, ); } } } if ( !confirm( "설계변경 작업을 초기화하고 당초 내역 상태로 되돌리시겠습니까?\n당초 사본에 포함되지 않은 기계·기초단가 자료는 현재 상태로 유지됩니다.", ) ) { return; } // 설계변경 당초 사본에 포함된 테이블만 복원한다. const changeTables = [ "EstMainDat", "HeadMainDat", "HeadTitleDat", "CostMainDat", "CostTitleDat", "MachMainDat", "MachTitleDat", "Wonga", ]; // 설계변경 관련 특정 필드 목록 (EstBusiness.baseline 스펙 일치) const baselineFields = [ "Code", "Name", "Size", "Unite", "GN", "TU", "TC", "NU", "GU", "JU", "SU", "NC", "GC", "JC", "SC", "LineData", "VSusic", "Cost", ]; EstApp.change(() => { // 1) baseline.tables에 존재하는 대상 테이블만 복원한다. for (const tName of changeTables) { if (project.baseline.tables[tName]) { project.tables[tName] = structuredClone(project.baseline.tables[tName]); } } // 2) 대상 테이블의 행들에서만 설계변경 관련 _ 접미사 필드 제거 (다른 업무 원본 필드, _origLinkIndex, _prevSCheck 등 완벽 보존) for (const tName of changeTables) { const rows = tName !== "MachMainDat" && project.baseline.tables[tName] && project.tables[tName]; if (!Array.isArray(rows)) continue; for (const row of rows) { row.NewItem = false; for (const f of baselineFields) { delete row[`${f}_`]; } } } // 3) baseline 제거 및 모드 환원 delete project.baseline; project.options.Mode = "내역서"; }); state.notice = "설계변경이 초기화되어 당초 내역 상태로 복원되었습니다."; } function resetWongaAction(state) { // REA TFRMWONGABASE popupInit: 원가계산서 항목 초기화 const rows = state.project.tables.Wonga; if (!rows || !rows.length) { throw Error("원가계산서 자료가 없습니다."); } if ( !confirm( "원가계산서 항목을 초기화하시겠습니까?\n(공사비역산 고정액이 해제되고 당초 수식 및 기준 단가로 재계산됩니다)", ) ) { return; } const direct = state.project.calculation?.direct || {}; const bases = { NC: Number(direct.NC) || 0, GC: Number(direct.GC) || 0, JC: Number(direct.JC) || 0, SC: Number(direct.SC) || 0, TC: Number(direct.TC) || 0, 노무비: Number(direct.NC) || 0, 재료비: Number(direct.GC) || 0, 경비: Number(direct.JC) || 0, 실적: Number(direct.SC) || 0, 직접공사비: Number(direct.TC) || 0, }; const restored = structuredClone(rows); EstWonga.resetWonga(restored, state.project.options, bases); EstApp.change(() => { state.project.tables.Wonga = restored; state.project.options.TotalFix = false; }); state.notice = "원가계산서 항목이 초기화되었습니다."; } function resetProgressInputsAction(state) { // REA mnuFileAccomInit: 기성 회차 입력 초기화 (선택된 회차의 금회/사정 입력 및 모드를 초기화) const project = state.project; if (!project.progress || !project.progress.length) { throw Error("기성 회차 자료가 없습니다."); } const round = project.progress.find((r) => r.id === state.roundId); if (!round) { throw Error("선택된 기성 회차가 없습니다."); } if (round.locked) { throw Error("확정된 회차는 입력을 초기화할 수 없습니다. 확정을 먼저 해제하세요."); } if ( !confirm( `[${round.name || "현재 회차"}]의 모든 금회 기성 및 사정 입력을 초기화하시겠습니까?\n(입력된 기성 수량과 비율이 모두 지워집니다)`, ) ) { return; } EstApp.change(() => { round.inputs = {}; delete round.result; }, false); state.notice = `[${round.name || "현재 회차"}]의 기성 입력이 초기화되었습니다.`; } function resetAnnualAction(state) { // REA 연차공사: 등록된 차수 배분 설정 초기화 (웹 전용 보조 기능, 원본 동등성 미확인) // 주의: REA 원본의 popInit은 단일 차수 수식/단가 초기화일 가능성이 있어 전체 차수 삭제와의 동등성은 미확인입니다. // 본 기능은 등록된 연차 차수 목록을 비우는 웹 전용 보조 기능으로 명확히 구분하여 제공합니다. const project = state.project; if (!project.annual || !project.annual.length) { throw Error("연차공사 차수 자료가 없습니다."); } if ( !confirm( "등록된 모든 연차공사 차수와 배분 수식을 초기화하시겠습니까?\n(웹 보조 기능: 기존 내역서 및 원가계산서 데이터는 유지됩니다)", ) ) { return; } EstApp.change(() => { project.annual = []; }, false); state.notice = "연차공사 차수 및 배분 설정이 초기화되었습니다 (웹 보조 기능)."; } function resetEscalationAction(state) { // REA 물가변동: 적용제외 수량 및 입력 초기화 (TFRMESCALATION popInit 스펙 기반) const rows = state.project.tables.EstMainDat; if (!rows || !rows.length) { throw Error("내역서 자료가 없습니다."); } if ( !confirm( "물가변동 적용제외 수량과 입력값을 초기화하시겠습니까?\n(예정·실행·기성·수동제외 수량이 초기화되고 적용제외 자동계산이 기본값으로 환원됩니다)", ) ) { return; } EstApp.change(() => { for (const row of rows) { delete row.EscPlanned; delete row.EscActual; delete row.EscProgress; delete row.EscManualQuantity; delete row.EscConstruction; delete row.EscPS; delete row.EscBefore; delete row.EscAuto; } delete state.project.options.EscDesignTotal; }, false); state.notice = "물가변동 적용제외 수량 및 설정이 초기화되었습니다."; } function adjustLevels(state, delta) { // REA mnuLevelUp, mnuLevelDown, mnuCostLevelUp, mnuCostLevelDown: 선택 행 레벨 일괄 증가/감소 const rows = state.project.tables[state.view]; if ( !rows || !["EstMainDat", "CostMainDat", "HeadMainDat", "MachMainDat"].includes(state.view) ) { throw Error("내역서, 단가산출, 일위대가, 기계경비 화면에서만 레벨을 조정할 수 있습니다."); } const selected = [...state.selection].sort((a, b) => a - b); if (!selected.length) { throw Error("레벨을 조정할 행을 선택하세요."); } EstApp.change(() => { for (const idx of selected) { const r = rows[idx]; if (!r) continue; const curLevel = Number(r.Level) || 0; // Level 범위: 0 ~ 9 const newLevel = Math.max(0, Math.min(9, curLevel + delta)); r.Level = newLevel; } }); state.notice = `${selected.length}개 행의 레벨이 일괄 ${delta > 0 ? "증가" : "감소"}되었습니다.`; } function pageRows(state, rows, key, eligible) { const query = String(state.query || "") .trim() .toLowerCase(); const filtered = rows .map((row, index) => ({ row, index })) .filter( ({ row }) => eligible(row) && (!query || ["Code", "Name", "Size", "Unite"].some((field) => String(row[field] ?? "") .toLowerCase() .includes(query), )), ); const pages = Math.max(1, Math.ceil(filtered.length / 100)); state[key] = Math.max(0, Math.min(Number(state[key]) || 0, pages - 1)); return { rows: filtered.slice(state[key] * 100, state[key] * 100 + 100), pages, total: filtered.length, }; } function progressPage(state, rows) { return pageRows( state, rows, "progressPage", (row) => !EstMath.num(row.Level) && (row.Code || row.Name || [54, 55, 57, 58, 62, 67].includes(Number(row.LinkIndex))), ); } function escalationPage(state, rows) { return pageRows( state, rows, "escalationPage", (row) => (row.Code || row.Name) && !EstMath.num(row.Level) && ![54, 55, 58, 59].includes(Number(row.LinkIndex)), ); } function changePage(state, rows) { return pageRows(state, rows, "changePage", (row) => row.Code || row.Name); } function handle(name, state) { if (name === "progress-prev" || name === "progress-next") { state.progressPage = (Number(state.progressPage) || 0) + (name === "progress-next" ? 1 : -1); EstApp.render(); return true; } if (name === "escalation-prev" || name === "escalation-next") { state.escalationPage = (Number(state.escalationPage) || 0) + (name === "escalation-next" ? 1 : -1); EstApp.render(); return true; } if (name === "change-prev" || name === "change-next") { state.changePage = (Number(state.changePage) || 0) + (name === "change-next" ? 1 : -1); EstApp.render(); return true; } if (name === "block-start") { setBlockStart(state); return true; } if (name === "block-end") { setBlockEnd(state); return true; } if (name === "block-clear") { clearBlockSelection(state); return true; } if (name === "select-all-rows") { selectAllRows(state); return true; } if (name === "block-del") { deleteBlockRows(state); return true; } if (name === "toggle-cost-comment") { toggleCostComment(state); return true; } if (name === "align-wonga-total") { alignWongaTotalAction(state); return true; } if (name === "reset-escalation") { resetEscalationAction(state); return true; } if (name === "reset-annual") { resetAnnualAction(state); return true; } if (name === "reset-round-inputs") { resetProgressInputsAction(state); return true; } if (name === "reset-wonga") { resetWongaAction(state); return true; } if (name === "level-batch-up") { adjustLevels(state, 1); return true; } if (name === "reset-change") { resetChangeAction(state); return true; } if (name === "level-batch-down") { adjustLevels(state, -1); return true; } return false; } return { setBlockStart, setBlockEnd, clearBlockSelection, selectAllRows, deleteBlockRows, toggleCostComment, alignWongaTotalAction, resetEscalationAction, resetAnnualAction, resetProgressInputsAction, resetWongaAction, resetChangeAction, adjustLevels, progressPage, escalationPage, changePage, handle, }; })();