feat(b09): 총괄설계내역서 맨 위 설계내역서 레벨 1 · 2 집계 · 누름 = 설계내역서 그 줄 빨강 (239-1)
- 총괄 표 맨 위 = 설계내역서 레벨 1 · 2 폴더 줄(관급 빼고 · 레벨 1 항목 포함) · 금액 = 설계내역서 셈 그대로 · 끝 = 순공사비 계 - 줄 누름 = 설계내역서 탭 그 줄로 · 이동해 온 줄 = is-pick 빨강 외곽선(고른 줄 상자와 따로) - 새 시험 test_239_1 · 200_2 셸 배선 문구 갱신 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
This commit is contained in:
@@ -61,7 +61,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 { isGroupTab, lineHint, renderGroupPanel } from "./B09_Estimation_UI_Tabs_Groups";
|
||||
import { isGroupTab, lineHint, pickClass, renderGroupPanel } from "./B09_Estimation_UI_Tabs_Groups";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
@@ -262,15 +262,19 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
);
|
||||
}
|
||||
// 200-2 설계내역서 = 고른 줄 상자(235-1) · 229-1 레벨 단추 = 보조 탭 줄 오른쪽 · 202-1 안내 줄 = 표 아래 · Enter · F2 = 입력
|
||||
// 239-1 이동해 온 줄 = 빨강 외곽선(목록표 → 탭 길 is-pick · 고른 줄 상자와 따로)
|
||||
const bill = (spec as BillTable).menu ? (spec as BillTable) : null;
|
||||
const table = makeTable(bill ? (i) => pickedClass(bill.rows[i]) : undefined);
|
||||
const at = focus === undefined ? -1 : rowIndex(spec.rows, focus);
|
||||
const jump: [number, number] | null = at >= 0 ? [at, at] : null;
|
||||
const table = makeTable(
|
||||
bill
|
||||
? (i) => [pickedClass(bill.rows[i]), pickClass(jump, i)].filter(Boolean).join(" ") || null
|
||||
: undefined,
|
||||
);
|
||||
const level = bill ? levelBar(table.root, bill, (i) => table.edit(i)) : null;
|
||||
if (level) bars.setTools(level.tools);
|
||||
panel.replaceChildren(table.root, ...(level ? [level.guide] : []), ...(hint ? [hint] : []));
|
||||
if (focus !== undefined) {
|
||||
const index = rowIndex(spec.rows, focus);
|
||||
if (index >= 0) requestAnimationFrame(() => table.focusRow(index));
|
||||
}
|
||||
if (at >= 0) requestAnimationFrame(() => table.focusRow(at));
|
||||
};
|
||||
|
||||
const apply = (next: Row, fromServer: boolean): void => {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* B09_Estimation_UI_Tabs_Total.ts
|
||||
* B09 총괄설계내역서 · 공사원가계산서 탭(PLAN 145-4) — 실무 엑셀 꼴 · 값 = `B09_Estimation_Calc_Total`.
|
||||
*
|
||||
* 총괄 = 가. 순공사비계(설계내역서 비목 합 · 145-3) + 제비율 줄 + 소계(나~바)
|
||||
* 총괄 = 맨 위 내역 줄(239-1 · 설계내역서 레벨 1 · 2 집계 + 순공사비 계 · 누름 = 설계내역서 그 줄 빨강) +
|
||||
* 가. 순공사비계(설계내역서 비목 합 · 145-3) + 제비율 줄 + 소계(나~바)
|
||||
* 맨 위 = 요율 고르는 값(추정가격 · 공사기간 · 환경보전비 공종 — 누르면 도로 ↔ 그 밖)(166-1)
|
||||
* 요율 = 자동(M01 조달청 제비율 · 짝 셈이 고름) · 사용자가 적으면 앞섬 · 출처 칸 누름 = 자동으로
|
||||
* 고칠 칸 = 요율 · 손입력(기초액 · 이윤 보정 · 부가세 조정 · 조달수수료율) ·
|
||||
@@ -84,6 +85,42 @@ const nameLevel = (rows: DataRow[]) => ({
|
||||
of: (i: number) => rows[i]?.level as number | undefined,
|
||||
});
|
||||
|
||||
/** 239-1 맨 위 내역 줄 = 설계내역서 레벨 1 · 2 폴더 줄(관급 빼고 · 레벨 1 항목도 — 합이 순공사비) ·
|
||||
* 금액 = 설계내역서 셈 그대로 · 끝 = 순공사비 계 · 누름 = 설계내역서 그 줄(`bill`) */
|
||||
function billRows(bill: Row | undefined): DataRow[] {
|
||||
const top = (bill?.rows || []).filter(
|
||||
(r: Row) => r.cost === "direct" && (r.level === 1 || (r.level === 2 && r.kind !== "item")),
|
||||
);
|
||||
if (!top.length) return [];
|
||||
const direct: Row = bill?.totals?.direct || {};
|
||||
const amounts = (r: Row) => ({
|
||||
amount: r.sum ?? null,
|
||||
labor: r.labor ?? null,
|
||||
material: r.material ?? null,
|
||||
expense: r.expense ?? null,
|
||||
});
|
||||
const blank = direct.blank ? ` · 빈 줄 ${direct.blank} — 아는 금액만` : "";
|
||||
return [
|
||||
...top.map((r: Row) => ({
|
||||
key: `bill:${r.key}`,
|
||||
part: "내역",
|
||||
name: r.no ? `${r.no} ${r.name}` : r.name,
|
||||
...amounts(r),
|
||||
note: "누르면 설계내역서 그 줄로",
|
||||
level: r.level,
|
||||
bill: r.key,
|
||||
})),
|
||||
{
|
||||
key: "bill_sum",
|
||||
part: "내역",
|
||||
name: "순공사비 계",
|
||||
...amounts(direct),
|
||||
note: `설계내역서 레벨 1 합(관급 제외)${blank}`,
|
||||
level: 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function rateText(formula: string, rate: Num): string {
|
||||
return rate === null ? `${formula}(요율 빈칸)` : formula.replace("율", `${rate}%`);
|
||||
}
|
||||
@@ -139,6 +176,7 @@ export const buildTotal: TabBuild = (ctx): TabTable => {
|
||||
const sums: Row = t.sums || {};
|
||||
const env = est.environment_kind === "road" ? "road" : "other";
|
||||
const rows: DataRow[] = [
|
||||
...billRows(ctx.sheets.bill),
|
||||
row("in_price", "요율", "추정가격(원)", null, "일반관리비 · 이윤 · 고용보험 구간", {
|
||||
hand: hand("in_price"),
|
||||
}),
|
||||
@@ -211,12 +249,13 @@ export const buildTotal: TabBuild = (ctx): TabTable => {
|
||||
rowClass: (i) => levelClass(rows[i]),
|
||||
level: nameLevel(rows),
|
||||
mergeSame: ["part"],
|
||||
hint: "요율 칸에 적으면 사용자 값 · 지우거나 출처 칸을 누르면 자동 값",
|
||||
hint: "맨 위 내역 줄을 누르면 설계내역서 그 줄로 · 요율 칸에 적으면 사용자 값 · 지우거나 출처 칸을 누르면 자동 값",
|
||||
editable: (i, key) =>
|
||||
(key === "rate" && RATE_KEYS.has(String(rows[i]?.key))) ||
|
||||
(key === "hand" && String(rows[i]?.key) in HAND),
|
||||
onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value),
|
||||
onCell: (i, key) => clickEstimate(ctx, String(rows[i]?.key), key, rows[i]?.from),
|
||||
link: (i) => (rows[i]?.bill ? { tab: "bill", key: String(rows[i].bill) } : null),
|
||||
tip: (i, key) => {
|
||||
const r = rows[i];
|
||||
if (!r?.rt || (key !== "rate" && key !== "candidate")) return null;
|
||||
|
||||
@@ -196,7 +196,9 @@ def test_왼쪽_맞춤_레벨_들여쓰기_글꼴_공용_표():
|
||||
|
||||
def test_셸_배선():
|
||||
src = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
assert "makeTable(bill ? (i) => pickedClass(bill.rows[i]) : undefined)" in src
|
||||
assert (
|
||||
"? (i) => [pickedClass(bill.rows[i]), pickClass(jump, i)]" in src
|
||||
) # 239-1 이동 줄 빨강 곁
|
||||
assert "if (level) bars.setTools(level.tools);" in src # 229-1 단추 = 보조 탭 줄
|
||||
assert "panel.replaceChildren(table.root, ...(level ? [level.guide] : [])" in src
|
||||
lvl = (B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8")
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""239-1 B09 총괄설계내역서 맨 위 = 설계내역서 레벨 1 · 2 집계 · 누름 = 설계내역서 그 줄 빨강.
|
||||
|
||||
- 맨 위 내역 줄 = 설계내역서 레벨 1 · 2 폴더 줄(관급 빼고 · 레벨 1 항목도) · 설계내역서 차례 · 레벨 그대로
|
||||
- 금액(합계 · 노무 · 재료 · 경비) = 설계내역서 그 줄 금액(Calc_Bill) · 끝 「순공사비 계」 = 총괄 가. 순공사비계
|
||||
- 줄 누름 = link {tab: bill, key} → Shell 이 설계내역서 그 줄에 is-pick(빨강 외곽선 · 고른 줄 상자와 따로)
|
||||
- 아래 원가계산 줄 = 그대로(요율 입력 줄부터 같은 차례)
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from test_182_b09_bill_level import BOOKS, HAND, INPUTS, USER # noqa: E402
|
||||
from test_184_3_b09_bill_menu import _node, _ts # noqa: E402
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Calc_Total import compute_total # noqa: E402
|
||||
|
||||
SRC = ["Calc_Base", "Calc_Total", "UI_Tabs", "UI_Tabs_Total"]
|
||||
#: Shell 이 부르는 줄 class(목록표 → 탭 길 · 181-2) — 묶음 탭 파일에서 그 함수만
|
||||
GROUPS = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Groups.ts").read_text("utf-8")
|
||||
PICK = re.search(r"export function pickClass\(.*?\n}\n", GROUPS, re.DOTALL).group(0)
|
||||
PARTS = ["sum", "labor", "material", "expense"]
|
||||
#: 빈 줄 없는 판(연결 없는 earth:5 · hand:c 뺌) — 총괄 순공사비계가 값을 받음
|
||||
FULL_INPUTS = {**INPUTS, "bill": {"items": [i for i in INPUTS["bill"]["items"] if i["ref"]]}}
|
||||
FULL_USER = {"bill": {"rows": [h for h in HAND if h["key"] != "hand:c"]}}
|
||||
|
||||
JS = """
|
||||
const sheets = __SHEETS__;
|
||||
const ctx = { sheets, payload: {}, user: { estimate: {} }, setUser: () => {} };
|
||||
const spec = buildTotal(ctx);
|
||||
const plain = buildTotal({ ...ctx, sheets: { total: sheets.total } });
|
||||
console.log(JSON.stringify({
|
||||
rows: spec.rows.map((r, i) => ({ ...r, link: spec.link(i, "name"), cls: spec.rowClass(i),
|
||||
edit: ["amount", "rate", "hand"].some((k) => spec.editable(i, k)) })),
|
||||
plain: plain.rows.map((r) => r.key),
|
||||
pick: [pickClass([3, 3], 2), pickClass([3, 3], 3), pickClass(null, 3)],
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
def _run(inputs, user):
|
||||
bill = compute_estimation(inputs, user, None, BOOKS)["bill"]
|
||||
total = compute_total(bill["sums"], {})
|
||||
sheets = {"bill": bill, "total": total}
|
||||
out = json.loads(_node(_ts(SRC) + PICK + JS.replace("__SHEETS__", json.dumps(sheets))))
|
||||
return bill, total, out
|
||||
|
||||
|
||||
def test_맨_위_줄_레벨_1_2_폴더_차례_금액_같음():
|
||||
bill, _, out = _run(INPUTS, USER)
|
||||
want = [
|
||||
r
|
||||
for r in bill["rows"]
|
||||
if r["cost"] == "direct" and (r["level"] == 1 or (r["level"] == 2 and r["kind"] != "item"))
|
||||
]
|
||||
top = [r for r in out["rows"] if r["key"].startswith("bill:")]
|
||||
assert [r["key"] for r in top] == [f"bill:{r['key']}" for r in want]
|
||||
assert {r["level"] for r in top} == {1, 2}
|
||||
assert "bill:sec:G" not in {r["key"] for r in top} # 관급 = 순공사비 밖
|
||||
assert "bill:haul:1" not in {r["key"] for r in top} # 레벨 2 항목(파일) 빠짐
|
||||
for got, src in zip(top, want):
|
||||
assert got["name"] == f"{src['no']} {src['name']}"
|
||||
assert [got["amount"], got["labor"], got["material"], got["expense"]] == [
|
||||
src[p] for p in PARTS
|
||||
]
|
||||
assert got["cls"] == ("is-title" if src["level"] == 1 else None)
|
||||
assert got["edit"] is False
|
||||
# 맨 위부터 내역 줄 → 순공사비 계 → 요율 입력 줄
|
||||
keys = [r["key"] for r in out["rows"]]
|
||||
assert keys[: len(top) + 2] == [r["key"] for r in top] + ["bill_sum", "in_price"]
|
||||
|
||||
|
||||
def test_합_순공사비_레벨1_합_총괄_같음():
|
||||
bill, total, out = _run(FULL_INPUTS, FULL_USER)
|
||||
assert bill["totals"]["direct"]["blank"] == 0
|
||||
rows = {r["key"]: r for r in out["rows"]}
|
||||
s = rows["bill_sum"]
|
||||
lv1 = [r for r in out["rows"] if r["key"].startswith("bill:") and r["level"] == 1]
|
||||
for col, p in zip(["amount", "labor", "material", "expense"], PARTS):
|
||||
assert s[col] == bill["totals"]["direct"][p]
|
||||
assert s[col] == sum(r[col] for r in lv1)
|
||||
assert s["amount"] == rows["direct"]["amount"] == total["sums"]["direct"] > 0
|
||||
assert s["level"] == 1 and s["link"] is None
|
||||
|
||||
|
||||
def test_빈_줄_있으면_계_안내():
|
||||
bill, _, out = _run(INPUTS, USER)
|
||||
s = next(r for r in out["rows"] if r["key"] == "bill_sum")
|
||||
assert bill["totals"]["direct"]["blank"] == 2
|
||||
assert s["amount"] == bill["totals"]["direct"]["sum"] and "빈 줄 2" in s["note"]
|
||||
|
||||
|
||||
def test_누름_설계내역서_그_줄_빨강():
|
||||
bill, _, out = _run(INPUTS, USER)
|
||||
keys = {r["key"] for r in bill["rows"]}
|
||||
for r in out["rows"]:
|
||||
if r["key"].startswith("bill:"):
|
||||
assert r["link"] == {"tab": "bill", "key": r["key"][5:]} and r["link"]["key"] in keys
|
||||
else:
|
||||
assert r["link"] is None # 원가계산 줄 = 이동 없음(그대로)
|
||||
assert out["pick"] == [None, "is-pick is-pick-top is-pick-end", None]
|
||||
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
assert "const jump: [number, number] | null = at >= 0 ? [at, at] : null;" in shell
|
||||
assert "[pickedClass(bill.rows[i]), pickClass(jump, i)]" in shell
|
||||
|
||||
|
||||
def test_설계내역서_없으면_원가계산_줄_그대로():
|
||||
_, _, out = _run(INPUTS, USER)
|
||||
assert out["plain"][0] == "in_price"
|
||||
assert out["plain"] == [r["key"] for r in out["rows"] if not r["key"].startswith("bill")]
|
||||
Reference in New Issue
Block a user