feat(est-test): 정렬·위치정리·호표 정리와 근거 메모

- 원본 처리 근거대로 기초단가 가나다 정렬, 위치정리, 호표 번호정렬
- 원본 서식 근거(메모) 글자 보기와 삭제

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-11 19:38:35 +09:00
co-authored by Claude Opus 5.5
parent f7b68be72a
commit b06b737437
7 changed files with 267 additions and 1 deletions
+90
View File
@@ -0,0 +1,90 @@
"""EST 테스트 앱 정렬·데이타 위치정리·호표 정리(원본 BarSort·TFRMTABLESORT·popTitleSort)."""
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-tidy.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)));
"""
return json.loads(subprocess.check_output(["node", "-e", runner], cwd=ROOT).decode("utf-8"))
def test_sort_base_groups_by_gangub_and_drops_blank():
result = run_js(
"""(() => {
const p = { tables: { StuffcDat: [
{ Index: 1, Code: 'M3', Name: '철근', Gangub: 0 }, { Index: 2, Code: 'M1', Name: '레미콘', Gangub: 1 },
{ Index: 3, Code: 'M2', Name: '거푸집', Gangub: 0 }, { Index: 0, Code: 'M9', Name: '번호없음' }, { Index: 4 } ] } };
const dropped = EstTidy.sortBase(p, 'StuffcDat');
const s = { tables: { SilJukDat: [{ Index: 1, Code: 'S2', Name: '가' }, { Index: 2, Code: 'S1', Name: '나' }] } };
EstTidy.sortBase(s, 'SilJukDat', true);
let bad = ''; try { EstTidy.sortBase(p, 'HeadMainDat'); } catch (e) { bad = e.message.slice(0, 4); }
return { order: p.tables.StuffcDat.map((r) => [r.Code, r.Index]), dropped, code: s.tables.SilJukDat.map((r) => r.Code), bad };
})()"""
)
assert result == {
"order": [["M2", 1], ["M3", 2], ["M1", 3]],
"dropped": 2,
"code": ["S1", "S2"],
"bad": "자재·노",
}
def test_arrange_groups_and_number_heads_keep_totals():
result = run_js(
"""(() => {
const calc = (x) => EstNative.calculate(EstModel.validate(x));
const p = calc(structuredClone(source));
const total = p.calculation.total;
const n = EstTidy.arrange(p, 'HeadMainDat', false, 10);
const heads = [...EstNative.groups(p.tables.HeadMainDat).values()].map((g) => g.rows[0]);
const sorted = heads.every((h, i) => i === 0 || String(heads[i - 1].Name) <= String(h.Name));
const numbers = heads.map((h) => h.Index);
const titles = p.tables.HeadTitleDat.filter((t) => heads.some((h) => h.Code === t.Code)).every((t) => t.Index === heads.find((h) => h.Code === t.Code).Index);
EstTidy.numberHeads(p, 'HeadMainDat', 1);
const after = calc(p);
return { n: n === heads.length, sorted, first: numbers[0], step: numbers[1] - numbers[0], titles,
renum: [...EstNative.groups(p.tables.HeadMainDat).values()].map((g) => g.rows[0].Index).join() === heads.map((_, i) => i + 1).join(),
total: after.calculation.total === total, errors: after.calculation.errors.length };
})()"""
)
assert result == {
"n": True,
"sorted": True,
"first": 10,
"step": 1,
"titles": True,
"renum": True,
"total": True,
"errors": 0,
}
+3
View File
@@ -464,6 +464,9 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
- 검증완료: `test_est_cleanup.py` 3개(안 쓰는 자재·일위 삭제·합계 불변·오류 0, 내역서순 순서·코드 유지, 빈 줄·수량산출서) 통과. 설치 main·sample·산림토목 ESTX에서 정리 전후 도급액·오류 수 같음. ORCA 브라우저에서 내역서순 정리 → 자재 30 → 17·도급액 79,603,000 유지·되살리기 복원.
- [x] 품명정리·자재/견적단가 등록(원본 ActionNameDeivde 0xf5a2b4·TfrmNameDevide 0xf595c0/0xf59640, popMainStuffcReg/EstimateReg → c_f00264/c_f00670 — 디스어셈블 근거) — 커서 자리 나누기·삭제할 칸·빈 단위만 채움·호표 목록 동기화, 등록 시 같은 품명·규격 거부·호표 거부·M/W+PkID 10자리 코드·자재 Cost1=TC=재료단가·견적 NC/GC/JC·원래 행 연결. 원본은 품명정리를 2개 이상 선택 때만 돌리나 웹은 1개도 허용.
- 검증완료: `test_est_name_reg.py` 2개(나누기·삭제할 칸·제목 동기화·호표 아님 거부, 자재/견적 등록 코드·가격·연결·금액 불변·중복/호표 거부·계산 오류 0) 통과. ORCA 브라우저에서 내역 행 자재 등록 → M0000000038·연결 5, 일위대가 「레미콘타설(…)」 3자리 나누기 → 품명 「레미콘」·규격 「타설(인력운반타설)철근구조물」, 샘플 복구.
- [x] 정렬·데이타 위치정리·호표 정리·근거(메모)(원본 BarSort 0x110ADEC·TfrmSiljukSort, mnuTableSort 0x10CCAEC·TFRMTABLESORT, popTitleSort 0x10D88EC, mnuGunguEdit/Del·DifineMemo — 디스어셈블 근거) — 관급구분별 가나다·Index 재번호·빈 행 제외(30줄 채우기는 안 함), 품명 오름/내림·시작번호·묶음 통째 이동, 번호만 재매김·제목표 동기화, 메모 WPTools 글자 읽기 전용 보기·삭제. 원본 위치정리 콤보 글자 뒤바뀜은 웹에서 실제 순서대로 표기.
- 검증완료: `test_est_tidy.py` 2개(관급구분·빈 행·실적 코드 정렬·화면 거부, 위치정리·번호정렬·제목 동기화·도급액 불변·오류 0) 통과. ORCA 브라우저에서 설치 main.estx 메모를 넣어 보기 → 「2015년 건설표준품셈 … 잔디블록」 글자 표시, 삭제 → null, 자재 정렬 → 빈 행 12개 제외, 샘플 복구.
- 남음: 메모 편집(원본 서식 저장), 위치정리 드래그 이동.
- 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133.
- [ ] 남은 핵심(실무 빈도순):
1. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기.
+2
View File
@@ -24,6 +24,8 @@
`편집 → 선택 행 자재단가 등록`·`견적단가 등록`은 원본처럼 내역·일위대가 행을 새 자재(M)·견적(W) 단가로 등록하고 그 행을 새 코드에 연결합니다. 같은 품명·규격이 있으면 거부하고, 일위대가 호표 제목 줄은 등록하지 않습니다. `품명정리`는 고른 일위대가 호표의 품명을 커서 자리에서 나눠 뒤쪽을 규격 앞에 붙이고, 단위가 비어 있으면 채웁니다.
`편집 → 정렬(가나다)`은 기초단가 표를 관급구분별로 묶어 품명·규격 순(실적은 코드 순 선택)으로 정렬하고 번호를 다시 매기며 빈 행은 뺍니다. `데이타 위치정리`는 기초단가 또는 일위대가·단가산출·기계경비 호표를 품명 오름/내림으로 다시 놓고 시작번호부터 번호를 매깁니다. `호표 정리`는 차례는 그대로 두고 호표 번호만 시작번호부터 다시 매깁니다. `근거(메모) 보기`는 원본 서식 메모의 글자를 읽기 전용으로 보이고, `근거(메모) 삭제`는 고른 행의 메모를 지웁니다.
`편집 → 무관자료 삭제`는 원본처럼 내역서가 쓰는 자재·노임·실적·견적과 일위대가·단가산출·기계경비 호표를 하위까지 따라가 표시하고 나머지를 지웁니다. 내역서·원가계산서·기계경비 적용기준은 지우지 않고, 묶음 머리 앞의 빈 줄을 정리합니다. `(내역서순)`은 표를 내역서 첫 등장 순서로 다시 늘어놓으며 코드는 바꾸지 않습니다. `무관 수량산출서 삭제`는 내역에 연결되지 않은 수량산출서를 지웁니다. 모두 되살리기로 되돌릴 수 있습니다.
`원가계산서 → 정산원가계산서 작성/해제`는 원본처럼 원가 표에 당초금액 열을 금액 앞에 보이고 금액 열을 정산금액으로 바꿉니다. 처음 켤 때(또는 `당초금액 초기화`) 원가 행마다 현재 금액을 당초금액으로 옮기고 도급액 고정 연결(55)을 풀며, 맨 아래 금액 행의 차이를 `(감)`·`(증)` 금액 행으로 적습니다. 켜져 있는 동안 재계산마다 차액 행을 다시 씁니다. 끄면 차액 행만 비우고 당초금액은 남깁니다.
+6
View File
@@ -48,6 +48,7 @@
<script defer src="native-forms-library.js"></script>
<script defer src="native-cleanup.js"></script>
<script defer src="native-name-reg.js"></script>
<script defer src="native-tidy.js"></script>
<script defer src="native-actions.js"></script>
<script defer src="native-business-actions.js"></script>
<script defer src="native-app.js"></script>
@@ -122,6 +123,11 @@
<button data-action="bid-rates">낙찰률 적용</button
><button data-action="restore-bid">당초단가(100%) 복원</button>
<hr />
<button data-action="tidy-sort">정렬(가나다, 기초단가)</button
><button data-action="tidy-arrange">데이타 위치정리</button
><button data-action="tidy-number">호표 정리(번호정렬)</button
><button data-action="memo-view">근거(메모) 보기</button
><button data-action="memo-delete">근거(메모) 삭제</button>
<button data-action="notlink-delete">무관자료 삭제</button
><button data-action="notlink-sort">무관자료 삭제(내역서순)</button
><button data-action="notlink-quantity">무관 수량산출서 삭제</button>
+2 -1
View File
@@ -609,7 +609,8 @@ const EstBusinessActions = (() => {
(typeof EstFormsLibrary !== "undefined" && EstFormsLibrary.handle(name, state, node)) ||
(typeof EstJungSang !== "undefined" && EstJungSang.handle(name, state)) ||
(typeof EstCleanup !== "undefined" && EstCleanup.handle(name, state)) ||
(typeof EstNameReg !== "undefined" && EstNameReg.handle(name, state))
(typeof EstNameReg !== "undefined" && EstNameReg.handle(name, state)) ||
(typeof EstTidy !== "undefined" && EstTidy.handle(name, state))
) {
EstApp.render();
} else if (name === "load-contract" || name === "load-bid") {
+163
View File
@@ -0,0 +1,163 @@
// 원본 표 정리 — 정렬(BarSort 0x110ADEC·TfrmSiljukSort), 데이타 위치정리(mnuTableSort 0x10CCAEC·TFRMTABLESORT),
// 호표 정리(popTitleSort 0x10D88EC), 근거(메모) 보기·삭제(mnuGunguEdit·mnuGunguDel, 필드 DifineMemo).
// 정렬: 기초단가 표를 관급구분(Gangub) 0·1·2 차례로 묶고 각 묶음을 품명+규격(실적은 코드 선택 가능) 순으로, Index 1..n.
// Index ≤ 0 이거나 품명·규격·수량·금액이 모두 빈 행은 버린다. 원본의 빈 행 30줄 채우기는 웹에서 하지 않는다.
// 위치정리: 기초단가·일위대가·단가산출·기계경비를 품명 오름/내림으로 다시 놓고 시작번호부터 Index — 묶음은 통째로 옮긴다.
// 원본 콤보 글자는 오름/내림이 뒤바뀌어 보이나(분석 추정) 웹은 실제 순서대로 적는다.
// 호표 정리: 현재 차례 그대로 묶음 머리 Index 를 시작번호부터 다시 매기고 제목표 같은 코드도 맞춘다(행·코드 불변).
const EstTidy = (() => {
const num = (value) => Number(value) || 0;
const base = ["StuffcDat", "LaborcDat", "SilJukDat", "EstiMateDat"];
const grouped = {
HeadMainDat: "HeadTitleDat",
CostMainDat: "CostTitleDat",
MachMainDat: "MachTitleDat",
};
const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
const key = (row) => `${row.Name ?? ""}${row.Size ?? ""}`;
function sortBase(project, table, byCode = false) {
if (!base.includes(table)) throw Error("자재·노임·실적·견적 단가 화면에서 정렬하세요.");
const rows = project.tables[table] || [];
const kept = rows.filter(
(row) => num(row.Index) > 0 && (key(row).trim() || num(row.GN) || num(row.TC)),
);
const out = [];
for (const gangub of [0, 1, 2])
out.push(
...kept
.filter((row) => num(row.Gangub) === gangub)
.sort((a, b) =>
byCode ? compare(String(a.Code ?? ""), String(b.Code ?? "")) : compare(key(a), key(b)),
),
);
out.forEach((row, i) => (row.Index = i + 1));
project.tables[table] = out;
return rows.length - out.length;
}
function arrange(project, table, descending, start) {
if (!Number.isInteger(start)) throw Error("시작번호는 정수입니다.");
const order = (a, b) => compare(String(a ?? ""), String(b ?? "")) * (descending ? -1 : 1);
if (base.includes(table)) {
const rows = (project.tables[table] || []).filter((row) => row.Code);
rows.sort((a, b) => order(a.Name, b.Name)).forEach((row, i) => (row.Index = start + i));
project.tables[table] = rows;
return rows.length;
}
if (!grouped[table]) throw Error("이 화면은 위치정리를 할 수 없습니다.");
const groups = [...EstNative.groups(project.tables[table] || []).values()].sort((a, b) =>
order(a.rows[0].Name, b.rows[0].Name),
);
project.tables[table] = groups.flatMap((g) => g.rows);
numberHeads(project, table, start);
return groups.length;
}
function numberHeads(project, table, start) {
if (!grouped[table]) throw Error("일위대가·단가산출·기계경비 화면에서 호표를 정리하세요.");
if (!Number.isInteger(start)) throw Error("시작번호는 정수입니다.");
const titles = new Map((project.tables[grouped[table]] || []).map((t) => [t.Code, t]));
let n = start;
for (const row of project.tables[table] || [])
if (num(row.Index) > 0) {
row.Index = n;
const title = titles.get(row.Code);
if (title) title.Index = n;
n++;
}
return n - start;
}
// 근거(메모): 원본 DifineMemo 는 WPTools 서식(zlib) — 웹은 글자만 꺼내 읽기 전용으로 보인다.
async function memoText(memo) {
if (memo == null) return "";
if (typeof memo === "string") return memo;
if (!memo.binary) return "";
let bytes = Uint8Array.from(atob(memo.binary), (c) => c.charCodeAt(0));
if (bytes[0] === 0x78)
bytes = new Uint8Array(
await new Response(
new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate")),
).arrayBuffer(),
);
const source = new TextDecoder("euc-kr").decode(bytes);
return EstQuantity.entities(
source
.replace(/<(numberstyles|stylesheet)[\s\S]*?<\/\1>/gi, "")
.replace(/<\/(div|p)>|<br\s*\/?>/gi, "\n")
.replace(/<[^>]*>/g, ""),
)
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function memoTarget(state) {
const index = [...state.selection][0],
row = state.project.tables[state.view]?.[index];
if (state.selection.size !== 1 || !row) throw Error("근거(메모)를 볼 행 하나를 고르세요.");
return { index, row };
}
async function run(name, state) {
if (name === "tidy-sort") {
const table = state.view;
let byCode = false;
if (table === "SilJukDat") byCode = !confirm("품명,규격으로 정렬할까요? [취소] 코드로 정렬");
if (!confirm("가,나,다 순으로 정렬합니다. 빈 행과 번호 없는 행은 빠집니다.")) return;
let dropped;
EstApp.change((project) => (dropped = sortBase(project, table, byCode)));
state.notice = `정렬했습니다${dropped ? ` (빈 행 ${dropped}개 제외)` : ""}.`;
} else if (name === "tidy-arrange" || name === "tidy-number") {
EstActions.form(
name === "tidy-arrange" ? "데이타 위치정리" : "호표 정리(번호정렬)",
[
...(name === "tidy-arrange"
? [
{
name: "order",
label: "품명 정렬",
value: "asc",
choices: [
["asc", "오름차순(가나다)"],
["desc", "내림차순"],
],
},
]
: []),
{ name: "start", label: "시작번호", type: "number", value: 1 },
],
(values) =>
EstApp.change((project) =>
name === "tidy-arrange"
? arrange(project, state.view, values.order === "desc", values.start)
: numberHeads(project, state.view, values.start),
),
);
} else if (name === "memo-view") {
const { row } = memoTarget(state),
body = await memoText(row.DifineMemo);
EstApp.showDialog(
`근거(메모) — ${row.Name ?? row.Code ?? ""}`,
body
? `<pre style="white-space:pre-wrap;max-height:400px;overflow:auto">${EstApp.esc(body)}</pre>${typeof row.DifineMemo === "string" ? "" : "<p>원본 서식 메모라 웹에서는 읽기만 합니다.</p>"}`
: "<p>이 행에는 근거(메모)가 없습니다.</p>",
);
} else if (name === "memo-delete") {
const { index } = memoTarget(state);
EstApp.change((project) => (project.tables[state.view][index].DifineMemo = null), false);
state.notice = "근거(메모)를 지웠습니다.";
}
}
const names = new Set(["tidy-sort", "tidy-arrange", "tidy-number", "memo-view", "memo-delete"]);
function handle(name, state) {
if (!names.has(name)) return false;
run(name, state).catch((error) => {
state.notice = error.message;
EstApp.render();
});
return true;
}
return { sortBase, arrange, numberHeads, memoText, handle };
})();
+1
View File
@@ -69,6 +69,7 @@ const allowed = new Set([
"native-forms-library.js",
"native-cleanup.js",
"native-name-reg.js",
"native-tidy.js",
"native-issues.js",
"native-attachments.js",
"native-standard.js",