Merge remote-tracking branch 'origin/dev' into main_desktop_1

This commit is contained in:
2026-09-14 00:55:27 +09:00
249 changed files with 141493 additions and 5964 deletions
+2 -3
View File
@@ -73,14 +73,13 @@ from B06_Section.B06_Section_Schema import (
SectionRegenerateRequest,
SectionSummaryResponse,
)
from B06_Section.B06_Section_Server_Calc_Prebuild import conversion_factors_for
from B06_Section.B06_Section_Server_Calc_Prebuild import conversion_factors_for, haul_limits_for
from common_util.common_util_auth import verify_session
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
from common_util.common_util_workflow_state import get_workflow_state
from config.config_db import get_db_pool, run_with_connection
from config.config_system import (
EARTHWORK_HAUL_EQUIPMENT_LIMITS_M,
FOREST_ROAD_MIN_WIDTH_M,
NATURAL_SPOIL_MIN_GROUND_SLOPE,
SECTION_VERTICAL_EXAGGERATION,
@@ -149,7 +148,7 @@ async def get_section_context(project_id: UUID) -> SectionContextResponse | JSON
earthwork_conversion=await conversion_factors_for(project_id),
haul_equipment_limits=[
HaulEquipmentLimit(key=key, max_distance_m=limit)
for key, limit in EARTHWORK_HAUL_EQUIPMENT_LIMITS_M
for key, limit in await haul_limits_for(project_id)
],
natural_spoil_min_ground_slope=NATURAL_SPOIL_MIN_GROUND_SLOPE,
)
+4 -1
View File
@@ -27,6 +27,7 @@ from B06_Section.B06_Section_Server_Calc_Prebuild import (
_mass_haul_context,
conversion_factors_for,
haul_inputs_for,
haul_limits_for,
)
from common_util.common_util_node_bundle import run_bundle_json
@@ -66,6 +67,8 @@ async def compute_haul_plan(
haul_inputs = await haul_inputs_for(project_id)
# 곡선이 쓰는 계수도 프로젝트가 고른 값으로 — 토적표·운반표와 같은 값이어야 한다.
factors = await conversion_factors_for(project_id)
# 장비 거리 경계도 프로젝트 값으로 — 도쟈 한계거리를 고쳤으면 배분이 그 값으로 선다.
limits = await haul_limits_for(project_id)
try:
output = await asyncio.to_thread(
run_bundle_json,
@@ -73,7 +76,7 @@ async def compute_haul_plan(
_NPM_SCRIPT,
{
"haul_plan_for": result,
"context": _mass_haul_context(haul_inputs, factors),
"context": _mass_haul_context(haul_inputs, factors, limits),
},
)
except Exception:
@@ -40,6 +40,7 @@ from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
from common_util.common_util_node_bundle import run_bundle_json
from common_util.common_util_project_settings import (
earthwork_conversion_factors,
haul_equipment_limits,
quantity_settings,
)
from common_util.common_util_storage import resolve_stored_project_path
@@ -98,9 +99,21 @@ async def conversion_factors_for(project_id: Any) -> dict[str, dict[str, float]]
return earthwork_conversion_factors(quantity_settings(root))
async def haul_limits_for(project_id: Any) -> list[tuple[str, float | None]]:
"""이 프로젝트가 쓸 운반장비 거리 경계(도쟈 한계거리를 고쳤으면 그 값). 못 읽으면 기본값."""
try:
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
root = resolve_stored_project_path(stored_path)
except Exception:
logger.warning("B06 프로젝트 경로를 못 찾음 — 기본 경계로 진행: project_id=%s", project_id)
return list(EARTHWORK_HAUL_EQUIPMENT_LIMITS_M)
return haul_equipment_limits(quantity_settings(root))
def _mass_haul_context(
haul_inputs: dict[str, Any] | None = None,
factors: dict[str, dict[str, float]] | None = None,
limits: list[tuple[str, float | None]] | None = None,
) -> dict[str, Any]:
"""유토곡선 계산에 필요한 값 — 화면이 `sections/context`로 받는 것과 같은 상수다.
@@ -118,9 +131,10 @@ def _mass_haul_context(
# 프로젝트가 고른 계수가 있으면 그것, 없으면 정본 기본값.
"earthwork_conversion": factors or EARTHWORK_CONVERSION_FACTORS,
"natural_spoil_min_ground_slope": NATURAL_SPOIL_MIN_GROUND_SLOPE,
# 프로젝트가 도쟈 한계거리를 고쳤으면 그 값, 없으면 정본 기본값.
"haul_equipment_limits": [
{"key": key, "max_distance_m": limit}
for key, limit in EARTHWORK_HAUL_EQUIPMENT_LIMITS_M
for key, limit in (limits or EARTHWORK_HAUL_EQUIPMENT_LIMITS_M)
],
# ⚠ B08 이 아직 이 값을 내지 않는다(2026-09-09) — 그때까지 `None`(아직 안 옴)이다.
# 값을 내기 시작하면 여기에 실어 주기만 하면 통로가 이어진다.
@@ -220,7 +234,9 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
{
"detail": detail,
"context": _mass_haul_context(
haul_inputs, earthwork_conversion_factors(quantity_settings(project_root))
haul_inputs,
earthwork_conversion_factors(quantity_settings(project_root)),
haul_equipment_limits(quantity_settings(project_root)),
),
},
)
@@ -33,6 +33,8 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Forms import judged_form
ROOT = Path(__file__).resolve().parent.parent
SOURCE = ROOT / "resources" / "data_cost_input_value" / "pum_forest_2026.json"
OUT_DIR = ROOT / "resources" / "data_work_item_master"
@@ -276,8 +278,9 @@ BASIS_RE = re.compile(r"(\d[\d,.]*)\s*(㎥|m3|㎡|m2|㏊|ha|km|㎞|m|인|본|개
# ⚠ **「당」 또는 「단위:」 가 있어야 밑수다.** 둘 다 없으면 규격일 뿐이다 —
# 만들다 실제로 걸렸다: `(무한궤도,0.7㎥)` 를 「0.7㎥당」으로 읽어 5건이 잘못 잡혔다.
#: ⚠ `(단위: 인/㎡당)` 꼴 — **분모가 밑수**다. 값의 단위(인)를 밑수로 읽으면 뜻이 뒤집힌다.
#: ⚠ 「단위:」 없이 `(인/100m당)` 으로만 적은 표가 22 곳이다(조림 5장·단끊기 5-16-1 등, 2026-09-13).
SOURCE_BASIS_RATIO_RE = re.compile(
r"[((]\s*단위\s*[::]\s*[^))/]+/\s*([\d,]*\.?\d*)\s*"
r"[((]\s*(?:단위\s*[::]\s*)?[^))/::]+/\s*([\d,]*\.?\d*)\s*"
r"(㎥|m3|㎡|m2|㏊|ha|㎞|km|m|매|본|개소|개|인|공|kg|㎏|톤|ton|주|식|대)\s*당\s*[))]"
)
@@ -563,16 +566,6 @@ def capacity_formula_pending(table: dict[str, Any]) -> bool:
return bool((keys | cells) & CAPACITY_SYMBOLS)
def variant_axis(table: dict[str, Any]) -> list[str]:
"""행이 갈리는 축 — 표의 첫 열 값들(토질·암종·규격). 값 열은 뺀다."""
seen: list[str] = []
for row in table.get("rows", []):
key = norm(row[0]) if row else ""
if key and key not in seen:
seen.append(key)
return seen[:24]
def basis_quantity_is_grouped(quantity: float | None) -> bool:
"""「10㎡당」처럼 **묶음 기준**인가. 1 이 아니면 곱셈이 그만큼 갈린다."""
return quantity is not None and abs(quantity - 1.0) > 1e-9
@@ -580,6 +573,10 @@ def basis_quantity_is_grouped(quantity: float | None) -> bool:
def build() -> dict[str, Any]:
data = json.loads(SOURCE.read_text(encoding="utf-8"))
# 사람 판정 선언(표 형태·부모 모양)은 절 번호로 적혀 판에 묶임 — 판이 다르면 멈춤(명세 17장).
from common_util.common_util_pum_edition import require_code_edition
require_code_edition(data["effective_date"], "공종 마스터 빌드 — 표 형태·부모 모양 판정")
tables = data["variables"]["pum"]["tables"]
# 원문을 함께 연다 — 밑수가 표 밖(본문)에 있기 때문이다. 못 열면 밑수 없이 간다.
source_lines: list[str] = []
@@ -608,6 +605,8 @@ def build() -> dict[str, Any]:
number = section_number(section)
chapter = number.split("-")[0] if number else None
form, why = detect_form(table, chapter)
# 사람이 가른 형태가 자동 판정을 이긴다 — 표마다 까닭 한 줄(미판정 표 판정, 2026-09-13).
form, why = judged_form(table["table_id"], number) or (form, why)
# ⚠ **본문이 정본이다.** 표 안을 긁는 쪽은 보조 — 비고에 적힌 다른 기준
# (「10㎡당」 같은 참고 문구)을 그 표의 밑수로 잘못 물어 온다.
# 실제로 13-3-1 이 본문 `(단위: ㎥당)` 인데 표 안 긁기가 `10㎡` 를 물어 왔다.
@@ -669,7 +668,8 @@ def build() -> dict[str, Any]:
"special_glyphs": special_glyphs(table),
# 공식 기호가 이 표에 직접 있는가 — 없으면 앞 표에서 물려받는 모양이다.
"capacity_formula_here": capacity_formula_pending(table),
"variant_key": variant_axis(table),
# 갈래 키 — 부모 모양을 단 뒤 표 읽기가 가른 대로 채움(`_Variants`). 못 가르면 빈칸.
"variant_key": [],
"condition_note": [norm(h) for h in table.get("headers", []) if norm(h)],
"raw_row": table.get("rows", []), # 원문 셀 — B09 자원 축이 읽는다.
}
@@ -694,6 +694,10 @@ def build() -> dict[str, Any]:
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Parents import apply_parent_modes
apply_parent_modes(nodes)
# 갈래 키(명세 14장 정정) — 표 모양 가르기는 B09 표 읽기 한 벌을 그대로 돌려 씀.
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Variants import attach_variant_keys
attach_variant_keys(nodes, data["effective_date"])
src_meta = {
"dataset_id": data["dataset_id"],
@@ -706,6 +710,8 @@ def build() -> dict[str, Any]:
"schema_version": SCHEMA_VERSION,
"dataset_id": "work_item_master_forest",
"effective_date": data["effective_date"],
# 공종 코드(`FP-*` 절 번호)가 묶인 품셈 판 — 자원 축·인계 줄이 이 값을 싣는다.
"pum_edition": data["effective_date"],
"generated_at": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
"dataset_version": src_meta,
"policy": {
@@ -0,0 +1,158 @@
"""공종 마스터 — **사람이 가른 표 형태** (2026-09-13 축 C 1장 · 미판정 표 판정).
자동 판정(`detect_form`)이 못 가르거나(undetermined 76표) 잘못 가른 표만 싣는다.
표마다 **왜 그렇게 갈랐는지 한 줄** — 원문 `(산림청고시 제2025-82호) 산림사업 표준품셈.md`.
⚠⚠ 뒤집히면 20배 틀림 — `productivity`(1÷값) / `requirement`(값÷밑수)는 **표 머리와 표 바로 위
본문의 단위 표현**으로만 가른다. 행 이름·비고의 직종 이름에 끌려 읽지 않는다.
⚠ **못 가른 표는 싣지 않는다** — undetermined 로 남는다(막갈이 깊이표·드론운반 인원 등 밑수 불명).
⚠ `coefficient`·`reference` 로 가른 표는 일위대가 줄로 안 읽힌다(자원 축이 건너뜀) — 시공능력
공식 계수는 형태와 상관없이 공식 읽기가 표를 직접 본다.
⚠ 표 번호는 원천 차례로 매긴 것이라 **절 번호를 함께 대조**한다 — 어긋나면 빌드를 멈춘다.
"""
from __future__ import annotations
#: 표 번호 → (절 번호, 형태, 까닭).
FORM_JUDGMENTS: dict[str, tuple[str, str, str]] = {
# ── 제2장 재료 기준 ─────────────────────────────────────────────
"F0044": (
"2-1-1",
"reference",
"기계별 주연료(ℓ/일·대)·잡품(%) — 행이 자원이 아니라 기계, 재료비 산출 기준",
),
"F0045": ("2-1-1", "reference", "위와 같은 모양(2드럼 케이블윈치)"),
"F0047": (
"2-1-2",
"reference",
"기계별 경유(ℓ/일·대)·잡품(%) — [주]③ 「1일당 소요량」, 행이 기계",
),
"F0048": ("2-1-3", "reference", "머리 「소요비용(원)」 — 품이 아니라 교체 1회당 금액"),
"F0054": ("2-1-5", "requirement", "본문 「(본당)」 + 행 「소금처리량(g)」 — 본당 재료 소요량"),
"F0063": (
"2-1-13",
"reference",
"「소모율 0.00125개/hr」 — 기계 시간당 소모품, 공종 밑수가 아님",
),
# ── 제5장 조림·녹화 ─────────────────────────────────────────────
"F0100": ("5-3-1", "reference", "수종별 묘목 규격(소·중·대묘) 표"),
"F0101": ("5-3-1", "reference", "간장·근원경 규격 기준 표"),
"F0105": ("5-3-4", "coefficient", "지주목 미설치 감률 「인력품의 10%·20%」"),
"F0107": ("5-3-5", "coefficient", "위와 같은 감률(흉고직경 표)"),
"F0137": ("5-24-2", "reference", "머리 「종자배합비율(%)」 조견표"),
"F0138": ("5-24-2", "reference", "위와 같은 조견표(기타 적용지역)"),
"F0146": ("5-29", "coefficient", "행이 K(버킷계수)·f·E·Cm — 굴착기 작업량 공식 계수"),
# ── 제7장 수확·운재 ─────────────────────────────────────────────
"F0187": ("7-9-2", "coefficient", "본문 「(단위 : %)」 — 집재재적·거리별 수집량 증가율"),
"F0189": ("7-11", "productivity", "표 바로 위 「(㎥/1대, 1일)」 — 대당 일 작업량"),
"F0190": ("7-11", "productivity", "위와 같음(최대집재거리 80m 이하)"),
"F0194": ("7-13", "reference", "검척 산출 예시(말구직경×개수) 표"),
"F0196": (
"7-15-1",
"productivity",
"표 바로 위 「(㎥/1대, 1일)」 — 「운반량」 줄이 작업량, 「회수」 줄은 참고",
),
"F0197": (
"7-15-2",
"productivity",
"표 바로 위 「(㎥/1대, 1일)」 — 「운재량」 줄이 작업량, [주]① 1대 1일",
),
"F0198": ("7-16", "productivity", "표 바로 위 「(㎥/1대, 1일)」 — 「운반량」 줄이 작업량"),
"F0199": ("7-17", "productivity", "표 바로 위 「(㎥/1대, 1일)」 — 「운반량」 줄이 작업량"),
"F0200": ("7-18", "productivity", "표 바로 위 「(㎥/1대, 1일)」 — 「운반량」 줄이 작업량"),
# ── 제8장 방제 ──────────────────────────────────────────────────
"F0205": ("8-2-1", "reference", "약제 이름·제형 목록"),
"F0208": ("8-2-2", "reference", "선정약제 목록 + 원액 주입량(㎖/㎝) 기준 — 품 표는 따로 있음"),
"F0212": ("8-2-3", "reference", "위와 같은 선정약제 목록"),
"F0216": ("8-2-4", "reference", "위와 같은 선정약제 목록"),
"F0218": ("8-2-5", "reference", "위와 같은 선정약제 목록"),
"F0453": (
"8-2-3",
"reference",
"〔부록 2〕 산림사업별 적용 가능 공종 목록 — 절 경계가 밀려 붙음",
),
"F0220": (
"8-3",
"productivity",
"머리 「벌목조재 (㎥/인)·무더기 훈증 (RM/인)·그루터기 박피 (본/인)」 — 인당 작업량",
),
"F0221": ("8-3", "coefficient", "본문 「(단위: %)」 — 수종·직경별 지조율"),
"F0222": ("8-3", "coefficient", "머리 「비율(수간재적의 %)」 — 근주재적 산정율"),
"F0227": ("8-6-1", "reference", "기종별 희석배수별 약제량(L, 1회) — 살포 계산표"),
"F0231": ("8-6-3", "reference", "희석배수별 약제소요량(㎖) — 물의 양 대비 계산표"),
# ── 제9장 토공 — 시공능력 공식 계수 ────────────────────────────
"F0281": ("9-15-1", "coefficient", "행이 K·f·E·Cm — [주]② Q1=3600×q×K×f×E/Cm"),
"F0282": ("9-15-2", "coefficient", "행이 T·L·E·q — 불도저 작업량 공식 계수"),
"F0284": ("9-16-2", "coefficient", "행이 V·W·E·D — [주]② Q=1000×V×W×E×D×f/N"),
"F0285": ("9-16-3", "coefficient", "흡입준비·운반·흡입·대기 시간 — 살수차 사이클 시간"),
"F0287": ("9-18", "coefficient", "행이 K·f·E·Cm — [주]① Q1=3600×q×K×f×E/Cm"),
"F0295": ("9-22", "coefficient", "행이 K·f·E·Cm — [주]③ Q1=3600×q×K×f×E/Cm"),
# ── 제10장 운반 ─────────────────────────────────────────────────
"F0300": ("10-3-3", "reference", "「운반비·하차비」 적용 항목 — 값이 없는 구성 안내"),
"F0301": ("10-4", "coefficient", "t1·t2·t3·t4 — 트레일러 운반 사이클 시간"),
"F0308": ("10-7-4", "coefficient", "1사이클당 운반량 q — 모노레일 공식 계수"),
"F0309": ("10-7-4", "coefficient", "본문 「(단위: 분/1회당)」 — 적재·짐부리기 시간"),
"F0316": ("10-9-1", "reference", "규격별 1회당 표준 운반량(kg·개·㎡) — 적재 기준"),
"F0319": ("10-11", "coefficient", "행이 L·E·q0 — [주]② q=3.2×e, Cm 공식 계수"),
"F0320": ("10-12-1", "coefficient", "행이 K·E0·Cm — 굴착기 적재 공식 계수"),
"F0321": ("10-12-1", "coefficient", "적재·t2·적하 시간 — 덤프트럭 사이클 공식 계수"),
"F0324": (
"10-13-2",
"reference",
"드론운반 단가산출 항목(준비시간·왕복비행시간 식) — [주] 일본 산출 예",
),
# ── 제11·12장 ───────────────────────────────────────────────────
"F0328": ("11-4", "coefficient", "행이 q·k·f·E — [주]① Q=3600×q×k×f×E/Cm"),
"F0358": ("12-2", "reference", "시설유형 Type-Ⅰ~Ⅳ 적용 기준 설명"),
"F0360": ("12-2", "reference", "현장조건 Type-Ⅰ~Ⅲ 적용 기준 설명"),
"F0388": ("12-34-4", "reference", "「재료비 JOINT FILLER」 항목만 — 값이 없는 구성 안내"),
"F0390": ("12-36", "reference", "제작비·운송비·설치비 「견적처리」"),
"F0392": ("12-38-1", "coefficient", "사용횟수별 잔존율(12회 25%·25회 10%)"),
"F0394": ("12-38-2", "coefficient", "부자재 요율 「주자재비의 24%·52%·79%」"),
"F0396": ("12-38-3", "reference", "설치 유형(복잡·보통·간단) 적용 시설 설명"),
# ── 제13장 구조물 ───────────────────────────────────────────────
"F0400": ("13-2-3", "reference", "큰돌 직경별 뒷길이 기준(㎝)"),
"F0406": (
"13-3",
"reference",
"〈돌쌓기의 개수 및 중량의 표준〉 — [주] 인력 돌쌓기·돌붙임 기준",
),
"F0408": (
"13-4-3",
"reference",
"고임돌 소요량(㎥/㎡) — 자재 원단위표(B08 고임돌표가 씀). 품으로 세우면 자재를 두 번 셈",
),
"F0410": (
"13-4-4",
"reference",
"[주]① 채움 콘크리트 소요량(㎥/㎡) — 자재 원단위표(B08 채움표가 씀). 품이면 두 번 셈",
),
"F0411": ("13-4-4", "reference", "[주]⑨ 성토 뒤채움 조약돌 두께(㎝) 기준"),
"F0412": (
"13-4-4",
"reference",
"[주]⑩ 뒷길이 표준(㎝) — 자동 판정이 「(m)」에 걸려 소요량형으로 오판",
),
"F0413": (
"13-4-4",
"reference",
"[주]⑪ 표준경사(1:n) — 자동 판정이 소요량형으로 오판, 찰쌓기(인력)를 막음",
),
"F0429": ("13-10-2", "coefficient", "[주]⑥ 관입률별 계수"),
"F0439": ("13-12-2", "coefficient", "[주]⑦ 높이·기울기 할증(%)"),
"F0446": ("13-15-2", "reference", "작업조건(어려운·쉬운) 구분 기준"),
}
def judged_form(table_id: str, number: str | None) -> tuple[str, str] | None:
"""사람이 가른 `(형태, 까닭)`. 없으면 `None`. 절 번호가 어긋나면 멈춘다."""
found = FORM_JUDGMENTS.get(table_id)
if found is None:
return None
section, form, why = found
if section != number:
raise ValueError(
f"표 형태 판정 {table_id} 의 절 {section} 이 원천의 {number} 와 다릅니다 — "
"원천 표 차례가 바뀌었으니 판정을 다시 대조할 것"
)
return form, f"사람 판정 — {why}"
@@ -0,0 +1,79 @@
"""공종 마스터 — **갈래 키(`variant_key`)를 표 읽기가 실제로 가른 대로** 싣는다 (명세 14장 정정).
종전 `variant_axis` 는 표 첫 열 값 24개만 담아, 단가표 갈래 제목 294 중 86 만 맞았다
(2026-09-13 브레인 실측). 머리행 갈래(벌목 「5m이상~8m미만」) · 두 단 머리 · 첫 열이 자원
이름인 표를 못 가렸다.
⚠ 표 모양을 여기서 **다시 판정하지 않는다** — 첫 열 · 열이 자원 · 뭉친 이름+갈래 열 · 축 셋 ·
두 단 머리 · 작업조 · 공정 합산 · 고르기형 등 모양 가르기는 B09 표 읽기에 이미 있다.
두 벌로 짜면 한쪽만 고쳐진다. 그 읽기를 **메모리의 이 마스터**에 돌려 표마다 가른 갈래
이름을 원문 표기 그대로 싣는다(키 비교는 받는 쪽이 `normalize_variant_key` 로).
⚠ 못 가른 표는 **빈칸** — 자원을 못 맞춘 표 · 계수 · 참조 · 미판정 표에 갈래를 지어내지 않는다.
공종마다 `variant_keys` — 표들의 갈래 + 원문이 정했으나 자원 줄로 안 서는 갈래
(불도저 운반 공식 갈래 · 합산형 단계 잎의 암질 행 · 9-4-1 [주]① 평균) · 합산형 부모는 단계 갈래.
"""
from __future__ import annotations
from typing import Any
def _add(labels: list[str], seen: set[str], label: str, normalize: Any) -> None:
key = normalize(label)
if key and key not in seen:
seen.add(key)
labels.append(label)
def attach_variant_keys(nodes: list[dict[str, Any]], edition: str) -> None:
"""표마다 `variant_key`, 공종마다 `variant_keys` 를 채운다(자리에서)."""
from B09_Estimation.B09_Estimation_MachineProductivity_Dozer import extract_dozer_factors
from B09_Estimation.B09_Estimation_ParentSteps import AVERAGE_BASIS, AVERAGE_VARIANT, rock_rows
from B09_Estimation.B09_Estimation_ResourceAxis import build_resource_axis
from B09_Estimation.B09_Estimation_ResourceAxis_Sources import load_combined_catalog
from B09_Estimation.B09_Estimation_UnitPrice import normalize_variant_key as normalize
axis = build_resource_axis(
{"effective_date": edition, "work_items": nodes}, load_combined_catalog()
)
by_table: dict[tuple[str, str], tuple[list[str], set[str]]] = {}
for row in axis.rows:
if row.variant:
labels, seen = by_table.setdefault((row.work_item_code, row.pum_table_id), ([], set()))
_add(labels, seen, row.variant, normalize)
steps = {
str(step["code"])
for node in nodes
if node.get("parent_mode") == "sum_steps"
for step in node.get("steps") or []
}
by_code: dict[str, list[str]] = {}
for node in nodes:
code = str(node.get("work_item_code") or "")
keys: list[str] = []
seen: set[str] = set()
for table in node.get("tables") or []:
labels, table_seen = by_table.get((code, str(table.get("pum_table_id"))), ([], set()))
found = extract_dozer_factors(code, table)
for label in found if isinstance(found, dict) else {}:
_add(labels, table_seen, label, normalize)
if code in steps:
for rock, _, _ in rock_rows({"tables": [table]}):
_add(labels, table_seen, rock, normalize)
table["variant_key"] = labels
for label in labels:
_add(keys, seen, label, normalize)
if code in AVERAGE_BASIS and keys:
_add(keys, seen, AVERAGE_VARIANT, normalize)
by_code[code] = keys
for node in nodes:
code = str(node.get("work_item_code") or "")
keys = list(by_code.get(code) or [])
if node.get("parent_mode") == "sum_steps":
seen = {normalize(k) for k in keys}
for step in node.get("steps") or []:
for label in by_code.get(str(step["code"])) or []:
_add(keys, seen, label, normalize)
node["variant_keys"] = keys
@@ -72,6 +72,20 @@ class SummaryInput:
rock_classes: list[str] = field(default_factory=list)
rock_ratios_pct: dict[str, float] = field(default_factory=dict)
application_ratios: dict[str, float] = field(default_factory=dict)
# 노체다짐 — 기본 꺼짐(2026-09-13 판정). 켜면 성토 밑에 별도 줄이 선다.
subgrade_compaction_enabled: bool = False
#: 노체다짐 줄 — ⭐ 2026-09-13 판정 「별도 줄 · 칸으로 켜고 끔 · 기본 꺼짐」.
#: 성토 단가(포설)에 묻지 않는다 — 묻으면 왜 그 금액인지 설명이 안 되고 켜고 끈 것이 안 보인다.
#: ⚠ [주]③ 「굴착기와 진동롤러 조합」은 작업 방식 설명 — 굴착기는 성토(포설 9-16-1) 줄이 이미 셈.
#: 이 줄에 굴착기를 또 넣으면 두 번 셈(2026-09-14 「#조합」 철회).
SUBGRADE_COMPACTION_SPEC = "진동롤러(자주식 10ton)"
SUBGRADE_COMPACTION_NOTE = (
"산출 조건에서 켬 — 품셈 9-16-2 [주]⑤ 「대규모 성토지로서 층다짐이 필요한 경우 적용」"
" · 수량 = 성토량(포설과 같은 밑수 ㎥)"
" · [주]③ 굴착기+진동롤러 조합 중 굴착기는 성토(포설) 줄이 셈"
)
def _ratio(source: SummaryInput, key: str) -> float:
@@ -121,6 +135,16 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]:
rows.append(SummaryRow(group="보정량계", amount=earth.get("adjusted_total_m3", 0.0)))
rows.append(SummaryRow(group="성토", amount=earth.get("fill_volume_m3", 0.0)))
if source.subgrade_compaction_enabled:
# ⚠ 끄면 줄 자체를 안 낸다 — 빈 줄은 「세야 함」으로 읽힌다(임목파쇄와 같은 자리).
rows.append(
SummaryRow(
group="노체다짐",
spec=SUBGRADE_COMPACTION_SPEC,
amount=earth.get("fill_volume_m3", 0.0),
note=SUBGRADE_COMPACTION_NOTE,
)
)
# ── 운반 — 수단별. 무대는 집계에 오르되 내역 줄이 아니다 ────────
rows.extend(_haul_rows(source))
+20 -3
View File
@@ -112,8 +112,13 @@ def build_handoff(
bench_cut_depth_m: float | None = None,
structure_trench_water: str | None = None,
stand_volume_class: str | None = None,
priced_sheets: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""B09 가 그대로 받는 모양. 없는 표는 건너뛰되 **빈 표와 구별해 적는다**."""
"""B09 가 그대로 받는 모양. 없는 표는 건너뛰되 **빈 표와 구별해 적는다**.
`priced_sheets` — 양식 일위대가로 셀 구조물도 장(`StructurePriceLink.priced_sheets`).
그 장의 구조물은 호표 `AX-ST` 줄 하나로 가고, 일위대가가 품은 줄은 따로 안 감.
"""
table = mapping or load_mapping()
work_items: list[dict[str, Any]] = []
unmatched: list[str] = []
@@ -135,7 +140,7 @@ def build_handoff(
# 띠·이동에서만 운반이 만들어져 사토 잔량이 어디에도 안 실렸다.
work_items.extend(spoil_haul_rows(haul_table, table))
if unit_quantity_table:
rows, misses = _structure_rows(unit_quantity_table, table)
rows, misses = _structure_rows(unit_quantity_table, table, priced_sheets)
work_items.extend(rows)
unmatched.extend(misses)
# 구조물이 낸 터파기·되메우기·잔토 — **공종 축으로 올린다.**
@@ -145,7 +150,7 @@ def build_handoff(
work_items.extend(rows)
unmatched.extend(misses)
# 기초잡석 — 버림이 선 구조물에 함께 서는 공종(확정 3차 ②). 묶음 구조물은 제외한다.
rows, misses = rubble_base_rows(unit_quantity_table, table)
rows, misses = rubble_base_rows(unit_quantity_table, table, priced_sheets)
work_items.extend(rows)
unmatched.extend(misses)
@@ -208,6 +213,14 @@ def build_handoff(
result["ratio_math_warnings"] = verify_ratio_math(result)
result["material_code_warnings"] = verify_no_code_on_materials(result)
result["bill_flag_warnings"] = verify_bill_flags(result)
# ⚠ 이중계상 경계(명세 6장) — 갈 곳 칸으로 판정한 어긴 자리. B09 내역서가 여기서 멈춘다.
from B08_Quantity.B08_Quantity_Engine_Handoff_Boundaries import (
verify_double_count_boundaries,
)
result["double_count_violations"] = verify_double_count_boundaries(
unit_quantity_table, material_table, table, priced_sheets, work_items
)
# ⚠ 보내는 단위가 **품셈 밑수**와 같은가 — 받는 쪽이 그대로 곱하는 자리다(2026-09-08).
result["basis_unit_warnings"] = verify_unit_matches_basis(
work_items, extra=table.declared_units()
@@ -218,6 +231,10 @@ def build_handoff(
(unit_quantity_table or {}).get("collected_stone_deduction_m3") or 0.0
)
result["placing_notes"] = placing_notes
# ⚠ 품셈 판(명세 17장) — 줄마다 코드가 본 판을 싣는다. 판이 다르면 B09 가 값을 안 씀.
result["pum_edition"] = table.pum_edition or None
for row in work_items:
row.setdefault("pum_edition", table.pum_edition or None)
return result
@@ -0,0 +1,159 @@
"""B08 → B09 인계 — **이중계상 경계를 갈 곳(`destination`) 칸으로 판정** (명세 6장 · 2026-09-13).
주석 경고뿐이던 규칙 셋을 수로 걸리는 검사로 세운다. 원문은 옛 계획서 8-7절(㉠·㉢)과 명세 6장.
① 구조물 터파기·되메우기는 **토공집계로만** — 갈 곳이 `earthwork` 인 성분을 다른 공종 줄이
또 집으면 같은 물량이 두 공종 코드에 붙음. 터파기·되메우기가 `earthwork` 밖으로 가도 오류
② 배합 성분(시멘트·모래·자갈)은 **B09 일위대가만 쪼갬** — 자재총괄에 뜨면 오류(㉢)
③ 자재 할증은 **자재총괄 한 곳** — 원단위표가 이미 할증을 붙였으면 오류(㉠).
자재총괄 합계의 할증 한 번·일위대가 재료비의 할증 전은 B09 가드가 금액 자리에서 봄
⚠ 새 얼개를 만들지 않는다 — 성분마다 붙은 `destination` 과, 인계 줄이 성분을
**이름으로 집는 자리**(구조물 줄 `billing_component` · 묶음 조각 `from_components` ·
콘크리트 타설 · 기초잡석)만 대조한다.
⚠ 여기는 목록만 낸다. 멈추는 것은 B09 `build_bill`(`DoubleCountError`) — 가드가 모인 한 자리.
"""
from __future__ import annotations
from typing import Any
from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import WorkItemMapping
from B08_Quantity.B08_Quantity_Engine_Handoff_Rows import PLACING_TARGET_NAMES
from B08_Quantity.B08_Quantity_Engine_Handoff_Trench import RUBBLE_GROUP
from B08_Quantity.B08_Quantity_Engine_MaterialSummary import verify_single_surcharge
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import MIX_COMPONENTS
#: 토공집계로만 가야 하는 성분 — 전개·양식·관측 원단위가 같은 이름을 쓴다.
EARTHWORK_ONLY = ("터파기", "되메우기")
#: 공종 줄이 값으로 매기면 안 되는 갈 곳 — 이미 다른 자리가 세거나(토공·사토 공제) 참고값이다.
NOT_BILLED_BY_WORK_ITEM = {
"earthwork": "토공집계",
"haul_deduction": "유토곡선 사토 공제",
"reference": "참고 줄",
}
def _pickers(structure: dict[str, Any], mapping: WorkItemMapping) -> list[tuple[str, set[str]]]:
"""이 구조물의 성분을 **이름으로 집는** 공종 줄 — (자리, 이름들). 집는 조건은 각 빌더와 같다."""
type_id = str(structure.get("type_id") or "")
entry = mapping.for_structure(type_id) or {}
composite = mapping.composite_for(type_id)
found: list[tuple[str, set[str]]] = []
if entry.get("billing_component"):
found.append(("구조물 줄", {str(entry["billing_component"])}))
if composite and not entry.get("work_item_code"):
for part in composite.get("parts") or []:
if isinstance(part, dict):
label = f"묶음 조각 「{part.get('name') or part.get('code')}」"
found.append((label, {str(name) for name in part.get("from_components") or []}))
if not composite:
# 묶음 구조물은 타설·기초잡석 줄이 건너뛴다(조각이 이미 셈) — 빌더와 같은 조건.
found.append(("콘크리트 타설 줄", set(PLACING_TARGET_NAMES)))
found.append(("기초잡석 줄", {RUBBLE_GROUP}))
return found
def verify_double_count_boundaries(
unit_quantity_table: dict[str, Any] | None,
material_table: dict[str, Any] | None,
mapping: WorkItemMapping,
priced_sheets: list[dict[str, Any]] | None = None,
work_items: list[dict[str, Any]] | None = None,
) -> list[str]:
"""어긴 자리를 사람 말로 — 비면 경계가 지켜진 것."""
found: list[str] = []
for structure in (unit_quantity_table or {}).get("structures") or []:
label = str(structure.get("name") or structure.get("type_id") or "구조물")
destinations: dict[str, set[str]] = {}
for component in structure.get("components") or []:
if float(component.get("amount") or 0.0) <= 0:
continue
name = str(component.get("name") or "").strip()
destination = str(component.get("destination") or "")
destinations.setdefault(name, set()).add(destination)
if name in EARTHWORK_ONLY and destination != "earthwork":
found.append(
f"① {label} 「{name}」 갈 곳이 {destination or '(없음)'} — "
"구조물 터파기·되메우기는 토공집계(earthwork)로만 감"
)
for where, names in _pickers(structure, mapping):
for name in sorted(names):
for destination in sorted(
destinations.get(name, set()) & set(NOT_BILLED_BY_WORK_ITEM)
):
found.append(
f"① {label} 「{name}」은 {NOT_BILLED_BY_WORK_ITEM[destination]}"
f"({destination})로 가는데 {where}이 또 셈 — 같은 물량이 두 자리에 붙음"
)
for row in (material_table or {}).get("rows") or []:
name = str(row.get("name") or "").strip()
if name in MIX_COMPONENTS:
found.append(
f"② 자재총괄에 배합 성분 「{name}」 — 시멘트·모래·자갈은 B09 일위대가만 쪼갬(㉢)"
)
found.extend(f"③ {warning} (㉠)" for warning in verify_single_surcharge(unit_quantity_table))
found.extend(_verify_conserved(unit_quantity_table, mapping, priced_sheets, work_items))
return found
#: 여러 구조물의 성분을 **이름으로 모아 한 줄**로 세우는 인계 줄 — (줄 이름, 모으는 성분 이름들).
#: ⚠ 이 줄들은 구조물마다 「누구 것을 뺐나」를 안 남긴다 — 그래서 물량 보존으로 본다.
SUMMED_ROWS = (
(RUBBLE_GROUP, frozenset({RUBBLE_GROUP})),
("콘크리트 타설", frozenset(PLACING_TARGET_NAMES)),
)
_TOLERANCE_M3 = 1e-6
def _verify_conserved(
unit_quantity_table: dict[str, Any] | None,
mapping: WorkItemMapping,
priced_sheets: list[dict[str, Any]] | None,
work_items: list[dict[str, Any]] | None,
) -> list[str]:
"""④ 양식 호표·묶음 조각이 품은 성분을 모음 줄이 또 셈 — **물량 보존**으로 판정(2026-09-13).
모음 줄 + 호표가 품은 몫 + 묶음 조각 ≤ 원단위 성분 합. 넘치면 같은 물량을 두 자리에서 셈
(검증 프로젝트 찰쌓기: 호표가 품은 기초잡석이 인계 줄로도 나가 8.3㎥ ← 실제 3.5㎥).
⚠ 빌더마다 「무엇을 건너뛰나」를 따라 짜지 않는다 — 건너뛰기를 빠뜨린 빌더를 잡는 검사다.
"""
if not unit_quantity_table or work_items is None:
return []
covered = {
sid: set(entry.get("covered") or [])
for entry in priced_sheets or []
for sid in entry.get("structure_ids") or []
}
found: list[str] = []
for row_name, names in SUMMED_ROWS:
available = held = 0.0
for structure in unit_quantity_table.get("structures") or []:
taken = covered.get(str(structure.get("structure_id")), set())
for component in structure.get("components") or []:
name = str(component.get("name") or "").strip()
if name not in names or component.get("unit") != "㎥":
continue
amount = float(component.get("amount") or 0.0)
available += amount
held += amount if name in taken else 0.0
in_parts = sum(
float(part.get("quantity") or 0.0)
for row in work_items
for part in row.get("composite_parts") or []
if isinstance(part, dict)
and part.get("from_components")
and set(part["from_components"]) <= names
)
summed = sum(
float(row.get("quantity") or 0.0)
for row in work_items
if row.get("name") == row_name and not row.get("composite_parts")
)
if summed + held + in_parts > available + _TOLERANCE_M3:
found.append(
f"④ 「{row_name}」 줄 {summed:g}㎥ + 양식 호표가 품은 {held:g}㎥ + 묶음 조각 "
f"{in_parts:g}㎥ 가 원단위 합 {available:g}㎥ 를 넘음 — 호표·조각이 품은 성분을"
" 모음 줄이 또 셈"
)
return found
@@ -192,6 +192,8 @@ 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)
@@ -284,6 +286,7 @@ def load_mapping(path: Path | None = None) -> WorkItemMapping:
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 []),
@@ -342,13 +342,19 @@ def _component_billing(
def _structure_rows(
unit_quantity_table: dict[str, Any], mapping: WorkItemMapping
unit_quantity_table: dict[str, Any],
mapping: WorkItemMapping,
priced_sheets: list[dict[str, Any]] | None = None,
) -> tuple[list[dict[str, Any]], list[str]]:
"""구조물 줄 — **작업 공종 하나**로 선다.
⚠ 전개 성분(터파기·야면석·모르터)은 여기 오지 않는다. 구조물 한 기가 내역 한 줄이고,
그 전개는 토공 합산·자재총괄·일위대가로 갈린다(8-2 이중계상 함정).
⭐ 양식 일위대가로 셀 장(`priced_sheets`)에 든 구조물은 **호표 `AX-ST` 줄**로 감(PLAN 6장 ②).
"""
from B08_Quantity.B08_Quantity_Engine_StructurePriceLink import by_structure, template_row
priced = by_structure(priced_sheets or [])
rows: list[dict[str, Any]] = []
unmatched: list[str] = []
for structure in unit_quantity_table.get("structures") or []:
@@ -400,7 +406,11 @@ def _structure_rows(
{"axis": axis, "value": (structure.get("options") or {}).get(axis)}
for axis in entry.get("secondary_axes") or []
]
if code is None and composite is None:
# 양식 일위대가로 셀 구조물 — 막힘은 일위대가 표가 가림(전개 갈래 사유는 안 셈).
sheet_entry = priced.get(str(structure.get("structure_id")))
if sheet_entry is not None:
pass
elif code is None and composite is None:
unmatched.append(f"{wording_type_label(type_id)} — 품셈 공종을 아직 못 이었습니다")
elif entry.get("class_from") in ("back_length", "bond") and class_key is None:
unmatched.append(f"{wording_type_label(type_id)} — {class_basis}")
@@ -476,6 +486,8 @@ def _structure_rows(
"origin": ORIGIN_STRUCTURE,
}
)
if sheet_entry is not None:
rows[-1] = template_row(rows[-1], structure, sheet_entry)
return rows, unmatched
@@ -149,18 +149,28 @@ def _row(**fields: Any) -> dict[str, Any]:
def rubble_base_rows(
unit_quantity_table: dict[str, Any], mapping: WorkItemMapping
unit_quantity_table: dict[str, Any],
mapping: WorkItemMapping,
priced_sheets: list[dict[str, Any]] | None = None,
) -> tuple[list[dict[str, Any]], list[str]]:
"""기초잡석(품셈 12-25) — 구조물 전개가 낸 성분을 **한 줄로** 올린다.
⚠ **묶음으로 서는 구조물은 건너뛴다** — 옹벽 묶음에 이미 `FP-12-25` 조각이 있어
여기서 또 세우면 같은 잡석을 두 번 센다(타설 줄에서 이미 겪은 자리).
⚠ **양식 일위대가가 기초잡석을 품은 구조물도 건너뛴다**(PLAN 6장 ② 브레인 판정) —
호표 `AX-ST` 금액에 이미 들어 있음.
"""
from B08_Quantity.B08_Quantity_Engine_StructurePriceLink import by_structure
priced = by_structure(priced_sheets or [])
total = 0.0
bases: list[str] = []
for structure in unit_quantity_table.get("structures") or []:
if mapping.composite_for(str(structure.get("type_id") or "")):
continue
sheet_entry = priced.get(str(structure.get("structure_id")))
if sheet_entry is not None and RUBBLE_GROUP in sheet_entry["covered"]:
continue
for component in structure.get("components") or []:
if str(component.get("name") or "") != RUBBLE_GROUP:
continue
+111 -62
View File
@@ -10,7 +10,7 @@
「토공집계 지장목제거로 이미 섬」이라고 가리키기만** 한다.
⚠ 값이 없는 줄의 사유를 적는다
· 표토제거(9-15) — 면적은 사면적에서 나오나 **두께·대상 구간이 설계로 안 정해져 있다**.
· 표토제거(9-15-2) — **노면 + 절토 사면 ㎡** · 노면 폭을 못 세면 안 섬(2026-09-13 「실무대로」).
· 제근·뿌리다듬기(9-20~21) — 단위가 **「개」(그루 수)**인데 입목 본수를 우리가 안 든다.
· 규준틀(11-2) — **개소** 산정 기준(구조물·절성토 구간별 몇 개소)이 안 정해져 있다.
@@ -110,13 +110,15 @@ def preparation_rows(
topsoil_thickness_m: float | None = None,
topsoil_haul_distance_m: float | None = None,
stand_volume_class: str | None = None,
road_surface_area_m2: float | None = None,
topsoil_road_only: bool = False,
) -> list[dict[str, Any]]:
"""준비공 줄 — 값이 서는 것과 안 서는 것을 **한 목록에** 낸다."""
slope = slope_totals or {}
tree_area = float(slope.get("tree_removal_fill", 0.0)) + float(
slope.get("tree_removal_cut", 0.0)
)
topsoil = _topsoil_row(slope, topsoil_thickness_m)
topsoil = _topsoil_row(slope, road_surface_area_m2, topsoil_road_only)
return [
{
"group": "준비공",
@@ -141,7 +143,7 @@ def preparation_rows(
},
topsoil,
# ⚠ 법이 요구하는 **운반·적치** — 제거 물량이 곧 밑수다(별표2).
_topsoil_haul_row(topsoil, topsoil_haul_distance_m),
_topsoil_haul_row(topsoil, topsoil_thickness_m, topsoil_haul_distance_m),
_root_removal_row(slope, stand_volume_class),
*_root_steps_rows(slope, stand_volume_class),
_batter_frame_row(list(slope_rows)),
@@ -149,53 +151,59 @@ def preparation_rows(
]
def _topsoil_row(slope: dict[str, float], thickness_m: float | None) -> dict[str, Any]:
"""표토제거 — **두께는 품셈이 아니라 설계가 정한다**(9-15 [주]② 「T : 표토두께(m)」).
def _topsoil_row(
slope: dict[str, float], road_area_m2: float | None, road_only: bool = False
) -> dict[str, Any]:
"""표토제거 — **밑수 ㎡ · 대상 · 20m 압토 셋이 한 묶음**(2026-09-13 판정 · PLAN 10장).
⚠ 두께를 안 넣으면 **0 으로 때우지 않고** 물량을 안 낸다. 대상 면적은 절·성토 사면적을
쓴다(사면 계열의 면고르기 면적과 같은 자리).
① 밑수 **㎡** — 품셈 9-15-2 가 T=0.2 m 를 **품 조건**으로 둔 면적 축(실무 영월 「표토제거
답외구간 M2」). 두께는 여기 안 곱한다 — 운반 부피에만 쓴다.
② 대상 **노면 + 절토대상지** — 별표2 Ⅰ.2.차.(6) 문언. 성토대상지는 (7) 「제거·정리」라 안 셈.
`road_only` 면 노면만(영월 한 건 관측 · 설계자 선택 · 2026-09-14 판정).
③ **20 m 압토는 품에 듦** — 운반 줄은 그 넘는 몫만(`_topsoil_haul_row`).
⚠ 하나만 고치면 합계가 그럴듯해 보이면서 틀린다 — 셋을 함께 바꿨다.
"""
area = float(slope.get("face_dressing_fill", 0.0)) + float(slope.get("face_dressing_cut", 0.0))
# ⚠ 면적이 0 이면 **0 ㎥ 를 「값 있음」으로 내지 않는다** — 0 은 「없음」과 구별이 안 되고,
# 받는 쪽이 「표토가 없는 노선」으로 읽는다. 절·성토 사면적이 0 인 임도는 성립하지
# 않으므로 이 자리는 사실상 **사면표가 아직 안 선 것**이다(2026-09-09 감사에서 잡음).
cut = 0.0 if road_only else float(slope.get("face_dressing_cut", 0.0))
base = {"group": "준비공", "item": "표토제거", "unit": "㎡", "work_item_code": TOPSOIL_CODE}
if road_area_m2 is None:
return {
**base,
"amount": None,
"status": STATUS_PENDING,
"reason": (
"노면 면적을 못 셉니다 — 횡단 설계에 노체 끝(노면 폭)이 없는 측점이 있음. "
f"대상은 노면 + 절토대상지(별표2) · 절토 사면 {cut:,.1f}㎡ 만으로는 세우지 않음"
),
"reference_amount": cut,
}
area = float(road_area_m2) + cut
# ⚠ 면적이 0 이면 **0 ㎡ 를 「값 있음」으로 내지 않는다** — 받는 쪽이 「표토가 없는 노선」으로
# 읽는다(2026-09-09 감사에서 잡음). 노면이 0 인 임도는 성립하지 않으므로 표가 안 선 것이다.
if area <= 0:
return {
"group": "준비공",
"item": "표토제거",
"unit": "㎥",
**base,
"amount": None,
"status": STATUS_PENDING,
"reason": (
"대상 면적이 0 ㎡ 입니다 — 사면표(면고르기 면적)가 아직 서지 않았습니다. "
"0 ㎥ 로 내면 「표토가 없는 노선」으로 읽히므로 값을 세우지 않습니다"
"대상 면적이 0 ㎡ 입니다 — 횡단·사면표가 아직 서지 않았습니다. "
"0 ㎡ 로 내면 「표토가 없는 노선」으로 읽히므로 값을 세우지 않습니다"
),
"work_item_code": "FP-09-15",
}
if thickness_m is None or float(thickness_m) <= 0:
if road_only:
return {
"group": "준비공",
"item": "표토제거",
"unit": "㎥",
"amount": None,
"status": STATUS_PENDING,
"reason": (
"표토 두께가 아직 입력되지 않았습니다 — 두께는 설계가 정하는 값입니다. "
f"{TOPSOIL_ORIGINAL_APPLIED} "
f"산출 조건에서 두께를 넣으면 값이 섭니다 (대상 면적 {area:,.1f}㎡)"
),
"reference_amount": area,
"work_item_code": "FP-09-15",
**base,
"amount": area,
"status": STATUS_READY,
"reason": f"노면 {area:,.1f}㎡ (노면만 — 산출 조건에서 고름)"
f" — {TOPSOIL_ROAD_ONLY_BASIS}",
}
thickness = float(thickness_m)
return {
"group": "준비공",
"item": "표토제거",
"unit": "㎥",
"amount": area * thickness,
**base,
"amount": area,
"status": STATUS_READY,
"reason": (f"사면적 {area:,.1f}㎡ × 두께 {thickness:g}m (품셈 9-15) · {TOPSOIL_AREA_GAP}"),
"work_item_code": "FP-09-15",
"reason": (
f"노면 {float(road_area_m2):,.1f}㎡ + 절토 사면 {cut:,.1f}㎡ — {TOPSOIL_TARGET_BASIS}"
),
}
@@ -213,19 +221,22 @@ TOPSOIL_ORIGINAL_APPLIED = (
" — 값을 자동으로 넣지 않되, 원문에 있는 값이므로 참고할 것"
)
#: ⚠⚠ **대상 면적이 법 문언과 어긋난다**(2026-09-09 준비공 축 감사에서 잡음).
#: 별표2 Ⅰ.2.차.(6) 은 대상을 **「노면·절토대상지」**로 못박고, 성토대상지는 (7) 에서
#: 「표토 등은 **제거·정리**한다」로 따로 두어 **운반·적치 의무를 안 건다.**
#: 우리는 `face_dressing_cut + face_dressing_fill` 로 **절토·성토 사면적을 다 더하고**
#: **노면(연장 × 노폭)은 안 센다** — 두 방향으로 어긋난 셈이다.
#: ㉡ 계열(「또는」을 다 더함) — 성토 사면까지 운반·적치 물량에 실림
#: 빠짐 — 노면 표토가 아예 안 섬(법이 첫째로 든 대상)
#: ⚠ **값을 임의로 고치지 않는다** — 실무가 사면적 한 덩이로 잡는지, 노면을 따로 잡는지가
#: 사용자·실무자 확인 사항이다. 지금은 **사유로 드러내기만** 한다.
TOPSOIL_AREA_GAP = (
"⚠ 대상 면적 확인 필요 — 별표2 는 「노면·절토대상지」로 두는데 우리는 절토·성토"
" **사면적을 다 더하고 노면은 안 셈**. 성토대상지는 별표2 (7) 이 「제거·정리」로만 두어"
" 운반·적치 의무를 안 검. 실무 확인 뒤 대상을 확정할 것"
#: 표토제거 공종 — **답외구간**(9-15-2). 임도는 논(畓)이 아니고 실무 영월도 「답외구간」으로 셈.
TOPSOIL_CODE = "FP-09-15-02"
#: ⭐ 2026-09-13 판정 「실무대로」 — 대상·밑수·압토 셋의 근거(화면 사유에 그대로 보임).
#: 종전(2026-09-09 감사)엔 절토·성토 **사면적을 다 더하고 노면은 안 세** 법 문언과 두 방향으로
#: 어긋나 있었고 사유로만 드러내고 있었다.
TOPSOIL_TARGET_BASIS = (
"대상은 별표2 Ⅰ.2.차.(6) 「노면·절토대상지」 · 성토대상지는 (7) 「제거·정리」라 안 셈"
" · 밑수 ㎡(품셈 9-15-2 가 T=0.2m 를 품 조건으로 둔 면적 축"
" · 실무 영월 「표토제거 답외구간 M2」)"
" · 20m 압토는 제거 품에 듦(2026-09-13 판정 「실무대로」)"
)
#: 노면만 — 설계자가 고른 경우의 근거. ⚠ 법 문언이 아니라는 사실을 사유에 함께 적는다.
TOPSOIL_ROAD_ONLY_BASIS = (
"⚠ 법 문언(별표2 Ⅰ.2.차.(6))은 「노면·절토대상지」라 기본은 노면 + 절토"
" · 노면만은 실무 영월 한 건(1m당 3.34㎡) 관측이라 설계자 선택"
" · 밑수 ㎡ · 20m 압토는 제거 품에 듦"
)
#: 표토 운반·적치 — ⚠ **법이 요구하는데 우리가 제거만 세고 있던 자리**(2026-09-09).
@@ -237,10 +248,11 @@ TOPSOIL_AREA_GAP = (
TOPSOIL_HAUL_LAW = (
"⚠ 법정 의무 — 시행규칙 별표2 「표토는 전량 제거한 후 … 최고 홍수위보다 높은 장소로"
" 운반하고 쌓아두어야 한다」. 제거만 세면 운반이 빠짐"
" · ⚠⚠ **다만 제거 품(9-15-2)에 이미 L=20m 압토가 들어 있음** — 실무 내역서도"
" 「표토제거 답외구간 / M2」 한 줄뿐이고 운반 줄이 따로 없음. 이 줄은 **20m 를 넘는"
" 몫**일 때만 새 줄이며, 그 가름을 아직 안 함(확인 필요)"
" · 제거 품(9-15-2)에 L=20m 압토가 들어 있어 이 줄은 **20m 를 넘는 몫**만 셈"
" (실무 영월 「표토제거 답외구간 / M2」 한 줄 · 운반 줄 없음)"
)
#: 제거 품이 품은 압토 거리(m) — 품셈 9-15-2 원문 적용값 `L(운반거리) 20m`.
TOPSOIL_PUSH_INCLUDED_M = 20.0
#: 제근 — ⚠ **본수가 아니라 임목축적으로 갈린다**(2026-09-09 원문 확인).
@@ -401,26 +413,56 @@ def _root_steps_rows(
]
def _topsoil_haul_row(topsoil: dict[str, Any], distance_m: float | None) -> dict[str, Any]:
"""표토 운반 — 물량은 제거 물량 그대로, 거리는 설계 입력.
def _topsoil_haul_row(
topsoil: dict[str, Any], thickness_m: float | None, distance_m: float | None
) -> dict[str, Any]:
"""표토 운반 — 부피는 **제거 면적 × 두께**, 거리는 설계 입력에서 **20m 압토를 뺀 몫**.
⚠ 제거 줄이 안 서면(두께 미입력) 운반도 안 선다 — 밑수가 그 줄이기 때문이다.
⚠ 제거 줄이 안 서거나 두께가 비면 운반도 안 선다 — 0 으로 때우지 않는다.
"""
amount = topsoil.get("amount")
area = topsoil.get("amount")
distance = None
if distance_m is not None:
try:
distance = float(distance_m)
except (TypeError, ValueError):
distance = None
if amount is None:
if area is None:
return {
"group": "준비공",
"item": "표토 운반·적치",
"unit": "㎥",
"amount": None,
"status": STATUS_PENDING,
"reason": f"{TOPSOIL_HAUL_LAW} · 제거 물량이 아직 안 서서 운반도 못 셈(두께 먼저)",
"reason": f"{TOPSOIL_HAUL_LAW} · 제거 면적이 아직 안 서서 운반도 못 셈",
"work_item_code": None,
}
if thickness_m is None or float(thickness_m) <= 0:
return {
"group": "준비공",
"item": "표토 운반·적치",
"unit": "㎥",
"amount": None,
"status": STATUS_PENDING,
"reason": (
f"{TOPSOIL_HAUL_LAW} · 운반 부피 = 제거 면적 × 표토 두께 — 두께가 아직 입력되지"
f" 않았습니다. {TOPSOIL_ORIGINAL_APPLIED} (제거 면적 {float(area):,.1f}㎡)"
),
"reference_amount": float(area),
"work_item_code": None,
}
amount = float(area) * float(thickness_m)
if distance is not None and 0 < distance <= TOPSOIL_PUSH_INCLUDED_M:
return {
"group": "준비공",
"item": "표토 운반·적치",
"unit": "㎥",
"amount": None,
"status": STATUS_COUNTED_ELSEWHERE,
"reason": (
f"{TOPSOIL_HAUL_LAW} · 운반거리 {distance:g}m 는 제거 품의 20m 압토 안 — 따로 안 셈"
),
"reference_amount": amount,
"work_item_code": None,
}
if distance is None or distance <= 0:
@@ -438,19 +480,22 @@ def _topsoil_haul_row(topsoil: dict[str, Any], distance_m: float | None) -> dict
"reference_amount": float(amount),
"work_item_code": None,
}
beyond = distance - TOPSOIL_PUSH_INCLUDED_M
return {
"group": "준비공",
"item": "표토 운반·적치",
"unit": "㎥",
"amount": float(amount),
"amount": amount,
"status": STATUS_READY,
"reason": (
f"{TOPSOIL_HAUL_LAW} · 제거 물량 그대로 · 운반거리 {distance:g}m(설계 입력)"
f"{TOPSOIL_HAUL_LAW} · 제거 면적 {float(area):,.1f}㎡ × 두께 {float(thickness_m):g}m"
f" · 운반거리 {distance:g}m(설계 입력) − 압토 {TOPSOIL_PUSH_INCLUDED_M:g}m"
f" = {beyond:g}m"
" · 갈래 「토사」로 보냄(표토는 토사임 — 덤프 운반 10-12 는 토사/암절취/발파암으로"
" 갈리고 부모 코드에는 품이 없음)"
),
"work_item_code": "FP-10-12",
"haul_distance_m": distance,
"haul_distance_m": beyond,
# ⚠ `FP-10-12` 는 **부모**다 — 품이 붙은 것은 잎(10-12-1 토사 · -2 암절취 · -3 발파암)
# 뿐이라 갈래를 안 보내면 B09 가 「후보 3건」만 보이고 금액이 안 선다. 표토가 토사인
# 것은 다툼이 없으므로 여기서 갈래를 실어 보낸다(2026-09-09 마스터 대조에서 잡음).
@@ -620,6 +665,8 @@ def build_table(
stand_volume_class: str | None = None,
chipping_enabled: Any = False,
chipping_volume_m3: Any = None,
road_surface_area_m2: float | None = None,
topsoil_road_only: bool = False,
) -> dict[str, Any]:
"""화면·인계가 그대로 쓰는 모양. **못 서는 줄도 목록에 남는다.**"""
rows = (
@@ -629,6 +676,8 @@ def build_table(
topsoil_thickness_m,
topsoil_haul_distance_m,
stand_volume_class,
road_surface_area_m2,
topsoil_road_only,
)
+ erosion_rows(structures, names)
+ chipping_rows(chipping_enabled, chipping_volume_m3)
@@ -69,6 +69,9 @@ class StationSlope:
# ⚠ 좌우가 다르면 **큰 쪽**을 쓴다. 「중심점 성토고 5m 이상」(품셈 11-3 [주]①) 판정은
# 가장 높은 쪽이 기준이고, 양쪽을 더하면 실제보다 두 배가 된다.
fill_height_m: float = 0.0
#: 노면 폭(m) — 노체 끝(`road_edges`) 좌우 사이. 표토제거 대상 「노면」 면적의 밑수(별표2).
#: 노체 끝이 저장에 없으면 `None` — 0 으로 때우지 않는다.
roadbed_width_m: float | None = None
segments: tuple[SlopeSegment, ...] = ()
# 사면이 샘플 범위 끝까지 원지반을 못 만나 **면적이 잘린** 측점.
# 설계 엔진이 `slope_unclosed` 로 이미 경고하는 값을 그대로 물고 온다. 잘린 측점은
@@ -207,6 +210,25 @@ def _material(design: dict[str, Any], ratio: float) -> str | None:
return "rock" if abs(ratio - rock) < abs(ratio - soil) else "soil"
def _roadbed_width(design: dict[str, Any]) -> float | None:
"""노면 폭 — 노체 끝 좌(+)·우(−) 오프셋 사이. 한쪽이라도 없으면 `None`."""
edges = design.get("road_edges") or {}
left = _num((edges.get("left") or {}).get("offset_m"))
right = _num((edges.get("right") or {}).get("offset_m"))
return None if left is None or right is None else abs(left - right)
def road_surface_area(slopes: Iterable[StationSlope]) -> float | None:
"""노면 면적(㎡) — 사면과 같은 평균단면적법. 노면 폭이 빈 측점이 하나라도 있으면 `None`."""
ordered = sorted(slopes, key=lambda s: s.chainage_m)
if not ordered or any(s.roadbed_width_m is None for s in ordered):
return None
return sum(
(float(a.roadbed_width_m) + float(b.roadbed_width_m)) / 2.0 * (b.chainage_m - a.chainage_m)
for a, b in zip(ordered, ordered[1:])
)
def station_slope(chainage_m: float, design: dict[str, Any]) -> StationSlope:
"""측점 하나의 사면길이. 좌우를 합쳐 절토·성토 각각의 총 사면길이를 낸다."""
ratios = _ratios(design)
@@ -225,6 +247,7 @@ def station_slope(chainage_m: float, design: dict[str, Any]) -> StationSlope:
# ⚠ 없는 측점은 0 이다 — 성토 사면길이로 **대신 채우지 않는다**(면이 다름).
bench_cut_length_m=_num(design.get("bench_cut_length_m")) or 0.0,
fill_height_m=max(fill_by_side.values(), default=0.0),
roadbed_width_m=_roadbed_width(design),
berm_width_m=_num(berm.get("width_m")) or 0.0,
segments=tuple(segments),
unclosed=bool(design.get("slope_unclosed")),
@@ -111,11 +111,17 @@ def _write(folder: Path, item: dict[str, Any]) -> None:
(folder / f"{item['code']}.json").write_text(text, encoding="utf-8")
def save_personal(folder: Path, template: dict[str, Any], overrides: dict[str, Any] | None) -> str:
"""[내 라이브러리에 저장] — 양식 + 프로젝트에서 고친 식을 **개인 단에 한 벌**로 씀. 코드.
def save_personal(
folder: Path,
template: dict[str, Any],
overrides: dict[str, Any] | None,
unit_price_rows: list[dict[str, Any]] | None = None,
) -> str:
"""[내 라이브러리에 저장] — 양식 + 고친 식·줄 조합을 **개인 단에 한 벌**로 씀. 코드.
⚠ 반대 방향(작업본 → 개인 단)이라 프로젝트는 안 바꿈(브레인 판정 Ⓑ).
⚠ 개인 단에 같은 종류가 있으면 **그 코드로 덮어씀** — 지금은 종류당 하나(판정 Ⓐ).
⛔ 수동 단가는 안 실음 — 프로젝트의 값이라 양식에 실으면 남의 프로젝트로 감(브레인 판정).
"""
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import overridden_rows
@@ -129,6 +135,8 @@ def save_personal(folder: Path, template: dict[str, Any], overrides: dict[str, A
for row in overridden_rows(template, overrides)
]
item = {k: v for k, v in template.items() if k != "imported_from"}
if unit_price_rows is not None:
item["unit_price"] = {**(item.get("unit_price") or {}), "rows": unit_price_rows}
_write(folder, {**item, "code": code, "library_tier": "personal", "rows": rows})
return code
@@ -0,0 +1,169 @@
"""구조물도 일위대가 → B09 내역 **받는 문** (PLAN 6장 첫 일감 ② · 2026-09-13 브레인 판정).
⭐ 종전엔 구조물도가 세운 일위대가(`B-AX-ST-*`)가 인계로 **한 번도 안 나갔음** — 돌쌓기는
`FP-13-04-05` ㎡ 단가만 가고 모르터·기초잡석은 따로거나 빠졌음. 사슬의 진짜 빈칸.
한 벌로 두는 것 — 인계(B08)와 내역(B09)이 **같은 목록·같은 키**를 봄
양식 일위대가로 셀 장 → `priced_sheets` (장마다 코드 · 제원 키 · 구성원 · 품은 줄)
인계 줄 → `template_row` (코드 AX-ST · 갈래 = 제원 키 · 수량 = 연장 m)
내역 금액 → `price_sheets` (B09 단가표로 B08 일위대가 엔진을 돌림 — 코드 한 벌)
⚠ 호표 코드 `B-AX-ST-<8hex>#<키>` — 키는 **사람이 읽는 제목이 아니라** 양식 제원 칸 값을 칸 이름
차례로 이은 것(연장 L 뺌) + `normalize_variant_key`. 제목 글자가 바뀌어도 코드는 안 바뀜.
⚠ 양식 일위대가가 품은 원단위 줄(돌쌓기·모르터·기초잡석)은 **따로 인계하지 않음** — `covered`.
⚠ 금액 정본은 B09 — 여기 `price_sheets` 는 B09 가 넘긴 단가표로만 셈. 수동 단가도 입력으로 씀.
"""
from __future__ import annotations
from collections.abc import Callable, Iterable
from typing import Any
#: 인계 줄 갈래 축 이름 — 「구조물도 장 제원」.
VARIANT_AXIS = "structure_sheet"
def _key_value(value: Any) -> str:
if value is None:
return ""
if isinstance(value, (int, float)) and not isinstance(value, bool):
return format(float(value), ".6g")
return str(value).strip()
def sheet_variant_key(template: dict[str, Any], sheet: dict[str, Any]) -> str:
"""장의 **제원 키** — 양식 칸 이름 차례로 `이름=값` 을 `|` 로 이음(연장 칸 뺌: m당)."""
values = (sheet.get("formula_sheet") or {}).get("vars") or {}
specs = template.get("vars") or {}
return "|".join(
f"{name}={_key_value(values.get(name))}"
for name in sorted(specs)
if specs[name].get("source") != "length_m"
)
def structure_price_code(code: str, key: str) -> str:
"""호표 코드 `B-AX-ST-<8hex>#<정규화 키>` — 인계·내역이 이것 하나로 만남."""
from B09_Estimation.B09_Estimation_UnitPrice import normalize_variant_key
return f"B-{code}#{normalize_variant_key(key)}"
def priced_sheets(
sheets: Iterable[dict[str, Any]],
templates: dict[str, dict[str, Any]],
row_edits: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
"""양식 일위대가로 셀 장 — 양식(고친 줄 조합 얹음)에 일위대가 줄과 코드가 있는 장만."""
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import with_rows
found = []
for sheet in sheets:
type_id = str(sheet.get("type_id") or "")
base = templates.get(type_id)
if not base or not base.get("code") or not sheet.get("formula_sheet"):
continue
template = with_rows(base, (row_edits or {}).get(type_id))
rows = template["unit_price"]["rows"]
if not rows:
continue
names = {row.get("seq"): str(row.get("name") or "") for row in template.get("rows") or []}
found.append(
{
"type_id": type_id,
"code": str(template["code"]),
"name": str(template.get("name") or type_id),
"title": str(sheet.get("title") or ""),
"key": sheet_variant_key(template, sheet),
"structure_ids": [
str(member["structure_id"])
for member in sheet.get("members") or []
if member.get("structure_id")
],
# 일위대가가 품은 원단위 줄 이름 — 따로 인계하면 두 번 셈.
"covered": sorted(
{names[r["from_row"]] for r in rows if r.get("from_row") in names}
),
"template": template,
"sheet": sheet,
}
)
return found
def by_structure(entries: Iterable[dict[str, Any]]) -> dict[str, dict[str, Any]]:
return {sid: entry for entry in entries for sid in entry["structure_ids"]}
def template_row(row: dict[str, Any], structure: dict[str, Any], entry: dict[str, Any]) -> dict:
"""전개 인계 줄(같은 칸 29개 계약)을 **양식 일위대가 줄**로 바꿔 씀 — 코드·갈래·수량·단위만."""
length = float(structure.get("length_m") or 0.0)
in_bill = length > 0
return {
**row,
"work_item_code": entry["code"],
"name": entry["name"],
"spec": entry["title"],
"spec_detail": entry["title"],
"unit": "m",
"quantity": length,
"composite_parts": None,
"composite_not_ready": None,
"structure_kind": None,
"variant_axis": VARIANT_AXIS,
"variant_value": entry["key"],
"secondary_axes": None,
"spec_class": None,
"spec_class_basis": f"구조물도 양식 일위대가 — 품은 줄: {'·'.join(entry['covered'])}",
# 막힘은 일위대가 표가 정함(B09 가 셈) — 전개 쪽 갈래 사유는 여기 안 실음.
"blocked_kind": None if in_bill else row.get("blocked_kind"),
"blocked_reason": "" if in_bill else row.get("blocked_reason") or "연장이 0 이라 안 세움",
"in_bill": in_bill,
"in_bill_reason": "" if in_bill else "연장이 0 이라 내역에 안 세움",
}
def price_sheets(
entries: Iterable[dict[str, Any]],
book: Any,
find_variant: Callable[[str, str], str | None],
library: Iterable[dict[str, Any]],
manual_all: dict[str, Any] | None = None,
) -> dict[str, dict[str, Any]]:
"""B09 내역이 쓸 호표 금액 `{B-AX-ST-…#키: {money, total, blocked, unconfirmed, reasons}}`.
⚠ `money` 는 **호표 표의 성분 소계**(칸마다 0.1원 절사한 합) · `total` 은 계금(1원 절사) —
명세 7장 「호표 안 성분 소계는 절사」. 안 자른 값을 쓰면 구조물도 화면(192,429)과
내역 단가(192,430)가 1원 갈림(2026-09-13 검증 프로젝트 실측).
"""
from decimal import Decimal
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import unit_price_money
from B09_Estimation.B09_Estimation_PriceBook import Money3
prices: dict[str, dict[str, Any]] = {}
library = list(library)
for entry in entries:
assembled = unit_price_money(
entry["template"],
entry["sheet"],
book,
find_variant,
library,
(manual_all or {}).get(entry["type_id"]) or {},
)
if assembled is None:
continue
table, _exact = assembled
prices[structure_price_code(entry["code"], entry["key"])] = {
"money": Money3(*(Decimal(str(table[k])) for k in ("material", "labor", "expense"))),
"total": Decimal(str(table["total"])),
"blocked": table["blocked"],
"unconfirmed": table["unconfirmed"],
"reasons": [
f"{row['name']}: {row['reason']}"
for row in table["rows"]
if row["reason"] and not row["skipped"] and "total" not in row
],
}
return prices
@@ -0,0 +1,358 @@
"""B08 **구조물 집계표** — 측점별 구조물 한 줄 · 종류별 표 (PLAN 2장 · 사용자 확정 ④).
한 줄 = 측점 + 종류 + 실치수(제원 칸) + 개소·연장. 실무 「돌-골막이치수」 치수조서가
실치수를 들고 구조도 탭이 끌어 쓰는 모양을 **한 탭으로 겸함**.
값이 어디서 오나 — 칸마다 출처를 붙임
auto 정본에 적힌 값 — `structures.json`(B05) · `pipe_points.json`(계곡 통과 시설) ·
관 연장은 B06 횡단 `design.pipe_length_m`
user 이 표에서 사람이 고쳐 **정본에 적은** 값 — 산출 조건에 「손댄 칸」 표만 둠(브레인 판정)
library 정본 칸이 비어 **양식 기본값**으로 선 칸(구조물도 양식 `vars.*.default`)
empty 정본도 양식도 값이 없음 — 계산 쪽이 막히거나 기준값으로 돎
⚠ 사용자 손 값은 덮개층이 아니라 **정본에 씀** — 도면·수량이 한 값(지침 5장 「쪼개지 않음」).
⚠ 손댄 칸의 정본 값이 뒤에 B05 에서 바뀌면 그 칸은 자동으로 돌아가되 **조용히 말고** 줄에 알림.
⚠ 값을 여기서 짓지 않음 — 읽어 줄 세우기만. 연장이 없는 관은 빈칸(0 으로 안 채움).
⚠ 관 지점 정본 타입(`managed_by`)은 `structures.json` 에 있어도 안 셈 — `pipe_points.json` 이 주인.
⛔ 고칠 수 있는 칸은 **상세 칸(`phase: detail`)만** — 자리·길이·높이·관경 같은 놓기 칸(`b05`)은
시·종점·횡단 설계와 **한 벌로 움직여**(B05 에서 길이 = 전 + 후, 시·종점이 따라 섬) 여기서 한 칸만
고치면 쪼개짐(지침 5장). 그 칸은 구조물 놓기(B05)에서 고침.
"""
from __future__ import annotations
import math
from collections.abc import Iterable
from typing import Any
SOURCE_AUTO = "auto"
SOURCE_USER = "user"
SOURCE_LIBRARY = "library"
SOURCE_EMPTY = "empty"
#: 산출 조건 자리 — 손댄 칸 `{줄 id: {칸: {"value": 적은 값, "was": 고치기 전 정본 값}}}`.
USER_CELLS_KEY = "structure_summary_user_cells"
#: 계곡 통과 시설 `facility` → 레지스트리 종류. 빈 값은 배관(`PipePoint` 기본).
FACILITY_TYPES = {
"pipe": "pipe",
"box_culvert": "box_culvert",
"ford_pavement": "ford_pavement",
"ford_bridge": "ford_bridge",
"revetment": "revetment",
}
def _blank(value: Any) -> bool:
return value is None or value == ""
def _number(value: Any) -> float | None:
if isinstance(value, bool) or _blank(value):
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _defaults(template: dict[str, Any] | None) -> dict[str, Any]:
"""양식 제원 칸 기본값 `{옵션 키: 기본값}` — 정본 칸이 비었을 때 계산이 쓰는 값."""
return {
str(spec["option"]): spec["default"]
for spec in ((template or {}).get("vars") or {}).values()
if spec.get("option") and "default" in spec and not _blank(spec["default"])
}
def _same(left: Any, right: Any) -> bool:
"""정본 값과 적은 값이 같은가 — 숫자는 3.0 과 "3" 을 같게 봄."""
a, b = _number(left), _number(right)
return a == b if a is not None and b is not None else left == right
def _cells(
keys: Iterable[str],
options: dict[str, Any],
defaults: dict[str, Any],
marks: dict[str, Any],
) -> dict:
cells = {}
for key in keys:
current = None if _blank(options.get(key)) else options[key]
mark = marks.get(key)
if mark is not None and not _same(current, mark.get("value")):
# 사람이 적은 값을 B05 가 바꿈 — 자동으로 돌아가되 옛 값을 함께 실어 화면이 알림.
cells[key] = {
"value": current,
"source": SOURCE_EMPTY if current is None else SOURCE_AUTO,
"replaced_user_value": mark.get("value"),
}
elif mark is not None:
cells[key] = {"value": current, "source": SOURCE_USER, "was": mark.get("was")}
elif current is not None:
cells[key] = {"value": options[key], "source": SOURCE_AUTO}
elif key in defaults:
cells[key] = {"value": defaults[key], "source": SOURCE_LIBRARY}
else:
cells[key] = {"value": None, "source": SOURCE_EMPTY}
return cells
def build_summary(
structures: Iterable[dict[str, Any]],
pipe_points: Iterable[dict[str, Any]],
types: dict[str, Any],
templates: dict[str, dict[str, Any]] | None = None,
pipe_lengths: dict[float, float] | None = None,
user_cells: dict[str, dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""종류별 표 목록(레지스트리 차례) — 표마다 칸 정의·줄·개소·연장 합·평균치수.
`types` 는 `structure_type_map()` · `templates` 는 종류별 양식(프로젝트에 박힌 것 → 기본) ·
`pipe_lengths` 는 B06 횡단의 `{측점: 관 연장}`(`pipe_lengths_from_designs`) ·
`user_cells` 는 이 표에서 손댄 칸(`USER_CELLS_KEY` 모양).
"""
# 관 길이 찾기는 배수관 물량과 **같은 규칙**(허용 0.5m) — 두 표가 같은 관에 다른 연장을 안 적게.
from B08_Quantity.B08_Quantity_Engine_Pipe import _nearest
templates = templates or {}
lengths = pipe_lengths or {}
rows_by_type: dict[str, list[dict[str, Any]]] = {}
notes: list[str] = []
def add(type_id: str, row: dict[str, Any]) -> None:
definition = types.get(type_id)
if definition is None:
notes.append(f"{type_id}: 레지스트리에 없는 종류라 안 셈")
return
options = row.pop("options")
keys = [field.key for field in definition.options]
marks = (user_cells or {}).get(str(row["id"])) or {}
row["cells"] = _cells(keys, options, _defaults(templates.get(type_id)), marks)
replaced = [key for key, cell in row["cells"].items() if "replaced_user_value" in cell]
if replaced:
labels = {field.key: field.label for field in definition.options}
row["replaced"] = [labels[key] for key in replaced]
if definition.design_owner:
row["note"] = f"{definition.design_owner}가 수량을 셈 — 여기선 자리만"
elif definition.reference_only:
row["note"] = "전문 상세설계 대상 — 배치까지만"
rows_by_type.setdefault(type_id, []).append(row)
for item in structures:
type_id = str(item.get("type_id") or "")
definition = types.get(type_id)
if definition is not None and definition.managed_by:
continue # 관 지점 정본이 주인 — 옛 저장분이 남아 있어도 두 번 안 셈
options = dict(item.get("options") or {})
start, end = item.get("start_m"), item.get("end_m")
span = abs(float(end) - float(start)) if start is not None and end is not None else None
stated = _number(options.get("length_m"))
add(
type_id,
{
"id": item.get("structure_id"),
"origin": "structures",
"chainage_m": item.get("chainage_m"),
"start_m": start,
"end_m": end,
"count": 1,
# 원단위 전개와 같은 규칙 — 제원 연장이 있으면 그것, 없으면 시·종점 거리.
"length_m": stated if stated else span,
"length_basis": "제원 연장" if stated else ("시·종점" if span else ""),
"memo": item.get("memo") or "",
"options": options,
},
)
for point in pipe_points:
facility = str(point.get("facility") or "pipe")
type_id = FACILITY_TYPES.get(facility, facility)
chainage = float(point.get("chainage_m") or 0.0)
length = _nearest(lengths, chainage) if type_id == "pipe" else None
add(
type_id,
{
"id": pipe_row_id(point),
"origin": "pipe_points",
"chainage_m": chainage,
"start_m": point.get("start_m"),
"end_m": point.get("end_m"),
"count": 1,
"length_m": length,
"length_basis": "B06 횡단 관 연장" if length else "",
"memo": "",
"options": dict(point.get("options") or {}),
},
)
tables = []
for type_id, definition in types.items():
rows = rows_by_type.get(type_id)
if not rows:
continue
rows.sort(key=lambda row: float(row.get("chainage_m") or row.get("start_m") or 0.0))
columns = [
{
"key": f.key,
"label": f.label,
"unit": f.unit or "",
"input": f.input,
"choices": list(f.choices),
"editable": f.phase == "detail" and f.enabled,
}
for f in definition.options
]
averages = {}
for column in columns:
values = [_number(row["cells"][column["key"]]["value"]) for row in rows]
present = [v for v in values if v is not None]
if present and column["input"] == "number":
averages[column["key"]] = sum(present) / len(present)
replaced = sum(1 for row in rows if row.get("replaced"))
if replaced:
notes.append(
f"{definition.name} {replaced}줄 — 이 표에서 고친 값을 B05 가 바꿔 "
"자동값으로 돌아감"
)
measured = [row["length_m"] for row in rows if row["length_m"]]
# 연장이 뜻 있는 표만 빈 연장을 셈 — 반사경·표지판 같은 점 시설은 연장이 없는 것이 정상.
needs_length = definition.placement == "interval" or type_id == "pipe"
tables.append(
{
"type_id": type_id,
"name": definition.name,
"group": definition.group,
"placement": definition.placement,
"columns": columns,
"rows": rows,
"count": len(rows),
"length_total_m": sum(measured) if measured else None,
"length_missing": len(rows) - len(measured) if needs_length else 0,
"averages": averages,
}
)
return {"tables": tables, "notes": notes}
def pipe_row_id(point: dict[str, Any]) -> str:
"""계곡 통과 시설 줄 id — 관 지점엔 식별자가 없어 기준점(㎜ 자리)으로 가름."""
return f"pipe@{float(point.get('chainage_m') or 0.0):.3f}"
def _type_of(target: dict[str, Any], is_pipe: bool) -> str:
if is_pipe:
facility = str(target.get("facility") or "pipe")
return FACILITY_TYPES.get(facility, facility)
return str(target.get("type_id") or "")
def coerce(field: Any, value: Any, name: str) -> Any:
"""칸 값 꼴 맞추기 — B05 저장 관문과 같은 규칙(수 칸은 0 이상 수 · 고르기 칸은 보기 안).
빈 값(None·"")은 `None` = 그 칸을 지움. 틀리면 `ValueError`.
"""
if _blank(value):
return None
if field.input == "number":
number = _number(value)
if number is None or not math.isfinite(number) or number < 0:
raise ValueError(f"{name} {field.label}: 0 이상의 수여야 함 — {value!r}")
return int(number) if number.is_integer() else number
# 수로 온 보기 값(뒷길이 35 → 35.0)은 보기 글과 같은 꼴로 — 「35.0」은 보기 「35」와 안 맞음.
whole = isinstance(value, float) and value.is_integer()
text = str(int(value)) if whole else str(value).strip()
if field.input == "select" and field.choices and text not in field.choices:
raise ValueError(f"{name} {field.label}: 보기에 없는 값 — {text}")
return text
def apply_edits(
structures: list[dict[str, Any]],
points: list[dict[str, Any]],
types: dict[str, Any],
edits: Iterable[dict[str, Any]],
marks: dict[str, dict[str, Any]],
) -> tuple[list[str], dict[str, dict[str, Any]]]:
"""손 고침을 **정본 사본에 바로** 얹음(제자리 바꿈) — (바뀐 줄 id, 새 손댄 칸 표).
고치기 전 값(`was`)은 처음 손댄 때의 정본 값을 지킴 — 그 값으로 되돌리면 표에서 빠져 「자동」.
놓기 칸·없는 줄·틀린 값은 `LookupError`/`ValueError` — 부르는 쪽이 아무것도 안 씀.
"""
from B08_Quantity.B08_Quantity_Engine_StructureSheet_Edit import EDITABLE_KEYS as SHEET_KEYS
from B08_Quantity.B08_Quantity_Engine_StructureSheet_Edit import clean_spec
rows = {str(item.get("structure_id")): (item, False) for item in structures}
rows |= {pipe_row_id(point): (point, True) for point in points}
marks = {row_id: dict(cells) for row_id, cells in marks.items()}
changed: list[str] = []
for edit in edits:
row_id, key = str(edit.get("id")), str(edit.get("key"))
if row_id not in rows:
raise LookupError(f"집계표 줄을 못 찾음: {row_id}")
target, is_pipe = rows[row_id]
definition = types.get(_type_of(target, is_pipe))
field = next((f for f in definition.options if f.key == key), None) if definition else None
if field is None:
raise ValueError(f"{row_id}: 그 종류에 없는 칸 — {key}")
if field.phase != "detail":
raise ValueError(
f"{definition.name} {field.label}: 자리·길이·높이 같은 놓기 칸은 "
"시·종점과 한 벌이라 구조물 놓기(B05)에서 고침"
)
value = coerce(field, edit.get("value"), definition.name)
if value is not None and key in SHEET_KEYS:
# 구조물도 [제원 저장]과 **같은 꼴**로 적음(뒷길이는 정수 등) — 두 길의 꼴이 같아야 함.
# ⚠ 검사(`coerce`)를 먼저 — `clean_spec` 은 못 읽은 값을 빼 버려 「지우기」로 읽힘.
value = clean_spec({key: value})[0].get(key, value)
options = dict(target.get("options") or {})
old = None if _blank(options.get(key)) else options[key]
if (value is None and old is None) or (value is not None and _same(old, value)):
continue
if value is None:
options.pop(key, None)
else:
options[key] = value
target["options"] = options
cells = marks.setdefault(row_id, {})
mark = cells.get(key)
# 옛 손 표가 정본과 같을 때만 그 「고치기 전」을 이어 씀 — B05 가 바꾼 뒤면 지금 값이 기준.
was = mark.get("was") if mark and _same(mark.get("value"), old) else old
if _same(was, value):
cells.pop(key, None)
else:
cells[key] = {"value": value, "was": was}
if not cells:
marks.pop(row_id)
if row_id not in changed:
changed.append(row_id)
return changed, marks
def prune_marks(
marks: dict[str, dict[str, Any]],
structures: Iterable[dict[str, Any]],
points: Iterable[dict[str, Any]],
) -> dict[str, dict[str, Any]]:
"""[저장] 때 손댄 칸 표 정리 — 없어진 줄과 **B05 가 이미 바꾼 칸**(알림을 본 뒤)은 뺌."""
current = {str(item.get("structure_id")): item for item in structures}
current |= {pipe_row_id(point): point for point in points}
kept: dict[str, dict[str, Any]] = {}
for row_id, cells in marks.items():
if row_id not in current:
continue
options = current[row_id].get("options") or {}
alive = {
key: mark
for key, mark in cells.items()
if _same(None if _blank(options.get(key)) else options[key], mark.get("value"))
}
if alive:
kept[row_id] = alive
return kept
def pipe_lengths_from_designs(designs: Iterable[dict[str, Any]]) -> dict[float, float]:
"""저장된 횡단 설계 → `{측점: 관 연장}` — 배수관 물량과 같은 읽기."""
from B08_Quantity.B08_Quantity_Engine_Pipe import _length_by_chainage
return _length_by_chainage(list(designs or []), "pipe_length_m")
@@ -8,6 +8,10 @@
일위대가를 먼저 세우고 그 단위당 금액을 씀. 레시피 150/350 이 2단 이상(명세 16장).
깊이 **5단**까지(PLAN 10장 판정) · 돌면 막힘. B-FP·X·L 쪽 재귀는 단가표가 이미 함.
⛔ 하위 양식은 **프로젝트에 박힌 것과 프로그램 기본**에서만 찾음 — 개인·회사 단은 안 읽음(4장 Ⓑ).
⚠ **저장 자리는 둘**(브레인 판정 2026-09-13) — 줄 더하기·빼기·고르개로 고친
**줄 조합은 양식+프로젝트**(`ROWS_KEY`, 종류별 · [내 라이브러리에 저장] 때 양식에 실림) ·
**수동 단가는 프로젝트만**(`MANUAL_KEY` · 양식에 실으면 내 단가가 남의 프로젝트로 감).
수동 단가 줄은 「미확정」으로 셈.
"""
from __future__ import annotations
@@ -21,6 +25,15 @@ from typing import Any
SUB_STRUCTURE_PREFIX = "B-AX-ST-"
#: 재귀 깊이 한도 — 맨 윗 표가 1단(PLAN 10장 「재귀 깊이 = 5단」).
MAX_DEPTH = 5
#: 산출 조건 자리 — 고친 줄 조합 `{type_id: [rows]}` · 수동 단가 `{type_id: {seq: 값}}`.
ROWS_KEY = "structure_unit_price_rows"
MANUAL_KEY = "structure_manual_prices"
#: 고르개 갈래 — 품셈(일위대가·단가산출) · 자원(자재·노임·시간당 중기).
SEARCH_KINDS = {
"work": ("unit_price", "price_basis"),
"resource": ("material", "labor", "machine_hourly"),
}
_MONEY_KEYS = ("material", "labor", "expense")
_UNIT_ALIASES = {"m2": "㎡", "m3": "㎥", "M2": "㎡", "M3": "㎥", "M": "m"}
@@ -34,6 +47,8 @@ class _Context:
book: Any
find_variant: Callable[[str, str], str | None]
library: dict[str, dict[str, Any]]
#: 맨 윗 표의 수동 단가 `{seq: 값}` — 하위 양식에는 안 씀(그 종류의 줄이 아님).
manual: dict[str, dict[str, Any]]
@dataclass
@@ -153,6 +168,7 @@ def _assemble(
sums = {"material": Decimal(0), "labor": Decimal(0), "expense": Decimal(0)}
exact = Money3()
blocked = 0
unconfirmed = 0
for row in spec.get("rows") or []:
out: dict[str, Any] = {
"seq": row.get("seq"),
@@ -165,6 +181,15 @@ def _assemble(
"reason": "",
}
source = sheet_rows.get(row.get("from_row")) if "from_row" in row else None
# 이중계상 경계 ①(명세 6장) — 토공집계·사토 공제·참고로 가는 줄을 일위대가에 또 넣지 않음.
from B08_Quantity.B08_Quantity_Engine_Handoff_Boundaries import NOT_BILLED_BY_WORK_ITEM
owner = NOT_BILLED_BY_WORK_ITEM.get(str((source or {}).get("destination") or ""))
if owner:
out["reason"] = f"이중계상 — 원단위 줄 {row['from_row']} 은 {owner}로 가는 줄"
blocked += 1
rows.append(out)
continue
if source is not None and (source.get("skipped") or source.get("error")):
# 원단위 줄이 안 선 장이면 일위대가 줄도 안 섬 — 막힘이 아님(버림 「안 넣음」 등).
out.update(skipped=bool(source.get("skipped")), reason=source.get("reason") or "")
@@ -193,7 +218,18 @@ def _assemble(
ref, why = _ref_of(row, values, ctx.find_variant)
out["ref_code"] = ref or ""
priced = None
if ref:
manual = ctx.manual.get(str(row.get("seq"))) if depth == 1 else None
if manual:
# 수동 단가가 단가표보다 이김 — 사람이 일부러 넣은 값. 대신 「미확정」으로 셈.
money = Money3(*(Decimal(str(manual.get(key) or 0)) for key in _MONEY_KEYS))
priced = _Priced(money, out["name"], out["spec"], out["unit"])
out.update(
manual=True,
manual_source=manual.get("source") or "",
manual_entered_at=manual.get("entered_at") or "",
)
unconfirmed += 1
elif ref:
try:
priced = _price(ref, row, ctx, depth, seen)
if out["unit"] and priced.unit and priced.unit != out["unit"]:
@@ -238,6 +274,8 @@ def _assemble(
"total": float(round_at(sum(sums.values()), OutputPlace.UNIT_PRICE_TOTAL)),
"blocked": blocked,
"complete": blocked == 0,
#: 수동 단가로 선 줄 수 — 화면 「미확정 N건」 배지.
"unconfirmed": unconfirmed,
}
return table, exact
@@ -248,14 +286,104 @@ def unit_price_table(
book: Any,
find_variant: Callable[[str, str], str | None],
library: Iterable[dict[str, Any]] = (),
manual: dict[str, dict[str, Any]] | None = None,
) -> dict[str, Any] | None:
"""장 하나의 일위대가 표. 양식에 `unit_price` 가 없으면 `None`.
수량 — `from_row`(원단위 줄 차례 → 그 줄의 단위당 값) 또는 박힌 `quantity`.
코드 — `ref_code`(단가표 코드 그대로) 또는 `work_item_code` + `variant_from`(제원 칸 → 갈래).
`library` — 하위 구조물 일위대가(`B-AX-ST-*`)를 찾을 양식들(프로젝트에 박힌 것 + 프로그램 기본).
`manual` — 이 프로젝트의 수동 단가 `{seq: {material, labor, expense, source, entered_at}}`.
"""
ctx = _Context(book, find_variant, {str(t["code"]): t for t in library if t.get("code")})
code = str(template.get("code") or "")
assembled = _assemble(template, sheet, ctx, 1, (code,) if code else ())
assembled = unit_price_money(template, sheet, book, find_variant, library, manual)
return assembled[0] if assembled else None
def unit_price_money(
template: dict[str, Any],
sheet: dict[str, Any],
book: Any,
find_variant: Callable[[str, str], str | None],
library: Iterable[dict[str, Any]] = (),
manual: dict[str, dict[str, Any]] | None = None,
) -> tuple[dict[str, Any], Any] | None:
"""`unit_price_table` 과 같되 **안 자른 단위당 3분할**도 — B09 내역 줄이 이 값에 수량을 곱함."""
ctx = _Context(
book, find_variant, {str(t["code"]): t for t in library if t.get("code")}, manual or {}
)
code = str(template.get("code") or "")
return _assemble(template, sheet, ctx, 1, (code,) if code else ())
def with_rows(template: dict[str, Any], rows: list[dict[str, Any]] | None) -> dict[str, Any]:
"""고친 줄 조합을 얹은 양식 — 고친 적 없으면 양식 줄. 줄 없는 양식도 빈 표로 세움."""
spec = template.get("unit_price") or {}
picked = (spec.get("rows") or []) if rows is None else rows
return {**template, "unit_price": {**spec, "rows": picked}}
def save_rows(
current: dict[str, Any], type_id: str, template: dict[str, Any], rows: list[dict[str, Any]]
) -> tuple[dict[str, Any], bool]:
"""줄 조합 저장본 — 양식과 같으면 그 종류를 지움(양식대로). (새 저장본, 바뀜)."""
merged = {key: value for key, value in current.items() if key != type_id}
if rows != ((template.get("unit_price") or {}).get("rows") or []):
merged[type_id] = rows
return merged, merged != current
def save_manual(
current: dict[str, Any],
type_id: str,
rows: list[dict[str, Any]],
prices: dict[str, dict[str, Any]],
today: str,
) -> dict[str, Any]:
"""수동 단가 저장본 — **남은 줄의 것만** 둠(뺀 줄의 값이 같은 차례 새 줄에 붙지 않게).
넣은 날짜는 값·출처가 그대로면 옛 날짜를 둠 — 다시 저장했다고 날짜가 새로 서면 안 됨.
"""
seqs = {str(row.get("seq")) for row in rows}
before = current.get(type_id) or {}
kept = {}
for seq, price in prices.items():
if str(seq) not in seqs:
continue
old = before.get(str(seq)) or {}
same = all(old.get(key) == price.get(key) for key in (*_MONEY_KEYS, "source"))
kept[str(seq)] = {**price, "entered_at": old.get("entered_at") if same else today}
merged = {key: value for key, value in current.items() if key != type_id}
if kept:
merged[type_id] = kept
return merged
def search_titles(book: Any, query: str, kind: str, limit: int = 50) -> list[dict[str, Any]]:
"""고르개 — 단가표에서 낱말이 **모두** 든(코드·이름·규격) 항목. 단가가 섰는지도 함께."""
from B09_Estimation.B09_Estimation_PriceBook import PriceBookError
kinds = SEARCH_KINDS[kind]
words = query.lower().split()
found: list[dict[str, Any]] = []
for title in book.titles.values() if words else ():
if len(found) >= limit:
break
if title.kind.value not in kinds:
continue
text = f"{title.code} {title.name} {title.spec}".lower()
if all(word in text for word in words):
try:
money = book.resolve(title.code)
total: float | None = float(money.material + money.labor + money.expense)
except PriceBookError:
total = None
found.append(
{
"code": title.code,
"name": title.name,
"spec": title.spec,
"unit": _unit(title.unit),
"price": total,
}
)
return found
+39 -5
View File
@@ -33,13 +33,12 @@ from B06_Section.B06_Section_Repository import (
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary_table
from B08_Quantity.B08_Quantity_Engine_EarthworkTable import StationArea, build_table
from B08_Quantity.B08_Quantity_Engine_HaulSummary import build_table as build_haul_table
from B08_Quantity.B08_Quantity_Engine_HaulSummary import check_against_plan
from B08_Quantity.B08_Quantity_Engine_Handoff import load_mapping
from B08_Quantity.B08_Quantity_Engine_HaulSummary import build_table as build_haul_table
from B08_Quantity.B08_Quantity_Engine_HaulSummary import check_against_plan, summary_input_rows
from B08_Quantity.B08_Quantity_Engine_Preparation import build_table as build_preparation_table
from B08_Quantity.B08_Quantity_Engine_HaulSummary import summary_input_rows
from B08_Quantity.B08_Quantity_Engine_SlopeArea import build_table as build_slope_table
from B08_Quantity.B08_Quantity_Engine_SlopeLength import station_slopes
from B08_Quantity.B08_Quantity_Engine_SlopeLength import road_surface_area, station_slopes
from B08_Quantity.B08_Quantity_Provenance import quantity_provenance
from common_util.common_util_project_settings import (
CONCRETE_PLACING_METHODS,
@@ -48,15 +47,18 @@ from common_util.common_util_project_settings import (
concrete_placing_method,
earthwork_conversion_choices,
earthwork_conversion_factors,
haul_limit_choice,
quantity_settings,
rock_classes,
save_section,
topsoil_target,
)
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import run_with_connection
from config.config_system_design import (
EARTHWORK_CONVERSION_FACTORS,
EARTHWORK_CONVERSION_PUMSEM_C_RANGES,
EARTHWORK_HAUL_EQUIPMENT_LIMITS_M,
)
logger = logging.getLogger(__name__)
@@ -90,6 +92,8 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
table = build_table(_stations(designs), factors)
# 화면이 「무엇을 골랐나 · 품셈 범위 안인가」를 보이는 데 쓴다. 계산에는 안 들어간다.
table["conversion_factor_choices"] = earthwork_conversion_choices(settings)
# 도쟈 한계거리 — 지금 값·기본값·근거를 함께 보인다(유토곡선 장비 경계, 2026-09-13 판정).
table["haul_limit_choice"] = haul_limit_choice(settings)
# 품셈 암종별 범위 — **화면 안내용**이다. 정의처가 서버 한 곳이라 내려보내 쓴다
# (프론트에 다시 적으면 두 벌이 되어 갈린다).
table["conversion_factor_pumsem_ranges"] = [
@@ -97,7 +101,8 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
for name, low, high in EARTHWORK_CONVERSION_PUMSEM_C_RANGES
]
# 사면 계열은 저장된 설계선에서 유도한다.
slope = build_slope_table(station_slopes(designs))
slopes = station_slopes(designs)
slope = build_slope_table(slopes)
table["slope"] = slope
plan = await _stored_haul_plan(project_id, route_id)
@@ -144,6 +149,8 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
key: application_ratio(settings, key)
for key in (settings.get("application_ratios_pct") or {})
},
# 노체다짐 — 기본 꺼짐. 켠 프로젝트에서만 줄이 선다(2026-09-13 판정).
subgrade_compaction_enabled=bool(settings.get("subgrade_compaction_enabled")),
)
)
# 준비공·사방공 — 못 서는 줄도 사유와 함께 남긴다(빈 표는 「빠뜨림」과 구별이 안 됨).
@@ -163,6 +170,10 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
# 임목파쇄 — 기본 꺼짐. 켠 프로젝트에서만 줄이 선다(확정 5차 5번).
settings.get("wood_chipping_enabled"),
settings.get("wood_chipping_volume_m3"),
# 표토제거 대상 「노면」 면적 — 사면과 같은 측점 설계에서(2026-09-13 판정 「실무대로」).
road_surface_area(slopes),
# 대상 — 기본 노면 + 절토(별표2 문언) · 노면만은 설계자가 고름(2026-09-14 판정).
topsoil_target(settings) == "road_only",
)
method, method_is_default = concrete_placing_method(settings)
# ⚠ 금액에 바로 걸리는 값이라 「기본값으로 돌고 있음」을 응답에 실어 화면이 띄우게 한다.
@@ -434,6 +445,8 @@ class QuantitySettingsBody(BaseModel):
structure_trench_water: str | None = None
# 표토 운반거리(m) — 별표2 가 요구하는 운반·적치의 밑수. 비면 그 줄이 막힌다.
topsoil_haul_distance_m: float | None = None
# 표토제거 대상 — "road_only" 는 노면만. `""` 는 기본(노면 + 절토, 별표2 문언)으로 되돌림.
topsoil_target: str | None = None
# 임목축적 등급 — "소림"·"중림"·"밀림"(품셈 9-21 [주]①). `""` 는 「안 정함」이다.
stand_volume_class: str | None = None
# 규준틀 개소당 재료 — `{자재명: 수량}`. 비우면 제안값(실무 관측)이 선다.
@@ -441,10 +454,14 @@ class QuantitySettingsBody(BaseModel):
# 임목파쇄 — 기본 꺼짐(확정 5차 5번). 켜면 줄이 서고, 부피를 넣으면 값이 선다.
wood_chipping_enabled: bool | None = None
wood_chipping_volume_m3: float | None = None
# 노체다짐 — 기본 꺼짐(9-16-2 [주]⑤ 조건부). 켜면 토공집계에 별도 줄.
subgrade_compaction_enabled: bool | None = None
# 토량환산계수(다짐) — `{갈래: {"compacted": C, "reason": 사유}}`.
# ⚠ **기본값을 복사해 넣지 않는다** — 안 고른 갈래는 키가 없어야 정본이 선다.
# 빈 dict 는 「전부 기본값으로 되돌림」이라 통째로 갈아 끼운다.
conversion_factors_override: dict[str, Any] | None = None
# 도쟈 한계거리(m) — `None` 은 기본값(60 m). 종무대 20 m 보다 커야 한다(도쟈 몫이 사라짐).
dozer_haul_limit_m: float | None = None
#: `None` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**.
@@ -456,6 +473,7 @@ NULLABLE_SETTING_KEYS = (
"rubble_base_thickness_m",
"topsoil_haul_distance_m",
"wood_chipping_volume_m3",
"dozer_haul_limit_m",
)
@@ -482,10 +500,25 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
for key in NULLABLE_SETTING_KEYS:
if key in body.model_fields_set:
values[key] = getattr(body, key)
dozer_limit = values.get("dozer_haul_limit_m")
free_haul = dict(EARTHWORK_HAUL_EQUIPMENT_LIMITS_M)["free_haul"] or 0.0
if dozer_limit is not None and dozer_limit <= free_haul:
# 조용히 기본값으로 돌리지 않는다 — 넣은 값이 안 쓰이는 줄 모른다.
return JSONResponse(
status_code=400,
content={
"status": "error",
"message": f"도쟈 한계거리는 종무대 {free_haul:g} m 보다 커야 합니다.",
},
)
if "concrete_placing_method" in values:
method = values["concrete_placing_method"]
# 「안 정함」으로 되돌릴 수 있어야 한다 — 빈 값이면 지운다(8-22 ② 와 같은 자리).
values["concrete_placing_method"] = method if method in CONCRETE_PLACING_METHODS else None
if "topsoil_target" in values:
# 기본(노면 + 절토)은 저장하지 않는다 — 「안 정함」과 같게 둬 법 문언이 선다.
target = values["topsoil_target"]
values["topsoil_target"] = target if target == "road_only" else None
if "conversion_factors_override" in values:
# 아는 갈래·양수만 남긴다. 사유는 값이 있을 때만 따라간다(계산에는 안 쓴다).
cleaned: dict[str, Any] = {}
@@ -520,6 +553,7 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
"rock_methods",
"material_supply",
"concrete_placing_method",
"topsoil_target",
"ancillary_counts",
# 고른 계수를 **기본값으로 되돌릴 길**이 있어야 한다 — 병합이면 못 지운다.
"conversion_factors_override",
+228 -1
View File
@@ -23,6 +23,7 @@ from uuid import UUID
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, Field
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B06_Section.B06_Section_Repository import get_cross_section_designs
@@ -194,6 +195,161 @@ async def get_material_summary(project_id: UUID) -> JSONResponse:
return JSONResponse(content=body)
@router.get("/{project_id}/quantity/structure-summary")
async def get_structure_summary(project_id: UUID) -> JSONResponse:
"""**구조물 집계표**(PLAN 2장) — 측점별 한 줄 · 종류별 표 · 칸마다 출처(자동·사용자·라이브러리).
⚠ 값을 셈하지 않음 — 정본 둘(`structures.json`·`pipe_points.json`)과 B06 관 연장을 읽기만.
"""
from B08_Quantity.B08_Quantity_Engine_StructureSummary import (
USER_CELLS_KEY,
build_summary,
pipe_lengths_from_designs,
)
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import template_of
from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file
try:
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
project_root = resolve_stored_project_path(stored_path)
revision, items = load_structures(project_root)
points = read_pipe_points_file(pipe_points_path_in(Path(project_root)))
except Exception:
logger.exception("B08 구조물 집계표 — 정본 읽기 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "구조물 정본을 읽지 못했습니다."},
)
types = structure_type_map()
embedded = project_templates(project_root)
templates = {type_id: template_of(type_id, embedded) or {} for type_id in types}
body = build_summary(
[item.model_dump() for item in items],
[point.as_dict() for point in points],
types,
templates,
pipe_lengths_from_designs(await _designs(project_id)),
quantity_settings(project_root).get(USER_CELLS_KEY) or {},
)
return JSONResponse(
content={"status": "success", "revision": revision, "project_id": str(project_id), **body}
)
async def _designs(project_id: UUID) -> list[dict[str, Any]]:
"""현재 노선의 저장된 횡단 설계. 못 읽으면 빈 목록 — 관 연장이 빈칸으로 섬(0 아님)."""
try:
context = await run_with_connection(get_workflow_route_context, project_id)
route_id = int((context or {}).get("route_id") or 0)
return await run_with_connection(get_cross_section_designs, route_id) if route_id else []
except Exception:
logger.exception("B08 구조물 집계표 — 횡단 설계 조회 실패: project_id=%s", project_id)
return []
class StructureSummaryEdit(BaseModel):
"""집계표 칸 하나 — 빈 값(null·"")은 그 칸을 지움."""
model_config = ConfigDict(extra="forbid")
id: str = Field(min_length=1, max_length=200)
key: str = Field(min_length=1, max_length=100)
value: float | str | None = None
class StructureSummaryEditRequest(BaseModel):
"""[저장] 한 번에 보내는 손 고침 — 읽어 간 구조물 판번호와 함께."""
model_config = ConfigDict(extra="forbid")
base_revision: int = Field(ge=0)
edits: list[StructureSummaryEdit] = Field(max_length=500)
def _summary_error(status: int, message: str) -> JSONResponse:
return JSONResponse(status_code=status, content={"status": "error", "message": message})
@router.put("/{project_id}/quantity/structure-summary")
async def put_structure_summary(
project_id: UUID, payload: StructureSummaryEditRequest
) -> JSONResponse:
"""집계표 손 고침을 **정본에 바로 씀**(브레인 판정) + 산출 조건에 「손댄 칸」 표.
⚠ 덮개층을 두지 않음 — `structures.json`·`pipe_points.json` 이 곧 도면·수량의 한 값(지침 5장).
⚠ 다 맞춰 본 뒤에 씀 — 한 칸이라도 틀리면 아무것도 안 씀(구조물 판번호가 어긋나도 409).
⛔ 놓기 칸(자리·길이·높이·관경)은 거절 — 시·종점과 한 벌이라 B05 몫.
"""
import asyncio
from B05_Profile.B05_Profile_Structures_Repository import (
StructureRevisionConflict,
save_structures,
)
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance
from B08_Quantity.B08_Quantity_Engine_StructureSummary import (
USER_CELLS_KEY,
apply_edits,
pipe_row_id,
prune_marks,
)
from common_util.common_util_drainage_pipes import pipe_points_path_in
from common_util.common_util_json import atomic_write_json
from common_util.common_util_project_settings import save_section
try:
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
project_root = resolve_stored_project_path(stored_path)
revision, items = load_structures(project_root)
path = pipe_points_path_in(Path(project_root))
document = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
except Exception:
logger.exception("B08 구조물 집계표 저장 — 정본 읽기 실패: project_id=%s", project_id)
return _summary_error(500, "구조물 정본을 읽지 못했습니다.")
if revision != payload.base_revision:
return _summary_error(409, "그 사이 구조물이 바뀌었습니다 — 표를 다시 불러와 고칠 것.")
structures = [item.model_dump() for item in items]
points = [point for point in document.get("points") or [] if isinstance(point, dict)]
settings = quantity_settings(project_root)
try:
changed, marks = apply_edits(
structures,
points,
structure_type_map(),
[edit.model_dump() for edit in payload.edits],
settings.get(USER_CELLS_KEY) or {},
)
instances = [StructureInstance.model_validate(item) for item in structures]
except (LookupError, ValueError) as exc:
return _summary_error(422, str(exc))
new_revision = revision
pipe_ids = {pipe_row_id(point) for point in points}
try:
if any(row_id not in pipe_ids for row_id in changed):
new_revision = await asyncio.to_thread(
save_structures, project_root, instances, base_revision=revision
)
if any(row_id in pipe_ids for row_id in changed):
# 관 지점 파일은 옵션만 바꿔 **그대로** 씀 — 노선 지문·좌표·다른 칸은 손대지 않음.
await asyncio.to_thread(atomic_write_json, path, {**document, "points": points})
except StructureRevisionConflict:
return _summary_error(409, "그 사이 구조물이 바뀌었습니다 — 표를 다시 불러와 고칠 것.")
except ValueError as exc:
return _summary_error(422, str(exc))
await asyncio.to_thread(
save_section,
project_root,
"quantity",
{USER_CELLS_KEY: prune_marks(marks, structures, points)},
replace_keys=[USER_CELLS_KEY],
)
return JSONResponse(
content={"status": "success", "revision": new_revision, "changed_rows": len(changed)}
)
async def project_haul_inputs(project_id: UUID) -> dict[str, Any]:
"""유토곡선(B06)이 받아야 할 **구조물 몫** — 채집석 공제 · 구조물 잔토.
@@ -249,10 +405,11 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
structures, names, skipped = _collect_structures(project_root)
settings = quantity_settings(project_root)
modes = await _section_modes(project_id)
unit_table = build_unit_table(
structures,
names,
await _section_modes(project_id),
modes,
await _ground_types(project_id),
settings.get("rubble_base_thickness_m"),
structure_formulas=settings.get("structure_formula_overrides"),
@@ -307,6 +464,8 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
structure_trench_water=settings.get("structure_trench_water"),
# 임목축적 등급 — 지장목제거 뿌리뽑기(제근 9-21)의 품 갈래. 안 넣으면 B09 가 후보를 보임.
stand_volume_class=settings.get("stand_volume_class"),
# 구조물도 양식 일위대가로 셀 장 — 그 구조물은 호표 `AX-ST` 줄 하나로(PLAN 6장 ②).
priced_sheets=_priced_sheets(project_root, unit_table, modes, settings),
)
handoff["summary"] = summarize(handoff)
handoff["skipped_structures"] = skipped
@@ -314,6 +473,74 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
return JSONResponse(content=handoff)
def _priced_sheets(
project_root: str,
unit_table: dict[str, Any],
section_modes: dict[float, str] | None,
settings: dict[str, Any],
) -> list[dict[str, Any]]:
"""양식 일위대가로 셀 구조물도 장 — 구조물도 탭과 **같은 접기**(`build_standard_sheets` →
`apply_templates`)로 세움. 인계와 내역 금액이 이 한 목록을 봄."""
from B08_Quantity.B08_Quantity_Engine_StructurePriceLink import priced_sheets
from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import apply_templates, template_of
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import ROWS_KEY
embedded = project_templates(project_root)
payload = build_standard_sheets(unit_table, section_modes)
apply_templates(payload, settings, embedded)
sheets = payload.get("sheets") or []
templates = {
type_id: template
for type_id in {str(sheet.get("type_id") or "") for sheet in sheets}
if (template := template_of(type_id, embedded))
}
return priced_sheets(sheets, templates, settings.get(ROWS_KEY))
async def structure_bill_prices(project_id: UUID, build: Any) -> dict[str, dict[str, Any]]:
"""B09 내역이 부름 — 구조물도 호표 금액. **B09 가 넘긴 단가표**로 B08 일위대가 엔진을 돌림.
⚠ 금액 정본은 B09 — 여기서 단가표를 따로 짓지 않음. 수동 단가(프로젝트 값)도 입력으로 씀.
⚠ 인계(`get_handoff`)와 **같은 전개**를 한 번 더 돎 — 두 요청이라 한 벌을 못 나눔.
"""
# ponytail: 인계와 전개를 두 번 돎 — 느려지면 인계 응답에 장 목록을 실어 한 번으로.
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import available_templates
from B08_Quantity.B08_Quantity_Engine_StructurePriceLink import price_sheets
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import MANUAL_KEY, ROWS_KEY, with_rows
from B09_Estimation.B09_Estimation_UnitPrice import find_variant_code
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
project_root = resolve_stored_project_path(stored_path)
structures, names, _skipped = _collect_structures(project_root)
settings = quantity_settings(project_root)
modes = await _section_modes(project_id)
unit_table = build_unit_table(
structures,
names,
modes,
await _ground_types(project_id),
settings.get("rubble_base_thickness_m"),
structure_formulas=settings.get("structure_formula_overrides"),
structure_templates=project_templates(project_root),
)
entries = _priced_sheets(project_root, unit_table, modes, settings)
if not entries:
return {}
row_edits = settings.get(ROWS_KEY) or {}
library = [
with_rows(template, row_edits.get(template.get("type_id")))
for template in available_templates(project_root)
]
return price_sheets(
entries,
build.book,
lambda code, value: find_variant_code(code, value, build),
library,
settings.get(MANUAL_KEY) or {},
)
def _pipe_table(
project_root: str,
pipe_lengths: list[dict[str, Any]],
@@ -323,9 +323,9 @@ async def put_structure_library_import(
payload: LibraryImportRequest,
session: dict[str, Any] = Depends(verify_session),
) -> JSONResponse:
"""고른 항목을 **프로젝트 작업본에 박고** 그 종류의 고친 식을 비움.
"""고른 항목을 **프로젝트 작업본에 박고** 그 종류의 고친 식·줄 조합·수동 단가를 비움.
⚠ 고친 식을 비우는 까닭 — 새 양식과 줄 차례가 안 맞을 수 있음. 묻는 것은 화면 몫.
⚠ 비우는 까닭 — 셋 다 줄 차례에 묶였는데 새 양식과 차례가 안 맞을 수 있음. 묻는 것은 화면 몫.
"""
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import (
find_item,
@@ -333,6 +333,7 @@ async def put_structure_library_import(
tier_dirs,
)
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import OVERRIDES_KEY
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import MANUAL_KEY, ROWS_KEY
from common_util.common_util_project_settings import quantity_settings, save_section
project_root = await _project_root(project_id)
@@ -346,23 +347,22 @@ async def put_structure_library_import(
content={"status": "error", "message": "가져올 양식 항목을 찾지 못했습니다."},
)
await asyncio.to_thread(import_item, project_root, item, payload.tier)
current = quantity_settings(project_root).get(OVERRIDES_KEY) or {}
cleared = len(current.get(payload.type_id) or {})
if cleared:
rest = {key: rows for key, rows in current.items() if key != payload.type_id}
await asyncio.to_thread(
save_section,
project_root,
"quantity",
{OVERRIDES_KEY: rest},
replace_keys=[OVERRIDES_KEY],
)
settings = quantity_settings(project_root)
current = {key: settings.get(key) or {} for key in (OVERRIDES_KEY, ROWS_KEY, MANUAL_KEY)}
touched = [key for key, value in current.items() if payload.type_id in value]
if touched:
rest = {
key: {k: v for k, v in current[key].items() if k != payload.type_id} for key in touched
}
await asyncio.to_thread(save_section, project_root, "quantity", rest, replace_keys=touched)
return JSONResponse(
content={
"status": "success",
"code": payload.code,
"imported_from": payload.tier,
"cleared_formulas": cleared,
"cleared_formulas": len(current[OVERRIDES_KEY].get(payload.type_id) or {}),
"cleared_unit_price_rows": payload.type_id in current[ROWS_KEY],
"cleared_manual_prices": len(current[MANUAL_KEY].get(payload.type_id) or {}),
}
)
@@ -404,6 +404,7 @@ async def put_structure_library_personal(
save_personal,
)
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import OVERRIDES_KEY, template_of
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import ROWS_KEY
from common_util.common_util_project_settings import quantity_settings
folder = _personal_dir(session)
@@ -421,8 +422,11 @@ async def put_structure_library_personal(
status_code=404,
content={"status": "error", "message": "양식이 있는 구조물도 장을 찾지 못했습니다."},
)
overrides = (quantity_settings(project_root).get(OVERRIDES_KEY) or {}).get(type_id)
code = await asyncio.to_thread(save_personal, folder, template, overrides)
settings = quantity_settings(project_root)
overrides = (settings.get(OVERRIDES_KEY) or {}).get(type_id)
# ⛔ 수동 단가(`MANUAL_KEY`)는 안 넘김 — 프로젝트의 값(브레인 판정).
rows = (settings.get(ROWS_KEY) or {}).get(type_id)
code = await asyncio.to_thread(save_personal, folder, template, overrides, rows)
return JSONResponse(content={"status": "success", "code": code, "edited": len(overrides or {})})
@@ -447,51 +451,193 @@ async def _price_build(project_id: UUID) -> Any:
return await _build_for(project_id)
async def _sheet_template(
project_id: UUID, project_root: str, sheet_key: str
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
"""장 하나와 그 양식(프로젝트에 박힌 것 → 프로그램 기본)."""
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import project_templates
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import template_of
sheets = (await _sheets_of(project_id, project_root)).get("sheets") or []
picked = next((s for s in sheets if s.get("key") == sheet_key), None)
if picked is None:
return None, None
return picked, template_of(str(picked.get("type_id") or ""), project_templates(project_root))
def _no_sheet() -> JSONResponse:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "그 구조물도 장을 찾지 못했습니다."},
)
async def _price_build_or_error(project_id: UUID) -> tuple[Any, JSONResponse | None]:
try:
return await _price_build(project_id), None
except Exception:
logger.exception("B08 구조물도 일위대가 — 단가표 조립 실패: project_id=%s", project_id)
return None, JSONResponse(
status_code=500,
content={"status": "error", "message": "단가표를 조립하지 못했습니다."},
)
@router.get("/{project_id}/quantity/structure-sheets/unit-price")
async def get_structure_unit_price(project_id: UUID, sheet_key: str) -> JSONResponse:
"""장 하나의 하단 **일위대가 표**(미리보기) — 줄 조합은 양식, 단가는 B09 단가표.
"""장 하나의 하단 **일위대가 표**(미리보기) — 줄 조합은 양식(+고친 것), 단가는 B09 단가표.
⚠ 장 조회와 창구를 나눔 — 단가표 첫 조립이 십여 초라 장 조회를 늦추지 않게.
⚠ 값을 여기서 정본으로 적지 않음 — 보이기만(내역 금액은 B09 가 셈).
⚠ `editor` — 화면이 고칠 줄 조합·양식 줄·수동 단가. 값(금액)은 늘 이 서버 계산만 씀.
"""
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import (
available_templates,
project_templates,
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import available_templates
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import (
MANUAL_KEY,
ROWS_KEY,
unit_price_table,
with_rows,
)
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import template_of
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import unit_price_table
from B09_Estimation.B09_Estimation_UnitPrice import find_variant_code
from common_util.common_util_project_settings import quantity_settings
project_root = await _project_root(project_id)
if project_root is None:
return _not_found()
sheets = (await _sheets_of(project_id, project_root)).get("sheets") or []
picked = next((s for s in sheets if s.get("key") == sheet_key), None)
picked, template = await _sheet_template(project_id, project_root, sheet_key)
if picked is None:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "그 구조물도 장을 찾지 못했습니다."},
)
template = template_of(str(picked.get("type_id") or ""), project_templates(project_root))
if not template or not template.get("unit_price"):
return _no_sheet()
if not template:
return JSONResponse(content={"status": "success", "unit_price": None})
try:
build = await _price_build(project_id)
except Exception:
logger.exception("B08 구조물도 일위대가 — 단가표 조립 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "단가표를 조립하지 못했습니다."},
)
build, error = await _price_build_or_error(project_id)
if error:
return error
settings = quantity_settings(project_root)
row_edits = settings.get(ROWS_KEY) or {}
type_id = str(template.get("type_id") or "")
manual = (settings.get(MANUAL_KEY) or {}).get(type_id) or {}
effective = with_rows(template, row_edits.get(type_id))
# 하위 양식도 그 종류에서 고친 줄 조합으로 풂 — 수동 단가는 맨 윗 표만.
library = [
with_rows(t, row_edits.get(t.get("type_id"))) for t in available_templates(project_root)
]
table = await asyncio.to_thread(
unit_price_table,
template,
effective,
picked,
build.book,
lambda code, value: find_variant_code(code, value, build),
available_templates(project_root),
library,
manual,
)
return JSONResponse(content={"status": "success", "unit_price": table})
editor = {
"rows": effective["unit_price"]["rows"],
"default_rows": (template.get("unit_price") or {}).get("rows") or [],
"edited": type_id in row_edits,
"manual_prices": manual,
}
return JSONResponse(content={"status": "success", "unit_price": table, "editor": editor})
class UnitPriceRowEdit(BaseModel):
"""일위대가 줄 하나 — 수량은 원단위 줄(`from_row`) 또는 박힌 값, 코드는 고르개가 채움."""
model_config = ConfigDict(extra="forbid")
seq: int = Field(ge=1)
name: str | None = Field(default=None, max_length=200)
spec: str | None = Field(default=None, max_length=200)
unit: str | None = Field(default=None, max_length=20)
from_row: int | None = Field(default=None, ge=1)
quantity: float | None = None
ref_code: str | None = Field(default=None, max_length=100)
work_item_code: str | None = Field(default=None, max_length=100)
variant_from: str | None = Field(default=None, max_length=50)
sub_vars: dict[str, float | str] | None = None
class ManualPrice(BaseModel):
"""수동 단가 — 단위당 3분할과 출처. 넣은 날짜는 서버가 붙임."""
model_config = ConfigDict(extra="forbid")
material: float = Field(default=0, ge=0)
labor: float = Field(default=0, ge=0)
expense: float = Field(default=0, ge=0)
source: str = Field(default="", max_length=200)
class UnitPriceEditRequest(BaseModel):
"""한 화면에서 고친 일위대가 — **저장 자리는 둘**(줄 조합 = 양식+프로젝트 · 단가 = 프로젝트)."""
model_config = ConfigDict(extra="forbid")
sheet_key: str
rows: list[UnitPriceRowEdit] = Field(max_length=100)
manual_prices: dict[str, ManualPrice] = Field(default_factory=dict)
@router.put("/{project_id}/quantity/structure-sheets/unit-price")
async def put_structure_unit_price(project_id: UUID, payload: UnitPriceEditRequest) -> JSONResponse:
"""고친 줄 조합·수동 단가를 산출 조건에 저장. 금액은 화면이 다시 조회해 서버 계산으로 받음."""
from datetime import date
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import (
MANUAL_KEY,
ROWS_KEY,
save_manual,
save_rows,
)
from common_util.common_util_project_settings import quantity_settings, save_section
rows = [row.model_dump(exclude_none=True) for row in payload.rows]
if len({row["seq"] for row in rows}) != len(rows):
return JSONResponse(
status_code=422,
content={"status": "error", "message": "일위대가 줄 차례가 겹칩니다."},
)
project_root = await _project_root(project_id)
if project_root is None:
return _not_found()
picked, template = await _sheet_template(project_id, project_root, payload.sheet_key)
if picked is None or template is None:
return _no_sheet()
type_id = str(template.get("type_id") or "")
settings = quantity_settings(project_root)
merged_rows, changed = save_rows(settings.get(ROWS_KEY) or {}, type_id, template, rows)
prices = {seq: price.model_dump() for seq, price in payload.manual_prices.items()}
merged_manual = save_manual(
settings.get(MANUAL_KEY) or {}, type_id, rows, prices, date.today().isoformat()
)
await asyncio.to_thread(
save_section,
project_root,
"quantity",
{ROWS_KEY: merged_rows, MANUAL_KEY: merged_manual},
replace_keys=[ROWS_KEY, MANUAL_KEY],
)
return JSONResponse(
content={
"status": "success",
"changed_rows": changed,
"edited": type_id in merged_rows,
"manual_prices": len(merged_manual.get(type_id) or {}),
}
)
@router.get("/{project_id}/quantity/structure-sheets/price-search")
async def get_structure_price_search(
project_id: UUID, q: str, kind: Literal["work", "resource"] = "work"
) -> JSONResponse:
"""고르개 — 이 프로젝트 단가표에서 품셈(일위대가)·자원(자재·노임·중기)을 낱말로 찾음."""
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import search_titles
build, error = await _price_build_or_error(project_id)
if error:
return error
items = await asyncio.to_thread(search_titles, build.book, q[:100], kind)
return JSONResponse(content={"status": "success", "items": items})
@router.get("/{project_id}/quantity/structure-sheets")
@@ -92,16 +92,22 @@ export interface QuantitySettings {
structure_trench_water?: string | null;
/** 표토 운반거리(m) — 별표2 가 요구하는 운반·적치의 밑수. 비면 그 줄이 막힌다. */
topsoil_haul_distance_m?: number | null;
/** 도쟈 한계거리(m) — `null`·없음이면 기본값(서버 정본 60 m)이 선다. */
dozer_haul_limit_m?: number | null;
/** 부대시설 개소 — `{항목키: 개소}`. ⚠ 산식으로 만들지 않는다(확정 13). */
ancillary_counts?: Record<string, number | null>;
/** 임목축적 등급 — `"소림"`·`"중림"`·`"밀림"`(품셈 9-21 [주]①). 본수가 아니라 축적이다. */
stand_volume_class?: string | null;
/** 표토제거 대상 — 없음이면 기본(노면 + 절토) · `"road_only"` 는 노면만. */
topsoil_target?: string | null;
/** 규준틀 개소당 재료 — `{자재명: 수량}`. 비우면 제안값(실무 관측)이 선다. */
frame_material?: Record<string, number | null>;
/** 임목파쇄 — 기본 꺼짐(확정 5차 5번). 켜야 줄이 선다. */
wood_chipping_enabled?: boolean | null;
/** 파쇄 부피(㎥) — 켜도 이 값이 없으면 줄만 서고 사유가 남는다. */
wood_chipping_volume_m3?: number | null;
/** 노체다짐 — 기본 꺼짐(9-16-2 [주]⑤ 조건부). 켜면 토공집계에 별도 줄. */
subgrade_compaction_enabled?: boolean | null;
}
/** 갈래 하나의 「무엇을 골랐나」. 서버 `earthwork_conversion_choices` 와 짝이다. */
@@ -141,6 +147,15 @@ export interface EarthworkTable {
conversion_factor_choices?: Record<string, ConversionFactorChoice>;
/** 품셈 암종별 범위(안내용). 정의처가 서버라 내려받아 보인다. */
conversion_factor_pumsem_ranges?: PumsemRange[];
/** 도쟈 한계거리 — 지금 값·기본값·근거(서버가 정본). 종무대 20 m 는 규정이라 값만 보인다. */
haul_limit_choice?: {
value: number;
default: number;
chosen: boolean;
basis: string;
free_haul_m: number;
free_haul_basis: string;
};
/** 근거 사전 — ⚠ **개발환경에서만** 실려 온다. 운영에서는 칸 자체가 없다. */
provenance?: ProvenancePayload;
}
+83 -1
View File
@@ -39,6 +39,7 @@ import {
type MaterialResponse,
} from "./B08_Quantity_UI_MaterialGrid";
import { renderStructureSheets } from "./B08_Quantity_UI_StructureSheet";
import { renderStructureSummary } from "./B08_Quantity_UI_StructureSummary";
/** locale 헬퍼 */
function L(key: keyof typeof ui_locales): string {
@@ -101,14 +102,19 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr
spoil_site_distance_m: draft.spoil_site_distance_m,
structure_trench_water: draft.structure_trench_water,
topsoil_haul_distance_m: draft.topsoil_haul_distance_m,
// `""` 는 기본(노면 + 절토)으로 되돌림 — 서버가 None 으로 둔다.
topsoil_target: draft.topsoil_target,
stand_volume_class: draft.stand_volume_class,
frame_material: draft.frame_material,
wood_chipping_enabled: draft.wood_chipping_enabled,
wood_chipping_volume_m3: draft.wood_chipping_volume_m3,
subgrade_compaction_enabled: draft.subgrade_compaction_enabled,
// 개소는 **통째로** 보낸다 — 지운 항목까지 그대로 가야 되돌릴 길이 있다.
ancillary_counts: draft.ancillary_counts,
// 토량환산계수 — 고른 갈래만 담긴다. 빈 dict 는 「전부 기본값으로 되돌림」이다.
conversion_factors_override: conversionOverridePayload(draft.conversion_factors),
// 도쟈 한계거리 — `null` 도 보낸다(기본값으로 되돌리는 길).
dozer_haul_limit_m: draft.dozer_haul_limit_m,
}),
},
);
@@ -281,6 +287,8 @@ interface DraftSettings {
structure_trench_water: string;
// 표토 운반거리(m) — 별표2 가 요구하는 운반·적치의 밑수. `null` 은 「안 정함」.
topsoil_haul_distance_m: number | null;
// 표토제거 대상 — `""` 기본(노면 + 절토, 별표2 문언) · "road_only" 노면만(설계자 선택).
topsoil_target: string;
// 부대시설 개소 — `{항목키: 개소}`. ⚠ 산식으로 만들지 않는다(확정 13).
ancillary_counts: Record<string, number | null>;
// 임목축적 등급 — "소림"·"중림"·"밀림". ⚠ 본수가 아니라 축적이다(품셈 9-21 [주]①).
@@ -290,10 +298,14 @@ interface DraftSettings {
// 임목파쇄 — **기본 꺼짐**(확정 5차 5번). 켜야 줄이 선다. 근주이식은 칸 자체가 없다.
wood_chipping_enabled: boolean;
wood_chipping_volume_m3: number | null;
// 노체다짐 — **기본 꺼짐**(9-16-2 [주]⑤ 조건부). 켜면 토공집계에 별도 줄(수량 = 성토량).
subgrade_compaction_enabled: boolean;
// 자재별 관급/사급 — 표 안에서 줄마다 고른 값.
material_supply: Record<string, SupplyChoice>;
// 갈래별 토량환산계수(다짐) — `compacted` 가 `null` 이면 「안 고름」이라 기본값이 선다.
conversion_factors: Record<string, FactorDraft>;
// 도쟈 한계거리(m) — `null` 은 기본값(서버 정본 60 m)이 선다.
dozer_haul_limit_m: number | null;
dirty: boolean;
}
@@ -402,6 +414,30 @@ function buildQuantitySidePanel(
);
if (factorFields) panel.append(factorFields);
// ── 운반장비 거리 경계 — 도쟈 한계거리는 설계 조건이라 칸으로(2026-09-13 판정) ──
// ⚠ 기본값과 근거를 **서버가 준 그대로** 옆에 보인다 — 「이 값이 어디서 왔나」가 화면에 있어야 함.
const haul = table?.haul_limit_choice;
if (haul) {
panel.append(field(L("B08_Quantity_Side_HaulLimits"), ""));
const dozer = optionalNumberField(
L("B08_Quantity_Side_DozerLimit_Label"),
draft.dozer_haul_limit_m,
"5",
(value) => {
draft.dozer_haul_limit_m = value;
draft.dirty = true;
},
);
const input = dozer.querySelector("input");
if (input) input.placeholder = String(haul.default);
panel.append(dozer);
panel.append(hintRow(`${L("B08_Quantity_Factor_Default")} ${haul.default} m — ${haul.basis}`));
panel.append(
hintRow(`${L("B08_Quantity_Side_FreeHaul")} ${haul.free_haul_m} m — ${haul.free_haul_basis}`),
);
panel.append(hintRow(L("B08_Quantity_Side_HaulLimits_Reach")));
}
// ── 지반 구성비 — 갈래 수는 프로젝트 세트가 정한다(코드에 안 박음, PLAN 8-13) ──
const classes = [...(table?.summary?.rock_classes ?? [])];
// ⚠ 비율을 아직 안 넣었으면 집계가 **「암」 한 줄**로 나온다(갈래로 안 갈림). 그 줄에도
@@ -459,10 +495,26 @@ function buildQuantitySidePanel(
}
}
// ── 표토 두께 — 정해야 준비공 표토제거 줄이 선다(품셈 9-15 [주]② 의 「T : 표토두께(m)」) ──
// ── 표토 두께 — 표토 운반 부피(제거 ㎡ × T)에 씀 · 제거 줄은 ㎡ 라 안 곱함(2026-09-13 「실무대로」) ──
// ⚠ 2026-09-08 ㉘ 자기 감사: 서버·엔진은 이 값을 받고 있었는데 **화면에 넣을 칸이 없었다.**
// 「죽은 칸」(넣어도 안 쓰임)의 반대 짝이다 — 쓰이는데 넣을 데가 없던 자리.
panel.append(field(L("B08_Quantity_Side_Topsoil"), ""));
// 대상 — 기본은 법 문언(노면 + 절토). 영월 한 건이 노면만이라 3배 차이 → 설계자가 고름(2026-09-14).
panel.append(
selectField(
L("B08_Quantity_Side_TopsoilTarget_Label"),
draft.topsoil_target,
[
{ value: "", label: L("B08_Quantity_TopsoilTarget_RoadAndCut") },
{ value: "road_only", label: L("B08_Quantity_TopsoilTarget_RoadOnly") },
],
(value) => {
draft.topsoil_target = value;
draft.dirty = true;
},
),
);
panel.append(hintRow(L("B08_Quantity_Side_TopsoilTarget_Hint")));
panel.append(
optionalNumberField(
L("B08_Quantity_Side_Topsoil_Label"),
@@ -492,6 +544,10 @@ function buildQuantitySidePanel(
),
);
panel.append(hintRow(L("B08_Quantity_Side_BenchCut_Hint")));
// 층따기 관행값 — **제안값**이지 확정이 아니다(원본 그림 확인 전 · 10장 판정). 칸에 넣지 않고 보이기만.
const benchSuggest = hintRow(L("B08_Quantity_Side_BenchCut_Suggest"));
benchSuggest.classList.add("b08-quantity__hint--warn");
panel.append(benchSuggest);
panel.append(
optionalNumberField(
L("B08_Quantity_Side_Rubble_Label"),
@@ -629,6 +685,24 @@ function buildQuantitySidePanel(
);
panel.append(hintRow(L("B08_Quantity_Side_Chipping_Hint")));
// ── 노체다짐 — ⚠ **기본 꺼짐**(2026-09-13 판정). 9-16-2 [주]⑤ 조건부라 켜야 토공집계에 줄이 선다.
panel.append(field(L("B08_Quantity_Side_SubgradeCompaction"), ""));
panel.append(
selectField(
L("B08_Quantity_SubgradeCompaction_Label"),
draft.subgrade_compaction_enabled ? "on" : "",
[
{ value: "", label: L("B08_Quantity_Chipping_Off") },
{ value: "on", label: L("B08_Quantity_Chipping_On") },
],
(value) => {
draft.subgrade_compaction_enabled = value === "on";
draft.dirty = true;
},
),
);
panel.append(hintRow(L("B08_Quantity_Side_SubgradeCompaction_Hint")));
// ── 콘크리트 타설 방식 — ⚠ **금액에 바로 걸리는 값**이라 잠정임을 조용히 두지 않는다 ──
panel.append(field(L("B08_Quantity_Side_Placing"), ""));
panel.append(
@@ -831,6 +905,11 @@ function buildQuantityBody(
},
// 구조물도 — 제원 조합 하나 = 한 장(PLAN 3장). 스스로 받아 오므로 표를 넘기지 않음.
// ⚠ 탭 차례 바꾸기·원단위 탭 흡수는 PLAN 5장 몫 — 여기서는 원단위 앞에 붙이기만 함.
// 구조물 집계표 — 측점별 한 줄(PLAN 2장). 구조물도의 앞 사슬이라 그 바로 앞에 둠.
{
label: L("B08_Quantity_Tab_StructureSummary"),
build: () => renderStructureSummary(projectId),
},
{ label: L("B08_Quantity_Tab_StructureSheet"), build: () => renderStructureSheets(projectId) },
{
label: L("B08_Quantity_Tab_UnitQuantity"),
@@ -916,10 +995,12 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
spoil_site_distance_m: (stored.spoil_site_distance_m as number | null) ?? null,
structure_trench_water: (stored.structure_trench_water as string) ?? "",
topsoil_haul_distance_m: (stored.topsoil_haul_distance_m as number | null) ?? null,
topsoil_target: (stored.topsoil_target as string) ?? "",
stand_volume_class: (stored.stand_volume_class as string) ?? "",
frame_material: { ...((stored.frame_material ?? {}) as Record<string, number | null>) },
wood_chipping_enabled: Boolean(stored.wood_chipping_enabled),
wood_chipping_volume_m3: (stored.wood_chipping_volume_m3 as number | null) ?? null,
subgrade_compaction_enabled: Boolean(stored.subgrade_compaction_enabled),
ancillary_counts: {
...((stored.ancillary_counts ?? {}) as Record<string, number | null>),
},
@@ -934,6 +1015,7 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
},
]),
),
dozer_haul_limit_m: (stored.dozer_haul_limit_m as number | null) ?? null,
dirty: false,
};
const reload = (): void => {
+12 -1
View File
@@ -124,6 +124,15 @@ const CSS = `
.b08-sheet__formula select.is-changed { outline: 1px solid var(--color-accent, #6c8ebf); }
.b08-sheet__formula .b08-sheet__digits { flex: 0 0 3.2rem; min-width: 3.2rem; }
.b08-sheet__actions { display: flex; gap: 8px; align-items: center; }
/* 수동 단가 — 빨간 테두리 + 「미확정 N건」 배지(PLAN 확정 ⑦). */
.b08-unit__manual { outline: 2px solid var(--color-danger, #d9534f); outline-offset: -2px; }
.b08-unit__badge { margin-left: 8px; padding: 0 6px; border-radius: 8px; color: #fff;
background: var(--color-danger, #d9534f); font-size: 12px; white-space: nowrap; }
.b08-unit-edit { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
.b08-unit-edit td { white-space: nowrap; }
.b08-unit-edit__picker { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
.b08-unit-edit__results { display: flex; flex-direction: column; align-items: flex-start;
gap: 2px; width: 100%; max-height: 14rem; overflow: auto; }
@media (max-width: 900px) {
.b08-sheet { flex-direction: column; }
.b08-sheet__aside { flex-basis: auto; width: 100%; }
@@ -430,7 +439,9 @@ export function renderStructureSheets(projectId: string | null): HTMLElement {
},
)
: null;
const unitPrice = sheet.library_item ? unitPriceSection(projectId, sheet.key) : null;
const unitPrice = sheet.library_item
? unitPriceSection(projectId, sheet.key, sheet.rows)
: null;
pane.replaceChildren(sheetBody(sheet, editor, unitPrice), aside);
};
sheets.forEach((sheet, index) => {
@@ -1,14 +1,19 @@
/* =============================================================================
* B08_Quantity_UI_StructureSheet_UnitPrice.ts
* 구조물도 장 아래 **일위대가 표**(미리보기) — PLAN 3장 하단 ①②.
* 구조물도 장 아래 **일위대가 표**(미리보기) — PLAN 3장 하단 ①②③.
*
* ⚠ 값을 셈하지 않음 — 서버(`…/structure-sheets/unit-price`)가 B09 단가표로 낸 금액을 적기만.
* ⚠ 장 조회와 따로 받음 — 단가표 첫 조립이 십여 초라 표가 늦게 차도 위 수량표는 먼저 보임.
* ⚠ 막힌 줄은 0 이 아니라 까닭을 적고, 하나라도 있으면 합계 앞에 「미완」.
* ⚠ 수동 단가 줄은 금액 칸 빨간 테두리 + 머리 「미확정 N건」 배지(PLAN 확정 ⑦).
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { el, num } from "./B08_Quantity_UI_StructureSheet_Formula";
import {
unitPriceEditor,
type UnitPriceEditorData,
} from "./B08_Quantity_UI_StructureSheet_UnitPriceEdit";
interface UnitPriceRow {
seq: number;
@@ -23,6 +28,9 @@ interface UnitPriceRow {
labor?: number;
expense?: number;
total?: number;
manual?: boolean;
manual_source?: string;
manual_entered_at?: string;
}
interface UnitPriceTable {
@@ -36,6 +44,7 @@ interface UnitPriceTable {
total: number;
blocked: number;
complete: boolean;
unconfirmed: number;
}
/** 금액 칸 — 0.1원 자리까지(금액란 규칙). 못 푼 줄은 빈칸. */
@@ -43,13 +52,17 @@ function won(value: number | undefined): string {
return value === undefined ? "" : num(value, 1);
}
/** 장 아래 일위대가 칸 — 받는 동안 안내를 두고, 오면 표로 갈음. */
export function unitPriceSection(projectId: string, sheetKey: string): HTMLElement {
/** 장 아래 일위대가 칸 — 받는 동안 안내를 두고, 오면 표로 갈음. 저장 뒤엔 다시 받음. */
export function unitPriceSection(
projectId: string,
sheetKey: string,
sheetRows: { no: number; name: string; unit: string }[],
): HTMLElement {
const wrap = el("div", "b08-grid");
wrap.append(
el("p", "b08-grid__caption", "일위대가(미리보기) 불러오는 중… 단가표 첫 조립은 십여 초"),
);
void (async () => {
const load = async (): Promise<void> => {
wrap.replaceChildren(
el("p", "b08-grid__caption", "일위대가(미리보기) 불러오는 중… 단가표 첫 조립은 십여 초"),
);
try {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets/unit-price?sheet_key=${encodeURIComponent(sheetKey)}`,
@@ -57,15 +70,38 @@ export function unitPriceSection(projectId: string, sheetKey: string): HTMLEleme
);
const payload = (await response.json().catch(() => ({}))) as {
unit_price?: UnitPriceTable | null;
editor?: UnitPriceEditorData;
message?: string;
};
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
const table = payload.unit_price;
if (!table) {
const editor = payload.editor;
if (!table || !editor) {
wrap.replaceChildren(el("p", "b08-grid__caption", "이 양식에는 일위대가 줄이 아직 없음"));
return;
}
wrap.replaceChildren(...render(table));
const open = el("button", "", editor.edited ? "줄 고치기 (고친 조합)" : "줄 고치기");
open.type = "button";
open.style.alignSelf = "flex-start";
const view = table.rows.length
? render(table)
: [el("p", "b08-grid__caption", "일위대가 줄이 아직 없음 — [줄 고치기]로 더함")];
open.addEventListener("click", () => {
open.disabled = true;
const panel = unitPriceEditor({
projectId,
sheetKey,
sheetRows,
data: editor,
onSaved: () => void load(),
onClose: () => {
panel.remove();
open.disabled = false;
},
});
wrap.append(panel);
});
wrap.replaceChildren(...view, open);
} catch (error) {
wrap.replaceChildren(
el(
@@ -75,7 +111,8 @@ export function unitPriceSection(projectId: string, sheetKey: string): HTMLEleme
),
);
}
})();
};
void load();
return wrap;
}
@@ -88,6 +125,9 @@ function render(table: UnitPriceTable): HTMLElement[] {
"b08-sheet__head",
`일위대가 ${table.code} · ${table.unit}당 ${total} (미리보기 — 내역 금액은 원가계산이 셈)`,
);
if (table.unconfirmed) {
head.append(el("span", "b08-unit__badge", `미확정 ${table.unconfirmed}건`));
}
const scroller = el("div", "b08-grid__scroll");
const grid = el("table", "b08-grid__table b08-grid__table--summary");
const headRow = document.createElement("tr");
@@ -109,19 +149,24 @@ function render(table: UnitPriceTable): HTMLElement[] {
const tbody = document.createElement("tbody");
for (const row of table.rows) {
const tr = document.createElement("tr");
const note = row.skipped ? `안 섬 — ${row.reason}` : row.reason ? `⚠ ${row.reason}` : "";
const manual = row.manual
? `수동 단가 — ${row.manual_source || "출처 없음"} · ${row.manual_entered_at ?? ""}`
: "";
const note = row.skipped ? `안 섬 — ${row.reason}` : row.reason ? `⚠ ${row.reason}` : manual;
// 단가표 이름에 갈래가 이미 들었거나 규격 칸이 코드 자체면 겹쳐 적지 않음.
const spec =
row.spec && !row.name.includes(row.spec) && !row.ref_code.includes(row.spec) ? row.spec : "";
const money = [row.material, row.labor, row.expense, row.total].map((value) => {
const cell = el("td", row.manual ? "b08-unit__manual" : "", won(value));
if (row.manual) cell.title = manual;
return cell;
});
tr.append(
el("td", "", spec ? `${row.name} (${spec})` : row.name),
el("td", "", row.ref_code),
el("td", "", row.quantity === null ? "" : num(row.quantity, 3)),
el("td", "", row.unit),
el("td", "", won(row.material)),
el("td", "", won(row.labor)),
el("td", "", won(row.expense)),
el("td", "", won(row.total)),
...money,
el("td", "", note),
);
tbody.append(tr);
@@ -0,0 +1,343 @@
/* =============================================================================
* B08_Quantity_UI_StructureSheet_UnitPriceEdit.ts
* 구조물도 일위대가 **줄 고치기** — 줄 더하기·빼기 · 고르개(품셈·자원 찾기) · 수동 단가. PLAN 3장 ③.
*
* ⚠ 저장 자리는 둘(브레인 판정) — 줄 조합은 양식+프로젝트, 수동 단가는 프로젝트만. 한 번의 [저장]이
* 둘을 함께 보내고 서버가 갈라 적음. [내 라이브러리에 저장]은 줄 조합만 실음.
* ⚠ 금액은 여기서 셈하지 않음 — 저장 뒤 표를 다시 받아 서버 계산(B09 단가표)으로 그림.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { el } from "./B08_Quantity_UI_StructureSheet_Formula";
export interface UnitPriceSpecRow {
seq: number;
name?: string;
spec?: string;
unit?: string;
from_row?: number;
quantity?: number;
ref_code?: string;
work_item_code?: string;
variant_from?: string;
sub_vars?: Record<string, number | string>;
}
export interface ManualPrice {
material: number;
labor: number;
expense: number;
source: string;
entered_at?: string;
}
export interface UnitPriceEditorData {
rows: UnitPriceSpecRow[];
default_rows: UnitPriceSpecRow[];
edited: boolean;
manual_prices: Record<string, ManualPrice>;
}
interface SearchItem {
code: string;
name: string;
spec: string;
unit: string;
price: number | null;
}
interface EditorOptions {
projectId: string;
sheetKey: string;
sheetRows: { no: number; name: string; unit: string }[];
data: UnitPriceEditorData;
onSaved: () => void;
onClose: () => void;
}
const MONEY: [keyof Omit<ManualPrice, "source" | "entered_at">, string][] = [
["material", "재료비"],
["labor", "노무비"],
["expense", "경비"],
];
function sheetUrl(projectId: string, tail: string): string {
return `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets${tail}`;
}
function input(value: string | number | undefined, width: string, type = "text"): HTMLInputElement {
const node = document.createElement("input");
node.type = type;
node.value = value === undefined ? "" : String(value);
node.style.width = width;
return node;
}
function codeText(row: UnitPriceSpecRow): string {
if (row.ref_code) return row.ref_code;
if (row.work_item_code) {
return row.variant_from
? `${row.work_item_code} (갈래: ${row.variant_from})`
: row.work_item_code;
}
return "코드 없음";
}
/** 줄 고치기 칸 — 로컬에서만 고치다 [저장]에 서버로. */
export function unitPriceEditor(options: EditorOptions): HTMLElement {
const { projectId, sheetKey, sheetRows, data, onSaved, onClose } = options;
let rows: UnitPriceSpecRow[] = structuredClone(data.rows);
const manual: Record<string, ManualPrice> = structuredClone(data.manual_prices);
let picking: number | null = null;
const wrap = el("div", "b08-unit-edit");
const tbody = document.createElement("tbody");
const picker = el("div", "b08-unit-edit__picker");
const status = el("span", "b08-grid__caption");
const render = (): void => {
tbody.replaceChildren(...rows.map((row, index) => rowOf(row, index)));
picker.hidden = picking === null;
};
const rowOf = (row: UnitPriceSpecRow, index: number): HTMLTableRowElement => {
const tr = document.createElement("tr");
const name = input(row.name, "9rem");
name.addEventListener("input", () => (row.name = name.value || undefined));
// 수량 — 원단위 줄을 따르거나 박힌 값.
const source = document.createElement("select");
source.append(new Option("박힌 값", ""));
for (const line of sheetRows) {
source.append(new Option(`원단위 ${line.no}. ${line.name} (${line.unit})`, String(line.no)));
}
source.value = row.from_row ? String(row.from_row) : "";
const fixed = input(row.quantity, "5rem", "number");
fixed.step = "any";
fixed.disabled = Boolean(row.from_row);
source.addEventListener("change", () => {
row.from_row = source.value ? Number(source.value) : undefined;
if (row.from_row) delete row.quantity;
fixed.disabled = Boolean(row.from_row);
});
fixed.addEventListener("input", () => {
row.quantity = fixed.value === "" ? undefined : Number(fixed.value);
});
const quantity = el("td", "");
quantity.append(source, fixed);
const unit = input(row.unit, "3rem");
unit.addEventListener("input", () => (row.unit = unit.value || undefined));
const code = el("td", "", codeText(row));
const find = el("button", "", "찾기");
find.type = "button";
find.title = "품셈·자원을 찾아 이 줄의 코드로 넣음";
find.addEventListener("click", () => {
picking = index;
render();
picker.querySelector("input")?.focus();
});
code.append(" ", find);
// 수동 단가 — 넣으면 빨간 테두리 · 표 머리에 「미확정」으로 셈. 프로젝트에만 저장됨.
const price = el("td", "");
const key = String(row.seq);
const on = document.createElement("input");
on.type = "checkbox";
on.checked = key in manual;
on.title = "수동 단가 — 이 프로젝트에만 저장(라이브러리로 안 감)";
price.append(on, " 수동");
const fields = MONEY.map(([field, label]) => {
const box = input(manual[key]?.[field], "5.5rem", "number");
box.min = "0";
box.step = "any";
box.placeholder = label;
box.title = `${label} 단가(단위당)`;
box.addEventListener("input", () => {
if (manual[key]) manual[key][field] = Number(box.value || 0);
});
return box;
});
const origin = input(manual[key]?.source, "7rem");
origin.placeholder = "출처";
origin.addEventListener("input", () => {
if (manual[key]) manual[key].source = origin.value;
});
const sync = (): void => {
for (const box of [...fields, origin]) {
box.disabled = !on.checked;
box.classList.toggle("b08-unit__manual", on.checked);
}
};
on.addEventListener("change", () => {
if (on.checked) {
manual[key] = { material: 0, labor: 0, expense: 0, source: "" };
fields.forEach((box, i) => (manual[key][MONEY[i][0]] = Number(box.value || 0)));
manual[key].source = origin.value;
} else {
delete manual[key];
}
sync();
});
sync();
price.append(...fields, origin);
if (manual[key]?.entered_at) price.append(` ${manual[key].entered_at}`);
const remove = el("button", "", "빼기");
remove.type = "button";
remove.addEventListener("click", () => {
rows = rows.filter((_, i) => i !== index);
delete manual[key];
picking = null;
render();
});
const actions = el("td", "");
actions.append(remove);
const nameCell = el("td", "");
nameCell.append(name);
const unitCell = el("td", "");
unitCell.append(unit);
tr.append(el("td", "", String(row.seq)), nameCell, quantity, unitCell, code, price, actions);
return tr;
};
// 고르개 — 이 프로젝트 단가표에서 낱말로 찾음.
const kind = document.createElement("select");
kind.append(new Option("품셈(일위대가)", "work"), new Option("자원(자재·노임·중기)", "resource"));
const query = input("", "12rem");
query.placeholder = "이름·코드·규격 낱말";
const go = el("button", "", "찾기");
go.type = "button";
const shut = el("button", "", "닫기");
shut.type = "button";
const results = el("div", "b08-unit-edit__results");
const search = async (): Promise<void> => {
if (!query.value.trim()) return;
results.replaceChildren(el("span", "b08-grid__caption", "찾는 중…"));
try {
const response = await fetch(
sheetUrl(
projectId,
`/price-search?q=${encodeURIComponent(query.value)}&kind=${kind.value}`,
),
{ credentials: "include" },
);
const payload = (await response.json()) as { items?: SearchItem[]; message?: string };
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
const items = payload.items ?? [];
results.replaceChildren(
...(items.length ? items.map(choice) : [el("span", "b08-grid__caption", "없음")]),
);
} catch (error) {
results.replaceChildren(
el("span", "b08-grid__caption", `못 찾음 — ${error instanceof Error ? error.message : ""}`),
);
}
};
const choice = (item: SearchItem): HTMLElement => {
const price = item.price === null ? "단가 안 섬" : `${item.price.toLocaleString("ko-KR")}원`;
const button = el(
"button",
"",
`${item.code} · ${item.name}${item.spec ? ` ${item.spec}` : ""} · ${item.unit} · ${price}`,
);
button.type = "button";
button.addEventListener("click", () => {
const row = picking === null ? undefined : rows[picking];
if (!row) return;
row.ref_code = item.code;
row.name = item.name;
row.spec = item.spec || undefined;
row.unit = item.unit || undefined;
delete row.work_item_code;
delete row.variant_from;
delete row.sub_vars;
picking = null;
render();
});
return button;
};
go.addEventListener("click", () => void search());
query.addEventListener("keydown", (event) => {
if (event.key === "Enter") void search();
});
shut.addEventListener("click", () => {
picking = null;
render();
});
picker.append(kind, query, go, shut, results);
const grid = el("table", "b08-grid__table");
const head = document.createElement("tr");
for (const label of ["차례", "이름", "수량", "단위", "코드", "단가 수동(단위당)", ""]) {
head.append(el("th", "", label));
}
const thead = document.createElement("thead");
thead.append(head);
grid.append(thead, tbody);
const scroller = el("div", "b08-grid__scroll");
scroller.append(grid);
const add = el("button", "", "줄 더하기");
add.type = "button";
add.addEventListener("click", () => {
// 양식 줄 차례와도 안 겹치게 — 뺀 양식 줄의 차례를 새 줄이 물려받지 않음.
const seq = Math.max(0, ...rows.map((r) => r.seq), ...data.default_rows.map((r) => r.seq)) + 1;
rows.push({ seq, name: "", quantity: 1 });
render();
});
const reset = el("button", "", "양식대로");
reset.type = "button";
reset.title = "줄 조합을 양식 원래대로 — [저장]해야 반영";
reset.addEventListener("click", () => {
rows = structuredClone(data.default_rows);
picking = null;
render();
});
const save = el("button", "b08-spec__save", "일위대가 저장");
save.type = "button";
save.addEventListener("click", () => {
void (async () => {
save.disabled = true;
status.textContent = "저장 중…";
try {
// 넣은 날짜는 서버가 붙임 — 값·출처만 보냄.
const prices = Object.fromEntries(
Object.entries(manual).map(([seq, { entered_at: _date, ...price }]) => [seq, price]),
);
const response = await fetch(sheetUrl(projectId, "/unit-price"), {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sheet_key: sheetKey, rows, manual_prices: prices }),
});
const payload = (await response.json().catch(() => ({}))) as { message?: string };
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
onSaved();
} catch (error) {
status.textContent = `저장 못 함 — ${error instanceof Error ? error.message : ""}`;
save.disabled = false;
}
})();
});
const close = el("button", "b08-quantity__tab", "고친 것 버리고 닫기");
close.type = "button";
close.addEventListener("click", onClose);
const actions = el("div", "b08-sheet__actions");
actions.append(add, save, reset, close, status);
wrap.append(
el(
"p",
"b08-grid__caption",
"줄 조합은 이 양식+프로젝트에 저장(내 라이브러리로 갈 수 있음) · 수동 단가는 이 프로젝트에만 저장",
),
scroller,
picker,
actions,
);
render();
return wrap;
}
@@ -0,0 +1,398 @@
/* =============================================================================
* B08_Quantity_UI_StructureSummary.ts
* **구조물 집계표** 탭 — 측점별 구조물 한 줄 · 종류별 표 · 칸 고치기 (PLAN 2장).
*
* ⚠ 값을 셈하지 않음 — 서버(`…/quantity/structure-summary`)가 정본을 읽어 세운 줄을 적기만.
* ⚠ 칸마다 출처 표시 — 자동(정본) · 사용자(이 표에서 고침) · 라이브러리(양식 기본값) · 빈칸.
* ⚠ 이 표에서 고친 값을 B05 가 바꿔 자동으로 돌아간 칸은 **조용히 넘기지 않고** 줄에 알림.
* ⚠ 고치기는 데이터 3층 — 칸 조작은 캐시(sessionStorage)에만, [저장] 때 서버가 **정본에** 씀.
* 상세 칸만 고침 — 자리·길이·높이·관경 같은 놓기 칸은 시·종점과 한 벌이라 B05 몫(회색).
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { stationLabel } from "./B08_Quantity_UI_EarthworkGrid";
import { el, num } from "./B08_Quantity_UI_StructureSheet_Formula";
type Source = "auto" | "user" | "library" | "empty";
type CellValue = number | string | null;
interface SummaryCell {
value: CellValue;
source: Source;
was?: CellValue;
replaced_user_value?: CellValue;
}
interface SummaryRow {
id: string;
origin: "structures" | "pipe_points";
chainage_m: number | null;
start_m: number | null;
end_m: number | null;
count: number;
length_m: number | null;
length_basis: string;
memo: string;
note?: string;
replaced?: string[];
cells: Record<string, SummaryCell>;
}
interface SummaryColumn {
key: string;
label: string;
unit: string;
input: string;
choices: string[];
editable: boolean;
}
interface SummaryTable {
type_id: string;
name: string;
columns: SummaryColumn[];
rows: SummaryRow[];
count: number;
length_total_m: number | null;
length_missing: number;
averages: Record<string, number>;
}
interface SummaryResponse {
revision: number;
tables: SummaryTable[];
notes: string[];
message?: string;
}
interface Edit {
id: string;
key: string;
value: CellValue;
}
const SOURCE_LABELS: Record<Source, string> = {
auto: "자동 — 구조물 놓기(B05)·계곡 시설 정본 값",
user: "사용자 — 이 표에서 고친 값(정본에 적힘)",
library: "라이브러리 — 정본이 비어 양식 기본값으로 섬",
empty: "빈칸 — 정본도 양식도 값 없음",
};
const STYLE_ID = "b08-structure-summary-style";
const CSS = `
.b08-sum { display: flex; flex-direction: column; gap: 10px; min-height: 0; }
.b08-sum__legend { display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; }
.b08-sum__cell--auto { box-shadow: inset 3px 0 0 var(--color-border, #888); }
.b08-sum__cell--user { box-shadow: inset 3px 0 0 var(--color-accent, #6c8ebf); }
.b08-sum__cell--library { box-shadow: inset 3px 0 0 var(--color-success, #5cb85c); }
.b08-sum__cell--empty { color: var(--color-text-muted, #999); }
.b08-sum__cell--replaced { outline: 2px solid var(--color-danger, #d9534f); outline-offset: -2px; }
.b08-sum td.is-changed { background: color-mix(in srgb, var(--color-accent, #6c8ebf) 18%, transparent); }
.b08-sum .b08-grid__table td { white-space: nowrap; }
.b08-sum td input, .b08-sum td select { font-size: 12px; max-width: 8rem; }
.b08-sum td input[type=number] { width: 5rem; }
.b08-sum td button { font-size: 11px; padding: 0 4px; margin-left: 2px; cursor: pointer; }
.b08-sum__actions { display: flex; gap: 8px; align-items: center; }
`;
function injectStyles(): void {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = CSS;
document.head.append(style);
}
/** 칸 글 — 정수는 그대로(뒷길이 45), 소수는 둘째 자리까지. */
function cellText(value: CellValue | undefined): string {
if (value === null || value === undefined || value === "") return "";
return typeof value === "number" && !Number.isInteger(value) ? num(value, 2) : String(value);
}
function station(row: SummaryRow): string {
if (row.start_m !== null && row.end_m !== null) {
return `${stationLabel(row.start_m)} ~ ${stationLabel(row.end_m)}`;
}
return row.chainage_m === null ? "" : stationLabel(row.chainage_m);
}
/** 정본에 지금 적힌 값 — 라이브러리·빈칸은 정본이 빈 것. */
function storedValue(cell: SummaryCell | undefined): CellValue {
return cell && (cell.source === "auto" || cell.source === "user") ? cell.value : null;
}
function same(a: CellValue, b: CellValue): boolean {
return String(a ?? "") === String(b ?? "");
}
/** 고친 칸 캐시 — [저장] 전까지 이 탭(sessionStorage)에만 둠. 판번호가 다르면 버림. */
class Draft {
private readonly storageKey: string;
readonly edits = new Map<string, Edit>();
onChange: () => void = () => undefined;
constructor(
projectId: string,
readonly revision: number,
) {
this.storageKey = `b08-structure-summary-draft:${projectId}`;
try {
const saved = JSON.parse(sessionStorage.getItem(this.storageKey) ?? "null") as {
revision: number;
edits: Edit[];
} | null;
if (saved?.revision === revision) {
for (const edit of saved.edits) this.edits.set(`${edit.id}|${edit.key}`, edit);
}
} catch {
// 캐시를 못 읽으면 빈 초안 — 정본은 그대로.
}
}
get(id: string, key: string): Edit | undefined {
return this.edits.get(`${id}|${key}`);
}
set(edit: Edit, stored: CellValue): void {
const slot = `${edit.id}|${edit.key}`;
if (same(edit.value, stored)) this.edits.delete(slot);
else this.edits.set(slot, edit);
this.persist();
}
clear(): void {
this.edits.clear();
this.persist();
}
private persist(): void {
try {
if (this.edits.size) {
const body = { revision: this.revision, edits: [...this.edits.values()] };
sessionStorage.setItem(this.storageKey, JSON.stringify(body));
} else {
sessionStorage.removeItem(this.storageKey);
}
} catch {
// 저장소가 막혀도 화면 초안은 살아 있음.
}
this.onChange();
}
}
function editor(
column: SummaryColumn,
row: SummaryRow,
cell: SummaryCell | undefined,
draft: Draft,
td: HTMLTableCellElement,
): HTMLElement[] {
const stored = storedValue(cell);
const pending = draft.get(row.id, column.key);
const current = pending ? pending.value : stored;
let control: HTMLInputElement | HTMLSelectElement;
if (column.input === "select" && column.choices.length) {
const select = document.createElement("select");
select.append(new Option("—", ""));
for (const choice of column.choices) select.append(new Option(choice, choice));
select.value = current === null ? "" : String(current);
control = select;
} else {
const input = document.createElement("input");
input.type = column.input === "number" ? "number" : "text";
if (column.input === "number") {
input.min = "0";
input.step = "any";
}
input.value = current === null ? "" : String(current);
control = input;
}
if (cell?.source === "library") control.title = `비우면 양식 기본값 ${cellText(cell.value)}`;
if (control instanceof HTMLInputElement && cell?.source === "library") {
control.placeholder = cellText(cell.value);
}
const mark = (): void => {
td.classList.toggle("is-changed", Boolean(draft.get(row.id, column.key)));
};
control.addEventListener("change", () => {
const raw = control.value.trim();
const value = raw === "" ? null : column.input === "number" ? Number(raw) : raw;
draft.set({ id: row.id, key: column.key, value }, stored);
mark();
});
mark();
const parts: HTMLElement[] = [control];
if (cell?.source === "user" && cell.was !== undefined) {
const undo = el("button", "", "↺");
undo.type = "button";
undo.title = `고치기 전 값(${cellText(cell.was) || "빈칸"})으로 — [저장]하면 「자동」으로 돌아감`;
undo.addEventListener("click", () => {
control.value = cell.was === null || cell.was === undefined ? "" : String(cell.was);
control.dispatchEvent(new Event("change"));
});
parts.push(undo);
}
return parts;
}
function renderTable(table: SummaryTable, draft: Draft): HTMLElement {
const wrap = el("div", "b08-grid");
const length =
table.length_total_m === null ? "" : ` · 연장 합 ${num(table.length_total_m, 2)} m`;
const missing = table.length_missing ? ` · 연장 빈 줄 ${table.length_missing}` : "";
wrap.append(el("p", "b08-sheet__head", `${table.name} — ${table.count}개소${length}${missing}`));
const scroller = el("div", "b08-grid__scroll");
const grid = el("table", "b08-grid__table");
const head = document.createElement("tr");
head.append(el("th", "", "측점"), el("th", "", "연장(m)"));
for (const column of table.columns) {
const th = el("th", "", column.label);
if (!column.editable) th.title = "놓기 칸 — 시·종점과 한 벌이라 구조물 놓기(B05)에서 고침";
head.append(th);
}
head.append(el("th", "", "비고"));
const thead = document.createElement("thead");
thead.append(head);
const tbody = document.createElement("tbody");
for (const row of table.rows) {
const tr = document.createElement("tr");
const lengthCell = el("td", "", row.length_m === null ? "" : num(row.length_m, 2));
lengthCell.title = row.length_basis || "연장 없음";
tr.append(el("td", "", station(row)), lengthCell);
for (const column of table.columns) {
const cell = row.cells[column.key];
const td = el("td", `b08-sum__cell--${cell?.source ?? "empty"}`);
if (column.editable) td.append(...editor(column, row, cell, draft, td));
else td.textContent = cellText(cell?.value);
td.title = cell ? SOURCE_LABELS[cell.source] : "";
if (cell?.source === "user" && cell.was !== undefined) {
td.title += ` · 고치기 전 ${cellText(cell.was) || "빈칸"}`;
}
if (cell?.replaced_user_value !== undefined) {
td.classList.add("b08-sum__cell--replaced");
td.title = `이 표에서 ${cellText(cell.replaced_user_value) || "빈칸"}(으)로 고쳤으나 B05 가 바꿔 자동값으로 돌아감`;
}
tr.append(td);
}
const notes = [
row.replaced?.length ? `⚠ B05 가 바꿈: ${row.replaced.join("·")}` : "",
row.note ?? "",
row.memo,
].filter(Boolean);
tr.append(el("td", "", notes.join(" · ")));
tbody.append(tr);
}
const average = document.createElement("tr");
average.append(el("td", "", "평균치수"), el("td", "", ""));
for (const column of table.columns) {
const value = table.averages[column.key];
average.append(el("td", "", value === undefined ? "" : num(value, 2)));
}
average.append(el("td", "", ""));
tbody.append(average);
grid.append(thead, tbody);
scroller.append(grid);
wrap.append(scroller);
return wrap;
}
/** 탭 본문 — 받는 동안 안내, 오면 종류별 표. [저장] 뒤엔 다시 받음. */
export function renderStructureSummary(projectId: string | null): HTMLElement {
injectStyles();
const root = el("div", "b08-sum");
if (!projectId) {
root.append(el("p", "b08-quantity__message", "프로젝트를 먼저 고를 것"));
return root;
}
const url = `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-summary`;
let draft: Draft | null = null;
// 저장 안 한 칸이 조용히 사라지지 않게 — 나갈 때 물음(자동저장은 안 만듦, CLAUDE.md 5장).
window.addEventListener("beforeunload", (event) => {
if (!draft?.edits.size || !root.isConnected) return;
event.preventDefault();
event.returnValue = "저장 안 한 칸이 있음";
});
const load = async (): Promise<void> => {
root.replaceChildren(el("p", "b08-grid__caption", "구조물 집계표 불러오는 중…"));
try {
const response = await fetch(url, { credentials: "include" });
const payload = (await response.json().catch(() => ({}))) as SummaryResponse;
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
const active = new Draft(projectId, payload.revision);
draft = active;
const legend = el("div", "b08-sum__legend");
for (const source of ["auto", "user", "library", "empty"] as Source[]) {
legend.append(el("span", `b08-sum__cell--${source}`, ` ${SOURCE_LABELS[source]} `));
}
const save = el("button", "b08-spec__save", "저장");
save.type = "button";
const discard = el("button", "b08-quantity__tab", "고친 것 버리기");
discard.type = "button";
const status = el("span", "b08-grid__caption");
active.onChange = () => {
const count = active.edits.size;
save.disabled = count === 0;
discard.disabled = count === 0;
status.textContent = count
? `고친 칸 ${count} — [저장]해야 정본에 적힘(구조물도·원단위가 그 값을 씀)`
: "상세 칸만 고침 · 자리·길이·높이·관경은 구조물 놓기(B05)에서";
};
active.onChange();
save.addEventListener("click", () => {
void (async () => {
save.disabled = true;
status.textContent = "저장 중…";
try {
const result = await fetch(url, {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
base_revision: active.revision,
edits: [...active.edits.values()],
}),
});
const body = (await result.json().catch(() => ({}))) as { message?: string };
if (!result.ok) throw new Error(body.message ?? `HTTP ${result.status}`);
active.clear();
await load();
} catch (error) {
status.textContent = `저장 못 함 — ${error instanceof Error ? error.message : ""}`;
save.disabled = false;
}
})();
});
discard.addEventListener("click", () => {
active.clear();
void load();
});
const actions = el("div", "b08-sum__actions");
actions.append(save, discard, status);
const total = payload.tables.reduce((sum, table) => sum + table.count, 0);
root.replaceChildren(
el(
"p",
"b08-sheet__head",
`구조물 집계표 · ${payload.tables.length}종 · ${total}개소 (측점별 실치수 — 구조물도가 이 값을 씀)`,
),
legend,
actions,
...payload.notes.map((note) => el("p", "b08-grid__caption b08-grid__caption--warn", note)),
...(payload.tables.length
? payload.tables.map((table) => renderTable(table, active))
: [el("p", "b08-grid__caption", "놓인 구조물이 없음")]),
);
} catch (error) {
root.replaceChildren(
el(
"p",
"b08-grid__caption b08-grid__caption--warn",
`구조물 집계표를 불러오지 못함 — ${error instanceof Error ? error.message : ""}`,
),
);
}
};
void load();
return root;
}
+12 -4
View File
@@ -110,11 +110,19 @@ def work_item_basis(build: Any) -> list[dict[str, Any]]:
for code, title in sorted(build.book.titles.items()):
if title.kind is not PriceKind.UNIT_PRICE:
continue
notes = [
detail.note.strip()
for detail in build.book.details.get(code, [])
if detail.note and detail.note.strip()
own = build.book.details.get(code, [])
# Q 식 문구는 층 차례 뒤 D(단가산출) 줄에 삶 — 그 일위대가가 부르는 D 의 줄 문구도 함께(명세 3장).
basis = [
detail
for ref in own
if ref.ref_code.startswith("D-")
for detail in build.book.details.get(ref.ref_code, [])
]
notes = []
for detail in [*own, *basis]:
text = detail.note.strip() if detail.note else ""
if text and text not in notes:
notes.append(text)
work_item_code = code[2:].split("#")[0]
source = build.factor_sources.get(work_item_code)
if source and source not in notes:
@@ -29,9 +29,12 @@ from B09_Estimation.B09_Estimation_Guards import (
check_drain_pipe_not_double_counted,
check_included_materials_not_listed,
check_free_haul_not_priced,
check_handoff_boundaries,
check_haul_volume_within_cut,
check_material_surcharge_once,
)
from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master
from common_util.common_util_pum_edition import edition_waiting_reason
from B09_Estimation.B09_Estimation_QuantityDigits import round_quantity
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
from B09_Estimation.B09_Estimation_UnitPrice import (
@@ -42,6 +45,12 @@ from B09_Estimation.B09_Estimation_UnitPrice import (
_ZERO = Decimal(0)
#: 총 절취량으로 셀 공종 — 9-3 토사깎기 · 9-4 암절취 · 9-5 발파암(인계 대응표 「흙깎기」 셋).
#: ⚠ 판에 묶인 절 번호임(명세 17장) — 판이 바뀌면 대응표와 함께 고칠 것.
CUT_CODE_PREFIXES = ("FP-09-03", "FP-09-04", "FP-09-05")
#: 구조물도 양식 호표 코드 머리(명세 2장 자체 확장) — 금액은 B08 일위대가 엔진을 B09 단가표로 셈.
STRUCTURE_PRICE_PREFIX = "AX-ST-"
#: 자재 공급 구분이 안 갈린 값. B08 이 실제로 이 값을 보낸다(2026-09-08 실물 확인).
#: **관급자재대에도, 도급 재료비에도 넣지 않는다** — 어느 쪽에 넣어도 총액이 틀린다.
SUPPLY_UNKNOWN = "unknown"
@@ -107,6 +116,8 @@ class HandoffWorkItem:
#: **근거는 산출근거 칸에 그대로 적는다** — 우리가 다시 지어내지 않는다.
spec_class: str = ""
spec_class_basis: str = ""
#: 이 줄의 공종 코드가 본 품셈 판 — 마스터 판과 다르면 값을 안 씀(명세 17장).
pum_edition: str = ""
@property
def display_name(self) -> str:
@@ -152,6 +163,8 @@ class BillRow:
expense_krw: Decimal = _ZERO
is_group: bool = False
in_bill: bool = True
#: 금액을 낸 단가 코드(`B-…#갈래`) — 단산 번호를 그 코드로 찾음. 안 선 줄은 빈 글.
price_code: str = ""
#: 줄 사유 **조각** — `(닿는 열 키, 글)`. 화면 「비고」는 이것을 이어 붙인 것이고,
#: 근거 호버는 열 키로 걸러 **그 사유가 닿는 칸에만** 띄운다(PLAN 8-36 ㉮).
#: ⚠ 종전엔 `note` 한 칸에 덮어썼다 — 한 줄에 사유가 둘이면 **하나가 조용히 사라졌다**
@@ -224,6 +237,8 @@ class BillResult:
price_basis: Any = None
#: 자재대 표 — 사급·관급·미정 셋으로 갈린다(PLAN 8-7 「금액은 B09」).
material_sheet: Any = None
#: 수동 단가로 선 자리 — 내역서 끝 「미확정 N건」(PLAN 확정 ⑦). 줄마다 `{name, count}`.
unconfirmed: list[dict[str, Any]] = field(default_factory=list)
@property
def direct_material_krw(self) -> Decimal:
@@ -285,6 +300,7 @@ def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[
variant_value=str(row.get("variant_value") or ""),
spec_class=row.get("spec_class") or "",
spec_class_basis=row.get("spec_class_basis") or "",
pum_edition=str(row.get("pum_edition") or ""),
)
for row in payload["work_items"]
]
@@ -351,9 +367,9 @@ def _number_of(path: tuple[int, ...]) -> str:
from B09_Estimation.B09_Estimation_BillOfQuantities_Rows import ( # noqa: E402
_composite_row,
_excluded_row,
_haul_price_of,
_leaf_row,
_material_row,
_structure_price_row,
)
@@ -362,11 +378,17 @@ def build_bill(
*,
build: UnitPriceBuild | None = None,
master: dict[str, Any] | None = None,
structure_prices: dict[str, dict[str, Any]] | None = None,
) -> BillResult:
"""인계 응답 한 벌을 ④ 예산내역서 한 장으로 접는다."""
"""인계 응답 한 벌을 ④ 예산내역서 한 장으로 접는다.
`structure_prices` — 구조물도 호표 금액 `{B-AX-ST-…#키: …}`(B08 `structure_bill_prices`,
**이 `build` 단가표로** 셈). 안 주면 호표 줄은 금액 없이 사유와 함께 섬.
"""
work_items, materials = parse_handoff(payload)
unit_prices = build or cached_build()
index = _master_index(master or load_work_item_master())
master = master or load_work_item_master()
index = _master_index(master)
result = BillResult()
# ── 1) 쓰인 공종의 조상만 남긴 가지치기 나무 ────────────────────────────────
@@ -374,6 +396,7 @@ def build_bill(
used: list[tuple[tuple[int, ...], HandoffWorkItem, list[_MasterNode]]] = []
orphans: list[HandoffWorkItem] = []
composites: list[HandoffWorkItem] = []
templated: list[HandoffWorkItem] = []
for item in work_items:
if not item.in_bill:
# ⚠ 코드 유무보다 **먼저** 가른다. 보정량계는 공종코드가 없어서가 아니라
@@ -394,6 +417,23 @@ def build_bill(
}
)
continue
if str(item.work_item_code or "").startswith(STRUCTURE_PRICE_PREFIX):
# 구조물도 호표(PLAN 6장 ②) — 공종 마스터에 없는 자체 확장 코드라 나무에 안 끼움.
templated.append(item)
continue
# 품셈 판이 다른 줄 — 절 번호가 같아도 같은 공종이라는 보장이 없어 값을 안 씀(명세 17장 규칙 4).
waiting = edition_waiting_reason(item.pum_edition, str(master.get("effective_date", "")))
if waiting:
result.missing.append(
{
"name": item.display_name,
"unit": item.unit,
"quantity": str(item.quantity),
"reason": waiting,
"blocked_kind": "edition_waiting",
}
)
continue
if (item.composite_parts or item.composite_not_ready) and not item.work_item_code:
# 묶음 줄 — 품셈에 그 공종이 없어 **조각을 합쳐** 한 줄로 세운다
# (옹벽 = 타설 + 거푸집 + 철근 + 잡석). 「코드 없음」으로 세면 안 된다.
@@ -411,6 +451,9 @@ def build_bill(
emitted: dict[str, str] = {} # 코드 → ITEM NO.
counters: dict[str, int] = {} # 부모 ITEM NO. → 마지막 번호
# 인계 줄 → 그 줄로 선 내역 줄. ⚠ 이름으로 되찾지 않음 — 도자운반 토사·리핑암처럼
# 이름이 같은 줄이 둘이면 **첫 줄 단가**가 둘째에도 붙음(2026-09-13 축 C 조사 실결함).
row_of: dict[int, BillRow] = {}
def next_number(parent_no: str) -> str:
counters[parent_no] = counters.get(parent_no, 0) + 1
@@ -440,12 +483,22 @@ def build_bill(
# 물려주면 ITEM NO. 가 겹쳐 어느 줄인지 못 가린다. 머리글만 물려준다.
item_no = next_number(parent_no)
emitted.setdefault(leaf.code, item_no)
result.rows.append(_leaf_row(item_no, leaf, item, unit_prices, result))
row_of[id(item)] = _leaf_row(item_no, leaf, item, unit_prices, result)
result.rows.append(row_of[id(item)])
# ── 1-2) 묶음 줄 ──────────────────────────────────────────────────────────
for item in composites:
counters[""] = counters.get("", 0) + 1
result.rows.append(_composite_row(str(counters[""]), item, unit_prices, result))
row_of[id(item)] = _composite_row(str(counters[""]), item, unit_prices, result)
result.rows.append(row_of[id(item)])
# ── 1-3) 구조물도 호표 줄 ─────────────────────────────────────────────────
for item in templated:
counters[""] = counters.get("", 0) + 1
row_of[id(item)] = _structure_price_row(
str(counters[""]), item, structure_prices or {}, result
)
result.rows.append(row_of[id(item)])
# ── 2) 공종을 못 고른 줄 — 이름째 남긴다 ────────────────────────────────────
for item in orphans:
@@ -472,6 +525,10 @@ def build_bill(
# ── 4) 검사 — `in_bill=false` 줄에 금액이 붙지 않았는가 ──────────────────────
check_excluded_rows_not_priced(rows=[r.as_dict() for r in result.excluded])
# 이중계상 경계 ①②③(명세 6장) — B08 이 갈 곳 칸으로 판정한 어긴 자리 · 자재 할증 한 번.
check_handoff_boundaries(payload.get("double_count_violations"))
check_material_surcharge_once(materials)
# ㉦ 큰돌쌓기 품에 포함된 자재(고임돌·채움콘크리트)를 따로 세지 않았는가.
check_included_materials_not_listed(
work_item_codes=[row.code or "" for row in result.rows],
@@ -496,7 +553,8 @@ def build_bill(
haul_rows = [
{
"equipment": item.haul_equipment,
"unit_price_krw": _haul_price_of(item, result),
# 그 줄에 실제로 붙은 단가. 안 선 줄은 0 — ㉡ 검사에 넘길 값이다.
"unit_price_krw": getattr(row_of.get(id(item)), "unit_price_krw", None) or _ZERO,
}
for item in work_items
if item.haul_equipment
@@ -504,8 +562,14 @@ def build_bill(
check_free_haul_not_priced(haul_rows=haul_rows)
# ㉡ 보조 — 운반토량 합이 총 절취량을 넘지 않는가(같은 흙을 두 번 세지 않았는가).
# 절취 = **코드로** 가름(9-3 토사깎기 · 9-4 암절취 · 9-5 발파암 — 인계 대응표의 「흙깎기」 셋).
# ⚠ 종전엔 줄 이름에 「깎기」·「절취」가 드는지로 셌음 — 이름이 바뀌면 검산이 조용히 멈춤.
cut_total = sum(
(item.quantity for item in work_items if "깎기" in item.name or "절취" in item.name),
(
item.quantity
for item in work_items
if str(item.work_item_code or "").startswith(CUT_CODE_PREFIXES)
),
_ZERO,
)
haul_total = sum((item.quantity for item in work_items if item.haul_equipment), _ZERO)
@@ -530,10 +594,11 @@ def build_bill(
for row in result.rows:
if row.is_group or row.code is None or row.amount_krw is None:
continue
entry = sheet.by_unit_price(f"B-{row.code}")
if entry is not None:
# 그 줄이 **실제로 쓴** 단가 코드(갈래 `#…` 포함)로 찾음 — 갈래로 선 줄도 번호가 붙게.
label = sheet.label_for(row.price_code or f"B-{row.code}")
if label:
# 종전처럼 **맨 앞**에 놓는다 — 실무 참조번호(「단산 46」)가 먼저 읽혀야 한다.
row.notes.insert(0, ("unit_price_krw", entry.label))
row.notes.insert(0, ("unit_price_krw", label))
result.price_basis = sheet
if any(m.surcharge_pct is None for m in materials):
@@ -554,6 +619,8 @@ def bill_summary(result: BillResult) -> dict[str, Any]:
"material_rows": len(result.material_rows),
"material_sheet": result.material_sheet.as_dict() if result.material_sheet else None,
"missing": result.missing,
"unconfirmed": result.unconfirmed,
"unconfirmed_count": sum(int(entry["count"]) for entry in result.unconfirmed),
"body_total_krw": str(result.body_total_krw),
"direct_material_krw": str(result.direct_material_krw),
"direct_labor_krw": str(result.direct_labor_krw),
@@ -9,8 +9,6 @@
from __future__ import annotations
from decimal import Decimal
from B09_Estimation.B09_Estimation_BillOfQuantities import (
SUPPLY_OWNER,
SUPPLY_UNKNOWN,
@@ -25,16 +23,6 @@ from B09_Estimation.B09_Estimation_BillOfQuantities import (
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, find_variant_code
_ZERO = Decimal(0)
def _haul_price_of(item: HandoffWorkItem, result: BillResult) -> Decimal:
"""그 운반 줄에 실제로 붙은 단가. 안 붙었으면 0 — ㉡ 검사에 넘길 값이다."""
for row in result.rows:
if row.name == item.name and row.unit_price_krw is not None:
return row.unit_price_krw
return _ZERO
def _composite_row(
item_no: str,
@@ -107,6 +95,65 @@ def _composite_row(
return row
def _structure_price_row(
item_no: str,
item: HandoffWorkItem,
structure_prices: dict[str, dict],
result: BillResult,
) -> BillRow:
"""구조물도 호표 줄(PLAN 6장 ②) — m당 금액은 B08 일위대가 엔진을 **이 내역의 단가표**로 돌린 값.
⚠ 일위대가에 막힌 줄이 있으면 **금액을 안 세움** — 절반짜리 단가가 제일 위험(묶음 줄과 같음).
⚠ 수동 단가로 선 줄은 금액을 세우되 「미확정」으로 셈 — 구조물도 화면과 내역이 같은 값.
"""
from B08_Quantity.B08_Quantity_Engine_StructurePriceLink import structure_price_code
ref = structure_price_code(str(item.work_item_code), item.variant_value)
row = BillRow(
item_no=item_no,
level=1,
code=item.work_item_code,
name=item.name,
spec=item.spec,
unit=item.unit,
quantity=item.quantity,
in_bill=item.in_bill,
)
entry = structure_prices.get(ref)
reason = ""
if entry is None:
reason = f"구조물도 일위대가 {ref} 를 못 받음 — 구조물도 탭에서 그 장의 일위대가를 확인"
elif entry["blocked"]:
reason = f"구조물도 일위대가 미완 — 막힌 줄 {entry['blocked']}: " + "; ".join(
entry["reasons"][:3]
)
if reason:
row.add_note("unit_price_krw", reason)
result.missing.append(
{
"name": row.name,
"code": ref,
"unit": row.unit,
"quantity": str(item.quantity),
"reason": reason,
}
)
return row
# 단가 = 호표 계금(구조물도 화면과 같은 값) · 금액 = 호표 성분 소계 × 수량(명세 7장).
line = entry["money"].scaled(item.quantity)
row.unit_price_krw = entry["total"]
row.amount_krw = round_at(line.total, OutputPlace.BOQ_ROW)
row.material_krw = line.material
row.labor_krw = line.labor
row.expense_krw = line.expense
row.add_note("unit_price_krw", f"호표 {ref}")
if entry["unconfirmed"]:
row.add_note("unit_price_krw", f"⚠ 수동 단가 {entry['unconfirmed']}건 미확정")
result.unconfirmed.append({"name": row.name, "code": ref, "count": entry["unconfirmed"]})
return row
def _excluded_row(item: HandoffWorkItem) -> BillRow:
"""`in_bill=false` 줄. **수량만 보이고 단가·금액을 안 붙인다.**
@@ -393,6 +440,7 @@ def _leaf_row(
# 쓰인 차례를 기억한다 — 실무 참조번호(「단산 46」)가 그 차례다.
if price_code not in result.used_unit_prices:
result.used_unit_prices.append(price_code)
row.price_code = price_code
unit_money = unit_prices.book.resolve(price_code)
line = unit_money.scaled(item.quantity)
@@ -35,6 +35,8 @@ from B09_Estimation.B09_Estimation_ResourceAxis import (
)
_RE_NUMBER = re.compile(r"^\d+(?:,\d{3})*(?:\.\d+)?$")
#: 두께 갈래 칸 — 「20㎝」. 넓히면 규격 칸(「0.6㎥」)을 갈래로 오해한다.
_RE_THICKNESS = re.compile(r"^\d+(?:\.\d+)?\s*(?:㎝|cm)$")
#: 시공량 열을 알아보는 말. 「시 공 량 (㎡)」처럼 띄어쓰기가 섞여 온다.
_OUTPUT_WORDS = ("시공량", "기준시공량", "적용시공량")
@@ -101,6 +103,7 @@ def parse_crew_table(
members: list[CrewMember] = []
outputs: list[Decimal] = []
labels: list[str] = []
thickness: list[tuple[str, Decimal]] = []
for row in rows:
if not row or not row[0]:
@@ -117,6 +120,11 @@ def parse_crew_table(
names = [part for part in _normalize(head).split(" ") if part]
resolved = [_resolve(catalog, name) for name in names]
if not all(resolved):
# 두께 갈래 줄 — 「30㎝ | 150」. 콘크리트 포장 12-6 은 두께 칸이 작업조 칸과
# **줄을 엇갈려** 병합돼 있어(hwpx 확인) 마스터가 작업조 줄 사이에 두께 줄을 끼워 둔다.
if _RE_THICKNESS.match(head) and len(_numbers_in(" ".join(row[1:]))) == 1:
thickness.append((head, _numbers_in(" ".join(row[1:]))[0]))
continue
# 자원이 아니면 유형 라벨 줄로 본다 — 「복 잡 | 보 통 | 간 단」.
if not _numbers_in(" ".join(row)):
if not members:
@@ -143,9 +151,17 @@ def parse_crew_table(
# 시공량은 보통 **첫 작업조 줄**에 붙어 온다.
if not outputs:
outputs = _outputs_of(row, counts)
# 작업조 줄에 두께 갈래가 붙어 온 자리 — 「포장공 | 3 | 20㎝ | 100 | …」.
for label, value in zip(row, row[1:]):
if _RE_THICKNESS.match(label) and _RE_NUMBER.match(value):
thickness.append((label, Decimal(value.replace(",", ""))))
if not members:
return "작업조 줄을 못 찾았습니다"
if thickness:
# 두께마다 시공량이 하나 — 그 옆 시공량 칸(「후진 진입·경운기 운반은 좌측의 50%까지 감」)은
# **범위라 값이 아니다.** 유형 라벨은 그 두 칸의 머리라 두께 갈래와 짝이 아니다.
return CrewTable(members=tuple(members), outputs=tuple(thickness))
if not outputs:
return "시공량 값을 못 찾았습니다"
if labels and len(labels) != len(outputs):
@@ -206,6 +222,16 @@ def match_crew_table(
work_item_code = node.get("work_item_code", "")
table_id = str(table.get("pum_table_id", ""))
form = str(table.get("pum_form", ""))
from B09_Estimation.B09_Estimation_WorkItemUnit import unit_of
if not unit and not unit_of(work_item_code):
# 밑수가 「일당」이라 비어 있는 표(12-6) — 1단위당 품의 단위는 **시공량 열 머리**의 단위다.
# ⚠ 공종 단위가 따로 적힌 자리는 그대로 둔다(12-2 에 딸린 비계 표가 m 를 들고 옴).
headers = table.get("condition_note") or []
found = [
re.search(r"[((]\s*([^))]+?)\s*[))]", str(headers[i])) for i in output_columns(table)
]
unit = found[0].group(1) if found and found[0] else ""
parsed = parse_crew_table(table, catalog)
if isinstance(parsed, str):
result.unmatched.append(
+62
View File
@@ -20,6 +20,7 @@
from __future__ import annotations
from decimal import Decimal
from typing import Any
_TOLERANCE = Decimal("0.5")
@@ -219,6 +220,67 @@ def check_excluded_rows_not_priced(
)
def check_handoff_boundaries(violations: list[str] | None) -> None:
"""이중계상 경계 ①②③(명세 6장) — B08 인계가 갈 곳 칸으로 판정해 보낸 어긴 자리.
판정은 성분 칸을 가진 B08 한 곳에서 한다(`B08_Quantity_Engine_Handoff_Boundaries`).
여기서는 **멈추기만** 한다 — 목록이 차 있는데 내역서를 세우면 같은 물량이 두 번 금액이 된다.
"""
if violations:
raise DoubleCountError("이중계상 경계를 어겼습니다 — " + " / ".join(violations))
#: 품셈 [주]가 「재료량에 할증 포함」이라 적은 공종 — 그 재료가 일위대가 재료비로 붙으면
#: **할증 뒤 값**이 들어가 자재총괄에서 한 번 더 붙는다(㉠). 원문 넷, 코드는 마스터가 붙인 자리.
SURCHARGE_INCLUDED_ITEMS: dict[str, str] = {
"FP-12-02": "용적 배합 콘크리트 참고표 「재료량에는 할증률이 포함」(마스터가 12-2 에 붙임)",
"FP-12-38-02": "유로폼 사용수량 [주]① 「재료량에는 재료의 할증 및 손율이 포함」",
"FP-13-11-04": "돌망태 사각형 [주]① 「자재비에는 재료의 할증을 포함」",
"AX-WK-c0842a0d": "모르타르 배합 참고자료 ※ 「위 재료량은 할증이 포함된 것이다」",
}
def check_materials_before_surcharge(book: Any) -> None:
"""③ 일위대가 재료비는 할증 전 — 할증 포함 재료량을 준 공종에 자재 줄이 붙으면 멈춘다.
⚠ 지금은 그 재료들이 자재 단가 층이 없어 **안 붙은 줄**로만 보인다 — 층이 서는 날 여기서 걸린다.
"""
from B09_Estimation.B09_Estimation_PriceBook import PriceKind
for title_code, details in book.details.items():
code = title_code[2:].split("#", 1)[0] if title_code.startswith("B-") else ""
if code not in SURCHARGE_INCLUDED_ITEMS:
continue
for detail in details:
ref = book.titles.get(detail.ref_code)
if ref is not None and ref.kind is PriceKind.MATERIAL:
raise DoubleCountError(
f"{title_code}: 자재 「{ref.name}」이 일위대가 재료비로 붙었습니다 — "
f"{SURCHARGE_INCLUDED_ITEMS[code]}. 일위대가 재료비는 할증 전 값이고 "
"할증은 자재총괄 한 곳뿐입니다 (PLAN 8-7 ㉠)."
)
def check_material_surcharge_once(materials: list[Any]) -> None:
"""③ 자재 줄 합계 = 순수량 × (1 + 할증률) **한 번** — 율이 없거나 품셈 포함이면 순수량 그대로.
내역서 자재대는 `total_amount` 를 수량으로 쓴다. 인계 도중 어디서든 할증이 한 번 더 붙으면
그 값이 그대로 금액이 되므로, **금액을 만드는 자리**에서 순수량과 율로 되짚는다.
"""
for material in materials:
net = Decimal(str(material.net_amount))
included = "할증 포함" in str(material.surcharge_note or "")
rate = None if included else material.surcharge_pct
expected = net if rate is None else net * (Decimal(1) + Decimal(str(rate)) / Decimal(100))
total = Decimal(str(material.total_amount))
if abs(total - expected) > max(Decimal("1e-6"), abs(expected) * Decimal("1e-9")):
raise DoubleCountError(
f"자재 「{material.material_name}」: 합계 {total} 가 순수량 {net} × "
f"(1+{rate or 0}%) = {expected} 와 다릅니다 — 할증이 두 번 붙었거나 빠졌습니다 "
"(PLAN 8-7 ㉠ 할증은 자재총괄 한 번)."
)
#: 제잡비 「윗단」 값을 쓴다는 뜻 — 물빼기 파이프를 **설치하는** 경우다.
#: 품셈 13-6-2 [주]③ 「… 상단에는 물빼기 파이프 설치에 관계되는 노무비, 재료비를
#: 포함한다」. 그러므로 윗단을 쓰면 파이프를 **따로 세면 안 된다**.
+30 -13
View File
@@ -31,7 +31,11 @@ from B09_Estimation.B09_Estimation_Rounding import (
OutputPlace,
round_at,
)
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build
from B09_Estimation.B09_Estimation_UnitPrice import (
HOURLY_WAGE_SUFFIX,
UnitPriceBuild,
cached_build,
)
_ZERO = Decimal(0)
@@ -55,7 +59,8 @@ def catalog_list(build: UnitPriceBuild, kind: PriceKind) -> list[dict[str, Any]]
rows: list[dict[str, Any]] = []
for code in sorted(build.book.titles):
title = build.book.titles[code]
if title.kind is not kind:
# 조종원 시간당(`…#시간`)은 기초단가가 아니라 일당에서 **셈한** 값 — 노무비목록표엔 안 실음.
if title.kind is not kind or code.endswith(HOURLY_WAGE_SUFFIX):
continue
try:
price: Decimal | None = title.adopted_price()
@@ -144,22 +149,20 @@ def resource_summary(
`quantities` = `{공종코드: 수량}` (내역서가 쓰는 것과 같은 모양).
한 자원이 여러 공종에 걸리면 **한 줄로 합친다** — 실무 시트가 그 모양이다.
⚠ **일위대가 안쪽을 한 겹만 편다.** 일위대가 → 자원(노무·자재·기계 사용료)까지가
실무 집계표의 깊이다. 기계 사용료(`X-`)를 다시 손료·연료로 쪼개면 **중기 집계표와
이중으로 세는 것**이 된다.
⚠ **자원(노무·자재·기계 사용료)까지 편다** — 일위대가 → 자원이 실무 집계표의 깊이다.
기계 사용료(`X-`)를 다시 손료·연료로 쪼개면 **중기 집계표와 이중으로 세는 것**이 된다.
⚠ 가운데 층(단계 합산 부모의 잎 일위대가 `B→B` · 단가산출 `B→D`)은 **끝까지 풀어** 자원에 닿음.
종전엔 한 겹만 펴 그런 참조를 **조용히 버렸음**(암절취 부모 · 층 차례 뒤 기계 몫, 2026-09-13).
"""
prices = build or cached_build()
book = prices.book
#: 자원코드 → [수량, 제목]
picked: dict[str, list[Any]] = {}
missing: list[str] = []
#: 풀어 내려갈 가운데 층 — 자원이 아니라 자원을 품은 표.
unfold = (PriceKind.UNIT_PRICE, PriceKind.PRICE_BASIS)
for raw_code, quantity in quantities.items():
code = raw_code if raw_code.startswith("B-") else f"B-{raw_code}"
if code not in book.titles:
missing.append(raw_code)
continue
amount = Decimal(str(quantity))
def walk(code: str, amount: Decimal, seen: tuple[str, ...]) -> None:
for detail in book.details.get(code, []):
if (
detail.percent_of_labor is not None
@@ -168,11 +171,22 @@ def resource_summary(
):
continue # 비율 줄은 자원이 아니다 — 목록표에 설 자재·노임이 없다
ref = detail.ref_code
if ref == code:
if ref == code or ref in seen:
continue
slot = picked.setdefault(ref, [_ZERO, book.titles.get(ref)])
title = book.titles.get(ref)
if title is not None and title.kind in unfold:
walk(ref, detail.quantity * amount, (*seen, ref))
continue
slot = picked.setdefault(ref, [_ZERO, title])
slot[0] += detail.quantity * amount
for raw_code, quantity in quantities.items():
code = raw_code if raw_code.startswith("B-") else f"B-{raw_code}"
if code not in book.titles:
missing.append(raw_code)
continue
walk(code, Decimal(str(quantity)), (code,))
groups: dict[str, list[dict[str, Any]]] = {
"labor": [],
"material": [],
@@ -190,6 +204,9 @@ def resource_summary(
PriceKind.MACHINE_HOURLY: "machine",
}.get(title.kind)
if bucket is None:
# 일식(W)은 단가 0 이라 자원이 아님 — 그 밖의 종류가 오면 조용히 버리지 않고 드러냄.
if title.kind is not PriceKind.LUMPSUM:
missing.append(f"{ref} (집계 칸 없는 종류 {title.kind.value})")
continue
try:
unit_money = book.resolve(ref)
@@ -31,8 +31,8 @@ from decimal import Decimal
from typing import Any
from B09_Estimation.B09_Estimation_MachineCost import (
OPERATOR_ALLOWANCE_FACTOR,
OPERATOR_ALLOWANCE_NOTICE,
hourly_operator_wage,
)
from B09_Estimation.B09_Estimation_PriceBook import PRICE_SLOT_COUNT, PriceKind
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build
@@ -169,7 +169,10 @@ def base_reference_data(
"day_wage_krw": _money(Decimal(str(wage))),
# 제수당·상여금·퇴직급여충당금 계수를 곱한 값 (2026-09-09 사용자 확정 ③).
# 근거·한계는 `MachineCost.OPERATOR_ALLOWANCE_FACTOR` 주석 한 곳에 모아 뒀다.
"hourly_krw": _money(Decimal(str(wage)) / Decimal(8) * OPERATOR_ALLOWANCE_FACTOR),
# 식 좌→우 순차 + 원 미만 절사(명세 7장 · STmate 18번 §2.1).
"hourly_krw": _money(
hourly_operator_wage(Decimal(str(wage)), digits=prices.operator_wage_digits)
),
"formula": "일당 ÷ 8시간 × 16/12 × 25/20",
}
)
+33 -9
View File
@@ -23,8 +23,9 @@ from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass, field
from decimal import Decimal
from decimal import ROUND_FLOOR, Decimal
from typing import Any
from B09_Estimation.B09_Estimation_Guards import (
@@ -73,6 +74,30 @@ OPERATOR_HOURS_PER_DAY = 8
#: ⚠ **기계 감가상각도 여기가 아니다** — 상각비는 손료(경비) 쪽이다(품셈 8-1-5 1호).
OPERATOR_ALLOWANCE_FACTOR = (Decimal(16) / Decimal(12)) * (Decimal(25) / Decimal(20))
#: 조종원 **시간당 노임 식** — 위 8시간·계수를 **식 문자열 한 줄**로(STmate 설정 `RXNM_`).
#: ⚠⚠ **왼쪽부터 차례로** 평가하고 **원 미만 절사**(명세 7장 · STmate 18번 §2.1).
#: 계수로 미리 접으면 `267,360 × 0.20833…` = 55,699.99… → 절사 55,699 로 **1원 틀림**.
#: 차례로 하면 33,420 → 534,720 → 44,560 → 1,114,000 → **55,700**(6건×3직종 18/18 일치).
#: ⚠ 위 `OPERATOR_ALLOWANCE_FACTOR` 는 근거 문구·검사용으로만 남김 — 금액에 곱하지 말 것.
OPERATOR_WAGE_FORMULA = "1/8*16/12*25/20"
def hourly_operator_wage(
daily_wage: Decimal, formula: str = OPERATOR_WAGE_FORMULA, *, digits: int | None = 0
) -> Decimal:
"""일 노임 → 시간당 노임. 식을 **좌→우 순차**로 풀고 `digits` 자리 아래 절사(`None` 이면 안 자름).
⚠ 자르는 자리는 **실무 설정**임 — 실무 여섯 건 중 다섯이 원 미만 절사(55,700),
2025 울진 소광 한 건이 0.1원 미만 절사(57,077.2). 기본은 다수인 0 자리.
"""
value = Decimal(daily_wage)
for op, number in re.findall(r"([*/]?)\s*(\d+(?:\.\d+)?)", formula):
value = value / Decimal(number) if op == "/" else value * Decimal(number)
if digits is None:
return value
return value.quantize(Decimal(1).scaleb(-digits), rounding=ROUND_FLOOR)
#: 화면·표가 그대로 띄우는 노티스 한 줄. 계수를 쓴 자리마다 같은 문구가 서야 한다.
OPERATOR_ALLOWANCE_NOTICE = (
"조종원 노임에 제수당·상여금·퇴직급여충당금 계수 1.667배(16/12 × 25/20)를 넣었습니다 — "
@@ -227,8 +252,6 @@ def hourly_machine_cost(
fuel_liters_per_hour: Decimal | None = None,
fuel_price_per_liter: Decimal | None = None,
operator_daily_wage: Decimal | None = None,
operator_hours_per_day: int = OPERATOR_HOURS_PER_DAY,
operator_allowance_factor: Decimal = OPERATOR_ALLOWANCE_FACTOR,
efficiency_factor: Decimal | None = None,
) -> HourlyMachineCost:
"""시간당 사용료 한 시간분.
@@ -257,15 +280,16 @@ def hourly_machine_cost(
# TODO(미결 PLAN 9-6): `mach_operator_map` 0건 — 기종별 운전사 직종이 품셈 본문에만 있다.
gaps.append("운전사 직종 매핑 미확보 — 노무비 성분 비어 있음")
else:
# 나눗수는 8시간 그대로 두고 **계수를 곱한다** — 나눗수를 줄이는 것과 다르다.
labor = (operator_daily_wage / Decimal(operator_hours_per_day)) * operator_allowance_factor
# ㉣ 보조 — 나눗수를 몰래 줄이면 효율을 사용료에 넣은 것이 된다.
# 나눗수는 8시간 그대로 두고 **계수를 곱한다** — 식 문자열을 좌→우로 풂(명세 7장).
exact = hourly_operator_wage(operator_daily_wage, digits=None)
# ㉣ 보조 — 나눗수를 몰래 줄이면 효율을 사용료에 넣은 것이 된다(자르기 전 값으로 봄).
check_operator_hours_basis(
labor_per_hour=labor,
labor_per_hour=exact,
daily_wage=operator_daily_wage,
hours_per_day=operator_hours_per_day,
allowance_factor=operator_allowance_factor,
hours_per_day=OPERATOR_HOURS_PER_DAY,
allowance_factor=OPERATOR_ALLOWANCE_FACTOR,
)
labor = exact.to_integral_value(rounding=ROUND_FLOOR)
return HourlyMachineCost(
machine=machine,
@@ -25,8 +25,7 @@ from functools import lru_cache
from typing import Any
from B09_Estimation.B09_Estimation_MachineCost import (
OPERATOR_ALLOWANCE_FACTOR,
OPERATOR_HOURS_PER_DAY,
hourly_operator_wage,
load_machine_catalog,
)
from B09_Estimation.B09_Estimation_PriceBook import PriceKind
@@ -150,8 +149,9 @@ def machine_expense_sheets(build: Any) -> list[dict[str, Any]]:
),
"operator_code": occupation,
"operator_daily_wage": _money(wage),
# 식 좌→우 순차 + 원 미만 절사(명세 7장) — 계수로 접으면 1원 틀림.
"operator_krw_per_hour": _money(
(wage / Decimal(OPERATOR_HOURS_PER_DAY)) * OPERATOR_ALLOWANCE_FACTOR
hourly_operator_wage(wage, digits=getattr(build, "operator_wage_digits", 0))
if wage is not None
else None
),
@@ -32,7 +32,7 @@ from __future__ import annotations
import re
from dataclasses import dataclass
from decimal import Decimal
from decimal import ROUND_HALF_UP, Decimal
from typing import Any
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
@@ -75,6 +75,18 @@ class ProductivityError(ValueError):
"""시공능력을 못 세운 경우. 0 이나 가운데값으로 때우지 않는다."""
_HUNDREDTH = Decimal("0.01")
def fix2(value: Decimal) -> Decimal:
"""**소수 2자리로 먼저 확정**(사사오입) — 작업량 Q 와 토량환산계수 f(명세 7장 · STmate 18번 §2.2).
⚠ 원값으로 나누면 어긋남 — `55,700 ÷ 15.708 = 3,545.96` ≠ `55,700 ÷ 15.71 = 3,545.5`.
f 도 `1/1.175 = 0.85` 로 먼저 자른 뒤 곱함(봉화 2024 단가산출근거 제2호표).
"""
return Decimal(value).quantize(_HUNDREDTH, rounding=ROUND_HALF_UP)
def parse_measure(cell: str) -> Decimal | None:
"""계수 셀 하나를 수로 읽는다. **확정값이 아니면 `None`.**
@@ -117,7 +129,12 @@ class CycleFactors:
def formula_text(self) -> str:
return (
f"Q = 3600 ÷ {self.cycle_seconds} × {self.bucket_capacity_m3} × "
f"{self.bucket_coefficient} × {self.volume_factor} × {self.efficiency}"
f"{self.bucket_coefficient} × {fix2(self.volume_factor)} × {self.efficiency}"
+ (
f" = {hourly_output(self)} ㎥/hr (f·Q 소수 2자리 확정)"
if self.cycle_seconds > 0
else ""
)
)
@@ -134,23 +151,23 @@ class FactorGap:
def hourly_output(factors: CycleFactors) -> Decimal:
"""시간당 작업량 `Q` (㎥/hr).
`Q = (3600 ÷ Cm) · q · K · f · E` — 품셈 8-1-4.
`Q = (3600 ÷ Cm) · q · K · f · E` — 품셈 8-1-4. f 와 Q 는 **소수 2자리로 확정**(`fix2`).
"""
if factors.cycle_seconds <= 0:
raise ProductivityError(
f"{factors.work_item_code}: 1싸이클 시간(Cm)이 {factors.cycle_seconds} 입니다."
)
cycles_per_hour = _SECONDS_PER_HOUR / factors.cycle_seconds
output = (
cycles_per_hour
_SECONDS_PER_HOUR
* factors.bucket_capacity_m3
* factors.bucket_coefficient
* factors.volume_factor
* fix2(factors.volume_factor)
* factors.efficiency
/ factors.cycle_seconds
)
if output <= 0:
raise ProductivityError(f"{factors.work_item_code}: 시간당 작업량이 {output} 입니다.")
return output
return fix2(output)
def machine_hours_per_unit(factors: CycleFactors) -> Decimal:
@@ -358,8 +375,6 @@ def attach_machine_share(
if node is None:
return _ZERO
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail
for table in node.get("tables", []):
factors = extract_cycle_factors(work_item_code, table, choices, machines)
if not isinstance(factors, CycleFactors):
@@ -381,19 +396,18 @@ def attach_machine_share(
if factors.machine_ratio_pct is None
else Decimal(str(factors.machine_ratio_pct)) / Decimal(100)
)
book.add_detail(
PriceDetail(
title_code,
hourly_code,
machine_hours_per_unit(factors) * share,
# 계수를 남의 절에서 빌려 왔으면 **그 사실을 줄 비고에 적는다.**
note=factors.formula_text
+ (
f" · {(sources or {}).get(work_item_code, '')}"
if (sources or {}).get(work_item_code)
else ""
),
)
# Q 로 선 장비 줄 — D(단가산출)에 달고 B 는 D 를 1 로 부름(층 차례 X → D → B).
book.add_output_detail(
title_code,
hourly_code,
machine_hours_per_unit(factors) * share,
# 계수를 남의 절에서 빌려 왔으면 **그 사실을 줄 비고에 적는다.**
note=factors.formula_text
+ (
f" · {(sources or {}).get(work_item_code, '')}"
if (sources or {}).get(work_item_code)
else ""
),
)
cycle_factors[work_item_code] = factors
return share * Decimal(100)
@@ -29,6 +29,8 @@ import re
from decimal import Decimal
from typing import Any
from B09_Estimation.B09_Estimation_MachineProductivity import fix2
_ZERO = Decimal(0)
#: 계수 이름 — 표 첫 칸이 「V(다짐속도,km/hr)」처럼 기호+설명이거나 「A」처럼 기호뿐이다.
@@ -105,19 +107,13 @@ def capacity_per_hour(factors: dict[str, Decimal], formula: str = FORMULA_ROLLER
"""시간당 작업량(㎥/시간). **식이 둘**이라 어느 식인지 함께 받는다.
롤러 `Q = 1000 × V × W × E × D × f / N` · 콤펙터 `Q = A × N × H × f × E / P`
⚠ f 와 Q 는 **소수 2자리로 확정**(명세 7장 — 원값으로 나누면 성분 단가가 어긋남).
"""
f = fix2(factors["f"])
if formula == FORMULA_PLATE:
return (
factors["A"] * factors["N"] * factors["H"] * factors["f"] * factors["E"] / factors["P"]
)
return (
Decimal(1000)
* factors["V"]
* factors["W"]
* factors["E"]
* factors["D"]
* factors["f"]
/ factors["N"]
return fix2(factors["A"] * factors["N"] * factors["H"] * f * factors["E"] / factors["P"])
return fix2(
Decimal(1000) * factors["V"] * factors["W"] * factors["E"] * factors["D"] * f / factors["N"]
)
@@ -30,6 +30,7 @@ from B09_Estimation.B09_Estimation_MachineProductivity import (
ProductivityError,
_first_measure,
extract_cycle_factors,
fix2,
parse_measure,
)
@@ -112,12 +113,13 @@ class DozerFactors:
def formula_text(self) -> str:
return (
f"Q = 60 ÷ {self.cycle_minutes:.4f}분 × ({self.blade_capacity_m3} × "
f"{self.distance_factor}) × {self.volume_factor} × {self.efficiency}"
f"{self.distance_factor}) × {fix2(self.volume_factor)} × {self.efficiency}"
+ (f" = {dozer_hourly_output(self)} ㎥/hr" if self.cycle_minutes > 0 else "")
)
def dozer_hourly_output(factors: DozerFactors) -> Decimal:
"""불도저 시간당 작업량 `Q` (㎥/hr). **밑수는 60(분)** 이다."""
"""불도저 시간당 작업량 `Q` (㎥/hr). **밑수는 60(분)** — f 와 Q 는 소수 2자리로 확정(명세 7장)."""
if factors.cycle_minutes <= 0:
raise ProductivityError(f"{factors.work_item_code}: 싸이클 시간이 0 이하입니다.")
blade = factors.blade_capacity_m3 * factors.distance_factor
@@ -125,12 +127,12 @@ def dozer_hourly_output(factors: DozerFactors) -> Decimal:
_MINUTES_PER_HOUR
/ factors.cycle_minutes
* blade
* factors.volume_factor
* fix2(factors.volume_factor)
* factors.efficiency
)
if output <= 0:
raise ProductivityError(f"{factors.work_item_code}: 시간당 작업량이 {output} 입니다.")
return output
return fix2(output)
def dozer_speeds(
@@ -399,8 +401,6 @@ def attach_dozer_share(
붙으면 장비를 두 번 세는 것이 된다. 그래서 부르는 쪽이 **먼저 이쪽을 보고, 안 붙었을
때만** 굴착기 쪽으로 간다.
"""
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail
node = next(
(w for w in master.get("work_items", []) if w.get("work_item_code") == work_item_code),
None,
@@ -427,13 +427,9 @@ def attach_dozer_share(
note=f"{factors.machine_name} 의 시간당 사용료가 아직 안 섰습니다.",
)
continue
book.add_detail(
PriceDetail(
title_code,
hourly_code,
dozer_machine_hours_per_unit(factors),
note=factors.formula_text,
)
# Q 로 선 장비 줄 — D(단가산출)에 달고 B 는 D 를 1 로 부름(층 차례 X → D → B).
book.add_output_detail(
title_code, hourly_code, dozer_machine_hours_per_unit(factors), factors.formula_text
)
return Decimal(100)
return _ZERO
+7 -11
View File
@@ -20,6 +20,7 @@ import re
from decimal import Decimal
from typing import Any
from B09_Estimation.B09_Estimation_MachineProductivity import fix2
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail, PriceKind, PriceTitle
ROCK_CLASSES = ("연암", "보통암", "경암")
@@ -107,10 +108,9 @@ def _add_rock_leaf(build: Any, node: dict[str, Any]) -> None:
return
variants = [(rock, q, f"작업능력 Q = {q} ㎥/hr ({rock})") for rock, q, _ in rows]
if code in AVERAGE_BASIS:
average = sum((q for _, q, _ in rows), Decimal(0)) / Decimal(len(rows))
variants.append(
(AVERAGE_VARIANT, average, f"Q = {average:.4f} ㎥/hr — {AVERAGE_BASIS[code]}")
)
# 평균 Q 도 **소수 2자리로 확정**한 뒤 나눔(명세 7장) — (5.0+3.4+2.6)/3 = 3.67.
average = fix2(sum((q for _, q, _ in rows), Decimal(0)) / Decimal(len(rows)))
variants.append((AVERAGE_VARIANT, average, f"Q = {average} ㎥/hr — {AVERAGE_BASIS[code]}"))
for variant, capacity, note in variants:
title_code = f"B-{code}#{variant}"
build.book.add_title(
@@ -123,13 +123,9 @@ def _add_rock_leaf(build: Any, node: dict[str, Any]) -> None:
)
)
for machine_code, _name in machines:
build.book.add_detail(
PriceDetail(
title_code,
f"X-{machine_code}",
Decimal(1) / capacity,
note=f"{note} · {source}",
)
# Q 로 선 장비 줄 — D(단가산출)에 달고 B 는 D 를 1 로 부름(층 차례 X → D → B).
build.book.add_output_detail(
title_code, f"X-{machine_code}", Decimal(1) / capacity, f"{note} · {source}"
)
build.variants.setdefault(code, []).append(variant)
# 표 줄은 다 읽었다 — 남는 것은 치즐뿐(자재 카탈로그가 없어 금액에 안 붙는 알려진 미결).
+60 -77
View File
@@ -1,11 +1,15 @@
"""B09 원가계산 — ③ 단가산출서 `D` 층 (PLAN 9-1 · 9-3).
"""B09 원가계산 — ③ 단가산출서 `D` 층 (PLAN 9-1 · 9-3 · 6장 층 차례).
**무엇인가** — 내역서 한 줄의 단가가 **어떻게 나왔는지** 보이는 표다. 실무 내역서는
줄마다 비고에 「단산 46 참조」처럼 **참조번호**를 적고, 그 번호의 산출서를 펴서 검산한다
(8-13 관측). STC 실측도 `D01341 절토(토사) 굴삭기0.7㎥ m³ 1,939` 처럼 **`D` 가 `B`
(일위대가)를 참조하는 한 층 위**였다.
**무엇인가** — 시공능력 **Q** 로 선 기계 몫의 단가가 **어떻게 나왔는지** 보이는 표다. 실무 내역서는
줄마다 비고에 「단산 46 참조」처럼 **참조번호**를 적고, 그 번호의 산출서를 펴서 검산한다(8-13 관측).
D 단가산출 → B 일위대가 → X 시간당 사용료 → S·M·L 카탈로그
X 시간당 중기사용료 → D 단가산출(Q 식: 시간당 성분 ÷ Q) → B 일위대가 → 내역
⚠⚠ **층 차례 정정(2026-09-13 브레인 판정 · 명세 3장)** — 종전엔 D 가 **B 를 그대로 부르는 껍데기**
(D → B 수량 1)였고 B 가 X 를 바로 불렀음(뒤집힘). 이제 D 는 **Q 쓰는 자리에만** 서고
(`PriceBook.add_output_detail`), B 가 D 를 수량 1 로 부름. 내역 코드 축은 B 하나(금액 같음).
⚠ 그래서 「단산 N」은 **D 를 품은 내역 줄에만** 붙음 — 사람 품만 있는 일위대가엔 없는 근거라 안 붙음
(STmate 도 D 가 있는 줄에만).
⚠ **표를 세 벌 만들지 않는다** (PLAN 9-3). `PriceBook` 의 「제목 + 상세」 한 쌍에
`kind` 만 `PRICE_BASIS` 로 얹는다 — 일위대가와 같은 구조, 같은 화면 모양이다.
@@ -20,16 +24,14 @@ from dataclasses import dataclass, field
from decimal import Decimal
from typing import Any
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail, PriceKind, PriceTitle
from B09_Estimation.B09_Estimation_PriceBook import PriceBook, PriceKind
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build
_ONE = Decimal(1)
@dataclass
class PriceBasisEntry:
"""단가산출서 한 장 — 참조번호 + 그 줄이 무엇을 참조하는지."""
"""단가산출서 한 장 — 참조번호 + 그 산출을 부르는 일위대가."""
number: int
code: str
@@ -37,8 +39,10 @@ class PriceBasisEntry:
spec: str
unit: str
unit_price_krw: Decimal
#: 이 산출서가 참조하는 일위대가 코드. 화면에서 눌러 내려가는 자리.
#: 이 산출서를 **처음 부른** 일위대가 코드 — 화면에서 눌러 내려가는 자리.
ref_code: str
#: 이 산출서를 부르는 내역 일위대가 전부(갈래·단계 합산 부모 포함).
unit_price_codes: list[str] = field(default_factory=list)
@property
def label(self) -> str:
@@ -65,58 +69,66 @@ class PriceBasisSheet:
entries: list[PriceBasisEntry] = field(default_factory=list)
def by_unit_price(self, ref_code: str) -> PriceBasisEntry | None:
return next((entry for entry in self.entries if entry.ref_code == ref_code), None)
return next((e for e in self.entries if ref_code in e.unit_price_codes), None)
def label_for(self, ref_code: str) -> str:
"""그 일위대가가 품은 산출서 번호 전부 — 단계 합산 부모는 둘 이상일 수 있음(「단산 3·4」)."""
numbers = [str(e.number) for e in self.entries if ref_code in e.unit_price_codes]
return f"단산 {'·'.join(numbers)} 참조" if numbers else ""
def as_dict(self) -> dict[str, Any]:
return {"entries": [entry.as_dict() for entry in self.entries]}
def _bases_of(book: PriceBook, code: str, seen: tuple[str, ...] = ()) -> list[str]:
"""일위대가가 품은 D — 단계 합산 부모(B → 잎 B)는 잎까지 내려가 찾음. 줄 차례 그대로."""
found: list[str] = []
for detail in book.details.get(code, []):
ref = detail.ref_code
title = book.titles.get(ref)
if title is None or ref == code or ref in seen:
continue
if title.kind is PriceKind.PRICE_BASIS:
found.append(ref)
elif title.kind is PriceKind.UNIT_PRICE:
found.extend(b for b in _bases_of(book, ref, (*seen, code)) if b not in found)
return found
def build_price_basis(
unit_price_codes: list[str],
build: UnitPriceBuild | None = None,
) -> PriceBasisSheet:
"""내역서에 쓰인 일위대가마다 산출서 한 장을 세운다.
"""내역에 쓰인 일위대가가 품은 **단가산출(D)** 마다 한 장.
번호는 **쓰인 차례**로 매긴다 — 실무 참조번호가 그 내역서 안의 차례이기 때문이다.
같은 일위대가가 두 줄에 쓰이면 **산출서는 한 장**이고 두 줄이 같은 번호를 가리킨다.
같은 D 를 두 줄이 부르면 **산출서는 한 장**이고 두 줄이 같은 번호를 가리킨다.
⚠ D 가 없는 일위대가(사람 품만)는 산출서가 없음 — 없는 근거를 가리키지 않음.
"""
prices = build or cached_build()
book = prices.book
sheet = PriceBasisSheet()
seen: set[str] = set()
numbered: dict[str, PriceBasisEntry] = {}
for code in unit_price_codes:
if not code or code in seen or code not in prices.book.titles:
if not code or code not in book.titles:
continue
seen.add(code)
title = prices.book.title(code)
money = prices.book.resolve(code)
basis_code = f"D-{code[2:]}" if code.startswith("B-") else f"D-{code}"
if basis_code not in prices.book.titles:
prices.book.add_title(
PriceTitle(
code=basis_code,
kind=PriceKind.PRICE_BASIS,
for basis in _bases_of(book, code):
entry = numbered.get(basis)
if entry is None:
title = book.title(basis)
entry = PriceBasisEntry(
number=len(sheet.entries) + 1,
code=basis,
name=title.name,
spec=title.spec,
unit=title.unit,
unit_price_krw=round_at(book.resolve(basis).total, OutputPlace.UNIT_PRICE_ROW),
ref_code=code,
)
)
# ⚠ 지금은 **일위대가를 그대로 한 줄로** 참조한다. 할증·기타 비용이 붙는
# 자리가 생기면 여기에 줄이 는다 — 구조를 미리 열어 둔다.
prices.book.add_detail(PriceDetail(basis_code, code, _ONE, note="일위대가 그대로"))
sheet.entries.append(
PriceBasisEntry(
number=len(sheet.entries) + 1,
code=basis_code,
name=title.name,
spec=title.spec,
unit=title.unit,
unit_price_krw=round_at(money.total, OutputPlace.UNIT_PRICE_ROW),
ref_code=code,
)
)
sheet.entries.append(entry)
numbered[basis] = entry
if code not in entry.unit_price_codes:
entry.unit_price_codes.append(code)
return sheet
@@ -124,42 +136,13 @@ def price_basis_detail(
code: str,
build: UnitPriceBuild | None = None,
) -> dict[str, Any]:
"""산출서 한 장의 본표 — 무엇을 참조해 그 단가가 나왔는지.
"""산출서 한 장의 본표 — Q 식 줄(시간당 중기사용료 × 1/Q)과 성분 합.
일위대가 본표와 **같은 모양**이라 화면이 같은 표를 쓴다.
일위대가 본표와 **같은 모양**(`detail_of`)이라 화면이 같은 표를 쓴다.
"""
from B09_Estimation.B09_Estimation_UnitPrice import detail_of
prices = build or cached_build()
title = prices.book.title(code)
rows: list[dict[str, Any]] = []
for detail in prices.book.details.get(code, []):
child = prices.book.title(detail.ref_code)
money = prices.book.resolve(detail.ref_code).scaled(detail.quantity)
rows.append(
{
"code": detail.ref_code,
"name": child.name,
"spec": child.spec,
"unit": child.unit,
"quantity": str(detail.quantity),
"total": str(round_at(money.total, OutputPlace.UNIT_PRICE_ROW)),
"drillable": True,
"note": detail.note,
}
)
money = prices.book.resolve(code)
return {
"code": code,
"name": title.name,
"spec": title.spec,
"unit": title.unit,
"rows": rows,
"total": str(round_at(money.total, OutputPlace.UNIT_PRICE_ROW)),
"material": str(money.material),
"labor": str(money.labor),
"expense": str(money.expense),
# 한 층 아래(일위대가) 본표를 그대로 딸려 보낸다 — 화면이 두 번 물어보지 않게.
"unit_price": detail_of(prices, rows[0]["code"]) if rows else None,
}
if prices.book.title(code).kind is not PriceKind.PRICE_BASIS:
raise LookupError(f"단가산출서 코드가 아닙니다: {code}")
return detail_of(prices, code)
+52 -5
View File
@@ -20,7 +20,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
from decimal import Decimal
from decimal import ROUND_FLOOR, Decimal
from enum import Enum
_ZERO = Decimal(0)
@@ -57,6 +57,15 @@ class PriceKind(str, Enum):
#: 카탈로그 층 — 상세를 갖지 않고 값이 바로 있는 종류.
CATALOG_KINDS = frozenset({PriceKind.MATERIAL, PriceKind.LABOR, PriceKind.MACHINE_BASE})
#: **호표 안에서 자르는** 층 — 줄 금액은 0.1원 미만, 성분 소계는 원 미만 절사(명세 7장).
#: 근거 STmate 17번 — 중기사용료 호표 성분 소계 398/398 절사
#: (굴삭기 0.7㎥ 23,128 + 55,700 + 18,015 = 96,843 · 골든셋 실무 143 호표 전수).
#: D(단가산출)도 같음 — 줄 `중기 성분 ÷ Q` 0.1원 · 머리 성분 원 미만(봉화 2024 제2호표 3,545.5 → 3,545).
#: ⚠ 일위대가(B) 호표도 같은 규칙(345 중 94.2%)이나 **아래층(D)부터 맞춘 뒤** 붙임(PLAN 6장 판정).
TRUNCATED_KINDS = frozenset({PriceKind.MACHINE_HOURLY, PriceKind.PRICE_BASIS})
_TENTH = Decimal("0.1")
_WON = Decimal(1)
class PriceBookError(LookupError):
"""단가 조립이 성립하지 않는 경우. 0 으로 때우지 않고 멈춘다."""
@@ -84,6 +93,15 @@ class Money3:
def scaled(self, factor: Decimal) -> Money3:
return Money3(self.material * factor, self.labor * factor, self.expense * factor)
def floored(self, unit: Decimal) -> Money3:
"""성분마다 `unit` 자리 아래 절사 — 호표 줄(0.1원)·성분 소계(원) 자르기."""
return Money3(
*(
v.quantize(unit, rounding=ROUND_FLOOR)
for v in (self.material, self.labor, self.expense)
)
)
@dataclass
class PriceTitle:
@@ -181,6 +199,30 @@ class PriceBook:
def add_detail(self, detail: PriceDetail) -> None:
self.details.setdefault(detail.parent_code, []).append(detail)
def add_output_detail(
self, parent_code: str, ref_code: str, quantity: Decimal, note: str = ""
) -> str:
"""**시공능력 Q** 로 선 장비 줄 — B 에 바로 안 달고 **D(단가산출)** 에 달고 B 는 D 를 1 로 부름.
층 차례 `X → D → B`(명세 3장 정정 · PLAN 6장 판정): D 는 Q 쓰는 자리에만 서고, 내역 코드 축은
B 하나라 B → D 수량 1(금액 같음). 한 B 에 Q 줄이 여럿이면 **D 한 장**에 모임. D 코드를 돌려줌.
"""
parent = self.title(parent_code)
basis_code = f"D-{parent_code[2:]}" if parent_code.startswith("B-") else f"D-{parent_code}"
if basis_code not in self.titles:
self.add_title(
PriceTitle(
code=basis_code,
kind=PriceKind.PRICE_BASIS,
name=parent.name,
spec=parent.spec,
unit=parent.unit,
)
)
self.add_detail(PriceDetail(parent_code, basis_code, Decimal(1), note="단가산출(Q)"))
self.add_detail(PriceDetail(basis_code, ref_code, quantity, note=note))
return basis_code
def title(self, code: str) -> PriceTitle:
try:
return self.titles[code]
@@ -207,6 +249,8 @@ class PriceBook:
raise PriceBookError(f"{code} ({title.name}): 상세 줄이 없어 단가를 조립할 수 없습니다")
total = Money3()
# 호표 안에서 자르는 층이면 줄 금액을 0.1원 미만 절사해 쌓고 끝에 성분 소계를 원 미만 절사.
truncate = title.kind in TRUNCATED_KINDS
# 제잡비 밑수로 쓸 **사람 품(직접노무비)** — 기계 줄 안의 조종원 노임은 안 센다.
# 근거는 아래 `percent_of_labor` 자리 주석의 인용 셋.
direct_labor = Decimal(0)
@@ -264,9 +308,10 @@ class PriceBook:
# ⚠ 밑수가 **명시된 잡재료를 뺀 주재료비**여야 하는데, 품셈이 명시한 잡재료는
# 이미 자원 줄로 서 있어 이 밑수에 함께 든다 — 그 공종에 잡재료가 명시돼
# 있으면 1-2-6 이 애초에 안 걸리는 자리이므로 **칸을 비워 두는 것이 맞다.**
total = total + Money3(
material=direct_material * row.percent_of_material / Decimal(100)
)
# ⚠ 중기(X) 호표의 **잡품**도 이 줄 — 「주연료비 × 율」의 가산 행(STmate 17번 §3,
# 잡품 단가 칸 = 연료 금액). 수량 × 단가로 연료 수량에 접으면 줄이 사라짐.
share = Money3(material=direct_material * row.percent_of_material / Decimal(100))
total = total + (share.floored(_TENTH) if truncate else share)
continue
child = self.resolve(row.ref_code, (*_seen, code))
@@ -275,12 +320,14 @@ class PriceBook:
total = total + total.scaled(row.percent_of_parent / Decimal(100))
continue
scaled = child.scaled(row.quantity)
if truncate:
scaled = scaled.floored(_TENTH)
if self.titles[row.ref_code].kind is PriceKind.LABOR:
direct_labor = direct_labor + scaled.labor
if self.titles[row.ref_code].kind is PriceKind.MATERIAL:
direct_material = direct_material + scaled.material
total = total + scaled
return total
return total.floored(_WON) if truncate else total
def material_base(self, code: str) -> Decimal:
"""공구손료·잡재료(산림품셈 1-2-6)의 **밑수** — 그 항목에 바로 붙은 자재 줄의 합.
@@ -23,6 +23,7 @@
from __future__ import annotations
import logging
from decimal import ROUND_HALF_UP, Decimal
#: (이름에 들어 있는 말, 그 단위) → 소수자리. **원문 표 순서 그대로** 옮겼다.
@@ -68,13 +69,22 @@ _DIGITS: tuple[tuple[tuple[str, ...], tuple[str, ...], int], ...] = (
#: 사면적·거푸집 면적까지 1자리로 자르면 틀린다. 확실한 ㎥ 만 잡는다.
_UNIT_ONLY = {"㎥": 2, "m3": 2}
logger = logging.getLogger(__name__)
#: 못 맞춘 줄을 이미 알린 (이름, 단위) — 내역을 그릴 때마다 같은 로그가 쌓이지 않게.
_UNMATCHED_LOGGED: set[tuple[str, str]] = set()
def _tight(text: str) -> str:
return "".join(str(text or "").split())
def digits_for(name: str, unit: str, spec: str = "") -> int | None:
"""그 줄의 수량 소수자리. **표에 없으면 `None`** — 지어내지 않는다."""
"""그 줄의 수량 소수자리. **표에 없으면 `None`** — 지어내지 않는다.
⚠ 이름으로 종목을 찾는다 — 품셈 1-2-2 표 자체가 종목 이름 표라 코드로 옮길 대응표가 없음
(금액이 아닌 표시 자리). **이름을 못 맞춘 줄은 로그**로 남긴다 — 마스터 갈래 키(1장)가
서면 함께 정리함(2026-09-13 브레인 판정: 이름은 그대로 두고 못 맞춘 것만 드러냄).
"""
haystack = _tight(name) + _tight(spec)
unit_tight = _tight(unit)
for words, units, digits in _DIGITS:
@@ -82,6 +92,16 @@ def digits_for(name: str, unit: str, spec: str = "") -> int | None:
continue
if any(word in haystack for word in words):
return digits
key = (_tight(name), unit_tight)
if key not in _UNMATCHED_LOGGED:
_UNMATCHED_LOGGED.add(key)
fallback = _UNIT_ONLY.get(unit_tight)
logger.info(
"B09 수량 자리 — 종목 이름을 못 맞춤: 「%s」 %s → %s",
name,
unit,
"단위만으로 %s자리" % fallback if fallback is not None else "표시 자리 없음",
)
return _UNIT_ONLY.get(unit_tight)
@@ -240,6 +240,8 @@ class ResourceRow:
#: ⚠ **이 값을 안 보면 단가가 조용히 틀린다** — 인력 몫 원단위를 전량에 곱하게 된다
#: (2026-09-08 실측: 측구터파기 39,575.6원/㎥ 이 인력 10 % 몫만이었다).
group_ratio_pct: Decimal | None = None
#: 이 줄을 읽어 낸 품셈 판 — `pum_table_id`·`work_item_code` 가 판에 묶임(명세 17장).
pum_edition: str = ""
def as_dict(self) -> dict[str, Any]:
return {
@@ -258,6 +260,7 @@ class ResourceRow:
None if self.alternative_amount is None else str(self.alternative_amount)
),
"group_ratio_pct": None if self.group_ratio_pct is None else str(self.group_ratio_pct),
"pum_edition": self.pum_edition,
}
@@ -441,6 +444,19 @@ def match_table(
# 「구 분 | 콘크리트공(인) | 보통인부(인)」처럼 **열이 자원**이고 행은 규격 갈래
# (무근·철근·소형구조물)다. 행을 자원으로 읽는 길로 보내면 통째로 못 맞춘다 —
# 콘크리트 타설(12-1)이 그래서 하나도 안 서고 있었다.
# 공정 줄을 더해 한 품이 되는 표(단끊기 5-16-1) — 사람이 적은 공종만. 아래 길은 표째 버린다.
from B09_Estimation.B09_Estimation_ResourceAxis_ProcessSum import match_process_sum_table
if match_process_sum_table(node, table, catalog, result, basis_quantity, unit):
return
# 머리가 두 단으로 병합된 표(인력 돌쌓기 13-4-1·13-4-4) — hwpx 병합으로 확인한 공종만.
from B09_Estimation.B09_Estimation_ResourceAxis_TwoLevelHeader import (
match_two_level_header_table,
)
if match_two_level_header_table(node, table, catalog, result, basis_quantity, unit):
return
from B09_Estimation.B09_Estimation_ResourceAxis_Transposed import match_transposed_table
if match_transposed_table(node, table, catalog, result, basis_quantity, unit):
@@ -653,6 +669,8 @@ def build_resource_axis(master: dict[str, Any], catalog: ResourceCatalog) -> Axi
for node in master.get("work_items", []):
for table in node.get("tables", []):
match_table(node, table, catalog, result)
for row in result.rows:
row.pum_edition = edition
return result
@@ -0,0 +1,120 @@
"""B09 원가계산 — **공정 줄을 더해 한 품이 되는 표** 읽기 (자원 축 보조, 2026-09-13).
공정별 | 보통인부(인) | 비고
보통토사 | 경질ㆍ고사점토 및 자갈섞인 점토 | 호박돌 섞인 토사
절 취 | 2.4 | 3.3 | 5.4
수평잡기 및 단정리 | 0.34 | 0.34 | 0.34
…
합계 | 2.03 | 2.09 | 2.23
행은 공정, 열은 토질 갈래, 자원은 머리 한 칸. 갈래마다 공정 줄을 **더한 것**이 한 품이다.
지금은 「숫자 칸이 자원 열보다 많다」로 표째 버려져 단끊기 5-16-1 이 한 줄도 안 섰다.
⚠ **모양만으로는 「더하는 표」인지 못 가른다** — 뭉기기 13-12-1 은 같은 모양인데 줄마다 단위가
달라(절취 ㎥ · 면고르기 시간/㎡) 더하면 틀린다. 그래서 **사람이 적은 공종만** 더한다.
⚠ 원문 합계 줄은 안 읽는다 — 줄 합과 다르면 **까닭을 적어 둔 공종만** 서고, 아니면 막는다.
"""
from __future__ import annotations
from decimal import Decimal
from typing import Any
from B09_Estimation.B09_Estimation_ResourceAxis import (
AxisResult,
ResourceCatalog,
ResourceRow,
UnmatchedRow,
parse_amount,
)
_TOTAL_LABELS = ("계", "합계", "소계", "총계")
#: 공종 → 근거 · 원문 합계 줄과 다를 때의 까닭 · [주]가 더하라는 직종(코드, 보통인부 몇 인당 1인).
PROCESS_SUM: dict[str, dict[str, Any]] = {
"FP-05-16-01": {
"basis": (
"품셈 5-16-1 표 머리 (인/100m당) · [주]④ 「수평잡기 및 단정리, 잡석 및 뿌리정리,"
" 성토면고르기, 고르기품은 100m당 품」 · [주]③ 절취량 0.15㎥/m 기준"
),
"total_mismatch": (
"원문 합계 줄(2.03·2.09·2.23)은 절취를 ㎥당(2.4÷15=0.16)으로 더한 값이라 줄 합과 다름"
" — 100m당 줄을 더함(STmate 단끊기 산출과 같음)"
),
"per_workers": ("1001", Decimal(20), "[주]⑤ 작업반장 1인/보통인부 20인 가산"),
},
}
def _tight(text: Any) -> str:
return "".join(str(text or "").split())
def match_process_sum_table(
node: dict[str, Any],
table: dict[str, Any],
catalog: ResourceCatalog,
result: AxisResult,
basis_quantity: Decimal | None,
unit: str,
) -> bool:
"""적어 둔 공종의 공정 합산 표를 읽는다. 그 공종이 아니면 `False`."""
from B09_Estimation.B09_Estimation_ResourceAxis_Transposed import transposed_columns
code = str(node.get("work_item_code") or "")
spec = PROCESS_SUM.get(code)
if spec is None:
return False
table_id = str(table.get("pum_table_id", ""))
rows = [[str(c).strip() for c in row] for row in (table.get("raw_row") or [])]
columns = transposed_columns(table, catalog)
def block(cell: str, why: str) -> bool:
result.unmatched.append(UnmatchedRow(code, table_id, cell, why))
result.partial_items[code] = why
return True
labels = [cell for cell in (rows[0] if rows else []) if cell]
if len(columns) != 1 or len(labels) < 2 or basis_quantity in (None, 0):
return block(code, "공정 합산 표 모양이 아닙니다(자원 한 칸 · 갈래 줄 · 밑수)")
sums = [Decimal(0)] * len(labels)
totals: list[Decimal] | None = None
for cells in rows[1:]:
numbers = [value for value in (parse_amount(c) for c in cells[1:]) if value is not None]
if not cells or not cells[0] or not numbers:
continue
if len(numbers) != len(labels):
return block(cells[0], f"값 {len(numbers)} 개가 갈래 {len(labels)} 개와 안 맞습니다")
if _tight(cells[0]) in _TOTAL_LABELS:
totals = numbers
continue
sums = [total + value for total, value in zip(sums, numbers)]
if totals is not None and totals != sums and not spec.get("total_mismatch"):
return block("합계", f"원문 합계 {totals} 가 줄 합 {sums} 와 다릅니다")
entry = columns[0][1]
members = [(entry, Decimal(1))]
if spec.get("per_workers"):
chief_code, workers, _note = spec["per_workers"]
chief = next((e for e in catalog.entries if e.code == chief_code), None)
if chief is None:
return block(chief_code, "[주]가 더하라는 직종이 카탈로그에 없습니다")
members.append((chief, Decimal(1) / workers))
for label, total in zip(labels, sums):
for member, ratio in members:
result.rows.append(
ResourceRow(
work_item_code=code,
pum_table_id=table_id,
pum_form=str(table.get("pum_form", "")),
resource_kind=member.kind,
resource_code=member.code,
resource_name=member.name,
resource_spec=member.spec,
amount=total * ratio / basis_quantity,
amount_unit=unit,
raw_row_index=0,
variant=label,
)
)
return True
@@ -0,0 +1,126 @@
"""B09 원가계산 — **머리가 두 단으로 병합된 표** 읽기 (자원 축 보조, 2026-09-13).
인력 돌쌓기 13-4-1 메쌓기 · 13-4-4 찰쌓기 — hwpx 원본의 병합 칸으로 머리 모양을 확인함.
뒷길이(㎝) | 견치돌(4칸) | 깬돌(4칸) | 깬잡석(4칸) | 호박돌 및 야면석(2칸·두 줄 병합)
| 골쌓기 켜쌓기 | 골쌓기 켜쌓기 | 골쌓기 켜쌓기 |
| 석공 | 보통인부 × 7 벌
35 | 0.50 | 0.40 | 0.55 | 0.44 | … | 0.16 | 0.14
마스터는 병합을 풀어 칸만 늘어놓아 **어느 값이 어느 돌·쌓기인지**가 사라졌다 — 행 읽기 길은
「값 묶음 0 개가 갈래 6 개와 안 맞습니다」로 표째 버렸다.
읽는 법 — 자원 줄의 되풀이 폭(석공·보통인부 = 2)으로 **잎 칸 수**(14 ÷ 2 = 7)를 얻고,
소분류 줄의 되풀이(골·켜 × 3)가 **앞의 대분류 셋**을 두 잎씩 먹고, 남은 대분류가 한 잎씩 먹는다.
⚠ 「앞에서부터」는 hwpx 병합으로 확인한 두 표에만 참이다 — **적어 둔 공종만** 읽고,
칸 수가 딱 맞지 않으면 한 줄도 안 세우고 막는다.
"""
from __future__ import annotations
from decimal import Decimal
from typing import Any
from B09_Estimation.B09_Estimation_ResourceAxis import (
AxisResult,
ResourceCatalog,
ResourceRow,
UnmatchedRow,
parse_amount,
)
#: 공종 → 확인 근거. 여기 없는 공종은 이 길로 안 읽는다.
TWO_LEVEL_HEADER: dict[str, str] = {
"FP-13-04-01": "hwpx 병합 — 견치돌·깬돌·깬잡석 4칸(골·켜) · 호박돌 및 야면석 2칸(행 병합)",
"FP-13-04-04": "hwpx 병합 — 견치돌·깬돌·깬잡석 4칸(골·켜) · 호박돌 및 야면석 2칸(행 병합)",
}
_ABSENT = ("-", "-", "–", "—")
def _label(text: Any) -> str:
"""「견 치 돌」처럼 벌려 쓴 한 낱말은 붙이고, 「호박돌 및 야면석」은 그대로."""
parts = str(text or "").split()
return "".join(parts) if parts and all(len(p) == 1 for p in parts) else " ".join(parts)
def _period(items: list[str]) -> int:
"""되풀이 폭 — 딱 나뉘는 가장 짧은 주기. 없으면 전체 길이."""
for width in range(1, len(items) + 1):
if len(items) % width == 0 and items == items[:width] * (len(items) // width):
return width
return len(items)
def match_two_level_header_table(
node: dict[str, Any],
table: dict[str, Any],
catalog: ResourceCatalog,
result: AxisResult,
basis_quantity: Decimal | None,
unit: str,
) -> bool:
"""적어 둔 공종의 두 단 머리 표를 읽는다. 그 공종이 아니면 `False`."""
from B09_Estimation.B09_Estimation_CrewOutput import _resolve
from B09_Estimation.B09_Estimation_ResourceAxis_ThreeAxis import _axis_label
code = str(node.get("work_item_code") or "")
if code not in TWO_LEVEL_HEADER:
return False
table_id = str(table.get("pum_table_id", ""))
head = list(table.get("condition_note") or [])
rows = [[str(c).strip() for c in row] for row in (table.get("raw_row") or [])]
def block(why: str) -> bool:
result.unmatched.append(UnmatchedRow(code, table_id, " | ".join(head), why))
result.partial_items[code] = why
return True
names = [c for c in rows[1] if c] if len(rows) >= 3 else []
entries = [_resolve(catalog, "".join(name.split())) for name in names]
if len(head) < 3 or len(names) < 2 or not all(entries):
# 같은 절에 딸린 다른 표(뒷길이 표준·표준경사)다 — 원래 길로 보낸다.
return False
if basis_quantity in (None, 0):
return block("두 단 머리 표에 밑수가 없습니다")
groups = [_label(g) for g in head[1:]]
subs = [_label(c) for c in rows[0] if c]
width = _period([e.code for e in entries])
leaves = len(names) // width
cycle = _period(subs)
split = len(subs) // cycle
if split > len(groups) or split * cycle + (len(groups) - split) != leaves:
return block(f"대분류 {len(groups)} · 소분류 {len(subs)} · 잎 {leaves} 칸이 딱 안 나뉩니다")
leaf_labels = [[g, s] for g in groups[:split] for s in subs[:cycle]]
leaf_labels += [[g] for g in groups[split:]]
staged: list[ResourceRow] = []
for index, cells in enumerate(rows[2:], start=2):
values = [c for c in cells[1:] if c]
if not cells or not cells[0]:
continue
readable = all(v in _ABSENT or parse_amount(v) is not None for v in values)
if len(values) != len(names) or not readable:
return block(
f"뒷길이 {cells[0]} 줄의 값 {len(values)} 칸이 자원 {len(names)} 칸과 안 맞습니다"
)
for position, (value, entry) in enumerate(zip(values, entries)):
if value in _ABSENT:
continue # 그 돌·뒷길이엔 품이 없다
labels = leaf_labels[position // width]
staged.append(
ResourceRow(
work_item_code=code,
pum_table_id=table_id,
pum_form=str(table.get("pum_form", "")),
resource_kind=entry.kind,
resource_code=entry.code,
resource_name=entry.name,
resource_spec=entry.spec,
amount=parse_amount(value) / basis_quantity,
amount_unit=unit,
raw_row_index=index,
variant=" · ".join([*labels, _axis_label(_label(head[0]), cells[0])]),
)
)
result.rows.extend(staged)
return True
+16 -3
View File
@@ -275,6 +275,8 @@ async def _build_for(project_id: UUID):
str(settings.get("transport_road") or ""),
# 품 할인·할증(1-4) — **안 고르면 안 붙는다.**
tuple(sorted(parse_labor_surcharge(settings.get("labor_surcharge")).items())),
# 조종원 시간당 노임 자르는 자리 — 실무마다 다름(명세 7장 정정). 안 정하면 원 미만.
str(settings.get("operator_wage_digits") or ""),
)
@@ -753,9 +755,12 @@ async def get_bill(project_id: UUID) -> JSONResponse:
⚠ 단가가 없거나 밑수를 모르는 줄은 **0 으로 안 때우고** `missing` 으로 드러낸다 —
화면이 그 목록을 그대로 보인다.
"""
from B08_Quantity.B08_Quantity_Router_Material import get_handoff
from B08_Quantity.B08_Quantity_Router_Material import get_handoff, structure_bill_prices
try:
# ⚠ **그 프로젝트가 고른 값**으로 조립한 단가표 — 구조물도 미리보기(`_build_for`)와 한 벌.
# 기본값(`cached_build()`)을 쓰면 계수를 고른 프로젝트에서 화면과 내역이 갈림(브레인).
build = await _build_for(project_id)
response = await get_handoff(project_id)
payload = json.loads(bytes(response.body).decode("utf-8"))
except Exception:
@@ -767,9 +772,16 @@ async def get_bill(project_id: UUID) -> JSONResponse:
if "work_items" not in payload:
# B08 이 오류 응답을 준 경우 — 그 사유를 그대로 넘긴다(감추지 않는다).
return JSONResponse(status_code=502, content={"status": "error", **payload})
try:
# 구조물도 호표 금액 — **이 내역과 같은 단가표**로(PLAN 6장 ②).
structure_prices = await structure_bill_prices(project_id, build)
except Exception:
# 못 세면 호표 줄이 금액 없이 「못 받음」 사유로 섬 — 조용히 0 원 안 됨.
logger.exception("B09 내역서 — 구조물도 호표 금액 실패: project_id=%s", project_id)
structure_prices = {}
try:
result = build_bill(payload)
result = build_bill(payload, build=build, structure_prices=structure_prices)
except DoubleCountError as error:
# 이중계상 감시에 걸린 경우 — 표를 그리지 않고 멈춘다.
logger.warning("B09 내역서 이중계상 감지: project_id=%s, %s", project_id, error)
@@ -803,7 +815,8 @@ async def get_price_basis_detail(project_id: UUID, code: str) -> JSONResponse:
from B09_Estimation.B09_Estimation_PriceBasis import price_basis_detail
try:
body = price_basis_detail(code)
# 내역서와 같은 단가표 — 번호·금액이 내역 줄과 갈리지 않게.
body = price_basis_detail(code, await _build_for(project_id))
except Exception:
logger.exception("B09 단가산출서 조회 실패: project_id=%s, code=%s", project_id, code)
return JSONResponse(
+151 -57
View File
@@ -17,6 +17,7 @@
from __future__ import annotations
import logging
import re
from dataclasses import dataclass, field
from dataclasses import replace as dataclass_replace
@@ -26,7 +27,8 @@ from typing import Any
from B09_Estimation.B09_Estimation_Guards import check_column_sums, check_surcharge_once
from B09_Estimation.B09_Estimation_MachineCost import (
OPERATOR_ALLOWANCE_FACTOR,
OPERATOR_WAGE_FORMULA,
hourly_operator_wage,
load_machine_catalog,
)
from B09_Estimation.B09_Estimation_MachineProductivity import (
@@ -34,6 +36,7 @@ from B09_Estimation.B09_Estimation_MachineProductivity import (
FactorGap,
attach_machine_share,
extract_cycle_factors,
fix2,
)
from B09_Estimation.B09_Estimation_MachineProductivity_Dozer import (
attach_dozer_share,
@@ -66,6 +69,9 @@ from B09_Estimation.B09_Estimation_WorkItemUnit import unit_of as work_item_unit
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
from B09_Estimation.B09_Estimation_Transport import parse_distance_km
logger = logging.getLogger(__name__)
#: 조종원 **시간당 노임** 제목 꼬리 — 일 노임 제목(`L…`)과 갈라 둠. 노무비목록표엔 안 실림(일당만).
HOURLY_WAGE_SUFFIX = "#시간"
_RE_EMPHASIS = re.compile(r"\*\*(.+?)\*\*")
_ZERO = Decimal(0)
#: 연료는 자재 카탈로그에 없어 합성 코드로 세운다 — 코드가 있어야 조인이 성립한다.
@@ -89,6 +95,8 @@ def _slots(value: Decimal) -> list[Decimal | None]:
@dataclass
class UnitPriceBuild:
book: PriceBook = field(default_factory=PriceBook)
#: 조종원 시간당 노임을 자른 자리(프로젝트 설정 `operator_wage_digits`, 기본 0 = 원 미만).
operator_wage_digits: int = 0
#: 세우지 못한 공종 — 값이 안 서는 것을 빈 줄로 두지 않는다.
skipped: list[str] = field(default_factory=list)
#: 기계 성분이 비어 있는 채로 쓰인 기종 (연료·조종원 미확보).
@@ -216,31 +224,57 @@ def _apply_combined_misc_rate(book: PriceBook, work_item_titles: list[str]) -> i
"""
swapped = 0
for title_code in work_item_titles:
details = book.details.get(title_code) or []
if not any(detail.ref_code.startswith(_ATTACHMENT_PREFIXES) for detail in details):
# 장비 줄은 B 에 바로(표 시간) 또는 그 D(단가산출, Q) 에 있음 — 둘을 한 묶음으로 봄(층 차례).
own = book.details.get(title_code) or []
group = [
own,
*(book.details.get(d.ref_code) or [] for d in own if d.ref_code.startswith("D-")),
]
if not any(
d.ref_code.startswith(_ATTACHMENT_PREFIXES) for details in group for d in details
):
continue
for index, detail in enumerate(details):
if not detail.ref_code.startswith("X-") or detail.ref_code.startswith(
_ATTACHMENT_PREFIXES
):
continue
combined = f"{detail.ref_code}#조합"
if combined not in book.titles:
continue
details[index] = dataclass_replace(
detail,
ref_code=combined,
note=(
(detail.note + " · " if detail.note else "")
+ "조합 사용 — 잡재료 16% (품셈 제8장 [주]⑤)"
),
)
swapped += 1
for details in group:
for index, detail in enumerate(details):
if not detail.ref_code.startswith("X-") or detail.ref_code.startswith(
_ATTACHMENT_PREFIXES
):
continue
combined = f"{detail.ref_code}#조합"
if combined not in book.titles:
continue
details[index] = dataclass_replace(
detail,
ref_code=combined,
note=(
(detail.note + " · " if detail.note else "")
+ "조합 사용 — 잡재료 16% (품셈 제8장 [주]⑤)"
),
)
swapped += 1
return swapped
#: 중기 호표 잡품 줄 이름표 — 화면이 「공구손료·잡재료(1-2-6)」와 가르는 데 씀.
MISC_NOTE_PREFIX = "잡품"
def _misc_row(hourly_code: str, percent: Decimal) -> PriceDetail:
"""중기 호표 **잡품** — 주연료비의 % 가 재료비로 붙는 가산 행(자기 호표를 가리키는 비율 줄)."""
return PriceDetail(
hourly_code,
hourly_code,
Decimal(0),
note=f"{MISC_NOTE_PREFIX} — 주연료비 × {percent}%",
percent_of_material=percent,
)
def _add_machine_layers(
book: PriceBook, machine_codes: set[str], fuel_region: str | None = None
book: PriceBook,
machine_codes: set[str],
fuel_region: str | None = None,
operator_wage_digits: int = 0,
) -> list[str]:
"""`S`(취득가) · `L`(운전사) · `M`(연료) 을 세우고 그 위에 `X` 를 올린다.
@@ -348,10 +382,11 @@ def _add_machine_layers(
liters = record.fuel_liters_per_hour
if liters is not None:
book.add_detail(PriceDetail(hourly_code, fuel_code, liters, note="주연료"))
if record.misc_material_percent is not None:
# 잡재료는 **주연료의 %** — 유가와 같이 움직인다.
liters = liters * (Decimal(1) + record.misc_material_percent / Decimal(100))
book.add_detail(PriceDetail(hourly_code, fuel_code, liters, note="주연료 + 잡재료"))
# 잡품은 **주연료비 × 율의 가산 행**(명세 7장 · STmate 17번 §3 「잡품 단가 칸 = 연료
# 금액」) — 연료 수량에 접지 않음. 유가와 같이 움직이는 것은 그대로.
book.add_detail(_misc_row(hourly_code, record.misc_material_percent))
# 조합 사용(리퍼·브레이커·집게)일 때 쓸 **잡재료 16%** 짜리 층을 함께 세운다.
# 같은 기계라도 조합이면 본체 잡재료가 줄어든다(품셈 제8장 [주]⑤).
@@ -373,32 +408,42 @@ def _add_machine_layers(
PriceDetail(
combined_code,
fuel_code,
record.fuel_liters_per_hour
* (Decimal(1) + COMBINED_MISC_PERCENT / Decimal(100)),
note=f"주연료 + 잡재료 {COMBINED_MISC_PERCENT}% (조합 사용)",
record.fuel_liters_per_hour,
note="주연료 (조합 사용)",
)
)
book.add_detail(_misc_row(combined_code, Decimal(COMBINED_MISC_PERCENT)))
else:
incomplete.append(f"{code} (연료소모량 없음)")
wage_code = record.operator_occupation_code
if wage_code and wage_code in wages and record.operator_person_days is not None:
# ㉣ 나눗수는 8시간 — `PriceDetail` 수량이 「1시간분 인」이 된다.
# 여기에 **제수당·상여금·퇴직급여충당금 계수**(1.667배)를 곱한다. 공표 노임이
# 기본급여액뿐이라 별도 계상해야 하는 몫이다(`MachineCost` 상수 주석에 근거).
per_hour_person = (record.operator_person_days / Decimal(8)) * OPERATOR_ALLOWANCE_FACTOR
if wage_code not in book.titles:
# ㉣ 나눗수는 8시간 + **제수당·상여금·퇴직급여충당금 계수**(16/12×25/20) — 공표 노임이
# 기본급여액뿐이라 별도 계상하는 몫(`MachineCost` 상수 주석에 근거).
# ⚠⚠ 계수를 **수량에 접지 않음** — `일당 × 0.20833…` 은 55,699.99… 로 1원 틀림.
# 시간당 노임을 식 좌→우 + 원 미만 절사로 **따로 세운 제목**(`#시간`)을 1시간분 인수로 부름
# (STmate 중기사용료 호표 「운전원 1 × 55,700」 모양, 명세 7장).
per_hour_person = record.operator_person_days
hourly_wage_code = f"{wage_code}{HOURLY_WAGE_SUFFIX}"
if hourly_wage_code not in book.titles:
book.add_title(
PriceTitle(
code=wage_code,
code=hourly_wage_code,
kind=PriceKind.LABOR,
name="조종원",
unit="인",
slots=_slots(wages[wage_code]),
name="조종원(시간당)",
unit="hr",
slots=_slots(
hourly_operator_wage(wages[wage_code], digits=operator_wage_digits)
),
# ⚠ 조종원도 노임이다 — 같은 플래그가 붙어야 한다.
reliability=load_labor_reliability().get(wage_code, ""),
)
)
wage_code = hourly_wage_code
cut = (
"원 미만" if operator_wage_digits == 0 else f"소수 {operator_wage_digits}자리 아래"
)
operator_note = f"조종원 (일당 × {OPERATOR_WAGE_FORMULA} 좌→우 · {cut} 절사)"
# 조합 층에도 조종원을 같이 단다 — 본체를 모는 사람은 하나뿐이다.
combined_code = f"{hourly_code}#조합"
if combined_code in book.titles:
@@ -407,7 +452,7 @@ def _add_machine_layers(
combined_code,
wage_code,
per_hour_person,
note="조종원 (1일 8시간 × 제수당·상여·퇴직충당 16/12 × 25/20)",
note=operator_note,
)
)
book.add_detail(
@@ -415,7 +460,7 @@ def _add_machine_layers(
hourly_code,
wage_code,
per_hour_person,
note="조종원 (1일 8시간 × 제수당·상여·퇴직충당 16/12 × 25/20)",
note=operator_note,
)
)
else:
@@ -512,14 +557,34 @@ def find_variant_code(
if _numbers_of(normalize_variant_key(code[len(prefix) :])) == numbers
]
if len(hits) == 1:
return hits[0]
return _loose(work_item_code, variant_value, hits[0], "수의 짝")
if len(numbers) == 1:
# 저장 제원이 **한 값**으로 온다(뒷길이 45㎝). 갈래는 구간이므로 그 값을 담는
# 구간을 고른다 — 「45」 → 「55cm이하」. **가장 좁은 구간**을 고른다.
return _bracket_for(numbers[0], candidates, prefix)
picked = _bracket_for(numbers[0], candidates, prefix)
return _loose(work_item_code, variant_value, picked, "구간") if picked else None
return None
#: 느슨한 맞춤을 이미 알린 짝 — 내역을 그릴 때마다 같은 줄이 로그를 채우지 않게.
_LOOSE_LOGGED: set[tuple[str, str, str]] = set()
def _loose(work_item_code: str, variant_value: str, picked: str, how: str) -> str:
"""글자가 아니라 **수·구간으로** 갈래를 고른 자리 — 로그로 남기고 그대로 돌려줌.
⚠ 조용히 다른 갈래에 붙는 자리가 바로 여기라 드러내 둠(명세 14장 정정 · 브레인 판정 (나)).
갈래의 정본은 지금 **B09 단가표 제목**이고 마스터 `variant_key` 는 첫 열만 담아 못 씀(1장 몫).
"""
key = (work_item_code, str(variant_value), picked)
if key not in _LOOSE_LOGGED:
_LOOSE_LOGGED.add(key)
logger.info(
"B09 갈래 느슨한 맞춤(%s): %s 「%s」 → %s", how, work_item_code, variant_value, picked
)
return picked
def _bracket_for(value: Decimal, candidates: list[str], prefix: str) -> str | None:
"""그 값을 담는 갈래 — 「N 이하」는 상한, 「A 이상~B 미만」은 범위로 본다."""
best: tuple[Decimal, str] | None = None
@@ -551,6 +616,7 @@ def build_unit_prices(
transport_distance_km: Decimal | None = None,
transport_road: str | None = None,
labor_surcharge_choices: dict[str, str] | None = None,
operator_wage_digits: int = 0,
) -> UnitPriceBuild:
"""자원 축을 일위대가(`B`)로 조립한다.
@@ -570,6 +636,9 @@ def build_unit_prices(
⚠ `labor_surcharge_choices` — 품의 할인·할증 26계열(산림품셈 1-4). **안 고르면 안 붙는다.**
어느 공종에 붙일지는 원문 [주]가 작업을 지목하므로 **켜는 것은 사용자 몫**이다.
⚠ `operator_wage_digits` — 조종원 시간당 노임을 자르는 자리(0 = 원 미만 절사). 실무마다 다름
(다섯 건 0 · 2025 울진 소광 1) — 규정이 없는 자리라 프로젝트 설정 칸(브레인 판정 2026-09-13).
"""
from B09_Estimation.B09_Estimation_FactorChoices import (
chosen_values,
@@ -583,6 +652,12 @@ def build_unit_prices(
)
master = load_work_item_master()
# 합산 단계·표 읽기 선언·가드 목록이 절 번호로 박혀 판에 묶임 — 판이 다르면 조립 안 함(명세 17장).
from common_util.common_util_pum_edition import require_code_edition
require_code_edition(
str(master.get("effective_date", "")), "B09 단가 조립 — 코드에 박힌 절 번호"
)
if factor_choices is None:
factor_choices = chosen_values(scan_range_factors(master))
# 「…와 동일」 참조로 이어 온 계수 — **사용자가 고른 값이 있으면 그것이 이긴다.**
@@ -696,7 +771,10 @@ def build_unit_prices(
from B09_Estimation.B09_Estimation_Transport import TRANSPORT_VARIANTS
machine_codes |= {variant["machine_code"] for variant in TRANSPORT_VARIANTS}
build.incomplete_machines = _add_machine_layers(build.book, machine_codes, fuel_region)
build.operator_wage_digits = operator_wage_digits
build.incomplete_machines = _add_machine_layers(
build.book, machine_codes, fuel_region, operator_wage_digits
)
# 규격 갈래(`variant`)가 있으면 **갈래마다 따로 세운다** — 무근·철근·소형구조물은
# 품이 달라 한 일위대가로 뭉치면 어느 것도 안 맞는다.
@@ -884,22 +962,22 @@ def build_unit_prices(
if capacity["ratio_pct"] is None
else Decimal(str(capacity["ratio_pct"])) / Decimal(100)
)
build.book.add_detail(
PriceDetail(
title_code,
hourly_code,
(Decimal(1) / capacity["capacity_per_hour"]) * group_share,
note=(
f"작업량을 표가 직접 줌 — {capacity['cell']} {capacity['capacity_per_hour']}"
f" (품셈 원문 표기 그대로)"
if not capacity.get("source_text")
# 표가 아니라 절 제목·[주]에서 온 값은 **어디서 왔는지 그대로** 싣는다.
else (
f"작업량을 원문이 직접 줌 — {capacity['cell']}"
f" {capacity['capacity_per_hour']} · {capacity['source_text']}"
)
),
)
# 작업량(Q)으로 선 장비 줄 — D(단가산출)에 달고 B 는 D 를 1 로 부름(층 차례 X → D → B).
build.book.add_output_detail(
title_code,
hourly_code,
# 작업량도 소수 2자리로 확정한 뒤 나눔(명세 7장) — 표·원문 값은 대개 이미 그 자리.
(Decimal(1) / fix2(capacity["capacity_per_hour"])) * group_share,
note=(
f"작업량을 표가 직접 줌 — {capacity['cell']} {capacity['capacity_per_hour']}"
f" (품셈 원문 표기 그대로)"
if not capacity.get("source_text")
# 표가 아니라 절 제목·[주]에서 온 값은 **어디서 왔는지 그대로** 싣는다.
else (
f"작업량을 원문이 직접 줌 — {capacity['cell']}"
f" {capacity['capacity_per_hour']} · {capacity['source_text']}"
)
),
)
attached_capacity = True
@@ -929,6 +1007,10 @@ def build_unit_prices(
from B09_Estimation.B09_Estimation_ParentSteps import attach_parent_steps
attach_parent_steps(build, master)
# 산림 품셈에 절이 없는 공종(모르타르 배합 등) — 명세 2장 ② 갈래 WK.
from B09_Estimation.B09_Estimation_WorkItems_AX import attach_work_items_ax
attach_work_items_ax(build)
# 기계 수송비 — 기계경비의 셋째 몫(손료·운전경비·**수송비**). 거리가 있어야 선다.
from B09_Estimation.B09_Estimation_Transport import attach_transport
@@ -946,6 +1028,10 @@ def build_unit_prices(
build.book, [code for code in build.book.titles if code.startswith("B-")]
)
build.labor_reliability = _labor_reliability_in_use(build.book)
# ③ 할증 포함 재료량을 준 공종에 자재가 재료비로 붙지 않았는가(명세 6장 · ㉠).
from B09_Estimation.B09_Estimation_Guards import check_materials_before_surcharge
check_materials_before_surcharge(build.book)
return build
@@ -1073,6 +1159,7 @@ def cached_build(
transport_distance_km: str = "",
transport_road: str = "",
labor_surcharge: tuple[tuple[str, str], ...] = (),
operator_wage_digits: str = "",
) -> UnitPriceBuild:
"""조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다.
@@ -1099,9 +1186,16 @@ def cached_build(
transport_distance_km=parse_distance_km(transport_distance_km),
transport_road=transport_road or None,
labor_surcharge_choices=dict(labor_surcharge),
operator_wage_digits=parse_operator_wage_digits(operator_wage_digits),
)
def parse_operator_wage_digits(value: Any) -> int:
"""조종원 시간당 노임 자르는 자리 설정 — 0·1·2 만 받고 그 밖(빈 값 포함)은 기본 0."""
text = str(value if value is not None else "").strip()
return int(text) if text in ("0", "1", "2") else 0
@dataclass
class DirectCostBreakdown:
"""⑤ 공사원가계산서가 받는 **직접비 3분할**.
@@ -224,18 +224,20 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict:
_ZERO,
)
amount = material_so_far * detail.percent_of_material / Decimal(100)
# 중기 호표의 잡품(주연료비 × 율)도 같은 비율 줄 — 이름표만 가름(품셈 제8장).
misc = detail.note.startswith("잡품")
rows.append(
{
"code": detail.ref_code,
"name": "공구손료·잡재료",
"spec": f"주재료비의 {detail.percent_of_material}%",
"name": "잡품" if misc else "공구손료·잡재료",
"spec": f"{'주연료비' if misc else '주재료비'}의 {detail.percent_of_material}%",
"unit": "%",
"quantity": str(detail.percent_of_material),
"material": str(amount),
"labor": "0",
"expense": "0",
"total": _money_text(amount),
"source": "품셈 1-2-6",
"source": "품셈 제8장" if misc else "품셈 1-2-6",
"drillable": False,
"note": detail.note,
}
@@ -0,0 +1,59 @@
"""B09 원가계산 — **산림 품셈에 절이 없는 공종**(`AX-WK-*`)의 일위대가 (명세 2장 ② 갈래 WK).
산림 품셈이 **부르기만 하고 품을 안 주는** 자리를 원문이 있는 다른 품셈에서 그대로 옮긴다.
⚠ 코드는 난수 8자리 · 동등 비교만(명세 2장 ④) — 읽기는 이름 칸이 맡음.
⚠ 값은 원문 그대로 · 줄마다 출처. 자재는 단가 층이 없어 **드러내기만** 함(치즐과 같은 자리).
"""
from __future__ import annotations
from decimal import Decimal
from typing import Any
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail, PriceKind, PriceTitle
#: 모르타르 배합 1:3 — 찰쌓기·찰붙임 줄눈메꿈(산림 품셈 13-4-4 [주]④ · 13-5-1 [주]③ 0.009㎥/㎡).
MORTAR_MIX = "AX-WK-c0842a0d"
WORK_ITEMS: dict[str, dict[str, Any]] = {
MORTAR_MIX: {
"name": "모르타르 배합",
"spec": "1:3",
"unit": "㎥",
"basis": (
"건설공사 표준품셈(2026) [건축부문] 9-1-1 모르타르 배합(㎥당)"
" — 산림 품셈엔 배합 절이 없음 · 배합비 1:3 은 돌쌓기 시방(시멘트:잔골재 부피비) · 실무 울진 대흥(2024)·소광(2025)"
" 「모르타르배합 1:3」 과 같은 짜임"
),
# (노임 코드, 수량, 칸) — 원문 [주]② 「배합이 포함된 것이며, 비빔은 제외」.
"labor": (("1002", "0.66", "보통인부 — 모래체가름 포함 칸(제외 칸 0.43)"),),
# 원문 참고자료 1:3 줄 · ※ 「위 재료량은 할증이 포함된 것이다」 — 할증 전 값이 아님.
"materials": ("시멘트 510 kg", "모래 1.10 ㎥"),
},
}
def attach_work_items_ax(build: Any) -> None:
"""`B-AX-WK-*` 제목을 세운다 — 노임 층이 없으면 안 세우고 사유를 남긴다."""
for code, item in WORK_ITEMS.items():
missing = [ref for ref, _, _ in item["labor"] if ref not in build.book.titles]
if missing:
build.component_gaps[code] = f"노임 단가 층이 없습니다 — {', '.join(missing)}"
continue
title_code = f"B-{code}"
build.book.add_title(
PriceTitle(
code=title_code,
kind=PriceKind.UNIT_PRICE,
name=item["name"],
spec=item["spec"],
unit=item["unit"],
)
)
for ref, quantity, cell in item["labor"]:
build.book.add_detail(
PriceDetail(title_code, ref, Decimal(quantity), note=f"{cell} · {item['basis']}")
)
build.unattached[code] = [
f"{material} — 자재 단가 층 없음(원문 할증 포함 값)" for material in item["materials"]
]
+56 -1
View File
@@ -39,6 +39,8 @@ from common_util.common_util_json import atomic_write_json
from config.config_system_design import (
EARTHWORK_CONVERSION_C_RANGES,
EARTHWORK_CONVERSION_FACTORS,
EARTHWORK_HAUL_EQUIPMENT_LIMITS_M,
EARTHWORK_HAUL_LIMIT_BASIS,
)
SETTINGS_FILENAME = "project_settings.json"
@@ -90,7 +92,10 @@ def default_settings() -> dict[str, Any]:
# 「시공법 미지정」으로 드러난다(2026-09-07 일감 9 에서 드러난 자리).
"rock_methods": {},
"conversion_factors_override": None,
"haul_limits_m_override": None,
# 도쟈 한계거리(m) — `None` 은 기본값(config 60 m, 근거 셋 일치)이 선다.
# 설계 조건이라 바꿀 수 있다(2026-09-13 판정). 종무대 20 m 는 규정이라 칸이 없다.
# ⚠ 종전 `haul_limits_m_override` 는 아무도 안 읽던 칸이라 이것으로 갈음했다.
"dozer_haul_limit_m": None,
"application_ratios_pct": {key: 100 for key in APPLICATION_RATIO_KEYS},
# 자재총괄의 관급/사급 구분 — `{자재명: "owner_supplied"|"contractor_supplied"}`
# 또는 `{자재명: {"supply": …, "install_by": "contractor"|"owner"}}`.
@@ -135,6 +140,10 @@ def default_settings() -> dict[str, Any]:
# 높은 장소로 **운반하고 쌓아두어야** 한다」. 제거만 세면 법이 요구하는 운반이 빠진다.
# 거리는 현장값이라 품셈이 정하지 않는다 — 비면 운반 줄이 막힌 채로 선다.
"topsoil_haul_distance_m": None,
# 표토제거 대상 — `None` 은 기본 「노면 + 절토」(별표2 문언) · `"road_only"` 는 노면만.
# ⚠ 노면만은 실무 영월 한 건(1m당 3.34㎡) 관측이라 일반화하지 않고 설계자가 고름
# (2026-09-14 브레인 판정 — 3배 차이라 칸으로 둠).
"topsoil_target": None,
# 임목축적 등급 — 품셈 9-21 제근이 **소·중·밀** 셋으로 갈리는 축.
# [주]① 「소림 30~60㎥/㏊ · 중림 60~90 · 밀림 90 이상」 — 본수가 아니라 **축적**이다.
# ⚠ 산림조사부·영림계획에서 옮겨 적는 값이라 프로그램이 만들 수 없다. 비면 막는다.
@@ -148,6 +157,9 @@ def default_settings() -> dict[str, Any]:
# 물을 일도 없다. 켜도 **부피는 지어내지 않는다**(아래 칸이 비면 줄만 서고 사유).
"wood_chipping_enabled": False,
"wood_chipping_volume_m3": None,
# 노체다짐(9-16-2) — **기본 꺼짐**(2026-09-13 판정). [주]⑤ 「대규모 성토지로서
# 층다짐이 필요한 경우 적용」 조건부라 늘 서지 않는다. 켜면 토공집계에 별도 줄.
"subgrade_compaction_enabled": False,
"dataset_versions": {},
},
"estimation": {
@@ -262,6 +274,17 @@ def concrete_placing_method(settings: dict[str, Any]) -> tuple[str, bool]:
return DEFAULT_CONCRETE_PLACING_METHOD, True
#: 표토제거 대상 — 기본은 법 문언(별표2 Ⅰ.2.차.(6) 「노면·절토대상지」).
TOPSOIL_TARGETS = ("road_and_cut", "road_only")
DEFAULT_TOPSOIL_TARGET = "road_and_cut"
def topsoil_target(settings: dict[str, Any]) -> str:
"""표토제거 대상. 모르는 값·안 정함은 기본(노면 + 절토)."""
stored = settings.get("topsoil_target")
return str(stored) if stored in TOPSOIL_TARGETS else DEFAULT_TOPSOIL_TARGET
def earthwork_conversion_factors(settings: dict[str, Any]) -> dict[str, dict[str, float]]:
"""이 프로젝트가 쓸 토량환산계수 — **기본값 위에 고른 값만 얹는다.**
@@ -318,6 +341,38 @@ def earthwork_conversion_choices(settings: dict[str, Any]) -> dict[str, dict[str
return choices
def haul_equipment_limits(settings: dict[str, Any]) -> list[tuple[str, float | None]]:
"""이 프로젝트가 쓸 운반장비 거리 경계 — 기본값 위에 **도쟈 한계거리만** 얹는다.
⚠ 유토곡선(B06)·운반표가 같은 값으로 서야 한다 — 읽는 자리마다 상수를 들지 말고
이 함수를 거친다.
⚠ 종무대 경계 이하 값은 받지 않는다(도쟈 몫이 사라짐) — 기본값이 선다.
"""
limits = dict(EARTHWORK_HAUL_EQUIPMENT_LIMITS_M)
value = settings.get("dozer_haul_limit_m")
if (
isinstance(value, (int, float))
and not isinstance(value, bool)
and float(value) > float(limits["free_haul"] or 0.0)
):
limits["dozer"] = float(value)
return [(key, limits[key]) for key, _ in EARTHWORK_HAUL_EQUIPMENT_LIMITS_M]
def haul_limit_choice(settings: dict[str, Any]) -> dict[str, Any]:
"""도쟈 한계거리 칸 — 화면이 지금 값·기본값·근거를 함께 보인다."""
default = dict(EARTHWORK_HAUL_EQUIPMENT_LIMITS_M)["dozer"]
value = dict(haul_equipment_limits(settings))["dozer"]
return {
"value": value,
"default": default,
"chosen": value != default,
"basis": EARTHWORK_HAUL_LIMIT_BASIS["dozer"],
"free_haul_m": dict(EARTHWORK_HAUL_EQUIPMENT_LIMITS_M)["free_haul"],
"free_haul_basis": EARTHWORK_HAUL_LIMIT_BASIS["free_haul"],
}
def application_ratio(settings: dict[str, Any], key: str) -> float:
"""반영률을 0~1 로. 없으면 100 %(=1.0) — 실무 관측치를 기본값으로 쓰지 않는다."""
raw = (settings.get("application_ratios_pct") or {}).get(key, 100)
+43
View File
@@ -0,0 +1,43 @@
"""품셈 판(`pum_edition`) — `FP-*` 참조가 **어느 판을 보고 적혔나** (명세 17장 규칙 3·4).
`FP-*` 는 절 번호라 **판 안에서만 유일**하다. 판이 바뀌면 같은 번호가 다른 공종일 수 있다.
공종 마스터 `effective_date` = 그 판
자원 축 줄 · 인계 줄 `pum_edition` 칸 — 그 줄이 본 판
코드에 박힌 절 번호 `CODE_PUM_EDITION` 한 곳 — 합산 단계·표 형태 판정·표 읽기 선언·가드 목록
⛔ 판이 다르면 **값을 쓰지 않는다** — 줄은 「확인 대기」로 세우고, 코드에 박힌 선언은 조립을 멈춘다.
조용히 이웃 절에 붙으면 품이 통째로 달라진다(규칙 4). 판을 넘어 잇는 것은 대응표 몫이다.
⚠ 라이브러리 항목의 `pum_edition` 은 랩탑 서브가 붙였다(구조물도 양식).
"""
from __future__ import annotations
#: 코드 안에 박힌 `FP-*` 가 본 판 — 산림사업 표준품셈(산림청고시 제2025-82호, 2026-01-01 시행).
#: ⚠ 판을 올리는 날 이 값만 바꾸지 말 것 — 박힌 선언을 대응표로 옮긴 **뒤에** 바꿀 것.
CODE_PUM_EDITION = "2026-01-01"
class PumEditionError(RuntimeError):
"""코드에 박힌 절 번호의 판과 공종 마스터의 판이 다를 때. 값을 쓰지 않고 멈춘다."""
def require_code_edition(master_edition: str, where: str) -> None:
"""코드에 박힌 선언을 쓰기 전에 부른다 — 판이 다르면 멈춘다."""
if str(master_edition) != CODE_PUM_EDITION:
raise PumEditionError(
f"{where}: 코드에 박힌 절 번호는 {CODE_PUM_EDITION} 판을 보고 적혔는데 공종 마스터는 "
f"{master_edition or '(판 없음)'} 판입니다 — 판 대응표로 옮기기 전에는 쓰지 않습니다 "
"(명세 17장 규칙 4)"
)
def edition_waiting_reason(row_edition: str | None, master_edition: str) -> str:
"""판이 다른 줄의 「확인 대기」 사유. 어느 한쪽이 판을 안 적었거나 같으면 빈 글."""
if not row_edition or not master_edition or str(row_edition) == str(master_edition):
return ""
return (
f"품셈 판 확인 대기 — 이 줄의 공종 코드는 {row_edition} 판,"
f" 공종 마스터는 {master_edition} 판 · 절 번호가 같아도"
" 같은 공종이라는 보장이 없어 값을 안 씀(명세 17장)"
)
+10
View File
@@ -422,6 +422,16 @@ EARTHWORK_HAUL_EQUIPMENT_LIMITS_M = (
("dozer", 60.0),
("dump_truck", None),
)
# 경계값 근거 — **화면이 기본값 옆에 그대로 보인다**(2026-09-13 브레인 판정 · PLAN 10장).
# ⚠ 성격이 다르다: 종무대 20 m 는 품셈 규정이라 칸을 안 두고, 도쟈 60 m 는 설계 조건이라
# 프로젝트 설정 `dozer_haul_limit_m` 으로 바꿀 수 있다.
EARTHWORK_HAUL_LIMIT_BASIS = {
"free_haul": "품셈 1-2-7 「소운반 20 m 이내는 품에 포함」 — 규정이라 안 바꿈",
"dozer": (
"근거 셋 일치 — 표준품셈 8-1-1(60 m 이하 도저) · 산림과임업기술 5장 p.441·478"
"(60 m 이하 도저 / 초과 덤프) · 실무 오솔길 EARTH.DAT 6개 공사지 전부 60 m"
),
}
# ─────────────────────────────────────────────────────────────────────────
@@ -70,6 +70,14 @@
"master_name": "노체 > 노체포설",
"note": "실무 내역 셋을 다 봤더니 「노체포설·노체다짐」으로 가른 줄이 **어디에도 없다**(2026-09-08 데스크탑 보조). 영월은 「암성토 BACK-HOE(0.7㎥)」 한 줄이고 그 단가산출 안에 적사→성토→다짐 3단계가 들어가며, 울진은 「유용성토」·「사토 및 다짐공」, 봉화는 성토사면다짐만 따로다. ⇒ **성토 본체 한 줄(포설) + 성토면다짐(㎡) 따로**가 실무 양식이라 부모(FP-09-16)에서 자식 포설로 내렸다. 다짐은 그 단가 안 단계로 둔다."
},
{
"group": "노체다짐",
"work_item_code": "FP-09-16-02",
"basis_unit": "㎥",
"basis_source": "산림사업 표준품셈(고시 2025-82) 원문 L5336 「Q=1000×V×W×E×D×f/N= ㎥/시간」 — 포설(9-16-1)과 같은 체적 축이라 성토량을 그대로 밑수로 씀.",
"master_name": "노체 > 노체다짐",
"note": "2026-09-13 브레인 판정 — 별도 줄 · 산출 조건 칸으로 켜고 끔 · 기본 꺼짐([주]⑤ 「대규모 성토지로서 층다짐이 필요한 경우」 조건부). [주]③ 「굴착기와 진동롤러 조합」은 작업 방식 설명 — 굴착기는 포설(9-16-1) 줄, 롤러는 이 줄(B09 B-FP-09-16-02 롤러만). 갈래 없음(2026-09-14 「#조합」 철회 — X-<기계>#조합 잡재료 16% 층과 다른 뜻)."
},
{
"group": "성토면다짐",
"work_item_code": "FP-09-17-01",
@@ -1,7 +1,7 @@
{
"schema_version": "1.0",
"dataset_id": "data_work_item_master_manifest",
"generated_at": "2026-09-13T18:54:39+09:00",
"generated_at": "2026-09-13T23:03:27+09:00",
"built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py",
"source": {
"dataset_id": "pum_forest",
@@ -12,18 +12,18 @@
"files": [
{
"file": "work_item_master_2026-01-01.json",
"sha256": "11c49ecde0a611307a82fd98b3625ee6577c01c2f77db6e1f515b41bf83ce685",
"size_bytes": 861050
"sha256": "1463ea14ec2d3d45ec18b3a7e4810333de8e5fc40aaeb0dd329c68f85c863e33",
"size_bytes": 835659
},
{
"file": "form_undetermined_2026-01-01.json",
"sha256": "7334dab9385bc1615a9cdb557ba814482e9a63d83b9e1232946918bfd8b5577f",
"size_bytes": 37139
"sha256": "c835699596bf57ffbda9f72a773a72116ebe48c2a2f79ae232ba5784ec9842c7",
"size_bytes": 6447
},
{
"file": "basis_missing_2026-01-01.json",
"sha256": "cb97edff0f47183173e23549c77f98f0509441edecc291bfcabba23254e60e9d",
"size_bytes": 18770
"sha256": "f850742268dcfb1bcc878e8b062ef44bc6a11d773074163ebb8ba1dec21a6d0d",
"size_bytes": 16790
}
]
}
@@ -130,24 +130,6 @@
"pum_form": "requirement",
"line": 2062
},
{
"pum_table_id": "F0092",
"section": "5-1-1. 관목굴취",
"pum_form": "requirement",
"line": 2081
},
{
"pum_table_id": "F0093",
"section": "5-1-2. 교목굴취(나무높이)",
"pum_form": "requirement",
"line": 2099
},
{
"pum_table_id": "F0094",
"section": "5-1-3. 교목굴취(근원직경)",
"pum_form": "requirement",
"line": 2125
},
{
"pum_table_id": "F0095",
"section": "5-1-3. 교목굴취(근원직경)",
@@ -160,108 +142,18 @@
"pum_form": "requirement",
"line": 2177
},
{
"pum_table_id": "F0098",
"section": "5-2. 뿌리돌림",
"pum_form": "requirement",
"line": 2194
},
{
"pum_table_id": "F0099",
"section": "5-3-1. 나무식재",
"pum_form": "requirement",
"line": 2219
},
{
"pum_table_id": "F0102",
"section": "5-3-2. 관목식재(단식)",
"pum_form": "requirement",
"line": 2254
},
{
"pum_table_id": "F0103",
"section": "5-3-3. 관목식재(군식)",
"pum_form": "requirement",
"line": 2272
},
{
"pum_table_id": "F0104",
"section": "5-3-4. 교목식재(나무높이)",
"pum_form": "requirement",
"line": 2291
},
{
"pum_table_id": "F0106",
"section": "5-3-5. 교목식재(흉고직경)",
"pum_form": "requirement",
"line": 2322
},
{
"pum_table_id": "F0108",
"section": "5-3-5. 교목식재(흉고직경)",
"pum_form": "requirement",
"line": 2355
},
{
"pum_table_id": "F0109",
"section": "5-4. 파종조림",
"pum_form": "requirement",
"line": 2366
},
{
"pum_table_id": "F0110",
"section": "5-5. 천연하종갱신",
"pum_form": "requirement",
"line": 2383
},
{
"pum_table_id": "F0111",
"section": "5-6. 움싹갱신",
"pum_form": "requirement",
"line": 2396
},
{
"pum_table_id": "F0112",
"section": "5-7. 생태보완조림",
"pum_form": "requirement",
"line": 2409
},
{
"pum_table_id": "F0113",
"section": "5-8. 큰나무 공익조림",
"pum_form": "requirement",
"line": 2426
},
{
"pum_table_id": "F0114",
"section": "5-9. 해안조림",
"pum_form": "requirement",
"line": 2439
},
{
"pum_table_id": "F0116",
"section": "5-11. 사초심기",
"pum_form": "requirement",
"line": 2475
},
{
"pum_table_id": "F0117",
"section": "5-12. 떼붙임(재배잔디)",
"pum_form": "requirement",
"line": 2496
},
{
"pum_table_id": "F0118",
"section": "5-13. 떼심기",
"pum_form": "requirement",
"line": 2511
},
{
"pum_table_id": "F0121",
"section": "5-16-1. 단끊기",
"pum_form": "requirement",
"line": 2565
},
{
"pum_table_id": "F0126",
"section": "5-19-1. 표토절취 및 모으기",
@@ -364,24 +256,6 @@
"pum_form": "requirement",
"line": 3244
},
{
"pum_table_id": "F0164",
"section": "6-6. 가지치기 및 수형교정",
"pum_form": "requirement",
"line": 3276
},
{
"pum_table_id": "F0165",
"section": "6-7-1. 교목 시비",
"pum_form": "requirement",
"line": 3304
},
{
"pum_table_id": "F0166",
"section": "6-7-2. 관목 시비",
"pum_form": "requirement",
"line": 3318
},
{
"pum_table_id": "F0170",
"section": "7-1-1. 수확",
@@ -478,6 +352,18 @@
"pum_form": "requirement",
"line": 3673
},
{
"pum_table_id": "F0189",
"section": "7-11. 동력상하차기(우드그래플) 집재-수확",
"pum_form": "productivity",
"line": 3723
},
{
"pum_table_id": "F0190",
"section": "7-11. 동력상하차기(우드그래플) 집재-수확",
"pum_form": "productivity",
"line": 3731
},
{
"pum_table_id": "F0191",
"section": "7-11. 동력상하차기(우드그래플) 집재-수확",
@@ -491,10 +377,34 @@
"line": 3763
},
{
"pum_table_id": "F0201",
"section": "8-1-1. 약제주입기",
"pum_form": "requirement",
"line": 3945
"pum_table_id": "F0196",
"section": "7-15-1. 수확",
"pum_form": "productivity",
"line": 3864
},
{
"pum_table_id": "F0197",
"section": "7-15-2 숲가꾸기, 병해충방제",
"pum_form": "productivity",
"line": 3878
},
{
"pum_table_id": "F0198",
"section": "7-16. 소형 포워더 운재",
"pum_form": "productivity",
"line": 3898
},
{
"pum_table_id": "F0199",
"section": "7-17. 소형트럭 운재",
"pum_form": "productivity",
"line": 3912
},
{
"pum_table_id": "F0200",
"section": "7-18. 지조운반(포워더 활용)",
"pum_form": "productivity",
"line": 3926
},
{
"pum_table_id": "F0202",
@@ -574,6 +484,12 @@
"pum_form": "requirement",
"line": 4320
},
{
"pum_table_id": "F0220",
"section": "8-3. 소각, 매몰, 훈증, 박피",
"pum_form": "productivity",
"line": 4348
},
{
"pum_table_id": "F0224",
"section": "8-5. 페르몬 유인트랩",
@@ -778,18 +694,6 @@
"pum_form": "requirement",
"line": 7087
},
{
"pum_table_id": "F0412",
"section": "13-4-4. 찰쌓기(인력)",
"pum_form": "requirement",
"line": 7174
},
{
"pum_table_id": "F0413",
"section": "13-4-4. 찰쌓기(인력)",
"pum_form": "requirement",
"line": 7185
},
{
"pum_table_id": "F0424",
"section": "13-8. 막돌쌓기",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -50,4 +50,4 @@
- 실무 레시피 350개 중 품셈 근거문구 보유는 82개뿐이다. 근거 없는 268개는 현행값으로 임의 확정하지 않는다.
- 과거 임도품셈 절 번호가 현행 산림사업 표준품셈에서 크게 바뀌었다. 번호만 같은 다른 절을 자동 연결하지 않는다.
- JSON의 `수량문자_확인`은 후보 절 본문에 같은 숫자가 있는지를 세는 1차 검색값이다. `일치` 확정은 위 두 항목처럼 조건과 행을 직접 대조한 경우만 한다.
- 후속 수동 대조는 근거 절이 확인된 11개부터 하고, 복수 절 참조 69개는 과거판-현행판 절 대응표가 있어야 안전하다.
- 후속 판 대응표는 `33_임도품셈_판_대응표.md`와 `30_원자료/임도품셈_판_대응표.csv`에 작성했다. 2017·2018·2024 원문 대조 결과 확정 12·미확인 2이며, 미확인은 자동 치환에 쓰지 않는다.
@@ -0,0 +1,37 @@
# 과거 임도품셈 ↔ 2026 현행 산림사업 표준품셈 대응표
작성일: 2026-09-13
산출물: `30_원자료/임도품셈_판_대응표.csv`
현행 기준: 산림청고시 제2025-82호, 2026-01-01 시행
## 결과
STmate 레시피의 임도계 품셈 인용 14개를 2017 임도표준품셈, 2018 통합 초판, 2024 개정판, 2026 현행판 순으로 대조했다.
| 판정 | 수 | 뜻 |
|---|---:|---|
| 확정 | 13 | 절 이동 경로와 표의 장비·계수·수량 또는 현행 원문의 적용 대상을 직접 대조 |
| 추정 | 0 | 원문 대조 후 남은 추정 없음 |
| 미확인 | 1 | 외부 건설품셈을 참조하여 현행 산림품셈 절로 연결 불가 |
2018 통합 초판의 목차에는 2017 임도품셈 번호가 괄호로 직접 병기되어 있다. 이 표기로 `3-3-1→4.3.2`, `3-3-2→4.3.3`, `3-6→4.24`, `3-7→4.25`, `5-1-1→4.27.1`, `5-1-2→4.27.2`, `7-3→4.40`을 확인했다. 2024 개정판과 2026 현행판은 실제 표의 장비 규격, 작업능력, 계수와 수량까지 대조했다.
## 추가 확인 결과
- `7-3 노면다짐`: 2017·2018 원문 모두 자체 품 없이 건설품셈 기계화 시공을 참조한다. 2024판 목차에는 노면다짐이 없고, 현행 `3-4-3`은 인력식 노면 보수이므로 대응 절을 `미확인`으로 유지한다.
- `9-3 임도성토면다짐`: 공종은 `2017 3-6 → 2018 4.24 → 2024 8-18 → 현행 9-17-1 비탈면 다짐`으로 이어진다. 현행 원문도 `임도성토면 다짐` 적용, 굴착기 부착용 유압식 진동 콤펙터와 굴착기 `0.7㎥`, `A=77.7㎡/시간`을 직접 명시하므로 절 대응은 `확정`한다. 다만 STmate의 `9-3`과 `Q=716.8`은 원문과 불일치하므로 해당 번호·값은 복사하지 않는다.
## 사용 경계
- 확정 13건은 **절 번호 대응**이 확정된 것이다. STmate 레시피의 값 전체가 현행에 적합하다는 뜻은 아니다.
- 과거 한 절이 현행 여러 절로 분리된 `선떼붙이기공`과 `암절취`는 일대다 대응을 유지한다. 대표 하위 절 하나로 자동 치환하지 않는다.
- `5-1-1 나 암절취`의 STmate 장비 규격 `굴삭기 0.4㎥`는 원문 `0.7㎥`와 다르므로 절 대응과 별개로 값 복사를 금지한다.
## 확인 원문
- 산림청, `임도표준품셈 최종파일 188264.pdf`(2017): 인쇄면 32, 38, 63~65, 91쪽
- 산림청, `2018년도 산림사업 표준품셈-최종.pdf`: 목차의 구절→신절 병기 및 4.16.2·4.16.4·4.39
- 산림청, `2024년도 개정판 안전 산림사업 표준품셈.pdf`: 인쇄면 60, 62, 146~148, 156~167, 188쪽
- 2026 현행: `resources/knowledge/original/행정규칙/임도 품셈 적용기준 (현 산림사업 표준품셈)/첨부/(산림청고시 제2025-82호) 산림사업 표준품셈.md` 9-17-1 및 `resources/data_work_item_master/work_item_master_2026-01-01.json`
@@ -0,0 +1,185 @@
# STmate 원가·수량 화면 구조 정리
작성일: 2026-09-13
근거: `30_원자료/ui_form_catalog.txt` · `30_원자료/print_form_map.txt` · `30_원자료/TWM_KANJUB.dfm` · `30_원자료/TPM_KAN_RATE.dfm` · `30_원자료/TWM_KAN_RATE.dfm` · `20_분석/22_UI_폼_분석.md` · `20_분석/19_진행률_장부.md` §3
## 1. 범위와 판정
- 카탈로그의 STmate 고유 폼 150개 가운데 원가·수량 입력, 기초단가, 일위대가·단가산출, 제잡비, 출력에 직접 참여하는 **48개 화면**을 추렸다.
- `PM_*` 인쇄 폼은 실제 출력 시트와 이어지는 화면만 포함했다. 같은 출력의 세로·가로·수공 변형은 한 행에 묶었다.
- 계약·실행·기성 화면은 설계 원가 흐름과 맞닿은 것만 `보조`로 표시했다.
- 조림·방제 등 사업별 설계 화면, 유지보수·다운로드·복구·암호·정보창은 제외했다.
- 화면 이름과 선택지는 카탈로그 원문을 유지했다. 기능 또는 출력 연결이 원자료에서 확인되지 않은 곳은 `미확인`이다.
## 2. 화면 갈래
### 2.1 내역 입력 — 11개
| 화면 | 무엇을 하나 | 주요 입력 칸·선택지 |
|---|---|---|
| `wBoq` | 공종목록에서 내역서로 항목을 넣는 중심 화면 | `cBox01/02`: `= 이상`~`====== 이상`; `RGrpTo`: `내역서 / 열린 보관함` |
| `wKongJong` | 공종 목록 조회·선택 | `RdoLevel`: `소계(+=)`~`총대계(+======)`; `RdoIndex`: `코드번호 순 / 명칭 순 / 분류번호 순`; `RdoFilt`: `일위대가/산근전체 / 대공종 해당목록만` |
| `wMBoq` | 주공종 집계와 계표시 확인 | `CBox01`: `1레벨(소계) 이상`~`6레벨(총대계) 이상` |
| `wITNum` | 내역서 공종번호 자동입력 | 첫 글자 `공란 / . / ( / - / 추가입력`; 끝 글자 `공란 / . / )`; 번호형식 `가..하… / a..z…`; `Fmt_Typ`: `0 / 00 / 000` |
| `wBoqRate` | 내역 항목에 할증율 적용 | `Rdo`: `전체(재,노,경 같은 비율) / 비목별(재,노,경 각각)` |
| `wM_Rate` | 선택 범위의 수량·비목 금액 일괄 보정 | 범위 `내역서 전체 / 선택된 항목만`; 비목별 `절사 / 반올림`; `소수 0위`~`소수 4위값을 적용` |
| `wExpnd` | 일위대가를 내역 행으로 풀기 | `RGrpToT`: `소계(=) / 계(==) / 합계(===)`; `절삭하여 / 반올림하여`; `소수 4위`~`소수 0위값을 적용` |
| `wM_Expnd` | 일위대가와 단가산출근거를 내역에 풀어넣기 | 재료·노무·경비·일식 각각 `절사 / 반올림`, `소수 0위`~`소수 4위값을 적용` |
| `wM_Expnd10` | 일위대가 풀기와 가산 비목 지정 | `입력안함 / 소계(=) / 계(==) / 합계(===)`; 입력 비목 `재료비 / 노무비 / 경비 / 전체`; 결과 비목 `재료비 / 노무비 / 경비 / 해당비목` |
| `wM_From_XLS` | XLS 자료를 내역으로 불러오기 | 주요 입력 칸·선택지 `미확인` |
| `wSameCode` | 내역의 동일 코드 처리 | 주요 입력 칸·선택지 `미확인` |
### 2.2 수량 — 2개
| 화면 | 무엇을 하나 | 주요 입력 칸·선택지 |
|---|---|---|
| `wQtyList` | 별도 수량산출서에서 코드별 수량을 조회·연결 | `cBoxCode`: `모든단가 / 노무비 / 재료비 / 경비 / 일위대가 / 중기비 / 단가산출 / 견적단가 / 대가,산근` |
| `uM_ConQTY` | 수량 자료 변환·연결 | 구체적인 칸과 변환 방향 `미확인` |
### 2.3 일위대가·단가산출 — 7개
| 화면 | 무엇을 하나 | 주요 입력 칸·선택지 |
|---|---|---|
| `wM_Edit_iLWi` | 일위대가표 구성행 수정 | 노임계수 `초기값설정 개별적용 / 기본 / 기타 / 기타-1`~`기타-4 노임산출계수`; 저장비목 `각 해당비목 / 재료비 / 노무비 / 경비` |
| `wM_Edit_San` | 단가산출근거와 Q식 수정 | 블록 `열블럭 / 행블럭 / 일반`; 함수 `∏ / INT / SIN / COS / TAN / SQRT / √ / ROUND / ROUNDDOWN …`; 연산 `/ / *`; 수량기호 `Q / A / T / N`; 저장비목 4택 |
| `wM_Edit_EQ` | 표준중기공식 편집 | 삽입 `공식 / 공식+코드` |
| `wM_Edit_USER` | 사용자 단가산출식 편집 | 주요 입력 칸·선택지 `미확인` |
| `wM_iLWi_San` | 일위대가·단가산출 자료 조회/연결 | 구체적인 칸과 편집 범위 `미확인` |
| `wM_Conv_A2W` | 실적단가를 일식단가로 변환등록 | `비목율(단가 => 노,재,경) / 조달청 발표단가 변환적용`; 연결 `공종코드↔분류번호 / 공종코드↔시설코드 / 명칭·규격·단위` |
| `wM_Boq_ExecX` | 시간·단위당 중기 실행단가 계산·입력 | 절사 단위 `1원 / 10원 / 100원 / 1,000원 / 10,000원 / 100,000원`; 보정 `기본보정 / 노무비 / 재료비 / 경비` |
### 2.4 기준자료(단가·노임·중기) — 10개
| 화면 | 무엇을 하나 | 주요 입력 칸·선택지 |
|---|---|---|
| `wM_JabPum` | 기준 단가 목록 조회·선택 | 코드종류 `모든단가 / 노무비 / 재료비 / 경비 / 일위대가 / 중기비 / 단가산출 / 견적단가 / 대가,산근 / 그림코드`; 검색 `명칭,규격 / 산근내용` |
| `wM_Edt_Hal` | 기준단가를 비율로 일괄 수정 | `절사 / 반올림`; 화면 안내상 적용단가 0원과 보호표시 항목은 제외 |
| `wM_Edit_XRate` | 시간당 손료계수 수정 | `계 입력 / 각계수 입력` |
| `wM_Boxa` | 단가목록 조회·비교 | `변동없음 / 1 단가`~`5 단가 / 최소단가`; 자재·공종 분류 필터 |
| `wM_Box_H` | 불러간 단가 목록 확인 | `변동없음 / 1 단가`~`5 단가 / 최소단가` |
| `wM_JDCode` | 공사원가 호환규정코드 검색·입력 | 기준 `명칭 / 명칭·규격 / 분류번호 / 시설코드 / 선택안함`; 검색 `명칭·규격·단위 / 호환규정코드 / 군급목록번호 / 중기품셈코드 / 시설코드번호`; 종류 `기본 / 자재 / 경비 / 중기 / 실적·기타` |
| `wM_Mrg_BSC` | 새 기준자료로 프로젝트 자료 갱신 | 매칭 `호환규정코드 / 시설코드 / 명칭·규격·단위 / 호환+명+규+단 / 명칭·단위 / 설정안함`; 명칭·단가·조달코드별 복사처/복사원 적용 |
| `wM_JajeXLS` | 자재 기준자료를 XLS로 내보내거나 불러오기 | 방향과 주요 선택지 `미확인` |
| `PM_iLWi`·`PM_iLWi_S` | 일위대가표 인쇄 원장을 구성 | 인쇄 전용 폼; 입력 칸 `미확인` |
| `PM_JOONG`·`PM_JOONG2`·`PM_JOONG_L` | 중기목록·시간당중기사용료 인쇄 원장을 구성 | 일반·변형·가로형 구분 외 입력 칸 `미확인` |
### 2.5 원가계산·제비율 — 6개
| 화면 | 무엇을 하나 | 주요 입력 칸·선택지 |
|---|---|---|
| `wM_KanJub` | 설계 제잡비를 계산하고 원가계산서를 만든다 | `원설계 / 1차변경 / 2차변경 / 3차변경`; `제잡비 원가계산방식 / 제잡비 표준시장방식`; 간접비·총공사비 절사 단위; `당초/변경 대비인쇄 / 변경분만 인쇄` |
| `wM_KAN_RATE` | 제잡비 요율표를 조회·수정·저장·출력 | 상단 `수정 / 취소 / 저장 / 출력 / 종료 / 불러오기`; 요율표 선택 콤보; 실제 탭은 DFM §4 참조 |
| `PM_KAN_RATE` | 제잡비 요율표 인쇄 | 공사규모 구간별 기초액·요율, 일반관리비 등 출력; 입력 화면 아님 |
| `wM_GetAMT` | 내역에서 잡비 계산 기초금액을 불러온다 | `모두보기 / 소계(+=) / 계(+==) / 합계(+===) / 총계(+====) / 총합계(+=====)` |
| `wM_KanJub_K` | 기성 제잡비 계산 | 집계수준 `소계`~`총합계 이상`; 부가세 `(0) 직접입력 / (1) 공급가액의 10% / (2) 재료비의 10% / (3) 재료비+산출경비의 10%`; 절사 단위 |
| `PM_Won_A`·`PM_Won_A_C`·`PM_Won_A_L` | 공사원가계산서 출력 원장을 구성 | 일반·변경·가로형 구분 외 입력 칸 `미확인` |
### 2.6 인쇄·출력 — 7개
| 화면 | 무엇을 하나 | 주요 입력 칸·선택지 |
|---|---|---|
| `PM_Prt` | 전체 인쇄 메뉴와 출력 모양 설정 | 글꼴 `굴림체 / 궁서체 / 바탕체 / 돋움체`; 코드·호표·분류번호 등 인쇄 필드; `수량/단위 / 단위/수량 / 표준`; `일반 / 토개공 / 수자원공사`; `세로 / 가로`; `일반 / 산근` |
| `PM_Prn_Ord` | 보고서 인쇄순서 지정 | `당초 설계 / 변경 설계 / 분할 설계 / 실적 공사 / 할증 내역 / 견적 내역 / 계약 내역 / 실행 예산 / 기성 내역 / 설계 변경 / 굴착·복구 / 굴착 변경` |
| `PM_Sel_St` | 인쇄할 공종과 집계수준 선택 | `소계(+=) 이상`~`총합계(+=====)`; 방향 `아래로 / 위로` |
| `wM_Sel_Prn` | 선택 영역·설계차수·표준시장단가 보정 후 인쇄 | `당초설계 / 변경설계 / 분할설계`; `7레벨공종이하 내역전체 / 해당레벨`; 비노무비 처리 3택; 노무비 끝전 보정 3택; `1차변경`~`8차변경` |
| `PM_Nae*` 계열 | 총괄·설계·분할·관급·견적·계약 내역서 출력 | 일반·가로·변경·계약 등 변형; 세부 입력 칸 `미확인` |
| `PM_Col_Dan*`·`PM_Col_X*` | 재료·노무·경비·중기·일위·산근 수량금액 집계표 출력 | 일반·가로·X 변형; 세부 입력 칸 `미확인` |
| `PM_SanMok*`·`PM_Dan*` | 단가 목록·단가대비·단가산출 목록 출력 | 일반·가로·인쇄 변형; 세부 입력 칸 `미확인` |
### 2.7 설정 — 5개
| 화면 | 무엇을 하나 | 주요 입력 칸·선택지 |
|---|---|---|
| `wM_Set_Sys` | 계산 초기값과 표시 자리수 설정 | `KCD/ACD/JCD/ECD/BPN`: `0 / 1 / 2 자리`; `ZCD`: `2 / 3 자리`; 환율범위 `시간당중기사용료 단독 / 중기 / 대가 / 산근 / 내역 전체`; 노임계수 `터널 / 장대터널 / 펌프준설선 / GRAB준설선 / 버켓준설선` |
| `wM_Set_Init` | 프로젝트 계산 초기값 설정 | 구체적인 탭·칸 `미확인` |
| `wM_Set_JLab` | 직종·노임 관련 초기 설정 | 구체적인 칸 `미확인` |
| `uM_XML_Info` | 공사원가 호환규정 XML 변환 설정 | 공사유형 `토목 / 건축 / 설비 / 전기 / 통신 / 조경 / 소방 / 국가유산수리공사` |
| `wSrch_PL` | 프로젝트 DATA 검색 | 종류 `노무비 / 재료비 / 경비 / 중기비 / 일위대가 / 단가산출 / 일식·표준시장 / 내역서` |
## 3. 화면 ↔ 출력 시트 30종
`print_form_map.txt`의 export 연결과 19번 §3의 실제 시트명을 합쳤다. `PM_*`은 인쇄용 폼이며 편집 화면에서 직접 호출되는지는 정적 자료만으로 확인되지 않은 경우가 있다.
| 화면·폼 계열 | 실제 출력 시트 |
|---|---|
| `wM_KanJub` → `PM_Won_A*` | 공사원가계산서 |
| `wBoq` → `PM_Nae*` | 설계내역서 · 착공내역서 · 총괄설계내역서 · 총괄내역서 |
| `wBoq`/변경 내역 → `PM_Nae*` 또는 `CHANGE_*` | 설계변경내역서 · 설계변경금액대비표 · 설계변경증감대비표 |
| `wM_Edit_iLWi` → `PM_iLWi*` | 일위대가표 |
| `wM_Edit_San` → `Calq_San`·`Edit_SanKun` | 단가산출근거 |
| `wM_JabPum`/중기 원장 → `PM_JOONG*` | 중기사용료 |
| `wM_Boxa` 또는 단가비교 → `PM_Col_Dan*` | 자재단가대비표 |
| `wM_Set_Sys` 관련 설정 출력 | 환율및기초자료 · `〓 INITIAL 〓` — 정확한 `PM_*` 호출은 `미확인` |
| `wM_JabPum` → `PM_SanMok*` | 재료비목록표 · 노무비목록표 · 경비목록표 · 일식견적목록표 |
| 일위대가 원장 → `PM_SanMok*` | 일위대가목록표 |
| 단가산출 원장 → `PM_SanMok*` | 단가산출근거목록표 |
| 중기 원장 → `PM_JOONG*` | 중기목록표 |
| 내역 집계 → `PM_Col_Dan*` | 재료비수량금액집계표 · 노무비수량금액집계표 · 경비수량금액집계표 |
| 중기 집계 → `PM_Col_Dan*` | 중기시간금액집계표 |
| 일위대가 집계 → `PM_Col_Dan*` | 일위대가수량금액집계표 |
| 단가산출 집계 → `Calq_San`·`Edit_SanKun`·`PM_Col_Dan*` | 단가산출근거수량금액집계표 |
| 일식견적 집계 → `PM_Col_Dan*` | 일식견적수량금액집계표 |
| 인쇄 메뉴의 보고서 목록 | `〓 목 차 〓` · `※※안내※※` — 생성 폼 `미확인` |
30종은 위 대응에 모두 들어간다. 다만 `환율및기초자료`, `〓 INITIAL 〓`, `〓 목 차 〓`, `※※안내※※`의 정확한 생성 폼 **4개 연결은 미확인**이다.
## 4. DFM 3벌의 실제 배치
### 4.1 `TWM_KANJUB.dfm` — 제잡비 계산 입력 화면
- 상단 고정 패널 다음에 본문 `TPageControl(Pg_Ctrl)`이 놓인다.
- 상단 칸 차례는 기본값 설정, 차수 선택, 계산방식, 요율, 출력, 변경/계산, 종료 계열이다. 확인되는 콤보는 `Cmbx_PORD1_H` → `cBx_K_JeolSa` → `cBx_Chasu` → `cBx_W_Type` 순이다.
- `cBx_Chasu`: `원설계 / 1차변경 / 2차변경 / 3차변경`; `cBx_W_Type`: `제잡비 원가계산방식 / 제잡비 표준시장방식`.
- 본문 탭은 아래쪽 탭바(`TabPosition=tpBottom`)이고 여러 줄 탭(`MultiLine=True`)이다. DFM에서 `TbSh_T`, `TbSh_TA` 등 원가 형식별 탭 객체가 확인된다. 탭 캡션 전체와 탭별 항목 수는 `미확인`이다.
- 각 탭 안은 `TScrollBox`로 구성되어 긴 제잡비 항목을 세로로 스크롤한다.
### 4.2 `TWM_KAN_RATE.dfm` — 요율 조회·편집 화면
- 상단 고정 도구막대: `수정(&E)` → `취소(&C)` → `저장(&S)` → `출력(&P)` → `종료(&Q)` 순이며 `불러오기` 버튼과 요율표 선택 콤보가 함께 있다.
- 본문은 `TPageControl(Pg_Ctrl)`이며 탭바가 아래쪽에 있다. 확인되는 첫 탭 객체는 `Tab_Rn`이다.
- 탭 안은 흰색 `TScrollBox`와 여러 요율 그리드로 구성된다. 확인되는 묶음은 고용 관련 요율(`rGrd_Rate_Kobo`), 산업안전 관련 요율(`rGrd_Rate_Anjun`) 등이다.
- 그리드 열은 공사규모 구간과 `요율`, `기초액`, `% + 천원` 형식을 함께 가진다. 즉 한 화면에서 단순 단일요율뿐 아니라 규모별 기초액 가산식도 편집한다.
- 전체 탭 캡션과 모든 그리드의 화면상 정확한 순서는 바이너리 DFM 문자열만으로 복원되지 않아 `미확인`이다.
### 4.3 `TPM_KAN_RATE.dfm` — 요율표 인쇄 화면
- 편집 위젯이 아니라 `THRPage` 기반 보고서 한 장이다. `ReportHeader` 뒤에 요율표가 이어진다.
- 제목·구간 라벨 → 금액 구간 → 기초액 → 요율 순으로 열이 반복된다.
- 확인되는 구간 예시는 `10억~50억 미만`, `70억~120억`이며 표시 형식은 `00.00 %` 또는 `(#,0 천원 + …)`이다.
- `일반관리비` 등 항목별 표가 인쇄 좌표에 고정 배치된다. `TWM_KAN_RATE`의 편집 그리드를 종이용 규모구간 표로 펼친 구조다.
## 5. B08·B09 화면 구조에 주는 근거
```text
수량산출서(wQtyList/uM_ConQTY)
→ 내역 입력(wBoq)
→ 일위대가·단가산출 편집
→ 기준자료(단가·노임·중기)
→ 제잡비 계산(wM_KanJub)
→ 인쇄 메뉴(PM_Prt)에서 30종 시트 선택
```
- B08 근거: 수량산출서는 코드 종류로 필터한 별도 화면이고, 내역서와 분리되어 있다.
- B09 근거: 직접비 편집, 기초단가, 일위대가·단가산출, 제잡비 요율, 출력이 각각 별도 화면이며 마지막에 인쇄 메뉴가 묶는다.
- 제잡비 입력 화면과 요율 관리 화면도 분리된다. 요율 관리 화면은 수정·저장·출력의 독립 흐름을 가진다.
- 출력은 계산 화면을 그대로 인쇄하지 않고 `PM_*` 보고서 폼을 따로 거친다.
## 6. 미확인
화면 단위 미확인 **10개**:
1. `wM_From_XLS`의 열 매핑·불러오기 옵션
2. `wSameCode`의 동일 코드 처리 선택지
3. `uM_ConQTY`의 변환 방향·연결 키
4. `wM_Edit_USER`의 사용자식 편집 범위
5. `wM_iLWi_San`의 조회·편집 경계
6. `wM_JajeXLS`의 입력·출력 방향
7. `wM_Set_Init`의 탭과 칸
8. `wM_Set_JLab`의 직종·노임 설정 칸
9. `TWM_KANJUB`의 전체 탭 캡션·항목 수
10. `TWM_KAN_RATE`의 전체 탭 캡션·그리드 순서
출력 연결 미확인 **4개**: `환율및기초자료` · `〓 INITIAL 〓` · `〓 목 차 〓` · `※※안내※※`의 정확한 생성 폼.
이 문서는 화면 구조 근거만 정리한다. Aislo B08·B09의 구현안이나 화면 설계는 포함하지 않는다.
@@ -0,0 +1,84 @@
# 원가계산 4형식과 설계 뒤 네 단계 — 공통과 차이
점검일: 2026-09-13
근거: [12번](12_원가계산_4형식과_RATE표_복원.md) · [27번](27_계약_실행_기성_단계.md) · [06번](06_규칙1_RATE와_규칙7_간접비_끝전조정.md) · [08번](08_규칙6_8_9_재계산_변경설계_일괄변경.md)
## 결론
**「공식 집합이 통째로 다르다」는 표현은 지나치다.** 네 형식 모두 `직접공사비 → 간접비 → 일반관리비 → 이윤 → 공급가액·부가세`의 큰 사슬을 공유한다. 차이는 직접비를 묶는 방식, 기타경비의 묶음/세분, 일부 항목의 존재, 밑수와 요율표이다.
- 큰 계산 단계: **6단계 중 6단계 공통(10할)**
- 화면상 간접비 항목: **공통 핵심 15개 / 합집합 25개(6할)**
- 따라서 구현 관점에서는 **공통 계산 사슬 + 형식별 항목·밑수·요율 설정**으로 읽는 것이 근거에 맞다.
## 1. 네 형식 비교
`같음`은 항목명만이 아니라 확인된 계산 역할과 밑수까지 같은 경우이다.
| 계산 단계 | 일반 `T` | 수공 `S` | 실적일반 `TA` | 실적수공 `SA` |
|---|---|---|---|---|
| 직접공사비 | 재료비·직접노무비·산출경비를 풀어 표시 | `순공사비` 묶음 | `직접공사비` 묶음 | `직접공사비` 묶음 |
| 간접노무비 | 같음 | 같음 | 같음 | 같음 |
| 산재·고용보험 | 총노무비 × 요율 | 같음(직노+간노로 명시) | 같음 | 같음(직노+간노로 명시) |
| 건강·장기요양·연금·퇴직공제 | 같음 | 같음 | 같음 | 같음 |
| 산업안전보건관리비 | 재료+직노+도급자관급자재 | 같음 | 직접공사비+도급자관급자재 | 같음 |
| 안전·품질관리비 | 별도내역 | 같음 | 같음 | 품질은 있음; 안전관리비는 목록에 없어 **미확인** |
| 기타성 경비 | `기타경비` 한 항목, 재료+노무비 | 8개 경비로 분리, 항목마다 5개 밑수 중 선택 | `기타경비` 한 항목, 직접공사비 | 8개 경비로 분리, 항목마다 5개 밑수 중 선택 |
| 이행·하도급·환경·기계대여 보증 등 | 공통 항목군 | 공통 항목군 | 공통 항목군 | 공통 항목군 |
| 보증·환경 항목 밑수 | 하도급·환경·기계대여는 재료+직노+산출경비 | 같음 | 하도급·기계대여는 직접공사비; 환경은 재료+직노+산출경비 | 재료+직노+산출경비 |
| 일반관리비 | 순공사원가+관리품목자재대 | 순공사비+간노+경비 | 직접공사비+간접공사비+관리품목자재 | 직접공사비+간접노무비 |
| 이윤 | 소계-재료비 | 순공사원가+일반관리비-재료비 | 복원식은 직접+간접공사비+일반관리비, 화면 문구와 충돌해 **미확인** | `TA`와 같은 충돌로 **미확인** |
| 공급가액·부가세·총액 | 같음 | 같음 | 같음 | 같음 |
> 12번의 `TA 환경보전비=직접공사비` 기술은 06번의 후속 정정과 충돌한다. 이 표는 06번의 명시적 정정인 `재료비+직접노무비+산출경비`를 따른다.
### 공통 핵심 15개
간접노무비, 산재보험료, 고용보험료, 건강보험료, 장기요양보험료, 연금보험료, 퇴직공제부금비, 산업안전보건관리비, 품질관리비, 공사이행수수료, 하도급대금지급보증수수료, 환경보전비, 건설기계대여금지급보증수수료, 석면피해구제분담금, 임금채권부담금이다.
합집합 25개는 이 15개에 안전관리비, 일반/실적일반의 기타경비 1개, 수공 계열의 세부 경비 8개를 더한 화면상 항목 수이다. `SA`의 안전관리비 존재가 미확인이므로 **6할은 최대 1개 항목의 집계 오차 가능성**이 있다.
## 2. 형식별 고유 차이
| 형식 | 빠지거나 갈리는 항목 | 달라지는 밑수 | 주 요율표 |
|---|---|---|---|
| 일반 `T` | 기타경비를 한 항목으로 계산; 고용개선지원비(PS단가) 별도 묶음 | 기타경비=재료+노무; 일반관리비에 관리품목자재 포함; 이윤에서 재료 제외 | `RATE_R`, `RATE_R1` + 공통 보조표 |
| 수공 `S` | 기타경비 대신 수도광열·복리후생·소모품/사무용품·여비/교통/통신·세금/공과·폐기물·도서인쇄·지급수수료 8개 | 각 세부 경비가 `재/직노/노/재+직노/재+노` 중 선택; 일반관리비·이윤 밑수도 수공식 | `RATE_R`, `RATE_R1`, `RateB` + 공통 보조표 |
| 실적일반 `TA` | 직접공사비 묶음 사용; 기타경비 한 항목; 국가유산 공종 없음; 노무형태·조정계수·재료비 방식 축 추가 | 안전비·기타경비 및 일부 보증은 직접공사비 계열; 일반관리비 밑수 변경; 이윤 밑수는 **미확인** | `RATE_RA`, `RATE_RA1`, `An_A`, `An_A1` + 공통 보조표 |
| 실적수공 `SA` | 실적 직접공사비 묶음+세부 경비 8개; 안전관리비 존재 **미확인**; 실적 추가축 사용 | 세부 경비는 `RateB`; 일반관리비는 직접공사비+간접노무비; 이윤 밑수는 **미확인** | `RATE_RA`, `RATE_RA1`, `RateB`, 실적 안전표 + 공통 보조표 |
공통 보조표는 고용보험, 산업안전, 환경, 이행보증, 하도급보증, 기계대여보증 등의 항목별 표이다. 즉 `T/S`, `TA/SA`가 각각 규모·기간 요율표를 공유하고, `S/SA`가 세부 경비 밑수표를 추가로 공유한다.
## 3. 설계 뒤 네 단계
| 단계 | 설계 대비 달라지는 계산 | 확인 수준 |
|---|---|---|
| 계약 | 하나의 낙찰률이 아니라 **노무·재료·경비별 단가 적용률**을 사용한다. 기초단가 적용, 적용률을 반영한 일위대가/산출근거 생성, 동일코드 계약단가 개별 생성, 비과세자재 적용을 선택할 수 있다. 행별 적용 제외가 있고 0%는 W코드 공내역을 만든다. | 구조 확인; 실제 계약 표본이 없어 원 단위 결과 **미확인** |
| 실행예산 | 설계 중기사용료와 별도로 `1단위당 중기실행단가`, `1단위=시간`, 실행수량을 둔다. 성분별 절사 차액은 기본/노무/재료/경비 중 지정한 순서로 보정한다. | 중기 화면 구조 확인; 전체 실행예산 및 실제 값 **미확인** |
| 기성 | 설계처럼 간접비마다 밑수×요율을 다시 적용하지 않고 **금회직접공사비 × 계약제잡비율** 단일 요율을 쓴다. 계약·전회·금회·누계와 각 비율을 보존한다. 부가세는 직접입력/공급가액 10%/재료비 10%/(재료비+산출경비) 10% 중 선택한다. | 구조 확인; 기성 표본이 없어 회차별 값과 `QTY1_B/N/O/H` 뜻 **미확인** |
| 준공 | 출력은 `계약금액 | 준공금액` 두 열이다. | 최종 누계를 옮기는 것으로 보이지만 별도 계산 규칙은 **미확인** |
설계의 부가세 선택지는 공급가액 10%, 재료비 10%, 없음, 산림조합, 산림조합-면세품이다. 따라서 부가세라는 이름은 같아도 **기성의 선택 집합은 설계와 다르다.**
## 4. 변경설계에서 당초값을 그대로 쓴 항목
| 판정 | 항목 | 근거 |
|---|---|---|
| 확정 | 건강보험료 | 2025 변경 표본에서 직접노무비가 증가했지만 당초=변경 |
| 확정 | 노인장기요양보험료 | 같은 표본에서 당초=변경 |
| 확정 | 연금보험료 | 같은 표본에서 당초=변경 |
| 재계산 확정 | 간접노무비·산재보험료·고용보험료 | 같은 표본에서 변경 직접노무비에 맞춰 재계산됨 |
| 미확인 | 위 항목 이외 전부 | 추가 `carry_original` 실데이터가 없음 |
보험료 변경 화면에는 `0 직접노무비×요율`, `1 당초액+직접노무비 증감×요율`, `2 당초액+증감액(비율)`의 세 모드가 있다. 그러나 **어느 보험 항목에 어느 모드가 허용·선택되는지**는 정적 자료만으로 전부 확정되지 않았다. 현재 확정 가능한 답은 **건강·장기요양·연금 외 추가 항목 없음**이 아니라 **추가로 확인된 항목 없음**이다.
## 5. 미확인 6건
1. `TA/SA` 이윤 밑수: 복원식과 화면의 `(노+경+일)×율` 문구가 충돌한다.
2. `SA`에 안전관리비가 실제로 빠지는지, 복원 목록의 번호 누락인지.
3. 변경설계에서 건강·장기요양·연금 외 `carry_original` 대상 및 모드 선택 조건.
4. 계약 단가 적용 결과의 원 단위 실증.
5. 실행예산 전체 계산과 기성 회차별 값·`QTY1_B/N/O/H`의 정확한 뜻.
6. 준공금액의 별도 계산 여부와 최종 누계 이관 규칙.
이 여섯 건은 표본 또는 통제 동적 시험 없이 추정해 채우지 않는다.
@@ -0,0 +1,15 @@
"STmate_인용판","과거_절","과거_인용명","STmate_적용공종","2017_임도품셈","2018_통합품셈","2024_개정품셈","현행_절","현행_FP코드","현행_명칭","현행_표ID","판정","근거와_경계"
"판본 미확인","번호 미기재","되메우기 및 다짐","구조물 되메우기(사질양토) 기계100%(굴삭기 0.7㎥)","미확인","미확인","8-13 되메우기 및 다짐","9-14-1","FP-09-14-01","되메우기","F0279","확정","2024 8-13과 현행 9-14-1의 굴삭기 0.7㎥·k=0.9·f=0.69·E=0.65·Cm=18(90°)가 동일. 인용 절·판본은 미확인이고 대응은 되메우기 구성에 한정"
"2017 임도표준품셈","5-1-2 사","암터파기","구조물터파기(육상암 0~1m) 기계100%(굴삭기 0.7㎥)","5-1-2 사 육상 암절취(0~1m)","4.27.2 구조물터파기","8-12 사 육상 암절취(0~1m)","9-13-7","FP-09-13-07","육상 암절취(0~1m)","F0267","확정","2018 목차에 5-1-2→4.27.2가 직접 병기되고 2024·현행의 인력10%+장비90%, 브레이커 Q=3.5, 치즐 0.006, 굴삭기 0.7㎥ 계수가 동일"
"2017 임도표준품셈","7-3","노면정리(건설품셈 기계 시공 적용)","노면정리","7-3 노면다짐","4.40 노면다짐","미확인","미확인","미확인","미확인","미확인","미확인","2017과 2018은 해당 절에서 건설품셈 기계화 시공을 외부 참조할 뿐 자체 표가 없음. 2024 목차에 노면다짐이 없고 현행 3-4-3은 인력식 노면 보수이므로 연결 금지"
"2018 통합 산림사업 표준품셈","4-16-2","선떼붙이기공","단끊기","선행 사방품셈 4-2-2","4.16.2 선떼붙이기공","4-16 선떼붙이기공","5-16 / 5-16-1 / 5-16-2","FP-05-16 / FP-05-16-01 / FP-05-16-02","선떼붙이기공 / 단끊기 / 선떼붙이기","F0121 / F0122","확정","2018 절과 2024 절이 직접 이어지고 현행은 단끊기·선떼붙이기로 분리. 일대다 대응으로 확정하며 대표 하위 절 하나로 자동 치환하지 않음"
"2018 통합 산림사업 표준품셈","4-16-2","절취없는 단끊기","단끊기 절취없음","선행 사방품셈 4-2-2","4.16.2 선떼붙이기공","4-16 가 단끊기","5-16-1","FP-05-16-01","단끊기","F0121","확정","2024와 현행의 수평잡기·단정리 0.34, 잡석·뿌리정리 0.36, 절·성토면고르기 1.17인/100m가 동일. 절취없는 구성에 한정"
"2017 임도표준품셈","3-6 가","임도성토면 다짐","성토사면다짐 B/H0.7+콤팩터","3-6 임도성토면 다짐","4.24 임도성토면 다짐","8-18 임도성토면 다짐","9-17-1","FP-09-17-01","비탈면 다짐","미확인","확정","2018 목차에 3-6→4.24가 직접 병기되고 2017·2024 모두 굴삭기 부착 유압식 진동 콤팩터+굴삭기 0.7㎥, A=77.7㎡/hr로 동일. 현행은 명칭을 비탈면 다짐으로 변경"
"판본·인용 오류 미확인","9-3","임도성토면다짐","성토사면다짐 굴삭기0.7㎥","3-6 임도성토면 다짐","4.24 임도성토면 다짐","8-18 임도성토면 다짐","9-17-1","FP-09-17-01","비탈면 다짐","미확인","확정","공종·장비는 2017 3-6→2018 4.24→2024 8-18→현행 9-17-1로 이어짐. 현행 원문이 임도성토면 다짐, 굴착기 부착 유압식 진동 콤펙터+굴착기 0.7㎥, A=77.7㎡/hr를 직접 명시함. STmate의 9-3과 Q=716.8은 원문과 불일치하므로 절 대응만 확정하고 해당 번호·값 복사는 금지"
"2018 통합 산림사업 표준품셈","4-16-4","파종공","씨뿌리기(성토면) 줄파종","선행 사방품셈 4-2-4","4.16.4 파종공","4-18 씨뿌리기(줄)","5-18","FP-05-18","씨뿌리기(줄)","F0125","확정","2024·현행의 종자 2g, 비료 29.3g, 특별인부 0.00055, 보통인부 0.00838인/m가 STmate 구성과 일치"
"2017 임도표준품셈","3-3-2","굴삭기 적용 암절취","암절취 굴삭기0.7㎥+대형브레카","3-3-2 암절취","4.3.3 암절취","8-3 암절취","9-4 / 9-4-1 / 9-4-2","FP-09-04 / FP-09-04-01 / FP-09-04-02","암절취 / 암파쇄 / 집토","F0241 / F0242","확정","2018 목차에 3-3-2→4.3.3이 직접 병기되고 2024·현행은 암파쇄+집토로 분리. 브레이커 작업능력 5.0·3.4·2.6㎥/hr와 굴삭기 0.7㎥ 계수가 동일한 일대다 대응"
"2017 임도표준품셈","2-3","제근(100㎡당)","제근","2-3 제근","4.20.2 제근","8-22 제근","9-21","FP-09-21","제근","F0294","확정","2018 목차에 2-3→4.20.2가 직접 병기되고 2024 8-22를 거쳐 현행 9-21로 이동. 밀림 보통인부 0.05인/100㎡ 일치"
"2017 임도표준품셈","5-1-1 나","암절취","측구터파기(암) 기계100%(굴삭기 0.4㎥)","5-1-1 나 암절취","4.27.1 나 암절취","8-11 나 암절취","9-12-2","FP-09-12-02","암절취","F0259","확정","2018 목차에 5-1-1→4.27.1이 직접 병기됨. 2024·현행의 연암 Q=3.5, 치즐 0.006 및 측구터파기 조건이 동일. STmate 장비 규격 0.4㎥ 표기는 원문 0.7㎥와 불일치하므로 값 복사는 금지"
"2017 임도표준품셈","3-7 가","층따기","층따기","3-7 층따기","4.25 층따기","8-19 층따기","9-18","FP-09-18","층따기","F0287","확정","2018 목차에 3-7→4.25가 직접 병기되고 2017·2024·현행 모두 굴삭기 0.7㎥, K=0.9, f=1/1.3, E=0.7, Cm=22(180°)가 동일"
"2017 임도표준품셈","3-3-1","토사 굴삭기 적용","토사절취(흙깎기) 굴삭기0.7㎥","3-3-1 토사","4.3.2 토사깎기","8-2 토사깎기","9-3-2","FP-09-03-02","기계","F0240","확정","2018 목차에 3-3-1→4.3.2가 직접 병기되고 2017·2024·현행의 굴삭기 0.7㎥, K=0.9, f=1/1.30, E=0.55~0.45, Cm=20(135°)가 동일"
"2018 통합 산림사업 표준품셈","4-39","쇄석·혼합석 부설","혼합석부설 T=0.1m","7-2 쇄석·혼합석 부설(선행)","4.39 쇄석·혼합석 부설","10-4 쇄석·혼합석 부설","11-4","FP-11-04","쇄석․혼합석 부설","F0328","확정","2018 목차에 7-2→4.39가 직접 병기됨. 2024·현행의 굴삭기 0.7㎥, q=0.7, k=0.55, f=1, E=0.35, Cm=20(135°)가 동일. T=0.1m 환산은 별도 계산"
1 STmate_인용판 과거_절 과거_인용명 STmate_적용공종 2017_임도품셈 2018_통합품셈 2024_개정품셈 현행_절 현행_FP코드 현행_명칭 현행_표ID 판정 근거와_경계
2 판본 미확인 번호 미기재 되메우기 및 다짐 구조물 되메우기(사질양토) 기계100%(굴삭기 0.7㎥) 미확인 미확인 8-13 되메우기 및 다짐 9-14-1 FP-09-14-01 되메우기 F0279 확정 2024 8-13과 현행 9-14-1의 굴삭기 0.7㎥·k=0.9·f=0.69·E=0.65·Cm=18(90°)가 동일. 인용 절·판본은 미확인이고 대응은 되메우기 구성에 한정
3 2017 임도표준품셈 5-1-2 사 암터파기 구조물터파기(육상암 0~1m) 기계100%(굴삭기 0.7㎥) 5-1-2 사 육상 암절취(0~1m) 4.27.2 구조물터파기 8-12 사 육상 암절취(0~1m) 9-13-7 FP-09-13-07 육상 암절취(0~1m) F0267 확정 2018 목차에 5-1-2→4.27.2가 직접 병기되고 2024·현행의 인력10%+장비90%, 브레이커 Q=3.5, 치즐 0.006, 굴삭기 0.7㎥ 계수가 동일
4 2017 임도표준품셈 7-3 노면정리(건설품셈 기계 시공 적용) 노면정리 7-3 노면다짐 4.40 노면다짐 미확인 미확인 미확인 미확인 미확인 미확인 2017과 2018은 해당 절에서 건설품셈 기계화 시공을 외부 참조할 뿐 자체 표가 없음. 2024 목차에 노면다짐이 없고 현행 3-4-3은 인력식 노면 보수이므로 연결 금지
5 2018 통합 산림사업 표준품셈 4-16-2 선떼붙이기공 단끊기 선행 사방품셈 4-2-2 4.16.2 선떼붙이기공 4-16 선떼붙이기공 5-16 / 5-16-1 / 5-16-2 FP-05-16 / FP-05-16-01 / FP-05-16-02 선떼붙이기공 / 단끊기 / 선떼붙이기 F0121 / F0122 확정 2018 절과 2024 절이 직접 이어지고 현행은 단끊기·선떼붙이기로 분리. 일대다 대응으로 확정하며 대표 하위 절 하나로 자동 치환하지 않음
6 2018 통합 산림사업 표준품셈 4-16-2 절취없는 단끊기 단끊기 절취없음 선행 사방품셈 4-2-2 4.16.2 선떼붙이기공 4-16 가 단끊기 5-16-1 FP-05-16-01 단끊기 F0121 확정 2024와 현행의 수평잡기·단정리 0.34, 잡석·뿌리정리 0.36, 절·성토면고르기 1.17인/100m가 동일. 절취없는 구성에 한정
7 2017 임도표준품셈 3-6 가 임도성토면 다짐 성토사면다짐 B/H0.7+콤팩터 3-6 임도성토면 다짐 4.24 임도성토면 다짐 8-18 임도성토면 다짐 9-17-1 FP-09-17-01 비탈면 다짐 미확인 확정 2018 목차에 3-6→4.24가 직접 병기되고 2017·2024 모두 굴삭기 부착 유압식 진동 콤팩터+굴삭기 0.7㎥, A=77.7㎡/hr로 동일. 현행은 명칭을 비탈면 다짐으로 변경
8 판본·인용 오류 미확인 9-3 임도성토면다짐 성토사면다짐 굴삭기0.7㎥ 3-6 임도성토면 다짐 4.24 임도성토면 다짐 8-18 임도성토면 다짐 9-17-1 FP-09-17-01 비탈면 다짐 미확인 확정 공종·장비는 2017 3-6→2018 4.24→2024 8-18→현행 9-17-1로 이어짐. 현행 원문이 임도성토면 다짐, 굴착기 부착 유압식 진동 콤펙터+굴착기 0.7㎥, A=77.7㎡/hr를 직접 명시함. STmate의 9-3과 Q=716.8은 원문과 불일치하므로 절 대응만 확정하고 해당 번호·값 복사는 금지
9 2018 통합 산림사업 표준품셈 4-16-4 파종공 씨뿌리기(성토면) 줄파종 선행 사방품셈 4-2-4 4.16.4 파종공 4-18 씨뿌리기(줄) 5-18 FP-05-18 씨뿌리기(줄) F0125 확정 2024·현행의 종자 2g, 비료 29.3g, 특별인부 0.00055, 보통인부 0.00838인/m가 STmate 구성과 일치
10 2017 임도표준품셈 3-3-2 굴삭기 적용 암절취 암절취 굴삭기0.7㎥+대형브레카 3-3-2 암절취 4.3.3 암절취 8-3 암절취 9-4 / 9-4-1 / 9-4-2 FP-09-04 / FP-09-04-01 / FP-09-04-02 암절취 / 암파쇄 / 집토 F0241 / F0242 확정 2018 목차에 3-3-2→4.3.3이 직접 병기되고 2024·현행은 암파쇄+집토로 분리. 브레이커 작업능력 5.0·3.4·2.6㎥/hr와 굴삭기 0.7㎥ 계수가 동일한 일대다 대응
11 2017 임도표준품셈 2-3 제근(100㎡당) 제근 2-3 제근 4.20.2 제근 8-22 제근 9-21 FP-09-21 제근 F0294 확정 2018 목차에 2-3→4.20.2가 직접 병기되고 2024 8-22를 거쳐 현행 9-21로 이동. 밀림 보통인부 0.05인/100㎡ 일치
12 2017 임도표준품셈 5-1-1 나 암절취 측구터파기(암) 기계100%(굴삭기 0.4㎥) 5-1-1 나 암절취 4.27.1 나 암절취 8-11 나 암절취 9-12-2 FP-09-12-02 암절취 F0259 확정 2018 목차에 5-1-1→4.27.1이 직접 병기됨. 2024·현행의 연암 Q=3.5, 치즐 0.006 및 측구터파기 조건이 동일. STmate 장비 규격 0.4㎥ 표기는 원문 0.7㎥와 불일치하므로 값 복사는 금지
13 2017 임도표준품셈 3-7 가 층따기 층따기 3-7 층따기 4.25 층따기 8-19 층따기 9-18 FP-09-18 층따기 F0287 확정 2018 목차에 3-7→4.25가 직접 병기되고 2017·2024·현행 모두 굴삭기 0.7㎥, K=0.9, f=1/1.3, E=0.7, Cm=22(180°)가 동일
14 2017 임도표준품셈 3-3-1 토사 굴삭기 적용 토사절취(흙깎기) 굴삭기0.7㎥ 3-3-1 토사 4.3.2 토사깎기 8-2 토사깎기 9-3-2 FP-09-03-02 기계 F0240 확정 2018 목차에 3-3-1→4.3.2가 직접 병기되고 2017·2024·현행의 굴삭기 0.7㎥, K=0.9, f=1/1.30, E=0.55~0.45, Cm=20(135°)가 동일
15 2018 통합 산림사업 표준품셈 4-39 쇄석·혼합석 부설 혼합석부설 T=0.1m 7-2 쇄석·혼합석 부설(선행) 4.39 쇄석·혼합석 부설 10-4 쇄석·혼합석 부설 11-4 FP-11-04 쇄석․혼합석 부설 F0328 확정 2018 목차에 7-2→4.39가 직접 병기됨. 2024·현행의 굴삭기 0.7㎥, q=0.7, k=0.55, f=1, E=0.35, Cm=20(135°)가 동일. T=0.1m 환산은 별도 계산
@@ -102,7 +102,7 @@
| `jb_shapes.py` · `jb_block_map.py` · `jb_expansion_diff.py` | JB 43개 형태·블록·확장 차이 |
| `rate_grid_defs.py` · `settings_axis_map.py` | 폼에서 그리드·선택축 추출 |
후속 레시피 분석은 `20_분석/31_일위대가_레시피_사전.md`와 `20_분석/32_특수산식과_현행품셈_대조.md`를 본다.
후속 레시피 분석은 `20_분석/31_일위대가_레시피_사전.md`부터 `20_분석/33_임도품셈_판_대응표.md`까지 본다.
## 다음
@@ -0,0 +1,13 @@
# 건설폐기물 수집·운반차량 덮개 재질에 관한 고시 — 변경이력
기준: 현행(2016.07.01)
**개정 이력**
| 시행일 | 구분 |
|---|---|
| 2016.07.01 | 현행 |
## 개정문
>
@@ -0,0 +1,8 @@
# 건설폐기물 수집·운반차량 덮개 재질에 관한 고시 — 문서 목록
> 총 3건 (본문 3 · 별표 0 · 첨부 0 · 압축 0 · KCS 0)
## 본문
- [메타정보](<_meta.md>)
- [변경이력](<CHANGELOG.md>)
- [현행 20160701](<현행_20160701.md>)
@@ -0,0 +1,10 @@
# 건설폐기물 수집·운반차량 덮개 재질에 관한 고시
- 정식명칭: 건설폐기물 수집·운반차량 덮개 재질에 관한 고시
- 목록상 명칭: 건설폐기물 수집·운반차량 덮개 재질에 관한 고시
- 식별자: `행정규칙ID=2100000043360` (불변)
- 소관: 기후에너지환경부
- 종류: 고시
- 현행 시행일: 2016.07.01 / 공포 2016.03.24 제2016-59호
- 출처: https://www.law.go.kr/행정규칙/건설폐기물수집·운반차량덮개재질에관한고시
- 수집 파일: 현행:20160701
@@ -0,0 +1,19 @@
# 건설폐기물 수집·운반차량 덮개 재질에 관한 고시
> 시행일 2016.07.01 | 공포일 2016.03.24 | 공포번호 2016-59 | 종류 고시 | 제개정 제정 | 소관 기후에너지환경부
> 출처: https://www.law.go.kr/행정규칙/건설폐기물수집·운반차량덮개재질에관한고시
건설폐기물 수집·운반차량 덮개 재질로서 그밖에 금속에 준하는 재질이란 한국산업표준 KS M ISO527 인장시험(시험편의 좁은 평행면 부분의 폭은 10mm로 함)을 적용하여 측정한 인장하중이 500N 이상인 것으로 방수기능을 갖추고 있는 재질을 말한다.
## 부칙
> 부칙 <제2016-59호,2016. 3. 24.>
> 제1조(시행일) 이 고시는 2016년 7월 1일부터 시행한다.
## 첨부파일
- (2016-59) 건설폐기물 수집·운반차량 덮개 재질에 관한 고시.hwp
- (2016-59) 건설폐기물 수집·운반차량 덮개 재질에 관한 고시.hwp
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?><AdmRulService><행정규칙기본정보><행정규칙일련번호>2100000043360</행정규칙일련번호><행정규칙명><![CDATA[건설폐기물 수집·운반차량 덮개 재질에 관한 고시]]></행정규칙명><행정규칙종류>고시</행정규칙종류><행정규칙종류코드>B0003</행정규칙종류코드><발령일자>20160324</발령일자><발령번호>2016-59</발령번호><제개정구분명>제정</제개정구분명><제개정구분코드>200401</제개정구분코드><조문형식여부>Y</조문형식여부><행정규칙ID>52240</행정규칙ID><소관부처명>기후에너지환경부</소관부처명><소관부처코드>1482000</소관부처코드><상위부처명>기후에너지환경부</상위부처명><담당부서기관코드>1480703</담당부서기관코드><담당부서기관명>기후에너지환경부(폐자원관리과)</담당부서기관명><담당자명>정종호</담당자명><전화번호>044-201-7363, 7375</전화번호><현행여부>Y</현행여부><시행일자>20160701</시행일자><생성일자>20160829</생성일자></행정규칙기본정보><조문내용><![CDATA[건설폐기물 수집·운반차량 덮개 재질로서 그밖에 금속에 준하는 재질이란 한국산업표준 KS M ISO527 인장시험(시험편의 좁은 평행면 부분의 폭은 10mm로 함)을 적용하여 측정한 인장하중이 500N 이상인 것으로 방수기능을 갖추고 있는 재질을 말한다.
]]></조문내용><부칙>
<부칙공포일자>20160324</부칙공포일자><부칙공포번호>2016-59</부칙공포번호><부칙내용 ><![CDATA[부칙 <제2016-59호,2016. 3. 24.>
제1조(시행일) 이 고시는 2016년 7월 1일부터 시행한다.]]></부칙내용></부칙><개정문>
<개정문내용>
<![CDATA[⊙환경부 고시 제2016-59호]]>
<![CDATA[]]>
<![CDATA[ 「건설폐기물의 재활용촉진에 관한 법률」 제13조 및 같은 법 시행규칙 제3조의3제4호에 따른 건설폐기물 수집·운반차량의 덮개 재질을 다음과 같이 고시합니다.]]>
<![CDATA[]]>
<![CDATA[ 2016년 3월 24일]]>
<![CDATA[]]>
<![CDATA[ 환경부장관]]>
<![CDATA[]]>
<![CDATA[]]>
<![CDATA[]]>
<![CDATA[건설폐기물 수집·운반차량 덮개 재질에 관한 고시]]>
<![CDATA[]]>
<![CDATA[]]>
<![CDATA[]]>
<![CDATA[[본문 생략]]]>
<![CDATA[]]>
<![CDATA[]]>
<![CDATA[]]>
<![CDATA[ 부칙]]>
<![CDATA[]]>
<![CDATA[제1조(시행일) 이 고시는 2016년 7월 1일부터 시행한다.]]>
<![CDATA[]]>
</개정문내용>
</개정문>
<첨부파일>
<첨부파일명><![CDATA[(2016-59) 건설폐기물 수집·운반차량 덮개 재질에 관한 고시.hwp]]>
</첨부파일명>
<첨부파일링크>http://law.go.kr/flDownload.do?flSeq=24153039
</첨부파일링크>
<첨부파일명><![CDATA[(2016-59) 건설폐기물 수집·운반차량 덮개 재질에 관한 고시.hwp]]>
</첨부파일명>
<첨부파일링크>http://law.go.kr/flDownload.do?flSeq=24153040
</첨부파일링크>
</첨부파일>
</AdmRulService>
@@ -0,0 +1,17 @@
# 건설폐기물의 재활용촉진에 관한 법률 시행규칙 — 변경이력
기준: 현행(2026.06.22)
**개정 이력**
| 시행일 | 구분 |
|---|---|
| 2026.06.22 | 현행 |
## 제개정이유
>
## 개정문
>
@@ -0,0 +1,8 @@
# 건설폐기물의 재활용촉진에 관한 법률 시행규칙 — 문서 목록
> 총 3건 (본문 3 · 별표 0 · 첨부 0 · 압축 0 · KCS 0)
## 본문
- [메타정보](<_meta.md>)
- [변경이력](<CHANGELOG.md>)
- [현행 20260622](<현행_20260622.md>)
@@ -0,0 +1,10 @@
# 건설폐기물의 재활용촉진에 관한 법률 시행규칙
- 정식명칭: 건설폐기물의 재활용촉진에 관한 법률 시행규칙
- 목록상 명칭: 건설폐기물의 재활용촉진에 관한 법률 시행규칙
- 식별자: `법령ID=287419` (불변)
- 소관: 기후에너지환경부
- 종류: 기후에너지환경부령
- 현행 시행일: 2026.06.22 / 공포 2026.06.22 제00042호
- 출처: https://www.law.go.kr/법령/건설폐기물의재활용촉진에관한법률시행규칙
- 수집 파일: 현행:20260622
@@ -0,0 +1,924 @@
# 건설폐기물의 재활용촉진에 관한 법률 시행규칙
> 시행일 2026.06.22 | 공포일 2026.06.22 | 공포번호 00042 | 종류 기후에너지환경부령 | 소관 기후에너지환경부
> 출처: https://www.law.go.kr/법령/건설폐기물의재활용촉진에관한법률시행규칙
### 제1조(목적)
이 규칙은 「건설폐기물의 재활용촉진에 관한 법률」 및 동법 시행령에서 위임된 사항과 그 시행에 관하여 필요한 사항을 규정함을 목적으로 한다.
### 제2조(허용보관량의 산출)
「건설폐기물의 재활용촉진에 관한 법률」(이하 "법"이라 한다) 제2조제5호에 따른 허용보관량의 산출방법은 다음 각 호와 같다. <개정 2010.6.9, 2013.12.13>
1. 법 제21조제3항에 따른 수집ㆍ운반업 허가를 받은 자(이하 "수집ㆍ운반업자"라 한다)중 법 제13조의2제2항에 따라 임시보관장소의 승인을 얻은 수집ㆍ운반업자의 경우 : 승인을 얻은 건설폐기물의 보관용적에 비중 1.5를 곱하여 산출
2. 법 제21조제3항에 따른 중간처리업 허가를 받은 자(이하 "중간처리업자"라 한다)의 경우 : 1일 처리능력에 별표 2 제2호가목(6)에 따라 허가받은 보관시설의 보관일수를 곱하여 산출
### 제2조의2(건설폐기물 처리방법)
법 제6조제2항에서 "기후에너지환경부령으로 정하는 처리방법"이란 다음 각 호와 같다. <개정 2025.10.1>
1. 중간처리(법 제2조제14호에 따른 재활용을 포함한다. 이하 같다): 소각(燒却), 중화(中和), 파쇄(破碎), 고형화(固形化)
2. 최종처리: 매립
### 제3조(재활용 통계조사)
① 특별자치시장ㆍ특별자치도지사 또는 시장ㆍ군수ㆍ구청장(자치구의 구청장을 말한다. 이하 같다)은 법 제11조에 따라 재활용 통계조사를 실시한 후 별지 제1호서식의 건설폐기물 재활용 통계조사보고서를 매년 2월말까지 특별시장ㆍ광역시장ㆍ도지사에게 제출하여야 하며(특별자치시장ㆍ특별자치도지사는 제외한다), 특별시장ㆍ광역시장ㆍ특별자치시장ㆍ도지사ㆍ특별자치도지사(이하 "시ㆍ도지사"로 한다)는 직접 작성하였거나 시장ㆍ군수ㆍ구청장으로부터 제출받은 건설폐기물 재활용 통계조사보고서를 종합하여 매년 3월 31일까지 기후에너지환경부장관에게 제출하여야 한다. <개정 2013.12.13, 2025.10.1>
②제1항 따른 건설폐기물 재활용 통계보고서의 제출은 법 제10조제1항에 따른 건설폐기물 정보관리체계를 이용하여 전송하는 것으로 갈음할 수 있다. <개정 2010.6.9>
### 제3조의2(건설폐기물의 분류체계)
법 제12조에 따른 건설폐기물의 종류별 분류체계는 별표 1과 같다.
### 제3조의3(수집운반차량 덮개 재질)
법 제13조제1항에서 "기후에너지환경부령으로 정하는 재질"이란 다음 각 호의 재질을 말한다. <개정 2025.10.1>
1. 강화플라스틱 재질
2. 폴리카보네이트 재질
3. 탄소섬유 재질
4. 그 밖에 금속에 준하는 재질로서 기후에너지환경부장관이 정하여 고시하는 재질
### 제4조(임시보관장소 승인절차 등)
① 삭제 <2013.12.13>
②법 제13조의2제2항에 따라 건설폐기물을 적정하게 처리 또는 보관할 수 있는 장소 외의 장소(이하 "임시보관장소"라 한다)의 승인을 얻고자 하는 자는 별지 제2호서식의 임시보관장소설치승인신청서에 다음의 서류를 첨부하여 당해 임시보관장소 설치예정지를 관할하는 시ㆍ도지사에게 제출하여야 한다. <개정 2013.12.13>
1. 건설폐기물의 수집ㆍ운반계획서
2. 임시보관장소의 규모를 확인할 수 있는 서류
3. 임시보관장소에 보관할 수 있는 건설폐기물의 양 및 그 산출근거에 관한 증빙서류
4. 건설폐기물의 보관과 관련하여 예상되는 환경오염에 대한 대책
5. 해당 토지 또는 건축물 등에 대한 적법한 사용권을 증빙할 수 있는 서류
③시ㆍ도지사는 임시보관장소의 설치승인을 한 때에는 별지 제3호서식의 임시보관장소설치승인서를 신청인에게 교부하여야 한다.
④제3항의 규정에 의하여 승인을 얻은 자가 다음 각호의 어느 하나에 해당하는 사유가 발생하여 승인을 얻은 사항을 변경하고자 하는 경우에는 변경전에 별지 제2호서식의 임시보관장소변경승인신청서에 임시보관장소설치승인서 및 변경내용을 증명하는 서류를 첨부하여 관할 시ㆍ도지사에게 제출하여야 한다.
1. 임시보관장소의 소재지 변경(관할 시ㆍ도 구역내의 소재지 변경에 한한다)
2. 임시보관대상 건설폐기물 종류의 변경
3. 승인을 얻은 건설폐기물의 보관량 또는 보관기간의 변경
⑤시ㆍ도지사는 임시보관장소의 변경승인을 한 때에는 임시보관장소설치승인서에 변경사항을 기재하여 신청인에게 교부하여야 한다.
⑥제3항 또는 제5항의 규정에 의하여 임시보관장소를 승인 또는 변경승인을 한 시ㆍ도지사는 그 내용을 즉시 해당 수집ㆍ운반업자의 허가기관에 통보하여야 한다.
### 제4조의2(임시보관장소 승인조건 등)
① 시ㆍ도지사는 법 제13조의2제3항에 따라 임시보관장소 승인시 다음 각 호의 사항 등에 관하여 조건을 붙일지를 검토하여야 한다.
1. 임시보관장소에 보관하는 건설폐기물의 보관기간에 관한 사항. 다만, 보관기간에 관한 조건은 정당한 사유가 없는 한 10일 이내로 하여야 한다.
2. 비산먼지 및 소음의 발생억제를 위하여 필요한 조치사항
3. 방치폐기물 발생예방을 위하여 필요한 조치사항
② 법 제13조의2제4항에서 "기후에너지환경부령으로 정하는 일정거리"란 1킬로미터를 말한다. <개정 2025.10.1>
③ 법 제13조의2제4항에 따라 갖추어야 하는 시설은 다음 각 호와 같다. 다만, 제4호 또는 제5호의 시설은 특별시ㆍ광역시ㆍ특별자치시ㆍ도ㆍ특별자치도(이하 "시ㆍ도"라 한다)의 조례로 정하는 바에 따라 시ㆍ도지사가 인정하는 경우에는 갖추지 아니할 수 있다.
1. 보관장소 전체를 두르는 높이 10미터 이상의 방진벽
2. 비산먼지 발생을 억제할 수 있는 물을 뿌리는 시설(해당 시설로부터 뿌려지는 물이 보관장소 전체에 미쳐야 한다)
3. 폐기물의 흩날림을 방지할 수 있는 방진 덮개
4. 바닥포장
5. 지붕 덮개시설
### 제5조(건설폐기물의 배출, 수집ㆍ운반 등의 구체적인 기준ㆍ방법)
①영 제9조제1항제2호에 따른 건설폐기물 수집ㆍ운반증의 발급절차, 규격 및 기재방법 등에 대하여는 「폐기물관리법 시행규칙」 별표 5 제6호의 규정을 준용한다. <개정 2007.10.25, 2010.6.9>
②영 제9조제2항에 따른 건설폐기물의 배출, 수집ㆍ운반, 보관, 중간처리에 관한 구체적인 기준 및 방법은 별표 1의2와 같다. <개정 2007.1.9, 2008.12.31>
### 제6조(용역이행실적 등의 신고)
중간처리업자는 법 제14조제3항에 따라 매년 2월 15일(제2호의 서류의 경우에는 4월 15일)까지 별지 제4호서식의 건설폐기물 중간처리용역 이행실적신고서에 다음의 서류를 첨부하여 법 제55조에 따른 협회(이하 "협회"라 한다)에 신고하여야 한다. <개정 2007.12.31, 2010.6.9, 2016.6.2, 2019.12.20>
1. 용역이행실적을 증명하는 다음 각 목의 서류
가. 국가, 지방자치단체 및 법 제2조제15호 각 목의 기관으로부터 수탁한 건설폐기물 중간처리용역의 경우에는 발주자가 발행한 별지 제5호서식의 건설폐기물 중간처리용역 이행실적증명서
나. 가목 이외의 자로부터 수탁한 건설폐기물 중간처리용역의 경우에는 다음 1)과 2)의 서류
1) 건설폐기물 중간처리용역 수탁계약서 사본
2) 관할세무서에 제출한 세금계산서합계표. 다만, 세금계산서합계표를 제출할 수 없는 경우에는 법 제17조에 따라 배출자 신고를 한 자(이하 "배출신고자"라 한다)가 제27조에 따라 신고기관의 장에게 제출한 해당 건설폐기물 배출 및 처리실적보고서
2. 관할세무서에 제출한 재무제표(재무상태표 및 손익계산서가 포함된 것을 말한다. 이하 이 호에서 같다) 또는 「주식회사의 외부감사에 관한 법률」에 의하여 외부감사를 행한 공인회계사가 검사한 재무제표
3. 법 제36조제1항의 규정에 의한 순환골재의 품질인증을 확인할 수 있는 서류(중간처리업자로서 품질인증을 받은 자에 한한다)
4. 별지 제6호서식의 순환골재판매내역서(중간처리업자로서 품질인증을 받은 자에 한한다)
5. 최근 3년간 「조세특례제한법 시행령」 별표 6에 따른 비용 중 건설폐기물 재활용기술 분야에 실제로 사용된 기술개발투자액을 증명할 수 있는 서류
6. 영 별표 3 제2호바목에 따른 신인도별 가점요소 및 감점요소에 관한 서류
7. 주변환경의 오염 방지를 위한 시설ㆍ기술인력의 보유현황을 증명할 수 있는 서류
8. 건설폐기물 중간처리업허가증 사본
### 제7조(용역이행능력의 공시시기 등)
①협회는 제6조에 따른 건설폐기물 중간처리용역 이행실적신고서를 제출받은 경우에는 법 제14조제1항에 따라 다음의 항목을 공시하여야 한다. <개정 2007.12.31, 2010.6.9, 2016.6.2, 2017.10.19>
1. 상호 및 성명(법인인 경우에는 대표자의 성명)
2. 주된 영업소의 소재지 및 전화번호
3. 건설폐기물 처리시설의 소재지
4. 사업자등록번호
5. 용역이행능력평가액
6. 건설폐기물 중간처리용역 실적
7. 시설ㆍ장비 및 기술능력의 현황(주변환경의 오염방지를 위한 시설ㆍ기술인력의 보유현황을 포함한다)
②협회는 제1항 각호의 사항을 매년 7월 31일까지 일간신문 또는 협회의 홈페이지에 공시하여야 한다.
③협회는 용역이행능력에 관한 서류를 비치하여 일반인이 열람할 수 있도록 하여야 한다.
④ 협회는 법 제14조에 따른 용역이행능력을 평가하기 위하여 필요한 경우에는 시ㆍ도지사에게 행정처분 현황 등 관련 자료를 요청할 수 있다. <신설 2007.12.31>
⑤ 협회는 중간처리업자가 제1항제4호에 따른 용역이행능력평가액의 확인을 요청한 때에는 별지 제9호의2서식에 따른 확인서를 발급하여야 한다. <신설 2008.12.31, 2010.6.9>
### 제7조의2(환경오염방지시설평점 등 산정방법)
영 별표 3 제2호마목2)에 따른 환경오염방지시설평점 및 환경오염방지인력평점의 산정 방법은 별표 4와 같다.
### 제8조(건설폐기물의 적절한 처리비용의 반영)
법 제15조제2항에 따라 발주자가 건설폐기물의 처리용역을 발주하는 때에는 건설폐기물의 배출량을 기준으로 적절한 처리비용을 반영하여야 하며, 당초 발주한 건설폐기물 외에 추가로 발생하는 건설폐기물에 대하여도 배출량을 기준으로 적절한 처리비용을 반영하여야 한다. <개정 2010.6.9>
### 제8조의2(건설폐기물 처리용역의 수탁능력 확인 등)
① 배출자는 법 제16조제1항 각 호 외의 부분 단서에 따라 수탁자의 건설폐기물 수집ㆍ운반 및 처리능력을 확인하기 위하여 수탁자로부터 다음 각 호의 구분에 따른 서류를 제출받아야 한다. <개정 2017.10.19>
1. 건설폐기물 처리업자에게 위탁하려는 경우: 건설폐기물 처리업허가증 사본, 법 제42조의 방치폐기물 처리이행보증 조치를 확인할 수 있는 서류 사본, 별지 제9호의2서식에 따른 용역이행능력평가액확인서 또는 「폐기물관리법 시행규칙」 제17조제1항에 따른 수탁처리능력확인서
2. 법 제16조제1항제3호 및 제4호에 해당하는 자에게 위탁하려는 경우: 폐기물처리업 허가증 또는 「폐기물관리법 시행규칙」 제67조제2항에 따른 폐기물처리 신고증명서 사본, 「폐기물관리법」 제40조제1항에 따른 방치폐기물 처리이행보증을 확인할 수 있는 서류 사본이 포함된 같은 법 시행규칙 제17조제1항에 따른 수탁처리능력확인서
② 배출자는 제1항에 따라 제출받은 자료를 검토하여 수탁자가 해당 폐기물을 수집ㆍ운반 및 처리할 능력이 있는지를 확인한 후 계약을 체결하여야 한다.
### 제9조(배출자의 신고 등)
①배출자는 법 제17조제1항에 따라 해당 건설공사의 착공일까지 별지 제7호서식의 건설폐기물 처리계획서를 작성하여 건설폐기물의 발생지를 관할하는 특별자치도지사ㆍ시장ㆍ군수ㆍ구청장(이하 "시장ㆍ군수ㆍ구청장"이라 한다)에게 신고하여야 한다. 다만, 별표 1의2제1호자목에 따라 처리된 폐금속류는 건설폐기물 처리계획서에 포함하지 아니할 수 있다. <개정 2010.6.9>
②시장ㆍ군수ㆍ구청장은 제1항의 규정에 의한 건설폐기물 처리계획서를 받은 때에는 별지 제8호서식의 건설폐기물 처리계획신고증명서를 신고인에게 교부하여야 한다. <개정 2010.6.9, 2019.12.20>
③제1항에 따라 신고를 한 자는 다음 각 호의 어느 하나에 해당하는 경우에는 건설폐기물을 수집ㆍ운반 또는 처리하기 전까지 별지 제7호서식의 건설폐기물 처리변경계획서에 건설폐기물 처리계획신고증명서 및 변경내용을 증명하는 서류를 첨부하여 시장ㆍ군수ㆍ구청장에게 제출하여야 한다. <개정 2010.6.9, 2017.10.19, 2019.12.20>
1. 신고한 건설폐기물의 총 발생량이 50퍼센트 이상 증가하는 경우
2. 신고한 건설폐기물 외의 건설폐기물이 5톤 이상 새로 발생되는 경우
3. 신고한 건설폐기물의 처리계획 중 처리업체ㆍ처리방법을 변경하는 경우
4. 상호 또는 사업장의 소재지를 변경하는 경우
5. 건설폐기물이 발생되는 공사기간이 90일 이상 연장되는 경우
④법 제17조제2항제4호에서 "그 밖에 기후에너지환경부령으로 정하는 사항"이란 다음 각 호와 같다. <개정 2010.6.9, 2025.10.1>
1. 해당 건설폐기물의 발생주기
2. 해당 건설폐기물의 보관방법
3. 해당 건설폐기물의 처리계획
4. 제8조의2제1항에 따른 수탁처리능력을 확인할 수 있는 서류 사본(건설폐기물을 위탁처리하는 경우로 한정한다)
### 제10조(건설폐기물 인계ㆍ인수방법 등)
① 법 제18조제1항 단서에 따라 건설폐기물 간이인계서의 작성으로 갈음할 수 있는 경우는 다음 각 호와 같다. <개정 2013.12.13>
1. 건설폐기물 총배출량이 10톤 미만인 경우. 다만, 추가로 발생된 건설폐기물로 인하여 총배출량이 10톤 이상으로 변경되는 경우에는 총배출량을 합산하여 법 제18조제1항 본문에 따라 전자정보처리프로그램에 입력하여야 한다.
2. 천재지변 또는 상ㆍ하수도관의 파열ㆍ누수 등으로 인한 긴급복구공사를 하는 경우
② 법 제18조제4항에 따른 전자정보처리프로그램 입력내용ㆍ방법ㆍ시기는 별표 2의2와 같고, 건설폐기물 간이인계서의 작성방법ㆍ시기 등은 다음 각 호와 같다. <개정 2020.4.17>
1. 건설폐기물 간이인계서 작성은 별지 제9호서식(1)부터 (4)까지로 한다.
2. 건설폐기물 간이인계서의 작성방법ㆍ인계 시기는 다음 각 목과 같다.
가. 배출자는 운반자에게 건설폐기물을 인계하는 때에 건설폐기물 간이인계서 4매의 해당란을 기재ㆍ확인한 후 인계하여야 한다.
나. 운반자는 건설폐기물을 인수한 때에 인계받은 건설폐기물 간이인계서 4매의 작성 해당란을 기재ㆍ확인한 후 건설폐기물 간이인계서(4)는 배출자에게 돌려주고, 나머지 3매는 건설폐기물을 처리하는 자에게 건설폐기물을 인계하는 때에 인계하여야 한다.
다. 건설폐기물을 처리하는 자는 건설폐기물을 인수하는 때에 인계받은 건설폐기물 간이인계서 3매의 해당란을 기재ㆍ확인한 후 건설폐기물 간이인계서(3)은 운반자에게 돌려주고, 건설폐기물 간이인계서(2)는 보관하며, 건설폐기물 간이인계서(1)은 운반자로부터 건설폐기물을 인수한 날부터 3일 이내에 배출자에게 송부하여야 한다.
### 제10조의2(전산정보의 열람)
법 제19조제5항에서 "기후에너지환경부령으로 정하는 자"란 법 제19조제4항에 따라 해당 건설폐기물의 처리정보를 법 제19조제1항의 전자정보처리프로그램에 입력한 배출자, 수집ㆍ운반자, 처리자를 말한다. <개정 2025.10.1>
### 제11조(건설폐기물의 처리를 위탁받은 때의 송부서류)
영 제13조제4호에서 "기후에너지환경부령이 정하는 서류"란 다음과 같다. 다만, 법 제15조에 따라 건설공사와 분리하여 발주된 건설폐기물 처리용역을 위탁받은 경우에는 제2호의 서류를 제외한다. <개정 2010.6.9, 2025.10.1>
1. 건설폐기물 처리업허가증 사본
2. 별지 제9호의2서식에 따른 용역이행능력평가액 확인서 또는 「폐기물관리법 시행규칙」 제17조제1항에 따른 수탁처리능력 확인서
3. 법 제42조에 따른 방치폐기물처리이행보증 조치를 확인할 수 있는 서류
### 제12조(건설폐기물 처리업의 허가 등)
① 삭제 <2013.12.13>
②법 제21조제1항에 따라 건설폐기물 처리업을 영위하고자 하는 자는 별지 제10호서식의 건설폐기물 처리 사업계획서에 다음의 서류를 첨부하여 건설폐기물 처리시설 설치예정지(수집ㆍ운반업의 경우에는 연락장소 또는 사무실 소재지)를 관할하는 시ㆍ도지사에게 제출하여야 한다. 건설폐기물 처리 사업계획서의 내용 중 허가기준에 관한 사항을 변경하고자 하는 때에도 또한 같다. <개정 2010.6.9, 2013.12.13, 2016.6.2>
1. 건설폐기물의 수집ㆍ운반 또는 중간처리계획서(시설설치계획, 장비ㆍ기술능력ㆍ사업장 부지의 확보계획을 포함한다)
2. 법 제30조제1항 각호의 배출시설(이하 "배출시설"이라 한다)의 설치 허가신청 또는 신고시의 첨부서류(설치되는 건설폐기물 처리시설이 배출시설에 해당하는 경우에 한하며, 제1호의 서류와 중복되는 경우를 제외한다)
③ 법 제21조제3항제1호에서 "기후에너지환경부령으로 정하는 일정거리"란 1킬로미터를 말한다. <신설 2013.12.13, 2025.10.1>
④ 법 제21조제3항제1호에 해당하는 처리시설은 다음 각 호에서 정하는 비산먼지ㆍ침출수ㆍ악취를 방지하는 건물 또는 시설을 모두 갖추어야 한다. <신설 2013.12.13>
1. 중간처리를 하기 위한 시설 공정에 다음 각 목의 건물 또는 시설 중 하나 이상을 설치할 것
가. 중간처리를 하기 위한 시설 전체를 옥내화하여 「건축법」 제11조에 따른 건축허가를 받은 건물
나. 중간처리를 하기 위한 시설 공정 내 폐기물 투입, 파쇄ㆍ분쇄 과정에서 발생한 분진의 흩날림을 방지할 수 있는 살수시설과 이송과정에서 발생한 분진의 흩날림을 방지할 수 있는 덮개 시설
2. 건설폐기물 보관시설에 다음 각 목의 시설 등을 갖출 것. 다만 라목 또는 마목의 시설은 시ㆍ도의 조례로 정하는 바에 따라 시ㆍ도지사가 인정하는 경우에는 갖추지 아니할 수 있다.
가. 보관시설 전체를 두르는 높이 10미터 이상의 방진벽(사업장 부지에 방진벽이 설치되지 아니하였거나, 설치되었음에도 보관 중인 폐기물이 외부에서 보이는 경우에 한하여 설치한다)
나. 비산먼지 발생을 억제할 수 있는 물을 뿌리는 시설(해당 시설로부터 뿌려지는 물이 보관시설 전체에 미쳐야 한다)
다. 폐기물의 흩날림을 방지할 수 있는 방진 덮개
라. 바닥포장
마. 지붕 덮개시설(건설폐기물 보관시설에서 폐기물 절단 행위가 필요한 경우에 한하여 설치한다)
⑤ 법 제21조제3항제2호에 따른 시설, 장비, 기술능력, 자본금 및 사업장 부지와 그 밖에 필요한 사항은 별표 2와 같다. <신설 2013.12.13>
⑥법 제21조제3항에 따라 사업계획의 적합통보를 받은 자는 별지 제11호서식의 건설폐기물 처리업허가신청서에 다음의 서류를 첨부하여 시ㆍ도지사에게 제출하여야 한다. <개정 2010.6.9, 2013.12.13, 2016.6.2>
1. 시설 및 장비명세서
2. 처리시설 설치내역서 및 그 도면과 처리공정도(수집ㆍ운반업의 경우를 제외한다)
3. 건설폐기물의 처리공정도(수집ㆍ운반업의 경우에는 수집ㆍ운반계획서를 말한다)
4. 기술능력의 보유현황 및 그 자격을 증명하는 서류(수집ㆍ운반업의 경우를 제외한다)
5. 허용보관량 및 그 산출근거에 관한 증빙자료(수집ㆍ운반업의 경우를 제외한다)
6. 자본금 또는 재산을 증명하는 서류
7. 사업장부지의 규모를 증명하는 서류(수집ㆍ운반업의 경우를 제외한다)
⑦ 삭제 <2016.6.2>
⑧시ㆍ도지사는 건설폐기물 처리업의 허가를 한 때에는 별지 제12호서식의 건설폐기물 수집ㆍ운반업허가증 또는 별지 제13호서식의 건설폐기물 중간처리업허가증을 신청인에게 교부하여야 한다. <개정 2010.6.9, 2013.12.13>
### 제13조(건설폐기물 처리업의 변경허가)
① 법 제22조제1항에서 "기후에너지환경부령으로 정하는 중요 사항"이란 다음 각 호를 말한다. <개정 2013.12.13, 2017.10.19, 2025.10.1>
1. 수집ㆍ운반 또는 중간처리대상 건설폐기물의 변경
2. 건설폐기물 처리시설 소재지의 변경
3. 운반차량(「폐기물관리법 시행규칙」 별표 5 제6호에 따른 임시차량을 제외한다)의 증차
4. 건설폐기물 처리시설 또는 순환아스팔트콘크리트 생산시설의 신설
5. 허가 또는 변경허가를 받은 처리용량의 30퍼센트 이상의 변경(허가 또는 변경허가를 받은 후 변경되는 누계를 말한다)
6. 주요설비의 변경(건설폐기물 처리시설 또는 순환아스팔트콘크리트 생산시설의 구조변경에 따라 배출시설의 변경허가 또는 변경신고대상이 되는 경우와 습식 파쇄ㆍ분쇄시설로의 구조변경이 있는 경우에 한정한다)
7. 허용보관량의 변경
8. 사업장 부지의 확장 또는 축소
② 법 제22조제1항에 따라 변경허가를 받으려는 자는 별지 제10호서식의 건설폐기물 처리업 변경계획서에 다음 각 호의 서류를 첨부하여 시ㆍ도지사에게 제출하여야 한다. <개정 2013.12.13, 2016.6.2, 2017.10.19>
1. 건설폐기물 처리업허가증
2. 변경계획 또는 변경내용을 증명하는 서류
3. 제12조제2항제2호에 따른 서류(배출시설에 해당하는 건설폐기물 처리시설 또는 순환아스팔트콘크리트 생산시설을 신설하는 경우만 해당한다)
4. 배출시설의 변경허가신청 또는 변경신고시의 첨부서류(처리용량 또는 주요설비의 변경으로 인하여 배출시설의 변경허가를 받거나 또는 변경신고를 하는 경우에 한정한다)
③ 시ㆍ도지사는 제2항에 따라 제출된 건설폐기물 처리업 변경계획서를 검토하고 그 계획이 적절한지를 건설폐기물 처리업 변경계획서를 제출한 자에게 통보하여야 한다. <신설 2013.12.13>
④ 제3항에 따라 적합통보를 받은 자는 적합통보를 받은 날부터 1년(수집ㆍ운반업의 경우에는 6월) 이내에 별지 제11호서식의 건설폐기물 처리업 변경허가 신청서를 시ㆍ도지사에게 제출하여야 한다. <신설 2013.12.13>
⑤ 시ㆍ도지사는 천재ㆍ지변 그 밖의 부득이한 사유로 인하여 제4항에 따른 기간 내에 변경허가 신청을 하지 못한 자에 대하여는 신청에 의하여 그 기간을 연장할 수 있다. <신설 2013.12.13>
⑥ 시ㆍ도지사는 건설폐기물 처리업의 변경허가를 한 때에는 변경된 사항을 기재하여 허가증을 재교부하여야 한다. <신설 2013.12.13>
### 제13조의2(건설폐기물 처리업의 변경신고)
① 법 제22조제2항에서 "기후에너지환경부령으로 정하는 경미한 사항"이란 다음 각 호와 같다. <개정 2016.6.2, 2025.10.1>
1. 상호의 변경(법 제31조에 따른 권리ㆍ의무의 승계로 상호가 변경되는 경우는 제외한다)
2. 대표자의 변경(법 제31조에 따른 권리ㆍ의무의 승계로 대표자가 변경되는 경우는 제외한다)
3. 연락장소 또는 사무실 소재지의 변경
4. 운반차량의 감차 또는 교체(「폐기물관리법 시행규칙」 별표 5 제6호에 따른 임시차량은 제외한다)
② 법 제22조제2항에 따라 변경신고하려는 자는 제1항제1호 및 제2호의 경우에는 그 사유가 발생한 날부터 30일 이내에, 같은 항 제3호 및 제4호의 경우에는 변경 전에 각각 별지 제11호의2서식의 건설폐기물 처리업 변경신고서에 다음 각 호의 서류를 첨부하여 시ㆍ도지사에게 제출해야 한다. <개정 2020.4.17>
1. 건설폐기물 처리업허가증
2. 변경내용을 증명하는 서류
### 제13조의3(건설폐기물 처리업자의 준수사항)
영 제13조제8호에서 "그 밖에 건설폐기물의 적절한 처리를 위하여 기후에너지환경부령으로 정하는 준수사항"이란 별표 2의3과 같다. <개정 2025.10.1>
### 제14조(허가증의 재교부)
건설폐기물 처리업자는 건설폐기물 처리업허가증을 잃어버리거나 건설폐기물 처리업허가증이 헐어 못쓰게 된 경우에는 시ㆍ도지사에게 재교부를 신청할 수 있다. <개정 2010.6.9>
### 제15조(허가취소 또는 영업정지 명령시 방치폐기물의 적절한 처리 등)
시ㆍ도지사가 건설폐기물 처리업 허가 및 임시보관장소 승인을 취소하거나 건설폐기물 처리업 영업정지 또는 임시보관장소로 건설폐기물 반입정지를 명하려면 법 제25조제5항에 따라 다음 각 호의 조치를 하여야 한다. <개정 2010.6.9, 2013.12.13>
1. 방치폐기물의 발생을 방지할 수 있도록 건설폐기물의 보관 및 수탁상황을 파악할 것
2. 건설폐기물 처리업자가 보관하고 있는 건설폐기물을 일정기간을 정하여 적절하게 처리하도록 할 것
### 제15조의2(건설폐기물 처리업자의 행정처분기준)
법 제25조제6항에 따른 행정처분기준은 별표 3과 같다. <개정 2013.12.13>
### 제16조(건설폐기물 처리시설의 설치승인 등)
①법 제27조제1항 본문에 따른 건설폐기물 처리시설을 직접 설치ㆍ운영하여 건설폐기물을 재활용하고자 하는 자는 별지 제14호서식의 건설폐기물 처리시설 설치승인신청서에 다음의 서류를 첨부하여 그 시설의 소재지를 관할하는 시ㆍ도지사에게 제출하여야 한다. <개정 2010.6.9, 2013.12.13>
1. 처리대상 건설폐기물의 처리공정도 및 건설폐기물 배출내역서
2. 건설폐기물의 성질과 상태ㆍ종류 및 예상배출량 내역서
3. 건설폐기물 처리시설의 설치 및 장비확보계획서
4. 건설폐기물의 중간처리 및 재활용 계획서
5. 건설폐기물 처리시설의 설계도서
6. 중간처리과정에서 발생되는 폐기물의 처리계획서
7. 배출시설의 설치 허가신청 또는 신고시의 첨부서류(배출시설에 해당하는 건설폐기물 처리시설을 설치하는 경우에 한하며, 제1호 내지 제6호의 서류와 중복되는 경우를 제외한다)
② 법 제27조제1항 단서에서 "기후에너지환경부령으로 정하는 규모의 건설폐기물 처리시설"이란 바닥면적이 330제곱미터 미만의 자연식 탈수ㆍ건조시설을 말한다. <개정 2013.12.13, 2025.10.1>
③ 법 제27조제3항에 따라 변경승인을 받거나 변경신고를 하여야 하는 사항은 다음 각 호와 같다. <개정 2013.12.13>
1. 상호의 변경
2. 건설폐기물 처리시설 소재지의 변경
3. 처리대상 건설폐기물의 변경
4. 승인 또는 변경승인을 받거나 신고 또는 변경신고한 처리용량의 합계의 100분의 30이상 증가(변경한 처리용량의 누계가 100분의 30이상인 경우를 포함한다)
5. 주요설비의 변경(건설폐기물 처리시설의 구조변경으로 인하여 배출시설의 변경허가 또는 변경신고대상에 되는 경우와 습식 파쇄ㆍ분쇄시설로의 구조변경이 있는 경우로 한정한다)
④ 제3항에 따라 변경승인을 받거나 변경신고를 하려는 자는 같은 항 제1호에 해당하면 승인 또는 신고 사유가 발생한 날부터 30일 이내에, 같은 항 제2호부터 제5호까지에 해당하면 변경 전에 각각 별지 제14호서식의 건설폐기물 처리시설 설치변경승인신청서 또는 별지 제18호서식의 건설폐기물 처리시설 설치변경신고서에 다음 각 호의 서류를 첨부하여 시ㆍ도지사에게 제출하여야 한다. <개정 2013.12.13, 2019.12.20>
1. 건설폐기물 처리시설 설치승인서 또는 건설폐기물 처리시설 설치신고증명서
2. 변경내용을 확인할 수 있는 서류(제3항제1호의 경우만 제출한다)
3. 건설폐기물 처리시설의 설치변경계획서(제3항제4호 및 제5호의 경우만 제출한다)
4. 배출시설의 변경허가신청 또는 변경신고시의 첨부서류(처리용량이나 주요 설비의 변경 등으로 배출시설의 변경허가 또는 변경신고 대상에 해당되는 경우만 제출한다)
⑤ 시ㆍ도지사는 제1항부터 제4항까지에 따른 건설폐기물 처리시설 설치ㆍ변경승인 신청 또는 설치ㆍ변경신고가 다음 각 호의 기준에 적합한 경우에는 별지 제15호서식의 건설폐기물 처리시설 설치승인서 또는 별지 제19호서식의 건설폐기물 처리시설 설치신고증명서를 신청인에게 내주어야 한다. <신설 2013.12.13, 2019.12.20>
1. 법 제13조에 따른 건설폐기물의 처리기준
2. 법 제29조에 따른 건설폐기물 처리시설의 설치 및 관리 기준
### 제17조
삭제 <2007.1.9>
### 제18조(시험ㆍ연구용 건설폐기물 처리시설의 설치신고 등)
① 법 제27조제4항 전단에 따라 시험ㆍ연구 등의 목적으로 건설폐기물 처리시설을 설치ㆍ운영하려는 자는 별지 제18호서식의 건설폐기물 처리시설 설치신고서에 다음 각 호의 서류를 첨부하여 시ㆍ도지사에게 제출하여야 한다. <개정 2013.12.13>
1. 건설폐기물 처리시설의 설치목적
2. 건설폐기물 처리시설의 설치계획 및 운영기간
3. 건설폐기물 처리시설의 운영계획서
4. 배출시설의 설치 허가신청 또는 신고시의 첨부서류(배출시설에 해당하는 건설폐기물 처리시설의 경우에만 제출한다)
② 법 제27조제4항 후단에 따라 변경신고를 하여야 하는 사항은 다음 각 호와 같다. <개정 2013.12.13>
1. 처리대상 건설폐기물 종류의 변경
2. 건설폐기물 처리시설 소재지의 변경
3. 주요설비의 변경(건설폐기물 처리시설의 구조변경으로 인하여 배출시설의 변경허가 또는 변경신고대상에 되는 경우와 습식 파쇄ㆍ분쇄시설로의 구조변경이 있는 경우로 한정한다)
③ 제2항에 따라 변경신고를 하려는 자는 별지 제18호서식의 건설폐기물 처리시설 설치변경신고서에 다음 각 호의 서류를 첨부하여 시ㆍ도지사에게 제출하여야 한다. <개정 2019.12.20>
1. 건설폐기물 처리시설 설치신고증명서
2. 건설폐기물 처리시설의 설치변경계획서(제2항제3호의 경우만 제출한다)
3. 배출시설의 변경허가신청 또는 변경신고시의 첨부서류(처리용량이나 주요설비의 변경 등으로 인하여 배출시설의 변경허가 또는 변경신고 대상에 해당되는 경우에만 제출한다)
④ 시ㆍ도지사는 제1항 및 제2항에 따른 건설폐기물 처리시설 설치신고서 또는 변경신고서를 받은 때에는 별지 제19호서식의 건설폐기물 처리시설 설치신고증명서를 신고인에게 내주어야 한다. <개정 2019.12.20>
### 제19조
삭제 <2010.6.9>
### 제20조(건설폐기물 처리시설의 사용신고)
건설폐기물 처리시설의 설치자는 법 제28조에 따라 해당 시설의 사용개시일 10일전까지 별지 제20호서식의 건설폐기물 처리시설 사용개시신고서에 해당 건설폐기물 처리시설의 유지ㆍ관리계획서를 첨부하여 시ㆍ도지사에게 제출하여야 한다. <개정 2010.6.9>
### 제21조(건설폐기물 처리시설의 설치 및 관리기준)
①법 제29조제1항에 따른 건설폐기물 처리시설의 설치기준은 다음과 같다. <개정 2007.10.25, 2010.6.9>
1. 건설폐기물이 흩날리거나 흘러나오는 것을 방지하는데 필요한 설비를 할 것
2. 처리시설의 바닥은 시멘트ㆍ아스팔트 등의 재료로 포장할 것. 다만, 토양오염의 우려가 없는 단순 파쇄ㆍ분쇄시설을 설치할 경우에는 합성수지, 합성고무 등 불투수성(不透水性) 재료로 포장할 수 있다.
3. 파쇄ㆍ분쇄시설은 투입ㆍ파쇄ㆍ이송ㆍ토출(吐出) 장치 및 분리ㆍ선별시설 등을 설치할 것
4. 파쇄ㆍ분쇄시설은 파쇄ㆍ분쇄된 골재의 크기를 조절할 수 있는 장치를 설치할 것
5. 파쇄ㆍ분쇄시설은 파쇄ㆍ분쇄에 의하여 발생되는 분진의 흩날림을 방지할 수 있는 설비를 갖출 것
6. 탈수ㆍ건조시설은 「폐기물관리법 시행규칙」 제35조의 규정에 적합할 것
②법 제29조제1항에 따른 건설폐기물 처리시설 관리기준은 다음 각 호와 같다. <개정 2007.10.25, 2010.6.9>
1. 건설폐기물 처리시설의 기능이 정상적으로 유지되도록 하고, 발생되는 분진의 흩날림을 방지 할 것
2. 건설폐기물을 보관 또는 처리함에 있어서는 당해 시설의 보관 또는 처리능력을 초과하지 아니하도록 할 것
3. 파쇄ㆍ분쇄시설은 시설의 처리용량에 맞도록 공급량을 조절하여 파쇄ㆍ분쇄할 것
4. 탈수ㆍ건조시설은 「폐기물관리법 시행규칙」 제42조제1항의 규정에 적합할 것
### 제22조(건설폐기물 처리시설의 개선기간 등)
①법 제29조제2항에 따라 시ㆍ도지사가 건설폐기물 처리시설의 개선 또는 사용중지를 명하고자 하는 때에는 개선 등에 필요한 조치의 내용, 시설의 종류 등을 고려하여 개선명령의 경우에는 1년의 범위안에서, 사용중지명령의 경우에는 6월의 범위안에서 각각 그 기간을 정하여야 한다. <개정 2010.6.9>
②시ㆍ도지사는 천재ㆍ지변 그 밖에 부득이한 사유로 인하여 제1항에 따른 개선기간안에 그 조치를 완료하지 못한 자에 대하여는 신청을 받아 6월의 범위안에서 그 기간을 연장할 수 있다. <개정 2010.6.9>
③건설폐기물 처리시설의 설치자 또는 관리자는 제1항 및 제2항에 따른 사용중지기간안에 건설폐기물 처리시설 설치 및 관리기준에 적합하게 개선한 때에는 이를 시ㆍ도지사에게 보고하여야 한다. <개정 2010.6.9>
④시ㆍ도지사는 제3항에 따른 보고를 받은 때에는 지체없이 이를 조사ㆍ확인하고, 건설폐기물 처리시설 설치 및 관리기준에 적합하게 개선한 것으로 인정되는 경우에는 그 명령을 철회하여야 한다. <개정 2010.6.9>
### 제23조(권리ㆍ의무의 승계신고)
법 제31조제1항 또는 제2항에 따른 권리ㆍ의무를 승계한 자는 승계한 날부터 30일 이내에 별지 제21호서식의 권리ㆍ의무승계신고서에 다음 각 호의 서류를 첨부하여 관할 시ㆍ도지사에게 제출하여야 한다. <개정 2010.6.9, 2013.12.13, 2017.10.19, 2019.12.20>
1. 건설폐기물 처리업허가증 또는 건설폐기물 처리시설 설치승인서ㆍ신고증명서
2. 승계사실을 증명하는 서류
3. 방치폐기물 처리이행보증을 증명할 수 있는 서류(중간처리업 승계의 경우에 한한다)
### 제24조(건설폐기물 처리상황 등의 기록)
①법 제32조 각호의 어느 하나에 해당하는 자가 기록ㆍ보존하여야 하는 장부는 다음과 같다. <개정 2010.6.9>
1. 배출신고자 : 별지 제22호서식의 건설폐기물 관리대장
2. 건설폐기물 처리업자
가. 수집ㆍ운반업자의 경우 : 별지 제23호서식의 건설폐기물 수집ㆍ운반관리대장
나. 중간처리업자의 경우 : 별지 제24호서식의 건설폐기물 중간처리관리대장, 별지 제25호서식의 건설폐기물 처리시설운영ㆍ관리대장 및 별지 제26호서식의 순환골재 및 순환골재 재활용제품 생산ㆍ판매대장
3. 건설폐기물 처리시설을 설치ㆍ운영하는 자 : 별지 제25호서식의 건설폐기물 처리시설운영ㆍ관리대장 및 별지 제26호서식의 순환골재 생산ㆍ판매대장
②제1항에 따라 기록ㆍ보존하여야 하는 장부는 이를 전자기록매체에 기록ㆍ보존할 수 있다. <개정 2010.6.9>
③법 제19조제1항에 따른 전자정보처리프로그램을 이용하여 장부를 작성ㆍ입력한 경우에는 제1항에 따른 장부를 기록ㆍ보존한 것으로 본다. <신설 2007.1.9, 2010.6.9>
### 제25조(휴업ㆍ폐업 등의 신고)
법 제33조제1항에 따라 건설폐기물 처리업자가 휴업ㆍ폐업 또는 재개업을 하고자 하는 경우에는 별지 제27호서식의 건설폐기물 처리업 휴업ㆍ폐업ㆍ재개업신고서에 다음의 서류를 첨부하여 시ㆍ도지사에게 제출하여야 한다. <개정 2010.6.9>
1. 휴업ㆍ폐업의 경우
가. 건설폐기물 처리업허가증
나. 위탁받은 건설폐기물의 처리완료내역
2. 재개업의 경우
가. 건설폐기물 처리시설의 점검결과
나. 기술능력의 보유현황 및 그 자격을 증명하는 서류
### 제26조(휴업ㆍ폐업의 신고전 방치폐기물의 적정처리)
건설폐기물 처리업자가 법 제33조제1항에 따라 휴업 또는 폐업의 신고를 하고자 하는 경우에는 미처리된 건설폐기물의 처리계획을 수립하여 그 폐기물을 처리하고 시ㆍ도지사 또는 공제조합의 확인ㆍ점검을 받아야 한다. <개정 2008.12.31, 2010.6.9>
### 제27조(보고서 제출)
① 배출신고자는 법 제34조제1항에 따라 별지 제28호서식의 건설폐기물 배출 및 처리실적보고서에 건설폐기물 처리 세금계산서 등 처리실적을 증명할 수 있는 서류를 첨부하여 해당 건설공사 준공 후 15일 이내에 관할 시장ㆍ군수ㆍ구청장에게 제출하되, 배출기간이 2개년도 이상에 걸쳐 배출되는 경우에는 해당 연도 건설폐기물의 배출 및 처리실적은 다음 연도 2월 말일(해당 연도 중에 준공되는 경우에는 준공 후 15일 이내를 말한다)까지 제출해야 한다. <개정 2008.12.31, 2010.6.9, 2020.4.17>
②법 제34조제1항에 따라 건설폐기물 처리업자는 매년 다음 각 호의 보고서를 다음연도 2월말까지 관할 시ㆍ도지사에게 제출하여야 한다. <개정 2008.12.31, 2010.6.9>
1. 수집ㆍ운반업자 : 별지 제29호서식의 건설폐기물 수집ㆍ운반실적보고서
2. 중간처리업자 : 별지 제30호서식의 건설폐기물 중간처리실적보고서 및 별지 제31호서식의 순환골재 및 순환골재 재활용제품 생산ㆍ판매실적보고서
③ 배출자는 법 제34조제1항에 따라 건설공사의 준공검사 전에 다음 각 호의 내용을 포함하는 별지 제32호서식의 건설폐기물 재활용실적보고서를 관할 시장ㆍ군수ㆍ구청장에게 제출하여야 한다. <신설 2010.6.9>
1. 해당 건설현장에서의 건설폐기물 재활용용도 및 방법
2. 해당 건설공사의 순환골재 및 순환골재 재활용제품 용도별 사용량
④제1항부터 제3항까지에 따른 보고서의 제출은 법 제10조에 따른 건설폐기물 정보관리체계를 이용하여 전송하여야 한다. <개정 2007.1.9, 2010.6.9>
⑤ 제3항에 따른 건설폐기물 재활용실적보고서를 제출받은 시장ㆍ군수ㆍ구청장은 법 제39조에 따른 순환골재 및 순환골재 재활용제품의 사용 권고 또는 시정조치명령을 위하여 시ㆍ도지사가 해당 보고서의 제출을 요청하는 경우에는 그 보고서를 시ㆍ도지사에게 제출할 수 있다. <신설 2017.10.19>
### 제28조(검사 등)
①기후에너지환경부장관, 시ㆍ도지사, 시장ㆍ군수ㆍ구청장은 법 제34조제1항에 따라 관계인에 대한 출입ㆍ검사를 실시함에 있어서 출입ㆍ검사의 대상사무소 또는 사업장 등이 다음 각 호에 따른 출입ㆍ검사의 대상 사무소 또는 사업장 등과 동일한 경우에는 이들을 통합하여 출입ㆍ검사를 실시하여야 한다. 다만, 민원, 환경오염사고, 광역감시활동 또는 기술인력ㆍ장비운영상 통합검사가 곤란하다고 인정되는 경우에는 그러하지 아니하다. <개정 2007.10.25, 2007.12.31, 2010.6.30, 2018.1.17, 2025.10.1>
1. 「대기환경보전법」 제82조제1항에 따른 출입ㆍ검사
2. 「물환경보전법」 제68조제1항에 따른 출입ㆍ검사
3. 「소음ㆍ진동관리법」 제47조제1항에 따른 출입ㆍ검사
4. 「폐기물관리법」 제39조제1항에 따른 출입ㆍ검사
②제1항의 규정에 의하여 출입ㆍ검사를 행하는 공무원은 기후에너지환경부장관이 정하는 서식에 따라 출입ㆍ검사의 목적, 인적사항, 검사결과 등을 기재한 서면을 배출자 등에게 교부하여야 한다. <개정 2025.10.1>
### 제29조(시험ㆍ분석기관)
기후에너지환경부장관, 시ㆍ도지사 또는 시장ㆍ군수ㆍ구청장은 법 제34조제1항에 따라 관계공무원에게 사업장 등에 출입하여 검사하게 하는 경우에 배출되는 폐기물 또는 재활용한 제품ㆍ시설물 등의 성분, 순환골재의 품질 등의 검사를 위한 시험ㆍ분석이 필요한 때에는 다음의 시험ㆍ분석기관으로 하여금 이를 시험ㆍ분석하게 할 수 있다. <개정 2005.7.22, 2008.3.3, 2010.6.9, 2013.3.23, 2025.10.1>
1. 국립환경과학원
2. 시ㆍ도 보건환경연구원
3. 유역환경청 또는 지방환경청
4. 한국환경공단
5. 삭제<2010.6.9>
6. 영 제29조제3항의 규정에 의하여 국토교통부장관이 지정하여 고시한 기관
7. 「국가표준기본법」 제23조제2항의 규정에 의하여 인정을 받은 시험ㆍ검사기관
8. 그 밖에 기후에너지환경부장관이 건설폐기물 또는 순환골재를 시험ㆍ분석할 수 있다고 인정하여 고시하는 시험ㆍ분석기관
### 제30조(순환골재 및 순환골재 재활용제품의 사용계획서 작성)
① 법 제38조제4항에 따라 순환골재등 의무사용 건설공사를 발주하는 발주자는 순환골재 및 순환골재 재활용제품의 사용용도 및 예상사용량 등이 포함된 별지 제33호서식의 순환골재 및 순환골재 재활용제품 사용계획서에 다음 각 호의 서류를 첨부하여 시ㆍ도지사에게 제출하여야 한다. <개정 2013.12.13>
1. 순환골재 및 순환골재 재활용제품 사용을 확인할 수 있는 서류(공급계약서 사본 또는 설계내역서 사본 등)
2. 법 제38조제1항에 따른 순환골재 품질기준 또는 영 제17조에 따른 의무 사용대상 순환골재 재활용제품임을 확인할 수 있는 서류
② 시ㆍ도지사는 제1항에 따라 순환골재 및 순환골재 재활용제품의 사용계획서를 제출받은 경우에는 별지 제34호서식의 순환골재 및 순환골재 재활용제품 사용계획서 제출증명서를 제출자에게 내주어야 한다.
### 제30조의2(중간처리 기술인력에 대한 교육)
① 중간처리업자는 법 제56조의2제1항에 따라 중간처리업의 기술인력으로 종사하는 사람을 3년마다 1회 이상 교육을 받도록 하여야 한다.
② 제1항에 따른 교육은 법 제55조에 따른 협회 또는 「환경정책기본법」 제59조제1항에 따른 환경보전협회(이하 "교육기관"이라 한다)에서 실시한다. <개정 2020.4.17>
③ 교육기관이 실시하는 교육기간은 1일 이내로 한다. 다만, 정보통신매체를 이용하여 원격교육을 실시하는 경우에는 기후에너지환경부장관이 인정하는 기간으로 한다. <개정 2025.10.1>
④ 교육기관은 기후에너지환경부장관의 승인을 받아 중간처리업자로부터 교육에 드는 비용을 징수할 수 있다. <개정 2025.10.1>
### 제30조의3(교육의 내용 등)
① 교육기관의 장은 매년 11월 30일까지 다음 해의 교육계획을 작성하여 기후에너지환경부장관에게 제출하여야 한다. <개정 2025.10.1>
② 제1항에 따른 교육계획에는 다음 각 호의 사항이 포함되어야 한다.
1. 교육의 기본방향
2. 교육 과목, 기간 및 예상교육인원
3. 교육대상자의 선발기준 및 선발계획
4. 교육성적의 평가방법
5. 그 밖에 교육을 위하여 필요한 사항
③ 기후에너지환경부장관은 교육기관에서 제출한 교육계획을 검토한 후 그 결과를 매년 12월 31일까지 교육기관의 장에게 통보하여야 한다. <개정 2025.10.1>
### 제30조의4(교육대상자의 선발)
① 교육기관의 장은 기후에너지환경부장관으로부터 통보받은 교육계획을 매년 1월 31일까지 시ㆍ도지사에게 알려야 한다. <개정 2025.10.1>
② 시ㆍ도지사는 관할구역의 교육대상자를 선발하여 그 명단을 교육과정이 시작되기 15일 전까지 교육기관의 장에게 알려야 한다.
③ 시ㆍ도지사는 제2항에 따라 교육대상자를 선발하면 그 교육대상자에게 지체 없이 알려야 한다.
### 제30조의5(교육결과 보고)
교육기관의 장은 법 제56조의2에 따른 교육을 하면 매 반기의 교육실적을 그 반기가 끝난 후 15일 이내에 기후에너지환경부장관에게 보고하여야 하고, 매 교육과정 종료 후 7일 이내에 교육결과를 교육대상자를 선발하여 통보한 시ㆍ도지사에게 알려야 한다. <개정 2025.10.1>
### 제31조(허가 수수료)
법 제60조의 규정에 의한 허가의 수수료는 40,000원(정보통신망을 이용하여 전자화폐ㆍ전자결재 등의 방법으로 수수료를 낼 때에는 36,000원), 변경허가의 수수료는 15,000원(정보통신망을 이용하여 전자화폐ㆍ전자결재 등의 방법으로 수수료를 낼 때에는 13,500원)으로 하며, 지방자치단체의 수입증지 또는 정보통신망을 이용하여 전자화폐ㆍ전자결제 방법으로 납부하여야 한다. <개정 2010.6.9, 2012.6.15>
### 제32조(규제의 재검토)
기후에너지환경부장관은 다음 각 호의 사항에 대하여 2023년 1월 1일을 기준으로 3년마다(매 3년이 되는 해의 1월 1일 전까지를 말한다) 그 타당성을 검토하여 개선 등의 조치를 해야 한다. <개정 2025.10.1, 2026.6.22>
1. 제4조의2에 따른 건설폐기물의 임시보관장소 승인조건 등
2. 제5조제2항 및 별표 1의2에 따른 건설폐기물의 배출, 수집ㆍ운반, 보관, 중간처리에 관한 구체적인 기준 및 방법
3. 삭제<2026.6.22>
### 제33조
삭제 <2008.12.31>
## 부칙
> 부칙 <제169호,2005.1.19>
>
>
> 제1조 (시행일) 이 규칙은 공포한 날부터 시행한다. 다만, 제3조제1항ㆍ제6조 및 제7조의 규정은 2006년 1월 1일부터 시행한다.
>
>
> 제2조 (건설폐기물 중간처리기준에 대한 특례) 영 별표 1 제1호 내지 제5호 및 제13호의 규정에 의한 건설폐기물의 중간처리에 대하여는 이 규칙 시행후 6월이 경과하는 날까지는「폐기물관리법 시행규칙」별표 4 제5호 라목(1)(나)의 규정을 적용한다.
>
>
> 제3조 (임시보관장소에 대한 경과조치) 이 규칙 시행당시「폐기물관리법 시행규칙」제6조의2제3항의 규정에 의하여 건설폐기물의 임시보관장소 설치승인을 얻은 수집ㆍ운반업자는 제4조제3항의 규정에 의한 임시보관장소의 설치승인을 얻은 것으로 본다.
>
>
> 제4조 (건설폐기물 수집ㆍ운반증에 대한 경과조치) 이 규칙 시행당시「폐기물관리법시행규칙」별표 4 제8호의 규정에 의하여 건설폐기물의 수집ㆍ운반증을 발급받은 자는 제5조제1항의 규정에 의한 건설폐기물 수집ㆍ운반증을 발급받은 것으로 본다.
>
>
> 제5조 (배출자신고에 대한 경과조치) ①이 규칙 시행당시「폐기물관리법 시행령」제2조제8호의 규정에 의한 건설공사에 의하여 폐기물을 5톤 이상 배출하는 자(발주자로부터 최초로 공사의 전부를 도급받은 자를 포함한다)가「폐기물관리법 시행규칙」제10조제2항의 규정에 의하여 사업장폐기물배출자신고를 한 경우에는 제9조제1항의 규정에 의한 건설폐기물처리계획신고를 한 것으로 본다.
>
>
> ②법 제2조제8호 단서의 규정에 의하여 발주자가 배출신고자가 되는 경우로서 이 규칙 시행당시 발주자가「폐기물관리법 시행규칙」제10조제2항의 규정에 의한 사업장폐기물배출자신고를 하지 아니한 경우에는 제9조제1항의 규정에 의한 건설폐기물처리계획서를 이 규칙 시행후 6월 이내에 시장ㆍ군수ㆍ구청장에게 제출하여야 한다. 다만, 이 규칙 시행후 6월 이내에 제27조제1항의 규정에 의하여 건설폐기물배출 및 처리실적보고서를 제출하는 경우에는 그러하지 아니하다.
>
>
> 제6조 (건설폐기물처리시설에 대한 경과조치) ①이 규칙 시행당시「폐기물관리법」제30조제2항 본문의 규정에 의하여 설치승인을 얻은 건설폐기물 처리시설은 제16조제2항의 규정에 의하여 건설폐기물처리시설의 설치승인을 얻은 것으로 본다.
>
>
> ②이 규칙 시행당시「폐기물관리법」제30조제2항 단서의 규정에 의하여 건설폐기물 처리시설의 설치신고를 한 자는 제16조제1항의 규정에 의한 건설폐기물처리시설 설치승인신청서를 이 규칙 시행후 1년 이내에 시ㆍ도지사에게 제출하여야 한다. 다만, 이 규칙 시행후 1년 이내에「폐기물관리법 시행규칙」제50조의 규정에 의하여 폐기물처리시설의 사용종료 또는 폐쇄를 신고하는 경우에는 그러하지 아니하다.
>
>
> ③이 규칙 시행당시「폐기물관리법 시행규칙」제20조의3제2항의 규정에 의하여 건설폐기물을 대상으로 폐기물처리시설설치ㆍ운영계획서를 제출한 자는 제18조제1항의 규정에 의한 건설폐기물처리시설설치신고서를 이 규칙 시행후 1년 이내에 시ㆍ도지사에게 제출하여야 한다. 다만, 이 규칙 시행후 1년 이내에 시설사용기간이 종료되는 폐기물처리시설인 경우에는 그러하지 아니하다.
>
>
> 제7조 (폐기물처리사업계획에 대한 경과조치) ①이 규칙 시행당시「폐기물관리법」제26조제2항의 규정에 의하여 건설폐기물을 대상으로 폐기물처리사업계획 적정통보를 받은 자는 별표 2의 규정에 불구하고 법 제21조제3항의 규정에 의한 건설폐기물처리사업계획 적정통보를 받은 것으로 보되, 그 유효기간은 2008년 6월 30일까지로 한다.
>
>
> ②제1항의 유효기간 내에 허가받은 건설폐기물처리업자가 제13조제1항제4호ㆍ제7호 또는 제8호에 해당하는 사유로 변경허가를 받으려는 경우에는 별표 2에 적합한 시설 및 장비를 갖추어 변경허가를 신청하여야 한다. <신설 2007.1.9>
>
>
> ③이 규칙 시행당시「폐기물관리법」제26조의 규정에 의하여 건설폐기물을 대상으로 폐기물처리사업계획서를 제출한 자는 이 규칙에 의한 건설폐기물처리업 허가요건에 적합하게 시설ㆍ장비ㆍ기술능력ㆍ자본금을 보완하여 건설폐기물 처리사업계획서를 다시 제출하여야 한다. <개정 2007.1.9>
>
>
> 제8조 (행정처분기준의 적용에 관한 경과조치) 이 규칙 시행전에「폐기물관리법 시행규칙」별표 16의 규정에 의한 위반행위중 이 규칙 별표 3의 규정에 의한 위반행위와 동일한 위반행위로 행정처분을 받은 경우에는 이 규칙 별표 3의 규정에 의한 위반행위로 행정처분을 받은 것으로 본다.
>
>
> 제9조 (다른 법령의 개정) 폐기물관리법시행규칙 일부를 다음과 같이 개정한다.
>
>
> 제7조제1항제2호 본문중 "건설폐재류(폐토사ㆍ폐벽돌ㆍ폐콘크리트 및 폐아스팔트콘크리트를 말한다. 이하 같다)"를 "건설폐재류(「건설폐기물의 재활용촉진에 관한 법률 시행령」별표 1 제1호 내지 제5호 및 제13호의 건설폐기물을 말한다. 이하 같다)"로 하고, 동호 단서중 "폐토사의"를 "건설폐토석의"로 한다.
>
>
> 제19조의2제1항제1호중 "(건설폐기물 및 감염성폐기물을 제외한다)"를 "(감염성폐기물을 제외한다)"로 하고, 동항제2호를 삭제한다.
>
>
> 별표 4 제2호 가목(2)중 "건설폐기물을 영업대상으로 하는 폐기물처리업자"를 "「건설폐기물의 재활용촉진에 관한 법률」제21조제4항의 규정에 의한 건설폐기물처리업자"로 하고, 동표 제4호 가목(3)중 "건설폐기물을 대상으로 하는 폐기물처리업자"를 "「건설폐기물의 재활용촉진에 관한 법률」제21조제4항의 규정에 의한 건설폐기물처리업자"로 하며, 동표 제5호 가목(5)ㆍ나목ㆍ다목 및 라목(1)(가)를 각각 삭제한다.
>
>
> 별표 6 제1호 다목 및 제2호 나목을 각각 삭제하고, 동표의 비고 제9호중 "제1호 다목 또는 제2호 나목의"를 "「건설폐기물의 재활용촉진에 관한 법률 시행규칙」별표 2의"로 한다.
>
>
> 별표 16 제2호(10)(가)의 위반행위란중 "소각전문의 경우 소각시설, 건설폐기물중간처리업의 경우 파쇄시설"을 "소각전문의 경우 소각시설"로 한다.
> 부칙 <제178호,2005.7.18>
>
>
> ①(시행일) 이 규칙은 2005년 7월 19일부터 시행한다.
>
>
> ②(다른 법령의 개정) 폐기물관리법시행규칙 일부를 다음과 같이 개정한다.
>
>
> 별표 4 제5호 라목(1)을 삭제한다.
> 부칙(환경부와 그 소속기관 직제 시행규칙) <제179호,2005.7.22>
>
>
> 제1조 (시행일) 이 규칙은 공포한 날부터 시행한다.
>
>
> 제2조 (다른 법령의 개정) ①건설폐기물의 재활용촉진에 관한 법률 시행규칙 일부를 다음과 같이 개정한다.
>
>
> 제29조제1호중 "국립환경연구원"을 "국립환경과학원"으로 한다.
>
>
> ②내지 <22>생략
> 부칙 <제226호,2007.1.9>
>
>
> ①(시행일) 이 규칙은 공포한 날부터 시행한다.
>
>
> ②(다른 법령의 개정) 폐기물관리법 시행규칙 일부를 다음과 같이 개정한다.
>
>
> 별표 4 제5호가목(1) 내지 (4) 및 라목 (2)를 삭제한다.
> 부칙(폐기물관리법 시행규칙) <제252호,2007.10.25>
>
>
> 제1조 (시행일) 이 규칙은 공포한 날부터 시행한다. <단서 생략>
>
>
> 제2조 부터 제11조까지 생략
>
>
> 제12조 (다른 법령의 개정) ① 건설폐기물의 재활용촉진에 관한 법률 시행규칙 일부를 다음과 같이 개정한다.
>
>
> 제5조제1항 중 "「폐기물관리법 시행규칙」 별표 4 제8호"를 "「폐기물관리법 시행규칙」 별표 5 제6호"로 하고, 제11조제2호 중 "「폐기물관리법 시행규칙」 제9조의3제1항"을 "「폐기물관리법 시행규칙」 제17조제1항"으로 하며, 제13조제1항제6호 중 "「폐기물관리법 시행규칙」 별표 4 제8호"를 "「폐기물관리법 시행규칙」 별표 5 제6호"로 한다.
>
>
> 제21조제1항제6호 중 "「폐기물관리법 시행규칙」 제20조"를 "「폐기물관리법 시행규칙」 제35조"로 하고, 같은 조 제2항제4호 중 "「폐기물관리법 시행규칙」 제24조제1항"을 "「폐기물관리법 시행규칙」 제42조제1항"으로 하고, 제28조제1항제4호 중 "「폐기물관리법」 제43조제1항"을 "「폐기물관리법」 제59조제1항"으로 하며, 별표 1 비고 3. 라목 중 "「폐기물관리법 시행규칙」 별표 11의2"를 "「폐기물관리법 시행규칙」 별표 17"로 한다.
>
>
> ② 부터 ⑩ 까지 생략
>
>
> 제13조 생략
> 부칙(대기환경보전법 시행규칙) <제270호,2007.12.31>
>
>
> 제1조 (시행일) 이 규칙은 공포한 날부터 시행한다. <단서 생략>
>
>
> 제2조 부터 제13조까지 생략
>
>
> 제14조 (다른 법령의 개정) ① 건설폐기물의 재활용촉진에 관한 법률 시행규칙 일부를 다음과 같이 개정한다.
>
>
> 제28조제1항제1호 본문 중 "「대기환경보전법」 제49조제1항의 규정에 의한"을 "「대기환경보전법」 제82조제1항에 따른"으로 한다.
>
>
> ② 및 ③ 생략
>
>
> 제15조 생략
> 부칙 <제271호,2007.12.31>
>
>
> 제1조 (시행일) 이 규칙은 공포한 날부터 시행한다.
>
>
> 제2조 (다른 법령의 개정) 폐기물관리법 시행규칙 일부를 다음과 같이 개정한다.
>
>
> 제13조제1항제2호 본문 중 "「건설폐기물의 재활용촉진에 관한 법률 시행령」 별표 1 제1호부터 제5호까지 및 제13호"를 "「건설폐기물의 재활용촉진에 관한 법률 시행령」 별표 1 제1호부터 제5호까지 및 제16호"로 한다.
> 부칙(환경부와 그 소속기관 직제 시행규칙) <제281호,2008.3.3>
>
>
> 제1조(시행일) 이 규칙은 공포한 날부터 시행한다.
>
>
> 제2조 생략
>
>
> 제3조(다른 법률의 개정) ① 부터 ⑦ 까지 생략
>
>
> ⑧ 건설폐기물의 재활용 촉진에 관한 법률 시행규칙 일부를 다음과 같이 개정한다.
>
>
> 제29조제6호 중 "건설교통부장관"을 "국토해양부장관"으로 한다.
>
>
> ⑨ 및 ⑩ 생략
> 부칙 <제313호,2008.12.31>
>
>
> 제1조(시행일) 이 규칙은 공포한 날부터 시행한다. 다만, 별표 1의2 제1호바목의 개정규정은 공포 후 6개월이 경과한 날부터 시행한다.
>
>
> 제2조(적용례) 별표 1의2 제1호바목의 개정규정은 이 규칙 시행 후 최초로 배출되는 건설폐기물부터 적용한다.
>
>
> 제3조(경과조치) 이 규칙 시행 전의 위반행위에 대한 행정처분은 개정규정에 의한다.
> 부칙(한시적 행정규제 유예 등을 위한 건설폐기물의 재활용 촉진에 관한 법률 시행규칙 등 일부개정령) <제336호,2009.6.30>
>
>
> 제1조(시행일) 이 규칙은 2009년 7월 1일부터 시행한다. 다만, 제9조의 개정규정은 2010년 1월 1일부터 시행한다.
>
>
> 제2조(「환경분야 시험ㆍ검사 등에 관한 법률 시행규칙」 개정에 따른 적용례) 제25조제2항의 개정규정은 이 규칙 시행 당시 제25조제2항제1호에 해당된 후 1년이 경과하지 아니한 자에 대하여도 적용한다.
> 부칙 <제372호,2010.6.9>
>
>
> 제1조(시행일) 이 규칙은 2010년 6월 10일부터 시행한다. 다만, 제30조의2제1항의 개정규정은 2011년 1월 1일부터 시행한다.
>
>
> 제2조(행정처분기준에 관한 경과조치) 이 규칙 시행 전의 위반행위에 대한 행정처분에 관하여는 별표 3의 개정규정에도 불구하고 종전의 규정에 따른다.
> 부칙(소음ㆍ진동관리법 시행규칙) <제374호,2010.6.30>
>
>
> 제1조(시행일) 이 규칙은 2010년 7월 1일부터 시행한다. <단서 생략>
>
>
> 제2조 및 제3조 생략
>
>
> 제4조(다른 법률의 개정) ① 및 ② 생략
>
>
> ③ 건설폐기물의 재활용촉진에 관한 법률 시행규칙 일부를 다음과 같이 개정한다.
>
>
> 제28조제1항제3호 중 "「소음ㆍ진동규제법」"을 "「소음ㆍ진동관리법」"으로 한다.
>
>
> ④ 부터 <21> 까지 생략
>
>
> 제5조 생략
> 부칙 <제400호,2011.2.25>
>
>
> 이 규칙은 공포한 날부터 시행한다.
> 부칙(건설폐기물의 재활용촉진에 관한 법률 시행규칙 등 일부개정령) <제460호,2012.6.15>
>
>
> 이 규칙은 공포한 날부터 시행한다.
> 부칙(환경부와 그 소속기관 직제 시행규칙) <제503호,2013.3.23>
>
>
> 제1조(시행일) 이 영은 공포한 날부터 시행한다.
>
>
> 제2조 생략
>
>
> 제3조(다른 법령의 개정) ① 건설폐기물의 재활용촉진에 관한 법률 시행규칙 일부를 다음과 같이 개정한다.
>
>
> 제29조제6호 중 "국토해양부장관"을 "국토교통부장관"으로 한다.
>
>
> ②부터 ⑬까지 생략
> 부칙 <제529호,2013.12.13>
>
>
> 제1조(시행일) 이 규칙은 2013년 12월 13일부터 시행한다. 다만, 제3조제1항의 개정규정은 2014년 6월 13일부터 시행하고, 제3조의3의 개정규정은 2016년 7월 1일부터 시행하며, 별표 제1의2의 개정규정(제3호다목에 한한다)은 2014년 3월 1일부터 시행한다.
>
>
> 제2조(건설폐기물 처리업 변경계획서 제출에 관한 적용례) 제13조제2항부터 제6항까지의 개정규정은 이 규칙 시행 후 법 제22조제1항에 따라 변경허가를 받으려는 자부터 적용한다.
>
>
> 제3조(방치폐기물 처리이행보증을 증명할 수 있는 서류 제출에 관한 적용례) 제23조의 개정규정은 이 규칙 시행 후 별지 제21호서식의 권리ㆍ의무승계신고서를 제출하는 자부터 적용한다.
>
>
> 제4조(건설폐기물 처리업자의 행정처분기준에 관한 경과조치) 이 법 시행 전의 위반행위에 대한 행정처분기준의 적용에 있어서는 종전의 규정에 따른다.
> 부칙(행정규제기본법 개정에 따른 규제 재검토기한 설정을 위한 가축분뇨의 관리 및 이용에 관한 법률 시행규칙 등 일부개정령) <제553호,2014.4.30>
>
>
> 이 규칙은 공포한 날부터 시행한다.
> 부칙(규제 재검토기한 설정을 위한 건설폐기물의 재활용촉진에 관한 법률 시행규칙 등 일부개정령) <제581호,2014.12.16>
>
>
> 이 규칙은 2015년 1월 1일부터 시행한다.
> 부칙(규제정비를 위한 건설폐기물의 재활용촉진에 관한 법률 시행규칙 등 일부개정령) <제584호,2014.12.29>
>
>
> 이 규칙은 공포한 날부터 시행한다. <단서 생략>
> 부칙 <제656호,2016.6.2>
>
>
> 제1조(시행일) 이 규칙은 2016년 6월 2일부터 시행한다. 다만, 별표 2 제2호가목(7) 및 별표 2의2 제3호다목 후단의 개정규정은 2017년 1월 1일부터 시행한다.
>
>
> 제2조(용역이행실적등의 신고 및 용역이행능력의 공시에 관한 경과조치) 2016년에 실시하는 중간처리업자의 용역이행실적 등의 신고 및 용역이행능력 공시에 대해서는 제6조, 제7조 및 별지 제4호서식의 개정규정에도 불구하고 종전의 규정에 따른다.
> 부칙 <제717호,2017.10.19>
>
>
> 이 규칙은 2017년 10월 19일부터 시행한다.
> 부칙(물환경보전법 시행규칙) <제745호,2018.1.17>
>
>
> 제1조(시행일) 이 규칙은 2018년 1월 18일부터 시행한다.
>
>
> 제2조 및 제3조 생략
>
>
> 제4조(다른 법령의 개정) ① 생략
>
>
> ② 건설폐기물의 재활용촉진에 관한 법률 시행규칙 일부를 다음과 같이 개정한다.
>
>
> 제28조제1항제2호를 다음과 같이 한다.
>
>
> 2. 「물환경보전법」 제68조제1항에 따른 출입ㆍ검사
>
>
> ③부터 <25>까지 생략
> 부칙(어려운 법령용어 정비를 위한 40개 법령의 일부개정에 관한 환경부령) <제833호,2019.12.20>
>
>
> 이 규칙은 공포한 날부터 시행한다.
> 부칙 <제861호,2020.4.17>
>
>
> 이 규칙은 2020년 4월 17일부터 시행한다. 다만, 제27조제1항의 개정규정은 2021년 1월 1일부터 시행한다.
> 부칙(어려운 법령용어 정비를 위한 16개 환경부령 일부개정령) <제942호,2021.9.16>
>
>
> 이 규칙은 공포한 날부터 시행한다.
> 부칙 <제1005호,2022.11.29>
>
>
> 이 규칙은 공포한 날부터 시행한다.
> 부칙(규제 재검토기한 정비를 위한 17개 법령의 일부개정에 관한 환경부령) <제1032호,2023.4.17>
>
>
> 이 규칙은 공포한 날부터 시행한다.
> 부칙 <제1177호,2025.5.29>
>
>
> 이 규칙은 공포한 날부터 시행한다.
> 부칙(기후에너지환경부와 그 소속기관 직제 시행규칙) <제1호,2025.10.1>
>
>
> 제1조(시행일) 이 규칙은 공포한 날부터 시행한다. 다만, 부칙 제6조에 따라 개정되는 부령 중 이 규칙 시행 전에 공포되었으나 시행일이 도래하지 아니한 부령을 개정한 부분은 각각 해당 부령의 시행일부터 시행한다.
>
>
> 제2조 부터 제5조까지 생략
>
>
> 제6조(다른 법령의 개정) ① 및 ② 생략
>
>
> ③ 건설폐기물의 재활용촉진에 관한 법률 시행규칙 일부를 다음과 같이 개정한다.
>
>
> 제2조의2 각 호 외의 부분, 제3조의3 각 호 외의 부분, 제4조의2제2항, 제9조제4항 각 호 외의 부분, 제10조의2, 제11조 각 호 외의 부분 본문, 제12조제3항, 제13조제1항 각 호 외의 부분, 제13조의2제1항 각 호 외의 부분, 제13조의3 및 제16조제2항 중 "환경부령"을 각각 "기후에너지환경부령"으로 한다.
>
>
> 제3조제1항, 제3조의3제4호, 제28조제1항 각 호 외의 부분 본문, 같은 조 제2항, 제29조 각 호 외의 부분, 같은 조 제8호, 제30조의2제3항 단서, 같은 조 제4항, 제30조의3제1항ㆍ제3항, 제30조의4제1항, 제30조의5 및 제32조 각 호 외의 부분 중 "환경부장관"을 각각 "기후에너지환경부장관"으로 한다.
>
>
> 별표 2 제2호가목(7), 같은 표 제3호나목 및 별표 2의2 제2호ㆍ제4호 중 "환경부장관"을 각각 "기후에너지환경부장관"으로 한다.
>
>
> 별지 제1호서식 제1쪽 중 "환경부장관"을 "기후에너지환경부장관"으로 한다.
>
>
> ④부터 <98>까지 생략
> 부칙(규제 재검토기한 해제를 위한 19개 법령의 일부개정에 관한 기후에너지환경부령) <제42호,2026.6.22>
>
>
> 이 규칙은 공포한 날부터 시행한다.
## 별표·서식 목록
| 구분 | 번호 | 제목 | PDF |
|---|---|---|---|
| 별표 | 1 | 건설폐기물의 종류별 분류체계(제3조의2 관련) | `별표/` |
| 별표 | 1의2 | 건설폐기물의 배출, 수집·운반, 보관, 중간처리에 관한 구체적인 기준 및 방법(제5조제2항관련) | `별표/` |
| 별표 | 2 | 건설폐기물 수집·운반업 및 중간처리업의 허가기준(제12조제5항관련) | `별표/` |
| 별표 | 2의2 | 전자정보처리프로그램의 입력내용ㆍ방법ㆍ시기(제10조제2항 관련) | `별표/` |
| 별표 | 2의3 | 건설폐기물 처리업자의 준수사항(제13조의3 관련) | `별표/` |
| 별표 | 3 | 건설폐기물 처리업자 행정처분기준(제15조의2 관련) | `별표/` |
| 별표 | 4 | 환경오염방지시설평점 및 환경오염방지인력평점의 산정방법(제7조의2 관련) | `별표/` |
| 서식 | 1 | 건설폐기물 재활용 통계조사보고서 | `별표/` |
| 서식 | 2 | 임시보관장소(설치승인, 변경승인)신청서 | `별표/` |
| 서식 | 3 | 임시보관장소설치승인서 | `별표/` |
| 서식 | 4 | 건설폐기물 중간처리용역 이행실적신고서 | `별표/` |
| 서식 | 5 | 건설폐기물 중간처리용역 이행실적증명서 | `별표/` |
| 서식 | 6 | 순환골재 판매내역서 | `별표/` |
| 서식 | 7 | 건설폐기물 처리(계획서, 변경계획서) | `별표/` |
| 서식 | 8 | 건설폐기물 처리계획신고증명서 | `별표/` |
| 서식 | 9 | 건설폐기물 간이인계서 | `별표/` |
| 서식 | 9의2 | 용역이행능력평가액 확인서 | `별표/` |
| 서식 | 10 | 건설폐기물 (처리 사업, 처리업 변경)계획서 | `별표/` |
| 서식 | 11 | 건설폐기물 처리업(허가, 변경허가)신청서 | `별표/` |
| 서식 | 11의2 | 건설폐기물 처리업 변경신고서 | `별표/` |
| 서식 | 12 | 건설폐기물 수집ㆍ운반업허가증 | `별표/` |
| 서식 | 13 | 건설폐기물 중간처리업허가증 | `별표/` |
| 서식 | 14 | 건설폐기물 처리시설 설치(승인, 변경승인)신청서 | `별표/` |
| 서식 | 15 | 건설폐기물 처리시설 설치승인서 | `별표/` |
| 서식 | 16 | 삭제 &lt;2007.1.9&gt; | `별표/` |
| 서식 | 17 | 삭제 &lt;2007.1.9&gt; | `별표/` |
| 서식 | 18 | 건설폐기물 처리시설 설치(신고서, 변경신고서) | `별표/` |
| 서식 | 19 | 건설폐기물 처리시설 설치신고증명서 | `별표/` |
| 서식 | 20 | 건설폐기물 처리시설 사용개시신고서 | `별표/` |
| 서식 | 21 | 권리ㆍ의무승계신고서 | `별표/` |
| 서식 | 22 | 건설폐기물관리대장 | `별표/` |
| 서식 | 23 | 건설폐기물수집·운반관리대장 | `별표/` |
| 서식 | 24 | 건설폐기물 중간처리관리대장 | `별표/` |
| 서식 | 25 | 건설폐기물처리시설운영·관리대장 | `별표/` |
| 서식 | 26 | 순환골재 등 생산ㆍ판매대장 | `별표/` |
| 서식 | 27 | 건설폐기물처리업(휴업, 폐업, 재개업)신고서 | `별표/` |
| 서식 | 28 | 건설폐기물 배출 및 처리실적보고서(배출신고자용) | `별표/` |
| 서식 | 29 | 건설폐기물수집·운반실적보고서 | `별표/` |
| 서식 | 30 | 건설폐기물 중간처리실적보고서 | `별표/` |
| 서식 | 31 | 순환골재 등 생산ㆍ판매실적보고서[중간처리업자용] | `별표/` |
| 서식 | 32 | 건설폐기물 재활용실적보고서 | `별표/` |
| 서식 | 33 | 순환골재 및 순환골재 재활용제품 사용계획서 | `별표/` |
| 서식 | 34 | 순환골재 및 순환골재 재활용제품 사용계획서 제출증명서 | `별표/` |
@@ -0,0 +1,17 @@
# 건설폐기물의 재활용촉진에 관한 법률 시행령 — 변경이력
기준: 현행(2025.10.01)
**개정 이력**
| 시행일 | 구분 |
|---|---|
| 2025.10.01 | 현행 |
## 제개정이유
>
## 개정문
>
@@ -0,0 +1,8 @@
# 건설폐기물의 재활용촉진에 관한 법률 시행령 — 문서 목록
> 총 3건 (본문 3 · 별표 0 · 첨부 0 · 압축 0 · KCS 0)
## 본문
- [메타정보](<_meta.md>)
- [변경이력](<CHANGELOG.md>)
- [현행 20251001](<현행_20251001.md>)
@@ -0,0 +1,10 @@
# 건설폐기물의 재활용촉진에 관한 법률 시행령
- 정식명칭: 건설폐기물의 재활용촉진에 관한 법률 시행령
- 목록상 명칭: 건설폐기물의 재활용촉진에 관한 법률 시행령
- 식별자: `법령ID=278497` (불변)
- 소관: 기후에너지환경부
- 종류: 대통령령
- 현행 시행일: 2025.10.01 / 공포 2025.10.01 제35804호
- 출처: https://www.law.go.kr/법령/건설폐기물의재활용촉진에관한법률시행령
- 수집 파일: 현행:20251001

Some files were not shown because too many files have changed in this diff Show More