From d02c1d3f08c11d463cf9b588eb57748b562eb9ea Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 21:21:37 +0900 Subject: [PATCH] =?UTF-8?q?feat(b09):=20=EC=82=B0=EA=B7=BC=20=ED=83=AD=20?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EB=91=98(=ED=91=9C=20=C2=B7=20=ED=98=B8?= =?UTF-8?q?=ED=91=9C=EB=B3=84=20=EC=B9=B4=EB=93=9C)=20=C2=B7=20=EA=B7=9C?= =?UTF-8?q?=EA=B2=A9=20=EC=B9=B8=20=EA=B8=B0=EA=B3=84=20=C2=B7=20=EC=A7=81?= =?UTF-8?q?=EC=A2=85=20=E3=80=8C=EC=9D=B4=EB=A6=84=20=EA=B7=9C=EA=B2=A9?= =?UTF-8?q?=E3=80=8D(157-2=20=C2=B7=20157-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 157-2 탭 머리 단추 = 「표로 보기」 ↔ 「호표별 보기」 · 고른 보기 localStorage 기억(못 읽으면 표) - 호표별 카드 = 표 줄 그대로 호표마다 묶음 · 머리 산근 n호표 · 공종 · 단위 · 합계 · 가름줄 없이 간격 · 긴 글 줄바꿈 · 줄 누름 이동 같음 - 157-3 산근 규격 글 = 기계 · 직종 원문번호 → 「이름 규격」(M02 85-1 도우미) · 원문번호는 풍선(두 보기 · 목록표) - 시험 test_157_2_b09_sangeun_views — 두 보기 같은 값 · 단추 바꿈 · 기억 실패 때 표 · 이름 규격 · 풍선 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X --- B09_Estimation/B09_Estimation_Sangeun.py | 29 ++- B09_Estimation/B09_Estimation_UI_Page.css | 80 +++++++ B09_Estimation/B09_Estimation_UI_Shell.ts | 59 +++-- B09_Estimation/B09_Estimation_UI_Tabs.ts | 2 + .../B09_Estimation_UI_Tabs_Sangeun.ts | 21 +- .../B09_Estimation_UI_Tabs_Sangeun_Cards.ts | 217 ++++++++++++++++++ .../tester/test_157_2_b09_sangeun_views.py | 148 ++++++++++++ 7 files changed, 530 insertions(+), 26 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_UI_Tabs_Sangeun_Cards.ts create mode 100644 resources/tester/test_157_2_b09_sangeun_views.py diff --git a/B09_Estimation/B09_Estimation_Sangeun.py b/B09_Estimation/B09_Estimation_Sangeun.py index 4be914331..1f763b79c 100644 --- a/B09_Estimation/B09_Estimation_Sangeun.py +++ b/B09_Estimation/B09_Estimation_Sangeun.py @@ -39,6 +39,7 @@ from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M02_MasterTemplete import M02_Item_Prices as item_prices from M02_MasterTemplete import M02_Labor_Doc as labor_doc +from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs from M02_MasterTemplete.M02_Item_Snapshot import ( PICKS_FILE, VALUES_FILE, @@ -247,6 +248,18 @@ def _captured(whole: Any, node: Any, env: dict) -> list[str]: return seen +def spec_text(whole: Any, given: dict[str, Any]) -> tuple[str, str]: + """규격 글(157-3) → (보일 글, 원문번호 글) — 기계 · 직종 번호 = 「이름 규격」(M02 85-1).""" + + def shown(name: str, value: Any) -> Any: + if not isinstance(value, str): + return value + return labor_inputs._labels(whole, {"이름": name, "고르기": [value]}).get(value, value) + + raw = ", ".join(f"{k}={v}" for k, v in given.items()) + return ", ".join(f"{k}={shown(k, v)}" for k, v in given.items()), raw + + def _money(price: float | None, qty: float | None) -> float | None: """줄 금액 = ROUNDDOWN(단가 × 수량, 1)(엑셀 자리).""" return None if price is None or qty is None else rounddown(price * qty, 1) @@ -302,10 +315,12 @@ class Solver: sums = block_totals(lines) why = next((ln["why"] for ln in lines if ln.get("why")), None) head, foot = self._text(whole, src, times, lines) + spec, spec_raw = spec_text(whole, src["given"]) return { "logic": code, "name": name, - "spec": ", ".join(f"{k}={v}" for k, v in src["given"].items()), + "spec": spec, + "spec_raw": spec_raw, "unit": unit, "source": row.get("출처") or "", "lines": lines, @@ -317,8 +332,8 @@ class Solver: @staticmethod def _stop(code: str, name: str, why: str) -> dict[str, Any]: - return {"logic": code, "name": name, "spec": "", "unit": "", "source": "", "lines": [], - **dict.fromkeys(PARTS), "sum": None, "why": why, + return {"logic": code, "name": name, "spec": "", "spec_raw": "", "unit": "", "source": "", + "lines": [], **dict.fromkeys(PARTS), "sum": None, "why": why, "head": [], "foot": ""} # fmt: skip def price_of(self, key: str) -> Any: @@ -453,6 +468,9 @@ class Solver: # ── 모음 ────────────────────────────────────────────────────────────── +LISTED = ("no", "key", "logic", "name", "spec", "spec_raw", "unit", "sum", *PARTS, "uses") + + def collect(entries: list[tuple[str, Path]], sheets: dict[str, Any]) -> dict[str, Any]: """`entries` = [(구조물 id, 사본 폴더)] → 블록 · 목록 · 「연결 없음」.""" solver = Solver(sheets) @@ -461,10 +479,7 @@ def collect(entries: list[tuple[str, Path]], sheets: dict[str, Any]) -> dict[str for src in rows_of(folder, whole): solver.add(whole, src, owner) blocks = sorted(solver.blocks.values(), key=lambda b: b["no"]) - listing = [ - {k: b[k] for k in ("no", "key", "logic", "name", "spec", "unit", "sum", *PARTS, "uses")} - for b in blocks - ] + listing = [{k: b[k] for k in LISTED} for b in blocks] gaps = [ gap("sg", b["key"], f"산근 {b['no']} {b['name']}", b["why"] or "단가 빈칸") for b in blocks diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index f8c01c2d6..5766632f4 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -30,3 +30,83 @@ white-space: normal; overflow-wrap: anywhere; } + +/* 157-2 산근 탭 머리 — 안내 글 + 보기 단추 */ +.b09-estimation__bar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--spacing-8); +} + +/* 157-2 산근 호표별 카드 — 호표 사이 또렷이 · 안은 가름줄 없이 간격 */ +.b09-sg-cards { + display: flex; + flex-direction: column; + gap: var(--spacing-24); +} + +.b09-sg-card__head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--spacing-8); + margin-bottom: var(--spacing-8); + font-weight: var(--font-weight-bold); +} + +.b09-sg-card__no { + color: var(--color-accent); +} + +.b09-sg-card__unit { + color: var(--color-text-secondary); +} + +.b09-sg-card__total { + margin-left: auto; + font-variant-numeric: tabular-nums; +} + +.b09-sg-card__line { + display: grid; + grid-template-columns: 7em minmax(0, 1fr) repeat(4, 7.5em) 10em; + column-gap: var(--spacing-8); + padding: var(--spacing-4) 0; +} + +.b09-sg-card__cols, +.b09-sg-card__sub { + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.b09-sg-card__line.is-title { + margin-top: var(--spacing-4); + font-weight: var(--font-weight-bold); +} + +.b09-sg-card__line.is-clickable { + cursor: pointer; +} + +.b09-sg-card__line.is-clickable:hover, +.b09-sg-card__line.is-focus, +.b09-sg-card.is-focus { + background: color-mix(in srgb, var(--color-accent) 8%, transparent); +} + +.b09-sg-card__text, +.b09-sg-card__note { + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.b09-sg-card__num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.b09-sg-card__cols .b09-sg-card__num { + font-variant-numeric: normal; +} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index aa1687d73..7120684a4 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -19,7 +19,7 @@ import { showToast, } from "@ui/ui_template_elements"; import { attachCollapsible } from "@ui/ui_template_collapsible"; -import { createDataTable } from "@ui/ui_template_data_table"; +import { createDataTable, type DataTable } from "@ui/ui_template_data_table"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; @@ -56,6 +56,7 @@ import { } from "./B09_Estimation_UI_Tabs_Summary"; import { buildCost, buildTotal } from "./B09_Estimation_UI_Tabs_Total"; import { buildSg, buildSgList } from "./B09_Estimation_UI_Tabs_Sangeun"; +import { renderSgPanel } from "./B09_Estimation_UI_Tabs_Sangeun_Cards"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -190,23 +191,45 @@ export function renderB09Estimation(root: HTMLElement): void { const goto = (link: Link | null): void => { if (link) show(link.tab, link.key); }; - const table = createDataTable({ - columns: spec.columns, - rows: spec.rows, - total: spec.total ?? null, - frozen: spec.frozen ?? 0, - empty: "", - caption: TABS.find(([id]) => id === tab)?.[1], - editable: spec.editable, - onEdit: spec.onEdit, - rowClass: spec.rowClass, - onCellClick: (i, key) => { - if (spec.onCell) spec.onCell(i, key); - if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key)); - }, - }); - table.root.dataset.b09Table = tab; - panel.replaceChildren(...(spec.hint ? [note(spec.hint)] : []), table.root); + const makeTable = (): DataTable => { + const made = createDataTable({ + columns: spec.columns, + rows: spec.rows, + total: spec.total ?? null, + frozen: spec.frozen ?? 0, + empty: "", + caption: TABS.find(([id]) => id === tab)?.[1], + editable: spec.editable, + onEdit: spec.onEdit, + rowClass: spec.rowClass, + onCellClick: (i, key) => { + if (spec.onCell) spec.onCell(i, key); + if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key)); + }, + }); + made.root.dataset.b09Table = tab; + // 157-3 칸 풍선 — 공용 표는 그대로 · 올린 칸에 title + if (spec.tip) + made.root.addEventListener("mouseover", (event) => { + const cell = (event.target as Element).closest("tr[data-i] td"); + const row = cell?.parentElement; + const column = cell ? spec.columns[cell.cellIndex] : undefined; + if (!cell || !row || !column) return; + const tip = spec.tip?.(Number(row.dataset.i), column.key); + if (tip) cell.title = tip; + }); + return made; + }; + const hint = spec.hint ? note(spec.hint) : null; + // 157-2 산근 = 보기 둘(표 · 호표별 카드) — 머리 단추로 바꿈 + if (tab === "sg") { + const redraw = (): void => show("sg"); + return void panel.replaceChildren( + ...renderSgPanel({ spec, hint, focus, goto, makeTable, redraw }), + ); + } + const table = makeTable(); + panel.replaceChildren(...(hint ? [hint] : []), table.root); if (focus !== undefined) { const index = rowIndex(spec.rows, focus); if (index >= 0) requestAnimationFrame(() => table.focusRow(index)); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index ee28a789e..e2c2ae4aa 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -87,6 +87,8 @@ export interface TabTable { onCell?: (index: number, key: string) => void; /** 줄 꼴 class(공용 표 `rowClass` · 예 제목 줄 굵게) */ rowClass?: (index: number) => string | null | undefined; + /** 칸 풍선 글(157-3 · 예 「이름 규격」 칸의 원문번호) */ + tip?: (index: number, key: string) => string | null | undefined; } export type TabBuild = (ctx: TabContext) => TabTable | string; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts index 2b269a626..8a285a8ad 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts @@ -29,6 +29,10 @@ const PART_NAME: Record = { }; const PARTS = ["labor", "material", "expense"] as const; +/** 157-3 규격 풍선 = 원문번호 글(「이름 규격」 으로 바꾼 칸만) */ +const raw = (shown: unknown, codes: unknown): string | null => + codes && codes !== shown ? `원문번호 ${String(codes)}` : null; + const notes = (sheets: Row): string => ((sheets.sg_notes as string[]) || []).map((n) => ` · ${n}`).join(""); @@ -55,6 +59,7 @@ export const buildSgList: TabBuild = ({ sheets }): TabTable => { "산근 = 연결된 구조물 조합 사본의 단가산출 로직 줄 · 사전조합 안 줄 · 일위대가화 줄의 하위 로직(노무 · 자재 · 중기 줄은 각 목록표) · 같은 로직 + 같은 입력값 = 한 호표 · 합계 = 블록 총계 · 줄 누름 = 그 블록" + notes(sheets), link: (i) => (rows[i] ? { tab: "sg", key: String(rows[i].key) } : null), + tip: (i, key) => (key === "spec" ? raw(rows[i]?.spec, rows[i]?.spec_raw) : null), }; }; @@ -81,7 +86,19 @@ export const buildSg: TabBuild = ({ sheets }): TabTable | string => { const noOf: Record = Object.fromEntries(blocks.map((b) => [b.key, b.no])); for (const b of blocks) { const head: string[] = b.head?.length ? b.head : [`${b.name} / ${b.unit}`]; - push({ key: b.key, kind: `제 ${b.no} 호표`, text: head[0], note: b.spec }, null, true); + push( + { + key: b.key, + kind: `제 ${b.no} 호표`, + text: head[0], + note: b.spec, + note_raw: b.spec_raw, + name: b.name, + unit: b.unit, + }, + null, + true, + ); head.slice(1).forEach((line, n) => push({ key: `${b.key}|머리${n}`, text: ` ${line}` })); (b.lines as Row[]).forEach((ln, n) => { const link = ln.ref?.key ? { tab: LINE_TAB[ln.ref.tab] ?? "compare", key: ln.ref.key } : null; @@ -145,5 +162,7 @@ export const buildSg: TabBuild = ({ sheets }): TabTable | string => { notes(sheets), link: (i: number) => links[i] ?? null, rowClass: (i: number) => (titles.has(i) ? "is-title" : null), + tip: (i: number, key: string) => + key === "note" ? raw(rows[i]?.note, rows[i]?.note_raw) : null, }; }; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun_Cards.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun_Cards.ts new file mode 100644 index 000000000..3c0571811 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun_Cards.ts @@ -0,0 +1,217 @@ +/* ============================================================================= + * B09_Estimation_UI_Tabs_Sangeun_Cards.ts + * B09 단가산출근거 탭 보기 둘(PLAN 157-2) — 보기 A = 표(`buildSg` 그대로) · 보기 B = 호표별 카드. + * + * 카드 = `buildSg` 가 낸 같은 줄을 호표(줄 키 앞머리)마다 묶어 그림 — 값 · 글은 표와 같음 + * 머리 = 산근 n호표 · 공종 · 단위 · 합계 · 몸 = 산출내역 · 합계 · 노 · 재 · 경 · 비고 줄(가름줄 없이 간격) + * 탭 머리 단추 = 보기 바꿈 · 고른 보기 = localStorage(`b09-sg-view`) · 못 읽으면 표 + * 줄 누름 = 표와 같은 이동(`spec.link`) · 이동해 온 줄 = 스크롤 + 강조 + * ========================================================================== */ + +import type { DataRow, DataTable } from "@ui/ui_template_data_table"; +import { formatCell } from "@ui/ui_template_data_table"; +import { createButton, createCard } from "@ui/ui_template_elements"; +import { rowIndex, type Link, type TabTable } from "./B09_Estimation_UI_Tabs"; + +export type SgView = "table" | "cards"; + +export const SG_VIEW_KEY = "b09-sg-view"; + +/** 단추 글 = 바꿀 보기 이름 */ +export const sgViewLabel = (view: SgView): string => + view === "cards" ? "표로 보기" : "호표별 보기"; + +export const flipSgView = (view: SgView): SgView => (view === "cards" ? "table" : "cards"); + +type Store = Pick; + +function localStore(): Store | null { + try { + return localStorage; + } catch { + return null; + } +} + +/** 고른 보기 — 못 읽거나 모르는 값 = 표 */ +export function readSgView(store: Store | null = localStore()): SgView { + try { + return store?.getItem(SG_VIEW_KEY) === "cards" ? "cards" : "table"; + } catch { + return "table"; + } +} + +export function writeSgView(view: SgView, store: Store | null = localStore()): void { + try { + store?.setItem(SG_VIEW_KEY, view); + } catch { + // 기억 못 해도 이 화면 안에서는 바뀐 보기 그대로 + } +} + +/** 카드 하나 = 호표 한 블록 — 줄 번호(`at`)는 표 줄 번호(이동 · 강조) */ +export interface SgCard { + key: string; + at: number; + head: DataRow; + lines: Array<{ at: number; row: DataRow }>; +} + +/** 표 줄 → 호표 카드(줄 키 `호표키|…` 앞머리로 묶음 · 줄은 그 객체 그대로) */ +export function sgCards(rows: DataRow[]): SgCard[] { + const cards: SgCard[] = []; + const byKey = new Map(); + rows.forEach((row, at) => { + const key = String(row.key ?? "").split("|")[0]; + const card = byKey.get(key); + if (card) return void card.lines.push({ at, row }); + const made: SgCard = { key, at, head: row, lines: [] }; + byKey.set(key, made); + cards.push(made); + }); + return cards; +} + +/* ── 그리기 ───────────────────────────────────────────────────────────── */ + +const MONEY = ["sum", "labor", "material", "expense"] as const; +const LABEL: Record = { + kind: "공종", + text: "산출내역", + sum: "합계", + labor: "노무비", + material: "재료비", + expense: "경비", + note: "비고 · 까닭", +}; + +/** 표 열과 같은 숫자 꼴(열 소수 자리) */ +function formats(spec: TabTable): Record { + return Object.fromEntries( + spec.columns + .filter((c) => c.kind === "number") + .map((c) => [ + c.key, + new Intl.NumberFormat("ko-KR", { + minimumFractionDigits: c.trim ? 0 : (c.decimals ?? 0), + maximumFractionDigits: c.decimals ?? 0, + }), + ]), + ); +} + +function span(className: string, value: string): HTMLSpanElement { + const s = document.createElement("span"); + s.className = className; + s.textContent = value; + return s; +} + +function line( + row: DataRow, + fmt: Record, + className: string, + tip?: string | null, +): HTMLElement { + const div = document.createElement("div"); + div.className = className; + div.append( + span("b09-sg-card__kind", String(row.kind ?? "")), + span("b09-sg-card__text", String(row.text ?? "").trimStart()), + ...MONEY.map((k) => span("b09-sg-card__num", formatCell(row[k], fmt[k] ?? null, "")[0])), + span("b09-sg-card__note", String(row.note ?? "")), + ); + if (tip) (div.lastElementChild as HTMLElement).title = tip; + return div; +} + +function cardsView( + spec: TabTable, + goto: (link: Link | null) => void, + focus: string | undefined, +): HTMLElement { + const fmt = formats(spec); + const host = document.createElement("div"); + host.className = "b09-sg-cards"; + host.dataset.b09Table = "sg"; + const tip = (at: number) => spec.tip?.(at, "note"); + const clickable = (el: HTMLElement, at: number): void => { + el.dataset.i = String(at); + if (spec.link?.(at, "kind")) el.classList.add("is-clickable"); + }; + for (const c of sgCards(spec.rows)) { + const head = document.createElement("div"); + head.className = "b09-sg-card__head"; + const no = String(c.head.kind ?? "").replace(/^제\s*(\d+)\s*호표$/, "산근 $1호표"); + head.append( + span("b09-sg-card__no", no), + span("b09-sg-card__name", String(c.head.name ?? c.head.text ?? "")), + span("b09-sg-card__unit", String(c.head.unit ?? "")), + span( + "b09-sg-card__total", + `합계 ${formatCell(c.lines[c.lines.length - 1]?.row.sum, fmt.sum ?? null, "")[0]}`, + ), + ); + const sub = line({ ...c.head, kind: "" }, fmt, "b09-sg-card__line b09-sg-card__sub", tip(c.at)); + const cols = line(LABEL, {}, "b09-sg-card__line b09-sg-card__cols"); + const lines = c.lines.map(({ at, row }) => { + const cls = spec.rowClass?.(at) ? " is-title" : ""; + const el = line(row, fmt, `b09-sg-card__line${cls}`, tip(at)); + clickable(el, at); + return el; + }); + const card = createCard({ body: [head, cols, sub, ...lines] }); + card.classList.add("b09-sg-card"); + clickable(card, c.at); + host.append(card); + } + host.addEventListener("click", (event) => { + const el = (event.target as Element).closest("[data-i]"); + if (el && host.contains(el)) goto(spec.link?.(Number(el.dataset.i), "kind") ?? null); + }); + const at = focus === undefined ? -1 : rowIndex(spec.rows, focus); + if (at >= 0) + requestAnimationFrame(() => { + const el = host.querySelector(`[data-i="${at}"]`); + el?.classList.add("is-focus"); + el?.scrollIntoView({ block: "center" }); + }); + return host; +} + +/** 이 화면 안 보기(기억이 안 돼도 바꾼 보기 유지) */ +let chosen: SgView | null = null; + +/** 산근 탭 — 머리(안내 + 보기 단추) · 고른 보기(표 · 카드) */ +export function renderSgPanel(o: { + spec: TabTable; + hint: HTMLElement | null; + focus?: string; + goto: (link: Link | null) => void; + makeTable: () => DataTable; + redraw: () => void; +}): HTMLElement[] { + const view = chosen ?? readSgView(); + const toggle = createButton({ + label: sgViewLabel(view), + variant: "ghost", + size: "sm", + onClick: () => { + chosen = flipSgView(view); + writeSgView(chosen); + o.redraw(); + }, + }); + toggle.dataset.b09SgView = view; + const bar = document.createElement("div"); + bar.className = "b09-estimation__bar"; + bar.append(...(o.hint ? [o.hint] : []), toggle); + if (view === "cards") return [bar, cardsView(o.spec, o.goto, o.focus)]; + const table = o.makeTable(); + if (o.focus !== undefined) { + const index = rowIndex(o.spec.rows, o.focus); + if (index >= 0) requestAnimationFrame(() => table.focusRow(index)); + } + return [bar, table.root]; +} diff --git a/resources/tester/test_157_2_b09_sangeun_views.py b/resources/tester/test_157_2_b09_sangeun_views.py new file mode 100644 index 000000000..bbc188944 --- /dev/null +++ b/resources/tester/test_157_2_b09_sangeun_views.py @@ -0,0 +1,148 @@ +"""157-2 B09 단가산출근거 탭 보기 둘 · 157-3 산근 규격 칸 「이름 규격」. + +- 157-2 보기 B(호표별 카드) = 보기 A(표) 줄 그대로 호표마다 묶음 — 같은 줄 객체 · 같은 차례 +- 단추 = 보기 바꿈(글 「호표별 보기」 ↔ 「표로 보기」) · 기억 못 읽음 · 모르는 값 = 표(A) +- 157-3 규격 = 기계 · 직종 원문번호 → 「이름 규격」(M02 85-1 도우미) · 번호는 풍선(tip) +""" + +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.B09_Estimation_Sangeun import spec_text # noqa: E402 + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +B09 = ROOT / "B09_Estimation" + + +def _node(js_body): + src = "".join( + IMPORTS.sub("", (B09 / f"B09_Estimation_{n}.ts").read_text("utf-8")) + for n in ("UI_Tabs", "UI_Tabs_Sangeun", "UI_Tabs_Sangeun_Cards") + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", src + js_body], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _block(key, no, spec="", spec_raw=""): + return { + "key": key, "no": no, "name": f"산근{no}", "unit": "m3", "spec": spec, "spec_raw": spec_raw, + "head": [f"산근{no} / m3", "산림품셈 적용"], + "lines": [ + {"kind": "labor", "name": "보통인부", "ref": {"tab": "labor", "key": "LB2"}, + "why": None, "labor": 2979.8, "material": 0.0, "expense": 0.0, + "text": ["보통인부 : 165,545 * 0.018 = 2,979.8 (긴 함수 " +"가" * 200 + ")"]}, + {"kind": "machine", "name": "굴착기", "ref": {"tab": "machine", "key": "EQ1"}, + "why": None, "labor": 3899.0, "material": 538.6, "expense": 912.8, + "text": ["굴착기 노무비", "굴착기 재료비", "굴착기 경비"]}, + ], + "labor": 6878, "material": 538, "expense": 912, "sum": 8328, "why": None, + "foot": "계 : 8,328", + } # fmt: skip + + +SHEETS = {"sg": [_block("SG:A", 1), _block("SG:B", 2)]} + + +def test_157_2_두_보기_같은_값(): + out = _node( + f"\nconst t = buildSg({{ sheets: {json.dumps(SHEETS, ensure_ascii=False)} }});" + "\nconst cards = sgCards(t.rows);" + "\nconst flat = cards.flatMap((c) => [c.head, ...c.lines.map((l) => l.row)]);" + "\nconst ats = cards.flatMap((c) => [c.at, ...c.lines.map((l) => l.at)]);" + "\nconsole.log(JSON.stringify({ n: t.rows.length, keys: cards.map((c) => c.key)," + " same: flat.length === t.rows.length && flat.every((r, i) => r === t.rows[i])," + " ats, heads: cards.map((c) => [c.head.kind, c.head.name, c.head.unit])," + " links: cards.map((c) => c.lines.map((l) => t.link(l.at, 'kind')?.key ?? null)) }));" + ) + assert out["keys"] == ["SG:A", "SG:B"] + assert out["same"] is True # 카드 줄 = 표 줄 객체 그대로 · 같은 차례 + assert out["ats"] == list(range(out["n"])) + assert out["heads"] == [["제 1 호표", "산근1", "m3"], ["제 2 호표", "산근2", "m3"]] + # 머리 1 · 노무 1 · 중기 3 · 총계 1 — 줄 누름 이동은 표와 같은 link + assert out["links"][0] == [None, "LB2", "EQ1", "EQ1", "EQ1", None] + + +def test_157_2_단추_바꿈(): + out = _node( + "\nconsole.log(JSON.stringify([sgViewLabel('table'), sgViewLabel('cards')," + " flipSgView('table'), flipSgView(flipSgView('table'))]));" + ) + assert out == ["호표별 보기", "표로 보기", "cards", "table"] + + +def test_157_2_기억_실패_때_표(): + out = _node( + "\nconst bad = { getItem: () => { throw new Error('막힘'); }," + " setItem: () => { throw new Error('막힘'); } };" + "\nconst mem = new Map();" + "\nconst ok = { getItem: (k) => mem.get(k) ?? null, setItem: (k, v) => mem.set(k, v) };" + "\nwriteSgView('cards', bad);" + "\nconst junk = { getItem: () => 'x', setItem: () => {} };" + "\nconst a = [readSgView(bad), readSgView(null), readSgView(junk), readSgView(ok)];" + "\nwriteSgView('cards', ok);" + "\nconsole.log(JSON.stringify([...a, readSgView(ok), mem.get(SG_VIEW_KEY)]));" + ) + assert out == ["table", "table", "table", "table", "cards", "cards"] + + +def test_157_2_화면_잇기(): + shell = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert 'tab === "sg"' in shell and "renderSgPanel(" in shell + cards = (B09 / "B09_Estimation_UI_Tabs_Sangeun_Cards.ts").read_text("utf-8") + assert "createCard(" in cards and "createButton(" in cards + css = (B09 / "B09_Estimation_UI_Page.css").read_text("utf-8") + assert "white-space: pre-wrap" in css # 긴 산출내역 글 = 카드 안 줄바꿈 + + +class _Whole: + numbers = { + "EQ": {"0201-0070": {"이름": "굴착기(무한궤도)", "규격": "0.6㎥"}}, + "LB": {"1001": {"이름": "보통인부"}}, + } + + +def test_157_3_규격_이름_규격(): + given = {"기계": "0201-0070", "직종": "1001", "규격": 150, "모름": "9999-9999"} + shown, raw = spec_text(_Whole(), given) + assert shown == "기계=굴착기(무한궤도) 0.6㎥, 직종=보통인부, 규격=150, 모름=9999-9999" + assert raw == "기계=0201-0070, 직종=1001, 규격=150, 모름=9999-9999" + + +def test_157_3_번호는_풍선(): + block = _block("SG:A", 1, "기계=굴착기(무한궤도) 0.6㎥", "기계=0201-0070") + plain = _block("SG:B", 2, "규격=150", "규격=150") + listing = [{k: b[k] for k in ("key", "no", "name", "spec", "spec_raw", "unit")} + for b in (block, plain)] # fmt: skip + sg_json = json.dumps([block, plain], ensure_ascii=False) + ls_json = json.dumps(listing, ensure_ascii=False) + out = _node( + f"\nconst sg = buildSg({{ sheets: {{ sg: {sg_json} }} }});" + f"\nconst ls = buildSgList({{ sheets: {{ sg_list: {ls_json} }} }});" + "\nconst heads = sg.rows.map((r, i) => [r, i])" + ".filter(([r]) => !String(r.key).includes('|'));" + "\nconsole.log(JSON.stringify({" + " sg: heads.map(([r, i]) => [r.note, sg.tip(i, 'note') ?? null])," + " ls: [0, 1].map((i) => [ls.rows[i].spec, ls.tip(i, 'spec') ?? null])," + " other: ls.tip(0, 'name') ?? null }));" + ) + assert out["sg"] == [ + ["기계=굴착기(무한궤도) 0.6㎥", "원문번호 기계=0201-0070"], + ["규격=150", None], + ] + assert out["ls"] == [ + ["기계=굴착기(무한궤도) 0.6㎥", "원문번호 기계=0201-0070"], + ["규격=150", None], + ] + assert out["other"] is None