feat(b05·b08): 콘크리트 포장 수량식 — 폭·확폭·두께 B06 잇기 + 칸 덮어쓰기 · 면적×두께 · 거푸집 2L · 수축줄눈 6m · 비닐·철망 고르기(12장 D 포장 둘 · 브레인 판정 ①~⑤·ⓐ)

- 등록부: 두께 칸 필수 걷고 「비면 B06 포장층 두께」 · 폭·확폭 면적 칸(비면 B06) · 줄눈 간격 제안 6m(교본 부록 4-7 4-6m · 소광) · 비닐·철망 고르기(기본값 없음)
- 연장은 구간(끝 − 시작) — 물넘이로 나뉜 조각에 길이 칸이 복사되는 자리
- 묶음 12-06#두께 · 12-08 · 12-07-01(12-7-2 줄눈설치는 안 씀 — 소광 실무가 컷팅)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
2026-09-15 03:55:12 +09:00
co-authored by Claude Opus 5
parent d54d52b6eb
commit 3cfe3ef58a
8 changed files with 453 additions and 6 deletions
+51 -1
View File
@@ -2532,8 +2532,58 @@
"input": "number",
"unit": "cm",
"default": null,
"required": true,
"required": false,
"phase": "detail",
"empty_means": "비우면 B06 포장층 두께(표준 횡단면 설정 · 기본 0.2m = 임도기술교본 3-2 「두께 0.2m」)를 이음 — 적으면 적은 값이 이김"
},
{
"key": "width_m",
"label": "포장 폭",
"input": "number",
"unit": "m",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 B06 노폭(구간 측점의 차도 표준 폭 · 교본 3-2 「포장 폭 3.0m」 · 실무 관측 3.5~4.0)을 이음 — 적으면 적은 값이 이김"
},
{
"key": "widening_area_m2",
"label": "확폭 면적",
"input": "number",
"unit": "㎡",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 B06 곡선부 확폭을 측점 사이 평균 × 거리로 더함(교본 3-2 「곡선부에는 확폭」) — 확폭이 없으면 0 을 적을 것"
},
{
"key": "joint_spacing_m",
"label": "수축줄눈 간격",
"input": "number",
"unit": "m",
"default": 6,
"default_basis": "제안값 · 임도기술교본 부록 4-7 7-1 3.5.1 「수축줄눈의 간격은 4-6m를 기준」 · 실무 소광 6m 관측 · 바꿀 수 있음",
"phase": "detail"
},
{
"key": "separation_sheet",
"label": "비닐(깔기·양생)",
"input": "select",
"choices": ["있음", "없음"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 비닐 재료가 안 서고 사유가 뜸 — 품셈 12-6 [주]② 「양생 재료비(비닐 등)는 별도 계상」이라 설계자가 고름(기본값 없음)"
},
{
"key": "wire_mesh",
"label": "철망",
"input": "select",
"choices": ["있음", "없음"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 철망 재료가 안 서고 사유가 뜸 — 품셈 12-6 [주]② 「철망재료비는 별도 계상」 · 교본 부록 7-1 3.4 「설계도서에 따라」 · 실무 소광 「철망포함」 관측"
}
]
},
@@ -419,6 +419,18 @@ def composite_quantities(
entry["not_ready"] = True
entry["why"] = why
missing.append({"code": spec.get("code"), "reason": why})
if suffix == "pavement_thickness":
# 12-6 갈래는 포장두께 20·30·40㎝ — 원단위가 쓴 두께(칸 또는 B06) 그대로(2026-09-15).
thickness = (structure.get("options") or {}).get("thickness_cm")
label = f"{thickness:g}" if isinstance(thickness, (int, float)) else ""
if label in ("20㎝", "30㎝", "40㎝"):
entry["kind"] = label
entry["code"] = f"{spec.get('code')}#{label}"
elif found:
why = f"12-6 표는 포장두께 20·30·40㎝ 갈래뿐 — 두께 {label or '없음'} 은 못 고름"
entry["not_ready"] = True
entry["why"] = why
missing.append({"code": spec.get("code"), "reason": why})
if suffix == "rebar_complexity":
# 갈래는 원문이 정한다 — 화면·인계에 이름과 근거를 함께 실어 사람이 검증하게 한다.
complexity, why = rebar_complexity(
@@ -118,6 +118,8 @@ EXPANDERS = {
"bed_sill": lambda h, l, o, f=None, r="": _bed_sill()(_num(o.get("area_m2"), 0.0), o),
# BOX암거 — m당 × 연장(산림과임업기술 예제 · 울진 2×2 · 2026-09-15 브레인 12장 D).
"box_culvert": lambda h, l, o, f=None, r="": _box_culvert()(o),
# 콘크리트 포장 — 면적 × 두께 · 거푸집 2L · 수축줄눈(교본 3-2 · 부록 4-7 · 소광 · 2026-09-15).
"pavement_concrete": lambda h, l, o, f=None, r="": _pavement().concrete_pavement(l, o),
# ⚠⚠ **큰돌쌓기(`boulder_masonry`)를 여기에 두지 않는다** (2026-09-07 발견).
# 큰돌쌓기는 품셈 **13-6** 이고 돌쌓기는 **13-4** 다 — **규격 축이 다르다.**
# 돌쌓기는 **뒷길이**(35·45·55·60㎝), 큰돌쌓기는 **직경**(40~60·60~80·80~100㎝).
@@ -135,6 +137,13 @@ def _box_culvert():
return box_culvert
def _pavement():
"""콘크리트 포장 모듈을 늦게 가져온다."""
from B08_Quantity import B08_Quantity_Engine_UnitQuantity_Pavement
return B08_Quantity_Engine_UnitQuantity_Pavement
def _soil_guard():
"""흙막이 사유 함수를 늦게 가져온다."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import soil_guard
@@ -183,12 +192,17 @@ EXPANDER_WITHHELD: dict[str, str] = {
#: ⚠ 안 적으면 「m · 연장」으로 읽혀 — 골막이·바닥막이(점 배치라 연장 0)는 **수량 0 이라 내역에 안 서고**
#: 구조물도가 전 줄 「단위당 못 냄」, 떼흙막이는 개소당 값이 연장으로 나뉘어 **떼 0.139/m** 로 보였음.
EXPANDER_BILLING: dict[str, Any] = {
"erosion_check": lambda options: ("개소", 1.0), # 정본 「골막이(찰)(치수조서연결)」 개소당
"soil_guard": lambda options: ("개소", 1.0), # 정본 「떼흙막이」 개소당(평균 붙박이)
"bed_sill": lambda options: ("", _num(options.get("area_m2"), 0.0)), # 정본 돌붙임 ㎡당
"box_culvert": lambda options: ("m", _num(options.get("length_m"), 0.0)), # 점 배치라 연장 칸
"erosion_check": lambda options, length: ("개소", 1.0), # 정본 「골막이(찰)」 개소당
"soil_guard": lambda options, length: ("개소", 1.0), # 정본 「떼흙막이」 개소당(평균 붙박이)
"bed_sill": lambda options, length: ("", _num(options.get("area_m2"), 0.0)), # 돌붙임 ㎡당
"box_culvert": lambda options, length: ("m", _num(options.get("length_m"), 0.0)), # 점 배치
"pavement_concrete": lambda options, length: _pavement().billing(length, options), # 면적 ㎡
}
#: 연장을 **구간(끝 − 시작)** 에서 읽는 종류 — 포장은 물넘이로 나뉘면 길이 칸(10)이 조각마다
#: 그대로 복사돼 칸 값이 구간과 안 맞음(`B05_Profile_UI_Structures_Panel_Commit` 나누기).
SPAN_LENGTH_TYPES = frozenset({"pavement_concrete"})
#: 한 구조물이 **여러 내역 줄**을 낳는 자리. 배수관은 관 자체와 유입부 집수정이 따로 선다
#: (품셈도 관부설과 집수정을 다른 공종으로 둔다). 한 줄로 합치면 어느 쪽 물량인지 못 가른다.
@@ -288,6 +302,11 @@ def expand(
start = _num(structure.get("start_m"))
end = _num(structure.get("end_m"))
length = _num(options.get("length_m")) or abs(end - start)
if type_id in SPAN_LENGTH_TYPES and None not in (
structure.get("start_m"),
structure.get("end_m"),
):
length = abs(end - start)
height = _num(options.get("height_m"))
# ⚠ 레지스트리 이름이 없으면 **코드값(`retaining_wall`)이 그대로 내역에 뜬다** —
# `_Wording.type_label` 이 이미 대비표를 들고 있으므로 그것을 쓴다(2026-09-09 감사).
@@ -340,7 +359,7 @@ def expand(
result.notes.extend(notes)
billing = EXPANDER_BILLING.get(type_id)
if billing is not None and result.components:
result.billing_unit, result.billing_quantity = billing(options)
result.billing_unit, result.billing_quantity = billing(options, length)
return result
@@ -62,6 +62,11 @@ DESTINATION = {
"합판거푸집": "unit_price",
"동바리": "unit_price",
"비계": "unit_price",
# ⭐ 2026-09-15 콘크리트 포장 — 거푸집(12-8)·수축줄눈(12-7-1 절단)은 품 · 비닐·철망은 12-6 [주]② 재료 별도.
"포장거푸집": "unit_price",
"수축줄눈": "unit_price",
"비닐": "material",
"철망": "material",
}
#: ⛔ **갈 곳 기본값 금지**(명세 13장 「빠진 것이 조용해짐」) — 표에 없는 이름은 막고 사유를 냄.
@@ -0,0 +1,171 @@
"""콘크리트 포장 전개식 — **면적 × 두께 · 거푸집 양면 · 수축줄눈** (2026-09-15 브레인 포장 둘).
근거
① 임도기술교본 3-2 기본설계 — 「포장 폭의 3.0m, 두께 0.2m를 기준으로 곡선부에는 확폭」
② 임도기술교본 부록 4-7 7-1 3.5.1 — 「수축줄눈의 간격은 4-6m를 기준」
③ 산림품셈 12-6 콘크리트 포장(인력) — 비닐·철망깔기·포설·양생 포함 · 거푸집·줄눈 제외 ·
[주]② 비닐·양생재·철망 재료 별도 · 12-7-1 포장절단 · 12-8 콘크리트 포장 거푸집(연장 m)
④ 실무 울진소광 `02-1-공종수량산출` 「포장및난간개거」 — 면적 = B × L · 거푸집(1면기준) = 2L ·
수축줄눈 = 내림(L÷6) × B · 「포장확폭」 면적 = 거리 × 확폭 · 신축줄눈 = 면적 ÷ 6
⭐ 폭·확폭·두께는 **B06 이 이미 앎** — 빈 칸만 B06 에서 잇고 칸이 이김(판정 ①④ · 한 값 원칙).
"""
from __future__ import annotations
import math
from typing import Any
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import Component, _num, routed
SOURCES = (
"산식 = 임도기술교본 3-2(폭·두께·곡선부 확폭) · 부록 4-7 7-1(수축줄눈 4-6m) · 실무 울진소광"
" 「포장및난간개거」·「포장확폭」(면적 B×L · 거푸집 2L · 줄눈 내림(L÷간격)×B"
" · 확폭 줄눈 면적÷간격)"
)
NOTE_JOINT = (
"수축줄눈 = 12-7-1 포장절단(m)으로 이음 — 12-7-2 줄눈설치는 안 씀 — 소광 실무가 컷팅"
"(「수축줄눈(컷팅)」) · 줄눈재를 넣는 공법이면 12-7-2 로 물을 것"
)
NOTE_FORM = (
"포장거푸집 = 양쪽 가장자리 2 × 연장(소광 「1면기준」) · 12-8 이 강재 3m·핀폴·20회를 품에 둠"
)
SHEET_UNCHOSEN = (
"비닐(깔기·양생)을 안 고름 — 품셈 12-6 [주]② 「양생에 필요한 재료비(비닐, 양생재 등)는 별도"
" 계상」이라 설계자가 고를 것(기본값 없음)"
)
MESH_UNCHOSEN = (
"철망을 안 고름 — 품셈 12-6 [주]② 「철망재료비는 별도 계상」 · 교본 부록 7-1 3.4 「설계도서에"
" 따라」 · 실무 소광은 「철망포함」 관측 — 설계자가 고를 것(기본값 없음)"
)
MISSING = "콘크리트 포장 {what} — 칸이 비고 B06 에서도 못 읽어 수량이 안 섬 · 칸에 적을 것"
JOINT = "joint_spacing_m"
def _stations(designs: list[dict[str, Any]] | None) -> list[tuple[float, dict[str, Any]]]:
rows = [
(_num(item.get("chainage_m")), item.get("design"))
for item in designs or ()
if isinstance(item, dict) and isinstance(item.get("design"), dict)
]
return sorted(rows, key=lambda row: row[0])
def _widening(design: dict[str, Any]) -> float:
return _num(design.get("widening_left_m")) + _num(design.get("widening_right_m"))
def _widening_at(stations: list[tuple[float, dict[str, Any]]], at: float) -> float:
"""그 자리의 확폭 — 앞뒤 측점 사이 직선 보간 · 끝 밖이면 가장 가까운 측점."""
if at <= stations[0][0]:
return _widening(stations[0][1])
for (s0, d0), (s1, d1) in zip(stations, stations[1:]):
if s0 <= at <= s1:
t = 0.0 if s1 == s0 else (at - s0) / (s1 - s0)
return _widening(d0) + (_widening(d1) - _widening(d0)) * t
return _widening(stations[-1][1])
def pavement_from_designs(
structure: dict[str, Any], designs: list[dict[str, Any]] | None
) -> dict[str, Any]:
"""빈 칸(폭·확폭·두께)을 B06 구간 측점에서 채운 **새** 구조물 — 칸이 차 있으면 그대로."""
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
options = dict(structure.get("options") or {})
notes = list(structure.get("notes") or [])
# 상세 칸은 배치 폼에 없어 제안값이 저장에 안 들어감 — 등록부 한 벌에서 채움(값 두 벌 금지).
spacing = next(o for o in structure_type_map()["pavement_concrete"].options if o.key == JOINT)
if options.get(JOINT) in (None, "") and spacing.default is not None:
options[JOINT] = spacing.default
notes.append(f"수축줄눈 간격 {spacing.default:g}m — {spacing.default_basis}")
stations = _stations(designs)
start, end = sorted((_num(structure.get("start_m")), _num(structure.get("end_m"))))
inside = [design for chainage, design in stations if start <= chainage <= end]
if stations and not inside:
inside = [min(stations, key=lambda row: abs(row[0] - (start + end) / 2))[1]]
if options.get("width_m") in (None, "") and inside:
widths = {round(_num(d.get("carriageway_standard_width_m")), 3) for d in inside} - {0.0}
if len(widths) == 1:
options["width_m"] = widths.pop()
notes.append(f"포장 폭 {options['width_m']:g}m — B06 노폭(구간 측점 {len(inside)}곳)")
elif widths:
notes.append(f"포장 폭 — 구간 측점의 B06 노폭이 갈림({sorted(widths)}) · 칸에 적을 것")
if options.get("thickness_cm") in (None, "") and inside:
thick = {round(_num(d.get("pavement_thickness_m")), 4) for d in inside} - {0.0}
if len(thick) == 1:
options["thickness_cm"] = round(thick.pop() * 100, 2)
notes.append(f"포장 두께 {options['thickness_cm']:g}㎝ — B06 포장층 두께")
if options.get("widening_area_m2") in (None, "") and stations and end > start:
cuts = [start, *[s for s, _d in stations if start < s < end], end]
area = sum(
(_widening_at(stations, a) + _widening_at(stations, b)) / 2 * (b - a)
for a, b in zip(cuts, cuts[1:])
)
options["widening_area_m2"] = round(area, 4)
notes.append(f"확폭 면적 {area:.2f}㎡ — B06 곡선부 확폭 × 측점 사이 거리(평균)")
return {**structure, "options": options, "notes": notes}
def concrete_pavement(length: float, options: dict[str, Any]) -> tuple[list[Component], list[str]]:
"""콘크리트 포장 한 구간 — (성분, 사유). 면적 밑수가 없으면 성분 없이 사유만."""
width = _num(options.get("width_m"))
thickness = _num(options.get("thickness_cm")) / 100
widening = options.get("widening_area_m2")
missing = [
what
for what, value in (
("연장", length),
("", width),
("두께", thickness),
("확폭 면적", 1.0 if isinstance(widening, (int, float)) else 0.0),
)
if value <= 0
]
if missing:
return [], [MISSING.format(what="·".join(missing))]
spacing = _num(options.get("joint_spacing_m"))
widening = float(widening)
area = width * length + widening
rows: list[tuple[str, str, float, str]] = [
(
"콘크리트",
"",
area * thickness,
f"(폭 {width:g} × 연장 {length:g} + 확폭 {widening:g}) × 두께 {thickness:g}",
),
("포장거푸집", "m", 2 * length, f"양쪽 2 × 연장 {length:g}"),
]
notes = [SOURCES, NOTE_FORM]
if spacing > 0:
joints = math.floor(length / spacing) * width + widening / spacing
rows.append(
(
"수축줄눈",
"m",
joints,
f"내림({length:g} ÷ {spacing:g}) ×{width:g} + 확폭 {widening:g} ÷ {spacing:g}",
)
)
notes.append(NOTE_JOINT)
for key, name, unchosen in (
("separation_sheet", "비닐", SHEET_UNCHOSEN),
("wire_mesh", "철망", MESH_UNCHOSEN),
):
choice = options.get(key)
if choice == "있음":
rows.append((name, "", area, f"포장 면적 {area:.4g}"))
elif choice != "없음":
notes.append(unchosen)
components = [
Component(name, unit, amount, destination, basis)
for name, unit, amount, basis in rows
if (destination := routed(name, notes))
]
return components, notes
def billing(length: float, options: dict[str, Any]) -> tuple[str, float]:
"""내역 줄 — 포장 면적 ㎡(소광 「콘크리트포장 T=20 ㎡」)."""
widening = options.get("widening_area_m2")
extra = float(widening) if isinstance(widening, (int, float)) else 0.0
return "", _num(options.get("width_m")) * length + extra
@@ -97,6 +97,11 @@ def _collect_structures(
if definition.reference_only:
skipped.append(f"{definition.name}: 전문 상세설계 대상 — 배치까지만")
continue
if type_id == "pavement_concrete":
# 폭·확폭·두께는 B06 이 앎 — 빈 칸만 이음(2026-09-15 브레인 포장 둘 ①④).
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Pavement import pavement_from_designs
payload = pavement_from_designs(payload, designs)
if definition.group == "B":
# ⚠ B군(종단배수)은 **연장표**로 간다 — `common_util_structure_lengths` 가
# 겹친 구간을 합쳐 주기 때문이다. 구조물별로 세면 겹친 구간을 두 번 센다.
@@ -492,6 +492,33 @@
],
"why": "품셈 12장에 「BOX암거」 공종이 없음 — 산림과임업기술(임도) 5장 수량산출 예제가 구체콘크리트·기초·거푸집·동바리·철근으로 가르고, 실무 울진 기번3 「암거수량집계표(2×2)」가 레미콘·버림·유로폼·합판·철근·동바리·비계로 셈(2026-09-15 브레인 12장 D).",
"needs": "일위대가 조립은 B09 몫 — B08 은 물량과 묶음만 넘김"
},
{
"type_id": "pavement_concrete",
"parts": [
{
"code": "FP-12-06",
"name": "콘크리트 포장(인력시공)",
"unit": "㎥",
"from_components": ["콘크리트"],
"kind_suffix": "pavement_thickness"
},
{
"code": "FP-12-08",
"name": "콘크리트 포장 거푸집",
"unit": "m",
"from_components": ["포장거푸집"]
},
{
"code": "FP-12-07-01",
"name": "포장절단",
"unit": "m",
"from_components": ["수축줄눈"],
"why": "수축줄눈은 12-7-1 포장절단으로 이음 — 12-7-2 줄눈설치는 안 씀 — 소광 실무가 컷팅(「수축줄눈(컷팅)」 · 2026-09-15 브레인 ⓐ)"
}
],
"why": "실무 내역은 「콘크리트포장 T=20 ㎡」 한 줄이고 품셈은 포설(12-6 ㎥ · 비닐·철망깔기·양생 포함) · 거푸집(12-8 연장 m) · 줄눈(12-7) 을 따로 둠 — 거푸집·줄눈 제외라 묶음으로 셈(2026-09-15 브레인 12장 D 포장 둘). 콘크리트·비닐·철망 재료는 자재 축.",
"needs": "일위대가 조립은 B09 몫 — B08 은 물량과 묶음만 넘김"
}
],
"unit_conversion": {
@@ -0,0 +1,158 @@
"""콘크리트 포장 전개식 — 면적 × 두께 · 거푸집 양면 · 수축줄눈 (2026-09-15 브레인 포장 둘 ①~⑤·ⓐ).
근거
임도기술교본 3-2 기본설계 — 포장 폭 3.0m · 두께 0.2m · 곡선부 확폭
임도기술교본 부록 4-7 7-1 3.5.1 — 「수축줄눈의 간격은 4-6m를 기준」
산림품셈 12-6 콘크리트 포장(인력) · 12-7-1 포장절단 · 12-8 콘크리트 포장 거푸집
실무 울진소광 「포장및난간개거」 — 면적 = B × L · 거푸집(1면기준) = 2L ·
수축줄눈 = 내림(L÷6) × B (103m·B 3.5 → 59.5 · 418m → 241.5) · 「포장확폭」 줄눈 = 면적 ÷ 6
판정 — 폭·확폭·두께는 **B06 에서 잇고 칸이 이김**(한 값 원칙) · 비닐·철망은 고르기(기본값 없음).
"""
from __future__ import annotations
import pytest
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Pavement import pavement_from_designs
FILLED = {
"width_m": 3.0,
"widening_area_m2": 6.0,
"thickness_cm": 20,
"joint_spacing_m": 6,
"separation_sheet": "있음",
"wire_mesh": "없음",
}
def _row(options: dict, start: float = 100.0, end: float = 130.0) -> dict:
structure = {
"structure_id": "p1",
"type_id": "pavement_concrete",
"start_m": start,
"end_m": end,
# 구간을 물넘이로 나누면 길이 칸(10)이 조각마다 그대로 복사됨 — 연장은 구간에서 읽음.
"options": {"length_m": 10, **options},
}
return build_table([structure], {"pavement_concrete": "콘크리트 포장"})["structures"][0]
def test_칸이_찬_구간은_면적_두께_거푸집_줄눈이_선다() -> None:
row = _row(FILLED)
got = {c["name"]: c for c in row["components"]}
area = 3.0 * 30 + 6.0
assert (row["billing_unit"], row["billing_quantity"]) == ("", pytest.approx(area))
assert got["콘크리트"]["amount"] == pytest.approx(area * 0.2)
assert got["콘크리트"]["destination"] == "material"
assert got["포장거푸집"]["amount"] == pytest.approx(2 * 30)
# 내림(30 ÷ 6) × 3.0 + 확폭 6 ÷ 6
assert got["수축줄눈"]["amount"] == pytest.approx(5 * 3.0 + 1.0)
assert got["비닐"]["amount"] == pytest.approx(area) and "철망" not in got
text = " ".join(row["notes"])
assert "12-7-2 줄눈설치는 안 씀 — 소광 실무가 컷팅" in text
@pytest.mark.parametrize(("length", "joints"), [(103.0, 59.5), (418.0, 241.5)])
def test_수축줄눈은_소광_관측과_같다(length: float, joints: float) -> None:
row = _row({**FILLED, "width_m": 3.5, "widening_area_m2": 0}, 0.0, length)
got = {c["name"]: c["amount"] for c in row["components"]}
assert got["수축줄눈"] == pytest.approx(joints)
def test_비닐_철망을_안_고르면_사유() -> None:
row = _row({k: v for k, v in FILLED.items() if k not in ("separation_sheet", "wire_mesh")})
names = {c["name"] for c in row["components"]}
assert "비닐" not in names and "철망" not in names and "콘크리트" in names
text = " ".join(row["notes"])
assert "비닐" in text and "철망" in text and "12-6" in text
DESIGNS = [
{"chainage_m": 90.0, "design": {"carriageway_standard_width_m": 3.0, "paved": False}},
{
"chainage_m": 100.0,
"design": {
"carriageway_standard_width_m": 3.0,
"widening_right_m": 1.0,
"paved": True,
"pavement_thickness_m": 0.2,
},
},
{
"chainage_m": 120.0,
"design": {
"carriageway_standard_width_m": 3.0,
"widening_left_m": 0.5,
"paved": True,
"pavement_thickness_m": 0.2,
},
},
{
"chainage_m": 140.0,
"design": {"carriageway_standard_width_m": 3.0, "paved": True, "pavement_thickness_m": 0.2},
},
]
def _structure(**options) -> dict:
return {
"type_id": "pavement_concrete",
"start_m": 100.0,
"end_m": 130.0,
"options": {"length_m": 10, **options},
}
def test_빈_칸은_B06_폭_확폭_두께를_잇는다() -> None:
filled = pavement_from_designs(_structure(), DESIGNS)
options = filled["options"]
assert options["width_m"] == pytest.approx(3.0)
assert options["thickness_cm"] == pytest.approx(20.0)
# 100 → 1.0 · 120 → 0.5 · 130 → 0.25(120~140 사이 보간): (1.0+0.5)/2×20 + (0.5+0.25)/2×10
assert options["widening_area_m2"] == pytest.approx(15.0 + 3.75)
assert any("B06" in note for note in filled["notes"])
# 상세 칸은 배치 폼에 없어 저장에 없음 — 등록부 제안값 6m 을 사유와 함께 채움.
assert options["joint_spacing_m"] == 6 and any("4-6m" in note for note in filled["notes"])
def test_칸을_적으면_칸이_이긴다() -> None:
filled = pavement_from_designs(_structure(width_m=3.5, widening_area_m2=0), DESIGNS)
assert filled["options"]["width_m"] == 3.5 and filled["options"]["widening_area_m2"] == 0
assert filled["options"]["thickness_cm"] == pytest.approx(20.0)
def test_B06_설계도_칸도_없으면_안_선다() -> None:
filled = pavement_from_designs(_structure(), [])
row = build_table([filled])["structures"][0]
assert row["components"] == []
assert any("" in note for note in row["notes"])
def test_인계_묶음은_12_6_두께_갈래_12_8_12_7_1() -> None:
table = build_table(
[{**_structure(**FILLED), "structure_id": "p1"}], {"pavement_concrete": "콘크리트 포장"}
)
row = next(
r for r in build_handoff(unit_quantity_table=table)["work_items"] if "포장" in r["name"]
)
parts = {part["code"]: part for part in row["composite_parts"] or []}
area = 3.0 * 30 + 6.0
assert parts["FP-12-06#20㎝"]["quantity"] == pytest.approx(area * 0.2)
assert parts["FP-12-08"]["quantity"] == pytest.approx(60.0)
assert parts["FP-12-07-01"]["quantity"] == pytest.approx(16.0)
def test_등록부_칸() -> None:
options = {o.key: o for o in structure_type_map()["pavement_concrete"].options}
assert not options["thickness_cm"].required and "B06" in options["thickness_cm"].empty_means
assert "B06" in options["width_m"].empty_means and "3.5" in options["width_m"].empty_means
assert "B06" in options["widening_area_m2"].empty_means
assert (
options["joint_spacing_m"].default == 6
and "4-6m" in options["joint_spacing_m"].default_basis
)
for key in ("separation_sheet", "wire_mesh"):
assert options[key].default is None and options[key].choices == ["있음", "없음"]