diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index f7f83289..4a492dcb 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -33,6 +33,13 @@ export interface StructureOptionField { phase?: "b05" | "detail"; } +/** 구조물 배치 폼을 어느 화면이 쓰는가 — B05 는 유무·종류·위치만, **B06/B07 은 상세 + * 치수까지** 받는다(2026-08-17 사용자 확정). 부르는 쪽이 정한다. */ +export interface StructuresSectionOptions { + /** 참이면 `phase: "detail"` 옵션(뒷길이·돌규격·형식 …)도 폼에 그린다. */ + includeDetail?: boolean; +} + /** B05 배치 폼에 그릴 옵션인가 — 상세(detail)는 B06/B07 몫이라 숨긴다. */ export function isB05Option(option: StructureOptionField): boolean { return (option.phase ?? "b05") !== "detail"; diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index 3abed2df..d1e4ba90 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -956,6 +956,15 @@ "required": true, "phase": "detail" }, + { + "key": "bond", + "label": "쌓기 방식", + "input": "select", + "choices": ["메쌓기", "찰쌓기"], + "default": null, + "required": true, + "phase": "detail" + }, { "key": "side", "label": "설치 측", diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 6363fb7f..fe1ab070 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -18,6 +18,7 @@ import { isB05Option, structureAnchorM, type StructureInstance, + type StructuresSectionOptions, type StructurePlacement, type StructureType, } from "./B05_Profile_Api_Structures"; @@ -49,7 +50,10 @@ import type { StructuresSection, } from "./B05_Profile_UI_Structures_Panel_Types"; -export function createStructuresSection(callbacks: StructuresCallbacks): StructuresSection { +export function createStructuresSection( + callbacks: StructuresCallbacks, + sectionOptions: StructuresSectionOptions = {}, +): StructuresSection { // 폼 뼈대는 전용 조립기가 세운다(2026-09-03 · 700줄 제한) — 여기서는 값·검증·저장만. const form = buildStructuresForm({ getInterval: () => callbacks.getInterval(), @@ -294,8 +298,8 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu syncRangeDisplay(); } - /** 타입의 옵션 스키마대로 입력 칸을 다시 그린다 — 상세(detail) 옵션은 B06/B07 - * 몫이라 그리지 않는다(B05 = 유무·종류·위치 단계, 2026-08-17 사용자 확정). */ + /** 타입의 옵션 스키마대로 입력 칸을 다시 그린다. 상세(detail)는 `includeDetail` 인 화면 + * (B06/B07)에서만 — 안 받으면 뒷길이·돌규격·형식이 비어 **수량이 갈래를 못 고른다**. */ function renderOptionFields(values: Record = {}): void { const type = currentType(); // 여기도 배열을 갈아끼우지 않는다 — 조각들이 참조로 받아 두므로 새 배열로 @@ -303,7 +307,9 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu optionInputs.length = 0; optionRow.replaceChildren(); // 서브폼이 담당하는 타입(계곡 통과 시설·독립 기슭막이)은 여기서 그리지 않는다. - const visible = type && !facilityFormKind(type) ? type.options.filter(isB05Option) : []; + const all = sectionOptions.includeDetail === true; + const visible = + type && !facilityFormKind(type) ? type.options.filter((o) => all || isB05Option(o)) : []; if (!visible.length) { optionRow.hidden = true; syncOptionLock(); @@ -317,8 +323,11 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu // 빈("선택하세요") 항목은 두지 않는다 — 첫 항목이 곧 기본값이고, 기본값은 // 구조물별로 레지스트리에서 지정한다(2026-08-17 사용자 지시 1). const choices = option.choices.map((choice) => [choice, choice] as [string, string]); + // 기본값 없는 필수 항목은 **빈 칸으로** — 첫 항목을 슬쩍 고르면 근거 없는 값이 나간다. + const mustPick = option.required === true && (option.default ?? "") === ""; + if (mustPick) choices.unshift(["", "— 선택 —"]); input = select(choices); - input.value = String(preset || (option.choices[0] ?? "")); + input.value = String(preset || (mustPick ? "" : (option.choices[0] ?? ""))); } else if (option.input === "number") { input = numberInput("0.1", "0"); input.value = String(preset ?? ""); diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index a0152ed3..b3bb3fd4 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -234,67 +234,70 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc /** 종단 알약 레인에 올릴 목록 — 구조물 정본 + 관 정본(가상 구조물). */ const pushMarks = (): void => deps.onMarks?.([...structures, ...pipesToStructureMarks(pipeFacilities)], markTypes); - const section = createStructuresSection({ - onChange: (next) => { - structures = withLocalIds(next); - section.setStructures(structures); - pushMarks(); - if (deps.projectId) writePendingStructures(deps.projectId, structures); - deps.onStructuresChanged?.(); - }, - onSelect: (structure) => { - if (structure) deps.focusChainage(structureAnchorM(structure)); - }, - getInterval: deps.stationInterval, - onReveal: () => deps.reveal?.(), - onPipeAdd: () => showToast(PIPE_ADD_GUIDE, "error"), - // 값 수정은 B06에서도 받는다 — 조정창 구간값과 같은 저장 경로(캐시 예약 → - // [저장]·[확정])로 보낸다. 기준점 이동만 배수유역 재분할이 걸려 B05 몫이다. - onPipeUpdate: (fromChainageM, toChainageM, attributes) => { - // 위치가 "옮겨졌다"고 볼 기준은 관 매칭과 같은 0.51m다 — 폼의 측점 표기는 - // 0.1m로 반올림되므로 0.005m 기준으로 보면 값만 고쳐도 이동으로 잡힌다 - // (2026-08-29 사용자 보고: 높이만 바꿨는데 이동 안내가 떴다). - const moved = Math.abs(fromChainageM - toChainageM) > PIPE_MATCH_M; - if (moved) { - if (deps.movePipe) { - deps.movePipe(fromChainageM, toChainageM); - const hit = pipeFacilities.find( - (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M, - ); - if (hit) hit.chainage_m = toChainageM; - currentChainageM = toChainageM; - section.setPipeFacilities(pipeFacilities); - pushMarks(); - showToast(PIPE_MOVE_NOTICE, "success"); - } else { - showToast(PIPE_MOVE_GUIDE, "error"); + const section = createStructuresSection( + { + onChange: (next) => { + structures = withLocalIds(next); + section.setStructures(structures); + pushMarks(); + if (deps.projectId) writePendingStructures(deps.projectId, structures); + deps.onStructuresChanged?.(); + }, + onSelect: (structure) => { + if (structure) deps.focusChainage(structureAnchorM(structure)); + }, + getInterval: deps.stationInterval, + onReveal: () => deps.reveal?.(), + onPipeAdd: () => showToast(PIPE_ADD_GUIDE, "error"), + // 값 수정은 B06에서도 받는다 — 조정창 구간값과 같은 저장 경로(캐시 예약 → + // [저장]·[확정])로 보낸다. 기준점 이동만 배수유역 재분할이 걸려 B05 몫이다. + onPipeUpdate: (fromChainageM, toChainageM, attributes) => { + // 위치가 "옮겨졌다"고 볼 기준은 관 매칭과 같은 0.51m다 — 폼의 측점 표기는 + // 0.1m로 반올림되므로 0.005m 기준으로 보면 값만 고쳐도 이동으로 잡힌다 + // (2026-08-29 사용자 보고: 높이만 바꿨는데 이동 안내가 떴다). + const moved = Math.abs(fromChainageM - toChainageM) > PIPE_MATCH_M; + if (moved) { + if (deps.movePipe) { + deps.movePipe(fromChainageM, toChainageM); + const hit = pipeFacilities.find( + (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M, + ); + if (hit) hit.chainage_m = toChainageM; + currentChainageM = toChainageM; + section.setPipeFacilities(pipeFacilities); + pushMarks(); + showToast(PIPE_MOVE_NOTICE, "success"); + } else { + showToast(PIPE_MOVE_GUIDE, "error"); + } } - } - const patch = attributes.options; - if (!patch || !Object.keys(patch).length) return; - if (!deps.queuePipeOptions) { - showToast(PIPE_ADD_GUIDE, "error"); - return; - } - // 단 수는 옵션이자 **조작 채널**이다 — 조정창에서 세우던 그 자리로 보내야 - // 횡단도에 단이 선다(2026-08-30 사용자 지시 3: 배수관 측점과 동일하게). - // 옵션은 **옮기기 전 자리**로 예약한다 — 저장 시점의 관 목록이 그 자리 기준이다. - deps.queuePipeOptions(fromChainageM, patch as Record); - deps.applyPipeOptions?.(fromChainageM, patch as Record); - // 화면 캐시(목록·폼 재로드용)도 같이 맞춘다 — 저장 전에도 값이 유지된다. - const hit = pipeFacilities.find( - (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M, - ); - if (hit) hit.options = { ...(hit.options ?? {}), ...patch }; - section.setPipeFacilities(pipeFacilities); + const patch = attributes.options; + if (!patch || !Object.keys(patch).length) return; + if (!deps.queuePipeOptions) { + showToast(PIPE_ADD_GUIDE, "error"); + return; + } + // 단 수는 옵션이자 **조작 채널**이다 — 조정창에서 세우던 그 자리로 보내야 + // 횡단도에 단이 선다(2026-08-30 사용자 지시 3: 배수관 측점과 동일하게). + // 옵션은 **옮기기 전 자리**로 예약한다 — 저장 시점의 관 목록이 그 자리 기준이다. + deps.queuePipeOptions(fromChainageM, patch as Record); + deps.applyPipeOptions?.(fromChainageM, patch as Record); + // 화면 캐시(목록·폼 재로드용)도 같이 맞춘다 — 저장 전에도 값이 유지된다. + const hit = pipeFacilities.find( + (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M, + ); + if (hit) hit.options = { ...(hit.options ?? {}), ...patch }; + section.setPipeFacilities(pipeFacilities); + }, + onPipeRemove: () => showToast(PIPE_ADD_GUIDE, "error"), + onPipeSelect: (chainageM) => { + // 목록에서 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다. + writeStructurePick(deps.projectId, chainageM); + if (chainageM !== null) deps.focusChainage(chainageM); + }, }, - onPipeRemove: () => showToast(PIPE_ADD_GUIDE, "error"), - onPipeSelect: (chainageM) => { - // 목록에서 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다. - writeStructurePick(deps.projectId, chainageM); - if (chainageM !== null) deps.focusChainage(chainageM); - }, - }); + { includeDetail: true }, + ); // 고른 것 없이 좌측 폼을 만지면 값이 아무 데도 가지 않는다(패널 실시간 반영이 // 선택된 항목에만 걸린다) — 조용히 무시되던 자리라 이유를 알린다(2026-08-30 사용자: diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff.py b/B08_Quantity/B08_Quantity_Engine_Handoff.py index bdfcde7c..2ca0bba3 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff.py @@ -60,6 +60,38 @@ MASONRY_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry" MASONRY_PREFIX = "masonry_class_" +#: 목재공작물 구조 갈래표 — 품셈 13-13-1 [주]③ 이 **재료 구성**으로 가른다. +TIMBER_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_timber" +TIMBER_PREFIX = "timber_structure_class_" + + +def load_timber_table(path: Path | None = None) -> dict[str, Any]: + """목재공작물 갈래표. 파일이 없으면 빈 표 — 갈래가 안 붙고 그대로 드러난다.""" + target = path + if target is None: + files = sorted(TIMBER_DIR.glob(TIMBER_PREFIX + "*.json")) if TIMBER_DIR.is_dir() else [] + target = files[-1] if files else None + if target is None or not target.is_file(): + return {} + return json.loads(target.read_text(encoding="utf-8")) + + +def timber_class(type_id: str, table: dict[str, Any] | None = None) -> tuple[str | None, str, bool]: + """(갈래, 근거, 잠정인가). **잠정이면 그 사실을 숨기지 않는다.** + + ⚠ 갈래를 고르되 **드러낸다** — 임의로 고르고 조용히 넘어가면 미결을 숨기는 것이다. + 밑수 1㎥ 는 **목재 채적**이라 「1㎥에 건축목공 17인」이 말이 된다(원문 [주]③). + """ + found = table if table is not None else load_timber_table() + for row in (found or {}).get("type_map") or []: + if row.get("type_id") == type_id and row.get("class"): + basis = f"품셈 13-13-1 [주]③ 「{row.get('matched')}」" + if row.get("provisional"): + basis += f" · ⚠ 잠정 — {row.get('compare', '')}" + return str(row["class"]), basis, bool(row.get("provisional")) + return None, f"품셈 13-13-1 [주]③ 예시에 없는 공작물({type_id}) — 임의로 고르지 않음", False + + def load_masonry_table(path: Path | None = None) -> dict[str, Any]: """돌쌓기 갈래표. 파일이 없으면 빈 표 — 갈래가 안 붙고 그대로 드러난다.""" target = path diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation.py b/B08_Quantity/B08_Quantity_Engine_Preparation.py index 8c706584..35c64a9f 100644 --- a/B08_Quantity/B08_Quantity_Engine_Preparation.py +++ b/B08_Quantity/B08_Quantity_Engine_Preparation.py @@ -34,13 +34,59 @@ EROSION_CONTROL_TYPES = frozenset( } ) +#: 규준틀 — **품셈 원문이 개소 기준을 정해 둔다**(2026-09-07 ㉒ 에서 찾음). +#: 11-2 [주]① 「비탈길이 **10m 이상** **20m마다** 설치한다」 +#: 11-3 [주]① 「중심점에서 **성토 높이 5m 이상**에 설치한다」 +#: ⚠ 재료량은 [주]④ 「설계수량에 따른다」 — **개소만 내고 재료는 미확보**로 둔다. +BATTER_MIN_SLOPE_LENGTH_M = 10.0 +BATTER_INTERVAL_M = 20.0 +LEVEL_MIN_FILL_HEIGHT_M = 5.0 + STATUS_COUNTED_ELSEWHERE = "다른 표에서 이미 섬" STATUS_PENDING = "값을 낼 근거가 없음" STATUS_NOT_APPLICABLE = "해당 없음" STATUS_READY = "값 있음" -def preparation_rows(slope_totals: dict[str, float] | None = None) -> list[dict[str, Any]]: +def batter_frame_count(slope_rows: Iterable[dict[str, Any]]) -> tuple[int, list[str]]: + """비탈 규준틀 개소 — **비탈길이 10m 이상인 구간에서 20m마다**(품셈 11-2 [주]①). + + ⚠ 「10m 이상」은 **비탈길이**(사면길이) 조건이고 「20m마다」는 **노선 거리** 간격이다. + 둘을 섞지 않는다 — 사면이 긴 구간의 **연장**을 20m 로 나눈다. + """ + length_m = 0.0 + notes: list[str] = [] + for row in slope_rows: + lengths = row.get("lengths") or {} + # 그 측점의 사면길이는 계열마다 있으나 **가장 긴 것**으로 본다(같은 사면이다). + longest = max((float(v) for v in lengths.values()), default=0.0) + if longest >= BATTER_MIN_SLOPE_LENGTH_M: + length_m += float(row.get("distance_m") or 0.0) + if length_m <= 0: + return 0, ["비탈길이 10m 이상인 구간이 없어 비탈 규준틀이 서지 않음"] + count = int(length_m // BATTER_INTERVAL_M) + 1 + notes.append( + f"비탈길이 {BATTER_MIN_SLOPE_LENGTH_M:g}m 이상 구간 {length_m:g}m ÷ " + f"{BATTER_INTERVAL_M:g}m + 1 (품셈 11-2 [주]①)" + ) + return count, notes + + +def level_frame_count(slope_rows: Iterable[dict[str, Any]]) -> tuple[int, list[str]]: + """수평 규준틀 — **중심점 성토 높이 5m 이상**에 설치(품셈 11-3 [주]①). + + ⚠ 성토고는 사면표에 없다 — 그 값을 받기 전에는 **0 으로 때우지 않고 미확보**로 둔다. + """ + return 0, [ + "중심점 성토고 5m 이상 지점에 설치(품셈 11-3 [주]①)인데 성토고가 이 표에 없어 개소를 못 셈" + ] + + +def preparation_rows( + slope_totals: dict[str, float] | None = None, + slope_rows: Iterable[dict[str, Any]] = (), + topsoil_thickness_m: float | None = None, +) -> list[dict[str, Any]]: """준비공 줄 — 값이 서는 것과 안 서는 것을 **한 목록에** 낸다.""" slope = slope_totals or {} tree_area = float(slope.get("tree_removal_fill", 0.0)) + float( @@ -62,15 +108,7 @@ def preparation_rows(slope_totals: dict[str, float] | None = None) -> list[dict[ ), "work_item_code": None, }, - { - "group": "준비공", - "item": "표토제거", - "unit": "㎥", - "amount": None, - "status": STATUS_PENDING, - "reason": "면적은 사면적에서 나오나 **두께·대상 구간**이 설계로 안 정해져 있음 (품셈 9-15)", - "work_item_code": "FP-09-15", - }, + _topsoil_row(slope, topsoil_thickness_m), { "group": "준비공", "item": "제근·뿌리다듬기", @@ -80,18 +118,67 @@ def preparation_rows(slope_totals: dict[str, float] | None = None) -> list[dict[ "reason": "단위가 「개」(그루 수)인데 입목 본수를 들고 있지 않음 (품셈 9-20~21)", "work_item_code": "FP-09-21", }, + _batter_frame_row(list(slope_rows)), { "group": "준비공", - "item": "규준틀", + "item": "수평 규준틀", "unit": "개소", "amount": None, "status": STATUS_PENDING, - "reason": "개소 산정 기준(구조물·절성토 구간별 몇 개소)이 안 정해져 있음 (품셈 11-2)", - "work_item_code": "FP-11-02", + "reason": level_frame_count(())[1][0], + "work_item_code": "FP-11-03", }, ] +def _topsoil_row(slope: dict[str, float], thickness_m: float | None) -> dict[str, Any]: + """표토제거 — **두께는 품셈이 아니라 설계가 정한다**(9-15 [주]② 「T : 표토두께(m)」). + + ⚠ 두께를 안 넣으면 **0 으로 때우지 않고** 물량을 안 낸다. 대상 면적은 절·성토 사면적을 + 쓴다(사면 계열의 면고르기 면적과 같은 자리). + """ + area = float(slope.get("face_dressing_fill", 0.0)) + float(slope.get("face_dressing_cut", 0.0)) + if thickness_m is None or float(thickness_m) <= 0: + return { + "group": "준비공", + "item": "표토제거", + "unit": "㎥", + "amount": None, + "status": STATUS_PENDING, + "reason": ( + "표토 두께가 아직 입력되지 않았습니다 — 품셈 9-15 [주]② 가 두께를 " + "「공식의 입력 변수(T)」로 두어 **품셈이 정하는 값이 아닙니다**. " + f"산출 조건에서 두께를 넣으면 값이 섭니다 (대상 면적 {area:,.1f}㎡)" + ), + "reference_amount": area, + "work_item_code": "FP-09-15", + } + thickness = float(thickness_m) + return { + "group": "준비공", + "item": "표토제거", + "unit": "㎥", + "amount": area * thickness, + "status": STATUS_READY, + "reason": f"사면적 {area:,.1f}㎡ × 두께 {thickness:g}m (품셈 9-15)", + "work_item_code": "FP-09-15", + } + + +def _batter_frame_row(slope_rows: list[dict[str, Any]]) -> dict[str, Any]: + """비탈 규준틀 한 줄. **개소는 원문 기준으로 서고 재료는 미확보**다.""" + count, notes = batter_frame_count(slope_rows) + return { + "group": "준비공", + "item": "비탈 규준틀", + "unit": "개소", + "amount": float(count) if count else None, + "status": STATUS_READY if count else STATUS_PENDING, + "reason": ("; ".join(notes) + " · 재료량은 품셈 11-2 [주]④ 「설계수량에 따른다」라 미확보"), + "work_item_code": "FP-11-02", + } + + def erosion_rows(structures: Iterable[dict[str, Any]] = ()) -> list[dict[str, Any]]: """사방공 줄 — 이 노선에 사방 시설이 **있을 때만** 값이 선다.""" found = sorted( @@ -130,9 +217,13 @@ def erosion_rows(structures: Iterable[dict[str, Any]] = ()) -> list[dict[str, An def build_table( slope_totals: dict[str, float] | None = None, structures: Iterable[dict[str, Any]] = (), + slope_rows: Iterable[dict[str, Any]] = (), + topsoil_thickness_m: float | None = None, ) -> dict[str, Any]: """화면·인계가 그대로 쓰는 모양. **못 서는 줄도 목록에 남는다.**""" - rows = preparation_rows(slope_totals) + erosion_rows(structures) + rows = preparation_rows(slope_totals, slope_rows, topsoil_thickness_m) + erosion_rows( + structures + ) return { "columns": ["구분", "공종", "단위", "수량", "상태", "사유"], "rows": rows, diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index e30c9e13..b6eb40d6 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -100,7 +100,10 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse: ) # 준비공·사방공 — 못 서는 줄도 사유와 함께 남긴다(빈 표는 「빠뜨림」과 구별이 안 됨). table["preparation"] = build_preparation_table( - slope.get("totals") or {}, await _route_structures(project_id) + slope.get("totals") or {}, + await _route_structures(project_id), + slope.get("rows") or [], + settings.get("topsoil_thickness_m"), ) method, method_is_default = concrete_placing_method(settings) # ⚠ 금액에 바로 걸리는 값이라 「기본값으로 돌고 있음」을 응답에 실어 화면이 띄우게 한다. @@ -179,6 +182,8 @@ class QuantitySettingsBody(BaseModel): material_supply: dict[str, Any] | None = None # 콘크리트 타설 방식. `""` 는 「안 정함」으로 되돌리는 뜻이라 서버가 None 으로 만든다. concrete_placing_method: str | None = None + # 표토제거 두께(m). 품셈이 정하는 값이 아니라 설계 입력이다(9-15 [주]②). + topsoil_thickness_m: float | None = None @router.put("/{project_id}/quantity/settings") diff --git a/common_util/common_util_project_settings.py b/common_util/common_util_project_settings.py index 25a5dd3a..879ce77d 100644 --- a/common_util/common_util_project_settings.py +++ b/common_util/common_util_project_settings.py @@ -92,6 +92,10 @@ def default_settings() -> dict[str, Any]: # 또는 `{자재명: {"supply": …, "install_by": "contractor"|"owner"}}`. # ⚠ **법이 아니라 발주 결정**이라 기본은 비워 둔다. 안 정한 자재는 「미분류」로 # 화면에 드러난다 — 사급으로 조용히 넘기면 관급자재대가 새 나간다. + # 표토제거 두께(m) — ⚠ **품셈이 정하는 값이 아니다.** 9-15 [주]② 가 + # 「T : 표토두께(m)」로 **공식의 입력 변수**로 두었다(2026-09-07 원문 확인). + # 기본값을 두지 않는다 — 안 넣으면 물량을 안 낸다(0 으로 때우지 않음). + "topsoil_thickness_m": None, "material_supply": {}, # 콘크리트 타설 방식 — `ready_mixed`(FP-12-01-01) / `machine_mixed`(-02) / # `hand_mixed`(-03). **설계 판단**이라 사용자가 고른다. diff --git a/resources/data_timber/timber_structure_class_2026-01-01.json b/resources/data_timber/timber_structure_class_2026-01-01.json new file mode 100644 index 00000000..a62ec24f --- /dev/null +++ b/resources/data_timber/timber_structure_class_2026-01-01.json @@ -0,0 +1,80 @@ +{ + "schema_version": "1.0", + "dataset_id": "timber_structure_class", + "effective_date": "2026-01-01", + "note": "목재공작물 구조 갈래(품셈 13-13-1 [주]③). **재료 구성**으로 가르며 원문이 예시를 든다. 구조물 종류가 늘면 `type_map` 에 줄만 더한다.", + "source": { + "doc": "산림사업 표준품셈 13-13-1 목재틀흙막이 [주]③", + "table_id": "F0440", + "quote": "보통구조 : 통나무나 대각재, 후판 등이 대부분(80%) 이상으로, 목재 채적에 비해 가공정도가 적은 공작물 (하: 통나무 경계목 / 중: 통나무 방풍책 / 상: 통나무 기슭막이) · 중등구조 : 통나무나 대각재, 후판 등이 절반(50%) 이상 (보통: 통나무 바닥막이·누구막이 / 상: 통나무 골막이) · 상등구조 : 소각재, 박판, 소폭판 등이 목재의 50% 이상 (통나무 사방댐 등)" + }, + "basis_unit": { + "note": "⚠ 밑수 1㎥ 는 **구조물 체적이 아니라 목재 채적**이다. 원문이 「목재 채적에 비해 가공정도」로 갈래를 가르는 데서 드러난다. 그래야 「1㎥에 건축목공 17인」이 말이 된다.", + "unit": "목재 채적 ㎥" + }, + "classes": [ + { + "key": "보통구조 하", + "carpenter": 6.285, + "laborer": 0.682, + "examples": [ + "통나무 경계목" + ] + }, + { + "key": "보통구조 중", + "carpenter": 7.274, + "laborer": 0.786, + "examples": [ + "통나무 방풍책" + ] + }, + { + "key": "보통구조 상", + "carpenter": 8.76, + "laborer": 0.958, + "examples": [ + "통나무 기슭막이" + ] + }, + { + "key": "중등구조 보통", + "carpenter": 10.612, + "laborer": 1.156, + "examples": [ + "통나무 바닥막이", + "누구막이" + ] + }, + { + "key": "중등구조 상", + "carpenter": 13.767, + "laborer": 1.497, + "examples": [ + "통나무 골막이" + ] + }, + { + "key": "상등구조", + "carpenter": 16.975, + "laborer": 1.848, + "examples": [ + "통나무 사방댐" + ] + } + ], + "type_map": [ + { + "type_id": "soil_guard", + "class": "보통구조 상", + "matched": "통나무 기슭막이", + "provisional": true, + "why": "임도 흙막이는 통나무를 짜 맞춘 틀이고 **소각재·박판·소폭판이 목재의 50 % 이상**이라는 상등구조 조건을 안 채운다. 원문 예시로도 「통나무 기슭막이」가 같은 급이고 「통나무 사방댐」은 사방 구조물이다.", + "compare": "상등구조를 쓰면 건축목공 8.760 → 16.975 인/㎥ 로 약 1.9배" + } + ], + "pending_user": { + "note": "⚠ 위 판정은 **잠정**이다. 사용자가 예/아니오로 답할 수 있게 물음을 좁혀 둔다.", + "question": "임도 흙막이의 목재공작물 구조 갈래가 「보통구조 상(통나무 기슭막이 급)」이 맞습니까?" + } +}