"""182 B09 설계내역서 손 줄 레벨 — 묶음 줄 · 부모 · 상위 합계 · 거울 · 저장 왕복 · 우클릭 레벨 메뉴. - 손 줄 `kind: "group"` = 묶음 줄(수량 · 연결 없음) · `parent` = 같은 공종 묶음 손 줄 키 - `level` = 제목(1 · 2) · 줄 = 공종 깊이 + 1 + 손 묶음 조상 수 · 부모 못 찾음 · 다른 공종 · 돎 = 공종 바로 아래 - 묶음 금액 = 자손 항목 합 + 「빈 줄 n」 · 공종 합 · `sums` = 항목만(원가계산서 그대로) """ 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_Store as store # noqa: E402 from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402 IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) 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": 10, "ref": {"tab": "sg_list", "key": "D01341"}}, {"key": "earth:5", "section": "1.2", "name": "절토(연암)", "unit": "㎥", "qty": 9, "ref": None}, {"key": "haul:1", "section": "3", "name": "운반", "unit": "㎥", "qty": 2, "ref": {"tab": "sg_list", "key": "D01341"}}, ] # 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}], } # fmt: skip DG = {"tab": "dg", "key": "DG:a"} def _hand(key, after, section="1.2", **more): row = {"key": key, "section": section, "after": after, "name": key[5:], "spec": "", "unit": "㎡", "qty": None, "ref": None} # fmt: skip return {**row, **more} HAND = [ _hand("hand:g1", "earth:4", kind="group"), _hand("hand:a", "hand:g1", parent="hand:g1", qty=10, ref=DG), _hand("hand:g2", "hand:a", kind="group", parent="hand:g1"), _hand("hand:b", "hand:g2", parent="hand:g2", qty=2, ref=DG), _hand("hand:c", "hand:b", parent="hand:g2", qty=1), # 연결 없음 = 빈 줄 _hand("hand:x", "hand:c", parent="hand:없음", qty=1, ref=DG), # 부모 없음 → 공종 아래 _hand("hand:y", "haul:1", "3", parent="hand:g1", qty=1, ref=DG), # 다른 공종 부모 _hand("hand:p", "earth:1", "1.1", kind="group", parent="hand:q"), # 돌기 _hand("hand:q", "hand:p", "1.1", kind="group", parent="hand:p"), ] USER = {"bill": {"rows": HAND}} #: 같은 손 줄 · 묶음 없이(묶음 줄 빼고 부모 지움) — 공종 합 · sums 비교 밑 FLAT = { "bill": { "rows": [ {k: v for k, v in h.items() if k != "parent"} for h in HAND if h.get("kind") != "group" ] } } def _rows(sheets): return {row["key"]: row for row in sheets["bill"]["rows"]} def _node(source: str) -> str: # 표준입력으로 줌 — 「-e」 는 윈도 명령 줄 길이(32k)를 넘음 result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"], input=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_레벨_차례_부모(): sheets = compute_estimation(INPUTS, USER, None, BOOKS) got = [(r["key"], r["level"]) for r in sheets["bill"]["rows"] if r["section"] == "1.2"] assert got == [ ("sec:1.2", 2), ("earth:4", 3), ("hand:g1", 3), ("hand:a", 4), ("hand:g2", 4), ("hand:b", 5), ("hand:c", 5), ("hand:x", 3), ("earth:5", 3), ] # fmt: skip rows = _rows(sheets) assert rows["sec:1"]["level"] == 1 and rows["sec:3"]["level"] == 1 # 184 — parent = 실제 부모 키(공종 바로 아래 = 그 제목 키) assert (rows["haul:1"]["level"], rows["hand:y"]["level"], rows["hand:y"]["parent"]) == ( 2, 2, "sec:3", ) assert rows["hand:x"]["parent"] == "sec:1.2" and rows["hand:b"]["parent"] == "hand:g2" # 돌기 — 먼저 나온 줄이 끊김(p = 공종 아래 · q = p 아래) assert (rows["hand:p"]["parent"], rows["hand:p"]["level"]) == ("sec:1.1", 3) assert (rows["hand:q"]["parent"], rows["hand:q"]["level"]) == ("hand:p", 4) # 묶음 줄 = 이름만 · 「연결 없음」 목록에 안 듦 g1 = rows["hand:g1"] assert (g1["kind"], g1["qty"], g1["ref"], g1["hand"]) == ("group", None, None, True) assert not {g["key"] for g in sheets["gaps"]} & {"hand:g1", "hand:g2", "hand:p", "hand:q"} def test_묶음_금액_자손_합_공종_합_그대로(): sheets = compute_estimation(INPUTS, USER, None, BOOKS) rows = _rows(sheets) g2, g1 = rows["hand:g2"], rows["hand:g1"] assert (g2["sum"], g2["labor"], g2["material"], g2["expense"]) == (2460, 2000, 400, 60) assert (g2["blank"], g2["note"]) == (1, "빈 줄 1") assert (g1["sum"], g1["labor"], g1["blank"]) == (12300 + 2460, 12000, 1) # a + b(+ c 빈 줄) assert rows["hand:p"]["sum"] == 0 and rows["hand:p"]["note"] == "" # 공종 합 · 원가계산서 sums · 계 = 묶음 없을 때와 같음(묶음 줄 안 셈) flat = compute_estimation(INPUTS, FLAT, None, BOOKS) titles = [r for r in sheets["bill"]["rows"] if r["kind"] == "title"] assert titles == [{**r} for r in flat["bill"]["rows"] if r["kind"] == "title"] assert sheets["bill"]["sums"] == flat["bill"]["sums"] assert sheets["bill"]["totals"] == flat["bill"]["totals"] assert rows["sec:1.2"]["sum"] == 19390 + 12300 + 2460 + 1230 # earth:4 + a + b + x def test_거울_TS_가_같은_값(): calc = _ts(["Calc_Base", "Calc_Bill"]) for user in (USER, FLAT, {}): 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_저장_왕복_kind_parent(tmp_path): raw = { "bill": { "rows": [ {**HAND[0], "qty": 5, "ref": DG}, # 묶음 줄 = 수량 · 연결 버림 HAND[1], {**HAND[3], "parent": "x:1", "kind": "zz"}, # hand: 아닌 부모 · 모르는 꼴 버림 ] } } clean = store.write_user(tmp_path, raw) assert store.read_user(tmp_path)["bill"] == clean["bill"] g1, a, b = clean["bill"]["rows"] assert (g1["kind"], g1["qty"], g1["ref"], "parent" in g1) == ("group", None, None, False) assert (a["parent"], "kind" in a, a["ref"]) == ("hand:g1", False, DG) assert "parent" not in b and "kind" not in b # 다시 읽은 값으로 셈 = 원래 값으로 셈 back = store.read_user(tmp_path) assert _rows(compute_estimation(INPUTS, back, None, BOOKS))["hand:g1"]["sum"] == 12300 MENU_JS = """ const rows = __ROWS__; const start = __USER__; 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 at = (key) => rows.findIndex((r) => r.key === key); const labels = (key) => billMenu(ctx, rows, at(key)).map(name); const run = (key, label) => { user = start; billMenu(ctx, rows, at(key)).find((x) => Array.isArray(x) && x[0] === label)[1](); return user.bill; }; // 1.2 공종 차례 [키(새 줄 = NEW), 부모] const order = (bill) => { const list = bill.rows.map((r) => [r.name === "새 줄" ? "NEW" : r.key, r.parent ?? null]); const from = list.findIndex(([k]) => k === "sec:1.2"); return list.slice(from + 1, list.findIndex(([k]) => k === "earth:5") + 1); }; const out = { g1: labels("hand:g1"), a: labels("hand:a"), g2: labels("hand:g2"), b: labels("hand:b"), x: labels("hand:x"), auto: labels("earth:4"), }; out.child = order(run("hand:g2", "아래에 하위 줄 추가")); out.below = order(run("hand:g1", "아래에 줄 추가")); out.belowB = order(run("hand:b", "아래에 줄 추가")); out.indentBill = run("hand:x", "들여쓰기 →"); out.indent = order(out.indentBill); out.outdent = order(run("hand:g2", "내어쓰기 ←")); out.toItem = run("hand:g2", "항목으로 바꾸기").rows.filter((r) => ["hand:g2", "hand:b"].includes(r.key)); out.toGroup = run("hand:x", "묶음 줄로 바꾸기").rows.find((r) => r.key === "hand:x"); out.removed = order(run("hand:g1", "줄 지우기")); console.log(JSON.stringify(out)); """ #: 1.2 공종 기본 차례(셈 출력) — [키, 부모] BASE = [ ["earth:4", "sec:1.2"], ["hand:g1", "sec:1.2"], ["hand:a", "hand:g1"], ["hand:g2", "hand:g1"], ["hand:b", "hand:g2"], ["hand:c", "hand:g2"], ["hand:x", "sec:1.2"], ["earth:5", "sec:1.2"], ] # fmt: skip def _put(order, at, *pairs): return [*order[:at], *[list(p) for p in pairs], *order[at:]] def test_우클릭_레벨_메뉴_TS(): # 184-3 — 모든 줄 메뉴 · 손질 = user.bill v2(차례 · 부모) rows = compute_estimation(INPUTS, USER, None, BOOKS)["bill"]["rows"] body = MENU_JS.replace("__ROWS__", json.dumps(rows, ensure_ascii=False)).replace( "__USER__", json.dumps(USER, ensure_ascii=False) ) out = json.loads(_node(_ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit"]) + body)) level = {"묶음 줄로 바꾸기", "항목으로 바꾸기", "들여쓰기 →", "내어쓰기 ←"} pick = lambda key: [x for x in out[key] if x in level] # noqa: E731 assert out["g1"][:3] == ["위에 줄 추가", "아래에 줄 추가", "아래에 하위 줄 추가"] assert "연결" not in out["g1"] and pick("g1") == ["내어쓰기 ←", "항목으로 바꾸기"] assert pick("a") == ["내어쓰기 ←", "묶음 줄로 바꾸기"] # 위 형제 없음 → 들여쓰기 없음 assert pick("g2") == ["내어쓰기 ←", "항목으로 바꾸기"] # 위 형제 a = 항목 assert pick("b") == ["내어쓰기 ←", "묶음 줄로 바꾸기"] # 레벨 5 묶음까지 assert pick("x") == ["들여쓰기 →", "내어쓰기 ←", "묶음 줄로 바꾸기"] # 위 형제 g1 assert pick("auto") == ["내어쓰기 ←"] and "줄 지우기" in out["auto"] # 자동 줄도 지움 assert out["child"] == _put(BASE, 6, ["NEW", "hand:g2"]) # g2 마지막 자식 뒤 assert out["below"] == _put(BASE, 6, ["NEW", "sec:1.2"]) # g1 자손 끝 뒤 · 같은 부모 assert out["belowB"] == _put(BASE, 5, ["NEW", "hand:g2"]) assert out["indent"] == [*BASE[:6], ["hand:x", "hand:g1"], BASE[7]] # g1 마지막 자식 assert out["outdent"] == [*BASE[:3], ["hand:g2", "sec:1.2"], *BASE[4:]] # g1 뒤 assert [(r["key"], r.get("kind"), r["parent"]) for r in out["toItem"]] == [ ("hand:g2", None, "hand:g1"), ("hand:b", None, "hand:g1")] # fmt: skip assert (out["toGroup"]["kind"], out["toGroup"]["qty"], out["toGroup"]["ref"]) == ( "group", None, None, ) # g1 지움 — 자식은 g1 부모(공종) 아래 · 그 자리 assert out["removed"] == [BASE[0], ["hand:a", "sec:1.2"], ["hand:g2", "sec:1.2"], *BASE[4:]] # 들여쓴 x = g1 금액에 듦(셈 짝 그대로) rows2 = _rows(compute_estimation(INPUTS, {"bill": out["indentBill"]}, None, BOOKS)) assert (rows2["hand:x"]["level"], rows2["hand:g1"]["sum"]) == (4, 12300 + 2460 + 1230) LINK_JS = """ const opened = []; const picked = {}; globalThis.createMapContextMenu = () => ({ element: { isConnected: true, style: {} }, open: (x, y, items) => opened.push({ x, y, items }), }); globalThis.requestAnimationFrame = () => 0; globalThis.createFindList = () => ({ search: { value: "", addEventListener() {}, focus() {} }, hint: {}, root: {}, setRows: (rows) => (picked.rows = rows), }); globalThis.openModal = (o) => { picked.title = o.title; o.mount({ append() {} }, () => (picked.closed = true)); }; let user = __USER__; const ctx = { sheets: { bill: __BILL__, daega: __DAEGA__ }, payload: {}, get user() { return user; }, setUser: (u) => (user = u) }; const listeners = {}; const root = { addEventListener: (type, fn) => (listeners[type] = fn), contains: () => true }; attachRowMenu(root, () => []); const cell = { getBoundingClientRect: () => ({ left: 10, bottom: 40 }) }; listeners.click({ target: { closest: () => cell } }); const table = buildBill(ctx); const at = (key) => table.rows.findIndex((r) => r.key === key); const name = (x) => (x === "-" ? "-" : x[0]); const out = { columns: table.columns.map((c) => c.key).slice(-2), connect: ["hand:a", "hand:c", "hand:g1", "earth:4"].map((k) => table.rows[at(k)].connect), link: [table.link(at("hand:a"), "connect"), table.link(at("hand:a"), "name")], cls: ["sec:1", "hand:g1", "hand:a", "earth:4"].map((k) => table.rowClass(at(k))), edit: [table.editable(at("hand:g1"), "name"), table.editable(at("hand:g1"), "qty")], }; table.onCell(at("hand:a"), "connect"); table.onCell(at("hand:g1"), "connect"); table.onCell(at("earth:4"), "connect"); table.onCell(at("hand:c"), "connect"); out.menus = opened.map((m) => [m.x, m.y, m.items.map(name)]); opened[2].items[0][1](); // hand:c → 일위대가 호표 고르기 창 picked.rows[0].onPick(); out.picked = [picked.title, picked.closed, picked.rows.map((r) => r.tag)]; out.ref = user.bill.rows.find((r) => r.key === "hand:c").ref; console.log(JSON.stringify(out)); """ def test_연결_단추_칸_메뉴_고르기_창_TS(): sheets = compute_estimation(INPUTS, USER, None, BOOKS) body = ( LINK_JS.replace("__USER__", json.dumps(USER, ensure_ascii=False)) .replace("__BILL__", json.dumps(sheets["bill"], ensure_ascii=False)) .replace("__DAEGA__", json.dumps(BOOKS["daega"], ensure_ascii=False)) ) src = _ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"]) out = json.loads(_node(src + body)) assert out["columns"] == ["connect", "note"] # 비고 앞 「연결」 칸 # 184-3 모든 항목 줄 · 새 줄 연결됨 = 호표 번호 · 자동 줄 연결됨 = 「연결됨」 · 묶음 = 없음 assert out["link"] == [None, {"tab": "dg", "key": "DG:a"}] # 단추 칸 = 이동 안 함 assert out["cls"] == [ "is-lv1 is-title", "is-lv3 is-group is-hand", "is-lv4 is-hand", "is-lv3"] # fmt: skip assert out["edit"] == [True, False] # 묶음 줄 = 이름만 picks = ["일위대가 호표", "단가산출 호표", "단가산출 로직(M01 · 새 산근)"] # 누른 칸 아래(left · bottom) · 묶음 줄 = 안 엶 · 연결된 줄(자동 줄도) = 연결 풀기 더함 unlink = [10, 40, [*picks, "-", "연결 풀기"]] assert out["menus"] == [unlink, unlink, [10, 40, picks]] assert out["picked"] == ["일위대가 호표 고르기", True, ["대가3"]] # 166-2 고르기 창 재사용 assert out["ref"] == DG ROW_MENU_JS = """ const opened = []; globalThis.createMapContextMenu = () => ({ element: { isConnected: true, style: {} }, open: (x, y, items) => opened.push({ x, y, items }), }); const listeners = {}; const root = { addEventListener: (type, fn) => (listeners[type] = fn), contains: () => true }; attachRowMenu(root, (i) => [["줄 " + i, () => {}]]); const row = { dataset: { i: "7" } }; // 글 칸(input) 안쪽 우클릭 — 줄 칸 · 글 칸 모두 같은 줄 메뉴 const click = (inInput) => { const ev = { target: { closest: (sel) => (sel === "tr[data-i]" ? row : inInput && sel === "input" ? {} : null) }, clientX: 5, clientY: 6, prevented: false, preventDefault() { this.prevented = true; } }; listeners.contextmenu(ev); return ev.prevented; }; const out = { onInput: click(true), onCell: click(false), opened: opened.map((m) => [m.x, m.y, m.items.map((i) => i[0])]) }; console.log(JSON.stringify(out)); """ def test_글_칸_위_우클릭도_줄_메뉴_TS(): src = _ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"]) out = json.loads(_node(src + ROW_MENU_JS)) assert out["onInput"] is True and out["onCell"] is True assert out["opened"] == [[5, 6, ["줄 7"]], [5, 6, ["줄 7"]]]