"""642-3 1차 줄 판정 · 엑셀 사전 · 유가전력 MO 지킴 시험(PLAN 785-3 · 설계 642-A 5-3 셋째 줄). - 북부청 임도 415 줄 1차 갈래 수 = 단산 268 · 일위 1 · 별도계상 64 · 재료(사전) 78 · 기계 3 · 율 1 (분석 642-C 3-1) · 판정 2차 필요 268 · 같은 EST 꼴 사방 201 · 거창 68 = 모든 줄 갈래 하나(100 %) - 엑셀 사전 = 단가표 시트 갈래 수 · 노임 19 M01 LB 하나씩 · 경유 → MO - 장부 짝 고른 것 꼴(새) · 옛 꼴 그대로 · 틀린 키 400 · 환산 옮김(Match.factor = Row.factor) - 유가전력 MO — 고정 줄 이음 셈(재료비 = MO 값 · 묶음 재료 → 율 밑) · 자재 목록 MO · TS 짝 거울 엑셀은 읽기만 · 층 · storage 안 씀. """ import collections import json import os import subprocess from pathlib import Path import pytest from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Hopyo_Elem as elem from M02_MasterTemplete import M02_Hopyo_Excel as ex from M02_MasterTemplete import M02_Hopyo_Ledger as ledger from M02_MasterTemplete import M02_Hopyo_Match as hm from M02_MasterTemplete import M02_Hopyo_Row as row from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Labor_Fixed as fixed HERE = Path(__file__).resolve().parent DOCS = HERE.parent / "knowledge" / "original" / "실무문서" BUKBU = DOCS / "2026_실무일위대가 모음" / "2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls" SABANG = DOCS / "2026_실무일위대가 모음" / "2026년_북부지방산림청_사방사업(계류+댐)_설계내역서.xls" GEOCHANG = ( DOCS / "01.산불거창(거창북상병곡)" / "01.내역서" / "00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx" ) OIL = "MO000002" # 자동차용경유 전국평균 def _read(path: Path, keys: bool) -> dict: data = path.read_bytes() book = ex.read_book(path.name, data) sheets, _ = ex._open(path.name, data, row.SHEETS) return {**book, "사전": row.lexicon(sheets, keys)} def _kinds(book: dict) -> collections.Counter: """줄마다 (갈래, 근거 첫 낱말) — 1차 ①~③ · ④ 사전.""" index = row._index(book["사전"]) out = collections.Counter() for h in book["호표"]: for line in h["줄"]: kind, why, _ = row.first(line) if kind is None: hits = row.second(line, index) kinds = sorted({x["갈래"] for x in hits}) or ["없음"] kind, why = "/".join(kinds), "사전" out[(kind, why.split(" ")[0])] += 1 return out @pytest.fixture(scope="module") def bukbu() -> dict: return _read(BUKBU, True) def test_북부청_415_줄_1차_갈래(bukbu): got = _kinds(bukbu) assert sum(got.values()) == 415 assert got == { ("단가산출", "비고"): 268, ("일위대가", "비고"): 1, ("재료", "별도계상"): 64, ("재료", "사전"): 78, ("기계", "단위"): 3, ("율", "단위"): 1, } def test_북부청_줄_판_판정과_후보(bukbu): out = row.rows(bukbu) lines = [x for v in out.values() for x in v] assert len(out) == 68 and len(lines) == 415 verdict = collections.Counter(x["판정"] for x in lines) assert verdict["2차 필요"] == 268 and set(verdict) <= set(row.VERDICTS) assert all(set(x["갈래"]) <= set(row.KINDS) and x["후보"] is not None for x in lines) first = {x["행"]: x for x in out[1]} # 제1호표(설계 2-10 기대표) assert first[6]["갈래"] == ["재료"] and first[6]["근거"] == "별도계상" assert first[6]["판정"] == "제안 있음" and len(first[6]["후보"]) == 1 apart = [x for x in lines if x["근거"] == "별도계상"] # 786-1 별도계상 64 줄 = 고정 줄 하나 assert len(apart) == 64 and all( x["갈래"] == ["재료"] and x["판정"] == "제안 있음" for x in apart ) for x in apart: (p,) = x["후보"] assert (p["갈래"], p["키"], p["별도계상"], p["품목"]) == ( "고정", None, True, "프로젝트에서", ) assert p["단위"] and p["단가"] == {"노무비": 0, "재료비": 0, "경비": 0} and "장부" not in x assert all( p["갈래"] != "고정" or "품목" not in p for x in lines for p in x["후보"] if x not in apart ) assert first[7]["번"] == 46 and first[7]["판정"] == "2차 필요" assert first[10]["갈래"] == ["재료"] and first[10]["근거"].startswith("사전 자재단가") rate = next(x for x in lines if x["갈래"] == ["율"]) assert rate["판정"] == "제안 있음" assert ( rate["후보"][0]["비목"] == "재료비" and rate["후보"][0]["기준"] == "재료비" ) # 재료비의 3 % sub = next(x for x in lines if x["갈래"] == ["일위대가"]) assert sub["번"] == 46 and sub["후보"][0]["키"] == "제46호표" and sub["판정"] == "제안 있음" for x in lines: # 고른 것 꼴(설계 2-1) for p in x["후보"]: assert {"갈래", "키", "이름", "단위", "입력값", "출처", "몫"} <= set(p) def test_엑셀_사전_단가표_시트(bukbu): lex = bukbu["사전"] assert lex["꼴"] == "EST" count = collections.Counter(x["갈래"] for x in lex["줄"]) assert count == { "단가산출": 95, "재료": 91, "일위대가": 68, "기계": 32, "인력": 19, "유가": 4, # 자재단가 경유 · 휘발유 + 기본재료비 둘 "환율": 3, } people = [x for x in lex["줄"] if x["갈래"] == "인력"] assert all(len(x["후보"]) == 1 and x["후보"][0]["키"].startswith("LB") for x in people) oil = next(x for x in lex["줄"] if x["갈래"] == "유가" and x["시트"] == "자재단가") assert oil["후보"][0]["키"] == OIL json.dumps(lex, ensure_ascii=False) # HP 에 둘 수 있는 꼴 @pytest.mark.parametrize("path, total", [(SABANG, 201), (GEOCHANG, 68)]) def test_같은_EST_꼴_모든_줄_갈래_하나(path, total): got = _kinds(_read(path, False)) assert sum(got.values()) == total assert not [k for k in got if "/" in k[0] or k[0] == "없음"] # ── 장부 짝 고른 것 꼴 ───────────────────────────────────────────────────── def test_장부_짝_고른_것_꼴과_옛_꼴(): known = ledger._known() mat = sorted(known[1])[0] new = ledger.check_pair( {"갈래": "유가", "키": OIL, "배": 1, "이름": "경유", "단위": "L"}, "시험", known ) assert new == {"갈래": "유가", "키": OIL, "입력값": {}, "배": 1, "몫": None, "이름": "경유", "단위": "L"} # fmt: skip rate = {"갈래": "율", "비목": "재료비", "기준": "재료", "배": 1} assert ledger.check_pair(rate, "시험", known)["기준"] == "재료" assert ledger.check_pair({"자재": mat, "배": 2}, "시험", known) == { "자재": mat, "입력값": {}, "배": 2, "몫": None} # fmt: skip for bad in ( {"갈래": "유가", "키": "MT000008"}, {"갈래": "고정", "키": "MO000002"}, {"갈래": "인력", "키": "LB999999"}, {"갈래": "재료", "키": mat, "입력값": {"x": 1}}, {"갈래": "율", "비목": "재료비"}, {"갈래": "없는갈래"}, ): with pytest.raises(ledger.store.StoreError) as got: ledger.check_pair(bad, "시험", known) assert got.value.status == 400 line = {"이름": "경유", "규격": "", "단위": "ℓ", "수량": 3, "단가": {"재료비": 1376}} book = [("personal", "1/2", {ledger.key(line): {"갈래": "짝", "짝": [new]}})] out = row.judge(line, {}, {}, book, folder="x") assert out["판정"] == "제안 있음" and out["후보"][0]["키"] == OIL assert out["후보"][0]["출처"] == {"장부": "personal"} # 단산 줄 = 장부 짝이 있어도 늘 2차 블록 풀이(장부 = 1차 후보 힌트만 · 785-7) sk = { "이름": "야면석소운반", "규격": "", "단위": "㎡", "수량": 1, "참조": {"종류": "단산", "번": 10}, } base = {"갈래": "기초", "키": "GC001140", "입력값": {}, "배": 1, "몫": None} book = [("personal", "1/2", {ledger.key(sk): {"갈래": "짝", "짝": [base]}})] out = row.judge(sk, {}, {}, book, folder="x") assert out["판정"] == "2차 필요" and out["후보"][0]["키"] == "GC001140" def test_환산_옮김(): for have, want, spec in ( ("㎡", "㎡", ""), ("cm", "m", ""), ("㎡", "㎥", "T=20cm"), ("개", "㎥", ""), ): assert hm.factor(have, want, spec) == row.factor(have, want, spec) assert row.factor("cm", "m")["배"] == 0.01 q = row.quantity({"수량": 2.5, "단위": "㎡", "규격": "T=20cm"}, "㎥", 0.5) assert q["배"] == 0.2 and q["우리"] == 0.25 # ── 유가전력 MO ───────────────────────────────────────────────────────── def _oil_price() -> float: return float(m01.loaded()[1].get(OIL)["값"]) def test_유가_후보(): keys = [x["키"] for x in elem.oils("경유")] assert keys[0] == OIL and all(k.startswith("MO") for k in keys) assert elem.lost({"갈래": "유가", "이름": "경유"})[0]["키"] == OIL OIL_DOC = { "열": "rv", "담은로직": [ {"로직": "GC000421", "수량": {"손": 2}}, { "로직": "", "고정": { "이름": "경유", "단위": "L", "단가": {"재료비": 1376}, "이음": {"유가": OIL}, }, # fmt: skip "수량": {"손": 3}, }, { "로직": "", "고정": { "이름": "잡재료", "단위": "%", "단가": {}, "비율": {"기준": "재료", "비목": "재료비"}, }, # fmt: skip "수량": {"손": 10}, }, ], "자재": [{"코드": OIL, "수량": {"손": 2}, "별도계상": "직접계산"}], # 기본 = 별도계상(합 밖) } def test_고정_줄_유가_이음_셈(): doc = labor.clean(OIL_DOC) fx = doc["담은로직"][1]["고정"] assert fx["이음"] == {"노임": None, "자재": None, "기계": None, "유가": OIL} assert fixed.group_of(fx) == "재료" assert doc["자재"][0]["코드"] == OIL # 자재 목록 MO out = labor.calc(doc, {}) line = out["줄"][1] assert line["기준"]["재료비"] == pytest.approx(_oil_price()) and line["이은"] == ["재료비"] assert out["자재"][0]["기준단가"] == pytest.approx(_oil_price()) with pytest.raises(labor.store.StoreError): # 머리 틀림 = 400 labor.clean({**OIL_DOC, "담은로직": [{**OIL_DOC["담은로직"][1], "고정": { **OIL_DOC["담은로직"][1]["고정"], "이음": {"유가": "MT000008"}}}]}) # fmt: skip old = labor.clean({"담은로직": [{"로직": "", "고정": {"이름": "a", "단가": {}}}]}) assert old["담은로직"][0]["고정"]["이음"] == {"노임": None, "자재": None, "기계": None} def _ts(answer: dict, doc: dict, values: dict) -> dict: proc = subprocess.run( ["node", os.path.join(HERE, "helper_m02_labor_calc.cjs")], input=json.dumps({"답": answer, "문서": doc, "값": values}, ensure_ascii=False), capture_output=True, text=True, encoding="utf-8", check=True, ) return json.loads(proc.stdout) def _same(a, b) -> None: if isinstance(a, dict): assert set(a) == set(b) for k in a: _same(a[k], b[k]) elif isinstance(a, list): assert len(a) == len(b) for x, y in zip(a, b): _same(x, y) elif isinstance(a, (int, float)) and not isinstance(a, bool): assert b == pytest.approx(a, rel=1e-9) else: assert a == b def test_유가_율_고정_줄_TS_거울(): doc = labor.clean(OIL_DOC) answer = labor.calc(doc, {}) py = labor.settle(answer, doc, {}) _same(py, _ts(answer, doc, {})) rate = py["줄"][2] oil = py["줄"][1]["반영"]["재료비"] mats = py["자재"][0]["반영"] or 0 assert rate["기준"]["재료비"] == pytest.approx(oil + mats) # 묶음 재료 = 유가 줄 + 자재