feat(est): add document and exchange workflows

This commit is contained in:
2026-11-06 15:13:29 +09:00
parent 74ab66b00e
commit a5157dd3a9
27 changed files with 8131 additions and 26 deletions
+3
View File
@@ -16,7 +16,10 @@ for (const file of [
"native-progress.js",
"native-business.js",
"native-resources.js",
"native-schedule.js",
"native-model.js",
"native-c3r.js",
"native-exchange.js",
"native-verify.js",
]) {
vm.runInContext(fs.readFileSync(path.join(base, file), "utf8"), context, { filename: file });
+67
View File
@@ -0,0 +1,67 @@
from pathlib import Path
from lxml import etree
from test_est_rea_parity import run
ROOT = Path(__file__).resolve().parents[2]
SCHEMA = next((ROOT / "resources/knowledge").rglob("조달청_공사원가호환규정.xsd"))
def test_c3r_export_validates_against_the_supplied_schema_and_preserves_prices():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.name='C3R test';p.options.SourceRefresh=false;
p.tables.StuffcDat=[{PkID:1,Code:'M1',Name:'material',TC:100,GC:100}];
p.tables.LaborcDat=[{PkID:1,Code:'L1',Name:'labor',TC:50,NC:50}];
p.tables.EstMainDat=[{PkID:1,Code:'M1',LinkIndex:5,Name:'material',GN:2,GU:100},{PkID:2,Code:'L1',LinkIndex:6,Name:'labor',GN:3,NU:50},{PkID:3,LinkIndex:54}];
p.tables.Wonga=[{Gubun:'G',Name:'material',BaseField:'GC'}, {Gubun:'N',Name:'labor',BaseField:'NC'},{Gubun:'T',Name:'total',VSusic:'G+N'}];
return EstC3r.output(EstNative.calculate(p),{company:'test',kind:'토목공사',date:'2026-11-06'});
})()""",
)
schema = etree.XMLSchema(etree.parse(str(SCHEMA)))
root = etree.fromstring(result["xml"].encode("utf-8"))
assert schema.validate(root), str(schema.error_log)
assert root.findtext(".//세부공종/재료비") == "200"
assert root.findtext(".//세부공종/수량") == "2"
assert root.findtext(".//작성정보/파일검증여부") == "false"
assert result["warnings"] == []
def test_xml_excluded_rows_do_not_inflate_group_totals_and_invalid_codes_are_rejected():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.name='test';p.tables.StuffcDat=[{Code:'M1',Name:'material',TC:100}];p.tables.EstMainDat=[
{Code:'M1',Name:'normal',LinkIndex:5,GN:2,GU:100,GC:200,TC:200},
{Code:'X1',Name:'excluded',GN:1,GC:999,TC:999,CalcIndex:3},{LinkIndex:54}];
const meta={company:'test',kind:'토목공사',date:'2026-11-06'};
const result=EstC3r.output(p,meta);let blocked=false;
p.tables.EstMainDat[0].Code='bad code';try{EstC3r.output(p,meta)}catch{blocked=true}
return {...result,blocked};
})()""",
)
root = etree.fromstring(result["xml"].encode("utf-8"))
assert root.findtext(".//내역서/재료비") == "200"
assert root.findall(".//세부공종")[1].findtext("계산제외") == "true"
assert result["blocked"]
def test_xml_keeps_parent_and_child_groups_instead_of_dropping_empty_parent_headers():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.name='test';p.tables.EstMainDat=[
{Code:'ROOT',Name:'parent',Level:3,GC:200,TC:200},
{Code:'CHILD',Name:'child',Level:1,GC:200,TC:200},
{Code:'FIX',Name:'fixed',GN:1,GC:200,TC:200,CalcIndex:1},{LinkIndex:54}];
return EstC3r.output(p,{company:'test',kind:'토목공사',date:'2026-11-06'});
})()""",
)
root = etree.fromstring(result["xml"].encode("utf-8"))
schema = etree.XMLSchema(etree.parse(str(SCHEMA)))
assert schema.validate(root), str(schema.error_log)
groups = root.findall(".//내역서")
assert [group.findtext("내역서번호") for group in groups] == ["01", "0101"]
assert [group.findtext("재료비") for group in groups] == ["200", "200"]
assert len(groups[0].findall("세부공종")) == 0
assert len(groups[1].findall("세부공종")) == 1
+46
View File
@@ -0,0 +1,46 @@
from test_est_rea_parity import run
def test_resource_report_separates_each_work_item_without_changing_aggregate():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.options.SourceRefresh=false;
p.tables.StuffcDat=[{Code:'M',Name:'material',InputTC:100,ValueIndex:4}];
p.tables.EstMainDat=[{PkID:1,Code:'M',LinkIndex:5,GN:2,GU:100},{PkID:2,Code:'M',LinkIndex:5,GN:3,GU:100},{LinkIndex:54}];
return {all:EstResources.calculate(p),split:EstResources.calculate(p,true)};
})()""",
)
assert len(result["all"]["rows"]) == 1
assert len(result["split"]["rows"]) == 2
assert (
sum(r["quantity"] for r in result["split"]["rows"])
== result["all"]["rows"][0]["quantity"]
== 5
)
assert (
sum(r["amount"] for r in result["split"]["rows"])
== result["all"]["rows"][0]["amount"]
== 500
)
def test_schedule_dates_ranges_and_weighted_user_progress():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.schedule=[
{id:'a',Code:'A',Name:'first',start:'2026-11-01',end:'2026-11-05',weight:100,progress:25},
{id:'b',Code:'B',Name:'second',start:'2026-11-06',end:'2026-11-10',weight:300,progress:50}];
const valid=EstSchedule.calculate(p),bad=[];
for(const change of [{start:'2026-02-30'},{progress:101},{weight:-1},{end:'2026-10-01'}]) {
const q=structuredClone(p);Object.assign(q.schedule[0],change);
try{EstModel.validate(q);bad.push(false)}catch{bad.push(true)}
}
return {valid,bad};
})()""",
)
assert result["valid"]["progress"] == 43.75
assert result["valid"]["rows"][0]["width"] == 50
assert result["valid"]["rows"][1]["left"] == 50
assert result["bad"] == [True] * 4
+88
View File
@@ -0,0 +1,88 @@
import io
import json
import subprocess
import zipfile
from pathlib import Path
import xml.etree.ElementTree as ET
from test_est_rea_parity import run
ROOT = Path(__file__).resolve().parents[2]
def export_workbook(project):
result = subprocess.run(
[
"node",
"-e",
"const fs=require('fs');process.stdout.write(require('./tools/est-test/exchange.cjs').workbook(JSON.parse(fs.readFileSync(0,'utf8'))))",
],
input=json.dumps(project, ensure_ascii=False).encode("utf-8"),
capture_output=True,
cwd=ROOT,
check=True,
)
return result.stdout
def test_excel_is_valid_office_zip_with_numeric_text_types_and_no_executable_formulas():
data = export_workbook(
{
"name": "한글 공사",
"tables": {
"EstMainDat": [
{
"PkID": 1,
"Code": "001",
"Name": '=HYPERLINK("bad")',
"GN": 1.23456789,
"TC": 99,
"NewItem": False,
"Editor": {"binary": "AAAA"},
}
],
"CadMainDat": [{"Code": "excluded"}],
},
}
)
with zipfile.ZipFile(io.BytesIO(data)) as archive:
assert archive.testzip() is None
workbook = archive.read("xl/workbook.xml").decode("utf-8")
assert "EstMainDat" in workbook and "CadMainDat" not in workbook
root = ET.fromstring(archive.read("xl/worksheets/sheet2.xml"))
ns = {"s": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
rows = root.findall(".//s:row", ns)
assert "Editor" not in ET.tostring(rows[0], encoding="unicode")
assert rows[1].find("s:c[@r='B2']", ns).get("t") == "inlineStr"
assert rows[1].find("s:c[@r='D2']/s:v", ns).text == "1.23456789"
assert root.findall(".//s:f", ns) == []
assert "=HYPERLINK" in ET.tostring(root, encoding="unicode")
def test_print_settings_validation_blocks_css_injection_and_bad_pagination():
result = run(
"math",
"""(()=>{
return [{paper:'A4;display:none'}, {rowsPerPage:0}, {rowsPerPage:1.5}, {font:100}, {margin:-1}, {orientation:'portrait',paper:'A4',rowsPerPage:20,font:9}].map(s=>{
const p=EstNative.empty();p.printSettings=s;
try{EstModel.validate(p);return true}catch{return false}
});
})()""",
)
assert result == [False, False, False, False, False, True]
def test_excel_price_edits_refresh_linked_values_and_keep_binary_fields():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.tables.StuffcDat=[{PkID:1,Code:'M',Name:'material',InputTC:100,ValueIndex:4,Editor:{binary:'AAAA'}}];
p.tables.EstMainDat=[{Code:'M',LinkIndex:5,GN:2},{LinkIndex:54}];
const q=EstNative.calculate(p),updated=EstExchange.apply(q,{StuffcDat:[{PkID:1,Code:'M',Name:'material',TC:150,InputTC:100,ValueIndex:4}]});
let rejected=false;try{EstExchange.apply(q,{StuffcDat:[{PkID:1,Code:'',TC:150}]})}catch{rejected=true}
return {next:EstNative.calculate(updated),rejected};
})()""",
)
assert result["rejected"]
assert result["next"]["tables"]["EstMainDat"][-1]["TC"] == 300
assert result["next"]["tables"]["StuffcDat"][0]["Editor"]["binary"] == "AAAA"
+39
View File
@@ -0,0 +1,39 @@
import json
import subprocess
from pathlib import Path
from test_est_rea_parity import run
ROOT = Path(__file__).resolve().parents[2]
def test_jdl_basic_tables_use_native_separators_encoding_and_preserve_direct_money():
p = run(
"math",
"""(()=>{
const p=EstNative.empty();p.name='JDL test';p.options.SourceRefresh=false;
p.tables.StuffcDat=[{PkID:1,Code:'M',Name:'material',InputTC:100,ValueIndex:4}];
p.tables.EstMainDat=[{PkID:1,Code:'M',Name:'material',LinkIndex:5,GN:10,GU:100},
{PkID:2,Code:'FIX',Name:'fixed',GN:0,GC:80,TC:80,CalcIndex:1},
{PkID:3,Code:'SKIP',Name:'excluded',GN:1,GC:999,TC:999,CalcIndex:3},{PkID:4,LinkIndex:54}];
p.tables.Wonga=[{Gubun:'G',Name:'material',BaseField:'GC'},{Gubun:'T',Name:'total',VSusic:'G'}];
return EstNative.calculate(p);
})()""",
)
script = """const fs=require('fs'),j=require('./tools/est-test/jdl.cjs'),a=require('./tools/est-test/archive.cjs');
const p=JSON.parse(fs.readFileSync(0,'utf8')),empty=structuredClone(p);for(const name of Object.keys(empty.tables))empty.tables[name]=[];
const out=j.output(p),files=a.unpack(out.bytes),next=j.read(out.bytes,empty);
const raw=files.get('mainpart.txt');
process.stdout.write(JSON.stringify({next,files:[...files.keys()],tabs:raw.includes(9),terminator:raw.includes(5),warnings:out.warnings}));"""
result = json.loads(
subprocess.check_output(
["node", "-e", script],
input=json.dumps(p, ensure_ascii=False).encode("utf-8"),
cwd=ROOT,
).decode("utf-8")
)
assert result["tabs"] and result["terminator"]
assert {"Global.ini", "Main.txt", "mainpart.txt", "CostAccount.txt"}.issubset(result["files"])
assert result["next"]["tables"]["EstMainDat"][-1]["TC"] == 1080
assert result["next"]["tables"]["Wonga"][-1]["Cost"] == 1080
assert result["next"]["options"]["AutoCalc"] is False
assert result["warnings"]
@@ -0,0 +1,83 @@
import json
import struct
import subprocess
import zipfile
import pytest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def test_recalculation_and_native_export_keep_original_title_identities_when_sample_exists():
sample = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/sample.estx")
if not sample.exists():
pytest.skip("installed EST sample unavailable")
script = """const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs'),s=require('./tools/est-test/server.cjs');
const bytes=fs.readFileSync(process.argv[1]),p=m.importEstx(bytes),q=s.calculate(p);
const ids=['HeadTitleDat','CostTitleDat','MachTitleDat'].every(key=>p.tables[key].every((r,i)=>r.PkID===q.tables[key][i].PkID));
const row=q.tables.EstMainDat.find(r=>r.GN&&r.TC);row.GN+=1;
const edited=s.calculate(q),out=e.exportEstx(edited),again=m.importEstx(out),original=m.unzip(bytes),files=m.unzip(out);
const indexes=[...original].filter(([name])=>name.endsWith('.idx')).every(([name,value])=>value.equals(files.get(name)));
process.stdout.write(JSON.stringify({ids,indexes,gn:again.tables.EstMainDat.find(r=>r.PkID===row.PkID).GN,expected:row.GN}));"""
result = json.loads(
subprocess.check_output(["node", "-e", script, str(sample)], cwd=ROOT).decode("utf-8")
)
assert result["ids"] and result["indexes"]
assert result["gn"] == result["expected"]
def fixture(path):
fields = [
("PkID", 2, 4),
("Code", 1, 16),
("Name", 1, 32),
("GN", 7, 8),
("GU", 7, 8),
("TC", 7, 8),
]
stride = 25 + sum(width + 1 for _, _, width in fields)
data = bytearray(512 + 768 * len(fields) + stride)
data[8:24] = bytes.fromhex("068abe8e592364cb403d71d2e3bc64d0")
struct.pack_into("<IHH", data, 41, 1, stride, len(fields))
start = 512 + 768 * len(fields)
offset = 25
for i, (name, kind, width) in enumerate(fields):
at = 512 + i * 768
data[at + 2] = len(name)
data[at + 3 : at + 3 + len(name)] = name.encode("ascii")
data[at + 164] = kind
struct.pack_into("<H", data, at + 169, width)
struct.pack_into("<I", data, at + 172, offset)
data[start + offset] = 1
payload = start + offset + 1
if name == "PkID":
struct.pack_into("<i", data, payload, 1)
elif name in ["Code", "Name"]:
text = ("A" if name == "Code" else "원본").encode("cp949")
data[payload : payload + len(text)] = text
else:
struct.pack_into("<d", data, payload, {"GN": 2, "GU": 100, "TC": 200}[name])
offset += width + 1
with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
archive.writestr("EstMainDat.dat", data)
archive.writestr("EstMainDat.idx", b"opaque index must remain unchanged")
archive.writestr("CadMainDat.blb", b"original drawing payload")
def test_estx_patches_values_and_preserves_indexes_drawings_and_rejects_structural_changes(
tmp_path,
):
source = tmp_path / "native.estx"
fixture(source)
script = """const fs=require('fs'),m=require('./tools/est-test/import-estx.cjs'),e=require('./tools/est-test/export-estx.cjs');
const b=fs.readFileSync(process.argv[1]),p=m.importEstx(b),same=e.exportEstx(p).equals(b);
p.tables.EstMainDat[0].GN=3;p.tables.EstMainDat[0].TC=300;p.tables.EstMainDat[0].Name='변경';
const next=e.exportEstx(p),q=m.importEstx(next),old=m.unzip(b),now=m.unzip(next);
let blocked=false;p.tables.EstMainDat[0].Code='B';try{e.exportEstx(p)}catch{blocked=true}
process.stdout.write(JSON.stringify({same,row:q.tables.EstMainDat[0],blocked,index:old.get('estmaindat.idx').equals(now.get('estmaindat.idx')),drawing:old.get('cadmaindat.blb').equals(now.get('cadmaindat.blb'))}));"""
result = json.loads(
subprocess.check_output(["node", "-e", script, str(source)], cwd=ROOT).decode("utf-8")
)
assert result["same"] and result["blocked"] and result["index"] and result["drawing"]
assert result["row"]["Name"] == "변경"
assert result["row"]["GN"] == 3 and result["row"]["TC"] == 300
+15 -5
View File
@@ -2,11 +2,20 @@
`start.cmd`를 실행한 뒤 **http://127.0.0.1:5175** 로 접속합니다. Node.js가 필요하며 별도 패키지 설치는 없습니다. Aislo 서버·로그인·DB와 연결하지 않습니다.
내역·일위대가·수식 단가산출·기계경비·기초 단가·원가계산서를 원본 테이블 구조로 편집합니다. 수량 연결, 설계변경 당초 보존, 낙찰률, 기성 회차와 사정 누계, 품목조정, 연차 배분, 소요 자원 전개 화면을 제공합니다. 실행 취소·다시 실행, 검색, 행 편집, JSON 저장·열기, CSV와 브라우저 인쇄를 사용할 수 있습니다.
내역·일위대가·수식 단가산출·기계경비·기초 단가·원가계산서를 원본 테이블 구조로 편집합니다. 수량 연결, 설계변경 당초 보존, 낙찰률, 기성 회차와 사정 누계, 품목조정, 연차 배분, 소요 자원 전개, 예정공정표 화면을 제공합니다. 실행 취소·다시 실행, 검색, 행 편집, JSON 저장·열기, CSV·Excel·조달청 XML·JDL 기본 표 입출력과 브라우저 인쇄를 사용할 수 있습니다.
인쇄 미리보기는 화면의 100행 페이지 제한 없이 검색 결과/현재 호표의 전체 행을 표시합니다. 출력 범위에서 현재 자료의 모든 호표·행도 선택할 수 있습니다. 브라우저 인쇄 창에서 종이 출력이나 PDF 저장을 선택합니다. 원본 인쇄 양식과 같은 레이아웃이라는 의미는 아닙니다.
인쇄 미리보기는 화면의 100행 페이지 제한 없이 검색 결과/현재 호표의 전체 행을 표시합니다. 출력 범위에서 현재 자료의 모든 호표·행도 선택할 수 있습니다. 원본 인쇄 옵션의 41개 항목 중 CAD를 제외한 40개 서식 이름을 선택하며, 내역·원가·호표/총괄·증감·동원·수량·공정은 표 자료로 출력합니다. 표지·갑지·설명서·시방서·심사 문서는 서식별 본문을 직접 입력합니다. 용지·방향·여백·글자 크기·페이지당 행·시작 쪽·업체명을 저장하고 인쇄/PDF로 출력합니다. 원본 인쇄 도형·병합·글꼴 배치와 동일하다는 의미는 아닙니다.
저장은 이 폴더의 `data/`에 작업을 보관하고 `.estweb.json` 파일도 내려받습니다. 브라우저 임시 저장은 같은 탭에서 복원하는 용도입니다. ESTX는 읽기 전용으로 가져오며 원본 압축 파일과 바이너리를 보존합니다. 편집 결과를 원본 ESTX로 다시 쓰는 기능은 없습니다.
저장은 이 폴더의 `data/`에 작업을 보관하고 `.estweb.json` 파일도 내려받습니다. 브라우저 임시 저장은 같은 탭에서 복원하는 용도입니다. JSON은 모든 웹 업무와 원본 압축 파일·바이너리를 보관하는 전체 저장 형식입니다. ESTX 수정 저장은 아래 지원 조건을 따릅니다.
## 추가 입출력·문서 기능 (2026-11-06)
- **Excel `.xlsx`**: CAD를 제외한 전체 표의 스칼라 필드를 숫자·문자·불리언으로 내보냅니다. 첫 행 필드명과 시트 이름을 유지한 파일을 다시 읽어 해당 표를 교체합니다. 기존 PkID의 바이너리는 보존하며 코드 변경·중복과 미계산 수식/오류 셀을 거부합니다. 출처 단가 수정은 연결 내역에 전파합니다. 파일에 바이너리·업무 회차·당초 사본을 넣지는 않으므로 전체 백업은 JSON을 사용합니다.
- **조달청 XML**: DB에 보관된 공사원가호환규정 XSD 1.0.5의 요소 순서와 필드를 사용합니다. 작성업체·공사구분을 입력하며 코드/문구 길이와 연결 자료를 검사합니다. 내역·일위·산근·기계·기초 단가·원가를 내보내고 다시 읽습니다. 연결 없는 고정 금액·기계 상세는 독립 단가로 전개하며 변환 전에 알립니다. 가져온 저장 금액과 원문을 보존하고 자동계산을 끕니다. 이는 제출기관 인증/등록 검사나 최신 규정 적합 판정을 뜻하지 않습니다.
- **JDL 기본 표**: 원본 검사 DLL의 필드 정의와 EST 처리기의 TAB/ENQ·CP949 표 구조에 따라 ZIP 안의 Global.ini·Main.txt·자원/공종/내역/원가 표를 생성·읽습니다. 복잡한 일위·산근·기계는 저장 금액의 고정 단가로 전개하며 제외 행은 생략 사실을 알립니다. 원본 JDL도 보존합니다. 확장 산식·기성/물가 파일의 완전 변환과 원본 검사 DLL 인증은 미확인입니다.
- **원본 구조 유지 ESTX 수정 저장**: 가져온 파일의 행 수와 인덱스 키를 유지하는 단가·수량·문구·금액 및 할당 공간 안의 BLOB/원가 서식을 기록하고 재읽기로 검산합니다. 원본 파일을 덮어쓰지 않으며 `.idx`와 CAD 바이너리를 보존합니다. 새 문서, 공사명/계산 옵션 변경, 행 추가·삭제·인덱스 키 변경, BLOB 공간 확장, 수정한 WPTools 수량 문서와 웹 기성·연차·당초 사본은 지원하지 않아 저장을 거부합니다. 이 범위는 JSON으로 보관합니다. 원본 EST 실행 검증은 못했습니다.
- **예정공정표**: 공정별 기간·진도와 가중치를 입력하고 기간 막대 및 입력 진도의 가중평균을 표시합니다. 금액 가중치를 수정할 수 있으며 날짜 역전·잘못된 진도를 차단합니다. 공휴일/공기 산정 기준을 임의 적용하지 않습니다.
- **공종별 동원 출력**: 한 번의 원천 계산에서 공종별 인원·자재·장비·실적·견적을 나누며 전체 합계와 금액/수량을 대조합니다. 문서 본문과 인쇄 설정도 저장·복원·취소 대상입니다.
Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요청은 순서대로 처리하며, 저장 대기 중의 추가 입력이나 새로 연 문서를 이전 저장 응답으로 덮어쓰지 않습니다. 실행 취소는 현재 작업 파일의 연결을 유지합니다.
@@ -52,12 +61,13 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요
물가변동은 같은 행 구조의 계약·입찰 자료(JSON/ESTX)를 불러와 조사·계약·변동 단가를 나눕니다. 원본 레코드 순서 방식에 따라 행 순서·코드·레벨이 다른 자료는 거부합니다. 등락 분기, 조사금액 기준 조정률, 자동 최대수량/수동 적용제외, 간접비 포함 적용대가와 기성 누계 불러오기를 제공합니다. 연차는 수식 참조 오류를 차단하며, 미리보기 뒤 선택한 차수만 독립 사본으로 엽니다.
**전체 원본 실행 동등성은 미확인입니다.** 위 네 업무는 REA 분기·합성 시나리오·실제 Edge 화면 조작·서버 저장/재열기로 검증했으며, 원본 업무 실행 결과 샘플 대조는 없습니다. 원본 페이지·도면 배치, CAD/VEC0650 편집, 원본 인쇄 서식, Excel/XML/JDL 변환, ESTX 수정 저장은 이번 지정 범위 밖이며 미구현입니다.
**CAD를 제외한 모든 원본 동작의 동등성이 확보된 상태는 아닙니다.** 위 업무와 추가 입출력은 REA 분기·합성 시나리오·실제 Edge 화면 조작·서버 저장/재열기로 검증했습니다. XML은 DB의 XSD 검사, Excel은 별도 읽기 라이브러리 검사, ESTX는 설치 sample.estx의 수정·재읽기를 통과했습니다. 원본 EST 실행 비교, 원본과 같은 페이지 도형 배치, JDL 인증/확장 산식, 구조를 변경하는 완전한 ESTX 쓰기와 앞서 기록한 원본 산식 잔여 오류는 남아 있습니다. CAD/VEC0650 편집은 사용자 요청으로 제외합니다.
검사:
```powershell
./venv/Scripts/python.exe -m pytest resources/tester/test_est_rea_parity.py resources/tester/test_est_business.py resources/tester/test_est_standalone.py resources/tester/test_est_history.py resources/tester/test_est_text_units.py resources/tester/test_est_change_workflow.py resources/tester/test_est_progress_workflow.py resources/tester/test_est_escalation_workflow.py resources/tester/test_est_annual_workflow.py -q
$estTests = Get-ChildItem resources/tester -File -Filter 'test_est*.py' | Select-Object -ExpandProperty FullName
./venv/Scripts/python.exe -m pytest @estTests -q
```
`native-*.js`가 현재 화면·계산 엔진이며 브라우저와 로컬 서버가 같은 계산 코드를 사용합니다. `build_sample.py`는 REA 자료에서 입력·기대값을 재생성합니다. 기존 `app.js`, `calc.js`, `formula.js`, `workflows.js`는 이전 데모의 회귀 시험용이며 현재 진입 화면에서 사용하지 않습니다.
+92
View File
@@ -0,0 +1,92 @@
const zlib = require("node:zlib");
const { crc32 } = require("./import-estx.cjs");
// ZIP32 container used by Office Open XML and ESTX; never extracts paths to disk.
function pack(files) {
const local = [],
central = [];
let offset = 0;
for (const [name, value] of files) {
const filename = Buffer.from(name, "utf8"),
data = Buffer.from(value),
compressed = zlib.deflateRawSync(data),
crc = crc32(data);
const header = Buffer.alloc(30);
header.writeUInt32LE(0x04034b50);
header.writeUInt16LE(20, 4);
header.writeUInt16LE(0x800, 6);
header.writeUInt16LE(8, 8);
header.writeUInt16LE(0x21, 12);
header.writeUInt32LE(crc, 14);
header.writeUInt32LE(compressed.length, 18);
header.writeUInt32LE(data.length, 22);
header.writeUInt16LE(filename.length, 26);
const directory = Buffer.alloc(46);
directory.writeUInt32LE(0x02014b50);
directory.writeUInt16LE(20, 4);
header.copy(directory, 6, 4, 30);
directory.writeUInt32LE(offset, 42);
local.push(header, filename, compressed);
central.push(directory, filename);
offset += header.length + filename.length + compressed.length;
}
const directory = Buffer.concat(central),
end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50);
end.writeUInt16LE(files.size, 8);
end.writeUInt16LE(files.size, 10);
end.writeUInt32LE(directory.length, 12);
end.writeUInt32LE(offset, 16);
return Buffer.concat([...local, directory, end]);
}
function unpack(buffer) {
let end = buffer.length - 22;
for (; end >= Math.max(0, buffer.length - 65557); end--)
if (buffer.readUInt32LE(end) === 0x06054b50) break;
if (end < Math.max(0, buffer.length - 65557)) throw Error("ZIP 디렉터리 없음");
const count = buffer.readUInt16LE(end + 10),
files = new Map();
if (count > 3000) throw Error("ZIP 항목 수 초과");
let position = buffer.readUInt32LE(end + 16),
total = 0;
for (let i = 0; i < count; i++) {
if (buffer.readUInt32LE(position) !== 0x02014b50) throw Error("ZIP 헤더 오류");
const flags = buffer.readUInt16LE(position + 8),
method = buffer.readUInt16LE(position + 10),
crc = buffer.readUInt32LE(position + 16),
compressed = buffer.readUInt32LE(position + 20),
size = buffer.readUInt32LE(position + 24),
length = buffer.readUInt16LE(position + 28),
extra = buffer.readUInt16LE(position + 30),
comment = buffer.readUInt16LE(position + 32),
at = buffer.readUInt32LE(position + 42),
name = buffer.subarray(position + 46, position + 46 + length).toString("utf8");
if (
flags & 1 ||
!name ||
name.startsWith("/") ||
name.includes("\\") ||
name.split("/").includes("..")
)
throw Error("지원하지 않는 ZIP 경로/암호화");
total += size;
if (total > 256 * 1024 * 1024) throw Error("압축 해제 크기 초과");
if (buffer.readUInt32LE(at) !== 0x04034b50) throw Error("ZIP 로컬 헤더 오류");
const start = at + 30 + buffer.readUInt16LE(at + 26) + buffer.readUInt16LE(at + 28);
if (start + compressed > buffer.length) throw Error("ZIP 데이터 범위 오류");
const source = buffer.subarray(start, start + compressed),
data =
method === 8
? zlib.inflateRawSync(source, { maxOutputLength: 256 * 1024 * 1024 })
: method === 0
? source
: null;
if (!data || data.length !== size || crc32(data) !== crc || files.has(name))
throw Error("ZIP CRC/중복 오류");
files.set(name, data);
position += 46 + length + extra + comment;
}
return files;
}
module.exports = { pack, unpack };
+106
View File
@@ -0,0 +1,106 @@
const { pack, unpack } = require("./archive.cjs");
const xml = (value) =>
String(value ?? "").replace(
/[&<>"']/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[c],
);
const column = (index) => {
let name = "";
for (index++; index; index = Math.floor((index - 1) / 26))
name = String.fromCharCode(65 + ((index - 1) % 26)) + name;
return name;
};
function sheet(rows) {
if (rows.length > 1048576 || rows.some((r) => r.length > 16384))
throw Error("Excel 행/열 한도 초과");
return `<?xml version="1.0" encoding="UTF-8"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" state="frozen"/></sheetView></sheetViews><sheetData>${rows
.map(
(r, i) =>
`<row r="${i + 1}">${r
.map((v, j) => {
const ref = column(j) + (i + 1);
if (v == null) return "";
if (typeof v === "number") {
if (!Number.isFinite(v)) throw Error("유효하지 않은 Excel 숫자");
return `<c r="${ref}"><v>${v}</v></c>`;
}
if (typeof v === "boolean") return `<c r="${ref}" t="b"><v>${v ? 1 : 0}</v></c>`;
const text = typeof v === "object" ? JSON.stringify(v) : String(v);
if (text.length > 32767) throw Error("Excel 셀 문자 한도 초과: " + ref);
return `<c r="${ref}" t="inlineStr"><is><t xml:space="preserve">${xml(text)}</t></is></c>`;
})
.join("")}</row>`,
)
.join("")}</sheetData><pageSetup paperSize="9" orientation="landscape"/></worksheet>`;
}
function workbook(project) {
const files = new Map(),
sheets = [];
for (const [name, rows] of Object.entries(project.tables)) {
if (!Array.isArray(rows) || name.startsWith("Cad") || name.length > 31) continue;
// Binary originals stay in JSON/ESTX; Excel edits scalar table fields only.
const keys = [
...new Set(
rows.flatMap((r) =>
Object.keys(r).filter(
(k) => !k.startsWith("_") && r[k] != null && typeof r[k] !== "object",
),
),
),
];
if (!keys.length) continue;
sheets.push({
name,
rows: [
keys,
...rows.map((r) =>
keys.map((k) => (typeof r[k] === "object" && r[k] != null ? null : r[k])),
),
],
});
}
sheets.unshift({
name: "공사정보",
rows: [
["항목", "값"],
["공사명", project.name],
["형식", "est-web-xlsx-1"],
["설명", "표의 첫 행은 필드명입니다. 바이너리는 포함하지 않습니다."],
],
});
sheets.forEach((s, i) => files.set(`xl/worksheets/sheet${i + 1}.xml`, sheet(s.rows)));
const ns = "http://schemas.openxmlformats.org/";
files.set(
"[Content_Types].xml",
`<?xml version="1.0"?><Types xmlns="${ns}package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>${sheets.map((s, i) => `<Override PartName="/xl/worksheets/sheet${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`).join("")}</Types>`,
);
files.set(
"_rels/.rels",
`<?xml version="1.0"?><Relationships xmlns="${ns}package/2006/relationships"><Relationship Id="rId1" Type="${ns}officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>`,
);
files.set(
"xl/workbook.xml",
`<?xml version="1.0" encoding="UTF-8"?><workbook xmlns="${ns}spreadsheetml/2006/main" xmlns:r="${ns}officeDocument/2006/relationships"><sheets>${sheets.map((s, i) => `<sheet name="${xml(s.name)}" sheetId="${i + 1}" r:id="rId${i + 1}"/>`).join("")}</sheets><calcPr calcId="0" fullCalcOnLoad="1"/></workbook>`,
);
files.set(
"xl/_rels/workbook.xml.rels",
`<?xml version="1.0"?><Relationships xmlns="${ns}package/2006/relationships">${sheets.map((s, i) => `<Relationship Id="rId${i + 1}" Type="${ns}officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${i + 1}.xml"/>`).join("")}</Relationships>`,
);
return pack(files);
}
function readWorkbook(bytes) {
const files = unpack(bytes),
selected = {};
if (!files.has("xl/workbook.xml") || !files.has("xl/_rels/workbook.xml.rels"))
throw Error("Excel 통합문서 구조 없음");
for (const [name, data] of files)
if (
name === "xl/workbook.xml" ||
name === "xl/_rels/workbook.xml.rels" ||
name === "xl/sharedStrings.xml" ||
/^xl\/worksheets\/[^/]+\.xml$/.test(name)
)
selected[name] = data.toString("utf8");
return selected;
}
module.exports = { workbook, readWorkbook, xml };
+239
View File
@@ -0,0 +1,239 @@
const zlib = require("node:zlib");
const { importEstx, unzip, Values, memoryRows } = require("./import-estx.cjs");
const { pack } = require("./archive.cjs");
const signature = Buffer.from("068abe8e592364cb403d71d2e3bc64d0", "hex");
const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
let characters;
function cp949(text) {
if (!characters) {
characters = new Map();
const decoder = new TextDecoder("euc-kr", { fatal: true });
for (let i = 0; i < 128; i++) characters.set(String.fromCharCode(i), Buffer.from([i]));
for (let a = 0x81; a <= 0xfe; a++)
for (let b = 0x41; b <= 0xfe; b++) {
try {
const bytes = Buffer.from([a, b]),
s = decoder.decode(bytes);
if (s.length === 1 && !characters.has(s)) characters.set(s, bytes);
} catch {}
}
}
const parts = [];
for (const c of String(text)) {
const bytes = characters.get(c);
if (!bytes) throw Error(`CP949로 저장할 수 없는 문자: ${c}`);
parts.push(bytes);
}
return Buffer.concat(parts);
}
function value(data) {
if (data === null) return Buffer.from([13]);
if (typeof data === "boolean") return Buffer.from([data ? 9 : 8]);
if (typeof data === "number") {
if (!Number.isFinite(data)) throw Error("TPF0 숫자 오류");
if (Number.isInteger(data) && data >= -128 && data <= 127) {
const b = Buffer.alloc(2);
b[0] = 2;
b.writeInt8(data, 1);
return b;
}
if (Number.isInteger(data) && data >= -2147483648 && data <= 2147483647) {
const b = Buffer.alloc(5);
b[0] = 4;
b.writeInt32LE(data, 1);
return b;
}
const b = Buffer.alloc(9);
b[0] = 21;
b.writeDoubleLE(data, 1);
return b;
}
if (typeof data === "string") {
const b = Buffer.from(data, "utf16le"),
h = Buffer.alloc(5);
h[0] = 18;
h.writeUInt32LE(data.length, 1);
return Buffer.concat([h, b]);
}
if (Buffer.isBuffer(data)) {
const h = Buffer.alloc(5);
h[0] = 10;
h.writeUInt32LE(data.length, 1);
return Buffer.concat([h, data]);
}
if (Array.isArray(data))
return Buffer.concat([Buffer.from([1]), ...data.map(value), Buffer.from([0])]);
throw Error("지원하지 않는 TPF0 값");
}
function memory(buffer, rows) {
const compressed = buffer[0] === 0x78,
bytes = compressed ? zlib.inflateSync(buffer) : buffer,
reader = new Values(bytes),
values = [];
while (reader.position < bytes.length) values.push(reader.value());
const names = values[1].filter((v, i) => i % 10 === 0),
entries = values[4];
if (rows.length !== entries.length)
throw Error("원가 서식 행 추가/삭제는 ESTX 인덱스 재생성이 필요합니다.");
values[4] = rows.map((row, i) => [
entries[i][0],
...names.flatMap((name) => (row[name] == null ? [true] : [false, row[name]])),
]);
const result = Buffer.concat([Buffer.from("TPF0"), ...values.map(value)]);
const output = compressed ? zlib.deflateSync(result) : result;
if (
!same(
memoryRows(output),
rows.map((row) => Object.fromEntries(names.map((name) => [name, row[name] ?? null]))),
)
)
throw Error("원가 서식 인코딩 검산 실패");
return output;
}
function updateBlob(file, start, id, bytes) {
const blocks = [],
seen = new Set();
let block = start;
while (block) {
if (seen.has(block) || block * 512 + 512 > file.length) throw Error("BLOB 체인 오류");
seen.add(block);
blocks.push(block);
block = file.readUInt32LE(block * 512 + 4);
}
if (bytes.length > blocks.length * 494)
throw Error(
"변경 BLOB이 원본 할당 공간을 초과합니다. 원본 공간 확장은 아직 지원하지 않습니다.",
);
if (file.readUInt32LE(start * 512 + 10) !== id) throw Error("BLOB ID 오류");
let at = 0;
for (const [i, number] of blocks.entries()) {
const offset = number * 512,
take = Math.min(494, bytes.length - at);
file.writeUInt16LE(take, offset + 8);
file.fill(0, offset + 18, offset + 512);
bytes.copy(file, offset + 18, at, at + take);
at += take;
if (i === 0) file.writeUInt32LE(bytes.length, offset + 14);
}
}
function exportEstx(project) {
if (!project.originalArchive)
throw Error("원본 ESTX를 가져온 작업에서만 ESTX 수정 저장이 가능합니다.");
const original = Buffer.from(project.originalArchive, "base64"),
base = importEstx(original),
files = unzip(original);
if (project.name !== base.name)
throw Error("공사명 변경의 원본 옵션 저장 구조가 미확인입니다. JSON으로 보관하세요.");
for (const key of Object.keys(base.options))
if (
!["SourceRefresh", "AutoCalc"].includes(key) &&
!same(project.options[key], base.options[key])
)
throw Error(`계산 옵션 ${key} 변경은 ESTX에 저장할 수 없습니다.`);
if (project.progress?.length || project.annual?.length || project.baseline)
throw Error(
"웹 기성/연차/당초 사본은 원본 별도 업무 파일 구조가 필요합니다. JSON 저장을 사용하세요.",
);
if (!same(project.quantitySheets || [], base.quantitySheets || []))
throw Error("편집한 수량산출서의 원본 WPTools 저장은 아직 지원하지 않습니다.");
let changed = 0;
const oldByLower = new Map(Object.keys(base.tables).map((key) => [key.toLowerCase(), key]));
for (const [filename, data] of files) {
if (!filename.endsWith(".dat") || !data.subarray(8, 24).equals(signature)) continue;
const key = oldByLower.get(filename.slice(0, -4));
if (!key) continue;
const rows = project.tables[key],
before = base.tables[key];
if (!rows || rows.length !== before.length)
throw Error(`${key}: 행 추가/삭제는 인덱스 재생성이 필요합니다.`);
const stride = data.readUInt16LE(45),
count = data.readUInt16LE(47),
start = 512 + 768 * count;
const blobFile = files.get(filename.replace(/\.dat$/, ".blb"));
for (let i = 0; i < rows.length; i++)
for (let f = 0; f < count; f++) {
const desc = 512 + 768 * f,
name = data.subarray(desc + 3, desc + 3 + data[desc + 2]).toString("ascii"),
kind = data[desc + 164],
width = data.readUInt16LE(desc + 169),
payload = data.readUInt32LE(desc + 172),
at = start + i * stride + payload,
old = before[i][name],
next = rows[i][name];
if (same(next, old)) continue;
// All potential index keys stay byte-identical. No stale index is ever emitted.
if (/^(PkID|Index|Code|OldCode|TitleCode|RecNo|Level|Flag)$/i.test(name))
throw Error(`${key}.${name}: 인덱스 키 변경은 지원하지 않습니다.`);
if (key.startsWith("Cad")) throw Error("CAD 수정 저장은 범위 밖입니다.");
if (next == null) {
data[at] = 0;
data.fill(0, at + 1, at + 1 + width);
changed++;
continue;
}
if (kind === 1) {
const bytes = cp949(next);
if (bytes.length >= width) throw Error(`${key}.${name}: 원본 문자열 길이 초과`);
data.fill(0, at + 1, at + 1 + width);
bytes.copy(data, at + 1);
} else if (kind === 7 && width === 8) {
if (typeof next !== "number" || !Number.isFinite(next)) throw Error("ESTX 실수 오류");
data.writeDoubleLE(next, at + 1);
} else if ([2, 6].includes(kind) && width === 4) {
if (!Number.isInteger(next)) throw Error("ESTX 정수 오류");
data.writeInt32LE(next, at + 1);
} else if (kind === 4 && width === 2) {
if (!Number.isInteger(next)) throw Error("ESTX 정수 오류");
data.writeInt16LE(next, at + 1);
} else if (kind === 3 && width === 8 && next?.binary && old?.binary && blobFile && data[at])
updateBlob(
blobFile,
data.readUInt32LE(at + 1),
data.readUInt32LE(at + 5),
Buffer.from(next.binary, "base64"),
);
else throw Error(`${key}.${name}: 미확인 필드 저장형 ${kind}`);
data[at] = 1;
changed++;
}
}
// Wonga's active web form is explicit; the original archive/import always chooses form zero.
if (!same(project.tables.Wonga, base.tables.Wonga)) {
const dat = files.get("wongadat.dat"),
blb = files.get("wongadat.blb");
if (!dat || !blb) throw Error("원가 서식 저장 표 없음");
const count = dat.readUInt16LE(47),
start = 512 + 768 * count;
let field;
for (let f = 0; f < count; f++) {
const desc = 512 + f * 768;
if (dat[desc + 164] === 3) {
field = dat.readUInt32LE(desc + 172);
break;
}
}
if (field == null) throw Error("원가 BLOB 필드 없음");
const originalBlob = Object.values(base.tables.wongadat[0]).find((v) => v?.binary);
const bytes = memory(Buffer.from(originalBlob.binary, "base64"), project.tables.Wonga);
updateBlob(
blb,
dat.readUInt32LE(start + field + 1),
dat.readUInt32LE(start + field + 5),
bytes,
);
changed++;
}
if (!changed) return original;
const result = pack(files),
check = importEstx(result);
for (const [key, rows] of Object.entries(base.tables)) {
if (key === "Wonga" || key === "wongadat") continue;
for (let i = 0; i < rows.length; i++)
for (const name of Object.keys(rows[i]))
if (!same(check.tables[key][i][name], project.tables[key][i][name]))
throw Error(`${key}.${name}: 저장 후 재읽기 검산 실패`);
}
if (!same(check.tables.Wonga, project.tables.Wonga)) throw Error("원가 저장 후 재읽기 검산 실패");
return result;
}
module.exports = { exportEstx, memory, cp949 };
+1 -1
View File
@@ -374,4 +374,4 @@ function importEstx(buffer, name = "가져온 내역서") {
originalArchive: buffer.toString("base64"),
};
}
module.exports = { importEstx, unzip, table, blob, memoryRows, optionObjects, crc32 };
module.exports = { importEstx, unzip, table, blob, memoryRows, optionObjects, crc32, Values };
+12
View File
@@ -17,11 +17,15 @@
<script defer src="native-progress.js"></script>
<script defer src="native-business.js"></script>
<script defer src="native-resources.js"></script>
<script defer src="native-schedule.js"></script>
<script defer src="native-model.js"></script>
<script defer src="native-views.js"></script>
<script defer src="native-extra.js"></script>
<script defer src="native-settings.js"></script>
<script defer src="native-report.js"></script>
<script defer src="native-forms.js"></script>
<script defer src="native-exchange.js"></script>
<script defer src="native-c3r.js"></script>
<script defer src="native-actions.js"></script>
<script defer src="native-business-actions.js"></script>
<script defer src="native-app.js"></script>
@@ -46,6 +50,13 @@
<hr />
<button data-action="csv">현재 자료 CSV 내보내기</button
><button data-action="print">인쇄</button>
<button data-action="export-xlsx">전체 자료 Excel 내보내기</button>
<button data-action="import-xlsx">Excel 표 불러오기</button>
<button data-action="export-xml">조달청 XML 내보내기</button>
<button data-action="import-xml">조달청 XML 불러오기</button>
<button data-action="export-estx">원본 구조 유지 ESTX 수정 저장</button>
<button data-action="export-jdl">JDL 기본 표 내보내기</button>
<button data-action="import-jdl">JDL 불러오기</button>
</div>
</details>
<details>
@@ -101,6 +112,7 @@
><button data-view="escalation">물가변동</button
><button data-view="annual">연차공사</button
><button data-view="resources">동원인원·자재·장비</button>
<button data-view="schedule">예정공정표</button>
</div>
</details>
<button data-action="settings" class="menu-button">환경설정</button>
+641
View File
@@ -0,0 +1,641 @@
{
"m.txt": [
{
"name": "F_CODE",
"size": 12,
"kind": 1
},
{
"name": "F_DES",
"size": 40,
"kind": 1
},
{
"name": "F_SIZE",
"size": 40,
"kind": 1
},
{
"name": "F_UNIT",
"size": 40,
"kind": 1
},
{
"name": "F_MPRICE",
"size": 25,
"kind": 6
},
{
"name": "F_ORICODE",
"size": 12,
"kind": 1
}
],
"l.txt": [
{
"name": "F_CODE",
"size": 12,
"kind": 1
},
{
"name": "F_DES",
"size": 40,
"kind": 1
},
{
"name": "F_SIZE",
"size": 40,
"kind": 1
},
{
"name": "F_UNIT",
"size": 40,
"kind": 1
},
{
"name": "F_LPRICE",
"size": 25,
"kind": 6
},
{
"name": "F_ORICODE",
"size": 12,
"kind": 1
}
],
"e.txt": [
{
"name": "F_CODE",
"size": 12,
"kind": 1
},
{
"name": "F_DES",
"size": 40,
"kind": 1
},
{
"name": "F_SIZE",
"size": 40,
"kind": 1
},
{
"name": "F_UNIT",
"size": 40,
"kind": 1
},
{
"name": "F_MPRICE",
"size": 25,
"kind": 6
},
{
"name": "F_LPRICE",
"size": 25,
"kind": 6
},
{
"name": "F_EPRICE",
"size": 25,
"kind": 6
},
{
"name": "F_ORICODE",
"size": 12,
"kind": 1
}
],
"w.txt": [
{
"name": "F_CODE",
"size": 12,
"kind": 1
},
{
"name": "F_DES",
"size": 40,
"kind": 1
},
{
"name": "F_SIZE",
"size": 40,
"kind": 1
},
{
"name": "F_UNIT",
"size": 40,
"kind": 1
},
{
"name": "F_MPRICE",
"size": 25,
"kind": 6
},
{
"name": "F_LPRICE",
"size": 25,
"kind": 6
},
{
"name": "F_EPRICE",
"size": 25,
"kind": 6
},
{
"name": "F_ORICODE",
"size": 12,
"kind": 1
}
],
"j.txt": [
{
"name": "F_CODE",
"size": 12,
"kind": 1
},
{
"name": "F_DES",
"size": 40,
"kind": 1
},
{
"name": "F_SIZE",
"size": 40,
"kind": 1
},
{
"name": "F_UNIT",
"size": 40,
"kind": 1
},
{
"name": "F_MPRICE",
"size": 25,
"kind": 6
},
{
"name": "F_LPRICE",
"size": 25,
"kind": 6
},
{
"name": "F_EPRICE",
"size": 25,
"kind": 6
},
{
"name": "F_ORICODE",
"size": 12,
"kind": 1
}
],
"gongjong.txt": [
{
"name": "F_CODE",
"size": 12,
"kind": 1
},
{
"name": "F_DES",
"size": 40,
"kind": 1
},
{
"name": "F_QQTY",
"size": 26,
"kind": 6
},
{
"name": "F_MPRICE",
"size": 25,
"kind": 6
},
{
"name": "F_LPRICE",
"size": 25,
"kind": 6
},
{
"name": "F_EPRICE",
"size": 25,
"kind": 6
},
{
"name": "F_ORICODE",
"size": 12,
"kind": 1
}
],
"unit.txt": [
{
"name": "F_CODE",
"size": 12,
"kind": 1
},
{
"name": "F_DES",
"size": 40,
"kind": 1
},
{
"name": "F_SIZE",
"size": 40,
"kind": 1
},
{
"name": "F_UNIT",
"size": 40,
"kind": 1
},
{
"name": "F_MPRICE",
"size": 25,
"kind": 6
},
{
"name": "F_LPRICE",
"size": 25,
"kind": 6
},
{
"name": "F_EPRICE",
"size": 25,
"kind": 6
},
{
"name": "F_ORICODE",
"size": 12,
"kind": 1
}
],
"junit.txt": [
{
"name": "F_CODE",
"size": 12,
"kind": 1
},
{
"name": "F_DES",
"size": 40,
"kind": 1
},
{
"name": "F_SIZE",
"size": 40,
"kind": 1
},
{
"name": "F_UNIT",
"size": 40,
"kind": 1
},
{
"name": "F_MPRICE",
"size": 25,
"kind": 6
},
{
"name": "F_LPRICE",
"size": 25,
"kind": 6
},
{
"name": "F_EPRICE",
"size": 25,
"kind": 6
},
{
"name": "F_ORICODE",
"size": 12,
"kind": 1
}
],
"danga.txt": [
{
"name": "F_CODE",
"size": 12,
"kind": 1
},
{
"name": "F_DES",
"size": 40,
"kind": 1
},
{
"name": "F_SIZE",
"size": 40,
"kind": 1
},
{
"name": "F_UNIT",
"size": 40,
"kind": 1
},
{
"name": "F_MPRICE",
"size": 25,
"kind": 6
},
{
"name": "F_LPRICE",
"size": 25,
"kind": 6
},
{
"name": "F_EPRICE",
"size": 25,
"kind": 6
},
{
"name": "F_ORICODE",
"size": 12,
"kind": 1
},
{
"name": "F_MEMO",
"size": 10,
"kind": 16
}
],
"mainpart.txt": [
{
"name": "F_CODE",
"size": 12,
"kind": 1
},
{
"name": "F_SEQ",
"size": 10,
"kind": 1
},
{
"name": "F_CODE1",
"size": 12,
"kind": 1
},
{
"name": "F_DES",
"size": 40,
"kind": 1
},
{
"name": "F_SIZE",
"size": 40,
"kind": 1
},
{
"name": "F_UNIT",
"size": 40,
"kind": 1
},
{
"name": "F_QQTY",
"size": 26,
"kind": 6
},
{
"name": "F_MPRICE",
"size": 25,
"kind": 6
},
{
"name": "F_LPRICE",
"size": 25,
"kind": 6
},
{
"name": "F_EPRICE",
"size": 25,
"kind": 6
},
{
"name": "F_SPROPER",
"size": 25,
"kind": 1
},
{
"name": "F_LOADBM",
"size": null,
"kind": 1
},
{
"name": "F_SAVEBM",
"size": null,
"kind": 1
},
{
"name": "F_SRATE",
"size": 26,
"kind": 6
},
{
"name": "F_KWAN",
"size": 26,
"kind": 1
},
{
"name": "F_CLASS",
"size": null,
"kind": 1
},
{
"name": "F_ORICODE",
"size": 12,
"kind": 1
}
],
"unitpart.txt": [
{
"name": "F_CODE",
"size": 12,
"kind": 1
},
{
"name": "F_SEQ",
"size": 10,
"kind": 1
},
{
"name": "F_CODE1",
"size": 12,
"kind": 1
},
{
"name": "F_DES",
"size": 40,
"kind": 1
},
{
"name": "F_SIZE",
"size": 40,
"kind": 1
},
{
"name": "F_UNIT",
"size": 40,
"kind": 1
},
{
"name": "F_QQTY",
"size": 26,
"kind": 6
},
{
"name": "F_MPRICE",
"size": 25,
"kind": 6
},
{
"name": "F_LPRICE",
"size": 25,
"kind": 6
},
{
"name": "F_EPRICE",
"size": 25,
"kind": 6
},
{
"name": "F_MCOST",
"size": 25,
"kind": 6
},
{
"name": "F_LCOST",
"size": 25,
"kind": 6
},
{
"name": "F_ECOST",
"size": 25,
"kind": 6
},
{
"name": "F_SPROPER",
"size": 25,
"kind": 1
},
{
"name": "F_LOADBM",
"size": null,
"kind": 1
},
{
"name": "F_SAVEBM",
"size": null,
"kind": 1
},
{
"name": "F_SRATE",
"size": 26,
"kind": 6
},
{
"name": "F_KWAN",
"size": 26,
"kind": 1
},
{
"name": "F_CLASS",
"size": null,
"kind": 1
},
{
"name": "F_ORICODE",
"size": 12,
"kind": 1
}
],
"jpart.txt": [
{
"name": "F_CODE",
"size": 12,
"kind": 1
},
{
"name": "F_SEQ",
"size": 10,
"kind": 1
},
{
"name": "F_CODE1",
"size": 12,
"kind": 1
},
{
"name": "F_DES",
"size": 40,
"kind": 1
},
{
"name": "F_SIZE",
"size": 40,
"kind": 1
},
{
"name": "F_UNIT",
"size": 40,
"kind": 1
},
{
"name": "F_QQTY",
"size": 26,
"kind": 6
},
{
"name": "F_MPRICE",
"size": 25,
"kind": 6
},
{
"name": "F_LPRICE",
"size": 25,
"kind": 6
},
{
"name": "F_EPRICE",
"size": 25,
"kind": 6
},
{
"name": "F_SPROPER",
"size": 25,
"kind": 1
},
{
"name": "F_LOADBM",
"size": null,
"kind": 1
},
{
"name": "F_SAVEBM",
"size": null,
"kind": 1
},
{
"name": "F_SRATE",
"size": 26,
"kind": 6
},
{
"name": "F_KWAN",
"size": 26,
"kind": 1
},
{
"name": "F_CLASS",
"size": null,
"kind": 1
},
{
"name": "F_ORICODE",
"size": 12,
"kind": 1
},
{
"name": "F_GBCODE",
"size": 12,
"kind": 1
}
]
}
+330
View File
@@ -0,0 +1,330 @@
// JDLCHK.dll field declarations: 4e926c..4fb010; EST writer e44f28/e4739c/e541ec.
const { pack, unpack } = require("./archive.cjs");
const { cp949 } = require("./export-estx.cjs");
const schema = require("./jdl-schema.json");
const decoder = new TextDecoder("euc-kr", { fatal: true });
const num = (v) => {
const n = Number(v || 0);
if (!Number.isFinite(n)) throw Error("JDL 수치 오류");
return n;
};
function text(v, max = 40) {
const s = String(v ?? "");
if (/[\x00-\x1f]/.test(s)) throw Error("JDL 문자열에 제어문자가 있습니다.");
if (cp949(s).length > max) throw Error(`JDL 문자열 길이 ${max}바이트 초과: ${s}`);
return s;
}
function encode(name, rows) {
const fields = schema[name];
return cp949(
rows
.map(
(row) =>
fields
.map((f) => {
const v = row[f.name];
return f.kind === 6 ? String(num(v)) : text(v, f.kind === 16 ? 100000 : f.size || 1);
})
.join("\t") + "\x05\r\n",
)
.join(""),
);
}
function parse(name, bytes) {
const fields = schema[name];
if (!fields || !bytes) return [];
return decoder
.decode(bytes)
.split(/\r?\n/)
.filter((v) => v.trim())
.map((line, i) => {
const values = line
.replace(/\x05\s*$/, "")
.split("\t")
.map((v) => v.trim());
if (values.length !== fields.length)
throw Error(
`${name} ${i + 1}행: ${fields.length}개 필드가 필요합니다 (${values.length}개).`,
);
return Object.fromEntries(
fields.map((f, j) => [
f.name,
f.kind === 6 ? num(values[j]) : text(values[j], f.kind === 16 ? 100000 : f.size || 1),
]),
);
});
}
function output(project) {
const tables = Object.fromEntries(Object.keys(schema).map((k) => [k, []])),
warnings = [],
map = new Map();
let serial = 0;
function source(kind, row) {
const key = kind + ":" + row.Code;
if (map.has(key)) return map.get(key);
const prefix = kind === 5 ? "M" : kind === 6 ? "L" : "W",
code = prefix + String(++serial).padStart(10, "0");
const price =
kind === 5
? { F_MPRICE: num(row.TC) }
: kind === 6
? { F_LPRICE: num(row.TC) }
: { F_MPRICE: num(row.GC), F_LPRICE: num(row.NC), F_EPRICE: num(row.JC) + num(row.SC) };
const name = prefix === "M" ? "m.txt" : prefix === "L" ? "l.txt" : "w.txt";
tables[name].push({
F_CODE: code,
F_DES: row.Name,
F_SIZE: row.Size,
F_UNIT: row.Unite,
...price,
F_ORICODE: "00000000000",
});
map.set(key, code);
return code;
}
for (const [kind, table] of [
[5, "StuffcDat"],
[6, "LaborcDat"],
[21, "SilJukDat"],
[101, "EstiMateDat"],
])
for (const row of project.tables[table] || []) source(kind, row);
for (const table of ["CostTitleDat", "MachTitleDat", "HeadTitleDat"])
for (const row of project.tables[table] || [])
source({ CostTitleDat: 104, MachTitleDat: 105, HeadTitleDat: 103 }[table], row);
let group = null,
groupNumber = 0,
sequence = 0;
function addGroup(name) {
if (++groupNumber > 99) throw Error("JDL 단일 레벨 변환은 공종 99개까지 가능합니다.");
group = {
F_CODE: String(groupNumber).padStart(2, "0"),
F_DES: text(name),
F_QQTY: 1,
F_MPRICE: 0,
F_LPRICE: 0,
F_EPRICE: 0,
F_ORICODE: "00000000000",
};
tables["gongjong.txt"].push(group);
sequence = 0;
}
for (const row of project.tables.EstMainDat) {
if (num(row.LinkIndex) === 54) break;
if (num(row.Level) > 0) {
addGroup(row.Name);
continue;
}
if (!group) addGroup(project.name);
if (num(row.CalcIndex) >= 2) {
warnings.push(`${row.Code || row.Name}: 합산 제외 행 생략`);
continue;
}
let key = map.get(num(row.LinkIndex) + ":" + row.Code),
gn = num(row.GN),
m = num(row.GU),
l = num(row.NU),
e = num(row.JU) + num(row.SU);
if (
!key ||
num(row.CalcIndex) === 1 ||
num(row.CalcIndex) === 3 ||
Math.abs(gn * (m + l + e) - num(row.TC)) > 0.000001
) {
key = source(101, {
Code: "LITERAL" + ++serial,
Name: row.Name,
Size: row.Size,
Unite: row.Unite,
GC: row.GC,
NC: row.NC,
JC: row.JC,
SC: row.SC,
});
gn = 1;
m = num(row.GC);
l = num(row.NC);
e = num(row.JC) + num(row.SC);
warnings.push(`${row.Code || row.Name}: 저장 금액을 독립 항목으로 전개`);
}
tables["mainpart.txt"].push({
F_CODE: group.F_CODE,
F_SEQ: String(++sequence),
F_CODE1: key,
F_DES: row.Name,
F_SIZE: row.Size,
F_UNIT: row.Unite,
F_QQTY: gn,
F_MPRICE: m,
F_LPRICE: l,
F_EPRICE: e,
F_SPROPER: "0",
F_LOADBM: "T",
F_SAVEBM: "T",
F_SRATE: 0,
F_KWAN: "0",
F_CLASS: "0",
F_ORICODE: "00000000000",
});
group.F_MPRICE += num(row.GC);
group.F_LPRICE += num(row.NC);
group.F_EPRICE += num(row.JC) + num(row.SC);
}
if (!tables["gongjong.txt"].length) addGroup(project.name);
const direct = tables["gongjong.txt"].reduce(
(a, r) => ({ m: a.m + r.F_MPRICE, l: a.l + r.F_LPRICE, e: a.e + r.F_EPRICE }),
{ m: 0, l: 0, e: 0 },
);
const global = `[BASIC]\r\nMAKER=ESTWEB\r\nVERSION=2.00\r\nCDATE=${new Date().toISOString().slice(0, 10).replaceAll("-", "")}\r\n[CPART]\r\nMQBIT=7C\r\nMABIT=0C\r\nMTBIT=0C\r\nMCMETHOD=0LME\r\nUABIT=0C\r\nUTBIT=0C\r\nUCMETHOD=0LME\r\nDCMETHOD=0LME\r\nJABIT=0C\r\nJTBIT=0C\r\nJCMETHOD=0LME\r\nDQBIT=2R\r\nDABIT=0C\r\nDTBIT=0C\r\n[JPART]\r\n[VPART]\r\n[COST]\r\nMCOST=${direct.m}\r\nLCOST=${direct.l}\r\nECOST=${direct.e}\r\nTCOST=${direct.m + direct.l + direct.e}\r\n`;
const files = new Map([
["Global.ini", cp949(global)],
["Main.txt", cp949(["0", text(project.name, 80), "02"].join("\t") + "\x05\r\n")],
]);
for (const [name, rows] of Object.entries(tables)) files.set(name, encode(name, rows));
const fees = (project.tables.Wonga || [])
.map(
(r) =>
[
text(r.Gubun, 12),
text(r.Name),
text(r.Youl, 25),
text(r.VSusic, 200),
String(num(r.Cost)),
].join("\t") + "\x05\r\n",
)
.join("");
files.set("CostAccount.txt", cp949(fees));
warnings.unshift(
"JDL의 기본 표로 금액을 전개합니다. 산근·기계·일위의 원본 편집 산식과 관급/특수 업무 구조는 JSON/ESTX에 보관하세요. 원본 검사 DLL의 인증은 미확인입니다.",
);
return { bytes: pack(files), warnings };
}
function read(bytes, project) {
const files = new Map([...unpack(bytes)].map(([k, v]) => [k.toLowerCase(), v]));
if (!files.has("global.ini") || !files.has("main.txt"))
throw Error("Global.ini/Main.txt 없는 JDL입니다.");
const main = decoder
.decode(files.get("main.txt"))
.replace(/\x05\s*$/, "")
.split("\t");
if (main.length !== 3) throw Error("JDL Main.txt 필드 오류");
project.name = main[1].trim();
project.options.AutoCalc = false;
project.options.SourceRefresh = false;
const rows = Object.fromEntries(
Object.keys(schema).map((name) => [name, parse(name, files.get(name))]),
);
const byCode = new Map();
for (const [file, table, kind] of [
["m.txt", "StuffcDat", 5],
["l.txt", "LaborcDat", 6],
["e.txt", "EstiMateDat", 101],
["w.txt", "EstiMateDat", 101],
["j.txt", "EstiMateDat", 101],
["unit.txt", "HeadTitleDat", 103],
["junit.txt", "MachTitleDat", 105],
["danga.txt", "CostTitleDat", 104],
])
for (const r of rows[file]) {
if (byCode.has(r.F_CODE)) throw Error("JDL 자원 코드 중복: " + r.F_CODE);
const row = {
PkID: project.tables[table].length + 1,
Code: r.F_CODE,
Name: r.F_DES,
Size: r.F_SIZE,
Unite: r.F_UNIT,
GC: num(r.F_MPRICE),
NC: num(r.F_LPRICE),
JC: num(r.F_EPRICE),
SC: 0,
};
row.TC = row.InputTC = row.GC + row.NC + row.JC;
row.ValueIndex = 4;
project.tables[table].push(row);
byCode.set(row.Code, { row, kind });
}
const headings = new Set();
let pk = 0;
for (const group of rows["gongjong.txt"]) {
if (headings.has(group.F_CODE)) throw Error("JDL 공종 코드 중복");
headings.add(group.F_CODE);
project.tables.EstMainDat.push({
PkID: ++pk,
Level: 1,
Code: group.F_CODE,
Name: group.F_DES,
GC: group.F_MPRICE,
NC: group.F_LPRICE,
JC: group.F_EPRICE,
SC: 0,
TC: group.F_MPRICE + group.F_LPRICE + group.F_EPRICE,
});
for (const r of rows["mainpart.txt"].filter((r) => r.F_CODE === group.F_CODE)) {
const source = byCode.get(r.F_CODE1);
if (!source) throw Error("JDL 연결 자원 없음: " + r.F_CODE1);
const row = {
PkID: ++pk,
Code: r.F_CODE1,
Name: r.F_DES,
Size: r.F_SIZE,
Unite: r.F_UNIT,
GN: r.F_QQTY,
GU: r.F_MPRICE,
NU: r.F_LPRICE,
JU: r.F_EPRICE,
SU: 0,
LinkIndex: source.kind,
CalcIndex: 0,
};
row.GC = Math.trunc(row.GN * row.GU);
row.NC = Math.trunc(row.GN * row.NU);
row.JC = Math.trunc(row.GN * row.JU);
row.SC = 0;
row.TC = row.GC + row.NC + row.JC;
row.TU = row.GU + row.NU + row.JU;
project.tables.EstMainDat.push(row);
}
}
if (rows["mainpart.txt"].some((r) => !headings.has(r.F_CODE))) throw Error("JDL 부모 공종 없음");
const direct = project.tables.EstMainDat.filter((r) => !r.Level),
sum = (k) => direct.reduce((a, r) => a + num(r[k]), 0);
project.tables.EstMainDat.push({
PkID: ++pk,
Name: "순공사비",
LinkIndex: 54,
GC: sum("GC"),
NC: sum("NC"),
JC: sum("JC"),
SC: 0,
TC: sum("TC"),
});
if (files.has("costaccount.txt")) {
const lines = decoder
.decode(files.get("costaccount.txt"))
.split(/\r?\n/)
.filter((s) => s.trim());
for (const line of lines) {
const f = line.replace(/\x05\s*$/, "").split("\t");
if (f.length < 5) throw Error("JDL 원가 필드 오류");
project.tables.Wonga.push({
Gubun: f[0].trim(),
Name: f[1].trim(),
Youl: f[2].trim(),
VSusic: f[3].trim(),
Cost: num(f[4]),
});
}
}
project.originalJdl = bytes.toString("base64");
project.importWarnings = [
"JDL 저장 금액을 보존하기 위해 자동계산을 껐습니다. 복잡한 단가산출식·손료/기성 확장 파일은 원문에 보존되며 자동 변환하지 않습니다.",
];
project.calculation = {
errors: [],
warnings: project.importWarnings,
direct: project.tables.EstMainDat.at(-1),
total: project.tables.Wonga.at(-1)?.Cost ?? sum("TC"),
};
return project;
}
module.exports = { output, read, parse };
+49 -1
View File
@@ -209,7 +209,53 @@ const EstActions = (() => {
if (!selected().length) throw Error("레벨을 바꿀 행을 선택하세요.");
const level = Number(node.dataset.level);
EstApp.change(() => selected().forEach((i) => (activeRows()[i].Level = level)));
} else if (name === "print") EstReport.open();
} else if (name === "export-xlsx") await EstExchange.exportExcel();
else if (name === "export-estx") await EstExchange.exportNative();
else if (name === "export-jdl") await EstExchange.exportJdl();
else if (name === "import-jdl") EstApp.byId("jdl-file").click();
else if (name === "export-xml")
form(
"조달청 XML 작성정보",
[
{ name: "company", label: "작성업체명", value: project.exportMeta?.company || "" },
{
name: "kind",
label: "공사구분",
value: project.exportMeta?.kind || "",
choices: [
["", "선택하세요"],
...[
"토목공사",
"건축공사",
"설비공사",
"전기공사",
"통신공사",
"조경공사",
"소방공사",
"문화재수리공사",
"국가유산수리공사",
].map((v) => [v, v]),
],
},
{ name: "number", label: "공사관리번호", value: project.exportMeta?.number || "" },
],
(meta) => {
const output = EstC3r.output(project, meta);
if (
output.warnings.length &&
!confirm(
output.warnings.slice(0, 15).join("\n") +
`\n총 ${output.warnings.length}건. 단가 전개 결과를 XML로 저장할까요?`,
)
)
return;
EstApp.change(() => (project.exportMeta = meta), false);
EstApp.download(`${project.name}.xml`, output.xml, "application/xml;charset=utf-8");
},
);
else if (name === "import-xml") EstApp.byId("xml-file").click();
else if (name === "import-xlsx") EstApp.byId("excel-file").click();
else if (name === "print") await EstReport.open();
else if (name === "print-document") EstReport.print();
else if (name === "csv") {
const rows = activeRows(),
@@ -430,6 +476,8 @@ const EstActions = (() => {
else await EstBusinessActions.handle(name, node);
}
function install() {
EstSchedule.install();
EstExchange.install();
EstBusinessActions.install();
document.addEventListener("click", async (event) => {
try {
+1
View File
@@ -32,6 +32,7 @@ const EstApp = (() => {
escalation: "물가변동",
annual: "연차공사",
resources: "동원 집계",
schedule: "예정공정표",
drawing: "도면·문서",
};
const esc = (value) =>
+579
View File
@@ -0,0 +1,579 @@
// Schema: knowledge/original/경쟁사 프로그램/STmate 분석/30_원자료/조달청_공사원가호환규정.xsd (1.0.5).
const EstC3r = (() => {
const kinds = {
5: "자재",
6: "노무",
21: "표준시장단가",
101: "경비항목",
103: "일위대가",
104: "중기단가산출",
105: "기계경비",
};
const esc = (value) => {
if (/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(String(value ?? "")))
throw Error("XML에 사용할 수 없는 제어문자가 있습니다.");
return String(value ?? "").replace(
/[&<>"']/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[c],
);
};
const tag = (name, value) => `<${name}>${esc(value)}</${name}>`;
const raw = (name, value) => `<${name}>${value}</${name}>`;
const n = (value) => {
const number = Number(value || 0);
if (!Number.isFinite(number)) throw Error("XML 수치 오류");
return number;
};
function text(value, length, label) {
const s = String(value ?? "").replace(/[\r\n\t]/g, " ");
if (s.length > length) throw Error(`${label}: XML 길이 ${length}자 초과`);
return s;
}
function code(value, length = 17) {
const s = String(value || "");
if (!s || s.length > length || !/^[A-Z0-9]+$/.test(s))
throw Error(`XML 코드 형식 오류: ${s} (영문 대문자/숫자 ${length}자 이내)`);
return s;
}
const name = (row) =>
tag("품명", text(row.Name, 50, "품명")) +
tag("규격", text(row.Size, 100, "규격")) +
tag("단위", text(row.Unite, 10, "단위"));
const amounts = (row) =>
tag("재료비", n(row.GC)) + tag("노무비", n(row.NC)) + tag("경비", n(row.JC) + n(row.SC));
const prices = (row) =>
tag("재료비단가", n(row.GU)) +
tag("노무비단가", n(row.NU)) +
tag("경비단가", n(row.JU) + n(row.SU));
const remarks = (row) => tag("비고", text(row.Bigo || row.Bigo1 || "", 50, "비고"));
const flag = (value) => (n(value) >= 2 ? "true" : "false");
function output(project, meta) {
if (!meta?.company?.trim() || !meta?.kind)
throw Error("XML 작성업체명과 공사구분을 입력하세요.");
if (
![
"토목공사",
"건축공사",
"설비공사",
"전기공사",
"통신공사",
"조경공사",
"소방공사",
"문화재수리공사",
"국가유산수리공사",
].includes(meta.kind)
)
throw Error("XML 공사구분 오류");
const date = meta.date || new Date().toISOString().slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || Number.isNaN(Date.parse(date)))
throw Error("XML 제작일 오류");
const source = project.tables,
generated = [],
warnings = [];
const available = new Map(
Object.keys(kinds).map((kind) => [
Number(kind),
new Set((source[EstNative.sourceTables[kind]] || []).map((row) => row.Code)),
]),
);
function requireSource(kind, key) {
if (!available.get(n(kind))?.has(key)) throw Error(`XML 연결 자료 없음: ${kind}/${key}`);
}
const expenseCodes = new Set((source.EstiMateDat || []).map((r) => r.Code));
let serial = 0;
function expense(row, unit = false) {
let value;
do {
value = "X" + String(++serial).padStart(10, "0");
} while (expenseCodes.has(value));
expenseCodes.add(value);
generated.push({
Code: value,
Name: row.Name,
Size: row.Size,
Unite: row.Unite,
NC: unit ? n(row.NU) : n(row.NC),
GC: unit ? n(row.GU) : n(row.GC),
JC: unit ? n(row.JU) + n(row.SU) : n(row.JC) + n(row.SC),
});
return value;
}
function detail(row, index) {
let kind = kinds[n(row.LinkIndex)],
key = row.Code,
gn = n(row.GN),
current = { ...row };
if (!kind || !key || [1, 3].includes(n(row.CalcIndex))) {
kind = "경비항목";
key = expense(row);
gn = 1;
current.GU = n(row.GC);
current.NU = n(row.NC);
current.JU = n(row.JC) + n(row.SC);
current.SU = 0;
warnings.push(`${row.Code || row.Name || index}: 독립 금액 항목으로 변환`);
} else requireSource(row.LinkIndex, key);
code(key);
return raw(
"세부공종",
tag("세부공종코드", key) +
tag("자원구분", kind) +
tag("계산제외", flag(row.CalcIndex)) +
tag("세부공종번호", index) +
tag("세부공종명", text(row.Name, 50, "세부공종명")) +
tag("규격", text(row.Size, 100, "규격")) +
tag("단위", text(row.Unite, 10, "단위")) +
tag("수량", gn) +
prices(current) +
amounts(row) +
tag("관급", "false") +
tag("자재구분", "구분없음") +
tag("세부공종구분", "일반내역") +
remarks(row),
);
}
let active = null;
const groups = [],
stack = [],
siblings = new Map();
function addGroup(name, level, header = null) {
while (stack.length && stack.at(-1).level <= level) stack.pop();
const parent = stack.at(-1)?.code || "",
number = (siblings.get(parent) || 0) + 1;
if (number > 99 || parent.length >= 20)
throw Error("XML 공종 계층/동일 단계 공종 수 한도 초과");
siblings.set(parent, number);
active = { name, level, header, code: parent + String(number).padStart(2, "0"), rows: [] };
groups.push(active);
stack.push(active);
}
for (const row of source.EstMainDat) {
if (n(row.LinkIndex) === 54) break;
if (n(row.Level) > 0) {
addGroup(row.Name, n(row.Level), row);
continue;
}
if (!active) addGroup(project.name, 10);
active.rows.push(row);
}
if (!groups.length) addGroup(project.name, 10);
const boq = groups
.map((group, i) =>
raw(
"내역서",
tag("내역서번호", group.code) +
tag("공종구분", "일반공종") +
tag("계산제외", "false") +
tag("공종번호", i + 1) +
tag("공종명", text(group.name, 50, "공종명")) +
amounts(
group.header || {
GC: group.rows.reduce((s, r) => s + (n(r.CalcIndex) < 2 ? n(r.GC) : 0), 0),
NC: group.rows.reduce((s, r) => s + (n(r.CalcIndex) < 2 ? n(r.NC) : 0), 0),
JC: group.rows.reduce(
(s, r) => s + (n(r.CalcIndex) < 2 ? n(r.JC) + n(r.SC) : 0),
0,
),
},
) +
tag("비고", "") +
group.rows.map((r, j) => detail(r, j + 1)).join(""),
),
)
.join("");
const head = [...EstNative.groups(source.HeadMainDat || []).values()]
.map((group) => {
const title =
source.HeadTitleDat?.find((r) => r.Code === group.rows[0].Code) || group.rows[0];
return raw(
"일위대가",
tag("일위대가코드", code(group.rows[0].Code, 12)) +
name(title) +
amounts(title) +
tag("비목구분", "합계") +
remarks(title) +
group.rows
.slice(1)
.map((row) => {
let kind = kinds[n(row.LinkIndex)],
key = row.Code,
current = { ...row };
if (!kind || !key || [1, 3].includes(n(row.CalcIndex))) {
kind = "경비항목";
key = expense(row);
current.GN = 1;
current.GU = n(row.GC);
current.NU = n(row.NC);
current.JU = n(row.JC) + n(row.SC);
current.SU = 0;
warnings.push(`${group.rows[0].Code}: 연결 없는 구성 항목을 독립 단가로 변환`);
} else requireSource(row.LinkIndex, key);
return raw(
"일위대가상세",
tag("자원구분", kind) +
tag("자원코드", code(key)) +
tag("계산제외", flag(row.CalcIndex)) +
name(row) +
tag("수량", n(current.GN)) +
prices(current) +
amounts(row) +
tag("관급", "false") +
remarks(row),
);
})
.join(""),
);
})
.join("");
const costs = [...EstNative.groups(source.CostMainDat || []).values()]
.map((group) => {
const title =
source.CostTitleDat?.find((r) => r.Code === group.rows[0].Code) || group.rows[0];
return raw(
"중기단가산출",
tag("중기단가산출코드", code(title.Code, 12)) +
name(title) +
amounts(title) +
tag("비목구분", "합계") +
remarks(title) +
tag(
"중기단가산출식",
group.rows
.slice(1)
.map((r) => EstText.clean(r.LineData || ""))
.join("\n"),
),
);
})
.join("");
const machines = [...EstNative.groups(source.MachMainDat || []).values()]
.map((group) => {
const title =
source.MachTitleDat?.find((r) => r.Code === group.rows[0].Code) || group.rows[0];
warnings.push(`${title.Code}: 기계경비 상세는 경비항목 단가로 전개`);
return raw(
"기계경비",
tag("기계경비코드", code(title.Code)) +
name(title) +
tag("작업시간", "E0") +
tag("작업장소", "") +
amounts(title) +
remarks(title) +
group.rows
.slice(1)
.filter((r) => n(r.Index) === 0)
.map((row) => {
const key = expense(row);
return raw(
"기계경비상세",
tag("자원구분", "경비항목") +
tag("자원코드", key) +
tag("계산제외", flag(row.CalcIndex)) +
name(row) +
tag("수량", 1) +
prices({ GU: n(row.GC), NU: n(row.NC), JU: n(row.JC) + n(row.SC) }) +
amounts(row) +
remarks(row),
);
})
.join(""),
);
})
.join("");
const materials = (source.StuffcDat || [])
.map((row) =>
raw(
"자재",
tag("자재코드", code(row.Code, 16)) +
name(row) +
tag("재료비단가", n(row.TC)) +
remarks(row),
),
)
.join("");
const labor = (source.LaborcDat || [])
.map((row) =>
raw(
"노무",
tag("노무코드", code(row.Code, 16)) +
name(row) +
tag("노무비단가", n(row.TC)) +
remarks(row),
),
)
.join("");
const expenses = [...(source.EstiMateDat || []), ...generated]
.map((row) =>
raw(
"경비항목",
tag("경비항목코드", code(row.Code, 16)) +
tag("경비구분", "견적항목") +
name(row) +
tag("통화구분", "") +
tag("재료비단가", n(row.GC)) +
tag("노무비단가", n(row.NC)) +
tag("경비단가", n(row.JC) + n(row.SC)) +
remarks(row),
),
)
.join("");
const market = (source.SilJukDat || [])
.map((row) => {
const key = code(row.Code, 12);
if (!/^[ACEMT]/.test(key)) throw Error(`표준시장단가 코드 접두어 오류: ${key}`);
return raw(
"실적단가",
tag("항목구분", "표준시장단가") +
tag("공종구분", meta.kind === "건축공사" ? "건축공사" : "토목공사") +
tag("실적공사비코드", key) +
name(row) +
tag("재료비단가", n(row.GC)) +
tag("노무비단가", n(row.NC)) +
tag("경비단가", n(row.JC) + n(row.SC)) +
remarks(row),
);
})
.join("");
const fees = (source.Wonga || [])
.map((row) =>
raw(
"제경비항목",
tag("세부공종코드", code(row.Gubun, 12)) +
tag("집계항목여부", row.BaseField ? "true" : "false") +
tag("비목명", text(row.Name, 50, "비목명")) +
tag("적용산식", text(row.VSusic, 200, "원가 수식")) +
tag("적용요율", n(String(row.Youl || 0).replace("%", ""))) +
tag("추가금액", 0) +
tag("산출금액", n(row.Cost)) +
tag("적용산식설명", ""),
),
)
.join("");
const body =
raw("공사제경비", fees) +
raw(
"단일공사내역서",
raw("단일공사정보", tag("공사구분", meta.kind) + tag("공사설명", "")) +
boq +
head +
costs +
machines +
materials +
labor +
expenses +
market,
);
return {
xml:
'<?xml version="1.0" encoding="UTF-8"?>' +
raw(
"공사원가호환규정",
tag("스키마버전", "1.0.5") +
raw(
"작성정보",
tag("작성업체명", text(meta.company, 50, "작성업체명")) +
tag("프로그램명", "EST 독립 웹앱") +
tag("프로그램버전", "est-web-2") +
tag("파일제작일", date) +
tag("파일검증여부", "false") +
tag("건축공사사업내용작성여부", "false"),
) +
raw(
"공사정보",
tag("공사관리번호", text(meta.number || "", 20, "관리번호")) +
tag("주공사", meta.kind) +
tag("공사명", text(project.name, 100, "공사명")) +
tag("비고", ""),
) +
raw("공사내역서", body),
),
warnings: [...new Set(warnings)],
};
}
function read(source) {
if (/<!DOCTYPE|<!ENTITY/i.test(source)) throw Error("XML 외부 선언은 허용하지 않습니다.");
const doc = new DOMParser().parseFromString(source, "application/xml");
if (doc.querySelector("parsererror") || doc.documentElement.localName !== "공사원가호환규정")
throw Error("공사원가호환규정 XML 형식이 아닙니다.");
const children = (node, name) => [...node.children].filter((c) => c.localName === name);
const value = (node, name) => children(node, name)[0]?.textContent || "";
const amount = (node, name) => {
const raw = value(node, name);
if (raw.trim() === "" || !Number.isFinite(Number(raw))) throw Error(`XML 숫자 오류: ${name}`);
return Number(raw);
};
const rows = (node, name) => [...node.getElementsByTagName(name)];
const result = EstNative.empty();
result.options.SourceRefresh = false;
result.options.AutoCalc = false; // Imported monetary values are authoritative until explicitly recalculated.
const info = children(doc.documentElement, "공사정보")[0];
if (!info) throw Error("XML 공사정보 없음");
result.name = value(info, "공사명");
result.exportMeta = {
company: value(children(doc.documentElement, "작성정보")[0], "작성업체명"),
kind: value(info, "주공사"),
number: value(info, "공사관리번호"),
};
const fields = (node) => ({
Name: value(node, "품명"),
Size: value(node, "규격"),
Unite: value(node, "단위"),
Bigo: value(node, "비고"),
});
const money = (node) => ({
GC: amount(node, "재료비"),
NC: amount(node, "노무비"),
JC: amount(node, "경비"),
SC: 0,
TC: amount(node, "재료비") + amount(node, "노무비") + amount(node, "경비"),
});
const unit = (node) => ({
GU: amount(node, "재료비단가"),
NU: amount(node, "노무비단가"),
JU: amount(node, "경비단가"),
SU: 0,
TU: amount(node, "재료비단가") + amount(node, "노무비단가") + amount(node, "경비단가"),
});
const linked = (node) => ({
Code: code(value(node, "자원코드")),
LinkIndex: Number(
Object.entries(kinds).find(([, v]) => v === value(node, "자원구분"))?.[0] || 0,
),
GN: amount(node, "수량"),
CalcIndex: ["true", "1"].includes(value(node, "계산제외")) ? 2 : 0,
...fields(node),
...money(node),
...unit(node),
});
let pk = 0;
const contracts = rows(doc.documentElement, "단일공사내역서");
if (contracts.length !== 1) throw Error("복합공사 XML은 단일공사별 파일로 나눈 뒤 불러오세요.");
const contract = contracts[0];
const contractGroups = children(contract, "내역서"),
maxDepth = Math.max(
1,
...contractGroups.map((group) => value(group, "내역서번호").length / 2),
);
for (const group of contractGroups) {
result.tables.EstMainDat.push({
PkID: ++pk,
Level: Math.max(1, maxDepth - value(group, "내역서번호").length / 2 + 1),
Code: value(group, "내역서번호"),
Name: value(group, "공종명"),
...money(group),
});
for (const row of children(group, "세부공종")) {
const next = {
PkID: ++pk,
Code: value(row, "세부공종코드"),
Name: value(row, "세부공종명"),
Size: value(row, "규격"),
Unite: value(row, "단위"),
GN: amount(row, "수량"),
...money(row),
...unit(row),
CalcIndex: ["true", "1"].includes(value(row, "계산제외")) ? 2 : 0,
LinkIndex: Number(
Object.entries(kinds).find(([, v]) => v === value(row, "자원구분"))?.[0] || 0,
),
};
result.tables.EstMainDat.push(next);
}
}
const direct = result.tables.EstMainDat.filter((r) => !r.Level && r.CalcIndex < 2);
result.tables.EstMainDat.push({
PkID: ++pk,
Name: "순공사비",
LinkIndex: 54,
...Object.fromEntries(
[...EstMath.components, "TC"].map((k) => [k, direct.reduce((s, r) => s + n(r[k]), 0)]),
),
});
for (const [element, table, key] of [
["자재", "StuffcDat", "자재코드"],
["노무", "LaborcDat", "노무코드"],
["경비항목", "EstiMateDat", "경비항목코드"],
["실적단가", "SilJukDat", "실적공사비코드"],
])
children(contract, element).forEach((row, i) => {
const amounts =
element === "자재"
? { GC: amount(row, "재료비단가") }
: element === "노무"
? { NC: amount(row, "노무비단가") }
: {
GC: amount(row, "재료비단가"),
NC: amount(row, "노무비단가"),
JC: amount(row, "경비단가"),
};
const tc = Object.values(amounts).reduce((a, b) => a + b, 0);
result.tables[table].push({
PkID: i + 1,
Code: code(value(row, key)),
...fields(row),
...amounts,
TC: tc,
InputTC: tc,
ValueIndex: 4,
});
});
for (const [element, main, title, key] of [
["일위대가", "HeadMainDat", "HeadTitleDat", "일위대가코드"],
["기계경비", "MachMainDat", "MachTitleDat", "기계경비코드"],
])
children(contract, element).forEach((row, i) => {
const header = { Code: code(value(row, key)), Index: i + 1, ...fields(row), ...money(row) };
result.tables[title].push({ ...header });
result.tables[main].push({ PkID: result.tables[main].length + 1, ...header });
children(row, element + "상세").forEach((child) =>
result.tables[main].push({
PkID: result.tables[main].length + 1,
Index: 0,
TitleCode: header.Code,
...linked(child),
}),
);
});
children(contract, "중기단가산출").forEach((row, i) => {
const header = {
Code: code(value(row, "중기단가산출코드")),
Index: i + 1,
...fields(row),
...money(row),
};
result.tables.CostTitleDat.push({ ...header });
result.tables.CostMainDat.push({ PkID: result.tables.CostMainDat.length + 1, ...header });
result.tables.CostMainDat.push({
PkID: result.tables.CostMainDat.length + 1,
Index: 0,
TitleCode: header.Code,
LineData: value(row, "중기단가산출식"),
...money(row),
CalcIndex: 1,
});
});
const fees = children(doc.documentElement, "공사내역서")[0];
for (const row of children(children(fees, "공사제경비")[0] || fees, "제경비항목"))
result.tables.Wonga.push({
Gubun: value(row, "세부공종코드"),
Name: value(row, "비목명"),
Youl: value(row, "적용요율"),
VSusic: value(row, "적용산식"),
Cost: amount(row, "산출금액"),
});
result.originalC3r = source;
result.importWarnings = [
"XML의 저장 금액을 보존하기 위해 자동계산을 껐습니다. 재계산 전 원본 상세·산식을 확인하세요.",
"XML 원문은 프로젝트에 보존됩니다. 제출기관 인증/등록 검사는 별도입니다.",
];
result.calculation = {
errors: [],
warnings: result.importWarnings,
direct: result.tables.EstMainDat.at(-1),
total: result.tables.Wonga.at(-1)?.Cost ?? result.tables.EstMainDat.at(-1).TC,
};
return EstModel.validate(result);
}
return { output, read };
})();
if (typeof module !== "undefined" && module.exports) module.exports = EstC3r;
+26 -7
View File
@@ -117,11 +117,28 @@ const EstNative = (() => {
errors = [],
warnings = [];
for (const key of allTables) tables[key] ||= [];
function saveTitles(table, calculated) {
const byCode = new Map(calculated.map((row) => [row.Code, row]));
const existing = new Set(tables[table].map((row) => row.Code));
tables[table] = tables[table]
.map((row) => {
const result = byCode.get(row.Code);
if (!result) return row;
const merged = { ...row, ...result };
// Main-row identities belong to a different native table/index.
for (const key of ["PkID", "Index", "RecNo", "Flag"]) {
if (Object.hasOwn(row, key)) merged[key] = row[key];
else delete merged[key];
}
return merged;
})
.concat(calculated.filter((row) => !existing.has(row.Code)));
}
for (const kind of [5, 6, 21, 101])
for (const row of tables[sourceTables[kind]]) price(row, kind);
const machine = EstMachine.calculate(tables.MachMainDat, tables.MachGijunDat, options);
tables.MachMainDat = machine.rows;
tables.MachTitleDat = machine.titles;
saveTitles("MachTitleDat", machine.titles);
errors.push(...machine.errors);
const costs = groups(tables.CostMainDat),
heads = groups(tables.HeadMainDat),
@@ -219,9 +236,10 @@ const EstNative = (() => {
errors.push(error.message);
}
}
tables.CostTitleDat = [...costs.keys()]
.map((code) => completed.get(`104:${code}`))
.filter(Boolean);
saveTitles(
"CostTitleDat",
[...costs.keys()].map((code) => completed.get(`104:${code}`)).filter(Boolean),
);
for (const [code] of heads) {
try {
calculateGroup(103, code);
@@ -229,9 +247,10 @@ const EstNative = (() => {
errors.push(error.message);
}
}
tables.HeadTitleDat = [...heads.keys()]
.map((code) => completed.get(`103:${code}`))
.filter(Boolean);
saveTitles(
"HeadTitleDat",
[...heads.keys()].map((code) => completed.get(`103:${code}`)).filter(Boolean),
);
const estimate = EstRows.estimate(tables.EstMainDat, options, resolve);
tables.EstMainDat = estimate.rows;
errors.push(...estimate.errors);
+279
View File
@@ -0,0 +1,279 @@
const EstExchange = (() => {
function xmlText(buffer) {
const bytes = new Uint8Array(buffer);
if (bytes[0] === 0xff && bytes[1] === 0xfe)
return new TextDecoder("utf-16le", { fatal: true }).decode(bytes);
if (bytes[0] === 0xfe && bytes[1] === 0xff)
return new TextDecoder("utf-16be", { fatal: true }).decode(bytes);
const header = new TextDecoder("ascii").decode(bytes.slice(0, 250)),
declared = header.match(/encoding\s*=\s*["']([^"']+)["']/i)?.[1]?.toLowerCase() || "utf-8",
encoding = ["cp949", "ks_c_5601-1987", "euc-kr", "ms949"].includes(declared)
? "euc-kr"
: declared;
if (!["utf-8", "utf8", "euc-kr", "utf-16", "utf-16le", "utf-16be"].includes(encoding))
throw Error("지원하지 않는 XML 인코딩: " + encoding);
return new TextDecoder(encoding, { fatal: true }).decode(bytes);
}
function parse(source) {
if (/<!DOCTYPE|<!ENTITY/i.test(source)) throw Error("XML 외부 선언은 허용하지 않습니다.");
const doc = new DOMParser().parseFromString(source, "application/xml");
if (doc.querySelector("parsererror")) throw Error("XML 구문 오류");
return doc;
}
function read(files) {
const workbook = parse(files["xl/workbook.xml"]),
rels = parse(files["xl/_rels/workbook.xml.rels"]);
const shared = files["xl/sharedStrings.xml"]
? [...parse(files["xl/sharedStrings.xml"]).getElementsByTagNameNS("*", "si")].map((si) =>
[...si.getElementsByTagNameNS("*", "t")].map((t) => t.textContent).join(""),
)
: [];
const tables = {};
for (const sheet of workbook.getElementsByTagNameNS("*", "sheet")) {
const name = sheet.getAttribute("name");
if (!EstNative.empty().tables[name] || name.startsWith("Cad")) continue;
const id = sheet.getAttributeNS(
"http://schemas.openxmlformats.org/officeDocument/2006/relationships",
"id",
),
rel = [...rels.getElementsByTagNameNS("*", "Relationship")].find(
(r) => r.getAttribute("Id") === id,
),
target = rel?.getAttribute("Target") || "";
const path = target.startsWith("/") ? target.slice(1) : "xl/" + target;
if (!files[path]) throw Error(`Excel 시트 파일 없음: ${name}`);
const rows = [...parse(files[path]).getElementsByTagNameNS("*", "row")]
.map((row) => {
const values = [];
for (const cell of row.getElementsByTagNameNS("*", "c")) {
const address = cell.getAttribute("r") || "",
letters = address.match(/^[A-Z]+/)?.[0];
if (!letters) throw Error("Excel 셀 주소 오류");
const at = [...letters].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) - 1;
if (at >= 16384) throw Error("Excel 열 범위 오류");
if (cell.getElementsByTagNameNS("*", "f").length)
throw Error(`${name} ${address}: 수식은 Excel에서 값으로 붙여넣은 뒤 가져오세요.`);
const type = cell.getAttribute("t"),
v = cell.getElementsByTagNameNS("*", "v")[0]?.textContent;
let value;
if (type === "s") {
value = shared[Number(v)];
if (value == null) throw Error("Excel 공유 문자열 오류");
} else if (type === "inlineStr")
value = [...cell.getElementsByTagNameNS("*", "t")].map((t) => t.textContent).join("");
else if (type === "b") value = v === "1";
else if (type === "e") throw Error(`${name} ${address}: Excel 오류값`);
else if (type === "str") value = v || "";
else if (v != null) {
value = Number(v);
if (!Number.isFinite(value)) throw Error("Excel 숫자 오류");
}
values[at] = value;
}
return values;
})
.filter((row) => row.some((v) => v != null));
const headers = rows.shift() || [];
if (
!headers.length ||
headers.some(
(k) =>
typeof k !== "string" ||
!/^[A-Za-z][A-Za-z0-9_]*$/.test(k) ||
["constructor", "prototype"].includes(k),
) ||
new Set(headers).size !== headers.length
)
throw Error(`${name}: 필드명 행이 올바르지 않습니다.`);
tables[name] = rows.map((values) =>
Object.fromEntries(headers.map((k, i) => [k, values[i] ?? null])),
);
}
if (!Object.keys(tables).length)
throw Error("가져올 EST 표 이름이 없습니다. 이 앱에서 내보낸 Excel 표를 사용하세요.");
return tables;
}
function apply(project, tables) {
const next = structuredClone(project);
for (const [table, rows] of Object.entries(tables)) {
const old = next.tables[table],
byId = new Map(old.filter((r) => r.PkID != null).map((r) => [r.PkID, r]));
const keys = rows[0] ? Object.keys(rows[0]) : [];
if (
!keys.includes("PkID") &&
old.some((r) => Object.values(r).some((v) => v && typeof v === "object"))
)
throw Error(`${table}: 바이너리를 보존하려면 PkID 열이 필요합니다.`);
const seen = new Set();
next.tables[table] = rows.map((row) => {
if (row.PkID != null) {
if (seen.has(row.PkID)) throw Error(`${table}: PkID 중복`);
seen.add(row.PkID);
}
const previous = byId.get(row.PkID);
// Keep original blobs and fields deliberately omitted from the workbook.
const merged = { ...previous, ...row };
if (["StuffcDat", "LaborcDat", "SilJukDat", "EstiMateDat"].includes(table)) {
if (
previous &&
row.TC !== previous.TC &&
(row.InputTC ?? previous.InputTC) === previous.InputTC
) {
merged.InputTC = row.TC;
merged.ValueIndex = 4;
}
for (const component of ["NC", "GC", "JC", "SC"])
if (
previous &&
row[component] !== previous[component] &&
(row["Input" + component] ?? previous["Input" + component]) ===
previous["Input" + component]
)
merged["Input" + component] = row[component];
if (
!previous ||
Object.keys(row).some((key) => (row[key] ?? null) !== (previous[key] ?? null))
)
next.options.SourceRefresh = true;
}
for (const key of keys)
if (previous?.[key] && typeof previous[key] === "object") merged[key] = previous[key];
if (Object.hasOwn(row, "Code") && (previous?.Code ?? "") !== (row.Code ?? "") && previous)
throw Error("Excel에서 기존 코드를 바꿀 수 없습니다. 앱의 코드 편집을 사용하세요.");
return merged;
});
}
return EstModel.validate(next);
}
async function exportExcel() {
document.activeElement?.blur();
const response = await fetch("/api/export-xlsx", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(EstApp.state.project),
});
if (!response.ok) throw Error((await response.json()).error);
EstApp.download(
`${EstApp.state.project.name}.xlsx`,
await response.blob(),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
}
async function exportNative() {
document.activeElement?.blur();
const response = await fetch("/api/export-estx", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(EstApp.state.project),
});
if (!response.ok) throw Error((await response.json()).error);
EstApp.download(
`${EstApp.state.project.name}_edited.estx`,
await response.blob(),
"application/octet-stream",
);
}
async function exportJdl() {
document.activeElement?.blur();
const project = EstApp.state.project,
out = await EstApp.api("/api/export-jdl", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(project),
});
if (!confirm(out.warnings.slice(0, 15).join("\n") + "\nJDL 기본 표로 내보낼까요?")) return;
EstApp.download(
`${project.name}.jdl`,
Uint8Array.from(atob(out.data), (c) => c.charCodeAt(0)),
"application/octet-stream",
);
}
function install() {
const jdlInput = document.createElement("input");
jdlInput.type = "file";
jdlInput.accept = ".jdl";
jdlInput.hidden = true;
jdlInput.id = "jdl-file";
document.body.append(jdlInput);
jdlInput.addEventListener("change", async () => {
const file = jdlInput.files[0],
key = EstApp.state.documentKey,
revision = EstApp.state.revision;
if (!file) return;
try {
const next = await EstApp.api("/api/import-jdl", {
method: "POST",
body: await file.arrayBuffer(),
});
if (key !== EstApp.state.documentKey || revision !== EstApp.state.revision)
throw Error("작업이 변경되었습니다. 다시 불러오세요.");
if (confirm("현재 작업 대신 JDL 내역서를 여시겠습니까?")) EstApp.setProject(next);
} catch (error) {
EstApp.state.notice = error.message;
EstApp.render();
} finally {
jdlInput.value = "";
}
});
const xmlInput = document.createElement("input");
xmlInput.type = "file";
xmlInput.accept = ".xml";
xmlInput.hidden = true;
xmlInput.id = "xml-file";
document.body.append(xmlInput);
xmlInput.addEventListener("change", async () => {
const file = xmlInput.files[0],
key = EstApp.state.documentKey,
revision = EstApp.state.revision;
if (!file) return;
try {
const next = EstC3r.read(xmlText(await file.arrayBuffer()));
if (key !== EstApp.state.documentKey || revision !== EstApp.state.revision)
throw Error("작업이 변경되었습니다. 다시 불러오세요.");
if (confirm("현재 작업 대신 XML 내역서를 여시겠습니까?")) EstApp.setProject(next);
} catch (error) {
EstApp.state.notice = error.message;
EstApp.render();
} finally {
xmlInput.value = "";
}
});
const input = document.createElement("input");
input.type = "file";
input.accept = ".xlsx";
input.hidden = true;
input.id = "excel-file";
document.body.append(input);
input.addEventListener("change", async () => {
const file = input.files[0];
if (!file) return;
const key = EstApp.state.documentKey,
revision = EstApp.state.revision;
try {
const files = await EstApp.api("/api/import-xlsx", {
method: "POST",
body: await file.arrayBuffer(),
}),
tables = read(files);
if (key !== EstApp.state.documentKey || revision !== EstApp.state.revision)
throw Error("작업이 변경되었습니다. Excel을 다시 불러오세요.");
const next = apply(EstApp.state.project, tables);
if (
!confirm(
`${Object.entries(tables)
.map(([name, rows]) => `${EstApp.titles[name] || name}: ${rows.length}행`)
.join("\n")}\n현재 작업의 해당 표를 교체합니다.`,
)
)
return;
EstApp.change(() => (EstApp.state.project = next));
} catch (error) {
EstApp.state.notice = error.message;
EstApp.render();
} finally {
input.value = "";
}
});
}
return { read, apply, exportExcel, exportNative, exportJdl, install };
})();
+4
View File
@@ -12,6 +12,10 @@ const EstExtra = (() => {
return `<div class="business-tools"><b>${title}</b>${tools}</div>${body}${note ? `<p class="business-note">${note}</p>` : ""}`;
}
function render(state) {
if (state.view === "schedule") {
EstSchedule.render();
return;
}
const { project, view } = state,
{ esc, number, byId } = EstApp;
let html = "";
+139
View File
@@ -0,0 +1,139 @@
const EstForms = (() => {
const common = ["Code", "Name", "Size", "Unite"];
function data(id, project) {
if (id === 109)
return {
columns: ["Code", "Name", "start", "end", "weight", "progress"],
rows: EstSchedule.calculate(project).rows,
};
const table = {
1: "EstMainDat",
117: "EstMainDat",
2: "HeadMainDat",
115: "HeadMainDat",
3: "CostMainDat",
4: "MachMainDat",
119: "MachGijunDat",
101: "EstiMateDat",
21: "SilJukDat",
5: "StuffcDat",
6: "LaborcDat",
7: "Wonga",
22: "Wonga",
}[id];
if (table) {
const columns =
table === "Wonga"
? ["Gubun", "Name", "Youl", "VSusic", "Cost"]
: table === "CostMainDat"
? ["Code", "LineData"]
: table === "HeadMainDat"
? [...common, "GN", "NU", "GU", "JU", "SU", "TC"]
: [...common, "GN", "TU", "TC", "NC", "GC", "JC", "SC"];
return { table, columns, rows: project.tables[table] || [] };
}
const totals = {
106: "EstMainDat",
118: "EstMainDat",
103: "HeadMainDat",
116: "HeadMainDat",
104: "CostMainDat",
105: "MachMainDat",
}[id];
if (totals) {
const rows =
totals === "EstMainDat"
? (project.tables[totals] || []).filter((r) => Number(r.Level) > 0)
: [...EstNative.groups(project.tables[totals] || []).values()].map((group) => {
const row = { ...group.rows[0] };
const titleTable = {
HeadMainDat: "HeadTitleDat",
CostMainDat: "CostTitleDat",
MachMainDat: "MachTitleDat",
}[totals];
const title = project.tables[titleTable]?.find((item) => item.Code === row.Code);
if (title) return { ...row, ...title };
for (const key of [...EstMath.components, "TC"])
row[key] = group.rows
.slice(1)
.filter((r) => Number(r.CalcIndex || 0) < 2)
.reduce((n, r) => n + EstMath.num(r[key]), 0);
return row;
});
return { table: totals, columns: [...common, "NC", "GC", "JC", "SC", "TC"], rows };
}
if ([107, 108].includes(id))
return {
columns: ["Code", "Name", "originalGN", "GN", "originalTC", "TC", "difference", "status"],
rows: EstBusiness.changes(project),
};
const kinds = {
13: 6,
110: 5,
111: 105,
161: 21,
162: 101,
112: 6,
113: 5,
114: 105,
163: 21,
164: 101,
};
if (kinds[id]) {
const output = EstResources.calculate(project, [112, 113, 114, 163, 164].includes(id));
return {
columns: [
...([112, 113, 114, 163, 164].includes(id) ? ["WorkCode", "WorkName"] : []),
"Code",
"Name",
"Size",
"Unite",
"quantity",
"amount",
],
errors: output.errors,
rows: output.rows.filter((r) => r.kind === kinds[id]),
};
}
if (id === 14)
return {
columns: ["Code", "Name", "formula", "GN", "TargetCode"],
rows: (project.quantitySheets || []).flatMap((sheet) =>
EstQuantity.calculate(sheet).items.map((item) => ({
...item,
Code: sheet.Code,
Name: item.Name || sheet.Name,
})),
),
};
return null;
}
const labels = {
originalGN: "당초 수량",
originalTC: "당초 금액",
difference: "증감",
status: "상태",
quantity: "소요량/시간",
amount: "금액",
start: "시작일",
end: "종료일",
weight: "가중치",
progress: "진도(%)",
WorkCode: "공종 코드",
WorkName: "공종명",
formula: "산식",
TargetCode: "연결 내역",
TU: "단가",
TC: "금액",
NC: "노무비",
GC: "재료비",
JC: "경비",
SC: "산출경비",
};
function grid(output) {
const { esc } = EstApp,
number = (v) => new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 12 }).format(v);
return `<table><thead><tr>${output.columns.map((key) => `<th>${esc(labels[key] || EstModel.labels[key] || key)}</th>`).join("")}</tr></thead><tbody>${output.rows.map((row) => `<tr>${output.columns.map((key) => `<td class="${typeof row[key] === "number" ? "numeric" : ""}">${esc(typeof row[key] === "number" ? number(row[key]) : key === "LineData" ? EstText.clean(row[key] || "") : (row[key] ?? ""))}</td>`).join("")}</tr>`).join("")}</tbody></table>`;
}
return { data, grid };
})();
+35
View File
@@ -298,6 +298,7 @@ const EstModel = (() => {
}
}
project.options ||= {};
EstSchedule.validate(project);
if (typeof project.options !== "object" || Array.isArray(project.options))
throw Error("계산 옵션 형식 오류");
const validNumber = (value, label) => {
@@ -312,6 +313,40 @@ const EstModel = (() => {
validNumber(project.options.EscDesignTotal, "조사 도급액");
if (project.options.EscDesignTotal < 0) throw Error("조사 도급액은 0 이상입니다.");
}
if (project.printSettings != null) {
const s = project.printSettings;
if (typeof s !== "object" || Array.isArray(s)) throw Error("인쇄 설정 형식 오류");
for (const [key, min, max] of [
["margin", 0, 50],
["font", 6, 20],
["rowsPerPage", 1, 100],
["startPage", 1, 99999],
])
if (s[key] != null) {
validNumber(s[key], key);
if (
s[key] < min ||
s[key] > max ||
(["rowsPerPage", "startPage"].includes(key) && !Number.isInteger(s[key]))
)
throw Error("인쇄 설정 범위 오류");
}
if (
(s.paper != null && !["A3", "A4"].includes(s.paper)) ||
(s.orientation != null && !["landscape", "portrait"].includes(s.orientation))
)
throw Error("인쇄 용지/방향 오류");
for (const key of ["form", "company", "notes"])
if (s[key] != null && (typeof s[key] !== "string" || s[key].length > 100000))
throw Error("인쇄 문구 형식 오류");
}
if (project.documents != null) {
if (typeof project.documents !== "object" || Array.isArray(project.documents))
throw Error("문서 본문 형식 오류");
for (const [key, value] of Object.entries(project.documents))
if (!/^(current|\d+)$/.test(key) || typeof value !== "string" || value.length > 100000)
throw Error("문서 본문 형식 오류");
}
for (const key of ["quantities", "progress", "annual", "drawings"]) {
if (project[key] === undefined) project[key] = [];
if (!Array.isArray(project[key])) throw Error(`${key} 형식 오류`);
+120 -8
View File
@@ -1,10 +1,34 @@
const EstReport = (() => {
let catalog = [];
function setting() {
const project = EstApp.state.project,
settings = project.printSettings || {};
return {
form: "current",
paper: "A4",
orientation: "landscape",
margin: 12,
font: 9,
company: "",
startPage: 1,
rowsPerPage: 30,
notes: "",
...settings,
};
}
function preview(all = false) {
const { state, esc } = EstApp;
const settings = setting(),
form = catalog.find((row) => String(row.Index) === settings.form),
output = form ? EstForms.data(form.Index, state.project) : null;
const number = (value) =>
new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 12 }).format(Number(value) || 0);
const root = document.createElement("div");
if (state.project.tables[state.view]) {
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>` : ""}`;
} else if (state.project.tables[state.view]) {
const rows = all
? state.project.tables[state.view].map((row, index) => ({ row, index }))
: EstApp.visibleRows();
@@ -50,7 +74,7 @@ const EstReport = (() => {
root
.querySelectorAll("[contenteditable]")
.forEach((node) => node.removeAttribute("contenteditable"));
const title = EstApp.titles[state.view] || state.view;
const title = form?.Name2 || EstApp.titles[state.view] || state.view;
const detail =
state.view === "quantity"
? state.project.quantitySheets?.find((sheet) => sheet.Code === state.quantitySheet)?.Name
@@ -61,30 +85,118 @@ const EstReport = (() => {
?.Name
: "";
const totals = state.project.calculation;
const errors = totals?.errors || [];
const errors = [...(totals?.errors || []), ...(output?.errors || [])];
const source = `<!doctype html><html lang="ko"><head><meta charset="UTF-8"><title>${esc(state.project.name)} · ${esc(title)}</title>
<style>@page{size:A3 landscape;margin:12mm}*{box-sizing:border-box}body{font:9pt "Malgun Gothic",sans-serif;color:#111;margin:0}
<style>@page{size:${settings.paper} ${settings.orientation};margin:${settings.margin}mm}*{box-sizing:border-box}body{font:${settings.font}pt "Malgun Gothic",sans-serif;color:#111;margin:0}
h1{font-size:17pt;margin:0 0 4mm}h2{font-size:12pt;margin:0 0 3mm}.summary{margin:2mm 0 4mm}
table{width:100%;border-collapse:collapse;font-size:8pt}thead{display:table-header-group}tr{break-inside:avoid}
table{width:100%;border-collapse:collapse;font-size:inherit}thead{display:table-header-group}tr{break-inside:avoid}
th,td{border:1px solid #777;padding:1.5mm;vertical-align:top;overflow-wrap:anywhere}th{background:#eee}
.heading,.level{font-weight:bold;background:#f4f4f4}.selected{background:transparent}
.numeric{text-align:right;font-variant-numeric:tabular-nums}
.col-LineData{font-family:"굴림체",Consolas,monospace;white-space:pre-wrap;min-width:130mm}
.error-row{outline:1px solid #a00}.errors{color:#900;white-space:pre-wrap}.muted{color:#555}
.document-text{white-space:pre-wrap;line-height:1.7;min-height:40mm}.page{break-after:page}.page:last-child{break-after:auto}.footer{text-align:right;margin-top:3mm}
</style></head><body><h1>${esc(state.project.name)}</h1><h2>${esc(title)}</h2>
${detail ? `<h2>${esc(detail)}</h2>` : ""}${totals ? `<div class="summary">순공사비 ${number(totals.direct?.TC)}원 · 도급액 ${number(totals.total)}원</div>` : ""}
${root.innerHTML}${errors.length ? `<p class="errors">계산 오류 ${errors.length}건: ${esc(errors.join(" / "))}</p>` : ""}</body></html>`;
${paginate(root, settings)}${errors.length ? `<p class="errors">계산 오류 ${errors.length}건: ${esc(errors.join(" / "))}</p>` : ""}</body></html>`;
EstApp.byId("print-frame").srcdoc = source;
}
function open() {
function paginate(root, settings) {
const table = root.querySelector("table");
if (!table || root.querySelectorAll("table").length !== 1) return root.innerHTML;
const rows = [...table.querySelectorAll("tbody>tr")],
pages = [];
for (let at = 0; at < rows.length; at += settings.rowsPerPage) {
const copy = table.cloneNode(false);
copy.innerHTML = table.querySelector("thead")?.outerHTML || "";
const body = document.createElement("tbody");
rows.slice(at, at + settings.rowsPerPage).forEach((row) => body.append(row.cloneNode(true)));
copy.append(body);
pages.push(
`<section class="page">${copy.outerHTML}<div class="footer">${EstApp.esc(settings.company)} ${settings.startPage + pages.length}</div></section>`,
);
}
return pages.join("");
}
async function open() {
catalog = await EstApp.api("/api/print-catalog");
const table = EstApp.state.project.tables[EstApp.state.view];
const s = setting(),
{ esc } = EstApp;
EstApp.showDialog(
"인쇄 미리보기",
`<div class="business-tools">
<label>서식 <select id="print-form"><option value="current">현재 화면</option>${catalog
.filter((row) => row.Index !== 151)
.map(
(row) =>
`<option value="${row.Index}" ${String(row.Index) === s.form ? "selected" : ""}>${esc(row.Name)}</option>`,
)
.join("")}</select></label>
<label>용지 <select id="print-paper">${["A4", "A3"].map((v) => `<option ${v === s.paper ? "selected" : ""}>${v}</option>`).join("")}</select></label>
<label>방향 <select id="print-orientation"><option value="landscape" ${s.orientation === "landscape" ? "selected" : ""}>가로</option><option value="portrait" ${s.orientation === "portrait" ? "selected" : ""}>세로</option></select></label>
${[
["margin", "여백(mm)", 0, 50],
["font", "글자(pt)", 6, 20],
["rowsPerPage", "페이지당 행", 1, 100],
["startPage", "시작 쪽", 1, 99999],
]
.map(
([key, label, min, max]) =>
`<label>${label}<input data-print="${key}" type="number" min="${min}" max="${max}" value="${s[key]}" style="width:65px"></label>`,
)
.join("")}
<label>업체명<input data-print="company" value="${esc(s.company)}"></label>
${table ? '<label>출력 범위 <select id="print-scope"><option value="current">현재 호표·검색 결과 전체 행</option><option value="all">현재 자료의 모든 호표·행</option></select></label>' : ""}
<button data-action="print-document">인쇄 / PDF 저장</button></div>
<button data-action="print-document">인쇄 / PDF 저장</button><span id="print-error" role="status"></span></div>
<label>문서 본문(표지·갑지·설명서·시방서·심사·공정 문서)<textarea data-print="notes" rows="3" style="width:100%">${esc(s.notes)}</textarea></label>
<iframe id="print-frame" title="인쇄할 문서" style="width:100%;height:65vh;border:1px solid #aaa"></iframe>`,
);
EstApp.byId("dialog-content")
.querySelectorAll("[data-print],#print-paper,#print-orientation,#print-form")
.forEach((node) =>
node.addEventListener("change", () => {
const key = node.dataset.print || node.id.slice(6),
value = node.type === "number" ? Number(node.value) : node.value;
try {
if (
node.type === "number" &&
(!Number.isFinite(value) ||
value < Number(node.min) ||
value > Number(node.max) ||
(["rowsPerPage", "startPage"].includes(key) && !Number.isInteger(value)))
)
throw Error("인쇄 설정 범위 오류");
EstApp.change(() => {
const next = { ...setting(), [key]: value };
if (key === "form") {
const original = catalog.find((row) => String(row.Index) === value);
if (original) {
next.rowsPerPage = Math.max(
1,
Math.min(100, Number(original.PrintLineCount) || 30),
);
next.margin = Math.min(50, Number(original.PrintLeft) || 12);
}
}
if (key === "notes") {
EstApp.state.project.documents ||= {};
EstApp.state.project.documents[next.form] = value;
}
if (key === "form") next.notes = EstApp.state.project.documents?.[value] || "";
EstApp.state.project.printSettings = next;
}, false);
if (key === "form")
EstApp.byId("dialog-content").querySelector("[data-print=notes]").value =
EstApp.state.project.printSettings.notes;
preview(EstApp.byId("print-scope")?.value === "all");
EstApp.byId("print-error").textContent = "";
} catch (error) {
EstApp.byId("print-error").textContent = error.message;
node.value = setting()[key];
}
}),
);
EstApp.byId("print-scope")?.addEventListener("change", (event) =>
preview(event.target.value === "all"),
);
+12 -4
View File
@@ -1,6 +1,6 @@
const EstResources = (() => {
const { num, components } = EstMath;
function calculate(input) {
function calculate(input, byWork = false) {
const project = EstNative.calculate({
...structuredClone(input),
options: { ...input.options, ResourceTracing: true },
@@ -10,14 +10,17 @@ const EstResources = (() => {
costs = EstNative.groups(tables.CostMainDat),
output = new Map(),
errors = [...project.calculation.errors];
let work = {},
workKey = "";
function record(kind, code, quantity, amount = null) {
const source = (tables[EstNative.sourceTables[kind]] || []).find((row) => row.Code === code);
if (!source) {
errors.push(`동원 원천 없음: ${kind}/${code}`);
return;
}
const id = `${kind}:${code}`;
const id = `${workKey}${kind}:${code}`;
const row = output.get(id) || {
...work,
Code: code,
Name: source.Name,
Size: source.Size,
@@ -63,8 +66,9 @@ const EstResources = (() => {
const amount = num(row[key]) * multiplier * rates[key];
if (!amount) continue;
const [kind, Code, Name] = labels[key],
id = `${kind}:${Code}`;
id = `${workKey}${kind}:${Code}`;
const item = output.get(id) || {
...work,
Code,
Name,
Size: "금액 입력분",
@@ -176,7 +180,7 @@ const EstResources = (() => {
const source = tables[EstNative.sourceTables[kind]]?.find((row) => row.Code === code);
record(kind, code, multiplier * weightedRate(source, rates));
}
for (const row of tables.EstMainDat) {
for (const [index, row] of tables.EstMainDat.entries()) {
if (num(row.LinkIndex) === 54) break;
if (
num(row.Level) > 0 ||
@@ -184,6 +188,10 @@ const EstResources = (() => {
[51, 52, 68].includes(num(row.LinkIndex))
)
continue;
if (byWork) {
workKey = `${index}:`;
work = { WorkCode: row.Code || "", WorkName: row.Name || "" };
}
if (!EstNative.sourceTables[num(row.LinkIndex)]) {
money(row, 1, { NC: 1, GC: 1, JC: 1, SC: 1 });
continue;
+103
View File
@@ -0,0 +1,103 @@
const EstSchedule = (() => {
function day(value) {
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value))
throw Error("공정 날짜는 YYYY-MM-DD 형식입니다.");
const time = Date.parse(value + "T00:00:00Z");
if (!Number.isFinite(time) || new Date(time).toISOString().slice(0, 10) !== value)
throw Error("공정 날짜 오류");
return time / 86400000;
}
function validate(project) {
if (project.schedule == null) return;
if (!Array.isArray(project.schedule) || project.schedule.length > 10000)
throw Error("공정표 형식/행 수 오류");
const ids = new Set();
for (const row of project.schedule) {
if (!row || typeof row.id !== "string" || !row.id || ids.has(row.id))
throw Error("공정 항목 ID 오류");
ids.add(row.id);
for (const key of ["Code", "Name", "start", "end"])
if (typeof row[key] !== "string" || row[key].length > 1000) throw Error("공정 문자열 오류");
for (const key of ["weight", "progress"])
if (
typeof row[key] !== "number" ||
!Number.isFinite(row[key]) ||
row[key] < 0 ||
row[key] > Number.MAX_SAFE_INTEGER
)
throw Error("공정 수치 오류");
if (row.progress > 100) throw Error("공정 진도는 0~100%입니다.");
if (row.start) day(row.start);
if (row.end) day(row.end);
if (row.start && row.end && day(row.start) > day(row.end))
throw Error("공정 종료일이 시작일보다 빠릅니다.");
}
}
function calculate(project) {
validate(project);
const rows = project.schedule || [],
dated = rows.filter((r) => r.start && r.end),
first = dated.length ? Math.min(...dated.map((r) => day(r.start))) : 0,
last = dated.length ? Math.max(...dated.map((r) => day(r.end))) : first,
span = last - first + 1,
weight = rows.reduce((sum, r) => sum + r.weight, 0);
return {
rows: rows.map((r) => ({
...r,
left: r.start && r.end ? ((day(r.start) - first) / span) * 100 : 0,
width: r.start && r.end ? ((day(r.end) - day(r.start) + 1) / span) * 100 : 0,
})),
weight,
progress: weight ? rows.reduce((sum, r) => sum + r.weight * r.progress, 0) / weight : 0,
start: dated.length ? new Date(first * 86400000).toISOString().slice(0, 10) : "",
end: dated.length ? new Date(last * 86400000).toISOString().slice(0, 10) : "",
};
}
function render() {
const { esc, number, state } = EstApp,
output = calculate(state.project);
EstApp.byId("work-content").innerHTML =
`<div class="business-tools"><button data-action="schedule-add">공정 추가</button><span>시작 ${esc(output.start)} · 종료 ${esc(output.end)} · 가중치 합계 ${number(output.weight)} · 입력 진도 가중평균 ${number(output.progress)}%</span></div><p>기간과 진도는 직접 입력합니다. 가중치는 공종 금액 또는 사용자가 정한 값을 사용합니다.</p><table class="sheet"><thead><tr>${["코드", "공정명", "시작일", "종료일", "가중치", "진도(%)", "기간", "작업"].map((h) => `<th>${h}</th>`).join("")}</tr></thead><tbody>${output.rows.map((r, i) => `<tr>${["Code", "Name", "start", "end", "weight", "progress"].map((k) => `<td><input data-schedule="${k}" data-index="${i}" type="${["start", "end"].includes(k) ? "date" : ["weight", "progress"].includes(k) ? "number" : "text"}" value="${esc(r[k])}" step="any"></td>`).join("")}<td style="min-width:180px"><div style="height:12px;background:#ddd"><div style="margin-left:${r.left}%;width:${r.width}%;height:12px;background:#316ea5"></div></div></td><td><button data-action="schedule-delete" data-index="${i}">삭제</button></td></tr>`).join("")}</tbody></table>`;
}
function install() {
document.addEventListener("click", (event) => {
const node = event.target.closest("[data-action]");
if (!node) return;
const p = EstApp.state.project;
if (node.dataset.action === "schedule-add")
EstApp.change(() => {
p.schedule ||= [];
const row = p.tables.EstMainDat.find(
(r) => r.Code && !Number(r.Level) && !p.schedule.some((s) => s.Code === r.Code),
);
p.schedule.push({
id: crypto.randomUUID(),
Code: row?.Code || "",
Name: row?.Name || "새 공정",
start: "",
end: "",
weight: Math.max(0, Number(row?.TC) || 0),
progress: 0,
});
}, false);
if (node.dataset.action === "schedule-delete")
EstApp.change(() => p.schedule.splice(Number(node.dataset.index), 1), false);
});
document.addEventListener("change", (event) => {
const node = event.target;
if (!node.dataset.schedule) return;
const p = structuredClone(EstApp.state.project),
row = p.schedule[Number(node.dataset.index)],
key = node.dataset.schedule;
try {
row[key] = ["weight", "progress"].includes(key) ? Number(node.value) : node.value;
validate(p);
EstApp.change(() => (EstApp.state.project = p), false);
} catch (error) {
EstApp.state.notice = error.message;
EstApp.render();
}
});
}
return { validate, calculate, render, install };
})();
File diff suppressed because it is too large Load Diff
+41
View File
@@ -5,6 +5,9 @@ const http = require("node:http"),
vm = require("node:vm"),
crypto = require("node:crypto");
const { importEstx } = require("./import-estx.cjs");
const exchange = require("./exchange.cjs");
const { exportEstx } = require("./export-estx.cjs");
const jdl = require("./jdl.cjs");
const base = __dirname,
port = Number(process.env.EST_TEST_PORT || 5175);
const dataDirectory = process.env.EST_TEST_DATA_DIR || path.join(base, "data");
@@ -21,6 +24,7 @@ const scripts = [
"native-progress.js",
"native-business.js",
"native-resources.js",
"native-schedule.js",
"native-model.js",
"native-verify.js",
];
@@ -51,6 +55,9 @@ const allowed = new Set([
"native-extra.js",
"native-settings.js",
"native-report.js",
"native-forms.js",
"native-c3r.js",
"native-exchange.js",
...scripts,
]);
function reply(res, status, data, type = "application/json; charset=utf-8") {
@@ -103,6 +110,10 @@ const server = http.createServer(async (req, res) => {
reply(res, 200, JSON.parse(fs.readFileSync(path.join(base, "sample.json"), "utf8")));
return;
}
if (req.method === "GET" && url.pathname === "/api/print-catalog") {
reply(res, 200, JSON.parse(fs.readFileSync(path.join(base, "print-catalog.json"), "utf8")));
return;
}
if (req.method === "GET" && url.pathname === "/api/parity") {
engine.sample = JSON.parse(fs.readFileSync(path.join(base, "sample.json"), "utf8"));
engine.expected = JSON.parse(fs.readFileSync(path.join(base, "parity.json"), "utf8"));
@@ -138,6 +149,36 @@ const server = http.createServer(async (req, res) => {
reply(res, 200, importEstx(bytes, name));
return;
}
if (req.method === "POST" && url.pathname === "/api/import-xlsx") {
reply(res, 200, exchange.readWorkbook(await body(req)));
return;
}
if (req.method === "POST" && url.pathname === "/api/import-jdl") {
const empty = vm.runInContext("EstNative.empty()", engine);
reply(res, 200, calculate(jdl.read(await body(req), empty)));
return;
}
if (req.method === "POST" && url.pathname === "/api/export-jdl") {
const project = calculate(JSON.parse((await body(req)).toString("utf8"))),
out = jdl.output(project);
reply(res, 200, { data: out.bytes.toString("base64"), warnings: out.warnings });
return;
}
if (req.method === "POST" && url.pathname === "/api/export-xlsx") {
const project = calculate(JSON.parse((await body(req)).toString("utf8")));
reply(
res,
200,
exchange.workbook(project),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
return;
}
if (req.method === "POST" && url.pathname === "/api/export-estx") {
const project = calculate(JSON.parse((await body(req)).toString("utf8")));
reply(res, 200, exportEstx(project), "application/octet-stream");
return;
}
if (req.method === "POST" && ["/api/calculate", "/api/save"].includes(url.pathname)) {
if (!req.headers["content-type"]?.startsWith("application/json")) {
reply(res, 415, { error: "JSON 요청이 필요합니다." });