diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index 3cbf1e06..c629b68a 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -25,6 +25,8 @@ export interface StructureOptionField { choices: string[]; unit: string | null; default: string | number | null; + /** 미확정 항목(기본값 없음) — 사용자가 값을 넣어야 저장된다. */ + required?: boolean; } export interface StructureType { diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index 45b721c1..b306a5aa 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -1,87 +1,938 @@ { "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에 저장하지 않는다.", + "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(사용자 필수 입력).", "types": [ - { "type_id": "pipe", "group": "A", "name": "배관(횡단배수관)", "placement": "point", "managed_by": "pipe_points", "style": { "color": "#4f8ef7", "abbr": "관" }, "drawing_views": ["plan", "profile", "cross_section", "detail", "quantity"], "options": [] }, - - { "type_id": "ditch_side", "group": "B", "name": "측구(옆도랑)", "placement": "interval", "style": { "color": "#2eaadc", "abbr": "측" }, "drawing_views": ["profile", "cross_section", "quantity"], "options": [ - { "key": "form", "label": "형식", "input": "select", "choices": ["일반형(제형)", "L형", "U형", "J형"], "default": "일반형(제형)" }, - { "key": "depth_cm", "label": "깊이", "input": "number", "unit": "cm", "default": 30 } ] }, - { "type_id": "ditch_ridge", "group": "B", "name": "산마루측구", "placement": "interval", "style": { "color": "#2e8bdc", "abbr": "산" }, "drawing_views": ["profile", "cross_section", "quantity"], "options": [ - { "key": "material", "label": "재료", "input": "select", "choices": ["콘크리트", "돌", "떼"], "default": "콘크리트" } ] }, - { "type_id": "ditch_berm", "group": "B", "name": "소단측구", "placement": "interval", "style": { "color": "#2ec5dc", "abbr": "소" }, "drawing_views": ["cross_section", "quantity"], "options": [] }, - { "type_id": "chute", "group": "B", "name": "도수로·산비탈수로", "placement": "interval", "style": { "color": "#1f9bb8", "abbr": "도" }, "drawing_views": ["profile", "cross_section", "quantity"], "options": [ - { "key": "material", "label": "재료", "input": "select", "choices": ["떼", "돌", "콘크리트", "블록판", "벤치플륨관", "반원관"], "default": "콘크리트" }, - { "key": "section", "label": "단면형", "input": "select", "choices": ["반달꼴", "U자형", "사다리꼴"], "default": "U자형" } ] }, - { "type_id": "slope_drain", "group": "B", "name": "절토사면 배수로", "placement": "interval", "style": { "color": "#28b1a1", "abbr": "사" }, "drawing_views": ["cross_section", "quantity"], "options": [ - { "key": "material", "label": "재료", "input": "select", "choices": ["떼", "돌"], "default": "돌" } ] }, - { "type_id": "underdrain", "group": "B", "name": "맹암거", "placement": "interval", "style": { "color": "#1f7fb8", "abbr": "맹" }, "drawing_views": ["profile", "cross_section", "quantity"], "options": [ - { "key": "note", "label": "규격", "input": "text", "default": "" } ] }, - - { "type_id": "retaining_wall", "group": "C", "name": "옹벽(철근콘크리트)", "placement": "interval", "style": { "color": "#e07a2e", "abbr": "옹" }, "drawing_views": ["profile", "cross_section", "detail", "quantity"], "options": [ - { "key": "form", "label": "형식", "input": "select", "choices": ["중력식", "반중력식", "캔틸레버식", "부벽식"], "default": "반중력식" }, - { "key": "height_m", "label": "높이", "input": "number", "unit": "m", "default": 2.0 } ] }, - { "type_id": "masonry_wet", "group": "C", "name": "돌쌓기(찰)", "placement": "interval", "style": { "color": "#d0692a", "abbr": "찰" }, "drawing_views": ["profile", "cross_section", "quantity"], "options": [ - { "key": "height_m", "label": "높이(≤3.0)", "input": "number", "unit": "m", "default": 2.0 }, - { "key": "back_len_cm", "label": "뒷길이", "input": "select", "choices": ["25", "30", "35", "45", "55", "60", "75"], "default": "45" } ] }, - { "type_id": "masonry_dry", "group": "C", "name": "돌쌓기(메)", "placement": "interval", "style": { "color": "#c25d2c", "abbr": "메" }, "drawing_views": ["profile", "cross_section", "quantity"], "options": [ - { "key": "height_m", "label": "높이(≤2.0)", "input": "number", "unit": "m", "default": 1.5 }, - { "key": "back_len_cm", "label": "뒷길이", "input": "select", "choices": ["25", "30", "35", "45", "55", "60", "75"], "default": "45" } ] }, - { "type_id": "soil_guard", "group": "C", "name": "흙막이", "placement": "interval", "style": { "color": "#b8742e", "abbr": "흙" }, "drawing_views": ["profile", "cross_section", "quantity"], "options": [ - { "key": "form", "label": "형식", "input": "select", "choices": ["콘크리트", "돌(찰)", "돌(메)", "블록쌓기", "돌망태", "흙포대", "통나무쌓기"], "default": "콘크리트" }, - { "key": "height_m", "label": "높이", "input": "number", "unit": "m", "default": 1.5 } ] }, - { "type_id": "boulder_masonry", "group": "C", "name": "큰돌쌓기", "placement": "interval", "style": { "color": "#a86a2a", "abbr": "큰" }, "drawing_views": ["profile", "cross_section", "quantity"], "options": [ - { "key": "height_m", "label": "높이", "input": "number", "unit": "m", "default": 1.5 }, - { "key": "stone_cm", "label": "돌규격", "input": "select", "choices": ["40~60", "60~80", "80~100"], "default": "60~80" } ] }, - - { "type_id": "erosion_check", "group": "D", "name": "골막이", "placement": "point", "style": { "color": "#7a52c7", "abbr": "골" }, "drawing_views": ["plan", "profile", "detail", "quantity"], "options": [ - { "key": "form", "label": "형식", "input": "select", "choices": ["돌", "돌망태", "콘크리트", "통나무", "바자", "흙"], "default": "돌" }, - { "key": "length_m", "label": "길이", "input": "number", "unit": "m", "default": 4.5 }, - { "key": "height_m", "label": "높이(≤2.0)", "input": "number", "unit": "m", "default": 2.0 } ] }, - { "type_id": "check_dam_small", "group": "D", "name": "소형사방댐(복합형)", "placement": "point", "style": { "color": "#6b46b8", "abbr": "댐" }, "drawing_views": ["plan", "profile", "detail", "quantity"], "options": [ - { "key": "height_m", "label": "높이", "input": "number", "unit": "m", "default": 2.0 } ] }, - { "type_id": "bed_sill", "group": "D", "name": "바닥막이", "placement": "point", "style": { "color": "#8a5cd0", "abbr": "바" }, "drawing_views": ["profile", "detail", "quantity"], "options": [ - { "key": "height_m", "label": "높이(≤3.0)", "input": "number", "unit": "m", "default": 1.5 }, - { "key": "apron", "label": "물받이", "input": "select", "choices": ["있음", "없음"], "default": "있음" } ] }, - { "type_id": "revetment", "group": "D", "name": "기슭막이", "placement": "interval", "style": { "color": "#9b6bdc", "abbr": "기" }, "drawing_views": ["profile", "cross_section", "quantity"], "options": [ - { "key": "form", "label": "형식", "input": "select", "choices": ["돌쌓기(찰)", "돌쌓기(메)", "돌붙임", "콘크리트", "돌망태", "통나무·목재틀", "바자"], "default": "돌쌓기(찰)" }, - { "key": "height_m", "label": "높이", "input": "number", "unit": "m", "default": 1.5 } ] }, - - { "type_id": "refuge", "group": "E", "name": "대피소", "placement": "interval", "style": { "color": "#2fa84f", "abbr": "대" }, "drawing_views": ["plan", "cross_section", "quantity"], "options": [ - { "key": "width_m", "label": "너비", "input": "number", "unit": "m", "default": 5.0 }, - { "key": "length_m", "label": "유효길이", "input": "number", "unit": "m", "default": 15.0 } ] }, - { "type_id": "work_yard", "group": "E", "name": "정차·작업장", "placement": "interval", "style": { "color": "#268f43", "abbr": "작" }, "drawing_views": ["plan", "cross_section", "quantity"], "options": [ - { "key": "width_m", "label": "너비", "input": "number", "unit": "m", "default": 7.0 }, - { "key": "length_m", "label": "유효길이", "input": "number", "unit": "m", "default": 20.0 } ] }, - { "type_id": "turnaround", "group": "E", "name": "차돌림곳", "placement": "site", "style": { "color": "#37b95a", "abbr": "차" }, "drawing_views": ["plan", "quantity"], "options": [ - { "key": "width_m", "label": "너비", "input": "number", "unit": "m", "default": 10.0 } ] }, - { "type_id": "mirror", "group": "E", "name": "반사경", "placement": "point", "style": { "color": "#43c76b", "abbr": "반" }, "drawing_views": ["plan"], "options": [ - { "key": "facing", "label": "방향", "input": "select", "choices": ["좌", "우", "양면"], "default": "양면" } ] }, - { "type_id": "guardrail", "group": "E", "name": "가드레일·경계석·위험표지", "placement": "interval", "style": { "color": "#1f9e46", "abbr": "가" }, "drawing_views": ["plan", "cross_section", "quantity"], "options": [ - { "key": "kind", "label": "종류", "input": "select", "choices": ["가드레일", "경계석", "위험표지"], "default": "가드레일" } ] }, - { "type_id": "position_sign", "group": "E", "name": "국가지점번호판", "placement": "point", "style": { "color": "#53a832", "abbr": "번" }, "drawing_views": ["plan", "detail"], "options": [] }, - { "type_id": "barrier_gate", "group": "E", "name": "차단기", "placement": "point", "style": { "color": "#3f9e2e", "abbr": "단" }, "drawing_views": ["plan", "detail"], "options": [] }, - { "type_id": "completion_sign", "group": "E", "name": "준공표지판", "placement": "point", "style": { "color": "#66b344", "abbr": "준" }, "drawing_views": ["plan"], "options": [] }, - { "type_id": "spoil_bank", "group": "E", "name": "사토장", "placement": "site", "style": { "color": "#8c9a2e", "abbr": "사" }, "drawing_views": ["plan", "quantity"], "options": [ - { "key": "area_m2", "label": "면적", "input": "number", "unit": "㎡", "default": 500 }, - { "key": "capacity_m3", "label": "용량", "input": "number", "unit": "㎥", "default": 1000 } ] }, - { "type_id": "borrow_pit", "group": "E", "name": "토사채취장(토취장)", "placement": "site", "style": { "color": "#7d8a2a", "abbr": "토" }, "drawing_views": ["plan", "quantity"], "options": [ - { "key": "area_m2", "label": "면적", "input": "number", "unit": "㎡", "default": 500 }, - { "key": "capacity_m3", "label": "용량", "input": "number", "unit": "㎥", "default": 1000 } ] }, - - { "type_id": "wildlife_path", "group": "F", "name": "야생동물 이동통로", "placement": "point", "style": { "color": "#3aa88f", "abbr": "야" }, "drawing_views": ["plan", "cross_section"], "options": [ - { "key": "form", "label": "형식", "input": "select", "choices": ["경사로", "자연형계단"], "default": "경사로" } ] }, - { "type_id": "eco_pond", "group": "F", "name": "생태연못", "placement": "site", "style": { "color": "#2f9e86", "abbr": "연" }, "drawing_views": ["plan", "detail", "quantity"], "options": [ - { "key": "area_m2", "label": "면적(≥100)", "input": "number", "unit": "㎡", "default": 100 } ] }, - { "type_id": "revegetation", "group": "F", "name": "비탈면 녹화", "placement": "interval", "style": { "color": "#4cb84c", "abbr": "녹" }, "drawing_views": ["cross_section", "quantity"], "options": [ - { "key": "method", "label": "공법", "input": "select", "choices": ["씨뿌리기(줄파종)", "초류종자 살포", "새심기", "선떼붙이기", "줄떼", "평떼", "비탈덮기", "녹생토 뿜어붙이기", "조공", "편책", "나무심기"], "default": "초류종자 살포" } ] }, - - { "type_id": "pavement_concrete", "group": "G", "name": "콘크리트 포장", "placement": "interval", "style": { "color": "#8a8f98", "abbr": "포" }, "drawing_views": ["profile", "cross_section", "quantity"], "options": [ - { "key": "thickness_cm", "label": "두께", "input": "number", "unit": "cm", "default": 20 } ] }, - { "type_id": "gravel_surfacing", "group": "G", "name": "쇄석(자갈) 포설", "placement": "interval", "style": { "color": "#9aa0a8", "abbr": "쇄" }, "drawing_views": ["cross_section", "quantity"], "options": [ - { "key": "thickness_cm", "label": "두께", "input": "number", "unit": "cm", "default": 7 } ] }, - - { "type_id": "etc", "group": "호환", "name": "기타", "placement": "point", "style": { "color": "#98a2b3", "abbr": "기" }, "drawing_views": ["profile"], "options": [ - { "key": "name", "label": "이름", "input": "text", "default": "기타 구조물" } ] } + { + "type_id": "pipe", + "group": "A", + "name": "배관(횡단배수관)", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#4f8ef7", + "abbr": "관" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [] + }, + { + "type_id": "ditch_side", + "group": "B", + "name": "측구(옆도랑)", + "placement": "interval", + "style": { + "color": "#2eaadc", + "abbr": "측" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "일반형(제형)", + "L형", + "U형", + "J형" + ], + "default": "일반형(제형)" + }, + { + "key": "depth_cm", + "label": "깊이", + "input": "number", + "unit": "cm", + "default": 30 + } + ] + }, + { + "type_id": "ditch_ridge", + "group": "B", + "name": "산마루측구", + "placement": "interval", + "style": { + "color": "#2e8bdc", + "abbr": "산" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "material", + "label": "재료", + "input": "select", + "choices": [ + "콘크리트", + "돌", + "떼" + ], + "default": "콘크리트" + } + ] + }, + { + "type_id": "ditch_berm", + "group": "B", + "name": "소단측구", + "placement": "interval", + "style": { + "color": "#2ec5dc", + "abbr": "소" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [] + }, + { + "type_id": "chute", + "group": "B", + "name": "도수로·산비탈수로", + "placement": "interval", + "style": { + "color": "#1f9bb8", + "abbr": "도" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "material", + "label": "재료", + "input": "select", + "choices": [ + "떼", + "돌", + "콘크리트", + "블록판", + "벤치플륨관", + "반원관" + ], + "default": "콘크리트" + }, + { + "key": "section", + "label": "단면형", + "input": "select", + "choices": [ + "반달꼴", + "U자형", + "사다리꼴" + ], + "default": "U자형" + } + ] + }, + { + "type_id": "slope_drain", + "group": "B", + "name": "절토사면 배수로", + "placement": "interval", + "style": { + "color": "#28b1a1", + "abbr": "사" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "material", + "label": "재료", + "input": "select", + "choices": [ + "떼", + "돌" + ], + "default": "돌" + } + ] + }, + { + "type_id": "underdrain", + "group": "B", + "name": "맹암거", + "placement": "interval", + "style": { + "color": "#1f7fb8", + "abbr": "맹" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "note", + "label": "규격", + "input": "text", + "default": "" + } + ] + }, + { + "type_id": "retaining_wall", + "group": "C", + "name": "옹벽(철근콘크리트)", + "placement": "interval", + "style": { + "color": "#e07a2e", + "abbr": "옹" + }, + "drawing_views": [ + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "중력식", + "반중력식", + "캔틸레버식", + "부벽식" + ], + "default": "반중력식" + }, + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": null, + "required": true + } + ] + }, + { + "type_id": "masonry_wet", + "group": "C", + "name": "돌쌓기(찰)", + "placement": "interval", + "style": { + "color": "#d0692a", + "abbr": "찰" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이(≤3.0)", + "input": "number", + "unit": "m", + "default": null, + "required": true + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": "45" + } + ] + }, + { + "type_id": "masonry_dry", + "group": "C", + "name": "돌쌓기(메)", + "placement": "interval", + "style": { + "color": "#c25d2c", + "abbr": "메" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이(≤2.0)", + "input": "number", + "unit": "m", + "default": null, + "required": true + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": "45" + } + ] + }, + { + "type_id": "soil_guard", + "group": "C", + "name": "흙막이", + "placement": "interval", + "style": { + "color": "#b8742e", + "abbr": "흙" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "콘크리트", + "돌(찰)", + "돌(메)", + "블록쌓기", + "돌망태", + "흙포대", + "통나무쌓기" + ], + "default": "콘크리트" + }, + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": null, + "required": true + } + ] + }, + { + "type_id": "boulder_masonry", + "group": "C", + "name": "큰돌쌓기", + "placement": "interval", + "style": { + "color": "#a86a2a", + "abbr": "큰" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": null, + "required": true + }, + { + "key": "stone_cm", + "label": "돌규격", + "input": "select", + "choices": [ + "40~60", + "60~80", + "80~100" + ], + "default": "60~80" + } + ] + }, + { + "type_id": "erosion_check", + "group": "D", + "name": "골막이", + "placement": "point", + "style": { + "color": "#7a52c7", + "abbr": "골" + }, + "drawing_views": [ + "plan", + "profile", + "detail", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "돌", + "돌망태", + "콘크리트", + "통나무", + "바자", + "흙" + ], + "default": "돌" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 4.5 + }, + { + "key": "height_m", + "label": "높이(≤2.0)", + "input": "number", + "unit": "m", + "default": 2.0 + } + ] + }, + { + "type_id": "check_dam_small", + "group": "D", + "name": "소형사방댐(복합형)", + "placement": "point", + "style": { + "color": "#6b46b8", + "abbr": "댐" + }, + "drawing_views": [ + "plan", + "profile", + "detail", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": null, + "required": true + } + ] + }, + { + "type_id": "bed_sill", + "group": "D", + "name": "바닥막이", + "placement": "point", + "style": { + "color": "#8a5cd0", + "abbr": "바" + }, + "drawing_views": [ + "profile", + "detail", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이(≤3.0)", + "input": "number", + "unit": "m", + "default": null, + "required": true + }, + { + "key": "apron", + "label": "물받이", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": "있음" + } + ] + }, + { + "type_id": "revetment", + "group": "D", + "name": "기슭막이", + "placement": "interval", + "style": { + "color": "#9b6bdc", + "abbr": "기" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "돌붙임", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], + "default": "돌쌓기(찰)" + }, + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": null, + "required": true + } + ] + }, + { + "type_id": "refuge", + "group": "E", + "name": "대피소", + "placement": "interval", + "style": { + "color": "#2fa84f", + "abbr": "대" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "width_m", + "label": "너비", + "input": "number", + "unit": "m", + "default": 5.0 + }, + { + "key": "length_m", + "label": "유효길이", + "input": "number", + "unit": "m", + "default": 15.0 + } + ] + }, + { + "type_id": "work_yard", + "group": "E", + "name": "정차·작업장", + "placement": "interval", + "style": { + "color": "#268f43", + "abbr": "작" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "width_m", + "label": "너비", + "input": "number", + "unit": "m", + "default": 7.0 + }, + { + "key": "length_m", + "label": "유효길이", + "input": "number", + "unit": "m", + "default": 20.0 + } + ] + }, + { + "type_id": "turnaround", + "group": "E", + "name": "차돌림곳", + "placement": "site", + "style": { + "color": "#37b95a", + "abbr": "차" + }, + "drawing_views": [ + "plan", + "quantity" + ], + "options": [ + { + "key": "width_m", + "label": "너비", + "input": "number", + "unit": "m", + "default": 10.0 + } + ] + }, + { + "type_id": "mirror", + "group": "E", + "name": "반사경", + "placement": "point", + "style": { + "color": "#43c76b", + "abbr": "반" + }, + "drawing_views": [ + "plan" + ], + "options": [ + { + "key": "facing", + "label": "방향", + "input": "select", + "choices": [ + "좌", + "우", + "양면" + ], + "default": "양면" + } + ] + }, + { + "type_id": "guardrail", + "group": "E", + "name": "가드레일·경계석·위험표지", + "placement": "interval", + "style": { + "color": "#1f9e46", + "abbr": "가" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "kind", + "label": "종류", + "input": "select", + "choices": [ + "가드레일", + "경계석", + "위험표지" + ], + "default": "가드레일" + } + ] + }, + { + "type_id": "position_sign", + "group": "E", + "name": "국가지점번호판", + "placement": "point", + "style": { + "color": "#53a832", + "abbr": "번" + }, + "drawing_views": [ + "plan", + "detail" + ], + "options": [] + }, + { + "type_id": "barrier_gate", + "group": "E", + "name": "차단기", + "placement": "point", + "style": { + "color": "#3f9e2e", + "abbr": "단" + }, + "drawing_views": [ + "plan", + "detail" + ], + "options": [] + }, + { + "type_id": "completion_sign", + "group": "E", + "name": "준공표지판", + "placement": "point", + "style": { + "color": "#66b344", + "abbr": "준" + }, + "drawing_views": [ + "plan" + ], + "options": [] + }, + { + "type_id": "spoil_bank", + "group": "E", + "name": "사토장", + "placement": "site", + "style": { + "color": "#8c9a2e", + "abbr": "사" + }, + "drawing_views": [ + "plan", + "quantity" + ], + "options": [ + { + "key": "area_m2", + "label": "면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": true + }, + { + "key": "capacity_m3", + "label": "용량", + "input": "number", + "unit": "㎥", + "default": null, + "required": true + } + ] + }, + { + "type_id": "borrow_pit", + "group": "E", + "name": "토사채취장(토취장)", + "placement": "site", + "style": { + "color": "#7d8a2a", + "abbr": "토" + }, + "drawing_views": [ + "plan", + "quantity" + ], + "options": [ + { + "key": "area_m2", + "label": "면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": true + }, + { + "key": "capacity_m3", + "label": "용량", + "input": "number", + "unit": "㎥", + "default": null, + "required": true + } + ] + }, + { + "type_id": "wildlife_path", + "group": "F", + "name": "야생동물 이동통로", + "placement": "point", + "style": { + "color": "#3aa88f", + "abbr": "야" + }, + "drawing_views": [ + "plan", + "cross_section" + ], + "options": [ + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "경사로", + "자연형계단" + ], + "default": "경사로" + } + ] + }, + { + "type_id": "eco_pond", + "group": "F", + "name": "생태연못", + "placement": "site", + "style": { + "color": "#2f9e86", + "abbr": "연" + }, + "drawing_views": [ + "plan", + "detail", + "quantity" + ], + "options": [ + { + "key": "area_m2", + "label": "면적(≥100)", + "input": "number", + "unit": "㎡", + "default": 100 + } + ] + }, + { + "type_id": "revegetation", + "group": "F", + "name": "비탈면 녹화", + "placement": "interval", + "style": { + "color": "#4cb84c", + "abbr": "녹" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "method", + "label": "공법", + "input": "select", + "choices": [ + "씨뿌리기(줄파종)", + "초류종자 살포", + "새심기", + "선떼붙이기", + "줄떼", + "평떼", + "비탈덮기", + "녹생토 뿜어붙이기", + "조공", + "편책", + "나무심기" + ], + "default": "초류종자 살포" + } + ] + }, + { + "type_id": "pavement_concrete", + "group": "G", + "name": "콘크리트 포장", + "placement": "interval", + "style": { + "color": "#8a8f98", + "abbr": "포" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "thickness_cm", + "label": "두께", + "input": "number", + "unit": "cm", + "default": null, + "required": true + } + ] + }, + { + "type_id": "gravel_surfacing", + "group": "G", + "name": "쇄석(자갈) 포설", + "placement": "interval", + "style": { + "color": "#9aa0a8", + "abbr": "쇄" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "thickness_cm", + "label": "두께", + "input": "number", + "unit": "cm", + "default": null, + "required": true + } + ] + }, + { + "type_id": "etc", + "group": "호환", + "name": "기타", + "placement": "point", + "style": { + "color": "#98a2b3", + "abbr": "기" + }, + "drawing_views": [ + "profile" + ], + "options": [ + { + "key": "name", + "label": "이름", + "input": "text", + "default": "기타 구조물" + } + ] + } ] -} +} \ No newline at end of file diff --git a/B05_Profile/B05_Profile_Structures_Migration.py b/B05_Profile/B05_Profile_Structures_Migration.py index 5c9aee52..ad9baeb8 100644 --- a/B05_Profile/B05_Profile_Structures_Migration.py +++ b/B05_Profile/B05_Profile_Structures_Migration.py @@ -12,6 +12,7 @@ 최소 구간을 임시로 주고, 사용자가 화면에서 종점을 조정한다 — 값을 지어내는 것보다 낫다. """ +import re from typing import Any, Iterable from B05_Profile.B05_Profile_Structures_Schema import StructureInstance, structure_type_map @@ -26,15 +27,50 @@ LEGACY_TYPE_MAP = { # 종점을 모르는 구간형 항목에 주는 임시 길이(m). 화면에서 조정하라는 표시값이다. DEFAULT_INTERVAL_LENGTH_M = 15.0 +# 확정 저장분은 표시 라벨 문자열뿐이다(예: "파형강관 D800", "대피로 2.0m"). +# 라벨 생성 규칙은 B05_Profile_UI_IrregularStations.ts의 structureLabel()이 정본. +_PIPE_LABEL = re.compile(r"^(배관|.*관)\s+D\d+", re.UNICODE) +_ESCAPE_LABEL = re.compile(r"^대피로(?:\s+([\d.]+)\s*m)?", re.UNICODE) + def _is_pipe(entry: dict[str, Any]) -> bool: + label = str(entry.get("structure", "")).strip() return ( entry.get("origin") == "pipe" or entry.get("structureType") == PIPE_STRUCTURE_NAME - or str(entry.get("structure", "")).strip() == PIPE_STRUCTURE_NAME + or label == PIPE_STRUCTURE_NAME + or bool(_PIPE_LABEL.match(label)) ) +def _legacy_type_of(entry: dict[str, Any]) -> str | None: + """기존 종류 이름을 알아낸다 — structureType이 없으면 라벨 문자열에서 판별한다. + + 확정 저장 형식(`{chainage_m, structure}`)에는 structureType이 없다(크로스체크 + 지적 1, 2026-08-16). 라벨은 structureLabel()이 만든 고정 형식이라 되짚을 수 있다. + """ + explicit = entry.get("structureType") + if explicit in LEGACY_TYPE_MAP: + return str(explicit) + label = str(entry.get("structure", "")).strip() + if label == "기성막이": + return "기성막이" + if _ESCAPE_LABEL.match(label): + return "대피로" + return None + + +def _escape_width_of(entry: dict[str, Any]) -> float | None: + """대피로 폭 — 명시 필드가 우선, 없으면 라벨("대피로 2.0m")에서 파싱한다.""" + width = entry.get("escapeWidthM") + if width is not None: + return float(width) + match = _ESCAPE_LABEL.match(str(entry.get("structure", "")).strip()) + if match and match.group(1): + return float(match.group(1)) + return None + + def migrate_irregular_stations(entries: Iterable[dict[str, Any]]) -> list[StructureInstance]: """기존 비정규 측점 목록을 구조물 인스턴스로 옮긴다(같은 입력이면 같은 결과).""" types = structure_type_map() @@ -46,8 +82,8 @@ def migrate_irregular_stations(entries: Iterable[dict[str, Any]]) -> list[Struct continue chainage = float(entry.get("chainage_m", 0.0)) - legacy_type = entry.get("structureType") - type_id = LEGACY_TYPE_MAP.get(legacy_type, "etc") + legacy_type = _legacy_type_of(entry) + type_id = LEGACY_TYPE_MAP.get(legacy_type or "", "etc") definition = types[type_id] key = (type_id, round(chainage, 3)) @@ -75,8 +111,8 @@ def migrate_irregular_stations(entries: Iterable[dict[str, Any]]) -> list[Struct def _options_for(type_id: str, entry: dict[str, Any]) -> dict[str, Any]: if type_id == "refuge": - width = entry.get("escapeWidthM") - return {"width_m": float(width)} if width is not None else {} + width = _escape_width_of(entry) + return {"width_m": width} if width is not None else {} if type_id == "etc": name = entry.get("customName") or entry.get("structure") or "기타 구조물" return {"name": str(name).strip()} diff --git a/B05_Profile/B05_Profile_Structures_Repository.py b/B05_Profile/B05_Profile_Structures_Repository.py index d1a6c8e1..fbfdd62f 100644 --- a/B05_Profile/B05_Profile_Structures_Repository.py +++ b/B05_Profile/B05_Profile_Structures_Repository.py @@ -62,10 +62,16 @@ def save_structures( structures: Iterable[StructureInstance], *, base_revision: int, + max_chainage_m: float | None = None, ) -> int: - """구조물 목록을 정본에 덮어쓰고 새 판번호를 돌려준다.""" + """구조물 목록을 정본에 덮어쓰고 새 판번호를 돌려준다. + + `max_chainage_m`는 노선 총연장(m) — 주어지면 범위 밖 배치를 거절한다. + """ items = list(structures) _validate_types(items) + _validate_unique_ids(items) + _validate_range(items, max_chainage_m) current_revision, _ = load_structures(project_root) if current_revision != base_revision: @@ -119,7 +125,11 @@ def _design_fingerprint(items: Iterable[StructureInstance]) -> set[str]: def _validate_types(items: list[StructureInstance]) -> None: - """레지스트리에 없는 타입, 다른 정본이 관리하는 타입(배관)을 걸러낸다.""" + """레지스트리 대조 검증 — 타입 존재·관리 주체·배치형태·옵션까지 서버가 지킨다. + + 화면만 믿으면 조작된 요청(placement 불일치·음수 제원·미정의 옵션)이 정본에 + 들어간다(2026-08-16 크로스체크 지적 2). 정본에 닿는 마지막 관문은 여기다. + """ types = structure_type_map() for item in items: definition = types.get(item.type_id) @@ -130,3 +140,53 @@ def _validate_types(items: list[StructureInstance]) -> None: f"{definition.name}은(는) {definition.managed_by} 정본이 관리합니다 — " "구조물 목록에 저장할 수 없습니다." ) + if item.placement != definition.placement: + raise ValueError( + f"{definition.name}의 배치형태는 {definition.placement}인데 " + f"{item.placement}로 보냈습니다." + ) + _validate_options(item, definition) + + +def _validate_options(item: StructureInstance, definition) -> None: + allowed = {option.key: option for option in definition.options} + for key, value in item.options.items(): + option = allowed.get(key) + if option is None: + raise ValueError(f"{definition.name}에 정의되지 않은 옵션입니다: {key}") + if option.input == "number": + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{definition.name}의 {option.label}은(는) 숫자여야 합니다.") + if not (value == value and abs(value) != float("inf")) or value < 0: + raise ValueError(f"{definition.name}의 {option.label}은(는) 0 이상이어야 합니다.") + elif option.input == "select" and option.choices and str(value) not in option.choices: + raise ValueError(f"{definition.name}의 {option.label} 값이 선택지에 없습니다: {value}") + for option in definition.options: + if option.required and item.options.get(option.key) in (None, ""): + raise ValueError( + f"{definition.name}의 {option.label}은(는) 필수 입력입니다 — " + "미확정 항목이라 기본값이 없습니다." + ) + + +def _validate_unique_ids(items: list[StructureInstance]) -> None: + seen: set[str] = set() + for item in items: + if not item.structure_id: + continue + if item.structure_id in seen: + raise ValueError(f"구조물 식별자가 중복되었습니다: {item.structure_id}") + seen.add(item.structure_id) + + +def _validate_range(items: list[StructureInstance], max_chainage_m: float | None) -> None: + """노선 연장을 알 때만 범위를 지킨다 — 연장 밖 배치는 도면·수량 어디에도 못 실린다.""" + if max_chainage_m is None: + return + limit = max_chainage_m + 1e-6 + for item in items: + positions = [item.chainage_m, item.start_m, item.end_m] + if any(value is not None and value > limit for value in positions): + raise ValueError( + f"구조물 위치가 노선 연장({max_chainage_m:.1f} m)을 벗어났습니다: {item.type_id}" + ) diff --git a/B05_Profile/B05_Profile_Structures_Router.py b/B05_Profile/B05_Profile_Structures_Router.py index bb1a9830..c93202c8 100644 --- a/B05_Profile/B05_Profile_Structures_Router.py +++ b/B05_Profile/B05_Profile_Structures_Router.py @@ -16,6 +16,7 @@ from fastapi import APIRouter from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B05_Profile.B05_Profile_Repository import get_latest_route from B05_Profile.B05_Profile_Structures_Repository import ( StructureRevisionConflict, load_structures, @@ -56,19 +57,41 @@ async def read_structure_types() -> StructureTypesResponse: async def _project_root(project_id: UUID) -> str | None: + """프로젝트 저장 경로 — 없는 프로젝트는 None (호출부가 404로 답한다). + + `get_project_storage_relative_path`는 없는 프로젝트에서 LookupError를 **던진다** + (None 반환이 아님) — 잡지 않으면 500으로 샌다(2026-08-16 크로스체크 지적 5). + """ pool = get_db_pool() - async with pool.acquire() as connection: - stored_path = await get_project_storage_relative_path(connection, project_id) + try: + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + except LookupError: + return None if not stored_path: return None return str(Path(resolve_stored_project_path(stored_path))) -async def _invalidate_downstream(project_id: UUID) -> None: +async def _route_length(project_id: UUID) -> float | None: + """최신 노선 총연장(m). 노선이 없거나 조회 실패면 None — 범위 검증만 생략된다.""" + try: + pool = get_db_pool() + async with pool.acquire() as connection: + latest = await get_latest_route(connection, project_id) + length = latest.get("total_length_m") if latest else None + return float(length) if length else None + except Exception: + logger.exception("B05 노선 연장 조회 실패: project_id=%s", project_id) + return None + + +async def _invalidate_downstream(project_id: UUID) -> bool: """구조물이 바뀌었으니 B06(stage 3) 이후의 완료 단계를 STALE로 되돌린다. 실패해도 저장은 이미 끝났다 — 무효화를 못 했다고 저장을 되돌리면 정본과 화면이 - 어긋난다. 로그만 남기고 넘어간다. + 어긋난다. 대신 성공 여부를 돌려줘 응답이 사실만 말하게 한다(성공한 척 금지 — + 2026-08-16 크로스체크 지적 5). """ try: pool = get_db_pool() @@ -83,8 +106,10 @@ async def _invalidate_downstream(project_id: UUID) -> None: (str(project_id), ROUTE_STAGE_NO), ) await connection.commit() + return True except Exception: logger.exception("B05 구조물 변경 후속 단계 무효화 실패: project_id=%s", project_id) + return False @router.get("/{project_id}/route/structures", response_model=StructureListResponse) @@ -98,6 +123,9 @@ async def read_structures(project_id: UUID) -> StructureListResponse | JSONRespo return StructureListResponse( project_id=str(project_id), revision=revision, structures=structures ) + except LookupError: + # 저장 경로 조회가 예외로 알려온 "프로젝트 없음" — 500이 아니라 404다. + return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING) except Exception: logger.exception("B05 구조물 조회 실패: project_id=%s", project_id) return JSONResponse( @@ -116,18 +144,25 @@ async def write_structures( if root is None: return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING) _, previous = load_structures(root) - revision = save_structures(root, payload.structures, base_revision=payload.base_revision) + revision = save_structures( + root, + payload.structures, + base_revision=payload.base_revision, + max_chainage_m=await _route_length(project_id), + ) # 설계에 영향을 주는 변경일 때만 B06 이후를 STALE로 돌린다 — 메모만 고쳐도 - # 횡단·수량을 다시 돌리게 만들지 않기 위함이다. - invalidated = requires_downstream_invalidation(previous, payload.structures) - if invalidated: - await _invalidate_downstream(project_id) + # 횡단·수량을 다시 돌리게 만들지 않기 위함이다. 응답 플래그는 실제로 STALE + # 전파가 **성공했을 때만** true(실패를 성공처럼 알리지 않는다). + needs_invalidation = requires_downstream_invalidation(previous, payload.structures) + invalidated = needs_invalidation and await _invalidate_downstream(project_id) return StructureSaveResponse( project_id=str(project_id), revision=revision, count=len(payload.structures), invalidated_downstream=invalidated, ) + except LookupError: + return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING) except StructureRevisionConflict as conflict: return JSONResponse( status_code=409, diff --git a/B05_Profile/B05_Profile_Structures_Schema.py b/B05_Profile/B05_Profile_Structures_Schema.py index aa5e5500..fd77e9c5 100644 --- a/B05_Profile/B05_Profile_Structures_Schema.py +++ b/B05_Profile/B05_Profile_Structures_Schema.py @@ -37,6 +37,9 @@ class StructureOptionField(BaseModel): choices: list[str] = Field(default_factory=list) unit: str | None = None default: Any = None + # 미확정 수치(기본값 없음)는 사용자가 직접 넣어야 저장된다 — 지식DB 원칙: + # 기본값 선정은 사용자 협의 영역, 임의값 자동 저장 금지(2026-08-16 크로스체크 반영). + required: bool = False class StructureType(BaseModel): diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index de5724da..cd078ba4 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -160,7 +160,13 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu let types: StructureType[] = []; let structures: StructureInstance[] = []; let editingId: string | null = null; - let optionInputs: Array<{ key: string; read: () => string | number }> = []; + let optionInputs: Array<{ + key: string; + required: boolean; + input: HTMLInputElement | HTMLSelectElement; + read: () => string | number; + isEmpty: () => boolean; + }> = []; function typeMap(): Map { return new Map(types.map((type) => [type.type_id, type])); @@ -201,6 +207,8 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu } else if (option.input === "number") { input = numberInput("0.1", "0"); input.value = String(preset ?? ""); + // 미확정 항목(기본값 없음)은 비워 두면 저장이 거절된다 — 칸에서 미리 알린다. + if (option.required) (input as HTMLInputElement).placeholder = "필수 입력"; } else { input = document.createElement("input"); (input as HTMLInputElement).type = "text"; @@ -210,7 +218,10 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu optionRow.append(field(label, input)); optionInputs.push({ key: option.key, + required: !!option.required, + input, read: () => (option.input === "number" ? Number(input.value) || 0 : input.value), + isEmpty: () => input.value.trim() === "", }); }); } @@ -303,6 +314,8 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu function readOptions(): Record { const values: Record = {}; optionInputs.forEach((input) => { + // 빈 칸은 아예 넣지 않는다 — 빈 숫자를 0으로 저장하면 "0으로 확정"과 구분이 안 된다. + if (input.isEmpty()) return; values[input.key] = input.read(); }); return values; @@ -330,6 +343,12 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu return; } } + // 필수 옵션(미확정 기본값 없음)이 비어 있으면 추가하지 않는다 — 서버도 거절한다. + const missing = optionInputs.find((entry) => entry.required && entry.isEmpty()); + if (missing) { + missing.input.focus(); + return; + } const base = { type_id: type.type_id,