Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1

This commit is contained in:
2026-10-10 14:28:53 +09:00
12 changed files with 290 additions and 75 deletions
+18
View File
@@ -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:
+2
View File
@@ -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(),
+93
View File
@@ -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 };
}
+31
View File
@@ -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);
}
+10 -14
View File
@@ -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<string, string> = {};
const BUILDERS: Partial<Record<TabId, TabBuild>> = {
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<Row>("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);
+3 -38
View File
@@ -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;
},
};
};
@@ -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 {
@@ -1,7 +1,7 @@
/* =============================================================================
* B09_Estimation_UI_Tabs_Sangeun.ts
* B09 단가산출근거 · 단가산출근거목록표 탭(PLAN 145-1) — 값 = 서버 `B09_Estimation_Sangeun`
* (서버 단독 · 이 탭엔 조작 없음 · [저장] · [단가 갱신] 때 서버가 다시 셈).
* (서버 단독 · 이 탭엔 조작 없음 · [저장] · [업데이트] 때 서버가 다시 셈).
*
* 목록표 = 산근 호표 한 줄(합계 · 노무비 · 재료비 · 경비 · 1 단위) · 줄 누름 = 그 블록
* 풀이표(155-5 · 엑셀 단가산출근거 꼴) = 공종 · 산출내역(서버가 지은 텍스트 수식 한 칸) · 합계 ·
@@ -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} — 전개 안 함(좌측 데이터 점검)` : ""}`;
};
/** 줄이 없을 때 표 위 맨 앞 안내 */
+2 -3
View File
@@ -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_길_셋():
+15 -16
View File
@@ -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_빈_줄_규칙():
@@ -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