feat(est-test): 구버전 EST Plus(.est) 불러오기·저장·단가비교
- 394/565 바이트 레코드(Extended·ShortString) 읽기·쓰기, 표별 변환과 설계변경 c* 파일 - 호표 코드 매기기·품명규격으로 연결 코드 붙이기, 저장 금액 유지 - 단가비교 창(4종, 차례 짝, 합계 다름 표시, 더블클릭 이동), Ctrl+Alt+E · Ctrl+Shift+F12 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,7 @@ for (const file of [
|
||||
"native-dongwon-check.js",
|
||||
"native-excel-files.js",
|
||||
"native-option-all.js",
|
||||
"native-estplus.js",
|
||||
"native-business-actions.js",
|
||||
"native-forms.js",
|
||||
"native-excel-to-est.js",
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""구버전 EST Plus(.est): 394 바이트 레코드 쓰기→읽기 왕복, 단가비교 자리, 짝짓기."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from test_est_rea_parity import run
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
EST = ROOT / "tools/est-test"
|
||||
|
||||
|
||||
def node(code):
|
||||
out = subprocess.check_output(
|
||||
["node", "-e", code], cwd=EST, text=True, encoding="utf-8"
|
||||
)
|
||||
return json.loads(out)
|
||||
|
||||
|
||||
def test_extended_and_round_trip_of_sample():
|
||||
result = node(
|
||||
"""
|
||||
const L=require('./legacy-estplus.cjs'),s=require('./sample.json');
|
||||
const b=Buffer.alloc(10),xs=[0,1,-2.5,1234567.891,0.001,1e15].map(v=>(L.writeX(b,0,v),L.readX(b,0)));
|
||||
const buf=L.write(s),p=L.read(buf,'t');
|
||||
const same=(t,keys)=>s.tables[t].every((r,i)=>keys.every(k=>Math.abs((Number(r[k])||0)-(Number(p.tables[t][i][k])||0))<1e-6));
|
||||
console.log(JSON.stringify({xs,
|
||||
est:same('EstMainDat',['GN','TU','TC','NC','GC','JC','Level']),
|
||||
head:same('HeadMainDat',['GN','TC','NC','GC','JC']),
|
||||
cost:s.tables.CostMainDat.every((r,i)=>r.Index>0||(r.LineData||'')===(p.tables.CostMainDat[i].LineData||'')),
|
||||
title:p.tables.CostTitleDat[0].Name,labor:p.tables.LaborcDat.length,
|
||||
linked:p.tables.EstMainDat.find(r=>r.Name==='터파기').Code,auto:p.options.AutoCalc,
|
||||
cmp:L.compare(buf,2)[0]}));
|
||||
""")
|
||||
assert result["xs"] == [0, 1, -2.5, 1234567.891, 0.001, 1e15]
|
||||
assert result["est"] and result["head"] and result["cost"]
|
||||
assert result["title"] == "터파기"
|
||||
assert result["labor"] == 9
|
||||
assert result["linked"] == "D0000000001"
|
||||
assert result["auto"] is False
|
||||
assert result["cmp"] == {"ONU": 66841, "OGU": 1336, "OJU": 0}
|
||||
|
||||
|
||||
def test_pair_flags_sum_mismatch_in_order():
|
||||
result = run(
|
||||
"math",
|
||||
"""(()=>{
|
||||
const p=EstNative.empty();p.tables.HeadMainDat=[{Index:1,Code:'U1',Name:'a',NC:10,GC:5},{Index:0},
|
||||
{Index:2,Code:'U2',Name:'b',NC:3}];
|
||||
return EstEstPlus.pair(p,2,[{ONU:10,OGU:5,OJU:0},{ONU:4,OGU:0,OJU:0}]).map(r=>[r.Code,r.UsesCheck]);
|
||||
})()""",
|
||||
)
|
||||
assert result == [["U1", False], ["U2", True]]
|
||||
@@ -512,6 +512,9 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
|
||||
- 검증완료: 파일목록 시험 1개·수량산출 시험 6개 통과. ORCA 브라우저에서 목록 등록 → 목록 파일로 B2 셀 연결(수량 12.5)·연결 수 1 → 삭제 확인, 원복.
|
||||
- [x] 환경설정 전체 창(TfrmOption btnLoad · spec_pb2 §9) — 탭 다섯(공통적용·내역서·일위단산기계·표준자재원가·설계변경수량심사) 92칸. 웹 계산·표시에 이어진 칸은 그 옵션에(일위 자리수·단산 금액란·중간 자리수·외화 절하·재료비 처리·이윤 사사오입·관급 조달수수료·원가 방향·자동계산·재계산 범위·정산금액·비목순서·정렬·두줄·Enter·코드 열·0 표시·바탕색·레벨·발췌근거·집계 진하게), 나머지는 값만(Env), PC 설정은 이 브라우저. 바꾼 칸만 씀. 적용 때 원본 알림(자동계산 끔/켬·표준단가비목·조달청 계산방식·산식 표시·공종+품명 합치기)과 순공사비 명칭 바꾸면 순공사비 행 이름도 바꿈. 기성 화면에서는 기성 환경설정. 순공사비 상단/하단 이동·제잡비 행 넣기는 값만(웹 계산이 순공사비 위치를 아래로 가정).
|
||||
- 검증완료: 환경설정 시험 1개·표시·기성 시험 4개 통과. ORCA 브라우저에서 탭 5·칸 92, 코드표시 켬 → 자재 표에 코드 열, 순공사비 명칭 → 행 이름 바뀜, 원복.
|
||||
- [x] 구버전 EST Plus 자료 불러오기·저장·단가비교(spec_misc §2 · 원본 역분석 spec_estplus — 레코드 394/565 바이트, Extended·ShortString) — 불러오기: .est/.ebk 를 풀어 내역·일위·단산·기계·노임·자재·표준·견적(565)·원가와 설계변경 c* 를 표로, 저장 금액 유지(자동계산 끔), 호표 머리에 코드를 매기고 본문 연결 행은 같은 품명·규격 원천 코드를 붙임(웹 보충). 저장(Ctrl+Shift+F12): 394 판 .est. 단가비교(Ctrl+Alt+E): 기계경비·단가산출·일위대가·내역서 중 골라 구형 파일 머리 레코드와 차례 짝, 합계 다르면 표시, 더블클릭 이동. 메모(.MEM)·기계 기준표(.mot)는 읽지 않음.
|
||||
- 미결: 실제 구버전 .est 표본이 없어 웹이 만든 파일로만 왕복 확인 — 원본 파일 대조 필요(문자 코드 cp949 가정).
|
||||
- 검증완료: 구버전 시험 2개 통과(Extended 왕복, 샘플 공사 쓰기→읽기 금액 일치, 단가산출 글줄·머리 꼬리표, 품명·규격 연결, 단가비교 자리). ORCA 브라우저에서 .est 저장 17,864 바이트 → 다시 불러오기 76행, 단가비교 창·일위 8건 일치, 원복.
|
||||
- [x] 다른 공사 불러오기 창(원본 barbtnSelfLoad → TfrmOtherLoad, 찾기 popSerch) — 저장된 다른 작업을 골라 같은 표를 띄우고 찾기(품명·규격, A&B 둘 다)·고른 줄을 하위 자료와 함께 가져오기(내역서는 고른 줄 아래에 줄, 나머지는 자료·호표 합침, 같은 코드는 이 공사 것). 검증완료: 바인딩 통과, ORCA 브라우저에서 산불진화임도 작업의 일위대가 3개 → 「취수장」 1개 → 가져오기 호표 8→9·하위 41건, 샘플 복구.
|
||||
- [x] 사용위치 검색 창(원본 TfrmUsesDataSerch btnSerch·btnAllDisplay) — 현재 표 항목을 품명·규격(빈칸·|·괄호 무시)으로 거르고 골라 사용위치 보기. 검증완료: 바인딩 통과, ORCA 브라우저에서 노임 9개 → 「보 통」 1개 → 보통인부 사용위치 11곳.
|
||||
- [x] 인쇄양식 불러오기·저장하기(원본 mnuPrintFormatLoad·Save 0x10ed318·0x10ed000 — spec_pc §3) — .pfot(인쇄정보표 MIDAS 이진) 읽기·쓰기 `print-format.cjs`(설명 BLOB·빈 칸 표지까지 보존, 원본 파일 그대로 다시 쓰면 바이트 같음). 불러오면 공사 인쇄표를 그 값으로, 저장은 지금 인쇄표를 원본 설치 main.pfot 필드 설명자 위에(`EST_PFOT_TEMPLATE`). .gfot(cxGrid 열 배치)는 웹 격자와 호환되지 않아 하지 않음.
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<script defer src="native-dongwon-check.js"></script>
|
||||
<script defer src="native-excel-files.js"></script>
|
||||
<script defer src="native-option-all.js"></script>
|
||||
<script defer src="native-estplus.js"></script>
|
||||
<script defer src="native-actions-more.js"></script>
|
||||
<script defer src="native-wonga.js"></script>
|
||||
<script defer src="native-jungsang.js"></script>
|
||||
@@ -129,6 +130,9 @@
|
||||
<button data-action="export-xlsx">전체 자료 Excel 내보내기</button>
|
||||
<button data-action="import-xlsx">Excel 표 불러오기</button>
|
||||
<button data-action="excel-files">엑셀물량 파일목록</button>
|
||||
<button data-action="estplus-load">EstPlus 데이타 불러오기(구버전 .est)</button>
|
||||
<button data-action="estplus-convert" title="Ctrl+Shift+F12">Est Plus데이타로 저장하기(.est)</button>
|
||||
<button data-action="estplus-compare" title="Ctrl+Alt+E">Est Plus와 단가비교</button>
|
||||
<button data-action="excel-map-open">Excel 내역 열 맞춰 가져오기</button>
|
||||
<button data-action="xml-check">조달청 XML 검사·자리수 자동 수정</button>
|
||||
<button data-action="export-xml">조달청 XML 내보내기</button>
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
// 구버전 EST Plus 레코드 형식(spec_estplus.md): 머리·표별 가변부 자리, Extended·ShortString 읽기·쓰기, 레코드 풀기.
|
||||
const { cp949 } = require("./export-estx.cjs");
|
||||
|
||||
const HEAD = {
|
||||
394: {
|
||||
code: [10, 11],
|
||||
name: [22, 50],
|
||||
size: [73, 50],
|
||||
unite: [124, 8],
|
||||
bigo: [133, 20],
|
||||
memo1: 154,
|
||||
mul: 162,
|
||||
memo2: 166,
|
||||
page: 170,
|
||||
checked: 172,
|
||||
newItem: 173,
|
||||
check: 174,
|
||||
sheet: 175,
|
||||
kind: 189,
|
||||
},
|
||||
565: {
|
||||
code: [10, 17],
|
||||
name: [28, 50],
|
||||
size: [79, 100],
|
||||
unite: [180, 10],
|
||||
bigo: [191, 50],
|
||||
memo1: 242,
|
||||
mul: 250,
|
||||
memo2: 254,
|
||||
page: 258,
|
||||
checked: 260,
|
||||
newItem: 261,
|
||||
check: 262,
|
||||
sheet: 263,
|
||||
kind: 287,
|
||||
},
|
||||
};
|
||||
const AMOUNTS = ["GN", "TU", "TC", "NU", "NC", "GU", "GC", "JU", "JC"];
|
||||
// 표별 가변부: [필드, 형, 394 자리, 565 자리] — X 실수 · L 정수 · b 부호바이트 · B 바이트 · S 문자(길이 394/565)
|
||||
const BODY = {
|
||||
dtm: [
|
||||
["Level", "b", 190, 288],
|
||||
["Pr", "S", 213, 311, 4, 24],
|
||||
...AMOUNTS.map((f, i) => [f, "X", 218 + 10 * i, 336 + 10 * i]),
|
||||
["SKind", "L", 368, 486],
|
||||
["OCheck", "L", 372, 490],
|
||||
["SCheck", "L", 376, 494],
|
||||
["Gangub", "L", 380, 498],
|
||||
["Biul", "X", 384, 508],
|
||||
],
|
||||
hdt: [
|
||||
["Level", "b", 190, 310],
|
||||
...AMOUNTS.map((f, i) => [f, "X", 191 + 10 * i, 311 + 10 * i]),
|
||||
["SKind", "L", 281, 441],
|
||||
["OCheck", "L", 285, 445],
|
||||
["SCheck", "L", 289, 449],
|
||||
["Gangub", "L", 293, 453],
|
||||
["Biul", "X", 297, 463],
|
||||
],
|
||||
cct: [
|
||||
["Level", "b", 190, 310],
|
||||
["Diget", "b", 191, 311],
|
||||
["DigetIndex", "b", 192, 312],
|
||||
["TC", "X", 193, 313],
|
||||
["NC", "X", 203, 323],
|
||||
["GC", "X", 213, 333],
|
||||
["JC", "X", 223, 343],
|
||||
["SKind", "L", 234, 364],
|
||||
["OCheck", "L", 238, 368],
|
||||
["SCheck", "L", 242, 372],
|
||||
["Biul", "X", 250, 380],
|
||||
],
|
||||
mdt: [
|
||||
["X190", "X", 190, 288],
|
||||
["X200", "X", 200, 298],
|
||||
["X210", "X", 210, 308],
|
||||
["X220", "X", 220, 318],
|
||||
],
|
||||
ldt: [["TC", "X", 230, 328]],
|
||||
sdt: [
|
||||
...[1, 2, 3, 4, 5, 6].map((n) => [`Cost${n}`, "X", 180 + 10 * n, 278 + 10 * n]),
|
||||
["TC", "X", 250, 348],
|
||||
...[1, 2, 3, 4, 5, 6].map((n) => [`Page${n}`, "S", 270 + 11 * n, 386 + 25 * n, 10, 24]),
|
||||
],
|
||||
sil: [
|
||||
["TC", "X", 211, 288],
|
||||
["NC", "X", 231, 298],
|
||||
["GC", "X", 251, 308],
|
||||
["JC", "X", 271, 318],
|
||||
["NCRatio", "X", 297, 368],
|
||||
],
|
||||
sti: [
|
||||
["GN", "X", 0, 288],
|
||||
["TC", "X", 0, 298],
|
||||
["NC", "X", 0, 308],
|
||||
["GC", "X", 0, 318],
|
||||
["JC", "X", 0, 328],
|
||||
["Ratio", "X", 0, 378],
|
||||
["NachalRatio", "X", 0, 388],
|
||||
["Diget", "B", 0, 398],
|
||||
["DigetIndex", "B", 0, 399],
|
||||
],
|
||||
wdt: [
|
||||
["Cost", "X", 190, 288],
|
||||
["Diget", "X", 250, 348],
|
||||
["EndLineIndex", "B", 280, 408],
|
||||
["Bimog1", "S", 281, 411, 10, 24],
|
||||
["Bimog2", "S", 292, 436, 10, 24],
|
||||
["Gubun", "S", 303, 461, 10, 24],
|
||||
],
|
||||
};
|
||||
const KIND = {
|
||||
dtm: 1,
|
||||
hdt: 2,
|
||||
cct: 3,
|
||||
mdt: 4,
|
||||
sdt: 5,
|
||||
ldt: 6,
|
||||
wdt: 7,
|
||||
sil: 21,
|
||||
swdt: 22,
|
||||
sti: 101,
|
||||
};
|
||||
const LINK_IN = { 2: 103, 3: 104, 4: 105, 20: 220, 51: 68 };
|
||||
for (let i = 11; i <= 18; i++) LINK_IN[i] = 200 + i;
|
||||
const LINK_OUT = Object.fromEntries(Object.entries(LINK_IN).map(([a, b]) => [b, Number(a)]));
|
||||
|
||||
// Extended(80비트) ↔ 수
|
||||
function readX(buf, at) {
|
||||
const lo = buf.readUInt32LE(at),
|
||||
hi = buf.readUInt32LE(at + 4),
|
||||
se = buf.readUInt16LE(at + 8),
|
||||
exp = se & 0x7fff;
|
||||
if (!exp && !hi && !lo) return 0;
|
||||
const v = (hi * 2 ** 32 + lo) * 2 ** (exp - 16383 - 63);
|
||||
return se & 0x8000 ? -v : v;
|
||||
}
|
||||
function writeX(buf, at, value) {
|
||||
let v = Number(value) || 0;
|
||||
if (!v || !Number.isFinite(v)) return buf.fill(0, at, at + 10);
|
||||
const sign = v < 0 ? 0x8000 : 0;
|
||||
v = Math.abs(v);
|
||||
let exp = Math.floor(Math.log2(v));
|
||||
if (2 ** exp > v) exp--;
|
||||
if (2 ** (exp + 1) <= v) exp++;
|
||||
const mant = BigInt(Math.round(v * 2 ** (63 - exp))); // 53 비트 정밀도 안
|
||||
buf.writeUInt32LE(Number(mant & 0xffffffffn), at);
|
||||
buf.writeUInt32LE(Number((mant >> 32n) & 0xffffffffn), at + 4);
|
||||
buf.writeUInt16LE(sign | (exp + 16383), at + 8);
|
||||
}
|
||||
const decoder = new TextDecoder("euc-kr");
|
||||
const readS = (buf, at, max) =>
|
||||
decoder.decode(buf.subarray(at + 1, at + 1 + Math.min(buf[at], max)));
|
||||
function writeS(buf, at, max, text) {
|
||||
const out = [];
|
||||
let used = 0;
|
||||
for (const ch of String(text ?? "")) {
|
||||
const bytes = cp949(ch);
|
||||
if (used + bytes.length > max) break;
|
||||
out.push(bytes);
|
||||
used += bytes.length;
|
||||
}
|
||||
buf[at] = used;
|
||||
Buffer.concat(out).copy(buf, at + 1);
|
||||
}
|
||||
|
||||
function records(buf, size) {
|
||||
const out = [];
|
||||
for (let at = 0; at + size <= buf.length; at += size) out.push(buf.subarray(at, at + size));
|
||||
return out;
|
||||
}
|
||||
// 레코드 → 머리 + 가변부 값
|
||||
function decode(rec, size, type) {
|
||||
const h = HEAD[size],
|
||||
s = (k) => readS(rec, h[k][0], h[k][1]);
|
||||
const row = {
|
||||
Index: rec.readInt32LE(5),
|
||||
link: rec[9],
|
||||
code: s("code").trim(),
|
||||
Name: s("name"),
|
||||
Size: s("size"),
|
||||
Unite: s("unite"),
|
||||
Bigo: s("bigo"),
|
||||
MullangIndex: rec.readInt32LE(h.mul),
|
||||
PageSkip: rec[h.page],
|
||||
Checked: rec[h.checked] > 0,
|
||||
NewItem: rec[h.newItem] > 0,
|
||||
check: rec[h.check],
|
||||
ExcelSheetIndex: rec[h.sheet],
|
||||
};
|
||||
for (const [f, t, a394, a565, n394, n565] of BODY[type] || []) {
|
||||
const at = size === 394 ? a394 : a565;
|
||||
if (!at && size === 394) continue;
|
||||
row[f] =
|
||||
t === "X"
|
||||
? readX(rec, at)
|
||||
: t === "L"
|
||||
? rec.readInt32LE(at)
|
||||
: t === "b"
|
||||
? rec.readInt8(at)
|
||||
: t === "B"
|
||||
? rec[at]
|
||||
: readS(rec, at, size === 394 ? n394 : n565).trim();
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HEAD,
|
||||
AMOUNTS,
|
||||
BODY,
|
||||
KIND,
|
||||
LINK_IN,
|
||||
LINK_OUT,
|
||||
readX,
|
||||
writeX,
|
||||
decoder,
|
||||
readS,
|
||||
writeS,
|
||||
records,
|
||||
decode,
|
||||
};
|
||||
@@ -0,0 +1,592 @@
|
||||
// 구버전 EST Plus 자료(.est/.ebk = ZIP, 표마다 Delphi 고정 레코드 파일 — tmp 역분석 spec_estplus.md) 읽기·쓰기.
|
||||
// 레코드 394 바이트(기본) · 565 바이트(.ini [공통] data_version = 3). 문자 = ShortString(cp949), 금액 = Extended(10바이트).
|
||||
// 읽기(mnuFileEstPlusLoad → 0x112ee48 · 표별 변환 0xd91504): .dtm 내역 · .hdt 일위 · .cct 단산 · .mdt 기계 · .ldt 노임 · .sdt 자재 ·
|
||||
// .sil 표준 · .sti 견적(565 만) · .wdt/.swdt 원가 · c* = 설계변경(「_」 칸). 원본처럼 대부분 행의 코드를 비우므로, 웹은 호표 머리에
|
||||
// 코드를 매기고 본문 연결 행은 같은 품명·규격의 원천 코드를 찾아 붙인다(웹 보충). 메모(.MEM)·.mot(기계 기준)는 읽지 않는다.
|
||||
// 쓰기(ActionConvertToEstPlus → 0xd96354/0xd960b8): 394 판으로 위 표 파일 + .ini 를 ZIP 으로.
|
||||
// 단가비교(TfrmCompare 0xf8998c): 고른 종류 파일의 Index > 0 레코드 노무·재료·경비 금액을 차례로.
|
||||
const { pack, unpack } = require("./archive.cjs");
|
||||
const { cp949 } = require("./export-estx.cjs");
|
||||
|
||||
const num = (v) => Number(v) || 0;
|
||||
const {
|
||||
HEAD,
|
||||
AMOUNTS,
|
||||
BODY,
|
||||
KIND,
|
||||
LINK_IN,
|
||||
LINK_OUT,
|
||||
readX,
|
||||
writeX,
|
||||
decoder,
|
||||
writeS,
|
||||
records,
|
||||
decode,
|
||||
} = require("./legacy-estplus-layout.cjs");
|
||||
|
||||
const link = (v) => LINK_IN[v] ?? v;
|
||||
const percent = (r, li, skip) =>
|
||||
["%", "%"].includes(r.Unite) && !skip.includes(li) && r.GN < 1 ? r.GN * 100 : r.GN;
|
||||
const clean = (t) =>
|
||||
String(t ?? "")
|
||||
.replace(/\n/g, "")
|
||||
.trim();
|
||||
const pad = (p, n) => p + String(n).padStart(10, "0");
|
||||
// 연결 코드 줄이기(0xd86924): A 로 시작하면 끝의 0 을 1자 남을 때까지
|
||||
const trimA = (code) => (/^A/.test(code) ? code.replace(/0+$/, "") || "A" : code);
|
||||
|
||||
function convert(files, name) {
|
||||
const warnings = new Set();
|
||||
const ini = [...files].find(([k]) => k.endsWith(".ini"))?.[1];
|
||||
const iniText = ini ? decoder.decode(ini) : "";
|
||||
const iniValue = (key) =>
|
||||
new RegExp(`^\\s*${key}\\s*=\\s*(.*)$`, "mi").exec(iniText)?.[1]?.trim();
|
||||
const size = Number(iniValue("data_version")) === 3 ? 565 : 394;
|
||||
const file = (ext) => [...files].find(([k]) => k.endsWith("." + ext))?.[1];
|
||||
const parse = (ext, type = ext) => {
|
||||
const buf = file(ext);
|
||||
if (!buf) return [];
|
||||
if (buf.length % size) warnings.add(`${ext}: 레코드 크기(${size})로 나누어지지 않습니다.`);
|
||||
return records(buf, size).map((r) => decode(r, size, type));
|
||||
};
|
||||
const t = {};
|
||||
const projectName = iniValue("project_title_name") || name;
|
||||
// 내역서
|
||||
const est = (rows) =>
|
||||
rows.map((r) => {
|
||||
let li = link(r.link),
|
||||
calc = 0;
|
||||
if (r.link === 64) ((li = 0), (calc = 1));
|
||||
const row = {
|
||||
Index: li === 57 && r.check ? r.check : r.Index,
|
||||
LinkIndex: li,
|
||||
Code: r.link === 21 ? r.code : "",
|
||||
Name: r.link === 56 ? projectName : clean(r.Name),
|
||||
Size: clean(r.Size),
|
||||
Unite: clean(r.Unite),
|
||||
Bigo: r.Bigo,
|
||||
Level: r.Level,
|
||||
Pr: r.Pr,
|
||||
...Object.fromEntries(AMOUNTS.map((f) => [f, r[f]])),
|
||||
SKind: r.SKind,
|
||||
OCheck: r.OCheck,
|
||||
SCheck: r.check > 0 ? 3 : r.SCheck,
|
||||
Gangub: r.Gangub,
|
||||
Biul: r.Biul,
|
||||
CalcIndex: calc,
|
||||
NewItem: r.NewItem,
|
||||
PageSkip: r.PageSkip,
|
||||
MullangIndex: r.MullangIndex,
|
||||
ExcelSheetIndex: r.ExcelSheetIndex,
|
||||
};
|
||||
row.GN = percent(row, li, [51, 52, 57, 58, 62, 64]);
|
||||
if ([51, 62].includes(li) && row.Code) row.Code = trimA(row.Code);
|
||||
return row;
|
||||
});
|
||||
t.EstMainDat = est(parse("dtm"));
|
||||
// 일위대가
|
||||
const heads = parse("hdt");
|
||||
t.HeadMainDat = heads.map((r) => {
|
||||
const li = r.link === 64 ? 0 : link(r.link);
|
||||
const row = {
|
||||
Index: r.Index,
|
||||
LinkIndex: li,
|
||||
Code: r.Index > 0 ? pad("U", r.Index) : [21, 62].includes(r.link) ? r.code : "",
|
||||
Name: clean(r.Name),
|
||||
Size: clean(r.Size),
|
||||
Unite: clean(r.Unite),
|
||||
Bigo: r.Bigo,
|
||||
Level: r.Level,
|
||||
...Object.fromEntries(AMOUNTS.map((f) => [f, r[f]])),
|
||||
SKind: r.SKind,
|
||||
OCheck: r.OCheck,
|
||||
SCheck: r.check > 0 ? 3 : r.SCheck,
|
||||
Gangub: r.Gangub,
|
||||
Biul: r.Biul,
|
||||
CalcIndex: r.link === 64 ? 1 : 0,
|
||||
MullangIndex: r.MullangIndex,
|
||||
ExcelSheetIndex: r.ExcelSheetIndex,
|
||||
};
|
||||
row.GN = percent(row, li, [51, 52, 57, 58, 62, 64]);
|
||||
if ([51, 52, 58, 62].includes(li) && row.Code && r.Index <= 0) row.Code = trimA(row.Code);
|
||||
return row;
|
||||
});
|
||||
t.HeadTitleDat = heads
|
||||
.filter((r) => r.Index > 0)
|
||||
.map((r) => ({
|
||||
Index: r.Index,
|
||||
Code: pad("U", r.Index),
|
||||
Name: clean(r.Name),
|
||||
Size: clean(r.Size),
|
||||
Unite: clean(r.Unite),
|
||||
TC: r.TC,
|
||||
NC: r.NC,
|
||||
GC: r.GC,
|
||||
JC: r.JC,
|
||||
OTC: r.TC,
|
||||
ONC: r.NC,
|
||||
OGC: r.GC,
|
||||
OJC: r.JC,
|
||||
Bigo: r.Bigo,
|
||||
NewItem: r.NewItem,
|
||||
UserInput: r.link === 64,
|
||||
}));
|
||||
// 단가산출: 한 줄 = Name+Size+Bigo+Unite 이어 붙임 · 머리는 꼬리표(#14 품명 · #19 규격 · #21 단위)로 나눔
|
||||
const costs = parse("cct");
|
||||
const tag = (line, c) => {
|
||||
const at = line.indexOf(c);
|
||||
return at < 0
|
||||
? ""
|
||||
: line
|
||||
.slice(at + 1)
|
||||
.split(/[\x00-\x1f]/)[0]
|
||||
.trim();
|
||||
};
|
||||
t.CostMainDat = costs.map((r) => {
|
||||
const line = r.Name + r.Size + r.Bigo + r.Unite;
|
||||
let index = r.Index === -4 ? -5 : r.Index,
|
||||
ocheck = r.OCheck,
|
||||
scheck = r.SCheck;
|
||||
if (index < 0 && index >= -5) {
|
||||
scheck = index;
|
||||
ocheck = index;
|
||||
index = 0;
|
||||
}
|
||||
return {
|
||||
Index: index,
|
||||
LinkIndex: r.link === 64 ? 0 : link(r.link),
|
||||
Code: r.Index > 0 ? pad("D", r.Index) : r.link === 21 ? r.code : "",
|
||||
...(r.Index > 0
|
||||
? {
|
||||
Name: tag(line, "\x0e") || clean(r.Name),
|
||||
Size: tag(line, "\x13"),
|
||||
Unite: tag(line, "\x15"),
|
||||
}
|
||||
: { LineData: line }),
|
||||
Level: r.Level,
|
||||
Diget: r.Diget,
|
||||
DigetIndex: r.DigetIndex,
|
||||
TC: r.TC,
|
||||
NC: r.NC,
|
||||
GC: r.GC,
|
||||
JC: r.JC,
|
||||
SKind: r.SKind,
|
||||
OCheck: ocheck,
|
||||
SCheck: scheck,
|
||||
Biul: r.Biul,
|
||||
CalcIndex: r.link === 64 ? 1 : 0,
|
||||
};
|
||||
});
|
||||
t.CostTitleDat = t.CostMainDat.filter((r) => r.Index > 0).map((r) => ({
|
||||
Index: r.Index,
|
||||
Code: r.Code,
|
||||
Name: r.Name,
|
||||
Size: r.Size,
|
||||
Unite: r.Unite,
|
||||
TC: r.TC,
|
||||
NC: r.NC,
|
||||
GC: r.GC,
|
||||
JC: r.JC,
|
||||
OTC: r.TC,
|
||||
ONC: r.NC,
|
||||
OGC: r.GC,
|
||||
OJC: r.JC,
|
||||
}));
|
||||
// 기계경비: 머리 X220 TC · 210 NC · 200 GC · 190 JC, 본문 −3 손료 · −2 연료 · −1 노무
|
||||
t.MachMainDat = [];
|
||||
t.MachTitleDat = [];
|
||||
for (const r of parse("mdt")) {
|
||||
if (!r.code && !clean(r.Name) && !r.X220) continue;
|
||||
const base = {
|
||||
LinkIndex: link(r.link),
|
||||
Code: "",
|
||||
Name: clean(r.Name),
|
||||
CalcIndex: r.link === 64 ? 1 : 0,
|
||||
};
|
||||
if (r.Index > 0) {
|
||||
if (t.MachTitleDat.length) t.MachMainDat.push({ Name: " " });
|
||||
const head = {
|
||||
...base,
|
||||
Index: r.Index,
|
||||
Code: pad("E", r.Index),
|
||||
Size: clean(r.Size),
|
||||
Unite: "hr",
|
||||
TC: r.X220,
|
||||
NC: r.X210,
|
||||
GC: r.X200,
|
||||
JC: r.X190,
|
||||
};
|
||||
t.MachMainDat.push({ ...head, Selected: 0 });
|
||||
t.MachTitleDat.push({ ...head, UserInput: r.link === 64 });
|
||||
} else if (r.Index === -3)
|
||||
t.MachMainDat.push({
|
||||
...base,
|
||||
Index: 0,
|
||||
Selected: 1,
|
||||
Size: "",
|
||||
GN: r.X200,
|
||||
Ratio: r.X210,
|
||||
TU: r.X190,
|
||||
JC: r.X220,
|
||||
Unite: clean(r.Size).replace(/\\/g, "₩"),
|
||||
});
|
||||
else if (r.Index === -2)
|
||||
t.MachMainDat.push({
|
||||
...base,
|
||||
Index: 0,
|
||||
Selected: 6,
|
||||
Size: clean(r.Size),
|
||||
GN: r.X190,
|
||||
TU: r.X200,
|
||||
GC: r.X220,
|
||||
Unite: /^(경|휘|중)/.test(clean(r.Unite))
|
||||
? "ℓ"
|
||||
: /잡/.test(base.Name)
|
||||
? "%"
|
||||
: clean(r.Unite),
|
||||
});
|
||||
else if (r.Index === -1)
|
||||
t.MachMainDat.push({
|
||||
...base,
|
||||
Index: 0,
|
||||
Selected: 11,
|
||||
Size: clean(r.Size),
|
||||
GN: r.X190,
|
||||
TU: r.X200,
|
||||
NC: r.X220,
|
||||
NCGasu: Number(clean(r.Size).replace(/[^\d.]/g, "")) || 1,
|
||||
Unite: clean(r.Unite) || "인",
|
||||
});
|
||||
}
|
||||
// 본문 행 TC := NC + GC + JC
|
||||
for (const r of t.MachMainDat)
|
||||
if ([1, 6, 11].includes(r.Selected)) {
|
||||
for (const f of ["NC", "GC", "JC"]) r[f] = num(r[f]);
|
||||
r.TC = r.NC + r.GC + r.JC;
|
||||
}
|
||||
// 기초단가
|
||||
const skip = (r) => !r.code && !clean(r.Name);
|
||||
t.LaborcDat = parse("ldt")
|
||||
.filter((r) => !skip(r))
|
||||
.map((r, i) => ({
|
||||
Index: i + 1,
|
||||
Code: pad("L", i + 1),
|
||||
Name: clean(r.Name),
|
||||
Size: clean(r.Size),
|
||||
Unite: "인",
|
||||
Cost1: r.TC,
|
||||
TC: r.TC,
|
||||
OTC: r.TC,
|
||||
ValueIndex: 0,
|
||||
Gangub: 0,
|
||||
Bigo: r.Bigo,
|
||||
}));
|
||||
t.StuffcDat = parse("sdt")
|
||||
.filter((r) => !skip(r))
|
||||
.map((r, i) => ({
|
||||
Index: i + 1,
|
||||
Code: pad("M", i + 1),
|
||||
Name: clean(r.Name),
|
||||
Size: clean(r.Size),
|
||||
Unite: clean(r.Unite),
|
||||
...Object.fromEntries(
|
||||
[1, 2, 3, 4, 5, 6].flatMap((n) => [
|
||||
[`Cost${n}`, r[`Cost${n}`]],
|
||||
[`Page${n}`, r[`Page${n}`]],
|
||||
]),
|
||||
),
|
||||
TC: r.TC,
|
||||
OTC: r.TC,
|
||||
Bigo: r.Bigo,
|
||||
}));
|
||||
t.SilJukDat = parse("sil")
|
||||
.filter((r) => !skip(r))
|
||||
.map((r, i) => {
|
||||
const only = !r.TC;
|
||||
const v = only
|
||||
? { TC: r.JC, NC: 0, GC: 0, JC: r.JC }
|
||||
: { TC: r.TC, NC: r.NC, GC: r.GC, JC: r.JC };
|
||||
return {
|
||||
Index: i + 1,
|
||||
Code: r.code,
|
||||
Name: clean(r.Name),
|
||||
Size: clean(r.Size),
|
||||
Unite: clean(r.Unite),
|
||||
Cost1: v.TC,
|
||||
...v,
|
||||
OTC: v.TC,
|
||||
ONC: v.NC,
|
||||
OGC: v.GC,
|
||||
OJC: v.JC,
|
||||
NCRatio: r.NCRatio,
|
||||
};
|
||||
});
|
||||
t.EstiMateDat =
|
||||
size === 565
|
||||
? parse("sti")
|
||||
.filter((r) => !skip(r))
|
||||
.map((r, i) => ({
|
||||
Index: i + 1,
|
||||
Code: r.code,
|
||||
Name: clean(r.Name),
|
||||
Size: clean(r.Size),
|
||||
Unite: clean(r.Unite),
|
||||
GN: r.GN,
|
||||
TC: r.TC,
|
||||
NC: r.NC,
|
||||
GC: r.GC,
|
||||
JC: r.JC,
|
||||
OTC: r.TC,
|
||||
ONC: r.NC,
|
||||
OGC: r.GC,
|
||||
OJC: r.JC,
|
||||
Ratio: r.Ratio,
|
||||
NachalRatio: r.NachalRatio,
|
||||
Diget: r.Diget,
|
||||
DigetIndex: r.DigetIndex,
|
||||
}))
|
||||
: [];
|
||||
const wonga = file("wdt") ? parse("wdt") : parse("swdt", "wdt");
|
||||
t.Wonga = wonga.map((r) => ({
|
||||
Index: r.Index,
|
||||
LinkIndex: r.link,
|
||||
Name: clean(r.Name),
|
||||
VSusic: clean(r.Size),
|
||||
VGungu: clean(r.Size),
|
||||
Biul: clean(r.Unite),
|
||||
Youl: clean(r.Unite),
|
||||
Checked: r.Checked,
|
||||
Cost: r.Cost,
|
||||
Diget: Math.round(r.Diget),
|
||||
EndLineIndex: r.EndLineIndex,
|
||||
Bimog1: r.Bimog1,
|
||||
Bimog2: r.Bimog2,
|
||||
Gubun: r.Gubun,
|
||||
Pr: "",
|
||||
GabgiInput: false,
|
||||
CalcIndex: r.link === 64 ? 1 : 0,
|
||||
}));
|
||||
// 설계변경(c*) → 같은 표의 「_」 칸, 차례대로
|
||||
const change = {
|
||||
cdtm: ["EstMainDat", "dtm"],
|
||||
chdt: ["HeadMainDat", "hdt"],
|
||||
ccct: ["CostMainDat", "cct"],
|
||||
};
|
||||
let changed = false;
|
||||
for (const [ext, [table, type]] of Object.entries(change)) {
|
||||
const rows = parse(ext, type);
|
||||
if (!rows.length) continue;
|
||||
changed = true;
|
||||
rows.forEach((r, i) => {
|
||||
const target = (t[table][i] ||= {});
|
||||
target.Index_ = r.Index;
|
||||
for (const f of ["Name", "Size", "Unite"]) target[f + "_"] = clean(r[f]);
|
||||
for (const f of AMOUNTS) if (r[f] != null) target[f + "_"] = r[f];
|
||||
});
|
||||
}
|
||||
// 본문 연결 행: 같은 품명·규격의 원천 코드(웹 보충)
|
||||
const source = {
|
||||
5: t.StuffcDat,
|
||||
6: t.LaborcDat,
|
||||
21: t.SilJukDat,
|
||||
101: t.EstiMateDat,
|
||||
103: t.HeadTitleDat,
|
||||
104: t.CostTitleDat,
|
||||
105: t.MachTitleDat,
|
||||
};
|
||||
const key = (r) => `${clean(r.Name).replace(/\s/g, "")}|${clean(r.Size).replace(/\s/g, "")}`;
|
||||
const maps = Object.fromEntries(
|
||||
Object.entries(source).map(([k, rows]) => [k, new Map(rows.map((r) => [key(r), r.Code]))]),
|
||||
);
|
||||
let linked = 0,
|
||||
missing = 0;
|
||||
for (const rows of [t.EstMainDat, t.HeadMainDat])
|
||||
for (const r of rows)
|
||||
if (!(num(r.Index) > 0) && maps[r.LinkIndex] && !r.Code) {
|
||||
const code = maps[r.LinkIndex].get(key(r));
|
||||
if (code) ((r.Code = code), linked++);
|
||||
else missing++;
|
||||
}
|
||||
if (missing)
|
||||
warnings.add(
|
||||
`연결 원천을 품명·규격으로 찾지 못한 행 ${missing}개(코드 없음 — 저장 금액 유지).`,
|
||||
);
|
||||
if (files.has(".mem") || [...files.keys()].some((k) => k.endsWith(".mem")))
|
||||
warnings.add("근거 메모(.MEM)는 읽지 않았습니다.");
|
||||
if (file("mot")) warnings.add("기계경비 기준표(.mot)는 읽지 않았습니다.");
|
||||
const mode = Number(iniValue("last_work_mode")) || 1;
|
||||
const standard = /^(1|true)$/i.test(iniValue("siljuk_est_mode") || "");
|
||||
for (const k of ["MachGijunDat", "CadMainDat", "CadTitleDat"]) t[k] ||= [];
|
||||
return {
|
||||
schema: "est-web-2",
|
||||
name: projectName,
|
||||
tables: t,
|
||||
options: {
|
||||
SourceRefresh: false,
|
||||
AutoCalc: false,
|
||||
TotalFix: false,
|
||||
VerFormat: true,
|
||||
WorkMode: standard ? (mode === 2 ? 4 : 3) : mode,
|
||||
...(changed && { Mode: "설계변경" }),
|
||||
},
|
||||
quantities: [],
|
||||
quantitySheets: [],
|
||||
progress: [],
|
||||
annual: [],
|
||||
schedule: [],
|
||||
drawings: [],
|
||||
importWarnings: [
|
||||
`구버전 EST Plus 자료(레코드 ${size}바이트)를 변환했습니다. 저장된 금액을 그대로 두려고 자동계산을 껐습니다. 품명·규격으로 연결 ${linked}개.`,
|
||||
...warnings,
|
||||
],
|
||||
};
|
||||
}
|
||||
const lower = (files) => new Map([...files].map(([k, v]) => [k.toLowerCase(), v]));
|
||||
function read(buffer, name = "EST Plus 자료") {
|
||||
return convert(lower(unpack(buffer)), name);
|
||||
}
|
||||
|
||||
// 쓰기(394 판)
|
||||
function record(type, row) {
|
||||
const rec = Buffer.alloc(394),
|
||||
h = HEAD[394];
|
||||
rec.writeInt32LE(-1, 0);
|
||||
rec.writeInt32LE(Math.trunc(num(row.Index)), 5);
|
||||
rec[9] = row.UserInput ? 64 : (LINK_OUT[num(row.LinkIndex)] ?? num(row.LinkIndex)) & 0xff;
|
||||
for (const [k, f] of [
|
||||
["code", "Code"],
|
||||
["name", "Name"],
|
||||
["size", "Size"],
|
||||
["unite", "Unite"],
|
||||
["bigo", "Bigo"],
|
||||
])
|
||||
writeS(rec, h[k][0], h[k][1], row[f]);
|
||||
rec.writeInt32LE(-1, h.memo1);
|
||||
rec.writeInt32LE(Math.trunc(num(row.MullangIndex)), h.mul);
|
||||
rec.writeInt32LE(-1, h.memo2);
|
||||
rec[h.page] = num(row.PageSkip) & 0xff;
|
||||
rec[171] = 1;
|
||||
rec[h.checked] = row.Checked ? 1 : 0;
|
||||
rec[h.newItem] = row.NewItem ? 1 : 0;
|
||||
rec[h.sheet] = num(row.ExcelSheetIndex) & 0xff;
|
||||
for (const at of [177, 181, 185]) rec.writeInt32LE(-1, at);
|
||||
rec[h.kind] = KIND[type];
|
||||
for (const [f, t, at, , n] of BODY[type === "swdt" ? "wdt" : type] || []) {
|
||||
if (!at) continue;
|
||||
const v = row[f];
|
||||
if (t === "X") writeX(rec, at, v);
|
||||
else if (t === "L") rec.writeInt32LE(Math.trunc(num(v)), at);
|
||||
else if (t === "b") rec.writeInt8(Math.max(-128, Math.min(127, Math.trunc(num(v)))), at);
|
||||
else if (t === "B") rec[at] = num(v) & 0xff;
|
||||
else writeS(rec, at, n, v);
|
||||
}
|
||||
return rec;
|
||||
}
|
||||
// 단가산출 글줄을 Name(50)·Size(50)·Bigo(20)·Unite(8) 칸에 차례로 나눠 담음(읽을 때 이어 붙이면 같은 줄)
|
||||
function splitLine(text) {
|
||||
const parts = ["", "", "", ""],
|
||||
caps = [50, 50, 20, 8];
|
||||
let k = 0,
|
||||
used = 0;
|
||||
for (const ch of String(text ?? "")) {
|
||||
const n = cp949(ch).length;
|
||||
while (k < 4 && used + n > caps[k]) ((k += 1), (used = 0));
|
||||
if (k >= 4) break;
|
||||
parts[k] += ch;
|
||||
used += n;
|
||||
}
|
||||
return { Name: parts[0], Size: parts[1], Bigo: parts[2], Unite: parts[3] };
|
||||
}
|
||||
function write(project) {
|
||||
const t = project.tables,
|
||||
out = [];
|
||||
const add = (ext, type, rows) =>
|
||||
rows.length && out.push([`data.${ext}`, Buffer.concat(rows.map((r) => record(type, r)))]);
|
||||
add("dtm", "dtm", t.EstMainDat || []);
|
||||
add("hdt", "hdt", t.HeadMainDat || []);
|
||||
add(
|
||||
"cct",
|
||||
"cct",
|
||||
(t.CostMainDat || []).map((r) => ({
|
||||
...r,
|
||||
...splitLine(
|
||||
num(r.Index) > 0 && !r.LineData
|
||||
? `\x0e${r.Name ?? ""}\x0e\x13${r.Size ?? ""}\x13\x15${r.Unite ?? ""}\x15`
|
||||
: r.LineData,
|
||||
),
|
||||
Index:
|
||||
num(r.Index) > 0
|
||||
? r.Index
|
||||
: num(r.SCheck) < 0
|
||||
? num(r.SCheck) === -5
|
||||
? -4
|
||||
: r.SCheck
|
||||
: r.Index,
|
||||
})),
|
||||
);
|
||||
add(
|
||||
"mdt",
|
||||
"mdt",
|
||||
(t.MachMainDat || [])
|
||||
.filter((r) => String(r.Name ?? "").trim() || num(r.Index) > 0)
|
||||
.map((r) => {
|
||||
const s = num(r.Selected);
|
||||
if (num(r.Index) > 0) return { ...r, X220: r.TC, X210: r.NC, X200: r.GC, X190: r.JC };
|
||||
if (s === 1)
|
||||
return {
|
||||
...r,
|
||||
Index: -3,
|
||||
Size: r.Unite,
|
||||
X200: r.GN,
|
||||
X210: r.Ratio,
|
||||
X190: r.TU,
|
||||
X220: r.JC,
|
||||
};
|
||||
if (s === 6) return { ...r, Index: -2, X190: r.GN, X200: r.TU, X220: r.GC };
|
||||
return { ...r, Index: -1, X190: r.GN, X200: r.TU, X220: r.NC };
|
||||
}),
|
||||
);
|
||||
add("ldt", "ldt", t.LaborcDat || []);
|
||||
add("sdt", "sdt", t.StuffcDat || []);
|
||||
add("sil", "sil", t.SilJukDat || []);
|
||||
add(
|
||||
"wdt",
|
||||
"wdt",
|
||||
(t.Wonga || []).map((r) => ({
|
||||
...r,
|
||||
Size: r.VSusic,
|
||||
Unite: r.Youl ?? r.Biul,
|
||||
Bigo: String(Math.trunc(num(r.Cost))),
|
||||
})),
|
||||
);
|
||||
const mode = num(project.options?.WorkMode) || 1;
|
||||
out.push([
|
||||
"data.ini",
|
||||
cp949(
|
||||
`[공통]\r\nlast_work_mode=${mode > 2 ? mode - 2 : mode}\r\nsiljuk_est_mode=${mode > 2 ? 1 : 0}\r\ndata_version=0\r\nproject_title_name=${project.name ?? ""}\r\n`,
|
||||
),
|
||||
]);
|
||||
return pack(new Map(out));
|
||||
}
|
||||
|
||||
// 단가비교: 종류(0 기계 · 1 단산 · 2 일위 · 3 내역) 파일의 Index > 0 레코드 노무·재료·경비
|
||||
const COMPARE = [
|
||||
["mdt", 210, 200, 190],
|
||||
["cct", 203, 213, 223],
|
||||
["hdt", 231, 251, 271],
|
||||
["dtm", 258, 278, 298],
|
||||
];
|
||||
function compare(buffer, kind) {
|
||||
const files = lower(unpack(buffer)),
|
||||
[ext, n, g, j] = COMPARE[kind] || [];
|
||||
if (!ext) throw Error("비교할 항목을 선택하세요.");
|
||||
const ini = [...files].find(([k]) => k.endsWith(".ini"))?.[1];
|
||||
if (ini && /data_version\s*=\s*3/i.test(decoder.decode(ini)))
|
||||
throw Error("565 바이트 판 자료의 단가비교 자리는 확인되지 않았습니다.");
|
||||
const buf = [...files].find(([k]) => k.endsWith("." + ext))?.[1];
|
||||
if (!buf) throw Error(`비교 파일(.${ext})이 없습니다.`);
|
||||
return records(buf, 394)
|
||||
.filter((r) => r.readInt32LE(5) > 0)
|
||||
.map((r) => ({ ONU: readX(r, n), OGU: readX(r, g), OJU: readX(r, j) }));
|
||||
}
|
||||
module.exports = { read, write, compare, readX, writeX, splitLine };
|
||||
@@ -15,6 +15,7 @@ const EstActionsMore = (() => {
|
||||
typeof EstDongwonCheck !== "undefined" && EstDongwonCheck,
|
||||
typeof EstExcelFiles !== "undefined" && EstExcelFiles,
|
||||
typeof EstOptionAll !== "undefined" && EstOptionAll,
|
||||
typeof EstEstPlus !== "undefined" && EstEstPlus,
|
||||
].filter(Boolean);
|
||||
function handle(name, state, node) {
|
||||
for (const module of modules()) if (module.handle(name, state, node)) return true;
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// 원본 구버전 EST Plus 자료 3종(spec_misc §2 · spec_estplus.md): 「EstPlus 데이타 불러오기」(mnuFileEstPlusLoad — .est 를 풀어
|
||||
// 표마다 변환해 새 작업으로), 「Est Plus와 단가비교」(ActionEstCompare Ctrl+Alt+E → TfrmCompare: 비교할 항목 4가지 중 고르고
|
||||
// 비교파일선택 → 이 공사 머리 행과 구형 파일 Index > 0 레코드를 차례로 짝지어 합계가 다르면 표시, 더블클릭 = 그 자료로 이동),
|
||||
// 「EstPlus 데이타로 저장」(ActionConvertToEstPlus Ctrl+Shift+F12 → 394 바이트 판 .est). 읽기·쓰기는 서버 legacy-estplus.cjs.
|
||||
const EstEstPlus = (() => {
|
||||
const num = (v) => Number(v) || 0;
|
||||
const KINDS = [
|
||||
["기계경비", "MachMainDat"],
|
||||
["단가산출", "CostTitleDat"],
|
||||
["일위대가", "HeadMainDat"],
|
||||
["내역서", "EstMainDat"],
|
||||
];
|
||||
const pickFile = () =>
|
||||
new Promise((resolve) => {
|
||||
const picker = document.createElement("input");
|
||||
picker.type = "file";
|
||||
picker.accept = ".est,.ebk";
|
||||
picker.addEventListener("change", () => resolve(picker.files[0]));
|
||||
picker.click();
|
||||
});
|
||||
|
||||
// 이 공사 쪽 행(Index > 0)과 구형 값을 차례로 짝지음 · 확인 = 합계 다름(정확 비교)
|
||||
function pair(project, kind, legacy) {
|
||||
const [, table] = KINDS[kind],
|
||||
rows = (project.tables[table] || [])
|
||||
.map((row, index) => ({ row, index }))
|
||||
.filter(({ row }) => num(row.Index) > 0);
|
||||
return rows.map(({ row, index }, i) => {
|
||||
const o = legacy[i] || { ONU: 0, OGU: 0, OJU: 0 },
|
||||
mine = num(row.NC) + num(row.GC) + num(row.JC) + num(row.SC);
|
||||
return {
|
||||
index,
|
||||
Index: row.Index,
|
||||
Code: row.Code,
|
||||
Name: row.Name || EstText.clean(row.LineData || ""),
|
||||
Size: row.Size,
|
||||
Unite: row.Unite,
|
||||
NC: num(row.NC),
|
||||
GC: num(row.GC),
|
||||
JC: num(row.JC),
|
||||
SC: num(row.SC),
|
||||
...o,
|
||||
UsesCheck: mine !== o.ONU + o.OGU + o.OJU,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
let compared = { kind: 0, rows: [] };
|
||||
function draw() {
|
||||
const { esc, number } = EstApp,
|
||||
{ kind, rows } = compared;
|
||||
EstApp.showDialog(
|
||||
"단가비교",
|
||||
`<div class="business-tools"><b>비교할항목 선택</b>${KINDS.map(([label], i) => `<label><input type="radio" name="ep-kind" value="${i}" ${i === kind ? "checked" : ""}> ${label}</label>`).join("")}<button data-action="estplus-compare-file">비교파일선택</button><b id="estplus-count">${rows.filter((r) => r.UsesCheck).length}</b><span id="estplus-error" role="alert"></span></div><div style="max-height:480px;overflow:auto"><table class="sheet"><thead><tr><th>확인</th><th>No</th><th>품명</th><th>규격</th><th>단위</th><th>노무비</th><th>재료비</th><th>경비</th><th>실적</th><th></th><th>노무비</th><th>재료비</th><th>경비</th></tr></thead><tbody>${rows
|
||||
.map(
|
||||
(r) =>
|
||||
`<tr data-ep-row="${r.index}" style="cursor:pointer${r.UsesCheck ? ";color:#b00" : ""}"><td>${r.UsesCheck ? "✔" : ""}</td><td>${r.Index}</td><td style="text-align:left">${esc(r.Name)}</td><td>${esc(r.Size ?? "")}</td><td>${esc(r.Unite ?? "")}</td><td>${number(r.NC)}</td><td>${number(r.GC)}</td><td>${number(r.JC)}</td><td>${number(r.SC)}</td><td></td><td>${number(r.ONU)}</td><td>${number(r.OGU)}</td><td>${number(r.OJU)}</td></tr>`,
|
||||
)
|
||||
.join("")}</tbody></table></div>`,
|
||||
);
|
||||
}
|
||||
async function compareFile() {
|
||||
compared.kind = Number(document.querySelector('[name="ep-kind"]:checked')?.value || 0);
|
||||
const file = await pickFile();
|
||||
if (!file) return;
|
||||
const legacy = await EstApp.api(`/api/estplus-compare?kind=${compared.kind}`, {
|
||||
method: "POST",
|
||||
body: await file.arrayBuffer(),
|
||||
});
|
||||
compared.rows = pair(EstApp.state.project, compared.kind, legacy);
|
||||
draw();
|
||||
}
|
||||
|
||||
async function load(state) {
|
||||
const file = await pickFile();
|
||||
if (!file) return;
|
||||
const project = await EstApp.api(
|
||||
`/api/estplus-read?name=${encodeURIComponent(file.name.replace(/\.(est|ebk)$/i, ""))}`,
|
||||
{ method: "POST", body: await file.arrayBuffer() },
|
||||
);
|
||||
if (!confirm("구버젼 데이타를 변환했습니다. 현재 작업을 변환한 자료로 바꾸시겠습니까?")) return;
|
||||
EstApp.setProject(project);
|
||||
state.notice = (project.importWarnings || []).join(" ");
|
||||
EstApp.render();
|
||||
}
|
||||
async function convert(state) {
|
||||
const response = await fetch("/api/estplus-write", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(state.project),
|
||||
});
|
||||
if (!response.ok) throw Error((await response.json()).error);
|
||||
EstApp.download(
|
||||
`${state.project.name || "내역서"}.est`,
|
||||
await response.blob(),
|
||||
"application/octet-stream",
|
||||
);
|
||||
state.notice = "변환을 마쳤습니다.";
|
||||
EstApp.render();
|
||||
}
|
||||
|
||||
function install() {
|
||||
document.addEventListener("keydown", (event) => {
|
||||
const k = event.key.toLowerCase();
|
||||
if (event.ctrlKey && event.altKey && k === "e") {
|
||||
event.preventDefault();
|
||||
handle("estplus-compare", EstApp.state);
|
||||
} else if (event.ctrlKey && event.shiftKey && event.key === "F12") {
|
||||
event.preventDefault();
|
||||
handle("estplus-convert", EstApp.state);
|
||||
}
|
||||
});
|
||||
document.addEventListener("dblclick", (event) => {
|
||||
const tr = event.target.closest?.("[data-ep-row]");
|
||||
if (!tr) return;
|
||||
const [, table] = KINDS[compared.kind],
|
||||
row = EstApp.state.project.tables[table][Number(tr.dataset.epRow)];
|
||||
if (!row?.Code && table !== "EstMainDat") return;
|
||||
EstApp.byId("dialog").close();
|
||||
const view = { CostTitleDat: "CostMainDat" }[table] || table;
|
||||
EstApp.navigate(view, table === "EstMainDat" ? "" : row.Code);
|
||||
});
|
||||
}
|
||||
if (typeof document !== "undefined" && document.addEventListener) install();
|
||||
|
||||
function handle(name, state) {
|
||||
const fail = (error) => {
|
||||
const note = EstApp.byId("estplus-error");
|
||||
if (note) note.textContent = error.message;
|
||||
else {
|
||||
state.notice = error.message;
|
||||
EstApp.render();
|
||||
}
|
||||
};
|
||||
if (name === "estplus-compare") draw();
|
||||
else if (name === "estplus-compare-file") compareFile().catch(fail);
|
||||
else if (name === "estplus-load") load(state).catch(fail);
|
||||
else if (name === "estplus-convert") convert(state).catch(fail);
|
||||
else return false;
|
||||
return true;
|
||||
}
|
||||
return { pair, handle };
|
||||
})();
|
||||
@@ -8,6 +8,7 @@ const { importEstx, table: dbisamTable } = require("./import-estx.cjs");
|
||||
const { readXls } = require("./xls-read.cjs");
|
||||
const bookStore = require("./book-store.cjs");
|
||||
const wongaFormats = require("./wonga-formats.cjs");
|
||||
const legacyEstPlus = require("./legacy-estplus.cjs");
|
||||
const printFormat = require("./print-format.cjs");
|
||||
const pfotTemplate =
|
||||
process.env.EST_PFOT_TEMPLATE || "C:/Dasansoft/Est Plus 2/System/EstFormat/main.pfot";
|
||||
@@ -114,6 +115,7 @@ const allowed = new Set([
|
||||
"native-dongwon-check.js",
|
||||
"native-excel-files.js",
|
||||
"native-option-all.js",
|
||||
"native-estplus.js",
|
||||
"native-actions-more.js",
|
||||
"native-wonga-tools.js",
|
||||
"native-estimate-print.js",
|
||||
@@ -346,6 +348,21 @@ const server = http.createServer(async (req, res) => {
|
||||
reply(res, 200, readXls(await body(req)));
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/api/estplus-read") {
|
||||
const name = String(url.searchParams.get("name") || "EST Plus 자료").slice(0, 200);
|
||||
reply(res, 200, legacyEstPlus.read(await body(req), name));
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/api/estplus-compare") {
|
||||
reply(res, 200, legacyEstPlus.compare(await body(req), Number(url.searchParams.get("kind"))));
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/api/estplus-write") {
|
||||
const project = JSON.parse((await body(req)).toString("utf8"));
|
||||
if (!project?.tables) throw Error("공사 자료 형식 오류");
|
||||
reply(res, 200, legacyEstPlus.write(project), "application/octet-stream");
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/api/wonga-formats") {
|
||||
const index = url.searchParams.get("index");
|
||||
reply(res, 200, wongaFormats.get(url.searchParams.get("std") === "1", index ?? undefined));
|
||||
|
||||
Reference in New Issue
Block a user