diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index d1e4ba90..1e898cfb 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -761,6 +761,15 @@ "required": true, "phase": "detail" }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": ["야면석·호박돌", "깬잡석", "깬돌", "견치돌"], + "default": null, + "required": true, + "phase": "detail" + }, { "key": "side", "label": "설치 측", @@ -826,6 +835,15 @@ "required": true, "phase": "detail" }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": ["야면석·호박돌", "깬잡석", "깬돌", "견치돌"], + "default": null, + "required": true, + "phase": "detail" + }, { "key": "side", "label": "설치 측", diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index bbfe1d64..45a7dab6 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -218,6 +218,9 @@ export interface CulvertSideSpec { /** 배수관 측점의 세트(배관·기슭막이·보호공) 제원 — 횡단 카드 오버레이 입력. */ export interface CulvertSet { type: "pipe"; + /** 이 시설이 **놓인** 누가거리(m) — 세트는 옆 측점에도 붙으므로 소유 측점을 가리는 열쇠다. + * (2026-09-08 — 없을 때 관 길이가 이웃 측점에도 실려 같은 관을 두 번 셀 뻔했다.) */ + chainage_m?: number; pipe_kind: string | null; diameter_m: number; /** 관 위 최소 토피(m) — 별표2 교량·암거 복토 50㎝ 교차 참조. B05 하향 차단 기준. */ @@ -467,6 +470,8 @@ export interface CrossDesign { /** 측점별 **암 절토 경사비**(1:n 의 n) — 사용자가 카드에 넣은 값(2026-09-07). * 0 은 「표준값을 씀」이다. 계산에 쓰이는 값이 아니라 **입력을 되싣는 자리**다. */ cut_slope_ratio_user?: number; + /** 배수관 연장(m) — 기하가 **m 단위 올림까지** 끝낸 값(2026-09-08). 수량(B08)이 읽는다. */ + pipe_length_m?: number; } export interface CrossDesignResponse { @@ -504,6 +509,8 @@ export interface CrossSectionPatch { display_half_width_m?: number; /** 측점별 암 절토 경사비(2026-09-07). 0 = 표준값으로 되돌림. */ cut_slope_ratio_user?: number; + /** 배수관 연장(m) — 구조물 면적과 같은 길로 정본에 실린다(2026-09-08). */ + pipe_length_m?: number; inlet_structure?: "auto" | "revet" | "I" | "L" | "U"; basin_adjust?: { innerWidthM: number; diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index b513afd5..865df085 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -435,7 +435,13 @@ def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]] if abs(chainage - pipe_chainage) <= reach: # 세월교·BOX암거·물넘이는 배수관과 그림이 달라 키를 나눈다 — 소비처가 섞이지 않는다. kind = spec.get("type") - section[_SECTION_KEYS.get(str(kind), "culvert")] = spec + # ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08). 세트는 폭의 + # 절반까지 옆 측점에도 붙으므로, 이것이 없으면 소비처가 「소유 측점」을 못 가려 + # **같은 시설을 여러 측점에서 센다**(관 9개에 길이가 10곳 실렸던 자리). + section[_SECTION_KEYS.get(str(kind), "culvert")] = { + **spec, + "chainage_m": pipe_chainage, + } attached += 1 break return attached diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index 8cf4525f..3cbfc1b2 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -175,6 +175,10 @@ class CrossSectionPatch(BaseModel): fill_area_m2: float | None = Field(default=None, ge=0) cut_soil_area_m2: float | None = Field(default=None, ge=0) cut_rock_area_m2: float | None = Field(default=None, ge=0) + # 배수관 연장(m) — 브라우저 기하가 **m 단위 올림까지** 끝낸 값. 수량(B08)이 배수관 + # 공종을 세려면 정본에 있어야 한다(2026-09-08). 계산은 서버가 같은 코드를 Node 로 + # 돌려 내므로 한 벌이다(`B06_Section_Server_Calc_Node`). + pipe_length_m: float | None = Field(default=None, ge=0) class SectionConfirmRequest(BaseModel): diff --git a/B06_Section/B06_Section_Server_Calc_Prebuild.py b/B06_Section/B06_Section_Server_Calc_Prebuild.py index fb9d7fb4..399ecd57 100644 --- a/B06_Section/B06_Section_Server_Calc_Prebuild.py +++ b/B06_Section/B06_Section_Server_Calc_Prebuild.py @@ -52,7 +52,15 @@ ROOT = Path(__file__).resolve().parents[1] BUNDLE = ROOT / "config" / "server_calc_node" / "B06_Section_Server_Calc_Node.js" _NPM_SCRIPT = "build:server-calc" # 정본에 얹는 값만 받는다 — Node 가 다른 키를 내도 설계 데이터에 흘리지 않는다. -_AREA_KEYS = ("cut_area_m2", "fill_area_m2", "cut_soil_area_m2", "cut_rock_area_m2") +# ⚠ **TS 쪽 `STRUCTURE_ROW_KEYS` 와 짝이다.** 한쪽만 늘리면 Node 가 값을 내도 여기서 조용히 +# 버려진다(2026-09-08 관 길이를 더하며 실제로 걸린 자리). 시험이 두 목록을 대조한다. +_AREA_KEYS = ( + "cut_area_m2", + "fill_area_m2", + "cut_soil_area_m2", + "cut_rock_area_m2", + "pipe_length_m", +) def _mass_haul_context() -> dict[str, Any]: diff --git a/B06_Section/B06_Section_Structure_Layouts.ts b/B06_Section/B06_Section_Structure_Layouts.ts index ec4b2613..8a61f7b8 100644 --- a/B06_Section/B06_Section_Structure_Layouts.ts +++ b/B06_Section/B06_Section_Structure_Layouts.ts @@ -107,12 +107,20 @@ export function trimOfLayouts(layouts: StoredLayouts) { ); } -/** 정본에 얹는 면적 키 — 이 넷만 오간다. */ -export const STRUCTURE_AREA_KEYS = [ +/** + * 정본에 얹는 키 — 구조물이 선 측점에서만 나오는 값들. + * + * 면적 넷에 **관 길이**를 더했다(2026-09-08). 관 길이는 브라우저 기하가 **m 단위 올림까지** + * 끝낸 값인데 정본에 없어 **수량이 배수관 연장을 못 냈다**(B08 창 보고). 계산을 서버로 + * 옮기거나 새로 짜지 않고, **이미 서버가 Node 로 돌리는 이 다리에 한 줄 더 실었다** + * (CLAUDE.md 5장 — 계산은 한 벌). + */ +export const STRUCTURE_ROW_KEYS = [ "cut_area_m2", "fill_area_m2", "cut_soil_area_m2", "cut_rock_area_m2", + "pipe_length_m", ] as const; /** 측점 하나의 폐회로 면적 — 구조물이 없으면 null(표준 계산값이 이미 맞다). */ @@ -122,9 +130,24 @@ function areaRowOf( ): Record | null { const layouts = computeStoredLayouts(section, sections); if (!layouts) return null; + // 관 길이는 면적과 **따로** 낸다 — 폐회로 면적을 못 내는 측점(설계선이 모자란 자리)에도 + // 관은 서 있고, 수량은 그 길이를 필요로 한다(2026-09-08). + // + // ⚠ **관을 가진 측점(소유)에만 싣는다.** 옆 측점도 그 관 구간에 걸리면 레이아웃을 만들지만 + // (`culvertLinkFor` — 3D·카드가 이어 그리려고), 그 자리에 길이를 실으면 **같은 관을 두 번** + // 세게 된다. 실측에서 관 9개에 값이 10곳 실렸던 자리다. + const pipeOwner = + !!section.culvert && + (typeof section.culvert.chainage_m !== "number" || + Math.abs(section.culvert.chainage_m - section.chainage_m) <= CHAINAGE_TOLERANCE_M); + const pipeLengthM = pipeOwner ? layouts.culvert?.pipe?.lengthM : undefined; + const pipeRow: Record | null = + typeof pipeLengthM === "number" && pipeLengthM > 0 + ? { chainage_m: section.chainage_m, pipe_length_m: Number(pipeLengthM.toFixed(4)) } + : null; const trim = trimOfLayouts(layouts); const design = layouts.design; - if (!trim || !Array.isArray(design.design_line) || design.design_line.length < 2) return null; + if (!trim || !Array.isArray(design.design_line) || design.design_line.length < 2) return pipeRow; const ground = section.samples .filter((sample) => sample.valid !== false && typeof sample.elevation_m === "number") .map((sample) => ({ offset: sample.offset_m ?? 0, elevation: sample.elevation_m as number })) @@ -136,9 +159,10 @@ function areaRowOf( rockBoundaryOffsetM: typeof design.rock_boundary_offset_m === "number" ? design.rock_boundary_offset_m : null, }); - if (!areas) return null; + if (!areas) return pipeRow; const round = (value: number): number => Number(value.toFixed(4)); const row: Record = { + ...(pipeRow ?? {}), chainage_m: section.chainage_m, cut_area_m2: round(areas.cutAreaM2), fill_area_m2: round(areas.fillAreaM2), @@ -173,7 +197,7 @@ export function applyStructureAreaRows( const design = sections.find((item) => item.chainage_m === row.chainage_m)?.design as Record | undefined; if (!design) continue; - for (const key of STRUCTURE_AREA_KEYS) { + for (const key of STRUCTURE_ROW_KEYS) { if (typeof row[key] === "number") design[key] = row[key]; } } diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 9c7c0f9a..e45c50c3 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -29,7 +29,7 @@ import { } from "@util/common_util_cross_berm"; import { applyStructureAreaRows, - STRUCTURE_AREA_KEYS, + STRUCTURE_ROW_KEYS, structureAreaRows, } from "./B06_Section_Structure_Layouts"; import { crossDesignChoices } from "./B06_Section_Cross_Design_Session"; @@ -393,7 +393,7 @@ export function collectSectionEdits(ctx: SectionPersistContext): { const design = section.design as Record | undefined; if (!design) continue; let patch: CrossSectionPatch | null = null; - for (const key of STRUCTURE_AREA_KEYS) { + for (const key of STRUCTURE_ROW_KEYS) { if (typeof design[key] !== "number") continue; patch = patch ?? patchFor(section.chainage_m); patch[key] = design[key] as number; diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff.py b/B08_Quantity/B08_Quantity_Engine_Handoff.py index 5bd0fa1a..f26afd6d 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff.py @@ -158,6 +158,7 @@ ORIGIN_STRUCTURE = "structure" ORIGIN_SLOPE = "slope" ORIGIN_HAUL = "haul" ORIGIN_PREPARATION = "preparation" +ORIGIN_PIPE = "pipe" #: 암 시공법 → 매핑표의 지반 이름. 품셈이 **긁어내기와 터뜨리기를 다른 공종**으로 두기 때문에 #: 갈래 이름(연암·보통암…)만으로는 공종을 못 고른다(2026-09-07 일감 9 실서버에서 드러남). @@ -202,6 +203,8 @@ class WorkItemMapping: composite: dict[str, Any] = field(default_factory=dict) concrete_placing: dict[str, Any] = field(default_factory=dict) unit_conversion: dict[str, Any] = field(default_factory=dict) + #: 배수관 — 관종별 공종·연장 키. 관 정본은 `pipe_points.json` 이다. + pipe: dict[str, Any] = field(default_factory=dict) def for_earthwork(self, group: str, ground: str | None) -> dict[str, Any] | None: # noqa: D401 """공종+지반유형으로 찾는다. 지반이 갈리지 않는 공종은 `ground` 없는 줄이 받는다.""" @@ -253,6 +256,7 @@ def load_mapping(path: Path | None = None) -> WorkItemMapping: pending_user=payload.get("pending_user") or {}, composite=payload.get("composite") or {}, concrete_placing=payload.get("concrete_placing") or {}, + pipe=payload.get("pipe") or {}, unit_conversion=(payload.get("composite") or {}).get("unit_conversion") or {}, ) @@ -579,13 +583,23 @@ def _haul_rows( return rows, unmatched -def blocked_of(structure: dict[str, Any], class_basis: str = "") -> tuple[str | None, str]: +def blocked_of( + structure: dict[str, Any], class_basis: str = "", has_code: bool = False +) -> tuple[str | None, str]: """(막힌 갈래, 사유). 안 막혔으면 `(None, "")`. ⚠ 사유 문구는 **`B08_Quantity_Wording` 것을 그대로** 쓴다 — 두 벌로 짜면 갈린다. 전개 알림(`notes`)에 이미 사람 말로 적혀 있으므로 그것을 그대로 옮긴다. + + ⚠⚠ **전개식이 없다고 다 막힌 것이 아니다** (2026-09-08 V-3 에서 드러남). + B군 종단배수(산마루측구 12-9-2 · 소단측구 12-9-3 · 맹암거 12-10)는 품셈 밑수가 + **1 m** 라 **연장이 곧 수량**이다 — 원단위 전개가 필요 없다. 그런데 「성분이 없으면 + 전개식 없음」으로 단정해 B09 가 **「우리가 만들 것」으로 빼 금액이 0** 이었다. + **공종코드가 붙었고 수량이 있으면 막힌 것이 아니다.** """ notes = [str(note) for note in structure.get("notes") or []] + if has_code and float(structure.get("length_m") or 0.0) > 0: + return None, "" if structure.get("components"): # 물량은 섰는데 **단가 갈래**를 못 고른 자리(돌쌓기 뒷길이 등). if class_basis and "입력되지 않았습니다" in class_basis: @@ -643,7 +657,7 @@ def _structure_rows( parts_missing: list[dict[str, Any]] = [] if composite: parts, parts_missing = composite_quantities(structure, composite, mapping) - blocked_kind, blocked_reason = blocked_of(structure, class_basis) + blocked_kind, blocked_reason = blocked_of(structure, class_basis, has_code=bool(code)) # 갈래 축과 **저장 제원 원본값**. 가공하지 않는다. variant_axis = str(entry.get("variant_axis") or "") or None variant_value = (structure.get("options") or {}).get(variant_axis) if variant_axis else None @@ -859,6 +873,130 @@ def _prep_blocked_kind(status: str) -> str | None: return BLOCKED_UNIT_DATA_MISSING +def _pipe_rows(pipe_table: dict[str, Any]) -> list[dict[str, Any]]: + """배수관 줄 — 값이 서는 줄도, 못 서는 줄도 함께 보낸다(준비공과 같은 규칙). + + ⚠ 터파기·되메우기를 붙이지 않는다 — 관 부설과 굴착이 각각 오면 **같은 굴착을 두 번** 센다 + (B09 ㉡ 가드와 같은 자리). + """ + rows: list[dict[str, Any]] = [] + for row in pipe_table.get("rows") or []: + ready = bool(row.get("in_bill")) + rows.append( + { + "work_item_code": row.get("work_item_code"), + "name": f"배수관({row.get('kind')})", + "spec": f"Ø{row.get('variant_value')}" if row.get("variant_value") else "", + "unit": str(row.get("unit") or "m"), + "quantity": float(row.get("quantity") or 0.0), + "quantity_gross": None, + "application_ratio_pct": None, + "application_ratio_breakdown": None, + "quantity_breakdown": None, + "ground_class": None, + "haul_distance_m": None, + "haul_equipment": None, + "station_from": row.get("chainage_m"), + "station_to": row.get("chainage_m"), + "excavation_method": None, + "spec_detail": f"Ø{row.get('variant_value')}" if row.get("variant_value") else "", + "composite_parts": None, + "structure_kind": None, + "blocked_kind": row.get("blocked_kind"), + "blocked_reason": str(row.get("blocked_reason") or ""), + # 갈래는 **저장 원본값**만 — 「…㎜ 이하」 구간 나누기는 원문을 읽는 쪽 몫. + "variant_axis": row.get("variant_axis"), + "variant_value": row.get("variant_value"), + "spec_class": None, + "spec_class_basis": str(row.get("blocked_reason") or ""), + "composite_not_ready": None, + "in_bill": ready, + "in_bill_reason": "" if ready else str(row.get("blocked_reason") or ""), + "origin": ORIGIN_PIPE, + } + ) + return rows + + +def _length_rows( + length_table: list[dict[str, Any]], mapping: WorkItemMapping +) -> list[dict[str, Any]]: + """B군 종단배수 — **종류별 한 줄**로 낸다(연장이 곧 수량). + + ⚠ **왜 구조물별로 안 내나** — `common_util_structure_lengths` 가 **겹친 구간을 합쳐** + 준다. 같은 시설을 겹쳐 놓으면 구조물별로 세는 순간 그 구간을 **두 번** 센다. + 그 규칙(겹침 합치기 · 측구 제외 · 관 소관 제외)이 이미 그 함수에 있으므로 + **두 벌로 짜지 않는다**(2026-09-08 랩탑 창 제안, 두 창 합의). + + ⚠ **C군(돌쌓기·옹벽 등)은 여기로 오지 않는다** — 그 함수는 종류별로 뭉쳐 내는데, + C군은 **측점·규격이 줄마다 달라** 구조물별로 서야 하고 자재도 줄마다 나온다. + 실무 내역도 B군은 「산마루측구 40m」 한 줄, C군은 구조물별 줄이다. + + ⚠ 겹침이 있으면(`length_m != raw_length_m`) **숨기지 않고 비고에 적는다.** + """ + rows: list[dict[str, Any]] = [] + for entry in length_table or []: + type_id = str(entry.get("type_id") or "") + found = mapping.for_structure(type_id) + code = (found or {}).get("work_item_code") + length = float(entry.get("length_m") or 0.0) + raw = float(entry.get("raw_length_m") or length) + # 구간 목록 — **겹침을 지운 뒤**의 것이라 그 합이 곧 `length_m` 이다 + # (80~120 과 100~140 은 80~140 한 줄로 합쳐져 온다, 2026-09-08 랩탑 창). + # ⚠ 표기(`NO.4+0.0`)는 만들지 않는다 — 측점 간격을 아는 화면 몫이다. + spans = [ + span + for span in (entry.get("spans") or []) + if span.get("start_m") is not None and span.get("end_m") is not None + ] + span_note = " · ".join(f"{s['start_m']:g}~{s['end_m']:g}m" for s in spans) + note = f"구간 {span_note}" if span_note else "" + if abs(raw - length) > 1e-9: + 겹침 = f"입력 구간 합 {raw:g}m 에서 겹친 {raw - length:g}m 를 뺀 값" + note = f"{note} · {겹침}" if note else 겹침 + # ⚠ 겹침 설명은 **비고**이지 막힌 사유가 아니다 — `blocked_reason` 에 넣으면 + # 받는 쪽이 「막힌 줄」로 읽어 금액을 안 붙인다(2026-09-08 실측에서 그랬다). + reason = "" + if code is None: + reason = f"{entry.get('name') or type_id} — 품셈 공종을 아직 못 이었습니다" + elif length <= 0: + reason = f"{entry.get('name') or type_id} — 연장이 0 이라 값이 서지 않습니다" + rows.append( + { + "work_item_code": code, + "name": str(entry.get("name") or type_id), + "spec": f"{entry.get('count')}개소", + "unit": "m", + "quantity": length, + "quantity_gross": raw if note else None, + "application_ratio_pct": None, + "application_ratio_breakdown": None, + "quantity_breakdown": None, + "ground_class": None, + "haul_distance_m": None, + "haul_equipment": None, + # 여러 구간이면 **처음과 끝**만 싣는다 — 사이 구간은 비고에 다 적혀 있다. + "station_from": spans[0]["start_m"] if spans else None, + "station_to": spans[-1]["end_m"] if spans else None, + "excavation_method": None, + "spec_detail": f"{entry.get('count')}개소", + "composite_parts": None, + "structure_kind": None, + "blocked_kind": None if (code and length > 0) else BLOCKED_FORMULA_MISSING, + "blocked_reason": reason, + "variant_axis": None, + "variant_value": None, + "spec_class": None, + "spec_class_basis": note, + "composite_not_ready": None, + "in_bill": bool(code and length > 0), + "in_bill_reason": "" if (code and length > 0) else reason, + "origin": ORIGIN_STRUCTURE, + } + ) + return rows + + def _material_rows(material_table: dict[str, Any]) -> list[dict[str, Any]]: """자재 줄 — **공종코드를 붙이지 않는다.** 자재 축은 B09 카탈로그가 잇는다(8-7).""" rows: list[dict[str, Any]] = [] @@ -887,6 +1025,8 @@ def build_handoff( unit_quantity_table: dict[str, Any] | None = None, material_table: dict[str, Any] | None = None, preparation_table: dict[str, Any] | None = None, + length_table: list[dict[str, Any]] | None = None, + pipe_table: dict[str, Any] | None = None, mapping: WorkItemMapping | None = None, ground_class_set: str | None = None, ground_classes: list[str] | None = None, @@ -913,7 +1053,11 @@ def build_handoff( unmatched.extend(misses) # 준비공·사방공 — **못 내는 줄도 사유와 함께** 보낸다(빼면 빠진 줄이 안 보인다). + # B군 종단배수 — 겹침을 합친 연장으로 종류별 한 줄(위 `_length_rows` 주석). + work_items.extend(_length_rows(length_table or [], table)) work_items.extend(_preparation_rows(preparation_table or {})) + # 배수관 — 정본 셋(관 지점·측점 연장·매핑)을 이은 결과. 못 서는 줄도 사유와 함께 감. + work_items.extend(_pipe_rows(pipe_table or {})) # 콘크리트 타설 — 품은 이 줄, 재료는 자재 쪽. 겹치지 않는다(위 `_placing_rows` 주석). placing_rows, placing_notes = _placing_rows( diff --git a/B08_Quantity/B08_Quantity_Engine_Pipe.py b/B08_Quantity/B08_Quantity_Engine_Pipe.py new file mode 100644 index 00000000..9261086c --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_Pipe.py @@ -0,0 +1,172 @@ +"""배수관 물량 — **정본 셋을 잇기만 한다** (2026-09-08 두 창 합의). + +값이 어디서 오나 + 관 자체(있나·어디·관경·관종) → `pipe_points.json` (레지스트리 `pipe` 타입이 + `managed_by: pipe_points`) + 관 연장(m) → 측점 `design.pipe_length_m` + (B06 횡단이 **서버 Node 로** 계산해 m 단위 올림까지 + 끝낸 값을 정본에 남긴다 — 계산이 두 벌이 아니다) + 관종 → 공종코드 → `work_item_mapping` 의 `pipe.kind_codes` + +⚠ **여기서 길이를 짓지 않는다.** 앞서 「도로폭 = 관 길이」처럼 잡을 뻔했는데 그것이 곧 + 임의 수치다. 연장이 없는 관은 **줄을 세우되 막힌 사유와 함께** 보낸다. + +⚠ **`facility` 가 `pipe` 인 점만 배관이다.** + `pipe_points.json` 은 **계곡 통과 시설 전부의 정본**이라 BOX암거·물넘이·세월교·독립 + 기슭막이가 같은 파일에 있다. `pipe_diameter_mm` 유무로 가르면 **관경 미지정 관을 놓친다** + (실측: `5601e828` 11점 중 2점이 `facility: ford_bridge`). + +⚠ **유출·유입부 기슭막이는 여기서 세지 않는다.** + 관 옵션(`outlet_revet_*`)이 정본이고 구조물 목록에서는 빠졌다(2026-08-28 이관). + 구조물 쪽으로 또 세면 이중계상이다. + +⚠ **터파기·되메우기를 관 줄에 붙이지 않는다.** + 관 부설과 굴착이 각각 오면 **같은 굴착을 두 번** 센다(B09 ㉡ 가드와 같은 자리). +""" + +from __future__ import annotations + +from typing import Any + +#: 배관으로 보는 `facility` 값. 그 밖(BOX암거·물넘이·세월교·독립 기슭막이)은 관이 아니다. +FACILITY_PIPE = "pipe" + +#: 연장을 못 찾은 줄의 막힌 갈래 — 「입력하면 풀림」이 아니라 **앞 단계가 내야 하는 값**이다. +BLOCKED_LENGTH_MISSING = "input_missing" + +#: ⚠ **둘을 갈라 말한다** — 「[저장]을 누르면 풀리는 것」과 「눌러도 안 풀리는 것」은 +#: 사용자가 할 일이 다르다. 뒤엣것에 앞 문구를 쓰면 눌러 보고 안 되어 헤맨다 +#: (2026-09-08 실측: 관 9개 중 3개가 **횡단 행 자체가 없는** 자리였다). +NOTE_LENGTH_MISSING = ( + "관 연장이 아직 정본에 없습니다 — 횡단설계에서 [저장]을 한 번 누르면 " + "그 측점의 관 길이가 남고 값이 섭니다" +) +NOTE_SECTION_MISSING = ( + "그 측점의 횡단 자체가 없습니다 — 관은 놓였는데 횡단이 안 만들어진 자리라 " + "[저장]으로는 안 풀립니다. 횡단설계에서 그 측점이 서야 합니다" +) +#: 관 자리에 횡단이 있는지 볼 때의 허용 오차. **아주 좁게** — 옆 측점을 「있다」로 세면 +#: 거짓 안내가 된다. 길이 찾기(0.5m)보다 좁은 것은 뜻이 다르기 때문이다. +SECTION_MATCH_TOLERANCE_M = 0.05 + +NOTE_KIND_DEFAULT = "관종을 안 정해 기본값({kind})으로 섰습니다 — 정하면 공종이 갈립니다" +NOTE_KIND_UNKNOWN = "「{kind}」은(는) 아는 관종이 아니라 공종을 못 골랐습니다" + + +def _length_by_chainage(designs: list[dict[str, Any]], key: str) -> dict[float, float]: + """측점별 관 길이. **없는 측점은 담지 않는다** — 0 으로 채우면 「없음」과 구별이 안 된다.""" + found: dict[float, float] = {} + for row in designs or []: + design = row.get("design") if isinstance(row, dict) else None + if not isinstance(design, dict): + continue + value = design.get(key) + if value is None: + continue + try: + length = float(value) + except (TypeError, ValueError): + continue + if length > 0: + found[round(float(row.get("chainage_m") or 0.0), 3)] = length + return found + + +def _nearest(lengths: dict[float, float], chainage: float, tolerance: float = 0.5) -> float | None: + """관 측점과 단면 측점이 소수점에서 어긋날 수 있어 **가까운 것**을 본다. + + ⚠ 좁게 본다(기본 0.5m) — 넓히면 옆 측점의 길이를 물어 와 조용히 틀린다. + """ + if not lengths: + return None + key = round(chainage, 3) + if key in lengths: + return lengths[key] + best = min(lengths, key=lambda x: abs(x - chainage)) + return lengths[best] if abs(best - chainage) <= tolerance else None + + +def build_rows( + pipe_points: list[dict[str, Any]], + designs: list[dict[str, Any]], + mapping: dict[str, Any] | None = None, + section_chainages: list[float] | None = None, +) -> dict[str, Any]: + """관 줄 목록. **값이 서는 줄도, 못 서는 줄도** 함께 낸다. + + `pipe_points` 는 `PipePoint.model_dump()` 또는 같은 모양의 딕셔너리 목록이다. + """ + table = mapping or {} + kind_codes: dict[str, str] = table.get("kind_codes") or {} + kind_key = str(table.get("kind_option_key") or "pipe_kind") + default_kind = str(table.get("default_kind") or "") + length_key = str(table.get("length_key") or "pipe_length_m") + diameter_key = str(table.get("diameter_option_key") or "pipe_diameter_mm") + + lengths = _length_by_chainage(designs, length_key) + # 횡단이 **있는데 길이가 없는 것**과 **관 자리에 횡단이 없는 것**을 가르기 위한 목록. + # 안 주면 종전대로 「[저장]하면 풀림」 하나로만 말한다. + sections = [float(x) for x in (section_chainages or [])] + rows: list[dict[str, Any]] = [] + notes: list[str] = [] + + for point in pipe_points or []: + if str(point.get("facility") or FACILITY_PIPE) != FACILITY_PIPE: + continue # 배관이 아닌 시설 — 그쪽 줄은 그쪽이 센다 + options = point.get("options") or {} + chainage = float(point.get("chainage_m") or 0.0) + + stored_kind = str(options.get(kind_key) or "").strip() + kind = stored_kind or default_kind + code = kind_codes.get(kind) + kind_note = "" + if not stored_kind and default_kind: + kind_note = NOTE_KIND_DEFAULT.format(kind=default_kind) + elif stored_kind and code is None: + kind_note = NOTE_KIND_UNKNOWN.format(kind=stored_kind) + + length = _nearest(lengths, chainage) + # ⚠ **관이 놓인 그 측점**이 있는지를 본다 — 옆 측점이 있는 것은 소용없다. + # 실측(2026-09-08 `5601e828`): 관 439.55 근처에 측점 440.0 만 있었고, 0.5m 로 + # 느슨히 보면 「횡단이 있다」로 읽혀 **「[저장]하면 풀린다」는 거짓 안내**가 떴다. + # 길이는 B06 이 **관이 놓인 측점에만** 싣는다(2026-09-08 이웃 오염을 고친 뒤). + has_section = not sections or any( + abs(x - chainage) <= SECTION_MATCH_TOLERANCE_M for x in sections + ) + blocked_kind = None if length else BLOCKED_LENGTH_MISSING + blocked_reason = "" + if not length: + blocked_reason = NOTE_LENGTH_MISSING if has_section else NOTE_SECTION_MISSING + if code is None: + blocked_kind = blocked_kind or BLOCKED_LENGTH_MISSING + blocked_reason = blocked_reason or kind_note + + rows.append( + { + "chainage_m": chainage, + "work_item_code": code, + "kind": kind, + "kind_from_default": not stored_kind, + # 갈래는 **저장 원본값**만 보낸다 — 「…㎜ 이하」 구간 나누기는 원문을 읽는 쪽 몫. + "variant_axis": diameter_key, + "variant_value": options.get(diameter_key), + "unit": str(table.get("unit") or "m"), + "quantity": float(length or 0.0), + "blocked_kind": blocked_kind, + "blocked_reason": blocked_reason or kind_note, + "in_bill": bool(length and code), + } + ) + if kind_note and kind_note not in notes: + notes.append(kind_note) + + missing = sum(1 for row in rows if not row["in_bill"]) + if missing: + notes.append(f"관 {len(rows)}개 중 {missing}개가 아직 값이 안 섭니다") + return { + "rows": rows, + "notes": notes, + "pipe_count": len(rows), + "ready_count": sum(1 for row in rows if row["in_bill"]), + "length_total_m": round(sum(row["quantity"] for row in rows if row["in_bill"]), 3), + } diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index ed3dcd3c..2ba08c1a 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -30,7 +30,10 @@ from __future__ import annotations +import json import math +from pathlib import Path +from functools import lru_cache from dataclasses import dataclass, field from typing import Any, Iterable @@ -49,14 +52,82 @@ from common_util.common_util_quantity_spread import spread_by_unit # ── 계수표 — 식에 박지 않고 여기서 고른다 ───────────────────────────── # 돌 뒷길이(㎝)별 원단위. 출처: `original/실무문서/_원단위라이브러리_울진소광.md` 「돌뒷길이별 원단위표」. # ⚠ 60㎝ 돌중량은 원본이 비어 있다 — 지어내지 않고 None 으로 둔다(PLAN 8-8 ㉮). +#: ⚠ **일곱 규격**이다 — 품셈 13-4-3·13-4-4 [주]① 이 25·30·35·45·55·60·75 를 다 준다. +#: 앞서 네 칸(35·45·55·60)만 들고 25·30 을 35 로, 75 를 60 으로 **접고** 있었다. +#: `stone_ton_per_m2`(돌중량)는 **실무 관측값**이라 그 넷에만 있다 — 없는 칸은 `None`. STONE_BACK_LENGTH_TABLE: dict[int, dict[str, float | None]] = { + 25: {"fill_concrete_m3_per_m2": 0.11, "wedge_stone_m3_per_m2": None, "stone_ton_per_m2": None}, + 30: {"fill_concrete_m3_per_m2": 0.14, "wedge_stone_m3_per_m2": 0.10, "stone_ton_per_m2": None}, 35: {"fill_concrete_m3_per_m2": 0.16, "wedge_stone_m3_per_m2": 0.12, "stone_ton_per_m2": 0.575}, 45: {"fill_concrete_m3_per_m2": 0.20, "wedge_stone_m3_per_m2": 0.15, "stone_ton_per_m2": 0.88}, 55: {"fill_concrete_m3_per_m2": 0.25, "wedge_stone_m3_per_m2": 0.18, "stone_ton_per_m2": 1.10}, 60: {"fill_concrete_m3_per_m2": 0.27, "wedge_stone_m3_per_m2": 0.20, "stone_ton_per_m2": None}, + 75: {"fill_concrete_m3_per_m2": 0.34, "wedge_stone_m3_per_m2": 0.25, "stone_ton_per_m2": None}, } DEFAULT_BACK_LENGTH_CM = 45 + +#: 돌 종류별 계수표 — 품셈 13-4-3·13-4-4 [주]① · 교본 7-3. +#: ⚠ 지금까지 **건설품셈 참고자료 한 벌**(돌 종류로 안 갈리는 표)로만 돌고 있었다. +#: 그 값이 「깬돌」 계열이라, **자재로는 야면석을 내면서 계수는 깬돌**을 쓰는 어긋남이 +#: 있었다(2026-09-08 지식DB 대조). 랩탑 창이 `stone_kind` 칸을 만들어 축이 생겼다. +STONE_KIND_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry" +STONE_KIND_PREFIX = "stone_kind_" +STONE_KIND_OPTION = "stone_kind" + + +@lru_cache(maxsize=1) +def load_stone_kind_table() -> dict[str, Any]: + """돌 종류별 계수표. 파일이 없으면 **빈 표** — 그러면 종전 값으로 돈다.""" + folder = STONE_KIND_DIR + if not folder.is_dir(): + return {} + files = sorted(folder.glob(STONE_KIND_PREFIX + "*.json")) + if not files: + return {} + try: + return json.loads(files[-1].read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + + +def stone_coefficients(options: dict[str, Any], back_cm: int) -> tuple[dict[str, Any], str]: + """(계수 한 벌, 알림). 돌 종류를 안 고르면 **종전 값**으로 돌되 그 사실을 알린다. + + ⚠ 값을 못 낸다고 멈추지 않는다 — 이미 저장된 프로젝트가 통째로 비어 버린다. + ⚠ 표에 「-」(그 규격에 그 돌을 안 씀)면 **지어내지 않고** 사유를 낸다. + """ + table = load_stone_kind_table() + key = str(back_cm) + kind = str(options.get(STONE_KIND_OPTION) or "").strip() + if not table: + return {}, "" + if key not in [str(x) for x in (table.get("back_lengths_cm") or [])]: + return {}, (f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 계수가 없습니다") + if not kind: + fallback = table.get("fallback") or {} + return { + "wedge_stone_m3_per_m2": (fallback.get("wedge_stone_m3_per_m2") or {}).get(key), + "fill_concrete_m3_per_m2": (fallback.get("fill_concrete_m3_per_m2") or {}).get(key), + "backfill_ratio": fallback.get("backfill_ratio_of_back_length"), + "kind": "", + }, str(fallback.get("message") or "") + if kind not in (table.get("kinds") or []): + return {}, f"「{kind}」은(는) 아는 돌 종류가 아니라 계수를 못 골랐습니다" + wedge = ((table.get("wedge_stone_m3_per_m2") or {}).get(kind) or {}).get(key) + fill = ((table.get("fill_concrete_m3_per_m2") or {}).get(kind) or {}).get(key) + ratio = (table.get("backfill_ratio_of_back_length") or {}).get(kind) + note = "" + if wedge is None: + note = f"품셈 13-4-3 에 「{kind} · 뒷길이 {back_cm}㎝」 칸이 비어 있습니다 — 그 규격에 그 돌을 쓰지 않습니다" + return { + "wedge_stone_m3_per_m2": wedge, + "fill_concrete_m3_per_m2": fill, + "backfill_ratio": ratio, + "kind": kind, + }, note + + # 돌쌓기 전개식의 상수 — 실무 수식에 박혀 있던 값을 뺀 것. STONE_MASONRY = { # ⚠ **곱하는 값이 아니라 검산 참고값이다** (2026-09-08 ㉘ 에서 고침). @@ -155,11 +226,11 @@ BACK_LENGTH_KEYS = ("back_len_cm", "stone_back_length_cm") def _back_length(options: dict[str, Any]) -> int: - """뒷길이(㎝). 표에 없는 값이면 **가장 가까운 아래 칸**이 아니라 기본으로 간다. + """저장된 뒷길이(㎝)를 **그대로** 돌려준다. 안 정했으면 기본 45. - ⚠ 표는 35·45·55·60 네 칸뿐인데 레지스트리 선택지는 25·30·35·45·55·60·75 일곱이다. - 25·30 은 35 로, 75 는 60 으로 접는다 — **원단위표가 그 구간을 그렇게 덮는다** - (품셈 13-4 뒷길이 표준). 접은 사실은 근거 문구에 적는다. + ⚠ **접지 않는다.** 품셈 13-4-3·13-4-4 [주]① 이 25·30·35·45·55·60·75 **일곱 규격**을 + 다 주므로 접을 까닭이 없다. 그 밖의 값(40 등)은 **계수가 없다고 드러낸다** — + 접으면 다른 규격 계수가 조용히 돈다. """ for key in BACK_LENGTH_KEYS: raw = options.get(key) @@ -169,13 +240,10 @@ def _back_length(options: dict[str, Any]) -> int: value = int(float(raw)) except (TypeError, ValueError): continue - if value in STONE_BACK_LENGTH_TABLE: - return value - # 표에 없는 값 — 그 값을 덮는 **가장 가까운 위 칸**으로 접는다(작은 돌이 큰 칸에 들어감). - larger = [cm for cm in sorted(STONE_BACK_LENGTH_TABLE) if cm >= value] - if larger: - return larger[0] - return max(STONE_BACK_LENGTH_TABLE) + # ⚠ **접지 않는다.** 앞서 「가장 가까운 위 칸」으로 접고 있었는데, 그러면 40㎝ 가 + # 45㎝ 계수로 **조용히** 돌고 999㎝ 도 60㎝ 로 접혔다(2026-09-08 실측). + # 표에 없으면 그 값을 그대로 돌려주고, 계수를 고르는 쪽이 「없다」고 드러낸다. + return value return DEFAULT_BACK_LENGTH_CM @@ -304,7 +372,34 @@ def stone_masonry( return [], ["높이·연장이 없어 전개하지 않음"] back_cm = _back_length(options) - table = STONE_BACK_LENGTH_TABLE[back_cm] + if back_cm not in STONE_BACK_LENGTH_TABLE: + # ⚠ 접지 않는다 — 다른 규격 계수가 조용히 도는 것보다 「없다」가 낫다. + return [], [ + f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 물량이 서지 않습니다" + ] + table = dict(STONE_BACK_LENGTH_TABLE[back_cm]) + # ⚠ **돌 종류로 계수가 갈린다** (품셈 13-4-3 · 13-4-4 [주]① · 교본 7-3). + # 안 고르면 종전 값(건설품셈 참고자료)으로 돌되 그 사실을 알린다 — 값을 못 낸다고 + # 멈추면 이미 저장된 프로젝트가 통째로 빈다. + picked, kind_note = stone_coefficients(options, back_cm) + if kind_note: + notes.append(kind_note) + # ⚠ **고른 종류의 빈 칸은 빈 칸으로 덮는다.** `None` 이라고 안 덮으면 종전 값(깬돌 계열)이 + # 남아 「야면석 75㎝」처럼 **원문에 「-」인 칸에 값이 서는** 일이 생긴다(만들다 잡음). + if picked.get("kind"): + for key in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"): + table[key] = picked.get(key) + else: + for key in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"): + if picked.get(key) is not None: + table[key] = picked[key] + #: ⚠ **표는 「뒤채움 몫」, 우리 식은 「빼는 몫」** — 뜻이 반대라 1 에서 뺀다. + #: 교본은 「뒤채움 = 뒷길이 × (깬돌·잡석 1/2, 야면석 1/3)」이고, 우리 식이 입적에서 + #: 빼는 것은 **돌 몸통**이라 `1 − 뒤채움몫` 이다. 종전 2/3 이 곧 야면석(1 − 1/3)이었다. + #: ⚠ 그대로 넣었더니 미지정 값이 15.130 → 19.045 로 바뀌었다(만들다 잡음). + backfill_share = picked.get("backfill_ratio") + body_ratio = 1.0 - float(backfill_share) if backfill_share is not None else 2.0 / 3.0 + kind_label = str(picked.get("kind") or "") # ⚠ `face_slope_ratio` 는 **레지스트리에 없는 키**다 — 즉 지금은 늘 기본 0.3 으로 돈다. # 상수로 두는 것이 아니라 「칸이 생기면 바로 받는다」는 뜻으로 남겨 둔다. # (키 이름 어긋남으로 저장값이 안 닿던 `back_len_cm` 사고와 구별할 것 — 이쪽은 **칸 자체가 없다**.) @@ -332,14 +427,27 @@ def stone_masonry( DESTINATION["돌쌓기"], f"정면적 × √(1+{slope_ratio}²) — 비탈면적", ), - Component( - "고임돌", - "㎥", - masonry_area * _num(table["wedge_stone_m3_per_m2"]), - DESTINATION["고임돌"], - f"돌쌓기 × {table['wedge_stone_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)", - ), ] + # ⚠ 고임돌 계수가 **원문에서 「-」**인 칸이 있다(견치돌 25·30 · 야면석 75 · 깬돌 25). + # 그 규격에 그 돌을 안 쓴다는 뜻이라 **0 줄을 만들지 않는다** — 0 은 「없음」과 + # 구별이 안 되고, 받는 쪽이 「값이 0 인 자재」로 읽는다. + if table["wedge_stone_m3_per_m2"] is None: + notes.append( + f"고임돌 계수가 품셈 표에 없습니다 — 뒷길이 {back_cm}㎝" + + (f" · {kind_label}" if kind_label else "") + + " 칸이 「-」입니다" + ) + else: + components.append( + Component( + "고임돌", + "㎥", + masonry_area * _num(table["wedge_stone_m3_per_m2"]), + DESTINATION["고임돌"], + f"돌쌓기 × {table['wedge_stone_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)" + + (f" · {kind_label}" if kind_label else ""), + ) + ) stone_ton = table["stone_ton_per_m2"] if stone_ton is None: @@ -356,13 +464,19 @@ def stone_masonry( ) ) - # 막자갈 = 입적 − (면적 × 뒷길이 × 2/3 + 고임돌). 실무 식 그대로. - wedge = masonry_area * _num(table["wedge_stone_m3_per_m2"]) - rubble = volume - (masonry_area * (back_cm / 100.0) * 2.0 / 3.0 + wedge) + # 막자갈 = 입적 − (면적 × 뒷길이 × 뒤채움몫 + 고임돌). + # 뒤채움 몫은 **돌 종류로 갈린다** — 깬돌·잡석 1/2 · 야면석 1/3 (교본 7-3). + wedge = masonry_area * _num(table["wedge_stone_m3_per_m2"]) # None 이면 0 — 막자갈에서 안 뺌 + rubble = volume - (masonry_area * (back_cm / 100.0) * body_ratio + wedge) if rubble > 0: components.append( Component( - "막자갈", "㎥", rubble, DESTINATION["막자갈"], "입적 − (면적×뒷길이×2/3 + 고임돌)" + "막자갈", + "㎥", + rubble, + DESTINATION["막자갈"], + f"입적 − (면적×뒷길이×{body_ratio:.4g} + 고임돌)" + + (f" · {kind_label}" if kind_label else ""), ) ) diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index bcae977f..fc4aeaff 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -83,6 +83,18 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse: settings, project_root = await _project_settings(project_id) plan = await _stored_haul_plan(project_id, route_id) haul = build_haul_table(plan) + # 배수관 연장 — B06 이 측점 `design.pipe_length_m` 에 남긴 값. **여기서 짓지 않는다.** + # 인계가 관 줄을 세울 때 쓴다. 단면을 두 번 읽지 않으려고 이 응답에 실어 보낸다. + table["pipe_lengths"] = [ + { + "chainage_m": row.get("chainage_m"), + "pipe_length_m": (row.get("design") or {}).get("pipe_length_m"), + } + for row in designs + if isinstance(row, dict) and (row.get("design") or {}).get("pipe_length_m") is not None + ] + # 횡단이 선 측점 목록 — 관 줄이 「길이가 없음」과 「횡단 자체가 없음」을 가르는 데 쓴다. + table["section_chainages"] = [row.get("chainage_m") for row in designs if isinstance(row, dict)] table["haul"] = haul # 운반계획은 [저장]·[확정]에서 정본에 남는 값이다 — 아직 없으면 빈 표가 정직하다. table["haul_available"] = bool(plan) diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index aee5eb14..b95b5d64 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -15,7 +15,9 @@ from __future__ import annotations +import json import logging +from pathlib import Path from typing import Any from uuid import UUID @@ -25,7 +27,7 @@ from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_Profile.B05_Profile_Structures_Repository import load_structures from B05_Profile.B05_Profile_Structures_Schema import structure_type_map -from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, summarize +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, load_mapping, summarize from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table as build_material_table from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table from common_util.common_util_project_settings import ( @@ -35,6 +37,7 @@ from common_util.common_util_project_settings import ( rock_method, ) from common_util.common_util_storage import resolve_stored_project_path +from common_util.common_util_structure_lengths import structure_lengths from config.config_db import run_with_connection logger = logging.getLogger(__name__) @@ -66,6 +69,11 @@ def _collect_structures( if definition.reference_only: skipped.append(f"{definition.name}: 전문 상세설계 대상 — 배치까지만") continue + if definition.group == "B": + # ⚠ B군(종단배수)은 **연장표**로 간다 — `common_util_structure_lengths` 가 + # 겹친 구간을 합쳐 주기 때문이다. 구조물별로 세면 겹친 구간을 두 번 센다. + # 여기서 빼지 않으면 **같은 시설이 두 줄로** 나간다. + continue targets.append(payload) return targets, names, sorted(set(skipped)) @@ -161,6 +169,14 @@ async def get_handoff(project_id: UUID) -> JSONResponse: material_table=material_table, # 준비공·사방공 — 값이 서는 줄도, 못 내는 줄도 함께 넘긴다(빼면 빠진 줄이 안 보임). preparation_table=earthwork.get("preparation"), + # B군 종단배수 — 겹침을 합친 연장. 그 규칙이 이미 그 함수에 있어 두 벌로 안 짠다. + length_table=[row for row in structure_lengths(project_root) if row.get("group") == "B"], + # 배수관 — 관 정본은 `pipe_points.json`, 연장은 측점 `design.pipe_length_m` 다. + pipe_table=_pipe_table( + project_root, + earthwork.get("pipe_lengths") or [], + earthwork.get("section_chainages") or [], + ), ground_class_set=settings.get("rock_class_set"), ground_classes=rock_classes(settings), ground_methods={name: rock_method(settings, name) for name in rock_classes(settings)}, @@ -173,6 +189,47 @@ async def get_handoff(project_id: UUID) -> JSONResponse: return JSONResponse(content=handoff) +def _pipe_table( + project_root: str, + pipe_lengths: list[dict[str, Any]], + section_chainages: list[Any] | None = None, +) -> dict[str, Any]: + """배수관 표 — 정본 셋을 읽어 잇는다. 못 읽으면 **빈 표**(줄이 안 서는 것이 정직하다). + + ⚠ 관 정본은 `structures.json` 이 아니라 `pipe_points.json` 이다 + (레지스트리 `pipe` 타입이 `managed_by: pipe_points`). + """ + from B08_Quantity.B08_Quantity_Engine_Pipe import build_rows as build_pipe_rows + from common_util.common_util_drainage_pipes import pipe_points_path_in + + path = pipe_points_path_in(Path(project_root)) + if not path.is_file(): + return {"rows": [], "notes": [], "pipe_count": 0, "ready_count": 0, "length_total_m": 0.0} + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + logger.exception("B08 관 지점 읽기 실패: %s", path) + return { + "rows": [], + "notes": ["관 지점 파일을 읽지 못했습니다"], + "pipe_count": 0, + "ready_count": 0, + "length_total_m": 0.0, + } + points = payload.get("points") or payload.get("items") or [] + # 토적표 라우터가 실어 준 모양을 엔진이 읽는 모양으로 옮긴다. + designs = [ + {"chainage_m": row.get("chainage_m"), "design": {"pipe_length_m": row.get("pipe_length_m")}} + for row in pipe_lengths + ] + return build_pipe_rows( + points, + designs, + (load_mapping().pipe or {}), + [float(x) for x in (section_chainages or []) if x is not None], + ) + + async def _earthwork_tables(project_id: UUID) -> dict[str, Any]: """토적표 라우터가 만든 집계·운반 표를 얻는다. 노선이 없으면 빈 값.""" from B08_Quantity.B08_Quantity_Router_Earthwork import ( diff --git a/common_util/common_util_culvert_sets.ts b/common_util/common_util_culvert_sets.ts index 368d7af2..1b67957e 100644 --- a/common_util/common_util_culvert_sets.ts +++ b/common_util/common_util_culvert_sets.ts @@ -331,7 +331,13 @@ export function attachCulvertSets( let reach = CHAINAGE_TOLERANCE_M; if (SPAN_LINKED_TYPES.has(String(spec.type))) reach += (num(spec.span_m, 0) ?? 0) / 2; if (Math.abs(chainage - pipeChainage) <= reach) { - section[SECTION_KEYS[String(spec.type)] ?? "culvert"] = spec; + // ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08, 짝: 파이썬 + // `attach_culvert_sets`). 세트는 폭의 절반까지 옆 측점에도 붙으므로, 이것이 없으면 + // 소비처가 「소유 측점」을 못 가려 **같은 시설을 여러 측점에서 센다**. + section[SECTION_KEYS[String(spec.type)] ?? "culvert"] = { + ...spec, + chainage_m: pipeChainage, + }; attached += 1; break; } diff --git a/common_util/common_util_structure_lengths.py b/common_util/common_util_structure_lengths.py index 43c617f8..bb6e45b7 100644 --- a/common_util/common_util_structure_lengths.py +++ b/common_util/common_util_structure_lengths.py @@ -23,14 +23,17 @@ from B05_Profile.B05_Profile_Structures_Schema import structure_type_map PENDING_TYPE_IDS: frozenset[str] = frozenset() -def _merge(spans: list[tuple[float, float]]) -> float: - """겹치는 구간을 합쳐 실제 덮인 길이를 낸다. +def _merge(spans: list[tuple[float, float]]) -> list[tuple[float, float]]: + """겹치는 구간을 **합쳐** 실제 덮인 구간 목록을 낸다. 같은 시설을 겹치게 두 번 넣으면 단순 합은 그 구간을 **두 번 센다**. 연장은 「덮인 길이」라 겹침을 지우는 쪽이 맞다. 원래 합(`raw_length_m`)도 함께 내보내므로 입력이 겹쳤다는 사실은 숨지 않는다. + + 합친 **구간 자체**를 돌려준다 — 길이만 내면 산출근거에 「어디부터 어디까지」를 못 적는다 + (2026-09-08 B08 창 요청). 길이는 부르는 쪽이 이 목록에서 더한다. """ - total = 0.0 + merged: list[tuple[float, float]] = [] current_start: float | None = None current_end = 0.0 for start, end in sorted(spans): @@ -40,11 +43,11 @@ def _merge(spans: list[tuple[float, float]]) -> float: if start <= current_end: current_end = max(current_end, end) continue - total += current_end - current_start + merged.append((current_start, current_end)) current_start, current_end = start, end if current_start is not None: - total += current_end - current_start - return total + merged.append((current_start, current_end)) + return merged def structure_lengths(project_root: str | Path) -> list[dict[str, Any]]: @@ -78,6 +81,7 @@ def structure_lengths(project_root: str | Path) -> list[dict[str, Any]]: rows: list[dict[str, Any]] = [] for type_id, count in counts.items(): entries = spans[type_id] + merged = _merge(entries) rows.append( { "type_id": type_id, @@ -85,9 +89,14 @@ def structure_lengths(project_root: str | Path) -> list[dict[str, Any]]: "name": types[type_id].name, "count": count, # 겹침을 지운 실제 연장 — 수량서에 쓸 값. - "length_m": round(_merge(entries), 2), + "length_m": round(sum(end - start for start, end in merged), 2), # 입력한 구간 길이의 단순 합 — 위와 다르면 구간이 겹쳐 있다는 뜻. "raw_length_m": round(sum(end - start for start, end in entries), 2), + # 겹침을 지운 **구간 목록**(누가거리 m) — 산출근거에 「어디부터 어디까지」를 + # 적는 자리다. 길이는 이 목록의 합과 같다(2026-09-08 B08 창 요청). + "spans": [ + {"start_m": round(start, 2), "end_m": round(end, 2)} for start, end in merged + ], } ) rows.sort(key=lambda row: (row["group"], row["name"])) diff --git a/resources/data_masonry/stone_kind_2026-01-01.json b/resources/data_masonry/stone_kind_2026-01-01.json new file mode 100644 index 00000000..3821fa2a --- /dev/null +++ b/resources/data_masonry/stone_kind_2026-01-01.json @@ -0,0 +1,164 @@ +{ + "schema_version": "1.0", + "dataset_id": "stone_kind_unit", + "effective_date": "2026-01-01", + "note": "돌쌓기 계수는 **돌 종류로 갈린다**. 지금까지 한 벌(깬돌 계열)로만 돌고 있었다 — 2026-09-08 지식DB 대조에서 드러났다.", + "why": "품셈 13-4-3(고임돌)은 돌 종류로 **네 줄**, 13-4-4 [주]①(채움 콘크리트)은 **두 줄**, 교본 7-3 뒤채움은 **두 값**이다. 세 곳이 같은 축인데 우리는 축 없이 하나로 돌고 있었다.", + "option_key": "stone_kind", + "option_note": "레지스트리 `masonry_wet`·`masonry_dry` 의 옵션. 2026-09-08 랩탑 창이 만들었고 키 이름을 두 창이 미리 맞췄다(`back_len_cm` 이름 어긋남 사고를 되풀이하지 않으려고).", + "sources": { + "forest_13_4_3": { + "doc": "산림사업 표준품셈 13-4-3 고임돌 소요량 (단위: ㎥/㎡당)", + "note": "돌 종류 네 줄. 「-」는 그 규격에 그 돌을 안 쓴다는 뜻이라 지어내지 않는다." + }, + "forest_13_4_4_note1": { + "doc": "산림사업 표준품셈 13-4-4 [주]① 찰쌓기 및 찰붙임의 채움 콘크리트 소요량 (㎥/㎡당)", + "note": "두 줄뿐 — 야면석·호박돌(뒷길이의 33.3 %) / 깬잡석·깬돌·견치돌(45 %)." + }, + "textbook_7_3": { + "doc": "임도기술교본 7장 3절 (지식DB 돌쌓기.md §4 · 흙막이.md §4)", + "quote": "뒤채움 = 돌쌓기 표면적 × 뒷길이 × (깬돌·잡석 1/2, 야면석 1/3)" + }, + "construction_reference": { + "doc": "건설공사 표준품셈 [참고자료] 돌쌓기 규격별 소요량", + "note": "⚠ **돌 종류로 안 갈리는 한 벌**이고, 그 값이 우리가 지금까지 쓰던 값이다(고임돌 = 깬돌 줄 · 채움 콘크리트 0.16/0.20/0.25/0.27). 2026 개정안이 이 참고자료를 **「삭제 검토」** 로 두었다." + } + }, + "policy": { + "primary": "산림사업이므로 **산림품셈이 1차 적용**(CLAUDE.md 3장). 건설품셈 값은 미지정일 때의 기본값 근거로만 쓴다.", + "unset_is_flagged": "돌 종류를 안 고르면 **건설품셈 참고자료 한 벌**로 돌되 그 사실을 알린다 — 값을 못 낸다고 멈추면 이미 저장된 프로젝트가 통째로 빈다.", + "no_interpolation": "표에 없는 뒷길이·「-」 칸은 **지어내지 않는다.**" + }, + "kinds": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "wedge_stone_m3_per_m2": { + "note": "고임돌 — 품셈 13-4-3. 키는 뒷길이(㎝). `null` 은 원문 「-」.", + "야면석·호박돌": { + "25": 0.06, + "30": 0.07, + "35": 0.09, + "45": 0.11, + "55": 0.14, + "60": 0.15, + "75": null + }, + "깬잡석": { + "25": 0.09, + "30": 0.11, + "35": 0.13, + "45": 0.16, + "55": 0.19, + "60": 0.21, + "75": 0.26 + }, + "깬돌": { + "25": null, + "30": 0.1, + "35": 0.12, + "45": 0.15, + "55": 0.18, + "60": 0.2, + "75": 0.25 + }, + "견치돌": { + "25": null, + "30": null, + "35": 0.12, + "45": 0.15, + "55": 0.18, + "60": 0.2, + "75": 0.25 + } + }, + "fill_concrete_m3_per_m2": { + "note": "채움 콘크리트 — 품셈 13-4-4 [주]①. **두 줄뿐**이라 견치돌·깬잡석·깬돌이 한 값을 쓴다.", + "야면석·호박돌": { + "25": 0.08, + "30": 0.1, + "35": 0.12, + "45": 0.15, + "55": 0.18, + "60": 0.2, + "75": 0.25 + }, + "깬잡석": { + "25": 0.11, + "30": 0.14, + "35": 0.16, + "45": 0.2, + "55": 0.25, + "60": 0.27, + "75": 0.34 + }, + "깬돌": { + "25": 0.11, + "30": 0.14, + "35": 0.16, + "45": 0.2, + "55": 0.25, + "60": 0.27, + "75": 0.34 + }, + "견치돌": { + "25": 0.11, + "30": 0.14, + "35": 0.16, + "45": 0.2, + "55": 0.25, + "60": 0.27, + "75": 0.34 + } + }, + "backfill_ratio_of_back_length": { + "note": "뒤채움이 뒷길이에서 차지하는 몫 — 교본 7-3. 나머지가 잡석(막자갈)이 된다.", + "야면석·호박돌": 0.3333333333333333, + "깬잡석": 0.5, + "깬돌": 0.5, + "견치돌": 0.5 + }, + "fallback": { + "note": "돌 종류 미지정일 때 — 건설품셈 [참고자료] 돌쌓기 규격별 소요량 한 벌(고임돌은 「깬돌」 열). ⚠ 일곱 규격을 다 싣는다 — 앞서 네 칸만 들고 있어 25·30·75 를 가까운 칸으로 **접어 올리고** 있었다.", + "wedge_stone_m3_per_m2": { + "25": null, + "30": 0.1, + "35": 0.12, + "45": 0.15, + "55": 0.18, + "60": 0.2, + "75": 0.25 + }, + "fill_concrete_m3_per_m2": { + "25": 0.11, + "30": 0.14, + "35": 0.16, + "45": 0.2, + "55": 0.25, + "60": 0.27, + "75": 0.34 + }, + "backfill_ratio_of_back_length": 0.3333333333333333, + "backfill_note": "⚠ 이 값은 **뒤채움이 차지하는 몫**이다. 우리 막자갈 식은 입적에서 **돌 몸통**을 빼므로 코드가 `1 − 이 값` 을 쓴다. 종전 하드코딩 2/3 이 곧 야면석(1 − 1/3)이었다.", + "message": "돌 종류를 안 정해 건설품셈 참고자료 값으로 섰습니다 — 구조물 상세 입력에서 고르면 산림품셈 계수로 바뀝니다" + }, + "not_here": { + "note": "이 표가 정하지 않는 것.", + "items": [ + "돌중량(ton/㎡) — 우리 값 0.575/0.88/1.10 은 **실무 관측**(울진 라이브러리)이다. 품셈은 「돌의 중량은 형상·종류·부피를 고려하고 건설품셈 1-3-3 재료의 단위중량을 참고하여 계상한다」로만 두어 **표를 안 준다.** 돌 종류 축과 짝이 맞는지 사용자 확정 대기.", + "전면면적이 정면적인지 비탈면적인지 — 2026 개정안 [주] 「시공량은 석재의 **전면면적**(㎡)을 기준한다」로 용어만 확인됐다." + ] + }, + "back_lengths_cm": [ + 25, + 30, + 35, + 45, + 55, + 60, + 75 + ], + "no_folding": "⚠ 표에 없는 뒷길이를 **가까운 칸으로 접지 않는다.** 접으면 40㎝ 가 45㎝ 계수로 조용히 돌고 999㎝ 도 60㎝ 로 접혔다(2026-09-08 실측). 표에 없으면 **계수가 없다고 드러낸다.**" +} diff --git a/resources/data_structure_unit/structure_unit_observed_2026-01-01.json b/resources/data_structure_unit/structure_unit_observed_2026-01-01.json index a0f8a6e8..05b9fb3b 100644 --- a/resources/data_structure_unit/structure_unit_observed_2026-01-01.json +++ b/resources/data_structure_unit/structure_unit_observed_2026-01-01.json @@ -278,6 +278,13 @@ "height_m": 1.6 }, "why": "울진 2공구에 H=1.6 이 실재하나 수치가 라이브러리에 없음. H=2.0 값을 비례로 줄이지 않음 — 기초·벽체는 높이에 비례하지 않음." + }, + { + "type_id": "retaining_wall", + "about": "기초잡석", + "why": "⚠ **우리 표가 빠뜨린 것이 아니라 원문에 없음**(2026-09-08 전수 확인). 울진 라이브러리 §7 「반중력식옹벽 H=2.0」 원문은 「콘크리트 1.35㎥(기초 0.75+벽체 0.60) + 버림 0.15㎥, 유로폼 3.20㎡, 기초 거푸집 0.6㎡, 물빼기 파이프 Ø50 0.32m, 철근 D13 13.45㎏ + D16 30.42㎏」이 전부다 — 기초잡석·터파기·되메우기·잔토가 다 없다.", + "note": "기초잡석이 있는 시트는 **§1 관보호공 날개벽**뿐이다(T=0.2 · Ø800 A-TYPE 1.15㎥/개소 등). 옹벽에 그 값을 옮겨 쓰면 **다른 구조물 값을 갖다 쓰는 것**이라 하지 않는다.", + "needs": "옹벽 기초잡석을 계상할지 · 하면 두께를 얼마로 볼지 — 사용자 확정. 품셈 12-25 는 「기초잡석 운반·부설·다짐」 품만 주고 **두께를 정하지 않는다**(㎥당)." } ] }, diff --git a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json index 7481524e..c041c79a 100644 --- a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json +++ b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json @@ -150,6 +150,24 @@ }, "class_note": "메/찰(`bond`)은 **공종 자체가 갈리는 의미 판정**이라 여기서 고른다. 직경 갈래는 `variant_value` 로 원본값만 보낸다 — 원문이 물결표를 섞어 써서.", "variant_axis": "stone_cm" + }, + { + "type_id": "ditch_ridge", + "work_item_code": "FP-12-09-02", + "master_name": "측구 > 산마루 측구", + "note": "품셈 12-9-2 · 밑수 1 m — 구조물 연장(m)이 그대로 수량이다. 2026-09-08 V-3 확인에서 이었다(그전에는 매핑이 없어 연장은 오는데 공종코드가 비어 있었다)." + }, + { + "type_id": "ditch_berm", + "work_item_code": "FP-12-09-03", + "master_name": "측구 > 소단 측구", + "note": "품셈 12-9-3 · 밑수 1 m" + }, + { + "type_id": "underdrain", + "work_item_code": "FP-12-10", + "master_name": "맹암거", + "note": "품셈 12-10 · 밑수 1 m" } ], "pending_user": { @@ -171,6 +189,18 @@ "FP-09-05 발파암" ], "why": "설계자가 넣는 암 갈래 이름(풍화암·연암·보통암·경암)이 리핑이냐 발파냐를 말하지 않음. 갈래마다 시공법을 지정하는 칸이 필요함" + }, + { + "type_id": "chute", + "name": "도수로·산비탈수로", + "why": "품셈 12장 측구 계열(12-9-1 L형 · 12-9-2 산마루 · 12-9-3 소단)과 12-10 맹암거에 **해당 공종이 없음**(2026-09-08 마스터 전수). 「수로」로 검색해도 나오는 것은 그 셋뿐임.", + "needs": "어느 공종으로 볼지 사용자 확정 — 또는 별도 표준도·일위대가" + }, + { + "type_id": "slope_drain", + "name": "절토사면 배수로", + "why": "위와 같음 — 품셈에 그 이름의 공종이 없음.", + "needs": "어느 공종으로 볼지 사용자 확정" } ] }, @@ -277,5 +307,27 @@ ], "b09_does": "원문 표기(물결표·공백·괄호)를 흡수해 자기 키로 옮긴다. 「…㎝ 이하」 구간 나누기도 그쪽 몫 — 그 구간이 **품셈 표의 구조**이기 때문." }, - "masonry_class_reference": "resources/data_masonry/masonry_class_2026-01-01.json — ⚠ 이제 **참고용**이다. 서브 판정과 어긋나면 그것이 곧 신호다." + "masonry_class_reference": "resources/data_masonry/masonry_class_2026-01-01.json — ⚠ 이제 **참고용**이다. 서브 판정과 어긋나면 그것이 곧 신호다.", + "pipe": { + "note": "배수관(횡단배수관) — **관종으로 공종이 갈린다.** 관은 `structures.json` 이 아니라 `pipe_points.json` 이 정본이고(레지스트리 `pipe` 타입이 `managed_by: pipe_points`), 연장은 B06 횡단이 서버 Node 로 계산해 측점 `design.pipe_length_m` 로 남긴다(2026-09-08 랩탑 창). B08 은 그 셋을 잇기만 한다.", + "kind_codes": { + "파형강관": "FP-12-11-03", + "흄관": "FP-12-11-02", + "VR관": "FP-12-11-01" + }, + "kind_option_key": "pipe_kind", + "default_kind": "파형강관", + "default_is_user_confirmed": true, + "default_note": "레지스트리 `pipe` 옵션의 기본값이며 **2026-08-17 사용자 확정**임. 그래도 저장값이 비어 있으면 「관종 미지정」으로 드러내고 기본값으로 돈다는 사실을 함께 싣는다(암 시공법과 같은 처리).", + "unit": "m", + "length_key": "pipe_length_m", + "diameter_option_key": "pipe_diameter_mm", + "variant_axis": "pipe_diameter_mm", + "facility_rule": "⚠ `pipe_points.json` 은 **계곡 통과 시설 전부의 정본**이다(배관·BOX암거·물넘이·세월교·독립 기슭막이). `facility` 가 `pipe` 인 점만 배관이다 — `pipe_diameter_mm` 유무로 가르면 **관경 미지정 관을 놓친다**(2026-09-08 랩탑 창).", + "revetment_note": "⚠ 유출·유입부 기슭막이는 **관 옵션(`inlet_revet_*`·`outlet_revet_*`)이 정본**이다. 레지스트리 `revetment` 타입이 `managed_by: pipe_points` 라 구조물 목록에서 빠졌으므로 **구조물 쪽으로 또 세지 않는다**(2026-08-28 이관).", + "not_ready": { + "흄관 밑수 두 벌": "`FP-12-11-02` 는 밑수가 「1 m」와 「1 개소」 두 벌이다(표가 둘). B09 가 `#갈래` 로 두 표를 각각 세우므로 B08 은 `variant_value` 로 어느 쪽인지 보내면 된다.", + "터파기·되메우기": "⚠ 관 부설과 터파기·되메우기가 각각 오면 **같은 굴착을 두 번 셀 수 있다**(B09 ㉡ 가드). 관 줄에는 지금 터파기를 붙이지 않는다." + } + } } diff --git a/resources/knowledge/03_미결_및_확인사항.md b/resources/knowledge/03_미결_및_확인사항.md index b0a4f55d..16ad066a 100644 --- a/resources/knowledge/03_미결_및_확인사항.md +++ b/resources/knowledge/03_미결_및_확인사항.md @@ -12,6 +12,7 @@ | 19 | [공통] ★STmate 실기 환경 확보 (STC 입출력 검증 전제) | STC = 발주처 제출 최종물, Aislo import/export 필수 요구사항(사용자 확정 2026-08-15). 포맷 명세는 [original/원가계산/STmate/](original/원가계산/STmate/) 정리 완료. **잔여 = STmate 실행 환경** — 생성·변형 STC를 열기→조회→수정→재계산→저장→재개방까지 검증해야 빈 테이블(BDQTY·SYSINFO) export 허용 여부·신규생성 규칙 확정 가능. 환경 없으면 [STC_왕복검증](original/원가계산/STmate/STC_왕복검증.md) R1~R9 실행 불가 | 선택지: ① STmate 보유·구매 ② 체험판 ③ 설계사무소 협조. **개발 단계 사용자 결정** | [STmate 포맷 명세](original/원가계산/STmate/_meta.md), [STC_난독화_상태](original/원가계산/STmate/STC_난독화_상태.md) | 미결 — 사용자 결정 대기 (2026-08-15) | | 20 | [임도] 유토곡선 평형선·극값 임계·balloon 표기 기준 | 법령·행정규칙·표준시방서·교본에 **세부 규칙 없음**(2026-09-03 확인). KDS 44 30 00 2.3.3은 「구간별 균형 배분·운반거리 최소화」까지만 규정. 실무 도면(`유토곡선.dwg`)은 압축 DWG라 표기 문자열 추출 불가 — 표기 관행은 도면 육안 확인 필요. 실무 곡선 원본 6건 관측치는 문서에 정리 | 실무 유토곡선 도면 육안 확인 후 사용자 협의로 확정 | [유토곡선_토량배분](technical_info/01_임도/03_계산정보/유토곡선_토량배분.md) §2·§3 | 미결 — 근거 공백 (2026-09-03) | | 21 | [임도] 토공 운반장비 한계거리 — 도자 60m vs 70m | 실무 `EARTH.DAT` 헤더 6건 전부 `무대 20.0 / 도자 60.0`. Aislo 현행 `EARTHWORK_HAUL_EQUIPMENT_LIMITS_M`은 20/70. 현행 법령·품셈에 장비별 한계거리 규정 없음 — 두 값 모두 후보 | 개발 단계 사용자 협의로 채택값 확정 | [유토곡선_토량배분](technical_info/01_임도/03_계산정보/유토곡선_토량배분.md) §4 | 미결 — 사용자 결정 대기 (2026-09-03) | +| 22 | [임도] 옆도랑(측구) 사다리꼴 단면 — 저폭 근거 공백 | 별표2 근거는 **너비 0.5~1 m · 깊이 30 ㎝ 내외**까지뿐([측구 §1](technical_info/01_임도/02_상세설계/측구.md))이고 **저폭 수치는 원문 전수에 없음**. Aislo 현행 0.9/0.3/0.3(상단폭·저폭·깊이)은 표준횡단도면 판독값이며 측벽이 45°라 [통수단면 §4](technical_info/01_임도/03_계산정보/통수단면.md) 경제 단면(사다리형 측벽 **60°**, B≒1.155H)과 어긋남 — 두 축 중 무엇을 기본으로 둘지 미정 | 개발 단계 사용자 협의로 채택 축 확정 (도면 판독값 vs 경제 단면식) | [측구](technical_info/01_임도/02_상세설계/측구.md) §1·[통수단면](technical_info/01_임도/03_계산정보/통수단면.md) §4 | 미결 — 사용자 결정 대기 (2026-09-08) | ## §2 확인사항 — 임도교본(2019) vs 사방교본(2023) 충돌 리스트 (2026-08-12, 최종검증에서 도출) diff --git a/resources/knowledge/original/원가계산/건설공사_표준품셈/pic/2026년_건설공사_표준품셈_개정사항_p35_1.png b/resources/knowledge/original/원가계산/건설공사_표준품셈/pic/2026년_건설공사_표준품셈_개정사항_p35_1.png new file mode 100644 index 00000000..9d775dad Binary files /dev/null and b/resources/knowledge/original/원가계산/건설공사_표준품셈/pic/2026년_건설공사_표준품셈_개정사항_p35_1.png differ diff --git a/resources/knowledge/original/원가계산/건설공사_표준품셈/pic/2026년_건설공사_표준품셈_개정사항_p39_1.png b/resources/knowledge/original/원가계산/건설공사_표준품셈/pic/2026년_건설공사_표준품셈_개정사항_p39_1.png new file mode 100644 index 00000000..af34ae6d Binary files /dev/null and b/resources/knowledge/original/원가계산/건설공사_표준품셈/pic/2026년_건설공사_표준품셈_개정사항_p39_1.png differ diff --git a/resources/knowledge/original/원가계산/건설공사_표준품셈/pic/2026년_건설공사_표준품셈_개정사항_p64_1.png b/resources/knowledge/original/원가계산/건설공사_표준품셈/pic/2026년_건설공사_표준품셈_개정사항_p64_1.png new file mode 100644 index 00000000..73c309b9 Binary files /dev/null and b/resources/knowledge/original/원가계산/건설공사_표준품셈/pic/2026년_건설공사_표준품셈_개정사항_p64_1.png differ diff --git a/resources/knowledge/original/원가계산/건설공사_표준품셈/pic/2026년_건설공사_표준품셈_개정사항_p98_1.png b/resources/knowledge/original/원가계산/건설공사_표준품셈/pic/2026년_건설공사_표준품셈_개정사항_p98_1.png new file mode 100644 index 00000000..e88ed606 Binary files /dev/null and b/resources/knowledge/original/원가계산/건설공사_표준품셈/pic/2026년_건설공사_표준품셈_개정사항_p98_1.png differ