diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.py b/B09_Estimation/B09_Estimation_Calc_Bill.py index 8ecc2ff6f..05866a5e3 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.py +++ b/B09_Estimation/B09_Estimation_Calc_Bill.py @@ -1,7 +1,7 @@ """B09 설계내역서 셈(PLAN 145-3) — 화면 없음 · 저장 안 함 · 파일 · M01 안 읽음. ⚠ 짝 = `B09_Estimation_Calc_Bill.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 · - 거울 시험 `resources/tester/test_145_3_b09_bill.py`. + 거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.py`(레벨). 실무 엑셀 설계내역서 꼴 · 공종 나무 = 엑셀 꼴 고정(`SECTIONS` · 공구 안 나눔): 1 토공사(1.1~1.6) · 2 구조물공(2.1~2.3) · 3 운반공 · 4 자재대 · 관급자재대(제외금액) @@ -18,6 +18,9 @@ 단가 = `ref_price(ref)` 하나 — dg = 일위대가목록표 · sg = 단가산출근거목록표 · logic = M01 로직 새 산근(서버가 `sg_hand{줄 키: 산근 키}` 로 줌 · 그전엔 빈칸 「[저장] 뒤」) · 그 밖 = `inputs.hopyo` · 비고 = 「대가n」 · 「산근 n호표」(155-3 꼴). +레벨(182) = 손 줄 `kind: "group"`(묶음 줄 · 수량 · 연결 없음) · `parent`(묶음 손 줄 키 · 없음 = 공종 바로 + 아래) · 줄마다 `level`(1 = 1 토공사 · 2 = 1.1 · 3 = 그 안 항목 · 손 묶음 + 1) · + 묶음 금액 = 자손 항목 합 · 공종 합 · `sums` = 항목만(묶음 줄 안 셈 · 원가계산서 그대로). 원가계산서로 넘길 합 = `sums`(직접재료비 · 직접노무비 · 산출경비 · 관급 순자재대 — 145-4 이음 꼴 · 빈 줄이 있으면 None) · 화면 표시용 = `totals`(아는 금액 합 · 빈 줄 수 · 조달수수료). """ @@ -153,6 +156,62 @@ def hand_items(user: dict[str, Any], books: dict[str, Any], hopyo: dict) -> list return out +def _group_row(item: dict[str, Any]) -> dict[str, Any]: + """묶음 손 줄(182) — 이름만 · 금액 = 자손 항목 합(`_nest`).""" + return { + "kind": "group", + "key": item["key"], + "section": item["section"], + "name": item.get("name") or "", + "spec": item.get("spec") or "", + "unit": item.get("unit") or "", + "qty": None, + "note": "", + "ref": None, + "hand": True, + "sum": 0.0, + "labor": 0.0, + "material": 0.0, + "expense": 0.0, + "blank": 0, + } + + +def _nest(rows: list[dict[str, Any]]) -> None: + """182 레벨 — 부모 = 같은 공종 묶음 손 줄(못 찾거나 돌면 공종 바로 아래) · level · 묶음 금액.""" + groups = {row["key"]: row for row in rows if row["kind"] == "group"} + for row in rows: + if not row.get("hand"): + continue + up = groups.get(row.get("parent") or "") + if up is None or up["section"] != row["section"]: + row["parent"] = None + continue + at, n = up, 0 + while at is not None and n <= len(groups): + if at["key"] == row["key"]: + row["parent"] = None + break + at, n = groups.get(at.get("parent") or ""), n + 1 + for row in rows: + chain = [] + at = groups.get(row.get("parent") or "") + while at is not None: + chain.append(at) + at = groups.get(at.get("parent") or "") + depth = 2 if next(p for c, _n, p in SECTIONS if c == row["section"]) else 1 + row["level"] = depth if row["kind"] == "title" else depth + 1 + len(chain) + if row["kind"] != "item": + continue + for g in chain: + for p in ("sum", *PARTS): + g[p] += row[p] or 0 + if row["sum"] is None: + g["blank"] += 1 + for g in groups.values(): + g["note"] = f"빈 줄 {g['blank']}" if g["blank"] else "" + + def _place(items: list[dict[str, Any]], hand: dict[str, Any], after: str) -> None: """손 줄 끼우기 — `after` 줄 바로 뒤 · 제목(`sec:`) = 그 공종 첫 자리 · 못 찾으면 끝.""" at = next((n + 1 for n, row in enumerate(items) if row["key"] == after), None) @@ -213,8 +272,13 @@ def compute_bill( prices = None if price is None else {"labor": 0, "material": price, "expense": 0} items.append(_item_row(item, prices, gaps, qty_of)) for item, prices, why in hand_items(user, books, hopyo): - _place(items, _item_row({**item, "why": why}, prices, gaps), item.get("after") or "") - owner = [row for row in items if row["section"] == "G"] + if item.get("kind") == "group": + row = _group_row(item) + else: + row = _item_row({**item, "why": why}, prices, gaps) + row["parent"] = item.get("parent") + _place(items, row, item.get("after") or "") + owner = [row for row in items if row["section"] == "G" and row["kind"] == "item"] net = 0.0 for row in owner: net += row["material"] or 0 @@ -248,6 +312,8 @@ def compute_bill( for code, name, parent in SECTIONS: sums[code] = {"sum": 0.0, "labor": 0.0, "material": 0.0, "expense": 0.0, "blank": 0} for row in items: + if row["kind"] != "item": + continue code = row["section"] chain = [code] parent = next(p for c, _n, p in SECTIONS if c == code) @@ -276,6 +342,7 @@ def compute_bill( } ) rows.extend(row for row in items if row["section"] == code) + _nest(rows) direct = {p: 0.0 for p in ("sum", *PARTS)} blank = 0 for code in DIRECT: diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.ts b/B09_Estimation/B09_Estimation_Calc_Bill.ts index c1065cf0b..3bc38f886 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.ts +++ b/B09_Estimation/B09_Estimation_Calc_Bill.ts @@ -3,7 +3,7 @@ * B09 설계내역서 셈(PLAN 145-3) — 화면 없음 · 저장 안 함 · import 는 짝 바탕 셈 하나(시험이 이어 붙임). * * ⚠ 짝 = `B09_Estimation_Calc_Bill.py`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 · - * 거울 시험 `resources/tester/test_145_3_b09_bill.py`. + * 거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.py`(레벨). * * 공종 나무 = 엑셀 꼴 고정(1 토공사 · 2 구조물공 · 3 운반공 · 4 자재대 · 관급자재대(제외금액)) · 공구 안 나눔 * 항목 = 서버가 B08 수량으로 짠 줄 + 대비표 「별도」(4 자재대) · 「관급」(관급자재대) 자재 + 조달수수료 @@ -11,6 +11,9 @@ * 제목 줄 = 아래 항목 금액 합(빈 줄은 「빈 줄 n」) · 원가계산서로 넘길 합 = sums(빈 줄 있으면 null) * 손질(166-2) = `user.bill` — qty{줄 키: 수} 자동 줄 수량 덮어쓰기 · rows[] 손 줄(after 뒤에 끼움) · * 단가 = `refPrice(ref)` 하나(dg 대가 목록 · sg 산근 목록 · logic = 서버 sg_hand 산근 · 그 밖 hopyo) + * 레벨(182) = 손 줄 `kind: "group"`(묶음 줄 · 수량 · 연결 없음) · `parent`(묶음 손 줄 키 · 없음 = 공종 바로 아래) · + * 줄마다 `level`(1 = 1 토공사 · 2 = 1.1 · 3 = 그 안 항목 · 손 묶음 + 1) · 묶음 금액 = 자손 항목 합 · + * 공종 합 · sums = 항목만(묶음 줄 안 셈 · 원가계산서 그대로) * ========================================================================== */ import { @@ -143,6 +146,62 @@ export function handItems(user: Row, books: Row, hopyo: Row): Array<[Row, Row | return out; } +/** 묶음 손 줄(182) — 이름만 · 금액 = 자손 항목 합(`nest`) */ +function groupRow(item: Row): Row { + return { + kind: "group", + key: item.key, + section: item.section, + name: item.name || "", + spec: item.spec || "", + unit: item.unit || "", + qty: null, + note: "", + ref: null, + hand: true, + sum: 0, + labor: 0, + material: 0, + expense: 0, + blank: 0, + }; +} + +/** 182 레벨 — 부모 = 같은 공종 묶음 손 줄(못 찾거나 돌면 공종 바로 아래) · level · 묶음 금액 */ +function nest(rows: Row[]): void { + const groups = new Map(); + for (const row of rows) if (row.kind === "group") groups.set(row.key, row); + for (const row of rows) { + if (!row.hand) continue; + const up = groups.get(row.parent ?? ""); + if (!up || up.section !== row.section) { + row.parent = null; + continue; + } + let at: Row | undefined = up; + for (let n = 0; at && n <= groups.size; n += 1) { + if (at.key === row.key) { + row.parent = null; + break; + } + at = groups.get(at.parent ?? ""); + } + } + for (const row of rows) { + const chain: Row[] = []; + for (let at = groups.get(row.parent ?? ""); at; at = groups.get(at.parent ?? "")) + chain.push(at); + const depth = SECTIONS.find(([c]) => c === row.section)![2] ? 2 : 1; + row.level = row.kind === "title" ? depth : depth + 1 + chain.length; + if (row.kind !== "item") continue; + for (const g of chain) { + for (const p of ["sum", ...PARTS]) g[p] += row[p] || 0; + if (row.sum === null) g.blank += 1; + } + } + for (const g of groups.values()) g.note = g.blank ? `빈 줄 ${g.blank}` : ""; +} + /** 손 줄 끼우기 — after 줄 바로 뒤 · 제목(sec:) = 그 공종 첫 자리 · 못 찾으면 끝 */ function place(items: Row[], hand: Row, after: string): void { let at = items.findIndex((row) => row.key === after); @@ -204,9 +263,12 @@ export function computeBill( const prices = price === null ? null : { labor: 0, material: price, expense: 0 }; items.push(itemRow(item, prices, gaps, qtyOf)); } - for (const [item, prices, why] of handItems(user, book, hopyo)) - place(items, itemRow({ ...item, why }, prices, gaps), item.after || ""); - const owner = items.filter((row) => row.section === "G"); + for (const [item, prices, why] of handItems(user, book, hopyo)) { + const row = item.kind === "group" ? groupRow(item) : itemRow({ ...item, why }, prices, gaps); + row.parent = item.parent ?? null; + place(items, row, item.after || ""); + } + const owner = items.filter((row) => row.section === "G" && row.kind === "item"); let net = 0; for (const row of owner) net += row.material || 0; const pct = num((user.estimate || {}).procurement_fee_pct); @@ -238,6 +300,7 @@ export function computeBill( for (const [code] of SECTIONS) sums[code] = { sum: 0, labor: 0, material: 0, expense: 0, blank: 0 }; for (const row of items) { + if (row.kind !== "item") continue; const code = row.section; const chain = [code]; const parent = SECTIONS.find(([c]) => c === code)![2]; @@ -264,6 +327,7 @@ export function computeBill( }); rows.push(...items.filter((row) => row.section === code)); } + nest(rows); const direct: Row = { sum: 0, labor: 0, material: 0, expense: 0 }; let blank = 0; for (const code of DIRECT) { diff --git a/B09_Estimation/B09_Estimation_Store.py b/B09_Estimation/B09_Estimation_Store.py index 858575c01..905be8362 100644 --- a/B09_Estimation/B09_Estimation_Store.py +++ b/B09_Estimation/B09_Estimation_Store.py @@ -109,7 +109,8 @@ def _clean_ref(ref: Any) -> dict[str, Any] | None: def clean_bill(raw: Any) -> dict[str, Any]: - """설계내역서 손질(166-2) — 수량 덮어쓰기(수만) · 손 줄(`hand:` 키 · 아는 공종만).""" + """설계내역서 손질(166-2) — 수량 덮어쓰기(수만) · 손 줄(`hand:` 키 · 아는 공종만) · + 182 레벨 = `kind`(group) · `parent`(`hand:` 키).""" raw = raw if isinstance(raw, dict) else {} qty = raw.get("qty") if isinstance(raw.get("qty"), dict) else {} codes = {code for code, _name, _parent in SECTIONS} @@ -119,14 +120,18 @@ def clean_bill(raw: Any) -> dict[str, Any]: continue if row.get("section") not in codes: continue + group = row.get("kind") == "group" # 182 묶음 줄 = 이름만 + parent = str(row.get("parent") or "") rows.append( { "key": str(row["key"]), "section": row["section"], "after": _text(row.get("after")), + **({"kind": "group"} if group else {}), + **({"parent": parent} if parent.startswith("hand:") else {}), **{k: _text(row.get(k)) for k in ("name", "spec", "unit")}, - "qty": _num(row.get("qty")), - "ref": _clean_ref(row.get("ref")), + "qty": None if group else _num(row.get("qty")), + "ref": None if group else _clean_ref(row.get("ref")), } ) out: dict[str, Any] = {} diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index ecd0e603e..8a81b93ae 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -44,6 +44,39 @@ gap: var(--spacing-8); } +/* 182 설계내역서 레벨 — 글자 크기(줄 높이 28px 고정 → 16px 까지) · 첫 칸 들여쓰기 1em × (레벨 − 1) */ +.b09-estimation .ui-data-table__table tbody tr.is-lv1 td { + font-size: var(--text-body); +} + +.b09-estimation .ui-data-table__table tbody tr:is(.is-lv2, .is-lv3) td { + font-size: var(--text-body-sm); +} + +.b09-estimation .ui-data-table__table tbody tr:is(.is-lv4, .is-lv5, .is-lv6) td { + font-size: 13px; +} + +.b09-estimation .ui-data-table__table tbody tr.is-group td { + font-weight: var(--font-weight-bold); +} + +.b09-estimation .ui-data-table__table tbody tr.is-lv2 td:first-child { + padding-left: calc(var(--spacing-8) + 1em); +} + +.b09-estimation .ui-data-table__table tbody tr.is-lv3 td:first-child { + padding-left: calc(var(--spacing-8) + 2em); +} + +.b09-estimation .ui-data-table__table tbody tr.is-lv4 td:first-child { + padding-left: calc(var(--spacing-8) + 3em); +} + +.b09-estimation .ui-data-table__table tbody tr:is(.is-lv5, .is-lv6) td:first-child { + padding-left: calc(var(--spacing-8) + 4em); +} + /* 166-2 설계내역서 손 줄 — 사용자가 더한 줄 표시(왼쪽 띠) */ .b09-estimation .ui-data-table__table tbody tr.is-hand td:first-child { background-image: linear-gradient(to right, var(--color-primary) 3px, transparent 3px); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts index f7fa8dc8e..c9e2fce17 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts @@ -6,7 +6,9 @@ * 고칠 칸 = 관급자재대 조달수수료 율(%) · 항목 수량(166-2 · 덮어쓰기 · 빈칸 = 자동) · * 손 줄 명칭 · 규격 · 단위 · 수량 · 빈칸 = 「연결 없음」(수량 · 단가 원천 없음) * 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표) - * 우클릭 = 줄 추가 · 지우기 · 연결(`…_UI_Tabs_Bill_Edit`) + * 우클릭 = 줄 추가 · 지우기 · 연결 · 레벨(`…_UI_Tabs_Bill_Edit`) + * 레벨(182) = 줄 `level`(셈이 냄) → class is-lvN · 글자 크기 · 첫 칸 들여쓰기(CSS) · 묶음 줄 = 이름만 고침 + * 「연결 ▾」 칸(182) = 손 항목 줄만 · 누름 = 우클릭 [연결] 과 같은 메뉴(연결됨 = 호표 번호) * ========================================================================== */ import type { DataRow } from "@ui/ui_template_data_table"; @@ -17,6 +19,7 @@ import { editQty, editText, handEditable, + openLinkMenu, type BillTable, } from "./B09_Estimation_UI_Tabs_Bill_Edit"; @@ -25,7 +28,7 @@ export const buildBill: TabBuild = (ctx): BillTable => { const bill: Row = sheets.bill || { rows: [], totals: null }; const rows: DataRow[] = bill.rows.map((r: Row) => ({ ...r, - name: r.kind === "title" ? r.name : ` ${r.name}`, + connect: r.hand && r.kind === "item" ? `${r.note || "연결"} ▾` : "", })); const direct: Row = bill.totals?.direct || {}; const pair = (key: string, label: string) => [ @@ -42,6 +45,7 @@ export const buildBill: TabBuild = (ctx): BillTable => { ...pair("labor", "노무비"), ...pair("material", "재료비"), ...pair("expense", "경비"), + text("connect", "연결", 96), text("note", "비고", 200), ], rows, @@ -49,10 +53,12 @@ export const buildBill: TabBuild = (ctx): BillTable => { frozen: 1, hint: "공종 나무 = 엑셀 꼴 고정 · 금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 4 자재대 = 대비표 「별도」 · 관급자재대 = 「관급」 표시 자재 · 조달수수료 율(%)은 수량 칸에 넣음 · " + - "수량 칸 = 덮어쓰기(빈칸 = 자동) · 우클릭 = 줄 추가 · 지우기 · 일위대가 · 단가산출 연결(새 산근 단가 = [저장] 뒤) · " + + "수량 칸 = 덮어쓰기(빈칸 = 자동) · 우클릭 · 「연결 ▾」 = 줄 추가 · 지우기 · 일위대가 · 단가산출 연결(새 산근 단가 = [저장] 뒤) · " + + "레벨 = 우클릭 묶음 줄로 · 하위 줄 추가 · 들여쓰기 · 내어쓰기(묶음 금액 = 아래 항목 합 · 공종 합은 항목만) · " + `빈 줄 ${direct.blank ?? 0} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음`, editable: (i, key) => { const r = rows[i]; + if (r?.kind === "group") return key === "name"; if (!r || r.kind !== "item") return false; return r.hand ? handEditable(key) : key === "qty"; }, @@ -71,9 +77,19 @@ export const buildBill: TabBuild = (ctx): BillTable => { if (key !== "qty" || !r || !("qty_auto" in r)) return null; return `자동 값 ${r.qty_auto ?? "빈칸"} — 우클릭 [수량 자동으로 되돌리기]`; }, - rowClass: (i) => (rows[i]?.hand ? "is-hand" : null), + // 182 레벨 — 글자 크기 · 들여쓰기(is-lvN) · 제목 · 묶음 줄 굵게 + rowClass: (i) => { + const r = rows[i]; + if (!r) return null; + const title = r.kind === "title" ? "is-title" : r.kind === "group" ? "is-group" : ""; + return [`is-lv${r.level ?? 1}`, title, r.hand ? "is-hand" : ""].filter((c) => c).join(" "); + }, menu: (i) => billMenu(ctx, rows, i), - link: (i) => { + onCell: (i, key) => { + if (key === "connect") openLinkMenu(ctx, rows, i); + }, + link: (i, key) => { + if (key === "connect") return null; // 「연결 ▾」 = 메뉴(이동 안 함) const ref = rows[i]?.ref as Row | null | undefined; return ref?.key ? { tab: ref.tab as TabId, key: String(ref.key) } : null; }, diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts index 350b0185c..5f69f8ab6 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts @@ -7,6 +7,8 @@ * rows[] = 손 줄 {key hand:… · section · after(앞 줄 키 · 제목 = sec:공종) · 명칭 · 규격 · 단위 · 수량 · ref} * 연결 = 있는 호표(일위대가목록표 dg · 단가산출근거목록표 sg) · M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가) * 우클릭 = 공용 메뉴(`ui_template_context_menu` · 화면 고정 자리) · 로직 고르기 · 입력값 = M02 창 재사용 + * 레벨(182) = 손 줄 kind(group = 묶음 줄 · 이름만) · parent(묶음 손 줄 키) — 하위 줄 추가 · 묶음 줄로 · + * 항목으로 · 들여쓰기 · 내어쓰기 · 손 묶음 깊이 2 · 「연결 ▾」 칸 = 우클릭 [연결] 과 같은 메뉴 * ========================================================================== */ import type { DataRow } from "@ui/ui_template_data_table"; @@ -29,6 +31,8 @@ export type BillTable = TabTable & { menu?: (index: number) => MapContextMenuIte const NEW_NAME = "새 줄"; const TEXT_KEYS = ["name", "spec", "unit"]; +/** 182 손 묶음 깊이 — 묶음 안 묶음까지 */ +const MAX_GROUP = 2; interface Bill { qty: Record; @@ -69,35 +73,138 @@ export function editText(ctx: TabContext, key: string, field: string, value: str put(ctx, bill); } -/** 새 줄 자리 — 공종 · 앞 줄 키(위 = 앞 줄 · 공종 첫 줄이면 제목 sec:공종) */ -function slot(rows: DataRow[], i: number, below: boolean): { section: string; after: string } { +const lv = (row: DataRow | undefined): number => Number(row?.level ?? 0); + +/** 줄 i 자손 끝(다음 줄 번호) — 더 깊은 레벨 줄이 이어지는 데까지 */ +function subtreeEnd(rows: DataRow[], i: number): number { + let k = i + 1; + while (k < rows.length && lv(rows[k]) > lv(rows[i])) k += 1; + return k; +} + +/** 부모 바꿈 — 없음 = 공종 바로 아래(키 지움) */ +function reparent(h: Row, parent: unknown): void { + if (parent) h.parent = parent; + else delete h.parent; +} + +/** 손 묶음 깊이 지킴 — 묶음 줄 위 묶음 ≤ 1 · 항목 위 묶음 ≤ 2(도는 부모는 셈이 끊음 · 여기선 멈춤) */ +function fits(bill: Bill): boolean { + const byKey = new Map(bill.rows.map((h) => [String(h.key), h])); + return bill.rows.every((h) => { + let n = 0; + const seen = new Set([h.key]); + let at = byKey.get(String(h.parent ?? "")); + while (at && at.kind === "group" && !seen.has(at.key)) { + seen.add(at.key); + n += 1; + at = byKey.get(String(at.parent ?? "")); + } + return n <= (h.kind === "group" ? MAX_GROUP - 1 : MAX_GROUP); + }); +} + +/** 새 줄 자리 — 공종 · 앞 줄 키(위 = 앞 줄 · 공종 첫 줄이면 제목 sec:공종 · 아래 = 자손 끝) · 부모 = 그 줄 부모 */ +function slot(rows: DataRow[], i: number, below: boolean): Row { const row = rows[i]; const section = String(row.section); if (row.kind === "title") return { section, after: `sec:${section}` }; - if (below) return { section, after: String(row.key) }; + const parent = row.hand && row.parent ? { parent: String(row.parent) } : {}; + if (below) return { section, after: String(rows[subtreeEnd(rows, i) - 1].key), ...parent }; const prev = rows[i - 1]; const same = prev && prev.kind !== "title" && prev.section === section; - return { section, after: same ? String(prev.key) : `sec:${section}` }; + return { section, after: same ? String(prev.key) : `sec:${section}`, ...parent }; } -function addRow(ctx: TabContext, rows: DataRow[], i: number, below: boolean): void { +function newRow(ctx: TabContext, at: Row): void { const bill = billOf(ctx.user); const key = `hand:${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; - const at = slot(rows, i, below); bill.rows.push({ key, ...at, name: NEW_NAME, spec: "", unit: "", qty: null, ref: null }); put(ctx, bill); } -/** 손 줄 지움 — 그 줄 뒤에 붙던 손 줄은 지운 줄 자리로 옮김 */ +function addRow(ctx: TabContext, rows: DataRow[], i: number, below: boolean): void { + newRow(ctx, slot(rows, i, below)); +} + +/** 묶음 줄 아래 하위 줄 — 자리 = 마지막 자손 뒤 */ +function addChild(ctx: TabContext, rows: DataRow[], i: number): void { + const after = String(rows[subtreeEnd(rows, i) - 1].key); + newRow(ctx, { section: String(rows[i].section), after, parent: String(rows[i].key) }); +} + +/** 손 줄 지움 — 그 줄 뒤에 붙던 손 줄은 지운 줄 자리로 · 자식은 지운 줄 부모로 옮김 */ function removeRow(ctx: TabContext, key: string): void { const bill = billOf(ctx.user); const gone = bill.rows.find((h) => h.key === key); if (!gone) return; bill.rows = bill.rows.filter((h) => h.key !== key); for (const h of bill.rows) if (h.after === key) h.after = gone.after; + for (const h of bill.rows) if (h.parent === key) reparent(h, gone.parent); put(ctx, bill); } +/** 레벨 바꿈 — 깊이 한도 안이면 [동작] · 넘으면 null(메뉴에 안 보임) */ +function levelAction( + ctx: TabContext, + key: string, + change: (own: Row, bill: Bill) => void, +): (() => void) | null { + const bill = billOf(ctx.user); + const own = bill.rows.find((h) => h.key === key); + if (!own) return null; + change(own, bill); + return fits(bill) ? () => put(ctx, bill) : null; +} + +/** 들여쓰기 대상 — 바로 위 같은 레벨 형제가 같은 공종 묶음 줄이면 그 줄 */ +function indentTarget(rows: DataRow[], i: number): DataRow | null { + const row = rows[i]; + for (let j = i - 1; j >= 0; j -= 1) { + const r = rows[j]; + if (lv(r) > lv(row)) continue; + return lv(r) === lv(row) && r.kind === "group" && r.section === row.section ? r : null; + } + return null; +} + +/** 레벨 메뉴 — 묶음 줄로 · 항목으로 · 들여쓰기 → · 내어쓰기 ←(막내만 · 자리 안 어긋남) */ +function levelMenu(ctx: TabContext, rows: DataRow[], i: number): MapContextMenuItem[] { + const row = rows[i]; + const key = String(row.key); + const out: MapContextMenuItem[] = []; + const add = (label: string, action: (() => void) | null): void => { + if (action) out.push([label, action]); + }; + if (row.kind === "group") + add( + "항목으로 바꾸기", + levelAction(ctx, key, (own, bill) => { + delete own.kind; + for (const h of bill.rows) if (h.parent === key) reparent(h, own.parent); + }), + ); + else + add( + "묶음 줄로 바꾸기", + levelAction(ctx, key, (own) => Object.assign(own, { kind: "group", qty: null, ref: null })), + ); + const target = indentTarget(rows, i); + if (target) + add( + "들여쓰기 →", + levelAction(ctx, key, (own) => reparent(own, String(target.key))), + ); + if (row.parent && lv(rows[subtreeEnd(rows, i)]) < lv(row)) + add( + "내어쓰기 ←", + levelAction(ctx, key, (own, bill) => + reparent(own, bill.rows.find((h) => h.key === row.parent)?.parent), + ), + ); + return out; +} + /** 연결 — 명칭이 비었거나 「새 줄」이면 고른 호표 명칭 · 규격 · 단위를 채움 */ function setRef(ctx: TabContext, key: string, ref: Row | null, from?: Row): void { const bill = billOf(ctx.user); @@ -182,7 +289,24 @@ function pickLogic(ctx: TabContext, key: string): void { openLogicPicker((logic) => askInputs(ctx, key, logic.키, logic.이름)); } -/** 줄 우클릭 메뉴 — 위 · 아래 줄 추가 · (손 줄) 연결 · 연결 풀기 · 지우기 · (자동 줄) 수량 자동으로 */ +type Leaf = [string, () => void]; + +/** 손 항목 줄 연결 — [고르기 셋, 그 밖(로직 입력값 · 연결 풀기)] */ +function linkItems(ctx: TabContext, key: string): [Leaf[], Leaf[]] { + const ref: Row | null = billOf(ctx.user).rows.find((h) => h.key === key)?.ref ?? null; + const more: Leaf[] = []; + if (ref?.tab === "logic") + more.push(["로직 입력값 고치기", () => askInputs(ctx, key, ref.code, ref.name || "", ref)]); + if (ref) more.push(["연결 풀기", () => setRef(ctx, key, null)]); + const picks: Leaf[] = [ + ["일위대가 호표", () => pickHopyo(ctx, key, "dg")], + ["단가산출 호표", () => pickHopyo(ctx, key, "sg")], + ["단가산출 로직(M01 · 새 산근)", () => pickLogic(ctx, key)], + ]; + return [picks, more]; +} + +/** 줄 우클릭 메뉴 — 위 · 아래(· 하위) 줄 추가 · (손 줄) 연결 · 연결 풀기 · 레벨 · 지우기 · (자동 줄) 수량 자동으로 */ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContextMenuItem[] { const row = rows[i]; if (!row) return []; @@ -191,19 +315,13 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex const items: MapContextMenuItem[] = []; if (row.kind !== "title") items.push(["위에 줄 추가", () => addRow(ctx, rows, i, false)]); items.push(["아래에 줄 추가", () => addRow(ctx, rows, i, true)]); + if (row.kind === "group") items.push(["아래에 하위 줄 추가", () => addChild(ctx, rows, i)]); if (row.hand) { - const ref: Row | null = bill.rows.find((h) => h.key === key)?.ref ?? null; - items.push("-", { - label: "연결", - children: [ - ["일위대가 호표", () => pickHopyo(ctx, key, "dg")], - ["단가산출 호표", () => pickHopyo(ctx, key, "sg")], - ["단가산출 로직(M01 · 새 산근)", () => pickLogic(ctx, key)], - ], - }); - if (ref?.tab === "logic") - items.push(["로직 입력값 고치기", () => askInputs(ctx, key, ref.code, ref.name || "", ref)]); - if (ref) items.push(["연결 풀기", () => setRef(ctx, key, null)]); + if (row.kind !== "group") { + const [picks, more] = linkItems(ctx, key); + items.push("-", { label: "연결", children: picks }, ...more); + } + items.push("-", ...levelMenu(ctx, rows, i)); items.push("-", ["줄 지우기", () => removeRow(ctx, key)]); } else if (row.kind === "item" && key in bill.qty) { items.push("-", [ @@ -218,6 +336,17 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex } let shared: MapContextMenu | null = null; +/** 마지막으로 누른 칸 — 「연결 ▾」 단추 메뉴 자리 */ +let lastCell: Element | null = null; + +/** 「연결 ▾」 칸 누름(182) — 우클릭 [연결] 과 같은 메뉴를 그 칸 아래에 엶 */ +export function openLinkMenu(ctx: TabContext, rows: DataRow[], i: number): void { + const row = rows[i]; + const box = lastCell?.getBoundingClientRect(); + if (!shared || !box || !row?.hand || row.kind !== "item") return; + const [picks, more] = linkItems(ctx, String(row.key)); + shared.open(box.left, box.bottom, more.length ? [...picks, "-", ...more] : picks); +} /** 표에 줄 우클릭 메뉴를 붙임(공용 표는 안 고침) — 글 칸 위는 브라우저 메뉴(붙여넣기) 그대로 */ export function attachRowMenu( @@ -230,6 +359,12 @@ export function attachRowMenu( ctxMenu.element.style.position = "fixed"; // 화면 기준 자리(clientX · Y) document.body.append(ctxMenu.element); } + // 칸 누름보다 먼저(잡기 단계) — 「연결 ▾」 메뉴를 그 칸 자리에 엶 + root.addEventListener( + "click", + (event) => (lastCell = (event.target as Element).closest("td")), + true, + ); root.addEventListener("contextmenu", (event) => { const target = event.target as Element; if (target.closest("input")) return; diff --git a/resources/tester/test_166_2_b09_bill_hand.py b/resources/tester/test_166_2_b09_bill_hand.py index ec00b9bfe..cba6989d6 100644 --- a/resources/tester/test_166_2_b09_bill_hand.py +++ b/resources/tester/test_166_2_b09_bill_hand.py @@ -223,7 +223,7 @@ console.log(JSON.stringify(out)); assert out["title"] == ["아래에 줄 추가"] assert out["auto"] == ["위에 줄 추가", "아래에 줄 추가", "-", "수량 자동으로 되돌리기"] assert out["hand"] == ["위에 줄 추가", "아래에 줄 추가", "-", "연결", "연결 풀기", "-", - "줄 지우기"] # fmt: skip + "묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip # 182 레벨 assert out["above"]["after"] == "sec:1.2" and out["above"]["name"] == "새 줄" assert out["qty"] == {} # hand:b 지움 — 남은 새 줄은 그대로(자기 자리) · 지운 줄 뒤에 붙던 줄은 지운 줄 자리로 diff --git a/resources/tester/test_182_b09_bill_level.py b/resources/tester/test_182_b09_bill_level.py new file mode 100644 index 000000000..775a9e234 --- /dev/null +++ b/resources/tester/test_182_b09_bill_level.py @@ -0,0 +1,302 @@ +"""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 + assert (rows["haul:1"]["level"], rows["hand:y"]["level"], rows["hand:y"]["parent"]) == ( + 2, + 2, + None, + ) + assert rows["hand:x"]["parent"] is None and rows["hand:b"]["parent"] == "hand:g2" + # 돌기 — 먼저 나온 줄이 끊김(p = 공종 아래 · q = p 아래) + assert (rows["hand:p"]["parent"], rows["hand:p"]["level"]) == (None, 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.rows; +}; +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 = run("hand:g2", "아래에 하위 줄 추가").at(-1); +out.below = run("hand:g1", "아래에 줄 추가").at(-1); +out.belowB = run("hand:b", "아래에 줄 추가").at(-1); +out.indent = run("hand:x", "들여쓰기 →").find((r) => r.key === "hand:x"); +out.outdent = run("hand:g2", "내어쓰기 ←").find((r) => r.key === "hand:g2"); +out.toItem = run("hand:g2", "항목으로 바꾸기").filter((r) => ["hand:g2", "hand:b"].includes(r.key)); +out.toGroup = run("hand:x", "묶음 줄로 바꾸기").find((r) => r.key === "hand:x"); +out.removed = run("hand:g1", "줄 지우기").filter((r) => ["hand:a", "hand:g2"].includes(r.key)); +console.log(JSON.stringify(out)); +""" + + +def test_우클릭_레벨_메뉴_TS(): + 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_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") == ["묶음 줄로 바꾸기"] # 뒤에 형제(g2) → 내어쓰기 없음 + assert pick("g2") == ["항목으로 바꾸기", "내어쓰기 ←"] # 막내 + assert pick("b") == [] # 묶음 안 묶음 안 = 깊이 한도 · 형제 c 뒤 → 내어쓰기 없음 + assert pick("x") == ["묶음 줄로 바꾸기", "들여쓰기 →"] # 위 형제 g1 + assert pick("auto") == [] and "줄 지우기" not in out["auto"] + assert {k: out["child"][k] for k in ("section", "after", "parent")} == { + "section": "1.2", "after": "hand:c", "parent": "hand:g2"} # fmt: skip + assert (out["below"]["after"], "parent" in out["below"]) == ("hand:c", False) # g1 자손 끝 + assert (out["belowB"]["after"], out["belowB"]["parent"]) == ("hand:b", "hand:g2") + assert out["indent"]["parent"] == "hand:g1" + assert "parent" not in out["outdent"] # 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 자리(earth:4 뒤)로 + assert [(r["key"], r["after"], r.get("parent")) for r in out["removed"]] == [ + ("hand:a", "earth:4", None), ("hand:g2", "hand:a", None)] # fmt: skip + # 들여쓴 x = g1 금액에 듦(셈 짝 그대로) + user = {"bill": {"rows": [r if r["key"] != "hand:x" else out["indent"] for r in HAND]}} + rows2 = _rows(compute_estimation(INPUTS, user, 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[1].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_Edit", "UI_Tabs_Bill"]) + out = json.loads(_node(src + body)) + assert out["columns"] == ["connect", "note"] # 비고 앞 「연결」 칸 + assert out["connect"] == ["대가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) · 묶음 줄 · 자동 줄 = 안 엶 · 연결된 줄 = 연결 풀기 더함 + assert out["menus"] == [[10, 40, [*picks, "-", "연결 풀기"]], [10, 40, picks]] + assert out["picked"] == ["일위대가 호표 고르기", True, ["대가3"]] # 166-2 고르기 창 재사용 + assert out["ref"] == DG