"""145-2 B09 일위대가표 · 일위대가목록표 — 엑셀 검산 대조 · 거울(`B09_Estimation_Calc_Labor.py` ↔ `.ts`) · 견본 사본 → 대가 블록(`B09_Estimation_Labor_Source`). 견본 사본 단정 = 155-3 갈래(사용자 뜻 · 145 정함 뒤집음) — 일위대가화(승격) 줄 = 대가(품셈) 블록 · 단가산출 줄 = 산근 줄(수량 그대로) · 사전조합 = 겹 대가 · 비고 「산근 n호표」. 엑셀 견본 = 기번3 공통 xlsx 「일위대가표」 같은 입력: 대가1 모르터바르기 109,259 · 대가2 파형강관부설 D800 16,001(15,002 · 584 · 415 · 공구손료 2 %) · 대가9 찰붙임 52,943(42,556 · 3,842 · 6,545 · 중기 줄) · 대가14 돌붙임(채집) 조합 101,522(77,755 · 9,519 · 14,248 · 산근 11 · 10 · 30 + 대가 9 · 1 겹) """ import json import re import shutil import subprocess 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_Sangeun as sangeun # noqa: E402 from B09_Estimation.B09_Estimation_Calc_Base import compute_base # noqa: E402 from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor, daega_price # noqa: E402 from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs # noqa: E402 from B09_Estimation.B09_Estimation_Linked import linked_resource_keys # noqa: E402 from M02_MasterTemplete import M02_Item_Prices as item_prices # noqa: E402 from M02_MasterTemplete import M02_Item_Snapshot as snapshots # noqa: E402 IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) # ── 엑셀 같은 입력 ───────────────────────────────────────────────────────── SHEETS = { "labor": [ {"key": "L00017", "price": 165545}, # 보통인부 {"key": "L00032", "price": 243168}, # 배관공(수도) {"key": "L00005", "price": 258935}, # 석공 ], "material": [ {"key": "M00101", "price": 120, "supply": "별도"}, # 시멘트 별도계상 → 대가 안 0원 {"key": "M00103", "price": 25000, "supply": "별도"}, # 모래 {"key": "M00222", "price": 99000, "supply": "관급"}, # 파형강관(800) ], "machine": [ {"key": "X00044", "labor": 47231, "material": 7784, "expense": 9994}, {"key": "X00020", "labor": 55700, "material": 18296, "expense": 31168}, ], "gaps": [], } SANGEUN = { "SG11": {"labor": 18289, "material": 5101, "expense": 7008}, "SG10": {"labor": 18084, "material": 2498, "expense": 4234}, "SG30": {"labor": 66071, "material": 1008, "expense": 304}, } def _l(kind, key, qty, name="", **extra): return {"kind": kind, "key": key, "qty": qty, "name": name or key, **extra} def _b(bid, kind, name, lines): return { "id": bid, "kind": kind, "name": name, "spec": "", "unit": "", "cite": "", "lines": lines, } EXCEL = { "blocks": [ _b("DG14", "조합", "돌붙임(채집)", [ _l("sangeun", "SG11", 1), _l("daega", "DG9", 1), _l("sangeun", "SG10", 0.15), _l("sangeun", "SG30", 0.2), _l("daega", "DG1", 0.009), ]), _b("DG1", "품셈", "모르터바르기", [ _l("material", "M00101", 510), _l("material", "M00103", 1.1), _l("labor", "L00017", 0.66), ]), _b("DG2", "품셈", "파형강관부설", [ _l("material", "M00222", 1), _l("labor", "L00032", 0.04), _l("labor", "L00017", 0.02), {"kind": "pct", "name": "공구손료", "part": "material", "base": "lines:labor", "qty": 2}, _l("machine", "X00044", 0.0416), ]), _b("DG9", "품셈", "찰붙임", [ _l("labor", "L00005", 0.1), _l("labor", "L00017", 0.03), _l("machine", "X00020", 0.21), ]), ], "owners": {"pipe@100.0": "DG14"}, } # fmt: skip def _block(out, bid): return next(b for b in out["daega_blocks"] if b["id"] == bid) def _sums(block): return (block["sum"], block["labor"], block["material"], block["expense"]) def test_엑셀_대가_블록_검산(): out = compute_labor(EXCEL, SHEETS, SANGEUN) assert _sums(_block(out, "DG1")) == (109259, 109259, 0, 0) assert _sums(_block(out, "DG2")) == (16001, 15002, 584, 415) assert _sums(_block(out, "DG9")) == (52943, 42556, 3842, 6545) assert _sums(_block(out, "DG14")) == (101522, 77755, 9519, 14248) pct = next(ln for ln in _block(out, "DG2")["lines"] if ln["kind"] == "pct") assert ( pct["prices"]["material"] == pytest.approx(13037.6) and pct["amounts"]["material"] == 260.7 ) mortar = _block(out, "DG14")["lines"][-1] assert mortar["ref_no"] == "대가2" and mortar["amounts"]["labor"] == 983.3 # 109,259 × 0.009 assert _block(out, "DG1")["lines"][0]["amounts"]["material"] == 0 # 별도계상 = 대가 안 0원 # 목록표 = 블록 합계 · 내역서가 읽는 대가 단가 assert out["daega"][0] == { "no": 1, "key": "DG14", "kind": "조합", "name": "돌붙임(채집)", "spec": "", "unit": "", "sum": 101522, "labor": 77755, "material": 9519, "expense": 14248, "blank": 0, "blank_names": [], } # fmt: skip assert daega_price(out, "pipe@100.0") == { "no": "대가1", "key": "DG14", "labor": 77755, "material": 9519, "expense": 14248, "sum": 101522, } assert daega_price(out, "pipe@999.0") is None assert out["gaps"] == [] def _blank_cases(): broken = json.loads(json.dumps(EXCEL)) broken["blocks"][2]["lines"][1]["key"] = "L09999" # 노임 없음 → 그 블록 · 부른 블록 빈칸 아님 broken["blocks"].append(_b("DGX", "조합", "순환", [_l("daega", "DGY", 1)])) broken["blocks"].append(_b("DGY", "조합", "순환2", [_l("daega", "DGX", 1)])) broken["blocks"].append(_b("DGZ", "품셈", "수량 없음", [_l("labor", "L00017", None)])) return broken def test_빈칸_규칙(): """166-6 — 블록 합 = 아는 줄 합 + 빈 줄 n · 내역 단가는 빈칸.""" out = compute_labor(_blank_cases(), SHEETS, {}) dg2 = _block(out, "DG2") assert (dg2["sum"], dg2["labor"], dg2["blank"]) == (6013, 5275, 2) assert dg2["blank_names"] == ["L00032", "공구손료"] # 노임 없음 · 그 노무 밑수 % 줄 dg14 = _block(out, "DG14") # 산근 단가 없음(145-1 전) assert (dg14["sum"], dg14["blank"]) == (53926, 3) assert _sums(dg14) == (53926, 43539, 3842, 6545) assert (_block(out, "DGZ")["sum"], _block(out, "DGZ")["blank"]) == (0, 1) assert _block(out, "DGX")["blank"] + _block(out, "DGY")["blank"] >= 1 # 순환 = 빈 줄 assert daega_price(out, "pipe@100.0") == { "no": "대가1", "key": "DG14", "labor": None, "material": None, "expense": None, "sum": None, } # fmt: skip whys = {g["why"] for g in out["gaps"]} assert {"노임 단가 없음", "산근 단가 없음(145-1)", "수량 없음", "% 밑수 없음"} <= whys def _ts(daega, sheets, sangeun): base = IMPORTS.sub("", (ROOT / "B09_Estimation/B09_Estimation_Calc_Base.ts").read_text("utf-8")) calc = IMPORTS.sub( "", (ROOT / "B09_Estimation/B09_Estimation_Calc_Labor.ts").read_text("utf-8") ) body = ( f"\nconst d = {json.dumps(daega, ensure_ascii=False)};" f"\nconst s = {json.dumps(sheets, ensure_ascii=False)};" f"\nconst g = {json.dumps(sangeun, ensure_ascii=False)};" "\nconst out = computeLabor(d, s, g);" "\nconsole.log(JSON.stringify({out, price: daegaPrice(out, 'pipe@100.0')}));" ) result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript", "-e", base + calc + body], capture_output=True, text=True, encoding="utf-8", timeout=60, ) # fmt: skip assert result.returncode == 0, result.stderr return json.loads(result.stdout) def _same(a, b): """JSON 거울 비교 — 수는 값으로(0 · 0.0 같음).""" if isinstance(a, dict) and isinstance(b, dict): return a.keys() == b.keys() and all(_same(a[k], b[k]) for k in a) if isinstance(a, list) and isinstance(b, list): return len(a) == len(b) and all(_same(x, y) for x, y in zip(a, b)) if isinstance(a, (int, float)) and isinstance(b, (int, float)): return abs(a - b) < 1e-9 return a == b @pytest.mark.parametrize("case", ["excel", "blank"]) def test_거울_파이썬_TS(case): daega, sangeun = (EXCEL, SANGEUN) if case == "excel" else (_blank_cases(), {}) py = json.loads(json.dumps(compute_labor(daega, SHEETS, sangeun), ensure_ascii=False)) ts = _ts(daega, SHEETS, sangeun) assert _same(py, ts["out"]) assert _same(daega_price(py, "pipe@100.0"), ts["price"]) # ── 견본 사본 → 대가 블록 ─────────────────────────────────────────────────── STONE = {"뒷길이": 45, "돌": "깬돌", "쌓기": "켜쌓기", "높이": 2} LINES = [ {"로직": "GF000219", "수량": {"손": 2}}, # 메쌓기(인력 줄) {"로직": "GF000119", "수량": {"손": 0.5}}, # 노체포설(중기 줄) {"로직": "GF000143", "수량": {"손": 1}}, # 케이블 크레인 운반대(재료 · 재료비 % 줄) ] SC = { "참조|system||SC-T": { "이름": "사전조합T", "단위": "m3", "담은로직": [{"로직": "GF000219", "입력값": STONE, "수량": {"손": 3}}], } } def _item(key): return {"layer": "system", "owner": None, "key": key, "name": f"조합 {key}"} def _values(given): return {"GF000219": {k: {"값": v} for k, v in given.items()}} def _snap(root, owner, key, lines, values, copies=None): folder = snapshots.structures_dir(root) / snapshots.folder_name(owner) folder.mkdir(parents=True) write = lambda name, data: (folder / name).write_text( # noqa: E731 json.dumps(data, ensure_ascii=False), encoding="utf-8" ) codes = [one["로직"] for one in lines if "로직" in one] for doc in (copies or {}).values(): codes += [one["로직"] for one in doc["담은로직"] if "로직" in one] write(snapshots.MANIFEST_NAME, {"원본": {**_item(key), "경로": ["기슭막이", key]}}) write("단가.json", item_prices.to_json(item_prices.collect(codes))) doc = {"종류": "일위대가", "단위": "m", "담은로직": lines, "자재": []} write("일위대가.json", {"줄": doc, **({"참조사본": copies} if copies else {})}) write("입력값.json", values) def _project(root: Path, points) -> Path: shutil.rmtree(root, ignore_errors=True) edits = root / "B04_PreProcess/drainage/edits" edits.mkdir(parents=True) rows = [{"chainage_m": c, "options": {}, "summary_item": _item(k), "source": "user"} for c, k in points] # fmt: skip (edits / "pipe_points.json").write_text(json.dumps({"points": rows}), encoding="utf-8") route = root / "B05_Profile/route" route.mkdir(parents=True) (route / "structures.json").write_text('{"revision": 1, "structures": []}', encoding="utf-8") return root @pytest.fixture(scope="module") def project(tmp_path_factory) -> Path: root = _project( tmp_path_factory.mktemp("p145") / "proj", [(100, "SS000901"), (200, "SS000901"), (300, "SS000901"), (400, "SS000902"), (500, "SS000903")], ) # fmt: skip _snap(root, "pipe@100.0", "SS000901", LINES, _values(STONE)) _snap(root, "pipe@200.0", "SS000901", LINES, _values(STONE)) # 같은 원본 + 같은 입력값 _snap(root, "pipe@300.0", "SS000901", LINES, _values({**STONE, "높이": 5})) # 입력값 다름 promoted = [ # 일위대가화 줄 셋(인력 · 중기 · 재료 + % 줄) + 사전조합 {"로직": "GF000219", "승격": True, "수량": {"손": 1}}, {"로직": "GF000119", "승격": True, "수량": {"손": 1}}, {"로직": "GF000143", "승격": True, "수량": {"손": 1}}, {"참조": {"층": "system", "주인": None, "키": "SC-T"}, "수량": {"손": 4}}, ] _snap(root, "pipe@400.0", "SS000902", promoted, _values(STONE), SC) # 500 = 지정만 있고 사본 없음 → 없음 return root def test_견본_사본_대가_블록(project): got = daega_inputs(project) owners = got["owners"] assert set(owners) == {"pipe@100.0", "pipe@200.0", "pipe@300.0", "pipe@400.0"} assert ( owners["pipe@100.0"] == owners["pipe@200.0"] != owners["pipe@300.0"] ) # 같은 원본+입력 = 한 호표 blocks = {b["id"]: b for b in got["blocks"]} # 단가산출 줄은 대가 항목 아님 → 품셈 블록 = 일위대가화 줄 셋만 assert [b["kind"] for b in got["blocks"]] == ["조합"] * 3 + ["사전조합"] + ["품셈"] * 3 a = blocks[owners["pipe@100.0"]] assert [(ln["kind"], ln["key"][:11], ln["qty"]) for ln in a["lines"]] == [ ("sangeun", "SG:GF000219", 2), ("sangeun", "SG:GF000119", 0.5), ("sangeun", "SG:GF000143", 1), ] b = blocks[owners["pipe@400.0"]] assert [ln["kind"] for ln in b["lines"]] == ["daega"] * 4 stone, digger, crane = (blocks[ln["key"]] for ln in b["lines"][:3]) assert stone["kind"] == "품셈" and stone["name"] == "돌쌓기 메쌓기" assert [(ln["kind"], ln["key"]) for ln in stone["lines"]] == [ ("labor", "LB000033"), ("labor", "LB000002"), ] assert stone["cite"] == "산림품셈 13-4-1" and stone["unit"] == "㎡" assert digger["lines"][0]["kind"] == "machine" and digger["lines"][0]["key"].startswith("EQ") pct = crane["lines"][-1] assert pct["kind"] == "pct" and pct["base"] == "part:material" and pct["qty"] == 20 sc = blocks[b["lines"][3]["key"]] assert sc["kind"] == "사전조합" and b["lines"][3]["qty"] == 4 assert ( sc["lines"][0]["kind"] == "sangeun" and sc["lines"][0]["key"] == a["lines"][0]["key"] and sc["lines"][0]["qty"] == 3 ) # 사전조합 안 단가산출 줄 = 같은 산근(같은 로직 + 같은 입력값) def test_견본_사본_끝까지_셈(project): keys = linked_resource_keys(project) inputs = prices.build_inputs(None, keys) inputs["daega"] = daega_inputs(project) base = compute_base(inputs, {}) sg_rows = {r["key"]: r for r in sangeun.build(project, base)["sg_list"]} out = compute_labor(inputs["daega"], base, sg_rows) owner = inputs["daega"]["owners"]["pipe@100.0"] combo = _block(out, owner) assert combo["sum"] is not None and combo["no"] == 1 # 조합 줄 = 산근 단가 × 수량(ROUNDDOWN 1) · 비고 「산근 n호표」 · 합계 = ROUNDDOWN 0 for ln in combo["lines"]: row = sg_rows[ln["key"]] assert ln["prices"]["labor"] == row["labor"] and ln["ref_no"] == f"산근 {row['no']}호표" # 일위대가화 블록 = 금액 다 참(기계 줄 빈칸 없음) for ln in _block(out, inputs["daega"]["owners"]["pipe@400.0"])["lines"]: assert _block(out, ln["key"])["sum"] is not None, ln["name"] price = daega_price(out, "pipe@100.0") assert price["no"] == "대가1" and price["sum"] == combo["sum"] assert daega_price(out, "pipe@500.0") is None # 사본 없음 = 없음 assert [g for g in out["gaps"] if g["tab"] == "dg" and g["key"] == owner] == [] def test_사본_없으면_빈_표(tmp_path): root = _project(tmp_path / "empty", [(100, "SS000901")]) got = daega_inputs(root) assert got == {"blocks": [], "owners": {}} out = compute_labor(got, {"gaps": []}) assert out["daega"] == [] and out["daega_blocks"] == [] and out["daega_owners"] == {} def _block_lines(blocks): """145-5 집계표 이음 꼴 — 짝 = `B09_Estimation_Summary.block_lines`(들어오면 그것을 씀).""" try: from B09_Estimation.B09_Estimation_Summary import block_lines except ImportError: kinds = {"labor": "labor", "material": "material", "expense": "expense", "machine": "machine", "sangeun": "hopyo", "daega": "hopyo"} # fmt: skip return { str(b["key"]): { "lines": [ {"kind": kinds[ln["kind"]], "key": ln["ref"]["key"], "qty": ln["qty"]} for ln in b["lines"] if ln["kind"] in kinds and (ln.get("ref") or {}).get("key") and ln["qty"] is not None ] } for b in blocks } return block_lines(blocks) def test_집계표_이음_꼴(): """블록 key = 대가 id(= daega_owners 값 · 내역서 대가 ref) · 줄 ref.key · 1 단위당 qty.""" out = compute_labor(EXCEL, SHEETS, SANGEUN) lines = _block_lines(out["daega_blocks"]) assert set(lines) == {"DG14", "DG1", "DG2", "DG9"} assert out["daega_owners"]["pipe@100.0"] in lines assert lines["DG14"]["lines"] == [ {"kind": "hopyo", "key": "SG11", "qty": 1}, {"kind": "hopyo", "key": "DG9", "qty": 1}, {"kind": "hopyo", "key": "SG10", "qty": 0.15}, {"kind": "hopyo", "key": "SG30", "qty": 0.2}, {"kind": "hopyo", "key": "DG1", "qty": 0.009}, ] assert {"kind": "machine", "key": "X00044", "qty": 0.0416} in lines["DG2"]["lines"] assert all(ln["kind"] != "pct" for ln in lines["DG2"]["lines"]) # % 줄 = 자원 아님 def test_한_바퀴_내역_대가_산근_중기_집계표(tmp_path): """이음 끝 확인 — 설계내역서 구조물 줄(B08 열 owners) → 대가 블록마다 나눔 → 대가 줄 → 산근(155-3 · 단가산출 줄) → 중기 → 집계표(145-5) 역전개 · 짝 셈 한 벌 = Router.compute_sheets.""" from B09_Estimation.B09_Estimation_Calc_Base import rounddown from B09_Estimation.B09_Estimation_Router import compute_sheets from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines root = _project(tmp_path / "loop", [(100, "SS000901"), (400, "SS000902")]) _snap(root, "pipe@100.0", "SS000901", LINES, _values(STONE)) promoted = [ {"로직": "GF000119", "승격": True, "수량": {"손": 1}}, # 노체포설 = 일위대가화 · 중기 줄 {"참조": {"층": "system", "주인": None, "키": "SC-T"}, "수량": {"손": 4}}, ] _snap(root, "pipe@400.0", "SS000902", promoted, _values(STONE), SC) inputs = prices.build_inputs(None, linked_resource_keys(root)) inputs["daega"] = daega_inputs(root) inputs["hopyo"] = {} inputs["bill"] = { "items": [ {"key": "st:revet", "section": "2.1", "name": "돌기슭막이", "spec": "", "unit": "m", "qty": 10, "ref": {"tab": "dg_list", "key": "revet"}, "note": "", "owners": {"pipe@100.0": 4, "pipe@400.0": 6}}, ] } # fmt: skip sheets = compute_sheets(root, inputs, {}) owners = sheets["daega_owners"] rows = [r for r in sheets["bill"]["rows"] if r.get("kind") == "item"] assert [r["ref"] for r in rows] == [ {"tab": "dg", "key": owners["pipe@100.0"]}, {"tab": "dg", "key": owners["pipe@400.0"]}, ] for row in rows: block = _block(sheets, row["ref"]["key"]) assert row["price_labor"] == block["labor"] and block["sum"] is not None assert row["labor"] == rounddown(row["qty"] * block["labor"], 0) # 산근 이음 — 단가산출 줄 키 = 145-1 산근 키 · 단가가 들어옴 combo = _block(sheets, owners["pipe@100.0"]) sg_line = combo["lines"][0] sg_row = next(r for r in sheets["sg_list"] if r["key"] == sg_line["key"]) assert sg_line["kind"] == "sangeun" and sg_line["prices"]["labor"] == sg_row["labor"] # 일위대가화 줄 = 대가 블록 · 기계 줄 = 중기 digger = _block(sheets, _block(sheets, owners["pipe@400.0"])["lines"][0]["key"]) assert digger["kind"] == "품셈" and digger["lines"][0]["kind"] == "machine" # 집계표 역전개 — 대가 · 사전조합 · 품셈 · 산근 끝까지 summary = compute_summary(sheets, hopyo_lines(sheets)) assert summary["unlinked"] == 0 labor = {r["key"]: r["qty"] for r in summary["sum_labor"]} assert labor["LB000033"] == 25.6 # 4 × 2 × 0.32 + 6 × (4 × 3 × 0.32) machine = {r["key"]: r["qty"] for r in summary["sum_machine"]} assert machine.get("EQ000020", 0) > 0.08 # 품셈 4 × 0.5 × 0.0412 + 산근 6 × (중기 시간)