feat(M02): 구조물 조합 일위대가 합계 칸 상태 색 · 머리와 합계 칸 한 상자 (101-1 · 101-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
This commit is contained in:
@@ -189,6 +189,9 @@ export interface StructureNumbers {
|
||||
/** 일위대가 합계(86-3) — 반영합 · 없으면 기준합(`합계기준` 참) · 계산값 없으면 칸 없음 */
|
||||
일위대가합계?: number;
|
||||
합계기준?: boolean;
|
||||
/** 합계 칸 색(101-1) — 붉음 `error` > 노랑 `short` > 초록 `ok` · 까닭 글은 칸 툴팁 */
|
||||
합계상태?: "ok" | "short" | "error";
|
||||
합계까닭?: string;
|
||||
}
|
||||
|
||||
/** `{열 id: {도번, 면적번}}` — 구조물 열만(글 열 · 없는 열은 키 없음 = 「없음」) */
|
||||
|
||||
@@ -237,20 +237,39 @@ def _total(sums: Any) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
def _sum_state(calc: dict[str, Any]) -> dict[str, Any]:
|
||||
"""합계 칸 색 상태(101-1) — 붉음 `error` > 노랑 `short` > 초록 `ok` + 까닭 글.
|
||||
붉음 = 기준이 안 나온 돈 줄(까닭 있음) · 기준 단가 없는 자재 / 노랑 = 수량 없는 줄 · 자재 /
|
||||
돈 아닌 로직 줄(`결과`)은 판정에서 뺌."""
|
||||
bad = short = 0
|
||||
for one in (*(calc.get("줄") or []), *(calc.get("자재") or [])):
|
||||
if not isinstance(one, dict):
|
||||
continue
|
||||
if one.get("기준", one.get("기준단가")) is None:
|
||||
bad += 1 if one.get("까닭") else 0
|
||||
elif one.get("수량") is None:
|
||||
short += 1
|
||||
why = [f"{t} {n}" for t, n in (("기준 단가 없음", bad), ("수량 없는 줄", short)) if n]
|
||||
return {
|
||||
"합계상태": "error" if bad else "short" if short else "ok",
|
||||
"합계까닭": " · ".join(why) or "모두 반영",
|
||||
}
|
||||
|
||||
|
||||
def _labor_sum(doc: dict[str, Any]) -> dict[str, Any]:
|
||||
"""일위대가 합계 칸(86-3) — 반영합 `계`(0 아님) · 없으면 기준합 `계` + `합계기준` 참 ·
|
||||
담은 로직 0 · 계산값 없음 = 빈 칸 (목록 `labor` 합계와 같은 규칙)."""
|
||||
담은 로직 0 · 계산값 없음 = 빈 칸 (목록 `labor` 합계와 같은 규칙) · `합계상태`(101-1)."""
|
||||
if not doc.get("담은로직"):
|
||||
return {}
|
||||
calc = doc.get("계산값") or {}
|
||||
if total := _total(calc.get("반영합")):
|
||||
return {"일위대가합계": total, "합계기준": False}
|
||||
return {"일위대가합계": total, "합계기준": False, **_sum_state(calc)}
|
||||
base = _total(calc.get("기준합"))
|
||||
return {} if base is None else {"일위대가합계": base, "합계기준": True}
|
||||
return {} if base is None else {"일위대가합계": base, "합계기준": True, **_sum_state(calc)}
|
||||
|
||||
|
||||
def structure_numbers() -> dict[str, dict[str, Any]]:
|
||||
"""`{열 id: {도번, 일위대가번, 면적번, (일위대가합계, 합계기준)}}` — 집계표 줄 · 좌측 줄이 한 번에
|
||||
"""`{열 id: {도번, 일위대가번, 면적번, (일위대가합계, 합계기준, 합계상태, 합계까닭)}}` — 집계표 줄 · 좌측 줄이 한 번에
|
||||
받음(번호 없는 쪽은 빈 글 · 합계는 일위대가 계산값이 있을 때만)."""
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for kind, key, _, _ in PARTS:
|
||||
|
||||
@@ -58,7 +58,6 @@ const TEXT = {
|
||||
noPills: "이 열 일위대가에 단가산출 로직 · 자재가 없어 알약 없음 — 일위대가 상세에서 담음",
|
||||
pillsFailed: "일위대가 알약을 못 받음 — {value}",
|
||||
discardCards: "카드(도면 · 면적산출)에 저장 안 한 고침이 있음 — 버리고 넘어갈까?",
|
||||
baseNote: "(기준)",
|
||||
};
|
||||
|
||||
/** 집계표 줄 열쇠 → 문서 */
|
||||
@@ -74,11 +73,17 @@ const fill = (text: string, value: string): string => text.replace("{value}", va
|
||||
/** `{열 id: {도번, 면적번}}` — 집계표 번호 줄(없는 열은 키 없음 = 「없음」) */
|
||||
export type ColumnNumbers = Record<string, StructureNumbers>;
|
||||
|
||||
/** 집계표 「일위대가 합계」 줄 글(86-3) — 반영합 · 수량이 없으면 기준합 + 「(기준)」 · 계산값 없음 = 빈 글(「—」) */
|
||||
/** 집계표 「일위대가 합계」 줄 글(86-3) — 반영합 · 없으면 기준합 · 계산값 없음 = 빈 글(「—」) · 색은 `laborSumState` */
|
||||
export function laborSumText(numbers: StructureNumbers | undefined): string {
|
||||
if (typeof numbers?.일위대가합계 !== "number") return "";
|
||||
const text = numbers.일위대가합계.toLocaleString("ko-KR", { maximumFractionDigits: 0 });
|
||||
return numbers.합계기준 ? `${text} ${TEXT.baseNote}` : text;
|
||||
return numbers.일위대가합계.toLocaleString("ko-KR", { maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
/** 합계 칸 색 상태 · 툴팁 까닭(101-1) — 계산값 없음 · 담은 것 없음 = 색 없음 */
|
||||
export function laborSumState(
|
||||
numbers: StructureNumbers | undefined,
|
||||
): { state: string; why: string } | undefined {
|
||||
return numbers?.합계상태 ? { state: numbers.합계상태, why: numbers.합계까닭 ?? "" } : undefined;
|
||||
}
|
||||
|
||||
/** 집계표 열 — 머리 글(제목행 종류 · 분류1 · 분류2 · 분류3) · 꼴만 씀 */
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
comboMerge,
|
||||
comboView,
|
||||
createStructurePanel,
|
||||
laborSumState,
|
||||
laborSumText,
|
||||
pillsFor,
|
||||
STRUCTURE_TABLE,
|
||||
@@ -361,6 +362,7 @@ export function createMain(
|
||||
drawingLabel: (colId: string) => struct.numbers()[colId]?.도번 ?? "",
|
||||
areaLabel: (colId: string) => struct.numbers()[colId]?.면적번 ?? "",
|
||||
laborSumLabel: (colId: string) => laborSumText(struct.numbers()[colId]), // 86-3
|
||||
laborSumState: (colId: string) => laborSumState(struct.numbers()[colId]), // 101-1
|
||||
combo: {
|
||||
...(readOnly || !extras.onHeadRename
|
||||
? {}
|
||||
|
||||
@@ -118,6 +118,39 @@ def test_일위대가_합계_줄_번호_칸(tmp_path: Path, monkeypatch: pytest.
|
||||
assert got["d"]["일위대가번"] == "SU-000004" and got["d"]["도번"] == ""
|
||||
|
||||
|
||||
def test_일위대가_합계_상태_넷(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""101-1 — 합계 칸 색: 붉음(기준 안 나온 돈 줄 · 단가 없는 자재) > 노랑(수량 없는 줄 · 자재) >
|
||||
초록(모두 반영) · 돈 아닌 로직 줄은 판정에서 뺌 · 담은 것 없음 = 상태 없음."""
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
monkeypatch.setattr(store, "FOLDER", tmp_path)
|
||||
(tmp_path / "labor").mkdir()
|
||||
money = {"기준": {"계": 100}, "까닭": None}
|
||||
docs = {
|
||||
"ok": {"줄": [{**money, "수량": 2}, {"결과": 3, "기준": None, "까닭": None}], "자재": [
|
||||
{"기준단가": 5, "수량": 1}
|
||||
]},
|
||||
"short": {"줄": [{**money, "수량": 2}, {**money, "수량": None}], "자재": []},
|
||||
"error": {"줄": [{**money, "수량": None}, {"기준": None, "까닭": "식 오류"}], "자재": [
|
||||
{"기준단가": None, "까닭": "단가 없음", "수량": None},
|
||||
{"기준단가": 5, "수량": None},
|
||||
]},
|
||||
} # fmt: skip
|
||||
for name, calc in docs.items():
|
||||
doc = {"담은로직": ["x"], "계산값": {**calc, "반영합": {"계": 7}, "기준합": {"계": 9}}}
|
||||
(tmp_path / "labor" / f"{name}.json").write_text(json.dumps(doc), encoding="utf-8")
|
||||
empty = {"담은로직": [], "계산값": {"줄": [], "반영합": {"계": 7}}}
|
||||
(tmp_path / "labor" / "empty.json").write_text(json.dumps(empty), encoding="utf-8")
|
||||
got = store.structure_numbers()
|
||||
assert (got["ok"]["합계상태"], got["ok"]["합계까닭"]) == ("ok", "모두 반영")
|
||||
assert (got["short"]["합계상태"], got["short"]["합계까닭"]) == ("short", "수량 없는 줄 1")
|
||||
assert (got["error"]["합계상태"], got["error"]["합계까닭"]) == (
|
||||
"error",
|
||||
"기준 단가 없음 2 · 수량 없는 줄 2",
|
||||
)
|
||||
assert "합계상태" not in got["empty"]
|
||||
|
||||
|
||||
def test_일위대가_합계_줄_표_부품과_연결():
|
||||
sheet = _text(SHEET / "ui_template_sheet.ts")
|
||||
assert "laborSumLabel: opts.laborSumLabel," in sheet
|
||||
@@ -131,6 +164,12 @@ def test_일위대가_합계_줄_표_부품과_연결():
|
||||
assert "state.combo && state.laborSumLabel" in render and "masterRow(KEY_SUM)" in render
|
||||
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
|
||||
assert "laborSumLabel: (colId: string) => laborSumText(struct.numbers()[colId])" in main
|
||||
assert "laborSumState: (colId: string) => laborSumState(struct.numbers()[colId])" in main
|
||||
assert "(기준)" not in _text(M02 / "M02_MasterTemplete_Structure.ts") # 101-1 글 지움
|
||||
assert "is-sum-${sum.state}" in render and "td.is-sum-error" in _text(
|
||||
SHEET / "ui_template_sheet.css"
|
||||
)
|
||||
assert "markSum(c)" in sheet # 101-2 머리 고르면 합계 칸도 같이
|
||||
assert "onNumbers: () => sheet?.recalc()" in main # 카드 [저장] 뒤 다시 읽기
|
||||
struct = _text(M02 / "M02_MasterTemplete_Structure.ts")
|
||||
assert "fetchStructureNumbers().then(" in struct and "opts.onNumbers?.()" in struct
|
||||
|
||||
@@ -74,7 +74,7 @@ def test_sub2_가_부르는_이름과_모양():
|
||||
), name
|
||||
assert "fetchStructureNumbers = (): Promise<Record<string, StructureNumbers>>" in body
|
||||
assert re.search(
|
||||
r"interface StructureNumbers \{\s*도번: string;\s*면적번: string;\s*/\*\* 일위대가[^*]*\*/\s*일위대가번: string;(\s*/\*\*[^*]*\*/)?\s*일위대가합계\?: number;\s*합계기준\?: boolean;\s*\}",
|
||||
r"interface StructureNumbers \{\s*도번: string;\s*면적번: string;\s*/\*\* 일위대가[^*]*\*/\s*일위대가번: string;(\s*/\*\*[^*]*\*/)?\s*일위대가합계\?: number;\s*합계기준\?: boolean;(\s*/\*\*[^*]*\*/)?(\s*합계\w+\?: [^;]+;)*\s*\}",
|
||||
body,
|
||||
)
|
||||
assert re.search(r'export type Kind = [^;]*"area" \| "labor"', body) # 재료산출 없앰
|
||||
@@ -93,7 +93,7 @@ def test_집계표만_컨테이너를_붙임():
|
||||
# 구조물 도면 저장은 `도면` · `더한장`(82-2) 칸만 갈아 끼움 — 도번 · 산출근거는 읽은 그대로
|
||||
# 맨 위 연결표는 도면 표에 맞춤(41-3 `toDrawing.withLinks`)
|
||||
assert re.search(
|
||||
r'toDrawing\.withLinks\(\{\s*\.\.\.\(loaded as StructureDoc\),\s*'
|
||||
r"toDrawing\.withLinks\(\{\s*\.\.\.\(loaded as StructureDoc\),\s*"
|
||||
r'\.\.\.\(edited as Pick<StructureDoc, "도면" \| "더한장">\),\s*\}\)',
|
||||
body,
|
||||
)
|
||||
|
||||
@@ -107,6 +107,19 @@
|
||||
background: color-mix(in srgb, var(--color-lavender-wash) 14%, var(--color-canvas));
|
||||
}
|
||||
|
||||
/* 구조물 조합 일위대가 합계 칸(101-1) — 초록 모두 반영 · 노랑 일부 빠짐 · 붉음 기준 단가 안 나옴(설계값·계산 칸보다 진함) */
|
||||
.ui-sheet__table td.is-sum-ok {
|
||||
background: color-mix(in srgb, var(--color-success) 26%, var(--color-canvas));
|
||||
}
|
||||
|
||||
.ui-sheet__table td.is-sum-short {
|
||||
background: color-mix(in srgb, var(--color-warning) 34%, var(--color-canvas));
|
||||
}
|
||||
|
||||
.ui-sheet__table td.is-sum-error {
|
||||
background: color-mix(in srgb, var(--color-danger) 28%, var(--color-canvas));
|
||||
}
|
||||
|
||||
.ui-sheet--project .ui-sheet__table tbody td.is-locked {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
@@ -141,6 +154,43 @@
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* 구조물 조합 — 고른 열의 머리 칸 + 합계 칸을 한 상자로(머리 아래 · 합계 위 변 없음 = 중간선 없음) */
|
||||
.ui-sheet--combo .ui-sheet__table:has(tr.ui-sheet__meta td.is-selected) th.is-head-picked {
|
||||
border-bottom-width: 0;
|
||||
outline: 0;
|
||||
box-shadow:
|
||||
inset 2px 0 0 var(--color-focus-ring),
|
||||
inset -2px 0 0 var(--color-focus-ring),
|
||||
inset 0 2px 0 var(--color-focus-ring);
|
||||
}
|
||||
|
||||
.ui-sheet--combo .ui-sheet__table tr.ui-sheet__meta td.is-selected {
|
||||
outline: 0;
|
||||
box-shadow:
|
||||
inset 2px 0 0 var(--color-focus-ring),
|
||||
inset -2px 0 0 var(--color-focus-ring),
|
||||
inset 0 -2px 0 var(--color-focus-ring);
|
||||
}
|
||||
|
||||
/* 틀고정 칸의 오른쪽 선 그림자(:87-91)와 합침 */
|
||||
.ui-sheet--combo
|
||||
.ui-sheet__table:has(tr.ui-sheet__meta td.is-selected)
|
||||
th.is-head-picked.is-frozen {
|
||||
box-shadow:
|
||||
inset 2px 0 0 var(--color-focus-ring),
|
||||
inset -2px 0 0 var(--color-focus-ring),
|
||||
inset 0 2px 0 var(--color-focus-ring),
|
||||
1px 0 0 var(--color-border);
|
||||
}
|
||||
|
||||
.ui-sheet--combo .ui-sheet__table tr.ui-sheet__meta td.is-selected.is-frozen {
|
||||
box-shadow:
|
||||
inset 2px 0 0 var(--color-focus-ring),
|
||||
inset -2px 0 0 var(--color-focus-ring),
|
||||
inset 0 -2px 0 var(--color-focus-ring),
|
||||
1px 0 0 var(--color-border);
|
||||
}
|
||||
|
||||
.ui-sheet__table tr.is-fixed td {
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
addLevel,
|
||||
deleteColumn,
|
||||
isBlankPick,
|
||||
KEY_SUM,
|
||||
KEY_UNIT,
|
||||
navRows,
|
||||
PICK_ROWS,
|
||||
@@ -61,6 +62,8 @@ export interface SheetOptions {
|
||||
areaLabel?: (colId: string) => string;
|
||||
/** 구조물 조합 표(`combo`) 「일위대가 합계」 줄 칸 글 — 주면 읽기 전용 줄이 생김(86-3). */
|
||||
laborSumLabel?: (colId: string) => string;
|
||||
/** 합계 칸 색 상태 · 툴팁 까닭(101-1) — `state` = ok · short · error · 없으면 색 없음. */
|
||||
laborSumState?: (colId: string) => { state: string; why: string } | undefined;
|
||||
/** 칸을 고름 — 누르기 · 방향키 · Tab. */
|
||||
onSelect?: (sel: SheetSelection) => void;
|
||||
/** 조회 후 선택 — master 일위대가 · 도면 · 면적산출 칸을 두 번 누르기 · Enter · F2 로 부름.
|
||||
@@ -93,6 +96,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
|
||||
drawingLabel: opts.drawingLabel,
|
||||
areaLabel: opts.areaLabel,
|
||||
laborSumLabel: opts.laborSumLabel,
|
||||
laborSumState: opts.laborSumState,
|
||||
pick: !!opts.onPick,
|
||||
headLocked: !!opts.lockHead,
|
||||
combo: !!opts.combo,
|
||||
@@ -186,7 +190,10 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
|
||||
state.result = recalcSheet(state.doc);
|
||||
const { scrollLeft, scrollTop } = scroll;
|
||||
scroll.replaceChildren(renderSheet(state));
|
||||
if (opts.combo && state.sel) deepestHead(state.sel.c)?.classList.add("is-head-picked");
|
||||
if (opts.combo && state.sel) {
|
||||
deepestHead(state.sel.c)?.classList.add("is-head-picked");
|
||||
markSum(state.sel.c);
|
||||
}
|
||||
scroll.scrollLeft = scrollLeft;
|
||||
scroll.scrollTop = scrollTop;
|
||||
syncToolbar();
|
||||
@@ -212,6 +219,10 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
|
||||
}
|
||||
return best;
|
||||
};
|
||||
/** 구조물 조합 — 머리를 고르면 그 열 합계 칸도 늘 같이 고름 표시(101-2) */
|
||||
const markSum = (colId: string): void => {
|
||||
for (const cell of cellsAt(KEY_SUM, colId)) cell.classList.add("is-selected");
|
||||
};
|
||||
const select = (r: string, c: string, reveal = false): void => {
|
||||
for (const cell of scroll.querySelectorAll(".is-selected, .is-head-picked"))
|
||||
cell.classList.remove("is-selected", "is-head-picked");
|
||||
@@ -220,6 +231,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
|
||||
head?.classList.add("is-head-picked");
|
||||
const cells = cellsAt(r, c);
|
||||
for (const cell of cells) cell.classList.add("is-selected");
|
||||
if (opts.combo) markSum(c);
|
||||
if (reveal) (cells[0] ?? head)?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
syncToolbar();
|
||||
opts.onSelect?.(selectionOf(state.doc, r, c));
|
||||
|
||||
@@ -46,6 +46,8 @@ export interface RenderState {
|
||||
areaLabel?: (colId: string) => string;
|
||||
/** 구조물 조합 표 「일위대가 합계」 줄 칸 글 — 주면 combo 표에 읽기 전용 줄이 생김(86-3). */
|
||||
laborSumLabel?: (colId: string) => string;
|
||||
/** 합계 칸 색 상태 · 툴팁 까닭(101-1) */
|
||||
laborSumState?: (colId: string) => { state: string; why: string } | undefined;
|
||||
/** 조회 후 선택 줄(`PICK_ROWS`)을 고름으로 받나 — 칸 겉모양만. */
|
||||
pick?: boolean;
|
||||
/** 머리 · 층 이름을 못 고침(`lockHead`) — 칸 겉모양만. */
|
||||
@@ -194,6 +196,14 @@ export function renderSheet(state: RenderState): HTMLElement {
|
||||
const text = blank ? "" : refText(state, key, col.id); // 마스터 줄 = 모두 번호 글
|
||||
const td = bodyCell(key, col, j, text);
|
||||
td.classList.remove("is-num");
|
||||
if (key === KEY_SUM) {
|
||||
td.classList.remove("is-calc", "is-bound", "is-hand"); // 합계 칸은 열 꼴 색 없음 — 상태 색만
|
||||
const sum = blank ? undefined : state.laborSumState?.(col.id);
|
||||
if (sum) {
|
||||
td.classList.add(`is-sum-${sum.state}`);
|
||||
td.title = sum.why;
|
||||
}
|
||||
}
|
||||
if (state.pick && PICK_ROWS.includes(key) && !blank) td.classList.add("is-pick");
|
||||
tr.append(td);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user