feat(est-test): 낙찰률 적용 범위와 전체낙찰율

- 원본 낙찰율 창처럼 전체·선택한 줄·선택한 공종 하위로 범위 지정, 적용된 줄 제외
- 전체낙찰율 한 비율 또는 비목별 선택, 편집 메뉴에 낙찰률 적용·당초단가 복원

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-11 17:16:20 +09:00
co-authored by Claude Opus 5.5
parent dc365e2140
commit 6518238ef1
9 changed files with 211 additions and 25 deletions
+88
View File
@@ -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
+3 -1
View File
@@ -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. 묶음 인쇄(설계서 한 벌).
+2
View File
@@ -22,6 +22,8 @@
내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다.
`편집 → 낙찰률 적용`은 원본 낙찰율 창처럼 전체낙찰율(한 비율) 또는 비목별낙찰율을 고르고, 전체 줄·선택한 줄·선택한 공종(하위 줄 모두)에 적용합니다. 이미 낙찰이 적용된 줄은 제외할 수 있고, `당초단가(100%) 복원`으로 되돌립니다.
`파일 → 프로젝트 추가`는 다른 공사 파일(ESTX·웹 JSON)의 순공사비 위 내역 행 전체를 하위 자료와 함께 이 공사의 순공사비 위에 합칩니다. 같은 코드 자료는 이 공사의 것을 그대로 씁니다.
`편집 → 내역 행 연결 자료와 함께 복사`는 고른 내역 행이 쓰는 자재·노임·실적·견적 자료와 일위대가·단가산출·기계경비 호표를 하위까지 모아 복사합니다. 다른 공사를 연 뒤 `연결 자료와 함께 붙여넣기`를 누르면 없는 자료만 추가하고, 같은 코드가 이미 있으면 그 공사의 자료를 그대로 씁니다. 붙여넣을 공사에 기계경비 적용기준이 비어 있을 때만 적용기준도 함께 옮깁니다.
+3
View File
@@ -43,6 +43,7 @@
<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-bid.js"></script>
<script defer src="native-actions.js"></script>
<script defer src="native-business-actions.js"></script>
<script defer src="native-app.js"></script>
@@ -111,6 +112,8 @@
<button data-action="toggle-sun-gongsa">순공사비 계산끝 지정/해제</button>
<button data-action="align-wonga-total">공사비역산 자리수 맞추기</button>
<button data-action="clear-estimate-quantities">내역 수량 초기화</button>
<button data-action="bid-rates">낙찰률 적용</button
><button data-action="restore-bid">당초단가(100%) 복원</button>
</div>
</details>
<details>
+6 -21
View File
@@ -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();
});
}
});
+102
View File
@@ -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 };
})();
+2 -1
View File
@@ -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") {
+4 -2
View File
@@ -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 ||
+1
View File
@@ -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",