feat(est-test): 갑지·표지·공사설명서·시방서 편집과 인쇄
- 원본 문서 레코드(MIDAS) 읽기, 공사명 동기화, 공사금액 자동입력·한글금액 - 설명서·시방서 갑지 붙여넣기, 인쇄 미리보기 반영, 편집 시 ESTX 저장 거부 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
"""EST 테스트 앱 갑지·표지·설명서(원본 TFRMGABGI·TFRMCOVER·TFRMESTEDITOR, DatGabgi/DatCover.opt)."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SAMPLE = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/sample.estx")
|
||||
FILES = [
|
||||
"native-math.js",
|
||||
"native-formula.js",
|
||||
"native-machine.js",
|
||||
"native-rows.js",
|
||||
"native-text.js",
|
||||
"native-quantity.cjs",
|
||||
"native-wonga.js",
|
||||
"native-engine.js",
|
||||
"native-row-match.js",
|
||||
"native-escalation.js",
|
||||
"native-progress.js",
|
||||
"native-business.js",
|
||||
"native-review.js",
|
||||
"native-resources.js",
|
||||
"native-schedule.js",
|
||||
"native-model.js",
|
||||
"native-verify.js",
|
||||
"native-docs.js",
|
||||
]
|
||||
|
||||
|
||||
def run_js(code: str):
|
||||
runner = f"""
|
||||
const fs = require('fs'), vm = require('vm');
|
||||
const ctx = vm.createContext({{ structuredClone, console }});
|
||||
for (const f of {json.dumps(FILES)})
|
||||
vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, {{ filename: f }});
|
||||
ctx.source = JSON.parse(fs.readFileSync('tools/est-test/sample.json', 'utf8'));
|
||||
process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx)));
|
||||
"""
|
||||
return json.loads(subprocess.check_output(["node", "-e", runner], cwd=ROOT).decode("utf-8"))
|
||||
|
||||
|
||||
def test_korean_amount_and_cost_lines():
|
||||
result = run_js(
|
||||
"""(() => ({
|
||||
k: [EstDocs.korean(15000000), EstDocs.korean(10), EstDocs.korean(100000001), EstDocs.korean(79603000), EstDocs.korean(0)],
|
||||
one: EstDocs.costLine('도급액', 15000000, 1), two: EstDocs.costLine('도급액', 15000000, 2),
|
||||
text: (() => { const p = EstNative.calculate(EstModel.validate(structuredClone(source)));
|
||||
const i = p.tables.Wonga.findIndex((r) => Number(r.LinkIndex) === 55); return EstDocs.costText(p, [i], 1); })(),
|
||||
}))()"""
|
||||
)
|
||||
assert result["k"] == ["일천오백만", "일십", "일억일", "칠천구백육십만삼천", "영"]
|
||||
assert result["one"] == "도급액 : 금 일천오백만원(₩15,000,000)"
|
||||
assert result["two"] == "도급액 : 금15,000,000원(금일천오백만원)"
|
||||
assert result["text"].endswith("원(₩79,603,000)")
|
||||
|
||||
|
||||
def test_preview_html_and_model_accepts_docs():
|
||||
result = run_js(
|
||||
"""(() => {
|
||||
const p = structuredClone(source);
|
||||
p.docs = { gabgi: { Name: 'x', Position: '경기도', Gulga: ['과장', '계장'], Cost: '도급액 : 금 일원(₩1)', CompanyPos: 2 },
|
||||
cover: { Name2: '부제', Date: '2026년도', CompanyName: '시청', CompanyPos: 0 }, explan: { text: '설명 본문' } };
|
||||
const g = EstDocs.html(8, p), c = EstDocs.html(9, p), e = EstDocs.html(11, p), none = EstDocs.html(10, p);
|
||||
EstModel.validate(p);
|
||||
return { g: [g.includes('경기도'), g.includes('<th>과장</th>'), g.includes(p.name), g.includes('text-align:right')],
|
||||
c: [c.includes('2026년도'), c.includes('부제'), c.includes('text-align:left')], e: e.includes('설명 본문'), none };
|
||||
})()"""
|
||||
)
|
||||
assert result == {
|
||||
"g": [True, True, True, True],
|
||||
"c": [True, True, True],
|
||||
"e": True,
|
||||
"none": "",
|
||||
}
|
||||
|
||||
|
||||
def test_import_reads_documents_and_export_refuses_edits():
|
||||
if not SAMPLE.exists():
|
||||
pytest.skip("installed EST sample unavailable")
|
||||
script = """
|
||||
const fs = require('fs'), imp = require(process.cwd() + '/tools/est-test/import-estx.cjs');
|
||||
const ex = require(process.cwd() + '/tools/est-test/export-estx.cjs');
|
||||
const p = imp.importEstx(fs.readFileSync(process.argv[1]));
|
||||
const same = Buffer.compare(ex.exportEstx(p), fs.readFileSync(process.argv[1])) === 0;
|
||||
p.docs.gabgi.Position = '바뀐 위치';
|
||||
let message = ''; try { ex.exportEstx(p); } catch (e) { message = e.message; }
|
||||
process.stdout.write(JSON.stringify({ name: p.docs.gabgi.Name, gulga: p.docs.gabgi.Gulga, cover: p.docs.cover.Date, same, message }));
|
||||
"""
|
||||
result = json.loads(
|
||||
subprocess.check_output(["node", "-e", script, str(SAMPLE)], cwd=ROOT).decode("utf-8")
|
||||
)
|
||||
assert result["name"] == "연습공사"
|
||||
assert result["gulga"] == ["과 장", "계 장", "심사자", "설계자"]
|
||||
assert result["cover"] == "2012년도"
|
||||
assert result["same"] is True
|
||||
assert result["message"].startswith("갑지·표지·설명서 편집은 ESTX")
|
||||
@@ -475,13 +475,15 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
|
||||
- 남음: 셀 편집·수식 계산·4방향 합계·품명/규격/수량 지정(항목과 셀 대응 근거 미확인), .xls 저장.
|
||||
- [x] 수량산출 누계(원본 TfrmMulWord ActionAdd Ctrl+U → 0x11155c4, 재계산 0x111c0b0 — 디스어셈블 근거) — 원본 F·a 표지를 `` 쌍·`row.totals` 로 읽고, 위쪽 품명 줄(포함)/앞 누계(제외)부터 줄마다 첫 결과 합을 표지 반올림(F+6자리+방식+자리)으로 매 계산 갱신, 커서 자리 누계 넣기. 원본 TFRMMULNAMEINPUT 「누계계산」 단추는 실제로 지정해제와 같은 처리라 따로 만들지 않음.
|
||||
- 검증완료: `test_est_quantity_total.py`(소계 범위·앞 누계 제외·반올림 표지·값 갱신 6,003·넣기 이름·표지 안 거부·원본 F 표지 읽기) 통과, 수량 시험 포함 26개 통과. ORCA 브라우저에서 「소계」 줄 끝에 누계 → ⟦누계⟧9⟦누계⟧(6+3), 샘플 복구.
|
||||
- [x] 갑지·표지·공사설명서·시방서(원본 TFRMGABGI·TFRMCOVER·TFRMESTEDITOR, DatGabgi/DatCover/DatExplean/DatSiBang.opt — 디스어셈블 근거) — `midas-read.cjs` MIDAS 레코드·WPTools 글자 읽기 → `project.docs`, 편집 창, 공사명↔공사 이름 동기화, 공사금액 자동입력 두 방식·한글금액(일 생략 없음), 설명서·시방서 갑지 붙여넣기, 인쇄 미리보기 8·9·10·11. 문서 편집 작업은 ESTX 저장 거부.
|
||||
- 검증완료: `test_est_docs.py` 3개(한글금액·금액 줄·원가 도급액, 미리보기·모델 통과, 설치 sample.estx 갑지 「연습공사」·결재란·표지 2012년도·원본 재저장 동일·편집 시 ESTX 거부) 통과. ORCA 브라우저에서 갑지 편집 → 공사금액 「금 칠천구백육십만삼천원(₩79,603,000)」, 인쇄 미리보기에 결재란·위치·공사명·개요·금액·발주처 표시(단일 표 쪽 나누기에서 갑지 제외 수정), 샘플 복구.
|
||||
- 남음: 서식·여백·글꼴·표, 양식목록(문서), 공사금액 자동변경, 작업모드 2·4의 C 판.
|
||||
- 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133.
|
||||
- [ ] 남은 핵심(실무 빈도순):
|
||||
3. 수량산출서 — 누계·4방향 합계, 제출용 전체 인쇄.
|
||||
7. 무관자료 삭제, 데이타 정리(정렬·번호), 품명정리.
|
||||
8. 원가계산서 — 정산원가계산서, 기타경비·요율 대화, 서식 저장·불러오기, 기성 원가 편집.
|
||||
9. 묶음 인쇄(설계서 한 벌).
|
||||
10. 표지·갑지·문서 편집 서식(공사명·위치·개요·금액 붙여넣기, 표).
|
||||
- 도메인 값이 필요해 사용자 협의 뒤: 산업안전보건관리비 비교, 조달청 표준공사코드, 현재데이타 갱신(단가 DB), 품셈 열람.
|
||||
|
||||
- [x] 연차 수수료의 원본 연결값 `LinkIndex_`가 57로 보존된 경우 ESTX 재열기에서 원가 계산에 반영하고, 없는 경우 저장 차단을 유지한다.
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
|
||||
`편집 → 선택 행 자재단가 등록`·`견적단가 등록`은 원본처럼 내역·일위대가 행을 새 자재(M)·견적(W) 단가로 등록하고 그 행을 새 코드에 연결합니다. 같은 품명·규격이 있으면 거부하고, 일위대가 호표 제목 줄은 등록하지 않습니다. `품명정리`는 고른 일위대가 호표의 품명을 커서 자리에서 나눠 뒤쪽을 규격 앞에 붙이고, 단위가 비어 있으면 채웁니다.
|
||||
|
||||
`파일 → 갑지·표지·공사설명서·시방서 편집`은 원본 ESTX의 문서 레코드(DatGabgi·DatCover·DatExplean·DatSiBang)를 읽어 공사명·공사위치·발주처·설계일자·공사개요·공사금액·결재란, 표지의 부제·설계년도를 고칩니다. 공사명은 공사 이름과 함께 바뀝니다. `공사금액 만들기`는 원가계산서에서 고른 비목을 `금 일천오백만원(₩15,000,000)` 또는 `금15,000,000원(금일천오백만원)` 방식으로 적습니다. 설명서·시방서에서는 갑지의 공사명·위치·개요·금액을 커서 자리에 붙입니다. 인쇄 서식 8·9·10·11 미리보기는 이 내용을 씁니다. 서식·여백·글꼴은 다루지 않고, 문서를 고친 작업은 ESTX 저장을 거부합니다.
|
||||
|
||||
`편집 → 통합 검색`은 원본 통합 검색처럼 일위대가·단가산출·기계경비·견적·실적·자재·노임 중 고른 자료를 검색어로 찾습니다. 검색 위치는 이 앱에 저장된 다른 작업입니다. 찾은 자료를 고르면 하위 연결 자료와 함께 현재 공사로 가져오고(같은 코드는 현재 자료 유지), 단가 고르기로 행에 넣습니다.
|
||||
|
||||
`편집 → 정렬(가나다)`은 기초단가 표를 관급구분별로 묶어 품명·규격 순(실적은 코드 순 선택)으로 정렬하고 번호를 다시 매기며 빈 행은 뺍니다. `데이타 위치정리`는 기초단가 또는 일위대가·단가산출·기계경비 호표를 품명 오름/내림으로 다시 놓고 시작번호부터 번호를 매깁니다. `호표 정리`는 차례는 그대로 두고 호표 번호만 시작번호부터 다시 매깁니다. `근거(메모) 보기`는 원본 서식 메모의 글자를 읽기 전용으로 보이고, `근거(메모) 삭제`는 고른 행의 메모를 지웁니다.
|
||||
|
||||
@@ -176,6 +176,8 @@ function exportEstx(project) {
|
||||
const original = Buffer.from(project.originalArchive, "base64"),
|
||||
base = importEstx(original),
|
||||
files = unzip(original);
|
||||
if (project.docs && !same(project.docs, base.docs))
|
||||
throw Error("갑지·표지·설명서 편집은 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요.");
|
||||
const optionFields = {
|
||||
GCToWonga: ["TWongaOption", "GCToWonga"],
|
||||
GangubExceptJodalRatio: ["TWongaOption", "GangubExceptJodalRatio"],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Read-only ESTX import. Layout: knowledge/다산소프트/ESTX_파일구조.md.
|
||||
const { readXls } = require("./xls-read.cjs");
|
||||
const { readDocuments } = require("./midas-read.cjs");
|
||||
const zlib = require("node:zlib");
|
||||
const quantity = require("./native-quantity.cjs");
|
||||
const { readPrintOptions } = require("./import-print.cjs");
|
||||
@@ -380,10 +381,17 @@ function importEstx(buffer, name = "가져온 내역서") {
|
||||
}
|
||||
}
|
||||
existingTargets(tables, quantitySheets, warnings);
|
||||
let docs = {};
|
||||
try {
|
||||
docs = readDocuments(files);
|
||||
} catch (error) {
|
||||
warnings.add(`갑지·표지 문서: ${error.message}`);
|
||||
}
|
||||
return {
|
||||
schema: "est-web-2",
|
||||
name: props.TCommonOption?.ProjectName || name,
|
||||
tables,
|
||||
docs,
|
||||
wongaTemplates: templates,
|
||||
originalOptions: objects,
|
||||
options,
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
<script defer src="native-name-reg.js"></script>
|
||||
<script defer src="native-tidy.js"></script>
|
||||
<script defer src="native-multi-search.js"></script>
|
||||
<script defer src="native-docs.js"></script>
|
||||
<script defer src="native-actions.js"></script>
|
||||
<script defer src="native-business-actions.js"></script>
|
||||
<script defer src="native-app.js"></script>
|
||||
@@ -82,6 +83,11 @@
|
||||
<button data-action="import-xml">조달청 XML 불러오기</button>
|
||||
<button data-action="export-estx">원본 구조 유지 ESTX 수정 저장</button>
|
||||
<button data-action="blank-save">공내역 파일로 저장</button>
|
||||
<hr />
|
||||
<button data-action="doc-edit" data-kind="gabgi">갑지 편집</button
|
||||
><button data-action="doc-edit" data-kind="cover">표지 편집</button
|
||||
><button data-action="doc-edit" data-kind="explan">공사설명서 편집</button
|
||||
><button data-action="doc-edit" data-kind="sibang">시방서 편집</button>
|
||||
<button data-action="project-add">프로젝트 추가(다른 공사 내역 합치기)</button>
|
||||
<button data-action="export-jdl">JDL 기본 표 내보내기</button>
|
||||
<button data-action="import-jdl">JDL 불러오기</button>
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// 원본 문서 레코드(.opt = MIDAS TClientDataSet 이진 묶음, 머리 96 19 E0 BD) 일반 읽기 — 갑지·표지·공사설명서·시방서.
|
||||
// 필드형: 1/4 정수 · 3/2 짧은 정수 · 4/8 실수 · 73/1 문자열(cp949) · 75/4 BLOB(바이트 그대로).
|
||||
// WPTools BLOB 은 wptText 로 글자만 꺼낸다(서식 버림).
|
||||
const zlib = require("node:zlib");
|
||||
const decoder = new TextDecoder("euc-kr");
|
||||
|
||||
function readRecords(buffer) {
|
||||
if (!buffer || buffer.length < 24 || buffer.readUInt32LE(0) !== 0xbde01996)
|
||||
throw Error("문서 레코드 머리 오류");
|
||||
const count = buffer.readUInt16LE(12),
|
||||
rows = buffer.readUInt16LE(14),
|
||||
start = buffer.readUInt16LE(22);
|
||||
if (count > 1024 || rows > 10000 || start > buffer.length) throw Error("문서 레코드 크기 오류");
|
||||
let at = 24;
|
||||
const take = (n) => {
|
||||
if (n < 0 || at + n > buffer.length) throw Error("문서 레코드 범위 오류");
|
||||
const out = buffer.subarray(at, at + n);
|
||||
at += n;
|
||||
return out;
|
||||
};
|
||||
const fields = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const name = take(take(1)[0]).toString("ascii"),
|
||||
width = take(2).readUInt16LE(),
|
||||
kind = take(2).readUInt16LE();
|
||||
take(2);
|
||||
const attributes = take(2).readUInt16LE();
|
||||
for (let j = 0; j < attributes; j++) {
|
||||
take(take(1)[0]);
|
||||
const size = take(2).readUInt16LE(),
|
||||
type = take(2).readUInt16LE();
|
||||
if (type === 73) take(take(2).readUInt16LE());
|
||||
else take(size);
|
||||
}
|
||||
fields.push({ name, width, kind });
|
||||
}
|
||||
at = start;
|
||||
const out = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
take(1);
|
||||
const status = take(Math.ceil(count / 4)),
|
||||
row = {};
|
||||
fields.forEach(({ name, width, kind }, i) => {
|
||||
const state = (status[i >> 2] >> ((i % 4) * 2)) & 3;
|
||||
if (state === 1 || state === 2) return (row[name] = null);
|
||||
if (kind === 1 && width === 4) row[name] = take(4).readInt32LE();
|
||||
else if (kind === 3 && width === 2) row[name] = take(2).readInt16LE();
|
||||
else if (kind === 4 && width === 8) row[name] = take(8).readDoubleLE();
|
||||
else if (kind === 73 && width === 1)
|
||||
row[name] = decoder.decode(take(take(1)[0])).replace(/\0+$/, "");
|
||||
else if (kind === 75 && width === 4) row[name] = Buffer.from(take(take(4).readUInt32LE()));
|
||||
else throw Error(`문서 레코드 필드형 오류: ${name}/${kind}/${width}`);
|
||||
});
|
||||
out.push(row);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function wptText(blob) {
|
||||
if (!blob?.length) return "";
|
||||
let bytes = blob;
|
||||
if (bytes[0] === 0x78) bytes = zlib.inflateSync(bytes);
|
||||
const source = decoder.decode(bytes);
|
||||
if (!source.startsWith("<!WPTools")) return source.trim();
|
||||
return source
|
||||
.replace(/<(numberstyles|stylesheet)[\s\S]*?<\/\1>/gi, "")
|
||||
.replace(/<\/(div|p)>|<br\s*\/?>/gi, "\n")
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
|
||||
.replace(/&/g, "&")
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
// 갑지·표지·공사설명서·시방서 → project.docs (글자 필드만; 서식·여백은 원본에 그대로 둔다)
|
||||
function readDocuments(files) {
|
||||
const docs = {};
|
||||
const one = (name) => {
|
||||
const key = [...files.keys()].find((k) => k.toLowerCase() === name.toLowerCase());
|
||||
return key ? readRecords(files.get(key))[0] || null : null;
|
||||
};
|
||||
const gabgi = one("DatGabgi.opt");
|
||||
if (gabgi)
|
||||
docs.gabgi = {
|
||||
Name: gabgi.Name ?? "",
|
||||
Position: gabgi.Position ?? "",
|
||||
CompanyName: gabgi.CompanyName ?? "",
|
||||
Explan: wptText(gabgi.EXPLEAN),
|
||||
Cost: wptText(gabgi.Cost),
|
||||
Gulga: gabgi.Gulga
|
||||
? decoder
|
||||
.decode(gabgi.Gulga)
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
Date: gabgi.Date ?? "",
|
||||
YearShow: Boolean(gabgi.YearShow),
|
||||
DayShow: Boolean(gabgi.DayShow),
|
||||
CompanyPos: gabgi.CompanyPos ?? 1,
|
||||
};
|
||||
const cover = one("DatCover.opt");
|
||||
if (cover)
|
||||
docs.cover = {
|
||||
Name: cover.Name ?? "",
|
||||
Name2: cover.Name2 ?? "",
|
||||
CompanyName: cover.CompanyName ?? "",
|
||||
Date: cover.Date ?? "",
|
||||
YearShow: Boolean(cover.YearShow),
|
||||
CompanyPos: cover.CompanyPos ?? 1,
|
||||
};
|
||||
for (const [key, file] of [
|
||||
["explan", "DatExplean.opt"],
|
||||
["sibang", "DatSiBang.opt"],
|
||||
]) {
|
||||
const row = one(file);
|
||||
if (row) docs[key] = { text: wptText(row.Editor) };
|
||||
}
|
||||
return docs;
|
||||
}
|
||||
|
||||
module.exports = { readRecords, wptText, readDocuments };
|
||||
@@ -611,7 +611,8 @@ const EstBusinessActions = (() => {
|
||||
(typeof EstCleanup !== "undefined" && EstCleanup.handle(name, state)) ||
|
||||
(typeof EstNameReg !== "undefined" && EstNameReg.handle(name, state)) ||
|
||||
(typeof EstTidy !== "undefined" && EstTidy.handle(name, state)) ||
|
||||
(typeof EstMultiSearch !== "undefined" && EstMultiSearch.handle(name, state))
|
||||
(typeof EstMultiSearch !== "undefined" && EstMultiSearch.handle(name, state)) ||
|
||||
(typeof EstDocs !== "undefined" && EstDocs.handle(name, state, node))
|
||||
) {
|
||||
EstApp.render();
|
||||
} else if (name === "load-contract" || name === "load-bid") {
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
// 원본 갑지(TFRMGABGI)·표지(TFRMCOVER)·공사설명서/시방서(TFRMESTEDITOR) 웹 편집 — project.docs.
|
||||
// 공사명은 열 때 공사 이름을 따르고 고치면 공사 이름에도 적는다(원본 TCommonOption.ProjectName 동기화).
|
||||
// 공사금액 자동입력: 원가계산서에서 고른 비목을 두 방식으로 적는다(원본 c_cb2e78·c_cb3080, 한글금액 c_931b44).
|
||||
// 1 = 「도급액 : 금 일천오백만원(₩15,000,000)」 · 2 = 「도급액 : 금15,000,000원(금일천오백만원)」, 「일」을 줄이지 않는다.
|
||||
// 설명서·시방서 붙여넣기는 갑지의 공사명·공사위치·공사개요·공사금액을 커서 자리에 넣는다. 서식·여백은 다루지 않는다.
|
||||
const EstDocs = (() => {
|
||||
const digits = ["", "일", "이", "삼", "사", "오", "육", "칠", "팔", "구"];
|
||||
const places = ["천", "백", "십", ""];
|
||||
const groups = ["", "만", "억", "조"];
|
||||
|
||||
function korean(value) {
|
||||
const n = Math.round(Number(value) || 0);
|
||||
if (!n) return "영";
|
||||
const text = String(Math.abs(n)),
|
||||
padded = text.padStart(Math.ceil(text.length / 4) * 4, " "),
|
||||
count = padded.length / 4;
|
||||
let out = "";
|
||||
for (let g = 0; g < count; g++) {
|
||||
const part = padded.slice(g * 4, g * 4 + 4);
|
||||
let chunk = "";
|
||||
[...part].forEach((ch, i) => {
|
||||
const d = Number(ch);
|
||||
if (ch !== " " && d) chunk += digits[d] + places[i];
|
||||
});
|
||||
if (chunk) out += chunk + groups[count - 1 - g];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const comma = (value) => Math.round(Number(value) || 0).toLocaleString("en-US");
|
||||
|
||||
function costLine(name, cost, method) {
|
||||
return method === 2
|
||||
? `${name} : 금${comma(cost)}원(금${korean(cost)}원)`
|
||||
: `${name} : 금 ${korean(cost)}원(₩${comma(cost)})`;
|
||||
}
|
||||
function costText(project, indices, method) {
|
||||
const rows = project.tables.Wonga || [];
|
||||
const picked = indices.map((i) => rows[i]).filter(Boolean);
|
||||
if (!picked.length) throw Error("공사금액에 넣을 원가 비목을 고르세요.");
|
||||
const width = Math.max(...picked.map((r) => String(r.Name ?? "").trim().length));
|
||||
return picked
|
||||
.map((r) =>
|
||||
costLine(
|
||||
String(r.Name ?? "")
|
||||
.trim()
|
||||
.padEnd(width, " "),
|
||||
r.Cost,
|
||||
method,
|
||||
),
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
const defaults = () => ({
|
||||
gabgi: {
|
||||
Name: "",
|
||||
Position: "",
|
||||
CompanyName: "",
|
||||
Explan: "",
|
||||
Cost: "",
|
||||
Gulga: ["과 장", "계 장", "심사자", "설계자"],
|
||||
Date: "",
|
||||
YearShow: true,
|
||||
DayShow: false,
|
||||
CompanyPos: 1,
|
||||
},
|
||||
cover: { Name: "", Name2: "", CompanyName: "", Date: "", YearShow: true, CompanyPos: 1 },
|
||||
explan: { text: "" },
|
||||
sibang: { text: "" },
|
||||
});
|
||||
function doc(project, kind) {
|
||||
project.docs ||= {};
|
||||
const value = { ...defaults()[kind], ...(project.docs[kind] || {}) };
|
||||
if ((kind === "gabgi" || kind === "cover") && project.name) value.Name = project.name;
|
||||
return value;
|
||||
}
|
||||
function year(value) {
|
||||
const text = String(value ?? "")
|
||||
.split("년")[0]
|
||||
.trim();
|
||||
return `${text || new Date().getFullYear()}년도`;
|
||||
}
|
||||
|
||||
// 인쇄 미리보기(서식 8 갑지 · 9 표지 · 10 시방서 · 11 공사설명서)
|
||||
function html(form, project) {
|
||||
const esc = (v) =>
|
||||
String(v ?? "").replace(
|
||||
/[&<>"]/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c],
|
||||
);
|
||||
const align = (pos) => ["left", "center", "right"][Number(pos)] || "center";
|
||||
if (form === 9) {
|
||||
const c = doc(project, "cover");
|
||||
return `<div class="document-cover" style="text-align:center;padding:40px 0">${c.YearShow ? `<p style="font-size:15pt">${esc(year(c.Date))}</p>` : ""}<h1 style="font-size:32pt;margin:80px 0 20px">${esc(c.Name)}</h1>${c.Name2 ? `<h2>${esc(c.Name2)}</h2>` : ""}<p style="font-size:15pt;margin-top:160px;text-align:${align(c.CompanyPos)}">${esc(c.CompanyName)}</p></div>`;
|
||||
}
|
||||
if (form === 8) {
|
||||
const g = doc(project, "gabgi");
|
||||
const gulga = g.Gulga.length
|
||||
? `<table class="sheet" style="float:right;margin-bottom:20px"><tr>${g.Gulga.map((n) => `<th>${esc(n)}</th>`).join("")}</tr><tr>${g.Gulga.map(() => "<td style='height:48px;min-width:60px'></td>").join("")}</tr></table>`
|
||||
: "";
|
||||
return `<div class="document-gabgi">${gulga}<p style="clear:both">${esc(g.Date)}</p>${g.Position ? `<p>공사위치 : ${esc(g.Position)}</p>` : ""}<h1 style="font-size:26pt;text-align:center;margin:40px 0">${esc(g.Name)}</h1>${g.Explan ? `<p>공사개요 :</p><pre class="document-text">${esc(g.Explan)}</pre>` : ""}${g.Cost ? `<p>공사금액 :</p><pre class="document-text">${esc(g.Cost)}</pre>` : ""}<p style="margin-top:60px;text-align:${align(g.CompanyPos)}">${esc(g.CompanyName)}</p></div>`;
|
||||
}
|
||||
if (form === 10 || form === 11) {
|
||||
const text = doc(project, form === 10 ? "sibang" : "explan").text;
|
||||
return text ? `<pre class="document-text">${esc(text)}</pre>` : "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// 화면
|
||||
const field = (name) => document.querySelector(`#dialog-content [name="${name}"]`);
|
||||
function editor(state, kind) {
|
||||
const { esc } = EstApp,
|
||||
d = doc(state.project, kind);
|
||||
const input = (name, label, value) =>
|
||||
`<label>${label}<input name="doc-${name}" value="${esc(value ?? "")}"></label>`;
|
||||
const area = (name, label, value, rows = 4) =>
|
||||
`<label>${label}<textarea name="doc-${name}" rows="${rows}" style="width:100%">${esc(value ?? "")}</textarea></label>`;
|
||||
const pos = (value) =>
|
||||
`<label>발주처 위치<select name="doc-CompanyPos">${["좌측", "중앙", "우측"].map((l, i) => `<option value="${i}" ${Number(value) === i ? "selected" : ""}>${l}</option>`).join("")}</select></label>`;
|
||||
let body = "";
|
||||
if (kind === "cover")
|
||||
body =
|
||||
input("Name", "공사명", d.Name) +
|
||||
input("Name2", "부제", d.Name2) +
|
||||
input("Date", "설계년도", d.Date) +
|
||||
input("CompanyName", "발주처", d.CompanyName) +
|
||||
pos(d.CompanyPos);
|
||||
else if (kind === "gabgi") {
|
||||
const rows = (state.project.tables.Wonga || [])
|
||||
.map((r, i) => [r, i])
|
||||
.filter(([r]) => Number(r.Cost) && String(r.Name ?? "").trim());
|
||||
const fixed = rows.find(([r]) => Number(r.LinkIndex) === 55)?.[1];
|
||||
body =
|
||||
input("Name", "공사명", d.Name) +
|
||||
input("Position", "공사위치", d.Position) +
|
||||
input("CompanyName", "발주처", d.CompanyName) +
|
||||
input("Date", "설계일자", d.Date) +
|
||||
pos(d.CompanyPos) +
|
||||
area("Explan", "공사개요", d.Explan) +
|
||||
area("Cost", "공사금액", d.Cost, 3) +
|
||||
`<p>공사금액 자동입력: <select name="doc-rows" multiple size="4">${rows.map(([r, i]) => `<option value="${i}" ${i === fixed ? "selected" : ""}>${esc(r.Name)} ${EstApp.number(r.Cost)}</option>`).join("")}</select> <select name="doc-method"><option value="1">금 일천…원(₩…)</option><option value="2">금…원(금 일천…원)</option></select> <button data-action="doc-cost">공사금액 만들기</button></p>` +
|
||||
area("Gulga", "결재란(한 줄에 하나)", d.Gulga.join("\n"), 4);
|
||||
} else
|
||||
body =
|
||||
area("text", kind === "explan" ? "공사설명서" : "시방서", d.text, 14) +
|
||||
`<p>갑지에서 붙여넣기: ${[
|
||||
["Name", "공사명"],
|
||||
["Position", "공사위치"],
|
||||
["Explan", "공사개요"],
|
||||
["Cost", "공사금액"],
|
||||
]
|
||||
.map(([k, l]) => `<button data-action="doc-paste" data-field="${k}">${l}</button>`)
|
||||
.join(" ")}</p>`;
|
||||
const title = { gabgi: "갑지", cover: "표지", explan: "공사설명서", sibang: "시방서" }[kind];
|
||||
EstApp.showDialog(
|
||||
`${title} 편집`,
|
||||
`<div class="form-fields">${body}</div><button data-action="doc-save" data-kind="${kind}">저장</button>`,
|
||||
);
|
||||
}
|
||||
function collect(kind) {
|
||||
const value = (name) => field(`doc-${name}`)?.value ?? "";
|
||||
if (kind === "cover")
|
||||
return {
|
||||
Name: value("Name"),
|
||||
Name2: value("Name2"),
|
||||
Date: value("Date"),
|
||||
CompanyName: value("CompanyName"),
|
||||
CompanyPos: Number(value("CompanyPos")),
|
||||
};
|
||||
if (kind === "gabgi")
|
||||
return {
|
||||
Name: value("Name"),
|
||||
Position: value("Position"),
|
||||
CompanyName: value("CompanyName"),
|
||||
Date: value("Date"),
|
||||
CompanyPos: Number(value("CompanyPos")),
|
||||
Explan: value("Explan"),
|
||||
Cost: value("Cost"),
|
||||
Gulga: value("Gulga")
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean),
|
||||
};
|
||||
return { text: value("text") };
|
||||
}
|
||||
|
||||
function handle(name, state, node) {
|
||||
if (name === "doc-edit") editor(state, node.dataset.kind);
|
||||
else if (name === "doc-save") {
|
||||
const kind = node.dataset.kind,
|
||||
next = collect(kind);
|
||||
EstApp.byId("dialog").close();
|
||||
EstApp.change((project) => {
|
||||
project.docs ||= {};
|
||||
project.docs[kind] = { ...doc(project, kind), ...next };
|
||||
if (next.Name && next.Name !== project.name) project.name = next.Name;
|
||||
}, false);
|
||||
state.notice = "문서를 저장했습니다.";
|
||||
} else if (name === "doc-cost") {
|
||||
const indices = [...field("doc-rows").selectedOptions].map((o) => Number(o.value));
|
||||
field("doc-Cost").value = costText(state.project, indices, Number(field("doc-method").value));
|
||||
} else if (name === "doc-paste") {
|
||||
const key = node.dataset.field,
|
||||
g = doc(state.project, "gabgi"),
|
||||
text = String(g[key] ?? "").trim();
|
||||
if (!text) throw Error("갑지에서 입력을 하지 않았습니다.");
|
||||
const area = field("doc-text"),
|
||||
at = area.selectionStart ?? area.value.length;
|
||||
area.value = area.value.slice(0, at) + text + area.value.slice(area.selectionEnd ?? at);
|
||||
} else return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
return { korean, costLine, costText, html, doc, handle };
|
||||
})();
|
||||
@@ -34,7 +34,11 @@ const EstReport = (() => {
|
||||
if (output) root.innerHTML = EstForms.grid(output);
|
||||
else if (form) {
|
||||
const total = state.project.calculation?.total || 0;
|
||||
root.innerHTML = `<div class="document-text">${esc(settings.notes)}</div>${[8, 9].includes(form.Index) ? `<h2>공사금액 ${number(total)}원</h2><p>${esc(settings.company)}</p>` : ""}`;
|
||||
const doc =
|
||||
typeof EstDocs !== "undefined" ? EstDocs.html(Number(form.Index), state.project) : "";
|
||||
root.innerHTML = doc
|
||||
? `${doc}${settings.notes ? `<div class="document-text">${esc(settings.notes)}</div>` : ""}`
|
||||
: `<div class="document-text">${esc(settings.notes)}</div>${[8, 9].includes(form.Index) ? `<h2>공사금액 ${number(total)}원</h2><p>${esc(settings.company)}</p>` : ""}`;
|
||||
} else if (state.project.tables[state.view]) {
|
||||
const rows = all
|
||||
? state.project.tables[state.view].map((row, index) => ({ row, index }))
|
||||
@@ -118,7 +122,8 @@ const EstReport = (() => {
|
||||
}
|
||||
function paginate(root, settings) {
|
||||
const table = root.querySelector("table");
|
||||
if (!table || root.querySelectorAll("table").length !== 1) return root.innerHTML;
|
||||
if (!table || root.querySelectorAll("table").length !== 1 || table.closest(".document-gabgi"))
|
||||
return root.innerHTML;
|
||||
const rows = [...table.querySelectorAll("tbody>tr")],
|
||||
pages = [];
|
||||
for (let at = 0; at < rows.length; at += settings.rowsPerPage) {
|
||||
|
||||
@@ -73,6 +73,7 @@ const allowed = new Set([
|
||||
"native-name-reg.js",
|
||||
"native-tidy.js",
|
||||
"native-multi-search.js",
|
||||
"native-docs.js",
|
||||
"native-issues.js",
|
||||
"native-attachments.js",
|
||||
"native-standard.js",
|
||||
|
||||
Reference in New Issue
Block a user