feat(est-test): 연결 자료와 함께 내역 복사·붙여넣기

- 내역 행이 쓰는 기초자료와 일위대가·단가산출·기계경비 호표를 하위까지 모아 다른 공사로 옮김
- 같은 코드는 붙여넣을 공사 자료 유지, 비어 있는 기계경비 적용기준만 함께 옮김

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-11 17:09:51 +09:00
co-authored by Claude Opus 5.5
parent 4876ae1e75
commit 5e9b6a7bea
7 changed files with 268 additions and 2 deletions
+102
View File
@@ -0,0 +1,102 @@
"""EST 테스트 앱 연결 자료와 함께 복사·붙여넣기(원본 TFRMOTHERLOAD)."""
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
FILES = [
"native-math.js",
"native-formula.js",
"native-machine.js",
"native-rows.js",
"native-text.js",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
"native-row-match.js",
"native-escalation.js",
"native-progress.js",
"native-business.js",
"native-review.js",
"native-resources.js",
"native-schedule.js",
"native-model.js",
"native-verify.js",
"native-transfer.js",
]
def run_js(code: str):
runner = f"""
const fs = require('fs'), vm = require('vm');
const ctx = vm.createContext({{ structuredClone, console }});
for (const f of {json.dumps(FILES)})
vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, {{ filename: f }});
ctx.source = JSON.parse(fs.readFileSync('tools/est-test/sample.json', 'utf8'));
process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx)));
"""
out = subprocess.check_output(["node", "-e", runner], cwd=ROOT)
return json.loads(out.decode("utf-8"))
PICK = """
const calc = (p) => EstNative.calculate(EstModel.validate(p));
const src = calc(source);
const linked = src.tables.EstMainDat.filter((r) => r.Code && !Number(r.Level));
const picked = [5, 103, 104].flatMap((k) => linked.filter((r) => Number(r.LinkIndex) === k).slice(0, 2));
const deps = EstTransfer.collect(src, picked);
"""
def test_copy_into_empty_project_keeps_prices_and_links():
result = run_js(
"(() => {"
+ PICK
+ """
const target = EstNative.empty();
target.options = structuredClone(src.options);
const merged = EstTransfer.merge(target, structuredClone(deps));
EstTransfer.paste(target, picked, 0);
const done = calc(target);
const pasted = done.tables.EstMainDat.slice(0, picked.length);
return {
kinds: [...new Set(picked.map((r) => Number(r.LinkIndex)))].sort((a, b) => a - b),
groups: Object.keys(deps.groups).sort(),
nested: (deps.flat.LaborcDat || []).length > 0,
missing: done.calculation.errors.filter((e) => e.includes('연결 자료 없음') || e.includes('하위 자료 없음')),
same: pasted.every((r, i) => Math.abs(Number(r.TC) - Number(picked[i].TC)) < 1),
added: merged.added.length > 0, kept: merged.kept.length, criteria: merged.criteria,
unique: new Set(done.tables.HeadMainDat.map((r) => r.PkID)).size === done.tables.HeadMainDat.length,
};
})()"""
)
assert result["kinds"] == [5, 103, 104]
assert result["groups"] == ["CostMainDat", "HeadMainDat", "MachMainDat"]
assert result["nested"] is True
assert result["missing"] == []
assert result["same"] is True
assert result["added"] is True
assert result["kept"] == 0
assert result["criteria"] is True
assert result["unique"] is True
def test_same_code_in_target_is_kept_not_overwritten():
result = run_js(
"(() => {"
+ PICK
+ """
const target = structuredClone(source);
const labor = target.tables.LaborcDat[0];
labor.Cost1 = Number(labor.Cost1) * 2; labor.TC = Number(labor.TC) * 2;
const before = target.tables.HeadMainDat.length;
target.tables.MachGijunDat[0].TC = 12345;
const merged = EstTransfer.merge(target, structuredClone(deps));
return { added: merged.added.length, kept: merged.kept.length > 0,
criteria: merged.criteria === false && target.tables.MachGijunDat[0].TC === 12345,
heads: target.tables.HeadMainDat.length === before,
labor: target.tables.LaborcDat[0].TC === labor.TC };
})()"""
)
assert result == {"added": 0, "kept": True, "criteria": True, "heads": True, "labor": True}
+4 -1
View File
@@ -444,11 +444,14 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
- [x] Excel 내역 열 맞춰 가져오기(원본 TFRMEXCELTOEST 내역서 탭 — tvViewEst 열 Code·Pr·Name·Size·GN·Unite·TU·TC·NU·NC·GU·GC·JU·JC·Bigo) — 시트·시작행·끝행·항목별 열 문자, 미리보기, 순공사비 위 추가·바꾸기. 단위·수량 없는 줄 = 공종 제목(Level 1), 금액은 다시 계산, 수식 셀은 저장된 계산값. 합계단가만 있는 줄은 비목 임의 배분 없이 거부.
- 검증완료: `test_est_excel_map.py` 3개(열 문자·쉼표 숫자·빈 줄·잘못된 입력, 제목·상세 구분·PkID 중복 없음·순공사비 위 배치·금액 계산, 합계단가만 거부·바꾸기 모드), 관련 9개 통과. ORCA 브라우저에서 openpyxl로 만든 수식 포함 xlsx를 서버로 읽어 시트 2개 표시·미리보기, 5행(제목 2) 가져오기 → 순공사비 45,293,066 → 61,798,566 확인 후 샘플 복구.
- 남음: 일위대가·단가산출·표준·견적·자재·노임 탭, 원본 「셀부호 밀기·당기기」.
- [x] 연결 자료와 함께 복사·붙여넣기(원본 TFRMOTHERLOAD) — 내역 행의 자재·노임·실적·견적 행과 일위대가·단가산출·기계경비 호표를 하위까지 모아 다른 공사에 붙여넣기. 같은 코드는 붙여넣을 공사 자료 유지(덮어쓰지 않음), 기계경비 적용기준은 붙여넣을 공사에 없을 때만 옮김.
- 검증완료: `test_est_transfer.py` 2개(빈 공사로 옮겨 연결 오류 0·금액 동일·하위 기계경비 호표 포함·PkID 중복 없음, 같은 코드·적용기준 덮어쓰지 않음), 관련 시험 통과. ORCA 브라우저에서 샘플 내역 3행 복사 → 새 내역 붙여넣기 → 연결 자료 12건 추가·금액 1,285,046·417,024·680,414 원본과 동일·계산 오류 0, 샘플 복구.
- 남음: 프로젝트 추가(다른 파일의 내역 전체 합치기), 두 공사 나란히 보기.
- 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133.
- [ ] 남은 핵심(실무 빈도순):
1. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기.
3. 수량산출서 — 누계·4방향 합계, 제출용 전체 인쇄.
5. 다른 공사 불러오기 시 딸린 일위·단가산출 함께 복사, 프로젝트 추가(합치기).
5. 프로젝트 추가(다른 파일의 내역 합치기).
6. 낙찰률 전체·범위·공종별 적용.
7. 무관자료 삭제, 데이타 정리(정렬·번호), 품명정리.
8. 원가계산서 — 정산원가계산서, 기타경비·요율 대화, 서식 저장·불러오기, 기성 원가 편집.
+2
View File
@@ -22,6 +22,8 @@
내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다.
`편집 → 내역 행 연결 자료와 함께 복사`는 고른 내역 행이 쓰는 자재·노임·실적·견적 자료와 일위대가·단가산출·기계경비 호표를 하위까지 모아 복사합니다. 다른 공사를 연 뒤 `연결 자료와 함께 붙여넣기`를 누르면 없는 자료만 추가하고, 같은 코드가 이미 있으면 그 공사의 자료를 그대로 씁니다. 붙여넣을 공사에 기계경비 적용기준이 비어 있을 때만 적용기준도 함께 옮깁니다.
`파일 → Excel 내역 열 맞춰 가져오기`는 원본 엑셀→EST 변환처럼 다른 프로그램이나 발주처의 Excel 내역을 읽습니다. 시트·시작행·끝행과 번호·품명·규격·단위·수량·단가 등 항목별 Excel 열 문자를 적고 미리보기로 확인한 뒤, 순공사비 줄 위에 추가하거나 그 위 행을 바꿉니다. 단위와 수량이 없는 줄은 공종 제목이 되고, 금액은 단가×수량으로 다시 계산합니다. 수식 셀은 Excel이 저장해 둔 값을 씁니다. 합계단가만 있고 노무·재료·경비로 나눈 단가가 없으면 비목을 정할 수 없어 가져오지 않습니다.
`파일 → 공내역 파일로 저장`은 원본처럼 내역·일위대가의 금액을 비운 사본을 내려받습니다. 남길 값은 수량만(단가 비움)·단가만(수량 비움)·수량·단가(금액만 비움) 중에서 고르고, 원본 ESTX를 가져온 작업은 ESTX로도 받을 수 있습니다. 사본은 자동계산을 끈 상태로 저장해 열어도 빈칸이 유지되며, 원가계산서 금액도 비웁니다. 현재 작업은 바뀌지 않습니다.
+3
View File
@@ -42,6 +42,7 @@
<script defer src="native-blank.js"></script>
<script defer src="native-quantity-sheets.js"></script>
<script defer src="native-excel-map.js"></script>
<script defer src="native-transfer.js"></script>
<script defer src="native-actions.js"></script>
<script defer src="native-business-actions.js"></script>
<script defer src="native-app.js"></script>
@@ -87,6 +88,8 @@
><button data-action="cut-row">선택 행 잘라내기</button
><button data-action="paste-row">행 붙여넣기</button
><button data-action="delete-row">선택 행 삭제</button>
<button data-action="copy-with-links">내역 행 연결 자료와 함께 복사</button
><button data-action="paste-with-links">연결 자료와 함께 붙여넣기</button>
<hr />
<button data-action="block-start">블럭 시작</button
><button data-action="block-end">블럭 끝</button
+2 -1
View File
@@ -603,7 +603,8 @@ const EstBusinessActions = (() => {
(typeof EstFind !== "undefined" && EstFind.handle(name, state)) ||
(typeof EstBlank !== "undefined" && EstBlank.handle(name, state)) ||
(typeof EstQuantitySheets !== "undefined" && EstQuantitySheets.handle(name, state, node)) ||
(typeof EstExcelMap !== "undefined" && EstExcelMap.handle(name, state))
(typeof EstExcelMap !== "undefined" && EstExcelMap.handle(name, state)) ||
(typeof EstTransfer !== "undefined" && EstTransfer.handle(name, state))
) {
EstApp.render();
} else if (name === "load-contract" || name === "load-bid") {
+154
View File
@@ -0,0 +1,154 @@
// 원본 다른 공사 불러오기·복사(TFRMOTHERLOAD)와 프로젝트 추가(mnuFileLoadProjectAs)의 하위 자료 보존.
// 내역 행이 쓰는 자재·노임·실적·견적 행과 일위대가·단가산출·기계경비 호표를 하위까지 모아 함께 옮긴다.
// 붙여넣을 공사에 같은 코드가 있으면 그 공사의 자료를 그대로 쓰고(덮어쓰지 않음) 개수만 알린다.
// 기계경비 적용기준은 붙여넣을 공사에 하나도 없을 때만 함께 옮긴다.
const EstTransfer = (() => {
const flat = { 5: "StuffcDat", 6: "LaborcDat", 21: "SilJukDat", 101: "EstiMateDat" };
const grouped = {
103: ["HeadTitleDat", "HeadMainDat"],
104: ["CostTitleDat", "CostMainDat"],
105: ["MachTitleDat", "MachMainDat"],
};
const num = (value) => Number(value) || 0;
function collect(source, rows) {
const deps = { flat: {}, groups: {} },
seen = new Set(),
queue = [...rows];
while (queue.length) {
const row = queue.shift(),
kind = num(row.LinkIndex),
code = row.Code;
if (!code || seen.has(`${kind}:${code}`)) continue;
if (flat[kind]) {
const found = (source.tables[flat[kind]] || []).find((r) => r.Code === code);
if (!found) continue;
seen.add(`${kind}:${code}`);
(deps.flat[flat[kind]] ||= []).push(structuredClone(found));
} else if (grouped[kind]) {
const [titleTable, mainTable] = grouped[kind],
group = EstNative.groups(source.tables[mainTable] || []).get(code);
if (!group) continue;
seen.add(`${kind}:${code}`);
const title = (source.tables[titleTable] || []).find((r) => r.Code === code);
(deps.groups[mainTable] ||= []).push({
code,
titleTable,
title: title ? structuredClone(title) : null,
rows: structuredClone(group.rows),
});
queue.push(...group.rows.slice(1));
}
}
// 기계경비 적용기준은 공사 전체 설정 — 붙여넣을 공사에 없을 때만 함께 옮긴다.
if (deps.groups.MachMainDat?.length && source.tables.MachGijunDat?.length)
deps.criteria = structuredClone(source.tables.MachGijunDat);
return deps;
}
function nextPkID(project, table, extra = []) {
return (
Math.max(
0,
...(project.tables[table] || []).map((r) => num(r.PkID)),
...extra.map((r) => num(r.PkID)),
) + 1
);
}
function merge(target, deps) {
const added = [],
kept = [];
for (const [table, list] of Object.entries(deps.flat)) {
const rows = (target.tables[table] ||= []);
for (const row of list) {
if (rows.some((r) => r.Code === row.Code)) {
kept.push(row.Code);
continue;
}
row.PkID = nextPkID(target, table);
row.Index = Math.max(0, ...rows.map((r) => num(r.Index))) + 1;
rows.push(row);
added.push(row.Code);
}
}
for (const [table, list] of Object.entries(deps.groups)) {
const rows = (target.tables[table] ||= []);
for (const group of list) {
if (EstNative.groups(rows).has(group.code)) {
kept.push(group.code);
continue;
}
let pk = nextPkID(target, table);
const index = Math.max(0, ...rows.map((r) => num(r.Index))) + 1;
group.rows.forEach((row, i) => {
row.PkID = pk++;
if (i === 0) row.Index = index;
});
rows.push(...group.rows);
if (group.title) {
const titles = (target.tables[group.titleTable] ||= []);
if (!titles.some((r) => r.Code === group.code)) {
group.title.PkID = nextPkID(target, group.titleTable);
group.title.Index = index;
titles.push(group.title);
}
}
added.push(group.code);
}
}
let criteria = false;
if (deps.criteria && !target.tables.MachGijunDat?.length) {
target.tables.MachGijunDat = deps.criteria;
criteria = true;
}
return { added, kept, criteria };
}
function paste(target, rows, at) {
const list = target.tables.EstMainDat;
let pk = nextPkID(target, "EstMainDat");
const copies = structuredClone(rows).map((row) => {
row.PkID = pk++;
row.NewItem = true;
delete row.PreviousCodes;
return row;
});
list.splice(at, 0, ...copies);
return copies.length;
}
// 화면
let clipboard = null;
function handle(name, state) {
if (name === "copy-with-links") {
if (state.view !== "EstMainDat") throw Error("내역서 화면에서 행을 고르세요.");
const rows = state.project.tables.EstMainDat,
picked = [...state.selection].sort((a, b) => a - b).map((i) => rows[i]);
if (!picked.length) throw Error("복사할 내역 행을 선택하세요.");
clipboard = {
rows: structuredClone(picked),
deps: collect(state.project, picked),
from: state.project.name,
};
const count =
Object.values(clipboard.deps.flat).flat().length +
Object.values(clipboard.deps.groups).flat().length;
state.notice = `내역 ${picked.length}행과 연결 자료 ${count}건을 복사했습니다. 다른 공사를 열고 붙여넣으세요.`;
} else if (name === "paste-with-links") {
if (!clipboard) throw Error("먼저 「연결 자료와 함께 복사」를 하세요.");
if (state.view !== "EstMainDat") throw Error("내역서 화면에서 붙여넣으세요.");
const rows = state.project.tables.EstMainDat,
at = ([...state.selection].sort((a, b) => a - b).at(-1) ?? rows.length - 1) + 1;
let result;
EstApp.change((project) => {
result = merge(project, structuredClone(clipboard.deps));
paste(project, clipboard.rows, at);
});
state.notice = `내역 ${clipboard.rows.length}행을 붙여넣었습니다. 연결 자료 ${result.added.length}건 추가${result.kept.length ? `, 같은 코드 ${result.kept.length}건은 이 공사 자료를 그대로 씀` : ""}${result.criteria ? ", 비어 있던 기계경비 적용기준도 함께 옮김" : ""}.`;
} else return false;
return true;
}
return { collect, merge, paste, handle };
})();
+1
View File
@@ -62,6 +62,7 @@ const allowed = new Set([
"native-blank.js",
"native-quantity-sheets.js",
"native-excel-map.js",
"native-transfer.js",
"native-issues.js",
"native-attachments.js",
"native-standard.js",