From 84eb027348f4d45b0bf5039a6ea319577022534b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 16 Aug 2026 23:13:19 +0900 Subject: [PATCH] =?UTF-8?q?fix(B05):=20=ED=81=AC=EB=A1=9C=EC=8A=A4?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=202=EC=B0=A8=204=EA=B1=B4=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=E2=80=94=20=EC=9A=B0=ED=81=B4=EB=A6=AD=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EC=8B=A4=ED=8C=A8=C2=B7=EB=AF=B8=ED=98=91=EC=9D=98?= =?UTF-8?q?=20=EA=B8=B0=EB=B3=B8=EA=B0=92=C2=B7STALE=20=ED=86=B5=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 교차검증 2차에서 재현된 실제 실패를 수정한다. 1. 우클릭 구조물 추가가 대부분 저장 거절되던 문제 - 메뉴가 레지스트리 앞 12개를 그대로 노출해 배관(별도 정본)과 필수 제원이 있는 C군이 섞였고, addAt()이 값 없이 즉시 저장해 서버가 거절했다. 실패 항목은 structure_id 없이 화면에 남아 수정·삭제도 막혔다. - 메뉴는 managed_by 없고 required 옵션 없는 타입만 노출한다. - 필수 입력 타입은 저장 대신 측점을 채운 사이드 폼을 열고 첫 필수 칸에 포커스를 준다. - 저장이 실패하거나 충돌하면 서버 정본을 다시 받아 화면을 되돌린다. 2. 미협의 선택값이 기본값으로 자동 저장되던 문제 - 재료·형식 선택형 18건의 default를 없애고 required로 바꿨다. 화면 select에는 "선택하세요" 빈 항목을 두고, defaultOptions()가 첫 선택지를 대신 채우던 동작을 없앴다. - 남긴 default는 법정 단일값, 사용자 확정값(골막이), 표시용 문자열뿐이다. 3. STALE 갱신 실패를 사용자가 알 수 없던 문제 - 응답에 needs_downstream_invalidation을 추가해 "되돌려야 했는가"와 "되돌렸는가"를 구분한다. 어긋나면 화면이 B06 재실행을 안내한다. 4. 회귀 방지: 레지스트리 정책 테스트 신설(우클릭 목록 구성·기본값 원칙). pytest 49건 통과 · tsc 0 · ruff 통과 · npm run build 성공. Co-Authored-By: Claude Fable 5 --- B05_Profile/B05_Profile_Api_Structures.ts | 7 ++- B05_Profile/B05_Profile_Structure_Types.json | 56 ++++++++++++------- B05_Profile/B05_Profile_Structures_Router.py | 1 + B05_Profile/B05_Profile_Structures_Schema.py | 4 +- B05_Profile/B05_Profile_UI_Page.ts | 34 +++++++---- B05_Profile/B05_Profile_UI_Profile_Panel.ts | 11 ++-- .../B05_Profile_UI_Structures_Panel.ts | 33 ++++++++++- 7 files changed, 107 insertions(+), 39 deletions(-) diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index c629b68a..462cfcd5 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -78,6 +78,9 @@ export interface StructureSaveResponse { project_id: string; revision: number; count: number; + /** 설계 영향 변경이라 B06 이후를 되돌려야 했는가. */ + needs_downstream_invalidation: boolean; + /** 실제로 되돌렸는가. needs와 어긋나면 화면이 사용자에게 알린다. */ invalidated_downstream: boolean; } @@ -163,9 +166,9 @@ export function structureAnchorM(structure: StructureInstance): number { export function defaultOptions(type: StructureType): Record { const options: Record = {}; type.options.forEach((field) => { + // 기본값이 있는 항목만 채운다. 필수 선택지의 첫 항목을 대신 넣어 주면 사용자가 + // 고르지도 않은 재료·형식이 확정값으로 저장된다(2026-08-16 크로스체크 지적 2). if (field.default !== null && field.default !== undefined) options[field.key] = field.default; - else if (field.input === "select" && field.choices.length) - options[field.key] = field.choices[0]; }); return options; } diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index b306a5aa..f7779a40 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 docs/raw/PLAN.md 구조물 리스트(2026-08-16)와 resources/knowledge/technical_info/01_임도 참조. enabled 기본 true(2026-08-16 사용자 확정 — 제외 표기 항목만 false). 배관은 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않는다. 기본값 원칙(2026-08-16 크로스체크 반영): 법정 명시값(별표2 등)·사용자 기확정값만 default 유지, 미확정 수치는 default 없음 + required(사용자 필수 입력).", + "comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 docs/raw/PLAN.md 구조물 리스트(2026-08-16)와 resources/knowledge/technical_info/01_임도 참조. enabled 기본 true(2026-08-16 사용자 확정 — 제외 표기 항목만 false). 배관은 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않는다. ★기본값 원칙(2026-08-16 크로스체크 2차 반영): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (지식DB 원칙: 기본값 선정은 사용자 협의 영역).", "types": [ { "type_id": "pipe", @@ -46,7 +46,8 @@ "U형", "J형" ], - "default": "일반형(제형)" + "default": null, + "required": true }, { "key": "depth_cm", @@ -81,7 +82,8 @@ "돌", "떼" ], - "default": "콘크리트" + "default": null, + "required": true } ] }, @@ -127,7 +129,8 @@ "벤치플륨관", "반원관" ], - "default": "콘크리트" + "default": null, + "required": true }, { "key": "section", @@ -138,7 +141,8 @@ "U자형", "사다리꼴" ], - "default": "U자형" + "default": null, + "required": true } ] }, @@ -164,7 +168,8 @@ "떼", "돌" ], - "default": "돌" + "default": null, + "required": true } ] }, @@ -217,7 +222,8 @@ "캔틸레버식", "부벽식" ], - "default": "반중력식" + "default": null, + "required": true }, { "key": "height_m", @@ -265,7 +271,8 @@ "60", "75" ], - "default": "45" + "default": null, + "required": true } ] }, @@ -305,7 +312,8 @@ "60", "75" ], - "default": "45" + "default": null, + "required": true } ] }, @@ -337,7 +345,8 @@ "흙포대", "통나무쌓기" ], - "default": "콘크리트" + "default": null, + "required": true }, { "key": "height_m", @@ -381,7 +390,8 @@ "60~80", "80~100" ], - "default": "60~80" + "default": null, + "required": true } ] }, @@ -413,7 +423,8 @@ "바자", "흙" ], - "default": "돌" + "default": null, + "required": true }, { "key": "length_m", @@ -488,7 +499,8 @@ "있음", "없음" ], - "default": "있음" + "default": null, + "required": true } ] }, @@ -520,7 +532,8 @@ "통나무·목재틀", "바자" ], - "default": "돌쌓기(찰)" + "default": null, + "required": true }, { "key": "height_m", @@ -552,7 +565,8 @@ "label": "너비", "input": "number", "unit": "m", - "default": 5.0 + "default": null, + "required": true }, { "key": "length_m", @@ -639,7 +653,8 @@ "우", "양면" ], - "default": "양면" + "default": null, + "required": true } ] }, @@ -667,7 +682,8 @@ "경계석", "위험표지" ], - "default": "가드레일" + "default": null, + "required": true } ] }, @@ -801,7 +817,8 @@ "경사로", "자연형계단" ], - "default": "경사로" + "default": null, + "required": true } ] }, @@ -860,7 +877,8 @@ "편책", "나무심기" ], - "default": "초류종자 살포" + "default": null, + "required": true } ] }, diff --git a/B05_Profile/B05_Profile_Structures_Router.py b/B05_Profile/B05_Profile_Structures_Router.py index c93202c8..21c5c826 100644 --- a/B05_Profile/B05_Profile_Structures_Router.py +++ b/B05_Profile/B05_Profile_Structures_Router.py @@ -159,6 +159,7 @@ async def write_structures( project_id=str(project_id), revision=revision, count=len(payload.structures), + needs_downstream_invalidation=needs_invalidation, invalidated_downstream=invalidated, ) except LookupError: diff --git a/B05_Profile/B05_Profile_Structures_Schema.py b/B05_Profile/B05_Profile_Structures_Schema.py index fd77e9c5..62ba1adb 100644 --- a/B05_Profile/B05_Profile_Structures_Schema.py +++ b/B05_Profile/B05_Profile_Structures_Schema.py @@ -151,7 +151,9 @@ class StructureSaveResponse(BaseModel): project_id: str revision: int count: int - # 설계 영향 변경이라 B06 이후 완료 단계를 STALE로 되돌렸는가. + # 설계 영향 변경이라 B06 이후를 되돌려야 했는가 / 실제로 되돌렸는가. + # 둘이 어긋나면(필요했는데 못 했다) 화면이 사용자에게 알린다. + needs_downstream_invalidation: bool = False invalidated_downstream: bool = False diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index 3bc7c1e3..acd5dcbe 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -472,27 +472,41 @@ export async function renderB05Route(root: HTMLElement): Promise { structureSaving = structureSaving.then(() => persistStructures(next)); } + /** 서버 정본을 다시 받아 화면(사이드 목록·그래프 마크)을 그 상태로 맞춘다. */ + async function refreshStructuresFromServer(): Promise { + const stored = await fetchStructures(activeProjectId).catch(() => null); + if (!stored) return false; + structureRevision = stored.revision; + panel.structures.setStructures(stored.structures); + profilePanel.setStructures(stored.structures); + return true; + } + async function persistStructures(next: StructureInstance[]): Promise { if (restoring) return; try { const saved = await saveStructures(activeProjectId, structureRevision, next); structureRevision = saved.revision; // 서버가 새 항목에 식별자를 붙이므로 그 결과로 화면 목록을 맞춘다. - const stored = await fetchStructures(activeProjectId); - structureRevision = stored.revision; - panel.structures.setStructures(stored.structures); - profilePanel.setStructures(stored.structures); + await refreshStructuresFromServer(); + // 구조물이 바뀌면 B06 이후를 다시 돌려야 한다. 그 표시를 서버가 못 남겼다면 + // 화면상 "완료"인 뒤 단계가 옛 구조물로 만든 결과라는 뜻이라 사용자가 알아야 한다. + if (saved.needs_downstream_invalidation && !saved.invalidated_downstream) { + showToast( + "구조물은 저장되었지만 이후 단계(횡단·수량) 재작업 표시에 실패했습니다. " + + "B06을 다시 실행해 주세요.", + "error", + ); + } } catch (error) { if (error instanceof StructureConflictError) { - const stored = await fetchStructures(activeProjectId).catch(() => null); - if (stored) { - structureRevision = stored.revision; - panel.structures.setStructures(stored.structures); - profilePanel.setStructures(stored.structures); - } + await refreshStructuresFromServer(); showToast("다른 창에서 구조물이 먼저 저장되어 최신 내용으로 되돌렸습니다.", "error"); return; } + // 저장이 거절되면 화면에만 남은 항목은 식별자가 없어 고치지도 지우지도 못한다. + // 서버 정본으로 되돌려 화면과 정본을 다시 일치시킨다(2026-08-16 크로스체크 지적 1). + await refreshStructuresFromServer(); showToast(error instanceof Error ? error.message : "구조물 저장에 실패했습니다.", "error"); } } diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index f2346b80..9870b172 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -821,11 +821,12 @@ export function createRouteProfilePanel( onRemove: (station) => callbacks?.onStructureRemove?.(station), onAddPipe: (chainage) => callbacks?.onPipeAdd?.(chainage), onAddStructure: (chainage, type) => callbacks?.onStructureAdd?.(chainage, type), - structureTypes: structureTypes.map((type) => ({ - type_id: type.type_id, - group: type.group, - name: type.name, - })), + // 우클릭 한 번으로 끝나는 타입만 메뉴에 올린다 — 배관은 관 지점 정본 소관이라 + // 위의 [배관 추가]가 따로 맡고, 필수 제원이 있는 타입은 값 없이 넣으면 서버가 + // 거절하므로 사이드 폼에서 값을 받는다(2026-08-16 크로스체크 지적 1). + structureTypes: structureTypes + .filter((type) => !type.managed_by && !type.options.some((option) => option.required)) + .map((type) => ({ type_id: type.type_id, group: type.group, name: type.name })), onAddStructureType: (chainage, typeId) => callbacks?.onStructureTypeAdd?.(chainage, typeId), }); // 구조물 정본 서클마크 — 배치형태와 무관하게 마크 하나, 고르면 벌룬(+구간 띠). diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index cd078ba4..0db3bf85 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -202,8 +202,11 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu const preset = values[option.key] ?? option.default ?? ""; let input: HTMLInputElement | HTMLSelectElement; if (option.input === "select") { - input = select(option.choices.map((choice) => [choice, choice] as [string, string])); - input.value = String(preset || option.choices[0] || ""); + // 필수 선택지는 첫 항목을 슬쩍 고른 것처럼 두지 않는다 — 재료·형식은 설계자가 + // 고르는 값이라, 안 고른 상태를 빈칸으로 드러낸다(2026-08-16 크로스체크 지적 2). + const choices = option.choices.map((choice) => [choice, choice] as [string, string]); + input = select(option.required ? [["", "선택하세요"], ...choices] : choices); + input.value = String(preset || (option.required ? "" : (option.choices[0] ?? ""))); } else if (option.input === "number") { input = numberInput("0.1", "0"); input.value = String(preset ?? ""); @@ -439,8 +442,34 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu }, addAt(chainageM, typeId) { const type = typeMap().get(typeId); + // 배관은 관 지점 정본 소관이라 이 목록에 넣을 수 없다(서버도 거절한다). if (!type || type.managed_by) return; const placement = placementOf(typeId); + + // 사용자가 값을 넣어야 하는 타입(미확정 제원·미협의 선택지)은 바로 저장하면 + // 서버가 거절하고, 화면에는 식별자 없는 유령 항목만 남는다. 그래서 위치만 채운 + // 폼을 열어 사용자가 값을 넣고 [추가]를 누르게 한다(2026-08-16 크로스체크 지적 1). + if (type.options.some((option) => option.required)) { + groupSelect.value = type.group; + syncTypeOptions(typeId); + typeSelect.value = typeId; + editingId = null; + startField.value = String(chainageM); + endField.value = + placement === "interval" ? String(chainageM + DEFAULT_INTERVAL_LENGTH_M) : ""; + sideSelect.value = "center"; + offsetField.value = "0"; + memoField.value = ""; + syncPlacementFields(); + renderOptionFields(); + syncButtons(); + renderList(); + root.scrollIntoView({ block: "nearest" }); + const firstRequired = optionInputs.find((entry) => entry.required); + (firstRequired?.input ?? startField).focus(); + return; + } + structures.push({ structure_id: null, type_id: typeId,