Merge branch 'sub_laptop_2' of C:/Aislo_wt/sub_laptop_2 into main_laptop_1
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_UI_Side.ts
|
||||
* 마스터 템플릿 좌측 패널 차례(716-3 · 717-2a) — 「단가산출」(층 단추 따로 · 기본 개인 · `UI_Side_Sangeun`) › 「일위대가」(층 단추 따로 · 2단 폴더 나무 · `UI_Side_Precombo` ·
|
||||
* 50-3 · 134-3) › 「내 기계」 · 「내 품목」(821-3 · 개인 층 · 펼치면 가운데 화면) › 「구조물 조합」(층 단추 · 나무) › 수평 구분선 › 「일위대가 불러오기」(구조물 조합 층 ·
|
||||
* 50-3 · 134-3) › 「구조물 조합」(층 단추 · 나무) › 「개인관리」(824-1 · 단추 「기계」 · 「자재」 · 가운데 화면) › 수평 구분선 › 「일위대가 불러오기」(구조물 조합 층 ·
|
||||
* `Hopyo_Side` · 324-4) › 「도면 양식」(새로 · 본떠 · 지우기).
|
||||
* 구조물 옵션 정의는 M01 로 옮김(716-1).
|
||||
* 도면 양식 = 시스템 관리자면 늘(마스터 층 것 · 구조물 조합 층과 무관 · 720-4).
|
||||
@@ -157,17 +157,16 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
getOpen: opt.getOpen,
|
||||
});
|
||||
const sangeunSec = section(L("M02_SangeunTitle"), sangeun.body);
|
||||
// 내 기계 · 내 품목(821-3) — 자기 개인 층 · 펼치면 가운데 화면(목록 · 양식)
|
||||
const hint = (key: "Side_Hint" | "Items_Side_Hint"): HTMLElement =>
|
||||
createEmpty({ text: mt(key), small: true });
|
||||
const myMachSec = openOnExpand(section(mt("Title"), hint("Side_Hint")), async () => {
|
||||
// 개인관리(824-1) — 단추 「기계」 · 「자재」(821-3 · 자기 개인 층) · 누르면 가운데 화면(목록 · 양식)
|
||||
const myMachBtn = createButton({ label: mt("Title"), variant: "ghost", size: "sm" });
|
||||
myMachBtn.addEventListener("click", async () => {
|
||||
if (!(await opt.confirmLeave())) return;
|
||||
let mark = (_d: boolean): void => {};
|
||||
const node = buildMyMachineView({ owner: null, setDirty: (d) => mark(d) });
|
||||
mark = opt.showView(mt("Title"), node);
|
||||
mark = opt.showView(mt("Crumb_Machine"), node);
|
||||
});
|
||||
myMachSec.dataset.myMachines = "";
|
||||
const myItemsSec = openOnExpand(section(mt("Items_Title"), hint("Items_Side_Hint")), async () => {
|
||||
const myItemsBtn = createButton({ label: mt("Items_Title"), variant: "ghost", size: "sm" });
|
||||
myItemsBtn.addEventListener("click", async () => {
|
||||
if (!(await opt.confirmLeave())) return;
|
||||
const node = buildMyItemsView({
|
||||
owner: null,
|
||||
@@ -184,8 +183,13 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
});
|
||||
},
|
||||
});
|
||||
opt.showView(mt("Items_Title"), node);
|
||||
opt.showView(mt("Crumb_Items"), node);
|
||||
});
|
||||
const personalSec = section(
|
||||
mt("Personal_Title"),
|
||||
el("div", { className: "m02-side__row", children: [myMachBtn, myItemsBtn] }),
|
||||
);
|
||||
personalSec.dataset.myMachines = "";
|
||||
// 실무자료로 일위대가를 만들었음(확인 창 · 엑셀 보기 창) → 목록 다시 · 이 패널이 내려갔으면 듣기 끝
|
||||
const onBuilt = (): void =>
|
||||
precombo.body.isConnected
|
||||
@@ -205,9 +209,8 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
children: [
|
||||
sangeunSec,
|
||||
precomboSec,
|
||||
myMachSec,
|
||||
myItemsSec,
|
||||
summary,
|
||||
personalSec,
|
||||
createDivider(),
|
||||
section(hopyo.title, hopyo.body),
|
||||
drawing,
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
/* 개인관리(824-1) — 제목 아래 단추 줄 띄움 */
|
||||
[data-my-machines] > .m02-side__row {
|
||||
margin-top: var(--spacing-12);
|
||||
}
|
||||
|
||||
/* 일위대가 조합 서고 목록표(51-4) — 표 꼴은 M01 목록(`m01-master__grid`) · 첫 칸 = 고름 */
|
||||
.m01-master__grid .m02-lib__pick {
|
||||
width: 2.5rem;
|
||||
|
||||
@@ -28,7 +28,7 @@ export function buildMyItemsView(opt: MyItemsViewOptions): HTMLElement {
|
||||
const uses = createFindList({ placeholder: mt("Open_Sk") });
|
||||
uses.root.hidden = true;
|
||||
const table = createDataTable({
|
||||
caption: mt("Items_Title"),
|
||||
caption: mt("Crumb_Items"),
|
||||
columns: [
|
||||
{ key: "이름", label: mt("Name"), width: 200 },
|
||||
{ key: "규격", label: mt("Spec"), width: 140 },
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
* ========================================================================== */
|
||||
|
||||
const TEXT = {
|
||||
Title: "내 기계",
|
||||
Items_Title: "내 품목",
|
||||
Personal_Title: "개인관리",
|
||||
Title: "기계",
|
||||
Items_Title: "자재",
|
||||
Crumb_Machine: "개인관리 › 기계",
|
||||
Crumb_Items: "개인관리 › 자재",
|
||||
New: "새 기계",
|
||||
Copy_Title: "복사해 내 기계로",
|
||||
Find: "이름 · 규격 · 키로 찾기",
|
||||
@@ -38,8 +41,6 @@ const TEXT = {
|
||||
No_Uses: "쓰는 단가산출 없음",
|
||||
Leave_Ask: "저장 안 한 고침을 버리고 닫을까?",
|
||||
Mine: "내 기계",
|
||||
Side_Hint: "펼치면 가운데에 목록 · 양식",
|
||||
Items_Side_Hint: "펼치면 가운데에 모아 보기(읽기)",
|
||||
Won: "원",
|
||||
Labor: "노",
|
||||
Material: "재",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* =============================================================================
|
||||
* M02_MyMachine_View.ts
|
||||
* M02 「내 기계」 화면(PLAN 821-3 · 요구서 796 3 장) — 좌측 「내 기계」 칸을 펼치면 가운데에 뜸(개인 층).
|
||||
* M02 「내 기계」 화면(PLAN 821-3 · 요구서 796 3 장) — 좌측 「개인관리」 › [기계] 단추로 가운데에 뜸(개인 층).
|
||||
* 목록 = 이름 · 규격 · 방식 · 시간당 경비(서버 셈) · 쓰는 곳 수 · 넣은 날 · 고친 날 · 찾기 · [새 기계]
|
||||
* 줄 누름 = 양식 한 장(`M02_MyMachine_Form`) · [목록] = 저장 안 한 고침이면 물음 · 저장 · 지우기 뒤 목록 다시
|
||||
* ========================================================================== */
|
||||
@@ -33,7 +33,7 @@ export function buildMyMachineView(opt: MyMachineViewOptions): HTMLElement {
|
||||
const hint = createText("");
|
||||
const fail = el("div");
|
||||
const table = createDataTable({
|
||||
caption: mt("Title"),
|
||||
caption: mt("Crumb_Machine"),
|
||||
columns: [
|
||||
{ key: "이름", label: mt("Name"), width: 180 },
|
||||
{ key: "규격", label: mt("Spec"), width: 120 },
|
||||
|
||||
@@ -15,6 +15,7 @@ for (const file of [
|
||||
"native-row-match.js",
|
||||
"native-escalation.js",
|
||||
"native-progress.js",
|
||||
"native-annual-load.js",
|
||||
"native-business.js",
|
||||
"native-review.js",
|
||||
"native-resources.js",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""EST 테스트 앱 품셈 보기(원본 BookData 암호 표 해독 · 목차 · 본문 · 검색)."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
BOOK = Path("C:/Dasansoft/Est Plus 2/System/BookData/forest.dat")
|
||||
|
||||
|
||||
def node(code: str):
|
||||
return json.loads(subprocess.check_output(["node", "-e", code], cwd=ROOT).decode("utf-8"))
|
||||
|
||||
|
||||
def test_blowfish_constants_and_rtf_text():
|
||||
out = node(r"""
|
||||
const b = require('./tools/est-test/book-store.cjs');
|
||||
const w = b.piWords(1042);
|
||||
const rtf = Buffer.from(String.raw`{\rtf1{\fonttbl{\f0 x;}}\'b0\'a1\cell 1.2\row \u44032?\par}`, 'latin1');
|
||||
process.stdout.write(JSON.stringify({ p0: w[0].toString(16), last: w[1041].toString(16), text: b.rtfText(rtf) }));
|
||||
""")
|
||||
assert out["p0"] == "243f6a88"
|
||||
assert out["last"] == "3ac372e6" # Blowfish S4[255]
|
||||
assert out["text"] == "가\t1.2\n가\n"
|
||||
|
||||
|
||||
def test_installed_forest_book():
|
||||
if not BOOK.exists():
|
||||
pytest.skip("installed EST book data unavailable")
|
||||
out = node(r"""
|
||||
const b = require('./tools/est-test/book-store.cjs');
|
||||
const list = b.list(), tree = b.tree('forest'), page = b.text('forest', 902);
|
||||
process.stdout.write(JSON.stringify({ books: list.map((x) => x.name), forest: list.find((x) => x.name === 'forest'),
|
||||
nodes: tree.length, page: page.text.split('\n').filter((l) => l.startsWith('1.0 이하'))[0],
|
||||
title: b.search('forest', '교목 굴취').map((h) => h.pkid), body: b.search('forest', '근원직경을', 'body').length }));
|
||||
""")
|
||||
assert out["books"] == ["civil", "archit", "machine", "electric", "culture", "forest", "woods"]
|
||||
assert out["forest"]["desc"].startswith("산림품셈(2026)") and out["forest"]["nodes"] == 563
|
||||
assert out["page"] == "1.0 이하\t0.072\t0.012\t"
|
||||
assert 902 in out["title"]
|
||||
assert out["body"] >= 1
|
||||
@@ -0,0 +1,61 @@
|
||||
"""EST 테스트 앱 현재데이타 갱신(원본 TFRMDATAUPDATE → 0x10e12c8 — 노임·자재·유류·표준·기계·환율)."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def run_js(code: str):
|
||||
runner = f"""
|
||||
const fs = require('fs'), vm = require('vm');
|
||||
const ctx = vm.createContext({{ structuredClone }});
|
||||
vm.runInContext(fs.readFileSync('tools/est-test/native-data-update.js', 'utf8'), ctx);
|
||||
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_update_rules():
|
||||
out = run_js(
|
||||
r"""(() => {
|
||||
const U = EstDataUpdate;
|
||||
const src = { tables: {
|
||||
LaborcDat: [{ Code: 'X1', Name: '보 통 인 부', Size: '', TC: 200, Cost1: 200, Bimog: 2 }],
|
||||
StuffcDat: [{ Code: 'S1', Name: '시멘트', Size: '40kg', Unite: '포당', TC: 6000, Cost1: 6000, Page1: '12', Ratio: 5 },
|
||||
{ Code: 'S2', Name: '경유', TC: 1700, Cost1: 1700 }],
|
||||
SilJukDat: [{ Code: 'C1', Name: '새 이름', Size: '', Unite: 'm', TC: 99, NC: 50 }],
|
||||
MachGijunDat: [{ Index: 36, Name: '달러($)', TC: 1400, Bigo: '새' }, { Index: 1, Name: '계수', TC: 0.3 }],
|
||||
MachTitleDat: [], MachMainDat: [] } };
|
||||
const mk = () => ({ tables: {
|
||||
LaborcDat: [{ Code: 'L1', Name: '보통인부', TC: 100, Cost1: 100, Cost1_: 7 }, { Code: 'L2', Name: '없는직종', TC: 1 }],
|
||||
StuffcDat: [{ Code: 'M1', Name: '시멘트', Size: '40 kg', Unite: '[포]', TC: 5000, Cost1: 5000, Ratio: 1 },
|
||||
{ Code: 'M2', Name: '경 유', TC: 1600, Cost1: 1600 }],
|
||||
SilJukDat: [{ Code: 'C1', Name: '옛 이름', TC: 1 }],
|
||||
MachGijunDat: [{ Index: 36, Name: '달러($)', TC: 1300, Bigo: '옛', TC_: 9 }],
|
||||
MachTitleDat: [], MachMainDat: [] } });
|
||||
const a = mk(), ra = U.apply(a, src, { labor: 1, stuff: 1, siljuk: 1, mach: 1, fuel: 1, rate: 1 });
|
||||
const b = mk(); U.apply(b, src, { stuff: 1 });
|
||||
const c = mk(); U.apply(c, src, { mach: 1 });
|
||||
const l = a.tables.LaborcDat;
|
||||
return {
|
||||
labor: [l[0].TC, l[0].Code, l[0].Cost1_, l[0].Checked, l[0].Bimog, l[1].Checked],
|
||||
stuff: [a.tables.StuffcDat[0].TC, a.tables.StuffcDat[0].Page1, a.tables.StuffcDat[0].Code, a.tables.StuffcDat[1].TC],
|
||||
stuffOnly: [b.tables.StuffcDat[0].TC, b.tables.StuffcDat[0].Ratio, b.tables.StuffcDat[1].TC],
|
||||
siljuk: [a.tables.SilJukDat[0].Code, a.tables.SilJukDat[0].Name, a.tables.SilJukDat[0].NC],
|
||||
gijun: a.tables.MachGijunDat.map((r) => [r.Index, r.TC, r.TC_ ?? null]),
|
||||
rateKept: c.tables.MachGijunDat.find((r) => r.Index === 36).TC,
|
||||
notices: ra.notices,
|
||||
fuel: [U.isFuel({ Name: '휘 발 유' }), U.isFuel({ Name: '선박용경유' })],
|
||||
};
|
||||
})()"""
|
||||
)
|
||||
assert out["labor"] == [200, "L1", 7, False, 2, True]
|
||||
assert out["stuff"] == [6000, "12", "M1", 1700]
|
||||
assert out["stuffOnly"] == [6000, 1, 1600]
|
||||
assert out["siljuk"] == ["C1", "새 이름", 50]
|
||||
assert out["gijun"] == [[36, 1400, 9], [1, 0.3, None]]
|
||||
assert out["rateKept"] == 1300
|
||||
assert out["notices"][0].startswith("노임단가에서 1 개의 찾지못한 데이타가 있습니다.")
|
||||
assert out["fuel"] == [True, False]
|
||||
@@ -202,3 +202,21 @@ def test_progress_preserves_prior_costs_for_multiple_blank_codes():
|
||||
assert result["errors"] == []
|
||||
assert [row["JunCost"] for row in result["costs"]] == [200, 100]
|
||||
assert [row["TotalCost"] for row in result["costs"]] == [500, 250]
|
||||
|
||||
|
||||
def test_annual_loaded_rows_replace_formula():
|
||||
result = run(
|
||||
"math",
|
||||
"""(()=>{
|
||||
const p=EstNative.empty();p.options.SourceRefresh=false;
|
||||
p.tables.EstMainDat=[{PkID:1,Code:'R',GN:10,GU:100},{PkID:2,Code:'S',GN:4,NU:50},{LinkIndex:54}];
|
||||
const loaded=[{PkID:1,Code:'R',GN:3,GU:100,GC:300,TC:300},{PkID:2,Code:'S',GN:2,NU:50,NC:100,TC:100}];
|
||||
p.annual=[{name:'1차',formula:'다른공사',rows:loaded}];
|
||||
const out=EstBusiness.annual(EstNative.calculate(p));
|
||||
const rows=out.results[0].project.tables.EstMainDat;
|
||||
return {gn:rows.slice(0,2).map(r=>r.GN), tc:out.results[0].project.calculation.direct.TC, errors:out.errors};
|
||||
})()""",
|
||||
)
|
||||
assert result["gn"] == [3, 2]
|
||||
assert result["tc"] == 400
|
||||
assert result["errors"] == []
|
||||
|
||||
@@ -489,6 +489,14 @@ 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] 연차 내역서·상단·하단데이타 불러오기(원본 TFRMESTMULTIEDIT popLoadEst·popUpDataLoad·popDownLoad 0xedb784 → 0xf11a60 — spec_part §7) — 차수 줄의 세 단추: 고른 .estx 내역서 줄을 차수에 짝지어 옮기고(행 수는 현재 내역), 수식칸에 파일 이름. 상단·하단은 설계변경 내역만(당초 「_」 칸 있는 것), 상단 = 당초값·하단 = 변경값. 그 차수의 상세 줄은 수식 대신 불러온 값(금액 고정, 다시 셈 없음). 서버 계산도 같은 코드(`native-annual-load.js`).
|
||||
- 검증완료: 기성·연차 시험 13개(불러온 줄 수량 3·2·직접공사비 400, 수식 무시) 통과. 당초/변경 고르기·설계변경 아닌 파일 거부 확인. ORCA 브라우저에서 차수 줄에 불러오기 단추 3개 표시(파일 선택은 수동 조작 필요).
|
||||
- [x] 현재데이타 갱신(원본 TFRMDATAUPDATE btnApply 0x10bc3d4 → 엔진 0x10e12c8 — spec_update) — 원천: Main Data(원본 설치 main.estx = BufDat\Main\Main_1, 서버 `/api/main-data`, `EST_MAIN_DATA` 로 경로 변경) 또는 고른 .estx. 항목: 노임·자재(유류 제외)·유류(이름 8종)·표준(코드 일치)·기계(손료계수·환율 표 교체, 기계 호표 이름·규격 일치, 산출근거 줄 교체 뒤 자재·노임 다시 잇기)·환율(36·89·69·77·70·76). 맞춤 규칙·복사 칸·단가미갱신 표시·안내 문구 원본대로. 행 추가·삭제 없음. 갱신 뒤 재계산. 하지 않음: 최신(웹접속 — 원본 회사 서버·유지보수 인증), 자재 단가열 출처 재배치(OtherTables — 같은 열 순서 가정), 원본 결함(환율 끔 때 파운드를 76 값으로 덮음).
|
||||
- 검증완료: `test_est_data_update.py`(노임 갱신·당초 칸 유지·못 찾음 표시와 문구, 자재 규격·단위 정리 맞춤·쪽수, 자재만 갱신 때 유류·비율 제외, 표준 코드 맞춤·이름 교체, 손료 계수 표 교체·당초 칸 유지, 환율 끔 유지) 통과. 샘플을 원본 main.estx 로 갱신하면 보통인부 172,068→172,698(2026 하반기)·못 찾음 0(145ms). ORCA 브라우저에서 같은 결과, 샘플 복구.
|
||||
- [x] 견적서 엑셀변환(원본 TFRMEXCELCONVERTESTIPRN btnConvert) — 견적서 창 「엑셀변환」: 머리(제목·공사명·귀하·견적일자·합계금액 한글·공급자)와 레벨에 맞춘 내역 줄(품명·규격·단위·수량·재료/노무/경비/합계 단가·금액·비고)을 .xlsx 로 내려받기. 원본의 인쇄비율·셀 수식은 하지 않음(값만).
|
||||
- 검증완료: 견적서·바인딩 시험 통과. ORCA 브라우저에서 「EST REA 보관 샘플_견적서.xlsx」(7,490 바이트) 내려받기, 샘플 복구.
|
||||
- [x] 품셈 보기·품셈자료 붙여넣기(원본 barbtnBook → ProductBook.exe/TfrmBookViewer, popupPasteBook 10d6e28 — spec_book) — 서버 `book-store.cjs` 가 원본 설치 BookData(7권, DBISAM 암호 표: MD5 키 Blowfish-CBC, BLOB zlib RTF)를 읽음(Node 에 Blowfish 가 없어 π 자리로 상수 만들어 직접 구현, `EST_BOOK_DIR` 로 경로 변경). 화면: 품셈 고르기·목차 트리(펼치기·접기)·본문(표 칸 나눔)·검색(제목 / 제목+본문 / 키워드, 검색어 빈칸·'|' 지움)·본문 검색 강조·복사하기. 붙여넣기(복사한 뒤만): 단가산출은 첫 줄을 마지막 산출근거 칸 커서 자리, 둘째 줄부터 아래 새 줄, 내역·일위·기계는 마지막 칸에 마지막 줄(수치 칸 검사). 사용자 결정: 원본처럼 잠긴 품셈 파일을 읽음.
|
||||
- 검증완료: `test_est_book.py`(Blowfish 상수 첫·끝 값, RTF 글자, 설치 7권·산림 563마디·교목굴취 표 줄·제목·본문 검색) 및 바인딩 시험 통과. ORCA 브라우저에서 산림품셈 목차 펼치기 → 5-1-2 교목굴취 표 34칸, 「보통인부」 강조, 복사 → 단가산출 붙여넣기 363→385줄, 샘플 복구.
|
||||
- [x] 수량산출서 삽입·선택공종 삭제(원본 TFRMMULLANG btnInsert·btnSelectDel) — 「삽입」: 현재 산출서 앞에 공종명칭·규격을 물어 새 산출서, 「선택공종 삭제」: 고른 여러 산출서를 한 번에 지움.
|
||||
- 검증완료: 산출서·바인딩 시험 7개 통과. ORCA 브라우저에서 둘째 위치에 「끼운 공종/1m」 삽입, 1·3번째 고른 삭제 → 「끼운 공종」만 남음, 샘플 복구.
|
||||
- [x] 새내역서 파일로저장(원본 mnuSaveNewEst 0x113384c) — 현재 화면은 그대로, 표 12개의 「_」 당초값 칸 비움·신규 표시 끔·원가 안전관리비 특례 줄(CalcIndex 4)→1·당초 사본 뺀 새 작업을 이름 붙여 서버에 따로 저장하고 내려받기. 재계산 없음.
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
// 원본 품셈 보기 자료(System\BookData — civil·archit·machine·electric·culture·forest·woods) 읽기.
|
||||
// DBISAM 4 암호 표: 키 = MD5(원본 세션 암호), Blowfish-CBC(IV 0) — .dat 머리 0x200 은 평문, 필드 설명은 한 사슬,
|
||||
// 레코드는 레코드마다·.blb 512 바이트 블록은 블록마다 사슬을 새로 시작. 필드 pkid·upperid·orderno·chapter·title·keywords·content(BLOB).
|
||||
// 본문 BLOB = zlib 압축 RTF(WPTools) → 글자(문단 줄바꿈, 표 칸 탭). Node 가 Blowfish 를 빼서 직접 구현(상수는 π 16진 자리).
|
||||
// 설치 폴더는 EST_BOOK_DIR 로 바꿀 수 있다. 읽기 전용.
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const zlib = require("zlib");
|
||||
const crypto = require("crypto");
|
||||
|
||||
const DIR = process.env.EST_BOOK_DIR || "C:/Dasansoft/Est Plus 2/System/BookData";
|
||||
const PASSWORD = 'LW`bdGD"4[';
|
||||
|
||||
// π 소수부 16진 자리 → Blowfish P(18)·S(4×256) 초기값
|
||||
function piWords(count) {
|
||||
const digits = count * 8 + 8,
|
||||
scale = 1n << BigInt(digits * 4 + 64);
|
||||
const atan = (x) => {
|
||||
const xx = x * x;
|
||||
let term = scale / x,
|
||||
sum = term,
|
||||
k = 1n;
|
||||
for (let sign = -1n; term; sign = -sign, k += 2n) {
|
||||
term /= xx;
|
||||
sum += (sign * term) / (k + 2n);
|
||||
}
|
||||
return sum;
|
||||
};
|
||||
const pi = 16n * atan(5n) - 4n * atan(239n);
|
||||
const frac = (pi - 3n * scale) >> 64n;
|
||||
const hex = frac.toString(16).padStart(digits, "0");
|
||||
return Array.from({ length: count }, (_, i) => parseInt(hex.slice(i * 8, i * 8 + 8), 16) >>> 0);
|
||||
}
|
||||
|
||||
function blowfish(key) {
|
||||
const init = piWords(18 + 1024),
|
||||
P = init.slice(0, 18),
|
||||
S = [0, 1, 2, 3].map((i) => init.slice(18 + i * 256, 18 + (i + 1) * 256));
|
||||
const F = (x) =>
|
||||
((((S[0][x >>> 24] + S[1][(x >>> 16) & 255]) >>> 0) ^ S[2][(x >>> 8) & 255]) +
|
||||
S[3][x & 255]) >>>
|
||||
0;
|
||||
function encrypt(l, r) {
|
||||
for (let i = 0; i < 16; i++) {
|
||||
l = (l ^ P[i]) >>> 0;
|
||||
r = (r ^ F(l)) >>> 0;
|
||||
[l, r] = [r, l];
|
||||
}
|
||||
[l, r] = [r, l];
|
||||
r = (r ^ P[16]) >>> 0;
|
||||
l = (l ^ P[17]) >>> 0;
|
||||
return [l, r];
|
||||
}
|
||||
function decrypt(l, r) {
|
||||
for (let i = 17; i > 1; i--) {
|
||||
l = (l ^ P[i]) >>> 0;
|
||||
r = (r ^ F(l)) >>> 0;
|
||||
[l, r] = [r, l];
|
||||
}
|
||||
[l, r] = [r, l];
|
||||
r = (r ^ P[1]) >>> 0;
|
||||
l = (l ^ P[0]) >>> 0;
|
||||
return [l, r];
|
||||
}
|
||||
for (let i = 0, k = 0; i < 18; i++) {
|
||||
let w = 0;
|
||||
for (let j = 0; j < 4; j++, k++) w = ((w << 8) | key[k % key.length]) >>> 0;
|
||||
P[i] = (P[i] ^ w) >>> 0;
|
||||
}
|
||||
let l = 0,
|
||||
r = 0;
|
||||
for (let i = 0; i < 18; i += 2) {
|
||||
[l, r] = encrypt(l, r);
|
||||
P[i] = l;
|
||||
P[i + 1] = r;
|
||||
}
|
||||
for (const box of S)
|
||||
for (let i = 0; i < 256; i += 2) {
|
||||
[l, r] = encrypt(l, r);
|
||||
box[i] = l;
|
||||
box[i + 1] = r;
|
||||
}
|
||||
// CBC 풀기(IV 0), 8 바이트 미만 꼬리는 그대로
|
||||
return function cbc(buf) {
|
||||
const out = Buffer.from(buf),
|
||||
n = buf.length - (buf.length % 8);
|
||||
let pl = 0,
|
||||
pr = 0;
|
||||
for (let o = 0; o < n; o += 8) {
|
||||
const cl = buf.readUInt32BE(o),
|
||||
cr = buf.readUInt32BE(o + 4),
|
||||
[dl, dr] = decrypt(cl, cr);
|
||||
out.writeUInt32BE((dl ^ pl) >>> 0, o);
|
||||
out.writeUInt32BE((dr ^ pr) >>> 0, o + 4);
|
||||
pl = cl;
|
||||
pr = cr;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
}
|
||||
|
||||
let cbc = null;
|
||||
const dec = (buf) =>
|
||||
(cbc ||= blowfish(crypto.createHash("md5").update(PASSWORD, "latin1").digest()))(buf);
|
||||
const cp949 = new TextDecoder("euc-kr");
|
||||
const books = new Map();
|
||||
|
||||
function load(name) {
|
||||
if (books.has(name)) return books.get(name);
|
||||
if (!/^[a-z]+$/.test(name)) throw Error("품셈 이름이 올바르지 않습니다.");
|
||||
const d = fs.readFileSync(path.join(DIR, `${name}.dat`));
|
||||
const size = d.readUInt16LE(0x2d),
|
||||
nf = d[0x2f],
|
||||
descEnd = d.indexOf(0, 0x48),
|
||||
desc = cp949.decode(d.subarray(0x48, Math.min(descEnd < 0 ? 0x68 : descEnd, 0x68)));
|
||||
const fd = dec(d.subarray(0x200, 0x200 + nf * 0x300));
|
||||
const fields = [];
|
||||
for (let i = 0; i < nf; i++) {
|
||||
const b = fd.subarray(i * 0x300, (i + 1) * 0x300);
|
||||
fields.push([b.subarray(3, 3 + b[2]).toString("latin1"), b[0xa4], b.readUInt16LE(0xac)]);
|
||||
}
|
||||
const rows = [];
|
||||
for (let o = 0x200 + nf * 0x300; o + size <= d.length; o += size) {
|
||||
const r = dec(d.subarray(o, o + size)),
|
||||
row = {};
|
||||
for (const [fn, t, off] of fields) {
|
||||
if (!r[off]) row[fn] = null;
|
||||
else if (t === 6) row[fn] = r.readInt32LE(off + 1);
|
||||
else if (t === 1) {
|
||||
const end = r.indexOf(0, off + 1);
|
||||
row[fn] = cp949.decode(r.subarray(off + 1, end < 0 ? off + 302 : Math.min(end, off + 302)));
|
||||
} else row[fn] = r.readUInt32LE(off + 1);
|
||||
}
|
||||
if (row.pkid != null) rows.push(row);
|
||||
}
|
||||
const book = { name, desc, rows, byId: new Map(rows.map((r) => [r.pkid, r])), texts: new Map() };
|
||||
books.set(name, book);
|
||||
return book;
|
||||
}
|
||||
|
||||
function blob(name, first) {
|
||||
const b = fs.readFileSync(path.join(DIR, `${name}.blb`));
|
||||
const parts = [];
|
||||
let block = first,
|
||||
total = null,
|
||||
got = 0;
|
||||
const seen = new Set();
|
||||
while (block && !seen.has(block)) {
|
||||
seen.add(block);
|
||||
const x = dec(b.subarray(block * 512, (block + 1) * 512)),
|
||||
next = x.readUInt32LE(4),
|
||||
length = x.readUInt16LE(8);
|
||||
if (total == null) total = x.readUInt32LE(14);
|
||||
parts.push(x.subarray(18, 18 + length));
|
||||
got += length;
|
||||
block = next;
|
||||
if (got >= total) break;
|
||||
}
|
||||
return zlib.inflateSync(Buffer.concat(parts));
|
||||
}
|
||||
|
||||
const SKIP = /^\{\\(\*|fonttbl|colortbl|stylesheet|info|pict|listtable|listoverridetable|object)/;
|
||||
function rtfText(raw) {
|
||||
const s = raw.toString("latin1"),
|
||||
out = [],
|
||||
pend = [];
|
||||
let i = 0,
|
||||
depth = 0,
|
||||
skip = null,
|
||||
uc = 1;
|
||||
const flush = () => {
|
||||
if (pend.length) out.push(cp949.decode(Buffer.from(pend.splice(0))));
|
||||
};
|
||||
while (i < s.length) {
|
||||
const c = s[i];
|
||||
if (c === "{") {
|
||||
depth++;
|
||||
if (skip == null && SKIP.test(s.slice(i, i + 24))) skip = depth;
|
||||
i++;
|
||||
} else if (c === "}") {
|
||||
if (skip === depth) skip = null;
|
||||
depth--;
|
||||
i++;
|
||||
} else if (c === "\\") {
|
||||
const hx = /^\\'([0-9a-fA-F]{2})/.exec(s.slice(i, i + 4));
|
||||
if (hx) {
|
||||
if (skip == null) pend.push(parseInt(hx[1], 16));
|
||||
i += 4;
|
||||
continue;
|
||||
}
|
||||
const cw = /^\\([a-zA-Z]+)(-?\d+)? ?/.exec(s.slice(i, i + 40));
|
||||
if (cw) {
|
||||
const [whole, w, a] = cw;
|
||||
i += whole.length;
|
||||
if (skip != null) continue;
|
||||
if (w === "u" && a) {
|
||||
flush();
|
||||
out.push(String.fromCharCode(Number(a) & 0xffff));
|
||||
i += uc;
|
||||
} else if (w === "uc" && a) uc = Number(a);
|
||||
else if (["par", "line", "row"].includes(w)) {
|
||||
flush();
|
||||
out.push("\n");
|
||||
} else if (["cell", "tab"].includes(w)) {
|
||||
flush();
|
||||
out.push("\t");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (skip == null && "\\{}".includes(s[i + 1] ?? "")) pend.push(s.charCodeAt(i + 1));
|
||||
i += 2;
|
||||
} else if (c === "\r" || c === "\n") i++;
|
||||
else {
|
||||
if (skip == null) pend.push(s.charCodeAt(i) & 0xff);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
function list() {
|
||||
const names = fs
|
||||
.readFileSync(path.join(DIR, "filelist.ini"), "latin1")
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
return names.map((name) => {
|
||||
const b = load(name);
|
||||
return { name, desc: b.desc, nodes: b.rows.length };
|
||||
});
|
||||
}
|
||||
function tree(name) {
|
||||
const b = load(name);
|
||||
return b.rows.map(({ pkid, upperid, orderno, chapter, title, content }) => ({
|
||||
pkid,
|
||||
parent: b.byId.has(upperid) ? upperid : null,
|
||||
order: orderno,
|
||||
chapter: chapter ?? "",
|
||||
title: title ?? "",
|
||||
content: Boolean(content),
|
||||
}));
|
||||
}
|
||||
function text(name, pkid) {
|
||||
const b = load(name),
|
||||
r = b.byId.get(Number(pkid));
|
||||
if (!r) throw Error("품셈 항목이 없습니다.");
|
||||
if (!r.content) return { pkid: r.pkid, chapter: r.chapter, title: r.title, text: "" };
|
||||
if (!b.texts.has(r.pkid)) b.texts.set(r.pkid, rtfText(blob(name, r.content)));
|
||||
return {
|
||||
pkid: r.pkid,
|
||||
chapter: r.chapter ?? "",
|
||||
title: r.title ?? "",
|
||||
text: b.texts.get(r.pkid),
|
||||
};
|
||||
}
|
||||
// 원본 전체검색(actFullSearch 0xdf8514): 검색어의 빈칸·'|' 를 지우고 제목(또는 제목+본문)에 들어 있으면.
|
||||
// 키워드검색(actKeywordSearch): keywords 칸에 들어 있으면.
|
||||
function search(name, word, mode = "title") {
|
||||
const b = load(name),
|
||||
kw = String(word ?? "").replace(/[\s|]/g, "");
|
||||
if (!kw) throw Error("검색어를 입력하세요.");
|
||||
return b.rows
|
||||
.filter((r) => {
|
||||
if (mode === "keyword") return String(r.keywords ?? "").includes(kw);
|
||||
if (String(r.title ?? "").includes(kw)) return true;
|
||||
return mode === "body" && r.content && text(name, r.pkid).text.includes(kw);
|
||||
})
|
||||
.map((r) => ({ pkid: r.pkid, chapter: r.chapter ?? "", title: r.title ?? "" }));
|
||||
}
|
||||
|
||||
module.exports = { list, tree, text, search, piWords, blowfish, rtfText };
|
||||
@@ -22,6 +22,7 @@
|
||||
<script defer src="native-escalation.js"></script>
|
||||
<script defer src="native-progress.js"></script>
|
||||
<script defer src="native-accom-view.js"></script>
|
||||
<script defer src="native-annual-load.js"></script>
|
||||
<script defer src="native-business.js"></script>
|
||||
<script defer src="native-review.js"></script>
|
||||
<script defer src="native-resources.js"></script>
|
||||
@@ -65,6 +66,8 @@
|
||||
<script defer src="native-jdl-compare.js"></script>
|
||||
<script defer src="native-global-vars.js"></script>
|
||||
<script defer src="native-new-estimate.js"></script>
|
||||
<script defer src="native-book.js"></script>
|
||||
<script defer src="native-data-update.js"></script>
|
||||
<script defer src="native-wonga-tools.js"></script>
|
||||
<script defer src="native-estimate-print.js"></script>
|
||||
<script defer src="native-xml-check.js"></script>
|
||||
@@ -150,7 +153,8 @@
|
||||
><button data-action="sum-index">SUM 색인지정</button
|
||||
><button data-action="cost-part">비목별 단가 불러오기(일위대가)</button
|
||||
><button data-action="global-var">전역변수 지정,해제</button
|
||||
><button data-action="global-var-list">전역변수 목록</button>
|
||||
><button data-action="global-var-list">전역변수 목록</button
|
||||
><button data-action="data-update">현재데이타 갱신</button>
|
||||
<hr />
|
||||
<button data-action="find">찾기·다음찾기(Ctrl+F)</button>
|
||||
<button data-action="multi-search">통합 검색(저장된 다른 작업)</button>
|
||||
@@ -298,6 +302,8 @@
|
||||
><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="book">품셈</button
|
||||
><button data-action="book-paste">품셈자료 붙여넣기</button
|
||||
><button data-action="add-row">행 추가</button><button data-action="copy-row">복사</button
|
||||
><button data-action="paste-row">붙여넣기</button
|
||||
><button data-action="delete-row">삭제</button
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// 원본 연차(다차수) 「내역서데이타 불러오기」·「상단데이타 불러오기」·「하단데이타 불러오기」(TFRMESTMULTIEDIT popLoadEst 0xedb784 →
|
||||
// 0xf11a60 — spec_part §7): 고른 .estx 의 내역서 줄을 차수 열에 짝지어 옮기고(행 수는 현재 내역 그대로), 차수 수식칸에 파일 이름을 적는다.
|
||||
// 상단·하단은 설계변경 내역만 받으며 상단 = 당초값(「_」 짝 칸)·하단 = 변경값(원본 옵션 +0x37 = 0 기준). 금액은 파일 값 그대로(다시 셈 없음).
|
||||
const EstAnnualLoad = (() => {
|
||||
const num = (v) => Number(v) || 0;
|
||||
const ORIGINAL = [
|
||||
"Name",
|
||||
"Size",
|
||||
"Unite",
|
||||
"GN",
|
||||
"TU",
|
||||
"TC",
|
||||
"NU",
|
||||
"NC",
|
||||
"GU",
|
||||
"GC",
|
||||
"JU",
|
||||
"JC",
|
||||
"SU",
|
||||
"SC",
|
||||
"Bigo",
|
||||
];
|
||||
// 차수 계산에서 불러온 줄을 쓰면 true
|
||||
function row(year, index, target) {
|
||||
const loaded = year.rows?.[index];
|
||||
if (!loaded) return false;
|
||||
Object.assign(target, structuredClone(loaded));
|
||||
target.CalcIndex = num(loaded.CalcIndex) >= 2 ? 3 : 1;
|
||||
return true;
|
||||
}
|
||||
function rows(source, tag) {
|
||||
const list = structuredClone(source.tables?.EstMainDat || []);
|
||||
if (!list.length) throw Error("내역서 데이타를 찾을수 없습니다.");
|
||||
if (tag) {
|
||||
const changed = list.some((r) =>
|
||||
Object.entries(r).some(([k, v]) => k.endsWith("_") && v != null && v !== ""),
|
||||
);
|
||||
if (!changed) throw Error("설계변경을 한 내역서가 아닙니다.");
|
||||
if (tag === 1)
|
||||
for (const r of list) for (const k of ORIGINAL) if (`${k}_` in r) r[k] = r[`${k}_`];
|
||||
}
|
||||
return list;
|
||||
}
|
||||
async function load(state, index, tag) {
|
||||
const file = await new Promise((resolve) => {
|
||||
const picker = document.createElement("input");
|
||||
picker.type = "file";
|
||||
picker.accept = ".estx";
|
||||
picker.addEventListener("change", () => resolve(picker.files[0]));
|
||||
picker.click();
|
||||
});
|
||||
if (!file) return;
|
||||
const source = await EstApp.api(`/api/import?name=${encodeURIComponent(file.name)}`, {
|
||||
method: "POST",
|
||||
body: await file.arrayBuffer(),
|
||||
});
|
||||
const list = rows(source, tag);
|
||||
EstApp.change((project) => {
|
||||
const year = project.annual[index];
|
||||
year.rows = list.slice(0, project.tables.EstMainDat.length);
|
||||
year.formula = file.name.replace(/\.estx$/i, "");
|
||||
}, false);
|
||||
state.notice = `「${file.name}」 내역서 ${list.length}줄을 차수에 불러왔습니다.`;
|
||||
}
|
||||
function handle(name, state, node) {
|
||||
if (name !== "annual-load") return false;
|
||||
load(state, Number(node.dataset.row), Number(node.dataset.tag)).catch((error) => {
|
||||
state.notice = error.message;
|
||||
EstApp.render();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return { row, rows, handle };
|
||||
})();
|
||||
@@ -0,0 +1,187 @@
|
||||
// 원본 품셈 보기(barbtnBook → ProductBook.exe · 같은 화면 TfrmBookViewer)와 품셈자료 붙여넣기(popupPasteBook 10d6e28).
|
||||
// 보기: 품셈 고르기 → 장 트리(펼치기·접기) → 본문(표는 칸 나눔), 검색(제목 · 제목+본문 · 키워드, 검색어 빈칸·'|' 지움),
|
||||
// 본문 검색(현재 쪽 강조), 복사하기(고른 글 또는 쪽 전체 → 클립보드, 붙여넣기 켬). 자료는 서버 book-store.cjs 가 원본 BookData 를 읽음.
|
||||
// 붙여넣기(원본은 품셈에서 복사한 뒤에만 켜짐): 단가산출은 첫 줄을 마지막 산출근거 칸 커서 자리에, 둘째 줄부터 그 줄 아래 새 줄로(다듬지 않음).
|
||||
// 내역서·일위대가·기계경비는 마지막으로 누른 칸 하나에 마지막 줄(글자 칸은 Trim, 수치 칸은 수치만).
|
||||
const EstBook = (() => {
|
||||
let lastCell = null;
|
||||
if (typeof document !== "undefined" && document.addEventListener)
|
||||
document.addEventListener("focusin", (event) => {
|
||||
const t = event.target.closest?.("[data-table][data-field][data-row]");
|
||||
if (t) lastCell = t;
|
||||
});
|
||||
|
||||
const api = (p) => EstApp.api(`/api/book${p}`);
|
||||
const st = (state) => (state.book ||= { name: "", open: new Set(), hits: null, pkid: null });
|
||||
|
||||
function treeHtml(b, nodes) {
|
||||
const { esc } = EstApp,
|
||||
kids = new Map();
|
||||
for (const n of nodes) {
|
||||
const k = n.parent ?? "root";
|
||||
if (!kids.has(k)) kids.set(k, []);
|
||||
kids.get(k).push(n);
|
||||
}
|
||||
for (const list of kids.values())
|
||||
list.sort((a, c) => (a.order == null) - (c.order == null) || (a.order ?? 0) - (c.order ?? 0));
|
||||
const walk = (key, depth) =>
|
||||
(kids.get(key) || [])
|
||||
.map((n) => {
|
||||
const has = kids.has(n.pkid),
|
||||
open = b.open.has(n.pkid);
|
||||
return `<div style="padding-left:${depth * 14}px">${has ? `<button class="tiny" data-action="book-fold" data-pkid="${n.pkid}">${open ? "−" : "+"}</button>` : "<span style='display:inline-block;width:18px'></span>"}<a href="#" data-book-node="${n.pkid}"${n.pkid === b.pkid ? ' style="font-weight:bold"' : ""}>${esc(n.chapter)} ${esc(n.title)}</a></div>${has && open ? walk(n.pkid, depth + 1) : ""}`;
|
||||
})
|
||||
.join("");
|
||||
return walk("root", 0);
|
||||
}
|
||||
function pageHtml(page, mark) {
|
||||
const { esc } = EstApp;
|
||||
const hl = (t) =>
|
||||
mark
|
||||
? esc(t)
|
||||
.split(esc(mark))
|
||||
.join(`<mark>${esc(mark)}</mark>`)
|
||||
: esc(t);
|
||||
const lines = String(page.text || "").split("\n");
|
||||
let html = "",
|
||||
table = [];
|
||||
const flush = () => {
|
||||
if (table.length)
|
||||
html += `<table class="sheet" style="font-size:12px">${table.map((cells) => `<tr>${cells.map((c) => `<td style="text-align:left">${hl(c)}</td>`).join("")}</tr>`).join("")}</table>`;
|
||||
table = [];
|
||||
};
|
||||
for (const line of lines) {
|
||||
if (line.includes("\t")) table.push(line.replace(/\t$/, "").split("\t"));
|
||||
else {
|
||||
flush();
|
||||
html += `<div style="white-space:pre-wrap">${hl(line) || " "}</div>`;
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return `<h4>${esc(page.chapter)} ${esc(page.title)}</h4>${html}`;
|
||||
}
|
||||
|
||||
async function render(state) {
|
||||
const b = st(state),
|
||||
{ esc } = EstApp;
|
||||
b.books ||= await api("");
|
||||
b.name ||= b.books[0]?.name;
|
||||
b.tree = b.treeName === b.name ? b.tree : await api(`/${b.name}/tree`);
|
||||
b.treeName = b.name;
|
||||
const left = b.hits
|
||||
? `<p>검색 결과 ${b.hits.length}건 <button data-action="book-tree">목차로</button></p>${b.hits.map((h) => `<div><a href="#" data-book-node="${h.pkid}">${esc(h.chapter)} ${esc(h.title)}</a></div>`).join("")}`
|
||||
: treeHtml(b, b.tree);
|
||||
EstApp.showDialog(
|
||||
"품셈",
|
||||
`<div class="business-tools"><select data-book-name>${b.books.map((x) => `<option value="${x.name}"${x.name === b.name ? " selected" : ""}>${esc(x.desc)}</option>`).join("")}</select><input data-book-word value="${esc(b.word || "")}" placeholder="검색어"><button data-action="book-search" data-mode="title">검 색</button><button data-action="book-search" data-mode="body">전체검색(제목+본문)</button><button data-action="book-search" data-mode="keyword">키워드검색</button><button data-action="book-expand">펼치기</button><button data-action="book-collapse">접기</button><input data-book-mark value="${esc(b.mark || "")}" placeholder="본문 검색" size="10"><button data-action="book-mark">본문 검색</button><button data-action="book-copy">복사하기</button></div><div style="display:flex;gap:10px;height:62vh"><div style="flex:0 0 38%;overflow:auto;border:1px solid #ccc;padding:4px;font-size:12px">${left}</div><div id="book-page" style="flex:1;overflow:auto;border:1px solid #ccc;padding:6px">${b.page ? pageHtml(b.page, b.mark) : "<p>왼쪽에서 항목을 고르세요.</p>"}</div></div>`,
|
||||
);
|
||||
}
|
||||
|
||||
async function run(name, state, node) {
|
||||
const b = st(state),
|
||||
v = (sel) => document.querySelector(`#dialog-content ${sel}`)?.value;
|
||||
if (name === "book") return render(state);
|
||||
if (name === "book-fold") {
|
||||
const id = Number(node.dataset.pkid);
|
||||
if (b.open.has(id)) b.open.delete(id);
|
||||
else b.open.add(id);
|
||||
} else if (name === "book-expand") b.open = new Set(b.tree.map((n) => n.pkid));
|
||||
else if (name === "book-collapse") b.open = new Set();
|
||||
else if (name === "book-tree") b.hits = null;
|
||||
else if (name === "book-search") {
|
||||
b.word = v("[data-book-word]") || "";
|
||||
b.hits = await api(
|
||||
`/${b.name}/search?q=${encodeURIComponent(b.word)}&mode=${node.dataset.mode}`,
|
||||
);
|
||||
} else if (name === "book-mark") b.mark = (v("[data-book-mark]") || "").trim();
|
||||
else if (name === "book-copy") {
|
||||
const sel = String(getSelection()).trim(),
|
||||
text = sel || b.page?.text || "";
|
||||
if (!text) throw Error("복사할 품셈 내용을 고르세요.");
|
||||
state.bookClip = text;
|
||||
navigator.clipboard?.writeText(text).catch(() => {});
|
||||
state.notice = "품셈 내용을 복사했습니다. 품셈자료 붙여넣기로 넣을 수 있습니다.";
|
||||
} else if (name === "book-paste") return paste(state);
|
||||
return render(state);
|
||||
}
|
||||
|
||||
function paste(state) {
|
||||
if (!state.bookClip) throw Error("품셈에서 먼저 복사하기를 하세요.");
|
||||
const lines = state.bookClip.split(/\r?\n/);
|
||||
if (!lastCell) throw Error("붙여넣을 칸을 먼저 누르세요.");
|
||||
const table = lastCell.dataset.table,
|
||||
row = Number(lastCell.dataset.row),
|
||||
field = lastCell.dataset.field;
|
||||
if (table === "CostMainDat") {
|
||||
if (field !== "LineData") throw Error("단가산출 산출근거 칸을 누른 뒤 붙여넣으세요.");
|
||||
const caret = lastCell.selectionStart ?? lastCell.value.length,
|
||||
shown = lastCell.value,
|
||||
first = shown.slice(0, caret) + lines[0] + shown.slice(lastCell.selectionEnd ?? caret);
|
||||
EstApp.change((project) => {
|
||||
const rows = project.tables.CostMainDat;
|
||||
rows[row].LineData = EstText.encode(first);
|
||||
let pk = Math.max(0, ...rows.map((r) => Number(r.PkID) || 0));
|
||||
rows.splice(
|
||||
row + 1,
|
||||
0,
|
||||
...lines.slice(1).map((LineData) => ({ PkID: ++pk, Index: 0, LineData })),
|
||||
);
|
||||
});
|
||||
} else if (["EstMainDat", "HeadMainDat", "MachMainDat"].includes(table)) {
|
||||
const line = lines.at(-1).trim();
|
||||
if (["Code", "SCheck"].includes(field)) throw Error("이 칸에는 붙여넣을 수 없습니다.");
|
||||
let value = line;
|
||||
if (EstModel.numeric.has(field)) {
|
||||
if (!/^[+-]?[\d,.]+$/.test(line)) throw Error("현재 칼럼에는 수치만 붙여넣을수 있습니다.");
|
||||
value = Number(line) || 0;
|
||||
}
|
||||
EstApp.change((project) => (project.tables[table][row][field] = value));
|
||||
} else throw Error("내역서·일위대가·단가산출·기계경비 칸에 붙여넣으세요.");
|
||||
return true;
|
||||
}
|
||||
|
||||
function install() {
|
||||
document.addEventListener("click", (event) => {
|
||||
const a = event.target.closest("[data-book-node]");
|
||||
if (!a) return;
|
||||
event.preventDefault();
|
||||
const state = EstApp.state,
|
||||
b = st(state);
|
||||
b.pkid = Number(a.dataset.bookNode);
|
||||
api(`/${b.name}/text/${b.pkid}`)
|
||||
.then((page) => {
|
||||
b.page = page;
|
||||
return render(state);
|
||||
})
|
||||
.catch((error) => (state.notice = error.message));
|
||||
});
|
||||
document.addEventListener("change", (event) => {
|
||||
if (event.target.dataset.bookName === undefined) return;
|
||||
const b = st(EstApp.state);
|
||||
Object.assign(b, { name: event.target.value, open: new Set(), hits: null, page: null });
|
||||
render(EstApp.state).catch((error) => (EstApp.state.notice = error.message));
|
||||
});
|
||||
}
|
||||
if (typeof document !== "undefined" && document.addEventListener) install();
|
||||
|
||||
const names = new Set([
|
||||
"book",
|
||||
"book-fold",
|
||||
"book-expand",
|
||||
"book-collapse",
|
||||
"book-tree",
|
||||
"book-search",
|
||||
"book-mark",
|
||||
"book-copy",
|
||||
"book-paste",
|
||||
]);
|
||||
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 { handle };
|
||||
})();
|
||||
@@ -636,7 +636,10 @@ const EstBusinessActions = (() => {
|
||||
(typeof EstEsInfo !== "undefined" && EstEsInfo.handle(name, state)) ||
|
||||
(typeof EstJdlCompare !== "undefined" && EstJdlCompare.handle(name, state, node)) ||
|
||||
(typeof EstGlobalVars !== "undefined" && EstGlobalVars.handle(name, state, node)) ||
|
||||
(typeof EstNewEstimate !== "undefined" && EstNewEstimate.handle(name, state))
|
||||
(typeof EstNewEstimate !== "undefined" && EstNewEstimate.handle(name, state)) ||
|
||||
(typeof EstBook !== "undefined" && EstBook.handle(name, state, node)) ||
|
||||
(typeof EstDataUpdate !== "undefined" && EstDataUpdate.handle(name, state)) ||
|
||||
(typeof EstAnnualLoad !== "undefined" && EstAnnualLoad.handle(name, state, node))
|
||||
) {
|
||||
EstApp.render();
|
||||
} else if (name === "insert-annual") {
|
||||
|
||||
@@ -632,6 +632,7 @@ const EstBusiness = (() => {
|
||||
row.LinkIndex = 99;
|
||||
}
|
||||
if (num(row.Level) > 0 || [54, 55, 58, 59, 62, 67].includes(num(row.LinkIndex))) continue;
|
||||
if (typeof EstAnnualLoad !== "undefined" && EstAnnualLoad.row(year, index, row)) continue;
|
||||
try {
|
||||
// f0f9b8/f1098c resolve the same column formula against each current field.
|
||||
const value = (field) =>
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
// 원본 「현재데이타 갱신」(TFRMDATAUPDATE btnApply 0x10bc3d4 → 엔진 0x10e12c8 — spec_update) — 원천 공사(Main Data = 원본 main.estx,
|
||||
// 또는 고른 .estx)의 단가로 현재 공사를 갱신. 항목: 노임(LaborcDat) · 자재(StuffcDat, 유류 제외) · 유류(이름이 휘발유·경유·중유 등 8종) ·
|
||||
// 표준(SilJukDat, 코드 일치) · 기계(MachGijunDat 통째 교체 + MachTitleDat 이름·규격 일치 + 그 산출근거 줄 교체) · 환율(MachGijunDat 36·89·69·77·70·76).
|
||||
// 맞추기: 코드(표준만) 또는 품명 / 품명+규격 / 품명+규격+단위(빈칸·|·(·) 제거, 단위는 첫 「당」·「[」·「]」 제거, 노임·기계는 단위 무시).
|
||||
// 찾으면 단가 칸을 원천 값으로(코드·품명·규격·단위·Index·낙찰률·「_」 당초 칸 제외), 단가미갱신(Checked) 끔. 못 찾으면 Checked 켬.
|
||||
// 행을 더하거나 지우지 않음(기계 산출근거 줄만 교체). 최신(웹접속)은 원본 회사 서버·유지보수 인증이 필요해 두지 않음.
|
||||
// 원본 결함(환율 끔 때 파운드(70) 환율을 76 값으로 덮음)은 재현하지 않음. 자재 단가열 출처 재배치(OtherTables)는 같은 열 순서로 가정.
|
||||
const EstDataUpdate = (() => {
|
||||
const num = (v) => Number(v) || 0;
|
||||
const norm = (s) => String(s ?? "").replace(/[\s|()]/g, "");
|
||||
const unit = (s) => norm(s).replace("당", "").replace("[", "").replace("]", "");
|
||||
const FUEL = ["휘발유", "경유", "중유", "가솔린", "디젤", "diesel", "gasoline", "heavyoil"];
|
||||
const isFuel = (row) =>
|
||||
FUEL.includes(
|
||||
String(row.Name ?? "")
|
||||
.replace(/[\s|]/g, "")
|
||||
.toLowerCase(),
|
||||
);
|
||||
const RATES = [36, 89, 69, 77, 70, 76];
|
||||
const SKIP = new Set([
|
||||
"Code",
|
||||
"Name",
|
||||
"Size",
|
||||
"Unite",
|
||||
"Index",
|
||||
"NachalRatio",
|
||||
"ErrorCheck",
|
||||
"PkID",
|
||||
"Checked",
|
||||
]);
|
||||
|
||||
function finder(source, kind) {
|
||||
return (row) => {
|
||||
if (kind === 21) {
|
||||
const code = norm(row.Code);
|
||||
return code ? source.find((s) => norm(s.Code) === code) : null;
|
||||
}
|
||||
const size = norm(row.Size),
|
||||
u = kind === 6 || kind === 105 ? "" : unit(row.Unite);
|
||||
return source.find((s) => {
|
||||
if (norm(s.Name) !== norm(row.Name)) return false;
|
||||
const sSize = kind === 6 ? "" : norm(s.Size);
|
||||
if (!size && !u) return true;
|
||||
if (sSize !== size) return false;
|
||||
if (!u) return true;
|
||||
return (kind === 105 ? "" : unit(s.Unite)) === u;
|
||||
});
|
||||
};
|
||||
}
|
||||
function copy(target, src, skipRatio) {
|
||||
for (const [key, value] of Object.entries(src)) {
|
||||
if (SKIP.has(key) || key.endsWith("_") || typeof value === "boolean") continue;
|
||||
if (skipRatio && key === "Ratio") continue;
|
||||
if (typeof value === "string" && !/Page|^Bigo$|^EXPLEAN$|^Date$/.test(key)) continue;
|
||||
target[key] = structuredClone(value);
|
||||
}
|
||||
}
|
||||
// 표 하나 갱신 → 못 찾은 수
|
||||
function table(rows, source, kind, filter = () => true, skipRatio = false) {
|
||||
const find = finder(source, kind);
|
||||
let missing = 0;
|
||||
for (const row of rows || []) {
|
||||
if (!norm(row.Name) && !norm(row.Size)) continue;
|
||||
if (!filter(row)) continue;
|
||||
const src = find(row);
|
||||
if (!src) {
|
||||
missing++;
|
||||
row.Checked = true;
|
||||
continue;
|
||||
}
|
||||
row.Checked = false;
|
||||
const code = row.Code;
|
||||
copy(row, src, skipRatio);
|
||||
if (kind === 21)
|
||||
Object.assign(row, { Code: code, Name: src.Name, Size: src.Size, Unite: src.Unite });
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
function groups(rows) {
|
||||
const out = new Map();
|
||||
let current = null;
|
||||
(rows || []).forEach((row, i) => {
|
||||
if (num(row.Index) > 0) {
|
||||
current = { head: i, body: [] };
|
||||
out.set(row.Code, current);
|
||||
} else if (current) current.body.push(i);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
function machine(project, source, notices) {
|
||||
const t = project.tables,
|
||||
s = source.tables;
|
||||
// 손료 계수·환율 표 통째 교체, 「_」 당초 칸은 같은 Index 행의 옛 값 유지
|
||||
const old = new Map((t.MachGijunDat || []).map((r) => [num(r.Index), r]));
|
||||
t.MachGijunDat = structuredClone(s.MachGijunDat || []).map((r) => {
|
||||
const prev = old.get(num(r.Index));
|
||||
if (prev) for (const k of Object.keys(prev)) if (k.endsWith("_")) r[k] = prev[k];
|
||||
return r;
|
||||
});
|
||||
if (project.baseline) {
|
||||
notices.push(
|
||||
"설계변경이 이루어진 데이타의 기계경비는적용기준(환율,노임,유류대)만 갱신됩니다.",
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
const find = finder(s.MachTitleDat || [], 105);
|
||||
let missing = 0;
|
||||
const tg = groups(t.MachMainDat),
|
||||
sg = groups(s.MachMainDat);
|
||||
const replaced = [];
|
||||
for (const title of t.MachTitleDat || []) {
|
||||
if (!norm(title.Name) && !norm(title.Size)) continue;
|
||||
const src = find(title);
|
||||
if (!src) {
|
||||
missing++;
|
||||
title.Checked = true;
|
||||
continue;
|
||||
}
|
||||
title.Checked = false;
|
||||
copy(title, src);
|
||||
const mine = tg.get(title.Code),
|
||||
theirs = sg.get(src.Code);
|
||||
if (mine && theirs) {
|
||||
copy(t.MachMainDat[mine.head], s.MachMainDat[theirs.head]);
|
||||
replaced.push([mine, theirs.body.map((i) => structuredClone(s.MachMainDat[i]))]);
|
||||
}
|
||||
}
|
||||
// 산출근거 줄 교체(뒤에서부터) + 자재·노임 연결은 현재 공사의 같은 이름 자료로 다시 잇기(못 찾으면 코드 비우고 211/212)
|
||||
replaced.sort((a, b) => b[0].head - a[0].head);
|
||||
for (const [mine, body] of replaced) {
|
||||
for (const row of body) {
|
||||
const li = num(row.LinkIndex);
|
||||
if (li !== 5 && li !== 6) continue;
|
||||
const list = li === 5 ? t.StuffcDat : t.LaborcDat,
|
||||
hit = (list || []).find(
|
||||
(r) => norm(r.Name) === norm(row.Name) && (li === 6 || norm(r.Size) === norm(row.Size)),
|
||||
);
|
||||
if (hit) row.Code = hit.Code;
|
||||
else Object.assign(row, { Code: "", LinkIndex: li === 5 ? 212 : 211 });
|
||||
}
|
||||
const start = mine.head + 1,
|
||||
count = mine.body.length;
|
||||
t.MachMainDat.splice(start, count, ...body);
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
// options = { labor, stuff, siljuk, mach, fuel, rate }
|
||||
function apply(project, source, o) {
|
||||
const t = project.tables,
|
||||
s = source.tables,
|
||||
notices = [];
|
||||
const keep = new Map(
|
||||
(t.MachGijunDat || [])
|
||||
.filter((r) => RATES.includes(num(r.Index)))
|
||||
.map((r) => [num(r.Index), { TC: r.TC, Bigo: r.Bigo }]),
|
||||
);
|
||||
const miss = { 노임단가: 0, 자재단가: 0, 표준시장단가: 0, 기계경비: 0 };
|
||||
if (o.labor) miss.노임단가 = table(t.LaborcDat, s.LaborcDat || [], 6);
|
||||
if (o.stuff && o.fuel) miss.자재단가 = table(t.StuffcDat, s.StuffcDat || [], 5);
|
||||
else if (o.fuel) table(t.StuffcDat, s.StuffcDat || [], 5, isFuel, true);
|
||||
else if (o.stuff) table(t.StuffcDat, s.StuffcDat || [], 5, (r) => !isFuel(r), true);
|
||||
if (o.siljuk) miss.표준시장단가 = table(t.SilJukDat, s.SilJukDat || [], 21);
|
||||
if (o.mach) miss.기계경비 = machine(project, source, notices);
|
||||
const rows = t.MachGijunDat || [];
|
||||
if (o.rate && !o.mach)
|
||||
for (const i of RATES) {
|
||||
const mine = rows.find((r) => num(r.Index) === i),
|
||||
theirs = (s.MachGijunDat || []).find((r) => num(r.Index) === i);
|
||||
if (mine && theirs) Object.assign(mine, { TC: theirs.TC, Bigo: theirs.Bigo });
|
||||
}
|
||||
if (!o.rate)
|
||||
for (const [i, v] of keep) {
|
||||
const mine = rows.find((r) => num(r.Index) === i);
|
||||
if (mine) Object.assign(mine, v);
|
||||
}
|
||||
const parts = Object.entries(miss)
|
||||
.filter(([, n]) => n > 0)
|
||||
.map(([k, n]) => `${k}에서 ${n} 개`);
|
||||
if (parts.length)
|
||||
notices.push(
|
||||
`${parts.join(", ")}의 찾지못한 데이타가 있습니다.\n\n단가미갱신란에 체크된 데이타를 검토하세요.`,
|
||||
);
|
||||
return { missing: miss, notices };
|
||||
}
|
||||
|
||||
// 화면
|
||||
function dialog(state) {
|
||||
const opts = [
|
||||
["labor", "노임단가"],
|
||||
["stuff", "자재단가"],
|
||||
["siljuk", "표준단가"],
|
||||
["mach", "기계경비"],
|
||||
["fuel", "유류대"],
|
||||
["rate", "환율"],
|
||||
];
|
||||
EstApp.showDialog(
|
||||
"현재데이타 갱신",
|
||||
`<p><label><input type="radio" name="du-src" value="main" checked> Main Data로 갱신(원본 설치 main.estx)</label><br><label><input type="radio" name="du-src" value="file"> 선택파일로 갱신(.estx)</label><br><label><input type="radio" disabled> 최신기초 자료로 갱신(웹접속) — 원본 회사 서버·유지보수 인증이 필요해 쓰지 않음</label></p><p>갱신시 적용할 항목 : ${opts.map(([k, l]) => `<label><input type="checkbox" name="du-opt" value="${k}" checked> ${l}</label>`).join(" ")}</p><p class="business-note">※주의사항 — 현재데이타 갱신후 이전 상태로 복원할 수 없습니다(저장 전에는 ↶ 취소로 되돌림).</p><button data-action="data-update-run">갱 신</button>`,
|
||||
);
|
||||
}
|
||||
async function source(kind) {
|
||||
if (kind === "main") return EstApp.api("/api/main-data");
|
||||
const file = await new Promise((resolve) => {
|
||||
const picker = document.createElement("input");
|
||||
picker.type = "file";
|
||||
picker.accept = ".estx";
|
||||
picker.addEventListener("change", () => resolve(picker.files[0]));
|
||||
picker.click();
|
||||
});
|
||||
if (!file) throw Error("갱신할 파일을 선택하지 않았습니다.");
|
||||
return EstApp.api(`/api/import?name=${encodeURIComponent(file.name)}`, {
|
||||
method: "POST",
|
||||
body: await file.arrayBuffer(),
|
||||
});
|
||||
}
|
||||
async function run(state) {
|
||||
const kind = document.querySelector('#dialog-content [name="du-src"]:checked')?.value,
|
||||
o = Object.fromEntries(
|
||||
[...document.querySelectorAll('#dialog-content [name="du-opt"]')].map((n) => [
|
||||
n.value,
|
||||
n.checked,
|
||||
]),
|
||||
);
|
||||
if (!Object.values(o).some(Boolean)) throw Error("갱신할 항목을 고르세요.");
|
||||
const src = await source(kind);
|
||||
if (
|
||||
!confirm(
|
||||
`현재 작업데이타를 "${kind === "main" ? "Main" : src.name}"의 단가로 갱신하시겠습니까?`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
let result;
|
||||
EstApp.byId("dialog").close();
|
||||
EstApp.change((project) => (result = apply(project, src, o)));
|
||||
state.notice = result.notices.join(" / ") || "현재데이타를 갱신했습니다.";
|
||||
EstApp.render();
|
||||
}
|
||||
function handle(name, state) {
|
||||
if (name === "data-update") dialog(state);
|
||||
else if (name === "data-update-run")
|
||||
run(state).catch((error) => {
|
||||
state.notice = error.message;
|
||||
EstApp.render();
|
||||
});
|
||||
else return false;
|
||||
return true;
|
||||
}
|
||||
return { apply, isFuel, handle };
|
||||
})();
|
||||
@@ -171,7 +171,7 @@ const EstEstimatePrint = (() => {
|
||||
`<label><input name="ep-${k}" type="checkbox" ${s[k] ? "checked" : ""}>${label}</label>`;
|
||||
EstApp.showDialog(
|
||||
"견적서 작성",
|
||||
`<div class="form-fields">${input("Title_Name", "제목")}${input("Project_Name", "공사명(| 줄바꿈)")}${input("Order_name", "발주처")}${input("Esti_Date", "견적일자")}<label>견적일자 선택<input type="date" data-date-target="ep-Esti_Date" data-date-format="estimate"></label>${input("Level", "설계내역 레벨표시(0~9)", "number")}${input("F_PageCount", "첫 페이지 줄수", "number")}${input("L_PageCount", "나머지 페이지 줄수", "number")}<label>품명·규격 정렬<select name="ep-Aligment"><option value="0" ${num(s.Aligment) ? "" : "selected"}>왼쪽</option><option value="1" ${num(s.Aligment) ? "selected" : ""}>가운데</option></select></label>${check("Bigo_Show", "비고란 보기")}${check("Company_Show", "공급자 정보 인쇄")}</div><h4>공급자</h4><div class="form-fields">${input("Reg_Num", "등록번호")}${input("ComPany_Name", "상호")}${input("Ceo_Name", "대표자")}${input("Addr", "사업장주소")}${input("Uptae", "업태")}${input("Jongmog", "종목")}${input("Tel", "전화,(FAX)")}</div><button data-action="estimate-print-preview">미리보기</button> <button data-action="estimate-print-print">인쇄</button> <button data-action="estimate-print-save">공급자정보 저장하기(.einf)</button> <button data-action="estimate-print-load">공급자정보 불러오기</button><iframe id="estimate-print-frame" style="width:100%;height:420px;border:1px solid #ccc;margin-top:8px"></iframe>`,
|
||||
`<div class="form-fields">${input("Title_Name", "제목")}${input("Project_Name", "공사명(| 줄바꿈)")}${input("Order_name", "발주처")}${input("Esti_Date", "견적일자")}<label>견적일자 선택<input type="date" data-date-target="ep-Esti_Date" data-date-format="estimate"></label>${input("Level", "설계내역 레벨표시(0~9)", "number")}${input("F_PageCount", "첫 페이지 줄수", "number")}${input("L_PageCount", "나머지 페이지 줄수", "number")}<label>품명·규격 정렬<select name="ep-Aligment"><option value="0" ${num(s.Aligment) ? "" : "selected"}>왼쪽</option><option value="1" ${num(s.Aligment) ? "selected" : ""}>가운데</option></select></label>${check("Bigo_Show", "비고란 보기")}${check("Company_Show", "공급자 정보 인쇄")}</div><h4>공급자</h4><div class="form-fields">${input("Reg_Num", "등록번호")}${input("ComPany_Name", "상호")}${input("Ceo_Name", "대표자")}${input("Addr", "사업장주소")}${input("Uptae", "업태")}${input("Jongmog", "종목")}${input("Tel", "전화,(FAX)")}</div><button data-action="estimate-print-preview">미리보기</button> <button data-action="estimate-print-print">인쇄</button> <button data-action="estimate-print-excel">엑셀변환</button> <button data-action="estimate-print-save">공급자정보 저장하기(.einf)</button> <button data-action="estimate-print-load">공급자정보 불러오기</button><iframe id="estimate-print-frame" style="width:100%;height:420px;border:1px solid #ccc;margin-top:8px"></iframe>`,
|
||||
);
|
||||
}
|
||||
function read(state) {
|
||||
@@ -198,7 +198,83 @@ const EstEstimatePrint = (() => {
|
||||
return frame;
|
||||
}
|
||||
|
||||
// 엑셀변환(TFRMEXCELCONVERTESTIPRN btnConvert): 견적서 머리(제목·공사명·귀하·견적일자·합계금액·공급자)와 내역 줄을 .xlsx 로.
|
||||
// 원본의 인쇄비율·셀 수식(TRUNC·합) 은 웹 Excel 내보내기가 값만 쓰므로 하지 않는다.
|
||||
async function excel(state) {
|
||||
const s = read(state),
|
||||
list = rows(state.project, num(s.Level)).filter((r) => r.Name || r.Code),
|
||||
sum = total(state.project);
|
||||
const columns = [
|
||||
"품명",
|
||||
"규격",
|
||||
"단위",
|
||||
"수량",
|
||||
"재료단가",
|
||||
"재료금액",
|
||||
"노무단가",
|
||||
"노무금액",
|
||||
"경비단가",
|
||||
"경비금액",
|
||||
"합계단가",
|
||||
"합계금액",
|
||||
"비고",
|
||||
];
|
||||
const v = (x) => (typeof x === "number" ? x : Number(x) || 0);
|
||||
const body = list.map((r) => [
|
||||
String(r.Name ?? ""),
|
||||
String(r.Size ?? ""),
|
||||
String(r.Unite ?? ""),
|
||||
v(r.GN),
|
||||
v(r.GU),
|
||||
v(r.GC),
|
||||
v(r.NU),
|
||||
v(r.NC),
|
||||
v(r.JU),
|
||||
v(r.JC),
|
||||
v(r.TU),
|
||||
v(r.TC),
|
||||
s.Bigo_Show ? String(r.Bigo ?? "") : "",
|
||||
]);
|
||||
const head = [
|
||||
s.Title_Name,
|
||||
`공사명 : ${String(s.Project_Name).replaceAll("|", " ")}`,
|
||||
`${s.Order_name} 귀하`,
|
||||
s.Esti_Date,
|
||||
"아래와 같이 견적합니다",
|
||||
`합계금액${totalText(sum)}`,
|
||||
s.Company_Show
|
||||
? `공급자 등록번호 ${s.Reg_Num} · 상호 ${s.ComPany_Name} · 성명 ${s.Ceo_Name} · 사업장주소 ${s.Addr} · 업태 ${s.Uptae} · 종목 ${s.Jongmog} · 전화 ${s.Tel}`
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(String.fromCharCode(10));
|
||||
const response = await fetch("/api/export-report-xlsx", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: `${state.project.name}_견적서`,
|
||||
columns,
|
||||
rows: body,
|
||||
detail: head,
|
||||
errors: [],
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw Error((await response.json()).error);
|
||||
EstApp.download(
|
||||
`${state.project.name || "견적서"}_견적서.xlsx`,
|
||||
await response.blob(),
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
);
|
||||
}
|
||||
|
||||
function handle(name, state) {
|
||||
if (name === "estimate-print-excel") {
|
||||
excel(state).catch((error) => {
|
||||
state.notice = error.message;
|
||||
EstApp.render();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (name === "estimate-print") open(state);
|
||||
else if (name === "estimate-print-preview") preview(state);
|
||||
else if (name === "estimate-print-print") {
|
||||
|
||||
@@ -329,6 +329,15 @@ const EstExtra = (() => {
|
||||
`data-row="${index}" data-direction="1" ${index === project.annual.length - 1 ? "disabled" : ""}`,
|
||||
) +
|
||||
button("insert-annual", "위에 삽입", `data-row="${index}"`) +
|
||||
["내역서", "상단", "하단"]
|
||||
.map((l, t) =>
|
||||
button(
|
||||
"annual-load",
|
||||
`${l}데이타 불러오기`,
|
||||
`data-row="${index}" data-tag="${t}"`,
|
||||
),
|
||||
)
|
||||
.join("") +
|
||||
button("delete-extra", "삭제", `data-section="annual" data-row="${index}"`),
|
||||
]),
|
||||
),
|
||||
|
||||
@@ -6,6 +6,10 @@ const http = require("node:http"),
|
||||
crypto = require("node:crypto");
|
||||
const { importEstx, table: dbisamTable } = require("./import-estx.cjs");
|
||||
const { readXls } = require("./xls-read.cjs");
|
||||
const bookStore = require("./book-store.cjs");
|
||||
const mainDataPath =
|
||||
process.env.EST_MAIN_DATA || "C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx";
|
||||
let mainData = null;
|
||||
const exchange = require("./exchange.cjs");
|
||||
const { exportEstx } = require("./export-estx.cjs");
|
||||
const jdl = require("./jdl.cjs");
|
||||
@@ -32,6 +36,7 @@ const scripts = [
|
||||
"native-row-match.js",
|
||||
"native-escalation.js",
|
||||
"native-progress.js",
|
||||
"native-annual-load.js",
|
||||
"native-business.js",
|
||||
"native-review.js",
|
||||
"native-resources.js",
|
||||
@@ -87,6 +92,8 @@ const allowed = new Set([
|
||||
"native-jdl-compare.js",
|
||||
"native-global-vars.js",
|
||||
"native-new-estimate.js",
|
||||
"native-book.js",
|
||||
"native-data-update.js",
|
||||
"native-wonga-tools.js",
|
||||
"native-estimate-print.js",
|
||||
"native-xml-check.js",
|
||||
@@ -249,6 +256,46 @@ const server = http.createServer(async (req, res) => {
|
||||
reply(res, 200, importEstx(bytes, name));
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/api/main-data") {
|
||||
// 현재데이타 갱신 원천 Main Data(원본 설치 main.estx = BufDat\Main\Main_1) — 갱신에 쓰는 표만
|
||||
if (!mainData) {
|
||||
const project = importEstx(fs.readFileSync(mainDataPath), "Main");
|
||||
const keys = [
|
||||
"LaborcDat",
|
||||
"StuffcDat",
|
||||
"SilJukDat",
|
||||
"MachTitleDat",
|
||||
"MachMainDat",
|
||||
"MachGijunDat",
|
||||
];
|
||||
mainData = {
|
||||
name: "Main",
|
||||
tables: Object.fromEntries(keys.map((k) => [k, project.tables[k] || []])),
|
||||
};
|
||||
}
|
||||
reply(res, 200, mainData);
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname.startsWith("/api/book")) {
|
||||
// 품셈 보기(원본 BookData) — /api/book · /api/book/<책>/tree · /text/<pkid> · /search?q=&mode=title|body|keyword
|
||||
const [, book, what, id] = url.pathname.slice(1).split("/").slice(1);
|
||||
reply(
|
||||
res,
|
||||
200,
|
||||
!book
|
||||
? bookStore.list()
|
||||
: what === "tree"
|
||||
? bookStore.tree(book)
|
||||
: what === "text"
|
||||
? bookStore.text(book, id)
|
||||
: what === "search"
|
||||
? bookStore.search(book, url.searchParams.get("q"), url.searchParams.get("mode"))
|
||||
: (() => {
|
||||
throw Error("품셈 요청 경로 없음");
|
||||
})(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/api/read-xls") {
|
||||
reply(res, 200, readXls(await body(req)));
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user