feat(B09): 총괄설계내역서 · 공사원가계산서 줄 레벨 구분(185-1)

- 총괄 = 가.~바. 소계 lv1 굵게 · 제비율 · 입력 줄 lv2
- 원가계산서 = 구분 줄 lv1 · 비목 소계 lv2 · 세목 lv3
- 182 is-lvN 글자 크기 재사용 · 들여쓰기 = 명칭 칸(is-indent) · 앞 칸(is-flat) 그대로
- 새 시험 test_185_1_b09_total_level

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3
This commit is contained in:
2026-10-10 12:54:52 +09:00
co-authored by Claude Opus 5.5
parent 9483c19ad3
commit 5a244c8421
3 changed files with 110 additions and 0 deletions
+13
View File
@@ -112,3 +112,16 @@
background: color-mix(in srgb, var(--color-accent) 22%, var(--color-canvas));
font-weight: var(--font-weight-bold);
}
/* 185-1 총괄 · 원가계산서 레벨 — 글자 크기 = 위 is-lvN · 들여쓰기 = 명칭 칸(is-indent) · 앞 칸(is-flat)은 그대로 */
.b09-estimation .ui-data-table__table tbody tr[class*="is-lv"] td.is-flat:first-child {
padding-left: var(--spacing-8);
}
.b09-estimation .ui-data-table__table tbody tr.is-lv2 td.is-indent {
padding-left: calc(var(--spacing-8) + 1em);
}
.b09-estimation .ui-data-table__table tbody tr.is-lv3 td.is-indent {
padding-left: calc(var(--spacing-8) + 2em);
}
@@ -8,6 +8,9 @@
* 고칠 칸 = 요율 · 손입력(기초액 · 이윤 보정 · 부가세 조정 · 조달수수료율) ·
* 엑셀에 없는 현행 비목 = 줄만(빈칸)
* 원가계산서 = 총괄 값 거울 + 구성비 · 고칠 칸 = 간접재료비 · 작업설 · 줄 누름 = 총괄 그 줄로
* 레벨(185-1) = 줄 `level`(화면이 정함 · 줄 짜임이 이 파일에 있음 · 셈 짝은 값만) → 182 is-lvN 글자 크기 ·
* 들여쓰기 = 명칭 칸(is-indent) · 앞 칸(is-flat)은 그대로 · 1단 = 굵게(is-title)
* 총괄 = 가.~바. 소계 1 · 그 밖 2 · 원가계산서 = 구분만 1 · 비목 소계 2 · 세목 3
* ========================================================================== */
import type { DataRow } from "@ui/ui_template_data_table";
@@ -74,6 +77,14 @@ function clickEstimate(ctx: TabContext, rowKey: string, column: string, from: un
ctx.setUser({ ...ctx.user, estimate });
}
/** 185-1 레벨 꼴 — 줄 class · 칸 class(들여쓰기 = 명칭 칸 · 앞 칸 그대로) */
const levelClass = (r: DataRow | undefined): string | null =>
r ? `is-lv${r.level}${r.level === 1 ? " is-title" : ""}` : null;
const indentClass =
(first: string, name: string) =>
(_i: number, key: string): string | null =>
key === name ? "is-indent" : key === first ? "is-flat" : null;
function rateText(formula: string, rate: Num): string {
return rate === null ? `${formula}(요율 빈칸)` : formula.replace("율", `${rate}%`);
}
@@ -113,6 +124,7 @@ export const buildTotal: TabBuild = (ctx): TabTable => {
rate: null,
hand: null,
candidate: "",
level: part.endsWith(".") ? 1 : 2,
...extra,
});
const rateRow = ([key, name, rt, formula]: [string, string, string, string], no: number) =>
@@ -197,6 +209,8 @@ export const buildTotal: TabBuild = (ctx): TabTable => {
],
rows,
frozen: 2,
rowClass: (i) => levelClass(rows[i]),
cellClass: indentClass("part", "name"),
hint:
"요율 = M01 조달청 제비율에서 자동(맨 위 고르는 값 · 공종 토목) — 칸에 적으면 사용자 값이 앞섬 · 지우거나 출처 칸 누르면 자동 · " +
"2024 엑셀 요율과 연도 차로 다를 수 있음 · 자동도 없으면 그 줄 · 그 줄을 쓰는 소계 빈칸 · 끝수 = 엑셀 그대로(줄마다 원 버림 · 관급 천원 올림) · " +
@@ -262,6 +276,7 @@ export const buildCost: TabBuild = (ctx): TabTable => {
share: share[key] ?? null,
note: formula[key] ?? "",
link: link ?? "",
level: !part ? 1 : name === "소계" ? 2 : 3,
}));
return {
columns: [
@@ -274,6 +289,8 @@ export const buildCost: TabBuild = (ctx): TabTable => {
],
rows,
frozen: 3,
rowClass: (i) => levelClass(rows[i]),
cellClass: indentClass("group", "name"),
hint: "총괄설계내역서 값 거울 · 구성비 = 총공사비 대비 · 고칠 칸 = 간접재료비 · 작업설(직접재료비가 그만큼 바뀌고 재료비 소계는 그대로 — 엑셀 꼴) · 줄 누름 = 총괄 그 줄",
editable: (i, key) => key === "amount" && String(rows[i]?.key) in HAND,
onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value),
@@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
"""185-1 B09 총괄설계내역서 · 공사원가계산서 줄 레벨 — 줄 `level` · 줄 class(182 is-lvN) · 칸 class · CSS.
레벨 = 화면이 정함(줄 짜임이 `B09_Estimation_UI_Tabs_Total.ts` 에 있음 · 셈 짝은 값만 냄 → 거울 없음).
총괄 = 가.~바. 소계 1(굵게) · 그 밖 2 · 원가계산서 = 구분만 1 · 비목 소계 2 · 세목 3.
들여쓰기 = 명칭 칸(is-indent) · 앞 칸(is-flat)은 그대로.
"""
import json
import re
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
B09 = ROOT / "B09_Estimation"
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
SUMS = {"material": 194234417, "labor": 346800554, "expense": 169324165, "supply_net": 69474220}
BODY = f"""
const user = {{ estimate: {{}} }};
const ctx = {{ sheets: {{ total: computeTotal({json.dumps(SUMS)}, user.estimate) }}, payload: {{}}, user, setUser: () => {{}} }};
const pack = (t) => t.rows.map((r, i) => [r.key, r.level, t.rowClass(i), t.cellClass(i, "name"), t.cellClass(i, t.columns[0].key), t.cellClass(i, "amount")]);
console.log(JSON.stringify({{ total: pack(buildTotal(ctx)), cost: pack(buildCost(ctx)) }}));
"""
def _run() -> dict:
names = ("Calc_Base", "Calc_Total", "UI_Tabs", "UI_Tabs_Total")
script = "".join(
IMPORTS.sub("", (B09 / f"B09_Estimation_{n}.ts").read_text(encoding="utf-8")) for n in names
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript"],
input=script + 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 test_총괄_레벨():
rows = {k: (lv, cls) for k, lv, cls, *_ in _run()["total"]}
top = ["direct", "net_cost", "da", "supply_price", "contract", "grand"]
assert all(rows[k] == (1, "is-lv1 is-title") for k in top)
for k in ["in_price", "in_env", "indirect_labor", "machine", "retirement", "overhead", "vat",
"supply"]: # fmt: skip
assert rows[k] == (2, "is-lv2"), k
assert {lv for lv, _ in rows.values()} == {1, 2}
def test_원가계산서_레벨():
rows = {k: (lv, cls) for k, lv, cls, *_ in _run()["cost"]}
for k in ["overhead", "profit", "supply_price", "vat", "contract", "supply", "grand"]:
assert rows[k] == (1, "is-lv1 is-title"), k
for k in ["material", "labor", "expense_sum"]:
assert rows[k] == (2, "is-lv2"), k
for k in ["direct_material", "scrap", "direct_labor", "indirect_labor", "expense", "safety"]:
assert rows[k] == (3, "is-lv3"), k
def test_칸_class_명칭만_들여씀():
out = _run()
for tab in ("total", "cost"):
for _k, _lv, _cls, name, first, amount in out[tab]:
assert (name, first, amount) == ("is-indent", "is-flat", None)
def test_CSS_레벨_규칙():
css = (B09 / "B09_Estimation_UI_Page.css").read_text(encoding="utf-8")
assert re.search(
r'tr\[class\*="is-lv"\] td\.is-flat:first-child \{\s*padding-left: var\(--spacing-8\);', css
)
for lv, em in ((2, 1), (3, 2)):
assert re.search(
rf"tr\.is-lv{lv} td\.is-indent \{{\s*padding-left: calc\(var\(--spacing-8\) \+ {em}em\);",
css,
)
# 글자 크기 = 182 규칙 재사용(줄 높이 28px → 16px 까지)
assert re.search(r"tr\.is-lv1 td \{\s*font-size: var\(--text-body\);", css)