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": "임도 흙막이의 목재공작물 구조 갈래가 「보통구조 상(통나무 기슭막이 급)」이 맞습니까?" + } +}