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

This commit is contained in:
2026-09-14 19:44:57 +09:00
16 changed files with 344 additions and 25 deletions
+4 -2
View File
@@ -105,15 +105,17 @@ def annotate(
component["reuse_note"] = NOTE_REUSE_MISSING
continue
count = entry.get("reuse_count")
# 근거 — 대개 1-7-1 분류 · 그 공종 표가 직접 적었으면 그 표(집수정 12-15 · 2026-09-14).
basis = str(entry.get("basis") or "품셈 1-7-1")
for component in targets:
component["reuse_count"] = count
component["reuse_note"] = (
f"품셈 1-7-1 {count}회 — 「{entry.get('matched_example')}"
f"{basis} {count}회 — 「{entry.get('matched_example')}"
if count
else NOTE_NOT_APPLICABLE
)
if count:
notes.append(f"{structure.get('name') or type_id} 거푸집 {count}회 (품셈 1-7-1)")
notes.append(f"{structure.get('name') or type_id} 거푸집 {count}회 ({basis})")
else:
missing.append(type_id)
return notes, sorted(set(missing))
@@ -38,11 +38,11 @@ def _pickers(structure: dict[str, Any], mapping: WorkItemMapping) -> list[tuple[
"""이 구조물의 성분을 **이름으로 집는** 공종 줄 — (자리, 이름들). 집는 조건은 각 빌더와 같다."""
type_id = str(structure.get("type_id") or "")
entry = mapping.for_structure(type_id) or {}
composite = mapping.composite_for(type_id)
composite = mapping.composite_for(type_id, structure)
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"):
if composite: # 걸린 묶음은 곧장 잇는 코드보다 위(빌더와 같은 조건)
for part in composite.get("parts") or []:
if isinstance(part, dict):
label = f"묶음 조각 「{part.get('name') or part.get('code')}"
@@ -271,13 +271,20 @@ class WorkItemMapping:
return row
return None
def composite_for(self, type_id: str) -> dict[str, Any] | None:
def composite_for(
self, type_id: str, structure: dict[str, Any] | None = None
) -> dict[str, Any] | None:
"""품셈에 그 이름의 공종이 없어 **여러 공종을 묶는** 자리인가.
빈 코드로 두면 「매핑을 못 찾은 줄」과 구별이 안 된다 — 묶음을 적어 구별한다.
"""
options = (structure or {}).get("options") or {}
for row in self.composite.get("items") or []:
if row.get("type_id") == type_id:
# `when` — 같은 종류라도 그 제원일 때만 묶음(콘크리트 집수정만 12-15 조립 · 09-14 ⑸).
when = row.get("when") or {}
if row.get("type_id") == type_id and all(
str(options.get(key) or "") == str(value) for key, value in when.items()
):
return row
return None
@@ -382,6 +389,30 @@ def composite_quantities(
entry["not_ready"] = True
entry["why"] = why
missing.append({"code": spec.get("code"), "reason": why})
if suffix == "formwork_reuse":
# 12-4 사용횟수 갈래 — B08 이 성분에 단 횟수(`Formwork.annotate`) 그대로(한 벌 · 09-14).
counts = {
component.get("reuse_count")
for component in structure.get("components") or []
if str(component.get("name") or "").strip() in found
}
count = next(iter(counts)) if len(counts) == 1 else None
if isinstance(count, int) and count > 0:
entry["kind"] = f"{count}"
entry["kind_basis"] = next(
(
str(component.get("reuse_note") or "")
for component in structure.get("components") or []
if str(component.get("name") or "").strip() in found
),
"",
)
entry["code"] = f"{spec.get('code')}#{count}"
elif found:
why = "거푸집 사용횟수가 없거나 둘 이상이라 12-4 갈래를 못 고름"
entry["not_ready"] = True
entry["why"] = why
missing.append({"code": spec.get("code"), "reason": why})
if suffix == "rebar_complexity":
# 갈래는 원문이 정한다 — 화면·인계에 이름과 근거를 함께 실어 사람이 검증하게 한다.
complexity, why = rebar_complexity(
@@ -459,10 +490,12 @@ def rebar_complexity(
continue
if row.get("form") == form:
if row.get("class"):
return str(row["class"]), f"품셈 12-3 [주]①{row.get('matched')}"
basis = row.get("basis") or "품셈 12-3 [주]①"
return str(row["class"]), f"{basis}{row.get('matched')}"
return None, str(row.get("why") or "원문 예시에 없음")
if fallback and fallback.get("class"):
return str(fallback["class"]), f"품셈 12-3 [주]①{fallback.get('matched')}"
basis = fallback.get("basis") or "품셈 12-3 [주]①" # 그 공종 표가 직접 적으면 그 표(12-15)
return str(fallback["class"]), f"{basis}{fallback.get('matched')}"
from B08_Quantity.B08_Quantity_Wording import type_label
detail = f"({form})" if form else "(형식이 아직 입력되지 않음)"
@@ -433,7 +433,11 @@ def _structure_rows(
# m 수량에 곱해 **2.6배** 금액이 섰다(2026-09-08 실증). 어느 성분으로 세는지는
# 매핑이 말한다(`billing_component`) — 코드가 짐작하지 않는다.
billing = _component_billing(structure, entry)
composite = mapping.composite_for(type_id) if code is None else None
composite = mapping.composite_for(type_id, structure)
if composite:
code = None # 묶음이 걸리면 곧장 잇는 코드보다 위(콘크리트 집수정 12-15 조립 · 09-14)
if composite.get("outside_note"):
class_basis = " · ".join(p for p in (class_basis, composite["outside_note"]) if p)
kind = structure_kind(structure) if composite else None
parts: list[dict[str, Any]] | None = None
parts_missing: list[dict[str, Any]] = []
@@ -600,7 +604,7 @@ def _placing_rows(
# 2026-09-08 B09 가 「철근이 겹치나」를 물어 그 김에 드러난 자리다 —
# 철근은 안 겹치고(자재는 재료·묶음 조각은 품, 재료 0원) **타설이 겹쳤다.**
# ⚠ 묶음이 아직 미확보라 지금은 값이 안 걸렸을 뿐, 묶음이 서는 날 두 번이 된다.
if mapping.composite_for(str(structure.get("type_id") or "")) or structure.get(
if mapping.composite_for(str(structure.get("type_id") or ""), structure) or structure.get(
"unconfirmed"
):
continue # 기본값으로 선 구조물도 — 금액에 안 듦
@@ -166,7 +166,7 @@ def rubble_base_rows(
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 "")) or structure.get(
if mapping.composite_for(str(structure.get("type_id") or ""), structure) or structure.get(
"unconfirmed"
):
continue # 묶음 조각이 품음 · 기본값으로 선 구조물은 금액에 안 듦
+4 -1
View File
@@ -87,7 +87,10 @@ class CostInput:
#: 안전관리비 대상액에 들어가는 **도급자설치 관급금액**. None 이면 관급 전액.
owner_supplied_for_safety_krw: Decimal | None = None
#: 그 금액이 부가세 포함인가 — 포함이면 1.1 로 나눈다(규정: 부가세 제외 기준).
#: 그 금액이 부가세 포함인가 — 포함이면 1.1 로 나눈다.
#: ⚠ 근거는 **실무**다 — 고시(산업안전보건관리비 계상 및 사용기준) 제4조① 단서는 「해당
#: 재료비를 **대상액에 포함**」까지만 적고 부가세를 말하지 않는다. ÷1.1 은 부가세 제외
#: 환산이며, 실무 원가계산서 **6건이 모두** 「관급재/1.1」로 적었다(2026-09-14 전수 확인).
owner_supplied_includes_vat: bool = True
#: 규모 구간 판정에 쓸 **추정가격**. 주면 그 값으로 한 번만 판정한다.
#: 없으면 직접공사비를 씨앗으로 **반복 수렴**한다 (`calculate_cost` 참조).
@@ -55,7 +55,18 @@ JUDGED_TABLES: dict[str, dict[str, Any]] = {
"prefix": "합판거푸집",
"why": "원문 L6187 「기준수량(1회사용) · 사용횟수별기준수량에대한 비율(%) 재료별·노무비」",
},
"F0353": {
"code": "FP-12-15",
"shape": "remark_labor",
"prefix": "집수정",
# 구체콘크리트는 바로 아래 다짐기 줄과 한 갈래 — 다짐기가 안 풀리면 갈래를 안 세움(⑴).
"needs_machine": {"구체콘크리트": "다짐:봉상후렉시블(45mm)"},
"why": "원문 L6460 「구체·버림콘크리트 ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
},
}
#: 비고 칸 인력 — 「콘크리트공0.24인/㎥, 보통인부 0.42인/㎥」.
_RE_REMARK_LABOR = re.compile(r"([가-힣]+)\s*(\d+(?:\.\d+)?)\s*인/㎥")
_RE_Q = re.compile(r"Q\s*=\s*(\d+(?:\.\d+)?)")
#: 비율 줄 — 「1회사용시 2회사용시 …」 칸.
_RE_USE_COUNT = re.compile(r"(\d+)회사용시")
#: 값으로 안 읽는 줄 — 사용고재 평가기준(원문이 셈을 안 줌 · 사유는 `KNOWN_GAPS`) · 비고.
@@ -117,6 +128,8 @@ def match_judged_table(
staged = _header_row(code, table, judged, rows, catalog, basis_quantity, unit)
elif judged["shape"] == "use_count":
staged = _use_count(code, table, rows, catalog, basis_quantity, unit)
elif judged["shape"] == "remark_labor":
staged = _remark_labor(code, table, judged, rows, catalog)
else:
staged = _merged_first(code, table, judged, rows, catalog, basis_quantity, unit)
if isinstance(staged, str):
@@ -219,3 +232,39 @@ def _use_count(code, table, rows, catalog, basis, unit) -> list | str:
amount = base * ratio / Decimal(100) / basis
staged.append(_row(code, table, entry, amount, unit, index, f"{count}"))
return staged
def _remark_labor(code, table, judged, rows, catalog) -> list | str:
"""㎥ 줄 비고 칸의 인력(인/㎥)으로 갈래 — 다짐기가 딸린 갈래는 그 기계가 풀려야 세움."""
table_id = str(table.get("pum_table_id", ""))
by_name = {"".join(cells[0].split()): cells for cells in rows if cells}
staged: list = []
for index, cells in enumerate(rows):
labors = _RE_REMARK_LABOR.findall(cells[-1] if cells else "")
if len(cells) < 3 or cells[2] != "" or not labors:
continue
variant = cells[0].split("(")[0].strip()
pieces = []
for name, amount in labors:
entry = _entry(catalog, name, code)
if entry is None:
return f"{variant} 인력 「{name}"
pieces.append((entry, Decimal(amount)))
machine_name = judged.get("needs_machine", {}).get(variant)
if machine_name:
machine_cells = by_name.get("".join(machine_name.split())) or []
found_q = _RE_Q.search(" ".join(machine_cells))
entry = _entry(catalog, machine_name, code) if machine_cells else None
if entry is None or found_q is None:
reason = unmatched_reason(catalog, machine_name)
staged.append(UnmatchedRow(code, table_id, machine_name, reason))
why = (
f"다짐기 「{machine_name}」 가 안 풀려 갈래를 안 세움 — 인력만이면 조립 줄이"
" 조용히 싸짐(2026-09-14 ㉯ ⑴)"
)
staged.append(UnmatchedRow(code, table_id, variant, why))
continue
pieces.append((entry, Decimal(1) / Decimal(found_q.group(1))))
for entry, amount in pieces:
staged.append(_row(code, table, entry, amount, "", index, variant))
return staged
@@ -199,10 +199,22 @@ def safety_management_cost(
variable = dataset.variable("rate_safety_pct")
brackets = variable["brackets"]
# 제3조(적용범위) — 「총공사금액 2천만 원 이상인 공사에 적용」. 하한은 요율 데이터가 든다.
# ⚠ 견주는 값은 **규모 기준액**(설계자가 준 추정가격 · 없으면 수렴한 총원가)이다. 고시의
# 「총공사금액」과 딱 같은 말은 아니나(관급·부가세 자리가 다름) 계산 차례상 안전관리비
# 앞에 설 수 있는 값이 그것뿐이라 같은 축으로 쓴다 — 보건관리자 문턱도 같은 축이다.
if not _threshold_met(
dataset, "rate_safety_pct", "minimum_total_construction_amount_krw", ctx.scale_reference
):
return _ZERO # 대상 아님 — 줄 자체를 만들지 않는다(0 원으로 채우지 않음)
owner_supplied = data.owner_supplied_for_safety_krw
if owner_supplied is None:
owner_supplied = data.owner_supplied_material_krw
if data.owner_supplied_includes_vat:
# 제4조① 단서는 「해당 재료비를 **대상액에 포함**」까지만 적고 부가세를 말하지 않는다.
# ÷1.1 은 **부가세 제외 환산**이며 근거는 실무다 — 실무 원가계산서 **6건이 모두**
# 「(직노+직재+간재+관급재/1.1) × 율」로 적었다(2026-09-14 골든셋 전수 확인).
owner_supplied = owner_supplied / _VAT_DIVISOR
base_with = ctx.material_cost + ctx.direct_labor_cost + owner_supplied
@@ -255,6 +267,9 @@ def safety_management_cost(
# 1.2배의 대상은 1·2호로 **산정이 끝난 금액**이다. 종전엔 1.2 를 곱한 뒤 한 번만 버려
# 영월 2024 B 줄이 20,330,639 로 원본(20,330,638)보다 1원 컸다(골든셋 실증).
# A(배수 1)는 어느 차례로 해도 같은 값이다.
# ⚠ 안 고른 갈래 — 거창 2025 원본은 `버림(밑수 × 율 × 1.2)` 로 1원 위다. 그 서류는
# 시트 이름·줄 차례가 달라 **STmate 출력이 아니며**, 우리 기준은 STmate 재현이라
# 사유로만 남기고 채택하지 않는다(브레인 판정 2026-09-14).
return floor_won(base * percent / _HUNDRED + flat) * multiplier, percent, flat
raw_a, percent_a, flat_a = evaluate(base_with, Decimal(1), "안전관리비 A(관급 포함)")
@@ -67,9 +67,10 @@
},
{
"type_id": "pipe_inlet_basin",
"reuse_count": 6,
"matched_example": "보호공 기초",
"note": "관보호공 집수정 — 원문 6회 줄의 「호안 및 보호공의 기초」에 해당. ⚠ 벽체까지 6회로 볼지는 확인 필요"
"reuse_count": 4,
"matched_example": "거푸집 합판4회",
"note": "그 공종 표가 직접 적은 「거푸집 | 합판4회」 가 정본 — 1-7-1 분류(「호안 및 보호공의 기초」 6회)보다 위(2026-09-14 브레인 ㉯ ②). 앞서 6회로 봤음.",
"basis": "품셈 12-15 집수정 표"
},
{
"type_id": "ford_pavement",
@@ -51,9 +51,10 @@
},
{
"type_id": "pipe_inlet_basin",
"class": "간단",
"matched": "간단한 기초",
"note": "관보호공 집수정 — 원문의 「간단한 기초」에 해당. ⚠ 벽체까지 간단으로 볼지는 확인 필요"
"class": "보통",
"matched": "철근가공조립(보통)",
"basis": "품셈 12-15 집수정 표",
"note": "그 공종 표가 직접 적은 「철근 | 철근가공조립(보통)」 이 정본 — 12-3 [주]① 「간단한 기초」 추정보다 위(2026-09-14 브레인 ㉯ ⑶ · 거푸집 4회와 같은 원칙). 앞서 간단으로 봤음."
}
],
"price_hint_krw_per_ton": {
@@ -327,6 +327,53 @@
"composite": {
"note": "품셈에 **그 이름의 공종이 없어** 여러 공종을 묶어 일위대가로 세우는 자리. 코드 하나로 못 적으므로 묶음을 적어 둔다 — 빈 코드로 두면 「매핑을 못 찾은 줄」과 구별이 안 된다.",
"items": [
{
"type_id": "pipe_inlet_basin",
"when": {
"inlet_basin_material": "콘크리트"
},
"note": "2026-09-14 브레인 ㉯ ⑴~⑸ — 원문 L6460 12-15 집수정 표는 조립형(구체·버림 인력 · 다짐기 · 거푸집 합판4회 · 철근가공조립(보통)). 콘크리트 집수정만 묶음 · 돌집수정은 종전대로 12-15 곧장(⑸).",
"outside_note": "ⓘ 12-15 표의 집수정 뚜껑(스틸그레이팅 개)·설치비(재료비의 5%)는 조각 모양이 달라 묶음에 안 넣음(⑷ · 2026-09-14)",
"parts": [
{
"code": "FP-12-15#구체콘크리트",
"name": "구체콘크리트",
"unit": "㎥",
"from_components": [
"콘크리트"
]
},
{
"code": "FP-12-15#버림콘크리트",
"name": "버림콘크리트",
"unit": "㎥",
"from_components": [
"버림콘크리트"
],
"why": "□형 원단위(콘크리트 2.84㎥ = 벽 + 바닥기초)에 버림 성분이 없음 — 「설계에 없음 = 0」 으로 짓지 않고 막음(⑵)"
},
{
"code": "FP-12-04",
"name": "합판거푸집",
"unit": "㎡",
"from_components": [
"합판거푸집"
],
"kind_suffix": "formwork_reuse"
},
{
"code": "FP-12-03",
"name": "철근 현장가공 및 조립",
"unit": "ton",
"from_components": [
"이형철근 D13",
"이형철근 D16"
],
"unit_from": "kg",
"kind_suffix": "rebar_complexity"
}
]
},
{
"type_id": "retaining_wall",
"parts": [
@@ -346,7 +393,8 @@
"unit": "㎡",
"from_components": [
"합판거푸집"
]
],
"kind_suffix": "formwork_reuse"
},
{
"code": "FP-12-38",
@@ -1,7 +1,7 @@
{
"schema_version": "1.0",
"dataset_id": "data_work_item_master_manifest",
"generated_at": "2026-09-14T18:26:57+09:00",
"generated_at": "2026-09-14T19:15:46+09:00",
"built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py",
"source": {
"dataset_id": "pum_forest",
@@ -12,8 +12,8 @@
"files": [
{
"file": "work_item_master_2026-01-01.json",
"sha256": "fa4d9bc80627d33a46b203f8910f9cc802c1249f2c6c8f09d2e818fce4332287",
"size_bytes": 838421
"sha256": "183a2933a2bf6f06fb99c0b86dc4073ec42c12a95784b3dd279f5eee50ee088f",
"size_bytes": 838487
},
{
"file": "form_undetermined_2026-01-01.json",
@@ -3,7 +3,7 @@
"dataset_id": "work_item_master_forest",
"effective_date": "2026-01-01",
"pum_edition": "2026-01-01",
"generated_at": "2026-09-14T18:26:57+09:00",
"generated_at": "2026-09-14T19:15:46+09:00",
"dataset_version": {
"dataset_id": "pum_forest",
"effective_date": "2026-01-01",
@@ -33148,7 +33148,9 @@
"formula_rows": [],
"special_glyphs": [],
"capacity_formula_here": false,
"variant_key": [],
"variant_key": [
"버림콘크리트"
],
"condition_note": [
"구 분",
"규 격",
@@ -33209,7 +33211,9 @@
]
}
],
"variant_keys": []
"variant_keys": [
"버림콘크리트"
]
},
{
"work_item_code": "FP-12-16",
@@ -0,0 +1,82 @@
"""12-15 집수정 조립 — 2026-09-14 브레인 ㉯ 남은 몫 판정 ⑴~⑸.
원문 L6460 12-15 표는 조립형(구체·버림 인력 비고 · 다짐기 · 거푸집 합판4회 ·
철근가공조립(보통) · 뚜껑 · 설치비 5%). B08 집수정을 12-15 곧장(개소) 이어 단가가 섰음.
다짐기(카탈로그 없음) 빠진 구체 갈래는 세움 인력만이면 조립 줄이 조용히 싸짐
원단위에 버림 성분 없음 성분 미확보 막음(0 지어냄)
철근 갈래는 12-15 보통(B08 추정 간단 걷음 · 4회와 같은 원칙)
뚜껑·설치비는 조각 모양이 달라 조립 · 사유만
돌집수정은 콘크리트 집수정 표가 아니라 종전대로
목적: 금액을 세워도 **조용히 사라지던 것이 사유로 드러남**.
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402
CONCRETE = {"structure_id": "b1", "type_id": "pipe_inlet_basin", "start_m": 50.0, "end_m": 50.0,
"options": {"inlet_basin_form": "□형(기본형)", "inlet_basin_material": "콘크리트",
"pipe_diameter_mm": "800"}} # fmt: skip
STONE = {"structure_id": "b2", "type_id": "pipe_inlet_basin", "start_m": 60.0, "end_m": 60.0,
"options": {"inlet_basin_form": "돌집수정 ㄷ형"}} # fmt: skip
def _basin(structure: dict) -> dict:
unit = build_table([structure], {"pipe_inlet_basin": "집수정"})
handoff = build_handoff(unit_quantity_table=unit)
return next(r for r in handoff["work_items"] if r["origin"] == "structure")
def test_버림_갈래는_서고_구체_갈래는_다짐기_없어_안_섬() -> None:
from B09_Estimation.B09_Estimation_UnitPrice import cached_build, detail_of
build = cached_build()
assert "B-FP-12-15#구체콘크리트" not in build.book.titles
rows = detail_of(build, "B-FP-12-15#버림콘크리트")["rows"]
assert {r["ref_code"]: float(r["quantity"]) for r in rows} == {"1013": 0.15, "1002": 0.27}
left = " ".join(build.unattached.get("FP-12-15", []))
assert "구체콘크리트" in left and "봉상후렉시블" in left, left
def test_콘크리트_집수정은_조립_조각으로_가고_막힌_까닭이_드러남() -> None:
row = _basin(CONCRETE)
assert row["work_item_code"] is None, row
codes = {p["code"]: p for p in row["composite_parts"]}
assert codes["FP-12-15#구체콘크리트"]["quantity"] > 0
assert codes["FP-12-04#4회"]["quantity"] > 0
assert "FP-12-03#보통" in codes, list(codes) # ⑶ 표가 적은 갈래
reasons = " ".join(str(m.get("reason")) for m in row["composite_not_ready"] or [])
assert "FP-12-15#버림콘크리트" in {str(m.get("code")) for m in row["composite_not_ready"]}
assert reasons, row
assert "뚜껑" in row["spec_class_basis"] and "설치비" in row["spec_class_basis"] # ⑷
def test_돌집수정은_종전대로_12_15_곧장() -> None:
row = _basin(STONE)
assert row["work_item_code"] == "FP-12-15" and not row["composite_parts"], row
def test_콘크리트_집수정_콘크리트는_타설_줄이_또_세지_않음() -> None:
unit = build_table([CONCRETE], {"pipe_inlet_basin": "집수정"})
handoff = build_handoff(unit_quantity_table=unit)
placing = [
r for r in handoff["work_items"] if str(r["work_item_code"] or "").startswith("FP-12-01")
]
assert not placing, placing # 구체 조각이 셈 — 두 번 안 셈
def test_내역_조립_줄은_금액_없이_사유() -> None:
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill
unit = build_table([CONCRETE], {"pipe_inlet_basin": "집수정"})
bill = build_bill(build_handoff(unit_quantity_table=unit))
line = next(r for r in bill.rows if r.code is None and "집수정" in r.name)
assert line.amount_krw is None and "묶음 조각" in line.note, line.note
@@ -0,0 +1,45 @@
"""거푸집 사용횟수 → 12-4 갈래 잇기 · 집수정 4회 — 2026-09-14 브레인 ㉯ ①②.
옹벽 조립 조각 FP-12-04 합판거푸집 꼬리가 없어 12-4 사용횟수 갈래(#1~6회)를 못 고름 →
B08 성분에 사용횟수(`reuse_count`, 품셈 1-7-1 옹벽 3) 꼬리로 붙임(값은 ).
집수정은 **12-15 표가 직접 적은 거푸집 합판4회 정본** 1-7-1 분류로 6회는 걷음.
화면(자재 · 인계) 표가 같은 값이어야 ( 벌이 값으로 갈리지 않게).
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_Formwork import FORMWORK_NAMES # noqa: E402
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402
WALL = {"structure_id": "w1", "type_id": "retaining_wall", "start_m": 100.0, "end_m": 110.0,
"options": {"form": "반중력식", "height_m": 2.0, "length_m": 10.0}} # fmt: skip
BASIN = {"structure_id": "b1", "type_id": "pipe_inlet_basin", "start_m": 50.0, "end_m": 50.0,
"options": {"inlet_basin_form": "□형(기본형)", "inlet_basin_material": "콘크리트",
"pipe_diameter_mm": "800"}} # fmt: skip
def test_옹벽_조각_합판거푸집이_사용횟수_갈래로_이어져_단가가_섬() -> None:
from B09_Estimation.B09_Estimation_UnitPrice import cached_build
unit = build_table([WALL], {"retaining_wall": "옹벽"})
row = next(
r for r in build_handoff(unit_quantity_table=unit)["work_items"] if r["composite_parts"]
)
part = next(p for p in row["composite_parts"] if str(p["code"]).startswith("FP-12-04"))
assert part["code"] == "FP-12-04#3회" and not part.get("not_ready"), part
assert "B-FP-12-04#3회" in cached_build().book.titles
def test_집수정_거푸집은_12_15_표의_4회_화면과_인계가_같은_값() -> None:
unit = build_table([BASIN], {"pipe_inlet_basin": "집수정"})
forms = [c for s in unit["structures"] for c in s["components"] if c["name"] in FORMWORK_NAMES]
assert forms and all(c["reuse_count"] == 4 for c in forms), forms
assert all("12-15" in c["reuse_note"] for c in forms), forms
@@ -172,3 +172,35 @@ def test_대상액은_직재_간재_직노_그리고_발주자_제공_재료다(
assert owner.line("safety_management_cost_a").base_amount_krw == base + 30_000_000
# 관급 제외 밑수(B)는 관급이 들어도 안 움직임.
assert owner.line("safety_management_cost_b").base_amount_krw == base
def _scaled(estimated_price: Decimal) -> object:
"""총공사금액(규모 기준액)만 갈아 끼워 돌림 — 대상액은 구간이 안 갈리게 작게 둠."""
return calculate_cost(
CostInput(
direct_material_krw=Decimal(5_000_000),
direct_labor_krw=Decimal(5_000_000),
direct_expense_krw=Decimal(0),
enabled_items=("safety_management_cost",),
estimated_price_krw=estimated_price,
cut_basis="none",
)
)
def test_제3조_총공사금액_2천만원_미만이면_줄이_안_선다() -> None:
"""제3조 — 「총공사금액 2천만 원 이상인 공사에 적용」. 하한은 요율 데이터가 들고 있음.
우리가 견주는 값은 **규모 기준액**(설계자가 추정가격 · 없으면 수렴한 총원가).
고시의 총공사금액 같은 말은 아니나(관급·부가세 자리가 다름) 계산 차례상
안전관리비 앞에 있는 값이 그것뿐이라 같은 축으로 보건관리자 문턱도 같은 .
"""
minimum = json.load(io.open(RATES, encoding="utf-8"))["variables"]["rate_safety_pct"].get(
"minimum_total_construction_amount_krw"
)
assert minimum == 20_000_000, minimum
assert not _scaled(Decimal(minimum) - 1).has("safety_management_cost")
assert _scaled(Decimal(minimum)).has("safety_management_cost")
# 안 서면 A·B 곁줄도 안 선다 — 0 원으로 채우지 않음.
below = _scaled(Decimal(minimum) - 1)
assert not below.has("safety_management_cost_a") and not below.has("safety_management_cost_b")