From 91317295f508a9bea09583dbbfe5db75efa62152 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 16:01:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(b08):=20=EB=9D=BC=EC=9D=B4=EB=B8=8C?= =?UTF-8?q?=EB=9F=AC=EB=A6=AC=20=EC=B9=B8=20[STmate=20=EC=97=91=EC=85=80?= =?UTF-8?q?=20=EC=9D=BD=EA=B8=B0]=20=E2=86=92=20=ED=98=B8=ED=91=9C=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=E2=86=92=20[=EB=82=B4=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=9F=AC=EB=A6=AC=EC=97=90=20?= =?UTF-8?q?=EB=84=A3=EA=B8=B0]=20=E2=80=94=20=EC=9D=BD=EC=9D=80=20?= =?UTF-8?q?=ED=98=B8=ED=91=9C=20=EC=88=98=C2=B7=EA=B5=AC=EC=84=B1=20?= =?UTF-8?q?=EC=A4=84=20=EC=88=98=EB=A5=BC=20=EB=B3=B4=EC=9D=B4=EA=B3=A0=20?= =?UTF-8?q?=EB=84=A3=EA=B8=B0=20=EC=A0=84=EC=97=90=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EB=AC=BC=EC=9D=8C(=EB=8D=AE=EC=96=B4=EC=94=80=C2=B7=EC=9B=90?= =?UTF-8?q?=EB=AC=B8=20=EC=8B=9C=EC=A0=90=20=EC=88=98=EB=9F=89=C2=B7?= =?UTF-8?q?=EB=8B=A8=EA=B0=80=20=EC=95=88=20=EA=B0=80=EC=A0=B8=EC=98=B4)?= =?UTF-8?q?=20=C2=B7=20=EB=84=A3=EA=B8=B0=EB=8A=94=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EC=9D=84=20=EB=8B=A4=EC=8B=9C=20=EB=B3=B4=EB=83=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배정 프로젝트 화면: 돌쌓기(찰) 장에서 봉화 엑셀 읽기 → 「호표 45개 · 구성 줄 209줄」 → 제6호표 기슭막이(깬잡석,찰쌓기) H=2.0m 구성 9줄 넣기 → 목록 「개인 · [고정형] …」 · [내 것 지우기]로 되돌림(목록 기본 한 줄) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../B08_Quantity_UI_StructureSheet_Library.ts | 11 ++ .../B08_Quantity_UI_StructureSheet_Stmate.ts | 144 ++++++++++++++++++ .../tester/test_b08_stmate_library_ui.py | 26 ++++ 3 files changed, 181 insertions(+) create mode 100644 B08_Quantity/B08_Quantity_UI_StructureSheet_Stmate.ts create mode 100644 resources/tester/test_b08_stmate_library_ui.py diff --git a/B08_Quantity/B08_Quantity_UI_StructureSheet_Library.ts b/B08_Quantity/B08_Quantity_UI_StructureSheet_Library.ts index 196a177a..bc2a91a0 100644 --- a/B08_Quantity/B08_Quantity_UI_StructureSheet_Library.ts +++ b/B08_Quantity/B08_Quantity_UI_StructureSheet_Library.ts @@ -8,6 +8,7 @@ * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; +import { buildStmatePanel } from "./B08_Quantity_UI_StructureSheet_Stmate"; const TIER_LABELS: Record = { personal: "개인", company: "회사", program: "기본" }; /** 항목 종류 배지 — 양식형 = 제원을 바꾸면 수량이 다시 남 · 고정형 = 박힌 수량(명세 13장). */ @@ -191,5 +192,15 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement { mine.append(save, remove); panel.append(title, scope, load, list, take, mine, status); + // 고정형 항목을 만드는 둘째 길 — STmate 출력 엑셀에서 호표 하나를 뽑아 개인 단에(PLAN 4장). + panel.append( + buildStmatePanel({ + projectId, + typeId, + onSaved: () => { + if (!list.hidden) load.click(); + }, + }), + ); return panel; } diff --git a/B08_Quantity/B08_Quantity_UI_StructureSheet_Stmate.ts b/B08_Quantity/B08_Quantity_UI_StructureSheet_Stmate.ts new file mode 100644 index 00000000..0b1aa968 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_StructureSheet_Stmate.ts @@ -0,0 +1,144 @@ +/* ============================================================================= + * B08_Quantity_UI_StructureSheet_Stmate.ts + * 라이브러리 칸 — [STmate 엑셀에서 뽑기] (PLAN 4장 · 2026-09-14 브레인 판정). + * + * 두 걸음: [읽기] → 호표 수·구성 줄 수·못 읽은 사유를 보임(아무것도 안 씀) → 호표를 고르고 + * [내 라이브러리에 넣기] → 수를 다시 묻고 **파일을 다시 보냄**(서버가 다시 읽어 씀 — 화면은 줄을 안 보냄). + * ⚠ 종류는 지금 보고 있는 장의 종류 — 이름으로 자동으로 안 붙임(판정 Ⓒ). + * ⚠ 모양은 옆 제원 칸(`b08-spec*`) 클래스를 그대로 씀. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; + +interface ReadHopyo { + no: number; + name: string; + spec: string; + unit: string; + rows: number; + percent_rows: number; + contract_rows: number; +} + +interface ReadResult { + project: string; + hopyo: ReadHopyo[]; + counts: { hopyo: number; rows: number }; + problems: string[]; +} + +async function postForm(url: string, form: FormData): Promise { + const response = await fetch(url, { method: "POST", credentials: "include", body: form }); + const payload = (await response.json().catch(() => ({}))) as T & { message?: string }; + if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); + return payload; +} + +export function buildStmatePanel(options: { + projectId: string; + typeId: string; + /** 넣은 뒤 — 목록을 다시 받게 함. */ + onSaved: () => void; +}): HTMLElement { + const { projectId, typeId, onSaved } = options; + const base = `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets/library`; + const box = document.createElement("div"); + box.className = "b08-sheet__actions"; + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".xlsx"; + input.className = "b08-spec__input"; + input.title = "STmate 가 내보낸 내역 엑셀(일위대가표 시트)"; + const read = document.createElement("button"); + read.type = "button"; + read.className = "b08-quantity__tab"; + read.textContent = "STmate 엑셀 읽기"; + const list = document.createElement("select"); + list.className = "b08-spec__input"; + list.hidden = true; + const put = document.createElement("button"); + put.type = "button"; + put.className = "b08-spec__save"; + put.textContent = "내 라이브러리에 넣기"; + put.hidden = true; + const status = document.createElement("p"); + status.className = "b08-spec__scope"; + + let result: ReadResult | null = null; + input.addEventListener("change", () => { + result = null; + list.hidden = put.hidden = true; + status.textContent = ""; + }); + + read.addEventListener("click", () => { + const file = input.files?.[0]; + if (!file) { + status.textContent = "엑셀 파일을 먼저 고르세요"; + return; + } + void (async () => { + read.disabled = true; + status.textContent = "읽는 중…"; + try { + const form = new FormData(); + form.append("file", file); + result = await postForm(`${base}/stmate/read`, form); + list.replaceChildren( + ...result.hopyo.map((h) => { + const option = document.createElement("option"); + option.value = String(h.no); + const extra = h.percent_rows ? ` · 가산 행 ${h.percent_rows}` : ""; + option.textContent = `제${h.no}호표 ${h.name} ${h.spec} / ${h.unit} · 구성 ${h.rows}줄${extra}`; + return option; + }), + ); + list.hidden = put.hidden = result.hopyo.length === 0; + const missed = result.problems.length + ? ` · 못 읽은 것 ${result.problems.length}건 — ${result.problems.slice(0, 3).join(" / ")}` + : ""; + status.textContent = `「${result.project}」 호표 ${result.counts.hopyo}개 · 구성 줄 ${result.counts.rows}줄 읽음${missed}`; + } catch (error) { + status.textContent = error instanceof Error ? error.message : "읽지 못함"; + } finally { + read.disabled = false; + } + })(); + }); + + put.addEventListener("click", () => { + const file = input.files?.[0]; + const picked = result?.hopyo.find((h) => String(h.no) === list.value); + if (!file || !result || !picked) return; + const question = + `읽은 것: 호표 ${result.counts.hopyo}개 · 구성 줄 ${result.counts.rows}줄\n` + + `넣을 것: 「${picked.name} ${picked.spec}」 구성 ${picked.rows}줄 → 이 장 종류로 내 라이브러리\n` + + "· 같은 종류 내 것이 있으면 덮어씀\n· 수량은 원문 시점값(현행 품셈 대조 전) · 단가는 안 가져옴"; + if (!window.confirm(question)) return; + void (async () => { + put.disabled = true; + status.textContent = "넣는 중…"; + try { + const form = new FormData(); + form.append("file", file); + form.append("hopyo_no", String(picked.no)); + form.append("type_id", typeId); + const saved = await postForm<{ code: string; name: string; note: string }>( + `${base}/stmate/save`, + form, + ); + status.textContent = `내 라이브러리에 넣음 — 「${saved.name}」 [고정형] · ${saved.note}`; + onSaved(); + } catch (error) { + status.textContent = error instanceof Error ? error.message : "넣지 못함"; + } finally { + put.disabled = false; + } + })(); + }); + + const wrap = document.createElement("div"); + box.append(read, put); + wrap.append(input, box, list, status); + return wrap; +} diff --git a/resources/tester/test_b08_stmate_library_ui.py b/resources/tester/test_b08_stmate_library_ui.py new file mode 100644 index 00000000..5bed025d --- /dev/null +++ b/resources/tester/test_b08_stmate_library_ui.py @@ -0,0 +1,26 @@ +"""라이브러리 칸의 [STmate 엑셀에서 뽑기] — 읽은 수를 보이고 묻고 넣는 두 걸음(2026-09-14 브레인 판정). + +⚠ 읽기 전에 넣기가 열리면 안 됨 · 넣기 전에 호표 수·구성 줄 수·덮어씀을 묻는 확인이 있어야 함 · + 넣기는 **파일을 다시 보냄**(서버가 다시 읽음 — 화면이 읽은 줄을 보내지 않음). +""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +UI = ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Stmate.ts" +PANEL = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Library.ts").read_text( + encoding="utf-8" +) + + +def test_라이브러리_칸에_뽑기_칸이_붙는다() -> None: + assert "buildStmatePanel(" in PANEL + + +def test_읽고_수를_보이고_묻고_파일을_다시_보내_넣는다() -> None: + ui = UI.read_text(encoding="utf-8") + assert "/stmate/read" in ui and "/stmate/save" in ui + assert 'form.append("file"' in ui and 'form.append("hopyo_no"' in ui + assert "window.confirm(" in ui and "counts.hopyo" in ui and "counts.rows" in ui + assert "put.hidden = true" in ui # 읽기 전에는 넣기 단추가 숨음 + assert "rows:" not in ui.split("/stmate/save")[1][:400] # 줄을 보내지 않음