"""707-1 경쟁사 내역 줄 불러오기 — 읽기(`Bill_Excel`) · 짜기(`Bill_Import`) · 고정 단가 칸 셈 거울. - 실무 xlsx(STmate 기번3 · 영월 · 봉화 · EST 거창) = 빠짐 0 · 불러온 뒤 직접비 합 = 엑셀 합 · 어긋난 줄 0 · 조달수수료 = 율(줄로 안 실음) · DRM = 「읽을 수 없음」 - 수량 갈래 (가) take = 붙인 자동 줄 수량 엑셀 · auto_qty = B08 / (나) price = B08 · src_qty = 엑셀 - 두 번 셈 막음 = 이름 같은 자동 줄에 붙임(한 번만 셈) · drop = 남은 자동 줄 지움 · 같은 파일 다시 = 갈아 끼움 · 조달수수료율 = 빈 때만 - 고정 단가 칸 = 연결이 단가를 못 낼 때만 · 다듬기 · TS 짝 같은 값 """ import json import re 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_Bill_Import as imp # noqa: E402 from B09_Estimation import B09_Estimation_Store as store # noqa: E402 from B09_Estimation.B09_Estimation_Bill_Excel import BillExcelError, read_bill # noqa: E402 from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402 K = ROOT / "resources" / "knowledge" / "original" / "실무문서" FILES = { "기번3": K / "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).xlsx", # noqa: E501 "영월": K / "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx", "봉화": K / "04.산불 봉화/01. 내역서/2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.xlsx", "거창": K / "01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx", # noqa: E501 } # fmt: skip #: (꼴, 항목 수, 엑셀 직접비 합) WANT = { "기번3": ("STmate", 136, 710359136), "영월": ("STmate", 108, 767148700), "봉화": ("STmate", 77, 781283415), "거창": ("EST", 60, 384916227), } EMPTY = {"materials": [], "bill": {"items": []}, "hopyo": {}} ITEMS = [ {"key": "earth:4", "section": "1.2", "name": "절토(토사)", "unit": "㎥", "qty": 10, "ref": {"tab": "sg_list", "key": "D01341"}}, {"key": "earth:9", "section": "1.6", "name": "노면 정리X", "unit": "㎡", "qty": 3, "ref": {"tab": "sg_list", "key": "D01341"}}, {"key": "earth:6", "section": "1.2", "name": "암발파", "unit": "㎥", "qty": None, "ref": {"tab": "sg_list", "key": "D01341"}}, {"key": "haul:3", "section": "3", "name": "모래", "unit": "㎥", "qty": 2, "ref": {"tab": "sg_list", "key": "D01341"}}, ] # fmt: skip INPUTS = {"materials": [], "bill": {"items": ITEMS}, "hopyo": {"D01341": { "labor": 1, "material": 0, "expense": 0}}} # fmt: skip IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) @pytest.fixture(scope="module") def parsed(): return {k: read_bill(p.name, p.read_bytes()) for k, p in FILES.items()} def _rows(inputs, user): return compute_estimation(inputs, user, None, {})["bill"] def _load(got, inputs=EMPTY, user=None, **kw): user = user or {} made, report = imp.apply(got, user, _rows(inputs, user)["rows"], "abcd1234", **kw) made = store.clean_user(made) return made, report, _rows(inputs, made) @pytest.mark.parametrize("name", list(FILES)) def test_실무_내역_빠짐_0_합계_엑셀과_같음(parsed, name): got = parsed[name] kind, items, total = WANT[name] assert (got["꼴"], sum(1 for x in got["줄"] if not x["묶음"])) == (kind, items) assert got["합계"]["직접"] == total and got["조달수수료율"] == 0.54 made, report, bill = _load(got) assert report["실음"] == len(got["줄"]) and report["붙임"] == 0 assert bill["totals"]["direct"]["sum"] == total and bill["totals"]["direct"]["blank"] == 0 assert imp.differences(got, report, bill["rows"]) == [] assert made["estimate"]["procurement_fee_pct"] == 0.54 assert max(r["level"] for r in bill["rows"]) <= 5 keys = {r["key"] for r in bill["rows"]} assert all(k in keys for k in report["줄"].values()) # 실은 줄 모두 보임 # 관급 = 제외(관급자재대 아래 · 직접비 밖) assert bill["totals"]["owner"]["net_material"] > 0 def test_EST_비고_참조_STmate_코드(parsed): est = [x["호표"] for x in parsed["거창"]["줄"] if not x["묶음"]] assert {"종류": "산근", "번": 46} in est and {"종류": "대가", "번": 8} in est st = next(x for x in parsed["기번3"]["줄"] if x["이름"] == "규준틀설치") assert st["호표"] == {"종류": "대가", "번": 5, "코드": "B00194"} def test_DRM_읽을_수_없음(): with pytest.raises(BillExcelError, match="읽을 수 없음"): read_bill("a.xlsx", b"\x9b DRMONE" + b"\x00" * 300) with pytest.raises(BillExcelError, match="엑셀"): read_bill("a.xlsx", b"PK\x03\x04 broken") def test_가_넘겨받기_붙인_자동_줄_수량_엑셀(parsed): made, report, bill = _load(parsed["기번3"], INPUTS, mode="take") rows = {r["key"]: r for r in bill["rows"]} assert report["붙임"] == 2 # 절토(토사) 첫 줄만(1공구) · 암발파 · 운반 「모래」 = 안 붙음 cut = rows["earth:4"] assert (cut["qty"], cut["auto_qty"], cut["src_qty"]) == (5675, 10, 5675) assert cut["fixed"] and cut["sum"] == 11003825 # 엑셀 단가 · 연결 덮개 null assert cut["parent"].startswith("hand:xabcd1234-") # 불러온 자리로 옮김 assert sum(1 for r in bill["rows"] if r["name"] == "절토(토사)") == 3 # 두 번 셈 없음 # 붙인 줄 금액 = 엑셀 · 안 붙은 자동 줄(노면 정리X 3 · 운반 모래 2) 그대로 = 직접비 = 엑셀 + 5 assert bill["totals"]["direct"]["sum"] == 710359136 + 5 assert "haul:3" in rows and rows["haul:3"]["parent"] == "sec:3" tops = [r["key"] for r in bill["rows"] if r["parent"] is None] assert tops.index("hand:xabcd1234-00006") < tops.index("sec:G") # 1공구 = 관급자재대 앞 def test_나_단가만_B08_이김_비교_칸(parsed): made, report, bill = _load(parsed["기번3"], INPUTS, mode="price") cut = {r["key"]: r for r in bill["rows"]}["earth:4"] assert (cut["qty"], cut["src_qty"], "auto_qty" in cut) == (10, 5675, False) assert cut["sum"] == 1939 * 10 # 엑셀 단가 × B08 수량 blast = {r["key"]: r for r in bill["rows"]}["earth:6"] assert (blast["qty"], blast["sum"]) == (3889, 43700693) # B08 수량 없음 = 엑셀 수량 diff = imp.differences(parsed["기번3"], report, bill["rows"]) assert [d["까닭"] for d in diff] == ["수량 = B08(단가만)"] def test_붙이지_않음_지우기_다시_부르기(parsed): _, report, bill = _load(parsed["기번3"], INPUTS, attach=False, drop=True) assert (report["붙임"], report["지움"]) == (0, 4) # 운반 줄도 지움 assert bill["totals"]["direct"]["sum"] == 710359136 made, _, _ = _load(parsed["기번3"], INPUTS) made["bill"]["rows"].append({"key": "hand:mine", "parent": "hand:xabcd1234-00007", "name": "내 줄", "qty": 1, "ref": None}) # fmt: skip again, report, bill = _load(parsed["기번3"], INPUTS, user=made) keys = [e["key"] for e in again["bill"]["rows"]] assert len(keys) == len(set(keys)) and report["붙임"] == 2 assert bill["totals"]["direct"]["sum"] == 710359136 + 5 # 갈아 끼움 · 두 번 안 셈 mine = next(e for e in again["bill"]["rows"] if e["key"] == "hand:mine") assert "parent" not in mine # 내 줄 = 남음(맨 위) def test_조달수수료율_빈_때만(parsed): made, _, _ = _load(parsed["거창"], user={"estimate": {"procurement_fee_pct": 1.0}}) assert made["estimate"]["procurement_fee_pct"] == 1.0 def test_고정_단가_칸_다듬기_연결이_이김(): raw = {"bill": {"v": 2, "rows": [ {"key": "hand:a", "name": "a", "qty": 2, "ref": {"tab": "sg", "key": "SG:1"}, "price": {"labor": 5, "material": "x"}, "src_qty": 3, "note": "대가 5호표"}, {"key": "earth:4", "price": {"expense": 7}, "src_qty": "y"}, ]}} # fmt: skip user = store.clean_user(raw) hand, auto = user["bill"]["rows"] assert hand["price"] == {"labor": 5, "material": 0, "expense": 0} and hand["src_qty"] == 3 assert hand["note"] == "대가 5호표" and auto == {"key": "earth:4", "price": { "labor": 0, "material": 0, "expense": 7}} # fmt: skip books = {"sg_list": [{"no": 1, "key": "SG:1", "labor": 100, "material": 0, "expense": 0, "sum": 100}]} # fmt: skip rows = {r["key"]: r for r in compute_estimation(INPUTS, user, None, books)["bill"]["rows"]} assert (rows["hand:a"]["sum"], rows["hand:a"].get("fixed")) == (200, None) # 연결 이김 assert rows["earth:4"]["sum"] == 10 and "fixed" not in rows["earth:4"] # 자동 연결 이김 rows = {r["key"]: r for r in compute_estimation(INPUTS, user, None, {})["bill"]["rows"]} assert (rows["hand:a"]["sum"], rows["hand:a"]["fixed"]) == (10, True) assert rows["hand:a"]["note"] == "대가 5호표" def test_거울_TS_가_같은_값(parsed): calc = "".join( IMPORTS.sub("", (ROOT / "B09_Estimation" / f"B09_Estimation_{n}.ts").read_text("utf-8")) for n in ("Calc_Base", "Calc_Bill") ) users = [ _load(parsed["거창"], INPUTS, mode="take")[0], _load(parsed["영월"], INPUTS, mode="price")[0], {"bill": {"v": 2, "rows": [{"key": "earth:4", "ref": None, "price": {"labor": 3}}, {"key": "hand:a", "qty": 2, "price": {"material": 4}}]}}, ] # fmt: skip body = ( f"\nconst i = {json.dumps(INPUTS, ensure_ascii=False)};" f"\nconst us = {json.dumps(users, ensure_ascii=False)};" "\nconsole.log(JSON.stringify(us.map((u) => computeEstimation(i, u, null, {}))));" ) result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"], input=calc + body, capture_output=True, text=True, encoding="utf-8", timeout=60, ) # fmt: skip assert result.returncode == 0, result.stderr assert json.loads(result.stdout) == [compute_estimation(INPUTS, u, None, {}) for u in users] def test_이음_같은_책_일위대가_이음_단가_어긋남_알림(parsed): got = parsed["기번3"] link = {"tab": "dg", "key": "DG:5", "lib": "precombo@personal@1-3@SC-000001"} made, report, _ = _load(got, links={5: link}) lines = [e for e in made["bill"]["rows"] if e.get("ref")] assert report["이음"] == 3 and len(lines) == 3 # 대가 5호표(종단규준틀) 공구마다 assert lines[0]["ref"] == link and lines[0]["price"]["labor"] == 21936 # 고정 단가 곁에 남김 same = {"daega": [{"no": 5, "key": "DG:5", "labor": 21936, "material": 3885, "expense": 0, "sum": 25821}]} # fmt: skip rows = compute_estimation(EMPTY, made, None, same)["bill"]["rows"] assert imp.differences(got, report, rows) == [] # 실무자료 사본 = 엑셀 단가 그대로 other = {"daega": [{**same["daega"][0], "labor": 30000, "sum": 33885}]} rows = compute_estimation(EMPTY, made, None, other)["bill"]["rows"] diff = imp.differences(got, report, rows) assert len(diff) == 3 and {d["까닭"] for d in diff} == {"이음 단가 = 우리 셈"} # 사본이 없어짐(연결이 단가를 못 냄) = 고정 단가 rows = compute_estimation(EMPTY, made, None, {})["bill"]["rows"] assert imp.differences(got, report, rows) == [] def test_이음_찾기_같은_파일_이름_본뜬만(tmp_path, monkeypatch): from B09_Estimation import B09_Estimation_Router_Import as route folder = tmp_path / "precombo" folder.mkdir() docs = { "SC-000001": {"층": "실무자료", "키": "HP-000001 제5호표", "파일": "a.xlsx"}, "SC-000002": {"층": "실무자료", "키": "HP-000001 제5호표 2째 줄", "파일": "a.xlsx"}, "SC-000003": {"층": "실무자료", "키": "HP-000002 제8호표", "파일": "b.xlsx"}, "SC-000004": {"층": "실무자료", "키": "HP-000001 제9호표", "파일": "a.xlsx"}, } for key, origin in docs.items(): (folder / f"{key}.json").write_text(json.dumps({"본뜬": origin}), encoding="utf-8") class Got: layer, owner = "personal", "1/3" def grant(session, layer): if layer != "personal": raise route.HTTPException(status_code=409) return Got monkeypatch.setattr(route.access, "grant", grant) monkeypatch.setattr(route.refs, "folder_of", lambda layer, owner: tmp_path) found = route._found({}, "a.xlsx", {5, 8}) assert found == {5: ("personal", "1/3", "SC-000001")} # 감싼 줄 · 다른 파일 · 안 쓰는 번 뺌