feat(est-test): 엑셀데이타를 EST로 변환 7개 탭

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 18:45:37 +09:00
co-authored by Claude Opus 5.5
parent 3722bfa23c
commit aa58f3ef7a
7 changed files with 414 additions and 1 deletions
+1
View File
@@ -36,6 +36,7 @@ for (const file of [
"native-extra.js",
"native-business-actions.js",
"native-forms.js",
"native-excel-to-est.js",
]) {
vm.runInContext(fs.readFileSync(path.join(base, file), "utf8"), context, { filename: file });
}
+36
View File
@@ -0,0 +1,36 @@
"""EST 테스트 앱 엑셀데이타를 EST로 변환 7개 탭(원본 TfrmExcelToEst — 셀 규칙·머리 줄 꼴·호표 만들기)."""
from test_est_rea_parity import run
def test_convert_tabs_and_heads():
result = run(
"math",
r"""(()=>{
const X=EstExcelToEst;
const sheets={노임단가:[['L1',' 보통인부 ','','M2',172698,'']],
견적단가:[['','.펌프','','1','EA','1,500','500','1,000','0','']],
표준시장단가:[['C1','포장','t=5','m2',1000,300,600,100,0.3,'포함',''],],
단가산출:[['','산근 1 호표 : 거푸집 합판 (1 ㎡ 당)',100,60,40,0],['','합판 1*2',40,0,40,0],['','#.산근2|철근가공|D10|ton',9,9,0,0]],
일위대가:[['','No 5 호표 터파기','','','',0,10,0,10,0,0,0,0,''],['','인부','','0.1','인',100,10,100,10,0,0,0,0,''],['','[합계]','','','',0,10,0,0,0,0,0,0,'']]};
const cols=(n)=>Array.from({length:n},(_,i)=>String.fromCharCode(65+i));
const tabs={LaborcDat:{sheet:'노임단가',start:1,end:1,columns:cols(6)},EstiMateDat:{sheet:'견적단가',start:1,end:1,columns:cols(10)},
SilJukDat:{sheet:'표준시장단가',start:1,end:1,columns:cols(11)},CostMainDat:{sheet:'단가산출',start:1,end:3,columns:cols(6)},
HeadMainDat:{sheet:'일위대가',start:1,end:3,columns:cols(14)},StuffcDat:{sheet:'없는시트',start:1,end:1,columns:cols(18)}};
const r=X.convert(sheets,tabs,{name:'시험',wonga:[]}), t=r.project.tables;
return {failed:r.failed, lab:[t.LaborcDat[0].Name,t.LaborcDat[0].Unite,t.LaborcDat[0].TC],
est:[t.EstiMateDat[0].Name,t.EstiMateDat[0].TC,t.EstiMateDat[0].OTC,t.EstiMateDat[0].Code.slice(0,1)],
sil:[t.SilJukDat[0].NCRatio,t.SilJukDat[0].Cost1,t.SilJukDat[0].ONC],
cost:t.CostTitleDat.map(x=>[x.Name,x.Size,x.Unite]), line:t.CostMainDat[1].LineData,
head:[t.HeadTitleDat.map(x=>x.Name),t.HeadMainDat.length], rows:t.EstMainDat.length, auto:r.project.options.AutoCalc};
})()""",
)
assert result["failed"] == ["없는시트 시트가 없습니다."]
assert result["lab"] == ["보통인부", "㎡", 172698]
assert result["est"] == ["펌프", 1500, 1500, "N"]
assert result["sil"] == [30, 1000, 300]
assert result["cost"] == [["거푸집", "합판", "㎡"], ["철근가공", "D10", "ton"]]
assert result["line"] == "합판 1×2"
assert result["head"] == [["터파기"], 2]
assert result["rows"] == 36
assert result["auto"] is False
+2
View File
@@ -489,6 +489,8 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
- 남음: 구성비·요율 전환(CalcBase), 약어 표시(SName), 제비율 기준 문서 보기(원본 한글 파일).
- [x] 수량산출서 검색·전체보기(원본 TFRMMULLANG btnSerch·btnAllDisplay — 빈칸·|·괄호 무시 포함 검색) — 고르기 줄을 `native-quantity-sheets.js` 로 옮겨 700줄 제한 유지.
- 검증완료: `test_est_quantity_sheets.py` 검색 시험 포함 수량 시험 22개 통과. ORCA 브라우저에서 「집수」 검색 → 항목에 집수정이 있는 「배수」만·선택 이동, 전체보기 → 3개 복귀, 샘플 복구.
- [x] 엑셀데이타를 EST로 변환 7개 탭(원본 TfrmExcelToEst 0x109c3bc — spec_pc §1) — 노임·자재·견적·표준시장·단가산출·일위대가·내역서 탭마다 시트명·시작/끝행·필드별 열 글자(.xlsx·.xls), 노임 → 자재 → 견적 → 표준 → 단산 → 일위 → 내역 차례로 새 공사(현재 원가 서식, 자동계산 끔 — Excel 값 그대로). 셀 규칙(Trim·줄바꿈·앞 「.」·㎡/㎥·「*」→「×」·쉼표·노무비율 ×100·「B:D」 이어붙임), 노임 단가 → Cost1·TC, 견적·표준 → O 칸 함께, 빈 코드 N+10자리, 내역 36행 채움. 단가산출·일위대가 머리 줄 꼴로 호표 만들기(코드·TitleCode·Index·LineData 품명/규격/단위 표지, [합계] 줄 지움), 못 읽은 시트 안내. 하지 않음: 글자 굵기 판정·고정폭 머리말·단가산출 목록 시트형(굵기·폭 정보 없음).
- 검증완료: `test_est_excel_to_est.py`(없는 시트 안내, 노임 ㎡·TC, 견적 앞점·쉼표·O칸·N코드, 표준 노무비율 30, 단산 호표 2개·×, 일위 호표·[합계] 지움, 36행, 자동계산 끔) 통과. ORCA 브라우저에서 탭 7개·단가산출 열 6칸 표시(파일 고르기는 수동).
- [x] 전회기성 불러오기·조달수수료·원가에 내역 금액·원가 구성비(spec_pb1 1·9·10 · spec_pb2 8) — 전회기성: 완료 회차 아무것이나 골라 그 누계를 전회로(현재 회차 거부, `round.junFrom`). 조달수수료 입력(Ctrl+J): 현재 줄을 Code AJJ01·LinkIndex 57·GN %·자리방법 Index·자리수 Gangub·계산 비목 OCheck·저장 칸 SCheck 로(57 줄이면 수정, 허용 검사·원본 문구). 원가계산서에 내역 금액 넣기: 원본은 끌어 놓기 — 웹은 줄 고르기 + 합계/노무/재료/경비 고르기 → 원가 줄 Cost 고정(LinkIndex 1, 덮어쓰기 확인). 구성비 표시: 원가 요율 칸을 「비율」로 바꿔 기준 행(CalcBase 또는 도급액) 대비 대상 비목 Cost 를 0.01% 절사·최대 나머지로 100% 맞춤(계산 불변).
- 검증완료: 기성·바인딩 시험 14개 통과. ORCA 브라우저에서 구성비 20줄 합 100.00%·머리 「비율」, 내역 금액 넣기 노무비 384,804, Ctrl+J 조달수수료 AJJ01·57·0.54%·절하, 샘플 복구.
- [x] 단가 도구(spec_pb2) — 중복 항목 정리(자재단가: 품명·규격·단위 빈칸·| 무시 같으면 TC 가장 큰 행만, 같으면 뒤 행, 묻지 않음), 단가정의 엑셀변환(표준시장단가 설명문 있는 항목만 「코드 / 번호. 품명 (규격) [단위]」+설명 줄, .xlsx), 할증·낙찰(고른 2행 이상 또는 전 행 Ratio·NachalRatio, 원래 단가 OTC 보관, 100 이면 자리 해제)·복원(두 율 0), 바꾸기(Main Data 로 현재 표 하나만 갱신, 못 찾음 표시), SUM(현재 줄을 항목선택계산 62 줄로: Code 'A'+두 자리 색인·적용율·비목, 원본 거부 문구, 줄 자신 SKind = 묶음 최대+1). 엔진: 62 줄 Code 를 두 자리 색인 번호로 읽음(설치 main.estx 의 「A01」 꼴, 예전 글자 해석은 A0103 의 C 를 놓쳤음).
+3 -1
View File
@@ -19,6 +19,7 @@
<script defer src="native-quantity-list.js"></script>
<script defer src="native-price-tools.js"></script>
<script defer src="native-misc-tools.js"></script>
<script defer src="native-excel-to-est.js"></script>
<script defer src="native-actions-more.js"></script>
<script defer src="native-wonga.js"></script>
<script defer src="native-jungsang.js"></script>
@@ -164,7 +165,8 @@
><button data-action="jodal-fee">조달수수료 입력(Ctrl+J)</button
><button data-action="cost-to-wonga">원가계산서에 내역 금액 넣기</button
><button data-action="wonga-biul-mode">원가 구성비·요율 표시 전환</button
><button data-action="jun-load">전회기성 불러오기</button>
><button data-action="jun-load">전회기성 불러오기</button
><button data-action="excel-to-est">엑셀 데이타 =&gt; EST 데이타(7개 표)</button>
<hr />
<button data-action="find">찾기·다음찾기(Ctrl+F)</button>
<button data-action="multi-search">통합 검색(저장된 다른 작업)</button>
+1
View File
@@ -5,6 +5,7 @@ const EstActionsMore = (() => {
typeof EstQuantityList !== "undefined" && EstQuantityList,
typeof EstPriceTools !== "undefined" && EstPriceTools,
typeof EstMiscTools !== "undefined" && EstMiscTools,
typeof EstExcelToEst !== "undefined" && EstExcelToEst,
].filter(Boolean);
function handle(name, state, node) {
for (const module of modules()) if (module.handle(name, state, node)) return true;
+370
View File
@@ -0,0 +1,370 @@
// 원본 「엑셀데이타를 EST로 변환」(mnuExcelToEst → TfrmExcelToEst btnOkClick 0x109c3bc — spec_pc §1) 7개 탭:
// 노임 → 자재 → 견적 → 표준시장 → 단가산출 → 일위대가 → 내역서 차례로 한 Excel 파일의 시트들을 새 공사로 옮긴다(금액은 Excel 값 그대로,
// 다시 셈 끔). 셀 규칙: 문자 Trim·줄바꿈 제거·품명/규격 앞 「.」 제거·단위 M2→㎡ M3→㎥·산출근거 「*」→「×」, 수치는 쉼표 빼고,
// 노무비율 ≤ 1 이면 ×100, 열 「B:D」 는 이어 붙임. 단가산출·일위대가는 머리 줄 꼴(#.산근 · #| · 제n호표 · 산근 n 호표 : … (… 당) ·
// (산근 · No n 호표 · No.n)을 찾아 호표를 만든다(원본의 글자 굵기 판정은 웹 Excel 읽기가 굵기를 몰라 하지 않음).
const EstExcelToEst = (() => {
const TABS = [
["LaborcDat", "노임단가", ["Code", "Name", "Size", "Unite", "Cost1", "Bigo"]],
[
"StuffcDat",
"자재단가",
[
"Code",
"Name",
"Size",
"Unite",
"Cost1",
"Page1",
"Cost2",
"Page2",
"Cost3",
"Page3",
"Cost4",
"Page4",
"Cost5",
"Page5",
"Cost6",
"Page6",
"TC",
"Bigo",
],
],
[
"EstiMateDat",
"견적단가",
["Code", "Name", "Size", "GN", "Unite", "TC", "NC", "GC", "JC", "Bigo"],
],
[
"SilJukDat",
"표준시장단가",
["Code", "Name", "Size", "Unite", "TC", "NC", "GC", "JC", "NCRatio", "EXPLEAN", "Bigo"],
],
["CostMainDat", "단가산출", ["Code", "LineData", "TC", "NC", "GC", "JC"]],
[
"HeadMainDat",
"일위대가",
[
"Code",
"Name",
"Size",
"GN",
"Unite",
"TU",
"TC",
"NU",
"NC",
"GU",
"GC",
"JU",
"JC",
"Bigo",
],
],
[
"EstMainDat",
"내역서",
[
"Code",
"Pr",
"Name",
"Size",
"GN",
"Unite",
"TU",
"TC",
"NU",
"NC",
"GU",
"GC",
"JU",
"JC",
"Bigo",
],
],
];
const TEXT = new Set([
"Code",
"Pr",
"Name",
"Size",
"Unite",
"Bigo",
"LineData",
"EXPLEAN",
"Page1",
"Page2",
"Page3",
"Page4",
"Page5",
"Page6",
]);
const num = (v) => Number(v) || 0;
const letter = (n) => {
let s = "";
for (n += 1; n > 0; n = Math.floor((n - 1) / 26))
s = String.fromCharCode(65 + ((n - 1) % 26)) + s;
return s;
};
const col = (s) =>
[...String(s).toUpperCase()].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) - 1;
const unit = (s) =>
String(s)
.replace(/^[Mm]2$/, "㎡")
.replace(/^[Mm]3$/, "㎥");
function cell(table, r, spec) {
const [a, b] = String(spec).toUpperCase().split(":");
if (!b) return table[r]?.[col(a)];
let out = "";
for (let c = col(a); c <= col(b); c++) {
const v = table[r]?.[c];
if (v != null && String(v) !== "") out += String(v);
else if (out.length <= 1) out += " ";
}
return out;
}
function value(field, raw) {
if (TEXT.has(field)) {
let t = String(raw ?? "").replace(/\r?\n/g, "");
t = field === "LineData" ? t.replaceAll("*", "×") : t.trim();
if (field === "Unite") t = unit(t);
if (field === "Name" || field === "Size") t = t.replace(/^\.+/, "");
return t;
}
let v = typeof raw === "number" ? raw : Number(String(raw ?? "").replaceAll(",", "")) || 0;
if (field === "NCRatio" && v <= 1) v *= 100;
return v;
}
function readRows(table, tab, fields, columns) {
const rows = [];
for (let r = tab.start - 1; r <= tab.end - 1; r++) {
const row = { Index: 0 };
fields.forEach((f, i) => {
if (columns[i]) row[f] = value(f, cell(table, r, columns[i]));
});
rows.push(row);
}
return rows;
}
// 머리 줄 꼴 → { Name, Size, Unite, skipNext }
function costHead(text, next) {
const t = String(text ?? "").trim(),
split = (s) => {
const parts = s.trim().split(/\s+/);
return {
Name: parts[0] || "",
Size: parts.slice(1, -1).join(" "),
Unite: parts.length > 1 ? parts.at(-1) : "",
};
};
if (/^#\./.test(t) && t.includes("산근")) {
if (t.includes("|")) {
const [, n = "", s = "", u = ""] = t.split("|");
return { Name: n.trim(), Size: s.trim(), Unite: u.trim() };
}
return split(t.replace(/^.*?산근\s*\d*/, "").replace(/\//g, " "));
}
if (/^#/.test(t) && t.includes("|")) {
const [, n = "", s = "", u = ""] = t.split("|");
return { Name: n.trim(), Size: s.trim(), Unite: u.trim() };
}
if (/산근.*호표/.test(t) && t.includes("당")) {
const body = t.split(/[::]/).slice(1).join(":"),
u = /\(\s*[\d.]*\s*([^()]*?)\s*당\s*\)/.exec(body)?.[1] || "",
rest = body
.replace(/\([^()]*당\s*\)/, "")
.trim()
.split(/\s+/);
return { Name: rest[0] || "", Size: rest.slice(1).join(" "), Unite: u };
}
if (/제\s*\d+\s*호표/.test(t) && next != null)
return { ...split(String(next)), skipNext: true };
if (t.replace(/\s/g, "").includes("(산근")) return split(t.replace(/\(산근[^)]*\)/, ""));
return null;
}
function headHead(name) {
const t = String(name ?? "").trim();
if (/No/.test(t) && t.includes("호표"))
return { Name: t.split("호표").slice(1).join("호표").trim(), Size: "", Unite: "" };
if (/^No\.\s*[1-9]/.test(t))
return { Name: t.replace(/^No\.\s*\d+\s*/, ""), Size: "", Unite: "" };
if (/\(호표/.test(t)) {
const parts = t
.replace(/\(호표[^)]*\)/, "")
.trim()
.split(/\s+/);
return { Name: parts[0] || "", Size: parts.slice(1).join(" "), Unite: "" };
}
return null;
}
function groups(rows, titles, kind, headOf) {
let pk = 0;
const out = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (kind === 103 && String(row.Name ?? "").replace(/\s/g, "") === "[합계]") continue;
const h = headOf(row, rows[i + 1]);
if (!h) {
out.push(row);
continue;
}
const index = titles.length + 1,
code = `N${String(++pk).padStart(10, "0")}`;
titles.push({
Index: index,
Code: code,
Name: h.Name,
Size: h.Size,
Unite: unit(h.Unite),
TC: num(row.TC),
NC: num(row.NC),
GC: num(row.GC),
JC: num(row.JC),
});
Object.assign(row, { Code: code, TitleCode: code, Index: index });
if (kind === 104)
row.LineData = `\x0e${h.Name}\x0e${h.Size ? `\x13${h.Size}\x13` : ""}${h.Unite ? `\x15${unit(h.Unite)}\x15` : ""}`;
out.push(row);
if (h.skipNext) i++;
}
return out;
}
function convert(sheets, tabs, template) {
const project = EstNative.empty();
project.name = template.name;
project.options = { ...project.options, AutoCalc: false, SourceRefresh: false };
project.tables.Wonga = structuredClone(template.wonga || []);
const failed = [];
for (const [table, label, fields] of TABS) {
const tab = tabs[table];
if (!tab?.sheet || !(tab.start > 0) || !(tab.end > 0)) continue;
const data = sheets[tab.sheet];
if (!data) {
failed.push(`${tab.sheet || label} 시트가 없습니다.`);
continue;
}
let rows = readRows(data, tab, fields, tab.columns);
if (table === "LaborcDat") rows.forEach((r) => (r.TC = r.Cost1));
if (table === "SilJukDat" || table === "EstiMateDat")
rows.forEach((r) =>
Object.assign(r, {
OTC: r.TC,
ONC: r.NC,
OGC: r.GC,
OJC: r.JC,
...(table === "SilJukDat" && { Cost1: r.TC }),
}),
);
if (table === "CostMainDat") {
project.tables.CostTitleDat = [];
rows = groups(rows, project.tables.CostTitleDat, 104, (r, n) =>
costHead(r.LineData, n?.LineData),
);
} else if (table === "HeadMainDat") {
project.tables.HeadTitleDat = [];
rows = groups(rows, project.tables.HeadTitleDat, 103, (r) => headHead(r.Name));
}
project.tables[table] = rows;
}
while (project.tables.EstMainDat.length < 36) project.tables.EstMainDat.push({ Index: 0 });
let pk = 0;
for (const t of Object.keys(project.tables))
for (const r of project.tables[t])
if (r && typeof r === "object" && r.PkID == null) r.PkID = ++pk;
for (const t of ["LaborcDat", "StuffcDat", "SilJukDat", "EstiMateDat"])
for (const r of project.tables[t] || [])
if (!String(r.Code ?? "").trim()) r.Code = `N${String(r.PkID).padStart(10, "0")}`;
return { project, failed };
}
// 화면
function form(state) {
const x = (state.e2e ||= {
tab: "LaborcDat",
tabs: Object.fromEntries(
TABS.map(([t, label, fields]) => [
t,
{ sheet: label, start: 0, end: 0, columns: fields.map((_, i) => letter(i)) },
]),
),
});
const { esc } = EstApp,
[, , fields] = TABS.find(([t]) => t === x.tab),
tab = x.tabs[x.tab];
EstApp.showDialog(
"엑셀데이타를 EST로 변환",
`<div class="business-tools">${TABS.map(([t, l]) => `<button data-action="e2e-tab" data-tab="${t}"${t === x.tab ? ' style="font-weight:bold"' : ""}>${l}</button>`).join("")}</div><p><button data-action="e2e-open">엑셀파일열기</button> ${esc(x.file || "")} ${x.sheets ? `(시트 ${Object.keys(x.sheets).length}개)` : ""}</p><div class="form-fields"><label>시트명<input name="e2e-sheet" value="${esc(tab.sheet)}"></label><label>시작행<input name="e2e-start" type="number" value="${tab.start}"></label><label>끝행<input name="e2e-end" type="number" value="${tab.end}"></label></div><p>해당 칼럼명칭 입력(엑셀 열 글자, 비우면 건너뜀, 「B:D」 는 이어 붙임)</p><div class="form-fields">${fields.map((f, i) => `<label>${f}<input name="e2e-col" data-i="${i}" value="${esc(tab.columns[i] || "")}" size="4"></label>`).join("")}</div><button data-action="e2e-run"${x.sheets ? "" : " disabled"}>변 환</button>`,
);
}
function read(state) {
const x = state.e2e,
tab = x.tabs[x.tab],
q = (n) => document.querySelector(`#dialog-content [name="${n}"]`);
if (!q("e2e-sheet")) return;
tab.sheet = q("e2e-sheet").value.trim();
tab.start = Number(q("e2e-start").value) || 0;
tab.end = Number(q("e2e-end").value) || 0;
document
.querySelectorAll('#dialog-content [name="e2e-col"]')
.forEach((n) => (tab.columns[Number(n.dataset.i)] = n.value.trim().toUpperCase()));
}
async function run(name, state, node) {
if (name === "excel-to-est") return form(state);
read(state);
const x = state.e2e;
if (name === "e2e-tab") x.tab = node.dataset.tab;
else if (name === "e2e-open") {
const file = await new Promise((resolve) => {
const picker = document.createElement("input");
picker.type = "file";
picker.accept = ".xlsx,.xls";
picker.addEventListener("change", () => resolve(picker.files[0]));
picker.click();
});
if (!file) return;
const body = await file.arrayBuffer();
x.sheets = /\.xls$/i.test(file.name)
? EstJdlCompare.xlsTables(await EstApp.api("/api/read-xls", { method: "POST", body }))
: EstExcelMap.sheetTables(await EstApp.api("/api/import-xlsx", { method: "POST", body }));
x.file = file.name;
} else if (name === "e2e-run") {
const tabs = Object.values(x.tabs);
if (
tabs.every((t) => !t.start) ||
tabs.every((t) => !t.end) ||
tabs.some((t) => t.end < t.start)
)
throw Error("변환할 엑셀자료의 범위설정이 잘못되었습니다.");
if (!confirm("현재 작업을 엑셀 자료로 만든 새 공사로 바꾸시겠습니까?")) return;
const out = convert(x.sheets, x.tabs, {
name: x.file.replace(/\.xlsx?$/i, ""),
wonga: (state.project.tables.Wonga || []).map(({ Cost, ...rest }) => rest),
});
if (
out.failed.length &&
!confirm(`${out.failed.join("\n")}\n변환을 못한 시트가 있습니다.\n\n종료하시겠습니까?`)
)
return;
EstApp.byId("dialog").close();
EstApp.setProject(out.project);
state.notice =
"엑셀 역변환 작업이 완료되었습니다. 금액은 엑셀 값 그대로이며 자동계산은 꺼 두었습니다.";
EstApp.render();
return;
}
form(state);
}
const names = new Set(["excel-to-est", "e2e-tab", "e2e-open", "e2e-run"]);
function handle(name, state, node) {
if (!names.has(name)) return false;
run(name, state, node).catch((error) => {
state.notice = error.message;
EstApp.render();
});
return true;
}
return { convert, costHead, headHead, handle };
})();
+1
View File
@@ -99,6 +99,7 @@ const allowed = new Set([
"native-quantity-list.js",
"native-price-tools.js",
"native-misc-tools.js",
"native-excel-to-est.js",
"native-actions-more.js",
"native-wonga-tools.js",
"native-estimate-print.js",