feat(est): add document and exchange workflows
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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`는 이전 데모의 회귀 시험용이며 현재 진입 화면에서 사용하지 않습니다.
|
||||
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,106 @@
|
||||
const { pack, unpack } = require("./archive.cjs");
|
||||
const xml = (value) =>
|
||||
String(value ?? "").replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 {
|
||||
|
||||
@@ -32,6 +32,7 @@ const EstApp = (() => {
|
||||
escalation: "물가변동",
|
||||
annual: "연차공사",
|
||||
resources: "동원 집계",
|
||||
schedule: "예정공정표",
|
||||
drawing: "도면·문서",
|
||||
};
|
||||
const esc = (value) =>
|
||||
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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} 형식 오류`);
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 요청이 필요합니다." });
|
||||
|
||||
Reference in New Issue
Block a user