feat(구조물): 측구는 「횡단 설계에서 관리」로 표시하고, B군 연장(m) 집계를 만듦 (계획서 3-6)
사용자 결정 두 가지를 반영함(2026-09-07). ① 측구(옆도랑) — 목록에 두되 표시만. 레지스트리에 `design_owner` 를 새로 두고 측구에 「횡단 설계」를 적음. 구조물 배치 폼에서 그 종류를 고르면 「횡단 설계에서 관리 — 여기서 넣어도 제원·수량은 그쪽 값을 씁니다.」가 뜸. `managed_by` 와 달리 저장은 그대로 되므로 배치는 계속 가능함. 까닭 — 횡단 설계가 측구 켬/끔·형식·터파기 단면적(`ditch_area_m2`)을 파이썬·TS 짝으로 이미 셈함. 구조물로 또 세면 같은 것을 두 번 계상함. 목록 항목에 안 붙이고 폼에 붙인 것은 「한 항목 = [측점][이름]만」이 2026-08-18 사용자 지시이기 때문임. ② B군 수량 1단계 — 구조물 정본에서 시설별 연장(m)을 냄. `common_util_structure_lengths.py` 하나. B군 수량 단위가 전부 m 이라 단면 기하가 필요 없음(맹암거 12-10 · L형 측구 12-9-1 · 산마루측구 12-9-2). 수량서 양식에 안 묶이므로 B08 을 실무 xlsx 양식으로 다시 짜도 그대로 씀. 빼는 것 셋 — 관 정본 소관(`managed_by`), 횡단 소관(`design_owner`), 그리고 소단측구 (놓일 소단이 아직 없음 — 계획서 3-9 뒤에 채움). 겹친 구간은 합쳐서 셈함. 같은 시설을 겹치게 두 번 넣으면 단순 합이 그 구간을 두 번 세기 때문임. 원래 합(`raw_length_m`)도 함께 내보내 겹침이 숨지 않게 함. 자체검증 - 새 시험 7건 `tmp/tests/test_structure_lengths.py` — 종류별 합산 / 겹침 제거(80m 인데 단순 합 110m) / 측구 제외 / 소단측구 제외 / 배관 제외 / 구간 없는 항목은 스키마가 막음 / 빈 프로젝트. - 화면 확인(8001·5174, `/api/health` `stale:false`) — 구조물군 B → 종류 측구 선택 시 안내가 254×44px 로 뜨고 문구가 맞음. 리셋으로 폼 되돌림. - 전체 521 passed · 18 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,9 @@ export interface StructureType {
|
||||
drawing_views: string[];
|
||||
/** 다른 정본이 관리하는 타입(배관 = pipe_points.json) — 구조물 목록에 넣지 않는다. */
|
||||
managed_by: string | null;
|
||||
/** 목록에는 두되 **제원·수량을 내는 주인이 다른 화면**인 타입 — 그 화면 이름.
|
||||
* 측구(옆도랑) = `"횡단 설계"`. 항목에 「~에서 관리」를 붙이고 수량 집계는 건너뛴다. */
|
||||
design_owner: string | null;
|
||||
reference_only: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
@@ -212,7 +215,6 @@ export function pipesToStructureMarks(
|
||||
})) as StructureInstance[];
|
||||
}
|
||||
|
||||
|
||||
export function structureAnchorM(structure: StructureInstance): number {
|
||||
return structure.chainage_m ?? structure.start_m ?? 0;
|
||||
}
|
||||
|
||||
@@ -448,6 +448,7 @@
|
||||
"enabled": false,
|
||||
"group": "B",
|
||||
"name": "측구(옆도랑)",
|
||||
"design_owner": "횡단 설계",
|
||||
"placement": "interval",
|
||||
"style": {
|
||||
"color": "#2eaadc",
|
||||
|
||||
@@ -65,6 +65,14 @@ class StructureType(BaseModel):
|
||||
drawing_views: list[str] = Field(default_factory=list)
|
||||
# 다른 정본이 관리하는 타입(배관 = pipe_points.json). structures.json에 저장하지 않는다.
|
||||
managed_by: str | None = None
|
||||
# 목록에는 두되 **제원·수량을 내는 주인이 다른 화면**인 타입 — 그 화면 이름을 적는다
|
||||
# (2026-09-07 사용자: 「두되 표시만 해줘」). `managed_by`와 달리 저장은 그대로 되고,
|
||||
# ① 화면이 「{이름}에서 관리」 표시를 붙이고 ② 수량 집계가 건너뛴다.
|
||||
#
|
||||
# 측구(옆도랑)가 그 경우다 — 횡단 설계가 측구 켬/끔·형식·터파기 단면적을 이미 셈하므로
|
||||
# (`B06_Section_Engine_Design.py` · `common_util_cross_design.ts` 짝), 구조물로 또 세면
|
||||
# **같은 것을 두 번 계상**한다(2026-09-07 조사).
|
||||
design_owner: str | None = None
|
||||
# 전문 상세설계가 따로 필요한 시설(교량 등) — 배치·제원 입력까지만 담당한다.
|
||||
reference_only: bool = False
|
||||
enabled: bool = True
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface StructuresFormElements {
|
||||
body: HTMLElement;
|
||||
groupSelect: HTMLSelectElement;
|
||||
typeSelect: HTMLSelectElement;
|
||||
/** 「제원·수량은 다른 화면이 냅니다」 안내 한 줄 — `design_owner` 가 있을 때만 보인다. */
|
||||
ownerNote: HTMLElement;
|
||||
startFields: StationFields;
|
||||
anchorFields: StationFields;
|
||||
endFields: StationFields;
|
||||
@@ -96,6 +98,13 @@ export function buildStructuresForm(options: {
|
||||
typeRow.className = "b05-structure__grid";
|
||||
typeRow.append(field("구조물군", groupSelect), field("종류", typeSelect));
|
||||
|
||||
// 「제원·수량은 다른 화면이 냅니다」 안내 한 줄 — 목록 항목은 [측점][이름]만 적는 규칙이라
|
||||
// (2026-08-18 사용자 지시) 표시는 이 폼에 둔다. 측구(옆도랑)가 그 경우다(2026-09-07 사용자:
|
||||
// 「두되 표시만 해줘」). 문구는 레지스트리 `design_owner` 값으로 만든다.
|
||||
const ownerNote = document.createElement("p");
|
||||
ownerNote.className = "b05-structure__owner-note";
|
||||
ownerNote.hidden = true;
|
||||
|
||||
// 옵션 칸은 타입마다 다르므로 선택할 때마다 새로 그린다.
|
||||
const optionRow = document.createElement("div");
|
||||
optionRow.className = "b05-structure__grid";
|
||||
@@ -129,13 +138,22 @@ export function buildStructuresForm(options: {
|
||||
const positionDivider = document.createElement("hr");
|
||||
positionDivider.className = "b05-structure__divider";
|
||||
|
||||
body.append(typeRow, positionRow, positionDivider, optionRow, facilityOptions.root, actions);
|
||||
body.append(
|
||||
typeRow,
|
||||
ownerNote,
|
||||
positionRow,
|
||||
positionDivider,
|
||||
optionRow,
|
||||
facilityOptions.root,
|
||||
actions,
|
||||
);
|
||||
|
||||
return {
|
||||
root,
|
||||
body,
|
||||
groupSelect,
|
||||
typeSelect,
|
||||
ownerNote,
|
||||
startFields,
|
||||
anchorFields,
|
||||
endFields,
|
||||
|
||||
@@ -60,6 +60,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
|
||||
body,
|
||||
groupSelect,
|
||||
typeSelect,
|
||||
ownerNote,
|
||||
startFields,
|
||||
anchorFields,
|
||||
endFields,
|
||||
@@ -208,6 +209,12 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
|
||||
startFields.wrap.hidden = !isInterval;
|
||||
endFields.wrap.hidden = !isInterval;
|
||||
primary.disabled = !type;
|
||||
// 제원·수량 주인이 다른 화면인 타입은 그 사실을 폼에 적는다 — 목록에서 안 보이면
|
||||
// 「측구가 왜 없지」로 헤매고, 그렇다고 수량에 넣으면 이중 계상이다(2026-09-07 사용자).
|
||||
ownerNote.textContent = type?.design_owner
|
||||
? `${type.design_owner}에서 관리 — 여기서 넣어도 제원·수량은 그쪽 값을 씁니다.`
|
||||
: "";
|
||||
ownerNote.hidden = !type?.design_owner;
|
||||
anchorFields.wrap.querySelector("span")!.textContent = isInterval
|
||||
? "기준 측점 (비우면 시작)"
|
||||
: "기준 측점";
|
||||
|
||||
@@ -194,6 +194,17 @@
|
||||
border-top: 1px solid var(--color-border, #3a3f4a);
|
||||
}
|
||||
|
||||
/* 「제원·수량은 다른 화면이 냅니다」 안내 — 경고가 아니라 안내라 색은 흐리게,
|
||||
* 왼쪽 선 하나로만 구분한다(2026-09-07). */
|
||||
.b05-structure__owner-note {
|
||||
margin: 0;
|
||||
padding: 4px 0 4px 8px;
|
||||
border-left: 2px solid var(--color-border, #3a3f4a);
|
||||
color: var(--color-text-muted, #9aa1ad);
|
||||
font-size: var(--text-caption, 12px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 시작·기준·종료 측점 = 3행. 한 행은 [라벨][측점][+거리] 가로 배치
|
||||
* (2026-08-17 사용자 지시 2). */
|
||||
.b05-structure__position-row {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""구조물 정본에서 **시설별 연장(m)** 을 낸다 — B군 배수시설 수량의 입력 (계획서 3-6).
|
||||
|
||||
왜 연장만인가 — B군(측구·산마루측구·도수로·맹암거 등)의 수량 단위가 **전부 m** 이다
|
||||
(맹암거 12-10 · L형 측구 12-9-1 · 산마루측구 12-9-2 · 소단측구 12-9-3, 지식DB
|
||||
`04_수량분석정보/배수공_수량.md`). 그래서 단면 기하 없이 구간 길이만으로 셈이 된다.
|
||||
별표2의 「횡단면도 각 측점 기입 물량」 목록에도 B군은 없어 도면에 그릴 의무가 없다
|
||||
(2026-09-07 조사).
|
||||
|
||||
**수량서 양식에 안 묶인다** — 여기서는 종류별 연장·개소만 내고, 어느 코드·어느 칸에
|
||||
넣을지는 B08 이 정한다. B08 은 실무 xlsx 양식으로 재작업 예정이라 그 사이에 두는 것이다.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
||||
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
|
||||
|
||||
# 아직 셈하지 않는 타입 — 계획서 3-9(소단 만들기)가 끝난 뒤에 채운다. 지금 설계에는
|
||||
# 소단(berm)이 없어 이 시설이 설 자리 자체가 없다(2026-09-07 조사·사용자 확정).
|
||||
PENDING_TYPE_IDS = frozenset({"ditch_berm"})
|
||||
|
||||
|
||||
def _merge(spans: list[tuple[float, float]]) -> float:
|
||||
"""겹치는 구간을 합쳐 실제 덮인 길이를 낸다.
|
||||
|
||||
같은 시설을 겹치게 두 번 넣으면 단순 합은 그 구간을 **두 번 센다**. 연장은 「덮인
|
||||
길이」라 겹침을 지우는 쪽이 맞다. 원래 합(`raw_length_m`)도 함께 내보내므로 입력이
|
||||
겹쳤다는 사실은 숨지 않는다.
|
||||
"""
|
||||
total = 0.0
|
||||
current_start: float | None = None
|
||||
current_end = 0.0
|
||||
for start, end in sorted(spans):
|
||||
if current_start is None:
|
||||
current_start, current_end = start, end
|
||||
continue
|
||||
if start <= current_end:
|
||||
current_end = max(current_end, end)
|
||||
continue
|
||||
total += current_end - current_start
|
||||
current_start, current_end = start, end
|
||||
if current_start is not None:
|
||||
total += current_end - current_start
|
||||
return total
|
||||
|
||||
|
||||
def structure_lengths(project_root: str | Path) -> list[dict[str, Any]]:
|
||||
"""구간형 구조물의 시설별 연장을 돌려준다 (기준점 순, 종류별 한 줄).
|
||||
|
||||
빼는 것 셋 —
|
||||
· `managed_by` 타입(배관 등): 구조물 정본이 아니라 관 지점 정본 소관이다.
|
||||
· `design_owner` 타입(측구 = 횡단 설계): 횡단이 이미 터파기 단면적까지 셈하므로
|
||||
여기서 또 세면 **같은 것을 두 번 계상**한다(2026-09-07 사용자 확정).
|
||||
· `PENDING_TYPE_IDS`(소단측구): 놓일 소단이 아직 없다.
|
||||
|
||||
시작·종료는 늘 있다 — 구간형은 스키마가 둘 다 없으면 저장을 막는다
|
||||
(`StructureInstance.validate_placement_fields`).
|
||||
"""
|
||||
types = structure_type_map()
|
||||
spans: dict[str, list[tuple[float, float]]] = {}
|
||||
counts: dict[str, int] = {}
|
||||
|
||||
for structure in load_structures(str(project_root))[1]:
|
||||
definition = types.get(structure.type_id)
|
||||
if definition is None or definition.placement != "interval":
|
||||
continue
|
||||
if definition.managed_by or definition.design_owner:
|
||||
continue
|
||||
if structure.type_id in PENDING_TYPE_IDS:
|
||||
continue
|
||||
start, end = float(structure.start_m), float(structure.end_m)
|
||||
counts[structure.type_id] = counts.get(structure.type_id, 0) + 1
|
||||
spans.setdefault(structure.type_id, []).append((min(start, end), max(start, end)))
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for type_id, count in counts.items():
|
||||
entries = spans[type_id]
|
||||
rows.append(
|
||||
{
|
||||
"type_id": type_id,
|
||||
"group": types[type_id].group,
|
||||
"name": types[type_id].name,
|
||||
"count": count,
|
||||
# 겹침을 지운 실제 연장 — 수량서에 쓸 값.
|
||||
"length_m": round(_merge(entries), 2),
|
||||
# 입력한 구간 길이의 단순 합 — 위와 다르면 구간이 겹쳐 있다는 뜻.
|
||||
"raw_length_m": round(sum(end - start for start, end in entries), 2),
|
||||
}
|
||||
)
|
||||
rows.sort(key=lambda row: (row["group"], row["name"]))
|
||||
return rows
|
||||
Reference in New Issue
Block a user