"""PLAN 252 — B09 공기 초안(공기산출 · 공기집계 셈 짝 · 서버 원천 · 사용자 값 다듬기).""" import json import re import subprocess import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) from B09_Estimation.draft.B09_Draft_Schedule_Calc import compute_schedule # noqa: E402 from B09_Estimation.draft.B09_Draft_Schedule_Source import ( # noqa: E402 clean_schedule, holiday_table, schedule_inputs, unit_resources, ) IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) #: 참고 설계서(25 거창) 공기산출 — [이름, 시공량, 일작업량(= 8 × Q)] · 기타공 2일 GEOCHANG = [ ("토사절취", 7641, 1206.4), ("암석절취", 2669, 160), ("암석절취", 297, 48), ("측구터파기", 110, 362.24), ("측구터파기", 103, 20), ("지장목제거", 10782, 4519.76), ("초류종자파종", 2718.4, 2400), ("전석메쌓기", 540, 74.4), ("유입구골막이", 4, 4), ("유출구물받이", 16, 6), ("터파기", 757, 838.24), ("터파기", 57, 80), ("터파기", 57, 48), ("되메우기", 523, 904.16), ("관구체다짐", 324, 180), ("파형강관매설", 185, 160), ("배수관날개벽", 16, 4), ("BOX암거", 5, 0.8), ("BOX암거 난간", 13, 6.4), ("콘크리트포장", 2170, 200), ("혼합석부설", 2870, 1108), ("노견줄떼", 820, 255.2), ] # fmt: skip #: 같은 설계서 공기집계 — 기후(강수 + 혹서) · 법정공휴일(2026 기준 · 그 설계서 값) B_REF = [5, 7, 6, 4, 7, 5, 4, 7, 7, 7, 5, 5] def _bill(rows): out = [{"kind": "title", "key": "sec:1", "name": "토사", "level": 1, "section": "1"}] for i, (name, qty, _d) in enumerate(rows): out.append({"kind": "item", "key": f"r{i}", "name": name, "qty": qty, "level": 2, "section": "1.2", "unit": "㎥"}) # fmt: skip return out def _ref_user(): return { "rows": {f"r{i}": {"daily": d} for i, (_n, _q, d) in enumerate(GEOCHANG)}, "etc": 2, "start": {"year": 2026, "month": 3}, "holiday": {f"2026-{m:02d}": b for m, b in enumerate(B_REF, 1)}, } SRC = { "units": {}, "holidays": holiday_table(), "climate": { "rain": [1.2, 1.4, 3.6, 4.5, 3.6, 4.4, 7.7, 6.9, 5.5, 3.1, 2.2, 1.2], "heat": [0, 0, 0, 0, 0.5, 1.2, 5.9, 8.4, 0, 0, 0, 0], }, "start": {"year": 2027, "month": 3}, } def test_참고_설계서_거창_공기산출_재현(): work = compute_schedule(SRC, _bill(GEOCHANG), _ref_user())["work"] days = [r["days"] for r in work["rows"] if r["kind"] == "item"] assert days[:6] == [6.4, 16.7, 6.2, 0.4, 5.2, 2.4] # 설계서 F7 · F8 · F9 · F10 · F11 · F14 assert days[-3:] == [10.9, 2.6, 3.3] # F37 · F38 · F39 assert (work["prep"], work["subtotal"], work["tidy"], work["total"]) == (4, 87, 4, 95) assert [r["kind"] for r in work["rows"][:2]] == ["head", "item"] # 묶음 줄 먼저 def test_참고_설계서_거창_작업가능일_채움(): cal = compute_schedule(SRC, _bill(GEOCHANG), _ref_user())["calendar"] months = cal["months"] # 설계서 3 ~ 7월 작업가능일수(달력 − ROUNDDOWN(A + B − A×B÷달력)) assert [m["avail"] for m in months] == [23, 23, 21, 21, 16] assert [(m["prep"], m["work"], m["tidy"]) for m in months] == [ (4, 19, 0), (0, 23, 0), (0, 21, 0), (0, 21, 0), (0, 3, 4), ] # fmt: skip # 다 쓴 달 = 비작업 전부 · 마지막 달 = ROUNDUP(15 × 7 ÷ 16) = 7 assert [m["off_used"] for m in months] == [8, 7, 10, 9, 7] assert (cal["days"], cal["months_n"], cal["end"]) == (136, 4.5, "2026-07-14") def test_공휴일_자동_2026(): cal = compute_schedule(SRC, [], {"start": {"year": 2026, "month": 1}, "tidy": 400}) auto = [m["b_auto"] for m in cal["calendar"]["months"][:12]] # 설계서 값과 다른 달 = 5월 노동절 · 6월 지방선거 · 7월 제헌절(공휴일 법 바뀜 · 목록이 넣음) assert auto == [5, 7, 6, 4, 8, 6, 5, 7, 7, 7, 5, 5] may = cal["calendar"]["months"][4]["names"] assert "5일 어린이날" in may and "25일 부처님오신날 대체 휴일" in may assert cal["calendar"]["months"][1]["days"] == 28 # 2026.2 = 28일(설계서는 29) def test_장비_인력_입력_반영률(): src = {**SRC, "units": { "a": {"machine": {"key": "M1", "name": "굴착기 0.7㎥", "hr": 0.0066}, "labor": 0.01}, "b": {"machine": None, "labor": 1.5}, "c": {"machine": None, "labor": None}, }} # fmt: skip bill = [ {"kind": "item", "key": "a", "qty": 1000, "section": "1.2", "level": 2}, {"kind": "item", "key": "b", "qty": 80, "section": "2.1", "level": 2}, {"kind": "item", "key": "c", "qty": 5, "section": "2.3", "level": 2}, {"kind": "item", "key": "h", "qty": 9, "section": "3", "level": 2}, {"kind": "item", "key": "mat:x", "qty": 9, "section": "4", "level": 2}, {"kind": "item", "key": "z", "qty": 0, "section": "1.2", "level": 2}, ] rows = {r["key"]: r for r in compute_schedule(src, bill, {})["work"]["rows"]} a, b, c, h = rows["a"], rows["b"], rows["c"], rows["h"] # 장비 = 1000 × 0.0066 ÷ 8 = 0.825 → 0.9 · Q = 1 ÷ 0.0066 assert (a["basis"], a["days"], round(a["q"], 2), a["put"]) == ("장비", 0.9, 151.52, 1) assert (b["basis"], b["days"], b["put"]) == ("인력", 12, 10) # 80 × 1.5 ÷ 10 assert (c["basis"], c["days"]) == ("연결 없음", None) assert (h["rate"], h["days"]) == (0, 0) # 운반공 기본 0 = 단위당 자원 없어도 0일 assert "mat:x" not in rows and "z" not in rows # 자재대 · 수량 0 = 줄 없음 user = {"rows": {"a": {"units": 2, "rate": 50}, "b": {"crew": 5}, "c": {"daily": 2}}} rows = {r["key"]: r for r in compute_schedule(src, bill, user)["work"]["rows"]} assert rows["a"]["days"] == 0.3 # 1000 × 0.5 × 0.0066 ÷ 16 = 0.20625 → 0.3 assert rows["b"]["days"] == 24 and (rows["c"]["basis"], rows["c"]["days"]) == ("입력", 2.5) def _ts_schedule(src, bill, user): base = IMPORTS.sub("", (ROOT / "B09_Estimation/B09_Estimation_Calc_Base.ts").read_text("utf-8")) calc = (ROOT / "B09_Estimation/draft/B09_Draft_Schedule_Calc.ts").read_text("utf-8") js = ( base + IMPORTS.sub("", calc) + f"\nconsole.log(JSON.stringify(computeSchedule({json.dumps(src, ensure_ascii=False)}, " f"{json.dumps(bill, ensure_ascii=False)}, {json.dumps(user, ensure_ascii=False)})));" ) # fmt: skip run = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"], input=js, capture_output=True, text=True, encoding="utf-8", timeout=60, ) # fmt: skip assert run.returncode == 0, run.stderr return json.loads(run.stdout) def test_거울_TS_가_같은_값(): src = {**SRC, "units": {"r0": {"machine": {"key": "M", "name": "굴", "hr": 0.0066}}, "r1": {"labor": 1.5}}} # fmt: skip cases = [ (SRC, _bill(GEOCHANG), _ref_user()), (src, _bill(GEOCHANG[:4]), {"rows": {"r0": {"rate": 50}}, "start": {"year": 2027, "month": 11}, "climate": {"rain": [9] * 12, "heat": [None] * 12}}), (SRC, [], {}), ] # fmt: skip for src_, bill, user in cases: py = json.loads(json.dumps(compute_schedule(src_, bill, user), ensure_ascii=False)) assert _ts_schedule(src_, bill, user) == py def test_사용자_값_다듬기(): raw = { "hours": 9, "tidy": -1, "etc": "x", "start": {"year": 2027, "month": 13}, "climate": {"rain": [1] * 12, "heat": [1, 2]}, "holiday": {"2027-05": 6, "bad": 3, "2027-06": 40}, "rows": {"a": {"rate": 50, "units": 0, "crew": 5, "daily": None, "x": 1}, "b": {"rate": 120}}, "junk": 1, } # fmt: skip assert clean_schedule(raw) == { "hours": 9, "climate": {"rain": [1] * 12}, "holiday": {"2027-05": 6}, "rows": {"a": {"rate": 50, "crew": 5}}, } assert clean_schedule(None) == {} from B09_Estimation.B09_Estimation_Store import clean_user assert clean_user({"schedule": {"tidy": 3}})["schedule"] == {"tidy": 3} assert "schedule" not in clean_user({"schedule": {"junk": 1}}) def test_서버_원천_단위당_자원(): sheets = { "bill": {"rows": [ {"kind": "item", "key": "earth:4", "ref": {"tab": "sg", "key": "S1"}}, {"kind": "item", "key": "hand:x", "ref": {"tab": "dg", "key": "D1"}}, {"kind": "item", "key": "earth:5", "ref": None}, ]}, "sg": [{"key": "S1", "lines": [ {"kind": "machine", "ref": {"key": "M1"}, "qty": 0.0066}, {"kind": "machine", "ref": {"key": "M2"}, "qty": 0.002}, {"kind": "labor", "ref": {"key": "L1"}, "qty": 0.01}, ]}], "daega_blocks": [{"key": "D1", "lines": [ {"kind": "labor", "ref": {"key": "L1"}, "qty": 0.5}, {"kind": "sangeun", "ref": {"key": "S1"}, "qty": 2}, {"kind": "labor", "ref": {"key": "L2"}, "qty": 1.0}, ]}], "machine": [{"key": "M1", "name": "굴착기", "spec": "0.7㎥"}], } # fmt: skip units = unit_resources(sheets) assert units["earth:4"]["machine"] == {"key": "M1", "name": "굴착기 0.7㎥", "hr": 0.0066} assert units["earth:4"]["labor"] == 0.01 # 겹 호표 = 끝까지(산근 2 단위 → 굴착기 0.0132 시간 · 인 0.5 + 0.02 + 1.0) assert round(units["hand:x"]["machine"]["hr"], 6) == 0.0132 assert round(units["hand:x"]["labor"], 6) == 1.52 assert "earth:5" not in units got = schedule_inputs(sheets, __import__("datetime").date(2026, 10, 11)) assert got["start"] == {"year": 2027, "month": 3} and "2027" in got["holidays"] def test_본_코드_훅_지워도_돌게(): store = (ROOT / "B09_Estimation/B09_Estimation_Store.py").read_text("utf-8") router = (ROOT / "B09_Estimation/B09_Estimation_Router.py").read_text("utf-8") shell = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8") assert "except ImportError" in store and "except ImportError" in router assert 'import.meta.glob("./draft/*_UI.ts", { eager: true })' in shell