From d516d09fd97cc8643ed87e616dd6bb0198eebc07 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 4 Nov 2026 22:23:07 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(785-2):=20Bind=20B2=20=C2=B7=20B3=20?= =?UTF-8?q?=C2=B7=20B4=20785-1=20=EA=BC=B4=20=EB=A7=9E=EC=B6=A4=20=C2=B7?= =?UTF-8?q?=20Solve=20=ED=92=82=20=EC=A0=88=20parts=20=C2=B7=20=EC=A7=80?= =?UTF-8?q?=ED=82=B4=20=EC=8B=9C=ED=97=98=20test=5F642=5F2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bind: B2 선택값 줄 = 낱말 같음 고름(「사용횟수」 ≠ 「사용」) · B3 상수 이름 · 덧(90˚) · 단위 맞춤(sec = 초 · km → m) · 경사도 → 경사각도 · B4 식 분자 · 분모 {수|기호} + 대입 - Solve.parts: Formula.works 절 + Clause 후보 {키, 절} → 풂 절(절 표 없는 후보뿐이면 블록 통째) - 새 test_642_2_bind_solve: 예시 1 · 2 · 3 + 단산46 · 39 입력값 · 출처 꼬리표 · 입력 필요 · 엇갈림 · 지어 넣은 값 0 · 품 같음 · defaults 규칙 Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Hopyo_Bind.py | 36 +++- M02_MasterTemplete/M02_Hopyo_Solve.py | 28 ++++ resources/tester/test_642_2_bind_solve.py | 194 ++++++++++++++++++++++ 3 files changed, 254 insertions(+), 4 deletions(-) create mode 100644 resources/tester/test_642_2_bind_solve.py diff --git a/M02_MasterTemplete/M02_Hopyo_Bind.py b/M02_MasterTemplete/M02_Hopyo_Bind.py index abd3eeb74..9c8856c73 100644 --- a/M02_MasterTemplete/M02_Hopyo_Bind.py +++ b/M02_MasterTemplete/M02_Hopyo_Bind.py @@ -26,6 +26,7 @@ mf = m01.cm.mf mp = mf.mp mm = mp.mm import master_auto as ma # noqa: E402 — mf 를 불러 scripts 경로가 깔린 뒤 +import master_unit as mu # noqa: E402 SKIP = ("지역", "자재지역", "조종원직종") NEG = ("아님", "해당 없음", "없음", "미적용", "사용 안 함") @@ -37,7 +38,10 @@ SYMBOLS = { "L": ("운반거리", "거리"), "n": ("운반회수",), "T": ("실작업시간",), + "경사도": ("경사각도",), } +_WORDS = re.compile(r"[\s::,/()※*·\[\]{}]+") +_SAME_UNIT = {"sec": "초", "s": "초", "min": "분", "hr": "시간", "h": "시간"} _ANGLE = re.compile(r"\(\s*(\d+)\s*[˚°]\s*\)") _MIX = re.compile(r"(? None: def _b2(got: _Got, picks: list[dict], text: str, keys: dict) -> None: """선택값 줄 · 글에 없는 켜기 칸 = 아님(표 줄을 고르는 찾기 조건 칸은 B5 몫 · 뺌).""" - for one in picks or []: - _b1(got, {"이름": "", "규격": one.get("글"), "행": one.get("행")}, "B2") + for one in picks or []: # 고름 글 = 줄의 낱말 하나와 같음(「사용횟수」 ≠ 「사용」) + words = {mm.plain_spec(w) for w in _WORDS.split(str(one.get("글") or "")) if w} + for name, spec in got.specs.items(): + hits = [o for o in spec.get("고르기") or [] if mm.plain_spec(str(o)) in words] + if len(hits) == 1: + got.put(name, hits[0], "B2", one.get("행"), one.get("글")) flat = mm.plain_spec(text) for name, spec in got.specs.items(): opts = spec.get("고르기") or [] @@ -281,15 +289,35 @@ def _b2(got: _Got, picks: list[dict], text: str, keys: dict) -> None: got.put(name, neg, "B2", None, f"글에 「{name}」 없음") +def _unit_ok(have: Any, want: Any) -> bool: + """상수 단위 ↔ 입력 단위 — 한쪽이 없음 · 같음(「회/일」 = 회) · 같은 성격(길이 등).""" + a, b = (mm.plain_unit(str(u or "").split("/")[0]) for u in (have, want)) + a, b = _SAME_UNIT.get(a, a), _SAME_UNIT.get(b, b) + return not a or not b or a == b or mu.same_kind(a, b) + + def _b3(got: _Got, consts: list[dict], master: mf.Master, uses: dict) -> None: for c in consts or []: sym, value, at, text = str(c.get("기호") or ""), c.get("값"), c.get("행"), c.get("글") or "" angle = _ANGLE.search(f"({c.get('덧') or ''}) {c.get('단위') or ''} {text}") if angle: got.put("선회각도", angle.group(1), "B3", at, text) - names = [n for n in (c.get("이름"), sym, *SYMBOLS.get(sym, ())) if n and n in got.specs] + names = [ + n + for n in (c.get("이름"), sym, *SYMBOLS.get(sym, ())) + if n in got.specs and _unit_ok(c.get("단위"), got.specs[n].get("단위")) + ] if names: - got.put(names[0], value, "B3", at, f"{sym} = {plain(_num(value))}") + unit = got.specs[names[0]].get("단위") + rate = mu.factor(c.get("단위"), unit) if c.get("단위") and unit else None + value = _num(value) * rate if rate and _num(value) is not None else value + got.put( + names[0], + value, + "B3", + at, + f"{sym} = {plain(_num(c.get('값')))}{c.get('단위') or ''}", + ) continue for name, refs in uses.items(): # 표 칸 이름 = 기호 → 그 줄 조건값(K = 0.55 → 버킷조건) if name in got.value or name not in got.specs: diff --git a/M02_MasterTemplete/M02_Hopyo_Solve.py b/M02_MasterTemplete/M02_Hopyo_Solve.py index 6ee2a525e..33d5a5566 100644 --- a/M02_MasterTemplete/M02_Hopyo_Solve.py +++ b/M02_MasterTemplete/M02_Hopyo_Solve.py @@ -249,3 +249,31 @@ def block( else: verdict = BASE_PLUS if extra else BY_BASE return {"번": blk.get("번"), "판정": verdict, "고를수": n, "절": secs, "덧": extra} + + +def _gather(nodes: list[dict], key: str) -> list: + return [x for one in nodes for x in [*one.get(key, []), *_gather(one.get("아래", []), key)]] + + +def parts(blk: dict, cands: list[Any]) -> list[dict]: + """절 나무(785-1 `Formula.works`) + 후보(키 · `Clause` 후보 {키, 절}) → 풂 절 — 절 표 있는 + 후보가 있으면 일 절마다 그 절 후보 + 절 표 없는 후보 · 없으면 블록 통째 한 절 · + 단위 줄 · 상수 · 식 · 선택값(머리 것 더함) · 몫.""" + from M02_MasterTemplete import M02_Hopyo_Formula as fo + + nodes = blk.get("절") or fo.tree(blk.get("글") or [], blk.get("글행"), blk.get("항목") or []) + top = fo.head(blk.get("글") or [], blk.get("글행")) + cands = [c if isinstance(c, dict) else {"키": c} for c in cands] + items = blk.get("항목") or [] + work = fo.works(nodes) if any(c.get("절") for c in cands) else [] + if not work: # 블록 통째 한 일(절 표 있는 후보가 없으면 한 기초가 블록 전부) + whole = {"제목": blk.get("이름", ""), "후보": [c["키"] for c in cands], "항목": None} + return [whole | {k: top[k] + _gather(nodes, k) for k in ("상수", "식", "선택값")}] + out = [] + for one in work: + mine = [c["키"] for c in cands if c.get("절") in (None, one["번"])] + at = [i for i in _gather([one], "항목") if not items[i].get("밖")] + part = {"제목": one["제목"], "번": one["번"], "후보": mine, "항목": [items[i] for i in at]} + part |= {k: top[k] + _gather([one], k) for k in ("상수", "식", "선택값")} + out.append(part | {"몫": one.get("몫")}) + return out diff --git a/resources/tester/test_642_2_bind_solve.py b/resources/tester/test_642_2_bind_solve.py new file mode 100644 index 000000000..af46b81fa --- /dev/null +++ b/resources/tester/test_642_2_bind_solve.py @@ -0,0 +1,194 @@ +# -*- coding: utf-8 -*- +"""642-2(PLAN 785-2) 옵션값 읽기 · 검산 · 판정 — 북부청 임도 예시 1 · 2 · 3 + 단산46 · 39 기대표 +(설계 642-A 2-10 · 5-3) · 출처 꼬리표 · 지어 넣은 값 0 · 입력 필요 · 품 같음 · master_auto.defaults. +읽기만(엑셀 · M01) · storage 안 씀.""" + +from decimal import Decimal +from pathlib import Path + +import pytest + +from M02_MasterTemplete import M02_Hopyo_Bind as hb +from M02_MasterTemplete import M02_Hopyo_Excel as hx +from M02_MasterTemplete import M02_Hopyo_Solve as hs + +NORTH = ( + Path(__file__).resolve().parents[1] + / "knowledge/original/실무문서/2026_실무일위대가 모음" + / "2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls" +) +PATHS = {"B1", "B2", "B3", "B4", "B5", "B6"} +# 후보 = 785-1 Clause 몫(조항 색인) — 여기선 설계 2-10 후보 열 그대로 +CANDS = { + 10: ["GC001140", "GC001141", "GC001142"], + 24: ["GC000211"], + 46: ["GC000198"], + 47: [{"키": "GC000206", "절": "1"}, {"키": "GC000207", "절": "2"}], + 39: ["GF000224", "GF000225"], +} + + +@pytest.fixture(scope="module") +def master(): + return hb.m01.cm.master() + + +@pytest.fixture(scope="module") +def book(): + return hx.read_book(NORTH.name, NORTH.read_bytes()) + + +@pytest.fixture(scope="module") +def solved(master, book): + blocks = {b["번"]: b for b in book["단산"]} + out = {} + for no, cands in CANDS.items(): + hopyo = next( + h for h in book["호표"] if any((x.get("참조") or {}).get("번") == no for x in h["줄"]) + ) + line = next(x for x in hopyo["줄"] if (x.get("참조") or {}).get("번") == no) + same = [x for x in hopyo["줄"] if x is not line] + blk = blocks[no] + out[no] = hs.block(master, blk, hs.parts(blk, cands), {"줄": line, "같은줄": same}) + return out + + +def _cand(solved, no, key, sec=0): + return next(c for c in solved[no]["절"][sec]["후보"] if c["키"] == key) + + +def _where(cand): + return {k: v["길"] for k, v in cand["바인딩"]["출처"].items()} + + +def test_예시1_단산10_소운반(solved): + got = solved[10] + assert got["판정"] == "기초로 풂" and got["고를수"] == 0 + assert got["절"][0]["고름"] == "GC001140" + c = _cand(solved, 10, "GC001140") + b = c["바인딩"] + assert b["입력값"] == { + "운반무게": 575, + "회당운반량": 25, + "운반거리": 6, + "경사각도": 35, + "운반회수": 42, + } + assert _where(c) == { + "운반무게": "B4", + "회당운반량": "B4", + "운반거리": "B3", + "경사각도": "B3", + "운반회수": "B3", + } + assert b["입력필요"] == [] and b["엇갈림"] == {} + assert set(b["안씀"]) == {"재료종별", "재료형상", "재료갈래", "운반로", "실작업시간"} + (row,) = c["줄"] + assert row["판정"] == "품 같음" + assert Decimal(str(row["우리"]["품"])).quantize(Decimal("0.000001")) == Decimal("0.547619") + assert round(c["금액"]["계"], 1) == 94227.7 + + +def test_예시2_단산47_절_둘(solved): + got = solved[47] + one, two = got["절"] + assert (one["고름"], two["고름"]) == ("GC000206", "GC000207") + a = _cand(solved, 47, "GC000206", 0)["바인딩"] + assert a["입력값"] == {"Type": "Type-Ⅰ"} and a["출처"]["Type"]["길"] == "B1" + c = _cand(solved, 47, "GC000207", 1) + b = c["바인딩"] + assert b["입력값"] == {"Type": "Type-Ⅰ", "호": "Ⅰ-1", "소량감률": 50} + assert _where(c) == {"Type": "B1", "호": "B5", "소량감률": "B5"} + assert [x["값"] for x in b["엇갈림"]["구분"]] == ["토목", "건축"] + assert b["기본"]["구분"] == "토목" + assert [r["판정"] for r in c["줄"]] == ["품 같음"] * 4 + assert got["판정"] == "고를 것" and got["고를수"] == 1 # 구분 하나만 사용자 + + +def test_예시3_단산24_합판거푸집(solved, master, book): + c = _cand(solved, 24, "GC000211") + b = c["바인딩"] + assert b["입력값"] == { + "사용횟수": "4회", + "유형": "보통", + "지붕슬래브": "아님", + "폼타이": "아님", + } + assert _where(c) == {"사용횟수": "B1", "유형": "B2", "지붕슬래브": "B2", "폼타이": "B2"} + assert b["입력필요"] == ["높이감률", "구멍땜"] + assert b["기본"]["높이감률"] is None and b["기본"]["구멍땜"] is None # 규칙에 맞는 칸 없음 + assert set(b["안씀"]) == {"폼타이규격", "측압"} and b["비움"] == ["자재지역"] + assert solved[24]["판정"] == "고를 것" and solved[24]["고를수"] == 2 + # 사용자가 0 넣음 → 노무 줄 품 같음 · 노무비 38,291 = 엑셀 + given = {**b["입력값"], "높이감률": 0, "구멍땜": 0} + items = [x for x in next(b for b in book["단산"] if b["번"] == 24)["항목"] if not x.get("밖")] + rows = hs.pair(hb.quantities(master, "GC000211", given), items) + assert {r["판정"] for r in rows if r["엑셀"]} == {"품 같음"} + fill = {n: b["기본"][n] for n in b["안씀"]} | {"자재지역": None} + money = hb.mf.run(master, "GC000211", {**fill, **given}) + assert round(money["노무비"]) == 38291 + + +def test_단산46_레미콘타설(solved): + got = solved[46] + c = _cand(solved, 46, "GC000198") + b = c["바인딩"] + assert b["입력값"] == {"타설방법": "장비사용타설", "구조물": "철근구조물", "소량감률": 50} + assert _where(c) == {"타설방법": "B1", "구조물": "B1", "소량감률": "B5"} + assert b["입력필요"] == ["굴착기"] and b["비움"] == ["지역"] # 굴착기 규격 = B3 · 사용자 + mach = next(r for r in c["줄"] if r["엑셀"] and r["엑셀"]["묶음"] == "X") + assert mach["판정"] == "품 같음" and mach["표지"] == "기계 갈래 엇갈림" + assert [d["이름"] for d in got["덧"]] == ["레미콘"] # 별도계상 = 덧 줄 + + +def test_단산39_찰붙임(solved): + sec = solved[39]["절"][0] + assert sec["고름"] == "GF000225" and solved[39]["판정"] == "기초로 풂" + c = _cand(solved, 39, "GF000225") + assert c["바인딩"]["입력값"] == { + "뒷길이": 35, + "굵은골재최대치수": 25, + "강도": 21, + "슬럼프": "120", + "채움콘크리트량": 0.12, + "보정작업": "해당 없음", + } + assert _where(c)["뒷길이"] == "B1" and _where(c)["채움콘크리트량"] == "B6" + assert _cand(solved, 39, "GF000224")["판정"] == "품 다름" + + +def test_지어_넣은_값_없음(solved): + for got in solved.values(): + for sec in got["절"]: + for c in sec["후보"]: + b = c["바인딩"] + assert set(b["입력값"]) == set(b["출처"]) # 출처 없는 값 0 + assert {v["길"] for v in b["출처"].values()} <= PATHS + + +@pytest.mark.parametrize( + "excel, ours, want", + [ + (0.67, 0.6667, True), + (1.18, 1.1765, True), + (3.53, 3.5294, True), + (0.1111111111, 0.11111, True), + (0.67, 0.69, False), + (0.547619, 0.5476190476, True), + (None, 1, False), + ], +) +def test_품_같음(excel, ours, want): + assert hs.same(excel, ours) is want + + +def test_defaults_규칙(master): + ma = hb.ma + jige = ma.defaults(master, master.logic("GC001140")) + assert jige["실작업시간"] == 0 and jige["운반무게"] == 0 # 가드 + assert jige["경사각도"] == 0 and jige["운반거리"] is None # 고르기 첫째 · 못 정함 + pour = ma.defaults(master, master.logic("GC000198")) + assert pour["소량감률"] == 0 and pour["타설방법"] == "인력운반타설" # 0 을 품은 범위 + form = ma.defaults(master, master.logic("GC000211")) + assert form["높이감률"] is None and form["구멍땜"] is None + assert 1 not in [v for v in jige.values() if not isinstance(v, str)] # 지어 넣는 1 없음 From 1cc9dca9fcf00fe617dc38a99b191c066ea5934a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 4 Nov 2026 22:23:34 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(tools):=20STMATE=20=EC=9B=90=EB=B3=B8?= =?UTF-8?q?=20DBF/SMT/STC/JB=5FR=20=EC=A7=81=EC=A0=91=20=EC=9D=BD=EA=B8=B0?= =?UTF-8?q?=C2=B7=EC=93=B0=EA=B8=B0=20=EB=B8=8C=EB=A6=AC=EC=A7=80=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EB=B0=8F=20UI=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(tools/stmate-test)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/stmate-test/README.md | 14 +- tools/stmate-test/app.js | 16 +- tools/stmate-test/db_bridge.js | 345 +++++++++++++++++ tools/stmate-test/index.html | 2 + tools/stmate-test/server.py | 687 +++++++++++++++++++++++++++++++++ 5 files changed, 1060 insertions(+), 4 deletions(-) create mode 100644 tools/stmate-test/db_bridge.js create mode 100644 tools/stmate-test/server.py diff --git a/tools/stmate-test/README.md b/tools/stmate-test/README.md index 1fc154232..1b846aab4 100644 --- a/tools/stmate-test/README.md +++ b/tools/stmate-test/README.md @@ -4,10 +4,18 @@ 경쟁사 설계예산 프로그램 **STMATE**(경영정보(주))에 대한 **REA 6.7.0 / Ghidra 12.1.4 정적 분석** 및 지식DB(`resources/knowledge/original/경쟁사 프로그램/STmate 분석/`) 결과를 바탕으로, 주요 기능과 계산 사슬·제어 옵션을 브라우저에서 독립적으로 확인하고 조작할 수 있도록 만든 테스트 웹앱입니다. -- **완전 분리 동작**: Aislo 본체 서버·DB·로그인 세션과 무관하게 `index.html` 단독으로 동작합니다. -- **상태 보관 및 입출력**: 현재 브라우저 탭(`sessionStorage`)에 자동 보관되며, 상단 버튼으로 **JSON 저장 · 파일 열기 · 예제 초기화**를 지원합니다. +- **완전 분리 동작**: Aislo 본체 서버·공용 MariaDB·로그인 세션과 무관하게 동작합니다. +- **PC 내 STMATE 원본 DBF · STC · JB_R 직접 읽기/쓰기 연동 (`server.py`)**: + - 실행 명령: `.\venv\Scripts\python.exe tools\stmate-test\server.py` (포트 `8095`, `http://127.0.0.1:8095`) + - PC의 `C:\경영정보(주)\STmate 자료\설계자료\실습(맛보기)` 원본 DBF 폴더, `C:\경영정보(주)\System\ST_BSC\*.JB_R` 제비율 기준 DB(2015~2026년 41종), 및 `resources/knowledge/original/실무문서/*.STC` 실무 프로젝트 DB를 직접 읽어 시뮬레이터에 동기화하고, 수정된 단가·수량·제비율 결과를 CP949 고정폭 바이너리 DBF(`COSTN.DBF`, `BOQ11/12.DBF`, `PRJLST.DBF/.SMT`) 및 `.STC` 패키지로 직접 기록합니다. +- **상태 보관 및 입출력**: 현재 브라우저 탭(`sessionStorage`)에 자동 보관되며, 상단 버튼으로 **JSON 저장 · 파일 열기 · 예제 초기화**도 함께 지원합니다. -## 주요 확인 및 제어 기능 (6개 탭) +## 주요 확인 및 제어 기능 (7개 탭) + +0. **00 원본 DBF · STC 연동 (`server.py`, `db_bridge.js`)** + - PC 내 STMATE 원본 DBF 폴더 및 `.STC` 패키지 선택·불러오기 (`COSTN.DBF`, `BOQ11/12.DBF`, `PRJLST.DBF`, `PRJLST.SMT`) + - `C:\경영정보(주)\System\ST_BSC\*.JB_R` 제비율 기준 DBF(행20·45·46 좌표) 로드 및 원가계산 요율 즉시 적용 + - `COSTN.DBF` 단가 직접 편집 및 시뮬레이터 계산 결과의 **원본 호환 DBF / `.STC` 파일 직접 쓰기(`tools/stmate-test/work_dbf/`)** 1. **01 기초자원 · 노임환산 (`ST_Sys.CPM`, `COSTN.DBF`)** - 자재·노무·경비 기초자원의 5개 가격지 후보(`AMT_11..51`: 조달·물자·물정·거래·견적) 입력 diff --git a/tools/stmate-test/app.js b/tools/stmate-test/app.js index 5d6e7d200..c60ca00a1 100644 --- a/tools/stmate-test/app.js +++ b/tools/stmate-test/app.js @@ -256,7 +256,8 @@ function renderAll() { }); const panel = $("tabPanel"); - if (activeTab === "resources") panel.innerHTML = renderResourcesTab(res); + if (activeTab === "db") panel.innerHTML = StmateDbBridge.renderDbTab(state, res); + else if (activeTab === "resources") panel.innerHTML = renderResourcesTab(res); else if (activeTab === "machines") panel.innerHTML = renderMachinesTab(res); else if (activeTab === "ilwi") panel.innerHTML = renderIlwiTab(res); else if (activeTab === "boq") panel.innerHTML = StmateCostViews.renderBoqTab(state, res); @@ -681,4 +682,17 @@ $("fileOpen").addEventListener("change", async (e) => { } }); +if (typeof StmateDbBridge !== "undefined") { + StmateDbBridge.initEvents( + () => state, + (newSt) => { + state = newSt; + selectedIlwiId = state.ilwiItems[0]?.id || ""; + }, + () => { + saveState(); + renderAll(); + }, + ); +} renderAll(); diff --git a/tools/stmate-test/db_bridge.js b/tools/stmate-test/db_bridge.js new file mode 100644 index 000000000..4dfafaf29 --- /dev/null +++ b/tools/stmate-test/db_bridge.js @@ -0,0 +1,345 @@ +/* -*- coding: utf-8 -*- */ +/** + * STMATE 원본 DBF / SMT / STC / JB_R 직접 읽기·쓰기 연동 컨트롤러 (db_bridge.js) + */ +const StmateDbBridge = (() => { + const API_BASE = "http://127.0.0.1:8095"; + const esc = (s) => + String(s ?? "").replace( + /[&<>"']/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c], + ); + const fmt = (n, maxDec = 2) => + new Intl.NumberFormat("ko-KR", { maximumFractionDigits: maxDec }).format(Number(n) || 0); + + const dbCtx = { + connected: false, + sources: { projects: [], jbr_files: [], work_root: "" }, + selectedProjectPath: "", + selectedJbrPath: "", + targetFolderName: "stmate_edited", + loadedDbf: null, + loadedJbr: null, + lastSaveResult: null, + statusMsg: "로컬 DB 브리지(127.0.0.1:8095) 연결 확인 중...", + }; + + async function checkAndLoadSources(onUpdate) { + try { + const resp = await fetch(`${API_BASE}/api/sources`); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const data = await resp.json(); + dbCtx.connected = true; + dbCtx.sources = data; + if (!dbCtx.selectedProjectPath && data.projects.length > 0) { + dbCtx.selectedProjectPath = data.projects[0].path; + } + const onlyJbr = data.jbr_files.filter((f) => f.ext === ".JB_R"); + if (!dbCtx.selectedJbrPath && onlyJbr.length > 0) { + dbCtx.selectedJbrPath = onlyJbr[onlyJbr.length - 1].path; + } + dbCtx.statusMsg = `연결됨 — PC 프로젝트 DB ${data.projects.length}건 · ST_BSC 제비율 DB ${data.jbr_files.length}건 감지`; + } catch (_) { + dbCtx.connected = false; + dbCtx.statusMsg = + "오프라인 — 터미널에서 .\\venv\\Scripts\\python.exe tools\\stmate-test\\server.py 실행 시 DBF 직접 읽기/쓰기 활성화"; + } + if (onUpdate) onUpdate(); + } + + async function loadProjectDbf(applyStateCallback, onUpdate) { + if (!dbCtx.selectedProjectPath) return; + try { + dbCtx.statusMsg = `DBF/STC 읽는 중: ${dbCtx.selectedProjectPath} ...`; + if (onUpdate) onUpdate(); + const resp = await fetch(`${API_BASE}/api/load`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ source_path: dbCtx.selectedProjectPath }), + }); + if (!resp.ok) throw new Error(await resp.text()); + const data = await resp.json(); + dbCtx.loadedDbf = data; + dbCtx.lastSaveResult = null; + dbCtx.statusMsg = `로드 완료: ${data.prjlst?.PNAME || data.source_path} (COSTN ${data.costn_count}건, ${data.boq_table_name} ${data.boq_count}건)`; + if (data.sim_state && applyStateCallback) { + applyStateCallback(data.sim_state); + } + } catch (err) { + dbCtx.statusMsg = `DBF 로드 실패: ${err.message || err}`; + if (onUpdate) onUpdate(); + } + } + + async function loadAndApplyJbr(state, saveAndRender) { + if (!dbCtx.selectedJbrPath) return; + try { + const resp = await fetch(`${API_BASE}/api/load-jbr`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jbr_path: dbCtx.selectedJbrPath }), + }); + if (!resp.ok) throw new Error(await resp.text()); + const data = await resp.json(); + dbCtx.loadedJbr = data; + const r = data.extracted_rates; + Object.assign(state.kanjubConfig.rates, { + KANNO: r.KANNO || state.kanjubConfig.rates.KANNO, + KITA: r.KITA || state.kanjubConfig.rates.KITA, + ILBAN: r.ILBAN || state.kanjubConfig.rates.ILBAN, + SANBO: r.SANBO, + KOBO: r.KOBO, + KUNBO: r.KUNBO, + YUNBO: r.YUNBO, + SLVBO: r.SLVBO, + IYUN: r.IYUN, + ANJUN: r.ANJUN, + }); + state.kanjubConfig.anjunBaseAdd = r.anjunBaseAdd; + dbCtx.statusMsg = `ST_BSC 제비율 DB(${data.name}) 요율 적용 완료`; + if (saveAndRender) saveAndRender(); + } catch (err) { + dbCtx.statusMsg = `JB_R 로드 실패: ${err.message || err}`; + if (saveAndRender) saveAndRender(); + } + } + + async function saveToDbf(state, calcRes, onUpdate) { + const srcPath = dbCtx.loadedDbf?.source_path || dbCtx.selectedProjectPath; + if (!srcPath) return; + try { + const k = calcRes.kanjubOrig; + const costnUpdates = (dbCtx.loadedDbf?.costn_rows || []).slice(0, 80).map((r) => ({ + CODE: r.CODE, + NAME: r.NAME, + TAMT1: r.TAMT1, + LAMT1: r.LAMT1, + MAMT1: r.MAMT1, + SAMT1: r.SAMT1, + })); + // 시뮬레이터 기초자원 단가 변경분도 COSTN 업데이트에 병합 + for (const resItem of calcRes.resources.list) { + costnUpdates.push({ + CODE: resItem.code, + NAME: resItem.name, + TAMT1: resItem.adoptedPrice, + LAMT1: resItem.kind === "노무비" ? resItem.adoptedPrice : 0, + MAMT1: resItem.kind === "재료비" ? resItem.adoptedPrice : 0, + SAMT1: resItem.kind === "경비" ? resItem.adoptedPrice : 0, + }); + } + + const boqUpdates = calcRes.boqOrig.rows + .filter((r) => r._recno !== undefined) + .map((r) => ({ + _recno: r._recno, + NAME1: r.name, + QTY1: r.qty, + MAMT1: r.unitInfo.M, + LAMT1: r.unitInfo.L, + SAMT1: r.unitInfo.S, + TAMT1: r.unitInfo.T, + })); + + const prjlstUpdates = { + MCOST1: k.dM, + LCOST1: k.dL, + SCOST1: k.dS, + TCOST1: k.dT, + COSTW1: k.grandTotal, + }; + const rates = state.kanjubConfig.rates; + const smtUpdates = { + "1_R_KANNO": rates.KANNO, + "1_R_SANBO": rates.SANBO, + "1_R_KOBO": rates.KOBO, + "1_R_KUNBO": rates.KUNBO, + "1_R_SLVBO": rates.SLVBO, + "1_R_YUNBO": rates.YUNBO, + "1_R_ANJUN": rates.ANJUN, + "1_R_T_KITA": rates.KITA, + "1_R_HWAN": rates.HWAN, + "1_R_EQIPSU": rates.EQIP, + "1_R_ILBAN": rates.ILBAN, + "1_R_IYUN": rates.IYUN, + "1_C_KANNO": k.kanno, + "1_C_ILBAN": k.ilbanAmt, + "1_C_IYUN_M": k.pretaxProfitAdj, + "1_C_KONGSA_J": k.contractBeforeAdj, + "1_C_UPDNKWN": k.govFinal, + }; + + const resp = await fetch(`${API_BASE}/api/save`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + source_path: srcPath, + target_folder_name: dbCtx.targetFolderName || "stmate_edited", + costn_updates: costnUpdates, + boq_updates: boqUpdates, + prjlst_updates: prjlstUpdates, + smt_updates: smtUpdates, + }), + }); + if (!resp.ok) throw new Error(await resp.text()); + const saved = await resp.json(); + dbCtx.lastSaveResult = saved; + dbCtx.statusMsg = `DBF·STC 저장 완료: ${saved.saved_stc} (${fmt(saved.stc_size, 0)} bytes)`; + await checkAndLoadSources(); + } catch (err) { + dbCtx.statusMsg = `DBF 저장 실패: ${err.message || err}`; + } + if (onUpdate) onUpdate(); + } + + function renderDbTab(state, calcRes) { + const projOpts = dbCtx.sources.projects + .map( + (p) => + ``, + ) + .join(""); + const jbrOpts = dbCtx.sources.jbr_files + .map( + (f) => + ``, + ) + .join(""); + + let dbfPreviewHtml = + '
상단에서 PC 내 STMATE 폴더 또는 .STC 파일을 선택하고 [원본 DBF/STC 불러오기]를 실행하세요.
'; + if (dbCtx.loadedDbf) { + const d = dbCtx.loadedDbf; + const costnRows = (d.costn_rows || []) + .slice(0, 25) + .map( + (r, i) => ` + + ${esc(r.CODE)} + + ${esc(r.SIZE)} + ${esc(r.UNIT)} + + + + + ${fmt(r.AMT_61, 0)} + `, + ) + .join(""); + + dbfPreviewHtml = ` +
+ 현재 연결 원본: ${esc(d.prjlst?.PNAME || d.source_path)} · 수록 테이블: ${esc(d.file_list.join(", "))} + 원장 COSTN.DBF ${d.costn_count}건 · 내역 ${esc(d.boq_table_name)} ${d.boq_count}건 · PRJLST.SMT 설정키 ${Object.keys(d.prjlst_smt || {}).length}개 +
+
+ + + ${costnRows} +
CODE품명(NAME)규격(SIZE)단위재료단가(MAMT1)노무단가(LAMT1)경비단가(SAMT1)합계단가(TAMT1)채택6칸(AMT_61)
+
`; + } + + const saveAlert = dbCtx.lastSaveResult + ? `
최근 쓰기 완료 폴더: ${esc(dbCtx.lastSaveResult.saved_dir)}생성된 패키지: ${esc(dbCtx.lastSaveResult.saved_stc)} (${fmt(dbCtx.lastSaveResult.stc_size, 0)} bytes, 파일 ${dbCtx.lastSaveResult.written_count}종)
` + : ""; + + return ` +
+
+
+

PC 내 STMATE 원본 DBF · SMT · STC · JB_R 직접 읽기/쓰기 연동

+

C:\\경영정보(주) 설치 폴더의 프로젝트 DBF(COSTN.DBF, BOQ11.DBF, PRJLST.DBF/.SMT), System\\ST_BSC\\*.JB_R 제비율 기준 DB, 그리고 실무 .STC 패키지를 직접 읽고 CP949 고정폭 바이너리 DBF로 다시 기록합니다.

+
+
+ +
+
+
+ 상태: ${esc(dbCtx.statusMsg)} + 브리지 주소: ${API_BASE} +
+
+ +
+ +
+ +
+ +
+ +
+ +
+
+ ${saveAlert} +
+
+
+
+

원본 COSTN.DBF 레코드 직접 조회 및 편집 (상위 25건 미리보기)

+

여기서 수정한 단가와 01~06 탭에서 조작한 수량·요율·원가 결과가 [DBF 및 .STC로 직접 쓰기] 실행 시 COSTN.DBF, BOQ11/12.DBF, PRJLST.DBF/.SMT에 기록됩니다.

+
+
+ ${dbfPreviewHtml} +
`; + } + + function initEvents(getState, setState, saveAndRender) { + document.addEventListener("click", (e) => { + const btn = e.target.closest("[data-db-action]"); + if (!btn) return; + const act = btn.dataset.dbAction; + if (act === "refresh-sources") { + checkAndLoadSources(saveAndRender); + } else if (act === "load-project") { + loadProjectDbf((newState) => { + setState(newState); + saveAndRender(); + }, saveAndRender); + } else if (act === "load-jbr") { + loadAndApplyJbr(getState(), saveAndRender); + } else if (act === "save-dbf") { + const st = getState(); + saveToDbf(st, StmateCalc.calculateAll(st), saveAndRender); + } + }); + + document.addEventListener("change", (e) => { + const el = e.target; + if (el.id === "selDbProject") dbCtx.selectedProjectPath = el.value; + else if (el.id === "selDbJbr") dbCtx.selectedJbrPath = el.value; + else if (el.id === "inpTargetFolder") dbCtx.targetFolderName = el.value; + else if (el.dataset.dbf === "costn" && dbCtx.loadedDbf?.costn_rows) { + const idx = Number(el.dataset.idx); + const fld = el.dataset.field; + if (dbCtx.loadedDbf.costn_rows[idx]) { + dbCtx.loadedDbf.costn_rows[idx][fld] = el.value; + } + } + }); + + checkAndLoadSources(saveAndRender); + } + + return { + dbCtx, + initEvents, + checkAndLoadSources, + loadProjectDbf, + loadAndApplyJbr, + saveToDbf, + renderDbTab, + }; +})(); diff --git a/tools/stmate-test/index.html b/tools/stmate-test/index.html index 54f84b72b..9c1c832d3 100644 --- a/tools/stmate-test/index.html +++ b/tools/stmate-test/index.html @@ -7,6 +7,7 @@ + @@ -47,6 +48,7 @@