From 7cd083a2d7053ffc50f65924b892235f54e5ac0b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 14:25:06 +0900 Subject: [PATCH] =?UTF-8?q?feat(B09):=20=EC=A2=8C=EC=B8=A1=20=E3=80=8C?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=90=EA=B2=80=E3=80=8D=20?= =?UTF-8?q?=EB=AC=B6=EC=9D=8C=20=E2=80=94=20=EC=9D=B4=EB=A6=84=20=C2=B7=20?= =?UTF-8?q?=EC=84=A4=EB=AA=85=20=EC=A7=80=EC=9B=80=20=C2=B7=20[=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8]=20=C2=B7=20=EB=82=A1=EC=9D=8C=20?= =?UTF-8?q?=EC=A0=90=20=EB=B0=B0=EC=A7=80=20=C2=B7=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EC=A0=90=EA=B2=80=20=ED=83=AD=EC=9D=84=20=EC=A2=8C=EC=B8=A1=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=EC=9C=BC=EB=A1=9C(203-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 묶음 이름 「원가 단가」 → 「데이터 점검」 · 굳힌 시각 안내 글 지움(공용 호버로) - [단가 갱신] → [업데이트] · 서버 book_stale(지금 M01 로 굳힌 책과 견줌) 까닭 있으면 점 배지 - 「연결 점검」 탭 = 좌측 목록(누르면 그 탭 그 줄 · 까닭 = 공용 호버) · 위 탭 줄에서 뺌 · 첫 탭 = 공사원가계산서 - test_203_1 새로 · test_144 · test_181_1 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- B09_Estimation/B09_Estimation_Prices.py | 18 +++ B09_Estimation/B09_Estimation_Router.py | 2 + B09_Estimation/B09_Estimation_UI_Check.ts | 93 +++++++++++++++ B09_Estimation/B09_Estimation_UI_Page.css | 31 +++++ B09_Estimation/B09_Estimation_UI_Shell.ts | 24 ++-- B09_Estimation/B09_Estimation_UI_Tabs.ts | 41 +------ B09_Estimation/B09_Estimation_UI_Tabs_Base.ts | 4 +- .../B09_Estimation_UI_Tabs_Sangeun.ts | 2 +- .../B09_Estimation_UI_Tabs_Summary.ts | 2 +- resources/tester/test_144_b09_skeleton.py | 5 +- resources/tester/test_181_1_b09_tab_groups.py | 31 +++-- resources/tester/test_203_1_b09_data_check.py | 112 ++++++++++++++++++ 12 files changed, 290 insertions(+), 75 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_UI_Check.ts create mode 100644 resources/tester/test_203_1_b09_data_check.py diff --git a/B09_Estimation/B09_Estimation_Prices.py b/B09_Estimation/B09_Estimation_Prices.py index 87badd217..e8dd8d916 100644 --- a/B09_Estimation/B09_Estimation_Prices.py +++ b/B09_Estimation/B09_Estimation_Prices.py @@ -15,6 +15,7 @@ from __future__ import annotations +import json from datetime import datetime from decimal import Decimal from typing import Any @@ -197,6 +198,23 @@ def refresh_book(keys: list[str]) -> dict[str, Any]: } +def book_stale(book: dict[str, Any] | None, keys: list[str]) -> str | None: + """203-1 [업데이트] 배지 — 지금 M01 로 새로 굳힌 책(`refresh_book`)과 견줌 · 다른 것 한 줄(같으면 None).""" + if book is None: + return "단가 아직 안 굳힘" + fresh = json.loads(json.dumps(refresh_book(keys), ensure_ascii=False)) # 저장 꼴(JSON)로 견줌 + rows = book.get("rows") or {} + new = sum(key not in rows for key in fresh["rows"]) + changed = sum(key in rows and rows[key] != row for key, row in fresh["rows"].items()) + parts = [ + f"새 단가 줄 {new}" if new else "", + f"M01 에서 바뀐 단가 {changed}" if changed else "", + "운전사 계수 바뀜" if book.get("driver_factor") != fresh["driver_factor"] else "", + "환율 바뀜" if book.get("fx") != fresh["fx"] else "", + ] + return " · ".join(p for p in parts if p) or None + + def extend_book(book: dict[str, Any] | None, keys: list[str]) -> dict[str, Any]: """[저장] — 책에 없는 새 줄만 더함(있는 줄 · 계수 · 환율은 안 바꿈) · 책이 없으면 새로 굳힘.""" if book is None: diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index b4a7d7185..0c36b0627 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -121,6 +121,8 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]: "inputs": inputs, "user": user, "book": {"made": book.get("made")} if book else None, + # 203-1 [업데이트] 배지 까닭(지금 M01 과 다른 것 한 줄 · 같으면 None) + "stale": await asyncio.to_thread(prices.book_stale, book, keys), "sheets": sheets, "rate_candidates": rate_candidates(), "rate_table": rate_table(), diff --git a/B09_Estimation/B09_Estimation_UI_Check.ts b/B09_Estimation/B09_Estimation_UI_Check.ts new file mode 100644 index 000000000..5bb00b5e9 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Check.ts @@ -0,0 +1,93 @@ +/* ============================================================================= + * B09_Estimation_UI_Check.ts + * B09 좌측 「데이터 점검」 묶음(PLAN 203-1) — [업데이트] · 연결 점검(옛 「연결 점검」 탭 내용). + * + * [업데이트] = 단가를 지금 M01 값으로 새로 굳힘 · 서버가 낡았다고 보면(`payload.stale` 까닭 한 줄) + * 단추에 점 배지(`is-pending`) · 굳힌 시각 · 까닭 = 공용 호버 + * 연결 점검 = 「연결 없음」 줄 목록(누르면 그 탭 그 줄로 · 까닭 = 공용 호버) + 서버 알림 · M01 에 없는 키 + * ========================================================================== */ + +import { attachHoverInfo } from "@ui/ui_template_hover_info"; +import type { Row } from "./B09_Estimation_Calc_Base"; +import { tabName, type Link, type TabId } from "./B09_Estimation_UI_Tabs"; + +export interface CheckItem { + link: Link; + tab: string; + name: string; + why: string; +} + +/** 빠진 연결 줄 · 알림 글(서버 알림 + M01 에 없는 키) */ +export function checkItems(sheets: Row, payload: Row): { items: CheckItem[]; notes: string[] } { + const gaps: Row[] = sheets.gaps || []; + const items = gaps.map((g) => ({ + link: { tab: g.tab as TabId, key: g.key }, + tab: tabName(g.tab), + name: String(g.name ?? ""), + why: String(g.why ?? ""), + })); + const notes: string[] = [...(payload.notes || [])]; + const missing: string[] = payload.inputs?.missing || []; + if (missing.length) notes.push(`M01 에 없는 키 ${missing.length} — ${missing.join(" · ")}`); + return { items, notes }; +} + +/** [업데이트] 호버 글 — 굳힌 시각 + 낡은 까닭 */ +export function refreshTip(payload: Row | null): string { + const made = payload?.book?.made; + const when = made ? `단가 굳힘 ${String(made).replace("T", " ")}` : "단가 아직 안 굳힘"; + const stale = payload?.stale; + return `${when} · 누르면 지금 M01 값으로 새로 굳힘${stale ? ` · 업데이트 필요 — ${stale}` : ""}`; +} + +export interface CheckPanel { + /** 묶음 안 연결 점검 자리 */ + list: HTMLElement; + draw: (payload: Row, sheets: Row) => void; +} + +export function createCheckPanel(refresh: HTMLElement, goto: (link: Link) => void): CheckPanel { + let payload: Row | null = null; + let items: CheckItem[] = []; + attachHoverInfo(refresh, () => refreshTip(payload)); + const list = document.createElement("div"); + list.className = "b09-check"; + attachHoverInfo(list, (row) => items[Number(row.dataset.i)]?.why, "[data-i]"); + + const draw = (next: Row, sheets: Row): void => { + payload = next; + refresh.classList.toggle("is-pending", Boolean(next.stale)); + const made = checkItems(sheets, next); + items = made.items; + const head = document.createElement("p"); + head.className = "b09-check__head"; + head.textContent = `연결 없음 ${items.length}곳`; + const rows = document.createElement("div"); + rows.className = "ui-sidebar-list"; + rows.append( + ...items.map((item, i) => { + const row = document.createElement("button"); + row.type = "button"; + row.className = "ui-sidebar-row"; + row.dataset.i = String(i); + const name = document.createElement("span"); + name.textContent = item.name; + const tab = document.createElement("span"); + tab.className = "b09-check__tab"; + tab.textContent = item.tab; + row.append(name, tab); + row.addEventListener("click", () => goto(item.link)); + return row; + }), + ); + const notes = made.notes.map((text) => { + const p = document.createElement("p"); + p.className = "b09-check__note"; + p.textContent = text; + return p; + }); + list.replaceChildren(head, ...(items.length ? [rows] : []), ...notes); + }; + return { list, draw }; +} diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index f5fbd8357..60c580bb2 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -209,3 +209,34 @@ .b09-estimation .ui-data-table__table tbody td.is-edited { background: color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas)); } + +/* 203-1 좌측 「데이터 점검」 — [업데이트] 필요 = 단추 위 점 배지(B05 [3D 업데이트] 꼴) */ +.b09-options .ui-btn.is-pending { + position: relative; + border-color: var(--color-warning); +} + +.b09-options .ui-btn.is-pending::after { + content: ""; + position: absolute; + top: 4px; + right: 4px; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-warning); +} + +/* 연결 점검 목록 — 이름 · 탭(작게) · 알림 글 */ +.b09-check__head, +.b09-check__note { + margin: var(--spacing-8) 0 0; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.b09-check__tab { + flex: none; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index fbc860214..4d027b121 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -4,7 +4,7 @@ * * 원본 = 기번3 공통 원가산출 엑셀 · 탭 하나 = 화면 표 하나(`…_UI_Tabs` 차례) · 옛 원가 코드 안 봄 * 자료 = GET …/estimation(입력 · 사용자 값 · 서버가 셈한 표) · 조작 = 캐시(`b09-estimation`) + - * 짝 계산(`…_Calc_*.ts`)으로 바로 다시 셈 · [저장] = PUT → 서버 정본 · [단가 갱신] = POST …/prices + * 짝 계산(`…_Calc_*.ts`)으로 바로 다시 셈 · [저장] = PUT → 서버 정본 · [업데이트] = POST …/prices * 호표 칸 누름 = 그 탭 그 줄로 이동 · 자동 저장 없음 * ========================================================================== */ @@ -31,9 +31,9 @@ import { computeEstimation } from "./B09_Estimation_Calc_Bill"; import { computeLabor, daegaPrice } from "./B09_Estimation_Calc_Labor"; import { computeTotal } from "./B09_Estimation_Calc_Total"; import { createTabBars } from "./B09_Estimation_UI_TabBar"; +import { createCheckPanel } from "./B09_Estimation_UI_Check"; import { buildDaegaBlocks, buildDaegaList } from "./B09_Estimation_UI_Tabs_Labor"; import { - buildCheck, rowIndex, TABS, type Link, @@ -70,7 +70,6 @@ function L(key: keyof typeof ui_locales): string { const LATER: Record = {}; const BUILDERS: Partial> = { - check: buildCheck, dg_list: buildDaegaList, dg: buildDaegaBlocks, bill: buildBill, @@ -152,7 +151,7 @@ export function renderB09Estimation(root: HTMLElement): void { let payload: Row | null = null; let user: Row = {}; let sheets: Row = {}; - let current: TabId = "check"; + let current: TabId = TABS[0][0]; // 181-1 위 탭(md) · 아래 탭(sm) 두 줄 — 줄 그리기 = `…_UI_TabBar` const bars = createTabBars((tab) => show(tab)); @@ -163,11 +162,10 @@ export function renderB09Estimation(root: HTMLElement): void { body.className = "b09-estimation"; body.append(bars.top, bars.sub, panel); - const made = document.createElement("p"); - made.className = "b09-estimation__hint"; - const recompute = (): void => { - if (payload) sheets = computeSheets(payload, user); + if (!payload) return; + sheets = computeSheets(payload, user); + check.draw(payload, sheets); // 203-1 좌측 연결 점검 · [업데이트] 배지 }; const setUser = (next: Row): void => { @@ -266,9 +264,6 @@ export function renderB09Estimation(root: HTMLElement): void { const draft = projectId && !fromServer ? readState("b09-estimation", projectId) : null; user = draft ?? next.user ?? {}; recompute(); - made.textContent = next.book?.made - ? `단가 굳힘 ${String(next.book.made).replace("T", " ")} — [단가 갱신] 때만 새로` - : "단가 아직 안 굳힘 — [저장] 이나 [단가 갱신] 때 M01 값을 굳힘"; show(current); }; @@ -297,12 +292,13 @@ export function renderB09Estimation(root: HTMLElement): void { }); save.dataset.b09Save = ""; const refresh = createButton({ - label: "단가 갱신", + label: "업데이트", variant: "ghost", onClick: () => void run("/estimation/prices", "POST", "단가를 지금 M01 값으로 새로 굳혔습니다."), }); refresh.dataset.b09Refresh = ""; + const check = createCheckPanel(refresh, (link) => show(link.tab, link.key)); // 184-3 내역 초기화 — 설계내역서 덮개(user.bill) 지우고 바로 저장 → 기본 로직 내역 const reset = createButton({ label: "내역 초기화", @@ -322,8 +318,8 @@ export function renderB09Estimation(root: HTMLElement): void { section.className = "ui-collapsible ui-sidebar-section ui-sidebar-card"; const heading = document.createElement("h3"); heading.className = "ui-collapsible__title"; - heading.textContent = "원가 단가"; - section.append(heading, made, refresh); + heading.textContent = "데이터 점검"; + section.append(heading, refresh, check.list); const actions = document.createElement("div"); actions.className = "ui-sidebar-actions"; actions.append(save, reset); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index 8f68f8ead..5f6eca99d 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -2,9 +2,10 @@ * B09_Estimation_UI_Tabs.ts * B09 탭 목록 · 표 짜기 공용(PLAN 144-1) — 탭 하나 = 화면 표 하나(실무 엑셀 탭 차례). * - * 탭 = 연결 점검(목차 · 안내) · 원가 · 총괄 · 내역 · 대가 둘 · 산근 둘 · 기초 · 중기 둘 · 단가 넷 · 집계표 넷 + * 탭 = 원가 · 총괄 · 내역 · 대가 둘 · 산근 둘 · 기초 · 중기 둘 · 단가 넷 · 집계표 넷 * 표 = 공용 `createDataTable` · 「연결 없음」 칸 = 빈칸 · 호표 칸 누름 = 그 탭 그 줄로 이동(`link`) - * 탭별 표 = `…_UI_Tabs_Base`(144) · `…_UI_Tabs_Cost`(145) · 여기는 차례 · 꼴 · 연결 점검만 + * 탭별 표 = `…_UI_Tabs_Base`(144) · `…_UI_Tabs_Cost`(145) · 여기는 차례 · 꼴만 + * 연결 점검(옛 첫 탭) = 좌측 「데이터 점검」 묶음(203-1 `…_UI_Check`) * ========================================================================== */ import type { MapContextMenuItem } from "@ui/ui_template_context_menu"; @@ -12,7 +13,6 @@ import type { DataColumn, DataRow } from "@ui/ui_template_data_table"; import type { Row } from "./B09_Estimation_Calc_Base"; export type TabId = - | "check" | "cost" | "total" | "bill" @@ -34,7 +34,6 @@ export type TabId = /** 엑셀 탭 차례 — [id, 탭 이름] */ export const TABS: Array<[TabId, string]> = [ - ["check", "연결 점검"], ["cost", "공사원가계산서"], ["total", "총괄설계내역서"], ["bill", "설계내역서"], @@ -59,7 +58,6 @@ export const tabName = (id: string): string => TABS.find(([t]) => t === id)?.[1] /** 위 탭(181-1) — [위 탭 이름, 아래 탭들] · 아래 탭 하나뿐 = 위 탭 단독 · 첫 아래 탭 = 위 탭 키 */ export const TAB_GROUPS: Array<[string, TabId[]]> = [ - ["연결 점검", ["check"]], ["공사원가계산서", ["cost"]], ["총괄설계내역서", ["total"]], ["설계내역서", ["bill"]], @@ -156,36 +154,3 @@ export function parseNumber(value: string): number | null | undefined { export function rowIndex(rows: DataRow[], key: string): number { return rows.findIndex((row) => row.key === key); } - -/* ── 연결 점검(목차 · 안내) ───────────────────────────────────────────── */ - -export const buildCheck: TabBuild = ({ sheets, payload }): TabTable => { - const gaps: Row[] = sheets.gaps || []; - const rows: DataRow[] = gaps.map((g, i) => ({ - no: i + 1, - key: `${g.tab}|${g.key}|${i}`, - tab: tabName(g.tab), - name: g.name, - why: g.why, - })); - const notes: string[] = [...(payload.notes || [])]; - const missing: string[] = payload.inputs?.missing || []; - if (missing.length) notes.push(`M01 에 없는 키 ${missing.length} — ${missing.join(" · ")}`); - return { - columns: [ - qty("no", "번호", 0), - text("tab", "탭", 150), - text("name", "명칭", 260), - text("why", "까닭", 320), - ], - rows, - frozen: 1, - hint: - `연결 없음 ${gaps.length}곳 — 줄을 누르면 그 탭 그 줄로 · 빈칸 = 값을 낸 원천이 없음(0 을 채우지 않음)` + - (notes.length ? ` · ${notes.join(" · ")}` : ""), - link: (i) => { - const g = gaps[i]; - return g ? { tab: g.tab as TabId, key: g.key } : null; - }, - }; -}; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts index e16abf10f..f1a978755 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts @@ -51,14 +51,14 @@ export const buildLabor: TabBuild = ({ sheets, payload }) => priceList( sheets.labor || [], "단가(원/인)", - `노임 = 굳힌 단가(${madeText(payload)}) · M01 인력 · 바꾸려면 [단가 갱신]`, + `노임 = 굳힌 단가(${madeText(payload)}) · M01 인력 · 바꾸려면 [업데이트]`, ); export const buildExpense: TabBuild = ({ sheets, payload }) => priceList( sheets.expense || [], "기준가(천원)", - `기계 손료 기준가 = 굳힌 단가(${madeText(payload)}) · M01 기계 가격 · 바꾸려면 [단가 갱신]`, + `기계 손료 기준가 = 굳힌 단가(${madeText(payload)}) · M01 기계 가격 · 바꾸려면 [업데이트]`, ); function madeText(payload: Row): string { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts index 1cf80dc9f..f98ab1cfa 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts @@ -1,7 +1,7 @@ /* ============================================================================= * B09_Estimation_UI_Tabs_Sangeun.ts * B09 단가산출근거 · 단가산출근거목록표 탭(PLAN 145-1) — 값 = 서버 `B09_Estimation_Sangeun` - * (서버 단독 · 이 탭엔 조작 없음 · [저장] · [단가 갱신] 때 서버가 다시 셈). + * (서버 단독 · 이 탭엔 조작 없음 · [저장] · [업데이트] 때 서버가 다시 셈). * * 목록표 = 산근 호표 한 줄(합계 · 노무비 · 재료비 · 경비 · 1 단위) · 줄 누름 = 그 블록 * 풀이표(155-5 · 엑셀 단가산출근거 꼴) = 공종 · 산출내역(서버가 지은 텍스트 수식 한 칸) · 합계 · diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts index 27d21fa0b..5fd4fba08 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts @@ -24,7 +24,7 @@ const HINT = /** 안내 + 「연결 없음 n」(전개 안 한 내역 항목 — 조합 밖 토공 · 운반 산근 등) */ const hint = (payload: Row, extra = ""): string => { const n = (payload.summary || {}).unlinked || 0; - return `${HINT}${extra}${n ? ` · 연결 없음 ${n} — 전개 안 함(연결 점검 탭)` : ""}`; + return `${HINT}${extra}${n ? ` · 연결 없음 ${n} — 전개 안 함(좌측 데이터 점검)` : ""}`; }; /** 줄이 없을 때 표 위 맨 앞 안내 */ diff --git a/resources/tester/test_144_b09_skeleton.py b/resources/tester/test_144_b09_skeleton.py index 5c652cd11..7b4409486 100644 --- a/resources/tester/test_144_b09_skeleton.py +++ b/resources/tester/test_144_b09_skeleton.py @@ -1,6 +1,6 @@ """144-1 B09 뼈대 — 탭 차례 · 저장 자리 · 길 · 빈칸 규칙 · 「연결 없음」 목록 · 단가 굳히기. -- 탭 = 기번3 공통 엑셀 보이는 탭 차례(목차 · 안내 = 「연결 점검」 한 탭 · INITIAL 안 옮김) +- 탭 = 기번3 공통 엑셀 보이는 탭 차례(목차 · 안내 = 좌측 「데이터 점검」 묶음 203-1 · INITIAL 안 옮김) - 저장 = 프로젝트 안 `B09_Estimation/v1/`(사용자 값 · 굳힌 단가) · 결과는 저장 안 함 - 줄 = 수요 따라(사용자 승인 ①) — 연결된 사본 없으면 노무 · 경비 · 중기 빈 표 · B08 자재만 줄 · 출처 빈칸 @@ -45,8 +45,7 @@ def test_탭_차례가_엑셀과_같다(): source.index("export const TABS") : source.index("];", source.index("export const TABS")) ] names = re.findall(r'\["\w+", "([^"]+)"\]', block) - assert names[0] == "연결 점검" - assert names[1:] == EXCEL_TABS + assert names == EXCEL_TABS # 203-1 「연결 점검」 탭 = 좌측 묶음으로 옮김 def test_길_셋(): diff --git a/resources/tester/test_181_1_b09_tab_groups.py b/resources/tester/test_181_1_b09_tab_groups.py index c1e288330..659be45e6 100644 --- a/resources/tester/test_181_1_b09_tab_groups.py +++ b/resources/tester/test_181_1_b09_tab_groups.py @@ -1,6 +1,6 @@ -"""181-1 B09 탭 두 층 — 위 탭 10 · 아래 탭 묶음(사용자 30번) · 줄 그리기 · 마지막 본 아래 탭. +"""181-1 B09 탭 두 층 — 위 탭 9(203-1 연결 점검 뺌) · 아래 탭 묶음(사용자 30번) · 줄 그리기 · 마지막 본 아래 탭. -- 위 탭 = `TAB_GROUPS`(`TABS` 19 키를 빠짐 · 겹침 없이 묶음) · `TABS`(엑셀 차례)는 그대로 +- 위 탭 = `TAB_GROUPS`(`TABS` 18 키를 빠짐 · 겹침 없이 묶음) · `TABS`(엑셀 차례)는 그대로 - 줄 = 위 md · 아래 sm · 아래 탭 없는 위 탭 = 빈 보조 줄(B08 꼴) """ @@ -18,7 +18,6 @@ BAR = "B09_Estimation/B09_Estimation_UI_TabBar.ts" # 사용자 30번 — 위 탭 차례 = 지금 탭 차례(묶음 첫 자리) · 아래 = 글에 적힌 차례 EXPECTED = [ - ["연결 점검", ["check"]], ["공사원가계산서", ["cost"]], ["총괄설계내역서", ["total"]], ["설계내역서", ["bill"]], @@ -31,7 +30,7 @@ EXPECTED = [ ] -def test_묶음_표_사용자_30번과_같고_19_탭이_한_번씩(): +def test_묶음_표_사용자_30번과_같고_18_탭이_한_번씩(): out = run( [TABS], "\nconsole.log(JSON.stringify({ groups: TAB_GROUPS, ids: TABS.map(([t]) => t)," @@ -39,11 +38,11 @@ def test_묶음_표_사용자_30번과_같고_19_탭이_한_번씩(): ) assert out["groups"] == EXPECTED flat = [t for _, tabs in out["groups"] for t in tabs] - assert sorted(flat) == sorted(out["ids"]) and len(flat) == len(set(flat)) == 19 + assert sorted(flat) == sorted(out["ids"]) and len(flat) == len(set(flat)) == 18 # 위 탭 차례 = 엑셀 차례에서 묶음이 처음 나오는 자리 firsts = list(dict.fromkeys(out["of"])) assert firsts == [g[0] for g in EXPECTED] - assert len(firsts) == 10 + assert len(firsts) == 9 def test_줄_그리기_두_층_빈_줄_마지막_본_아래_탭(): @@ -61,26 +60,26 @@ const view = () => ({ }); bars.draw('dg'); const a = view(); -bars.top.children[4].click(); // 켜진 위 탭 다시 누름 = 아무 일 없음 +bars.top.children[3].click(); // 켜진 위 탭 다시 누름 = 아무 일 없음 bars.sub.children[0].click(); // 일위대가목록표 bars.draw('compare'); // 묶음을 넘는 이동 = 위 탭 따라 켜짐 const b = view(); bars.draw('sum_material'); bars.draw('dg'); -bars.top.children[8].click(); // 재료 = 마지막 본 아래 탭(재료비수량금액집계표) -bars.top.children[7].click(); // 중기 = 본 적 없음 → 첫 아래 탭 +bars.top.children[7].click(); // 재료 = 마지막 본 아래 탭(재료비수량금액집계표) +bars.top.children[6].click(); // 중기 = 본 적 없음 → 첫 아래 탭 bars.draw('cost'); const c = view(); -bars.top.children[0].click(); // 연결 점검 = 단독 위 탭 +bars.top.children[1].click(); // 총괄설계내역서 = 단독 위 탭 bars.sub.children[0].click(); // 빈 줄 단추(숨김) = 누름 없음 console.log(JSON.stringify({ a, b, c, picks })); """, ) a, b, c = out["a"], out["b"], out["c"] - assert len(a["top"]) == 10 and [t[0] for t in a["top"]][:5] == [ - "연결 점검", "공사원가계산서", "총괄설계내역서", "설계내역서", "일위대가" + assert len(a["top"]) == 9 and [t[0] for t in a["top"]][:5] == [ + "공사원가계산서", "총괄설계내역서", "설계내역서", "일위대가", "단가산출" ] # fmt: skip - assert [t[2] for t in a["top"]] == ["false"] * 4 + ["true"] + ["false"] * 5 + assert [t[2] for t in a["top"]] == ["false"] * 3 + ["true"] + ["false"] * 5 assert all("ui-btn--ghost" in t[1] for t in a["top"] if t[2] == "false") assert a["sub"] == [["일위대가목록표", "false", "dg_list"], ["일위대가표", "true", "dg"]] assert not a["blank"] and a["role"] == "tablist" @@ -89,11 +88,11 @@ console.log(JSON.stringify({ a, b, c, picks })); ["자재단가대비표", "true", "compare"], ["재료비수량금액집계표", "false", "sum_material"], ] # fmt: skip - assert b["top"][8][2] == "true" and b["top"][4][2] == "false" + assert b["top"][7][2] == "true" and b["top"][3][2] == "false" # 단독 위 탭 — 위 단추가 탭 키도 가짐 · 아래 줄 = 숨긴 단추 한 개 assert c["blank"] and c["role"] == "presentation" and len(c["sub"]) == 1 - assert c["top"][1][3] == "cost" and c["top"][1][4] == "cost" and c["top"][4][4] is None - assert out["picks"] == ["dg_list", "sum_material", "machine", "check"] + assert c["top"][0][3] == "cost" and c["top"][0][4] == "cost" and c["top"][3][4] is None + assert out["picks"] == ["dg_list", "sum_material", "machine", "total"] def test_잇기_쉘이_두_줄을_쓰고_CSS_빈_줄_규칙(): diff --git a/resources/tester/test_203_1_b09_data_check.py b/resources/tester/test_203_1_b09_data_check.py new file mode 100644 index 000000000..2beecbcb5 --- /dev/null +++ b/resources/tester/test_203_1_b09_data_check.py @@ -0,0 +1,112 @@ +"""203-1 B09 좌측 「데이터 점검」 묶음(사용자 60번). + +- 묶음 이름 = 데이터 점검 · 안 설명 글 없음(굳힌 시각 · 까닭 = [업데이트] 공용 호버) +- [단가 갱신] → [업데이트] · 낡으면(서버 `book_stale` = 지금 M01 로 굳힌 책과 다름) 점 배지(is-pending) +- 「연결 점검」 탭 내용 = 묶음 안 목록(누르면 그 탭 그 줄) · 위 탭 줄에서 뺌 · 첫 탭 = 공사원가계산서 +""" + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation import B09_Estimation_Prices as prices # noqa: E402 +from resources.tester.helper_b09_group_cards import TABS, run # noqa: E402 + +CHECK = "B09_Estimation/B09_Estimation_UI_Check.ts" +SHELL = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8") + + +def test_묶음_이름_단추_글_설명_없음(): + assert 'heading.textContent = "데이터 점검";' in SHELL and "원가 단가" not in SHELL + assert 'label: "업데이트",' in SHELL and "단가 갱신" not in SHELL + # 옛 굳힌 시각 안내 글(made) 없음 · 묶음 = 제목 · [업데이트] · 연결 점검 + assert "made.textContent" not in SHELL and "b09-estimation__hint" not in SHELL + assert "section.append(heading, refresh, check.list);" in SHELL + assert "check.draw(payload, sheets);" in SHELL + + +def test_연결_점검_탭_없음_첫_탭_공사원가계산서(): + out = run( + [TABS], + "\nconsole.log(JSON.stringify({ ids: TABS.map(([t]) => t), names: TABS.map(([, n]) => n)," + " groups: TAB_GROUPS.map(([n]) => n), first: TABS[0][0] }));", + ) + assert "check" not in out["ids"] and "연결 점검" not in out["names"] + out["groups"] + assert out["first"] == "cost" and out["groups"][0] == "공사원가계산서" + assert "let current: TabId = TABS[0][0];" in SHELL + assert "buildCheck" not in SHELL and "check:" not in SHELL + + +def test_좌측_목록_이동_호버_배지(): + out = run( + [TABS, CHECK], + """ +const moves = []; +const refresh = document.createElement("button"); +const panel = createCheckPanel(refresh, (link) => moves.push(link)); +const sheets = { gaps: [ + { tab: "dg", key: "DG1", name: "찰쌓기", why: "대가 원천 없음" }, + { tab: "material", key: "MT9", name: "모래", why: "단가 없음" }, +] }; +const payload = { book: { made: "2026-10-01T09:00:00" }, stale: "M01 에서 바뀐 단가 2", + notes: ["B08 자재 집계표 못 읽음"], inputs: { missing: ["EQ1", "LB2"] } }; +panel.draw(payload, sheets); +const view = () => ({ + pending: refresh.classList.contains("is-pending"), + texts: panel.list.children.map((c) => c.text), + rows: (panel.list.children[1]?.children ?? []).map((r) => [r.dataset.i, r.className, r.text]), +}); +const a = view(); +const rowsBox = panel.list.children[1]; +rowsBox.children[1].click(); +const tipRefresh = hoverCalls.find((h) => h.root === refresh).text(); +const rowHover = hoverCalls.find((h) => h.root === panel.list); +const tipRow = rowHover.text(rowsBox.children[0]); +panel.draw({ book: null, stale: null }, { gaps: [] }); +const b = view(); +const tipEmpty = refreshTip({ book: null, stale: "단가 아직 안 굳힘" }); +console.log(JSON.stringify({ a, b, moves, tipRefresh, tipRow, selector: rowHover.selector, tipEmpty })); +""", + ) + a, b = out["a"], out["b"] + assert a["pending"] and not b["pending"] + assert a["texts"][0] == "연결 없음 2곳" + assert a["rows"] == [ + ["0", "ui-sidebar-row", "찰쌓기일위대가표"], + ["1", "ui-sidebar-row", "모래재료비목록표"], + ] + assert a["texts"][2:] == ["B08 자재 집계표 못 읽음", "M01 에 없는 키 2 — EQ1 · LB2"] + assert out["moves"] == [{"tab": "material", "key": "MT9"}] + assert out["tipRow"] == "대가 원천 없음" and out["selector"] == "[data-i]" + assert "단가 굳힘 2026-10-01 09:00:00" in out["tipRefresh"] + assert "업데이트 필요 — M01 에서 바뀐 단가 2" in out["tipRefresh"] + assert b["texts"] == ["연결 없음 0곳"] and b["rows"] == [] + assert out["tipEmpty"].startswith("단가 아직 안 굳힘") and "업데이트 필요" in out["tipEmpty"] + + +def test_배지_조건_서버_book_stale(): + keys = ["EQ000004"] # 불도저 · 조종원 · 경유(test_144 와 같은 견본) + book = json.loads(json.dumps(prices.refresh_book(keys), ensure_ascii=False)) + assert prices.book_stale(book, keys) is None # 방금 굳힌 책 = 업데이트 필요 없음 + assert prices.book_stale(None, keys) == "단가 아직 안 굳힘" + first, *rest = list(book["rows"]) + old = {**book, "rows": {k: book["rows"][k] for k in rest}, "fx": []} + old["rows"][rest[0]] = {**old["rows"][rest[0]], "price": -1} + old["driver_factor"] = {"num": 1, "den": 1, "text": "1/1"} + assert prices.book_stale(old, keys) == ( + "새 단가 줄 1 · M01 에서 바뀐 단가 1 · 운전사 계수 바뀜 · 환율 바뀜" + ) + router = (ROOT / "B09_Estimation/B09_Estimation_Router.py").read_text("utf-8") + assert '"stale": await asyncio.to_thread(prices.book_stale, book, keys),' in router + + +def test_배지_꼴_경고_색_토큰(): + css = (ROOT / "B09_Estimation/B09_Estimation_UI_Page.css").read_text("utf-8") + block = css[css.index(".b09-options .ui-btn.is-pending::after") :] + block = block[: block.index("}")] + assert "position: absolute" in block and "border-radius: 50%" in block + assert "background: var(--color-warning)" in block