feat(B08): 콘크리트 구조물 관측 원단위 + 품셈 몫 표시
⑩ 콘크리트 구조물. 품셈에 구조물별 표준 물량표가 없어(구조물_수량.md · 배수공_수량.md §2) 실무 설계원본 관측값을 데이터 파일로 둠. - `resources/data_structure_unit/` — 반중력식옹벽 H=2.0(m당) · 돌집수정 ㄷ/ㄴ형 · 집수정 Ø800 · 콘크리트포장 T=20cm. 줄마다 출처를 실음. - 규격 정확 일치로만 씀. 보간하지 않음 — H=1.6 을 H=2.0 에서 줄여 쓰면 틀림 (기초·벽 두께는 높이에 비례하지 않음). 안 맞으면 「원단위 미확보」로 드러내고 표에 있는 규격을 함께 알림. - BOX암거·세월교는 관측값도 없고 저장 제원에 두께가 없어 전개식도 못 세움. `not_found` 에 사유와 함께 남기고 미확보로 냄 — 두께를 지어내면 콘크리트· 거푸집·철근으로 번져 나감. - 성분마다 `basis_kind`(derived/observed)를 달아 화면에 「치수 전개 / 실무 관측」 으로 보임. 두 근거가 한 표에 섞이므로 안 적으면 되짚을 수 없음. - 배수관의 유입부 집수정을 별도 줄로 세움. 품셈도 관부설과 집수정을 다른 공종으로 둠 — 한 줄로 합치면 어느 쪽 물량인지 못 가름. 품셈 몫 표시 (조율 창 제보 교차 확인) - `인력(10%)`·`장비(90%)` 처럼 딱지가 비율을 달고 오는 표 25건 확인. 형태 판정은 정상(requirement)이었으나 **장비 몫이 시공능력 공식이라 값이 아님**을 아무도 알 수 없었음. `resource_shares`·`partial_ratio` 를 실어 「단가가 일부만 선 것」이 정상으로 흘러가지 않게 함. 공식 기호는 첫 표에만 있고 뒤 표가 물려받으므로 기호 유무가 아니라 몫 유무로 판정 — 기호로 세면 절반을 놓침(9-13-2). 반영률 계약 갱신 — breakdown 을 늘 실음(`application_ratio_breakdown` · `quantity_breakdown`). `application_ratio_pct` 는 두 율이 같을 때만 채우는 편의값. 검증 — 관측 원단위 17건 · 인계 43건 · 품셈 23건 통과, 전체 548 passed. tsc 오류 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -250,6 +250,36 @@ def detect_basis(table: dict[str, Any]) -> tuple[float | None, str | None]:
|
||||
return None, None
|
||||
|
||||
|
||||
# ⚠ **딱지가 비율을 달고 오는 표** — `인력(10%)` · `장비(90%)` (2026-09-07 서브 창 제보로 확인).
|
||||
# 그 표는 소요량형이면서 **장비 몫이 시공능력 공식**(Q = 3600·q·K·f·E ÷ Cm)이라
|
||||
# **인력 10 % 만 값으로 서 있다.** 형태 한 낱말(`requirement`)로만 적으면 받는 쪽이
|
||||
# 그 단가를 전량에 곱해 **내역서가 9할 싸게** 선다. 그래서 **몫과 미완 여부를 따로 싣는다.**
|
||||
SHARE_TAG_RE = re.compile(
|
||||
r"^(자재|재료|재료비|자재비|잡재료|장비|기계|인력|노무|노무비|인건비|경비|공구손료)"
|
||||
r"\s*[((]\s*(\d+(?:\.\d+)?)\s*[%%]\s*[))]$"
|
||||
)
|
||||
#: 시공능력 공식 파라미터. 이 기호가 있으면 그 몫은 **아직 조립이 안 된 것**이다.
|
||||
CAPACITY_SYMBOLS = {"K", "k", "f", "E", "Cm", "㎝(sec)", "q", "qo", "Q"}
|
||||
|
||||
|
||||
def resource_shares(table: dict[str, Any]) -> dict[str, float]:
|
||||
"""`{인력: 10.0, 장비: 90.0}` — 딱지에 붙은 몫. 없으면 빈 칸."""
|
||||
shares: dict[str, float] = {}
|
||||
for row in table.get("rows", []):
|
||||
if not row:
|
||||
continue
|
||||
if m := SHARE_TAG_RE.match(norm(row[0])):
|
||||
shares[m.group(1)] = float(m.group(2))
|
||||
return shares
|
||||
|
||||
|
||||
def capacity_formula_pending(table: dict[str, Any]) -> bool:
|
||||
"""장비 몫이 **시공능력 공식**으로만 적혀 있어 아직 값이 안 된 상태인가."""
|
||||
keys = {norm(row[0]) for row in table.get("rows", []) if row}
|
||||
cells = {norm(c) for row in table.get("rows", []) for c in row}
|
||||
return bool((keys | cells) & CAPACITY_SYMBOLS)
|
||||
|
||||
|
||||
def variant_axis(table: dict[str, Any]) -> list[str]:
|
||||
"""행이 갈리는 축 — 표의 첫 열 값들(토질·암종·규격). 값 열은 뺀다."""
|
||||
seen: list[str] = []
|
||||
@@ -279,6 +309,13 @@ def build() -> dict[str, Any]:
|
||||
chapter = number.split("-")[0] if number else None
|
||||
form, why = detect_form(table, chapter)
|
||||
basis_qty, basis_unit = detect_basis(table)
|
||||
shares = resource_shares(table)
|
||||
# ⚠ 「값이 일부만 선 표」를 정상으로 흘려보내지 않는다. **몫이 적혀 있으면 부분값**으로
|
||||
# 본다 — 관측한 25건 모두 장비 몫이 시공능력 공식으로만 적혀 있어 값이 아니었고,
|
||||
# 공식 기호가 첫 표에만 있고 이어지는 표는 그것을 물려받는 모양이라
|
||||
# 「기호가 있는 표만」으로 세면 절반을 놓친다(9-13-2 가 그 경우).
|
||||
# ⚠ 이 깃발은 **표시일 뿐 값을 지우지 않는다** — 넓게 잡아도 정상 값이 안 사라진다.
|
||||
partial = bool(shares)
|
||||
entry = {
|
||||
"pum_table_id": table["table_id"],
|
||||
"section": section,
|
||||
@@ -287,6 +324,10 @@ def build() -> dict[str, Any]:
|
||||
"form_basis": why,
|
||||
"basis_quantity": basis_qty,
|
||||
"basis_unit": basis_unit,
|
||||
"resource_shares": shares,
|
||||
"partial_ratio": partial,
|
||||
# 공식 기호가 이 표에 직접 있는가 — 없으면 앞 표에서 물려받는 모양이다.
|
||||
"capacity_formula_here": capacity_formula_pending(table),
|
||||
"variant_key": variant_axis(table),
|
||||
"condition_note": [norm(h) for h in table.get("headers", []) if norm(h)],
|
||||
"raw_row": table.get("rows", []), # 원문 셀 — B09 자원 축이 읽는다.
|
||||
|
||||
@@ -51,6 +51,12 @@ class SummaryRow:
|
||||
# 칸이 비어 있으면 「적용됐는지」를 받는 쪽이 단정할 수 없다.
|
||||
amount_gross: float | None = None
|
||||
application_ratio_pct: float | None = None
|
||||
# ⚠ 성·절토면이 갈리는 줄은 **늘 갈래별로** 싣는다 (2026-09-07 3자 계약 확정).
|
||||
# 「율이 같을 때만 pct, 다를 때만 breakdown」으로 두면 받는 쪽에 갈래가 둘 생기고
|
||||
# 그게 **한쪽만 고쳐지는** 자리가 된다. `application_ratio_pct` 는 두 율이 같을 때만
|
||||
# 채우는 **편의값**이고, 정본은 아래 두 칸이다.
|
||||
application_ratio_breakdown: dict[str, float] | None = None
|
||||
quantity_breakdown: dict[str, float] | None = None
|
||||
note: str = ""
|
||||
# 내역서 줄이 되는가 — 무대처럼 품에 포함된 것은 False (PLAN 8-7 ㉡).
|
||||
in_bill: bool = True
|
||||
@@ -129,6 +135,8 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]:
|
||||
amount=fill_face * _ratio(source, "fill_slope_compaction"),
|
||||
amount_gross=fill_face,
|
||||
application_ratio_pct=_ratio(source, "fill_slope_compaction") * 100.0,
|
||||
application_ratio_breakdown={"fill": _ratio(source, "fill_slope_compaction") * 100.0},
|
||||
quantity_breakdown={"fill": fill_face * _ratio(source, "fill_slope_compaction")},
|
||||
note=_ratio_note(source, "fill_slope_compaction", "성토면"),
|
||||
)
|
||||
)
|
||||
@@ -150,6 +158,14 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]:
|
||||
application_ratio_pct=(
|
||||
seed_fill_ratio * 100.0 if seed_fill_ratio == seed_cut_ratio else None
|
||||
),
|
||||
application_ratio_breakdown={
|
||||
"fill": seed_fill_ratio * 100.0,
|
||||
"cut": seed_cut_ratio * 100.0,
|
||||
},
|
||||
quantity_breakdown={
|
||||
"fill": fill_face * seed_fill_ratio,
|
||||
"cut": cut_face * seed_cut_ratio,
|
||||
},
|
||||
note=_seed_note(source),
|
||||
)
|
||||
)
|
||||
@@ -161,6 +177,14 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]:
|
||||
amount=removal * _ratio(source, "obstacle_removal"),
|
||||
amount_gross=removal,
|
||||
application_ratio_pct=_ratio(source, "obstacle_removal") * 100.0,
|
||||
application_ratio_breakdown={
|
||||
"fill": _ratio(source, "obstacle_removal") * 100.0,
|
||||
"cut": _ratio(source, "obstacle_removal") * 100.0,
|
||||
},
|
||||
quantity_breakdown={
|
||||
"fill": slope.get("tree_removal_fill", 0.0) * _ratio(source, "obstacle_removal"),
|
||||
"cut": slope.get("tree_removal_cut", 0.0) * _ratio(source, "obstacle_removal"),
|
||||
},
|
||||
note=_ratio_note(source, "obstacle_removal", "성토면+절토면"),
|
||||
)
|
||||
)
|
||||
@@ -232,6 +256,8 @@ def build_table(source: SummaryInput) -> dict[str, Any]:
|
||||
"amount": row.amount,
|
||||
"amount_gross": row.amount_gross,
|
||||
"application_ratio_pct": row.application_ratio_pct,
|
||||
"application_ratio_breakdown": row.application_ratio_breakdown,
|
||||
"quantity_breakdown": row.quantity_breakdown,
|
||||
"note": row.note,
|
||||
"in_bill": row.in_bill,
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
하기 때문이다 — 율만 보내면 B09 가 또 곱해 값이 두 배가 된다. 100 % 인 줄도 `100.0` 을
|
||||
적고, `None` 은 **반영률 개념이 없는 줄**에만 쓴다.
|
||||
`verify_ratio_math()` 가 세 값이 서로 맞는지 실제로 재 본다.
|
||||
성·절토면이 갈리는 줄은 **늘** `application_ratio_breakdown`(갈래별 율)과
|
||||
`quantity_breakdown`(갈래별 물량)을 싣는다. `application_ratio_pct` 는 두 율이 같을 때만
|
||||
채우는 **편의값**이다 — 「같을 때만 pct, 다를 때만 breakdown」으로 두면 받는 쪽에 갈래가
|
||||
둘 생기고 그게 한쪽만 고쳐지는 자리가 된다(2026-09-07 3자 계약).
|
||||
|
||||
⚠ `ground_class_set` 을 함께 싣는다 (2026-09-07 서브 이견 채택)
|
||||
값이 「연암」이어도 **그 프로젝트가 몇 갈래 세트를 쓰는지**를 알아야 ④예산내역서에서 줄을
|
||||
@@ -185,6 +189,9 @@ def _earthwork_rows(
|
||||
# 반영률 — 곱하기는 여기가 끝이다. 받는 쪽은 적기만 한다.
|
||||
"quantity_gross": row.get("amount_gross"),
|
||||
"application_ratio_pct": row.get("application_ratio_pct"),
|
||||
# 율이 부분마다 다른 줄 — 받는 쪽이 문장을 안 뜯게 칸으로 준다.
|
||||
"application_ratio_breakdown": row.get("application_ratio_breakdown"),
|
||||
"quantity_breakdown": row.get("quantity_breakdown"),
|
||||
"ground_class": ground,
|
||||
"haul_distance_m": None,
|
||||
"haul_equipment": None,
|
||||
@@ -226,6 +233,8 @@ def _haul_rows(
|
||||
# 운반에는 반영률 개념이 없다 — 그래서 `None` 이다(0 이 아니다).
|
||||
"quantity_gross": None,
|
||||
"application_ratio_pct": None,
|
||||
"application_ratio_breakdown": None,
|
||||
"quantity_breakdown": None,
|
||||
"ground_class": row.get("ground") or None,
|
||||
"haul_distance_m": float(row.get("average_distance_m") or 0.0),
|
||||
"haul_equipment": equipment,
|
||||
@@ -266,6 +275,8 @@ def _structure_rows(
|
||||
"quantity": length,
|
||||
"quantity_gross": None,
|
||||
"application_ratio_pct": None,
|
||||
"application_ratio_breakdown": None,
|
||||
"quantity_breakdown": None,
|
||||
"ground_class": None,
|
||||
"haul_distance_m": None,
|
||||
"haul_equipment": None,
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""콘크리트 구조물 — **관측 원단위표** 조회 (B08 일감 ⑩ · PLAN 8-6·8-8).
|
||||
|
||||
왜 전개식이 아니라 관측값인가
|
||||
지식DB 가 못 박아 둔 사실이다 — **구조물별 표준 물량표는 품셈에 없다**
|
||||
(`구조물_수량.md` 마지막 줄 · `배수공_수량.md` §2). 콘크리트 구조물의 물량은
|
||||
**설계 표준도**에서 나오는데 그 표준도가 원문(법·품셈)에 없다. 그래서 옹벽·집수정처럼
|
||||
치수가 표준화된 것은 **실무 설계원본에서 뽑은 관측값**이 유일한 원천이다.
|
||||
|
||||
두 근거가 한 표에 섞인다 — 그래서 줄마다 `basis` 를 단다
|
||||
· `derived` — 저장된 치수에서 **식으로** 나온 값(돌쌓기 계열, `..._Engine_UnitQuantity`).
|
||||
· `observed` — 실무 관측 원단위표에서 **규격을 맞춰 꺼낸** 값(이 모듈).
|
||||
섞어 두고 근거를 안 적으면, 나중에 「이 값이 왜 이런가」를 아무도 못 되짚는다.
|
||||
|
||||
⚠⚠ **보간하지 않는다**
|
||||
관측값은 **그 규격에서만** 맞다. `반중력식 H=2.0` 의 콘크리트 1.35 ㎥/m 를 H=1.6 으로
|
||||
줄여 쓰면 틀린다 — 기초·벽 두께는 높이에 비례하지 않는다. 규격이 표에 없으면
|
||||
**「원단위 미확보」로 드러낸다.** 가까운 값을 갖다 쓰는 길을 두지 않는다.
|
||||
|
||||
⚠ 치수를 지어내지 않는다
|
||||
BOX암거는 `structures.json` 에 **벽·저판·상판 두께가 없어** 전개식조차 못 세운다.
|
||||
두께를 가정하면 그 값이 콘크리트·거푸집·철근으로 **번져 나간다**. 미확보로 낸다.
|
||||
|
||||
⚠ 이중계상 규칙은 그대로다
|
||||
㉢ 배합을 분해하지 않는다(콘크리트 ㎥·모르터 ㎥ 까지). ㉠ 할증은 자재총괄 한 곳뿐.
|
||||
터파기·되메우기·잔토는 `destination: earthwork` 로 토공에 합산된다.
|
||||
"""
|
||||
|
||||
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_structure_unit"
|
||||
DATASET_PREFIX = "structure_unit_observed_"
|
||||
|
||||
#: 값이 어디서 왔나 — 한 표에 섞이므로 줄마다 단다.
|
||||
BASIS_DERIVED = "derived" # 저장된 치수에서 식으로
|
||||
BASIS_OBSERVED = "observed" # 실무 관측 원단위표에서
|
||||
|
||||
NOTE_UNIT_MISSING = "원단위 미확보"
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _same(left: Any, right: Any) -> bool:
|
||||
"""규격 한 칸 비교. 숫자는 값으로, 나머지는 글자로 **정확히** 본다.
|
||||
|
||||
`"800"` 과 `800` 은 같게 보되(입력 폼이 문자열을 준다), `2.0` 과 `1.6` 은 다르다 —
|
||||
가까운 값을 같다고 보는 길은 두지 않는다.
|
||||
"""
|
||||
if isinstance(left, (int, float)) and not isinstance(left, bool):
|
||||
try:
|
||||
return abs(float(left) - float(right)) < 1e-9
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return str(left).strip() == str(right).strip()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ObservedUnitTable:
|
||||
"""관측 원단위표 한 판."""
|
||||
|
||||
effective_date: str = ""
|
||||
entries: list[dict[str, Any]] = field(default_factory=list)
|
||||
sources: dict[str, Any] = field(default_factory=dict)
|
||||
not_found: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def find(self, type_id: str, spec: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""규격이 **모두** 맞는 줄만 돌려준다. 하나라도 어긋나면 없는 것으로 본다."""
|
||||
for entry in self.entries:
|
||||
if entry.get("type_id") != type_id:
|
||||
continue
|
||||
wanted = entry.get("spec") or {}
|
||||
if all(key in spec and _same(value, spec[key]) for key, value in wanted.items()):
|
||||
return entry
|
||||
return None
|
||||
|
||||
def specs_for(self, type_id: str) -> list[dict[str, Any]]:
|
||||
"""그 종류로 표에 있는 규격 목록 — 「무엇이 있는지」를 화면이 보이게."""
|
||||
return [
|
||||
entry.get("spec") or {} for entry in self.entries if entry.get("type_id") == type_id
|
||||
]
|
||||
|
||||
|
||||
def load_observed_table(path: Path | None = None) -> ObservedUnitTable:
|
||||
"""관측 원단위표를 읽는다. 파일이 없으면 **빈 표** — 전부 「미확보」로 드러난다."""
|
||||
target = path or _latest_dataset_path()
|
||||
if target is None or not target.is_file():
|
||||
return ObservedUnitTable()
|
||||
payload = json.loads(target.read_text(encoding="utf-8"))
|
||||
return ObservedUnitTable(
|
||||
effective_date=str(payload.get("effective_date") or ""),
|
||||
entries=list(payload.get("entries") or []),
|
||||
sources=payload.get("sources") or {},
|
||||
not_found=payload.get("not_found") or {},
|
||||
)
|
||||
|
||||
|
||||
def scale_for(entry: dict[str, Any], structure: dict[str, Any]) -> tuple[float, str]:
|
||||
"""관측값에 곱할 수 — 단위가 `m` 면 연장, `㎡` 면 면적, `개소` 면 1.
|
||||
|
||||
⚠ **규격을 늘리는 것이 아니라 개수를 세는 것**이다. `H=2.0 옹벽 10m` 는 같은 단면이
|
||||
10m 이어진 것이라 곱해도 되지만, `H=1.6` 으로 바꾸는 것은 단면이 달라지므로 안 된다.
|
||||
"""
|
||||
options = structure.get("options") or {}
|
||||
unit = str(entry.get("unit") or "개소")
|
||||
if unit == "m":
|
||||
length = options.get("length_m")
|
||||
if length is None:
|
||||
start, end = structure.get("start_m"), structure.get("end_m")
|
||||
length = (
|
||||
abs(float(end) - float(start)) if start is not None and end is not None else 0.0
|
||||
)
|
||||
return float(length or 0.0), f"연장 {float(length or 0.0):g} m"
|
||||
if unit == "㎡":
|
||||
width = options.get("ford_width_m")
|
||||
length = options.get("length_m") or 0.0
|
||||
area = float(width or 0.0) * float(length or 0.0)
|
||||
return area, f"면적 {area:g} ㎡"
|
||||
return 1.0, "1 개소"
|
||||
|
||||
|
||||
def expand_observed(
|
||||
type_id: str,
|
||||
spec: dict[str, Any],
|
||||
structure: dict[str, Any],
|
||||
table: ObservedUnitTable | None = None,
|
||||
) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
"""(성분 목록, 알림). 규격이 표에 없으면 **빈 목록 + 미확보 알림**을 낸다."""
|
||||
found = (table or load_observed_table()).find(type_id, spec)
|
||||
if found is None:
|
||||
known = (table or load_observed_table()).specs_for(type_id)
|
||||
detail = f" — 표에 있는 규격: {known}" if known else ""
|
||||
return [], [f"{NOTE_UNIT_MISSING} ({type_id} {spec}){detail}"]
|
||||
|
||||
scale, scale_note = scale_for(found, structure)
|
||||
if scale <= 0:
|
||||
return [], [f"{NOTE_UNIT_MISSING} — 곱할 연장·면적이 0 ({type_id})"]
|
||||
|
||||
source_key = str(found.get("source") or "")
|
||||
components: list[dict[str, Any]] = []
|
||||
for item in found.get("components") or []:
|
||||
note = str(item.get("basis_note") or "")
|
||||
components.append(
|
||||
{
|
||||
"name": item["name"],
|
||||
"unit": item["unit"],
|
||||
"amount": float(item["amount"]) * scale,
|
||||
"destination": item.get("destination") or "material",
|
||||
# 근거를 값 옆에 붙인다 — 관측값임을 화면·인계에서 바로 알아야 한다.
|
||||
"basis": f"관측 원단위 {item['amount']:g}/{found.get('unit')} × {scale_note}"
|
||||
+ (f" ({note})" if note else ""),
|
||||
"basis_kind": BASIS_OBSERVED,
|
||||
"source": source_key,
|
||||
}
|
||||
)
|
||||
return components, [f"관측 원단위 적용 — {found.get('source_note') or source_key}"]
|
||||
@@ -34,6 +34,13 @@ import math
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Iterable
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_ObservedUnit import (
|
||||
BASIS_DERIVED,
|
||||
BASIS_OBSERVED,
|
||||
ObservedUnitTable,
|
||||
expand_observed,
|
||||
load_observed_table,
|
||||
)
|
||||
from common_util.common_util_quantity_spread import spread_by_unit
|
||||
|
||||
# ── 계수표 — 식에 박지 않고 여기서 고른다 ─────────────────────────────
|
||||
@@ -100,6 +107,10 @@ class Component:
|
||||
amount: float
|
||||
destination: str
|
||||
basis: str = ""
|
||||
# ⚠ 값이 어디서 왔나 — `derived`(치수에서 식으로) / `observed`(실무 관측 원단위표).
|
||||
# 두 근거가 한 표에 섞이므로 줄마다 단다. 안 적으면 나중에 못 되짚는다.
|
||||
basis_kind: str = BASIS_DERIVED
|
||||
source: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -251,6 +262,16 @@ def stone_masonry(
|
||||
|
||||
|
||||
# 구조물 종류 → 전개식. 없는 종류는 전개하지 않고 이름만 남긴다(지어내지 않는다).
|
||||
# ⚠ **관측 원단위표로 가는 종류** — 치수가 저장돼 있지 않아 전개식을 못 세우는 것들이다.
|
||||
# 값의 키(규격)를 저장 제원의 어느 칸에서 읽는지 여기 적는다. 표에 규격이 없으면
|
||||
# 「원단위 미확보」로 드러난다 — 가까운 값을 갖다 쓰지 않는다.
|
||||
OBSERVED_SPEC_KEYS: dict[str, tuple[str, ...]] = {
|
||||
"retaining_wall": ("form", "height_m"),
|
||||
"ford_pavement": ("thickness_cm",),
|
||||
# 배수관의 유입부 집수정은 관 자체와 **다른 줄**이다 — 관은 관대로 서고 집수정이 따로 선다.
|
||||
"pipe_inlet_basin": ("inlet_basin_form", "inlet_basin_material", "pipe_diameter_mm"),
|
||||
}
|
||||
|
||||
EXPANDERS = {
|
||||
"masonry_wet": lambda h, l, o: stone_masonry(h, l, o, wet=True),
|
||||
"masonry_dry": lambda h, l, o: stone_masonry(h, l, o, wet=False),
|
||||
@@ -258,7 +279,55 @@ EXPANDERS = {
|
||||
}
|
||||
|
||||
|
||||
def expand(structure: dict[str, Any], names: dict[str, str] | None = None) -> StructureQuantity:
|
||||
#: 한 구조물이 **여러 내역 줄**을 낳는 자리. 배수관은 관 자체와 유입부 집수정이 따로 선다
|
||||
#: (품셈도 관부설과 집수정을 다른 공종으로 둔다). 한 줄로 합치면 어느 쪽 물량인지 못 가른다.
|
||||
ATTACHMENTS: dict[str, tuple[tuple[str, str, str], ...]] = {
|
||||
# (붙는 종류, 그것이 있는지 보는 옵션 칸, 줄 이름 꼬리)
|
||||
"pipe": (("pipe_inlet_basin", "inlet_basin_form", "유입부 집수정"),),
|
||||
}
|
||||
|
||||
|
||||
def attachments_of(structure: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""구조물에 딸린 **별도 줄**을 만든다. 제원은 원본을 그대로 물려준다(치수 두 벌 금지)."""
|
||||
rows: list[dict[str, Any]] = []
|
||||
options = structure.get("options") or {}
|
||||
for type_id, gate_key, label in ATTACHMENTS.get(str(structure.get("type_id") or ""), ()):
|
||||
if not options.get(gate_key):
|
||||
continue # 그 부속이 없는 배치다 — 빈 줄을 만들지 않는다
|
||||
rows.append(
|
||||
{
|
||||
**structure,
|
||||
"structure_id": f"{structure.get('structure_id')}-{type_id}",
|
||||
"type_id": type_id,
|
||||
"attachment_of": structure.get("structure_id"),
|
||||
"attachment_parent_type": structure.get("type_id"),
|
||||
"attachment_label": label,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _observed_components(
|
||||
type_id: str,
|
||||
structure: dict[str, Any],
|
||||
observed: ObservedUnitTable | None,
|
||||
) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
"""관측 원단위표에서 꺼낸다. 규격 키가 정해져 있지 않은 종류는 건드리지 않는다."""
|
||||
keys = OBSERVED_SPEC_KEYS.get(type_id)
|
||||
if keys is None:
|
||||
return [], []
|
||||
options = structure.get("options") or {}
|
||||
spec = {key: options[key] for key in keys if options.get(key) is not None}
|
||||
if not spec:
|
||||
return [], [f"{type_id} 규격이 비어 있음 — 관측 원단위를 고를 수 없음"]
|
||||
return expand_observed(type_id, spec, structure, observed)
|
||||
|
||||
|
||||
def expand(
|
||||
structure: dict[str, Any],
|
||||
names: dict[str, str] | None = None,
|
||||
observed: ObservedUnitTable | None = None,
|
||||
) -> StructureQuantity:
|
||||
"""구조물 하나를 전개한다. 치수는 저장된 제원에서만 읽는다(치수 두 벌 금지)."""
|
||||
type_id = str(structure.get("type_id") or "")
|
||||
options = structure.get("options") or {}
|
||||
@@ -266,10 +335,15 @@ def expand(structure: dict[str, Any], names: dict[str, str] | None = None) -> St
|
||||
end = _num(structure.get("end_m"))
|
||||
length = _num(options.get("length_m")) or abs(end - start)
|
||||
height = _num(options.get("height_m"))
|
||||
label = (names or {}).get(type_id, type_id)
|
||||
if structure.get("attachment_label"):
|
||||
# 「배수관 · 유입부 집수정」처럼 어디에 딸린 줄인지 이름에 남긴다.
|
||||
parent = (names or {}).get(str(structure.get("attachment_parent_type") or ""), "")
|
||||
label = f"{parent or label} · {structure['attachment_label']}".strip(" ·")
|
||||
result = StructureQuantity(
|
||||
structure_id=structure.get("structure_id"),
|
||||
type_id=type_id,
|
||||
name=(names or {}).get(type_id, type_id),
|
||||
name=label,
|
||||
length_m=length,
|
||||
height_m=height,
|
||||
start_m=start if structure.get("start_m") is not None else None,
|
||||
@@ -277,6 +351,12 @@ def expand(structure: dict[str, Any], names: dict[str, str] | None = None) -> St
|
||||
)
|
||||
expander = EXPANDERS.get(type_id)
|
||||
if expander is None:
|
||||
# 전개식이 없으면 **관측 원단위표**를 본다(치수가 저장돼 있지 않은 종류).
|
||||
components, notes = _observed_components(type_id, structure, observed)
|
||||
if components or notes:
|
||||
result.components = [Component(**item) for item in components]
|
||||
result.notes.extend(notes)
|
||||
return result
|
||||
result.notes.append(f"'{type_id}' 전개식이 아직 없음 — 물량을 내지 않음")
|
||||
return result
|
||||
result.components, notes = expander(height, length, options)
|
||||
@@ -302,7 +382,13 @@ def build_table(
|
||||
structures: Iterable[dict[str, Any]], names: dict[str, str] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""화면·API 가 그대로 쓰는 모양. 성분별 총량과 구조물별 내역을 함께 낸다."""
|
||||
quantities = [expand(item, names) for item in structures]
|
||||
observed = load_observed_table()
|
||||
# 딸린 줄(배수관의 유입부 집수정 등)을 원본 뒤에 세운다 — 한 줄로 합치지 않는다.
|
||||
expanded_inputs: list[dict[str, Any]] = []
|
||||
for item in structures:
|
||||
expanded_inputs.append(item)
|
||||
expanded_inputs.extend(attachments_of(item))
|
||||
quantities = [expand(item, names, observed) for item in expanded_inputs]
|
||||
violations = verify_no_mix_components(quantities)
|
||||
|
||||
totals: dict[str, dict[str, Any]] = {}
|
||||
@@ -338,6 +424,8 @@ def build_table(
|
||||
"amount": component.amount,
|
||||
"destination": component.destination,
|
||||
"basis": component.basis,
|
||||
"basis_kind": component.basis_kind,
|
||||
"source": component.source,
|
||||
}
|
||||
for component in item.components
|
||||
],
|
||||
|
||||
@@ -52,6 +52,9 @@ export interface UnitQuantityStructure {
|
||||
amount: number;
|
||||
destination: string;
|
||||
basis: string;
|
||||
/** 값이 어디서 왔나 — `derived`(치수에서 식으로) / `observed`(실무 관측 원단위표). */
|
||||
basis_kind?: string;
|
||||
source?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -306,7 +309,7 @@ export function renderUnitQuantityGrid(response: MaterialResponse): HTMLElement
|
||||
scroller.className = "b08-grid__scroll";
|
||||
const element = document.createElement("table");
|
||||
element.className = "b08-grid__table b08-grid__table--summary";
|
||||
element.append(headRow(["구조물", "규격", "성분", "단위", "수량", "갈 곳", "근거"]));
|
||||
element.append(headRow(["구조물", "규격", "성분", "단위", "수량", "갈 곳", "근거", "출처"]));
|
||||
|
||||
const body = document.createElement("tbody");
|
||||
for (const structure of unit.structures) {
|
||||
@@ -333,6 +336,9 @@ export function renderUnitQuantityGrid(response: MaterialResponse): HTMLElement
|
||||
tr.append(textCell(num(component.amount, 3)));
|
||||
tr.append(textCell(DESTINATION_LABELS[component.destination] ?? component.destination));
|
||||
tr.append(textCell(component.basis, "b08-grid__note"));
|
||||
// ⚠ 식에서 나온 값과 실무 관측값이 한 표에 섞인다 — 어느 쪽인지 화면에서 보여야
|
||||
// 나중에 「이 값이 왜 이런가」를 되짚을 수 있다.
|
||||
tr.append(textCell(component.basis_kind === "observed" ? "실무 관측" : "치수 전개"));
|
||||
body.append(tr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"dataset_id": "structure_unit_observed",
|
||||
"effective_date": "2026-01-01",
|
||||
"note": "콘크리트 구조물의 **관측** 원단위표. 품셈에는 구조물별 표준 물량표가 없어(구조물_수량.md · 배수공_수량.md §2) 실무 설계원본에서 뽑은 값이다.",
|
||||
"policy": {
|
||||
"basis": "observed",
|
||||
"no_interpolation": true,
|
||||
"no_invented_dimensions": true,
|
||||
"notes": [
|
||||
"⚠ 관측값은 **그 규격에서만** 맞다. 규격이 다르면 비례로 늘리지 않는다 — 벽 두께·기초는 높이에 비례하지 않는다.",
|
||||
"⚠ 규격이 표에 없으면 「원단위 미확보」로 드러낸다. 가까운 값을 갖다 쓰지 않는다.",
|
||||
"⚠ 줄마다 basis 를 싣는다 — 치수에서 나온 값(derived)과 한 표에 섞이기 때문이다."
|
||||
]
|
||||
},
|
||||
"sources": {
|
||||
"uljin_library": {
|
||||
"doc": "울진소광 구조도 숨김탭 원단위 라이브러리",
|
||||
"path": "resources/knowledge/original/실무문서/_원단위라이브러리_울진소광.md"
|
||||
},
|
||||
"uljin_compare": {
|
||||
"doc": "종합비교 04 — 임도 구조물 원단위 (울진 1공구 수량집계표 관측)",
|
||||
"path": "resources/knowledge/original/실무문서/_종합비교/04_임도구조물_원단위.md"
|
||||
}
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"type_id": "retaining_wall",
|
||||
"spec": { "form": "반중력식", "height_m": 2.0 },
|
||||
"unit": "m",
|
||||
"source": "uljin_library",
|
||||
"source_note": "§7 옹벽류 — 반중력식옹벽 H=2.0",
|
||||
"components": [
|
||||
{ "name": "콘크리트", "unit": "㎥", "amount": 1.35, "destination": "unit_price", "basis_note": "기초 0.75 + 벽체 0.60" },
|
||||
{ "name": "버림콘크리트", "unit": "㎥", "amount": 0.15, "destination": "unit_price" },
|
||||
{ "name": "유로폼", "unit": "㎡", "amount": 3.2, "destination": "unit_price", "basis_note": "배면+전면" },
|
||||
{ "name": "합판거푸집", "unit": "㎡", "amount": 0.6, "destination": "unit_price", "basis_note": "기초" },
|
||||
{ "name": "물구멍", "unit": "m", "amount": 0.32, "destination": "material", "basis_note": "Ø50" },
|
||||
{ "name": "이형철근 D13", "unit": "kg", "amount": 13.45, "destination": "material" },
|
||||
{ "name": "이형철근 D16", "unit": "kg", "amount": 30.42, "destination": "material" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type_id": "pipe_inlet_basin",
|
||||
"spec": { "inlet_basin_form": "돌집수정 ㄷ형" },
|
||||
"unit": "개소",
|
||||
"source": "uljin_compare",
|
||||
"source_note": "관보호공 돌집수정 ㄷ형 /개소",
|
||||
"components": [
|
||||
{ "name": "콘크리트", "unit": "㎥", "amount": 4.03, "destination": "unit_price" },
|
||||
{ "name": "모르터", "unit": "㎥", "amount": 0.157, "destination": "unit_price" },
|
||||
{ "name": "터파기", "unit": "㎥", "amount": 21.1, "destination": "earthwork", "basis_note": "토사 14.8 + 암 6.3 — 지반 구분은 토공집계가 다시 가름" },
|
||||
{ "name": "되메우기", "unit": "㎥", "amount": 2.6, "destination": "earthwork" },
|
||||
{ "name": "잔토처리", "unit": "㎥", "amount": 18.5, "destination": "earthwork" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type_id": "pipe_inlet_basin",
|
||||
"spec": { "inlet_basin_form": "돌집수정 ㄴ형" },
|
||||
"unit": "개소",
|
||||
"source": "uljin_compare",
|
||||
"source_note": "관보호공 돌집수정 ㄴ형 /개소",
|
||||
"components": [
|
||||
{ "name": "콘크리트", "unit": "㎥", "amount": 2.69, "destination": "unit_price" },
|
||||
{ "name": "모르터", "unit": "㎥", "amount": 0.096, "destination": "unit_price" },
|
||||
{ "name": "터파기", "unit": "㎥", "amount": 16.4, "destination": "earthwork", "basis_note": "토사 4.9 + 암 11.5" },
|
||||
{ "name": "되메우기", "unit": "㎥", "amount": 1.2, "destination": "earthwork" },
|
||||
{ "name": "잔토처리", "unit": "㎥", "amount": 15.2, "destination": "earthwork" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type_id": "pipe_inlet_basin",
|
||||
"spec": { "inlet_basin_form": "□형(기본형)", "inlet_basin_material": "콘크리트", "pipe_diameter_mm": "800" },
|
||||
"unit": "개소",
|
||||
"source": "uljin_library",
|
||||
"source_note": "§2 집수정 Ø800 — 내부 3.0×1.0×1.2, 벽 0.2, 바닥기초 3.4×1.4×0.2",
|
||||
"components": [
|
||||
{ "name": "콘크리트", "unit": "㎥", "amount": 2.84, "destination": "unit_price" },
|
||||
{ "name": "합판거푸집", "unit": "㎡", "amount": 21.28, "destination": "unit_price" },
|
||||
{ "name": "이형철근 D13", "unit": "kg", "amount": 4.78, "destination": "material" },
|
||||
{ "name": "면목", "unit": "m", "amount": 12.67, "destination": "material", "basis_note": "A25" },
|
||||
{ "name": "터파기", "unit": "㎥", "amount": 10.64, "destination": "earthwork" },
|
||||
{ "name": "되메우기", "unit": "㎥", "amount": 6.44, "destination": "earthwork" },
|
||||
{ "name": "잔토처리", "unit": "㎥", "amount": 4.2, "destination": "earthwork" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type_id": "ford_pavement",
|
||||
"spec": { "thickness_cm": 20 },
|
||||
"unit": "㎡",
|
||||
"source": "uljin_compare",
|
||||
"source_note": "콘크리트포장 T=20cm /㎡",
|
||||
"components": [
|
||||
{ "name": "레미콘", "unit": "㎥", "amount": 0.2, "destination": "unit_price" },
|
||||
{ "name": "와이어메쉬", "unit": "㎡", "amount": 1.16, "destination": "material" },
|
||||
{ "name": "터파기", "unit": "㎥", "amount": 0.2, "destination": "earthwork" },
|
||||
{ "name": "잔토처리", "unit": "㎥", "amount": 0.2, "destination": "earthwork" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"not_found": {
|
||||
"note": "규격은 우리 모델에 있으나 **관측 원단위가 어디에도 없는** 것. 지어내지 않는다.",
|
||||
"items": [
|
||||
{
|
||||
"type_id": "box_culvert",
|
||||
"why": "울진 2공구에 BOX암거가 실재하나 원단위 라이브러리에 탭이 없음. 게다가 structures.json 의 BOX 제원은 body_width_m·body_height_m 와 날개벽뿐이라 **벽·저판·상판 두께가 없어 전개식도 못 세움**.",
|
||||
"needs": "표준 단면(벽·저판·상판 두께) 확보 — 사용자 확정 대기"
|
||||
},
|
||||
{
|
||||
"type_id": "ford_bridge",
|
||||
"why": "세월교 본체(날개벽 포함) 원단위 없음. 관 부분은 pipe 로 따로 섬.",
|
||||
"needs": "표준도 물량 또는 실무 관측"
|
||||
},
|
||||
{
|
||||
"type_id": "retaining_wall",
|
||||
"spec": { "form": "반중력식", "height_m": 1.6 },
|
||||
"why": "울진 2공구에 H=1.6 이 실재하나 수치가 라이브러리에 없음. H=2.0 값을 비례로 줄이지 않음 — 기초·벽체는 높이에 비례하지 않음."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"dataset_id": "data_work_item_master_manifest",
|
||||
"generated_at": "2026-09-08T00:00:17+09:00",
|
||||
"generated_at": "2026-09-08T00:36:35+09:00",
|
||||
"built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py",
|
||||
"source": {
|
||||
"dataset_id": "pum_forest",
|
||||
@@ -12,8 +12,8 @@
|
||||
"files": [
|
||||
{
|
||||
"file": "work_item_master_2026-01-01.json",
|
||||
"sha256": "593653135d5a2871180e7a3921f9238629b275438ef47230412aa21e9bdd80c0",
|
||||
"size_bytes": 725931
|
||||
"sha256": "fa9a1e9d84e1172568f074f8eb5ca2042eedefecb1146d9d4edf2044df9e26d9",
|
||||
"size_bytes": 771414
|
||||
},
|
||||
{
|
||||
"file": "form_undetermined_2026-01-01.json",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user