diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Standard_Sheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Standard_Sheet.py new file mode 100644 index 00000000..aabf0165 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Standard_Sheet.py @@ -0,0 +1,182 @@ +"""표준도(구조물도) 하단표 — **장 나눔**과 **표 조판**. + +실무 원본은 울진소광 `07-구조도-소광리.xlsx`(보이는 22탭 + 숨긴 31탭). **탭 하나 = 장 하나**이고 +탭 안은 「위 치수조서 + 아래 수량산출서」다. 이 모듈이 만드는 것은 **아래쪽 표**다. + +실무 표의 짜임(돌쌓기 계열 네 탭 실측, 2026-09-09):: + + (단위 표시) m당 + 공종 | 산 출 근 거 | 수량 | 단위 + 1 | 면적 2 × 1 × 1.044 2.088 ㎡ + ... + 11 | 잔토정리 터파기 − 되메우기 0.165 ㎥ + +찰쌓기는 11줄, 메쌓기는 8줄(채움콘크리트·모르터·물구멍이 빠짐)이다. 우리 B08 전개가 이미 +줄마다 `name · unit · amount · basis` 를 내므로 **여기서 새로 계산하지 않는다** — 접어서 낼 뿐이다. + +⚠ **장 나눔 축은 「제원 조합」이다**(2026-09-09 사용자 확정 ⑨). 같은 종류라도 높이·기울기· +뒷길이·돌 종류가 다르면 그림도 수량도 달라지므로 **장이 갈린다**. 기울기가 제원의 한 칸이라 +「기울기가 바뀌면 장이 갈린다」는 확정이 저절로 지켜진다. + +⚠ **단위당 값**은 성분 수량을 `billing_quantity` 로 나눈 것이다. 실무 시트 머리의 `m당` · +`개소당` · `㎡당` 이 그 단위이고, **구조물마다 다르다**(통일하지 않음 — 4-1). +""" + +from __future__ import annotations + +import json +from typing import Any + +#: 장을 가르지 **않는** 제원 칸 — 개소마다 다를 뿐 그림·단위수량을 안 바꾼다. +#: 여기 없는 칸은 전부 장 나눔에 들어간다(모르는 칸을 빠뜨려 두 장이 한 장으로 합쳐지는 것보다, +#: 장이 하나 더 서는 쪽이 안전하다 — 합쳐지면 값이 조용히 틀린다). +PER_PLACE_OPTION_KEYS: frozenset[str] = frozenset( + { + "start_m", + "end_m", + "station", + "station_m", + "side", + "length_m", + "note", + "memo", + "label", + } +) + + +def billing_of(structure: dict[str, Any]) -> tuple[str, float]: + """이 장이 설 **단위와 그 수량**. + + ⚠ `billing_unit` 이 비어 있으면 「단위가 없다」가 아니라 **「m · 연장」**이라는 뜻이다 + (`StructureQuantity` 계약). 관측 원단위가 「개소당」·「㎡당」인 종류만 자기 단위를 채운다. + 이것을 「미정」으로 읽으면 **돌쌓기처럼 흔한 종류가 전부 단위 없는 장**이 된다. + """ + unit = str(structure.get("billing_unit") or "") + if unit: + return unit, float(structure.get("billing_quantity") or 0.0) + return "m", float(structure.get("length_m") or 0.0) + + +def _sheet_options(structure: dict[str, Any]) -> dict[str, Any]: + """장 나눔에 쓰는 제원만 남긴다.""" + options = structure.get("options") or {} + return { + key: value for key, value in sorted(options.items()) if key not in PER_PLACE_OPTION_KEYS + } + + +def sheet_key(structure: dict[str, Any]) -> str: + """제원 조합 하나를 가리키는 이름. 같은 값이면 같은 장이다.""" + payload = { + "type_id": structure.get("type_id") or "", + "height_m": round(float(structure.get("height_m") or 0.0), 3), + "options": _sheet_options(structure), + } + return json.dumps(payload, ensure_ascii=False, sort_keys=True) + + +def sheet_title(structure: dict[str, Any]) -> str: + """장 제목 — 「이름 H=2.0 1:0.3 뒷길이 45㎝」처럼 **무엇이 갈랐는지**가 보이게.""" + parts = [str(structure.get("name") or structure.get("type_id") or "구조물")] + height = float(structure.get("height_m") or 0.0) + if height > 0: + parts.append(f"H={height:g}") + options = structure.get("options") or {} + slope = options.get("face_slope_ratio") + if slope is not None: + parts.append(f"1:{float(slope):g}") + back = options.get("back_len_cm") or options.get("stone_back_length_cm") + if back: + parts.append(f"뒷길이 {int(back)}㎝") + kind = options.get("stone_kind") + if kind: + parts.append(str(kind)) + return " ".join(parts) + + +def _unit_amount(amount: float, quantity: float) -> float | None: + """단위당 값. 셀 단위를 못 정했으면 **0 으로 나누지 않고 `None`** 을 낸다.""" + if quantity <= 0: + return None + return amount / quantity + + +def _rows_of(structure: dict[str, Any]) -> list[dict[str, Any]]: + """구조물 하나의 성분을 표 줄로 접는다 — 실무 시트의 `공종 | 산출근거 | 수량 | 단위`.""" + _unit, quantity = billing_of(structure) + rows: list[dict[str, Any]] = [] + for index, component in enumerate(structure.get("components") or [], start=1): + amount = float(component.get("amount") or 0.0) + rows.append( + { + "no": index, + "name": component.get("name") or "", + # 실무 시트의 「산출근거」 칸 — B08 이 이미 사람이 읽는 문구로 낸다. + "basis": component.get("basis") or "", + "unit_amount": _unit_amount(amount, quantity), + "amount": amount, + "unit": component.get("unit") or "", + # 값이 식에서 나왔나(derived) 관측 원단위표에서 왔나(observed) — 되짚기용. + "basis_kind": component.get("basis_kind") or "", + "source": component.get("source") or "", + } + ) + return rows + + +def build_standard_sheets(unit_table: dict[str, Any]) -> dict[str, Any]: + """B08 원단위 전개(`build_unit_table` 결과)를 **표준도 장 목록**으로 접는다. + + ⚠ 계산을 다시 하지 않는다 — 들어온 전개를 제원 조합으로 묶고 단위당으로 나눌 뿐이다. + """ + groups: dict[str, dict[str, Any]] = {} + for structure in unit_table.get("structures") or []: + key = sheet_key(structure) + sheet = groups.get(key) + if sheet is None: + unit, _quantity = billing_of(structure) + sheet = { + "key": key, + "title": sheet_title(structure), + "type_id": structure.get("type_id") or "", + "height_m": float(structure.get("height_m") or 0.0), + "options": _sheet_options(structure), + # 실무 시트 머리의 「m당」·「개소당」·「㎡당」. 종류마다 다르다 — 통일하지 않는다. + "unit_label": f"{unit}당", + "billing_unit": unit, + "rows": _rows_of(structure), + "members": [], + "notes": list(structure.get("notes") or []), + } + groups[key] = sheet + sheet["members"].append( + { + "structure_id": structure.get("structure_id"), + "name": structure.get("name") or "", + "start_m": structure.get("start_m"), + "end_m": structure.get("end_m"), + "length_m": float(structure.get("length_m") or 0.0), + "billing_quantity": billing_of(structure)[1], + } + ) + for note in structure.get("notes") or []: + if note not in sheet["notes"]: + sheet["notes"].append(note) + + sheets = sorted(groups.values(), key=lambda sheet: (sheet["type_id"], sheet["height_m"])) + for sheet in sheets: + sheet["member_count"] = len(sheet["members"]) + sheet["billing_total"] = sum(member["billing_quantity"] for member in sheet["members"]) + # ⚠ 단위당을 못 낸 줄 — 「값이 없다」를 숫자 0 으로 때우지 않고 이름으로 드러낸다. + sheet["unpriced_rows"] = [ + row["name"] for row in sheet["rows"] if row["unit_amount"] is None + ] + + return { + "sheets": sheets, + "sheet_count": len(sheets), + # 왜 안 실렸는지 — 화면이 「구조물이 없다」와 「걸러졌다」를 가릴 수 있어야 한다. + "structure_count": int(unit_table.get("structure_count") or 0), + "pending_choices": list(unit_table.get("pending_choices") or []), + } diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index e81516c3..4817f469 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -240,6 +240,47 @@ async def _designs_by_chainage(route_id: int) -> dict[int, dict[str, Any]]: } +@router.get("/{project_id}/standard-sheets") +async def get_standard_sheets(project_id: UUID) -> JSONResponse: + """표준도(구조물도) **장 목록 + 하단표**. + + ⚠ 수량을 여기서 새로 셈하지 않는다 — B08 원단위 전개를 그대로 받아 **제원 조합으로 묶고 + 단위당으로 접기만** 한다(계산 자리는 한 곳, CLAUDE.md 5장). + """ + from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Sheet import build_standard_sheets + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table + from B08_Quantity.B08_Quantity_Router_Material import _collect_structures + + try: + pool = get_db_pool() + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + project_root = str(Path(resolve_stored_project_path(stored_path)).resolve()) + except Exception: + logger.exception("B07 표준도 조회 실패(경로): project_id=%s", project_id) + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, + ) + + try: + structures, names, skipped = await asyncio.to_thread(_collect_structures, project_root) + unit_table = await asyncio.to_thread(build_unit_table, structures, names) + except Exception: + logger.exception("B07 표준도 전개 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "구조물 원단위를 전개하지 못했습니다."}, + ) + + payload = build_standard_sheets(unit_table) + payload["status"] = "success" + payload["project_id"] = str(project_id) + # 왜 안 실렸는지 — 「구조물이 없다」와 「걸러졌다」를 화면이 가릴 수 있어야 한다. + payload["skipped_structures"] = skipped + return JSONResponse(content=payload) + + @router.get("/{project_id}/design-drawings", response_model=DesignDrawingListResponse) async def get_design_drawing_list( project_id: UUID, diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff.py b/B08_Quantity/B08_Quantity_Engine_Handoff.py index b24a0e0d..26c8353d 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff.py @@ -183,6 +183,11 @@ def build_handoff( result["basis_unit_warnings"] = verify_unit_matches_basis( work_items, extra=table.declared_units() ) + # ⚠ 채집석 공제 — **양수 ㎥ 로 넘기기만** 한다. 빼는 자리는 유토곡선의 사토뿐이다 + # (2026-09-09 세 창 확정 · 부호를 넘기면 두 번 뒤집힌다). + result["collected_stone_deduction_m3"] = float( + (unit_quantity_table or {}).get("collected_stone_deduction_m3") or 0.0 + ) result["placing_notes"] = placing_notes return result diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py index 37db599b..19f02bbf 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py @@ -406,6 +406,10 @@ def _structure_rows( #: (넣으려면 돌쌓기 일위대가에 타설 품이 있는지부터 확인할 것 — B09 ㉢ 과 같은 자리.) PLACING_TARGET_NAMES = frozenset({"콘크리트", "버림콘크리트", "레미콘"}) +#: 버림 타설 줄의 갈래 이름 — **실무 내역 표기 그대로**(「레미콘타설(장비) 무근,버림」). +#: ⚠ 버림은 늘 무근이라 구조물 종류(무근/철근)를 따라가지 않는다. +BLINDING_PLACING_KIND = "무근,버림" + def _placing_rows( unit_quantity_table: dict[str, Any], @@ -435,15 +439,18 @@ def _placing_rows( # ⚠ 묶음이 아직 미확보라 지금은 값이 안 걸렸을 뿐, 묶음이 서는 날 두 번이 된다. if mapping.composite_for(str(structure.get("type_id") or "")): continue - volume = sum( - float(component.get("amount") or 0.0) - for component in structure.get("components") or [] - if str(component.get("name") or "").strip() in PLACING_TARGET_NAMES - and component.get("unit") == "㎥" - ) - if volume <= 0: - continue - buckets[structure_kind(structure)] = buckets.get(structure_kind(structure), 0.0) + volume + # ⚠ 버림은 **따로 센다** — 실무 내역이 「레미콘타설(장비) **무근,버림**」으로 갈라 + # 적는다(봉화 제50호표, 2026-09-09 데스크탑 보조 확인). 같은 공종·같은 단가라 + # 금액은 안 움직이고 **이름만 맞추는 것**이다. + for component in structure.get("components") or []: + name = str(component.get("name") or "").strip() + if name not in PLACING_TARGET_NAMES or component.get("unit") != "㎥": + continue + volume = float(component.get("amount") or 0.0) + if volume <= 0: + continue + kind = BLINDING_PLACING_KIND if name == "버림콘크리트" else structure_kind(structure) + buckets[kind] = buckets.get(kind, 0.0) + volume rows = [ { "work_item_code": code, diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation.py b/B08_Quantity/B08_Quantity_Engine_Preparation.py index ba97fa77..0973ab9f 100644 --- a/B08_Quantity/B08_Quantity_Engine_Preparation.py +++ b/B08_Quantity/B08_Quantity_Engine_Preparation.py @@ -246,16 +246,117 @@ def erosion_rows( ] +#: 부대시설·가설공사 — **법이 요구하는데 우리가 안 내던 다섯 줄**(2026-09-09 사용자 확정 ⑬). +#: `key` 는 설정의 `ancillary_counts` 칸 이름, `code` 는 품셈 공종(없으면 `None`). +#: ⚠ 다섯 중 **품셈에 공종이 있는 것은 가설창고 하나뿐**이다(마스터 전수 확인). +#: 나머지 넷은 **금액이 못 선다** — 줄은 세우되 「공종 자체가 품셈에 없음」이라고 적는다. +#: 「아직 안 만든 것」과 갈라 적어야 다음에 할 일이 달라진다. +ANCILLARY_ITEMS: tuple[dict[str, Any], ...] = ( + { + "key": "national_point_sign", + "item": "국가지점번호판", + "unit": "개소", + "code": None, + "legal": True, + "why": ( + "⚠ 법정 의무 — 임도규정 제26조제5항 「국가지점번호판을 제작하여 500미터 마다 " + "설치·관리하되, 필요시 거리를 조정할 수 있으며」. ⚠ 기점 포함·종점 잔여·갈림길 " + "중복을 원문이 정하지 않아 **연장÷500 을 산식으로 쓰지 않는다** — 개소를 넣으면 섬" + ), + }, + { + "key": "guide_sign", + "item": "임도 안내판", + "unit": "개소", + "code": None, + "legal": True, + "why": ( + "⚠ 법정 의무 — 임도규정 제26조제6항 「임도의 시점 및 종점에 안내판…을 설치하여야 " + "한다」. 노선을 이어 가면 최초 시점·최종 종점에 둘 수 있어 **개소는 설계 판단**임" + ), + }, + { + "key": "gate", + "item": "차단기", + "unit": "개소", + "code": None, + "legal": False, + "why": "임도기술교본 11장(실무 참고) — 법령·행정규칙에는 없음. 설치 개소는 설계 판단", + }, + { + "key": "site_container", + "item": "가설창고(컨테이너)", + "unit": "개소", + "code": "FP-11-01", + "legal": False, + "why": "품셈 11-1 콘테이너형 가설건축물 — ⚠ 개소는 현장 조건이라 설계가 정함", + }, + { + "key": "flood_supplies", + "item": "수방대책 자재", + "unit": "식", + "code": None, + "legal": False, + "why": ( + "임도기술교본 11장 — 비닐·말뚝·마대·삽 등을 현장 입구에 비치. " + "품목·수량이 원문에 없어 **한 벌(식)로 받는다**" + ), + }, +) + +#: 품셈에 그 이름의 공종이 아예 없는 줄에 적는 사유. 「아직 안 만든 것」과 갈라 쓴다. +REASON_NO_WORK_ITEM = "품셈에 그 이름의 공종이 없음 — 금액은 별도 단가로만 설 수 있음" + + +def ancillary_rows(counts: dict[str, Any] | None = None) -> list[dict[str, Any]]: + """부대시설·가설공사 줄. **개소를 안 넣어도 줄은 선다** — 빠진 것이 보이게. + + ⚠ 개소를 **지어내지 않는다**(확정 ⑬). 설정 `ancillary_counts` 에 넣은 값만 쓴다. + """ + given = {str(key): value for key, value in (counts or {}).items()} + rows: list[dict[str, Any]] = [] + for spec in ANCILLARY_ITEMS: + raw = given.get(spec["key"]) + try: + amount = float(raw) if raw is not None and str(raw).strip() != "" else None + except (TypeError, ValueError): + amount = None + reasons = [spec["why"]] + if spec["code"] is None: + reasons.append(REASON_NO_WORK_ITEM) + if amount is None: + reasons.append("개소가 아직 입력되지 않았습니다 — 넣으면 물량이 섭니다") + status = STATUS_PENDING + else: + status = STATUS_READY if spec["code"] else STATUS_PENDING + rows.append( + { + "group": "부대시설", + "item": spec["item"], + "unit": spec["unit"], + "amount": amount, + "status": status, + "work_item_code": spec["code"], + "legal_required": bool(spec["legal"]), + "reason": " · ".join(reasons), + } + ) + return rows + + 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, names: dict[str, str] | None = None, + ancillary_counts: dict[str, Any] | None = None, ) -> dict[str, Any]: """화면·인계가 그대로 쓰는 모양. **못 서는 줄도 목록에 남는다.**""" - rows = preparation_rows(slope_totals, slope_rows, topsoil_thickness_m) + erosion_rows( - structures, names + rows = ( + preparation_rows(slope_totals, slope_rows, topsoil_thickness_m) + + erosion_rows(structures, names) + + ancillary_rows(ancillary_counts) ) return { "columns": ["구분", "공종", "단위", "수량", "상태", "사유"], diff --git a/B08_Quantity/B08_Quantity_Engine_SlopeArea.py b/B08_Quantity/B08_Quantity_Engine_SlopeArea.py index 47d92d1e..94384282 100644 --- a/B08_Quantity/B08_Quantity_Engine_SlopeArea.py +++ b/B08_Quantity/B08_Quantity_Engine_SlopeArea.py @@ -86,8 +86,11 @@ def _length_of(slope: StationSlope, series: str, face: str) -> float: 법면보호공은 면고르기를 참조한다 — 같은 사면길이를 쓴다. 끊고 싶으면 이 함수만 고친다. 층따기는 성토면만 대상이다. """ - if series == "bench_cut" and face != "fill": - return 0.0 + if series == "bench_cut": + # ⚠ 층따기는 **원지반 표면**을 깎는 일이라 밑수가 성토 비탈면이 아니다 + # (교본 6장 4절). B06 설계가 측점마다 내는 값을 그대로 쓴다. + # 없으면 0 — 성토 사면길이로 대신 채우면 **다른 면을 세게 된다**(2026-09-09 정정). + return slope.bench_cut_length_m if face == "fill" else 0.0 return slope.fill_length_m if face == "fill" else slope.cut_length_m diff --git a/B08_Quantity/B08_Quantity_Engine_SlopeLength.py b/B08_Quantity/B08_Quantity_Engine_SlopeLength.py index e7497743..5594d578 100644 --- a/B08_Quantity/B08_Quantity_Engine_SlopeLength.py +++ b/B08_Quantity/B08_Quantity_Engine_SlopeLength.py @@ -58,6 +58,12 @@ class StationSlope: chainage_m: float cut_length_m: float = 0.0 fill_length_m: float = 0.0 + #: 층따기 밑수 — **원지반 표면**의 경사길이(m). B06 설계가 측점마다 낸다 + #: (`design.bench_cut_length_m`, 2026-09-09 랩탑 메인). + #: ⚠ **성토 비탈면 길이와 다른 면이다** — 층따기는 성토부 **아래 원지반**을 계단으로 + #: 깎는 일이라(교본 6장 4절) 비탈면이 아니라 지표면을 따라간다. 앞서 성토 사면길이를 + #: 밑수로 쓰고 있었는데 **면이 달랐다.** + bench_cut_length_m: float = 0.0 berm_width_m: float = 0.0 # 성토고(m) — 성토 사면 조각들의 **수직 낙차 합**. 노면 끝에서 원지반까지 내려간 높이다. # ⚠ 좌우가 다르면 **큰 쪽**을 쓴다. 「중심점 성토고 5m 이상」(품셈 11-3 [주]①) 판정은 @@ -216,6 +222,8 @@ def station_slope(chainage_m: float, design: dict[str, Any]) -> StationSlope: chainage_m=float(chainage_m), cut_length_m=sum(s.length_m for s in segments if s.role == "cut"), fill_length_m=sum(s.length_m for s in segments if s.role == "fill"), + # ⚠ 없는 측점은 0 이다 — 성토 사면길이로 **대신 채우지 않는다**(면이 다름). + bench_cut_length_m=_num(design.get("bench_cut_length_m")) or 0.0, fill_height_m=max(fill_by_side.values(), default=0.0), berm_width_m=_num(berm.get("width_m")) or 0.0, segments=tuple(segments), diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index 5b9e2317..d730a4ca 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -157,6 +157,39 @@ STONE_MASONRY = { # `earthwork` = 토공 대분류로 합산(울진 토적집계 D12~D14 실증) # `material` = 자재총괄로 감(할증은 거기서 한 번만) # `unit_price` = 일위대가 재료비 구성으로 감(B09 가 배합을 분해) +#: 버림 콘크리트 — **빠뜨리고 있던 줄**이다(2026-09-09 사용자 확정 ⑭). +#: 두께 근거: KDS 44 90 00 도로암거구조설계기준 「기초시공시 기초지반 다짐을 시행하고 +#: 구조물 시공이 원활하도록 **100 mm 두께의 버림콘크리트**를 타설하도록 한다」. +#: 폭 근거: KCS 34 50 05 「버림 콘크리트의 두께는 설계도서에 따르며, **폭은 잡석다짐의 +#: 폭과 동일**하게 한다」. +#: ⚠ 우리에게 **잡석다짐 폭이 아직 없다**(기초잡석은 품셈에 공종만 있고 두께·폭이 없음). +#: 그래서 **터파기 폭(평균두께 + 여유 0.2m)을 잠정으로** 쓰고 근거 문구에 그 사실을 적는다. +#: 잡석다짐 폭이 정해지면 이 한 줄만 바꾸면 된다. +#: 돌을 **사 오나 캐나** — 기본은 「캔다」(2026-09-09 사용자 확정 ②). +#: 법이 그쪽을 권한다 — 별표2 「석축 등에 필요한 야면석 등은 **가급적 현장에서 +#: 채취·사용**하도록 운반거리를 조사한다」. 실무 견적 다섯 권에도 야면석 **구입 단가가 +#: 0건**이고 울진 일위대가는 「구입/채집」 두 벌을 갖고 있다. +#: ⚠ 구조물마다 바꿀 수 있다 — 저장 제원에 「구입」이라 적힌 구조물만 공제에서 빠진다. +STONE_SUPPLY_KEYS = ("stone_supply", "stone_source") +STONE_SUPPLY_PURCHASED = {"구입", "구입품", "사서", "purchase", "purchased", "buy"} + +#: ⚠⚠ **채집석 공제는 사토에서 한 번만 뺀다** (2026-09-09 세 창 확정 · 랩탑 메인의 통로에도 +#: 같은 문장이 박혀 있다 — 두 곳이 같은 말이라야 나중에 누가 봐도 안 갈린다). +#: +#: 채집석 공제는 사토에서 한 번만 뺀다. +#: B08 은 소요량(collected_stone_deduction_m3, ㎥ 양수)을 내기만 하고 공제하지 않으며, +#: 빼는 자리는 유토곡선의 사토뿐이다 — +#: 실어 내는 몫(spoil_m3 − natural_spoil_m3)에서 먼저 빼고 모자라면 자연방토에서 뺀다. +#: +#: ⚠ **부호를 넘기지 않는다.** 실무 시트가 `−274.66` 으로 적혀 있어 그대로 넘기면 두 번 +#: 뒤집힌다. 우리는 **양수**로 주고, 빼는 것은 받는 쪽이 한다. +COLLECTED_STONE_KEY = "collected_stone_deduction_m3" + +BLINDING_THICKNESS_M = 0.10 +#: 버림을 뺄 수 있는 칸 — 「기본은 넣고, 빼고 싶으면 뺀다」(사용자 확정 ⑭). +#: 저장 제원에 이 칸이 없으면 **넣는 쪽**이 기본이다. +BLINDING_OPTION_KEYS = ("blinding_concrete", "base_blinding") + DESTINATION = { "터파기": "earthwork", "되메우기": "earthwork", @@ -169,9 +202,13 @@ DESTINATION = { "막자갈": "material", "콘크리트": "unit_price", "채움콘크리트": "unit_price", + "버림콘크리트": "unit_price", "모르터": "unit_price", "거푸집": "unit_price", - "물구멍관": "material", # ⚠ 자재 카탈로그가 이름으로 찾는다 — 공백 없는 한 낱말(B09 규약) + "물구멍관": "material", + # ⚠ 자재도 토공도 아닌 자리 — **유토곡선이 사토에서 뺄 밑수**다. 자재총괄은 + # `material` 만 모으므로 여기 섞이지 않는다. + "채집석": "haul_deduction", # ⚠ 자재 카탈로그가 이름으로 찾는다 — 공백 없는 한 낱말(B09 규약) } # ⚠ 배합 성분 — 산출물에 나타나면 안 된다(㉢). B09 일위대가가 배합표로 분해한다. @@ -221,6 +258,48 @@ class StructureQuantity: billing_quantity: float = 0.0 +def is_collected_stone(options: dict[str, Any]) -> bool: + """이 구조물의 돌을 **캐서 쓰는가**. 정한 적이 없으면 「캔다」(확정 ②).""" + for key in STONE_SUPPLY_KEYS: + raw = options.get(key) + if raw is None: + continue + if str(raw).strip().lower() in STONE_SUPPLY_PURCHASED: + return False + return True + + +def wants_blinding(options: dict[str, Any]) -> bool: + """버림 콘크리트를 넣을지. **정한 적이 없으면 넣는다**(사용자 확정 ⑭). + + 「안 넣음」·「제외」·`false` 로 적혀 있을 때만 뺀다 — 빈 칸을 「빼기」로 읽으면 + 저장해 둔 적 없는 프로젝트에서 줄이 통째로 사라진다. + """ + for key in BLINDING_OPTION_KEYS: + raw = options.get(key) + if raw is None: + continue + text = str(raw).strip().lower() + if text in {"false", "0", "no", "제외", "안 넣음", "안넣음", "빼기"}: + return False + return True + + +def _blinding_component(base_width_m: float, length_m: float, options: dict[str, Any]): + """버림 콘크리트 한 줄. 넣지 않기로 했으면 `None`.""" + if not wants_blinding(options) or base_width_m <= 0 or length_m <= 0: + return None + return Component( + "버림콘크리트", + "㎥", + base_width_m * length_m * BLINDING_THICKNESS_M, + DESTINATION["버림콘크리트"], + f"기초 폭 {base_width_m:.2f}m × 연장 × 두께 {BLINDING_THICKNESS_M:.2f}m" + " · 두께는 KDS 44 90 00(100㎜) · ⚠ 폭은 잡석다짐 폭(KCS 34 50 05)이라야 하나" + " 그 값이 아직 없어 **터파기 폭을 잠정**으로 씀", + ) + + #: ⚠ **저장 제원의 실제 칸 이름**은 `back_len_cm` 이다(레지스트리 확인). #: 앞서 `stone_back_length_cm` 을 읽고 있어 **저장값이 영영 안 닿고 늘 기본 45㎝ 로 돌았다** #: — 뒷길이를 75 로 골라도 45 계수가 붙던 자리다. 값이 나오므로 아무 시험도 안 잡았다. @@ -325,6 +404,10 @@ def boulder_masonry( # 낸다. 근거 문구에 그 사실을 적어 되짚을 수 있게 한다. upper_cm = float(str(diameter).split("~")[-1]) thickness = upper_cm / 100.0 + # 버림 콘크리트 — 돌쌓기와 같은 자리(사용자 확정 ⑭). 빼려면 저장 제원에서 「안 넣음」. + blinding = _blinding_component(thickness + constants["excavation_extra_m"], length_m, options) + if blinding is not None: + components.append(blinding) excavation = height_m * (thickness + constants["excavation_extra_m"]) * length_m backfill = height_m * constants["backfill_thickness_m"] * length_m components.extend( @@ -470,7 +553,8 @@ def stone_masonry( # 막자갈 = 입적 − (면적 × 뒷길이 × 뒤채움몫 + 고임돌). # 뒤채움 몫은 **돌 종류로 갈린다** — 깬돌·잡석 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) + stone_body = masonry_area * (back_cm / 100.0) * body_ratio # 면석 몸통 체적 + rubble = volume - (stone_body + wedge) if rubble > 0: components.append( Component( @@ -527,6 +611,28 @@ def stone_masonry( ) ) + # 채집석 — **캐서 쓰는 구조물**의 돌 체적. 사토에서 뺄 밑수이고 **여기서 빼지 않는다.** + if is_collected_stone(options): + collected = max(stone_body + wedge + max(rubble, 0.0), 0.0) + if collected > 0: + components.append( + Component( + "채집석", + "㎥", + collected, + DESTINATION["채집석"], + f"면석 몸통 {stone_body:.3f} + 고임돌 {wedge:.3f} + 막자갈" + f" {max(rubble, 0.0):.3f} ㎥ · 현장 채집분 ·" + " ⚠ 여기서 빼지 않음 — 사토에서 한 번만 뺌", + ) + ) + + # 버림 콘크리트 — 기초 바닥에 까는 얇은 층. 빼려면 저장 제원에서 「안 넣음」으로 둔다. + base_width = thickness + constants["excavation_extra_m"] + blinding = _blinding_component(base_width, length_m, options) + if blinding is not None: + components.append(blinding) + # 터파기·되메우기·잔토 — 토공으로 합산되는 값이다(내역 줄이 아니다). excavation = height_m * (thickness + constants["excavation_extra_m"]) * length_m backfill = height_m * constants["backfill_thickness_m"] * length_m @@ -781,5 +887,15 @@ def build_table( "surcharge_applied": False, "mix_components_found": violations, "structure_count": len(quantities), + # ⚠ 사토에서 뺄 밑수 — **양수**로 낸다. 빼는 것은 유토곡선(랩탑 메인) 몫이다. + COLLECTED_STONE_KEY: round( + sum( + component.amount + for item in quantities + for component in item.components + if component.name == "채집석" + ), + 3, + ), "amount_spread": spread_by_unit(totals.values(), value_key="amount"), } diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index fc4aeaff..63894e8c 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -131,6 +131,8 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse: slope.get("rows") or [], settings.get("topsoil_thickness_m"), {type_id: definition.name for type_id, definition in structure_type_map().items()}, + # 부대시설 개소 — 산식으로 만들지 않고 **설계자가 넣은 값**만 쓴다(확정 ⑬). + settings.get("ancillary_counts") or {}, ) method, method_is_default = concrete_placing_method(settings) # ⚠ 금액에 바로 걸리는 값이라 「기본값으로 돌고 있음」을 응답에 실어 화면이 띄우게 한다. diff --git a/B09_Estimation/B09_Estimation_Lists.py b/B09_Estimation/B09_Estimation_Lists.py new file mode 100644 index 00000000..6a1ebc62 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Lists.py @@ -0,0 +1,228 @@ +"""B09 원가계산 — **목록표·집계표** (사용자 확정 12번: 내야 할 표 16개 전체). + +지금까지 안 내던 일곱 표 중 여섯이 여기서 난다. + + A5-1 중기목록표 코드·명칭·규격·단위 · **합계·노무비·재료비·경비** · 비고 + A6 노무비목록표 코드·명칭·규격·단위 · **단가** · 비고 + A7 재료비목록표 〃 + A8 경비목록표 〃 (기계 취득가 `S-` 층이 여기 온다) + A11 자원 집계표 코드·명칭·규격 · **수량** · 단위 · 단가 · **금액** · 비고 + — 노무비·재료비·경비·중기 네 벌 + +**서식은 지어내지 않았다** — 실무 내역서(영월 기번6 · 봉화 기번41)의 같은 이름 시트를 +그대로 옮겼다(2026-09-09 실측). 칸 이름·차례가 그 시트와 같다. + +⚠ **새 계산이 아니다.** 목록표는 `PriceBook` 의 제목을 종류별로 늘어놓는 것이고, +집계표는 **내역서에 이미 선 금액을 자원별로 되모으는 것**이다. 값을 여기서 다시 만들면 +내역서와 어긋난다(CLAUDE.md 5장 「같은 계산을 두 벌로 짜지 않는다」). + +⚠ **집계표는 반올림**이다(단수 규칙 `RESOURCE_SUMMARY`). 내역서 본체는 절사라 +**두 표의 합이 원 단위로 어긋나는 것이 정상**이다 — 그 사실을 화면에 함께 낸다. +""" + +from __future__ import annotations + +from decimal import Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_PriceBook import PriceKind +from B09_Estimation.B09_Estimation_Rounding import ( + SUMMARY_MISMATCH_NOTE, + OutputPlace, + round_at, +) +from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build + +_ZERO = Decimal(0) + +#: 목록표 한 장이 담는 종류. 실무 시트 이름 그대로 쓴다. +LIST_KINDS: tuple[tuple[str, str, PriceKind], ...] = ( + ("labor", "노무비목록표", PriceKind.LABOR), + ("material", "재료비목록표", PriceKind.MATERIAL), + ("expense", "경비목록표", PriceKind.MACHINE_BASE), +) + + +def _money(value: Decimal | None) -> str | None: + return None if value is None else str(value) + + +def catalog_list(build: UnitPriceBuild, kind: PriceKind) -> list[dict[str, Any]]: + """목록표 한 장 — 그 종류의 **기초단가 줄**을 코드 차례로 늘어놓는다. + + ⚠ 단가가 안 선 줄도 **빼지 않는다.** 빼면 「없는 것」과 「값을 못 구한 것」이 같아 보인다. + """ + rows: list[dict[str, Any]] = [] + for code in sorted(build.book.titles): + title = build.book.titles[code] + if title.kind is not kind: + continue + try: + price: Decimal | None = title.adopted_price() + note = "" + except Exception as error: # 채택 슬롯이 비었다 — 값을 지어내지 않는다 + price, note = None, str(error) + rows.append( + { + "code": code, + "name": title.name, + "spec": title.spec, + "unit": title.unit, + "unit_price_krw": _money(price), + "note": note, + } + ) + return rows + + +def machine_base_list() -> list[dict[str, Any]]: + """경비목록표 — **기계 취득가격(천원)** 목록. + + ⚠ 내 `S-` 층과 **다른 값**이다. `S-` 는 「취득가 × 시간당 손료계수」라 **원/시간**이고, + 실무 경비목록표는 **취득가 그 자체를 천원 단위**로 싣는다(영월 실측: + `S00104 불도저(무한궤도) 19톤 **천원** 184,499`). 손료를 여기 실으면 자릿수가 세 자리 + 어긋난 채 「경비」로 읽힌다. + """ + from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog + + catalog = load_machine_catalog() + rows: list[dict[str, Any]] = [] + for code in sorted(catalog.machines): + machine = catalog.machines[code] + rows.append( + { + "code": f"S-{code}", + "name": machine.name, + "spec": machine.specification, + "unit": "천원", + "unit_price_krw": _money(machine.price_thousand_krw), + "note": "" if machine.loss_coefficient_per_hour is not None else "손료계수 미확보", + } + ) + return rows + + +def machine_list(build: UnitPriceBuild) -> list[dict[str, Any]]: + """중기목록표 — 시간당 사용료를 **3분할까지** 보인다 (실무 시트와 같은 칸). + + 실무 서식: `X00205 굴삭기(무한궤도) 0.7㎥ 시간 96,843 = 노무 55,700 + 재료 18,015 + 경비 23,128` + """ + rows: list[dict[str, Any]] = [] + for code in sorted(build.book.titles): + title = build.book.titles[code] + if title.kind is not PriceKind.MACHINE_HOURLY: + continue + try: + money = build.book.resolve(code) + row = { + "total_krw": _money(round_at(money.total, OutputPlace.UNIT_PRICE_ROW)), + "labor_krw": _money(round_at(money.labor, OutputPlace.UNIT_PRICE_ROW)), + "material_krw": _money(round_at(money.material, OutputPlace.UNIT_PRICE_ROW)), + "expense_krw": _money(round_at(money.expense, OutputPlace.UNIT_PRICE_ROW)), + "note": "", + } + except Exception as error: # 층이 덜 섰다 — 0 으로 안 때운다 + row = { + "total_krw": None, + "labor_krw": None, + "material_krw": None, + "expense_krw": None, + "note": str(error), + } + rows.append( + {"code": code, "name": title.name, "spec": title.spec, "unit": title.unit, **row} + ) + return rows + + +def resource_summary( + quantities: dict[str, Decimal], + build: UnitPriceBuild | None = None, +) -> dict[str, Any]: + """자원 집계표 — 공종 수량을 **자원별로 되모은다**. + + `quantities` = `{공종코드: 수량}` (내역서가 쓰는 것과 같은 모양). + 한 자원이 여러 공종에 걸리면 **한 줄로 합친다** — 실무 시트가 그 모양이다. + + ⚠ **일위대가 안쪽을 한 겹만 편다.** 일위대가 → 자원(노무·자재·기계 사용료)까지가 + 실무 집계표의 깊이다. 기계 사용료(`X-`)를 다시 손료·연료로 쪼개면 **중기 집계표와 + 이중으로 세는 것**이 된다. + """ + prices = build or cached_build() + book = prices.book + #: 자원코드 → [수량, 제목] + picked: dict[str, list[Any]] = {} + missing: list[str] = [] + + for raw_code, quantity in quantities.items(): + code = raw_code if raw_code.startswith("B-") else f"B-{raw_code}" + if code not in book.titles: + missing.append(raw_code) + continue + amount = Decimal(str(quantity)) + for detail in book.details.get(code, []): + if detail.percent_of_labor is not None or detail.percent_of_parent is not None: + continue # 비율 줄은 자원이 아니다 — 경비로만 붙는다 + ref = detail.ref_code + if ref == code: + continue + slot = picked.setdefault(ref, [_ZERO, book.titles.get(ref)]) + slot[0] += detail.quantity * amount + + groups: dict[str, list[dict[str, Any]]] = { + "labor": [], + "material": [], + "expense": [], + "machine": [], + } + for ref, (amount, title) in sorted(picked.items()): + if title is None: + missing.append(ref) + continue + bucket = { + PriceKind.LABOR: "labor", + PriceKind.MATERIAL: "material", + PriceKind.MACHINE_BASE: "expense", + PriceKind.MACHINE_HOURLY: "machine", + }.get(title.kind) + if bucket is None: + continue + try: + unit_money = book.resolve(ref) + unit_price: Decimal | None = unit_money.total + # ⚠ 집계표는 **반올림** — 내역서 본체(절사)와 원 단위로 어긋나는 것이 정상이다. + money: Decimal | None = round_at( + unit_money.total * amount, OutputPlace.RESOURCE_SUMMARY + ) + note = "" + except Exception as error: + unit_price, money, note = None, None, str(error) + groups[bucket].append( + { + "code": ref, + "name": title.name, + "spec": title.spec, + "quantity": str(amount), + "unit": title.unit, + "unit_price_krw": _money(unit_price), + "amount_krw": _money(money), + "note": note, + } + ) + + return { + "groups": groups, + "missing": sorted(set(missing)), + "note": SUMMARY_MISMATCH_NOTE, + } + + +def all_lists(build: UnitPriceBuild | None = None) -> dict[str, Any]: + """목록표 넷을 한 번에 — 화면이 탭 하나에서 다 쓴다.""" + prices = build or cached_build() + return { + "labor": catalog_list(prices, PriceKind.LABOR), + "material": catalog_list(prices, PriceKind.MATERIAL), + "expense": machine_base_list(), + "machine": machine_list(prices), + } diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index bf58a57c..816aee63 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -217,6 +217,25 @@ async def list_unit_price_titles(project_id: UUID) -> JSONResponse: ) +@router.get("/{project_id}/estimation/base-data") +async def get_base_data_lists(project_id: UUID) -> JSONResponse: + """**기초자료 네 표** — 노무비·재료비·경비 목록표 + 중기목록표 (사용자 확정 12번). + + 별표2 설계서 구성에 드는 표들이라 **없으면 설계서가 성립하지 않는다.** 서식은 + 실무 내역서(영월 기번6·봉화 기번41) 같은 이름 시트를 그대로 따랐다. + """ + from B09_Estimation.B09_Estimation_Lists import all_lists + + try: + return JSONResponse(content={"status": "success", **all_lists(cached_build())}) + except Exception: + logger.exception("B09 기초자료 목록 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "기초자료 목록을 못 만들었습니다."}, + ) + + @router.get("/{project_id}/estimation/unit-prices/{code}") async def get_unit_price_detail(project_id: UUID, code: str) -> JSONResponse: """일위대가 **본표** — 「무엇으로 이루어졌나」. 줄마다 원천·파고들기 표시가 붙는다.""" diff --git a/common_util/common_util_project_settings.py b/common_util/common_util_project_settings.py index 879ce77d..8ab40ad4 100644 --- a/common_util/common_util_project_settings.py +++ b/common_util/common_util_project_settings.py @@ -102,6 +102,13 @@ def default_settings() -> dict[str, Any]: # ⚠ 기본은 `None` — 「안 정함」과 「일부러 레디믹스트를 고른 것」을 갈라야 # 화면이 「기본값 적용 중」을 정직하게 띄운다. 값을 미리 넣으면 그 구별이 사라진다. "concrete_placing_method": None, + # 부대시설 개소 — `{항목키: 개소}` (2026-09-09 사용자 확정 ⑬). + # ⚠ **산식으로 만들지 않는다.** 국가지점번호판은 임도규정 제26조제5항이 + # 「500미터 마다 설치·관리하되 **필요시 거리를 조정**」이라 하고, 기점 포함· + # 종점 잔여·갈림길 중복을 원문이 안 정한다(지식DB 부대시설 [구현]). + # ⇒ `ceil(연장÷500)` 을 확정 산식으로 쓰지 않고 **개소를 받는다.** + # 비워 두면 물량을 안 낸다(0 으로 때우지 않음). + "ancillary_counts": {}, "dataset_versions": {}, }, "estimation": { 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 61f3a127..a8b4156f 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 @@ -75,10 +75,9 @@ "group": "층따기", "work_item_code": "FP-09-18", "master_name": "층따기", - "basis_unit": "㎥", - "basis_source": "품셈 9-18 [주] 「Q1 = 3600×q×K×f×E/㎝ = ㎥/시간」 — 절 머리에 「(단위: …)」가 없고 **공식으로만** 단위가 밝혀지는 자리라 마스터 `basis_unit` 이 비어 있다(2026-09-08 B09 확인).", - "mismatch_reason": "층따기는 품셈이 **체적(㎥)**으로 세는데 우리 집계는 **성토 비탈면적(㎡)** 입니다 — 층따기 단의 높이·폭이 있어야 체적이 나옵니다(교본: 「층따기 높이·폭은 설계도서에 명시」). 그 값이 정해지면 물량이 섭니다.", - "mismatch_kind": "input_missing" + "basis_unit": "㎡", + "basis_source": "⭐ 2026-09-09 사용자 확정 ⑦ — 층따기 수량 단위는 **㎡**(실무 관행). 밑수는 **원지반 표면의 경사길이**(B06 `design.bench_cut_length_m`)를 평균단면적법으로 면적화한 값이다. ⚠ 품셈 9-18 [주]는 `Q1 = 3600×q×K×f×E/㎝ = ㎥/시간` 이라 **공식은 체적 기준**이다 — 단의 높이·폭이 설계도서 값이라 체적을 지어낼 수 없어 면적으로 간다. ⇒ **받는 쪽(B09)이 ㎡ 단가를 세워야 한다**(㎥ 단가를 그대로 곱하면 금액이 틀림).", + "unit_note": "앞서 2026-09-08 에는 이 어긋남을 「막힘」으로 두어 금액이 안 섰다(410만원 자리). 사용자 확정으로 ㎡ 가 정본이 되었으므로 막지 않는다." }, { "group": "면고르기",