"""145-1 B09 단가산출근거 · 목록표 — 단가산출 줄 → 산근 블록 · 엑셀 검산 · 같은 산근 하나 · 빈 표. - 산근 = 155-4 갈래(사용자 뜻 · 145 정함 뒤집음) — 일위대가 로직 줄 중 단가산출 줄(승격 아님) + 사전조합 안 단가산출 줄 · 일위대가화(승격 · 옛 SP) 줄 자체는 산근 아님(대가 블록) - 줄 금액 = ROUNDDOWN(B09 굳힌 단가 × 1 단위 수량, 1) · 총계 = 비목마다 ROUNDDOWN 0 — 엑셀 단가산출근거 제 6호표(뒷채움) 줄 값 · 총계 8,328(6,878 · 538 · 912)과 같음 - 같은 로직 + 같은 입력값 = 산근 하나(쓰는 구조물이 늘어남) · 입력이 다르면 둘 · 번호 = 첫 등장 - 사본 없으면 빈 표 + 「연결 없음」 · 조합 밖 토공 · 운반 산근 안내 한 줄 · 작업량 Q = ROUND 2 """ import asyncio import json import sys from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from B09_Estimation import B09_Estimation_Prices as prices # noqa: E402 from B09_Estimation import B09_Estimation_Router as router_mod # noqa: E402 from B09_Estimation import B09_Estimation_Sangeun as sg # noqa: E402 from B09_Estimation.B09_Estimation_Calc_Base import compute_base # noqa: E402 from B09_Estimation.B09_Estimation_Linked import used_keys # noqa: E402 from M02_MasterTemplete import M02_Item_Prices as item_prices # noqa: E402 STONE = "GF000219" # 돌쌓기 메쌓기(인력) — 석공 0.195 · 보통인부 0.156 / ㎡ NET = "GC000337" # 낙석방지망 — 노무 · 크레인(중기) · 자재 셋 NET_IN = {"구분": "기계식", "방지망": "포켓식", "지역": "전국평균", "크레인": "2104-0010"} SP_REF = {"층": "system", "주인": None, "키": "SP-000001"} SC_REF = {"층": "system", "주인": None, "키": "SC-000001"} def _snapshot(folder: Path, rows, values=None, copies=None) -> Path: folder.mkdir(parents=True, exist_ok=True) labor = { "줄": {"종류": "일위대가", "담은로직": rows}, **({"참조사본": copies} if copies else {}), } files = { "단가.json": item_prices.to_json(item_prices.collect([STONE, NET])), "일위대가.json": labor, "입력값.json": {k: {n: {"값": v} for n, v in d.items()} for k, d in (values or {}).items()}, } for name, data in files.items(): (folder / name).write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") return folder def _sheets(*folders: Path) -> dict: keys = [k for f in folders for k in used_keys(f)] return compute_base(prices.build_inputs(None, list(dict.fromkeys(keys))), {}) def test_단가산출_줄만_산근_블록_단가는_B09_표(tmp_path): a = _snapshot(tmp_path / "a", [{"로직": STONE}, {"로직": NET, "승격": True}], {NET: NET_IN}) sheets = _sheets(a) got = sg.collect([("s1", a)], sheets) (block,) = got["sg"] assert (block["no"], block["logic"], block["unit"]) == (1, STONE, "㎡") labor = {r["key"]: r["price"] for r in sheets["labor"]} lines = block["lines"] assert [(ln["kind"], ln["ref"]["key"], ln["qty"]) for ln in lines] == [ ("labor", "LB000033", 0.195), ("labor", "LB000002", 0.156), ] want = [sg.rounddown(labor[ln["ref"]["key"]] * ln["qty"], 1) for ln in lines] assert [ln["labor"] for ln in lines] == want assert block["labor"] == sg.rounddown(sum(want), 0) and block["sum"] == block["labor"] assert block["material"] == 0 and block["expense"] == 0 and block["why"] is None assert got["sg_list"][0]["key"] == block["key"] and got["sg_gaps"] == [] assert sg.price_of(got, block["key"]) == { "no": 1, "labor": block["labor"], "material": 0.0, "expense": 0.0, "sum": block["sum"], } # fmt: skip def test_중기는_중기목록표_노_재_경_자재는_적용_단가(tmp_path): a = _snapshot(tmp_path / "a", [{"로직": NET}], {NET: NET_IN}) sheets = _sheets(a) (block,) = sg.collect([("s1", a)], sheets)["sg"] kinds = [ln["kind"] for ln in block["lines"]] assert kinds == ["labor", "labor", "machine", "material", "material", "material"] crane = block["lines"][2] row = next(r for r in sheets["machine"] if r["key"] == crane["ref"]["key"]) for part in sg.PARTS: assert crane[part] == sg.rounddown(row[part] * crane["qty"], 1) net = block["lines"][3] applied = next(r["applied"] for r in sheets["compare"] if r["key"] == net["ref"]["key"]) assert net["material"] == sg.rounddown(applied * net["qty"], 1) assert block["sum"] == block["labor"] + block["material"] + block["expense"] def test_엑셀_6호표_뒷채움_검산(): """단가산출근거 C285 = 8,328(6,878 · 538 · 912) — 보통인부 0.018 × 165,545 · 굴삭기(0.2m3) 0.07 × 중기목록표 55,700 · 7,695 · 13,041(줄 2,979.8 · 3,899.0 · 538.6 · 912.8 · 계 8,330.2 → 비목마다 버림).""" zero = dict.fromkeys(sg.PARTS, 0.0) labor = {**zero, "labor": sg._money(165545, 0.018)} machine = {p: sg._money(v, 0.07) for p, v in zip(sg.PARTS, (55700, 7695, 13041))} assert labor["labor"] == 2979.8 assert (machine["labor"], machine["material"], machine["expense"]) == (3899.0, 538.6, 912.8) assert sg.block_totals([labor, machine]) == { "labor": 6878, "material": 538, "expense": 912, "sum": 8328, } # fmt: skip assert sg.block_totals([labor, {**machine, "material": None}])["sum"] is None # 빈칸 = 빈칸 def test_같은_로직_같은_입력은_하나_다르면_둘_사전조합_안_줄도_같은_산근(tmp_path): a = _snapshot(tmp_path / "a", [{"로직": NET}], {NET: NET_IN}) b = _snapshot(tmp_path / "b", [{"로직": NET}], {NET: {**NET_IN, "": ""}}) other = {**NET_IN, "방지망": "비포켓식"} c = _snapshot(tmp_path / "c", [{"로직": NET}], {NET: other}) sc = {"이름": "사전조합", "담은로직": [{"로직": NET, "입력값": NET_IN}]} sp = {"담은로직": [{"로직": NET, "입력값": other}], "배": 1} # 옛 SP = 일위대가화 = 산근 아님 d = _snapshot( tmp_path / "d", [{"로직": "", "참조": SC_REF}, {"로직": "", "참조": SP_REF}, {"로직": STONE}], copies={"참조|system||SC-000001": sc, "참조|system||SP-000001": sp}, ) got = sg.collect([("s1", a), ("s2", b), ("s3", c), ("s4", d)], _sheets(a, c, d)) blocks = got["sg"] assert [(x["no"], x["logic"], x["uses"]) for x in blocks] == [ ( 1, NET, ["s1", "s2", "s4"], ), # 빈 입력 칸은 무시 · 사전조합 안 줄(같은 로직 · 입력) = 같은 산근 (2, NET, ["s3"]), (3, STONE, ["s4"]), ] assert blocks[0]["key"] == sg.sangeun_key(NET, {**NET_IN, "x": None}, 100, {}) assert blocks[0]["key"] != sg.sangeun_key(NET, NET_IN, 90) # 켜쌓기율이 다르면 다른 산근 def test_사본_없으면_빈_표_연결_없음(tmp_path, monkeypatch): got = sg.collect([("s1", tmp_path / "없음")], {}) assert got["sg"] == [] and got["sg_list"] == [] and got["sg_gaps"] == [] assert got["sg_notes"] == [sg.NO_SOURCE, sg.EMPTY] plain = _snapshot(tmp_path / "p", [{"로직": STONE, "승격": True}]) # 일위대가화만 = 빈 표 assert sg.collect([("s1", plain)], {})["sg"] == [] async def fake_materials(_project_id): return {} monkeypatch.setattr(router_mod, "material_sheets", fake_materials) sheets = asyncio.run(router_mod.payload("p", tmp_path))["sheets"] assert sheets["sg"] == [] and sheets["sg_list"] == [] assert sg.EMPTY in sheets["sg_notes"] and sg.NO_SOURCE in sheets["sg_notes"] def test_단가_없으면_빈칸과_연결_없음(tmp_path): a = _snapshot(tmp_path / "a", [{"로직": STONE}]) got = sg.collect([("s1", a)], {"labor": [], "machine": [], "compare": []}) (block,) = got["sg"] assert block["labor"] is None and block["sum"] is None assert "노무비목록표에 없음" in block["why"] assert [(g["tab"], g["key"]) for g in got["sg_gaps"]] == [("sg", block["key"])] def test_작업량_Q는_소수_둘(): import M01_MasterData.M01_MasterData_Store as m01 whole = m01.loaded()[1] for code, row in sorted((whole.index.get("GF") or {}).items()): if not any(sg._Q.fullmatch(s["이름"]) for s in row.get("중간", [])): continue try: env = sg._env(whole, row, {}) except Exception: # noqa: BLE001 — 입력이 필요한 로직은 건너뜀 continue q = next(env[s["이름"]] for s in row["중간"] if sg._Q.fullmatch(s["이름"])) if hasattr(q, "as_tuple"): assert q == round(q, 2), code return pytest.skip("자동값으로 도는 작업량 Q 로직 없음") def test_화면_탭_둘_서버_산근_그대로(): shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") builders = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Builders.ts").read_text("utf-8") assert "sg_list: buildSgList," in builders and "sg: buildSg," in builders # 303-2 표 짜기 assert 'const SERVER_ONLY = ["sg", "sg_list", "sg_gaps", "sg_notes"] as const;' in shell assert "gaps: [...base.gaps, ...(server.sg_gaps || [])" in shell # 뒤에 대가(145-2) gaps 이음 tabs = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Sangeun.ts").read_text("utf-8") assert 'link: (i) => (rows[i] ? { tab: "sg", key: String(rows[i].key) } : null),' in tabs # 155-5 — 중기 = 노 · 재 · 경 세 줄(서버 글 줄 수와 같게 · 값 0 줄도 둠) assert 'const parts = ln.kind === "machine" && !ln.why ? PARTS : [null];' in tabs