Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/안티그래비티' into sub_laptop_2
This commit is contained in:
@@ -366,7 +366,7 @@ def read_book(name: str, data: bytes) -> dict[str, Any]:
|
||||
{**_json(hopyo), "줄": [_json(line) for line in lines]}
|
||||
for hopyo, lines in zip(hopyos, raw, strict=True)
|
||||
]
|
||||
keep = ("번", "이름", "단위", "행", "출처", "직종", "글", "금액", "항목")
|
||||
keep = ("번", "이름", "단위", "행", "출처", "직종", "글", "글행", "금액", "항목", "절")
|
||||
basis = [{k: b[k] for k in keep} for b in blocks.values()]
|
||||
book = {
|
||||
"꼴": "엑셀꼴",
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
"""M02 일위대가 실무자료 단가산출 블록 글 → 절 나무 · 선택값 · 상수 · 대입식 · 몫(PLAN 785-1 ·
|
||||
요구 642 · 설계 642-A 2-2 ⓑ · 2-5 ④-2 · ④-3 B2~B4).
|
||||
|
||||
글 먼저 · 찾기 없음 — 글에 적힌 것만 꼴로 옮김(값을 짓지 않음 · 못 읽은 것은 안 냄) ·
|
||||
M01 · 엔진 안 부름.
|
||||
|
||||
절 나무 `tree(글, 글행, 항목)` → [절] — 절 = {번, 층, 제목, 갈래, 행 [시작, 끝], 글 [첫, 끝],
|
||||
상수, 식, 선택값, 몫, 소계, 항목, 아래}:
|
||||
층 1 「1.」 · 2 「가.」 · 3 「1)」 · 빠진 번호 그대로(단산6 1 → 3) ·
|
||||
갈래 = 합계(합계 · 소계 · 총계) · 비목(재료비 · 인건비 · 노무비 · 경비 …) · 일(그 밖) ·
|
||||
상수 = 「q = 0.6㎥ K = 0.55」 · 「L = 6m {경사도 = 35°(α=8)}」 · 「운반회수 : n = 42회/일」 →
|
||||
{기호, 값, 단위, 덧?, 이름?, 행} ·
|
||||
식 = 「기호 = 식 = … = 값」 → {기호, 글, 값, 단위, 꼴 곱|합|분수, 분자, 분모, 대입, 행} —
|
||||
곱 = 「×」 「÷」 「/」 만 · 괄호 뒤 「÷」 는 괄호 안 모두 분모 ·
|
||||
대입 = 기호 식과 수 식의 인자 자리가 같을 때 {기호: 수} ·
|
||||
합(「+」) · 분수(「──」 그림 줄)는 값만 ·
|
||||
선택값 = 「※ …」 · 작업조건 줄 {글, 행} ·
|
||||
몫 = 그 절 비목 줄(노무비 · 재료비 · 경비) 「단가 ÷ Q」 · 「단가 × Q」 뒤 곱수(모두 같을 때) ·
|
||||
없으면 직종 줄 「단가 × 품」 뒤 % 곱수 → {글, 값, 행} ·
|
||||
소계 = 합계 갈래 아래 절(없으면 그 절 「소계」 줄) 비목 값 {노무비, 재료비, 경비, 계} ·
|
||||
항목 = 행이 그 절(아래 절 뺌) 안인 단위 줄 차례.
|
||||
머리 `head(글, 글행)` = 첫 절 앞 줄 {행, 상수, 식, 선택값}.
|
||||
일 절 `works(나무)` = 일 갈래 중 아래에 일 절이 없는 절 · 하나도 없으면 [](블록 통째 한 일).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
NUM = r"[\d,]+(?:\.\d+)?"
|
||||
_LEVELS = (
|
||||
(1, re.compile(r"^\s*(\d+)\s*\.\s*(.*)$")),
|
||||
(2, re.compile(r"^\s*([가-하])\s*\.\s*(.*)$")),
|
||||
(3, re.compile(r"^\s*(\d+)\)\s*(.*)$")),
|
||||
)
|
||||
_DECIMAL = re.compile(r"^\s*\d+\.\d")
|
||||
_TOTAL = re.compile(r"(합\s*계|소\s*계|총\s*계)")
|
||||
_COSTS = ("재료비", "인건비", "노무비", "경비", "기계경비", "자재비", "재료", "인건", "노무")
|
||||
_SEG = re.compile(
|
||||
r"(?:^\s*|(?<=\s\s)|(?<=[{(]))(?:(?P<label>[가-힣]{2,8})\s*[::]\s*)?"
|
||||
r"(?P<sym>[A-Za-z][A-Za-z0-9]{0,3}|[가-힣]{2,6}|[α-ωΑ-Ω])\s*=(?!=)\s*"
|
||||
)
|
||||
_UNIT = r"[^\s\d×xX*÷/()=+,{}\[\]─]+(?:/[^\s\d×xX*÷/()=+,{}\[\]─]+)?"
|
||||
_VALUE = re.compile(
|
||||
r"^\s*(?P<num>" + NUM + r")\s*(?P<unit>" + _UNIT + r")?\s*(?:\((?P<note>[^()]*)\))?"
|
||||
r"[\s)}\]]*$"
|
||||
)
|
||||
_TOKEN = re.compile(
|
||||
r"\s*(?P<op>[×*÷/])?\s*(?:(?P<open>\()|(?P<close>\))|(?P<num>"
|
||||
+ NUM
|
||||
+ r")\s*(?P<unit>"
|
||||
+ _UNIT
|
||||
+ r")?|(?P<sym>[A-Za-z][A-Za-z0-9]*))"
|
||||
)
|
||||
_COLON = re.compile(
|
||||
r"^\s*(?:\d+\)|[0-9가-힣]\.)?\s*(?P<name>[^::=]{1,20}?)\s*[::]\s*(?P<rhs>.*)$"
|
||||
)
|
||||
_CHOICE = re.compile(r"^\s*※|작업\s*조건")
|
||||
_MONEY = {"노무비": "노무비", "재료비": "재료비", "경비": "경비", "계": "계", "소계": "계"}
|
||||
|
||||
|
||||
def _flat(value: Any) -> str:
|
||||
return re.sub(r"\s+", "", str(value or ""))
|
||||
|
||||
|
||||
def _num(text: str | None) -> float | None:
|
||||
try:
|
||||
return float(str(text).replace(",", ""))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _kind(title: str) -> str:
|
||||
flat = _flat(title)
|
||||
if _TOTAL.search(flat):
|
||||
return "합계"
|
||||
head = re.split(r"[(::]", flat)[0]
|
||||
return "비목" if head in _COSTS or head.endswith(_COSTS[:5]) and len(head) <= 6 else "일"
|
||||
|
||||
|
||||
# ── 줄 하나 → 상수 · 식 ─────────────────────────────────────────────────
|
||||
def factors(expr: str) -> list[tuple[str, dict[str, Any]]] | None:
|
||||
"""「1 ㎡ × 575㎏/㎡ ÷ (qs × K)」 → [(곱|나눔, {수|기호, 단위})] · 「+」 · 못 읽으면 None."""
|
||||
if "+" in expr or "─" in expr:
|
||||
return None
|
||||
out: list[tuple[str, dict[str, Any]]] = []
|
||||
stack = [1] # 괄호마다 부호(1 곱 · -1 나눔)
|
||||
need = False # 다음 인자 · 괄호 앞에 연산이 있어야 함
|
||||
at, text = 0, expr.strip()
|
||||
while at < len(text):
|
||||
hit = _TOKEN.match(text, at)
|
||||
if not hit or hit.end() == at:
|
||||
return None
|
||||
at = hit.end()
|
||||
op = hit.group("op")
|
||||
sign = -1 if op in ("÷", "/") else 1
|
||||
if hit.group("close"):
|
||||
if len(stack) == 1 or op:
|
||||
return None
|
||||
stack.pop()
|
||||
need = True
|
||||
continue
|
||||
if need != bool(op):
|
||||
return None # 두 인자 사이 연산 없음 · 연산 뒤 인자 없음
|
||||
if hit.group("open"):
|
||||
stack.append(stack[-1] * sign)
|
||||
need = False
|
||||
continue
|
||||
need = True
|
||||
one: dict[str, Any] = {}
|
||||
if hit.group("num"):
|
||||
one["수"] = _num(hit.group("num"))
|
||||
one["단위"] = hit.group("unit") or ""
|
||||
else:
|
||||
one["기호"], one["단위"] = hit.group("sym"), ""
|
||||
out.append(("곱" if stack[-1] * sign > 0 else "나눔", one))
|
||||
return out if out and len(stack) == 1 else None
|
||||
|
||||
|
||||
def _value(text: str) -> dict[str, Any] | None:
|
||||
hit = _VALUE.match(text)
|
||||
if not hit:
|
||||
return None
|
||||
out = {"값": _num(hit.group("num")), "단위": hit.group("unit") or ""}
|
||||
if hit.group("note"):
|
||||
out["덧"] = hit.group("note").strip()
|
||||
return out
|
||||
|
||||
|
||||
def _last_value(text: str) -> dict[str, Any] | None:
|
||||
"""식 끝 「= 23.1 ㎥/hr」 · 「= 5분(왕복)」 · 「= 7.79 회」."""
|
||||
hit = re.search(r"(" + NUM + r")\s*(" + _UNIT + r")?\s*(?:\(([^()]*)\))?\s*$", text)
|
||||
if not hit:
|
||||
return None
|
||||
out = {"값": _num(hit.group(1)), "단위": hit.group(2) or ""}
|
||||
if hit.group(3):
|
||||
out["덧"] = hit.group(3).strip()
|
||||
return out
|
||||
|
||||
|
||||
def _formula(sym: str, body: str) -> dict[str, Any] | None:
|
||||
parts = [p.strip() for p in body.split("=")]
|
||||
end = _last_value(parts[-1]) if len(parts) > 1 else None
|
||||
if end is None:
|
||||
return None
|
||||
first = parts[0]
|
||||
out: dict[str, Any] = {"기호": sym, "글": f"{sym} = {body.strip()}", **end}
|
||||
if "─" in first:
|
||||
return {**out, "꼴": "분수"}
|
||||
got = factors(first)
|
||||
if got is None:
|
||||
return {**out, "꼴": "합" if "+" in first else "분수"}
|
||||
out["꼴"] = "곱"
|
||||
out["분자"] = [f for way, f in got if way == "곱"]
|
||||
out["분모"] = [f for way, f in got if way == "나눔"]
|
||||
numbers = factors(parts[1]) if len(parts) > 2 else None
|
||||
if numbers and len(numbers) == len(got):
|
||||
out["대입"] = {
|
||||
f["기호"]: n["수"]
|
||||
for (way, f), (way2, n) in zip(got, numbers, strict=True)
|
||||
if "기호" in f and "수" in n and way == way2
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def line_of(text: str, at: int | None = None) -> dict[str, list[dict[str, Any]]]:
|
||||
"""줄 하나 → {상수 [..], 식 [..]} — 「기호 = 」 마디(줄 머리 · 두 빈칸 · 「{」 「(」 뒤)마다."""
|
||||
out: dict[str, list[dict[str, Any]]] = {"상수": [], "식": []}
|
||||
hits = list(_SEG.finditer(text))
|
||||
for i, hit in enumerate(hits):
|
||||
end = hits[i + 1].start() if i + 1 < len(hits) else len(text)
|
||||
body = text[hit.end() : end]
|
||||
sym, label = hit.group("sym"), hit.group("label")
|
||||
if (got := _value(body.strip().rstrip("{(").strip())) is not None:
|
||||
one = {"기호": sym, **got, **({"이름": label} if label else {}), "행": at}
|
||||
out["상수"].append(one)
|
||||
elif (got := _formula(sym, body)) is not None:
|
||||
out["식"].append({**got, **({"이름": label} if label else {}), "행": at})
|
||||
return out
|
||||
|
||||
|
||||
# ── 절 몫 · 소계 ───────────────────────────────────────────────────────
|
||||
def _share(lines: list[tuple[str, int | None]]) -> dict[str, Any] | None:
|
||||
"""비목 줄 「단가 ÷ Q × 575 / 1750 × 90%」 → 「÷ Q」 뒤 곱수(비목 줄 모두 같을 때) ·
|
||||
없으면 직종 줄 「단가 × 품인 × 10%」 품 뒤 % 곱수."""
|
||||
costs: list[tuple[str, float, int | None]] = []
|
||||
trades: list[tuple[str, float, int | None]] = []
|
||||
for text, at in lines:
|
||||
hit = _COLON.match(text)
|
||||
if not hit or "=" not in hit.group("rhs"):
|
||||
continue
|
||||
name, rhs = _flat(hit.group("name")), hit.group("rhs").split("=")[0]
|
||||
got = factors(rhs)
|
||||
if not got or len(got) < 3 or any("수" not in f for _, f in got):
|
||||
continue
|
||||
tail = got[2:]
|
||||
if name in ("노무비", "재료비", "경비"):
|
||||
pass
|
||||
elif got[1][1]["단위"].startswith("인"):
|
||||
tail = [t for t in tail if t[1]["단위"] == "%"]
|
||||
if not tail or len(tail) != len(got) - 2:
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
value = 1.0
|
||||
for way, f in tail:
|
||||
n = f["수"] / 100 if f["단위"] == "%" else f["수"]
|
||||
value = value * n if way == "곱" else value / n
|
||||
words = " ".join(f"{'×' if way == '곱' else '/'} {f['수']:g}{f['단위']}" for way, f in tail)
|
||||
(costs if name in ("노무비", "재료비", "경비") else trades).append((words, value, at))
|
||||
for group in (costs, trades):
|
||||
if group and len({w for w, _, _ in group}) == 1:
|
||||
words, value, at = group[0]
|
||||
return {"글": words, "값": round(value, 6), "행": at}
|
||||
return None
|
||||
|
||||
|
||||
def _totals(lines: list[tuple[str, int | None]], only_sum: bool) -> dict[str, float | None]:
|
||||
out: dict[str, float | None] = {}
|
||||
for text, _ in lines:
|
||||
hit = _COLON.match(text)
|
||||
if not hit:
|
||||
continue
|
||||
key = _MONEY.get(_flat(hit.group("name")))
|
||||
if not key or (only_sum and key != "계"):
|
||||
continue
|
||||
rhs = hit.group("rhs")
|
||||
if re.search(r"[×*÷]", rhs.split("=")[0]):
|
||||
continue
|
||||
nums = re.findall(NUM, rhs)
|
||||
out[key] = _num(nums[-1]) if nums else None
|
||||
return out
|
||||
|
||||
|
||||
# ── 절 나무 ────────────────────────────────────────────────────────────
|
||||
def _title(text: str) -> tuple[int, str, str] | None:
|
||||
if _DECIMAL.match(text):
|
||||
return None
|
||||
for depth, pattern in _LEVELS:
|
||||
if hit := pattern.match(text):
|
||||
return depth, hit.group(1), hit.group(2).strip()
|
||||
return None
|
||||
|
||||
|
||||
def _fill(one: dict[str, Any], lines: list[tuple[str, int | None]]) -> None:
|
||||
for text, at in lines:
|
||||
got = line_of(text, at)
|
||||
one["상수"] += got["상수"]
|
||||
one["식"] += got["식"]
|
||||
if _CHOICE.search(text):
|
||||
one["선택값"].append({"글": text.strip(), "행": at})
|
||||
|
||||
|
||||
def head(text: list[str], rows: list[int] | None = None) -> dict[str, Any]:
|
||||
"""첫 절 앞 머리 줄 → {행, 상수, 식, 선택값}."""
|
||||
rows = rows or [None] * len(text)
|
||||
lines = []
|
||||
for raw, at in zip(text, rows, strict=True):
|
||||
if _title(str(raw)):
|
||||
break
|
||||
lines.append((str(raw), at))
|
||||
out: dict[str, Any] = {"행": [lines[0][1], lines[-1][1]] if lines else None}
|
||||
out |= {"상수": [], "식": [], "선택값": []}
|
||||
_fill(out, lines)
|
||||
return out
|
||||
|
||||
|
||||
def tree(
|
||||
text: list[str], rows: list[int] | None = None, items: list[dict[str, Any]] | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""블록 몸 글(머리 줄 뺌) → 절 나무 — 꼴은 머리 글."""
|
||||
rows = list(rows) if rows else [None] * len(text)
|
||||
roots: list[dict[str, Any]] = []
|
||||
path: list[dict[str, Any]] = []
|
||||
own: dict[int, list[tuple[str, int | None]]] = {}
|
||||
for at, (raw, row) in enumerate(zip(text, rows, strict=True)):
|
||||
line = str(raw)
|
||||
if got := _title(line):
|
||||
depth, no, title = got
|
||||
while path and path[-1]["층"] >= depth:
|
||||
path.pop()
|
||||
one = {"번": no, "층": depth, "제목": title, "갈래": _kind(title), "행": [row, row]}
|
||||
one |= {"글": [at, at], "상수": [], "식": [], "선택값": [], "몫": None, "소계": {}}
|
||||
one |= {"항목": [], "아래": []}
|
||||
(path[-1]["아래"] if path else roots).append(one)
|
||||
path.append(one)
|
||||
own[id(one)] = [(line, row)]
|
||||
continue
|
||||
if not path:
|
||||
continue
|
||||
own[id(path[-1])].append((line, row))
|
||||
for one in path:
|
||||
one["행"][1], one["글"][1] = row, at
|
||||
for one in _walk(roots):
|
||||
lines = own[id(one)]
|
||||
_fill(one, lines)
|
||||
one["몫"] = _share(lines)
|
||||
total = next((k for k in one["아래"] if k["갈래"] == "합계"), None)
|
||||
if one["갈래"] == "합계":
|
||||
one["소계"] = _totals(lines, False)
|
||||
elif total is not None:
|
||||
one["소계"] = _totals(own[id(total)], False)
|
||||
else:
|
||||
one["소계"] = _totals(lines, True)
|
||||
if items:
|
||||
_attach(roots, items)
|
||||
return roots
|
||||
|
||||
|
||||
def _walk(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
out = []
|
||||
for one in nodes:
|
||||
out.append(one)
|
||||
out += _walk(one["아래"])
|
||||
return out
|
||||
|
||||
|
||||
def _attach(roots: list[dict[str, Any]], items: list[dict[str, Any]]) -> None:
|
||||
"""단위 줄 차례 → 행이 든 가장 깊은 절."""
|
||||
for at, item in enumerate(items):
|
||||
row = item.get("행")
|
||||
if not isinstance(row, int):
|
||||
continue
|
||||
best = None
|
||||
for one in _walk(roots):
|
||||
lo, hi = one["행"]
|
||||
if isinstance(lo, int) and isinstance(hi, int) and lo <= row <= hi:
|
||||
if best is None or one["층"] > best["층"]:
|
||||
best = one
|
||||
if best is not None:
|
||||
best["항목"].append(at)
|
||||
|
||||
|
||||
def works(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""일 절 — 일 갈래 중 아래에 일 절이 없는 절(비목 · 합계 아래만 있으면 그 절)."""
|
||||
out = []
|
||||
for one in nodes:
|
||||
if one["갈래"] != "일":
|
||||
continue
|
||||
kids = works(one["아래"])
|
||||
out += kids or [one]
|
||||
return out
|
||||
|
||||
|
||||
def find(nodes: list[dict[str, Any]], path: str) -> dict[str, Any] | None:
|
||||
"""「1」 · 「1.가」 → 절."""
|
||||
first, _, rest = path.partition(".")
|
||||
for one in nodes:
|
||||
if one["번"] == first:
|
||||
return find(one["아래"], rest) if rest else one
|
||||
return None
|
||||
@@ -5,7 +5,7 @@
|
||||
블록 = B열(머리 「산출근거」) 머리 줄 「N.이름[단위]」(앞 빈칸 없음)부터 다음 머리 앞까지 ·
|
||||
출처 = 「…품셈」(오타 「품줌」) 뒤 20 글자 안 절 번호 — 책 = 앞 낱말(`book_of` · 781-1: 산림 ·
|
||||
임도(옛) · 사방(옛) · 건설 부문 · 그 밖 건설품셈(부문 모름)) ·
|
||||
부문 풀이 · 옛 번호 대응 = `M02_Hopyo_Basis.settle` ·
|
||||
부문 풀이 · 옛 번호 대응 = `M02_Hopyo_Clause.settle` ·
|
||||
직종 = 「이름 : 수」 줄의 빈칸 뺀 이름이 우리 인력(LB) 이름 · 옛이름일 때만(지금 이름으로) ·
|
||||
글 = 머리 뺀 몸 줄 `GLINES` 줄 · 줄마다 `GCHARS` 자 안 · 산출근거 열이 빈 줄은 그 오른쪽 ~ 돈 머리
|
||||
(맨 오른쪽 묶음) 앞 칸을 빈칸으로 이어 붙임(거창 꼴 · 돈 머리 없으면 `WIDE` 열) ·
|
||||
@@ -28,6 +28,7 @@ from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Hopyo_Formula as formula
|
||||
from M02_MasterTemplete import M02_Hopyo_UnitLine as unit_line
|
||||
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
|
||||
from M02_MasterTemplete.M02_Hopyo_Words import mtypes, parse_rhs
|
||||
@@ -171,8 +172,8 @@ def _columns(rows: list[list[Any]]) -> tuple[int, int, dict[str, int]]:
|
||||
|
||||
|
||||
def read(rows: list[list[Any]] | None) -> dict[int, dict[str, Any]]:
|
||||
"""단가산출 시트 줄 → `{N: {번, 이름, 단위, 행: [시작, 끝], 출처, 직종, 글, 금액, 항목}}`
|
||||
(행 = 엑셀 1부터)."""
|
||||
"""단가산출 시트 줄 → `{N: {번, 이름, 단위, 행: [시작, 끝], 출처, 직종, 글, 글행, 금액, 항목,
|
||||
절}}`(행 = 엑셀 1부터 · 글행 = 글 줄마다 행 · 절 = `M02_Hopyo_Formula.tree`)."""
|
||||
if not rows:
|
||||
return {}
|
||||
start, col, money = _columns(rows)
|
||||
@@ -222,7 +223,9 @@ def read(rows: list[list[Any]] | None) -> dict[int, dict[str, Any]]:
|
||||
if name and name not in block["직종"]:
|
||||
block["직종"].append(name)
|
||||
for block in blocks.values():
|
||||
block["항목"] = items(block["글"], block.pop("_글행"))
|
||||
block["글행"] = block.pop("_글행")
|
||||
block["항목"] = items(block["글"], block["글행"])
|
||||
block["절"] = formula.tree(block["글"], block["글행"], block["항목"])
|
||||
return blocks
|
||||
|
||||
|
||||
|
||||
@@ -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) 결과 기반이므로, 원본 실행 파일의 모든 예외 분기와 수치 동등성을 보증하지 않습니다.
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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>&X..L&</code>), 재료비(<code>&X..M&</code>), 경비(<code>&X..S&</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>&X..&</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();
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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>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>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 };
|
||||
})();
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user