fix(B05): 크로스체크 2차 4건 반영 — 우클릭 추가 실패·미협의 기본값·STALE 통지
교차검증 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, string | number> {
|
||||
const options: Record<string, string | number> = {};
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -472,27 +472,41 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
structureSaving = structureSaving.then(() => persistStructures(next));
|
||||
}
|
||||
|
||||
/** 서버 정본을 다시 받아 화면(사이드 목록·그래프 마크)을 그 상태로 맞춘다. */
|
||||
async function refreshStructuresFromServer(): Promise<boolean> {
|
||||
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<void> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
// 구조물 정본 서클마크 — 배치형태와 무관하게 마크 하나, 고르면 벌룬(+구간 띠).
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user