diff --git a/resources/tester/test_est_price_tools.py b/resources/tester/test_est_price_tools.py
new file mode 100644
index 000000000..45cfbca5b
--- /dev/null
+++ b/resources/tester/test_est_price_tools.py
@@ -0,0 +1,61 @@
+"""EST 테스트 앱 단가 도구(원본 중복 항목 정리 · 단가정의 엑셀 · 할증/낙찰/복원 · 항목선택계산 SUM 줄)."""
+
+import json
+import subprocess
+from pathlib import Path
+
+from test_est_rea_parity import run
+
+ROOT = Path(__file__).resolve().parents[2]
+
+
+def run_js(code: str):
+ runner = f"""
+ const fs = require('fs'), vm = require('vm');
+ const ctx = vm.createContext({{ structuredClone }});
+ vm.runInContext(fs.readFileSync('tools/est-test/native-price-tools.js', 'utf8'), ctx);
+ process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx)));
+ """
+ return json.loads(subprocess.check_output(["node", "-e", runner], cwd=ROOT).decode("utf-8"))
+
+
+def test_price_tools_rules():
+ out = run_js(
+ r"""(() => {
+ const P = EstPriceTools;
+ const same = P.samecheck([{ Name: '철 근', Size: 'D10', Unite: 'ton', TC: 5 }, { Name: '철근', Size: 'D10', Unite: 'ton', TC: 9 },
+ { Name: '철근|', Size: 'D10', Unite: 'ton', TC: 9 }, { Name: '모래', TC: 1 }]);
+ const g = P.gungu([{ Code: 'C1', Index: 3, Name: '포장', Size: 't=5', Unite: 'm2', EXPLEAN: '포함: 재료\n제외: 운반' }, { Code: 'C2', Name: '빈' }]);
+ const rows = [{ Name: 'a', TC: 100 }, { Name: 'b', TC: 200, OTC: 210 }, { Name: '', TC: 0 }];
+ const n = P.setRate(rows, [], 'NachalRatio', 88);
+ const one = [{ Name: 'x', TC: 1 }, { Name: 'y', TC: 2 }]; P.setRate(one, [1], 'Ratio', 5);
+ const r = { Name: '', Size: '', LinkIndex: 0 };
+ P.sumRow('HeadMainDat', r, { name: '기구손료', size: '', rate: 2, base: 2, target: 3, picked: [1, 3] });
+ const errs = [];
+ for (const [row, o] of [[{ Index: 1 }, {}], [{}, { base: 0, target: 2, picked: [1] }], [{}, { base: 1, target: 1, picked: [] }],
+ [{}, { base: 1, target: 1, picked: [1, 2, 3, 4, 5, 6] }]])
+ try { P.sumRow('HeadMainDat', row, { name: '', size: '', rate: 1, ...o }); } catch (e) { errs.push(e.message.slice(0, 8)); }
+ return { same: [same.removed, same.rows.map((x) => [x.Name, x.TC])], g, n, rows: rows.map((x) => [x.NachalRatio ?? null, x.OTC ?? null]),
+ one: one.map((x) => x.Ratio ?? null), r: [r.Code, r.LinkIndex, r.GN, r.Biul, r.Unite], errs };
+ })()"""
+ )
+ assert out["same"] == [2, [["철근|", 9], ["모래", 1]]]
+ assert out["g"] == [["C1", "3. 포장 (t=5) [m2]"], ["", "포함: 재료"], ["", "제외: 운반"], ["", ""]]
+ assert out["n"] == 3
+ assert out["rows"] == [[88, 100], [88, 210], [None, None]]
+ assert out["one"] == [5, 5]
+ assert out["r"] == ["A0103", 62, 2, 0.02, "%"]
+ assert out["errs"] == ["현재 라인에는 ", "비목별항목은 비", "더할항목의 색인", "더할항목의 색인"]
+
+
+def test_sum_row_code_numbers_select_groups():
+ result = run(
+ "math",
+ """(()=>{
+ const rows=[{Index:1,Code:'H'},{Code:'a',GN:1,NC:100,NU:100,SKind:1},{Code:'b',GN:1,NC:50,NU:50,SKind:3},
+ {Code:'c',GN:1,NC:7,NU:7,SKind:2},{Code:'A0103',LinkIndex:62,GN:10,OCheck:2,SCheck:2}];
+ const out=EstRows.head(rows,{HeadDigits:0});
+ return out.rows?out.rows[4].NC:out[4].NC;
+ })()""",
+ )
+ assert result == 15
diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md
index 673b9f8c7..7dd2cba17 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] 단가 도구(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 를 놓쳤음).
+ - 검증완료: `test_est_price_tools.py` 2개(중복 2개 지움·단가정의 줄·율 쓰기 범위·SUM Code A0103·거부 4종, 엔진 A0103 → 색인 1·3 합 150×10% = 15) 및 REA 대조 39개 통과. ORCA 브라우저에서 보통인부 낙찰 90% 172,068→154,861, 복원 →172,068, 바꾸기(Main) → 2026 하반기 값, 샘플 복구.
- [x] 워드형 수량산출서 표와 편집 도구(원본 TFRMMULWORD·TFRMMULLANG — spec_word · spec_pa) — 가져오기가 WPT
을 통째로 버리던 결함 고침: 표를 한 줄 { table: [[칸]] }(글·수량 표지 r/L/G·품명/규격 표지·수식·세로병합)로 보존(설치 main.estx 표 5개 9×13~5×5). 문서 읽기는 `native-quantity-wpt.cjs` 로 분리. 표 화면: 칸 고르기(Shift 범위)·글/수식 고치기·방향 셀 합(우·좌·위·아래, 원본 범위 규칙·1,234.5 꼴)·수량으로 지정/해제·셀수직병합/분리·표 모양(테두리)·아래 표 만들기. 줄 도구: 문단(정렬·들여쓰기)·그림 넣기·한페이지 삽입/삭제(웹 한 쪽 45줄)·품명지정/규격지정(한 줄 하나·40자·제목 위 금지·원본 문구)·테두리선·산출근거 양식(화면 표시)·공종명칭 입력(머리 제목 줄 굵게 큰 글자). 수량 불러오기(Main Data 산출서 75개에서 새 산출서로), 수량목록보기(모든 산출서·수량 항목·원문 미리보기·검색·선택수량복사), 내역서 수량붙여넣기(연결)(고른 줄부터 아래로 수량·연결, 품명,규격입력 체크 시 이름도). 표·서식을 고친 산출서는 ESTX 저장 거부(JSON), 고치지 않은 표 산출서는 ESTX 저장이 표 줄을 건너 문단만 맞춤.
- 검증완료: `test_est_quantity_table.py` 2개(표 읽기·문단 G 표지 무시·아래 합 SUM(B2:B3)=1,002.5·수량 값·병합/분리·설치 표 5개) 및 수량·ESTX 내보내기 회귀 49개 통과. ORCA 브라우저에서 보차도경계석 보수 표 35칸, 수량 1.0→2.5 항목 수량 반영, 아래 합 SUM(D2:D6)=3.923, 수량 불러오기 75개·문단 가운데·산출근거 양식·쪽 삽입 45줄·삭제, 공종명칭 「보차도경계석 보수(L=1m)」, 품명지정 ⟦품명⟧, 수량목록 복사 → 내역 붙여넣기 1·0.19·연결 id:36, 샘플 복구.
- 남음: 글자모양(글꼴·색), 수량산출서 원본 모양 인쇄(쪽·간지), 엑셀형 시트 수량붙여넣기(연결).
diff --git a/tools/est-test/index.html b/tools/est-test/index.html
index 0d0212ecb..f7a997cad 100644
--- a/tools/est-test/index.html
+++ b/tools/est-test/index.html
@@ -17,6 +17,7 @@
+
@@ -309,6 +310,13 @@
> {
- const modules = () => [typeof EstQuantityList !== "undefined" && EstQuantityList].filter(Boolean);
+ const modules = () =>
+ [
+ typeof EstQuantityList !== "undefined" && EstQuantityList,
+ typeof EstPriceTools !== "undefined" && EstPriceTools,
+ ].filter(Boolean);
function handle(name, state, node) {
for (const module of modules()) if (module.handle(name, state, node)) return true;
return false;
diff --git a/tools/est-test/native-data-update.js b/tools/est-test/native-data-update.js
index 569eefa32..736d047e9 100644
--- a/tools/est-test/native-data-update.js
+++ b/tools/est-test/native-data-update.js
@@ -247,5 +247,5 @@ const EstDataUpdate = (() => {
else return false;
return true;
}
- return { apply, isFuel, handle };
+ return { apply, isFuel, table, handle };
})();
diff --git a/tools/est-test/native-price-tools.js b/tools/est-test/native-price-tools.js
new file mode 100644
index 000000000..c045102c5
--- /dev/null
+++ b/tools/est-test/native-price-tools.js
@@ -0,0 +1,211 @@
+// 단가 목록 도구(spec_pb2): 중복 항목 정리(TfrmStuffc popupSamecheck 0xf61d94), 단가정의 엑셀변환(TfrmSiljuk popupGunguToExcel),
+// 할증·낙찰·복원(TfrmSetDiget 모드 51·52, btnRestor 0x110919c), 바꾸기(barbtnChange → TfrmChangeValue: Main Data 로 이 표만 갱신),
+// SUM(barbtnSum → 항목선택계산 LinkIndex 62 줄, TfrmSetGabBiIndex SUM 모드).
+const EstPriceTools = (() => {
+ const num = (v) => Number(v) || 0;
+ const key = (s) => String(s ?? "").replace(/[ |]/g, "");
+ const TABLE_KIND = { StuffcDat: 5, LaborcDat: 6, SilJukDat: 21, EstiMateDat: 101 };
+
+ // 같은 품명·규격·단위(빈칸·| 무시) 묶음에서 TC 가장 큰 행 하나만(같으면 뒤 행) 남김. 묻지 않음, 다시 셈 없음.
+ function samecheck(rows) {
+ const keep = new Map();
+ rows.forEach((row, i) => {
+ const k = `${key(row.Name)}\u0000${key(row.Size)}\u0000${key(row.Unite)}`,
+ prev = keep.get(k);
+ if (prev == null || !(num(rows[prev].TC) > num(row.TC))) keep.set(k, i);
+ });
+ const kept = new Set(keep.values());
+ const removed = rows.length - kept.size;
+ return { rows: rows.filter((_, i) => kept.has(i)), removed };
+ }
+
+ // 표준시장단가 EXPLEAN 이 있는 행만 「코드 / 번호. 품명 (규격) [단위]」 + 설명 줄
+ function gungu(rows) {
+ const out = [];
+ for (const r of rows) {
+ const text = String(r.EXPLEAN ?? "").trim();
+ if (!text) continue;
+ out.push([
+ String(r.Code ?? ""),
+ `${r.Index ?? ""}. ${r.Name ?? ""} (${r.Size ?? ""}) [${r.Unite ?? ""}]`,
+ ]);
+ for (const line of text.split(/\r?\n/)) out.push(["", line]);
+ out.push(["", ""]);
+ }
+ return out;
+ }
+
+ // 할증(Ratio)·낙찰(NachalRatio) 율 쓰기. v = 100 이면 원래값(그 율 칸 100, 자리 해제), 복원 = 0.
+ function setRate(rows, indices, field, v) {
+ const list = indices.length >= 2 ? indices : rows.map((_, i) => i);
+ for (const i of list) {
+ const row = rows[i];
+ if (!num(row.TC) && !String(row.Name ?? "").trim()) continue;
+ if (!num(row.OTC)) row.OTC = row.TC;
+ for (const f of [field].flat()) row[f] = v;
+ if (v === 100) Object.assign(row, { Diget: 0, DigetIndex: 0 });
+ }
+ return list.length;
+ }
+
+ // 항목선택계산(62) 줄
+ function sumRow(view, row, o) {
+ const banned =
+ view === "EstMainDat"
+ ? [
+ 5, 6, 21, 51, 52, 53, 54, 56, 57, 58, 59, 61, 101, 103, 104, 105, 211, 212, 213, 214,
+ 215, 217, 218,
+ ]
+ : [5, 6, 21, 51, 52, 53, 61, 101, 103, 104, 105, 211, 212, 213, 214, 215, 217, 218];
+ if (
+ (view === "EstMainDat" && num(row.Level) > 0) ||
+ (view === "HeadMainDat" && num(row.Index) > 0) ||
+ (banned.includes(num(row.LinkIndex)) &&
+ (String(row.Name ?? "").trim() || String(row.Size ?? "").trim()))
+ )
+ throw Error("현재 라인에는 합계(SUM)를 할수가 없습니다.");
+ if ((o.base === 0) !== (o.target === 0)) throw Error("비목별항목은 비목별적용만 가능합니다.");
+ if (!o.picked.length) throw Error("더할항목의 색인들을 선택하세요");
+ if (o.picked.length > 5) throw Error("더할항목의 색인들은 6개이상 선택할수없습니다");
+ Object.assign(row, {
+ Name: o.name,
+ Size: o.size,
+ LinkIndex: 62,
+ Code: `A${o.picked.map((n) => String(n).padStart(2, "0")).join("")}`,
+ GN: o.rate,
+ Biul: o.rate / 100,
+ Unite: "%",
+ OCheck: o.base,
+ SCheck: o.target,
+ });
+ }
+ function nextSKind(rows, at, view) {
+ let start = at,
+ end = at;
+ const head = (r) => (view === "EstMainDat" ? num(r.Level) > 0 : num(r.Index) > 0);
+ while (start > 0 && !head(rows[start - 1])) start--;
+ while (end + 1 < rows.length && !head(rows[end + 1])) end++;
+ return Math.max(0, ...rows.slice(start, end + 1).map((r) => num(r.SKind))) + 1;
+ }
+
+ async function run(name, state) {
+ const view = state.view,
+ rows = state.project.tables[view],
+ picked = [...state.selection].filter((i) => rows?.[i]).sort((a, b) => a - b);
+ if (name === "price-samecheck") {
+ if (view !== "StuffcDat") throw Error("자재단가 화면에서 쓰세요.");
+ let removed = 0;
+ EstApp.change((project) => {
+ const out = samecheck(project.tables.StuffcDat);
+ project.tables.StuffcDat = out.rows;
+ removed = out.removed;
+ });
+ state.notice = `중복 항목 ${removed}개를 지웠습니다.`;
+ } else if (name === "price-gungu-excel") {
+ const list = gungu(state.project.tables.SilJukDat || []);
+ if (!list.length) throw Error("단가정의(설명)가 있는 표준시장단가가 없습니다.");
+ const response = await fetch("/api/export-report-xlsx", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ name: "단가정의",
+ columns: ["코드", "단가정의"],
+ rows: list,
+ errors: [],
+ }),
+ });
+ if (!response.ok) throw Error((await response.json()).error);
+ EstApp.download(
+ `${state.project.name || "공사"}_단가정의.xlsx`,
+ await response.blob(),
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ );
+ } else if (name === "price-ratio" || name === "price-nachal" || name === "price-restore") {
+ if (!TABLE_KIND[view]) throw Error("자재·노임·표준시장·견적 단가 화면에서 쓰세요.");
+ // 복원(원본은 할증·낙찰 창 안 단추): 두 율 모두 0
+ const field =
+ name === "price-nachal"
+ ? "NachalRatio"
+ : name === "price-ratio"
+ ? "Ratio"
+ : ["Ratio", "NachalRatio"];
+ let v = 0;
+ if (name !== "price-restore") {
+ const input = prompt(
+ name === "price-nachal" ? "낙찰율을 입력하세요(%)" : "할증율을 입력하세요(%)",
+ "",
+ );
+ if (input == null) return;
+ v = Number(input);
+ if (!Number.isFinite(v)) throw Error("율은 수치로 입력하세요.");
+ }
+ if (
+ !confirm(
+ v === 100 || v === 0 ? "원래값으로 복원하시겠습니까?" : "설정값을 적용하시겠습니까?",
+ )
+ )
+ return;
+ let n = 0;
+ EstApp.change((project) => (n = setRate(project.tables[view], picked, field, v)));
+ state.notice = `${n}개 행에 적용했습니다.`;
+ } else if (name === "price-change") {
+ const kind = TABLE_KIND[view];
+ if (!kind) throw Error("자재·노임·표준시장·견적 단가 화면에서 쓰세요.");
+ if (
+ !confirm(
+ "선택한 Main데이타의 단가로 바꾸시겠습니까?\n바꾸기후 전체재계산을 해야 금액이 바뀝니다.",
+ )
+ )
+ return;
+ const source = await EstApp.api("/api/main-data");
+ let missing = 0;
+ EstApp.change((project) => {
+ missing = EstDataUpdate.table(project.tables[view], source.tables[view] || [], kind);
+ });
+ state.notice = missing
+ ? `${missing} 개의 찾지못한 데이타가 있습니다. 확인란에 체크된 데이타를 검토하세요.`
+ : "Main 데이타의 단가로 바꿨습니다.";
+ } else if (name === "price-sum") {
+ if (!["EstMainDat", "HeadMainDat"].includes(view) || !picked.length)
+ throw Error("내역서·일위대가에서 SUM 줄로 바꿀 행을 고르세요.");
+ const row = rows[picked[0]];
+ const letters = prompt("더할 색인(A~Z, 최대 5개, 예 AC)", "")?.toUpperCase();
+ if (letters == null) return;
+ const rate = Number(prompt("적용율(%)", "100"));
+ const o = {
+ name: prompt("명칭", row.Name || "잡재료비") ?? "",
+ size: row.Size ?? "",
+ rate: Number.isFinite(rate) ? rate : 0,
+ base: num(row.OCheck),
+ target: num(row.SCheck),
+ picked: [...new Set(letters.replace(/[^A-Z]/g, ""))].map((c) => c.charCodeAt(0) - 64),
+ };
+ sumRow(view, structuredClone(row), o);
+ EstApp.change((project) => {
+ const list = project.tables[view];
+ sumRow(view, list[picked[0]], o);
+ list[picked[0]].SKind = nextSKind(list, picked[0], view);
+ });
+ } else return false;
+ EstApp.render();
+ return true;
+ }
+ const names = new Set([
+ "price-samecheck",
+ "price-gungu-excel",
+ "price-ratio",
+ "price-nachal",
+ "price-restore",
+ "price-change",
+ "price-sum",
+ ]);
+ function handle(name, state) {
+ if (!names.has(name)) return false;
+ run(name, state).catch((error) => {
+ state.notice = error.message;
+ EstApp.render();
+ });
+ return true;
+ }
+ return { samecheck, gungu, setRate, sumRow, handle };
+})();
diff --git a/tools/est-test/native-rows.js b/tools/est-test/native-rows.js
index 7a4d9f387..8520c15f3 100644
--- a/tools/est-test/native-rows.js
+++ b/tools/est-test/native-rows.js
@@ -13,13 +13,18 @@ const EstRows = (() => {
}
function percent(row, preceding, digits = 0) {
if ([1, 3].includes(num(row.CalcIndex))) return row;
- // f1a8c4 matches Chr(64 + source.SKind) against the percentage row's Code.
- const selectedGroups = String(row.Code || "").toUpperCase();
+ // 항목선택계산(62) Code = 'A' + 두 자리 색인 번호들(원본 SUM effc54, 예 A0103 = A·C); 그 밖 꼴은 글자 Chr(64+SKind) 로.
+ const selectedGroups = String(row.Code || "").toUpperCase(),
+ numbered = /^A((?:\d\d)+)$/.exec(selectedGroups),
+ groups = numbered ? new Set(numbered[1].match(/\d\d/g).map(Number)) : null;
const eligible =
num(row.LinkIndex) === 62
? preceding.filter(
(r) =>
- num(r.SKind) > 0 && selectedGroups.includes(String.fromCharCode(64 + num(r.SKind))),
+ num(r.SKind) > 0 &&
+ (groups
+ ? groups.has(num(r.SKind))
+ : selectedGroups.includes(String.fromCharCode(64 + num(r.SKind)))),
)
: preceding;
const base = baseKind(row),
diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs
index e5be16fe4..6a53aa440 100644
--- a/tools/est-test/server.cjs
+++ b/tools/est-test/server.cjs
@@ -97,6 +97,7 @@ const allowed = new Set([
"native-data-update.js",
"native-quantity-table.js",
"native-quantity-list.js",
+ "native-price-tools.js",
"native-actions-more.js",
"native-wonga-tools.js",
"native-estimate-print.js",