From fc249c00f678b1b36e21160426966416c525b14b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 28 Sep 2026 09:55:44 +0900 Subject: [PATCH] =?UTF-8?q?feat(m02):=20=EC=A2=8C=EC=B8=A1=20=C2=B7=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=C2=B7=20=ED=8E=B8=EC=A7=91=20=EB=A8=B8?= =?UTF-8?q?=EB=A6=AC=20=EB=B2=88=ED=98=B8=20=EB=A7=A8=20=EC=95=9E=20?= =?UTF-8?q?=E3=80=8CSD010101=20=EB=8F=8C=EA=B8=B0=EC=8A=AD=EB=A7=89?= =?UTF-8?q?=EC=9D=B4=20=E2=80=A6=E3=80=8D=20=C2=B7=20copyStructure=20?= =?UTF-8?q?=C2=B7=20copyBasis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Api_Fetch — copyStructure(열, from) · copyBasis(열, from): Promise · fetchStructureNumbers(): Record<열 id, {도번, 표번}>(브레인 확정 이름 · 모양) · fetchBasisNumbers 뺌 - 좌측 구조물 도면 · 산출근거 줄 = 번호 + 구조물 이름 + 변수값(제목행) · 구조물 열(꼴=수)만 - 설계 컨테이너 카드 머리 = 번호 · 이름 차례 · 글 열 고르면 컨테이너 닫음 - 편집 화면 머리 = 「SD010101 …」 · 「SC010101 …」 - 시험: test_m02_structure_panel(이름 · 모양 · 줄 글) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7 --- .../M02_MasterTemplete_Api_Fetch.ts | 21 ++++- .../M02_MasterTemplete_Structure.ts | 31 ++++--- .../M02_MasterTemplete_UI_Main.ts | 6 +- .../M02_MasterTemplete_UI_Side.ts | 83 +++++++------------ resources/tester/test_m02_structure_panel.py | 40 +++++++-- 5 files changed, 102 insertions(+), 79 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 8ae41196..384a3321 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -114,12 +114,25 @@ export const listStructures = async (): Promise => export const createStructure = (column: string): Promise => call(`/structures/${enc(column)}`, { method: "POST" }); -/** `{열 id: 도번}` — 집계표 도면 줄(없는 열은 키 없음 = 「없음」) */ -export const fetchStructureNumbers = (): Promise> => +/** 한 열의 번호 — 도번 `SD` · 표번 `SC` + 같은 6자리 [종류][분류1][분류2](없는 쪽은 빈 글) */ +export interface StructureNumbers { + 도번: string; + 표번: string; +} + +/** `{열 id: {도번, 표번}}` — 구조물 열만(글 열 · 없는 열은 키 없음 = 「없음」) */ +export const fetchStructureNumbers = (): Promise> => call("/structures/numbers"); -/** `{열 id: 표번}` — 좌측 산출근거 줄(도번과 같은 규칙 · 「표-01」 꼴) */ -export const fetchBasisNumbers = (): Promise> => call("/basis/numbers"); +/** 다른 열(`from`) 구조물 도면 내용을 받는 열(`column`)로 가져옴 — 도번 · 열은 받는 쪽 그대로 */ +export const copyStructure = async (column: string, from: string): Promise => { + await call(`/structures/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" }); +}; + +/** 다른 열(`from`) 산출근거 내용을 받는 열(`column`)로 가져옴 — 표번 · 열은 받는 쪽 그대로 */ +export const copyBasis = async (column: string, from: string): Promise => { + await call(`/basis/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" }); +}; /* --- 프로젝트 층 단추 다섯 --- */ const project = (id: string, tail: string): string => `/projects/${enc(id)}/templates/${tail}`; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index 84b123e5..00ca7466 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -19,6 +19,7 @@ import { fetchStructureNumbers, readTemplate, type StructureDoc, + type StructureNumbers, } from "./M02_MasterTemplete_Api_Fetch"; import { mountDrawingTemplate, @@ -48,15 +49,23 @@ const fill = (text: string, value: string): string => text.replace("{value}", va /** 스프레드시트는 산출근거 카드를 열 때만 받음 */ const loadSpreadsheet = () => import("../A00_Common/spreadsheet/spreadsheet"); -/** 집계표 열 — 머리 글만 씀 */ +/** 집계표 열 — 머리 글(제목행 종류 · 분류1 · 분류2) · 꼴만 씀 */ export interface StructureColumn { id: string; 머리: (string | null)[]; + 꼴?: string | null; } -/** 열 머리 글 — 층을 ` · ` 로 이음(빈 층은 건너뜀 · 다 비면 열 id) */ +/** 구조물 열 = 꼴=수 — NO · 측점 · 비고 같은 글 열은 도면 · 산출근거 · 번호가 없음(서버와 같은 규칙) */ +export const isStructureColumn = (col: StructureColumn): boolean => col.꼴 === "수"; + +/** 구조물 이름 + 변수값 — 제목행 층을 빈칸으로 이음(빈 층은 건너뜀 · 다 비면 열 id) */ export const columnHead = (col: StructureColumn): string => - col.머리.filter((part): part is string => !!part && part.trim() !== "").join(" · ") || col.id; + col.머리.filter((part): part is string => !!part && part.trim() !== "").join(" ") || col.id; + +/** 번호가 맨 앞 — 「SD010101 돌기슭막이 H=1.0」(번호가 없으면 이름만) */ +export const structureLabel = (number: string, col: StructureColumn): string => + [number, columnHead(col)].filter(Boolean).join(" "); export interface StructurePanelOptions { /** 시스템 관리자만 저장함(구조물 도면은 서버가 미리 만들어 둠) */ @@ -80,7 +89,7 @@ export interface StructurePanelHandle { } export function createStructurePanel(opts: StructurePanelOptions): StructurePanelHandle { - let numbers: Record = {}; + let numbers: Record = {}; let column: string | null = null; let columnLabel = ""; let doc: StructureDoc | null = null; @@ -98,7 +107,7 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane className: "m02-struct__card ui-collapsible ui-sidebar-section ui-sidebar-card", children: [ el("h3", { className: "ui-collapsible__title", text: TEXT.drawingInfo }), - el("div", { className: "m02-struct__head", children: [columnName, number, edit] }), + el("div", { className: "m02-struct__head", children: [number, columnName, edit] }), previewHost, ], }); @@ -162,7 +171,7 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane /** 미리보기 · 산출근거 — 문서가 없으면 없음 글(서버가 열마다 미리 만들어 둬 보통은 없음) */ const paintBody = (mine: number): void => { drop(); - number.textContent = doc?.도번 || numbers[column ?? ""] || TEXT.noNumber; + number.textContent = doc?.도번 || numbers[column ?? ""]?.도번 || TEXT.noNumber; edit.hidden = !opts.isAdmin; basisNo.textContent = basisNumber || TEXT.noTableNumber; editBasis.hidden = !opts.isAdmin; @@ -196,7 +205,7 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane column = col.id; columnLabel = columnHead(col); columnName.textContent = columnLabel; - number.textContent = numbers[col.id] ?? TEXT.noNumber; + number.textContent = numbers[col.id]?.도번 || TEXT.noNumber; doc = null; basisDoc = null; basisNumber = ""; @@ -217,9 +226,11 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane paintBody(mine); } + /** 글 열(NO · 측점 · 비고)은 구조물이 아니라 컨테이너를 닫음 */ const show = (colId: string | null, columns: StructureColumn[]): void => { - if (colId === column) return; - void load(colId ? (columns.find((c) => c.id === colId) ?? null) : null); + const col = columns.find((c) => c.id === colId && isStructureColumn(c)) ?? null; + if ((col?.id ?? null) === column) return; + void load(col); }; edit.addEventListener("click", async () => { @@ -238,7 +249,7 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane return { root, - numbers: () => numbers, + numbers: () => Object.fromEntries(Object.entries(numbers).map(([id, n]) => [id, n.도번])), reload: async () => { numbers = await fetchStructureNumbers().catch(() => ({})); }, diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 53f66bbd..75d458a8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -240,13 +240,13 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle { ) : got.문서; if (mine !== seq) return; - // 구조물 도면 · 산출근거는 열 id 로 열리니 머리에 도번 · 표번을 함께 보임 + // 구조물 도면 · 산출근거는 열 id 로 열리니 머리 맨 앞에 도번 · 표번(「SD010101 돌기슭막이 …」) if (next.kind === "structure") { const number = (got.문서 as StructureDoc)?.도번; - if (number) title.textContent = `${number} · ${next.label ?? next.name}`; + if (number) title.textContent = `${number} ${next.label ?? next.name}`; } else if (next.kind === "basis") { const number = (got.문서 as { 표번?: string })?.표번; - if (number) title.textContent = `${number} · ${next.label ?? next.name}`; + if (number) title.textContent = `${number} ${next.label ?? next.name}`; } await mount(shown, next); } catch (error) { diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index b21dceac..c39364c3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -1,8 +1,8 @@ /* ============================================================================= * M02_MasterTemplete_UI_Side.ts * 마스터 템플릿 좌측 패널 — 시스템 양식 목록(표/도면/구조물 도면/산출근거) · 새로 · 본떠 · 지우기. - * 서버 길은 계약 `6_계약.md`. 구조물 도면 · 산출근거 줄은 집계표 열마다 하나씩 — - * 서버가 미리 만들어 둠(도번·표번 항상 있음 · 같은 번호 매기기 규칙). + * 서버 길은 계약 `6_계약.md`. 구조물 도면 · 산출근거 줄은 집계표 구조물 열(꼴=수)마다 하나씩 — + * 서버가 미리 만들어 둠(도번 SD · 표번 SC + 같은 6자리) · 줄 글은 번호가 맨 앞(「SD010101 돌기슭막이 …」). * ========================================================================== */ import { @@ -18,16 +18,22 @@ import { t as L } from "@ui/ui_template_locale"; import { openModal } from "@ui/ui_template_modal"; import { deleteTemplate, - fetchBasisNumbers, fetchStructureNumbers, listTemplates, readTemplate, saveTemplate, StaleError, type Kind, + type StructureNumbers, type TemplateInfo, } from "./M02_MasterTemplete_Api_Fetch"; -import { STRUCTURE_TABLE, columnHead, type StructureColumn } from "./M02_MasterTemplete_Structure"; +import { + STRUCTURE_TABLE, + columnHead, + isStructureColumn, + structureLabel, + type StructureColumn, +} from "./M02_MasterTemplete_Structure"; import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main"; const KINDS: { kind: Kind; label: () => string }[] = [ @@ -83,8 +89,7 @@ export function buildSide(opt: SideOptions): SideHandle { const layer = "system" as const; let items: TemplateInfo[] = []; let columns: StructureColumn[] = []; - let numbers: Record = {}; - let basisNumbers: Record = {}; + let numbers: Record = {}; let seq = 0; let picked: { kind: Kind; name: string } | null = null; @@ -153,41 +158,27 @@ export function buildSide(opt: SideOptions): SideHandle { } } - /** 구조물 도면 줄 — 집계표 열마다 하나(서버가 미리 만들어 둬 도번은 항상 있음) */ - function paintStructure(): void { - structureBody.replaceChildren( + /** 구조물 도면 · 산출근거 줄 — 구조물 열마다 하나 · 「번호 이름 변수값」(번호가 없으면 「없음」) */ + function paintRows(body: HTMLElement, kind: "structure" | "basis"): void { + body.replaceChildren( ...(columns.length ? columns.map((col) => { - const num = numbers[col.id] ?? L("M02_StructureNone"); + const num = + (kind === "structure" ? numbers[col.id]?.도번 : numbers[col.id]?.표번) || + L("M02_StructureNone"); const b = el("button", { - className: `m02-side__item ui-sidebar-row${isPicked("structure", col.id) ? " is-active" : ""}`, - text: `${columnHead(col)} · ${num}`, + className: `m02-side__item ui-sidebar-row${isPicked(kind, col.id) ? " is-active" : ""}`, + text: structureLabel(num, col), attrs: { type: "button" }, }); - b.addEventListener("click", () => void selectColumn(col)); - return b; - }) - : [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]), - ); - } - - /** 산출근거 줄 — 구조물 도면과 같은 열 목록 · 표번은 도번과 같은 규칙(서버가 미리 만들어 둠) */ - function paintBasis(): void { - basisBody.replaceChildren( - ...(columns.length - ? columns.map((col) => { - const num = basisNumbers[col.id] ?? L("M02_StructureNone"); - const b = el("button", { - className: `m02-side__item ui-sidebar-row${isPicked("basis", col.id) ? " is-active" : ""}`, - text: `${columnHead(col)} · ${num}`, - attrs: { type: "button" }, - }); - b.addEventListener("click", () => void selectBasisColumn(col)); + b.addEventListener("click", () => void selectColumn(col, kind)); return b; }) : [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]), ); } + const paintStructure = (): void => paintRows(structureBody, "structure"); + const paintBasis = (): void => paintRows(basisBody, "basis"); function paintButtons(): void { const editable = canEdit(layer, opt.isAdmin); @@ -204,25 +195,11 @@ export function buildSide(opt: SideOptions): SideHandle { paintButtons(); } - async function selectColumn(col: StructureColumn): Promise { - if (!isPicked("structure", col.id) && !(await opt.confirmLeave())) return; + async function selectColumn(col: StructureColumn, kind: "structure" | "basis"): Promise { + if (!isPicked(kind, col.id) && !(await opt.confirmLeave())) return; picked = null; - opt.onOpen({ - layer, - projectId: null, - kind: "structure", - name: col.id, - label: columnHead(col), - }); - paintStructure(); - paintBasis(); - paintButtons(); - } - - async function selectBasisColumn(col: StructureColumn): Promise { - if (!isPicked("basis", col.id) && !(await opt.confirmLeave())) return; - picked = null; - opt.onOpen({ layer, projectId: null, kind: "basis", name: col.id, label: columnHead(col) }); + // 제목은 편집 화면이 문서 번호를 앞에 붙임(「SD010101 돌기슭막이 …」) + opt.onOpen({ layer, projectId: null, kind, name: col.id, label: columnHead(col) }); paintStructure(); paintBasis(); paintButtons(); @@ -230,18 +207,16 @@ export function buildSide(opt: SideOptions): SideHandle { async function refreshStructure(): Promise { try { - const [table, nums, basisNums] = await Promise.all([ + const [table, nums] = await Promise.all([ readTemplate(layer, "table", STRUCTURE_TABLE, null), fetchStructureNumbers(), - fetchBasisNumbers(), ]); - columns = ((table.문서 as { 열?: StructureColumn[] }).열 ?? []) as StructureColumn[]; + const all = ((table.문서 as { 열?: StructureColumn[] }).열 ?? []) as StructureColumn[]; + columns = all.filter(isStructureColumn); numbers = nums; - basisNumbers = basisNums; } catch (error) { columns = []; numbers = {}; - basisNumbers = {}; showToast(L("M02_LoadFailed").replace("{value}", why(error)), "error"); } paintStructure(); diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index 15e8a1e9..b1510126 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -49,8 +49,9 @@ def test_소스가_700줄을_넘지_않음(): ), # 산출근거 저장은 컨테이너가 안 함 — [산출근거 수정] 이 여는 메인 칸 전체 편집이 함 ("M02_MasterTemplete_Api_Fetch.ts", ["saveTemplate"], MAIN), - # 좌측 산출근거 줄 번호는 좌측 패널이 받음(구조물 도면 줄과 같은 자리) - ("M02_MasterTemplete_Api_Fetch.ts", ["fetchBasisNumbers"], SIDE), + # 좌측 구조물 도면 · 산출근거 줄 번호는 한 번에 받음(`{열 id: {도번, 표번}}`) + ("M02_MasterTemplete_Api_Fetch.ts", ["fetchStructureNumbers"], SIDE), + ("M02_MasterTemplete_Structure.ts", ["structureLabel", "isStructureColumn"], SIDE), ("M02_MasterTemplete_Drawing.ts", ["mountDrawingTemplate"], PANEL), ("../A00_Common/spreadsheet/spreadsheet.ts", ["createSpreadsheet"], PANEL), ], @@ -64,6 +65,17 @@ def test_컨테이너가_쓰는_이름이_내보내진_것(source: str, names: l assert name in used, name +def test_sub2_가_부르는_이름과_모양(): + """브레인 확정 이름 · 모양 — sub2 가 이대로 부름(바꾸면 브레인에게).""" + body = _text(M02 / "M02_MasterTemplete_Api_Fetch.ts") + for name in ("copyStructure", "copyBasis"): + assert re.search( + rf"export const {name} = async \(column: string, from: string\): Promise", body + ), name + assert "fetchStructureNumbers = (): Promise>" in body + assert re.search(r"interface StructureNumbers \{\s*도번: string;\s*표번: string;\s*\}", body) + + def test_집계표만_컨테이너를_붙임(): """다른 표 양식에는 안 붙음 — 시스템 층 · 이름이 집계표일 때만.""" body = _text(MAIN) @@ -74,15 +86,21 @@ def test_집계표만_컨테이너를_붙임(): @pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") def test_열_머리_글_잇기(): - """`columnHead` — 빈 층은 건너뛰고 ` · ` 로 이음 · 다 비면 열 id.""" - found = re.search(r"^export const columnHead[\s\S]*?;$", _text(PANEL), re.M) - assert found, "columnHead 를 못 찾음" + """`columnHead` — 빈 층은 건너뛰고 빈칸으로 이음 · 다 비면 열 id · + `structureLabel` — 번호가 맨 앞(「SD010101 돌기슭막이 …」) · 번호 없으면 이름만.""" + funcs = [ + re.search(rf"^export const {name}[\s\S]*?;$", _text(PANEL), re.M) + for name in ("columnHead", "structureLabel") + ] + assert all(funcs), "columnHead · structureLabel 을 못 찾음" # 부품 파일은 화면 모듈을 부르므로 통째로 못 싣음 — 그 함수만 떼어 Node 가 타입을 벗겨 돌림 script = ( "interface StructureColumn { id: string; 머리: (string | null)[] }\n" - + found.group(0).replace("export ", "") + + "\n".join(f.group(0).replace("export ", "") for f in funcs) + "\nconsole.log(JSON.stringify(process.argv.slice(1)" - + ".map((a) => columnHead(JSON.parse(a)))));" + + ".map((a) => columnHead(JSON.parse(a)))" + + ".concat([structureLabel('SD010101', {id: 'rv', 머리: ['돌기슭막이', null, 'H=1.0']})," + + " structureLabel('', {id: 'rv', 머리: ['돌기슭막이', null, null]})])));" ) cases = [ {"id": "pv_l", "머리": ["콘크리트포장", "T=(두께별)", "L"]}, @@ -96,4 +114,10 @@ def test_열_머리_글_잇기(): capture_output=True, text=True, encoding="utf-8", timeout=60, ) # fmt: skip assert result.returncode == 0, result.stderr - assert json.loads(result.stdout) == ["콘크리트포장 · T=(두께별) · L", "NO", "etc"] + assert json.loads(result.stdout) == [ + "콘크리트포장 T=(두께별) L", + "NO", + "etc", + "SD010101 돌기슭막이 H=1.0", + "돌기슭막이", + ]