diff --git a/resources/tester/test_est_bid.py b/resources/tester/test_est_bid.py
new file mode 100644
index 000000000..2893b3ce2
--- /dev/null
+++ b/resources/tester/test_est_bid.py
@@ -0,0 +1,88 @@
+"""EST 테스트 앱 낙찰률 적용 범위(원본 TFRMBIMOGBIUL 전체·선택·공종별·적용된 줄 제외)."""
+
+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-bid.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"))
+
+
+SETUP = """
+ const p = EstNative.calculate(EstModel.validate(structuredClone(source)));
+ const rows = p.tables.EstMainDat;
+ const heading = rows.findIndex((r) => Number(r.Level) > 0 && EstBid.subtree(rows, rows.indexOf(r)).length >= 2);
+ const kids = EstBid.subtree(rows, heading);
+ const outside = rows.findIndex((r, i) => i > kids.at(-1) && !Number(r.Level) && Number(r.NU) > 0 && Number(r.LinkIndex) !== 54);
+ const unit = (q, i) => Number(q.tables.EstMainDat[i].NU);
+"""
+
+
+def test_group_scope_changes_only_rows_under_selected_heading():
+ result = run_js(
+ "(() => {"
+ + SETUP
+ + """
+ const only = EstBid.scope(p, 'EstMainDat', new Set([heading]), 'groups', false);
+ const q = EstBusiness.bid(p, EstBid.rates({method:'total', T:80}), only);
+ const inside = kids.find((i) => unit(p, i) > 0);
+ const base = Number(p.tables.EstMainDat[inside].ONU ?? unit(p, inside));
+ return { inside: Math.abs(unit(q, inside) - base * 0.8) < 1 && q.tables.EstMainDat[inside].ApplyNacChal === 1,
+ outside: unit(q, outside) === unit(p, outside), size: only.EstMainDat.size === kids.length };
+ })()"""
+ )
+ assert result == {"inside": True, "outside": True, "size": True}
+
+
+def test_selected_rows_skip_applied_and_errors():
+ result = run_js(
+ "(() => {"
+ + SETUP
+ + """
+ const first = kids.find((i) => unit(p, i) > 0);
+ const once = EstBusiness.bid(p, [90, 90, 90, 90], { EstMainDat: new Set([first]) });
+ const errors = [];
+ const t = (fn) => { try { fn(); errors.push('ok'); } catch (e) { errors.push(e.message.slice(0, 6)); } };
+ t(() => EstBid.scope(once, 'EstMainDat', new Set([first]), 'rows', true));
+ t(() => EstBid.scope(once, 'EstMainDat', new Set(), 'rows', false));
+ t(() => EstBid.scope(once, 'EstMainDat', new Set([first]), 'groups', false));
+ t(() => EstBid.rates({method:'bimog', N:80, G:-1, J:1, S:1}));
+ const kept = EstBid.scope(once, 'EstMainDat', new Set([first, outside]), 'rows', true);
+ return { errors, kept: [...kept.EstMainDat], all: EstBid.scope(once, 'EstMainDat', new Set(), 'all', false) };
+ })()"""
+ )
+ assert [e[:4] for e in result["errors"]] == ["적용할 ", "낙찰률을", "낙찰률을", "낙찰률은"]
+ assert len(result["kept"]) == 1
+ assert result["all"] is None
diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md
index 4dc94f846..51e613391 100644
--- a/tools/est-test/PLAN.md
+++ b/tools/est-test/PLAN.md
@@ -449,11 +449,13 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
- 남음: 두 공사 나란히 보기.
- [x] 프로젝트 추가(원본 mnuFileLoadProjectAs) — 다른 공사 파일(ESTX·JSON)의 순공사비 위 내역 전체를 하위 자료와 함께 이 공사 순공사비 위에 합침. 같은 코드 자료 유지.
- 검증완료: `test_est_transfer.py` 3개 통과(합친 뒤 순공사비 2배·계산 오류 0·빈 공사 거부). ORCA 브라우저에서 메뉴 확인, 샘플에 샘플 합치기 → 35행·같은 코드 46건 유지·순공사비 45,293,066 → 90,586,132, 샘플 복구. 파일 고르기 창 자체는 자동 조작 불가로 기존 가져오기 경로(`/api/import`)를 그대로 씀.
+ - [x] 낙찰률 적용 범위(원본 TFRMBIMOGBIUL 내역서낙찰·공종별) — 전체낙찰율/비목별, 전체·선택한 줄·선택한 공종 하위, 낙찰 적용된 줄 제외. 기존 계산(`EstBusiness.bid`)에 행 묶음 지정만 더함. 편집 메뉴에 낙찰률 적용·당초단가 복원.
+ - 검증완료: `test_est_bid.py` 2개(공종 하위만 변경·밖 불변, 적용된 줄 제외·빈 선택·제목 없음·음수 거부), 낙찰 관련 업무·설계변경 시험 포함 41개 통과. ORCA 브라우저에서 「구조물공」 선택 → 전체 80% 공종별 → 하위 4줄만 변경·순공사비 45,293,066 → 42,598,790, 되돌리기 원상.
+ - 남음: 근거자료낙찰 탭(공종별 낙찰율 목록), 「연금·건강보험료·안전관리비 고정」.
- 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133.
- [ ] 남은 핵심(실무 빈도순):
1. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기.
3. 수량산출서 — 누계·4방향 합계, 제출용 전체 인쇄.
- 6. 낙찰률 전체·범위·공종별 적용.
7. 무관자료 삭제, 데이타 정리(정렬·번호), 품명정리.
8. 원가계산서 — 정산원가계산서, 기타경비·요율 대화, 서식 저장·불러오기, 기성 원가 편집.
9. 묶음 인쇄(설계서 한 벌).
diff --git a/tools/est-test/README.md b/tools/est-test/README.md
index 1cccddaf5..6bc535cfc 100644
--- a/tools/est-test/README.md
+++ b/tools/est-test/README.md
@@ -22,6 +22,8 @@
내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다.
+`편집 → 낙찰률 적용`은 원본 낙찰율 창처럼 전체낙찰율(한 비율) 또는 비목별낙찰율을 고르고, 전체 줄·선택한 줄·선택한 공종(하위 줄 모두)에 적용합니다. 이미 낙찰이 적용된 줄은 제외할 수 있고, `당초단가(100%) 복원`으로 되돌립니다.
+
`파일 → 프로젝트 추가`는 다른 공사 파일(ESTX·웹 JSON)의 순공사비 위 내역 행 전체를 하위 자료와 함께 이 공사의 순공사비 위에 합칩니다. 같은 코드 자료는 이 공사의 것을 그대로 씁니다.
`편집 → 내역 행 연결 자료와 함께 복사`는 고른 내역 행이 쓰는 자재·노임·실적·견적 자료와 일위대가·단가산출·기계경비 호표를 하위까지 모아 복사합니다. 다른 공사를 연 뒤 `연결 자료와 함께 붙여넣기`를 누르면 없는 자료만 추가하고, 같은 코드가 이미 있으면 그 공사의 자료를 그대로 씁니다. 붙여넣을 공사에 기계경비 적용기준이 비어 있을 때만 적용기준도 함께 옮깁니다.
diff --git a/tools/est-test/index.html b/tools/est-test/index.html
index 99d20fea9..8d914406d 100644
--- a/tools/est-test/index.html
+++ b/tools/est-test/index.html
@@ -43,6 +43,7 @@
+
@@ -111,6 +112,8 @@
+
diff --git a/tools/est-test/native-actions.js b/tools/est-test/native-actions.js
index fa70740ff..5bc51a560 100644
--- a/tools/est-test/native-actions.js
+++ b/tools/est-test/native-actions.js
@@ -403,24 +403,6 @@ const EstActions = (() => {
project.options.OldRecalcMethod = Number(values.method);
}),
);
- else if (name === "bid-rates")
- form(
- "비목별 낙찰률",
- [
- { name: "N", label: "노무 %", type: "number", value: 100 },
- { name: "G", label: "재료 %", type: "number", value: 100 },
- { name: "J", label: "경비 %", type: "number", value: 100 },
- { name: "S", label: "실적 %", type: "number", value: 100 },
- ],
- (values) => {
- if (Object.values(values).some((n) => !Number.isFinite(n) || n < 0))
- throw Error("낙찰률은 0 이상의 수치입니다.");
- EstApp.change(
- () =>
- (state.project = EstBusiness.bid(project, [values.N, values.G, values.J, values.S])),
- );
- },
- );
else if (name === "restore-bid") {
if (confirm("당초단가(100%)로 복원하시겠습니까?"))
EstApp.change(() => {
@@ -663,16 +645,19 @@ const EstActions = (() => {
EstApp.render();
});
EstApp.byId("previous-page").addEventListener("click", () => {
- EstApp.state.page--; EstApp.render();
+ EstApp.state.page--;
+ EstApp.render();
});
EstApp.byId("next-page").addEventListener("click", () => {
- EstApp.state.page++; EstApp.render();
+ EstApp.state.page++;
+ EstApp.render();
});
document.addEventListener("keydown", (event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
event.preventDefault();
EstApp.save().catch((error) => {
- EstApp.state.notice = error.message; EstApp.render();
+ EstApp.state.notice = error.message;
+ EstApp.render();
});
}
});
diff --git a/tools/est-test/native-bid.js b/tools/est-test/native-bid.js
new file mode 100644
index 000000000..aa5e4e955
--- /dev/null
+++ b/tools/est-test/native-bid.js
@@ -0,0 +1,102 @@
+// 원본 낙찰율(TFRMBIMOGBIUL) — 전체낙찰율·비목별낙찰율, 적용방법(현재/선택한/전체 라인), 공종별(선택한 공종 하위),
+// 「낙찰율 적용된 공종 제외」. 계산은 EstBusiness.bid(행 묶음 지정)를 그대로 쓴다.
+const EstBid = (() => {
+ const num = (value) => Number(value) || 0;
+
+ // 내역서 공종(Level>0) 행의 하위 상세 행 번호 — 원본 내역 집계와 같은 범위(같거나 높은 Level 전까지).
+ function subtree(rows, heading) {
+ const level = num(rows[heading]?.Level),
+ out = [];
+ if (level <= 0) return out;
+ for (let i = heading + 1; i < rows.length; i++) {
+ if (num(rows[i].Level) >= level || [54, 58, 59].includes(num(rows[i].LinkIndex))) break;
+ if (!num(rows[i].Level)) out.push(i);
+ }
+ return out;
+ }
+
+ function scope(project, view, selection, range, skipApplied) {
+ const table = view === "HeadMainDat" ? "HeadMainDat" : "EstMainDat",
+ rows = project.tables[table] || [],
+ picked = [...selection].map(Number).sort((a, b) => a - b);
+ let indices;
+ if (range === "all") return null;
+ if (range === "rows") {
+ if (!picked.length) throw Error("낙찰률을 적용할 줄을 선택하세요.");
+ indices = picked;
+ } else {
+ if (table !== "EstMainDat") throw Error("공종별 적용은 내역서 화면에서 하세요.");
+ const headings = picked.filter((i) => num(rows[i]?.Level) > 0);
+ if (!headings.length) throw Error("낙찰률을 적용할 공종(제목 줄)을 선택하세요.");
+ indices = [...new Set(headings.flatMap((i) => subtree(rows, i)))];
+ }
+ if (skipApplied) indices = indices.filter((i) => !rows[i].ApplyNacChal);
+ if (!indices.length) throw Error("적용할 줄이 없습니다.");
+ return { [table]: new Set(indices) };
+ }
+
+ function rates(values) {
+ const list =
+ values.method === "total"
+ ? [values.T, values.T, values.T, values.T]
+ : [values.N, values.G, values.J, values.S];
+ if (list.some((n) => !Number.isFinite(n) || n < 0))
+ throw Error("낙찰률은 0 이상의 수치입니다.");
+ return list;
+ }
+
+ function open(state) {
+ const view = state.view;
+ EstActions.form(
+ "낙찰률 적용",
+ [
+ {
+ name: "method",
+ label: "방식",
+ value: "bimog",
+ choices: [
+ ["total", "전체낙찰율(한 비율)"],
+ ["bimog", "비목별낙찰율"],
+ ],
+ },
+ { name: "T", label: "전체 %", type: "number", value: 100 },
+ { name: "N", label: "노무 %", type: "number", value: 100 },
+ { name: "G", label: "재료 %", type: "number", value: 100 },
+ { name: "J", label: "경비 %", type: "number", value: 100 },
+ { name: "S", label: "실적 %", type: "number", value: 100 },
+ {
+ name: "range",
+ label: "적용방법",
+ value: "all",
+ choices: [
+ ["all", "전체 라인에 적용"],
+ ["rows", "선택한 라인에 적용"],
+ ["groups", "선택한 공종(하위 줄)에 적용"],
+ ],
+ },
+ {
+ name: "skip",
+ label: "낙찰 적용된 줄",
+ value: "keep",
+ choices: [
+ ["keep", "다시 적용"],
+ ["skip", "제외"],
+ ],
+ },
+ ],
+ (values) => {
+ const list = rates(values),
+ only = scope(state.project, view, state.selection, values.range, values.skip === "skip");
+ EstApp.change(() => (state.project = EstBusiness.bid(state.project, list, only)));
+ },
+ );
+ }
+
+ function handle(name, state) {
+ if (name !== "bid-rates") return false;
+ open(state);
+ return true;
+ }
+
+ return { subtree, scope, rates, handle };
+})();
diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js
index f30499af4..16cb25921 100644
--- a/tools/est-test/native-business-actions.js
+++ b/tools/est-test/native-business-actions.js
@@ -604,7 +604,8 @@ const EstBusinessActions = (() => {
(typeof EstBlank !== "undefined" && EstBlank.handle(name, state)) ||
(typeof EstQuantitySheets !== "undefined" && EstQuantitySheets.handle(name, state, node)) ||
(typeof EstExcelMap !== "undefined" && EstExcelMap.handle(name, state)) ||
- (typeof EstTransfer !== "undefined" && EstTransfer.handle(name, state))
+ (typeof EstTransfer !== "undefined" && EstTransfer.handle(name, state)) ||
+ (typeof EstBid !== "undefined" && EstBid.handle(name, state))
) {
EstApp.render();
} else if (name === "load-contract" || name === "load-bid") {
diff --git a/tools/est-test/native-business.js b/tools/est-test/native-business.js
index a4df4a97f..86a3f0bda 100644
--- a/tools/est-test/native-business.js
+++ b/tools/est-test/native-business.js
@@ -413,14 +413,16 @@ const EstBusiness = (() => {
});
return rows;
}
- function bid(project, rates) {
+ // only: 적용할 행 묶음 { EstMainDat: Set(index), HeadMainDat: Set(index) } — 없으면 전체(원본 낙찰율 적용방법).
+ function bid(project, rates, only = null) {
const next = copy(project);
if (project.options && "SourceRefresh" in project.options) {
next.options._preBidSourceRefresh ??= project.options.SourceRefresh;
}
next.options = { ...next.options, SourceRefresh: false };
for (const key of ["HeadMainDat", "EstMainDat"])
- for (const row of next.tables[key]) {
+ for (const [index, row] of next.tables[key].entries()) {
+ if (only && !only[key]?.has(index)) continue;
if (
num(row.Level) > 0 ||
num(row.Index) > 0 ||
diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs
index 571301a67..a82541fe8 100644
--- a/tools/est-test/server.cjs
+++ b/tools/est-test/server.cjs
@@ -63,6 +63,7 @@ const allowed = new Set([
"native-quantity-sheets.js",
"native-excel-map.js",
"native-transfer.js",
+ "native-bid.js",
"native-issues.js",
"native-attachments.js",
"native-standard.js",