Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1

This commit is contained in:
2026-11-04 22:24:36 +09:00
10 changed files with 3168 additions and 0 deletions
+474
View File
@@ -0,0 +1,474 @@
"""M02 일위대가 실무자료 옵션값 읽기(PLAN 785-2 · 요구 641 · 642 · 설계 642-A 2-5 ④-3) —
우리 기초 하나 + 엑셀 글감 → 입력값. 찾지 않고 읽음 · 지어 넣는 1 · 0 없음.
길(앞이 먼저 · 두 길이 다른 값 = 엇갈림 · 앞 값 둠):
B1 부르는 호표 줄 규격 · 품명 괄호(`Match.spec_inputs`)
B6 같은 호표 다른 줄 — 레미콘 규격 「a-b-c」 → 굵은골재 · 강도 · 슬럼프 · 채움량 = 그 줄 수량 ÷ 이 줄 수량
B2 선택값 줄(785-1 Formula) · 글에 없는 고르기 = 「아님 · 해당 없음」(글 · 줄에 칸 이름 · 다른 고름이 없을 때)
B3 상수 줄(기호표 `SYMBOLS` · 기호 = 입력 이름 · 「(90˚)」 = 선회각도 · 표 칸 이름 = 기호면 그 표 줄 조건값)
B4 대입식 — 우리 줄 수량 식을 중간까지 펴 분자 · 분모 자리 + 단위로 짝(하나뿐일 때만)
B5 표 거꾸로 — 못 읽은 찾기 조건 칸 = 엑셀 품과 같은 표 줄(`fit` 검산) · 「감률」 칸 = 품 배수
못 읽은 칸 = 비움(지역 · 자재지역 · 조종원직종 = 프로젝트) · 입력 필요(빈 줄을 낳음 · 요소 글에 듦) ·
안 씀(그 밖) · 기본 = `master_auto.defaults` 제안(저장 아님). 엔진 몫 `quantities` = 금액 없이 품만.
서버 단독 · 읽기만 · 짝 = `M02_Hopyo_Solve`.
"""
from __future__ import annotations
import re
from decimal import Decimal
from typing import Any, Callable
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Hopyo_Match as hm
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 = ("아님", "해당 없음", "없음", "미적용", "사용 안 함")
# 상수 기호 → 입력 이름(앞이 먼저 · 기호 = 입력 이름이면 바로)
SYMBOLS = {
"q": ("버킷용량",),
"E": ("E입력",),
"Cm": ("싸이클입력",),
"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 = ("굵은골재최대치수", "강도", "슬럼프")
CONC = ("레미콘", "콘크리트")
def _num(value: Any) -> Decimal | None:
try:
return None if value in (None, "") else Decimal(str(value))
except ArithmeticError:
return None
def plain(value: Any) -> Any:
"""JSON 수 — Decimal → int · float(글은 그대로)."""
if isinstance(value, Decimal):
value = value.normalize()
return int(value) if value == value.to_integral_value() else float(value)
return value
def specs_of(row: dict) -> list[dict]:
return mf.inputs_of(row) if isinstance(row.get("흐름"), dict) else row.get("입력", [])
def _pick(spec: dict, value: Any) -> Any:
"""고르기 칸이면 같은 수 · 같은 글의 고름(「080」 = 80) · 없으면 None · 고르기 아니면 수."""
picks = spec.get("고르기")
if not picks:
return _num(value)
hit = next((o for o in picks if str(o) == str(value)), None)
n = _num(value)
if hit is None and n is not None:
hit = next((o for o in picks if _num(o) == n), None)
return hit
# ── 엔진 몫(품만) ─────────────────────────────────────────────────────
def _env(master: mf.Master, row: dict, given: dict) -> dict:
"""입력 검사(고르기 · 범위 · 기계 키) — 빈 칸은 자리값으로 검사만 하고 None 으로 되돌림."""
holes = {}
for spec in specs_of(row):
if given.get(spec["이름"]) is None:
span = spec.get("범위")
holes[spec["이름"]] = (spec.get("고르기") or [None])[0] or (span[0] if span else 0)
env = mf._inputs(row, {**{s["이름"]: None for s in specs_of(row)}, **given, **holes}, master)
return {**env, **dict.fromkeys(holes)}
def _rate(extra: dict, env: dict, master: mf.Master) -> Decimal | None:
"""덧줄 율 — 식 꼴 「(기준) × 율 / 100」 의 율 ÷ 100 · 다른 꼴 = None."""
node = mf.parse(extra["식"])
if node[0] == "bin" and node[1] == "/" and node[3] == ("num", Decimal(100)):
left = node[2]
if left[0] == "bin" and left[1] == "*":
return mf.evaluate(left[3], env, master) / 100
return None
def quantities(master: mf.Master, key: str, given: dict) -> list[dict]:
"""로직 줄마다 품 `{이름, 종류, 단위, 요소, 품, 까닭?}` + 덧줄 율(종류 「율」) — 금액 안 셈 ·
빈 칸을 쓰는 줄만 품 None + 까닭(`mf.run` 과 같은 길)."""
row = master.logic(key)
if isinstance(row.get("흐름"), dict): # 흐름 로직 — 엔진 한 번(빈 칸 = 자리값 없음 → 못 셈)
try:
got = mf.run(master, key, given)
except mf.FormulaError as e:
return [{"이름": "", "종류": "", "단위": "", "품": None, "까닭": str(e)}]
return [{**x, "종류": "", "품": x.get("수량")} for x in got.get("줄", [])]
env = _env(master, row, given)
for step in row.get("중간", []):
try:
env[step["이름"]] = mf.evaluate(mf.parse(step["식"]), env, master)
except mf.EmptyInput as e:
env[step["이름"]] = mf.Blank(str(e))
out = []
for item in row.get("호표", []):
one = {k: item.get(k) for k in ("이름", "종류", "단위", "요소", "비목")}
try:
one["품"] = mf.evaluate(mf.parse(item["수량"]), env, master)
except mf.EmptyInput as e:
one |= {"품": None, "까닭": str(e)}
out.append(one)
for extra in row.get("덧줄", []):
one = {"이름": extra["이름"], "종류": "율", "단위": "%", "비목": extra.get("비목")}
try:
one["품"] = _rate(extra, env, master)
except mf.EmptyInput as e:
one |= {"품": None, "까닭": str(e)}
out.append(one)
return out
# ── 식 풀기 ───────────────────────────────────────────────────────────
def _names(node) -> set:
return {p[1] for p in mf.walk(node) if p[0] == "name"}
def _mids(row: dict) -> dict:
return {s["이름"]: mf.parse(s["식"]) for s in row.get("중간", [])}
def deps(row: dict, text: str) -> set:
"""식이 기대는 입력 이름 — 중간을 거쳐 폄(만약 갈래 모두 · 넉넉히)."""
mids, seen, todo = _mids(row), set(), _names(mf.parse(text))
while todo:
name = todo.pop()
if name in seen:
continue
seen.add(name)
if name in mids:
todo |= _names(mids[name])
return seen
def _place(node, side: str, mids: dict, out: dict, depth: int = 0) -> None:
"""이름마다 분자 · 분모 자리 — 「/」 오른쪽이 뒤집힘 · 만약은 두 갈래만 · 찾기 · 함수 안은 안 봄."""
kind = node[0]
if kind == "name":
if node[1] in mids and depth < 20:
_place(mids[node[1]], side, mids, out, depth + 1)
else:
out.setdefault(node[1], set()).add(side)
elif kind == "bin" and node[1] in ("*", "/", "+", "-"):
_place(node[2], side, mids, out, depth)
flip = {"분자": "분모", "분모": "분자"}[side] if node[1] == "/" else side
_place(node[3], flip, mids, out, depth)
elif kind == "call" and node[1] == "만약":
for arm in node[2][1:]:
_place(arm, side, mids, out, depth)
def _element_names(row: dict, element: Any) -> set:
"""요소가 기대는 입력 — 글 = 식 · 묶음 = 「{이름}」 구멍."""
if isinstance(element, str):
return deps(row, element)
if isinstance(element, dict):
return {n for v in element.values() for n in re.findall(r"\{([^{}]+)\}", str(v))}
return set()
def places(row: dict) -> dict:
"""입력 이름 → {분자 | 분모} — 호표 줄 수량 식에서."""
out: dict = {}
mids = _mids(row)
for item in row.get("호표", []):
_place(mf.parse(item["수량"]), "분자", mids, out)
return out
def finds(master: mf.Master, row: dict, depth: int = 0) -> dict:
"""찾기 조건 칸으로 쓰인 입력 → [(표, 조건)] — 「로직(키, 칸=이름)」 한 겹 안도 이름을 옮겨 봄."""
out = {k: list(v) for k, v in ma._uses(row)[0].items()}
if depth > 1:
return out
for text in ma._texts(row):
for part in mf.walk(mf.parse(text)):
if part[0] != "logic" or not isinstance(part[1], str) or "{" in part[1]:
continue
try:
inner = finds(master, master.logic(part[1]), depth + 1)
except mf.FormulaError:
continue
passed = {k: v[1] for k, v in part[2] if v[0] == "name"}
for name, uses in inner.items():
if name in passed:
out.setdefault(passed[name], []).extend(uses)
return out
# ── 읽기 ─────────────────────────────────────────────────────────────
class _Got:
"""읽은 값 · 출처 · 엇갈림."""
def __init__(self, specs: list[dict]) -> None:
self.specs = {s["이름"]: s for s in specs}
self.value: dict = {}
self.where: dict = {}
self.clash: dict = {}
def put(self, name: str, value: Any, how: str, at: Any = None, text: Any = "") -> None:
spec = self.specs.get(name)
value = _pick(spec, value) if spec else None
if value is None or name in SKIP:
return
mine = {"값": plain(value), "길": how, "행": at}
if name not in self.value:
self.value[name], self.where[name] = value, {"길": how, "행": at, "글": str(text)[:120]}
elif self.value[name] != value:
first = {
"값": plain(self.value[name]),
**{k: self.where[name][k] for k in ("길", "행")},
}
self.clash.setdefault(name, [first])
if mine not in self.clash[name]:
self.clash[name].append(mine)
def _b1(got: _Got, line: dict | None, how: str = "B1") -> None:
if not line:
return
values, _ = hm.spec_inputs(line, list(got.specs.values()))
text = f"{line.get('이름') or ''} {line.get('규격') or ''}".strip()
for name, value in values.items():
got.put(name, value, how, line.get("행"), text)
def _b6(got: _Got, line: dict | None, same: list[dict]) -> None:
for other in same or []:
text = f"{other.get('이름') or ''} {other.get('규격') or ''}"
mix = _MIX.search(text)
if not mix or not any(w in text for w in CONC):
continue
for name, value in zip(MIX_NAMES, mix.groups(), strict=True):
got.put(name, value, "B6", other.get("행"), text)
have, mine = _num(other.get("수량")), _num((line or {}).get("수량"))
for name, spec in got.specs.items():
unit = mm.plain_unit(spec.get("단위"))
fill = "콘크리트" in name and unit.count("/") == 1 and have and mine
if fill and unit.split("/") == [
mm.plain_unit(other.get("단위")),
mm.plain_unit(line.get("단위")),
]:
got.put(
name, (have / mine).quantize(Decimal("0.0001")), "B6", other.get("행"), text
)
def _b2(got: _Got, picks: list[dict], text: str, keys: dict) -> None:
"""선택값 줄 · 글에 없는 켜기 칸 = 아님(표 줄을 고르는 찾기 조건 칸은 B5 몫 · 뺌)."""
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 []
neg = next((o for o in opts if str(o) in NEG), None)
if neg is None or name in got.value or name in keys:
continue
words = [name, *(hm._PAREN.sub("", str(o)) for o in opts if o != neg)]
if not any(len(w) > 2 and mm.plain_spec(w) in flat for w in words):
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 in got.specs and _unit_ok(c.get("단위"), got.specs[n].get("단위"))
]
if names:
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:
continue
for ref, cond in refs:
rows = [r for r in master.get(ref).get("줄", []) if _num(r.get(sym)) == _num(value)]
conds = {r.get(cond) for r in rows}
if len(conds) == 1 and None not in conds:
got.put(name, conds.pop(), "B5", at, f"{sym} = {plain(_num(value))} → {ref}")
def _b4(got: _Got, forms: list[dict], row: dict) -> None:
where = places(row)
for form in forms or []:
for side in ("분자", "분모"):
for term in form.get(side) or []:
unit = mm.plain_unit(str(term.get("단위") or "").split("/")[0])
hits = [
n
for n, s in got.specs.items()
if side in where.get(n, ()) and unit and mm.plain_unit(s.get("단위")) == unit
]
value = term.get("수", term.get("값"))
if term.get("기호") is not None:
value = (form.get("대입") or {}).get(term["기호"])
if len(hits) == 1 and _num(value) is not None:
got.put(hits[0], value, "B4", form.get("행"), form.get("글"))
def _ok(fit: Callable, given: dict) -> dict:
try:
return fit(given) or {}
except mf.FormulaError:
return {}
def _b5(got: _Got, master: mf.Master, row: dict, uses: dict, fit: Callable, need: set) -> None:
"""못 읽은 찾기 조건 칸 — 표 줄마다 품 검산 · 줄이 갈리면 그 값(같은 값 여럿 = 엇갈림)."""
rate = [n for n in need if n.endswith("감률") and n not in got.value]
zero = dict.fromkeys(rate, Decimal(0))
tables: dict = {}
for name in sorted(need):
for ref, cond in uses.get(name, []):
if name not in got.value and got.specs.get(name, {}).get("고르기"):
tables.setdefault(ref, []).append((name, cond))
for ref, conds in tables.items():
rows, combos = master.get(ref).get("줄", []), []
bound = {
c: got.value[n]
for n, refs in uses.items()
if n in got.value
for r, c in refs
if r == ref
}
for r in rows:
if all(r.get(c) is None or str(r.get(c)) == str(v) for c, v in bound.items()):
combo = {n: r.get(c) for n, c in conds}
combos += [combo] if combo not in combos else []
exact, scaled = [], []
for combo in combos:
fill = {n: v if v is not None else got.specs[n]["고르기"][0] for n, v in combo.items()}
res = _ok(fit, {**got.value, **zero, **fill})
if res.get("맞음"):
exact.append(combo)
elif rate and (res.get("배") or 0) > 1:
scaled.append(combo)
live = exact or scaled # 그대로 같은 줄이 감률 배 줄보다 먼저
if not live or (len(live) == len(combos) > 1):
continue
for name, _ in conds:
seen = {str(c[name]) for c in live if c[name] is not None}
for v in [str(o) for o in got.specs[name]["고르기"] if str(o) in seen]:
got.put(name, v, "B5", None, f"{ref} 줄 = 엑셀 품")
if rate: # 감률 = 엑셀 품 ÷ 우리 품(감률 0)이 모든 노무 · 기계 줄에서 같은 배
res = _ok(fit, {**got.value, **zero})
k = res.get("배")
if k and k > 1:
cut = (Decimal(1) - Decimal(1) / Decimal(str(k))) * 100
cut = (
cut.quantize(Decimal("1"))
if abs(cut - cut.quantize(Decimal("1"))) < Decimal("0.5")
else cut
)
if _ok(fit, {**got.value, rate[0]: cut}).get("맞음"):
got.put(
rate[0],
cut,
"B5",
None,
f"품 × {plain(Decimal(str(k)).quantize(Decimal('0.01')))}",
)
def bind(master: mf.Master, key: str, src: dict, fit: Callable | None = None) -> dict:
"""옵션값 읽기 → `{입력값, 출처, 엇갈림, 안씀, 입력필요, 비움, 기본}` — 설계 642-A 2-5 ④-3."""
row = master.logic(key)
specs = specs_of(row)
got = _Got(specs)
line, block = src.get("줄"), src.get("블록") or {}
_b1(got, line)
if not line and block:
_b1(got, {"이름": block.get("이름"), "규격": "", "행": (block.get("행") or [None])[0]})
_b6(got, line, src.get("같은줄") or [])
uses = finds(master, row) if not isinstance(row.get("흐름"), dict) else {}
_b3(got, src.get("상수") or [], master, uses)
if not isinstance(row.get("흐름"), dict):
_b4(got, src.get("식") or src.get("대입식") or [], row)
whole = " ".join(
[
*(block.get("글") or []),
str((line or {}).get("이름") or ""),
str((line or {}).get("규격") or ""),
]
)
direct = {} if isinstance(row.get("흐름"), dict) else ma._uses(row)[0]
_b2(got, src.get("선택값") or [], whole, direct)
if fit:
_b5(got, master, row, uses, fit, _needed(master, key, row, got.value))
need = _needed(master, key, row, got.value)
base = ma.defaults(master, row)
unread = [s["이름"] for s in specs if s["이름"] not in got.value]
skip = [n for n in unread if n in SKIP]
want = [n for n in unread if n in need and n not in SKIP and n not in got.clash]
unused = [n for n in unread if n not in skip and n not in want and n not in got.clash]
return {
"입력값": {k: plain(v) for k, v in got.value.items() if k not in got.clash},
"출처": {k: v for k, v in got.where.items() if k not in got.clash},
"엇갈림": got.clash,
"안씀": unused,
"입력필요": want,
"비움": skip,
"기본": {n: plain(base.get(n)) for n in [*want, *unused, *got.clash]},
}
def _needed(master: mf.Master, key: str, row: dict, have: dict) -> set:
"""못 읽은 칸 중 쓰이는 것 — 빈 품 줄이 기대는 칸(기본값으로 0 이 되는 줄 = 꺼진 줄 · 뺌) +
품 있는 줄의 요소 글에 든 칸(기계 · 지역 등 단가 몫)."""
if isinstance(row.get("흐름"), dict):
return {s["이름"] for s in specs_of(row)} - set(have)
lines = quantities(master, key, have)
base = {k: v for k, v in ma.defaults(master, row).items() if v is not None and k not in have}
probe = (
quantities(master, key, {**base, **have}) if any(x["품"] is None for x in lines) else lines
)
items = [*row.get("호표", []), *({"수량": x["식"]} for x in row.get("덧줄", []))]
need: set = set()
for item, now, then in zip(items, lines, probe, strict=True):
if now.get("까닭") and then["품"] != 0:
need |= deps(row, item["수량"])
elif now["품"] not in (None, 0):
need |= _element_names(row, item.get("요소"))
# ponytail: 0 이 되는 줄 = 꺼진 줄로 봄(기본값 하나로 판정) — 값마다 다른 줄이 나오면 칸마다 검산으로
return need - set(have)
+279
View File
@@ -0,0 +1,279 @@
"""M02 일위대가 실무자료 블록 풀이 검산 · 판정(PLAN 785-2 · 요구 642 · 설계 642-A 2-5 ④-4 · ④-5) —
후보 기초마다 옵션값 읽기(`M02_Hopyo_Bind`) → 엔진 한 번(품 · 금액 참고) → 엑셀 단위 줄과 짝 · 판정.
짝(같은 묶음끼리): L 노무 = 직종 이름 · X 기계 = 시간 단위 로직 줄 · M 자재 = 뜻 낱말(남은 하나끼리) ·
R 율 = 덧줄 율(같은 율 › 남은 하나끼리) · E 값 · S 하위 = 엑셀만.
품 같음(`same`) = 엑셀 자릿수 반올림 폭 안(0.67 ↔ 0.6667) 또는 0.5 % · 금액은 참고(노임 · 단가 해가 다름).
줄 판정 = 품 같음 · 품 다름 · 입력 필요 · 엑셀만 · 우리만(우리 줄 품 0 = 꺼진 줄 · 안 보임).
절 판정 = 기초 풂(품 다름 없는 후보 하나) · 후보 여럿 · 기초 없음 /
블록 판정 = 기초로 풂 · 기초+덧(엑셀만 줄 → `Units` 잇기) · 줄 조합(기초 없음뿐) · 고를 것 n.
서버 단독 · 읽기만 · 자동 고름 없음(제안 · 사용자 확인).
"""
from __future__ import annotations
from decimal import Decimal
from statistics import median
from typing import Any, Callable
from M02_MasterTemplete import M02_Hopyo_Bind as hb
from M02_MasterTemplete import M02_Hopyo_Match as hm
mf, mm, ma = hb.mf, hb.mm, hb.ma
SAME, DIFF, NEED, EXCEL, OURS = "품 같음", "품 다름", "입력 필요", "엑셀만", "우리만"
SOLVED, SOME, NONE = "기초 풂", "후보 여럿", "기초 없음"
BY_BASE, BASE_PLUS, BY_LINES, PICK = "기초로 풂", "기초+덧", "줄 조합", "고를 것"
SHARE = Decimal("0.005")
HOURS = {"시간", "hr", "h", "시"}
def _d(value: Any) -> Decimal | None:
try:
return None if value is None else Decimal(str(round(float(value), 10)))
except (ArithmeticError, TypeError, ValueError):
return None
def same(excel: Any, ours: Any) -> bool:
"""품 같음 — 엑셀 적힌 자릿수의 반올림 폭 안 또는 0.5 % 안."""
e, o = _d(excel), _d(ours)
if e is None or o is None:
return False
gap = abs(e - o)
places = -e.normalize().as_tuple().exponent
width = Decimal(5) * Decimal(10) ** -(places + 1) if 0 <= places <= 4 else Decimal(0)
return gap <= width or gap <= abs(e) * SHARE
def _kind(line: dict) -> str | None:
kind = line.get("종류")
if kind in ("로직", "기계"):
return "X" if str(line.get("단위") or "").strip() in HOURS else "S"
return {"인력": "L", "재료": "M", "율": "R"}.get(kind)
def _strict(item: dict, line: dict) -> bool:
kind = item.get("묶음")
if kind == "L":
return hm.tidy(line.get("이름")) == hm.tidy(item.get("직종") or item.get("이름"))
if kind == "M":
return hm.near(hm.words(item.get("이름")), hm.words(line.get("이름")))
if kind == "R":
return same(item.get("품"), line.get("품"))
return kind == "X"
def _excel(item: dict, share: Decimal) -> dict:
q = _d(item.get("품"))
return {
"묶음": item.get("묶음"),
"이름": item.get("이름"),
"품": hb.plain(q / share if q is not None and item.get("묶음") != "R" else q),
"행": item.get("행"),
**({"별도": True} if item.get("어떻게") == "별도" else {}),
}
def _row(item: dict | None, line: dict | None, share: Decimal) -> dict:
ex = _excel(item, share) if item else None
us = {k: hb.plain(line.get(k)) for k in ("이름", "종류", "단위", "품")} if line else None
if line and line.get("까닭"):
us["까닭"] = line["까닭"]
if ex is None:
verdict = NEED if line.get("까닭") else OURS
elif us is None:
verdict = EXCEL
elif us["품"] is None:
verdict = NEED if line.get("까닭") else DIFF
else:
verdict = SAME if same(ex["품"], us["품"]) else DIFF
out = {"엑셀": ex, "우리": us, "판정": verdict}
names = f"{(ex or {}).get('이름') or ''}|{(us or {}).get('이름') or ''}"
if item and line and item.get("묶음") == "X" and "타이어" in names and "무한궤도" in names:
out["표지"] = "기계 갈래 엇갈림"
return out
def pair(lines: list[dict], items: list[dict], share: Any = 1) -> list[dict]:
"""엑셀 단위 줄 ↔ 우리 줄 짝 → 줄 판정(엑셀 차례 · 남은 우리 줄 뒤)."""
share = _d(share) or Decimal(1)
used: set = set()
got: dict = {}
late = []
for i, item in enumerate(items):
pool = [j for j, ln in enumerate(lines) if j not in used and _kind(ln) == item.get("묶음")]
hits = [j for j in pool if _strict(item, lines[j])]
exact = [j for j in hits if lines[j].get("이름") == (item.get("직종") or item.get("이름"))]
if hits:
got[i] = (exact or hits)[0]
used.add(got[i])
else:
late.append(i)
for i in late: # 남은 하나끼리(결속선 ↔ 어닐링철선)
kind = items[i].get("묶음")
pool = [j for j, ln in enumerate(lines) if j not in used and _kind(ln) == kind]
rest = [k for k in late if items[k].get("묶음") == kind and k not in got]
if len(pool) == 1 and len(rest) == 1 and kind in ("M", "R"):
got[i] = pool[0]
used.add(pool[0])
rows = [_row(item, lines[got[i]] if i in got else None, share) for i, item in enumerate(items)]
for j, line in enumerate(lines):
if (
j not in used
and line.get("품") != 0
and (line.get("품") is not None or line.get("까닭"))
):
rows.append(_row(None, line, share))
return rows
def _usable(items: list[dict]) -> list[dict]:
return [x for x in items if not x.get("밖")]
def fitter(master: mf.Master, key: str, items: list[dict], share: Any = 1) -> Callable:
"""입력값 → `{맞음, 배}` — 맞음 = 짝 하나 이상 · 모두 품 같음 / 배 = 노무 · 기계 줄이 모두 같은 배
(자재 · 율 줄은 같음) — 표 거꾸로 · 감률 읽기(B5)가 씀."""
items = _usable(items)
def fit(given: dict) -> dict:
rows = pair(hb.quantities(master, key, given), items, share)
both = [r for r in rows if r["엑셀"] and r["우리"] and r["엑셀"]["품"] and r["우리"]["품"]]
ok = bool(both) and all(r["판정"] == SAME for r in both)
labor = [r for r in both if r["엑셀"]["묶음"] in ("L", "X")]
k = None
if labor:
k = median(_d(r["엑셀"]["품"]) / _d(r["우리"]["품"]) for r in labor)
flat = all(same(r["엑셀"]["품"], _d(r["우리"]["품"]) * k) for r in labor)
rest = all(r["판정"] == SAME for r in both if r not in labor)
k = float(k) if flat and rest else None
return {"맞음": ok, "배": k}
return fit
def _money(master: mf.Master, key: str, bound: dict, given: dict) -> dict:
"""금액 참고 — 입력 필요가 없을 때 엔진 한 번(안 씀 · 비움 = 기본 제안 · 저장 안 함)."""
if bound["입력필요"]:
return {"계": None, "까닭": f"입력 필요 {len(bound['입력필요'])}"}
row = master.logic(key)
base = ma.defaults(master, row)
fill = {n: base.get(n) for n in [*bound["안씀"], *bound["비움"]]}
try:
got = mf.run(master, key, mf.fill_empty(row, {**fill, **given}))
except mf.FormulaError as e:
return {"계": None, "까닭": str(e)}
if "결과" in got:
return {"결과": hb.plain(got["결과"])}
return {k: hb.plain(got.get(k)) for k in ("노무비", "재료비", "경비", "계")}
def candidate(master: mf.Master, key: str, src: dict, items: list[dict], share: Any = 1) -> dict:
"""후보 기초 하나 — 바인딩 · 줄 대조 · 판정 · 금액 참고."""
row = master.logic(key)
bound = hb.bind(master, key, src, fitter(master, key, items, share))
given = {**bound["입력값"], **{n: v[0]["값"] for n, v in bound["엇갈림"].items()}}
idle = {n: bound["기본"][n] for n in bound["안씀"] if bound["기본"].get(n) is not None}
rows = pair(hb.quantities(master, key, {**idle, **given}), _usable(items), share)
count = {v: sum(r["판정"] == v for r in rows) for v in (SAME, DIFF, NEED, EXCEL, OURS)}
if count[DIFF]:
verdict = DIFF
elif bound["입력필요"] or count[NEED] or bound["엇갈림"]:
verdict = NEED
else:
verdict = SAME if count[SAME] else EXCEL
return {
"키": key,
"이름": row.get("이름"),
"바인딩": bound,
"줄": rows,
"셈": count,
"판정": verdict,
"금액": _money(master, key, bound, given),
}
def _section(master: mf.Master, block: dict, part: dict, src: dict) -> dict:
items = part.get("항목")
items = _usable(block.get("항목") or []) if items is None else items
mine = {**src, **{k: part[k] for k in ("선택값", "상수", "식", "대입식") if k in part}}
share = part.get("몫")
share = (share.get("값") if isinstance(share, dict) else share) or 1
cands = [candidate(master, k, mine, items, share) for k in part.get("후보") or []]
good = [c for c in cands if c["셈"][DIFF] == 0 and (c["셈"][SAME] or len(cands) == 1)]
verdict = NONE if not cands else SOLVED if len(good) == 1 else SOME
pick = good[0]["키"] if verdict == SOLVED else None
return {
"제목": part.get("제목", ""),
"판정": verdict,
"고름": pick,
"후보": cands,
"항목": items,
}
def block(
master: mf.Master, blk: dict, parts: list[dict], src: dict | None = None, units: Any = None
) -> dict:
"""블록 풀이 — 절마다 후보 검산 · 절 판정 · 덧 줄(엑셀만 → `Units`) · 블록 판정."""
src = {**(src or {}), "블록": blk}
secs = [_section(master, blk, p, src) for p in parts]
extra, n = [], 0
for sec in secs:
chosen = next((c for c in sec["후보"] if c["키"] == sec["고름"]), None)
if sec["판정"] == SOME:
n += 1
if chosen:
b = chosen["바인딩"]
n += len(b["입력필요"]) + len(b["엇갈림"]) + chosen["셈"][DIFF]
alone = [r for r in chosen["줄"] if r["판정"] == EXCEL]
lone = [x for x in sec["항목"] if any(r["엑셀"]["행"] == x.get("행") for r in alone)]
else:
lone = sec["항목"] if sec["판정"] == NONE else []
for item in lone:
if units is None:
from M02_MasterTemplete import M02_Hopyo_Units as hu
units = hu.Units()
link = units.line(item)
extra.append(
{"절": sec["제목"], "행": item.get("행"), "이름": item.get("이름"), **link}
)
n += link.get("상태") != "이어짐"
for sec in secs:
sec.pop("항목")
if secs and all(s["판정"] == NONE for s in secs):
verdict = BY_LINES
elif n:
verdict = PICK
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
@@ -88,6 +88,49 @@ def _lowest(master: mf.Master, uses: list):
return (both or next((s for s in sets if s), [None]))[0]
def _first_row(master: mf.Master, uses: list):
"""찾기 조건 칸의 표 첫 줄 값 — 범위 줄이면 아래 끝(열린 아래 끝 = 위 끝) · 빈 칸 줄은 건넘."""
for ref, cond in uses:
try:
rows = master.get(ref).get("줄", [])
except mf.FormulaError:
continue
for r in rows:
got = r.get(cond)
if isinstance(got, list):
got = next((v for v in got if v is not None), None)
if got is not None:
return got
return None
def _holds_zero(span) -> bool:
low, high = span[0], span[1]
return (low is None or Decimal(str(low)) <= 0) and (high is None or Decimal(str(high)) >= 0)
def defaults(master: mf.Master, row: dict) -> dict:
"""「기본」 제안(PLAN 785-2 · 639-1a) — 「기본」 칸 › 고르기 첫째 › 가드 0 › 0 을 품은 범위 0 ›
찾기 조건 표 첫 줄 › None(못 정함 = 사용자 입력). 1 을 지어 넣지 않음 · `values` 와 따로."""
specs = mf.inputs_of(row) if isinstance(row.get("흐름"), dict) else row.get("입력", [])
finds = {} if isinstance(row.get("흐름"), dict) else _uses(row)[0]
guard = guards(row)
out = {}
for spec in specs:
name, span = spec["이름"], spec.get("범위")
if spec.get("기본") not in (None, ""):
out[name] = spec["기본"]
elif spec.get("고르기"):
out[name] = spec["고르기"][0]
elif name in guard:
out[name] = Decimal(0)
elif span and _holds_zero(span):
out[name] = Decimal(0)
else:
out[name] = _first_row(master, finds.get(name, []))
return out
def values(master: mf.Master, row: dict) -> dict:
"""로직 줄 하나의 입력마다 자동값 · 흐름 로직은 「설계 때 정할 것」 예시값."""
if isinstance(row.get("흐름"), dict):
+194
View File
@@ -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 없음
+50
View File
@@ -0,0 +1,50 @@
# STMATE 계산 및 제어 흐름 독립 테스트 웹앱 (`tools/stmate-test`)
## 개요
경쟁사 설계예산 프로그램 **STMATE**(경영정보(주))에 대한 **REA 6.7.0 / Ghidra 12.1.4 정적 분석** 및 지식DB(`resources/knowledge/original/경쟁사 프로그램/STmate 분석/`) 결과를 바탕으로, 주요 기능과 계산 사슬·제어 옵션을 브라우저에서 독립적으로 확인하고 조작할 수 있도록 만든 테스트 웹앱입니다.
- **완전 분리 동작**: Aislo 본체 서버·DB·로그인 세션과 무관하게 `index.html` 단독으로 동작합니다.
- **상태 보관 및 입출력**: 현재 브라우저 탭(`sessionStorage`)에 자동 보관되며, 상단 버튼으로 **JSON 저장 · 파일 열기 · 예제 초기화**를 지원합니다.
## 주요 확인 및 제어 기능 (6개 탭)
1. **01 기초자원 · 노임환산 (`ST_Sys.CPM`, `COSTN.DBF`)**
- 자재·노무·경비 기초자원의 5개 가격지 후보(`AMT_11..51`: 조달·물자·물정·거래·견적) 입력
- 채택 방식 제어: **0이 아닌 후보 중 최저가 자동채택(`min`)** 또는 **특정 후보 슬롯 수동고정**
- 노임 시간당 환산식(`RXNM_`, 예: `1/8*16/12*25/20`) 입력 및 **좌→우 순차 평가**(선계수 곱셈 오차 방지) 대조
- 당초/변경 USD 환율(`EXLNM1/2`) 및 달러 단가 소수점 자리수(`ECDNM`) 설정
2. **02 중기 · 단가산출(Q식) (`ST_SanCal.CPM`, `COSTN.SMT`)**
- 시간당 중기사용료(`X` 코드): 운전원 시간당 노임 연결, 연료·소모품(재료비), 손료·정비·관리비(경비) 성분별 절사 합산
- 단가산출(`D` 코드): 품셈 인자(`q, K, f, E, Cm`) 및 작업량 식(`Q = 3600*q*K*f*E/Cm` 등) 평가 → `Q` 소수 2자리 확정 후 중기 성분을 나누어 **소수 1자리 절사**
3. **03 일위대가 조립 (`ST_EdiLWi.CPM`)**
- 기초자원(`M/L/S`), 중기(`X`), 단가산출(`D`)을 조합하여 단위당 복합단가(`TAMT = MAMT + LAMT + SAMT`) 산출
- 수량 할증률(`X_HAL %`) 및 나눗셈 수량 플래그(`DEVD1`: `1/QTY`) 제어
- 호표 성분 소계 원 단위 절사(`ROUNDDOWN`)와 집계표 사사오입(`ROUND`) 비교
4. **04 내역서 · 차액보정 (`ST_Func_C.CPM :: SUM_TOT / OPTIMIZ`)**
- 일반 말단 공종 행과 제외금액 구역(`C*` 관급자재대 등 직접공사비 합산 제외) 구분 제어
- 계산 모드 제어: **비목별 금액 우선(`CHNNM=0`, `ROUNDDOWN(수량×단가)`)** vs **총액 우선(`CHNNM=1`, `OPTIMIZ` 차액보정)**
- 차액 보정 우선순위(`OPTNM`: `SML` 경비→재료→노무 등)에 따른 단수 차액 배분 시뮬레이션
5. **05 제잡비 · 원가계산 (`ST_KanJub.CPM :: Calc_Kanjub`)**
- 원가계산 4형식 전환 제어: **일반형식(`T`) · 수공형식(`S`) · 실적일반(`TA`) · 실적수공(`SA`)**
- 형식별 밑수 자동 전환 확인 (예: 환경보전비·보증수수료 밑수가 일반형식은 `재+직노+산출경비`, 실적일반은 `직접공사비`)
- 간접비 순차 절사 전달 (예: 건강보험료 절사 확정액 `C_KUNBO` → 노인장기요양보험료 밑수)
- 산업안전보건관리비 관급자재 포함(`(직재+직노+관급원자재/1.1)×율 + 기초액`) 및 1.2배 상한 비교 제어
- 부가세 옵션(`I_BUGA`), 도급액 절사 단위(`I_TOT_JEOL`), **끝전조정(`차액 → ROUND(차액/1.1) → 이윤 차감`)**, 관급자재 천원 올림(`ROUNDUP(-3)`) 제어
6. **06 변경설계 · REA 근거 (`ST_KanJub.CPM`, `Main 0x55a640`)**
- 당초 대비 변경수량 입력 및 간접비 항목별 변경 계산모드 제어:
- `recalculated` (변경 직접비 기준 재계산)
- `carry_original` (사후정산 항목 당초액 유지 — 건강·장기요양·연금 등)
- `proportional` (방법 A: 당초액 비례 증감)
- `delta_rate` (방법 B: 증감 직접비 × 요율)
- `K_ORD` 등록·차수내리기 전이 규칙(`K - (K>5 ? K-4 : 1)`) 시뮬레이터 및 REA 정적분석 핵심 주소록 확인
## 주의사항
- 예제에 포함된 기초단가와 요율은 기능 확인용 시험 데이터이며, 현행 법령·품셈의 확정 기준값이 아닙니다.
- 라이선스 동글 없이 수행한 정적 역공학(REA) 결과 기반이므로, 원본 실행 파일의 모든 예외 분기와 수치 동등성을 보증하지 않습니다.
+684
View File
@@ -0,0 +1,684 @@
/* -*- coding: utf-8 -*- */
/**
* STMATE 계산 사슬 및 제어 옵션 시뮬레이터 UI 컨트롤러 (app.js)
*/
const STORAGE_KEY = "aislo_stmate_test_v1";
function createDefaultSample() {
const mkRes = (id, name, spec, unit, kind, candidates, manualPrice, convertHourly = false) => ({
id,
code: id,
name,
spec,
unit,
kind,
candidates,
adoptMode: "min",
manualPrice,
convertHourly,
isUsd: false,
});
return {
sysConfig: {
rxFormula: "1/8*16/12*25/20",
condCodes: "L00008, L00009, L00010",
usdRate1: 1350,
usdRate2: 1400,
useChangedUsd: false,
ecdDigits: 0,
chnMode: "bimok",
optOrder: "SML",
kOrdSim: { pclass: 7, regMode: 7, flag: 0, currentK: 2 },
},
resources: [
mkRes(
"L00008",
"건설기계운전사 (일노임→시간당)",
"표준직종",
"hr",
"노무비",
[0, 0, 0, 0, 267360],
267360,
true,
),
mkRes("L00001", "보통인부", "시중노임", "인", "노무비", [165545, 0, 0, 0, 165545], 165545),
mkRes("L00002", "특별인부", "시중노임", "인", "노무비", [212410, 0, 0, 0, 212410], 212410),
mkRes(
"M00001",
"경유 (중기연료)",
"저유황",
"L",
"재료비",
[1520, 1480, 1453, 1510, 1490],
1453,
),
mkRes(
"M00002",
"레미콘 (사급자재)",
"25-24-150",
"㎥",
"재료비",
[92000, 89500, 91000, 88700, 90000],
88700,
),
mkRes(
"M00901",
"관급 원심력콘크리트관(흄관)",
"D1000mm",
"본",
"재료비",
[285000, 279000, 281000, 0, 279000],
279000,
),
mkRes(
"S00902",
"조달수수료 (관급부대비)",
"0.54%",
"식",
"경비",
[771029, 0, 0, 0, 771029],
771029,
),
],
machines: [
{
id: "X00001",
code: "X00001",
name: "굴삭기 (무한궤도)",
spec: "0.2 ㎥",
unit: "hr",
operatorId: "L00008",
operatorCount: 1,
fuelId: "M00001",
fuelConsumption: 5.0,
miscRatePercent: 6.0,
acquisitionPrice: 41800000,
hourlyRateFactor: 0.000312,
},
],
sanItems: [
{
id: "D00002",
code: "D00002",
name: "구조물 터파기(토사) 굴삭기 0.2㎥",
spec: "기계 100%",
unit: "㎥",
machineId: "X00001",
formulaType: "3600_qKfE_Cm",
q: 0.2,
K: 0.7,
f: 0.85,
E: 0.55,
Cm: 15,
},
],
ilwiItems: [
{
id: "B00001",
code: "B00001",
name: "소형 구조물 기초 콘크리트 타설 및 터파기 복합공",
spec: "인력+기계복합",
unit: "㎥",
lines: [
{ refId: "M00002", qty: 1.0, halPercent: 2.0, devd: false },
{ refId: "L00001", qty: 0.35, halPercent: 0, devd: false },
{ refId: "L00002", qty: 0.15, halPercent: 0, devd: false },
{ refId: "D00002", qty: 1.2, halPercent: 0, devd: false },
],
},
],
boqItems: [
{
id: "BOQ1",
code: "D00002",
name: "본선 구조물 터파기 (토사)",
refId: "D00002",
rowType: "normal",
qty: 1250.5,
changedQty: 1420.0,
},
{
id: "BOQ2",
code: "B00001",
name: "배수 구조물 콘크리트 및 기초 복합시공",
refId: "B00001",
rowType: "normal",
qty: 340.3,
changedQty: 395.0,
},
{
id: "BOQ3",
code: "M00901",
name: "[제외구역 C4] 관급 흄관 자재대 (원자재)",
refId: "M00901",
rowType: "gov_mat",
qty: 120,
changedQty: 120,
},
{
id: "BOQ4",
code: "S00902",
name: "[제외구역 C4] 관급 조달수수료",
refId: "S00902",
rowType: "gov_fee",
qty: 1,
changedQty: 1,
},
],
kanjubConfig: {
scheme: "T",
indirectTruncDigits: 0,
anjunBaseAdd: 5349000,
anjunGovVatExcluded: true,
anjunCap12: true,
vatMode: "supply_10",
totJeolDigits: -3,
enableProfitEndAdj: true,
govRoundMode: "up",
govRoundDigits: -3,
rates: {
KANNO: 14.5,
SANBO: 3.56,
KOBO: 1.01,
KUNBO: 3.545,
SLVBO: 12.95,
YUNBO: 4.5,
TBUGUM: 0.0,
ANJUN: 1.86,
KITA: 6.0,
HWAN: 0.8,
EQIP: 0.4,
ILBAN: 6.0,
IYUN: 15.0,
S_SUDO: 0.45,
S_BOKRI: 1.15,
S_SOMO: 0.65,
S_TONGSIN: 0.85,
S_SEGUM: 0.55,
S_PEGI: 0.35,
S_DOSEO: 0.25,
S_JIGUP: 0.45,
},
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",
},
},
};
}
let state = loadState();
let activeTab = "resources";
let selectedIlwiId = state.ilwiItems[0]?.id || "";
function loadState() {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return createDefaultSample();
const parsed = JSON.parse(raw);
if (parsed && Array.isArray(parsed.resources) && parsed.kanjubConfig) return parsed;
} catch (_) {}
return createDefaultSample();
}
function saveState() {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (_) {}
}
const $ = (id) => document.getElementById(id);
const esc = (s) =>
String(s ?? "").replace(
/[&<>"']/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c],
);
const fmt = (n, maxDec = 2) =>
new Intl.NumberFormat("ko-KR", { maximumFractionDigits: maxDec }).format(Number(n) || 0);
const uid = (prefix) =>
`${prefix}${Date.now().toString(36).slice(-4)}${Math.random().toString(36).slice(2, 5)}`;
function renderAll() {
const res = StmateCalc.calculateAll(state);
renderKpi(res);
document.querySelectorAll("#mainTabs button[data-tab]").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.tab === activeTab);
});
const panel = $("tabPanel");
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);
else if (activeTab === "kanjub") panel.innerHTML = StmateCostViews.renderKanjubTab(state, res);
else if (activeTab === "revision")
panel.innerHTML = StmateCostViews.renderRevisionTab(state, res);
}
function renderKpi(res) {
const k = res.kanjubOrig,
kr = res.kanjubRev;
const cards = [
[
"직접공사비 (재+직노+산출경비)",
`${fmt(k.dT, 0)} 원`,
`재료 ${fmt(k.dM, 0)} · 직노 ${fmt(k.dL, 0)} · 산출경비 ${fmt(k.dS, 0)}`,
],
[
"순공사원가 (직접비+간접비)",
`${fmt(k.pureConstructionCost, 0)} 원`,
`간접노무비 ${fmt(k.kanno, 0)} · 제경비소계 ${fmt(k.indirectExpenseSum, 0)}`,
],
[
"도급공사비 (끝전보정 후)",
`${fmt(k.contractFinal, 0)} 원`,
`이윤 세전조정: ${k.pretaxProfitAdj >= 0 ? "-" : "+"}${fmt(Math.abs(k.pretaxProfitAdj), 0)} 원`,
],
[
"총공사비 (도급 + 관급자재)",
`${fmt(k.grandTotal, 0)} 원`,
`관급자재대 ${fmt(k.govFinal, 0)} · 변경차액 ${kr.grandTotal - k.grandTotal >= 0 ? "+" : ""}${fmt(kr.grandTotal - k.grandTotal, 0)}`,
],
];
$("kpiSummary").innerHTML = cards
.map(
([title, val, sub]) =>
`<div class="kpi-card"><span class="kpi-label">${esc(title)}</span><strong class="kpi-value">${esc(val)}</strong><span class="kpi-sub">${esc(sub)}</span></div>`,
)
.join("");
}
function renderResourcesTab(res) {
const sys = state.sysConfig;
const rowsHtml = res.resources.list
.map((r, i) => {
const candInputs = [0, 1, 2, 3, 4]
.map(
(ci) =>
`<td class="num-col"><input type="number" data-sec="cand" data-idx="${i}" data-cidx="${ci}" value="${r.cands[ci] || 0}" min="0" step="any"></td>`,
)
.join("");
const modeOpts = [
["min", "최저가 자동(0제외)"],
["slot1", "후보1(조달) 고정"],
["slot2", "후보2(물자) 고정"],
["slot3", "후보3(물정) 고정"],
["slot4", "후보4(거래) 고정"],
["slot5", "후보5(견적) 고정"],
["manual", "수동단가 직접적용"],
]
.map(
([v, l]) => `<option value="${v}" ${r.adoptMode === v ? "selected" : ""}>${l}</option>`,
)
.join("");
const wageBadge = r.wageInfo
? `<div class="sub-hint">순차평가: <strong>${fmt(r.wageInfo.seqResult, 0)}원</strong> vs 선계수곱: ${fmt(r.wageInfo.preCalcResult, 0)}원 ${r.wageInfo.seqResult !== r.wageInfo.preCalcResult ? '<span class="diff-warn">(1원차이 발생!)</span>' : ""}</div>`
: "";
return `
<tr>
<td><input type="text" data-sec="resources" data-idx="${i}" data-field="code" value="${esc(r.code)}" class="code-input"></td>
<td><input type="text" data-sec="resources" data-idx="${i}" data-field="name" value="${esc(r.name)}">${wageBadge}</td>
<td><input type="text" data-sec="resources" data-idx="${i}" data-field="unit" value="${esc(r.unit)}" class="short-input"></td>
<td><select data-sec="resources" data-idx="${i}" data-field="kind">${StmateCalc.KINDS.map((k) => `<option value="${k}" ${r.kind === k ? "selected" : ""}>${k}</option>`).join("")}</select></td>
${candInputs}
<td><select data-sec="resources" data-idx="${i}" data-field="adoptMode">${modeOpts}</select></td>
<td class="center-col"><input type="checkbox" data-sec="resources" data-idx="${i}" data-field="convertHourly" ${r.convertHourly ? "checked" : ""} ${r.kind !== "노무비" ? "disabled" : ""}></td>
<td class="money-col highlight-col"><strong>${fmt(r.adoptedPrice, 0)}</strong><div class="tiny-tag">${esc(r.chosenLabel)}</div></td>
<td class="center-col"><button type="button" class="btn-del" data-action="del-res" data-idx="${i}">삭제</button></td>
</tr>`;
})
.join("");
return `
<div class="panel-card">
<div class="card-header">
<div>
<h2>초기값 설정 및 노임 시간당 환산 (<code>ST_Sys.CPM</code> · <code>INITIAL</code>)</h2>
<p>STMATE는 <code>RXNM_</code>에 저장된 식(<code>1/8*16/12*25/20</code>)을 <strong>왼쪽부터 차례로 평가</strong>합니다. 계수를 미리 곱하면 부동소수점 오차로 55,699원이 되어 1원 오차가 발생합니다(18번 문서 §2.1).</p>
</div>
</div>
<div class="control-grid">
<label class="ctrl-item"><span>노임 시간당 환산식 (<code>RXNM_</code>)</span><input type="text" data-sec="sysConfig" data-field="rxFormula" value="${esc(sys.rxFormula)}"></label>
<label class="ctrl-item"><span>시간당 환산 직종 코드 (<code>COND_</code>)</span><input type="text" data-sec="sysConfig" data-field="condCodes" value="${esc(sys.condCodes)}"></label>
<label class="ctrl-item"><span>당초 USD 환율 (<code>EXLNM1</code>)</span><input type="number" data-sec="sysConfig" data-field="usdRate1" value="${sys.usdRate1}" step="any"></label>
<label class="ctrl-item">
<span>달러 장비 단가 소수점 (<code>ECDNM</code>)</span>
<select data-sec="sysConfig" data-field="ecdDigits">
<option value="0" ${Number(sys.ecdDigits) === 0 ? "selected" : ""}>0 자리 (원 단위)</option>
<option value="1" ${Number(sys.ecdDigits) === 1 ? "selected" : ""}>1 자리</option>
<option value="2" ${Number(sys.ecdDigits) === 2 ? "selected" : ""}>2 자리</option>
</select>
</label>
</div>
</div>
<div class="panel-card">
<div class="card-header">
<div>
<h2>기초자원 단가 원장 및 5개 가격지 후보 자동채택 (<code>COSTN.DBF</code> · <code>JMKN1..6</code>)</h2>
<p>0이 아닌 후보 단가 중 <strong>최저가 자동채택</strong>(실무 76/76건 일치, 17번 문서 §5.5) 또는 특정 가격지 슬롯 수동고정을 실험합니다.</p>
</div>
<button type="button" class="btn primary" data-action="add-res">+ 기초자원 추가</button>
</div>
<div class="table-scroll">
<table class="data-table">
<thead><tr><th>코드</th><th>품명 / 규격</th><th>단위</th><th>비목</th><th>후보1(조달)</th><th>후보2(물자)</th><th>후보3(물정)</th><th>후보4(거래)</th><th>후보5(견적)</th><th>채택 규칙</th><th>시간환산</th><th>최종 채택단가(6칸)</th><th>관리</th></tr></thead>
<tbody>${rowsHtml}</tbody>
</table>
</div>
</div>`;
}
function renderMachinesTab(res) {
const laborOpts = state.resources
.filter((r) => r.kind === "노무비")
.map((r) => [r.id, `${r.code} ${r.name}`]);
const matOpts = state.resources
.filter((r) => r.kind === "재료비")
.map((r) => [r.id, `${r.code} ${r.name}`]);
const machOpts = state.machines.map((m) => [m.id, `${m.code} ${m.name} (${m.spec})`]);
const machRows = res.machines.list
.map(
(m, i) => `
<tr>
<td><input type="text" data-sec="machines" data-idx="${i}" data-field="code" value="${esc(m.code)}" class="code-input"></td>
<td><input type="text" data-sec="machines" data-idx="${i}" data-field="name" value="${esc(m.name)}"></td>
<td><input type="text" data-sec="machines" data-idx="${i}" data-field="spec" value="${esc(m.spec)}" class="short-input"></td>
<td><select data-sec="machines" data-idx="${i}" data-field="operatorId">${laborOpts.map(([v, l]) => `<option value="${esc(v)}" ${m.operatorId === v ? "selected" : ""}>${esc(l)}</option>`).join("")}</select></td>
<td><select data-sec="machines" data-idx="${i}" data-field="fuelId">${matOpts.map(([v, l]) => `<option value="${esc(v)}" ${m.fuelId === v ? "selected" : ""}>${esc(l)}</option>`).join("")}</select></td>
<td class="num-col"><input type="number" data-sec="machines" data-idx="${i}" data-field="fuelConsumption" value="${m.fuelConsumption}" step="any"></td>
<td class="num-col"><input type="number" data-sec="machines" data-idx="${i}" data-field="miscRatePercent" value="${m.miscRatePercent}" step="any"></td>
<td class="num-col"><input type="number" data-sec="machines" data-idx="${i}" data-field="acquisitionPrice" value="${m.acquisitionPrice}" step="any"></td>
<td class="num-col"><input type="number" data-sec="machines" data-idx="${i}" data-field="hourlyRateFactor" value="${m.hourlyRateFactor}" step="any"></td>
<td class="money-col">${fmt(m.M, 0)}</td>
<td class="money-col">${fmt(m.L, 0)}</td>
<td class="money-col">${fmt(m.S, 0)}</td>
<td class="money-col highlight-col"><strong>${fmt(m.T, 0)}</strong></td>
<td class="center-col"><button type="button" class="btn-del" data-action="del-mach" data-idx="${i}">삭제</button></td>
</tr>`,
)
.join("");
const sanRows = res.sanCal.list
.map(
(d, i) => `
<tr>
<td><input type="text" data-sec="sanItems" data-idx="${i}" data-field="code" value="${esc(d.code)}" class="code-input"></td>
<td><input type="text" data-sec="sanItems" data-idx="${i}" data-field="name" value="${esc(d.name)}"></td>
<td><select data-sec="sanItems" data-idx="${i}" data-field="machineId">${machOpts.map(([v, l]) => `<option value="${esc(v)}" ${d.machineId === v ? "selected" : ""}>${esc(l)}</option>`).join("")}</select></td>
<td>
<select data-sec="sanItems" data-idx="${i}" data-field="formulaType">
<option value="3600_qKfE_Cm" ${d.formulaType === "3600_qKfE_Cm" ? "selected" : ""}>3600*q*K*f*E/Cm (초단위)</option>
<option value="60_qKfE_Cm" ${d.formulaType === "60_qKfE_Cm" ? "selected" : ""}>60*q*K*f*E/Cm (분단위)</option>
</select>
</td>
<td class="num-col"><input type="number" data-sec="sanItems" data-idx="${i}" data-field="q" value="${d.q}" step="any"></td>
<td class="num-col"><input type="number" data-sec="sanItems" data-idx="${i}" data-field="K" value="${d.K}" step="any"></td>
<td class="num-col"><input type="number" data-sec="sanItems" data-idx="${i}" data-field="f" value="${d.f}" step="any"></td>
<td class="num-col"><input type="number" data-sec="sanItems" data-idx="${i}" data-field="E" value="${d.E}" step="any"></td>
<td class="num-col"><input type="number" data-sec="sanItems" data-idx="${i}" data-field="Cm" value="${d.Cm}" step="any"></td>
<td class="money-col"><strong>${fmt(d.Q, 2)}</strong><div class="tiny-tag">원값 ${fmt(d.rawQ, 3)}</div></td>
<td class="money-col">${fmt(d.M, 1)}<div class="tiny-tag">내역 ${fmt(d.intM, 0)}</div></td>
<td class="money-col">${fmt(d.L, 1)}<div class="tiny-tag">내역 ${fmt(d.intL, 0)}</div></td>
<td class="money-col">${fmt(d.S, 1)}<div class="tiny-tag">내역 ${fmt(d.intS, 0)}</div></td>
<td class="money-col highlight-col"><strong>${fmt(d.T, 1)}</strong><div class="tiny-tag">내역합 ${fmt(d.intT, 0)}</div></td>
<td class="center-col"><button type="button" class="btn-del" data-action="del-san" data-idx="${i}">삭제</button></td>
</tr>`,
)
.join("");
return `
<div class="panel-card">
<div class="card-header">
<div>
<h2>시간당 중기사용료 (<code>X</code> 코드 · 중기사용료 시트)</h2>
<p>중기 한 대를 참조할 때 노무비(<code>&amp;X..L&amp;</code>), 재료비(<code>&amp;X..M&amp;</code>), 경비(<code>&amp;X..S&amp;</code>) 세 성분을 각각 원 단위 절사하여 산출합니다(17번 문서 §3).</p>
</div>
<button type="button" class="btn primary" data-action="add-mach">+ 중기 추가</button>
</div>
<div class="table-scroll">
<table class="data-table">
<thead><tr><th>코드</th><th>기계명</th><th>규격</th><th>운전원(L)</th><th>주연료(M)</th><th>연료량(L/hr)</th><th>잡품률(%)</th><th>취득가격(원)</th><th>시간손료계수</th><th>재료비(M)</th><th>노무비(L)</th><th>경비(S)</th><th>시간당합계(T)</th><th>관리</th></tr></thead>
<tbody>${machRows}</tbody>
</table>
</div>
</div>
<div class="panel-card">
<div class="card-header">
<div>
<h2>단가산출근거 (<code>D</code> 코드 · <code>ST_SanCal.CPM</code> · <code>COSTN.SMT</code>)</h2>
<p>작업량 <code>Q</code>를 <strong>소수 2자리로 먼저 확정</strong>한 뒤, 중기 시간당 성분단가를 <code>Q</code>로 나누어 <strong>소수 1자리 절사</strong>합니다. 내역서 수록 시에는 정수 절사값이 전달됩니다(18번 문서 §2.2).</p>
</div>
<button type="button" class="btn primary" data-action="add-san">+ 단가산출(D) 추가</button>
</div>
<div class="table-scroll">
<table class="data-table">
<thead><tr><th>코드</th><th>단가산출 공종명</th><th>참조 중기(<code>&amp;X..&amp;</code>)</th><th>Q 산출식 유형</th><th>q(용량)</th><th>K(계수)</th><th>f(토량)</th><th>E(효율)</th><th>Cm(싸이클)</th><th>작업량 Q(㎥/hr)</th><th>재료비/Q</th><th>노무비/Q</th><th>경비/Q</th><th>호표소계(소수1)</th><th>관리</th></tr></thead>
<tbody>${sanRows}</tbody>
</table>
</div>
</div>`;
}
function renderIlwiTab(res) {
if (!state.ilwiItems.some((x) => x.id === selectedIlwiId))
selectedIlwiId = state.ilwiItems[0]?.id || "";
const curIdx = state.ilwiItems.findIndex((x) => x.id === selectedIlwiId);
const curIlwi = res.ilwi.list[curIdx];
const allRefChoices = [
...state.resources.map((r) => [r.id, `[기초 ${r.kind}] ${r.code} ${r.name} (${r.unit})`]),
...state.machines.map((m) => [m.id, `[중기 X] ${m.code} ${m.name} (${m.unit})`]),
...state.sanItems.map((d) => [d.id, `[단산 D] ${d.code} ${d.name} (${d.unit})`]),
];
let detailHtml = '<div class="empty-box">일위대가 항목을 추가해 주세요.</div>';
if (curIlwi) {
const lineRows = curIlwi.lines
.map(
(ln, li) => `
<tr>
<td><select data-sec="ilwiLines" data-pidx="${curIdx}" data-idx="${li}" data-field="refId">${allRefChoices.map(([v, l]) => `<option value="${esc(v)}" ${ln.refId === v ? "selected" : ""}>${esc(l)}</option>`).join("")}</select></td>
<td class="num-col"><input type="number" data-sec="ilwiLines" data-pidx="${curIdx}" data-idx="${li}" data-field="qty" value="${ln.qty}" step="any"></td>
<td class="center-col"><input type="checkbox" data-sec="ilwiLines" data-pidx="${curIdx}" data-idx="${li}" data-field="devd" ${ln.devd ? "checked" : ""}></td>
<td class="num-col"><input type="number" data-sec="ilwiLines" data-pidx="${curIdx}" data-idx="${li}" data-field="halPercent" value="${ln.halPercent}" step="any"></td>
<td class="money-col">${fmt(ln.appliedQty, 4)}</td>
<td class="money-col">${fmt(ln.lineM, 1)}</td>
<td class="money-col">${fmt(ln.lineL, 1)}</td>
<td class="money-col">${fmt(ln.lineS, 1)}</td>
<td class="money-col"><strong>${fmt(ln.lineT, 1)}</strong></td>
<td class="center-col"><button type="button" class="btn-del" data-action="del-ilwi-line" data-pidx="${curIdx}" data-idx="${li}">삭제</button></td>
</tr>`,
)
.join("");
detailHtml = `
<div class="control-grid">
<label class="ctrl-item"><span>일위대가 선택</span><select id="selIlwiPicker">${state.ilwiItems.map((x) => `<option value="${esc(x.id)}" ${x.id === selectedIlwiId ? "selected" : ""}>${esc(x.code)} - ${esc(x.name)}</option>`).join("")}</select></label>
<label class="ctrl-item"><span>일위대가 코드 (<code>BDCODE</code>)</span><input type="text" data-sec="ilwiItems" data-idx="${curIdx}" data-field="code" value="${esc(curIlwi.code)}"></label>
<label class="ctrl-item"><span>공종명</span><input type="text" data-sec="ilwiItems" data-idx="${curIdx}" data-field="name" value="${esc(curIlwi.name)}"></label>
<label class="ctrl-item"><span>단위</span><input type="text" data-sec="ilwiItems" data-idx="${curIdx}" data-field="unit" value="${esc(curIlwi.unit)}"></label>
</div>
<div class="sub-toolbar">
<span>호표 성분 소계(원 단위 절사): 재료비 <strong>${fmt(curIlwi.M, 0)}원</strong> · 노무비 <strong>${fmt(curIlwi.L, 0)}원</strong> · 경비 <strong>${fmt(curIlwi.S, 0)}원</strong> → 합계 <strong>${fmt(curIlwi.T, 0)}원</strong></span>
<div>
<button type="button" class="btn secondary" data-action="add-ilwi-line" data-pidx="${curIdx}">+ 구성 행 추가</button>
<button type="button" class="btn-del" data-action="del-ilwi" data-idx="${curIdx}">현재 호표 삭제</button>
</div>
</div>
<div class="table-scroll">
<table class="data-table">
<thead><tr><th>참조 자원 / 단가산출</th><th>원수량(<code>QTY1</code>)</th><th>역수(<code>DEVD1</code>)</th><th>할증(<code>X_HAL %</code>)</th><th>적용수량</th><th>재료비</th><th>노무비</th><th>경비</th><th>행 합계</th><th>관리</th></tr></thead>
<tbody>${lineRows}</tbody>
</table>
</div>`;
}
return `
<div class="panel-card">
<div class="card-header">
<div>
<h2>일위대가표 구성 및 수량 변환 (<code>ST_EdiLWi.CPM</code> · <code>BDQTY.DBF</code>)</h2>
<p>구성 자원의 수량 할증(<code>X_HAL</code>) 및 나눗셈 수량 역수 변환(<code>DEVD1: 1/QTY1</code>, 16단계·30단계 REA 메모)을 적용하고, 호표 소계에서 각 성분을 원 단위로 절사하여 <code>TAMT = MAMT + LAMT + SAMT</code> 불변식을 유지합니다.</p>
</div>
<button type="button" class="btn primary" data-action="add-ilwi">+ 새 일위대가 추가</button>
</div>
${detailHtml}
</div>`;
}
document.addEventListener("click", (e) => {
const tabBtn = e.target.closest("#mainTabs button[data-tab]");
if (tabBtn) {
activeTab = tabBtn.dataset.tab;
renderAll();
return;
}
const actBtn = e.target.closest("[data-action]");
if (!actBtn) return;
const act = actBtn.dataset.action,
idx = Number(actBtn.dataset.idx),
pidx = Number(actBtn.dataset.pidx);
if (act === "add-res") {
state.resources.push(
Object.assign(createDefaultSample().resources[3], {
id: uid("R"),
code: `M00${state.resources.length + 10}`,
name: "신규 시험 자재",
}),
);
} else if (act === "del-res") state.resources.splice(idx, 1);
else if (act === "add-mach") {
state.machines.push(
Object.assign(createDefaultSample().machines[0], {
id: uid("X"),
code: `X0000${state.machines.length + 1}`,
name: "신규 중기장비",
}),
);
} else if (act === "del-mach") state.machines.splice(idx, 1);
else if (act === "add-san") {
state.sanItems.push(
Object.assign(createDefaultSample().sanItems[0], {
id: uid("D"),
code: `D0000${state.sanItems.length + 2}`,
name: "신규 기계 시공 단가산출",
}),
);
} else if (act === "del-san") state.sanItems.splice(idx, 1);
else if (act === "add-ilwi") {
const newIlwi = {
id: uid("B"),
code: `B0000${state.ilwiItems.length + 1}`,
name: "신규 일위대가 호표",
spec: "기본",
unit: "㎥",
lines: [],
};
state.ilwiItems.push(newIlwi);
selectedIlwiId = newIlwi.id;
} else if (act === "del-ilwi") {
state.ilwiItems.splice(idx, 1);
selectedIlwiId = state.ilwiItems[0]?.id || "";
} else if (act === "add-ilwi-line") {
state.ilwiItems[pidx].lines.push({
refId: state.resources[0]?.id || "",
qty: 1,
halPercent: 0,
devd: false,
});
} else if (act === "del-ilwi-line") state.ilwiItems[pidx].lines.splice(idx, 1);
else if (act === "add-boq") {
state.boqItems.push(
Object.assign(createDefaultSample().boqItems[0], {
id: uid("BOQ"),
code: `B000${state.boqItems.length + 1}`,
name: "신규 내역 공종",
}),
);
} else if (act === "del-boq") state.boqItems.splice(idx, 1);
saveState();
renderAll();
});
document.addEventListener("change", (e) => {
const el = e.target;
if (el.id === "selIlwiPicker") {
selectedIlwiId = el.value;
renderAll();
return;
}
const sec = el.dataset.sec;
if (!sec) return;
const idx = Number(el.dataset.idx),
field = el.dataset.field;
const val =
el.type === "checkbox" ? el.checked : el.type === "number" ? Number(el.value) || 0 : el.value;
if (sec === "sysConfig") state.sysConfig[field] = val;
else if (sec === "cand")
state.resources[idx].candidates[Number(el.dataset.cidx)] = Number(el.value) || 0;
else if (sec === "resources") state.resources[idx][field] = val;
else if (sec === "machines") state.machines[idx][field] = val;
else if (sec === "sanItems") state.sanItems[idx][field] = val;
else if (sec === "ilwiItems") state.ilwiItems[idx][field] = val;
else if (sec === "ilwiLines") state.ilwiItems[Number(el.dataset.pidx)].lines[idx][field] = val;
else if (sec === "boqItems") state.boqItems[idx][field] = val;
else if (sec === "kanjubConfig") state.kanjubConfig[field] = val;
else if (sec === "kanjubRates") state.kanjubConfig.rates[field] = Number(el.value) || 0;
else if (sec === "revModes") state.kanjubConfig.revModes[field] = val;
else if (sec === "kOrdSim") state.sysConfig.kOrdSim[field] = Number(el.value) || 0;
saveState();
renderAll();
});
$("btnPresetBonghwa").addEventListener("click", () => {
state = createDefaultSample();
selectedIlwiId = state.ilwiItems[0]?.id || "";
saveState();
renderAll();
});
$("btnSaveJson").addEventListener("click", () => {
const blob = new Blob([JSON.stringify(state, null, 2)], { type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "stmate-test-state.json";
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
});
$("fileOpen").addEventListener("change", async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const imported = JSON.parse(await file.text());
if (!imported || !Array.isArray(imported.resources) || !imported.kanjubConfig)
throw new Error("Invalid format");
state = imported;
selectedIlwiId = state.ilwiItems[0]?.id || "";
saveState();
renderAll();
} catch (_) {
alert("유효한 STMATE 테스트 JSON 파일이 아닙니다.");
} finally {
e.target.value = "";
}
});
renderAll();
+283
View File
@@ -0,0 +1,283 @@
/* -*- coding: utf-8 -*- */
/**
* STMATE 시뮬레이터: 내역서(BOQ), 제잡비(Calc_Kanjub), 변경설계(Revision) 렌더러 (app_views_cost.js)
*/
const StmateCostViews = (() => {
const esc = (s) =>
String(s ?? "").replace(
/[&<>"']/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c],
);
const fmt = (n, maxDec = 2) =>
new Intl.NumberFormat("ko-KR", { maximumFractionDigits: maxDec }).format(Number(n) || 0);
function renderBoqTab(state, res) {
const sys = state.sysConfig,
boq = res.boqOrig;
const unitChoices = [
...state.ilwiItems.map((b) => [b.id, `[일위 B] ${b.code} ${b.name}`]),
...state.sanItems.map((d) => [d.id, `[단산 D] ${d.code} ${d.name}`]),
...state.machines.map((m) => [m.id, `[중기 X] ${m.code} ${m.name}`]),
...state.resources.map((r) => [r.id, `[단독 ${r.code[0]}] ${r.code} ${r.name}`]),
];
const rowsHtml = boq.rows
.map(
(r, i) => `
<tr class="${r.rowType !== "normal" ? "excl-row" : ""}">
<td><input type="text" data-sec="boqItems" data-idx="${i}" data-field="code" value="${esc(r.code)}" class="code-input"></td>
<td><input type="text" data-sec="boqItems" data-idx="${i}" data-field="name" value="${esc(r.name)}"></td>
<td><select data-sec="boqItems" data-idx="${i}" data-field="refId">${unitChoices.map(([v, l]) => `<option value="${esc(v)}" ${r.refId === v ? "selected" : ""}>${esc(l)}</option>`).join("")}</select></td>
<td>
<select data-sec="boqItems" data-idx="${i}" data-field="rowType">
<option value="normal" ${r.rowType === "normal" ? "selected" : ""}>일반 말단행(직접비)</option>
<option value="gov_mat" ${r.rowType === "gov_mat" ? "selected" : ""}>C* 제외구역(관급원자재)</option>
<option value="gov_fee" ${r.rowType === "gov_fee" ? "selected" : ""}>C* 제외구역(조달수수료)</option>
</select>
</td>
<td class="num-col"><input type="number" data-sec="boqItems" data-idx="${i}" data-field="qty" value="${r.qty}" step="any"></td>
<td class="money-col">${fmt(r.unitInfo.T, 0)}</td>
<td class="money-col">${fmt(r.M, 0)}</td>
<td class="money-col">${fmt(r.L, 0)}</td>
<td class="money-col">${fmt(r.S, 0)}</td>
<td class="money-col highlight-col"><strong>${fmt(r.T, 0)}</strong>${r.optimizTarget ? `<div class="diff-warn">OPTIMIZ: ${esc(r.optimizTarget)}</div>` : ""}</td>
<td class="money-col">${fmt(r.aggT, 0)}</td>
<td class="center-col"><button type="button" class="btn-del" data-action="del-boq" data-idx="${i}">삭제</button></td>
</tr>`,
)
.join("");
return `
<div class="panel-card">
<div class="card-header">
<div>
<h2>내역서 계산 방식 및 <code>ST_Func_C.CPM :: OPTIMIZ</code> 차액 보정 제어</h2>
<p>14·15단계 REA 분석에서 확인된 <code>CHNNM</code>(비목별 금액 우선 vs 총액 우선) 및 <code>OPTNM</code>(<code>SML</code> 차액 보정 순서) 동작을 검증합니다. 또한 <code>C*</code> 제외금액 구역(관급자재대)은 직접공사비 합산에서 분리됩니다(16번 문서 §4.2).</p>
</div>
<button type="button" class="btn primary" data-action="add-boq">+ 내역 행 추가</button>
</div>
<div class="control-grid">
<label class="ctrl-item">
<span>내역서 계산 방식 (<code>CHNNM</code> · <code>+0x7c5</code>)</span>
<select data-sec="sysConfig" data-field="chnMode">
<option value="bimok" ${sys.chnMode === "bimok" ? "selected" : ""}>0: 비목별 금액 먼저 (각 비목 절사 후 합산)</option>
<option value="total" ${sys.chnMode === "total" ? "selected" : ""}>1: 총액 먼저 (OPTIMIZ로 단수 차액 성분 배분)</option>
</select>
</label>
<label class="ctrl-item">
<span>차액 보정 우선순위 (<code>OPTNM</code> · <code>+0x7dc</code>)</span>
<select data-sec="sysConfig" data-field="optOrder">
<option value="SML" ${sys.optOrder === "SML" ? "selected" : ""}>SML (경비 → 재료비 → 노무비)</option>
<option value="MLS" ${sys.optOrder === "MLS" ? "selected" : ""}>MLS (재료비 → 노무비 → 경비)</option>
<option value="LMS" ${sys.optOrder === "LMS" ? "selected" : ""}>LMS (노무비 → 재료비 → 경비)</option>
</select>
</label>
</div>
<div class="table-scroll">
<table class="data-table">
<thead>
<tr><th>코드(<code>BDCODE1</code>)</th><th>공종명</th><th>연결 단가</th><th>구역 구분</th><th>설계수량(<code>QTY1</code>)</th><th>합계단가</th><th>재료비(<code>M_AMT1</code>)</th><th>노무비(<code>L_AMT1</code>)</th><th>경비(<code>S_AMT1</code>)</th><th>내역 합계(<code>T_AMT1</code>)</th><th>집계표(사사오입)</th><th>관리</th></tr>
</thead>
<tbody>
${rowsHtml}
<tr class="total-row">
<td colspan="6">직접공사비 소계 (<code>C*</code> 제외금액 구역 불포함)</td>
<td class="money-col">${fmt(boq.directM, 0)}</td>
<td class="money-col">${fmt(boq.directL, 0)}</td>
<td class="money-col">${fmt(boq.directS, 0)}</td>
<td class="money-col">${fmt(boq.directTotal, 0)}</td>
<td colspan="2">관급원자재: ${fmt(boq.govRawTotal, 0)} / 수수료: ${fmt(boq.govFeeTotal, 0)}</td>
</tr>
</tbody>
</table>
</div>
</div>`;
}
function renderKanjubTab(state, res) {
const cfg = state.kanjubConfig,
k = res.kanjubOrig;
const itemRows = k.items
.map(
(it) => `
<tr>
<td><code>${esc(it.code)}</code></td>
<td><strong>${esc(it.label)}</strong></td>
<td><span class="formula-tag">${esc(it.baseDesc)}</span></td>
<td class="money-col">${fmt(it.baseAmt, 0)}</td>
<td class="num-col"><input type="number" data-sec="kanjubRates" data-field="${esc(it.code)}" value="${it.ratePct}" step="any"></td>
<td class="money-col highlight-col"><strong>${fmt(it.amount, 0)}</strong></td>
</tr>`,
)
.join("");
return `
<div class="panel-card">
<div class="card-header">
<div>
<h2>원가계산 4형식 및 제잡비·끝전조정 제어 (<code>ST_KanJub.CPM :: Calc_Kanjub</code>)</h2>
<p>원가계산 형식(<code>T/S/TA/SA</code>)을 바꾸면 기타경비·환경보전비·일반관리비·이윤의 <strong>밑수 공식 자체가 전환</strong>됩니다(12번 문서 §7). 또한 도급공사비 절사 차액을 세전 환산(<code>ROUND(차액/1.1)</code>)하여 이윤에서 빼는 끝전조정을 검증합니다(16번 문서 §4.4).</p>
</div>
</div>
<div class="control-grid">
<label class="ctrl-item">
<span>원가계산 형식 (<code>TWM_KANJUB</code> 4탭)</span>
<select data-sec="kanjubConfig" data-field="scheme">
<option value="T" ${cfg.scheme === "T" ? "selected" : ""}>T: 일반형식 (표준 제비율)</option>
<option value="S" ${cfg.scheme === "S" ? "selected" : ""}>S: 수공형식 (세부경비 8종 분리)</option>
<option value="TA" ${cfg.scheme === "TA" ? "selected" : ""}>TA: 실적일반 (환경·보증 밑수=직공비, 이윤에 재료비 포함)</option>
<option value="SA" ${cfg.scheme === "SA" ? "selected" : ""}>SA: 실적수공 (일반관리비 밑수=직공비+간노)</option>
</select>
</label>
<label class="ctrl-item">
<span>부가가치세 기준 (<code>I_BUGA</code> · 태그 59)</span>
<select data-sec="kanjubConfig" data-field="vatMode">
<option value="supply_10" ${cfg.vatMode === "supply_10" ? "selected" : ""}>(1) 공급가액의 10%</option>
<option value="mat_10" ${cfg.vatMode === "mat_10" ? "selected" : ""}>(2) 재료비의 10% (면세·임협식 변형)</option>
<option value="mat_exp_10" ${cfg.vatMode === "mat_exp_10" ? "selected" : ""}>(3) (재료비+산출경비)의 10%</option>
<option value="zero" ${cfg.vatMode === "zero" ? "selected" : ""}>(0) 영세율 / 부가세 0원</option>
</select>
</label>
<label class="ctrl-item">
<span>도급액 절사 자리 (<code>I_TOT_JEOL</code> · <code>+0x204</code>)</span>
<select data-sec="kanjubConfig" data-field="totJeolDigits">
<option value="-3" ${Number(cfg.totJeolDigits) === -3 ? "selected" : ""}>1,000원 미만 절사 (-3)</option>
<option value="-4" ${Number(cfg.totJeolDigits) === -4 ? "selected" : ""}>10,000원 미만 절사 (-4)</option>
<option value="-2" ${Number(cfg.totJeolDigits) === -2 ? "selected" : ""}>100원 미만 절사 (-2)</option>
<option value="0" ${Number(cfg.totJeolDigits) === 0 ? "selected" : ""}>절사 없음 (0)</option>
</select>
</label>
<label class="ctrl-item">
<span>산업안전보건관리비 기초액 (원)</span>
<input type="number" data-sec="kanjubConfig" data-field="anjunBaseAdd" value="${cfg.anjunBaseAdd}" step="1000">
</label>
<label class="ctrl-item">
<span>관급자재대 끝전 처리 (기본: 천원 올림)</span>
<select data-sec="kanjubConfig" data-field="govRoundMode">
<option value="up" ${cfg.govRoundMode === "up" ? "selected" : ""}>천원 단위 올림 (ROUNDUP -3)</option>
<option value="half" ${cfg.govRoundMode === "half" ? "selected" : ""}>천원 단위 반올림 (ROUND -3)</option>
<option value="down" ${cfg.govRoundMode === "down" ? "selected" : ""}>천원 단위 절사 (ROUNDDOWN -3)</option>
</select>
</label>
<label class="ctrl-item checkbox-row">
<input type="checkbox" data-sec="kanjubConfig" data-field="enableProfitEndAdj" ${cfg.enableProfitEndAdj !== false ? "checked" : ""}>
<span>끝전 차액을 이윤(<code>C_IYUN_M</code>)에서 세전 차감 보정 (<code>0x4724f0</code>)</span>
</label>
</div>
<div class="table-scroll">
<table class="data-table">
<thead><tr><th>저장키</th><th>원가계산 항목</th><th>산정기초 (밑수 공식)</th><th>밑수 금액 (D_)</th><th>적용 요율 (R_ %)</th><th>산출 확정액 (S_)</th></tr></thead>
<tbody>
<tr class="sub-row"><td><code>DAMT_M/L/S</code></td><td><strong>직접공사비 합계</strong></td><td>직접재료비 ${fmt(k.dM, 0)} + 직접노무비 ${fmt(k.dL, 0)} + 산출경비 ${fmt(k.dS, 0)}</td><td class="money-col">${fmt(k.dT, 0)}</td><td class="center-col">—</td><td class="money-col"><strong>${fmt(k.dT, 0)}</strong></td></tr>
${itemRows}
<tr class="sub-row"><td><code>PURE_COST</code></td><td><strong>순공사원가</strong></td><td>재료비 + 노무비(직노+간노) + 경비(산출+제경비)</td><td class="money-col">${fmt(k.pureConstructionCost, 0)}</td><td class="center-col">—</td><td class="money-col"><strong>${fmt(k.pureConstructionCost, 0)}</strong></td></tr>
<tr><td><code>ILBAN</code></td><td><strong>일반관리비</strong></td><td><span class="formula-tag">${esc(k.ilbanDesc)}</span></td><td class="money-col">${fmt(k.ilbanBase, 0)}</td><td class="num-col"><input type="number" data-sec="kanjubRates" data-field="ILBAN" value="${cfg.rates.ILBAN}" step="any"></td><td class="money-col highlight-col"><strong>${fmt(k.ilbanAmt, 0)}</strong></td></tr>
<tr><td><code>IYUN</code></td><td><strong>이윤 (끝전보정 후)</strong></td><td><span class="formula-tag">${esc(k.iyunDesc)}</span><div class="tiny-tag">보정전 산식이윤 ${fmt(k.iyunRaw, 0)} − 세전끝전조정 ${fmt(k.pretaxProfitAdj, 0)} (차액 ${fmt(k.diffTotal, 0)}/1.1 반올림)</div></td><td class="money-col">${fmt(k.iyunBase, 0)}</td><td class="num-col"><input type="number" data-sec="kanjubRates" data-field="IYUN" value="${cfg.rates.IYUN}" step="any"></td><td class="money-col highlight-col"><strong>${fmt(k.iyunFinal, 0)}</strong></td></tr>
<tr class="sub-row"><td><code>SUPPLY</code></td><td><strong>총원가 (공급가액)</strong></td><td>순공사원가 + 일반관리비 + 최종이윤 (보정전: ${fmt(k.supplyBeforeAdj, 0)})</td><td class="money-col">${fmt(k.supplyFinal, 0)}</td><td class="center-col">—</td><td class="money-col"><strong>${fmt(k.supplyFinal, 0)}</strong></td></tr>
<tr><td><code>BUGA</code></td><td><strong>부가가치세</strong></td><td>과세표준 × 세율 (보정전 도급액: ${fmt(k.contractBeforeAdj, 0)})</td><td class="money-col">${fmt(k.supplyFinal, 0)}</td><td class="center-col">10%</td><td class="money-col highlight-col"><strong>${fmt(k.vatFinal, 0)}</strong></td></tr>
<tr class="total-row"><td><code>CONTRACT</code></td><td><strong>도급공사비</strong></td><td>공급가액 + 부가가치세 (끝전 절사 완료)</td><td class="money-col">${fmt(k.contractBeforeAdj, 0)}</td><td class="center-col">—</td><td class="money-col"><strong>${fmt(k.contractFinal, 0)}</strong></td></tr>
<tr><td><code>KWANGUP</code></td><td><strong>관급자재대</strong></td><td>관급원자재(${fmt(k.govRawTotal, 0)}) + 조달수수료(${fmt(k.govFeeTotal, 0)}) 끝전 처리</td><td class="money-col">${fmt(k.govRawTotal + k.govFeeTotal, 0)}</td><td class="center-col">—</td><td class="money-col"><strong>${fmt(k.govFinal, 0)}</strong></td></tr>
<tr class="total-row"><td><code>GRAND_TOT</code></td><td><strong>총공사비</strong></td><td>도급공사비 + 관급자재대</td><td class="money-col">—</td><td class="center-col">—</td><td class="money-col"><strong>${fmt(k.grandTotal, 0)} 원</strong></td></tr>
</tbody>
</table>
</div>
</div>`;
}
function renderRevisionTab(state, res) {
const boqOrig = res.boqOrig,
boqRev = res.boqRev,
kOrig = res.kanjubOrig,
kRev = res.kanjubRev;
const kSim = state.sysConfig.kOrdSim || { pclass: 7, regMode: 7, flag: 0, currentK: 2 };
const kSimOut = StmateCalc.evalKOrdTransition(
kSim.pclass,
kSim.regMode,
kSim.flag,
kSim.currentK,
);
const boqCompareRows = state.boqItems
.map(
(b, i) => `
<tr>
<td><code>${esc(b.code)}</code></td><td>${esc(b.name)}</td>
<td class="money-col">${fmt(b.qty, 2)}</td>
<td class="num-col"><input type="number" data-sec="boqItems" data-idx="${i}" data-field="changedQty" value="${b.changedQty ?? b.qty}" step="any"></td>
<td class="money-col">${fmt(boqOrig.rows[i].T, 0)}</td>
<td class="money-col">${fmt(boqRev.rows[i].T, 0)}</td>
<td class="money-col highlight-col"><strong>${boqRev.rows[i].T - boqOrig.rows[i].T >= 0 ? "+" : ""}${fmt(boqRev.rows[i].T - boqOrig.rows[i].T, 0)}</strong></td>
</tr>`,
)
.join("");
const revModeChoices = [
["recalculated", "recalculated (변경 밑수로 재계산)"],
["carry_original", "carry_original (당초액 유지 · 사후정산)"],
["proportional", "방법 A (당초액 × 직접비 증감비율)"],
["delta_rate", "방법 B (당초액 + 밑수증감 × 요율)"],
];
const kanjubCompareRows = kRev.items
.map((it) => {
const curMode = state.kanjubConfig.revModes[it.code] || "recalculated";
return `
<tr>
<td><code>${esc(it.code)}</code></td><td><strong>${esc(it.label)}</strong></td>
<td><select data-sec="revModes" data-field="${esc(it.code)}">${revModeChoices.map(([v, l]) => `<option value="${v}" ${curMode === v ? "selected" : ""}>${l}</option>`).join("")}</select></td>
<td class="money-col">${fmt(it.origAmt, 0)}</td>
<td class="money-col">${fmt(it.amount, 0)}</td>
<td class="money-col highlight-col"><strong>${it.deltaAmt >= 0 ? "+" : ""}${fmt(it.deltaAmt, 0)}</strong></td>
</tr>`;
})
.join("");
return `
<div class="panel-card">
<div class="card-header">
<div>
<h2>설계변경 수량 및 간접비 변경 계산모드 (<code>cBx_CH_BO</code> · 08번 문서 §2 · 18단계 REA)</h2>
<p>실무 변경설계에서는 직접노무비가 늘어나도 건강·장기요양·연금보험료 등 사후정산 항목은 <strong>당초액을 그대로 유지(<code>carry_original</code>)</strong>하고, 나머지는 재계산하거나 방법 A/B 공식을 적용합니다.</p>
</div>
</div>
<div class="table-scroll">
<table class="data-table">
<thead><tr><th>코드</th><th>내역 공종명</th><th>당초 수량</th><th>변경 수량 (조작)</th><th>당초 금액</th><th>변경 금액</th><th>증감 차액</th></tr></thead>
<tbody>
${boqCompareRows}
<tr class="total-row"><td colspan="4">직접공사비 당초 / 변경 / 증감 합계</td><td class="money-col">${fmt(boqOrig.directTotal, 0)}</td><td class="money-col">${fmt(boqRev.directTotal, 0)}</td><td class="money-col">${boqRev.directTotal - boqOrig.directTotal >= 0 ? "+" : ""}${fmt(boqRev.directTotal - boqOrig.directTotal, 0)}</td></tr>
</tbody>
</table>
</div>
<div class="table-scroll" style="margin-top:16px;">
<table class="data-table">
<thead><tr><th>키</th><th>제잡비 항목</th><th>변경설계 계산 모드 선택</th><th>당초 금액 (<code>C_AMT_B</code>)</th><th>변경 금액</th><th>증감 차액</th></tr></thead>
<tbody>
${kanjubCompareRows}
<tr class="total-row"><td colspan="3">총공사비 (도급 + 관급) 당초 / 변경 / 증감</td><td class="money-col">${fmt(kOrig.grandTotal, 0)}</td><td class="money-col">${fmt(kRev.grandTotal, 0)}</td><td class="money-col">${kRev.grandTotal - kOrig.grandTotal >= 0 ? "+" : ""}${fmt(kRev.grandTotal - kOrig.grandTotal, 0)}</td></tr>
</tbody>
</table>
</div>
</div>
<div class="panel-card">
<div class="card-header">
<div>
<h2><code>K_ORD</code> 등록·차수내리기 전이 규칙 시뮬레이터 (<code>STMATE_REA_최종보고서</code> §4.1)</h2>
<p>Main <code>0x559228</code>(NewProject) 및 <code>0x55a640</code>(MkBoq)에서 관측된 <code>PCLASS</code> · 등록모드 · 플래그 조합에 따른 <code>K_ORD</code> 저장식과 차수내리기(<code>K - (K&gt;5 ? K-4 : 1)</code>) 동작을 확인합니다.</p>
</div>
</div>
<div class="control-grid">
<label class="ctrl-item"><span>원본 <code>PCLASS</code></span><select data-sec="kOrdSim" data-field="pclass"><option value="4" ${Number(kSim.pclass) === 4 ? "selected" : ""}>4</option><option value="6" ${Number(kSim.pclass) === 6 ? "selected" : ""}>6</option><option value="7" ${Number(kSim.pclass) === 7 ? "selected" : ""}>7</option></select></label>
<label class="ctrl-item"><span>등록 모드 (6 또는 7)</span><select data-sec="kOrdSim" data-field="regMode"><option value="6" ${Number(kSim.regMode) === 6 ? "selected" : ""}>모드 6</option><option value="7" ${Number(kSim.regMode) === 7 ? "selected" : ""}>모드 7</option></select></label>
<label class="ctrl-item"><span>플래그 (0 또는 1)</span><select data-sec="kOrdSim" data-field="flag"><option value="0" ${Number(kSim.flag) === 0 ? "selected" : ""}>0</option><option value="1" ${Number(kSim.flag) === 1 ? "selected" : ""}>1</option></select></label>
<label class="ctrl-item"><span>현재 <code>K_ORD</code> (K)</span><input type="number" data-sec="kOrdSim" data-field="currentK" value="${kSim.currentK}" min="1" max="20"></label>
</div>
<div class="sub-toolbar">
<span>등록 시 새 <code>K_ORD</code>: <strong>${kSimOut.nextK}</strong> · 차수내리기 실행 시 <code>K - (K&gt;5 ? K-4 : 1)</code>: <strong>${kSimOut.downK}</strong></span>
<span>REA 확인: <code>Calq_San</code> 행 selector(<code>+0x430</code>) 0 초기화 지배 · 공유 <code>tb_Ref(+0x58) → MmComRec+8</code></span>
</div>
</div>`;
}
return { renderBoqTab, renderKanjubTab, renderRevisionTab };
})();
+674
View File
@@ -0,0 +1,674 @@
/* -*- coding: utf-8 -*- */
/**
* STMATE 계산 사슬 및 제어 옵션 시뮬레이터 엔진 (calc.js)
* 근거: resources/knowledge/original/경쟁사 프로그램/STmate 분석/ (REA 정적분석 및 골든셋 회귀)
*/
const StmateCalc = (() => {
const KINDS = ["재료비", "노무비", "경비"];
const KIND_KEYS = { 재료비: "M", 노무비: "L", 경비: "S" };
// MROUNDUD(selector=0): 0 방향 절사 (ROUNDDOWN)
function roundDown(val, digits = 0) {
const n = Number(val) || 0;
const f = Math.pow(10, digits);
const s = n * f;
return Math.trunc(s + (s >= 0 ? 1e-9 : -1e-9)) / f;
}
// MROUNDUP: 0에서 먼 방향 올림 (ROUNDUP)
function roundUp(val, digits = 0) {
const n = Number(val) || 0;
if (n === 0) return 0;
const f = Math.pow(10, digits);
const s = n * f;
return (n > 0 ? Math.ceil(s - 1e-9) : Math.floor(s + 1e-9)) / f;
}
// 사사오입 (Excel ROUND / 세전 끝전조정 / 집계표)
function roundHalfUp(val, digits = 0) {
const n = Number(val) || 0;
const f = Math.pow(10, digits);
const r = Math.floor(Math.abs(n) * f + 0.5 + 1e-9) / f;
return n < 0 ? -r : r;
}
// 은행가 반올림 (Delphi System.Round 최근접 짝수)
function roundBanker(val, digits = 0) {
const n = Number(val) || 0;
const f = Math.pow(10, digits);
const s = n * f;
const fl = Math.floor(s);
if (Math.abs(s - fl - 0.5) < 1e-9) return (fl % 2 === 0 ? fl : fl + 1) / f;
return Math.round(s) / f;
}
function applyRoundMode(val, mode, digits = 0) {
if (mode === "up") return roundUp(val, digits);
if (mode === "half") return roundHalfUp(val, digits);
if (mode === "banker") return roundBanker(val, digits);
return roundDown(val, digits);
}
// 1. 노임 시간당 환산식 좌→우 순차 평가 (RXNM_ = "1/8*16/12*25/20", 18번 문서 §2.1)
function evalWageFormulaLeftToRight(dailyWage, exprStr) {
const base = Number(dailyWage) || 0;
const clean = String(exprStr || "").replace(/\s+/g, "");
if (!clean) return { seqResult: base, preCalcResult: base, steps: [] };
const tokens = clean.match(/([*/])?(\d+(?:\.\d+)?)/g) || [];
let acc = base,
coeff = 1;
const steps = [];
for (const tok of tokens) {
const op = tok[0] === "*" || tok[0] === "/" ? tok[0] : "*";
const num = parseFloat(op === tok[0] ? tok.slice(1) : tok);
if (!Number.isFinite(num) || num === 0) continue;
acc = op === "*" ? acc * num : acc / num;
coeff = op === "*" ? coeff * num : coeff / num;
steps.push(`${op}${num} → ${roundHalfUp(acc, 4)}`);
}
return { seqResult: roundDown(acc, 0), preCalcResult: Math.trunc(base * coeff), coeff, steps };
}
// 2. 기초자원 후보단가 채택 및 노임 환산 처리
function calcResources(data) {
const sys = data.sysConfig || {};
const rxFormula = sys.rxFormula || "1/8*16/12*25/20";
const condCodes = new Set(
String(sys.condCodes || "")
.split(/[,\s]+/)
.map((s) => s.trim())
.filter(Boolean),
);
const usdRate = Number(sys.useChangedUsd ? sys.usdRate2 : sys.usdRate1) || 1000;
const ecdDigits = Number(sys.ecdDigits) || 0;
const map = {};
const list = (data.resources || []).map((res) => {
const cands = Array.isArray(res.candidates) ? res.candidates.map(Number) : [0, 0, 0, 0, 0];
const nonZero = cands.filter((v) => v > 0);
let basePrice = 0,
chosenLabel = "미지정";
if (res.adoptMode === "min") {
basePrice = nonZero.length ? Math.min(...nonZero) : 0;
chosenLabel = nonZero.length ? `최저가(후보${cands.indexOf(basePrice) + 1})` : "0원";
} else if (res.adoptMode && res.adoptMode.startsWith("slot")) {
const sIdx = parseInt(res.adoptMode.replace("slot", ""), 10) - 1;
basePrice = cands[sIdx] || 0;
chosenLabel = `후보${sIdx + 1} 고정`;
} else {
basePrice = Number(res.manualPrice) || 0;
chosenLabel = "수동입력";
}
const krwPrice = res.isUsd ? roundDown(basePrice * usdRate, ecdDigits) : basePrice;
let adoptedPrice = krwPrice,
wageInfo = null;
if (res.kind === "노무비" && (res.convertHourly || condCodes.has(res.code))) {
wageInfo = evalWageFormulaLeftToRight(krwPrice, rxFormula);
adoptedPrice = wageInfo.seqResult;
}
const item = { ...res, cands, basePrice, adoptedPrice, chosenLabel, wageInfo };
map[res.id] = item;
map[res.code] = item;
return item;
});
return { list, map };
}
// 3. 중기 시간당 사용료(X 코드) 계산 (17번 문서 §3)
function calcMachines(data, resMap) {
const map = {};
const list = (data.machines || []).map((m) => {
const opHourly = resMap[m.operatorId]?.adoptedPrice || 0;
const fuelPrice = resMap[m.fuelId]?.adoptedPrice || 0;
const rawFuel = (Number(m.fuelConsumption) || 0) * fuelPrice;
const M = roundDown(rawFuel * (1 + (Number(m.miscRatePercent) || 0) / 100), 0);
const L = roundDown(opHourly * (Number(m.operatorCount) || 1), 0);
const S = roundDown((Number(m.acquisitionPrice) || 0) * (Number(m.hourlyRateFactor) || 0), 0);
const item = { ...m, M, L, S, T: M + L + S, opHourly, fuelPrice };
map[m.id] = item;
map[m.code] = item;
return item;
});
return { list, map };
}
// 4. 단가산출근거(D 코드, Q식) 계산 (18번 문서 §2.2)
function evalQFormula(dItem) {
const q = Number(dItem.q) || 0,
K = Number(dItem.K) || 0,
f = Number(dItem.f) || 0;
const E = Number(dItem.E) || 0,
Cm = Number(dItem.Cm) || 1;
const mult = dItem.formulaType === "60_qKfE_Cm" ? 60 : 3600;
const rawQ = Cm !== 0 ? (mult * q * K * f * E) / Cm : 0;
return { rawQ, Q: roundHalfUp(rawQ, 2) };
}
function calcSanCal(data, machineMap) {
const map = {};
const list = (data.sanItems || []).map((d) => {
const mach = machineMap[d.machineId];
const { rawQ, Q } = evalQFormula(d);
const M = mach && Q > 0 ? roundDown(mach.M / Q, 1) : 0;
const L = mach && Q > 0 ? roundDown(mach.L / Q, 1) : 0;
const S = mach && Q > 0 ? roundDown(mach.S / Q, 1) : 0;
const intM = roundDown(M, 0),
intL = roundDown(L, 0),
intS = roundDown(S, 0);
const item = {
...d,
mach,
rawQ,
Q,
M,
L,
S,
T: roundHalfUp(M + L + S, 1),
intM,
intL,
intS,
intT: intM + intL + intS,
};
map[d.id] = item;
map[d.code] = item;
return item;
});
return { list, map };
}
// 5. 일위대가(B 코드) 조립 (17번 문서 §2, 30번 문서 DEVD1·X_HAL)
function calcIlwi(data, resMap, machMap, sanMap) {
const map = {};
const list = (data.ilwiItems || []).map((ilwi) => {
let sumM = 0,
sumL = 0,
sumS = 0;
const lines = (ilwi.lines || []).map((ln) => {
const ref = resMap[ln.refId] || machMap[ln.refId] || sanMap[ln.refId];
const rawQty = Number(ln.qty) || 0;
const baseQty = ln.devd && rawQty !== 0 ? roundHalfUp(1 / rawQty, 7) : rawQty;
const appliedQty = roundHalfUp(baseQty * (1 + (Number(ln.halPercent) || 0) / 100), 7);
let uM = 0,
uL = 0,
uS = 0;
if (ref) {
if ("adoptedPrice" in ref) {
if (ref.kind === "재료비") uM = ref.adoptedPrice;
else if (ref.kind === "노무비") uL = ref.adoptedPrice;
else uS = ref.adoptedPrice;
} else {
uM = ref.M || 0;
uL = ref.L || 0;
uS = ref.S || 0;
}
}
const lineM = roundDown(uM * appliedQty, 2),
lineL = roundDown(uL * appliedQty, 2),
lineS = roundDown(uS * appliedQty, 2);
sumM += lineM;
sumL += lineL;
sumS += lineS;
return {
...ln,
refName: ref?.name || "미연결",
refCode: ref?.code || "-",
appliedQty,
uM,
uL,
uS,
lineM,
lineL,
lineS,
lineT: roundHalfUp(lineM + lineL + lineS, 2),
};
});
const M = roundDown(sumM, 0),
L = roundDown(sumL, 0),
S = roundDown(sumS, 0);
const item = { ...ilwi, lines, M, L, S, T: M + L + S };
map[ilwi.id] = item;
map[ilwi.code] = item;
return item;
});
return { list, map };
}
// 6. 내역서(BOQ) 및 ST_Func_C::OPTIMIZ 차액보정 계산 (15단계 메모, 16번 문서)
function calcBoq(data, resMap, machMap, sanMap, ilwiMap, useChangedQty = false) {
const sys = data.sysConfig || {};
const chnMode = sys.chnMode || "bimok";
const optOrder = (sys.optOrder || "SML").toUpperCase();
const lookupUnit = (refId) => {
const r = ilwiMap[refId] || sanMap[refId] || machMap[refId] || resMap[refId];
if (!r) return { M: 0, L: 0, S: 0, T: 0, name: "미연결", code: "-" };
if ("adoptedPrice" in r) {
const M = r.kind === "재료비" ? r.adoptedPrice : 0,
L = r.kind === "노무비" ? r.adoptedPrice : 0,
S = r.kind === "경비" ? r.adoptedPrice : 0;
return { M, L, S, T: M + L + S, name: r.name, code: r.code, unit: r.unit };
}
if ("intT" in r)
return {
M: r.intM,
L: r.intL,
S: r.intS,
T: r.intT,
name: r.name,
code: r.code,
unit: r.unit,
};
return { M: r.M, L: r.L, S: r.S, T: r.T, name: r.name, code: r.code, unit: r.unit };
};
let directM = 0,
directL = 0,
directS = 0,
govRawTotal = 0,
govFeeTotal = 0;
const rows = (data.boqItems || []).map((row) => {
const u = lookupUnit(row.refId);
const qty =
Number(useChangedQty && row.changedQty !== undefined ? row.changedQty : row.qty) || 0;
const rawM = u.M * qty,
rawL = u.L * qty,
rawS = u.S * qty,
rawT = u.T * qty;
let M = roundDown(rawM, 0),
L = roundDown(rawL, 0),
S = roundDown(rawS, 0),
T = M + L + S;
let optimizDiff = 0,
optimizTarget = "";
if (chnMode === "total") {
const targetT = roundDown(rawT, 0);
optimizDiff = targetT - (M + L + S);
if (optimizDiff !== 0) {
const fracMap = { S: rawS - S, M: rawM - M, L: rawL - L };
let bestKey = "S",
bestFrac = -1;
for (const k of optOrder.split("").filter((c) => ["S", "M", "L"].includes(c))) {
if (fracMap[k] > bestFrac) {
bestFrac = fracMap[k];
bestKey = k;
}
}
if (bestKey === "S") S += optimizDiff;
else if (bestKey === "M") M += optimizDiff;
else L += optimizDiff;
T = targetT;
optimizTarget = `${bestKey}(+${optimizDiff})`;
}
}
const aggM = roundHalfUp(rawM, 0),
aggL = roundHalfUp(rawL, 0),
aggS = roundHalfUp(rawS, 0);
if (row.rowType === "gov_mat") govRawTotal += T;
else if (row.rowType === "gov_fee") govFeeTotal += T;
else {
directM += M;
directL += L;
directS += S;
}
return {
...row,
unitInfo: u,
appliedQty: qty,
M,
L,
S,
T,
optimizDiff,
optimizTarget,
aggM,
aggL,
aggS,
aggT: aggM + aggL + aggS,
};
});
return {
rows,
directM,
directL,
directS,
directTotal: directM + directL + directS,
govRawTotal,
govFeeTotal,
govCombinedRaw: govRawTotal + govFeeTotal,
};
}
// 7. 제잡비 및 원가계산서 (ST_KanJub.CPM :: Calc_Kanjub, 06·12·16번 문서)
function calcKanjub(data, boqRes, origKanjubRes = null) {
const cfg = data.kanjubConfig || {},
scheme = cfg.scheme || "T",
rates = cfg.rates || {},
revModes = cfg.revModes || {};
const indTruncDigits = Number(cfg.indirectTruncDigits) || 0;
const dM = boqRes.directM,
dL = boqRes.directL,
dS = boqRes.directS,
dT = boqRes.directTotal,
govPureMat = boqRes.govRawTotal;
const truncInd = (v) => roundDown(v, indTruncDigits);
function resolveRow(code, label, baseDesc, baseAmt, ratePct, rawCalcAmt) {
const truncAmt = truncInd(rawCalcAmt);
if (!origKanjubRes)
return { code, label, baseDesc, baseAmt, ratePct, amount: truncAmt, modeUsed: "원설계" };
const origRow = origKanjubRes.itemMap[code];
const origAmt = origRow ? origRow.amount : 0,
origBase = origRow ? origRow.baseAmt : 0;
const mode = revModes[code] || "recalculated";
let finalAmt = truncAmt;
if (mode === "carry_original") finalAmt = origAmt;
else if (mode === "proportional")
finalAmt =
origAmt + truncInd(origAmt * (origBase > 0 ? (baseAmt - origBase) / origBase : 0));
else if (mode === "delta_rate")
finalAmt = origAmt + truncInd(((baseAmt - origBase) * ratePct) / 100);
return {
code,
label,
baseDesc,
baseAmt,
ratePct,
amount: finalAmt,
origAmt,
deltaAmt: finalAmt - origAmt,
modeUsed: mode,
};
}
const items = [],
itemMap = {};
const pushItem = (row) => {
items.push(row);
itemMap[row.code] = row;
return row.amount;
};
const rKanno = Number(rates.KANNO) ?? 14.5;
const kanno = pushItem(
resolveRow("KANNO", "간접노무비", "직접노무비", dL, rKanno, (dL * rKanno) / 100),
);
const totalLabor = dL + kanno;
pushItem(
resolveRow(
"SANBO",
"산재보험료",
"노무비(직노+간노)",
totalLabor,
Number(rates.SANBO) ?? 3.56,
(totalLabor * (Number(rates.SANBO) ?? 3.56)) / 100,
),
);
pushItem(
resolveRow(
"KOBO",
"고용보험료",
"노무비(직노+간노)",
totalLabor,
Number(rates.KOBO) ?? 1.01,
(totalLabor * (Number(rates.KOBO) ?? 1.01)) / 100,
),
);
const kunbo = pushItem(
resolveRow(
"KUNBO",
"건강보험료",
"직접노무비",
dL,
Number(rates.KUNBO) ?? 3.545,
(dL * (Number(rates.KUNBO) ?? 3.545)) / 100,
),
);
pushItem(
resolveRow(
"SLVBO",
"노인장기요양보험료",
"건강보험료(확정액)",
kunbo,
Number(rates.SLVBO) ?? 12.95,
(kunbo * (Number(rates.SLVBO) ?? 12.95)) / 100,
),
);
pushItem(
resolveRow(
"YUNBO",
"연금보험료",
"직접노무비",
dL,
Number(rates.YUNBO) ?? 4.5,
(dL * (Number(rates.YUNBO) ?? 4.5)) / 100,
),
);
pushItem(
resolveRow(
"TBUGUM",
"퇴직공제부금비",
"직접노무비",
dL,
Number(rates.TBUGUM) ?? 0,
(dL * (Number(rates.TBUGUM) ?? 0)) / 100,
),
);
const rAnjun = Number(rates.ANJUN) ?? 1.86,
anjunBaseAdd = Number(cfg.anjunBaseAdd) || 5349000;
const anjunGovDiv = cfg.anjunGovVatExcluded !== false ? govPureMat / 1.1 : govPureMat;
const anjunBaseNoGov = scheme === "TA" || scheme === "SA" ? dT : dM + dL;
const anjunBaseWithGov = anjunBaseNoGov + anjunGovDiv;
const anjunNormal = truncInd((anjunBaseWithGov * rAnjun) / 100) + anjunBaseAdd;
const anjunCap12 = truncInd(((anjunBaseNoGov * rAnjun) / 100 + anjunBaseAdd) * 1.2);
const anjunRaw = cfg.anjunCap12 !== false ? Math.min(anjunNormal, anjunCap12) : anjunNormal;
pushItem(
resolveRow(
"ANJUN",
"산업안전보건관리비",
scheme === "TA" || scheme === "SA"
? "직접공사비+관급원자재/1.1 (+기초액)"
: "직재+직노+관급원자재/1.1 (+기초액)",
roundHalfUp(anjunBaseWithGov, 0),
rAnjun,
anjunRaw,
),
);
if (scheme === "S" || scheme === "SA") {
const suBase = dM + totalLabor;
for (const [sc, sl, sr] of [
["S_SUDO", "수도광열비", Number(rates.S_SUDO) ?? 0.45],
["S_BOKRI", "복리후생비", Number(rates.S_BOKRI) ?? 1.15],
["S_SOMO", "소모품·사무용품비", Number(rates.S_SOMO) ?? 0.65],
["S_TONGSIN", "여비·교통·통신비", Number(rates.S_TONGSIN) ?? 0.85],
["S_SEGUM", "세금·공과금", Number(rates.S_SEGUM) ?? 0.55],
["S_PEGI", "폐기물처리비", Number(rates.S_PEGI) ?? 0.35],
["S_DOSEO", "도서인쇄비", Number(rates.S_DOSEO) ?? 0.25],
["S_JIGUP", "지급수수료", Number(rates.S_JIGUP) ?? 0.45],
])
pushItem(
resolveRow(
sc,
`[수공] ${sl}`,
"재료비+노무비(직노+간노)",
suBase,
sr,
(suBase * sr) / 100,
),
);
} else {
const rKita = Number(rates.KITA) ?? 6.0,
kitaBase = scheme === "TA" ? dT : dM + totalLabor;
pushItem(
resolveRow(
"KITA",
"기타경비",
scheme === "TA" ? "직접공사비(실적일반)" : "재료비+노무비(직노+간노)",
kitaBase,
rKita,
(kitaBase * rKita) / 100,
),
);
}
const hwanBase = scheme === "TA" ? dT : dM + dL + dS,
hwanDesc = scheme === "TA" ? "직접공사비(실적일반)" : "재료비+직접노무비+산출경비";
pushItem(
resolveRow(
"HWAN",
"환경보전비",
hwanDesc,
hwanBase,
Number(rates.HWAN) ?? 0.8,
(hwanBase * (Number(rates.HWAN) ?? 0.8)) / 100,
),
);
pushItem(
resolveRow(
"EQIP",
"건설기계대여대금 지급보증",
hwanDesc,
hwanBase,
Number(rates.EQIP) ?? 0.4,
(hwanBase * (Number(rates.EQIP) ?? 0.4)) / 100,
),
);
const indirectExpenseSum = items
.filter((x) => x.code !== "KANNO")
.reduce((acc, x) => acc + x.amount, 0);
const totalExpense = dS + indirectExpenseSum,
pureConstructionCost = dM + totalLabor + totalExpense;
const rIlban = Number(rates.ILBAN) ?? 6.0,
ilbanBase = scheme === "SA" ? dT + kanno : pureConstructionCost;
const ilbanDesc = scheme === "SA" ? "직접공사비+간접노무비(실적수공)" : "순공사원가(재+노+경)";
const ilbanAmt = truncInd((ilbanBase * rIlban) / 100);
const rIyun = Number(rates.IYUN) ?? 15.0;
const iyunBase =
scheme === "TA" || scheme === "SA"
? pureConstructionCost + ilbanAmt
: totalLabor + totalExpense + ilbanAmt;
const iyunDesc =
scheme === "TA" || scheme === "SA"
? "직접비+간접비+일반관리비(실적형식)"
: "노무비+경비+일반관리비(재료비 제외)";
const iyunRaw = truncInd((iyunBase * rIyun) / 100);
const vatMode = cfg.vatMode || "supply_10",
totJeolDigits = Number(cfg.totJeolDigits) ?? -3;
const calcVat = (sVal) =>
vatMode === "zero"
? 0
: vatMode === "mat_10"
? roundDown(dM * 0.1, 0)
: vatMode === "mat_exp_10"
? roundDown((dM + dS) * 0.1, 0)
: roundDown(sVal * 0.1, 0);
const supplyBeforeAdj = pureConstructionCost + ilbanAmt + iyunRaw,
vatBeforeAdj = calcVat(supplyBeforeAdj),
contractBeforeAdj = supplyBeforeAdj + vatBeforeAdj;
let contractTarget = contractBeforeAdj,
diffTotal = 0,
pretaxProfitAdj = 0,
iyunFinal = iyunRaw,
supplyFinal = supplyBeforeAdj,
vatFinal = vatBeforeAdj;
if (totJeolDigits < 0 && cfg.enableProfitEndAdj !== false) {
contractTarget = roundDown(contractBeforeAdj, totJeolDigits);
diffTotal = contractBeforeAdj - contractTarget;
pretaxProfitAdj = vatMode === "supply_10" ? roundHalfUp(diffTotal / 1.1, 0) : diffTotal;
iyunFinal = iyunRaw - pretaxProfitAdj;
supplyFinal = pureConstructionCost + ilbanAmt + iyunFinal;
vatFinal = calcVat(supplyFinal);
if (supplyFinal + vatFinal !== contractTarget) vatFinal = contractTarget - supplyFinal;
}
const contractFinal = supplyFinal + vatFinal;
const govFinal =
boqRes.govCombinedRaw > 0
? applyRoundMode(
boqRes.govCombinedRaw,
cfg.govRoundMode || "up",
Number(cfg.govRoundDigits) ?? -3,
)
: 0;
return {
scheme,
dM,
dL,
dS,
dT,
kanno,
totalLabor,
indirectExpenseSum,
totalExpense,
pureConstructionCost,
ilbanBase,
ilbanDesc,
ilbanAmt,
iyunBase,
iyunDesc,
iyunRaw,
pretaxProfitAdj,
iyunFinal,
supplyBeforeAdj,
vatBeforeAdj,
contractBeforeAdj,
diffTotal,
supplyFinal,
vatFinal,
contractFinal,
govRawTotal: boqRes.govRawTotal,
govFeeTotal: boqRes.govFeeTotal,
govFinal,
grandTotal: contractFinal + govFinal,
items,
itemMap,
};
}
// 8. K_ORD 등록·차수내리기 전이 규칙 시뮬레이션 (STMATE_REA_최종보고서 §4.1)
function evalKOrdTransition(pclass, regMode, flag, currentK) {
const K = Number(currentK) || 1;
const nextK =
Number(regMode) === 6
? Number(pclass) === 6
? K + 1
: 1
: Number(regMode) === 7 && Number(pclass) === 7 && Number(flag) === 0
? K + 1
: 1;
return { nextK, downK: K - (K > 5 ? K - 4 : 1) };
}
function calculateAll(data) {
const resources = calcResources(data);
const machines = calcMachines(data, resources.map);
const sanCal = calcSanCal(data, machines.map);
const ilwi = calcIlwi(data, resources.map, machines.map, sanCal.map);
const boqOrig = calcBoq(data, resources.map, machines.map, sanCal.map, ilwi.map, false);
const kanjubOrig = calcKanjub(data, boqOrig, null);
const boqRev = calcBoq(data, resources.map, machines.map, sanCal.map, ilwi.map, true);
const kanjubRev = calcKanjub(data, boqRev, kanjubOrig);
return { resources, machines, sanCal, ilwi, boqOrig, kanjubOrig, boqRev, kanjubRev };
}
return {
KINDS,
KIND_KEYS,
roundDown,
roundUp,
roundHalfUp,
roundBanker,
evalWageFormulaLeftToRight,
evalQFormula,
evalKOrdTransition,
calculateAll,
};
})();
if (typeof module !== "undefined" && module.exports) module.exports = StmateCalc;
+68
View File
@@ -0,0 +1,68 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>STMATE 계산·제어 흐름 독립 테스트</title>
<link rel="stylesheet" href="style.css" />
<script defer src="calc.js"></script>
<script defer src="app_views_cost.js"></script>
<script defer src="app.js"></script>
</head>
<body>
<header class="topbar">
<div class="brand">
<div class="badges">
<span class="badge accent">독립 테스트 웹앱</span>
<span class="badge">REA 6.7.0 / Ghidra 12.1.4 분석 반영</span>
<span class="badge">무로그인 · 로컬 전용</span>
</div>
<h1>STMATE 계산 사슬 및 제어 옵션 시뮬레이터</h1>
<p>
기초자원 후보단가·노임환산(`ST_Sys`) → 중기·Q식 단가산출(`ST_SanCal`) →
일위대가(`ST_EdiLWi`) → 내역·OPTIMIZ(`ST_Func_C`) → 제잡비 4형식·끝전조정(`ST_KanJub`) →
변경설계 차액
</p>
</div>
<div class="top-actions">
<button id="btnPresetBonghwa" class="btn secondary" title="실무 검증형 예제 불러오기">
예제 초기화
</button>
<label class="btn secondary" for="fileOpen"
>JSON 열기<input id="fileOpen" type="file" accept=".json,application/json" hidden
/></label>
<button id="btnSaveJson" class="btn primary">JSON 저장</button>
</div>
</header>
<main class="container">
<div class="alert-banner">
<strong>시험·검증 전용 안내</strong>
<span
>이 웹앱은 Aislo 본체와 완전히 분리된 독립 테스트 도구입니다. 수록된 단가·요율은 계산
경로와 제어 옵션을 점검하기 위한 시험 데이터이며, 현행 법령·품셈 확정값이 아닙니다.</span
>
</div>
<section class="kpi-grid" id="kpiSummary" aria-label="핵심 계산 요약"></section>
<nav class="tab-nav" id="mainTabs" aria-label="STMATE 기능 단계">
<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>
<button type="button" data-tab="boq">04 내역서 · OPTIMIZ</button>
<button type="button" data-tab="kanjub">05 제잡비 · 원가계산</button>
<button type="button" data-tab="revision">06 변경설계 · REA 근거</button>
</nav>
<section id="tabPanel" class="tab-panel" aria-live="polite"></section>
<footer class="footer-note">
<span
>모든 조작 상태는 현재 브라우저 탭(`sessionStorage`)에 자동 보관됩니다. 영구 보관하거나
다른 환경에서 열려면 <strong>JSON 저장 / 열기</strong>를 사용하세요.</span
>
</footer>
</main>
</body>
</html>
+419
View File
@@ -0,0 +1,419 @@
/* -*- coding: utf-8 -*- */
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #f1f5f9;
color: #0f172a;
font:
14px/1.5 system-ui,
-apple-system,
"Malgun Gothic",
sans-serif;
}
.topbar {
background: #0f172a;
color: #f8fafc;
padding: 24px max(20px, calc((100% - 1440px) / 2));
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
border-bottom: 3px solid #0284c7;
}
.badges {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.badge {
display: inline-block;
padding: 3px 9px;
border-radius: 4px;
font-size: 11px;
font-weight: 700;
background: #1e293b;
color: #cbd5e1;
border: 1px solid #334155;
}
.badge.accent {
background: #0284c7;
color: #ffffff;
border-color: #0369a1;
}
.topbar h1 {
margin: 0 0 6px;
font-size: 22px;
letter-spacing: -0.02em;
}
.topbar p {
margin: 0;
color: #94a3b8;
font-size: 13px;
}
.top-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.btn {
border: 1px solid transparent;
border-radius: 6px;
padding: 9px 14px;
font: inherit;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn.primary {
background: #0284c7;
color: #ffffff;
}
.btn.primary:hover {
background: #0369a1;
}
.btn.secondary {
background: #1e293b;
color: #e2e8f0;
border-color: #475569;
}
.btn.secondary:hover {
background: #334155;
}
.panel-card .btn.secondary {
background: #e2e8f0;
color: #1e293b;
border-color: #cbd5e1;
}
.btn-del {
border: 1px solid #fecaca;
background: #fef2f2;
color: #b91c1c;
border-radius: 5px;
padding: 5px 9px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.btn-del:hover {
background: #fee2e2;
}
.container {
max-width: 1440px;
margin: 0 auto;
padding: 20px;
}
.alert-banner {
background: #fffbeb;
border: 1px solid #fde68a;
color: #92400e;
border-radius: 8px;
padding: 12px 16px;
display: flex;
gap: 10px;
align-items: baseline;
flex-wrap: wrap;
margin-bottom: 16px;
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 18px;
}
.kpi-card {
background: #ffffff;
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 14px 16px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.kpi-label {
display: block;
font-size: 12px;
color: #64748b;
font-weight: 600;
}
.kpi-value {
display: block;
font-size: 20px;
color: #0f172a;
margin: 4px 0;
font-variant-numeric: tabular-nums;
}
.kpi-sub {
display: block;
font-size: 11px;
color: #475569;
}
.tab-nav {
display: flex;
gap: 4px;
border-bottom: 2px solid #cbd5e1;
overflow-x: auto;
margin-bottom: 16px;
}
.tab-nav button {
border: 0;
background: transparent;
color: #475569;
font: inherit;
font-weight: 700;
padding: 12px 16px;
cursor: pointer;
border-bottom: 3px solid transparent;
margin-bottom: -2px;
white-space: nowrap;
}
.tab-nav button:hover {
color: #0f172a;
background: #e2e8f0;
}
.tab-nav button.active {
color: #0284c7;
border-bottom-color: #0284c7;
background: #ffffff;
}
.panel-card {
background: #ffffff;
border: 1px solid #cbd5e1;
border-radius: 8px;
margin-bottom: 18px;
overflow: hidden;
}
.card-header {
padding: 16px 20px;
border-bottom: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
background: #f8fafc;
}
.card-header h2 {
margin: 0;
font-size: 16px;
color: #0f172a;
}
.card-header p {
margin: 4px 0 0;
font-size: 12.5px;
color: #475569;
}
.control-grid {
padding: 16px 20px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 14px;
background: #ffffff;
border-bottom: 1px solid #e2e8f0;
}
.ctrl-item {
display: flex;
flex-direction: column;
gap: 5px;
font-size: 12.5px;
font-weight: 600;
color: #334155;
}
.ctrl-item.checkbox-row {
flex-direction: row;
align-items: center;
gap: 8px;
padding-top: 20px;
}
.ctrl-item.checkbox-row input {
width: auto;
}
.sub-toolbar {
padding: 12px 20px;
background: #f0f9ff;
border-bottom: 1px solid #bae6fd;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
font-size: 13px;
color: #0369a1;
}
.table-scroll {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
min-width: 920px;
}
.data-table th,
.data-table td {
padding: 8px 10px;
border-bottom: 1px solid #e2e8f0;
text-align: left;
vertical-align: middle;
font-size: 13px;
}
.data-table th {
background: #f1f5f9;
color: #334155;
font-weight: 700;
font-size: 12px;
white-space: nowrap;
}
.data-table td.money-col {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.data-table td.num-col {
width: 105px;
}
.data-table td.center-col {
text-align: center;
white-space: nowrap;
}
.data-table td.highlight-col {
background: #f8fafc;
}
.data-table tr.sub-row {
background: #f8fafc;
}
.data-table tr.total-row {
background: #e0f2fe;
font-weight: 700;
}
.data-table tr.excl-row {
background: #fffbeb;
}
input[type="text"],
input[type="number"],
select {
width: 100%;
padding: 6px 8px;
border: 1px solid #cbd5e1;
border-radius: 5px;
font: inherit;
font-size: 13px;
color: #0f172a;
background: #ffffff;
}
input[type="number"] {
text-align: right;
font-variant-numeric: tabular-nums;
}
input.code-input {
width: 86px;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
input.short-input {
width: 72px;
}
.sub-hint {
font-size: 11px;
color: #475569;
margin-top: 3px;
}
.tiny-tag {
font-size: 11px;
color: #64748b;
font-weight: 500;
}
.diff-warn {
color: #dc2626;
font-weight: 700;
font-size: 11px;
}
.formula-tag {
display: inline-block;
padding: 2px 6px;
background: #f1f5f9;
border: 1px solid #cbd5e1;
border-radius: 4px;
font-size: 11.5px;
color: #334155;
}
.empty-box {
padding: 24px;
text-align: center;
color: #64748b;
}
.footer-note {
margin-top: 16px;
padding: 12px 4px;
font-size: 12px;
color: #64748b;
text-align: center;
}
@media (max-width: 900px) {
.topbar {
flex-direction: column;
align-items: flex-start;
}
.kpi-grid {
grid-template-columns: repeat(2, 1fr);
}
}