diff --git a/resources/tester/helper_est_native.cjs b/resources/tester/helper_est_native.cjs
index 4113dbad9..5297e0b8a 100644
--- a/resources/tester/helper_est_native.cjs
+++ b/resources/tester/helper_est_native.cjs
@@ -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 });
}
diff --git a/resources/tester/test_est_excel_to_est.py b/resources/tester/test_est_excel_to_est.py
new file mode 100644
index 000000000..bf4874a95
--- /dev/null
+++ b/resources/tester/test_est_excel_to_est.py
@@ -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
diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md
index 3355420c9..3520cc57c 100644
--- a/tools/est-test/PLAN.md
+++ b/tools/est-test/PLAN.md
@@ -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 를 놓쳤음).
diff --git a/tools/est-test/index.html b/tools/est-test/index.html
index 91b146e97..e03a04309 100644
--- a/tools/est-test/index.html
+++ b/tools/est-test/index.html
@@ -19,6 +19,7 @@
+
@@ -164,7 +165,8 @@
>
+ >
diff --git a/tools/est-test/native-actions-more.js b/tools/est-test/native-actions-more.js
index 2c1a4608d..1f2c9e305 100644
--- a/tools/est-test/native-actions-more.js
+++ b/tools/est-test/native-actions-more.js
@@ -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;
diff --git a/tools/est-test/native-excel-to-est.js b/tools/est-test/native-excel-to-est.js
new file mode 100644
index 000000000..17cecb3af
--- /dev/null
+++ b/tools/est-test/native-excel-to-est.js
@@ -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로 변환",
+ `${TABS.map(([t, l]) => ``).join("")}
${esc(x.file || "")} ${x.sheets ? `(시트 ${Object.keys(x.sheets).length}개)` : ""}
해당 칼럼명칭 입력(엑셀 열 글자, 비우면 건너뜀, 「B:D」 는 이어 붙임)
${fields.map((f, i) => ``).join("")}
`,
+ );
+ }
+ 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 };
+})();
diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs
index 5573ec358..e4620e34f 100644
--- a/tools/est-test/server.cjs
+++ b/tools/est-test/server.cjs
@@ -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",