feat(est-test): 연결 자료와 함께 내역 복사·붙여넣기
- 내역 행이 쓰는 기초자료와 일위대가·단가산출·기계경비 호표를 하위까지 모아 다른 공사로 옮김 - 같은 코드는 붙여넣을 공사 자료 유지, 비어 있는 기계경비 적용기준만 함께 옮김 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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}
|
||||
@@ -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. 원가계산서 — 정산원가계산서, 기타경비·요율 대화, 서식 저장·불러오기, 기성 원가 편집.
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
|
||||
내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다.
|
||||
|
||||
`편집 → 내역 행 연결 자료와 함께 복사`는 고른 내역 행이 쓰는 자재·노임·실적·견적 자료와 일위대가·단가산출·기계경비 호표를 하위까지 모아 복사합니다. 다른 공사를 연 뒤 `연결 자료와 함께 붙여넣기`를 누르면 없는 자료만 추가하고, 같은 코드가 이미 있으면 그 공사의 자료를 그대로 씁니다. 붙여넣을 공사에 기계경비 적용기준이 비어 있을 때만 적용기준도 함께 옮깁니다.
|
||||
|
||||
`파일 → Excel 내역 열 맞춰 가져오기`는 원본 엑셀→EST 변환처럼 다른 프로그램이나 발주처의 Excel 내역을 읽습니다. 시트·시작행·끝행과 번호·품명·규격·단위·수량·단가 등 항목별 Excel 열 문자를 적고 미리보기로 확인한 뒤, 순공사비 줄 위에 추가하거나 그 위 행을 바꿉니다. 단위와 수량이 없는 줄은 공종 제목이 되고, 금액은 단가×수량으로 다시 계산합니다. 수식 셀은 Excel이 저장해 둔 값을 씁니다. 합계단가만 있고 노무·재료·경비로 나눈 단가가 없으면 비목을 정할 수 없어 가져오지 않습니다.
|
||||
|
||||
`파일 → 공내역 파일로 저장`은 원본처럼 내역·일위대가의 금액을 비운 사본을 내려받습니다. 남길 값은 수량만(단가 비움)·단가만(수량 비움)·수량·단가(금액만 비움) 중에서 고르고, 원본 ESTX를 가져온 작업은 ESTX로도 받을 수 있습니다. 사본은 자동계산을 끈 상태로 저장해 열어도 빈칸이 유지되며, 원가계산서 금액도 비웁니다. 현재 작업은 바뀌지 않습니다.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user