feat(b09): 301 유로폼 사용수량 12-38-2 — 임대료 또는 손료를 「자재 단가」 탭에서 설계자가 넣은 쪽으로 세움

· 손료 = 자재가 × 표 수량 곧장(패널 0.089 · 내부 패널 0.003매/㎡) + 부자재 24/52/79% · 소모자재 5%(주자재비 = 패널 + 내부 패널) — 봉화 2024 호표 재료비 보통 4,500 · 간단 3,697 과 일치
· 임대료 = 「유로폼 임대료 ㎡당」 한 줄 · 부자재 % 는 원문이 안 적어 안 걺
· 둘 다 · 한쪽만 반쯤 · 아무것도 — 안 세우고 사유(부모 12-38 까지 그대로)
· 12-38-1 잔존율은 안 곱함 — 표 수량이 이미 12회·25% 몫으로 보임(우리 역산 · 차 2.5% 는 추정이라 비고에 밝힘)
· 이중계상 ③ 가드에서 12-38-02 만 뺌(패널이 자재총괄에 줄 없음) · 유로폼이 자재총괄로 가면 빨강 시험
· 합산형 부모 — 갈래 벌이 같으면 여러 단계 갈래끼리 합산(12-38 = 사용수량 + 설치·해체)
· 작업조 표 7 은 밑수 없음 목록에서 뺌(1단위당 = 인원 ÷ 시공량) — 12-38-3 설치·해체가 막혀 있던 자리
· 마스터 재생성 — 12-38 · 12-38-02 갈래 키만 바뀜

기본 조립 제목 금액 변화 0 · 프로젝트 내역 변화 0 · 잴 시험 일곱 빨강→초록 · 끄기 넷 빨강 · 전체 시험 1677 + 318 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
2026-09-14 21:29:04 +09:00
co-authored by Claude Opus 5
parent 1e791b487d
commit efaf2403a0
13 changed files with 357 additions and 62 deletions
@@ -648,8 +648,10 @@ def build() -> dict[str, Any]:
basis_found += 1
if basis_quantity_is_grouped(basis_qty):
basis_grouped += 1
elif form in ("requirement", "productivity"):
elif form in ("requirement", "productivity") and not crew_table(table):
# 참조·계수표는 곱할 값이 아니므로 목록에 넣지 않는다 — 잡음이 되면 안 본다.
# 작업조 표도 뺌 — 밑수가 시공량 열(1단위당 = 인원 ÷ 시공량 · B09 CrewOutput)이라
# 「N㎡당」 문구가 없어도 곱셈이 안 틀림(12-38-3 설치·해체가 여기 걸려 막혀 있었음 · 2026-09-14 301).
basis_missing.append(
{
"pum_table_id": table["table_id"],
@@ -11,7 +11,7 @@
⚠ 못 가른 표는 **빈칸** — 자원을 못 맞춘 표 · 계수 · 참조 · 미판정 표에 갈래를 지어내지 않는다.
공종마다 `variant_keys` — 표들의 갈래 + 원문이 정했으나 자원 줄로 안 서는 갈래
(불도저 운반 공식 갈래 · 합산형 단계 잎의 암질 행 · 9-4-1 [주]① 평균) · 합산형 부모는 단계 갈래.
(불도저 운반 공식 갈래 · 합산형 단계 잎의 암질 행 · 9-4-1 [주]① 평균 · 유로폼 12-38-2 부자재 요율 머리) · 합산형 부모는 단계 갈래.
"""
from __future__ import annotations
@@ -28,6 +28,9 @@ def _add(labels: list[str], seen: set[str], label: str, normalize: Any) -> None:
def attach_variant_keys(nodes: list[dict[str, Any]], edition: str) -> None:
"""표마다 `variant_key`, 공종마다 `variant_keys` 를 채운다(자리에서)."""
from B09_Estimation.B09_Estimation_Euroform import CODE as EUROFORM_CODE
from B09_Estimation.B09_Estimation_Euroform import RATE_TABLE as EUROFORM_RATE_TABLE
from B09_Estimation.B09_Estimation_Euroform import rates as euroform_rates
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
@@ -62,6 +65,9 @@ def attach_variant_keys(nodes: list[dict[str, Any]], edition: str) -> None:
if code in steps:
for rock, _, _ in rock_rows({"tables": [table]}):
_add(labels, table_seen, rock, normalize)
if code == EUROFORM_CODE and table.get("pum_table_id") == EUROFORM_RATE_TABLE:
for head in euroform_rates({"tables": [table]}): # 부자재 요율 머리 갈래
_add(labels, table_seen, head, normalize)
table["variant_key"] = labels
for label in labels:
_add(keys, seen, label, normalize)
+148
View File
@@ -0,0 +1,148 @@
"""B09 원가계산 — **유로폼 사용수량** 12-38-2 (2026-09-14 브레인 301 판정 ①~⑥ · ③′).
정본 = 산림 12-38-2(10㎡당 패널 0.89매 · 내부 패널 0.03매 · 부자재 주자재비 간단 24 · 보통 52 · 복잡 79% ·
소모자재 5%) · 건설 공통 6-3-3 은 참고(고시 총칙 「타 부문과 유사한 공종은 본 품셈 우선」).
원문 「자재비는 거래형태 등을 고려하여 **임대료 또는 손료**로 산정」 — 둘을 나란히 주므로 설계자가 고름(제안값 없음).
고르는 자리 「자재 단가」 탭(화약류와 같은 통로) — 넣은 쪽으로 섬
손료 패널·내부 패널 단가 × 표 수량 곧장(실무 넷 — 봉화 「31,500 × 0.89 / 10」) ·
부자재·소모자재 = (패널 + 내부 패널) × %(봉화 「2,866.5 × 52%」)
임대료 「유로폼 임대료 ㎡당」 한 줄(설계자가 임대기간 반영해 셈) · % 는 원문이 안 적어 안 걺
둘 다 · 반쯤 · 아무것도 — 안 섬 + 사유(부모 12-38 까지 그대로 올라감)
⚠ 12-38-1 잔존율(12회 25%)은 곱하지 않음 — 표 수량이 이미 그 몫으로 보임. 곱하면 두 번 나눔(패널 몫 약 1/16).
⚠ 가드(이중계상 ③)에서 12-38-02 를 뺀 까닭은 `B09_Estimation_Guards.SURCHARGE_INCLUDED_ITEMS` 곁에.
"""
from __future__ import annotations
import re
from decimal import Decimal
from typing import Any
CODE = "FP-12-38-02"
PANEL = "AR-M-10aba455"
INNER = "AR-M-5b89b294"
RENT = "AR-M-d00a6b1a"
QUANTITY_TABLE = "F0393"
RATE_TABLE = "F0394"
CHOICE_MISSING = (
"자재비 — 임대료 또는 손료 설계자 선택(12-38-2 「거래형태 등을 고려하여 임대료 또는 손료로 산정」)"
" · 「자재 단가」 탭에 패널·내부 패널 단가(손료) 또는 유로폼 임대료 ㎡당(임대료) 중 하나를 넣으면 섬"
)
CHOICE_BOTH = "자재비 — 손료(패널 단가)와 임대료가 둘 다 들어옴 · 하나만 넣을 것"
LOSS_HALF = "자재비 손료 — {name} 단가 없음 · 패널·내부 패널 둘 다 넣어야 섬"
REUSE_NOTE = (
"12-38-2 표 수량 곧장(실무 넷 실증) · 12-38-1 잔존율은 안 곱함 — 표 수량이 이미 12회·잔존율 25% 몫으로"
" 보임(우리 역산: 10㎡ ÷ 0.72㎡ = 13.9매 × 0.75 ÷ 12 = 0.87 ≈ 0.89 · 원문이 적지는 않음) · 차 2.5%"
" [주]① 「할증 및 손율 포함」으로 봄(추정) · 「25회 10%」 는 표에 수량이 없어 안 세움"
)
RENT_NOTE = "설계자 임대료(12-38-2 「임대료는 시중 물가지 등을 참고하여 결정」) · 부자재·소모자재 % 는 원문이 임대료에 안 적어 안 걺"
_RE_PERCENT = re.compile(r"(\d+(?:\.\d+)?)\s*%")
def _tight(text: Any) -> str:
return re.sub(r"\s", "", str(text or ""))
def _table(node: dict[str, Any], table_id: str) -> dict[str, Any]:
return next((t for t in node.get("tables") or [] if t.get("pum_table_id") == table_id), {})
def rates(node: dict[str, Any]) -> dict[str, Decimal]:
"""부자재 요율 표(F0394) — 머리 갈래(원문 표기 「간 단」…) → %. 마스터 갈래 키도 이것을 씀."""
table = _table(node, RATE_TABLE)
heads = table.get("condition_note") or []
for row in table.get("raw_row") or []:
found = {str(h): _RE_PERCENT.search(str(c)) for h, c in zip(heads[1:], row[1:])}
return {h: Decimal(m.group(1)) for h, m in found.items() if m}
return {}
def _quantities(node: dict[str, Any]) -> tuple[Decimal | None, Decimal | None, Decimal | None]:
"""(패널 매/㎡, 내부 패널 매/㎡, 소모자재 %) — 표 F0393 10㎡당을 1㎡당으로."""
from B09_Estimation.B09_Estimation_ResourceAxis import parse_amount
table = _table(node, QUANTITY_TABLE)
per = Decimal(str(table.get("basis_quantity") or 0))
panel = inner = consumable = None
for row in table.get("raw_row") or []:
name, value = _tight(row[0]), str(row[-1])
if name == "패널" and parse_amount(value) and per:
panel = parse_amount(value) / per
elif name == "내부패널" and parse_amount(value) and per:
inner = parse_amount(value) / per
elif name.startswith("소모자재") and _RE_PERCENT.search(value):
consumable = Decimal(_RE_PERCENT.search(value).group(1))
return panel, inner, consumable
def attach_euroform(build: Any, nodes_by_code: dict[str, dict[str, Any]]) -> None:
"""「자재 단가」 칸 셋을 세우고, 넣은 쪽으로 12-38-02 를 세움 — 부모 합산(`attach_parent_steps`) 앞."""
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail, PriceKind, PriceTitle
node = nodes_by_code.get(CODE) or {}
for material in (PANEL, INNER, RENT):
uses = build.material_uses.setdefault(material, [])
if CODE not in uses:
uses.append(CODE)
book = build.book
loss = [m for m in (PANEL, INNER) if m in book.titles]
panel, inner, consumable = _quantities(node)
table_rates = rates(node)
reason = ""
if loss and RENT in book.titles:
reason = CHOICE_BOTH
elif RENT in book.titles:
title_code = f"B-{CODE}"
book.add_title(
PriceTitle(
code=title_code,
kind=PriceKind.UNIT_PRICE,
name="유로폼 사용수량",
spec="임대료",
unit="",
)
)
book.add_detail(PriceDetail(title_code, RENT, Decimal(1), note=RENT_NOTE))
elif len(loss) == 2 and panel and inner and consumable is not None and table_rates:
for head, rate in table_rates.items():
variant = _tight(head)
title_code = f"B-{CODE}#{variant}"
book.add_title(
PriceTitle(
code=title_code,
kind=PriceKind.UNIT_PRICE,
name=f"유로폼 사용수량 ({variant})",
spec=variant,
unit="",
)
)
book.add_detail(PriceDetail(title_code, PANEL, panel, note=REUSE_NOTE))
book.add_detail(
PriceDetail(title_code, INNER, inner, note="12-38-2 내부 패널 표 수량 곧장")
)
for label, percent in (
("부자재(웨지핀·플랫타이·강관파이프·훅)", rate),
("소모자재(박리제 등)", consumable),
):
book.add_detail(
PriceDetail(
title_code,
title_code,
Decimal(0),
note=f"{label} — 주자재비(패널 + 내부 패널)의 {percent}% (12-38-2)",
percent_of_material=percent,
)
)
build.variants.setdefault(CODE, []).append(variant)
elif loss:
missing = "내부 패널" if PANEL in loss else "패널"
reason = LOSS_HALF.format(name=missing)
else:
reason = CHOICE_MISSING
if reason:
build.component_gaps[CODE] = reason
build.unattached[CODE] = [reason]
+5 -2
View File
@@ -231,10 +231,13 @@ def check_handoff_boundaries(violations: list[str] | None) -> None:
#: 품셈 [주]가 「재료량에 할증 포함」이라 적은 공종 — 그 재료가 일위대가 재료비로 붙으면
#: **할증 뒤 값**이 들어가 자재총괄에서 한 번 더 붙는다(㉠). 원문 , 코드는 마스터가 붙인 자리.
#: **할증 뒤 값**이 들어가 자재총괄에서 한 번 더 붙는다(㉠). 원문 , 코드는 마스터가 붙인 자리.
#: ⚠ 유로폼 사용수량 12-38-02([주]① 「할증 및 손율이 포함」)는 **뺐음**(2026-09-14 브레인 301 ④) —
#: 이 가드의 전제 「자재총괄에서 할증이 한 번 더」가 안 섬: 패널은 B08 자재총괄에 줄이 없음(유로폼 ㎡ 갈 곳
#: `unit_price`) · 손료 수량이라 할증 전 값이 원문에 없음 · 실무 넷도 일위대가에 곧장 붙임.
#: 유로폼·패널이 자재총괄로 가는 날 되돌릴 것 — `test_b09_euroform` 이 그 자리에서 빨강.
SURCHARGE_INCLUDED_ITEMS: dict[str, str] = {
"FP-12-02": "용적 배합 콘크리트 참고표 「재료량에는 할증률이 포함」(마스터가 12-2 에 붙임)",
"FP-12-38-02": "유로폼 사용수량 [주]① 「재료량에는 재료의 할증 및 손율이 포함」",
"FP-13-11-04": "돌망태 사각형 [주]① 「자재비에는 재료의 할증을 포함」",
"AX-WK-c0842a0d": "모르타르 배합 참고자료 ※ 「위 재료량은 할증이 포함된 것이다」",
}
+3 -1
View File
@@ -186,7 +186,9 @@ def _assemble(build: Any, node: dict[str, Any], names: dict[str, str]) -> None:
return
per_step.append((step, weight, titles))
varying = [item for item in per_step if set(item[2]) - {""}]
if len(varying) > 1:
# 갈래가 같은 이름으로 여러 단계에 걸치면 갈래끼리 합산(유로폼 12-38 = 사용수량 + 설치·해체
# 둘 다 간단·보통·복잡 · 2026-09-14 301). 갈래 벌이 다르면 짝을 못 지어 종전대로 막음.
if len({frozenset(set(ts) - {""}) for _, _, ts in varying}) > 1:
build.component_gaps[parent] = "갈래가 두 단계 이상에 걸쳐 조립하지 않았습니다"
return
units = {build.book.titles[t].unit for _, _, ts in per_step for t in ts.values()} - {""}
@@ -1056,6 +1056,10 @@ def build_unit_prices(
covered += machine_share
if covered < Decimal(100):
build.partial_ratio[work_item_code] = covered
# 유로폼 사용수량(12-38-2) — 임대료 또는 손료 설계자 단가로 섬(2026-09-14 301). 부모 합산 앞.
from B09_Estimation.B09_Estimation_Euroform import attach_euroform
attach_euroform(build, nodes_by_code)
# 단계 합산형 부모(9-4 암절취 = 암파쇄 + 집토) — 잎이 다 선 뒤, 조합 16% 바꿔 달기 전(명세 2장).
from B09_Estimation.B09_Estimation_ParentSteps import attach_parent_steps
@@ -342,6 +342,45 @@
"M00118"
]
}
},
{
"code": "AR-M-10aba455",
"kind": "material",
"name": "유로폼 패널",
"spec": "600 x 1,200mm",
"unit": "매",
"source": {
"pum_edition": "2026-01-01",
"pum_table_ids": [
"F0393"
]
}
},
{
"code": "AR-M-5b89b294",
"kind": "material",
"name": "유로폼 내부 패널",
"spec": "(200+200) x 1,200mm",
"unit": "매",
"source": {
"pum_edition": "2026-01-01",
"pum_table_ids": [
"F0393"
]
}
},
{
"code": "AR-M-d00a6b1a",
"kind": "material",
"name": "유로폼 임대료",
"spec": "㎡당(임대기간 반영 · 설계자 셈)",
"unit": "㎡",
"source": {
"pum_edition": "2026-01-01",
"pum_table_ids": [
"F0393"
]
}
}
]
}
@@ -1,7 +1,7 @@
{
"schema_version": "1.0",
"dataset_id": "data_work_item_master_manifest",
"generated_at": "2026-09-14T19:15:46+09:00",
"generated_at": "2026-09-14T21:01:09+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": "183a2933a2bf6f06fb99c0b86dc4073ec42c12a95784b3dd279f5eee50ee088f",
"size_bytes": 838487
"sha256": "915a1403611a9c713e5c59bff4422a2fe9f53d4168a44a9fada43d14b6c9d9d6",
"size_bytes": 838599
},
{
"file": "form_undetermined_2026-01-01.json",
@@ -22,8 +22,8 @@
},
{
"file": "basis_missing_2026-01-01.json",
"sha256": "f850742268dcfb1bcc878e8b062ef44bc6a11d773074163ebb8ba1dec21a6d0d",
"size_bytes": 16790
"sha256": "d57f26c736829ef40c50dc616d96358db785295d1aa0d5959aa7bc9eaa9be842",
"size_bytes": 15845
}
]
}
@@ -172,12 +172,6 @@
"pum_form": "requirement",
"line": 2689
},
{
"pum_table_id": "F0132",
"section": "5-22-4. 평떼 시비",
"pum_form": "requirement",
"line": 2731
},
{
"pum_table_id": "F0143",
"section": "5-27. 식재면 관리",
@@ -190,12 +184,6 @@
"pum_form": "requirement",
"line": 2919
},
{
"pum_table_id": "F0145",
"section": "5-28-2. 방초매트 및 야자섬유매트 포장",
"pum_form": "requirement",
"line": 2929
},
{
"pum_table_id": "F0153",
"section": "6-1. 비료주기",
@@ -628,24 +616,6 @@
"pum_form": "requirement",
"line": 6217
},
{
"pum_table_id": "F0339",
"section": "12-7-1. 포장절단",
"pum_form": "requirement",
"line": 6254
},
{
"pum_table_id": "F0340",
"section": "12-7-2. 줄눈설치",
"pum_form": "requirement",
"line": 6269
},
{
"pum_table_id": "F0341",
"section": "12-8. 콘크리트 포장 거푸집",
"pum_form": "requirement",
"line": 6280
},
{
"pum_table_id": "F0350",
"section": "12-12. 날개벽",
@@ -676,12 +646,6 @@
"pum_form": "requirement",
"line": 6775
},
{
"pum_table_id": "F0395",
"section": "12-38-3. 설치 및 해체",
"pum_form": "requirement",
"line": 6943
},
{
"pum_table_id": "F0401",
"section": "13-2-4. 야면석 채집(인력)",
@@ -729,12 +693,6 @@
"section": "13-15-2. 목책 설치",
"pum_form": "requirement",
"line": 7745
},
{
"pum_table_id": "F0449",
"section": "13-16-2. 통기성매트",
"pum_form": "requirement",
"line": 7788
}
]
}
@@ -3,7 +3,7 @@
"dataset_id": "work_item_master_forest",
"effective_date": "2026-01-01",
"pum_edition": "2026-01-01",
"generated_at": "2026-09-14T19:15:46+09:00",
"generated_at": "2026-09-14T21:01:09+09:00",
"dataset_version": {
"dataset_id": "pum_forest",
"effective_date": "2026-01-01",
@@ -23,7 +23,7 @@
"tables_orphan": 17,
"form_undetermined": 14,
"basis_found": 204,
"basis_missing": 122,
"basis_missing": 115,
"basis_grouped": 48
},
"orphan_tables": [
@@ -35677,9 +35677,9 @@
],
"steps_basis": "품셈 12-38 유로폼 = 12-38-2 사용수량(자재) + 12-38-3 설치 및 해체(품) — 12-38-1 사용횟수는 금액 단계가 아니라 사용수량의 잔존율 조건",
"variant_keys": [
"복 잡",
"간 단",
"보 통",
"간 단"
"복 잡"
]
},
{
@@ -35803,7 +35803,11 @@
"formula_rows": [],
"special_glyphs": [],
"capacity_formula_here": false,
"variant_key": [],
"variant_key": [
"간 단",
"보 통",
"복 잡"
],
"condition_note": [
"구 분",
"간 단",
@@ -35820,7 +35824,11 @@
]
}
],
"variant_keys": []
"variant_keys": [
"간 단",
"보 통",
"복 잡"
]
},
{
"work_item_code": "FP-12-38-03",
+124
View File
@@ -0,0 +1,124 @@
"""유로폼 사용수량 12-38-2 — 2026-09-14 브레인 301 판정 ①~⑥ · ③′.
① 산림 12-38-2 정본 ② 「임대료 또는 손료」 설계자 선택 · 제안값 없음
③′ 손료 = 자재가 × 표 수량 곧장(실무 넷 — 봉화 「31,500 × 0.89 / 10」) · 12-38-1 잔존율은 안 곱함
④ 이중계상 ③ 가드에서 12-38-02 만 뺌 — 패널이 B08 자재총괄에 줄이 없어서. 되살아날 자리를 시험으로 걺
⑤ 고르는 자리 = 「자재 단가」 탭(넣은 쪽으로 섬 · 둘 다면 안 섬) ⑥ 임대료 갈래엔 % 안 걺
"""
from __future__ import annotations
from decimal import Decimal
from B09_Estimation.B09_Estimation_UnitPrice import cached_build, detail_of
PANEL = "AR-M-10aba455"
INNER = "AR-M-5b89b294"
RENT = "AR-M-d00a6b1a"
LOSS = ((PANEL, "31500", "봉화 2024"), (INNER, "21000", "봉화 2024"))
def _titles(build, code: str) -> list[str]:
return [t for t in build.book.titles if t == f"B-{code}" or t.startswith(f"B-{code}#")]
def test_아무것도_안_넣으면_안_서고_설계자_선택_사유() -> None:
build = cached_build()
assert not _titles(build, "FP-12-38-02")
reason = build.component_gaps.get("FP-12-38-02", "")
assert "임대료 또는 손료" in reason, reason
assert "임대료 또는 손료" in build.component_gaps.get("FP-12-38", "") # 부모까지 까닭이 올라감
for material in (PANEL, INNER, RENT):
assert "FP-12-38-02" in build.material_uses.get(material, []), material
def test_손료는_표_수량_곧장_부자재_소모자재는_주자재비의_퍼센트() -> None:
"""봉화 2024 호표 「유로폼 설치 및 해체」 재료비 보통 4,500 · 간단 3,697 과 같은 값 — 실무 실증."""
build = cached_build(material_prices=LOSS)
assert sorted(_titles(build, "FP-12-38-02")) == [
"B-FP-12-38-02#간단",
"B-FP-12-38-02#보통",
"B-FP-12-38-02#복잡",
]
detail = detail_of(build, "B-FP-12-38-02#보통")["rows"]
rows = {r["ref_code"]: Decimal(r["quantity"]) for r in detail if r.get("ref_code")}
assert rows[PANEL] == Decimal("0.089") and rows[INNER] == Decimal("0.003"), rows
assert build.book.resolve("B-FP-12-38-02#보통").material == Decimal(4500)
assert build.book.resolve("B-FP-12-38-02#간단").material == Decimal(3697) # 봉화 간단 3,697
# 부모 12-38 = 사용수량 + 설치 및 해체 — 두 단계 갈래가 같으면 갈래끼리 합산
parent = {r["ref_code"] for r in detail_of(build, "B-FP-12-38#보통")["rows"]}
assert parent == {"B-FP-12-38-02#보통", "B-FP-12-38-03#보통"}, parent
def test_작업조_표는_밑수가_시공량이라_밑수_없음이_아님() -> None:
"""12-38-3 「(단위: 일 당) 형틀목공 4인 · 시공량 35㎡」 — 1㎡당 = 인원 ÷ 시공량(CrewOutput).
밑수 「N㎡당」 문구가 없다고 `basis_missing` 에 들어 설치·해체가 통째로 막혀 있었음(작업조 표 넷).
"""
build = cached_build()
crew = ("FP-05-28-02", "FP-12-07-02", "FP-12-38-03", "FP-13-16-02")
assert not [code for code in crew if code in build.basis_missing]
rows = {
r["name"]: Decimal(r["quantity"]) for r in detail_of(build, "B-FP-12-38-03#보통")["rows"]
}
assert rows["형틀목공"] == Decimal(4) / Decimal(35), rows
def test_잔존율은_안_곱하고_역산은_추정이라_밝힘() -> None:
build = cached_build(material_prices=LOSS)
note = next(
r["note"]
for r in detail_of(build, "B-FP-12-38-02#보통")["rows"]
if r.get("ref_code") == PANEL
)
assert "우리 역산" in note and "추정" in note and "25회" in note, note
def test_임대료는_한_줄만_퍼센트_안_걺() -> None:
build = cached_build(material_prices=((RENT, "5000", "견적"),))
assert _titles(build, "FP-12-38-02") == ["B-FP-12-38-02"]
rows = detail_of(build, "B-FP-12-38-02")["rows"]
assert [r["ref_code"] for r in rows] == [RENT], rows
assert build.book.resolve("B-FP-12-38-02").material == Decimal(5000)
assert "B-FP-12-38#보통" in build.book.titles
def test_둘_다_넣거나_한쪽만_반쯤_넣으면_안_섬() -> None:
both = cached_build(material_prices=(*LOSS, (RENT, "5000", "견적")))
assert not _titles(both, "FP-12-38-02")
assert "둘 다" in both.component_gaps["FP-12-38-02"]
half = cached_build(material_prices=(LOSS[0],))
assert not _titles(half, "FP-12-38-02")
assert "내부 패널" in half.component_gaps["FP-12-38-02"]
def test_유로폼이_자재총괄에_줄로_서면_가드를_되살릴_것() -> None:
"""④ 12-38-02 를 가드에서 뺀 전제 — 패널·유로폼이 B08 자재총괄(`material`)에 줄이 없음.
누가 유로폼을 자재총괄로 보내는 날 할증이 두 번 붙음 → 여기서 빨강 · 가드에 12-38-02 를 되돌릴 것.
"""
import json
from pathlib import Path
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import DESTINATION
from B09_Estimation.B09_Estimation_Guards import SURCHARGE_INCLUDED_ITEMS
assert "FP-12-38-02" not in SURCHARGE_INCLUDED_ITEMS
words = ("유로폼", "패널")
assert all(DESTINATION[n] != "material" for n in DESTINATION if any(w in n for w in words))
root = Path(__file__).resolve().parents[2] / "resources" / "data_structure_unit"
for path in root.glob("*.json"):
text = path.read_text(encoding="utf-8")
def walk(node):
if isinstance(node, dict):
name = str(node.get("name") or "")
if any(w in name for w in words):
assert node.get("destination") != "material", (path.name, node)
for value in node.values():
walk(value)
elif isinstance(node, list):
for value in node:
walk(value)
walk(json.loads(text))
+1 -1
View File
@@ -57,7 +57,7 @@ def test_단계가_못_서면_부모를_안_세우고_사유를_남긴다():
build = _build()
assert not any(code.startswith("B-FP-09-05#") for code in build.book.titles)
assert "FP-09-05-01" in build.component_gaps["FP-09-05"] # 발파 착암기 층 없음
assert "FP-12-38-02" in build.component_gaps["FP-12-38"] # 사용수량 미구현
assert "FP-12-38-02" in build.component_gaps["FP-12-38"] # 사용수량 — 임대료·손료 단가 안 넣음
# 깎기 표엔 기계 이름이 없음 — [주] 원문의 기종으로 섬(부모에서 물려받지 않음)
assert "[주]" in build.book.details["D-FP-09-05-02#경암"][0].note # Q 줄은 D 에
@@ -112,9 +112,10 @@ def test_자재_합계는_할증_한_번() -> None:
def test_할증_포함_재료량_공종에_자재가_재료비로_붙으면_오류() -> None:
book = PriceBook()
book.add_title(PriceTitle(code="M-시험", kind=PriceKind.MATERIAL, name="패널", unit=""))
book.add_title(PriceTitle(code="B-FP-12-38-02", kind=PriceKind.UNIT_PRICE, name="사용수량"))
book.add_detail(PriceDetail("B-FP-12-38-02", "M-시험", Decimal("0.089")))
# 유로폼 12-38-02 는 가드에서 뺐음(2026-09-14 301 ④ · 자재총괄에 줄 없음) — 돌망태 사각형으로 잼
book.add_title(PriceTitle(code="M-시험", kind=PriceKind.MATERIAL, name="철망태", unit=""))
book.add_title(PriceTitle(code="B-FP-13-11-04", kind=PriceKind.UNIT_PRICE, name="사각형"))
book.add_detail(PriceDetail("B-FP-13-11-04", "M-시험", Decimal("1.0")))
# 금액을 세우지 않고도 걸림 — 줄이 붙는 순간이 어긴 자리
with pytest.raises(DoubleCountError):
check_materials_before_surcharge(book)