feat(est-test): 발췌근거 출처 열 이름·보이기

- 원본 barbtnColumn: 자재·노임 단가의 출처 열(Cost1~6) 이름·쪽수·보기 편집, 자재는 8가지 출처 이름·중복 거부
- 숨긴 출처는 최저·평균·최고 단가 고르기에서 제외(브라우저·서버 같은 엔진)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 12:48:37 +09:00
co-authored by Claude Opus 5.5
parent 5e6c2988fc
commit 7222577442
9 changed files with 179 additions and 8 deletions
+60
View File
@@ -0,0 +1,60 @@
"""EST 테스트 앱 발췌근거(원본 barbtnColumn — 가격 출처 열 이름·보이기, 숨긴 출처는 단가 고르기에서 제외)."""
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-sources.js",
"native-engine.js",
"native-model.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 }});
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_hidden_source_leaves_price_choice_and_labels():
out = run_js(
r"""(() => {
const p = EstNative.empty();
p.options.SourceRefresh = false;
p.tables.StuffcDat = [{ Code: 'M1', Name: '철근', Cost1: 100, Cost2: 80, Cost3: 120, ValueIndex: 0 },
{ Code: 'M2', Name: '모래', Cost1: 100, Cost2: 80, Cost3: 120, ValueIndex: 2 }];
const tc = (q) => EstNative.calculate(q).tables.StuffcDat.map((r) => r.GC);
const before = tc(p);
const list = EstSources.get(p.options, 'StuffcDat');
list[1] = { caption: '물가자료', page: '120', visible: false };
list[2] = { caption: '유통물가', page: '', visible: false };
EstSources.set(p.options, 'StuffcDat', list);
const after = tc(p);
let dup = '';
try { EstSources.set(p.options, 'StuffcDat', list.map(() => ({ caption: '가격정보' }))); } catch (e) { dup = e.message; }
EstSources.set(p.options, 'LaborcDat', list.map(() => ({ caption: '같음' })));
return { before, after, dup, label: EstSources.label(p.options, 'StuffcDat', 'Cost2'),
other: EstSources.label(p.options, 'EstMainDat', 'Cost2'),
cols: EstSources.columns(p.options, 'StuffcDat', ['Code', 'Cost1', 'Cost2', 'Cost3', 'TC']) };
})()"""
)
assert out["before"] == [80, 120]
assert out["after"] == [100, 100]
assert out["dup"] == "같은 발췌근거가 있습니다."
assert out["label"] == "물가자료 (120)"
assert out["other"] == ""
assert out["cols"] == ["Code", "Cost1", "TC"]
+2
View File
@@ -487,6 +487,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] 발췌근거(원본 barbtnColumn — TfrmHeaderChange 0x110b7ec · 자재 TfrmStuffcHeadChange 0xf61b84) — 자재·노임 화면 「발췌근거」 단추로 출처 열(Cost1~6) 이름·쪽수(자재)·보기 편집, 자재는 8가지 출처 이름에서 고르고 중복 거부, 숨긴 출처는 최저·평균·최고 단가 고르기에서 빠짐(서버 재계산도 같은 코드). 값은 계산 환경 `PriceSources`(ESTX 저장에는 남지 않음 — 원본도 출처 이름은 격자 배치에만 둠).
- 검증완료: `test_est_sources.py`(최저 80→100·최고 120→100, 중복 거부, 머리글 「물가자료 (120)」, 숨긴 열 빠짐) 및 REA 대조 39개 통과. ORCA 브라우저에서 자재단가 발췌근거 2번 「물가자료」 쪽 77, 3번 숨김 → 머리글 반영·도급액 불변, 설정 지워 복구.
- [x] 조달청 표준공사코드 적용(원본 mnuCodeChange → TfrmCodeChange — 디스어셈블 근거) — 작업데이타 7종 탭(일위·단산·기계·표준·견적·자재·노임), 코드변경(목록 코드·직접 입력, 기계 16자리면 끝 0), 검사 색(EST 코드 노랑·다른 표 중복 연분홍+비고·자리수 진황), 변경취소, 적용(자기 표·본문 머리 Code/TitleCode·참조 행 LinkIndex=종류·Index≤0 만, 재계산 없음), 코드일괄변경(고른 .estx 의 같은 표에서 자동연결 맞춤 규칙, EST→비EST 만·중복 금지, 표준은 옛 코드 부분일치), 자동변경(표준 행 번호 구간 C·A·M), 다산코드정리, 사용자정의코드, 목록·작업데이타 검색(& 두 조각). 조달청 목록은 원본 설치의 PPS_STUFFC.dat·PPS_HEAD.dat 를 사용자가 골라 불러옴(내장 자료 없음, `/api/pps-table`). 원본의 분류 트리(PPS_*TYPE)·다산정리 PkID 쓰기는 하지 않음.
- 검증완료: `test_est_code_change.py` 2개(검사·중복·자리수 거부, 취소, 적용 범위·Index>0 제외, 기계 머리 TitleCode, 자동변경·다산·사용자정의코드 꼴, 설치 JodalCodeDB 로 노임 일괄변경 1건·PPS_STUFFC 58,653행) 통과. ORCA 브라우저에서 샘플 노임 「보통인부」 L0000000000→L001010101000001 적용 → 일위·단산 참조 11행 함께 바뀜, 도급액 79,603,000 불변, 샘플 복구.
- [x] 기성 원가계산서 편집(원본 TfrmWongaAccomH/V 팝업 A3~A8 — 디스어셈블 근거) — 회차마다 원가 서식 사본(`round.wonga`)에 한줄 삽입·추가(끝줄 표지 이어받기)·삭제(확인), 내용 삭제(연결·금액·요율·수식·자리 비움), 선택항목 절하(도급액 행 허용), 선택데이타 절하(직전 자리, 구성비 기준 행 제외), 공사비역산 자리수(금회 도급액 10^n 미만 절하 → 이윤 역산), 초기화(사본 지움 = 도급 원가 서식), 되살리기(직전 조작 전), 산출근거 약어로 표시(수식칸 잠금만). 기성 행도 Diget>0 이면 자리맞춤. 도급금액 열은 순서가 아닌 기호·이름으로 짝지음. 원본의 번호·참조 재작성은 하지 않음(새 행 기호는 직접 입력). 확정 회차는 거부. 서버 허용 목록에서 빠져 있던 할증·부가세 스크립트(404)도 넣음.
+2
View File
@@ -76,6 +76,8 @@
편집(E) › `조달청 표준공사코드 적용`은 노임·자재·기계·일위 등의 코드를 조달청 코드로 바꿉니다. 왼쪽 목록은 원본 설치 폴더 `System\MainSys` 의 `PPS_STUFFC.dat`(노임·자재·장비) 또는 `PPS_HEAD.dat`(일위·단산·표준)를 골라 불러옵니다. 코드를 바꾼 뒤 `적용`을 누르면 그 코드를 쓰는 내역·일위·단산 줄도 함께 바뀝니다. `코드일괄변경`은 고른 .estx 파일(예: 원본 JodalCodeDB.estx)에서 품명·규격이 같은 코드를 찾아 EST 코드만 바꿉니다.
자재단가·노임단가 화면의 `발췌근거` 단추로 가격 출처 열(출처1~6)의 이름과 쪽수를 바꾸거나 숨길 수 있습니다. 숨긴 출처는 최저·평균·최고 단가를 고를 때 빠집니다.
원본의 엑셀형 수량산출서(본문이 옛 Excel .xls)는 가져올 때 셀 값과 수식을 함께 읽어 수량산출 화면 아래 격자로 보입니다. 셀을 누르면 수식줄에서 값이나 `=수식`을 고치고 바로 다시 계산합니다(참조·범위·사칙·SUM·ROUND 등). Shift 로 범위를 골라 행·열 넣기·지우기, 잘라내기·복사·붙여넣기, 굵게·정렬·병합·대각선을 쓸 수 있고, 수식의 참조는 원본처럼 따라 옮겨집니다. `우측·위·좌측·아래 합`은 원본처럼 그 방향의 숫자 셀을 더하는 SUM 을 넣고, `수량으로 지정`은 셀을 수량 항목에 연결해 항목 수량이 그 셀 값(산출서 자리수)을 따르게 합니다. 원본 파일에는 셀과 항목의 대응이 보이지 않아, 가져올 때 저장 수량과 값이 같은 합계 셀을 「추정 연결」로 둡니다. 셀을 고친 엑셀형 산출서는 ESTX 저장을 거부합니다.
산출 줄 옆 `수량 지정`은 줄 글에서 드래그해 고른 숫자를 결과 표지로 감싸고 수량 항목을 새로 만듭니다. 품명·규격·단위 표지가 있으면 항목 이름에 옮깁니다. `지정 해제`는 커서가 놓인 결과 표지를 풀고, 다른 줄에서 쓰지 않는 항목을 지웁니다. 내역에 연결된 항목은 먼저 연결을 비워야 합니다. 이 변경이 있는 작업은 ESTX 수정 저장을 거부합니다.
+2
View File
@@ -16,6 +16,7 @@
<script defer src="native-quantity.cjs"></script>
<script defer src="native-wonga.js"></script>
<script defer src="native-jungsang.js"></script>
<script defer src="native-sources.js"></script>
<script defer src="native-engine.js"></script>
<script defer src="native-row-match.js"></script>
<script defer src="native-escalation.js"></script>
@@ -268,6 +269,7 @@
><button id="haljung-calc-btn" data-action="haljung-calc" hidden>할증계산</button
><button id="vat-calc-btn" data-action="vat-calc" hidden>부가세계산</button
><button id="unit-calc-btn" data-action="unit-calc" hidden>단위환산</button
><button id="sources-btn" data-action="sources" hidden title="발췌근거 삭제,삽입 및 변경">발췌근거</button
><button id="uses-search-btn" data-action="uses-data-search">사용위치</button
><button data-action="add-row">행 추가</button><button data-action="copy-row">복사</button
><button data-action="paste-row">붙여넣기</button
+2 -1
View File
@@ -625,7 +625,8 @@ const EstBusinessActions = (() => {
(typeof EstDefaults !== "undefined" && EstDefaults.handle(name, state, node)) ||
(typeof EstExcelView !== "undefined" && EstExcelView.handle(name, state, node)) ||
(typeof EstProgressWonga !== "undefined" && EstProgressWonga.handle(name, state)) ||
(typeof EstCodeChangeView !== "undefined" && EstCodeChangeView.handle(name, state, node))
(typeof EstCodeChangeView !== "undefined" && EstCodeChangeView.handle(name, state, node)) ||
(typeof EstSources !== "undefined" && EstSources.handle(name, state))
) {
EstApp.render();
} else if (name === "load-contract" || name === "load-bid") {
+11 -5
View File
@@ -33,10 +33,11 @@ const EstNative = (() => {
"MulTitleDat",
];
const clone = (value) => structuredClone(value);
function price(row, kind) {
const candidates = Array.from({ length: 6 }, (_, i) => num(row[`Cost${i + 1}`])).filter(
(value) => value !== 0,
);
function price(row, kind, hidden) {
// 발췌근거에서 숨긴 출처(EstSources)는 최저·평균·최고에서 뺀다
const candidates = Array.from({ length: 6 }, (_, i) =>
hidden?.has(i + 1) ? 0 : num(row[`Cost${i + 1}`]),
).filter((value) => value !== 0);
let total;
switch (num(row.ValueIndex)) {
case 0:
@@ -144,7 +145,12 @@ const EstNative = (() => {
.concat(calculated.filter((row) => !existing.has(row.Code)));
}
for (const kind of [5, 6, 21, 101])
for (const row of tables[sourceTables[kind]]) price(row, kind);
for (const row of tables[sourceTables[kind]])
price(
row,
kind,
typeof EstSources !== "undefined" ? EstSources.hidden(options, sourceTables[kind]) : null,
);
const machine = EstMachine.calculate(tables.MachMainDat, tables.MachGijunDat, options);
tables.MachMainDat = machine.rows;
saveTitles("MachTitleDat", machine.titles);
+93
View File
@@ -0,0 +1,93 @@
// 원본 「발췌근거」(barbtnColumn — 일반 TfrmHeaderChange 0x110b7ec · 자재 TfrmStuffcHeadChange 0xf61b84):
// 단가 표의 가격 출처 열(Cost1~6) 머리글 이름·쪽수·보이기를 고친다. 숨긴 출처는 최저·평균·최고 단가 고르기(113e918)에서 빠진다.
// 자재 표는 출처 이름을 정해진 8가지에서 고르고 같은 이름이 둘이면 거부한다. 값은 계산 환경 PriceSources[표] 에 둔다.
const EstSources = (() => {
const NAMES = [
"가격정보",
"물가자료",
"유통물가",
"물가정보",
"거래가격",
"견적가1",
"견적가2",
"견적가3",
];
const TABLES = ["StuffcDat", "LaborcDat"];
function get(options, table) {
const saved = options?.PriceSources?.[table] || [];
return Array.from({ length: 6 }, (_, i) => ({
caption: saved[i]?.caption || `출처${i + 1}`,
page: saved[i]?.page ?? "",
visible: saved[i]?.visible !== false,
}));
}
function hidden(options, table) {
return new Set(
get(options, table)
.map((s, i) => (s.visible ? -1 : i + 1))
.filter((i) => i > 0),
);
}
function columns(options, table, cols) {
const off = hidden(options, table);
return off.size ? cols.filter((k) => !off.has(Number(/^Cost(\d)$/.exec(k)?.[1]))) : cols;
}
function label(options, table, key) {
const m = /^Cost(\d)$/.exec(key);
if (!m || !TABLES.includes(table)) return "";
const s = get(options, table)[Number(m[1]) - 1];
return s.page ? `${s.caption} (${s.page})` : s.caption;
}
function set(options, table, list) {
if (table === "StuffcDat") {
const seen = new Set();
for (const s of list) {
if (seen.has(s.caption)) throw Error("같은 발췌근거가 있습니다.");
seen.add(s.caption);
}
}
options.PriceSources = { ...(options.PriceSources || {}), [table]: list };
}
function dialog(state) {
const table = state.view;
if (!TABLES.includes(table)) throw Error("자재단가·노임단가 화면에서 쓰세요.");
const { esc } = EstApp,
list = get(state.project.options, table),
stuffc = table === "StuffcDat";
EstApp.showDialog(
"발췌근거 수정 및 보기",
`<table class="sheet"><thead><tr><th>No</th><th>발췌근거</th>${stuffc ? "<th>페이지</th>" : ""}<th>보기</th></tr></thead><tbody>${list
.map(
(s, i) =>
`<tr><td>${i + 1}</td><td>${
stuffc
? `<select name="src-caption" data-i="${i}">${[...new Set([s.caption, ...NAMES])].map((n) => `<option${n === s.caption ? " selected" : ""}>${esc(n)}</option>`).join("")}</select>`
: `<input name="src-caption" data-i="${i}" value="${esc(s.caption)}">`
}</td>${stuffc ? `<td><input name="src-page" data-i="${i}" value="${esc(s.page)}" size="6"></td>` : ""}<td><input type="checkbox" name="src-visible" data-i="${i}"${s.visible ? " checked" : ""}></td></tr>`,
)
.join("")}</tbody></table><button data-action="sources-apply">적 용</button>`,
);
}
function apply(state) {
const table = state.view,
q = (name, i) => document.querySelector(`#dialog-content [name="${name}"][data-i="${i}"]`);
const list = Array.from({ length: 6 }, (_, i) => ({
caption: q("src-caption", i).value.trim() || `출처${i + 1}`,
page: q("src-page", i)?.value.trim() ?? "",
visible: q("src-visible", i).checked,
}));
set(structuredClone(state.project.options), table, list);
EstApp.change((project) => set(project.options, table, list));
EstApp.byId("dialog").close();
}
function handle(name, state) {
if (name === "sources") dialog(state);
else if (name === "sources-apply") apply(state);
else return false;
return true;
}
return { NAMES, get, hidden, columns, label, set, handle };
})();
+6 -2
View File
@@ -76,7 +76,7 @@ const EstViews = (() => {
}
function grid(table, list, columns) {
const { esc, number, state } = EstApp;
return `<table class="sheet" data-grid="${table}"><thead><tr><th class="row-number">선택</th>${columns.map((key) => `<th class="col-${key}">${esc((table === "Wonga" && typeof EstJungSang !== "undefined" && EstJungSang.label(state.project, key)) || EstModel.labels[key] || key)}</th>`).join("")}</tr></thead><tbody>${list.map(({ row, index }) => `<tr data-index="${index}" class="${state.selection.has(index) ? "selected " : ""}${Number(row.Index) > 0 ? "heading " : ""}${Number(row.Level) > 0 ? "level " : ""}${row.Error ? "error-row" : ""}"><th class="row-number"><label><input type="checkbox" data-select="${index}" ${state.selection.has(index) ? "checked" : ""}>${index + 1}</label></th>${columns.map((key) => `<td class="col-${key}"${key === "Name" && Number(row.LeftSpace) > 0 ? ` style="padding-left:${Number(row.LeftSpace) * 6}px"` : ""}>${cell(table, index, key, row[key])}</td>`).join("")}</tr>`).join("")}</tbody></table>`;
return `<table class="sheet" data-grid="${table}"><thead><tr><th class="row-number">선택</th>${columns.map((key) => `<th class="col-${key}">${esc((table === "Wonga" && typeof EstJungSang !== "undefined" && EstJungSang.label(state.project, key)) || (typeof EstSources !== "undefined" && EstSources.label(state.project.options, table, key)) || EstModel.labels[key] || key)}</th>`).join("")}</tr></thead><tbody>${list.map(({ row, index }) => `<tr data-index="${index}" class="${state.selection.has(index) ? "selected " : ""}${Number(row.Index) > 0 ? "heading " : ""}${Number(row.Level) > 0 ? "level " : ""}${row.Error ? "error-row" : ""}"><th class="row-number"><label><input type="checkbox" data-select="${index}" ${state.selection.has(index) ? "checked" : ""}>${index + 1}</label></th>${columns.map((key) => `<td class="col-${key}"${key === "Name" && Number(row.LeftSpace) > 0 ? ` style="padding-left:${Number(row.LeftSpace) * 6}px"` : ""}>${cell(table, index, key, row[key])}</td>`).join("")}</tr>`).join("")}</tbody></table>`;
}
function render(state) {
const { project, view } = state,
@@ -103,6 +103,8 @@ const EstViews = (() => {
if (haljungBtn) haljungBtn.hidden = view !== "HeadMainDat";
const vatBtn = byId("vat-calc-btn");
if (vatBtn) vatBtn.hidden = view !== "StuffcDat";
const sourcesBtn = byId("sources-btn");
if (sourcesBtn) sourcesBtn.hidden = !["StuffcDat", "LaborcDat"].includes(view);
const unitCalcBtn = byId("unit-calc-btn");
if (unitCalcBtn) unitCalcBtn.hidden = view !== "HeadMainDat";
byId("move-estimate-up").hidden = ![
@@ -124,7 +126,9 @@ const EstViews = (() => {
columns =
view === "Wonga" && typeof EstJungSang !== "undefined"
? EstJungSang.columns(project, EstModel.columns(view))
: EstModel.columns(view);
: typeof EstSources !== "undefined"
? EstSources.columns(project.options, view, EstModel.columns(view))
: EstModel.columns(view);
byId("work-content").innerHTML = grid(view, list, columns);
byId("paging").hidden = false;
byId("page-info").textContent =
+1
View File
@@ -26,6 +26,7 @@ const scripts = [
"native-quantity.cjs",
"native-wonga.js",
"native-jungsang.js",
"native-sources.js",
"native-engine.js",
"native-row-match.js",
"native-escalation.js",