- B06 운반계획의 borrow_m3·구간(residuals)을 운반표에 싣고 토공집계에 「토취(반입토)」 줄 · 인계는 코드 없이 수량만 · 막힘 사유 「토취장 거리·재료 미정」(집계 비고 그대로) · 다짐상태 그대로(반입 재료를 몰라 되돌리지 않음) - 종전엔 어느 탭·인계에도 안 옮겨 수량이 통째로 빠졌음 - 936be972: 토공집계 7,259.84㎥(246.54~529.33m 3,494.69 · 720~1,078.01m 3,765.15) · 내역 「금액을 못 세운 줄」 · 본체 금액 변화 0 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
535 lines
28 KiB
Python
535 lines
28 KiB
Python
"""공종 매핑과 갈래표 — **어느 품셈 공종에 잇나** (`Engine_Handoff` 에서 갈라냄).
|
|
|
|
⚠ **왜 갈랐나** — `Engine_Handoff.py` 가 1,279줄로 700줄 제한의 두 배였다(2026-09-08).
|
|
**인계 계약(줄의 모양)은 하나도 안 바뀐다** — 파일만 가른다. 부르는 쪽은 종전대로
|
|
`B08_Quantity_Engine_Handoff` 에서 그대로 가져다 쓴다(그쪽이 다시 내보낸다).
|
|
|
|
여기 있는 것 — 매핑표 읽기 · `WorkItemMapping` · 갈래표(돌쌓기·철근·유로폼·목재공작물) ·
|
|
묶음 전개 · 줄의 출처·막힘 갈래 이름.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
DATASET_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_work_item_mapping"
|
|
DATASET_PREFIX = "work_item_mapping_"
|
|
|
|
#: 철근 갈래표 — **품셈 12-3 [주]① 원문**이 구조물 예시로 갈라 둔 것이라 사람이 고르는 값이
|
|
#: 아니다(거푸집 사용횟수 1-7-1 과 같은 자리). 원문 예시에 안 걸리면 지어내지 않는다.
|
|
REBAR_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_rebar"
|
|
REBAR_PREFIX = "rebar_complexity_"
|
|
|
|
#: 돌쌓기 규격 갈래표 — 저장 제원 값으로 자동 판정한다(사람이 고르는 값이 아니다).
|
|
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
|
|
if target is None:
|
|
files = sorted(MASONRY_DIR.glob(MASONRY_PREFIX + "*.json")) if MASONRY_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 masonry_class(
|
|
options: dict[str, Any], table: dict[str, Any] | None = None
|
|
) -> tuple[str | None, str]:
|
|
"""돌쌓기 갈래 — 저장 뒷길이로 **「…㎝ 이하」 구간**을 고른다.
|
|
|
|
⚠ 저장 선택지(25·30·35·45·55·60·75)와 단가 갈래(35·55·75 이하)는 축이 다르다.
|
|
**저장값 이상인 첫 경계**를 고르는 것이 「이하」 구간의 뜻이다.
|
|
"""
|
|
found = table if table is not None else load_masonry_table()
|
|
spec = (found or {}).get("back_length") or {}
|
|
from B08_Quantity.B08_Quantity_Wording import option_missing
|
|
|
|
option_key = str(spec.get("option_key") or "back_len_cm")
|
|
raw = options.get(option_key)
|
|
if raw is None:
|
|
# ⚠ 「없다」만 말하지 않는다 — **어디서 채우면 단가가 붙는지**까지.
|
|
# 이름은 부르는 쪽(`unmatched`)이 이미 앞에 붙이므로 여기서는 칸 이름만 말한다.
|
|
return None, option_missing(option_key) + " (단가 갈래를 못 고름)"
|
|
try:
|
|
value = float(raw)
|
|
except (TypeError, ValueError):
|
|
return None, f"뒷길이 값을 못 읽음({raw!r})"
|
|
for row in spec.get("classes") or []:
|
|
if value <= float(row["max_cm"]):
|
|
return str(row["key"]), f"뒷길이 {value:g}㎝ → 품셈 13-4 「{row['key']}」 구간"
|
|
return None, f"뒷길이 {value:g}㎝ 를 덮는 갈래가 표에 없음"
|
|
|
|
|
|
def normalize_kind_key(label: str) -> str:
|
|
"""갈래 키 — **내부 공백만** 지운다 (2026-09-07 두 창 확정).
|
|
|
|
원문 표는 「보 통」처럼 자간 공백이 들어 있어 그대로 쓰면 양쪽이 안 맞는다.
|
|
⚠ **다른 글자는 손대지 않는다** — 정규화를 넓히면 오늘 아홉 번 겪은 그 병을
|
|
여기서 새로 만든다. 원문 문구는 버리지 않고 `label` 로 함께 싣는다.
|
|
"""
|
|
return "".join(str(label).split())
|
|
|
|
|
|
#: 줄이 어디서 왔나 — 되짚을 때 쓴다.
|
|
ORIGIN_EARTHWORK = "earthwork"
|
|
ORIGIN_STRUCTURE = "structure"
|
|
ORIGIN_SLOPE = "slope"
|
|
ORIGIN_HAUL = "haul"
|
|
ORIGIN_PREPARATION = "preparation"
|
|
ORIGIN_PIPE = "pipe"
|
|
|
|
#: 암 시공법 → 매핑표의 지반 이름. 품셈이 **긁어내기와 터뜨리기를 다른 공종**으로 두기 때문에
|
|
#: 갈래 이름(연암·보통암…)만으로는 공종을 못 고른다(2026-09-07 일감 9 실서버에서 드러남).
|
|
METHOD_TO_GROUND = {"ripping": "리핑암", "blasting": "발파암"}
|
|
NOTE_METHOD_MISSING = "시공법 미지정으로 공종을 못 고름"
|
|
#: 암 총량이 갈래로 안 나뉘어 한 줄 「암」으로 선 경우 — 시공법은 **갈래마다** 고르므로 이 줄은
|
|
#: 시공법만 골라서는 안 풀린다. 빠진 입력은 구성비다(2026-09-14 흙깎기·측구터파기 암).
|
|
NOTE_ROCK_RATIO_MISSING = (
|
|
"암 갈래 구성비(%)가 아직 없어 암 총량이 한 줄 「암」으로 섬 — 산출 조건 「암 갈래 구성비」를 "
|
|
"넣고 갈래마다 시공법(리핑·발파)을 고르면 공종이 섬"
|
|
)
|
|
|
|
#: 철근으로 보는 성분 이름 조각. **정확한 낱말이 아니라 앞머리**로 본다 —
|
|
#: 「이형철근 D13」·「원형철근」처럼 규격이 뒤에 붙기 때문이다. `철근콘크리트`는 성분 이름이
|
|
#: 아니라 공종 이름이라 성분 목록에는 안 온다.
|
|
REBAR_PREFIXES = ("이형철근", "원형철근", "철근")
|
|
|
|
#: 줄이 왜 막혔나 — **받는 쪽이 「사용자가 입력하면 풀리는 것」과 「우리가 만들어야 하는 것」을
|
|
#: 화면에서 갈라야** 한다(2026-09-07 3자 확정). 8-27 표에서 이미 가른 그 축이다.
|
|
BLOCKED_INPUT_MISSING = "input_missing" # 저장 제원 칸이 비어 있음 — 입력하면 풀림
|
|
BLOCKED_UNIT_DATA_MISSING = "unit_data_missing" # 원단위·표준 물량 자료가 없음
|
|
BLOCKED_FORMULA_MISSING = "formula_missing" # 수량 산출식 자체가 없음
|
|
#: 치수가 없어 **등록부 기본값으로 선** 구조물 줄(2026-09-14 브레인 판정) — 수량은 보이되 금액·합계엔
|
|
#: 안 듦. B09 가 내역 제자리에 빈 금액 + 빨간 테두리로 세우고 「미확정 N건 — 금액에 안 들어감」.
|
|
BLOCKED_UNCONFIRMED = "unconfirmed"
|
|
|
|
#: 사면 계열 — 토공집계에 함께 실리지만 출처가 사면적이다.
|
|
#: ⚠ `item` 칸이 **지반 갈래**인 공종 — 그 밖의 공종에서 `item` 은 **작업 갈래**다
|
|
#: (지장목제거의 「뿌리뽑기·잡관목제거」). 갈래로 읽으면 「시공법 미지정」이라는 **틀린 사유**가
|
|
#: 붙는다(2026-09-09 실측). 정의처는 `EarthworkSummary` 이고 여기서 그대로 가져다 쓴다.
|
|
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402
|
|
BORROW_NAME,
|
|
)
|
|
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402
|
|
GROUND_SPLIT_GROUPS as GROUND_SPLIT_GROUPS,
|
|
)
|
|
|
|
#: 코드 없이 **수량만** 서는 집계 줄 — 막힘 사유는 집계 비고 그대로(토취 · 2026-09-14 브레인 ①).
|
|
SUMMARY_ONLY_GROUPS = frozenset({BORROW_NAME})
|
|
|
|
SLOPE_GROUPS = frozenset({"성토면다짐", "초류종자살포", "지장목제거", "층따기", "면고르기"})
|
|
|
|
#: 집계 합계 줄 — 내역 줄이 아니라 검산용이다.
|
|
SUBTOTAL_GROUPS = frozenset({"보정량계"})
|
|
|
|
#: ⚠⚠ **토공집계표에도 운반 줄이 있다 — 그런데 내역 줄은 운반표 쪽이다**(2026-09-09 실측).
|
|
#: 집계표는 실무 토적집계 모양이라 「무대·도자운반·덤프운반」을 함께 싣는데, 인계본에는
|
|
#: 운반표(`_haul_rows`, FP-10-11·FP-10-12)가 **같은 물량으로 또 실렸다** — 같은 운반이
|
|
#: 두 줄이었다(실측: 도자 17.389·61.130, 덤프 37.511·122.417 이 두 축에 각각).
|
|
#: ⇒ 집계 쪽은 **값은 내되 내역 줄이 아니다**. 빼지 않는 까닭은 검산(무대+도자+덤프 = 총
|
|
#: 운반토량)이 그 값을 쓰기 때문이다 — 무대를 그렇게 둔 것과 같은 규칙이다.
|
|
HAUL_SUMMARY_GROUPS = frozenset({"무대(종방향유용토)", "도자운반", "덤프운반"})
|
|
NOTE_HAUL_IN_SUMMARY = (
|
|
"집계 값 — 내역 줄은 운반표 쪽(FP-10-11·FP-10-12)이 세움. 여기서 또 세우면 같은 운반이"
|
|
" 두 줄이 됨(검산용으로만 실림)"
|
|
)
|
|
|
|
|
|
def _latest_dataset_path(directory: Path | None = None) -> Path | None:
|
|
folder = directory or DATASET_DIR
|
|
if not folder.is_dir():
|
|
return None
|
|
files = sorted(folder.glob(DATASET_PREFIX + "*.json"))
|
|
return files[-1] if files else None
|
|
|
|
|
|
#: ⚠ **공종 수량 단위가 아닌 것** — 품셈 절 머리에 섞여 있는 모양들이다(2026-09-08 B09 전수).
|
|
#: `(㎥/1대, 1일)` 는 **시공량**이라 「1대가 하루에 몇 ㎥」이고, 밑수(「1㎥ 에 얼마」)의
|
|
#: **역수**다. 그대로 밑수로 받으면 그 공종이 조용히 뒤집힌 단위를 갖는다.
|
|
#: `(일당)`·`(조)`·`(인)` 은 **품의 단위**이지 공종 단위가 아니다.
|
|
NON_QUANTITY_UNITS = frozenset({"일당", "조", "인", "일", "대", "인당"})
|
|
|
|
|
|
def is_quantity_unit(unit: str) -> bool:
|
|
"""그 글자가 **공종 수량의 밑수 단위**로 쓸 수 있는가.
|
|
|
|
⚠ **좁게 본다** — 애매하면 안 받는다. 안 받으면 대조가 없을 뿐이고, 잘못 받으면
|
|
**틀린 단위로 검사를 통과시킨다**(그쪽이 더 나쁘다).
|
|
"""
|
|
text = str(unit or "").strip()
|
|
if not text or text in NON_QUANTITY_UNITS:
|
|
return False
|
|
# 「㎥/1대, 1일」처럼 나눗셈·쉼표가 있으면 시공량이거나 밑수가 둘이다.
|
|
return "/" not in text and "," not in text
|
|
|
|
|
|
@dataclass
|
|
class WorkItemMapping:
|
|
"""수량 줄 → 공종 마스터 코드. 못 찾으면 `None` 을 돌려주고 부른 쪽이 목록에 남긴다."""
|
|
|
|
effective_date: str = ""
|
|
#: 매핑의 공종 코드가 본 품셈 판(명세 17장) — 인계 줄마다 이 값이 `pum_edition` 으로 실린다.
|
|
pum_edition: str = ""
|
|
earthwork: list[dict[str, Any]] = field(default_factory=list)
|
|
haul: list[dict[str, Any]] = field(default_factory=list)
|
|
structure: list[dict[str, Any]] = field(default_factory=list)
|
|
#: 준비공 부대시설·가설 — 지금은 **밑수 단위를 적어 두는 자리**로만 쓴다(가설창고 11-1).
|
|
ancillary: list[dict[str, Any]] = field(default_factory=list)
|
|
pending_user: dict[str, Any] = field(default_factory=dict)
|
|
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)
|
|
#: 갈래 이름 별칭 — 우리 「리핑암」 ↔ 일위대가 「파쇄암」처럼 **같은 것을 다른 이름**으로
|
|
#: 부르는 자리. ⚠ 갈래 이름 자체를 갈지 않는다(흙깎기 매핑이 그 이름으로 서 있다).
|
|
#: ⚠ 정본은 **별칭표 한 벌**(`common_util_aliases`)이다 — 여기는 인계본에 싣는 보기일 뿐.
|
|
ground_aliases: dict[str, Any] = field(default_factory=dict)
|
|
|
|
def declared_units(self) -> dict[str, str]:
|
|
"""공종코드 → **매핑이 원문에서 읽어 적은 밑수 단위**. 적힌 줄만 낸다.
|
|
|
|
⚠ 마스터가 못 채운 자리를 메우는 값이다(층따기 9-18 처럼 공식 [주]로만 단위가
|
|
밝혀지는 공종). 여기 적을 때는 **어느 원문 줄에서 읽었는지**(`basis_source`)를
|
|
함께 남길 것 — 근거 없는 단위가 대조의 기준이 되면 안 된다.
|
|
"""
|
|
found: dict[str, str] = {}
|
|
for row in (*self.earthwork, *self.haul, *self.structure, *self.ancillary):
|
|
code, unit = row.get("work_item_code"), row.get("basis_unit")
|
|
if code and unit and is_quantity_unit(str(unit)):
|
|
found[str(code)] = str(unit)
|
|
return found
|
|
|
|
def for_earthwork(
|
|
self, group: str, ground: str | None, item: str | None = None
|
|
) -> dict[str, Any] | None: # noqa: D401
|
|
"""공종+지반유형으로 찾는다. 지반이 갈리지 않는 공종은 `ground` 없는 줄이 받는다.
|
|
|
|
`item` 은 **작업 갈래**(지장목제거의 「잡관목제거」) — 매핑 줄에 `item` 이 있으면 같아야 함.
|
|
"""
|
|
exact = [
|
|
row
|
|
for row in self.earthwork
|
|
if row.get("group") == group
|
|
and row.get("ground") == ground
|
|
and ("item" not in row or row.get("item") == item)
|
|
]
|
|
if exact:
|
|
return exact[0]
|
|
# 지반을 안 가르는 공종(성토·층따기 등)은 `ground` 칸이 없는 줄로 맞춘다.
|
|
loose = [
|
|
row
|
|
for row in self.earthwork
|
|
if row.get("group") == group
|
|
and "ground" not in row
|
|
and ("item" not in row or row.get("item") == item)
|
|
]
|
|
return loose[0] if loose else None
|
|
|
|
def for_haul(self, equipment: str) -> dict[str, Any] | None:
|
|
for row in self.haul:
|
|
if row.get("equipment") == equipment:
|
|
return row
|
|
return None
|
|
|
|
def for_structure(self, type_id: str) -> dict[str, Any] | None:
|
|
for row in self.structure:
|
|
if row.get("type_id") == type_id:
|
|
return row
|
|
return None
|
|
|
|
def composite_for(
|
|
self, type_id: str, structure: dict[str, Any] | None = None
|
|
) -> dict[str, Any] | None:
|
|
"""품셈에 그 이름의 공종이 없어 **여러 공종을 묶는** 자리인가.
|
|
|
|
빈 코드로 두면 「매핑을 못 찾은 줄」과 구별이 안 된다 — 묶음을 적어 구별한다.
|
|
"""
|
|
options = (structure or {}).get("options") or {}
|
|
for row in self.composite.get("items") or []:
|
|
# `when` — 같은 종류라도 그 제원일 때만 묶음(콘크리트 집수정만 12-15 조립 · 09-14 ⑸).
|
|
when = row.get("when") or {}
|
|
if row.get("type_id") == type_id and all(
|
|
str(options.get(key) or "") == str(value) for key, value in when.items()
|
|
):
|
|
return row
|
|
return None
|
|
|
|
|
|
def load_mapping(path: Path | None = None) -> WorkItemMapping:
|
|
"""매핑표를 읽는다. 파일이 없으면 **빈 표** — 전 줄이 `unmatched` 로 드러난다."""
|
|
target = path or _latest_dataset_path()
|
|
if target is None or not target.is_file():
|
|
return WorkItemMapping()
|
|
payload = json.loads(target.read_text(encoding="utf-8"))
|
|
from common_util.common_util_aliases import load_aliases, names_view
|
|
|
|
# 별칭은 매핑표가 아니라 별칭표 한 벌에서 온다 — **매핑이 기대는 공종 마스터 판**의 줄만 싣는다.
|
|
edition = str((payload.get("master") or {}).get("effective_date") or "")
|
|
variant_aliases = [row for row in load_aliases("variant") if row["pum_edition"] == edition]
|
|
return WorkItemMapping(
|
|
effective_date=str(payload.get("effective_date") or ""),
|
|
pum_edition=edition,
|
|
earthwork=list(payload.get("earthwork") or []),
|
|
haul=list(payload.get("haul") or []),
|
|
structure=list(payload.get("structure") or []),
|
|
ancillary=list(payload.get("ancillary") or []),
|
|
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 {},
|
|
ground_aliases={"aliases": names_view(variant_aliases)} if variant_aliases else {},
|
|
)
|
|
|
|
|
|
def composite_quantities(
|
|
structure: dict[str, Any],
|
|
composite: dict[str, Any],
|
|
mapping: WorkItemMapping,
|
|
) -> tuple[list[dict[str, Any]], list[str]]:
|
|
"""묶음 조각마다 **부위별 수량**을 채운다. (조각 목록, 못 채운 사유).
|
|
|
|
⚠ 조각은 **원단위 성분 이름으로** 찾는다. 이름이 어긋나면 물량이 조용히 0 이 되므로
|
|
못 찾으면 그 조각을 `not_ready` 로 남기고 사유를 적는다 — 0 을 적지 않는다.
|
|
⚠ **단위를 반드시 맞춘다.** 철근 단가는 `원/ton` 인데 원단위는 `㎏` 이다.
|
|
안 맞추면 **1000배 틀린다** — 밑수에서 겪은 것과 같은 자리다.
|
|
"""
|
|
# ⚠ 원단위 자체가 없으면 조각을 늘어놓지 않는다 — 같은 사유가 다섯 번 반복되면
|
|
# **진짜 사유가 묻힌다**(화면에서 실제로 그렇게 보였다). 한 줄로 말한다.
|
|
if not (structure.get("components") or []):
|
|
note = "; ".join(structure.get("notes") or []) or "구조물 원단위가 없음"
|
|
return [], [{"code": None, "reason": note}]
|
|
|
|
# ⚠ 조각 이름은 **이름+규격 키**(「이형철근 D13」)로도, 이름만(「콘크리트」)으로도 옴 — 두
|
|
# 색인을 함께 둠. 이름 한 칸만 키로 쓰면 규격만 다른 성분(D13·D16)이 **서로 덮어씀**.
|
|
by_key: dict[str, float] = {}
|
|
by_name: dict[str, float] = {}
|
|
key_of: dict[int, str] = {}
|
|
for index, component in enumerate(structure.get("components") or []):
|
|
name = str(component.get("name") or "").strip()
|
|
key = f"{name} {str(component.get('spec') or '').strip()}".strip()
|
|
amount = float(component.get("amount") or 0.0)
|
|
by_key[key] = by_key.get(key, 0.0) + amount
|
|
by_name[name] = by_name.get(name, 0.0) + amount
|
|
key_of[index] = key
|
|
kg_to_ton = float((mapping.unit_conversion or {}).get("kg_to_ton") or 0.001)
|
|
|
|
parts: list[dict[str, Any]] = []
|
|
missing: list[str] = []
|
|
for spec in composite.get("parts") or []:
|
|
if not isinstance(spec, dict): # 옛 모양(코드 문자열)은 그대로 흘린다
|
|
parts.append({"code": str(spec)})
|
|
continue
|
|
sources = list(spec.get("from_components") or [])
|
|
found = [name for name in sources if name in by_key or name in by_name]
|
|
total = sum(by_key[name] if name in by_key else by_name[name] for name in found)
|
|
if spec.get("unit_from") == "kg" and spec.get("unit") == "ton":
|
|
total *= kg_to_ton
|
|
kinds = {
|
|
component.get("basis_kind")
|
|
for index, component in enumerate(structure.get("components") or [])
|
|
if key_of[index] in found or str(component.get("name") or "").strip() in found
|
|
}
|
|
suffix = spec.get("kind_suffix")
|
|
entry: dict[str, Any] = {
|
|
"code": spec.get("code"),
|
|
"name": spec.get("name"),
|
|
"unit": spec.get("unit"),
|
|
"quantity": total if found else None,
|
|
# 조각마다 근거를 단다 — 치수 전개와 관측값이 한 묶음에 섞인다.
|
|
"basis_kind": next(iter(kinds)) if len(kinds) == 1 else (sorted(kinds) or None),
|
|
"from_components": sources,
|
|
}
|
|
if spec.get("incomplete_note"):
|
|
# ⚠ 물량은 섰으나 **일부 몫이 빠진** 조각 — 「못 채움」과 달리 값은 있다.
|
|
# 화면·인계 둘 다 그 사실을 알아야 「다 섰다」로 오해하지 않는다.
|
|
entry["incomplete_note"] = spec["incomplete_note"]
|
|
if suffix == "euroform_type":
|
|
kind, why = euroform_type(str(structure.get("type_id") or ""))
|
|
entry["kind"] = normalize_kind_key(kind) if kind else None
|
|
entry["kind_label"] = kind # 원문 문구 그대로
|
|
entry["kind_basis"] = why
|
|
if kind:
|
|
entry["code"] = f"{spec.get('code')}#{normalize_kind_key(kind)}"
|
|
else:
|
|
entry["not_ready"] = True
|
|
entry["why"] = why
|
|
missing.append({"code": spec.get("code"), "reason": why})
|
|
if suffix == "formwork_reuse":
|
|
# 12-4 사용횟수 갈래 — B08 이 성분에 단 횟수(`Formwork.annotate`) 그대로(한 벌 · 09-14).
|
|
counts = {
|
|
component.get("reuse_count")
|
|
for component in structure.get("components") or []
|
|
if str(component.get("name") or "").strip() in found
|
|
}
|
|
count = next(iter(counts)) if len(counts) == 1 else None
|
|
if isinstance(count, int) and count > 0:
|
|
entry["kind"] = f"{count}회"
|
|
entry["kind_basis"] = next(
|
|
(
|
|
str(component.get("reuse_note") or "")
|
|
for component in structure.get("components") or []
|
|
if str(component.get("name") or "").strip() in found
|
|
),
|
|
"",
|
|
)
|
|
entry["code"] = f"{spec.get('code')}#{count}회"
|
|
elif found:
|
|
why = "거푸집 사용횟수가 없거나 둘 이상이라 12-4 갈래를 못 고름"
|
|
entry["not_ready"] = True
|
|
entry["why"] = why
|
|
missing.append({"code": spec.get("code"), "reason": why})
|
|
if suffix == "rebar_complexity":
|
|
# 갈래는 원문이 정한다 — 화면·인계에 이름과 근거를 함께 실어 사람이 검증하게 한다.
|
|
complexity, why = rebar_complexity(
|
|
str(structure.get("type_id") or ""), structure.get("options") or {}
|
|
)
|
|
entry["kind"] = normalize_kind_key(complexity) if complexity else None
|
|
entry["kind_label"] = complexity # 원문 문구 그대로(자간 공백 포함)
|
|
entry["kind_basis"] = why
|
|
if complexity:
|
|
entry["code"] = f"{spec.get('code')}#{normalize_kind_key(complexity)}"
|
|
else:
|
|
entry["not_ready"] = True
|
|
entry["why"] = why
|
|
missing.append({"code": spec.get("code"), "reason": why})
|
|
if spec.get("not_ready") or not found:
|
|
entry["not_ready"] = True
|
|
entry["why"] = str(spec.get("why") or "원단위에 해당 성분이 없음")
|
|
# ⚠ 「단가 없음」과 「물량 없음」을 받는 쪽이 갈라야 하므로 **구조로** 낸다.
|
|
missing.append({"code": spec.get("code"), "reason": entry["why"]})
|
|
parts.append(entry)
|
|
return parts, missing
|
|
|
|
|
|
def structure_kind(structure: dict[str, Any]) -> str:
|
|
"""콘크리트 구조물 종류 — **원단위에 철근이 있나 없나로 판정한다.**
|
|
|
|
사람이 고르는 값이 아니다(2026-09-07 3자 확정). 옹벽 관측 원단위에 `D13`·`D16` 이
|
|
실려 있으므로 철근구조물로 자동으로 선다. 소형구조물 판정 기준은 아직 없다.
|
|
"""
|
|
for component in structure.get("components") or []:
|
|
name = str(component.get("name") or "").strip()
|
|
if any(name.startswith(prefix) for prefix in REBAR_PREFIXES):
|
|
return "철근구조물"
|
|
return "무근구조물"
|
|
|
|
|
|
def placing_code(mapping: WorkItemMapping, method: str | None) -> tuple[str | None, bool]:
|
|
"""(타설 공종코드, 기본값을 쓴 것인가). 모르는 방식이면 기본으로 떨어지되 그 사실을 알린다."""
|
|
table = mapping.concrete_placing or {}
|
|
codes = table.get("method_codes") or {}
|
|
default = str(table.get("default_method") or "")
|
|
if method in codes:
|
|
return codes[method], False
|
|
return codes.get(default), True
|
|
|
|
|
|
def load_rebar_table(path: Path | None = None) -> dict[str, Any]:
|
|
"""철근 갈래표를 읽는다. 파일이 없으면 **빈 표** — 전부 「갈래 미확보」로 드러난다."""
|
|
target = path
|
|
if target is None:
|
|
files = sorted(REBAR_DIR.glob(REBAR_PREFIX + "*.json")) if REBAR_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 rebar_complexity(
|
|
type_id: str, options: dict[str, Any], table: dict[str, Any] | None = None
|
|
) -> tuple[str | None, str]:
|
|
"""(철근 갈래, 근거). **원문 예시에 걸리는 것만** 정하고 안 걸리면 `(None, 사유)`.
|
|
|
|
품셈 12-3 [주]① — 「간단: 측구·간단한 기초·**중력식 옹벽** / 보통: 수문·**반중력식 옹벽**·
|
|
교대 / 복잡: 교량 슬래브·암거·우물통·**부벽식 옹벽** / 매우복잡: 구주식 교대·교각…」.
|
|
사람에게 묻지 않는다 — **판정할 수 있는 것을 물으면 그것이 곧 미결이 된다.**
|
|
"""
|
|
found = table if table is not None else load_rebar_table()
|
|
form = options.get("form")
|
|
fallback: dict[str, Any] | None = None
|
|
for row in found.get("form_map") or []:
|
|
if row.get("type_id") != type_id:
|
|
continue
|
|
if "form" not in row:
|
|
fallback = row
|
|
continue
|
|
if row.get("form") == form:
|
|
if row.get("class"):
|
|
basis = row.get("basis") or "품셈 12-3 [주]①"
|
|
return str(row["class"]), f"{basis} 「{row.get('matched')}」"
|
|
return None, str(row.get("why") or "원문 예시에 없음")
|
|
if fallback and fallback.get("class"):
|
|
basis = fallback.get("basis") or "품셈 12-3 [주]①" # 그 공종 표가 직접 적으면 그 표(12-15)
|
|
return str(fallback["class"]), f"{basis} 「{fallback.get('matched')}」"
|
|
from B08_Quantity.B08_Quantity_Wording import type_label
|
|
|
|
detail = f"({form})" if form else "(형식이 아직 입력되지 않음)"
|
|
return None, (
|
|
f"{type_label(type_id)} {detail} 는 품셈 12-3 [주]① 예시에 없어 "
|
|
"철근 갈래를 정하지 못했습니다 — 임의로 고르지 않습니다"
|
|
)
|
|
|
|
|
|
def euroform_type(type_id: str, table: dict[str, Any] | None = None) -> tuple[str | None, str]:
|
|
"""유로폼 설치·해체 유형 — **품셈 12-38-3 [주]④ 원문**이 시설 예시로 갈라 둔다.
|
|
|
|
「보통: 측구, 수로, **옹벽**, 일반적인 벽체, 박스」. 거푸집 사용횟수(1-7-1)·철근 갈래
|
|
(12-3 [주]①)에 이어 **네 번째** 같은 자리다 — 미결로 올리기 전에 원문부터 뒤진다.
|
|
|
|
⚠ 12-38-1 「사용횟수」와 헷갈리지 말 것 — 그쪽은 유로폼(강재)의 **잔존율**이고
|
|
1-7-1 의 소모성 거푸집 전용 횟수와도 다른 자리다.
|
|
"""
|
|
from B08_Quantity.B08_Quantity_Engine_Formwork import load_formwork_table
|
|
|
|
found = table if table is not None else load_formwork_table().euroform_type
|
|
for row in (found or {}).get("type_map") or []:
|
|
if row.get("type_id") == type_id and row.get("class"):
|
|
return str(row["class"]), f"품셈 12-38-3 [주]④ 「{row.get('matched')}」"
|
|
from B08_Quantity.B08_Quantity_Wording import type_label
|
|
|
|
return None, (
|
|
f"{type_label(type_id)} 는 품셈 12-38-3 [주]④ 예시에 없어 유로폼 유형을 "
|
|
"정하지 못했습니다 — 임의로 고르지 않습니다"
|
|
)
|