feat(est-test): 낙찰률 적용 범위와 전체낙찰율
- 원본 낙찰율 창처럼 전체·선택한 줄·선택한 공종 하위로 범위 지정, 적용된 줄 제외 - 전체낙찰율 한 비율 또는 비목별 선택, 편집 메뉴에 낙찰률 적용·당초단가 복원 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -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. 묶음 인쇄(설계서 한 벌).
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
|
||||
내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다.
|
||||
|
||||
`편집 → 낙찰률 적용`은 원본 낙찰율 창처럼 전체낙찰율(한 비율) 또는 비목별낙찰율을 고르고, 전체 줄·선택한 줄·선택한 공종(하위 줄 모두)에 적용합니다. 이미 낙찰이 적용된 줄은 제외할 수 있고, `당초단가(100%) 복원`으로 되돌립니다.
|
||||
|
||||
`파일 → 프로젝트 추가`는 다른 공사 파일(ESTX·웹 JSON)의 순공사비 위 내역 행 전체를 하위 자료와 함께 이 공사의 순공사비 위에 합칩니다. 같은 코드 자료는 이 공사의 것을 그대로 씁니다.
|
||||
|
||||
`편집 → 내역 행 연결 자료와 함께 복사`는 고른 내역 행이 쓰는 자재·노임·실적·견적 자료와 일위대가·단가산출·기계경비 호표를 하위까지 모아 복사합니다. 다른 공사를 연 뒤 `연결 자료와 함께 붙여넣기`를 누르면 없는 자료만 추가하고, 같은 코드가 이미 있으면 그 공사의 자료를 그대로 씁니다. 붙여넣을 공사에 기계경비 적용기준이 비어 있을 때만 적용기준도 함께 옮깁니다.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user