feat(b09): 산근 탭 보기 둘(표 · 호표별 카드) · 규격 칸 기계 · 직종 「이름 규격」(157-2 · 157-3)

- 157-2 탭 머리 단추 = 「표로 보기」 ↔ 「호표별 보기」 · 고른 보기 localStorage 기억(못 읽으면 표)
- 호표별 카드 = 표 줄 그대로 호표마다 묶음 · 머리 산근 n호표 · 공종 · 단위 · 합계 · 가름줄 없이 간격 · 긴 글 줄바꿈 · 줄 누름 이동 같음
- 157-3 산근 규격 글 = 기계 · 직종 원문번호 → 「이름 규격」(M02 85-1 도우미) · 원문번호는 풍선(두 보기 · 목록표)
- 시험 test_157_2_b09_sangeun_views — 두 보기 같은 값 · 단추 바꿈 · 기억 실패 때 표 · 이름 규격 · 풍선

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
This commit is contained in:
2026-10-09 21:21:37 +09:00
co-authored by Claude Opus 5.5
parent c08d321af4
commit d02c1d3f08
7 changed files with 530 additions and 26 deletions
+22 -7
View File
@@ -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 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_Item_Prices as item_prices
from M02_MasterTemplete import M02_Labor_Doc as labor_doc 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 ( from M02_MasterTemplete.M02_Item_Snapshot import (
PICKS_FILE, PICKS_FILE,
VALUES_FILE, VALUES_FILE,
@@ -247,6 +248,18 @@ def _captured(whole: Any, node: Any, env: dict) -> list[str]:
return seen 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: def _money(price: float | None, qty: float | None) -> float | None:
"""줄 금액 = ROUNDDOWN(단가 × 수량, 1)(엑셀 자리).""" """줄 금액 = ROUNDDOWN(단가 × 수량, 1)(엑셀 자리)."""
return None if price is None or qty is None else rounddown(price * qty, 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) sums = block_totals(lines)
why = next((ln["why"] for ln in lines if ln.get("why")), None) why = next((ln["why"] for ln in lines if ln.get("why")), None)
head, foot = self._text(whole, src, times, lines) head, foot = self._text(whole, src, times, lines)
spec, spec_raw = spec_text(whole, src["given"])
return { return {
"logic": code, "logic": code,
"name": name, "name": name,
"spec": ", ".join(f"{k}={v}" for k, v in src["given"].items()), "spec": spec,
"spec_raw": spec_raw,
"unit": unit, "unit": unit,
"source": row.get("출처") or "", "source": row.get("출처") or "",
"lines": lines, "lines": lines,
@@ -317,8 +332,8 @@ class Solver:
@staticmethod @staticmethod
def _stop(code: str, name: str, why: str) -> dict[str, Any]: def _stop(code: str, name: str, why: str) -> dict[str, Any]:
return {"logic": code, "name": name, "spec": "", "unit": "", "source": "", "lines": [], return {"logic": code, "name": name, "spec": "", "spec_raw": "", "unit": "", "source": "",
**dict.fromkeys(PARTS), "sum": None, "why": why, "lines": [], **dict.fromkeys(PARTS), "sum": None, "why": why,
"head": [], "foot": ""} # fmt: skip "head": [], "foot": ""} # fmt: skip
def price_of(self, key: str) -> Any: 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]: def collect(entries: list[tuple[str, Path]], sheets: dict[str, Any]) -> dict[str, Any]:
"""`entries` = [(구조물 id, 사본 폴더)] → 블록 · 목록 · 「연결 없음」.""" """`entries` = [(구조물 id, 사본 폴더)] → 블록 · 목록 · 「연결 없음」."""
solver = Solver(sheets) 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): for src in rows_of(folder, whole):
solver.add(whole, src, owner) solver.add(whole, src, owner)
blocks = sorted(solver.blocks.values(), key=lambda b: b["no"]) blocks = sorted(solver.blocks.values(), key=lambda b: b["no"])
listing = [ listing = [{k: b[k] for k in LISTED} for b in blocks]
{k: b[k] for k in ("no", "key", "logic", "name", "spec", "unit", "sum", *PARTS, "uses")}
for b in blocks
]
gaps = [ gaps = [
gap("sg", b["key"], f"산근 {b['no']} {b['name']}", b["why"] or "단가 빈칸") gap("sg", b["key"], f"산근 {b['no']} {b['name']}", b["why"] or "단가 빈칸")
for b in blocks for b in blocks
+80
View File
@@ -30,3 +30,83 @@
white-space: normal; white-space: normal;
overflow-wrap: anywhere; 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;
}
+41 -18
View File
@@ -19,7 +19,7 @@ import {
showToast, showToast,
} from "@ui/ui_template_elements"; } from "@ui/ui_template_elements";
import { attachCollapsible } from "@ui/ui_template_collapsible"; 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 { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { workflowSteps } from "../A00_Common/b_page_scaffold";
@@ -56,6 +56,7 @@ import {
} from "./B09_Estimation_UI_Tabs_Summary"; } from "./B09_Estimation_UI_Tabs_Summary";
import { buildCost, buildTotal } from "./B09_Estimation_UI_Tabs_Total"; import { buildCost, buildTotal } from "./B09_Estimation_UI_Tabs_Total";
import { buildSg, buildSgList } from "./B09_Estimation_UI_Tabs_Sangeun"; 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 { function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex]; return ui_locales[key][currentLanguageIndex];
@@ -190,23 +191,45 @@ export function renderB09Estimation(root: HTMLElement): void {
const goto = (link: Link | null): void => { const goto = (link: Link | null): void => {
if (link) show(link.tab, link.key); if (link) show(link.tab, link.key);
}; };
const table = createDataTable({ const makeTable = (): DataTable => {
columns: spec.columns, const made = createDataTable({
rows: spec.rows, columns: spec.columns,
total: spec.total ?? null, rows: spec.rows,
frozen: spec.frozen ?? 0, total: spec.total ?? null,
empty: "", frozen: spec.frozen ?? 0,
caption: TABS.find(([id]) => id === tab)?.[1], empty: "",
editable: spec.editable, caption: TABS.find(([id]) => id === tab)?.[1],
onEdit: spec.onEdit, editable: spec.editable,
rowClass: spec.rowClass, onEdit: spec.onEdit,
onCellClick: (i, key) => { rowClass: spec.rowClass,
if (spec.onCell) spec.onCell(i, key); onCellClick: (i, key) => {
if (spec.link && !spec.editable?.(i, key)) goto(spec.link(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); made.root.dataset.b09Table = tab;
// 157-3 칸 풍선 — 공용 표는 그대로 · 올린 칸에 title
if (spec.tip)
made.root.addEventListener("mouseover", (event) => {
const cell = (event.target as Element).closest<HTMLTableCellElement>("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) { if (focus !== undefined) {
const index = rowIndex(spec.rows, focus); const index = rowIndex(spec.rows, focus);
if (index >= 0) requestAnimationFrame(() => table.focusRow(index)); if (index >= 0) requestAnimationFrame(() => table.focusRow(index));
+2
View File
@@ -87,6 +87,8 @@ export interface TabTable {
onCell?: (index: number, key: string) => void; onCell?: (index: number, key: string) => void;
/** 줄 꼴 class(공용 표 `rowClass` · 예 제목 줄 굵게) */ /** 줄 꼴 class(공용 표 `rowClass` · 예 제목 줄 굵게) */
rowClass?: (index: number) => string | null | undefined; rowClass?: (index: number) => string | null | undefined;
/** 칸 풍선 글(157-3 · 예 「이름 규격」 칸의 원문번호) */
tip?: (index: number, key: string) => string | null | undefined;
} }
export type TabBuild = (ctx: TabContext) => TabTable | string; export type TabBuild = (ctx: TabContext) => TabTable | string;
@@ -29,6 +29,10 @@ const PART_NAME: Record<string, string> = {
}; };
const PARTS = ["labor", "material", "expense"] as const; 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 => const notes = (sheets: Row): string =>
((sheets.sg_notes as string[]) || []).map((n) => ` · ${n}`).join(""); ((sheets.sg_notes as string[]) || []).map((n) => ` · ${n}`).join("");
@@ -55,6 +59,7 @@ export const buildSgList: TabBuild = ({ sheets }): TabTable => {
"산근 = 연결된 구조물 조합 사본의 단가산출 로직 줄 · 사전조합 안 줄 · 일위대가화 줄의 하위 로직(노무 · 자재 · 중기 줄은 각 목록표) · 같은 로직 + 같은 입력값 = 한 호표 · 합계 = 블록 총계 · 줄 누름 = 그 블록" + "산근 = 연결된 구조물 조합 사본의 단가산출 로직 줄 · 사전조합 안 줄 · 일위대가화 줄의 하위 로직(노무 · 자재 · 중기 줄은 각 목록표) · 같은 로직 + 같은 입력값 = 한 호표 · 합계 = 블록 총계 · 줄 누름 = 그 블록" +
notes(sheets), notes(sheets),
link: (i) => (rows[i] ? { tab: "sg", key: String(rows[i].key) } : null), 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<string, number> = Object.fromEntries(blocks.map((b) => [b.key, b.no])); const noOf: Record<string, number> = Object.fromEntries(blocks.map((b) => [b.key, b.no]));
for (const b of blocks) { for (const b of blocks) {
const head: string[] = b.head?.length ? b.head : [`${b.name} / ${b.unit}`]; 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}` })); head.slice(1).forEach((line, n) => push({ key: `${b.key}|머리${n}`, text: ` ${line}` }));
(b.lines as Row[]).forEach((ln, n) => { (b.lines as Row[]).forEach((ln, n) => {
const link = ln.ref?.key ? { tab: LINE_TAB[ln.ref.tab] ?? "compare", key: ln.ref.key } : null; 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), notes(sheets),
link: (i: number) => links[i] ?? null, link: (i: number) => links[i] ?? null,
rowClass: (i: number) => (titles.has(i) ? "is-title" : 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,
}; };
}; };
@@ -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<Storage, "getItem" | "setItem">;
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<string, SgCard>();
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<string, string> = {
kind: "공종",
text: "산출내역",
sum: "합계",
labor: "노무비",
material: "재료비",
expense: "경비",
note: "비고 · 까닭",
};
/** 표 열과 같은 숫자 꼴(열 소수 자리) */
function formats(spec: TabTable): Record<string, Intl.NumberFormat> {
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<string, Intl.NumberFormat>,
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<HTMLElement>("[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<HTMLElement>(`[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];
}
@@ -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