feat(est-test): 정렬·위치정리·호표 정리와 근거 메모
- 원본 처리 근거대로 기초단가 가나다 정렬, 위치정리, 호표 번호정렬 - 원본 서식 근거(메모) 글자 보기와 삭제 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
}
|
||||
@@ -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. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기.
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
|
||||
`편집 → 선택 행 자재단가 등록`·`견적단가 등록`은 원본처럼 내역·일위대가 행을 새 자재(M)·견적(W) 단가로 등록하고 그 행을 새 코드에 연결합니다. 같은 품명·규격이 있으면 거부하고, 일위대가 호표 제목 줄은 등록하지 않습니다. `품명정리`는 고른 일위대가 호표의 품명을 커서 자리에서 나눠 뒤쪽을 규격 앞에 붙이고, 단위가 비어 있으면 채웁니다.
|
||||
|
||||
`편집 → 정렬(가나다)`은 기초단가 표를 관급구분별로 묶어 품명·규격 순(실적은 코드 순 선택)으로 정렬하고 번호를 다시 매기며 빈 행은 뺍니다. `데이타 위치정리`는 기초단가 또는 일위대가·단가산출·기계경비 호표를 품명 오름/내림으로 다시 놓고 시작번호부터 번호를 매깁니다. `호표 정리`는 차례는 그대로 두고 호표 번호만 시작번호부터 다시 매깁니다. `근거(메모) 보기`는 원본 서식 메모의 글자를 읽기 전용으로 보이고, `근거(메모) 삭제`는 고른 행의 메모를 지웁니다.
|
||||
|
||||
`편집 → 무관자료 삭제`는 원본처럼 내역서가 쓰는 자재·노임·실적·견적과 일위대가·단가산출·기계경비 호표를 하위까지 따라가 표시하고 나머지를 지웁니다. 내역서·원가계산서·기계경비 적용기준은 지우지 않고, 묶음 머리 앞의 빈 줄을 정리합니다. `(내역서순)`은 표를 내역서 첫 등장 순서로 다시 늘어놓으며 코드는 바꾸지 않습니다. `무관 수량산출서 삭제`는 내역에 연결되지 않은 수량산출서를 지웁니다. 모두 되살리기로 되돌릴 수 있습니다.
|
||||
|
||||
`원가계산서 → 정산원가계산서 작성/해제`는 원본처럼 원가 표에 당초금액 열을 금액 앞에 보이고 금액 열을 정산금액으로 바꿉니다. 처음 켤 때(또는 `당초금액 초기화`) 원가 행마다 현재 금액을 당초금액으로 옮기고 도급액 고정 연결(55)을 풀며, 맨 아래 금액 행의 차이를 `(감)`·`(증)` 금액 행으로 적습니다. 켜져 있는 동안 재계산마다 차액 행을 다시 씁니다. 끄면 차액 행만 비우고 당초금액은 남깁니다.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user