feat(M02): 구조물 조합 상단 표 「일위대가 합계」 읽기 전용 줄 — 반영합 · 없으면 기준합(기준) · 카드 저장 뒤 다시 읽기(86-3)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
This commit is contained in:
@@ -175,6 +175,9 @@ export interface StructureNumbers {
|
||||
면적번: string;
|
||||
/** 일위대가 `SU-######`(39장) */
|
||||
일위대가번: string;
|
||||
/** 일위대가 합계(86-3) — 반영합 · 없으면 기준합(`합계기준` 참) · 계산값 없으면 칸 없음 */
|
||||
일위대가합계?: number;
|
||||
합계기준?: boolean;
|
||||
}
|
||||
|
||||
/** `{열 id: {도번, 면적번}}` — 구조물 열만(글 열 · 없는 열은 키 없음 = 「없음」) */
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
readTemplate,
|
||||
saveTemplate,
|
||||
type StructureDoc,
|
||||
type StructureNumbers,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
cardShell,
|
||||
@@ -48,7 +47,7 @@ export type EditKind = ItemPart;
|
||||
/** 카드 종류 — 열 문서 셋 + 일위대가(39장 · 보기 갈래 카드 · 고침은 일위대가 상세) */
|
||||
export type CardKind = EditKind | "labor";
|
||||
export type SheetKind = Exclude<EditKind, "structure">;
|
||||
export const PART_FIELD: Record<CardKind, keyof StructureNumbers> = {
|
||||
export const PART_FIELD: Record<CardKind, "도번" | "면적번" | "일위대가번"> = {
|
||||
structure: "도번",
|
||||
labor: "일위대가번",
|
||||
area: "면적번",
|
||||
@@ -100,6 +99,8 @@ export interface ItemCardsOptions {
|
||||
extraCards?: HTMLElement[];
|
||||
/** 카드 가운데 하나라도 저장 안 한 고침이 생김 · 없어짐(PLAN 31-4 · 31-7) */
|
||||
onDirty?: (dirty: boolean) => void;
|
||||
/** 카드 [저장] 이 끝남 — 번호 · 일위대가 합계를 다시 읽는 자리(86-3) */
|
||||
onSaved?: () => void;
|
||||
/** 면적산출 카드를 미리보기에서 고침(31-3) — 없으면 보기만 */
|
||||
sheetEdit?: SheetEditOptions;
|
||||
/** 「가져오기」(39-5 · 43-9b) — 다른 항목의 이 칸을 골라 덮어씀 · 빈 카드도 같음 · 없으면 단추 없음 */
|
||||
@@ -195,7 +196,14 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
|
||||
const no = el("span", { className: "m02-struct__no" });
|
||||
const name = el("span", { className: "m02-struct__title" });
|
||||
const shell = cardShell({ title, body, sub: [no, name] });
|
||||
const saver = saveControl(shell, save, () => opts.onDirty?.(anyDirty()));
|
||||
const saver = saveControl(
|
||||
shell,
|
||||
async () => {
|
||||
await save();
|
||||
opts.onSaved?.();
|
||||
},
|
||||
() => opts.onDirty?.(anyDirty()),
|
||||
);
|
||||
const help = helpButton(shell, title, CARD_HELP[kind]); // 제목 바로 옆(47-1)
|
||||
const edit = createButton({
|
||||
label: TEXT.edit,
|
||||
|
||||
@@ -226,13 +226,39 @@ def _codes() -> dict[str, str]:
|
||||
return codes
|
||||
|
||||
|
||||
def structure_numbers() -> dict[str, dict[str, str]]:
|
||||
"""`{열 id: {도번, 면적번}}` — 집계표 줄 · 좌측 줄이 한 번에 받음(없는 쪽은 빈 글)."""
|
||||
out: dict[str, dict[str, str]] = {}
|
||||
def _total(sums: Any) -> float | None:
|
||||
"""`계산값` 합 칸의 `계` — 수 아닌 값 · 없음 = None."""
|
||||
total = sums.get("계") if isinstance(sums, dict) else None
|
||||
if total is None or isinstance(total, bool):
|
||||
return None
|
||||
try:
|
||||
return float(total)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _labor_sum(doc: dict[str, Any]) -> dict[str, Any]:
|
||||
"""일위대가 합계 칸(86-3) — 반영합 `계`(0 아님) · 없으면 기준합 `계` + `합계기준` 참 ·
|
||||
담은 로직 0 · 계산값 없음 = 빈 칸 (목록 `labor` 합계와 같은 규칙)."""
|
||||
if not doc.get("담은로직"):
|
||||
return {}
|
||||
calc = doc.get("계산값") or {}
|
||||
if total := _total(calc.get("반영합")):
|
||||
return {"일위대가합계": total, "합계기준": False}
|
||||
base = _total(calc.get("기준합"))
|
||||
return {} if base is None else {"일위대가합계": base, "합계기준": True}
|
||||
|
||||
|
||||
def structure_numbers() -> dict[str, dict[str, Any]]:
|
||||
"""`{열 id: {도번, 일위대가번, 면적번, (일위대가합계, 합계기준)}}` — 집계표 줄 · 좌측 줄이 한 번에
|
||||
받음(번호 없는 쪽은 빈 글 · 합계는 일위대가 계산값이 있을 때만)."""
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for kind, key, _, _ in PARTS:
|
||||
for name, doc in _docs(kind):
|
||||
row = out.setdefault(name, {k: "" for _, k, _, _ in PARTS})
|
||||
row[key] = str(doc.get(key) or "")
|
||||
if kind == "labor":
|
||||
row.update(_labor_sum(doc))
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ const TEXT = {
|
||||
noPills: "이 열 일위대가에 단가산출 로직 · 자재가 없어 알약 없음 — 일위대가 상세에서 담음",
|
||||
pillsFailed: "일위대가 알약을 못 받음 — {value}",
|
||||
discardCards: "카드(도면 · 면적산출)에 저장 안 한 고침이 있음 — 버리고 넘어갈까?",
|
||||
baseNote: "(기준)",
|
||||
};
|
||||
|
||||
/** 집계표 줄 열쇠 → 문서 */
|
||||
@@ -73,6 +74,13 @@ const fill = (text: string, value: string): string => text.replace("{value}", va
|
||||
/** `{열 id: {도번, 면적번}}` — 집계표 번호 줄(없는 열은 키 없음 = 「없음」) */
|
||||
export type ColumnNumbers = Record<string, StructureNumbers>;
|
||||
|
||||
/** 집계표 「일위대가 합계」 줄 글(86-3) — 반영합 · 수량이 없으면 기준합 + 「(기준)」 · 계산값 없음 = 빈 글(「—」) */
|
||||
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;
|
||||
}
|
||||
|
||||
/** 집계표 열 — 머리 글(제목행 종류 · 분류1 · 분류2 · 분류3) · 꼴만 씀 */
|
||||
export interface StructureColumn {
|
||||
id: string;
|
||||
@@ -183,6 +191,8 @@ export interface StructurePanelOptions {
|
||||
/** [도면 수정] · [면적산출 수정] — 그 열을 CAD · 스프레드시트 전체 편집으로 엶
|
||||
* (페이지의 `open`) · label 은 화면 제목에 씀 */
|
||||
onEdit: (column: string, label: string, kind: CardKind) => void;
|
||||
/** 번호 · 일위대가 합계를 다시 읽음(카드 [저장] 뒤 · 86-3) — 집계표 줄 글을 새로 그림 */
|
||||
onNumbers?: () => void;
|
||||
/** 집계표에 저장 안 한 고침이 있으면 버릴지 물음 — 취소면 false */
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
/** 카드 고침을 버리지 않고 머묾 — 표의 고른 열을 되돌림(31-3) */
|
||||
@@ -221,6 +231,15 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
|
||||
editable: () => opts.isAdmin,
|
||||
onEdit: (kind) => void openEdit(kind),
|
||||
numberOf: (kind, id) => numbers[id]?.[PART_FIELD[kind]],
|
||||
// 카드 [저장] 뒤 — 서버가 일위대가 계산값을 다시 적었을 수 있어 번호 · 합계를 다시 읽음(86-3)
|
||||
onSaved: () =>
|
||||
void fetchStructureNumbers().then(
|
||||
(got) => {
|
||||
numbers = got;
|
||||
opts.onNumbers?.();
|
||||
},
|
||||
() => undefined,
|
||||
),
|
||||
// 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」
|
||||
warnOnFail: (id) => !!numbers[id],
|
||||
// 「복사해 가져오기」 · 빈 카드 [복사 생성](39-5) — 집계표 줄 「조회 후 선택」 과 같은 길
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
comboMerge,
|
||||
comboView,
|
||||
createStructurePanel,
|
||||
laborSumText,
|
||||
pillsFor,
|
||||
STRUCTURE_TABLE,
|
||||
type StructureColumn,
|
||||
@@ -262,6 +263,7 @@ export function createMain(
|
||||
isAdmin,
|
||||
confirmLeave,
|
||||
keepColumn: (colId) => sheet?.selectColumn(colId), // 카드 고침을 안 버림 — 고른 열 되돌림
|
||||
onNumbers: () => sheet?.recalc(), // 카드 [저장] 뒤 번호 · 일위대가 합계 줄을 새로(86-3)
|
||||
// 같은 페이지에서 그 열을 CAD·스프레드시트 전체 편집으로 엶
|
||||
onEdit: (column, label, kind) => {
|
||||
void open({ layer, projectId: null, kind, name: column, label });
|
||||
@@ -357,6 +359,7 @@ export function createMain(
|
||||
laborLabel: (colId: string) => struct.numbers()[colId]?.일위대가번 ?? "",
|
||||
drawingLabel: (colId: string) => struct.numbers()[colId]?.도번 ?? "",
|
||||
areaLabel: (colId: string) => struct.numbers()[colId]?.면적번 ?? "",
|
||||
laborSumLabel: (colId: string) => laborSumText(struct.numbers()[colId]), // 86-3
|
||||
combo: {
|
||||
...(readOnly || !extras.onHeadRename
|
||||
? {}
|
||||
|
||||
@@ -90,3 +90,49 @@ def test_머리_고침은_쓸_수_있는_층만_나무로():
|
||||
assert page.index("...(canEdit(layer, isAdmin)\n ? {\n onHeadRename") > 0
|
||||
tree = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
|
||||
assert "if (opt.canEdit && (await ready())) await edit(body, colId);" in tree
|
||||
|
||||
|
||||
def test_일위대가_합계_줄_번호_칸(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""86-3 — `structure_numbers` 의 일위대가 합계: 반영합 `계` · 없으면 기준합 `계` + 합계기준 ·
|
||||
담은 로직 0 · 계산값 없음 = 칸 없음(목록 `labor` 합계와 같은 규칙)."""
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
monkeypatch.setattr(store, "FOLDER", tmp_path)
|
||||
(tmp_path / "labor").mkdir()
|
||||
docs = {
|
||||
"a": {"일위대가번": "SU-000001", "담은로직": ["x"], "계산값": {"반영합": {"계": 1500.4}}},
|
||||
"b": {
|
||||
"일위대가번": "SU-000002",
|
||||
"담은로직": ["x"],
|
||||
"계산값": {"반영합": {"계": 0}, "기준합": {"계": 900}},
|
||||
},
|
||||
"c": {"일위대가번": "SU-000003", "담은로직": [], "계산값": {"반영합": {"계": 5}}},
|
||||
"d": {"일위대가번": "SU-000004", "담은로직": ["x"], "계산값": {}},
|
||||
}
|
||||
for name, doc in docs.items():
|
||||
(tmp_path / "labor" / f"{name}.json").write_text(json.dumps(doc), encoding="utf-8")
|
||||
got = store.structure_numbers()
|
||||
assert (got["a"]["일위대가합계"], got["a"]["합계기준"]) == (1500.4, False)
|
||||
assert (got["b"]["일위대가합계"], got["b"]["합계기준"]) == (900.0, True)
|
||||
assert "일위대가합계" not in got["c"] and "일위대가합계" not in got["d"]
|
||||
assert got["d"]["일위대가번"] == "SU-000004" and got["d"]["도번"] == ""
|
||||
|
||||
|
||||
def test_일위대가_합계_줄_표_부품과_연결():
|
||||
sheet = _text(SHEET / "ui_template_sheet.ts")
|
||||
assert "laborSumLabel: opts.laborSumLabel," in sheet
|
||||
ops = _text(SHEET / "ui_template_sheet_ops.ts")
|
||||
assert 'export const KEY_SUM = "s:laborsum";' in ops
|
||||
assert "[KEY_PRICE, KEY_DRAWING, KEY_AREA, KEY_SUM]" in ops # 읽기만 — 고치기 · 쓰기 막힘
|
||||
assert "KEY_SUM" not in re.search(r"MASTER_ROWS = \[[^\]]*\]", ops).group(
|
||||
0
|
||||
) # 방향키 · 고르기 밖
|
||||
render = _text(SHEET / "ui_template_sheet_render.ts")
|
||||
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 "onNumbers: () => sheet?.recalc()" in main # 카드 [저장] 뒤 다시 읽기
|
||||
struct = _text(M02 / "M02_MasterTemplete_Structure.ts")
|
||||
assert "fetchStructureNumbers().then(" in struct and "opts.onNumbers?.()" in struct
|
||||
cards = _text(M02 / "M02_MasterTemplete_ItemCards.ts")
|
||||
assert "opts.onSaved?.();" in cards
|
||||
|
||||
@@ -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*\}",
|
||||
r"interface StructureNumbers \{\s*도번: string;\s*면적번: string;\s*/\*\* 일위대가[^*]*\*/\s*일위대가번: string;(\s*/\*\*[^*]*\*/)?\s*일위대가합계\?: number;\s*합계기준\?: boolean;\s*\}",
|
||||
body,
|
||||
)
|
||||
assert re.search(r'export type Kind = [^;]*"area" \| "labor"', body) # 재료산출 없앰
|
||||
|
||||
@@ -76,7 +76,7 @@ def test_text_column_pick_rows_blank():
|
||||
def test_labor_row_shows_su_number():
|
||||
"""마스터 일위대가 줄 = 그 열 일위대가번(SU) — 열의 옛 `일위대가` 칸(UA 키)은 안 읽음."""
|
||||
ops = OPS.read_text(encoding="utf-8")
|
||||
assert "REF_ROWS: readonly string[] = [KEY_PRICE, KEY_DRAWING, KEY_AREA]" in ops
|
||||
assert "REF_ROWS: readonly string[] = [KEY_PRICE, KEY_DRAWING, KEY_AREA, KEY_SUM]" in ops
|
||||
render = (OPS.parent / "ui_template_sheet_render.ts").read_text(encoding="utf-8")
|
||||
assert re.search(r"key === KEY_PRICE\s*\?\s*state\.laborLabel", render)
|
||||
assert "col.일위대가" not in render
|
||||
|
||||
@@ -59,6 +59,8 @@ export interface SheetOptions {
|
||||
drawingLabel?: (colId: string) => string;
|
||||
/** 면적산출 줄 칸 글(번호) — `drawingLabel` 과 같음. */
|
||||
areaLabel?: (colId: string) => string;
|
||||
/** 구조물 조합 표(`combo`) 「일위대가 합계」 줄 칸 글 — 주면 읽기 전용 줄이 생김(86-3). */
|
||||
laborSumLabel?: (colId: string) => string;
|
||||
/** 칸을 고름 — 누르기 · 방향키 · Tab. */
|
||||
onSelect?: (sel: SheetSelection) => void;
|
||||
/** 조회 후 선택 — master 일위대가 · 도면 · 면적산출 칸을 두 번 누르기 · Enter · F2 로 부름.
|
||||
@@ -90,6 +92,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
|
||||
laborLabel: opts.laborLabel,
|
||||
drawingLabel: opts.drawingLabel,
|
||||
areaLabel: opts.areaLabel,
|
||||
laborSumLabel: opts.laborSumLabel,
|
||||
pick: !!opts.onPick,
|
||||
headLocked: !!opts.lockHead,
|
||||
combo: !!opts.combo,
|
||||
|
||||
@@ -22,11 +22,13 @@ export const KEY_UNIT = "s:unit";
|
||||
export const KEY_PRICE = "s:price";
|
||||
export const KEY_DRAWING = "s:drawing";
|
||||
export const KEY_AREA = "s:area";
|
||||
/** 구조물 조합 표 읽기 전용 줄 「일위대가 합계」(86-3) — 마스터 줄 차례 · 조회 후 선택에는 안 들어감. */
|
||||
export const KEY_SUM = "s:laborsum";
|
||||
/** 마스터 전용 줄(첫 쪽 단위 줄 아래 차례) — 일위대가 · 도면 · 면적산출 줄은 손으로 못 침(번호 글). */
|
||||
export const MASTER_ROWS = [KEY_PRICE, KEY_DRAWING, KEY_AREA] as const;
|
||||
export type MasterRowKey = (typeof MASTER_ROWS)[number];
|
||||
/** 번호 글 줄 — 부른 쪽이 준 글(`refLabel`)만 보임(일위대가 = SU · 열의 옛 `일위대가` 칸은 안 읽음). */
|
||||
export const REF_ROWS: readonly string[] = [KEY_PRICE, KEY_DRAWING, KEY_AREA];
|
||||
export const REF_ROWS: readonly string[] = [KEY_PRICE, KEY_DRAWING, KEY_AREA, KEY_SUM];
|
||||
/** 조회 후 선택 줄 — `onPick` 을 주면 이 줄 칸은 치지 않고 고름. */
|
||||
export const PICK_ROWS: readonly string[] = MASTER_ROWS;
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
isBlankPick,
|
||||
isCalcColumn,
|
||||
KEY_AREA,
|
||||
KEY_SUM,
|
||||
KEY_PRICE,
|
||||
KEY_UNIT,
|
||||
MASTER_ROWS,
|
||||
@@ -43,6 +44,8 @@ export interface RenderState {
|
||||
laborLabel?: (colId: string) => string;
|
||||
drawingLabel?: (colId: string) => string;
|
||||
areaLabel?: (colId: string) => string;
|
||||
/** 구조물 조합 표 「일위대가 합계」 줄 칸 글 — 주면 combo 표에 읽기 전용 줄이 생김(86-3). */
|
||||
laborSumLabel?: (colId: string) => string;
|
||||
/** 조회 후 선택 줄(`PICK_ROWS`)을 고름으로 받나 — 칸 겉모양만. */
|
||||
pick?: boolean;
|
||||
/** 머리 · 층 이름을 못 고침(`lockHead`) — 칸 겉모양만. */
|
||||
@@ -55,11 +58,13 @@ export interface RenderState {
|
||||
|
||||
/** 일위대가 · 도면 · 면적산출 줄 칸 글(번호). */
|
||||
export const refText = (state: RenderState, key: string, colId: string): string =>
|
||||
(key === KEY_AREA
|
||||
? state.areaLabel
|
||||
: key === KEY_PRICE
|
||||
? state.laborLabel
|
||||
: state.drawingLabel)?.(colId)?.trim() || st("Drawing_None");
|
||||
key === KEY_SUM
|
||||
? state.laborSumLabel?.(colId)?.trim() || "—"
|
||||
: (key === KEY_AREA
|
||||
? state.areaLabel
|
||||
: key === KEY_PRICE
|
||||
? state.laborLabel
|
||||
: state.drawingLabel)?.(colId)?.trim() || st("Drawing_None");
|
||||
|
||||
/** 열 폭 — 저장 폭(없으면 기본) · 머리 글이 안 잘리는 최소 폭보다 좁지 않게. */
|
||||
const columnWidth = (doc: SheetDoc, col: SheetColumn, mins: Map<string, number>): number =>
|
||||
@@ -173,17 +178,18 @@ export function renderSheet(state: RenderState): HTMLElement {
|
||||
return tr;
|
||||
};
|
||||
|
||||
const masterRow = (key: MasterRowKey): HTMLElement => {
|
||||
const masterRow = (key: MasterRowKey | typeof KEY_SUM): HTMLElement => {
|
||||
const label = (
|
||||
{
|
||||
"s:price": "Row_Unit_Price",
|
||||
"s:drawing": "Row_Drawing",
|
||||
"s:area": "Row_Area",
|
||||
"s:laborsum": "Row_LaborSum",
|
||||
} as const
|
||||
)[key];
|
||||
const tr = el("tr", { className: "ui-sheet__meta", children: [gutter("th", st(label))] });
|
||||
cols.forEach((col, j) => {
|
||||
const blank = isBlankPick(key, col);
|
||||
const blank = key === KEY_SUM ? col.꼴 === "글" : isBlankPick(key, col);
|
||||
const text = blank ? "" : refText(state, key, col.id); // 마스터 줄 = 모두 번호 글
|
||||
const td = bodyCell(key, col, j, text);
|
||||
td.classList.remove("is-num");
|
||||
@@ -264,6 +270,9 @@ ${formulaLabel(formula, names)}`
|
||||
children: [
|
||||
// 구조물 조합 = 일위대가 · 도면 · 면적산출 키 줄 안 보임(보기만 · 번호는 장부 · 카드에)
|
||||
...(mode === "master" && p === 0 && !state.combo ? MASTER_ROWS.map(masterRow) : []),
|
||||
...(mode === "master" && p === 0 && state.combo && state.laborSumLabel
|
||||
? [masterRow(KEY_SUM)]
|
||||
: []), // 구조물 조합 = 읽기 전용 합계 줄 하나(86-3)
|
||||
...page.rows.map((row, i) => dataRow(row, page.first + i)),
|
||||
...(page.totals ? totalRows() : []),
|
||||
],
|
||||
|
||||
@@ -11,6 +11,7 @@ const TEXT = {
|
||||
Col_New: ["새 열", "New column"],
|
||||
Row_Unit: ["단위", "Unit"],
|
||||
Row_Unit_Price: ["일위대가", "Unit price"],
|
||||
Row_LaborSum: ["일위대가 합계", "Unit price total"],
|
||||
Row_Drawing: ["도면", "Drawing"],
|
||||
Row_Area: ["면적산출", "Area"],
|
||||
Add_Suffix: [" 추가", " +"],
|
||||
|
||||
Reference in New Issue
Block a user