Merge remote-tracking branches 'origin/CODEX', 'origin/main_laptop_1' and 'origin/안티그래비티' into sub_laptop_1
This commit is contained in:
@@ -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"(?<!\d)(\d{2})-(\d{2,3})-(\d{2,3})(?!\d)")
|
||||
MIX_NAMES = ("굵은골재최대치수", "강도", "슬럼프")
|
||||
@@ -268,8 +272,12 @@ def _b6(got: _Got, line: dict | None, same: list[dict]) -> 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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 없음
|
||||
@@ -18,7 +18,7 @@ def calculate(data):
|
||||
def test_est_test_calculation_and_linked_price_change():
|
||||
data = {
|
||||
"sources": [{"id": "s", "kind": "재료비", "price": 10.129}],
|
||||
"units": [{"id": "u", "lines": [{"sourceId": "s", "quantity": 2}]}],
|
||||
"units": [{"id": "u", "lines": [{"sourceId": "s", "quantity": 2, "digits": 2}]}],
|
||||
"estimate": [{"unitId": "u", "quantity": 3}],
|
||||
"costs": [{"base": "직접공사비", "rate": 10}, {"base": "누계", "rate": 5}],
|
||||
}
|
||||
@@ -42,6 +42,74 @@ def test_est_test_unlinked_rows_contribute_zero():
|
||||
assert result["units"]["u"]["total"] == result["total"] == 0
|
||||
|
||||
|
||||
def test_source_selection_recursive_unit_and_percent():
|
||||
data = {
|
||||
"sources": [
|
||||
{
|
||||
"id": "s",
|
||||
"kind": "실적",
|
||||
"price": 900,
|
||||
"prices": [0, 125, 155, 0, 0, 0],
|
||||
"priceMode": "평균",
|
||||
}
|
||||
],
|
||||
"units": [
|
||||
{"id": "u1", "name": "기초", "lines": [{"sourceId": "s", "quantity": 2}]},
|
||||
{
|
||||
"id": "u2",
|
||||
"name": "상위",
|
||||
"lines": [
|
||||
{"type": "일위대가", "sourceId": "", "unitId": "u1", "quantity": 1},
|
||||
{
|
||||
"type": "비율",
|
||||
"sourceId": "",
|
||||
"base": "실적",
|
||||
"kind": "경비",
|
||||
"quantity": 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"estimate": [{"unitId": "u2", "quantity": 2}],
|
||||
"costs": [],
|
||||
}
|
||||
result = calculate(data)
|
||||
assert result["units"]["u1"]["parts"]["실적"] == 280
|
||||
assert result["units"]["u2"]["parts"]["경비"] == 28
|
||||
assert result["directTotal"] == 616
|
||||
|
||||
|
||||
def test_sample_percentage_row_and_cycle_error():
|
||||
data = {
|
||||
"sources": [{"id": "labor", "kind": "노무비", "price": 66841}],
|
||||
"units": [
|
||||
{
|
||||
"id": "u",
|
||||
"name": "호표",
|
||||
"lines": [
|
||||
{"sourceId": "labor", "quantity": 1},
|
||||
{
|
||||
"type": "비율",
|
||||
"sourceId": "",
|
||||
"base": "노무비",
|
||||
"kind": "재료비",
|
||||
"quantity": 2,
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
"estimate": [],
|
||||
"costs": [],
|
||||
}
|
||||
result = calculate(data)
|
||||
assert result["units"]["u"]["parts"]["재료비"] == 1336
|
||||
data["units"][0]["lines"].append(
|
||||
{"type": "일위대가", "sourceId": "", "unitId": "u", "quantity": 1}
|
||||
)
|
||||
assert result["errors"] == []
|
||||
assert calculate(data)["errors"]
|
||||
|
||||
|
||||
def test_progress_uses_approved_quantity_only_when_selected():
|
||||
script = (
|
||||
'const vm=require("vm"),fs=require("fs");const c={};'
|
||||
@@ -55,3 +123,71 @@ def test_progress_uses_approved_quantity_only_when_selected():
|
||||
assert ordinary["currentAmount"] == 300
|
||||
assert ordinary["cumulativeAmount"] == 500
|
||||
assert approved["cumulativeAmount"] == 300
|
||||
|
||||
|
||||
def test_machine_components():
|
||||
script = (
|
||||
'const vm=require("vm"),fs=require("fs");const c={};'
|
||||
'vm.runInNewContext(fs.readFileSync(process.argv[1],"utf8")+";this.calc=EstCalc",c);'
|
||||
"console.log(JSON.stringify(c.calc.machine({purchase:1000000,rentalCoefficient:100,"
|
||||
"fuelPrice:1000,fuelQuantity:2,suppliesRate:5,laborPrice:8000,laborFactor:0.125,laborHours:1})));"
|
||||
)
|
||||
result = json.loads(
|
||||
subprocess.check_output(["node", "-e", script, str(CALC)], text=True, encoding="utf-8")
|
||||
)
|
||||
assert result == {"재료비": 2100, "노무비": 1000, "경비": 10, "실적": 0}
|
||||
|
||||
|
||||
def test_formula_parser_rejects_invalid_expressions():
|
||||
formula = ROOT / "tools" / "est-test" / "formula.js"
|
||||
script = (
|
||||
'const vm=require("vm"),fs=require("fs");const c={};'
|
||||
'vm.runInNewContext(fs.readFileSync(process.argv[1],"utf8")+";this.f=EstFormula",c);'
|
||||
'let failed=false;try{c.f.evaluate("1/0",{})}catch{failed=true};'
|
||||
'console.log(JSON.stringify([c.f.evaluate("([금액]+2)*3",{"금액":8}),failed]));'
|
||||
)
|
||||
assert json.loads(
|
||||
subprocess.check_output(["node", "-e", script, str(formula)], text=True, encoding="utf-8")
|
||||
) == [30, True]
|
||||
|
||||
|
||||
def test_escalation_comparison_branches():
|
||||
workflows = ROOT / "tools" / "est-test" / "workflows.js"
|
||||
script = (
|
||||
'const vm=require("vm"),fs=require("fs");const c={};'
|
||||
'vm.runInNewContext(fs.readFileSync(process.argv[1],"utf8")+";this.w=EstWorkflows",c);'
|
||||
"console.log(JSON.stringify([c.w.escalation(100,80,120,2),"
|
||||
"c.w.escalation(100,110,125,2),c.w.escalation(100,80,90,1),"
|
||||
"c.w.excluded(3,5,4)]));"
|
||||
)
|
||||
a, b, lower, excluded = json.loads(
|
||||
subprocess.check_output(["node", "-e", script, str(workflows)], text=True, encoding="utf-8")
|
||||
)
|
||||
assert (a["rate"], a["difference"], a["amount"]) == (20, 16, 192)
|
||||
assert (b["difference"], b["amount"]) == (15, 250)
|
||||
assert lower["difference"] == 0
|
||||
assert excluded == 5
|
||||
|
||||
|
||||
def test_resource_quantities_follow_nested_units():
|
||||
workflows = ROOT / "tools" / "est-test" / "workflows.js"
|
||||
script = (
|
||||
'const vm=require("vm"),fs=require("fs");const c={};'
|
||||
'vm.runInNewContext(fs.readFileSync(process.argv[1],"utf8")+";this.w=EstWorkflows",c);'
|
||||
"console.log(JSON.stringify(c.w.resources(JSON.parse(process.argv[2]))));"
|
||||
)
|
||||
data = {
|
||||
"sources": [{"id": "s", "name": "자재"}],
|
||||
"units": [
|
||||
{"id": "a", "lines": [{"sourceId": "s", "quantity": 2}]},
|
||||
{"id": "b", "lines": [{"type": "일위대가", "unitId": "a", "quantity": 3}]},
|
||||
],
|
||||
"estimate": [{"unitId": "b", "quantity": 4}],
|
||||
}
|
||||
result = json.loads(
|
||||
subprocess.check_output(
|
||||
["node", "-e", script, str(workflows), json.dumps(data)], text=True, encoding="utf-8"
|
||||
)
|
||||
)
|
||||
assert result["rows"][0]["quantity"] == 24
|
||||
assert result["errors"] == []
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
`index.html`을 브라우저에서 열면 됩니다. 별도 서버, 로그인, 설치 또는 Aislo DB가 필요하지 않습니다.
|
||||
|
||||
기초 단가 → 일위대가 → 내역서 → 요율 원가계산 흐름, 간단한 수량산출 연결과 기성 수량 입력을 시험합니다. 값은 현재 탭의 임시 저장소에 보관되며 JSON 저장·열기로 파일을 직접 관리합니다. 예제 수치와 요율은 임의의 테스트 값입니다.
|
||||
기초 단가의 6개 출처 선택, 일위대가 간 참조·비율, 4개 비목 집계, 단순 기계경비, 원가 사칙연산 수식, 수량산출 연결, 기성 수량, 물가변동 품목 단가, 동원 수량 전개를 시험합니다. 값은 현재 탭의 임시 저장소에 보관되며 JSON 저장·열기로 파일을 직접 관리합니다. 예제 수치와 요율은 임의의 테스트 값입니다.
|
||||
|
||||
EST Plus 2 REA 분석의 일부 계산 흐름만 구현 중이며 원본의 기능이나 결과와 동등하지 않습니다. 수식 단가산출, 기계경비, ESTX 가져오기, 설계변경, 기성 회차 이력·원가, 물가변동 및 법정 요율 등은 구현하지 않았습니다. 상단 메뉴 이름은 배치 참고용이며 실제 기능은 작업화면 이동과 도구 버튼으로 사용합니다.
|
||||
EST Plus 2 REA 분석의 일부 계산 흐름만 구현 중이며 원본의 기능이나 결과와 동등하지 않습니다. 수식 단가산출, 기계경비의 외화·다중 연료·낙찰 분기, ESTX 가져오기, 설계변경, 기성 회차 이력·원가, 물가변동 원가 연동, 동원의 산근 전개와 법정 요율 등은 구현하지 않았습니다. 상단 메뉴 이름은 배치 참고용이며 실제 기능은 작업화면 이동과 도구 버튼으로 사용합니다.
|
||||
|
||||
+33
-14
@@ -2,6 +2,8 @@ const key = 'aislo-est-test-v1';
|
||||
const sample = () => ({
|
||||
projectName: '시험 내역서',
|
||||
quantities: [],
|
||||
machines: [],
|
||||
escalation: [],
|
||||
progress: [],
|
||||
progressApproved: false,
|
||||
sources: [
|
||||
@@ -18,11 +20,11 @@ function valid(d) {
|
||||
const text = v => typeof v === 'string' && v.length <= 500;
|
||||
const num = v => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1e12;
|
||||
const ids = arr => new Set(arr.map(x => x.id)).size === arr.length;
|
||||
return d && (d.projectName === undefined || text(d.projectName)) && (d.progressApproved === undefined || typeof d.progressApproved === 'boolean') && (d.progress === undefined || (Array.isArray(d.progress) && d.progress.length <= 1000 && d.progress.every(p => text(p.estimateId) && num(p.previous) && num(p.current) && num(p.approved)))) && (d.quantities === undefined || (Array.isArray(d.quantities) && d.quantities.length <= 1000 && d.quantities.every(q => text(q.id) && text(q.estimateId) && text(q.name) && num(q.length) && num(q.width) && num(q.count)))) && ['sources', 'units', 'estimate', 'costs'].every(k => Array.isArray(d[k]) && d[k].length <= 1000) &&
|
||||
d.sources.every(s => text(s.id) && text(s.name) && text(s.spec) && text(s.unit) && EstCalc.kinds.includes(s.kind) && num(s.price)) && ids(d.sources) &&
|
||||
d.units.every(u => text(u.id) && text(u.name) && text(u.unit) && Array.isArray(u.lines) && u.lines.length <= 1000 && u.lines.every(l => text(l.sourceId) && num(l.quantity))) && ids(d.units) &&
|
||||
return d && (d.projectName === undefined || text(d.projectName)) && (d.escalation === undefined || (Array.isArray(d.escalation) && d.escalation.length <= 1000 && d.escalation.every(e => text(e.name) && ['bid','contract','current','quantity','planned','actual','progress'].every(k=>num(e[k]))))) && (d.machines === undefined || (Array.isArray(d.machines) && d.machines.length <= 1000 && d.machines.every(m => text(m.sourceId) && ['purchase','rentalCoefficient','fuelPrice','fuelQuantity','suppliesRate','laborPrice','laborFactor','laborHours'].every(k => num(m[k]))))) && (d.progressApproved === undefined || typeof d.progressApproved === 'boolean') && (d.progress === undefined || (Array.isArray(d.progress) && d.progress.length <= 1000 && d.progress.every(p => text(p.estimateId) && num(p.previous) && num(p.current) && num(p.approved)))) && (d.quantities === undefined || (Array.isArray(d.quantities) && d.quantities.length <= 1000 && d.quantities.every(q => text(q.id) && text(q.estimateId) && text(q.name) && num(q.length) && num(q.width) && num(q.count)))) && ['sources', 'units', 'estimate', 'costs'].every(k => Array.isArray(d[k]) && d[k].length <= 1000) &&
|
||||
d.sources.every(s => text(s.id) && text(s.name) && text(s.spec) && text(s.unit) && EstCalc.kinds.includes(s.kind) && num(s.price) && (s.prices === undefined || (Array.isArray(s.prices) && s.prices.length === 6 && s.prices.every(num))) && (s.priceMode === undefined || ['직접입력','최저','평균','최고','선택'].includes(s.priceMode)) && (s.priceIndex === undefined || (Number.isInteger(s.priceIndex) && s.priceIndex >= 0 && s.priceIndex < 6))) && ids(d.sources) &&
|
||||
d.units.every(u => text(u.id) && text(u.name) && text(u.unit) && Array.isArray(u.lines) && u.lines.length <= 1000 && u.lines.every(l => text(l.sourceId) && num(l.quantity) && (l.type === undefined || ['기초단가','일위대가','비율'].includes(l.type)) && (l.unitId === undefined || text(l.unitId)) && (l.digits === undefined || (Number.isInteger(l.digits) && l.digits >= 0 && l.digits <= 6)) && (l.base === undefined || [...EstCalc.kinds,'합계','비목별'].includes(l.base)) && (l.kind === undefined || EstCalc.kinds.includes(l.kind)))) && ids(d.units) &&
|
||||
d.estimate.every(e => text(e.id) && text(e.name) && text(e.unitId) && num(e.quantity)) && ids(d.estimate) &&
|
||||
d.costs.every(c => text(c.id) && text(c.name) && [...EstCalc.kinds, '직접공사비', '누계'].includes(c.base) && num(c.rate)) && ids(d.costs);
|
||||
d.costs.every(c => text(c.id) && text(c.name) && [...EstCalc.kinds, '직접공사비', '누계'].includes(c.base) && num(c.rate) && (c.formula === undefined || text(c.formula))) && ids(d.costs);
|
||||
}
|
||||
let data;
|
||||
try { const saved = JSON.parse(sessionStorage.getItem(key)); data = valid(saved) ? saved : sample(); } catch { data = sample(); }
|
||||
@@ -42,14 +44,18 @@ const card = (title, subtitle, button, body, note = '') => `<div class="card"><d
|
||||
function render() {
|
||||
const result = EstCalc.calculate(data);
|
||||
$('projectName').value = data.projectName || '';
|
||||
$('summary').innerHTML = [['재료비',result.direct['재료비']],['노무비',result.direct['노무비']],['경비',result.direct['경비']],['원가 합계',result.total]].map(([label,n]) => `<div class="metric"><span>${label}</span><strong>${money(n)} 원</strong></div>`).join('');
|
||||
$('summary').innerHTML = [['재료비',result.direct['재료비']],['노무비',result.direct['노무비']],['경비',result.direct['경비']],['실적',result.direct['실적']],['원가 합계',result.total]].map(([label,n]) => `<div class="metric"><span>${label}</span><strong>${money(n)} 원</strong></div>`).join('');
|
||||
if (result.errors.length) $('summary').innerHTML += `<div class="metric warning" role="alert">계산 오류: ${esc(result.errors.join(' / '))}</div>`;
|
||||
document.querySelectorAll('[data-tab]').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
|
||||
const sourceChoices = [['', '선택'], ...data.sources.map(s => [s.id, `${s.name} (${s.unit})`])];
|
||||
const unitChoices = [['', '선택'], ...data.units.map(u => [u.id, `${u.name} (${u.unit})`])];
|
||||
const kindChoices = EstCalc.kinds.map(k => [k,k]);
|
||||
if (tab === 'sources') {
|
||||
const rows = data.sources.map((s,i) => `<tr><td>${field('sources',i,'name',s.name)}</td><td>${field('sources',i,'spec',s.spec)}</td><td>${field('sources',i,'unit',s.unit)}</td><td>${select('sources',i,'kind',s.kind,kindChoices)}</td><td>${field('sources',i,'price',s.price,'number')}</td><td>${remove('sources',i)}</td></tr>`);
|
||||
$('panel').innerHTML = card('기초 단가', '자재·노무·장비 등의 시험 단가를 직접 입력합니다.', add('sources','단가 추가'), table(['품명','규격','단위','비목','단가 (원)',''],rows), '이곳의 단가가 일위대가 구성 행에 연결됩니다. 연결 중인 단가는 삭제할 수 없습니다.');
|
||||
const rows = data.sources.map((s,i) => `<tr><td>${field('sources',i,'name',s.name)}</td><td>${field('sources',i,'spec',s.spec)}</td><td>${field('sources',i,'unit',s.unit)}</td><td>${select('sources',i,'kind',s.kind,kindChoices)}</td><td>${select('sources',i,'priceMode',s.priceMode || '직접입력',['직접입력','최저','평균','최고','선택'].map(k=>[k,k]))}</td>${Array.from({length:6},(_,n)=>`<td>${field('sources',i,`price${n}`,s.prices?.[n] || 0,'number')}</td>`).join('')}<td>${field('sources',i,'priceIndex',(s.priceIndex || 0)+1,'number')}</td><td>${field('sources',i,'price',s.price,'number')}</td><td class="money">${money(EstCalc.sourcePrice(s))}</td><td>${remove('sources',i)}</td></tr>`);
|
||||
$('panel').innerHTML = card('기초 단가', '단가 출처 여섯 칸과 적용 방법을 선택합니다.', add('sources','단가 추가'), table(['품명','규격','단위','비목','적용 방법','출처1','출처2','출처3','출처4','출처5','출처6','선택 번호','입력 단가','적용 단가',''],rows), '최저·평균·최고는 0이 아닌 출처를 사용하며 평균은 정수 절사합니다.');
|
||||
} else if (tab === 'machines') {
|
||||
const rows = (data.machines || []).map((m,i) => { const p=EstCalc.machine(m); return `<tr><td>${select('machines',i,'sourceId',m.sourceId,sourceChoices)}</td>${['purchase','rentalCoefficient','fuelPrice','fuelQuantity','suppliesRate','laborPrice','laborFactor','laborHours'].map(k=>`<td>${field('machines',i,k,m[k],'number')}</td>`).join('')}${EstCalc.kinds.map(k=>`<td class="money">${money(p[k])}</td>`).join('')}<td>${remove('machines',i)}</td></tr>`; });
|
||||
$('panel').innerHTML = card('기계경비', '기초단가 항목에 손료·연료·운전사 금액을 연결합니다.', add('machines','기계 추가'), table(['연결 기초단가','기계가격','손료계수','연료단가','연료량','잡품률 %','노임단가','환산계수','운전시간','재료비','노무비','경비','실적',''],rows), '손료 = 기계가격 × 손료계수 × 10⁻⁷, 잡품 = 연료 × 잡품률, 운전사 = 노임 × 환산계수 × 시간. 외화·다중 연료·낙찰률 등은 아직 미구현입니다.');
|
||||
} else if (tab === 'units') {
|
||||
if (!data.units.some(u => u.id === selected)) selected = data.units[0]?.id || '';
|
||||
const u = data.units.find(u => u.id === selected), ui = data.units.indexOf(u);
|
||||
@@ -58,17 +64,24 @@ function render() {
|
||||
if (u) {
|
||||
body += table(['일위대가 이름','단위','합계 (원)'],[`<tr><td>${field('units',ui,'name',u.name)}</td><td>${field('units',ui,'unit',u.unit)}</td><td class="money">${money(result.units[u.id].total)}</td></tr>`]);
|
||||
body += `<div class="cardhead"><h2>구성 항목</h2>${add('lines','구성 추가')}</div>`;
|
||||
body += table(['기초 단가','소요량','단가 (원)','비목','금액 (원)',''],u.lines.map((l,i) => {const r=result.units[u.id].lines[i]; return `<tr><td>${select('lines',i,'sourceId',l.sourceId,sourceChoices)}</td><td>${field('lines',i,'quantity',l.quantity,'number')}</td><td class="money">${r.source ? money(r.source.price) : '—'}</td><td>${esc(r.source?.kind || '미연결')}</td><td class="money">${money(r.amount)}</td><td>${remove('lines',i)}</td></tr>`;}));
|
||||
body += `<div class="details">${EstCalc.kinds.map(k=>`${k} ${money(result.units[u.id].parts[k])}원`).join(' · ')}<br>행 금액은 소수 둘째 자리까지 절사하고 비목별 합계는 원 미만을 절사합니다.</div>`;
|
||||
body += table(['종류','연결 / 비율 기준','결과 비목','수량 / 요율','자릿수','금액 (원)',''],u.lines.map((l,i) => {const r=result.units[u.id].lines[i]; const type=l.type || '기초단가'; const link=type==='일위대가' ? select('lines',i,'unitId',l.unitId || '',unitChoices.filter(x => x[0] !== u.id)) : type==='비율' ? select('lines',i,'base',l.base || '합계',[...EstCalc.kinds,'합계','비목별'].map(k=>[k,k])) : select('lines',i,'sourceId',l.sourceId,sourceChoices); return `<tr><td>${select('lines',i,'type',type,['기초단가','일위대가','비율'].map(k=>[k,k]))}</td><td>${link}</td><td>${type==='비율' ? select('lines',i,'kind',l.kind || '경비',kindChoices) : esc(r.source?.kind || (r.unit ? '비목별' : '미연결'))}</td><td>${field('lines',i,'quantity',l.quantity,'number')}</td><td>${field('lines',i,'digits',l.digits ?? 0,'number','max="6"')}</td><td class="money">${money(r.amount)}</td><td>${remove('lines',i)}</td></tr>`;}));
|
||||
body += `<div class="details">${EstCalc.kinds.map(k=>`${k} ${money(result.units[u.id].parts[k])}원`).join(' · ')}<br>행 비목별 자릿수 절사 후 합계를 원 미만 절사합니다. ${esc(result.errors.join(' / '))}</div>`;
|
||||
} else body += '<div class="empty">일위대가를 추가하세요.</div>';
|
||||
$('panel').innerHTML = card('일위대가', '기초 단가와 소요량을 조합해 단위당 금액을 계산합니다.', add('units','일위대가 추가'), body);
|
||||
} else if (tab === 'estimate') {
|
||||
const rows = data.estimate.map((e,i) => {const u=result.units[e.unitId], r=result.rows[i]; return `<tr><td>${field('estimate',i,'name',e.name)}</td><td>${select('estimate',i,'unitId',e.unitId,unitChoices)}</td><td>${field('estimate',i,'quantity',e.quantity,'number')}</td><td class="money">${u ? money(u.total) : '—'}</td>${EstCalc.kinds.map(k=>`<td class="money">${money(r.parts[k])}</td>`).join('')}<td class="money">${money(r.total)}</td><td>${remove('estimate',i)}</td></tr>`;});
|
||||
$('panel').innerHTML = card('공사 내역서','일위대가와 공사 수량을 연결합니다.',add('estimate','내역 추가'),table(['공종명','일위대가','수량','단가','재료비','노무비','경비','금액',''],rows),'각 비목의 단가 × 수량을 원 미만 절사하여 직접공사비로 집계합니다.');
|
||||
$('panel').innerHTML = card('공사 내역서','일위대가와 공사 수량을 연결합니다.',add('estimate','내역 추가'),table(['공종명','일위대가','수량','단가','재료비','노무비','경비','실적','금액',''],rows),'각 비목의 단가 × 수량을 원 미만 절사하여 직접공사비로 집계합니다.');
|
||||
} else if (tab === 'quantities') {
|
||||
const choices = [['', '선택'], ...data.estimate.map(e => [e.id, e.name])];
|
||||
const rows = (data.quantities || []).map((q,i) => `<tr><td>${field('quantities',i,'name',q.name)}</td><td>${select('quantities',i,'estimateId',q.estimateId,choices)}</td><td>${field('quantities',i,'length',q.length,'number')}</td><td>${field('quantities',i,'width',q.width,'number')}</td><td>${field('quantities',i,'count',q.count,'number')}</td><td class="money">${money(q.length*q.width*q.count)}</td><td>${remove('quantities',i)}</td></tr>`);
|
||||
$('panel').innerHTML = card('수량산출서','수량을 산출하고 선택한 내역에 반영합니다.',`${add('quantities','산출 행 추가')} <button id="applyQuantities">내역에 수량 반영</button>`,table(['산출 항목','연결 공종','길이','폭','횟수','산출 수량',''],rows),'산출 수량 = 길이 × 폭 × 횟수. 같은 공종에 연결된 산출 수량을 합산해 내역 수량을 덮어씁니다.');
|
||||
} else if (tab === 'resources') {
|
||||
const output=EstWorkflows.resources(data);
|
||||
const rows=output.rows.map(({source,quantity})=>`<tr><td>${esc(source.name)}</td><td>${esc(source.spec)}</td><td>${esc(source.unit)}</td><td>${esc(source.kind)}</td><td class="money">${money(quantity)}</td><td class="money">${money(EstCalc.sourcePrice(source))}</td></tr>`);
|
||||
$('panel').innerHTML=card('동원인원·자재·장비', '내역 수량에서 일위대가를 전개하여 기초단가별 수량을 집계합니다.', '',table(['품명','규격','단위','비목','소요 수량','단가'],rows),`비율 행의 금액 전개, 단가산출 글과 장비 시간 환산은 미구현입니다. ${esc(output.errors.join(' / '))}`);
|
||||
} else if (tab === 'escalation') {
|
||||
const rows=(data.escalation || []).map((e,i)=>{ const r=EstWorkflows.escalation(e.bid,e.contract,e.current,e.quantity), excluded=EstWorkflows.excluded(e.planned,e.actual,e.progress); return `<tr><td>${field('escalation',i,'name',e.name)}</td>${['bid','contract','current','quantity','planned','actual','progress'].map(k=>`<td>${field('escalation',i,k,e[k],'number')}</td>`).join('')}<td class="money">${money(r.rate)}%</td><td class="money">${money(r.difference)}</td><td class="money">${money(r.price)}</td><td class="money">${money(r.amount)}</td><td class="money">${money(excluded)}</td><td>${remove('escalation',i)}</td></tr>`; });
|
||||
$('panel').innerHTML=card('물가변동', '품목별 기준·계약·비교 단가와 적용제외 수량을 계산합니다.',add('escalation','품목 추가'),table(['품목','입찰 당시','계약 단가','변동 당시','수량','예정공정','실행공정','기성','등락률','등락폭','조정단가','조정금액','제외 수량',''],rows),'REA의 품목조정 경로입니다. 원가계산서 재계산·파일 연동과 성분별 자동 전개는 아직 미구현입니다.');
|
||||
} else if (tab === 'progress') {
|
||||
const rows = (data.progress || []).map((p,i) => {
|
||||
const e = data.estimate.find(x => x.id === p.estimateId), price = e ? result.units[e.unitId]?.total || 0 : 0;
|
||||
@@ -78,8 +91,8 @@ function render() {
|
||||
$('panel').innerHTML = card('기성고', '전회·금회·사정 수량을 입력해 금회와 누계를 확인합니다.', '<button id="syncProgress">내역 불러오기</button>', `<div class="details"><label><input id="progressApproved" type="checkbox" style="width:auto;min-width:0" ${data.progressApproved ? 'checked' : ''}> 누계에 금회사정 수량 사용</label></div>${table(['공종','계약 수량','단가','전회 수량','금회 수량','금회사정 수량','금회 금액','누계 수량','누계 금액',''],rows)}`, '내역 불러오기는 새 공종만 추가합니다. 이 화면은 수량 입력 방식만 지원하며 회차별 이력·기성 원가계산은 아직 구현되지 않았습니다.');
|
||||
} else {
|
||||
const bases = [...EstCalc.kinds,'직접공사비','누계'].map(k=>[k,k]);
|
||||
const rows = data.costs.map((c,i) => `<tr><td>${field('costs',i,'name',c.name)}</td><td>${select('costs',i,'base',c.base,bases)}</td><td class="money">${money(result.costs[i].base)}</td><td>${field('costs',i,'rate',c.rate,'number')}</td><td class="money">${money(result.costs[i].amount)}</td><td class="money">${money(result.costs[i].running)}</td><td>${remove('costs',i)}</td></tr>`);
|
||||
$('panel').innerHTML = card('원가계산서','기준 금액과 요율을 선택해 위에서 아래로 계산합니다.',add('costs','요율 행 추가'),table(['항목','기준','기준 금액','요율 (%)','계산 금액','누계',''],[`<tr class="total"><td colspan="4">직접공사비</td><td class="money">${money(result.directTotal)}</td><td class="money">${money(result.directTotal)}</td><td></td></tr>`,...rows]),'각 행은 기준 금액 × 요율 ÷ 100의 원 미만을 절사합니다. 누계는 앞선 항목까지의 합계입니다. 수식 편집 및 법정 요율은 제공하지 않습니다.');
|
||||
const rows = data.costs.map((c,i) => `<tr><td>${field('costs',i,'name',c.name)}</td><td>${select('costs',i,'base',c.base,bases)}</td><td class="money">${money(result.costs[i].base)}</td><td>${field('costs',i,'rate',c.rate,'number')}</td><td>${field('costs',i,'formula',c.formula || '')}</td><td class="money">${money(result.costs[i].amount)}</td><td class="money">${money(result.costs[i].running)}</td><td>${remove('costs',i)}</td></tr>`);
|
||||
$('panel').innerHTML = card('원가계산서','기준 금액·요율 또는 수식을 위에서 아래로 계산합니다.',add('costs','요율 행 추가'),table(['항목','기준','기준 금액','요율 (%)','수식','계산 금액','누계',''],[`<tr class="total"><td colspan="5">직접공사비</td><td class="money">${money(result.directTotal)}</td><td class="money">${money(result.directTotal)}</td><td></td></tr>`,...rows]),`수식 예: [직접공사비]*0.1, [앞선 항목명]+[재료비]. 수식이 있으면 요율 대신 수식을 사용합니다. ${esc(result.errors.join(' / '))}`);
|
||||
}
|
||||
}
|
||||
function persist() { try { sessionStorage.setItem(key,JSON.stringify(data)); } catch { alert('임시 저장 공간을 사용할 수 없습니다. JSON 파일로 저장해 주세요.'); } }
|
||||
@@ -93,6 +106,8 @@ document.addEventListener('click', event => {
|
||||
if (k==='lines') data.units.find(u=>u.id===selected).lines.push({sourceId:data.sources[0]?.id || '',quantity:1});
|
||||
if (k==='estimate') data.estimate.push({id:id(),name:'새 공종',unitId:data.units[0]?.id || '',quantity:1});
|
||||
if (k==='quantities') { data.quantities ||= []; data.quantities.push({id:id(),name:'새 수량산출',estimateId:data.estimate[0]?.id || '',length:1,width:1,count:1}); }
|
||||
if (k==='machines') { data.machines ||= []; data.machines.push({sourceId:'',purchase:0,rentalCoefficient:0,fuelPrice:0,fuelQuantity:0,suppliesRate:0,laborPrice:0,laborFactor:0,laborHours:0}); }
|
||||
if (k==='escalation') { data.escalation ||= []; data.escalation.push({name:'새 품목',bid:0,contract:0,current:0,quantity:0,planned:0,actual:0,progress:0}); }
|
||||
if (k==='costs') data.costs.push({id:id(),name:'새 요율',base:'직접공사비',rate:0});
|
||||
persist(); render();
|
||||
}
|
||||
@@ -112,9 +127,9 @@ document.addEventListener('click', event => {
|
||||
}
|
||||
if (t.dataset.remove) {
|
||||
const k=t.dataset.remove, i=Number(t.dataset.index), arr=k==='lines' ? data.units.find(u=>u.id===selected).lines : data[k], item=arr[i];
|
||||
if (k==='sources' && data.units.some(u=>u.lines.some(l=>l.sourceId===item.id))) { alert('일위대가에서 사용 중인 단가입니다. 연결을 먼저 해제하세요.'); return; }
|
||||
if (k==='sources' && (data.units.some(u=>u.lines.some(l=>l.sourceId===item.id)) || (data.machines || []).some(m=>m.sourceId===item.id))) { alert('사용 중인 단가입니다. 연결을 먼저 해제하세요.'); return; }
|
||||
if (k==='estimate' && ((data.quantities || []).some(q=>q.estimateId===item.id) || (data.progress || []).some(p=>p.estimateId===item.id))) { alert('수량산출 또는 기성고에서 사용 중인 공종입니다.'); return; }
|
||||
if (k==='units' && data.estimate.some(e=>e.unitId===item.id)) { alert('내역에서 사용 중인 일위대가입니다. 연결을 먼저 해제하세요.'); return; }
|
||||
if (k==='units' && (data.estimate.some(e=>e.unitId===item.id) || data.units.some(u => u.id !== item.id && u.lines.some(l => l.type === '일위대가' && l.unitId === item.id)))) { alert('다른 항목에서 사용 중인 일위대가입니다. 연결을 먼저 해제하세요.'); return; }
|
||||
arr.splice(i,1); persist(); render();
|
||||
}
|
||||
});
|
||||
@@ -126,7 +141,11 @@ document.addEventListener('change', event => {
|
||||
if (!el.dataset.collection) return;
|
||||
const arr=el.dataset.collection==='lines' ? data.units.find(u=>u.id===selected).lines : data[el.dataset.collection];
|
||||
const value=el.type==='number' ? Number(el.value) : el.value;
|
||||
if (el.dataset.collection==='machines' && el.dataset.field==='sourceId' && value && data.machines.some((m,i)=>i!==Number(el.dataset.index) && m.sourceId===value)) { alert('이미 다른 기계에 연결된 기초단가입니다.'); render(); return; }
|
||||
if (el.type==='number' && (el.value.trim()==='' || !Number.isFinite(value) || value<0 || value>1e12)) { alert('0 이상 1조 이하의 숫자를 입력하세요.'); render(); return; }
|
||||
if (el.dataset.field==='digits' && (!Number.isInteger(value) || value>6)) { alert('자릿수는 0부터 6까지 입력하세요.'); render(); return; }
|
||||
if (el.dataset.field==='priceIndex') { if (!Number.isInteger(value) || value<1 || value>6) { alert('선택 번호는 1부터 6까지 입력하세요.'); render(); return; } arr[Number(el.dataset.index)].priceIndex=value-1; persist(); render(); return; }
|
||||
if (/^price[0-5]$/.test(el.dataset.field)) { const source=arr[Number(el.dataset.index)]; source.prices ||= [0,0,0,0,0,0]; source.prices[Number(el.dataset.field.slice(-1))]=value; persist(); render(); return; }
|
||||
arr[Number(el.dataset.index)][el.dataset.field]=value; persist(); render();
|
||||
});
|
||||
$('save').addEventListener('click', () => { const a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([JSON.stringify(data,null,2)],{type:'application/json'})); a.download='est-test.json'; a.click(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); });
|
||||
|
||||
+64
-17
@@ -1,43 +1,90 @@
|
||||
// EST 보고서의 성분별 행 절사와 원 미만 합계 절사를 참고한 테스트 계산.
|
||||
// shortcut: 실제 EST의 모든 자릿수·비율 옵션은 미구현, 원본 대조 시 확장.
|
||||
// EST Plus 2 REA 최종보고서 §3~7: 비목별 계산과 연결 순서.
|
||||
const EstCalc = (() => {
|
||||
const kinds = ['재료비', '노무비', '경비'];
|
||||
const trunc = n => Math.trunc((Number(n) || 0) * 100) / 100;
|
||||
const kinds = ['재료비', '노무비', '경비', '실적'];
|
||||
const blank = () => Object.fromEntries(kinds.map(k => [k, 0]));
|
||||
const truncate = (n, digits = 0) => Math.trunc((n + Math.sign(n) * 2e-10) * 10 ** digits) / 10 ** digits;
|
||||
function sourcePrice(source) {
|
||||
if (!source) return 0;
|
||||
const values = (source.prices || []).filter(v => Number.isFinite(v) && v > 0);
|
||||
if (source.priceMode === '최저') return values.length ? Math.min(...values) : 0;
|
||||
if (source.priceMode === '최고') return values.length ? Math.max(...values) : 0;
|
||||
if (source.priceMode === '평균') return values.length ? Math.trunc(values.reduce((a,b) => a+b, 0) / values.length) : 0;
|
||||
if (source.priceMode === '선택') return source.prices?.[source.priceIndex || 0] || 0;
|
||||
return source.price || 0;
|
||||
}
|
||||
function machine(m) {
|
||||
const digits = m.digits ?? 1;
|
||||
const rental = truncate(m.purchase * m.rentalCoefficient * 1e-7, digits);
|
||||
const fuel = truncate(m.fuelPrice * m.fuelQuantity, digits);
|
||||
const supplies = truncate(fuel * m.suppliesRate / 100, digits);
|
||||
const operator = truncate(m.laborPrice * m.laborFactor * m.laborHours, digits);
|
||||
return { 재료비: Math.trunc(fuel + supplies), 노무비: Math.trunc(operator), 경비: Math.trunc(rental), 실적: 0 };
|
||||
}
|
||||
function calculate(data) {
|
||||
const prices = Object.fromEntries(data.sources.map(s => [s.id, s]));
|
||||
const units = Object.fromEntries(data.units.map(u => {
|
||||
const parts = Object.fromEntries(kinds.map(k => [k, 0]));
|
||||
const records = Object.fromEntries(data.units.map(u => [u.id, u]));
|
||||
const units = Object.create(null), visiting = new Set(), errors = [];
|
||||
function calculateUnit(u) {
|
||||
if (units[u.id]) return units[u.id];
|
||||
if (visiting.has(u.id)) { errors.push(`일위대가 순환 참조: ${u.name}`); return {parts: blank(), lines: [], total: 0}; }
|
||||
visiting.add(u.id);
|
||||
const parts = blank();
|
||||
const lines = u.lines.map(line => {
|
||||
const source = prices[line.sourceId];
|
||||
const amount = source ? trunc(source.price * line.quantity) : 0;
|
||||
if (source) parts[source.kind] += amount;
|
||||
return { source, amount };
|
||||
const source = Object.hasOwn(prices, line.sourceId) ? prices[line.sourceId] : null;
|
||||
const unit = Object.hasOwn(records, line.unitId) ? records[line.unitId] : null;
|
||||
let unitParts = blank();
|
||||
if (line.type === '일위대가') {
|
||||
if (unit) unitParts = calculateUnit(unit).parts;
|
||||
else errors.push(`${u.name}: 연결된 일위대가 없음`);
|
||||
}
|
||||
else if (line.type === '비율') {
|
||||
const base = line.base || '합계';
|
||||
if (base === '합계') unitParts[line.kind || '경비'] = kinds.reduce((sum,k) => sum + parts[k], 0);
|
||||
else if (base === '비목별') unitParts = { ...parts };
|
||||
else unitParts[line.kind || '경비'] = parts[base] || 0;
|
||||
} else if (source) {
|
||||
const machinery = (data.machines || []).find(m => m.sourceId === source.id);
|
||||
if (machinery) unitParts = machine(machinery);
|
||||
else unitParts[source.kind] = sourcePrice(source);
|
||||
} else errors.push(`${u.name}: 연결된 기초단가 없음`);
|
||||
const quantity = line.type === '비율' || line.unit === '%' ? line.quantity / 100 : line.quantity;
|
||||
const components = Object.fromEntries(kinds.map(k => [k, truncate(unitParts[k] * quantity, line.digits ?? 0)]));
|
||||
const amount = kinds.reduce((sum,k) => sum + components[k], 0);
|
||||
if (line.calcIndex !== 2 && line.calcIndex !== 3) for (const k of kinds) parts[k] += components[k];
|
||||
return { source, unit, components, amount };
|
||||
});
|
||||
for (const k of kinds) parts[k] = Math.trunc(parts[k]);
|
||||
return [u.id, { parts, lines, total: kinds.reduce((sum, k) => sum + parts[k], 0) }];
|
||||
}));
|
||||
const direct = Object.fromEntries(kinds.map(k => [k, 0]));
|
||||
visiting.delete(u.id);
|
||||
return units[u.id] = { parts, lines, total: kinds.reduce((sum, k) => sum + parts[k], 0) };
|
||||
}
|
||||
for (const u of data.units) calculateUnit(u);
|
||||
const direct = blank();
|
||||
const rows = data.estimate.map(row => {
|
||||
const unit = units[row.unitId];
|
||||
const unit = Object.hasOwn(units, row.unitId) ? units[row.unitId] : null;
|
||||
const parts = Object.fromEntries(kinds.map(k => [k, unit ? Math.trunc(unit.parts[k] * row.quantity) : 0]));
|
||||
for (const k of kinds) direct[k] += parts[k];
|
||||
return { parts, total: kinds.reduce((sum, k) => sum + parts[k], 0) };
|
||||
});
|
||||
const directTotal = kinds.reduce((sum, k) => sum + direct[k], 0);
|
||||
let running = directTotal;
|
||||
const references = Object.assign(Object.create(null), direct, { 직접공사비: directTotal });
|
||||
const costs = data.costs.map(row => {
|
||||
const base = row.base === '누계' ? running : row.base === '직접공사비' ? directTotal : direct[row.base] || 0;
|
||||
const amount = Math.trunc(base * row.rate / 100);
|
||||
references['누계'] = running;
|
||||
let amount;
|
||||
try { amount = row.formula ? Math.trunc(EstFormula.evaluate(row.formula, references)) : Math.trunc(base * row.rate / 100); }
|
||||
catch (error) { errors.push(`${row.name}: ${error.message}`); amount = 0; }
|
||||
running += amount;
|
||||
references[row.name] = amount;
|
||||
return { base, amount, running };
|
||||
});
|
||||
return { units, rows, direct, directTotal, costs, total: running };
|
||||
return { units, rows, direct, directTotal, costs, total: running, errors };
|
||||
}
|
||||
function progress(unitPrice, previous, current, approved, useApproved) {
|
||||
const accepted = useApproved ? approved : current;
|
||||
const cumulative = previous + accepted;
|
||||
return { currentAmount: Math.trunc(unitPrice * current), approvedAmount: Math.trunc(unitPrice * approved), cumulative, cumulativeAmount: Math.trunc(unitPrice * cumulative) };
|
||||
}
|
||||
return { kinds, calculate, progress };
|
||||
return { kinds, calculate, progress, sourcePrice, truncate, machine };
|
||||
})();
|
||||
if (typeof module !== 'undefined') module.exports = EstCalc;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// 원가 및 단가산출의 사칙연산·괄호·[항목명] 참조를 평가한다.
|
||||
const EstFormula = (() => {
|
||||
function evaluate(expression, values) {
|
||||
const tokens = expression.match(/\s+|\d+(?:\.\d+)?|\[[^\]]+\]|[()+*/-]/g) || [];
|
||||
if (tokens.join('') !== expression) throw Error('지원하지 않는 수식 문자');
|
||||
const items = tokens.filter(t => !/^\s+$/.test(t));
|
||||
let i = 0;
|
||||
function primary() {
|
||||
const token = items[i++];
|
||||
if (token === '(') { const n = sum(); if (items[i++] !== ')') throw Error('괄호 오류'); return n; }
|
||||
if (token === '-') return -primary();
|
||||
if (token === '+') return primary();
|
||||
if (/^\d/.test(token || '')) return Number(token);
|
||||
if (token?.startsWith('[')) { const key=token.slice(1,-1); if (!Object.hasOwn(values,key)) throw Error(`참조 항목 없음: ${key}`); return values[key]; }
|
||||
throw Error('수식 오류');
|
||||
}
|
||||
function product() {
|
||||
let n = primary();
|
||||
while (items[i] === '*' || items[i] === '/') { const op=items[i++], v=primary(); if (op === '/' && v === 0) throw Error('0으로 나눌 수 없음'); n=op === '*' ? n*v : n/v; }
|
||||
return n;
|
||||
}
|
||||
function sum() {
|
||||
let n = product();
|
||||
while (items[i] === '+' || items[i] === '-') { const op=items[i++], v=product(); n=op === '+' ? n+v : n-v; }
|
||||
return n;
|
||||
}
|
||||
const result=sum();
|
||||
if (i !== items.length || !Number.isFinite(result)) throw Error('수식 오류');
|
||||
return result;
|
||||
}
|
||||
return { evaluate };
|
||||
})();
|
||||
if (typeof module !== 'undefined') module.exports = EstFormula;
|
||||
@@ -3,7 +3,7 @@
|
||||
<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="calc.js"></script><script defer src="app.js"></script>
|
||||
<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>
|
||||
@@ -12,7 +12,7 @@
|
||||
<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="quantities">▤ <span>수량산출</span></button><button data-tab="costs">▧ <span>원가계산서</span></button><button data-tab="progress">▦ <span>기성고</span></button></nav><div class="sideinfo">작업 자료<br><b>독립 테스트</b><p>내용을 수정한 뒤 저장하기를 눌러 파일로 보관하세요.</p></div></aside>
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// EST Plus 2 REA 최종보고서 §6-11, §6-12의 계산 경로.
|
||||
const EstWorkflows = (() => {
|
||||
function escalation(bid, contract, current, quantity) {
|
||||
if (!bid || !current) return { rate: 0, difference: 0, price: contract, amount: Math.trunc(contract * quantity) };
|
||||
const rate = Math.trunc(((current - bid) / bid) * 10000) / 100;
|
||||
let difference = 0;
|
||||
if (bid >= contract && current >= bid) difference = Math.trunc(contract * rate / 100);
|
||||
else if (bid < contract && current >= contract) difference = Math.trunc(current - contract);
|
||||
const price = contract + difference;
|
||||
return { rate, difference, price, amount: Math.trunc(price * quantity) };
|
||||
}
|
||||
function excluded(planned, actual, progress) { return Math.max(planned, actual, progress); }
|
||||
function resources(data) {
|
||||
const sources = Object.fromEntries(data.sources.map(s => [s.id,s]));
|
||||
const units = Object.fromEntries(data.units.map(u => [u.id,u]));
|
||||
const quantities = Object.create(null), errors = [];
|
||||
function expand(unitId, multiplier, path) {
|
||||
const unit = Object.hasOwn(units, unitId) ? units[unitId] : null;
|
||||
if (!unit) return;
|
||||
if (path.includes(unitId)) { errors.push(`순환 참조: ${unit.name}`); return; }
|
||||
for (const line of unit.lines) {
|
||||
if (line.calcIndex === 2 || line.calcIndex === 3 || line.type === '비율') continue;
|
||||
const quantity = multiplier * line.quantity;
|
||||
if (line.type === '일위대가') expand(line.unitId,quantity,[...path,unitId]);
|
||||
else if (Object.hasOwn(sources,line.sourceId)) quantities[line.sourceId] = (quantities[line.sourceId] || 0) + quantity;
|
||||
}
|
||||
}
|
||||
for (const row of data.estimate) expand(row.unitId,row.quantity,[]);
|
||||
return { rows: Object.entries(quantities).map(([id,quantity]) => ({source:sources[id],quantity})), errors };
|
||||
}
|
||||
return { escalation, excluded, resources };
|
||||
})();
|
||||
if (typeof module !== 'undefined') module.exports = EstWorkflows;
|
||||
@@ -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`: 조달·물자·물정·거래·견적) 입력
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) =>
|
||||
`<option value="${esc(p.path)}" ${dbCtx.selectedProjectPath === p.path ? "selected" : ""}>${esc(p.name)} — ${esc(p.path)}</option>`,
|
||||
)
|
||||
.join("");
|
||||
const jbrOpts = dbCtx.sources.jbr_files
|
||||
.map(
|
||||
(f) =>
|
||||
`<option value="${esc(f.path)}" ${dbCtx.selectedJbrPath === f.path ? "selected" : ""}>${esc(f.name)} (${fmt(f.size, 0)} B)</option>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
let dbfPreviewHtml =
|
||||
'<div class="empty-box">상단에서 PC 내 STMATE 폴더 또는 <code>.STC</code> 파일을 선택하고 <strong>[원본 DBF/STC 불러오기]</strong>를 실행하세요.</div>';
|
||||
if (dbCtx.loadedDbf) {
|
||||
const d = dbCtx.loadedDbf;
|
||||
const costnRows = (d.costn_rows || [])
|
||||
.slice(0, 25)
|
||||
.map(
|
||||
(r, i) => `
|
||||
<tr>
|
||||
<td><code>${esc(r.CODE)}</code></td>
|
||||
<td><input type="text" data-dbf="costn" data-idx="${i}" data-field="NAME" value="${esc(r.NAME)}"></td>
|
||||
<td>${esc(r.SIZE)}</td>
|
||||
<td class="center-col">${esc(r.UNIT)}</td>
|
||||
<td class="num-col"><input type="number" data-dbf="costn" data-idx="${i}" data-field="MAMT1" value="${Number(r.MAMT1) || 0}" step="any"></td>
|
||||
<td class="num-col"><input type="number" data-dbf="costn" data-idx="${i}" data-field="LAMT1" value="${Number(r.LAMT1) || 0}" step="any"></td>
|
||||
<td class="num-col"><input type="number" data-dbf="costn" data-idx="${i}" data-field="SAMT1" value="${Number(r.SAMT1) || 0}" step="any"></td>
|
||||
<td class="num-col"><input type="number" data-dbf="costn" data-idx="${i}" data-field="TAMT1" value="${Number(r.TAMT1) || 0}" step="any"></td>
|
||||
<td class="money-col">${fmt(r.AMT_61, 0)}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
dbfPreviewHtml = `
|
||||
<div class="sub-toolbar">
|
||||
<span>현재 연결 원본: <strong>${esc(d.prjlst?.PNAME || d.source_path)}</strong> · 수록 테이블: <code>${esc(d.file_list.join(", "))}</code></span>
|
||||
<span>원장 <code>COSTN.DBF</code> <strong>${d.costn_count}건</strong> · 내역 <code>${esc(d.boq_table_name)}</code> <strong>${d.boq_count}건</strong> · <code>PRJLST.SMT</code> 설정키 <strong>${Object.keys(d.prjlst_smt || {}).length}개</strong></span>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>CODE</th><th>품명(<code>NAME</code>)</th><th>규격(<code>SIZE</code>)</th><th>단위</th><th>재료단가(<code>MAMT1</code>)</th><th>노무단가(<code>LAMT1</code>)</th><th>경비단가(<code>SAMT1</code>)</th><th>합계단가(<code>TAMT1</code>)</th><th>채택6칸(<code>AMT_61</code>)</th></tr></thead>
|
||||
<tbody>${costnRows}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const saveAlert = dbCtx.lastSaveResult
|
||||
? `<div class="sub-toolbar"><span>최근 쓰기 완료 폴더: <code>${esc(dbCtx.lastSaveResult.saved_dir)}</code></span><span>생성된 패키지: <code>${esc(dbCtx.lastSaveResult.saved_stc)}</code> (${fmt(dbCtx.lastSaveResult.stc_size, 0)} bytes, 파일 ${dbCtx.lastSaveResult.written_count}종)</span></div>`
|
||||
: "";
|
||||
|
||||
return `
|
||||
<div class="panel-card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2>PC 내 STMATE 원본 DBF · SMT · STC · JB_R 직접 읽기/쓰기 연동</h2>
|
||||
<p><code>C:\\경영정보(주)</code> 설치 폴더의 프로젝트 DBF(<code>COSTN.DBF</code>, <code>BOQ11.DBF</code>, <code>PRJLST.DBF/.SMT</code>), <code>System\\ST_BSC\\*.JB_R</code> 제비율 기준 DB, 그리고 실무 <code>.STC</code> 패키지를 직접 읽고 CP949 고정폭 바이너리 DBF로 다시 기록합니다.</p>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" class="btn secondary" data-db-action="refresh-sources">연결/목록 새로고침</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sub-toolbar">
|
||||
<span>상태: <strong>${esc(dbCtx.statusMsg)}</strong></span>
|
||||
<span>브리지 주소: <code>${API_BASE}</code></span>
|
||||
</div>
|
||||
<div class="control-grid">
|
||||
<label class="ctrl-item">
|
||||
<span>1. 프로젝트 원본 DB 폴더 또는 <code>.STC</code> 선택</span>
|
||||
<select id="selDbProject">${projOpts || '<option value="">감지된 프로젝트 없음</option>'}</select>
|
||||
</label>
|
||||
<div class="ctrl-item" style="justify-content:flex-end;">
|
||||
<button type="button" class="btn primary" data-db-action="load-project" ${!dbCtx.connected ? "disabled" : ""}>원본 DBF/STC 불러오기 (시뮬레이터 동기화)</button>
|
||||
</div>
|
||||
<label class="ctrl-item">
|
||||
<span>2. 시스템 제비율 DB (<code>C:\\경영정보(주)\\System\\ST_BSC\\*.JB_R</code>) 선택</span>
|
||||
<select id="selDbJbr">${jbrOpts || '<option value="">감지된 JB_R 없음</option>'}</select>
|
||||
</label>
|
||||
<div class="ctrl-item" style="justify-content:flex-end;">
|
||||
<button type="button" class="btn secondary" data-db-action="load-jbr" ${!dbCtx.connected ? "disabled" : ""}>선택한 .JB_R 제비율 읽어서 원가계산 적용</button>
|
||||
</div>
|
||||
<label class="ctrl-item">
|
||||
<span>3. DBF/STC 쓰기 대상 폴더명 (<code>tools/stmate-test/work_dbf/</code> 하위)</span>
|
||||
<input type="text" id="inpTargetFolder" value="${esc(dbCtx.targetFolderName)}">
|
||||
</label>
|
||||
<div class="ctrl-item" style="justify-content:flex-end;">
|
||||
<button type="button" class="btn primary" data-db-action="save-dbf" ${!dbCtx.connected ? "disabled" : ""}>현재 단가·내역·원가 결과를 DBF 및 .STC로 직접 쓰기</button>
|
||||
</div>
|
||||
</div>
|
||||
${saveAlert}
|
||||
</div>
|
||||
<div class="panel-card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2>원본 <code>COSTN.DBF</code> 레코드 직접 조회 및 편집 (상위 25건 미리보기)</h2>
|
||||
<p>여기서 수정한 단가와 01~06 탭에서 조작한 수량·요율·원가 결과가 <strong>[DBF 및 .STC로 직접 쓰기]</strong> 실행 시 <code>COSTN.DBF</code>, <code>BOQ11/12.DBF</code>, <code>PRJLST.DBF/.SMT</code>에 기록됩니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
${dbfPreviewHtml}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
})();
|
||||
@@ -7,6 +7,7 @@
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<script defer src="calc.js"></script>
|
||||
<script defer src="app_views_cost.js"></script>
|
||||
<script defer src="db_bridge.js"></script>
|
||||
<script defer src="app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -47,6 +48,7 @@
|
||||
<section class="kpi-grid" id="kpiSummary" aria-label="핵심 계산 요약"></section>
|
||||
|
||||
<nav class="tab-nav" id="mainTabs" aria-label="STMATE 기능 단계">
|
||||
<button type="button" data-tab="db">00 원본 DBF · STC 연동</button>
|
||||
<button type="button" data-tab="resources" class="active">01 기초자원 · 노임환산</button>
|
||||
<button type="button" data-tab="machines">02 중기 · 단가산출(Q식)</button>
|
||||
<button type="button" data-tab="ilwi">03 일위대가 조립</button>
|
||||
|
||||
@@ -0,0 +1,687 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""STMATE 원본 DBF / SMT / STC / JB_R 직접 읽기·쓰기 연동 로컬 서버 (server.py)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import io
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import struct
|
||||
import zipfile
|
||||
from decimal import Decimal, ROUND_DOWN
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
REPO_ROOT = HERE.parent.parent
|
||||
WORK_DBF_ROOT = HERE / "work_dbf"
|
||||
STMATE_ROOT = pathlib.Path(r"C:\경영정보(주)")
|
||||
STMATE_SYS_BSC = STMATE_ROOT / "System" / "ST_BSC"
|
||||
PRAC_DOCS_ROOT = REPO_ROOT / "resources" / "knowledge" / "original" / "실무문서"
|
||||
|
||||
STRUCT_RE = re.compile(r"^(T\d|E\d|C\d|:)$")
|
||||
SMT_KV_RE = re.compile(r"([A-Za-z0-9_]+)=([^\r\n\x00]+)")
|
||||
|
||||
app = FastAPI(title="STMATE DBF Direct Bridge")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
def parse_dbf_bytes(b: bytes) -> tuple[list[dict], list[dict], dict]:
|
||||
"""dBASE III/IV 바이트를 파싱하여 (필드정의, 레코드목록, 헤더메타) 반환."""
|
||||
if len(b) < 32:
|
||||
raise ValueError("DBF 헤더 길이 부족")
|
||||
ver = b[0]
|
||||
nrec, hlen, rlen = struct.unpack_from("<IHH", b, 4)
|
||||
fields: list[dict] = []
|
||||
off = 32
|
||||
while off < len(b) and b[off] != 0x0D:
|
||||
raw_name = b[off : off + 11].split(b"\x00", 1)[0]
|
||||
name = raw_name.decode("cp949", "replace").strip()
|
||||
ftype = chr(b[off + 11])
|
||||
flen = b[off + 16]
|
||||
fdec = b[off + 17]
|
||||
fields.append(
|
||||
{
|
||||
"name": name,
|
||||
"type": ftype,
|
||||
"len": flen,
|
||||
"dec": fdec,
|
||||
"raw_desc": b[off : off + 32].hex(),
|
||||
}
|
||||
)
|
||||
off += 32
|
||||
|
||||
rows: list[dict] = []
|
||||
for i in range(nrec):
|
||||
pos = hlen + i * rlen
|
||||
if pos + rlen > len(b):
|
||||
break
|
||||
rec = b[pos : pos + rlen]
|
||||
deleted = rec[0:1] == b"*"
|
||||
row: dict = {"_recno": i, "_deleted": deleted}
|
||||
cur = 1
|
||||
for f in fields:
|
||||
val_bytes = rec[cur : cur + f["len"]]
|
||||
cur += f["len"]
|
||||
row[f["name"]] = val_bytes.decode("cp949", "replace").strip()
|
||||
if not deleted:
|
||||
rows.append(row)
|
||||
|
||||
meta = {"version": ver, "hlen": hlen, "rlen": rlen, "header_tail_hex": b[off:hlen].hex()}
|
||||
return fields, rows, meta
|
||||
|
||||
|
||||
def build_dbf_bytes(fields: list[dict], rows: list[dict], meta: dict | None = None) -> bytes:
|
||||
"""(필드정의, 레코드목록)을 STMATE 호환 CP949 고정폭 DBF 바이트로 직렬화."""
|
||||
ver = (meta or {}).get("version", 0x03)
|
||||
today = datetime.date.today()
|
||||
nrec = len(rows)
|
||||
rlen = 1 + sum(int(f["len"]) for f in fields)
|
||||
tail_bytes = bytes.fromhex((meta or {}).get("header_tail_hex", "0d"))
|
||||
if not tail_bytes or tail_bytes[0] != 0x0D:
|
||||
tail_bytes = b"\x0d"
|
||||
hlen = 32 + 32 * len(fields) + len(tail_bytes)
|
||||
|
||||
hdr = bytearray(32)
|
||||
hdr[0] = ver
|
||||
hdr[1] = today.year - 1900
|
||||
hdr[2] = today.month
|
||||
hdr[3] = today.day
|
||||
struct.pack_into("<IHH", hdr, 4, nrec, hlen, rlen)
|
||||
|
||||
out = io.BytesIO()
|
||||
out.write(hdr)
|
||||
for f in fields:
|
||||
if f.get("raw_desc"):
|
||||
desc = bytearray(bytes.fromhex(f["raw_desc"]))
|
||||
desc[16] = int(f["len"])
|
||||
desc[17] = int(f.get("dec", 0))
|
||||
out.write(desc)
|
||||
else:
|
||||
desc = bytearray(32)
|
||||
nm_b = f["name"].encode("cp949", "replace")[:10]
|
||||
desc[: len(nm_b)] = nm_b
|
||||
desc[11] = ord((f.get("type") or "C")[0])
|
||||
desc[16] = int(f["len"])
|
||||
desc[17] = int(f.get("dec", 0))
|
||||
out.write(desc)
|
||||
out.write(tail_bytes)
|
||||
|
||||
for row in rows:
|
||||
out.write(b"*" if row.get("_deleted") else b" ")
|
||||
for f in fields:
|
||||
flen = int(f["len"])
|
||||
fdec = int(f.get("dec", 0))
|
||||
ftype = f.get("type", "C")
|
||||
raw_val = row.get(f["name"], "")
|
||||
if ftype in ("N", "F"):
|
||||
sval = str(raw_val).strip()
|
||||
if sval == "" or sval == "None":
|
||||
sval = ""
|
||||
else:
|
||||
try:
|
||||
num = float(sval)
|
||||
sval = f"{num:.{fdec}f}" if fdec > 0 else f"{int(round(num))}"
|
||||
except Exception:
|
||||
pass
|
||||
enc = sval.encode("ascii", "replace")[:flen]
|
||||
out.write(enc.rjust(flen, b" "))
|
||||
else:
|
||||
sval = "" if raw_val is None else str(raw_val)
|
||||
enc = sval.encode("cp949", "replace")[:flen]
|
||||
out.write(enc.ljust(flen, b" "))
|
||||
|
||||
out.write(b"\x1a")
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def parse_prjlst_smt(b: bytes) -> dict[str, str]:
|
||||
if len(b) <= 0x200:
|
||||
return {}
|
||||
txt = b[0x200:].decode("cp949", "replace")
|
||||
return {k: v.strip() for k, v in SMT_KV_RE.findall(txt)}
|
||||
|
||||
|
||||
def update_prjlst_smt_bytes(orig_b: bytes, updates: dict[str, str | int | float]) -> bytes:
|
||||
"""PRJLST.SMT의 SIxMemo 헤더(0x200)를 보존하며 키=값 본문을 갱신."""
|
||||
header = bytearray(orig_b[:0x200]) if len(orig_b) >= 0x200 else bytearray(b"\x00" * 0x200)
|
||||
txt = orig_b[0x200:].decode("cp949", "replace") if len(orig_b) > 0x200 else ""
|
||||
for k, v in updates.items():
|
||||
pat = re.compile(rf"(^|\r?\n)({re.escape(k)}=)([^\r\n\x00]*)", re.M)
|
||||
if pat.search(txt):
|
||||
txt = pat.sub(rf"\g<1>\g<2>{v}", txt)
|
||||
else:
|
||||
txt += f"\r\n{k}={v}"
|
||||
body_b = txt.encode("cp949", "replace")
|
||||
return bytes(header) + body_b
|
||||
|
||||
|
||||
def fnum(val: object, default: float = 0.0) -> float:
|
||||
try:
|
||||
s = str(val or "").strip().replace(",", "")
|
||||
return float(s) if s else default
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def discover_sources() -> dict:
|
||||
projects: list[dict] = []
|
||||
if STMATE_ROOT.exists():
|
||||
for costn in sorted(STMATE_ROOT.rglob("COSTN.DBF")):
|
||||
pdir = costn.parent
|
||||
projects.append(
|
||||
{
|
||||
"id": str(pdir),
|
||||
"name": f"[PC 폴더] {pdir.name}",
|
||||
"path": str(pdir),
|
||||
"kind": "folder",
|
||||
}
|
||||
)
|
||||
for stc in sorted(STMATE_ROOT.rglob("*.STC")):
|
||||
projects.append(
|
||||
{
|
||||
"id": str(stc),
|
||||
"name": f"[PC STC] {stc.name}",
|
||||
"path": str(stc),
|
||||
"kind": "stc",
|
||||
}
|
||||
)
|
||||
|
||||
if PRAC_DOCS_ROOT.exists():
|
||||
for stc in sorted(PRAC_DOCS_ROOT.rglob("*.STC")):
|
||||
projects.append(
|
||||
{
|
||||
"id": str(stc),
|
||||
"name": f"[실무 STC] {stc.stem}",
|
||||
"path": str(stc),
|
||||
"kind": "stc",
|
||||
}
|
||||
)
|
||||
|
||||
if WORK_DBF_ROOT.exists():
|
||||
for costn in sorted(WORK_DBF_ROOT.rglob("COSTN.DBF")):
|
||||
pdir = costn.parent
|
||||
projects.append(
|
||||
{
|
||||
"id": str(pdir),
|
||||
"name": f"[작업본 DBF] {pdir.name}",
|
||||
"path": str(pdir),
|
||||
"kind": "work_folder",
|
||||
}
|
||||
)
|
||||
|
||||
jbr_files: list[dict] = []
|
||||
if STMATE_SYS_BSC.exists():
|
||||
for p in sorted(STMATE_SYS_BSC.glob("*.JB_R*")):
|
||||
jbr_files.append(
|
||||
{
|
||||
"name": p.name,
|
||||
"path": str(p),
|
||||
"ext": p.suffix.upper(),
|
||||
"size": p.stat().st_size,
|
||||
}
|
||||
)
|
||||
|
||||
return {"projects": projects, "jbr_files": jbr_files, "work_root": str(WORK_DBF_ROOT)}
|
||||
|
||||
|
||||
def read_project_files(source_path: str) -> dict[str, bytes]:
|
||||
p = pathlib.Path(source_path)
|
||||
if not p.exists():
|
||||
raise HTTPException(status_code=404, detail=f"경로를 찾을 수 없음: {source_path}")
|
||||
files: dict[str, bytes] = {}
|
||||
if p.is_file() and p.suffix.upper() == ".STC":
|
||||
with zipfile.ZipFile(p, "r") as zf:
|
||||
for nm in zf.namelist():
|
||||
files[pathlib.Path(nm).name.upper()] = zf.read(nm)
|
||||
elif p.is_dir():
|
||||
for fp in p.iterdir():
|
||||
if fp.is_file():
|
||||
files[fp.name.upper()] = fp.read_bytes()
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="지원하지 않는 경로 형식")
|
||||
return files
|
||||
|
||||
|
||||
def convert_dbf_to_sim_state(
|
||||
costn_rows: list[dict], boq_rows: list[dict], smt_kv: dict[str, str], prj_row: dict
|
||||
) -> dict:
|
||||
"""원본 COSTN.DBF / BOQ11~12.DBF / PRJLST.SMT 레코드를 시뮬레이터 state로 변환."""
|
||||
resources: list[dict] = []
|
||||
machines: list[dict] = []
|
||||
san_items: list[dict] = []
|
||||
ilwi_items: list[dict] = []
|
||||
|
||||
for r in costn_rows:
|
||||
code = (r.get("CODE") or "").strip()
|
||||
if not code:
|
||||
continue
|
||||
prefix = code[0].upper()
|
||||
name = r.get("NAME") or code
|
||||
spec = r.get("SIZE") or ""
|
||||
unit = r.get("UNIT") or ""
|
||||
t1, l1, m1, s1 = (
|
||||
fnum(r.get("TAMT1")),
|
||||
fnum(r.get("LAMT1")),
|
||||
fnum(r.get("MAMT1")),
|
||||
fnum(r.get("SAMT1")),
|
||||
)
|
||||
cands = [fnum(r.get(f"AMT_{i}1")) for i in range(1, 6)]
|
||||
amt61 = fnum(r.get("AMT_61")) or t1
|
||||
|
||||
if prefix in ("M", "L", "S"):
|
||||
kind = "노무비" if prefix == "L" else ("경비" if prefix == "S" else "재료비")
|
||||
non_zero = [x for x in cands if x > 0]
|
||||
is_min = bool(non_zero and abs(min(non_zero) - amt61) < 1e-4)
|
||||
resources.append(
|
||||
{
|
||||
"id": code,
|
||||
"code": code,
|
||||
"name": name,
|
||||
"spec": spec,
|
||||
"unit": unit,
|
||||
"kind": kind,
|
||||
"candidates": cands if any(cands) else [amt61, 0, 0, 0, 0],
|
||||
"adoptMode": "min" if is_min else "manual",
|
||||
"manualPrice": amt61,
|
||||
"convertHourly": False,
|
||||
"isUsd": (r.get("DOLLOR") or "").upper() == "T",
|
||||
}
|
||||
)
|
||||
else:
|
||||
# B(일위대가), D(단가산출), X(중기)도 내역서 직접 참조가 가능하도록 복합단가 보존
|
||||
ilwi_items.append(
|
||||
{
|
||||
"id": code,
|
||||
"code": code,
|
||||
"name": f"[{prefix}] {name}",
|
||||
"spec": spec,
|
||||
"unit": unit,
|
||||
"fixedM": m1,
|
||||
"fixedL": l1,
|
||||
"fixedS": s1,
|
||||
"lines": [],
|
||||
}
|
||||
)
|
||||
# 고정 성분단가를 기초자원 참조행으로 연결하여 시뮬레이터에서 동일 단가 유지
|
||||
for comp_tag, comp_kind, val in (
|
||||
("M", "재료비", m1),
|
||||
("L", "노무비", l1),
|
||||
("S", "경비", s1),
|
||||
):
|
||||
if val > 0:
|
||||
res_id = f"{code}_{comp_tag}"
|
||||
resources.append(
|
||||
{
|
||||
"id": res_id,
|
||||
"code": res_id,
|
||||
"name": f"{name} ({comp_kind})",
|
||||
"spec": spec,
|
||||
"unit": unit,
|
||||
"kind": comp_kind,
|
||||
"candidates": [val, 0, 0, 0, 0],
|
||||
"adoptMode": "manual",
|
||||
"manualPrice": val,
|
||||
"convertHourly": False,
|
||||
"isUsd": False,
|
||||
}
|
||||
)
|
||||
ilwi_items[-1]["lines"].append(
|
||||
{"refId": res_id, "qty": 1.0, "halPercent": 0, "devd": False}
|
||||
)
|
||||
|
||||
# BOQ 제외금액 구역(C*) 판별 (16번 문서 §4.2)
|
||||
excl_span: set[int] = set()
|
||||
for i, r in enumerate(boq_rows):
|
||||
bd = (r.get("BDCODE1") or "").strip()
|
||||
if bd.startswith("C"):
|
||||
end_code = r.get("ENDCODE")
|
||||
start_idx = next(
|
||||
(j for j, q in enumerate(boq_rows) if q.get("SORTCODE") == end_code), None
|
||||
)
|
||||
if start_idx is not None:
|
||||
for k in range(min(start_idx, i), max(start_idx, i) + 1):
|
||||
excl_span.add(k)
|
||||
|
||||
boq_items: list[dict] = []
|
||||
known_ids = {x["id"] for x in resources} | {x["id"] for x in ilwi_items}
|
||||
for i, r in enumerate(boq_rows):
|
||||
bd = (r.get("BDCODE1") or "").strip()
|
||||
if not bd or STRUCT_RE.match(bd):
|
||||
continue
|
||||
q1 = fnum(r.get("QTY1"))
|
||||
q2 = fnum(r.get("QTY2")) if r.get("QTY2") else q1
|
||||
name1 = r.get("NAME1") or bd
|
||||
if bd not in known_ids:
|
||||
# BOQ 자체 단가를 가진 말단행이면 호표 자동 생성
|
||||
m1, l1, s1 = fnum(r.get("MAMT1")), fnum(r.get("LAMT1")), fnum(r.get("SAMT1"))
|
||||
lines = []
|
||||
for ctag, ckind, cval in (("M", "재료비", m1), ("L", "노무비", l1), ("S", "경비", s1)):
|
||||
if cval > 0:
|
||||
rid = f"{bd}_{ctag}"
|
||||
resources.append(
|
||||
{
|
||||
"id": rid,
|
||||
"code": rid,
|
||||
"name": f"{name1} ({ckind})",
|
||||
"spec": r.get("SIZE1") or "",
|
||||
"unit": r.get("UNIT1") or "",
|
||||
"kind": ckind,
|
||||
"candidates": [cval, 0, 0, 0, 0],
|
||||
"adoptMode": "manual",
|
||||
"manualPrice": cval,
|
||||
"convertHourly": False,
|
||||
"isUsd": False,
|
||||
}
|
||||
)
|
||||
lines.append({"refId": rid, "qty": 1.0, "halPercent": 0, "devd": False})
|
||||
ilwi_items.append(
|
||||
{
|
||||
"id": bd,
|
||||
"code": bd,
|
||||
"name": name1,
|
||||
"spec": r.get("SIZE1") or "",
|
||||
"unit": r.get("UNIT1") or "",
|
||||
"lines": lines,
|
||||
}
|
||||
)
|
||||
known_ids.add(bd)
|
||||
|
||||
row_type = "normal"
|
||||
if i in excl_span:
|
||||
row_type = "gov_fee" if ("수수료" in name1 or fnum(r.get("S_AMT1")) > 0) else "gov_mat"
|
||||
|
||||
boq_items.append(
|
||||
{
|
||||
"id": f"BOQ_{i + 1}",
|
||||
"code": bd,
|
||||
"name": name1,
|
||||
"refId": bd,
|
||||
"rowType": row_type,
|
||||
"qty": q1,
|
||||
"changedQty": q2 if q2 > 0 else q1,
|
||||
"_recno": r.get("_recno", i),
|
||||
}
|
||||
)
|
||||
|
||||
rates = {
|
||||
"KANNO": fnum(smt_kv.get("1_R_KANNO"), 14.5),
|
||||
"SANBO": fnum(smt_kv.get("1_R_SANBO"), 3.56),
|
||||
"KOBO": fnum(smt_kv.get("1_R_KOBO"), 1.01),
|
||||
"KUNBO": fnum(smt_kv.get("1_R_KUNBO"), 3.545),
|
||||
"SLVBO": fnum(smt_kv.get("1_R_SLVBO"), 12.95),
|
||||
"YUNBO": fnum(smt_kv.get("1_R_YUNBO"), 4.5),
|
||||
"TBUGUM": fnum(smt_kv.get("1_R_TBUGUM"), 0.0),
|
||||
"ANJUN": fnum(smt_kv.get("1_R_ANJUN"), 1.86),
|
||||
"KITA": fnum(smt_kv.get("1_R_T_KITA"), 6.0),
|
||||
"HWAN": fnum(smt_kv.get("1_R_HWAN"), 0.8),
|
||||
"EQIP": fnum(smt_kv.get("1_R_EQIPSU"), 0.4),
|
||||
"ILBAN": fnum(smt_kv.get("1_R_ILBAN"), 6.0),
|
||||
"IYUN": fnum(smt_kv.get("1_R_IYUN"), 15.0),
|
||||
"S_SUDO": fnum(smt_kv.get("1_R_S_SUDO"), 0.0),
|
||||
"S_BOKRI": fnum(smt_kv.get("1_R_S_BOKRI"), 0.0),
|
||||
"S_SOMO": fnum(smt_kv.get("1_R_S_SOMO"), 0.0),
|
||||
"S_TONGSIN": fnum(smt_kv.get("1_R_S_TONGSIN"), 0.0),
|
||||
"S_SEGUM": fnum(smt_kv.get("1_R_S_SEGUM"), 0.0),
|
||||
"S_PEGI": fnum(smt_kv.get("1_R_S_PEGI"), 0.0),
|
||||
"S_DOSEO": fnum(smt_kv.get("1_R_S_DOSEO"), 0.0),
|
||||
"S_JIGUP": fnum(smt_kv.get("1_R_S_JIGUP"), 0.0),
|
||||
}
|
||||
|
||||
jab_type = int(fnum(smt_kv.get("JAB_TYPE"), 0))
|
||||
cal_actl = int(fnum(smt_kv.get("CAL_ACTL"), 0))
|
||||
scheme = ("SA" if jab_type == 1 else "TA") if cal_actl == 1 else ("S" if jab_type == 1 else "T")
|
||||
tot_jeol = -int(fnum(smt_kv.get("1_I_TOT_JEOL"), 3))
|
||||
|
||||
return {
|
||||
"projectMeta": {
|
||||
"pname": prj_row.get("PNAME", ""),
|
||||
"pnum": prj_row.get("PNUM", ""),
|
||||
"kOrd": int(fnum(prj_row.get("K_ORD"), 0)),
|
||||
},
|
||||
"sysConfig": {
|
||||
"rxFormula": "1/8*16/12*25/20",
|
||||
"condCodes": "L00008, L00009, L00010",
|
||||
"usdRate1": 1350,
|
||||
"usdRate2": 1400,
|
||||
"useChangedUsd": False,
|
||||
"ecdDigits": 0,
|
||||
"chnMode": "total" if prj_row.get("CHAMT") == "T" else "bimok",
|
||||
"optOrder": "SML",
|
||||
"kOrdSim": {
|
||||
"pclass": int(fnum(prj_row.get("PCLASS"), 7)),
|
||||
"regMode": 7,
|
||||
"flag": 0,
|
||||
"currentK": int(fnum(prj_row.get("K_ORD"), 2)) or 2,
|
||||
},
|
||||
},
|
||||
"resources": resources[:120],
|
||||
"machines": machines,
|
||||
"sanItems": san_items,
|
||||
"ilwiItems": ilwi_items[:120],
|
||||
"boqItems": boq_items,
|
||||
"kanjubConfig": {
|
||||
"scheme": scheme,
|
||||
"indirectTruncDigits": 0,
|
||||
"anjunBaseAdd": fnum(smt_kv.get("1_C_ANJUN_G"), 5349000),
|
||||
"anjunGovVatExcluded": True,
|
||||
"anjunCap12": True,
|
||||
"vatMode": "supply_10",
|
||||
"totJeolDigits": tot_jeol,
|
||||
"enableProfitEndAdj": True,
|
||||
"govRoundMode": "up",
|
||||
"govRoundDigits": -int(fnum(smt_kv.get("1_I_KWN_JEOLSA"), 3)),
|
||||
"rates": rates,
|
||||
"revModes": {
|
||||
"KANNO": "recalculated",
|
||||
"SANBO": "recalculated",
|
||||
"KOBO": "recalculated",
|
||||
"KUNBO": "carry_original",
|
||||
"SLVBO": "carry_original",
|
||||
"YUNBO": "carry_original",
|
||||
"TBUGUM": "recalculated",
|
||||
"ANJUN": "recalculated",
|
||||
"KITA": "recalculated",
|
||||
"HWAN": "recalculated",
|
||||
"EQIP": "recalculated",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class LoadReq(BaseModel):
|
||||
source_path: str
|
||||
|
||||
|
||||
class LoadJbrReq(BaseModel):
|
||||
jbr_path: str
|
||||
|
||||
|
||||
class SaveDbfReq(BaseModel):
|
||||
source_path: str
|
||||
target_folder_name: str = "stmate_edited"
|
||||
costn_updates: list[dict] = []
|
||||
boq_updates: list[dict] = []
|
||||
prjlst_updates: dict = {}
|
||||
smt_updates: dict = {}
|
||||
|
||||
|
||||
@app.get("/api/sources")
|
||||
def api_sources():
|
||||
return discover_sources()
|
||||
|
||||
|
||||
@app.post("/api/load")
|
||||
def api_load(req: LoadReq):
|
||||
files = read_project_files(req.source_path)
|
||||
if "COSTN.DBF" not in files:
|
||||
raise HTTPException(status_code=400, detail="COSTN.DBF 파일이 포함되지 않은 경로임")
|
||||
|
||||
costn_flds, costn_rows, _ = parse_dbf_bytes(files["COSTN.DBF"])
|
||||
boq_name = "BOQ12.DBF" if "BOQ12.DBF" in files else "BOQ11.DBF"
|
||||
boq_flds, boq_rows, _ = parse_dbf_bytes(files[boq_name]) if boq_name in files else ([], [], {})
|
||||
prj_flds, prj_rows, _ = (
|
||||
parse_dbf_bytes(files["PRJLST.DBF"]) if "PRJLST.DBF" in files else ([], [{}], {})
|
||||
)
|
||||
smt_kv = parse_prjlst_smt(files.get("PRJLST.SMT", b""))
|
||||
|
||||
rate_r_rows = []
|
||||
if "RATE_R.DBF" in files:
|
||||
_, rate_r_rows, _ = parse_dbf_bytes(files["RATE_R.DBF"])
|
||||
|
||||
sim_state = convert_dbf_to_sim_state(
|
||||
costn_rows, boq_rows, smt_kv, prj_rows[0] if prj_rows else {}
|
||||
)
|
||||
return {
|
||||
"source_path": req.source_path,
|
||||
"boq_table_name": boq_name,
|
||||
"file_list": sorted(files.keys()),
|
||||
"prjlst": prj_rows[0] if prj_rows else {},
|
||||
"prjlst_smt": smt_kv,
|
||||
"costn_count": len(costn_rows),
|
||||
"costn_rows": costn_rows[:200],
|
||||
"boq_count": len(boq_rows),
|
||||
"boq_rows": boq_rows[:200],
|
||||
"rate_r_count": len(rate_r_rows),
|
||||
"sim_state": sim_state,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/load-jbr")
|
||||
def api_load_jbr(req: LoadJbrReq):
|
||||
p = pathlib.Path(req.jbr_path)
|
||||
if not p.exists():
|
||||
raise HTTPException(status_code=404, detail=f"JB_R 파일 없음: {req.jbr_path}")
|
||||
flds, rows, _ = parse_dbf_bytes(p.read_bytes())
|
||||
matrix: list[list[float]] = []
|
||||
for r in rows:
|
||||
matrix.append([fnum(r.get(f["name"])) for f in flds])
|
||||
|
||||
def cell(row_idx: int, col_1based: int, default: float = 0.0) -> float:
|
||||
if 0 <= row_idx < len(matrix) and 0 <= col_1based - 1 < len(matrix[row_idx]):
|
||||
return matrix[row_idx][col_1based - 1]
|
||||
return default
|
||||
|
||||
raw_base = cell(20, 18, 5349.0)
|
||||
anjun_base = raw_base * 1000 if 0 < raw_base < 100000 else raw_base
|
||||
extracted_rates = {
|
||||
"KANNO": cell(20, 1, 14.5),
|
||||
"KITA": cell(20, 6, 6.0),
|
||||
"ILBAN": cell(20, 11, 6.0),
|
||||
"SANBO": cell(45, 1, 3.56),
|
||||
"KOBO": cell(45, 17, 1.01),
|
||||
"KUNBO": cell(46, 1, 3.545),
|
||||
"YUNBO": cell(46, 6, 4.5),
|
||||
"SLVBO": cell(46, 11, 12.95),
|
||||
"IYUN": cell(20, 16, 15.0),
|
||||
"ANJUN": cell(20, 17, 1.86),
|
||||
"anjunBaseAdd": anjun_base,
|
||||
}
|
||||
return {
|
||||
"name": p.name,
|
||||
"row_count": len(matrix),
|
||||
"col_count": len(flds),
|
||||
"extracted_rates": extracted_rates,
|
||||
"preview_rows": {
|
||||
"row6_kanno_kita": matrix[6][:12] if len(matrix) > 6 else [],
|
||||
"row20_iyun_anjun": matrix[20][:20] if len(matrix) > 20 else [],
|
||||
"row45_sanbo_kobo": matrix[45][:20] if len(matrix) > 45 else [],
|
||||
"row46_kunbo_yunbo_slvbo": matrix[46][:15] if len(matrix) > 46 else [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/save")
|
||||
def api_save(req: SaveDbfReq):
|
||||
files = read_project_files(req.source_path)
|
||||
safe_name = re.sub(r"[^\w\-가-힣]", "_", req.target_folder_name or "stmate_edited").strip("_")
|
||||
out_dir = WORK_DBF_ROOT / (safe_name or "stmate_edited")
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 1. COSTN.DBF 수정 반영 및 직렬화
|
||||
if "COSTN.DBF" in files:
|
||||
flds, rows, meta = parse_dbf_bytes(files["COSTN.DBF"])
|
||||
upd_map = {str(u.get("CODE", "")).strip(): u for u in req.costn_updates if u.get("CODE")}
|
||||
for r in rows:
|
||||
cd = (r.get("CODE") or "").strip()
|
||||
if cd in upd_map:
|
||||
for k, v in upd_map[cd].items():
|
||||
if k in r:
|
||||
r[k] = str(v)
|
||||
files["COSTN.DBF"] = build_dbf_bytes(flds, rows, meta)
|
||||
|
||||
# 2. BOQ11/12.DBF 수량·단가·비목금액 재계산 및 직렬화
|
||||
boq_name = "BOQ12.DBF" if "BOQ12.DBF" in files else "BOQ11.DBF"
|
||||
if boq_name in files:
|
||||
flds, rows, meta = parse_dbf_bytes(files[boq_name])
|
||||
upd_by_rec = {int(u["_recno"]): u for u in req.boq_updates if "_recno" in u}
|
||||
for r in rows:
|
||||
rno = int(r.get("_recno", -1))
|
||||
if rno in upd_by_rec:
|
||||
u = upd_by_rec[rno]
|
||||
for k in ("NAME1", "QTY1", "MAMT1", "LAMT1", "SAMT1", "TAMT1"):
|
||||
if k in u and k in r:
|
||||
r[k] = str(u[k])
|
||||
# 비목별 금액 ROUNDDOWN(QTY1 * 단가) 동기화
|
||||
q = Decimal(str(fnum(r.get("QTY1"))))
|
||||
m_u = Decimal(str(fnum(r.get("MAMT1"))))
|
||||
l_u = Decimal(str(fnum(r.get("LAMT1"))))
|
||||
s_u = Decimal(str(fnum(r.get("SAMT1"))))
|
||||
m_a = int((q * m_u).quantize(Decimal(1), rounding=ROUND_DOWN))
|
||||
l_a = int((q * l_u).quantize(Decimal(1), rounding=ROUND_DOWN))
|
||||
s_a = int((q * s_u).quantize(Decimal(1), rounding=ROUND_DOWN))
|
||||
r["M_AMT1"] = str(m_a)
|
||||
r["L_AMT1"] = str(l_a)
|
||||
r["S_AMT1"] = str(s_a)
|
||||
r["T_AMT1"] = str(m_a + l_a + s_a)
|
||||
files[boq_name] = build_dbf_bytes(flds, rows, meta)
|
||||
|
||||
# 3. PRJLST.DBF 및 PRJLST.SMT 갱신
|
||||
if "PRJLST.DBF" in files and req.prjlst_updates:
|
||||
flds, rows, meta = parse_dbf_bytes(files["PRJLST.DBF"])
|
||||
if rows:
|
||||
for k, v in req.prjlst_updates.items():
|
||||
if k in rows[0]:
|
||||
rows[0][k] = str(v)
|
||||
files["PRJLST.DBF"] = build_dbf_bytes(flds, rows, meta)
|
||||
|
||||
if "PRJLST.SMT" in files and req.smt_updates:
|
||||
files["PRJLST.SMT"] = update_prjlst_smt_bytes(files["PRJLST.SMT"], req.smt_updates)
|
||||
|
||||
# 4. 폴더 내 개별 DBF/SMT 저장 및 .STC 패키지 생성
|
||||
written_files = []
|
||||
for fname, content in files.items():
|
||||
fp = out_dir / fname
|
||||
fp.write_bytes(content)
|
||||
written_files.append({"name": fname, "size": len(content)})
|
||||
|
||||
stc_path = WORK_DBF_ROOT / f"{out_dir.name}.STC"
|
||||
with zipfile.ZipFile(stc_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for fname, content in files.items():
|
||||
zf.writestr(fname, content)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"saved_dir": str(out_dir),
|
||||
"saved_stc": str(stc_path),
|
||||
"stc_size": stc_path.stat().st_size,
|
||||
"written_count": len(written_files),
|
||||
"written_files": written_files,
|
||||
}
|
||||
|
||||
|
||||
app.mount("/", StaticFiles(directory=str(HERE), html=True), name="static")
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host="127.0.0.1", port=8095)
|
||||
Reference in New Issue
Block a user