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:
2026-09-08 00:38:18 +09:00
co-authored by Claude Opus 5
parent 96c304ac35
commit 513769796f
9 changed files with 1909 additions and 8 deletions
@@ -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
],
+7 -1
View File
@@ -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