diff --git a/M02_MasterTemplete/M02_Hopyo_Bind.py b/M02_MasterTemplete/M02_Hopyo_Bind.py new file mode 100644 index 000000000..9c8856c73 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Bind.py @@ -0,0 +1,474 @@ +"""M02 일위대가 실무자료 옵션값 읽기(PLAN 785-2 · 요구 641 · 642 · 설계 642-A 2-5 ④-3) — +우리 기초 하나 + 엑셀 글감 → 입력값. 찾지 않고 읽음 · 지어 넣는 1 · 0 없음. + +길(앞이 먼저 · 두 길이 다른 값 = 엇갈림 · 앞 값 둠): + B1 부르는 호표 줄 규격 · 품명 괄호(`Match.spec_inputs`) + B6 같은 호표 다른 줄 — 레미콘 규격 「a-b-c」 → 굵은골재 · 강도 · 슬럼프 · 채움량 = 그 줄 수량 ÷ 이 줄 수량 + B2 선택값 줄(785-1 Formula) · 글에 없는 고르기 = 「아님 · 해당 없음」(글 · 줄에 칸 이름 · 다른 고름이 없을 때) + B3 상수 줄(기호표 `SYMBOLS` · 기호 = 입력 이름 · 「(90˚)」 = 선회각도 · 표 칸 이름 = 기호면 그 표 줄 조건값) + B4 대입식 — 우리 줄 수량 식을 중간까지 펴 분자 · 분모 자리 + 단위로 짝(하나뿐일 때만) + B5 표 거꾸로 — 못 읽은 찾기 조건 칸 = 엑셀 품과 같은 표 줄(`fit` 검산) · 「감률」 칸 = 품 배수 +못 읽은 칸 = 비움(지역 · 자재지역 · 조종원직종 = 프로젝트) · 입력 필요(빈 줄을 낳음 · 요소 글에 듦) · +안 씀(그 밖) · 기본 = `master_auto.defaults` 제안(저장 아님). 엔진 몫 `quantities` = 금액 없이 품만. +서버 단독 · 읽기만 · 짝 = `M02_Hopyo_Solve`. +""" + +from __future__ import annotations + +import re +from decimal import Decimal +from typing import Any, Callable + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Hopyo_Match as hm + +mf = m01.cm.mf +mp = mf.mp +mm = mp.mm +import master_auto as ma # noqa: E402 — mf 를 불러 scripts 경로가 깔린 뒤 +import master_unit as mu # noqa: E402 + +SKIP = ("지역", "자재지역", "조종원직종") +NEG = ("아님", "해당 없음", "없음", "미적용", "사용 안 함") +# 상수 기호 → 입력 이름(앞이 먼저 · 기호 = 입력 이름이면 바로) +SYMBOLS = { + "q": ("버킷용량",), + "E": ("E입력",), + "Cm": ("싸이클입력",), + "L": ("운반거리", "거리"), + "n": ("운반회수",), + "T": ("실작업시간",), + "경사도": ("경사각도",), +} +_WORDS = re.compile(r"[\s::,/()※*·\[\]{}]+") +_SAME_UNIT = {"sec": "초", "s": "초", "min": "분", "hr": "시간", "h": "시간"} +_ANGLE = re.compile(r"\(\s*(\d+)\s*[˚°]\s*\)") +_MIX = re.compile(r"(? Decimal | None: + try: + return None if value in (None, "") else Decimal(str(value)) + except ArithmeticError: + return None + + +def plain(value: Any) -> Any: + """JSON 수 — Decimal → int · float(글은 그대로).""" + if isinstance(value, Decimal): + value = value.normalize() + return int(value) if value == value.to_integral_value() else float(value) + return value + + +def specs_of(row: dict) -> list[dict]: + return mf.inputs_of(row) if isinstance(row.get("흐름"), dict) else row.get("입력", []) + + +def _pick(spec: dict, value: Any) -> Any: + """고르기 칸이면 같은 수 · 같은 글의 고름(「080」 = 80) · 없으면 None · 고르기 아니면 수.""" + picks = spec.get("고르기") + if not picks: + return _num(value) + hit = next((o for o in picks if str(o) == str(value)), None) + n = _num(value) + if hit is None and n is not None: + hit = next((o for o in picks if _num(o) == n), None) + return hit + + +# ── 엔진 몫(품만) ───────────────────────────────────────────────────── +def _env(master: mf.Master, row: dict, given: dict) -> dict: + """입력 검사(고르기 · 범위 · 기계 키) — 빈 칸은 자리값으로 검사만 하고 None 으로 되돌림.""" + holes = {} + for spec in specs_of(row): + if given.get(spec["이름"]) is None: + span = spec.get("범위") + holes[spec["이름"]] = (spec.get("고르기") or [None])[0] or (span[0] if span else 0) + env = mf._inputs(row, {**{s["이름"]: None for s in specs_of(row)}, **given, **holes}, master) + return {**env, **dict.fromkeys(holes)} + + +def _rate(extra: dict, env: dict, master: mf.Master) -> Decimal | None: + """덧줄 율 — 식 꼴 「(기준) × 율 / 100」 의 율 ÷ 100 · 다른 꼴 = None.""" + node = mf.parse(extra["식"]) + if node[0] == "bin" and node[1] == "/" and node[3] == ("num", Decimal(100)): + left = node[2] + if left[0] == "bin" and left[1] == "*": + return mf.evaluate(left[3], env, master) / 100 + return None + + +def quantities(master: mf.Master, key: str, given: dict) -> list[dict]: + """로직 줄마다 품 `{이름, 종류, 단위, 요소, 품, 까닭?}` + 덧줄 율(종류 「율」) — 금액 안 셈 · + 빈 칸을 쓰는 줄만 품 None + 까닭(`mf.run` 과 같은 길).""" + row = master.logic(key) + if isinstance(row.get("흐름"), dict): # 흐름 로직 — 엔진 한 번(빈 칸 = 자리값 없음 → 못 셈) + try: + got = mf.run(master, key, given) + except mf.FormulaError as e: + return [{"이름": "", "종류": "", "단위": "", "품": None, "까닭": str(e)}] + return [{**x, "종류": "", "품": x.get("수량")} for x in got.get("줄", [])] + env = _env(master, row, given) + for step in row.get("중간", []): + try: + env[step["이름"]] = mf.evaluate(mf.parse(step["식"]), env, master) + except mf.EmptyInput as e: + env[step["이름"]] = mf.Blank(str(e)) + out = [] + for item in row.get("호표", []): + one = {k: item.get(k) for k in ("이름", "종류", "단위", "요소", "비목")} + try: + one["품"] = mf.evaluate(mf.parse(item["수량"]), env, master) + except mf.EmptyInput as e: + one |= {"품": None, "까닭": str(e)} + out.append(one) + for extra in row.get("덧줄", []): + one = {"이름": extra["이름"], "종류": "율", "단위": "%", "비목": extra.get("비목")} + try: + one["품"] = _rate(extra, env, master) + except mf.EmptyInput as e: + one |= {"품": None, "까닭": str(e)} + out.append(one) + return out + + +# ── 식 풀기 ─────────────────────────────────────────────────────────── +def _names(node) -> set: + return {p[1] for p in mf.walk(node) if p[0] == "name"} + + +def _mids(row: dict) -> dict: + return {s["이름"]: mf.parse(s["식"]) for s in row.get("중간", [])} + + +def deps(row: dict, text: str) -> set: + """식이 기대는 입력 이름 — 중간을 거쳐 폄(만약 갈래 모두 · 넉넉히).""" + mids, seen, todo = _mids(row), set(), _names(mf.parse(text)) + while todo: + name = todo.pop() + if name in seen: + continue + seen.add(name) + if name in mids: + todo |= _names(mids[name]) + return seen + + +def _place(node, side: str, mids: dict, out: dict, depth: int = 0) -> None: + """이름마다 분자 · 분모 자리 — 「/」 오른쪽이 뒤집힘 · 만약은 두 갈래만 · 찾기 · 함수 안은 안 봄.""" + kind = node[0] + if kind == "name": + if node[1] in mids and depth < 20: + _place(mids[node[1]], side, mids, out, depth + 1) + else: + out.setdefault(node[1], set()).add(side) + elif kind == "bin" and node[1] in ("*", "/", "+", "-"): + _place(node[2], side, mids, out, depth) + flip = {"분자": "분모", "분모": "분자"}[side] if node[1] == "/" else side + _place(node[3], flip, mids, out, depth) + elif kind == "call" and node[1] == "만약": + for arm in node[2][1:]: + _place(arm, side, mids, out, depth) + + +def _element_names(row: dict, element: Any) -> set: + """요소가 기대는 입력 — 글 = 식 · 묶음 = 「{이름}」 구멍.""" + if isinstance(element, str): + return deps(row, element) + if isinstance(element, dict): + return {n for v in element.values() for n in re.findall(r"\{([^{}]+)\}", str(v))} + return set() + + +def places(row: dict) -> dict: + """입력 이름 → {분자 | 분모} — 호표 줄 수량 식에서.""" + out: dict = {} + mids = _mids(row) + for item in row.get("호표", []): + _place(mf.parse(item["수량"]), "분자", mids, out) + return out + + +def finds(master: mf.Master, row: dict, depth: int = 0) -> dict: + """찾기 조건 칸으로 쓰인 입력 → [(표, 조건)] — 「로직(키, 칸=이름)」 한 겹 안도 이름을 옮겨 봄.""" + out = {k: list(v) for k, v in ma._uses(row)[0].items()} + if depth > 1: + return out + for text in ma._texts(row): + for part in mf.walk(mf.parse(text)): + if part[0] != "logic" or not isinstance(part[1], str) or "{" in part[1]: + continue + try: + inner = finds(master, master.logic(part[1]), depth + 1) + except mf.FormulaError: + continue + passed = {k: v[1] for k, v in part[2] if v[0] == "name"} + for name, uses in inner.items(): + if name in passed: + out.setdefault(passed[name], []).extend(uses) + return out + + +# ── 읽기 ───────────────────────────────────────────────────────────── +class _Got: + """읽은 값 · 출처 · 엇갈림.""" + + def __init__(self, specs: list[dict]) -> None: + self.specs = {s["이름"]: s for s in specs} + self.value: dict = {} + self.where: dict = {} + self.clash: dict = {} + + def put(self, name: str, value: Any, how: str, at: Any = None, text: Any = "") -> None: + spec = self.specs.get(name) + value = _pick(spec, value) if spec else None + if value is None or name in SKIP: + return + mine = {"값": plain(value), "길": how, "행": at} + if name not in self.value: + self.value[name], self.where[name] = value, {"길": how, "행": at, "글": str(text)[:120]} + elif self.value[name] != value: + first = { + "값": plain(self.value[name]), + **{k: self.where[name][k] for k in ("길", "행")}, + } + self.clash.setdefault(name, [first]) + if mine not in self.clash[name]: + self.clash[name].append(mine) + + +def _b1(got: _Got, line: dict | None, how: str = "B1") -> None: + if not line: + return + values, _ = hm.spec_inputs(line, list(got.specs.values())) + text = f"{line.get('이름') or ''} {line.get('규격') or ''}".strip() + for name, value in values.items(): + got.put(name, value, how, line.get("행"), text) + + +def _b6(got: _Got, line: dict | None, same: list[dict]) -> None: + for other in same or []: + text = f"{other.get('이름') or ''} {other.get('규격') or ''}" + mix = _MIX.search(text) + if not mix or not any(w in text for w in CONC): + continue + for name, value in zip(MIX_NAMES, mix.groups(), strict=True): + got.put(name, value, "B6", other.get("행"), text) + have, mine = _num(other.get("수량")), _num((line or {}).get("수량")) + for name, spec in got.specs.items(): + unit = mm.plain_unit(spec.get("단위")) + fill = "콘크리트" in name and unit.count("/") == 1 and have and mine + if fill and unit.split("/") == [ + mm.plain_unit(other.get("단위")), + mm.plain_unit(line.get("단위")), + ]: + got.put( + name, (have / mine).quantize(Decimal("0.0001")), "B6", other.get("행"), text + ) + + +def _b2(got: _Got, picks: list[dict], text: str, keys: dict) -> None: + """선택값 줄 · 글에 없는 켜기 칸 = 아님(표 줄을 고르는 찾기 조건 칸은 B5 몫 · 뺌).""" + for one in picks or []: # 고름 글 = 줄의 낱말 하나와 같음(「사용횟수」 ≠ 「사용」) + words = {mm.plain_spec(w) for w in _WORDS.split(str(one.get("글") or "")) if w} + for name, spec in got.specs.items(): + hits = [o for o in spec.get("고르기") or [] if mm.plain_spec(str(o)) in words] + if len(hits) == 1: + got.put(name, hits[0], "B2", one.get("행"), one.get("글")) + flat = mm.plain_spec(text) + for name, spec in got.specs.items(): + opts = spec.get("고르기") or [] + neg = next((o for o in opts if str(o) in NEG), None) + if neg is None or name in got.value or name in keys: + continue + words = [name, *(hm._PAREN.sub("", str(o)) for o in opts if o != neg)] + if not any(len(w) > 2 and mm.plain_spec(w) in flat for w in words): + got.put(name, neg, "B2", None, f"글에 「{name}」 없음") + + +def _unit_ok(have: Any, want: Any) -> bool: + """상수 단위 ↔ 입력 단위 — 한쪽이 없음 · 같음(「회/일」 = 회) · 같은 성격(길이 등).""" + a, b = (mm.plain_unit(str(u or "").split("/")[0]) for u in (have, want)) + a, b = _SAME_UNIT.get(a, a), _SAME_UNIT.get(b, b) + return not a or not b or a == b or mu.same_kind(a, b) + + +def _b3(got: _Got, consts: list[dict], master: mf.Master, uses: dict) -> None: + for c in consts or []: + sym, value, at, text = str(c.get("기호") or ""), c.get("값"), c.get("행"), c.get("글") or "" + angle = _ANGLE.search(f"({c.get('덧') or ''}) {c.get('단위') or ''} {text}") + if angle: + got.put("선회각도", angle.group(1), "B3", at, text) + names = [ + n + for n in (c.get("이름"), sym, *SYMBOLS.get(sym, ())) + if n in got.specs and _unit_ok(c.get("단위"), got.specs[n].get("단위")) + ] + if names: + unit = got.specs[names[0]].get("단위") + rate = mu.factor(c.get("단위"), unit) if c.get("단위") and unit else None + value = _num(value) * rate if rate and _num(value) is not None else value + got.put( + names[0], + value, + "B3", + at, + f"{sym} = {plain(_num(c.get('값')))}{c.get('단위') or ''}", + ) + continue + for name, refs in uses.items(): # 표 칸 이름 = 기호 → 그 줄 조건값(K = 0.55 → 버킷조건) + if name in got.value or name not in got.specs: + continue + for ref, cond in refs: + rows = [r for r in master.get(ref).get("줄", []) if _num(r.get(sym)) == _num(value)] + conds = {r.get(cond) for r in rows} + if len(conds) == 1 and None not in conds: + got.put(name, conds.pop(), "B5", at, f"{sym} = {plain(_num(value))} → {ref}") + + +def _b4(got: _Got, forms: list[dict], row: dict) -> None: + where = places(row) + for form in forms or []: + for side in ("분자", "분모"): + for term in form.get(side) or []: + unit = mm.plain_unit(str(term.get("단위") or "").split("/")[0]) + hits = [ + n + for n, s in got.specs.items() + if side in where.get(n, ()) and unit and mm.plain_unit(s.get("단위")) == unit + ] + value = term.get("수", term.get("값")) + if term.get("기호") is not None: + value = (form.get("대입") or {}).get(term["기호"]) + if len(hits) == 1 and _num(value) is not None: + got.put(hits[0], value, "B4", form.get("행"), form.get("글")) + + +def _ok(fit: Callable, given: dict) -> dict: + try: + return fit(given) or {} + except mf.FormulaError: + return {} + + +def _b5(got: _Got, master: mf.Master, row: dict, uses: dict, fit: Callable, need: set) -> None: + """못 읽은 찾기 조건 칸 — 표 줄마다 품 검산 · 줄이 갈리면 그 값(같은 값 여럿 = 엇갈림).""" + rate = [n for n in need if n.endswith("감률") and n not in got.value] + zero = dict.fromkeys(rate, Decimal(0)) + tables: dict = {} + for name in sorted(need): + for ref, cond in uses.get(name, []): + if name not in got.value and got.specs.get(name, {}).get("고르기"): + tables.setdefault(ref, []).append((name, cond)) + for ref, conds in tables.items(): + rows, combos = master.get(ref).get("줄", []), [] + bound = { + c: got.value[n] + for n, refs in uses.items() + if n in got.value + for r, c in refs + if r == ref + } + for r in rows: + if all(r.get(c) is None or str(r.get(c)) == str(v) for c, v in bound.items()): + combo = {n: r.get(c) for n, c in conds} + combos += [combo] if combo not in combos else [] + exact, scaled = [], [] + for combo in combos: + fill = {n: v if v is not None else got.specs[n]["고르기"][0] for n, v in combo.items()} + res = _ok(fit, {**got.value, **zero, **fill}) + if res.get("맞음"): + exact.append(combo) + elif rate and (res.get("배") or 0) > 1: + scaled.append(combo) + live = exact or scaled # 그대로 같은 줄이 감률 배 줄보다 먼저 + if not live or (len(live) == len(combos) > 1): + continue + for name, _ in conds: + seen = {str(c[name]) for c in live if c[name] is not None} + for v in [str(o) for o in got.specs[name]["고르기"] if str(o) in seen]: + got.put(name, v, "B5", None, f"{ref} 줄 = 엑셀 품") + if rate: # 감률 = 엑셀 품 ÷ 우리 품(감률 0)이 모든 노무 · 기계 줄에서 같은 배 + res = _ok(fit, {**got.value, **zero}) + k = res.get("배") + if k and k > 1: + cut = (Decimal(1) - Decimal(1) / Decimal(str(k))) * 100 + cut = ( + cut.quantize(Decimal("1")) + if abs(cut - cut.quantize(Decimal("1"))) < Decimal("0.5") + else cut + ) + if _ok(fit, {**got.value, rate[0]: cut}).get("맞음"): + got.put( + rate[0], + cut, + "B5", + None, + f"품 × {plain(Decimal(str(k)).quantize(Decimal('0.01')))}", + ) + + +def bind(master: mf.Master, key: str, src: dict, fit: Callable | None = None) -> dict: + """옵션값 읽기 → `{입력값, 출처, 엇갈림, 안씀, 입력필요, 비움, 기본}` — 설계 642-A 2-5 ④-3.""" + row = master.logic(key) + specs = specs_of(row) + got = _Got(specs) + line, block = src.get("줄"), src.get("블록") or {} + _b1(got, line) + if not line and block: + _b1(got, {"이름": block.get("이름"), "규격": "", "행": (block.get("행") or [None])[0]}) + _b6(got, line, src.get("같은줄") or []) + uses = finds(master, row) if not isinstance(row.get("흐름"), dict) else {} + _b3(got, src.get("상수") or [], master, uses) + if not isinstance(row.get("흐름"), dict): + _b4(got, src.get("식") or src.get("대입식") or [], row) + whole = " ".join( + [ + *(block.get("글") or []), + str((line or {}).get("이름") or ""), + str((line or {}).get("규격") or ""), + ] + ) + direct = {} if isinstance(row.get("흐름"), dict) else ma._uses(row)[0] + _b2(got, src.get("선택값") or [], whole, direct) + if fit: + _b5(got, master, row, uses, fit, _needed(master, key, row, got.value)) + need = _needed(master, key, row, got.value) + base = ma.defaults(master, row) + unread = [s["이름"] for s in specs if s["이름"] not in got.value] + skip = [n for n in unread if n in SKIP] + want = [n for n in unread if n in need and n not in SKIP and n not in got.clash] + unused = [n for n in unread if n not in skip and n not in want and n not in got.clash] + return { + "입력값": {k: plain(v) for k, v in got.value.items() if k not in got.clash}, + "출처": {k: v for k, v in got.where.items() if k not in got.clash}, + "엇갈림": got.clash, + "안씀": unused, + "입력필요": want, + "비움": skip, + "기본": {n: plain(base.get(n)) for n in [*want, *unused, *got.clash]}, + } + + +def _needed(master: mf.Master, key: str, row: dict, have: dict) -> set: + """못 읽은 칸 중 쓰이는 것 — 빈 품 줄이 기대는 칸(기본값으로 0 이 되는 줄 = 꺼진 줄 · 뺌) + + 품 있는 줄의 요소 글에 든 칸(기계 · 지역 등 단가 몫).""" + if isinstance(row.get("흐름"), dict): + return {s["이름"] for s in specs_of(row)} - set(have) + lines = quantities(master, key, have) + base = {k: v for k, v in ma.defaults(master, row).items() if v is not None and k not in have} + probe = ( + quantities(master, key, {**base, **have}) if any(x["품"] is None for x in lines) else lines + ) + items = [*row.get("호표", []), *({"수량": x["식"]} for x in row.get("덧줄", []))] + need: set = set() + for item, now, then in zip(items, lines, probe, strict=True): + if now.get("까닭") and then["품"] != 0: + need |= deps(row, item["수량"]) + elif now["품"] not in (None, 0): + need |= _element_names(row, item.get("요소")) + # ponytail: 0 이 되는 줄 = 꺼진 줄로 봄(기본값 하나로 판정) — 값마다 다른 줄이 나오면 칸마다 검산으로 + return need - set(have) diff --git a/M02_MasterTemplete/M02_Hopyo_Solve.py b/M02_MasterTemplete/M02_Hopyo_Solve.py new file mode 100644 index 000000000..33d5a5566 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Solve.py @@ -0,0 +1,279 @@ +"""M02 일위대가 실무자료 블록 풀이 검산 · 판정(PLAN 785-2 · 요구 642 · 설계 642-A 2-5 ④-4 · ④-5) — +후보 기초마다 옵션값 읽기(`M02_Hopyo_Bind`) → 엔진 한 번(품 · 금액 참고) → 엑셀 단위 줄과 짝 · 판정. + +짝(같은 묶음끼리): L 노무 = 직종 이름 · X 기계 = 시간 단위 로직 줄 · M 자재 = 뜻 낱말(남은 하나끼리) · +R 율 = 덧줄 율(같은 율 › 남은 하나끼리) · E 값 · S 하위 = 엑셀만. +품 같음(`same`) = 엑셀 자릿수 반올림 폭 안(0.67 ↔ 0.6667) 또는 0.5 % · 금액은 참고(노임 · 단가 해가 다름). +줄 판정 = 품 같음 · 품 다름 · 입력 필요 · 엑셀만 · 우리만(우리 줄 품 0 = 꺼진 줄 · 안 보임). +절 판정 = 기초 풂(품 다름 없는 후보 하나) · 후보 여럿 · 기초 없음 / +블록 판정 = 기초로 풂 · 기초+덧(엑셀만 줄 → `Units` 잇기) · 줄 조합(기초 없음뿐) · 고를 것 n. +서버 단독 · 읽기만 · 자동 고름 없음(제안 · 사용자 확인). +""" + +from __future__ import annotations + +from decimal import Decimal +from statistics import median +from typing import Any, Callable + +from M02_MasterTemplete import M02_Hopyo_Bind as hb +from M02_MasterTemplete import M02_Hopyo_Match as hm + +mf, mm, ma = hb.mf, hb.mm, hb.ma +SAME, DIFF, NEED, EXCEL, OURS = "품 같음", "품 다름", "입력 필요", "엑셀만", "우리만" +SOLVED, SOME, NONE = "기초 풂", "후보 여럿", "기초 없음" +BY_BASE, BASE_PLUS, BY_LINES, PICK = "기초로 풂", "기초+덧", "줄 조합", "고를 것" +SHARE = Decimal("0.005") +HOURS = {"시간", "hr", "h", "시"} + + +def _d(value: Any) -> Decimal | None: + try: + return None if value is None else Decimal(str(round(float(value), 10))) + except (ArithmeticError, TypeError, ValueError): + return None + + +def same(excel: Any, ours: Any) -> bool: + """품 같음 — 엑셀 적힌 자릿수의 반올림 폭 안 또는 0.5 % 안.""" + e, o = _d(excel), _d(ours) + if e is None or o is None: + return False + gap = abs(e - o) + places = -e.normalize().as_tuple().exponent + width = Decimal(5) * Decimal(10) ** -(places + 1) if 0 <= places <= 4 else Decimal(0) + return gap <= width or gap <= abs(e) * SHARE + + +def _kind(line: dict) -> str | None: + kind = line.get("종류") + if kind in ("로직", "기계"): + return "X" if str(line.get("단위") or "").strip() in HOURS else "S" + return {"인력": "L", "재료": "M", "율": "R"}.get(kind) + + +def _strict(item: dict, line: dict) -> bool: + kind = item.get("묶음") + if kind == "L": + return hm.tidy(line.get("이름")) == hm.tidy(item.get("직종") or item.get("이름")) + if kind == "M": + return hm.near(hm.words(item.get("이름")), hm.words(line.get("이름"))) + if kind == "R": + return same(item.get("품"), line.get("품")) + return kind == "X" + + +def _excel(item: dict, share: Decimal) -> dict: + q = _d(item.get("품")) + return { + "묶음": item.get("묶음"), + "이름": item.get("이름"), + "품": hb.plain(q / share if q is not None and item.get("묶음") != "R" else q), + "행": item.get("행"), + **({"별도": True} if item.get("어떻게") == "별도" else {}), + } + + +def _row(item: dict | None, line: dict | None, share: Decimal) -> dict: + ex = _excel(item, share) if item else None + us = {k: hb.plain(line.get(k)) for k in ("이름", "종류", "단위", "품")} if line else None + if line and line.get("까닭"): + us["까닭"] = line["까닭"] + if ex is None: + verdict = NEED if line.get("까닭") else OURS + elif us is None: + verdict = EXCEL + elif us["품"] is None: + verdict = NEED if line.get("까닭") else DIFF + else: + verdict = SAME if same(ex["품"], us["품"]) else DIFF + out = {"엑셀": ex, "우리": us, "판정": verdict} + names = f"{(ex or {}).get('이름') or ''}|{(us or {}).get('이름') or ''}" + if item and line and item.get("묶음") == "X" and "타이어" in names and "무한궤도" in names: + out["표지"] = "기계 갈래 엇갈림" + return out + + +def pair(lines: list[dict], items: list[dict], share: Any = 1) -> list[dict]: + """엑셀 단위 줄 ↔ 우리 줄 짝 → 줄 판정(엑셀 차례 · 남은 우리 줄 뒤).""" + share = _d(share) or Decimal(1) + used: set = set() + got: dict = {} + late = [] + for i, item in enumerate(items): + pool = [j for j, ln in enumerate(lines) if j not in used and _kind(ln) == item.get("묶음")] + hits = [j for j in pool if _strict(item, lines[j])] + exact = [j for j in hits if lines[j].get("이름") == (item.get("직종") or item.get("이름"))] + if hits: + got[i] = (exact or hits)[0] + used.add(got[i]) + else: + late.append(i) + for i in late: # 남은 하나끼리(결속선 ↔ 어닐링철선) + kind = items[i].get("묶음") + pool = [j for j, ln in enumerate(lines) if j not in used and _kind(ln) == kind] + rest = [k for k in late if items[k].get("묶음") == kind and k not in got] + if len(pool) == 1 and len(rest) == 1 and kind in ("M", "R"): + got[i] = pool[0] + used.add(pool[0]) + rows = [_row(item, lines[got[i]] if i in got else None, share) for i, item in enumerate(items)] + for j, line in enumerate(lines): + if ( + j not in used + and line.get("품") != 0 + and (line.get("품") is not None or line.get("까닭")) + ): + rows.append(_row(None, line, share)) + return rows + + +def _usable(items: list[dict]) -> list[dict]: + return [x for x in items if not x.get("밖")] + + +def fitter(master: mf.Master, key: str, items: list[dict], share: Any = 1) -> Callable: + """입력값 → `{맞음, 배}` — 맞음 = 짝 하나 이상 · 모두 품 같음 / 배 = 노무 · 기계 줄이 모두 같은 배 + (자재 · 율 줄은 같음) — 표 거꾸로 · 감률 읽기(B5)가 씀.""" + items = _usable(items) + + def fit(given: dict) -> dict: + rows = pair(hb.quantities(master, key, given), items, share) + both = [r for r in rows if r["엑셀"] and r["우리"] and r["엑셀"]["품"] and r["우리"]["품"]] + ok = bool(both) and all(r["판정"] == SAME for r in both) + labor = [r for r in both if r["엑셀"]["묶음"] in ("L", "X")] + k = None + if labor: + k = median(_d(r["엑셀"]["품"]) / _d(r["우리"]["품"]) for r in labor) + flat = all(same(r["엑셀"]["품"], _d(r["우리"]["품"]) * k) for r in labor) + rest = all(r["판정"] == SAME for r in both if r not in labor) + k = float(k) if flat and rest else None + return {"맞음": ok, "배": k} + + return fit + + +def _money(master: mf.Master, key: str, bound: dict, given: dict) -> dict: + """금액 참고 — 입력 필요가 없을 때 엔진 한 번(안 씀 · 비움 = 기본 제안 · 저장 안 함).""" + if bound["입력필요"]: + return {"계": None, "까닭": f"입력 필요 {len(bound['입력필요'])}"} + row = master.logic(key) + base = ma.defaults(master, row) + fill = {n: base.get(n) for n in [*bound["안씀"], *bound["비움"]]} + try: + got = mf.run(master, key, mf.fill_empty(row, {**fill, **given})) + except mf.FormulaError as e: + return {"계": None, "까닭": str(e)} + if "결과" in got: + return {"결과": hb.plain(got["결과"])} + return {k: hb.plain(got.get(k)) for k in ("노무비", "재료비", "경비", "계")} + + +def candidate(master: mf.Master, key: str, src: dict, items: list[dict], share: Any = 1) -> dict: + """후보 기초 하나 — 바인딩 · 줄 대조 · 판정 · 금액 참고.""" + row = master.logic(key) + bound = hb.bind(master, key, src, fitter(master, key, items, share)) + given = {**bound["입력값"], **{n: v[0]["값"] for n, v in bound["엇갈림"].items()}} + idle = {n: bound["기본"][n] for n in bound["안씀"] if bound["기본"].get(n) is not None} + rows = pair(hb.quantities(master, key, {**idle, **given}), _usable(items), share) + count = {v: sum(r["판정"] == v for r in rows) for v in (SAME, DIFF, NEED, EXCEL, OURS)} + if count[DIFF]: + verdict = DIFF + elif bound["입력필요"] or count[NEED] or bound["엇갈림"]: + verdict = NEED + else: + verdict = SAME if count[SAME] else EXCEL + return { + "키": key, + "이름": row.get("이름"), + "바인딩": bound, + "줄": rows, + "셈": count, + "판정": verdict, + "금액": _money(master, key, bound, given), + } + + +def _section(master: mf.Master, block: dict, part: dict, src: dict) -> dict: + items = part.get("항목") + items = _usable(block.get("항목") or []) if items is None else items + mine = {**src, **{k: part[k] for k in ("선택값", "상수", "식", "대입식") if k in part}} + share = part.get("몫") + share = (share.get("값") if isinstance(share, dict) else share) or 1 + cands = [candidate(master, k, mine, items, share) for k in part.get("후보") or []] + good = [c for c in cands if c["셈"][DIFF] == 0 and (c["셈"][SAME] or len(cands) == 1)] + verdict = NONE if not cands else SOLVED if len(good) == 1 else SOME + pick = good[0]["키"] if verdict == SOLVED else None + return { + "제목": part.get("제목", ""), + "판정": verdict, + "고름": pick, + "후보": cands, + "항목": items, + } + + +def block( + master: mf.Master, blk: dict, parts: list[dict], src: dict | None = None, units: Any = None +) -> dict: + """블록 풀이 — 절마다 후보 검산 · 절 판정 · 덧 줄(엑셀만 → `Units`) · 블록 판정.""" + src = {**(src or {}), "블록": blk} + secs = [_section(master, blk, p, src) for p in parts] + extra, n = [], 0 + for sec in secs: + chosen = next((c for c in sec["후보"] if c["키"] == sec["고름"]), None) + if sec["판정"] == SOME: + n += 1 + if chosen: + b = chosen["바인딩"] + n += len(b["입력필요"]) + len(b["엇갈림"]) + chosen["셈"][DIFF] + alone = [r for r in chosen["줄"] if r["판정"] == EXCEL] + lone = [x for x in sec["항목"] if any(r["엑셀"]["행"] == x.get("행") for r in alone)] + else: + lone = sec["항목"] if sec["판정"] == NONE else [] + for item in lone: + if units is None: + from M02_MasterTemplete import M02_Hopyo_Units as hu + + units = hu.Units() + link = units.line(item) + extra.append( + {"절": sec["제목"], "행": item.get("행"), "이름": item.get("이름"), **link} + ) + n += link.get("상태") != "이어짐" + for sec in secs: + sec.pop("항목") + if secs and all(s["판정"] == NONE for s in secs): + verdict = BY_LINES + elif n: + verdict = PICK + else: + verdict = BASE_PLUS if extra else BY_BASE + return {"번": blk.get("번"), "판정": verdict, "고를수": n, "절": secs, "덧": extra} + + +def _gather(nodes: list[dict], key: str) -> list: + return [x for one in nodes for x in [*one.get(key, []), *_gather(one.get("아래", []), key)]] + + +def parts(blk: dict, cands: list[Any]) -> list[dict]: + """절 나무(785-1 `Formula.works`) + 후보(키 · `Clause` 후보 {키, 절}) → 풂 절 — 절 표 있는 + 후보가 있으면 일 절마다 그 절 후보 + 절 표 없는 후보 · 없으면 블록 통째 한 절 · + 단위 줄 · 상수 · 식 · 선택값(머리 것 더함) · 몫.""" + from M02_MasterTemplete import M02_Hopyo_Formula as fo + + nodes = blk.get("절") or fo.tree(blk.get("글") or [], blk.get("글행"), blk.get("항목") or []) + top = fo.head(blk.get("글") or [], blk.get("글행")) + cands = [c if isinstance(c, dict) else {"키": c} for c in cands] + items = blk.get("항목") or [] + work = fo.works(nodes) if any(c.get("절") for c in cands) else [] + if not work: # 블록 통째 한 일(절 표 있는 후보가 없으면 한 기초가 블록 전부) + whole = {"제목": blk.get("이름", ""), "후보": [c["키"] for c in cands], "항목": None} + return [whole | {k: top[k] + _gather(nodes, k) for k in ("상수", "식", "선택값")}] + out = [] + for one in work: + mine = [c["키"] for c in cands if c.get("절") in (None, one["번"])] + at = [i for i in _gather([one], "항목") if not items[i].get("밖")] + part = {"제목": one["제목"], "번": one["번"], "후보": mine, "항목": [items[i] for i in at]} + part |= {k: top[k] + _gather([one], k) for k in ("상수", "식", "선택값")} + out.append(part | {"몫": one.get("몫")}) + return out diff --git a/resources/master_data/scripts/master_auto.py b/resources/master_data/scripts/master_auto.py index f9940947e..a4499faaa 100644 --- a/resources/master_data/scripts/master_auto.py +++ b/resources/master_data/scripts/master_auto.py @@ -88,6 +88,49 @@ def _lowest(master: mf.Master, uses: list): return (both or next((s for s in sets if s), [None]))[0] +def _first_row(master: mf.Master, uses: list): + """찾기 조건 칸의 표 첫 줄 값 — 범위 줄이면 아래 끝(열린 아래 끝 = 위 끝) · 빈 칸 줄은 건넘.""" + for ref, cond in uses: + try: + rows = master.get(ref).get("줄", []) + except mf.FormulaError: + continue + for r in rows: + got = r.get(cond) + if isinstance(got, list): + got = next((v for v in got if v is not None), None) + if got is not None: + return got + return None + + +def _holds_zero(span) -> bool: + low, high = span[0], span[1] + return (low is None or Decimal(str(low)) <= 0) and (high is None or Decimal(str(high)) >= 0) + + +def defaults(master: mf.Master, row: dict) -> dict: + """「기본」 제안(PLAN 785-2 · 639-1a) — 「기본」 칸 › 고르기 첫째 › 가드 0 › 0 을 품은 범위 0 › + 찾기 조건 표 첫 줄 › None(못 정함 = 사용자 입력). 1 을 지어 넣지 않음 · `values` 와 따로.""" + specs = mf.inputs_of(row) if isinstance(row.get("흐름"), dict) else row.get("입력", []) + finds = {} if isinstance(row.get("흐름"), dict) else _uses(row)[0] + guard = guards(row) + out = {} + for spec in specs: + name, span = spec["이름"], spec.get("범위") + if spec.get("기본") not in (None, ""): + out[name] = spec["기본"] + elif spec.get("고르기"): + out[name] = spec["고르기"][0] + elif name in guard: + out[name] = Decimal(0) + elif span and _holds_zero(span): + out[name] = Decimal(0) + else: + out[name] = _first_row(master, finds.get(name, [])) + return out + + def values(master: mf.Master, row: dict) -> dict: """로직 줄 하나의 입력마다 자동값 · 흐름 로직은 「설계 때 정할 것」 예시값.""" if isinstance(row.get("흐름"), dict): diff --git a/resources/tester/test_642_2_bind_solve.py b/resources/tester/test_642_2_bind_solve.py new file mode 100644 index 000000000..af46b81fa --- /dev/null +++ b/resources/tester/test_642_2_bind_solve.py @@ -0,0 +1,194 @@ +# -*- coding: utf-8 -*- +"""642-2(PLAN 785-2) 옵션값 읽기 · 검산 · 판정 — 북부청 임도 예시 1 · 2 · 3 + 단산46 · 39 기대표 +(설계 642-A 2-10 · 5-3) · 출처 꼬리표 · 지어 넣은 값 0 · 입력 필요 · 품 같음 · master_auto.defaults. +읽기만(엑셀 · M01) · storage 안 씀.""" + +from decimal import Decimal +from pathlib import Path + +import pytest + +from M02_MasterTemplete import M02_Hopyo_Bind as hb +from M02_MasterTemplete import M02_Hopyo_Excel as hx +from M02_MasterTemplete import M02_Hopyo_Solve as hs + +NORTH = ( + Path(__file__).resolve().parents[1] + / "knowledge/original/실무문서/2026_실무일위대가 모음" + / "2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls" +) +PATHS = {"B1", "B2", "B3", "B4", "B5", "B6"} +# 후보 = 785-1 Clause 몫(조항 색인) — 여기선 설계 2-10 후보 열 그대로 +CANDS = { + 10: ["GC001140", "GC001141", "GC001142"], + 24: ["GC000211"], + 46: ["GC000198"], + 47: [{"키": "GC000206", "절": "1"}, {"키": "GC000207", "절": "2"}], + 39: ["GF000224", "GF000225"], +} + + +@pytest.fixture(scope="module") +def master(): + return hb.m01.cm.master() + + +@pytest.fixture(scope="module") +def book(): + return hx.read_book(NORTH.name, NORTH.read_bytes()) + + +@pytest.fixture(scope="module") +def solved(master, book): + blocks = {b["번"]: b for b in book["단산"]} + out = {} + for no, cands in CANDS.items(): + hopyo = next( + h for h in book["호표"] if any((x.get("참조") or {}).get("번") == no for x in h["줄"]) + ) + line = next(x for x in hopyo["줄"] if (x.get("참조") or {}).get("번") == no) + same = [x for x in hopyo["줄"] if x is not line] + blk = blocks[no] + out[no] = hs.block(master, blk, hs.parts(blk, cands), {"줄": line, "같은줄": same}) + return out + + +def _cand(solved, no, key, sec=0): + return next(c for c in solved[no]["절"][sec]["후보"] if c["키"] == key) + + +def _where(cand): + return {k: v["길"] for k, v in cand["바인딩"]["출처"].items()} + + +def test_예시1_단산10_소운반(solved): + got = solved[10] + assert got["판정"] == "기초로 풂" and got["고를수"] == 0 + assert got["절"][0]["고름"] == "GC001140" + c = _cand(solved, 10, "GC001140") + b = c["바인딩"] + assert b["입력값"] == { + "운반무게": 575, + "회당운반량": 25, + "운반거리": 6, + "경사각도": 35, + "운반회수": 42, + } + assert _where(c) == { + "운반무게": "B4", + "회당운반량": "B4", + "운반거리": "B3", + "경사각도": "B3", + "운반회수": "B3", + } + assert b["입력필요"] == [] and b["엇갈림"] == {} + assert set(b["안씀"]) == {"재료종별", "재료형상", "재료갈래", "운반로", "실작업시간"} + (row,) = c["줄"] + assert row["판정"] == "품 같음" + assert Decimal(str(row["우리"]["품"])).quantize(Decimal("0.000001")) == Decimal("0.547619") + assert round(c["금액"]["계"], 1) == 94227.7 + + +def test_예시2_단산47_절_둘(solved): + got = solved[47] + one, two = got["절"] + assert (one["고름"], two["고름"]) == ("GC000206", "GC000207") + a = _cand(solved, 47, "GC000206", 0)["바인딩"] + assert a["입력값"] == {"Type": "Type-Ⅰ"} and a["출처"]["Type"]["길"] == "B1" + c = _cand(solved, 47, "GC000207", 1) + b = c["바인딩"] + assert b["입력값"] == {"Type": "Type-Ⅰ", "호": "Ⅰ-1", "소량감률": 50} + assert _where(c) == {"Type": "B1", "호": "B5", "소량감률": "B5"} + assert [x["값"] for x in b["엇갈림"]["구분"]] == ["토목", "건축"] + assert b["기본"]["구분"] == "토목" + assert [r["판정"] for r in c["줄"]] == ["품 같음"] * 4 + assert got["판정"] == "고를 것" and got["고를수"] == 1 # 구분 하나만 사용자 + + +def test_예시3_단산24_합판거푸집(solved, master, book): + c = _cand(solved, 24, "GC000211") + b = c["바인딩"] + assert b["입력값"] == { + "사용횟수": "4회", + "유형": "보통", + "지붕슬래브": "아님", + "폼타이": "아님", + } + assert _where(c) == {"사용횟수": "B1", "유형": "B2", "지붕슬래브": "B2", "폼타이": "B2"} + assert b["입력필요"] == ["높이감률", "구멍땜"] + assert b["기본"]["높이감률"] is None and b["기본"]["구멍땜"] is None # 규칙에 맞는 칸 없음 + assert set(b["안씀"]) == {"폼타이규격", "측압"} and b["비움"] == ["자재지역"] + assert solved[24]["판정"] == "고를 것" and solved[24]["고를수"] == 2 + # 사용자가 0 넣음 → 노무 줄 품 같음 · 노무비 38,291 = 엑셀 + given = {**b["입력값"], "높이감률": 0, "구멍땜": 0} + items = [x for x in next(b for b in book["단산"] if b["번"] == 24)["항목"] if not x.get("밖")] + rows = hs.pair(hb.quantities(master, "GC000211", given), items) + assert {r["판정"] for r in rows if r["엑셀"]} == {"품 같음"} + fill = {n: b["기본"][n] for n in b["안씀"]} | {"자재지역": None} + money = hb.mf.run(master, "GC000211", {**fill, **given}) + assert round(money["노무비"]) == 38291 + + +def test_단산46_레미콘타설(solved): + got = solved[46] + c = _cand(solved, 46, "GC000198") + b = c["바인딩"] + assert b["입력값"] == {"타설방법": "장비사용타설", "구조물": "철근구조물", "소량감률": 50} + assert _where(c) == {"타설방법": "B1", "구조물": "B1", "소량감률": "B5"} + assert b["입력필요"] == ["굴착기"] and b["비움"] == ["지역"] # 굴착기 규격 = B3 · 사용자 + mach = next(r for r in c["줄"] if r["엑셀"] and r["엑셀"]["묶음"] == "X") + assert mach["판정"] == "품 같음" and mach["표지"] == "기계 갈래 엇갈림" + assert [d["이름"] for d in got["덧"]] == ["레미콘"] # 별도계상 = 덧 줄 + + +def test_단산39_찰붙임(solved): + sec = solved[39]["절"][0] + assert sec["고름"] == "GF000225" and solved[39]["판정"] == "기초로 풂" + c = _cand(solved, 39, "GF000225") + assert c["바인딩"]["입력값"] == { + "뒷길이": 35, + "굵은골재최대치수": 25, + "강도": 21, + "슬럼프": "120", + "채움콘크리트량": 0.12, + "보정작업": "해당 없음", + } + assert _where(c)["뒷길이"] == "B1" and _where(c)["채움콘크리트량"] == "B6" + assert _cand(solved, 39, "GF000224")["판정"] == "품 다름" + + +def test_지어_넣은_값_없음(solved): + for got in solved.values(): + for sec in got["절"]: + for c in sec["후보"]: + b = c["바인딩"] + assert set(b["입력값"]) == set(b["출처"]) # 출처 없는 값 0 + assert {v["길"] for v in b["출처"].values()} <= PATHS + + +@pytest.mark.parametrize( + "excel, ours, want", + [ + (0.67, 0.6667, True), + (1.18, 1.1765, True), + (3.53, 3.5294, True), + (0.1111111111, 0.11111, True), + (0.67, 0.69, False), + (0.547619, 0.5476190476, True), + (None, 1, False), + ], +) +def test_품_같음(excel, ours, want): + assert hs.same(excel, ours) is want + + +def test_defaults_규칙(master): + ma = hb.ma + jige = ma.defaults(master, master.logic("GC001140")) + assert jige["실작업시간"] == 0 and jige["운반무게"] == 0 # 가드 + assert jige["경사각도"] == 0 and jige["운반거리"] is None # 고르기 첫째 · 못 정함 + pour = ma.defaults(master, master.logic("GC000198")) + assert pour["소량감률"] == 0 and pour["타설방법"] == "인력운반타설" # 0 을 품은 범위 + form = ma.defaults(master, master.logic("GC000211")) + assert form["높이감률"] is None and form["구멍땜"] is None + assert 1 not in [v for v in jige.values() if not isinstance(v, str)] # 지어 넣는 1 없음 diff --git a/tools/stmate-test/README.md b/tools/stmate-test/README.md new file mode 100644 index 000000000..1fc154232 --- /dev/null +++ b/tools/stmate-test/README.md @@ -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) 결과 기반이므로, 원본 실행 파일의 모든 예외 분기와 수치 동등성을 보증하지 않습니다. diff --git a/tools/stmate-test/app.js b/tools/stmate-test/app.js new file mode 100644 index 000000000..5d6e7d200 --- /dev/null +++ b/tools/stmate-test/app.js @@ -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]) => + `
ST_Sys.CPM · INITIAL)STMATE는 RXNM_에 저장된 식(1/8*16/12*25/20)을 왼쪽부터 차례로 평가합니다. 계수를 미리 곱하면 부동소수점 오차로 55,699원이 되어 1원 오차가 발생합니다(18번 문서 §2.1).
COSTN.DBF · JMKN1..6)0이 아닌 후보 단가 중 최저가 자동채택(실무 76/76건 일치, 17번 문서 §5.5) 또는 특정 가격지 슬롯 수동고정을 실험합니다.
+| 코드 | 품명 / 규격 | 단위 | 비목 | 후보1(조달) | 후보2(물자) | 후보3(물정) | 후보4(거래) | 후보5(견적) | 채택 규칙 | 시간환산 | 최종 채택단가(6칸) | 관리 |
|---|
X 코드 · 중기사용료 시트)중기 한 대를 참조할 때 노무비(&X..L&), 재료비(&X..M&), 경비(&X..S&) 세 성분을 각각 원 단위 절사하여 산출합니다(17번 문서 §3).
| 코드 | 기계명 | 규격 | 운전원(L) | 주연료(M) | 연료량(L/hr) | 잡품률(%) | 취득가격(원) | 시간손료계수 | 재료비(M) | 노무비(L) | 경비(S) | 시간당합계(T) | 관리 |
|---|
D 코드 · ST_SanCal.CPM · COSTN.SMT)작업량 Q를 소수 2자리로 먼저 확정한 뒤, 중기 시간당 성분단가를 Q로 나누어 소수 1자리 절사합니다. 내역서 수록 시에는 정수 절사값이 전달됩니다(18번 문서 §2.2).
| 코드 | 단가산출 공종명 | 참조 중기(&X..&) | Q 산출식 유형 | q(용량) | K(계수) | f(토량) | E(효율) | Cm(싸이클) | 작업량 Q(㎥/hr) | 재료비/Q | 노무비/Q | 경비/Q | 호표소계(소수1) | 관리 |
|---|
| 참조 자원 / 단가산출 | 원수량(QTY1) | 역수(DEVD1) | 할증(X_HAL %) | 적용수량 | 재료비 | 노무비 | 경비 | 행 합계 | 관리 |
|---|
ST_EdiLWi.CPM · BDQTY.DBF)구성 자원의 수량 할증(X_HAL) 및 나눗셈 수량 역수 변환(DEVD1: 1/QTY1, 16단계·30단계 REA 메모)을 적용하고, 호표 소계에서 각 성분을 원 단위로 절사하여 TAMT = MAMT + LAMT + SAMT 불변식을 유지합니다.
ST_Func_C.CPM :: OPTIMIZ 차액 보정 제어14·15단계 REA 분석에서 확인된 CHNNM(비목별 금액 우선 vs 총액 우선) 및 OPTNM(SML 차액 보정 순서) 동작을 검증합니다. 또한 C* 제외금액 구역(관급자재대)은 직접공사비 합산에서 분리됩니다(16번 문서 §4.2).
코드(BDCODE1) | 공종명 | 연결 단가 | 구역 구분 | 설계수량(QTY1) | 합계단가 | 재료비(M_AMT1) | 노무비(L_AMT1) | 경비(S_AMT1) | 내역 합계(T_AMT1) | 집계표(사사오입) | 관리 |
|---|---|---|---|---|---|---|---|---|---|---|---|
직접공사비 소계 (C* 제외금액 구역 불포함) |
+ ${fmt(boq.directM, 0)} | +${fmt(boq.directL, 0)} | +${fmt(boq.directS, 0)} | +${fmt(boq.directTotal, 0)} | +관급원자재: ${fmt(boq.govRawTotal, 0)} / 수수료: ${fmt(boq.govFeeTotal, 0)} | +||||||
${esc(it.code)}ST_KanJub.CPM :: Calc_Kanjub)원가계산 형식(T/S/TA/SA)을 바꾸면 기타경비·환경보전비·일반관리비·이윤의 밑수 공식 자체가 전환됩니다(12번 문서 §7). 또한 도급공사비 절사 차액을 세전 환산(ROUND(차액/1.1))하여 이윤에서 빼는 끝전조정을 검증합니다(16번 문서 §4.4).
| 저장키 | 원가계산 항목 | 산정기초 (밑수 공식) | 밑수 금액 (D_) | 적용 요율 (R_ %) | 산출 확정액 (S_) |
|---|---|---|---|---|---|
DAMT_M/L/S | 직접공사비 합계 | 직접재료비 ${fmt(k.dM, 0)} + 직접노무비 ${fmt(k.dL, 0)} + 산출경비 ${fmt(k.dS, 0)} | ${fmt(k.dT, 0)} | — | ${fmt(k.dT, 0)} |
PURE_COST | 순공사원가 | 재료비 + 노무비(직노+간노) + 경비(산출+제경비) | ${fmt(k.pureConstructionCost, 0)} | — | ${fmt(k.pureConstructionCost, 0)} |
ILBAN | 일반관리비 | ${esc(k.ilbanDesc)} | ${fmt(k.ilbanBase, 0)} | ${fmt(k.ilbanAmt, 0)} | |
IYUN | 이윤 (끝전보정 후) | ${esc(k.iyunDesc)} 보정전 산식이윤 ${fmt(k.iyunRaw, 0)} − 세전끝전조정 ${fmt(k.pretaxProfitAdj, 0)} (차액 ${fmt(k.diffTotal, 0)}/1.1 반올림) | ${fmt(k.iyunBase, 0)} | ${fmt(k.iyunFinal, 0)} | |
SUPPLY | 총원가 (공급가액) | 순공사원가 + 일반관리비 + 최종이윤 (보정전: ${fmt(k.supplyBeforeAdj, 0)}) | ${fmt(k.supplyFinal, 0)} | — | ${fmt(k.supplyFinal, 0)} |
BUGA | 부가가치세 | 과세표준 × 세율 (보정전 도급액: ${fmt(k.contractBeforeAdj, 0)}) | ${fmt(k.supplyFinal, 0)} | 10% | ${fmt(k.vatFinal, 0)} |
CONTRACT | 도급공사비 | 공급가액 + 부가가치세 (끝전 절사 완료) | ${fmt(k.contractBeforeAdj, 0)} | — | ${fmt(k.contractFinal, 0)} |
KWANGUP | 관급자재대 | 관급원자재(${fmt(k.govRawTotal, 0)}) + 조달수수료(${fmt(k.govFeeTotal, 0)}) 끝전 처리 | ${fmt(k.govRawTotal + k.govFeeTotal, 0)} | — | ${fmt(k.govFinal, 0)} |
GRAND_TOT | 총공사비 | 도급공사비 + 관급자재대 | — | — | ${fmt(k.grandTotal, 0)} 원 |
${esc(b.code)}${esc(it.code)}cBx_CH_BO · 08번 문서 §2 · 18단계 REA)실무 변경설계에서는 직접노무비가 늘어나도 건강·장기요양·연금보험료 등 사후정산 항목은 당초액을 그대로 유지(carry_original)하고, 나머지는 재계산하거나 방법 A/B 공식을 적용합니다.
| 코드 | 내역 공종명 | 당초 수량 | 변경 수량 (조작) | 당초 금액 | 변경 금액 | 증감 차액 |
|---|---|---|---|---|---|---|
| 직접공사비 당초 / 변경 / 증감 합계 | ${fmt(boqOrig.directTotal, 0)} | ${fmt(boqRev.directTotal, 0)} | ${boqRev.directTotal - boqOrig.directTotal >= 0 ? "+" : ""}${fmt(boqRev.directTotal - boqOrig.directTotal, 0)} | |||
| 키 | 제잡비 항목 | 변경설계 계산 모드 선택 | 당초 금액 (C_AMT_B) | 변경 금액 | 증감 차액 |
|---|---|---|---|---|---|
| 총공사비 (도급 + 관급) 당초 / 변경 / 증감 | ${fmt(kOrig.grandTotal, 0)} | ${fmt(kRev.grandTotal, 0)} | ${kRev.grandTotal - kOrig.grandTotal >= 0 ? "+" : ""}${fmt(kRev.grandTotal - kOrig.grandTotal, 0)} | ||
K_ORD 등록·차수내리기 전이 규칙 시뮬레이터 (STMATE_REA_최종보고서 §4.1)Main 0x559228(NewProject) 및 0x55a640(MkBoq)에서 관측된 PCLASS · 등록모드 · 플래그 조합에 따른 K_ORD 저장식과 차수내리기(K - (K>5 ? K-4 : 1)) 동작을 확인합니다.
+ 기초자원 후보단가·노임환산(`ST_Sys`) → 중기·Q식 단가산출(`ST_SanCal`) → + 일위대가(`ST_EdiLWi`) → 내역·OPTIMIZ(`ST_Func_C`) → 제잡비 4형식·끝전조정(`ST_KanJub`) → + 변경설계 차액 +
+