feat(est-test): 기계경비 공식 모음과 불러오기
- 원본처럼 공식 명칭·본문 목록 편집, 단가산출에 본문 줄 넣기 - 옛 EST Plus mach_Text.dat 구공식변환 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
"""EST 테스트 앱 기계경비 공식 모음(원본 TfrmMachGongSic·TfrmMachText·구공식변환 0xd90c60)."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_old_file_parse_and_insert(tmp_path):
|
||||
script = r"""
|
||||
const fs = require('fs'), vm = require('vm');
|
||||
const ctx = vm.createContext({ TextDecoder, DataView, Uint8Array });
|
||||
vm.runInContext(fs.readFileSync('tools/est-test/native-mach-formula.js', 'utf8'), ctx);
|
||||
const rec = (head, a, b) => {
|
||||
const r = Buffer.alloc(394); r.writeInt32LE(head, 5);
|
||||
const ea = Buffer.from(a, 'latin1'), eb = Buffer.from(b, 'latin1');
|
||||
r[0x16] = ea.length; ea.copy(r, 0x17); r[0x49] = eb.length; eb.copy(r, 0x4a); return r;
|
||||
};
|
||||
const file = Buffer.concat([rec(1, 'Back', 'hoe'), rec(0, 'Q = ', '1.0'), rec(0, 'E = 0.7', ''), rec(2, 'Dozer', ''), rec(0, 'f = 1', '')]);
|
||||
ctx.bytes = new Uint8Array(file);
|
||||
const parsed = vm.runInContext('EstMachFormula.parseOld(bytes)', ctx);
|
||||
let bad = ''; try { vm.runInContext('EstMachFormula.parseOld(new Uint8Array(10))', ctx); } catch (e) { bad = e.message.slice(0, 6); }
|
||||
ctx.p = { tables: { CostMainDat: [{ PkID: 3, Index: 1, Code: 'D1' }, { PkID: 4, LineData: '끝' }] } };
|
||||
ctx.body = 'Q = 1\rE = 0.7\r';
|
||||
const count = vm.runInContext('EstMachFormula.insertInto(p, 0, body)', ctx);
|
||||
process.stdout.write(JSON.stringify({ parsed, bad, count, rows: ctx.p.tables.CostMainDat.map((r) => [r.PkID, r.LineData ?? null]) }));
|
||||
"""
|
||||
out = json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT).decode("utf-8"))
|
||||
assert out["parsed"] == [
|
||||
{"name": "Backhoe", "body": "Q = 1.0\rE = 0.7\r", "index": 1},
|
||||
{"name": "Dozer", "body": "f = 1\r", "index": 2},
|
||||
]
|
||||
assert out["bad"] == "구공식 파일"
|
||||
assert out["count"] == 2
|
||||
assert out["rows"] == [[3, None], [5, "Q = 1"], [6, "E = 0.7"], [4, "끝"]]
|
||||
|
||||
|
||||
def test_store_accepts_machine_formula_kind(tmp_path):
|
||||
script = """
|
||||
const f = require(process.cwd() + '/tools/est-test/forms-store.cjs');
|
||||
f.save(process.argv[1], { kind: 'machformula', name: '백호', data: { index: 1, body: 'Q = 1' } });
|
||||
process.stdout.write(JSON.stringify(f.read(process.argv[1]).map((x) => [x.kind, x.name])));
|
||||
"""
|
||||
out = json.loads(
|
||||
subprocess.check_output(["node", "-e", script, str(tmp_path)], cwd=ROOT).decode("utf-8")
|
||||
)
|
||||
assert out == [["machformula", "백호"]]
|
||||
@@ -500,6 +500,8 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
|
||||
- [x] 묶음 인쇄·간지(원본 TEstPrint.Print 0x10fef98, TFRMESTPRINT2·TFRMPRINTLISTCHECK·TFRMESTPRINTPRIVATE — 디스어셈블 근거) — 인쇄표 차례·Checked 서식, 간지(Name2 가운데·겹테두리), 한 문서 이어 인쇄, 간지 쪽 번호 숨김, 공사별 선택 저장(`project.printBatch`). 원본은 서식마다 따로 인쇄 작업·간지는 쪽 번호를 소모하지 않음 — 웹은 브라우저 쪽 번호라 간지도 쪽 수에 들어감(번호만 숨김).
|
||||
- 검증완료: `test_est_print_batch.py`(공통 행 제외·간지→서식 차례·간지 끔·선택 저장) 통과. ORCA 브라우저에서 인쇄표 40개 목록·4개 선택 → 간지 4·서식 4·표 3, 샘플 복구.
|
||||
- 남음: 설정값 일괄적용(간지 글꼴·여백 13항목), 번호자동매김·시작 쪽, 끌어서 차례 바꾸기.
|
||||
- [x] 기계경비 공식 모음(원본 TfrmMachGongSic 0xf146f0·MachText.Dat, TfrmMachText 0xd3d58c, 구공식변환 0xd90c60 — 디스어셈블 근거) — 서버 양식 보관함 종류 machformula, 삽입·추가·삭제·번호 1..N·저장, 단가산출 현재 행 아래로 본문 줄 넣기, 옛 394바이트 레코드 변환. 원본이 기계경비 연결 때 덧붙이는 요약 4줄은 형식 확인 전이라 빼둠.
|
||||
- 검증완료: `test_est_mach_formula.py` 2개(구공식 두 공식·본문 CR·크기 오류, 줄 넣기 PkID·위치, 보관함 종류) 통과. ORCA 브라우저에서 공식 추가·저장 → 보관함 등록 → 단가산출 호표 아래 2줄 넣기 → 보관함 정리·샘플 복구.
|
||||
- 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133.
|
||||
- [ ] 남은 핵심(실무 빈도순):
|
||||
3. 수량산출서 — 누계·4방향 합계, 제출용 전체 인쇄.
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
|
||||
내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다.
|
||||
|
||||
`편집 → 기계경비 공식`은 원본 기계경비 공식 창처럼 명칭과 공식 본문을 모아 두는 목록(공사와 무관하게 이 앱 서버에 저장)을 편집합니다. 단가산출 화면에서 행을 고르고 `단가산출에 불러오기`를 누르면 공식 본문을 줄마다 그 아래에 넣습니다(계산식으로 풀지 않음). `구공식변환`으로 옛 EST Plus `mach_Text.dat`(394바이트 레코드)를 읽어 덧붙입니다.
|
||||
|
||||
`편집 → 하단으로 집계`는 원본처럼 공종이 끝나는 자리(다음 공종 머리·순공사비 앞·마지막)에 「소 계」 줄을 넣고, 계산할 때 그 공종 상세 행의 합을 채웁니다. 겹쳐 넣지 않으며 `상단으로 집계`는 소계 줄을 지웁니다. `공종 다듬기`는 레벨마다 공종명을 들여 보입니다. `단가산출 들여쓰기`는 산출근거 줄 앞 빈칸을 같은 칸 수로 맞추고(분수 세 줄은 함께 옮김), `단가산출 레벨 확인`은 현재 호표의 레벨 줄과 비목 소계를 계층으로 보입니다.
|
||||
|
||||
`편집 → 데이타 자동연결`은 원본처럼 내역서·일위대가·기계경비 행을 현재 공사의 노임·자재·견적·표준·기계경비·단가산출·일위대가 표와 품명(·규격·단위)으로 맞춰 코드·단가·연결종류를 적습니다. 빈칸·|·괄호를 무시하고 단위의 「당」·[ ]를 지우며, 노임은 품명만 맞추고 못 찾으면 규격으로 찾아 품명·규격을 맞바꿉니다. `Over Write`를 끄면 이미 연결된 행은 건너뜁니다. `전체연결 끊기`는 원본처럼 모든 행의 연결종류를 0으로 합니다.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const kinds = new Set(["wonga", "document"]);
|
||||
const kinds = new Set(["wonga", "document", "machformula"]);
|
||||
const LIMIT = 2 * 1024 * 1024;
|
||||
|
||||
function file(directory) {
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
<script defer src="native-autolink.js"></script>
|
||||
<script defer src="native-est-tools.js"></script>
|
||||
<script defer src="native-print-batch.js"></script>
|
||||
<script defer src="native-mach-formula.js"></script>
|
||||
<script defer src="native-actions.js"></script>
|
||||
<script defer src="native-business-actions.js"></script>
|
||||
<script defer src="native-app.js"></script>
|
||||
@@ -148,6 +149,7 @@
|
||||
><button data-action="est-left-space">공종 다듬기</button
|
||||
><button data-action="cost-indent">단가산출 들여쓰기</button
|
||||
><button data-action="cost-level-check">단가산출 레벨 확인</button>
|
||||
<button data-action="mach-formula">기계경비 공식(모음·불러오기)</button>
|
||||
<button data-action="tidy-sort">정렬(가나다, 기초단가)</button
|
||||
><button data-action="tidy-arrange">데이타 위치정리</button
|
||||
><button data-action="tidy-number">호표 정리(번호정렬)</button
|
||||
|
||||
@@ -619,7 +619,8 @@ const EstBusinessActions = (() => {
|
||||
(typeof EstReviewAuto !== "undefined" && EstReviewAuto.handle(name, state)) ||
|
||||
(typeof EstAutoLink !== "undefined" && EstAutoLink.handle(name, state)) ||
|
||||
(typeof EstEstTools !== "undefined" && EstEstTools.handle(name, state)) ||
|
||||
(typeof EstPrintBatch !== "undefined" && EstPrintBatch.handle(name, state))
|
||||
(typeof EstPrintBatch !== "undefined" && EstPrintBatch.handle(name, state)) ||
|
||||
(typeof EstMachFormula !== "undefined" && EstMachFormula.handle(name, state, node))
|
||||
) {
|
||||
EstApp.render();
|
||||
} else if (name === "load-contract" || name === "load-bid") {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// 원본 기계경비 공식(TfrmMachGongSic 0xf146f0, 전역 파일 System\MainSys\MachText.Dat: Index·Name·BigoMemo)과
|
||||
// 기계경비공식불러오기(TfrmMachText 0xd3d58c: 고른 공식 본문을 줄마다 단가산출 LineData 행으로 현재 행 뒤에 넣음, 계산하지 않음).
|
||||
// 웹은 공식 모음을 서버 양식 보관함(종류 machformula)에 둔다. 번호는 늘 1..N.
|
||||
// 구공식변환(숨김 단추 0xda5c18 → 0xd90c60): 옛 EST Plus mach_Text.dat 394바이트 레코드 — +5 정수 > 0 이면 새 공식 머리
|
||||
// (이름 = +0x16 짧은 문자열 + +0x49 짧은 문자열), 아니면 본문 줄(앞글+뒤글+CR).
|
||||
// 원본이 기계경비 연결 때 덧붙이는 요약 4줄(노무비·재료비·경비·소계)은 형식 확인 전이라 넣지 않는다.
|
||||
const EstMachFormula = (() => {
|
||||
function parseOld(bytes) {
|
||||
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
||||
if (!view.length || view.length % 394)
|
||||
throw Error("구공식 파일 크기가 394바이트 단위가 아닙니다.");
|
||||
const decoder = new TextDecoder("euc-kr"),
|
||||
data = new DataView(view.buffer, view.byteOffset, view.byteLength);
|
||||
const short = (at, max) =>
|
||||
decoder.decode(view.subarray(at + 1, at + 1 + Math.min(view[at], max)));
|
||||
const out = [];
|
||||
let current = null;
|
||||
for (let at = 0; at < view.length; at += 394) {
|
||||
const head = data.getInt32(at + 5, true),
|
||||
text = short(at + 0x16, 50) + short(at + 0x49, 255);
|
||||
if (head > 0) {
|
||||
current = { name: text.slice(0, 100), body: "" };
|
||||
out.push(current);
|
||||
} else if (current) current.body += `${text}\r`;
|
||||
}
|
||||
return out.map((f, i) => ({ ...f, index: i + 1 }));
|
||||
}
|
||||
|
||||
function lines(body) {
|
||||
return String(body ?? "")
|
||||
.split(/\r\n|\r|\n/)
|
||||
.filter((line, i, all) => line !== "" || i < all.length - 1);
|
||||
}
|
||||
|
||||
function insertInto(project, at, body) {
|
||||
const rows = project.tables.CostMainDat;
|
||||
if (!rows?.[at]) throw Error("단가산출에서 넣을 위치(행)를 고르세요.");
|
||||
const add = lines(body);
|
||||
if (!add.length) throw Error("공식 본문이 비어 있습니다.");
|
||||
let pk = Math.max(0, ...rows.map((r) => Number(r.PkID) || 0));
|
||||
rows.splice(at + 1, 0, ...add.map((LineData) => ({ PkID: ++pk, Index: 0, LineData })));
|
||||
return add.length;
|
||||
}
|
||||
|
||||
// 화면
|
||||
async function load() {
|
||||
return (await EstApp.api("/api/forms"))
|
||||
.filter((f) => f.kind === "machformula")
|
||||
.sort((a, b) => (a.data.index ?? 0) - (b.data.index ?? 0));
|
||||
}
|
||||
async function save(list) {
|
||||
const old = await load();
|
||||
for (const f of old)
|
||||
if (!list.some((n) => n.name === f.name))
|
||||
await EstApp.api("/api/forms/delete", post({ kind: "machformula", name: f.name }));
|
||||
for (const [i, f] of list.entries())
|
||||
await EstApp.api(
|
||||
"/api/forms",
|
||||
post({ kind: "machformula", name: f.name, data: { index: i + 1, body: f.body } }),
|
||||
);
|
||||
}
|
||||
const post = (body) => ({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
async function editor(state) {
|
||||
const list = (await load()).map((f) => ({ name: f.name, body: f.data.body }));
|
||||
state.machFormulas = list;
|
||||
draw(state);
|
||||
}
|
||||
function draw(state) {
|
||||
const { esc } = EstApp,
|
||||
list = state.machFormulas,
|
||||
cost = state.view === "CostMainDat";
|
||||
EstApp.showDialog(
|
||||
"기계경비 공식",
|
||||
`<div style="max-height:380px;overflow:auto">${list
|
||||
.map(
|
||||
(f, i) =>
|
||||
`<div class="form-fields"><label>No ${i + 1} 명칭<input name="mf-name" data-i="${i}" value="${esc(f.name)}"></label><label>공식<textarea name="mf-body" data-i="${i}" rows="4" style="width:100%">${esc(f.body)}</textarea></label><button data-action="mach-formula-insert" data-i="${i}">이 위에 삽입</button> <button data-action="mach-formula-delete" data-i="${i}">삭제</button>${cost ? ` <button data-action="mach-formula-use" data-i="${i}">단가산출에 불러오기</button>` : ""}</div>`,
|
||||
)
|
||||
.join(
|
||||
"<hr>",
|
||||
)}</div><button data-action="mach-formula-append">추가</button> <button data-action="mach-formula-old">구공식변환(옛 mach_Text.dat)</button> <button data-action="mach-formula-save">저장</button>`,
|
||||
);
|
||||
}
|
||||
function read(state) {
|
||||
for (const node of document.querySelectorAll('#dialog-content [name="mf-name"]'))
|
||||
state.machFormulas[Number(node.dataset.i)].name = node.value.trim();
|
||||
for (const node of document.querySelectorAll('#dialog-content [name="mf-body"]'))
|
||||
state.machFormulas[Number(node.dataset.i)].body = node.value;
|
||||
}
|
||||
function unique(list) {
|
||||
const seen = new Set();
|
||||
for (const f of list) {
|
||||
if (!f.name) throw Error("공식 명칭을 입력하세요.");
|
||||
if (seen.has(f.name)) throw Error(`같은 명칭이 있습니다: ${f.name}`);
|
||||
seen.add(f.name);
|
||||
}
|
||||
}
|
||||
|
||||
async function run(name, state, node) {
|
||||
const i = Number(node?.dataset.i);
|
||||
if (name === "mach-formula") return editor(state);
|
||||
read(state);
|
||||
const list = state.machFormulas;
|
||||
if (name === "mach-formula-append") list.push({ name: `새 공식 ${list.length + 1}`, body: "" });
|
||||
else if (name === "mach-formula-insert")
|
||||
list.splice(i, 0, { name: `새 공식 ${list.length + 1}`, body: "" });
|
||||
else if (name === "mach-formula-delete") list.splice(i, 1);
|
||||
else if (name === "mach-formula-save") {
|
||||
unique(list);
|
||||
await save(list);
|
||||
state.notice = `기계경비 공식 ${list.length}개를 저장했습니다.`;
|
||||
} else if (name === "mach-formula-use") {
|
||||
const at = [...state.selection].sort((a, b) => a - b).at(-1);
|
||||
let count;
|
||||
EstApp.change((project) => (count = insertInto(project, at, list[i].body)));
|
||||
EstApp.byId("dialog").close();
|
||||
state.notice = `「${list[i].name}」 공식 ${count}줄을 넣었습니다.`;
|
||||
return;
|
||||
} else if (name === "mach-formula-old") {
|
||||
const picker = document.createElement("input");
|
||||
picker.type = "file";
|
||||
picker.accept = ".dat";
|
||||
picker.addEventListener("change", async () => {
|
||||
try {
|
||||
const file = picker.files[0];
|
||||
if (!file) return;
|
||||
for (const f of parseOld(new Uint8Array(await file.arrayBuffer())))
|
||||
list.push({ name: f.name, body: f.body });
|
||||
draw(state);
|
||||
} catch (error) {
|
||||
state.notice = error.message;
|
||||
EstApp.render();
|
||||
}
|
||||
});
|
||||
picker.click();
|
||||
return;
|
||||
}
|
||||
draw(state);
|
||||
}
|
||||
const names = new Set([
|
||||
"mach-formula",
|
||||
"mach-formula-append",
|
||||
"mach-formula-insert",
|
||||
"mach-formula-delete",
|
||||
"mach-formula-save",
|
||||
"mach-formula-use",
|
||||
"mach-formula-old",
|
||||
]);
|
||||
function handle(name, state, node) {
|
||||
if (!names.has(name)) return false;
|
||||
run(name, state, node).catch((error) => {
|
||||
state.notice = error.message;
|
||||
EstApp.render();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return { parseOld, lines, insertInto, handle };
|
||||
})();
|
||||
@@ -81,6 +81,7 @@ const allowed = new Set([
|
||||
"native-autolink.js",
|
||||
"native-est-tools.js",
|
||||
"native-print-batch.js",
|
||||
"native-mach-formula.js",
|
||||
"native-issues.js",
|
||||
"native-attachments.js",
|
||||
"native-standard.js",
|
||||
|
||||
Reference in New Issue
Block a user