"""166-2 B09 설계내역서 손질 — 수량 덮어쓰기 · 손 줄 · 호표 연결 · 새 산근 · 거울 · 우클릭 메뉴. - 수량 = 옛 꼴(184 전) `user.bill.qty[줄 키]` 가 있으면 그 값(`auto_qty` = 자동 값) · 키 지우면 자동(B08) - 손 줄 = `user.bill.rows` — `after` 줄 뒤(제목 = 공종 첫 자리 · 못 찾으면 공종 끝) · 단가 = `ref_price` 하나(dg 대가 목록 · sg 산근 목록 · logic = `sg_hand` 산근) · 비고 = 「대가n」 · 「산근 n호표」 - 새 산근(M01 로직) = 사본 산근 뒤 번호 · 쓰는 곳 = 「손 줄 …」 · 같은 로직 · 입력 = 같은 산근 - 호표 탭 연동 = 손 줄이 연결한 산근이 단가산출근거(목록)에 실리고 집계표 역전개에 듦 """ import json import re import subprocess import sys from pathlib import Path 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 sg # noqa: E402 from B09_Estimation.B09_Estimation_Calc_Base import compute_base # noqa: E402 from B09_Estimation.B09_Estimation_Calc_Bill import WHY_LOGIC, compute_estimation # noqa: E402 from B09_Estimation.B09_Estimation_Linked import used_keys # noqa: E402 from B09_Estimation.B09_Estimation_Store import clean_user # noqa: E402 from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines # noqa: E402 from M02_MasterTemplete import M02_Item_Prices as item_prices # noqa: E402 IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) STONE = "GF000219" # 돌쌓기 메쌓기(인력) — 석공 0.195 · 보통인부 0.156 / ㎡ NET = "GC000337" NET_IN = {"구분": "기계식", "방지망": "포켓식", "지역": "전국평균", "크레인": "2104-0010"} ITEMS = [ {"key": "earth:1", "section": "1.1", "name": "규준틀설치 종단규준틀", "unit": "개소", "qty": 14, "ref": {"tab": "dg_list", "key": "B00194"}}, {"key": "earth:4", "section": "1.2", "name": "절토(토사)", "unit": "㎥", "qty": 5675, "ref": {"tab": "sg_list", "key": "D01341"}}, {"key": "earth:5", "section": "1.2", "name": "절토(연암)", "unit": "㎥", "qty": 972, "ref": None, "why": "조합 밖 토공 산근 — 연결 없음"}, ] # fmt: skip HOPYO = { "B00194": {"labor": 21936, "material": 3885, "expense": 0}, "D01341": {"labor": 1116, "material": 360, "expense": 463}, } INPUTS = {"materials": [], "bill": {"items": ITEMS}, "hopyo": HOPYO} BOOKS = { "daega": [{"no": 3, "key": "DG:a", "labor": 1000, "material": 200, "expense": 30, "sum": 1230}], "sg_list": [{"no": 1, "key": "SG:x", "labor": 500, "material": 0, "expense": 7, "sum": 507}, {"no": 2, "key": "SG:new", "labor": 10, "material": 1, "expense": 0, "sum": 11}], "sg_hand": {"hand:c": "SG:new"}, } # fmt: skip HAND = [ {"key": "hand:a", "section": "1.1", "after": "earth:1", "name": "제근", "spec": "", "unit": "㎡", "qty": 10, "ref": {"tab": "dg", "key": "DG:a"}}, {"key": "hand:b", "section": "1.2", "after": "sec:1.2", "name": "벌개", "spec": "", "unit": "㎡", "qty": 4, "ref": {"tab": "sg", "key": "SG:x"}}, {"key": "hand:c", "section": "1.2", "after": "없는줄", "name": "메쌓기", "spec": "", "unit": "㎡", "qty": 2.5, "ref": {"tab": "logic", "code": STONE, "given": {}, "rate": None, "picks": None}}, {"key": "hand:d", "section": "1.2", "after": "hand:b", "name": "새 산근", "spec": "", "unit": "㎡", "qty": 3, "ref": {"tab": "logic", "code": NET, "given": NET_IN}}, ] # fmt: skip USER = {"bill": {"qty": {"earth:4": 100}, "rows": HAND}} def _rows(sheets): return {row["key"]: row for row in sheets["bill"]["rows"]} def _node(source: str) -> str: result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript", "-e", source], capture_output=True, text=True, encoding="utf-8", timeout=60, ) # fmt: skip assert result.returncode == 0, result.stderr return result.stdout def _ts(names: list[str]) -> str: return "".join( IMPORTS.sub("", (ROOT / "B09_Estimation" / f"B09_Estimation_{n}.ts").read_text("utf-8")) for n in names ) def test_수량_덮어쓰기_자동_값_남김_지우면_자동(): rows = _rows(compute_estimation(INPUTS, USER, None, BOOKS)) row = rows["earth:4"] assert (row["qty"], row["auto_qty"], row["labor"]) == (100, 5675, 111600) # 1,116 × 100 plain = _rows(compute_estimation(INPUTS, {}, None, BOOKS))["earth:4"] assert plain["qty"] == 5675 and "auto_qty" not in plain # 184 auto_{칸} 꼴 # 단가 그대로 · 수량 없던 줄도 덮어쓰면 금액이 생김 rows = _rows(compute_estimation(INPUTS, {"bill": {"qty": {"earth:1": 1}}}, None, BOOKS)) assert rows["earth:1"]["sum"] == 25821 def test_손_줄_자리_단가_비고(): sheets = compute_estimation(INPUTS, USER, None, BOOKS) keys = [r["key"] for r in sheets["bill"]["rows"] if r["section"] in ("1.1", "1.2")] assert keys == [ "sec:1.1", "earth:1", "hand:a", # earth:1 뒤 # 제목 = 첫 자리 · 못 찾음 = 끝 "sec:1.2", "hand:b", "hand:d", "earth:4", "earth:5", "hand:c", ] # fmt: skip rows = _rows(sheets) a = rows["hand:a"] assert (a["note"], a["ref"], a["hand"]) == ("대가3", {"tab": "dg", "key": "DG:a"}, True) assert (a["labor"], a["material"], a["expense"], a["sum"]) == (10000, 2000, 300, 12300) assert rows["hand:b"]["note"] == "산근 1호표" and rows["hand:b"]["sum"] == 2028 c = rows["hand:c"] # 로직 = 서버가 준 sg_hand 산근 assert (c["note"], c["ref"], c["sum"]) == ("산근 2호표", {"tab": "sg", "key": "SG:new"}, 27) d = rows["hand:d"] # 새 산근 [저장] 전 = 빈칸 + 까닭 assert d["sum"] is None and d["ref"] is None assert ("bill", "hand:d", WHY_LOGIC) in {(g["tab"], g["key"], g["why"]) for g in sheets["gaps"]} assert rows["sec:1.1"]["sum"] == 361494 + 12300 # 제목 합에 손 줄 assert rows["sec:1.2"]["blank"] == 2 # 절토(연암) · 새 산근 def test_거울_TS_가_같은_값(): calc = _ts(["Calc_Base", "Calc_Bill"]) for user in (USER, {}, {"bill": {"qty": {"earth:5": None}}}): body = ( f"\nconst i = {json.dumps(INPUTS, ensure_ascii=False)};" f"\nconst u = {json.dumps(user, ensure_ascii=False)};" f"\nconst b = {json.dumps(BOOKS, ensure_ascii=False)};" "\nconsole.log(JSON.stringify(computeEstimation(i, u, null, b)));" ) assert json.loads(_node(calc + body)) == compute_estimation(INPUTS, user, None, BOOKS) def test_저장_다듬기_bill(): raw = { "bill": { "qty": {"earth:4": 100, "earth:5": "x", "st:a#대가1": 2.5}, "rows": [ *HAND[:1], {"key": "x:1", "section": "1.1"}, # hand: 아님 {"key": "hand:z", "section": "9"}, # 모르는 공종 {**HAND[2], "ref": {**HAND[2]["ref"], "given": {"a": 1, "b": [1]}}, "junk": 1}, {**HAND[1], "ref": {"tab": "zz", "key": "q"}}, ], } } bill = clean_user(raw)["bill"] assert bill["qty"] == {"earth:4": 100, "st:a#대가1": 2.5} assert [r["key"] for r in bill["rows"]] == ["hand:a", "hand:c", "hand:b"] assert bill["rows"][1]["ref"] == {"tab": "logic", "code": STONE, "name": "", "given": {"a": 1}, "rate": None, "picks": None} # fmt: skip assert "junk" not in bill["rows"][1] and bill["rows"][2]["ref"] is None assert "bill" not in clean_user({"bill": {"qty": {}, "rows": []}}) def _snapshot(folder: Path, rows, values=None) -> Path: folder.mkdir(parents=True, exist_ok=True) files = { "단가.json": item_prices.to_json(item_prices.collect([STONE, NET])), "일위대가.json": {"줄": {"종류": "일위대가", "담은로직": rows}}, "입력값.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 test_새_산근_사본_뒤_번호_호표_탭_집계표(tmp_path): a = _snapshot(tmp_path / "a", [{"로직": NET}], {NET: NET_IN}) user = {"bill": {"rows": [{**HAND[2], "qty": 10}, HAND[3]]}} hand_keys = sg.hand_resource_keys(user) assert {"LB000033", "LB000002"} <= set(hand_keys) # 석공 · 보통인부 → 단가 책 수요 keys = list(dict.fromkeys([*used_keys(a), *hand_keys])) base = compute_base(prices.build_inputs(None, keys), {}) got = sg.collect([("s1", a)], base, sg.hand_sources(user)) listed = [(r["no"], r["logic"], r["uses"]) for r in got["sg_list"]] assert listed == [(1, NET, ["s1", "수기 줄 새 산근"]), (2, STONE, ["수기 줄 메쌓기"])] assert got["sg_hand"] == { "hand:c": got["sg_list"][1]["key"], "hand:d": got["sg_list"][0]["key"], } stone = got["sg"][1] assert stone["sum"] is not None and stone["why"] is None # 내역서 — 손 줄 단가 = 새 산근 · 비고 「산근 2호표」 · 집계표 역전개에 듦 books = {"daega": [], "sg_list": got["sg_list"], "sg_hand": got["sg_hand"]} sheets = {**compute_estimation({**INPUTS, "bill": {"items": []}}, user, None, books), **got} row = _rows(sheets)["hand:c"] assert (row["note"], row["price_sum"]) == ("산근 2호표", stone["sum"]) labor = compute_summary(sheets, hopyo_lines(sheets))["sum_labor"] qty = {r["key"]: r["qty"] for r in labor} assert qty["LB000033"] >= round(10 * 0.195, 2) # 석공 = 손 줄 수량 × 0.195(+ 낙석망) def test_우클릭_메뉴_항목_추가_줄_삭제_연결_TS(): # 184-3 — 모든 줄 메뉴 · 셈 출력 줄 = 계약 꼴(parent · auto · auto_{칸}) · 첫 손질 = v2 로 굳힘 src = _ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit"]) rows = [ {"kind": "title", "key": "sec:1", "parent": None, "auto": True}, {"kind": "title", "key": "sec:1.2", "parent": "sec:1", "auto": True}, {"kind": "item", "key": "earth:4", "parent": "sec:1.2", "auto": True, "qty": 100, "auto_qty": 5675}, {"kind": "item", "key": "hand:b", "parent": "sec:1.2", "hand": True}, ] # fmt: skip body = f""" const rows = {json.dumps(rows, ensure_ascii=False)}; const first = {json.dumps(HAND[1], ensure_ascii=False)}; const start = {{ bill: {{ qty: {{ "earth:4": 100 }}, rows: [first] }} }}; let user = start; const ctx = {{ sheets: {{}}, payload: {{}}, get user() {{ return user; }}, setUser: (u) => (user = u) }}; const name = (x) => (x === "-" ? "-" : Array.isArray(x) ? x[0] : x.label); const labels = (i) => billMenu(ctx, rows, i).map(name); const run = (i, label) => {{ user = start; billMenu(ctx, rows, i).find((x) => Array.isArray(x) && x[0] === label)[1](); return user.bill; }}; const out = {{ title: labels(1), auto: labels(2), hand: labels(3) }}; out.below = run(2, "항목 추가"); out.reset = run(2, "자동값으로 되돌리기"); out.left = run(3, "줄 삭제"); console.log(JSON.stringify(out)); """ out = json.loads(_node(src + body)) # 246-1 더함 = 「항목 추가」 하나 · 지움 = 「줄 삭제」 · 2단 제목 ← = 1단으로 assert out["title"] == ["항목 추가", "-", "내어쓰기 ←", "-", "줄 삭제"] assert out["auto"] == ["항목 추가", "-", "단가연결", "-", "아래로 ↓", "내어쓰기 ←", "-", "자동값으로 되돌리기", "줄 삭제"] # fmt: skip # 204 — 위 항목 아래로 들여쓰기 됨(그 항목 = 저절로 묶음) assert out["hand"] == ["항목 추가", "-", "단가연결", "단가연결 풀기", "-", "위로 ↑", "들여쓰기 →", "내어쓰기 ←", "묶음 줄로 바꾸기", "-", "줄 삭제"] # fmt: skip # 항목 추가(항목 줄) = 바로 아래 같은 부모 · 옛 꼴 → v2(옛 손 줄 칸 · 수량 덮개 옮김) below = out["below"] assert [e["key"][:5] for e in below["rows"]] == ["sec:1", "sec:1", "earth", "hand:", "hand:"] earth, new, hand = below["rows"][2:] assert (new["name"], new["parent"], below["v"]) == ("새 줄", "sec:1.2", 2) assert earth == {"key": "earth:4", "parent": "sec:1.2", "qty": 100} assert (hand["key"], hand["qty"], hand["ref"]) == ("hand:b", 4, HAND[1]["ref"]) assert "qty" not in out["reset"]["rows"][2] # 자동값으로 = 수량 덮개 지움 # 새 줄 지움 = 덮개에서 빠짐(gone 은 자동 줄만) assert [e["key"] for e in out["left"]["rows"]] == ["sec:1", "sec:1.2", "earth:4"] assert out["left"]["gone"] == []