diff --git a/resources/tester/test_est_transfer.py b/resources/tester/test_est_transfer.py
index 1d5fc9f7d..d581fc95d 100644
--- a/resources/tester/test_est_transfer.py
+++ b/resources/tester/test_est_transfer.py
@@ -100,3 +100,29 @@ def test_same_code_in_target_is_kept_not_overwritten():
})()"""
)
assert result == {"added": 0, "kept": True, "criteria": True, "heads": True, "labor": True}
+
+
+def test_project_add_merges_rows_above_net_cost():
+ result = run_js(
+ """(() => {
+ const calc = (p) => EstNative.calculate(EstModel.validate(p));
+ const target = calc(structuredClone(source)), other = calc(structuredClone(source));
+ const net = (p) => p.tables.EstMainDat.find((r) => Number(r.LinkIndex) === 54);
+ const before = Number(net(target).TC), count = target.tables.EstMainDat.length;
+ const boundary = target.tables.EstMainDat.findIndex((r) => Number(r.LinkIndex) === 54);
+ const out = EstTransfer.addProject(target, other);
+ const done = calc(target);
+ let empty = '';
+ try { EstTransfer.addProject(target, { tables: { EstMainDat: [{ LinkIndex: 54 }] } }); } catch (e) { empty = e.message; }
+ return { rows: out.rows === boundary, added: out.added.length, grew: done.tables.EstMainDat.length - count === boundary,
+ doubled: Math.abs(Number(net(done).TC) - 2 * before) < 2, errors: done.calculation.errors.length, empty: empty.slice(0, 8) };
+ })()"""
+ )
+ assert result == {
+ "rows": True,
+ "added": 0,
+ "grew": True,
+ "doubled": True,
+ "errors": 0,
+ "empty": "추가할 공사에 ",
+ }
diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md
index 0a284af62..4dc94f846 100644
--- a/tools/est-test/PLAN.md
+++ b/tools/est-test/PLAN.md
@@ -446,12 +446,13 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
- 남음: 일위대가·단가산출·표준·견적·자재·노임 탭, 원본 「셀부호 밀기·당기기」.
- [x] 연결 자료와 함께 복사·붙여넣기(원본 TFRMOTHERLOAD) — 내역 행의 자재·노임·실적·견적 행과 일위대가·단가산출·기계경비 호표를 하위까지 모아 다른 공사에 붙여넣기. 같은 코드는 붙여넣을 공사 자료 유지(덮어쓰지 않음), 기계경비 적용기준은 붙여넣을 공사에 없을 때만 옮김.
- 검증완료: `test_est_transfer.py` 2개(빈 공사로 옮겨 연결 오류 0·금액 동일·하위 기계경비 호표 포함·PkID 중복 없음, 같은 코드·적용기준 덮어쓰지 않음), 관련 시험 통과. ORCA 브라우저에서 샘플 내역 3행 복사 → 새 내역 붙여넣기 → 연결 자료 12건 추가·금액 1,285,046·417,024·680,414 원본과 동일·계산 오류 0, 샘플 복구.
- - 남음: 프로젝트 추가(다른 파일의 내역 전체 합치기), 두 공사 나란히 보기.
+ - 남음: 두 공사 나란히 보기.
+ - [x] 프로젝트 추가(원본 mnuFileLoadProjectAs) — 다른 공사 파일(ESTX·JSON)의 순공사비 위 내역 전체를 하위 자료와 함께 이 공사 순공사비 위에 합침. 같은 코드 자료 유지.
+ - 검증완료: `test_est_transfer.py` 3개 통과(합친 뒤 순공사비 2배·계산 오류 0·빈 공사 거부). ORCA 브라우저에서 메뉴 확인, 샘플에 샘플 합치기 → 35행·같은 코드 46건 유지·순공사비 45,293,066 → 90,586,132, 샘플 복구. 파일 고르기 창 자체는 자동 조작 불가로 기존 가져오기 경로(`/api/import`)를 그대로 씀.
- 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133.
- [ ] 남은 핵심(실무 빈도순):
1. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기.
3. 수량산출서 — 누계·4방향 합계, 제출용 전체 인쇄.
- 5. 프로젝트 추가(다른 파일의 내역 합치기).
6. 낙찰률 전체·범위·공종별 적용.
7. 무관자료 삭제, 데이타 정리(정렬·번호), 품명정리.
8. 원가계산서 — 정산원가계산서, 기타경비·요율 대화, 서식 저장·불러오기, 기성 원가 편집.
diff --git a/tools/est-test/README.md b/tools/est-test/README.md
index 67e697f16..1cccddaf5 100644
--- a/tools/est-test/README.md
+++ b/tools/est-test/README.md
@@ -22,6 +22,8 @@
내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다.
+`파일 → 프로젝트 추가`는 다른 공사 파일(ESTX·웹 JSON)의 순공사비 위 내역 행 전체를 하위 자료와 함께 이 공사의 순공사비 위에 합칩니다. 같은 코드 자료는 이 공사의 것을 그대로 씁니다.
+
`편집 → 내역 행 연결 자료와 함께 복사`는 고른 내역 행이 쓰는 자재·노임·실적·견적 자료와 일위대가·단가산출·기계경비 호표를 하위까지 모아 복사합니다. 다른 공사를 연 뒤 `연결 자료와 함께 붙여넣기`를 누르면 없는 자료만 추가하고, 같은 코드가 이미 있으면 그 공사의 자료를 그대로 씁니다. 붙여넣을 공사에 기계경비 적용기준이 비어 있을 때만 적용기준도 함께 옮깁니다.
`파일 → Excel 내역 열 맞춰 가져오기`는 원본 엑셀→EST 변환처럼 다른 프로그램이나 발주처의 Excel 내역을 읽습니다. 시트·시작행·끝행과 번호·품명·규격·단위·수량·단가 등 항목별 Excel 열 문자를 적고 미리보기로 확인한 뒤, 순공사비 줄 위에 추가하거나 그 위 행을 바꿉니다. 단위와 수량이 없는 줄은 공종 제목이 되고, 금액은 단가×수량으로 다시 계산합니다. 수식 셀은 Excel이 저장해 둔 값을 씁니다. 합계단가만 있고 노무·재료·경비로 나눈 단가가 없으면 비목을 정할 수 없어 가져오지 않습니다.
diff --git a/tools/est-test/index.html b/tools/est-test/index.html
index d00944f8e..99d20fea9 100644
--- a/tools/est-test/index.html
+++ b/tools/est-test/index.html
@@ -74,6 +74,7 @@
+
diff --git a/tools/est-test/native-transfer.js b/tools/est-test/native-transfer.js
index 962002e9d..0a575a1e3 100644
--- a/tools/est-test/native-transfer.js
+++ b/tools/est-test/native-transfer.js
@@ -118,6 +118,48 @@ const EstTransfer = (() => {
return copies.length;
}
+ // 프로젝트 추가: 다른 공사의 순공사비 위 내역 행 전체를 하위 자료와 함께 이 공사의 순공사비 위에 붙인다.
+ function addProject(target, source) {
+ const list = source.tables?.EstMainDat || [];
+ let end = list.findIndex((row) => Number(row.LinkIndex) === 54);
+ if (end < 0) end = list.length;
+ const rows = list.slice(0, end);
+ if (!rows.length) throw Error("추가할 공사에 순공사비 위 내역 행이 없습니다.");
+ const result = merge(target, collect(source, rows));
+ let at = target.tables.EstMainDat.findIndex((row) => Number(row.LinkIndex) === 54);
+ if (at < 0) at = target.tables.EstMainDat.length;
+ paste(target, rows, at);
+ return { ...result, rows: rows.length };
+ }
+
+ function chooseProject(state) {
+ if (state.view !== "EstMainDat") throw Error("내역서 화면에서 프로젝트를 추가하세요.");
+ const input = document.createElement("input");
+ input.type = "file";
+ input.accept = ".estx,.ebkx,.json";
+ input.addEventListener("change", async () => {
+ try {
+ const file = input.files[0];
+ if (!file) return;
+ const source = /\.(estx|ebkx)$/i.test(file.name)
+ ? await EstApp.api(`/api/import?name=${encodeURIComponent(file.name)}`, {
+ method: "POST",
+ body: await file.arrayBuffer(),
+ })
+ : JSON.parse(await file.text());
+ if (!confirm(`「${source.name || file.name}」의 내역을 이 공사의 순공사비 위에 합칩니다.`))
+ return;
+ let result;
+ EstApp.change((project) => (result = addProject(project, source)));
+ state.notice = `내역 ${result.rows}행을 합쳤습니다. 연결 자료 ${result.added.length}건 추가${result.kept.length ? `, 같은 코드 ${result.kept.length}건은 이 공사 자료를 그대로 씀` : ""}.`;
+ } catch (error) {
+ state.notice = error.message;
+ }
+ EstApp.render();
+ });
+ input.click();
+ }
+
// 화면
let clipboard = null;
function handle(name, state) {
@@ -146,9 +188,10 @@ const EstTransfer = (() => {
paste(project, clipboard.rows, at);
});
state.notice = `내역 ${clipboard.rows.length}행을 붙여넣었습니다. 연결 자료 ${result.added.length}건 추가${result.kept.length ? `, 같은 코드 ${result.kept.length}건은 이 공사 자료를 그대로 씀` : ""}${result.criteria ? ", 비어 있던 기계경비 적용기준도 함께 옮김" : ""}.`;
- } else return false;
+ } else if (name === "project-add") chooseProject(state);
+ else return false;
return true;
}
- return { collect, merge, paste, handle };
+ return { collect, merge, paste, addProject, handle };
})();