Merge remote-tracking branches 'origin/CODEX' and 'origin/안티그래비티' into sub_laptop_1

This commit is contained in:
2026-11-05 07:07:41 +09:00
36 changed files with 5601 additions and 33 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+63
View File
@@ -0,0 +1,63 @@
const fs = require("fs");
const p = require("../../tools/est-test/import-estx.cjs").importEstx(
fs.readFileSync(process.argv[2]),
);
const q = require("../../tools/est-test/server.cjs").calculate(p);
const out = { errors: q.calculation.errors, counts: {}, differences: [] };
for (const table of [
"HeadMainDat",
"EstMainDat",
"MachMainDat",
"CostMainDat",
"HeadTitleDat",
"MachTitleDat",
"CostTitleDat",
]) {
let checked = 0,
matched = 0,
group = "";
const lookup = new Map((q.tables[table] || []).map((r) => [r.Code, r]));
for (let i = 0; i < p.tables[table].length; i++) {
const a = p.tables[table][i],
b = table.includes("Title") ? lookup.get(a.Code) : q.tables[table][i];
if (a.Index > 0) group = a.Code;
if (table === "CostMainDat") {
const before = [...String(a.LineData || "").matchAll(/\x12([-\d.,]+)\x12/g)],
after = [...String(b?.LineData || "").matchAll(/\x12([-\d.,]+)\x12/g)];
for (let part = 0; part < before.length; part++) {
const x = Number(before[part][1].replaceAll(",", "")),
y = after[part] ? Number(after[part][1].replaceAll(",", "")) : null;
checked++;
if (y !== null && Math.abs(x - y) < 1e-6) matched++;
else if (out.differences.filter((d) => d.table === table).length < 120)
out.differences.push({
table,
index: i,
group,
part,
before: x,
after: y,
line: a.LineData,
});
}
} else
for (const key of ["NC", "GC", "JC", "SC", "TC"]) {
if (a[key] === null || a[key] === undefined) continue;
checked++;
if (b && Math.abs(Number(a[key]) - Number(b[key] || 0)) < 1e-6) matched++;
else if (out.differences.filter((d) => d.table === table).length < 15)
out.differences.push({
table,
index: i,
group,
key,
code: a.Code,
before: a[key],
after: b?.[key],
});
}
}
out.counts[table] = { checked, matched };
}
console.log(JSON.stringify(out));
+105
View File
@@ -0,0 +1,105 @@
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const base = path.resolve(__dirname, "../../tools/est-test");
const context = vm.createContext({ structuredClone, console });
for (const file of [
"native-math.js",
"native-formula.js",
"native-machine.js",
"native-rows.js",
"native-text.js",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
"native-business.js",
"native-resources.js",
"native-model.js",
"native-verify.js",
]) {
vm.runInContext(fs.readFileSync(path.join(base, file), "utf8"), context, { filename: file });
}
const project = JSON.parse(fs.readFileSync(path.join(base, "sample.json"), "utf8"));
context.project = project;
const command = process.argv[2];
let result;
if (command === "machine")
result = vm.runInContext(
"EstMachine.calculate(project.tables.MachMainDat, project.tables.MachGijunDat, project.options)",
context,
);
else if (command === "head")
result = vm.runInContext("EstRows.head(project.tables.HeadMainDat, project.options)", context);
else if (command === "estimate")
result = vm.runInContext("EstRows.estimate(project.tables.EstMainDat, project.options)", context);
else if (command === "text")
result = vm.runInContext(
"EstText.calculate(project.tables.CostMainDat, project.options)",
context,
);
else if (command === "wonga")
result = vm.runInContext("EstWonga.calculate(project.tables.Wonga, project.options)", context);
else if (command === "all") result = vm.runInContext("EstNative.calculate(project)", context);
else if (command === "refresh")
result = vm.runInContext(
"project.options.SourceRefresh=true; EstNative.calculate(project)",
context,
);
else if (command === "repeat")
result = vm.runInContext(
'project.options.SourceRefresh=true; project.tables.LaborcDat.forEach(row=>{for(const key of ["Cost1","Cost2","Cost3","Cost4","Cost5","Cost6"]) row[key] = Number(row[key] || 0)*1.1}); const a=EstNative.calculate(project); [a,EstNative.calculate(a)]',
context,
);
else if (command === "import")
result = require(path.join(base, "import-estx.cjs")).importEstx(fs.readFileSync(process.argv[3]));
else if (command === "extended") {
context.extended = JSON.parse(
fs.readFileSync(path.join(__dirname, "fixtures/est_native_extended.json"), "utf8"),
);
result = vm.runInContext(
`({
head: EstRows.head(extended.tables.HeadMainDat, extended.options),
text: EstText.calculate(extended.tables.CostMainDat, extended.options),
machine: EstMachine.calculate(extended.tables.MachMainDat, extended.tables.MachGijunDat, extended.options)
})`,
context,
);
} else if (command === "quantity") {
context.sheets = JSON.parse(
fs.readFileSync(path.join(__dirname, "fixtures/est_quantity_sheets.json"), "utf8"),
);
result = vm.runInContext(
"sheets.map(sheet=>({Code:sheet.Code,original:sheet.items,...EstQuantity.calculate(sheet)}))",
context,
);
} else if (command === "business")
result = vm.runInContext(
`
const p=EstNative.empty();
p.tables.StuffcDat=[{Code:'M',Name:'material',Cost1:100,ValueIndex:0}];
p.tables.HeadMainDat=[{Index:1,Code:'H',Name:'head'},{Index:0,Code:'M',LinkIndex:5,GN:2}];
p.tables.HeadTitleDat=[{Code:'H',Name:'head'}];
p.tables.EstMainDat=[{PkID:1,Code:'H',Name:'item',GN:10,LinkIndex:103},{PkID:2,Name:'direct',LinkIndex:54}];
p.tables.EstMainDat.push({PkID:4,Name:'부가세',LinkIndex:58},{PkID:5,Name:'total',LinkIndex:55});
p.tables.Wonga=[{Gubun:'G',Name:'material',BaseField:'GC',Cost:0},{Gubun:'V',Name:'부가세',VSusic:'G*0.1',Youl:'10%'},{Gubun:'T',Name:'total',VSusic:'G+V',LinkIndex:55}];
let q=EstNative.calculate(p);q=EstBusiness.baseline(q);
q.progress=[{id:'one',inputs:{'1':{Gum:25,GumMode:'ratio',Sa:1.5}},approved:true,locked:false}];
const first=EstBusiness.progress(q,'one');q.progress[0].result=first;q.progress[0].locked=true;
q.progress.push({id:'two',inputs:{'1':{Gum:500,GumMode:'amount',Sa:1}},approved:false,locked:false});
const second=EstBusiness.progress(q,'two');
q.annual=[{name:'1차',formula:'[계약수량]*0.4'},{name:'2차',formula:'[계약수량]-[1차]'}];
const years=EstBusiness.annual(q);const resources=EstResources.calculate(q);
const esc=structuredClone(q);esc.options.SourceRefresh=false;
Object.assign(esc.tables.EstMainDat[0],{AGU:180,GU:240,EscPlanned:2,EscActual:3,EscProgress:1});
const escalation=EstBusiness.escalation(esc);
const changed=structuredClone(q);changed.tables.StuffcDat[0].Cost1=120;
changed.tables.EstMainDat.splice(1,0,{PkID:3,Code:'M',Name:'new',GN:1,LinkIndex:5,NewItem:true});
const originals=[0,1,2].map(method=>EstBusiness.original(changed,method));
const bid=EstBusiness.bid(q,[90]);const bidAgain=EstBusiness.bid(bid,[90]);
({first,second,years,resources,escalation,originals,bid,bidAgain,project:q});
`,
context,
);
else if (command === "math") result = vm.runInContext(process.argv[3], context);
else throw Error("unknown command");
process.stdout.write(JSON.stringify(result));
+267
View File
@@ -0,0 +1,267 @@
"""REA 저장 샘플과 웹앱 엔진의 대조. 원본 프로그램 실행 시험이 아님."""
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
HELPER = Path(__file__).with_name("helper_est_native.cjs")
EXPECTED = json.loads(
(ROOT / "resources/tester/fixtures/est_rea_expected.json").read_text(encoding="utf-8")
)
SAMPLE = json.loads((ROOT / "tools/est-test/sample.json").read_text(encoding="utf-8"))
def run(command, *args):
return json.loads(
subprocess.check_output(["node", str(HELPER), command, *args], text=True, encoding="utf-8")
)
def test_machine_stored_components_heads_and_titles():
result = run("machine")
assert result["errors"] == []
count = 0
for expected in EXPECTED["machine"]["rows"]:
if "stored" not in expected:
continue
row = result["rows"][expected["slot"]]
if isinstance(expected["stored"], dict):
for key, value in expected["stored"].items():
assert abs((row.get(key) or 0) - (value or 0)) < 1e-6, (
expected["slot"],
key,
row.get(key),
value,
)
else:
assert abs(row[expected["field"]] - expected["stored"]) < 1e-6, expected
count += 1
by_code = {row["Code"]: row for row in result["titles"]}
for expected in SAMPLE["tables"]["MachTitleDat"]:
actual = by_code[expected["Code"]]
for key in ["NC", "GC", "JC", "TC"]:
assert actual[key] == expected[key]
count += 1
assert count == 48
def test_numeric_helpers_distinguish_rounding_modes():
assert run(
"math",
"[EstMath.even(2.5),EstMath.even(3.5),EstMath.floating(-1.25,1,1),EstMath.fixed(1.23999,2)]",
) == [2, 4, -1.3, 1.24]
def test_head_41_stored_checks():
result = run("head")
assert result["errors"] == []
rows = result["rows"]
for expected in EXPECTED["head_est"]["head"]:
actual = rows[expected["source_slot"]]
for key, value in expected["stored"].items():
assert abs((actual.get(key) or 0) - value) < 1e-6, (expected, key, actual.get(key))
assert len(EXPECTED["head_est"]["head"]) == 41
def test_estimate_37_stored_checks():
result = run("estimate")
assert result["errors"] == []
for expected in EXPECTED["head_est"]["est"]:
row = result["rows"][expected["slot"]]
for key, value in expected["stored"].items():
assert abs((row.get(key) or 0) - value) < 1e-6, (
expected["slot"],
key,
row.get(key),
value,
)
assert len(EXPECTED["head_est"]["est"]) == 37
def test_linedata_118_results_and_8_titles():
result = run("text")
assert result["errors"] == []
assert len(result["checks"]) == 118
for item in result["checks"]:
assert abs(item["stored"] - item["calculated"]) < 1e-6, item
for expected in EXPECTED["linedata"]["rows"]:
if expected["kind"] == "title":
actual = result["rows"][expected["slot"]]
for key, value in expected["stored"].items():
assert actual[key] == value
def test_wonga_all_23_formulas_and_total_fix():
result = run("wonga")
assert result["errors"] == []
for expected in EXPECTED["wonga"]["results"]:
row = result["rows"][expected["row_position"]]
assert row["Cost"] == float(expected["stored"]), (
row["Gubun"],
row["Cost"],
expected["stored"],
)
assert len(EXPECTED["wonga"]["results"]) == 23
assert result["total"] == 79603000
assert [step["difference"] for step in result["adjustments"]] == [324, -32, 3, 0]
def test_entire_refresh_pipeline_preserves_stored_outputs():
result = run("refresh")
assert result["calculation"]["errors"] == []
for table in ["MachMainDat", "HeadMainDat", "EstMainDat"]:
for index, (before, after) in enumerate(
zip(SAMPLE["tables"][table], result["tables"][table])
):
for key in ["NC", "GC", "JC", "SC", "TC"]:
assert abs((before.get(key) or 0) - (after.get(key) or 0)) < 1e-6, (
table,
index,
key,
)
for table in ["CostTitleDat", "HeadTitleDat", "MachTitleDat"]:
lookup = {row["Code"]: row for row in result["tables"][table]}
for before in SAMPLE["tables"][table]:
for key in ["NC", "GC", "JC", "SC", "TC"]:
assert abs((before.get(key) or 0) - (lookup[before["Code"]].get(key) or 0)) < 1e-6
def test_recalculation_after_price_edit_is_idempotent():
first, second = run("repeat")
assert first["calculation"]["errors"] == second["calculation"]["errors"] == []
assert first["tables"]["CostTitleDat"] == second["tables"]["CostTitleDat"]
assert first["tables"]["EstMainDat"] == second["tables"]["EstMainDat"]
def test_progress_accepted_rounds_annual_allocation_and_resources():
result = run("business")
first, second = result["first"], result["second"]
assert first["errors"] == second["errors"] == []
assert first["rows"][0]["GumGN"] == 2.5
assert first["rows"][0]["TotalGN"] == 1.5
assert first["total"] == 330
assert second["rows"][0]["JunGN"] == 1.5
assert second["rows"][0]["GumGN"] == 2.5
assert second["rows"][0]["TotalGN"] == 4
assert second["rows"][0]["TotalTC"] == 800
assert first["rows"][-1]["GumTC"] == 550
assert first["rows"][-1]["SaTC"] == first["rows"][-1]["TotalTC"] == 330
assert second["rows"][-1]["JunTC"] == 330
assert second["rows"][-1]["TotalTC"] == 880
assert result["years"]["errors"] == []
assert [r["project"]["calculation"]["total"] for r in result["years"]["results"]] == [880, 1320]
assert result["resources"]["errors"] == []
assert result["resources"]["rows"][0]["quantity"] == 20
escalation = result["escalation"]
assert escalation["errors"] == []
assert escalation["rows"][0]["EscExcluded"] == 3
assert escalation["rows"][0]["EscApplied"] == 7
assert escalation["contractTotal"] == 2200
assert escalation["adjustedTotal"] == 2640
assert escalation["excludedTotal"] == 660
assert escalation["applied"] == 1540
assert escalation["rate"] == 20
assert escalation["adjustment"] == 308
assert [
next(row["TC"] for row in item["tables"]["EstMainDat"] if row.get("LinkIndex") == 54)
for item in result["originals"]
] == [2120, 2520, 2520]
assert result["bid"]["calculation"]["direct"]["TC"] == 1800
assert result["bidAgain"]["tables"]["EstMainDat"] == result["bid"]["tables"]["EstMainDat"]
def test_estx_import_reads_original_tables_options_and_templates(tmp_path):
import zipfile
bundle = next((ROOT / "resources/knowledge/original").glob("*/EST Plus 2/*/05_*/sample_bundle"))
archive = tmp_path / "sample.estx"
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as output:
for file in bundle.iterdir():
if file.is_file():
output.write(file, file.name)
result = run("import", str(archive))
assert result["schema"] == "est-web-2"
assert len(result["tables"]["EstMainDat"]) == len(SAMPLE["tables"]["EstMainDat"])
assert len(result["originalOptions"]) == 17
assert len(result["wongaTemplates"]) == 4
assert result["options"]["TotalFix"] is True
assert result["options"]["GCToWonga"] is False
assert result["originalArchive"]
def test_source_price_edit_changes_downstream_costs():
first, _ = run("repeat")
original = run("refresh")
assert first["calculation"]["direct"]["TC"] != original["calculation"]["direct"]["TC"]
assert first["tables"]["Wonga"] != original["tables"]["Wonga"]
def test_extended_original_rows_cover_conversion_fractions_and_fuel():
fixture = json.loads(
(ROOT / "resources/tester/fixtures/est_native_extended.json").read_text(encoding="utf-8")
)
result = run("extended")
for name, table in [("head", "HeadMainDat"), ("machine", "MachMainDat")]:
assert result[name]["errors"] == []
for index, (before, after) in enumerate(
zip(fixture["tables"][table], result[name]["rows"])
):
for key in ["NC", "GC", "JC", "SC", "TC"]:
assert abs((before.get(key) or 0) - (after.get(key) or 0)) < 1e-6, (
table,
index,
key,
)
assert result["text"]["errors"] == []
for check in result["text"]["checks"]:
assert abs(check["stored"] - check["calculated"]) < 1e-6, check
def test_installed_main_archive_all_stored_values_when_available():
import os
import pytest
archive = Path(
os.environ.get("EST_REA_MAIN", "C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx")
)
if not archive.is_file():
pytest.skip("Original EST main.estx is not installed; portable fixtures still run")
output = subprocess.check_output(
["node", str(HELPER.with_name("helper_est_archive.cjs")), str(archive)],
text=True,
encoding="utf-8",
timeout=120,
)
result = json.loads(output)
assert result["errors"] == []
assert result["differences"] == []
assert all(item["checked"] == item["matched"] for item in result["counts"].values())
assert sum(item["checked"] for item in result["counts"].values()) > 100000
def test_original_quantity_sheets_and_three_saved_formula_disagreements():
sheets = run("quantity")
assert len(sheets) == 75
# Original editor formulas disagree with MulList saved numbers in three
# places. Keep both visible instead of changing the formulas to replay them.
expected = {
("N0000000083", "r000004"): (286, 0.35 * 1.3 * 220),
("N0000000086", "r000004"): (0, 1 * 1),
("N0000000106", "r000000"): (138.72, 1.25 * 2 * 68 * 1.02),
}
differences = set()
checked = 0
for sheet in sheets:
assert sheet["errors"] == []
for before, after in zip(sheet["original"], sheet["items"]):
checked += 1
key = (sheet["Code"], before["Code"])
if key in expected:
assert before["GN"] == expected[key][0]
assert abs(after["GN"] - expected[key][1]) < 1e-6
differences.add(key)
else:
assert abs((before.get("GN") or 0) - (after.get("GN") or 0)) < 1e-6, key
assert checked == 449
assert differences == set(expected)
+1
View File
@@ -0,0 +1 @@
/data/
+24 -4
View File
@@ -1,7 +1,27 @@
# EST 계산 흐름 테스트
# EST 독립 웹앱
`index.html`을 브라우저에서 열면 됩니다. 별도 서버, 로그인, 설치 또는 Aislo DB가 필요하지 않습니다.
`start.cmd`를 실행한 뒤 **http://127.0.0.1:5175** 로 접속합니다. Node.js가 필요하며 별도 패키지 설치는 없습니다. Aislo 서버·로그인·DB와 연결하지 않습니다.
기초 단가의 6개 출처 선택, 일위대가 간 참조·비율, 4개 비목 집계, 단순 기계경비, 원가 사칙연산 수식, 수량산출 연결, 기성 수량, 물가변동 품목 단가, 동원 수량 전개를 시험합니다. 값은 현재 탭의 임시 저장소에 보관되며 JSON 저장·열기로 파일을 직접 관리합니다. 예제 수치와 요율은 임의의 테스트 값입니다.
내역·일위대가·수식 단가산출·기계경비·기초 단가·원가계산서를 원본 테이블 구조로 편집합니다. 수량 연결, 설계변경 당초 보존, 낙찰률, 기성 회차와 사정 누계, 품목조정, 연차 배분, 소요 자원 전개 화면을 제공합니다. 실행 취소·다시 실행, 검색, 행 편집, JSON 저장·열기, CSV와 브라우저 인쇄를 사용할 수 있습니다.
EST Plus 2 REA 분석의 일부 계산 흐름만 구현 중이며 원본의 기능이나 결과와 동등하지 않습니다. 수식 단가산출, 기계경비의 외화·다중 연료·낙찰 분기, ESTX 가져오기, 설계변경, 기성 회차 이력·원가, 물가변동 원가 연동, 동원의 산근 전개와 법정 요율 등은 구현하지 않았습니다. 상단 메뉴 이름은 배치 참고용이며 실제 기능은 작업화면 이동과 도구 버튼으로 사용합니다.
저장은 이 폴더의 `data/`에 작업을 보관하고 `.estweb.json` 파일도 내려받습니다. 브라우저 임시 저장은 같은 탭에서 복원하는 용도입니다. ESTX는 읽기 전용으로 가져오며 원본 압축 파일과 바이너리를 보존합니다. 편집 결과를 원본 ESTX로 다시 쓰는 기능은 없습니다.
초기 자료는 기술정보 DB의 실제 REA 저장 샘플입니다. `REA 샘플 대조`에서 원본 저장값과 275개 계산 결과를 비교합니다. 테스트용 임의 요율을 원본 기본값으로 넣지 않습니다. 새 원가 서식의 요율은 사용자가 입력합니다.
## 검증과 남은 범위
기계 성분·합계 48건, 일위 41건, 내역 37건, 산출 결과 118건과 호표 8건, 원가 수식 23건을 대조합니다. 원가 도급액 고정 반복 차이 `324 → -32 → 3 → 0`을 재현합니다. 단가 갱신 후 전체 재계산과 반복 계산의 안정성도 시험합니다.
설치된 대용량 `main.estx`에서도 일위 52,530개, 기계 17,675개, 내역 205개, 산출식 결과 23,889개, 호표 목록 19,381개를 대조해 **113,680개 모두 일치**했습니다. 원본 파일은 읽기만 합니다. 같은 파일이 있는 환경에서는 이 대조를 자동 시험하며, 다른 환경에서는 저장소의 원본 발췌 행을 시험합니다. 원천 단가 전체 갱신 후 오류 0 및 반복 계산 값의 안정성도 확인했습니다.
원본 수량산출서 75개의 WPTools 글·결과 표지·위첨자를 읽어 편집하고 내역 수량에 연결합니다. 저장 수량 449개 중 446개가 재계산 결과와 일치합니다. 나머지 3개는 원본 글의 산식과 저장 수량이 서로 다릅니다(예: `0.35×1.3×220`의 저장값 286, 계산값 100.1). 화면에 원본 저장 수량과 재계산 수량을 함께 표시합니다. 원본 수치를 맞추려고 산식을 변경하지 않았습니다.
**전체 원본 동등성은 아직 확보하지 못했습니다.** 기성·물가변동·연차 등은 분석 분기와 합성 시나리오를 시험했으며 원본 업무 샘플 대조는 없습니다. 원본 화면 실행 비교는 못했습니다. 원본 페이지·도면 배치, CAD/VEC0650 편집, 원본 인쇄 서식, Excel/XML/JDL 변환, ESTX 수정 저장은 미구현입니다. 원본 대체품의 완료 판정으로 보지 않습니다.
검사:
```powershell
./venv/Scripts/python.exe -m pytest resources/tester/test_est_rea_parity.py resources/tester/test_est_standalone.py -q
```
`native-*.js`가 현재 화면·계산 엔진이며 브라우저와 로컬 서버가 같은 계산 코드를 사용합니다. `build_sample.py`는 REA 자료에서 입력·기대값을 재생성합니다. 기존 `app.js`, `calc.js`, `formula.js`, `workflows.js`는 이전 데모의 회귀 시험용이며 현재 진입 화면에서 사용하지 않습니다.
+73
View File
@@ -0,0 +1,73 @@
"""Build read-only REA sample inputs and expected stored values; does not run EST."""
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
ANALYSIS = ROOT / "resources/knowledge/original/경쟁사 프로그램/EST Plus 2/REA 분석"
SOURCE = ANALYSIS / "07_수치검증_작업자료_20261025"
DETAIL = ANALYSIS / "09_수식집계_변경저장_추적_20261029"
def read(path):
return json.loads(path.read_text(encoding="utf-8-sig"))
def convert(value):
if isinstance(value, dict) and "decimal" in value:
return float(value["decimal"])
return value
def build():
tables = {}
for path in SOURCE.glob("*.decoded_candidates.json"):
key = path.name.split(".")[0]
tables[key] = [r["values"] for r in read(path)["rows"]]
wonga = read(DETAIL / "WongaDat_memory_table_candidate_rows.json")
table = next(t for t in wonga["tables"] if t["source"].endswith("_1_decompressed.bin"))
tables["Wonga"] = [{k: convert(v) for k, v in row["values"].items()} for row in table["rows"]]
sample = {
"schema": "est-web-2",
"name": "EST REA 보관 샘플",
"tables": tables,
"options": {"TotalFix": True, "VerFormat": True, "SourceRefresh": False},
"quantities": [],
"progress": [],
"annual": [],
"drawings": [],
"evidence": "REA 저장 샘플. 물리 슬롯의 활성 여부 및 원본 실행 동등성은 별도 검증 대상.",
}
target = Path(__file__).parent / "sample.json"
target.write_text(json.dumps(sample, ensure_ascii=False, allow_nan=False), encoding="utf-8")
expected = {
"qualification": "기존 REA 분석의 저장값. 웹앱 계산값으로 정답을 만들지 않음.",
"machine": read(DETAIL / "machine_sample_formula_reconstruction_check.json"),
"head_est": read(DETAIL / "head_est_sample_rule_check.json"),
"linedata": read(DETAIL / "linedata_sample_precision_check.json"),
"wonga": read(DETAIL / "sample_wonga_Gubun_formula_candidate_check.json"),
}
for check in expected["head_est"]["head"]:
candidates = []
for slot, row in enumerate(tables["HeadMainDat"]):
if row.get("Name") != check.get("Name") or (
"Code" in check and row.get("Code") != check["Code"]
):
continue
if all(
abs((row.get(key) or 0) - value) < 1e-6 for key, value in check["stored"].items()
):
candidates.append(slot)
if not candidates:
raise ValueError(f"head sample identity not found: {check}")
check["source_slot"] = candidates[0]
fixture = ROOT / "resources/tester/fixtures/est_rea_expected.json"
fixture.write_text(json.dumps(expected, ensure_ascii=False, allow_nan=False), encoding="utf-8")
(Path(__file__).parent / "parity.json").write_text(
json.dumps(expected, ensure_ascii=False, allow_nan=False), encoding="utf-8"
)
print(f"sample: {sum(map(len, tables.values()))} records, {len(tables)} tables")
if __name__ == "__main__":
build()
+359
View File
@@ -0,0 +1,359 @@
// Read-only ESTX import. Layout: knowledge/다산소프트/ESTX_파일구조.md.
const zlib = require("node:zlib");
const quantity = require("./native-quantity.cjs");
const decoder = new TextDecoder("euc-kr", { fatal: true });
const LIMIT = 256 * 1024 * 1024;
const crcTable = Array.from({ length: 256 }, (_, n) => {
for (let i = 0; i < 8; i++) n = n & 1 ? 0xedb88320 ^ (n >>> 1) : n >>> 1;
return n >>> 0;
});
function crc32(bytes) {
let crc = 0xffffffff;
for (const byte of bytes) crc = crcTable[(crc ^ byte) & 255] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
function unzip(buffer) {
let end = -1;
for (let i = buffer.length - 22; i >= Math.max(0, buffer.length - 65557); i--)
if (buffer.readUInt32LE(i) === 0x06054b50) {
end = i;
break;
}
if (end < 0) 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);
const compressed = buffer.readUInt32LE(position + 20),
size = buffer.readUInt32LE(position + 24);
const nameLength = buffer.readUInt16LE(position + 28),
extra = buffer.readUInt16LE(position + 30),
comment = buffer.readUInt16LE(position + 32),
local = buffer.readUInt32LE(position + 42);
const name = buffer.subarray(position + 46, position + 46 + nameLength).toString("utf8");
if (flags & 1) throw Error("암호화된 ESTX는 지원하지 않습니다.");
if (!name || name.includes("..") || /[\\/]/.test(name)) throw Error("예상하지 못한 ZIP 경로");
total += size;
if (total > LIMIT) throw Error("압축 해제 크기 한도 초과");
if (buffer.readUInt32LE(local) !== 0x04034b50) throw Error("ZIP 로컬 헤더 오류");
const start = local + 30 + buffer.readUInt16LE(local + 26) + buffer.readUInt16LE(local + 28);
if (start + compressed > buffer.length) throw Error("ZIP 데이터 범위 오류");
const source = buffer.subarray(start, start + compressed);
const data =
method === 8
? zlib.inflateRawSync(source, { maxOutputLength: LIMIT })
: method === 0
? source
: null;
if (!data || data.length !== size || crc32(data) !== crc)
throw Error(`ZIP CRC/길이 오류: ${name}`);
if (files.has(name.toLowerCase())) throw Error(`중복 파일: ${name}`);
files.set(name.toLowerCase(), data);
position += 46 + nameLength + extra + comment;
}
return files;
}
function blob(file, start, id) {
if (!file || start === 0) return Buffer.alloc(0);
const parts = [],
seen = new Set();
let block = start,
previous = 0,
expected = 0;
while (block) {
const offset = block * 512;
if (seen.has(block) || offset + 512 > file.length) throw Error("BLOB 체인 범위/순환 오류");
seen.add(block);
if (file.readUInt32LE(offset) !== previous) throw Error("BLOB 역연결 오류");
const length = file.readUInt16LE(offset + 8);
if (length > 494) throw Error("BLOB 조각 길이 오류");
if (block === start) {
if (file.readUInt32LE(offset + 10) !== id) throw Error("BLOB 식별자 오류");
expected = file.readUInt32LE(offset + 14);
if (expected > LIMIT) throw Error("BLOB 크기 한도 초과");
}
parts.push(file.subarray(offset + 18, offset + 18 + length));
previous = block;
block = file.readUInt32LE(offset + 4);
}
const result = Buffer.concat(parts);
if (result.length !== expected) throw Error("BLOB 총 길이 오류");
return result;
}
function table(buffer, blobs, warnings) {
if (buffer.length < 512) throw Error("테이블 헤더가 짧습니다.");
const stride = buffer.readUInt16LE(45),
count = buffer.readUInt16LE(47),
start = 512 + 768 * count;
if (!stride || count > 1024 || start > buffer.length || (buffer.length - start) % stride)
throw Error("DBISAM 후보 레이아웃과 다른 파일입니다.");
const fields = [];
for (let i = 0; i < count; i++) {
const offset = 512 + 768 * i,
name = buffer.subarray(offset + 3, offset + 3 + buffer[offset + 2]).toString("ascii");
const kind = buffer[offset + 164],
width = buffer.readUInt16LE(offset + 169),
payload = buffer.readUInt32LE(offset + 172);
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || payload + width + 1 > stride)
throw Error("필드 설명자 오류");
fields.push({ name, kind, width, payload });
}
const rows = [];
for (let offset = start; offset < buffer.length; offset += stride) {
const row = {};
for (const f of fields) {
const flag = buffer[offset + f.payload],
raw = buffer.subarray(offset + f.payload + 1, offset + f.payload + 1 + f.width);
if (!flag) {
row[f.name] = null;
continue;
}
if (flag !== 1) throw Error(`지원하지 않는 행 존재 플래그: ${flag}`);
if (f.kind === 1) {
const zero = raw.indexOf(0);
const bytes = zero >= 0 ? raw.subarray(0, zero) : raw;
try {
row[f.name] = decoder.decode(bytes);
} catch {
row[f.name] = new TextDecoder("euc-kr").decode(bytes);
row._originalTextBytes ||= {};
row._originalTextBytes[f.name] = bytes.toString("base64");
warnings.add(`CP949 범위 밖 문자열: ${f.name}. 원문 바이트를 별도 보존했습니다.`);
}
} else if (f.kind === 7 && f.width === 8) {
row[f.name] = raw.readDoubleLE();
if (!Number.isFinite(row[f.name])) throw Error("유효하지 않은 실수 필드");
} else if ([2, 6].includes(f.kind) && f.width === 4) row[f.name] = raw.readInt32LE();
else if (f.kind === 4 && f.width === 2) row[f.name] = raw.readInt16LE();
else if (f.kind === 3 && f.width === 8) {
const bytes = blob(blobs, raw.readUInt32LE(), raw.readUInt32LE(4));
row[f.name] = { binary: bytes.toString("base64") };
} else {
row[f.name] = { binary: raw.toString("base64"), kind: f.kind };
warnings.add(`미해석 필드형: ${f.kind}/${f.width}`);
}
}
rows.push(row);
}
return rows;
}
class Values {
constructor(buffer) {
this.buffer = buffer;
this.position = 4;
}
take(n) {
if (n < 0 || this.position + n > this.buffer.length) throw Error("TPF0 범위 오류");
const bytes = this.buffer.subarray(this.position, this.position + n);
this.position += n;
return bytes;
}
byte() {
return this.take(1)[0];
}
length() {
return this.take(4).readUInt32LE();
}
short() {
return decoder.decode(this.take(this.byte()));
}
value(depth = 0) {
if (depth > 40) throw Error("TPF0 중첩 한도 초과");
const tag = this.byte();
if ([0, 13].includes(tag)) return null;
if (tag === 1) {
const values = [];
while (this.buffer[this.position] !== 0) values.push(this.value(depth + 1));
this.byte();
return values;
}
if (tag === 2) return this.take(1).readInt8();
if (tag === 3) return this.take(2).readInt16LE();
if (tag === 4) return this.take(4).readInt32LE();
if (tag === 19) return Number(this.take(8).readBigInt64LE());
if (tag === 5) {
const raw = this.take(10),
mantissa = Number(raw.readBigUInt64LE()),
exponent = raw.readUInt16LE(8),
sign = exponent & 0x8000 ? -1 : 1;
return ((sign * mantissa) / 2 ** 63) * 2 ** ((exponent & 0x7fff) - 16383);
}
if (tag === 16) return Number(this.take(8).readBigInt64LE()) / 10000;
if ([17, 21].includes(tag)) return this.take(8).readDoubleLE();
if (tag === 15) return this.take(4).readFloatLE();
if ([6, 7].includes(tag)) return decoder.decode(this.take(this.byte()));
if (tag === 8 || tag === 9) return tag === 9;
if (tag === 10) return this.take(this.length());
if (tag === 12) return decoder.decode(this.take(this.length()));
if (tag === 18) return this.take(this.length() * 2).toString("utf16le");
if (tag === 20) return this.take(this.length()).toString("utf8");
if (tag === 11) {
const values = [];
while (this.buffer[this.position] !== 0) values.push(decoder.decode(this.take(this.byte())));
this.byte();
return values;
}
throw Error(`미지원 TPF0 태그: ${tag}`);
}
}
function optionObjects(buffer, warnings) {
const objects = [];
let offset = 0;
while ((offset = buffer.indexOf("TPF0", offset)) >= 0) {
const length = offset >= 4 ? buffer.readUInt32LE(offset - 4) : 0;
if (length < 6 || offset + length > buffer.length) {
offset += 4;
continue;
}
try {
const reader = new Values(buffer.subarray(offset, offset + length));
const className = reader.short(),
name = reader.short(),
properties = {};
if (!/^T[A-Za-z]+Option$/.test(className)) {
offset += 4;
continue;
}
while (reader.buffer[reader.position] !== 0) properties[reader.short()] = reader.value();
reader.byte();
reader.byte();
if (reader.position !== reader.buffer.length) throw Error("옵션 객체 길이 불일치");
objects.push({ className, name, properties });
} catch (error) {
warnings.add(`옵션 객체: ${error.message}`);
}
offset += Math.max(4, length);
}
return objects;
}
function memoryRows(buffer) {
if (buffer[0] === 0x78) buffer = zlib.inflateSync(buffer, { maxOutputLength: LIMIT });
if (buffer.subarray(0, 4).toString() !== "TPF0") throw Error("TPF0 메모리 표가 아닙니다.");
const reader = new Values(buffer),
values = [];
while (reader.position < buffer.length) values.push(reader.value());
const descriptors = values[1],
entries = values[4];
if (!Array.isArray(descriptors) || !Array.isArray(entries) || descriptors.length % 10)
throw Error("메모리 표 설명자 오류");
const fields = Array.from({ length: descriptors.length / 10 }, (_, i) => descriptors[i * 10]);
return entries.map((entry) => {
const row = {};
let position = 1;
for (const name of fields) {
const absent = entry[position++];
row[name] = absent ? null : entry[position++];
}
if (position !== entry.length) throw Error("메모리 표 행 길이 오류");
return row;
});
}
function importEstx(buffer, name = "가져온 내역서") {
const files = unzip(buffer),
tables = {},
warnings = new Set(),
templates = [];
for (const [filename, data] of files) {
if (
!filename.endsWith(".dat") ||
!data.subarray(8, 24).equals(Buffer.from("068abe8e592364cb403d71d2e3bc64d0", "hex"))
)
continue;
const rows = table(data, files.get(filename.replace(/\.dat$/, ".blb")), warnings);
const key = filename.replace(/\.dat$/, "");
const canonical =
{
stuffcdat: "StuffcDat",
laborcdat: "LaborcDat",
siljukdat: "SilJukDat",
estimatedat: "EstiMateDat",
machgijundat: "MachGijunDat",
machmaindat: "MachMainDat",
machtitledat: "MachTitleDat",
costmaindat: "CostMainDat",
costtitledat: "CostTitleDat",
headmaindat: "HeadMainDat",
headtitledat: "HeadTitleDat",
estmaindat: "EstMainDat",
mulmaindat: "MulMainDat",
multitledat: "MulTitleDat",
cadmaindat: "CadMainDat",
cadtitledat: "CadTitleDat",
}[key] || key;
tables[canonical] = rows;
if (key === "wongadat")
for (const row of rows) {
const value = Object.values(row).find((value) => value?.binary);
if (value) {
try {
templates.push({
index: row.Index,
rows: memoryRows(Buffer.from(value.binary, "base64")),
});
} catch (error) {
warnings.add(`원가 서식 ${row.Index}: ${error.message}`);
}
}
}
}
if (!tables.EstMainDat) throw Error("내역 테이블이 없는 ESTX입니다.");
tables.Wonga = templates[0]?.rows || [];
const objects = files.has("datestoption.opt")
? optionObjects(files.get("datestoption.opt"), warnings)
: [];
const props = Object.fromEntries(objects.map((o) => [o.className, o.properties]));
const options = {
GCToWonga: props.TWongaOption?.GCToWonga ?? false,
SourceRefresh: false,
VerFormat: props.TWongaOption?.VerFormat ?? true,
TotalFix: props.TWongaOption?.TotalFix ?? tables.Wonga.some((r) => r.LinkIndex === 55),
AutoCalc: props.TEstOption?.CalcAuto ?? true,
HeadDigits: props.THeadOption?.SumValueDiget || 0,
TotalFit: props.THeadOption?.CalcTrunc || false,
DollarCostCut: props.TMachOption?.DollarCostCut || false,
JDLCalcMethod: props.TCommonOption?.JDLCalcMethod || false,
OldRecalcMethod: props.TChangeOption?.OldRecalcMethod || 0,
};
const quantitySheets = [];
for (const row of tables.MulMainDat || []) {
if (!row.Editor?.binary || !row.MulList?.binary) continue;
try {
const packed = Buffer.from(row.Editor.binary, "base64");
const bytes =
packed[0] === 0x78 ? zlib.inflateSync(packed, { maxOutputLength: LIMIT }) : packed;
const text = new TextDecoder("euc-kr").decode(bytes);
const items = memoryRows(Buffer.from(row.MulList.binary, "base64"));
const name = tables.MulTitleDat?.find((r) => r.Code === row.Code)?.Name || row.Code;
quantitySheets.push(
quantity.fromWpt(row.Code, name, text, items, {
digits: props.TMulLangOption?.Diget ?? 2,
roundMode: (props.TMulLangOption?.CalcMethod ?? 1) + 1,
}),
);
} catch (error) {
warnings.add(`수량산출 ${row.Code}: ${error.message}`);
}
}
return {
schema: "est-web-2",
name: props.TCommonOption?.ProjectName || name,
tables,
wongaTemplates: templates,
originalOptions: objects,
options,
quantities: [],
quantitySheets,
progress: [],
annual: [],
drawings: [],
importWarnings: [...warnings, "활성 원가 서식과 인덱스 편집 규칙은 자동 확정하지 않았습니다."],
originalArchive: buffer.toString("base64"),
};
}
module.exports = { importEstx, unzip, table, memoryRows, optionObjects, crc32 };
+159 -18
View File
@@ -1,19 +1,160 @@
<!doctype html>
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>EST 업무 테스트</title><link rel="stylesheet" href="style.css">
<script defer src="formula.js"></script><script defer src="calc.js"></script><script defer src="workflows.js"></script><script defer src="app.js"></script>
</head>
<body>
<header>
<div class="titlebar"><strong>EST 업무 테스트</strong><span>독립 실행 · 시험자료</span></div>
<div class="menubar"><span>파일</span><span>편집</span><span>공종</span><span>재계산</span><span>원가계산서</span><span>수량</span><span>도구</span><span>작업모드</span></div>
<div class="toolbar"><button id="reset">▤ 새 내역서</button><label class="button">▣ 불러오기<input id="open" type="file" accept=".json,application/json" hidden></label><button id="save">▣ 저장하기</button><i></i><button id="recalculate">⟳ 전체 재계산</button><button id="print">▧ 인쇄</button><i></i><span>공사명</span><input id="projectName" aria-label="공사명" value="시험 내역서" maxlength="200"></div>
</header>
<div class="workspace">
<aside><div class="sidehead">작업화면 이동</div><nav class="tabs" aria-label="작업 화면"><button data-tab="estimate">▦ <span>내역서</span></button><button data-tab="units">▤ <span>일위대가</span></button><button data-tab="sources">▥ <span>기초단가</span></button><button data-tab="machines">⚙ <span>기계경비</span></button><button data-tab="quantities">▤ <span>수량산출</span></button><button data-tab="costs">▧ <span>원가계산서</span></button><button data-tab="progress">▦ <span>기성고</span></button><button data-tab="escalation">▤ <span>물가변동</span></button><button data-tab="resources">▤ <span>동원 집계</span></button></nav><div class="sideinfo">작업 자료<br><b>독립 테스트</b><p>내용을 수정한 뒤 저장하기를 눌러 파일로 보관하세요.</p></div></aside>
<main><div class="notice">구현 중 · 상단 메뉴는 배치 참고용이며 왼쪽 작업화면과 도구 버튼만 동작합니다. 예제 수치는 임의 값이며 원본 EST와의 동등성은 검증되지 않았습니다.</div><div class="summary" id="summary"></div><section id="panel" aria-live="polite"></section></main>
</div>
<footer><span>● 준비</span><span>저장: JSON 파일 · 탭을 닫기 전에 저장하세요</span></footer>
</body></html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>EST 독립 업무 프로그램</title>
<link rel="stylesheet" href="native.css" />
<script defer src="native-math.js"></script>
<script defer src="native-formula.js"></script>
<script defer src="native-machine.js"></script>
<script defer src="native-rows.js"></script>
<script defer src="native-text.js"></script>
<script defer src="native-quantity.cjs"></script>
<script defer src="native-wonga.js"></script>
<script defer src="native-engine.js"></script>
<script defer src="native-business.js"></script>
<script defer src="native-resources.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-actions.js"></script>
<script defer src="native-app.js"></script>
</head>
<body>
<header>
<div class="titlebar">
<b>EST 독립 업무 프로그램</b><span>내역 · 단가 · 원가</span
><button data-action="parity">REA 샘플 대조</button>
</div>
<div class="menubar">
<details>
<summary>파일(F)</summary>
<div class="menu">
<button data-action="new">새 내역서 만들기</button
><button data-action="open">불러오기…</button
><button data-action="projects">저장한 작업…</button>
<hr />
<button data-action="save">저장하기 Ctrl+S</button
><button data-action="save-as">다른 이름으로 저장</button
><button data-action="sample">REA 샘플 열기</button>
<hr />
<button data-action="csv">현재 자료 CSV 내보내기</button
><button data-action="print">인쇄</button>
</div>
</details>
<details>
<summary>편집(E)</summary>
<div class="menu">
<button data-action="undo">실행 취소</button
><button data-action="redo">다시 실행</button>
<hr />
<button data-action="copy-row">선택 행 복사</button
><button data-action="cut-row">선택 행 잘라내기</button
><button data-action="paste-row">행 붙여넣기</button
><button data-action="delete-row">선택 행 삭제</button>
<hr />
<button data-action="find">찾기</button>
</div>
</details>
<details>
<summary>LEVEL</summary>
<div class="menu levels">
<button data-action="level" data-level="0">LEVEL 0</button
><button data-action="level" data-level="1">LEVEL 1</button
><button data-action="level" data-level="2">LEVEL 2</button
><button data-action="level" data-level="3">LEVEL 3</button
><button data-action="level" data-level="4">LEVEL 4</button
><button data-action="level" data-level="5">LEVEL 5</button
><button data-action="level" data-level="6">LEVEL 6</button
><button data-action="level" data-level="7">LEVEL 7</button
><button data-action="level" data-level="8">LEVEL 8</button
><button data-action="level" data-level="9">LEVEL 9</button>
</div>
</details>
<details>
<summary>재계산(C)</summary>
<div class="menu">
<button data-action="recalculate">일건서류 전체 재계산</button
><button data-action="recalculate">내역서 재계산</button
><button data-action="apply-quantity">수량산출 반영 후 재계산</button>
</div>
</details>
<details>
<summary>원가계산서(W)</summary>
<div class="menu">
<button data-view="Wonga">원가계산서 보기</button
><button data-action="template">원가 서식 선택</button
><button data-action="settings">자릿수·도급액 고정</button>
</div>
</details>
<details>
<summary>작업모드(M)</summary>
<div class="menu">
<button data-view="EstMainDat">내역서</button
><button data-view="change">설계변경</button><button data-view="progress">기성고</button
><button data-view="escalation">물가변동</button
><button data-view="annual">연차공사</button
><button data-view="resources">동원인원·자재·장비</button>
</div>
</details>
<button data-action="settings" class="menu-button">환경설정</button>
</div>
<div class="toolbar">
<button data-action="new">▤ 새내역</button><button data-action="open">▣ 불러오기</button
><button data-action="save">▣ 저장</button><i></i><button data-action="undo">↶ 취소</button
><button data-action="recalculate">⟳ 재계산</button
><button data-action="print">▧ 인쇄</button><i></i
><label>공사명 <input id="project-name" aria-label="공사명" /></label
><label><input id="automatic" type="checkbox" checked /> 자동계산</label
><label><input id="refresh-sources" type="checkbox" /> 원천 단가 재조회</label>
</div>
<nav class="work-tabs" aria-label="작업 화면">
<button data-view="EstMainDat">내역서</button><button data-view="HeadMainDat">일위</button
><button data-view="CostMainDat">단가</button><button data-view="MachMainDat">기계</button
><button data-view="EstiMateDat">견적</button><button data-view="SilJukDat">표준</button
><button data-view="StuffcDat">자재</button><button data-view="LaborcDat">노임</button
><button data-view="quantity">수량</button><button data-view="drawing">도면</button
><button data-view="Wonga">원가</button><button data-view="MachGijunDat">기계기준</button>
</nav>
</header>
<main>
<div id="messages" role="alert" hidden></div>
<div class="work-title"><b id="view-title">내역서</b><span id="selected-info"></span></div>
<div id="standard-tools" class="row-tools">
<button id="add-group" data-action="add-group" hidden>묶음 추가</button
><button data-action="add-row">행 추가</button><button data-action="copy-row">복사</button
><button data-action="paste-row">붙여넣기</button
><button data-action="delete-row">삭제</button
><input
id="search"
type="search"
aria-label="현재 자료 검색"
placeholder="품명·규격·코드 검색"
/>
<div id="paging">
<button id="previous-page" aria-label="이전 페이지">‹</button><span id="page-info"></span
><button id="next-page" aria-label="다음 페이지">›</button>
</div>
</div>
<div class="work-area">
<aside id="group-pane" hidden>
<h2>공종 목록</h2>
<div id="group-list"></div>
</aside>
<section id="work-content" aria-label="자료 편집"></section>
</div>
</main>
<div id="totals" class="totals"></div>
<footer>
<span id="status">시작 중…</span><span>독립 저장 · ESTX 원본은 변경하지 않음</span>
</footer>
<input id="open-file" type="file" accept=".json,.estx" hidden />
<dialog id="dialog">
<div class="dialog-head">
<b id="dialog-title"></b><button data-action="close-dialog" aria-label="닫기">×</button>
</div>
<div id="dialog-content"></div>
</dialog>
</body>
</html>
+656
View File
@@ -0,0 +1,656 @@
const EstActions = (() => {
let pending = null,
clipboard = [];
const uid = () => globalThis.crypto.randomUUID();
function form(title, fields, callback) {
const { esc, showDialog } = EstApp;
showDialog(
title,
`<div class="form-fields">${fields.map((f) => `<label>${esc(f.label)}${f.choices ? `<select name="${f.name}">${f.choices.map(([v, label]) => `<option value="${esc(v)}" ${String(v) === String(f.value) ? "selected" : ""}>${esc(label)}</option>`).join("")}</select>` : `<input name="${f.name}" type="${f.type || "text"}" value="${esc(f.value ?? "")}" step="any">`}</label>`).join("")}</div><button data-action="dialog-apply">적용</button>`,
);
pending = () =>
callback(
Object.fromEntries(
fields.map((f) => {
const node = document.querySelector(`#dialog-content [name="${f.name}"]`);
return [f.name, f.type === "number" ? Number(node.value) : node.value];
}),
),
);
}
function selected() {
return [...EstApp.state.selection].sort((a, b) => a - b);
}
function activeRows() {
const { state } = EstApp;
const rows = state.project.tables[state.view];
if (!rows) throw Error("작업 표를 먼저 선택하세요.");
return rows;
}
function undo(redo = false) {
const { state } = EstApp,
source = redo ? state.future : state.history,
target = redo ? state.history : state.future;
if (!source.length) return;
target.push(JSON.stringify(state.project));
state.project = JSON.parse(source.pop());
state.selection.clear();
EstApp.cache();
EstApp.render();
}
function insertRow() {
const rows = activeRows(),
{ state } = EstApp;
EstApp.change(() => {
const row = EstModel.newRow(state.view, rows);
if (state.view === "MachMainDat") row.Index = -3;
const group = EstNative.groups(rows).get(state.group);
const at =
selected().at(-1) ?? (group ? group.start + group.rows.length - 1 : rows.length - 1);
rows.splice(at + 1, 0, row);
state.selection = new Set([at + 1]);
});
}
function insertGroup() {
const { state } = EstApp,
rows = activeRows();
form(
"새 묶음 만들기",
[
{ name: "code", label: "코드", value: `U${Date.now().toString(36)}` },
{ name: "name", label: "품명" },
{ name: "size", label: "규격" },
{ name: "unit", label: "단위", value: "식" },
],
(values) => {
if (!values.code || rows.some((r) => r.Code === values.code && Number(r.Index) > 0))
throw Error("코드를 입력하거나 중복 코드를 바꾸세요.");
EstApp.change(() => {
const index = Math.max(0, ...rows.map((r) => Number(r.Index) || 0)) + 1;
const row = {
...EstModel.newRow(state.view, rows),
Index: index,
Code: values.code,
Name: values.name,
Size: values.size,
Unite: values.unit,
GN: 0,
};
if (state.view === "CostMainDat")
row.LineData = `\x0e${values.name}\x0e \x13${values.size}\x13 \x15${values.unit}\x15`;
rows.push(row);
state.group = values.code;
});
},
);
}
function deleteRows() {
const { state } = EstApp,
rows = activeRows(),
indexes = new Set(selected());
if (!indexes.size) throw Error("삭제할 행을 선택하세요.");
for (const group of ["HeadMainDat", "CostMainDat", "MachMainDat"].includes(state.view)
? EstNative.groups(rows).values()
: [])
if (indexes.has(group.start))
for (let i = group.start; i < group.start + group.rows.length; i++) indexes.add(i);
const codes = [...indexes].map((i) => rows[i].Code).filter(Boolean),
kind = Number(
Object.entries(EstNative.sourceTables).find(([, table]) => table === state.view)?.[0] ||
{ HeadMainDat: 103, CostMainDat: 104, MachMainDat: 105 }[state.view],
);
if (
kind &&
Object.entries(state.project.tables).some(([table, list]) =>
list.some((row, i) =>
table !== state.view || !indexes.has(i)
? row.LinkIndex === kind && codes.includes(row.Code)
: false,
),
)
)
throw Error("다른 항목에서 사용 중입니다. 연결을 먼저 해제하세요.");
if (!confirm(`${indexes.size}개 행을 삭제하시겠습니까?`)) return;
EstApp.change(() => {
[...indexes].sort((a, b) => b - a).forEach((i) => rows.splice(i, 1));
state.selection.clear();
});
}
async function action(name, node) {
const { state, api, esc, number, showDialog } = EstApp,
project = state.project;
state.notice = "";
if (name === "new") {
if (confirm("저장하지 않은 입력을 비우고 새 내역서를 만드시겠습니까?"))
EstApp.setProject(EstNative.empty());
} else if (name === "sample") {
if (confirm("현재 작업을 REA 샘플로 바꾸시겠습니까?"))
EstApp.setProject(await api("/api/sample"));
} else if (name === "open") EstApp.byId("open-file").click();
else if (name === "save" || name === "save-as") await EstApp.save(name === "save-as");
else if (name === "undo") undo();
else if (name === "redo") undo(true);
else if (name === "add-row") insertRow();
else if (name === "add-group") insertGroup();
else if (name === "delete-row") deleteRows();
else if (name === "copy-row" || name === "cut-row") {
const rows = activeRows(),
indices = new Set(selected());
if (["HeadMainDat", "CostMainDat", "MachMainDat"].includes(state.view))
for (const group of EstNative.groups(rows).values())
if (indices.has(group.start))
for (let i = group.start; i < group.start + group.rows.length; i++) indices.add(i);
clipboard = [...indices].sort((a, b) => a - b).map((i) => structuredClone(rows[i]));
if (!clipboard.length) throw Error("복사할 행을 선택하세요.");
if (name === "cut-row") deleteRows();
} else if (name === "paste-row") {
if (!clipboard.length) throw Error("복사한 행이 없습니다.");
const rows = activeRows();
EstApp.change(() => {
let at = (selected().at(-1) ?? rows.length - 1) + 1;
const grouped = ["HeadMainDat", "CostMainDat", "MachMainDat"].includes(state.view);
if (grouped && clipboard.some((r) => Number(r.Index) > 0))
for (const group of EstNative.groups(rows).values())
if (at > group.start && at <= group.start + group.rows.length) {
at = group.start + group.rows.length;
break;
}
const copies = structuredClone(clipboard);
let next = Math.max(0, ...rows.map((r) => Number(r.PkID) || 0));
let groupCode = "",
nextIndex = Math.max(0, ...rows.map((r) => Number(r.Index) || 0));
copies.forEach((r) => {
r.PkID = ++next;
r.NewItem = true;
if (grouped && Number(r.Index) > 0 && r.Code) {
r.Code = `${r.Code}_${next}`;
r.Index = ++nextIndex;
groupCode = r.Code;
}
if (grouped && groupCode) r.TitleCode = groupCode;
});
rows.splice(at, 0, ...copies);
});
} else if (name === "recalculate") {
if (project.options.AutoCalc === false)
throw Error("내역서 자동계산이 꺼져 있어 전체 재계산을 실행할 수 없습니다.");
EstApp.calculate();
EstApp.cache();
EstApp.render();
} else if (name === "find") EstApp.byId("search").focus();
else if (name === "level") {
if (!selected().length) throw Error("레벨을 바꿀 행을 선택하세요.");
const level = Number(node.dataset.level);
EstApp.change(() => selected().forEach((i) => (activeRows()[i].Level = level)));
} else if (name === "print") window.print();
else if (name === "csv") {
const rows = activeRows(),
keys = EstModel.columns(state.view);
const quote = (value) => `"${String(value ?? "").replaceAll('"', '""')}"`;
const csv = [
keys.map((key) => EstModel.labels[key] || key),
...rows.map((row) =>
keys.map((key) =>
typeof row[key] === "number"
? row[key]
: /^[=+\-@]/.test(String(row[key] || ""))
? `'${row[key]}`
: EstText.clean(row[key]),
),
),
]
.map((row) => row.map(quote).join(","))
.join("\r\n");
EstApp.download(
`${state.project.name}_${state.view}.csv`,
"\uFEFF" + csv,
"text/csv;charset=utf-8",
);
} else if (name === "projects") {
const projects = await api("/api/projects");
showDialog(
"저장한 작업",
projects.length
? projects
.map(
(p) =>
`<p><button data-action="load-project" data-id="${p.id}">${esc(p.name)}</button> <small>${esc(p.modified)}</small></p>`,
)
.join("")
: "저장된 작업이 없습니다.",
);
} else if (name === "load-project") {
if (confirm("현재 작업을 저장한 작업으로 바꾸시겠습니까?")) {
EstApp.setProject(await api(`/api/projects/${node.dataset.id}`));
EstApp.byId("dialog").close();
}
} else if (name === "parity") {
const result = await api("/api/parity");
showDialog(
"REA 샘플 대조",
`<p><b>${result.checked}개 중 ${result.passed}개 일치 · 불일치 ${result.failed}개</b></p><p>보관된 EST 샘플의 저장값과 계산 결과를 비교했습니다. 원본 실행 비교는 포함하지 않습니다.</p>${result.errors.length ? `<p>${esc(result.errors.join(" / "))}</p>` : ""}<table><thead><tr><th>영역</th><th>검사 수</th><th>일치</th></tr></thead><tbody>${[...new Set(result.results.map((r) => r.group))].map((group) => `<tr><td>${esc(group)}</td><td>${result.results.filter((r) => r.group === group).length}</td><td>${result.results.filter((r) => r.group === group && r.pass).length}</td></tr>`).join("")}</tbody></table>`,
);
} else if (name === "settings")
form(
"계산 환경 설정",
[
{
name: "head",
label: "일위대가 성분 소수자리",
type: "number",
value: project.options.HeadDigits || 0,
},
{
name: "estimate",
label: "내역 성분 소수자리",
type: "number",
value: project.options.EstDigits || 0,
},
{
name: "total",
value: project.options.TotalFix ? "1" : "0",
label: "도급액 고정",
choices: [
["0", "해제"],
["1", "원가 LinkIndex 55행 금액에 고정"],
],
},
{
name: "fit",
value: project.options.TotalFit ? "1" : "0",
label: "성분 합계 맞춤",
choices: [
["0", "해제"],
["1", "최대 나머지 배분"],
],
},
{
name: "dollar",
value: project.options.DollarCostCut ? "1" : "0",
label: "외화 원화환산 천원 절사",
choices: [
["0", "원 단위"],
["1", "천원 단위"],
],
},
{
name: "jdl",
value: project.options.JDLCalcMethod ? "1" : "0",
label: "운전사 중간금액 소수1자리 절사",
choices: [
["0", "해제"],
["1", "사용"],
],
},
],
(values) => {
if (![values.head, values.estimate].every((n) => Number.isInteger(n) && n >= 0 && n <= 6))
throw Error("소수 자릿수는 0~6 정수입니다.");
EstApp.change(() =>
Object.assign(project.options, {
HeadDigits: values.head,
EstDigits: values.estimate,
TotalFix: values.total === "1",
TotalFit: values.fit === "1",
DollarCostCut: values.dollar === "1",
JDLCalcMethod: values.jdl === "1",
}),
);
},
);
else if (name === "raw-line") {
const row = activeRows()[Number(node.dataset.row)];
showDialog(
"산출근거 편집",
`<p>품명·규격·단가·변수·결과·합계 표지를 짝으로 지정합니다.</p><div class="marker-tools">${["품명", "규격", "단위", "단가", "변수", "다른줄", "결과", "합계"].map((m) => `<button data-marker="${m}">${m}</button>`).join("")}</div><textarea id="line-editor" rows="10">${esc(EstText.display(row.LineData))}</textarea><button data-action="line-apply" data-row="${node.dataset.row}">적용</button>`,
);
} else if (name === "line-apply") {
const text = EstApp.byId("line-editor").value;
EstApp.change(() => (activeRows()[Number(node.dataset.row)].LineData = EstText.encode(text)));
EstApp.byId("dialog").close();
} else if (name === "template") {
const templates = project.wongaTemplates || [];
showDialog(
"원가 서식 선택",
`<p>현재 원가 표를 바꾸므로 작업을 저장한 뒤 선택하세요.</p>${templates.map((t, i) => `<button data-action="use-template" data-row="${i}">원본 서식 ${t.index}</button>`).join("")}<button data-action="basic-wonga">비목 기초 뼈대 만들기</button>`,
);
} else if (name === "use-template") {
if (confirm("현재 원가 표를 선택한 서식으로 바꾸시겠습니까?"))
EstApp.change(
() =>
(project.tables.Wonga = structuredClone(
project.wongaTemplates[Number(node.dataset.row)].rows,
)),
);
EstApp.byId("dialog").close();
} else if (name === "basic-wonga") {
if (confirm("현재 원가 표를 기본 뼈대로 바꾸시겠습니까?"))
EstApp.change(() => {
project.options.TotalFix = false;
project.tables.Wonga = [
{ Gubun: "N", Name: "직접 노무비", BaseField: "NC", Cost: 0 },
{ Gubun: "G", Name: "직접 재료비", BaseField: "GC", Cost: 0 },
{ Gubun: "J", Name: "직접 경비", BaseField: "JC", Cost: 0 },
{ Gubun: "S", Name: "표준시장단가", BaseField: "SC", Cost: 0 },
{ Gubun: "T", Name: "순공사비", VSusic: "N+G+J+S" },
{ Gubun: "R", Name: "도급액", VSusic: "T", LinkIndex: 55 },
];
});
EstApp.byId("dialog").close();
} else if (name === "capture-baseline") {
if (
confirm(
"현재 계산값을 당초·계약 내역으로 보존하시겠습니까? 기존 당초 사본이 있으면 교체됩니다.",
)
)
EstApp.change(() => (state.project = EstBusiness.baseline(project)));
} else if (name === "capture-bid") {
if (confirm("현재 단가를 입찰 당시 단가로 기록하시겠습니까?"))
EstApp.change(() =>
project.tables.EstMainDat.forEach((row) =>
EstMath.units.forEach((key) => (row[`A${key}`] = EstMath.num(row[key]))),
),
);
} else if (name === "recalculate-original")
form(
"당초분 재계산",
[
{
name: "method",
value: String(project.options.OldRecalcMethod || 0),
label: "계산 방법",
choices: [
["0", "신규공종만 계산"],
["1", "변경된 단가만"],
["2", "당초분 전체"],
],
},
],
(values) =>
EstApp.change(() => {
project.baseline = EstBusiness.original(project, Number(values.method));
project.options.OldRecalcMethod = Number(values.method);
}),
);
else if (name === "bid-rates")
form(
"비목별 낙찰률",
[
{ name: "N", label: "노무 %", type: "number", value: 100 },
{ name: "G", label: "재료 %", type: "number", value: 100 },
{ name: "J", label: "경비 %", type: "number", value: 100 },
{ name: "S", label: "실적 %", type: "number", value: 100 },
],
(values) => {
if (Object.values(values).some((n) => !Number.isFinite(n) || n < 0))
throw Error("낙찰률은 0 이상의 수치입니다.");
EstApp.change(
() =>
(state.project = EstBusiness.bid(project, [values.N, values.G, values.J, values.S])),
);
},
);
else if (name === "add-quantity")
EstApp.change(
() =>
project.quantities.push({
Code: `Q${project.quantities.length + 1}`,
Name: "새 수량",
TargetCode: "",
formula: "1",
digits: 2,
}),
false,
);
else if (name === "apply-quantity") {
const values = Object.create(null),
totals = new Map();
for (const row of project.quantities) {
const value = EstMath.floating(
EstExpression.evaluate(row.formula, (key) => values[key]),
row.digits ?? 2,
1,
);
values[row.Code] = value;
if (row.TargetCode) totals.set(row.TargetCode, (totals.get(row.TargetCode) || 0) + value);
}
if (!totals.size) throw Error("연결 내역코드를 입력하세요.");
if (confirm("연결된 내역 수량을 산출 결과로 바꾸시겠습니까?"))
EstApp.change(() => {
for (const row of project.tables.EstMainDat)
if (totals.has(row.Code)) {
row.GN = totals.get(row.Code);
row.MullangIndex = 1;
row.ExcelSheetIndex = 0;
row.MulCode = row.Code;
}
});
} else if (name === "add-round") {
const next = {
id: uid(),
name: `${project.progress.length + 1}회 기성`,
inputs: {},
approved: false,
locked: false,
};
EstApp.change(() => {
project.progress.push(next);
state.roundId = next.id;
}, false);
} else if (name === "lock-round") {
const round = project.progress.find((r) => r.id === state.roundId),
result = EstBusiness.progress(project, round.id);
if (result.errors.length) throw Error(result.errors.join("\n"));
if (confirm("이 회차를 확정하시겠습니까? 전회 누계로 보존되고 직접 편집이 잠깁니다."))
EstApp.change(() => {
round.result = result;
round.locked = true;
}, false);
} else if (name === "add-annual")
EstApp.change(
() =>
project.annual.push({ name: `${project.annual.length + 1}차`, formula: "[계약수량]" }),
false,
);
else if (name === "open-annual") {
const result = EstBusiness.annual(project);
if (result.errors.length) throw Error(result.errors.join("\n"));
if (confirm("현재 화면을 차수 내역 사본으로 바꾸시겠습니까?"))
EstApp.setProject(result.results[Number(node.dataset.row)].project);
} else if (name === "delete-extra") {
if (confirm("이 항목을 삭제하시겠습니까?"))
EstApp.change(
() => project[node.dataset.section].splice(Number(node.dataset.row), 1),
false,
);
} else if (name === "dialog-apply" && pending) {
await pending();
pending = null;
EstApp.byId("dialog").close();
} else if (name === "close-dialog") EstApp.byId("dialog").close();
}
function install() {
document.addEventListener("click", async (event) => {
try {
const marker = event.target.closest("[data-marker]");
if (marker) {
const editor = EstApp.byId("line-editor"),
start = editor.selectionStart,
end = editor.selectionEnd,
tag = `⟦${marker.dataset.marker}⟧`;
editor.setRangeText(tag + editor.value.slice(start, end) + tag, start, end, "select");
editor.focus();
return;
}
const view = event.target.closest("[data-view]");
if (view) {
EstApp.navigate(view.dataset.view);
return;
}
const group = event.target.closest("[data-group]");
if (group) {
EstApp.navigate(EstApp.state.view, group.dataset.group);
return;
}
const node = event.target.closest("[data-action]");
if (node) {
await action(node.dataset.action, node);
node.closest("details")?.removeAttribute("open");
}
} catch (error) {
EstApp.state.notice = error.message;
EstApp.render();
}
});
document.addEventListener("change", (event) => {
const node = event.target,
{ state } = EstApp,
project = state.project;
state.notice = "";
try {
if (node.matches("[data-select]")) {
const index = Number(node.dataset.select);
node.checked ? state.selection.add(index) : state.selection.delete(index);
EstApp.render();
return;
}
if (node.id === "project-name") {
EstApp.change(() => (project.name = node.value), false);
return;
}
if (node.id === "automatic" || node.id === "refresh-sources") {
EstApp.change(
() =>
(project.options[node.id === "automatic" ? "AutoCalc" : "SourceRefresh"] =
node.checked),
);
return;
}
if (node.id === "round-choice") {
state.roundId = node.value;
EstApp.render();
return;
}
if (node.id === "escalation-component") {
state.escalationComponent = node.value;
EstApp.render();
return;
}
if (node.id === "use-approved") {
const round = project.progress.find((r) => r.id === state.roundId);
if (round.locked) throw Error("확정 회차입니다.");
EstApp.change(() => (round.approved = node.checked), false);
return;
}
if (node.matches("[data-progress]")) {
const round = project.progress.find((r) => r.id === state.roundId);
if (round.locked) throw Error("확정 회차입니다.");
const value = node.type === "number" ? Number(node.value) : node.value;
if (node.type === "number" && !Number.isFinite(value)) throw Error("수치 입력 오류");
EstApp.change(() => {
round.inputs[node.dataset.key] ||= {};
round.inputs[node.dataset.key][node.dataset.progress] = value;
}, false);
return;
}
if (node.matches("[data-extra]")) {
const value = node.type === "number" ? Number(node.value) : node.value;
if (node.type === "number" && !Number.isFinite(value)) throw Error("수치 입력 오류");
EstApp.change(
() =>
(project[node.dataset.extra][Number(node.dataset.row)][node.dataset.field] = value),
false,
);
return;
}
if (node.matches("[data-table][data-field]")) {
const row = project.tables[node.dataset.table][Number(node.dataset.row)],
key = node.dataset.field;
const numeric = EstModel.numeric.has(key),
value = numeric
? node.value === ""
? null
: Number(node.value)
: key === "LineData"
? EstText.encode(node.value)
: node.value;
if (
numeric &&
value !== null &&
(!Number.isFinite(value) || Math.abs(value) > Number.MAX_SAFE_INTEGER)
)
throw Error("유효한 수치를 입력하세요.");
if (key === "Level" && (value < 0 || value > 9 || !Number.isInteger(value)))
throw Error("레벨은 0~9 정수입니다.");
if (key === "Precision" && (value < 0 || value > 6 || !Number.isInteger(value)))
throw Error("소수 자릿수는 0~6 정수입니다.");
EstApp.remember();
row[key] = value;
if (["StuffcDat", "LaborcDat", "EstiMateDat", "SilJukDat"].includes(node.dataset.table)) {
if (key === "TC") {
row.InputTC = value;
row.ValueIndex = 4;
}
if (["NC", "GC", "JC", "SC"].includes(key)) row[`Input${key}`] = value;
project.options.SourceRefresh = true;
}
if (project.options.AutoCalc !== false) EstApp.calculate();
EstApp.cache();
setTimeout(() => EstApp.render(), 0);
}
} catch (error) {
state.notice = error.message;
EstApp.render();
}
});
EstApp.byId("open-file").addEventListener("change", async (event) => {
try {
await EstApp.open(event.target.files[0]);
} catch (error) {
EstApp.state.notice = error.message;
EstApp.render();
} finally {
event.target.value = "";
}
});
EstApp.byId("search").addEventListener("input", (event) => {
EstApp.state.query = event.target.value;
EstApp.state.page = 0;
EstApp.render();
});
EstApp.byId("previous-page").addEventListener("click", () => {
EstApp.state.page--;
EstApp.render();
});
EstApp.byId("next-page").addEventListener("click", () => {
EstApp.state.page++;
EstApp.render();
});
document.addEventListener("keydown", (event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
event.preventDefault();
EstApp.save().catch((error) => {
EstApp.state.notice = error.message;
EstApp.render();
});
}
});
document.addEventListener("dblclick", (event) => {
const input = event.target.closest('[data-field="Code"]');
if (!input) return;
const row = EstApp.state.project.tables[input.dataset.table][Number(input.dataset.row)],
kind = Number(row.LinkIndex);
const table =
{ 103: "HeadMainDat", 104: "CostMainDat", 105: "MachMainDat" }[kind] ||
EstNative.sourceTables[kind];
if (table) {
EstApp.navigate(table, row.Code);
if (![103, 104, 105].includes(kind)) {
EstApp.state.query = row.Code;
EstApp.render();
}
}
});
}
return { install };
})();
+248
View File
@@ -0,0 +1,248 @@
const EstApp = (() => {
const CACHE = "est-web-v2-working";
const state = {
project: null,
view: "EstMainDat",
group: "",
selection: new Set(),
query: "",
page: 0,
history: [],
future: [],
notice: "",
business: null,
};
const titles = {
EstMainDat: "내역서",
HeadMainDat: "일위대가",
CostMainDat: "단가산출",
MachMainDat: "기계경비",
EstiMateDat: "견적단가",
SilJukDat: "표준시장단가",
StuffcDat: "자재단가",
LaborcDat: "노임단가",
MachGijunDat: "기계 기준표",
Wonga: "원가계산서",
quantity: "수량산출",
change: "설계변경",
progress: "기성고",
escalation: "물가변동",
annual: "연차공사",
resources: "동원 집계",
drawing: "도면·문서",
};
const esc = (value) =>
String(value ?? "").replace(
/[&<>"']/g,
(ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[ch],
);
const number = (value) =>
new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 4 }).format(Number(value) || 0);
const byId = (id) => document.getElementById(id);
async function api(url, options = {}) {
const response = await fetch(url, options),
data = await response.json();
if (!response.ok) throw Error(data.error || `요청 실패: ${response.status}`);
return data;
}
function cache() {
try {
sessionStorage.setItem(CACHE, JSON.stringify(state.project));
} catch {
state.notice = "임시 저장 용량이 부족합니다. 저장 버튼으로 작업을 보관하세요.";
}
}
function remember() {
const serialized = JSON.stringify(state.project);
if (serialized.length < 10 * 1024 * 1024) {
state.history.push(serialized);
if (state.history.length > 8) state.history.shift();
}
state.future = [];
}
function calculate() {
try {
state.project = EstNative.calculate(EstModel.validate(state.project));
} catch (error) {
state.notice = error.message;
}
}
function change(mutator, recalculate = true) {
remember();
mutator(state.project);
if (recalculate && state.project.options.AutoCalc !== false) calculate();
cache();
render();
}
function setProject(project) {
state.project = EstModel.legacy(project);
state.history = [];
state.future = [];
state.selection.clear();
state.group = "";
state.page = 0;
calculate();
cache();
render();
}
function navigate(view, group = "") {
state.view = view;
state.group = group;
state.selection.clear();
state.query = "";
state.page = 0;
state.business = null;
render();
}
function visibleRows() {
const rows = state.project.tables[state.view] || [];
let list = rows.map((row, index) => ({ row, index }));
if (["HeadMainDat", "CostMainDat", "MachMainDat"].includes(state.view)) {
const groups = EstNative.groups(rows);
if (!state.group || !groups.has(state.group)) state.group = groups.keys().next().value || "";
const group = groups.get(state.group);
list = group ? list.slice(group.start, group.start + group.rows.length) : [];
}
if (state.query) {
const q = state.query.toLowerCase();
list = list.filter(({ row }) =>
Object.values(row).some((v) => typeof v === "string" && v.toLowerCase().includes(q)),
);
}
return list;
}
function render() {
if (!state.project) return;
const active = document.activeElement,
scroll = byId("work-content")?.scrollLeft || 0;
const focus = active?.dataset.table
? { table: active.dataset.table, row: active.dataset.row, field: active.dataset.field }
: active?.id
? { id: active.id }
: null;
const caret =
active?.type === "text" || active?.tagName === "TEXTAREA" ? active.selectionStart : null;
byId("project-name").value = state.project.name;
byId("automatic").checked = state.project.options.AutoCalc !== false;
byId("refresh-sources").checked = Boolean(state.project.options.SourceRefresh);
byId("view-title").textContent = titles[state.view] || state.view;
document
.querySelectorAll("[data-view]")
.forEach((button) => button.classList.toggle("active", button.dataset.view === state.view));
const calculated = state.project.calculation || { direct: {}, total: 0, errors: [] };
byId("totals").innerHTML = [
["노무", calculated.direct?.NC],
["재료", calculated.direct?.GC],
["경비", calculated.direct?.JC],
["실적", calculated.direct?.SC],
["순공사비", calculated.direct?.TC],
["도급액", calculated.total],
]
.map(([label, n]) => `<span>${label} <b>${number(n)}</b></span>`)
.join("");
const errors = calculated.errors || [];
byId("messages").hidden = !errors.length && !state.notice;
byId("messages").textContent = state.notice || errors.join(" / ");
const rowCount =
state.project.tables[state.view]?.length ??
(state.view === "quantity"
? (state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet)?.items
.length ?? state.project.quantities.length)
: 0);
byId("status").textContent =
`${state.project.name} · ${titles[state.view]} · ${rowCount}행 · ${errors.length ? `계산 오류 ${errors.length}건` : "계산 오류 없음"}`;
EstViews.render(state);
byId("work-content").scrollLeft = scroll;
const next = focus?.table
? document.querySelector(
`[data-table="${focus.table}"][data-row="${focus.row}"][data-field="${focus.field}"]`,
)
: focus?.id
? byId(focus.id)
: null;
if (next) {
next.focus({ preventScroll: true });
if (caret !== null && next.setSelectionRange) next.setSelectionRange(caret, caret);
}
}
function download(name, text, type = "application/json") {
const a = document.createElement("a"),
blob = new Blob([text], { type });
a.href = URL.createObjectURL(blob);
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
async function save(asNew = false) {
if (asNew) delete state.project.id;
const result = await api("/api/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(state.project),
});
state.project = result;
cache();
state.notice = "저장했습니다. 이 앱 폴더의 data에도 작업이 보관됩니다.";
render();
download(`${state.project.name || "내역서"}.estweb.json`, JSON.stringify(result, null, 2));
}
async function open(file) {
if (!file) return;
if (
state.project &&
!confirm("현재 작업을 새 파일로 바꾸시겠습니까? 저장하지 않은 입력은 사라집니다.")
)
return;
let project;
if (/\.estx$/i.test(file.name))
project = await api(
`/api/import?name=${encodeURIComponent(file.name.replace(/\.estx$/i, ""))}`,
{ method: "POST", body: await file.arrayBuffer() },
);
else project = JSON.parse(await file.text());
setProject(project);
if (project.importWarnings?.length)
showDialog(
"ESTX 가져오기 확인",
`<ul>${project.importWarnings.map((w) => `<li>${esc(w)}</li>`).join("")}</ul>`,
);
}
function showDialog(title, html) {
byId("dialog-title").textContent = title;
byId("dialog-content").innerHTML = html;
byId("dialog").showModal();
}
async function initialize() {
try {
const saved = sessionStorage.getItem(CACHE);
setProject(saved ? JSON.parse(saved) : await api("/api/sample"));
EstActions.install();
EstQuantity.install();
} catch (error) {
byId("messages").hidden = false;
byId("messages").textContent = `시작 실패: ${error.message}`;
}
}
return {
state,
titles,
esc,
number,
byId,
api,
cache,
remember,
calculate,
change,
setProject,
navigate,
visibleRows,
render,
download,
save,
open,
showDialog,
initialize,
};
})();
document.addEventListener("DOMContentLoaded", () => EstApp.initialize());
+374
View File
@@ -0,0 +1,374 @@
// REA 전체점검 4~5: 변경 보존, 기성 3입력, 품목조정률, 연차 사본 계산.
const EstBusiness = (() => {
const { num, components, units, floating, sum } = EstMath;
const copy = (value) => structuredClone(value);
const rowKey = (row, index) => String(row.PkID ?? row.Code ?? index);
const special = (row) => [57, 58, 62, 67].includes(num(row.LinkIndex));
function baseline(project) {
const next = copy(project);
next.baseline = { tables: copy(project.tables), name: project.name };
for (const rows of Object.values(next.baseline.tables))
for (const row of rows) row.NewItem = false;
for (const rows of Object.values(next.tables))
for (const row of rows) {
row.NewItem = false;
for (const key of [
"Code",
"Name",
"Size",
"Unite",
"GN",
"TU",
"TC",
...units,
...components,
"LineData",
"VSusic",
"Cost",
])
if (Object.hasOwn(row, key)) row[`${key}_`] = copy(row[key]);
}
next.options.Mode = "설계변경";
return next;
}
function changes(project) {
const old = project.baseline?.tables.EstMainDat || [];
return project.tables.EstMainDat.map((row, index) => {
const original = old.find((r, i) => rowKey(r, i) === rowKey(row, index));
return {
key: rowKey(row, index),
Code: row.Code,
Name: row.Name,
GN: num(row.GN),
originalGN: num(original?.GN),
TC: num(row.TC),
originalTC: num(original?.TC),
difference: num(row.TC) - num(original?.TC),
NewItem: !original,
};
});
}
function bid(project, rates) {
const next = copy(project);
for (const key of ["HeadMainDat", "EstMainDat"])
for (const row of next.tables[key]) {
if (
num(row.Level) > 0 ||
num(row.Index) > 0 ||
[51, 52, 54, 55, 57, 58, 59, 62, 67, 68].includes(num(row.LinkIndex))
)
continue;
units.forEach((field, i) => {
row[`O${field}`] ??= num(row[field]);
row[field] = floating((num(row[`O${field}`]) * num(rates[i] ?? rates[0])) / 100);
});
row.TU = units.reduce((n, k) => n + num(row[k]), 0);
row.ApplyNacChal = 1;
}
next.options.SourceRefresh = false;
return EstNative.calculate(next);
}
function original(project, method = 0) {
if (!project.baseline) throw Error("당초 내역을 먼저 확정하세요.");
const old = {
...EstNative.empty(),
name: project.baseline.name,
tables: copy(project.baseline.tables),
options: {
...project.options,
RecalculateOriginal: true,
OldRecalcMethod: method,
SourceRefresh: true,
TotalFix: false,
},
};
for (const table of ["HeadMainDat", "CostMainDat", "EstMainDat"]) {
const rows = old.tables[table];
let previous = -1;
project.tables[table].forEach((row, index) => {
const existing = rows.findIndex((r, i) => rowKey(r, i) === rowKey(row, index));
if (existing >= 0) previous = existing;
else if (row.NewItem) rows.splice(++previous, 0, copy(row));
});
}
for (const table of [
"StuffcDat",
"LaborcDat",
"SilJukDat",
"EstiMateDat",
"MachGijunDat",
"MachMainDat",
])
old.tables[table] = copy(project.tables[table]);
const calculated = EstNative.calculate(old);
if (calculated.calculation.errors.length) throw Error(calculated.calculation.errors.join("\n"));
return { name: old.name, tables: calculated.tables };
}
function inputValue(row, input, prefix) {
const mode = input[`${prefix}Mode`] || "quantity",
value = num(input[prefix]);
if (mode === "amount") return num(row.TC) ? (value / num(row.TC)) * num(row.GN) : 0;
if (mode === "ratio")
return floating((value / 100) * num(row.GN), input.QuantityDigits ?? 4, 1);
return value;
}
function progress(project, roundId) {
const round = project.progress.find((r) => r.id === roundId);
if (!round) throw Error("기성 회차 없음");
const position = project.progress.indexOf(round),
rows = copy(project.tables.EstMainDat),
errors = [];
const previousRounds = project.progress.slice(0, position);
if (previousRounds.some((r) => !r.locked)) errors.push("전회 기성 회차를 먼저 확정하세요.");
const previous = previousRounds.at(-1)?.result?.rows || [];
const segment = { Gum: [], Sa: [] };
rows.forEach((row, index) => {
const key = rowKey(row, index),
input = round.inputs?.[key] || {};
const old = previous.find((r, i) => rowKey(r, i) === key);
row.JunGN = num(old?.TotalGN);
row.JunTC = num(old?.TotalTC);
row.JunRatio = num(old?.TotalRatio);
for (const k of components) row[`Jun${k}`] = num(old?.[`Total${k}`]);
if (num(row.Level) > 0) {
segment.Gum = [];
segment.Sa = [];
return;
}
for (const prefix of ["Gum", "Sa"]) {
const quantity = inputValue(row, input, prefix),
factor = special(row) ? quantity / 100 : quantity;
row[`${prefix}GN`] = quantity;
components.forEach(
(k, i) =>
(row[`${prefix}${k}`] = EstMath.fixed(
num(row[units[i]]) * factor,
input.AmountDigits || 0,
)),
);
if ([62, 67].includes(num(row.LinkIndex))) {
const percent = { ...row, GN: num(row.GN), CalcIndex: 0 };
const converted = segment[prefix].map((r) =>
Object.fromEntries(
[...components, "TC"]
.map((k) => [k, num(r[`${prefix}${k}`])])
.concat([["SKind", r.SKind]]),
),
);
EstRows.percent(percent, converted);
components.forEach((k) => (row[`${prefix}${k}`] = num(percent[k])));
}
row[`${prefix}TC`] = components.reduce((n, k) => n + num(row[`${prefix}${k}`]), 0);
row[`${prefix}Ratio`] = num(row.TC)
? floating((row[`${prefix}TC`] / num(row.TC)) * 100, 2, 1)
: 0;
segment[prefix].push(row);
}
const accepted = round.approved ? "Sa" : "Gum";
for (const k of ["GN", "TC", "Ratio", ...components])
row[`Total${k}`] = num(row[`Jun${k}`]) + num(row[`${accepted}${k}`]);
});
for (let i = rows.length - 1; i >= 0; i--) {
const row = rows[i];
if (num(row.Level) <= 0) continue;
const children = [];
for (
let j = i + 1;
j < rows.length &&
num(rows[j].Level) < num(row.Level) &&
![54, 59].includes(num(rows[j].LinkIndex));
j++
)
if (num(rows[j].Level) === 0 && num(rows[j].CalcIndex) < 2) children.push(rows[j]);
for (const prefix of ["Jun", "Gum", "Sa", "Total"])
for (const k of ["TC", ...components])
row[`${prefix}${k}`] = children.reduce((n, r) => n + num(r[`${prefix}${k}`]), 0);
}
const boundary = rows.findIndex((r) => num(r.LinkIndex) === 54);
const direct = rows
.slice(0, boundary < 0 ? rows.length : boundary)
.filter((r) => !num(r.Level) && num(r.CalcIndex) < 2);
const accepted = round.approved ? "Sa" : "Gum";
const current = direct.reduce((n, r) => n + num(r[`${accepted}TC`]), 0);
const contract = direct.reduce((n, r) => n + num(r.TC), 0),
ratio = contract ? current / contract : 0;
function costsFor(prefix) {
const part = direct.reduce((n, r) => n + num(r[`${prefix}TC`]), 0);
const proportion = contract ? part / contract : 0;
const bases = Object.fromEntries(
[...components, "TC"].map((key) => [
key,
direct.reduce((n, r) => n + num(r[`${prefix}${key}`]), 0),
]),
);
const material = project.options.GCToWonga
? bases.GC
: direct
.filter((r) => num(r.LinkIndex) === 5)
.reduce((n, r) => n + num(r[`${prefix}GC`]), 0);
const wonga = copy(project.tables.Wonga);
for (const row of wonga) {
if (row.Youl && !String(row.Name).replace(/\s/g, "").includes("부가")) {
row.Cost = Math.trunc(num(row.Cost) * proportion);
row.VSusic = "";
} else if (!row.VSusic && !row.BaseField && ![1, 3].includes(num(row.CalcIndex)))
row.Cost = Math.trunc(num(row.Cost) * proportion);
if (row.BaseField) {
row.Cost = num(bases[row.BaseField]);
row.VSusic = "";
} else if (!row.VSusic && !row.Youl && ![1, 3].includes(num(row.CalcIndex))) {
const name = EstNative.normalize(row.Name);
if (name.includes("직접재료")) row.Cost = material;
else if (name.includes("간접재료")) row.Cost = bases.GC - material;
else if (name.includes("직접노무")) row.Cost = bases.NC;
else if (name.includes("산출경비")) row.Cost = bases.JC;
}
}
const costs = EstWonga.calculate(wonga, { ...project.options, TotalFix: false }, bases);
errors.push(...costs.errors);
const byName = new Map(
costs.rows
.filter((r) => num(r.CalcIndex) < 2 && r.Gubun)
.map((r) => [EstNative.normalize(r.Name), r]),
);
for (const row of rows) {
if (num(row.LinkIndex) === 54) {
for (const key of [...components, "TC"]) row[`${prefix}${key}`] = bases[key];
} else if (
[55, 58].includes(num(row.LinkIndex)) &&
byName.has(EstNative.normalize(row.Name))
)
row[`${prefix}TC`] = num(byName.get(EstNative.normalize(row.Name)).Cost);
}
return costs;
}
const gumCosts = costsFor("Gum"),
saCosts = costsFor("Sa"),
costs = round.approved ? saCosts : gumCosts;
for (const row of rows)
if ([54, 55, 58].includes(num(row.LinkIndex))) {
for (const key of [...components, "TC"])
row[`Total${key}`] = num(row[`Jun${key}`]) + num(row[`${accepted}${key}`]);
}
return {
rows,
wonga: costs.rows,
total: costs.total || current,
ratio: floating(ratio * 100, 2, 1),
errors,
};
}
function escalation(project) {
const rows = copy(project.tables.EstMainDat),
errors = [];
const contractRows = project.baseline?.tables.EstMainDat || rows;
for (let index = 0; index < rows.length; index++) {
const row = rows[index],
old = contractRows.find((r, i) => rowKey(r, i) === rowKey(row, index)) || row;
if (num(row.Level) > 0 || [1, 3].includes(num(row.CalcIndex))) continue;
let total = 0;
units.forEach((unit, i) => {
const A = num(row[`A${unit}`]),
C = num(old[unit]),
B = num(row[unit]);
const D = A && B ? floating(((B - A) / A) * 100, 2) : 0;
let E = 0;
if (A && B && A >= C && B >= A) E = Math.trunc((C * D) / 100);
else if (A && B && A < C && B >= C) E = Math.trunc(B - C);
row[`D${unit}`] = D;
row[`E${unit}`] = E;
row[`F${unit}`] = C + E;
row[`E${components[i]}`] = Math.trunc(E * num(row.GN));
row[components[i]] = Math.trunc((C + E) * num(row.GN));
total += row[components[i]];
});
row.TC = total;
const excluded = Math.max(num(row.EscPlanned), num(row.EscActual), num(row.EscProgress));
row.EscExcluded = excluded;
row.EscApplied = num(old.GN) - excluded;
}
// 내역 재집계는 조정 단가를 사용한다.
for (const row of rows)
units.forEach((unit) => {
if (Object.hasOwn(row, `F${unit}`)) row[unit] = row[`F${unit}`];
});
const adjusted = EstRows.estimate(rows, { ...project.options, SourceRefresh: false });
function costCopy(estimateRows) {
const next = copy(project);
next.tables.EstMainDat = estimateRows;
next.options = { ...next.options, SourceRefresh: false, TotalFix: false };
return EstNative.calculate(next);
}
const adjustedProject = costCopy(adjusted.rows);
const contractProject = costCopy(copy(contractRows));
const excludedRows = copy(contractRows);
excludedRows.forEach((row, index) => {
if (num(row.Level) > 0 || [54, 55, 57, 58, 59, 62, 67].includes(num(row.LinkIndex))) return;
const current = rows.find((r, i) => rowKey(r, i) === rowKey(row, index));
row.GN = num(current?.EscExcluded);
});
const excludedProject = costCopy(excludedRows);
const contractTotal = num(contractProject.calculation.total);
const applied = contractTotal - num(excludedProject.calculation.total);
const rate = contractTotal
? floating(
((num(adjustedProject.calculation.total) - contractTotal) / contractTotal) * 100,
2,
)
: 0;
return {
rows: adjusted.rows,
direct: adjusted.direct,
wonga: adjustedProject.tables.Wonga,
contractTotal,
adjustedTotal: num(adjustedProject.calculation.total),
excludedTotal: num(excludedProject.calculation.total),
applied,
rate,
adjustment: Math.trunc((applied * rate) / 100),
errors: [
...new Set([
...errors,
...adjusted.errors,
...adjustedProject.calculation.errors,
...contractProject.calculation.errors,
...excludedProject.calculation.errors,
]),
],
};
}
function annual(project) {
const errors = [],
results = [];
for (const year of project.annual || []) {
const next = copy(project);
next.progress = [];
next.annual = [];
next.options.TotalFix = false;
for (let index = 0; index < next.tables.EstMainDat.length; index++) {
const row = next.tables.EstMainDat[index];
if (num(row.LinkIndex) === 57) {
row.CalcIndex = 3;
components.forEach((k) => (row[k] = 0));
row.TC = 0;
continue;
}
if (num(row.Level) > 0 || [54, 55, 58, 59, 62, 67].includes(num(row.LinkIndex))) continue;
try {
row.GN = EstExpression.evaluate(year.formula || "[계약수량]", (name) => {
if (name === "계약수량") return num(project.tables.EstMainDat[index].GN);
const previous = results.find((r) => r.name === name);
return previous ? num(previous.project.tables.EstMainDat[index]?.GN) : undefined;
});
} catch (error) {
errors.push(`${year.name}: ${error.message}`);
}
}
const calculated = EstNative.calculate(next);
errors.push(...calculated.calculation.errors);
results.push({ name: year.name, project: calculated });
}
return { results, errors };
}
return { baseline, changes, bid, original, progress, escalation, annual, rowKey };
})();
+286
View File
@@ -0,0 +1,286 @@
// 원본 테이블 이름과 필드를 유지하며 REA 전체 계산 순서로 실행한다.
const EstNative = (() => {
const { num, components, units, fixed, sum } = EstMath;
const sourceTables = {
5: "StuffcDat",
6: "LaborcDat",
21: "SilJukDat",
101: "EstiMateDat",
103: "HeadTitleDat",
104: "CostTitleDat",
105: "MachTitleDat",
};
const normalize = (text) =>
String(text || "")
.normalize("NFKC")
.toLowerCase()
.replace(/[\s()[\]]/g, "");
const allTables = [
"StuffcDat",
"LaborcDat",
"SilJukDat",
"EstiMateDat",
"MachGijunDat",
"MachMainDat",
"MachTitleDat",
"CostMainDat",
"CostTitleDat",
"HeadMainDat",
"HeadTitleDat",
"EstMainDat",
"Wonga",
"MulMainDat",
"MulTitleDat",
];
const clone = (value) => structuredClone(value);
function price(row, kind) {
const candidates = Array.from({ length: 6 }, (_, i) => num(row[`Cost${i + 1}`])).filter(
(value) => value !== 0,
);
let total;
switch (num(row.ValueIndex)) {
case 0:
total = candidates.length ? Math.min(...candidates) : 0;
break;
case 1:
total = candidates.length
? Math.trunc(candidates.reduce((a, b) => a + b, 0) / candidates.length)
: 0;
break;
case 2:
total = candidates.length ? Math.max(...candidates) : 0;
break;
case 3:
total = num(row[`Cost${num(row.SelectField) || 1}`]);
break;
default:
total = num(row.InputTC ?? row.TC);
row.InputTC = total;
}
const original = components.map((key) => num(row[`Input${key}`] ?? row[key]));
if ([21, 101].includes(kind))
components.forEach((key, i) => (row[`Input${key}`] = original[i]));
const originalTotal = original.reduce((a, b) => a + b, 0);
const rate =
(1 + num(row.Ratio) / 100) * (num(row.NachalRatio) ? num(row.NachalRatio) / 100 : 1);
const precision = EstMath.precision(row);
const adjust = (value) =>
rate === 1 && !num(row.DigetIndex)
? value
: fixed(value * rate, precision.digits, precision.method);
components.forEach(
(key, i) => (row[key] = [21, 101].includes(kind) && originalTotal ? adjust(original[i]) : 0),
);
if (![21, 101].includes(kind) || !originalTotal)
row[kind === 6 ? "NC" : kind === 21 ? "SC" : kind === 101 ? "JC" : "GC"] = adjust(total);
row.TC = sum(row);
return row;
}
function groups(rows) {
const result = new Map();
let current = null;
rows.forEach((row, index) => {
if (num(row.Index) > 0) {
current = { start: index, rows: [], code: row.Code };
result.set(row.Code, current);
}
if (current) current.rows.push(row);
});
return result;
}
function calculate(input) {
const project = clone(input),
tables = project.tables,
options = project.options || {},
errors = [],
warnings = [];
for (const key of allTables) tables[key] ||= [];
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;
errors.push(...machine.errors);
const costs = groups(tables.CostMainDat),
heads = groups(tables.HeadMainDat),
completed = new Map(),
active = new Set();
const sourceIndexes = new Map();
for (const [kind, table] of Object.entries(sourceTables)) {
const codes = new Map(),
names = new Map();
for (const row of tables[table] || []) {
if (row.Code && !codes.has(row.Code)) codes.set(row.Code, row);
const key = normalize(row.Name);
if (!names.has(key)) names.set(key, []);
names.get(key).push(row);
}
sourceIndexes.set(num(kind), { codes, names });
}
function find(row, kind) {
const index = sourceIndexes.get(kind);
let source = row.Code ? index?.codes.get(row.Code) : null;
if (!source && row.Name)
source = (index?.names.get(normalize(row.Name)) || []).find(
(r) =>
normalize(r.Name) === normalize(row.Name) &&
(!row.Size || normalize(r.Size) === normalize(row.Size)) &&
(!row.Unite || normalize(r.Unite) === normalize(row.Unite)),
);
return source;
}
function calculateGroup(kind, code) {
const id = `${kind}:${code}`;
if (completed.has(id)) return completed.get(id);
if (active.has(id)) throw Error(`순환 참조: ${id}`);
const group = (kind === 103 ? heads : costs).get(code);
if (!group) throw Error(`하위 자료 없음: ${id}`);
active.add(id);
const result =
kind === 103
? EstRows.head(group.rows, options, resolve)
: EstText.calculate(group.rows, options, options.SourceRefresh ? resolve : () => null);
const title = {
...tables[sourceTables[kind]].find((row) => row.Code === code),
...result.titles[0],
};
completed.set(id, title);
active.delete(id);
errors.push(...result.errors);
const main = kind === 103 ? "HeadMainDat" : "CostMainDat";
tables[main].splice(group.start, group.rows.length, ...result.rows);
return title;
}
function resolve(row) {
const kind = num(row.LinkIndex);
if (!sourceTables[kind]) return null;
if (options.RecalculateOriginal && num(options.OldRecalcMethod) === 0 && !row.NewItem)
return null;
let source = find(row, kind);
if ([103, 104].includes(kind)) {
const code = source?.Code || row.Code;
if (code) source = calculateGroup(kind, code);
}
if (!source) throw Error(`연결 자료 없음 (${kind}): ${row.Code || row.Name || ""}`);
const result = Object.fromEntries(components.map((key) => [key, num(source[key])]));
if (
options.RecalculateOriginal &&
num(options.OldRecalcMethod) === 1 &&
components.every((key, i) => result[key] === num(row[EstMath.units[i]]))
)
return null;
result.TC = sum(result);
Object.assign(result, {
Code: source.Code,
Name: source.Name,
Size: source.Size,
Unite: source.Unite,
SourceKind: kind,
});
if ([1, 2, 3].includes(num(row.SCheck))) {
const key = { 1: "GC", 2: "NC", 3: "JC" }[num(row.SCheck)];
components.forEach((k) => (result[k] = k === key ? result.TC : 0));
}
return result;
}
for (const [code] of costs) {
try {
calculateGroup(104, code);
} catch (error) {
errors.push(error.message);
}
}
tables.CostTitleDat = [...costs.keys()]
.map((code) => completed.get(`104:${code}`))
.filter(Boolean);
for (const [code] of heads) {
try {
calculateGroup(103, code);
} catch (error) {
errors.push(error.message);
}
}
tables.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);
const direct = estimate.direct;
const boundary = tables.EstMainDat.findIndex((row) => num(row.LinkIndex) === 54);
const leaves = tables.EstMainDat.slice(
0,
boundary < 0 ? tables.EstMainDat.length : boundary,
).filter((row) => !num(row.Level) && num(row.CalcIndex) < 2);
const directMaterial = options.GCToWonga
? num(direct.GC)
: leaves.filter((row) => num(row.LinkIndex) === 5).reduce((n, row) => n + num(row.GC), 0);
const governmentHeading = tables.EstMainDat.findIndex(
(row, i) => i > boundary && num(row.Level) > 0 && normalize(row.Name).includes("관급"),
);
let government = null;
if (governmentHeading >= 0) {
government = 0;
const level = num(tables.EstMainDat[governmentHeading].Level);
for (
let i = governmentHeading + 1;
i < tables.EstMainDat.length && num(tables.EstMainDat[i].Level) < level;
i++
)
if (!num(tables.EstMainDat[i].Level)) government += num(tables.EstMainDat[i].TC);
}
if (options.AutoWongaBase !== false)
for (const row of tables.Wonga) {
if (row.VSusic || [1, 3].includes(num(row.CalcIndex)) || row.BaseField) continue;
const name = normalize(row.Name);
if (name.includes("직접재료")) row.Cost = directMaterial;
else if (name.includes("간접재료")) row.Cost = num(direct.GC) - directMaterial;
else if (name.includes("직접노무")) row.Cost = num(direct.NC);
else if (name.includes("산출경비")) row.Cost = num(direct.JC);
else if (name.includes("관급자재") && government !== null) row.Cost = government;
}
const wonga = EstWonga.calculate(tables.Wonga, options, {
NC: num(direct.NC),
GC: num(direct.GC),
JC: num(direct.JC),
SC: num(direct.SC),
TC: num(direct.TC),
노무비: num(direct.NC),
재료비: num(direct.GC),
경비: num(direct.JC),
실적: num(direct.SC),
직접공사비: num(direct.TC),
});
tables.Wonga = wonga.rows;
errors.push(...wonga.errors);
const wongaNames = new Map(
wonga.rows
.filter((row) => num(row.CalcIndex) < 2 && row.Gubun)
.map((row) => [normalize(row.Name), row]),
);
for (const row of tables.EstMainDat)
if ([55, 58].includes(num(row.LinkIndex)) && wongaNames.has(normalize(row.Name)))
row.TC = num(wongaNames.get(normalize(row.Name)).Cost);
project.calculation = {
errors: [...new Set(errors)],
warnings,
direct,
total: wonga.total,
wongaAdjustments: wonga.adjustments,
};
return project;
}
function empty() {
return {
schema: "est-web-2",
name: "새 내역서",
tables: Object.fromEntries(allTables.map((key) => [key, []])),
options: { SourceRefresh: true, VerFormat: true, TotalFix: false },
quantities: [],
progress: [],
annual: [],
drawings: [],
};
}
return { calculate, price, groups, empty, allTables, sourceTables, normalize };
})();
+280
View File
@@ -0,0 +1,280 @@
const EstExtra = (() => {
function field(section, index, key, value, type = "number") {
return `<input data-extra="${section}" data-row="${index}" data-field="${key}" type="${type}" step="any" value="${EstApp.esc(value)}" aria-label="${EstApp.esc(key)}">`;
}
function button(action, text, extra = "") {
return `<button data-action="${action}" ${extra}>${text}</button>`;
}
function table(headers, rows) {
return `<table class="sheet"><thead><tr>${headers.map((h) => `<th>${h}</th>`).join("")}</tr></thead><tbody>${rows.map((row) => `<tr>${row.map((cell) => `<td>${cell}</td>`).join("")}</tr>`).join("")}</tbody></table>`;
}
function box(title, tools, body, note = "") {
return `<div class="business-tools"><b>${title}</b>${tools}</div>${body}${note ? `<p class="business-note">${note}</p>` : ""}`;
}
function render(state) {
const { project, view } = state,
{ esc, number, byId } = EstApp;
let html = "";
if (view === "quantity") {
if (project.quantitySheets?.length && state.quantitySheet !== "manual") {
EstQuantity.render(state);
return;
}
const values = Object.create(null),
rows = project.quantities.map((row, index) => {
let result,
error = "";
try {
result = EstMath.floating(
EstExpression.evaluate(row.formula || "0", (key) => values[key]),
row.digits ?? 2,
1,
);
values[row.Code] = result;
} catch (e) {
error = e.message;
}
return [
field("quantities", index, "Code", row.Code, "text"),
field("quantities", index, "Name", row.Name, "text"),
field("quantities", index, "TargetCode", row.TargetCode, "text"),
field("quantities", index, "formula", row.formula, "text"),
field("quantities", index, "digits", row.digits ?? 2),
esc(error || number(result)),
button("delete-extra", "삭제", `data-section="quantities" data-row="${index}"`),
];
});
html =
EstQuantity.selector(state) +
box(
"수량산출서",
button("add-quantity", "산출 항목 추가") + button("apply-quantity", "내역에 수량 반영"),
table(["산출코드", "품명", "연결 내역코드", "수식", "소수자리", "결과", ""], rows),
"앞선 산출 항목을 [산출코드]로 참조합니다. 내역 반영은 연결된 코드의 산출값을 합산하여 수량에 반영합니다.",
);
} else if (view === "change") {
const changes = EstBusiness.changes(project);
html = box(
"설계변경",
button("capture-baseline", "현재 내역을 당초로 확정") +
button("recalculate-original", "당초 단가 재계산") +
button("bid-rates", "낙찰률 적용"),
table(
["코드", "품명", "당초 수량", "변경 수량", "당초 금액", "변경 금액", "증감"],
changes
.filter((row) => row.Code)
.map((row) => [
esc(row.Code),
esc(row.Name),
number(row.originalGN),
number(row.GN),
number(row.originalTC),
number(row.TC),
number(row.difference),
]),
),
project.baseline
? `당초: ${esc(project.baseline.name)}. 당초 자료는 별도 사본으로 보존합니다.`
: "현재 작업을 저장한 뒤 당초 내역을 확정하세요.",
);
} else if (view === "progress") {
state.roundId ||= project.progress[0]?.id;
const round = project.progress.find((r) => r.id === state.roundId);
let body = '<p class="empty">회차를 추가하세요.</p>',
tools = button("add-round", "기성 회차 추가");
if (round) {
tools +=
`<select id="round-choice">${project.progress.map((r) => `<option value="${esc(r.id)}" ${r.id === round.id ? "selected" : ""}>${esc(r.name)}${r.locked ? " (확정)" : ""}</option>`).join("")}</select>` +
button("lock-round", "회차 확정") +
`<label><input id="use-approved" type="checkbox" ${round.approved ? "checked" : ""} ${round.locked ? "disabled" : ""}> 누계에 사정 사용</label>`;
let output;
try {
output =
round.locked && round.result ? round.result : EstBusiness.progress(project, round.id);
} catch (e) {
output = { rows: [], errors: [e.message] };
}
state.business = output;
body =
table(
[
"코드",
"공종",
"도급수량",
"전회수량",
"금회 입력",
"입력방법",
"사정 입력",
"입력방법",
"금회금액",
"사정금액",
"누계수량",
"누계금액",
],
output.rows
.filter((r) => r.Code && !EstMath.num(r.Level))
.map((row, index) => {
const originalIndex = project.tables.EstMainDat.findIndex(
(r) => r.PkID === row.PkID && r.Code === row.Code,
),
key = EstBusiness.rowKey(row, originalIndex),
input = round.inputs?.[key] || {};
const edit = (prefix) =>
`<input data-progress="${prefix}" data-key="${esc(key)}" type="number" step="any" value="${input[prefix] ?? 0}" ${round.locked ? "disabled" : ""}>`;
const method = (prefix) =>
`<select data-progress="${prefix}Mode" data-key="${esc(key)}" ${round.locked ? "disabled" : ""}>${[
["quantity", "수량"],
["amount", "금액"],
["ratio", "비율 %"],
]
.map(
([v, label]) =>
`<option value="${v}" ${v === (input[`${prefix}Mode`] || "quantity") ? "selected" : ""}>${label}</option>`,
)
.join("")}</select>`;
return [
esc(row.Code),
esc(row.Name),
number(row.GN),
number(row.JunGN),
edit("Gum"),
method("Gum"),
edit("Sa"),
method("Sa"),
number(row.GumTC),
number(row.SaTC),
number(row.TotalGN),
number(row.TotalTC),
];
}),
) +
`<p class="business-note">${esc((output.errors || []).join(" / "))} 금회 도급액 ${number(output.total)} · 기성비율 ${number(output.ratio)}%</p>`;
}
html = box(
"기성고",
tools,
body,
"전회는 직전 확정 회차 누계를 읽습니다. 금회·사정은 수량·금액·비율 중 하나를 입력해 계산합니다.",
);
} else if (view === "escalation") {
const output = EstBusiness.escalation(project);
state.business = output;
const comp = state.escalationComponent || "NU";
const choices = EstMath.units
.map(
(key, i) =>
`<option value="${key}" ${key === comp ? "selected" : ""}>${["노무", "재료", "경비", "실적"][i]}</option>`,
)
.join("");
const rows = project.tables.EstMainDat.map((row, index) => ({ row, index }))
.filter(({ row }) => row.Code && !EstMath.num(row.Level))
.map(({ row, index }) => {
const old =
project.baseline?.tables.EstMainDat.find(
(r) => r.PkID === row.PkID && r.Code === row.Code,
) || row,
result = output.rows[index];
return [
esc(row.Code),
esc(row.Name),
EstViews.cell("EstMainDat", index, `A${comp}`, row[`A${comp}`]),
number(old[comp]),
number(row[comp]),
number(result[`D${comp}`]),
number(result[`E${comp}`]),
number(result[`F${comp}`]),
EstViews.cell("EstMainDat", index, "EscPlanned", row.EscPlanned || 0),
EstViews.cell("EstMainDat", index, "EscActual", row.EscActual || 0),
EstViews.cell("EstMainDat", index, "EscProgress", row.EscProgress || 0),
number(result.EscExcluded),
number(result.EscApplied),
];
});
html = box(
"물가변동 품목조정",
`<select id="escalation-component">${choices}</select>` +
button("capture-bid", "현재 단가를 입찰단가로 기록") +
button("capture-baseline", "계약 내역 확정"),
table(
[
"코드",
"품명",
"입찰 당시",
"계약 단가",
"변동 당시",
"등락률 %",
"등락폭",
"조정단가",
"예정공정수량",
"실행공정수량",
"기성수량",
"적용제외",
"적용수량",
],
rows,
),
`계약 도급액 ${number(output.contractTotal)} · 조정 도급액 ${number(output.adjustedTotal)} · 적용제외(간접비 포함) ${number(output.excludedTotal)} · 적용대가 ${number(output.applied)} · 조정률 ${number(output.rate)}% · 조정금액 ${number(output.adjustment)}. ${esc(output.errors.join(" / "))}`,
);
} else if (view === "annual") {
const output = EstBusiness.annual(project);
state.business = output;
html = box(
"연차공사",
button("add-annual", "차수 추가"),
table(
["차수", "수량 배분 수식", "순공사비", "도급액", "작업", ""],
project.annual.map((year, index) => [
field("annual", index, "name", year.name, "text"),
field("annual", index, "formula", year.formula, "text"),
number(output.results[index]?.project.calculation.direct.TC),
number(output.results[index]?.project.calculation.total),
button("open-annual", "차수 내역 열기", `data-row="${index}"`),
button("delete-extra", "삭제", `data-section="annual" data-row="${index}"`),
]),
),
`수식 예: [계약수량]*0.5, [계약수량]-[1차]. ${esc(output.errors.join(" / "))}`,
);
} else if (view === "resources") {
const output = EstResources.calculate(project);
html = box(
"동원인원·자재·장비",
"",
table(
["종류", "코드", "품명", "규격", "단위", "소요량 / 환산시간", "금액"],
output.rows.map((row) => [
esc({ 5: "자재", 6: "노임", 21: "표준", 101: "견적", 105: "기계" }[row.kind]),
esc(row.Code),
esc(row.Name),
esc(row.Size),
esc(row.Unite),
number(row.quantity),
number(row.amount),
]),
),
esc(output.errors.join(" / ")),
);
} else if (view === "drawing") {
const rows = project.tables.CadMainDat || [];
html = box(
"원본 도면·문서 자료",
"",
table(
["번호", "코드", "이름", "보존 필드"],
rows.map((row, index) => [
index + 1,
esc(row.Code),
esc(row.Name),
esc(
Object.keys(row)
.filter((key) => row[key]?.binary)
.join(", "),
),
]),
),
"가져온 도면 바이너리는 프로젝트에 보존됩니다. VEC0650/CAD 편집·출력의 재현 근거는 현재 자료에 없어 동작을 구현하지 않았습니다.",
);
}
byId("work-content").innerHTML = html;
}
return { render };
})();
+111
View File
@@ -0,0 +1,111 @@
// 수식은 실행 가능한 JavaScript로 변환하지 않고 토큰과 구문 트리로 평가한다.
const EstExpression = (() => {
function evaluate(input, resolve = () => undefined, settings = {}) {
const text = String(input)
.replace(/×|·/g, "*")
.replace(/÷/g, "/")
.replace(/−|-/g, "-")
.replace(/+/g, "+")
.replace(/%/g, "%")
.replace(/√\s*\(/g, "SQRT(")
.replace(/π/g, "PI")
.replace(/(?<=\d),(?=\d{3}(?:\D|$))/g, "");
const tokens = [];
const pattern =
/\s+|(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?|\[[^\]]+\]|[A-Za-z_가-힣][A-Za-z_0-9가-힣.]*|[+*/^%(),:~=-]/y;
let at = 0;
while (at < text.length) {
pattern.lastIndex = at;
const match = pattern.exec(text);
if (!match) throw Error(`수식 문자 오류 (${at + 1}): ${text.slice(at, at + 12)}`);
if (match[0].trim()) tokens.push(match[0]);
at = pattern.lastIndex;
}
let position = 0;
const functions = {
ABS: Math.abs,
SQRT: Math.sqrt,
MIN: Math.min,
MAX: Math.max,
ROUND: (n, d = 0) => EstMath.floating(n, d, 1),
TRUNC: (n, d = 0) => EstMath.floating(n, d, 2),
FLOOR: Math.floor,
CEIL: Math.ceil,
SUM: (...args) => args.reduce((a, b) => a + b, 0),
};
function value(token, force = false) {
const key = token.startsWith("[") ? token.slice(1, -1) : token;
const resolved = resolve(key);
const numeric = /^(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/.test(key);
if (resolved !== undefined && (!numeric || force || settings.numericReferences))
return Number(resolved);
if (numeric && !force) return Number(key);
if (key === "PI") return Math.PI;
throw Error(`참조 항목 없음: ${key}`);
}
function atom() {
const token = tokens[position++];
if (token === undefined) throw Error("수식이 끝나지 않았습니다.");
if (token === "+" || token === "-") return (token === "-" ? -1 : 1) * atom();
let n;
if (token === "(") {
n = sum();
if (tokens[position++] !== ")") throw Error("괄호가 맞지 않습니다.");
} else if (functions[token.toUpperCase()] && tokens[position] === "(") {
position++;
const args = [];
if (tokens[position] !== ")") {
args.push(sum());
while (tokens[position] === ",") {
position++;
args.push(sum());
}
}
if (tokens[position++] !== ")") throw Error("함수 괄호 오류");
n = functions[token.toUpperCase()](...args);
} else if ([":", "~"].includes(tokens[position])) {
position++;
const end = tokens[position++];
if (!settings.range) throw Error("범위 참조를 사용할 수 없습니다.");
n = settings.range(token.replace(/^\[|\]$/g, ""), end?.replace(/^\[|\]$/g, ""));
} else n = value(token, token.startsWith("["));
while (tokens[position] === "%") {
position++;
n /= 100;
}
return n;
}
function power() {
const n = atom();
if (tokens[position] === "^") {
position++;
return n ** power();
}
return n;
}
function product() {
let n = power();
while (["*", "/"].includes(tokens[position])) {
const op = tokens[position++],
v = power();
if (op === "/" && v === 0) throw Error("0으로 나눌 수 없습니다.");
n = op === "*" ? n * v : n / v;
}
return n;
}
function sum() {
let n = product();
while (["+", "-"].includes(tokens[position])) {
const op = tokens[position++],
v = product();
n = op === "+" ? n + v : n - v;
}
return n;
}
const result = sum();
if (position !== tokens.length || !Number.isFinite(result))
throw Error("수식 결과가 유효하지 않습니다.");
return result;
}
return { evaluate };
})();
+133
View File
@@ -0,0 +1,133 @@
// REA f18804 → f16638 / f15868 / f15f40 / f16320 → f16ec8.
const EstMachine = (() => {
const { num, components, fixed, floating, even, sum } = EstMath;
const fuels = new Set([
"주연료",
"선박용경유",
"전력",
"경유",
"휘발유",
"중유",
"전",
"가스",
"가솔린",
"디젤",
"diesel",
"gasoline",
"heavyoil",
"lpg",
"l.p.g",
]);
function calculate(rows, criteria, options = {}) {
const settings = new Map(criteria.map((row) => [num(row.Index), num(row.TC)]));
const get = (index) => settings.get(index) ?? 0;
const output = structuredClone(rows),
titles = [],
errors = [];
let head = null,
body = [];
function finish() {
if (!head) return;
components.forEach(
(key) =>
(head[key] = floating(
body.filter((r) => num(r.CalcIndex) < 2).reduce((n, r) => n + num(r[key]), 0),
)),
);
head.TC = sum(head);
titles.push({
...head,
OTC: head.TC,
ONC: head.NC,
OGC: head.GC,
OJC: head.JC,
OSC: head.SC,
});
}
for (const row of output) {
const index = num(row.Index),
link = num(row.LinkIndex);
if (index > 0 && link !== 57) {
finish();
head = row;
body = [];
continue;
}
if (!head || row.Index == null) continue;
if (link === 52) {
finish();
const title = titles[titles.length - 1],
rate = num(row.GN) / 100;
components.forEach((key) => (head[key] = floating(num(title[key]) * rate)));
head.TC = sum(head);
Object.assign(title, head, { NachalRatio: num(row.GN) });
row.TU = title.OTC;
head = null;
body = [];
continue;
}
if (![0, 2].includes(num(row.CalcIndex))) {
body.push(row);
continue;
}
components.forEach((key) => (row[key] = 0));
const q = num(row.GN) / (["%", "%"].includes(row.Unite) ? 100 : 1),
rate = 1 + num(row.Ratio) / 100;
const digits = EstMath.even(get(index));
let value = 0,
field = "JC";
if (index === -3) {
const currency = String(row.Unite || "")[0];
if (currency === "\\") value = num(row.TU) * q * 1e-7;
else if ("$PEFLMY".includes(currency || "\u0000")) {
let exchange = get(currency.charCodeAt(0));
if (currency === "L" && !settings.has(76)) exchange = get(70);
if (currency === "Y") exchange /= 100;
if (!exchange) errors.push(`${head.Name}: 환율 없음 (${currency})`);
const cut = options.DollarCostCut ? 1000 : 1;
// f1580c receives two Currency integers; binary float multiplication can
// turn an exact thousand (226644000) into the previous thousand.
const converted =
Number(
(BigInt(even(num(row.TU) * 10000)) * BigInt(even(exchange * 10000))) /
BigInt(cut * 100000000),
) * cut;
value = converted * q * 1e-7;
} else value = num(row.TU) * q;
value = fixed((even(value * 10000) / 10000) * rate, digits);
} else if (index === -2) {
let unitPrice = num(row.TU),
quantity = q;
if (String(row.Name || "").includes("잡")) {
const fuel = [...body].reverse().find(
(r) =>
num(r.Index) === -2 &&
fuels.has(
String(r.Name || "")
.replace(/\s/g, "")
.toLowerCase(),
),
);
unitPrice = fuel ? num(fuel.GC) || (num(fuel.SCheck) === 3 ? num(fuel.JC) : 0) : 0;
row.TU = unitPrice;
row.Unite = "%";
quantity = num(row.GN) / 100;
}
value = fixed(even(even(unitPrice * quantity * 10000) * rate) / 10000, digits);
field = num(row.SCheck) === 3 ? "JC" : "GC";
} else if (index === -1) {
const coefficient = Math.trunc(get(num(row.NCGasu)) * 1e10) / 1e10;
let price = num(row.TU) * coefficient;
if (options.JDLCalcMethod) price = floating(price, 1);
value = fixed(even(even(price * q * 10000) * rate) / 10000, digits);
field = "NC";
}
row[field] = value;
row.TC = sum(row);
body.push(row);
}
finish();
return { rows: output, titles, errors };
}
return { calculate };
})();
+73
View File
@@ -0,0 +1,73 @@
// REA: 932984 (10^4 정수), 93281c (실수), 4037e8 (FPU Round).
const EstMath = (() => {
const components = ["NC", "GC", "JC", "SC"];
const units = ["NU", "GU", "JU", "SU"];
const num = (value) => (value == null || value === "" ? 0 : Number(value));
const sum = (row) => components.reduce((n, key) => n + num(row[key]), 0);
function even(value) {
const sign = Math.sign(value),
a = Math.abs(value),
i = Math.floor(a),
f = a - i;
return sign * (f > 0.5 ? i + 1 : f < 0.5 ? i : i % 2 ? i + 1 : i);
}
function half(value) {
return Math.trunc(value) + Math.trunc((value - Math.trunc(value)) * 2);
}
function mode(value, method) {
if (method === 1) return half(value);
if (method === 3) return half(value + 0.4);
return Math.trunc(value);
}
function fixed(value, digits = 0, method = 2) {
const v = even(value * 10000),
k = 10 ** digits;
return mode((v * k) / 10000, method) / k;
}
function floating(value, digits = 0, method = 2) {
const k = 10 ** digits;
return mode((value + Math.sign(value) * 2e-10) * k, method) / k;
}
function precision(row, fallback = 0) {
return num(row.DigetIndex)
? { digits: Math.max(0, num(row.Diget) - 1), method: num(row.DigetIndex) }
: { digits: fallback, method: 2 };
}
function amounts(row, digits = 0, totalFit = false) {
if ([1, 3].includes(num(row.CalcIndex))) return row;
const q = num(row.GN) / (["%", "%"].includes(row.Unite) ? 100 : 1);
const p = precision(row, digits),
raw = units.map((key) => num(row[key]) * q);
components.forEach((key, i) => (row[key] = fixed(raw[i], p.digits, p.method)));
row.TU = units.reduce((n, key) => n + num(row[key]), 0);
if (totalFit) {
const step = 10 ** -p.digits,
target = fixed(
raw.reduce((a, b) => a + b, 0),
p.digits,
p.method,
);
let difference = Math.round((target - sum(row)) / step);
const order = components
.map((key, i) => ({ key, remainder: raw[i] - row[key] }))
.sort((a, b) => (difference >= 0 ? b.remainder - a.remainder : a.remainder - b.remainder));
for (let i = 0; difference && i < 100; i++) {
row[order[i % 4].key] += Math.sign(difference) * step;
difference -= Math.sign(difference);
}
}
row.TC = sum(row);
return row;
}
function add(rows, predicate = (row) => num(row.CalcIndex) < 2) {
const result = Object.fromEntries(
components.map((key) => [
key,
floating(rows.filter(predicate).reduce((n, row) => n + num(row[key]), 0)),
]),
);
result.TC = sum(result);
return result;
}
return { components, units, num, sum, even, half, fixed, floating, precision, amounts, add };
})();
+385
View File
@@ -0,0 +1,385 @@
const EstModel = (() => {
const labels = {
Index: "구분",
Code: "코드",
Name: "품명",
Size: "규격",
Unite: "단위",
GN: "수량",
TU: "단가",
TC: "금액",
NU: "노무단가",
GU: "재료단가",
JU: "경비단가",
SU: "실적단가",
NC: "노무비",
GC: "재료비",
JC: "경비",
SC: "실적",
Level: "레벨",
LinkIndex: "연결종류",
CalcIndex: "계산구분",
SCheck: "결과비목",
OCheck: "기준비목",
SKind: "구간",
Diget: "자릿수코드",
DigetIndex: "처리방식",
Gangub: "맞춤자리",
Ratio: "조정률",
NachalRatio: "낙찰률",
ValueIndex: "단가선택",
SelectField: "선택출처",
NCGasu: "운전사계수",
LineData: "산출근거",
Gubun: "기호",
Cost: "원가금액",
Youl: "요율",
VSusic: "수식",
BaseField: "기초연결",
InputTC: "입력금액",
Precision: "줄 소수자리",
RoundMode: "줄 처리방식",
};
for (let i = 1; i <= 6; i++) {
labels[`Cost${i}`] = `출처${i}`;
labels[`Page${i}`] = `쪽수${i}`;
}
const numeric = new Set([
"Index",
"PkID",
"GN",
"TU",
"TC",
"NU",
"GU",
"JU",
"SU",
"NC",
"GC",
"JC",
"SC",
"Level",
"LinkIndex",
"CalcIndex",
"SCheck",
"OCheck",
"SKind",
"Diget",
"DigetIndex",
"Gangub",
"Ratio",
"NachalRatio",
"ValueIndex",
"SelectField",
"NCGasu",
"InputTC",
"Precision",
"RoundMode",
"Cost",
...Array.from({ length: 6 }, (_, i) => `Cost${i + 1}`),
]);
for (const key of ["ANU", "AGU", "AJU", "ASU", "EscPlanned", "EscActual", "EscProgress"])
numeric.add(key);
const definitions = {
EstMainDat: [
"Level",
"Code",
"Name",
"Size",
"Unite",
"GN",
"TU",
"NU",
"GU",
"JU",
"SU",
"TC",
"NC",
"GC",
"JC",
"SC",
"LinkIndex",
"CalcIndex",
"SCheck",
"OCheck",
"SKind",
"Gangub",
"Index",
"Diget",
"DigetIndex",
],
HeadMainDat: [
"Index",
"Code",
"Name",
"Size",
"Unite",
"GN",
"NU",
"GU",
"JU",
"SU",
"TC",
"NC",
"GC",
"JC",
"SC",
"LinkIndex",
"CalcIndex",
"SCheck",
"OCheck",
"SKind",
"Diget",
"DigetIndex",
],
CostMainDat: [
"Index",
"Code",
"LineData",
"Level",
"LinkIndex",
"SCheck",
"Precision",
"RoundMode",
"TC",
"NC",
"GC",
"JC",
"SC",
],
MachMainDat: [
"Index",
"Code",
"Name",
"Size",
"Unite",
"GN",
"TU",
"Ratio",
"TC",
"NC",
"GC",
"JC",
"NCGasu",
"SCheck",
"LinkIndex",
"CalcIndex",
],
MachGijunDat: ["Index", "Name", "TC", "Bigo"],
Wonga: [
"Gubun",
"Name",
"Youl",
"VSusic",
"Cost",
"BaseField",
"LinkIndex",
"CalcIndex",
"Diget",
"DigetIndex",
],
StuffcDat: [
"Code",
"Name",
"Size",
"Unite",
"ValueIndex",
"SelectField",
"Cost1",
"Cost2",
"Cost3",
"Cost4",
"Cost5",
"Cost6",
"TC",
"Ratio",
"NachalRatio",
"Diget",
"DigetIndex",
],
LaborcDat: [
"Code",
"Name",
"Unite",
"ValueIndex",
"SelectField",
"Cost1",
"Cost2",
"Cost3",
"Cost4",
"Cost5",
"Cost6",
"TC",
"Ratio",
"NachalRatio",
],
EstiMateDat: [
"Code",
"Name",
"Size",
"Unite",
"InputTC",
"TC",
"NC",
"GC",
"JC",
"SC",
"Ratio",
"NachalRatio",
],
SilJukDat: [
"Code",
"Name",
"Size",
"Unite",
"Cost1",
"TC",
"NC",
"GC",
"JC",
"SC",
"Ratio",
"NachalRatio",
],
};
function columns(table) {
return (
definitions[table] || ["Index", "Code", "Name", "Size", "Unite", "TC", "NC", "GC", "JC", "SC"]
);
}
function validate(project) {
if (
!project ||
project.schema !== "est-web-2" ||
typeof project.name !== "string" ||
!project.tables ||
typeof project.tables !== "object" ||
Array.isArray(project.tables)
)
throw Error("EST 웹앱 프로젝트 형식이 아닙니다.");
let records = 0;
for (const [table, rows] of Object.entries(project.tables)) {
if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(table) || !Array.isArray(rows))
throw Error("테이블 형식 오류");
records += rows.length;
if (records > 500000) throw Error("레코드 수 한도 초과");
for (const row of rows) {
if (!row || typeof row !== "object" || Array.isArray(row)) throw Error("행 형식 오류");
for (const [key, value] of Object.entries(row)) {
if (["__proto__", "constructor", "prototype"].includes(key))
throw Error("허용하지 않는 필드 이름");
if (
numeric.has(key) &&
value != null &&
(typeof value !== "number" ||
!Number.isFinite(value) ||
Math.abs(value) > Number.MAX_SAFE_INTEGER)
)
throw Error(`수치 필드 오류: ${table}.${key}`);
if (typeof value === "string" && value.length > 200000)
throw Error("문자열 길이 한도 초과");
}
}
}
project.options ||= {};
for (const key of ["quantities", "progress", "annual", "drawings"]) {
if (project[key] === undefined) project[key] = [];
if (!Array.isArray(project[key])) throw Error(`${key} 형식 오류`);
}
return project;
}
function legacy(data) {
if (data.schema === "est-web-2") return validate(data);
if (!["sources", "units", "estimate", "costs"].every((key) => Array.isArray(data[key])))
throw Error("지원하지 않는 프로젝트 파일");
const project = EstNative.empty(),
mapping = {
재료비: ["StuffcDat", 5, "GC"],
노무비: ["LaborcDat", 6, "NC"],
경비: ["EstiMateDat", 101, "JC"],
실적: ["SilJukDat", 21, "SC"],
};
project.name = data.projectName || "이전 작업";
project.legacyOriginal = structuredClone(data);
for (const source of data.sources) {
const [table, kind, component] = mapping[source.kind] || mapping.재료비;
project.tables[table].push({
Code: source.id,
Name: source.name,
Size: source.spec,
Unite: source.unit,
TC: source.price,
InputTC: source.price,
[component]: source.price,
ValueIndex: 4,
});
source._nativeKind = kind;
}
data.units.forEach((unit, index) => {
project.tables.HeadMainDat.push({
Index: index + 1,
Code: unit.id,
Name: unit.name,
Unite: unit.unit,
});
unit.lines.forEach((line) => {
const source = data.sources.find((s) => s.id === line.sourceId);
project.tables.HeadMainDat.push({
Index: 0,
Code: line.type === "일위대가" ? line.unitId : line.sourceId,
Name: source?.name,
Size: line.base === "합계" ? "합계" : line.base,
Unite: line.type === "비율" ? "%" : source?.unit,
GN: line.quantity,
LinkIndex:
line.type === "일위대가" ? 103 : line.type === "비율" ? 58 : source?._nativeKind || 0,
SCheck: { 재료비: 1, 노무비: 2, 경비: 3 }[line.kind] || 0,
Diget: line.digits ? line.digits + 1 : 0,
DigetIndex: line.digits ? 2 : 0,
});
});
project.tables.HeadTitleDat.push({ Code: unit.id, Name: unit.name, Unite: unit.unit });
});
project.tables.EstMainDat = data.estimate.map((row, index) => ({
PkID: index + 1,
Index: 0,
Code: row.unitId,
Name: row.name,
GN: row.quantity,
LinkIndex: 103,
Level: 0,
NewItem: true,
}));
project.tables.EstMainDat.push({ Name: "순공사비", LinkIndex: 54 });
const bases = { 재료비: "GC", 노무비: "NC", 경비: "JC", 실적: "SC", 직접공사비: "TC" };
project.tables.Wonga = [{ Gubun: "기초", Name: "직접공사비", BaseField: "TC", Cost: 0 }];
for (const row of data.costs)
project.tables.Wonga.push({
Gubun: row.id,
Name: row.name,
Youl: `${row.rate}%`,
VSusic:
row.formula ||
`[${row.base === "누계" ? "기초" : bases[row.base] || "TC"}]*${row.rate}/100`,
});
return validate(project);
}
function newRow(table, rows) {
const max = Math.max(0, ...rows.map((r) => Number(r.PkID) || 0));
const row = {
PkID: max + 1,
Index: 0,
Name: "새 항목",
GN: 1,
CalcIndex: 0,
LinkIndex: 0,
Level: 0,
};
if (["StuffcDat", "LaborcDat", "SilJukDat", "EstiMateDat"].includes(table))
Object.assign(row, { Code: `U${Date.now().toString(36)}`, ValueIndex: 4, InputTC: 0, TC: 0 });
if (table === "Wonga") Object.assign(row, { Gubun: `R${max + 1}`, VSusic: "", Cost: 0 });
if (table === "CostMainDat") Object.assign(row, { LineData: "Q = 1 + 1 = \x122\x12" });
return row;
}
return { labels, numeric, columns, validate, legacy, newRow };
})();
+184
View File
@@ -0,0 +1,184 @@
// WPTools bookmarks retain the original quantity sheet's result identities.
const EstQuantity = (() => {
function entities(text) {
return String(text)
.replace(/&#(x[0-9a-f]+|\d+);/gi, (_, n) => {
const code = n[0].toLowerCase() === "x" ? parseInt(n.slice(1), 16) : Number(n);
return code <= 0x10ffff ? String.fromCodePoint(code) : "";
})
.replace(/&quot;/g, '"')
.replace(/&apos;|&#39;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&");
}
function fromWpt(code, name, text, items, options) {
const tags = new Map(),
rows = [];
const superscripts = new Set(
[...text.matchAll(/<cs\b[^>]*\bnr=(\d+)[^>]*>/gi)]
.filter((m) => (Number(m[0].match(/CharStyleON:(\d+)/)?.[1] || 0) & 16) !== 0)
.map((m) => m[1]),
);
for (const match of text.matchAll(/<begin\b[^>]*\bTag=(\d+)[^>]*>/gi)) {
const name = entities(match[0]).match(/Name:["']([^"']+)["']/)?.[1];
if (name) tags.set(match[1], name);
}
for (const match of text.matchAll(/<div\b[^>]*>([\s\S]*?)<\/div>/gi)) {
const markers = [];
let raised = false;
const tag = (name) =>
/^r\d+$/.test(name) || /^L\d+$/.test(name)
? "\x12"
: /^N\d+$/.test(name)
? "\x0e"
: /^S\d+$/.test(name)
? "\x13"
: "";
let body = match[1].replace(/<c\s+nr=(\d+)\s*\/>/gi, (_, id) => {
const next = superscripts.has(id),
value = next && !raised ? "^(" : !next && raised ? ")" : "";
raised = next;
return value;
});
if (raised) body += ")";
body = body
.replace(/<begin\b[^>]*\bTag=(\d+)[^>]*>/gi, (_, id) => {
const name = tags.get(id) || "";
if (tag(name) === "\x12") markers.push(name);
return tag(name);
})
.replace(/<end\b[^>]*\bTag=(\d+)[^>]*>/gi, (_, id) => tag(tags.get(id) || ""));
body = entities(body.replace(/<[^>]*>/g, "").replace(/\r?\n/g, ""));
if (body.trim()) rows.push({ LineData: body, markers });
}
return { Code: code, Name: name, rows, items, options };
}
function calculate(sheet) {
const input = [
{ Index: 1, Code: sheet.Code, LineData: "" },
...sheet.rows.map((row) => {
const ResultFormats = (row.markers || []).map((name) => {
const override = name.match(/^[LrG]\d{6}([123])(\d)$/);
return {
digits: override ? Number(override[2]) : (sheet.options?.digits ?? 2),
method: override ? Number(override[1]) : (sheet.options?.roundMode ?? 2),
};
});
const format = ResultFormats[0] || {
digits: sheet.options?.digits ?? 2,
method: sheet.options?.roundMode ?? 2,
};
return {
...row,
LineData: row.LineData.replace(/\*개소당|\bPIPE\b|소\s*계|∴/g, ""),
Index: 0,
SCheck: 0,
LinkIndex: 0,
Precision: format.digits,
RoundMode: format.method,
DigetIndex: format.method,
Diget: format.digits + 1,
ResultFormats,
};
}),
];
const result = EstText.calculate(input),
values = new Map(),
checks = [];
result.rows.slice(1).forEach((row, index) => {
const actual = [...String(row.LineData || "").matchAll(/\x12([-\d.,]+)\x12/g)];
const stored = [...String(sheet.rows[index].LineData || "").matchAll(/\x12([-\d.,]+)\x12/g)];
(sheet.rows[index].markers || []).forEach((name, part) => {
if (!actual[part]) return;
const value = Number(actual[part][1].replaceAll(",", ""));
values.set(name, value);
checks.push({
name,
stored: Number(stored[part]?.[1].replaceAll(",", "")),
calculated: value,
});
});
let part = 0;
row.LineData = sheet.rows[index].LineData.replace(
/\x12([-\d.,]+)\x12/g,
(whole) => actual[part++]?.[0] || whole,
);
});
return {
rows: result.rows.slice(1),
items: sheet.items.map((item) => ({ ...item, GN: values.get(item.Code) ?? item.GN })),
checks,
errors: result.errors,
};
}
function selector(state) {
if (!state.project.quantitySheets?.length) return "";
const { esc } = EstApp;
return `<div class="business-tools"><label>수량산출서 <select id="quantity-sheet-choice"><option value="manual">수식표 직접 작성</option>${state.project.quantitySheets.map((sheet) => `<option value="${esc(sheet.Code)}" ${state.quantitySheet === sheet.Code ? "selected" : ""}>${esc(sheet.Name || sheet.Code)}</option>`).join("")}</select></label></div>`;
}
function render(state) {
state.quantitySheet ||= state.project.quantitySheets[0]?.Code;
const sheet =
state.project.quantitySheets.find((s) => s.Code === state.quantitySheet) ||
state.project.quantitySheets[0];
state.quantitySheet = sheet.Code;
const output = calculate(sheet),
{ esc, number, byId } = EstApp;
byId("work-content").innerHTML =
selector(state) +
`<div class="business-tools"><b>${esc(sheet.Name)}</b><button data-quantity-apply>내역 수량 반영</button></div><table class="sheet"><thead><tr><th>산출코드</th><th>품명</th><th>규격</th><th>원본 저장 수량</th><th>재계산 수량</th><th>연결 내역코드</th></tr></thead><tbody>${output.items.map((item, index) => `<tr><td>${esc(item.Code)}</td><td>${esc(item.Name)}</td><td>${esc(item.Size)}</td><td>${number(sheet.items[index].GN)}</td><td>${number(item.GN)}</td><td><input data-quantity-target="${index}" value="${esc(item.TargetCode || item.LinkCode || "")}" aria-label="연결 내역코드"></td></tr>`).join("")}</tbody></table><p class="business-note">${esc(output.errors.join(" / "))} 원본 결과 표지의 이름을 유지해 수량을 연결합니다. 원본 ESTX의 편집기·도면 바이너리는 별도로 보존합니다.</p><table class="sheet"><thead><tr><th>줄</th><th>산출근거 편집</th></tr></thead><tbody>${sheet.rows.map((row, index) => `<tr><td>${index + 1}</td><td><textarea data-quantity-line="${index}" rows="1" style="min-width:750px" aria-label="산출근거 ${index + 1}">${esc(EstText.display(output.rows[index].LineData))}</textarea></td></tr>`).join("")}</tbody></table>`;
}
function install() {
document.addEventListener("change", (event) => {
const node = event.target,
{ state } = EstApp;
if (node.id === "quantity-sheet-choice") {
state.quantitySheet = node.value;
EstApp.render();
return;
}
const sheet = state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet);
if (!sheet) return;
if (node.dataset.quantityLine !== undefined)
EstApp.change(
() =>
(sheet.rows[Number(node.dataset.quantityLine)].LineData = EstText.encode(node.value)),
false,
);
if (node.dataset.quantityTarget !== undefined)
EstApp.change(
() => (sheet.items[Number(node.dataset.quantityTarget)].TargetCode = node.value),
false,
);
});
document.addEventListener("click", (event) => {
if (!event.target.closest("[data-quantity-apply]")) return;
try {
const { state } = EstApp,
sheet = state.project.quantitySheets.find((s) => s.Code === state.quantitySheet),
output = calculate(sheet);
if (output.errors.length) throw Error(output.errors.join(" / "));
const quantities = new Map();
for (const item of output.items)
if (item.TargetCode || item.LinkCode) {
const code = item.TargetCode || item.LinkCode;
if (!state.project.tables.EstMainDat.some((r) => r.Code === code))
throw Error(`연결 내역코드 없음: ${code}`);
quantities.set(code, (quantities.get(code) || 0) + Number(item.GN || 0));
}
if (!quantities.size) throw Error("연결할 내역코드를 먼저 입력하세요.");
EstApp.change(() => {
for (const row of state.project.tables.EstMainDat)
if (quantities.has(row.Code) && !Number(row.Level)) row.GN = quantities.get(row.Code);
});
} catch (error) {
EstApp.state.notice = error.message;
EstApp.render();
}
});
}
return { fromWpt, calculate, selector, render, install };
})();
if (typeof module !== "undefined" && module.exports) module.exports = EstQuantity;
+112
View File
@@ -0,0 +1,112 @@
const EstResources = (() => {
const { num, components } = EstMath;
function calculate(input) {
const project = EstNative.calculate({
...structuredClone(input),
options: { ...input.options, SourceRefresh: true },
});
const tables = project.tables,
heads = EstNative.groups(tables.HeadMainDat),
costs = EstNative.groups(tables.CostMainDat),
output = new Map(),
errors = [...project.calculation.errors];
function record(kind, code, quantity, amount = 0) {
const source = (tables[EstNative.sourceTables[kind]] || []).find((row) => row.Code === code);
if (!source) {
errors.push(`동원 원천 없음: ${kind}/${code}`);
return;
}
const id = `${kind}:${code}`;
const row = output.get(id) || {
Code: code,
Name: source.Name,
Size: source.Size,
Unite: source.Unite,
kind,
quantity: 0,
amount: 0,
};
row.quantity += quantity;
row.amount += amount;
output.set(id, row);
}
function expand(kind, code, multiplier, path) {
const id = `${kind}:${code}`;
if (path.includes(id)) {
errors.push(`동원 순환 참조: ${id}`);
return;
}
if (kind === 104) {
const group = costs.get(code);
if (!group) {
errors.push(`산근 없음: ${code}`);
return;
}
for (const row of group.rows) {
if (!row.ResourceCode || row.ResourceFactor == null) continue;
if ([103, 104].includes(num(row.ResourceKind)))
expand(num(row.ResourceKind), row.ResourceCode, multiplier * num(row.ResourceFactor), [
...path,
id,
]);
else if (num(row.ResourceKind) === 105)
record(105, row.ResourceCode, 0, multiplier * num(row.TC));
else
record(
num(row.ResourceKind),
row.ResourceCode,
multiplier * num(row.ResourceFactor),
multiplier * num(row.TC),
);
}
return;
}
if (kind === 103) {
const group = heads.get(code);
if (!group) {
errors.push(`일위 없음: ${code}`);
return;
}
let rate = 1;
const bid = group.rows.find((row) => num(row.LinkIndex) === 52);
if (bid) rate = num(bid.GN) / 100;
for (const row of group.rows.slice(1)) {
if (num(row.CalcIndex) >= 2 || [51, 52, 58, 62, 68].includes(num(row.LinkIndex)))
continue;
const quantity =
(multiplier * num(row.GN) * rate) / (["%", "%"].includes(row.Unite) ? 100 : 1);
if ([103, 104].includes(num(row.LinkIndex)))
expand(num(row.LinkIndex), row.Code, quantity, [...path, id]);
else if (EstNative.sourceTables[num(row.LinkIndex)])
record(num(row.LinkIndex), row.Code, quantity, multiplier * num(row.TC));
}
return;
}
record(kind, code, multiplier);
}
for (const row of tables.EstMainDat) {
if (num(row.LinkIndex) === 54) break;
if (
num(row.Level) > 0 ||
num(row.CalcIndex) >= 2 ||
!EstNative.sourceTables[num(row.LinkIndex)]
)
continue;
expand(num(row.LinkIndex), row.Code, num(row.GN), []);
}
const rows = [...output.values()];
for (const row of rows)
if (row.kind === 105 && row.amount) {
const price = num(tables.MachTitleDat.find((r) => r.Code === row.Code)?.TC);
if (price) {
row.quantity += row.amount / price;
row.Unite = "HR";
} else {
row.Unite = "제외금액";
row.quantity = 0;
}
}
return { rows, errors: [...new Set(errors)] };
}
return { calculate };
})();
+190
View File
@@ -0,0 +1,190 @@
// REA f1a078, f17c10, f1a8c4, f3242c, f36074, f34970.
const EstRows = (() => {
const { num, components, units, sum, fixed, floating, amounts, add } = EstMath;
const target = { 1: "GC", 2: "NC", 3: "JC", 4: "SC" };
function baseKind(row) {
const text = String(row.Size || "");
if (/[노품인]/.test(text)) return "NC";
if (/[재자]/.test(text)) return "GC";
if (text.includes("경")) return "JC";
if (/계|총|합/.test(text)) return "TC";
if (text.includes("비목")) return null;
return { 1: "GC", 2: "NC", 3: "JC", 4: "TC" }[num(row.OCheck) % 5] || null;
}
function percent(row, preceding, digits = 0) {
if ([1, 3].includes(num(row.CalcIndex))) return row;
// f1a8c4 matches Chr(64 + source.SKind) against the percentage row's Code.
const selectedGroups = String(row.Code || "").toUpperCase();
const eligible =
num(row.LinkIndex) === 62
? preceding.filter(
(r) =>
num(r.SKind) > 0 && selectedGroups.includes(String.fromCharCode(64 + num(r.SKind))),
)
: preceding;
const base = baseKind(row),
sc = num(row.SCheck),
p = EstMath.precision(row, digits);
components.forEach((key) => (row[key] = 0));
if (base) {
const amount = eligible.reduce((n, r) => n + num(r[base]), 0);
row.TU = amount;
row[target[sc] || "JC"] = floating((amount * num(row.GN)) / 100, p.digits, p.method);
} else {
components.forEach(
(key) =>
(row[key] = floating(
(eligible.reduce((n, r) => n + num(r[key]), 0) * num(row.GN)) / 100,
p.digits,
p.method,
)),
);
row.TU = eligible.reduce((n, r) => n + num(r.TC), 0);
}
row.TC = sum(row);
return row;
}
function procurement(row, preceding) {
if ([1, 3].includes(num(row.CalcIndex))) return row;
const key = { 1: "GC", 2: "NC", 3: "JC", 4: "TC" }[num(row.OCheck)] || "TC";
const base = preceding
.filter((r) => num(r.LinkIndex) !== 57)
.reduce((n, r) => n + num(r[key]), 0);
const fee = Math.trunc((base * num(row.GN)) / 100),
step = 10 ** num(row.Gangub),
quotient = (base + fee) / step;
const rounded =
num(row.Index) === 1
? EstMath.half(quotient)
: num(row.Index) === 2
? Math.trunc(quotient)
: EstMath.half(quotient + 0.5);
components.forEach((k) => (row[k] = 0));
row[target[num(row.SCheck)] || "JC"] = rounded * step - base;
row.TU = base;
row.TC = sum(row);
return row;
}
function subtotal(row, preceding, digits = 0) {
const eligible = preceding.filter((r) => num(r.CalcIndex) < 2 && num(r.LinkIndex) !== 51);
const values = Object.fromEntries(
components.map((k) => [k, eligible.reduce((n, r) => n + num(r[k]), 0)]),
);
const kind = baseKind(row),
sc = target[num(row.SCheck)];
units.forEach((key) => (row[key] = 0));
if (sc) row[units[components.indexOf(sc)]] = kind === "TC" ? sum(values) : num(values[kind]);
else
components.forEach(
(key, i) => (row[units[i]] = kind && kind !== "TC" && kind !== key ? 0 : values[key]),
);
return amounts(row, digits);
}
function head(rows, options = {}, resolve = () => null) {
const output = structuredClone(rows),
titles = [],
errors = [];
let current = null,
body = [];
function finish() {
if (!current) return;
Object.assign(current, add(body));
titles.push({ ...current });
}
for (const row of output) {
if (num(row.Index) > 0) {
finish();
current = row;
body = [];
continue;
}
if (!current) continue;
const link = num(row.LinkIndex);
if ([58, 62].includes(link)) percent(row, body, options.HeadDigits || 0);
else if ([51, 68].includes(link)) {
subtotal(row, body, options.HeadDigits || 0);
if (link === 68) {
// f3242c resets its accumulators to the converted subtotal at LinkIndex 68.
body = [row];
continue;
}
} else if (link === 52) {
const before = add(body),
ratio = num(row.GN) / 100;
components.forEach((key) => (row[key] = floating(before[key] * ratio)));
row.TC = sum(row);
row.TU = before.TC;
body = [row];
continue;
} else {
if (options.SourceRefresh && ![1, 3].includes(num(row.CalcIndex))) {
try {
const price = resolve(row);
if (price) units.forEach((key, i) => (row[key] = num(price[components[i]])));
} catch (error) {
errors.push(`${current.Name}: ${error.message}`);
}
}
amounts(row, options.HeadDigits || 0, options.TotalFit);
}
body.push(row);
}
finish();
return { rows: output, titles, errors };
}
function estimate(rows, options = {}, resolve = () => null) {
const output = structuredClone(rows),
errors = [];
let segment = [],
prior = [],
boundary = false;
for (const row of output) {
const level = num(row.Level),
link = num(row.LinkIndex);
if (level > 0) {
segment = [];
continue;
}
if (link === 54) {
Object.assign(row, add(prior));
boundary = true;
continue;
}
if (link === 59) {
boundary = true;
continue;
}
if (boundary && [55, 58, 59].includes(link)) continue;
if (link === 57) procurement(row, segment);
else if ([62, 67, 58].includes(link)) percent(row, segment, options.EstDigits || 0);
else {
if (options.SourceRefresh && ![1, 3].includes(num(row.CalcIndex))) {
try {
const price = resolve(row);
if (price) units.forEach((key, i) => (row[key] = num(price[components[i]])));
} catch (error) {
errors.push(`${row.Name}: ${error.message}`);
}
}
amounts(row, options.EstDigits || 0, options.TotalFit);
}
segment.push(row);
if (!boundary) prior.push(row);
}
for (let i = output.length - 1; i >= 0; i--) {
const row = output[i],
level = num(row.Level);
if (level <= 0) continue;
const children = [];
for (let j = i + 1; j < output.length; j++) {
const next = output[j];
if (num(next.Level) >= level || [54, 58, 59].includes(num(next.LinkIndex))) break;
if (num(next.Level) === 0) children.push(next);
}
Object.assign(row, add(children));
}
const direct = output.find((r) => num(r.LinkIndex) === 54) || add(prior);
return { rows: output, direct, errors };
}
return { head, estimate, percent, procurement, subtotal, baseKind };
})();
+339
View File
@@ -0,0 +1,339 @@
// REA f2a1fc/f254c8: LineData 표지와 일반·성분·마지막 합계의 정밀도.
const EstText = (() => {
const { num, components, floating, sum } = EstMath;
const marker = /\x12([-\d.,]+)\x12/;
const component = { [-1]: "NC", [-2]: "GC", [-3]: "JC", [-4]: "SC" };
const clean = (s) =>
String(s || "")
.replace(/[\x06\x07][^\x06\x07]*[\x06\x07]/g, "")
.replace(/[\x03\x0c\x0e\x11\x12\x13\x15\x1a]/g, "");
const numeric = (s) => Number(String(s).replaceAll(",", ""));
const sumCurrency = (values) =>
values.reduce((n, value) => n + EstMath.even(num(value) * 10000), 0) / 10000;
const stripLabels = (text) =>
String(text).replace(/\x0e[^\x0e]*\x0e|\x13[^\x13]*\x13|\x15[^\x15]*\x15/g, "");
function expression(text) {
return clean(
String(text).replace(
/\x0e[^\x0e]*\x0e|\x13[^\x13]*\x13|\x15[^\x15]*\x15|\{[^}]*\}|\([가-힣][^()]*\)/g,
"",
),
)
.replace(
/(\d)\s*(?:공\/?㎥|공|단|면|경간|개월|월|인|㎥|㎢|㎡|hr|sec|min|km|ton|kg|㎏|㎞|ℓ|회|개소|개|대|분|초|본|장|매|병|조|식|일|시간|원|포|동|μ|EA|mm|cm|m|g|L)(?:\/(?:개소|개|대|본|인|분|포|\$|hr|일|㎥|㎡|m|a))?/gi,
"$1",
)
.replace(/(?<=\d)\s*\/(?:일|㎥|㎡)(?=\s|$)/g, "")
.replace(/(?<=\))\s*(?:개|인|본|면|단)(?:\/(?:㎡|㎥|hr))?/g, "")
.replace(/[①-⑳]/g, "")
.replace(/=\s*$/, "");
}
function calculate(input, options = {}, resolve = () => null) {
const rows = structuredClone(input),
titles = [],
errors = [],
checks = [];
let head = null,
variables = Object.create(null),
changed = new Map(),
currentSource = null,
totals = {},
levelTotals = new Map(),
finalLevel = -1;
function finish() {
if (!head) return;
components.forEach((key) => (head[key] = num(totals[key])));
head.TC = sum(head);
titles.push({ ...head });
}
for (let index = 0; index < rows.length; index++) {
const row = rows[index];
if (num(row.Index) > 0) {
finish();
head = row;
variables = Object.create(null);
changed = new Map();
currentSource = null;
totals = {};
levelTotals = new Map();
for (const [key, tag] of [
["Name", "\x0e"],
["Size", "\x13"],
["Unite", "\x15"],
]) {
const match = String(row.LineData || "").match(new RegExp(`${tag}([^${tag}]+)${tag}`));
if (match) row[key] = match[1];
}
const levels = [];
for (let j = index + 1; j < rows.length && num(rows[j].Index) <= 0; j++)
if (String(rows[j].LineData || "").includes("\x1a")) levels.push(num(rows[j].Level));
finalLevel = levels.length ? Math.min(...levels) : -1;
continue;
}
if (!head || !row.LineData) continue;
const original = String(row.LineData),
matched = original.match(marker),
sc = num(row.SCheck);
const oldValue = matched ? numeric(matched[1]) : null;
if (num(row.LinkIndex) > 0 && /\x0e/.test(original)) {
const name = original.match(/\x0e([^\x0e]+)\x0e/),
size = original.match(/\x13([^\x13]+)\x13/);
try {
currentSource = resolve({ ...row, Name: name?.[1], Size: size?.[1] });
} catch (error) {
errors.push(`${head.Code}:${index + 1} ${error.message}`);
}
}
let text = original;
if (currentSource && sc < 0 && component[sc])
text = text.replace(
/\x03([-\d.,]+)\x03/g,
() => `\x03${num(currentSource[component[sc]])}\x03`,
);
else if (currentSource && sc === 0 && /\x0e/.test(original))
text = text.replace(/\x03([-\d.,]+)\x03/g, () => `\x03${num(currentSource.TC)}\x03`);
text = text.replace(
/\x11([-\d.,]+)\x11/g,
(whole, value) => `\x11${changed.get(numeric(value)) ?? numeric(value)}\x11`,
);
text = text.replace(
/\x0c([-\d.,]+)\x0c/g,
(whole, value) => `\x0c${changed.get(numeric(value)) ?? numeric(value)}\x0c`,
);
if (text.includes("\x1a") && text.includes(":") && sc !== -5) {
const start = text.indexOf(":") + 1,
end = text.includes("\x12") ? text.indexOf("\x12") : text.length;
text =
text.slice(0, start) +
text
.slice(start, end)
.replace(/\d[\d,]*\.?\d*/g, (value) =>
String(changed.get(numeric(value)) ?? numeric(value)),
) +
text.slice(end);
row.LineData = text;
}
let calculated = null,
digits = 0;
const assignedResults = new Map();
const assignedNames = new Map();
try {
if (matched && /─/.test(text)) {
const above = clean(rows[index - 1]?.LineData),
below = clean(rows[index + 1]?.LineData);
const tops = above.trim().split(/\s{2,}/),
bottoms = below.trim().split(/\s{2,}/);
let part = 0;
const formula = text
.slice(text.indexOf("=") + 1)
.split("=")[0]
.replace(/─+/g, () => {
const i = part++;
return `(${expression(tops[i] || "")})/(${expression(bottoms[i] || "")})`;
});
calculated = EstExpression.evaluate(expression(formula), (key) => variables[key]);
} else if (matched && text.includes("\x1a")) {
if (sc === -5)
calculated = sumCurrency(Object.values(levelTotals.get(num(row.Level)) || totals));
else {
let body = clean(text.slice(0, text.indexOf("\x12")))
.replace(/=\s*$/, "")
.trim();
if (body.includes(":")) body = body.slice(body.indexOf(":") + 1);
calculated = body ? EstExpression.evaluate(body, (key) => variables[key]) : 0;
}
} else if (matched && sc === -5) {
const section = [];
for (
let j = index - 1;
j >= 0 &&
num(rows[j].Index) <= 0 &&
num(rows[j].Level) <= 0 &&
num(rows[j].SCheck) !== -5;
j--
)
if (component[num(rows[j].SCheck)]) section.push(num(rows[j].TC));
calculated = sumCurrency(section);
} else if (matched && sc < 0) {
let body = stripLabels(text.slice(0, text.indexOf("\x12"))).replace(/=\s*$/, "");
if (body.includes(":")) body = body.slice(body.indexOf(":") + 1);
body = body.split("=")[0];
calculated = EstExpression.evaluate(expression(body), (key) => variables[key]);
}
const assignmentText = text.replace(
/\{[^}]*\}|\x0e[^\x0e]*\x0e|\x13[^\x13]*\x13|\x15[^\x15]*\x15/g,
(value) => " ".repeat(value.length),
);
const assignments =
sc < 0
? []
: [
...assignmentText.matchAll(
/(?:^\s*| {2,}|[,,]\s*|(?<=[가-힣])\s+|[::]\s*)([A-Za-z][A-Za-z0-9]*)\s*=/g,
),
];
for (let j = 0; j < assignments.length; j++) {
const assignment = assignments[j],
end = assignments[j + 1]?.index ?? text.length;
let body = text.slice(assignment.index + assignment[0].length, end);
body = body.split(/\{(?!%)|\s+[::]\s*/)[0].replace(/[},,]\s*$/, "");
if (body.includes("─") && calculated !== null) {
variables[assignment[1]] = floating(calculated, 2, 1);
continue;
}
const result = body.match(marker);
const parts = body.split("=");
body = parts[0];
body = expression(body)
.trim()
.replace(/\s+(?:min|hr|sec)\s*$/i, "")
.split(/\s{2,}(?=\d+\s*[가-힣]|[가-힣])/)[0];
// 단위가 붙은 상수와 주석은 수치 부분만 사용한다.
const literal = body.match(/^([-+]?\d[\d,.]*)(?:\s*[A-Za-z가-힣㎥㎡ℓ˚°()].*)$/);
if (literal) body = literal[1];
let value;
try {
value = EstExpression.evaluate(body, (key) => variables[key]);
} catch (error) {
if (!result && error.message.startsWith("참조 항목 없음:")) continue;
if (parts.length > 1)
value = EstExpression.evaluate(
expression(parts[parts.length - 2]),
(key) => variables[key],
);
else throw Error(`${error.message} (${assignment[1]} = ${body})`);
}
const savedDigits = result?.[1].includes(".") ? result[1].split(".")[1].length : 0;
const precision = result ? Math.max(2, savedDigits) : 8;
const explicit = num(row.DigetIndex) ? EstMath.precision(row) : null;
variables[assignment[1]] = result
? floating(value, row.Precision ?? explicit?.digits ?? precision, explicit?.method ?? 1)
: value;
if (result) {
const position = text.indexOf("\x12", assignment.index + assignment[0].length);
assignedResults.set(position, variables[assignment[1]]);
assignedNames.set(position, assignment[1]);
if (position === text.indexOf("\x12")) calculated = variables[assignment[1]];
}
}
if (matched && calculated === null && sc === 0 && text.includes("=")) {
let body = stripLabels(text.slice(0, text.indexOf("\x12")))
.replace(/\x06[^\x06]*\x06/g, "")
.split("=")
.filter((x) => x.trim())
.pop();
if (body.includes(":")) body = body.slice(body.indexOf(":") + 1);
calculated = EstExpression.evaluate(expression(body), (key) => variables[key]);
}
if (matched && calculated !== null) {
const explicit = num(row.DigetIndex) ? EstMath.precision(row) : null;
digits =
row.Precision ??
explicit?.digits ??
(sc < 0 || num(row.LinkIndex) !== 0
? num(row.Level) === finalLevel && text.includes("\x1a")
? 0
: 1
: Math.max(2, matched[1].split(".")[1]?.length || 0));
const method =
row.RoundMode || explicit?.method || (sc < 0 || num(row.LinkIndex) !== 0 ? 2 : 1);
calculated = floating(calculated, digits, method);
const firstValue = calculated;
let rendered = text.replace(marker, `\x12${calculated}\x12`);
const results = [...text.matchAll(/\x12([-\d.,]+)\x12/g)];
let assignmentName = assignedNames.get(results[0]?.index);
for (let part = 1; part < results.length; part++) {
const previous = results[part - 1],
next = results[part];
const format = row.ResultFormats?.[part];
const operation = expression(
text.slice(previous.index + previous[0].length, next.index),
).trim();
calculated = assignedResults.has(next.index)
? assignedResults.get(next.index)
: floating(
EstExpression.evaluate(
expression(`${calculated}${operation}`),
(key) => variables[key],
),
format?.digits ?? digits,
format?.method ?? method,
);
assignmentName = assignedNames.get(next.index) || assignmentName;
if (assignmentName) variables[assignmentName] = calculated;
const old = numeric(next[1]);
let occurrence = 0;
rendered = rendered.replace(/\x12([-\d.,]+)\x12/g, (value) =>
occurrence++ === part ? `\x12${calculated}\x12` : value,
);
changed.set(old, calculated);
checks.push({ slot: index, part, stored: old, calculated });
}
row.TC = calculated;
if (currentSource && text.includes("\x03")) {
let body = stripLabels(text.slice(0, text.indexOf("\x12"))).split("=")[0];
if (body.includes(":")) body = body.slice(body.indexOf(":") + 1);
try {
row.ResourceFactor = EstExpression.evaluate(
expression(body.replace(/\x03[-\d.,]+\x03/g, "1")),
(key) => variables[key],
);
row.ResourceCode = currentSource.Code;
row.ResourceKind = currentSource.SourceKind;
row.ResourceComponent = component[sc] || "";
} catch {
row.ResourceFactor = null;
}
}
row.LineData = rendered;
changed.set(oldValue, firstValue);
checks.push({ slot: index, stored: oldValue, calculated: firstValue });
if (text.includes("\x1a") && component[sc]) {
if (sc === -1 || !levelTotals.has(num(row.Level))) levelTotals.set(num(row.Level), {});
levelTotals.get(num(row.Level))[component[sc]] = calculated;
if (num(row.Level) === finalLevel) totals[component[sc]] = calculated;
}
} else if (!matched && component[sc] && text.includes("\x03") && !text.includes("\x1a")) {
const body = stripLabels(text.slice(text.indexOf(":") + 1));
row.TC = floating(
EstExpression.evaluate(expression(body), (key) => variables[key]),
1,
);
row.LineData = text;
} else if (!matched && text.includes("\x1a") && component[sc]) {
const value = clean(text).split(":").slice(1).join(":").trim();
const calculated = floating(
changed.get(numeric(value)) ?? numeric(value),
num(row.Level) === finalLevel ? 0 : 1,
);
if (sc === -1 || !levelTotals.has(num(row.Level))) levelTotals.set(num(row.Level), {});
levelTotals.get(num(row.Level))[component[sc]] = calculated;
if (num(row.Level) === finalLevel) totals[component[sc]] = calculated;
}
} catch (error) {
errors.push(`${head.Code}:${index + 1} ${error.message}`);
row.Error = error.message;
}
}
finish();
return { rows, titles, errors, checks };
}
const names = {
"\x03": "단가",
"\x0c": "다른줄",
"\x0e": "품명",
"\x11": "변수",
"\x12": "결과",
"\x13": "규격",
"\x15": "단위",
"\x1a": "합계",
};
const display = (text) =>
String(text || "").replace(/[\x03\x0c\x0e\x11\x12\x13\x15\x1a]/g, (ch) => `⟦${names[ch]}⟧`);
const encode = (text) =>
String(text).replace(
/⟦([^⟦⟧]+)⟧/g,
(whole, name) => Object.keys(names).find((key) => names[key] === name) || whole,
);
return { calculate, clean, display, encode };
})();
+60
View File
@@ -0,0 +1,60 @@
const EstVerification = (() => {
function run(sample, expected) {
const machine = EstMachine.calculate(
sample.tables.MachMainDat,
sample.tables.MachGijunDat,
sample.options,
);
const head = EstRows.head(sample.tables.HeadMainDat, sample.options);
const estimate = EstRows.estimate(sample.tables.EstMainDat, sample.options);
const text = EstText.calculate(sample.tables.CostMainDat, sample.options);
const wonga = EstWonga.calculate(sample.tables.Wonga, sample.options);
const results = [];
function check(group, id, actual, wanted) {
const differences = [];
for (const [key, value] of Object.entries(wanted))
if (Math.abs((actual[key] || 0) - (value || 0)) > 1e-6)
differences.push({ field: key, expected: value, actual: actual[key] });
results.push({ group, id, pass: !differences.length, differences });
}
for (const row of expected.machine.rows)
if (Object.hasOwn(row, "stored"))
check(
"기계경비",
row.slot,
machine.rows[row.slot],
typeof row.stored === "object" ? row.stored : { [row.field]: row.stored },
);
for (const row of sample.tables.MachTitleDat)
check(
"기계 목록",
row.Code,
machine.titles.find((r) => r.Code === row.Code) || {},
Object.fromEntries(["TC", "NC", "GC", "JC"].map((k) => [k, row[k]])),
);
for (const row of expected.head_est.head)
check("일위대가", row.source_slot, head.rows[row.source_slot], row.stored);
for (const row of expected.head_est.est)
check("내역서", row.slot, estimate.rows[row.slot], row.stored);
for (const row of text.checks)
check("단가산출 결과", row.slot, { value: row.calculated }, { value: row.stored });
for (const row of expected.linedata.rows)
if (row.kind === "title") check("단가산출 목록", row.slot, text.rows[row.slot], row.stored);
for (const row of expected.wonga.results)
check("원가계산서", row.Gubun, wonga.rows[row.row_position], { Cost: Number(row.stored) });
return {
checked: results.length,
passed: results.filter((r) => r.pass).length,
failed: results.filter((r) => !r.pass).length,
errors: [
...machine.errors,
...head.errors,
...estimate.errors,
...text.errors,
...wonga.errors,
],
results,
};
}
return { run };
})();
+121
View File
@@ -0,0 +1,121 @@
const EstViews = (() => {
const enums = {
LinkIndex: [
[0, "직접입력"],
[5, "자재"],
[6, "노임"],
[21, "표준"],
[101, "견적"],
[103, "일위"],
[104, "단가산출"],
[105, "기계"],
[51, "소계"],
[52, "낙찰"],
[54, "순공사비"],
[55, "도급액"],
[57, "조달수수료"],
[58, "비율"],
[59, "경계"],
[61, "자릿수"],
[62, "구간비율"],
[67, "내역비율"],
[68, "묶음끝"],
],
CalcIndex: [
[0, "계산·합산"],
[1, "고정·합산"],
[2, "계산·제외"],
[3, "고정·제외"],
],
ValueIndex: [
[0, "최저"],
[1, "평균"],
[2, "최고"],
[3, "출처선택"],
[4, "직접입력"],
],
DigetIndex: [
[0, "AUTO"],
[1, "사사오입"],
[2, "절사"],
[3, "올림식"],
],
SCheck: [
[0, "비목별"],
[1, "재료"],
[2, "노무"],
[3, "경비"],
[-1, "노무 소계"],
[-2, "재료 소계"],
[-3, "경비 소계"],
[-4, "실적 소계"],
[-5, "합계"],
],
BaseField: [
["", "직접 입력"],
["NC", "직접 노무"],
["GC", "직접 재료"],
["JC", "직접 경비"],
["SC", "직접 실적"],
["TC", "순공사비"],
],
};
function cell(table, index, key, value) {
const { esc } = EstApp,
label = EstModel.labels[key] || key;
const attrs = `data-table="${table}" data-row="${index}" data-field="${key}" aria-label="${esc(label)}"`;
if (enums[key])
return `<select ${attrs}>${enums[key].map(([v, name]) => `<option value="${v}" ${String(v) === String(value ?? (key === "BaseField" ? "" : 0)) ? "selected" : ""}>${esc(name)}</option>`).join("")}</select>`;
if (key === "LineData")
return `<textarea ${attrs} rows="1" spellcheck="false">${esc(EstText.display(value))}</textarea><button class="tiny" data-action="raw-line" data-row="${index}" title="산출근거 편집">…</button>`;
if (value && typeof value === "object") return `<span class="muted">바이너리 자료</span>`;
if (EstModel.numeric.has(key))
return `<input ${attrs} inputmode="decimal" type="number" step="any" value="${value ?? ""}">`;
return `<input ${attrs} type="text" value="${esc(value)}" maxlength="10000">`;
}
function grid(table, list, columns) {
const { esc, number, state } = EstApp;
return `<table class="sheet" data-grid="${table}"><thead><tr><th class="row-number">선택</th>${columns.map((key) => `<th class="col-${key}">${esc(EstModel.labels[key] || key)}</th>`).join("")}</tr></thead><tbody>${list.map(({ row, index }) => `<tr data-index="${index}" class="${state.selection.has(index) ? "selected " : ""}${Number(row.Index) > 0 ? "heading " : ""}${Number(row.Level) > 0 ? "level " : ""}${row.Error ? "error-row" : ""}"><th class="row-number"><label><input type="checkbox" data-select="${index}" ${state.selection.has(index) ? "checked" : ""}>${index + 1}</label></th>${columns.map((key) => `<td class="col-${key}">${cell(table, index, key, row[key])}</td>`).join("")}</tr>`).join("")}</tbody></table>`;
}
function render(state) {
const { project, view } = state,
{ byId, esc, number } = EstApp;
if (project.tables[view]) EstApp.visibleRows();
const grouped = ["HeadMainDat", "CostMainDat", "MachMainDat"].includes(view);
byId("group-pane").hidden = !grouped;
if (grouped) {
const records = EstNative.groups(project.tables[view] || []);
byId("group-list").innerHTML =
[...records]
.map(
([code, group]) =>
`<button data-group="${esc(code)}" class="${state.group === code ? "active" : ""}"><b>${esc(group.rows[0].Name || EstText.clean(group.rows[0].LineData))}</b><small>${esc(code)}</small><span>${number(group.rows[0].TC)}</span></button>`,
)
.join("") || '<p class="muted">자료가 없습니다. 묶음 추가를 누르세요.</p>';
}
byId("search").value = state.query;
byId("standard-tools").hidden = !project.tables[view];
byId("add-group").hidden = !grouped;
if (project.tables[view]) {
const all = EstApp.visibleRows(),
start = state.page * 100,
list = all.slice(start, start + 100),
columns = EstModel.columns(view);
byId("work-content").innerHTML = grid(view, list, columns);
byId("paging").hidden = false;
byId("page-info").textContent =
`${all.length ? start + 1 : 0}–${Math.min(start + 100, all.length)} / ${all.length}행`;
byId("previous-page").disabled = state.page === 0;
byId("next-page").disabled = start + 100 >= all.length;
} else {
byId("paging").hidden = true;
EstExtra.render(state);
}
const chosen = [...state.selection][0],
row = project.tables[view]?.[chosen];
byId("selected-info").textContent = row
? `${chosen + 1}행 · ${row.Code || ""} ${row.Name || EstText.clean(row.LineData)}`
: "행 왼쪽을 선택한 뒤 복사·삭제·레벨 지정 등을 사용할 수 있습니다.";
}
return { render, grid, cell, enums };
})();
+93
View File
@@ -0,0 +1,93 @@
// REA c9f8ec / c9d278 / c9f0e4: 수식 참조, 원 미만 절사, 도급액 고정 반복.
const EstWonga = (() => {
const { num } = EstMath;
const compact = (text) => String(text || "").replace(/\s/g, "");
function calculate(input, options = {}, bases = {}) {
const rows = structuredClone(input),
errors = [],
adjustments = [];
const key = (row) =>
String(options.VerFormat === false ? row.NumStr || row.Gubun : row.Gubun || row.NumStr || "");
const keys = new Map(rows.map((row, index) => [key(row), index]));
keys.delete("");
rows.forEach((row, index) => {
if (compact(row.Name)) keys.set(compact(row.Name), index);
});
const overrides = new Map();
function pass() {
const done = new Set(),
active = new Set();
function resolve(name) {
if (Object.hasOwn(bases, name)) return bases[name];
const index = keys.get(name) ?? keys.get(compact(name));
return index === undefined ? undefined : evaluate(index);
}
function range(start, end) {
let a = keys.get(start),
b = keys.get(end);
if (a === undefined || b === undefined) throw Error(`범위 참조 없음: ${start}:${end}`);
if (a > b) [a, b] = [b, a];
let sum = 0;
for (let i = a; i <= b; i++)
if ([0, 1, 4].includes(num(rows[i].CalcIndex))) sum += evaluate(i);
return sum;
}
function evaluate(index) {
const row = rows[index];
if (overrides.has(index)) return (row.Cost = overrides.get(index));
if (done.has(index)) return num(row.Cost);
if (active.has(index)) throw Error(`원가 수식 순환 참조: ${key(row)}`);
active.add(index);
if (row.BaseField && Object.hasOwn(bases, row.BaseField)) row.Cost = bases[row.BaseField];
else if (row.VSusic && ![1, 3].includes(num(row.CalcIndex)))
row.Cost = Math.trunc(
EstExpression.evaluate(row.VSusic, resolve, { numericReferences: true, range }),
);
else row.Cost = num(row.Cost);
active.delete(index);
done.add(index);
return row.Cost;
}
for (let i = 0; i < rows.length; i++) evaluate(i);
}
const targetIndex = rows.findIndex((row) => num(row.LinkIndex) === 55);
const target = targetIndex >= 0 ? num(rows[targetIndex].Cost) : 0;
try {
pass();
if (options.TotalFix && targetIndex >= 0) {
const profit = rows.findIndex(
(row) => compact(row.Name).includes("이윤") && ![1, 3].includes(num(row.CalcIndex)),
);
const vat = rows.findIndex((row) => compact(row.Name).includes("부가"));
if (profit < 0) throw Error("도급액 고정: 조정할 이윤 행이 없습니다.");
for (let iteration = 0; iteration < 100; iteration++) {
const delta = num(rows[targetIndex].Cost) - target;
adjustments.push({
iteration,
profit: num(rows[profit].Cost),
total: num(rows[targetIndex].Cost),
difference: delta,
});
if (delta === 0) break;
overrides.set(profit, num(rows[profit].Cost) - delta);
pass();
if (iteration === 99 && vat >= 0) {
overrides.set(vat, num(rows[vat].Cost) + target - num(rows[targetIndex].Cost));
pass();
}
}
if (num(rows[targetIndex].Cost) !== target)
throw Error("도급액 고정 계산이 수렴하지 않았습니다.");
}
} catch (error) {
errors.push(error.message);
}
return {
rows,
errors,
adjustments,
total: targetIndex >= 0 ? num(rows[targetIndex].Cost) : num(rows.at(-1)?.Cost),
};
}
return { calculate };
})();
+628
View File
@@ -0,0 +1,628 @@
* {
box-sizing: border-box;
}
[hidden] {
display: none !important;
}
html,
body {
height: 100%;
margin: 0;
}
body {
display: flex;
flex-direction: column;
background: #edf0f4;
color: #203345;
font:
12px "Malgun Gothic",
sans-serif;
}
button,
input,
select,
textarea {
font: inherit;
color: inherit;
}
button {
cursor: pointer;
border: 1px solid #b4c4d2;
background: linear-gradient(#fff, #e5edf4);
padding: 5px 10px;
border-radius: 2px;
}
button:hover {
background: #d9edff;
border-color: #7da4c3;
}
button:disabled {
opacity: 0.45;
cursor: default;
}
input,
select,
textarea {
border: 1px solid #c5d2dd;
background: #fff;
padding: 4px;
min-width: 0;
}
input:focus,
select:focus,
textarea:focus {
outline: 2px solid #7db3dd;
outline-offset: -2px;
}
input[type="checkbox"] {
vertical-align: middle;
accent-color: #346f9e;
}
header {
flex: none;
}
.titlebar {
height: 34px;
display: flex;
align-items: center;
gap: 26px;
padding: 0 12px;
background: linear-gradient(90deg, #1f4b70, #487b9f);
color: #fff;
}
.titlebar b {
font-size: 14px;
}
.titlebar span {
font-size: 11px;
color: #d3e5f2;
}
.titlebar button {
margin-left: auto;
background: #ecf4fa;
color: #254b66;
font-size: 11px;
padding: 3px 10px;
}
.menubar {
display: flex;
align-items: center;
gap: 3px;
background: #f9fafb;
border-bottom: 1px solid #becbd7;
height: 29px;
padding-left: 5px;
}
.menubar details {
position: relative;
}
.menubar summary {
list-style: none;
cursor: pointer;
padding: 6px 13px;
}
.menubar summary:hover,
.menubar details[open] > summary {
background: #d9e9f6;
}
.menu {
position: absolute;
z-index: 20;
top: 100%;
left: 0;
min-width: 210px;
padding: 4px;
background: #f8fafc;
border: 1px solid #a6b9c8;
box-shadow: 2px 4px 9px #19395130;
display: flex;
flex-direction: column;
}
.menu button {
text-align: left;
background: transparent;
border: 0;
padding: 8px 14px;
white-space: nowrap;
}
.menu button:hover {
background: #d6e9f8;
}
.menu hr {
width: 100%;
margin: 3px 0;
border: 0;
border-top: 1px solid #d4dee7;
}
.menu-button {
border: 0;
background: transparent;
font-size: 12px;
}
.toolbar {
display: flex;
align-items: center;
gap: 5px;
padding: 7px 9px;
border-bottom: 1px solid #b7c6d3;
white-space: nowrap;
overflow: auto;
}
.toolbar label {
margin-left: 10px;
}
.toolbar input[type="text"],
#project-name {
width: 210px;
}
.toolbar i {
border-left: 1px solid #bac9d6;
height: 24px;
margin: 0 6px;
}
.work-tabs {
display: flex;
gap: 2px;
background: #dbe4ed;
padding: 4px 6px 0;
border-bottom: 1px solid #8fa9be;
overflow: auto;
flex: none;
}
.work-tabs button {
background: #e8edf3;
border: 1px solid #b8c8d5;
border-bottom: 0;
padding: 7px 20px;
white-space: nowrap;
}
.work-tabs .active {
background: #fff;
color: #1a547e;
font-weight: bold;
border-color: #8fa9be;
box-shadow: inset 0 3px #4e8cb9;
}
main {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
margin: 7px;
border: 1px solid #afc0cd;
background: #fff;
}
.work-title {
display: flex;
gap: 20px;
align-items: center;
padding: 7px 10px;
background: #dce8f2;
border-bottom: 1px solid #b6c7d6;
flex: none;
}
.work-title b {
font-size: 13px;
color: #1b5279;
}
.work-title span {
color: #688094;
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-tools {
padding: 5px 7px;
display: flex;
align-items: center;
gap: 4px;
background: #f1f5f8;
border-bottom: 1px solid #c5d2dc;
flex: none;
}
.row-tools input[type="search"] {
width: 230px;
margin-left: 14px;
}
#paging {
display: flex;
gap: 7px;
align-items: center;
margin-left: auto;
color: #708393;
font-size: 11px;
}
#paging button {
padding: 2px 9px;
}
.work-area {
display: flex;
flex: 1;
min-height: 0;
}
#group-pane {
width: 220px;
flex: none;
border-right: 3px solid #bdcbd7;
overflow: auto;
background: #f6f8fa;
}
#group-pane h2 {
font-size: 12px;
background: #e6edf3;
padding: 8px 10px;
margin: 0;
color: #516e85;
border-bottom: 1px solid #c2d1dd;
}
#group-list {
display: flex;
flex-direction: column;
}
#group-list button {
border: 0;
border-bottom: 1px solid #d4dfe8;
background: transparent;
text-align: left;
position: relative;
padding: 10px;
}
#group-list button:hover {
background: #e6f1fa;
}
#group-list .active {
background: #cde5f7;
border-left: 3px solid #3278aa;
}
#group-list b {
display: block;
font-size: 12px;
max-height: 37px;
overflow: hidden;
}
#group-list small {
display: block;
color: #7690a3;
margin-top: 3px;
}
#group-list span {
display: block;
text-align: right;
color: #24577c;
margin-top: 3px;
}
#work-content {
overflow: auto;
flex: 1;
min-width: 0;
background: #fff;
}
.sheet {
border-collapse: separate;
border-spacing: 0;
min-width: 100%;
font-variant-numeric: tabular-nums;
}
.sheet th,
.sheet td {
border-right: 1px solid #d2dde5;
border-bottom: 1px solid #d2dde5;
padding: 0;
white-space: nowrap;
}
.sheet thead th {
position: sticky;
top: 0;
z-index: 3;
background: linear-gradient(#edf3f8, #dce6ee);
color: #3c5a70;
text-align: center;
font-size: 11px;
font-weight: normal;
padding: 8px 5px;
min-width: 87px;
}
.sheet td {
height: 29px;
background: #fff;
min-width: 87px;
}
.sheet tr:nth-child(even) td {
background: #fafcfd;
}
.sheet input,
.sheet select,
.sheet textarea {
width: 100%;
height: 28px;
border: 0;
background: transparent;
padding: 4px 6px;
min-width: 85px;
}
.sheet input[type="number"] {
text-align: right;
-moz-appearance: textfield;
}
.sheet input::-webkit-inner-spin-button {
appearance: none;
}
.sheet .row-number {
position: sticky;
left: 0;
z-index: 2;
background: #e8eff5 !important;
min-width: 55px;
max-width: 55px;
text-align: center;
color: #647e91;
font-size: 10px;
}
.sheet .row-number input {
width: 12px;
height: 12px;
min-width: 0;
display: inline;
vertical-align: middle;
margin: 0 4px 0 0;
}
.sheet .row-number label {
padding: 7px 4px;
display: block;
cursor: pointer;
}
.sheet tr.selected td {
background: #e1f0fb;
}
.sheet tr.level td {
background: #eaf0f6;
font-weight: bold;
}
.sheet tr.heading td {
background: #e3edf5;
font-weight: bold;
}
.sheet .col-Name {
min-width: 210px;
}
.sheet .col-Size {
min-width: 185px;
}
.sheet .col-Code {
min-width: 115px;
}
.sheet .col-LineData {
min-width: 650px;
position: relative;
}
.sheet .col-LineData textarea {
min-width: 620px;
height: 31px;
resize: vertical;
white-space: pre;
font-family: "Malgun Gothic", monospace;
font-weight: normal;
padding-right: 22px;
}
.sheet .col-LineData .tiny {
position: absolute;
right: 0;
top: 4px;
padding: 0 4px;
}
.sheet .col-VSusic {
min-width: 250px;
}
.sheet .col-VSusic input {
min-width: 250px;
}
.sheet .col-Level,
.sheet .col-Index {
min-width: 50px;
}
.sheet .col-Level input,
.sheet .col-Index input {
min-width: 50px;
}
#messages {
background: #fff1df;
border-bottom: 1px solid #ead5ae;
padding: 9px;
color: #9a4c15;
flex: none;
max-height: 70px;
overflow: auto;
}
.totals {
display: flex;
justify-content: flex-end;
background: #e3edf5;
border-top: 1px solid #b5c8d7;
flex: none;
overflow: auto;
white-space: nowrap;
}
.totals span {
padding: 9px 15px;
border-left: 1px solid #c0d0dd;
color: #617b8f;
}
.totals b {
color: #183e5b;
margin-left: 8px;
font-size: 13px;
}
.totals span:last-child {
background: #d4e6f3;
}
footer {
display: flex;
justify-content: space-between;
align-items: center;
height: 24px;
padding: 0 9px;
background: #f0f4f7;
color: #668093;
font-size: 10px;
flex: none;
}
dialog {
border: 1px solid #8da9bf;
padding: 0;
max-width: 900px;
min-width: 380px;
max-height: 85vh;
background: #fff;
color: #203345;
box-shadow: 0 15px 60px #1239;
}
dialog::backdrop {
background: #17344c55;
}
.dialog-head {
position: sticky;
top: 0;
background: #dbe9f4;
padding: 9px 12px;
display: flex;
justify-content: space-between;
gap: 30px;
align-items: center;
z-index: 2;
}
.dialog-head button {
padding: 0 7px;
font-size: 19px;
}
#dialog-content {
padding: 18px;
overflow: auto;
}
#dialog-content table {
border-collapse: collapse;
min-width: 400px;
}
#dialog-content td,
#dialog-content th {
border: 1px solid #c8d5df;
padding: 7px 10px;
}
#dialog-content textarea {
width: 100%;
min-width: 550px;
font-family: monospace;
white-space: pre;
}
.form-fields {
display: grid;
gap: 12px;
margin-bottom: 18px;
}
.form-fields label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 25px;
}
.form-fields input,
.form-fields select {
width: 220px;
padding: 6px;
}
.marker-tools {
display: flex;
gap: 4px;
margin-bottom: 10px;
}
.business-tools {
padding: 12px;
display: flex;
align-items: center;
gap: 7px;
background: #edf3f8;
border-bottom: 1px solid #c9d6df;
flex-wrap: wrap;
}
.business-tools b {
margin-right: 15px;
}
.business-note {
padding: 12px 16px;
color: #627e92;
line-height: 1.7;
}
.empty {
padding: 30px;
color: #7c90a0;
}
.muted {
color: #7a90a0;
padding: 10px;
}
.business-tools input[type="checkbox"] {
width: auto;
}
.business-tools select {
padding: 5px;
}
.error-row {
background: #fff1e5;
}
@media (max-width: 800px) {
.toolbar label {
margin-left: 4px;
}
.work-tabs button {
padding: 7px 13px;
}
#group-pane {
width: 150px;
}
.totals {
justify-content: flex-start;
}
.totals span {
padding: 8px;
}
.work-title span {
display: none;
}
}
@media print {
header,
footer,
.totals,
.row-tools,
#group-pane,
.work-title,
#messages,
button {
display: none !important;
}
body,
main,
.work-area,
#work-content {
display: block;
height: auto;
overflow: visible;
border: 0;
margin: 0;
}
.sheet thead th {
position: static;
}
.sheet .row-number {
position: static;
}
input,
select,
textarea {
border: 0;
appearance: none;
}
.sheet {
font-size: 9px;
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+185
View File
@@ -0,0 +1,185 @@
// 독립 로컬 서버. Aislo의 DB·API·설정·패키지에 의존하지 않는다.
const http = require("node:http"),
fs = require("node:fs"),
path = require("node:path"),
vm = require("node:vm"),
crypto = require("node:crypto");
const { importEstx } = require("./import-estx.cjs");
const base = __dirname,
port = Number(process.env.EST_TEST_PORT || 5175);
const dataDirectory = process.env.EST_TEST_DATA_DIR || path.join(base, "data");
const scripts = [
"native-math.js",
"native-formula.js",
"native-machine.js",
"native-rows.js",
"native-text.js",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
"native-business.js",
"native-resources.js",
"native-model.js",
"native-verify.js",
];
const engine = vm.createContext({ structuredClone, console });
for (const file of scripts)
vm.runInContext(fs.readFileSync(path.join(base, file), "utf8"), engine, { filename: file });
function calculate(project) {
engine.input = project;
try {
return vm.runInContext(
"input.options?.AutoCalc === false ? EstModel.validate(input) : EstNative.calculate(EstModel.validate(input))",
engine,
{
timeout: 20000,
},
);
} finally {
delete engine.input;
}
}
const allowed = new Set([
"index.html",
"native.css",
"native-app.js",
"native-views.js",
"native-actions.js",
"native-extra.js",
...scripts,
]);
function reply(res, status, data, type = "application/json; charset=utf-8") {
res.writeHead(status, {
"Content-Type": type,
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
});
res.end(type.startsWith("application/json") ? JSON.stringify(data) : data);
}
async function body(req) {
const chunks = [];
let length = 0;
for await (const chunk of req) {
length += chunk.length;
if (length > 256 * 1024 * 1024) throw Error("파일은 256MB 이하만 열 수 있습니다.");
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
function list() {
if (!fs.existsSync(dataDirectory)) return [];
return fs
.readdirSync(dataDirectory)
.filter((name) => /^[a-f0-9]{24}\.json$/.test(name))
.map((name) => {
const file = path.join(dataDirectory, name),
record = JSON.parse(fs.readFileSync(file, "utf8"));
return {
id: name.slice(0, -5),
name: record.name,
modified: fs.statSync(file).mtime.toISOString(),
};
})
.sort((a, b) => b.modified.localeCompare(a.modified));
}
const server = http.createServer(async (req, res) => {
try {
const origin = req.headers.origin;
if (origin && origin !== `http://127.0.0.1:${port}` && origin !== `http://localhost:${port}`) {
reply(res, 403, { error: "다른 사이트의 요청은 허용하지 않습니다." });
return;
}
const url = new URL(req.url, "http://127.0.0.1");
if (req.method === "GET" && url.pathname === "/api/health") {
reply(res, 200, { app: "est-independent", schema: "est-web-2" });
return;
}
if (req.method === "GET" && url.pathname === "/api/sample") {
reply(res, 200, JSON.parse(fs.readFileSync(path.join(base, "sample.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"));
try {
reply(
res,
200,
vm.runInContext("EstVerification.run(sample,expected)", engine, { timeout: 20000 }),
);
} finally {
delete engine.sample;
delete engine.expected;
}
return;
}
if (req.method === "GET" && url.pathname === "/api/projects") {
reply(res, 200, list());
return;
}
if (req.method === "GET" && /^\/api\/projects\/[a-f0-9]{24}$/.test(url.pathname)) {
const id = url.pathname.split("/").pop(),
file = path.join(dataDirectory, `${id}.json`);
if (!fs.existsSync(file)) {
reply(res, 404, { error: "저장된 작업 없음" });
return;
}
reply(res, 200, JSON.parse(fs.readFileSync(file, "utf8")));
return;
}
if (req.method === "POST" && url.pathname === "/api/import") {
const bytes = await body(req),
name = String(url.searchParams.get("name") || "ESTX 가져오기").slice(0, 200);
reply(res, 200, importEstx(bytes, name));
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 요청이 필요합니다." });
return;
}
const project = JSON.parse((await body(req)).toString("utf8"));
const result = calculate(project);
if (url.pathname === "/api/save") {
fs.mkdirSync(dataDirectory, { recursive: true });
result.id = /^[a-f0-9]{24}$/.test(project.id || "")
? project.id
: crypto.randomBytes(12).toString("hex");
const target = path.join(dataDirectory, `${result.id}.json`),
temporary = `${target}.tmp`;
fs.writeFileSync(temporary, JSON.stringify(result), "utf8");
fs.renameSync(temporary, target);
}
reply(res, 200, result);
return;
}
if (req.method === "GET") {
const file = url.pathname === "/" ? "index.html" : decodeURIComponent(url.pathname.slice(1));
if (!allowed.has(file)) {
reply(res, 404, { error: "파일 없음" });
return;
}
const type = file.endsWith(".html")
? "text/html; charset=utf-8"
: file.endsWith(".css")
? "text/css; charset=utf-8"
: "text/javascript; charset=utf-8";
reply(res, 200, fs.readFileSync(path.join(base, file)), type);
return;
}
reply(res, 404, { error: "요청 경로 없음" });
} catch (error) {
reply(res, 400, { error: error.message });
}
});
server.on("error", (error) => {
console.error(
error.code === "EADDRINUSE"
? `포트 ${port}가 이미 사용 중입니다. 기존 서버를 확인하세요.`
: error.message,
);
process.exitCode = 1;
});
if (require.main === module)
server.listen(port, "127.0.0.1", () => console.log(`EST 독립 웹앱: http://127.0.0.1:${port}`));
module.exports = { server, calculate };
+4
View File
@@ -0,0 +1,4 @@
@echo off
cd /d "%~dp0"
node server.cjs
pause
+39 -1
View File
@@ -188,8 +188,39 @@
renderWorkspace();
});
onClick("modal-apply-expnd", () => {
const code = ($("expnd-code") || {}).value;
const ilwi =
(ctx.state.ilwiItems || []).find((x) => x.code === code) || ctx.state.ilwiItems[0];
if (ilwi && ilwi.lines && ilwi.lines.length) {
ilwi.lines.forEach((ln, idx) => {
const r = ctx.state.resources.find((x) => x.id === ln.refId || x.code === ln.refId);
const recno = (Date.now() % 90000) + idx + 1;
ctx.state.boqItems.push({
id: "BOQ_" + recno,
code: r ? r.code : ln.refId,
name: `[전개] ${r ? r.name : ln.refId}`,
refId: ln.refId,
rowType: "normal",
qty: ln.qty || 1,
changedQty: ln.qty || 1,
_recno: recno,
});
if (ctx.dbfData && ctx.dbfData.boq_rows) {
ctx.dbfData.boq_rows.splice(5 + idx, 0, {
_recno: recno,
ITNUM: "",
BDCODE1: r ? r.code : ln.refId,
NAME1: `[전개] ${r ? r.name : ln.refId}`,
SIZE1: r ? r.spec : "",
UNIT1: r ? r.unit : "식",
QTY1: String(ln.qty || 1),
ENDCODE: "0.0000",
});
}
});
}
closeModal();
alert("선택 공종의 일위대가/산근 구성행이 전개되었습니다.");
renderWorkspace();
});
bindEach(".btn-apply-qty-expr", "onclick", (btn) => {
const id = btn.getAttribute("data-id");
@@ -455,6 +486,13 @@
const code = $("ilwi-btn-apply-f9").getAttribute("data-code");
const txt = ($("smt-formula-textarea") || {}).value || "";
ctx.uiState.editedSmtMap[code] = txt;
if (ctx.dbfData && ctx.dbfData.costn_memos) {
const prev = ctx.dbfData.costn_memos[code] || { code, blockNo: 1 };
ctx.dbfData.costn_memos[code] = Object.assign({}, prev, {
text: txt,
byteLen: txt.length,
});
}
const evalRes = ViewDanIlwi.evaluateSmtFormulaText(txt, ctx);
if (evalRes.sumT > 0)
ViewPrjBoq.syncCostnUnitPrice(ctx, code, evalRes.sumM, evalRes.sumL, evalRes.sumS);
+18 -3
View File
@@ -327,7 +327,10 @@ const StmateCalc = (() => {
});
const directT = directM + directL + directS,
govCombinedRaw = govRawTotal + govFeeTotal;
const totals = { directM, directL, directS, directT, govRawTotal, govFeeTotal, govCombinedRaw };
const totals = Object.assign(
{ directM, directL, directS, directT },
{ govRawTotal, govMatTotal: govRawTotal, govFeeTotal, govCombinedRaw },
);
return Object.assign({ rows, directTotal: directT, totals }, totals);
}
@@ -348,7 +351,7 @@ const StmateCalc = (() => {
const truncAmt = truncInd(rawCalcAmt);
const base = Object.assign(
{ key: code, code, label, formula: baseDesc },
{ baseDesc, baseAmt, rate: ratePct, ratePct },
{ baseDesc, baseAmt, base: baseAmt, rate: ratePct, ratePct },
);
if (!origKanjubRes) return Object.assign(base, { amount: truncAmt, modeUsed: "원설계" });
const origRow = origKanjubRes.itemMap[code],
@@ -507,6 +510,7 @@ const StmateCalc = (() => {
["label", label],
["formula", formula],
["baseAmt", baseAmt],
["base", baseAmt],
["rate", rate],
["amount", amount],
]);
@@ -658,8 +662,19 @@ const StmateCalc = (() => {
kanjubOrig = calcKanjub(data, boqOrig, null);
const boqRev = calcBoq(data, resources.map, machines.map, sanCal.map, ilwi.map, true),
kanjubRev = calcKanjub(data, boqRev, kanjubOrig);
const revMap = Object.fromEntries((boqRev.rows || []).map((r) => [r.id, r]));
(boqOrig.rows || []).forEach((r) => {
const rv = revMap[r.id];
r.changedTAmt = rv ? rv.tAmt : r.tAmt;
r.diffTAmt = r.changedTAmt - r.tAmt;
});
const base = { resources, machines, sanCal, ilwi, boqOrig, kanjubOrig };
Object.assign(base, { boqRev, kanjubRev, resList: resources.list });
Object.assign(base, {
boqRev,
kanjubRev,
resList: resources.list,
revComputed: { revBoq: boqRev, revKj: kanjubRev },
});
return Object.assign(base, { boqComputed: boqOrig, kanjubComputed: kanjubOrig });
}
+21 -4
View File
@@ -82,13 +82,14 @@
const vExpr = m[2].trim();
const val = evalMathWithVars(vExpr);
if (val > 0 || vExpr === "0") {
vars[vName] = Math.round(val * 10000) / 10000;
vars[vName] = Math.round(val * 100) / 100;
assignedHere.push(`${vName} = ${vars[vName]}`);
}
}
// 2) 자원 참조 (&X00026L& / Q = , &M00080& * 0.00438'㎥' = )
// 2) 자원 참조 (&X00026L& / Q = , &M00080& * 0.00438'㎥' = ) 또는 주석형 단가 요약
const refMatch = ln.match(/&([A-Z]\d{4,5})([LMS])?&(.*)/);
const commentCostMatch = !refMatch && ln.match(/(재료비|노무비|경\s*비)\s*[:=]\s*([\d,]+)/);
let refInfo = null;
if (refMatch) {
const refCode = refMatch[1];
@@ -116,6 +117,20 @@
baseUnitPrice,
calcAmt: roundedAmt,
};
} else if (commentCostMatch) {
const cKind = commentCostMatch[1].replace(/\s+/g, "");
const cVal = num(commentCostMatch[2], 0);
const cTag = cKind === "노무비" ? "L" : cKind === "경비" ? "S" : "M";
if (cTag === "L") sumL += cVal;
else if (cTag === "S") sumS += cVal;
else sumM += cVal;
refInfo = {
refCode: cKind,
compTag: cTag,
refName: "단가 직접지정",
baseUnitPrice: cVal,
calcAmt: cVal,
};
}
linesOut.push({
@@ -357,8 +372,10 @@
ctx.uiState.selectedIlwiCode &&
bdxRows.some((r) => String(r.CODE).trim() === ctx.uiState.selectedIlwiCode)
? ctx.uiState.selectedIlwiCode
: bdxRows[0]
? String(bdxRows[0].CODE).trim()
: bdxRows.find((r) => Boolean(memos[String(r.CODE || "").trim()])) || bdxRows[0]
? String(
(bdxRows.find((r) => Boolean(memos[String(r.CODE || "").trim()])) || bdxRows[0]).CODE,
).trim()
: "";
const selRow = bdxRows.find((r) => String(r.CODE).trim() === selCode) || {};
+5 -3
View File
@@ -518,14 +518,16 @@
`;
}
if (toolKind === "wExpnd") {
const bdxList = (ctx.state.ilwiItems || []).slice(0, 30);
return `
<div style="display:flex;flex-direction:column;gap:10px;">
<p style="margin:0;"><strong>[wExpnd / wM_Expnd] 일위대가·단가산출 내역서 풀어넣기</strong> — 호표 구성행을 내역서 하위 행으로 전개합니다.</p>
<div class="kanjub-16grid" style="grid-template-columns:repeat(2,1fr);">
<p style="margin:0;"><strong>[wExpnd / wM_Expnd] 일위대가·단가산출 내역서 풀어넣기</strong> — 선택한 호표의 재료·노무·경비 구성행을 내역서에 직접 전개합니다.</p>
<div class="kanjub-16grid" style="grid-template-columns:repeat(3,1fr);">
<div class="kanjub-field"><label>전개할 호표 선택</label><select id="expnd-code">${bdxList.map((x) => `<option value="${x.code}">${x.code} ${x.rawName || x.name}</option>`).join("")}</select></div>
<div class="kanjub-field"><label>집계 구분선(RGrpToT)</label><select id="expnd-tot"><option value="E1">소계(=)</option><option value="E2">계(==)</option><option value="E3">합계(===)</option></select></div>
<div class="kanjub-field"><label>단가 끝전 처리</label><select id="expnd-round"><option value="trunc">절삭하여 소수 0위 적용</option><option value="round">반올림하여 소수 0위 적용</option></select></div>
</div>
<div style="text-align:right;"><button class="win-btn primary" id="modal-apply-expnd">일위대가 풀어넣기 확인</button></div>
<div style="text-align:right;"><button class="win-btn primary" id="modal-apply-expnd">일위대가 풀어넣기 실행</button></div>
</div>
`;
}