"""707-4 내역 줄 ↔ 우리 공종 잇기 — 짝 표 · 맞춤 기억 · 묶음 통째 잇기(`Bill_Link`) · 기억 층(`Router_Link`). - 짝 표 = 우리 토공 줄(`Bill_Items.EARTH`) 모두 · 실무 이름(EST 거창 · STmate 봉화) → 우리 줄 · 암 갈래(EST 연암 · 보통암) = 후보 둘 · 하나 아님 · 여러 줄 → 한 줄 = 하나 아님 - 맞춤 기억 = 거창 엑셀에서 이은 짝 → 같은 회사 장수 · 진안 .estx 저절로 이음 · 두 번 셈 0 - 수량 갈래 take = 엑셀 수량 덮개 / price = B08 · 엑셀 = 비교 칸 - 묶음 ↔ 구조물 줄 — take = 안 줄이 구조물 줄 아래 / price = 1 단위 고정 단가 · 안 줄 지움 · 금액 = 엑셀 - 못 잇는 짝(남의 줄 · 자재 줄 · 이미 이음 · 꼴 다름) · 기억 층 = 회사 관리자 회사 · 아니면 개인 - 셈 짝(`Calc_Bill` py · ts) 안 바뀜 — 거울은 test_707_1 · test_145_3 그대로 """ 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_Bill_Link as lk # noqa: E402 from B09_Estimation import B09_Estimation_Estx as ex # noqa: E402 from B09_Estimation import B09_Estimation_Router_Link as route # noqa: E402 from B09_Estimation import B09_Estimation_Store as store # noqa: E402 from B09_Estimation.B09_Estimation_Bill_Excel import read_bill # noqa: E402 from B09_Estimation.B09_Estimation_Bill_Items import EARTH, earth_items # noqa: E402 from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402 from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store # noqa: E402 K = ROOT / "resources" / "knowledge" / "original" / "실무문서" GEO = ( K / "01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx" ) BONG = K / "04.산불 봉화/01. 내역서/2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.xlsx" ESTX = { "장수": K / "02.산불 장수/01.내역서/00.단가변경_26년_산불_무주(장수,장수,덕산,산54-1).estx", "진안": K / "03.산불진안/01.내역서/00.단가변경_26년_산불_무주(진안,백운,백암,산1-2).estx", } ST = "st:pipe:box|1" ITEMS = [{**i, "qty": 100} for i in earth_items(None)] + [ {"key": ST, "section": "2.2", "name": "BOX 암거", "unit": "m", "qty": 10, "ref": {"tab": "sg_list", "key": "X"}}, ] # fmt: skip INPUTS = {"materials": [], "bill": {"items": ITEMS}, "hopyo": {"X": { "labor": 100, "material": 0, "expense": 0}}} # fmt: skip def _bill(user): return compute_estimation(INPUTS, user, None, {})["bill"] def _load(parsed): """707-1 먼저 싣기(붙임 없음) → (사용자 값, 셈 줄).""" user, _ = imp.apply(parsed, {}, _bill({})["rows"], "abcd1234", attach=False) user = store.clean_user(user) return user, _bill(user)["rows"] def _link(user, rows, pairs, mode="take", **kw): made, report = lk.link(user, rows, pairs, mode, **kw) made = store.clean_user(made) return made, report, _bill(made) @pytest.fixture(scope="module") def geo(): return read_bill(GEO.name, GEO.read_bytes()) def _pick(suggestions): """사용자 고름 꼴 — 보통암 = 둘째(암발파) · 그 밖 = 첫 후보 · 자동 줄 하나에 한 줄.""" out, used = [], set() for s in suggestions: keys = [c["key"] for c in s["후보"]] goal = keys[1] if len(keys) > 1 and "보통암" in s["spec"] else keys[0] if goal not in used: out.append((s["key"], goal)) used.add(goal) return out def test_짝_표_우리_토공_줄_모두(): names = {name for _s, name, _u, _w in EARTH} assert len(names) == len(EARTH) == 17 assert all(lk.table(n, "") == (n,) for n in names) # 우리 이름 그대로 assert all(set(t) <= names for _a, _p, t in lk.PAIRS) # 짝 표 = 있는 줄만 assert set(lk.EARTH_KEY.values()) == {f"earth:{n}" for n in range(1, 18)} def test_짝_표_실무_이름_암_갈래(geo): user, rows = _load(geo) got = {(s["name"], s["spec"]): s for s in lk.suggest(rows, {})} one = {k: s["후보"][0]["name"] for k, s in got.items() if s["하나"]} assert one == { ("토사절취", "백호0.7㎥"): "절토(토사)", ("측구터파기", "토사"): "측구파기(토사)", ("성토사면다짐", "B/H0.7+유압식콤팩터"): "성토사면다짐", ("토사운반", "굴삭기+덤프+굴삭기"): "덤프운반", ("수평규준틀", ""): "규준틀설치 종단규준틀", ("비탈규준틀", ""): "규준틀설치 횡단규준틀", ("구조물 터파기(토사)", "육샹토사(0~2m,굴삭기0.2)"): "구조물터파기(토사)", ("터파기(암)", ""): "구조물터파기(연암)", ("되메우기", "백호0.7㎥(100%)"): "구조물 되메우기", } rock = got[("암석절취", "보통암")] assert [c["name"] for c in rock["후보"]] == ["절토(연암)", "암발파"] assert not rock["하나"] and rock["까닭"] == "한 줄 ↔ 한 줄 아님 — 고름" side = got[("측구터파기", "연암")] # 연암 · 보통암 둘 → 측구파기(연암) 하나 assert not side["하나"] and side["까닭"] == "여러 줄 → 한 줄 — 고름" assert all(s["꼬리표"] == lk.TABLE for s in got.values()) bong = lk.suggest(_load(read_bill(BONG.name, BONG.read_bytes()))[1], {}) named = {s["name"]: s["후보"][0]["name"] for s in bong if s["하나"]} assert named["토사절취(흙깎기)"] == "절토(토사)" assert named["구조물터파기(육상암0~1m)"] == "구조물터파기(연암)" assert named["비탈규준틀(@60M)"] == "규준틀설치 횡단규준틀" def test_맞춤_기억_두_번째_공사_저절로(geo, tmp_path): user, rows = _load(geo) made, report, bill = _link(user, rows, _pick(lk.suggest(rows, {}))) assert report["이음"] == 12 and report["못함"] == [] hopyo_store.remember(tmp_path, report["기억"], lk.MEMORY) assert hopyo_store.memory(tmp_path) == {} # 실무자료 맞춤과 다른 파일 memory = hopyo_store.memory(tmp_path, lk.MEMORY) assert memory["암석절취|보통암|m3"]["확정"] == "earth:6" for name, path in ESTX.items(): parsed = ex.read_bill(path.name, path.read_bytes()) user, rows = _load(parsed) lines = lk.suggest(rows, memory) assert sum(s["꼬리표"] == lk.RECALL for s in lines) == 12, name pairs = lk.auto_pairs(lines) assert len(pairs) == 11, name # 측구 연암 = 보통암 줄과 한 자동 줄 → 고름 made, report, bill = _link(user, rows, pairs) assert report["이음"] == 11 and report["못함"] == [] keys = [r["key"] for r in bill["rows"]] assert len(keys) == len(set(keys)) # 두 번 셈 0 — 줄 하나씩 assert not {k for k, _t in pairs} & set(keys) # 이은 불러온 줄 = 지움 # take = 엑셀 수량 · 고정 단가 → 직접비(아는 금액) = 파일 합 + 안 이은 구조물 줄 assert bill["totals"]["direct"]["sum"] == parsed["합계"]["직접"] + 1000, name assert lk.auto_pairs(lk.suggest(bill["rows"], memory)) == [] # 다시 = 이음 없음 def test_수량_갈래_가_나(geo): user, rows = _load(geo) line = next(r for r in rows if r["name"] == "토사절취") for mode, qty in (("take", 7641), ("price", 100)): made, report, bill = _link(user, rows, [(line["key"], "earth:4")], mode) cut = next(r for r in bill["rows"] if r["key"] == "earth:4") assert (cut["qty"], cut["src_qty"], cut["fixed"]) == (qty, 7641, True) assert cut["parent"] == line["parent"] # 불러온 자리 assert cut["price_sum"] == line["price_sum"] assert line["key"] not in {r["key"] for r in bill["rows"]} def test_묶음_통째_잇기(geo): user, rows = _load(geo) base = _bill(user)["totals"]["direct"]["sum"] group = next(r for r in rows if r["name"] == "콘크리트구조물") inner = [r["key"] for r in rows if r["parent"] == group["key"]] made, report, bill = _link(user, rows, [(group["key"], ST)], "take") got = {r["key"]: r for r in bill["rows"]} assert report["묶음"] == 1 and group["key"] not in got assert got[ST]["kind"] == "group" and got[ST]["sum"] == group["sum"] assert all(got[k]["parent"] == ST for k in inner) # 안 줄 = 딸려 감(1 : N) assert bill["totals"]["direct"]["sum"] == base - 1000 # 구조물 줄 제 금액 = 안 줄 합으로 assert report["기억"] == {"콘크리트구조물||": {"확정": ST, "이름": "BOX 암거"}} made, report, bill = _link(user, rows, [(group["key"], ST)], "price") got = {r["key"]: r for r in bill["rows"]} assert report["지움"] == len(inner) and not set(inner) & set(got) assert got[ST]["kind"] == "item" and (got[ST]["qty"], got[ST]["src_qty"]) == (10, 10) assert abs(got[ST]["sum"] - group["sum"]) <= 3 # B08 수량 × 1 단위 단가 · 끝수 made, report, bill = _link(user, rows, [(group["key"], ST)], "price", counts={group["key"]: 5}) assert next(r for r in bill["rows"] if r["key"] == ST)["sum"] == pytest.approx( group["sum"] * 2, abs=3 ) # 경쟁사 5 개 · B08 10 개 = 두 배 memory = {"콘크리트구조물||": {"확정": "st:other", "이름": "BOX 암거"}} assert lk.auto_pairs(lk.suggest(rows, memory)) == [(group["key"], ST)] # 키 없음 = 같은 이름 def test_못_잇는_짝(geo): user, rows = _load(geo) line = next(r for r in rows if r["name"] == "토사절취") group = next(r for r in rows if r["name"] == "콘크리트구조물") pairs = [ ("earth:3", "earth:4"), (line["key"], "mat:x"), (line["key"], ST), (group["key"], "earth:4"), (line["key"], "earth:4"), (next(r for r in rows if r["name"] == "수평규준틀")["key"], "earth:4"), ] _made, report, _bill = _link(user, rows, pairs) assert [m["까닭"] for m in report["못함"]] == [ "불러온 줄이 아님", "이을 수 없는 줄", "묶음 ↔ 구조물 줄만", "묶음 ↔ 구조물 줄만", "이미 이은 줄", ] assert report["이음"] == 1 with pytest.raises(ValueError): lk.link(user, rows, [], "both") assert lk.link(user, rows, [], "take")[1]["이음"] == 0 @pytest.mark.parametrize( "role, layer", [("USER", "personal"), ("ADMIN", "company")], ids=["일반", "회사관리자"] ) def test_기억_층(tmp_path, monkeypatch, role, layer): monkeypatch.setattr(route.access, "folder", lambda got, create=False: tmp_path / got.layer) monkeypatch.setattr(route.m02_store, "FOLDER", tmp_path / "system") master = {"a||": {"확정": "earth:9"}, "m||": {"확정": "earth:1"}} # 시스템 승인(718-2) hopyo_store.remember(tmp_path / "system", master, lk.MEMORY) session = {"company_id": 7, "user_id": 3, "role": role} assert route.keep(session, {"a||": {"확정": "earth:4", "이름": "절토(토사)"}}) == layer assert route.keep(session, {}) is None hopyo_store.remember( tmp_path / "company", {"a||": {"확정": "earth:5"}, "b||": {"확정": "x"}}, lk.MEMORY ) got = route.recalled(session) assert got["a||"]["확정"] == ("earth:4" if layer == "personal" else "earth:5") assert got["b||"]["확정"] == "x" assert got["m||"]["확정"] == "earth:1" # 회사 · 개인 줄이 마스터보다 앞섬(a = 아래 층) assert route.recalled({"user_id": 3, "role": role}) == master # 회사 없음 = 마스터만 def _same(a, b): """두 셈 = 같은 줄 · 같은 직접비.""" assert [r["key"] for r in a["rows"]] == [r["key"] for r in b["rows"]] assert a["totals"]["direct"]["sum"] == b["totals"]["direct"]["sum"] @pytest.mark.parametrize("mode", ["take", "price"]) def test_잇기_풀기_제자리(geo, mode): user, rows = _load(geo) before = _bill(user) group = next(r for r in rows if r["name"] == "콘크리트구조물") pairs = [*_pick(lk.suggest(rows, {})), (group["key"], ST)] made, report, bill = _link(user, rows, pairs, mode) assert report["이음"] == 12 and report["묶음"] == 1 linked = [e for e in made["bill"]["rows"] if e.get("was")] assert len(linked) == 13 # 저장 다듬기 뒤에도 되돌릴 것 남음 for entry in linked[::2] + linked[1::2]: # 이은 차례와 다르게 made, why = lk.unlink(made, bill["rows"], entry["key"]) assert why == "" made = store.clean_user(made) bill = _bill(made) _same(bill, before) assert lk.unlink(made, bill["rows"], "earth:4")[1] == "이은 줄이 아님" def test_줄마다_갈래_판(geo): user, rows = _load(geo) cut = next(r for r in rows if r["name"] == "토사절취") side = next(r for r in rows if r["name"] == "측구터파기" and r["spec"] == "토사") pairs = [(cut["key"], "earth:4"), (side["key"], "earth:11")] made, report, bill = _link(user, rows, pairs, "take", modes={side["key"]: "price"}) got = {r["key"]: r for r in bill["rows"]} assert got["earth:4"]["qty"] == cut["qty"] and got["earth:11"]["qty"] == 100 with pytest.raises(ValueError): lk.link(user, rows, pairs, "take", modes={cut["key"]: "both"}) board = lk.board(made, bill["rows"]) left = {r["key"] for r in board["남은"]} assert cut["key"] not in left and side["key"] not in left assert {r["key"] for r in board["이은"]} == {"earth:4", "earth:11"} assert board["이은"][0]["memo"] in report["기억"] assert not {"earth:4", "earth:11"} & {r["key"] for r in board["자리"]} group = next(r for r in rows if r["name"] == "콘크리트구조물") inner = {r["key"] for r in rows if r["parent"] == group["key"]} made, _r, bill = _link(made, bill["rows"], [(group["key"], ST)], "take") left = {r["key"] for r in lk.board(made, bill["rows"])["남은"]} assert not inner & left # 구조물 묶음에 딸린 줄 = 남은 줄 아님 def test_이_짝_잊기(tmp_path, monkeypatch): monkeypatch.setattr(route.access, "folder", lambda got, create=False: tmp_path / got.layer) user = {"company_id": 7, "user_id": 3, "role": "USER"} gone = {lk.FORGOT: True} route.keep(user, {"a||": {"확정": "earth:4"}, "b||": {"확정": "earth:5"}}) hopyo_store.remember(tmp_path / "company", {"c||": {"확정": "x"}}, lk.MEMORY) assert route.keep(user, {"a||": gone}) == "personal" assert route.recalled(user)["a||"] == gone and route.recalled(user)["b||"]["확정"] == "earth:5" route.keep(user, {"c||": gone}) # 일반 = 회사 기억을 개인 층 표시로 덮음 assert route.recalled(user)["c||"] == gone def test_잊은_짝_느슨한_기억에도_안_나옴(tmp_path, monkeypatch): monkeypatch.setattr(route.access, "folder", lambda got, create=False: tmp_path / got.layer) user = {"company_id": 7, "user_id": 3, "role": "USER"} row = {"name": "토사절취", "spec": "A", "unit": "m3"} other = {"name": "토사절취", "spec": "B", "unit": "m3"} live = {"확정": "earth:4", "이름": "절토(토사)"} route.keep(user, {lk.key_of(other): live}) assert ( lk.recall(route.recalled(user), row) == live ) # 규격 달라도 품명 · 단위 같음 = 느슨한 기억 route.keep(user, {lk.key_of(row): {lk.FORGOT: True}}) memory = route.recalled(user) assert lk.recall(memory, row) is None # 잊음 = 느슨한 기억도 끊김 assert lk.recall(memory, other) == live # 다른 줄은 그대로 third = {"name": "토사절취", "spec": "C", "unit": "m3"} assert lk.recall(memory, third) is None # 잊은 짝 곁에서는 느슨하게 안 좁힘 route.keep(user, {lk.key_of(row): live}) # 다시 이음 = 풀림 assert lk.recall(route.recalled(user), row) == live def test_잊은_짝_짝_표_제안에도_안_나옴(geo): _user, rows = _load(geo) now = {s["key"]: s for s in lk.suggest(rows, {})} line = next(r for r in rows if (r["name"], r.get("spec")) == ("토사절취", "백호0.7㎥")) assert now[line["key"]]["꼬리표"] == lk.TABLE memory = {lk.key_of(line): {lk.FORGOT: True}} after = {s["key"]: s for s in lk.suggest(rows, memory)} assert line["key"] not in after # 짝 표 제안도 잊음 assert set(now) - set(after) == {line["key"]} # 다른 줄은 그대로 memory[lk.key_of(line)] = {"확정": "earth:4", "이름": "절토(토사)"} # 다시 이음 = 풀림 assert lk.suggest(rows, memory)[0] is not None assert line["key"] in {s["key"] for s in lk.suggest(rows, memory)}